summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
Diffstat (limited to 'spec')
-rw-r--r--spec/benchmarks/banzai_benchmark.rb24
-rw-r--r--spec/bin/sidekiq_cluster_spec.rb7
-rw-r--r--spec/channels/awareness_channel_spec.rb81
-rw-r--r--spec/commands/metrics_server/metrics_server_spec.rb2
-rw-r--r--spec/commands/sidekiq_cluster/cli_spec.rb121
-rw-r--r--spec/components/layouts/horizontal_section_component_spec.rb30
-rw-r--r--spec/components/pajamas/alert_component_spec.rb4
-rw-r--r--spec/components/pajamas/avatar_component_spec.rb2
-rw-r--r--spec/components/pajamas/banner_component_spec.rb10
-rw-r--r--spec/components/pajamas/card_component_spec.rb12
-rw-r--r--spec/components/pajamas/checkbox_component_spec.rb8
-rw-r--r--spec/components/pajamas/checkbox_tag_component_spec.rb8
-rw-r--r--spec/components/pajamas/radio_component_spec.rb8
-rw-r--r--spec/config/inject_enterprise_edition_module_spec.rb2
-rw-r--r--spec/config/mail_room_spec.rb21
-rw-r--r--spec/config/object_store_settings_spec.rb18
-rw-r--r--spec/config/settings_spec.rb18
-rw-r--r--spec/config/smime_signature_settings_spec.rb8
-rw-r--r--spec/contracts/provider/helpers/contract_source_helper.rb5
-rw-r--r--spec/contracts/provider_specs/helpers/provider/contract_source_helper_spec.rb8
-rw-r--r--spec/contracts/publish-contracts.sh3
-rw-r--r--spec/controllers/admin/application_settings_controller_spec.rb47
-rw-r--r--spec/controllers/admin/applications_controller_spec.rb100
-rw-r--r--spec/controllers/admin/clusters_controller_spec.rb45
-rw-r--r--spec/controllers/admin/cohorts_controller_spec.rb1
-rw-r--r--spec/controllers/admin/dev_ops_report_controller_spec.rb1
-rw-r--r--spec/controllers/admin/instance_review_controller_spec.rb2
-rw-r--r--spec/controllers/admin/integrations_controller_spec.rb7
-rw-r--r--spec/controllers/admin/runner_projects_controller_spec.rb18
-rw-r--r--spec/controllers/admin/runners_controller_spec.rb47
-rw-r--r--spec/controllers/admin/sessions_controller_spec.rb25
-rw-r--r--spec/controllers/admin/spam_logs_controller_spec.rb10
-rw-r--r--spec/controllers/admin/usage_trends_controller_spec.rb1
-rw-r--r--spec/controllers/admin/users_controller_spec.rb29
-rw-r--r--spec/controllers/application_controller_spec.rb51
-rw-r--r--spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb3
-rw-r--r--spec/controllers/concerns/confirm_email_warning_spec.rb2
-rw-r--r--spec/controllers/concerns/content_security_policy_patch_spec.rb2
-rw-r--r--spec/controllers/concerns/continue_params_spec.rb5
-rw-r--r--spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb11
-rw-r--r--spec/controllers/concerns/kas_cookie_spec.rb122
-rw-r--r--spec/controllers/concerns/metrics_dashboard_spec.rb2
-rw-r--r--spec/controllers/concerns/product_analytics_tracking_spec.rb38
-rw-r--r--spec/controllers/concerns/redis_tracking_spec.rb135
-rw-r--r--spec/controllers/concerns/renders_commits_spec.rb2
-rw-r--r--spec/controllers/concerns/send_file_upload_spec.rb24
-rw-r--r--spec/controllers/concerns/sorting_preference_spec.rb41
-rw-r--r--spec/controllers/confirmations_controller_spec.rb72
-rw-r--r--spec/controllers/dashboard/projects_controller_spec.rb46
-rw-r--r--spec/controllers/every_controller_spec.rb3
-rw-r--r--spec/controllers/explore/groups_controller_spec.rb4
-rw-r--r--spec/controllers/explore/projects_controller_spec.rb20
-rw-r--r--spec/controllers/graphql_controller_spec.rb82
-rw-r--r--spec/controllers/groups/children_controller_spec.rb12
-rw-r--r--spec/controllers/groups/clusters_controller_spec.rb43
-rw-r--r--spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb2
-rw-r--r--spec/controllers/groups/group_members_controller_spec.rb43
-rw-r--r--spec/controllers/groups/milestones_controller_spec.rb62
-rw-r--r--spec/controllers/groups/runners_controller_spec.rb134
-rw-r--r--spec/controllers/groups/settings/applications_controller_spec.rb141
-rw-r--r--spec/controllers/groups/settings/integrations_controller_spec.rb1
-rw-r--r--spec/controllers/groups/variables_controller_spec.rb10
-rw-r--r--spec/controllers/groups_controller_spec.rb23
-rw-r--r--spec/controllers/help_controller_spec.rb27
-rw-r--r--spec/controllers/import/bitbucket_controller_spec.rb33
-rw-r--r--spec/controllers/import/bitbucket_server_controller_spec.rb2
-rw-r--r--spec/controllers/import/bulk_imports_controller_spec.rb65
-rw-r--r--spec/controllers/import/fogbugz_controller_spec.rb7
-rw-r--r--spec/controllers/import/gitea_controller_spec.rb26
-rw-r--r--spec/controllers/import/github_controller_spec.rb114
-rw-r--r--spec/controllers/import/gitlab_controller_spec.rb313
-rw-r--r--spec/controllers/import/manifest_controller_spec.rb6
-rw-r--r--spec/controllers/import/phabricator_controller_spec.rb83
-rw-r--r--spec/controllers/invites_controller_spec.rb20
-rw-r--r--spec/controllers/jira_connect/app_descriptor_controller_spec.rb2
-rw-r--r--spec/controllers/jira_connect/branches_controller_spec.rb4
-rw-r--r--spec/controllers/jira_connect/events_controller_spec.rb2
-rw-r--r--spec/controllers/jira_connect/subscriptions_controller_spec.rb22
-rw-r--r--spec/controllers/oauth/applications_controller_spec.rb60
-rw-r--r--spec/controllers/oauth/authorizations_controller_spec.rb3
-rw-r--r--spec/controllers/omniauth_callbacks_controller_spec.rb75
-rw-r--r--spec/controllers/passwords_controller_spec.rb3
-rw-r--r--spec/controllers/profiles/accounts_controller_spec.rb16
-rw-r--r--spec/controllers/profiles/preferences_controller_spec.rb31
-rw-r--r--spec/controllers/profiles/two_factor_auths_controller_spec.rb2
-rw-r--r--spec/controllers/profiles_controller_spec.rb32
-rw-r--r--spec/controllers/projects/artifacts_controller_spec.rb62
-rw-r--r--spec/controllers/projects/badges_controller_spec.rb12
-rw-r--r--spec/controllers/projects/blame_controller_spec.rb58
-rw-r--r--spec/controllers/projects/blob_controller_spec.rb52
-rw-r--r--spec/controllers/projects/branches_controller_spec.rb294
-rw-r--r--spec/controllers/projects/clusters_controller_spec.rb58
-rw-r--r--spec/controllers/projects/commit_controller_spec.rb255
-rw-r--r--spec/controllers/projects/commits_controller_spec.rb88
-rw-r--r--spec/controllers/projects/compare_controller_spec.rb27
-rw-r--r--spec/controllers/projects/cycle_analytics_controller_spec.rb7
-rw-r--r--spec/controllers/projects/deploy_keys_controller_spec.rb10
-rw-r--r--spec/controllers/projects/deployments_controller_spec.rb4
-rw-r--r--spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb6
-rw-r--r--spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb11
-rw-r--r--spec/controllers/projects/environments/prometheus_api_controller_spec.rb2
-rw-r--r--spec/controllers/projects/environments/sample_metrics_controller_spec.rb2
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb170
-rw-r--r--spec/controllers/projects/feature_flags_controller_spec.rb11
-rw-r--r--spec/controllers/projects/find_file_controller_spec.rb15
-rw-r--r--spec/controllers/projects/forks_controller_spec.rb7
-rw-r--r--spec/controllers/projects/grafana_api_controller_spec.rb29
-rw-r--r--spec/controllers/projects/graphs_controller_spec.rb1
-rw-r--r--spec/controllers/projects/group_links_controller_spec.rb2
-rw-r--r--spec/controllers/projects/hooks_controller_spec.rb12
-rw-r--r--spec/controllers/projects/imports_controller_spec.rb16
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb72
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb69
-rw-r--r--spec/controllers/projects/mattermosts_controller_spec.rb20
-rw-r--r--spec/controllers/projects/merge_requests/conflicts_controller_spec.rb84
-rw-r--r--spec/controllers/projects/merge_requests/creations_controller_spec.rb43
-rw-r--r--spec/controllers/projects/merge_requests/diffs_controller_spec.rb21
-rw-r--r--spec/controllers/projects/merge_requests/drafts_controller_spec.rb16
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb159
-rw-r--r--spec/controllers/projects/milestones_controller_spec.rb21
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb57
-rw-r--r--spec/controllers/projects/pages_controller_spec.rb121
-rw-r--r--spec/controllers/projects/performance_monitoring/dashboards_controller_spec.rb40
-rw-r--r--spec/controllers/projects/pipeline_schedules_controller_spec.rb6
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb242
-rw-r--r--spec/controllers/projects/project_members_controller_spec.rb8
-rw-r--r--spec/controllers/projects/prometheus/alerts_controller_spec.rb5
-rw-r--r--spec/controllers/projects/raw_controller_spec.rb10
-rw-r--r--spec/controllers/projects/refs_controller_spec.rb13
-rw-r--r--spec/controllers/projects/registry/repositories_controller_spec.rb16
-rw-r--r--spec/controllers/projects/registry/tags_controller_spec.rb29
-rw-r--r--spec/controllers/projects/releases_controller_spec.rb6
-rw-r--r--spec/controllers/projects/repositories_controller_spec.rb10
-rw-r--r--spec/controllers/projects/runner_projects_controller_spec.rb62
-rw-r--r--spec/controllers/projects/runners_controller_spec.rb137
-rw-r--r--spec/controllers/projects/service_desk_controller_spec.rb4
-rw-r--r--spec/controllers/projects/settings/ci_cd_controller_spec.rb15
-rw-r--r--spec/controllers/projects/settings/merge_requests_controller_spec.rb11
-rw-r--r--spec/controllers/projects/settings/operations_controller_spec.rb32
-rw-r--r--spec/controllers/projects/snippets/blobs_controller_spec.rb17
-rw-r--r--spec/controllers/projects/snippets_controller_spec.rb11
-rw-r--r--spec/controllers/projects/tree_controller_spec.rb38
-rw-r--r--spec/controllers/projects/wikis_controller_spec.rb2
-rw-r--r--spec/controllers/projects/work_items_controller_spec.rb156
-rw-r--r--spec/controllers/projects_controller_spec.rb146
-rw-r--r--spec/controllers/registrations/welcome_controller_spec.rb38
-rw-r--r--spec/controllers/registrations_controller_spec.rb231
-rw-r--r--spec/controllers/repositories/git_http_controller_spec.rb67
-rw-r--r--spec/controllers/search_controller_spec.rb113
-rw-r--r--spec/controllers/sessions_controller_spec.rb48
-rw-r--r--spec/controllers/snippets/blobs_controller_spec.rb8
-rw-r--r--spec/db/schema_spec.rb115
-rw-r--r--spec/deprecation_warnings.rb21
-rw-r--r--spec/experiments/application_experiment_spec.rb8
-rw-r--r--spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb49
-rw-r--r--spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb9
-rw-r--r--spec/factories/abuse/trust_score.rb10
-rw-r--r--spec/factories/abuse_reports.rb8
-rw-r--r--spec/factories/achievements/user_achievements.rb14
-rw-r--r--spec/factories/airflow/dags.rb8
-rw-r--r--spec/factories/bulk_import/batch_trackers.rb37
-rw-r--r--spec/factories/bulk_import/export_batches.rb24
-rw-r--r--spec/factories/bulk_import/exports.rb4
-rw-r--r--spec/factories/chat_names.rb1
-rw-r--r--spec/factories/ci/builds.rb12
-rw-r--r--spec/factories/ci/catalog/resources.rb7
-rw-r--r--spec/factories/ci/pipelines.rb12
-rw-r--r--spec/factories/ci/processable.rb20
-rw-r--r--spec/factories/ci/reports/security/findings.rb1
-rw-r--r--spec/factories/ci/reports/security/reports.rb13
-rw-r--r--spec/factories/ci/runner_machine_builds.rb8
-rw-r--r--spec/factories/ci/runner_machines.rb13
-rw-r--r--spec/factories/ci/runner_managers.rb13
-rw-r--r--spec/factories/ci/runners.rb6
-rw-r--r--spec/factories/clusters/agents/authorizations/ci_access/group_authorizations.rb18
-rw-r--r--spec/factories/clusters/agents/authorizations/ci_access/project_authorizations.rb18
-rw-r--r--spec/factories/clusters/agents/authorizations/user_access/group_authorizations.rb10
-rw-r--r--spec/factories/clusters/agents/authorizations/user_access/project_authorizations.rb10
-rw-r--r--spec/factories/clusters/agents/group_authorizations.rb18
-rw-r--r--spec/factories/clusters/agents/project_authorizations.rb18
-rw-r--r--spec/factories/clusters/applications/helm.rb124
-rw-r--r--spec/factories/clusters/clusters.rb15
-rw-r--r--spec/factories/container_registry/data_repair_detail.rb20
-rw-r--r--spec/factories/customer_relations/contacts.rb4
-rw-r--r--spec/factories/customer_relations/organizations.rb2
-rw-r--r--spec/factories/design_management/repositories.rb7
-rw-r--r--spec/factories/draft_note.rb4
-rw-r--r--spec/factories/environments.rb27
-rw-r--r--spec/factories/gitlab/database/async_foreign_keys/postgres_async_constraint_validation.rb17
-rw-r--r--spec/factories/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation.rb9
-rw-r--r--spec/factories/gitlab/database/background_migration/schema_inconsistencies.rb11
-rw-r--r--spec/factories/group_members.rb16
-rw-r--r--spec/factories/import_failures.rb4
-rw-r--r--spec/factories/integrations.rb50
-rw-r--r--spec/factories/issues.rb15
-rw-r--r--spec/factories/member_roles.rb11
-rw-r--r--spec/factories/merge_requests_diff_llm_summary.rb10
-rw-r--r--spec/factories/ml/candidates.rb14
-rw-r--r--spec/factories/notes.rb49
-rw-r--r--spec/factories/notes/notes_metadata.rb8
-rw-r--r--spec/factories/organizations.rb16
-rw-r--r--spec/factories/packages/debian/component_file.rb7
-rw-r--r--spec/factories/packages/debian/distribution.rb6
-rw-r--r--spec/factories/packages/debian/file_metadatum.rb80
-rw-r--r--spec/factories/packages/npm/metadata_cache.rb10
-rw-r--r--spec/factories/packages/package_files.rb30
-rw-r--r--spec/factories/packages/packages.rb7
-rw-r--r--spec/factories/project_error_tracking_settings.rb5
-rw-r--r--spec/factories/project_hooks.rb2
-rw-r--r--spec/factories/project_members.rb6
-rw-r--r--spec/factories/projects.rb38
-rw-r--r--spec/factories/projects/data_transfers.rb4
-rw-r--r--spec/factories/resource_events/abuse_report_events.rb9
-rw-r--r--spec/factories/resource_events/issue_assignment_events.rb9
-rw-r--r--spec/factories/resource_events/merge_request_assignment_events.rb9
-rw-r--r--spec/factories/search_index.rb10
-rw-r--r--spec/factories/serverless/domain.rb11
-rw-r--r--spec/factories/serverless/domain_cluster.rb17
-rw-r--r--spec/factories/service_desk/custom_email_credential.rb11
-rw-r--r--spec/factories/service_desk/custom_email_verification.rb11
-rw-r--r--spec/factories/slack_integrations.rb25
-rw-r--r--spec/factories/u2f_registrations.rb12
-rw-r--r--spec/factories/users.rb17
-rw-r--r--spec/factories/users/banned_users.rb7
-rw-r--r--spec/factories/work_items.rb30
-rw-r--r--spec/factories/work_items/resource_link_events.rb10
-rw-r--r--spec/fast_spec_helper.rb26
-rw-r--r--spec/features/abuse_report_spec.rb24
-rw-r--r--spec/features/action_cable_logging_spec.rb2
-rw-r--r--spec/features/admin/admin_abuse_reports_spec.rb260
-rw-r--r--spec/features/admin/admin_appearance_spec.rb268
-rw-r--r--spec/features/admin/admin_browse_spam_logs_spec.rb2
-rw-r--r--spec/features/admin/admin_deploy_keys_spec.rb2
-rw-r--r--spec/features/admin/admin_groups_spec.rb4
-rw-r--r--spec/features/admin/admin_health_check_spec.rb6
-rw-r--r--spec/features/admin/admin_hook_logs_spec.rb2
-rw-r--r--spec/features/admin/admin_hooks_spec.rb4
-rw-r--r--spec/features/admin/admin_labels_spec.rb7
-rw-r--r--spec/features/admin/admin_mode/login_spec.rb344
-rw-r--r--spec/features/admin/admin_mode/logout_spec.rb4
-rw-r--r--spec/features/admin/admin_mode/workers_spec.rb4
-rw-r--r--spec/features/admin/admin_mode_spec.rb10
-rw-r--r--spec/features/admin/admin_projects_spec.rb44
-rw-r--r--spec/features/admin/admin_runners_spec.rb77
-rw-r--r--spec/features/admin/admin_sees_background_migrations_spec.rb2
-rw-r--r--spec/features/admin/admin_settings_spec.rb69
-rw-r--r--spec/features/admin/admin_system_info_spec.rb2
-rw-r--r--spec/features/admin/admin_users_impersonation_tokens_spec.rb4
-rw-r--r--spec/features/admin/admin_uses_repository_checks_spec.rb2
-rw-r--r--spec/features/admin/broadcast_messages_spec.rb73
-rw-r--r--spec/features/admin/integrations/instance_integrations_spec.rb4
-rw-r--r--spec/features/admin/users/user_spec.rb7
-rw-r--r--spec/features/admin/users/users_spec.rb36
-rw-r--r--spec/features/admin_variables_spec.rb16
-rw-r--r--spec/features/alerts_settings/user_views_alerts_settings_spec.rb1
-rw-r--r--spec/features/boards/board_filters_spec.rb2
-rw-r--r--spec/features/boards/boards_spec.rb13
-rw-r--r--spec/features/boards/issue_ordering_spec.rb4
-rw-r--r--spec/features/boards/new_issue_spec.rb50
-rw-r--r--spec/features/boards/sidebar_spec.rb2
-rw-r--r--spec/features/breadcrumbs_schema_markup_spec.rb2
-rw-r--r--spec/features/broadcast_messages_spec.rb32
-rw-r--r--spec/features/calendar_spec.rb381
-rw-r--r--spec/features/callouts/registration_enabled_spec.rb2
-rw-r--r--spec/features/canonical_link_spec.rb2
-rw-r--r--spec/features/clusters/cluster_detail_page_spec.rb2
-rw-r--r--spec/features/clusters/cluster_health_dashboard_spec.rb22
-rw-r--r--spec/features/clusters/create_agent_spec.rb2
-rw-r--r--spec/features/commit_spec.rb42
-rw-r--r--spec/features/commits/user_uses_quick_actions_spec.rb2
-rw-r--r--spec/features/commits_spec.rb28
-rw-r--r--spec/features/contextual_sidebar_spec.rb69
-rw-r--r--spec/features/cycle_analytics_spec.rb9
-rw-r--r--spec/features/dashboard/activity_spec.rb2
-rw-r--r--spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb12
-rw-r--r--spec/features/dashboard/groups_list_spec.rb9
-rw-r--r--spec/features/dashboard/issuables_counter_spec.rb2
-rw-r--r--spec/features/dashboard/issues_filter_spec.rb79
-rw-r--r--spec/features/dashboard/issues_spec.rb45
-rw-r--r--spec/features/dashboard/label_filter_spec.rb34
-rw-r--r--spec/features/dashboard/merge_requests_spec.rb4
-rw-r--r--spec/features/dashboard/milestones_spec.rb2
-rw-r--r--spec/features/dashboard/projects_spec.rb28
-rw-r--r--spec/features/dashboard/root_explore_spec.rb6
-rw-r--r--spec/features/dashboard/shortcuts_spec.rb2
-rw-r--r--spec/features/dashboard/snippets_spec.rb12
-rw-r--r--spec/features/dashboard/todos/todos_spec.rb2
-rw-r--r--spec/features/display_system_header_and_footer_bar_spec.rb2
-rw-r--r--spec/features/emails/issues_spec.rb110
-rw-r--r--spec/features/expand_collapse_diffs_spec.rb14
-rw-r--r--spec/features/explore/groups_spec.rb44
-rw-r--r--spec/features/explore/navbar_spec.rb13
-rw-r--r--spec/features/explore/user_explores_projects_spec.rb12
-rw-r--r--spec/features/file_uploads/project_import_spec.rb4
-rw-r--r--spec/features/frequently_visited_projects_and_groups_spec.rb4
-rw-r--r--spec/features/global_search_spec.rb58
-rw-r--r--spec/features/group_variables_spec.rb15
-rw-r--r--spec/features/groups/board_spec.rb6
-rw-r--r--spec/features/groups/container_registry_spec.rb4
-rw-r--r--spec/features/groups/crm/contacts/create_spec.rb2
-rw-r--r--spec/features/groups/dependency_proxy_spec.rb10
-rw-r--r--spec/features/groups/group_runners_spec.rb38
-rw-r--r--spec/features/groups/group_settings_spec.rb92
-rw-r--r--spec/features/groups/import_export/connect_instance_spec.rb2
-rw-r--r--spec/features/groups/integrations/group_integrations_spec.rb16
-rw-r--r--spec/features/groups/issues_spec.rb22
-rw-r--r--spec/features/groups/labels/index_spec.rb3
-rw-r--r--spec/features/groups/members/filter_members_spec.rb2
-rw-r--r--spec/features/groups/members/leave_group_spec.rb2
-rw-r--r--spec/features/groups/members/list_members_spec.rb2
-rw-r--r--spec/features/groups/members/manage_groups_spec.rb4
-rw-r--r--spec/features/groups/members/manage_members_spec.rb4
-rw-r--r--spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb4
-rw-r--r--spec/features/groups/members/search_members_spec.rb2
-rw-r--r--spec/features/groups/members/sort_members_spec.rb4
-rw-r--r--spec/features/groups/milestone_spec.rb6
-rw-r--r--spec/features/groups/milestones/milestone_editing_spec.rb18
-rw-r--r--spec/features/groups/new_group_page_spec.rb50
-rw-r--r--spec/features/groups/packages_spec.rb8
-rw-r--r--spec/features/groups/settings/access_tokens_spec.rb2
-rw-r--r--spec/features/groups/settings/packages_and_registries_spec.rb9
-rw-r--r--spec/features/groups/show_spec.rb2
-rw-r--r--spec/features/groups_spec.rb41
-rw-r--r--spec/features/help_dropdown_spec.rb2
-rw-r--r--spec/features/help_pages_spec.rb2
-rw-r--r--spec/features/ide/user_opens_merge_request_spec.rb4
-rw-r--r--spec/features/ide_spec.rb13
-rw-r--r--spec/features/import/manifest_import_spec.rb2
-rw-r--r--spec/features/incidents/incident_details_spec.rb2
-rw-r--r--spec/features/incidents/incident_timeline_events_spec.rb7
-rw-r--r--spec/features/incidents/user_uses_quick_actions_spec.rb2
-rw-r--r--spec/features/incidents/user_views_alert_details_spec.rb34
-rw-r--r--spec/features/integrations_settings_spec.rb29
-rw-r--r--spec/features/invites_spec.rb47
-rw-r--r--spec/features/issuables/issuable_list_spec.rb2
-rw-r--r--spec/features/issuables/markdown_references/internal_references_spec.rb2
-rw-r--r--spec/features/issuables/markdown_references/jira_spec.rb2
-rw-r--r--spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb11
-rw-r--r--spec/features/issues/csv_spec.rb1
-rw-r--r--spec/features/issues/discussion_lock_spec.rb3
-rw-r--r--spec/features/issues/filtered_search/dropdown_hint_spec.rb4
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb12
-rw-r--r--spec/features/issues/filtered_search/recent_searches_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/search_bar_spec.rb3
-rw-r--r--spec/features/issues/filtered_search/visual_tokens_spec.rb42
-rw-r--r--spec/features/issues/form_spec.rb510
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb3
-rw-r--r--spec/features/issues/incident_issue_spec.rb4
-rw-r--r--spec/features/issues/issue_detail_spec.rb29
-rw-r--r--spec/features/issues/issue_sidebar_spec.rb14
-rw-r--r--spec/features/issues/move_spec.rb7
-rw-r--r--spec/features/issues/rss_spec.rb12
-rw-r--r--spec/features/issues/service_desk_spec.rb4
-rw-r--r--spec/features/issues/user_bulk_edits_issues_labels_spec.rb4
-rw-r--r--spec/features/issues/user_bulk_edits_issues_spec.rb14
-rw-r--r--spec/features/issues/user_comments_on_issue_spec.rb4
-rw-r--r--spec/features/issues/user_creates_branch_and_merge_request_spec.rb24
-rw-r--r--spec/features/issues/user_creates_issue_spec.rb89
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb43
-rw-r--r--spec/features/issues/user_sorts_issue_comments_spec.rb3
-rw-r--r--spec/features/issues/user_toggles_subscription_spec.rb4
-rw-r--r--spec/features/issues/user_uses_quick_actions_spec.rb2
-rw-r--r--spec/features/jira_connect/branches_spec.rb2
-rw-r--r--spec/features/jira_oauth_provider_authorize_spec.rb35
-rw-r--r--spec/features/labels_hierarchy_spec.rb73
-rw-r--r--spec/features/markdown/markdown_spec.rb4
-rw-r--r--spec/features/markdown/metrics_spec.rb41
-rw-r--r--spec/features/markdown/observability_spec.rb124
-rw-r--r--spec/features/markdown/sandboxed_mermaid_spec.rb24
-rw-r--r--spec/features/merge_request/batch_comments_spec.rb4
-rw-r--r--spec/features/merge_request/maintainer_edits_fork_spec.rb2
-rw-r--r--spec/features/merge_request/user_accepts_merge_request_spec.rb20
-rw-r--r--spec/features/merge_request/user_comments_on_diff_spec.rb4
-rw-r--r--spec/features/merge_request/user_comments_on_merge_request_spec.rb2
-rw-r--r--spec/features/merge_request/user_creates_image_diff_notes_spec.rb2
-rw-r--r--spec/features/merge_request/user_creates_mr_spec.rb151
-rw-r--r--spec/features/merge_request/user_edits_assignees_sidebar_spec.rb4
-rw-r--r--spec/features/merge_request/user_edits_merge_request_spec.rb2
-rw-r--r--spec/features/merge_request/user_edits_mr_spec.rb228
-rw-r--r--spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb2
-rw-r--r--spec/features/merge_request/user_manages_subscription_spec.rb4
-rw-r--r--spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb160
-rw-r--r--spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb110
-rw-r--r--spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb2
-rw-r--r--spec/features/merge_request/user_posts_diff_notes_spec.rb2
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb4
-rw-r--r--spec/features/merge_request/user_resolves_conflicts_spec.rb2
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb2
-rw-r--r--spec/features/merge_request/user_reverts_merge_request_spec.rb7
-rw-r--r--spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb3
-rw-r--r--spec/features/merge_request/user_sees_diff_spec.rb2
-rw-r--r--spec/features/merge_request/user_sees_discussions_navigation_spec.rb8
-rw-r--r--spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb8
-rw-r--r--spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb46
-rw-r--r--spec/features/merge_request/user_sees_merge_widget_spec.rb7
-rw-r--r--spec/features/merge_request/user_sees_pipelines_spec.rb21
-rw-r--r--spec/features/merge_request/user_sees_real_time_reviewers_spec.rb24
-rw-r--r--spec/features/merge_request/user_sees_versions_spec.rb2
-rw-r--r--spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb2
-rw-r--r--spec/features/merge_request/user_uses_quick_actions_spec.rb2
-rw-r--r--spec/features/merge_request/user_views_open_merge_request_spec.rb32
-rw-r--r--spec/features/merge_requests/filters_generic_behavior_spec.rb2
-rw-r--r--spec/features/merge_requests/rss_spec.rb4
-rw-r--r--spec/features/merge_requests/user_exports_as_csv_spec.rb1
-rw-r--r--spec/features/merge_requests/user_lists_merge_requests_spec.rb39
-rw-r--r--spec/features/merge_requests/user_mass_updates_spec.rb8
-rw-r--r--spec/features/merge_requests/user_sorts_merge_requests_spec.rb2
-rw-r--r--spec/features/milestones/user_deletes_milestone_spec.rb2
-rw-r--r--spec/features/monitor_sidebar_link_spec.rb3
-rw-r--r--spec/features/nav/new_nav_invite_members_spec.rb50
-rw-r--r--spec/features/nav/new_nav_toggle_spec.rb2
-rw-r--r--spec/features/nav/pinned_nav_items_spec.rb195
-rw-r--r--spec/features/nav/top_nav_responsive_spec.rb26
-rw-r--r--spec/features/nav/top_nav_spec.rb14
-rw-r--r--spec/features/oauth_login_spec.rb2
-rw-r--r--spec/features/oauth_registration_spec.rb1
-rw-r--r--spec/features/populate_new_pipeline_vars_with_params_spec.rb2
-rw-r--r--spec/features/profiles/chat_names_spec.rb44
-rw-r--r--spec/features/profiles/gpg_keys_spec.rb3
-rw-r--r--spec/features/profiles/list_users_comment_template_spec.rb21
-rw-r--r--spec/features/profiles/list_users_saved_replies_spec.rb21
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb2
-rw-r--r--spec/features/profiles/user_creates_comment_template_spec.rb29
-rw-r--r--spec/features/profiles/user_deletes_comment_template_spec.rb28
-rw-r--r--spec/features/profiles/user_edit_preferences_spec.rb29
-rw-r--r--spec/features/profiles/user_edit_profile_spec.rb30
-rw-r--r--spec/features/profiles/user_updates_comment_template_spec.rb30
-rw-r--r--spec/features/profiles/user_uses_comment_template_spec.rb29
-rw-r--r--spec/features/profiles/user_visits_profile_authentication_log_spec.rb2
-rw-r--r--spec/features/project_group_variables_spec.rb2
-rw-r--r--spec/features/project_variables_spec.rb15
-rw-r--r--spec/features/projects/badges/list_spec.rb39
-rw-r--r--spec/features/projects/blobs/blame_spec.rb43
-rw-r--r--spec/features/projects/blobs/blob_show_spec.rb28
-rw-r--r--spec/features/projects/blobs/edit_spec.rb6
-rw-r--r--spec/features/projects/blobs/user_views_pipeline_editor_button_spec.rb2
-rw-r--r--spec/features/projects/branches/user_creates_branch_spec.rb6
-rw-r--r--spec/features/projects/branches_spec.rb24
-rw-r--r--spec/features/projects/ci/editor_spec.rb8
-rw-r--r--spec/features/projects/ci/lint_spec.rb4
-rw-r--r--spec/features/projects/clusters/gcp_spec.rb2
-rw-r--r--spec/features/projects/clusters/user_spec.rb2
-rw-r--r--spec/features/projects/commit/cherry_pick_spec.rb2
-rw-r--r--spec/features/projects/commit/comments/user_adds_comment_spec.rb6
-rw-r--r--spec/features/projects/commit/comments/user_deletes_comments_spec.rb4
-rw-r--r--spec/features/projects/commit/comments/user_edits_comments_spec.rb2
-rw-r--r--spec/features/projects/commit/diff_notes_spec.rb13
-rw-r--r--spec/features/projects/commit/user_comments_on_commit_spec.rb6
-rw-r--r--spec/features/projects/commit/user_reverts_commit_spec.rb2
-rw-r--r--spec/features/projects/commit/user_sees_pipelines_tab_spec.rb58
-rw-r--r--spec/features/projects/compare_spec.rb19
-rw-r--r--spec/features/projects/container_registry_spec.rb21
-rw-r--r--spec/features/projects/environments/environment_metrics_spec.rb15
-rw-r--r--spec/features/projects/environments/environment_spec.rb30
-rw-r--r--spec/features/projects/environments/environments_spec.rb56
-rw-r--r--spec/features/projects/files/dockerfile_dropdown_spec.rb2
-rw-r--r--spec/features/projects/files/editing_a_file_spec.rb50
-rw-r--r--spec/features/projects/files/gitignore_dropdown_spec.rb2
-rw-r--r--spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb2
-rw-r--r--spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb2
-rw-r--r--spec/features/projects/files/user_browses_files_spec.rb7
-rw-r--r--spec/features/projects/files/user_creates_files_spec.rb4
-rw-r--r--spec/features/projects/files/user_edits_files_spec.rb4
-rw-r--r--spec/features/projects/fork_spec.rb28
-rw-r--r--spec/features/projects/import_export/export_file_spec.rb61
-rw-r--r--spec/features/projects/import_export/import_file_spec.rb1
-rw-r--r--spec/features/projects/import_export/test_project_export.tar.gzbin3176 -> 4799 bytes
-rw-r--r--spec/features/projects/integrations/apple_app_store_spec.rb24
-rw-r--r--spec/features/projects/integrations/google_play_spec.rb24
-rw-r--r--spec/features/projects/integrations/project_integrations_spec.rb12
-rw-r--r--spec/features/projects/integrations/user_activates_mattermost_slash_command_spec.rb8
-rw-r--r--spec/features/projects/integrations/user_activates_prometheus_spec.rb1
-rw-r--r--spec/features/projects/integrations/user_activates_slack_notifications_spec.rb1
-rw-r--r--spec/features/projects/integrations/user_activates_slack_slash_command_spec.rb2
-rw-r--r--spec/features/projects/integrations/user_uses_inherited_settings_spec.rb10
-rw-r--r--spec/features/projects/issuable_templates_spec.rb2
-rw-r--r--spec/features/projects/issues/viewing_relocated_issues_spec.rb2
-rw-r--r--spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb2
-rw-r--r--spec/features/projects/jobs_spec.rb19
-rw-r--r--spec/features/projects/labels/update_prioritization_spec.rb2
-rw-r--r--spec/features/projects/members/anonymous_user_sees_members_spec.rb2
-rw-r--r--spec/features/projects/members/group_members_spec.rb2
-rw-r--r--spec/features/projects/members/groups_with_access_list_spec.rb4
-rw-r--r--spec/features/projects/members/manage_groups_spec.rb4
-rw-r--r--spec/features/projects/members/manage_members_spec.rb4
-rw-r--r--spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb4
-rw-r--r--spec/features/projects/members/member_leaves_project_spec.rb2
-rw-r--r--spec/features/projects/members/sorting_spec.rb4
-rw-r--r--spec/features/projects/members/tabs_spec.rb2
-rw-r--r--spec/features/projects/milestones/milestone_editing_spec.rb18
-rw-r--r--spec/features/projects/navbar_spec.rb17
-rw-r--r--spec/features/projects/network_graph_spec.rb9
-rw-r--r--spec/features/projects/new_project_spec.rb106
-rw-r--r--spec/features/projects/packages_spec.rb4
-rw-r--r--spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb12
-rw-r--r--spec/features/projects/pipeline_schedules_spec.rb474
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb55
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb39
-rw-r--r--spec/features/projects/releases/user_creates_release_spec.rb17
-rw-r--r--spec/features/projects/settings/access_tokens_spec.rb2
-rw-r--r--spec/features/projects/settings/branch_rules_settings_spec.rb11
-rw-r--r--spec/features/projects/settings/forked_project_settings_spec.rb2
-rw-r--r--spec/features/projects/settings/monitor_settings_spec.rb5
-rw-r--r--spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb13
-rw-r--r--spec/features/projects/settings/registry_settings_spec.rb13
-rw-r--r--spec/features/projects/settings/repository_settings_spec.rb39
-rw-r--r--spec/features/projects/settings/service_desk_setting_spec.rb10
-rw-r--r--spec/features/projects/settings/user_manages_project_members_spec.rb2
-rw-r--r--spec/features/projects/settings/user_renames_a_project_spec.rb4
-rw-r--r--spec/features/projects/settings/webhooks_settings_spec.rb2
-rw-r--r--spec/features/projects/snippets/create_snippet_spec.rb2
-rw-r--r--spec/features/projects/snippets/user_updates_snippet_spec.rb2
-rw-r--r--spec/features/projects/terraform_spec.rb2
-rw-r--r--spec/features/projects/tree/create_directory_spec.rb2
-rw-r--r--spec/features/projects/tree/create_file_spec.rb2
-rw-r--r--spec/features/projects/tree/tree_show_spec.rb2
-rw-r--r--spec/features/projects/tree/upload_file_spec.rb2
-rw-r--r--spec/features/projects/user_changes_project_visibility_spec.rb19
-rw-r--r--spec/features/projects/user_sees_user_popover_spec.rb2
-rw-r--r--spec/features/projects/user_uses_shortcuts_spec.rb13
-rw-r--r--spec/features/projects/user_views_empty_project_spec.rb2
-rw-r--r--spec/features/projects/work_items/work_item_children_spec.rb179
-rw-r--r--spec/features/projects/work_items/work_item_spec.rb92
-rw-r--r--spec/features/protected_branches_spec.rb9
-rw-r--r--spec/features/protected_tags_spec.rb34
-rw-r--r--spec/features/reportable_note/issue_spec.rb4
-rw-r--r--spec/features/runners_spec.rb51
-rw-r--r--spec/features/search/user_searches_for_code_spec.rb55
-rw-r--r--spec/features/search/user_uses_header_search_field_spec.rb18
-rw-r--r--spec/features/security/admin_access_spec.rb2
-rw-r--r--spec/features/security/dashboard_access_spec.rb2
-rw-r--r--spec/features/security/group/internal_access_spec.rb2
-rw-r--r--spec/features/security/group/private_access_spec.rb2
-rw-r--r--spec/features/security/group/public_access_spec.rb2
-rw-r--r--spec/features/security/project/internal_access_spec.rb2
-rw-r--r--spec/features/security/project/private_access_spec.rb2
-rw-r--r--spec/features/security/project/public_access_spec.rb2
-rw-r--r--spec/features/security/project/snippet/internal_access_spec.rb2
-rw-r--r--spec/features/security/project/snippet/private_access_spec.rb2
-rw-r--r--spec/features/security/project/snippet/public_access_spec.rb2
-rw-r--r--spec/features/signed_commits_spec.rb9
-rw-r--r--spec/features/snippets/notes_on_personal_snippets_spec.rb37
-rw-r--r--spec/features/snippets/show_spec.rb9
-rw-r--r--spec/features/snippets/user_creates_snippet_spec.rb6
-rw-r--r--spec/features/snippets/user_edits_snippet_spec.rb2
-rw-r--r--spec/features/tags/developer_creates_tag_spec.rb2
-rw-r--r--spec/features/tags/developer_deletes_tag_spec.rb22
-rw-r--r--spec/features/tags/developer_views_tags_spec.rb1
-rw-r--r--spec/features/tags/maintainer_deletes_protected_tag_spec.rb7
-rw-r--r--spec/features/topic_show_spec.rb2
-rw-r--r--spec/features/u2f_spec.rb216
-rw-r--r--spec/features/unsubscribe_links_spec.rb2
-rw-r--r--spec/features/user_can_display_performance_bar_spec.rb2
-rw-r--r--spec/features/user_sees_revert_modal_spec.rb5
-rw-r--r--spec/features/user_sorts_things_spec.rb2
-rw-r--r--spec/features/users/login_spec.rb358
-rw-r--r--spec/features/users/show_spec.rb74
-rw-r--r--spec/features/users/signup_spec.rb18
-rw-r--r--spec/features/webauthn_spec.rb249
-rw-r--r--spec/features/whats_new_spec.rb6
-rw-r--r--spec/features/work_items/work_item_children_spec.rb136
-rw-r--r--spec/features/work_items/work_item_spec.rb37
-rw-r--r--spec/finders/abuse_reports_finder_spec.rb129
-rw-r--r--spec/finders/access_requests_finder_spec.rb9
-rw-r--r--spec/finders/achievements/achievements_finder_spec.rb26
-rw-r--r--spec/finders/alert_management/alerts_finder_spec.rb17
-rw-r--r--spec/finders/ci/pipelines_for_merge_request_finder_spec.rb68
-rw-r--r--spec/finders/clusters/agent_authorizations_finder_spec.rb140
-rw-r--r--spec/finders/clusters/agent_tokens_finder_spec.rb9
-rw-r--r--spec/finders/clusters/agents/authorizations/ci_access/finder_spec.rb140
-rw-r--r--spec/finders/clusters/agents/authorizations/user_access/finder_spec.rb198
-rw-r--r--spec/finders/concerns/finder_with_group_hierarchy_spec.rb76
-rw-r--r--spec/finders/context_commits_finder_spec.rb21
-rw-r--r--spec/finders/crm/contacts_finder_spec.rb4
-rw-r--r--spec/finders/crm/organizations_finder_spec.rb22
-rw-r--r--spec/finders/data_transfer/group_data_transfer_finder_spec.rb84
-rw-r--r--spec/finders/data_transfer/mocked_transfer_finder_spec.rb22
-rw-r--r--spec/finders/data_transfer/project_data_transfer_finder_spec.rb80
-rw-r--r--spec/finders/deployments_finder_spec.rb71
-rw-r--r--spec/finders/fork_targets_finder_spec.rb25
-rw-r--r--spec/finders/group_descendants_finder_spec.rb22
-rw-r--r--spec/finders/group_members_finder_spec.rb149
-rw-r--r--spec/finders/groups/accepting_group_transfers_finder_spec.rb14
-rw-r--r--spec/finders/groups/accepting_project_creations_finder_spec.rb104
-rw-r--r--spec/finders/groups/accepting_project_imports_finder_spec.rb105
-rw-r--r--spec/finders/groups/accepting_project_shares_finder_spec.rb122
-rw-r--r--spec/finders/groups/accepting_project_transfers_finder_spec.rb42
-rw-r--r--spec/finders/groups/user_groups_finder_spec.rb20
-rw-r--r--spec/finders/issuables/crm_organization_filter_spec.rb22
-rw-r--r--spec/finders/members_finder_spec.rb39
-rw-r--r--spec/finders/merge_requests_finder_spec.rb76
-rw-r--r--spec/finders/milestones_finder_spec.rb92
-rw-r--r--spec/finders/notes_finder_spec.rb57
-rw-r--r--spec/finders/packages/conan/package_finder_spec.rb25
-rw-r--r--spec/finders/packages/group_packages_finder_spec.rb4
-rw-r--r--spec/finders/packages/npm/package_finder_spec.rb8
-rw-r--r--spec/finders/pending_todos_finder_spec.rb57
-rw-r--r--spec/finders/personal_access_tokens_finder_spec.rb2
-rw-r--r--spec/finders/projects_finder_spec.rb48
-rw-r--r--spec/finders/serverless_domain_finder_spec.rb103
-rw-r--r--spec/finders/snippets_finder_spec.rb21
-rw-r--r--spec/finders/template_finder_spec.rb14
-rw-r--r--spec/finders/users_finder_spec.rb8
-rw-r--r--spec/fixtures/api/schemas/cluster_status.json11
-rw-r--r--spec/fixtures/api/schemas/entities/diff_viewer.json6
-rw-r--r--spec/fixtures/api/schemas/entities/discussion.json11
-rw-r--r--spec/fixtures/api/schemas/internal/pages/lookup_path.json66
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/notes.json5
-rw-r--r--spec/fixtures/auth_key.p816
-rw-r--r--spec/fixtures/diagram.drawio.svg38
-rw-r--r--spec/fixtures/emails/valid_reply_with_references_in_comma.eml42
-rw-r--r--spec/fixtures/gitlab/import_export/corrupted_project_export.tar.gzbin4603 -> 5288 bytes
-rw-r--r--spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gzbin3758 -> 4950 bytes
-rw-r--r--spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event.yml1
-rw-r--r--spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event_ee.yml1
-rw-r--r--spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml1
-rw-r--r--spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml1
-rw-r--r--spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml1
-rw-r--r--spec/fixtures/lib/gitlab/email/basic.html6
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/project.json174
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_pipelines.ndjson8
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/commit_notes.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/design_management_repository.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_environments.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/designs/tree/project.json15
-rw-r--r--spec/fixtures/lib/gitlab/import_export/designs/tree/project/issues.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/designs/tree/project/project_members.ndjson2
-rw-r--r--spec/fixtures/markdown.md.erb39
-rw-r--r--spec/fixtures/packages/debian/README.md2
-rw-r--r--spec/fixtures/packages/debian/sample-ddeb_1.2.3~alpha2_amd64.ddebbin0 -> 1068 bytes
-rw-r--r--spec/fixtures/packages/debian/sample/debian/.gitignore2
-rw-r--r--spec/fixtures/packages/debian/sample/debian/control4
-rwxr-xr-xspec/fixtures/packages/debian/sample/debian/rules7
-rw-r--r--spec/fixtures/packages/debian/sample_1.2.3~alpha2.dsc9
-rw-r--r--spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xzbin864 -> 964 bytes
-rw-r--r--spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.buildinfo306
-rw-r--r--spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.changes21
-rw-r--r--spec/fixtures/packages/npm/metadata.json20
-rw-r--r--spec/fixtures/packages/npm/payload_with_empty_attachment.json29
-rw-r--r--spec/fixtures/pages_with_custom_root.zipbin0 -> 631 bytes
-rw-r--r--spec/fixtures/pages_with_custom_root.zip.metabin0 -> 175 bytes
-rw-r--r--spec/fixtures/pages_with_custom_root.zip.meta0bin0 -> 197 bytes
-rw-r--r--spec/fixtures/scripts/test_report.json2
-rw-r--r--spec/fixtures/security_reports/feature-branch/gl-sast-report.json22
-rw-r--r--spec/fixtures/security_reports/feature-branch/gl-secret-detection-report.json36
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-missing-scanner.json52
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-bandit.json13
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-gosec.json13
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-minimal.json18
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json13
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json13
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-multiple-findings.json13
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report.json22
-rw-r--r--spec/fixtures/security_reports/master/gl-secret-detection-report.json35
-rw-r--r--spec/fixtures/service_account.json12
-rw-r--r--spec/fixtures/structure.sql79
-rw-r--r--spec/fixtures/work_items_invalid_types.csv4
-rw-r--r--spec/fixtures/work_items_missing_header.csv3
-rw-r--r--spec/fixtures/work_items_valid.csv3
-rw-r--r--spec/fixtures/work_items_valid_types.csv3
-rw-r--r--spec/frontend/.eslintrc.yml1
-rw-r--r--spec/frontend/__helpers__/assert_props.js41
-rw-r--r--spec/frontend/__helpers__/create_mock_source_editor_extension.js12
-rw-r--r--spec/frontend/__helpers__/experimentation_helper.js7
-rw-r--r--spec/frontend/__helpers__/fixtures.js5
-rw-r--r--spec/frontend/__helpers__/gon_helper.js5
-rw-r--r--spec/frontend/__helpers__/init_vue_mr_page_helper.js10
-rw-r--r--spec/frontend/__helpers__/keep_alive_component_helper_spec.js4
-rw-r--r--spec/frontend/__helpers__/shared_test_setup.js16
-rw-r--r--spec/frontend/__helpers__/vue_mock_directive.js32
-rw-r--r--spec/frontend/__helpers__/vue_test_utils_helper.js18
-rw-r--r--spec/frontend/__helpers__/vue_test_utils_helper_spec.js49
-rw-r--r--spec/frontend/__helpers__/vuex_action_helper.js2
-rw-r--r--spec/frontend/__helpers__/vuex_action_helper_spec.js14
-rw-r--r--spec/frontend/__helpers__/wait_for_text.js2
-rw-r--r--spec/frontend/__mocks__/@gitlab/ui.js18
-rw-r--r--spec/frontend/__mocks__/file_mock.js2
-rw-r--r--spec/frontend/__mocks__/lodash/debounce.js19
-rw-r--r--spec/frontend/__mocks__/lodash/throttle.js2
-rw-r--r--spec/frontend/__mocks__/mousetrap/index.js6
-rw-r--r--spec/frontend/abuse_reports/components/abuse_category_selector_spec.js4
-rw-r--r--spec/frontend/access_tokens/components/expires_at_field_spec.js4
-rw-r--r--spec/frontend/access_tokens/components/new_access_token_app_spec.js5
-rw-r--r--spec/frontend/access_tokens/components/token_spec.js4
-rw-r--r--spec/frontend/access_tokens/components/tokens_app_spec.js4
-rw-r--r--spec/frontend/access_tokens/index_spec.js2
-rw-r--r--spec/frontend/activities_spec.js6
-rw-r--r--spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap12
-rw-r--r--spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js37
-rw-r--r--spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js4
-rw-r--r--spec/frontend/add_context_commits_modal/store/actions_spec.js2
-rw-r--r--spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js76
-rw-r--r--spec/frontend/admin/abuse_report/components/history_items_spec.js66
-rw-r--r--spec/frontend/admin/abuse_report/components/report_header_spec.js59
-rw-r--r--spec/frontend/admin/abuse_report/components/reported_content_spec.js193
-rw-r--r--spec/frontend/admin/abuse_report/components/user_detail_spec.js66
-rw-r--r--spec/frontend/admin/abuse_report/components/user_details_spec.js210
-rw-r--r--spec/frontend/admin/abuse_report/mock_data.js61
-rw-r--r--spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js202
-rw-r--r--spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js91
-rw-r--r--spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js225
-rw-r--r--spec/frontend/admin/abuse_reports/components/app_spec.js104
-rw-r--r--spec/frontend/admin/abuse_reports/mock_data.js18
-rw-r--r--spec/frontend/admin/abuse_reports/utils_spec.js31
-rw-r--r--spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js4
-rw-r--r--spec/frontend/admin/application_settings/inactive_project_deletion/components/form_spec.js4
-rw-r--r--spec/frontend/admin/application_settings/network_outbound_spec.js70
-rw-r--r--spec/frontend/admin/applications/components/delete_application_spec.js1
-rw-r--r--spec/frontend/admin/background_migrations/components/database_listbox_spec.js4
-rw-r--r--spec/frontend/admin/broadcast_messages/components/base_spec.js9
-rw-r--r--spec/frontend/admin/broadcast_messages/components/message_form_spec.js39
-rw-r--r--spec/frontend/admin/broadcast_messages/components/messages_table_spec.js19
-rw-r--r--spec/frontend/admin/broadcast_messages/mock_data.js5
-rw-r--r--spec/frontend/admin/deploy_keys/components/table_spec.js10
-rw-r--r--spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js4
-rw-r--r--spec/frontend/admin/signup_restrictions/components/signup_form_spec.js20
-rw-r--r--spec/frontend/admin/signup_restrictions/mock_data.js4
-rw-r--r--spec/frontend/admin/statistics_panel/components/app_spec.js4
-rw-r--r--spec/frontend/admin/topics/components/remove_avatar_spec.js6
-rw-r--r--spec/frontend/admin/topics/components/topic_select_spec.js1
-rw-r--r--spec/frontend/admin/users/components/actions/actions_spec.js13
-rw-r--r--spec/frontend/admin/users/components/actions/delete_with_contributions_spec.js12
-rw-r--r--spec/frontend/admin/users/components/app_spec.js5
-rw-r--r--spec/frontend/admin/users/components/associations/__snapshots__/associations_list_spec.js.snap34
-rw-r--r--spec/frontend/admin/users/components/associations/associations_list_spec.js52
-rw-r--r--spec/frontend/admin/users/components/modals/delete_user_modal_spec.js13
-rw-r--r--spec/frontend/admin/users/components/user_actions_spec.js19
-rw-r--r--spec/frontend/admin/users/components/user_avatar_spec.js7
-rw-r--r--spec/frontend/admin/users/components/user_date_spec.js5
-rw-r--r--spec/frontend/admin/users/components/users_table_spec.js11
-rw-r--r--spec/frontend/admin/users/index_spec.js4
-rw-r--r--spec/frontend/admin/users/new_spec.js7
-rw-r--r--spec/frontend/airflow/dags/components/dags_spec.js115
-rw-r--r--spec/frontend/airflow/dags/components/mock_data.js67
-rw-r--r--spec/frontend/alert_management/components/alert_management_empty_state_spec.js6
-rw-r--r--spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js6
-rw-r--r--spec/frontend/alert_management/components/alert_management_table_spec.js5
-rw-r--r--spec/frontend/alert_spec.js276
-rw-r--r--spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap34
-rw-r--r--spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js9
-rw-r--r--spec/frontend/alerts_settings/components/alerts_form_spec.js6
-rw-r--r--spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js7
-rw-r--r--spec/frontend/alerts_settings/components/alerts_settings_form_spec.js20
-rw-r--r--spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js14
-rw-r--r--spec/frontend/analytics/components/activity_chart_spec.js39
-rw-r--r--spec/frontend/analytics/cycle_analytics/base_spec.js265
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/__snapshots__/total_time_spec.js.snap (renamed from spec/frontend/analytics/cycle_analytics/__snapshots__/total_time_spec.js.snap)0
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/base_spec.js283
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js228
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/formatted_stage_count_spec.js30
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/path_navigation_spec.js148
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/stage_table_spec.js366
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/total_time_spec.js41
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/value_stream_filters_spec.js89
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/value_stream_metrics_spec.js205
-rw-r--r--spec/frontend/analytics/cycle_analytics/filter_bar_spec.js229
-rw-r--r--spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js34
-rw-r--r--spec/frontend/analytics/cycle_analytics/mock_data.js4
-rw-r--r--spec/frontend/analytics/cycle_analytics/path_navigation_spec.js150
-rw-r--r--spec/frontend/analytics/cycle_analytics/stage_table_spec.js371
-rw-r--r--spec/frontend/analytics/cycle_analytics/store/actions_spec.js31
-rw-r--r--spec/frontend/analytics/cycle_analytics/store/mutations_spec.js13
-rw-r--r--spec/frontend/analytics/cycle_analytics/total_time_spec.js45
-rw-r--r--spec/frontend/analytics/cycle_analytics/utils_spec.js30
-rw-r--r--spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js91
-rw-r--r--spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js185
-rw-r--r--spec/frontend/analytics/devops_reports/components/service_ping_disabled_spec.js4
-rw-r--r--spec/frontend/analytics/product_analytics/components/activity_chart_spec.js34
-rw-r--r--spec/frontend/analytics/shared/components/daterange_spec.js15
-rw-r--r--spec/frontend/analytics/shared/components/metric_popover_spec.js4
-rw-r--r--spec/frontend/analytics/shared/components/metric_tile_spec.js10
-rw-r--r--spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js30
-rw-r--r--spec/frontend/analytics/shared/utils_spec.js28
-rw-r--r--spec/frontend/analytics/usage_trends/components/app_spec.js5
-rw-r--r--spec/frontend/analytics/usage_trends/components/usage_counts_spec.js4
-rw-r--r--spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js5
-rw-r--r--spec/frontend/analytics/usage_trends/components/users_chart_spec.js5
-rw-r--r--spec/frontend/api/alert_management_alerts_api_spec.js3
-rw-r--r--spec/frontend/api/groups_api_spec.js13
-rw-r--r--spec/frontend/api/packages_api_spec.js12
-rw-r--r--spec/frontend/api/projects_api_spec.js25
-rw-r--r--spec/frontend/api/tags_api_spec.js3
-rw-r--r--spec/frontend/api/user_api_spec.js26
-rw-r--r--spec/frontend/api_spec.js25
-rw-r--r--spec/frontend/approvals/mock_data.js10
-rw-r--r--spec/frontend/artifacts/components/app_spec.js109
-rw-r--r--spec/frontend/artifacts/components/artifact_row_spec.js80
-rw-r--r--spec/frontend/artifacts/components/artifacts_table_row_details_spec.js123
-rw-r--r--spec/frontend/artifacts/components/feedback_banner_spec.js63
-rw-r--r--spec/frontend/artifacts/components/job_artifacts_table_spec.js363
-rw-r--r--spec/frontend/artifacts/graphql/cache_update_spec.js67
-rw-r--r--spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js20
-rw-r--r--spec/frontend/authentication/password/components/password_input_spec.js64
-rw-r--r--spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js25
-rw-r--r--spec/frontend/authentication/u2f/authenticate_spec.js104
-rw-r--r--spec/frontend/authentication/u2f/mock_u2f_device.js23
-rw-r--r--spec/frontend/authentication/u2f/register_spec.js84
-rw-r--r--spec/frontend/authentication/u2f/util_spec.js61
-rw-r--r--spec/frontend/authentication/webauthn/authenticate_spec.js5
-rw-r--r--spec/frontend/authentication/webauthn/components/registration_spec.js255
-rw-r--r--spec/frontend/authentication/webauthn/error_spec.js13
-rw-r--r--spec/frontend/authentication/webauthn/register_spec.js5
-rw-r--r--spec/frontend/authentication/webauthn/util_spec.js31
-rw-r--r--spec/frontend/awards_handler_spec.js10
-rw-r--r--spec/frontend/badges/components/badge_form_spec.js1
-rw-r--r--spec/frontend/badges/components/badge_list_row_spec.js1
-rw-r--r--spec/frontend/badges/components/badge_list_spec.js4
-rw-r--r--spec/frontend/badges/components/badge_settings_spec.js4
-rw-r--r--spec/frontend/badges/components/badge_spec.js4
-rw-r--r--spec/frontend/batch_comments/components/diff_file_drafts_spec.js4
-rw-r--r--spec/frontend/batch_comments/components/draft_note_spec.js4
-rw-r--r--spec/frontend/batch_comments/components/drafts_count_spec.js4
-rw-r--r--spec/frontend/batch_comments/components/preview_dropdown_spec.js19
-rw-r--r--spec/frontend/batch_comments/components/preview_item_spec.js4
-rw-r--r--spec/frontend/batch_comments/components/review_bar_spec.js8
-rw-r--r--spec/frontend/batch_comments/components/submit_dropdown_spec.js21
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js6
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js26
-rw-r--r--spec/frontend/behaviors/components/diagram_performance_warning_spec.js4
-rw-r--r--spec/frontend/behaviors/components/json_table_spec.js7
-rw-r--r--spec/frontend/behaviors/copy_to_clipboard_spec.js2
-rw-r--r--spec/frontend/behaviors/gl_emoji_spec.js14
-rw-r--r--spec/frontend/behaviors/markdown/highlight_current_user_spec.js10
-rw-r--r--spec/frontend/behaviors/markdown/render_gfm_spec.js26
-rw-r--r--spec/frontend/behaviors/markdown/render_observability_spec.js61
-rw-r--r--spec/frontend/behaviors/quick_submit_spec.js18
-rw-r--r--spec/frontend/behaviors/requires_input_spec.js5
-rw-r--r--spec/frontend/behaviors/shortcuts/keybindings_spec.js6
-rw-r--r--spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js7
-rw-r--r--spec/frontend/blame/blame_redirect_spec.js5
-rw-r--r--spec/frontend/blame/streaming/index_spec.js110
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap2
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap2
-rw-r--r--spec/frontend/blob/components/blob_content_error_spec.js4
-rw-r--r--spec/frontend/blob/components/blob_content_spec.js4
-rw-r--r--spec/frontend/blob/components/blob_edit_header_spec.js24
-rw-r--r--spec/frontend/blob/components/blob_header_default_actions_spec.js11
-rw-r--r--spec/frontend/blob/components/blob_header_filepath_spec.js4
-rw-r--r--spec/frontend/blob/components/blob_header_spec.js170
-rw-r--r--spec/frontend/blob/components/blob_header_viewer_switcher_spec.js53
-rw-r--r--spec/frontend/blob/components/mock_data.js2
-rw-r--r--spec/frontend/blob/components/table_contents_spec.js1
-rw-r--r--spec/frontend/blob/csv/csv_viewer_spec.js4
-rw-r--r--spec/frontend/blob/file_template_selector_spec.js2
-rw-r--r--spec/frontend/blob/line_highlighter_spec.js6
-rw-r--r--spec/frontend/blob/notebook/notebook_viever_spec.js2
-rw-r--r--spec/frontend/blob/pdf/pdf_viewer_spec.js5
-rw-r--r--spec/frontend/blob/pipeline_tour_success_modal_spec.js1
-rw-r--r--spec/frontend/blob/sketch/index_spec.js5
-rw-r--r--spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js5
-rw-r--r--spec/frontend/blob_edit/blob_bundle_spec.js11
-rw-r--r--spec/frontend/blob_edit/edit_blob_spec.js2
-rw-r--r--spec/frontend/boards/board_card_inner_spec.js12
-rw-r--r--spec/frontend/boards/board_list_helper.js1
-rw-r--r--spec/frontend/boards/board_list_spec.js124
-rw-r--r--spec/frontend/boards/components/board_add_new_column_form_spec.js124
-rw-r--r--spec/frontend/boards/components/board_add_new_column_spec.js61
-rw-r--r--spec/frontend/boards/components/board_add_new_column_trigger_spec.js6
-rw-r--r--spec/frontend/boards/components/board_app_spec.js51
-rw-r--r--spec/frontend/boards/components/board_card_spec.js47
-rw-r--r--spec/frontend/boards/components/board_column_spec.js8
-rw-r--r--spec/frontend/boards/components/board_configuration_options_spec.js8
-rw-r--r--spec/frontend/boards/components/board_content_sidebar_spec.js93
-rw-r--r--spec/frontend/boards/components/board_content_spec.js91
-rw-r--r--spec/frontend/boards/components/board_filtered_search_spec.js52
-rw-r--r--spec/frontend/boards/components/board_form_spec.js79
-rw-r--r--spec/frontend/boards/components/board_list_header_spec.js178
-rw-r--r--spec/frontend/boards/components/board_new_issue_spec.js4
-rw-r--r--spec/frontend/boards/components/board_new_item_spec.js4
-rw-r--r--spec/frontend/boards/components/board_settings_sidebar_spec.js48
-rw-r--r--spec/frontend/boards/components/board_top_bar_spec.js19
-rw-r--r--spec/frontend/boards/components/boards_selector_spec.js17
-rw-r--r--spec/frontend/boards/components/config_toggle_spec.js4
-rw-r--r--spec/frontend/boards/components/issue_board_filtered_search_spec.js16
-rw-r--r--spec/frontend/boards/components/issue_due_date_spec.js4
-rw-r--r--spec/frontend/boards/components/issue_time_estimate_spec.js4
-rw-r--r--spec/frontend/boards/components/item_count_spec.js8
-rw-r--r--spec/frontend/boards/components/new_board_button_spec.js6
-rw-r--r--spec/frontend/boards/components/sidebar/board_editable_item_spec.js5
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js5
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js118
-rw-r--r--spec/frontend/boards/components/toggle_focus_spec.js6
-rw-r--r--spec/frontend/boards/mock_data.js195
-rw-r--r--spec/frontend/boards/project_select_spec.js5
-rw-r--r--spec/frontend/boards/stores/actions_spec.js22
-rw-r--r--spec/frontend/boards/stores/getters_spec.js8
-rw-r--r--spec/frontend/bootstrap_linked_tabs_spec.js5
-rw-r--r--spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap41
-rw-r--r--spec/frontend/branches/components/delete_branch_button_spec.js4
-rw-r--r--spec/frontend/branches/components/delete_branch_modal_spec.js94
-rw-r--r--spec/frontend/branches/components/delete_merged_branches_spec.js44
-rw-r--r--spec/frontend/branches/components/divergence_graph_spec.js4
-rw-r--r--spec/frontend/branches/components/graph_bar_spec.js4
-rw-r--r--spec/frontend/branches/components/sort_dropdown_spec.js6
-rw-r--r--spec/frontend/captcha/captcha_modal_spec.js74
-rw-r--r--spec/frontend/captcha/init_recaptcha_script_spec.js2
-rw-r--r--spec/frontend/ci/artifacts/components/app_spec.js118
-rw-r--r--spec/frontend/ci/artifacts/components/artifact_row_spec.js127
-rw-r--r--spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js58
-rw-r--r--spec/frontend/ci/artifacts/components/artifacts_table_row_details_spec.js138
-rw-r--r--spec/frontend/ci/artifacts/components/feedback_banner_spec.js59
-rw-r--r--spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js684
-rw-r--r--spec/frontend/ci/artifacts/components/job_checkbox_spec.js132
-rw-r--r--spec/frontend/ci/artifacts/graphql/cache_update_spec.js67
-rw-r--r--spec/frontend/ci/ci_lint/components/ci_lint_spec.js1
-rw-r--r--spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js10
-rw-r--r--spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js5
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js26
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js153
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js26
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js31
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js57
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js67
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js749
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js189
-rw-r--r--spec/frontend/ci/ci_variable_list/mocks.js5
-rw-r--r--spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js12
-rw-r--r--spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js46
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js6
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js43
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js76
-rw-r--r--spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js6
-rw-r--r--spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js9
-rw-r--r--spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js5
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js3
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js102
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js127
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js39
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js60
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js70
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js79
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js252
-rw-r--r--spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js5
-rw-r--r--spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js7
-rw-r--r--spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js5
-rw-r--r--spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js6
-rw-r--r--spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js6
-rw-r--r--spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js10
-rw-r--r--spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_editor/mock_data.js32
-rw-r--r--spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js20
-rw-r--r--spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js113
-rw-r--r--spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js190
-rw-r--r--spec/frontend/ci/pipeline_new/mock_data.js15
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js8
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js6
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js20
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js7
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js14
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_schedules/mock_data.js2
-rw-r--r--spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js9
-rw-r--r--spec/frontend/ci/reports/components/grouped_issues_list_spec.js4
-rw-r--r--spec/frontend/ci/reports/components/issue_status_icon_spec.js5
-rw-r--r--spec/frontend/ci/reports/components/report_link_spec.js4
-rw-r--r--spec/frontend/ci/reports/components/report_section_spec.js4
-rw-r--r--spec/frontend/ci/reports/components/summary_row_spec.js5
-rw-r--r--spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js127
-rw-r--r--spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js122
-rw-r--r--spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js15
-rw-r--r--spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js44
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js6
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js12
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js58
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js6
-rw-r--r--spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap201
-rw-r--r--spec/frontend/ci/runner/components/registration/cli_command_spec.js39
-rw-r--r--spec/frontend/ci/runner/components/registration/platforms_drawer_spec.js108
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_compatibility_alert_spec.js53
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js149
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_feedback_banner_spec.js52
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_instructions_spec.js326
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js16
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_token_spec.js46
-rw-r--r--spec/frontend/ci/runner/components/registration/utils_spec.js94
-rw-r--r--spec/frontend/ci/runner/components/runner_assigned_item_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_bulk_delete_spec.js18
-rw-r--r--spec/frontend/ci/runner/components/runner_create_form_spec.js189
-rw-r--r--spec/frontend/ci/runner/components/runner_delete_button_spec.js24
-rw-r--r--spec/frontend/ci/runner/components/runner_details_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_edit_button_spec.js6
-rw-r--r--spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js18
-rw-r--r--spec/frontend/ci/runner/components/runner_groups_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_header_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js35
-rw-r--r--spec/frontend/ci/runner/components/runner_jobs_spec.js16
-rw-r--r--spec/frontend/ci/runner/components/runner_jobs_table_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_list_empty_state_spec.js78
-rw-r--r--spec/frontend/ci/runner/components/runner_list_spec.js6
-rw-r--r--spec/frontend/ci/runner/components/runner_membership_toggle_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_pagination_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_pause_button_spec.js12
-rw-r--r--spec/frontend/ci/runner/components/runner_paused_badge_spec.js6
-rw-r--r--spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js14
-rw-r--r--spec/frontend/ci/runner/components/runner_platforms_radio_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_projects_spec.js9
-rw-r--r--spec/frontend/ci/runner/components/runner_status_badge_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_tag_spec.js8
-rw-r--r--spec/frontend/ci/runner/components/runner_tags_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/runner_type_badge_spec.js9
-rw-r--r--spec/frontend/ci/runner/components/runner_type_tabs_spec.js7
-rw-r--r--spec/frontend/ci/runner/components/runner_update_form_spec.js14
-rw-r--r--spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js5
-rw-r--r--spec/frontend/ci/runner/components/stat/runner_count_spec.js14
-rw-r--r--spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/stat/runner_stats_spec.js4
-rw-r--r--spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js124
-rw-r--r--spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js120
-rw-r--r--spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js13
-rw-r--r--spec/frontend/ci/runner/group_runners/group_runners_app_spec.js89
-rw-r--r--spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js4
-rw-r--r--spec/frontend/ci/runner/mock_data.js138
-rw-r--r--spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js125
-rw-r--r--spec/frontend/ci/runner/project_register_runner_app/project_register_runner_app_spec.js120
-rw-r--r--spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js5
-rw-r--r--spec/frontend/ci/runner/runner_search_utils_spec.js5
-rw-r--r--spec/frontend/ci/runner/sentry_utils_spec.js2
-rw-r--r--spec/frontend/ci_secure_files/components/metadata/__snapshots__/modal_spec.js.snap6
-rw-r--r--spec/frontend/ci_secure_files/components/metadata/button_spec.js4
-rw-r--r--spec/frontend/ci_secure_files/components/metadata/modal_spec.js1
-rw-r--r--spec/frontend/ci_secure_files/components/secure_files_list_spec.js13
-rw-r--r--spec/frontend/ci_secure_files/mock_data.js4
-rw-r--r--spec/frontend/clusters/agents/components/activity_events_list_spec.js4
-rw-r--r--spec/frontend/clusters/agents/components/activity_history_item_spec.js4
-rw-r--r--spec/frontend/clusters/agents/components/agent_integration_status_row_spec.js4
-rw-r--r--spec/frontend/clusters/agents/components/create_token_button_spec.js8
-rw-r--r--spec/frontend/clusters/agents/components/create_token_modal_spec.js7
-rw-r--r--spec/frontend/clusters/agents/components/integration_status_spec.js4
-rw-r--r--spec/frontend/clusters/agents/components/revoke_token_button_spec.js10
-rw-r--r--spec/frontend/clusters/agents/components/show_spec.js7
-rw-r--r--spec/frontend/clusters/agents/components/token_table_spec.js4
-rw-r--r--spec/frontend/clusters/clusters_bundle_spec.js5
-rw-r--r--spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap2
-rw-r--r--spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap209
-rw-r--r--spec/frontend/clusters/components/new_cluster_spec.js4
-rw-r--r--spec/frontend/clusters/components/remove_cluster_confirmation_spec.js22
-rw-r--r--spec/frontend/clusters/forms/components/integration_form_spec.js31
-rw-r--r--spec/frontend/clusters_list/components/agent_empty_state_spec.js6
-rw-r--r--spec/frontend/clusters_list/components/agent_table_spec.js204
-rw-r--r--spec/frontend/clusters_list/components/agent_token_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/agents_spec.js2
-rw-r--r--spec/frontend/clusters_list/components/ancestor_notice_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/clusters_actions_spec.js5
-rw-r--r--spec/frontend/clusters_list/components/clusters_empty_state_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/clusters_main_view_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/clusters_spec.js5
-rw-r--r--spec/frontend/clusters_list/components/clusters_view_all_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/delete_agent_button_spec.js9
-rw-r--r--spec/frontend/clusters_list/components/install_agent_modal_spec.js5
-rw-r--r--spec/frontend/clusters_list/components/mock_data.js108
-rw-r--r--spec/frontend/clusters_list/components/node_error_help_text_spec.js4
-rw-r--r--spec/frontend/clusters_list/store/actions_spec.js6
-rw-r--r--spec/frontend/code_navigation/components/app_spec.js4
-rw-r--r--spec/frontend/code_navigation/components/popover_spec.js4
-rw-r--r--spec/frontend/code_review/signals_spec.js145
-rw-r--r--spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap140
-rw-r--r--spec/frontend/comment_templates/components/form_spec.js145
-rw-r--r--spec/frontend/comment_templates/components/list_item_spec.js154
-rw-r--r--spec/frontend/comment_templates/components/list_spec.js46
-rw-r--r--spec/frontend/comment_templates/pages/index_spec.js45
-rw-r--r--spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js18
-rw-r--r--spec/frontend/commit/commit_pipeline_status_component_spec.js11
-rw-r--r--spec/frontend/commit/components/commit_box_pipeline_status_spec.js16
-rw-r--r--spec/frontend/commit/components/signature_badge_spec.js134
-rw-r--r--spec/frontend/commit/components/x509_certificate_details_spec.js36
-rw-r--r--spec/frontend/commit/mock_data.js31
-rw-r--r--spec/frontend/commit/pipelines/pipelines_table_spec.js118
-rw-r--r--spec/frontend/commons/nav/user_merge_requests_spec.js33
-rw-r--r--spec/frontend/confidential_merge_request/components/project_form_group_spec.js1
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap2
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap33
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js6
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js10
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js91
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js101
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js184
-rw-r--r--spec/frontend/content_editor/components/content_editor_alert_spec.js6
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js70
-rw-r--r--spec/frontend/content_editor/components/editor_state_observer_spec.js4
-rw-r--r--spec/frontend/content_editor/components/formatting_toolbar_spec.js25
-rw-r--r--spec/frontend/content_editor/components/loading_indicator_spec.js4
-rw-r--r--spec/frontend/content_editor/components/suggestions_dropdown_spec.js22
-rw-r--r--spec/frontend/content_editor/components/toolbar_attachment_button_spec.js60
-rw-r--r--spec/frontend/content_editor/components/toolbar_button_spec.js6
-rw-r--r--spec/frontend/content_editor/components/toolbar_image_button_spec.js97
-rw-r--r--spec/frontend/content_editor/components/toolbar_link_button_spec.js224
-rw-r--r--spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js42
-rw-r--r--spec/frontend/content_editor/components/toolbar_table_button_spec.js1
-rw-r--r--spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js5
-rw-r--r--spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap52
-rw-r--r--spec/frontend/content_editor/components/wrappers/code_block_spec.js8
-rw-r--r--spec/frontend/content_editor/components/wrappers/details_spec.js6
-rw-r--r--spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js6
-rw-r--r--spec/frontend/content_editor/components/wrappers/label_spec.js36
-rw-r--r--spec/frontend/content_editor/components/wrappers/reference_label_spec.js32
-rw-r--r--spec/frontend/content_editor/components/wrappers/reference_spec.js46
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js32
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js6
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js6
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js6
-rw-r--r--spec/frontend/content_editor/extensions/attachment_spec.js556
-rw-r--r--spec/frontend/content_editor/extensions/drawio_diagram_spec.js103
-rw-r--r--spec/frontend/content_editor/extensions/link_spec.js5
-rw-r--r--spec/frontend/content_editor/extensions/paste_markdown_spec.js54
-rw-r--r--spec/frontend/content_editor/markdown_snapshot_spec.js6
-rw-r--r--spec/frontend/content_editor/remark_markdown_processing_spec.js2
-rw-r--r--spec/frontend/content_editor/render_html_and_json_for_all_examples.js2
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js4
-rw-r--r--spec/frontend/content_editor/services/create_content_editor_spec.js18
-rw-r--r--spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js24
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js112
-rw-r--r--spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js4
-rw-r--r--spec/frontend/content_editor/test_constants.js12
-rw-r--r--spec/frontend/content_editor/test_utils.js27
-rw-r--r--spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap66
-rw-r--r--spec/frontend/contributors/component/contributors_spec.js6
-rw-r--r--spec/frontend/contributors/store/actions_spec.js6
-rw-r--r--spec/frontend/create_item_dropdown_spec.js5
-rw-r--r--spec/frontend/crm/contact_form_wrapper_spec.js1
-rw-r--r--spec/frontend/crm/contacts_root_spec.js1
-rw-r--r--spec/frontend/crm/crm_form_spec.js4
-rw-r--r--spec/frontend/crm/organization_form_wrapper_spec.js4
-rw-r--r--spec/frontend/crm/organizations_root_spec.js1
-rw-r--r--spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js3
-rw-r--r--spec/frontend/custom_metrics/components/custom_metrics_form_spec.js4
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js9
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js5
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js5
-rw-r--r--spec/frontend/deploy_freeze/store/actions_spec.js4
-rw-r--r--spec/frontend/deploy_keys/components/app_spec.js1
-rw-r--r--spec/frontend/deploy_keys/components/key_spec.js38
-rw-r--r--spec/frontend/deploy_keys/components/keys_panel_spec.js5
-rw-r--r--spec/frontend/deploy_tokens/components/new_deploy_token_spec.js51
-rw-r--r--spec/frontend/deploy_tokens/components/revoke_button_spec.js4
-rw-r--r--spec/frontend/deprecated_jquery_dropdown_spec.js5
-rw-r--r--spec/frontend/design_management/components/delete_button_spec.js6
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap3
-rw-r--r--spec/frontend/design_management/components/design_notes/design_discussion_spec.js170
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js4
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_spec.js56
-rw-r--r--spec/frontend/design_management/components/design_notes/design_reply_form_spec.js258
-rw-r--r--spec/frontend/design_management/components/design_notes/toggle_replies_widget_spec.js4
-rw-r--r--spec/frontend/design_management/components/design_overlay_spec.js48
-rw-r--r--spec/frontend/design_management/components/design_presentation_spec.js6
-rw-r--r--spec/frontend/design_management/components/design_scaler_spec.js7
-rw-r--r--spec/frontend/design_management/components/design_sidebar_spec.js10
-rw-r--r--spec/frontend/design_management/components/design_todo_button_spec.js6
-rw-r--r--spec/frontend/design_management/components/image_spec.js4
-rw-r--r--spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap10
-rw-r--r--spec/frontend/design_management/components/list/item_spec.js4
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap39
-rw-r--r--spec/frontend/design_management/components/toolbar/design_navigation_spec.js71
-rw-r--r--spec/frontend/design_management/components/toolbar/index_spec.js46
-rw-r--r--spec/frontend/design_management/components/upload/button_spec.js4
-rw-r--r--spec/frontend/design_management/components/upload/design_version_dropdown_spec.js49
-rw-r--r--spec/frontend/design_management/components/upload/mock_data/all_versions.js20
-rw-r--r--spec/frontend/design_management/mock_data/all_versions.js8
-rw-r--r--spec/frontend/design_management/mock_data/apollo_mock.js205
-rw-r--r--spec/frontend/design_management/mock_data/project.js17
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap8
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap4
-rw-r--r--spec/frontend/design_management/pages/design/index_spec.js83
-rw-r--r--spec/frontend/design_management/pages/index_spec.js54
-rw-r--r--spec/frontend/design_management/router_spec.js6
-rw-r--r--spec/frontend/design_management/utils/cache_update_spec.js4
-rw-r--r--spec/frontend/diffs/components/app_spec.js135
-rw-r--r--spec/frontend/diffs/components/collapsed_files_warning_spec.js4
-rw-r--r--spec/frontend/diffs/components/commit_item_spec.js27
-rw-r--r--spec/frontend/diffs/components/compare_dropdown_layout_spec.js5
-rw-r--r--spec/frontend/diffs/components/compare_versions_spec.js24
-rw-r--r--spec/frontend/diffs/components/diff_code_quality_item_spec.js66
-rw-r--r--spec/frontend/diffs/components/diff_code_quality_spec.js44
-rw-r--r--spec/frontend/diffs/components/diff_content_spec.js5
-rw-r--r--spec/frontend/diffs/components/diff_discussion_reply_spec.js4
-rw-r--r--spec/frontend/diffs/components/diff_discussions_spec.js4
-rw-r--r--spec/frontend/diffs/components/diff_file_header_spec.js10
-rw-r--r--spec/frontend/diffs/components/diff_file_row_spec.js4
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js40
-rw-r--r--spec/frontend/diffs/components/diff_gutter_avatars_spec.js4
-rw-r--r--spec/frontend/diffs/components/diff_line_note_form_spec.js17
-rw-r--r--spec/frontend/diffs/components/diff_row_spec.js4
-rw-r--r--spec/frontend/diffs/components/diff_view_spec.js19
-rw-r--r--spec/frontend/diffs/components/hidden_files_warning_spec.js8
-rw-r--r--spec/frontend/diffs/components/image_diff_overlay_spec.js4
-rw-r--r--spec/frontend/diffs/components/merge_conflict_warning_spec.js4
-rw-r--r--spec/frontend/diffs/components/no_changes_spec.js5
-rw-r--r--spec/frontend/diffs/components/settings_dropdown_spec.js1
-rw-r--r--spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap126
-rw-r--r--spec/frontend/diffs/components/shared/findings_drawer_spec.js19
-rw-r--r--spec/frontend/diffs/components/tree_list_spec.js83
-rw-r--r--spec/frontend/diffs/create_diffs_store.js2
-rw-r--r--spec/frontend/diffs/mock_data/diff_code_quality.js5
-rw-r--r--spec/frontend/diffs/mock_data/findings_drawer.js21
-rw-r--r--spec/frontend/diffs/store/actions_spec.js499
-rw-r--r--spec/frontend/diffs/store/getters_spec.js25
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js9
-rw-r--r--spec/frontend/diffs/store/utils_spec.js57
-rw-r--r--spec/frontend/diffs/utils/merge_request_spec.js94
-rw-r--r--spec/frontend/diffs/utils/tree_worker_utils_spec.js30
-rw-r--r--spec/frontend/drawio/content_editor_facade_spec.js138
-rw-r--r--spec/frontend/drawio/drawio_editor_spec.js479
-rw-r--r--spec/frontend/drawio/markdown_field_editor_facade_spec.js148
-rw-r--r--spec/frontend/dropzone_input_spec.js7
-rw-r--r--spec/frontend/editor/components/helpers.js3
-rw-r--r--spec/frontend/editor/components/source_editor_toolbar_button_spec.js36
-rw-r--r--spec/frontend/editor/components/source_editor_toolbar_spec.js37
-rw-r--r--spec/frontend/editor/schema/ci/ci_schema_spec.js12
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/rules_needs.yml46
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml38
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules_needs.yml32
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml31
-rw-r--r--spec/frontend/editor/source_editor_ci_schema_ext_spec.js11
-rw-r--r--spec/frontend/editor/source_editor_extension_base_spec.js112
-rw-r--r--spec/frontend/editor/source_editor_markdown_ext_spec.js25
-rw-r--r--spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js106
-rw-r--r--spec/frontend/editor/source_editor_webide_ext_spec.js1
-rw-r--r--spec/frontend/editor/utils_spec.js30
-rw-r--r--spec/frontend/emoji/awards_app/store/actions_spec.js4
-rw-r--r--spec/frontend/emoji/components/category_spec.js21
-rw-r--r--spec/frontend/emoji/components/emoji_group_spec.js4
-rw-r--r--spec/frontend/emoji/components/emoji_list_spec.js33
-rw-r--r--spec/frontend/environment.js19
-rw-r--r--spec/frontend/environments/canary_ingress_spec.js10
-rw-r--r--spec/frontend/environments/canary_update_modal_spec.js10
-rw-r--r--spec/frontend/environments/confirm_rollback_modal_spec.js36
-rw-r--r--spec/frontend/environments/delete_environment_modal_spec.js6
-rw-r--r--spec/frontend/environments/deploy_board_component_spec.js4
-rw-r--r--spec/frontend/environments/deploy_freeze_alert_spec.js111
-rw-r--r--spec/frontend/environments/edit_environment_spec.js5
-rw-r--r--spec/frontend/environments/empty_state_spec.js69
-rw-r--r--spec/frontend/environments/enable_review_app_modal_spec.js6
-rw-r--r--spec/frontend/environments/environment_actions_spec.js115
-rw-r--r--spec/frontend/environments/environment_details/components/deployment_actions_spec.js119
-rw-r--r--spec/frontend/environments/environment_details/deployments_table_spec.js58
-rw-r--r--spec/frontend/environments/environment_details/index_spec.js109
-rw-r--r--spec/frontend/environments/environment_details/page_spec.js69
-rw-r--r--spec/frontend/environments/environment_external_url_spec.js33
-rw-r--r--spec/frontend/environments/environment_folder_spec.js4
-rw-r--r--spec/frontend/environments/environment_form_spec.js20
-rw-r--r--spec/frontend/environments/environment_item_spec.js34
-rw-r--r--spec/frontend/environments/environment_pin_spec.js12
-rw-r--r--spec/frontend/environments/environment_stop_spec.js2
-rw-r--r--spec/frontend/environments/environment_table_spec.js8
-rw-r--r--spec/frontend/environments/environments_app_spec.js16
-rw-r--r--spec/frontend/environments/environments_detail_header_spec.js57
-rw-r--r--spec/frontend/environments/environments_folder_view_spec.js1
-rw-r--r--spec/frontend/environments/folder/environments_folder_view_spec.js18
-rw-r--r--spec/frontend/environments/graphql/mock_data.js109
-rw-r--r--spec/frontend/environments/graphql/resolvers_spec.js182
-rw-r--r--spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap34
-rw-r--r--spec/frontend/environments/kubernetes_agent_info_spec.js124
-rw-r--r--spec/frontend/environments/kubernetes_overview_spec.js131
-rw-r--r--spec/frontend/environments/kubernetes_pods_spec.js114
-rw-r--r--spec/frontend/environments/kubernetes_summary_spec.js115
-rw-r--r--spec/frontend/environments/kubernetes_tabs_spec.js168
-rw-r--r--spec/frontend/environments/mock_data.js3
-rw-r--r--spec/frontend/environments/new_environment_item_spec.js127
-rw-r--r--spec/frontend/environments/new_environment_spec.js5
-rw-r--r--spec/frontend/environments/stop_stale_environments_modal_spec.js10
-rw-r--r--spec/frontend/error_tracking/components/error_details_info_spec.js190
-rw-r--r--spec/frontend/error_tracking/components/error_details_spec.js132
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_actions_spec.js6
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js49
-rw-r--r--spec/frontend/error_tracking/components/stacktrace_entry_spec.js6
-rw-r--r--spec/frontend/error_tracking/components/stacktrace_spec.js6
-rw-r--r--spec/frontend/error_tracking/events_tracking_spec.js16
-rw-r--r--spec/frontend/error_tracking/store/actions_spec.js4
-rw-r--r--spec/frontend/error_tracking/store/details/actions_spec.js6
-rw-r--r--spec/frontend/error_tracking/store/list/actions_spec.js6
-rw-r--r--spec/frontend/error_tracking/utils_spec.js16
-rw-r--r--spec/frontend/error_tracking_settings/components/app_spec.js8
-rw-r--r--spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js6
-rw-r--r--spec/frontend/error_tracking_settings/components/project_dropdown_spec.js6
-rw-r--r--spec/frontend/experimentation/components/gitlab_experiment_spec.js7
-rw-r--r--spec/frontend/experimentation/utils_spec.js3
-rw-r--r--spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js19
-rw-r--r--spec/frontend/feature_flags/components/edit_feature_flag_spec.js5
-rw-r--r--spec/frontend/feature_flags/components/empty_state_spec.js8
-rw-r--r--spec/frontend/feature_flags/components/environments_dropdown_spec.js1
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_spec.js64
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_table_spec.js48
-rw-r--r--spec/frontend/feature_flags/components/form_spec.js4
-rw-r--r--spec/frontend/feature_flags/components/new_environments_dropdown_spec.js4
-rw-r--r--spec/frontend/feature_flags/components/new_feature_flag_spec.js8
-rw-r--r--spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js8
-rw-r--r--spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js8
-rw-r--r--spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js8
-rw-r--r--spec/frontend/feature_flags/components/strategies/users_with_id_spec.js8
-rw-r--r--spec/frontend/feature_flags/components/strategy_parameters_spec.js8
-rw-r--r--spec/frontend/feature_flags/components/strategy_spec.js11
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_helper_spec.js6
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_popover_spec.js5
-rw-r--r--spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js5
-rw-r--r--spec/frontend/filtered_search/dropdown_user_spec.js11
-rw-r--r--spec/frontend/filtered_search/dropdown_utils_spec.js7
-rw-r--r--spec/frontend/filtered_search/filtered_search_manager_spec.js4
-rw-r--r--spec/frontend/filtered_search/visual_token_value_spec.js4
-rw-r--r--spec/frontend/fixtures/abuse_reports.rb2
-rw-r--r--spec/frontend/fixtures/api_deploy_keys.rb5
-rw-r--r--spec/frontend/fixtures/api_projects.rb15
-rw-r--r--spec/frontend/fixtures/comment_templates.rb74
-rw-r--r--spec/frontend/fixtures/environments.rb2
-rw-r--r--spec/frontend/fixtures/issues.rb9
-rw-r--r--spec/frontend/fixtures/job_artifacts.rb2
-rw-r--r--spec/frontend/fixtures/jobs.rb84
-rw-r--r--spec/frontend/fixtures/merge_requests.rb6
-rw-r--r--spec/frontend/fixtures/metrics_dashboard.rb1
-rw-r--r--spec/frontend/fixtures/milestones.rb43
-rw-r--r--spec/frontend/fixtures/pipelines.rb25
-rw-r--r--spec/frontend/fixtures/projects.rb2
-rw-r--r--spec/frontend/fixtures/prometheus_integration.rb1
-rw-r--r--spec/frontend/fixtures/runner.rb48
-rw-r--r--spec/frontend/fixtures/saved_replies.rb46
-rw-r--r--spec/frontend/fixtures/startup_css.rb17
-rw-r--r--spec/frontend/fixtures/static/oauth_remember_me.html2
-rw-r--r--spec/frontend/fixtures/static/search_autocomplete.html15
-rw-r--r--spec/frontend/fixtures/timelogs.rb53
-rw-r--r--spec/frontend/fixtures/u2f.rb48
-rw-r--r--spec/frontend/fixtures/users.rb75
-rw-r--r--spec/frontend/fixtures/webauthn.rb1
-rw-r--r--spec/frontend/flash_spec.js276
-rw-r--r--spec/frontend/frequent_items/components/app_spec.js2
-rw-r--r--spec/frontend/frequent_items/components/frequent_items_list_item_spec.js2
-rw-r--r--spec/frontend/frequent_items/components/frequent_items_list_spec.js6
-rw-r--r--spec/frontend/frequent_items/store/actions_spec.js1
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js5
-rw-r--r--spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js8
-rw-r--r--spec/frontend/gitlab_version_check/components/gitlab_version_check_badge_spec.js1
-rw-r--r--spec/frontend/gl_field_errors_spec.js5
-rw-r--r--spec/frontend/google_cloud/aiml/panel_spec.js43
-rw-r--r--spec/frontend/google_cloud/aiml/service_table_spec.js34
-rw-r--r--spec/frontend/google_cloud/components/google_cloud_menu_spec.js11
-rw-r--r--spec/frontend/google_cloud/components/incubation_banner_spec.js4
-rw-r--r--spec/frontend/google_cloud/components/revoke_oauth_spec.js4
-rw-r--r--spec/frontend/google_cloud/configuration/panel_spec.js4
-rw-r--r--spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js4
-rw-r--r--spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js4
-rw-r--r--spec/frontend/google_cloud/databases/panel_spec.js4
-rw-r--r--spec/frontend/google_cloud/databases/service_table_spec.js4
-rw-r--r--spec/frontend/google_cloud/deployments/panel_spec.js4
-rw-r--r--spec/frontend/google_cloud/deployments/service_table_spec.js4
-rw-r--r--spec/frontend/google_cloud/gcp_regions/form_spec.js4
-rw-r--r--spec/frontend/google_cloud/gcp_regions/list_spec.js4
-rw-r--r--spec/frontend/google_cloud/service_accounts/form_spec.js4
-rw-r--r--spec/frontend/google_cloud/service_accounts/list_spec.js4
-rw-r--r--spec/frontend/google_tag_manager/index_spec.js4
-rw-r--r--spec/frontend/grafana_integration/components/grafana_integration_spec.js13
-rw-r--r--spec/frontend/graphql_shared/utils_spec.js124
-rw-r--r--spec/frontend/group_settings/components/shared_runners_form_spec.js69
-rw-r--r--spec/frontend/groups/components/app_spec.js15
-rw-r--r--spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js4
-rw-r--r--spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js4
-rw-r--r--spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js5
-rw-r--r--spec/frontend/groups/components/group_folder_spec.js4
-rw-r--r--spec/frontend/groups/components/group_item_spec.js4
-rw-r--r--spec/frontend/groups/components/group_name_and_path_spec.js4
-rw-r--r--spec/frontend/groups/components/groups_spec.js6
-rw-r--r--spec/frontend/groups/components/invite_members_banner_spec.js9
-rw-r--r--spec/frontend/groups/components/item_actions_spec.js5
-rw-r--r--spec/frontend/groups/components/item_caret_spec.js7
-rw-r--r--spec/frontend/groups/components/item_stats_spec.js7
-rw-r--r--spec/frontend/groups/components/item_stats_value_spec.js7
-rw-r--r--spec/frontend/groups/components/item_type_icon_spec.js7
-rw-r--r--spec/frontend/groups/components/new_top_level_group_alert_spec.js4
-rw-r--r--spec/frontend/groups/components/overview_tabs_spec.js2
-rw-r--r--spec/frontend/groups/components/transfer_group_form_spec.js6
-rw-r--r--spec/frontend/groups/settings/components/group_settings_readme_spec.js112
-rw-r--r--spec/frontend/groups/settings/mock_data.js6
-rw-r--r--spec/frontend/groups_projects/components/transfer_locations_spec.js4
-rw-r--r--spec/frontend/header_search/components/app_spec.js90
-rw-r--r--spec/frontend/header_search/components/header_search_autocomplete_items_spec.js11
-rw-r--r--spec/frontend/header_search/components/header_search_default_items_spec.js4
-rw-r--r--spec/frontend/header_search/components/header_search_scoped_items_spec.js7
-rw-r--r--spec/frontend/header_search/init_spec.js18
-rw-r--r--spec/frontend/header_search/mock_data.js10
-rw-r--r--spec/frontend/header_search/store/actions_spec.js2
-rw-r--r--spec/frontend/header_search/store/getters_spec.js7
-rw-r--r--spec/frontend/header_spec.js6
-rw-r--r--spec/frontend/helpers/init_simple_app_helper_spec.js6
-rw-r--r--spec/frontend/helpers/startup_css_helper_spec.js7
-rw-r--r--spec/frontend/ide/components/activity_bar_spec.js59
-rw-r--r--spec/frontend/ide/components/branches/item_spec.js4
-rw-r--r--spec/frontend/ide/components/branches/search_list_spec.js5
-rw-r--r--spec/frontend/ide/components/cannot_push_code_alert_spec.js6
-rw-r--r--spec/frontend/ide/components/commit_sidebar/actions_spec.js4
-rw-r--r--spec/frontend/ide/components/commit_sidebar/editor_header_spec.js39
-rw-r--r--spec/frontend/ide/components/commit_sidebar/empty_state_spec.js4
-rw-r--r--spec/frontend/ide/components/commit_sidebar/form_spec.js27
-rw-r--r--spec/frontend/ide/components/commit_sidebar/list_item_spec.js4
-rw-r--r--spec/frontend/ide/components/commit_sidebar/list_spec.js6
-rw-r--r--spec/frontend/ide/components/commit_sidebar/message_field_spec.js4
-rw-r--r--spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js8
-rw-r--r--spec/frontend/ide/components/commit_sidebar/radio_group_spec.js6
-rw-r--r--spec/frontend/ide/components/commit_sidebar/success_message_spec.js4
-rw-r--r--spec/frontend/ide/components/error_message_spec.js5
-rw-r--r--spec/frontend/ide/components/file_row_extra_spec.js2
-rw-r--r--spec/frontend/ide/components/file_templates/bar_spec.js4
-rw-r--r--spec/frontend/ide/components/file_templates/dropdown_spec.js5
-rw-r--r--spec/frontend/ide/components/ide_file_row_spec.js5
-rw-r--r--spec/frontend/ide/components/ide_project_header_spec.js4
-rw-r--r--spec/frontend/ide/components/ide_review_spec.js6
-rw-r--r--spec/frontend/ide/components/ide_side_bar_spec.js5
-rw-r--r--spec/frontend/ide/components/ide_sidebar_nav_spec.js11
-rw-r--r--spec/frontend/ide/components/ide_spec.js13
-rw-r--r--spec/frontend/ide/components/ide_status_bar_spec.js4
-rw-r--r--spec/frontend/ide/components/ide_status_list_spec.js3
-rw-r--r--spec/frontend/ide/components/ide_status_mr_spec.js4
-rw-r--r--spec/frontend/ide/components/ide_tree_list_spec.js4
-rw-r--r--spec/frontend/ide/components/ide_tree_spec.js74
-rw-r--r--spec/frontend/ide/components/jobs/detail/description_spec.js4
-rw-r--r--spec/frontend/ide/components/jobs/detail/scroll_button_spec.js6
-rw-r--r--spec/frontend/ide/components/jobs/detail_spec.js16
-rw-r--r--spec/frontend/ide/components/jobs/item_spec.js4
-rw-r--r--spec/frontend/ide/components/jobs/list_spec.js5
-rw-r--r--spec/frontend/ide/components/jobs/stage_spec.js5
-rw-r--r--spec/frontend/ide/components/merge_requests/item_spec.js5
-rw-r--r--spec/frontend/ide/components/merge_requests/list_spec.js5
-rw-r--r--spec/frontend/ide/components/nav_dropdown_button_spec.js4
-rw-r--r--spec/frontend/ide/components/nav_dropdown_spec.js4
-rw-r--r--spec/frontend/ide/components/new_dropdown/button_spec.js4
-rw-r--r--spec/frontend/ide/components/new_dropdown/index_spec.js60
-rw-r--r--spec/frontend/ide/components/new_dropdown/modal_spec.js21
-rw-r--r--spec/frontend/ide/components/new_dropdown/upload_spec.js60
-rw-r--r--spec/frontend/ide/components/panes/collapsible_sidebar_spec.js5
-rw-r--r--spec/frontend/ide/components/panes/right_spec.js5
-rw-r--r--spec/frontend/ide/components/pipelines/empty_state_spec.js4
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js5
-rw-r--r--spec/frontend/ide/components/repo_commit_section_spec.js22
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js16
-rw-r--r--spec/frontend/ide/components/repo_tab_spec.js84
-rw-r--r--spec/frontend/ide/components/repo_tabs_spec.js4
-rw-r--r--spec/frontend/ide/components/resizable_panel_spec.js5
-rw-r--r--spec/frontend/ide/components/shared/commit_message_field_spec.js6
-rw-r--r--spec/frontend/ide/components/shared/tokened_input_spec.js4
-rw-r--r--spec/frontend/ide/components/terminal/empty_state_spec.js4
-rw-r--r--spec/frontend/ide/components/terminal/terminal_spec.js4
-rw-r--r--spec/frontend/ide/components/terminal/view_spec.js4
-rw-r--r--spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js4
-rw-r--r--spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js4
-rw-r--r--spec/frontend/ide/init_gitlab_web_ide_spec.js7
-rw-r--r--spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js7
-rw-r--r--spec/frontend/ide/lib/languages/codeowners_spec.js85
-rw-r--r--spec/frontend/ide/services/index_spec.js3
-rw-r--r--spec/frontend/ide/services/terminals_spec.js2
-rw-r--r--spec/frontend/ide/stores/actions/file_spec.js4
-rw-r--r--spec/frontend/ide/stores/actions/merge_request_spec.js8
-rw-r--r--spec/frontend/ide/stores/actions/project_spec.js6
-rw-r--r--spec/frontend/ide/stores/actions_spec.js10
-rw-r--r--spec/frontend/ide/stores/extend_spec.js5
-rw-r--r--spec/frontend/ide/stores/getters_spec.js7
-rw-r--r--spec/frontend/ide/stores/modules/commit/actions_spec.js10
-rw-r--r--spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js8
-rw-r--r--spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js6
-rw-r--r--spec/frontend/import/details/components/import_details_app_spec.js18
-rw-r--r--spec/frontend/import/details/components/import_details_table_spec.js113
-rw-r--r--spec/frontend/import/details/mock_data.js53
-rw-r--r--spec/frontend/import_entities/components/group_dropdown_spec.js8
-rw-r--r--spec/frontend/import_entities/components/import_status_spec.js78
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js47
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js4
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_table_spec.js62
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js11
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js4
-rw-r--r--spec/frontend/import_entities/import_groups/services/status_poller_spec.js6
-rw-r--r--spec/frontend/import_entities/import_projects/components/advanced_settings_spec.js8
-rw-r--r--spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js24
-rw-r--r--spec/frontend/import_entities/import_projects/components/github_organizations_box_spec.js97
-rw-r--r--spec/frontend/import_entities/import_projects/components/github_status_table_spec.js125
-rw-r--r--spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js11
-rw-r--r--spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js19
-rw-r--r--spec/frontend/import_entities/import_projects/store/actions_spec.js18
-rw-r--r--spec/frontend/import_entities/import_projects/store/mutations_spec.js13
-rw-r--r--spec/frontend/incidents/components/incidents_list_spec.js9
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap59
-rw-r--r--spec/frontend/incidents_settings/components/incidents_settings_service_spec.js6
-rw-r--r--spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js30
-rw-r--r--spec/frontend/incidents_settings/components/pagerduty_form_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/active_checkbox_spec.js8
-rw-r--r--spec/frontend/integrations/edit/components/confirmation_modal_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/dynamic_field_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js53
-rw-r--r--spec/frontend/integrations/edit/components/jira_issues_fields_spec.js9
-rw-r--r--spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js6
-rw-r--r--spec/frontend/integrations/edit/components/override_dropdown_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/sections/apple_app_store_spec.js57
-rw-r--r--spec/frontend/integrations/edit/components/sections/configuration_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/sections/connection_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/sections/google_play_spec.js54
-rw-r--r--spec/frontend/integrations/edit/components/sections/jira_issues_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/sections/jira_trigger_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/sections/trigger_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/trigger_field_spec.js6
-rw-r--r--spec/frontend/integrations/edit/components/trigger_fields_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/upload_dropzone_field_spec.js88
-rw-r--r--spec/frontend/integrations/index/components/integrations_list_spec.js4
-rw-r--r--spec/frontend/integrations/index/components/integrations_table_spec.js61
-rw-r--r--spec/frontend/integrations/index/mock_data.js9
-rw-r--r--spec/frontend/integrations/overrides/components/integration_overrides_spec.js1
-rw-r--r--spec/frontend/integrations/overrides/components/integration_tabs_spec.js4
-rw-r--r--spec/frontend/invite_members/components/confetti_spec.js8
-rw-r--r--spec/frontend/invite_members/components/group_select_spec.js8
-rw-r--r--spec/frontend/invite_members/components/import_project_members_modal_spec.js18
-rw-r--r--spec/frontend/invite_members/components/import_project_members_trigger_spec.js4
-rw-r--r--spec/frontend/invite_members/components/invite_group_notification_spec.js14
-rw-r--r--spec/frontend/invite_members/components/invite_group_trigger_spec.js5
-rw-r--r--spec/frontend/invite_members/components/invite_groups_modal_spec.js43
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js302
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js72
-rw-r--r--spec/frontend/invite_members/components/invite_modal_base_spec.js31
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js5
-rw-r--r--spec/frontend/invite_members/components/project_select_spec.js4
-rw-r--r--spec/frontend/invite_members/mock_data/member_modal.js31
-rw-r--r--spec/frontend/invite_members/utils/member_utils_spec.js30
-rw-r--r--spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js8
-rw-r--r--spec/frontend/issuable/components/csv_export_modal_spec.js18
-rw-r--r--spec/frontend/issuable/components/csv_import_export_buttons_spec.js92
-rw-r--r--spec/frontend/issuable/components/csv_import_modal_spec.js4
-rw-r--r--spec/frontend/issuable/components/issuable_by_email_spec.js2
-rw-r--r--spec/frontend/issuable/components/issuable_header_warnings_spec.js7
-rw-r--r--spec/frontend/issuable/components/issue_assignees_spec.js5
-rw-r--r--spec/frontend/issuable/components/issue_milestone_spec.js159
-rw-r--r--spec/frontend/issuable/components/related_issuable_item_spec.js63
-rw-r--r--spec/frontend/issuable/components/status_box_spec.js5
-rw-r--r--spec/frontend/issuable/issuable_form_spec.js69
-rw-r--r--spec/frontend/issuable/popover/components/issue_popover_spec.js4
-rw-r--r--spec/frontend/issuable/popover/components/mr_popover_spec.js8
-rw-r--r--spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js245
-rw-r--r--spec/frontend/issuable/related_issues/components/issue_token_spec.js7
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_block_spec.js152
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_list_spec.js98
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_root_spec.js8
-rw-r--r--spec/frontend/issues/create_merge_request_dropdown_spec.js8
-rw-r--r--spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js70
-rw-r--r--spec/frontend/issues/issue_spec.js8
-rw-r--r--spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js5
-rw-r--r--spec/frontend/issues/list/components/issue_card_time_info_spec.js4
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js192
-rw-r--r--spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js7
-rw-r--r--spec/frontend/issues/list/mock_data.js25
-rw-r--r--spec/frontend/issues/list/utils_spec.js20
-rw-r--r--spec/frontend/issues/new/components/title_suggestions_item_spec.js4
-rw-r--r--spec/frontend/issues/new/components/title_suggestions_spec.js123
-rw-r--r--spec/frontend/issues/new/components/type_popover_spec.js4
-rw-r--r--spec/frontend/issues/new/components/type_select_spec.js141
-rw-r--r--spec/frontend/issues/new/mock_data.js64
-rw-r--r--spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js1
-rw-r--r--spec/frontend/issues/related_merge_requests/store/actions_spec.js4
-rw-r--r--spec/frontend/issues/show/components/app_spec.js592
-rw-r--r--spec/frontend/issues/show/components/delete_issue_modal_spec.js4
-rw-r--r--spec/frontend/issues/show/components/description_spec.js263
-rw-r--r--spec/frontend/issues/show/components/edit_actions_spec.js6
-rw-r--r--spec/frontend/issues/show/components/edited_spec.js73
-rw-r--r--spec/frontend/issues/show/components/fields/description_spec.js25
-rw-r--r--spec/frontend/issues/show/components/fields/description_template_spec.js4
-rw-r--r--spec/frontend/issues/show/components/fields/title_spec.js5
-rw-r--r--spec/frontend/issues/show/components/fields/type_spec.js8
-rw-r--r--spec/frontend/issues/show/components/form_spec.js4
-rw-r--r--spec/frontend/issues/show/components/header_actions_spec.js366
-rw-r--r--spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js6
-rw-r--r--spec/frontend/issues/show/components/incidents/highlight_bar_spec.js7
-rw-r--r--spec/frontend/issues/show/components/incidents/incident_tabs_spec.js57
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js19
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js8
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js10
-rw-r--r--spec/frontend/issues/show/components/incidents/utils_spec.js4
-rw-r--r--spec/frontend/issues/show/components/locked_warning_spec.js9
-rw-r--r--spec/frontend/issues/show/components/new_header_actions_popover_spec.js77
-rw-r--r--spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js6
-rw-r--r--spec/frontend/issues/show/components/task_list_item_actions_spec.js19
-rw-r--r--spec/frontend/issues/show/components/title_spec.js89
-rw-r--r--spec/frontend/issues/show/mock_data/mock_data.js74
-rw-r--r--spec/frontend/jira_connect/branches/components/new_branch_form_spec.js4
-rw-r--r--spec/frontend/jira_connect/branches/components/project_dropdown_spec.js4
-rw-r--r--spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js8
-rw-r--r--spec/frontend/jira_connect/branches/pages/index_spec.js4
-rw-r--r--spec/frontend/jira_connect/subscriptions/api_spec.js28
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/add_namespace_button_spec.js6
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal_spec.js4
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js108
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js4
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/app_spec.js298
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js4
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js4
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/sign_in_legacy_button_spec.js58
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js6
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js4
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/user_link_spec.js80
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js74
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js35
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js18
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js97
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js34
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js4
-rw-r--r--spec/frontend/jira_connect/subscriptions/store/actions_spec.js20
-rw-r--r--spec/frontend/jira_connect/subscriptions/store/mutations_spec.js16
-rw-r--r--spec/frontend/jira_connect/subscriptions/utils_spec.js46
-rw-r--r--spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap18
-rw-r--r--spec/frontend/jira_import/components/jira_import_app_spec.js5
-rw-r--r--spec/frontend/jira_import/components/jira_import_form_spec.js5
-rw-r--r--spec/frontend/jira_import/components/jira_import_progress_spec.js5
-rw-r--r--spec/frontend/jira_import/components/jira_import_setup_spec.js5
-rw-r--r--spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js4
-rw-r--r--spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js4
-rw-r--r--spec/frontend/jobs/components/job/artifacts_block_spec.js5
-rw-r--r--spec/frontend/jobs/components/job/commit_block_spec.js4
-rw-r--r--spec/frontend/jobs/components/job/empty_state_spec.js7
-rw-r--r--spec/frontend/jobs/components/job/environments_block_spec.js5
-rw-r--r--spec/frontend/jobs/components/job/erased_block_spec.js4
-rw-r--r--spec/frontend/jobs/components/job/job_app_spec.js3
-rw-r--r--spec/frontend/jobs/components/job/job_container_item_spec.js5
-rw-r--r--spec/frontend/jobs/components/job/job_log_controllers_spec.js17
-rw-r--r--spec/frontend/jobs/components/job/job_retry_forward_deployment_modal_spec.js19
-rw-r--r--spec/frontend/jobs/components/job/job_sidebar_details_container_spec.js7
-rw-r--r--spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js6
-rw-r--r--spec/frontend/jobs/components/job/jobs_container_spec.js4
-rw-r--r--spec/frontend/jobs/components/job/manual_variables_form_spec.js112
-rw-r--r--spec/frontend/jobs/components/job/mock_data.js27
-rw-r--r--spec/frontend/jobs/components/job/sidebar_detail_row_spec.js32
-rw-r--r--spec/frontend/jobs/components/job/sidebar_header_spec.js2
-rw-r--r--spec/frontend/jobs/components/job/sidebar_spec.js8
-rw-r--r--spec/frontend/jobs/components/job/stages_dropdown_spec.js6
-rw-r--r--spec/frontend/jobs/components/job/stuck_block_spec.js7
-rw-r--r--spec/frontend/jobs/components/job/trigger_block_spec.js4
-rw-r--r--spec/frontend/jobs/components/job/unmet_prerequisites_block_spec.js4
-rw-r--r--spec/frontend/jobs/components/log/collapsible_section_spec.js4
-rw-r--r--spec/frontend/jobs/components/log/duration_badge_spec.js4
-rw-r--r--spec/frontend/jobs/components/log/line_header_spec.js4
-rw-r--r--spec/frontend/jobs/components/log/line_number_spec.js4
-rw-r--r--spec/frontend/jobs/components/log/log_spec.js77
-rw-r--r--spec/frontend/jobs/components/log/mock_data.js24
-rw-r--r--spec/frontend/jobs/components/table/cells/actions_cell_spec.js8
-rw-r--r--spec/frontend/jobs/components/table/cells/duration_cell_spec.js4
-rw-r--r--spec/frontend/jobs/components/table/cells/job_cell_spec.js4
-rw-r--r--spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js4
-rw-r--r--spec/frontend/jobs/components/table/graphql/cache_config_spec.js19
-rw-r--r--spec/frontend/jobs/components/table/job_table_app_spec.js144
-rw-r--r--spec/frontend/jobs/components/table/jobs_table_spec.js84
-rw-r--r--spec/frontend/jobs/components/table/jobs_table_tabs_spec.js23
-rw-r--r--spec/frontend/jobs/mixins/delayed_job_mixin_spec.js5
-rw-r--r--spec/frontend/jobs/mock_data.js20
-rw-r--r--spec/frontend/jobs/store/utils_spec.js8
-rw-r--r--spec/frontend/labels/components/delete_label_modal_spec.js83
-rw-r--r--spec/frontend/labels/components/promote_label_modal_spec.js1
-rw-r--r--spec/frontend/language_switcher/components/app_spec.js4
-rw-r--r--spec/frontend/lib/apollo/indexed_db_persistent_storage_spec.js90
-rw-r--r--spec/frontend/lib/apollo/mock_data/cache_with_persist_directive_and_field.json151
-rw-r--r--spec/frontend/lib/apollo/persist_link_spec.js4
-rw-r--r--spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js5
-rw-r--r--spec/frontend/lib/dompurify_spec.js14
-rw-r--r--spec/frontend/lib/mousetrap_spec.js113
-rw-r--r--spec/frontend/lib/utils/axios_startup_calls_spec.js7
-rw-r--r--spec/frontend/lib/utils/chart_utils_spec.js55
-rw-r--r--spec/frontend/lib/utils/color_utils_spec.js42
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js8
-rw-r--r--spec/frontend/lib/utils/confirm_via_gl_modal/confirm_action_spec.js3
-rw-r--r--spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js18
-rw-r--r--spec/frontend/lib/utils/css_utils_spec.js22
-rw-r--r--spec/frontend/lib/utils/datetime/date_format_utility_spec.js12
-rw-r--r--spec/frontend/lib/utils/datetime/time_spent_utility_spec.js25
-rw-r--r--spec/frontend/lib/utils/datetime/timeago_utility_spec.js37
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js40
-rw-r--r--spec/frontend/lib/utils/error_message_spec.js48
-rw-r--r--spec/frontend/lib/utils/file_upload_spec.js22
-rw-r--r--spec/frontend/lib/utils/intersection_observer_spec.js2
-rw-r--r--spec/frontend/lib/utils/number_utility_spec.js29
-rw-r--r--spec/frontend/lib/utils/poll_spec.js2
-rw-r--r--spec/frontend/lib/utils/ref_validator_spec.js79
-rw-r--r--spec/frontend/lib/utils/secret_detection_spec.js68
-rw-r--r--spec/frontend/lib/utils/sticky_spec.js77
-rw-r--r--spec/frontend/lib/utils/tappable_promise_spec.js63
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js62
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js32
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js20
-rw-r--r--spec/frontend/lib/utils/vuex_module_mappers_spec.js4
-rw-r--r--spec/frontend/lib/utils/web_ide_navigator_spec.js38
-rw-r--r--spec/frontend/listbox/redirect_behavior_spec.js6
-rw-r--r--spec/frontend/locale/sprintf_spec.js11
-rw-r--r--spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js4
-rw-r--r--spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js6
-rw-r--r--spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js4
-rw-r--r--spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js7
-rw-r--r--spec/frontend/members/components/action_buttons/remove_member_button_spec.js6
-rw-r--r--spec/frontend/members/components/action_buttons/resend_invite_button_spec.js6
-rw-r--r--spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js6
-rw-r--r--spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js4
-rw-r--r--spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js6
-rw-r--r--spec/frontend/members/components/app_spec.js1
-rw-r--r--spec/frontend/members/components/avatars/group_avatar_spec.js4
-rw-r--r--spec/frontend/members/components/avatars/invite_avatar_spec.js4
-rw-r--r--spec/frontend/members/components/avatars/user_avatar_spec.js4
-rw-r--r--spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js9
-rw-r--r--spec/frontend/members/components/filter_sort/sort_dropdown_spec.js2
-rw-r--r--spec/frontend/members/components/members_tabs_spec.js4
-rw-r--r--spec/frontend/members/components/modals/leave_modal_spec.js4
-rw-r--r--spec/frontend/members/components/modals/remove_group_link_modal_spec.js5
-rw-r--r--spec/frontend/members/components/modals/remove_member_modal_spec.js4
-rw-r--r--spec/frontend/members/components/table/created_at_spec.js4
-rw-r--r--spec/frontend/members/components/table/expiration_datepicker_spec.js6
-rw-r--r--spec/frontend/members/components/table/member_action_buttons_spec.js4
-rw-r--r--spec/frontend/members/components/table/member_avatar_spec.js4
-rw-r--r--spec/frontend/members/components/table/member_source_spec.js6
-rw-r--r--spec/frontend/members/components/table/members_table_cell_spec.js5
-rw-r--r--spec/frontend/members/components/table/members_table_spec.js4
-rw-r--r--spec/frontend/members/components/table/role_dropdown_spec.js96
-rw-r--r--spec/frontend/members/index_spec.js3
-rw-r--r--spec/frontend/members/utils_spec.js2
-rw-r--r--spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js4
-rw-r--r--spec/frontend/merge_conflicts/store/actions_spec.js6
-rw-r--r--spec/frontend/merge_request_spec.js12
-rw-r--r--spec/frontend/merge_request_tabs_spec.js47
-rw-r--r--spec/frontend/merge_requests/components/compare_app_spec.js4
-rw-r--r--spec/frontend/merge_requests/components/compare_dropdown_spec.js1
-rw-r--r--spec/frontend/milestones/components/delete_milestone_modal_spec.js14
-rw-r--r--spec/frontend/milestones/components/milestone_combobox_spec.js5
-rw-r--r--spec/frontend/milestones/components/promote_milestone_modal_spec.js8
-rw-r--r--spec/frontend/milestones/index_spec.js38
-rw-r--r--spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap268
-rw-r--r--spec/frontend/ml/experiment_tracking/components/delete_button_spec.js68
-rw-r--r--spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js47
-rw-r--r--spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js316
-rw-r--r--spec/frontend/ml/experiment_tracking/components/model_experiments_header_spec.js35
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js49
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js119
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js23
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js19
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js321
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js58
-rw-r--r--spec/frontend/monitoring/components/charts/column_spec.js4
-rw-r--r--spec/frontend/monitoring/components/charts/gauge_spec.js5
-rw-r--r--spec/frontend/monitoring/components/charts/heatmap_spec.js4
-rw-r--r--spec/frontend/monitoring/components/charts/single_stat_spec.js4
-rw-r--r--spec/frontend/monitoring/components/charts/time_series_spec.js4
-rw-r--r--spec/frontend/monitoring/components/create_dashboard_modal_spec.js4
-rw-r--r--spec/frontend/monitoring/components/dashboard_actions_menu_spec.js17
-rw-r--r--spec/frontend/monitoring/components/dashboard_header_spec.js25
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_builder_spec.js2
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_spec.js16
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js5
-rw-r--r--spec/frontend/monitoring/components/dashboard_url_time_spec.js11
-rw-r--r--spec/frontend/monitoring/components/embeds/embed_group_spec.js3
-rw-r--r--spec/frontend/monitoring/components/embeds/metric_embed_spec.js3
-rw-r--r--spec/frontend/monitoring/components/graph_group_spec.js4
-rw-r--r--spec/frontend/monitoring/components/group_empty_state_spec.js4
-rw-r--r--spec/frontend/monitoring/components/refresh_button_spec.js1
-rw-r--r--spec/frontend/monitoring/components/variables/dropdown_field_spec.js8
-rw-r--r--spec/frontend/monitoring/components/variables/text_field_spec.js6
-rw-r--r--spec/frontend/monitoring/mock_data.js26
-rw-r--r--spec/frontend/monitoring/pages/dashboard_page_spec.js10
-rw-r--r--spec/frontend/monitoring/pages/panel_new_page_spec.js4
-rw-r--r--spec/frontend/monitoring/store/actions_spec.js11
-rw-r--r--spec/frontend/nav/components/new_nav_toggle_spec.js222
-rw-r--r--spec/frontend/nav/components/responsive_app_spec.js4
-rw-r--r--spec/frontend/nav/components/responsive_header_spec.js6
-rw-r--r--spec/frontend/nav/components/responsive_home_spec.js6
-rw-r--r--spec/frontend/nav/components/top_nav_app_spec.js4
-rw-r--r--spec/frontend/nav/components/top_nav_container_view_spec.js4
-rw-r--r--spec/frontend/nav/components/top_nav_dropdown_menu_spec.js4
-rw-r--r--spec/frontend/nav/components/top_nav_menu_sections_spec.js4
-rw-r--r--spec/frontend/nav/components/top_nav_new_dropdown_spec.js29
-rw-r--r--spec/frontend/new_branch_spec.js7
-rw-r--r--spec/frontend/notebook/cells/code_spec.js4
-rw-r--r--spec/frontend/notebook/cells/markdown_spec.js6
-rw-r--r--spec/frontend/notebook/cells/output/dataframe_spec.js59
-rw-r--r--spec/frontend/notebook/cells/output/dataframe_util_spec.js133
-rw-r--r--spec/frontend/notebook/cells/output/error_spec.js48
-rw-r--r--spec/frontend/notebook/cells/output/index_spec.js22
-rw-r--r--spec/frontend/notebook/cells/prompt_spec.js4
-rw-r--r--spec/frontend/notebook/index_spec.js6
-rw-r--r--spec/frontend/notebook/mock_data.js102
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js154
-rw-r--r--spec/frontend/notes/components/comment_type_dropdown_spec.js4
-rw-r--r--spec/frontend/notes/components/diff_discussion_header_spec.js4
-rw-r--r--spec/frontend/notes/components/discussion_actions_spec.js11
-rw-r--r--spec/frontend/notes/components/discussion_counter_spec.js3
-rw-r--r--spec/frontend/notes/components/discussion_filter_note_spec.js5
-rw-r--r--spec/frontend/notes/components/discussion_filter_spec.js133
-rw-r--r--spec/frontend/notes/components/discussion_navigator_spec.js10
-rw-r--r--spec/frontend/notes/components/discussion_notes_replies_wrapper_spec.js4
-rw-r--r--spec/frontend/notes/components/discussion_notes_spec.js5
-rw-r--r--spec/frontend/notes/components/discussion_reply_placeholder_spec.js4
-rw-r--r--spec/frontend/notes/components/discussion_resolve_button_spec.js4
-rw-r--r--spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js4
-rw-r--r--spec/frontend/notes/components/email_participants_warning_spec.js5
-rw-r--r--spec/frontend/notes/components/mr_discussion_filter_spec.js110
-rw-r--r--spec/frontend/notes/components/note_actions/reply_button_spec.js5
-rw-r--r--spec/frontend/notes/components/note_actions/timeline_event_button_spec.js6
-rw-r--r--spec/frontend/notes/components/note_actions_spec.js42
-rw-r--r--spec/frontend/notes/components/note_attachment_spec.js5
-rw-r--r--spec/frontend/notes/components/note_awards_list_spec.js236
-rw-r--r--spec/frontend/notes/components/note_body_spec.js12
-rw-r--r--spec/frontend/notes/components/note_edited_text_spec.js69
-rw-r--r--spec/frontend/notes/components/note_form_spec.js230
-rw-r--r--spec/frontend/notes/components/note_header_spec.js33
-rw-r--r--spec/frontend/notes/components/note_signed_out_widget_spec.js4
-rw-r--r--spec/frontend/notes/components/noteable_discussion_spec.js15
-rw-r--r--spec/frontend/notes/components/noteable_note_spec.js64
-rw-r--r--spec/frontend/notes/components/notes_activity_header_spec.js4
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js19
-rw-r--r--spec/frontend/notes/components/timeline_toggle_spec.js4
-rw-r--r--spec/frontend/notes/components/toggle_replies_widget_spec.js4
-rw-r--r--spec/frontend/notes/deprecated_notes_spec.js12
-rw-r--r--spec/frontend/notes/stores/actions_spec.js32
-rw-r--r--spec/frontend/notifications/components/custom_notifications_modal_spec.js54
-rw-r--r--spec/frontend/notifications/components/notifications_dropdown_spec.js4
-rw-r--r--spec/frontend/oauth_application/components/oauth_secret_spec.js116
-rw-r--r--spec/frontend/oauth_remember_me_spec.js14
-rw-r--r--spec/frontend/observability/index_spec.js64
-rw-r--r--spec/frontend/observability/observability_app_spec.js144
-rw-r--r--spec/frontend/observability/skeleton_spec.js15
-rw-r--r--spec/frontend/operation_settings/components/metrics_settings_spec.js9
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/delete_modal_spec.js158
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js152
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js48
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js45
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js313
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js22
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js11
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js4
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js124
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js77
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/stubs.js2
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js19
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/app_spec.js84
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js35
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js55
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/mock_data.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/details/details_header_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_header_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_row_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js8
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/pages/tags_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js10
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js7
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js10
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js35
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js8
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js9
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js92
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap3
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap199
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap14
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js10
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js236
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js17
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js34
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/functional/delete_packages_spec.js8
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap12
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js18
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js53
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/package_registry/mock_data.js83
-rw-r--r--spec/frontend/packages_and_registries/package_registry/pages/details_spec.js218
-rw-r--r--spec/frontend/packages_and_registries/package_registry/pages/list_spec.js98
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/exceptions_input_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js10
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js23
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js3
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js1
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js7
-rw-r--r--spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap29
-rw-r--r--spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/shared/components/delete_package_modal_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/shared/components/package_icon_and_name_spec.js32
-rw-r--r--spec/frontend/packages_and_registries/shared/components/package_path_spec.js11
-rw-r--r--spec/frontend/packages_and_registries/shared/components/package_tags_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/shared/components/publish_method_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/shared/components/registry_list_spec.js23
-rw-r--r--spec/frontend/packages_and_registries/shared/components/settings_block_spec.js4
-rw-r--r--spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js6
-rw-r--r--spec/frontend/pages/admin/application_settings/account_and_limits_spec.js7
-rw-r--r--spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js6
-rw-r--r--spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js66
-rw-r--r--spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js57
-rw-r--r--spec/frontend/pages/admin/jobs/components/jobs_skeleton_loader_spec.js28
-rw-r--r--spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js394
-rw-r--r--spec/frontend/pages/admin/jobs/components/table/cells/project_cell_spec.js32
-rw-r--r--spec/frontend/pages/admin/jobs/components/table/cells/runner_cell_spec.js64
-rw-r--r--spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js106
-rw-r--r--spec/frontend/pages/admin/jobs/index/components/cancel_jobs_modal_spec.js66
-rw-r--r--spec/frontend/pages/admin/jobs/index/components/cancel_jobs_spec.js57
-rw-r--r--spec/frontend/pages/admin/projects/components/namespace_select_spec.js4
-rw-r--r--spec/frontend/pages/dashboard/todos/index/todos_spec.js5
-rw-r--r--spec/frontend/pages/groups/new/components/app_spec.js28
-rw-r--r--spec/frontend/pages/groups/new/components/create_group_description_details_spec.js4
-rw-r--r--spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js9
-rw-r--r--spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js10
-rw-r--r--spec/frontend/pages/import/history/components/import_error_details_spec.js11
-rw-r--r--spec/frontend/pages/import/history/components/import_history_app_spec.js14
-rw-r--r--spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js7
-rw-r--r--spec/frontend/pages/projects/forks/new/components/app_spec.js4
-rw-r--r--spec/frontend/pages/projects/forks/new/components/fork_form_spec.js13
-rw-r--r--spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js17
-rw-r--r--spec/frontend/pages/projects/graphs/code_coverage_spec.js11
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js6
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js8
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js5
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js4
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js9
-rw-r--r--spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js15
-rw-r--r--spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js6
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_alert_spec.js5
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_content_spec.js5
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_form_spec.js10
-rw-r--r--spec/frontend/pdf/index_spec.js4
-rw-r--r--spec/frontend/pdf/page_spec.js4
-rw-r--r--spec/frontend/performance_bar/components/add_request_spec.js4
-rw-r--r--spec/frontend/performance_bar/components/detailed_metric_spec.js4
-rw-r--r--spec/frontend/performance_bar/components/performance_bar_app_spec.js55
-rw-r--r--spec/frontend/performance_bar/components/request_warning_spec.js4
-rw-r--r--spec/frontend/performance_bar/index_spec.js10
-rw-r--r--spec/frontend/performance_bar/services/performance_bar_service_spec.js2
-rw-r--r--spec/frontend/performance_bar/stores/performance_bar_store_spec.js8
-rw-r--r--spec/frontend/persistent_user_callout_spec.js4
-rw-r--r--spec/frontend/pipeline_wizard/components/commit_spec.js22
-rw-r--r--spec/frontend/pipeline_wizard/components/editor_spec.js4
-rw-r--r--spec/frontend/pipeline_wizard/components/input_wrapper_spec.js8
-rw-r--r--spec/frontend/pipeline_wizard/components/step_nav_spec.js14
-rw-r--r--spec/frontend/pipeline_wizard/components/step_spec.js6
-rw-r--r--spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js4
-rw-r--r--spec/frontend/pipeline_wizard/components/widgets/list_spec.js12
-rw-r--r--spec/frontend/pipeline_wizard/components/widgets/text_spec.js8
-rw-r--r--spec/frontend/pipeline_wizard/components/wrapper_spec.js33
-rw-r--r--spec/frontend/pipeline_wizard/pipeline_wizard_spec.js4
-rw-r--r--spec/frontend/pipelines/components/dag/dag_annotations_spec.js9
-rw-r--r--spec/frontend/pipelines/components/dag/dag_graph_spec.js5
-rw-r--r--spec/frontend/pipelines/components/dag/dag_spec.js21
-rw-r--r--spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js15
-rw-r--r--spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js46
-rw-r--r--spec/frontend/pipelines/components/jobs/jobs_app_spec.js10
-rw-r--r--spec/frontend/pipelines/components/jobs/utils_spec.js14
-rw-r--r--spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list_spec.js12
-rw-r--r--spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js15
-rw-r--r--spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js7
-rw-r--r--spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js5
-rw-r--r--spec/frontend/pipelines/components/pipeline_tabs_spec.js5
-rw-r--r--spec/frontend/pipelines/components/pipelines_filtered_search_spec.js2
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js5
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js5
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js109
-rw-r--r--spec/frontend/pipelines/empty_state_spec.js5
-rw-r--r--spec/frontend/pipelines/graph/action_component_spec.js1
-rw-r--r--spec/frontend/pipelines/graph/graph_component_wrapper_spec.js175
-rw-r--r--spec/frontend/pipelines/graph/graph_view_selector_spec.js19
-rw-r--r--spec/frontend/pipelines/graph/job_group_dropdown_spec.js4
-rw-r--r--spec/frontend/pipelines/graph/job_item_spec.js51
-rw-r--r--spec/frontend/pipelines/graph/linked_pipeline_spec.js170
-rw-r--r--spec/frontend/pipelines/graph/linked_pipelines_column_spec.js4
-rw-r--r--spec/frontend/pipelines/graph/stage_column_component_spec.js4
-rw-r--r--spec/frontend/pipelines/graph_shared/links_inner_spec.js1
-rw-r--r--spec/frontend/pipelines/graph_shared/links_layer_spec.js4
-rw-r--r--spec/frontend/pipelines/header_component_spec.js11
-rw-r--r--spec/frontend/pipelines/mock_data.js87
-rw-r--r--spec/frontend/pipelines/nav_controls_spec.js37
-rw-r--r--spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js4
-rw-r--r--spec/frontend/pipelines/pipeline_labels_spec.js4
-rw-r--r--spec/frontend/pipelines/pipeline_multi_actions_spec.js2
-rw-r--r--spec/frontend/pipelines/pipeline_operations_spec.js77
-rw-r--r--spec/frontend/pipelines/pipeline_tabs_spec.js2
-rw-r--r--spec/frontend/pipelines/pipeline_triggerer_spec.js6
-rw-r--r--spec/frontend/pipelines/pipeline_url_spec.js4
-rw-r--r--spec/frontend/pipelines/pipelines_actions_spec.js171
-rw-r--r--spec/frontend/pipelines/pipelines_artifacts_spec.js5
-rw-r--r--spec/frontend/pipelines/pipelines_manual_actions_spec.js216
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js33
-rw-r--r--spec/frontend/pipelines/pipelines_table_spec.js6
-rw-r--r--spec/frontend/pipelines/test_reports/stores/actions_spec.js6
-rw-r--r--spec/frontend/pipelines/test_reports/stores/mutations_spec.js6
-rw-r--r--spec/frontend/pipelines/test_reports/test_case_details_spec.js5
-rw-r--r--spec/frontend/pipelines/test_reports/test_reports_spec.js4
-rw-r--r--spec/frontend/pipelines/test_reports/test_suite_table_spec.js4
-rw-r--r--spec/frontend/pipelines/time_ago_spec.js5
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js5
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_status_token_spec.js5
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js5
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js5
-rw-r--r--spec/frontend/popovers/components/popovers_spec.js5
-rw-r--r--spec/frontend/profile/account/components/delete_account_modal_spec.js6
-rw-r--r--spec/frontend/profile/account/components/update_username_spec.js52
-rw-r--r--spec/frontend/profile/components/activity_calendar_spec.js120
-rw-r--r--spec/frontend/profile/components/followers_tab_spec.js21
-rw-r--r--spec/frontend/profile/components/following_tab_spec.js21
-rw-r--r--spec/frontend/profile/components/overview_tab_spec.js60
-rw-r--r--spec/frontend/profile/components/profile_tabs_spec.js57
-rw-r--r--spec/frontend/profile/components/user_achievements_spec.js122
-rw-r--r--spec/frontend/profile/mock_data.js22
-rw-r--r--spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js5
-rw-r--r--spec/frontend/profile/preferences/components/diffs_colors_spec.js14
-rw-r--r--spec/frontend/profile/preferences/components/integration_view_spec.js5
-rw-r--r--spec/frontend/profile/preferences/components/profile_preferences_spec.js9
-rw-r--r--spec/frontend/profile/utils_spec.js15
-rw-r--r--spec/frontend/projects/clusters_deprecation_slert/components/clusters_deprecation_alert_spec.js4
-rw-r--r--spec/frontend/projects/commit/components/branches_dropdown_spec.js10
-rw-r--r--spec/frontend/projects/commit/components/commit_options_dropdown_spec.js2
-rw-r--r--spec/frontend/projects/commit/components/form_modal_spec.js77
-rw-r--r--spec/frontend/projects/commit/components/projects_dropdown_spec.js15
-rw-r--r--spec/frontend/projects/commit/store/actions_spec.js6
-rw-r--r--spec/frontend/projects/commit_box/info/init_details_button_spec.js47
-rw-r--r--spec/frontend/projects/commit_box/info/load_branches_spec.js10
-rw-r--r--spec/frontend/projects/commits/components/author_select_spec.js52
-rw-r--r--spec/frontend/projects/commits/store/actions_spec.js8
-rw-r--r--spec/frontend/projects/compare/components/app_spec.js5
-rw-r--r--spec/frontend/projects/compare/components/repo_dropdown_spec.js5
-rw-r--r--spec/frontend/projects/compare/components/revision_card_spec.js5
-rw-r--r--spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js51
-rw-r--r--spec/frontend/projects/compare/components/revision_dropdown_spec.js48
-rw-r--r--spec/frontend/projects/components/project_delete_button_spec.js5
-rw-r--r--spec/frontend/projects/components/shared/delete_button_spec.js7
-rw-r--r--spec/frontend/projects/details/upload_button_spec.js4
-rw-r--r--spec/frontend/projects/new/components/app_spec.js46
-rw-r--r--spec/frontend/projects/new/components/deployment_target_select_spec.js3
-rw-r--r--spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js1
-rw-r--r--spec/frontend/projects/new/components/new_project_url_select_spec.js23
-rw-r--r--spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap30
-rw-r--r--spec/frontend/projects/pipelines/charts/components/app_spec.js4
-rw-r--r--spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_area_chart_spec.js5
-rw-r--r--spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js7
-rw-r--r--spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js5
-rw-r--r--spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js4
-rw-r--r--spec/frontend/projects/prune_unreachable_objects_button_spec.js7
-rw-r--r--spec/frontend/projects/settings/branch_rules/components/edit/branch_dropdown_spec.js8
-rw-r--r--spec/frontend/projects/settings/branch_rules/components/edit/index_spec.js4
-rw-r--r--spec/frontend/projects/settings/branch_rules/components/edit/protections/index_spec.js4
-rw-r--r--spec/frontend/projects/settings/branch_rules/components/edit/protections/merge_protections_spec.js4
-rw-r--r--spec/frontend/projects/settings/branch_rules/components/edit/protections/push_protections_spec.js4
-rw-r--r--spec/frontend/projects/settings/branch_rules/components/view/index_spec.js48
-rw-r--r--spec/frontend/projects/settings/branch_rules/components/view/protection_row_spec.js2
-rw-r--r--spec/frontend/projects/settings/branch_rules/components/view/protection_spec.js2
-rw-r--r--spec/frontend/projects/settings/components/default_branch_selector_spec.js5
-rw-r--r--spec/frontend/projects/settings/components/new_access_dropdown_spec.js20
-rw-r--r--spec/frontend/projects/settings/components/shared_runners_toggle_spec.js20
-rw-r--r--spec/frontend/projects/settings/components/transfer_project_form_spec.js12
-rw-r--r--spec/frontend/projects/settings/repository/branch_rules/app_spec.js6
-rw-r--r--spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js4
-rw-r--r--spec/frontend/projects/settings/repository/branch_rules/mock_data.js2
-rw-r--r--spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js3
-rw-r--r--spec/frontend/projects/settings/utils_spec.js24
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js5
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js6
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js6
-rw-r--r--spec/frontend/projects/terraform_notification/terraform_notification_spec.js4
-rw-r--r--spec/frontend/prometheus_metrics/custom_metrics_spec.js6
-rw-r--r--spec/frontend/prometheus_metrics/prometheus_metrics_spec.js7
-rw-r--r--spec/frontend/protected_branches/protected_branch_edit_spec.js8
-rw-r--r--spec/frontend/read_more_spec.js9
-rw-r--r--spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap80
-rw-r--r--spec/frontend/ref/components/ref_selector_spec.js81
-rw-r--r--spec/frontend/ref/stores/actions_spec.js7
-rw-r--r--spec/frontend/ref/stores/mutations_spec.js10
-rw-r--r--spec/frontend/releases/__snapshots__/util_spec.js.snap8
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js30
-rw-r--r--spec/frontend/releases/components/app_index_spec.js29
-rw-r--r--spec/frontend/releases/components/app_show_spec.js15
-rw-r--r--spec/frontend/releases/components/asset_links_form_spec.js5
-rw-r--r--spec/frontend/releases/components/confirm_delete_modal_spec.js4
-rw-r--r--spec/frontend/releases/components/evidence_block_spec.js4
-rw-r--r--spec/frontend/releases/components/issuable_stats_spec.js5
-rw-r--r--spec/frontend/releases/components/release_block_assets_spec.js7
-rw-r--r--spec/frontend/releases/components/release_block_footer_spec.js5
-rw-r--r--spec/frontend/releases/components/release_block_header_spec.js4
-rw-r--r--spec/frontend/releases/components/release_block_milestone_info_spec.js5
-rw-r--r--spec/frontend/releases/components/release_block_spec.js4
-rw-r--r--spec/frontend/releases/components/releases_pagination_spec.js4
-rw-r--r--spec/frontend/releases/components/releases_sort_spec.js7
-rw-r--r--spec/frontend/releases/components/tag_create_spec.js107
-rw-r--r--spec/frontend/releases/components/tag_field_exsting_spec.js5
-rw-r--r--spec/frontend/releases/components/tag_field_new_spec.js231
-rw-r--r--spec/frontend/releases/components/tag_field_spec.js5
-rw-r--r--spec/frontend/releases/components/tag_search_spec.js144
-rw-r--r--spec/frontend/releases/release_notification_service_spec.js107
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js35
-rw-r--r--spec/frontend/releases/stores/modules/detail/getters_spec.js108
-rw-r--r--spec/frontend/repository/commits_service_spec.js4
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap2
-rw-r--r--spec/frontend/repository/components/blob_button_group_spec.js38
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js10
-rw-r--r--spec/frontend/repository/components/blob_controls_spec.js2
-rw-r--r--spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js2
-rw-r--r--spec/frontend/repository/components/blob_viewers/notebook_viewer_spec.js22
-rw-r--r--spec/frontend/repository/components/breadcrumbs_spec.js107
-rw-r--r--spec/frontend/repository/components/delete_blob_modal_spec.js16
-rw-r--r--spec/frontend/repository/components/directory_download_links_spec.js4
-rw-r--r--spec/frontend/repository/components/fork_info_spec.js292
-rw-r--r--spec/frontend/repository/components/fork_suggestion_spec.js2
-rw-r--r--spec/frontend/repository/components/fork_sync_conflicts_modal_spec.js46
-rw-r--r--spec/frontend/repository/components/last_commit_spec.js57
-rw-r--r--spec/frontend/repository/components/new_directory_modal_spec.js14
-rw-r--r--spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap42
-rw-r--r--spec/frontend/repository/components/preview/index_spec.js95
-rw-r--r--spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap3
-rw-r--r--spec/frontend/repository/components/table/index_spec.js4
-rw-r--r--spec/frontend/repository/components/table/parent_row_spec.js4
-rw-r--r--spec/frontend/repository/components/table/row_spec.js293
-rw-r--r--spec/frontend/repository/components/tree_content_spec.js269
-rw-r--r--spec/frontend/repository/components/upload_blob_modal_spec.js52
-rw-r--r--spec/frontend/repository/mixins/highlight_mixin_spec.js2
-rw-r--r--spec/frontend/repository/mock_data.js72
-rw-r--r--spec/frontend/repository/pages/blob_spec.js4
-rw-r--r--spec/frontend/repository/pages/index_spec.js2
-rw-r--r--spec/frontend/repository/pages/tree_spec.js2
-rw-r--r--spec/frontend/right_sidebar_spec.js6
-rw-r--r--spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap21
-rw-r--r--spec/frontend/saved_replies/components/list_item_spec.js22
-rw-r--r--spec/frontend/saved_replies/components/list_spec.js68
-rw-r--r--spec/frontend/scripts/frontend/__fixtures__/locale/de/converted.json21
-rw-r--r--spec/frontend/scripts/frontend/__fixtures__/locale/de/gitlab.po13
-rw-r--r--spec/frontend/scripts/frontend/po_to_json_spec.js244
-rw-r--r--spec/frontend/search/highlight_blob_search_result_spec.js6
-rw-r--r--spec/frontend/search/mock_data.js307
-rw-r--r--spec/frontend/search/sidebar/components/app_spec.js36
-rw-r--r--spec/frontend/search/sidebar/components/checkbox_filter_spec.js59
-rw-r--r--spec/frontend/search/sidebar/components/confidentiality_filter_spec.js35
-rw-r--r--spec/frontend/search/sidebar/components/filters_spec.js23
-rw-r--r--spec/frontend/search/sidebar/components/language_filter_spec.js222
-rw-r--r--spec/frontend/search/sidebar/components/language_filters_spec.js152
-rw-r--r--spec/frontend/search/sidebar/components/radio_filter_spec.js10
-rw-r--r--spec/frontend/search/sidebar/components/scope_navigation_spec.js57
-rw-r--r--spec/frontend/search/sidebar/components/scope_new_navigation_spec.js83
-rw-r--r--spec/frontend/search/sidebar/components/status_filter_spec.js35
-rw-r--r--spec/frontend/search/sort/components/app_spec.js5
-rw-r--r--spec/frontend/search/store/actions_spec.js48
-rw-r--r--spec/frontend/search/store/getters_spec.js55
-rw-r--r--spec/frontend/search/store/utils_spec.js50
-rw-r--r--spec/frontend/search/topbar/components/app_spec.js4
-rw-r--r--spec/frontend/search/topbar/components/group_filter_spec.js4
-rw-r--r--spec/frontend/search/topbar/components/project_filter_spec.js4
-rw-r--r--spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js4
-rw-r--r--spec/frontend/search/topbar/components/searchable_dropdown_spec.js49
-rw-r--r--spec/frontend/search_autocomplete_spec.js293
-rw-r--r--spec/frontend/search_autocomplete_utils_spec.js114
-rw-r--r--spec/frontend/search_settings/components/search_settings_spec.js4
-rw-r--r--spec/frontend/security_configuration/components/app_spec.js160
-rw-r--r--spec/frontend/security_configuration/components/auto_dev_ops_alert_spec.js4
-rw-r--r--spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js4
-rw-r--r--spec/frontend/security_configuration/components/feature_card_spec.js107
-rw-r--r--spec/frontend/security_configuration/components/training_provider_list_spec.js7
-rw-r--r--spec/frontend/security_configuration/components/upgrade_banner_spec.js107
-rw-r--r--spec/frontend/security_configuration/constants.js1
-rw-r--r--spec/frontend/security_configuration/mock_data.js32
-rw-r--r--spec/frontend/security_configuration/utils_spec.js38
-rw-r--r--spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap85
-rw-r--r--spec/frontend/self_monitor/components/self_monitor_form_spec.js95
-rw-r--r--spec/frontend/self_monitor/store/actions_spec.js254
-rw-r--r--spec/frontend/self_monitor/store/mutations_spec.js64
-rw-r--r--spec/frontend/sentry/index_spec.js52
-rw-r--r--spec/frontend/sentry/legacy_index_spec.js7
-rw-r--r--spec/frontend/sentry/sentry_browser_wrapper_spec.js2
-rw-r--r--spec/frontend/sentry/sentry_config_spec.js7
-rw-r--r--spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js58
-rw-r--r--spec/frontend/set_status_modal/user_profile_set_status_wrapper_spec.js4
-rw-r--r--spec/frontend/settings_panels_spec.js5
-rw-r--r--spec/frontend/shortcuts_spec.js135
-rw-r--r--spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js4
-rw-r--r--spec/frontend/sidebar/components/assignees/assignee_avatar_spec.js7
-rw-r--r--spec/frontend/sidebar/components/assignees/assignee_title_spec.js5
-rw-r--r--spec/frontend/sidebar/components/assignees/assignees_realtime_spec.js1
-rw-r--r--spec/frontend/sidebar/components/assignees/assignees_spec.js4
-rw-r--r--spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js4
-rw-r--r--spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js4
-rw-r--r--spec/frontend/sidebar/components/assignees/issuable_assignees_spec.js5
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js3
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js11
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js5
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js4
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js13
-rw-r--r--spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js4
-rw-r--r--spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js8
-rw-r--r--spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js4
-rw-r--r--spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js12
-rw-r--r--spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js7
-rw-r--r--spec/frontend/sidebar/components/copy/copyable_field_spec.js4
-rw-r--r--spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js8
-rw-r--r--spec/frontend/sidebar/components/crm_contacts/crm_contacts_spec.js5
-rw-r--r--spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js42
-rw-r--r--spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js4
-rw-r--r--spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js4
-rw-r--r--spec/frontend/sidebar/components/incidents/escalation_status_spec.js4
-rw-r--r--spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js6
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js4
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js101
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js354
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js18
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js4
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js6
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js4
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js4
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js13
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js8
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js40
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js16
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js32
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js4
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js8
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js4
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js4
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js4
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js22
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js11
-rw-r--r--spec/frontend/sidebar/components/lock/edit_form_buttons_spec.js15
-rw-r--r--spec/frontend/sidebar/components/lock/edit_form_spec.js5
-rw-r--r--spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js28
-rw-r--r--spec/frontend/sidebar/components/milestone/milestone_dropdown_spec.js4
-rw-r--r--spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js267
-rw-r--r--spec/frontend/sidebar/components/move/move_issue_button_spec.js10
-rw-r--r--spec/frontend/sidebar/components/move/move_issues_button_spec.js27
-rw-r--r--spec/frontend/sidebar/components/participants/participants_spec.js197
-rw-r--r--spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js1
-rw-r--r--spec/frontend/sidebar/components/reviewers/reviewer_title_spec.js5
-rw-r--r--spec/frontend/sidebar/components/reviewers/reviewers_spec.js4
-rw-r--r--spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js3
-rw-r--r--spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js153
-rw-r--r--spec/frontend/sidebar/components/severity/severity_spec.js7
-rw-r--r--spec/frontend/sidebar/components/severity/sidebar_severity_spec.js157
-rw-r--r--spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js160
-rw-r--r--spec/frontend/sidebar/components/sidebar_dropdown_spec.js4
-rw-r--r--spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js37
-rw-r--r--spec/frontend/sidebar/components/status/status_dropdown_spec.js4
-rw-r--r--spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js7
-rw-r--r--spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js4
-rw-r--r--spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js66
-rw-r--r--spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js4
-rw-r--r--spec/frontend/sidebar/components/time_tracking/report_spec.js5
-rw-r--r--spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js8
-rw-r--r--spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js7
-rw-r--r--spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js1
-rw-r--r--spec/frontend/sidebar/components/todo_toggle/todo_spec.js4
-rw-r--r--spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js6
-rw-r--r--spec/frontend/sidebar/mock_data.js12
-rw-r--r--spec/frontend/sidebar/sidebar_mediator_spec.js2
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap7
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap2
-rw-r--r--spec/frontend/snippets/components/edit_spec.js31
-rw-r--r--spec/frontend/snippets/components/embed_dropdown_spec.js5
-rw-r--r--spec/frontend/snippets/components/show_spec.js4
-rw-r--r--spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js5
-rw-r--r--spec/frontend/snippets/components/snippet_blob_edit_spec.js8
-rw-r--r--spec/frontend/snippets/components/snippet_blob_view_spec.js222
-rw-r--r--spec/frontend/snippets/components/snippet_description_edit_spec.js4
-rw-r--r--spec/frontend/snippets/components/snippet_description_view_spec.js4
-rw-r--r--spec/frontend/snippets/components/snippet_header_spec.js138
-rw-r--r--spec/frontend/snippets/components/snippet_title_spec.js4
-rw-r--r--spec/frontend/snippets/components/snippet_visibility_edit_spec.js4
-rw-r--r--spec/frontend/snippets/mock_data.js19
-rw-r--r--spec/frontend/streaming/chunk_writer_spec.js214
-rw-r--r--spec/frontend/streaming/handle_streamed_anchor_link_spec.js132
-rw-r--r--spec/frontend/streaming/html_stream_spec.js46
-rw-r--r--spec/frontend/streaming/rate_limit_stream_requests_spec.js155
-rw-r--r--spec/frontend/streaming/render_balancer_spec.js69
-rw-r--r--spec/frontend/streaming/render_html_streams_spec.js96
-rw-r--r--spec/frontend/super_sidebar/components/context_switcher_spec.js309
-rw-r--r--spec/frontend/super_sidebar/components/context_switcher_toggle_spec.js50
-rw-r--r--spec/frontend/super_sidebar/components/counter_spec.js11
-rw-r--r--spec/frontend/super_sidebar/components/create_menu_spec.js69
-rw-r--r--spec/frontend/super_sidebar/components/frequent_items_list_spec.js79
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js128
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js75
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js91
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js372
-rw-r--r--spec/frontend/super_sidebar/components/global_search/mock_data.js456
-rw-r--r--spec/frontend/super_sidebar/components/global_search/store/actions_spec.js111
-rw-r--r--spec/frontend/super_sidebar/components/global_search/store/getters_spec.js334
-rw-r--r--spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js63
-rw-r--r--spec/frontend/super_sidebar/components/global_search/utils_spec.js60
-rw-r--r--spec/frontend/super_sidebar/components/groups_list_spec.js90
-rw-r--r--spec/frontend/super_sidebar/components/help_center_spec.js163
-rw-r--r--spec/frontend/super_sidebar/components/items_list_spec.js101
-rw-r--r--spec/frontend/super_sidebar/components/menu_section_spec.js102
-rw-r--r--spec/frontend/super_sidebar/components/merge_request_menu_spec.js42
-rw-r--r--spec/frontend/super_sidebar/components/nav_item_link_spec.js37
-rw-r--r--spec/frontend/super_sidebar/components/nav_item_router_link_spec.js56
-rw-r--r--spec/frontend/super_sidebar/components/nav_item_spec.js156
-rw-r--r--spec/frontend/super_sidebar/components/pinned_section_spec.js75
-rw-r--r--spec/frontend/super_sidebar/components/projects_list_spec.js85
-rw-r--r--spec/frontend/super_sidebar/components/search_results_spec.js69
-rw-r--r--spec/frontend/super_sidebar/components/sidebar_menu_spec.js184
-rw-r--r--spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js207
-rw-r--r--spec/frontend/super_sidebar/components/sidebar_portal_spec.js68
-rw-r--r--spec/frontend/super_sidebar/components/super_sidebar_spec.js224
-rw-r--r--spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js106
-rw-r--r--spec/frontend/super_sidebar/components/user_bar_spec.js180
-rw-r--r--spec/frontend/super_sidebar/components/user_menu_spec.js502
-rw-r--r--spec/frontend/super_sidebar/components/user_name_group_spec.js114
-rw-r--r--spec/frontend/super_sidebar/mock_data.js224
-rw-r--r--spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js139
-rw-r--r--spec/frontend/super_sidebar/user_counts_manager_spec.js166
-rw-r--r--spec/frontend/super_sidebar/utils_spec.js171
-rw-r--r--spec/frontend/surveys/merge_request_performance/app_spec.js28
-rw-r--r--spec/frontend/syntax_highlight_spec.js6
-rw-r--r--spec/frontend/tags/components/delete_tag_modal_spec.js6
-rw-r--r--spec/frontend/tags/components/sort_dropdown_spec.js6
-rw-r--r--spec/frontend/terms/components/app_spec.js5
-rw-r--r--spec/frontend/terraform/components/empty_state_spec.js27
-rw-r--r--spec/frontend/terraform/components/init_command_modal_spec.js74
-rw-r--r--spec/frontend/terraform/components/states_table_actions_spec.js10
-rw-r--r--spec/frontend/terraform/components/states_table_spec.js7
-rw-r--r--spec/frontend/terraform/components/terraform_list_spec.js13
-rw-r--r--spec/frontend/test_setup.js13
-rw-r--r--spec/frontend/time_tracking/components/timelog_source_cell_spec.js136
-rw-r--r--spec/frontend/time_tracking/components/timelogs_app_spec.js238
-rw-r--r--spec/frontend/time_tracking/components/timelogs_table_spec.js223
-rw-r--r--spec/frontend/toggles/index_spec.js3
-rw-r--r--spec/frontend/token_access/inbound_token_access_spec.js4
-rw-r--r--spec/frontend/token_access/mock_data.js34
-rw-r--r--spec/frontend/token_access/opt_in_jwt_spec.js144
-rw-r--r--spec/frontend/token_access/outbound_token_access_spec.js72
-rw-r--r--spec/frontend/token_access/token_access_app_spec.js20
-rw-r--r--spec/frontend/token_access/token_projects_table_spec.js38
-rw-r--r--spec/frontend/tooltips/components/tooltips_spec.js5
-rw-r--r--spec/frontend/tracking/tracking_initialization_spec.js22
-rw-r--r--spec/frontend/tracking/tracking_spec.js91
-rw-r--r--spec/frontend/usage_quotas/components/usage_quotas_app_spec.js4
-rw-r--r--spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js4
-rw-r--r--spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js52
-rw-r--r--spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js5
-rw-r--r--spec/frontend/usage_quotas/storage/components/usage_graph_spec.js28
-rw-r--r--spec/frontend/usage_quotas/storage/mock_data.js26
-rw-r--r--spec/frontend/user_lists/components/edit_user_list_spec.js6
-rw-r--r--spec/frontend/user_lists/components/new_user_list_spec.js4
-rw-r--r--spec/frontend/user_lists/components/user_lists_spec.js7
-rw-r--r--spec/frontend/user_lists/components/user_lists_table_spec.js4
-rw-r--r--spec/frontend/user_lists/store/edit/actions_spec.js4
-rw-r--r--spec/frontend/user_lists/store/new/actions_spec.js4
-rw-r--r--spec/frontend/user_popovers_spec.js26
-rw-r--r--spec/frontend/validators/length_validator_spec.js91
-rw-r--r--spec/frontend/vue3migration/compiler_spec.js38
-rw-r--r--spec/frontend/vue3migration/components/comments_on_root_level.vue5
-rw-r--r--spec/frontend/vue3migration/components/default_slot_with_comment.vue18
-rw-r--r--spec/frontend/vue3migration/components/key_inside_template.vue7
-rw-r--r--spec/frontend/vue3migration/components/simple.vue10
-rw-r--r--spec/frontend/vue3migration/components/slot_with_comment.vue20
-rw-r--r--spec/frontend/vue3migration/components/slots_with_same_name.vue14
-rw-r--r--spec/frontend/vue3migration/components/v_once_inside_v_if.vue12
-rw-r--r--spec/frontend/vue_compat_test_setup.js141
-rw-r--r--spec/frontend/vue_merge_request_widget/components/action_buttons.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js257
-rw-r--r--spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js48
-rw-r--r--spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/extensions/child_content_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/components/extensions/status_icon_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_alert_message_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_author_spec.js1
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_container_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js245
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js7
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js353
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_related_links_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/review_app_link_spec.js9
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/commit_edit_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog_spec.js10
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_archived_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js2
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js6
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_checking_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js6
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_commits_header_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js16
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_missing_branch_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_not_allowed_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js62
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js7
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_sha_mismatch_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js44
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/new_ready_to_merge_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js6
-rw-r--r--spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap82
-rw-r--r--spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js2
-rw-r--r--spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js26
-rw-r--r--spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js10
-rw-r--r--spec/frontend/vue_merge_request_widget/deployment/deployment_list_spec.js1
-rw-r--r--spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js8
-rw-r--r--spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js98
-rw-r--r--spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js1
-rw-r--r--spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js3
-rw-r--r--spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js25
-rw-r--r--spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js14
-rw-r--r--spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js3
-rw-r--r--spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js5
-rw-r--r--spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js189
-rw-r--r--spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js2
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_details_spec.js10
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_management_sidebar_todo_spec.js4
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_metrics_spec.js63
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_status_spec.js6
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_summary_row_spec.js7
-rw-r--r--spec/frontend/vue_shared/alert_details/router_spec.js35
-rw-r--r--spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js157
-rw-r--r--spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js3
-rw-r--r--spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js6
-rw-r--r--spec/frontend/vue_shared/alert_details/system_notes/alert_management_system_note_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap8
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap40
-rw-r--r--spec/frontend/vue_shared/components/actions_button_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/alert_details_table_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/awards_list_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/changed_file_icon_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/chronic_duration_input_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/ci_badge_link_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/ci_icon_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/clipboard_button_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/clone_dropdown_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/code_block_highlighted_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/code_block_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/color_picker/color_picker_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js12
-rw-r--r--spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js12
-rw-r--r--spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/commit_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/confidentiality_badge_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/confirm_fork_modal_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/confirm_modal_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/content_transition_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js12
-rw-r--r--spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/utils_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js250
-rw-r--r--spec/frontend/vue_shared/components/dismissible_alert_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/dismissible_container_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/dom_element_listener_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js25
-rw-r--r--spec/frontend/vue_shared/components/ensure_data_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/entity_select/entity_select_spec.js18
-rw-r--r--spec/frontend/vue_shared/components/entity_select/project_select_spec.js18
-rw-r--r--spec/frontend/vue_shared/components/expand_button_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/file_finder/index_spec.js250
-rw-r--r--spec/frontend/vue_shared/components/file_finder/item_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/file_icon_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/file_row_header_spec.js28
-rw-r--r--spec/frontend/vue_shared/components/file_row_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/file_tree_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js98
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js89
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js140
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js111
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js129
-rw-r--r--spec/frontend/vue_shared/components/form/__snapshots__/form_footer_actions_spec.js.snap14
-rw-r--r--spec/frontend/vue_shared/components/form/form_footer_actions_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/form/title_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/gl_countdown_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/header_ci_component_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/help_popover_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/integration_help_text_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/keep_alive_slots_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/local_storage_sync_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js76
-rw-r--r--spec/frontend/vue_shared/components/markdown/drawio_toolbar_button_spec.js66
-rw-r--r--spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js58
-rw-r--r--spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js37
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js70
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_view_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/markdown/header_spec.js84
-rw-r--r--spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js310
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestions_spec.js54
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_spec.js19
-rw-r--r--spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/memory_graph_spec.js20
-rw-r--r--spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/metric_images/store/actions_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/modal_copy_button_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/navigation_tabs_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap4
-rw-r--r--spec/frontend/vue_shared/components/notes/noteable_warning_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/notes/placeholder_note_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/notes/system_note_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/ordered_layout_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/page_size_selector_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/paginated_list_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js69
-rw-r--r--spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/pagination_links_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/panel_resizer_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/papa_parse_alert_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/project_avatar_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js136
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_selector_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js266
-rw-r--r--spec/frontend/vue_shared/components/projects_list/projects_list_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap32
-rw-r--r--spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/registry/code_instruction_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/registry/details_row_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/registry/history_item_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/registry/list_item_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/registry/metadata_item_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/registry/registry_search_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/registry/title_area_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap23
-rw-r--r--spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap4
-rw-r--r--spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js64
-rw-r--r--spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/rich_timestamp_tooltip_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap3
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap3
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js81
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/security_reports/help_icon_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/security_reports/security_summary_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/segmented_control_button_group_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/settings/settings_block_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/slot_switch_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/smart_virtual_list_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/source_editor_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/split_button_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/stacked_progress_bar_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/table_pagination_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/time_ago_tooltip_spec.js26
-rw-r--r--spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js36
-rw-r--r--spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js36
-rw-r--r--spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js113
-rw-r--r--spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap14
-rw-r--r--spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/url_sync_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/user_callout_dismisser_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js16
-rw-r--r--spec/frontend/vue_shared/components/user_select_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/vuex_module_provider_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js158
-rw-r--r--spec/frontend/vue_shared/directives/track_event_spec.js61
-rw-r--r--spec/frontend/vue_shared/directives/validation_spec.js5
-rw-r--r--spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js4
-rw-r--r--spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js4
-rw-r--r--spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js37
-rw-r--r--spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js32
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js1
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js8
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js8
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js1
-rw-r--r--spec/frontend/vue_shared/issuable/list/mock_data.js6
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js149
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js4
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js7
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js24
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js4
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js28
-rw-r--r--spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js1
-rw-r--r--spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js2
-rw-r--r--spec/frontend/vue_shared/new_namespace/components/welcome_spec.js2
-rw-r--r--spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js33
-rw-r--r--spec/frontend/vue_shared/plugins/global_toast_spec.js22
-rw-r--r--spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js4
-rw-r--r--spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js42
-rw-r--r--spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js5
-rw-r--r--spec/frontend/vue_shared/security_reports/security_reports_app_spec.js8
-rw-r--r--spec/frontend/webhooks/components/form_url_app_spec.js4
-rw-r--r--spec/frontend/webhooks/components/form_url_mask_item_spec.js6
-rw-r--r--spec/frontend/webhooks/components/push_events_spec.js2
-rw-r--r--spec/frontend/webhooks/components/test_dropdown_spec.js13
-rw-r--r--spec/frontend/whats_new/components/app_spec.js3
-rw-r--r--spec/frontend/whats_new/components/feature_spec.js5
-rw-r--r--spec/frontend/whats_new/utils/get_drawer_body_height_spec.js4
-rw-r--r--spec/frontend/whats_new/utils/notification_spec.js5
-rw-r--r--spec/frontend/work_items/components/app_spec.js4
-rw-r--r--spec/frontend/work_items/components/item_state_spec.js4
-rw-r--r--spec/frontend/work_items/components/item_title_spec.js6
-rw-r--r--spec/frontend/work_items/components/notes/__snapshots__/work_item_note_replying_spec.js.snap2
-rw-r--r--spec/frontend/work_items/components/notes/activity_filter_spec.js74
-rw-r--r--spec/frontend/work_items/components/notes/work_item_activity_sort_filter_spec.js109
-rw-r--r--spec/frontend/work_items/components/notes/work_item_add_note_spec.js112
-rw-r--r--spec/frontend/work_items/components/notes/work_item_comment_form_spec.js100
-rw-r--r--spec/frontend/work_items/components/notes/work_item_discussion_spec.js44
-rw-r--r--spec/frontend/work_items/components/notes/work_item_history_only_filter_note_spec.js44
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_actions_spec.js207
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_spec.js137
-rw-r--r--spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js63
-rw-r--r--spec/frontend/work_items/components/widget_wrapper_spec.js2
-rw-r--r--spec/frontend/work_items/components/work_item_actions_spec.js235
-rw-r--r--spec/frontend/work_items/components/work_item_assignees_spec.js37
-rw-r--r--spec/frontend/work_items/components/work_item_award_emoji_spec.js170
-rw-r--r--spec/frontend/work_items/components/work_item_created_updated_spec.js85
-rw-r--r--spec/frontend/work_items/components/work_item_description_rendered_spec.js4
-rw-r--r--spec/frontend/work_items/components/work_item_description_spec.js56
-rw-r--r--spec/frontend/work_items/components/work_item_detail_modal_spec.js137
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js213
-rw-r--r--spec/frontend/work_items/components/work_item_due_date_spec.js4
-rw-r--r--spec/frontend/work_items/components/work_item_labels_spec.js58
-rw-r--r--spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js4
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js98
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js19
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js152
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js8
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js6
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_spec.js228
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js100
-rw-r--r--spec/frontend/work_items/components/work_item_milestone_spec.js64
-rw-r--r--spec/frontend/work_items/components/work_item_notes_spec.js158
-rw-r--r--spec/frontend/work_items/components/work_item_state_spec.js4
-rw-r--r--spec/frontend/work_items/components/work_item_title_spec.js4
-rw-r--r--spec/frontend/work_items/components/work_item_todos_spec.js97
-rw-r--r--spec/frontend/work_items/components/work_item_type_icon_spec.js6
-rw-r--r--spec/frontend/work_items/mock_data.js1248
-rw-r--r--spec/frontend/work_items/pages/create_work_item_spec.js25
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js6
-rw-r--r--spec/frontend/work_items/router_spec.js17
-rw-r--r--spec/frontend/work_items/utils_spec.js21
-rw-r--r--spec/frontend/work_items_hierarchy/components/app_spec.js4
-rw-r--r--spec/frontend/work_items_hierarchy/components/hierarchy_spec.js4
-rw-r--r--spec/frontend/zen_mode_spec.js37
-rw-r--r--spec/frontend_integration/README.md2
-rw-r--r--spec/frontend_integration/content_editor/content_editor_integration_spec.js8
-rw-r--r--spec/frontend_integration/ide/ide_integration_spec.js1
-rw-r--r--spec/frontend_integration/ide/user_opens_file_spec.js1
-rw-r--r--spec/frontend_integration/ide/user_opens_ide_spec.js5
-rw-r--r--spec/frontend_integration/ide/user_opens_mr_spec.js1
-rw-r--r--spec/frontend_integration/snippets/snippets_notes_spec.js7
-rw-r--r--spec/graphql/graphql_triggers_spec.rb52
-rw-r--r--spec/graphql/mutations/achievements/award_spec.rb53
-rw-r--r--spec/graphql/mutations/achievements/delete_spec.rb56
-rw-r--r--spec/graphql/mutations/achievements/revoke_spec.rb57
-rw-r--r--spec/graphql/mutations/achievements/update_spec.rb57
-rw-r--r--spec/graphql/mutations/alert_management/alerts/set_assignees_spec.rb1
-rw-r--r--spec/graphql/mutations/alert_management/alerts/todo/create_spec.rb1
-rw-r--r--spec/graphql/mutations/alert_management/create_alert_issue_spec.rb2
-rw-r--r--spec/graphql/mutations/alert_management/update_alert_status_spec.rb1
-rw-r--r--spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb52
-rw-r--r--spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb26
-rw-r--r--spec/graphql/mutations/container_repositories/destroy_spec.rb2
-rw-r--r--spec/graphql/mutations/container_repositories/destroy_tags_spec.rb4
-rw-r--r--spec/graphql/mutations/customer_relations/contacts/create_spec.rb12
-rw-r--r--spec/graphql/mutations/customer_relations/organizations/create_spec.rb2
-rw-r--r--spec/graphql/mutations/customer_relations/organizations/update_spec.rb12
-rw-r--r--spec/graphql/mutations/design_management/delete_spec.rb31
-rw-r--r--spec/graphql/mutations/environments/stop_spec.rb72
-rw-r--r--spec/graphql/mutations/members/bulk_update_base_spec.rb16
-rw-r--r--spec/graphql/mutations/release_asset_links/create_spec.rb2
-rw-r--r--spec/graphql/mutations/release_asset_links/delete_spec.rb14
-rw-r--r--spec/graphql/mutations/release_asset_links/update_spec.rb2
-rw-r--r--spec/graphql/mutations/work_items/update_spec.rb21
-rw-r--r--spec/graphql/resolvers/achievements/achievements_resolver_spec.rb44
-rw-r--r--spec/graphql/resolvers/base_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/blobs_resolver_spec.rb8
-rw-r--r--spec/graphql/resolvers/ci/all_jobs_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/ci/group_runners_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/ci/inherited_variables_resolver_spec.rb40
-rw-r--r--spec/graphql/resolvers/ci/jobs_resolver_spec.rb21
-rw-r--r--spec/graphql/resolvers/ci/project_runners_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/ci/runner_projects_resolver_spec.rb26
-rw-r--r--spec/graphql/resolvers/ci/runner_status_resolver_spec.rb28
-rw-r--r--spec/graphql/resolvers/ci/runners_resolver_spec.rb10
-rw-r--r--spec/graphql/resolvers/ci/variables_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb13
-rw-r--r--spec/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver_spec.rb28
-rw-r--r--spec/graphql/resolvers/clusters/agents/authorizations/user_access_resolver_spec.rb29
-rw-r--r--spec/graphql/resolvers/crm/contacts_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/crm/organization_state_counts_resolver_spec.rb10
-rw-r--r--spec/graphql/resolvers/crm/organizations_resolver_spec.rb44
-rw-r--r--spec/graphql/resolvers/data_transfer/group_data_transfer_resolver_spec.rb65
-rw-r--r--spec/graphql/resolvers/data_transfer/project_data_transfer_resolver_spec.rb68
-rw-r--r--spec/graphql/resolvers/data_transfer_resolver_spec.rb31
-rw-r--r--spec/graphql/resolvers/group_labels_resolver_spec.rb34
-rw-r--r--spec/graphql/resolvers/group_milestones_resolver_spec.rb66
-rw-r--r--spec/graphql/resolvers/labels_resolver_spec.rb34
-rw-r--r--spec/graphql/resolvers/metrics/dashboard_resolver_spec.rb14
-rw-r--r--spec/graphql/resolvers/metrics/dashboards/annotation_resolver_spec.rb16
-rw-r--r--spec/graphql/resolvers/paginated_tree_resolver_spec.rb12
-rw-r--r--spec/graphql/resolvers/project_issues_resolver_spec.rb10
-rw-r--r--spec/graphql/resolvers/project_milestones_resolver_spec.rb38
-rw-r--r--spec/graphql/resolvers/timelog_resolver_spec.rb22
-rw-r--r--spec/graphql/types/achievements/achievement_type_spec.rb1
-rw-r--r--spec/graphql/types/achievements/user_achievement_type_spec.rb24
-rw-r--r--spec/graphql/types/ci/catalog/resource_type_spec.rb18
-rw-r--r--spec/graphql/types/ci/config/include_type_enum_spec.rb2
-rw-r--r--spec/graphql/types/ci/inherited_ci_variable_type_spec.rb19
-rw-r--r--spec/graphql/types/ci/job_trace_type_spec.rb27
-rw-r--r--spec/graphql/types/ci/job_type_spec.rb9
-rw-r--r--spec/graphql/types/ci/runner_manager_type_spec.rb18
-rw-r--r--spec/graphql/types/ci/runner_type_spec.rb8
-rw-r--r--spec/graphql/types/ci/variable_sort_enum_spec.rb2
-rw-r--r--spec/graphql/types/clusters/agent_activity_event_type_spec.rb2
-rw-r--r--spec/graphql/types/clusters/agent_token_type_spec.rb2
-rw-r--r--spec/graphql/types/clusters/agent_type_spec.rb2
-rw-r--r--spec/graphql/types/clusters/agents/authorizations/ci_access_type_spec.rb11
-rw-r--r--spec/graphql/types/clusters/agents/authorizations/user_access_type_spec.rb11
-rw-r--r--spec/graphql/types/commit_signature_interface_spec.rb5
-rw-r--r--spec/graphql/types/commit_signatures/ssh_signature_type_spec.rb2
-rw-r--r--spec/graphql/types/data_transfer/project_data_transfer_type_spec.rb38
-rw-r--r--spec/graphql/types/design_management/design_at_version_type_spec.rb2
-rw-r--r--spec/graphql/types/design_management/design_type_spec.rb7
-rw-r--r--spec/graphql/types/group_type_spec.rb2
-rw-r--r--spec/graphql/types/issue_type_spec.rb45
-rw-r--r--spec/graphql/types/key_type_spec.rb2
-rw-r--r--spec/graphql/types/merge_request_type_spec.rb4
-rw-r--r--spec/graphql/types/permission_types/issue_spec.rb2
-rw-r--r--spec/graphql/types/permission_types/work_item_spec.rb3
-rw-r--r--spec/graphql/types/project_member_relation_enum_spec.rb3
-rw-r--r--spec/graphql/types/project_statistics_redirect_type_spec.rb10
-rw-r--r--spec/graphql/types/project_type_spec.rb13
-rw-r--r--spec/graphql/types/projects/fork_details_type_spec.rb2
-rw-r--r--spec/graphql/types/release_asset_link_type_spec.rb2
-rw-r--r--spec/graphql/types/root_storage_statistics_type_spec.rb2
-rw-r--r--spec/graphql/types/time_tracking/timelog_connection_type_spec.rb2
-rw-r--r--spec/graphql/types/timelog_type_spec.rb2
-rw-r--r--spec/graphql/types/user_preferences_type_spec.rb3
-rw-r--r--spec/graphql/types/user_type_spec.rb3
-rw-r--r--spec/graphql/types/visibility_pipeline_id_type_enum_spec.rb13
-rw-r--r--spec/graphql/types/work_item_type_spec.rb5
-rw-r--r--spec/graphql/types/work_items/available_export_fields_enum_spec.rb27
-rw-r--r--spec/graphql/types/work_items/widget_interface_spec.rb13
-rw-r--r--spec/graphql/types/work_items/widgets/award_emoji_type_spec.rb12
-rw-r--r--spec/graphql/types/work_items/widgets/current_user_todos_input_type_spec.rb9
-rw-r--r--spec/graphql/types/work_items/widgets/current_user_todos_type_spec.rb11
-rw-r--r--spec/graphql/types/work_items/widgets/hierarchy_update_input_type_spec.rb8
-rw-r--r--spec/graphql/types/work_items/widgets/notifications_type_spec.rb12
-rw-r--r--spec/graphql/types/work_items/widgets/notifications_update_input_type_spec.rb9
-rw-r--r--spec/haml_lint/linter/no_plain_nodes_spec.rb34
-rw-r--r--spec/helpers/abuse_reports_helper_spec.rb13
-rw-r--r--spec/helpers/access_tokens_helper_spec.rb2
-rw-r--r--spec/helpers/admin/abuse_reports_helper_spec.rb34
-rw-r--r--spec/helpers/analytics/cycle_analytics_helper_spec.rb61
-rw-r--r--spec/helpers/application_helper_spec.rb80
-rw-r--r--spec/helpers/application_settings_helper_spec.rb74
-rw-r--r--spec/helpers/artifacts_helper_spec.rb1
-rw-r--r--spec/helpers/avatars_helper_spec.rb159
-rw-r--r--spec/helpers/blame_helper_spec.rb12
-rw-r--r--spec/helpers/blob_helper_spec.rb26
-rw-r--r--spec/helpers/broadcast_messages_helper_spec.rb79
-rw-r--r--spec/helpers/ci/builds_helper_spec.rb59
-rw-r--r--spec/helpers/ci/catalog/resources_helper_spec.rb35
-rw-r--r--spec/helpers/ci/jobs_helper_spec.rb53
-rw-r--r--spec/helpers/ci/pipeline_editor_helper_spec.rb7
-rw-r--r--spec/helpers/ci/pipelines_helper_spec.rb36
-rw-r--r--spec/helpers/ci/runners_helper_spec.rb38
-rw-r--r--spec/helpers/ci/variables_helper_spec.rb2
-rw-r--r--spec/helpers/clusters_helper_spec.rb84
-rw-r--r--spec/helpers/commits_helper_spec.rb15
-rw-r--r--spec/helpers/device_registration_helper_spec.rb37
-rw-r--r--spec/helpers/diff_helper_spec.rb59
-rw-r--r--spec/helpers/emoji_helper_spec.rb22
-rw-r--r--spec/helpers/environment_helper_spec.rb18
-rw-r--r--spec/helpers/environments_helper_spec.rb18
-rw-r--r--spec/helpers/explore_helper_spec.rb2
-rw-r--r--spec/helpers/feature_flags_helper_spec.rb14
-rw-r--r--spec/helpers/groups/observability_helper_spec.rb91
-rw-r--r--spec/helpers/groups_helper_spec.rb7
-rw-r--r--spec/helpers/ide_helper_spec.rb191
-rw-r--r--spec/helpers/integrations_helper_spec.rb7
-rw-r--r--spec/helpers/issuables_helper_spec.rb117
-rw-r--r--spec/helpers/issues_helper_spec.rb26
-rw-r--r--spec/helpers/jira_connect_helper_spec.rb35
-rw-r--r--spec/helpers/labels_helper_spec.rb14
-rw-r--r--spec/helpers/markup_helper_spec.rb39
-rw-r--r--spec/helpers/merge_requests_helper_spec.rb72
-rw-r--r--spec/helpers/namespaces_helper_spec.rb37
-rw-r--r--spec/helpers/nav/new_dropdown_helper_spec.rb71
-rw-r--r--spec/helpers/nav/top_nav_helper_spec.rb58
-rw-r--r--spec/helpers/nav_helper_spec.rb76
-rw-r--r--spec/helpers/notes_helper_spec.rb13
-rw-r--r--spec/helpers/notify_helper_spec.rb17
-rw-r--r--spec/helpers/packages_helper_spec.rb140
-rw-r--r--spec/helpers/page_layout_helper_spec.rb21
-rw-r--r--spec/helpers/plan_limits_helper_spec.rb28
-rw-r--r--spec/helpers/profiles_helper_spec.rb44
-rw-r--r--spec/helpers/projects/ml/experiments_helper_spec.rb48
-rw-r--r--spec/helpers/projects/pipeline_helper_spec.rb3
-rw-r--r--spec/helpers/projects/settings/branch_rules_helper_spec.rb25
-rw-r--r--spec/helpers/projects_helper_spec.rb190
-rw-r--r--spec/helpers/protected_refs_helper_spec.rb51
-rw-r--r--spec/helpers/registrations_helper_spec.rb16
-rw-r--r--spec/helpers/routing/pseudonymization_helper_spec.rb228
-rw-r--r--spec/helpers/safe_format_helper_spec.rb41
-rw-r--r--spec/helpers/search_helper_spec.rb55
-rw-r--r--spec/helpers/sessions_helper_spec.rb34
-rw-r--r--spec/helpers/sidebars_helper_spec.rb422
-rw-r--r--spec/helpers/sorting_helper_spec.rb54
-rw-r--r--spec/helpers/storage_helper_spec.rb28
-rw-r--r--spec/helpers/todos_helper_spec.rb60
-rw-r--r--spec/helpers/tree_helper_spec.rb1
-rw-r--r--spec/helpers/users/callouts_helper_spec.rb70
-rw-r--r--spec/helpers/users/group_callouts_helper_spec.rb10
-rw-r--r--spec/helpers/users_helper_spec.rb103
-rw-r--r--spec/helpers/version_check_helper_spec.rb30
-rw-r--r--spec/helpers/visibility_level_helper_spec.rb25
-rw-r--r--spec/helpers/work_items_helper_spec.rb24
-rw-r--r--spec/initializers/active_record_transaction_observer_spec.rb49
-rw-r--r--spec/initializers/check_forced_decomposition_spec.rb8
-rw-r--r--spec/initializers/circuitbox_spec.rb15
-rw-r--r--spec/initializers/direct_upload_support_spec.rb4
-rw-r--r--spec/initializers/google_cloud_profiler_spec.rb87
-rw-r--r--spec/initializers/load_balancing_spec.rb2
-rw-r--r--spec/initializers/mail_starttls_patch_spec.rb86
-rw-r--r--spec/initializers/net_http_patch_spec.rb6
-rw-r--r--spec/initializers/net_http_response_patch_spec.rb10
-rw-r--r--spec/initializers/safe_session_store_patch_spec.rb62
-rw-r--r--spec/initializers/settings_spec.rb7
-rw-r--r--spec/lib/api/ci/helpers/runner_spec.rb74
-rw-r--r--spec/lib/api/entities/clusters/agent_authorization_spec.rb36
-rw-r--r--spec/lib/api/entities/clusters/agents/authorizations/ci_access_spec.rb36
-rw-r--r--spec/lib/api/entities/ml/mlflow/run_info_spec.rb4
-rw-r--r--spec/lib/api/entities/ml/mlflow/run_spec.rb2
-rw-r--r--spec/lib/api/entities/personal_access_token_spec.rb2
-rw-r--r--spec/lib/api/entities/plan_limit_spec.rb1
-rw-r--r--spec/lib/api/entities/project_job_token_scope_spec.rb38
-rw-r--r--spec/lib/api/entities/project_spec.rb22
-rw-r--r--spec/lib/api/entities/ssh_key_spec.rb2
-rw-r--r--spec/lib/api/entities/user_spec.rb52
-rw-r--r--spec/lib/api/github/entities_spec.rb2
-rw-r--r--spec/lib/api/helpers/internal_helpers_spec.rb60
-rw-r--r--spec/lib/api/helpers/members_helpers_spec.rb18
-rw-r--r--spec/lib/api/helpers/packages/npm_spec.rb133
-rw-r--r--spec/lib/api/helpers/packages_helpers_spec.rb3
-rw-r--r--spec/lib/api/helpers_spec.rb2
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb49
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/build_entity_spec.rb21
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/feature_flag_entity_spec.rb10
-rw-r--r--spec/lib/atlassian/jira_connect_spec.rb2
-rw-r--r--spec/lib/atlassian/jira_issue_key_extractor_spec.rb10
-rw-r--r--spec/lib/atlassian/jira_issue_key_extractors/branch_spec.rb57
-rw-r--r--spec/lib/backup/database_spec.rb111
-rw-r--r--spec/lib/backup/gitaly_backup_spec.rb22
-rw-r--r--spec/lib/backup/manager_spec.rb52
-rw-r--r--spec/lib/backup/repositories_spec.rb11
-rw-r--r--spec/lib/banzai/filter/blockquote_fence_filter_spec.rb4
-rw-r--r--spec/lib/banzai/filter/code_language_filter_spec.rb82
-rw-r--r--spec/lib/banzai/filter/commit_trailers_filter_spec.rb21
-rw-r--r--spec/lib/banzai/filter/external_link_filter_spec.rb32
-rw-r--r--spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb6
-rw-r--r--spec/lib/banzai/filter/inline_observability_filter_spec.rb88
-rw-r--r--spec/lib/banzai/filter/issuable_reference_expansion_filter_spec.rb196
-rw-r--r--spec/lib/banzai/filter/kroki_filter_spec.rb42
-rw-r--r--spec/lib/banzai/filter/markdown_engines/base_spec.rb17
-rw-r--r--spec/lib/banzai/filter/markdown_engines/common_mark_spec.rb17
-rw-r--r--spec/lib/banzai/filter/markdown_filter_spec.rb21
-rw-r--r--spec/lib/banzai/filter/math_filter_spec.rb11
-rw-r--r--spec/lib/banzai/filter/mermaid_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/plantuml_filter_spec.rb12
-rw-r--r--spec/lib/banzai/filter/references/design_reference_filter_spec.rb10
-rw-r--r--spec/lib/banzai/filter/references/issue_reference_filter_spec.rb9
-rw-r--r--spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb9
-rw-r--r--spec/lib/banzai/filter/references/reference_cache_spec.rb5
-rw-r--r--spec/lib/banzai/filter/references/work_item_reference_filter_spec.rb314
-rw-r--r--spec/lib/banzai/filter/repository_link_filter_spec.rb13
-rw-r--r--spec/lib/banzai/filter/suggestion_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/syntax_highlight_filter_spec.rb84
-rw-r--r--spec/lib/banzai/filter/timeout_html_pipeline_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/timeout_text_pipeline_filter_spec.rb15
-rw-r--r--spec/lib/banzai/issuable_extractor_spec.rb15
-rw-r--r--spec/lib/banzai/pipeline/gfm_pipeline_spec.rb2
-rw-r--r--spec/lib/banzai/pipeline/incident_management/timeline_event_pipeline_spec.rb6
-rw-r--r--spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb4
-rw-r--r--spec/lib/banzai/pipeline/wiki_pipeline_spec.rb40
-rw-r--r--spec/lib/banzai/reference_parser/commit_parser_spec.rb31
-rw-r--r--spec/lib/banzai/reference_parser/work_item_parser_spec.rb46
-rw-r--r--spec/lib/banzai/reference_redactor_spec.rb17
-rw-r--r--spec/lib/banzai/renderer_spec.rb4
-rw-r--r--spec/lib/bulk_imports/clients/graphql_spec.rb31
-rw-r--r--spec/lib/bulk_imports/clients/http_spec.rb37
-rw-r--r--spec/lib/bulk_imports/features_spec.rb43
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb18
-rw-r--r--spec/lib/bulk_imports/groups/stage_spec.rb58
-rw-r--r--spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb130
-rw-r--r--spec/lib/bulk_imports/ndjson_pipeline_spec.rb54
-rw-r--r--spec/lib/bulk_imports/projects/pipelines/ci_pipelines_pipeline_spec.rb8
-rw-r--r--spec/lib/bulk_imports/projects/pipelines/commit_notes_pipeline_spec.rb69
-rw-r--r--spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb2
-rw-r--r--spec/lib/bulk_imports/projects/pipelines/references_pipeline_spec.rb10
-rw-r--r--spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb92
-rw-r--r--spec/lib/container_registry/gitlab_api_client_spec.rb357
-rw-r--r--spec/lib/container_registry/path_spec.rb10
-rw-r--r--spec/lib/error_tracking/collector/payload_validator_spec.rb2
-rw-r--r--spec/lib/error_tracking/sentry_client/token_spec.rb21
-rw-r--r--spec/lib/feature_groups/gitlab_team_members_spec.rb65
-rw-r--r--spec/lib/feature_spec.rb57
-rw-r--r--spec/lib/generators/batched_background_migration/batched_background_migration_generator_spec.rb82
-rw-r--r--spec/lib/generators/batched_background_migration/expected_files/my_batched_migration.txt22
-rw-r--r--spec/lib/generators/batched_background_migration/expected_files/my_batched_migration_dictionary_matcher.txt6
-rw-r--r--spec/lib/generators/batched_background_migration/expected_files/my_batched_migration_spec_matcher.txt7
-rw-r--r--spec/lib/generators/batched_background_migration/expected_files/queue_my_batched_migration.txt28
-rw-r--r--spec/lib/generators/batched_background_migration/expected_files/queue_my_batched_migration_spec.txt26
-rw-r--r--spec/lib/generators/gitlab/snowplow_event_definition_generator_spec.rb26
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb4
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/request_params_spec.rb40
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/stage_event_spec.rb4
-rw-r--r--spec/lib/gitlab/api_authentication/token_resolver_spec.rb2
-rw-r--r--spec/lib/gitlab/app_logger_spec.rb20
-rw-r--r--spec/lib/gitlab/asciidoc_spec.rb12
-rw-r--r--spec/lib/gitlab/audit/auditor_spec.rb33
-rw-r--r--spec/lib/gitlab/auth/auth_finders_spec.rb13
-rw-r--r--spec/lib/gitlab/auth/o_auth/auth_hash_spec.rb81
-rw-r--r--spec/lib/gitlab/auth/o_auth/provider_spec.rb2
-rw-r--r--spec/lib/gitlab/auth/o_auth/user_spec.rb83
-rw-r--r--spec/lib/gitlab/auth/otp/strategies/duo_auth/manual_otp_spec.rb94
-rw-r--r--spec/lib/gitlab/auth/u2f_webauthn_converter_spec.rb29
-rw-r--r--spec/lib/gitlab/auth_spec.rb120
-rw-r--r--spec/lib/gitlab/avatar_cache_spec.rb54
-rw-r--r--spec/lib/gitlab/background_migration/backfill_admin_mode_scope_for_personal_access_tokens_spec.rb11
-rw-r--r--spec/lib/gitlab/background_migration/backfill_cluster_agents_has_vulnerabilities_spec.rb10
-rw-r--r--spec/lib/gitlab/background_migration/backfill_design_management_repositories_spec.rb68
-rw-r--r--spec/lib/gitlab/background_migration/backfill_environment_tiers_spec.rb10
-rw-r--r--spec/lib/gitlab/background_migration/backfill_group_features_spec.rb18
-rw-r--r--spec/lib/gitlab/background_migration/backfill_imported_issue_search_data_spec.rb16
-rw-r--r--spec/lib/gitlab/background_migration/backfill_namespace_details_spec.rb43
-rw-r--r--spec/lib/gitlab/background_migration/backfill_namespace_id_for_namespace_route_spec.rb35
-rw-r--r--spec/lib/gitlab/background_migration/backfill_namespace_id_of_vulnerability_reads_spec.rb17
-rw-r--r--spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children_spec.rb21
-rw-r--r--spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots_spec.rb21
-rw-r--r--spec/lib/gitlab/background_migration/backfill_partitioned_table_spec.rb140
-rw-r--r--spec/lib/gitlab/background_migration/backfill_prepared_at_merge_requests_spec.rb55
-rw-r--r--spec/lib/gitlab/background_migration/backfill_project_feature_package_registry_access_level_spec.rb17
-rw-r--r--spec/lib/gitlab/background_migration/backfill_project_member_namespace_id_spec.rb70
-rw-r--r--spec/lib/gitlab/background_migration/backfill_project_namespace_details_spec.rb47
-rw-r--r--spec/lib/gitlab/background_migration/backfill_project_wiki_repositories_spec.rb65
-rw-r--r--spec/lib/gitlab/background_migration/backfill_releases_author_id_spec.rb59
-rw-r--r--spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb68
-rw-r--r--spec/lib/gitlab/background_migration/backfill_upvotes_count_on_issues_spec.rb46
-rw-r--r--spec/lib/gitlab/background_migration/backfill_user_namespace_spec.rb39
-rw-r--r--spec/lib/gitlab/background_migration/backfill_vulnerability_reads_cluster_agent_spec.rb10
-rw-r--r--spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb29
-rw-r--r--spec/lib/gitlab/background_migration/batched_migration_job_spec.rb22
-rw-r--r--spec/lib/gitlab/background_migration/cleanup_orphaned_lfs_objects_projects_spec.rb85
-rw-r--r--spec/lib/gitlab/background_migration/cleanup_personal_access_tokens_with_nil_expires_at_spec.rb38
-rw-r--r--spec/lib/gitlab/background_migration/delete_orphaned_deployments_spec.rb54
-rw-r--r--spec/lib/gitlab/background_migration/delete_orphaned_packages_dependencies_spec.rb57
-rw-r--r--spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb142
-rw-r--r--spec/lib/gitlab/background_migration/drop_invalid_security_findings_spec.rb57
-rw-r--r--spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb126
-rw-r--r--spec/lib/gitlab/background_migration/encrypt_ci_trigger_token_spec.rb4
-rw-r--r--spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb46
-rw-r--r--spec/lib/gitlab/background_migration/fix_first_mentioned_in_commit_at_spec.rb166
-rw-r--r--spec/lib/gitlab/background_migration/fix_merge_request_diff_commit_users_spec.rb25
-rw-r--r--spec/lib/gitlab/background_migration/fix_vulnerability_reads_has_issues_spec.rb100
-rw-r--r--spec/lib/gitlab/background_migration/issues_internal_id_scope_updater_spec.rb90
-rw-r--r--spec/lib/gitlab/background_migration/migrate_evidences_for_vulnerability_findings_spec.rb119
-rw-r--r--spec/lib/gitlab/background_migration/migrate_human_user_type_spec.rb43
-rw-r--r--spec/lib/gitlab/background_migration/migrate_links_for_vulnerability_findings_spec.rb192
-rw-r--r--spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb413
-rw-r--r--spec/lib/gitlab/background_migration/migrate_project_taggings_context_from_tags_to_topics_spec.rb30
-rw-r--r--spec/lib/gitlab/background_migration/migrate_remediations_for_vulnerability_findings_spec.rb173
-rw-r--r--spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb67
-rw-r--r--spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb98
-rw-r--r--spec/lib/gitlab/background_migration/nullify_creator_id_column_of_orphaned_projects_spec.rb3
-rw-r--r--spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb35
-rw-r--r--spec/lib/gitlab/background_migration/populate_vulnerability_dismissal_fields_spec.rb114
-rw-r--r--spec/lib/gitlab/background_migration/prune_stale_project_export_jobs_spec.rb17
-rw-r--r--spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb530
-rw-r--r--spec/lib/gitlab/background_migration/remove_backfilled_job_artifacts_expire_at_spec.rb6
-rw-r--r--spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb171
-rw-r--r--spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/remove_project_group_link_with_missing_groups_spec.rb124
-rw-r--r--spec/lib/gitlab/background_migration/remove_self_managed_wiki_notes_spec.rb17
-rw-r--r--spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/reset_too_many_tags_skipped_registry_imports_spec.rb72
-rw-r--r--spec/lib/gitlab/background_migration/set_correct_vulnerability_state_spec.rb16
-rw-r--r--spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb33
-rw-r--r--spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb50
-rw-r--r--spec/lib/gitlab/background_migration/update_delayed_project_removal_to_null_for_user_namespaces_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/update_jira_tracker_data_deployment_type_based_on_url_spec.rb10
-rw-r--r--spec/lib/gitlab/background_migration/update_timelogs_project_id_spec.rb52
-rw-r--r--spec/lib/gitlab/background_migration/update_users_where_two_factor_auth_required_from_group_spec.rb84
-rw-r--r--spec/lib/gitlab/background_task_spec.rb4
-rw-r--r--spec/lib/gitlab/bare_repository_import/importer_spec.rb197
-rw-r--r--spec/lib/gitlab/bare_repository_import/repository_spec.rb123
-rw-r--r--spec/lib/gitlab/bitbucket_import/importer_spec.rb2
-rw-r--r--spec/lib/gitlab/bitbucket_import/project_creator_spec.rb4
-rw-r--r--spec/lib/gitlab/bullet/exclusions_spec.rb15
-rw-r--r--spec/lib/gitlab/cache/client_spec.rb162
-rw-r--r--spec/lib/gitlab/cache/metadata_spec.rb13
-rw-r--r--spec/lib/gitlab/cache/metrics_spec.rb5
-rw-r--r--spec/lib/gitlab/changes_list_spec.rb2
-rw-r--r--spec/lib/gitlab/chat/responder_spec.rb68
-rw-r--r--spec/lib/gitlab/checks/changes_access_spec.rb12
-rw-r--r--spec/lib/gitlab/checks/diff_check_spec.rb12
-rw-r--r--spec/lib/gitlab/ci/ansi2json/state_spec.rb68
-rw-r--r--spec/lib/gitlab/ci/ansi2json_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/badge/release/template_spec.rb23
-rw-r--r--spec/lib/gitlab/ci/build/auto_retry_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/build/cache_spec.rb47
-rw-r--r--spec/lib/gitlab/ci/build/context/build_spec.rb25
-rw-r--r--spec/lib/gitlab/ci/build/context/global_spec.rb11
-rw-r--r--spec/lib/gitlab/ci/build/hook_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/build/rules_spec.rb144
-rw-r--r--spec/lib/gitlab/ci/components/instance_path_spec.rb35
-rw-r--r--spec/lib/gitlab/ci/config/entry/cache_spec.rb26
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb78
-rw-r--r--spec/lib/gitlab/ci/config/entry/policy_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/config/entry/processable_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/config/entry/publish_spec.rb40
-rw-r--r--spec/lib/gitlab/ci/config/entry/pull_policy_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/reports_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/root_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/config/entry/trigger_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/external/context_spec.rb70
-rw-r--r--spec/lib/gitlab/ci/config/external/file/artifact_spec.rb43
-rw-r--r--spec/lib/gitlab/ci/config/external/file/base_spec.rb133
-rw-r--r--spec/lib/gitlab/ci/config/external/file/component_spec.rb35
-rw-r--r--spec/lib/gitlab/ci/config/external/file/local_spec.rb30
-rw-r--r--spec/lib/gitlab/ci/config/external/file/project_spec.rb71
-rw-r--r--spec/lib/gitlab/ci/config/external/file/remote_spec.rb41
-rw-r--r--spec/lib/gitlab/ci/config/external/file/template_spec.rb35
-rw-r--r--spec/lib/gitlab/ci/config/external/interpolator_spec.rb319
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper/base_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper/filter_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper/location_expander_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb68
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper/normalizer_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper/variables_expander_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper/verifier_spec.rb275
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper_spec.rb13
-rw-r--r--spec/lib/gitlab/ci/config/external/processor_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/config/external/rules_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/header/input_spec.rb70
-rw-r--r--spec/lib/gitlab/ci/config/header/root_spec.rb133
-rw-r--r--spec/lib/gitlab/ci/config/header/spec_spec.rb56
-rw-r--r--spec/lib/gitlab/ci/config/normalizer/number_strategy_spec.rb22
-rw-r--r--spec/lib/gitlab/ci/config/yaml/result_spec.rb54
-rw-r--r--spec/lib/gitlab/ci/config/yaml_spec.rb159
-rw-r--r--spec/lib/gitlab/ci/config_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/input/arguments/base_spec.rb19
-rw-r--r--spec/lib/gitlab/ci/input/arguments/default_spec.rb53
-rw-r--r--spec/lib/gitlab/ci/input/arguments/options_spec.rb54
-rw-r--r--spec/lib/gitlab/ci/input/arguments/required_spec.rb45
-rw-r--r--spec/lib/gitlab/ci/input/arguments/unknown_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/input/inputs_spec.rb126
-rw-r--r--spec/lib/gitlab/ci/interpolation/access_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/interpolation/block_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/interpolation/config_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/interpolation/context_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/interpolation/template_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/jwt_spec.rb17
-rw-r--r--spec/lib/gitlab/ci/jwt_v2_spec.rb78
-rw-r--r--spec/lib/gitlab/ci/lint_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/parsers/security/common_spec.rb8
-rw-r--r--spec/lib/gitlab/ci/parsers/security/sast_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/parsers/security/secret_detection_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb190
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb11
-rw-r--r--spec/lib/gitlab/ci/pipeline/duration_spec.rb156
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb48
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/build_spec.rb128
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/project_config/repository_spec.rb8
-rw-r--r--spec/lib/gitlab/ci/project_config/source_spec.rb8
-rw-r--r--spec/lib/gitlab/ci/reports/codequality_mr_diff_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/reports/security/scanner_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb163
-rw-r--r--spec/lib/gitlab/ci/runner_releases_spec.rb32
-rw-r--r--spec/lib/gitlab/ci/secure_files/cer_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/secure_files/mobile_provision_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/secure_files/p12_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/status/composite_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/status/processable/waiting_for_resource_spec.rb21
-rw-r--r--spec/lib/gitlab/ci/templates/Jobs/build_gitlab_ci_yaml_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/templates/Jobs/sast_iac_latest_gitlab_ci_yaml_spec.rb34
-rw-r--r--spec/lib/gitlab/ci/trace/chunked_io_spec.rb10
-rw-r--r--spec/lib/gitlab/ci/variables/builder/pipeline_spec.rb91
-rw-r--r--spec/lib/gitlab/ci/variables/builder_spec.rb165
-rw-r--r--spec/lib/gitlab/ci/variables/collection_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/yaml_processor/result_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb244
-rw-r--r--spec/lib/gitlab/color_schemes_spec.rb8
-rw-r--r--spec/lib/gitlab/color_spec.rb10
-rw-r--r--spec/lib/gitlab/config/entry/validators_spec.rb2
-rw-r--r--spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb220
-rw-r--r--spec/lib/gitlab/config/loader/yaml_spec.rb28
-rw-r--r--spec/lib/gitlab/config_checker/external_database_checker_spec.rb6
-rw-r--r--spec/lib/gitlab/console_spec.rb4
-rw-r--r--spec/lib/gitlab/consul/internal_spec.rb8
-rw-r--r--spec/lib/gitlab/content_security_policy/config_loader_spec.rb6
-rw-r--r--spec/lib/gitlab/data_builder/deployment_spec.rb3
-rw-r--r--spec/lib/gitlab/database/async_constraints/migration_helpers_spec.rb288
-rw-r--r--spec/lib/gitlab/database/async_constraints/postgres_async_constraint_validation_spec.rb109
-rw-r--r--spec/lib/gitlab/database/async_constraints/validators/check_constraint_spec.rb20
-rw-r--r--spec/lib/gitlab/database/async_constraints/validators/foreign_key_spec.rb35
-rw-r--r--spec/lib/gitlab/database/async_constraints/validators_spec.rb21
-rw-r--r--spec/lib/gitlab/database/async_constraints_spec.rb29
-rw-r--r--spec/lib/gitlab/database/async_foreign_keys/foreign_key_validator_spec.rb152
-rw-r--r--spec/lib/gitlab/database/async_foreign_keys/migration_helpers_spec.rb167
-rw-r--r--spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb52
-rw-r--r--spec/lib/gitlab/database/async_foreign_keys_spec.rb23
-rw-r--r--spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb86
-rw-r--r--spec/lib/gitlab/database/background_migration/batch_optimizer_spec.rb9
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_job_spec.rb151
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_spec.rb13
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb15
-rw-r--r--spec/lib/gitlab/database/background_migration/health_status/indicators/patroni_apdex_spec.rb148
-rw-r--r--spec/lib/gitlab/database/background_migration/health_status_spec.rb26
-rw-r--r--spec/lib/gitlab/database/background_migration_job_spec.rb20
-rw-r--r--spec/lib/gitlab/database/consistency_checker_spec.rb2
-rw-r--r--spec/lib/gitlab/database/dynamic_model_helpers_spec.rb44
-rw-r--r--spec/lib/gitlab/database/gitlab_schema_spec.rb80
-rw-r--r--spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb11
-rw-r--r--spec/lib/gitlab/database/load_balancing/logger_spec.rb13
-rw-r--r--spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb17
-rw-r--r--spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb78
-rw-r--r--spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb65
-rw-r--r--spec/lib/gitlab/database/load_balancing_spec.rb7
-rw-r--r--spec/lib/gitlab/database/lock_writes_manager_spec.rb24
-rw-r--r--spec/lib/gitlab/database/loose_foreign_keys_spec.rb49
-rw-r--r--spec/lib/gitlab/database/migration_helpers/automatic_lock_writes_on_tables_spec.rb25
-rw-r--r--spec/lib/gitlab/database/migration_helpers/convert_to_bigint_spec.rb35
-rw-r--r--spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb16
-rw-r--r--spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb4
-rw-r--r--spec/lib/gitlab/database/migration_helpers/v2_spec.rb79
-rw-r--r--spec/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers_spec.rb97
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb365
-rw-r--r--spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb44
-rw-r--r--spec/lib/gitlab/database/migrations/constraints_helpers_spec.rb35
-rw-r--r--spec/lib/gitlab/database/migrations/instrumentation_spec.rb19
-rw-r--r--spec/lib/gitlab/database/migrations/pg_backend_pid_spec.rb52
-rw-r--r--spec/lib/gitlab/database/migrations/runner_backoff/active_record_mixin_spec.rb106
-rw-r--r--spec/lib/gitlab/database/migrations/runner_backoff/communicator_spec.rb84
-rw-r--r--spec/lib/gitlab/database/migrations/runner_backoff/migration_helpers_spec.rb41
-rw-r--r--spec/lib/gitlab/database/migrations/runner_spec.rb2
-rw-r--r--spec/lib/gitlab/database/migrations/sidekiq_helpers_spec.rb264
-rw-r--r--spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb21
-rw-r--r--spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb2
-rw-r--r--spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb7
-rw-r--r--spec/lib/gitlab/database/partitioning/ci_sliding_list_strategy_spec.rb178
-rw-r--r--spec/lib/gitlab/database/partitioning/convert_table_to_first_list_partition_spec.rb273
-rw-r--r--spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb48
-rw-r--r--spec/lib/gitlab/database/partitioning/list/convert_table_spec.rb365
-rw-r--r--spec/lib/gitlab/database/partitioning/list/locking_configuration_spec.rb46
-rw-r--r--spec/lib/gitlab/database/partitioning/partition_manager_spec.rb35
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb7
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb122
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb225
-rw-r--r--spec/lib/gitlab/database/partitioning_spec.rb80
-rw-r--r--spec/lib/gitlab/database/pg_depend_spec.rb21
-rw-r--r--spec/lib/gitlab/database/postgres_foreign_key_spec.rb58
-rw-r--r--spec/lib/gitlab/database/postgres_partition_spec.rb14
-rw-r--r--spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb16
-rw-r--r--spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb17
-rw-r--r--spec/lib/gitlab/database/query_analyzers/prevent_cross_database_modification_spec.rb29
-rw-r--r--spec/lib/gitlab/database/reflection_spec.rb8
-rw-r--r--spec/lib/gitlab/database/reindexing_spec.rb4
-rw-r--r--spec/lib/gitlab/database/schema_validation/adapters/column_database_adapter_spec.rb72
-rw-r--r--spec/lib/gitlab/database/schema_validation/adapters/column_structure_sql_adapter_spec.rb78
-rw-r--r--spec/lib/gitlab/database/schema_validation/database_spec.rb96
-rw-r--r--spec/lib/gitlab/database/schema_validation/inconsistency_spec.rb96
-rw-r--r--spec/lib/gitlab/database/schema_validation/index_spec.rb22
-rw-r--r--spec/lib/gitlab/database/schema_validation/indexes_spec.rb56
-rw-r--r--spec/lib/gitlab/database/schema_validation/runner_spec.rb50
-rw-r--r--spec/lib/gitlab/database/schema_validation/schema_inconsistency_spec.rb17
-rw-r--r--spec/lib/gitlab/database/schema_validation/schema_objects/column_spec.rb25
-rw-r--r--spec/lib/gitlab/database/schema_validation/schema_objects/index_spec.rb11
-rw-r--r--spec/lib/gitlab/database/schema_validation/schema_objects/table_spec.rb45
-rw-r--r--spec/lib/gitlab/database/schema_validation/schema_objects/trigger_spec.rb11
-rw-r--r--spec/lib/gitlab/database/schema_validation/structure_sql_spec.rb66
-rw-r--r--spec/lib/gitlab/database/schema_validation/track_inconsistency_spec.rb82
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/base_validator_spec.rb36
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/different_definition_indexes_spec.rb8
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/different_definition_tables_spec.rb7
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/different_definition_triggers_spec.rb8
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/extra_indexes_spec.rb7
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/extra_table_columns_spec.rb7
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/extra_tables_spec.rb7
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/extra_triggers_spec.rb7
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/missing_indexes_spec.rb14
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/missing_table_columns_spec.rb7
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/missing_tables_spec.rb9
-rw-r--r--spec/lib/gitlab/database/schema_validation/validators/missing_triggers_spec.rb9
-rw-r--r--spec/lib/gitlab/database/tables_locker_spec.rb237
-rw-r--r--spec/lib/gitlab/database/tables_truncate_spec.rb20
-rw-r--r--spec/lib/gitlab/database/transaction_timeout_settings_spec.rb2
-rw-r--r--spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb4
-rw-r--r--spec/lib/gitlab/database/with_lock_retries_spec.rb4
-rw-r--r--spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb169
-rw-r--r--spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb315
-rw-r--r--spec/lib/gitlab/database_importers/self_monitoring/project/delete_service_spec.rb53
-rw-r--r--spec/lib/gitlab/database_spec.rb44
-rw-r--r--spec/lib/gitlab/diff/formatters/image_formatter_spec.rb10
-rw-r--r--spec/lib/gitlab/diff/highlight_cache_spec.rb16
-rw-r--r--spec/lib/gitlab/diff/highlight_spec.rb15
-rw-r--r--spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb88
-rw-r--r--spec/lib/gitlab/doorkeeper_secret_storing/secret/pbkdf2_sha512_spec.rb11
-rw-r--r--spec/lib/gitlab/email/handler/create_issue_handler_spec.rb2
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb22
-rw-r--r--spec/lib/gitlab/email/hook/silent_mode_interceptor_spec.rb74
-rw-r--r--spec/lib/gitlab/email/hook/validate_addresses_interceptor_spec.rb52
-rw-r--r--spec/lib/gitlab/email/html_to_markdown_parser_spec.rb12
-rw-r--r--spec/lib/gitlab/email/incoming_email_spec.rb34
-rw-r--r--spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb6
-rw-r--r--spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb6
-rw-r--r--spec/lib/gitlab/email/message/repository_push_spec.rb2
-rw-r--r--spec/lib/gitlab/email/receiver_spec.rb13
-rw-r--r--spec/lib/gitlab/email/reply_parser_spec.rb77
-rw-r--r--spec/lib/gitlab/email/service_desk_email_spec.rb53
-rw-r--r--spec/lib/gitlab/emoji_spec.rb17
-rw-r--r--spec/lib/gitlab/endpoint_attributes_spec.rb7
-rw-r--r--spec/lib/gitlab/error_tracking_spec.rb43
-rw-r--r--spec/lib/gitlab/etag_caching/middleware_spec.rb7
-rw-r--r--spec/lib/gitlab/exception_log_formatter_spec.rb6
-rw-r--r--spec/lib/gitlab/external_authorization/config_spec.rb2
-rw-r--r--spec/lib/gitlab/favicon_spec.rb12
-rw-r--r--spec/lib/gitlab/file_finder_spec.rb126
-rw-r--r--spec/lib/gitlab/fogbugz_import/project_creator_spec.rb6
-rw-r--r--spec/lib/gitlab/git/blame_mode_spec.rb62
-rw-r--r--spec/lib/gitlab/git/blame_pagination_spec.rb175
-rw-r--r--spec/lib/gitlab/git/commit_spec.rb38
-rw-r--r--spec/lib/gitlab/git/diff_collection_spec.rb20
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb201
-rw-r--r--spec/lib/gitlab/git/wraps_gitaly_errors_spec.rb81
-rw-r--r--spec/lib/gitlab/git_access_spec.rb30
-rw-r--r--spec/lib/gitlab/git_ref_validator_spec.rb5
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_service_spec.rb52
-rw-r--r--spec/lib/gitlab/gitaly_client/ref_service_spec.rb20
-rw-r--r--spec/lib/gitlab/gitaly_client/repository_service_spec.rb41
-rw-r--r--spec/lib/gitlab/gitaly_client/with_feature_flag_actors_spec.rb16
-rw-r--r--spec/lib/gitlab/github_import/bulk_importing_spec.rb232
-rw-r--r--spec/lib/gitlab/github_import/client_spec.rb78
-rw-r--r--spec/lib/gitlab/github_import/clients/proxy_spec.rb123
-rw-r--r--spec/lib/gitlab/github_import/importer/attachments/issues_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/importer/attachments/merge_requests_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/importer/attachments/releases_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/importer/collaborator_importer_spec.rb86
-rw-r--r--spec/lib/gitlab/github_import/importer/collaborators_importer_spec.rb156
-rw-r--r--spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb18
-rw-r--r--spec/lib/gitlab/github_import/importer/labels_importer_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/importer/milestones_importer_spec.rb6
-rw-r--r--spec/lib/gitlab/github_import/importer/note_attachments_importer_spec.rb45
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb84
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb314
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests/all_merged_by_importer_spec.rb65
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests/merged_by_importer_spec.rb88
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests/review_importer_spec.rb314
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests/review_requests_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests/reviews_importer_spec.rb95
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests_merged_by_importer_spec.rb61
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests_reviews_importer_spec.rb86
-rw-r--r--spec/lib/gitlab/github_import/importer/releases_importer_spec.rb6
-rw-r--r--spec/lib/gitlab/github_import/logger_spec.rb34
-rw-r--r--spec/lib/gitlab/github_import/markdown/attachment_spec.rb58
-rw-r--r--spec/lib/gitlab/github_import/parallel_scheduling_spec.rb89
-rw-r--r--spec/lib/gitlab/github_import/project_relation_type_spec.rb49
-rw-r--r--spec/lib/gitlab/github_import/representation/collaborator_spec.rb50
-rw-r--r--spec/lib/gitlab/github_import/representation/diff_note_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/representation/issue_event_spec.rb6
-rw-r--r--spec/lib/gitlab/github_import/representation/issue_spec.rb3
-rw-r--r--spec/lib/gitlab/github_import/representation/lfs_object_spec.rb3
-rw-r--r--spec/lib/gitlab/github_import/representation/note_spec.rb24
-rw-r--r--spec/lib/gitlab/github_import/representation/note_text_spec.rb110
-rw-r--r--spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/representation/pull_request_spec.rb3
-rw-r--r--spec/lib/gitlab/github_import/representation/pull_requests/review_requests_spec.rb23
-rw-r--r--spec/lib/gitlab/github_import/settings_spec.rb15
-rw-r--r--spec/lib/gitlab/github_import/user_finder_spec.rb35
-rw-r--r--spec/lib/gitlab/gitlab_import/client_spec.rb111
-rw-r--r--spec/lib/gitlab/gitlab_import/importer_spec.rb57
-rw-r--r--spec/lib/gitlab/gitlab_import/project_creator_spec.rb37
-rw-r--r--spec/lib/gitlab/gl_repository/identifier_spec.rb6
-rw-r--r--spec/lib/gitlab/gl_repository/repo_type_spec.rb23
-rw-r--r--spec/lib/gitlab/gl_repository_spec.rb9
-rw-r--r--spec/lib/gitlab/grape_logging/loggers/response_logger_spec.rb6
-rw-r--r--spec/lib/gitlab/graphql/authorize/authorize_resource_spec.rb10
-rw-r--r--spec/lib/gitlab/graphql/deprecations/deprecation_spec.rb2
-rw-r--r--spec/lib/gitlab/graphql/known_operations_spec.rb1
-rw-r--r--spec/lib/gitlab/graphql/loaders/lazy_relation_loader/registry_spec.rb24
-rw-r--r--spec/lib/gitlab/graphql/loaders/lazy_relation_loader/relation_proxy_spec.rb29
-rw-r--r--spec/lib/gitlab/graphql/loaders/lazy_relation_loader_spec.rb123
-rw-r--r--spec/lib/gitlab/graphql/subscriptions/action_cable_with_load_balancing_spec.rb138
-rw-r--r--spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb1
-rw-r--r--spec/lib/gitlab/graphql/tracers/timer_tracer_spec.rb2
-rw-r--r--spec/lib/gitlab/harbor/client_spec.rb12
-rw-r--r--spec/lib/gitlab/http_connection_adapter_spec.rb40
-rw-r--r--spec/lib/gitlab/i18n/pluralization_spec.rb53
-rw-r--r--spec/lib/gitlab/i18n_spec.rb17
-rw-r--r--spec/lib/gitlab/import/errors_spec.rb48
-rw-r--r--spec/lib/gitlab/import/logger_spec.rb32
-rw-r--r--spec/lib/gitlab/import/metrics_spec.rb130
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml155
-rw-r--r--spec/lib/gitlab/import_export/attribute_configuration_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/attributes_finder_spec.rb18
-rw-r--r--spec/lib/gitlab/import_export/attributes_permitter_spec.rb22
-rw-r--r--spec/lib/gitlab/import_export/base/relation_object_saver_spec.rb30
-rw-r--r--spec/lib/gitlab/import_export/command_line_util_spec.rb61
-rw-r--r--spec/lib/gitlab/import_export/config_spec.rb10
-rw-r--r--spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb12
-rw-r--r--spec/lib/gitlab/import_export/fork_spec.rb59
-rw-r--r--spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb92
-rw-r--r--spec/lib/gitlab/import_export/group/tree_restorer_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/import_export_equivalence_spec.rb67
-rw-r--r--spec/lib/gitlab/import_export/import_failure_service_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb32
-rw-r--r--spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb35
-rw-r--r--spec/lib/gitlab/import_export/json/legacy_reader/shared_example.rb102
-rw-r--r--spec/lib/gitlab/import_export/json/legacy_writer_spec.rb101
-rw-r--r--spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb40
-rw-r--r--spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/model_configuration_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/project/export_task_spec.rb8
-rw-r--r--spec/lib/gitlab/import_export/project/exported_relations_merger_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/project/import_task_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/project/object_builder_spec.rb7
-rw-r--r--spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb52
-rw-r--r--spec/lib/gitlab/import_export/project/tree_restorer_spec.rb213
-rw-r--r--spec/lib/gitlab/import_export/project/tree_saver_spec.rb61
-rw-r--r--spec/lib/gitlab/import_export/references_configuration_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml135
-rw-r--r--spec/lib/gitlab/import_sources_spec.rb20
-rw-r--r--spec/lib/gitlab/incoming_email_spec.rb34
-rw-r--r--spec/lib/gitlab/instrumentation/redis_base_spec.rb12
-rw-r--r--spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb35
-rw-r--r--spec/lib/gitlab/internal_post_receive/response_spec.rb2
-rw-r--r--spec/lib/gitlab/issuable_sorter_spec.rb42
-rw-r--r--spec/lib/gitlab/jira_import/issues_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/json_logger_spec.rb29
-rw-r--r--spec/lib/gitlab/jwt_authenticatable_spec.rb8
-rw-r--r--spec/lib/gitlab/kas/client_spec.rb29
-rw-r--r--spec/lib/gitlab/kas/user_access_spec.rb96
-rw-r--r--spec/lib/gitlab/kroki_spec.rb2
-rw-r--r--spec/lib/gitlab/kubernetes/config_map_spec.rb16
-rw-r--r--spec/lib/gitlab/kubernetes/helm/api_spec.rb269
-rw-r--r--spec/lib/gitlab/kubernetes/helm/pod_spec.rb89
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/base_command_spec.rb50
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/certificate_spec.rb28
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/delete_command_spec.rb38
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/init_command_spec.rb35
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/install_command_spec.rb183
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/patch_command_spec.rb87
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/reset_command_spec.rb32
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v3/base_command_spec.rb44
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v3/delete_command_spec.rb35
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v3/install_command_spec.rb168
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v3/patch_command_spec.rb81
-rw-r--r--spec/lib/gitlab/legacy_github_import/client_spec.rb4
-rw-r--r--spec/lib/gitlab/legacy_github_import/importer_spec.rb31
-rw-r--r--spec/lib/gitlab/legacy_github_import/project_creator_spec.rb4
-rw-r--r--spec/lib/gitlab/legacy_github_import/user_formatter_spec.rb23
-rw-r--r--spec/lib/gitlab/loggable_spec.rb68
-rw-r--r--spec/lib/gitlab/manifest_import/project_creator_spec.rb4
-rw-r--r--spec/lib/gitlab/memory/watchdog/monitor/rss_memory_limit_spec.rb3
-rw-r--r--spec/lib/gitlab/metrics/boot_time_tracker_spec.rb4
-rw-r--r--spec/lib/gitlab/metrics/dashboard/finder_spec.rb37
-rw-r--r--spec/lib/gitlab/metrics/dashboard/service_selector_spec.rb6
-rw-r--r--spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb4
-rw-r--r--spec/lib/gitlab/metrics/sidekiq_slis_spec.rb65
-rw-r--r--spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb126
-rw-r--r--spec/lib/gitlab/metrics/subscribers/active_record_spec.rb14
-rw-r--r--spec/lib/gitlab/metrics/subscribers/external_http_spec.rb47
-rw-r--r--spec/lib/gitlab/metrics/subscribers/load_balancing_spec.rb2
-rw-r--r--spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb27
-rw-r--r--spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb35
-rw-r--r--spec/lib/gitlab/middleware/compressed_json_spec.rb66
-rw-r--r--spec/lib/gitlab/middleware/go_spec.rb2
-rw-r--r--spec/lib/gitlab/middleware/multipart_spec.rb4
-rw-r--r--spec/lib/gitlab/middleware/request_context_spec.rb8
-rw-r--r--spec/lib/gitlab/monitor/demo_projects_spec.rb6
-rw-r--r--spec/lib/gitlab/multi_collection_paginator_spec.rb7
-rw-r--r--spec/lib/gitlab/nav/top_nav_menu_item_spec.rb3
-rw-r--r--spec/lib/gitlab/net_http_adapter_spec.rb3
-rw-r--r--spec/lib/gitlab/observability_spec.rb186
-rw-r--r--spec/lib/gitlab/octokit/middleware_spec.rb31
-rw-r--r--spec/lib/gitlab/omniauth_initializer_spec.rb8
-rw-r--r--spec/lib/gitlab/optimistic_locking_spec.rb13
-rw-r--r--spec/lib/gitlab/other_markup_spec.rb4
-rw-r--r--spec/lib/gitlab/pages/random_domain_spec.rb44
-rw-r--r--spec/lib/gitlab/pages/virtual_host_finder_spec.rb214
-rw-r--r--spec/lib/gitlab/patch/draw_route_spec.rb4
-rw-r--r--spec/lib/gitlab/patch/node_loader_spec.rb80
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb7
-rw-r--r--spec/lib/gitlab/phabricator_import/cache/map_spec.rb85
-rw-r--r--spec/lib/gitlab/phabricator_import/conduit/client_spec.rb59
-rw-r--r--spec/lib/gitlab/phabricator_import/conduit/maniphest_spec.rb39
-rw-r--r--spec/lib/gitlab/phabricator_import/conduit/response_spec.rb79
-rw-r--r--spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb27
-rw-r--r--spec/lib/gitlab/phabricator_import/conduit/user_spec.rb49
-rw-r--r--spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb21
-rw-r--r--spec/lib/gitlab/phabricator_import/importer_spec.rb35
-rw-r--r--spec/lib/gitlab/phabricator_import/issues/importer_spec.rb61
-rw-r--r--spec/lib/gitlab/phabricator_import/issues/task_importer_spec.rb83
-rw-r--r--spec/lib/gitlab/phabricator_import/project_creator_spec.rb59
-rw-r--r--spec/lib/gitlab/phabricator_import/representation/task_spec.rb49
-rw-r--r--spec/lib/gitlab/phabricator_import/representation/user_spec.rb28
-rw-r--r--spec/lib/gitlab/phabricator_import/user_finder_spec.rb93
-rw-r--r--spec/lib/gitlab/phabricator_import/worker_state_spec.rb49
-rw-r--r--spec/lib/gitlab/project_authorizations_spec.rb567
-rw-r--r--spec/lib/gitlab/prometheus/internal_spec.rb4
-rw-r--r--spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb31
-rw-r--r--spec/lib/gitlab/quick_actions/extractor_spec.rb41
-rw-r--r--spec/lib/gitlab/rack_attack/instrumented_cache_store_spec.rb89
-rw-r--r--spec/lib/gitlab/rack_attack/request_spec.rb2
-rw-r--r--spec/lib/gitlab/rack_attack/store_spec.rb113
-rw-r--r--spec/lib/gitlab/reactive_cache_set_cache_spec.rb28
-rw-r--r--spec/lib/gitlab/redis/cache_spec.rb26
-rw-r--r--spec/lib/gitlab/redis/cluster_rate_limiting_spec.rb7
-rw-r--r--spec/lib/gitlab/redis/db_load_balancing_spec.rb8
-rw-r--r--spec/lib/gitlab/redis/feature_flag_spec.rb13
-rw-r--r--spec/lib/gitlab/redis/multi_store_spec.rb251
-rw-r--r--spec/lib/gitlab/redis/queues_spec.rb9
-rw-r--r--spec/lib/gitlab/redis/rate_limiting_spec.rb15
-rw-r--r--spec/lib/gitlab/redis/repository_cache_spec.rb23
-rw-r--r--spec/lib/gitlab/redis/shared_state_spec.rb9
-rw-r--r--spec/lib/gitlab/redis/sidekiq_status_spec.rb9
-rw-r--r--spec/lib/gitlab/reference_extractor_spec.rb6
-rw-r--r--spec/lib/gitlab/regex_spec.rb99
-rw-r--r--spec/lib/gitlab/repository_set_cache_spec.rb64
-rw-r--r--spec/lib/gitlab/request_context_spec.rb40
-rw-r--r--spec/lib/gitlab/resource_events/assignment_event_recorder_spec.rb91
-rw-r--r--spec/lib/gitlab/runtime_spec.rb18
-rw-r--r--spec/lib/gitlab/safe_device_detector_spec.rb2
-rw-r--r--spec/lib/gitlab/sanitizers/exception_message_spec.rb3
-rw-r--r--spec/lib/gitlab/seeders/ci/runner/runner_fleet_seeder_spec.rb24
-rw-r--r--spec/lib/gitlab/seeders/ci/variables_group_seeder_spec.rb92
-rw-r--r--spec/lib/gitlab/seeders/ci/variables_instance_seeder_spec.rb54
-rw-r--r--spec/lib/gitlab/seeders/ci/variables_project_seeder_spec.rb92
-rw-r--r--spec/lib/gitlab/seeders/project_environment_seeder_spec.rb52
-rw-r--r--spec/lib/gitlab/serverless/service_spec.rb136
-rw-r--r--spec/lib/gitlab/service_desk_email_spec.rb53
-rw-r--r--spec/lib/gitlab/service_desk_spec.rb8
-rw-r--r--spec/lib/gitlab/sidekiq_config/worker_router_spec.rb30
-rw-r--r--spec/lib/gitlab/sidekiq_config_spec.rb21
-rw-r--r--spec/lib/gitlab/sidekiq_daemon/memory_killer_spec.rb562
-rw-r--r--spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb4
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb7
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb8
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb81
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb8
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb6
-rw-r--r--spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb8
-rw-r--r--spec/lib/gitlab/sidekiq_queue_spec.rb10
-rw-r--r--spec/lib/gitlab/slash_commands/global_slack_handler_spec.rb91
-rw-r--r--spec/lib/gitlab/slug/environment_spec.rb59
-rw-r--r--spec/lib/gitlab/slug/path_spec.rb2
-rw-r--r--spec/lib/gitlab/source_spec.rb57
-rw-r--r--spec/lib/gitlab/spamcheck/client_spec.rb75
-rw-r--r--spec/lib/gitlab/spamcheck/result_spec.rb43
-rw-r--r--spec/lib/gitlab/subscription_portal_spec.rb85
-rw-r--r--spec/lib/gitlab/template/finders/global_template_finder_spec.rb10
-rw-r--r--spec/lib/gitlab/timeless_spec.rb37
-rw-r--r--spec/lib/gitlab/tracking/destinations/database_events_snowplow_spec.rb136
-rw-r--r--spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb2
-rw-r--r--spec/lib/gitlab/tracking/event_definition_spec.rb2
-rw-r--r--spec/lib/gitlab/tracking/standard_context_spec.rb6
-rw-r--r--spec/lib/gitlab/tracking_spec.rb125
-rw-r--r--spec/lib/gitlab/untrusted_regexp_spec.rb59
-rw-r--r--spec/lib/gitlab/url_blocker_spec.rb235
-rw-r--r--spec/lib/gitlab/url_blockers/ip_allowlist_entry_spec.rb24
-rw-r--r--spec/lib/gitlab/url_builder_spec.rb26
-rw-r--r--spec/lib/gitlab/usage/metric_definition_spec.rb2
-rw-r--r--spec/lib/gitlab/usage/metric_spec.rb1
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb119
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_ci_internal_pipelines_metric_spec.rb26
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_group_type_active_metric_spec.rb17
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_group_type_active_online_metric_spec.rb18
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_instance_type_active_metric_spec.rb13
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_instance_type_active_online_metric_spec.rb13
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_metric_spec.rb13
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_project_type_active_metric_spec.rb17
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_project_type_active_online_metric_spec.rb18
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_issues_created_manually_from_alerts_metric_spec.rb6
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/database_mode_spec.rb9
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/edition_metric_spec.rb13
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/gitlab_dedicated_metric_spec.rb9
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/incoming_email_encrypted_secrets_enabled_metric_spec.rb2
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/index_inconsistencies_metric_spec.rb30
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/installation_creation_date_approximation_metric_spec.rb22
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/installation_creation_date_metric_spec.rb20
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/installation_type_metric_spec.rb21
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/service_desk_email_encrypted_secrets_enabled_metric_spec.rb2
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/version_metric_spec.rb9
-rw-r--r--spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb2
-rw-r--r--spec/lib/gitlab/usage/service_ping_report_spec.rb29
-rw-r--r--spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb16
-rw-r--r--spec/lib/gitlab/usage_data_counters/container_registry_event_counter_spec.rb11
-rw-r--r--spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb26
-rw-r--r--spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb13
-rw-r--r--spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb204
-rw-r--r--spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb79
-rw-r--r--spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb2
-rw-r--r--spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb72
-rw-r--r--spec/lib/gitlab/usage_data_metrics_spec.rb12
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb123
-rw-r--r--spec/lib/gitlab/user_access_spec.rb6
-rw-r--r--spec/lib/gitlab/utils/email_spec.rb32
-rw-r--r--spec/lib/gitlab/utils/error_message_spec.rb28
-rw-r--r--spec/lib/gitlab/utils/measuring_spec.rb2
-rw-r--r--spec/lib/gitlab/utils/nokogiri_spec.rb4
-rw-r--r--spec/lib/gitlab/utils/strong_memoize_spec.rb69
-rw-r--r--spec/lib/gitlab/utils/uniquify_spec.rb44
-rw-r--r--spec/lib/gitlab/utils/usage_data_spec.rb4
-rw-r--r--spec/lib/gitlab/utils/username_and_email_generator_spec.rb24
-rw-r--r--spec/lib/gitlab/workhorse_spec.rb32
-rw-r--r--spec/lib/gitlab_settings/options_spec.rb155
-rw-r--r--spec/lib/gitlab_settings/settings_spec.rb67
-rw-r--r--spec/lib/gitlab_spec.rb22
-rw-r--r--spec/lib/json_web_token/hmac_token_spec.rb4
-rw-r--r--spec/lib/object_storage/config_spec.rb9
-rw-r--r--spec/lib/object_storage/direct_upload_spec.rb2
-rw-r--r--spec/lib/object_storage/pending_direct_upload_spec.rb70
-rw-r--r--spec/lib/product_analytics/settings_spec.rb101
-rw-r--r--spec/lib/product_analytics/tracker_spec.rb8
-rw-r--r--spec/lib/security/weak_passwords_spec.rb2
-rw-r--r--spec/lib/service_ping/build_payload_spec.rb8
-rw-r--r--spec/lib/sidebars/admin/menus/abuse_reports_menu_spec.rb42
-rw-r--r--spec/lib/sidebars/admin/menus/admin_overview_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/admin/menus/admin_settings_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/admin/menus/analytics_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/admin/menus/applications_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/admin/menus/ci_cd_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/admin/menus/deploy_keys_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/admin/menus/labels_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/admin/menus/messages_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/admin/menus/monitoring_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/admin/menus/system_hooks_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/admin/panel_spec.rb31
-rw-r--r--spec/lib/sidebars/concerns/super_sidebar_panel_spec.rb141
-rw-r--r--spec/lib/sidebars/groups/menus/group_information_menu_spec.rb6
-rw-r--r--spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb55
-rw-r--r--spec/lib/sidebars/groups/menus/issues_menu_spec.rb14
-rw-r--r--spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb11
-rw-r--r--spec/lib/sidebars/groups/menus/merge_requests_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/groups/menus/observability_menu_spec.rb60
-rw-r--r--spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb4
-rw-r--r--spec/lib/sidebars/groups/menus/scope_menu_spec.rb16
-rw-r--r--spec/lib/sidebars/groups/menus/settings_menu_spec.rb6
-rw-r--r--spec/lib/sidebars/groups/super_sidebar_menus/analyze_menu_spec.rb28
-rw-r--r--spec/lib/sidebars/groups/super_sidebar_menus/build_menu_spec.rb21
-rw-r--r--spec/lib/sidebars/groups/super_sidebar_menus/manage_menu_spec.rb23
-rw-r--r--spec/lib/sidebars/groups/super_sidebar_menus/monitor_menu_spec.rb22
-rw-r--r--spec/lib/sidebars/groups/super_sidebar_menus/operations_menu_spec.rb24
-rw-r--r--spec/lib/sidebars/groups/super_sidebar_menus/plan_menu_spec.rb30
-rw-r--r--spec/lib/sidebars/groups/super_sidebar_menus/secure_menu_spec.rb25
-rw-r--r--spec/lib/sidebars/groups/super_sidebar_panel_spec.rb55
-rw-r--r--spec/lib/sidebars/menu_item_spec.rb10
-rw-r--r--spec/lib/sidebars/menu_spec.rb132
-rw-r--r--spec/lib/sidebars/panel_spec.rb28
-rw-r--r--spec/lib/sidebars/projects/menus/ci_cd_menu_spec.rb16
-rw-r--r--spec/lib/sidebars/projects/menus/confluence_menu_spec.rb9
-rw-r--r--spec/lib/sidebars/projects/menus/deployments_menu_spec.rb8
-rw-r--r--spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb84
-rw-r--r--spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb52
-rw-r--r--spec/lib/sidebars/projects/menus/issues_menu_spec.rb25
-rw-r--r--spec/lib/sidebars/projects/menus/merge_requests_menu_spec.rb27
-rw-r--r--spec/lib/sidebars/projects/menus/monitor_menu_spec.rb4
-rw-r--r--spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb29
-rw-r--r--spec/lib/sidebars/projects/menus/project_information_menu_spec.rb6
-rw-r--r--spec/lib/sidebars/projects/menus/repository_menu_spec.rb47
-rw-r--r--spec/lib/sidebars/projects/menus/scope_menu_spec.rb14
-rw-r--r--spec/lib/sidebars/projects/menus/security_compliance_menu_spec.rb4
-rw-r--r--spec/lib/sidebars/projects/menus/settings_menu_spec.rb6
-rw-r--r--spec/lib/sidebars/projects/menus/snippets_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/projects/menus/wiki_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/projects/panel_spec.rb2
-rw-r--r--spec/lib/sidebars/projects/super_sidebar_menus/analyze_menu_spec.rb30
-rw-r--r--spec/lib/sidebars/projects/super_sidebar_menus/build_menu_spec.rb29
-rw-r--r--spec/lib/sidebars/projects/super_sidebar_menus/code_menu_spec.rb29
-rw-r--r--spec/lib/sidebars/projects/super_sidebar_menus/manage_menu_spec.rb23
-rw-r--r--spec/lib/sidebars/projects/super_sidebar_menus/monitor_menu_spec.rb27
-rw-r--r--spec/lib/sidebars/projects/super_sidebar_menus/operations_menu_spec.rb27
-rw-r--r--spec/lib/sidebars/projects/super_sidebar_menus/plan_menu_spec.rb26
-rw-r--r--spec/lib/sidebars/projects/super_sidebar_menus/secure_menu_spec.rb29
-rw-r--r--spec/lib/sidebars/projects/super_sidebar_panel_spec.rb66
-rw-r--r--spec/lib/sidebars/search/panel_spec.rb31
-rw-r--r--spec/lib/sidebars/static_menu_spec.rb44
-rw-r--r--spec/lib/sidebars/uncategorized_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/user_profile/menus/activity_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/user_profile/menus/contributed_projects_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/user_profile/menus/followers_menu_spec.rb14
-rw-r--r--spec/lib/sidebars/user_profile/menus/following_menu_spec.rb14
-rw-r--r--spec/lib/sidebars/user_profile/menus/groups_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/user_profile/menus/overview_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/user_profile/menus/personal_projects_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/user_profile/menus/snippets_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/user_profile/menus/starred_projects_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/user_profile/panel_spec.rb26
-rw-r--r--spec/lib/sidebars/user_settings/menus/access_tokens_menu_spec.rb65
-rw-r--r--spec/lib/sidebars/user_settings/menus/account_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/active_sessions_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/applications_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/authentication_log_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/chat_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/comment_templates_menu_spec.rb65
-rw-r--r--spec/lib/sidebars/user_settings/menus/emails_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/gpg_keys_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/notifications_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/password_menu_spec.rb38
-rw-r--r--spec/lib/sidebars/user_settings/menus/preferences_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/profile_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/menus/ssh_keys_menu_spec.rb13
-rw-r--r--spec/lib/sidebars/user_settings/panel_spec.rb17
-rw-r--r--spec/lib/sidebars/your_work/panel_spec.rb17
-rw-r--r--spec/lib/slack/api_spec.rb41
-rw-r--r--spec/lib/slack/block_kit/app_home_opened_spec.rb62
-rw-r--r--spec/lib/slack/block_kit/incident_management/incident_modal_opened_spec.rb41
-rw-r--r--spec/lib/unnested_in_filters/rewriter_spec.rb26
-rw-r--r--spec/lib/uploaded_file_spec.rb42
-rw-r--r--spec/mailers/emails/in_product_marketing_spec.rb6
-rw-r--r--spec/mailers/emails/issues_spec.rb36
-rw-r--r--spec/mailers/emails/merge_requests_spec.rb15
-rw-r--r--spec/mailers/emails/pages_domains_spec.rb10
-rw-r--r--spec/mailers/emails/pipelines_spec.rb18
-rw-r--r--spec/mailers/emails/profile_spec.rb38
-rw-r--r--spec/mailers/emails/service_desk_spec.rb347
-rw-r--r--spec/mailers/emails/work_items_spec.rb17
-rw-r--r--spec/mailers/notify_spec.rb217
-rw-r--r--spec/metrics_server/metrics_server_spec.rb4
-rw-r--r--spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb69
-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/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb23
-rw-r--r--spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb23
-rw-r--r--spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb18
-rw-r--r--spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb48
-rw-r--r--spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb47
-rw-r--r--spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb23
-rw-r--r--spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb47
-rw-r--r--spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb23
-rw-r--r--spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb21
-rw-r--r--spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb21
-rw-r--r--spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb21
-rw-r--r--spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb29
-rw-r--r--spec/migrations/20211012134316_clean_up_migrate_merge_request_diff_commit_users_spec.rb48
-rw-r--r--spec/migrations/20211018152654_schedule_remove_duplicate_vulnerabilities_findings3_spec.rb166
-rw-r--r--spec/migrations/20211028155449_schedule_fix_merge_request_diff_commit_users_migration_spec.rb63
-rw-r--r--spec/migrations/20211101222614_consume_remaining_user_namespace_jobs_spec.rb21
-rw-r--r--spec/migrations/20211110143306_add_not_null_constraint_to_security_findings_uuid_spec.rb23
-rw-r--r--spec/migrations/20211110151350_schedule_drop_invalid_security_findings_spec.rb72
-rw-r--r--spec/migrations/20211116091751_change_namespace_type_default_to_user_spec.rb5
-rw-r--r--spec/migrations/20211116111644_schedule_remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb190
-rw-r--r--spec/migrations/20211117084814_migrate_remaining_u2f_registrations_spec.rb43
-rw-r--r--spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb78
-rw-r--r--spec/migrations/20211126204445_add_task_to_work_item_types_spec.rb54
-rw-r--r--spec/migrations/20211130165043_backfill_sequence_column_for_sprints_table_spec.rb42
-rw-r--r--spec/migrations/20211207125331_remove_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb2
-rw-r--r--spec/migrations/20220124130028_dedup_runner_projects_spec.rb2
-rw-r--r--spec/migrations/20220128155251_remove_dangling_running_builds_spec.rb2
-rw-r--r--spec/migrations/20220307192610_remove_duplicate_project_tag_releases_spec.rb4
-rw-r--r--spec/migrations/20220309084954_remove_leftover_external_pull_request_deletions_spec.rb2
-rw-r--r--spec/migrations/20220310141349_remove_dependency_list_usage_data_from_redis_spec.rb2
-rw-r--r--spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb5
-rw-r--r--spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb2
-rw-r--r--spec/migrations/20220505044348_fix_automatic_iterations_cadences_start_date_spec.rb12
-rw-r--r--spec/migrations/20220513043344_reschedule_expire_o_auth_tokens_spec.rb2
-rw-r--r--spec/migrations/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events_spec.rb2
-rw-r--r--spec/migrations/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb2
-rw-r--r--spec/migrations/20220607082910_add_sync_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb2
-rw-r--r--spec/migrations/20220628012902_finalise_project_namespace_members_spec.rb8
-rw-r--r--spec/migrations/20220801155858_schedule_disable_legacy_open_source_licence_for_recent_public_projects_spec.rb4
-rw-r--r--spec/migrations/20220802204737_remove_deactivated_user_highest_role_stats_spec.rb2
-rw-r--r--spec/migrations/20220816163444_update_start_date_for_iterations_cadences_spec.rb30
-rw-r--r--spec/migrations/20220819153725_add_vulnerability_advisory_foreign_key_to_sbom_vulnerable_component_versions_spec.rb2
-rw-r--r--spec/migrations/20220819162852_add_sbom_component_version_foreign_key_to_sbom_vulnerable_component_versions_spec.rb2
-rw-r--r--spec/migrations/20220921144258_remove_orphan_group_token_users_spec.rb16
-rw-r--r--spec/migrations/20220928225711_schedule_update_ci_pipeline_artifacts_locked_status_spec.rb4
-rw-r--r--spec/migrations/20221002234454_finalize_group_member_namespace_id_migration_spec.rb8
-rw-r--r--spec/migrations/20221018050323_add_objective_and_keyresult_to_work_item_types_spec.rb35
-rw-r--r--spec/migrations/20221018193635_ensure_task_note_renaming_background_migration_finished_spec.rb6
-rw-r--r--spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb4
-rw-r--r--spec/migrations/20221104115712_backfill_project_statistics_storage_size_without_uploads_size_spec.rb2
-rw-r--r--spec/migrations/20221115173607_ensure_work_item_type_backfill_migration_finished_spec.rb4
-rw-r--r--spec/migrations/20221209235940_cleanup_o_auth_access_tokens_with_null_expires_in_spec.rb2
-rw-r--r--spec/migrations/20221215151822_schedule_backfill_releases_author_id_spec.rb30
-rw-r--r--spec/migrations/20221221110733_remove_temp_index_for_project_statistics_upload_size_migration_spec.rb2
-rw-r--r--spec/migrations/20230105172120_sync_new_amount_used_with_amount_used_on_ci_namespace_monthly_usages_table_spec.rb2
-rw-r--r--spec/migrations/20230118144623_schedule_migration_for_remediation_spec.rb25
-rw-r--r--spec/migrations/20230125195503_queue_backfill_compliance_violations_spec.rb32
-rw-r--r--spec/migrations/20230130182412_schedule_create_vulnerability_links_migration_spec.rb30
-rw-r--r--spec/migrations/20230201171450_finalize_backfill_environment_tier_migration_spec.rb6
-rw-r--r--spec/migrations/20230202131928_encrypt_ci_trigger_token_spec.rb27
-rw-r--r--spec/migrations/20230202211434_migrate_redis_slot_keys_spec.rb54
-rw-r--r--spec/migrations/20230208125736_schedule_migration_for_links_spec.rb25
-rw-r--r--spec/migrations/20230209222452_schedule_remove_project_group_link_with_missing_groups_spec.rb32
-rw-r--r--spec/migrations/20230214181633_finalize_ci_build_needs_big_int_conversion_spec.rb43
-rw-r--r--spec/migrations/20230220102212_swap_columns_ci_build_needs_big_int_conversion_spec.rb60
-rw-r--r--spec/migrations/20230221093533_add_tmp_partial_index_on_vulnerability_report_types_spec.rb22
-rw-r--r--spec/migrations/20230221214519_remove_incorrectly_onboarded_namespaces_from_onboarding_progress_spec.rb59
-rw-r--r--spec/migrations/20230223065753_finalize_nullify_creator_id_of_orphaned_projects_spec.rb97
-rw-r--r--spec/migrations/20230224085743_update_issues_internal_id_scope_spec.rb28
-rw-r--r--spec/migrations/20230224144233_migrate_evidences_from_raw_metadata_spec.rb25
-rw-r--r--spec/migrations/20230228142350_add_notifications_work_item_widget_spec.rb8
-rw-r--r--spec/migrations/20230302185739_queue_fix_vulnerability_reads_has_issues_spec.rb27
-rw-r--r--spec/migrations/20230302811133_re_migrate_redis_slot_keys_spec.rb77
-rw-r--r--spec/migrations/20230303105806_queue_delete_orphaned_packages_dependencies_spec.rb26
-rw-r--r--spec/migrations/20230309071242_delete_security_policy_bot_users_spec.rb24
-rw-r--r--spec/migrations/20230313142631_backfill_ml_candidates_package_id_spec.rb61
-rw-r--r--spec/migrations/20230313150531_reschedule_migration_for_remediation_spec.rb31
-rw-r--r--spec/migrations/20230314144640_reschedule_migration_for_links_spec.rb25
-rw-r--r--spec/migrations/20230317004428_migrate_daily_redis_hll_events_to_weekly_aggregation_spec.rb124
-rw-r--r--spec/migrations/20230317162059_add_current_user_todos_work_item_widget_spec.rb8
-rw-r--r--spec/migrations/20230321153035_add_package_id_created_at_desc_index_to_package_files_spec.rb20
-rw-r--r--spec/migrations/20230321163947_backfill_ml_candidates_project_id_spec.rb50
-rw-r--r--spec/migrations/20230321170823_backfill_ml_candidates_internal_id_spec.rb64
-rw-r--r--spec/migrations/20230322085041_remove_user_namespace_records_from_vsa_aggregation_spec.rb41
-rw-r--r--spec/migrations/20230322145403_add_project_id_foreign_key_to_packages_npm_metadata_caches_spec.rb24
-rw-r--r--spec/migrations/20230323101138_add_award_emoji_work_item_widget_spec.rb8
-rw-r--r--spec/migrations/20230327103401_queue_migrate_human_user_type_spec.rb26
-rw-r--r--spec/migrations/20230327123333_backfill_product_analytics_data_collector_host_spec.rb47
-rw-r--r--spec/migrations/20230328030101_add_secureflag_training_provider_spec.rb25
-rw-r--r--spec/migrations/20230328100534_truncate_error_tracking_tables_spec.rb56
-rw-r--r--spec/migrations/20230329100222_drop_software_licenses_temp_index_spec.rb20
-rw-r--r--spec/migrations/20230330103104_reschedule_migrate_evidences_spec.rb25
-rw-r--r--spec/migrations/20230403085957_add_tmp_partial_index_on_vulnerability_report_types2_spec.rb49
-rw-r--r--spec/migrations/20230405200858_requeue_backfill_project_wiki_repositories_spec.rb26
-rw-r--r--spec/migrations/20230406121544_queue_backfill_design_management_repositories_spec.rb26
-rw-r--r--spec/migrations/20230411153310_cleanup_bigint_conversion_for_sent_notifications_spec.rb21
-rw-r--r--spec/migrations/20230412141541_reschedule_links_avoiding_duplication_spec.rb31
-rw-r--r--spec/migrations/20230412185837_queue_populate_vulnerability_dismissal_fields_spec.rb37
-rw-r--r--spec/migrations/20230412214119_finalize_encrypt_ci_trigger_token_spec.rb96
-rw-r--r--spec/migrations/20230418215853_add_assignee_widget_to_incidents_spec.rb47
-rw-r--r--spec/migrations/20230419105225_remove_phabricator_from_application_settings_spec.rb22
-rw-r--r--spec/migrations/20230426102200_fix_import_sources_on_application_settings_after_phabricator_removal_spec.rb34
-rw-r--r--spec/migrations/20230428085332_remove_shimo_zentao_integration_records_spec.rb46
-rw-r--r--spec/migrations/20230502102832_schedule_index_to_members_on_source_and_type_and_access_level_spec.rb22
-rw-r--r--spec/migrations/20230502120021_schedule_index_to_project_authorizations_on_project_user_access_level_spec.rb22
-rw-r--r--spec/migrations/20230504084524_remove_gitlab_import_source_spec.rb22
-rw-r--r--spec/migrations/20230508150219_reschedule_evidences_handling_unicode_spec.rb31
-rw-r--r--spec/migrations/20230508175057_backfill_corrected_secure_files_expirations_spec.rb24
-rw-r--r--spec/migrations/20230509131736_add_default_organization_spec.rb20
-rw-r--r--spec/migrations/20230510062502_queue_cleanup_personal_access_tokens_with_nil_expires_at_spec.rb26
-rw-r--r--spec/migrations/add_open_source_plan_spec.rb86
-rw-r--r--spec/migrations/backfill_current_value_with_progress_work_item_progresses_spec.rb48
-rw-r--r--spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb2
-rw-r--r--spec/migrations/backfill_user_namespace_spec.rb29
-rw-r--r--spec/migrations/bulk_insert_cluster_enabled_grants_spec.rb2
-rw-r--r--spec/migrations/cleanup_backfill_integrations_enable_ssl_verification_spec.rb2
-rw-r--r--spec/migrations/cleanup_vulnerability_state_transitions_with_same_from_state_to_state_spec.rb2
-rw-r--r--spec/migrations/delete_migrate_shared_vulnerability_scanners_spec.rb58
-rw-r--r--spec/migrations/disable_job_token_scope_when_unused_spec.rb10
-rw-r--r--spec/migrations/drop_packages_events_table_spec.rb24
-rw-r--r--spec/migrations/ensure_award_emoji_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_commit_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_design_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_epic_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_issue_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_merge_request_metrics_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb37
-rw-r--r--spec/migrations/ensure_merge_request_metrics_id_bigint_backfill_is_finished_for_self_hosts_spec.rb25
-rw-r--r--spec/migrations/ensure_mr_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_note_diff_files_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_notes_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_snippet_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_suggestions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_system_note_metadata_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_timelogs_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_todos_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb35
-rw-r--r--spec/migrations/ensure_unique_debian_packages_spec.rb56
-rw-r--r--spec/migrations/ensure_vum_bigint_backfill_is_finished_for_gl_dot_com_spec.rb35
-rw-r--r--spec/migrations/finalize_invalid_member_cleanup_spec.rb4
-rw-r--r--spec/migrations/finalize_issues_iid_scoping_to_namespace_spec.rb72
-rw-r--r--spec/migrations/finalize_issues_namespace_id_backfilling_spec.rb8
-rw-r--r--spec/migrations/finalize_orphaned_routes_cleanup_spec.rb8
-rw-r--r--spec/migrations/finalize_project_namespaces_backfill_spec.rb8
-rw-r--r--spec/migrations/finalize_routes_backfilling_for_projects_spec.rb8
-rw-r--r--spec/migrations/finalize_traversal_ids_background_migrations_spec.rb60
-rw-r--r--spec/migrations/insert_daily_invites_trial_plan_limits_spec.rb51
-rw-r--r--spec/migrations/queue_backfill_admin_mode_scope_for_personal_access_tokens_spec.rb18
-rw-r--r--spec/migrations/queue_backfill_prepared_at_data_spec.rb24
-rw-r--r--spec/migrations/recreate_index_security_ci_builds_on_name_and_id_parser_features_spec.rb28
-rw-r--r--spec/migrations/remove_invalid_deploy_access_level_spec.rb48
-rw-r--r--spec/migrations/remove_packages_events_package_id_fk_spec.rb23
-rw-r--r--spec/migrations/remove_saml_provider_and_identities_non_root_group_spec.rb53
-rw-r--r--spec/migrations/remove_schedule_and_status_from_pending_alert_escalations_spec.rb37
-rw-r--r--spec/migrations/remove_scim_token_and_scim_identity_non_root_group_spec.rb58
-rw-r--r--spec/migrations/requeue_backfill_admin_mode_scope_for_personal_access_tokens_spec.rb19
-rw-r--r--spec/migrations/rerun_remove_invalid_deploy_access_level_spec.rb86
-rw-r--r--spec/migrations/reschedule_incident_work_item_type_id_backfill_spec.rb70
-rw-r--r--spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb2
-rw-r--r--spec/migrations/schedule_fixing_security_scan_statuses_spec.rb4
-rw-r--r--spec/migrations/schedule_migrate_shared_vulnerability_identifiers_spec.rb32
-rw-r--r--spec/migrations/schedule_purging_stale_security_scans_spec.rb2
-rw-r--r--spec/migrations/schedule_recalculate_vulnerability_finding_signatures_for_findings_spec.rb90
-rw-r--r--spec/migrations/set_email_confirmation_setting_before_removing_send_user_confirmation_email_column_spec.rb2
-rw-r--r--spec/migrations/set_email_confirmation_setting_from_soft_email_confirmation_ff_spec.rb62
-rw-r--r--spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb70
-rw-r--r--spec/migrations/start_backfill_ci_queuing_tables_spec.rb2
-rw-r--r--spec/migrations/swap_award_emoji_note_id_to_bigint_for_gitlab_dot_com_spec.rb67
-rw-r--r--spec/migrations/swap_commit_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/swap_design_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/swap_epic_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/swap_issue_user_mentions_note_id_to_bigint_for_gitlab_dot_com_2_spec.rb84
-rw-r--r--spec/migrations/swap_merge_request_metrics_id_to_bigint_for_gitlab_dot_com_spec.rb76
-rw-r--r--spec/migrations/swap_merge_request_metrics_id_to_bigint_for_self_hosts_spec.rb155
-rw-r--r--spec/migrations/swap_merge_request_user_mentions_note_id_to_bigint_spec.rb66
-rw-r--r--spec/migrations/swap_note_diff_files_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/swap_sent_notifications_id_columns_spec.rb71
-rw-r--r--spec/migrations/swap_snippet_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/swap_suggestions_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/swap_system_note_metadata_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/swap_timelogs_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/swap_todos_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/swap_vulnerability_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb66
-rw-r--r--spec/migrations/sync_new_amount_used_for_ci_namespace_monthly_usages_spec.rb2
-rw-r--r--spec/migrations/sync_new_amount_used_for_ci_project_monthly_usages_spec.rb2
-rw-r--r--spec/migrations/update_application_settings_container_registry_exp_pol_worker_capacity_default_spec.rb2
-rw-r--r--spec/migrations/update_application_settings_protected_paths_spec.rb2
-rw-r--r--spec/migrations/update_default_scan_method_of_dast_site_profile_spec.rb22
-rw-r--r--spec/models/abuse/trust_score_spec.rb57
-rw-r--r--spec/models/abuse_report_spec.rb212
-rw-r--r--spec/models/achievements/user_achievement_spec.rb29
-rw-r--r--spec/models/active_session_spec.rb46
-rw-r--r--spec/models/airflow/dags_spec.rb17
-rw-r--r--spec/models/alert_management/alert_assignee_spec.rb6
-rw-r--r--spec/models/alert_management/alert_spec.rb10
-rw-r--r--spec/models/alert_management/alert_user_mention_spec.rb6
-rw-r--r--spec/models/analytics/cycle_analytics/stage_spec.rb74
-rw-r--r--spec/models/application_record_spec.rb10
-rw-r--r--spec/models/application_setting_spec.rb104
-rw-r--r--spec/models/audit_event_spec.rb4
-rw-r--r--spec/models/authentication_event_spec.rb15
-rw-r--r--spec/models/award_emoji_spec.rb35
-rw-r--r--spec/models/awareness_session_spec.rb163
-rw-r--r--spec/models/blob_viewer/metrics_dashboard_yml_spec.rb16
-rw-r--r--spec/models/blob_viewer/package_json_spec.rb13
-rw-r--r--spec/models/board_spec.rb8
-rw-r--r--spec/models/broadcast_message_spec.rb52
-rw-r--r--spec/models/bulk_import_spec.rb29
-rw-r--r--spec/models/bulk_imports/batch_tracker_spec.rb16
-rw-r--r--spec/models/bulk_imports/entity_spec.rb95
-rw-r--r--spec/models/bulk_imports/export_batch_spec.rb17
-rw-r--r--spec/models/bulk_imports/export_spec.rb3
-rw-r--r--spec/models/bulk_imports/file_transfer/group_config_spec.rb49
-rw-r--r--spec/models/bulk_imports/file_transfer/project_config_spec.rb41
-rw-r--r--spec/models/bulk_imports/tracker_spec.rb5
-rw-r--r--spec/models/chat_name_spec.rb7
-rw-r--r--spec/models/ci/bridge_spec.rb32
-rw-r--r--spec/models/ci/build_dependencies_spec.rb11
-rw-r--r--spec/models/ci/build_metadata_spec.rb12
-rw-r--r--spec/models/ci/build_need_spec.rb2
-rw-r--r--spec/models/ci/build_pending_state_spec.rb7
-rw-r--r--spec/models/ci/build_report_result_spec.rb13
-rw-r--r--spec/models/ci/build_runner_session_spec.rb2
-rw-r--r--spec/models/ci/build_spec.rb671
-rw-r--r--spec/models/ci/build_trace_chunk_spec.rb9
-rw-r--r--spec/models/ci/build_trace_metadata_spec.rb2
-rw-r--r--spec/models/ci/build_trace_spec.rb6
-rw-r--r--spec/models/ci/catalog/listing_spec.rb66
-rw-r--r--spec/models/ci/catalog/resource_spec.rb42
-rw-r--r--spec/models/ci/commit_with_pipeline_spec.rb45
-rw-r--r--spec/models/ci/daily_build_group_report_result_spec.rb6
-rw-r--r--spec/models/ci/group_spec.rb12
-rw-r--r--spec/models/ci/group_variable_spec.rb14
-rw-r--r--spec/models/ci/job_artifact_spec.rb31
-rw-r--r--spec/models/ci/job_token/allowlist_spec.rb24
-rw-r--r--spec/models/ci/job_token/scope_spec.rb18
-rw-r--r--spec/models/ci/job_variable_spec.rb2
-rw-r--r--spec/models/ci/pipeline_schedule_spec.rb17
-rw-r--r--spec/models/ci/pipeline_spec.rb479
-rw-r--r--spec/models/ci/processable_spec.rb20
-rw-r--r--spec/models/ci/ref_spec.rb7
-rw-r--r--spec/models/ci/resource_group_spec.rb21
-rw-r--r--spec/models/ci/runner_machine_spec.rb197
-rw-r--r--spec/models/ci/runner_manager_build_spec.rb100
-rw-r--r--spec/models/ci/runner_manager_spec.rb291
-rw-r--r--spec/models/ci/runner_spec.rb302
-rw-r--r--spec/models/ci/runner_version_spec.rb13
-rw-r--r--spec/models/ci/secure_file_spec.rb29
-rw-r--r--spec/models/ci/sources/pipeline_spec.rb7
-rw-r--r--spec/models/ci/stage_spec.rb24
-rw-r--r--spec/models/ci/variable_spec.rb2
-rw-r--r--spec/models/clusters/agent_spec.rb195
-rw-r--r--spec/models/clusters/agent_token_spec.rb22
-rw-r--r--spec/models/clusters/agents/authorizations/ci_access/group_authorization_spec.rb16
-rw-r--r--spec/models/clusters/agents/authorizations/ci_access/implicit_authorization_spec.rb14
-rw-r--r--spec/models/clusters/agents/authorizations/ci_access/project_authorization_spec.rb16
-rw-r--r--spec/models/clusters/agents/authorizations/user_access/group_authorization_spec.rb71
-rw-r--r--spec/models/clusters/agents/authorizations/user_access/project_authorization_spec.rb50
-rw-r--r--spec/models/clusters/agents/group_authorization_spec.rb16
-rw-r--r--spec/models/clusters/agents/implicit_authorization_spec.rb14
-rw-r--r--spec/models/clusters/agents/project_authorization_spec.rb16
-rw-r--r--spec/models/clusters/applications/crossplane_spec.rb62
-rw-r--r--spec/models/clusters/applications/helm_spec.rb116
-rw-r--r--spec/models/clusters/applications/ingress_spec.rb180
-rw-r--r--spec/models/clusters/applications/jupyter_spec.rb130
-rw-r--r--spec/models/clusters/applications/knative_spec.rb260
-rw-r--r--spec/models/clusters/applications/prometheus_spec.rb349
-rw-r--r--spec/models/clusters/applications/runner_spec.rb127
-rw-r--r--spec/models/clusters/cluster_spec.rb254
-rw-r--r--spec/models/clusters/integrations/prometheus_spec.rb4
-rw-r--r--spec/models/clusters/kubernetes_namespace_spec.rb10
-rw-r--r--spec/models/clusters/platforms/kubernetes_spec.rb43
-rw-r--r--spec/models/commit_collection_spec.rb33
-rw-r--r--spec/models/commit_spec.rb152
-rw-r--r--spec/models/commit_status_spec.rb55
-rw-r--r--spec/models/compare_spec.rb34
-rw-r--r--spec/models/concerns/atomic_internal_id_spec.rb99
-rw-r--r--spec/models/concerns/awareness_spec.rb39
-rw-r--r--spec/models/concerns/ci/has_status_spec.rb26
-rw-r--r--spec/models/concerns/ci/maskable_spec.rb2
-rw-r--r--spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb80
-rw-r--r--spec/models/concerns/ci/partitionable/switch_spec.rb9
-rw-r--r--spec/models/concerns/ci/partitionable_spec.rb12
-rw-r--r--spec/models/concerns/ci/track_environment_usage_spec.rb20
-rw-r--r--spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb21
-rw-r--r--spec/models/concerns/clusters/agents/authorizations/ci_access/config_scopes_spec.rb21
-rw-r--r--spec/models/concerns/clusters/agents/authorizations/user_access/scopes_spec.rb27
-rw-r--r--spec/models/concerns/database_event_tracking_spec.rb44
-rw-r--r--spec/models/concerns/deployment_platform_spec.rb45
-rw-r--r--spec/models/concerns/each_batch_spec.rb32
-rw-r--r--spec/models/concerns/expirable_spec.rb66
-rw-r--r--spec/models/concerns/has_user_type_spec.rb18
-rw-r--r--spec/models/concerns/issuable_spec.rb36
-rw-r--r--spec/models/concerns/mentionable_spec.rb2
-rw-r--r--spec/models/concerns/prometheus_adapter_spec.rb2
-rw-r--r--spec/models/concerns/protected_ref_access_spec.rb45
-rw-r--r--spec/models/concerns/redis_cacheable_spec.rb52
-rw-r--r--spec/models/concerns/require_email_verification_spec.rb16
-rw-r--r--spec/models/concerns/routable_spec.rb11
-rw-r--r--spec/models/concerns/subscribable_spec.rb26
-rw-r--r--spec/models/concerns/taskable_spec.rb7
-rw-r--r--spec/models/concerns/token_authenticatable_spec.rb5
-rw-r--r--spec/models/concerns/token_authenticatable_strategies/base_spec.rb37
-rw-r--r--spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb149
-rw-r--r--spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb92
-rw-r--r--spec/models/concerns/uniquify_spec.rb44
-rw-r--r--spec/models/concerns/web_hooks/has_web_hooks_spec.rb41
-rw-r--r--spec/models/container_registry/data_repair_detail_spec.rb29
-rw-r--r--spec/models/container_registry/event_spec.rb24
-rw-r--r--spec/models/container_repository_spec.rb100
-rw-r--r--spec/models/customer_relations/contact_spec.rb8
-rw-r--r--spec/models/customer_relations/organization_spec.rb74
-rw-r--r--spec/models/deployment_spec.rb24
-rw-r--r--spec/models/design_management/design_collection_spec.rb2
-rw-r--r--spec/models/design_management/design_spec.rb17
-rw-r--r--spec/models/design_management/git_repository_spec.rb64
-rw-r--r--spec/models/design_management/repository_spec.rb57
-rw-r--r--spec/models/design_management/version_spec.rb10
-rw-r--r--spec/models/diff_note_spec.rb35
-rw-r--r--spec/models/environment_spec.rb33
-rw-r--r--spec/models/environment_status_spec.rb39
-rw-r--r--spec/models/error_tracking/project_error_tracking_setting_spec.rb38
-rw-r--r--spec/models/event_collection_spec.rb3
-rw-r--r--spec/models/event_spec.rb26
-rw-r--r--spec/models/generic_commit_status_spec.rb3
-rw-r--r--spec/models/group_group_link_spec.rb127
-rw-r--r--spec/models/group_label_spec.rb43
-rw-r--r--spec/models/group_spec.rb62
-rw-r--r--spec/models/hooks/web_hook_spec.rb24
-rw-r--r--spec/models/import_export_upload_spec.rb2
-rw-r--r--spec/models/import_failure_spec.rb27
-rw-r--r--spec/models/instance_configuration_spec.rb6
-rw-r--r--spec/models/integration_spec.rb2
-rw-r--r--spec/models/integrations/apple_app_store_spec.rb15
-rw-r--r--spec/models/integrations/buildkite_spec.rb9
-rw-r--r--spec/models/integrations/campfire_spec.rb14
-rw-r--r--spec/models/integrations/chat_message/deployment_message_spec.rb2
-rw-r--r--spec/models/integrations/every_integration_spec.rb4
-rw-r--r--spec/models/integrations/ewm_spec.rb12
-rw-r--r--spec/models/integrations/field_spec.rb16
-rw-r--r--spec/models/integrations/gitlab_slack_application_spec.rb337
-rw-r--r--spec/models/integrations/google_play_spec.rb101
-rw-r--r--spec/models/integrations/hangouts_chat_spec.rb17
-rw-r--r--spec/models/integrations/harbor_spec.rb2
-rw-r--r--spec/models/integrations/jira_spec.rb164
-rw-r--r--spec/models/integrations/mattermost_slash_commands_spec.rb9
-rw-r--r--spec/models/integrations/prometheus_spec.rb50
-rw-r--r--spec/models/integrations/redmine_spec.rb4
-rw-r--r--spec/models/integrations/slack_slash_commands_spec.rb9
-rw-r--r--spec/models/integrations/slack_workspace/api_scope_spec.rb20
-rw-r--r--spec/models/integrations/squash_tm_spec.rb117
-rw-r--r--spec/models/integrations/youtrack_spec.rb6
-rw-r--r--spec/models/internal_id_spec.rb12
-rw-r--r--spec/models/issue_spec.rb260
-rw-r--r--spec/models/key_spec.rb2
-rw-r--r--spec/models/label_spec.rb11
-rw-r--r--spec/models/member_spec.rb117
-rw-r--r--spec/models/members/member_role_spec.rb107
-rw-r--r--spec/models/members/project_member_spec.rb20
-rw-r--r--spec/models/merge_request/diff_llm_summary_spec.rb17
-rw-r--r--spec/models/merge_request/metrics_spec.rb8
-rw-r--r--spec/models/merge_request_spec.rb138
-rw-r--r--spec/models/milestone_spec.rb12
-rw-r--r--spec/models/ml/candidate_spec.rb127
-rw-r--r--spec/models/ml/experiment_spec.rb26
-rw-r--r--spec/models/namespace/aggregation_schedule_spec.rb26
-rw-r--r--spec/models/namespace/package_setting_spec.rb52
-rw-r--r--spec/models/namespace/root_storage_statistics_spec.rb162
-rw-r--r--spec/models/namespace_setting_spec.rb92
-rw-r--r--spec/models/namespace_spec.rb483
-rw-r--r--spec/models/namespaces/randomized_suffix_path_spec.rb2
-rw-r--r--spec/models/note_spec.rb53
-rw-r--r--spec/models/notes/note_metadata_spec.rb35
-rw-r--r--spec/models/oauth_access_token_spec.rb4
-rw-r--r--spec/models/onboarding/completion_spec.rb73
-rw-r--r--spec/models/onboarding/progress_spec.rb6
-rw-r--r--spec/models/operations/feature_flag_spec.rb6
-rw-r--r--spec/models/organization_spec.rb98
-rw-r--r--spec/models/packages/debian/file_metadatum_spec.rb44
-rw-r--r--spec/models/packages/debian/group_distribution_spec.rb7
-rw-r--r--spec/models/packages/debian/project_distribution_spec.rb7
-rw-r--r--spec/models/packages/dependency_spec.rb19
-rw-r--r--spec/models/packages/event_spec.rb51
-rw-r--r--spec/models/packages/npm/metadata_cache_spec.rb150
-rw-r--r--spec/models/packages/npm/metadatum_spec.rb14
-rw-r--r--spec/models/packages/package_file_spec.rb12
-rw-r--r--spec/models/packages/package_spec.rb79
-rw-r--r--spec/models/pages/lookup_path_spec.rb92
-rw-r--r--spec/models/pages_deployment_spec.rb60
-rw-r--r--spec/models/pages_domain_spec.rb39
-rw-r--r--spec/models/personal_access_token_spec.rb93
-rw-r--r--spec/models/plan_limits_spec.rb3
-rw-r--r--spec/models/preloaders/labels_preloader_spec.rb14
-rw-r--r--spec/models/preloaders/runner_manager_policy_preloader_spec.rb38
-rw-r--r--spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb16
-rw-r--r--spec/models/preloaders/users_max_access_level_by_project_preloader_spec.rb61
-rw-r--r--spec/models/preloaders/users_max_access_level_in_projects_preloader_spec.rb51
-rw-r--r--spec/models/project_ci_cd_setting_spec.rb18
-rw-r--r--spec/models/project_feature_spec.rb36
-rw-r--r--spec/models/project_label_spec.rb35
-rw-r--r--spec/models/project_setting_spec.rb70
-rw-r--r--spec/models/project_spec.rb598
-rw-r--r--spec/models/project_wiki_spec.rb29
-rw-r--r--spec/models/projects/data_transfer_spec.rb32
-rw-r--r--spec/models/projects/forks/details_spec.rb162
-rw-r--r--spec/models/projects/forks/divergence_counts_spec.rb98
-rw-r--r--spec/models/projects/import_export/relation_export_spec.rb22
-rw-r--r--spec/models/protected_branch/merge_access_level_spec.rb5
-rw-r--r--spec/models/protected_branch/push_access_level_spec.rb5
-rw-r--r--spec/models/protected_branch_spec.rb169
-rw-r--r--spec/models/protected_tag/create_access_level_spec.rb13
-rw-r--r--spec/models/releases/link_spec.rb9
-rw-r--r--spec/models/releases/source_spec.rb6
-rw-r--r--spec/models/repository_spec.rb313
-rw-r--r--spec/models/resource_events/abuse_report_event_spec.rb17
-rw-r--r--spec/models/resource_events/issue_assignment_event_spec.rb17
-rw-r--r--spec/models/resource_events/merge_request_assignment_event_spec.rb17
-rw-r--r--spec/models/resource_state_event_spec.rb14
-rw-r--r--spec/models/serverless/domain_cluster_spec.rb75
-rw-r--r--spec/models/serverless/domain_spec.rb97
-rw-r--r--spec/models/serverless/function_spec.rb21
-rw-r--r--spec/models/service_desk/custom_email_credential_spec.rb67
-rw-r--r--spec/models/service_desk/custom_email_verification_spec.rb170
-rw-r--r--spec/models/service_desk_setting_spec.rb65
-rw-r--r--spec/models/slack_integration_spec.rb147
-rw-r--r--spec/models/snippet_spec.rb32
-rw-r--r--spec/models/spam_log_spec.rb3
-rw-r--r--spec/models/terraform/state_spec.rb2
-rw-r--r--spec/models/terraform/state_version_spec.rb4
-rw-r--r--spec/models/u2f_registration_spec.rb141
-rw-r--r--spec/models/upload_spec.rb8
-rw-r--r--spec/models/user_detail_spec.rb20
-rw-r--r--spec/models/user_preference_spec.rb63
-rw-r--r--spec/models/user_spec.rb526
-rw-r--r--spec/models/users/credit_card_validation_spec.rb103
-rw-r--r--spec/models/wiki_directory_spec.rb9
-rw-r--r--spec/models/wiki_page/meta_spec.rb10
-rw-r--r--spec/models/wiki_page_spec.rb14
-rw-r--r--spec/models/work_item_spec.rb244
-rw-r--r--spec/models/work_items/resource_link_event_spec.rb16
-rw-r--r--spec/models/work_items/widget_definition_spec.rb5
-rw-r--r--spec/models/work_items/widgets/award_emoji_spec.rb30
-rw-r--r--spec/models/work_items/widgets/notifications_spec.rb19
-rw-r--r--spec/policies/abuse_report_policy_spec.rb25
-rw-r--r--spec/policies/achievements/user_achievement_policy_spec.rb78
-rw-r--r--spec/policies/ci/build_policy_spec.rb32
-rw-r--r--spec/policies/ci/pipeline_policy_spec.rb18
-rw-r--r--spec/policies/ci/pipeline_schedule_policy_spec.rb14
-rw-r--r--spec/policies/ci/runner_manager_policy_spec.rb176
-rw-r--r--spec/policies/clusters/agent_policy_spec.rb18
-rw-r--r--spec/policies/concerns/policy_actor_spec.rb18
-rw-r--r--spec/policies/design_management/design_policy_spec.rb4
-rw-r--r--spec/policies/environment_policy_spec.rb3
-rw-r--r--spec/policies/global_policy_spec.rb142
-rw-r--r--spec/policies/group_policy_spec.rb272
-rw-r--r--spec/policies/identity_provider_policy_spec.rb10
-rw-r--r--spec/policies/issue_policy_spec.rb4
-rw-r--r--spec/policies/metrics/dashboard/annotation_policy_spec.rb16
-rw-r--r--spec/policies/namespaces/user_namespace_policy_spec.rb28
-rw-r--r--spec/policies/project_group_link_policy_spec.rb2
-rw-r--r--spec/policies/project_hook_policy_spec.rb6
-rw-r--r--spec/policies/project_policy_spec.rb279
-rw-r--r--spec/presenters/blob_presenter_spec.rb10
-rw-r--r--spec/presenters/ci/build_presenter_spec.rb16
-rw-r--r--spec/presenters/ci/build_runner_presenter_spec.rb78
-rw-r--r--spec/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter_spec.rb2
-rw-r--r--spec/presenters/commit_presenter_spec.rb13
-rw-r--r--spec/presenters/issue_email_participant_presenter_spec.rb43
-rw-r--r--spec/presenters/issue_presenter_spec.rb28
-rw-r--r--spec/presenters/merge_request_presenter_spec.rb9
-rw-r--r--spec/presenters/ml/candidate_details_presenter_spec.rb111
-rw-r--r--spec/presenters/ml/candidates_csv_presenter_spec.rb84
-rw-r--r--spec/presenters/packages/detail/package_presenter_spec.rb3
-rw-r--r--spec/presenters/packages/npm/package_presenter_spec.rb161
-rw-r--r--spec/presenters/pages_domain_presenter_spec.rb44
-rw-r--r--spec/presenters/project_clusterable_presenter_spec.rb10
-rw-r--r--spec/presenters/project_hook_presenter_spec.rb4
-rw-r--r--spec/presenters/releases/link_presenter_spec.rb2
-rw-r--r--spec/presenters/service_hook_presenter_spec.rb4
-rw-r--r--spec/rails_autoload.rb56
-rw-r--r--spec/requests/abuse_reports_controller_spec.rb1
-rw-r--r--spec/requests/admin/abuse_reports_controller_spec.rb92
-rw-r--r--spec/requests/admin/applications_controller_spec.rb2
-rw-r--r--spec/requests/admin/background_migrations_controller_spec.rb11
-rw-r--r--spec/requests/admin/broadcast_messages_controller_spec.rb5
-rw-r--r--spec/requests/admin/impersonation_tokens_controller_spec.rb2
-rw-r--r--spec/requests/admin/integrations_controller_spec.rb14
-rw-r--r--spec/requests/admin/projects_controller_spec.rb86
-rw-r--r--spec/requests/admin/users_controller_spec.rb42
-rw-r--r--spec/requests/admin/version_check_controller_spec.rb2
-rw-r--r--spec/requests/api/access_requests_spec.rb2
-rw-r--r--spec/requests/api/admin/batched_background_migrations_spec.rb93
-rw-r--r--spec/requests/api/admin/ci/variables_spec.rb131
-rw-r--r--spec/requests/api/admin/instance_clusters_spec.rb139
-rw-r--r--spec/requests/api/admin/plan_limits_spec.rb64
-rw-r--r--spec/requests/api/admin/sidekiq_spec.rb27
-rw-r--r--spec/requests/api/api_guard/admin_mode_middleware_spec.rb2
-rw-r--r--spec/requests/api/api_guard/response_coercer_middleware_spec.rb2
-rw-r--r--spec/requests/api/api_spec.rb24
-rw-r--r--spec/requests/api/appearance_spec.rb22
-rw-r--r--spec/requests/api/applications_spec.rb10
-rw-r--r--spec/requests/api/avatar_spec.rb1
-rw-r--r--spec/requests/api/award_emoji_spec.rb2
-rw-r--r--spec/requests/api/badges_spec.rb4
-rw-r--r--spec/requests/api/broadcast_messages_spec.rb87
-rw-r--r--spec/requests/api/bulk_imports_spec.rb87
-rw-r--r--spec/requests/api/ci/job_artifacts_spec.rb6
-rw-r--r--spec/requests/api/ci/jobs_spec.rb52
-rw-r--r--spec/requests/api/ci/pipeline_schedules_spec.rb4
-rw-r--r--spec/requests/api/ci/pipelines_spec.rb197
-rw-r--r--spec/requests/api/ci/runner/jobs_artifacts_spec.rb63
-rw-r--r--spec/requests/api/ci/runner/jobs_put_spec.rb6
-rw-r--r--spec/requests/api/ci/runner/jobs_request_post_spec.rb115
-rw-r--r--spec/requests/api/ci/runner/runners_delete_spec.rb92
-rw-r--r--spec/requests/api/ci/runner/runners_post_spec.rb81
-rw-r--r--spec/requests/api/ci/runner/runners_verify_post_spec.rb69
-rw-r--r--spec/requests/api/ci/runners_reset_registration_token_spec.rb15
-rw-r--r--spec/requests/api/ci/runners_spec.rb166
-rw-r--r--spec/requests/api/ci/secure_files_spec.rb2
-rw-r--r--spec/requests/api/ci/variables_spec.rb2
-rw-r--r--spec/requests/api/clusters/agent_tokens_spec.rb19
-rw-r--r--spec/requests/api/clusters/agents_spec.rb2
-rw-r--r--spec/requests/api/commit_statuses_spec.rb4
-rw-r--r--spec/requests/api/commits_spec.rb63
-rw-r--r--spec/requests/api/composer_packages_spec.rb6
-rw-r--r--spec/requests/api/conan_project_packages_spec.rb23
-rw-r--r--spec/requests/api/debian_group_packages_spec.rb69
-rw-r--r--spec/requests/api/debian_project_packages_spec.rb89
-rw-r--r--spec/requests/api/deploy_keys_spec.rb134
-rw-r--r--spec/requests/api/deploy_tokens_spec.rb45
-rw-r--r--spec/requests/api/deployments_spec.rb6
-rw-r--r--spec/requests/api/doorkeeper_access_spec.rb2
-rw-r--r--spec/requests/api/draft_notes_spec.rb214
-rw-r--r--spec/requests/api/environments_spec.rb40
-rw-r--r--spec/requests/api/error_tracking/project_settings_spec.rb359
-rw-r--r--spec/requests/api/files_spec.rb35
-rw-r--r--spec/requests/api/freeze_periods_spec.rb68
-rw-r--r--spec/requests/api/graphql/achievements/user_achievements_query_spec.rb80
-rw-r--r--spec/requests/api/graphql/ci/ci_cd_setting_spec.rb1
-rw-r--r--spec/requests/api/graphql/ci/config_variables_spec.rb4
-rw-r--r--spec/requests/api/graphql/ci/group_variables_spec.rb2
-rw-r--r--spec/requests/api/graphql/ci/inherited_ci_variables_spec.rb108
-rw-r--r--spec/requests/api/graphql/ci/instance_variables_spec.rb2
-rw-r--r--spec/requests/api/graphql/ci/job_spec.rb3
-rw-r--r--spec/requests/api/graphql/ci/jobs_spec.rb186
-rw-r--r--spec/requests/api/graphql/ci/manual_variables_spec.rb2
-rw-r--r--spec/requests/api/graphql/ci/project_variables_spec.rb2
-rw-r--r--spec/requests/api/graphql/ci/runner_spec.rb350
-rw-r--r--spec/requests/api/graphql/ci/runners_spec.rb31
-rw-r--r--spec/requests/api/graphql/current_user/todos_query_spec.rb2
-rw-r--r--spec/requests/api/graphql/current_user_query_spec.rb2
-rw-r--r--spec/requests/api/graphql/custom_emoji_query_spec.rb2
-rw-r--r--spec/requests/api/graphql/group/data_transfer_spec.rb115
-rw-r--r--spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb10
-rw-r--r--spec/requests/api/graphql/group/labels_query_spec.rb19
-rw-r--r--spec/requests/api/graphql/group/milestones_spec.rb6
-rw-r--r--spec/requests/api/graphql/issues_spec.rb34
-rw-r--r--spec/requests/api/graphql/jobs_query_spec.rb41
-rw-r--r--spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb16
-rw-r--r--spec/requests/api/graphql/metrics/dashboard_query_spec.rb15
-rw-r--r--spec/requests/api/graphql/multiplexed_queries_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/achievements/award_spec.rb106
-rw-r--r--spec/requests/api/graphql/mutations/achievements/delete_spec.rb79
-rw-r--r--spec/requests/api/graphql/mutations/achievements/revoke_spec.rb91
-rw-r--r--spec/requests/api/graphql/mutations/achievements/update_spec.rb90
-rw-r--r--spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/award_emojis/add_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/ci/job/cancel_spec.rb45
-rw-r--r--spec/requests/api/graphql/mutations/ci/job/play_spec.rb79
-rw-r--r--spec/requests/api/graphql/mutations/ci/job/retry_spec.rb92
-rw-r--r--spec/requests/api/graphql/mutations/ci/job/unschedule_spec.rb48
-rw-r--r--spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb197
-rw-r--r--spec/requests/api/graphql/mutations/ci/job_cancel_spec.rb45
-rw-r--r--spec/requests/api/graphql/mutations/ci/job_play_spec.rb79
-rw-r--r--spec/requests/api/graphql/mutations/ci/job_retry_spec.rb92
-rw-r--r--spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb19
-rw-r--r--spec/requests/api/graphql/mutations/ci/job_unschedule_spec.rb48
-rw-r--r--spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb108
-rw-r--r--spec/requests/api/graphql/mutations/ci/runner/create_spec.rb313
-rw-r--r--spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb8
-rw-r--r--spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/design_management/update_spec.rb77
-rw-r--r--spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb68
-rw-r--r--spec/requests/api/graphql/mutations/issues/create_spec.rb1
-rw-r--r--spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb128
-rw-r--r--spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb18
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb15
-rw-r--r--spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb15
-rw-r--r--spec/requests/api/graphql/mutations/notes/create/note_spec.rb17
-rw-r--r--spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb153
-rw-r--r--spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/releases/create_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/snippets/update_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/user_preferences/update_spec.rb12
-rw-r--r--spec/requests/api/graphql/mutations/work_items/convert_spec.rb61
-rw-r--r--spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb3
-rw-r--r--spec/requests/api/graphql/mutations/work_items/create_spec.rb152
-rw-r--r--spec/requests/api/graphql/mutations/work_items/export_spec.rb71
-rw-r--r--spec/requests/api/graphql/mutations/work_items/update_spec.rb730
-rw-r--r--spec/requests/api/graphql/mutations/work_items/update_task_spec.rb2
-rw-r--r--spec/requests/api/graphql/namespace/projects_spec.rb2
-rw-r--r--spec/requests/api/graphql/packages/package_spec.rb25
-rw-r--r--spec/requests/api/graphql/project/alert_management/alert/metrics_dashboard_url_spec.rb25
-rw-r--r--spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/base_service_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb122
-rw-r--r--spec/requests/api/graphql/project/cluster_agents_spec.rb5
-rw-r--r--spec/requests/api/graphql/project/commit_references_spec.rb240
-rw-r--r--spec/requests/api/graphql/project/container_repositories_spec.rb4
-rw-r--r--spec/requests/api/graphql/project/data_transfer_spec.rb112
-rw-r--r--spec/requests/api/graphql/project/environments_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/flow_metrics_spec.rb23
-rw-r--r--spec/requests/api/graphql/project/fork_details_spec.rb34
-rw-r--r--spec/requests/api/graphql/project/merge_request_spec.rb27
-rw-r--r--spec/requests/api/graphql/project/merge_requests_spec.rb27
-rw-r--r--spec/requests/api/graphql/project/milestones_spec.rb29
-rw-r--r--spec/requests/api/graphql/project/project_statistics_redirect_spec.rb78
-rw-r--r--spec/requests/api/graphql/project/release_spec.rb8
-rw-r--r--spec/requests/api/graphql/project/user_access_authorized_agents_spec.rb129
-rw-r--r--spec/requests/api/graphql/project/work_items_spec.rb132
-rw-r--r--spec/requests/api/graphql/project_query_spec.rb61
-rw-r--r--spec/requests/api/graphql/query_spec.rb36
-rw-r--r--spec/requests/api/graphql/user/user_achievements_query_spec.rb95
-rw-r--r--spec/requests/api/graphql/user_spec.rb18
-rw-r--r--spec/requests/api/graphql/work_item_spec.rb186
-rw-r--r--spec/requests/api/graphql_spec.rb2
-rw-r--r--spec/requests/api/group_clusters_spec.rb2
-rw-r--r--spec/requests/api/group_milestones_spec.rb78
-rw-r--r--spec/requests/api/group_variables_spec.rb2
-rw-r--r--spec/requests/api/groups_spec.rb367
-rw-r--r--spec/requests/api/helm_packages_spec.rb15
-rw-r--r--spec/requests/api/helpers_spec.rb2
-rw-r--r--spec/requests/api/import_github_spec.rb74
-rw-r--r--spec/requests/api/integrations/slack/events_spec.rb91
-rw-r--r--spec/requests/api/integrations/slack/interactions_spec.rb69
-rw-r--r--spec/requests/api/integrations/slack/options_spec.rb64
-rw-r--r--spec/requests/api/integrations_spec.rb54
-rw-r--r--spec/requests/api/internal/base_spec.rb98
-rw-r--r--spec/requests/api/internal/kubernetes_spec.rb177
-rw-r--r--spec/requests/api/internal/pages_spec.rb382
-rw-r--r--spec/requests/api/internal/workhorse_spec.rb2
-rw-r--r--spec/requests/api/issue_links_spec.rb6
-rw-r--r--spec/requests/api/issues/get_group_issues_spec.rb30
-rw-r--r--spec/requests/api/issues/get_project_issues_spec.rb62
-rw-r--r--spec/requests/api/issues/issues_spec.rb82
-rw-r--r--spec/requests/api/issues/post_projects_issues_spec.rb24
-rw-r--r--spec/requests/api/issues/put_projects_issues_spec.rb69
-rw-r--r--spec/requests/api/keys_spec.rb47
-rw-r--r--spec/requests/api/lint_spec.rb228
-rw-r--r--spec/requests/api/maven_packages_spec.rb246
-rw-r--r--spec/requests/api/members_spec.rb40
-rw-r--r--spec/requests/api/merge_requests_spec.rb149
-rw-r--r--spec/requests/api/metadata_spec.rb2
-rw-r--r--spec/requests/api/metrics/dashboard/annotations_spec.rb15
-rw-r--r--spec/requests/api/metrics/user_starred_dashboards_spec.rb28
-rw-r--r--spec/requests/api/ml/mlflow/experiments_spec.rb215
-rw-r--r--spec/requests/api/ml/mlflow/runs_spec.rb354
-rw-r--r--spec/requests/api/ml/mlflow_spec.rb630
-rw-r--r--spec/requests/api/namespaces_spec.rb74
-rw-r--r--spec/requests/api/notes_spec.rb10
-rw-r--r--spec/requests/api/npm_instance_packages_spec.rb34
-rw-r--r--spec/requests/api/npm_project_packages_spec.rb127
-rw-r--r--spec/requests/api/nuget_group_packages_spec.rb12
-rw-r--r--spec/requests/api/nuget_project_packages_spec.rb11
-rw-r--r--spec/requests/api/oauth_tokens_spec.rb4
-rw-r--r--spec/requests/api/package_files_spec.rb84
-rw-r--r--spec/requests/api/pages/internal_access_spec.rb68
-rw-r--r--spec/requests/api/pages/pages_spec.rb22
-rw-r--r--spec/requests/api/pages/private_access_spec.rb68
-rw-r--r--spec/requests/api/pages/public_access_spec.rb68
-rw-r--r--spec/requests/api/pages_domains_spec.rb44
-rw-r--r--spec/requests/api/personal_access_tokens/self_information_spec.rb6
-rw-r--r--spec/requests/api/personal_access_tokens_spec.rb72
-rw-r--r--spec/requests/api/project_attributes.yml20
-rw-r--r--spec/requests/api/project_clusters_spec.rb2
-rw-r--r--spec/requests/api/project_export_spec.rb123
-rw-r--r--spec/requests/api/project_import_spec.rb108
-rw-r--r--spec/requests/api/project_job_token_scope_spec.rb76
-rw-r--r--spec/requests/api/project_milestones_spec.rb87
-rw-r--r--spec/requests/api/project_snapshots_spec.rb13
-rw-r--r--spec/requests/api/project_snippets_spec.rb136
-rw-r--r--spec/requests/api/project_templates_spec.rb25
-rw-r--r--spec/requests/api/projects_spec.rb1036
-rw-r--r--spec/requests/api/protected_branches_spec.rb124
-rw-r--r--spec/requests/api/protected_tags_spec.rb15
-rw-r--r--spec/requests/api/pypi_packages_spec.rb30
-rw-r--r--spec/requests/api/release/links_spec.rb28
-rw-r--r--spec/requests/api/releases_spec.rb37
-rw-r--r--spec/requests/api/repositories_spec.rb1
-rw-r--r--spec/requests/api/resource_access_tokens_spec.rb112
-rw-r--r--spec/requests/api/rubygem_packages_spec.rb30
-rw-r--r--spec/requests/api/search_spec.rb23
-rw-r--r--spec/requests/api/settings_spec.rb66
-rw-r--r--spec/requests/api/sidekiq_metrics_spec.rb17
-rw-r--r--spec/requests/api/snippets_spec.rb20
-rw-r--r--spec/requests/api/statistics_spec.rb8
-rw-r--r--spec/requests/api/tags_spec.rb2
-rw-r--r--spec/requests/api/terraform/modules/v1/packages_spec.rb7
-rw-r--r--spec/requests/api/terraform/state_spec.rb92
-rw-r--r--spec/requests/api/terraform/state_version_spec.rb10
-rw-r--r--spec/requests/api/topics_spec.rb95
-rw-r--r--spec/requests/api/unleash_spec.rb8
-rw-r--r--spec/requests/api/usage_data_non_sql_metrics_spec.rb10
-rw-r--r--spec/requests/api/usage_data_queries_spec.rb12
-rw-r--r--spec/requests/api/users_preferences_spec.rb5
-rw-r--r--spec/requests/api/users_spec.rb1223
-rw-r--r--spec/requests/api/v3/github_spec.rb70
-rw-r--r--spec/requests/dashboard_controller_spec.rb2
-rw-r--r--spec/requests/git_http_spec.rb29
-rw-r--r--spec/requests/groups/achievements_controller_spec.rb78
-rw-r--r--spec/requests/groups/email_campaigns_controller_spec.rb6
-rw-r--r--spec/requests/groups/observability_controller_spec.rb18
-rw-r--r--spec/requests/groups/settings/access_tokens_controller_spec.rb2
-rw-r--r--spec/requests/groups/settings/applications_controller_spec.rb2
-rw-r--r--spec/requests/groups/usage_quotas_controller_spec.rb2
-rw-r--r--spec/requests/ide_controller_spec.rb153
-rw-r--r--spec/requests/import/github_controller_spec.rb42
-rw-r--r--spec/requests/import/github_groups_controller_spec.rb2
-rw-r--r--spec/requests/import/gitlab_projects_controller_spec.rb14
-rw-r--r--spec/requests/jira_authorizations_spec.rb10
-rw-r--r--spec/requests/jira_connect/oauth_application_ids_controller_spec.rb6
-rw-r--r--spec/requests/jira_connect/public_keys_controller_spec.rb21
-rw-r--r--spec/requests/jira_connect/users_controller_spec.rb46
-rw-r--r--spec/requests/jwks_controller_spec.rb11
-rw-r--r--spec/requests/jwt_controller_spec.rb10
-rw-r--r--spec/requests/oauth/applications_controller_spec.rb2
-rw-r--r--spec/requests/oauth/authorizations_controller_spec.rb2
-rw-r--r--spec/requests/oauth/tokens_controller_spec.rb2
-rw-r--r--spec/requests/oauth_tokens_spec.rb2
-rw-r--r--spec/requests/openid_connect_spec.rb6
-rw-r--r--spec/requests/profiles/comment_templates_controller_spec.rb35
-rw-r--r--spec/requests/profiles/saved_replies_controller_spec.rb35
-rw-r--r--spec/requests/projects/airflow/dags_controller_spec.rb105
-rw-r--r--spec/requests/projects/aws/configuration_controller_spec.rb59
-rw-r--r--spec/requests/projects/ci/promeheus_metrics/histograms_controller_spec.rb2
-rw-r--r--spec/requests/projects/cluster_agents_controller_spec.rb2
-rw-r--r--spec/requests/projects/cycle_analytics_events_spec.rb2
-rw-r--r--spec/requests/projects/environments_controller_spec.rb4
-rw-r--r--spec/requests/projects/google_cloud/configuration_controller_spec.rb2
-rw-r--r--spec/requests/projects/google_cloud/databases_controller_spec.rb2
-rw-r--r--spec/requests/projects/google_cloud/deployments_controller_spec.rb114
-rw-r--r--spec/requests/projects/google_cloud/gcp_regions_controller_spec.rb2
-rw-r--r--spec/requests/projects/google_cloud/revoke_oauth_controller_spec.rb2
-rw-r--r--spec/requests/projects/google_cloud/service_accounts_controller_spec.rb2
-rw-r--r--spec/requests/projects/incident_management/timeline_events_spec.rb4
-rw-r--r--spec/requests/projects/issue_links_controller_spec.rb26
-rw-r--r--spec/requests/projects/issues_controller_spec.rb39
-rw-r--r--spec/requests/projects/merge_requests_controller_spec.rb5
-rw-r--r--spec/requests/projects/merge_requests_discussions_spec.rb295
-rw-r--r--spec/requests/projects/merge_requests_spec.rb11
-rw-r--r--spec/requests/projects/metrics/dashboards/builder_spec.rb16
-rw-r--r--spec/requests/projects/metrics_dashboard_spec.rb12
-rw-r--r--spec/requests/projects/ml/candidates_controller_spec.rb53
-rw-r--r--spec/requests/projects/ml/experiments_controller_spec.rb230
-rw-r--r--spec/requests/projects/pipelines_controller_spec.rb36
-rw-r--r--spec/requests/projects/settings/access_tokens_controller_spec.rb2
-rw-r--r--spec/requests/projects/uploads_spec.rb2
-rw-r--r--spec/requests/projects/usage_quotas_spec.rb2
-rw-r--r--spec/requests/projects/wikis_controller_spec.rb72
-rw-r--r--spec/requests/projects/work_items_spec.rb178
-rw-r--r--spec/requests/rack_attack_global_spec.rb2
-rw-r--r--spec/requests/registrations_controller_spec.rb24
-rw-r--r--spec/requests/sandbox_controller_spec.rb2
-rw-r--r--spec/requests/search_controller_spec.rb10
-rw-r--r--spec/requests/self_monitoring_project_spec.rb213
-rw-r--r--spec/requests/sessions_spec.rb48
-rw-r--r--spec/requests/time_tracking/timelogs_controller_spec.rb46
-rw-r--r--spec/requests/users/pins_spec.rb67
-rw-r--r--spec/requests/users_controller_spec.rb142
-rw-r--r--spec/requests/verifies_with_email_spec.rb4
-rw-r--r--spec/requests/web_ide/remote_ide_controller_spec.rb2
-rw-r--r--spec/routing/directs/subscription_portal_spec.rb63
-rw-r--r--spec/routing/import_routing_spec.rb42
-rw-r--r--spec/routing/project_routing_spec.rb102
-rw-r--r--spec/routing/user_routing_spec.rb2
-rw-r--r--spec/rubocop/cop/background_migration/feature_category_spec.rb2
-rw-r--r--spec/rubocop/cop/background_migration/missing_dictionary_file_spec.rb137
-rw-r--r--spec/rubocop/cop/gettext/static_identifier_spec.rb174
-rw-r--r--spec/rubocop/cop/gitlab/avoid_feature_get_spec.rb2
-rw-r--r--spec/rubocop/cop/gitlab/deprecate_track_redis_hll_event_spec.rb17
-rw-r--r--spec/rubocop/cop/gitlab/doc_url_spec.rb2
-rw-r--r--spec/rubocop/cop/gitlab/keys_first_and_values_first_spec.rb2
-rw-r--r--spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb15
-rw-r--r--spec/rubocop/cop/gitlab/service_response_spec.rb2
-rw-r--r--spec/rubocop/cop/graphql/authorize_types_spec.rb22
-rw-r--r--spec/rubocop/cop/lint/last_keyword_argument_spec.rb2
-rw-r--r--spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb2
-rw-r--r--spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb2
-rw-r--r--spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb24
-rw-r--r--spec/rubocop/cop/migration/add_reference_spec.rb2
-rw-r--r--spec/rubocop/cop/migration/background_migrations_spec.rb2
-rw-r--r--spec/rubocop/cop/migration/batch_migrations_post_only_spec.rb2
-rw-r--r--spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb8
-rw-r--r--spec/rubocop/cop/migration/schedule_async_spec.rb1
-rw-r--r--spec/rubocop/cop/migration/update_column_in_batches_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/avoid_conditional_statements_spec.rb42
-rw-r--r--spec/rubocop/cop/rspec/avoid_test_prof_spec.rb2
-rw-r--r--spec/rubocop/cop/rspec/htt_party_basic_auth_spec.rb40
-rw-r--r--spec/rubocop/cop/rspec/httparty_basic_auth_spec.rb40
-rw-r--r--spec/rubocop/cop/rspec/invalid_feature_category_spec.rb14
-rw-r--r--spec/rubocop/cop/rspec/misspelled_aggregate_failures_spec.rb136
-rw-r--r--spec/rubocop/cop/rspec/shared_groups_metadata_spec.rb70
-rw-r--r--spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb48
-rw-r--r--spec/rubocop/cop/search/namespaced_class_spec.rb100
-rw-r--r--spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb123
-rw-r--r--spec/scripts/api/create_merge_request_discussion_spec.rb40
-rw-r--r--spec/scripts/api/get_package_and_test_job_spec.rb147
-rw-r--r--spec/scripts/create_pipeline_failure_incident_spec.rb120
-rw-r--r--spec/scripts/database/schema_validator_spec.rb67
-rw-r--r--spec/scripts/failed_tests_spec.rb6
-rw-r--r--spec/scripts/generate_failed_package_and_test_mr_message_spec.rb79
-rw-r--r--spec/scripts/generate_rspec_pipeline_spec.rb253
-rw-r--r--spec/scripts/lib/glfm/shared_spec.rb3
-rw-r--r--spec/scripts/lib/glfm/update_example_snapshots_spec.rb3
-rw-r--r--spec/scripts/pipeline/create_test_failure_issues_spec.rb188
-rw-r--r--spec/scripts/pipeline_test_report_builder_spec.rb5
-rw-r--r--spec/scripts/review_apps/automated_cleanup_spec.rb262
-rw-r--r--spec/serializers/access_token_entity_base_spec.rb2
-rw-r--r--spec/serializers/admin/abuse_report_details_entity_spec.rb158
-rw-r--r--spec/serializers/admin/abuse_report_details_serializer_spec.rb20
-rw-r--r--spec/serializers/admin/abuse_report_entity_spec.rb40
-rw-r--r--spec/serializers/admin/abuse_report_serializer_spec.rb23
-rw-r--r--spec/serializers/analytics_issue_entity_spec.rb6
-rw-r--r--spec/serializers/build_details_entity_spec.rb10
-rw-r--r--spec/serializers/ci/codequality_mr_diff_entity_spec.rb2
-rw-r--r--spec/serializers/ci/downloadable_artifact_entity_spec.rb3
-rw-r--r--spec/serializers/ci/job_entity_spec.rb6
-rw-r--r--spec/serializers/ci/pipeline_entity_spec.rb8
-rw-r--r--spec/serializers/cluster_application_entity_spec.rb81
-rw-r--r--spec/serializers/cluster_entity_spec.rb14
-rw-r--r--spec/serializers/cluster_serializer_spec.rb4
-rw-r--r--spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb1
-rw-r--r--spec/serializers/deploy_keys/deploy_key_entity_spec.rb1
-rw-r--r--spec/serializers/diff_file_entity_spec.rb4
-rw-r--r--spec/serializers/diff_viewer_entity_spec.rb47
-rw-r--r--spec/serializers/discussion_diff_file_entity_spec.rb3
-rw-r--r--spec/serializers/entity_date_helper_spec.rb10
-rw-r--r--spec/serializers/environment_entity_spec.rb44
-rw-r--r--spec/serializers/environment_serializer_spec.rb5
-rw-r--r--spec/serializers/environment_status_entity_spec.rb3
-rw-r--r--spec/serializers/group_child_entity_spec.rb9
-rw-r--r--spec/serializers/group_deploy_key_entity_spec.rb1
-rw-r--r--spec/serializers/group_issuable_autocomplete_entity_spec.rb3
-rw-r--r--spec/serializers/import/bulk_import_entity_spec.rb2
-rw-r--r--spec/serializers/import/github_failure_entity_spec.rb319
-rw-r--r--spec/serializers/import/github_failure_serializer_spec.rb71
-rw-r--r--spec/serializers/integrations/field_entity_spec.rb11
-rw-r--r--spec/serializers/issue_board_entity_spec.rb24
-rw-r--r--spec/serializers/issue_entity_spec.rb25
-rw-r--r--spec/serializers/issue_sidebar_basic_entity_spec.rb5
-rw-r--r--spec/serializers/jira_connect/app_data_serializer_spec.rb11
-rw-r--r--spec/serializers/linked_project_issue_entity_spec.rb12
-rw-r--r--spec/serializers/merge_request_metrics_helper_spec.rb12
-rw-r--r--spec/serializers/merge_request_poll_cached_widget_entity_spec.rb42
-rw-r--r--spec/serializers/merge_request_poll_widget_entity_spec.rb8
-rw-r--r--spec/serializers/note_entity_spec.rb63
-rw-r--r--spec/serializers/pipeline_details_entity_spec.rb34
-rw-r--r--spec/serializers/pipeline_serializer_spec.rb48
-rw-r--r--spec/serializers/profile/event_entity_spec.rb149
-rw-r--r--spec/serializers/project_import_entity_spec.rb30
-rw-r--r--spec/serializers/runner_entity_spec.rb18
-rw-r--r--spec/services/access_token_validation_service_spec.rb2
-rw-r--r--spec/services/achievements/award_service_spec.rb80
-rw-r--r--spec/services/achievements/destroy_service_spec.rb39
-rw-r--r--spec/services/achievements/revoke_service_spec.rb66
-rw-r--r--spec/services/achievements/update_service_spec.rb48
-rw-r--r--spec/services/admin/abuse_report_update_service_spec.rb199
-rw-r--r--spec/services/admin/set_feature_flag_service_spec.rb2
-rw-r--r--spec/services/alert_management/alerts/todo/create_service_spec.rb2
-rw-r--r--spec/services/alert_management/alerts/update_service_spec.rb2
-rw-r--r--spec/services/alert_management/create_alert_issue_service_spec.rb2
-rw-r--r--spec/services/alert_management/http_integrations/create_service_spec.rb2
-rw-r--r--spec/services/alert_management/http_integrations/destroy_service_spec.rb2
-rw-r--r--spec/services/alert_management/http_integrations/update_service_spec.rb2
-rw-r--r--spec/services/alert_management/metric_images/upload_service_spec.rb2
-rw-r--r--spec/services/alert_management/process_prometheus_alert_service_spec.rb2
-rw-r--r--spec/services/analytics/cycle_analytics/stages/list_service_spec.rb2
-rw-r--r--spec/services/application_settings/update_service_spec.rb6
-rw-r--r--spec/services/audit_event_service_spec.rb2
-rw-r--r--spec/services/audit_events/build_service_spec.rb2
-rw-r--r--spec/services/auth/container_registry_authentication_service_spec.rb2
-rw-r--r--spec/services/auth/dependency_proxy_authentication_service_spec.rb2
-rw-r--r--spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb2
-rw-r--r--spec/services/authorized_project_update/periodic_recalculate_service_spec.rb2
-rw-r--r--spec/services/authorized_project_update/project_access_changed_service_spec.rb2
-rw-r--r--spec/services/authorized_project_update/project_recalculate_per_user_service_spec.rb2
-rw-r--r--spec/services/authorized_project_update/project_recalculate_service_spec.rb2
-rw-r--r--spec/services/auto_merge/base_service_spec.rb2
-rw-r--r--spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb2
-rw-r--r--spec/services/auto_merge_service_spec.rb2
-rw-r--r--spec/services/award_emojis/add_service_spec.rb2
-rw-r--r--spec/services/award_emojis/base_service_spec.rb2
-rw-r--r--spec/services/award_emojis/collect_user_emoji_service_spec.rb2
-rw-r--r--spec/services/award_emojis/copy_service_spec.rb2
-rw-r--r--spec/services/award_emojis/destroy_service_spec.rb2
-rw-r--r--spec/services/award_emojis/toggle_service_spec.rb2
-rw-r--r--spec/services/base_container_service_spec.rb2
-rw-r--r--spec/services/base_count_service_spec.rb2
-rw-r--r--spec/services/boards/create_service_spec.rb2
-rw-r--r--spec/services/boards/destroy_service_spec.rb2
-rw-r--r--spec/services/boards/issues/create_service_spec.rb2
-rw-r--r--spec/services/boards/issues/list_service_spec.rb14
-rw-r--r--spec/services/boards/issues/move_service_spec.rb2
-rw-r--r--spec/services/boards/lists/create_service_spec.rb2
-rw-r--r--spec/services/boards/lists/destroy_service_spec.rb2
-rw-r--r--spec/services/boards/lists/list_service_spec.rb2
-rw-r--r--spec/services/boards/lists/move_service_spec.rb2
-rw-r--r--spec/services/boards/lists/update_service_spec.rb2
-rw-r--r--spec/services/boards/visits/create_service_spec.rb2
-rw-r--r--spec/services/branches/create_service_spec.rb4
-rw-r--r--spec/services/branches/delete_merged_service_spec.rb2
-rw-r--r--spec/services/branches/delete_service_spec.rb2
-rw-r--r--spec/services/branches/diverging_commit_counts_service_spec.rb2
-rw-r--r--spec/services/branches/validate_new_service_spec.rb2
-rw-r--r--spec/services/bulk_create_integration_service_spec.rb2
-rw-r--r--spec/services/bulk_imports/archive_extraction_service_spec.rb2
-rw-r--r--spec/services/bulk_imports/batched_relation_export_service_spec.rb104
-rw-r--r--spec/services/bulk_imports/create_service_spec.rb321
-rw-r--r--spec/services/bulk_imports/export_service_spec.rb53
-rw-r--r--spec/services/bulk_imports/file_decompression_service_spec.rb2
-rw-r--r--spec/services/bulk_imports/file_download_service_spec.rb2
-rw-r--r--spec/services/bulk_imports/file_export_service_spec.rb64
-rw-r--r--spec/services/bulk_imports/lfs_objects_export_service_spec.rb25
-rw-r--r--spec/services/bulk_imports/relation_batch_export_service_spec.rb67
-rw-r--r--spec/services/bulk_imports/relation_export_service_spec.rb24
-rw-r--r--spec/services/bulk_imports/repository_bundle_export_service_spec.rb2
-rw-r--r--spec/services/bulk_imports/tree_export_service_spec.rb12
-rw-r--r--spec/services/bulk_imports/uploads_export_service_spec.rb35
-rw-r--r--spec/services/bulk_push_event_payload_service_spec.rb2
-rw-r--r--spec/services/bulk_update_integration_service_spec.rb10
-rw-r--r--spec/services/captcha/captcha_verification_service_spec.rb2
-rw-r--r--spec/services/chat_names/find_user_service_spec.rb2
-rw-r--r--spec/services/ci/abort_pipelines_service_spec.rb2
-rw-r--r--spec/services/ci/append_build_trace_service_spec.rb2
-rw-r--r--spec/services/ci/archive_trace_service_spec.rb34
-rw-r--r--spec/services/ci/build_cancel_service_spec.rb2
-rw-r--r--spec/services/ci/build_erase_service_spec.rb2
-rw-r--r--spec/services/ci/build_report_result_service_spec.rb2
-rw-r--r--spec/services/ci/build_unschedule_service_spec.rb2
-rw-r--r--spec/services/ci/catalog/validate_resource_service_spec.rb57
-rw-r--r--spec/services/ci/change_variable_service_spec.rb2
-rw-r--r--spec/services/ci/change_variables_service_spec.rb2
-rw-r--r--spec/services/ci/compare_accessibility_reports_service_spec.rb2
-rw-r--r--spec/services/ci/compare_codequality_reports_service_spec.rb2
-rw-r--r--spec/services/ci/compare_reports_base_service_spec.rb2
-rw-r--r--spec/services/ci/compare_test_reports_service_spec.rb2
-rw-r--r--spec/services/ci/components/fetch_service_spec.rb2
-rw-r--r--spec/services/ci/copy_cross_database_associations_service_spec.rb2
-rw-r--r--spec/services/ci/create_downstream_pipeline_service_spec.rb14
-rw-r--r--spec/services/ci/create_pipeline_service/artifacts_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service/cache_spec.rb18
-rw-r--r--spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb11
-rw-r--r--spec/services/ci/create_pipeline_service/custom_config_content_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service/custom_yaml_tags_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/dry_run_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/environment_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/evaluate_runner_tags_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/include_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service/limit_active_jobs_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/logger_spec.rb6
-rw-r--r--spec/services/ci/create_pipeline_service/merge_requests_spec.rb15
-rw-r--r--spec/services/ci/create_pipeline_service/needs_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/parallel_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/parameter_content_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/partitioning_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service/rate_limit_spec.rb5
-rw-r--r--spec/services/ci/create_pipeline_service/rules_spec.rb139
-rw-r--r--spec/services/ci/create_pipeline_service/scripts_spec.rb28
-rw-r--r--spec/services/ci/create_pipeline_service/tags_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service/variables_spec.rb3
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb333
-rw-r--r--spec/services/ci/create_web_ide_terminal_service_spec.rb2
-rw-r--r--spec/services/ci/daily_build_group_report_result_service_spec.rb2
-rw-r--r--spec/services/ci/delete_objects_service_spec.rb2
-rw-r--r--spec/services/ci/delete_unit_tests_service_spec.rb2
-rw-r--r--spec/services/ci/deployments/destroy_service_spec.rb2
-rw-r--r--spec/services/ci/destroy_pipeline_service_spec.rb2
-rw-r--r--spec/services/ci/destroy_secure_file_service_spec.rb2
-rw-r--r--spec/services/ci/disable_user_pipeline_schedules_service_spec.rb2
-rw-r--r--spec/services/ci/drop_pipeline_service_spec.rb2
-rw-r--r--spec/services/ci/ensure_stage_service_spec.rb2
-rw-r--r--spec/services/ci/expire_pipeline_cache_service_spec.rb2
-rw-r--r--spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb2
-rw-r--r--spec/services/ci/find_exposed_artifacts_service_spec.rb2
-rw-r--r--spec/services/ci/generate_codequality_mr_diff_report_service_spec.rb2
-rw-r--r--spec/services/ci/generate_coverage_reports_service_spec.rb2
-rw-r--r--spec/services/ci/generate_kubeconfig_service_spec.rb14
-rw-r--r--spec/services/ci/generate_terraform_reports_service_spec.rb2
-rw-r--r--spec/services/ci/job_artifacts/bulk_delete_by_project_service_spec.rb121
-rw-r--r--spec/services/ci/job_artifacts/create_service_spec.rb525
-rw-r--r--spec/services/ci/job_artifacts/delete_project_artifacts_service_spec.rb2
-rw-r--r--spec/services/ci/job_artifacts/delete_service_spec.rb2
-rw-r--r--spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb40
-rw-r--r--spec/services/ci/job_artifacts/destroy_associations_service_spec.rb31
-rw-r--r--spec/services/ci/job_artifacts/destroy_batch_service_spec.rb15
-rw-r--r--spec/services/ci/job_artifacts/expire_project_build_artifacts_service_spec.rb2
-rw-r--r--spec/services/ci/job_artifacts/track_artifact_report_service_spec.rb62
-rw-r--r--spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb3
-rw-r--r--spec/services/ci/job_token_scope/add_project_service_spec.rb4
-rw-r--r--spec/services/ci/list_config_variables_service_spec.rb2
-rw-r--r--spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb2
-rw-r--r--spec/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service_spec.rb2
-rw-r--r--spec/services/ci/pipeline_artifacts/destroy_all_expired_service_spec.rb3
-rw-r--r--spec/services/ci/pipeline_bridge_status_service_spec.rb2
-rw-r--r--spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb2
-rw-r--r--spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb58
-rw-r--r--spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb21
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_no_needs.yml31
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_build_cancels_test1_and_test2_have_when.yml46
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_build_cancels_with_allow_failure_test1_and_test2_have_when.yml47
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_no_prev_stage.yml29
-rw-r--r--spec/services/ci/pipeline_schedules/take_ownership_service_spec.rb2
-rw-r--r--spec/services/ci/pipeline_trigger_service_spec.rb2
-rw-r--r--spec/services/ci/pipelines/add_job_service_spec.rb12
-rw-r--r--spec/services/ci/pipelines/hook_service_spec.rb2
-rw-r--r--spec/services/ci/play_bridge_service_spec.rb2
-rw-r--r--spec/services/ci/play_build_service_spec.rb5
-rw-r--r--spec/services/ci/play_manual_stage_service_spec.rb7
-rw-r--r--spec/services/ci/prepare_build_service_spec.rb2
-rw-r--r--spec/services/ci/process_build_service_spec.rb2
-rw-r--r--spec/services/ci/process_pipeline_service_spec.rb2
-rw-r--r--spec/services/ci/process_sync_events_service_spec.rb5
-rw-r--r--spec/services/ci/prometheus_metrics/observe_histograms_service_spec.rb2
-rw-r--r--spec/services/ci/queue/pending_builds_strategy_spec.rb2
-rw-r--r--spec/services/ci/register_job_service_spec.rb1166
-rw-r--r--spec/services/ci/reset_skipped_jobs_service_spec.rb487
-rw-r--r--spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb2
-rw-r--r--spec/services/ci/retry_job_service_spec.rb15
-rw-r--r--spec/services/ci/retry_pipeline_service_spec.rb22
-rw-r--r--spec/services/ci/run_scheduled_build_service_spec.rb5
-rw-r--r--spec/services/ci/runners/create_runner_service_spec.rb203
-rw-r--r--spec/services/ci/runners/process_runner_version_update_service_spec.rb13
-rw-r--r--spec/services/ci/runners/register_runner_service_spec.rb64
-rw-r--r--spec/services/ci/runners/stale_machines_cleanup_service_spec.rb45
-rw-r--r--spec/services/ci/runners/stale_managers_cleanup_service_spec.rb45
-rw-r--r--spec/services/ci/runners/unregister_runner_manager_service_spec.rb63
-rw-r--r--spec/services/ci/stuck_builds/drop_pending_service_spec.rb2
-rw-r--r--spec/services/ci/stuck_builds/drop_running_service_spec.rb2
-rw-r--r--spec/services/ci/stuck_builds/drop_scheduled_service_spec.rb2
-rw-r--r--spec/services/ci/test_failure_history_service_spec.rb2
-rw-r--r--spec/services/ci/track_failed_build_service_spec.rb2
-rw-r--r--spec/services/ci/unlock_artifacts_service_spec.rb20
-rw-r--r--spec/services/ci/update_build_queue_service_spec.rb2
-rw-r--r--spec/services/ci/update_instance_variables_service_spec.rb2
-rw-r--r--spec/services/ci/update_pending_build_service_spec.rb2
-rw-r--r--spec/services/clusters/agent_tokens/create_service_spec.rb8
-rw-r--r--spec/services/clusters/agent_tokens/revoke_service_spec.rb77
-rw-r--r--spec/services/clusters/agent_tokens/track_usage_service_spec.rb2
-rw-r--r--spec/services/clusters/agents/authorizations/ci_access/filter_service_spec.rb100
-rw-r--r--spec/services/clusters/agents/authorizations/ci_access/refresh_service_spec.rb154
-rw-r--r--spec/services/clusters/agents/authorizations/user_access/refresh_service_spec.rb181
-rw-r--r--spec/services/clusters/agents/authorize_proxy_user_service_spec.rb65
-rw-r--r--spec/services/clusters/agents/create_activity_event_service_spec.rb13
-rw-r--r--spec/services/clusters/agents/create_service_spec.rb2
-rw-r--r--spec/services/clusters/agents/delete_expired_events_service_spec.rb2
-rw-r--r--spec/services/clusters/agents/delete_service_spec.rb2
-rw-r--r--spec/services/clusters/agents/filter_authorizations_service_spec.rb100
-rw-r--r--spec/services/clusters/agents/refresh_authorization_service_spec.rb154
-rw-r--r--spec/services/clusters/build_kubernetes_namespace_service_spec.rb2
-rw-r--r--spec/services/clusters/build_service_spec.rb2
-rw-r--r--spec/services/clusters/cleanup/project_namespace_service_spec.rb2
-rw-r--r--spec/services/clusters/cleanup/service_account_service_spec.rb18
-rw-r--r--spec/services/clusters/create_service_spec.rb4
-rw-r--r--spec/services/clusters/destroy_service_spec.rb2
-rw-r--r--spec/services/clusters/integrations/create_service_spec.rb2
-rw-r--r--spec/services/clusters/integrations/prometheus_health_check_service_spec.rb2
-rw-r--r--spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb4
-rw-r--r--spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb2
-rw-r--r--spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb2
-rw-r--r--spec/services/clusters/kubernetes_spec.rb2
-rw-r--r--spec/services/clusters/management/validate_management_project_permissions_service_spec.rb2
-rw-r--r--spec/services/clusters/update_service_spec.rb2
-rw-r--r--spec/services/cohorts_service_spec.rb2
-rw-r--r--spec/services/commits/cherry_pick_service_spec.rb2
-rw-r--r--spec/services/commits/commit_patch_service_spec.rb2
-rw-r--r--spec/services/commits/tag_service_spec.rb2
-rw-r--r--spec/services/compare_service_spec.rb2
-rw-r--r--spec/services/concerns/audit_event_save_type_spec.rb2
-rw-r--r--spec/services/concerns/exclusive_lease_guard_spec.rb48
-rw-r--r--spec/services/concerns/merge_requests/assigns_merge_params_spec.rb2
-rw-r--r--spec/services/concerns/rate_limited_service_spec.rb2
-rw-r--r--spec/services/container_expiration_policies/cleanup_service_spec.rb3
-rw-r--r--spec/services/container_expiration_policies/update_service_spec.rb2
-rw-r--r--spec/services/customer_relations/contacts/create_service_spec.rb6
-rw-r--r--spec/services/customer_relations/contacts/update_service_spec.rb2
-rw-r--r--spec/services/customer_relations/organizations/create_service_spec.rb8
-rw-r--r--spec/services/customer_relations/organizations/update_service_spec.rb14
-rw-r--r--spec/services/database/consistency_check_service_spec.rb2
-rw-r--r--spec/services/database/consistency_fix_service_spec.rb2
-rw-r--r--spec/services/dependency_proxy/auth_token_service_spec.rb2
-rw-r--r--spec/services/dependency_proxy/find_cached_manifest_service_spec.rb2
-rw-r--r--spec/services/dependency_proxy/group_settings/update_service_spec.rb2
-rw-r--r--spec/services/dependency_proxy/head_manifest_service_spec.rb2
-rw-r--r--spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb2
-rw-r--r--spec/services/dependency_proxy/request_token_service_spec.rb2
-rw-r--r--spec/services/deploy_keys/create_service_spec.rb2
-rw-r--r--spec/services/deployments/archive_in_project_service_spec.rb2
-rw-r--r--spec/services/deployments/create_for_build_service_spec.rb2
-rw-r--r--spec/services/deployments/create_service_spec.rb2
-rw-r--r--spec/services/deployments/link_merge_requests_service_spec.rb2
-rw-r--r--spec/services/deployments/older_deployments_drop_service_spec.rb2
-rw-r--r--spec/services/deployments/update_environment_service_spec.rb2
-rw-r--r--spec/services/deployments/update_service_spec.rb2
-rw-r--r--spec/services/design_management/copy_design_collection/copy_service_spec.rb3
-rw-r--r--spec/services/design_management/copy_design_collection/queue_service_spec.rb3
-rw-r--r--spec/services/design_management/delete_designs_service_spec.rb4
-rw-r--r--spec/services/design_management/design_user_notes_count_service_spec.rb2
-rw-r--r--spec/services/design_management/generate_image_versions_service_spec.rb2
-rw-r--r--spec/services/design_management/move_designs_service_spec.rb2
-rw-r--r--spec/services/design_management/save_designs_service_spec.rb5
-rw-r--r--spec/services/discussions/capture_diff_note_position_service_spec.rb2
-rw-r--r--spec/services/discussions/capture_diff_note_positions_service_spec.rb2
-rw-r--r--spec/services/discussions/update_diff_position_service_spec.rb2
-rw-r--r--spec/services/draft_notes/create_service_spec.rb2
-rw-r--r--spec/services/draft_notes/destroy_service_spec.rb2
-rw-r--r--spec/services/draft_notes/publish_service_spec.rb2
-rw-r--r--spec/services/emails/confirm_service_spec.rb2
-rw-r--r--spec/services/emails/create_service_spec.rb2
-rw-r--r--spec/services/emails/destroy_service_spec.rb2
-rw-r--r--spec/services/environments/auto_stop_service_spec.rb3
-rw-r--r--spec/services/environments/canary_ingress/update_service_spec.rb3
-rw-r--r--spec/services/environments/create_for_build_service_spec.rb2
-rw-r--r--spec/services/environments/reset_auto_stop_service_spec.rb2
-rw-r--r--spec/services/environments/schedule_to_delete_review_apps_service_spec.rb2
-rw-r--r--spec/services/environments/stop_service_spec.rb2
-rw-r--r--spec/services/error_tracking/base_service_spec.rb2
-rw-r--r--spec/services/error_tracking/collect_error_service_spec.rb2
-rw-r--r--spec/services/error_tracking/issue_details_service_spec.rb2
-rw-r--r--spec/services/error_tracking/issue_latest_event_service_spec.rb2
-rw-r--r--spec/services/error_tracking/issue_update_service_spec.rb2
-rw-r--r--spec/services/error_tracking/list_issues_service_spec.rb2
-rw-r--r--spec/services/event_create_service_spec.rb60
-rw-r--r--spec/services/events/destroy_service_spec.rb2
-rw-r--r--spec/services/events/render_service_spec.rb2
-rw-r--r--spec/services/feature_flags/create_service_spec.rb10
-rw-r--r--spec/services/feature_flags/destroy_service_spec.rb5
-rw-r--r--spec/services/feature_flags/hook_service_spec.rb2
-rw-r--r--spec/services/feature_flags/update_service_spec.rb7
-rw-r--r--spec/services/files/create_service_spec.rb2
-rw-r--r--spec/services/files/delete_service_spec.rb6
-rw-r--r--spec/services/files/multi_service_spec.rb16
-rw-r--r--spec/services/files/update_service_spec.rb6
-rw-r--r--spec/services/git/base_hooks_service_spec.rb38
-rw-r--r--spec/services/git/branch_hooks_service_spec.rb2
-rw-r--r--spec/services/git/branch_push_service_spec.rb2
-rw-r--r--spec/services/git/process_ref_changes_service_spec.rb2
-rw-r--r--spec/services/git/tag_hooks_service_spec.rb2
-rw-r--r--spec/services/git/tag_push_service_spec.rb2
-rw-r--r--spec/services/git/wiki_push_service/change_spec.rb12
-rw-r--r--spec/services/google_cloud/create_cloudsql_instance_service_spec.rb2
-rw-r--r--spec/services/google_cloud/create_service_accounts_service_spec.rb2
-rw-r--r--spec/services/google_cloud/enable_cloud_run_service_spec.rb2
-rw-r--r--spec/services/google_cloud/enable_cloudsql_service_spec.rb2
-rw-r--r--spec/services/google_cloud/gcp_region_add_or_replace_service_spec.rb2
-rw-r--r--spec/services/google_cloud/generate_pipeline_service_spec.rb2
-rw-r--r--spec/services/google_cloud/get_cloudsql_instances_service_spec.rb2
-rw-r--r--spec/services/google_cloud/service_accounts_service_spec.rb2
-rw-r--r--spec/services/google_cloud/setup_cloudsql_instance_service_spec.rb2
-rw-r--r--spec/services/gpg_keys/create_service_spec.rb2
-rw-r--r--spec/services/grafana/proxy_service_spec.rb2
-rw-r--r--spec/services/gravatar_service_spec.rb2
-rw-r--r--spec/services/groups/auto_devops_service_spec.rb2
-rw-r--r--spec/services/groups/autocomplete_service_spec.rb2
-rw-r--r--spec/services/groups/deploy_tokens/create_service_spec.rb2
-rw-r--r--spec/services/groups/deploy_tokens/destroy_service_spec.rb2
-rw-r--r--spec/services/groups/deploy_tokens/revoke_service_spec.rb2
-rw-r--r--spec/services/groups/group_links/create_service_spec.rb2
-rw-r--r--spec/services/groups/group_links/destroy_service_spec.rb2
-rw-r--r--spec/services/groups/group_links/update_service_spec.rb4
-rw-r--r--spec/services/groups/import_export/export_service_spec.rb2
-rw-r--r--spec/services/groups/import_export/import_service_spec.rb2
-rw-r--r--spec/services/groups/merge_requests_count_service_spec.rb2
-rw-r--r--spec/services/groups/nested_create_service_spec.rb2
-rw-r--r--spec/services/groups/open_issues_count_service_spec.rb2
-rw-r--r--spec/services/groups/participants_service_spec.rb2
-rw-r--r--spec/services/groups/transfer_service_spec.rb61
-rw-r--r--spec/services/groups/update_service_spec.rb2
-rw-r--r--spec/services/groups/update_shared_runners_service_spec.rb2
-rw-r--r--spec/services/groups/update_statistics_service_spec.rb2
-rw-r--r--spec/services/ide/base_config_service_spec.rb2
-rw-r--r--spec/services/ide/schemas_config_service_spec.rb2
-rw-r--r--spec/services/ide/terminal_config_service_spec.rb2
-rw-r--r--spec/services/import/bitbucket_server_service_spec.rb4
-rw-r--r--spec/services/import/fogbugz_service_spec.rb5
-rw-r--r--spec/services/import/github/cancel_project_import_service_spec.rb14
-rw-r--r--spec/services/import/github/notes/create_service_spec.rb2
-rw-r--r--spec/services/import/github_service_spec.rb7
-rw-r--r--spec/services/import/gitlab_projects/create_project_service_spec.rb12
-rw-r--r--spec/services/import/gitlab_projects/file_acquisition_strategies/file_upload_spec.rb2
-rw-r--r--spec/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3_spec.rb2
-rw-r--r--spec/services/import/prepare_service_spec.rb2
-rw-r--r--spec/services/import/validate_remote_git_endpoint_service_spec.rb24
-rw-r--r--spec/services/import_csv/base_service_spec.rb69
-rw-r--r--spec/services/import_export_clean_up_service_spec.rb2
-rw-r--r--spec/services/incident_management/incidents/create_service_spec.rb2
-rw-r--r--spec/services/incident_management/issuable_escalation_statuses/after_update_service_spec.rb3
-rw-r--r--spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb2
-rw-r--r--spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb2
-rw-r--r--spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb3
-rw-r--r--spec/services/incident_management/pager_duty/create_incident_issue_service_spec.rb2
-rw-r--r--spec/services/incident_management/pager_duty/process_webhook_service_spec.rb2
-rw-r--r--spec/services/incident_management/timeline_event_tags/create_service_spec.rb2
-rw-r--r--spec/services/incident_management/timeline_events/create_service_spec.rb4
-rw-r--r--spec/services/incident_management/timeline_events/destroy_service_spec.rb3
-rw-r--r--spec/services/incident_management/timeline_events/update_service_spec.rb1
-rw-r--r--spec/services/integrations/propagate_service_spec.rb2
-rw-r--r--spec/services/integrations/slack_event_service_spec.rb56
-rw-r--r--spec/services/integrations/slack_events/app_home_opened_service_spec.rb113
-rw-r--r--spec/services/integrations/slack_events/url_verification_service_spec.rb11
-rw-r--r--spec/services/integrations/slack_interaction_service_spec.rb70
-rw-r--r--spec/services/integrations/slack_interactions/block_action_service_spec.rb48
-rw-r--r--spec/services/integrations/slack_interactions/incident_management/incident_modal_closed_service_spec.rb78
-rw-r--r--spec/services/integrations/slack_interactions/incident_management/incident_modal_opened_service_spec.rb141
-rw-r--r--spec/services/integrations/slack_interactions/incident_management/incident_modal_submit_service_spec.rb296
-rw-r--r--spec/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler_spec.rb158
-rw-r--r--spec/services/integrations/slack_option_service_spec.rb76
-rw-r--r--spec/services/integrations/slack_options/label_search_handler_spec.rb47
-rw-r--r--spec/services/integrations/slack_options/user_search_handler_spec.rb52
-rw-r--r--spec/services/integrations/test/project_service_spec.rb2
-rw-r--r--spec/services/issuable/bulk_update_service_spec.rb2
-rw-r--r--spec/services/issuable/callbacks/milestone_spec.rb101
-rw-r--r--spec/services/issuable/common_system_notes_service_spec.rb2
-rw-r--r--spec/services/issuable/destroy_label_links_service_spec.rb2
-rw-r--r--spec/services/issuable/destroy_service_spec.rb2
-rw-r--r--spec/services/issuable/discussions_list_service_spec.rb2
-rw-r--r--spec/services/issuable/process_assignees_spec.rb2
-rw-r--r--spec/services/issue_links/create_service_spec.rb3
-rw-r--r--spec/services/issue_links/destroy_service_spec.rb3
-rw-r--r--spec/services/issue_links/list_service_spec.rb2
-rw-r--r--spec/services/issues/after_create_service_spec.rb9
-rw-r--r--spec/services/issues/base_service_spec.rb9
-rw-r--r--spec/services/issues/build_service_spec.rb36
-rw-r--r--spec/services/issues/clone_service_spec.rb2
-rw-r--r--spec/services/issues/close_service_spec.rb75
-rw-r--r--spec/services/issues/create_service_spec.rb77
-rw-r--r--spec/services/issues/duplicate_service_spec.rb2
-rw-r--r--spec/services/issues/import_csv_service_spec.rb3
-rw-r--r--spec/services/issues/issuable_base_service_spec.rb9
-rw-r--r--spec/services/issues/prepare_import_csv_service_spec.rb2
-rw-r--r--spec/services/issues/referenced_merge_requests_service_spec.rb2
-rw-r--r--spec/services/issues/related_branches_service_spec.rb2
-rw-r--r--spec/services/issues/relative_position_rebalancing_service_spec.rb2
-rw-r--r--spec/services/issues/reopen_service_spec.rb12
-rw-r--r--spec/services/issues/reorder_service_spec.rb2
-rw-r--r--spec/services/issues/resolve_discussions_spec.rb14
-rw-r--r--spec/services/issues/set_crm_contacts_service_spec.rb2
-rw-r--r--spec/services/issues/update_service_spec.rb101
-rw-r--r--spec/services/issues/zoom_link_service_spec.rb3
-rw-r--r--spec/services/jira/requests/projects/list_service_spec.rb2
-rw-r--r--spec/services/jira_connect/sync_service_spec.rb12
-rw-r--r--spec/services/jira_connect_installations/destroy_service_spec.rb2
-rw-r--r--spec/services/jira_connect_installations/proxy_lifecycle_event_service_spec.rb6
-rw-r--r--spec/services/jira_connect_subscriptions/create_service_spec.rb2
-rw-r--r--spec/services/jira_import/cloud_users_mapper_service_spec.rb2
-rw-r--r--spec/services/jira_import/server_users_mapper_service_spec.rb2
-rw-r--r--spec/services/jira_import/start_import_service_spec.rb2
-rw-r--r--spec/services/jira_import/users_importer_spec.rb2
-rw-r--r--spec/services/keys/create_service_spec.rb2
-rw-r--r--spec/services/keys/destroy_service_spec.rb2
-rw-r--r--spec/services/keys/expiry_notification_service_spec.rb2
-rw-r--r--spec/services/keys/last_used_service_spec.rb60
-rw-r--r--spec/services/keys/revoke_service_spec.rb13
-rw-r--r--spec/services/labels/available_labels_service_spec.rb2
-rw-r--r--spec/services/labels/create_service_spec.rb2
-rw-r--r--spec/services/labels/find_or_create_service_spec.rb2
-rw-r--r--spec/services/labels/promote_service_spec.rb2
-rw-r--r--spec/services/labels/transfer_service_spec.rb2
-rw-r--r--spec/services/labels/update_service_spec.rb2
-rw-r--r--spec/services/lfs/lock_file_service_spec.rb2
-rw-r--r--spec/services/lfs/locks_finder_service_spec.rb2
-rw-r--r--spec/services/lfs/push_service_spec.rb2
-rw-r--r--spec/services/lfs/unlock_file_service_spec.rb2
-rw-r--r--spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb2
-rw-r--r--spec/services/loose_foreign_keys/cleaner_service_spec.rb2
-rw-r--r--spec/services/loose_foreign_keys/process_deleted_records_service_spec.rb2
-rw-r--r--spec/services/markdown_content_rewriter_service_spec.rb2
-rw-r--r--spec/services/markup/rendering_service_spec.rb19
-rw-r--r--spec/services/mattermost/create_team_service_spec.rb28
-rw-r--r--spec/services/members/approve_access_request_service_spec.rb20
-rw-r--r--spec/services/members/base_service_spec.rb5
-rw-r--r--spec/services/members/create_service_spec.rb3
-rw-r--r--spec/services/members/creator_service_spec.rb2
-rw-r--r--spec/services/members/destroy_service_spec.rb143
-rw-r--r--spec/services/members/groups/creator_service_spec.rb4
-rw-r--r--spec/services/members/import_project_team_service_spec.rb2
-rw-r--r--spec/services/members/invitation_reminder_email_service_spec.rb2
-rw-r--r--spec/services/members/invite_member_builder_spec.rb2
-rw-r--r--spec/services/members/invite_service_spec.rb3
-rw-r--r--spec/services/members/projects/creator_service_spec.rb4
-rw-r--r--spec/services/members/request_access_service_spec.rb2
-rw-r--r--spec/services/members/standard_member_builder_spec.rb2
-rw-r--r--spec/services/members/unassign_issuables_service_spec.rb2
-rw-r--r--spec/services/members/update_service_spec.rb2
-rw-r--r--spec/services/merge_requests/add_context_service_spec.rb2
-rw-r--r--spec/services/merge_requests/add_spent_time_service_spec.rb2
-rw-r--r--spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb24
-rw-r--r--spec/services/merge_requests/after_create_service_spec.rb23
-rw-r--r--spec/services/merge_requests/approval_service_spec.rb2
-rw-r--r--spec/services/merge_requests/assign_issues_service_spec.rb16
-rw-r--r--spec/services/merge_requests/base_service_spec.rb38
-rw-r--r--spec/services/merge_requests/cleanup_refs_service_spec.rb2
-rw-r--r--spec/services/merge_requests/close_service_spec.rb6
-rw-r--r--spec/services/merge_requests/conflicts/list_service_spec.rb2
-rw-r--r--spec/services/merge_requests/conflicts/resolve_service_spec.rb21
-rw-r--r--spec/services/merge_requests/create_approval_event_service_spec.rb2
-rw-r--r--spec/services/merge_requests/create_pipeline_service_spec.rb2
-rw-r--r--spec/services/merge_requests/create_service_spec.rb55
-rw-r--r--spec/services/merge_requests/delete_non_latest_diffs_service_spec.rb3
-rw-r--r--spec/services/merge_requests/execute_approval_hooks_service_spec.rb2
-rw-r--r--spec/services/merge_requests/ff_merge_service_spec.rb14
-rw-r--r--spec/services/merge_requests/get_urls_service_spec.rb2
-rw-r--r--spec/services/merge_requests/handle_assignees_change_service_spec.rb2
-rw-r--r--spec/services/merge_requests/mark_reviewer_reviewed_service_spec.rb2
-rw-r--r--spec/services/merge_requests/merge_orchestration_service_spec.rb9
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb122
-rw-r--r--spec/services/merge_requests/merge_to_ref_service_spec.rb28
-rw-r--r--spec/services/merge_requests/mergeability/check_base_service_spec.rb2
-rw-r--r--spec/services/merge_requests/mergeability/check_broken_status_service_spec.rb2
-rw-r--r--spec/services/merge_requests/mergeability/check_ci_status_service_spec.rb2
-rw-r--r--spec/services/merge_requests/mergeability/check_discussions_status_service_spec.rb2
-rw-r--r--spec/services/merge_requests/mergeability/check_draft_status_service_spec.rb2
-rw-r--r--spec/services/merge_requests/mergeability/check_open_status_service_spec.rb2
-rw-r--r--spec/services/merge_requests/mergeability/detailed_merge_status_service_spec.rb27
-rw-r--r--spec/services/merge_requests/mergeability/logger_spec.rb2
-rw-r--r--spec/services/merge_requests/mergeability/run_checks_service_spec.rb2
-rw-r--r--spec/services/merge_requests/mergeability_check_service_spec.rb22
-rw-r--r--spec/services/merge_requests/migrate_external_diffs_service_spec.rb2
-rw-r--r--spec/services/merge_requests/post_merge_service_spec.rb8
-rw-r--r--spec/services/merge_requests/push_options_handler_service_spec.rb2
-rw-r--r--spec/services/merge_requests/rebase_service_spec.rb28
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb238
-rw-r--r--spec/services/merge_requests/reload_diffs_service_spec.rb10
-rw-r--r--spec/services/merge_requests/reload_merge_head_diff_service_spec.rb2
-rw-r--r--spec/services/merge_requests/reopen_service_spec.rb8
-rw-r--r--spec/services/merge_requests/request_review_service_spec.rb2
-rw-r--r--spec/services/merge_requests/resolve_todos_service_spec.rb2
-rw-r--r--spec/services/merge_requests/resolved_discussion_notification_service_spec.rb2
-rw-r--r--spec/services/merge_requests/squash_service_spec.rb26
-rw-r--r--spec/services/merge_requests/update_assignees_service_spec.rb36
-rw-r--r--spec/services/merge_requests/update_reviewers_service_spec.rb20
-rw-r--r--spec/services/merge_requests/update_service_spec.rb65
-rw-r--r--spec/services/metrics/dashboard/annotations/create_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/annotations/delete_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/clone_dashboard_service_spec.rb6
-rw-r--r--spec/services/metrics/dashboard/cluster_dashboard_service_spec.rb3
-rw-r--r--spec/services/metrics/dashboard/cluster_metrics_embed_service_spec.rb3
-rw-r--r--spec/services/metrics/dashboard/custom_dashboard_service_spec.rb3
-rw-r--r--spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/default_embed_service_spec.rb3
-rw-r--r--spec/services/metrics/dashboard/dynamic_embed_service_spec.rb3
-rw-r--r--spec/services/metrics/dashboard/gitlab_alert_embed_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/panel_preview_service_spec.rb22
-rw-r--r--spec/services/metrics/dashboard/pod_dashboard_service_spec.rb3
-rw-r--r--spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb88
-rw-r--r--spec/services/metrics/dashboard/system_dashboard_service_spec.rb3
-rw-r--r--spec/services/metrics/dashboard/transient_embed_service_spec.rb3
-rw-r--r--spec/services/metrics/dashboard/update_dashboard_service_spec.rb2
-rw-r--r--spec/services/metrics/global_metrics_update_service_spec.rb14
-rw-r--r--spec/services/metrics/sample_metrics_service_spec.rb2
-rw-r--r--spec/services/metrics/users_starred_dashboards/create_service_spec.rb2
-rw-r--r--spec/services/metrics/users_starred_dashboards/delete_service_spec.rb2
-rw-r--r--spec/services/milestones/close_service_spec.rb2
-rw-r--r--spec/services/milestones/closed_issues_count_service_spec.rb3
-rw-r--r--spec/services/milestones/create_service_spec.rb2
-rw-r--r--spec/services/milestones/destroy_service_spec.rb2
-rw-r--r--spec/services/milestones/find_or_create_service_spec.rb2
-rw-r--r--spec/services/milestones/issues_count_service_spec.rb3
-rw-r--r--spec/services/milestones/merge_requests_count_service_spec.rb3
-rw-r--r--spec/services/milestones/promote_service_spec.rb2
-rw-r--r--spec/services/milestones/transfer_service_spec.rb2
-rw-r--r--spec/services/milestones/update_service_spec.rb2
-rw-r--r--spec/services/ml/experiment_tracking/candidate_repository_spec.rb32
-rw-r--r--spec/services/ml/experiment_tracking/experiment_repository_spec.rb2
-rw-r--r--spec/services/ml/experiment_tracking/handle_candidate_gitlab_metadata_service_spec.rb40
-rw-r--r--spec/services/namespace_settings/update_service_spec.rb2
-rw-r--r--spec/services/namespaces/in_product_marketing_emails_service_spec.rb2
-rw-r--r--spec/services/namespaces/package_settings/update_service_spec.rb2
-rw-r--r--spec/services/namespaces/statistics_refresher_service_spec.rb2
-rw-r--r--spec/services/note_summary_spec.rb2
-rw-r--r--spec/services/notes/build_service_spec.rb12
-rw-r--r--spec/services/notes/copy_service_spec.rb8
-rw-r--r--spec/services/notes/create_service_spec.rb141
-rw-r--r--spec/services/notes/destroy_service_spec.rb14
-rw-r--r--spec/services/notes/post_process_service_spec.rb2
-rw-r--r--spec/services/notes/quick_actions_service_spec.rb106
-rw-r--r--spec/services/notes/render_service_spec.rb16
-rw-r--r--spec/services/notes/resolve_service_spec.rb2
-rw-r--r--spec/services/notes/update_service_spec.rb4
-rw-r--r--spec/services/notification_recipients/build_service_spec.rb2
-rw-r--r--spec/services/notification_recipients/builder/default_spec.rb2
-rw-r--r--spec/services/notification_recipients/builder/new_note_spec.rb2
-rw-r--r--spec/services/notification_service_spec.rb34
-rw-r--r--spec/services/onboarding/progress_service_spec.rb2
-rw-r--r--spec/services/packages/cleanup/execute_policy_service_spec.rb2
-rw-r--r--spec/services/packages/cleanup/update_policy_service_spec.rb2
-rw-r--r--spec/services/packages/composer/composer_json_service_spec.rb2
-rw-r--r--spec/services/packages/composer/create_package_service_spec.rb2
-rw-r--r--spec/services/packages/composer/version_parser_service_spec.rb2
-rw-r--r--spec/services/packages/conan/create_package_file_service_spec.rb2
-rw-r--r--spec/services/packages/conan/create_package_service_spec.rb2
-rw-r--r--spec/services/packages/conan/search_service_spec.rb28
-rw-r--r--spec/services/packages/conan/single_package_search_service_spec.rb45
-rw-r--r--spec/services/packages/create_dependency_service_spec.rb2
-rw-r--r--spec/services/packages/create_event_service_spec.rb48
-rw-r--r--spec/services/packages/create_package_file_service_spec.rb2
-rw-r--r--spec/services/packages/create_temporary_package_service_spec.rb2
-rw-r--r--spec/services/packages/debian/create_package_file_service_spec.rb2
-rw-r--r--spec/services/packages/debian/extract_changes_metadata_service_spec.rb2
-rw-r--r--spec/services/packages/debian/extract_metadata_service_spec.rb86
-rw-r--r--spec/services/packages/debian/find_or_create_package_service_spec.rb21
-rw-r--r--spec/services/packages/debian/generate_distribution_service_spec.rb28
-rw-r--r--spec/services/packages/debian/parse_debian822_service_spec.rb20
-rw-r--r--spec/services/packages/debian/process_changes_service_spec.rb36
-rw-r--r--spec/services/packages/debian/process_package_file_service_spec.rb41
-rw-r--r--spec/services/packages/generic/create_package_file_service_spec.rb16
-rw-r--r--spec/services/packages/generic/find_or_create_package_service_spec.rb2
-rw-r--r--spec/services/packages/go/create_package_service_spec.rb2
-rw-r--r--spec/services/packages/go/sync_packages_service_spec.rb2
-rw-r--r--spec/services/packages/helm/extract_file_metadata_service_spec.rb2
-rw-r--r--spec/services/packages/helm/process_file_service_spec.rb2
-rw-r--r--spec/services/packages/mark_package_files_for_destruction_service_spec.rb3
-rw-r--r--spec/services/packages/mark_package_for_destruction_service_spec.rb8
-rw-r--r--spec/services/packages/mark_packages_for_destruction_service_spec.rb7
-rw-r--r--spec/services/packages/maven/create_package_service_spec.rb2
-rw-r--r--spec/services/packages/maven/find_or_create_package_service_spec.rb51
-rw-r--r--spec/services/packages/maven/metadata/append_package_file_service_spec.rb2
-rw-r--r--spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb2
-rw-r--r--spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb2
-rw-r--r--spec/services/packages/maven/metadata/sync_service_spec.rb2
-rw-r--r--spec/services/packages/npm/create_metadata_cache_service_spec.rb83
-rw-r--r--spec/services/packages/npm/create_package_service_spec.rb178
-rw-r--r--spec/services/packages/npm/create_tag_service_spec.rb2
-rw-r--r--spec/services/packages/npm/deprecate_package_service_spec.rb115
-rw-r--r--spec/services/packages/npm/generate_metadata_service_spec.rb173
-rw-r--r--spec/services/packages/nuget/create_dependency_service_spec.rb2
-rw-r--r--spec/services/packages/nuget/metadata_extraction_service_spec.rb2
-rw-r--r--spec/services/packages/nuget/search_service_spec.rb2
-rw-r--r--spec/services/packages/nuget/sync_metadatum_service_spec.rb2
-rw-r--r--spec/services/packages/nuget/update_package_from_metadata_service_spec.rb23
-rw-r--r--spec/services/packages/pypi/create_package_service_spec.rb2
-rw-r--r--spec/services/packages/remove_tag_service_spec.rb2
-rw-r--r--spec/services/packages/rpm/parse_package_service_spec.rb2
-rw-r--r--spec/services/packages/rpm/repository_metadata/build_filelist_xml_service_spec.rb2
-rw-r--r--spec/services/packages/rpm/repository_metadata/build_other_xml_service_spec.rb2
-rw-r--r--spec/services/packages/rpm/repository_metadata/build_primary_xml_service_spec.rb2
-rw-r--r--spec/services/packages/rpm/repository_metadata/build_repomd_xml_service_spec.rb2
-rw-r--r--spec/services/packages/rpm/repository_metadata/update_xml_service_spec.rb2
-rw-r--r--spec/services/packages/rubygems/create_dependencies_service_spec.rb2
-rw-r--r--spec/services/packages/rubygems/create_gemspec_service_spec.rb2
-rw-r--r--spec/services/packages/rubygems/dependency_resolver_service_spec.rb2
-rw-r--r--spec/services/packages/rubygems/metadata_extraction_service_spec.rb2
-rw-r--r--spec/services/packages/rubygems/process_gem_service_spec.rb2
-rw-r--r--spec/services/packages/terraform_module/create_package_service_spec.rb2
-rw-r--r--spec/services/packages/update_package_file_service_spec.rb2
-rw-r--r--spec/services/packages/update_tags_service_spec.rb2
-rw-r--r--spec/services/pages/delete_service_spec.rb2
-rw-r--r--spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb2
-rw-r--r--spec/services/pages/zip_directory_service_spec.rb2
-rw-r--r--spec/services/pages_domains/create_acme_order_service_spec.rb2
-rw-r--r--spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb2
-rw-r--r--spec/services/personal_access_tokens/create_service_spec.rb20
-rw-r--r--spec/services/personal_access_tokens/last_used_service_spec.rb2
-rw-r--r--spec/services/personal_access_tokens/revoke_service_spec.rb2
-rw-r--r--spec/services/personal_access_tokens/rotate_service_spec.rb67
-rw-r--r--spec/services/post_receive_service_spec.rb2
-rw-r--r--spec/services/preview_markdown_service_spec.rb12
-rw-r--r--spec/services/product_analytics/build_activity_graph_service_spec.rb2
-rw-r--r--spec/services/product_analytics/build_graph_service_spec.rb2
-rw-r--r--spec/services/projects/after_rename_service_spec.rb2
-rw-r--r--spec/services/projects/alerting/notify_service_spec.rb2
-rw-r--r--spec/services/projects/all_issues_count_service_spec.rb2
-rw-r--r--spec/services/projects/all_merge_requests_count_service_spec.rb17
-rw-r--r--spec/services/projects/android_target_platform_detector_service_spec.rb30
-rw-r--r--spec/services/projects/apple_target_platform_detector_service_spec.rb2
-rw-r--r--spec/services/projects/auto_devops/disable_service_spec.rb2
-rw-r--r--spec/services/projects/autocomplete_service_spec.rb2
-rw-r--r--spec/services/projects/batch_open_issues_count_service_spec.rb2
-rw-r--r--spec/services/projects/batch_open_merge_requests_count_service_spec.rb32
-rw-r--r--spec/services/projects/blame_service_spec.rb131
-rw-r--r--spec/services/projects/branches_by_mode_service_spec.rb2
-rw-r--r--spec/services/projects/cleanup_service_spec.rb2
-rw-r--r--spec/services/projects/container_repository/cleanup_tags_service_spec.rb7
-rw-r--r--spec/services/projects/container_repository/delete_tags_service_spec.rb2
-rw-r--r--spec/services/projects/container_repository/destroy_service_spec.rb2
-rw-r--r--spec/services/projects/container_repository/gitlab/cleanup_tags_service_spec.rb54
-rw-r--r--spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb2
-rw-r--r--spec/services/projects/container_repository/third_party/cleanup_tags_service_spec.rb174
-rw-r--r--spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb2
-rw-r--r--spec/services/projects/count_service_spec.rb2
-rw-r--r--spec/services/projects/create_from_template_service_spec.rb2
-rw-r--r--spec/services/projects/create_service_spec.rb68
-rw-r--r--spec/services/projects/deploy_tokens/create_service_spec.rb2
-rw-r--r--spec/services/projects/deploy_tokens/destroy_service_spec.rb2
-rw-r--r--spec/services/projects/destroy_service_spec.rb17
-rw-r--r--spec/services/projects/detect_repository_languages_service_spec.rb2
-rw-r--r--spec/services/projects/download_service_spec.rb2
-rw-r--r--spec/services/projects/enable_deploy_key_service_spec.rb2
-rw-r--r--spec/services/projects/fetch_statistics_increment_service_spec.rb2
-rw-r--r--spec/services/projects/fork_service_spec.rb40
-rw-r--r--spec/services/projects/forks/sync_service_spec.rb185
-rw-r--r--spec/services/projects/forks_count_service_spec.rb2
-rw-r--r--spec/services/projects/git_deduplication_service_spec.rb4
-rw-r--r--spec/services/projects/gitlab_projects_import_service_spec.rb2
-rw-r--r--spec/services/projects/group_links/create_service_spec.rb15
-rw-r--r--spec/services/projects/group_links/destroy_service_spec.rb11
-rw-r--r--spec/services/projects/group_links/update_service_spec.rb11
-rw-r--r--spec/services/projects/hashed_storage/base_attachment_service_spec.rb2
-rw-r--r--spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb2
-rw-r--r--spec/services/projects/hashed_storage/migrate_repository_service_spec.rb2
-rw-r--r--spec/services/projects/hashed_storage/migration_service_spec.rb18
-rw-r--r--spec/services/projects/hashed_storage/rollback_attachments_service_spec.rb2
-rw-r--r--spec/services/projects/hashed_storage/rollback_repository_service_spec.rb2
-rw-r--r--spec/services/projects/hashed_storage/rollback_service_spec.rb2
-rw-r--r--spec/services/projects/import_error_filter_spec.rb2
-rw-r--r--spec/services/projects/import_export/relation_export_service_spec.rb4
-rw-r--r--spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb2
-rw-r--r--spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb2
-rw-r--r--spec/services/projects/lfs_pointers/lfs_download_service_spec.rb2
-rw-r--r--spec/services/projects/lfs_pointers/lfs_import_service_spec.rb2
-rw-r--r--spec/services/projects/lfs_pointers/lfs_link_service_spec.rb35
-rw-r--r--spec/services/projects/lfs_pointers/lfs_object_download_list_service_spec.rb2
-rw-r--r--spec/services/projects/move_access_service_spec.rb2
-rw-r--r--spec/services/projects/move_deploy_keys_projects_service_spec.rb2
-rw-r--r--spec/services/projects/move_forks_service_spec.rb2
-rw-r--r--spec/services/projects/move_lfs_objects_projects_service_spec.rb2
-rw-r--r--spec/services/projects/move_notification_settings_service_spec.rb2
-rw-r--r--spec/services/projects/move_project_authorizations_service_spec.rb2
-rw-r--r--spec/services/projects/move_project_group_links_service_spec.rb2
-rw-r--r--spec/services/projects/move_project_members_service_spec.rb2
-rw-r--r--spec/services/projects/move_users_star_projects_service_spec.rb2
-rw-r--r--spec/services/projects/open_issues_count_service_spec.rb2
-rw-r--r--spec/services/projects/open_merge_requests_count_service_spec.rb7
-rw-r--r--spec/services/projects/operations/update_service_spec.rb2
-rw-r--r--spec/services/projects/overwrite_project_service_spec.rb2
-rw-r--r--spec/services/projects/participants_service_spec.rb2
-rw-r--r--spec/services/projects/prometheus/alerts/notify_service_spec.rb26
-rw-r--r--spec/services/projects/prometheus/metrics/destroy_service_spec.rb2
-rw-r--r--spec/services/projects/protect_default_branch_service_spec.rb4
-rw-r--r--spec/services/projects/readme_renderer_service_spec.rb4
-rw-r--r--spec/services/projects/record_target_platforms_service_spec.rb70
-rw-r--r--spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb2
-rw-r--r--spec/services/projects/repository_languages_service_spec.rb2
-rw-r--r--spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb2
-rw-r--r--spec/services/projects/transfer_service_spec.rb52
-rw-r--r--spec/services/projects/unlink_fork_service_spec.rb8
-rw-r--r--spec/services/projects/update_pages_service_spec.rb68
-rw-r--r--spec/services/projects/update_remote_mirror_service_spec.rb2
-rw-r--r--spec/services/projects/update_repository_storage_service_spec.rb2
-rw-r--r--spec/services/projects/update_service_spec.rb247
-rw-r--r--spec/services/projects/update_statistics_service_spec.rb4
-rw-r--r--spec/services/prometheus/proxy_service_spec.rb2
-rw-r--r--spec/services/prometheus/proxy_variable_substitution_service_spec.rb2
-rw-r--r--spec/services/protected_branches/api_service_spec.rb2
-rw-r--r--spec/services/protected_branches/cache_service_spec.rb3
-rw-r--r--spec/services/protected_branches/destroy_service_spec.rb2
-rw-r--r--spec/services/protected_branches/update_service_spec.rb2
-rw-r--r--spec/services/protected_tags/create_service_spec.rb2
-rw-r--r--spec/services/protected_tags/destroy_service_spec.rb2
-rw-r--r--spec/services/protected_tags/update_service_spec.rb2
-rw-r--r--spec/services/push_event_payload_service_spec.rb2
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb267
-rw-r--r--spec/services/quick_actions/target_service_spec.rb2
-rw-r--r--spec/services/releases/create_evidence_service_spec.rb2
-rw-r--r--spec/services/releases/create_service_spec.rb20
-rw-r--r--spec/services/releases/destroy_service_spec.rb2
-rw-r--r--spec/services/releases/links/create_service_spec.rb84
-rw-r--r--spec/services/releases/links/destroy_service_spec.rb72
-rw-r--r--spec/services/releases/links/update_service_spec.rb89
-rw-r--r--spec/services/repositories/changelog_service_spec.rb2
-rw-r--r--spec/services/repositories/destroy_service_spec.rb2
-rw-r--r--spec/services/repository_archive_clean_up_service_spec.rb2
-rw-r--r--spec/services/reset_project_cache_service_spec.rb2
-rw-r--r--spec/services/resource_access_tokens/create_service_spec.rb108
-rw-r--r--spec/services/resource_access_tokens/revoke_service_spec.rb2
-rw-r--r--spec/services/resource_events/change_labels_service_spec.rb2
-rw-r--r--spec/services/resource_events/change_milestone_service_spec.rb2
-rw-r--r--spec/services/resource_events/change_state_service_spec.rb2
-rw-r--r--spec/services/resource_events/merge_into_notes_service_spec.rb2
-rw-r--r--spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb2
-rw-r--r--spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb8
-rw-r--r--spec/services/resource_events/synthetic_state_notes_builder_service_spec.rb2
-rw-r--r--spec/services/search/global_service_spec.rb2
-rw-r--r--spec/services/search/group_service_spec.rb2
-rw-r--r--spec/services/search/snippet_service_spec.rb2
-rw-r--r--spec/services/security/ci_configuration/container_scanning_create_service_spec.rb2
-rw-r--r--spec/services/security/ci_configuration/dependency_scanning_create_service_spec.rb2
-rw-r--r--spec/services/security/ci_configuration/sast_iac_create_service_spec.rb2
-rw-r--r--spec/services/security/ci_configuration/sast_parser_service_spec.rb2
-rw-r--r--spec/services/security/ci_configuration/secret_detection_create_service_spec.rb2
-rw-r--r--spec/services/security/merge_reports_service_spec.rb2
-rw-r--r--spec/services/serverless/associate_domain_service_spec.rb91
-rw-r--r--spec/services/service_desk_settings/update_service_spec.rb2
-rw-r--r--spec/services/service_ping/submit_service_ping_service_spec.rb2
-rw-r--r--spec/services/service_response_spec.rb2
-rw-r--r--spec/services/snippets/bulk_destroy_service_spec.rb2
-rw-r--r--spec/services/snippets/count_service_spec.rb2
-rw-r--r--spec/services/snippets/create_service_spec.rb2
-rw-r--r--spec/services/snippets/destroy_service_spec.rb4
-rw-r--r--spec/services/snippets/repository_validation_service_spec.rb2
-rw-r--r--spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb2
-rw-r--r--spec/services/snippets/update_repository_storage_service_spec.rb2
-rw-r--r--spec/services/snippets/update_service_spec.rb2
-rw-r--r--spec/services/snippets/update_statistics_service_spec.rb2
-rw-r--r--spec/services/spam/akismet_mark_as_spam_service_spec.rb2
-rw-r--r--spec/services/spam/akismet_service_spec.rb2
-rw-r--r--spec/services/spam/ham_service_spec.rb2
-rw-r--r--spec/services/spam/spam_action_service_spec.rb32
-rw-r--r--spec/services/spam/spam_params_spec.rb2
-rw-r--r--spec/services/spam/spam_verdict_service_spec.rb255
-rw-r--r--spec/services/submodules/update_service_spec.rb2
-rw-r--r--spec/services/suggestions/apply_service_spec.rb2
-rw-r--r--spec/services/suggestions/create_service_spec.rb2
-rw-r--r--spec/services/suggestions/outdate_service_spec.rb2
-rw-r--r--spec/services/system_hooks_service_spec.rb2
-rw-r--r--spec/services/system_note_service_spec.rb4
-rw-r--r--spec/services/system_notes/alert_management_service_spec.rb2
-rw-r--r--spec/services/system_notes/base_service_spec.rb2
-rw-r--r--spec/services/system_notes/commit_service_spec.rb82
-rw-r--r--spec/services/system_notes/design_management_service_spec.rb2
-rw-r--r--spec/services/system_notes/incident_service_spec.rb2
-rw-r--r--spec/services/system_notes/incidents_service_spec.rb2
-rw-r--r--spec/services/system_notes/issuables_service_spec.rb26
-rw-r--r--spec/services/system_notes/merge_requests_service_spec.rb2
-rw-r--r--spec/services/system_notes/time_tracking_service_spec.rb8
-rw-r--r--spec/services/system_notes/zoom_service_spec.rb2
-rw-r--r--spec/services/tags/create_service_spec.rb2
-rw-r--r--spec/services/tags/destroy_service_spec.rb2
-rw-r--r--spec/services/task_list_toggle_service_spec.rb21
-rw-r--r--spec/services/tasks_to_be_done/base_service_spec.rb6
-rw-r--r--spec/services/terraform/remote_state_handler_spec.rb3
-rw-r--r--spec/services/terraform/states/destroy_service_spec.rb2
-rw-r--r--spec/services/terraform/states/trigger_destroy_service_spec.rb2
-rw-r--r--spec/services/test_hooks/project_service_spec.rb2
-rw-r--r--spec/services/test_hooks/system_service_spec.rb2
-rw-r--r--spec/services/timelogs/delete_service_spec.rb2
-rw-r--r--spec/services/todo_service_spec.rb102
-rw-r--r--spec/services/todos/allowed_target_filter_service_spec.rb2
-rw-r--r--spec/services/todos/destroy/confidential_issue_service_spec.rb2
-rw-r--r--spec/services/todos/destroy/design_service_spec.rb2
-rw-r--r--spec/services/todos/destroy/destroyed_issuable_service_spec.rb2
-rw-r--r--spec/services/todos/destroy/project_private_service_spec.rb2
-rw-r--r--spec/services/todos/destroy/unauthorized_features_service_spec.rb2
-rw-r--r--spec/services/topics/merge_service_spec.rb4
-rw-r--r--spec/services/two_factor/destroy_service_spec.rb2
-rw-r--r--spec/services/update_container_registry_info_service_spec.rb2
-rw-r--r--spec/services/update_merge_request_metrics_service_spec.rb2
-rw-r--r--spec/services/upload_service_spec.rb2
-rw-r--r--spec/services/uploads/destroy_service_spec.rb2
-rw-r--r--spec/services/user_preferences/update_service_spec.rb6
-rw-r--r--spec/services/user_project_access_changed_service_spec.rb2
-rw-r--r--spec/services/users/activity_service_spec.rb3
-rw-r--r--spec/services/users/approve_service_spec.rb20
-rw-r--r--spec/services/users/authorized_build_service_spec.rb2
-rw-r--r--spec/services/users/ban_service_spec.rb2
-rw-r--r--spec/services/users/banned_user_base_service_spec.rb2
-rw-r--r--spec/services/users/batch_status_cleaner_service_spec.rb2
-rw-r--r--spec/services/users/block_service_spec.rb2
-rw-r--r--spec/services/users/build_service_spec.rb2
-rw-r--r--spec/services/users/create_service_spec.rb2
-rw-r--r--spec/services/users/deactivate_service_spec.rb86
-rw-r--r--spec/services/users/destroy_service_spec.rb2
-rw-r--r--spec/services/users/dismiss_callout_service_spec.rb2
-rw-r--r--spec/services/users/dismiss_group_callout_service_spec.rb2
-rw-r--r--spec/services/users/dismiss_project_callout_service_spec.rb2
-rw-r--r--spec/services/users/email_verification/generate_token_service_spec.rb23
-rw-r--r--spec/services/users/email_verification/validate_token_service_spec.rb7
-rw-r--r--spec/services/users/in_product_marketing_email_records_spec.rb2
-rw-r--r--spec/services/users/keys_count_service_spec.rb2
-rw-r--r--spec/services/users/last_push_event_service_spec.rb2
-rw-r--r--spec/services/users/migrate_records_to_ghost_user_in_batches_service_spec.rb2
-rw-r--r--spec/services/users/migrate_records_to_ghost_user_service_spec.rb2
-rw-r--r--spec/services/users/refresh_authorized_projects_service_spec.rb2
-rw-r--r--spec/services/users/registrations_build_service_spec.rb2
-rw-r--r--spec/services/users/reject_service_spec.rb2
-rw-r--r--spec/services/users/repair_ldap_blocked_service_spec.rb2
-rw-r--r--spec/services/users/respond_to_terms_service_spec.rb2
-rw-r--r--spec/services/users/saved_replies/create_service_spec.rb2
-rw-r--r--spec/services/users/saved_replies/destroy_service_spec.rb2
-rw-r--r--spec/services/users/saved_replies/update_service_spec.rb2
-rw-r--r--spec/services/users/set_status_service_spec.rb2
-rw-r--r--spec/services/users/signup_service_spec.rb8
-rw-r--r--spec/services/users/unban_service_spec.rb2
-rw-r--r--spec/services/users/unblock_service_spec.rb2
-rw-r--r--spec/services/users/update_canonical_email_service_spec.rb28
-rw-r--r--spec/services/users/update_highest_member_role_service_spec.rb2
-rw-r--r--spec/services/users/update_service_spec.rb48
-rw-r--r--spec/services/users/update_todo_count_cache_service_spec.rb2
-rw-r--r--spec/services/users/upsert_credit_card_validation_service_spec.rb10
-rw-r--r--spec/services/users/validate_manual_otp_service_spec.rb33
-rw-r--r--spec/services/users/validate_push_otp_service_spec.rb2
-rw-r--r--spec/services/verify_pages_domain_service_spec.rb2
-rw-r--r--spec/services/web_hooks/destroy_service_spec.rb2
-rw-r--r--spec/services/web_hooks/log_destroy_service_spec.rb2
-rw-r--r--spec/services/web_hooks/log_execution_service_spec.rb2
-rw-r--r--spec/services/webauthn/authenticate_service_spec.rb2
-rw-r--r--spec/services/webauthn/register_service_spec.rb2
-rw-r--r--spec/services/wiki_pages/base_service_spec.rb2
-rw-r--r--spec/services/wiki_pages/create_service_spec.rb2
-rw-r--r--spec/services/wiki_pages/destroy_service_spec.rb2
-rw-r--r--spec/services/wiki_pages/event_create_service_spec.rb2
-rw-r--r--spec/services/wiki_pages/update_service_spec.rb2
-rw-r--r--spec/services/wikis/create_attachment_service_spec.rb2
-rw-r--r--spec/services/work_items/build_service_spec.rb2
-rw-r--r--spec/services/work_items/create_from_task_service_spec.rb2
-rw-r--r--spec/services/work_items/create_service_spec.rb335
-rw-r--r--spec/services/work_items/delete_service_spec.rb2
-rw-r--r--spec/services/work_items/delete_task_service_spec.rb2
-rw-r--r--spec/services/work_items/export_csv_service_spec.rb28
-rw-r--r--spec/services/work_items/import_csv_service_spec.rb122
-rw-r--r--spec/services/work_items/parent_links/base_service_spec.rb31
-rw-r--r--spec/services/work_items/parent_links/create_service_spec.rb94
-rw-r--r--spec/services/work_items/parent_links/destroy_service_spec.rb38
-rw-r--r--spec/services/work_items/parent_links/reorder_service_spec.rb176
-rw-r--r--spec/services/work_items/prepare_import_csv_service_spec.rb52
-rw-r--r--spec/services/work_items/task_list_reference_removal_service_spec.rb2
-rw-r--r--spec/services/work_items/task_list_reference_replacement_service_spec.rb2
-rw-r--r--spec/services/work_items/update_service_spec.rb29
-rw-r--r--spec/services/work_items/widgets/assignees_service/update_service_spec.rb24
-rw-r--r--spec/services/work_items/widgets/award_emoji_service/update_service_spec.rb96
-rw-r--r--spec/services/work_items/widgets/current_user_todos_service/update_service_spec.rb106
-rw-r--r--spec/services/work_items/widgets/description_service/update_service_spec.rb23
-rw-r--r--spec/services/work_items/widgets/hierarchy_service/create_service_spec.rb31
-rw-r--r--spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb98
-rw-r--r--spec/services/work_items/widgets/labels_service/update_service_spec.rb48
-rw-r--r--spec/services/work_items/widgets/milestone_service/create_service_spec.rb28
-rw-r--r--spec/services/work_items/widgets/milestone_service/update_service_spec.rb58
-rw-r--r--spec/services/work_items/widgets/notifications_service/update_service_spec.rb117
-rw-r--r--spec/services/work_items/widgets/start_and_due_date_service/update_service_spec.rb24
-rw-r--r--spec/services/x509_certificate_revoke_service_spec.rb2
-rw-r--r--spec/simplecov_env.rb1
-rw-r--r--spec/spec_helper.rb120
-rw-r--r--spec/support/ability_check.rb73
-rw-r--r--spec/support/ability_check_todo.yml73
-rw-r--r--spec/support/banzai/filter_timeout_shared_examples.rb37
-rw-r--r--spec/support/banzai/reference_filter_shared_examples.rb88
-rw-r--r--spec/support/capybara.rb63
-rw-r--r--spec/support/capybara_wait_for_all_requests.rb47
-rw-r--r--spec/support/cycle_analytics_helpers/test_generation.rb160
-rw-r--r--spec/support/database/prevent_cross_joins.rb2
-rw-r--r--spec/support/fast_quarantine.rb37
-rw-r--r--spec/support/finder_collection_allowlist.yml5
-rw-r--r--spec/support/flaky_tests.rb37
-rw-r--r--spec/support/google_api/cloud_platform_helpers.rb166
-rw-r--r--spec/support/graphql/fake_query_type.rb22
-rw-r--r--spec/support/graphql/subscriptions/action_cable/mock_action_cable.rb100
-rw-r--r--spec/support/helpers/api_internal_base_helpers.rb16
-rw-r--r--spec/support/helpers/board_helpers.rb16
-rw-r--r--spec/support/helpers/callouts_test_helper.rb9
-rw-r--r--spec/support/helpers/chunked_io_helpers.rb (renamed from spec/support/chunked_io/chunked_io_helpers.rb)0
-rw-r--r--spec/support/helpers/ci/source_pipeline_helpers.rb12
-rw-r--r--spec/support/helpers/ci/template_helpers.rb4
-rw-r--r--spec/support/helpers/content_editor_helpers.rb49
-rw-r--r--spec/support/helpers/cycle_analytics_helpers.rb15
-rw-r--r--spec/support/helpers/cycle_analytics_helpers/test_generation.rb166
-rw-r--r--spec/support/helpers/database/database_helpers.rb8
-rw-r--r--spec/support/helpers/database/inject_failure_helpers.rb41
-rw-r--r--spec/support/helpers/database/multiple_databases_helpers.rb22
-rw-r--r--spec/support/helpers/email_helpers.rb21
-rw-r--r--spec/support/helpers/every_sidekiq_worker_test_helper.rb9
-rw-r--r--spec/support/helpers/fake_u2f_device.rb47
-rw-r--r--spec/support/helpers/fake_webauthn_device.rb2
-rw-r--r--spec/support/helpers/feature_flag_helpers.rb24
-rw-r--r--spec/support/helpers/features/access_token_helpers.rb23
-rw-r--r--spec/support/helpers/features/admin_users_helpers.rb28
-rw-r--r--spec/support/helpers/features/blob_spec_helpers.rb18
-rw-r--r--spec/support/helpers/features/branches_helpers.rb33
-rw-r--r--spec/support/helpers/features/canonical_link_helpers.rb22
-rw-r--r--spec/support/helpers/features/invite_members_modal_helper.rb154
-rw-r--r--spec/support/helpers/features/invite_members_modal_helpers.rb148
-rw-r--r--spec/support/helpers/features/iteration_helpers.rb9
-rw-r--r--spec/support/helpers/features/list_rows_helpers.rb28
-rw-r--r--spec/support/helpers/features/members_helpers.rb114
-rw-r--r--spec/support/helpers/features/merge_request_helpers.rb32
-rw-r--r--spec/support/helpers/features/mirroring_helpers.rb28
-rw-r--r--spec/support/helpers/features/notes_helpers.rb76
-rw-r--r--spec/support/helpers/features/releases_helpers.rb107
-rw-r--r--spec/support/helpers/features/responsive_table_helpers.rb22
-rw-r--r--spec/support/helpers/features/runners_helpers.rb92
-rw-r--r--spec/support/helpers/features/snippet_helpers.rb89
-rw-r--r--spec/support/helpers/features/snippet_spec_helpers.rb83
-rw-r--r--spec/support/helpers/features/sorting_helpers.rb36
-rw-r--r--spec/support/helpers/features/source_editor_spec_helpers.rb26
-rw-r--r--spec/support/helpers/features/top_nav_spec_helpers.rb46
-rw-r--r--spec/support/helpers/features/two_factor_helpers.rb123
-rw-r--r--spec/support/helpers/features/web_ide_spec_helpers.rb167
-rw-r--r--spec/support/helpers/filtered_search_helpers.rb19
-rw-r--r--spec/support/helpers/fixture_helpers.rb2
-rw-r--r--spec/support/helpers/gitaly_setup.rb54
-rw-r--r--spec/support/helpers/google_api/cloud_platform_helpers.rb168
-rw-r--r--spec/support/helpers/graphql/arguments.rb (renamed from spec/support/graphql/arguments.rb)0
-rw-r--r--spec/support/helpers/graphql/fake_query_type.rb23
-rw-r--r--spec/support/helpers/graphql/fake_tracer.rb (renamed from spec/support/graphql/fake_tracer.rb)0
-rw-r--r--spec/support/helpers/graphql/field_inspection.rb (renamed from spec/support/graphql/field_inspection.rb)0
-rw-r--r--spec/support/helpers/graphql/field_selection.rb (renamed from spec/support/graphql/field_selection.rb)0
-rw-r--r--spec/support/helpers/graphql/resolver_factories.rb (renamed from spec/support/graphql/resolver_factories.rb)0
-rw-r--r--spec/support/helpers/graphql/subscriptions/action_cable/mock_action_cable.rb100
-rw-r--r--spec/support/helpers/graphql/subscriptions/action_cable/mock_gitlab_schema.rb (renamed from spec/support/graphql/subscriptions/action_cable/mock_gitlab_schema.rb)0
-rw-r--r--spec/support/helpers/graphql/subscriptions/notes/helper.rb (renamed from spec/support/graphql/subscriptions/notes/helper.rb)0
-rw-r--r--spec/support/helpers/graphql/var.rb (renamed from spec/support/graphql/var.rb)0
-rw-r--r--spec/support/helpers/graphql_helpers.rb46
-rw-r--r--spec/support/helpers/http_io_helpers.rb49
-rw-r--r--spec/support/helpers/jira_integration_helpers.rb2
-rw-r--r--spec/support/helpers/keyset_pagination_helpers.rb23
-rw-r--r--spec/support/helpers/login_helpers.rb26
-rw-r--r--spec/support/helpers/markdown_feature.rb8
-rw-r--r--spec/support/helpers/metrics_dashboard_helpers.rb4
-rw-r--r--spec/support/helpers/migrations_helpers.rb2
-rw-r--r--spec/support/helpers/migrations_helpers/cluster_helpers.rb (renamed from spec/support/migrations_helpers/cluster_helpers.rb)0
-rw-r--r--spec/support/helpers/migrations_helpers/namespaces_helper.rb15
-rw-r--r--spec/support/helpers/migrations_helpers/schema_version_finder.rb35
-rw-r--r--spec/support/helpers/migrations_helpers/vulnerabilities_findings_helper.rb118
-rw-r--r--spec/support/helpers/models/ci/partitioning_testing/cascade_check.rb34
-rw-r--r--spec/support/helpers/models/ci/partitioning_testing/partition_identifiers.rb (renamed from spec/support/models/ci/partitioning_testing/partition_identifiers.rb)0
-rw-r--r--spec/support/helpers/models/ci/partitioning_testing/rspec_hooks.rb23
-rw-r--r--spec/support/helpers/models/ci/partitioning_testing/schema_helpers.rb (renamed from spec/support/models/ci/partitioning_testing/schema_helpers.rb)0
-rw-r--r--spec/support/helpers/models/merge_request_without_merge_request_diff.rb7
-rw-r--r--spec/support/helpers/navbar_structure_helper.rb20
-rw-r--r--spec/support/helpers/note_interaction_helpers.rb2
-rw-r--r--spec/support/helpers/project_template_test_helper.rb4
-rw-r--r--spec/support/helpers/prometheus/metric_builders.rb29
-rw-r--r--spec/support/helpers/query_recorder.rb4
-rw-r--r--spec/support/helpers/redis_helpers.rb (renamed from spec/support/redis/redis_helpers.rb)0
-rw-r--r--spec/support/helpers/repo_helpers.rb4
-rw-r--r--spec/support/helpers/search_helpers.rb8
-rw-r--r--spec/support/helpers/session_helpers.rb6
-rw-r--r--spec/support/helpers/snowplow_helpers.rb10
-rw-r--r--spec/support/helpers/stub_configuration.rb6
-rw-r--r--spec/support/helpers/stub_gitlab_calls.rb6
-rw-r--r--spec/support/helpers/stub_object_storage.rb126
-rw-r--r--spec/support/helpers/test_env.rb4
-rw-r--r--spec/support/helpers/test_reports_helper.rb103
-rw-r--r--spec/support/helpers/trace_helpers.rb (renamed from spec/support/trace/trace_helpers.rb)0
-rw-r--r--spec/support/helpers/usage_data_helpers.rb13
-rw-r--r--spec/support/helpers/user_login_helper.rb16
-rw-r--r--spec/support/helpers/wait_for_requests.rb3
-rw-r--r--spec/support/helpers/workhorse_helpers.rb58
-rw-r--r--spec/support/http_io/http_io_helpers.rb51
-rw-r--r--spec/support/import_export/common_util.rb25
-rw-r--r--spec/support/import_export/export_file_helper.rb22
-rw-r--r--spec/support/matchers/background_migrations_matchers.rb14
-rw-r--r--spec/support/matchers/be_a_foreign_key_column_of.rb19
-rw-r--r--spec/support/matchers/be_indexed_by.rb26
-rw-r--r--spec/support/matchers/exceed_redis_call_limit.rb57
-rw-r--r--spec/support/matchers/have_plain_text_content.rb16
-rw-r--r--spec/support/matchers/markdown_matchers.rb13
-rw-r--r--spec/support/matchers/request_urgency_matcher.rb29
-rw-r--r--spec/support/matchers/snapshot_matcher.rb55
-rw-r--r--spec/support/migrations_helpers/namespaces_helper.rb14
-rw-r--r--spec/support/migrations_helpers/schema_version_finder.rb34
-rw-r--r--spec/support/migrations_helpers/vulnerabilities_findings_helper.rb118
-rw-r--r--spec/support/models/ci/partitioning_testing/cascade_check.rb34
-rw-r--r--spec/support/models/ci/partitioning_testing/rspec_hooks.rb19
-rw-r--r--spec/support/models/merge_request_without_merge_request_diff.rb7
-rw-r--r--spec/support/permissions_check.rb18
-rw-r--r--spec/support/prometheus/additional_metrics_shared_examples.rb159
-rw-r--r--spec/support/prometheus/metric_builders.rb29
-rw-r--r--spec/support/protected_branch_helpers.rb23
-rw-r--r--spec/support/protected_tags/access_control_ce_shared_examples.rb49
-rw-r--r--spec/support/redis/redis_new_instance_shared_examples.rb111
-rw-r--r--spec/support/redis/redis_shared_examples.rb459
-rw-r--r--spec/support/rspec.rb12
-rw-r--r--spec/support/rspec_order.rb2
-rw-r--r--spec/support/rspec_order_todo.yml358
-rw-r--r--spec/support/services/clusters/create_service_shared.rb64
-rw-r--r--spec/support/services/deploy_token_shared_examples.rb86
-rw-r--r--spec/support/services/issuable_import_csv_service_shared_examples.rb138
-rw-r--r--spec/support/services/issuable_update_service_shared_examples.rb99
-rw-r--r--spec/support/services/migrate_to_ghost_user_service_shared_examples.rb89
-rw-r--r--spec/support/services/service_response_shared_examples.rb25
-rw-r--r--spec/support/shared_contexts/bulk_imports_requests_shared_context.rb25
-rw-r--r--spec/support/shared_contexts/design_management_shared_contexts.rb33
-rw-r--r--spec/support/shared_contexts/features/integrations/instance_and_group_integrations_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/features/integrations/integrations_shared_context.rb209
-rw-r--r--spec/support/shared_contexts/features/integrations/project_integrations_jira_context.rb1
-rw-r--r--spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb78
-rw-r--r--spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb42
-rw-r--r--spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb78
-rw-r--r--spec/support/shared_contexts/glfm/example_snapshot_fixtures.rb2
-rw-r--r--spec/support/shared_contexts/graphql/types/query_type_shared_context.rb1
-rw-r--r--spec/support/shared_contexts/issuable/merge_request_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/lib/gitlab/database/load_balancing/wal_tracking_shared_context.rb68
-rw-r--r--spec/support/shared_contexts/lib/gitlab/database/partitioning/list_partitioning_shared_context.rb92
-rw-r--r--spec/support/shared_contexts/merge_request_create_shared_context.rb15
-rw-r--r--spec/support/shared_contexts/merge_request_edit_shared_context.rb12
-rw-r--r--spec/support/shared_contexts/merge_requests_allowing_collaboration_shared_context.rb12
-rw-r--r--spec/support/shared_contexts/models/distribution_shared_context.rb22
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb56
-rw-r--r--spec/support/shared_contexts/policies/group_policy_shared_context.rb7
-rw-r--r--spec/support/shared_contexts/policies/project_policy_shared_context.rb15
-rw-r--r--spec/support/shared_contexts/rack_attack_shared_context.rb3
-rw-r--r--spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb3
-rw-r--r--spec/support/shared_contexts/requests/api/graphql/releases_and_group_releases_shared_context.rb56
-rw-r--r--spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb4
-rw-r--r--spec/support/shared_contexts/services/clusters/create_service_shared_context.rb19
-rw-r--r--spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb8
-rw-r--r--spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb3
-rw-r--r--spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb629
-rw-r--r--spec/support/shared_examples/analytics/cycle_analytics/request_params_examples.rb131
-rw-r--r--spec/support/shared_examples/banzai/filters/filter_timeout_shared_examples.rb70
-rw-r--r--spec/support/shared_examples/banzai/filters/inline_embeds_shared_examples.rb16
-rw-r--r--spec/support/shared_examples/banzai/filters/reference_filter_shared_examples.rb88
-rw-r--r--spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/bulk_imports/visibility_level_examples.rb37
-rw-r--r--spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb13
-rw-r--r--spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb22
-rw-r--r--spec/support/shared_examples/controllers/project_import_rate_limiter_shared_examples.rb (renamed from spec/support/controllers/project_import_rate_limiter_shared_examples.rb)0
-rw-r--r--spec/support/shared_examples/controllers/repositories/git_http_controller_shared_examples.rb25
-rw-r--r--spec/support/shared_examples/controllers/snippets_sort_order_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/controllers/unique_hll_events_examples.rb3
-rw-r--r--spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb56
-rw-r--r--spec/support/shared_examples/db/seeds/data_seeder_shared_examples.rb111
-rw-r--r--spec/support/shared_examples/features/2fa_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/features/abuse_report_shared_examples.rb18
-rw-r--r--spec/support/shared_examples/features/access_tokens_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/confidential_notes_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/content_editor_shared_examples.rb415
-rw-r--r--spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/features/dashboard/sidebar_shared_examples.rb11
-rw-r--r--spec/support/shared_examples/features/deploy_token_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/features/editable_merge_request_shared_examples.rb16
-rw-r--r--spec/support/shared_examples/features/explore/sidebar_shared_examples.rb28
-rw-r--r--spec/support/shared_examples/features/incident_details_routing_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/features/integrations/user_activates_mattermost_slash_command_integration_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/features/manage_applications_shared_examples.rb93
-rw-r--r--spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb2
-rw-r--r--spec/support/shared_examples/features/milestone_editing_shared_examples.rb21
-rw-r--r--spec/support/shared_examples/features/packages_shared_examples.rb69
-rw-r--r--spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb105
-rw-r--r--spec/support/shared_examples/features/protected_tags_with_deploy_keys_examples.rb61
-rw-r--r--spec/support/shared_examples/features/reportable_note_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/features/rss_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/features/runners_shared_examples.rb45
-rw-r--r--spec/support/shared_examples/features/search/redacted_search_results_shared_examples.rb202
-rw-r--r--spec/support/shared_examples/features/secure_oauth_authorizations_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/trial_email_validation_shared_example.rb67
-rw-r--r--spec/support/shared_examples/features/variable_list_pagination_shared_examples.rb66
-rw-r--r--spec/support/shared_examples/features/variable_list_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/features/wiki/file_attachments_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/wiki/user_previews_wiki_changes_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb1
-rw-r--r--spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/features/wiki/user_views_wiki_sidebar_shared_examples.rb38
-rw-r--r--spec/support/shared_examples/features/work_items_shared_examples.rb265
-rw-r--r--spec/support/shared_examples/finders/issues_finder_shared_examples.rb39
-rw-r--r--spec/support/shared_examples/graphql/members_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/graphql/mutation_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/graphql/mutations/members/bulk_update_shared_examples.rb123
-rw-r--r--spec/support/shared_examples/graphql/mutations/set_assignees_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/graphql/notes_quick_actions_for_work_items_shared_examples.rb195
-rw-r--r--spec/support/shared_examples/graphql/resolvers/data_transfer_resolver_shared_examples.rb23
-rw-r--r--spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb5
-rw-r--r--spec/support/shared_examples/helpers/callouts_for_web_hooks.rb49
-rw-r--r--spec/support/shared_examples/integrations/integration_settings_form.rb6
-rw-r--r--spec/support/shared_examples/lib/api/ai_workhorse_shared_examples.rb43
-rw-r--r--spec/support/shared_examples/lib/api/terraform_state_enabled_shared_examples.rb29
-rw-r--r--spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb9
-rw-r--r--spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb61
-rw-r--r--spec/support/shared_examples/lib/gitlab/database/async_constraints_validation_shared_examples.rb131
-rw-r--r--spec/support/shared_examples/lib/gitlab/database/index_validators_shared_examples.rb38
-rw-r--r--spec/support/shared_examples/lib/gitlab/database/schema_objects_shared_examples.rb26
-rw-r--r--spec/support/shared_examples/lib/gitlab/database/table_validators_shared_examples.rb84
-rw-r--r--spec/support/shared_examples/lib/gitlab/database/trigger_validators_shared_examples.rb33
-rw-r--r--spec/support/shared_examples/lib/gitlab/gitaly_client_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/lib/gitlab/json_logger_shared_examples.rb29
-rw-r--r--spec/support/shared_examples/lib/gitlab/local_and_remote_storage_migration_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/lib/gitlab/search_language_filter_shared_examples.rb25
-rw-r--r--spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb28
-rw-r--r--spec/support/shared_examples/lib/gitlab/utils/username_and_email_generator_shared_examples.rb104
-rw-r--r--spec/support/shared_examples/lib/menus_shared_examples.rb55
-rw-r--r--spec/support/shared_examples/lib/sentry/client_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/lib/sidebars/admin/menus/admin_menus_shared_examples.rb78
-rw-r--r--spec/support/shared_examples/lib/sidebars/user_profile/user_profile_menus_shared_examples.rb93
-rw-r--r--spec/support/shared_examples/lib/sidebars/user_settings/menus/user_settings_menus_shared_examples.rb52
-rw-r--r--spec/support/shared_examples/mailers/export_csv_shared_examples.rb37
-rw-r--r--spec/support/shared_examples/mailers/notify_shared_examples.rb16
-rw-r--r--spec/support/shared_examples/metrics_instrumentation_shared_examples.rb (renamed from spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb)0
-rw-r--r--spec/support/shared_examples/migrations/add_work_item_widget_shared_examples.rb33
-rw-r--r--spec/support/shared_examples/models/active_record_enum_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/models/chat_integration_shared_examples.rb28
-rw-r--r--spec/support/shared_examples/models/ci/token_format_shared_examples.rb29
-rw-r--r--spec/support/shared_examples/models/clusters/prometheus_client_shared.rb10
-rw-r--r--spec/support/shared_examples/models/concerns/auto_disabling_hooks_shared_examples.rb114
-rw-r--r--spec/support/shared_examples/models/concerns/cascading_namespace_setting_shared_examples.rb84
-rw-r--r--spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb61
-rw-r--r--spec/support/shared_examples/models/concerns/integrations/base_slack_notification_shared_examples.rb1
-rw-r--r--spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb40
-rw-r--r--spec/support/shared_examples/models/concerns/protected_branch_access_examples.rb19
-rw-r--r--spec/support/shared_examples/models/concerns/protected_ref_access_allowed_access_levels_examples.rb36
-rw-r--r--spec/support/shared_examples/models/concerns/protected_ref_access_examples.rb106
-rw-r--r--spec/support/shared_examples/models/concerns/protected_tag_access_examples.rb21
-rw-r--r--spec/support/shared_examples/models/concerns/timebox_shared_examples.rb9
-rw-r--r--spec/support/shared_examples/models/concerns/unstoppable_hooks_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/models/concerns/web_hooks/has_web_hooks_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb9
-rw-r--r--spec/support/shared_examples/models/database_event_tracking_shared_examples.rb56
-rw-r--r--spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/models/integrations/base_slash_commands_shared_examples.rb3
-rw-r--r--spec/support/shared_examples/models/issue_tracker_service_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/models/member_shared_examples.rb48
-rw-r--r--spec/support/shared_examples/models/members_notifications_shared_example.rb2
-rw-r--r--spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb21
-rw-r--r--spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb265
-rw-r--r--spec/support/shared_examples/models/project_ci_cd_settings_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/models/resource_event_shared_examples.rb40
-rw-r--r--spec/support/shared_examples/models/wiki_shared_examples.rb49
-rw-r--r--spec/support/shared_examples/observability/csp_shared_examples.rb25
-rw-r--r--spec/support/shared_examples/observability/embed_observabilities_examples.rb61
-rw-r--r--spec/support/shared_examples/policies/project_policy_shared_examples.rb21
-rw-r--r--spec/support/shared_examples/projects/container_repository/cleanup_tags_service_shared_examples.rb60
-rw-r--r--spec/support/shared_examples/prometheus/additional_metrics_shared_examples.rb161
-rw-r--r--spec/support/shared_examples/protected_tags/access_control_ce_shared_examples.rb32
-rw-r--r--spec/support/shared_examples/quick_actions/issuable/close_quick_action_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/quick_actions/issuable/max_issuable_examples.rb20
-rw-r--r--spec/support/shared_examples/quick_actions/issue/issue_links_quick_actions_shared_examples.rb123
-rw-r--r--spec/support/shared_examples/quick_actions/issue/promote_to_incident_quick_action_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/redis/redis_new_instance_shared_examples.rb84
-rw-r--r--spec/support/shared_examples/redis/redis_shared_examples.rb429
-rw-r--r--spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/admin_mode_shared_examples.rb111
-rw-r--r--spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb36
-rw-r--r--spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb38
-rw-r--r--spec/support/shared_examples/requests/api/discussions_shared_examples.rb15
-rw-r--r--spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb3
-rw-r--r--spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/graphql/mutations/subscription_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/graphql/projects/branch_protections/access_level_request_examples.rb16
-rw-r--r--spec/support/shared_examples/requests/api/hooks_shared_examples.rb100
-rw-r--r--spec/support/shared_examples/requests/api/integrations/github_enterprise_jira_dvcs_end_of_life_shared_examples.rb23
-rw-r--r--spec/support/shared_examples/requests/api/integrations/slack/slack_request_verification_shared_examples.rb70
-rw-r--r--spec/support/shared_examples/requests/api/issuable_update_shared_examples.rb9
-rw-r--r--spec/support/shared_examples/requests/api/labels_api_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/milestones_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/requests/api/ml/mlflow/mlflow_shared_examples.rb69
-rw-r--r--spec/support/shared_examples/requests/api/notes_shared_examples.rb88
-rw-r--r--spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb448
-rw-r--r--spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb29
-rw-r--r--spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb5
-rw-r--r--spec/support/shared_examples/requests/api/packages_shared_examples.rb46
-rw-r--r--spec/support/shared_examples/requests/api/pipelines/visibility_table_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb9
-rw-r--r--spec/support/shared_examples/requests/api/repository_storage_moves_shared_examples.rb20
-rw-r--r--spec/support/shared_examples/requests/api/resolvable_discussions_shared_examples.rb3
-rw-r--r--spec/support/shared_examples/requests/api/snippets_shared_examples.rb27
-rw-r--r--spec/support/shared_examples/requests/api/status_shared_examples.rb21
-rw-r--r--spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb3
-rw-r--r--spec/support/shared_examples/requests/applications_controller_shared_examples.rb30
-rw-r--r--spec/support/shared_examples/requests/graphql_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/projects/aws/aws__ff_examples.rb18
-rw-r--r--spec/support/shared_examples/requests/rack_attack_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/requests/self_monitoring_shared_examples.rb130
-rw-r--r--spec/support/shared_examples/requests/user_activity_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/security_training_providers_importer.rb4
-rw-r--r--spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb81
-rw-r--r--spec/support/shared_examples/serializers/note_entity_shared_examples.rb3
-rw-r--r--spec/support/shared_examples/services/base_helm_service_shared_examples.rb22
-rw-r--r--spec/support/shared_examples/services/clusters/create_service_shared_examples.rb28
-rw-r--r--spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb55
-rw-r--r--spec/support/shared_examples/services/deploy_token_shared_examples.rb88
-rw-r--r--spec/support/shared_examples/services/import_csv_service_shared_examples.rb38
-rw-r--r--spec/support/shared_examples/services/incident_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/services/issuable/issuable_description_quick_actions_shared_examples.rb (renamed from spec/support/services/issuable_description_quick_actions_shared_examples.rb)0
-rw-r--r--spec/support/shared_examples/services/issuable/issuable_import_csv_service_shared_examples.rb107
-rw-r--r--spec/support/shared_examples/services/issuable/issuable_update_service_shared_examples.rb137
-rw-r--r--spec/support/shared_examples/services/issuable/update_service_shared_examples.rb29
-rw-r--r--spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/services/issues/move_and_clone_services_shared_examples.rb (renamed from spec/support/services/issues/move_and_clone_services_shared_examples.rb)0
-rw-r--r--spec/support/shared_examples/services/migrate_to_ghost_user_service_shared_examples.rb89
-rw-r--r--spec/support/shared_examples/services/packages/debian/generate_distribution_shared_examples.rb141
-rw-r--r--spec/support/shared_examples/services/packages_shared_examples.rb18
-rw-r--r--spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb29
-rw-r--r--spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb7
-rw-r--r--spec/support/shared_examples/services/service_response_shared_examples.rb21
-rw-r--r--spec/support/shared_examples/services/snowplow_tracking_shared_examples.rb1
-rw-r--r--spec/support/shared_examples/services/work_items/widgets/milestone_service_shared_examples.rb42
-rw-r--r--spec/support/shared_examples/views/pipeline_status_changes_email.rb14
-rw-r--r--spec/support/shared_examples/work_items/export_and_import_shared_examples.rb39
-rw-r--r--spec/support/shared_examples/workers/batched_background_migration_execution_worker_shared_example.rb14
-rw-r--r--spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb206
-rw-r--r--spec/support/shared_examples/workers/self_monitoring_shared_examples.rb28
-rw-r--r--spec/support/stub_dot_com_check.rb20
-rw-r--r--spec/support/stub_member_access_level.rb46
-rw-r--r--spec/support/test_reports/test_reports_helper.rb103
-rw-r--r--spec/support/tmpdir.rb2
-rw-r--r--spec/support_specs/ability_check_spec.rb148
-rw-r--r--spec/support_specs/capybara_wait_for_all_requests_spec.rb61
-rw-r--r--spec/support_specs/helpers/keyset_pagination_helpers_spec.rb88
-rw-r--r--spec/support_specs/helpers/migrations_helpers_spec.rb38
-rw-r--r--spec/support_specs/matchers/event_store_spec.rb2
-rw-r--r--spec/support_specs/matchers/exceed_redis_call_limit_spec.rb59
-rw-r--r--spec/support_specs/stub_member_access_level_spec.rb69
-rw-r--r--spec/tasks/dev_rake_spec.rb4
-rw-r--r--spec/tasks/gettext_rake_spec.rb90
-rw-r--r--spec/tasks/gitlab/background_migrations_rake_spec.rb45
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb98
-rw-r--r--spec/tasks/gitlab/db/decomposition/connection_status_spec.rb61
-rw-r--r--spec/tasks/gitlab/db/decomposition/rollback/bump_ci_sequences_rake_spec.rb8
-rw-r--r--spec/tasks/gitlab/db/lock_writes_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/db/truncate_legacy_tables_rake_spec.rb25
-rw-r--r--spec/tasks/gitlab/db/validate_config_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/db_rake_spec.rb227
-rw-r--r--spec/tasks/gitlab/feature_categories_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/gitaly_rake_spec.rb22
-rw-r--r--spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb41
-rw-r--r--spec/tasks/gitlab/security/update_banned_ssh_keys_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/setup_rake_spec.rb4
-rw-r--r--spec/tasks/gitlab/storage_rake_spec.rb2
-rw-r--r--spec/tooling/danger/analytics_instrumentation_spec.rb234
-rw-r--r--spec/tooling/danger/database_dictionary_spec.rb152
-rw-r--r--spec/tooling/danger/feature_flag_spec.rb22
-rw-r--r--spec/tooling/danger/multiversion_spec.rb79
-rw-r--r--spec/tooling/danger/product_intelligence_spec.rb223
-rw-r--r--spec/tooling/danger/project_helper_spec.rb162
-rw-r--r--spec/tooling/danger/sidekiq_args_spec.rb125
-rw-r--r--spec/tooling/danger/specs/feature_category_suggestion_spec.rb99
-rw-r--r--spec/tooling/danger/specs/match_with_array_suggestion_spec.rb99
-rw-r--r--spec/tooling/danger/specs/project_factory_suggestion_spec.rb112
-rw-r--r--spec/tooling/danger/specs_spec.rb263
-rw-r--r--spec/tooling/danger/stable_branch_spec.rb41
-rw-r--r--spec/tooling/docs/deprecation_handling_spec.rb2
-rw-r--r--spec/tooling/graphql/docs/renderer_spec.rb8
-rw-r--r--spec/tooling/lib/tooling/fast_quarantine_spec.rb193
-rw-r--r--spec/tooling/lib/tooling/find_changes_spec.rb289
-rw-r--r--spec/tooling/lib/tooling/find_files_using_feature_flags_spec.rb122
-rw-r--r--spec/tooling/lib/tooling/find_tests_spec.rb159
-rw-r--r--spec/tooling/lib/tooling/gettext_extractor_spec.rb276
-rw-r--r--spec/tooling/lib/tooling/helpers/file_handler_spec.rb127
-rw-r--r--spec/tooling/lib/tooling/helpers/predictive_tests_helper_spec.rb51
-rw-r--r--spec/tooling/lib/tooling/kubernetes_client_spec.rb376
-rw-r--r--spec/tooling/lib/tooling/mappings/base_spec.rb44
-rw-r--r--spec/tooling/lib/tooling/mappings/graphql_base_type_mappings_spec.rb251
-rw-r--r--spec/tooling/lib/tooling/mappings/js_to_system_specs_mappings_spec.rb109
-rw-r--r--spec/tooling/lib/tooling/mappings/partial_to_views_mappings_spec.rb280
-rw-r--r--spec/tooling/lib/tooling/mappings/view_to_js_mappings_spec.rb89
-rw-r--r--spec/tooling/lib/tooling/mappings/view_to_system_specs_mappings_spec.rb127
-rw-r--r--spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb96
-rw-r--r--spec/tooling/lib/tooling/predictive_tests_spec.rb134
-rw-r--r--spec/tooling/quality/test_level_spec.rb11
-rw-r--r--spec/tooling/rspec_flaky/config_spec.rb19
-rw-r--r--spec/uploaders/attachment_uploader_spec.rb10
-rw-r--r--spec/uploaders/avatar_uploader_spec.rb10
-rw-r--r--spec/uploaders/ci/pipeline_artifact_uploader_spec.rb6
-rw-r--r--spec/uploaders/dependency_proxy/file_uploader_spec.rb9
-rw-r--r--spec/uploaders/design_management/design_v432x230_uploader_spec.rb14
-rw-r--r--spec/uploaders/external_diff_uploader_spec.rb8
-rw-r--r--spec/uploaders/file_uploader_spec.rb22
-rw-r--r--spec/uploaders/gitlab_uploader_spec.rb17
-rw-r--r--spec/uploaders/job_artifact_uploader_spec.rb8
-rw-r--r--spec/uploaders/lfs_object_uploader_spec.rb8
-rw-r--r--spec/uploaders/object_storage/cdn/google_cdn_spec.rb13
-rw-r--r--spec/uploaders/object_storage/cdn_spec.rb88
-rw-r--r--spec/uploaders/object_storage_spec.rb329
-rw-r--r--spec/uploaders/packages/composer/cache_uploader_spec.rb8
-rw-r--r--spec/uploaders/packages/debian/component_file_uploader_spec.rb12
-rw-r--r--spec/uploaders/packages/debian/distribution_release_file_uploader_spec.rb12
-rw-r--r--spec/uploaders/packages/npm/metadata_cache_uploader_spec.rb34
-rw-r--r--spec/uploaders/packages/package_file_uploader_spec.rb8
-rw-r--r--spec/uploaders/packages/rpm/repository_file_uploader_spec.rb8
-rw-r--r--spec/uploaders/pages/deployment_uploader_spec.rb6
-rw-r--r--spec/uploaders/personal_file_uploader_spec.rb10
-rw-r--r--spec/validators/addressable_url_validator_spec.rb70
-rw-r--r--spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb5
-rw-r--r--spec/views/admin/application_settings/_repository_check.html.haml_spec.rb13
-rw-r--r--spec/views/admin/application_settings/ci_cd.html.haml_spec.rb5
-rw-r--r--spec/views/admin/application_settings/network.html.haml_spec.rb21
-rw-r--r--spec/views/admin/groups/_form.html.haml_spec.rb42
-rw-r--r--spec/views/admin/projects/_form.html.haml_spec.rb41
-rw-r--r--spec/views/admin/sessions/new.html.haml_spec.rb4
-rw-r--r--spec/views/admin/sessions/two_factor.html.haml_spec.rb10
-rw-r--r--spec/views/ci/status/_badge.html.haml_spec.rb10
-rw-r--r--spec/views/ci/status/_icon.html.haml_spec.rb10
-rw-r--r--spec/views/devise/confirmations/almost_there.html.haml_spec.rb17
-rw-r--r--spec/views/devise/sessions/new.html.haml_spec.rb97
-rw-r--r--spec/views/devise/shared/_error_messages.html.haml_spec.rb43
-rw-r--r--spec/views/devise/shared/_signup_box.html.haml_spec.rb11
-rw-r--r--spec/views/events/event/_common.html.haml_spec.rb15
-rw-r--r--spec/views/groups/edit.html.haml_spec.rb6
-rw-r--r--spec/views/groups/group_members/index.html.haml_spec.rb1
-rw-r--r--spec/views/groups/packages/index.html.haml_spec.rb39
-rw-r--r--spec/views/groups/settings/_general.html.haml_spec.rb21
-rw-r--r--spec/views/groups/show.html.haml_spec.rb38
-rw-r--r--spec/views/help/index.html.haml_spec.rb9
-rw-r--r--spec/views/layouts/_head.html.haml_spec.rb2
-rw-r--r--spec/views/layouts/_search.html.haml_spec.rb77
-rw-r--r--spec/views/layouts/application.html.haml_spec.rb4
-rw-r--r--spec/views/layouts/devise.html.haml_spec.rb10
-rw-r--r--spec/views/layouts/group.html.haml_spec.rb30
-rw-r--r--spec/views/layouts/header/_new_dropdown.haml_spec.rb27
-rw-r--r--spec/views/layouts/minimal.html.haml_spec.rb13
-rw-r--r--spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb11
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb38
-rw-r--r--spec/views/layouts/project.html.haml_spec.rb29
-rw-r--r--spec/views/notify/autodevops_disabled_email.text.erb_spec.rb14
-rw-r--r--spec/views/notify/import_issues_csv_email.html.haml_spec.rb4
-rw-r--r--spec/views/notify/import_work_items_csv_email.html.haml_spec.rb133
-rw-r--r--spec/views/notify/new_achievement_email.html.haml_spec.rb26
-rw-r--r--spec/views/notify/pipeline_failed_email.text.erb_spec.rb14
-rw-r--r--spec/views/profiles/keys/_key.html.haml_spec.rb39
-rw-r--r--spec/views/profiles/preferences/show.html.haml_spec.rb4
-rw-r--r--spec/views/projects/_home_panel.html.haml_spec.rb24
-rw-r--r--spec/views/projects/commit/_commit_box.html.haml_spec.rb3
-rw-r--r--spec/views/projects/commit/show.html.haml_spec.rb13
-rw-r--r--spec/views/projects/edit.html.haml_spec.rb32
-rw-r--r--spec/views/projects/empty.html.haml_spec.rb4
-rw-r--r--spec/views/projects/issues/_related_issues.html.haml_spec.rb37
-rw-r--r--spec/views/projects/merge_requests/edit.html.haml_spec.rb78
-rw-r--r--spec/views/projects/packages/index.html.haml_spec.rb39
-rw-r--r--spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb4
-rw-r--r--spec/views/projects/pipelines/show.html.haml_spec.rb19
-rw-r--r--spec/views/projects/project_members/index.html.haml_spec.rb1
-rw-r--r--spec/views/projects/runners/_project_runners.html.haml_spec.rb62
-rw-r--r--spec/views/projects/settings/merge_requests/show.html.haml_spec.rb8
-rw-r--r--spec/views/projects/tags/index.html.haml_spec.rb4
-rw-r--r--spec/views/registrations/welcome/show.html.haml_spec.rb2
-rw-r--r--spec/views/search/_results.html.haml_spec.rb6
-rw-r--r--spec/views/search/show.html.haml_spec.rb6
-rw-r--r--spec/views/shared/_label_row.html.haml_spec.rb4
-rw-r--r--spec/views/shared/milestones/_issuables.html.haml_spec.rb9
-rw-r--r--spec/views/shared/runners/_runner_details.html.haml_spec.rb49
-rw-r--r--spec/workers/admin_email_worker_spec.rb2
-rw-r--r--spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb2
-rw-r--r--spec/workers/analytics/usage_trends/counter_job_worker_spec.rb2
-rw-r--r--spec/workers/approve_blocked_pending_approval_users_worker_spec.rb2
-rw-r--r--spec/workers/authorized_keys_worker_spec.rb2
-rw-r--r--spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb2
-rw-r--r--spec/workers/authorized_project_update/project_recalculate_per_user_worker_spec.rb2
-rw-r--r--spec/workers/authorized_project_update/project_recalculate_worker_spec.rb2
-rw-r--r--spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb2
-rw-r--r--spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb6
-rw-r--r--spec/workers/authorized_project_update/user_refresh_with_low_urgency_worker_spec.rb2
-rw-r--r--spec/workers/authorized_projects_worker_spec.rb2
-rw-r--r--spec/workers/auto_devops/disable_worker_spec.rb2
-rw-r--r--spec/workers/auto_merge_process_worker_spec.rb2
-rw-r--r--spec/workers/background_migration/ci_database_worker_spec.rb6
-rw-r--r--spec/workers/background_migration_worker_spec.rb2
-rw-r--r--spec/workers/build_hooks_worker_spec.rb6
-rw-r--r--spec/workers/build_queue_worker_spec.rb6
-rw-r--r--spec/workers/build_success_worker_spec.rb2
-rw-r--r--spec/workers/bulk_imports/entity_worker_spec.rb2
-rw-r--r--spec/workers/bulk_imports/export_request_worker_spec.rb2
-rw-r--r--spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb80
-rw-r--r--spec/workers/bulk_imports/relation_batch_export_worker_spec.rb26
-rw-r--r--spec/workers/bulk_imports/relation_export_worker_spec.rb65
-rw-r--r--spec/workers/bulk_imports/stuck_import_worker_spec.rb2
-rw-r--r--spec/workers/chat_notification_worker_spec.rb2
-rw-r--r--spec/workers/ci/archive_trace_worker_spec.rb2
-rw-r--r--spec/workers/ci/archive_traces_cron_worker_spec.rb14
-rw-r--r--spec/workers/ci/build_finished_worker_spec.rb2
-rw-r--r--spec/workers/ci/build_prepare_worker_spec.rb2
-rw-r--r--spec/workers/ci/build_schedule_worker_spec.rb2
-rw-r--r--spec/workers/ci/build_trace_chunk_flush_worker_spec.rb2
-rw-r--r--spec/workers/ci/cancel_pipeline_worker_spec.rb2
-rw-r--r--spec/workers/ci/create_cross_project_pipeline_worker_spec.rb37
-rw-r--r--spec/workers/ci/create_downstream_pipeline_worker_spec.rb2
-rw-r--r--spec/workers/ci/daily_build_group_report_results_worker_spec.rb2
-rw-r--r--spec/workers/ci/delete_objects_worker_spec.rb2
-rw-r--r--spec/workers/ci/delete_unit_tests_worker_spec.rb2
-rw-r--r--spec/workers/ci/drop_pipeline_worker_spec.rb2
-rw-r--r--spec/workers/ci/job_artifacts/expire_project_build_artifacts_worker_spec.rb2
-rw-r--r--spec/workers/ci/job_artifacts/track_artifact_report_worker_spec.rb5
-rw-r--r--spec/workers/ci/merge_requests/add_todo_when_build_fails_worker_spec.rb2
-rw-r--r--spec/workers/ci/parse_secure_file_metadata_worker_spec.rb2
-rw-r--r--spec/workers/ci/pending_builds/update_group_worker_spec.rb2
-rw-r--r--spec/workers/ci/pending_builds/update_project_worker_spec.rb2
-rw-r--r--spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb2
-rw-r--r--spec/workers/ci/pipeline_artifacts/create_quality_report_worker_spec.rb2
-rw-r--r--spec/workers/ci/pipeline_artifacts/expire_artifacts_worker_spec.rb2
-rw-r--r--spec/workers/ci/pipeline_bridge_status_worker_spec.rb2
-rw-r--r--spec/workers/ci/pipeline_success_unlock_artifacts_worker_spec.rb2
-rw-r--r--spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb2
-rw-r--r--spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb2
-rw-r--r--spec/workers/ci/retry_pipeline_worker_spec.rb2
-rw-r--r--spec/workers/ci/runners/stale_machines_cleanup_cron_worker_spec.rb18
-rw-r--r--spec/workers/ci/schedule_delete_objects_cron_worker_spec.rb2
-rw-r--r--spec/workers/ci/stuck_builds/drop_running_worker_spec.rb2
-rw-r--r--spec/workers/ci/stuck_builds/drop_scheduled_worker_spec.rb2
-rw-r--r--spec/workers/ci/test_failure_history_worker_spec.rb2
-rw-r--r--spec/workers/ci/track_failed_build_worker_spec.rb2
-rw-r--r--spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb2
-rw-r--r--spec/workers/ci_platform_metrics_update_cron_worker_spec.rb2
-rw-r--r--spec/workers/cleanup_container_repository_worker_spec.rb2
-rw-r--r--spec/workers/clusters/agents/delete_expired_events_worker_spec.rb2
-rw-r--r--spec/workers/clusters/agents/notify_git_push_worker_spec.rb41
-rw-r--r--spec/workers/clusters/applications/activate_integration_worker_spec.rb2
-rw-r--r--spec/workers/clusters/applications/deactivate_integration_worker_spec.rb2
-rw-r--r--spec/workers/clusters/cleanup/project_namespace_worker_spec.rb3
-rw-r--r--spec/workers/clusters/cleanup/service_account_worker_spec.rb2
-rw-r--r--spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb2
-rw-r--r--spec/workers/concerns/application_worker_spec.rb2
-rw-r--r--spec/workers/concerns/cluster_agent_queue_spec.rb5
-rw-r--r--spec/workers/concerns/cluster_queue_spec.rb21
-rw-r--r--spec/workers/concerns/cronjob_queue_spec.rb8
-rw-r--r--spec/workers/concerns/gitlab/github_import/object_importer_spec.rb92
-rw-r--r--spec/workers/concerns/gitlab/github_import/queue_spec.rb18
-rw-r--r--spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb18
-rw-r--r--spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb2
-rw-r--r--spec/workers/concerns/gitlab/notify_upon_death_spec.rb2
-rw-r--r--spec/workers/concerns/limited_capacity/job_tracker_spec.rb2
-rw-r--r--spec/workers/concerns/limited_capacity/worker_spec.rb2
-rw-r--r--spec/workers/concerns/packages/cleanup_artifact_worker_spec.rb2
-rw-r--r--spec/workers/concerns/pipeline_background_queue_spec.rb21
-rw-r--r--spec/workers/concerns/pipeline_queue_spec.rb21
-rw-r--r--spec/workers/concerns/project_import_options_spec.rb2
-rw-r--r--spec/workers/concerns/reenqueuer_spec.rb2
-rw-r--r--spec/workers/concerns/repository_check_queue_spec.rb6
-rw-r--r--spec/workers/concerns/waitable_worker_spec.rb53
-rw-r--r--spec/workers/concerns/worker_attributes_spec.rb2
-rw-r--r--spec/workers/concerns/worker_context_spec.rb20
-rw-r--r--spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb30
-rw-r--r--spec/workers/container_expiration_policy_worker_spec.rb12
-rw-r--r--spec/workers/container_registry/cleanup_worker_spec.rb73
-rw-r--r--spec/workers/container_registry/delete_container_repository_worker_spec.rb2
-rw-r--r--spec/workers/container_registry/migration/enqueuer_worker_spec.rb3
-rw-r--r--spec/workers/container_registry/migration/guard_worker_spec.rb2
-rw-r--r--spec/workers/container_registry/migration/observer_worker_spec.rb2
-rw-r--r--spec/workers/container_registry/record_data_repair_detail_worker_spec.rb191
-rw-r--r--spec/workers/counters/cleanup_refresh_worker_spec.rb2
-rw-r--r--spec/workers/create_commit_signature_worker_spec.rb2
-rw-r--r--spec/workers/create_note_diff_file_worker_spec.rb2
-rw-r--r--spec/workers/create_pipeline_worker_spec.rb2
-rw-r--r--spec/workers/database/batched_background_migration/ci_database_worker_spec.rb3
-rw-r--r--spec/workers/database/batched_background_migration_worker_spec.rb2
-rw-r--r--spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb2
-rw-r--r--spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb2
-rw-r--r--spec/workers/database/drop_detached_partitions_worker_spec.rb2
-rw-r--r--spec/workers/database/partition_management_worker_spec.rb2
-rw-r--r--spec/workers/delete_container_repository_worker_spec.rb2
-rw-r--r--spec/workers/delete_diff_files_worker_spec.rb2
-rw-r--r--spec/workers/delete_merged_branches_worker_spec.rb2
-rw-r--r--spec/workers/delete_user_worker_spec.rb52
-rw-r--r--spec/workers/dependency_proxy/cleanup_blob_worker_spec.rb2
-rw-r--r--spec/workers/dependency_proxy/cleanup_dependency_proxy_worker_spec.rb2
-rw-r--r--spec/workers/dependency_proxy/cleanup_manifest_worker_spec.rb2
-rw-r--r--spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb2
-rw-r--r--spec/workers/deployments/archive_in_project_worker_spec.rb2
-rw-r--r--spec/workers/deployments/drop_older_deployments_worker_spec.rb18
-rw-r--r--spec/workers/deployments/hooks_worker_spec.rb6
-rw-r--r--spec/workers/deployments/link_merge_request_worker_spec.rb2
-rw-r--r--spec/workers/deployments/update_environment_worker_spec.rb2
-rw-r--r--spec/workers/design_management/copy_design_collection_worker_spec.rb2
-rw-r--r--spec/workers/design_management/new_version_worker_spec.rb10
-rw-r--r--spec/workers/destroy_pages_deployments_worker_spec.rb2
-rw-r--r--spec/workers/detect_repository_languages_worker_spec.rb2
-rw-r--r--spec/workers/disallow_two_factor_for_group_worker_spec.rb2
-rw-r--r--spec/workers/disallow_two_factor_for_subgroups_worker_spec.rb2
-rw-r--r--spec/workers/email_receiver_worker_spec.rb6
-rw-r--r--spec/workers/emails_on_push_worker_spec.rb10
-rw-r--r--spec/workers/environments/auto_delete_cron_worker_spec.rb2
-rw-r--r--spec/workers/environments/auto_stop_cron_worker_spec.rb2
-rw-r--r--spec/workers/environments/auto_stop_worker_spec.rb2
-rw-r--r--spec/workers/environments/canary_ingress/update_worker_spec.rb2
-rw-r--r--spec/workers/error_tracking_issue_link_worker_spec.rb2
-rw-r--r--spec/workers/every_sidekiq_worker_spec.rb26
-rw-r--r--spec/workers/expire_build_artifacts_worker_spec.rb2
-rw-r--r--spec/workers/export_csv_worker_spec.rb2
-rw-r--r--spec/workers/external_service_reactive_caching_worker_spec.rb2
-rw-r--r--spec/workers/file_hook_worker_spec.rb2
-rw-r--r--spec/workers/flush_counter_increments_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_gists_import/import_gist_worker_spec.rb66
-rw-r--r--spec/workers/gitlab/github_import/advance_stage_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/attachments/import_issue_worker_spec.rb17
-rw-r--r--spec/workers/gitlab/github_import/attachments/import_merge_request_worker_spec.rb17
-rw-r--r--spec/workers/gitlab/github_import/attachments/import_note_worker_spec.rb3
-rw-r--r--spec/workers/gitlab/github_import/attachments/import_release_worker_spec.rb3
-rw-r--r--spec/workers/gitlab/github_import/import_collaborator_worker_spec.rb38
-rw-r--r--spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_issue_event_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_issue_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_note_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_protected_branch_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_pull_request_merged_by_worker_spec.rb4
-rw-r--r--spec/workers/gitlab/github_import/import_pull_request_review_worker_spec.rb4
-rw-r--r--spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb18
-rw-r--r--spec/workers/gitlab/github_import/import_release_attachments_worker_spec.rb8
-rw-r--r--spec/workers/gitlab/github_import/pull_requests/import_merged_by_worker_spec.rb19
-rw-r--r--spec/workers/gitlab/github_import/pull_requests/import_review_request_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/pull_requests/import_review_worker_spec.rb19
-rw-r--r--spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/import_attachments_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/import_collaborators_worker_spec.rb90
-rw-r--r--spec/workers/gitlab/github_import/stage/import_issue_events_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/import_lfs_objects_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/import_protected_branches_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker_spec.rb4
-rw-r--r--spec/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker_spec.rb4
-rw-r--r--spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb4
-rw-r--r--spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb10
-rw-r--r--spec/workers/gitlab/import/stuck_import_job_spec.rb2
-rw-r--r--spec/workers/gitlab/import/stuck_project_import_jobs_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/jira_import/import_issue_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/jira_import/stage/start_import_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/jira_import/stuck_jira_import_jobs_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/phabricator_import/base_worker_spec.rb74
-rw-r--r--spec/workers/gitlab/phabricator_import/import_tasks_worker_spec.rb16
-rw-r--r--spec/workers/gitlab_performance_bar_stats_worker_spec.rb2
-rw-r--r--spec/workers/gitlab_service_ping_worker_spec.rb8
-rw-r--r--spec/workers/gitlab_shell_worker_spec.rb2
-rw-r--r--spec/workers/google_cloud/create_cloudsql_instance_worker_spec.rb2
-rw-r--r--spec/workers/group_destroy_worker_spec.rb23
-rw-r--r--spec/workers/group_export_worker_spec.rb2
-rw-r--r--spec/workers/group_import_worker_spec.rb2
-rw-r--r--spec/workers/groups/update_statistics_worker_spec.rb2
-rw-r--r--spec/workers/groups/update_two_factor_requirement_for_members_worker_spec.rb2
-rw-r--r--spec/workers/hashed_storage/migrator_worker_spec.rb2
-rw-r--r--spec/workers/hashed_storage/project_migrate_worker_spec.rb2
-rw-r--r--spec/workers/hashed_storage/project_rollback_worker_spec.rb2
-rw-r--r--spec/workers/hashed_storage/rollbacker_worker_spec.rb2
-rw-r--r--spec/workers/import_issues_csv_worker_spec.rb2
-rw-r--r--spec/workers/incident_management/add_severity_system_note_worker_spec.rb2
-rw-r--r--spec/workers/incident_management/close_incident_worker_spec.rb4
-rw-r--r--spec/workers/incident_management/pager_duty/process_incident_worker_spec.rb2
-rw-r--r--spec/workers/incident_management/process_alert_worker_v2_spec.rb2
-rw-r--r--spec/workers/integrations/create_external_cross_reference_worker_spec.rb2
-rw-r--r--spec/workers/integrations/execute_worker_spec.rb2
-rw-r--r--spec/workers/integrations/irker_worker_spec.rb13
-rw-r--r--spec/workers/integrations/slack_event_worker_spec.rb129
-rw-r--r--spec/workers/invalid_gpg_signature_update_worker_spec.rb2
-rw-r--r--spec/workers/issuable/label_links_destroy_worker_spec.rb2
-rw-r--r--spec/workers/issuable_export_csv_worker_spec.rb49
-rw-r--r--spec/workers/issuables/clear_groups_issue_counter_worker_spec.rb2
-rw-r--r--spec/workers/issue_due_scheduler_worker_spec.rb2
-rw-r--r--spec/workers/issues/close_worker_spec.rb2
-rw-r--r--spec/workers/issues/placement_worker_spec.rb2
-rw-r--r--spec/workers/issues/rebalancing_worker_spec.rb2
-rw-r--r--spec/workers/issues/reschedule_stuck_issue_rebalances_worker_spec.rb2
-rw-r--r--spec/workers/jira_connect/forward_event_worker_spec.rb2
-rw-r--r--spec/workers/jira_connect/retry_request_worker_spec.rb2
-rw-r--r--spec/workers/jira_connect/sync_branch_worker_spec.rb6
-rw-r--r--spec/workers/jira_connect/sync_builds_worker_spec.rb6
-rw-r--r--spec/workers/jira_connect/sync_deployments_worker_spec.rb6
-rw-r--r--spec/workers/jira_connect/sync_feature_flags_worker_spec.rb6
-rw-r--r--spec/workers/jira_connect/sync_merge_request_worker_spec.rb35
-rw-r--r--spec/workers/jira_connect/sync_project_worker_spec.rb60
-rw-r--r--spec/workers/loose_foreign_keys/cleanup_worker_spec.rb2
-rw-r--r--spec/workers/mail_scheduler/issue_due_worker_spec.rb2
-rw-r--r--spec/workers/mail_scheduler/notification_service_worker_spec.rb2
-rw-r--r--spec/workers/member_invitation_reminder_emails_worker_spec.rb2
-rw-r--r--spec/workers/members_destroyer/unassign_issuables_worker_spec.rb2
-rw-r--r--spec/workers/merge_request_cleanup_refs_worker_spec.rb8
-rw-r--r--spec/workers/merge_request_mergeability_check_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/close_issue_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/create_approval_event_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/create_approval_note_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/delete_source_branch_worker_spec.rb16
-rw-r--r--spec/workers/merge_requests/execute_approval_hooks_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/handle_assignees_change_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/resolve_todos_after_approval_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/resolve_todos_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/set_reviewer_reviewed_worker_spec.rb57
-rw-r--r--spec/workers/merge_requests/update_head_pipeline_worker_spec.rb40
-rw-r--r--spec/workers/merge_worker_spec.rb2
-rw-r--r--spec/workers/metrics/dashboard/prune_old_annotations_worker_spec.rb2
-rw-r--r--spec/workers/metrics/dashboard/schedule_annotations_prune_worker_spec.rb2
-rw-r--r--spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb2
-rw-r--r--spec/workers/metrics/global_metrics_update_worker_spec.rb30
-rw-r--r--spec/workers/migrate_external_diffs_worker_spec.rb2
-rw-r--r--spec/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker_spec.rb105
-rw-r--r--spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb2
-rw-r--r--spec/workers/namespaces/process_sync_events_worker_spec.rb6
-rw-r--r--spec/workers/namespaces/prune_aggregation_schedules_worker_spec.rb5
-rw-r--r--spec/workers/namespaces/root_statistics_worker_spec.rb12
-rw-r--r--spec/workers/namespaces/schedule_aggregation_worker_spec.rb4
-rw-r--r--spec/workers/namespaces/update_root_statistics_worker_spec.rb2
-rw-r--r--spec/workers/new_issue_worker_spec.rb2
-rw-r--r--spec/workers/new_merge_request_worker_spec.rb12
-rw-r--r--spec/workers/new_note_worker_spec.rb2
-rw-r--r--spec/workers/object_pool/create_worker_spec.rb2
-rw-r--r--spec/workers/object_pool/destroy_worker_spec.rb12
-rw-r--r--spec/workers/object_pool/join_worker_spec.rb2
-rw-r--r--spec/workers/onboarding/issue_created_worker_spec.rb2
-rw-r--r--spec/workers/onboarding/pipeline_created_worker_spec.rb2
-rw-r--r--spec/workers/onboarding/progress_worker_spec.rb2
-rw-r--r--spec/workers/onboarding/user_added_worker_spec.rb2
-rw-r--r--spec/workers/packages/cleanup/delete_orphaned_dependencies_worker_spec.rb118
-rw-r--r--spec/workers/packages/cleanup/execute_policy_worker_spec.rb2
-rw-r--r--spec/workers/packages/cleanup_package_file_worker_spec.rb2
-rw-r--r--spec/workers/packages/cleanup_package_registry_worker_spec.rb2
-rw-r--r--spec/workers/packages/composer/cache_cleanup_worker_spec.rb2
-rw-r--r--spec/workers/packages/composer/cache_update_worker_spec.rb2
-rw-r--r--spec/workers/packages/debian/cleanup_dangling_package_files_worker_spec.rb85
-rw-r--r--spec/workers/packages/debian/generate_distribution_worker_spec.rb10
-rw-r--r--spec/workers/packages/debian/process_changes_worker_spec.rb6
-rw-r--r--spec/workers/packages/debian/process_package_file_worker_spec.rb2
-rw-r--r--spec/workers/packages/go/sync_packages_worker_spec.rb2
-rw-r--r--spec/workers/packages/helm/extraction_worker_spec.rb2
-rw-r--r--spec/workers/packages/mark_package_files_for_destruction_worker_spec.rb2
-rw-r--r--spec/workers/packages/npm/deprecate_package_worker_spec.rb35
-rw-r--r--spec/workers/packages/nuget/extraction_worker_spec.rb27
-rw-r--r--spec/workers/packages/rubygems/extraction_worker_spec.rb2
-rw-r--r--spec/workers/pages_domain_removal_cron_worker_spec.rb2
-rw-r--r--spec/workers/pages_domain_ssl_renewal_cron_worker_spec.rb2
-rw-r--r--spec/workers/pages_domain_ssl_renewal_worker_spec.rb2
-rw-r--r--spec/workers/pages_domain_verification_cron_worker_spec.rb2
-rw-r--r--spec/workers/pages_domain_verification_worker_spec.rb2
-rw-r--r--spec/workers/pages_worker_spec.rb2
-rw-r--r--spec/workers/partition_creation_worker_spec.rb2
-rw-r--r--spec/workers/personal_access_tokens/expired_notification_worker_spec.rb2
-rw-r--r--spec/workers/personal_access_tokens/expiring_worker_spec.rb2
-rw-r--r--spec/workers/pipeline_hooks_worker_spec.rb6
-rw-r--r--spec/workers/pipeline_metrics_worker_spec.rb22
-rw-r--r--spec/workers/pipeline_notification_worker_spec.rb2
-rw-r--r--spec/workers/pipeline_process_worker_spec.rb10
-rw-r--r--spec/workers/post_receive_spec.rb3
-rw-r--r--spec/workers/process_commit_worker_spec.rb14
-rw-r--r--spec/workers/project_cache_worker_spec.rb11
-rw-r--r--spec/workers/project_destroy_worker_spec.rb25
-rw-r--r--spec/workers/project_export_worker_spec.rb2
-rw-r--r--spec/workers/projects/after_import_worker_spec.rb2
-rw-r--r--spec/workers/projects/finalize_project_statistics_refresh_worker_spec.rb2
-rw-r--r--spec/workers/projects/import_export/create_relation_exports_worker_spec.rb67
-rw-r--r--spec/workers/projects/import_export/relation_export_worker_spec.rb49
-rw-r--r--spec/workers/projects/import_export/wait_relation_exports_worker_spec.rb123
-rw-r--r--spec/workers/projects/inactive_projects_deletion_cron_worker_spec.rb28
-rw-r--r--spec/workers/projects/inactive_projects_deletion_notification_worker_spec.rb11
-rw-r--r--spec/workers/projects/post_creation_worker_spec.rb2
-rw-r--r--spec/workers/projects/process_sync_events_worker_spec.rb6
-rw-r--r--spec/workers/projects/record_target_platforms_worker_spec.rb69
-rw-r--r--spec/workers/projects/refresh_build_artifacts_size_statistics_worker_spec.rb2
-rw-r--r--spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb2
-rw-r--r--spec/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker_spec.rb2
-rw-r--r--spec/workers/projects/update_repository_storage_worker_spec.rb2
-rw-r--r--spec/workers/propagate_integration_group_worker_spec.rb2
-rw-r--r--spec/workers/propagate_integration_inherit_descendant_worker_spec.rb2
-rw-r--r--spec/workers/propagate_integration_inherit_worker_spec.rb2
-rw-r--r--spec/workers/propagate_integration_worker_spec.rb2
-rw-r--r--spec/workers/prune_old_events_worker_spec.rb14
-rw-r--r--spec/workers/purge_dependency_proxy_cache_worker_spec.rb2
-rw-r--r--spec/workers/reactive_caching_worker_spec.rb2
-rw-r--r--spec/workers/rebase_worker_spec.rb14
-rw-r--r--spec/workers/releases/create_evidence_worker_spec.rb2
-rw-r--r--spec/workers/releases/manage_evidence_worker_spec.rb2
-rw-r--r--spec/workers/remote_mirror_notification_worker_spec.rb8
-rw-r--r--spec/workers/remove_expired_group_links_worker_spec.rb2
-rw-r--r--spec/workers/remove_expired_members_worker_spec.rb17
-rw-r--r--spec/workers/remove_unaccepted_member_invites_worker_spec.rb58
-rw-r--r--spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb16
-rw-r--r--spec/workers/repository_check/batch_worker_spec.rb2
-rw-r--r--spec/workers/repository_check/clear_worker_spec.rb2
-rw-r--r--spec/workers/repository_check/dispatch_worker_spec.rb2
-rw-r--r--spec/workers/repository_check/single_repository_worker_spec.rb2
-rw-r--r--spec/workers/repository_cleanup_worker_spec.rb2
-rw-r--r--spec/workers/repository_fork_worker_spec.rb2
-rw-r--r--spec/workers/repository_update_remote_mirror_worker_spec.rb20
-rw-r--r--spec/workers/run_pipeline_schedule_worker_spec.rb8
-rw-r--r--spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb2
-rw-r--r--spec/workers/schedule_migrate_external_diffs_worker_spec.rb2
-rw-r--r--spec/workers/self_monitoring_project_create_worker_spec.rb16
-rw-r--r--spec/workers/self_monitoring_project_delete_worker_spec.rb19
-rw-r--r--spec/workers/service_desk_email_receiver_worker_spec.rb2
-rw-r--r--spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb2
-rw-r--r--spec/workers/snippets/update_repository_storage_worker_spec.rb2
-rw-r--r--spec/workers/ssh_keys/expired_notification_worker_spec.rb3
-rw-r--r--spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb3
-rw-r--r--spec/workers/ssh_keys/update_last_used_at_worker_spec.rb23
-rw-r--r--spec/workers/stage_update_worker_spec.rb2
-rw-r--r--spec/workers/stuck_ci_jobs_worker_spec.rb2
-rw-r--r--spec/workers/stuck_export_jobs_worker_spec.rb2
-rw-r--r--spec/workers/stuck_merge_jobs_worker_spec.rb2
-rw-r--r--spec/workers/system_hook_push_worker_spec.rb2
-rw-r--r--spec/workers/tasks_to_be_done/create_worker_spec.rb2
-rw-r--r--spec/workers/terraform/states/destroy_worker_spec.rb2
-rw-r--r--spec/workers/todos_destroyer/confidential_issue_worker_spec.rb2
-rw-r--r--spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb2
-rw-r--r--spec/workers/todos_destroyer/destroyed_issuable_worker_spec.rb2
-rw-r--r--spec/workers/todos_destroyer/entity_leave_worker_spec.rb2
-rw-r--r--spec/workers/todos_destroyer/group_private_worker_spec.rb2
-rw-r--r--spec/workers/todos_destroyer/private_features_worker_spec.rb2
-rw-r--r--spec/workers/todos_destroyer/project_private_worker_spec.rb2
-rw-r--r--spec/workers/trending_projects_worker_spec.rb2
-rw-r--r--spec/workers/update_container_registry_info_worker_spec.rb2
-rw-r--r--spec/workers/update_external_pull_requests_worker_spec.rb2
-rw-r--r--spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb14
-rw-r--r--spec/workers/update_highest_role_worker_spec.rb2
-rw-r--r--spec/workers/update_merge_requests_worker_spec.rb2
-rw-r--r--spec/workers/update_project_statistics_worker_spec.rb2
-rw-r--r--spec/workers/upload_checksum_worker_spec.rb2
-rw-r--r--spec/workers/user_status_cleanup/batch_worker_spec.rb2
-rw-r--r--spec/workers/users/create_statistics_worker_spec.rb2
-rw-r--r--spec/workers/users/deactivate_dormant_users_worker_spec.rb8
-rw-r--r--spec/workers/users/migrate_records_to_ghost_user_in_batches_worker_spec.rb2
-rw-r--r--spec/workers/web_hook_worker_spec.rb6
-rw-r--r--spec/workers/web_hooks/log_destroy_worker_spec.rb2
-rw-r--r--spec/workers/work_items/import_work_items_csv_worker_spec.rb44
-rw-r--r--spec/workers/x509_certificate_revoke_worker_spec.rb2
-rw-r--r--spec/workers/x509_issuer_crl_check_worker_spec.rb2
6349 files changed, 151563 insertions, 78904 deletions
diff --git a/spec/benchmarks/banzai_benchmark.rb b/spec/benchmarks/banzai_benchmark.rb
index 7a60825c1e6..45f45bcc8dd 100644
--- a/spec/benchmarks/banzai_benchmark.rb
+++ b/spec/benchmarks/banzai_benchmark.rb
@@ -16,8 +16,15 @@ require 'benchmark/ips'
# or
# rake benchmark:banzai
#
+# A specific filter can also be benchmarked by using the `FILTER`
+# environment variable.
+#
+# BENCHMARK=1 FILTER=MathFilter rspec spec/benchmarks/banzai_benchmark.rb --tag specific_filter
+# or
+# FILTER=MathFilter rake benchmark:banzai
+#
# rubocop: disable RSpec/TopLevelDescribePath
-RSpec.describe 'GitLab Markdown Benchmark', :aggregate_failures do
+RSpec.describe 'GitLab Markdown Benchmark', :aggregate_failures, feature_category: :team_planning do
include MarkupHelper
let_it_be(:feature) { MarkdownFeature.new }
@@ -83,9 +90,15 @@ RSpec.describe 'GitLab Markdown Benchmark', :aggregate_failures do
benchmark_pipeline_filters(:plain_markdown)
end
- it 'benchmarks specified filters in the FullPipeline' do
- filter_klass_list = [Banzai::Filter::MathFilter]
- benchmark_pipeline_filters(:full, filter_klass_list)
+ it 'benchmarks specified filters in the FullPipeline', :specific_filter do
+ begin
+ filter = ENV['FILTER'] || 'MarkdownFilter'
+ filter_klass = "Banzai::Filter::#{filter}".constantize
+ rescue NameError
+ raise 'Incorrect filter specified. Correct example: FILTER=MathFilter'
+ end
+
+ benchmark_pipeline_filters(:full, [filter_klass])
end
end
@@ -114,7 +127,8 @@ RSpec.describe 'GitLab Markdown Benchmark', :aggregate_failures do
pipeline = Banzai::Pipeline[pipeline_type]
filter_source = build_filter_text(pipeline, markdown_text)
- puts "\n--> Benchmarking #{pipeline.name.demodulize} filters\n"
+ filter_msg = filter_klass_list ? filter_klass_list.first.name.demodulize : 'all filters'
+ puts "\n--> Benchmarking #{filter_msg} for #{pipeline.name.demodulize}\n"
Benchmark.ips do |x|
x.config(time: 10, warmup: 2)
diff --git a/spec/bin/sidekiq_cluster_spec.rb b/spec/bin/sidekiq_cluster_spec.rb
index eb014c511e3..b36fb82c295 100644
--- a/spec/bin/sidekiq_cluster_spec.rb
+++ b/spec/bin/sidekiq_cluster_spec.rb
@@ -12,7 +12,8 @@ RSpec.describe 'bin/sidekiq-cluster', :aggregate_failures do
context 'when selecting some queues and excluding others' do
where(:args, :included, :excluded) do
%w[--negate cronjob] | '-qdefault,1' | '-qcronjob,1'
- %w[--queue-selector resource_boundary=cpu] | '-qupdate_merge_requests,1' | '-qdefault,1'
+ %w[--queue-selector resource_boundary=cpu] | %w[-qupdate_merge_requests,1 -qdefault,1 -qmailers,1] |
+ '-qauthorized_keys_worker,1'
end
with_them do
@@ -23,8 +24,8 @@ RSpec.describe 'bin/sidekiq-cluster', :aggregate_failures do
expect(status).to be(0)
expect(output).to include('bundle exec sidekiq')
- expect(Shellwords.split(output)).to include(included)
- expect(Shellwords.split(output)).not_to include(excluded)
+ expect(Shellwords.split(output)).to include(*included)
+ expect(Shellwords.split(output)).not_to include(*excluded)
end
end
end
diff --git a/spec/channels/awareness_channel_spec.rb b/spec/channels/awareness_channel_spec.rb
deleted file mode 100644
index 47b1cd0188f..00000000000
--- a/spec/channels/awareness_channel_spec.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe AwarenessChannel, :clean_gitlab_redis_shared_state, type: :channel do
- before do
- stub_action_cable_connection(current_user: user)
- end
-
- context "with user" do
- let(:user) { create(:user) }
-
- describe "when no path parameter given" do
- it "rejects subscription" do
- subscribe path: nil
-
- expect(subscription).to be_rejected
- end
- end
-
- describe "with valid path parameter" do
- it "successfully subscribes" do
- subscribe path: "/test"
-
- session = AwarenessSession.for("/test")
-
- expect(subscription).to be_confirmed
- # check if we can use session object instead
- expect(subscription).to have_stream_from("awareness:#{session.to_param}")
- end
-
- it "broadcasts set of collaborators when subscribing" do
- session = AwarenessSession.for("/test")
-
- freeze_time do
- collaborator = {
- id: user.id,
- name: user.name,
- username: user.username,
- avatar_url: user.avatar_url(size: 36),
- last_activity: Time.zone.now,
- last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words(
- Time.zone.now, Time.zone.now
- )
- }
-
- expect do
- subscribe path: "/test"
- end.to have_broadcasted_to("awareness:#{session.to_param}")
- .with(collaborators: [collaborator])
- end
- end
-
- it "transmits payload when user is touched" do
- subscribe path: "/test"
-
- perform :touch
-
- expect(transmissions.size).to be 1
- end
-
- it "unsubscribes from channel" do
- subscribe path: "/test"
- session = AwarenessSession.for("/test")
-
- expect { subscription.unsubscribe_from_channel }
- .to change { session.size }.by(-1)
- end
- end
- end
-
- context "with guest" do
- let(:user) { nil }
-
- it "rejects subscription" do
- subscribe path: "/test"
-
- expect(subscription).to be_rejected
- end
- end
-end
diff --git a/spec/commands/metrics_server/metrics_server_spec.rb b/spec/commands/metrics_server/metrics_server_spec.rb
index 310e31da045..88a28b02903 100644
--- a/spec/commands/metrics_server/metrics_server_spec.rb
+++ b/spec/commands/metrics_server/metrics_server_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe 'GitLab metrics server', :aggregate_failures do
if use_golang_server
stub_env('GITLAB_GOLANG_METRICS_SERVER', '1')
allow(Settings).to receive(:monitoring).and_return(
- Settingslogic.new(config.dig('test', 'monitoring')))
+ GitlabSettings::Options.build(config.dig('test', 'monitoring')))
else
config_file.write(YAML.dump(config))
config_file.close
diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb
index 0c32fa2571a..085be1ceac2 100644
--- a/spec/commands/sidekiq_cluster/cli_spec.rb
+++ b/spec/commands/sidekiq_cluster/cli_spec.rb
@@ -1,13 +1,12 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
-require 'rspec-parameterized'
+require 'spec_helper'
require_relative '../../support/stub_settings_source'
require_relative '../../../sidekiq_cluster/cli'
require_relative '../../support/helpers/next_instance_of'
-RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubocop:disable RSpec/FilePath
+RSpec.describe Gitlab::SidekiqCluster::CLI, feature_category: :gitlab_cli, stub_settings_source: true do # rubocop:disable RSpec/FilePath
include NextInstanceOf
let(:cli) { described_class.new('/dev/null') }
@@ -19,17 +18,12 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
let(:sidekiq_exporter_enabled) { false }
let(:sidekiq_exporter_port) { '3807' }
- let(:config_file) { Tempfile.new('gitlab.yml') }
let(:config) do
{
- 'test' => {
- 'monitoring' => {
- 'sidekiq_exporter' => {
- 'address' => 'localhost',
- 'enabled' => sidekiq_exporter_enabled,
- 'port' => sidekiq_exporter_port
- }
- }
+ 'sidekiq_exporter' => {
+ 'address' => 'localhost',
+ 'enabled' => sidekiq_exporter_enabled,
+ 'port' => sidekiq_exporter_port
}
}
end
@@ -38,23 +32,22 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
let(:metrics_cleanup_service) { instance_double(Prometheus::CleanupMultiprocDirService, execute: nil) }
before do
- stub_env('RAILS_ENV', 'test')
-
- config_file.write(YAML.dump(config))
- config_file.close
-
- allow(::Settings).to receive(:source).and_return(config_file.path)
- ::Settings.reload!
-
allow(Gitlab::ProcessManagement).to receive(:write_pid)
allow(Gitlab::SidekiqCluster::SidekiqProcessSupervisor).to receive(:instance).and_return(supervisor)
allow(supervisor).to receive(:supervise)
allow(Prometheus::CleanupMultiprocDirService).to receive(:new).and_return(metrics_cleanup_service)
+
+ stub_config(sidekiq: { routing_rules: [] })
end
- after do
- config_file.unlink
+ around do |example|
+ original = Settings['monitoring']
+ Settings['monitoring'] = config
+
+ example.run
+
+ Settings['monitoring'] = original
end
describe '#run' do
@@ -67,7 +60,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
context 'with arguments' do
it 'starts the Sidekiq workers' do
expect(Gitlab::SidekiqCluster).to receive(:start)
- .with([['foo']], default_options)
+ .with([['foo'] + described_class::DEFAULT_QUEUES], default_options)
.and_return([])
cli.run(%w(foo))
@@ -101,7 +94,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
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'])
expect(Gitlab::SidekiqCluster).to receive(:start)
- .with([['baz']], default_options)
+ .with([['baz'] + described_class::DEFAULT_QUEUES], default_options)
.and_return([])
cli.run(%w(foo -n))
@@ -110,9 +103,10 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
context 'with --max-concurrency flag' do
it 'starts Sidekiq workers for specified queues with a max concurrency' do
+ expected_queues = [%w(foo bar baz), %w(solo)].each { |queues| queues.concat(described_class::DEFAULT_QUEUES) }
expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(%w(foo bar baz))
expect(Gitlab::SidekiqCluster).to receive(:start)
- .with([%w(foo bar baz), %w(solo)], default_options.merge(max_concurrency: 2))
+ .with(expected_queues, default_options.merge(max_concurrency: 2))
.and_return([])
cli.run(%w(foo,bar,baz solo -m 2))
@@ -121,9 +115,10 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
context 'with --min-concurrency flag' do
it 'starts Sidekiq workers for specified queues with a min concurrency' do
+ expected_queues = [%w(foo bar baz), %w(solo)].each { |queues| queues.concat(described_class::DEFAULT_QUEUES) }
expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(%w(foo bar baz))
expect(Gitlab::SidekiqCluster).to receive(:start)
- .with([%w(foo bar baz), %w(solo)], default_options.merge(min_concurrency: 2))
+ .with(expected_queues, default_options.merge(min_concurrency: 2))
.and_return([])
cli.run(%w(foo,bar,baz solo --min-concurrency 2))
@@ -133,7 +128,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
context 'with --timeout flag' do
it 'when given', 'starts Sidekiq workers with given timeout' do
expect(Gitlab::SidekiqCluster).to receive(:start)
- .with([['foo']], default_options.merge(timeout: 10))
+ .with([['foo'] + described_class::DEFAULT_QUEUES], default_options.merge(timeout: 10))
.and_return([])
cli.run(%w(foo --timeout 10))
@@ -141,7 +136,8 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
it 'when not given', 'starts Sidekiq workers with default timeout' do
expect(Gitlab::SidekiqCluster).to receive(:start)
- .with([['foo']], default_options.merge(timeout: Gitlab::SidekiqCluster::DEFAULT_SOFT_TIMEOUT_SECONDS))
+ .with([['foo'] + described_class::DEFAULT_QUEUES], default_options.merge(timeout:
+ Gitlab::SidekiqCluster::DEFAULT_SOFT_TIMEOUT_SECONDS))
.and_return([])
cli.run(%w(foo))
@@ -155,8 +151,10 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
it 'prints out a list of queues in alphabetical order' do
expected_queues = [
+ 'default',
'epics:epics_update_epics_dates',
'epics_new_epic_issue',
+ 'mailers',
'new_epic',
'todos_destroyer:todos_destroyer_confidential_epic'
]
@@ -173,7 +171,8 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
it 'starts Sidekiq workers for all queues in all_queues.yml with a namespace in argv' do
expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(['cronjob:foo', 'cronjob:bar'])
expect(Gitlab::SidekiqCluster).to receive(:start)
- .with([['cronjob', 'cronjob:foo', 'cronjob:bar']], default_options)
+ .with([['cronjob', 'cronjob:foo', 'cronjob:bar'] +
+ described_class::DEFAULT_QUEUES], default_options)
.and_return([])
cli.run(%w(cronjob))
@@ -211,7 +210,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
'CI and SCM queues' => {
query: 'feature_category=continuous_integration|feature_category=source_code_management',
included_queues: %w(pipeline_default:ci_drop_pipeline merge),
- excluded_queues: %w(mailers)
+ excluded_queues: %w()
}
}
end
@@ -222,6 +221,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
expect(opts).to eq(default_options)
expect(queues.first).to include(*included_queues)
expect(queues.first).not_to include(*excluded_queues)
+ expect(queues.first).to include(*described_class::DEFAULT_QUEUES)
[]
end
@@ -234,6 +234,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
expect(opts).to eq(default_options)
expect(queues.first).not_to include(*included_queues)
expect(queues.first).to include(*excluded_queues)
+ expect(queues.first).to include(*described_class::DEFAULT_QUEUES)
[]
end
@@ -246,13 +247,15 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
expected_workers =
if Gitlab.ee?
[
- %w[cronjob:clusters_integrations_check_prometheus_health incident_management_close_incident status_page_publish],
- %w[project_export projects_import_export_parallel_project_export projects_import_export_relation_export project_template_export]
+ %w[cronjob:clusters_integrations_check_prometheus_health incident_management_close_incident status_page_publish] + described_class::DEFAULT_QUEUES,
+ %w[bulk_imports_pipeline bulk_imports_relation_export project_export projects_import_export_parallel_project_export projects_import_export_relation_export repository_import project_template_export] +
+ described_class::DEFAULT_QUEUES
]
else
[
- %w[cronjob:clusters_integrations_check_prometheus_health incident_management_close_incident],
- %w[project_export projects_import_export_parallel_project_export projects_import_export_relation_export]
+ %w[cronjob:clusters_integrations_check_prometheus_health incident_management_close_incident] + described_class::DEFAULT_QUEUES,
+ %w[bulk_imports_pipeline bulk_imports_relation_export project_export projects_import_export_parallel_project_export projects_import_export_relation_export repository_import] +
+ described_class::DEFAULT_QUEUES
]
end
@@ -290,6 +293,40 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
.to raise_error(Gitlab::SidekiqConfig::WorkerMatcher::QueryError)
end
end
+
+ context "with routing rules specified" do
+ before do
+ stub_config(sidekiq: { routing_rules: [['resource_boundary=cpu', 'foo']] })
+ end
+
+ it "starts Sidekiq workers only for given queues without any additional DEFAULT_QUEUES" do
+ expect(Gitlab::SidekiqCluster).to receive(:start)
+ .with([['foo']], default_options)
+ .and_return([])
+
+ cli.run(%w(foo))
+ end
+ end
+
+ context "with sidekiq settings not specified" do
+ before do
+ stub_config(sidekiq: nil)
+ end
+
+ it "does not throw an error" do
+ allow(Gitlab::SidekiqCluster).to receive(:start).and_return([])
+
+ expect { cli.run(%w(foo)) }.not_to raise_error
+ end
+
+ it "starts Sidekiq workers with given queues, and additional default and mailers queues (DEFAULT_QUEUES)" do
+ expect(Gitlab::SidekiqCluster).to receive(:start)
+ .with([['foo'] + described_class::DEFAULT_QUEUES], default_options)
+ .and_return([])
+
+ cli.run(%w(foo))
+ end
+ end
end
context 'metrics server' do
@@ -319,13 +356,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
context 'when sidekiq_exporter is not set up' do
let(:config) do
- {
- 'test' => {
- 'monitoring' => {
- 'sidekiq_exporter' => {}
- }
- }
- }
+ { 'sidekiq_exporter' => {} }
end
it 'does not start a sidekiq metrics server' do
@@ -337,13 +368,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
context 'with missing sidekiq_exporter setting' do
let(:config) do
- {
- 'test' => {
- 'monitoring' => {
- 'sidekiq_exporter' => nil
- }
- }
- }
+ { 'sidekiq_exporter' => nil }
end
it 'does not start a sidekiq metrics server' do
diff --git a/spec/components/layouts/horizontal_section_component_spec.rb b/spec/components/layouts/horizontal_section_component_spec.rb
index efc48213911..b0a749e58d6 100644
--- a/spec/components/layouts/horizontal_section_component_spec.rb
+++ b/spec/components/layouts/horizontal_section_component_spec.rb
@@ -9,8 +9,8 @@ RSpec.describe Layouts::HorizontalSectionComponent, type: :component do
describe 'slots' do
it 'renders title' do
render_inline described_class.new do |c|
- c.title { title }
- c.body { body }
+ c.with_title { title }
+ c.with_body { body }
end
expect(page).to have_css('h4', text: title)
@@ -18,8 +18,8 @@ RSpec.describe Layouts::HorizontalSectionComponent, type: :component do
it 'renders body slot' do
render_inline described_class.new do |c|
- c.title { title }
- c.body { body }
+ c.with_title { title }
+ c.with_body { body }
end
expect(page).to have_content(body)
@@ -28,9 +28,9 @@ RSpec.describe Layouts::HorizontalSectionComponent, type: :component do
context 'when description slot is provided' do
before do
render_inline described_class.new do |c|
- c.title { title }
- c.description { description }
- c.body { body }
+ c.with_title { title }
+ c.with_description { description }
+ c.with_body { body }
end
end
@@ -42,8 +42,8 @@ RSpec.describe Layouts::HorizontalSectionComponent, type: :component do
context 'when description slot is not provided' do
before do
render_inline described_class.new do |c|
- c.title { title }
- c.body { body }
+ c.with_title { title }
+ c.with_body { body }
end
end
@@ -57,8 +57,8 @@ RSpec.describe Layouts::HorizontalSectionComponent, type: :component do
describe 'border' do
it 'defaults to true and adds gl-border-b CSS class' do
render_inline described_class.new do |c|
- c.title { title }
- c.body { body }
+ c.with_title { title }
+ c.with_body { body }
end
expect(page).to have_css('.gl-border-b')
@@ -66,8 +66,8 @@ RSpec.describe Layouts::HorizontalSectionComponent, type: :component do
it 'does not add gl-border-b CSS class when set to false' do
render_inline described_class.new(border: false) do |c|
- c.title { title }
- c.body { body }
+ c.with_title { title }
+ c.with_body { body }
end
expect(page).not_to have_css('.gl-border-b')
@@ -77,8 +77,8 @@ RSpec.describe Layouts::HorizontalSectionComponent, type: :component do
describe 'options' do
it 'adds options to wrapping element' do
render_inline described_class.new(options: { data: { testid: 'foo-bar' }, class: 'foo-bar' }) do |c|
- c.title { title }
- c.body { body }
+ c.with_title { title }
+ c.with_body { body }
end
expect(page).to have_css('.foo-bar[data-testid="foo-bar"]')
diff --git a/spec/components/pajamas/alert_component_spec.rb b/spec/components/pajamas/alert_component_spec.rb
index 4a90a9e0b88..8f02979357e 100644
--- a/spec/components/pajamas/alert_component_spec.rb
+++ b/spec/components/pajamas/alert_component_spec.rb
@@ -8,8 +8,8 @@ RSpec.describe Pajamas::AlertComponent, :aggregate_failures, type: :component do
before do
render_inline described_class.new do |c|
- c.body { body }
- c.actions { actions }
+ c.with_body { body }
+ c.with_actions { actions }
end
end
diff --git a/spec/components/pajamas/avatar_component_spec.rb b/spec/components/pajamas/avatar_component_spec.rb
index 3b4e4e49fc2..d59ef390fad 100644
--- a/spec/components/pajamas/avatar_component_spec.rb
+++ b/spec/components/pajamas/avatar_component_spec.rb
@@ -92,7 +92,7 @@ RSpec.describe Pajamas::AvatarComponent, type: :component do
let(:record) { user }
it "uses a gravatar" do
- expect(rendered_component).to match /gravatar\.com/
+ expect(rendered_content).to match /gravatar\.com/
end
end
end
diff --git a/spec/components/pajamas/banner_component_spec.rb b/spec/components/pajamas/banner_component_spec.rb
index 861b10c3f69..6b99b4c1d76 100644
--- a/spec/components/pajamas/banner_component_spec.rb
+++ b/spec/components/pajamas/banner_component_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Pajamas::BannerComponent, type: :component do
describe 'basic usage' do
before do
render_inline(subject) do |c|
- c.title { title }
+ c.with_title { title }
content
end
end
@@ -124,8 +124,8 @@ RSpec.describe Pajamas::BannerComponent, type: :component do
context 'with illustration slot' do
before do
render_inline(subject) do |c|
- c.title { title }
- c.illustration { "<svg></svg>".html_safe }
+ c.with_title { title }
+ c.with_illustration { "<svg></svg>".html_safe }
content
end
end
@@ -147,8 +147,8 @@ RSpec.describe Pajamas::BannerComponent, type: :component do
context 'with primary_action slot' do
before do
render_inline(subject) do |c|
- c.title { title }
- c.primary_action { "<a class='special' href='#'>Special</a>".html_safe }
+ c.with_title { title }
+ c.with_primary_action { "<a class='special' href='#'>Special</a>".html_safe }
content
end
end
diff --git a/spec/components/pajamas/card_component_spec.rb b/spec/components/pajamas/card_component_spec.rb
index 38d23cfca9c..6328385754d 100644
--- a/spec/components/pajamas/card_component_spec.rb
+++ b/spec/components/pajamas/card_component_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe Pajamas::CardComponent, :aggregate_failures, type: :component do
context 'slots' do
before do
render_inline described_class.new do |c|
- c.header { header }
- c.body { body }
- c.footer { footer }
+ c.with_header { header }
+ c.with_body { body }
+ c.with_footer { footer }
end
end
@@ -51,9 +51,9 @@ RSpec.describe Pajamas::CardComponent, :aggregate_failures, type: :component do
header_options: { class: '_header_class_', data: { testid: '_header_testid_' } },
body_options: { class: '_body_class_', data: { testid: '_body_testid_' } },
footer_options: { class: '_footer_class_', data: { testid: '_footer_testid_' } }) do |c|
- c.header { header }
- c.body { body }
- c.footer { footer }
+ c.with_header { header }
+ c.with_body { body }
+ c.with_footer { footer }
end
end
diff --git a/spec/components/pajamas/checkbox_component_spec.rb b/spec/components/pajamas/checkbox_component_spec.rb
index 3d50509ef10..ea3ebe77811 100644
--- a/spec/components/pajamas/checkbox_component_spec.rb
+++ b/spec/components/pajamas/checkbox_component_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe Pajamas::CheckboxComponent, :aggregate_failures, type: :component
method: method
)
) do |c|
- c.label { label }
+ c.with_label { label }
end
end
end
@@ -94,7 +94,7 @@ RSpec.describe Pajamas::CheckboxComponent, :aggregate_failures, type: :component
label: label
)
) do |c|
- c.help_text { help_text }
+ c.with_help_text { help_text }
end
end
end
@@ -112,8 +112,8 @@ RSpec.describe Pajamas::CheckboxComponent, :aggregate_failures, type: :component
method: method
)
) do |c|
- c.label { label }
- c.help_text { help_text }
+ c.with_label { label }
+ c.with_help_text { help_text }
end
end
end
diff --git a/spec/components/pajamas/checkbox_tag_component_spec.rb b/spec/components/pajamas/checkbox_tag_component_spec.rb
index bca7a6005d5..fb5f927469b 100644
--- a/spec/components/pajamas/checkbox_tag_component_spec.rb
+++ b/spec/components/pajamas/checkbox_tag_component_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Pajamas::CheckboxTagComponent, :aggregate_failures, type: :compon
context 'with default options' do
before do
render_inline(described_class.new(name: name)) do |c|
- c.label { label }
+ c.with_label { label }
end
end
@@ -32,7 +32,7 @@ RSpec.describe Pajamas::CheckboxTagComponent, :aggregate_failures, type: :compon
label_options: label_options
)
) do |c|
- c.label { label }
+ c.with_label { label }
end
end
@@ -48,8 +48,8 @@ RSpec.describe Pajamas::CheckboxTagComponent, :aggregate_failures, type: :compon
context 'with `help_text` slot' do
before do
render_inline(described_class.new(name: name)) do |c|
- c.label { label }
- c.help_text { help_text }
+ c.with_label { label }
+ c.with_help_text { help_text }
end
end
diff --git a/spec/components/pajamas/radio_component_spec.rb b/spec/components/pajamas/radio_component_spec.rb
index 8df432746d0..5b12e7c2785 100644
--- a/spec/components/pajamas/radio_component_spec.rb
+++ b/spec/components/pajamas/radio_component_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe Pajamas::RadioComponent, :aggregate_failures, type: :component do
value: value
)
) do |c|
- c.label { label }
+ c.with_label { label }
end
end
end
@@ -95,7 +95,7 @@ RSpec.describe Pajamas::RadioComponent, :aggregate_failures, type: :component do
label: label
)
) do |c|
- c.help_text { help_text }
+ c.with_help_text { help_text }
end
end
end
@@ -114,8 +114,8 @@ RSpec.describe Pajamas::RadioComponent, :aggregate_failures, type: :component do
value: value
)
) do |c|
- c.label { label }
- c.help_text { help_text }
+ c.with_label { label }
+ c.with_help_text { help_text }
end
end
end
diff --git a/spec/config/inject_enterprise_edition_module_spec.rb b/spec/config/inject_enterprise_edition_module_spec.rb
index e8c0905ff89..6689fed02f5 100644
--- a/spec/config/inject_enterprise_edition_module_spec.rb
+++ b/spec/config/inject_enterprise_edition_module_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe InjectEnterpriseEditionModule, feature_category: :not_owned do
+RSpec.describe InjectEnterpriseEditionModule, feature_category: :shared do
let(:extension_name) { 'FF' }
let(:extension_namespace) { Module.new }
let(:fish_name) { 'Fish' }
diff --git a/spec/config/mail_room_spec.rb b/spec/config/mail_room_spec.rb
index a3806fb3cb6..05230b65956 100644
--- a/spec/config/mail_room_spec.rb
+++ b/spec/config/mail_room_spec.rb
@@ -9,24 +9,13 @@ RSpec.describe 'mail_room.yml', feature_category: :service_desk do
let(:gitlab_config_path) { 'config/mail_room.yml' }
let(:queues_config_path) { 'config/redis.queues.yml' }
- let(:configuration) do
- vars = {
- 'MAIL_ROOM_GITLAB_CONFIG_FILE' => absolute_path(gitlab_config_path),
- 'GITLAB_REDIS_QUEUES_CONFIG_FILE' => absolute_path(queues_config_path)
- }
- cmd = "puts ERB.new(File.read(#{absolute_path(mailroom_config_path).inspect})).result"
-
- result = Gitlab::Popen.popen_with_detail(%W(ruby -rerb -e #{cmd}), absolute_path('config'), vars)
- output = result.stdout
- errors = result.stderr
- status = result.status
- raise "Error interpreting #{mailroom_config_path}: #{output}\n#{errors}" unless status == 0
-
- YAML.safe_load(output, permitted_classes: [Symbol])
- end
+ let(:configuration) { YAML.safe_load(ERB.new(File.read(mailroom_config_path)).result, permitted_classes: [Symbol]) }
before do
- stub_env('GITLAB_REDIS_QUEUES_CONFIG_FILE', absolute_path(queues_config_path))
+ stub_env('MAIL_ROOM_GITLAB_CONFIG_FILE', absolute_path(gitlab_config_path))
+ allow(Gitlab::Redis::Queues).to receive(:config_file_name).and_return(queues_config_path)
+ Gitlab::MailRoom.instance_variable_set(:@enabled_configs, nil)
+ Gitlab::MailRoom.instance_variable_set(:@yaml, nil)
end
context 'when incoming email is disabled' do
diff --git a/spec/config/object_store_settings_spec.rb b/spec/config/object_store_settings_spec.rb
index 7b4fa495288..b8e46affc2a 100644
--- a/spec/config/object_store_settings_spec.rb
+++ b/spec/config/object_store_settings_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
require Rails.root.join('config', 'object_store_settings.rb')
-RSpec.describe ObjectStoreSettings, feature_category: :not_owned do
+RSpec.describe ObjectStoreSettings, feature_category: :shared do
describe '#parse!' do
- let(:settings) { Settingslogic.new(config) }
+ let(:settings) { GitlabSettings::Options.build(config) }
subject { described_class.new(settings).parse! }
@@ -68,7 +68,7 @@ RSpec.describe ObjectStoreSettings, feature_category: :not_owned do
expect(settings.artifacts['enabled']).to be true
expect(settings.artifacts['object_store']['enabled']).to be true
- expect(settings.artifacts['object_store']['connection']).to eq(connection)
+ expect(settings.artifacts['object_store']['connection'].to_hash).to eq(connection)
expect(settings.artifacts['object_store']['direct_upload']).to be true
expect(settings.artifacts['object_store']['proxy_download']).to be false
expect(settings.artifacts['object_store']['remote_directory']).to eq('artifacts')
@@ -78,7 +78,7 @@ RSpec.describe ObjectStoreSettings, feature_category: :not_owned do
expect(settings.lfs['enabled']).to be true
expect(settings.lfs['object_store']['enabled']).to be true
- expect(settings.lfs['object_store']['connection']).to eq(connection)
+ expect(settings.lfs['object_store']['connection'].to_hash).to eq(connection)
expect(settings.lfs['object_store']['direct_upload']).to be true
expect(settings.lfs['object_store']['proxy_download']).to be true
expect(settings.lfs['object_store']['remote_directory']).to eq('lfs-objects')
@@ -88,7 +88,7 @@ RSpec.describe ObjectStoreSettings, feature_category: :not_owned do
expect(settings.pages['enabled']).to be true
expect(settings.pages['object_store']['enabled']).to be true
- expect(settings.pages['object_store']['connection']).to eq(connection)
+ expect(settings.pages['object_store']['connection'].to_hash).to eq(connection)
expect(settings.pages['object_store']['remote_directory']).to eq('pages')
expect(settings.pages['object_store']['bucket_prefix']).to eq(nil)
expect(settings.pages['object_store']['consolidated_settings']).to be true
@@ -128,7 +128,7 @@ RSpec.describe ObjectStoreSettings, feature_category: :not_owned do
it 'populates artifacts CDN config' do
subject
- expect(settings.artifacts['object_store']['cdn']).to eq(cdn_config)
+ expect(settings.artifacts['object_store']['cdn'].to_hash).to eq(cdn_config)
end
end
@@ -163,7 +163,7 @@ RSpec.describe ObjectStoreSettings, feature_category: :not_owned do
it 'allows pages to define its own connection' do
expect { subject }.not_to raise_error
- expect(settings.pages['object_store']['connection']).to eq(pages_connection)
+ expect(settings.pages['object_store']['connection'].to_hash).to eq(pages_connection)
expect(settings.pages['object_store']['consolidated_settings']).to be_falsey
end
end
@@ -230,7 +230,7 @@ RSpec.describe ObjectStoreSettings, feature_category: :not_owned do
end
it 'respects original values' do
- original_settings = Settingslogic.new({
+ original_settings = GitlabSettings::Options.build({
'enabled' => true,
'remote_directory' => 'artifacts'
})
@@ -244,7 +244,7 @@ RSpec.describe ObjectStoreSettings, feature_category: :not_owned do
end
it 'supports bucket prefixes' do
- original_settings = Settingslogic.new({
+ original_settings = GitlabSettings::Options.build({
'enabled' => true,
'remote_directory' => 'gitlab/artifacts'
})
diff --git a/spec/config/settings_spec.rb b/spec/config/settings_spec.rb
index 0928f2b72ff..55e675d5107 100644
--- a/spec/config/settings_spec.rb
+++ b/spec/config/settings_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Settings, feature_category: :authentication_and_authorization do
+RSpec.describe Settings, feature_category: :system_access do
using RSpec::Parameterized::TableSyntax
describe 'omniauth' do
@@ -31,7 +31,7 @@ RSpec.describe Settings, feature_category: :authentication_and_authorization do
with_them do
before do
allow(Gitlab.config).to receive(:gitlab).and_return(
- Settingslogic.new({
+ GitlabSettings::Options.build({
'host' => host,
'https' => true,
'port' => port,
@@ -198,4 +198,18 @@ RSpec.describe Settings, feature_category: :authentication_and_authorization do
end
end
end
+
+ describe '.build_sidekiq_routing_rules' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:input_rules, :result) do
+ nil | [['*', 'default']]
+ [] | [['*', 'default']]
+ [['name=foobar', 'foobar']] | [['name=foobar', 'foobar']]
+ end
+
+ with_them do
+ it { expect(described_class.send(:build_sidekiq_routing_rules, input_rules)).to eq(result) }
+ end
+ end
end
diff --git a/spec/config/smime_signature_settings_spec.rb b/spec/config/smime_signature_settings_spec.rb
index 477ad4a74ed..53e70f1f2cc 100644
--- a/spec/config/smime_signature_settings_spec.rb
+++ b/spec/config/smime_signature_settings_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SmimeSignatureSettings, feature_category: :not_owned do
+RSpec.describe SmimeSignatureSettings, feature_category: :shared do
describe '.parse' do
let(:default_smime_key) { Rails.root.join('.gitlab_smime_key') }
let(:default_smime_cert) { Rails.root.join('.gitlab_smime_cert') }
@@ -19,7 +19,7 @@ RSpec.describe SmimeSignatureSettings, feature_category: :not_owned do
context 'when providing custom values' do
it 'sets correct default values to disabled' do
- custom_settings = Settingslogic.new({})
+ custom_settings = GitlabSettings::Options.build({})
parsed_settings = described_class.parse(custom_settings)
@@ -30,7 +30,7 @@ RSpec.describe SmimeSignatureSettings, feature_category: :not_owned do
end
it 'enables smime with default key and cert' do
- custom_settings = Settingslogic.new({
+ custom_settings = GitlabSettings::Options.build({
'enabled' => true
})
@@ -46,7 +46,7 @@ RSpec.describe SmimeSignatureSettings, feature_category: :not_owned do
custom_key = '/custom/key'
custom_cert = '/custom/cert'
custom_ca_certs = '/custom/ca_certs'
- custom_settings = Settingslogic.new({
+ custom_settings = GitlabSettings::Options.build({
'enabled' => true,
'key_file' => custom_key,
'cert_file' => custom_cert,
diff --git a/spec/contracts/provider/helpers/contract_source_helper.rb b/spec/contracts/provider/helpers/contract_source_helper.rb
index f59f228722d..e1891b316f3 100644
--- a/spec/contracts/provider/helpers/contract_source_helper.rb
+++ b/spec/contracts/provider/helpers/contract_source_helper.rb
@@ -2,7 +2,6 @@
module Provider
module ContractSourceHelper
- QA_PACT_BROKER_HOST = "http://localhost:9292/pacts"
PREFIX_PATHS = {
rake: {
ce: "../../contracts/project",
@@ -10,7 +9,7 @@ module Provider
},
spec: "../contracts/project"
}.freeze
- SUB_PATH_REGEX = %r{project/(?<file_path>.*?)_helper.rb}.freeze
+ SUB_PATH_REGEX = %r{project/(?<file_path>.*?)_helper.rb}
class << self
def contract_location(requester:, file_path:, edition: :ce)
@@ -26,7 +25,7 @@ module Provider
provider_url = "provider/#{construct_provider_url_path(file_path)}"
consumer_url = "consumer/#{construct_consumer_url_path(file_path)}"
- "#{QA_PACT_BROKER_HOST}/#{provider_url}/#{consumer_url}/latest"
+ "#{ENV['QA_PACT_BROKER_HOST']}/pacts/#{provider_url}/#{consumer_url}/latest"
end
def construct_provider_url_path(file_path)
diff --git a/spec/contracts/provider_specs/helpers/provider/contract_source_helper_spec.rb b/spec/contracts/provider_specs/helpers/provider/contract_source_helper_spec.rb
index 39537aa153d..18da71e0601 100644
--- a/spec/contracts/provider_specs/helpers/provider/contract_source_helper_spec.rb
+++ b/spec/contracts/provider_specs/helpers/provider/contract_source_helper_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require_relative '../../../provider/helpers/contract_source_helper'
-RSpec.describe Provider::ContractSourceHelper, feature_category: :not_owned do
+RSpec.describe Provider::ContractSourceHelper, feature_category: :shared do
let(:pact_helper_path) { 'pact_helpers/project/pipelines/new/post_create_pipeline_helper.rb' }
let(:split_pact_helper_path) { %w[pipelines new post_create_pipeline] }
let(:provider_url_path) { 'POST%20create%20pipeline' }
@@ -54,8 +54,12 @@ RSpec.describe Provider::ContractSourceHelper, feature_category: :not_owned do
end
describe '#pact_broker_url' do
+ before do
+ stub_env('QA_PACT_BROKER_HOST', 'http://localhost')
+ end
+
it 'returns the full url to the contract that the provider test is verifying' do
- contract_url_path = "http://localhost:9292/pacts/provider/" \
+ contract_url_path = "http://localhost/pacts/provider/" \
"#{provider_url_path}/consumer/#{consumer_url_path}/latest"
expect(subject.pact_broker_url(split_pact_helper_path)).to eq(contract_url_path)
diff --git a/spec/contracts/publish-contracts.sh b/spec/contracts/publish-contracts.sh
index 8b9d4b6ecc6..b50ba9afae8 100644
--- a/spec/contracts/publish-contracts.sh
+++ b/spec/contracts/publish-contracts.sh
@@ -1,6 +1,5 @@
LATEST_SHA=$(git rev-parse HEAD)
GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
-BROKER_BASE_URL="http://localhost:9292"
cd "${0%/*}" || exit 1
@@ -18,7 +17,7 @@ function publish_contract () {
for contract in $CONTRACTS
do
printf "\e[32mPublishing %s...\033[0m\n" "$contract"
- pact-broker publish "$contract" --consumer-app-version "$LATEST_SHA" --branch "$GIT_BRANCH" --broker-base-url "$BROKER_BASE_URL" --output json
+ pact-broker publish "$contract" --consumer-app-version "$LATEST_SHA" --branch "$GIT_BRANCH" --broker-base-url "$QA_PACT_BROKER_HOST" --output json
done
if [ ${ERROR} = 1 ]; then
diff --git a/spec/controllers/admin/application_settings_controller_spec.rb b/spec/controllers/admin/application_settings_controller_spec.rb
index 32ac0f8dc07..a721722a5c3 100644
--- a/spec/controllers/admin/application_settings_controller_spec.rb
+++ b/spec/controllers/admin/application_settings_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Admin::ApplicationSettingsController, :do_not_mock_admin_mode_setting do
+RSpec.describe Admin::ApplicationSettingsController, :do_not_mock_admin_mode_setting, feature_category: :shared do
include StubENV
include UsageDataHelpers
@@ -204,8 +204,29 @@ RSpec.describe Admin::ApplicationSettingsController, :do_not_mock_admin_mode_set
expect(ApplicationSetting.current.valid_runner_registrars).to eq(['project'])
end
+ it 'updates GitLab for Slack app settings' do
+ settings = {
+ slack_app_enabled: true,
+ slack_app_id: 'slack_app_id',
+ slack_app_secret: 'slack_app_secret',
+ slack_app_signing_secret: 'slack_app_signing_secret',
+ slack_app_verification_token: 'slack_app_verification_token'
+ }
+
+ put :update, params: { application_setting: settings }
+
+ expect(response).to redirect_to(general_admin_application_settings_path)
+ expect(ApplicationSetting.current).to have_attributes(
+ slack_app_enabled: true,
+ slack_app_id: 'slack_app_id',
+ slack_app_secret: 'slack_app_secret',
+ slack_app_signing_secret: 'slack_app_signing_secret',
+ slack_app_verification_token: 'slack_app_verification_token'
+ )
+ end
+
context 'boolean attributes' do
- shared_examples_for 'updates booolean attribute' do |attribute|
+ shared_examples_for 'updates boolean attribute' do |attribute|
specify do
existing_value = ApplicationSetting.current.public_send(attribute)
new_value = !existing_value
@@ -217,10 +238,11 @@ RSpec.describe Admin::ApplicationSettingsController, :do_not_mock_admin_mode_set
end
end
- it_behaves_like 'updates booolean attribute', :user_defaults_to_private_profile
- it_behaves_like 'updates booolean attribute', :can_create_group
- it_behaves_like 'updates booolean attribute', :admin_mode
- it_behaves_like 'updates booolean attribute', :require_admin_approval_after_user_signup
+ it_behaves_like 'updates boolean attribute', :user_defaults_to_private_profile
+ it_behaves_like 'updates boolean attribute', :can_create_group
+ it_behaves_like 'updates boolean attribute', :admin_mode
+ it_behaves_like 'updates boolean attribute', :require_admin_approval_after_user_signup
+ it_behaves_like 'updates boolean attribute', :remember_me_enabled
end
context "personal access token prefix settings" do
@@ -398,9 +420,20 @@ RSpec.describe Admin::ApplicationSettingsController, :do_not_mock_admin_mode_set
expect(application_settings.reload.invitation_flow_enforcement).to eq(true)
end
end
+
+ context 'maximum includes' do
+ let(:application_settings) { ApplicationSetting.current }
+
+ it 'updates ci_max_includes setting' do
+ put :update, params: { application_setting: { ci_max_includes: 200 } }
+
+ expect(response).to redirect_to(general_admin_application_settings_path)
+ expect(application_settings.reload.ci_max_includes).to eq(200)
+ end
+ end
end
- describe 'PUT #reset_registration_token', feature_category: :credential_management do
+ describe 'PUT #reset_registration_token', feature_category: :user_management do
before do
sign_in(admin)
end
diff --git a/spec/controllers/admin/applications_controller_spec.rb b/spec/controllers/admin/applications_controller_spec.rb
index bf7707f177c..1feda0ed36f 100644
--- a/spec/controllers/admin/applications_controller_spec.rb
+++ b/spec/controllers/admin/applications_controller_spec.rb
@@ -38,44 +38,49 @@ RSpec.describe Admin::ApplicationsController do
end
end
- describe 'POST #create' do
- context 'with hash_oauth_secrets flag off' do
- before do
- stub_feature_flags(hash_oauth_secrets: false)
- end
+ describe 'PUT #renew' do
+ let(:oauth_params) do
+ {
+ id: application.id
+ }
+ end
- it 'creates the application' do
- create_params = attributes_for(:application, trusted: true, confidential: false, scopes: ['api'])
+ subject { put :renew, params: oauth_params }
- expect do
- post :create, params: { doorkeeper_application: create_params }
- end.to change { Doorkeeper::Application.count }.by(1)
+ it { is_expected.to have_gitlab_http_status(:ok) }
+ it { expect { subject }.to change { application.reload.secret } }
- application = Doorkeeper::Application.last
+ it 'returns the secret in json format' do
+ subject
- expect(response).to redirect_to(admin_application_path(application))
- expect(application).to have_attributes(create_params.except(:uid, :owner_type))
- end
+ expect(json_response['secret']).not_to be_nil
end
- context 'with hash_oauth_secrets flag on' do
+ context 'when renew fails' do
before do
- stub_feature_flags(hash_oauth_secrets: true)
+ allow_next_found_instance_of(Doorkeeper::Application) do |application|
+ allow(application).to receive(:save).and_return(false)
+ end
end
- it 'creates the application' do
- create_params = attributes_for(:application, trusted: true, confidential: false, scopes: ['api'])
+ it { expect { subject }.not_to change { application.reload.secret } }
+ it { is_expected.to have_gitlab_http_status(:unprocessable_entity) }
+ end
+ end
- expect do
- post :create, params: { doorkeeper_application: create_params }
- end.to change { Doorkeeper::Application.count }.by(1)
+ describe 'POST #create' do
+ it 'creates the application' do
+ create_params = attributes_for(:application, trusted: true, confidential: false, scopes: ['api'])
- application = Doorkeeper::Application.last
+ expect do
+ post :create, params: { doorkeeper_application: create_params }
+ end.to change { Doorkeeper::Application.count }.by(1)
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template :show
- expect(application).to have_attributes(create_params.except(:uid, :owner_type))
- end
+ application = Doorkeeper::Application.last
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template :show
+ expect(application).to have_attributes(create_params.except(:uid, :owner_type))
end
it 'renders the application form on errors' do
@@ -88,43 +93,18 @@ RSpec.describe Admin::ApplicationsController do
end
context 'when the params are for a confidential application' do
- context 'with hash_oauth_secrets flag off' do
- before do
- stub_feature_flags(hash_oauth_secrets: false)
- end
-
- it 'creates a confidential application' do
- create_params = attributes_for(:application, confidential: true, scopes: ['read_user'])
-
- expect do
- post :create, params: { doorkeeper_application: create_params }
- end.to change { Doorkeeper::Application.count }.by(1)
-
- application = Doorkeeper::Application.last
-
- expect(response).to redirect_to(admin_application_path(application))
- expect(application).to have_attributes(create_params.except(:uid, :owner_type))
- end
- end
+ it 'creates a confidential application' do
+ create_params = attributes_for(:application, confidential: true, scopes: ['read_user'])
- context 'with hash_oauth_secrets flag on' do
- before do
- stub_feature_flags(hash_oauth_secrets: true)
- end
-
- it 'creates a confidential application' do
- create_params = attributes_for(:application, confidential: true, scopes: ['read_user'])
-
- expect do
- post :create, params: { doorkeeper_application: create_params }
- end.to change { Doorkeeper::Application.count }.by(1)
+ expect do
+ post :create, params: { doorkeeper_application: create_params }
+ end.to change { Doorkeeper::Application.count }.by(1)
- application = Doorkeeper::Application.last
+ application = Doorkeeper::Application.last
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template :show
- expect(application).to have_attributes(create_params.except(:uid, :owner_type))
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template :show
+ expect(application).to have_attributes(create_params.except(:uid, :owner_type))
end
end
diff --git a/spec/controllers/admin/clusters_controller_spec.rb b/spec/controllers/admin/clusters_controller_spec.rb
index 8e62aeed7d0..d04cd20f4e6 100644
--- a/spec/controllers/admin/clusters_controller_spec.rb
+++ b/spec/controllers/admin/clusters_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Admin::ClustersController, feature_category: :kubernetes_management do
+RSpec.describe Admin::ClustersController, feature_category: :deployment_management do
include AccessMatchersForController
include GoogleApi::CloudPlatformHelpers
@@ -259,14 +259,6 @@ RSpec.describe Admin::ClustersController, feature_category: :kubernetes_manageme
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('cluster_status')
end
-
- it 'invokes schedule_status_update on each application' do
- expect_next_instance_of(Clusters::Applications::Ingress) do |instance|
- expect(instance).to receive(:schedule_status_update)
- end
-
- get_cluster_status
- end
end
describe 'security' do
@@ -292,20 +284,37 @@ RSpec.describe Admin::ClustersController, feature_category: :kubernetes_manageme
end
describe 'functionality' do
- render_views
+ context 'when remove_monitor_metrics FF is disabled' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
- it 'responds successfully' do
- get_show
+ render_views
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:cluster)).to eq(cluster)
+ it 'responds successfully' do
+ get_show
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:cluster)).to eq(cluster)
+ end
+
+ it 'renders integration tab view' do
+ get_show(tab: 'integrations')
+
+ expect(response).to render_template('clusters/clusters/_integrations')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
- it 'renders integration tab view' do
- get_show(tab: 'integrations')
+ context 'when remove_monitor_metrics FF is enabled' do
+ render_views
- expect(response).to render_template('clusters/clusters/_integrations')
- expect(response).to have_gitlab_http_status(:ok)
+ it 'renders details tab view' do
+ get_show(tab: 'integrations')
+
+ expect(response).to render_template('clusters/clusters/_details')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
end
diff --git a/spec/controllers/admin/cohorts_controller_spec.rb b/spec/controllers/admin/cohorts_controller_spec.rb
index 50626a5da91..26f6540258e 100644
--- a/spec/controllers/admin/cohorts_controller_spec.rb
+++ b/spec/controllers/admin/cohorts_controller_spec.rb
@@ -17,7 +17,6 @@ RSpec.describe Admin::CohortsController do
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
subject { get :index }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'perform_analytics_usage_action' }
let(:label) { 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly' }
diff --git a/spec/controllers/admin/dev_ops_report_controller_spec.rb b/spec/controllers/admin/dev_ops_report_controller_spec.rb
index 52a46b5e99a..d8166380760 100644
--- a/spec/controllers/admin/dev_ops_report_controller_spec.rb
+++ b/spec/controllers/admin/dev_ops_report_controller_spec.rb
@@ -32,7 +32,6 @@ RSpec.describe Admin::DevOpsReportController do
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
subject { get :show, format: :html }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'perform_analytics_usage_action' }
let(:label) { 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly' }
diff --git a/spec/controllers/admin/instance_review_controller_spec.rb b/spec/controllers/admin/instance_review_controller_spec.rb
index 6eab135b3a6..f0225a71e00 100644
--- a/spec/controllers/admin/instance_review_controller_spec.rb
+++ b/spec/controllers/admin/instance_review_controller_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Admin::InstanceReviewController, feature_category: :service_ping
include UsageDataHelpers
let(:admin) { create(:admin) }
- let(:subscriptions_instance_review_url) { Gitlab::SubscriptionPortal.subscriptions_instance_review_url }
+ let(:subscriptions_instance_review_url) { ::Gitlab::Routing.url_helpers.subscription_portal_instance_review_url }
before do
sign_in(admin)
diff --git a/spec/controllers/admin/integrations_controller_spec.rb b/spec/controllers/admin/integrations_controller_spec.rb
index e75f27589d7..9e2a2900b33 100644
--- a/spec/controllers/admin/integrations_controller_spec.rb
+++ b/spec/controllers/admin/integrations_controller_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Admin::IntegrationsController do
let(:admin) { create(:admin) }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
sign_in(admin)
end
@@ -29,11 +30,7 @@ RSpec.describe Admin::IntegrationsController do
end
end
- context 'when GitLab.com' do
- before do
- allow(::Gitlab).to receive(:com?) { true }
- end
-
+ context 'when GitLab.com', :saas do
it 'returns 404' do
get :edit, params: { id: Integration.available_integration_names.sample }
diff --git a/spec/controllers/admin/runner_projects_controller_spec.rb b/spec/controllers/admin/runner_projects_controller_spec.rb
index 38cc2d171ac..06a73984ac0 100644
--- a/spec/controllers/admin/runner_projects_controller_spec.rb
+++ b/spec/controllers/admin/runner_projects_controller_spec.rb
@@ -21,30 +21,32 @@ RSpec.describe Admin::RunnerProjectsController, feature_category: :runner_fleet
}
end
- context 'assigning runner to same project' do
- let(:project_runner) { create(:ci_runner, :project, projects: [project]) }
+ context 'when assigning to another project' do
+ let(:project_runner) { create(:ci_runner, :project, projects: [source_project]) }
+ let(:source_project) { create(:project) }
it 'redirects to the admin runner edit page' do
send_create
+ expect(flash[:success]).to be_present
expect(response).to have_gitlab_http_status(:redirect)
expect(response).to redirect_to edit_admin_runner_url(project_runner)
end
end
- context 'assigning runner to another project' do
- let(:project_runner) { create(:ci_runner, :project, projects: [source_project]) }
- let(:source_project) { create(:project) }
+ context 'when assigning to same project' do
+ let(:project_runner) { create(:ci_runner, :project, projects: [project]) }
it 'redirects to the admin runner edit page' do
send_create
+ expect(flash[:alert]).to be_present
expect(response).to have_gitlab_http_status(:redirect)
expect(response).to redirect_to edit_admin_runner_url(project_runner)
end
end
- context 'for unknown project' do
+ context 'when assigning to an unknown project' do
let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project]) }
let(:project_id) { 0 }
@@ -70,7 +72,7 @@ RSpec.describe Admin::RunnerProjectsController, feature_category: :runner_fleet
}
end
- context 'unassigning runner from project' do
+ context 'when unassigning runner from project' do
let(:runner_project_id) { project_runner.runner_projects.last.id }
it 'redirects to the admin runner edit page' do
@@ -81,7 +83,7 @@ RSpec.describe Admin::RunnerProjectsController, feature_category: :runner_fleet
end
end
- context 'for unknown project runner relationship' do
+ context 'when unassigning from unknown project' do
let(:runner_project_id) { 0 }
it 'shows 404 for unknown project runner relationship' do
diff --git a/spec/controllers/admin/runners_controller_spec.rb b/spec/controllers/admin/runners_controller_spec.rb
index a39a1f38a11..b1a2d90589a 100644
--- a/spec/controllers/admin/runners_controller_spec.rb
+++ b/spec/controllers/admin/runners_controller_spec.rb
@@ -35,26 +35,59 @@ RSpec.describe Admin::RunnersController, feature_category: :runner_fleet do
end
describe '#new' do
- context 'when create_runner_workflow is enabled' do
+ it 'renders a :new template' do
+ get :new
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:new)
+ end
+
+ context 'when create_runner_workflow_for_admin is disabled' do
before do
- stub_feature_flags(create_runner_workflow: true)
+ stub_feature_flags(create_runner_workflow_for_admin: false)
end
- it 'renders a :new template' do
+ it 'returns :not_found' do
get :new
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe '#register' do
+ subject(:register) { get :register, params: { id: new_runner.id } }
+
+ context 'when runner can be registered after creation' do
+ let_it_be(:new_runner) { create(:ci_runner, registration_type: :authenticated_user) }
+
+ it 'renders a :register template' do
+ register
+
expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template(:new)
+ expect(response).to render_template(:register)
end
end
- context 'when create_runner_workflow is disabled' do
+ context 'when runner cannot be registered after creation' do
+ let_it_be(:new_runner) { runner }
+
+ it 'returns :not_found' do
+ register
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when create_runner_workflow_for_admin is disabled' do
+ let_it_be(:new_runner) { create(:ci_runner, registration_type: :authenticated_user) }
+
before do
- stub_feature_flags(create_runner_workflow: false)
+ stub_feature_flags(create_runner_workflow_for_admin: false)
end
it 'returns :not_found' do
- get :new
+ register
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/controllers/admin/sessions_controller_spec.rb b/spec/controllers/admin/sessions_controller_spec.rb
index 5fa7a7f278d..07088eed6d4 100644
--- a/spec/controllers/admin/sessions_controller_spec.rb
+++ b/spec/controllers/admin/sessions_controller_spec.rb
@@ -220,7 +220,9 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
end
end
- shared_examples 'when using two-factor authentication via hardware device' do
+ context 'when using two-factor authentication via WebAuthn' do
+ let(:user) { create(:admin, :two_factor_via_webauthn) }
+
def authenticate_2fa(user_params)
post(:create, params: { user: user_params }, session: { otp_user_id: user.id })
end
@@ -237,10 +239,6 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
end
it 'can login with valid auth' do
- # we can stub both without an differentiation between webauthn / u2f
- # as these not interfere with each other und this saves us passing aroud
- # parameters
- allow(U2fRegistration).to receive(:authenticate).and_return(true)
allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).and_return(true)
expect(controller.current_user_mode.admin_mode?).to be(false)
@@ -255,7 +253,6 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
end
it 'cannot login with invalid auth' do
- allow(U2fRegistration).to receive(:authenticate).and_return(false)
allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).and_return(false)
expect(controller.current_user_mode.admin_mode?).to be(false)
@@ -267,22 +264,6 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
expect(controller.current_user_mode.admin_mode?).to be(false)
end
end
-
- context 'when using two-factor authentication via U2F' do
- it_behaves_like 'when using two-factor authentication via hardware device' do
- let(:user) { create(:admin, :two_factor_via_u2f) }
-
- before do
- stub_feature_flags(webauthn: false)
- end
- end
- end
-
- context 'when using two-factor authentication via WebAuthn' do
- it_behaves_like 'when using two-factor authentication via hardware device' do
- let(:user) { create(:admin, :two_factor_via_webauthn) }
- end
- end
end
end
diff --git a/spec/controllers/admin/spam_logs_controller_spec.rb b/spec/controllers/admin/spam_logs_controller_spec.rb
index 51f7ecdece6..b39c3bd009b 100644
--- a/spec/controllers/admin/spam_logs_controller_spec.rb
+++ b/spec/controllers/admin/spam_logs_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Admin::SpamLogsController do
+RSpec.describe Admin::SpamLogsController, feature_category: :instance_resiliency do
let(:admin) { create(:admin) }
let(:user) { create(:user) }
let!(:first_spam) { create(:spam_log, user: user) }
@@ -13,9 +13,10 @@ RSpec.describe Admin::SpamLogsController do
end
describe '#index' do
- it 'lists all spam logs' do
+ it 'lists paginated spam logs' do
get :index
+ expect(assigns(:spam_logs)).to be_kind_of(Kaminari::PaginatableWithoutCount)
expect(response).to have_gitlab_http_status(:ok)
end
end
@@ -33,10 +34,7 @@ RSpec.describe Admin::SpamLogsController do
end.not_to change { SpamLog.count }
expect(response).to have_gitlab_http_status(:found)
- expect(
- Users::GhostUserMigration.where(user: user,
- initiator_user: admin)
- ).to be_exists
+ expect(Users::GhostUserMigration.where(user: user, initiator_user: admin)).to be_exists
expect(flash[:notice]).to eq("User #{user.username} was successfully removed.")
end
end
diff --git a/spec/controllers/admin/usage_trends_controller_spec.rb b/spec/controllers/admin/usage_trends_controller_spec.rb
index 87cf8988b4e..7b801d53f14 100644
--- a/spec/controllers/admin/usage_trends_controller_spec.rb
+++ b/spec/controllers/admin/usage_trends_controller_spec.rb
@@ -17,7 +17,6 @@ RSpec.describe Admin::UsageTrendsController do
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
subject { get :index }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'perform_analytics_usage_action' }
let(:label) { 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly' }
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 63e68118066..9b00451de30 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -185,22 +185,14 @@ RSpec.describe Admin::UsersController do
delete :destroy, params: { id: user.username }, format: :json
expect(response).to have_gitlab_http_status(:ok)
- expect(
- Users::GhostUserMigration.where(user: user,
- initiator_user: admin,
- hard_delete: false)
- ).to be_exists
+ expect(Users::GhostUserMigration.where(user: user, initiator_user: admin, hard_delete: false)).to be_exists
end
it 'initiates user removal and passes hard delete option' do
delete :destroy, params: { id: user.username, hard_delete: true }, format: :json
expect(response).to have_gitlab_http_status(:ok)
- expect(
- Users::GhostUserMigration.where(user: user,
- initiator_user: admin,
- hard_delete: true)
- ).to be_exists
+ expect(Users::GhostUserMigration.where(user: user, initiator_user: admin, hard_delete: true)).to be_exists
end
context 'prerequisites for account deletion' do
@@ -231,11 +223,7 @@ RSpec.describe Admin::UsersController do
expect(response).to redirect_to(admin_users_path)
expect(flash[:notice]).to eq(_('The user is being deleted.'))
- expect(
- Users::GhostUserMigration.where(user: user,
- initiator_user: admin,
- hard_delete: true)
- ).to be_exists
+ expect(Users::GhostUserMigration.where(user: user, initiator_user: admin, hard_delete: true)).to be_exists
end
end
end
@@ -252,10 +240,7 @@ RSpec.describe Admin::UsersController do
it 'initiates user removal', :sidekiq_inline do
subject
- expect(
- Users::GhostUserMigration.where(user: user,
- initiator_user: admin)
- ).to be_exists
+ expect(Users::GhostUserMigration.where(user: user, initiator_user: admin)).to be_exists
end
it 'displays the rejection message' do
@@ -403,7 +388,7 @@ RSpec.describe Admin::UsersController do
put :deactivate, params: { id: user.username }
user.reload
expect(user.deactivated?).to be_falsey
- expect(flash[:notice]).to eq("The user you are trying to deactivate has been active in the past #{Gitlab::CurrentSettings.deactivate_dormant_users_period} days and cannot be deactivated")
+ expect(flash[:alert]).to eq("The user you are trying to deactivate has been active in the past #{Gitlab::CurrentSettings.deactivate_dormant_users_period} days and cannot be deactivated")
end
end
end
@@ -425,7 +410,7 @@ RSpec.describe Admin::UsersController do
put :deactivate, params: { id: user.username }
user.reload
expect(user.deactivated?).to be_falsey
- expect(flash[:notice]).to eq('Error occurred. A blocked user cannot be deactivated')
+ expect(flash[:alert]).to eq('Error occurred. A blocked user cannot be deactivated')
end
end
@@ -436,7 +421,7 @@ RSpec.describe Admin::UsersController do
put :deactivate, params: { id: internal_user.username }
expect(internal_user.reload.deactivated?).to be_falsey
- expect(flash[:notice]).to eq('Internal users cannot be deactivated')
+ expect(flash[:alert]).to eq('Internal users cannot be deactivated')
end
end
end
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index f1adb9020fa..ce76be9f509 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe ApplicationController do
+RSpec.describe ApplicationController, feature_category: :shared do
include TermsHelper
let(:user) { create(:user) }
@@ -736,23 +736,11 @@ RSpec.describe ApplicationController do
end
end
- context 'user not logged in' do
- it 'sets the default headers' do
- get :index
-
- expect(response.headers['Cache-Control']).to be_nil
- expect(response.headers['Pragma']).to be_nil
- end
- end
-
- context 'user logged in' do
- it 'sets the default headers' do
- sign_in(user)
-
- get :index
+ it 'sets the default headers' do
+ get :index
- expect(response.headers['Pragma']).to eq 'no-cache'
- end
+ expect(response.headers['Cache-Control']).to be_nil
+ expect(response.headers['Pragma']).to be_nil
end
end
@@ -779,7 +767,6 @@ RSpec.describe ApplicationController do
subject
expect(response.headers['Cache-Control']).to eq 'private, no-store'
- expect(response.headers['Pragma']).to eq 'no-cache'
expect(response.headers['Expires']).to eq 'Fri, 01 Jan 1990 00:00:00 GMT'
end
@@ -905,12 +892,12 @@ RSpec.describe ApplicationController do
end
end
- describe 'rescue_from Gitlab::Auth::IpBlacklisted' do
+ describe 'rescue_from Gitlab::Auth::IpBlocked' do
controller(described_class) do
skip_before_action :authenticate_user!
def index
- raise Gitlab::Auth::IpBlacklisted
+ raise Gitlab::Auth::IpBlocked
end
end
@@ -1130,4 +1117,28 @@ RSpec.describe ApplicationController do
end
end
end
+
+ context 'when Gitlab::Git::ResourceExhaustedError exception is raised' do
+ before do
+ sign_in user
+ end
+
+ controller(described_class) do
+ def index
+ raise Gitlab::Git::ResourceExhaustedError.new(
+ "Upstream Gitaly has been exhausted: maximum time in concurrency queue reached. Try again later", 50
+ )
+ end
+ end
+
+ it 'returns a plaintext error response with 429 status' do
+ get :index
+
+ expect(response).to have_gitlab_http_status(:too_many_requests)
+ expect(response.body).to include(
+ "Upstream Gitaly has been exhausted: maximum time in concurrency queue reached. Try again later"
+ )
+ expect(response.headers['Retry-After']).to eq(50)
+ end
+ end
end
diff --git a/spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb b/spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb
index 246119a8118..28a0ce437de 100644
--- a/spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb
+++ b/spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
+
require 'spec_helper'
RSpec.describe Analytics::CycleAnalytics::ValueStreamActions, type: :controller,
-feature_category: :planning_analytics do
+ feature_category: :team_planning do
subject(:controller) do
Class.new(ApplicationController) do
include Analytics::CycleAnalytics::ValueStreamActions
diff --git a/spec/controllers/concerns/confirm_email_warning_spec.rb b/spec/controllers/concerns/confirm_email_warning_spec.rb
index b8a4b94aa66..7cfbd86cdcb 100644
--- a/spec/controllers/concerns/confirm_email_warning_spec.rb
+++ b/spec/controllers/concerns/confirm_email_warning_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe ConfirmEmailWarning, feature_category: :system_access do
before do
- stub_feature_flags(soft_email_confirmation: true)
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
end
controller(ApplicationController) do
diff --git a/spec/controllers/concerns/content_security_policy_patch_spec.rb b/spec/controllers/concerns/content_security_policy_patch_spec.rb
index 6322950977c..9b4ddb35993 100644
--- a/spec/controllers/concerns/content_security_policy_patch_spec.rb
+++ b/spec/controllers/concerns/content_security_policy_patch_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
# Based on https://github.com/rails/rails/pull/45115/files#diff-35ef6d1bd8b8d3b037ec819a704cd78db55db916a57abfc2859882826fc679b6
-RSpec.describe ContentSecurityPolicyPatch, feature_category: :not_owned do
+RSpec.describe ContentSecurityPolicyPatch, feature_category: :shared do
include Rack::Test::Methods
let(:routes) do
diff --git a/spec/controllers/concerns/continue_params_spec.rb b/spec/controllers/concerns/continue_params_spec.rb
index ba600b8156a..9ac7087430e 100644
--- a/spec/controllers/concerns/continue_params_spec.rb
+++ b/spec/controllers/concerns/continue_params_spec.rb
@@ -31,10 +31,7 @@ RSpec.describe ContinueParams do
it 'cleans up any params that are not allowed' do
allow(controller).to receive(:params) do
- strong_continue_params(to: '/hello',
- notice: 'world',
- notice_now: '!',
- something: 'else')
+ strong_continue_params(to: '/hello', notice: 'world', notice_now: '!', something: 'else')
end
expect(controller.continue_params.keys).to contain_exactly(*%w(to notice notice_now))
diff --git a/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb b/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb
index a58b83dc42c..fc8b1efd226 100644
--- a/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb
+++ b/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb
@@ -25,9 +25,7 @@ RSpec.describe ControllerWithCrossProjectAccessCheck do
# `described_class` is not available in this context
include ControllerWithCrossProjectAccessCheck
- requires_cross_project_access :index, show: false,
- unless: -> { unless_condition },
- if: -> { if_condition }
+ requires_cross_project_access :index, show: false, unless: -> { unless_condition }, if: -> { if_condition }
def index
head :ok
@@ -86,9 +84,10 @@ RSpec.describe ControllerWithCrossProjectAccessCheck do
requires_cross_project_access
- skip_cross_project_access_check index: true, show: false,
- unless: -> { unless_condition },
- if: -> { if_condition }
+ skip_cross_project_access_check index: true,
+ show: false,
+ unless: -> { unless_condition },
+ if: -> { if_condition }
def index
head :ok
diff --git a/spec/controllers/concerns/kas_cookie_spec.rb b/spec/controllers/concerns/kas_cookie_spec.rb
new file mode 100644
index 00000000000..d80df106cfd
--- /dev/null
+++ b/spec/controllers/concerns/kas_cookie_spec.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe KasCookie, feature_category: :deployment_management do
+ describe '#set_kas_cookie' do
+ controller(ApplicationController) do
+ include KasCookie
+
+ def index
+ set_kas_cookie
+
+ render json: {}, status: :ok
+ end
+ end
+
+ before do
+ allow(::Gitlab::Kas).to receive(:enabled?).and_return(true)
+ end
+
+ subject(:kas_cookie) do
+ get :index
+
+ request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY]
+ end
+
+ context 'when user is signed out' do
+ it { is_expected.to be_blank }
+ end
+
+ context 'when user is signed in' do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'sets the KAS cookie', :aggregate_failures do
+ allow(::Gitlab::Kas::UserAccess).to receive(:cookie_data).and_return('foobar')
+
+ expect(kas_cookie).to be_present
+ expect(kas_cookie).to eq('foobar')
+ expect(::Gitlab::Kas::UserAccess).to have_received(:cookie_data)
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(kas_user_access: false)
+ end
+
+ it { is_expected.to be_blank }
+ end
+ end
+ end
+
+ describe '#content_security_policy' do
+ let_it_be(:user) { create(:user) }
+
+ controller(ApplicationController) do
+ include KasCookie
+
+ def index
+ render json: {}, status: :ok
+ end
+ end
+
+ before do
+ stub_config_setting(host: 'gitlab.example.com')
+ sign_in(user)
+ allow(::Gitlab::Kas).to receive(:enabled?).and_return(true)
+ allow(::Gitlab::Kas).to receive(:tunnel_url).and_return(kas_tunnel_url)
+ end
+
+ subject(:kas_csp_connect_src) do
+ get :index
+
+ request.env['action_dispatch.content_security_policy'].directives['connect-src']
+ end
+
+ context "when feature flag is disabled" do
+ let_it_be(:kas_tunnel_url) { 'ws://gitlab.example.com/-/k8s-proxy/' }
+
+ before do
+ stub_feature_flags(kas_user_access: false)
+ end
+
+ it 'does not add KAS url to connect-src directives' do
+ expect(kas_csp_connect_src).not_to include(::Gitlab::Kas.tunnel_url)
+ end
+ end
+
+ context 'when feature flag is enabled' do
+ before do
+ stub_feature_flags(kas_user_access: true)
+ end
+
+ context 'when KAS is on same domain as rails' do
+ let_it_be(:kas_tunnel_url) { 'ws://gitlab.example.com/-/k8s-proxy/' }
+
+ it 'does not add KAS url to CSP connect-src directive' do
+ expect(kas_csp_connect_src).not_to include(::Gitlab::Kas.tunnel_url)
+ end
+ end
+
+ context 'when KAS is on subdomain' do
+ let_it_be(:kas_tunnel_url) { 'ws://kas.gitlab.example.com/k8s-proxy/' }
+
+ it 'adds KAS url to CSP connect-src directive' do
+ expect(kas_csp_connect_src).to include(::Gitlab::Kas.tunnel_url)
+ end
+ end
+
+ context 'when KAS tunnel url is configured without trailing slash' do
+ let_it_be(:kas_tunnel_url) { 'ws://kas.gitlab.example.com/k8s-proxy' }
+
+ it 'adds KAS url to CSP connect-src directive with trailing slash' do
+ expect(kas_csp_connect_src).to include("#{::Gitlab::Kas.tunnel_url}/")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/concerns/metrics_dashboard_spec.rb b/spec/controllers/concerns/metrics_dashboard_spec.rb
index 83546403ce5..d68a9d70ec6 100644
--- a/spec/controllers/concerns/metrics_dashboard_spec.rb
+++ b/spec/controllers/concerns/metrics_dashboard_spec.rb
@@ -113,7 +113,7 @@ RSpec.describe MetricsDashboard do
it 'includes project_blob_path only for project dashboards' do
expect(system_dashboard['project_blob_path']).to be_nil
- expect(project_dashboard['project_blob_path']).to eq("/#{project.namespace.path}/#{project.name}/-/blob/master/.gitlab/dashboards/test.yml")
+ expect(project_dashboard['project_blob_path']).to eq("/#{project.namespace.path}/#{project.path}/-/blob/master/.gitlab/dashboards/test.yml")
end
it 'allows editing only for project dashboards' do
diff --git a/spec/controllers/concerns/product_analytics_tracking_spec.rb b/spec/controllers/concerns/product_analytics_tracking_spec.rb
index 12b4065b89c..65c2c77c027 100644
--- a/spec/controllers/concerns/product_analytics_tracking_spec.rb
+++ b/spec/controllers/concerns/product_analytics_tracking_spec.rb
@@ -8,7 +8,11 @@ RSpec.describe ProductAnalyticsTracking, :snowplow, feature_category: :product_a
let(:user) { create(:user) }
let(:event_name) { 'an_event' }
+ let(:event_action) { 'an_action' }
+ let(:event_label) { 'a_label' }
+
let!(:group) { create(:group) }
+ let_it_be(:project) { create(:project) }
before do
allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
@@ -19,8 +23,15 @@ RSpec.describe ProductAnalyticsTracking, :snowplow, feature_category: :product_a
include ProductAnalyticsTracking
skip_before_action :authenticate_user!, only: :show
- track_event(:index, :show, name: 'an_event', destinations: [:redis_hll, :snowplow],
- conditions: [:custom_condition_one?, :custom_condition_two?]) { |controller| controller.get_custom_id }
+ track_event(
+ :index,
+ :show,
+ name: 'an_event',
+ action: 'an_action',
+ label: 'a_label',
+ destinations: [:redis_hll, :snowplow],
+ conditions: [:custom_condition_one?, :custom_condition_two?]
+ ) { |controller| controller.get_custom_id }
def index
render html: 'index'
@@ -44,6 +55,10 @@ RSpec.describe ProductAnalyticsTracking, :snowplow, feature_category: :product_a
Group.first
end
+ def tracking_project_source
+ Project.first
+ end
+
def custom_condition_one?
true
end
@@ -64,7 +79,10 @@ RSpec.describe ProductAnalyticsTracking, :snowplow, feature_category: :product_a
expect_snowplow_event(
category: anything,
- action: event_name,
+ action: event_action,
+ property: event_name,
+ label: event_label,
+ project: project,
namespace: group,
user: user,
context: [context]
@@ -89,20 +107,6 @@ RSpec.describe ProductAnalyticsTracking, :snowplow, feature_category: :product_a
expect_snowplow_tracking(user)
end
- context 'when FF is disabled' do
- before do
- stub_const("#{described_class}::MIGRATED_EVENTS", [])
- allow(Feature).to receive(:enabled?).and_call_original
- allow(Feature).to receive(:enabled?).with('route_hll_to_snowplow', anything).and_return(false)
- end
-
- it 'doesnt track snowplow event' do
- get :index
-
- expect_no_snowplow_event
- end
- end
-
it 'tracks the event if DNT is not enabled' do
stub_do_not_track('0')
diff --git a/spec/controllers/concerns/redis_tracking_spec.rb b/spec/controllers/concerns/redis_tracking_spec.rb
deleted file mode 100644
index 0ad8fa79e5e..00000000000
--- a/spec/controllers/concerns/redis_tracking_spec.rb
+++ /dev/null
@@ -1,135 +0,0 @@
-# frozen_string_literal: true
-
-require "spec_helper"
-
-RSpec.describe RedisTracking do
- include TrackingHelpers
-
- let(:user) { create(:user) }
-
- controller(ApplicationController) do
- include RedisTracking
-
- skip_before_action :authenticate_user!, only: :show
- track_redis_hll_event(:index, :show,
- name: 'g_compliance_approval_rules',
- if: [:custom_condition_one?, :custom_condition_two?]) { |controller| controller.get_custom_id }
-
- def index
- render html: 'index'
- end
-
- def new
- render html: 'new'
- end
-
- def show
- render html: 'show'
- end
-
- def get_custom_id
- 'some_custom_id'
- end
-
- private
-
- def custom_condition_one?
- true
- end
-
- def custom_condition_two?
- true
- end
- end
-
- def expect_tracking
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
- .with('g_compliance_approval_rules', values: instance_of(String))
- end
-
- def expect_no_tracking
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
- end
-
- context 'when user is logged in' do
- before do
- sign_in(user)
- end
-
- it 'tracks the event' do
- expect_tracking
-
- get :index
- end
-
- it 'tracks the event if DNT is not enabled' do
- stub_do_not_track('0')
-
- expect_tracking
-
- get :index
- end
-
- it 'does not track the event if DNT is enabled' do
- stub_do_not_track('1')
-
- expect_no_tracking
-
- get :index
- end
-
- it 'does not track the event if the format is not HTML' do
- expect_no_tracking
-
- get :index, format: :json
- end
-
- it 'does not track the event if a custom condition returns false' do
- expect(controller).to receive(:custom_condition_two?).and_return(false)
-
- expect_no_tracking
-
- get :index
- end
-
- it 'does not track the event for untracked actions' do
- expect_no_tracking
-
- get :new
- end
- end
-
- context 'when user is not logged in' do
- let(:visitor_id) { SecureRandom.uuid }
-
- it 'tracks the event when there is a visitor id' do
- cookies[:visitor_id] = { value: visitor_id, expires: 24.months }
-
- expect_tracking
-
- get :show, params: { id: 1 }
- end
- end
-
- context 'when user is not logged in and there is no visitor_id' do
- it 'does not track the event' do
- expect_no_tracking
-
- get :index
- end
-
- it 'tracks the event when there is custom id' do
- expect_tracking
-
- get :show, params: { id: 1 }
- end
-
- it 'does not track the event when there is no custom id' do
- expect(controller).to receive(:get_custom_id).and_return(nil)
-
- expect_no_tracking
-
- get :show, params: { id: 2 }
- end
- end
-end
diff --git a/spec/controllers/concerns/renders_commits_spec.rb b/spec/controllers/concerns/renders_commits_spec.rb
index 6a504681527..45f194b63e7 100644
--- a/spec/controllers/concerns/renders_commits_spec.rb
+++ b/spec/controllers/concerns/renders_commits_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe RendersCommits do
@merge_request = MergeRequest.find(params[:id])
@commits = set_commits_for_rendering(
@merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch),
- commits_count: @merge_request.commits_count
+ commits_count: @merge_request.commits_count
)
render json: { html: view_to_html_string('projects/merge_requests/_commits') }
diff --git a/spec/controllers/concerns/send_file_upload_spec.rb b/spec/controllers/concerns/send_file_upload_spec.rb
index 6acbff6e745..bf6b68df54e 100644
--- a/spec/controllers/concerns/send_file_upload_spec.rb
+++ b/spec/controllers/concerns/send_file_upload_spec.rb
@@ -2,12 +2,12 @@
require 'spec_helper'
-RSpec.describe SendFileUpload do
+RSpec.describe SendFileUpload, feature_category: :user_profile do
let(:uploader_class) do
Class.new(GitlabUploader) do
include ObjectStorage::Concern
- storage_options Gitlab.config.uploads
+ storage_location :uploads
private
@@ -77,26 +77,6 @@ RSpec.describe SendFileUpload do
allow(uploader).to receive(:model).and_return(image_owner)
end
- it_behaves_like 'handles image resize requests allowed by FF'
-
- context 'when FF is disabled' do
- before do
- stub_feature_flags(dynamic_image_resizing: false)
- end
-
- it_behaves_like 'bypasses image resize requests not allowed by FF'
- end
- end
-
- shared_examples 'bypasses image resize requests not allowed by FF' do
- it 'does not write workhorse command header' do
- expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
-
- subject
- end
- end
-
- shared_examples 'handles image resize requests allowed by FF' do
context 'with valid width parameter' do
it 'renders OK with workhorse command header' do
expect(controller).not_to receive(:send_file)
diff --git a/spec/controllers/concerns/sorting_preference_spec.rb b/spec/controllers/concerns/sorting_preference_spec.rb
index 82a920215ca..6880d83142d 100644
--- a/spec/controllers/concerns/sorting_preference_spec.rb
+++ b/spec/controllers/concerns/sorting_preference_spec.rb
@@ -26,11 +26,14 @@ RSpec.describe SortingPreference do
describe '#set_sort_order' do
let(:group) { build(:group) }
+ let(:controller_name) { 'issues' }
+ let(:action_name) { 'issues' }
let(:issue_weights_available) { true }
before do
allow(controller).to receive(:default_sort_order).and_return('updated_desc')
- allow(controller).to receive(:action_name).and_return('issues')
+ allow(controller).to receive(:controller_name).and_return(controller_name)
+ allow(controller).to receive(:action_name).and_return(action_name)
allow(controller).to receive(:can_sort_by_issue_weight?).and_return(issue_weights_available)
user.user_preference.update!(issues_sort: sorting_field)
end
@@ -62,6 +65,42 @@ RSpec.describe SortingPreference do
end
end
end
+
+ context 'when user preference contains merged date sorting' do
+ let(:sorting_field) { 'merged_at_desc' }
+ let(:can_sort_by_merged_date?) { false }
+
+ before do
+ allow(controller)
+ .to receive(:can_sort_by_merged_date?)
+ .with(can_sort_by_merged_date?)
+ .and_return(can_sort_by_merged_date?)
+ end
+
+ it 'sets default sort order' do
+ is_expected.to eq('updated_desc')
+ end
+
+ shared_examples 'user can sort by merged date' do
+ it 'sets sort order from user_preference' do
+ is_expected.to eq('merged_at_desc')
+ end
+ end
+
+ context 'when controller_name is merge_requests' do
+ let(:controller_name) { 'merge_requests' }
+ let(:can_sort_by_merged_date?) { true }
+
+ it_behaves_like 'user can sort by merged date'
+ end
+
+ context 'when action_name is merge_requests' do
+ let(:action_name) { 'merge_requests' }
+ let(:can_sort_by_merged_date?) { true }
+
+ it_behaves_like 'user can sort by merged date'
+ end
+ end
end
describe '#set_sort_order_from_user_preference' do
diff --git a/spec/controllers/confirmations_controller_spec.rb b/spec/controllers/confirmations_controller_spec.rb
index 773a416dcb4..fea43894f1c 100644
--- a/spec/controllers/confirmations_controller_spec.rb
+++ b/spec/controllers/confirmations_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ConfirmationsController do
+RSpec.describe ConfirmationsController, feature_category: :system_access do
include DeviseHelpers
before do
@@ -58,8 +58,7 @@ RSpec.describe ConfirmationsController do
m.call(*args)
expect(Gitlab::ApplicationContext.current)
- .to include('meta.user' => user.username,
- 'meta.caller_id' => 'ConfirmationsController#show')
+ .to include('meta.user' => user.username, 'meta.caller_id' => 'ConfirmationsController#show')
end
perform_request
@@ -94,8 +93,7 @@ RSpec.describe ConfirmationsController do
m.call(*args)
expect(Gitlab::ApplicationContext.current)
- .to include('meta.user' => user.username,
- 'meta.caller_id' => 'ConfirmationsController#show')
+ .to include('meta.user' => user.username, 'meta.caller_id' => 'ConfirmationsController#show')
end
travel_to(3.days.from_now) { perform_request }
@@ -150,51 +148,67 @@ RSpec.describe ConfirmationsController do
end
end
- context 'when reCAPTCHA is disabled' do
+ context "when `email_confirmation_setting` is set to `soft`" do
before do
- stub_application_setting(recaptcha_enabled: false)
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
end
- it 'successfully sends password reset when reCAPTCHA is not solved' do
- perform_request
+ context 'when reCAPTCHA is disabled' do
+ before do
+ stub_application_setting(recaptcha_enabled: false)
+ end
- expect(response).to redirect_to(dashboard_projects_path)
- end
- end
+ it 'successfully sends password reset when reCAPTCHA is not solved' do
+ perform_request
- context 'when reCAPTCHA is enabled' do
- before do
- stub_application_setting(recaptcha_enabled: true)
+ expect(response).to redirect_to(dashboard_projects_path)
+ end
end
- context 'when the reCAPTCHA is not solved' do
+ context 'when reCAPTCHA is enabled' do
before do
- Recaptcha.configuration.skip_verify_env.delete('test')
+ stub_application_setting(recaptcha_enabled: true)
end
- it 'displays an error' do
- perform_request
+ context 'when the reCAPTCHA is not solved' do
+ before do
+ Recaptcha.configuration.skip_verify_env.delete('test')
+ end
+
+ it 'displays an error' do
+ alert_text = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
+
+ perform_request
+
+ expect(response).to render_template(:new)
+ expect(flash[:alert]).to include alert_text
+ end
- expect(response).to render_template(:new)
- expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
+ it 'sets gon variables' do
+ Gon.clear
+
+ perform_request
+
+ expect(response).to render_template(:new)
+ expect(Gon.all_variables).not_to be_empty
+ end
end
- it 'sets gon variables' do
- Gon.clear
+ it 'successfully sends password reset when reCAPTCHA is solved' do
+ Recaptcha.configuration.skip_verify_env << 'test'
perform_request
- expect(response).to render_template(:new)
- expect(Gon.all_variables).not_to be_empty
+ expect(response).to redirect_to(dashboard_projects_path)
end
end
+ end
- it 'successfully sends password reset when reCAPTCHA is solved' do
- Recaptcha.configuration.skip_verify_env << 'test'
-
+ context "when `email_confirmation_setting` is not set to `soft`" do
+ it 'redirects to the users_almost_there path' do
perform_request
- expect(response).to redirect_to(dashboard_projects_path)
+ expect(response).to redirect_to(users_almost_there_path)
end
end
end
diff --git a/spec/controllers/dashboard/projects_controller_spec.rb b/spec/controllers/dashboard/projects_controller_spec.rb
index e8ee146a13a..893546def5a 100644
--- a/spec/controllers/dashboard/projects_controller_spec.rb
+++ b/spec/controllers/dashboard/projects_controller_spec.rb
@@ -17,6 +17,7 @@ RSpec.describe Dashboard::ProjectsController, :aggregate_failures, feature_categ
before_all do
project.add_developer(user)
project2.add_developer(user)
+ user.toggle_star(project2)
end
before do
@@ -39,6 +40,21 @@ RSpec.describe Dashboard::ProjectsController, :aggregate_failures, feature_categ
expect(assigns(:projects)).to eq(projects)
end
+ it 'assigns the correct all_user_projects' do
+ get :index
+ all_user_projects = assigns(:all_user_projects)
+
+ expect(all_user_projects.count).to eq(2)
+ end
+
+ it 'assigns the correct all_starred_projects' do
+ get :index
+ all_starred_projects = assigns(:all_starred_projects)
+
+ expect(all_starred_projects.count).to eq(1)
+ expect(all_starred_projects).to include(project2)
+ end
+
context 'project sorting' do
it_behaves_like 'set sort order from user preference' do
let(:sorting_param) { 'created_asc' }
@@ -62,6 +78,36 @@ RSpec.describe Dashboard::ProjectsController, :aggregate_failures, feature_categ
end
end
+ context 'with archived project' do
+ let_it_be(:archived_project) do
+ project2.tap { |p| p.update!(archived: true) }
+ end
+
+ it 'does not display archived project' do
+ get :index
+ projects_result = assigns(:projects)
+
+ expect(projects_result).not_to include(archived_project)
+ expect(projects_result).to include(project)
+ end
+
+ it 'excludes archived project from all_user_projects' do
+ get :index
+ all_user_projects = assigns(:all_user_projects)
+
+ expect(all_user_projects.count).to eq(1)
+ expect(all_user_projects).not_to include(archived_project)
+ end
+
+ it 'excludes archived project from all_starred_projects' do
+ get :index
+ all_starred_projects = assigns(:all_starred_projects)
+
+ expect(all_starred_projects.count).to eq(0)
+ expect(all_starred_projects).not_to include(archived_project)
+ end
+ end
+
context 'with deleted project' do
let!(:pending_delete_project) do
project.tap { |p| p.update!(pending_delete: true) }
diff --git a/spec/controllers/every_controller_spec.rb b/spec/controllers/every_controller_spec.rb
index 902872b6e92..b76da85ad72 100644
--- a/spec/controllers/every_controller_spec.rb
+++ b/spec/controllers/every_controller_spec.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
+
RSpec.describe "Every controller" do
context "feature categories" do
let_it_be(:feature_categories) do
@@ -52,7 +53,7 @@ RSpec.describe "Every controller" do
non_existing_used_actions = used_actions - existing_actions
expect(non_existing_used_actions).to be_empty,
- "#{controller} used #{non_existing_used_actions} to define feature category, but the route does not exist"
+ "#{controller} used #{non_existing_used_actions} to define feature category, but the route does not exist"
end
end
end
diff --git a/spec/controllers/explore/groups_controller_spec.rb b/spec/controllers/explore/groups_controller_spec.rb
index a3bd8102462..76bd94fd681 100644
--- a/spec/controllers/explore/groups_controller_spec.rb
+++ b/spec/controllers/explore/groups_controller_spec.rb
@@ -41,9 +41,9 @@ RSpec.describe Explore::GroupsController do
it_behaves_like 'explore groups'
- context 'generic_explore_groups flag is disabled' do
+ context 'gitlab.com' do
before do
- stub_feature_flags(generic_explore_groups: false)
+ allow(Gitlab).to receive(:com?).and_return(true)
end
it_behaves_like 'explore groups'
diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb
index c4f0feb21e2..c2bdb0171e7 100644
--- a/spec/controllers/explore/projects_controller_spec.rb
+++ b/spec/controllers/explore/projects_controller_spec.rb
@@ -239,9 +239,14 @@ RSpec.describe Explore::ProjectsController, feature_category: :projects do
context 'when user is signed in' do
let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, name: 'Project 1') }
+ let_it_be(:project2) { create(:project, name: 'Project 2') }
before do
sign_in(user)
+ project.add_developer(user)
+ project2.add_developer(user)
+ user.toggle_star(project2)
end
include_examples 'explore projects'
@@ -260,6 +265,21 @@ RSpec.describe Explore::ProjectsController, feature_category: :projects do
let(:controller_action) { :index }
let(:params_with_name) { { name: 'some project' } }
+ it 'assigns the correct all_user_projects' do
+ get :index
+ all_user_projects = assigns(:all_user_projects)
+
+ expect(all_user_projects.count).to eq(2)
+ end
+
+ it 'assigns the correct all_starred_projects' do
+ get :index
+ all_starred_projects = assigns(:all_starred_projects)
+
+ expect(all_starred_projects.count).to eq(1)
+ expect(all_starred_projects).to include(project2)
+ end
+
context 'when disable_anonymous_project_search is enabled' do
before do
stub_feature_flags(disable_anonymous_project_search: true)
diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb
index 7aad67b01e8..92b228b6836 100644
--- a/spec/controllers/graphql_controller_spec.rb
+++ b/spec/controllers/graphql_controller_spec.rb
@@ -43,8 +43,9 @@ RSpec.describe GraphqlController, feature_category: :integrations do
post :execute
expect(json_response).to include(
- 'errors' => include(a_hash_including('message' => /Internal server error/,
- 'raisedAt' => /graphql_controller_spec.rb/))
+ 'errors' => include(
+ a_hash_including('message' => /Internal server error/, 'raisedAt' => /graphql_controller_spec.rb/)
+ )
)
end
@@ -64,6 +65,22 @@ RSpec.describe GraphqlController, feature_category: :integrations do
)
expect(response).to have_gitlab_http_status(:forbidden)
end
+
+ it 'handles Gitlab::Git::ResourceExhaustedError', :aggregate_failures do
+ allow(controller).to receive(:execute) do
+ raise Gitlab::Git::ResourceExhaustedError.new("Upstream Gitaly has been exhausted. Try again later", 50)
+ end
+
+ post :execute
+
+ expect(json_response).to include(
+ 'errors' => include(
+ a_hash_including('message' => 'Upstream Gitaly has been exhausted. Try again later')
+ )
+ )
+ expect(response).to have_gitlab_http_status(:too_many_requests)
+ expect(response.headers['Retry-After']).to be(50)
+ end
end
describe 'POST #execute' do
@@ -108,6 +125,41 @@ RSpec.describe GraphqlController, feature_category: :integrations do
])
end
+ it 'executes a multiplexed queries with variables with no errors' do
+ query = <<~GQL
+ mutation($a: String!, $b: String!) {
+ echoCreate(input: { messages: [$a, $b] }) { echoes }
+ }
+ GQL
+ multiplex = [
+ { query: query, variables: { a: 'A', b: 'B' } },
+ { query: query, variables: { a: 'a', b: 'b' } }
+ ]
+
+ post :execute, params: { _json: multiplex }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq(
+ [
+ { 'data' => { 'echoCreate' => { 'echoes' => %w[A B] } } },
+ { 'data' => { 'echoCreate' => { 'echoes' => %w[a b] } } }
+ ])
+ end
+
+ it 'does not allow string as _json parameter (a malformed multiplex query)' do
+ post :execute, params: { _json: 'bad' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq({
+ "errors" => [
+ {
+ "message" => "Unexpected end of document",
+ "locations" => []
+ }
+ ]
+ })
+ end
+
it 'sets a limit on the total query size' do
graphql_query = "{#{(['__typename'] * 1000).join(' ')}}"
@@ -172,14 +224,28 @@ RSpec.describe GraphqlController, feature_category: :integrations do
post :execute
end
- it 'calls the track gitlab cli when trackable method' do
- agent = 'GLab - GitLab CLI'
- request.env['HTTP_USER_AGENT'] = agent
+ context 'if using the GitLab CLI' do
+ it 'call trackable for the old UserAgent' do
+ agent = 'GLab - GitLab CLI'
- expect(Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter)
- .to receive(:track_api_request_when_trackable).with(user_agent: agent, user: user)
+ request.env['HTTP_USER_AGENT'] = agent
- post :execute
+ expect(Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter)
+ .to receive(:track_api_request_when_trackable).with(user_agent: agent, user: user)
+
+ post :execute
+ end
+
+ it 'call trackable for the current UserAgent' do
+ agent = 'glab/v1.25.3-27-g7ec258fb (built 2023-02-16), darwin'
+
+ request.env['HTTP_USER_AGENT'] = agent
+
+ expect(Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter)
+ .to receive(:track_api_request_when_trackable).with(user_agent: agent, user: user)
+
+ post :execute
+ end
end
it "assigns username in ApplicationContext" do
diff --git a/spec/controllers/groups/children_controller_spec.rb b/spec/controllers/groups/children_controller_spec.rb
index d0656ee47ce..2e37ed95c1c 100644
--- a/spec/controllers/groups/children_controller_spec.rb
+++ b/spec/controllers/groups/children_controller_spec.rb
@@ -275,6 +275,18 @@ RSpec.describe Groups::ChildrenController, feature_category: :subgroups do
allow(Kaminari.config).to receive(:default_per_page).and_return(per_page)
end
+ it 'rejects negative per_page parameter' do
+ get :index, params: { group_id: group.to_param, per_page: -1 }, format: :json
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'rejects non-numeric per_page parameter' do
+ get :index, params: { group_id: group.to_param, per_page: 'abc' }, format: :json
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
context 'with only projects' do
let!(:other_project) { create(:project, :public, namespace: group) }
let!(:first_page_projects) { create_list(:project, per_page, :public, namespace: group) }
diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb
index 01ea7101f2e..f36494c3d78 100644
--- a/spec/controllers/groups/clusters_controller_spec.rb
+++ b/spec/controllers/groups/clusters_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::ClustersController, feature_category: :kubernetes_management do
+RSpec.describe Groups::ClustersController, feature_category: :deployment_management do
include AccessMatchersForController
include GoogleApi::CloudPlatformHelpers
@@ -322,12 +322,6 @@ RSpec.describe Groups::ClustersController, feature_category: :kubernetes_managem
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('cluster_status')
end
-
- it 'invokes schedule_status_update on each application' do
- expect_any_instance_of(Clusters::Applications::Ingress).to receive(:schedule_status_update)
-
- go
- end
end
describe 'security' do
@@ -360,20 +354,37 @@ RSpec.describe Groups::ClustersController, feature_category: :kubernetes_managem
end
describe 'functionality' do
- render_views
+ context 'when remove_monitor_metrics FF is disabled' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
- it 'renders view' do
- go
+ render_views
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:cluster)).to eq(cluster)
+ it 'renders view' do
+ go
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:cluster)).to eq(cluster)
+ end
+
+ it 'renders integration tab view', :aggregate_failures do
+ go(tab: 'integrations')
+
+ expect(response).to render_template('clusters/clusters/_integrations')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
- it 'renders integration tab view', :aggregate_failures do
- go(tab: 'integrations')
+ context 'when remove_monitor_metrics FF is enabled' do
+ render_views
- expect(response).to render_template('clusters/clusters/_integrations')
- expect(response).to have_gitlab_http_status(:ok)
+ it 'renders details tab view', :aggregate_failures do
+ go(tab: 'integrations')
+
+ expect(response).to render_template('clusters/clusters/_details')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
end
diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
index f1ca9e11a1a..a59c90a3cf2 100644
--- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
+++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
@@ -249,7 +249,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do
expect(send_data_type).to eq('send-dependency')
expect(header).to eq(
"Authorization" => ["Bearer abcd1234"],
- "Accept" => ::ContainerRegistry::Client::ACCEPTED_TYPES
+ "Accept" => ::DependencyProxy::Manifest::ACCEPTED_TYPES
)
expect(url).to eq(DependencyProxy::Registry.manifest_url(image, tag))
expect(response.headers['Content-Type']).to eq('application/gzip')
diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb
index 4e5dc01f466..fe4b80e12fe 100644
--- a/spec/controllers/groups/group_members_controller_spec.rb
+++ b/spec/controllers/groups/group_members_controller_spec.rb
@@ -55,6 +55,20 @@ RSpec.describe Groups::GroupMembersController do
expect(assigns(:invited_members).count).to eq(1)
end
+
+ context 'when filtering by user type' do
+ let_it_be(:service_account) { create(:user, :service_account) }
+
+ before do
+ group.add_developer(service_account)
+ end
+
+ it 'returns only service accounts' do
+ get :index, params: { group_id: group, user_type: 'service_account' }
+
+ expect(assigns(:members).map(&:user_id)).to match_array([service_account.id])
+ end
+ end
end
context 'when user cannot manage members' do
@@ -67,6 +81,21 @@ RSpec.describe Groups::GroupMembersController do
expect(assigns(:invited_members)).to be_nil
end
+
+ context 'when filtering by user type' do
+ let_it_be(:service_account) { create(:user, :service_account) }
+
+ before do
+ group.add_developer(user)
+ group.add_developer(service_account)
+ end
+
+ it 'returns only service accounts' do
+ get :index, params: { group_id: group, user_type: 'service_account' }
+
+ expect(assigns(:members).map(&:user_id)).to match_array([user.id, service_account.id])
+ end
+ end
end
context 'when user has owner access to subgroup' do
@@ -489,13 +518,11 @@ RSpec.describe Groups::GroupMembersController do
describe 'PUT #update' do
it 'is successful' do
- put :update,
- params: {
- group_member: { access_level: Gitlab::Access::GUEST },
- group_id: group,
- id: membership
- },
- format: :json
+ put :update, params: {
+ group_member: { access_level: Gitlab::Access::GUEST },
+ group_id: group,
+ id: membership
+ }, format: :json
expect(response).to have_gitlab_http_status(:ok)
end
@@ -505,7 +532,7 @@ RSpec.describe Groups::GroupMembersController do
it 'is successful' do
delete :destroy, params: { group_id: group, id: membership }
- expect(response).to have_gitlab_http_status(:found)
+ expect(response).to have_gitlab_http_status(:see_other)
end
end
diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb
index a3c4c47ab15..87030448b30 100644
--- a/spec/controllers/groups/milestones_controller_spec.rb
+++ b/spec/controllers/groups/milestones_controller_spec.rb
@@ -230,11 +230,10 @@ RSpec.describe Groups::MilestonesController do
describe "#create" do
it "creates group milestone with Chinese title" do
- post :create,
- params: {
- group_id: group.to_param,
- milestone: milestone_params
- }
+ post :create, params: {
+ group_id: group.to_param,
+ milestone: milestone_params
+ }
milestone = Milestone.find_by_title(title)
@@ -251,17 +250,31 @@ RSpec.describe Groups::MilestonesController do
it "updates group milestone" do
milestone_params[:title] = "title changed"
- put :update,
- params: {
- id: milestone.iid,
- group_id: group.to_param,
- milestone: milestone_params
- }
+ put :update, params: {
+ id: milestone.iid,
+ group_id: group.to_param,
+ milestone: milestone_params
+ }
milestone.reload
expect(response).to redirect_to(group_milestone_path(group, milestone.iid))
expect(milestone.title).to eq("title changed")
end
+
+ it "handles ActiveRecord::StaleObjectError" do
+ milestone_params[:title] = "title changed"
+ # Purposely reduce the lock_version to trigger an ActiveRecord::StaleObjectError
+ milestone_params[:lock_version] = milestone.lock_version - 1
+
+ put :update, params: {
+ id: milestone.iid,
+ group_id: group.to_param,
+ milestone: milestone_params
+ }
+
+ expect(response).not_to redirect_to(group_milestone_path(group, milestone.iid))
+ expect(response).to render_template(:edit)
+ end
end
describe "#destroy" do
@@ -390,21 +403,19 @@ RSpec.describe Groups::MilestonesController do
context 'for a non-GET request' do
context 'when requesting the canonical path with different casing' do
it 'does not 404' do
- post :create,
- params: {
- group_id: group.to_param,
- milestone: { title: title }
- }
+ post :create, params: {
+ group_id: group.to_param,
+ milestone: { title: title }
+ }
expect(response).not_to have_gitlab_http_status(:not_found)
end
it 'does not redirect to the correct casing' do
- post :create,
- params: {
- group_id: group.to_param,
- milestone: { title: title }
- }
+ post :create, params: {
+ group_id: group.to_param,
+ milestone: { title: title }
+ }
expect(response).not_to have_gitlab_http_status(:moved_permanently)
end
@@ -414,11 +425,10 @@ RSpec.describe Groups::MilestonesController do
let(:redirect_route) { group.redirect_routes.create!(path: 'old-path') }
it 'returns not found' do
- post :create,
- params: {
- group_id: redirect_route.path,
- milestone: { title: title }
- }
+ post :create, params: {
+ group_id: redirect_route.path,
+ milestone: { title: title }
+ }
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb
index 1a60f7d824e..9ae5cb6f87c 100644
--- a/spec/controllers/groups/runners_controller_spec.rb
+++ b/spec/controllers/groups/runners_controller_spec.rb
@@ -6,8 +6,8 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:runner) { create(:ci_runner, :group, groups: [group]) }
- let!(:runner) { create(:ci_runner, :group, groups: [group]) }
let!(:project_runner) { create(:ci_runner, :project, projects: [project]) }
let!(:instance_runner) { create(:ci_runner, :instance) }
@@ -37,6 +37,12 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do
expect_snowplow_event(category: described_class.name, action: 'index', user: user, namespace: group)
end
+
+ it 'assigns variables' do
+ get :index, params: { group_id: group }
+
+ expect(assigns(:group_new_runner_path)).to eq(new_group_runner_path(group))
+ end
end
context 'when user is not owner' do
@@ -58,6 +64,130 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do
end
end
+ describe '#new' do
+ context 'when create_runner_workflow_for_namespace is enabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: [group])
+ end
+
+ context 'when user is owner' do
+ before do
+ group.add_owner(user)
+ end
+
+ it 'renders new with 200 status code' do
+ get :new, params: { group_id: group }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:new)
+ end
+ end
+
+ context 'when user is not owner' do
+ before do
+ group.add_maintainer(user)
+ end
+
+ it 'renders a 404' do
+ get :new, params: { group_id: group }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when create_runner_workflow_for_namespace is disabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: false)
+ end
+
+ context 'when user is owner' do
+ before do
+ group.add_owner(user)
+ end
+
+ it 'renders a 404' do
+ get :new, params: { group_id: group }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ describe '#register' do
+ subject(:register) { get :register, params: { group_id: group, id: new_runner } }
+
+ context 'when create_runner_workflow_for_namespace is enabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: [group])
+ end
+
+ context 'when user is owner' do
+ before do
+ group.add_owner(user)
+ end
+
+ context 'when runner can be registered after creation' do
+ let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) }
+
+ it 'renders a :register template' do
+ register
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:register)
+ end
+ end
+
+ context 'when runner cannot be registered after creation' do
+ let_it_be(:new_runner) { runner }
+
+ it 'returns :not_found' do
+ register
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when user is not owner' do
+ before do
+ group.add_maintainer(user)
+ end
+
+ context 'when runner can be registered after creation' do
+ let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) }
+
+ it 'returns :not_found' do
+ register
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ context 'when create_runner_workflow_for_namespace is disabled' do
+ let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) }
+
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: false)
+ end
+
+ context 'when user is owner' do
+ before do
+ group.add_owner(user)
+ end
+
+ it 'returns :not_found' do
+ register
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
describe '#show' do
context 'when user is owner' do
before do
@@ -158,6 +288,8 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do
end
describe '#update' do
+ let!(:runner) { create(:ci_runner, :group, groups: [group]) }
+
context 'when user is an owner' do
before do
group.add_owner(user)
diff --git a/spec/controllers/groups/settings/applications_controller_spec.rb b/spec/controllers/groups/settings/applications_controller_spec.rb
index b9457770ed6..c398fd044c2 100644
--- a/spec/controllers/groups/settings/applications_controller_spec.rb
+++ b/spec/controllers/groups/settings/applications_controller_spec.rb
@@ -71,43 +71,18 @@ RSpec.describe Groups::Settings::ApplicationsController do
group.add_owner(user)
end
- context 'with hash_oauth_secrets flag on' do
- before do
- stub_feature_flags(hash_oauth_secrets: true)
- end
-
- it 'creates the application' do
- create_params = attributes_for(:application, trusted: false, confidential: false, scopes: ['api'])
-
- expect do
- post :create, params: { group_id: group, doorkeeper_application: create_params }
- end.to change { Doorkeeper::Application.count }.by(1)
-
- application = Doorkeeper::Application.last
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template :show
- expect(application).to have_attributes(create_params.except(:uid, :owner_type))
- end
- end
+ it 'creates the application' do
+ create_params = attributes_for(:application, trusted: false, confidential: false, scopes: ['api'])
- context 'with hash_oauth_secrets flag off' do
- before do
- stub_feature_flags(hash_oauth_secrets: false)
- end
-
- it 'creates the application' do
- create_params = attributes_for(:application, trusted: false, confidential: false, scopes: ['api'])
-
- expect do
- post :create, params: { group_id: group, doorkeeper_application: create_params }
- end.to change { Doorkeeper::Application.count }.by(1)
+ expect do
+ post :create, params: { group_id: group, doorkeeper_application: create_params }
+ end.to change { Doorkeeper::Application.count }.by(1)
- application = Doorkeeper::Application.last
+ application = Doorkeeper::Application.last
- expect(response).to redirect_to(group_settings_application_path(group, application))
- expect(application).to have_attributes(create_params.except(:uid, :owner_type))
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template :show
+ expect(application).to have_attributes(create_params.except(:uid, :owner_type))
end
it 'renders the application form on errors' do
@@ -120,43 +95,18 @@ RSpec.describe Groups::Settings::ApplicationsController do
end
context 'when the params are for a confidential application' do
- context 'with hash_oauth_secrets flag off' do
- before do
- stub_feature_flags(hash_oauth_secrets: false)
- end
-
- it 'creates a confidential application' do
- create_params = attributes_for(:application, confidential: true, scopes: ['read_user'])
-
- expect do
- post :create, params: { group_id: group, doorkeeper_application: create_params }
- end.to change { Doorkeeper::Application.count }.by(1)
+ it 'creates a confidential application' do
+ create_params = attributes_for(:application, confidential: true, scopes: ['read_user'])
- application = Doorkeeper::Application.last
-
- expect(response).to redirect_to(group_settings_application_path(group, application))
- expect(application).to have_attributes(create_params.except(:uid, :owner_type))
- end
- end
-
- context 'with hash_oauth_secrets flag on' do
- before do
- stub_feature_flags(hash_oauth_secrets: true)
- end
-
- it 'creates a confidential application' do
- create_params = attributes_for(:application, confidential: true, scopes: ['read_user'])
-
- expect do
- post :create, params: { group_id: group, doorkeeper_application: create_params }
- end.to change { Doorkeeper::Application.count }.by(1)
+ expect do
+ post :create, params: { group_id: group, doorkeeper_application: create_params }
+ end.to change { Doorkeeper::Application.count }.by(1)
- application = Doorkeeper::Application.last
+ application = Doorkeeper::Application.last
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template :show
- expect(application).to have_attributes(create_params.except(:uid, :owner_type))
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template :show
+ expect(application).to have_attributes(create_params.except(:uid, :owner_type))
end
end
@@ -188,6 +138,61 @@ RSpec.describe Groups::Settings::ApplicationsController do
end
end
+ describe 'PUT #renew' do
+ context 'when user is owner' do
+ before do
+ group.add_owner(user)
+ end
+
+ let(:oauth_params) do
+ {
+ group_id: group,
+ id: application.id
+ }
+ end
+
+ subject { put :renew, params: oauth_params }
+
+ it { is_expected.to have_gitlab_http_status(:ok) }
+ it { expect { subject }.to change { application.reload.secret } }
+
+ it 'returns the secret in json format' do
+ subject
+
+ expect(json_response['secret']).not_to be_nil
+ end
+
+ context 'when renew fails' do
+ before do
+ allow_next_found_instance_of(Doorkeeper::Application) do |application|
+ allow(application).to receive(:save).and_return(false)
+ end
+ end
+
+ it { expect { subject }.not_to change { application.reload.secret } }
+ it { is_expected.to have_gitlab_http_status(:unprocessable_entity) }
+ end
+ end
+
+ context 'when user is not owner' do
+ before do
+ group.add_maintainer(user)
+ end
+
+ let(:oauth_params) do
+ {
+ group_id: group,
+ id: application.id
+ }
+ end
+
+ it 'renders a 404' do
+ put :renew, params: oauth_params
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
describe 'PATCH #update' do
context 'when user is owner' do
before do
diff --git a/spec/controllers/groups/settings/integrations_controller_spec.rb b/spec/controllers/groups/settings/integrations_controller_spec.rb
index 377c38ce087..3ae43c8ab7c 100644
--- a/spec/controllers/groups/settings/integrations_controller_spec.rb
+++ b/spec/controllers/groups/settings/integrations_controller_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe Groups::Settings::IntegrationsController do
let_it_be(:group) { create(:group) }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
sign_in(user)
end
diff --git a/spec/controllers/groups/variables_controller_spec.rb b/spec/controllers/groups/variables_controller_spec.rb
index 6dbe75bb1df..8c6efae89c3 100644
--- a/spec/controllers/groups/variables_controller_spec.rb
+++ b/spec/controllers/groups/variables_controller_spec.rb
@@ -77,12 +77,10 @@ RSpec.describe Groups::VariablesController do
describe 'PATCH #update' do
it 'is successful' do
- patch :update,
- params: {
- group_id: group,
- variables_attributes: [{ id: variable.id, key: 'hello' }]
- },
- format: :json
+ patch :update, params: {
+ group_id: group,
+ variables_attributes: [{ id: variable.id, key: 'hello' }]
+ }, format: :json
expect(response).to have_gitlab_http_status(:ok)
end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index 9184cd2263e..8617cc8af8f 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -152,29 +152,6 @@ RSpec.describe GroupsController, factory_default: :keep, feature_category: :code
end
end
end
-
- describe 'require_verification_for_namespace_creation experiment', :experiment do
- before do
- sign_in(owner)
- stub_experiments(require_verification_for_namespace_creation: :candidate)
- end
-
- it 'tracks a "start_create_group" event' do
- expect(experiment(:require_verification_for_namespace_creation)).to track(
- :start_create_group
- ).on_next_instance.with_context(user: owner)
-
- get :new
- end
-
- context 'when creating a sub-group' do
- it 'does not track a "start_create_group" event' do
- expect(experiment(:require_verification_for_namespace_creation)).not_to track(:start_create_group)
-
- get :new, params: { parent_id: group.id }
- end
- end
- end
end
describe 'GET #activity' do
diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb
index 2375146f346..056df213209 100644
--- a/spec/controllers/help_controller_spec.rb
+++ b/spec/controllers/help_controller_spec.rb
@@ -181,6 +181,7 @@ RSpec.describe HelpController do
context 'when requested file exists' do
before do
stub_doc_file_read(file_name: 'user/ssh.md', content: fixture_file('blockquote_fence_after.md'))
+ stub_application_setting(help_page_documentation_base_url: '')
subject
end
@@ -223,13 +224,13 @@ RSpec.describe HelpController do
context 'when gitlab_docs is disabled' do
let(:docs_enabled) { false }
- it_behaves_like 'documentation pages local render'
+ it_behaves_like 'documentation pages redirect', 'https://docs.gitlab.com'
end
context 'when host is missing' do
let(:host) { nil }
- it_behaves_like 'documentation pages local render'
+ it_behaves_like 'documentation pages redirect', 'https://docs.gitlab.com'
end
end
@@ -251,6 +252,10 @@ RSpec.describe HelpController do
end
context 'when requested file is missing' do
+ before do
+ stub_application_setting(help_page_documentation_base_url: '')
+ end
+
it 'renders not found' do
get :show, params: { path: 'foo/bar' }, format: :md
expect(response).to be_not_found
@@ -261,11 +266,7 @@ RSpec.describe HelpController do
context 'for image formats' do
context 'when requested file exists' do
it 'renders the raw file' do
- get :show,
- params: {
- path: 'user/img/markdown_logo'
- },
- format: :png
+ get :show, params: { path: 'user/img/markdown_logo' }, format: :png
aggregate_failures do
expect(response).to be_successful
@@ -277,11 +278,7 @@ RSpec.describe HelpController do
context 'when requested file is missing' do
it 'renders not found' do
- get :show,
- params: {
- path: 'foo/bar'
- },
- format: :png
+ get :show, params: { path: 'foo/bar' }, format: :png
expect(response).to be_not_found
end
end
@@ -289,11 +286,7 @@ RSpec.describe HelpController do
context 'for other formats' do
it 'always renders not found' do
- get :show,
- params: {
- path: 'user/ssh'
- },
- format: :foo
+ get :show, params: { path: 'user/ssh' }, format: :foo
expect(response).to be_not_found
end
end
diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb
index 35f712dc50d..906cc5cb336 100644
--- a/spec/controllers/import/bitbucket_controller_spec.rb
+++ b/spec/controllers/import/bitbucket_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::BitbucketController do
+RSpec.describe Import::BitbucketController, feature_category: :importers do
include ImportSpecHelper
let(:user) { create(:user) }
@@ -48,11 +48,13 @@ RSpec.describe Import::BitbucketController do
let(:expires_at) { Time.current + 1.day }
let(:expires_in) { 1.day }
let(:access_token) do
- double(token: token,
- secret: secret,
- expires_at: expires_at,
- expires_in: expires_in,
- refresh_token: refresh_token)
+ double(
+ token: token,
+ secret: secret,
+ expires_at: expires_at,
+ expires_in: expires_in,
+ refresh_token: refresh_token
+ )
end
before do
@@ -63,10 +65,10 @@ RSpec.describe Import::BitbucketController do
allow_any_instance_of(OAuth2::Client)
.to receive(:get_token)
.with(hash_including(
- 'grant_type' => 'authorization_code',
- 'code' => code,
- 'redirect_uri' => users_import_bitbucket_callback_url),
- {})
+ 'grant_type' => 'authorization_code',
+ 'code' => code,
+ 'redirect_uri' => users_import_bitbucket_callback_url),
+ {})
.and_return(access_token)
stub_omniauth_provider('bitbucket')
@@ -443,5 +445,16 @@ RSpec.describe Import::BitbucketController do
)
end
end
+
+ context 'when user can not import projects' do
+ let!(:other_namespace) { create(:group, name: 'other_namespace').tap { |other_namespace| other_namespace.add_developer(user) } }
+
+ it 'returns 422 response' do
+ post :create, params: { target_namespace: other_namespace.name }, format: :json
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(response.parsed_body['errors']).to eq('You are not allowed to import projects in this namespace.')
+ end
+ end
end
end
diff --git a/spec/controllers/import/bitbucket_server_controller_spec.rb b/spec/controllers/import/bitbucket_server_controller_spec.rb
index ac56d3af54f..b2a56423253 100644
--- a/spec/controllers/import/bitbucket_server_controller_spec.rb
+++ b/spec/controllers/import/bitbucket_server_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::BitbucketServerController do
+RSpec.describe Import::BitbucketServerController, feature_category: :importers do
let(:user) { create(:user) }
let(:project_key) { 'test-project' }
let(:repo_slug) { 'some-repo' }
diff --git a/spec/controllers/import/bulk_imports_controller_spec.rb b/spec/controllers/import/bulk_imports_controller_spec.rb
index a3992ae850e..c5e5aa03669 100644
--- a/spec/controllers/import/bulk_imports_controller_spec.rb
+++ b/spec/controllers/import/bulk_imports_controller_spec.rb
@@ -121,12 +121,12 @@ RSpec.describe Import::BulkImportsController, feature_category: :importers do
params = { page: 1, per_page: 20, filter: '' }.merge(params_override)
get :status,
- params: params,
- format: format,
- session: {
- bulk_import_gitlab_url: 'https://gitlab.example.com',
- bulk_import_gitlab_access_token: 'demo-pat'
- }
+ params: params,
+ format: format,
+ session: {
+ bulk_import_gitlab_url: 'https://gitlab.example.com',
+ bulk_import_gitlab_access_token: 'demo-pat'
+ }
end
include_context 'bulk imports requests context', 'https://gitlab.example.com'
@@ -157,8 +157,7 @@ RSpec.describe Import::BulkImportsController, feature_category: :importers do
end
let(:source_version) do
- Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION,
- ::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)
+ Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION, ::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)
end
before do
@@ -214,36 +213,41 @@ RSpec.describe Import::BulkImportsController, feature_category: :importers do
end
end
- context 'when host url is local or not http' do
- %w[https://localhost:3000 http://192.168.0.1 ftp://testing].each do |url|
- before do
- stub_application_setting(allow_local_requests_from_web_hooks_and_services: false)
-
- session[:bulk_import_gitlab_access_token] = 'test'
- session[:bulk_import_gitlab_url] = url
- end
+ shared_examples 'unacceptable url' do |url, expected_error|
+ before do
+ stub_application_setting(allow_local_requests_from_web_hooks_and_services: false)
- it 'denies network request' do
- get :status
+ session[:bulk_import_gitlab_access_token] = 'test'
+ session[:bulk_import_gitlab_url] = url
+ end
- expect(controller).to redirect_to(new_group_path(anchor: 'import-group-pane'))
- expect(flash[:alert]).to eq('Specified URL cannot be used: "Only allowed schemes are http, https"')
- end
+ it 'denies network request' do
+ get :status
+ expect(controller).to redirect_to(new_group_path(anchor: 'import-group-pane'))
+ expect(flash[:alert]).to eq("Specified URL cannot be used: \"#{expected_error}\"")
end
+ end
+
+ context 'when host url is local or not http' do
+ include_examples 'unacceptable url', 'https://localhost:3000', "Only allowed schemes are http, https"
+ include_examples 'unacceptable url', 'http://192.168.0.1', "Only allowed schemes are http, https"
+ include_examples 'unacceptable url', 'ftp://testing', "Only allowed schemes are http, https"
context 'when local requests are allowed' do
%w[https://localhost:3000 http://192.168.0.1].each do |url|
- before do
- stub_application_setting(allow_local_requests_from_web_hooks_and_services: true)
+ context "with #{url}" do
+ before do
+ stub_application_setting(allow_local_requests_from_web_hooks_and_services: true)
- session[:bulk_import_gitlab_access_token] = 'test'
- session[:bulk_import_gitlab_url] = url
- end
+ session[:bulk_import_gitlab_access_token] = 'test'
+ session[:bulk_import_gitlab_url] = url
+ end
- it 'allows network request' do
- get :status
+ it 'allows network request' do
+ get :status
- expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
end
end
@@ -270,8 +274,7 @@ RSpec.describe Import::BulkImportsController, feature_category: :importers do
context 'when connection error occurs' do
let(:source_version) do
- Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION,
- ::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)
+ Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION, ::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)
end
before do
diff --git a/spec/controllers/import/fogbugz_controller_spec.rb b/spec/controllers/import/fogbugz_controller_spec.rb
index ed2a588eadf..3b099ba2613 100644
--- a/spec/controllers/import/fogbugz_controller_spec.rb
+++ b/spec/controllers/import/fogbugz_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::FogbugzController do
+RSpec.describe Import::FogbugzController, feature_category: :importers do
include ImportSpecHelper
let(:user) { create(:user) }
@@ -11,6 +11,8 @@ RSpec.describe Import::FogbugzController do
let(:namespace_id) { 5 }
before do
+ stub_application_setting(import_sources: ['fogbugz'])
+
sign_in(user)
end
@@ -116,8 +118,7 @@ RSpec.describe Import::FogbugzController do
describe 'GET status' do
let(:repo) do
- instance_double(Gitlab::FogbugzImport::Repository,
- id: 'demo', name: 'vim', safe_name: 'vim', path: 'vim')
+ instance_double(Gitlab::FogbugzImport::Repository, id: 'demo', name: 'vim', safe_name: 'vim', path: 'vim')
end
it 'redirects to new page form when client is invalid' do
diff --git a/spec/controllers/import/gitea_controller_spec.rb b/spec/controllers/import/gitea_controller_spec.rb
index 568712d29cb..3dfda909a93 100644
--- a/spec/controllers/import/gitea_controller_spec.rb
+++ b/spec/controllers/import/gitea_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::GiteaController do
+RSpec.describe Import::GiteaController, feature_category: :importers do
include ImportSpecHelper
let(:provider) { :gitea }
@@ -10,6 +10,10 @@ RSpec.describe Import::GiteaController do
include_context 'a GitHub-ish import controller'
+ before do
+ stub_application_setting(import_sources: ['gitea'])
+ end
+
def assign_host_url
session[:gitea_host_url] = host_url
end
@@ -42,19 +46,23 @@ RSpec.describe Import::GiteaController do
expect(response).to have_gitlab_http_status(:ok)
end
- context 'when host url is local or not http' do
- %w[https://localhost:3000 http://192.168.0.1 ftp://testing].each do |url|
- let(:host_url) { url }
+ shared_examples "unacceptable url" do |url, expected_error|
+ let(:host_url) { url }
- it 'denies network request' do
- get :status, format: :json
+ it 'denies network request' do
+ get :status, format: :json
- expect(controller).to redirect_to(new_import_url)
- expect(flash[:alert]).to eq('Specified URL cannot be used: "Only allowed schemes are http, https"')
- end
+ expect(controller).to redirect_to(new_import_url)
+ expect(flash[:alert]).to eq("Specified URL cannot be used: \"#{expected_error}\"")
end
end
+ context 'when host url is local or not http' do
+ include_examples 'unacceptable url', 'https://localhost:3000', 'Only allowed schemes are http, https'
+ include_examples 'unacceptable url', 'http://192.168.0.1', 'Only allowed schemes are http, https'
+ include_examples 'unacceptable url', 'ftp://testing', 'Only allowed schemes are http, https'
+ end
+
context 'when DNS Rebinding protection is enabled' do
let(:token) { 'gitea token' }
diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb
index 406a3604b23..fdc0ddda9f4 100644
--- a/spec/controllers/import/github_controller_spec.rb
+++ b/spec/controllers/import/github_controller_spec.rb
@@ -139,7 +139,7 @@ RSpec.describe Import::GithubController, feature_category: :importers do
expect_next_instance_of(Gitlab::GithubImport::Clients::Proxy) do |client|
expect(client).to receive(:repos)
.with(expected_filter, expected_options)
- .and_return({ repos: [], page_info: {} })
+ .and_return({ repos: [], page_info: {}, count: 0 })
end
get :status, params: params, format: :json
@@ -149,6 +149,7 @@ RSpec.describe Import::GithubController, feature_category: :importers do
expect(json_response['provider_repos'].size).to eq 0
expect(json_response['incompatible_repos'].size).to eq 0
expect(json_response['page_info']).to eq({})
+ expect(json_response['provider_repo_count']).to eq(0)
end
end
@@ -161,9 +162,7 @@ RSpec.describe Import::GithubController, feature_category: :importers do
let(:provider_repos) { [] }
let(:expected_filter) { '' }
let(:expected_options) do
- pagination_params.merge(relation_params).merge(
- first: 25, page: 1, per_page: 25
- )
+ pagination_params.merge(relation_params).merge(first: 25)
end
before do
@@ -171,6 +170,9 @@ RSpec.describe Import::GithubController, feature_category: :importers do
if client_auth_success
allow(proxy).to receive(:repos).and_return({ repos: provider_repos })
allow(proxy).to receive(:client).and_return(client_stub)
+ allow_next_instance_of(Gitlab::GithubImport::ProjectRelationType) do |instance|
+ allow(instance).to receive(:for).with('example/repo').and_return('owned')
+ end
else
allow(proxy).to receive(:repos).and_raise(Octokit::Unauthorized)
end
@@ -279,22 +281,12 @@ RSpec.describe Import::GithubController, feature_category: :importers do
it_behaves_like 'calls repos through Clients::Proxy with expected args'
end
-
- context 'when page is specified' do
- let(:pagination_params) { { before: nil, after: nil, page: 2 } }
- let(:params) { pagination_params }
- let(:expected_options) do
- pagination_params.merge(relation_params).merge(first: 25, page: 2, per_page: 25)
- end
-
- it_behaves_like 'calls repos through Clients::Proxy with expected args'
- end
end
context 'when relation type params present' do
let(:organization_login) { 'test-login' }
let(:params) { pagination_params.merge(relation_type: 'organization', organization_login: organization_login) }
- let(:pagination_defaults) { { first: 25, page: 1, per_page: 25 } }
+ let(:pagination_defaults) { { first: 25 } }
let(:expected_options) do
pagination_defaults.merge(pagination_params).merge(
relation_type: 'organization', organization_login: organization_login
@@ -359,7 +351,13 @@ RSpec.describe Import::GithubController, feature_category: :importers do
end
end
- describe "POST create" do
+ describe "POST create", :clean_gitlab_redis_cache do
+ before do
+ allow_next_instance_of(Gitlab::GithubImport::ProjectRelationType) do |instance|
+ allow(instance).to receive(:for).with("#{provider_username}/vim").and_return('owned')
+ end
+ end
+
it_behaves_like 'a GitHub-ish import controller: POST create'
it_behaves_like 'project import rate limiter'
@@ -387,14 +385,74 @@ RSpec.describe Import::GithubController, feature_category: :importers do
end
end
+ describe "GET failures" do
+ let_it_be_with_reload(:project) { create(:project, import_type: 'github', import_status: :started, import_source: 'example/repo', import_url: 'https://fake.url') }
+ let!(:import_failure) do
+ create(:import_failure,
+ project: project,
+ source: 'Gitlab::GithubImport::Importer::PullRequestImporter',
+ external_identifiers: { iid: 2, object_type: 'pull_request', title: 'My Pull Request' }
+ )
+ end
+
+ context 'when import is not finished' do
+ it 'return bad_request' do
+ get :failures, params: { project_id: project.id }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq('The import is not complete.')
+ end
+ end
+
+ context 'when import is finished' do
+ before do
+ project.import_state.finish
+ end
+
+ it 'includes failure details in response' do
+ get :failures, params: { project_id: project.id }
+
+ expect(json_response[0]['type']).to eq('pull_request')
+ expect(json_response[0]['title']).to eq('My Pull Request')
+ expect(json_response[0]['provider_url']).to eq("https://fake.url/example/repo/pull/2")
+ expect(json_response[0]['details']['source']).to eq(import_failure.source)
+ end
+
+ it 'paginates records' do
+ issue_title = 'My Issue'
+
+ create(
+ :import_failure,
+ project: project,
+ source: 'Gitlab::GithubImport::Importer::IssueAndLabelLinksImporter',
+ external_identifiers: { iid: 3, object_type: 'issue', title: issue_title }
+ )
+
+ get :failures, params: { project_id: project.id, page: 2, per_page: 1 }
+
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['title']).to eq(issue_title)
+ end
+ end
+ end
+
describe "POST cancel" do
- let_it_be(:project) { create(:project, :import_started, import_type: 'github', import_url: 'https://fake.url') }
+ let_it_be(:project) do
+ create(
+ :project, :import_started,
+ import_type: 'github', import_url: 'https://fake.url', import_source: 'login/repo'
+ )
+ end
context 'when project import was canceled' do
before do
allow(Import::Github::CancelProjectImportService)
.to receive(:new).with(project, user)
.and_return(double(execute: { status: :success, project: project }))
+
+ allow_next_instance_of(Gitlab::GithubImport::ProjectRelationType) do |instance|
+ allow(instance).to receive(:for).with('login/repo').and_return('owned')
+ end
end
it 'returns success' do
@@ -471,4 +529,26 @@ RSpec.describe Import::GithubController, feature_category: :importers do
end
end
end
+
+ describe 'GET counts' do
+ let(:expected_result) do
+ {
+ 'owned' => 3,
+ 'collaborated' => 2,
+ 'organization' => 1
+ }
+ end
+
+ it 'returns repos count by type' do
+ expect_next_instance_of(Gitlab::GithubImport::Clients::Proxy) do |client_proxy|
+ expect(client_proxy).to receive(:count_repos_by).with('owned', user.id).and_return(3)
+ expect(client_proxy).to receive(:count_repos_by).with('collaborated', user.id).and_return(2)
+ expect(client_proxy).to receive(:count_repos_by).with('organization', user.id).and_return(1)
+ end
+
+ get :counts
+
+ expect(json_response).to eq(expected_result)
+ end
+ end
end
diff --git a/spec/controllers/import/gitlab_controller_spec.rb b/spec/controllers/import/gitlab_controller_spec.rb
deleted file mode 100644
index 7b3978297fb..00000000000
--- a/spec/controllers/import/gitlab_controller_spec.rb
+++ /dev/null
@@ -1,313 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Import::GitlabController do
- include ImportSpecHelper
-
- let(:user) { create(:user) }
- let(:token) { "asdasd12345" }
- let(:access_params) { { gitlab_access_token: token } }
-
- def assign_session_token
- session[:gitlab_access_token] = token
- end
-
- before do
- sign_in(user)
- allow(controller).to receive(:gitlab_import_enabled?).and_return(true)
- end
-
- describe "GET callback" do
- it "updates access token" do
- allow_next_instance_of(Gitlab::GitlabImport::Client) do |instance|
- allow(instance).to receive(:get_token).and_return(token)
- end
- stub_omniauth_provider('gitlab')
-
- get :callback
-
- expect(session[:gitlab_access_token]).to eq(token)
- expect(controller).to redirect_to(status_import_gitlab_url)
- end
-
- it "importable_repos should return an array" do
- allow_next_instance_of(Gitlab::GitlabImport::Client) do |instance|
- allow(instance).to receive(:projects).and_return([{ "id": 1 }].to_enum)
- end
-
- expect(controller.send(:importable_repos)).to be_an_instance_of(Array)
- end
-
- it "passes namespace_id query param to status if provided" do
- namespace_id = 30
-
- allow_next_instance_of(Gitlab::GitlabImport::Client) do |instance|
- allow(instance).to receive(:get_token).and_return(token)
- end
-
- get :callback, params: { namespace_id: namespace_id }
-
- expect(controller).to redirect_to(status_import_gitlab_url(namespace_id: namespace_id))
- end
- end
-
- describe "GET status" do
- let(:repo_fake) { Struct.new(:id, :path, :path_with_namespace, :web_url, keyword_init: true) }
- let(:repo) { repo_fake.new(id: 1, path: 'vim', path_with_namespace: 'asd/vim', web_url: 'https://gitlab.com/asd/vim') }
-
- context 'when session contains access token' do
- before do
- assign_session_token
- end
-
- it_behaves_like 'import controller status' do
- let(:repo_id) { repo.id }
- let(:import_source) { repo.path_with_namespace }
- let(:provider_name) { 'gitlab' }
- let(:client_repos_field) { :projects }
- end
- end
-
- it 'redirects to auth if session does not contain access token' do
- remote_gitlab_url = 'https://test.host/auth/gitlab'
-
- allow(Gitlab::GitlabImport::Client)
- .to receive(:new)
- .and_return(double(authorize_url: remote_gitlab_url))
-
- get :status
-
- expect(response).to redirect_to(remote_gitlab_url)
- end
- end
-
- describe "POST create" do
- let(:project) { create(:project) }
- let(:gitlab_username) { user.username }
- let(:gitlab_user) do
- { username: gitlab_username }.with_indifferent_access
- end
-
- let(:gitlab_repo) do
- {
- path: 'vim',
- path_with_namespace: "#{gitlab_username}/vim",
- owner: { name: gitlab_username },
- namespace: { path: gitlab_username }
- }.with_indifferent_access
- end
-
- before do
- stub_client(user: gitlab_user, project: gitlab_repo)
- assign_session_token
- end
-
- it 'returns 200 response when the project is imported successfully' do
- allow(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
- .and_return(double(execute: project))
-
- post :create, format: :json
-
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- it 'returns 422 response when the project could not be imported' do
- allow(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
- .and_return(double(execute: build(:project)))
-
- post :create, format: :json
-
- expect(response).to have_gitlab_http_status(:unprocessable_entity)
- end
-
- context "when the repository owner is the GitLab.com user" do
- context "when the GitLab.com user and GitLab server user's usernames match" do
- it "takes the current user's namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
- .and_return(double(execute: project))
-
- post :create, format: :json
- end
- end
-
- context "when the GitLab.com user and GitLab server user's usernames don't match" do
- let(:gitlab_username) { "someone_else" }
-
- it "takes the current user's namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
- .and_return(double(execute: project))
-
- post :create, format: :json
- end
- end
- end
-
- context "when the repository owner is not the GitLab.com user" do
- let(:other_username) { "someone_else" }
-
- before do
- gitlab_repo["namespace"]["path"] = other_username
- assign_session_token
- end
-
- context "when a namespace with the GitLab.com user's username already exists" do
- let!(:existing_namespace) { create(:group, name: other_username) }
-
- context "when the namespace is owned by the GitLab server user" do
- before do
- existing_namespace.add_owner(user)
- end
-
- it "takes the existing namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, existing_namespace, user, access_params)
- .and_return(double(execute: project))
-
- post :create, format: :json
- end
- end
-
- context "when the namespace is not owned by the GitLab server user" do
- it "doesn't create a project" do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .not_to receive(:new)
-
- post :create, format: :json
- end
- end
- end
-
- context "when a namespace with the GitLab.com user's username doesn't exist" do
- context "when current user can create namespaces" do
- it "creates the namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).and_return(double(execute: project))
-
- expect { post :create, format: :json }.to change(Namespace, :count).by(1)
- end
-
- it "takes the new namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, an_instance_of(Group), user, access_params)
- .and_return(double(execute: project))
-
- post :create, format: :json
- end
- end
-
- context "when current user can't create namespaces" do
- before do
- user.update_attribute(:can_create_group, false)
- end
-
- it "doesn't create the namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).and_return(double(execute: project))
-
- expect { post :create, format: :json }.not_to change(Namespace, :count)
- end
-
- it "takes the current user's namespace" do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
- .and_return(double(execute: project))
-
- post :create, format: :json
- end
- end
- end
-
- context 'user has chosen an existing nested namespace for the project' do
- let(:parent_namespace) { create(:group, name: 'foo') }
- let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) }
-
- before do
- parent_namespace.add_owner(user)
- nested_namespace.add_owner(user)
- end
-
- it 'takes the selected namespace and name' do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, nested_namespace, user, access_params)
- .and_return(double(execute: project))
-
- post :create, params: { target_namespace: nested_namespace.full_path }, format: :json
- end
- end
-
- context 'user has chosen a non-existent nested namespaces for the project' do
- let(:test_name) { 'test_name' }
-
- it 'takes the selected namespace and name' do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
- .and_return(double(execute: project))
-
- post :create, params: { target_namespace: 'foo/bar' }, format: :json
- end
-
- it 'creates the namespaces' do
- allow(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
- .and_return(double(execute: project))
-
- expect { post :create, params: { target_namespace: 'foo/bar' }, format: :json }
- .to change { Namespace.count }.by(2)
- end
-
- it 'new namespace has the right parent' do
- allow(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
- .and_return(double(execute: project))
-
- post :create, params: { target_namespace: 'foo/bar' }, format: :json
-
- expect(Namespace.find_by_path_or_name('bar').parent.path).to eq('foo')
- end
- end
-
- context 'user has chosen existent and non-existent nested namespaces and name for the project' do
- let(:test_name) { 'test_name' }
- let!(:parent_namespace) { create(:group, name: 'foo') }
-
- before do
- parent_namespace.add_owner(user)
- end
-
- it 'takes the selected namespace and name' do
- expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
- .and_return(double(execute: project))
-
- post :create, params: { target_namespace: 'foo/foobar/bar' }, format: :json
- end
-
- it 'creates the namespaces' do
- allow(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).with(gitlab_repo, kind_of(Namespace), user, access_params)
- .and_return(double(execute: project))
-
- expect { post :create, params: { target_namespace: 'foo/foobar/bar' }, format: :json }
- .to change { Namespace.count }.by(2)
- end
- end
-
- context 'when user can not create projects in the chosen namespace' do
- it 'returns 422 response' do
- other_namespace = create(:group, name: 'other_namespace')
-
- post :create, params: { target_namespace: other_namespace.name }, format: :json
-
- expect(response).to have_gitlab_http_status(:unprocessable_entity)
- end
- end
-
- it_behaves_like 'project import rate limiter'
- end
- end
-end
diff --git a/spec/controllers/import/manifest_controller_spec.rb b/spec/controllers/import/manifest_controller_spec.rb
index 6f805b44e89..69eb736375c 100644
--- a/spec/controllers/import/manifest_controller_spec.rb
+++ b/spec/controllers/import/manifest_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::ManifestController, :clean_gitlab_redis_shared_state do
+RSpec.describe Import::ManifestController, :clean_gitlab_redis_shared_state, feature_category: :importers do
include ImportSpecHelper
let_it_be(:user) { create(:user) }
@@ -13,6 +13,8 @@ RSpec.describe Import::ManifestController, :clean_gitlab_redis_shared_state do
end
before do
+ stub_application_setting(import_sources: ['manifest'])
+
sign_in(user)
end
@@ -45,7 +47,7 @@ RSpec.describe Import::ManifestController, :clean_gitlab_redis_shared_state do
end
end
- context 'when the user cannot create projects in the group' do
+ context 'when the user cannot import projects in the group' do
it 'displays an error' do
sign_in(create(:user))
diff --git a/spec/controllers/import/phabricator_controller_spec.rb b/spec/controllers/import/phabricator_controller_spec.rb
deleted file mode 100644
index 9be85a40d82..00000000000
--- a/spec/controllers/import/phabricator_controller_spec.rb
+++ /dev/null
@@ -1,83 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Import::PhabricatorController do
- let(:current_user) { create(:user) }
-
- before do
- sign_in current_user
- end
-
- describe 'GET #new' do
- subject { get :new }
-
- context 'when the import source is not available' do
- before do
- stub_application_setting(import_sources: [])
- end
-
- it { is_expected.to have_gitlab_http_status(:not_found) }
- end
-
- context 'when the import source is available' do
- before do
- stub_application_setting(import_sources: ['phabricator'])
- end
-
- it { is_expected.to have_gitlab_http_status(:ok) }
- end
- end
-
- describe 'POST #create' do
- subject(:post_create) { post :create, params: params }
-
- context 'with valid params' do
- let(:params) do
- { path: 'phab-import',
- name: 'Phab import',
- phabricator_server_url: 'https://phabricator.example.com',
- api_token: 'hazaah',
- namespace_id: current_user.namespace_id }
- end
-
- it 'creates a project to import', :sidekiq_might_not_need_inline do
- expect_next_instance_of(Gitlab::PhabricatorImport::Importer) do |importer|
- expect(importer).to receive(:execute)
- end
-
- expect { post_create }.to change { current_user.namespace.projects.reload.size }.from(0).to(1)
-
- expect(current_user.namespace.projects.last).to be_import
- end
- end
-
- context 'when an import param is missing' do
- let(:params) do
- { path: 'phab-import',
- name: 'Phab import',
- phabricator_server_url: nil,
- api_token: 'hazaah',
- namespace_id: current_user.namespace_id }
- end
-
- it 'does not create the project' do
- expect { post_create }.not_to change { current_user.namespace.projects.reload.size }
- end
- end
-
- context 'when a project param is missing' do
- let(:params) do
- { phabricator_server_url: 'https://phabricator.example.com',
- api_token: 'hazaah',
- namespace_id: current_user.namespace_id }
- end
-
- it 'does not create the project' do
- expect { post_create }.not_to change { current_user.namespace.projects.reload.size }
- end
- end
-
- it_behaves_like 'project import rate limiter'
- end
-end
diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb
index b3b7753df61..f3b21e191c4 100644
--- a/spec/controllers/invites_controller_spec.rb
+++ b/spec/controllers/invites_controller_spec.rb
@@ -192,6 +192,26 @@ RSpec.describe InvitesController do
expect(session[:invite_email]).to eq(member.invite_email)
end
+ context 'with stored location for user' do
+ it 'stores the correct path for user' do
+ request
+
+ expect(controller.stored_location_for(:user)).to eq(activity_project_path(member.source))
+ end
+
+ context 'with relative root' do
+ before do
+ stub_default_url_options(script_name: '/gitlab')
+ end
+
+ it 'stores the correct path for user' do
+ request
+
+ expect(controller.stored_location_for(:user)).to eq(activity_project_path(member.source))
+ end
+ end
+ end
+
context 'when it is part of our invite email experiment' do
let(:extra_params) { { invite_type: 'initial_email' } }
diff --git a/spec/controllers/jira_connect/app_descriptor_controller_spec.rb b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb
index 4f8b2b90637..48b315646de 100644
--- a/spec/controllers/jira_connect/app_descriptor_controller_spec.rb
+++ b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraConnect::AppDescriptorController do
+RSpec.describe JiraConnect::AppDescriptorController, feature_category: :integrations do
describe '#show' do
let(:descriptor) do
json_response.deep_symbolize_keys
diff --git a/spec/controllers/jira_connect/branches_controller_spec.rb b/spec/controllers/jira_connect/branches_controller_spec.rb
index 45daf3b5309..1d3ddc2e33b 100644
--- a/spec/controllers/jira_connect/branches_controller_spec.rb
+++ b/spec/controllers/jira_connect/branches_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraConnect::BranchesController do
+RSpec.describe JiraConnect::BranchesController, feature_category: :integrations do
describe '#new' do
context 'when logged in' do
let_it_be(:user) { create(:user) }
@@ -17,7 +17,7 @@ RSpec.describe JiraConnect::BranchesController do
expect(response).to be_successful
expect(assigns(:new_branch_data)).to include(
initial_branch_name: 'ACME-123-my-issue',
- success_state_svg_path: start_with('/assets/illustrations/merge_requests-')
+ success_state_svg_path: start_with('/assets/illustrations/empty-state/empty-merge-requests-md-')
)
end
diff --git a/spec/controllers/jira_connect/events_controller_spec.rb b/spec/controllers/jira_connect/events_controller_spec.rb
index 7da9eb7ac16..ffad3aa7b02 100644
--- a/spec/controllers/jira_connect/events_controller_spec.rb
+++ b/spec/controllers/jira_connect/events_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraConnect::EventsController do
+RSpec.describe JiraConnect::EventsController, feature_category: :integrations do
shared_examples 'verifies asymmetric JWT token' do
context 'when token is valid' do
include_context 'valid JWT token'
diff --git a/spec/controllers/jira_connect/subscriptions_controller_spec.rb b/spec/controllers/jira_connect/subscriptions_controller_spec.rb
index e9c94f09c99..a05f18f1a16 100644
--- a/spec/controllers/jira_connect/subscriptions_controller_spec.rb
+++ b/spec/controllers/jira_connect/subscriptions_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraConnect::SubscriptionsController do
+RSpec.describe JiraConnect::SubscriptionsController, feature_category: :integrations do
let_it_be(:installation) { create(:jira_connect_installation) }
describe '#index' do
@@ -56,26 +56,6 @@ RSpec.describe JiraConnect::SubscriptionsController do
expect(json_response).to include('subscriptions_path' => jira_connect_subscriptions_path)
end
- context 'when not signed in to GitLab' do
- it 'contains a login path' do
- expect(json_response).to include('login_path' => jira_connect_users_path)
- end
- end
-
- context 'when signed in to GitLab' do
- let(:user) { create(:user) }
-
- before do
- sign_in(user)
-
- get :index, params: { jwt: jwt }
- end
-
- it 'does not contain a login path' do
- expect(json_response).to include('login_path' => nil)
- end
- end
-
context 'with context qsh' do
# The JSON endpoint will be requested by frontend using a JWT that Atlassian provides via Javascript.
# This JWT will likely use a context-qsh because Atlassian don't know for which endpoint it will be used.
diff --git a/spec/controllers/oauth/applications_controller_spec.rb b/spec/controllers/oauth/applications_controller_spec.rb
index 9b16dc9a463..5b9fd192ad4 100644
--- a/spec/controllers/oauth/applications_controller_spec.rb
+++ b/spec/controllers/oauth/applications_controller_spec.rb
@@ -71,6 +71,39 @@ RSpec.describe Oauth::ApplicationsController do
it_behaves_like 'redirects to 2fa setup page when the user requires it'
end
+ describe 'PUT #renew' do
+ let(:oauth_params) do
+ {
+ id: application.id
+ }
+ end
+
+ subject { put :renew, params: oauth_params }
+
+ it { is_expected.to have_gitlab_http_status(:ok) }
+ it { expect { subject }.to change { application.reload.secret } }
+
+ it_behaves_like 'redirects to login page when the user is not signed in'
+ it_behaves_like 'redirects to 2fa setup page when the user requires it'
+
+ it 'returns the secret in json format' do
+ subject
+
+ expect(json_response['secret']).not_to be_nil
+ end
+
+ context 'when renew fails' do
+ before do
+ allow_next_found_instance_of(Doorkeeper::Application) do |application|
+ allow(application).to receive(:save).and_return(false)
+ end
+ end
+
+ it { expect { subject }.not_to change { application.reload.secret } }
+ it { is_expected.to have_gitlab_http_status(:unprocessable_entity) }
+ end
+ end
+
describe 'GET #show' do
subject { get :show, params: { id: application.id } }
@@ -113,30 +146,11 @@ RSpec.describe Oauth::ApplicationsController do
subject { post :create, params: oauth_params }
- context 'when hash_oauth_tokens flag set' do
- before do
- stub_feature_flags(hash_oauth_secrets: true)
- end
-
- it 'creates an application' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template :show
- end
- end
-
- context 'when hash_oauth_tokens flag not set' do
- before do
- stub_feature_flags(hash_oauth_secrets: false)
- end
-
- it 'creates an application' do
- subject
+ it 'creates an application' do
+ subject
- expect(response).to have_gitlab_http_status(:found)
- expect(response).to redirect_to(oauth_application_path(Doorkeeper::Application.last))
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template :show
end
it 'redirects back to profile page if OAuth applications are disabled' do
diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb
index 5185aa64d9f..3476c7b8465 100644
--- a/spec/controllers/oauth/authorizations_controller_spec.rb
+++ b/spec/controllers/oauth/authorizations_controller_spec.rb
@@ -7,8 +7,7 @@ RSpec.describe Oauth::AuthorizationsController do
let(:application_scopes) { 'api read_user' }
let!(:application) do
- create(:oauth_application, scopes: application_scopes,
- redirect_uri: 'http://example.com')
+ create(:oauth_application, scopes: application_scopes, redirect_uri: 'http://example.com')
end
let(:params) do
diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb
index ab3f3fd397d..ebfa48870a9 100644
--- a/spec/controllers/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/omniauth_callbacks_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe OmniauthCallbacksController, type: :controller do
+RSpec.describe OmniauthCallbacksController, type: :controller, feature_category: :system_access do
include LoginHelpers
describe 'omniauth' do
@@ -202,20 +202,30 @@ RSpec.describe OmniauthCallbacksController, type: :controller do
end
end
- context 'when user with 2FA is unconfirmed' do
+ context 'when a user has 2FA enabled' do
render_views
let(:user) { create(:omniauth_user, :two_factor, extern_uid: 'my-uid', provider: provider) }
- before do
- user.update_column(:confirmed_at, nil)
- end
+ context 'when a user is unconfirmed' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'hard')
- it 'redirects to login page' do
- post provider
+ user.update!(confirmed_at: nil)
+ end
+
+ it 'redirects to login page' do
+ post provider
+
+ expect(response).to redirect_to(new_user_session_path)
+ expect(flash[:alert]).to match(/You have to confirm your email address before continuing./)
+ end
+ end
- expect(response).to redirect_to(new_user_session_path)
- expect(flash[:alert]).to match(/You have to confirm your email address before continuing./)
+ context 'when a user is confirmed' do
+ it 'returns 200 response' do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
end
@@ -324,9 +334,10 @@ RSpec.describe OmniauthCallbacksController, type: :controller do
expect(controller).to receive(:atlassian_oauth2).and_wrap_original do |m, *args|
m.call(*args)
- expect(Gitlab::ApplicationContext.current)
- .to include('meta.user' => user.username,
- 'meta.caller_id' => 'OmniauthCallbacksController#atlassian_oauth2')
+ expect(Gitlab::ApplicationContext.current).to include(
+ 'meta.user' => user.username,
+ 'meta.caller_id' => 'OmniauthCallbacksController#atlassian_oauth2'
+ )
end
post :atlassian_oauth2
@@ -419,6 +430,31 @@ RSpec.describe OmniauthCallbacksController, type: :controller do
end
end
+ describe '#openid_connect' do
+ let(:user) { create(:omniauth_user, extern_uid: extern_uid, provider: provider) }
+ let(:extern_uid) { 'my-uid' }
+ let(:provider) { 'openid_connect' }
+
+ before do
+ prepare_provider_route('openid_connect')
+
+ mock_auth_hash(provider, extern_uid, user.email, additional_info: {})
+
+ request.env['devise.mapping'] = Devise.mappings[:user]
+ request.env['omniauth.auth'] = Rails.application.env_config['omniauth.auth']
+ end
+
+ it_behaves_like 'known sign in' do
+ let(:post_action) { post provider }
+ end
+
+ it 'allows sign in' do
+ post provider
+
+ expect(request.env['warden']).to be_authenticated
+ end
+ end
+
describe '#saml' do
let(:last_request_id) { 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685' }
let(:user) { create(:omniauth_user, :two_factor, extern_uid: 'my-uid', provider: 'saml') }
@@ -431,8 +467,12 @@ RSpec.describe OmniauthCallbacksController, type: :controller do
before do
stub_last_request_id(last_request_id)
- stub_omniauth_saml_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'],
- providers: [saml_config])
+ stub_omniauth_saml_config(
+ enabled: true,
+ auto_link_saml_user: true,
+ allow_single_sign_on: ['saml'],
+ providers: [saml_config]
+ )
mock_auth_hash_with_saml_xml('saml', +'my-uid', user.email, mock_saml_response)
request.env['devise.mapping'] = Devise.mappings[:user]
request.env['omniauth.auth'] = Rails.application.env_config['omniauth.auth']
@@ -523,9 +563,10 @@ RSpec.describe OmniauthCallbacksController, type: :controller do
expect(controller).to receive(:saml).and_wrap_original do |m, *args|
m.call(*args)
- expect(Gitlab::ApplicationContext.current)
- .to include('meta.user' => user.username,
- 'meta.caller_id' => 'OmniauthCallbacksController#saml')
+ expect(Gitlab::ApplicationContext.current).to include(
+ 'meta.user' => user.username,
+ 'meta.caller_id' => 'OmniauthCallbacksController#saml'
+ )
end
post :saml, params: { SAMLResponse: mock_saml_response }
diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb
index 9494f55c631..aad946acad4 100644
--- a/spec/controllers/passwords_controller_spec.rb
+++ b/spec/controllers/passwords_controller_spec.rb
@@ -99,8 +99,7 @@ RSpec.describe PasswordsController do
m.call(*args)
expect(Gitlab::ApplicationContext.current)
- .to include('meta.user' => user.username,
- 'meta.caller_id' => 'PasswordsController#update')
+ .to include('meta.user' => user.username, 'meta.caller_id' => 'PasswordsController#update')
end
subject
diff --git a/spec/controllers/profiles/accounts_controller_spec.rb b/spec/controllers/profiles/accounts_controller_spec.rb
index ba349768b0f..f0ee2e178cf 100644
--- a/spec/controllers/profiles/accounts_controller_spec.rb
+++ b/spec/controllers/profiles/accounts_controller_spec.rb
@@ -16,18 +16,16 @@ RSpec.describe Profiles::AccountsController do
expect(response).to have_gitlab_http_status(:not_found)
end
- [:saml, :cas3].each do |provider|
- describe "#{provider} provider" do
- let(:user) { create(:omniauth_user, provider: provider.to_s) }
+ describe "saml provider" do
+ let(:user) { create(:omniauth_user, provider: 'saml') }
- it "does not allow to unlink connected account" do
- identity = user.identities.last
+ it "does not allow to unlink connected account" do
+ identity = user.identities.last
- delete :unlink, params: { provider: provider.to_s }
+ delete :unlink, params: { provider: 'saml' }
- expect(response).to have_gitlab_http_status(:found)
- expect(user.reload.identities).to include(identity)
- end
+ expect(response).to have_gitlab_http_status(:found)
+ expect(user.reload.identities).to include(identity)
end
end
diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb
index e2a216bb462..e2ade5e3de9 100644
--- a/spec/controllers/profiles/preferences_controller_spec.rb
+++ b/spec/controllers/profiles/preferences_controller_spec.rb
@@ -53,8 +53,7 @@ RSpec.describe Profiles::PreferencesController do
first_day_of_week: '1',
preferred_language: 'jp',
tab_width: '5',
- render_whitespace_in_code: 'true',
- use_legacy_web_ide: 'true'
+ render_whitespace_in_code: 'true'
}.with_indifferent_access
expect(user).to receive(:assign_attributes).with(ActionController::Parameters.new(prefs).permit!)
@@ -109,5 +108,33 @@ RSpec.describe Profiles::PreferencesController do
expect(response.parsed_body['type']).to eq('alert')
end
end
+
+ context 'on disable_follow_users feature flag' do
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(disable_follow_users: false)
+ end
+
+ it 'does not update enabled_following preference of user' do
+ prefs = { enabled_following: false }
+
+ go params: prefs
+ user.reload
+
+ expect(user.enabled_following).to eq(true)
+ end
+ end
+
+ context 'with feature flag enabled' do
+ it 'does not update enabled_following preference of user' do
+ prefs = { enabled_following: false }
+
+ go params: prefs
+ user.reload
+
+ expect(user.enabled_following).to eq(false)
+ end
+ end
+ end
end
end
diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
index 7d7cdededdb..dde0af3c543 100644
--- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb
+++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Profiles::TwoFactorAuthsController, feature_category: :authentication_and_authorization do
+RSpec.describe Profiles::TwoFactorAuthsController, feature_category: :system_access do
before do
# `user` should be defined within the action-specific describe blocks
sign_in(user)
diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb
index daf0f36c28b..b1c43a33386 100644
--- a/spec/controllers/profiles_controller_spec.rb
+++ b/spec/controllers/profiles_controller_spec.rb
@@ -11,8 +11,7 @@ RSpec.describe ProfilesController, :request_store do
sign_in(user)
new_password = User.random_password
expect do
- post :update,
- params: { user: { password: new_password, password_confirmation: new_password } }
+ post :update, params: { user: { password: new_password, password_confirmation: new_password } }
end.not_to change { user.reload.encrypted_password }
expect(response).to have_gitlab_http_status(:found)
@@ -23,8 +22,7 @@ RSpec.describe ProfilesController, :request_store do
it 'allows an email update from a user without an external email address' do
sign_in(user)
- put :update,
- params: { user: { email: "john@gmail.com", name: "John", validation_password: password } }
+ put :update, params: { user: { email: "john@gmail.com", name: "John", validation_password: password } }
user.reload
@@ -37,8 +35,7 @@ RSpec.describe ProfilesController, :request_store do
create(:email, :confirmed, user: user, email: 'john@gmail.com')
sign_in(user)
- put :update,
- params: { user: { email: "john@gmail.com", name: "John" } }
+ put :update, params: { user: { email: "john@gmail.com", name: "John" } }
user.reload
@@ -54,8 +51,7 @@ RSpec.describe ProfilesController, :request_store do
ldap_user.create_user_synced_attributes_metadata(provider: 'ldap', name_synced: true, email_synced: true)
sign_in(ldap_user)
- put :update,
- params: { user: { email: "john@gmail.com", name: "John" } }
+ put :update, params: { user: { email: "john@gmail.com", name: "John" } }
ldap_user.reload
@@ -71,8 +67,7 @@ RSpec.describe ProfilesController, :request_store do
ldap_user.create_user_synced_attributes_metadata(provider: 'ldap', name_synced: true, email_synced: true, location_synced: false)
sign_in(ldap_user)
- put :update,
- params: { user: { email: "john@gmail.com", name: "John", location: "City, Country" } }
+ put :update, params: { user: { email: "john@gmail.com", name: "John", location: "City, Country" } }
ldap_user.reload
@@ -85,10 +80,7 @@ RSpec.describe ProfilesController, :request_store do
it 'allows setting a user status', :freeze_time do
sign_in(user)
- put(
- :update,
- params: { user: { status: { message: 'Working hard!', availability: 'busy', clear_status_after: '8_hours' } } }
- )
+ put :update, params: { user: { status: { message: 'Working hard!', availability: 'busy', clear_status_after: '8_hours' } } }
expect(user.reload.status.message).to eq('Working hard!')
expect(user.reload.status.availability).to eq('busy')
@@ -183,22 +175,14 @@ RSpec.describe ProfilesController, :request_store do
end
it 'updates a username using JSON request' do
- put :update_username,
- params: {
- user: { username: new_username }
- },
- format: :json
+ put :update_username, params: { user: { username: new_username } }, format: :json
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['message']).to eq(s_('Profiles|Username successfully changed'))
end
it 'renders an error message when the username was not updated' do
- put :update_username,
- params: {
- user: { username: 'invalid username.git' }
- },
- format: :json
+ put :update_username, params: { user: { username: 'invalid username.git' } }, format: :json
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['message']).to match(/Username change failed/)
diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb
index c707b5dc39d..c7b74b5cf68 100644
--- a/spec/controllers/projects/artifacts_controller_spec.rb
+++ b/spec/controllers/projects/artifacts_controller_spec.rb
@@ -9,11 +9,13 @@ RSpec.describe Projects::ArtifactsController, feature_category: :build_artifacts
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:pipeline, reload: true) do
- create(:ci_pipeline,
- project: project,
- sha: project.commit.sha,
- ref: project.default_branch,
- status: 'success')
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: project.commit.sha,
+ ref: project.default_branch,
+ status: 'success'
+ )
end
let!(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
@@ -25,31 +27,13 @@ RSpec.describe Projects::ArtifactsController, feature_category: :build_artifacts
describe 'GET index' do
subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
- context 'when feature flag is on' do
- render_views
-
- before do
- stub_feature_flags(artifacts_management_page: true)
- end
-
- it 'renders the page with data for the artifacts app' do
- subject
+ render_views
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template('projects/artifacts/index')
- end
- end
-
- context 'when feature flag is off' do
- before do
- stub_feature_flags(artifacts_management_page: false)
- end
-
- it 'renders no content' do
- subject
+ it 'renders the page with data for the artifacts app' do
+ subject
- expect(response).to have_gitlab_http_status(:no_content)
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template('projects/artifacts/index')
end
end
@@ -177,9 +161,10 @@ RSpec.describe Projects::ArtifactsController, feature_category: :build_artifacts
end
it 'sends the codequality report' do
- expect(controller).to receive(:send_file)
- .with(job.job_artifacts_codequality.file.path,
- hash_including(disposition: 'attachment', filename: filename)).and_call_original
+ expect(controller).to receive(:send_file).with(
+ job.job_artifacts_codequality.file.path,
+ hash_including(disposition: 'attachment', filename: filename)
+ ).and_call_original
download_artifact(file_type: file_type)
@@ -557,8 +542,7 @@ RSpec.describe Projects::ArtifactsController, feature_category: :build_artifacts
context 'with regular branch' do
before do
- pipeline.update!(ref: 'master',
- sha: project.commit('master').sha)
+ pipeline.update!(ref: 'master', sha: project.commit('master').sha)
get :latest_succeeded, params: params_from_ref('master')
end
@@ -568,8 +552,7 @@ RSpec.describe Projects::ArtifactsController, feature_category: :build_artifacts
context 'with branch name containing slash' do
before do
- pipeline.update!(ref: 'improve/awesome',
- sha: project.commit('improve/awesome').sha)
+ pipeline.update!(ref: 'improve/awesome', sha: project.commit('improve/awesome').sha)
get :latest_succeeded, params: params_from_ref('improve/awesome')
end
@@ -579,8 +562,7 @@ RSpec.describe Projects::ArtifactsController, feature_category: :build_artifacts
context 'with branch name and path containing slashes' do
before do
- pipeline.update!(ref: 'improve/awesome',
- sha: project.commit('improve/awesome').sha)
+ pipeline.update!(ref: 'improve/awesome', sha: project.commit('improve/awesome').sha)
get :latest_succeeded, params: params_from_ref('improve/awesome', job.name, 'file/README.md')
end
@@ -596,11 +578,13 @@ RSpec.describe Projects::ArtifactsController, feature_category: :build_artifacts
before do
create_file_in_repo(project, 'master', 'master', 'test.txt', 'This is test')
- create(:ci_pipeline,
+ create(
+ :ci_pipeline,
project: project,
sha: project.commit.sha,
ref: project.default_branch,
- status: 'failed')
+ status: 'failed'
+ )
get :latest_succeeded, params: params_from_ref(project.default_branch)
end
diff --git a/spec/controllers/projects/badges_controller_spec.rb b/spec/controllers/projects/badges_controller_spec.rb
index d41e8d6169f..ef2afd7ca38 100644
--- a/spec/controllers/projects/badges_controller_spec.rb
+++ b/spec/controllers/projects/badges_controller_spec.rb
@@ -98,6 +98,16 @@ RSpec.describe Projects::BadgesController do
expect(response.body).to include('123')
end
end
+
+ if badge_type == :release
+ context 'when value_width param is used' do
+ it 'sets custom value width' do
+ get_badge(badge_type, value_width: '123')
+
+ expect(response.body).to include('123')
+ end
+ end
+ end
end
shared_examples 'a badge resource' do |badge_type|
@@ -186,7 +196,7 @@ RSpec.describe Projects::BadgesController do
namespace_id: project.namespace.to_param,
project_id: project,
ref: pipeline.ref
- }.merge(args.slice(:style, :key_text, :key_width, :ignore_skipped))
+ }.merge(args.slice(:style, :key_text, :key_width, :value_width, :ignore_skipped))
get badge, params: params, format: :svg
end
diff --git a/spec/controllers/projects/blame_controller_spec.rb b/spec/controllers/projects/blame_controller_spec.rb
index 62a544bb3fc..50556bdb652 100644
--- a/spec/controllers/projects/blame_controller_spec.rb
+++ b/spec/controllers/projects/blame_controller_spec.rb
@@ -2,9 +2,9 @@
require 'spec_helper'
-RSpec.describe Projects::BlameController do
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
+RSpec.describe Projects::BlameController, feature_category: :source_code_management do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
before do
sign_in(user)
@@ -13,37 +13,55 @@ RSpec.describe Projects::BlameController do
controller.instance_variable_set(:@project, project)
end
- describe "GET show" do
- render_views
-
- before do
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: id
- })
- end
-
- context "valid branch, valid file" do
+ shared_examples 'blame_response' do
+ context 'valid branch, valid file' do
let(:id) { 'master/files/ruby/popen.rb' }
it { is_expected.to respond_with(:success) }
end
- context "valid branch, invalid file" do
+ context 'valid branch, invalid file' do
let(:id) { 'master/files/ruby/invalid-path.rb' }
it 'redirects' do
- expect(subject)
- .to redirect_to("/#{project.full_path}/-/tree/master")
+ expect(subject).to redirect_to("/#{project.full_path}/-/tree/master")
end
end
- context "invalid branch, valid file" do
+ context 'invalid branch, valid file' do
let(:id) { 'invalid-branch/files/ruby/missing_file.rb' }
it { is_expected.to respond_with(:not_found) }
end
end
+
+ describe 'GET show' do
+ render_views
+
+ before do
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: id }
+ end
+
+ it_behaves_like 'blame_response'
+ end
+
+ describe 'GET page' do
+ render_views
+
+ before do
+ get :page, params: { namespace_id: project.namespace, project_id: project, id: id }
+ end
+
+ it_behaves_like 'blame_response'
+ end
+
+ describe 'GET streaming' do
+ render_views
+
+ before do
+ get :streaming, params: { namespace_id: project.namespace, project_id: project, id: id }
+ end
+
+ it_behaves_like 'blame_response'
+ end
end
diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb
index ec92d92e2a9..b07cb7a228d 100644
--- a/spec/controllers/projects/blob_controller_spec.rb
+++ b/spec/controllers/projects/blob_controller_spec.rb
@@ -100,13 +100,7 @@ RSpec.describe Projects::BlobController, feature_category: :source_code_manageme
let(:id) { 'master/README.md' }
before do
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: id
- },
- format: :json)
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: id }, format: :json
end
it do
@@ -120,14 +114,7 @@ RSpec.describe Projects::BlobController, feature_category: :source_code_manageme
let(:id) { 'master/README.md' }
before do
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: id,
- viewer: 'none'
- },
- format: :json)
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: id, viewer: 'none' }, format: :json
end
it do
@@ -140,12 +127,8 @@ RSpec.describe Projects::BlobController, feature_category: :source_code_manageme
context 'with tree path' do
before do
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: id
- })
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: id }
+
controller.instance_variable_set(:@blob, nil)
end
@@ -387,11 +370,22 @@ RSpec.describe Projects::BlobController, feature_category: :source_code_manageme
end
end
- it_behaves_like 'tracking unique hll events' do
+ context 'events tracking' do
+ let(:target_event) { 'g_edit_by_sfe' }
+
subject(:request) { put :update, params: default_params }
- let(:target_event) { 'g_edit_by_sfe' }
- let(:expected_value) { instance_of(Integer) }
+ it_behaves_like 'tracking unique hll events' do
+ let(:expected_value) { instance_of(Integer) }
+ end
+
+ it_behaves_like 'Snowplow event tracking with RedisHLL context' do
+ let(:action) { 'perform_sfe_action' }
+ let(:category) { described_class.to_s }
+ let(:namespace) { project.namespace.reload }
+ let(:property) { target_event }
+ let(:label) { 'usage_activity_by_stage_monthly.create.action_monthly_active_users_sfe_edit' }
+ end
end
end
@@ -519,6 +513,7 @@ RSpec.describe Projects::BlobController, feature_category: :source_code_manageme
describe 'POST create' do
let(:user) { create(:user) }
+ let(:target_event) { 'g_edit_by_sfe' }
let(:default_params) do
{
namespace_id: project.namespace,
@@ -540,10 +535,17 @@ RSpec.describe Projects::BlobController, feature_category: :source_code_manageme
subject(:request) { post :create, params: default_params }
it_behaves_like 'tracking unique hll events' do
- let(:target_event) { 'g_edit_by_sfe' }
let(:expected_value) { instance_of(Integer) }
end
+ it_behaves_like 'Snowplow event tracking with RedisHLL context' do
+ let(:action) { 'perform_sfe_action' }
+ let(:category) { described_class.to_s }
+ let(:namespace) { project.namespace }
+ let(:property) { target_event }
+ let(:label) { 'usage_activity_by_stage_monthly.create.action_monthly_active_users_sfe_edit' }
+ end
+
it 'redirects to blob' do
request
diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb
index dcde22c1fd6..600f8047a1d 100644
--- a/spec/controllers/projects/branches_controller_spec.rb
+++ b/spec/controllers/projects/branches_controller_spec.rb
@@ -22,13 +22,12 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
before do
sign_in(developer)
- post :create,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- branch_name: branch,
- ref: ref
- }
+ post :create, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ branch_name: branch,
+ ref: ref
+ }
end
context "valid branch name, valid source" do
@@ -83,13 +82,12 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
end
it 'redirects' do
- post :create,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- branch_name: branch,
- issue_iid: issue.iid
- }
+ post :create, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ branch_name: branch,
+ issue_iid: issue.iid
+ }
expect(subject)
.to redirect_to("/#{project.full_path}/-/tree/1-feature-branch")
@@ -98,13 +96,12 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
it 'posts a system note' do
expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, developer, "1-feature-branch", branch_project: project)
- post :create,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- branch_name: branch,
- issue_iid: issue.iid
- }
+ post :create, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ branch_name: branch,
+ issue_iid: issue.iid
+ }
end
context 'confidential_issue_project_id is present' do
@@ -167,13 +164,12 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
expect_any_instance_of(::Branches::CreateService).to receive(:execute).and_return(result)
expect(SystemNoteService).to receive(:new_issue_branch).and_return(true)
- post :create,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project.to_param,
- branch_name: branch,
- issue_iid: issue.iid
- }
+ post :create, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ branch_name: branch,
+ issue_iid: issue.iid
+ }
expect(response).to redirect_to project_tree_path(project, branch)
end
@@ -189,13 +185,12 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
expect_any_instance_of(::Branches::CreateService).to receive(:execute).and_return(result)
expect(SystemNoteService).to receive(:new_issue_branch).and_return(true)
- post :create,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project.to_param,
- branch_name: branch,
- issue_iid: issue.iid
- }
+ post :create, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ branch_name: branch,
+ issue_iid: issue.iid
+ }
expect(response.location).to include(project_new_blob_path(project, branch))
expect(response).to have_gitlab_http_status(:found)
@@ -210,13 +205,12 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
expect_any_instance_of(::Branches::CreateService).to receive(:execute).and_return(result)
expect(SystemNoteService).to receive(:new_issue_branch).and_return(true)
- post :create,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project.to_param,
- branch_name: branch,
- issue_iid: issue.iid
- }
+ post :create, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ branch_name: branch,
+ issue_iid: issue.iid
+ }
expect(response.location).to include(project_new_blob_path(project, branch))
expect(response).to have_gitlab_http_status(:found)
@@ -229,13 +223,12 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
it "doesn't post a system note" do
expect(SystemNoteService).not_to receive(:new_issue_branch)
- post :create,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- branch_name: branch,
- issue_iid: issue.iid
- }
+ post :create, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ branch_name: branch,
+ issue_iid: issue.iid
+ }
end
end
@@ -249,13 +242,12 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
it "doesn't post a system note" do
expect(SystemNoteService).not_to receive(:new_issue_branch)
- post :create,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- branch_name: branch,
- issue_iid: issue.iid
- }
+ post :create, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ branch_name: branch,
+ issue_iid: issue.iid
+ }
end
end
end
@@ -285,18 +277,17 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
create_branch name: "<script>alert('merge');</script>", ref: "<script>alert('ref');</script>"
expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(response.body).to include 'Failed to create branch'
end
end
def create_branch(name:, ref:)
- post :create,
- format: :json,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project.to_param,
- branch_name: name,
- ref: ref
- }
+ post :create, format: :json, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ branch_name: name,
+ ref: ref
+ }
end
end
@@ -345,13 +336,11 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
before do
sign_in(developer)
- post :destroy,
- format: format,
- params: {
- id: branch,
- namespace_id: project.namespace,
- project_id: project
- }
+ post :destroy, format: format, params: {
+ id: branch,
+ namespace_id: project.namespace,
+ project_id: project
+ }
end
context 'as JS' do
@@ -445,11 +434,10 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
describe "DELETE destroy_all_merged" do
def destroy_all_merged
- delete :destroy_all_merged,
- params: {
- namespace_id: project.namespace,
- project_id: project
- }
+ delete :destroy_all_merged, params: {
+ namespace_id: project.namespace,
+ project_id: project
+ }
end
context 'when user is allowed to push' do
@@ -492,13 +480,11 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
context 'when rendering a JSON format' do
it 'filters branches by name' do
- get :index,
- format: :json,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- search: 'master'
- }
+ get :index, format: :json, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ search: 'master'
+ }
expect(json_response.length).to eq 1
expect(json_response.first).to eq 'master'
@@ -523,13 +509,11 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
status: :success,
created_at: 2.months.ago)
- get :index,
- format: :html,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- state: 'all'
- }
+ get :index, format: :html, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ state: 'all'
+ }
expect(assigns[:branch_pipeline_statuses]["master"].group).to eq("success")
expect(assigns[:sort]).to eq('updated_desc')
@@ -555,13 +539,11 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
status: :success,
created_at: 2.months.ago)
- get :index,
- format: :html,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- state: 'all'
- }
+ get :index, format: :html, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ state: 'all'
+ }
expect(assigns[:branch_pipeline_statuses]["master"].group).to eq("running")
expect(assigns[:branch_pipeline_statuses]["test"].group).to eq("success")
@@ -570,13 +552,11 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
context 'when a branch contains no pipelines' do
it 'no commit statuses are received' do
- get :index,
- format: :html,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- state: 'stale'
- }
+ get :index, format: :html, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ state: 'stale'
+ }
expect(assigns[:branch_pipeline_statuses]).to be_blank
expect(assigns[:sort]).to eq('updated_asc')
@@ -589,14 +569,12 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
# was not raised whenever the cache is enabled yet cold.
context 'when cache is enabled yet cold', :request_store do
it 'return with a status 200' do
- get :index,
- format: :html,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- sort: 'name_asc',
- state: 'all'
- }
+ get :index, format: :html, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ sort: 'name_asc',
+ state: 'all'
+ }
expect(response).to have_gitlab_http_status(:ok)
expect(assigns[:sort]).to eq('name_asc')
@@ -609,13 +587,11 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
end
it 'return with a status 200' do
- get :index,
- format: :html,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- state: 'all'
- }
+ get :index, format: :html, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ state: 'all'
+ }
expect(response).to have_gitlab_http_status(:ok)
end
@@ -623,37 +599,31 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
context 'when deprecated sort/search/page parameters are specified' do
it 'returns with a status 301 when sort specified' do
- get :index,
- format: :html,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- sort: 'updated_asc'
- }
+ get :index, format: :html, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ sort: 'updated_asc'
+ }
expect(response).to redirect_to project_branches_filtered_path(project, state: 'all')
end
it 'returns with a status 301 when search specified' do
- get :index,
- format: :html,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- search: 'feature'
- }
+ get :index, format: :html, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ search: 'feature'
+ }
expect(response).to redirect_to project_branches_filtered_path(project, state: 'all')
end
it 'returns with a status 301 when page specified' do
- get :index,
- format: :html,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- page: 2
- }
+ get :index, format: :html, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ page: 2
+ }
expect(response).to redirect_to project_branches_filtered_path(project, state: 'all')
end
@@ -747,13 +717,11 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
end
it 'returns the commit counts behind and ahead of default branch' do
- get :diverging_commit_counts,
- format: :json,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- names: %w[fix add-pdf-file branch-merged]
- }
+ get :diverging_commit_counts, format: :json, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ names: %w[fix add-pdf-file branch-merged]
+ }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq(
@@ -766,12 +734,10 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
it 'returns the commits counts with no names provided' do
allow_any_instance_of(Repository).to receive(:branch_count).and_return(Kaminari.config.default_per_page)
- get :diverging_commit_counts,
- format: :json,
- params: {
- namespace_id: project.namespace,
- project_id: project
- }
+ get :diverging_commit_counts, format: :json, params: {
+ namespace_id: project.namespace,
+ project_id: project
+ }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to be > 1
@@ -783,25 +749,21 @@ RSpec.describe Projects::BranchesController, feature_category: :source_code_mana
end
it 'returns 422 if no names are specified' do
- get :diverging_commit_counts,
- format: :json,
- params: {
- namespace_id: project.namespace,
- project_id: project
- }
+ get :diverging_commit_counts, format: :json, params: {
+ namespace_id: project.namespace,
+ project_id: project
+ }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['error']).to eq("Specify at least one and at most #{Kaminari.config.default_per_page} branch names")
end
it 'returns the list of counts' do
- get :diverging_commit_counts,
- format: :json,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- names: %w[fix add-pdf-file branch-merged]
- }
+ get :diverging_commit_counts, format: :json, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ names: %w[fix add-pdf-file branch-merged]
+ }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to be > 1
diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb
index c7d2b1fa3af..f976b5bfe67 100644
--- a/spec/controllers/projects/clusters_controller_spec.rb
+++ b/spec/controllers/projects/clusters_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ClustersController, feature_category: :kubernetes_management do
+RSpec.describe Projects::ClustersController, feature_category: :deployment_management do
include AccessMatchersForController
include GoogleApi::CloudPlatformHelpers
include KubernetesHelpers
@@ -123,7 +123,7 @@ RSpec.describe Projects::ClustersController, feature_category: :kubernetes_manag
{
id: proxyable.id.to_s,
namespace_id: project.namespace.full_path,
- project_id: project.name
+ project_id: project.path
}
end
@@ -171,7 +171,7 @@ RSpec.describe Projects::ClustersController, feature_category: :kubernetes_manag
{
id: cluster.id,
namespace_id: project.namespace.full_path,
- project_id: project.name
+ project_id: project.path
}
end
end
@@ -358,12 +358,6 @@ RSpec.describe Projects::ClustersController, feature_category: :kubernetes_manag
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('cluster_status')
end
-
- it 'invokes schedule_status_update on each application' do
- expect_any_instance_of(Clusters::Applications::Ingress).to receive(:schedule_status_update)
-
- go
- end
end
describe 'security' do
@@ -403,20 +397,37 @@ RSpec.describe Projects::ClustersController, feature_category: :kubernetes_manag
end
describe 'functionality' do
- render_views
+ context 'when remove_monitor_metrics FF is disabled' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
- it "renders view" do
- go
+ render_views
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:cluster)).to eq(cluster)
+ it "renders view" do
+ go
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:cluster)).to eq(cluster)
+ end
+
+ it 'renders integration tab view' do
+ go(tab: 'integrations')
+
+ expect(response).to render_template('clusters/clusters/_integrations')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
- it 'renders integration tab view' do
- go(tab: 'integrations')
+ context 'when remove_monitor_metrics FF is enabled' do
+ render_views
- expect(response).to render_template('clusters/clusters/_integrations')
- expect(response).to have_gitlab_http_status(:ok)
+ it 'renders details tab view', :aggregate_failures do
+ go(tab: 'integrations')
+
+ expect(response).to render_template('clusters/clusters/_details')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
end
@@ -441,11 +452,12 @@ RSpec.describe Projects::ClustersController, feature_category: :kubernetes_manag
describe 'PUT update' do
def go(format: :html)
- put :update, params: params.merge(namespace_id: project.namespace.to_param,
- project_id: project.to_param,
- id: cluster,
- format: format
- )
+ put :update, params: params.merge(
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: cluster,
+ format: format
+ )
end
before do
diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb
index 8d3939d8133..44486d0ed41 100644
--- a/spec/controllers/projects/commit_controller_spec.rb
+++ b/spec/controllers/projects/commit_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::CommitController do
+RSpec.describe Projects::CommitController, feature_category: :source_code_management do
include ProjectForksHelper
let_it_be(:project) { create(:project, :repository) }
@@ -84,22 +84,6 @@ RSpec.describe Projects::CommitController do
expect(response).to be_successful
end
- it 'only loads blobs in the current page' do
- stub_feature_flags(async_commit_diff_files: false)
- stub_const('Projects::CommitController::COMMIT_DIFFS_PER_PAGE', 1)
-
- commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863')
-
- expect_next_instance_of(Repository) do |repository|
- # This commit contains 3 changed files but we expect only the blobs for the first one to be loaded
- expect(repository).to receive(:blobs_at).with([[commit.id, '.gitignore']], anything).and_call_original
- end
-
- go(id: commit.id)
-
- expect(response).to be_ok
- end
-
shared_examples "export as" do |format|
it "does generally work" do
go(id: commit.id, format: format)
@@ -155,12 +139,7 @@ RSpec.describe Projects::CommitController do
let(:commit) { fork_project.commit('remove-submodule') }
it 'renders it' do
- get(:show,
- params: {
- namespace_id: fork_project.namespace,
- project_id: fork_project,
- id: commit.id
- })
+ get :show, params: { namespace_id: fork_project.namespace, project_id: fork_project, id: commit.id }
expect(response).to be_successful
end
@@ -174,10 +153,10 @@ RSpec.describe Projects::CommitController do
go(id: commit.id, merge_request_iid: merge_request.iid)
expect(assigns(:new_diff_note_attrs)).to eq({
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- commit_id: commit.id
- })
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ commit_id: commit.id
+ })
expect(response).to be_ok
end
end
@@ -187,12 +166,7 @@ RSpec.describe Projects::CommitController do
it 'contains branch and tags information' do
commit = project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
- get(:branches,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: commit.id
- })
+ get :branches, params: { namespace_id: project.namespace, project_id: project, id: commit.id }
expect(assigns(:branches)).to include('master', 'feature_conflict')
expect(assigns(:branches_limit_exceeded)).to be_falsey
@@ -205,12 +179,7 @@ RSpec.describe Projects::CommitController do
allow_any_instance_of(Repository).to receive(:branch_count).and_return(1001)
allow_any_instance_of(Repository).to receive(:tag_count).and_return(1001)
- get(:branches,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: commit.id
- })
+ get :branches, params: { namespace_id: project.namespace, project_id: project, id: commit.id }
expect(assigns(:branches)).to eq([])
expect(assigns(:branches_limit_exceeded)).to be_truthy
@@ -234,12 +203,7 @@ RSpec.describe Projects::CommitController do
describe 'POST revert' do
context 'when target branch is not provided' do
it 'renders the 404 page' do
- post(:revert,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: commit.id
- })
+ post :revert, params: { namespace_id: project.namespace, project_id: project, id: commit.id }
expect(response).not_to be_successful
expect(response).to have_gitlab_http_status(:not_found)
@@ -248,13 +212,7 @@ RSpec.describe Projects::CommitController do
context 'when the revert commit is missing' do
it 'renders the 404 page' do
- post(:revert,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- start_branch: 'master',
- id: '1234567890'
- })
+ post :revert, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: '1234567890' }
expect(response).not_to be_successful
expect(response).to have_gitlab_http_status(:not_found)
@@ -263,13 +221,7 @@ RSpec.describe Projects::CommitController do
context 'when the revert was successful' do
it 'redirects to the commits page' do
- post(:revert,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- start_branch: 'master',
- id: commit.id
- })
+ post :revert, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: commit.id }
expect(response).to redirect_to project_commits_path(project, 'master')
expect(flash[:notice]).to eq('The commit has been successfully reverted.')
@@ -278,27 +230,53 @@ RSpec.describe Projects::CommitController do
context 'when the revert failed' do
before do
- post(:revert,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- start_branch: 'master',
- id: commit.id
- })
+ post :revert, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: commit.id }
end
it 'redirects to the commit page' do
# Reverting a commit that has been already reverted.
- post(:revert,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- start_branch: 'master',
- id: commit.id
- })
+ post :revert, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: commit.id }
expect(response).to redirect_to project_commit_path(project, commit.id)
- expect(flash[:alert]).to match('Sorry, we cannot revert this commit automatically.')
+ expect(flash[:alert]).to match('Commit revert failed:')
+ end
+ end
+
+ context 'in the context of a merge_request' do
+ let(:merge_request) { create(:merge_request, :merged, source_project: project) }
+ let(:repository) { project.repository }
+
+ before do
+ merge_commit_id = repository.merge(user,
+ merge_request.diff_head_sha,
+ merge_request,
+ 'Test message')
+
+ repository.commit(merge_commit_id)
+ merge_request.update!(merge_commit_sha: merge_commit_id)
+ end
+
+ context 'when the revert was successful' do
+ it 'redirects to the merge request page' do
+ post :revert, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: merge_request.merge_commit_sha }
+
+ expect(response).to redirect_to project_merge_request_path(project, merge_request)
+ expect(flash[:notice]).to eq('The merge request has been successfully reverted.')
+ end
+ end
+
+ context 'when the revert failed' do
+ before do
+ post :revert, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: merge_request.merge_commit_sha }
+ end
+
+ it 'redirects to the merge request page' do
+ # Reverting a merge request that has been already reverted.
+ post :revert, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: merge_request.merge_commit_sha }
+
+ expect(response).to redirect_to project_merge_request_path(project, merge_request)
+ expect(flash[:alert]).to match('Merge request revert failed:')
+ end
end
end
end
@@ -306,12 +284,7 @@ RSpec.describe Projects::CommitController do
describe 'POST cherry_pick' do
context 'when target branch is not provided' do
it 'renders the 404 page' do
- post(:cherry_pick,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: master_pickable_commit.id
- })
+ post :cherry_pick, params: { namespace_id: project.namespace, project_id: project, id: master_pickable_commit.id }
expect(response).not_to be_successful
expect(response).to have_gitlab_http_status(:not_found)
@@ -320,13 +293,7 @@ RSpec.describe Projects::CommitController do
context 'when the cherry-pick commit is missing' do
it 'renders the 404 page' do
- post(:cherry_pick,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- start_branch: 'master',
- id: '1234567890'
- })
+ post :cherry_pick, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: '1234567890' }
expect(response).not_to be_successful
expect(response).to have_gitlab_http_status(:not_found)
@@ -335,13 +302,7 @@ RSpec.describe Projects::CommitController do
context 'when the cherry-pick was successful' do
it 'redirects to the commits page' do
- post(:cherry_pick,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- start_branch: 'master',
- id: master_pickable_commit.id
- })
+ post :cherry_pick, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: master_pickable_commit.id }
expect(response).to redirect_to project_commits_path(project, 'master')
expect(flash[:notice]).to eq('The commit has been successfully cherry-picked into master.')
@@ -350,27 +311,52 @@ RSpec.describe Projects::CommitController do
context 'when the cherry_pick failed' do
before do
- post(:cherry_pick,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- start_branch: 'master',
- id: master_pickable_commit.id
- })
+ post :cherry_pick, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: master_pickable_commit.id }
end
it 'redirects to the commit page' do
# Cherry-picking a commit that has been already cherry-picked.
- post(:cherry_pick,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- start_branch: 'master',
- id: master_pickable_commit.id
- })
+ post :cherry_pick, params: { namespace_id: project.namespace, project_id: project, start_branch: 'master', id: master_pickable_commit.id }
expect(response).to redirect_to project_commit_path(project, master_pickable_commit.id)
- expect(flash[:alert]).to match('Sorry, we cannot cherry-pick this commit automatically.')
+ expect(flash[:alert]).to match('Commit cherry-pick failed:')
+ end
+ end
+
+ context 'in the context of a merge_request' do
+ let(:merge_request) { create(:merge_request, :merged, source_project: project) }
+ let(:repository) { project.repository }
+
+ before do
+ merge_commit_id = repository.merge(user,
+ merge_request.diff_head_sha,
+ merge_request,
+ 'Test message')
+ repository.commit(merge_commit_id)
+ merge_request.update!(merge_commit_sha: merge_commit_id)
+ end
+
+ context 'when the cherry_pick was successful' do
+ it 'redirects to the merge request page' do
+ post :cherry_pick, params: { namespace_id: project.namespace, project_id: project, start_branch: 'merge-test', id: merge_request.merge_commit_sha }
+
+ expect(response).to redirect_to project_merge_request_path(project, merge_request)
+ expect(flash[:notice]).to eq('The merge request has been successfully cherry-picked into merge-test.')
+ end
+ end
+
+ context 'when the cherry_pick failed' do
+ before do
+ post :cherry_pick, params: { namespace_id: project.namespace, project_id: project, start_branch: 'merge-test', id: merge_request.merge_commit_sha }
+ end
+
+ it 'redirects to the merge request page' do
+ # Reverting a merge request that has been already cherry-picked.
+ post :cherry_pick, params: { namespace_id: project.namespace, project_id: project, start_branch: 'merge-test', id: merge_request.merge_commit_sha }
+
+ expect(response).to redirect_to project_merge_request_path(project, merge_request)
+ expect(flash[:alert]).to match('Merge request cherry-pick failed:')
+ end
end
end
@@ -381,15 +367,14 @@ RSpec.describe Projects::CommitController do
let(:create_merge_request) { nil }
def send_request
- post(:cherry_pick,
- params: {
- namespace_id: forked_project.namespace,
- project_id: forked_project,
- target_project_id: target_project.id,
- start_branch: 'feature',
- id: forked_project.commit.id,
- create_merge_request: create_merge_request
- })
+ post :cherry_pick, params: {
+ namespace_id: forked_project.namespace,
+ project_id: forked_project,
+ target_project_id: target_project.id,
+ start_branch: 'feature',
+ id: forked_project.commit.id,
+ create_merge_request: create_merge_request
+ }
end
def merge_request_url(source_project, branch)
@@ -458,6 +443,37 @@ RSpec.describe Projects::CommitController do
end
end
+ describe 'GET #diff_files' do
+ subject(:send_request) { get :diff_files, params: params }
+
+ let(:format) { :html }
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: commit.id,
+ format: format
+ }
+ end
+
+ it 'renders diff files' do
+ send_request
+
+ expect(assigns(:diffs)).to be_a(Gitlab::Diff::FileCollection::Commit)
+ expect(assigns(:environment)).to be_nil
+ end
+
+ context 'when format is not html' do
+ let(:format) { :json }
+
+ it 'returns 404 page' do
+ send_request
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
describe 'GET diff_for_path' do
def diff_for_path(extra_params = {})
params = {
@@ -478,8 +494,7 @@ RSpec.describe Projects::CommitController do
diff_for_path(id: commit2.id, old_path: existing_path, new_path: existing_path)
expect(assigns(:diff_notes_disabled)).to be_falsey
- expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'Commit',
- commit_id: commit2.id)
+ expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'Commit', commit_id: commit2.id)
end
it 'only renders the diffs for the path given' do
diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb
index 67aa82dacbb..956167ce838 100644
--- a/spec/controllers/projects/commits_controller_spec.rb
+++ b/spec/controllers/projects/commits_controller_spec.rb
@@ -3,8 +3,9 @@
require 'spec_helper'
RSpec.describe Projects::CommitsController, feature_category: :source_code_management do
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:repository) { project.repository }
+ let_it_be(:user) { create(:user) }
before do
project.add_maintainer(user)
@@ -18,11 +19,7 @@ RSpec.describe Projects::CommitsController, feature_category: :source_code_manag
describe "GET commits_root" do
context "no ref is provided" do
it 'redirects to the default branch of the project' do
- get(:commits_root,
- params: {
- namespace_id: project.namespace,
- project_id: project
- })
+ get :commits_root, params: { namespace_id: project.namespace, project_id: project }
expect(response).to redirect_to project_commits_path(project)
end
@@ -34,12 +31,7 @@ RSpec.describe Projects::CommitsController, feature_category: :source_code_manag
context 'with file path' do
before do
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: id
- })
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: id }
end
context "valid branch, valid file" do
@@ -48,6 +40,12 @@ RSpec.describe Projects::CommitsController, feature_category: :source_code_manag
it { is_expected.to respond_with(:success) }
end
+ context "HEAD, valid file" do
+ let(:id) { 'HEAD/README.md' }
+
+ it { is_expected.to respond_with(:success) }
+ end
+
context "valid branch, invalid file" do
let(:id) { 'master/invalid-path.rb' }
@@ -78,13 +76,7 @@ RSpec.describe Projects::CommitsController, feature_category: :source_code_manag
offset: 0
).and_call_original
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: id,
- limit: "foo"
- })
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: id, limit: "foo" }
expect(response).to be_successful
end
@@ -98,27 +90,44 @@ RSpec.describe Projects::CommitsController, feature_category: :source_code_manag
offset: 0
).and_call_original
- get(:show, params: {
+ get :show, params: {
namespace_id: project.namespace,
project_id: project,
id: id,
limit: { 'broken' => 'value' }
- })
+ }
expect(response).to be_successful
end
end
end
+ it 'loads tags for commits' do
+ expect_next_instance_of(CommitCollection) do |collection|
+ expect(collection).to receive(:load_tags)
+ end
+
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: 'master/README.md' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'when tag has a non-ASCII encoding' do
+ before do
+ repository.add_tag(user, 'tést', 'master')
+ end
+
+ it 'does not raise an exception' do
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: 'master' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
context "when the ref name ends in .atom" do
context "when the ref does not exist with the suffix" do
before do
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: "master.atom"
- })
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: "master.atom" }
end
it "renders as atom" do
@@ -138,12 +147,11 @@ RSpec.describe Projects::CommitsController, feature_category: :source_code_manag
allow_any_instance_of(Repository).to receive(:commit).and_call_original
allow_any_instance_of(Repository).to receive(:commit).with('master.atom').and_return(commit)
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: "master.atom"
- })
+ get :show, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: "master.atom"
+ }
end
it "renders as HTML" do
@@ -182,13 +190,11 @@ RSpec.describe Projects::CommitsController, feature_category: :source_code_manag
before do
expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original unless id.include?(' ')
- get(:signatures,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: id
- },
- format: :json)
+ get :signatures, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: id
+ }, format: :json
end
context "valid branch" do
diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb
index 3751b89951c..a49f8b51c12 100644
--- a/spec/controllers/projects/compare_controller_spec.rb
+++ b/spec/controllers/projects/compare_controller_spec.rb
@@ -284,14 +284,18 @@ RSpec.describe Projects::CompareController do
let(:to_ref) { '5937ac0a7beb003549fc5fd26fc247adbce4a52e' }
let(:page) { 1 }
- it 'shows the diff' do
- show_request
+ shared_examples 'valid compare page' do
+ it 'shows the diff' do
+ show_request
- expect(response).to be_successful
- expect(assigns(:diffs).diff_files.first).to be_present
- expect(assigns(:commits).length).to be >= 1
+ expect(response).to be_successful
+ expect(assigns(:diffs).diff_files.first).to be_present
+ expect(assigns(:commits).length).to be >= 1
+ end
end
+ it_behaves_like 'valid compare page'
+
it 'only loads blobs in the current page' do
stub_const('Projects::CompareController::COMMIT_DIFFS_PER_PAGE', 1)
@@ -306,6 +310,19 @@ RSpec.describe Projects::CompareController do
expect(response).to be_successful
end
+
+ context 'when from_ref is HEAD ref' do
+ let(:from_ref) { 'HEAD' }
+ let(:to_ref) { 'feature' } # Need to change to_ref too so there's something to compare with HEAD
+
+ it_behaves_like 'valid compare page'
+ end
+
+ context 'when to_ref is HEAD ref' do
+ let(:to_ref) { 'HEAD' }
+
+ it_behaves_like 'valid compare page'
+ end
end
context 'when page is not valid' do
diff --git a/spec/controllers/projects/cycle_analytics_controller_spec.rb b/spec/controllers/projects/cycle_analytics_controller_spec.rb
index 034e6104f99..4ff8c21706b 100644
--- a/spec/controllers/projects/cycle_analytics_controller_spec.rb
+++ b/spec/controllers/projects/cycle_analytics_controller_spec.rb
@@ -15,11 +15,7 @@ RSpec.describe Projects::CycleAnalyticsController do
it 'increases the counter' do
expect(Gitlab::UsageDataCounters::CycleAnalyticsCounter).to receive(:count).with(:views)
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project
- })
+ get :show, params: { namespace_id: project.namespace, project_id: project }
expect(response).to be_successful
end
@@ -35,7 +31,6 @@ RSpec.describe Projects::CycleAnalyticsController do
subject { get :show, params: request_params, format: :html }
let(:request_params) { { namespace_id: project.namespace, project_id: project } }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'perform_analytics_usage_action' }
let(:namespace) { project.namespace }
diff --git a/spec/controllers/projects/deploy_keys_controller_spec.rb b/spec/controllers/projects/deploy_keys_controller_spec.rb
index ec63bad22b5..52a605cf548 100644
--- a/spec/controllers/projects/deploy_keys_controller_spec.rb
+++ b/spec/controllers/projects/deploy_keys_controller_spec.rb
@@ -276,9 +276,9 @@ RSpec.describe Projects::DeployKeysController do
let(:extra_params) { {} }
subject do
- put :update, params: extra_params.reverse_merge(id: deploy_key.id,
- namespace_id: project.namespace,
- project_id: project)
+ put :update, params: extra_params.reverse_merge(
+ id: deploy_key.id, namespace_id: project.namespace, project_id: project
+ )
end
def deploy_key_params(title, can_push)
@@ -330,9 +330,7 @@ RSpec.describe Projects::DeployKeysController do
context 'when a different deploy key id param is injected' do
let(:extra_params) { deploy_key_params('updated title', '1') }
let(:hacked_params) do
- extra_params.reverse_merge(id: other_deploy_key_id,
- namespace_id: project.namespace,
- project_id: project)
+ extra_params.reverse_merge(id: other_deploy_key_id, namespace_id: project.namespace, project_id: project)
end
subject { put :update, params: hacked_params }
diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb
index c6532e83441..a696eb933e9 100644
--- a/spec/controllers/projects/deployments_controller_spec.rb
+++ b/spec/controllers/projects/deployments_controller_spec.rb
@@ -210,8 +210,6 @@ RSpec.describe Projects::DeploymentsController do
end
def deployment_params(opts = {})
- opts.reverse_merge(namespace_id: project.namespace,
- project_id: project,
- environment_id: environment.id)
+ opts.reverse_merge(namespace_id: project.namespace, project_id: project, environment_id: environment.id)
end
end
diff --git a/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb b/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb
index 2d39e0e5317..a7f3212a6f9 100644
--- a/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb
+++ b/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb
@@ -80,8 +80,12 @@ RSpec.describe Projects::DesignManagement::Designs::RawImagesController do
let(:oldest_version) { design.versions.ordered.last }
shared_examples 'a successful request for sha' do
+ before do
+ allow(DesignManagement::GitRepository).to receive(:new).and_call_original
+ end
+
it do
- expect_next_instance_of(DesignManagement::Repository) do |repository|
+ expect_next_instance_of(DesignManagement::GitRepository) do |repository|
expect(repository).to receive(:blob_at).with(expected_ref, design.full_path).and_call_original
end
diff --git a/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb b/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb
index 5cc6e1b1bb4..1bb5112681c 100644
--- a/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb
+++ b/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb
@@ -139,10 +139,13 @@ RSpec.describe Projects::DesignManagement::Designs::ResizedImageController, feat
let(:sha) { newest_version.sha }
before do
- create(:design, :with_smaller_image_versions,
- issue: create(:issue, project: project),
- versions_count: 1,
- versions_sha: sha)
+ create(
+ :design,
+ :with_smaller_image_versions,
+ issue: create(:issue, project: project),
+ versions_count: 1,
+ versions_sha: sha
+ )
end
it 'serves the newest image' do
diff --git a/spec/controllers/projects/environments/prometheus_api_controller_spec.rb b/spec/controllers/projects/environments/prometheus_api_controller_spec.rb
index 6b0c164e432..ef2d743c82f 100644
--- a/spec/controllers/projects/environments/prometheus_api_controller_spec.rb
+++ b/spec/controllers/projects/environments/prometheus_api_controller_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe Projects::Environments::PrometheusApiController do
{
id: proxyable.id.to_s,
namespace_id: project.namespace.full_path,
- project_id: project.name
+ project_id: project.path
}
end
diff --git a/spec/controllers/projects/environments/sample_metrics_controller_spec.rb b/spec/controllers/projects/environments/sample_metrics_controller_spec.rb
index 14e3ded76f2..b266c569edd 100644
--- a/spec/controllers/projects/environments/sample_metrics_controller_spec.rb
+++ b/spec/controllers/projects/environments/sample_metrics_controller_spec.rb
@@ -46,7 +46,7 @@ RSpec.describe Projects::Environments::SampleMetricsController do
{
id: environment.id.to_s,
namespace_id: project.namespace.full_path,
- project_id: project.name,
+ project_id: project.path,
identifier: 'sample_metric_query_result',
start: '2019-12-02T23:31:45.000Z',
end: '2019-12-03T00:01:45.000Z'
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index 169fed1ab17..f097d08fe1b 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -15,6 +15,7 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
let!(:environment) { create(:environment, name: 'production', project: project) }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
sign_in(user)
end
@@ -44,17 +45,9 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
allow_any_instance_of(Environment).to receive(:has_terminals?).and_return(true)
allow_any_instance_of(Environment).to receive(:rollout_status).and_return(kube_deployment_rollout_status)
- create(:environment, project: project,
- name: 'staging/review-1',
- state: :available)
-
- create(:environment, project: project,
- name: 'staging/review-2',
- state: :available)
-
- create(:environment, project: project,
- name: 'staging/review-3',
- state: :stopped)
+ create(:environment, project: project, name: 'staging/review-1', state: :available)
+ create(:environment, project: project, name: 'staging/review-2', state: :available)
+ create(:environment, project: project, name: 'staging/review-3', state: :stopped)
end
let(:environments) { json_response['environments'] }
@@ -84,9 +77,7 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
it 'ignores search option if is shorter than a minimum' do
get :index, params: environment_params(format: :json, search: 'st')
- expect(environments.map { |env| env['name'] }).to contain_exactly('production',
- 'staging/review-1',
- 'staging/review-2')
+ expect(environments.map { |env| env['name'] }).to contain_exactly('production', 'staging/review-1', 'staging/review-2')
expect(json_response['available_count']).to eq 3
expect(json_response['stopped_count']).to eq 1
end
@@ -96,9 +87,7 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
get :index, params: environment_params(format: :json, search: 'review')
- expect(environments.map { |env| env['name'] }).to contain_exactly('review-app',
- 'staging/review-1',
- 'staging/review-2')
+ expect(environments.map { |env| env['name'] }).to contain_exactly('review-app', 'staging/review-1', 'staging/review-2')
expect(json_response['available_count']).to eq 3
expect(json_response['stopped_count']).to eq 1
end
@@ -245,23 +234,18 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
context 'when using JSON format' do
before do
- create(:environment, project: project,
- name: 'staging-1.0/review',
- state: :available)
- create(:environment, project: project,
- name: 'staging-1.0/zzz',
- state: :available)
+ create(:environment, project: project, name: 'staging-1.0/review', state: :available)
+ create(:environment, project: project, name: 'staging-1.0/zzz', state: :available)
end
let(:environments) { json_response['environments'] }
it 'sorts the subfolders lexicographically' do
get :folder, params: {
- namespace_id: project.namespace,
- project_id: project,
- id: 'staging-1.0'
- },
- format: :json
+ namespace_id: project.namespace,
+ project_id: project,
+ id: 'staging-1.0'
+ }, format: :json
expect(response).to be_ok
expect(response).not_to render_template 'folder'
@@ -560,6 +544,18 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
expect(response).to redirect_to(project_metrics_dashboard_path(project))
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 404 not found' do
+ get :metrics_redirect, params: { namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
describe 'GET #metrics' do
@@ -631,6 +627,20 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
expect(response).to redirect_to(project_metrics_dashboard_path(project, environment: environment))
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 404 not found' do
+ expect(environment).not_to receive(:metrics)
+
+ get :metrics, params: environment_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
describe 'GET #additional_metrics' do
@@ -726,6 +736,18 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
expect(response).to have_gitlab_http_status(:ok)
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 404 not found' do
+ additional_metrics(window_params)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
describe 'GET #metrics_dashboard' do
@@ -1016,98 +1038,8 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
end
end
- describe '#append_info_to_payload' do
- let(:search_param) { 'my search param' }
-
- context 'when search_environment_logging feature is disabled' do
- before do
- stub_feature_flags(environments_search_logging: false)
- end
-
- it 'does not log search params in meta.environment.search' do
- expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload|
- method.call(payload)
-
- expect(payload[:metadata]).not_to have_key('meta.environment.search')
- expect(payload[:action]).to eq("search")
- expect(payload[:controller]).to eq("Projects::EnvironmentsController")
- end
-
- get :search, params: environment_params(format: :json, search: search_param)
- end
-
- it 'logs params correctly when search params are missing' do
- expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload|
- method.call(payload)
-
- expect(payload[:metadata]).not_to have_key('meta.environment.search')
- expect(payload[:action]).to eq("search")
- expect(payload[:controller]).to eq("Projects::EnvironmentsController")
- end
-
- get :search, params: environment_params(format: :json)
- end
-
- it 'logs params correctly when search params is empty string' do
- expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload|
- method.call(payload)
-
- expect(payload[:metadata]).not_to have_key('meta.environment.search')
- expect(payload[:action]).to eq("search")
- expect(payload[:controller]).to eq("Projects::EnvironmentsController")
- end
-
- get :search, params: environment_params(format: :json, search: "")
- end
- end
-
- context 'when search_environment_logging feature is enabled' do
- before do
- stub_feature_flags(environments_search_logging: true)
- end
-
- it 'logs search params in meta.environment.search' do
- expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload|
- method.call(payload)
-
- expect(payload[:metadata]['meta.environment.search']).to eq(search_param)
- expect(payload[:action]).to eq("search")
- expect(payload[:controller]).to eq("Projects::EnvironmentsController")
- end
-
- get :search, params: environment_params(format: :json, search: search_param)
- end
-
- it 'logs params correctly when search params are missing' do
- expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload|
- method.call(payload)
-
- expect(payload[:metadata]).not_to have_key('meta.environment.search')
- expect(payload[:action]).to eq("search")
- expect(payload[:controller]).to eq("Projects::EnvironmentsController")
- end
-
- get :search, params: environment_params(format: :json)
- end
-
- it 'logs params correctly when search params is empty string' do
- expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload|
- method.call(payload)
-
- expect(payload[:metadata]).not_to have_key('meta.environment.search')
- expect(payload[:action]).to eq("search")
- expect(payload[:controller]).to eq("Projects::EnvironmentsController")
- end
-
- get :search, params: environment_params(format: :json, search: "")
- end
- end
- end
-
def environment_params(opts = {})
- opts.reverse_merge(namespace_id: project.namespace,
- project_id: project,
- id: environment.id)
+ opts.reverse_merge(namespace_id: project.namespace, project_id: project, id: environment.id)
end
def additional_metrics(opts = {})
diff --git a/spec/controllers/projects/feature_flags_controller_spec.rb b/spec/controllers/projects/feature_flags_controller_spec.rb
index 29ad51d590f..ac2e4233709 100644
--- a/spec/controllers/projects/feature_flags_controller_spec.rb
+++ b/spec/controllers/projects/feature_flags_controller_spec.rb
@@ -193,8 +193,7 @@ RSpec.describe Projects::FeatureFlagsController do
it 'routes based on iid' do
other_project = create(:project)
other_project.add_developer(user)
- other_feature_flag = create(:operations_feature_flag, project: other_project,
- name: 'other_flag')
+ other_feature_flag = create(:operations_feature_flag, project: other_project, name: 'other_flag')
params = {
namespace_id: other_project.namespace,
project_id: other_project,
@@ -485,8 +484,7 @@ RSpec.describe Projects::FeatureFlagsController do
context 'when creating a version 2 feature flag with a gitlabUserList strategy' do
let!(:user_list) do
- create(:operations_feature_flag_user_list, project: project,
- name: 'My List', user_xids: 'user1,user2')
+ create(:operations_feature_flag_user_list, project: project, name: 'My List', user_xids: 'user1,user2')
end
let(:params) do
@@ -627,10 +625,7 @@ RSpec.describe Projects::FeatureFlagsController do
context 'with a version 2 feature flag' do
let!(:new_version_flag) do
- create(:operations_feature_flag,
- name: 'new-feature',
- active: true,
- project: project)
+ create(:operations_feature_flag, name: 'new-feature', active: true, project: project)
end
it 'creates a new strategy and scope' do
diff --git a/spec/controllers/projects/find_file_controller_spec.rb b/spec/controllers/projects/find_file_controller_spec.rb
index a6c71cff74b..68810bae368 100644
--- a/spec/controllers/projects/find_file_controller_spec.rb
+++ b/spec/controllers/projects/find_file_controller_spec.rb
@@ -18,12 +18,7 @@ RSpec.describe Projects::FindFileController do
render_views
before do
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: id
- })
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: id }
end
context "valid branch" do
@@ -41,13 +36,7 @@ RSpec.describe Projects::FindFileController do
describe "GET #list" do
def go(format: 'json')
- get :list,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: id
- },
- format: format
+ get :list, params: { namespace_id: project.namespace, project_id: project, id: id }, format: format
end
context "valid branch" do
diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb
index 25c722173c1..3ea7054a64c 100644
--- a/spec/controllers/projects/forks_controller_spec.rb
+++ b/spec/controllers/projects/forks_controller_spec.rb
@@ -168,12 +168,7 @@ RSpec.describe Projects::ForksController, feature_category: :source_code_managem
let(:format) { :html }
subject(:do_request) do
- get :new,
- format: format,
- params: {
- namespace_id: project.namespace,
- project_id: project
- }
+ get :new, format: format, params: { namespace_id: project.namespace, project_id: project }
end
context 'when user is signed in' do
diff --git a/spec/controllers/projects/grafana_api_controller_spec.rb b/spec/controllers/projects/grafana_api_controller_spec.rb
index 90ab49f9467..fa20fc5037f 100644
--- a/spec/controllers/projects/grafana_api_controller_spec.rb
+++ b/spec/controllers/projects/grafana_api_controller_spec.rb
@@ -15,6 +15,7 @@ RSpec.describe Projects::GrafanaApiController, feature_category: :metrics do
end
before do
+ stub_feature_flags(remove_monitor_metrics: false)
sign_in(user) if user
end
@@ -23,7 +24,7 @@ RSpec.describe Projects::GrafanaApiController, feature_category: :metrics do
let(:params) do
{
namespace_id: project.namespace.full_path,
- project_id: project.name,
+ project_id: project.path,
proxy_path: 'api/v1/query_range',
datasource_id: '1',
query: 'rate(relevant_metric)',
@@ -87,13 +88,15 @@ RSpec.describe Projects::GrafanaApiController, feature_category: :metrics do
it 'returns a grafana datasource response' do
get :proxy, params: params
- expect(Grafana::ProxyService)
- .to have_received(:new)
- .with(project, '1', 'api/v1/query_range',
- { 'query' => params[:query],
- 'start' => params[:start_time],
- 'end' => params[:end_time],
- 'step' => params[:step] })
+ expect(Grafana::ProxyService).to have_received(:new).with(
+ project, '1', 'api/v1/query_range',
+ {
+ 'query' => params[:query],
+ 'start' => params[:start_time],
+ 'end' => params[:end_time],
+ 'step' => params[:step]
+ }
+ )
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq({})
@@ -168,6 +171,14 @@ RSpec.describe Projects::GrafanaApiController, feature_category: :metrics do
it_behaves_like 'accessible'
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it_behaves_like 'not accessible'
+ end
end
describe 'GET #metrics_dashboard' do
@@ -178,7 +189,7 @@ RSpec.describe Projects::GrafanaApiController, feature_category: :metrics do
embedded: true,
grafana_url: 'https://grafana.example.com',
namespace_id: project.namespace.full_path,
- project_id: project.name
+ project_id: project.path
}
end
diff --git a/spec/controllers/projects/graphs_controller_spec.rb b/spec/controllers/projects/graphs_controller_spec.rb
index 1e9d999311a..3e5bcbbc9ba 100644
--- a/spec/controllers/projects/graphs_controller_spec.rb
+++ b/spec/controllers/projects/graphs_controller_spec.rb
@@ -141,7 +141,6 @@ RSpec.describe Projects::GraphsController do
end
let(:request_params) { { namespace_id: project.namespace.path, project_id: project.path, id: 'master' } }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'perform_analytics_usage_action' }
let(:namespace) { project.namespace }
diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb
index a5c00d24e30..2075dd3e7a7 100644
--- a/spec/controllers/projects/group_links_controller_spec.rb
+++ b/spec/controllers/projects/group_links_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GroupLinksController, feature_category: :authentication_and_authorization do
+RSpec.describe Projects::GroupLinksController, feature_category: :system_access do
let_it_be(:group) { create(:group, :private) }
let_it_be(:group2) { create(:group, :private) }
let_it_be(:project) { create(:project, :private, group: group2) }
diff --git a/spec/controllers/projects/hooks_controller_spec.rb b/spec/controllers/projects/hooks_controller_spec.rb
index 815370d428d..c056e7a33aa 100644
--- a/spec/controllers/projects/hooks_controller_spec.rb
+++ b/spec/controllers/projects/hooks_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::HooksController do
+RSpec.describe Projects::HooksController, feature_category: :integrations do
include AfterNextHelpers
let_it_be(:project) { create(:project) }
@@ -173,6 +173,16 @@ RSpec.describe Projects::HooksController do
let(:params) { { namespace_id: project.namespace, project_id: project, id: hook } }
it_behaves_like 'Web hook destroyer'
+
+ context 'when user does not have permission' do
+ let(:user) { create(:user, developer_projects: [project]) }
+
+ it 'renders a 404' do
+ delete :destroy, params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
describe '#test' do
diff --git a/spec/controllers/projects/imports_controller_spec.rb b/spec/controllers/projects/imports_controller_spec.rb
index 65a80b9e8ec..4502f3d7bd9 100644
--- a/spec/controllers/projects/imports_controller_spec.rb
+++ b/spec/controllers/projects/imports_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ImportsController do
+RSpec.describe Projects::ImportsController, feature_category: :importers do
let(:user) { create(:user) }
let(:project) { create(:project) }
@@ -27,7 +27,7 @@ RSpec.describe Projects::ImportsController do
project.add_maintainer(user)
end
- context 'when repository does not exists' do
+ context 'when repository does not exist' do
it 'renders template' do
get :show, params: { namespace_id: project.namespace.to_param, project_id: project }
@@ -149,17 +149,7 @@ RSpec.describe Projects::ImportsController do
import_state.update!(status: :started)
end
- context 'when group allows developers to create projects' do
- let(:group) { create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) }
-
- it 'renders template' do
- get :show, params: { namespace_id: project.namespace.to_param, project_id: project }
-
- expect(response).to render_template :show
- end
- end
-
- context 'when group prohibits developers to create projects' do
+ context 'when group prohibits developers to import projects' do
let(:group) { create(:group, project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS) }
it 'returns 404 response' do
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 9c272872a73..5f606b1f4f3 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -183,22 +183,10 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
let_it_be(:task) { create(:issue, :task, project: project) }
shared_examples 'redirects to show work item page' do
- context 'when use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- end
-
- it 'redirects to work item page' do
- make_request
-
- expect(response).to redirect_to(project_work_items_path(project, task.id, query))
- end
- end
-
it 'redirects to work item page using iid' do
make_request
- expect(response).to redirect_to(project_work_items_path(project, task.iid, query.merge(iid_path: true)))
+ expect(response).to redirect_to(project_work_items_path(project, task.iid, query))
end
end
@@ -255,7 +243,7 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
get :new, params: { namespace_id: project.namespace, project_id: project }
expect(assigns(:issue)).to be_a_new(Issue)
- expect(assigns(:issue).issue_type).to eq('issue')
+ expect(assigns(:issue).work_item_type.base_type).to eq('issue')
end
where(:conf_value, :conf_result) do
@@ -292,7 +280,7 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
get :new, params: { namespace_id: project.namespace, project_id: project, issue: { issue_type: issue_type } }
end
- subject { assigns(:issue).issue_type }
+ subject { assigns(:issue).work_item_type.base_type }
it { is_expected.to eq('issue') }
@@ -585,15 +573,13 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
end
def reorder_issue(issue, move_after_id: nil, move_before_id: nil)
- put :reorder,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: issue.iid,
- move_after_id: move_after_id,
- move_before_id: move_before_id
- },
- format: :json
+ put :reorder, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: issue.iid,
+ move_after_id: move_after_id,
+ move_before_id: move_before_id
+ }, format: :json
end
end
@@ -601,14 +587,12 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
let(:issue_params) { { title: 'New title' } }
subject do
- put :update,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: issue.to_param,
- issue: issue_params
- },
- format: :json
+ put :update, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: issue.to_param,
+ issue: issue_params
+ }, format: :json
end
before do
@@ -635,7 +619,7 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
subject
expect(response).to have_gitlab_http_status(:ok)
- expect(issue.reload.issue_type).to eql('incident')
+ expect(issue.reload.work_item_type.base_type).to eq('incident')
end
end
@@ -746,7 +730,7 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
go(id: issue.iid)
expect(json_response).to include('title_text', 'description', 'description_text')
- expect(json_response).to include('task_status', 'lock_version')
+ expect(json_response).to include('task_completion_status', 'lock_version')
end
end
end
@@ -1091,7 +1075,6 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning 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
@@ -1100,7 +1083,6 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
it 'defaults to issue type' do
issue = post_new_issue(issue_type: 'task')
- expect(issue.issue_type).to eq('issue')
expect(issue.work_item_type.base_type).to eq('issue')
end
end
@@ -1109,7 +1091,6 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
it 'defaults to issue type' do
issue = post_new_issue(issue_type: 'objective')
- expect(issue.issue_type).to eq('issue')
expect(issue.work_item_type.base_type).to eq('issue')
end
end
@@ -1118,7 +1099,6 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
it 'defaults to issue type' do
issue = post_new_issue(issue_type: 'key_result')
- expect(issue.issue_type).to eq('issue')
expect(issue.work_item_type.base_type).to eq('issue')
end
end
@@ -1168,7 +1148,6 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
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
@@ -1419,7 +1398,7 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
context 'setting issue type' do
let(:issue_type) { 'issue' }
- subject { post_new_issue(issue_type: issue_type)&.issue_type }
+ subject { post_new_issue(issue_type: issue_type)&.work_item_type&.base_type }
it { is_expected.to eq('issue') }
@@ -1484,7 +1463,7 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
it "deletes the issue" do
delete :destroy, params: { namespace_id: project.namespace, project_id: project, id: issue.iid, destroy_confirm: true }
- expect(response).to have_gitlab_http_status(:found)
+ expect(response).to have_gitlab_http_status(:see_other)
expect(controller).to set_flash[:notice].to(/The issue was successfully deleted\./)
end
@@ -1927,12 +1906,11 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
end
it 'redirects from an old issue/designs correctly' do
- get :designs,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: issue
- }
+ get :designs, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: issue
+ }
expect(response).to redirect_to(designs_project_issue_path(new_project, issue))
expect(response).to have_gitlab_http_status(:moved_permanently)
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index 2d047957430..ede26ebd032 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -1,11 +1,13 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, feature_category: :continuous_integration do
+RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, feature_category: :continuous_integration, factory_default: :keep do
include ApiHelpers
include HttpIOHelpers
+ let_it_be(:namespace) { create_default(:namespace) }
let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
let_it_be(:owner) { create(:owner) }
let_it_be(:admin) { create(:admin) }
let_it_be(:maintainer) { create(:user) }
@@ -19,11 +21,16 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
+ create_default(:owner)
+ create_default(:user)
+ create_default(:ci_trigger_request)
+ create_default(:ci_stage)
end
let(:user) { developer }
- let(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be_with_reload(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be(:default_pipeline) { create_default(:ci_pipeline) }
before do
stub_feature_flags(ci_enable_live_trace: true)
@@ -106,9 +113,10 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
def create_job(name, status)
user = create(:user)
pipeline = create(:ci_pipeline, project: project, user: user)
- create(:ci_build, :tags, :triggered, :artifacts,
- pipeline: pipeline, name: name, status: status,
- user: user)
+ create(
+ :ci_build, :tags, :triggered, :artifacts,
+ pipeline: pipeline, name: name, status: status, user: user
+ )
end
end
@@ -151,7 +159,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
end
context 'when requesting JSON' do
- let(:merge_request) { create(:merge_request, source_project: project) }
let(:user) { developer }
before do
@@ -210,9 +217,9 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
end
context 'when job has artifacts' do
- context 'with not expiry date' do
- let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
+ let_it_be(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
+ context 'with not expiry date' do
context 'when artifacts are unlocked' do
before do
job.pipeline.unlocked!
@@ -233,7 +240,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
context 'when artifacts are locked' do
before do
- job.pipeline.artifacts_locked!
+ job.pipeline.reload.artifacts_locked!
end
it 'exposes needed information' do
@@ -251,11 +258,13 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
end
context 'with expired artifacts' do
- let(:job) { create(:ci_build, :success, :artifacts, :expired, pipeline: pipeline) }
+ before do
+ job.update!(artifacts_expire_at: 1.minute.ago)
+ end
context 'when artifacts are unlocked' do
before do
- job.pipeline.unlocked!
+ job.pipeline.reload.unlocked!
end
it 'exposes needed information' do
@@ -274,7 +283,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
context 'when artifacts are locked' do
before do
- job.pipeline.artifacts_locked!
+ job.pipeline.reload.artifacts_locked!
end
it 'exposes needed information' do
@@ -291,19 +300,17 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
end
end
end
- end
-
- context 'when job passed with no trace' do
- let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
- it 'exposes empty state illustrations' do
- get_show_json
+ context 'when job passed with no trace' do
+ it 'exposes empty state illustrations' do
+ get_show_json
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('job/job_details')
- expect(json_response['status']['illustration']).to have_key('image')
- expect(json_response['status']['illustration']).to have_key('size')
- expect(json_response['status']['illustration']).to have_key('title')
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('job/job_details')
+ expect(json_response['status']['illustration']).to have_key('image')
+ expect(json_response['status']['illustration']).to have_key('size')
+ expect(json_response['status']['illustration']).to have_key('title')
+ end
end
end
@@ -319,7 +326,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
end
context 'with deployment' do
- let(:merge_request) { create(:merge_request, source_project: project) }
let(:environment) { create(:environment, project: project, name: 'staging', state: :available) }
let(:job) { create(:ci_build, :running, environment: environment.name, pipeline: pipeline) }
@@ -511,7 +517,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
end
context 'when requesting triggered job JSON' do
- let!(:merge_request) { create(:merge_request, source_project: project) }
let(:trigger) { create(:ci_trigger, project: project) }
let(:trigger_request) { create(:ci_trigger_request, pipeline: pipeline, trigger: trigger) }
let(:job) { create(:ci_build, pipeline: pipeline, trigger_request: trigger_request) }
@@ -832,8 +837,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
retried_build = Ci::Build.last
Ci::Build.clone_accessors.each do |accessor|
- expect(job.read_attribute(accessor))
- .to eq(retried_build.read_attribute(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
@@ -855,10 +859,10 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
def post_retry
post :retry, params: {
- namespace_id: project.namespace,
- project_id: project,
- id: job.id
- }
+ namespace_id: project.namespace,
+ project_id: project,
+ id: job.id
+ }
end
end
@@ -869,8 +873,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu
before do
project.add_developer(user)
- create(:protected_branch, :developers_can_merge,
- name: 'protected-branch', project: project)
+ create(:protected_branch, :developers_can_merge, name: 'protected-branch', project: project)
sign_in(user)
end
diff --git a/spec/controllers/projects/mattermosts_controller_spec.rb b/spec/controllers/projects/mattermosts_controller_spec.rb
index 19a04654114..b5092a0f091 100644
--- a/spec/controllers/projects/mattermosts_controller_spec.rb
+++ b/spec/controllers/projects/mattermosts_controller_spec.rb
@@ -19,11 +19,10 @@ RSpec.describe Projects::MattermostsController do
end
it 'accepts the request' do
- get(:new,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project
- })
+ get :new, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project
+ }
expect(response).to have_gitlab_http_status(:ok)
end
@@ -33,12 +32,11 @@ RSpec.describe Projects::MattermostsController do
let(:mattermost_params) { { trigger: 'http://localhost:3000/trigger', team_id: 'abc' } }
subject do
- post(:create,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- mattermost: mattermost_params
- })
+ post :create, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ mattermost: mattermost_params
+ }
end
context 'no request can be made to mattermost' do
diff --git a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
index 311af26abf6..926cd7ea681 100644
--- a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb
@@ -22,13 +22,11 @@ RSpec.describe Projects::MergeRequests::ConflictsController do
allow(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_loading_conflict_ui_action)
- get :show,
- params: {
- namespace_id: merge_request_with_conflicts.project.namespace.to_param,
- project_id: merge_request_with_conflicts.project,
- id: merge_request_with_conflicts.iid
- },
- format: 'html'
+ get :show, params: {
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project,
+ id: merge_request_with_conflicts.iid
+ }, format: 'html'
end
it 'does tracks the resolve call' do
@@ -45,13 +43,11 @@ RSpec.describe Projects::MergeRequests::ConflictsController do
allow(Gitlab::Git::Conflict::Parser).to receive(:parse)
.and_raise(Gitlab::Git::Conflict::Parser::UnmergeableFile)
- get :show,
- params: {
- namespace_id: merge_request_with_conflicts.project.namespace.to_param,
- project_id: merge_request_with_conflicts.project,
- id: merge_request_with_conflicts.iid
- },
- format: 'json'
+ get :show, params: {
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project,
+ id: merge_request_with_conflicts.iid
+ }, format: 'json'
end
it 'returns a 200 status code' do
@@ -70,13 +66,11 @@ RSpec.describe Projects::MergeRequests::ConflictsController do
context 'with valid conflicts' do
before do
- get :show,
- params: {
- namespace_id: merge_request_with_conflicts.project.namespace.to_param,
- project_id: merge_request_with_conflicts.project,
- id: merge_request_with_conflicts.iid
- },
- format: 'json'
+ get :show, params: {
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project,
+ id: merge_request_with_conflicts.iid
+ }, format: 'json'
end
it 'matches the schema' do
@@ -91,7 +85,7 @@ RSpec.describe Projects::MergeRequests::ConflictsController do
end
it 'includes each file that has conflicts' do
- filenames = json_response['files'].map { |file| file['new_path'] }
+ filenames = json_response['files'].pluck('new_path')
expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb')
end
@@ -120,7 +114,7 @@ RSpec.describe Projects::MergeRequests::ConflictsController do
it 'has unique section IDs across files' do
section_ids = json_response['files'].flat_map do |file|
- file['sections'].map { |section| section['id'] }.compact
+ file['sections'].pluck('id').compact
end
expect(section_ids.uniq).to eq(section_ids)
@@ -130,15 +124,13 @@ RSpec.describe Projects::MergeRequests::ConflictsController do
describe 'GET conflict_for_path' do
def conflict_for_path(path)
- get :conflict_for_path,
- params: {
- namespace_id: merge_request_with_conflicts.project.namespace.to_param,
- project_id: merge_request_with_conflicts.project,
- id: merge_request_with_conflicts.iid,
- old_path: path,
- new_path: path
- },
- format: 'json'
+ get :conflict_for_path, params: {
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project,
+ id: merge_request_with_conflicts.iid,
+ old_path: path,
+ new_path: path
+ }, format: 'json'
end
context 'when the conflicts cannot be resolved in the UI' do
@@ -178,11 +170,13 @@ RSpec.describe Projects::MergeRequests::ConflictsController do
aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to include('old_path' => path,
- 'new_path' => path,
- 'blob_icon' => 'doc-text',
- 'blob_path' => a_string_ending_with(path),
- 'content' => content)
+ expect(json_response).to include(
+ 'old_path' => path,
+ 'new_path' => path,
+ 'blob_icon' => 'doc-text',
+ 'blob_path' => a_string_ending_with(path),
+ 'content' => content
+ )
end
end
end
@@ -197,15 +191,13 @@ RSpec.describe Projects::MergeRequests::ConflictsController do
end
def resolve_conflicts(files)
- post :resolve_conflicts,
- params: {
- namespace_id: merge_request_with_conflicts.project.namespace.to_param,
- project_id: merge_request_with_conflicts.project,
- id: merge_request_with_conflicts.iid,
- files: files,
- commit_message: 'Commit message'
- },
- format: 'json'
+ post :resolve_conflicts, params: {
+ namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+ project_id: merge_request_with_conflicts.project,
+ id: merge_request_with_conflicts.iid,
+ files: files,
+ commit_message: 'Commit message'
+ }, format: 'json'
end
context 'with valid params' do
diff --git a/spec/controllers/projects/merge_requests/creations_controller_spec.rb b/spec/controllers/projects/merge_requests/creations_controller_spec.rb
index 3d4a884587f..c6a4dcbfdf0 100644
--- a/spec/controllers/projects/merge_requests/creations_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/creations_controller_spec.rb
@@ -99,9 +99,7 @@ RSpec.describe Projects::MergeRequests::CreationsController, feature_category: :
describe 'GET pipelines' do
before do
- create(:ci_pipeline, sha: fork_project.commit('remove-submodule').id,
- ref: 'remove-submodule',
- project: fork_project)
+ create(:ci_pipeline, sha: fork_project.commit('remove-submodule').id, ref: 'remove-submodule', project: fork_project)
end
it 'renders JSON including serialized pipelines' do
@@ -188,13 +186,12 @@ RSpec.describe Projects::MergeRequests::CreationsController, feature_category: :
expect(Ability).to receive(:allowed?).with(user, :read_project, project) { true }
expect(Ability).to receive(:allowed?).with(user, :create_merge_request_in, project) { true }.at_least(:once)
- get :branch_to,
- params: {
- namespace_id: fork_project.namespace,
- project_id: fork_project,
- target_project_id: project.id,
- ref: 'master'
- }
+ get :branch_to, params: {
+ namespace_id: fork_project.namespace,
+ project_id: fork_project,
+ target_project_id: project.id,
+ ref: 'master'
+ }
expect(assigns(:commit)).not_to be_nil
expect(response).to have_gitlab_http_status(:ok)
@@ -204,13 +201,12 @@ RSpec.describe Projects::MergeRequests::CreationsController, feature_category: :
expect(Ability).to receive(:allowed?).with(user, :read_project, project) { true }
expect(Ability).to receive(:allowed?).with(user, :create_merge_request_in, project) { false }.at_least(:once)
- get :branch_to,
- params: {
- namespace_id: fork_project.namespace,
- project_id: fork_project,
- target_project_id: project.id,
- ref: 'master'
- }
+ get :branch_to, params: {
+ namespace_id: fork_project.namespace,
+ project_id: fork_project,
+ target_project_id: project.id,
+ ref: 'master'
+ }
expect(assigns(:commit)).to be_nil
expect(response).to have_gitlab_http_status(:ok)
@@ -220,13 +216,12 @@ RSpec.describe Projects::MergeRequests::CreationsController, feature_category: :
expect(Ability).to receive(:allowed?).with(user, :read_project, project) { false }
expect(Ability).to receive(:allowed?).with(user, :create_merge_request_in, project) { true }.at_least(:once)
- get :branch_to,
- params: {
- namespace_id: fork_project.namespace,
- project_id: fork_project,
- target_project_id: project.id,
- ref: 'master'
- }
+ get :branch_to, params: {
+ namespace_id: fork_project.namespace,
+ project_id: fork_project,
+ target_project_id: project.id,
+ ref: 'master'
+ }
expect(assigns(:commit)).to be_nil
expect(response).to have_gitlab_http_status(:ok)
diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
index 23a33d7e0b1..3b562b4c151 100644
--- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
@@ -247,9 +247,11 @@ RSpec.describe Projects::MergeRequests::DiffsController, feature_category: :code
straight: true)
end
- go(diff_head: true,
- diff_id: merge_request.merge_request_diff.id,
- start_sha: merge_request.merge_request_diff.start_commit_sha)
+ go(
+ diff_head: true,
+ diff_id: merge_request.merge_request_diff.id,
+ start_sha: merge_request.merge_request_diff.start_commit_sha
+ )
end
end
end
@@ -329,15 +331,17 @@ RSpec.describe Projects::MergeRequests::DiffsController, feature_category: :code
diff_for_path(old_path: existing_path, new_path: existing_path)
expect(assigns(:diff_notes_disabled)).to be_falsey
- expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- commit_id: nil)
+ expect(assigns(:new_diff_note_attrs)).to eq(
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ commit_id: nil
+ )
end
it 'only renders the diffs for the path given' do
diff_for_path(old_path: existing_path, new_path: existing_path)
- paths = json_response['diff_files'].map { |file| file['new_path'] }
+ paths = json_response['diff_files'].pluck('new_path')
expect(paths).to include(existing_path)
end
@@ -528,8 +532,7 @@ RSpec.describe Projects::MergeRequests::DiffsController, feature_category: :code
context 'with diff_id and start_sha params' do
subject do
- go(diff_id: merge_request.merge_request_diff.id,
- start_sha: merge_request.merge_request_diff.start_commit_sha)
+ go(diff_id: merge_request.merge_request_diff.id, start_sha: merge_request.merge_request_diff.start_commit_sha)
end
it_behaves_like 'serializes diffs with expected arguments' do
diff --git a/spec/controllers/projects/merge_requests/drafts_controller_spec.rb b/spec/controllers/projects/merge_requests/drafts_controller_spec.rb
index 39482938a8b..6632473a85c 100644
--- a/spec/controllers/projects/merge_requests/drafts_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/drafts_controller_spec.rb
@@ -299,8 +299,7 @@ RSpec.describe Projects::MergeRequests::DraftsController do
it 'publishes a draft note with quick actions and applies them', :sidekiq_inline do
project.add_developer(user2)
- create(:draft_note, merge_request: merge_request, author: user,
- note: "/assign #{user2.to_reference}")
+ create(:draft_note, merge_request: merge_request, author: user, note: "/assign #{user2.to_reference}")
expect(merge_request.assignees).to be_empty
@@ -350,12 +349,13 @@ RSpec.describe Projects::MergeRequests::DraftsController do
let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) }
def create_reply(discussion_id, resolves: false)
- create(:draft_note,
- merge_request: merge_request,
- author: user,
- discussion_id: discussion_id,
- resolve_discussion: resolves
- )
+ create(
+ :draft_note,
+ merge_request: merge_request,
+ author: user,
+ discussion_id: discussion_id,
+ resolve_discussion: resolves
+ )
end
it 'resolves a thread if the draft note resolves it' do
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index ceb3f803db5..f78d50bba24 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -210,9 +210,7 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
diff = merge_request.merge_request_diff
diff.clean!
- diff.update!(real_size: nil,
- start_commit_sha: nil,
- base_commit_sha: nil)
+ diff.update!(real_size: nil, start_commit_sha: nil, base_commit_sha: nil)
go(format: :html)
@@ -270,24 +268,22 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
end
it 'redirects from an old merge request correctly' do
- get :show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: merge_request
- }
+ get :show, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: merge_request
+ }
expect(response).to redirect_to(project_merge_request_path(new_project, merge_request))
expect(response).to have_gitlab_http_status(:moved_permanently)
end
it 'redirects from an old merge request commits correctly' do
- get :commits,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: merge_request
- }
+ get :commits, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: merge_request
+ }
expect(response).to redirect_to(commits_project_merge_request_path(new_project, merge_request))
expect(response).to have_gitlab_http_status(:moved_permanently)
@@ -385,13 +381,12 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
def get_merge_requests(page = nil)
- get :index,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- state: 'opened',
- page: page.to_param
- }
+ get :index, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ state: 'opened',
+ page: page.to_param
+ }
end
it_behaves_like "issuables list meta-data", :merge_request
@@ -580,6 +575,16 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
it 'returns :failed' do
expect(json_response).to eq('status' => 'failed')
end
+
+ context 'for logging' do
+ let(:expected_params) { { merge_action_status: 'failed' } }
+ let(:subject_proc) { proc { subject } }
+
+ subject { post :merge, params: base_params }
+
+ it_behaves_like 'storing arguments in the application context'
+ it_behaves_like 'not executing any extra queries for the application context'
+ end
end
context 'when the sha parameter does not match the source SHA' do
@@ -590,6 +595,16 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
it 'returns :sha_mismatch' do
expect(json_response).to eq('status' => 'sha_mismatch')
end
+
+ context 'for logging' do
+ let(:expected_params) { { merge_action_status: 'sha_mismatch' } }
+ let(:subject_proc) { proc { subject } }
+
+ subject { post :merge, params: base_params.merge(sha: 'foo') }
+
+ it_behaves_like 'storing arguments in the application context'
+ it_behaves_like 'not executing any extra queries for the application context'
+ end
end
context 'when the sha parameter matches the source SHA' do
@@ -611,6 +626,16 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
merge_with_sha
end
+ context 'for logging' do
+ let(:expected_params) { { merge_action_status: 'success' } }
+ let(:subject_proc) { proc { subject } }
+
+ subject { merge_with_sha }
+
+ it_behaves_like 'storing arguments in the application context'
+ it_behaves_like 'not executing any extra queries for the application context'
+ end
+
context 'when squash is passed as 1' do
it 'updates the squash attribute on the MR to true' do
merge_request.update!(squash: false)
@@ -678,6 +703,16 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
merge_when_pipeline_succeeds
end
+ context 'for logging' do
+ let(:expected_params) { { merge_action_status: 'merge_when_pipeline_succeeds' } }
+ let(:subject_proc) { proc { subject } }
+
+ subject { merge_when_pipeline_succeeds }
+
+ it_behaves_like 'storing arguments in the application context'
+ it_behaves_like 'not executing any extra queries for the application context'
+ end
+
context 'when project.only_allow_merge_if_pipeline_succeeds? is true' do
before do
project.update_column(:only_allow_merge_if_pipeline_succeeds, true)
@@ -816,7 +851,7 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
it "deletes the merge request" do
delete :destroy, params: { namespace_id: project.namespace, project_id: project, id: merge_request.iid, destroy_confirm: true }
- expect(response).to have_gitlab_http_status(:found)
+ expect(response).to have_gitlab_http_status(:see_other)
expect(controller).to set_flash[:notice].to(/The merge request was successfully deleted\./)
end
@@ -842,15 +877,13 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
describe 'GET commits' do
def go(page: nil, per_page: 1, format: 'html')
- get :commits,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid,
- page: page,
- per_page: per_page
- },
- format: format
+ get :commits, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid,
+ page: page,
+ per_page: per_page
+ }, format: format
end
it 'renders the commits template to a string' do
@@ -884,17 +917,18 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
describe 'GET pipelines' do
before do
- create(:ci_pipeline, project: merge_request.source_project,
- ref: merge_request.source_branch,
- sha: merge_request.diff_head_sha)
+ create(
+ :ci_pipeline,
+ project: merge_request.source_project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha
+ )
- get :pipelines,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid
- },
- format: :json
+ get :pipelines, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid
+ }, format: :json
end
context 'with "enabled" builds on a public project' do
@@ -1955,17 +1989,18 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
let(:issue2) { create(:issue, project: project) }
def post_assign_issues
- merge_request.update!(description: "Closes #{issue1.to_reference} and #{issue2.to_reference}",
- author: user,
- source_branch: 'feature',
- target_branch: 'master')
+ merge_request.update!(
+ description: "Closes #{issue1.to_reference} and #{issue2.to_reference}",
+ author: user,
+ source_branch: 'feature',
+ target_branch: 'master'
+ )
- post :assign_related_issues,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: merge_request.iid
- }
+ post :assign_related_issues, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid
+ }
end
it 'displays an flash error message on fail' do
@@ -2143,10 +2178,13 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
describe 'GET pipeline_status.json' do
context 'when head_pipeline exists' do
let!(:pipeline) do
- create(:ci_pipeline, project: merge_request.source_project,
- ref: merge_request.source_branch,
- sha: merge_request.diff_head_sha,
- head_pipeline_of: merge_request)
+ create(
+ :ci_pipeline,
+ project: merge_request.source_project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha,
+ head_pipeline_of: merge_request
+ )
end
let(:status) { pipeline.detailed_status(double('user')) }
@@ -2199,11 +2237,10 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review
def get_pipeline_status
get :pipeline_status, params: {
- namespace_id: project.namespace,
- project_id: project,
- id: merge_request.iid
- },
- format: :json
+ namespace_id: project.namespace,
+ project_id: project,
+ id: merge_request.iid
+ }, format: :json
end
end
diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb
index 28da7eff8fc..e2b73e55145 100644
--- a/spec/controllers/projects/milestones_controller_spec.rb
+++ b/spec/controllers/projects/milestones_controller_spec.rb
@@ -156,6 +156,27 @@ RSpec.describe Projects::MilestonesController do
end
end
+ describe "#update" do
+ let(:milestone_params) do
+ { title: "title changed" }
+ end
+
+ it "handles ActiveRecord::StaleObjectError" do
+ # Purposely reduce the lock_version to trigger an ActiveRecord::StaleObjectError
+ milestone_params[:lock_version] = milestone.lock_version - 1
+
+ put :update, params: {
+ id: milestone.iid,
+ milestone: milestone_params,
+ namespace_id: project.namespace.id,
+ project_id: project.id
+ }
+
+ expect(response).not_to redirect_to(project_milestone_path(project, milestone.iid))
+ expect(response).to render_template(:edit)
+ end
+ end
+
describe "#destroy" do
it "removes milestone" do
expect(issue.milestone_id).to eq(milestone.id)
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index 23b0b58158f..5e4e47be2c5 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -37,6 +37,8 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
project.add_developer(user)
end
+ specify { expect(get(:index, params: request_params)).to have_request_urgency(:medium) }
+
it 'passes last_fetched_at from headers to NotesFinder and MergeIntoNotesService' do
last_fetched_at = Time.zone.at(3.hours.ago.to_i) # remove nanoseconds
@@ -244,6 +246,8 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
sign_in(user)
end
+ specify { expect(create!).to have_request_urgency(:low) }
+
describe 'making the creation request' do
before do
create!
@@ -432,6 +436,13 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
expect(json_response['commands_changes']).to include('emoji_award', 'time_estimate', 'spend_time')
expect(json_response['commands_changes']).not_to include('target_project', 'title')
end
+
+ it 'includes command_names' do
+ create!
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['command_names']).to include('award', 'estimate', 'spend')
+ end
end
context 'with commands that do not return changes' do
@@ -450,6 +461,13 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['commands_changes']).not_to include('target_project', 'title')
end
+
+ it 'includes command_names' do
+ create!
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['command_names']).to include('move', 'title')
+ end
end
end
end
@@ -484,10 +502,7 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
let(:commit) { create(:commit, project: project) }
let(:existing_comment) do
- create(:note_on_commit,
- note: 'first',
- project: project,
- commit_id: merge_request.commit_shas.first)
+ create(:note_on_commit, note: 'first', project: project, commit_id: merge_request.commit_shas.first)
end
let(:discussion) { existing_comment.discussion }
@@ -735,19 +750,21 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
end
describe 'PUT update' do
- context "should update the note with a valid issue" do
- let(:request_params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- id: note,
- format: :json,
- note: {
- note: "New comment"
- }
+ let(:request_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: note,
+ format: :json,
+ note: {
+ note: "New comment"
}
- end
+ }
+ end
+
+ specify { expect(put(:update, params: request_params)).to have_request_urgency(:low) }
+ context "should update the note with a valid issue" do
before do
sign_in(note.author)
project.add_developer(note.author)
@@ -793,6 +810,8 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
}
end
+ specify { expect(delete(:destroy, params: request_params)).to have_request_urgency(:low) }
+
context 'user is the author of a note' do
before do
sign_in(note.author)
@@ -834,6 +853,8 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
let(:emoji_name) { 'thumbsup' }
+ it { is_expected.to have_request_urgency(:low) }
+
it "toggles the award emoji" do
expect do
subject
@@ -869,6 +890,8 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
sign_in user
end
+ specify { expect(post(:resolve, params: request_params)).to have_request_urgency(:low) }
+
context "when the user is not authorized to resolve the note" do
it "returns status 404" do
post :resolve, params: request_params
@@ -932,6 +955,8 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
note.resolve!(user)
end
+ specify { expect(delete(:unresolve, params: request_params)).to have_request_urgency(:low) }
+
context "when the user is not authorized to resolve the note" do
it "returns status 404" do
delete :unresolve, params: request_params
@@ -1001,6 +1026,8 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: :
expect(json_response.count).to eq(1)
expect(json_response.first).to include({ "line_text" => "Test" })
end
+
+ specify { expect(get(:outdated_line_change, params: request_params)).to have_request_urgency(:low) }
end
# Convert a time to an integer number of microseconds
diff --git a/spec/controllers/projects/pages_controller_spec.rb b/spec/controllers/projects/pages_controller_spec.rb
index 136f98ac907..ded5dd57e3e 100644
--- a/spec/controllers/projects/pages_controller_spec.rb
+++ b/spec/controllers/projects/pages_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::PagesController do
+RSpec.describe Projects::PagesController, feature_category: :pages do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
@@ -14,7 +14,12 @@ RSpec.describe Projects::PagesController do
end
before do
- allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
+ stub_config(pages: {
+ enabled: true,
+ external_https: true,
+ access_control: false
+ })
+
sign_in(user)
project.add_maintainer(user)
end
@@ -123,49 +128,99 @@ RSpec.describe Projects::PagesController do
end
describe 'PATCH update' do
- let(:request_params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- project: { pages_https_only: 'false' }
- }
- end
+ context 'when updating pages_https_only' do
+ let(:request_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ project: { pages_https_only: 'true' }
+ }
+ end
- let(:update_service) { double(execute: { status: :success }) }
+ it 'updates project field and redirects back to the pages settings' do
+ project.update!(pages_https_only: false)
- before do
- allow(Projects::UpdateService).to receive(:new) { update_service }
- end
+ expect { patch :update, params: request_params }
+ .to change { project.reload.pages_https_only }
+ .from(false).to(true)
- it 'returns 302 status' do
- patch :update, params: request_params
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(project_pages_path(project))
+ end
- expect(response).to have_gitlab_http_status(:found)
- end
+ context 'when it fails to update' do
+ it 'adds an error message' do
+ expect_next_instance_of(Projects::UpdateService) do |service|
+ expect(service)
+ .to receive(:execute)
+ .and_return(status: :error, message: 'some error happened')
+ end
- it 'redirects back to the pages settings' do
- patch :update, params: request_params
+ expect { patch :update, params: request_params }
+ .not_to change { project.reload.pages_https_only }
- expect(response).to redirect_to(project_pages_path(project))
+ expect(response).to redirect_to(project_pages_path(project))
+ expect(flash[:alert]).to eq('some error happened')
+ end
+ end
end
- it 'calls the update service' do
- expect(Projects::UpdateService)
- .to receive(:new)
- .with(project, user, ActionController::Parameters.new(request_params[:project]).permit!)
- .and_return(update_service)
+ context 'when updating pages_unique_domain' do
+ let(:request_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ project: {
+ project_setting_attributes: {
+ pages_unique_domain_enabled: 'true'
+ }
+ }
+ }
+ end
- patch :update, params: request_params
- end
+ before do
+ create(:project_setting, project: project, pages_unique_domain_enabled: false)
+ end
- context 'when update_service returns an error message' do
- let(:update_service) { double(execute: { status: :error, message: 'some error happened' }) }
+ context 'with pages_unique_domain feature flag disabled' do
+ it 'does not update pages unique domain' do
+ stub_feature_flags(pages_unique_domain: false)
- it 'adds an error message' do
- patch :update, params: request_params
+ expect { patch :update, params: request_params }
+ .not_to change { project.project_setting.reload.pages_unique_domain_enabled }
+ end
+ end
- expect(response).to redirect_to(project_pages_path(project))
- expect(flash[:alert]).to eq('some error happened')
+ context 'with pages_unique_domain feature flag enabled' do
+ before do
+ stub_feature_flags(pages_unique_domain: true)
+ end
+
+ it 'updates pages_https_only and pages_unique_domain and redirects back to pages settings' do
+ expect { patch :update, params: request_params }
+ .to change { project.project_setting.reload.pages_unique_domain_enabled }
+ .from(false).to(true)
+
+ expect(project.project_setting.pages_unique_domain).not_to be_nil
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(project_pages_path(project))
+ end
+
+ context 'when it fails to update' do
+ it 'adds an error message' do
+ expect_next_instance_of(Projects::UpdateService) do |service|
+ expect(service)
+ .to receive(:execute)
+ .and_return(status: :error, message: 'some error happened')
+ end
+
+ expect { patch :update, params: request_params }
+ .not_to change { project.project_setting.reload.pages_unique_domain_enabled }
+
+ expect(response).to redirect_to(project_pages_path(project))
+ expect(flash[:alert]).to eq('some error happened')
+ end
+ end
end
end
end
diff --git a/spec/controllers/projects/performance_monitoring/dashboards_controller_spec.rb b/spec/controllers/projects/performance_monitoring/dashboards_controller_spec.rb
index 939366e5b0b..02407e31756 100644
--- a/spec/controllers/projects/performance_monitoring/dashboards_controller_spec.rb
+++ b/spec/controllers/projects/performance_monitoring/dashboards_controller_spec.rb
@@ -2,11 +2,11 @@
require 'spec_helper'
-RSpec.describe Projects::PerformanceMonitoring::DashboardsController do
+RSpec.describe Projects::PerformanceMonitoring::DashboardsController, feature_category: :metrics do
let_it_be(:user) { create(:user) }
let_it_be(:namespace) { create(:namespace) }
- let!(:project) { create(:project, :repository, name: 'dashboard-project', namespace: namespace) }
+ let_it_be(:project) { create(:project, :repository, namespace: namespace) }
let(:repository) { project.repository }
let(:branch) { double(name: branch_name) }
let(:commit_message) { 'test' }
@@ -25,6 +25,10 @@ RSpec.describe Projects::PerformanceMonitoring::DashboardsController do
}
end
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
describe 'POST #create' do
context 'authenticated user' do
before do
@@ -64,7 +68,7 @@ RSpec.describe Projects::PerformanceMonitoring::DashboardsController do
post :create, params: params
expect(response).to have_gitlab_http_status :created
- expect(controller).to set_flash[:notice].to eq("Your dashboard has been copied. You can <a href=\"/-/ide/project/#{namespace.path}/#{project.name}/edit/#{branch_name}/-/.gitlab/dashboards/#{file_name}\">edit it here</a>.")
+ expect(controller).to set_flash[:notice].to eq("Your dashboard has been copied. You can <a href=\"/-/ide/project/#{project.full_path}/edit/#{branch_name}/-/.gitlab/dashboards/#{file_name}\">edit it here</a>.")
expect(json_response).to eq('status' => 'success', 'dashboard' => { 'path' => ".gitlab/dashboards/#{file_name}" })
end
@@ -102,6 +106,18 @@ RSpec.describe Projects::PerformanceMonitoring::DashboardsController do
expect(json_response).to eq('error' => "Request parameter branch is missing.")
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 404 not found' do
+ post :create, params: params
+
+ expect(response).to have_gitlab_http_status :not_found
+ end
+ end
end
end
end
@@ -120,7 +136,7 @@ RSpec.describe Projects::PerformanceMonitoring::DashboardsController do
end
context 'project without repository feature' do
- let!(:project) { create(:project, name: 'dashboard-project', namespace: namespace) }
+ let_it_be(:project) { create(:project, namespace: namespace) }
it 'responds with :not_found status code' do
post :create, params: params
@@ -203,7 +219,7 @@ RSpec.describe Projects::PerformanceMonitoring::DashboardsController do
put :update, params: params
expect(response).to have_gitlab_http_status :created
- expect(controller).to set_flash[:notice].to eq("Your dashboard has been updated. You can <a href=\"/-/ide/project/#{namespace.path}/#{project.name}/edit/#{branch_name}/-/.gitlab/dashboards/#{file_name}\">edit it here</a>.")
+ expect(controller).to set_flash[:notice].to eq("Your dashboard has been updated. You can <a href=\"/-/ide/project/#{project.full_path}/edit/#{branch_name}/-/.gitlab/dashboards/#{file_name}\">edit it here</a>.")
expect(json_response).to eq('status' => 'success', 'dashboard' => { 'default' => false, 'display_name' => "custom_dashboard.yml", 'path' => ".gitlab/dashboards/#{file_name}", 'system_dashboard' => false })
end
@@ -217,6 +233,18 @@ RSpec.describe Projects::PerformanceMonitoring::DashboardsController do
expect(json_response).to eq('error' => 'something went wrong')
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 404 not found' do
+ put :update, params: params
+
+ expect(response).to have_gitlab_http_status :not_found
+ end
+ end
end
end
@@ -246,7 +274,7 @@ RSpec.describe Projects::PerformanceMonitoring::DashboardsController do
end
context 'project without repository feature' do
- let!(:project) { create(:project, name: 'dashboard-project', namespace: namespace) }
+ let_it_be(:project) { create(:project, namespace: namespace) }
it 'responds with :not_found status code' do
put :update, params: params
diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb
index a628c1ab230..6d810fdcd51 100644
--- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb
+++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb
@@ -410,9 +410,9 @@ RSpec.describe Projects::PipelineSchedulesController, feature_category: :continu
it { expect { go }.to be_denied_for(:visitor) }
context 'when user is schedule owner' do
- it { expect { go }.to be_denied_for(:owner).of(project).own(pipeline_schedule) }
- it { expect { go }.to be_denied_for(:maintainer).of(project).own(pipeline_schedule) }
- it { expect { go }.to be_denied_for(:developer).of(project).own(pipeline_schedule) }
+ it { expect { go }.to be_allowed_for(:owner).of(project).own(pipeline_schedule) }
+ it { expect { go }.to be_allowed_for(:maintainer).of(project).own(pipeline_schedule) }
+ it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
it { expect { go }.to be_denied_for(:reporter).of(project).own(pipeline_schedule) }
it { expect { go }.to be_denied_for(:guest).of(project).own(pipeline_schedule) }
it { expect { go }.to be_denied_for(:user).own(pipeline_schedule) }
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 4e0c098ad81..8c5f8fc6259 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -203,18 +203,16 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
def get_pipelines_index_html(params = {})
get :index, params: {
- namespace_id: project.namespace,
- project_id: project
- }.merge(params),
- format: :html
+ namespace_id: project.namespace,
+ project_id: project
+ }.merge(params), format: :html
end
def get_pipelines_index_json(params = {})
get :index, params: {
- namespace_id: project.namespace,
- project_id: project
- }.merge(params),
- format: :json
+ namespace_id: project.namespace,
+ project_id: project
+ }.merge(params), format: :json
end
def create_all_pipeline_types
@@ -236,12 +234,15 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
def create_pipeline(status, sha, merge_request: nil)
user = create(:user)
- pipeline = create(:ci_empty_pipeline, status: status,
- project: project,
- sha: sha.id,
- ref: sha.id.first(8),
- user: user,
- merge_request: merge_request)
+ pipeline = create(
+ :ci_empty_pipeline,
+ status: status,
+ project: project,
+ sha: sha.id,
+ ref: sha.id.first(8),
+ user: user,
+ merge_request: merge_request
+ )
build_stage = create(:ci_stage, name: 'build', pipeline: pipeline)
test_stage = create(:ci_stage, name: 'test', pipeline: pipeline)
@@ -279,23 +280,6 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
end
end
- describe 'GET #index' do
- before do
- stub_application_setting(auto_devops_enabled: false)
- end
-
- context 'with runners_availability_section experiment' do
- it 'tracks the assignment', :experiment do
- stub_experiments(runners_availability_section: true)
-
- expect(experiment(:runners_availability_section))
- .to track(:assignment).with_context(namespace: project.namespace).on_next_instance
-
- get :index, params: { namespace_id: project.namespace, project_id: project }
- end
- end
- end
-
describe 'GET #show' do
def get_pipeline_html
get :show, params: { namespace_id: project.namespace, project_id: project, id: pipeline }, format: :html
@@ -378,9 +362,7 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
let(:project) { create(:project, :repository) }
let(:pipeline) do
- create(:ci_empty_pipeline, project: project,
- user: user,
- sha: project.commit.id)
+ create(:ci_empty_pipeline, project: project, user: user, sha: project.commit.id)
end
let(:build_stage) { create(:ci_stage, name: 'build', pipeline: pipeline) }
@@ -598,9 +580,7 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
def create_pipeline(project)
create(:ci_empty_pipeline, project: project).tap do |pipeline|
- create(:ci_build, pipeline: pipeline,
- ci_stage: create(:ci_stage, name: 'test', pipeline: pipeline),
- name: 'rspec')
+ create(:ci_build, pipeline: pipeline, ci_stage: create(:ci_stage, name: 'test', pipeline: pipeline), name: 'rspec')
end
end
@@ -771,11 +751,8 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
before do
get :status, params: {
- namespace_id: project.namespace,
- project_id: project,
- id: pipeline.id
- },
- format: :json
+ namespace_id: project.namespace, project_id: project, id: pipeline.id
+ }, format: :json
end
it 'return a detailed pipeline status in json' do
@@ -825,7 +802,6 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
subject { get :charts, params: request_params, format: :html }
let(:request_params) { { namespace_id: project.namespace, project_id: project, id: pipeline.id, chart: tab[:chart_param] } }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'perform_analytics_usage_action' }
let(:namespace) { project.namespace }
@@ -868,9 +844,7 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
context 'when latest commit contains [ci skip]' do
before do
- project.repository.create_file(user, 'new-file.txt', 'A new file',
- message: '[skip ci] This is a test',
- branch_name: 'master')
+ project.repository.create_file(user, 'new-file.txt', 'A new file', message: '[skip ci] This is a test', branch_name: 'master')
end
it_behaves_like 'creates a pipeline'
@@ -906,11 +880,8 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
subject do
post :create, params: {
- namespace_id: project.namespace,
- project_id: project,
- pipeline: { ref: 'master' }
- },
- format: :json
+ namespace_id: project.namespace, project_id: project, pipeline: { ref: 'master' }
+ }, format: :json
end
before do
@@ -969,11 +940,8 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
describe 'POST retry.json' do
subject(:post_retry) do
post :retry, params: {
- namespace_id: project.namespace,
- project_id: project,
- id: pipeline.id
- },
- format: :json
+ namespace_id: project.namespace, project_id: project, id: pipeline.id
+ }, format: :json
end
let!(:pipeline) { create(:ci_pipeline, :failed, project: project) }
@@ -1036,11 +1004,8 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
before do
post :cancel, params: {
- namespace_id: project.namespace,
- project_id: project,
- id: pipeline.id
- },
- format: :json
+ namespace_id: project.namespace, project_id: project, id: pipeline.id
+ }, format: :json
end
it 'cancels a pipeline without returning any content', :sidekiq_might_not_need_inline do
@@ -1183,7 +1148,7 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
def clear_controller_memoization
controller.clear_memoization(:pipeline_test_report)
- controller.instance_variable_set(:@pipeline, nil)
+ controller.remove_instance_variable(:@pipeline)
end
end
@@ -1192,17 +1157,11 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
let(:branch_secondary) { project.repository.branches[1] }
let!(:pipeline_master) do
- create(:ci_pipeline,
- ref: branch_main.name,
- sha: branch_main.target,
- project: project)
+ create(:ci_pipeline, ref: branch_main.name, sha: branch_main.target, project: project)
end
let!(:pipeline_secondary) do
- create(:ci_pipeline,
- ref: branch_secondary.name,
- sha: branch_secondary.target,
- project: project)
+ create(:ci_pipeline, ref: branch_secondary.name, sha: branch_secondary.target, project: project)
end
before do
@@ -1319,149 +1278,6 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
end
end
- describe 'GET config_variables.json', :use_clean_rails_memory_store_caching do
- include ReactiveCachingHelpers
-
- let(:ci_config) { '' }
- let(:files) { { '.gitlab-ci.yml' => YAML.dump(ci_config) } }
- let(:project) { create(:project, :auto_devops_disabled, :custom_repo, files: files) }
- let(:service) { Ci::ListConfigVariablesService.new(project, user) }
-
- before do
- allow(Ci::ListConfigVariablesService)
- .to receive(:new)
- .and_return(service)
- end
-
- context 'when sending a valid ref' do
- let(:ref) { 'master' }
- let(:ci_config) do
- {
- variables: {
- KEY1: { value: 'val 1', description: 'description 1' }
- },
- test: {
- stage: 'test',
- script: 'echo'
- }
- }
- end
-
- before do
- synchronous_reactive_cache(service)
- end
-
- it 'returns variable list' do
- 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
-
- context 'when sending an invalid ref' do
- let(:ref) { 'invalid-ref' }
-
- before do
- synchronous_reactive_cache(service)
- end
-
- it 'returns empty json' do
- get_config_variables
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to eq({})
- end
- end
-
- context 'when sending an invalid config' do
- let(:ref) { 'master' }
- let(:ci_config) do
- {
- variables: {
- KEY1: { value: 'val 1', description: 'description 1' }
- },
- test: {
- stage: 'invalid',
- script: 'echo'
- }
- }
- end
-
- before do
- synchronous_reactive_cache(service)
- end
-
- it 'returns empty result' do
- get_config_variables
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to eq({})
- end
- end
-
- context 'when the cache is empty' do
- let(:ref) { 'master' }
- let(:ci_config) do
- {
- variables: {
- KEY1: { value: 'val 1', description: 'description 1' }
- },
- test: {
- stage: 'test',
- script: 'echo'
- }
- }
- end
-
- it 'returns no content' do
- get_config_variables
-
- expect(response).to have_gitlab_http_status(:no_content)
- end
- end
-
- context 'when project uses external project ci config' do
- let(:other_project) { create(:project, :custom_repo, files: other_project_files) }
- let(:other_project_files) { { '.gitlab-ci.yml' => YAML.dump(other_project_ci_config) } }
- let(:ref) { 'master' }
-
- let(:other_project_ci_config) do
- {
- variables: {
- KEY1: { value: 'val 1', description: 'description 1' }
- },
- test: {
- stage: 'test',
- script: 'echo'
- }
- }
- end
-
- before do
- other_project.add_developer(user)
- project.update!(ci_config_path: ".gitlab-ci.yml@#{other_project.full_path}:master")
- synchronous_reactive_cache(service)
- end
-
- it 'returns other project config variables' do
- 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 get_config_variables
- get :config_variables, params: { namespace_id: project.namespace,
- project_id: project,
- sha: ref },
- format: :json
- end
- end
-
describe 'GET downloadable_artifacts.json' do
context 'when pipeline is empty' do
let(:pipeline) { create(:ci_empty_pipeline) }
diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb
index ab33195eb83..dbea3592e24 100644
--- a/spec/controllers/projects/project_members_controller_spec.rb
+++ b/spec/controllers/projects/project_members_controller_spec.rb
@@ -560,12 +560,4 @@ RSpec.describe Projects::ProjectMembersController do
end
it_behaves_like 'controller actions'
-
- context 'when project_members_index_by_project_namespace feature flag is disabled' do
- before do
- stub_feature_flags(project_members_index_by_project_namespace: false)
- end
-
- it_behaves_like 'controller actions'
- end
end
diff --git a/spec/controllers/projects/prometheus/alerts_controller_spec.rb b/spec/controllers/projects/prometheus/alerts_controller_spec.rb
index 09b9f25c0c6..91d3ba7e106 100644
--- a/spec/controllers/projects/prometheus/alerts_controller_spec.rb
+++ b/spec/controllers/projects/prometheus/alerts_controller_spec.rb
@@ -117,10 +117,7 @@ RSpec.describe Projects::Prometheus::AlertsController do
describe 'GET #metrics_dashboard' do
let!(:alert) do
- create(:prometheus_alert,
- project: project,
- environment: environment,
- prometheus_metric: metric)
+ create(:prometheus_alert, project: project, environment: environment, prometheus_metric: metric)
end
it 'returns a json object with the correct keys' do
diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb
index 40252cf65cd..b15a37d8d90 100644
--- a/spec/controllers/projects/raw_controller_spec.rb
+++ b/spec/controllers/projects/raw_controller_spec.rb
@@ -12,13 +12,9 @@ RSpec.describe Projects::RawController, feature_category: :source_code_managemen
describe 'GET #show' do
def get_show
- get(:show,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: file_path,
- inline: inline
- }.merge(params))
+ get :show, params: {
+ namespace_id: project.namespace, project_id: project, id: file_path, inline: inline
+ }.merge(params)
end
subject { get_show }
diff --git a/spec/controllers/projects/refs_controller_spec.rb b/spec/controllers/projects/refs_controller_spec.rb
index 7a511ab676e..0b1d0b75de7 100644
--- a/spec/controllers/projects/refs_controller_spec.rb
+++ b/spec/controllers/projects/refs_controller_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe Projects::RefsController, feature_category: :source_code_manageme
'tree' | nil | lazy { project_tree_path(project, id) }
'tree' | 'heads' | lazy { project_tree_path(project, id) }
'blob' | nil | lazy { project_blob_path(project, id) }
- 'blob' | 'heads' | lazy { project_blob_path(project, id, ref_type: 'heads') }
+ 'blob' | 'heads' | lazy { project_blob_path(project, id) }
'graph' | nil | lazy { project_network_path(project, id) }
'graph' | 'heads' | lazy { project_network_path(project, id, ref_type: 'heads') }
'graphs' | nil | lazy { project_graph_path(project, id) }
@@ -54,14 +54,9 @@ RSpec.describe Projects::RefsController, feature_category: :source_code_manageme
let(:path) { 'foo/bar/baz.html' }
def default_get(format = :html)
- get :logs_tree,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: 'master',
- path: path
- },
- format: format
+ get :logs_tree, params: {
+ namespace_id: project.namespace.to_param, project_id: project, id: 'master', path: path
+ }, format: format
end
def xhr_get(format = :html, params = {})
diff --git a/spec/controllers/projects/registry/repositories_controller_spec.rb b/spec/controllers/projects/registry/repositories_controller_spec.rb
index 59bc1ba04e7..834fdddd583 100644
--- a/spec/controllers/projects/registry/repositories_controller_spec.rb
+++ b/spec/controllers/projects/registry/repositories_controller_spec.rb
@@ -59,8 +59,7 @@ RSpec.describe Projects::Registry::RepositoriesController do
context 'when root container repository is not created' do
context 'when there are tags for this repository' do
before do
- stub_container_registry_tags(repository: :any,
- tags: %w[rc1 latest])
+ stub_container_registry_tags(repository: :any, tags: %w[rc1 latest])
end
it 'creates a root container repository' do
@@ -139,19 +138,12 @@ RSpec.describe Projects::Registry::RepositoriesController do
end
def go_to_index(format: :html, params: {})
- get :index, params: params.merge({
- namespace_id: project.namespace,
- project_id: project
- }),
- format: format
+ get :index, params: params.merge({ namespace_id: project.namespace, project_id: project }), format: format
end
def delete_repository(repository)
delete :destroy, params: {
- namespace_id: project.namespace,
- project_id: project,
- id: repository
- },
- format: :json
+ namespace_id: project.namespace, project_id: project, id: repository
+ }, format: :json
end
end
diff --git a/spec/controllers/projects/registry/tags_controller_spec.rb b/spec/controllers/projects/registry/tags_controller_spec.rb
index 7b786f4a8af..afa7bd6a60d 100644
--- a/spec/controllers/projects/registry/tags_controller_spec.rb
+++ b/spec/controllers/projects/registry/tags_controller_spec.rb
@@ -76,11 +76,8 @@ RSpec.describe Projects::Registry::TagsController do
def get_tags
get :index, params: {
- namespace_id: project.namespace,
- project_id: project,
- repository_id: repository
- },
- format: :json
+ namespace_id: project.namespace, project_id: project, repository_id: repository
+ }, format: :json
end
end
@@ -121,12 +118,11 @@ RSpec.describe Projects::Registry::TagsController do
def destroy_tag(name)
post :destroy, params: {
- namespace_id: project.namespace,
- project_id: project,
- repository_id: repository,
- id: name
- },
- format: :json
+ namespace_id: project.namespace,
+ project_id: project,
+ repository_id: repository,
+ id: name
+ }, format: :json
end
end
@@ -162,12 +158,11 @@ RSpec.describe Projects::Registry::TagsController do
def bulk_destroy_tags(names)
post :bulk_destroy, params: {
- namespace_id: project.namespace,
- project_id: project,
- repository_id: repository,
- ids: names
- },
- format: :json
+ namespace_id: project.namespace,
+ project_id: project,
+ repository_id: repository,
+ ids: names
+ }, format: :json
end
end
diff --git a/spec/controllers/projects/releases_controller_spec.rb b/spec/controllers/projects/releases_controller_spec.rb
index 2afd080344d..17bf9308834 100644
--- a/spec/controllers/projects/releases_controller_spec.rb
+++ b/spec/controllers/projects/releases_controller_spec.rb
@@ -158,9 +158,9 @@ RSpec.describe Projects::ReleasesController do
it_behaves_like 'successful request'
- it 'is accesible at a URL encoded path' do
+ it 'is accessible at a URL encoded path' do
expect(edit_project_release_path(project, release))
- .to eq("/#{project.namespace.path}/#{project.name}/-/releases/awesome%2Fv1.0/edit")
+ .to eq("/#{project.full_path}/-/releases/awesome%2Fv1.0/edit")
end
end
@@ -199,7 +199,7 @@ RSpec.describe Projects::ReleasesController do
it 'is accesible at a URL encoded path' do
expect(project_release_path(project, release))
- .to eq("/#{project.namespace.path}/#{project.name}/-/releases/awesome%2Fv1.0")
+ .to eq("/#{project.full_path}/-/releases/awesome%2Fv1.0")
end
end
diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb
index 8186176a46b..0efed45336f 100644
--- a/spec/controllers/projects/repositories_controller_spec.rb
+++ b/spec/controllers/projects/repositories_controller_spec.rb
@@ -106,19 +106,11 @@ RSpec.describe Projects::RepositoriesController, feature_category: :source_code_
end
end
- context "when the request format is HTML" do
- it "renders 404" do
- get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master' }, format: "html"
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
describe 'rate limiting' do
it 'rate limits user when thresholds hit' do
allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true)
- get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master' }, format: "html"
+ get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master' }, format: "zip"
expect(response).to have_gitlab_http_status(:too_many_requests)
end
diff --git a/spec/controllers/projects/runner_projects_controller_spec.rb b/spec/controllers/projects/runner_projects_controller_spec.rb
new file mode 100644
index 00000000000..beedaad0fa9
--- /dev/null
+++ b/spec/controllers/projects/runner_projects_controller_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::RunnerProjectsController, feature_category: :runner_fleet do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:source_project) { create(:project) }
+
+ before do
+ sign_in(user)
+ project.add_maintainer(user)
+ end
+
+ describe '#create' do
+ subject(:send_create) do
+ post :create, params: {
+ namespace_id: group.path,
+ project_id: project.path,
+ runner_project: { runner_id: project_runner.id }
+ }
+ end
+
+ context 'when assigning runner to another project' do
+ let(:project_runner) { create(:ci_runner, :project, projects: [source_project]) }
+
+ it 'redirects to the project runners page' do
+ source_project.add_maintainer(user)
+
+ send_create
+
+ expect(flash[:success]).to be_present
+ expect(response).to have_gitlab_http_status(:redirect)
+ expect(response).to redirect_to project_runners_path(project)
+ end
+ end
+ end
+
+ describe '#destroy' do
+ subject(:send_destroy) do
+ delete :destroy, params: {
+ namespace_id: group.path,
+ project_id: project.path,
+ id: runner_project_id
+ }
+ end
+
+ context 'when unassigning runner from project' do
+ let(:project_runner) { create(:ci_runner, :project, projects: [project]) }
+ let(:runner_project_id) { project_runner.runner_projects.last.id }
+
+ it 'redirects to the project runners page' do
+ send_destroy
+
+ expect(flash[:success]).to be_present
+ expect(response).to have_gitlab_http_status(:redirect)
+ expect(response).to redirect_to project_runners_path(project)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/runners_controller_spec.rb b/spec/controllers/projects/runners_controller_spec.rb
index 5733b8114d4..e0e4d0f7bc5 100644
--- a/spec/controllers/projects/runners_controller_spec.rb
+++ b/spec/controllers/projects/runners_controller_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe Projects::RunnersController, feature_category: :runner_fleet do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
- let(:runner) { create(:ci_runner, :project, projects: [project]) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:runner) { create(:ci_runner, :project, projects: [project]) }
let(:params) do
{
@@ -20,6 +20,137 @@ RSpec.describe Projects::RunnersController, feature_category: :runner_fleet do
project.add_maintainer(user)
end
+ describe '#new' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project
+ }
+ end
+
+ context 'when create_runner_workflow_for_namespace is enabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace])
+ end
+
+ context 'when user is maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'renders new with 200 status code' do
+ get :new, params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:new)
+ end
+ end
+
+ context 'when user is not maintainer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'renders a 404' do
+ get :new, params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when create_runner_workflow_for_namespace is disabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: false)
+ end
+
+ context 'when user is maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'renders a 404' do
+ get :new, params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ describe '#register' do
+ subject(:register) { get :register, params: { namespace_id: project.namespace, project_id: project, id: new_runner } }
+
+ context 'when create_runner_workflow_for_namespace is enabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace])
+ end
+
+ context 'when user is maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'when runner can be registered after creation' do
+ let_it_be(:new_runner) { create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user) }
+
+ it 'renders a :register template' do
+ register
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:register)
+ end
+ end
+
+ context 'when runner cannot be registered after creation' do
+ let_it_be(:new_runner) { runner }
+
+ it 'returns :not_found' do
+ register
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when user is not maintainer' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when runner can be registered after creation' do
+ let_it_be(:new_runner) { create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user) }
+
+ it 'returns :not_found' do
+ register
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ context 'when create_runner_workflow_for_namespace is disabled' do
+ let_it_be(:new_runner) { create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user) }
+
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: false)
+ end
+
+ context 'when user is maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'returns :not_found' do
+ register
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
describe '#update' do
it 'updates the runner and ticks the queue' do
new_desc = runner.description.swapcase
diff --git a/spec/controllers/projects/service_desk_controller_spec.rb b/spec/controllers/projects/service_desk_controller_spec.rb
index e078bf9461e..6b914ac8f19 100644
--- a/spec/controllers/projects/service_desk_controller_spec.rb
+++ b/spec/controllers/projects/service_desk_controller_spec.rb
@@ -12,8 +12,8 @@ RSpec.describe Projects::ServiceDeskController do
let_it_be(:user) { create(:user) }
before do
- allow(Gitlab::IncomingEmail).to receive(:enabled?) { true }
- allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true }
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?) { true }
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?) { true }
project.add_maintainer(user)
sign_in(user)
diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb
index ba917fa3a31..1c332eadc42 100644
--- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb
+++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb
@@ -173,12 +173,11 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let(:params) { { ci_config_path: '' } }
subject do
- patch :update,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- project: params
- }
+ patch :update, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ project: params
+ }
end
it 'redirects to the settings page' do
@@ -241,9 +240,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'creates a pipeline', :sidekiq_inline do
- project.repository.create_file(user, 'Gemfile', 'Gemfile contents',
- message: 'Add Gemfile',
- branch_name: 'master')
+ project.repository.create_file(user, 'Gemfile', 'Gemfile contents', message: 'Add Gemfile', branch_name: 'master')
expect { subject }.to change { Ci::Pipeline.count }.by(1)
end
diff --git a/spec/controllers/projects/settings/merge_requests_controller_spec.rb b/spec/controllers/projects/settings/merge_requests_controller_spec.rb
index 106ec62bea0..398fc97a00d 100644
--- a/spec/controllers/projects/settings/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/settings/merge_requests_controller_spec.rb
@@ -36,12 +36,11 @@ RSpec.describe Projects::Settings::MergeRequestsController do
merge_method: :ff
}
- put :update,
- params: {
- namespace_id: project.namespace,
- project_id: project.id,
- project: params
- }
+ put :update, params: {
+ namespace_id: project.namespace,
+ project_id: project.id,
+ project: params
+ }
expect(response).to redirect_to project_settings_merge_requests_path(project)
params.each do |param, value|
diff --git a/spec/controllers/projects/settings/operations_controller_spec.rb b/spec/controllers/projects/settings/operations_controller_spec.rb
index 76d8191e342..04dbd9ab671 100644
--- a/spec/controllers/projects/settings/operations_controller_spec.rb
+++ b/spec/controllers/projects/settings/operations_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::Settings::OperationsController do
+RSpec.describe Projects::Settings::OperationsController, feature_category: :incident_management do
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project) }
@@ -11,6 +11,8 @@ RSpec.describe Projects::Settings::OperationsController do
end
before do
+ stub_feature_flags(remove_monitor_metrics: false)
+
sign_in(user)
end
@@ -65,6 +67,20 @@ RSpec.describe Projects::Settings::OperationsController do
end
end
+ shared_examples 'PATCHable without metrics dashboard' do
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ include_examples 'PATCHable' do
+ let(:permitted_params) do
+ ActionController::Parameters.new({}).permit!
+ end
+ end
+ end
+ end
+
describe 'GET #show' do
it 'renders show template' do
get :show, params: project_params(project)
@@ -124,7 +140,7 @@ RSpec.describe Projects::Settings::OperationsController do
end
end
- context 'incident management' do
+ context 'incident management', feature_category: :incident_management do
describe 'GET #show' do
context 'with existing setting' do
let!(:incident_management_setting) do
@@ -278,7 +294,7 @@ RSpec.describe Projects::Settings::OperationsController do
end
end
- context 'error tracking' do
+ context 'error tracking', feature_category: :error_tracking do
describe 'GET #show' do
context 'with existing setting' do
let!(:error_tracking_setting) do
@@ -323,7 +339,7 @@ RSpec.describe Projects::Settings::OperationsController do
end
end
- context 'metrics dashboard setting' do
+ context 'metrics dashboard setting', feature_category: :metrics do
describe 'PATCH #update' do
let(:params) do
{
@@ -333,11 +349,12 @@ RSpec.describe Projects::Settings::OperationsController do
}
end
- it_behaves_like 'PATCHable'
+ include_examples 'PATCHable'
+ include_examples 'PATCHable without metrics dashboard'
end
end
- context 'grafana integration' do
+ context 'grafana integration', feature_category: :metrics do
describe 'PATCH #update' do
let(:params) do
{
@@ -349,7 +366,8 @@ RSpec.describe Projects::Settings::OperationsController do
}
end
- it_behaves_like 'PATCHable'
+ include_examples 'PATCHable'
+ include_examples 'PATCHable without metrics dashboard'
end
end
diff --git a/spec/controllers/projects/snippets/blobs_controller_spec.rb b/spec/controllers/projects/snippets/blobs_controller_spec.rb
index ca656705e07..4d12452e3d5 100644
--- a/spec/controllers/projects/snippets/blobs_controller_spec.rb
+++ b/spec/controllers/projects/snippets/blobs_controller_spec.rb
@@ -26,15 +26,14 @@ RSpec.describe Projects::Snippets::BlobsController do
let(:inline) { nil }
subject do
- get(:raw,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- snippet_id: snippet,
- path: filepath,
- ref: ref,
- inline: inline
- })
+ get :raw, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ snippet_id: snippet,
+ path: filepath,
+ ref: ref,
+ inline: inline
+ }
end
context 'with a snippet without a repository' do
diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb
index a388fc4620f..119e52480db 100644
--- a/spec/controllers/projects/snippets_controller_spec.rb
+++ b/spec/controllers/projects/snippets_controller_spec.rb
@@ -102,12 +102,11 @@ RSpec.describe Projects::SnippetsController do
project.add_maintainer(admin)
sign_in(admin)
- post :mark_as_spam,
- params: {
- namespace_id: project.namespace,
- project_id: project,
- id: snippet.id
- }
+ post :mark_as_spam, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: snippet.id
+ }
end
it 'updates the snippet', :enable_admin_mode do
diff --git a/spec/controllers/projects/tree_controller_spec.rb b/spec/controllers/projects/tree_controller_spec.rb
index 37149e1d3ca..61998d516e8 100644
--- a/spec/controllers/projects/tree_controller_spec.rb
+++ b/spec/controllers/projects/tree_controller_spec.rb
@@ -136,12 +136,9 @@ RSpec.describe Projects::TreeController, feature_category: :source_code_manageme
allow(::Gitlab::GitalyClient).to receive(:call).and_call_original
expect(::Gitlab::GitalyClient).not_to receive(:call).with(anything, :commit_service, :find_commit, anything, anything)
- get(:show,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: id
- })
+ get :show, params: {
+ namespace_id: project.namespace.to_param, project_id: project, id: id
+ }
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -151,12 +148,9 @@ RSpec.describe Projects::TreeController, feature_category: :source_code_manageme
render_views
before do
- get(:show,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: id
- })
+ get :show, params: {
+ namespace_id: project.namespace.to_param, project_id: project, id: id
+ }
end
context 'redirect to blob' do
@@ -164,8 +158,7 @@ RSpec.describe Projects::TreeController, feature_category: :source_code_manageme
it 'redirects' do
redirect_url = "/#{project.full_path}/-/blob/master/README.md"
- expect(subject)
- .to redirect_to(redirect_url)
+ expect(subject).to redirect_to(redirect_url)
end
end
end
@@ -174,15 +167,14 @@ RSpec.describe Projects::TreeController, feature_category: :source_code_manageme
render_views
before do
- post(:create_dir,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: 'master',
- dir_name: path,
- branch_name: branch_name,
- commit_message: 'Test commit message'
- })
+ post :create_dir, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: 'master',
+ dir_name: path,
+ branch_name: branch_name,
+ commit_message: 'Test commit message'
+ }
end
context 'successful creation' do
diff --git a/spec/controllers/projects/wikis_controller_spec.rb b/spec/controllers/projects/wikis_controller_spec.rb
index 7243588681d..353cd62686f 100644
--- a/spec/controllers/projects/wikis_controller_spec.rb
+++ b/spec/controllers/projects/wikis_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::WikisController do
+RSpec.describe Projects::WikisController, feature_category: :wiki do
it_behaves_like 'wiki controller actions' do
let(:container) { create(:project, :public, namespace: user.namespace) }
let(:routing_params) { { namespace_id: container.namespace, project_id: container } }
diff --git a/spec/controllers/projects/work_items_controller_spec.rb b/spec/controllers/projects/work_items_controller_spec.rb
new file mode 100644
index 00000000000..e0f61a4977b
--- /dev/null
+++ b/spec/controllers/projects/work_items_controller_spec.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::WorkItemsController, feature_category: :team_planning do
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:work_item) { create(:work_item, project: project) }
+
+ let(:file) { 'file' }
+
+ before do
+ project.add_reporter(reporter)
+ project.add_guest(guest)
+ end
+
+ shared_examples 'response with 404 status' do
+ it 'renders a not found message' do
+ expect(WorkItems::ImportWorkItemsCsvWorker).not_to receive(:perform_async)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ shared_examples 'redirects to new session path' do
+ it 'redirects to sign in' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+
+ describe 'GET index' do
+ specify do
+ expect(
+ get(:index, params: { namespace_id: project.namespace, project_id: project, work_items_path: work_item.id })
+ ).to have_request_urgency(:low)
+ end
+ end
+
+ describe 'POST authorize' do
+ subject do
+ post(:authorize, params: { namespace_id: project.namespace, project_id: project, file: file })
+ end
+
+ specify do
+ expect(subject).to have_request_urgency(:high)
+ end
+
+ context 'when user is anonymous' do
+ it_behaves_like 'redirects to new session path'
+ end
+ end
+
+ describe 'POST import_csv' do
+ subject { post :import_csv, params: { namespace_id: project.namespace, project_id: project, file: file } }
+
+ let(:upload_service) { double }
+ let(:uploader) { double }
+ let(:upload) { double }
+ let(:upload_id) { 99 }
+
+ specify do
+ expect(subject).to have_request_urgency(:low)
+ end
+
+ context 'with authorized user' do
+ before do
+ sign_in(reporter)
+ allow(controller).to receive(:file_is_valid?).and_return(true)
+ end
+
+ context 'when feature is available' do
+ context 'when the upload is processed successfully' do
+ before do
+ mock_upload
+ end
+
+ it 'renders the correct message' do
+ expect(WorkItems::ImportWorkItemsCsvWorker).to receive(:perform_async)
+ .with(reporter.id, project.id, upload_id)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['message']).to eq(
+ "Your work items are being imported. Once finished, you'll receive a confirmation email."
+ )
+ end
+ end
+
+ context 'when file is not valid' do
+ before do
+ allow(controller).to receive(:file_is_valid?).and_return(false)
+ end
+
+ it 'renders the error message' do
+ expect(WorkItems::ImportWorkItemsCsvWorker).not_to receive(:perform_async)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['errors'])
+ .to eq('The uploaded file was invalid. Supported file extensions are .csv.')
+ end
+ end
+
+ context 'when service response includes errors' do
+ before do
+ mock_upload(false)
+ end
+
+ it 'renders the error message' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['errors']).to eq('File upload error.')
+ end
+ end
+ end
+
+ context 'when feature is not available' do
+ before do
+ stub_feature_flags(import_export_work_items_csv: false)
+ end
+
+ it_behaves_like 'response with 404 status'
+ end
+ end
+
+ context 'with unauthorised user' do
+ before do
+ mock_upload
+ sign_in(guest)
+ allow(controller).to receive(:file_is_valid?).and_return(true)
+ end
+
+ it_behaves_like 'response with 404 status'
+ end
+
+ context 'with anonymous user' do
+ it 'redirects to sign in page' do
+ expect(WorkItems::ImportWorkItemsCsvWorker).not_to receive(:perform_async)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index c5ec6651ab3..b652aba1fff 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -571,11 +571,7 @@ RSpec.describe ProjectsController, feature_category: :projects do
it 'allows an admin user to access the page', :enable_admin_mode do
sign_in(create(:user, :admin))
- get :edit,
- params: {
- namespace_id: project.namespace.path,
- id: project.path
- }
+ get :edit, params: { namespace_id: project.namespace.path, id: project.path }
expect(response).to have_gitlab_http_status(:ok)
end
@@ -584,11 +580,7 @@ RSpec.describe ProjectsController, feature_category: :projects do
sign_in(user)
project.add_maintainer(user)
- get :edit,
- params: {
- namespace_id: project.namespace.path,
- id: project.path
- }
+ get :edit, params: { namespace_id: project.namespace.path, id: project.path }
expect(assigns(:badge_api_endpoint)).not_to be_nil
end
@@ -606,10 +598,7 @@ RSpec.describe ProjectsController, feature_category: :projects do
before do
group.add_owner(user)
- post :archive, params: {
- namespace_id: project.namespace.path,
- id: project.path
- }
+ post :archive, params: { namespace_id: project.namespace.path, id: project.path }
end
it 'archives the project' do
@@ -853,12 +842,7 @@ RSpec.describe ProjectsController, feature_category: :projects do
merge_method: :ff
}
- put :update,
- params: {
- namespace_id: project.namespace,
- id: project.id,
- project: params
- }
+ put :update, params: { namespace_id: project.namespace, id: project.id, project: params }
expect(response).to have_gitlab_http_status(:found)
params.each do |param, value|
@@ -874,22 +858,12 @@ RSpec.describe ProjectsController, feature_category: :projects do
}
expect do
- put :update,
- params: {
- namespace_id: project.namespace,
- id: project.id,
- project: params
- }
+ put :update, params: { namespace_id: project.namespace, id: project.id, project: params }
end.not_to change { project.namespace.reload }
end
def update_project(**parameters)
- put :update,
- params: {
- namespace_id: project.namespace.path,
- id: project.path,
- project: parameters
- }
+ put :update, params: { namespace_id: project.namespace.path, id: project.path, project: parameters }
end
end
@@ -913,12 +887,9 @@ RSpec.describe ProjectsController, feature_category: :projects do
it_behaves_like 'unauthorized when external service denies access' do
subject do
- put :update,
- params: {
- namespace_id: project.namespace,
- id: project,
- project: { description: 'Hello world' }
- }
+ put :update, params: {
+ namespace_id: project.namespace, id: project, project: { description: 'Hello world' }
+ }
project.reload
end
@@ -1038,13 +1009,9 @@ RSpec.describe ProjectsController, feature_category: :projects do
old_namespace = project.namespace
- put :transfer,
- params: {
- namespace_id: old_namespace.path,
- new_namespace_id: new_namespace_id,
- id: project.path
- },
- format: :js
+ put :transfer, params: {
+ namespace_id: old_namespace.path, new_namespace_id: new_namespace_id, id: project.path
+ }, format: :js
project.reload
@@ -1057,13 +1024,9 @@ RSpec.describe ProjectsController, feature_category: :projects do
it 'updates namespace' do
sign_in(admin)
- put :transfer,
- params: {
- namespace_id: project.namespace.path,
- new_namespace_id: new_namespace.id,
- id: project.path
- },
- format: :js
+ put :transfer, params: {
+ namespace_id: project.namespace.path, new_namespace_id: new_namespace.id, id: project.path
+ }, format: :js
project.reload
@@ -1183,32 +1146,19 @@ RSpec.describe ProjectsController, feature_category: :projects do
it "toggles star if user is signed in" do
sign_in(user)
expect(user.starred?(public_project)).to be_falsey
- post(:toggle_star,
- params: {
- namespace_id: public_project.namespace,
- id: public_project
- })
+
+ post :toggle_star, params: { namespace_id: public_project.namespace, id: public_project }
expect(user.starred?(public_project)).to be_truthy
- post(:toggle_star,
- params: {
- namespace_id: public_project.namespace,
- id: public_project
- })
+
+ post :toggle_star, params: { namespace_id: public_project.namespace, id: public_project }
expect(user.starred?(public_project)).to be_falsey
end
it "does nothing if user is not signed in" do
- post(:toggle_star,
- params: {
- namespace_id: project.namespace,
- id: public_project
- })
+ post :toggle_star, params: { namespace_id: project.namespace, id: public_project }
expect(user.starred?(public_project)).to be_falsey
- post(:toggle_star,
- params: {
- namespace_id: project.namespace,
- id: public_project
- })
+
+ post :toggle_star, params: { namespace_id: project.namespace, id: public_project }
expect(user.starred?(public_project)).to be_falsey
end
end
@@ -1223,12 +1173,9 @@ RSpec.describe ProjectsController, feature_category: :projects do
let(:forked_project) { fork_project(create(:project, :public), user) }
it 'removes fork from project' do
- delete(:remove_fork,
- params: {
- namespace_id: forked_project.namespace.to_param,
- id: forked_project.to_param
- },
- format: :js)
+ delete :remove_fork, params: {
+ namespace_id: forked_project.namespace.to_param, id: forked_project.to_param
+ }, format: :js
expect(forked_project.reload.forked?).to be_falsey
expect(flash[:notice]).to eq(s_('The fork relationship has been removed.'))
@@ -1240,12 +1187,9 @@ RSpec.describe ProjectsController, feature_category: :projects do
let(:unforked_project) { create(:project, namespace: user.namespace) }
it 'does nothing if project was not forked' do
- delete(:remove_fork,
- params: {
- namespace_id: unforked_project.namespace,
- id: unforked_project
- },
- format: :js)
+ delete :remove_fork, params: {
+ namespace_id: unforked_project.namespace, id: unforked_project
+ }, format: :js
expect(flash[:notice]).to be_nil
expect(response).to redirect_to(edit_project_path(unforked_project))
@@ -1254,12 +1198,10 @@ RSpec.describe ProjectsController, feature_category: :projects do
end
it "does nothing if user is not signed in" do
- delete(:remove_fork,
- params: {
- namespace_id: project.namespace,
- id: project
- },
- format: :js)
+ delete :remove_fork, params: {
+ namespace_id: project.namespace, id: project
+ }, format: :js
+
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
@@ -1352,6 +1294,19 @@ RSpec.describe ProjectsController, feature_category: :projects do
expect(response).to have_gitlab_http_status(:success)
end
end
+
+ context 'when sort param is invalid' do
+ let(:request) { get :refs, params: { namespace_id: project.namespace, id: project, sort: 'invalid' } }
+
+ it 'uses default sort by name' do
+ request
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response['Branches']).to include('master')
+ expect(json_response['Tags']).to include('v1.0.0')
+ expect(json_response['Commits']).to be_nil
+ end
+ end
end
describe 'POST #preview_markdown' do
@@ -1818,18 +1773,13 @@ RSpec.describe ProjectsController, feature_category: :projects do
it 'updates Service Desk attributes' do
project.add_maintainer(user)
sign_in(user)
- allow(Gitlab::IncomingEmail).to receive(:enabled?) { true }
- allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true }
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?) { true }
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?) { true }
params = {
service_desk_enabled: true
}
- put :update,
- params: {
- namespace_id: project.namespace,
- id: project,
- project: params
- }
+ put :update, params: { namespace_id: project.namespace, id: project, project: params }
project.reload
expect(response).to have_gitlab_http_status(:found)
diff --git a/spec/controllers/registrations/welcome_controller_spec.rb b/spec/controllers/registrations/welcome_controller_spec.rb
index b5416d226e1..4118754144c 100644
--- a/spec/controllers/registrations/welcome_controller_spec.rb
+++ b/spec/controllers/registrations/welcome_controller_spec.rb
@@ -2,10 +2,10 @@
require 'spec_helper'
-RSpec.describe Registrations::WelcomeController, feature_category: :authentication_and_authorization do
+RSpec.describe Registrations::WelcomeController, feature_category: :system_access do
let(:user) { create(:user) }
- describe '#welcome' do
+ describe '#show' do
subject(:show) { get :show }
context 'without a signed in user' do
@@ -27,6 +27,14 @@ RSpec.describe Registrations::WelcomeController, feature_category: :authenticati
end
it { is_expected.to render_template(:show) }
+
+ render_views
+
+ it 'has the expected submission url' do
+ show
+
+ expect(response.body).to include("action=\"#{users_sign_up_welcome_path}\"")
+ end
end
context 'when role and setup_for_company is set' do
@@ -57,6 +65,32 @@ RSpec.describe Registrations::WelcomeController, feature_category: :authenticati
expect(subject).not_to redirect_to(profile_two_factor_auth_path)
end
end
+
+ context 'when welcome step is completed' do
+ before do
+ user.update!(setup_for_company: true)
+ end
+
+ context 'when user is confirmed' do
+ before do
+ sign_in(user)
+ end
+
+ it { is_expected.to redirect_to dashboard_projects_path }
+ end
+
+ context 'when user is not confirmed' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'hard')
+
+ sign_in(user)
+
+ user.update!(confirmed_at: nil)
+ end
+
+ it { is_expected.to redirect_to user_session_path }
+ end
+ end
end
describe '#update' do
diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb
index b217b100349..9aa8a2ae605 100644
--- a/spec/controllers/registrations_controller_spec.rb
+++ b/spec/controllers/registrations_controller_spec.rb
@@ -12,15 +12,23 @@ RSpec.describe RegistrationsController, feature_category: :user_profile do
end
describe '#new' do
- subject { get :new }
+ subject(:new) { get :new }
it 'renders new template and sets the resource variable' do
- expect(subject).to render_template(:new)
+ expect(new).to render_template(:new)
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:resource)).to be_a(User)
end
it_behaves_like "switches to user preferred language", 'Sign up'
+
+ render_views
+
+ it 'has the expected registration url' do
+ new
+
+ expect(response.body).to include("action=\"#{user_registration_path}\"")
+ end
end
describe '#create' do
@@ -75,7 +83,7 @@ RSpec.describe RegistrationsController, feature_category: :user_profile do
end
context 'email confirmation' do
- context 'when `email_confirmation_setting` is set to `hard`' do
+ context 'when email confirmation setting is set to `hard`' do
before do
stub_application_setting_enum('email_confirmation_setting', 'hard')
end
@@ -122,7 +130,7 @@ RSpec.describe RegistrationsController, feature_category: :user_profile do
end
context 'email confirmation' do
- context 'when `email_confirmation_setting` is set to `hard`' do
+ context 'when email confirmation setting is set to `hard`' do
before do
stub_application_setting_enum('email_confirmation_setting', 'hard')
stub_feature_flags(identity_verification: false)
@@ -157,7 +165,7 @@ RSpec.describe RegistrationsController, feature_category: :user_profile do
stub_feature_flags(identity_verification: false)
end
- context 'when `email_confirmation_setting` is set to `off`' do
+ context 'when email confirmation setting is set to `off`' do
it 'signs the user in' do
stub_application_setting_enum('email_confirmation_setting', 'off')
@@ -166,103 +174,97 @@ RSpec.describe RegistrationsController, feature_category: :user_profile do
end
end
- context 'when `email_confirmation_setting` is set to `hard`' do
+ context 'when email confirmation setting is set to `hard`' do
before do
stub_application_setting_enum('email_confirmation_setting', 'hard')
+ allow(User).to receive(:allow_unconfirmed_access_for).and_return 0
end
- context 'when soft email confirmation is not enabled' do
- before do
- stub_feature_flags(soft_email_confirmation: false)
- allow(User).to receive(:allow_unconfirmed_access_for).and_return 0
- end
+ it 'does not authenticate the user and sends a confirmation email' do
+ expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ expect(controller.current_user).to be_nil
+ end
- it 'does not authenticate the user and sends a confirmation email' do
- expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
- expect(controller.current_user).to be_nil
- end
+ it 'tracks an almost there redirect' do
+ post_create
- it 'tracks an almost there redirect' do
- post_create
+ expect_snowplow_event(
+ category: described_class.name,
+ action: 'render',
+ user: User.find_by(email: base_user_params[:email])
+ )
+ end
- expect_snowplow_event(
- category: described_class.name,
- action: 'render',
- user: User.find_by(email: base_user_params[:email])
- )
- end
+ context 'when registration is triggered from an accepted invite' do
+ context 'when it is part from the initial invite email', :snowplow do
+ let_it_be(:member) { create(:project_member, :invited, invite_email: user_params.dig(:user, :email)) }
- context 'when registration is triggered from an accepted invite' do
- context 'when it is part from the initial invite email', :snowplow do
- let_it_be(:member) { create(:project_member, :invited, invite_email: user_params.dig(:user, :email)) }
+ let(:originating_member_id) { member.id }
+ let(:session_params) do
+ {
+ invite_email: user_params.dig(:user, :email),
+ originating_member_id: originating_member_id
+ }
+ end
- let(:originating_member_id) { member.id }
- let(:session_params) do
- {
- invite_email: user_params.dig(:user, :email),
- originating_member_id: originating_member_id
- }
+ context 'when member exists from the session key value' do
+ it 'tracks the invite acceptance' do
+ subject
+
+ expect_snowplow_event(
+ category: 'RegistrationsController',
+ action: 'accepted',
+ label: 'invite_email',
+ property: member.id.to_s,
+ user: member.reload.user
+ )
+
+ expect_snowplow_event(
+ category: 'RegistrationsController',
+ action: 'create_user',
+ label: 'invited',
+ user: member.reload.user
+ )
end
+ end
- context 'when member exists from the session key value' do
- it 'tracks the invite acceptance' do
- subject
-
- expect_snowplow_event(
- category: 'RegistrationsController',
- action: 'accepted',
- label: 'invite_email',
- property: member.id.to_s,
- user: member.reload.user
- )
-
- expect_snowplow_event(
- category: 'RegistrationsController',
- action: 'create_user',
- label: 'invited',
- user: member.reload.user
- )
- end
- end
+ context 'when member does not exist from the session key value' do
+ let(:originating_member_id) { nil }
+
+ it 'does not track invite acceptance' do
+ subject
+
+ expect_no_snowplow_event(
+ category: 'RegistrationsController',
+ action: 'accepted',
+ label: 'invite_email'
+ )
- context 'when member does not exist from the session key value' do
- let(:originating_member_id) { nil }
-
- it 'does not track invite acceptance' do
- subject
-
- expect_no_snowplow_event(
- category: 'RegistrationsController',
- action: 'accepted',
- label: 'invite_email'
- )
-
- expect_snowplow_event(
- category: 'RegistrationsController',
- action: 'create_user',
- label: 'signup',
- user: member.reload.user
- )
- end
+ expect_snowplow_event(
+ category: 'RegistrationsController',
+ action: 'create_user',
+ label: 'signup',
+ user: member.reload.user
+ )
end
end
+ end
- context 'when invite email matches email used on registration' do
- let(:session_params) { { invite_email: user_params.dig(:user, :email) } }
+ context 'when invite email matches email used on registration' do
+ let(:session_params) { { invite_email: user_params.dig(:user, :email) } }
- it 'signs the user in without sending a confirmation email', :aggregate_failures do
- expect { subject }.not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
- expect(controller.current_user).to be_confirmed
- end
+ it 'signs the user in without sending a confirmation email', :aggregate_failures do
+ expect { subject }.not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ expect(controller.current_user).to be_confirmed
end
+ end
- context 'when invite email does not match the email used on registration' do
- let(:session_params) { { invite_email: 'bogus@email.com' } }
+ context 'when invite email does not match the email used on registration' do
+ let(:session_params) { { invite_email: 'bogus@email.com' } }
- it 'does not authenticate the user and sends a confirmation email', :aggregate_failures do
- expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
- expect(controller.current_user).to be_nil
- end
+ it 'does not authenticate the user and sends a confirmation email', :aggregate_failures do
+ expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ expect(controller.current_user).to be_nil
end
end
end
@@ -286,45 +288,45 @@ RSpec.describe RegistrationsController, feature_category: :user_profile do
expect(controller.current_user).to be_nil
end
end
+ end
- context 'when soft email confirmation is enabled' do
- before do
- stub_feature_flags(soft_email_confirmation: true)
- allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days
- end
+ context 'when email confirmation setting is set to `soft`' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
+ allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days
+ end
- it 'authenticates the user and sends a confirmation email' do
- expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
- expect(controller.current_user).to be_present
- expect(response).to redirect_to(users_sign_up_welcome_path)
- end
+ it 'authenticates the user and sends a confirmation email' do
+ expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ expect(controller.current_user).to be_present
+ expect(response).to redirect_to(users_sign_up_welcome_path)
+ end
- it 'does not track an almost there redirect' do
- post_create
+ it 'does not track an almost there redirect' do
+ post_create
- expect_no_snowplow_event(
- category: described_class.name,
- action: 'render',
- user: User.find_by(email: base_user_params[:email])
- )
- end
+ expect_no_snowplow_event(
+ category: described_class.name,
+ action: 'render',
+ user: User.find_by(email: base_user_params[:email])
+ )
+ end
- context 'when invite email matches email used on registration' do
- let(:session_params) { { invite_email: user_params.dig(:user, :email) } }
+ context 'when invite email matches email used on registration' do
+ let(:session_params) { { invite_email: user_params.dig(:user, :email) } }
- it 'signs the user in without sending a confirmation email', :aggregate_failures do
- expect { subject }.not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
- expect(controller.current_user).to be_confirmed
- end
+ it 'signs the user in without sending a confirmation email', :aggregate_failures do
+ expect { subject }.not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ expect(controller.current_user).to be_confirmed
end
+ end
- context 'when invite email does not match the email used on registration' do
- let(:session_params) { { invite_email: 'bogus@email.com' } }
+ context 'when invite email does not match the email used on registration' do
+ let(:session_params) { { invite_email: 'bogus@email.com' } }
- it 'authenticates the user and sends a confirmation email without confirming', :aggregate_failures do
- expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
- expect(controller.current_user).not_to be_confirmed
- end
+ it 'authenticates the user and sends a confirmation email without confirming', :aggregate_failures do
+ expect { subject }.to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ expect(controller.current_user).not_to be_confirmed
end
end
end
@@ -756,8 +758,7 @@ RSpec.describe RegistrationsController, feature_category: :user_profile do
m.call(*args)
expect(Gitlab::ApplicationContext.current)
- .to include('meta.user' => user.username,
- 'meta.caller_id' => 'RegistrationsController#destroy')
+ .to include('meta.user' => user.username, 'meta.caller_id' => 'RegistrationsController#destroy')
end
post :destroy
diff --git a/spec/controllers/repositories/git_http_controller_spec.rb b/spec/controllers/repositories/git_http_controller_spec.rb
index da62acb1fda..276bd9b65b9 100644
--- a/spec/controllers/repositories/git_http_controller_spec.rb
+++ b/spec/controllers/repositories/git_http_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Repositories::GitHttpController do
+RSpec.describe Repositories::GitHttpController, feature_category: :source_code_management do
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:personal_snippet) { create(:personal_snippet, :public, :repository) }
let_it_be(:project_snippet) { create(:project_snippet, :public, :repository, project: project) }
@@ -14,7 +14,7 @@ RSpec.describe Repositories::GitHttpController do
request.headers.merge! auth_env(user.username, user.password, nil)
end
- context 'when Gitaly is unavailable' do
+ context 'when Gitaly is unavailable', :use_clean_rails_redis_caching do
it 'responds with a 503 message' do
expect(Gitlab::GitalyClient).to receive(:call).and_raise(GRPC::Unavailable)
@@ -26,6 +26,58 @@ RSpec.describe Repositories::GitHttpController do
end
end
+ shared_examples 'handles user activity' do
+ it 'updates the user activity' do
+ activity_project = container.is_a?(PersonalSnippet) ? nil : project
+
+ activity_service = instance_double(Users::ActivityService)
+
+ args = { author: user, project: activity_project, namespace: activity_project&.namespace }
+ expect(Users::ActivityService).to receive(:new).with(args).and_return(activity_service)
+
+ expect(activity_service).to receive(:execute)
+
+ get :info_refs, params: params
+ end
+ end
+
+ shared_examples 'handles logging git upload pack operation' do
+ before do
+ password = user.try(:password) || user.try(:token)
+ request.headers.merge! auth_env(user.username, password, nil)
+ end
+
+ context 'with git pull/fetch/clone action' do
+ let(:params) { super().merge(service: 'git-upload-pack') }
+
+ it_behaves_like 'handles user activity'
+ end
+ end
+
+ shared_examples 'handles logging git receive pack operation' do
+ let(:params) { super().merge(service: 'git-receive-pack') }
+
+ before do
+ request.headers.merge! auth_env(user.username, user.password, nil)
+ end
+
+ context 'with git push action when log_user_git_push_activity is enabled' do
+ it_behaves_like 'handles user activity'
+ end
+
+ context 'when log_user_git_push_activity is disabled' do
+ before do
+ stub_feature_flags(log_user_git_push_activity: false)
+ end
+
+ it 'does not log user activity' do
+ expect(controller).not_to receive(:log_user_activity)
+
+ get :info_refs, params: params
+ end
+ end
+ end
+
context 'when repository container is a project' do
it_behaves_like Repositories::GitHttpController do
let(:container) { project }
@@ -33,6 +85,8 @@ RSpec.describe Repositories::GitHttpController do
let(:access_checker_class) { Gitlab::GitAccess }
it_behaves_like 'handles unavailable Gitaly'
+ it_behaves_like 'handles logging git upload pack operation'
+ it_behaves_like 'handles logging git receive pack operation'
describe 'POST #git_upload_pack' do
before do
@@ -83,6 +137,8 @@ RSpec.describe Repositories::GitHttpController do
let(:container) { project }
let(:user) { create(:deploy_token, :project, projects: [project]) }
let(:access_checker_class) { Gitlab::GitAccess }
+
+ it_behaves_like 'handles logging git upload pack operation'
end
end
end
@@ -92,6 +148,9 @@ RSpec.describe Repositories::GitHttpController do
let(:container) { create(:project_wiki, :empty_repo, project: project) }
let(:user) { project.first_owner }
let(:access_checker_class) { Gitlab::GitAccessWiki }
+
+ it_behaves_like 'handles logging git upload pack operation'
+ it_behaves_like 'handles logging git receive pack operation'
end
end
@@ -102,6 +161,8 @@ RSpec.describe Repositories::GitHttpController do
let(:access_checker_class) { Gitlab::GitAccessSnippet }
it_behaves_like 'handles unavailable Gitaly'
+ it_behaves_like 'handles logging git upload pack operation'
+ it_behaves_like 'handles logging git receive pack operation'
end
end
@@ -112,6 +173,8 @@ RSpec.describe Repositories::GitHttpController do
let(:access_checker_class) { Gitlab::GitAccessSnippet }
it_behaves_like 'handles unavailable Gitaly'
+ it_behaves_like 'handles logging git upload pack operation'
+ it_behaves_like 'handles logging git receive pack operation'
end
end
end
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 0f7f4a1910b..497e2d84f4f 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -38,6 +38,41 @@ RSpec.describe SearchController, feature_category: :global_search do
it_behaves_like 'with external authorization service enabled', :show, { search: 'hello' }
it_behaves_like 'support for active record query timeouts', :show, { search: 'hello' }, :search_objects, :html
+ describe 'rate limit scope' do
+ it 'uses current_user and search scope' do
+ %w[projects blobs users issues merge_requests].each do |scope|
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user, scope])
+ get :show, params: { search: 'hello', scope: scope }
+ end
+ end
+
+ it 'uses just current_user when no search scope is used' do
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get :show, params: { search: 'hello' }
+ end
+
+ it 'uses just current_user when search scope is abusive' do
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get(:show, params: { search: 'hello', scope: 'hack-the-mainframe' })
+
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get :show, params: { search: 'hello', scope: 'blobs' * 1000 }
+ end
+
+ context 'when search_rate_limited_scopes feature flag is disabled' do
+ before do
+ stub_feature_flags(search_rate_limited_scopes: false)
+ end
+
+ it 'uses just current_user' do
+ %w[projects blobs users issues merge_requests].each do |scope|
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get :show, params: { search: 'hello', scope: scope }
+ end
+ end
+ end
+ end
+
context 'uses the right partials depending on scope' do
using RSpec::Parameterized::TableSyntax
render_views
@@ -227,12 +262,10 @@ RSpec.describe SearchController, feature_category: :global_search do
let(:label) { 'redis_hll_counters.search.search_total_unique_counts_monthly' }
let(:property) { 'i_search_total' }
let(:context) do
- [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll,
- event: property).to_context]
+ [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: property).to_context]
end
let(:namespace) { create(:group) }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
end
context 'on restricted projects' do
@@ -347,6 +380,36 @@ RSpec.describe SearchController, feature_category: :global_search do
expect(json_response).to eq({ 'count' => '1' })
end
+ describe 'rate limit scope' do
+ it 'uses current_user and search scope' do
+ %w[projects blobs users issues merge_requests].each do |scope|
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user, scope])
+ get :count, params: { search: 'hello', scope: scope }
+ end
+ end
+
+ it 'uses just current_user when search scope is abusive' do
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get :count, params: { search: 'hello', scope: 'hack-the-mainframe' }
+
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get :count, params: { search: 'hello', scope: 'blobs' * 1000 }
+ end
+
+ context 'when search_rate_limited_scopes feature flag is disabled' do
+ before do
+ stub_feature_flags(search_rate_limited_scopes: false)
+ end
+
+ it 'uses just current_user' do
+ %w[projects blobs users issues merge_requests].each do |scope|
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get :count, params: { search: 'hello', scope: scope }
+ end
+ end
+ end
+ end
+
it 'raises an error if search term is missing' do
expect do
get :count, params: { scope: 'projects' }
@@ -408,6 +471,36 @@ RSpec.describe SearchController, feature_category: :global_search do
expect(json_response).to match_array([])
end
+ describe 'rate limit scope' do
+ it 'uses current_user and search scope' do
+ %w[projects blobs users issues merge_requests].each do |scope|
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user, scope])
+ get :autocomplete, params: { term: 'hello', scope: scope }
+ end
+ end
+
+ it 'uses just current_user when search scope is abusive' do
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get :autocomplete, params: { term: 'hello', scope: 'hack-the-mainframe' }
+
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get :autocomplete, params: { term: 'hello', scope: 'blobs' * 1000 }
+ end
+
+ context 'when search_rate_limited_scopes feature flag is disabled' do
+ before do
+ stub_feature_flags(search_rate_limited_scopes: false)
+ end
+
+ it 'uses just current_user' do
+ %w[projects blobs users issues merge_requests].each do |scope|
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user])
+ get :autocomplete, params: { term: 'hello', scope: scope }
+ end
+ end
+ end
+ end
+
it_behaves_like 'rate limited endpoint', rate_limit_key: :search_rate_limit do
let(:current_user) { user }
@@ -428,6 +521,15 @@ RSpec.describe SearchController, feature_category: :global_search do
get :autocomplete, params: { term: 'setting', filter: 'generic' }
end
+
+ it 'sets correct cache control headers' do
+ get :autocomplete, params: { term: 'setting', filter: 'generic' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ expect(response.headers['Cache-Control']).to eq('max-age=60, private')
+ expect(response.headers['Pragma']).to be_nil
+ end
end
describe '#append_info_to_payload' do
@@ -518,6 +620,11 @@ RSpec.describe SearchController, feature_category: :global_search do
get endpoint, params: params.merge(project_id: project.id)
end
end
+
+ it 'uses request IP as rate limiting scope' do
+ expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit_unauthenticated, scope: [request.ip])
+ get endpoint, params: params.merge(project_id: project.id)
+ end
end
end
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index 1f7d169bae5..80856512bba 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -375,8 +375,7 @@ RSpec.describe SessionsController do
context 'when OTP is valid for another user' do
it 'does not authenticate' do
- authenticate_2fa(login: another_user.username,
- otp_attempt: another_user.current_otp)
+ authenticate_2fa(login: another_user.username, otp_attempt: another_user.current_otp)
expect(subject.current_user).not_to eq another_user
end
@@ -384,8 +383,7 @@ RSpec.describe SessionsController do
context 'when OTP is invalid for another user' do
it 'does not authenticate' do
- authenticate_2fa(login: another_user.username,
- otp_attempt: 'invalid')
+ authenticate_2fa(login: another_user.username, otp_attempt: 'invalid')
expect(subject.current_user).not_to eq another_user
end
@@ -495,51 +493,49 @@ RSpec.describe SessionsController do
end
end
- context 'when using two-factor authentication via U2F device' do
- let(:user) { create(:user, :two_factor) }
+ context 'when using two-factor authentication via WebAuthn device' do
+ let(:user) { create(:user, :two_factor_via_webauthn) }
- def authenticate_2fa_u2f(user_params)
+ def authenticate_2fa(user_params)
post(:create, params: { user: user_params }, session: { otp_user_id: user.id })
end
- before do
- stub_feature_flags(webauthn: false)
- end
-
context 'remember_me field' do
it 'sets a remember_user_token cookie when enabled' do
- allow(U2fRegistration).to receive(:authenticate).and_return(true)
+ allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).and_return(true)
allow(controller).to receive(:find_user).and_return(user)
- expect(controller)
- .to receive(:remember_me).with(user).and_call_original
+ expect(controller).to receive(:remember_me).with(user).and_call_original
- authenticate_2fa_u2f(remember_me: '1', login: user.username, device_response: "{}")
+ authenticate_2fa(remember_me: '1', login: user.username, device_response: "{}")
expect(response.cookies['remember_user_token']).to be_present
end
it 'does nothing when disabled' do
- allow(U2fRegistration).to receive(:authenticate).and_return(true)
+ allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).and_return(true)
allow(controller).to receive(:find_user).and_return(user)
expect(controller).not_to receive(:remember_me)
- authenticate_2fa_u2f(remember_me: '0', login: user.username, device_response: "{}")
+ authenticate_2fa(remember_me: '0', login: user.username, device_response: "{}")
expect(response.cookies['remember_user_token']).to be_nil
end
end
it "creates an audit log record" do
- allow(U2fRegistration).to receive(:authenticate).and_return(true)
- expect { authenticate_2fa_u2f(login: user.username, device_response: "{}") }.to change { AuditEvent.count }.by(1)
- expect(AuditEvent.last.details[:with]).to eq("two-factor-via-u2f-device")
+ allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).and_return(true)
+
+ expect { authenticate_2fa(login: user.username, device_response: "{}") }.to(
+ change { AuditEvent.count }.by(1))
+ expect(AuditEvent.last.details[:with]).to eq("two-factor-via-webauthn-device")
end
it "creates an authentication event record" do
- allow(U2fRegistration).to receive(:authenticate).and_return(true)
+ allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).and_return(true)
- expect { authenticate_2fa_u2f(login: user.username, device_response: "{}") }.to change { AuthenticationEvent.count }.by(1)
- expect(AuthenticationEvent.last.provider).to eq("two-factor-via-u2f-device")
+ expect { authenticate_2fa(login: user.username, device_response: "{}") }.to(
+ change { AuthenticationEvent.count }.by(1))
+ expect(AuthenticationEvent.last.provider).to eq("two-factor-via-webauthn-device")
end
end
end
@@ -567,8 +563,7 @@ RSpec.describe SessionsController do
it 'sets the username and caller_id in the context' do
expect(controller).to receive(:destroy).and_wrap_original do |m, *args|
expect(Gitlab::ApplicationContext.current)
- .to include('meta.user' => user.username,
- 'meta.caller_id' => 'SessionsController#destroy')
+ .to include('meta.user' => user.username, 'meta.caller_id' => 'SessionsController#destroy')
m.call(*args)
end
@@ -607,8 +602,7 @@ RSpec.describe SessionsController do
m.call(*args)
end
- post(:create,
- params: { user: { login: user.username, password: user.password.succ } })
+ post :create, params: { user: { login: user.username, password: user.password.succ } }
end
end
end
diff --git a/spec/controllers/snippets/blobs_controller_spec.rb b/spec/controllers/snippets/blobs_controller_spec.rb
index b9f58587a58..b92621d4041 100644
--- a/spec/controllers/snippets/blobs_controller_spec.rb
+++ b/spec/controllers/snippets/blobs_controller_spec.rb
@@ -17,13 +17,7 @@ RSpec.describe Snippets::BlobsController do
let(:inline) { nil }
subject do
- get(:raw,
- params: {
- snippet_id: snippet,
- path: filepath,
- ref: ref,
- inline: inline
- })
+ get :raw, params: { snippet_id: snippet, path: filepath, ref: ref, inline: inline }
end
where(:snippet_visibility_level, :user, :status) do
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index 6019f10eeeb..9f228c75127 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -10,14 +10,21 @@ RSpec.describe 'Database schema', feature_category: :database do
let(:columns_name_with_jsonb) { retrieve_columns_name_with_jsonb }
IGNORED_INDEXES_ON_FKS = {
- slack_integrations_scopes: %w[slack_api_scope_id],
- # Will be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/391312
- approval_project_rules: %w[scan_result_policy_id],
- approval_merge_request_rules: %w[scan_result_policy_id]
+ # `search_index_id index_type` is the composite foreign key configured for `search_namespace_index_assignments`,
+ # but in Search::NamespaceIndexAssignment model, only `search_index_id` is used as foreign key and indexed
+ search_namespace_index_assignments: [%w[search_index_id index_type]],
+ slack_integrations_scopes: [%w[slack_api_scope_id]]
}.with_indifferent_access.freeze
TABLE_PARTITIONS = %w[ci_builds_metadata].freeze
+ # If splitting FK and table removal into two MRs as suggested in the docs, use this constant in the initial FK removal MR.
+ # In the subsequent table removal MR, remove the entries.
+ # See: https://docs.gitlab.com/ee/development/migration_style_guide.html#dropping-a-database-table
+ REMOVED_FKS = {
+ # example_table: %w[example_column]
+ }.with_indifferent_access.freeze
+
# List of columns historically missing a FK, don't add more columns
# See: https://docs.gitlab.com/ee/development/database/foreign_keys.html#naming-foreign-keys
IGNORED_FK_COLUMNS = {
@@ -33,29 +40,17 @@ RSpec.describe 'Database schema', feature_category: :database do
award_emoji: %w[awardable_id user_id],
aws_roles: %w[role_external_id],
boards: %w[milestone_id iteration_id],
+ broadcast_messages: %w[namespace_id],
chat_names: %w[chat_id team_id user_id integration_id],
chat_teams: %w[team_id],
- ci_build_needs: %w[partition_id],
- ci_build_pending_states: %w[partition_id build_id],
- ci_build_report_results: %w[partition_id],
- ci_build_trace_chunks: %w[partition_id build_id],
- ci_build_trace_metadata: %w[partition_id],
ci_builds: %w[erased_by_id trigger_request_id partition_id],
- ci_builds_runner_session: %w[partition_id build_id],
- p_ci_builds_metadata: %w[partition_id],
- ci_job_artifacts: %w[partition_id],
- ci_job_variables: %w[partition_id],
ci_namespace_monthly_usages: %w[namespace_id],
- ci_pending_builds: %w[partition_id],
ci_pipeline_variables: %w[partition_id],
ci_pipelines: %w[partition_id],
- ci_resources: %w[partition_id build_id],
ci_runner_projects: %w[runner_id],
- ci_running_builds: %w[partition_id],
- ci_sources_pipelines: %w[partition_id source_partition_id],
+ ci_sources_pipelines: %w[partition_id source_partition_id source_job_id],
ci_stages: %w[partition_id],
ci_trigger_requests: %w[commit_id],
- ci_unit_test_failures: %w[partition_id build_id],
cluster_providers_aws: %w[security_group_id vpc_id access_key_id],
cluster_providers_gcp: %w[gcp_project_id operation_id],
compliance_management_frameworks: %w[group_id],
@@ -91,13 +86,13 @@ RSpec.describe 'Database schema', feature_category: :database do
oauth_access_grants: %w[resource_owner_id application_id],
oauth_access_tokens: %w[resource_owner_id application_id],
oauth_applications: %w[owner_id],
+ p_ci_runner_machine_builds: %w[partition_id build_id],
product_analytics_events_experimental: %w[event_id txn_id user_id],
project_build_artifacts_size_refreshes: %w[last_job_artifact_id],
project_data_transfers: %w[project_id namespace_id],
project_error_tracking_settings: %w[sentry_project_id],
- project_group_links: %w[group_id],
project_statistics: %w[namespace_id],
- projects: %w[creator_id ci_id mirror_user_id],
+ projects: %w[ci_id mirror_user_id],
redirect_routes: %w[source_id],
repository_languages: %w[programming_language_id],
routes: %w[source_id],
@@ -121,7 +116,10 @@ RSpec.describe 'Database schema', feature_category: :database do
vulnerability_reads: %w[cluster_agent_id],
# See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87584
# Fixes performance issues with the deletion of web-hooks with many log entries
- web_hook_logs: %w[web_hook_id]
+ web_hook_logs: %w[web_hook_id],
+ webauthn_registrations: %w[u2f_registration_id], # this column will be dropped
+ ml_candidates: %w[internal_id],
+ value_stream_dashboard_counts: %w[namespace_id]
}.with_indifferent_access.freeze
context 'for table' do
@@ -134,48 +132,52 @@ RSpec.describe 'Database schema', feature_category: :database do
describe table do
let(:indexes) { connection.indexes(table) }
let(:columns) { connection.columns(table) }
- let(:foreign_keys) { connection.foreign_keys(table) }
+ let(:foreign_keys) { to_foreign_keys(Gitlab::Database::PostgresForeignKey.by_constrained_table_name(table)) }
let(:loose_foreign_keys) { Gitlab::Database::LooseForeignKeys.definitions.group_by(&:from_table).fetch(table, []) }
let(:all_foreign_keys) { foreign_keys + loose_foreign_keys }
- # take the first column in case we're using a composite primary key
- let(:primary_key_column) { Array(connection.primary_key(table)).first }
+ let(:composite_primary_key) { Array.wrap(connection.primary_key(table)) }
context 'all foreign keys' do
# for index to be effective, the FK constraint has to be at first place
it 'are indexed' do
- first_indexed_column = indexes.filter_map do |index|
+ indexed_columns = indexes.filter_map do |index|
columns = index.columns
# In cases of complex composite indexes, a string is returned eg:
# "lower((extern_uid)::text), group_id"
- columns = columns.split(',') if columns.is_a?(String)
- column = columns.first.chomp
+ columns = columns.split(',').map(&:chomp) if columns.is_a?(String)
# A partial index is not suitable for a foreign key column, unless
# the only condition is for the presence of the foreign key itself
- column if index.where.nil? || index.where == "(#{column} IS NOT NULL)"
+ columns if index.where.nil? || index.where == "(#{columns.first} IS NOT NULL)"
end
foreign_keys_columns = all_foreign_keys.map(&:column)
- required_indexed_columns = foreign_keys_columns - ignored_index_columns(table)
+ required_indexed_columns = to_columns(foreign_keys_columns - ignored_index_columns(table))
- # Add the primary key column to the list of indexed columns because
+ # Add the composite primary key to the list of indexed columns because
# postgres and mysql both automatically create an index on the primary
# key. Also, the rails connection.indexes() method does not return
# automatically generated indexes (like the primary key index).
- first_indexed_column.push(primary_key_column)
+ indexed_columns.push(composite_primary_key)
- expect(first_indexed_column.uniq).to include(*required_indexed_columns)
+ expect(required_indexed_columns).to be_indexed_by(indexed_columns)
end
end
context 'columns ending with _id' do
let(:column_names) { columns.map(&:name) }
let(:column_names_with_id) { column_names.select { |column_name| column_name.ends_with?('_id') } }
- let(:foreign_keys_columns) { all_foreign_keys.reject { |fk| fk.name&.end_with?("_p") }.map(&:column).uniq } # we can have FK and loose FK present at the same time
let(:ignored_columns) { ignored_fk_columns(table) }
+ let(:foreign_keys_columns) do
+ to_columns(
+ all_foreign_keys
+ .reject { |fk| fk.name&.end_with?("_id_convert_to_bigint") }
+ .map(&:column)
+ )
+ end
it 'do have the foreign keys' do
- expect(column_names_with_id - ignored_columns).to match_array(foreign_keys_columns)
+ expect(column_names_with_id - ignored_columns).to be_a_foreign_key_column_of(foreign_keys_columns)
end
it 'and having foreign key are not in the ignore list' do
@@ -200,7 +202,6 @@ RSpec.describe 'Database schema', feature_category: :database do
'Ci::Processable' => %w[failure_reason],
'Ci::Runner' => %w[access_level],
'Ci::Stage' => %w[status],
- 'Clusters::Applications::Ingress' => %w[ingress_type],
'Clusters::Cluster' => %w[platform_type provider_type],
'CommitStatus' => %w[failure_reason],
'GenericCommitStatus' => %w[failure_reason],
@@ -308,6 +309,28 @@ RSpec.describe 'Database schema', feature_category: :database do
expect(problematic_tables).to be_empty
end
end
+
+ context 'for CI partitioned table' do
+ # Check that each partitionable model with more than 1 column has the partition_id column at the trailing
+ # position. Using PARTITIONABLE_MODELS instead of iterating tables since when partitioning existing tables,
+ # the routing table only gets created after the PK has already been created, which would be too late for a check.
+
+ skip_tables = %w[]
+ partitionable_models = Ci::Partitionable::Testing::PARTITIONABLE_MODELS
+ (partitionable_models - skip_tables).each do |klass|
+ model = klass.safe_constantize
+ table_name = model.table_name
+
+ primary_key_columns = Array.wrap(model.connection.primary_key(table_name))
+ next if primary_key_columns.count == 1
+
+ describe table_name do
+ it 'expects every PK to have partition_id at trailing position' do
+ expect(primary_key_columns).to match([an_instance_of(String), 'partition_id'])
+ end
+ end
+ end
+ end
end
context 'index names' do
@@ -338,12 +361,32 @@ RSpec.describe 'Database schema', feature_category: :database do
ApplicationRecord.connection.select_all(sql).to_a
end
+ def to_foreign_keys(constraints)
+ constraints.map do |constraint|
+ from_table = constraint.constrained_table_identifier
+ ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
+ from_table,
+ constraint.referenced_table_identifier,
+ {
+ name: constraint.name,
+ column: constraint.constrained_columns,
+ on_delete: constraint.on_delete_action&.to_sym,
+ gitlab_schema: Gitlab::Database::GitlabSchema.table_schema!(from_table)
+ }
+ )
+ end
+ end
+
+ def to_columns(items)
+ items.map { |item| Array.wrap(item) }.uniq
+ end
+
def models_by_table_name
@models_by_table_name ||= ApplicationRecord.descendants.reject(&:abstract_class).group_by(&:table_name)
end
def ignored_fk_columns(table)
- IGNORED_FK_COLUMNS.fetch(table, [])
+ REMOVED_FKS.merge(IGNORED_FK_COLUMNS).fetch(table, [])
end
def ignored_index_columns(table)
diff --git a/spec/deprecation_warnings.rb b/spec/deprecation_warnings.rb
new file mode 100644
index 00000000000..45fed5fecca
--- /dev/null
+++ b/spec/deprecation_warnings.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require_relative '../lib/gitlab/utils'
+return if Gitlab::Utils.to_boolean(ENV['SILENCE_DEPRECATIONS'], default: false)
+
+# Enable deprecation warnings by default and make them more visible
+# to developers to ease upgrading to newer Ruby versions.
+Warning[:deprecated] = true
+
+# rubocop:disable Layout/LineLength
+case RUBY_VERSION[/\d+\.\d+/, 0]
+when '3.2'
+ warn "#{__FILE__}:#{__LINE__}: warning: Ignored warnings for Ruby < 3.2 are no longer necessary."
+else
+ require 'warning'
+ # Ignore Ruby warnings until Ruby 3.2.
+ # ... ruby/3.1.3/lib/ruby/gems/3.1.0/gems/rspec-parameterized-table_syntax-1.0.0/lib/rspec/parameterized/table_syntax.rb:38: warning: Refinement#include is deprecated and will be removed in Ruby 3.2
+
+ Warning.ignore(%r{rspec-parameterized-table_syntax-1\.0\.0/lib/rspec/parameterized/table_syntax\.rb:\d+: warning: Refinement#include is deprecated})
+end
+# rubocop:enable Layout/LineLength
diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb
index 7aca5e492f4..ef8f8cbce3b 100644
--- a/spec/experiments/application_experiment_spec.rb
+++ b/spec/experiments/application_experiment_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ApplicationExperiment, :experiment do
+RSpec.describe ApplicationExperiment, :experiment, feature_category: :experimentation_conversion do
subject(:application_experiment) { described_class.new('namespaced/stub', **context) }
let(:context) { {} }
@@ -187,13 +187,11 @@ RSpec.describe ApplicationExperiment, :experiment do
end
with_them do
- it "returns the url or nil if invalid" do
- allow(Gitlab).to receive(:com?).and_return(true)
+ it "returns the url or nil if invalid on SaaS", :saas do
expect(application_experiment.process_redirect_url(url)).to eq(processed_url)
end
- it "considers all urls invalid when not on dev or com" do
- allow(Gitlab).to receive(:com?).and_return(false)
+ it "considers all urls invalid when not on SaaS" do
expect(application_experiment.process_redirect_url(url)).to be_nil
end
end
diff --git a/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb b/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb
deleted file mode 100644
index c91a8f1950e..00000000000
--- a/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe RequireVerificationForNamespaceCreationExperiment, :experiment do
- subject(:experiment) { described_class.new(user: user) }
-
- let(:user_created_at) { RequireVerificationForNamespaceCreationExperiment::EXPERIMENT_START_DATE + 1.hour }
- let(:user) { create(:user, created_at: user_created_at) }
-
- describe '#candidate?' do
- context 'when experiment subject is candidate' do
- before do
- stub_experiments(require_verification_for_namespace_creation: :candidate)
- end
-
- it 'returns true' do
- expect(experiment.candidate?).to eq(true)
- end
- end
-
- context 'when experiment subject is control' do
- before do
- stub_experiments(require_verification_for_namespace_creation: :control)
- end
-
- it 'returns false' do
- expect(experiment.candidate?).to eq(false)
- end
- end
- end
-
- describe 'exclusions' do
- context 'when user is new' do
- it 'is not excluded' do
- expect(subject).not_to exclude(user: user)
- end
- end
-
- context 'when user is NOT new' do
- let(:user_created_at) { RequireVerificationForNamespaceCreationExperiment::EXPERIMENT_START_DATE - 1.day }
- let(:user) { create(:user, created_at: user_created_at) }
-
- it 'is excluded' do
- expect(subject).to exclude(user: user)
- end
- end
- end
-end
diff --git a/spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb b/spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb
deleted file mode 100644
index ee02fa5f1f2..00000000000
--- a/spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# 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
-end
diff --git a/spec/factories/abuse/trust_score.rb b/spec/factories/abuse/trust_score.rb
new file mode 100644
index 00000000000..a5ea7666945
--- /dev/null
+++ b/spec/factories/abuse/trust_score.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :abuse_trust_score, class: 'Abuse::TrustScore' do
+ user
+ score { 0.1 }
+ source { :spamcheck }
+ correlation_id_value { 'abcdefg' }
+ end
+end
diff --git a/spec/factories/abuse_reports.rb b/spec/factories/abuse_reports.rb
index 355fb142994..699da744fab 100644
--- a/spec/factories/abuse_reports.rb
+++ b/spec/factories/abuse_reports.rb
@@ -7,5 +7,13 @@ FactoryBot.define do
message { 'User sends spam' }
reported_from_url { 'http://gitlab.com' }
links_to_spam { ['https://gitlab.com/issue1', 'https://gitlab.com/issue2'] }
+
+ trait :closed do
+ status { 'closed' }
+ end
+
+ trait :with_screenshot do
+ screenshot { fixture_file_upload('spec/fixtures/dk.png') }
+ end
end
end
diff --git a/spec/factories/achievements/user_achievements.rb b/spec/factories/achievements/user_achievements.rb
new file mode 100644
index 00000000000..a5fd1df38dd
--- /dev/null
+++ b/spec/factories/achievements/user_achievements.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :user_achievement, class: 'Achievements::UserAchievement' do
+ user
+ achievement
+ awarded_by_user factory: :user
+
+ trait :revoked do
+ revoked_by_user factory: :user
+ revoked_at { Time.now }
+ end
+ end
+end
diff --git a/spec/factories/airflow/dags.rb b/spec/factories/airflow/dags.rb
deleted file mode 100644
index ca4276e2c8f..00000000000
--- a/spec/factories/airflow/dags.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-# frozen_string_literal: true
-FactoryBot.define do
- factory :airflow_dags, class: '::Airflow::Dags' do
- sequence(:dag_name) { |n| "dag_name_#{n}" }
-
- project
- end
-end
diff --git a/spec/factories/bulk_import/batch_trackers.rb b/spec/factories/bulk_import/batch_trackers.rb
new file mode 100644
index 00000000000..427eefc5f3e
--- /dev/null
+++ b/spec/factories/bulk_import/batch_trackers.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :bulk_import_batch_tracker, class: 'BulkImports::BatchTracker' do
+ association :tracker, factory: :bulk_import_tracker
+
+ status { 0 }
+ fetched_objects_count { 1000 }
+ imported_objects_count { 1000 }
+
+ sequence(:batch_number) { |n| n }
+
+ trait :created do
+ status { 0 }
+ end
+
+ trait :started do
+ status { 1 }
+ end
+
+ trait :finished do
+ status { 2 }
+ end
+
+ trait :timeout do
+ status { 3 }
+ end
+
+ trait :failed do
+ status { -1 }
+ end
+
+ trait :skipped do
+ status { -2 }
+ end
+ end
+end
diff --git a/spec/factories/bulk_import/export_batches.rb b/spec/factories/bulk_import/export_batches.rb
new file mode 100644
index 00000000000..f5f12696f5f
--- /dev/null
+++ b/spec/factories/bulk_import/export_batches.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :bulk_import_export_batch, class: 'BulkImports::ExportBatch' do
+ association :export, factory: :bulk_import_export
+
+ upload { association(:bulk_import_export_upload) }
+
+ status { 0 }
+ batch_number { 1 }
+
+ trait :started do
+ status { 0 }
+ end
+
+ trait :finished do
+ status { 1 }
+ end
+
+ trait :failed do
+ status { -1 }
+ end
+ end
+end
diff --git a/spec/factories/bulk_import/exports.rb b/spec/factories/bulk_import/exports.rb
index dd8831ce33a..795a9bbfe20 100644
--- a/spec/factories/bulk_import/exports.rb
+++ b/spec/factories/bulk_import/exports.rb
@@ -20,5 +20,9 @@ FactoryBot.define do
trait :failed do
status { -1 }
end
+
+ trait :batched do
+ batched { true }
+ end
end
end
diff --git a/spec/factories/chat_names.rb b/spec/factories/chat_names.rb
index 56567394bf5..c872694ee64 100644
--- a/spec/factories/chat_names.rb
+++ b/spec/factories/chat_names.rb
@@ -3,7 +3,6 @@
FactoryBot.define do
factory :chat_name, class: 'ChatName' do
user
- integration
team_id { 'T0001' }
team_domain { 'Awesome Team' }
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index 224f460488b..dc75e17499c 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -415,7 +415,7 @@ FactoryBot.define do
runner factory: :ci_runner
after(:create) do |build|
- build.create_runtime_metadata!
+ ::Ci::RunningBuild.upsert_shared_runner_build!(build)
end
end
@@ -694,7 +694,7 @@ FactoryBot.define do
end
end
- trait :non_public_artifacts do
+ trait :with_private_artifacts_config do
options do
{
artifacts: { public: false }
@@ -702,6 +702,14 @@ FactoryBot.define do
end
end
+ trait :with_public_artifacts_config do
+ options do
+ {
+ artifacts: { public: true }
+ }
+ end
+ end
+
trait :non_playable do
status { 'created' }
self.when { 'manual' }
diff --git a/spec/factories/ci/catalog/resources.rb b/spec/factories/ci/catalog/resources.rb
new file mode 100644
index 00000000000..66c2e58cdd9
--- /dev/null
+++ b/spec/factories/ci/catalog/resources.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :catalog_resource, class: 'Ci::Catalog::Resource' do
+ project factory: :project
+ end
+end
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index d68562c0aa5..2b6bddd2f6d 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -21,6 +21,12 @@ FactoryBot.define do
transient { name { nil } }
+ transient { ci_ref_presence { true } }
+
+ before(:create) do |pipeline, evaluator|
+ pipeline.ensure_ci_ref! if evaluator.ci_ref_presence && pipeline.ci_ref_id.nil?
+ end
+
after(:build) do |pipeline, evaluator|
if evaluator.child_of
pipeline.project = evaluator.child_of.project
@@ -54,12 +60,6 @@ FactoryBot.define do
end
factory :ci_pipeline do
- transient { ci_ref_presence { true } }
-
- before(:create) do |pipeline, evaluator|
- pipeline.ensure_ci_ref! if evaluator.ci_ref_presence && pipeline.ci_ref_id.nil?
- end
-
trait :invalid do
status { :failed }
yaml_errors { 'invalid YAML' }
diff --git a/spec/factories/ci/processable.rb b/spec/factories/ci/processable.rb
index 49e66368f94..49756433713 100644
--- a/spec/factories/ci/processable.rb
+++ b/spec/factories/ci/processable.rb
@@ -26,13 +26,19 @@ FactoryBot.define do
before(:create) do |processable, evaluator|
next if processable.ci_stage
- if ci_stage = processable.pipeline.stages.find_by(name: evaluator.stage)
- processable.ci_stage = ci_stage
- else
- processable.ci_stage = create(:ci_stage, pipeline: processable.pipeline,
- project: processable.project || evaluator.project,
- name: evaluator.stage, position: evaluator.stage_idx, status: 'created')
- end
+ processable.ci_stage =
+ if ci_stage = processable.pipeline.stages.find_by(name: evaluator.stage)
+ ci_stage
+ else
+ create(
+ :ci_stage,
+ pipeline: processable.pipeline,
+ project: processable.project || evaluator.project,
+ name: evaluator.stage,
+ position: evaluator.stage_idx,
+ status: 'created'
+ )
+ end
end
trait :waiting_for_resource do
diff --git a/spec/factories/ci/reports/security/findings.rb b/spec/factories/ci/reports/security/findings.rb
index 78c11210f97..c57a2dd479f 100644
--- a/spec/factories/ci/reports/security/findings.rb
+++ b/spec/factories/ci/reports/security/findings.rb
@@ -27,6 +27,7 @@ FactoryBot.define do
url: "https://crypto.stackexchange.com/questions/31428/pbewithmd5anddes-cipher-does-not-check-for-integrity-first"
}
],
+ raw_source_code_extract: 'AES/ECB/NoPadding',
evidence: {
summary: 'Credit card detected',
request: {
diff --git a/spec/factories/ci/reports/security/reports.rb b/spec/factories/ci/reports/security/reports.rb
index 5699b8fee3e..60d1f4615ac 100644
--- a/spec/factories/ci/reports/security/reports.rb
+++ b/spec/factories/ci/reports/security/reports.rb
@@ -19,6 +19,19 @@ FactoryBot.define do
evaluator.findings.each { |o| report.add_finding(o) }
end
+ factory :dependency_scanning_security_report do
+ type { :dependency_scanning }
+
+ after :create do |report|
+ artifact = report.pipeline.job_artifacts.dependency_scanning.last
+ if artifact.present?
+ content = File.read(artifact.file.path)
+
+ Gitlab::Ci::Parsers::Security::DependencyScanning.parse!(content, report)
+ end
+ end
+ end
+
skip_create
initialize_with do
diff --git a/spec/factories/ci/runner_machine_builds.rb b/spec/factories/ci/runner_machine_builds.rb
new file mode 100644
index 00000000000..34238760112
--- /dev/null
+++ b/spec/factories/ci/runner_machine_builds.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_runner_machine_build, class: 'Ci::RunnerManagerBuild' do
+ build factory: :ci_build, scheduling_type: :dag
+ runner_manager factory: :ci_runner_machine
+ end
+end
diff --git a/spec/factories/ci/runner_machines.rb b/spec/factories/ci/runner_machines.rb
deleted file mode 100644
index 9d601caa634..00000000000
--- a/spec/factories/ci/runner_machines.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :ci_runner_machine, class: 'Ci::RunnerMachine' do
- runner factory: :ci_runner
- system_xid { "r_#{SecureRandom.hex.slice(0, 10)}" }
-
- trait :stale do
- created_at { 1.year.ago }
- contacted_at { Ci::RunnerMachine::STALE_TIMEOUT.ago }
- end
- end
-end
diff --git a/spec/factories/ci/runner_managers.rb b/spec/factories/ci/runner_managers.rb
new file mode 100644
index 00000000000..7a2b0c37215
--- /dev/null
+++ b/spec/factories/ci/runner_managers.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_runner_machine, class: 'Ci::RunnerManager' do
+ runner factory: :ci_runner
+ system_xid { "r_#{SecureRandom.hex.slice(0, 10)}" }
+
+ trait :stale do
+ created_at { 1.year.ago }
+ contacted_at { Ci::RunnerManager::STALE_TIMEOUT.ago }
+ end
+ end
+end
diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb
index 4758986b47c..f001cecd28e 100644
--- a/spec/factories/ci/runners.rb
+++ b/spec/factories/ci/runners.rb
@@ -66,6 +66,12 @@ FactoryBot.define do
end
end
+ trait :with_runner_manager do
+ after(:build) do |runner, evaluator|
+ runner.runner_managers << build(:ci_runner_machine, runner: runner)
+ end
+ end
+
trait :inactive do
active { false }
end
diff --git a/spec/factories/clusters/agents/authorizations/ci_access/group_authorizations.rb b/spec/factories/clusters/agents/authorizations/ci_access/group_authorizations.rb
new file mode 100644
index 00000000000..659114eef8e
--- /dev/null
+++ b/spec/factories/clusters/agents/authorizations/ci_access/group_authorizations.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :agent_ci_access_group_authorization, class: 'Clusters::Agents::Authorizations::CiAccess::GroupAuthorization' do
+ association :agent, factory: :cluster_agent
+ group
+
+ transient do
+ environments { nil }
+ end
+
+ config do
+ { default_namespace: 'production' }.tap do |c|
+ c[:environments] = environments if environments
+ end
+ end
+ end
+end
diff --git a/spec/factories/clusters/agents/authorizations/ci_access/project_authorizations.rb b/spec/factories/clusters/agents/authorizations/ci_access/project_authorizations.rb
new file mode 100644
index 00000000000..10d4f8fb946
--- /dev/null
+++ b/spec/factories/clusters/agents/authorizations/ci_access/project_authorizations.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :agent_ci_access_project_authorization, class: 'Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization' do
+ association :agent, factory: :cluster_agent
+ project
+
+ transient do
+ environments { nil }
+ end
+
+ config do
+ { default_namespace: 'production' }.tap do |c|
+ c[:environments] = environments if environments
+ end
+ end
+ end
+end
diff --git a/spec/factories/clusters/agents/authorizations/user_access/group_authorizations.rb b/spec/factories/clusters/agents/authorizations/user_access/group_authorizations.rb
new file mode 100644
index 00000000000..203aadbd741
--- /dev/null
+++ b/spec/factories/clusters/agents/authorizations/user_access/group_authorizations.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :agent_user_access_group_authorization,
+ class: 'Clusters::Agents::Authorizations::UserAccess::GroupAuthorization' do
+ association :agent, factory: :cluster_agent
+ config { {} }
+ group
+ end
+end
diff --git a/spec/factories/clusters/agents/authorizations/user_access/project_authorizations.rb b/spec/factories/clusters/agents/authorizations/user_access/project_authorizations.rb
new file mode 100644
index 00000000000..8171607f578
--- /dev/null
+++ b/spec/factories/clusters/agents/authorizations/user_access/project_authorizations.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :agent_user_access_project_authorization,
+ class: 'Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization' do
+ association :agent, factory: :cluster_agent
+ config { {} }
+ project
+ end
+end
diff --git a/spec/factories/clusters/agents/group_authorizations.rb b/spec/factories/clusters/agents/group_authorizations.rb
deleted file mode 100644
index abe25794234..00000000000
--- a/spec/factories/clusters/agents/group_authorizations.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :agent_group_authorization, class: 'Clusters::Agents::GroupAuthorization' do
- association :agent, factory: :cluster_agent
- group
-
- transient do
- environments { nil }
- end
-
- config do
- { default_namespace: 'production' }.tap do |c|
- c[:environments] = environments if environments
- end
- end
- end
-end
diff --git a/spec/factories/clusters/agents/project_authorizations.rb b/spec/factories/clusters/agents/project_authorizations.rb
deleted file mode 100644
index eecbfe95bfc..00000000000
--- a/spec/factories/clusters/agents/project_authorizations.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :agent_project_authorization, class: 'Clusters::Agents::ProjectAuthorization' do
- association :agent, factory: :cluster_agent
- project
-
- transient do
- environments { nil }
- end
-
- config do
- { default_namespace: 'production' }.tap do |c|
- c[:environments] = environments if environments
- end
- end
- end
-end
diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb
deleted file mode 100644
index 0647058d63a..00000000000
--- a/spec/factories/clusters/applications/helm.rb
+++ /dev/null
@@ -1,124 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :clusters_applications_helm, class: 'Clusters::Applications::Helm' do
- cluster factory: %i(cluster provided_by_gcp)
-
- transient do
- helm_installed { true }
- end
-
- before(:create) do |_record, evaluator|
- if evaluator.helm_installed
- stub_method(Gitlab::Kubernetes::Helm::V2::Certificate, :generate_root) do
- OpenStruct.new( # rubocop: disable Style/OpenStructUse
- key_string: File.read(Rails.root.join('spec/fixtures/clusters/sample_key.key')),
- cert_string: File.read(Rails.root.join('spec/fixtures/clusters/sample_cert.pem'))
- )
- end
- end
- end
-
- after(:create) do |_record, evaluator|
- if evaluator.helm_installed
- restore_original_methods(Gitlab::Kubernetes::Helm::V2::Certificate)
- end
- end
-
- trait :not_installable do
- status { -2 }
- end
-
- trait :errored do
- status { -1 }
- status_reason { 'something went wrong' }
- end
-
- trait :installable do
- status { 0 }
- end
-
- trait :scheduled do
- status { 1 }
- end
-
- trait :installing do
- status { 2 }
- end
-
- trait :installed do
- status { 3 }
- end
-
- trait :updating do
- status { 4 }
- end
-
- trait :updated do
- status { 5 }
- end
-
- trait :update_errored do
- status { 6 }
- status_reason { 'something went wrong' }
- end
-
- trait :uninstalling do
- status { 7 }
- end
-
- trait :uninstall_errored do
- status { 8 }
- status_reason { 'something went wrong' }
- end
-
- trait :uninstalled do
- status { 10 }
- end
-
- trait :externally_installed do
- status { 11 }
- end
-
- trait :timed_out do
- installing
- updated_at { ClusterWaitForAppInstallationWorker::TIMEOUT.ago }
- end
-
- # Common trait used by the apps below
- trait :no_helm_installed do
- cluster factory: %i(cluster provided_by_gcp)
-
- transient do
- helm_installed { false }
- end
- end
-
- factory :clusters_applications_ingress, class: 'Clusters::Applications::Ingress' do
- cluster factory: %i(cluster with_installed_helm provided_by_gcp)
- end
-
- factory :clusters_applications_crossplane, class: 'Clusters::Applications::Crossplane' do
- stack { 'gcp' }
- cluster factory: %i(cluster with_installed_helm provided_by_gcp)
- end
-
- factory :clusters_applications_prometheus, class: 'Clusters::Applications::Prometheus' do
- cluster factory: %i(cluster with_installed_helm provided_by_gcp)
- end
-
- factory :clusters_applications_runner, class: 'Clusters::Applications::Runner' do
- cluster factory: %i(cluster with_installed_helm provided_by_gcp)
- end
-
- factory :clusters_applications_knative, class: 'Clusters::Applications::Knative' do
- hostname { 'example.com' }
- cluster factory: %i(cluster with_installed_helm provided_by_gcp)
- end
-
- factory :clusters_applications_jupyter, class: 'Clusters::Applications::Jupyter' do
- oauth_application factory: :oauth_application
- cluster factory: %i(cluster with_installed_helm provided_by_gcp project)
- end
- end
-end
diff --git a/spec/factories/clusters/clusters.rb b/spec/factories/clusters/clusters.rb
index 32cd6beb7ea..2785a8c9946 100644
--- a/spec/factories/clusters/clusters.rb
+++ b/spec/factories/clusters/clusters.rb
@@ -82,25 +82,10 @@ FactoryBot.define do
sequence(:environment_scope) { |n| "production#{n}/*" }
end
- trait :with_installed_helm do
- application_helm factory: %i(clusters_applications_helm installed)
- end
-
trait :with_installed_prometheus do
- application_prometheus factory: %i(clusters_applications_prometheus installed)
integration_prometheus factory: %i(clusters_integrations_prometheus)
end
- trait :with_all_applications do
- application_helm factory: %i(clusters_applications_helm installed)
- application_ingress factory: %i(clusters_applications_ingress installed)
- application_crossplane factory: %i(clusters_applications_crossplane installed)
- application_prometheus factory: %i(clusters_applications_prometheus installed)
- application_runner factory: %i(clusters_applications_runner installed)
- application_jupyter factory: %i(clusters_applications_jupyter installed)
- application_knative factory: %i(clusters_applications_knative installed)
- end
-
trait :with_domain do
domain { 'example.com' }
end
diff --git a/spec/factories/container_registry/data_repair_detail.rb b/spec/factories/container_registry/data_repair_detail.rb
new file mode 100644
index 00000000000..79467c464db
--- /dev/null
+++ b/spec/factories/container_registry/data_repair_detail.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :container_registry_data_repair_detail, class: 'ContainerRegistry::DataRepairDetail' do
+ project
+ updated_at { 1.hour.ago }
+
+ trait :ongoing do
+ status { :ongoing }
+ end
+
+ trait :completed do
+ status { :completed }
+ end
+
+ trait :failed do
+ status { :failed }
+ end
+ end
+end
diff --git a/spec/factories/customer_relations/contacts.rb b/spec/factories/customer_relations/contacts.rb
index 1896510d362..6410e298bc3 100644
--- a/spec/factories/customer_relations/contacts.rb
+++ b/spec/factories/customer_relations/contacts.rb
@@ -8,10 +8,6 @@ FactoryBot.define do
last_name { generate(:name) }
email { generate(:email) }
- trait :with_organization do
- organization
- end
-
trait :inactive do
state { :inactive }
end
diff --git a/spec/factories/customer_relations/organizations.rb b/spec/factories/customer_relations/organizations.rb
index b6efd46f1a4..789099190ac 100644
--- a/spec/factories/customer_relations/organizations.rb
+++ b/spec/factories/customer_relations/organizations.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :organization, class: 'CustomerRelations::Organization' do
+ factory :crm_organization, class: 'CustomerRelations::Organization' do
group
name { generate(:name) }
diff --git a/spec/factories/design_management/repositories.rb b/spec/factories/design_management/repositories.rb
new file mode 100644
index 00000000000..d903fd88c13
--- /dev/null
+++ b/spec/factories/design_management/repositories.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :design_management_repository, class: 'DesignManagement::Repository' do
+ project
+ end
+end
diff --git a/spec/factories/draft_note.rb b/spec/factories/draft_note.rb
index cde8831f169..8433271a3c5 100644
--- a/spec/factories/draft_note.rb
+++ b/spec/factories/draft_note.rb
@@ -28,9 +28,7 @@ FactoryBot.define do
end
position do
- association(:image_diff_position,
- file: path,
- diff_refs: diff_refs)
+ association(:image_diff_position, file: path, diff_refs: diff_refs)
end
end
end
diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb
index 34843dab0fe..2df9f482bb9 100644
--- a/spec/factories/environments.rb
+++ b/spec/factories/environments.rb
@@ -46,20 +46,19 @@ FactoryBot.define do
after(:create) do |environment, evaluator|
pipeline = create(:ci_pipeline, project: environment.project)
- deployable = create(:ci_build, :success, name: "#{environment.name}:deploy",
- pipeline: pipeline)
-
- deployment = create(:deployment,
- :success,
- environment: environment,
- project: environment.project,
- deployable: deployable,
- ref: evaluator.ref,
- sha: environment.project.commit(evaluator.ref).id)
-
- teardown_build = create(:ci_build, :manual,
- name: "#{environment.name}:teardown",
- pipeline: pipeline)
+ deployable = create(:ci_build, :success, name: "#{environment.name}:deploy", pipeline: pipeline)
+
+ deployment = create(
+ :deployment,
+ :success,
+ environment: environment,
+ project: environment.project,
+ deployable: deployable,
+ ref: evaluator.ref,
+ sha: environment.project.commit(evaluator.ref).id
+ )
+
+ teardown_build = create(:ci_build, :manual, name: "#{environment.name}:teardown", pipeline: pipeline)
deployment.update_column(:on_stop, teardown_build.name)
environment.update_attribute(:deployments, [deployment])
diff --git a/spec/factories/gitlab/database/async_foreign_keys/postgres_async_constraint_validation.rb b/spec/factories/gitlab/database/async_foreign_keys/postgres_async_constraint_validation.rb
new file mode 100644
index 00000000000..81f67e958c0
--- /dev/null
+++ b/spec/factories/gitlab/database/async_foreign_keys/postgres_async_constraint_validation.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :postgres_async_constraint_validation,
+ class: 'Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation' do
+ sequence(:name) { |n| "fk_users_id_#{n}" }
+ table_name { "users" }
+
+ trait :foreign_key do
+ constraint_type { :foreign_key }
+ end
+
+ trait :check_constraint do
+ constraint_type { :check_constraint }
+ end
+ end
+end
diff --git a/spec/factories/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation.rb b/spec/factories/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation.rb
deleted file mode 100644
index a61b5cde7a0..00000000000
--- a/spec/factories/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :postgres_async_foreign_key_validation,
- class: 'Gitlab::Database::AsyncForeignKeys::PostgresAsyncForeignKeyValidation' do
- sequence(:name) { |n| "fk_users_id_#{n}" }
- table_name { "users" }
- end
-end
diff --git a/spec/factories/gitlab/database/background_migration/schema_inconsistencies.rb b/spec/factories/gitlab/database/background_migration/schema_inconsistencies.rb
new file mode 100644
index 00000000000..b71b0971417
--- /dev/null
+++ b/spec/factories/gitlab/database/background_migration/schema_inconsistencies.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :schema_inconsistency, class: '::Gitlab::Database::SchemaValidation::SchemaInconsistency' do
+ issue factory: :issue
+
+ object_name { 'name' }
+ table_name { 'table' }
+ valitador_name { 'validator' }
+ end
+end
diff --git a/spec/factories/group_members.rb b/spec/factories/group_members.rb
index 702db45554e..e1841745cb4 100644
--- a/spec/factories/group_members.rb
+++ b/spec/factories/group_members.rb
@@ -30,6 +30,12 @@ FactoryBot.define do
after(:build) { |group_member, _| group_member.user.block! }
end
+ trait :banned do
+ after(:create) do |member|
+ create(:namespace_ban, namespace: member.member_namespace.root_ancestor, user: member.user) unless member.owner?
+ end
+ end
+
trait :minimal_access do
to_create { |instance| instance.save!(validate: false) }
@@ -54,10 +60,12 @@ FactoryBot.define do
after(:build) do |group_member, evaluator|
if evaluator.tasks_to_be_done.present?
- build(:member_task,
- member: group_member,
- project: build(:project, namespace: group_member.source),
- tasks_to_be_done: evaluator.tasks_to_be_done)
+ build(
+ :member_task,
+ member: group_member,
+ project: build(:project, namespace: group_member.source),
+ tasks_to_be_done: evaluator.tasks_to_be_done
+ )
end
end
end
diff --git a/spec/factories/import_failures.rb b/spec/factories/import_failures.rb
index df0793664f4..b4a7c6c46b1 100644
--- a/spec/factories/import_failures.rb
+++ b/spec/factories/import_failures.rb
@@ -21,5 +21,9 @@ FactoryBot.define do
trait :soft_failure do
retry_count { 1 }
end
+
+ trait :github_import_failure do
+ external_identifiers { { iid: 2, object_type: 'pull_request', title: 'Implement cool feature' } }
+ end
end
end
diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb
index 7740b2da911..10568d7f1cd 100644
--- a/spec/factories/integrations.rb
+++ b/spec/factories/integrations.rb
@@ -43,6 +43,29 @@ FactoryBot.define do
end
end
+ factory :gitlab_slack_application_integration, class: 'Integrations::GitlabSlackApplication' do
+ project
+ active { true }
+ type { 'Integrations::GitlabSlackApplication' }
+ slack_integration { association :slack_integration, integration: instance }
+
+ transient do
+ all_channels { true }
+ end
+
+ after(:build) do |integration, evaluator|
+ next unless evaluator.all_channels
+
+ integration.event_channel_names.each do |name|
+ integration.send("#{name}=".to_sym, "##{name}")
+ end
+ end
+
+ trait :all_features_supported do
+ slack_integration { association :slack_integration, :all_features_supported, integration: instance }
+ end
+ end
+
factory :packagist_integration, class: 'Integrations::Packagist' do
project
type { 'Integrations::Packagist' }
@@ -85,9 +108,12 @@ FactoryBot.define do
api_url { '' }
username { 'jira_username' }
password { 'jira_password' }
+ jira_auth_type { 0 }
jira_issue_transition_automatic { false }
jira_issue_transition_id { '56-1' }
issues_enabled { false }
+ jira_issue_prefix { '' }
+ jira_issue_regex { '' }
project_key { nil }
vulnerabilities_enabled { false }
vulnerabilities_issuetype { nil }
@@ -98,6 +124,7 @@ FactoryBot.define do
if evaluator.create_data
integration.jira_tracker_data = build(:jira_tracker_data,
integration: integration, url: evaluator.url, api_url: evaluator.api_url,
+ jira_auth_type: evaluator.jira_auth_type,
jira_issue_transition_automatic: evaluator.jira_issue_transition_automatic,
jira_issue_transition_id: evaluator.jira_issue_transition_id,
username: evaluator.username, password: evaluator.password, issues_enabled: evaluator.issues_enabled,
@@ -199,6 +226,7 @@ FactoryBot.define do
url { 'https://mysite.atlassian.net' }
username { 'jira_user' }
password { 'my-secret-password' }
+ jira_auth_type { 0 }
end
trait :chat_notification do
@@ -261,7 +289,27 @@ FactoryBot.define do
app_store_issuer_id { 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' }
app_store_key_id { 'ABC1' }
- app_store_private_key { File.read('spec/fixtures/ssl_key.pem') }
+ app_store_private_key_file_name { 'auth_key.p8' }
+ app_store_private_key { File.read('spec/fixtures/auth_key.p8') }
+ end
+
+ factory :google_play_integration, class: 'Integrations::GooglePlay' do
+ project
+ active { true }
+ type { 'Integrations::GooglePlay' }
+
+ package_name { 'com.gitlab.foo.bar' }
+ service_account_key_file_name { 'service_account.json' }
+ service_account_key { File.read('spec/fixtures/service_account.json') }
+ end
+
+ factory :squash_tm_integration, class: 'Integrations::SquashTm' do
+ project
+ active { true }
+ type { 'Integrations::SquashTm' }
+
+ url { 'https://url-to-squash.com' }
+ token { 'squash_tm_token' }
end
# this is for testing storing values inside properties, which is deprecated and will be removed in
diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb
index 70a4a3ec822..67824a10288 100644
--- a/spec/factories/issues.rb
+++ b/spec/factories/issues.rb
@@ -66,6 +66,11 @@ FactoryBot.define do
end
end
+ trait :requirement do
+ issue_type { :requirement }
+ association :work_item_type, :default, :requirement
+ end
+
trait :task do
issue_type { :task }
association :work_item_type, :default, :task
@@ -81,6 +86,16 @@ FactoryBot.define do
association :work_item_type, :default, :key_result
end
+ trait :incident do
+ issue_type { :incident }
+ association :work_item_type, :default, :incident
+ end
+
+ trait :test_case do
+ issue_type { :test_case }
+ association :work_item_type, :default, :test_case
+ end
+
factory :incident do
issue_type { :incident }
association :work_item_type, :default, :incident
diff --git a/spec/factories/member_roles.rb b/spec/factories/member_roles.rb
deleted file mode 100644
index 503438d2521..00000000000
--- a/spec/factories/member_roles.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :member_role do
- namespace { association(:group) }
- base_access_level { Gitlab::Access::DEVELOPER }
-
- trait(:developer) { base_access_level { Gitlab::Access::DEVELOPER } }
- trait(:guest) { base_access_level { Gitlab::Access::GUEST } }
- end
-end
diff --git a/spec/factories/merge_requests_diff_llm_summary.rb b/spec/factories/merge_requests_diff_llm_summary.rb
new file mode 100644
index 00000000000..c72ce97efcb
--- /dev/null
+++ b/spec/factories/merge_requests_diff_llm_summary.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :merge_request_diff_llm_summary, class: 'MergeRequest::DiffLlmSummary' do
+ association :user, factory: :user
+ association :merge_request_diff, factory: :merge_request_diff
+ provider { 0 }
+ content { 'test' }
+ end
+end
diff --git a/spec/factories/ml/candidates.rb b/spec/factories/ml/candidates.rb
index 1b41e39d711..b9a2320138a 100644
--- a/spec/factories/ml/candidates.rb
+++ b/spec/factories/ml/candidates.rb
@@ -1,9 +1,11 @@
# frozen_string_literal: true
FactoryBot.define do
factory :ml_candidates, class: '::Ml::Candidate' do
- association :experiment, factory: :ml_experiments
+ association :project, factory: :project
association :user
+ experiment { association :ml_experiments, project_id: project.id }
+
trait :with_metrics_and_params do
after(:create) do |candidate|
candidate.metrics = FactoryBot.create_list(:ml_candidate_metrics, 2, candidate: candidate )
@@ -19,10 +21,12 @@ FactoryBot.define do
trait :with_artifact do
after(:create) do |candidate|
- FactoryBot.create(:generic_package,
- name: candidate.package_name,
- version: candidate.package_version,
- project: candidate.project)
+ candidate.package = FactoryBot.create(
+ :generic_package,
+ name: candidate.package_name,
+ version: candidate.package_version,
+ project: candidate.project
+ )
end
end
end
diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb
index 530b4616765..b1e7866f9ce 100644
--- a/spec/factories/notes.rb
+++ b/spec/factories/notes.rb
@@ -12,6 +12,7 @@ FactoryBot.define do
factory :note_on_commit, traits: [:on_commit]
factory :note_on_issue, traits: [:on_issue], aliases: [:votable_note]
+ factory :note_on_work_item, traits: [:on_work_item]
factory :note_on_merge_request, traits: [:on_merge_request]
factory :note_on_project_snippet, traits: [:on_project_snippet]
factory :note_on_personal_snippet, traits: [:on_personal_snippet]
@@ -54,28 +55,34 @@ FactoryBot.define do
end
position do
- association(:text_diff_position,
- file: "files/ruby/popen.rb",
- old_line: nil,
- new_line: line_number,
- diff_refs: diff_refs)
+ association(
+ :text_diff_position,
+ file: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: line_number,
+ diff_refs: diff_refs
+ )
end
trait :folded_position do
position do
- association(:text_diff_position,
- file: "files/ruby/popen.rb",
- old_line: 1,
- new_line: 1,
- diff_refs: diff_refs)
+ association(
+ :text_diff_position,
+ file: "files/ruby/popen.rb",
+ old_line: 1,
+ new_line: 1,
+ diff_refs: diff_refs
+ )
end
end
factory :image_diff_note_on_merge_request do
position do
- association(:image_diff_position,
- file: "files/images/any_image.png",
- diff_refs: diff_refs)
+ association(
+ :image_diff_position,
+ file: "files/images/any_image.png",
+ diff_refs: diff_refs
+ )
end
end
end
@@ -100,9 +107,11 @@ FactoryBot.define do
factory :diff_note_on_design, parent: :note, traits: [:on_design], class: 'DiffNote' do
position do
- association(:image_diff_position,
- file: noteable.full_path,
- diff_refs: noteable.diff_refs)
+ association(
+ :image_diff_position,
+ file: noteable.full_path,
+ diff_refs: noteable.diff_refs
+ )
end
end
@@ -122,6 +131,10 @@ FactoryBot.define do
noteable { association(:issue, project: project) }
end
+ trait :on_work_item do
+ noteable { association(:work_item, project: project) }
+ end
+
trait :on_merge_request do
noteable { association(:merge_request, source_project: project) }
end
@@ -191,6 +204,10 @@ FactoryBot.define do
confidential { true }
end
+ trait :internal do
+ internal { true }
+ end
+
trait :with_review do
review
end
diff --git a/spec/factories/notes/notes_metadata.rb b/spec/factories/notes/notes_metadata.rb
new file mode 100644
index 00000000000..555debbc0e5
--- /dev/null
+++ b/spec/factories/notes/notes_metadata.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :note_metadata, class: 'Notes::NoteMetadata' do
+ note
+ email_participant { 'email@example.com' }
+ end
+end
diff --git a/spec/factories/organizations.rb b/spec/factories/organizations.rb
new file mode 100644
index 00000000000..7ff0493d140
--- /dev/null
+++ b/spec/factories/organizations.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :organization do
+ sequence(:name) { |n| "Organization ##{n}" }
+
+ trait :default do
+ id { Organization::DEFAULT_ORGANIZATION_ID }
+ name { 'Default' }
+ initialize_with do
+ # Ensure we only use one default organization
+ Organization.find_by(id: Organization::DEFAULT_ORGANIZATION_ID) || new(**attributes)
+ end
+ end
+ end
+end
diff --git a/spec/factories/packages/debian/component_file.rb b/spec/factories/packages/debian/component_file.rb
index a2422e4a126..0a134ee16c4 100644
--- a/spec/factories/packages/debian/component_file.rb
+++ b/spec/factories/packages/debian/component_file.rb
@@ -20,7 +20,6 @@ FactoryBot.define do
component_file.file = fixture_file_upload(evaluator.file_fixture) if evaluator.file_fixture.present?
end
- file_md5 { '12345abcde' }
file_sha256 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
trait(:packages) do
@@ -47,5 +46,11 @@ FactoryBot.define do
trait(:object_storage) do
file_store { Packages::PackageFileUploader::Store::REMOTE }
end
+
+ trait(:empty) do
+ file_sha256 { 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' }
+ file_fixture { nil }
+ size { 0 }
+ end
end
end
diff --git a/spec/factories/packages/debian/distribution.rb b/spec/factories/packages/debian/distribution.rb
index 48892d16efb..7a9d8561a9c 100644
--- a/spec/factories/packages/debian/distribution.rb
+++ b/spec/factories/packages/debian/distribution.rb
@@ -4,14 +4,14 @@ FactoryBot.define do
factory :debian_project_distribution, class: 'Packages::Debian::ProjectDistribution' do
container { association(:project) }
- sequence(:codename) { |n| "#{FFaker::Lorem.word}#{n}" }
+ sequence(:codename) { |n| "codename-#{n}" }
factory :debian_group_distribution, class: 'Packages::Debian::GroupDistribution' do
container { association(:group) }
end
trait(:with_suite) do
- sequence(:suite) { |n| "#{FFaker::Lorem.word}#{n}" }
+ sequence(:suite) { |n| "suite-#{n}" }
end
trait(:with_file) do
@@ -24,7 +24,7 @@ FactoryBot.define do
FILESIGNATURE
end
- after(:build) do |distribution, evaluator|
+ after(:build) do |distribution, _evaluator|
distribution.file = fixture_file_upload('spec/fixtures/packages/debian/distribution/Release')
distribution.signed_file = fixture_file_upload('spec/fixtures/packages/debian/distribution/InRelease')
end
diff --git a/spec/factories/packages/debian/file_metadatum.rb b/spec/factories/packages/debian/file_metadatum.rb
index 505b9975f79..6b6cd9c51f3 100644
--- a/spec/factories/packages/debian/file_metadatum.rb
+++ b/spec/factories/packages/debian/file_metadatum.rb
@@ -2,11 +2,18 @@
FactoryBot.define do
factory :debian_file_metadatum, class: 'Packages::Debian::FileMetadatum' do
- package_file { association(:debian_package_file, without_loaded_metadatum: true) }
+ package_file do
+ if file_type == 'unknown'
+ association(:debian_package_file, :unknown, without_loaded_metadatum: true)
+ else
+ association(:debian_package_file, without_loaded_metadatum: true)
+ end
+ end
+
file_type { 'deb' }
component { 'main' }
architecture { 'amd64' }
- fields { { 'a': 'b' } }
+ fields { { 'a' => 'b' } }
trait(:unknown) do
file_type { 'unknown' }
@@ -30,21 +37,23 @@ FactoryBot.define do
{
'Format' => '3.0 (native)',
'Source' => package_file.package.name,
- 'Binary' => 'sample-dev, libsample0, sample-udeb',
+ 'Binary' => 'sample-dev, libsample0, sample-udeb, sample-ddeb',
'Architecture' => 'any',
- 'Version': package_file.package.version,
+ 'Version' => package_file.package.version,
'Maintainer' => "#{FFaker::Name.name} <#{FFaker::Internet.email}>",
'Homepage' => FFaker::Internet.http_url,
'Standards-Version' => '4.5.0',
'Build-Depends' => 'debhelper-compat (= 13)',
- 'Package-List' => <<~EOF.rstrip,
- libsample0 deb libs optional arch=any',
- sample-dev deb libdevel optional arch=any',
- sample-udeb udeb libs optional arch=any',
- EOF
- 'Checksums-Sha1' => "\nc5cfc111ea924842a89a06d5673f07dfd07de8ca 864 sample_1.2.3~alpha2.tar.xz",
- 'Checksums-Sha256' => "\n40e4682bb24a73251ccd7c7798c0094a649091e5625d6a14bcec9b4e7174f3da 864 sample_1.2.3~alpha2.tar.xz",
- 'Files' => "\nd5ca476e4229d135a88f9c729c7606c9 864 sample_1.2.3~alpha2.tar.xz"
+ 'Package-List' => <<~PACKAGELIST.rstrip,
+ libsample0 deb libs optional arch=any
+ sample-ddeb deb libs optional arch=any
+ sample-dev deb libdevel optional arch=any
+ sample-udeb udeb libs optional arch=any
+ PACKAGELIST
+ 'Checksums-Sha1' => "\n4a9cb2a7c77a68dc0fe54ba8ecef133a7c949e9d 964 sample_1.2.3~alpha2.tar.xz",
+ 'Checksums-Sha256' =>
+ "\nc9d05185ca158bb804977fa9d7b922e8a0f644a2da41f99d2787dd61b1e2e2c5 964 sample_1.2.3~alpha2.tar.xz",
+ 'Files' => "\nadc69e57cda38d9bb7c8d59cacfb6869 964 sample_1.2.3~alpha2.tar.xz"
}
end
end
@@ -55,22 +64,22 @@ FactoryBot.define do
architecture { 'amd64' }
fields do
{
- 'Package' => 'libsample0',
- 'Source' => package_file.package.name,
- 'Version' => package_file.package.version,
- 'Architecture' => 'amd64',
- 'Maintainer' => "#{FFaker::Name.name} <#{FFaker::Internet.email}>",
- 'Installed-Size' => '7',
- 'Section' => 'libs',
- 'Priority' => 'optional',
- 'Multi-Arch' => 'same',
- 'Homepage' => FFaker::Internet.http_url,
- 'Description' => <<~EOF.rstrip
- Some mostly empty lib
- Used in GitLab tests.
+ 'Package' => 'libsample0',
+ 'Source' => package_file.package.name,
+ 'Version' => package_file.package.version,
+ 'Architecture' => 'amd64',
+ 'Maintainer' => "#{FFaker::NameCN.name} #{FFaker::Name.name} <#{FFaker::Internet.email}>",
+ 'Installed-Size' => '7',
+ 'Section' => 'libs',
+ 'Priority' => 'optional',
+ 'Multi-Arch' => 'same',
+ 'Homepage' => FFaker::Internet.http_url,
+ 'Description' => <<~DESCRIPTION.rstrip
+ Some mostly empty lib
+ Used in GitLab tests.
- Testing another paragraph.
- EOF
+ Testing another paragraph.
+ DESCRIPTION
}
end
end
@@ -92,12 +101,12 @@ FactoryBot.define do
'Priority' => 'optional',
'Multi-Arch' => 'same',
'Homepage' => FFaker::Internet.http_url,
- 'Description' => <<~EOF.rstrip
+ 'Description' => <<~DESCRIPTION.rstrip
Some mostly empty development files
Used in GitLab tests.
Testing another paragraph.
- EOF
+ DESCRIPTION
}
end
end
@@ -106,21 +115,28 @@ FactoryBot.define do
file_type { 'udeb' }
component { 'main' }
architecture { 'amd64' }
- fields { { 'a': 'b' } }
+ fields { { 'a' => 'b' } }
+ end
+
+ trait(:ddeb) do
+ file_type { 'ddeb' }
+ component { 'main' }
+ architecture { 'amd64' }
+ fields { { 'a' => 'b' } }
end
trait(:buildinfo) do
file_type { 'buildinfo' }
component { 'main' }
architecture { nil }
- fields { { 'Architecture': 'amd64 source' } }
+ fields { { 'Architecture' => 'amd64 source' } }
end
trait(:changes) do
file_type { 'changes' }
component { nil }
architecture { nil }
- fields { { 'Architecture': 'source amd64' } }
+ fields { { 'Architecture' => 'source amd64' } }
end
end
end
diff --git a/spec/factories/packages/npm/metadata_cache.rb b/spec/factories/packages/npm/metadata_cache.rb
new file mode 100644
index 00000000000..e76ddf3c983
--- /dev/null
+++ b/spec/factories/packages/npm/metadata_cache.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :npm_metadata_cache, class: 'Packages::Npm::MetadataCache' do
+ project
+ sequence(:package_name) { |n| "@#{project.root_namespace.path}/package-#{n}" }
+ file { fixture_file_upload('spec/fixtures/packages/npm/metadata.json') }
+ size { 401.bytes }
+ end
+end
diff --git a/spec/factories/packages/package_files.rb b/spec/factories/packages/package_files.rb
index 7d3dd274777..4a2d412832c 100644
--- a/spec/factories/packages/package_files.rb
+++ b/spec/factories/packages/package_files.rb
@@ -131,9 +131,9 @@ FactoryBot.define do
trait(:source) do
file_name { 'sample_1.2.3~alpha2.tar.xz' }
- file_md5 { 'd5ca476e4229d135a88f9c729c7606c9' }
- file_sha1 { 'c5cfc111ea924842a89a06d5673f07dfd07de8ca' }
- file_sha256 { '40e4682bb24a73251ccd7c7798c0094a649091e5625d6a14bcec9b4e7174f3da' }
+ file_md5 { 'adc69e57cda38d9bb7c8d59cacfb6869' }
+ file_sha1 { '4a9cb2a7c77a68dc0fe54ba8ecef133a7c949e9d' }
+ file_sha256 { 'c9d05185ca158bb804977fa9d7b922e8a0f644a2da41f99d2787dd61b1e2e2c5' }
transient do
file_metadatum_trait { :source }
@@ -142,9 +142,9 @@ FactoryBot.define do
trait(:dsc) do
file_name { 'sample_1.2.3~alpha2.dsc' }
- file_md5 { 'ceccb6bb3e45ce6550b24234d4023e0f' }
- file_sha1 { '375ba20ea1789e1e90d469c3454ce49a431d0442' }
- file_sha256 { '81fc156ba937cdb6215362cc4bf6b8dc47be9b4253ba0f1a4ab10c7ea0c4c4e5' }
+ file_md5 { '629921cfc477bfa84adfd2ccaba89783' }
+ file_sha1 { '443c98a4cf4acd21e2259ae8f2d60fc9932de353' }
+ file_sha256 { 'f91070524a59bbb3a1f05a78409e92cb9ee86470b34018bc0b93bd5b2dd3868c' }
transient do
file_metadatum_trait { :dsc }
@@ -184,11 +184,22 @@ FactoryBot.define do
end
end
+ trait(:ddeb) do
+ file_name { 'sample-ddeb_1.2.3~alpha2_amd64.ddeb' }
+ file_md5 { '90d1107471eed48c73ad78b19ac83639' }
+ file_sha1 { '9c5af97cf8dfbe8126c807f540c88757f382b307' }
+ file_sha256 { 'a6bcc8a4b010f99ce0ea566ac69088e1910e754593c77f2b4942e3473e784e4d' }
+
+ transient do
+ file_metadatum_trait { :ddeb }
+ end
+ end
+
trait(:buildinfo) do
file_name { 'sample_1.2.3~alpha2_amd64.buildinfo' }
- file_md5 { '12a5ac4f16ad75f8741327ac23b4c0d7' }
- file_sha1 { '661f7507efa6fdd3763c95581d0baadb978b7ef5' }
- file_sha256 { 'd0c169e9caa5b303a914b27b5adf69768fe6687d4925905b7d0cd9c0f9d4e56c' }
+ file_md5 { 'cc07ff4d741aec132816f9bd67c6875d' }
+ file_sha1 { 'bcc4ca85f17a31066b726cd4e04485ab24a682c6' }
+ file_sha256 { '5a3dac17c4ff0d49fa5f47baa973902b59ad2ee05147062b8ed8f19d196731d1' }
transient do
file_metadatum_trait { :buildinfo }
@@ -204,6 +215,7 @@ FactoryBot.define do
end
trait(:keep) do
+ # do not override attributes
end
end
diff --git a/spec/factories/packages/packages.rb b/spec/factories/packages/packages.rb
index d0fde0a16cd..283df3428db 100644
--- a/spec/factories/packages/packages.rb
+++ b/spec/factories/packages/packages.rb
@@ -78,19 +78,24 @@ FactoryBot.define do
after :build do |package, evaluator|
if evaluator.published_in == :create
- create(:debian_publication, package: package)
+ build(:debian_publication, package: package)
elsif !evaluator.published_in.nil?
create(:debian_publication, package: package, distribution: evaluator.published_in)
end
end
after :create do |package, evaluator|
+ if evaluator.published_in == :create
+ package.debian_publication.save!
+ end
+
unless evaluator.without_package_files
create :debian_package_file, :source, evaluator.file_metadatum_trait, package: package
create :debian_package_file, :dsc, evaluator.file_metadatum_trait, package: package
create :debian_package_file, :deb, evaluator.file_metadatum_trait, package: package
create :debian_package_file, :deb_dev, evaluator.file_metadatum_trait, package: package
create :debian_package_file, :udeb, evaluator.file_metadatum_trait, package: package
+ create :debian_package_file, :ddeb, evaluator.file_metadatum_trait, package: package
create :debian_package_file, :buildinfo, evaluator.file_metadatum_trait, package: package
create :debian_package_file, :changes, evaluator.file_metadatum_trait, package: package
end
diff --git a/spec/factories/project_error_tracking_settings.rb b/spec/factories/project_error_tracking_settings.rb
index a8ad1af6345..dc0277cb58d 100644
--- a/spec/factories/project_error_tracking_settings.rb
+++ b/spec/factories/project_error_tracking_settings.rb
@@ -16,7 +16,12 @@ FactoryBot.define do
end
trait :integrated do
+ api_url { nil }
integrated { true }
+ token { nil }
+ project_name { nil }
+ organization_name { nil }
+ sentry_project_id { nil }
end
end
end
diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb
index d84e287d765..3e70b897df6 100644
--- a/spec/factories/project_hooks.rb
+++ b/spec/factories/project_hooks.rb
@@ -35,7 +35,7 @@ FactoryBot.define do
end
trait :permanently_disabled do
- recent_failures { WebHook::FAILURE_THRESHOLD + 1 }
+ recent_failures { WebHooks::AutoDisabling::FAILURE_THRESHOLD + 1 }
end
end
end
diff --git a/spec/factories/project_members.rb b/spec/factories/project_members.rb
index 57f228650a1..fb62b2ed951 100644
--- a/spec/factories/project_members.rb
+++ b/spec/factories/project_members.rb
@@ -26,6 +26,12 @@ FactoryBot.define do
after(:build) { |project_member, _| project_member.user.block! }
end
+ trait :banned do
+ after(:create) do |member|
+ create(:namespace_ban, namespace: member.member_namespace.root_ancestor, user: member.user) unless member.owner?
+ end
+ end
+
trait :awaiting do
after(:create) do |member|
member.update!(state: ::Member::STATE_AWAITING)
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index f113ca2425f..856f0f6cd05 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -8,8 +8,8 @@ FactoryBot.define do
# Project does not have bare repository.
# Use this factory if you don't need repository in tests
factory :project, class: 'Project' do
- sequence(:name) { |n| "project#{n}" }
- path { name.downcase.gsub(/\s/, '_') }
+ sequence(:path) { |n| "project-#{n}" }
+ name { "#{path.humanize} Name" }
# Behaves differently to nil due to cache_has_external_* methods.
has_external_issue_tracker { false }
@@ -222,7 +222,7 @@ FactoryBot.define do
# the transient `files` attribute. Each file will be created in its own
# commit, operating against the master branch. So, the following call:
#
- # create(:project, :custom_repo, files: { 'foo/a.txt' => 'foo', 'b.txt' => bar' })
+ # create(:project, :custom_repo, files: { 'foo/a.txt' => 'foo', 'b.txt' => 'bar' })
#
# will create a repository containing two files, and two commits, in master
trait :custom_repo do
@@ -245,6 +245,19 @@ FactoryBot.define do
end
end
+ # A basic repository with a single file 'test.txt'. It also has the HEAD as the default branch.
+ trait :small_repo do
+ custom_repo
+
+ files { { 'test.txt' => 'test' } }
+
+ after(:create) do |project|
+ Sidekiq::Worker.skipping_transaction_check do
+ raise "Failed to assign the repository head!" unless project.change_head(project.default_branch_or_main)
+ end
+ end
+ end
+
# Test repository - https://gitlab.com/gitlab-org/gitlab-test
trait :repository do
test_repo
@@ -354,6 +367,18 @@ FactoryBot.define do
end
end
+ trait :stubbed_commit_count do
+ after(:build) do |project|
+ stub_method(project.repository, :commit_count) { 2 }
+ end
+ end
+
+ trait :stubbed_branch_count do
+ after(:build) do |project|
+ stub_method(project.repository, :branch_count) { 2 }
+ end
+ end
+
trait :wiki_repo do
after(:create) do |project|
stub_feature_flags(main_branch_over_master: false)
@@ -510,4 +535,11 @@ FactoryBot.define do
trait :in_subgroup do
namespace factory: [:group, :nested]
end
+
+ trait :readme do
+ custom_repo
+
+ path { 'gitlab-profile' }
+ files { { 'README.md' => 'Hello World' } }
+ end
end
diff --git a/spec/factories/projects/data_transfers.rb b/spec/factories/projects/data_transfers.rb
index 4184f475663..3c335c876e4 100644
--- a/spec/factories/projects/data_transfers.rb
+++ b/spec/factories/projects/data_transfers.rb
@@ -5,5 +5,9 @@ FactoryBot.define do
project factory: :project
namespace { project.root_namespace }
date { Time.current.utc.beginning_of_month }
+ repository_egress { 1 }
+ artifacts_egress { 2 }
+ packages_egress { 3 }
+ registry_egress { 4 }
end
end
diff --git a/spec/factories/resource_events/abuse_report_events.rb b/spec/factories/resource_events/abuse_report_events.rb
new file mode 100644
index 00000000000..0771a37f01b
--- /dev/null
+++ b/spec/factories/resource_events/abuse_report_events.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :abuse_report_event, class: 'ResourceEvents::AbuseReportEvent' do
+ action { :ban_user }
+ abuse_report
+ user
+ end
+end
diff --git a/spec/factories/resource_events/issue_assignment_events.rb b/spec/factories/resource_events/issue_assignment_events.rb
new file mode 100644
index 00000000000..72319905d0d
--- /dev/null
+++ b/spec/factories/resource_events/issue_assignment_events.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :issue_assignment_event, class: 'ResourceEvents::IssueAssignmentEvent' do
+ action { :add }
+ issue
+ user
+ end
+end
diff --git a/spec/factories/resource_events/merge_request_assignment_events.rb b/spec/factories/resource_events/merge_request_assignment_events.rb
new file mode 100644
index 00000000000..6d388543648
--- /dev/null
+++ b/spec/factories/resource_events/merge_request_assignment_events.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :merge_request_assignment_event, class: 'ResourceEvents::MergeRequestAssignmentEvent' do
+ action { :add }
+ merge_request
+ user
+ end
+end
diff --git a/spec/factories/search_index.rb b/spec/factories/search_index.rb
new file mode 100644
index 00000000000..15d7024dbf1
--- /dev/null
+++ b/spec/factories/search_index.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :search_index, class: 'Search::Index' do
+ initialize_with { type.present? ? type.new : Search::Index.new }
+ sequence(:path) { |n| "index-path-#{n}" }
+ sequence(:bucket_number) { |n| n }
+ type { Search::NoteIndex }
+ end
+end
diff --git a/spec/factories/serverless/domain.rb b/spec/factories/serverless/domain.rb
deleted file mode 100644
index c09af068d19..00000000000
--- a/spec/factories/serverless/domain.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :serverless_domain, class: '::Serverless::Domain' do
- function_name { 'test-function' }
- serverless_domain_cluster { association(:serverless_domain_cluster) }
- environment { association(:environment) }
-
- skip_create
- end
-end
diff --git a/spec/factories/serverless/domain_cluster.rb b/spec/factories/serverless/domain_cluster.rb
deleted file mode 100644
index e8ff6cf42b2..00000000000
--- a/spec/factories/serverless/domain_cluster.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :serverless_domain_cluster, class: '::Serverless::DomainCluster' do
- pages_domain { association(:pages_domain) }
- knative { association(:clusters_applications_knative) }
- creator { association(:user) }
-
- certificate do
- File.read(Rails.root.join('spec/fixtures/', 'ssl_certificate.pem'))
- end
-
- key do
- File.read(Rails.root.join('spec/fixtures/', 'ssl_key.pem'))
- end
- end
-end
diff --git a/spec/factories/service_desk/custom_email_credential.rb b/spec/factories/service_desk/custom_email_credential.rb
new file mode 100644
index 00000000000..da131dd8250
--- /dev/null
+++ b/spec/factories/service_desk/custom_email_credential.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :service_desk_custom_email_credential, class: '::ServiceDesk::CustomEmailCredential' do
+ project
+ smtp_address { "smtp.example.com" }
+ smtp_username { "text@example.com" }
+ smtp_port { 587 }
+ smtp_password { "supersecret" }
+ end
+end
diff --git a/spec/factories/service_desk/custom_email_verification.rb b/spec/factories/service_desk/custom_email_verification.rb
new file mode 100644
index 00000000000..3f3a2ea570d
--- /dev/null
+++ b/spec/factories/service_desk/custom_email_verification.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :service_desk_custom_email_verification, class: '::ServiceDesk::CustomEmailVerification' do
+ state { 'started' }
+ token { 'XXXXXXXXXXXX' }
+ project
+ triggerer factory: :user
+ triggered_at { Time.current }
+ end
+end
diff --git a/spec/factories/slack_integrations.rb b/spec/factories/slack_integrations.rb
new file mode 100644
index 00000000000..a43ba8e7453
--- /dev/null
+++ b/spec/factories/slack_integrations.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :slack_integration do
+ sequence(:team_id) { |n| "T123#{n}" }
+ sequence(:user_id) { |n| "U123#{n}" }
+ sequence(:bot_user_id) { |n| "U123#{n}" }
+ sequence(:bot_access_token) { |n| OpenSSL::Digest::SHA256.hexdigest(n.to_s) }
+ sequence(:team_name) { |n| "team#{n}" }
+ sequence(:alias) { |n| "namespace#{n}/project_name#{n}" }
+
+ integration { association :gitlab_slack_application_integration, slack_integration: instance }
+
+ trait :legacy do
+ bot_user_id { nil }
+ bot_access_token { nil }
+ end
+
+ trait :all_features_supported do
+ after(:build) do |slack_integration, _evaluator|
+ slack_integration.authorized_scope_names = %w[commands chat:write chat:write.public]
+ end
+ end
+ end
+end
diff --git a/spec/factories/u2f_registrations.rb b/spec/factories/u2f_registrations.rb
deleted file mode 100644
index 40ad221415c..00000000000
--- a/spec/factories/u2f_registrations.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :u2f_registration do
- user
-
- certificate { FFaker::BaconIpsum.characters(728) }
- key_handle { FFaker::BaconIpsum.characters(86) }
- public_key { FFaker::BaconIpsum.characters(88) }
- counter { 0 }
- end
-end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index e641f925758..351583b7ef6 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -10,6 +10,7 @@ FactoryBot.define do
confirmed_at { Time.now }
confirmation_token { nil }
can_create_group { true }
+ color_scheme_id { 1 }
trait :admin do
admin { true }
@@ -59,6 +60,10 @@ FactoryBot.define do
user_type { :project_bot }
end
+ trait :service_account do
+ user_type { :service_account }
+ end
+
trait :migration_bot do
user_type { :migration_bot }
end
@@ -67,6 +72,10 @@ FactoryBot.define do
user_type { :security_bot }
end
+ trait :llm_bot do
+ user_type { :llm_bot }
+ end
+
trait :external do
external { true }
end
@@ -111,14 +120,6 @@ FactoryBot.define do
end
end
- trait :two_factor_via_u2f do
- transient { registrations_count { 5 } }
-
- after(:create) do |user, evaluator|
- create_list(:u2f_registration, evaluator.registrations_count, user: user)
- end
- end
-
trait :two_factor_via_webauthn do
transient { registrations_count { 5 } }
diff --git a/spec/factories/users/banned_users.rb b/spec/factories/users/banned_users.rb
new file mode 100644
index 00000000000..f2b6eb5893a
--- /dev/null
+++ b/spec/factories/users/banned_users.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :banned_user, class: 'Users::BannedUser' do
+ user { association(:user) }
+ end
+end
diff --git a/spec/factories/work_items.rb b/spec/factories/work_items.rb
index cff246d4071..10764457d84 100644
--- a/spec/factories/work_items.rb
+++ b/spec/factories/work_items.rb
@@ -14,6 +14,19 @@ FactoryBot.define do
confidential { true }
end
+ trait :opened do
+ state_id { WorkItem.available_states[:opened] }
+ end
+
+ trait :locked do
+ discussion_locked { true }
+ end
+
+ trait :closed do
+ state_id { WorkItem.available_states[:closed] }
+ closed_at { Time.now }
+ end
+
trait :task do
issue_type { :task }
association :work_item_type, :default, :task
@@ -24,6 +37,16 @@ FactoryBot.define do
association :work_item_type, :default, :incident
end
+ trait :requirement do
+ issue_type { :requirement }
+ association :work_item_type, :default, :requirement
+ end
+
+ trait :test_case do
+ issue_type { :test_case }
+ association :work_item_type, :default, :test_case
+ end
+
trait :last_edited_by_user do
association :last_edited_by, factory: :user
end
@@ -37,5 +60,12 @@ FactoryBot.define do
issue_type { :key_result }
association :work_item_type, :default, :key_result
end
+
+ before(:create, :build) do |work_item, evaluator|
+ if evaluator.namespace.present?
+ work_item.project = nil
+ work_item.namespace = evaluator.namespace
+ end
+ end
end
end
diff --git a/spec/factories/work_items/resource_link_events.rb b/spec/factories/work_items/resource_link_events.rb
new file mode 100644
index 00000000000..696f6dcc43f
--- /dev/null
+++ b/spec/factories/work_items/resource_link_events.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :resource_link_event, class: 'WorkItems::ResourceLinkEvent' do
+ action { :add }
+ issue { association(:issue) }
+ user { issue&.author || association(:user) }
+ child_work_item { association(:work_item, :task) }
+ end
+end
diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb
index 0df771b4025..fcf0c43243f 100644
--- a/spec/fast_spec_helper.rb
+++ b/spec/fast_spec_helper.rb
@@ -11,34 +11,24 @@ require_relative '../config/bundler_setup'
ENV['GITLAB_ENV'] = 'test'
ENV['IN_MEMORY_APPLICATION_SETTINGS'] = 'true'
+require './spec/deprecation_warnings'
+
# Enable zero monkey patching mode before loading any other RSpec code.
RSpec.configure(&:disable_monkey_patching!)
-require 'active_support/dependencies'
-require_relative '../config/initializers/0_inject_enterprise_edition_module'
+require 'active_support/all'
+require 'pry'
+require_relative 'rails_autoload'
+
require_relative '../config/settings'
require_relative 'support/rspec'
require_relative '../lib/gitlab/utils'
require_relative '../lib/gitlab/utils/strong_memoize'
-require 'active_support/all'
-require 'pry'
require_relative 'simplecov_env'
SimpleCovEnv.start!
-unless ActiveSupport::Dependencies.autoload_paths.frozen?
- ActiveSupport::Dependencies.autoload_paths << 'lib'
- ActiveSupport::Dependencies.autoload_paths << 'ee/lib'
- ActiveSupport::Dependencies.autoload_paths << 'jh/lib'
-end
-
ActiveSupport::XmlMini.backend = 'Nokogiri'
-RSpec.configure do |config|
- # Makes diffs show entire non-truncated values.
- config.before(:each, unlimited_max_formatted_output_length: true) do |_example|
- config.expect_with :rspec do |c|
- c.max_formatted_output_length = nil
- end
- end
-end
+# Consider tweaking configuration in `spec/support/rspec.rb` which is also
+# used by `spec/spec_helper.rb`.
diff --git a/spec/features/abuse_report_spec.rb b/spec/features/abuse_report_spec.rb
index 1267025a7bf..82b7379b67c 100644
--- a/spec/features/abuse_report_spec.rb
+++ b/spec/features/abuse_report_spec.rb
@@ -12,28 +12,10 @@ RSpec.describe 'Abuse reports', :js, feature_category: :insider_threat do
before do
sign_in(reporter1)
+ stub_feature_flags(moved_mr_sidebar: false)
end
describe 'report abuse to administrator' do
- shared_examples 'reports the user with an abuse category' do
- it do
- fill_and_submit_abuse_category_form
- fill_and_submit_report_abuse_form
-
- expect(page).to have_content 'Thank you for your report'
- end
- end
-
- shared_examples 'reports the user without an abuse category' do
- it do
- click_link 'Report abuse to administrator'
-
- fill_and_submit_report_abuse_form
-
- expect(page).to have_content 'Thank you for your report'
- end
- end
-
context 'when reporting an issue for abuse' do
before do
visit project_issue_path(project, issue)
@@ -133,7 +115,7 @@ RSpec.describe 'Abuse reports', :js, feature_category: :insider_threat do
before do
visit project_issue_path(project, issue)
- click_button 'More actions'
+ find('.more-actions-toggle button').click
end
it_behaves_like 'reports the user with an abuse category'
@@ -143,7 +125,7 @@ RSpec.describe 'Abuse reports', :js, feature_category: :insider_threat do
private
def fill_and_submit_abuse_category_form(category = "They're posting spam.")
- click_button 'Report abuse to administrator'
+ click_button 'Report abuse'
choose category
click_button 'Next'
diff --git a/spec/features/action_cable_logging_spec.rb b/spec/features/action_cable_logging_spec.rb
index c02a41c4c59..c8a4e1efb7a 100644
--- a/spec/features/action_cable_logging_spec.rb
+++ b/spec/features/action_cable_logging_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'ActionCable logging', :js, feature_category: :not_owned do
+RSpec.describe 'ActionCable logging', :js, feature_category: :shared do
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/features/admin/admin_abuse_reports_spec.rb b/spec/features/admin/admin_abuse_reports_spec.rb
index 10f12d7116f..9739ea53f81 100644
--- a/spec/features/admin/admin_abuse_reports_spec.rb
+++ b/spec/features/admin/admin_abuse_reports_spec.rb
@@ -2,79 +2,247 @@
require 'spec_helper'
-RSpec.describe "Admin::AbuseReports", :js, feature_category: :not_owned do
- let(:user) { create(:user) }
+RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
context 'as an admin' do
- before do
- admin = create(:admin)
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- end
+ describe 'displayed reports' do
+ include FilteredSearchHelpers
- describe 'if a user has been reported for abuse' do
- let!(:abuse_report) { create(:abuse_report, user: user) }
+ let_it_be(:open_report) { create(:abuse_report, created_at: 5.days.ago, updated_at: 2.days.ago) }
+ let_it_be(:open_report2) { create(:abuse_report, created_at: 4.days.ago, updated_at: 3.days.ago, category: 'phishing') }
+ let_it_be(:closed_report) { create(:abuse_report, :closed) }
- describe 'in the abuse report view' do
- it 'presents information about abuse report' do
- visit admin_abuse_reports_path
+ let(:abuse_report_row_selector) { '[data-testid="abuse-report-row"]' }
- expect(page).to have_content('Abuse Reports')
- expect(page).to have_content(abuse_report.message)
- expect(page).to have_link(user.name, href: user_path(user))
- expect(page).to have_link('Remove user')
- end
+ before do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+
+ visit admin_abuse_reports_path
end
- describe 'in the profile page of the user' do
- it 'shows a link to the admin view of the user' do
- visit user_path(user)
+ it 'only includes open reports by default' do
+ expect_displayed_reports_count(2)
+
+ expect_report_shown(open_report, open_report2)
- expect(page).to have_link '', href: admin_user_path(user)
+ within '[data-testid="abuse-reports-filtered-search-bar"]' do
+ expect(page).to have_content 'Status = Open'
end
end
- end
- describe 'if a many users have been reported for abuse' do
- let(:report_count) { AbuseReport.default_per_page + 3 }
+ it 'can be filtered by status, user, reporter, and category', :aggregate_failures do
+ # filter by status
+ filter %w[Status Closed]
+ expect_displayed_reports_count(1)
+ expect_report_shown(closed_report)
+ expect_report_not_shown(open_report, open_report2)
- before do
- report_count.times do
- create(:abuse_report, user: create(:user))
+ filter %w[Status Open]
+ expect_displayed_reports_count(2)
+ expect_report_shown(open_report, open_report2)
+ expect_report_not_shown(closed_report)
+
+ # filter by user
+ filter(['User', open_report2.user.username])
+
+ expect_displayed_reports_count(1)
+ expect_report_shown(open_report2)
+ expect_report_not_shown(open_report, closed_report)
+
+ # filter by reporter
+ filter(['Reporter', open_report.reporter.username])
+
+ expect_displayed_reports_count(1)
+ expect_report_shown(open_report)
+ expect_report_not_shown(open_report2, closed_report)
+
+ # filter by category
+ filter(['Category', open_report2.category])
+
+ expect_displayed_reports_count(1)
+ expect_report_shown(open_report2)
+ expect_report_not_shown(open_report, closed_report)
+ end
+
+ it 'can be sorted by created_at and updated_at in desc and asc order', :aggregate_failures do
+ # created_at desc (default)
+ expect(report_rows[0].text).to include(report_text(open_report2))
+ expect(report_rows[1].text).to include(report_text(open_report))
+
+ # created_at asc
+ toggle_sort_direction
+
+ expect(report_rows[0].text).to include(report_text(open_report))
+ expect(report_rows[1].text).to include(report_text(open_report2))
+
+ # updated_at ascending
+ sort_by 'Updated date'
+
+ expect(report_rows[0].text).to include(report_text(open_report2))
+ expect(report_rows[1].text).to include(report_text(open_report))
+
+ # updated_at descending
+ toggle_sort_direction
+
+ expect(report_rows[0].text).to include(report_text(open_report))
+ expect(report_rows[1].text).to include(report_text(open_report2))
+ end
+
+ def report_rows
+ page.all(abuse_report_row_selector)
+ end
+
+ def report_text(report)
+ "#{report.user.name} reported for #{report.category}"
+ end
+
+ def expect_report_shown(*reports)
+ reports.each do |r|
+ expect(page).to have_content(report_text(r))
end
end
- describe 'in the abuse report view' do
- it 'presents information about abuse report' do
- visit admin_abuse_reports_path
+ def expect_report_not_shown(*reports)
+ reports.each do |r|
+ expect(page).not_to have_content(report_text(r))
+ end
+ end
+
+ def expect_displayed_reports_count(count)
+ expect(page).to have_css(abuse_report_row_selector, count: count)
+ end
+
+ def filter(tokens)
+ # remove all existing filters first
+ page.find_all('.gl-token-close').each(&:click)
- expect(page).to have_selector('.pagination')
- expect(page).to have_selector('.pagination .js-pagination-page', count: (report_count.to_f / AbuseReport.default_per_page).ceil)
+ select_tokens(*tokens, submit: true, input_text: 'Filter reports')
+ end
+
+ def sort_by(sort)
+ page.within('.vue-filtered-search-bar-container .sort-dropdown-container') do
+ page.find('.gl-dropdown-toggle').click
+
+ page.within('.dropdown-menu') do
+ click_button sort
+ wait_for_requests
+ end
end
end
end
- describe 'filtering by user' do
- let!(:user2) { create(:user) }
- let!(:abuse_report) { create(:abuse_report, user: user) }
- let!(:abuse_report_2) { create(:abuse_report, user: user2) }
+ context 'when abuse_reports_list feature flag is disabled' do
+ before do
+ stub_feature_flags(abuse_reports_list: false)
- it 'shows only single user report' do
- visit admin_abuse_reports_path
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ end
+
+ describe 'if a user has been reported for abuse' do
+ let_it_be(:abuse_report) { create(:abuse_report, user: user) }
+
+ describe 'in the abuse report view' do
+ before do
+ visit admin_abuse_reports_path
+ end
+
+ it 'presents information about abuse report' do
+ expect(page).to have_content('Abuse Reports')
+
+ expect(page).to have_content(user.name)
+ expect(page).to have_content(abuse_report.reporter.name)
+ expect(page).to have_content(abuse_report.message)
+ expect(page).to have_link(user.name, href: user_path(user))
+ end
+
+ it 'present actions items' do
+ expect(page).to have_link('Remove user & report')
+ expect(page).to have_link('Block user')
+ expect(page).to have_link('Remove user')
+ end
+ end
+
+ describe 'in the profile page of the user' do
+ it 'shows a link to view user in the admin area' do
+ visit user_path(user)
+
+ expect(page).to have_link 'View user in admin area', href: admin_user_path(user)
+ end
+ end
+ end
+
+ describe 'if an admin has been reported for abuse' do
+ let_it_be(:admin_abuse_report) { create(:abuse_report, user: admin) }
+
+ describe 'in the abuse report view' do
+ before do
+ visit admin_abuse_reports_path
+ end
- page.within '.filter-form' do
- click_button 'User'
- wait_for_requests
+ it 'presents information about abuse report' do
+ page.within(:table_row, { "User" => admin.name }) do
+ expect(page).to have_content(admin.name)
+ expect(page).to have_content(admin_abuse_report.reporter.name)
+ expect(page).to have_content(admin_abuse_report.message)
+ expect(page).to have_link(admin.name, href: user_path(admin))
+ end
+ end
- page.within '.dropdown-menu-user' do
- click_link user2.name
+ it 'does not present actions items' do
+ page.within(:table_row, { "User" => admin.name }) do
+ expect(page).not_to have_link('Remove user & report')
+ expect(page).not_to have_link('Block user')
+ expect(page).not_to have_link('Remove user')
+ end
end
+ end
+ end
+
+ describe 'if a many users have been reported for abuse' do
+ let(:report_count) { AbuseReport.default_per_page + 3 }
- wait_for_requests
+ before do
+ report_count.times do
+ create(:abuse_report, user: create(:user))
+ end
end
- expect(page).to have_content(user2.name)
- expect(page).not_to have_content(user.name)
+ describe 'in the abuse report view' do
+ it 'presents information about abuse report' do
+ visit admin_abuse_reports_path
+
+ expect(page).to have_selector('.pagination')
+ expect(page).to have_selector('.pagination .js-pagination-page', count: (report_count.to_f / AbuseReport.default_per_page).ceil)
+ end
+ end
+ end
+
+ describe 'filtering by user' do
+ let!(:user2) { create(:user) }
+ let!(:abuse_report) { create(:abuse_report, user: user) }
+ let!(:abuse_report_2) { create(:abuse_report, user: user2) }
+
+ it 'shows only single user report' do
+ visit admin_abuse_reports_path
+
+ page.within '.filter-form' do
+ click_button 'User'
+ wait_for_requests
+
+ page.within '.dropdown-menu-user' do
+ click_link user2.name
+ end
+
+ wait_for_requests
+ end
+
+ expect(page).to have_content(user2.name)
+ expect(page).not_to have_content(user.name)
+ end
end
end
end
diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb
index 252d9ac5bac..db0ae79c9c4 100644
--- a/spec/features/admin/admin_appearance_spec.rb
+++ b/spec/features/admin/admin_appearance_spec.rb
@@ -2,190 +2,192 @@
require 'spec_helper'
-RSpec.describe 'Admin Appearance', feature_category: :not_owned do
+RSpec.describe 'Admin Appearance', feature_category: :shared do
let!(:appearance) { create(:appearance) }
let(:admin) { create(:admin) }
flag_values = [true, false]
flag_values.each do |val|
- before do
- stub_feature_flags(restyle_login_page: val)
- end
-
- it 'create new appearance' do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- visit admin_application_settings_appearances_path
-
- fill_in 'appearance_title', with: 'MyCompany'
- fill_in 'appearance_description', with: 'dev server'
- fill_in 'appearance_pwa_name', with: 'GitLab PWA'
- fill_in 'appearance_pwa_short_name', with: 'GitLab'
- fill_in 'appearance_pwa_description', with: 'GitLab as PWA'
- fill_in 'appearance_new_project_guidelines', with: 'Custom project guidelines'
- fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines'
- click_button 'Update appearance settings'
-
- expect(page).to have_current_path admin_application_settings_appearances_path, ignore_query: true
- expect(page).to have_content 'Appearance'
-
- expect(page).to have_field('appearance_title', with: 'MyCompany')
- expect(page).to have_field('appearance_description', with: 'dev server')
- expect(page).to have_field('appearance_pwa_name', with: 'GitLab PWA')
- expect(page).to have_field('appearance_pwa_short_name', with: 'GitLab')
- expect(page).to have_field('appearance_pwa_description', with: 'GitLab as PWA')
- expect(page).to have_field('appearance_new_project_guidelines', with: 'Custom project guidelines')
- expect(page).to have_field('appearance_profile_image_guidelines', with: 'Custom profile image guidelines')
- expect(page).to have_content 'Last edit'
- end
+ context "with #{val}" do
+ before do
+ stub_feature_flags(restyle_login_page: val)
+ end
- it 'preview sign-in page appearance' do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
+ it 'create new appearance' do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ visit admin_application_settings_appearances_path
- visit admin_application_settings_appearances_path
- click_link "Sign-in page"
+ fill_in 'appearance_title', with: 'MyCompany'
+ fill_in 'appearance_description', with: 'dev server'
+ fill_in 'appearance_pwa_name', with: 'GitLab PWA'
+ fill_in 'appearance_pwa_short_name', with: 'GitLab'
+ fill_in 'appearance_pwa_description', with: 'GitLab as PWA'
+ fill_in 'appearance_new_project_guidelines', with: 'Custom project guidelines'
+ fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines'
+ click_button 'Update appearance settings'
- expect(find('#login')).to be_disabled
- expect(find('#password')).to be_disabled
- expect(find('button')).to be_disabled
+ expect(page).to have_current_path admin_application_settings_appearances_path, ignore_query: true
+ expect(page).to have_content 'Appearance'
+
+ expect(page).to have_field('appearance_title', with: 'MyCompany')
+ expect(page).to have_field('appearance_description', with: 'dev server')
+ expect(page).to have_field('appearance_pwa_name', with: 'GitLab PWA')
+ expect(page).to have_field('appearance_pwa_short_name', with: 'GitLab')
+ expect(page).to have_field('appearance_pwa_description', with: 'GitLab as PWA')
+ expect(page).to have_field('appearance_new_project_guidelines', with: 'Custom project guidelines')
+ expect(page).to have_field('appearance_profile_image_guidelines', with: 'Custom profile image guidelines')
+ expect(page).to have_content 'Last edit'
+ end
- expect_custom_sign_in_appearance(appearance)
- end
+ it 'preview sign-in page appearance' do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
- it 'preview new project page appearance', :js do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
+ visit admin_application_settings_appearances_path
+ click_link "Sign-in page"
- visit admin_application_settings_appearances_path
- click_link "New project page"
+ expect(find('#login')).to be_disabled
+ expect(find('#password')).to be_disabled
+ expect(find('button')).to be_disabled
- expect_custom_new_project_appearance(appearance)
- end
+ expect_custom_sign_in_appearance(appearance)
+ end
- context 'Custom system header and footer' do
- before do
+ it 'preview new project page appearance', :js do
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
- end
- context 'when system header and footer messages are empty' do
- it 'shows custom system header and footer fields' do
- visit admin_application_settings_appearances_path
+ visit admin_application_settings_appearances_path
+ click_link "New project page"
- expect(page).to have_field('appearance_header_message', with: '')
- expect(page).to have_field('appearance_footer_message', with: '')
- expect(page).to have_field('appearance_message_background_color')
- expect(page).to have_field('appearance_message_font_color')
- end
+ expect_custom_new_project_appearance(appearance)
end
- context 'when system header and footer messages are not empty' do
+ context 'Custom system header and footer' do
before do
- appearance.update!(header_message: 'Foo', footer_message: 'Bar')
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
end
- it 'shows custom system header and footer fields' do
- visit admin_application_settings_appearances_path
+ context 'when system header and footer messages are empty' do
+ it 'shows custom system header and footer fields' do
+ visit admin_application_settings_appearances_path
- expect(page).to have_field('appearance_header_message', with: appearance.header_message)
- expect(page).to have_field('appearance_footer_message', with: appearance.footer_message)
- expect(page).to have_field('appearance_message_background_color')
- expect(page).to have_field('appearance_message_font_color')
+ expect(page).to have_field('appearance_header_message', with: '')
+ expect(page).to have_field('appearance_footer_message', with: '')
+ expect(page).to have_field('appearance_message_background_color')
+ expect(page).to have_field('appearance_message_font_color')
+ end
end
- end
- end
- it 'custom sign-in page' do
- visit new_user_session_path
+ context 'when system header and footer messages are not empty' do
+ before do
+ appearance.update!(header_message: 'Foo', footer_message: 'Bar')
+ end
- expect_custom_sign_in_appearance(appearance)
- end
+ it 'shows custom system header and footer fields' do
+ visit admin_application_settings_appearances_path
- it 'custom new project page', :js do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- visit new_project_path
- click_link 'Create blank project'
+ expect(page).to have_field('appearance_header_message', with: appearance.header_message)
+ expect(page).to have_field('appearance_footer_message', with: appearance.footer_message)
+ expect(page).to have_field('appearance_message_background_color')
+ expect(page).to have_field('appearance_message_font_color')
+ end
+ end
+ end
- expect_custom_new_project_appearance(appearance)
- end
+ it 'custom sign-in page' do
+ visit new_user_session_path
- context 'Profile page with custom profile image guidelines' do
- before do
+ expect_custom_sign_in_appearance(appearance)
+ end
+
+ it 'custom new project page', :js do
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
- visit admin_application_settings_appearances_path
- fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines, please :smile:!'
- click_button 'Update appearance settings'
+ visit new_project_path
+ click_link 'Create blank project'
+
+ expect_custom_new_project_appearance(appearance)
end
- it 'renders guidelines when set' do
- sign_in create(:user)
- visit profile_path
+ context 'Profile page with custom profile image guidelines' do
+ before do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ visit admin_application_settings_appearances_path
+ fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines, please :smile:!'
+ click_button 'Update appearance settings'
+ end
+
+ it 'renders guidelines when set' do
+ sign_in create(:user)
+ visit profile_path
- expect(page).to have_content 'Custom profile image guidelines, please 😄!'
+ expect(page).to have_content 'Custom profile image guidelines, please 😄!'
+ end
end
- end
- it 'appearance logo' do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- visit admin_application_settings_appearances_path
+ it 'appearance logo' do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ visit admin_application_settings_appearances_path
- attach_file(:appearance_logo, logo_fixture)
- click_button 'Update appearance settings'
- expect(page).to have_css(logo_selector)
+ attach_file(:appearance_logo, logo_fixture)
+ click_button 'Update appearance settings'
+ expect(page).to have_css(logo_selector)
- click_link 'Remove logo'
- expect(page).not_to have_css(logo_selector)
- end
+ click_link 'Remove logo'
+ expect(page).not_to have_css(logo_selector)
+ end
- it 'appearance pwa icon' do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- visit admin_application_settings_appearances_path
+ it 'appearance pwa icon' do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ visit admin_application_settings_appearances_path
- attach_file(:appearance_pwa_icon, logo_fixture)
- click_button 'Update appearance settings'
- expect(page).to have_css(pwa_icon_selector)
+ attach_file(:appearance_pwa_icon, logo_fixture)
+ click_button 'Update appearance settings'
+ expect(page).to have_css(pwa_icon_selector)
- click_link 'Remove icon'
- expect(page).not_to have_css(pwa_icon_selector)
- end
+ click_link 'Remove icon'
+ expect(page).not_to have_css(pwa_icon_selector)
+ end
- it 'header logos' do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- visit admin_application_settings_appearances_path
+ it 'header logos' do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ visit admin_application_settings_appearances_path
- attach_file(:appearance_header_logo, logo_fixture)
- click_button 'Update appearance settings'
- expect(page).to have_css(header_logo_selector)
+ attach_file(:appearance_header_logo, logo_fixture)
+ click_button 'Update appearance settings'
+ expect(page).to have_css(header_logo_selector)
- click_link 'Remove header logo'
- expect(page).not_to have_css(header_logo_selector)
- end
+ click_link 'Remove header logo'
+ expect(page).not_to have_css(header_logo_selector)
+ end
- it 'Favicon' do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- visit admin_application_settings_appearances_path
+ it 'Favicon' do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ visit admin_application_settings_appearances_path
- attach_file(:appearance_favicon, logo_fixture)
- click_button 'Update appearance settings'
+ attach_file(:appearance_favicon, logo_fixture)
+ click_button 'Update appearance settings'
- expect(page).to have_css('.appearance-light-logo-preview')
+ expect(page).to have_css('.appearance-light-logo-preview')
- click_link 'Remove favicon'
+ click_link 'Remove favicon'
- expect(page).not_to have_css('.appearance-light-logo-preview')
+ expect(page).not_to have_css('.appearance-light-logo-preview')
- # allowed file types
- attach_file(:appearance_favicon, Rails.root.join('spec', 'fixtures', 'sanitized.svg'))
- click_button 'Update appearance settings'
+ # allowed file types
+ attach_file(:appearance_favicon, Rails.root.join('spec', 'fixtures', 'sanitized.svg'))
+ click_button 'Update appearance settings'
- expect(page).to have_content 'Favicon You are not allowed to upload "svg" files, allowed types: png, ico'
+ expect(page).to have_content 'Favicon You are not allowed to upload "svg" files, allowed types: png, ico'
+ end
end
end
diff --git a/spec/features/admin/admin_browse_spam_logs_spec.rb b/spec/features/admin/admin_browse_spam_logs_spec.rb
index 461c9d08273..c272a8630b7 100644
--- a/spec/features/admin/admin_browse_spam_logs_spec.rb
+++ b/spec/features/admin/admin_browse_spam_logs_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Admin browse spam logs', feature_category: :not_owned do
+RSpec.describe 'Admin browse spam logs', feature_category: :shared do
let!(:spam_log) { create(:spam_log, description: 'abcde ' * 20) }
before do
diff --git a/spec/features/admin/admin_deploy_keys_spec.rb b/spec/features/admin/admin_deploy_keys_spec.rb
index e55e1cce6b9..f59b4db5cc2 100644
--- a/spec/features/admin/admin_deploy_keys_spec.rb
+++ b/spec/features/admin/admin_deploy_keys_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'admin deploy keys', :js, feature_category: :authentication_and_authorization do
+RSpec.describe 'admin deploy keys', :js, feature_category: :system_access do
include Spec::Support::Helpers::ModalHelpers
let_it_be(:admin) { create(:admin) }
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index a07a5c48713..34fe98d22bd 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe 'Admin Groups', feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::MembersHelpers
+ include Features::InviteMembersModalHelpers
include Spec::Support::Helpers::ModalHelpers
let(:internal) { Gitlab::VisibilityLevel::INTERNAL }
diff --git a/spec/features/admin/admin_health_check_spec.rb b/spec/features/admin/admin_health_check_spec.rb
index de71a48d2dc..66014e676d5 100644
--- a/spec/features/admin/admin_health_check_spec.rb
+++ b/spec/features/admin/admin_health_check_spec.rb
@@ -2,8 +2,9 @@
require 'spec_helper'
-RSpec.describe "Admin Health Check", feature_category: :continuous_verification do
+RSpec.describe "Admin Health Check", :js, feature_category: :error_budgets do
include StubENV
+ include Spec::Support::Helpers::ModalHelpers
let_it_be(:admin) { create(:admin) }
before do
@@ -30,7 +31,8 @@ RSpec.describe "Admin Health Check", feature_category: :continuous_verification
describe 'reload access token' do
it 'changes the access token' do
orig_token = Gitlab::CurrentSettings.health_check_access_token
- click_button 'Reset health check access token'
+ click_link 'Reset health check access token'
+ accept_gl_confirm('Are you sure you want to reset the health check token?')
expect(page).to have_content('New health check access token has been generated!')
expect(find('#health-check-token').text).not_to eq orig_token
diff --git a/spec/features/admin/admin_hook_logs_spec.rb b/spec/features/admin/admin_hook_logs_spec.rb
index d6507e68692..34208cca113 100644
--- a/spec/features/admin/admin_hook_logs_spec.rb
+++ b/spec/features/admin/admin_hook_logs_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Admin::HookLogs', feature_category: :continuous_verification do
+RSpec.describe 'Admin::HookLogs', feature_category: :integrations do
let_it_be(:system_hook) { create(:system_hook) }
let_it_be(:hook_log) { create(:web_hook_log, web_hook: system_hook, internal_error_message: 'some error') }
let_it_be(:admin) { create(:admin) }
diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb
index 363c152371e..a8aa2680b55 100644
--- a/spec/features/admin/admin_hooks_spec.rb
+++ b/spec/features/admin/admin_hooks_spec.rb
@@ -106,7 +106,7 @@ RSpec.describe 'Admin::Hooks', feature_category: :integrations do
visit admin_hooks_path
click_button 'Test'
- click_button 'Push events'
+ click_link 'Push events'
end
it { expect(page).to have_current_path(admin_hooks_path, ignore_query: true) }
@@ -142,7 +142,7 @@ RSpec.describe 'Admin::Hooks', feature_category: :integrations do
visit admin_hooks_path
click_button 'Test'
- click_button 'Merge request events'
+ click_link 'Merge request events'
expect(page).to have_content 'Hook executed successfully'
end
diff --git a/spec/features/admin/admin_labels_spec.rb b/spec/features/admin/admin_labels_spec.rb
index 8d2813d26f7..68d63ac321e 100644
--- a/spec/features/admin/admin_labels_spec.rb
+++ b/spec/features/admin/admin_labels_spec.rb
@@ -37,8 +37,11 @@ RSpec.describe 'admin issues labels', feature_category: :team_planning do
end
it 'deletes all labels', :js do
- page.all('.labels .js-remove-label').each do |remove|
- accept_gl_confirm(button_text: 'Delete label') { remove.click }
+ page.all('.labels .label-actions-list').each do |label|
+ label.click
+ accept_gl_confirm(button_text: 'Delete label') do
+ click_link 'Delete'
+ end
wait_for_requests
end
diff --git a/spec/features/admin/admin_mode/login_spec.rb b/spec/features/admin/admin_mode/login_spec.rb
index 393721fe451..c0c8b12342a 100644
--- a/spec/features/admin/admin_mode/login_spec.rb
+++ b/spec/features/admin/admin_mode/login_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Admin Mode Login', feature_category: :authentication_and_authorization do
+RSpec.describe 'Admin Mode Login', feature_category: :system_access do
include TermsHelper
include UserLoginHelper
include LdapHelpers
@@ -15,249 +15,251 @@ RSpec.describe 'Admin Mode Login', feature_category: :authentication_and_authori
flag_values = [true, false]
flag_values.each do |val|
- before do
- stub_feature_flags(restyle_login_page: val)
- end
- context 'with valid username/password' do
- let(:user) { create(:admin, :two_factor) }
+ context "with #{val}" do
+ before do
+ stub_feature_flags(restyle_login_page: val)
+ end
+ context 'with valid username/password' do
+ let(:user) { create(:admin, :two_factor) }
- context 'using one-time code' do
- it 'blocks login if we reuse the same code immediately' do
- gitlab_sign_in(user, remember: true)
+ context 'using one-time code' do
+ it 'blocks login if we reuse the same code immediately' do
+ gitlab_sign_in(user, remember: true)
- expect(page).to have_content('Two-Factor Authentication')
+ expect(page).to have_content(_('Enter verification code'))
- repeated_otp = user.current_otp
- enter_code(repeated_otp)
- gitlab_enable_admin_mode_sign_in(user)
+ repeated_otp = user.current_otp
+ enter_code(repeated_otp)
+ gitlab_enable_admin_mode_sign_in(user)
- expect(page).to have_content('Two-Factor Authentication')
+ expect(page).to have_content(_('Enter verification code'))
- enter_code(repeated_otp)
+ enter_code(repeated_otp)
- expect(page).to have_current_path admin_session_path, ignore_query: true
- expect(page).to have_content('Invalid two-factor code')
- end
+ expect(page).to have_current_path admin_session_path, ignore_query: true
+ expect(page).to have_content('Invalid two-factor code')
+ end
- context 'not re-using codes' do
- before do
- gitlab_sign_in(user, remember: true)
+ context 'not re-using codes' do
+ before do
+ gitlab_sign_in(user, remember: true)
- expect(page).to have_content('Two-factor authentication code')
+ expect(page).to have_content('Enter verification code')
- enter_code(user.current_otp)
- gitlab_enable_admin_mode_sign_in(user)
+ enter_code(user.current_otp)
+ gitlab_enable_admin_mode_sign_in(user)
- expect(page).to have_content('Two-Factor Authentication')
- end
+ expect(page).to have_content(_('Enter verification code'))
+ end
- it 'allows login with valid code' do
- # Cannot reuse the TOTP
- travel_to(30.seconds.from_now) do
- enter_code(user.current_otp)
+ it 'allows login with valid code' do
+ # Cannot reuse the TOTP
+ travel_to(30.seconds.from_now) do
+ enter_code(user.current_otp)
- expect(page).to have_current_path admin_root_path, ignore_query: true
- expect(page).to have_content('Admin mode enabled')
+ expect(page).to have_current_path admin_root_path, ignore_query: true
+ expect(page).to have_content('Admin mode enabled')
+ end
end
- end
- it 'blocks login with invalid code' do
- # Cannot reuse the TOTP
- travel_to(30.seconds.from_now) do
- enter_code('foo')
+ it 'blocks login with invalid code' do
+ # Cannot reuse the TOTP
+ travel_to(30.seconds.from_now) do
+ enter_code('foo')
- expect(page).to have_content('Invalid two-factor code')
+ expect(page).to have_content('Invalid two-factor code')
+ end
end
- end
- it 'allows login with invalid code, then valid code' do
- # Cannot reuse the TOTP
- travel_to(30.seconds.from_now) do
- enter_code('foo')
+ it 'allows login with invalid code, then valid code' do
+ # Cannot reuse the TOTP
+ travel_to(30.seconds.from_now) do
+ enter_code('foo')
- expect(page).to have_content('Invalid two-factor code')
+ expect(page).to have_content('Invalid two-factor code')
- enter_code(user.current_otp)
+ enter_code(user.current_otp)
- expect(page).to have_current_path admin_root_path, ignore_query: true
- expect(page).to have_content('Admin mode enabled')
+ expect(page).to have_current_path admin_root_path, ignore_query: true
+ expect(page).to have_content('Admin mode enabled')
+ end
end
- end
- context 'using backup code' do
- let(:codes) { user.generate_otp_backup_codes! }
+ context 'using backup code' do
+ let(:codes) { user.generate_otp_backup_codes! }
- before do
- expect(codes.size).to eq 10
+ before do
+ expect(codes.size).to eq 10
- # Ensure the generated codes get saved
- user.save!
- end
+ # Ensure the generated codes get saved
+ user.save!
+ end
- context 'with valid code' do
- it 'allows login' do
- enter_code(codes.sample)
+ context 'with valid code' do
+ it 'allows login' do
+ enter_code(codes.sample)
- expect(page).to have_current_path admin_root_path, ignore_query: true
- expect(page).to have_content('Admin mode enabled')
- end
+ expect(page).to have_current_path admin_root_path, ignore_query: true
+ expect(page).to have_content('Admin mode enabled')
+ end
- it 'invalidates the used code' do
- expect { enter_code(codes.sample) }
- .to change { user.reload.otp_backup_codes.size }.by(-1)
+ it 'invalidates the used code' do
+ expect { enter_code(codes.sample) }
+ .to change { user.reload.otp_backup_codes.size }.by(-1)
+ end
end
- end
- context 'with invalid code' do
- it 'blocks login' do
- code = codes.sample
- expect(user.invalidate_otp_backup_code!(code)).to eq true
+ context 'with invalid code' do
+ it 'blocks login' do
+ code = codes.sample
+ expect(user.invalidate_otp_backup_code!(code)).to eq true
- user.save!
- expect(user.reload.otp_backup_codes.size).to eq 9
+ user.save!
+ expect(user.reload.otp_backup_codes.size).to eq 9
- enter_code(code)
+ enter_code(code)
- expect(page).to have_content('Invalid two-factor code.')
+ expect(page).to have_content('Invalid two-factor code.')
+ end
end
end
end
end
- end
-
- context 'when logging in via omniauth' do
- let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml', password_automatically_set: false) }
- let(:mock_saml_response) do
- File.read('spec/fixtures/authentication/saml_response.xml')
- end
- before do
- stub_omniauth_saml_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'], providers: [mock_saml_config_with_upstream_two_factor_authn_contexts])
- end
-
- context 'when authn_context is worth two factors' do
+ context 'when logging in via omniauth' do
+ let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml', password_automatically_set: false) }
let(:mock_saml_response) do
File.read('spec/fixtures/authentication/saml_response.xml')
- .gsub('urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
- 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS')
end
- it 'signs user in without prompting for second factor' do
- sign_in_using_saml!
+ before do
+ stub_omniauth_saml_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'], providers: [mock_saml_config_with_upstream_two_factor_authn_contexts])
+ end
+
+ context 'when authn_context is worth two factors' do
+ let(:mock_saml_response) do
+ File.read('spec/fixtures/authentication/saml_response.xml')
+ .gsub('urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
+ 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS')
+ end
- expect(page).not_to have_content('Two-Factor Authentication')
+ it 'signs user in without prompting for second factor' do
+ sign_in_using_saml!
- enable_admin_mode_using_saml!
+ expect(page).not_to have_content(_('Enter verification code'))
- expect(page).not_to have_content('Two-Factor Authentication')
- expect(page).to have_current_path admin_root_path, ignore_query: true
- expect(page).to have_content('Admin mode enabled')
+ enable_admin_mode_using_saml!
+
+ expect(page).not_to have_content(_('Enter verification code'))
+ expect(page).to have_current_path admin_root_path, ignore_query: true
+ expect(page).to have_content('Admin mode enabled')
+ end
end
- end
- context 'when two factor authentication is required' do
- it 'shows 2FA prompt after omniauth login' do
- sign_in_using_saml!
+ context 'when two factor authentication is required' do
+ it 'shows 2FA prompt after omniauth login' do
+ sign_in_using_saml!
- expect(page).to have_content('Two-Factor Authentication')
- enter_code(user.current_otp)
+ expect(page).to have_content(_('Enter verification code'))
+ enter_code(user.current_otp)
- enable_admin_mode_using_saml!
+ enable_admin_mode_using_saml!
- expect(page).to have_content('Two-Factor Authentication')
+ expect(page).to have_content(_('Enter verification code'))
- # Cannot reuse the TOTP
- travel_to(30.seconds.from_now) do
- enter_code(user.current_otp)
+ # Cannot reuse the TOTP
+ travel_to(30.seconds.from_now) do
+ enter_code(user.current_otp)
- expect(page).to have_current_path admin_root_path, ignore_query: true
- expect(page).to have_content('Admin mode enabled')
+ expect(page).to have_current_path admin_root_path, ignore_query: true
+ expect(page).to have_content('Admin mode enabled')
+ end
end
end
- end
- def sign_in_using_saml!
- gitlab_sign_in_via('saml', user, 'my-uid', mock_saml_response)
- end
+ def sign_in_using_saml!
+ gitlab_sign_in_via('saml', user, 'my-uid', mock_saml_response)
+ end
- def enable_admin_mode_using_saml!
- gitlab_enable_admin_mode_sign_in_via('saml', user, 'my-uid', mock_saml_response)
+ def enable_admin_mode_using_saml!
+ gitlab_enable_admin_mode_sign_in_via('saml', user, 'my-uid', mock_saml_response)
+ end
end
- end
- context 'when logging in via ldap' do
- let(:uid) { 'my-uid' }
- let(:provider_label) { 'Main LDAP' }
- let(:provider_name) { 'main' }
- let(:provider) { "ldap#{provider_name}" }
- let(:ldap_server_config) do
- {
- 'label' => provider_label,
- 'provider_name' => provider,
- 'attributes' => {},
- 'encryption' => 'plain',
- 'uid' => 'uid',
- 'base' => 'dc=example,dc=com'
- }
- end
+ context 'when logging in via ldap' do
+ let(:uid) { 'my-uid' }
+ let(:provider_label) { 'Main LDAP' }
+ let(:provider_name) { 'main' }
+ let(:provider) { "ldap#{provider_name}" }
+ let(:ldap_server_config) do
+ {
+ 'label' => provider_label,
+ 'provider_name' => provider,
+ 'attributes' => {},
+ 'encryption' => 'plain',
+ 'uid' => 'uid',
+ 'base' => 'dc=example,dc=com'
+ }
+ end
- let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: uid, provider: provider) }
+ let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: uid, provider: provider) }
- before do
- setup_ldap(provider, user, uid, ldap_server_config)
- end
+ before do
+ setup_ldap(provider, user, uid, ldap_server_config)
+ end
- context 'when two factor authentication is required' do
- it 'shows 2FA prompt after ldap login' do
- sign_in_using_ldap!(user, provider_label)
- expect(page).to have_content('Two-Factor Authentication')
+ context 'when two factor authentication is required' do
+ it 'shows 2FA prompt after ldap login' do
+ sign_in_using_ldap!(user, provider_label)
+ expect(page).to have_content(_('Enter verification code'))
- enter_code(user.current_otp)
- enable_admin_mode_using_ldap!(user)
+ enter_code(user.current_otp)
+ enable_admin_mode_using_ldap!(user)
- expect(page).to have_content('Two-Factor Authentication')
+ expect(page).to have_content(_('Enter verification code'))
- # Cannot reuse the TOTP
- travel_to(30.seconds.from_now) do
- enter_code(user.current_otp)
+ # Cannot reuse the TOTP
+ travel_to(30.seconds.from_now) do
+ enter_code(user.current_otp)
- expect(page).to have_current_path admin_root_path, ignore_query: true
- expect(page).to have_content('Admin mode enabled')
+ expect(page).to have_current_path admin_root_path, ignore_query: true
+ expect(page).to have_content('Admin mode enabled')
+ end
end
end
- end
- def setup_ldap(provider, user, uid, ldap_server_config)
- stub_ldap_setting(enabled: true)
+ def setup_ldap(provider, user, uid, ldap_server_config)
+ stub_ldap_setting(enabled: true)
- allow(::Gitlab::Auth::Ldap::Config).to receive_messages(enabled: true, servers: [ldap_server_config])
- allow(Gitlab::Auth::OAuth::Provider).to receive_messages(providers: [provider.to_sym])
+ allow(::Gitlab::Auth::Ldap::Config).to receive_messages(enabled: true, servers: [ldap_server_config])
+ allow(Gitlab::Auth::OAuth::Provider).to receive_messages(providers: [provider.to_sym])
- Ldap::OmniauthCallbacksController.define_providers!
- Rails.application.reload_routes!
+ Ldap::OmniauthCallbacksController.define_providers!
+ Rails.application.reload_routes!
- mock_auth_hash(provider, uid, user.email)
- allow(Gitlab::Auth::Ldap::Access).to receive(:allowed?).with(user).and_return(true)
+ mock_auth_hash(provider, uid, user.email)
+ allow(Gitlab::Auth::Ldap::Access).to receive(:allowed?).with(user).and_return(true)
- allow_any_instance_of(ActionDispatch::Routing::RoutesProxy)
- .to receive(:"user_#{provider}_omniauth_callback_path")
- .and_return("/users/auth/#{provider}/callback")
- end
+ allow_any_instance_of(ActionDispatch::Routing::RoutesProxy)
+ .to receive(:"user_#{provider}_omniauth_callback_path")
+ .and_return("/users/auth/#{provider}/callback")
+ end
- def sign_in_using_ldap!(user, provider_label)
- visit new_user_session_path
- click_link provider_label
- fill_in 'username', with: user.username
- fill_in 'password', with: user.password
- click_button 'Sign in'
- end
+ def sign_in_using_ldap!(user, provider_label)
+ visit new_user_session_path
+ click_link provider_label
+ fill_in 'username', with: user.username
+ fill_in 'password', with: user.password
+ click_button 'Sign in'
+ end
- def enable_admin_mode_using_ldap!(user)
- visit new_admin_session_path
- click_link provider_label
- fill_in 'username', with: user.username
- fill_in 'password', with: user.password
- click_button 'Enter Admin Mode'
+ def enable_admin_mode_using_ldap!(user)
+ visit new_admin_session_path
+ click_link provider_label
+ fill_in 'username', with: user.username
+ fill_in 'password', with: user.password
+ click_button 'Enter admin mode'
+ end
end
end
end
diff --git a/spec/features/admin/admin_mode/logout_spec.rb b/spec/features/admin/admin_mode/logout_spec.rb
index f4e8941d25a..a64d3f241f6 100644
--- a/spec/features/admin/admin_mode/logout_spec.rb
+++ b/spec/features/admin/admin_mode/logout_spec.rb
@@ -2,10 +2,10 @@
require 'spec_helper'
-RSpec.describe 'Admin Mode Logout', :js, feature_category: :authentication_and_authorization do
+RSpec.describe 'Admin Mode Logout', :js, feature_category: :system_access do
include TermsHelper
include UserLoginHelper
- include Spec::Support::Helpers::Features::TopNavSpecHelpers
+ include Features::TopNavSpecHelpers
let(:user) { create(:admin) }
diff --git a/spec/features/admin/admin_mode/workers_spec.rb b/spec/features/admin/admin_mode/workers_spec.rb
index f3639fd0800..124c43eef9d 100644
--- a/spec/features/admin/admin_mode/workers_spec.rb
+++ b/spec/features/admin/admin_mode/workers_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
# Test an operation that triggers background jobs requiring administrative rights
-RSpec.describe 'Admin mode for workers', :request_store, feature_category: :authentication_and_authorization do
- include Spec::Support::Helpers::Features::AdminUsersHelpers
+RSpec.describe 'Admin mode for workers', :request_store, feature_category: :system_access do
+ include Features::AdminUsersHelpers
let(:user) { create(:user) }
let(:user_to_delete) { create(:user) }
diff --git a/spec/features/admin/admin_mode_spec.rb b/spec/features/admin/admin_mode_spec.rb
index 769ff75b5a2..65249fa0235 100644
--- a/spec/features/admin/admin_mode_spec.rb
+++ b/spec/features/admin/admin_mode_spec.rb
@@ -2,9 +2,9 @@
require 'spec_helper'
-RSpec.describe 'Admin mode', :js, feature_category: :not_owned do
+RSpec.describe 'Admin mode', :js, feature_category: :shared do
include MobileHelpers
- include Spec::Support::Helpers::Features::TopNavSpecHelpers
+ include Features::TopNavSpecHelpers
include StubENV
let(:admin) { create(:admin) }
@@ -50,7 +50,7 @@ RSpec.describe 'Admin mode', :js, feature_category: :not_owned do
fill_in 'user_password', with: admin.password
- click_button 'Enter Admin Mode'
+ click_button 'Enter admin mode'
expect(page).to have_current_path(admin_root_path)
end
@@ -65,7 +65,7 @@ RSpec.describe 'Admin mode', :js, feature_category: :not_owned do
fill_in 'user_password', with: admin.password
- click_button 'Enter Admin Mode'
+ click_button 'Enter admin mode'
expect(page).to have_current_path(admin_root_path)
end
@@ -111,7 +111,7 @@ RSpec.describe 'Admin mode', :js, feature_category: :not_owned do
open_top_nav
expect(page).to have_link(text: 'Admin', href: admin_root_path, visible: true)
- expect(page).to have_link(text: 'Leave Admin Mode', href: destroy_admin_session_path, visible: true)
+ expect(page).to have_link(text: 'Leave admin mode', href: destroy_admin_session_path, visible: true)
end
end
diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb
index f08e6521184..ac2e9de7aee 100644
--- a/spec/features/admin/admin_projects_spec.rb
+++ b/spec/features/admin/admin_projects_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe "Admin::Projects", feature_category: :projects do
- include Spec::Support::Helpers::Features::MembersHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::MembersHelpers
+ include Features::InviteMembersModalHelpers
include Spec::Support::Helpers::ModalHelpers
include ListboxHelpers
@@ -161,4 +161,44 @@ RSpec.describe "Admin::Projects", feature_category: :projects do
expect(page).to have_current_path(dashboard_projects_path, ignore_query: true, url: false)
end
end
+
+ describe 'project edit' do
+ it 'updates project details' do
+ project = create(:project, :private, name: 'Garfield', description: 'Funny Cat')
+
+ visit edit_admin_namespace_project_path({ id: project.to_param, namespace_id: project.namespace.to_param })
+
+ aggregate_failures do
+ expect(page).to have_content(project.name)
+ expect(page).to have_content(project.description)
+ end
+
+ fill_in 'Project name', with: 'Scooby-Doo'
+ fill_in 'Project description (optional)', with: 'Funny Dog'
+
+ click_button 'Save changes'
+
+ visit edit_admin_namespace_project_path({ id: project.to_param, namespace_id: project.namespace.to_param })
+
+ aggregate_failures do
+ expect(page).to have_content('Scooby-Doo')
+ expect(page).to have_content('Funny Dog')
+ end
+ end
+ end
+
+ describe 'project runner registration edit' do
+ it 'updates runner registration' do
+ visit edit_admin_namespace_project_path({ id: project.to_param, namespace_id: project.namespace.to_param })
+
+ expect(find_field('New project runners can be registered')).to be_checked
+
+ uncheck 'New project runners can be registered'
+ click_button 'Save changes'
+
+ visit edit_admin_namespace_project_path({ id: project.to_param, namespace_id: project.namespace.to_param })
+
+ expect(find_field('New project runners can be registered')).not_to be_checked
+ end
+ end
end
diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb
index 04dc206f052..582535790bd 100644
--- a/spec/features/admin/admin_runners_spec.rb
+++ b/spec/features/admin/admin_runners_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe "Admin Runners", feature_category: :runner_fleet do
- include Spec::Support::Helpers::Features::RunnersHelpers
+ include Features::RunnersHelpers
include Spec::Support::Helpers::ModalHelpers
let_it_be(:admin) { create(:admin) }
@@ -23,8 +23,6 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
describe "runners creation" do
before do
- stub_feature_flags(create_runner_workflow: true)
-
visit admin_runners_path
end
@@ -34,15 +32,30 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
end
describe "runners registration" do
- before do
- stub_feature_flags(create_runner_workflow: false)
+ context 'when create_runner_workflow_for_namespace is enabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_admin: true)
- visit admin_runners_path
+ visit admin_runners_path
+ end
+
+ it_behaves_like "shows and resets runner registration token" do
+ let(:dropdown_text) { s_('Runners|Register an instance runner') }
+ let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token }
+ end
end
- it_behaves_like "shows and resets runner registration token" do
- let(:dropdown_text) { s_('Runners|Register an instance runner') }
- let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token }
+ context 'when create_runner_workflow_for_namespace is disabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_admin: false)
+
+ visit admin_runners_path
+ end
+
+ it_behaves_like "shows and resets runner registration token" do
+ let(:dropdown_text) { s_('Runners|Register an instance runner') }
+ let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token }
+ end
end
end
@@ -373,11 +386,9 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
it_behaves_like 'shows no runners found'
- it 'shows active tab' do
+ it 'shows active tab with no runner' do
expect(page).to have_link('Instance', class: 'active')
- end
- it 'shows no runner' do
expect(page).not_to have_content 'runner-project'
expect(page).not_to have_content 'runner-group'
end
@@ -471,10 +482,12 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
it_behaves_like 'shows no runners registered'
it 'shows tabs with total counts equal to 0' do
- expect(page).to have_link('All 0')
- expect(page).to have_link('Instance 0')
- expect(page).to have_link('Group 0')
- expect(page).to have_link('Project 0')
+ aggregate_failures do
+ expect(page).to have_link('All 0')
+ expect(page).to have_link('Instance 0')
+ expect(page).to have_link('Group 0')
+ expect(page).to have_link('Project 0')
+ end
end
end
@@ -493,6 +506,16 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
end
end
+ describe "Runner create page", :js do
+ before do
+ visit new_admin_runner_path
+ end
+
+ it_behaves_like 'creates runner and shows register page' do
+ let(:register_path_pattern) { register_admin_runner_path('.*') }
+ end
+ end
+
describe "Runner show page", :js do
let_it_be(:runner) do
create(
@@ -546,11 +569,8 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
end
end
- it 'deletes runner' do
+ it 'deletes runner and redirects to runner list' do
expect(page.find('[data-testid="alert-success"]')).to have_content('deleted')
- end
-
- it 'redirects to runner list' do
expect(current_url).to match(admin_runners_path)
end
end
@@ -593,12 +613,9 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
wait_for_requests
end
- it 'show success alert' do
- expect(page.find('[data-testid="alert-success"]')).to have_content('saved')
- end
-
- it 'redirects to runner page' do
+ it 'show success alert and redirects to runner page' do
expect(current_url).to match(admin_runner_path(project_runner))
+ expect(page.find('[data-testid="alert-success"]')).to have_content('saved')
end
end
@@ -632,12 +649,12 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet 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)
+ expect(assigned_project).to have_content(project2.name)
end
end
context 'with project runner' do
- let(:project_runner) { create(:ci_runner, :project, projects: [project1]) }
+ let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project1]) }
before do
visit edit_admin_runner_path(project_runner)
@@ -647,7 +664,7 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
end
context 'with locked runner' do
- let(:locked_runner) { create(:ci_runner, :project, projects: [project1], locked: true) }
+ let_it_be(:locked_runner) { create(:ci_runner, :project, projects: [project1], locked: true) }
before do
visit edit_admin_runner_path(locked_runner)
@@ -658,7 +675,7 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
end
describe 'disable/destroy' do
- let(:runner) { create(:ci_runner, :project, projects: [project1]) }
+ let_it_be(:runner) { create(:ci_runner, :project, projects: [project1]) }
before do
visit edit_admin_runner_path(runner)
@@ -672,7 +689,7 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do
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)
+ expect(new_runner_project).to have_content(project1.name)
end
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 cad1bf74d2e..77266e65e4c 100644
--- a/spec/features/admin/admin_sees_background_migrations_spec.rb
+++ b/spec/features/admin/admin_sees_background_migrations_spec.rb
@@ -191,7 +191,7 @@ RSpec.describe "Admin > Admin sees background migrations", feature_category: :da
visit admin_background_migrations_path
within '#content-body' do
- expect(page).to have_link('Learn more', href: help_page_path('user/admin_area/monitoring/background_migrations'))
+ expect(page).to have_link('Learn more', href: help_page_path('update/background_migrations'))
end
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index 26efed85513..1f43caf37e7 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Admin updates settings', feature_category: :not_owned do
+RSpec.describe 'Admin updates settings', feature_category: :shared do
include StubENV
include TermsHelper
include UsageDataHelpers
@@ -53,17 +53,6 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
end
it 'modify import sources' do
- expect(current_settings.import_sources).not_to be_empty
-
- page.within('[data-testid="admin-visibility-access-settings"]') do
- Gitlab::ImportSources.options.map do |name, _|
- uncheck name
- end
-
- click_button 'Save changes'
- end
-
- expect(page).to have_content "Application settings saved successfully"
expect(current_settings.import_sources).to be_empty
page.within('[data-testid="admin-visibility-access-settings"]') do
@@ -157,7 +146,7 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
expect(user_internal_regex['placeholder']).to eq 'Regex pattern'
end
- context 'Dormant users' do
+ context 'Dormant users', feature_category: :user_management do
context 'when Gitlab.com' do
let(:dot_com?) { true }
@@ -182,7 +171,7 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
expect(page).to have_field('Days of inactivity before deactivation')
end
- it 'changes dormant users' do
+ it 'changes dormant users', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408224' do
expect(page).to have_unchecked_field('Deactivate dormant users after a period of inactivity')
expect(current_settings.deactivate_dormant_users).to be_falsey
@@ -199,7 +188,7 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
expect(page).to have_checked_field('Deactivate dormant users after a period of inactivity')
end
- it 'change dormant users period' do
+ it 'change dormant users period', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408224' do
expect(page).to have_field _('Days of inactivity before deactivation')
page.within(find('[data-testid="account-limit"]')) do
@@ -360,8 +349,8 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
end
end
- context 'GitLab for Jira App settings' do
- it 'changes the setting' do
+ context 'GitLab for Jira App settings', feature_category: :integrations do
+ it 'changes the settings' do
page.within('#js-jira_connect-settings') do
fill_in 'Jira Connect Application ID', with: '1234'
fill_in 'Jira Connect Proxy URL', with: 'https://example.com'
@@ -375,6 +364,28 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
expect(page).to have_content "Application settings saved successfully"
end
end
+
+ context 'GitLab for Slack app settings', feature_category: :integrations do
+ it 'changes the settings' do
+ page.within('.as-slack') do
+ check 'Enable Slack application'
+ fill_in 'Client ID', with: 'slack_app_id'
+ fill_in 'Client secret', with: 'slack_app_secret'
+ fill_in 'Signing secret', with: 'slack_app_signing_secret'
+ fill_in 'Verification token', with: 'slack_app_verification_token'
+ click_button 'Save changes'
+ end
+
+ expect(current_settings).to have_attributes(
+ slack_app_enabled: true,
+ slack_app_id: 'slack_app_id',
+ slack_app_secret: 'slack_app_secret',
+ slack_app_signing_secret: 'slack_app_signing_secret',
+ slack_app_verification_token: 'slack_app_verification_token'
+ )
+ expect(page).to have_content 'Application settings saved successfully'
+ end
+ end
end
context 'Integrations page' do
@@ -426,6 +437,7 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
fill_in 'application_setting_auto_devops_domain', with: 'domain.com'
uncheck 'Keep the latest artifacts for all jobs in the latest successful pipelines'
uncheck 'Enable pipeline suggestion banner'
+ fill_in 'application_setting_ci_max_includes', with: 200
click_button 'Save changes'
end
@@ -433,6 +445,7 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
expect(current_settings.auto_devops_domain).to eq('domain.com')
expect(current_settings.keep_latest_artifact).to be false
expect(current_settings.suggest_pipeline_enabled).to be false
+ expect(current_settings.ci_max_includes).to be 200
expect(page).to have_content "Application settings saved successfully"
end
@@ -442,7 +455,6 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
page.within('.as-ci-cd') do
fill_in 'plan_limits_ci_pipeline_size', with: 10
fill_in 'plan_limits_ci_active_jobs', with: 20
- fill_in 'plan_limits_ci_active_pipelines', with: 25
fill_in 'plan_limits_ci_project_subscriptions', with: 30
fill_in 'plan_limits_ci_pipeline_schedules', with: 40
fill_in 'plan_limits_ci_needs_size_limit', with: 50
@@ -454,7 +466,6 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
limits = default_plan.reload.limits
expect(limits.ci_pipeline_size).to eq(10)
expect(limits.ci_active_jobs).to eq(20)
- expect(limits.ci_active_pipelines).to eq(25)
expect(limits.ci_project_subscriptions).to eq(30)
expect(limits.ci_pipeline_schedules).to eq(40)
expect(limits.ci_needs_size_limit).to eq(50)
@@ -487,7 +498,7 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
container_registry_delete_tags_service_timeout: 'Container Registry delete tags service execution timeout',
container_registry_expiration_policies_worker_capacity: 'Cleanup policy maximum workers running concurrently',
container_registry_cleanup_tags_service_max_list_size: 'Cleanup policy maximum number of tags to be deleted',
- container_registry_expiration_policies_caching: 'Enable container expiration caching'
+ container_registry_expiration_policies_caching: 'Enable cleanup policy caching'
}
end
@@ -638,6 +649,8 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
end
it 'loads togglable usage ping payload on click', :js do
+ allow(Gitlab::Usage::ServicePingReport).to receive(:for).and_return({ uuid: '12345678', hostname: '127.0.0.1' })
+
stub_usage_data_connections
stub_database_flavor_check
@@ -666,11 +679,11 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
visit network_admin_application_settings_path
page.within('.as-outbound') do
- check 'Allow requests to the local network from web hooks and services'
+ check 'Allow requests to the local network from webhooks and integrations'
# Enabled by default
uncheck 'Allow requests to the local network from system hooks'
# Enabled by default
- uncheck 'Enforce DNS rebinding attack protection'
+ uncheck 'Enforce DNS-rebinding attack protection'
click_button 'Save changes'
end
@@ -762,6 +775,18 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
expect(current_settings.users_get_by_id_limit_allowlist).to eq(%w[someone someone_else])
end
+ it 'changes Projects API rate limits settings' do
+ visit network_admin_application_settings_path
+
+ page.within('.as-projects-api-limits') do
+ fill_in 'Maximum requests per 10 minutes per IP address', with: 100
+ click_button 'Save changes'
+ end
+
+ expect(page).to have_content "Application settings saved successfully"
+ expect(current_settings.projects_api_rate_limit_unauthenticated).to eq(100)
+ end
+
shared_examples 'regular throttle rate limit settings' do
it 'changes rate limit settings' do
visit network_admin_application_settings_path
diff --git a/spec/features/admin/admin_system_info_spec.rb b/spec/features/admin/admin_system_info_spec.rb
index 6c4a316ae77..21a001f12c3 100644
--- a/spec/features/admin/admin_system_info_spec.rb
+++ b/spec/features/admin/admin_system_info_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Admin System Info', feature_category: :not_owned do
+RSpec.describe 'Admin System Info', feature_category: :shared do
before do
admin = create(:admin)
sign_in(admin)
diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
index 5e6cc206883..0350c8ab066 100644
--- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb
+++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
@@ -2,9 +2,9 @@
require 'spec_helper'
-RSpec.describe 'Admin > Users > Impersonation Tokens', :js, feature_category: :authentication_and_authorization do
+RSpec.describe 'Admin > Users > Impersonation Tokens', :js, feature_category: :system_access do
include Spec::Support::Helpers::ModalHelpers
- include Spec::Support::Helpers::AccessTokenHelpers
+ include Features::AccessTokenHelpers
let(:admin) { create(:admin) }
let!(:user) { create(:user) }
diff --git a/spec/features/admin/admin_uses_repository_checks_spec.rb b/spec/features/admin/admin_uses_repository_checks_spec.rb
index 318572a7664..d9d36ec3bae 100644
--- a/spec/features/admin/admin_uses_repository_checks_spec.rb
+++ b/spec/features/admin/admin_uses_repository_checks_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe 'Admin uses repository checks', :request_store, feature_category:
visit_admin_project_page(project)
expect(page).not_to have_css('.repository-check')
- expect(page).to have_content('Enter Admin Mode')
+ expect(page).to have_content('Enter admin mode')
end
end
diff --git a/spec/features/admin/broadcast_messages_spec.rb b/spec/features/admin/broadcast_messages_spec.rb
new file mode 100644
index 00000000000..fca4cdb0ff4
--- /dev/null
+++ b/spec/features/admin/broadcast_messages_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Admin Broadcast Messages', :js, feature_category: :onboarding do
+ context 'when creating and editing' do
+ it 'previews, creates and edits a broadcast message' do
+ admin = create(:admin)
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+
+ # create
+ visit admin_broadcast_messages_path
+
+ fill_in 'Message', with: 'test message'
+
+ wait_for_requests
+
+ page.within(preview_container) do
+ expect(page).to have_content('test message')
+ end
+
+ click_button 'Add broadcast message'
+
+ wait_for_requests
+
+ page.within(preview_container) do
+ expect(page).to have_content('Your message here')
+ end
+
+ page.within(first_message_container) do
+ expect(page).to have_content('test message')
+ end
+
+ # edit
+ page.within(first_message_container) do
+ find('[data-testid="edit-message"]').click
+ end
+
+ wait_for_requests
+
+ expect(find('[data-testid="message-input"]').value).to eq('test message')
+
+ fill_in 'Message', with: 'changed test message'
+
+ wait_for_requests
+
+ page.within(preview_container) do
+ expect(page).to have_content('changed test message')
+ end
+
+ click_button 'Update broadcast message'
+
+ wait_for_requests
+
+ page.within(preview_container) do
+ expect(page).to have_content('Your message here')
+ end
+
+ page.within(first_message_container) do
+ expect(page).to have_content('changed test message')
+ end
+ end
+
+ def preview_container
+ find('[data-testid="preview-broadcast-message"]')
+ end
+
+ def first_message_container
+ find('[data-testid="message-row"]', match: :first)
+ end
+ end
+end
diff --git a/spec/features/admin/integrations/instance_integrations_spec.rb b/spec/features/admin/integrations/instance_integrations_spec.rb
index 3b2ed1d9810..d963aa700eb 100644
--- a/spec/features/admin/integrations/instance_integrations_spec.rb
+++ b/spec/features/admin/integrations/instance_integrations_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe 'Instance integrations', :js, feature_category: :integrations do
include_context 'instance integration activation'
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
it_behaves_like 'integration settings form' do
let(:integrations) { Integration.find_or_initialize_all_non_project_specific(Integration.for_instance) }
diff --git a/spec/features/admin/users/user_spec.rb b/spec/features/admin/users/user_spec.rb
index 66129617220..18d62cb585f 100644
--- a/spec/features/admin/users/user_spec.rb
+++ b/spec/features/admin/users/user_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Admin::Users::User', feature_category: :user_management do
- include Spec::Support::Helpers::Features::AdminUsersHelpers
+ include Features::AdminUsersHelpers
include Spec::Support::Helpers::ModalHelpers
let_it_be(:user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') }
@@ -273,8 +273,11 @@ RSpec.describe 'Admin::Users::User', feature_category: :user_management do
end
context 'when viewing the confirm email warning', :js do
- let_it_be(:another_user) { create(:user, :unconfirmed) }
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
+ end
+ let_it_be(:another_user) { create(:user, :unconfirmed) }
let(:warning_alert) { page.find(:css, '[data-testid="alert-warning"]') }
let(:expected_styling) { { 'pointer-events' => 'none', 'cursor' => 'default' } }
diff --git a/spec/features/admin/users/users_spec.rb b/spec/features/admin/users/users_spec.rb
index 07db0750074..8e80ce5edd9 100644
--- a/spec/features/admin/users/users_spec.rb
+++ b/spec/features/admin/users/users_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Admin::Users', feature_category: :user_management do
- include Spec::Support::Helpers::Features::AdminUsersHelpers
+ include Features::AdminUsersHelpers
include Spec::Support::Helpers::ModalHelpers
include ListboxHelpers
@@ -311,6 +311,40 @@ RSpec.describe 'Admin::Users', feature_category: :user_management do
end
end
+ describe 'users pending approval' do
+ it 'sends a welcome email and a password reset email to the user upon admin approval', :sidekiq_inline do
+ user = create(:user, :blocked_pending_approval, created_by_id: current_user.id)
+
+ visit admin_users_path
+
+ click_link 'Pending approval'
+
+ click_user_dropdown_toggle(user.id)
+
+ find('[data-testid="approve"]').click
+
+ expect(page).to have_content("Approve user #{user.name}?")
+
+ within_modal do
+ perform_enqueued_jobs do
+ click_button 'Approve'
+ end
+ end
+
+ expect(page).to have_content('Successfully approved')
+
+ welcome_email = ActionMailer::Base.deliveries.find { |m| m.subject == 'Welcome to GitLab!' }
+ expect(welcome_email.to).to eq([user.email])
+ expect(welcome_email.text_part.body).to have_content('Your GitLab account request has been approved!')
+
+ password_reset_email = ActionMailer::Base.deliveries.find { |m| m.subject == 'Account was created for you' }
+ expect(password_reset_email.to).to eq([user.email])
+ expect(password_reset_email.text_part.body).to have_content('Click here to set your password')
+
+ expect(ActionMailer::Base.deliveries.count).to eq(2)
+ end
+ end
+
describe 'internal users' do
context 'when showing a `Ghost User`' do
let_it_be(:ghost_user) { create(:user, :ghost) }
diff --git a/spec/features/admin_variables_spec.rb b/spec/features/admin_variables_spec.rb
index d1adbf59984..744d18a3b6d 100644
--- a/spec/features/admin_variables_spec.rb
+++ b/spec/features/admin_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Instance variables', :js, feature_category: :pipeline_authoring do
+RSpec.describe 'Instance variables', :js, feature_category: :secrets_management do
let(:admin) { create(:admin) }
let(:page_path) { ci_cd_admin_application_settings_path }
@@ -12,9 +12,21 @@ RSpec.describe 'Instance variables', :js, feature_category: :pipeline_authoring
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
+
visit page_path
wait_for_requests
end
- it_behaves_like 'variable list', isAdmin: true
+ context 'when ci_variables_pages FF is enabled' do
+ it_behaves_like 'variable list', is_admin: true
+ it_behaves_like 'variable list pagination', :ci_instance_variable
+ end
+
+ context 'when ci_variables_pages FF is disabled' do
+ before do
+ stub_feature_flags(ci_variables_pages: false)
+ end
+
+ it_behaves_like 'variable list', is_admin: true
+ end
end
diff --git a/spec/features/alerts_settings/user_views_alerts_settings_spec.rb b/spec/features/alerts_settings/user_views_alerts_settings_spec.rb
index 70223b2c0d4..94f5cf8f5b2 100644
--- a/spec/features/alerts_settings/user_views_alerts_settings_spec.rb
+++ b/spec/features/alerts_settings/user_views_alerts_settings_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe 'Alert integrations settings form', :js, feature_category: :incid
describe 'when viewing alert integrations as a maintainer' do
context 'with the default page permissions' do
before do
+ stub_feature_flags(remove_monitor_metrics: false)
visit project_settings_operations_path(project, anchor: 'js-alert-management-settings')
wait_for_requests
end
diff --git a/spec/features/boards/board_filters_spec.rb b/spec/features/boards/board_filters_spec.rb
index dee63be8119..006b7ce45d4 100644
--- a/spec/features/boards/board_filters_spec.rb
+++ b/spec/features/boards/board_filters_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe 'Issue board filters', :js, feature_category: :team_planning do
set_filter('assignee')
end
- it_behaves_like 'loads all the users when opened' do
+ it_behaves_like 'loads all the users when opened', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/351426' do
let(:issue) { issue_2 }
end
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index 3e2e391d060..1ea6e079104 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -150,8 +150,7 @@ RSpec.describe 'Project issue boards', :js, feature_category: :team_planning do
find('.board .board-list')
inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do
- evaluate_script("window.scrollTo(0, document.body.scrollHeight)")
- evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
+ evaluate_script("[...document.querySelectorAll('.board:nth-child(2) .board-list [data-testid=\"board-card-gl-io\"]')].pop().scrollIntoView()")
end
expect(page).to have_selector('.board-card', count: 20)
@@ -160,8 +159,7 @@ RSpec.describe 'Project issue boards', :js, feature_category: :team_planning do
find('.board .board-list')
inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do
- evaluate_script("window.scrollTo(0, document.body.scrollHeight)")
- evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
+ evaluate_script("[...document.querySelectorAll('.board:nth-child(2) .board-list [data-testid=\"board-card-gl-io\"]')].pop().scrollIntoView()")
end
expect(page).to have_selector('.board-card', count: 30)
@@ -170,8 +168,7 @@ RSpec.describe 'Project issue boards', :js, feature_category: :team_planning do
find('.board .board-list')
inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do
- evaluate_script("window.scrollTo(0, document.body.scrollHeight)")
- evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight")
+ evaluate_script("[...document.querySelectorAll('.board:nth-child(2) .board-list [data-testid=\"board-card-gl-io\"]')].pop().scrollIntoView()")
end
expect(page).to have_selector('.board-card', count: 38)
@@ -594,7 +591,9 @@ RSpec.describe 'Project issue boards', :js, feature_category: :team_planning do
def remove_list
page.within(find('.board:nth-child(2)')) do
- find('button[title="List settings"]').click
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ click_button('Edit list settings')
end
page.within(find('.js-board-settings-sidebar')) do
diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb
index 8aecaab42c2..b6196fa6a1d 100644
--- a/spec/features/boards/issue_ordering_spec.rb
+++ b/spec/features/boards/issue_ordering_spec.rb
@@ -138,7 +138,7 @@ RSpec.describe 'Issue Boards', :js, feature_category: :team_planning do
wait_for_requests
end
- it 'moves to end of list' do
+ it 'moves to end of list', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410100' do
expect(all('.board-card').first).to have_content(issue3.title)
page.within(find('.board:nth-child(2)')) do
@@ -151,7 +151,7 @@ RSpec.describe 'Issue Boards', :js, feature_category: :team_planning do
expect(all('.board-card').last).to have_content(issue3.title)
end
- it 'moves to start of list' do
+ it 'moves to start of list', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410100' do
expect(all('.board-card').last).to have_content(issue1.title)
page.within(find('.board:nth-child(2)')) do
diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb
index d597c57ac1c..6753f0ea009 100644
--- a/spec/features/boards/new_issue_spec.rb
+++ b/spec/features/boards/new_issue_spec.rb
@@ -32,18 +32,23 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
end
it 'displays new issue button' do
- expect(first('.board')).to have_button('New issue', count: 1)
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ expect(first('.board')).to have_button('Create new issue', count: 1)
end
it 'does not display new issue button in closed list' do
page.within('.board:nth-child(3)') do
- expect(page).not_to have_button('New issue')
+ expect(page).not_to have_selector("[data-testid='header-list-actions']")
+ expect(page).not_to have_button('Create new issue')
end
end
it 'shows form when clicking button' do
page.within(first('.board')) do
- click_button 'New issue'
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ click_button 'Create new issue'
expect(page).to have_selector('.board-new-issue-form')
end
@@ -51,7 +56,9 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
it 'hides form when clicking cancel' do
page.within(first('.board')) do
- click_button 'New issue'
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ click_button 'Create new issue'
expect(page).to have_selector('.board-new-issue-form')
@@ -63,7 +70,9 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
it 'creates new issue, places it on top of the list, and opens sidebar' do
page.within(first('.board')) do
- click_button 'New issue'
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ click_button 'Create new issue'
end
page.within(first('.board-new-issue-form')) do
@@ -91,7 +100,9 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
it 'successfuly loads labels to be added to newly created issue' do
page.within(first('.board')) do
- click_button 'New issue'
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ click_button 'Create new issue'
end
page.within(first('.board-new-issue-form')) do
@@ -121,7 +132,9 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
wait_for_all_requests
page.within('.board:nth-child(2)') do
- click_button('New issue')
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ click_button('Create new issue')
page.within(first('.board-new-issue-form')) do
find('.form-control').set('new issue')
@@ -144,12 +157,14 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
end
it 'does not display new issue button in open list' do
- expect(first('.board')).not_to have_button('New issue')
+ expect(page).not_to have_selector("[data-testid='header-list-actions']")
+ expect(first('.board')).not_to have_button('Create new issue')
end
it 'does not display new issue button in label list' do
page.within('.board:nth-child(2)') do
- expect(page).not_to have_button('New issue')
+ expect(page).not_to have_selector("[data-testid='header-list-actions']")
+ expect(page).not_to have_button('Create new issue')
end
end
end
@@ -173,7 +188,8 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
context 'when backlog does not exist' do
it 'does not display new issue button in label list' do
page.within('.board.is-draggable') do
- expect(page).not_to have_button('New issue')
+ expect(page).not_to have_selector("[data-testid='header-list-actions']")
+ expect(page).not_to have_button('Create new issue')
end
end
end
@@ -182,12 +198,14 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
let_it_be(:backlog_list) { create(:backlog_list, board: group_board) }
it 'does not display new issue button in open list' do
- expect(first('.board')).not_to have_button('New issue')
+ expect(page).not_to have_selector("[data-testid='header-list-actions']")
+ expect(first('.board')).not_to have_button('Create new issue')
end
it 'does not display new issue button in label list' do
page.within('.board.is-draggable') do
- expect(page).not_to have_button('New issue')
+ expect(page).not_to have_selector("[data-testid='header-list-actions']")
+ expect(page).not_to have_button('Create new issue')
end
end
end
@@ -205,7 +223,9 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
context 'when backlog does not exist' do
it 'display new issue button in label list' do
- expect(board_list_header).to have_button('New issue')
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ expect(board_list_header).to have_button('Create new issue')
end
end
@@ -214,7 +234,9 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d
before do
page.within(board_list_header) do
- click_button 'New issue'
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ click_button 'Create new issue'
end
project_select_dropdown.click
diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb
index 0a16e95c0bf..358da1e1279 100644
--- a/spec/features/boards/sidebar_spec.rb
+++ b/spec/features/boards/sidebar_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe 'Project issue boards sidebar', :js, feature_category: :team_plan
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:board) { create(:board, project: project) }
- let_it_be(:label) { create(:label, project: project, name: 'Label') }
+ let_it_be(:label) { create(:label, project: project, name: 'Label') }
let_it_be(:list) { create(:list, board: board, label: label, position: 0) }
let_it_be(:issue, reload: true) { create(:issue, project: project, relative_position: 1) }
diff --git a/spec/features/breadcrumbs_schema_markup_spec.rb b/spec/features/breadcrumbs_schema_markup_spec.rb
index d924423c9a9..6610519cd24 100644
--- a/spec/features/breadcrumbs_schema_markup_spec.rb
+++ b/spec/features/breadcrumbs_schema_markup_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Breadcrumbs schema markup', :aggregate_failures, feature_category: :not_owned do
+RSpec.describe 'Breadcrumbs schema markup', :aggregate_failures, feature_category: :shared do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, namespace: user.namespace) }
let_it_be(:issue) { create(:issue, project: project) }
diff --git a/spec/features/broadcast_messages_spec.rb b/spec/features/broadcast_messages_spec.rb
index 3e4289347e3..2fad15c8a1f 100644
--- a/spec/features/broadcast_messages_spec.rb
+++ b/spec/features/broadcast_messages_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe 'Broadcast Messages', feature_category: :onboarding do
shared_examples 'a Broadcast Messages' do |type|
it 'shows broadcast message' do
- visit root_path
+ visit explore_projects_path
expect(page).to have_content 'SampleMessage'
end
@@ -15,17 +15,17 @@ RSpec.describe 'Broadcast Messages', feature_category: :onboarding do
it 'renders styled links' do
create(:broadcast_message, type, message: "<a href='gitlab.com' style='color: purple'>click me</a>")
- visit root_path
+ visit explore_projects_path
expected_html = "<p><a href=\"gitlab.com\" style=\"color: purple\">click me</a></p>"
expect(page.body).to include(expected_html)
end
end
- shared_examples 'a dismissable Broadcast Messages' do
+ shared_examples 'a dismissible Broadcast Messages' do
it 'hides broadcast message after dismiss', :js,
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/390900' do
- visit root_path
+ visit explore_projects_path
find('.js-dismiss-current-broadcast-notification').click
@@ -34,25 +34,25 @@ RSpec.describe 'Broadcast Messages', feature_category: :onboarding do
it 'broadcast message is still hidden after refresh', :js,
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391406' do
- visit root_path
+ visit explore_projects_path
find('.js-dismiss-current-broadcast-notification').click
wait_for_cookie_set("hide_broadcast_message_#{broadcast_message.id}")
- visit root_path
+ visit explore_projects_path
expect(page).not_to have_content 'SampleMessage'
end
end
describe 'banner type' do
- let!(:broadcast_message) { create(:broadcast_message, message: 'SampleMessage') }
+ let_it_be(:broadcast_message) { create(:broadcast_message, message: 'SampleMessage') }
it_behaves_like 'a Broadcast Messages'
- it 'is not dismissable' do
- visit root_path
+ it 'is not dismissible' do
+ visit explore_projects_path
expect(page).not_to have_selector('.js-dismiss-current-broadcast-notification')
end
@@ -62,33 +62,33 @@ RSpec.describe 'Broadcast Messages', feature_category: :onboarding do
sign_in(user)
- visit root_path
+ visit explore_projects_path
expect(page).to have_content 'Hi {{name}}'
end
end
- describe 'dismissable banner type' do
- let!(:broadcast_message) { create(:broadcast_message, dismissable: true, message: 'SampleMessage') }
+ describe 'dismissible banner type' do
+ let_it_be(:broadcast_message) { create(:broadcast_message, dismissable: true, message: 'SampleMessage') }
it_behaves_like 'a Broadcast Messages'
- it_behaves_like 'a dismissable Broadcast Messages'
+ it_behaves_like 'a dismissible Broadcast Messages'
end
describe 'notification type' do
- let!(:broadcast_message) { create(:broadcast_message, :notification, message: 'SampleMessage') }
+ let_it_be(:broadcast_message) { create(:broadcast_message, :notification, message: 'SampleMessage') }
it_behaves_like 'a Broadcast Messages', :notification
- it_behaves_like 'a dismissable Broadcast Messages'
+ it_behaves_like 'a dismissible Broadcast Messages'
it 'replaces placeholders' do
create(:broadcast_message, :notification, message: 'Hi {{name}}')
sign_in(user)
- visit root_path
+ visit explore_projects_path
expect(page).to have_content "Hi #{user.name}"
end
diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb
index b2a29c88b68..67baed5dc91 100644
--- a/spec/features/calendar_spec.rb
+++ b/spec/features/calendar_spec.rb
@@ -44,15 +44,25 @@ RSpec.describe 'Contributions Calendar', :js, feature_category: :user_profile do
"#{get_cell_level_selector(contributions)}[title='#{contribution_text}<br /><span class=\"gl-text-gray-300\">#{date}</span>']"
end
+ def get_days_of_week
+ page.all('[data-testid="user-contrib-cell-group"]')[1]
+ .all('[data-testid="user-contrib-cell"]')
+ .map do |node|
+ node[:title].match(/(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)/)[0]
+ end
+ end
+
def push_code_contribution
event = create(:push_event, project: contributed_project, author: user)
- create(:push_event_payload,
- event: event,
- commit_from: '11f9ac0a48b62cef25eedede4c1819964f08d5ce',
- commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
- commit_count: 3,
- ref: 'master')
+ create(
+ :push_event_payload,
+ event: event,
+ commit_from: '11f9ac0a48b62cef25eedede4c1819964f08d5ce',
+ commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ commit_count: 3,
+ ref: 'master'
+ )
end
def note_comment_contribution
@@ -70,162 +80,331 @@ RSpec.describe 'Contributions Calendar', :js, feature_category: :user_profile do
find('#js-overview .user-calendar-activities', visible: visible).text
end
- before do
- stub_feature_flags(profile_tabs_vue: false)
- sign_in user
- end
-
- describe 'calendar day selection' do
+ shared_context 'when user page is visited' do
before do
visit user.username
- page.find('.js-overview-tab a').click
+ page.click_link('Overview')
wait_for_requests
end
+ end
- it 'displays calendar' do
- expect(find('#js-overview')).to have_css('.js-contrib-calendar')
+ context 'with `profile_tabs_vue` feature flag disabled' do
+ before do
+ stub_feature_flags(profile_tabs_vue: false)
+ sign_in user
end
- describe 'select calendar day' do
- let(:cells) { page.all('#js-overview .user-contrib-cell') }
+ describe 'calendar day selection' do
+ include_context 'when user page is visited'
- before do
- cells[0].click
- wait_for_requests
- @first_day_activities = selected_day_activities
+ it 'displays calendar' do
+ expect(find('#js-overview')).to have_css('.js-contrib-calendar')
end
- it 'displays calendar day activities' do
- expect(selected_day_activities).not_to be_empty
- end
+ describe 'select calendar day' do
+ let(:cells) { page.all('#js-overview .user-contrib-cell') }
- describe 'select another calendar day' do
before do
- cells[1].click
+ cells[0].click
wait_for_requests
end
- it 'displays different calendar day activities' do
- expect(selected_day_activities).not_to eq(@first_day_activities)
+ it 'displays calendar day activities' do
+ expect(selected_day_activities).not_to be_empty
end
- end
- describe 'deselect calendar day' do
- before do
- cells[0].click
- wait_for_requests
- cells[0].click
+ describe 'select another calendar day' do
+ it 'displays different calendar day activities' do
+ first_day_activities = selected_day_activities
+
+ cells[1].click
+ wait_for_requests
+
+ expect(selected_day_activities).not_to eq(first_day_activities)
+ end
end
- it 'hides calendar day activities' do
- expect(selected_day_activities(visible: false)).to be_empty
+ describe 'deselect calendar day' do
+ before do
+ cells[0].click
+ wait_for_requests
+ cells[0].click
+ end
+
+ it 'hides calendar day activities' do
+ expect(selected_day_activities(visible: false)).to be_empty
+ end
end
end
end
- end
- shared_context 'visit user page' do
- before do
- visit user.username
- page.find('.js-overview-tab a').click
- wait_for_requests
- end
- end
+ describe 'calendar daily activities' do
+ shared_examples 'a day with activity' do |contribution_count:|
+ include_context 'when user page is visited'
+
+ it 'displays calendar activity square for 1 contribution', :sidekiq_inline do
+ expect(find('#js-overview')).to have_selector(get_cell_level_selector(contribution_count), count: 1)
+
+ today = Date.today.strftime(date_format)
+ expect(find('#js-overview')).to have_selector(get_cell_date_selector(contribution_count, today), count: 1)
+ end
+ end
- describe 'calendar daily activities' do
- shared_examples 'a day with activity' do |contribution_count:|
- include_context 'visit user page'
+ describe '1 issue and 1 work item creation calendar activity' do
+ before do
+ Issues::CreateService.new(
+ container: contributed_project,
+ current_user: user,
+ params: issue_params,
+ spam_params: nil
+ ).execute
+ WorkItems::CreateService.new(
+ container: contributed_project,
+ current_user: user,
+ params: { title: 'new task' },
+ spam_params: nil
+ ).execute
+ end
- it 'displays calendar activity square for 1 contribution', :sidekiq_might_not_need_inline do
- expect(find('#js-overview')).to have_selector(get_cell_level_selector(contribution_count), count: 1)
+ it_behaves_like 'a day with activity', contribution_count: 2
- today = Date.today.strftime(date_format)
- expect(find('#js-overview')).to have_selector(get_cell_date_selector(contribution_count, today), count: 1)
+ describe 'issue title is shown on activity page' do
+ include_context 'when user page is visited'
+
+ it 'displays calendar activity log', :sidekiq_inline do
+ expect(all('#js-overview .overview-content-list .event-target-title').map(&:text)).to contain_exactly(
+ match(/#{issue_title}/),
+ match(/new task/)
+ )
+ end
+ end
end
- end
- describe '1 issue and 1 work item creation calendar activity' do
- before do
- Issues::CreateService.new(container: contributed_project, current_user: user, params: issue_params, spam_params: nil).execute
- WorkItems::CreateService.new(
- container: contributed_project,
- current_user: user,
- params: { title: 'new task' },
- spam_params: nil
- ).execute
+ describe '1 comment calendar activity' do
+ before do
+ note_comment_contribution
+ end
+
+ it_behaves_like 'a day with activity', contribution_count: 1
end
- it_behaves_like 'a day with activity', contribution_count: 2
+ describe '10 calendar activities' do
+ before do
+ 10.times { push_code_contribution }
+ end
+
+ it_behaves_like 'a day with activity', contribution_count: 10
+ end
+
+ describe 'calendar activity on two days' do
+ before do
+ push_code_contribution
+
+ travel_to(Date.yesterday) do
+ Issues::CreateService.new(
+ container: contributed_project,
+ current_user: user,
+ params: issue_params,
+ spam_params: nil
+ ).execute
+ end
+ end
+
+ include_context 'when user page is visited'
- describe 'issue title is shown on activity page' do
- include_context 'visit user page'
+ it 'displays calendar activity squares for both days', :sidekiq_inline do
+ expect(find('#js-overview')).to have_selector(get_cell_level_selector(1), count: 2)
+ end
- it 'displays calendar activity log', :sidekiq_inline do
- expect(all('#js-overview .overview-content-list .event-target-title').map(&:text)).to contain_exactly(
- match(/#{issue_title}/),
- match(/new task/)
- )
+ it 'displays calendar activity square for yesterday', :sidekiq_inline do
+ yesterday = Date.yesterday.strftime(date_format)
+ expect(find('#js-overview')).to have_selector(get_cell_date_selector(1, yesterday), count: 1)
+ end
+
+ it 'displays calendar activity square for today' do
+ today = Date.today.strftime(date_format)
+ expect(find('#js-overview')).to have_selector(get_cell_date_selector(1, today), count: 1)
end
end
end
- describe '1 comment calendar activity' do
- before do
- note_comment_contribution
+ describe 'on smaller screens' do
+ shared_examples 'hidden activity calendar' do
+ include_context 'when user page is visited'
+
+ it 'hides the activity calender' do
+ expect(find('#js-overview')).not_to have_css('.js-contrib-calendar')
+ end
end
- it_behaves_like 'a day with activity', contribution_count: 1
+ context 'when screen size is xs' do
+ before do
+ resize_screen_xs
+ end
+
+ it_behaves_like 'hidden activity calendar'
+ end
end
- describe '10 calendar activities' do
- before do
- 10.times { push_code_contribution }
+ describe 'first_day_of_week setting' do
+ context 'when first day of the week is set to Monday' do
+ before do
+ stub_application_setting(first_day_of_week: 1)
+ end
+
+ include_context 'when user page is visited'
+
+ it 'shows calendar with Monday as the first day of the week' do
+ expect(get_days_of_week).to eq(%w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday])
+ end
+ end
+
+ context 'when first day of the week is set to Sunday' do
+ before do
+ stub_application_setting(first_day_of_week: 0)
+ end
+
+ include_context 'when user page is visited'
+
+ it 'shows calendar with Sunday as the first day of the week' do
+ expect(get_days_of_week).to eq(%w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday])
+ end
end
+ end
+ end
+
+ context 'with `profile_tabs_vue` feature flag enabled' do
+ before do
+ sign_in user
+ end
- it_behaves_like 'a day with activity', contribution_count: 10
+ include_context 'when user page is visited'
+
+ it 'displays calendar' do
+ expect(page).to have_css('[data-testid="contrib-calendar"]')
end
- describe 'calendar activity on two days' do
- before do
- push_code_contribution
+ describe 'calendar daily activities' do
+ shared_examples 'a day with activity' do |contribution_count:|
+ include_context 'when user page is visited'
- travel_to(Date.yesterday) do
- Issues::CreateService.new(container: contributed_project, current_user: user, params: issue_params, spam_params: nil).execute
+ it 'displays calendar activity square for 1 contribution', :sidekiq_inline do
+ expect(page).to have_selector(get_cell_level_selector(contribution_count), count: 1)
+
+ today = Date.today.strftime(date_format)
+ expect(page).to have_selector(get_cell_date_selector(contribution_count, today), count: 1)
end
end
- include_context 'visit user page'
- it 'displays calendar activity squares for both days', :sidekiq_might_not_need_inline do
- expect(find('#js-overview')).to have_selector(get_cell_level_selector(1), count: 2)
+ describe '1 issue and 1 work item creation calendar activity' do
+ before do
+ Issues::CreateService.new(
+ container: contributed_project,
+ current_user: user,
+ params: issue_params,
+ spam_params: nil
+ ).execute
+ WorkItems::CreateService.new(
+ container: contributed_project,
+ current_user: user,
+ params: { title: 'new task' },
+ spam_params: nil
+ ).execute
+ end
+
+ it_behaves_like 'a day with activity', contribution_count: 2
end
- it 'displays calendar activity square for yesterday', :sidekiq_might_not_need_inline do
- yesterday = Date.yesterday.strftime(date_format)
- expect(find('#js-overview')).to have_selector(get_cell_date_selector(1, yesterday), count: 1)
+ describe '1 comment calendar activity' do
+ before do
+ note_comment_contribution
+ end
+
+ it_behaves_like 'a day with activity', contribution_count: 1
end
- it 'displays calendar activity square for today' do
- today = Date.today.strftime(date_format)
- expect(find('#js-overview')).to have_selector(get_cell_date_selector(1, today), count: 1)
+ describe '10 calendar activities' do
+ before do
+ 10.times { push_code_contribution }
+ end
+
+ it_behaves_like 'a day with activity', contribution_count: 10
+ end
+
+ describe 'calendar activity on two days' do
+ before do
+ push_code_contribution
+
+ travel_to(Date.yesterday) do
+ Issues::CreateService.new(
+ container: contributed_project,
+ current_user: user,
+ params: issue_params,
+ spam_params: nil
+ ).execute
+ end
+ end
+
+ include_context 'when user page is visited'
+
+ it 'displays calendar activity squares for both days', :sidekiq_inline do
+ expect(page).to have_selector(get_cell_level_selector(1), count: 2)
+ end
+
+ it 'displays calendar activity square for yesterday', :sidekiq_inline do
+ yesterday = Date.yesterday.strftime(date_format)
+ expect(page).to have_selector(get_cell_date_selector(1, yesterday), count: 1)
+ end
+
+ it 'displays calendar activity square for today' do
+ today = Date.today.strftime(date_format)
+ expect(page).to have_selector(get_cell_date_selector(1, today), count: 1)
+ end
end
end
- end
- describe 'on smaller screens' do
- shared_examples 'hidden activity calendar' do
- include_context 'visit user page'
+ describe 'on smaller screens' do
+ shared_examples 'hidden activity calendar' do
+ include_context 'when user page is visited'
- it 'hides the activity calender' do
- expect(find('#js-overview')).not_to have_css('.js-contrib-calendar')
+ it 'hides the activity calender' do
+ expect(page).not_to have_css('[data-testid="contrib-calendar"]')
+ end
+ end
+
+ context 'when screen size is xs' do
+ before do
+ resize_screen_xs
+ end
+
+ it_behaves_like 'hidden activity calendar'
end
end
- context 'size xs' do
- before do
- resize_screen_xs
+ describe 'first_day_of_week setting' do
+ context 'when first day of the week is set to Monday' do
+ before do
+ stub_application_setting(first_day_of_week: 1)
+ end
+
+ include_context 'when user page is visited'
+
+ it 'shows calendar with Monday as the first day of the week' do
+ expect(get_days_of_week).to eq(%w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday])
+ end
end
- it_behaves_like 'hidden activity calendar'
+ context 'when first day of the week is set to Sunday' do
+ before do
+ stub_application_setting(first_day_of_week: 0)
+ end
+
+ include_context 'when user page is visited'
+
+ it 'shows calendar with Sunday as the first day of the week' do
+ expect(get_days_of_week).to eq(%w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday])
+ end
+ end
end
end
end
diff --git a/spec/features/callouts/registration_enabled_spec.rb b/spec/features/callouts/registration_enabled_spec.rb
index 15c900592a1..3282a40854d 100644
--- a/spec/features/callouts/registration_enabled_spec.rb
+++ b/spec/features/callouts/registration_enabled_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Registration enabled callout', feature_category: :authentication_and_authorization do
+RSpec.describe 'Registration enabled callout', feature_category: :system_access do
let_it_be(:admin) { create(:admin) }
let_it_be(:non_admin) { create(:user) }
let_it_be(:project) { create(:project) }
diff --git a/spec/features/canonical_link_spec.rb b/spec/features/canonical_link_spec.rb
index d8f9a7584e7..0ed76c30ce4 100644
--- a/spec/features/canonical_link_spec.rb
+++ b/spec/features/canonical_link_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Canonical link', feature_category: :remote_development do
- include Spec::Support::Helpers::Features::CanonicalLinkHelpers
+ include Features::CanonicalLinkHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, namespace: user.namespace) }
diff --git a/spec/features/clusters/cluster_detail_page_spec.rb b/spec/features/clusters/cluster_detail_page_spec.rb
index e8fb5f4105d..31dec5e38da 100644
--- a/spec/features/clusters/cluster_detail_page_spec.rb
+++ b/spec/features/clusters/cluster_detail_page_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Clusterable > Show page', feature_category: :kubernetes_management do
+RSpec.describe 'Clusterable > Show page', feature_category: :deployment_management do
include KubernetesHelpers
let(:current_user) { create(:user) }
diff --git a/spec/features/clusters/cluster_health_dashboard_spec.rb b/spec/features/clusters/cluster_health_dashboard_spec.rb
index b557f803a99..e932f8c6b98 100644
--- a/spec/features/clusters/cluster_health_dashboard_spec.rb
+++ b/spec/features/clusters/cluster_health_dashboard_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Cluster Health board', :js, :kubeclient, :use_clean_rails_memory_store_caching, :sidekiq_inline,
-feature_category: :kubernetes_management do
+feature_category: :deployment_management do
include KubernetesHelpers
include PrometheusHelpers
@@ -13,6 +13,8 @@ feature_category: :kubernetes_management do
let_it_be(:cluster_path) { project_cluster_path(clusterable, cluster) }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
+
clusterable.add_maintainer(current_user)
sign_in(current_user)
@@ -28,6 +30,24 @@ feature_category: :kubernetes_management do
expect(page).to have_css('.cluster-health-graphs')
end
+ context 'feature remove_monitor_metrics enabled' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'does not show the cluster health tab' do
+ visit cluster_path
+
+ expect(page).not_to have_text('Health')
+ end
+
+ it 'does not show the cluster health section' do
+ visit project_cluster_path(clusterable, cluster, { tab: 'health' })
+
+ expect(page).not_to have_text('you must first enable Prometheus in the Integrations tab')
+ end
+ end
+
context 'no prometheus available' do
it 'shows enable Prometheus message' do
visit cluster_path
diff --git a/spec/features/clusters/create_agent_spec.rb b/spec/features/clusters/create_agent_spec.rb
index 01902c36e99..93a49151978 100644
--- a/spec/features/clusters/create_agent_spec.rb
+++ b/spec/features/clusters/create_agent_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Cluster agent registration', :js, feature_category: :kubernetes_management do
+RSpec.describe 'Cluster agent registration', :js, feature_category: :deployment_management do
let_it_be(:project) { create(:project, :custom_repo, files: { '.gitlab/agents/example-agent-1/config.yaml' => '' }) }
let_it_be(:current_user) { create(:user, maintainer_projects: [project]) }
let_it_be(:token) { Devise.friendly_token }
diff --git a/spec/features/commit_spec.rb b/spec/features/commit_spec.rb
index a9672569a4a..dd96b763e55 100644
--- a/spec/features/commit_spec.rb
+++ b/spec/features/commit_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe 'Commit', feature_category: :source_code_management do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
- describe "single commit view" do
+ shared_examples "single commit view" do
let(:commit) do
project.repository.commits(nil, limit: 100).find do |commit|
commit.diffs.size > 1
@@ -16,7 +16,6 @@ RSpec.describe 'Commit', feature_category: :source_code_management do
let(:files) { commit.diffs.diff_files.to_a }
before do
- stub_feature_flags(async_commit_diff_files: false)
project.add_maintainer(user)
sign_in(user)
end
@@ -28,15 +27,9 @@ RSpec.describe 'Commit', feature_category: :source_code_management do
visit project_commit_path(project, commit)
end
- it "shows the short commit message" do
+ it "shows the short commit message, number of total changes and stats", :js, :aggregate_failures do
expect(page).to have_content(commit.title)
- end
-
- it "reports the correct number of total changes" do
expect(page).to have_content("Changes #{commit.diffs.size}")
- end
-
- it 'renders diff stats', :js do
expect(page).to have_selector(".diff-stats")
end
@@ -50,23 +43,36 @@ RSpec.describe 'Commit', feature_category: :source_code_management do
visit project_commit_path(project, commit)
end
- it "shows an adjusted count for changed files on this page", :js do
- expect(page).to have_content("Showing 1 changed file")
+ def diff_files_on_page
+ page.all('.files .diff-file').pluck(:id)
end
- it "shows only the first diff on the first page" do
- expect(page).to have_selector(".files ##{files[0].file_hash}")
- expect(page).not_to have_selector(".files ##{files[1].file_hash}")
- end
+ it "shows paginated content and controls to navigate", :js, :aggregate_failures do
+ expect(page).to have_content("Showing 1 changed file")
+
+ wait_for_requests
+
+ expect(diff_files_on_page).to eq([files[0].file_hash])
- it "can navigate to the second page" do
within(".files .gl-pagination") do
click_on("2")
end
- expect(page).not_to have_selector(".files ##{files[0].file_hash}")
- expect(page).to have_selector(".files ##{files[1].file_hash}")
+ wait_for_requests
+
+ expect(diff_files_on_page).to eq([files[1].file_hash])
end
end
end
+
+ it_behaves_like "single commit view"
+
+ context "when super sidebar is enabled" do
+ before do
+ user.update!(use_new_navigation: true)
+ stub_feature_flags(super_sidebar_nav: true)
+ end
+
+ it_behaves_like "single commit view"
+ end
end
diff --git a/spec/features/commits/user_uses_quick_actions_spec.rb b/spec/features/commits/user_uses_quick_actions_spec.rb
index 6d043a0bb2f..c83a30c99c3 100644
--- a/spec/features/commits/user_uses_quick_actions_spec.rb
+++ b/spec/features/commits/user_uses_quick_actions_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Commit > User uses quick actions', :js, feature_category: :source_code_management do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
include RepoHelpers
let(:project) { create(:project, :public, :repository) }
diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb
index eafe74f4b0b..c38ae0c2b0d 100644
--- a/spec/features/commits_spec.rb
+++ b/spec/features/commits_spec.rb
@@ -107,7 +107,7 @@ RSpec.describe 'Commits', feature_category: :source_code_management do
describe 'Cancel all builds' do
it 'cancels commit', :js, :sidekiq_might_not_need_inline do
visit pipeline_path(pipeline)
- click_on 'Cancel running'
+ click_on 'Cancel pipeline'
expect(page).to have_content 'canceled'
end
end
@@ -133,7 +133,7 @@ RSpec.describe 'Commits', feature_category: :source_code_management do
expect(page).to have_content pipeline.sha[0..7]
expect(page).to have_content pipeline.git_commit_message.gsub!(/\s+/, ' ')
expect(page).to have_content pipeline.user.name
- expect(page).not_to have_link('Cancel running')
+ expect(page).not_to have_link('Cancel pipeline')
expect(page).not_to have_link('Retry')
end
@@ -156,7 +156,7 @@ RSpec.describe 'Commits', feature_category: :source_code_management do
expect(page).to have_content pipeline.git_commit_message.gsub!(/\s+/, ' ')
expect(page).to have_content pipeline.user.name
- expect(page).not_to have_link('Cancel running')
+ expect(page).not_to have_link('Cancel pipeline')
expect(page).not_to have_link('Retry')
end
end
@@ -165,10 +165,24 @@ RSpec.describe 'Commits', feature_category: :source_code_management do
context 'viewing commits for a branch' do
let(:branch_name) { 'master' }
+ let(:ref_selector) { '.ref-selector' }
+ let(:ref_with_hash) { 'ref-#-hash' }
+
+ def switch_ref_to(ref_name)
+ first(ref_selector).click
+ wait_for_requests
+
+ page.within ref_selector do
+ fill_in 'Search by Git revision', with: ref_name
+ wait_for_requests
+ find('li', text: ref_name, match: :prefer_exact).click
+ end
+ end
before do
project.add_maintainer(user)
sign_in(user)
+ project.repository.create_branch(ref_with_hash, branch_name)
visit project_commits_path(project, branch_name)
end
@@ -180,11 +194,17 @@ RSpec.describe 'Commits', feature_category: :source_code_management do
end
end
+ it 'switches ref to ref containing a hash', :js do
+ switch_ref_to(ref_with_hash)
+
+ expect(page).to have_selector ref_selector, text: ref_with_hash
+ end
+
it 'shows the ref switcher with the multi-file editor enabled', :js do
set_cookie('new_repo', 'true')
visit project_commits_path(project, branch_name)
- expect(find('.ref-selector')).to have_content branch_name
+ expect(find(ref_selector)).to have_content branch_name
end
end
diff --git a/spec/features/contextual_sidebar_spec.rb b/spec/features/contextual_sidebar_spec.rb
index 2b671d4b3f1..132c8eb7192 100644
--- a/spec/features/contextual_sidebar_spec.rb
+++ b/spec/features/contextual_sidebar_spec.rb
@@ -39,74 +39,5 @@ RSpec.describe 'Contextual sidebar', :js, feature_category: :remote_development
expect(page).to have_selector('.is-showing-fly-out')
end
end
-
- context 'with invite_members_in_side_nav experiment', :experiment do
- it 'allows opening of modal for the candidate experience' do
- stub_experiments(invite_members_in_side_nav: :candidate)
- expect(experiment(:invite_members_in_side_nav)).to track(:assignment)
- .with_context(group: project.group)
- .on_next_instance
-
- visit project_path(project)
-
- page.within '[data-test-id="side-nav-invite-members"' do
- find('[data-test-id="invite-members-button"').click
- end
-
- expect(page).to have_content("You're inviting members to the")
- end
-
- it 'does not have invite members link in side nav for the control experience' do
- stub_experiments(invite_members_in_side_nav: :control)
- expect(experiment(:invite_members_in_side_nav)).to track(:assignment)
- .with_context(group: project.group)
- .on_next_instance
-
- visit project_path(project)
-
- expect(page).not_to have_css('[data-test-id="side-nav-invite-members"')
- end
- end
- end
-
- context 'when context is a group' do
- let_it_be(:user) { create(:user) }
- let_it_be(:group) do
- create(:group).tap do |g|
- g.add_owner(user)
- end
- end
-
- before do
- sign_in(user)
- end
-
- context 'with invite_members_in_side_nav experiment', :experiment do
- it 'allows opening of modal for the candidate experience' do
- stub_experiments(invite_members_in_side_nav: :candidate)
- expect(experiment(:invite_members_in_side_nav)).to track(:assignment)
- .with_context(group: group)
- .on_next_instance
-
- visit group_path(group)
-
- page.within '[data-test-id="side-nav-invite-members"' do
- find('[data-test-id="invite-members-button"').click
- end
-
- expect(page).to have_content("You're inviting members to the")
- end
-
- it 'does not have invite members link in side nav for the control experience' do
- stub_experiments(invite_members_in_side_nav: :control)
- expect(experiment(:invite_members_in_side_nav)).to track(:assignment)
- .with_context(group: group)
- .on_next_instance
-
- visit group_path(group)
-
- expect(page).not_to have_css('[data-test-id="side-nav-invite-members"')
- end
- end
end
end
diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb
index 55bf77d00b1..56272f58e0d 100644
--- a/spec/features/cycle_analytics_spec.rb
+++ b/spec/features/cycle_analytics_spec.rb
@@ -47,12 +47,12 @@ RSpec.describe 'Value Stream Analytics', :js, feature_category: :value_stream_ma
end
it 'displays metrics with relevant values' do
- expect(metrics_values).to eq(['-'] * 4)
+ expect(metrics_values).to eq(['-'] * 3)
end
it 'shows active stage with empty message' do
expect(page).to have_selector('.gl-path-active-item-indigo', text: 'Issue')
- expect(page).to have_content("We don't have enough data to show this stage.")
+ expect(page).to have_content("There are 0 items to show in this stage, for these filters, within this time range.")
end
end
@@ -98,7 +98,6 @@ RSpec.describe 'Value Stream Analytics', :js, feature_category: :value_stream_ma
aggregate_failures 'with relevant values' do
expect(metrics_tiles).to have_content('Commit')
expect(metrics_tiles).to have_content('Deploy')
- expect(metrics_tiles).to have_content('Deployment Frequency')
expect(metrics_tiles).to have_content('New Issue')
end
end
@@ -132,11 +131,11 @@ RSpec.describe 'Value Stream Analytics', :js, feature_category: :value_stream_ma
end
it 'can filter the metrics by date' do
- expect(metrics_values).to match_array(%w[21 2 1 0])
+ expect(metrics_values).to match_array(%w[21 2 1])
set_daterange(from, to)
- expect(metrics_values).to eq(['-'] * 4)
+ expect(metrics_values).to eq(['-'] * 3)
end
it 'can sort records', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338332' do
diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb
index 2f9b7bb7e0f..2345e4be722 100644
--- a/spec/features/dashboard/activity_spec.rb
+++ b/spec/features/dashboard/activity_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe 'Dashboard > Activity', feature_category: :user_profile do
sign_in(user)
end
- it_behaves_like 'a dashboard page with sidebar', :activity_dashboard_path, :activity
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :activity_dashboard_path, :activity
context 'tabs' do
it 'shows Your Activity' do
diff --git a/spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb b/spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb
index f5b02a87758..3040c97a16f 100644
--- a/spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb
+++ b/spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'The group dashboard', :js, feature_category: :subgroups do
include ExternalAuthorizationServiceHelpers
- include Spec::Support::Helpers::Features::TopNavSpecHelpers
+ include Features::TopNavSpecHelpers
let(:user) { create(:user) }
@@ -21,9 +21,8 @@ RSpec.describe 'The group dashboard', :js, feature_category: :subgroups do
within_top_nav do
expect(page).to have_button('Projects')
expect(page).to have_button('Groups')
- expect(page).to have_link('Activity')
- expect(page).to have_link('Milestones')
- expect(page).to have_link('Snippets')
+ expect(page).to have_link('Your work')
+ expect(page).to have_link('Explore')
end
end
@@ -36,9 +35,8 @@ RSpec.describe 'The group dashboard', :js, feature_category: :subgroups do
within_top_nav do
expect(page).to have_button('Projects')
expect(page).to have_button('Groups')
- expect(page).not_to have_link('Activity')
- expect(page).not_to have_link('Milestones')
- expect(page).to have_link('Snippets')
+ expect(page).to have_link('Your work')
+ expect(page).to have_link('Explore')
end
end
end
diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb
index a45e0a58ed6..7112b30957a 100644
--- a/spec/features/dashboard/groups_list_spec.rb
+++ b/spec/features/dashboard/groups_list_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe 'Dashboard Groups page', :js, feature_category: :subgroups do
page.find("[data-testid='group-#{group.id}-dropdown-button'").click
end
- it_behaves_like 'a dashboard page with sidebar', :dashboard_groups_path, :groups
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_groups_path, :groups
it 'shows groups user is member of' do
group.add_owner(user)
@@ -230,4 +230,11 @@ RSpec.describe 'Dashboard Groups page', :js, feature_category: :subgroups do
expect(page).not_to have_content(another_group.name)
end
end
+
+ it 'links to the "Explore groups" page' do
+ sign_in(user)
+ visit dashboard_groups_path
+
+ expect(page).to have_link("Explore groups", href: explore_groups_path)
+ end
end
diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb
index 5dc59cfa841..5e6ec007569 100644
--- a/spec/features/dashboard/issuables_counter_spec.rb
+++ b/spec/features/dashboard/issuables_counter_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe 'Navigation bar counter', :use_clean_rails_memory_store_caching,
sign_in(user)
end
- it 'reflects dashboard issues count' do
+ it 'reflects dashboard issues count', :js do
visit issues_path
expect_counters('issues', '1', n_("%d assigned issue", "%d assigned issues", 1) % 1)
diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb
index a7734ed50c2..964ac2f714d 100644
--- a/spec/features/dashboard/issues_filter_spec.rb
+++ b/spec/features/dashboard/issues_filter_spec.rb
@@ -3,48 +3,60 @@
require 'spec_helper'
RSpec.describe 'Dashboard Issues filtering', :js, feature_category: :team_planning do
- include Spec::Support::Helpers::Features::SortingHelpers
+ include Features::SortingHelpers
include FilteredSearchHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project) }
- let(:milestone) { create(:milestone, project: project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:milestone) { create(:milestone, project: project) }
- let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
- let!(:issue2) { create(:issue, project: project, author: user, assignees: [user], milestone: milestone) }
+ let_it_be(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
+ let_it_be(:issue2) { create(:issue, project: project, author: user, assignees: [user], milestone: milestone) }
+ let_it_be(:label) { create(:label, project: project, title: 'bug') }
+ let_it_be(:label_link) { create(:label_link, label: label, target: issue) }
+
+ let_it_be(:project2) { create(:project, namespace: user.namespace) }
+ let_it_be(:label2) { create(:label, title: 'bug') }
before do
+ project.labels << label
+ project2.labels << label2
project.add_maintainer(user)
sign_in(user)
-
- visit_issues
end
context 'without any filter' do
it 'shows error message' do
+ visit issues_dashboard_path
+
expect(page).to have_content 'Please select at least one filter to see results'
end
end
context 'filtering by milestone' do
it 'shows all issues with no milestone' do
- input_filtered_search("milestone:=none")
+ visit issues_dashboard_path
+
+ select_tokens 'Milestone', '=', 'None', submit: true
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_selector('.issue', count: 1)
end
it 'shows all issues with the selected milestone' do
- input_filtered_search("milestone:=%\"#{milestone.title}\"")
+ visit issues_dashboard_path
+
+ select_tokens 'Milestone', '=', milestone.title, submit: true
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_selector('.issue', count: 1)
end
it 'updates atom feed link' do
- visit_issues(milestone_title: '', assignee_username: user.username)
+ visit issues_dashboard_path(milestone_title: '', assignee_username: user.username)
+ click_button 'Actions'
- link = find('[data-testid="rss-feed-link"]')
+ 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)
@@ -59,40 +71,47 @@ RSpec.describe 'Dashboard Issues filtering', :js, feature_category: :team_planni
end
context 'filtering by label' do
- let(:label) { create(:label, project: project) }
- let!(:label_link) { create(:label_link, label: label, target: issue) }
+ before do
+ visit issues_dashboard_path
+ end
it 'shows all issues with the selected label' do
- input_filtered_search("label:=~#{label.title}")
+ select_tokens 'Label', '=', label.title, submit: true
- page.within 'ul.content-list' do
- expect(page).to have_content issue.title
- expect(page).not_to have_content issue2.title
- end
+ expect(page).to have_content issue.title
+ expect(page).not_to have_content issue2.title
+ end
+
+ it 'removes duplicate labels' do
+ select_tokens 'Label', '='
+ send_keys 'bu'
+
+ expect_suggestion('bug')
+ expect_suggestion_count(3) # Expect None, Any, and bug
end
end
context 'sorting' do
before do
- visit_issues(assignee_username: user.username)
+ visit issues_dashboard_path(assignee_username: user.username)
end
- it 'remembers last sorting value' do
- pajamas_sort_by(s_('SortOptions|Created date'))
- visit_issues(assignee_username: user.username)
+ it 'remembers last sorting value', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408749' do
+ click_button 'Created date'
+ click_button 'Updated date'
+
+ visit issues_dashboard_path(assignee_username: user.username)
- expect(page).to have_button('Created date')
+ expect(page).to have_button('Updated date')
end
it 'keeps sorting issues after visiting Projects Issues page' do
- pajamas_sort_by(s_('SortOptions|Created date'))
+ click_button 'Created date'
+ click_button 'Due date'
+
visit project_issues_path(project)
- expect(page).to have_button('Created date')
+ expect(page).to have_button('Due date')
end
end
-
- def visit_issues(...)
- visit issues_dashboard_path(...)
- end
end
diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb
index 654cc9978a7..70d9f7e5137 100644
--- a/spec/features/dashboard/issues_spec.rb
+++ b/spec/features/dashboard/issues_spec.rb
@@ -5,15 +5,15 @@ require 'spec_helper'
RSpec.describe 'Dashboard Issues', feature_category: :team_planning do
include FilteredSearchHelpers
- let(:current_user) { create :user }
- let(:user) { current_user } # Shared examples depend on this being available
- let!(:public_project) { create(:project, :public) }
- let(:project) { create(:project) }
- let(:project_with_issues_disabled) { create(:project, :issues_disabled) }
- let!(:authored_issue) { create :issue, author: current_user, project: project }
- let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project }
- let!(:assigned_issue) { create :issue, assignees: [current_user], project: project }
- let!(:other_issue) { create :issue, project: project }
+ let_it_be(:current_user) { create :user }
+ let_it_be(:user) { current_user } # Shared examples depend on this being available
+ let_it_be(:public_project) { create(:project, :public) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:project_with_issues_disabled) { create(:project, :issues_disabled) }
+ let_it_be(:authored_issue) { create :issue, author: current_user, project: project }
+ let_it_be(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project }
+ let_it_be(:assigned_issue) { create :issue, assignees: [current_user], project: project }
+ let_it_be(:other_issue) { create :issue, project: project }
before do
[project, project_with_issues_disabled].each { |project| project.add_maintainer(current_user) }
@@ -21,18 +21,18 @@ RSpec.describe 'Dashboard Issues', feature_category: :team_planning do
visit issues_dashboard_path(assignee_username: current_user.username)
end
- it_behaves_like 'a dashboard page with sidebar', :issues_dashboard_path, :issues
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :issues_dashboard_path, :issues
- describe 'issues' do
+ describe 'issues', :js do
it 'shows issues assigned to current user' do
expect(page).to have_content(assigned_issue.title)
expect(page).not_to have_content(authored_issue.title)
expect(page).not_to have_content(other_issue.title)
end
- it 'shows issues when current user is author', :js do
- reset_filters
- input_filtered_search("author:=#{current_user.to_reference}")
+ it 'shows issues when current user is author' do
+ click_button 'Clear'
+ select_tokens 'Author', '=', current_user.to_reference, submit: true
expect(page).to have_content(authored_issue.title)
expect(page).to have_content(authored_issue_on_public_project.title)
@@ -41,12 +41,21 @@ RSpec.describe 'Dashboard Issues', feature_category: :team_planning do
end
it 'state filter tabs work' do
- find('#state-closed').click
- expect(page).to have_current_path(issues_dashboard_url(assignee_username: current_user.username, state: 'closed'), url: true)
+ click_link 'Closed'
+
+ expect(page).not_to have_content(assigned_issue.title)
+ expect(page).not_to have_content(authored_issue.title)
+ expect(page).not_to have_content(other_issue.title)
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"
+ describe 'RSS link' do
+ before do
+ click_button 'Actions'
+ end
+
+ it_behaves_like "it has an RSS link with current_user's feed token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
+ end
end
describe 'new issue dropdown' do
diff --git a/spec/features/dashboard/label_filter_spec.rb b/spec/features/dashboard/label_filter_spec.rb
deleted file mode 100644
index f116c84ff40..00000000000
--- a/spec/features/dashboard/label_filter_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Dashboard > label filter', :js, feature_category: :team_planning do
- include FilteredSearchHelpers
-
- let(:filtered_search) { find('.filtered-search') }
- let(:filter_dropdown) { find("#js-dropdown-label .filter-dropdown") }
-
- let(:user) { create(:user) }
- let(:project) { create(:project, name: 'test', namespace: user.namespace) }
- let(:project2) { create(:project, name: 'test2', path: 'test2', namespace: user.namespace) }
- let(:label) { create(:label, title: 'bug', color: '#ff0000') }
- let(:label2) { create(:label, title: 'bug') }
-
- before do
- project.labels << label
- project2.labels << label2
-
- sign_in(user)
- visit issues_dashboard_path
-
- init_label_search
- end
-
- context 'duplicate labels' do
- it 'removes duplicate labels' do
- filtered_search.send_keys('bu')
-
- expect(filter_dropdown).to have_selector('.filter-dropdown-item', text: 'bug', count: 1)
- end
- end
-end
diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb
index 34bab9dffd0..d53f5affe64 100644
--- a/spec/features/dashboard/merge_requests_spec.rb
+++ b/spec/features/dashboard/merge_requests_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Dashboard Merge Requests', feature_category: :code_review_workflow do
- include Spec::Support::Helpers::Features::SortingHelpers
+ include Features::SortingHelpers
include FilteredSearchHelpers
include ProjectForksHelper
@@ -19,7 +19,7 @@ RSpec.describe 'Dashboard Merge Requests', feature_category: :code_review_workfl
sign_in(current_user)
end
- it_behaves_like 'a dashboard page with sidebar', :merge_requests_dashboard_path, :merge_requests
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :merge_requests_dashboard_path, :merge_requests
it 'disables target branch filter' do
visit merge_requests_dashboard_path
diff --git a/spec/features/dashboard/milestones_spec.rb b/spec/features/dashboard/milestones_spec.rb
index 3b197bbf009..0dd25ffaa94 100644
--- a/spec/features/dashboard/milestones_spec.rb
+++ b/spec/features/dashboard/milestones_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'Dashboard > Milestones', feature_category: :team_planning do
visit dashboard_milestones_path
end
- it_behaves_like 'a dashboard page with sidebar', :dashboard_milestones_path, :milestones
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_milestones_path, :milestones
it 'sees milestones' do
expect(page).to have_current_path dashboard_milestones_path, ignore_query: true
diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb
index 779fbb48ddb..32bce32ec6c 100644
--- a/spec/features/dashboard/projects_spec.rb
+++ b/spec/features/dashboard/projects_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'Dashboard Projects', feature_category: :projects do
let_it_be(:user) { create(:user) }
- let_it_be(:project, reload: true) { create(:project, :repository) }
+ let_it_be(:project, reload: true) { create(:project, :repository, creator: build(:user)) } # ensure creator != owner to avoid N+1 false-positive
let_it_be(:project2) { create(:project, :public) }
before do
@@ -18,7 +18,13 @@ RSpec.describe 'Dashboard Projects', feature_category: :projects do
end
end
- it_behaves_like "a dashboard page with sidebar", :dashboard_projects_path, :projects
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_projects_path, :projects
+
+ it 'links to the "Explore projects" page' do
+ visit dashboard_projects_path
+
+ expect(page).to have_link("Explore projects", href: explore_projects_path)
+ end
context 'when user has access to the project' do
it 'shows role badge' do
@@ -106,6 +112,8 @@ RSpec.describe 'Dashboard Projects', feature_category: :projects do
end
context 'when on Starred projects tab', :js do
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :starred_dashboard_projects_path, :projects
+
it 'shows the empty state when there are no starred projects' do
visit(starred_dashboard_projects_path)
@@ -239,7 +247,7 @@ RSpec.describe 'Dashboard Projects', feature_category: :projects do
create(:ci_pipeline, :with_job, status: :success, project: project, ref: project.default_branch, sha: project.commit.sha)
visit dashboard_projects_path
- control_count = ActiveRecord::QueryRecorder.new { visit dashboard_projects_path }.count
+ control = ActiveRecord::QueryRecorder.new { visit dashboard_projects_path }
new_project = create(:project, :repository, name: 'new project')
create(:ci_pipeline, :with_job, status: :success, project: new_project, ref: new_project.commit.sha)
@@ -247,15 +255,11 @@ RSpec.describe 'Dashboard Projects', feature_category: :projects do
ActiveRecord::QueryRecorder.new { visit dashboard_projects_path }.count
- # There are seven known N+1 queries: https://gitlab.com/gitlab-org/gitlab/-/issues/214037
- # 1. Project#open_issues_count
- # 2. Project#open_merge_requests_count
- # 3. Project#forks_count
- # 4. ProjectsHelper#load_pipeline_status
- # 5. RendersMemberAccess#preload_max_member_access_for_collection
- # 6. User#max_member_access_for_project_ids
- # 7. Ci::CommitWithPipeline#last_pipeline
+ # There are a few known N+1 queries: https://gitlab.com/gitlab-org/gitlab/-/issues/214037
+ # - User#max_member_access_for_project_ids
+ # - ProjectsHelper#load_pipeline_status / Ci::CommitWithPipeline#last_pipeline
+ # - Ci::Pipeline#detailed_status
- expect { visit dashboard_projects_path }.not_to exceed_query_limit(control_count + 7)
+ expect { visit dashboard_projects_path }.not_to exceed_query_limit(control).with_threshold(4)
end
end
diff --git a/spec/features/dashboard/root_explore_spec.rb b/spec/features/dashboard/root_explore_spec.rb
index a232ebec68e..9e844f81a29 100644
--- a/spec/features/dashboard/root_explore_spec.rb
+++ b/spec/features/dashboard/root_explore_spec.rb
@@ -2,16 +2,12 @@
require 'spec_helper'
-RSpec.describe 'Root explore', feature_category: :not_owned do
+RSpec.describe 'Root explore', :saas, feature_category: :shared do
let_it_be(:public_project) { create(:project, :public) }
let_it_be(:archived_project) { create(:project, :archived) }
let_it_be(:internal_project) { create(:project, :internal) }
let_it_be(:private_project) { create(:project, :private) }
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
context 'when logged in' do
let_it_be(:user) { create(:user) }
diff --git a/spec/features/dashboard/shortcuts_spec.rb b/spec/features/dashboard/shortcuts_spec.rb
index 30587756505..155f7e93961 100644
--- a/spec/features/dashboard/shortcuts_spec.rb
+++ b/spec/features/dashboard/shortcuts_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Dashboard shortcuts', :js, feature_category: :not_owned do
+RSpec.describe 'Dashboard shortcuts', :js, feature_category: :shared do
context 'logged in' do
let(:user) { create(:user) }
let(:project) { create(:project) }
diff --git a/spec/features/dashboard/snippets_spec.rb b/spec/features/dashboard/snippets_spec.rb
index ba40290d866..da985c6dc07 100644
--- a/spec/features/dashboard/snippets_spec.rb
+++ b/spec/features/dashboard/snippets_spec.rb
@@ -5,7 +5,14 @@ require 'spec_helper'
RSpec.describe 'Dashboard snippets', feature_category: :source_code_management do
let_it_be(:user) { create(:user) }
- it_behaves_like 'a dashboard page with sidebar', :dashboard_snippets_path, :snippets
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_snippets_path, :snippets
+
+ it 'links to the "Explore snippets" page' do
+ sign_in(user)
+ visit dashboard_snippets_path
+
+ expect(page).to have_link("Explore snippets", href: explore_snippets_path)
+ end
context 'when the project has snippets' do
let(:project) { create(:project, :public, creator: user) }
@@ -37,7 +44,8 @@ RSpec.describe 'Dashboard snippets', feature_category: :source_code_management d
element = page.find('.row.empty-state')
expect(element).to have_content("Code snippets")
- expect(element.find('.svg-content img.js-lazy-loaded')['src']).to have_content('illustrations/snippets_empty')
+ expect(element.find('.svg-content img.js-lazy-loaded')['src'])
+ .to have_content('illustrations/empty-state/empty-snippets-md')
end
it 'shows new snippet button in main content area' do
diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb
index 59bb1a452c9..d0003b69415 100644
--- a/spec/features/dashboard/todos/todos_spec.rb
+++ b/spec/features/dashboard/todos/todos_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do
project.add_developer(user)
end
- it_behaves_like 'a dashboard page with sidebar', :dashboard_todos_path, :todos
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_todos_path, :todos
context 'User does not have todos' do
before do
diff --git a/spec/features/display_system_header_and_footer_bar_spec.rb b/spec/features/display_system_header_and_footer_bar_spec.rb
index 22fd0987418..9b2bf0ef1fa 100644
--- a/spec/features/display_system_header_and_footer_bar_spec.rb
+++ b/spec/features/display_system_header_and_footer_bar_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Display system header and footer bar', feature_category: :not_owned do
+RSpec.describe 'Display system header and footer bar', feature_category: :shared do
let(:header_message) { "Foo" }
let(:footer_message) { "Bar" }
diff --git a/spec/features/emails/issues_spec.rb b/spec/features/emails/issues_spec.rb
new file mode 100644
index 00000000000..c425dad88aa
--- /dev/null
+++ b/spec/features/emails/issues_spec.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "E-Mails > Issues", :js, feature_category: :team_planning do
+ let_it_be(:project) { create(:project_empty_repo, :public, name: 'Long Earth') }
+ let_it_be(:author) { create(:user, username: 'author', name: 'Sally Linsay') }
+ let_it_be(:current_user) { create(:user, username: 'current_user', name: 'Shi-mi') }
+
+ before do
+ project.add_developer(current_user)
+ sign_in(current_user)
+ end
+
+ describe 'assignees' do
+ let_it_be(:assignee) { create(:user, username: 'assignee', name: 'Joshua Valienté') }
+ let_it_be(:issue_without_assignee) { create(:issue, project: project, author: author, title: 'No milk today!') }
+
+ let_it_be(:issue_with_assignee) do
+ create(
+ :issue, project: project, author: author, assignees: [assignee],
+ title: 'All your base are belong to us')
+ end
+
+ it 'sends confirmation e-mail for assigning' do
+ synchronous_notifications
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(author.id, issue_without_assignee.id, [], current_user.id, nil)
+ .once
+ .and_call_original
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(assignee.id, issue_without_assignee.id, [], current_user.id, NotificationReason::ASSIGNED)
+ .once
+ .and_call_original
+
+ visit issue_path(issue_without_assignee)
+ assign_to(assignee)
+
+ expect(find('#notes-list')).to have_text("Shi-mi assigned to @assignee just now")
+ end
+
+ it 'sends confirmation e-mail for reassigning' do
+ synchronous_notifications
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(author.id, issue_with_assignee.id, [assignee.id], current_user.id, NotificationReason::ASSIGNED)
+ .once
+ .and_call_original
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(assignee.id, issue_with_assignee.id, [assignee.id], current_user.id, nil)
+ .once
+ .and_call_original
+
+ visit issue_path(issue_with_assignee)
+ assign_to(author)
+
+ expect(find('#notes-list')).to have_text("Shi-mi assigned to @author and unassigned @assignee just now")
+ end
+
+ it 'sends confirmation e-mail for unassigning' do
+ synchronous_notifications
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(author.id, issue_with_assignee.id, [assignee.id], current_user.id, nil)
+ .once
+ .and_call_original
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(assignee.id, issue_with_assignee.id, [assignee.id], current_user.id, nil)
+ .once
+ .and_call_original
+
+ visit issue_path(issue_with_assignee)
+ quick_action('/unassign')
+
+ expect(find('#notes-list')).to have_text("Shi-mi unassigned @assignee just now")
+ end
+ end
+
+ describe 'closing' do
+ let_it_be(:issue) { create(:issue, project: project, author: author, title: 'Public Holiday') }
+
+ it 'sends confirmation e-mail for closing' do
+ synchronous_notifications
+ expect(Notify).to receive(:closed_issue_email)
+ .with(author.id, issue.id, current_user.id, { closed_via: nil, reason: nil })
+ .once
+ .and_call_original
+
+ visit issue_path(issue)
+ quick_action("/close")
+
+ expect(find('#notes-list')).to have_text("Shi-mi closed just now")
+ end
+ end
+
+ private
+
+ def assign_to(user)
+ quick_action("/assign @#{user.username}")
+ end
+
+ def quick_action(command)
+ fill_in 'note[note]', with: command
+ click_button 'Comment'
+ end
+
+ def synchronous_notifications
+ expect_next_instance_of(NotificationService) do |service|
+ expect(service).to receive(:async).and_return(service)
+ end
+ end
+end
diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb
index 1f09b01ddec..43dd80187ce 100644
--- a/spec/features/expand_collapse_diffs_spec.rb
+++ b/spec/features/expand_collapse_diffs_spec.rb
@@ -19,6 +19,8 @@ RSpec.describe 'Expand and collapse diffs', :js, feature_category: :source_code_
# Ensure that undiffable.md is in .gitattributes
project.repository.copy_gitattributes(branch)
visit project_commit_path(project, project.commit(branch))
+
+ wait_for_requests
end
def file_container(filename)
@@ -222,10 +224,16 @@ RSpec.describe 'Expand and collapse diffs', :js, feature_category: :source_code_
let(:branch) { 'expand-collapse-files' }
# safe-files -> 100 | safe-lines -> 5000 | commit-files -> 105
- it 'does collapsing from the safe number of files to the end on small files' do
- expect(page).to have_link('Expand all')
+ it 'does collapsing from the safe number of files to the end on small files', :aggregate_failures do
+ expect(page).not_to have_link('Expand all')
+ expect(page).to have_selector('.diff-content', count: 20)
+ expect(page).to have_selector('.diff-collapsed', count: 0)
- expect(page).to have_selector('.diff-content', count: 105)
+ visit project_commit_path(project, project.commit(branch), page: 6)
+ wait_for_requests
+
+ expect(page).to have_link('Expand all')
+ expect(page).to have_selector('.diff-content', count: 5)
expect(page).to have_selector('.diff-collapsed', count: 5)
%w(file-95.txt file-96.txt file-97.txt file-98.txt file-99.txt).each do |filename|
diff --git a/spec/features/explore/groups_spec.rb b/spec/features/explore/groups_spec.rb
index 458f83dffb4..57a7e8ea523 100644
--- a/spec/features/explore/groups_spec.rb
+++ b/spec/features/explore/groups_spec.rb
@@ -29,8 +29,8 @@ RSpec.describe 'Explore Groups', :js, feature_category: :subgroups do
shared_examples 'renders public and internal projects' do
it do
visit_page
- expect(page).to have_content(public_project.name)
- expect(page).to have_content(internal_project.name)
+ expect(page).to have_content(public_project.name).or(have_content(public_project.path))
+ expect(page).to have_content(internal_project.name).or(have_content(internal_project.path))
expect(page).not_to have_content(private_project.name)
end
end
@@ -38,7 +38,7 @@ RSpec.describe 'Explore Groups', :js, feature_category: :subgroups do
shared_examples 'renders only public project' do
it do
visit_page
- expect(page).to have_content(public_project.name)
+ expect(page).to have_content(public_project.name).or(have_content(public_project.path))
expect(page).not_to have_content(internal_project.name)
expect(page).not_to have_content(private_project.name)
end
@@ -47,7 +47,7 @@ RSpec.describe 'Explore Groups', :js, feature_category: :subgroups do
shared_examples 'renders group in public groups area' do
it do
visit explore_groups_path
- expect(page).to have_content(group.name)
+ expect(page).to have_content(group.path)
end
end
@@ -56,32 +56,44 @@ RSpec.describe 'Explore Groups', :js, feature_category: :subgroups do
sign_in(user)
end
- it_behaves_like 'renders public and internal projects' do
- subject(:visit_page) { visit group_path(group) }
+ context 'for group_path' do
+ it_behaves_like 'renders public and internal projects' do
+ subject(:visit_page) { visit group_path(group) }
+ end
end
- it_behaves_like 'renders public and internal projects' do
- subject(:visit_page) { visit issues_group_path(group) }
+ context 'for issues_group_path' do
+ it_behaves_like 'renders public and internal projects' do
+ subject(:visit_page) { visit issues_group_path(group) }
+ end
end
- it_behaves_like 'renders public and internal projects' do
- subject(:visit_page) { visit merge_requests_group_path(group) }
+ context 'for merge_requests_group_path' do
+ it_behaves_like 'renders public and internal projects' do
+ subject(:visit_page) { visit merge_requests_group_path(group) }
+ end
end
it_behaves_like 'renders group in public groups area'
end
context 'when signed out' do
- it_behaves_like 'renders only public project' do
- subject(:visit_page) { visit group_path(group) }
+ context 'for group_path' do
+ it_behaves_like 'renders only public project' do
+ subject(:visit_page) { visit group_path(group) }
+ end
end
- it_behaves_like 'renders only public project' do
- subject(:visit_page) { visit issues_group_path(group) }
+ context 'for issues_group_path' do
+ it_behaves_like 'renders only public project' do
+ subject(:visit_page) { visit issues_group_path(group) }
+ end
end
- it_behaves_like 'renders only public project' do
- subject(:visit_page) { visit merge_requests_group_path(group) }
+ context 'for merge_requests_group_path' do
+ it_behaves_like 'renders only public project' do
+ subject(:visit_page) { visit merge_requests_group_path(group) }
+ end
end
it_behaves_like 'renders group in public groups area'
diff --git a/spec/features/explore/navbar_spec.rb b/spec/features/explore/navbar_spec.rb
new file mode 100644
index 00000000000..8f281abe6a7
--- /dev/null
+++ b/spec/features/explore/navbar_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe '"Explore" navbar', feature_category: :navigation do
+ include_context '"Explore" navbar structure'
+
+ it_behaves_like 'verified navigation bar' do
+ before do
+ visit explore_projects_path
+ end
+ end
+end
diff --git a/spec/features/explore/user_explores_projects_spec.rb b/spec/features/explore/user_explores_projects_spec.rb
index 14fddf5d84c..f259ba6a167 100644
--- a/spec/features/explore/user_explores_projects_spec.rb
+++ b/spec/features/explore/user_explores_projects_spec.rb
@@ -3,6 +3,18 @@
require 'spec_helper'
RSpec.describe 'User explores projects', feature_category: :user_profile do
+ describe '"All" tab' do
+ it_behaves_like 'an "Explore" page with sidebar and breadcrumbs', :explore_projects_path, :projects
+ end
+
+ describe '"Most starred" tab' do
+ it_behaves_like 'an "Explore" page with sidebar and breadcrumbs', :starred_explore_projects_path, :projects
+ end
+
+ describe '"Trending" tab' do
+ it_behaves_like 'an "Explore" page with sidebar and breadcrumbs', :trending_explore_projects_path, :projects
+ end
+
context 'when some projects exist' do
let_it_be(:archived_project) { create(:project, :archived) }
let_it_be(:internal_project) { create(:project, :internal) }
diff --git a/spec/features/file_uploads/project_import_spec.rb b/spec/features/file_uploads/project_import_spec.rb
index c261834206d..3934e0319ad 100644
--- a/spec/features/file_uploads/project_import_spec.rb
+++ b/spec/features/file_uploads/project_import_spec.rb
@@ -22,6 +22,10 @@ RSpec.describe 'Upload a project export archive', :api, :js, feature_category: :
)
end
+ before do
+ stub_application_setting(import_sources: ['gitlab_project'])
+ end
+
RSpec.shared_examples 'for a project export archive' do
it { expect { subject }.to change { Project.count }.by(1) }
diff --git a/spec/features/frequently_visited_projects_and_groups_spec.rb b/spec/features/frequently_visited_projects_and_groups_spec.rb
index 50e20910e16..514b642a2d4 100644
--- a/spec/features/frequently_visited_projects_and_groups_spec.rb
+++ b/spec/features/frequently_visited_projects_and_groups_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe 'Frequently visited items', :js, feature_category: :not_owned do
- include Spec::Support::Helpers::Features::TopNavSpecHelpers
+RSpec.describe 'Frequently visited items', :js, feature_category: :shared do
+ include Features::TopNavSpecHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb
index 15393ec4cd6..f94f0288f99 100644
--- a/spec/features/global_search_spec.rb
+++ b/spec/features/global_search_spec.rb
@@ -13,64 +13,8 @@ RSpec.describe 'Global search', :js, feature_category: :global_search do
sign_in(user)
end
- describe 'when new_header_search feature is disabled' do
+ describe 'when header search' do
before do
- # TODO: Remove this along with feature flag #339348
- stub_feature_flags(new_header_search: false)
- 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)
-
- submit_search('foobar')
- end
-
- 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')
- end
- end
-
- it 'closes the dropdown on blur' do
- find('#search').click
- fill_in 'search', with: "a"
-
- expect(page).to have_selector("div[data-testid='dashboard-search-options'].show")
-
- 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
-
- it 'focuses search input when shortcut "s" is pressed' do
- expect(page).not_to have_selector('#search:focus')
-
- find('body').native.send_key('s')
-
- expect(page).to have_selector('#search:focus')
- end
- end
-
- 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
diff --git a/spec/features/group_variables_spec.rb b/spec/features/group_variables_spec.rb
index 117f50aefc6..3e87c90e7dc 100644
--- a/spec/features/group_variables_spec.rb
+++ b/spec/features/group_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Group variables', :js, feature_category: :pipeline_authoring do
+RSpec.describe 'Group variables', :js, feature_category: :secrets_management do
let(:user) { create(:user) }
let(:group) { create(:group) }
let!(:variable) { create(:ci_group_variable, key: 'test_key', value: 'test_value', masked: true, group: group) }
@@ -15,5 +15,16 @@ RSpec.describe 'Group variables', :js, feature_category: :pipeline_authoring do
wait_for_requests
end
- it_behaves_like 'variable list'
+ context 'when ci_variables_pages FF is enabled' do
+ it_behaves_like 'variable list'
+ it_behaves_like 'variable list pagination', :ci_group_variable
+ end
+
+ context 'when ci_variables_pages FF is disabled' do
+ before do
+ stub_feature_flags(ci_variables_pages: false)
+ end
+
+ it_behaves_like 'variable list'
+ end
end
diff --git a/spec/features/groups/board_spec.rb b/spec/features/groups/board_spec.rb
index c451a97bed5..8acf3ffe441 100644
--- a/spec/features/groups/board_spec.rb
+++ b/spec/features/groups/board_spec.rb
@@ -25,8 +25,10 @@ RSpec.describe 'Group Boards', feature_category: :team_planning do
it 'adds an issue to the backlog' do
page.within(find('.board', match: :first)) do
- issue_title = 'New Issue'
- click_button 'New issue'
+ dropdown = first("[data-testid='header-list-actions']")
+ dropdown.click
+ issue_title = 'Create new issue'
+ click_button issue_title
wait_for_requests
diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb
index 11f94967aaf..ab8d8238bdc 100644
--- a/spec/features/groups/container_registry_spec.rb
+++ b/spec/features/groups/container_registry_spec.rb
@@ -95,7 +95,11 @@ RSpec.describe 'Container Registry', :js, feature_category: :container_registry
first('[data-testid="additional-actions"]').click
first('[data-testid="single-delete-button"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
+ stub_container_registry_tags(repository: %r{my/image}, tags: [], with_manifest: true)
find('.modal .modal-footer .btn-danger').click
+
+ expect(page).to have_content '0 tags'
+ expect(page).not_to have_content '1 tag'
end
end
end
diff --git a/spec/features/groups/crm/contacts/create_spec.rb b/spec/features/groups/crm/contacts/create_spec.rb
index 860cadd322d..aa05ef82a8b 100644
--- a/spec/features/groups/crm/contacts/create_spec.rb
+++ b/spec/features/groups/crm/contacts/create_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe 'Create a CRM contact', :js, feature_category: :service_desk do
let(:user) { create(:user) }
let(:group) { create(:group, :crm_enabled) }
- let!(:organization) { create(:organization, group: group, name: 'GitLab') }
+ let!(:crm_organization) { create(:crm_organization, group: group, name: 'GitLab') }
before do
group.add_owner(user)
diff --git a/spec/features/groups/dependency_proxy_spec.rb b/spec/features/groups/dependency_proxy_spec.rb
index 05984d40ea6..60922f813df 100644
--- a/spec/features/groups/dependency_proxy_spec.rb
+++ b/spec/features/groups/dependency_proxy_spec.rb
@@ -52,6 +52,12 @@ RSpec.describe 'Group Dependency Proxy', feature_category: :dependency_proxy do
expect(find('input[data-testid="proxy-url"]').value).to have_content('/dependency_proxy/containers')
end
+ it 'has link to settings' do
+ visit path
+
+ expect(page).to have_link s_('DependencyProxy|Configure in settings')
+ end
+
it 'hides the proxy URL when feature is disabled' do
visit settings_path
wait_for_requests
@@ -80,6 +86,10 @@ RSpec.describe 'Group Dependency Proxy', feature_category: :dependency_proxy do
it 'does not show the feature toggle but shows the proxy URL' do
expect(find('input[data-testid="proxy-url"]').value).to have_content('/dependency_proxy/containers')
end
+
+ it 'does not have link to settings' do
+ expect(page).not_to have_link s_('DependencyProxy|Configure in settings')
+ end
end
end
diff --git a/spec/features/groups/group_runners_spec.rb b/spec/features/groups/group_runners_spec.rb
index ae757e04716..514110d78ae 100644
--- a/spec/features/groups/group_runners_spec.rb
+++ b/spec/features/groups/group_runners_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe "Group Runners", feature_category: :runner_fleet do
- include Spec::Support::Helpers::Features::RunnersHelpers
+ include Features::RunnersHelpers
include Spec::Support::Helpers::ModalHelpers
let_it_be(:group_owner) { create(:user) }
@@ -16,10 +16,12 @@ RSpec.describe "Group Runners", feature_category: :runner_fleet do
end
describe "Group runners page", :js do
- let!(:group_registration_token) { group.runners_token }
+ describe "legacy runners registration" do
+ let_it_be(:group_registration_token) { group.runners_token }
- describe "runners registration" do
before do
+ stub_feature_flags(create_runner_workflow_for_namespace: false)
+
visit group_runners_path(group)
end
@@ -60,15 +62,11 @@ RSpec.describe "Group Runners", feature_category: :runner_fleet do
let(:runner) { group_runner }
end
- it 'shows a group badge' do
- within_runner_row(group_runner.id) do
- expect(page).to have_selector '.badge', text: s_('Runners|Group')
- end
- end
-
- it 'can edit runner information' do
+ it 'shows an editable group badge' do
within_runner_row(group_runner.id) do
expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner))
+
+ expect(page).to have_selector '.badge', text: s_('Runners|Group')
end
end
@@ -102,15 +100,11 @@ RSpec.describe "Group Runners", feature_category: :runner_fleet do
let(:runner) { project_runner }
end
- it 'shows a project badge' do
- within_runner_row(project_runner.id) do
- expect(page).to have_selector '.badge', text: s_('Runners|Project')
- end
- end
-
- it 'can edit runner information' do
+ it 'shows an editable project runner' do
within_runner_row(project_runner.id) do
expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, project_runner))
+
+ expect(page).to have_selector '.badge', text: s_('Runners|Project')
end
end
end
@@ -202,6 +196,16 @@ RSpec.describe "Group Runners", feature_category: :runner_fleet do
end
end
+ describe "Group runner create page", :js do
+ before do
+ visit new_group_runner_path(group)
+ end
+
+ it_behaves_like 'creates runner and shows register page' do
+ let(:register_path_pattern) { register_group_runner_path(group, '.*') }
+ end
+ end
+
describe "Group runner show page", :js do
let_it_be(:group_runner) do
create(:ci_runner, :group, groups: [group], description: 'runner-foo')
diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb
index 5510e73ef0f..6443f4a6c38 100644
--- a/spec/features/groups/group_settings_spec.rb
+++ b/spec/features/groups/group_settings_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Edit group settings', feature_category: :subgroups do
+ include Spec::Support::Helpers::ModalHelpers
+
let(:user) { create(:user) }
let(:group) { create(:group, path: 'foo') }
@@ -72,7 +74,7 @@ RSpec.describe 'Edit group settings', feature_category: :subgroups do
visit new_project_full_path
expect(page).to have_current_path(new_project_full_path, ignore_query: true)
- expect(find('.breadcrumbs')).to have_content(project.path)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
it 'the old project path redirects to the new path' do
@@ -80,7 +82,7 @@ RSpec.describe 'Edit group settings', feature_category: :subgroups do
visit old_project_full_path
expect(page).to have_current_path(new_project_full_path, ignore_query: true)
- expect(find('.breadcrumbs')).to have_content(project.path)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
end
end
@@ -147,14 +149,16 @@ RSpec.describe 'Edit group settings', feature_category: :subgroups do
selected_group.add_owner(user)
end
- it 'can successfully transfer the group', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/384966' do
+ it 'can successfully transfer the group' do
+ selected_group_path = selected_group.path
+
visit edit_group_path(selected_group)
page.within('[data-testid="transfer-locations-dropdown"]') do
click_button _('Select parent group')
- fill_in _('Search'), with: target_group_name
+ fill_in _('Search'), with: target_group&.name || ''
wait_for_requests
- click_button target_group_name
+ click_button(target_group&.name || 'No parent group')
end
click_button s_('GroupSettings|Transfer group')
@@ -166,8 +170,16 @@ RSpec.describe 'Edit group settings', feature_category: :subgroups do
click_button 'Confirm'
end
- expect(page).to have_text "Group '#{selected_group.name}' was successfully transferred."
- expect(current_url).to include(selected_group.reload.full_path)
+ within('[data-testid="breadcrumb-links"]') do
+ expect(page).to have_content(target_group.name) if target_group
+ expect(page).to have_content(selected_group.name)
+ end
+
+ if target_group
+ expect(current_url).to include("#{target_group.path}/#{selected_group_path}")
+ else
+ expect(current_url).to include(selected_group_path)
+ end
end
end
@@ -175,14 +187,13 @@ RSpec.describe 'Edit group settings', feature_category: :subgroups do
let(:selected_group) { create(:group, path: 'foo-subgroup', parent: group) }
context 'when transfering to no parent group' do
- let(:target_group_name) { 'No parent group' }
+ let(:target_group) { nil }
it_behaves_like 'can transfer the group'
end
context 'when transfering to a parent group' do
let(:target_group) { create(:group, path: 'foo-parentgroup') }
- let(:target_group_name) { target_group.name }
before do
target_group.add_owner(user)
@@ -194,7 +205,7 @@ RSpec.describe 'Edit group settings', feature_category: :subgroups do
context 'when transfering from a root group to a parent group' do
let(:selected_group) { create(:group, path: 'foo-rootgroup') }
- let(:target_group_name) { group.name }
+ let(:target_group) { group }
it_behaves_like 'can transfer the group'
end
@@ -235,6 +246,67 @@ RSpec.describe 'Edit group settings', feature_category: :subgroups do
end
end
+ describe 'group README', :js do
+ let_it_be(:group) { create(:group) }
+
+ context 'with gitlab-profile project and README.md' do
+ let_it_be(:project) { create(:project, :readme, namespace: group) }
+
+ it 'renders link to Group README and navigates to it on click' do
+ visit edit_group_path(group)
+ wait_for_requests
+
+ click_link('README')
+ wait_for_requests
+
+ expect(page).to have_current_path(project_blob_path(project, "#{project.default_branch}/README.md"))
+ expect(page).to have_text('README.md')
+ end
+ end
+
+ context 'with gitlab-profile project and no README.md' do
+ let_it_be(:project) { create(:project, path: 'gitlab-profile', namespace: group) }
+
+ it 'renders Add README button and allows user to create a README via the IDE' do
+ visit edit_group_path(group)
+ wait_for_requests
+
+ expect(page).not_to have_selector('.ide')
+
+ click_button('Add README')
+
+ accept_gl_confirm("This will create a README.md for project #{group.readme_project.present.path_with_namespace}.", button_text: 'Add README')
+ wait_for_requests
+
+ expect(page).to have_current_path("/-/ide/project/#{group.readme_project.present.path_with_namespace}/edit/main/-/README.md/")
+
+ page.within('.ide') do
+ expect(page).to have_text('README.md')
+ end
+ end
+ end
+
+ context 'with no gitlab-profile project and no README.md' do
+ it 'renders Add README button and allows user to create both the gitlab-profile project and README via the IDE' do
+ visit edit_group_path(group)
+ wait_for_requests
+
+ expect(page).not_to have_selector('.ide')
+
+ click_button('Add README')
+
+ accept_gl_confirm("This will create a project #{group.full_path}/gitlab-profile and add a README.md.", button_text: 'Create and add README')
+ wait_for_requests
+
+ expect(page).to have_current_path("/-/ide/project/#{group.full_path}/gitlab-profile/edit/main/-/README.md/")
+
+ page.within('.ide') do
+ expect(page).to have_text('README.md')
+ end
+ end
+ end
+ end
+
def update_path(new_group_path)
visit edit_group_path(group)
diff --git a/spec/features/groups/import_export/connect_instance_spec.rb b/spec/features/groups/import_export/connect_instance_spec.rb
index 8aea18a268b..f6548c035f0 100644
--- a/spec/features/groups/import_export/connect_instance_spec.rb
+++ b/spec/features/groups/import_export/connect_instance_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'Import/Export - Connect to another instance', :js, feature_categ
pat = 'demo-pat'
expect(page).to have_content 'Import groups by direct transfer'
- expect(page).to have_content 'Not all related objects are migrated'
+ expect(page).to have_content 'Not all group items are migrated'
fill_in :bulk_import_gitlab_url, with: source_url
fill_in :bulk_import_gitlab_access_token, with: pat
diff --git a/spec/features/groups/integrations/group_integrations_spec.rb b/spec/features/groups/integrations/group_integrations_spec.rb
index 0d65fa5964b..8cddda91e89 100644
--- a/spec/features/groups/integrations/group_integrations_spec.rb
+++ b/spec/features/groups/integrations/group_integrations_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe 'Group integrations', :js do
include_context 'group integration activation'
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
it_behaves_like 'integration settings form' do
let(:integrations) { Integration.find_or_initialize_all_non_project_specific(Integration.for_group(group)) }
@@ -12,4 +16,16 @@ RSpec.describe 'Group integrations', :js do
visit_group_integration(integration.title)
end
end
+
+ context 'with remove_monitor_metrics flag enabled' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns a 404 for the prometheus edit page' do
+ visit edit_group_settings_integration_path(group, :prometheus)
+
+ expect(page).to have_content "Page Not Found"
+ end
+ end
end
diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb
index 00c0d4c3ebe..6d0d768d356 100644
--- a/spec/features/groups/issues_spec.rb
+++ b/spec/features/groups/issues_spec.rb
@@ -23,6 +23,10 @@ RSpec.describe 'Group issues page', feature_category: :subgroups do
context 'rss feed' do
let(:access_level) { ProjectFeature::ENABLED }
+ before do
+ click_button 'Actions'
+ end
+
context 'when signed in' do
let(:user) do
user_in_group.ensure_feed_token
@@ -30,29 +34,15 @@ RSpec.describe 'Group issues page', feature_category: :subgroups do
user_in_group
end
+ it_behaves_like "it has an RSS link with current_user's feed token"
it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
-
- # Note: The one from rss_shared_example.rb uses a css pseudo-class `:has`
- # which is VERY experimental and only supported in Nokogiri used by Capybara
- # 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(page).to have_link 'Subscribe to RSS feed', href: /feed_token=#{user.feed_token}/
- end
- end
end
context 'when signed out' do
let(:user) { nil }
+ it_behaves_like "it has an RSS link without a feed token"
it_behaves_like "an autodiscoverable RSS feed without a feed token"
-
- # 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(page).not_to have_link 'Subscribe to RSS feed', href: /feed_token/
- end
- end
end
end
diff --git a/spec/features/groups/labels/index_spec.rb b/spec/features/groups/labels/index_spec.rb
index ea27fa2c5d9..7b0a38a83db 100644
--- a/spec/features/groups/labels/index_spec.rb
+++ b/spec/features/groups/labels/index_spec.rb
@@ -24,6 +24,7 @@ RSpec.describe 'Group labels', feature_category: :team_planning do
end
it 'shows an edit label button', :js do
- expect(page).to have_selector('.edit')
+ click_button 'Label actions dropdown'
+ expect(page).to have_link('Edit')
end
end
diff --git a/spec/features/groups/members/filter_members_spec.rb b/spec/features/groups/members/filter_members_spec.rb
index dc33bb11bea..c2ec709576b 100644
--- a/spec/features/groups/members/filter_members_spec.rb
+++ b/spec/features/groups/members/filter_members_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Filter members', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
let(:user) { create(:user) }
let(:nested_group_user) { create(:user) }
diff --git a/spec/features/groups/members/leave_group_spec.rb b/spec/features/groups/members/leave_group_spec.rb
index cfb1b24bccb..e1c2d8c0547 100644
--- a/spec/features/groups/members/leave_group_spec.rb
+++ b/spec/features/groups/members/leave_group_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Leave group', feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
include Spec::Support::Helpers::ModalHelpers
let(:user) { create(:user) }
diff --git a/spec/features/groups/members/list_members_spec.rb b/spec/features/groups/members/list_members_spec.rb
index 1aea5a76b41..6e20f92c16b 100644
--- a/spec/features/groups/members/list_members_spec.rb
+++ b/spec/features/groups/members/list_members_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > List members', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
let(:user1) { create(:user, name: 'John Doe') }
let(:user2) { create(:user, name: 'Mary Jane') }
diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb
index ee8786a2e36..f9c11dd0183 100644
--- a/spec/features/groups/members/manage_groups_spec.rb
+++ b/spec/features/groups/members/manage_groups_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Manage groups', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::MembersHelpers
+ include Features::InviteMembersModalHelpers
include Spec::Support::Helpers::ModalHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb
index 5cd5908b359..2d5a3dbb8f8 100644
--- a/spec/features/groups/members/manage_members_spec.rb
+++ b/spec/features/groups/members/manage_members_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Manage members', feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::MembersHelpers
+ include Features::InviteMembersModalHelpers
include Spec::Support::Helpers::ModalHelpers
let_it_be(:user1) { create(:user, name: 'John Doe') }
diff --git a/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb
index e9f80b05fa7..4f56c807ec8 100644
--- a/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb
+++ b/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::MembersHelpers
+ include Features::InviteMembersModalHelpers
let_it_be(:user1) { create(:user, name: 'John Doe') }
let_it_be(:group) { create(:group) }
diff --git a/spec/features/groups/members/search_members_spec.rb b/spec/features/groups/members/search_members_spec.rb
index 6b2896b194c..80de1cabd1e 100644
--- a/spec/features/groups/members/search_members_spec.rb
+++ b/spec/features/groups/members/search_members_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Search group member', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
let(:user) { create :user }
let(:member) { create :user }
diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb
index 5634122ec16..d2e5445deae 100644
--- a/spec/features/groups/members/sort_members_spec.rb
+++ b/spec/features/groups/members/sort_members_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Sort members', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
let(:owner) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) }
@@ -18,7 +18,7 @@ RSpec.describe 'Groups > Members > Sort members', :js, feature_category: :subgro
def expect_sort_by(text, sort_direction)
within('[data-testid="members-sort-dropdown"]') do
- expect(page).to have_css('button[aria-haspopup="true"]', text: text)
+ expect(page).to have_css('button[aria-haspopup="menu"]', text: text)
expect(page).to have_button("Sorting Direction: #{sort_direction == :asc ? 'Ascending' : 'Descending'}")
end
end
diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb
index a70a1e2e70b..376e1e6063f 100644
--- a/spec/features/groups/milestone_spec.rb
+++ b/spec/features/groups/milestone_spec.rb
@@ -25,17 +25,17 @@ RSpec.describe 'Group milestones', feature_category: :subgroups do
description.native.send_keys('')
- click_button('Preview')
+ click_button("Preview")
preview = find('.js-md-preview')
expect(preview).to have_content('Nothing to preview.')
- click_button('Write')
+ click_button("Continue editing")
description.native.send_keys(':+1: Nice')
- click_button('Preview')
+ click_button("Preview")
expect(preview).to have_css('gl-emoji')
expect(find('#milestone_description', visible: false)).not_to be_visible
diff --git a/spec/features/groups/milestones/milestone_editing_spec.rb b/spec/features/groups/milestones/milestone_editing_spec.rb
new file mode 100644
index 00000000000..b3c7cfe88af
--- /dev/null
+++ b/spec/features/groups/milestones/milestone_editing_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "Milestone editing", feature_category: :team_planning do
+ let_it_be(:group) { create(:group, owner: user) }
+ let_it_be(:user) { create(:group_member, :maintainer, user: create(:user), group: group).user }
+
+ let(:milestone) { create(:milestone, group: group, title: "12345676543") }
+
+ before do
+ sign_in(user)
+
+ visit(edit_group_milestone_path(group, milestone))
+ end
+
+ it_behaves_like 'milestone handling version conflicts'
+end
diff --git a/spec/features/groups/new_group_page_spec.rb b/spec/features/groups/new_group_page_spec.rb
index a07c27331d9..1efdc3fff07 100644
--- a/spec/features/groups/new_group_page_spec.rb
+++ b/spec/features/groups/new_group_page_spec.rb
@@ -3,15 +3,14 @@
require 'spec_helper'
RSpec.describe 'New group page', :js, feature_category: :subgroups do
- let(:user) { create(:user) }
- let(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:parent_group) { create(:group) }
before do
+ parent_group.add_owner(user)
sign_in(user)
end
- it_behaves_like 'a dashboard page with sidebar', :new_group_path, :groups
-
describe 'new top level group alert' do
context 'when a user visits the new group page' do
it 'shows the new top level group alert' do
@@ -22,8 +21,6 @@ RSpec.describe 'New group page', :js, feature_category: :subgroups do
end
context 'when a user visits the new sub group page' do
- let(:parent_group) { create(:group) }
-
it 'does not show the new top level group alert' do
visit new_group_path(parent_id: parent_group.id, anchor: 'create-group-pane')
@@ -31,4 +28,45 @@ RSpec.describe 'New group page', :js, feature_category: :subgroups do
end
end
end
+
+ describe 'sidebar' do
+ context 'in the current navigation' do
+ before do
+ user.update!(use_new_navigation: false)
+ end
+
+ context 'for a new top-level group' do
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :new_group_path, :groups
+ end
+
+ context 'for a new subgroup' do
+ it 'shows the group sidebar of the parent group' do
+ visit new_group_path(parent_id: parent_group.id, anchor: 'create-group-pane')
+ expect(page).to have_selector(
+ ".nav-sidebar[aria-label=\"Group navigation\"] .context-header[title=\"#{parent_group.name}\"]"
+ )
+ end
+ end
+ end
+
+ context 'in the new navigation' do
+ before do
+ user.update!(use_new_navigation: true)
+ end
+
+ context 'for a new top-level group' do
+ it 'shows the "Your work" navigation' do
+ visit new_group_path
+ expect(page).to have_selector(".super-sidebar .context-switcher-toggle", text: "Your work")
+ end
+ end
+
+ context 'for a new subgroup' do
+ it 'shows the group navigation of the parent group' do
+ visit new_group_path(parent_id: parent_group.id, anchor: 'create-group-pane')
+ expect(page).to have_selector(".super-sidebar .context-switcher-toggle", text: parent_group.name)
+ end
+ end
+ end
+ end
end
diff --git a/spec/features/groups/packages_spec.rb b/spec/features/groups/packages_spec.rb
index dd238657fbc..b7f9cd3e93a 100644
--- a/spec/features/groups/packages_spec.rb
+++ b/spec/features/groups/packages_spec.rb
@@ -37,8 +37,8 @@ RSpec.describe 'Group Packages', feature_category: :package_registry do
end
context 'when there are packages' do
- let_it_be(:second_project) { create(:project, name: 'second-project', group: group) }
- let_it_be(:npm_package) { create(:npm_package, project: project, name: 'zzz', created_at: 1.day.ago, version: '1.0.0') }
+ let_it_be(:second_project) { create(:project, group: group) }
+ let_it_be(:npm_package) { create(:npm_package, :with_build, project: project, name: 'zzz', created_at: 1.day.ago, version: '1.0.0') }
let_it_be(:maven_package) { create(:maven_package, project: second_project, name: 'aaa', created_at: 2.days.ago, version: '2.0.0') }
let_it_be(:packages) { [npm_package, maven_package] }
@@ -47,10 +47,12 @@ RSpec.describe 'Group Packages', feature_category: :package_registry do
it_behaves_like 'packages list', check_project_name: true
+ it_behaves_like 'pipelines on packages list'
+
it_behaves_like 'package details link'
it 'allows you to navigate to the project page' do
- find('[data-testid="root-link"]', text: project.name).click
+ find('[data-testid="root-link"]', text: project.path).click
expect(page).to have_current_path(project_path(project))
expect(page).to have_content(project.name)
diff --git a/spec/features/groups/settings/access_tokens_spec.rb b/spec/features/groups/settings/access_tokens_spec.rb
index 1bee3be1ddb..cb92f9abdf5 100644
--- a/spec/features/groups/settings/access_tokens_spec.rb
+++ b/spec/features/groups/settings/access_tokens_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Group > Settings > Access Tokens', :js, feature_category: :authentication_and_authorization do
+RSpec.describe 'Group > Settings > Access Tokens', :js, feature_category: :system_access do
include Spec::Support::Helpers::ModalHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/features/groups/settings/packages_and_registries_spec.rb b/spec/features/groups/settings/packages_and_registries_spec.rb
index 80e2dcd5174..8ea8dc9219a 100644
--- a/spec/features/groups/settings/packages_and_registries_spec.rb
+++ b/spec/features/groups/settings/packages_and_registries_spec.rb
@@ -56,6 +56,15 @@ RSpec.describe 'Group Package and registry settings', feature_category: :package
expect(sidebar).to have_link _('Packages and registries')
end
+ it 'passes axe automated accessibility testing', :js do
+ visit_settings_page
+
+ wait_for_requests
+
+ expect(page).to be_axe_clean.within('[data-testid="packages-and-registries-group-settings"]')
+ .skipping :'link-in-text-block'
+ end
+
it 'has a Duplicate packages section', :js do
visit_settings_page
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index 5cab79b40cf..0f936173e5d 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Group show page', feature_category: :subgroups do
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::InviteMembersModalHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 8806d1c2219..088b5b11a9a 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -510,6 +510,47 @@ RSpec.describe 'Group', feature_category: :subgroups do
end
end
end
+
+ context 'when in a private group' do
+ before do
+ group.update!(
+ visibility_level: Gitlab::VisibilityLevel::PRIVATE,
+ project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS
+ )
+ end
+
+ context 'when visibility levels have been restricted to private only by an administrator' do
+ before do
+ stub_application_setting(
+ restricted_visibility_levels: [
+ Gitlab::VisibilityLevel::PRIVATE
+ ]
+ )
+ end
+
+ it 'does not display the "New project" button' do
+ visit group_path(group)
+
+ page.within '[data-testid="group-buttons"]' do
+ expect(page).not_to have_link('New project')
+ end
+ end
+ end
+ end
+ end
+
+ describe 'group README', :js do
+ context 'with gitlab-profile project and README.md' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :readme, namespace: group) }
+
+ it 'renders README block on group page' do
+ visit group_path(group)
+ wait_for_requests
+
+ expect(page).to have_text('README.md')
+ end
+ end
end
def remove_with_confirm(button_text, confirm_with)
diff --git a/spec/features/help_dropdown_spec.rb b/spec/features/help_dropdown_spec.rb
index a5c9221ad26..5f1d3a5e2b7 100644
--- a/spec/features/help_dropdown_spec.rb
+++ b/spec/features/help_dropdown_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Help Dropdown", :js, feature_category: :not_owned do
+RSpec.describe "Help Dropdown", :js, feature_category: :shared do
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create(:admin) }
diff --git a/spec/features/help_pages_spec.rb b/spec/features/help_pages_spec.rb
index 6c0901d6169..905c5e25f6e 100644
--- a/spec/features/help_pages_spec.rb
+++ b/spec/features/help_pages_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Help Pages', feature_category: :not_owned do
+RSpec.describe 'Help Pages', feature_category: :shared do
describe 'Get the main help page' do
before do
allow(File).to receive(:read).and_call_original
diff --git a/spec/features/ide/user_opens_merge_request_spec.rb b/spec/features/ide/user_opens_merge_request_spec.rb
index 0074b4b1eb0..dc280133a20 100644
--- a/spec/features/ide/user_opens_merge_request_spec.rb
+++ b/spec/features/ide/user_opens_merge_request_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'IDE merge request', :js, feature_category: :web_ide do
+ include CookieHelper
+
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
let(:project) { create(:project, :public, :repository) }
let(:user) { project.first_owner }
@@ -12,6 +14,8 @@ RSpec.describe 'IDE merge request', :js, feature_category: :web_ide do
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
+
visit(merge_request_path(merge_request))
end
diff --git a/spec/features/ide_spec.rb b/spec/features/ide_spec.rb
index 2ca8d3f7156..bdf8be95415 100644
--- a/spec/features/ide_spec.rb
+++ b/spec/features/ide_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'IDE', :js, feature_category: :web_ide do
- include WebIdeSpecHelpers
+ include Features::WebIdeSpecHelpers
let_it_be(:ide_iframe_selector) { '#ide iframe' }
let_it_be(:normal_project) { create(:project, :repository) }
@@ -48,17 +48,6 @@ RSpec.describe 'IDE', :js, feature_category: :web_ide do
it_behaves_like 'legacy Web IDE'
end
- context 'with vscode feature flag on and use_legacy_web_ide=true' do
- let(:vscode_ff) { true }
- let(:user) { create(:user, use_legacy_web_ide: true) }
-
- before do
- ide_visit(project)
- end
-
- it_behaves_like 'legacy Web IDE'
- end
-
describe 'sub-groups' do
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) }
diff --git a/spec/features/import/manifest_import_spec.rb b/spec/features/import/manifest_import_spec.rb
index bb3eb34637b..aba38eb0196 100644
--- a/spec/features/import/manifest_import_spec.rb
+++ b/spec/features/import/manifest_import_spec.rb
@@ -7,6 +7,8 @@ RSpec.describe 'Import multiple repositories by uploading a manifest file', :js,
let(:group) { create(:group) }
before do
+ stub_application_setting(import_sources: ['manifest'])
+
sign_in(user)
group.add_owner(user)
diff --git a/spec/features/incidents/incident_details_spec.rb b/spec/features/incidents/incident_details_spec.rb
index 709919d0196..a166ff46177 100644
--- a/spec/features/incidents/incident_details_spec.rb
+++ b/spec/features/incidents/incident_details_spec.rb
@@ -94,6 +94,7 @@ RSpec.describe 'Incident details', :js, feature_category: :incident_management d
end
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, issue)
wait_for_requests
@@ -113,6 +114,7 @@ RSpec.describe 'Incident details', :js, feature_category: :incident_management d
end
it 'routes the user to the issue details page when the `issue_type` is set to issue' do
+ set_cookie('new-actions-popover-viewed', 'true')
visit incident_project_issues_path(project, incident)
wait_for_requests
diff --git a/spec/features/incidents/incident_timeline_events_spec.rb b/spec/features/incidents/incident_timeline_events_spec.rb
index 7404ac64cc9..4d51ed652c9 100644
--- a/spec/features/incidents/incident_timeline_events_spec.rb
+++ b/spec/features/incidents/incident_timeline_events_spec.rb
@@ -43,9 +43,7 @@ RSpec.describe 'Incident timeline events', :js, feature_category: :incident_mana
expect(page).to have_content(s_('Incident|No timeline items have been added yet.'))
end
- it 'submits event data on save with feature flag on' do
- stub_feature_flags(incident_event_tags: true)
-
+ it 'submits event data on save' do
# Add event
click_button(s_('Incident|Add new timeline event'))
@@ -96,5 +94,6 @@ RSpec.describe 'Incident timeline events', :js, feature_category: :incident_mana
it_behaves_like 'for each incident details route',
'add, edit, and delete timeline events',
- tab_text: s_('Incident|Timeline')
+ tab_text: s_('Incident|Timeline'),
+ tab: 'timeline'
end
diff --git a/spec/features/incidents/user_uses_quick_actions_spec.rb b/spec/features/incidents/user_uses_quick_actions_spec.rb
index 3740f2fca47..27facbcafe8 100644
--- a/spec/features/incidents/user_uses_quick_actions_spec.rb
+++ b/spec/features/incidents/user_uses_quick_actions_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Incidents > User uses quick actions', :js, feature_category: :incident_management do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
describe 'incident-only commands' do
let_it_be(:user) { create(:user) }
diff --git a/spec/features/incidents/user_views_alert_details_spec.rb b/spec/features/incidents/user_views_alert_details_spec.rb
new file mode 100644
index 00000000000..f3d0273071c
--- /dev/null
+++ b/spec/features/incidents/user_views_alert_details_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User uploads alerts to incident', :js, feature_category: :incident_management do
+ let_it_be(:incident) { create(:incident) }
+ let_it_be(:project) { incident.project }
+ let_it_be(:user) { create(:user, developer_projects: [project]) }
+
+ context 'with alert' do
+ let_it_be(:alert) { create(:alert_management_alert, issue_id: incident.id, project: project) }
+
+ shared_examples 'shows alert tab with details' do
+ specify do
+ expect(page).to have_link(s_('Incident|Alert details'))
+ expect(page).to have_content(alert.title)
+ end
+ end
+
+ it_behaves_like 'for each incident details route',
+ 'shows alert tab with details',
+ tab_text: s_('Incident|Alert details'),
+ tab: 'alerts'
+ end
+
+ context 'with no alerts' do
+ it 'hides the Alert details tab' do
+ sign_in(user)
+ visit project_issue_path(project, incident)
+
+ expect(page).not_to have_link(s_('Incident|Alert details'))
+ end
+ end
+end
diff --git a/spec/features/integrations_settings_spec.rb b/spec/features/integrations_settings_spec.rb
new file mode 100644
index 00000000000..70ce2f55161
--- /dev/null
+++ b/spec/features/integrations_settings_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Integration settings', feature_category: :integrations do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ context 'with Shimo Zentao integration records' do
+ before do
+ create(:integration, project: project, type_new: 'Integrations::Shimo', category: 'issue_tracker')
+ create(:integration, project: project, type_new: 'Integrations::Zentao', category: 'issue_tracker')
+ end
+
+ it 'shows settings without Shimo Zentao', :js do
+ visit namespace_project_settings_integrations_path(namespace_id: project.namespace.full_path,
+ project_id: project.path)
+
+ expect(page).to have_content('Add an integration')
+ expect(page).not_to have_content('ZenTao')
+ expect(page).not_to have_content('Shimo')
+ end
+ end
+end
diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb
index 1091bea1ce3..a3d4b30b59c 100644
--- a/spec/features/invites_spec.rb
+++ b/spec/features/invites_spec.rb
@@ -28,14 +28,10 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
fill_in 'new_user_username', with: new_user.username
fill_in 'new_user_email', with: new_user.email
fill_in 'new_user_password', with: new_user.password
- click_button submit_button_text
- end
- def fill_in_sign_in_form(user)
- fill_in 'user_login', with: user.email
- fill_in 'user_password', with: user.password
- check 'user_remember_me'
- click_button 'Sign in'
+ wait_for_all_requests
+
+ click_button submit_button_text
end
def fill_in_welcome_form
@@ -73,7 +69,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
end
end
- context 'when invite is sent before account is created - ldap or service sign in for manual acceptance edge case' do
+ context 'when invite is sent before account is created;ldap or service sign in for manual acceptance edge case' do
let(:user) { create(:user, email: 'user@example.com') }
context 'when invite clicked and not signed in' do
@@ -84,7 +80,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
it 'sign in, grants access and redirects to group activity page' do
click_link 'Sign in'
- fill_in_sign_in_form(user)
+ gitlab_sign_in(user, remember: true, visit: false)
expect(page).to have_current_path(activity_group_path(group), ignore_query: true)
end
@@ -151,7 +147,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
end
end
- context 'when inviting an unregistered user' do
+ context 'when inviting an unregistered user', :js do
let(:new_user) { build_stubbed(:user) }
let(:invite_email) { new_user.email }
let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email, created_by: owner) }
@@ -175,17 +171,19 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
fill_in_sign_up_form(new_user)
expect(page).to have_current_path(new_user_session_path, ignore_query: true)
- expect(page).to have_content('You have signed up successfully. However, we could not sign you in because your account is awaiting approval from your GitLab administrator')
+ sign_up_message = 'You have signed up successfully. However, we could not sign you in because your account ' \
+ 'is awaiting approval from your GitLab administrator.'
+ expect(page).to have_content(sign_up_message)
end
end
- context 'email confirmation disabled' do
+ context 'with email confirmation disabled' do
before do
stub_application_setting_enum('email_confirmation_setting', 'off')
end
- context 'the user signs up for an account with the invitation email address' do
- it 'redirects to the most recent membership activity page with all the projects/groups invitations automatically accepted' do
+ context 'when the user signs up for an account with the invitation email address' do
+ it 'redirects to the most recent membership activity page with all invitations automatically accepted' do
fill_in_sign_up_form(new_user)
fill_in_welcome_form
@@ -194,7 +192,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
end
end
- context 'the user sign-up using a different email address' do
+ context 'when the user sign-up using a different email address' do
let(:invite_email) { build_stubbed(:user).email }
it 'signs up and redirects to the activity page' do
@@ -206,9 +204,9 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
end
end
- context 'email confirmation enabled' do
+ context 'with 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: '') }
+ let(:new_user) { build_stubbed(:user, password: '11111111') }
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 }
@@ -232,8 +230,8 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
end
end
- context 'the user signs up for an account with the invitation email address' do
- it 'redirects to the most recent membership activity page with all the projects/groups invitations automatically accepted' do
+ context 'when the user signs up for an account with the invitation email address' do
+ it 'redirects to the most recent membership activity page with all invitations automatically accepted' do
fill_in_sign_up_form(new_user)
fill_in_welcome_form
@@ -241,12 +239,11 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
end
end
- context 'the user sign-up using a different email address' do
+ context 'when the user signs up using a different email address' do
let(:invite_email) { build_stubbed(:user).email }
- context 'when soft email confirmation is not enabled' do
+ context 'when email confirmation is not set to `soft`' do
before do
- stub_feature_flags(soft_email_confirmation: false)
allow(User).to receive(:allow_unconfirmed_access_for).and_return 0
stub_feature_flags(identity_verification: false)
end
@@ -254,16 +251,16 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate
it 'signs up and redirects to the group activity page' do
fill_in_sign_up_form(new_user)
confirm_email(new_user)
- fill_in_sign_in_form(new_user)
+ gitlab_sign_in(new_user, remember: true, visit: false)
fill_in_welcome_form
expect(page).to have_current_path(activity_group_path(group), ignore_query: true)
end
end
- context 'when soft email confirmation is enabled' do
+ context 'when email confirmation setting is set to `soft`' do
before do
- stub_feature_flags(soft_email_confirmation: true)
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days
end
diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb
index 350b0582565..c979aff2147 100644
--- a/spec/features/issuables/issuable_list_spec.rb
+++ b/spec/features/issuables/issuable_list_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe 'issuable list', :js, feature_category: :team_planning do
end
end
- it 'displays a warning if counting the number of issues times out' do
+ it 'displays a warning if counting the number of issues times out', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/393344' do
allow_any_instance_of(IssuesFinder).to receive(:count_by_state).and_raise(ActiveRecord::QueryCanceled)
visit_issuable_list(:issue)
diff --git a/spec/features/issuables/markdown_references/internal_references_spec.rb b/spec/features/issuables/markdown_references/internal_references_spec.rb
index aeae76b1b77..04950c7c7d4 100644
--- a/spec/features/issuables/markdown_references/internal_references_spec.rb
+++ b/spec/features/issuables/markdown_references/internal_references_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe "Internal references", :js, feature_category: :team_planning do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
let(:private_project_user) { private_project.first_owner }
let(:private_project) { create(:project, :private, :repository) }
diff --git a/spec/features/issuables/markdown_references/jira_spec.rb b/spec/features/issuables/markdown_references/jira_spec.rb
index 52464c6be8b..887bc7d0c87 100644
--- a/spec/features/issuables/markdown_references/jira_spec.rb
+++ b/spec/features/issuables/markdown_references/jira_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe "Jira", :js, feature_category: :team_planning do
end
it "creates a link to the referenced issue on the preview" do
- find(".js-md-preview-button").click
+ click_button("Preview")
wait_for_requests
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 0bdb5930f30..c982052fc0e 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
@@ -20,6 +20,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
before do
stub_feature_flags(moved_mr_sidebar: false)
+ stub_feature_flags(hide_create_issue_resolve_all: false)
end
describe 'as a user with access to the project' do
@@ -33,7 +34,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
find('.discussions-counter .dropdown-toggle').click
within('.discussions-counter') do
- 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))
+ expect(page).to have_link(_("Resolve all with new issue"), href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid))
end
end
@@ -44,7 +45,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 "Create issue to resolve all threads"
+ expect(page).not_to have_content "Resolve all with new issue"
end
end
@@ -69,7 +70,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 'Create issue to resolve all threads'
+ expect(page).not_to have_link 'Resolve all with new issue'
end
end
@@ -83,13 +84,13 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
end
it 'has a link to resolve all threads by creating an issue' do
- 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)
+ expect(page).to have_link 'Resolve all with new issue', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
context 'creating an issue for threads' do
before do
page.within '.mr-state-widget' do
- page.click_link 'Create issue to resolve all threads', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
+ page.click_link 'Resolve all with new issue', 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/csv_spec.rb b/spec/features/issues/csv_spec.rb
index 8629201459f..21d5041b210 100644
--- a/spec/features/issues/csv_spec.rb
+++ b/spec/features/issues/csv_spec.rb
@@ -16,6 +16,7 @@ RSpec.describe 'Issues csv', :js, feature_category: :team_planning do
def request_csv(params = {})
visit project_issues_path(project, params)
+ click_button 'Actions'
click_button 'Export as CSV'
click_on 'Export issues'
end
diff --git a/spec/features/issues/discussion_lock_spec.rb b/spec/features/issues/discussion_lock_spec.rb
index 33fc9a6fd96..fb9addff1a2 100644
--- a/spec/features/issues/discussion_lock_spec.rb
+++ b/spec/features/issues/discussion_lock_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe 'Discussion Lock', :js, feature_category: :team_planning do
before do
sign_in(user)
+ stub_feature_flags(moved_mr_sidebar: false)
end
context 'when a user is a team member' do
@@ -99,7 +100,7 @@ RSpec.describe 'Discussion Lock', :js, feature_category: :team_planning do
it 'the user can not create a comment' do
page.within('#notes') do
expect(page).not_to have_selector('js-main-target-form')
- expect(page.find('.disabled-comment'))
+ expect(page.find('.disabled-comments'))
.to have_content('This issue is locked. Only project members can comment.')
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
index 39034a40b1f..1cd8326c5fe 100644
--- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
@@ -52,8 +52,8 @@ RSpec.describe 'Dropdown hint', :js, feature_category: :team_planning do
click_filtered_search_bar
send_keys 'as'
- # Expect Assignee and Release
- expect_suggestion_count 2
+ # Expect Assignee, Release, Search for this text
+ expect_suggestion_count 3
end
end
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
index f67d5c40efd..a65befc3115 100644
--- a/spec/features/issues/filtered_search/filter_issues_spec.rb
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -61,7 +61,7 @@ RSpec.describe 'Filter issues', :js, feature_category: :team_planning do
it 'filters by all available tokens' do
search_term = 'issue'
select_tokens 'Assignee', '=', user.username, 'Author', '=', user.username, 'Label', '=', caps_sensitive_label.title, 'Milestone', '=', milestone.title
- send_keys search_term, :enter
+ send_keys search_term, :enter, :enter
expect_assignee_token(user.name)
expect_author_token(user.name)
@@ -261,7 +261,7 @@ RSpec.describe 'Filter issues', :js, feature_category: :team_planning do
it 'filters issues by searched label, label2, author, assignee, milestone and text' do
search_term = 'bug'
select_tokens 'Label', '=', bug_label.title, 'Label', '=', caps_sensitive_label.title, 'Author', '=', user.username, 'Assignee', '=', user.username, 'Milestone', '=', milestone.title
- send_keys search_term, :enter
+ send_keys search_term, :enter, :enter
expect_label_token(bug_label.title)
expect_label_token(caps_sensitive_label.title)
@@ -275,7 +275,7 @@ RSpec.describe 'Filter issues', :js, feature_category: :team_planning do
it 'filters issues by searched label, label2, author, assignee, not included in a milestone' do
search_term = 'bug'
select_tokens 'Label', '=', bug_label.title, 'Label', '=', caps_sensitive_label.title, 'Author', '=', user.username, 'Assignee', '=', user.username, 'Milestone', '!=', milestone.title
- send_keys search_term, :enter
+ send_keys search_term, :enter, :enter
expect_label_token(bug_label.title)
expect_label_token(caps_sensitive_label.title)
@@ -488,13 +488,13 @@ RSpec.describe 'Filter issues', :js, feature_category: :team_planning do
context 'searched text with other filters' do
it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do
click_filtered_search_bar
- send_keys 'bug '
+ send_keys 'bug', :enter
select_tokens 'Author', '=', user.username
- send_keys 'report '
+ send_keys 'report', :enter
select_tokens 'Label', '=', bug_label.title
select_tokens 'Label', '=', caps_sensitive_label.title
select_tokens 'Milestone', '=', milestone.title
- send_keys 'foo', :enter
+ send_keys 'foo', :enter, :enter
expect_issues_list_count(1)
expect_search_term('bug report foo')
diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb
index 2d9c73f2756..0efa2f49e36 100644
--- a/spec/features/issues/filtered_search/recent_searches_spec.rb
+++ b/spec/features/issues/filtered_search/recent_searches_spec.rb
@@ -100,7 +100,7 @@ RSpec.describe 'Recent searches', :js, feature_category: :team_planning do
def submit_then_clear_search(search)
click_filtered_search_bar
- send_keys(search, :enter)
+ send_keys(search, :enter, :enter)
click_button 'Clear'
end
end
diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb
index c975df2a531..35c099b29aa 100644
--- a/spec/features/issues/filtered_search/search_bar_spec.rb
+++ b/spec/features/issues/filtered_search/search_bar_spec.rb
@@ -70,7 +70,8 @@ RSpec.describe 'Search bar', :js, feature_category: :team_planning do
original_size = get_suggestion_count
send_keys 'autho'
- expect_suggestion_count 1
+ # Expect Author, Search for this text
+ expect_suggestion_count 2
click_button 'Clear'
click_filtered_search_bar
diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb
index f25925ed33d..3031b20eb7c 100644
--- a/spec/features/issues/filtered_search/visual_tokens_spec.rb
+++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb
@@ -79,10 +79,10 @@ RSpec.describe 'Visual tokens', :js, feature_category: :team_planning do
describe 'editing a search term while editing another filter token' do
before do
click_filtered_search_bar
- send_keys 'foo '
+ send_keys 'foo', :enter
select_tokens 'Assignee', '='
click_token_segment 'foo'
- send_keys ' '
+ send_keys :enter
end
it 'opens author dropdown' do
@@ -98,44 +98,6 @@ RSpec.describe 'Visual tokens', :js, feature_category: :team_planning do
end
end
- describe 'add new token after editing existing token' do
- before do
- select_tokens 'Assignee', '=', user.username, 'Label', '=', 'None'
- click_token_segment(user.name)
- send_keys ' '
- end
-
- describe 'opens dropdowns' do
- it 'opens hint dropdown' do
- expect_visible_suggestions_list
- end
-
- it 'opens token dropdown' do
- click_on 'Author'
-
- expect_visible_suggestions_list
- end
- end
-
- describe 'visual tokens' do
- it 'creates visual token' do
- click_on 'Author'
- click_on '= is'
- click_on 'The Rock'
-
- expect_author_token 'The Rock'
- end
- end
-
- it 'does not tokenize incomplete token' do
- click_on 'Author'
- find('.js-navbar').click
-
- expect_empty_search_term
- expect_token_segment 'Assignee'
- end
- end
-
describe 'search using incomplete visual tokens' do
before do
select_tokens 'Author', '=', user.username, 'Assignee', '=', 'None'
diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb
index 585740f7782..9702e43a559 100644
--- a/spec/features/issues/form_spec.rb
+++ b/spec/features/issues/form_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do
include ActionView::Helpers::JavaScriptHelper
+ include ListboxHelpers
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
@@ -13,9 +14,11 @@ RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do
let_it_be(:label) { create(:label, project: project) }
let_it_be(:label2) { create(:label, project: project) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) }
+ let_it_be(:issue2) { create(:issue, project: project, assignees: [user], milestone: milestone) }
let_it_be(:confidential_issue) { create(:issue, project: project, assignees: [user], milestone: milestone, confidential: true) }
let(:current_user) { user }
+ let(:visible_label_selection_on_metadata) { false }
before_all do
project.add_maintainer(user)
@@ -25,6 +28,7 @@ RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do
before do
stub_licensed_features(multiple_issue_assignees: false, issue_weights: false)
+ stub_feature_flags(visible_label_selection_on_metadata: visible_label_selection_on_metadata)
sign_in(current_user)
end
@@ -112,110 +116,240 @@ RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do
end
end
- it 'allows user to create new issue' do
- fill_in 'issue_title', with: 'title'
- fill_in 'issue_description', with: 'title'
+ context 'with the visible_label_selection_on_metadata feature flag enabled' do
+ let(:visible_label_selection_on_metadata) { true }
- expect(find('a', text: 'Assign to me')).to be_visible
- click_button 'Unassigned'
+ it 'allows user to create new issue' do
+ fill_in 'issue_title', with: 'title'
+ fill_in 'issue_description', with: 'title'
- wait_for_requests
+ expect(find('a', text: 'Assign to me')).to be_visible
+ click_button 'Unassigned'
- page.within '.dropdown-menu-user' do
- click_link user2.name
- end
- expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s)
- page.within '.js-assignee-search' do
- expect(page).to have_content user2.name
- end
- expect(find('a', text: 'Assign to me')).to be_visible
+ wait_for_requests
+
+ page.within '.dropdown-menu-user' do
+ click_link user2.name
+ end
+ expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s)
+ page.within '.js-assignee-search' do
+ expect(page).to have_content user2.name
+ end
+ expect(find('a', text: 'Assign to me')).to be_visible
+
+ click_link 'Assign to me'
+ assignee_ids = page.all('input[name="issue[assignee_ids][]"]', visible: false)
+
+ expect(assignee_ids[0].value).to match(user.id.to_s)
+
+ page.within '.js-assignee-search' do
+ expect(page).to have_content user.name
+ end
+ expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible
+
+ click_button 'Select milestone'
+ click_button milestone.title
+ expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
+ expect(page).to have_button milestone.title
+
+ click_button _('Select label')
+ wait_for_all_requests
+ page.within '[data-testid="sidebar-labels"]' do
+ click_button label.title
+ click_button label2.title
+ click_button _('Close')
+ wait_for_requests
+ page.within('[data-testid="embedded-labels-list"]') do
+ expect(page).to have_content(label.title)
+ expect(page).to have_content(label2.title)
+ end
+ end
+
+ click_button 'Create issue'
+
+ page.within '.issuable-sidebar' do
+ page.within '.assignee' do
+ expect(page).to have_content "Assignee"
+ end
- click_link 'Assign to me'
- assignee_ids = page.all('input[name="issue[assignee_ids][]"]', visible: false)
+ page.within '.milestone' do
+ expect(page).to have_content milestone.title
+ end
+
+ page.within '.labels' do
+ expect(page).to have_content label.title
+ expect(page).to have_content label2.title
+ end
+ end
- expect(assignee_ids[0].value).to match(user.id.to_s)
+ page.within '.breadcrumbs' do
+ issue = Issue.find_by(title: 'title')
- page.within '.js-assignee-search' do
- expect(page).to have_content user.name
+ expect(page).to have_text("Issues #{issue.to_reference}")
+ end
end
- expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible
- click_button 'Select milestone'
- click_button milestone.title
- expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
- expect(page).to have_button milestone.title
+ it 'correctly updates the dropdown toggle when removing a label' do
+ click_button _('Select label')
+
+ wait_for_all_requests
- click_button 'Labels'
- page.within '.dropdown-menu-labels' do
- click_link label.title
- click_link label2.title
+ page.within '[data-testid="sidebar-labels"]' do
+ click_button label.title
+ click_button _('Close')
+
+ wait_for_requests
+
+ page.within('[data-testid="embedded-labels-list"]') do
+ expect(page).to have_content(label.title)
+ end
+
+ expect(page.find('.gl-dropdown-button-text')).to have_content(label.title)
+ end
+
+ click_button label.title, class: 'gl-dropdown-toggle'
+
+ wait_for_all_requests
+
+ page.within '[data-testid="sidebar-labels"]' do
+ click_button label.title, class: 'dropdown-item'
+ click_button _('Close')
+
+ wait_for_requests
+
+ expect(page).not_to have_selector('[data-testid="embedded-labels-list"]')
+ expect(page.find('.gl-dropdown-button-text')).to have_content(_('Select label'))
+ end
end
- find('.js-issuable-form-dropdown.js-label-select').click
+ it 'clears label search input field when a label is selected', :js do
+ click_button _('Select label')
- page.within '.js-label-select' do
- expect(page).to have_content label.title
+ wait_for_all_requests
+
+ page.within '[data-testid="sidebar-labels"]' do
+ search_field = find('input[type="search"]')
+
+ search_field.native.send_keys(label.title)
+
+ expect(page).to have_css('.gl-search-box-by-type-clear')
+
+ click_button label.title, class: 'dropdown-item'
+
+ expect(page).not_to have_css('.gl-search-box-by-type-clear')
+ expect(search_field.value).to eq ''
+ end
end
- expect(page.all('input[name="issue[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
- expect(page.all('input[name="issue[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
+ end
- click_button 'Create issue'
+ context 'with the visible_label_selection_on_metadata feature flag disabled' do
+ let(:visible_label_selection_on_metadata) { false }
+
+ it 'allows user to create new issue' do
+ fill_in 'issue_title', with: 'title'
+ fill_in 'issue_description', with: 'title'
- page.within '.issuable-sidebar' do
- page.within '.assignee' do
- expect(page).to have_content "Assignee"
+ expect(find('a', text: 'Assign to me')).to be_visible
+ click_button 'Unassigned'
+
+ wait_for_requests
+
+ page.within '.dropdown-menu-user' do
+ click_link user2.name
end
+ expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s)
+ page.within '.js-assignee-search' do
+ expect(page).to have_content user2.name
+ end
+ expect(find('a', text: 'Assign to me')).to be_visible
+
+ click_link 'Assign to me'
+ assignee_ids = page.all('input[name="issue[assignee_ids][]"]', visible: false)
+
+ expect(assignee_ids[0].value).to match(user.id.to_s)
+
+ page.within '.js-assignee-search' do
+ expect(page).to have_content user.name
+ end
+ expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible
- page.within '.milestone' do
- expect(page).to have_content milestone.title
+ click_button 'Select milestone'
+ click_button milestone.title
+ expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
+ expect(page).to have_button milestone.title
+
+ click_button 'Labels'
+ page.within '.dropdown-menu-labels' do
+ click_link label.title
+ click_link label2.title
end
- page.within '.labels' do
+ find('.js-issuable-form-dropdown.js-label-select').click
+
+ page.within '.js-label-select' do
expect(page).to have_content label.title
- expect(page).to have_content label2.title
end
- end
+ expect(page.all('input[name="issue[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
+ expect(page.all('input[name="issue[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
- page.within '.breadcrumbs' do
- issue = Issue.find_by(title: 'title')
+ click_button 'Create issue'
- expect(page).to have_text("Issues #{issue.to_reference}")
- end
- end
+ page.within '.issuable-sidebar' do
+ page.within '.assignee' do
+ expect(page).to have_content "Assignee"
+ end
- it 'displays an error message when submitting an invalid form' do
- click_button 'Create issue'
+ page.within '.milestone' do
+ expect(page).to have_content milestone.title
+ end
- page.within('[data-testid="issue-title-input-field"]') do
- expect(page).to have_text(_('This field is required.'))
- end
- end
+ page.within '.labels' do
+ expect(page).to have_content label.title
+ expect(page).to have_content label2.title
+ end
+ end
- it 'correctly updates the dropdown toggle when removing a label' do
- click_button 'Labels'
+ page.within '.breadcrumbs' do
+ issue = Issue.find_by(title: 'title')
- page.within '.dropdown-menu-labels' do
- click_link label.title
+ expect(page).to have_text("Issues #{issue.to_reference}")
+ end
end
- expect(find('.js-label-select')).to have_content(label.title)
+ it 'correctly updates the dropdown toggle when removing a label' do
+ click_button 'Labels'
+
+ page.within '.dropdown-menu-labels' do
+ click_link label.title
+ end
+
+ expect(find('.js-label-select')).to have_content(label.title)
+
+ page.within '.dropdown-menu-labels' do
+ click_link label.title
+ end
- page.within '.dropdown-menu-labels' do
- click_link label.title
+ expect(find('.js-label-select')).to have_content('Labels')
end
- expect(find('.js-label-select')).to have_content('Labels')
- end
+ it 'clears label search input field when a label is selected' do
+ click_button 'Labels'
+
+ page.within '.dropdown-menu-labels' do
+ search_field = find('input[type="search"]')
- it 'clears label search input field when a label is selected' do
- click_button 'Labels'
+ search_field.set(label2.title)
+ click_link label2.title
+ expect(search_field.value).to eq ''
+ end
+ end
+ end
- page.within '.dropdown-menu-labels' do
- search_field = find('input[type="search"]')
+ it 'displays an error message when submitting an invalid form' do
+ click_button 'Create issue'
- search_field.set(label2.title)
- click_link label2.title
- expect(search_field.value).to eq ''
+ page.within('[data-testid="issue-title-input-field"]') do
+ expect(page).to have_text(_('This field is required.'))
end
end
@@ -248,19 +382,15 @@ RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do
describe 'displays issue type options in the dropdown' do
shared_examples 'type option is visible' do |label:, identifier:|
it "shows #{identifier} option", :aggregate_failures do
- page.within('[data-testid="issue-type-select-dropdown"]') do
- expect(page).to have_selector(%([data-testid="issue-type-#{identifier}-icon"]))
- expect(page).to have_content(label)
- end
+ wait_for_requests
+ expect_listbox_item(label)
end
end
shared_examples 'type option is missing' do |label:, identifier:|
it "does not show #{identifier} option", :aggregate_failures do
- page.within('[data-testid="issue-type-select-dropdown"]') do
- expect(page).not_to have_selector(%([data-testid="issue-type-#{identifier}-icon"]))
- expect(page).not_to have_content(label)
- end
+ wait_for_requests
+ expect_no_listbox_item(label)
end
end
@@ -428,42 +558,100 @@ RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do
visit edit_project_issue_path(project, issue)
end
- it 'allows user to update issue' do
- expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s)
- expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
- expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible
+ context 'with the visible_label_selection_on_metadata feature flag enabled' do
+ let(:visible_label_selection_on_metadata) { true }
- page.within '.js-user-search' do
- expect(page).to have_content user.name
- end
+ it 'allows user to update issue' do
+ expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s)
+ expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
+ expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible
+
+ page.within '.js-user-search' do
+ expect(page).to have_content user.name
+ end
- expect(page).to have_button milestone.title
+ expect(page).to have_button milestone.title
- click_button 'Labels'
- page.within '.dropdown-menu-labels' do
- click_link label.title
- click_link label2.title
- end
- page.within '.js-label-select' do
- expect(page).to have_content label.title
+ click_button _('Select label')
+
+ wait_for_all_requests
+
+ page.within '[data-testid="sidebar-labels"]' do
+ click_button label.title
+ click_button label2.title
+ click_button _('Close')
+
+ wait_for_requests
+
+ page.within('[data-testid="embedded-labels-list"]') do
+ expect(page).to have_content(label.title)
+ expect(page).to have_content(label2.title)
+ end
+ end
+
+ expect(page.all('input[name="issue[label_ids][]"]', visible: false)
+ .map(&:value))
+ .to contain_exactly(label.id.to_s, label2.id.to_s)
+
+ click_button 'Save changes'
+
+ page.within '.issuable-sidebar' do
+ page.within '.assignee' do
+ expect(page).to have_content user.name
+ end
+
+ page.within '.milestone' do
+ expect(page).to have_content milestone.title
+ end
+
+ page.within '.labels' do
+ expect(page).to have_content label.title
+ expect(page).to have_content label2.title
+ end
+ end
end
- expect(page.all('input[name="issue[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
- expect(page.all('input[name="issue[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
+ end
+
+ context 'with the visible_label_selection_on_metadata feature flag disabled' do
+ let(:visible_label_selection_on_metadata) { false }
- click_button 'Save changes'
+ it 'allows user to update issue' do
+ expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s)
+ expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
+ expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible
- page.within '.issuable-sidebar' do
- page.within '.assignee' do
+ page.within '.js-user-search' do
expect(page).to have_content user.name
end
- page.within '.milestone' do
- expect(page).to have_content milestone.title
- end
+ expect(page).to have_button milestone.title
- page.within '.labels' do
+ click_button 'Labels'
+ page.within '.dropdown-menu-labels' do
+ click_link label.title
+ click_link label2.title
+ end
+ page.within '.js-label-select' do
expect(page).to have_content label.title
- expect(page).to have_content label2.title
+ end
+ expect(page.all('input[name="issue[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
+ expect(page.all('input[name="issue[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
+
+ click_button 'Save changes'
+
+ page.within '.issuable-sidebar' do
+ page.within '.assignee' do
+ expect(page).to have_content user.name
+ end
+
+ page.within '.milestone' do
+ expect(page).to have_content milestone.title
+ end
+
+ page.within '.labels' do
+ expect(page).to have_content label.title
+ expect(page).to have_content label2.title
+ end
end
end
end
@@ -477,14 +665,69 @@ RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do
end
describe 'inline edit' do
- before do
- visit project_issue_path(project, issue)
+ context 'within issue 1' do
+ before do
+ visit project_issue_path(project, issue)
+ wait_for_requests
+ end
+
+ it 'opens inline edit form with shortcut' do
+ find('body').send_keys('e')
+
+ expect(page).to have_selector('.detail-page-description form')
+ end
+
+ describe 'when user has made no changes' do
+ it 'let user leave the page without warnings' do
+ expected_content = 'Issue created'
+ expect(page).to have_content(expected_content)
+
+ find('body').send_keys('e')
+
+ click_link 'Boards'
+
+ expect(page).not_to have_content(expected_content)
+ end
+ end
+
+ describe 'when user has made changes' do
+ it 'shows a warning and can stay on page', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/397683' do
+ content = 'new issue content'
+
+ find('body').send_keys('e')
+ fill_in 'issue-description', with: content
+
+ click_link 'Boards'
+
+ page.driver.browser.switch_to.alert.dismiss
+
+ click_button 'Save changes'
+ wait_for_requests
+
+ expect(page).to have_content(content)
+ end
+ end
end
- it 'opens inline edit form with shortcut' do
- find('body').send_keys('e')
+ context 'within issue 2' do
+ before do
+ visit project_issue_path(project, issue2)
+ wait_for_requests
+ end
- expect(page).to have_selector('.detail-page-description form')
+ describe 'when user has made changes' do
+ it 'shows a warning and can leave page', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410497' do
+ content = 'new issue content'
+ find('body').send_keys('e')
+ fill_in 'issue-description', with: content
+
+ click_link 'Boards'
+
+ page.driver.browser.switch_to.alert.accept
+
+ expect(page).not_to have_content(content)
+ end
+ end
end
end
@@ -499,22 +742,55 @@ RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do
visit new_project_issue_path(sub_group_project)
end
- it 'creates project label from dropdown' do
- click_button 'Labels'
+ context 'with the visible_label_selection_on_metadata feature flag enabled', :js do
+ let(:visible_label_selection_on_metadata) { true }
- click_link 'Create project label'
+ it 'creates project label from dropdown' do
+ find('[data-testid="labels-select-dropdown-contents"] button').click
- page.within '.dropdown-new-label' do
- fill_in 'new_label_name', with: 'test label'
- first('.suggest-colors-dropdown a').click
+ wait_for_all_requests
- click_button 'Create'
+ page.within '[data-testid="sidebar-labels"]' do
+ click_button _('Create project label')
- wait_for_requests
+ wait_for_requests
+ end
+
+ page.within '.js-labels-create' do
+ find('[data-testid="label-title-input"]').fill_in with: 'test label'
+ first('.suggest-colors-dropdown a').click
+
+ click_button 'Create'
+
+ wait_for_all_requests
+ end
+
+ page.within '.js-labels-list' do
+ expect(page).to have_button 'test label'
+ end
end
+ end
+
+ context 'with the visible_label_selection_on_metadata feature flag disabled' do
+ let(:visible_label_selection_on_metadata) { false }
+
+ it 'creates project label from dropdown' do
+ click_button 'Labels'
+
+ click_link 'Create project label'
- page.within '.dropdown-menu-labels' do
- expect(page).to have_link 'test label'
+ page.within '.dropdown-new-label' do
+ fill_in 'new_label_name', with: 'test label'
+ first('.suggest-colors-dropdown a').click
+
+ click_button 'Create'
+
+ wait_for_requests
+ end
+
+ page.within '.dropdown-menu-labels' do
+ expect(page).to have_link 'test label'
+ end
end
end
end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index 2bd5373b715..665c7307231 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'GFM autocomplete', :js, feature_category: :team_planning do
+ include CookieHelper
+
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') }
@@ -45,6 +47,7 @@ RSpec.describe 'GFM autocomplete', :js, feature_category: :team_planning do
before do
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, issue_to_edit)
wait_for_requests
diff --git a/spec/features/issues/incident_issue_spec.rb b/spec/features/issues/incident_issue_spec.rb
index 41bbd79202f..145b51d207a 100644
--- a/spec/features/issues/incident_issue_spec.rb
+++ b/spec/features/issues/incident_issue_spec.rb
@@ -51,8 +51,8 @@ RSpec.describe 'Incident Detail', :js, feature_category: :team_planning do
aggregate_failures 'when on summary tab (default tab)' do
hidden_items = find_all('.js-issue-widgets')
- # Linked Issues/MRs and comment box and emoji block
- expect(hidden_items.count).to eq(3)
+ # Description footer + Linked Issues/MRs + comment box + emoji block
+ expect(hidden_items.count).to eq(4)
expect(hidden_items).to all(be_visible)
edit_button = find_all('[aria-label="Edit title and description"]')
diff --git a/spec/features/issues/issue_detail_spec.rb b/spec/features/issues/issue_detail_spec.rb
index 20a69c61871..29a61d584ee 100644
--- a/spec/features/issues/issue_detail_spec.rb
+++ b/spec/features/issues/issue_detail_spec.rb
@@ -48,6 +48,30 @@ RSpec.describe 'Issue Detail', :js, feature_category: :team_planning do
end
end
+ context 'when issue description has task list items' do
+ before do
+ description = '- [ ] I am a task
+
+| Table |
+|-------|
+| <ul><li>[ ] I am inside a table</li><ul> |'
+ issue.update!(description: description)
+
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'shows task actions ellipsis button when hovering over the task list item, but not within a table', :aggregate_failures do
+ find('li', text: 'I am a task').hover
+
+ expect(page).to have_button 'Task actions'
+
+ find('li', text: 'I am inside a table').hover
+
+ expect(page).not_to have_button 'Task actions'
+ end
+ end
+
context 'when issue description has xss snippet' do
before do
issue.update!(description: '![xss" onload=alert(1);//](a)')
@@ -74,6 +98,7 @@ RSpec.describe 'Issue Detail', :js, feature_category: :team_planning do
project.add_developer(user_to_be_deleted)
sign_in(user_to_be_deleted)
+ stub_feature_flags(moved_mr_sidebar: false)
visit project_issue_path(project, issue)
wait_for_requests
@@ -105,7 +130,7 @@ RSpec.describe 'Issue Detail', :js, feature_category: :team_planning do
describe 'when an issue `issue_type` is edited' do
before do
sign_in(user)
-
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, issue)
wait_for_requests
end
@@ -139,7 +164,7 @@ RSpec.describe 'Issue Detail', :js, feature_category: :team_planning do
describe 'when an incident `issue_type` is edited' do
before do
sign_in(user)
-
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, incident)
wait_for_requests
end
diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb
index 686074f7412..ee71181fba2 100644
--- a/spec/features/issues/issue_sidebar_spec.rb
+++ b/spec/features/issues/issue_sidebar_spec.rb
@@ -4,7 +4,8 @@ require 'spec_helper'
RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
include MobileHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::InviteMembersModalHelpers
+ include CookieHelper
let_it_be(:group) { create(:group, :nested) }
let_it_be(:project) { create(:project, :public, namespace: group) }
@@ -20,6 +21,7 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
context 'when signed in' do
before do
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
end
context 'when concerning the assignee', :js do
@@ -86,12 +88,12 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
end
within '.js-right-sidebar' do
- find('.block.assignee').click(x: 0, y: 0)
+ find('.block.assignee').click(x: 0, y: 0, offset: 0)
find('.block.assignee .edit-link').click
end
- expect(page.all('.dropdown-menu-user li').length).to eq(1)
- expect(find('.dropdown-input-field').value).to eq(user2.name)
+ expect(page.all('.dropdown-menu-user li').length).to eq(6)
+ expect(find('.dropdown-input-field').value).to eq('')
end
it 'shows label text as "Apply" when assignees are changed' do
@@ -119,8 +121,6 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite members')
- expect(page).to have_selector('[data-track-action="click_invite_members"]')
- expect(page).to have_selector('[data-track-label="edit_assignee"]')
click_link 'Invite members'
end
@@ -207,6 +207,7 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
context 'as an allowed user' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
project.add_developer(user)
visit_issue(project, issue)
end
@@ -295,6 +296,7 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
context 'as a guest' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
project.add_guest(user)
visit_issue(project, issue)
end
diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb
index ea68f2266b3..4512e88ae72 100644
--- a/spec/features/issues/move_spec.rb
+++ b/spec/features/issues/move_spec.rb
@@ -97,7 +97,7 @@ RSpec.describe 'issue move to another project', feature_category: :team_planning
end
end
- context 'service desk issue moved to a project with service desk disabled', :js do
+ context 'service desk issue moved to a project with service desk disabled', :saas, :js do
let(:project_title) { 'service desk disabled project' }
let(:warning_selector) { '.js-alert-moved-from-service-desk-warning' }
let(:namespace) { create(:namespace) }
@@ -106,9 +106,8 @@ RSpec.describe 'issue move to another project', feature_category: :team_planning
let(:service_desk_issue) { create(:issue, project: service_desk_project, author: ::User.support_bot) }
before do
- allow(Gitlab).to receive(:com?).and_return(true)
- allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
- allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
regular_project.add_reporter(user)
service_desk_project.add_reporter(user)
diff --git a/spec/features/issues/rss_spec.rb b/spec/features/issues/rss_spec.rb
index 36dffeded50..eb45d3c8d8b 100644
--- a/spec/features/issues/rss_spec.rb
+++ b/spec/features/issues/rss_spec.rb
@@ -23,24 +23,20 @@ RSpec.describe 'Project Issues RSS', :js, feature_category: :team_planning do
before do
sign_in(user)
visit path
+ click_button 'Actions'
end
- it "shows the RSS button with current_user's feed token" do
- expect(page).to have_link 'Subscribe to RSS feed', href: /feed_token=#{user.feed_token}/
- end
-
+ it_behaves_like "it has an RSS link 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
+ click_button 'Actions'
end
- it "shows the RSS button without a feed token" do
- expect(page).not_to have_link 'Subscribe to RSS feed', href: /feed_token/
- end
-
+ it_behaves_like "it has an RSS link without a feed token"
it_behaves_like "an autodiscoverable RSS feed without a feed token"
end
diff --git a/spec/features/issues/service_desk_spec.rb b/spec/features/issues/service_desk_spec.rb
index 922ab95538b..0cadeb62fa2 100644
--- a/spec/features/issues/service_desk_spec.rb
+++ b/spec/features/issues/service_desk_spec.rb
@@ -10,8 +10,8 @@ RSpec.describe 'Service Desk Issue Tracker', :js, feature_category: :team_planni
before do
# The following two conditions equate to Gitlab::ServiceDesk.supported == true
- allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
- allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
project.add_maintainer(user)
sign_in(user)
diff --git a/spec/features/issues/user_bulk_edits_issues_labels_spec.rb b/spec/features/issues/user_bulk_edits_issues_labels_spec.rb
index 1fc6609d1f5..a01ae9ae0c2 100644
--- a/spec/features/issues/user_bulk_edits_issues_labels_spec.rb
+++ b/spec/features/issues/user_bulk_edits_issues_labels_spec.rb
@@ -406,7 +406,7 @@ RSpec.describe 'Issues > Labels bulk assignment', feature_category: :team_planni
context 'cannot bulk assign labels' do
it do
- expect(page).not_to have_button 'Edit issues'
+ expect(page).not_to have_button 'Bulk edit'
expect(page).not_to have_unchecked_field 'Select all'
expect(page).not_to have_unchecked_field issue1.title
end
@@ -462,7 +462,7 @@ RSpec.describe 'Issues > Labels bulk assignment', feature_category: :team_planni
def enable_bulk_update
visit project_issues_path(project)
wait_for_requests
- click_button 'Edit issues'
+ click_button 'Bulk edit'
end
def disable_bulk_update
diff --git a/spec/features/issues/user_bulk_edits_issues_spec.rb b/spec/features/issues/user_bulk_edits_issues_spec.rb
index 5696bde4069..3e119d86c05 100644
--- a/spec/features/issues/user_bulk_edits_issues_spec.rb
+++ b/spec/features/issues/user_bulk_edits_issues_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'Multiple issue updating from issues#index', :js, feature_categor
it 'sets to closed', :js do
visit project_issues_path(project)
- click_button 'Edit issues'
+ click_button 'Bulk edit'
check 'Select all'
click_button 'Select status'
click_button 'Closed'
@@ -29,7 +29,7 @@ RSpec.describe 'Multiple issue updating from issues#index', :js, feature_categor
create_closed
visit project_issues_path(project, state: 'closed')
- click_button 'Edit issues'
+ click_button 'Bulk edit'
check 'Select all'
click_button 'Select status'
click_button 'Open'
@@ -43,7 +43,7 @@ RSpec.describe 'Multiple issue updating from issues#index', :js, feature_categor
it 'updates to current user' do
visit project_issues_path(project)
- click_button 'Edit issues'
+ click_button 'Bulk edit'
check 'Select all'
click_update_assignee_button
click_button user.username
@@ -61,7 +61,7 @@ RSpec.describe 'Multiple issue updating from issues#index', :js, feature_categor
expect(find('.issue:first-of-type')).to have_link "Assigned to #{user.name}"
- click_button 'Edit issues'
+ click_button 'Bulk edit'
check 'Select all'
click_update_assignee_button
click_button 'Unassigned'
@@ -77,7 +77,7 @@ RSpec.describe 'Multiple issue updating from issues#index', :js, feature_categor
it 'updates milestone' do
visit project_issues_path(project)
- click_button 'Edit issues'
+ click_button 'Bulk edit'
check 'Select all'
click_button 'Select milestone'
click_button milestone.title
@@ -94,7 +94,7 @@ RSpec.describe 'Multiple issue updating from issues#index', :js, feature_categor
expect(find('.issue:first-of-type')).to have_text milestone.title
- click_button 'Edit issues'
+ click_button 'Bulk edit'
check 'Select all'
click_button 'Select milestone'
click_button 'No milestone'
@@ -110,7 +110,7 @@ RSpec.describe 'Multiple issue updating from issues#index', :js, feature_categor
it 'after selecting all issues, unchecking one issue only unselects that one issue' do
visit project_issues_path(project)
- click_button 'Edit issues'
+ click_button 'Bulk edit'
check 'Select all'
uncheck issue.title
diff --git a/spec/features/issues/user_comments_on_issue_spec.rb b/spec/features/issues/user_comments_on_issue_spec.rb
index 59e1413fc97..3ace560fb40 100644
--- a/spec/features/issues/user_comments_on_issue_spec.rb
+++ b/spec/features/issues/user_comments_on_issue_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
RSpec.describe "User comments on issue", :js, feature_category: :team_planning do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) }
@@ -32,6 +32,8 @@ RSpec.describe "User comments on issue", :js, feature_category: :team_planning d
end
end
+ it_behaves_like 'edits content using the content editor'
+
it "adds comment with code block" do
code_block_content = "Command [1]: /usr/local/bin/git , see [text](doc/text)"
comment = "```\n#{code_block_content}\n```"
diff --git a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
index bbc14368d82..6d9eb3a7191 100644
--- a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
+++ b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
@@ -73,8 +73,8 @@ RSpec.describe 'User creates branch and merge request on issue page', :js, featu
expect(page).to have_content('New merge request')
expect(page).to have_content("From #{issue.to_branch_name} into #{project.default_branch}")
- expect(page).to have_content("Closes ##{issue.iid}")
expect(page).to have_field("Title", with: "Draft: Resolve \"Cherry-Coloured Funk\"")
+ expect(page).to have_field("Description", with: "Closes ##{issue.iid}")
expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: issue.to_branch_name, target_branch: project.default_branch, issue_iid: issue.iid }))
end
end
@@ -98,8 +98,8 @@ RSpec.describe 'User creates branch and merge request on issue page', :js, featu
expect(page).to have_content('New merge request')
expect(page).to have_content("From #{branch_name} into #{project.default_branch}")
- expect(page).to have_content("Closes ##{issue.iid}")
expect(page).to have_field("Title", with: "Draft: Resolve \"Cherry-Coloured Funk\"")
+ expect(page).to have_field("Description", with: "Closes ##{issue.iid}")
expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: branch_name, target_branch: project.default_branch, issue_iid: issue.iid }))
end
end
@@ -113,6 +113,26 @@ RSpec.describe 'User creates branch and merge request on issue page', :js, featu
expect(page).to have_current_path project_tree_path(project, branch_name), ignore_query: true
end
end
+
+ context 'when branch name is invalid' do
+ shared_examples 'has error message' do |dropdown|
+ it 'has error message' do
+ select_dropdown_option(dropdown, 'custom-branch-name w~th ^bad chars?')
+
+ wait_for_requests
+
+ expect(page).to have_text("Can't contain spaces, ~, ^, ?")
+ end
+ end
+
+ context 'when creating a merge request', :sidekiq_might_not_need_inline do
+ it_behaves_like 'has error message', 'create-mr'
+ end
+
+ context 'when creating a branch', :sidekiq_might_not_need_inline do
+ it_behaves_like 'has error message', 'create-branch'
+ end
+ end
end
context "when there is a referenced merge request" do
diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb
index c5d0791dc57..d4148717f0a 100644
--- a/spec/features/issues/user_creates_issue_spec.rb
+++ b/spec/features/issues/user_creates_issue_spec.rb
@@ -8,16 +8,20 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
let_it_be(:project) { create(:project_empty_repo, :public) }
let_it_be(:user) { create(:user) }
+ let(:visible_label_selection_on_metadata) { false }
+
context "when unauthenticated" do
before do
sign_out(:user)
end
- it "redirects to signin then back to new issue after signin", :js do
+ it "redirects to signin then back to new issue after signin", :js, quarantine: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/1486' do
create(:issue, project: project)
visit project_issues_path(project)
+ wait_for_all_requests
+
page.within ".nav-controls" do
click_link "New issue"
end
@@ -32,6 +36,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
context "when signed in as guest", :js do
before do
+ stub_feature_flags(visible_label_selection_on_metadata: visible_label_selection_on_metadata)
project.add_guest(user)
sign_in(user)
@@ -61,18 +66,18 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
page.within(form) do
click_button("Preview")
- preview = find(".js-md-preview") # this element is findable only when the "Preview" link is clicked.
+ preview = find(".js-vue-md-preview") # this element is findable only when the "Preview" link is clicked.
expect(preview).to have_content("Nothing to preview.")
- click_button("Write")
+ click_button("Continue editing")
fill_in("Description", with: "Bug fixed :smile:")
click_button("Preview")
expect(preview).to have_css("gl-emoji")
expect(textarea).not_to be_visible
- click_button("Write")
+ click_button("Continue editing")
fill_in("Description", with: "/confidential")
click_button("Preview")
@@ -90,18 +95,50 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
- it "creates issue" do
- issue_title = "500 error on profile"
+ context 'with the visible_label_selection_on_metadata feature flag enabled' do
+ let(:visible_label_selection_on_metadata) { true }
+
+ it "creates issue" do
+ issue_title = "500 error on profile"
+
+ fill_in("Title", with: issue_title)
+
+ click_button _('Select label')
+
+ wait_for_all_requests
+
+ page.within '[data-testid="sidebar-labels"]' do
+ click_button label_titles.first
+ click_button _('Close')
+
+ wait_for_requests
+ end
+
+ click_button("Create issue")
+
+ expect(page).to have_content(issue_title)
+ .and have_content(user.name)
+ .and have_content(project.name)
+ .and have_content(label_titles.first)
+ end
+ end
+
+ context 'with the visible_label_selection_on_metadata feature flag disabled' do
+ let(:visible_label_selection_on_metadata) { false }
+
+ it "creates issue" do
+ issue_title = "500 error on profile"
- fill_in("Title", with: issue_title)
- click_button("Label")
- click_link(label_titles.first)
- click_button("Create issue")
+ fill_in("Title", with: issue_title)
+ click_button("Label")
+ click_link(label_titles.first)
+ click_button("Create issue")
- expect(page).to have_content(issue_title)
- .and have_content(user.name)
- .and have_content(project.name)
- .and have_content(label_titles.first)
+ expect(page).to have_content(issue_title)
+ .and have_content(user.name)
+ .and have_content(project.name)
+ .and have_content(label_titles.first)
+ end
end
end
@@ -127,6 +164,8 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
+ it_behaves_like 'edits content using the content editor'
+
context 'dropzone upload file', :js do
before do
visit new_project_issue_path(project)
@@ -184,7 +223,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
it 'pre-fills the issue type dropdown with issue type' do
- expect(find('.js-issuable-type-filter-dropdown-wrap .dropdown-toggle-text')).to have_content('Issue')
+ expect(find('.js-issuable-type-filter-dropdown-wrap .gl-button-text')).to have_content('Issue')
end
it 'does not hide the milestone select' do
@@ -200,7 +239,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
it 'does not pre-fill the issue type dropdown with incident type' do
- expect(find('.js-issuable-type-filter-dropdown-wrap .dropdown-toggle-text')).not_to have_content('Incident')
+ expect(find('.js-issuable-type-filter-dropdown-wrap .gl-button-text')).not_to have_content('Incident')
end
it 'shows the milestone select' do
@@ -231,6 +270,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
wait_for_requests
expect(page).to have_field('Title', with: '')
+ expect(page).to have_field('Description', with: '')
fill_in 'issue_title', with: 'bug 345'
fill_in 'issue_description', with: 'bug description'
@@ -238,6 +278,21 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
click_button 'Create issue'
end
end
+
+ it 'clears local storage after cancelling a new issue creation', :js do
+ 2.times do
+ visit new_project_issue_path(project)
+ wait_for_requests
+
+ expect(page).to have_field('Title', with: '')
+ expect(page).to have_field('Description', with: '')
+
+ fill_in 'issue_title', with: 'bug 345'
+ fill_in 'issue_description', with: 'bug description'
+
+ click_link 'Cancel'
+ end
+ end
end
context 'when signed in as reporter', :js do
@@ -257,7 +312,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
it 'pre-fills the issue type dropdown with incident type' do
- expect(find('.js-issuable-type-filter-dropdown-wrap .dropdown-toggle-text')).to have_content('Incident')
+ expect(find('.js-issuable-type-filter-dropdown-wrap .gl-button-text')).to have_content('Incident')
end
it 'hides the epic select' do
diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb
index bf2af918f39..bc20660d2a0 100644
--- a/spec/features/issues/user_edits_issue_spec.rb
+++ b/spec/features/issues/user_edits_issue_spec.rb
@@ -3,6 +3,8 @@
require "spec_helper"
RSpec.describe "Issues > User edits issue", :js, feature_category: :team_planning do
+ include CookieHelper
+
let_it_be(:project) { create(:project_empty_repo, :public) }
let_it_be(:project_with_milestones) { create(:project_empty_repo, :public) }
let_it_be(:user) { create(:user) }
@@ -18,6 +20,7 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
project.add_developer(user)
project_with_milestones.add_developer(user)
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
end
context "from edit page" do
@@ -26,6 +29,8 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
visit edit_project_issue_path(project, issue)
end
+ it_behaves_like 'edits content using the content editor'
+
it "previews content", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391757' do
form = first(".gfm-form")
@@ -34,9 +39,7 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
click_button("Preview")
end
- expect(form).to have_button("Write")
-
- click_button("Write")
+ click_button("Continue editing")
fill_in("Description", with: "/confidential")
click_button("Preview")
@@ -51,7 +54,7 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
first('.js-user-search').click
click_link 'Unassigned'
- click_button 'Save changes'
+ click_button _('Save changes')
page.within('.assignee') do
expect(page).to have_content 'None - assign yourself'
@@ -76,7 +79,7 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
expect(find('#issuable-due-date').value).to eq date.to_s
- click_button 'Save changes'
+ click_button _('Save changes')
page.within '.issuable-sidebar' do
expect(page).to have_content date.to_s(:medium)
@@ -89,9 +92,15 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
fill_in 'issue_title', with: 'bug 345'
fill_in 'issue_description', with: 'bug description'
- click_button 'Save changes'
+ click_button _('Save changes')
- expect(page).to have_content 'Someone edited the issue the same time you did'
+ expect(page).to have_content(
+ format(
+ _("Someone edited this %{model_name} at the same time you did. Please check out the %{link_to_model} and make sure your changes will not unintentionally remove theirs."), # rubocop:disable Layout/LineLength
+ model_name: _('issue'),
+ link_to_model: _('issue')
+ )
+ )
end
end
end
@@ -111,23 +120,27 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
markdown_field_focused_selector = 'textarea:focus'
click_edit_issue_description
- expect(page).to have_selector(markdown_field_focused_selector)
+ issuable_form = find('[data-testid="issuable-form"]')
- click_on _('View rich text')
- click_on _('Rich text')
+ expect(issuable_form).to have_selector(markdown_field_focused_selector)
- expect(page).not_to have_selector(content_editor_focused_selector)
+ page.within issuable_form do
+ click_button("Switch to rich text")
+ end
+
+ expect(issuable_form).not_to have_selector(content_editor_focused_selector)
refresh
click_edit_issue_description
- expect(page).to have_selector(content_editor_focused_selector)
+ expect(issuable_form).to have_selector(content_editor_focused_selector)
- click_on _('View markdown')
- click_on _('Markdown')
+ page.within issuable_form do
+ click_button("Switch to Markdown")
+ end
- expect(page).not_to have_selector(markdown_field_focused_selector)
+ expect(issuable_form).not_to have_selector(markdown_field_focused_selector)
end
end
diff --git a/spec/features/issues/user_sorts_issue_comments_spec.rb b/spec/features/issues/user_sorts_issue_comments_spec.rb
index ca52e620ea7..153066343f2 100644
--- a/spec/features/issues/user_sorts_issue_comments_spec.rb
+++ b/spec/features/issues/user_sorts_issue_comments_spec.rb
@@ -16,9 +16,8 @@ RSpec.describe 'Comment sort direction', feature_category: :team_planning do
it 'saves sort order' do
# open dropdown, and select 'Newest first'
page.within('.issuable-details') do
- find('#discussion-preferences-dropdown').click
+ click_button('Sort or filter')
click_button('Oldest first')
- find('#discussion-preferences-dropdown').click
click_button('Newest first')
end
diff --git a/spec/features/issues/user_toggles_subscription_spec.rb b/spec/features/issues/user_toggles_subscription_spec.rb
index 904fafdf56a..00b04c10d33 100644
--- a/spec/features/issues/user_toggles_subscription_spec.rb
+++ b/spec/features/issues/user_toggles_subscription_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe "User toggles subscription", :js, feature_category: :team_plannin
context 'user is not logged in' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
visit(project_issue_path(project, issue))
end
@@ -20,9 +21,9 @@ RSpec.describe "User toggles subscription", :js, feature_category: :team_plannin
context 'user is logged in' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
project.add_developer(user)
sign_in(user)
-
visit(project_issue_path(project, issue))
end
@@ -52,6 +53,7 @@ RSpec.describe "User toggles subscription", :js, feature_category: :team_plannin
context 'user is logged in without edit permission' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
sign_in(user2)
visit(project_issue_path(project, issue))
diff --git a/spec/features/issues/user_uses_quick_actions_spec.rb b/spec/features/issues/user_uses_quick_actions_spec.rb
index 963f1c56fef..e85a521e242 100644
--- a/spec/features/issues/user_uses_quick_actions_spec.rb
+++ b/spec/features/issues/user_uses_quick_actions_spec.rb
@@ -8,7 +8,7 @@ require 'spec_helper'
# Because this kind of spec takes more time to run there is no need to add new ones
# for each existing quick action unless they test something not tested by existing tests.
RSpec.describe 'Issues > User uses quick actions', :js, feature_category: :team_planning do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
context "issuable common quick actions" do
let(:new_url_opts) { {} }
diff --git a/spec/features/jira_connect/branches_spec.rb b/spec/features/jira_connect/branches_spec.rb
index c90c0d2dda9..25dc14a1dc9 100644
--- a/spec/features/jira_connect/branches_spec.rb
+++ b/spec/features/jira_connect/branches_spec.rb
@@ -75,7 +75,7 @@ RSpec.describe 'Create GitLab branches from Jira', :js, feature_category: :integ
select_listbox_item(source_branch)
fill_in 'Branch name', with: new_branch
- click_on 'Create branch'
+ click_button 'Create branch'
expect(page).to have_text('New branch was successfully created. You can now close this window and return to Jira.')
diff --git a/spec/features/jira_oauth_provider_authorize_spec.rb b/spec/features/jira_oauth_provider_authorize_spec.rb
index a542aaa7619..e873d9c219f 100644
--- a/spec/features/jira_oauth_provider_authorize_spec.rb
+++ b/spec/features/jira_oauth_provider_authorize_spec.rb
@@ -4,18 +4,39 @@ require 'spec_helper'
RSpec.describe 'JIRA OAuth Provider', feature_category: :integrations do
describe 'JIRA DVCS OAuth Authorization' do
- let(:application) { create(:oauth_application, redirect_uri: oauth_jira_dvcs_callback_url, scopes: 'read_user') }
+ let_it_be(:application) do
+ create(:oauth_application, redirect_uri: oauth_jira_dvcs_callback_url, scopes: 'read_user')
+ end
+
+ let(:authorize_path) do
+ oauth_jira_dvcs_authorize_path(client_id: application.uid,
+ redirect_uri: oauth_jira_dvcs_callback_url,
+ response_type: 'code',
+ state: 'my_state',
+ scope: 'read_user')
+ end
before do
sign_in(user)
+ end
- visit oauth_jira_dvcs_authorize_path(client_id: application.uid,
- redirect_uri: oauth_jira_dvcs_callback_url,
- response_type: 'code',
- state: 'my_state',
- scope: 'read_user')
+ it_behaves_like 'Secure OAuth Authorizations' do
+ before do
+ visit authorize_path
+ end
end
- it_behaves_like 'Secure OAuth Authorizations'
+ context 'when the flag is disabled' do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ stub_feature_flags(jira_dvcs_end_of_life_amnesty: false)
+ visit authorize_path
+ end
+
+ it 'presents as an endpoint that does not exist' do
+ expect(page).to have_gitlab_http_status(:not_found)
+ end
+ end
end
end
diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb
index d6e607e80df..e8f40a1ceab 100644
--- a/spec/features/labels_hierarchy_spec.rb
+++ b/spec/features/labels_hierarchy_spec.rb
@@ -157,28 +157,71 @@ RSpec.describe 'Labels Hierarchy', :js, feature_category: :team_planning do
end
end
- context 'when creating new issuable' do
+ context 'with the visible_label_selection_on_metadata feature flag enabled' do
before do
- visit new_project_issue_path(project_1)
+ stub_feature_flags(visible_label_selection_on_metadata: true)
end
- it 'is able to assign ancestor group labels' do
- fill_in 'issue_title', with: 'new created issue'
- fill_in 'issue_description', with: 'new issue description'
+ context 'when creating new issuable' do
+ before do
+ visit new_project_issue_path(project_1)
+ end
+
+ it 'is able to assign ancestor group labels' do
+ fill_in 'issue_title', with: 'new created issue'
+ fill_in 'issue_description', with: 'new issue description'
+
+ click_button _('Select label')
+
+ wait_for_all_requests
+
+ page.within '[data-testid="sidebar-labels"]' do
+ click_button grandparent_group_label.title
+ click_button parent_group_label.title
+ click_button project_label_1.title
+ click_button _('Close')
+
+ wait_for_requests
+ end
+
+ find('.btn-confirm').click
+
+ expect(page.find('.issue-details h1.title')).to have_content('new created issue')
+ expect(page).to have_selector('span.gl-label-text', text: grandparent_group_label.title)
+ expect(page).to have_selector('span.gl-label-text', text: parent_group_label.title)
+ expect(page).to have_selector('span.gl-label-text', text: project_label_1.title)
+ end
+ end
+ end
- find(".js-label-select").click
- wait_for_requests
+ context 'with the visible_label_selection_on_metadata feature flag disabled' do
+ before do
+ stub_feature_flags(visible_label_selection_on_metadata: false)
+ end
- find('a.label-item', text: grandparent_group_label.title).click
- find('a.label-item', text: parent_group_label.title).click
- find('a.label-item', text: project_label_1.title).click
+ context 'when creating new issuable' do
+ before do
+ visit new_project_issue_path(project_1)
+ end
+
+ it 'is able to assign ancestor group labels' do
+ fill_in 'issue_title', with: 'new created issue'
+ fill_in 'issue_description', with: 'new issue description'
+
+ find(".js-label-select").click
+ wait_for_requests
- find('.btn-confirm').click
+ find('a.label-item', text: grandparent_group_label.title).click
+ find('a.label-item', text: parent_group_label.title).click
+ find('a.label-item', text: project_label_1.title).click
- expect(page.find('.issue-details h1.title')).to have_content('new created issue')
- expect(page).to have_selector('span.gl-label-text', text: grandparent_group_label.title)
- expect(page).to have_selector('span.gl-label-text', text: parent_group_label.title)
- expect(page).to have_selector('span.gl-label-text', text: project_label_1.title)
+ find('.btn-confirm').click
+
+ expect(page.find('.issue-details h1.title')).to have_content('new created issue')
+ expect(page).to have_selector('span.gl-label-text', text: grandparent_group_label.title)
+ expect(page).to have_selector('span.gl-label-text', text: parent_group_label.title)
+ expect(page).to have_selector('span.gl-label-text', text: project_label_1.title)
+ end
end
end
diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb
index 6e62aa892bb..a31ad5a868e 100644
--- a/spec/features/markdown/markdown_spec.rb
+++ b/spec/features/markdown/markdown_spec.rb
@@ -68,7 +68,7 @@ RSpec.describe 'GitLab Markdown', :aggregate_failures, feature_category: :team_p
end
aggregate_failures 'parses mermaid code block' do
- expect(doc).to have_selector('pre[lang=mermaid] > code.js-render-mermaid')
+ expect(doc).to have_selector('pre[data-canonical-lang=mermaid] > code.js-render-mermaid')
end
aggregate_failures 'parses strikethroughs' do
@@ -250,6 +250,7 @@ RSpec.describe 'GitLab Markdown', :aggregate_failures, feature_category: :team_p
aggregate_failures 'all reference filters' do
expect(doc).to reference_users
expect(doc).to reference_issues
+ expect(doc).to reference_work_items
expect(doc).to reference_merge_requests
expect(doc).to reference_snippets
expect(doc).to reference_commit_ranges
@@ -345,6 +346,7 @@ RSpec.describe 'GitLab Markdown', :aggregate_failures, feature_category: :team_p
aggregate_failures 'all reference filters' do
expect(doc).to reference_users
expect(doc).to reference_issues
+ expect(doc).to reference_work_items
expect(doc).to reference_merge_requests
expect(doc).to reference_snippets
expect(doc).to reference_commit_ranges
diff --git a/spec/features/markdown/metrics_spec.rb b/spec/features/markdown/metrics_spec.rb
index b5e42b16f87..1b68f78e993 100644
--- a/spec/features/markdown/metrics_spec.rb
+++ b/spec/features/markdown/metrics_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Metrics rendering', :js, :kubeclient, :use_clean_rails_memory_store_caching, :sidekiq_inline, feature_category: :team_planning do
+RSpec.describe 'Metrics rendering', :js, :kubeclient, :use_clean_rails_memory_store_caching, :sidekiq_inline, feature_category: :metrics do
include PrometheusHelpers
include KubernetesHelpers
include GrafanaApiHelpers
@@ -17,6 +17,7 @@ RSpec.describe 'Metrics rendering', :js, :kubeclient, :use_clean_rails_memory_st
let(:metrics_url) { urls.metrics_project_environment_url(project, environment) }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
clear_host_from_memoized_variables
stub_gitlab_domain
@@ -28,6 +29,20 @@ RSpec.describe 'Metrics rendering', :js, :kubeclient, :use_clean_rails_memory_st
clear_host_from_memoized_variables
end
+ shared_examples_for 'metrics dashboard unavailable' do
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'shows no embedded metrics' do
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_no_css('div.prometheus-graph')
+ end
+ end
+ end
+
context 'internal metrics embeds' do
before do
import_common_metrics
@@ -36,6 +51,8 @@ RSpec.describe 'Metrics rendering', :js, :kubeclient, :use_clean_rails_memory_st
allow(Prometheus::ProxyService).to receive(:new).and_call_original
end
+ include_examples 'metrics dashboard unavailable'
+
it 'shows embedded metrics' do
visit project_issue_path(project, issue)
@@ -50,6 +67,20 @@ RSpec.describe 'Metrics rendering', :js, :kubeclient, :use_clean_rails_memory_st
.at_least(:once)
end
+ context 'with remove_monitor_metrics flag enabled' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'does not show embedded metrics' do
+ visit project_issue_path(project, issue)
+
+ expect(page).not_to have_css('div.prometheus-graph')
+ expect(page).not_to have_text('Memory Usage (Total)')
+ expect(page).not_to have_text('Core Usage (Total)')
+ end
+ end
+
context 'when dashboard params are in included the url' do
let(:metrics_url) { urls.metrics_project_environment_url(project, environment, **chart_params) }
@@ -120,7 +151,9 @@ RSpec.describe 'Metrics rendering', :js, :kubeclient, :use_clean_rails_memory_st
allow(Grafana::ProxyService).to receive(:new).and_call_original
end
- it 'shows embedded metrics' do
+ include_examples 'metrics dashboard unavailable'
+
+ it 'shows embedded metrics', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/402973' do
visit project_issue_path(project, issue)
expect(page).to have_css('div.prometheus-graph')
@@ -157,6 +190,8 @@ RSpec.describe 'Metrics rendering', :js, :kubeclient, :use_clean_rails_memory_st
stub_any_prometheus_request_with_response
end
+ include_examples 'metrics dashboard unavailable'
+
it 'shows embedded metrics' do
visit project_issue_path(project, issue)
@@ -186,6 +221,8 @@ RSpec.describe 'Metrics rendering', :js, :kubeclient, :use_clean_rails_memory_st
let(:metrics_url) { urls.namespace_project_cluster_url(*params, **query_params) }
let(:description) { "# Summary \n[](#{metrics_url})" }
+ include_examples 'metrics dashboard unavailable'
+
it 'shows embedded metrics' do
visit project_issue_path(project, issue)
diff --git a/spec/features/markdown/observability_spec.rb b/spec/features/markdown/observability_spec.rb
index 86caf3eb1b1..ec414d4396e 100644
--- a/spec/features/markdown/observability_spec.rb
+++ b/spec/features/markdown/observability_spec.rb
@@ -2,82 +2,44 @@
require 'spec_helper'
-RSpec.describe 'Observability rendering', :js do
+RSpec.describe 'Observability rendering', :js, feature_category: :metrics do
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :repository, group: group) }
let_it_be(:user) { create(:user) }
- let_it_be(:observable_url) { "https://observe.gitlab.com/" }
-
- let_it_be(:expected) do
- %(<iframe src="#{observable_url}?theme=light&amp;kiosk" frameborder="0")
- end
+ let_it_be(:observable_url) { "https://www.gitlab.com/groups/#{group.path}/-/observability/explore?observability_path=/explore?foo=bar" }
+ let_it_be(:expected_observable_url) { "https://observe.gitlab.com/-/#{group.id}/explore?foo=bar" }
before do
- project.add_maintainer(user)
+ stub_config_setting(url: "https://www.gitlab.com")
+ group.add_developer(user)
sign_in(user)
end
- context 'when embedding in an issue' do
- let(:issue) do
- create(:issue, project: project, description: observable_url)
- end
-
- before do
- visit project_issue_path(project, issue)
- wait_for_requests
- end
-
- it 'renders iframe in description' do
- page.within('.description') do
- expect(page.html).to include(expected)
- end
- end
-
- it 'renders iframe in comment' do
- expect(page).not_to have_css('.note-text')
-
- page.within('.js-main-target-form') do
- fill_in('note[note]', with: observable_url)
- click_button('Comment')
+ context 'when user is a developer of the embedded group' do
+ context 'when embedding in an issue' do
+ let(:issue) do
+ create(:issue, project: project, description: observable_url)
end
- wait_for_requests
-
- page.within('.note-text') do
- expect(page.html).to include(expected)
+ before do
+ visit project_issue_path(project, issue)
+ wait_for_requests
end
- end
- end
-
- context 'when embedding in an MR' do
- let(:merge_request) do
- create(:merge_request, source_project: project, target_project: project, description: observable_url)
- end
- before do
- visit merge_request_path(merge_request)
- wait_for_requests
+ it_behaves_like 'embeds observability'
end
- it 'renders iframe in description' do
- page.within('.description') do
- expect(page.html).to include(expected)
+ context 'when embedding in an MR' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project, target_project: project, description: observable_url)
end
- end
- it 'renders iframe in comment' do
- expect(page).not_to have_css('.note-text')
-
- page.within('.js-main-target-form') do
- fill_in('note[note]', with: observable_url)
- click_button('Comment')
+ before do
+ visit merge_request_path(merge_request)
+ wait_for_requests
end
- wait_for_requests
-
- page.within('.note-text') do
- expect(page.html).to include(expected)
- end
+ it_behaves_like 'embeds observability'
end
end
@@ -96,28 +58,7 @@ RSpec.describe 'Observability rendering', :js do
wait_for_requests
end
- it 'does not render iframe in description' do
- page.within('.description') do
- expect(page.html).not_to include(expected)
- expect(page.html).to include(observable_url)
- end
- end
-
- it 'does not render iframe in comment' do
- expect(page).not_to have_css('.note-text')
-
- page.within('.js-main-target-form') do
- fill_in('note[note]', with: observable_url)
- click_button('Comment')
- end
-
- wait_for_requests
-
- page.within('.note-text') do
- expect(page.html).not_to include(expected)
- expect(page.html).to include(observable_url)
- end
- end
+ it_behaves_like 'does not embed observability'
end
context 'when embedding in an MR' do
@@ -130,28 +71,7 @@ RSpec.describe 'Observability rendering', :js do
wait_for_requests
end
- it 'does not render iframe in description' do
- page.within('.description') do
- expect(page.html).not_to include(expected)
- expect(page.html).to include(observable_url)
- end
- end
-
- it 'does not render iframe in comment' do
- expect(page).not_to have_css('.note-text')
-
- page.within('.js-main-target-form') do
- fill_in('note[note]', with: observable_url)
- click_button('Comment')
- end
-
- wait_for_requests
-
- page.within('.note-text') do
- expect(page.html).not_to include(expected)
- expect(page.html).to include(observable_url)
- end
- end
+ it_behaves_like 'does not embed observability'
end
end
end
diff --git a/spec/features/markdown/sandboxed_mermaid_spec.rb b/spec/features/markdown/sandboxed_mermaid_spec.rb
index 0282d02d809..f8a535191da 100644
--- a/spec/features/markdown/sandboxed_mermaid_spec.rb
+++ b/spec/features/markdown/sandboxed_mermaid_spec.rb
@@ -53,4 +53,28 @@ RSpec.describe 'Sandboxed Mermaid rendering', :js, feature_category: :team_plann
end
end
end
+
+ context 'in a project milestone' do
+ let(:milestone) { create(:project_milestone, project: project, description: description) }
+
+ it 'includes mermaid frame correctly', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408560' do
+ visit(project_milestone_path(project, milestone))
+
+ wait_for_requests
+
+ expect(page.html).to include(expected)
+ end
+ end
+
+ context 'in a group milestone' do
+ let(:group_milestone) { create(:group_milestone, description: description) }
+
+ it 'includes mermaid frame correctly' do
+ visit(group_milestone_path(group_milestone.group, group_milestone))
+
+ wait_for_requests
+
+ expect(page.html).to include(expected)
+ end
+ end
end
diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb
index ddbcb04fa80..6e98812753a 100644
--- a/spec/features/merge_request/batch_comments_spec.rb
+++ b/spec/features/merge_request/batch_comments_spec.rb
@@ -52,6 +52,8 @@ RSpec.describe 'Merge request > Batch comments', :js, feature_category: :code_re
find('.js-note-delete').click
+ wait_for_requests
+
page.within('.modal') do
click_button('Delete comment', match: :first)
end
@@ -66,6 +68,8 @@ RSpec.describe 'Merge request > Batch comments', :js, feature_category: :code_re
find('.js-note-edit').click
+ wait_for_requests
+
# make sure comment form is in view
execute_script("window.scrollBy(0, 200)")
diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb
index b8dc3af8a6a..c9aa22e396b 100644
--- a/spec/features/merge_request/maintainer_edits_fork_spec.rb
+++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'a maintainer edits files on a source-branch of an MR from a fork', :js, :sidekiq_might_not_need_inline,
feature_category: :code_review_workflow do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
+ include Features::SourceEditorSpecHelpers
include ProjectForksHelper
let(:user) { create(:user, username: 'the-maintainer') }
let(:target_project) { create(:project, :public, :repository) }
diff --git a/spec/features/merge_request/user_accepts_merge_request_spec.rb b/spec/features/merge_request/user_accepts_merge_request_spec.rb
index 8ff0c294b24..e3989a8a192 100644
--- a/spec/features/merge_request/user_accepts_merge_request_spec.rb
+++ b/spec/features/merge_request/user_accepts_merge_request_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
it 'when merge method is set to merge commit' do
visit(merge_request_path(merge_request))
- click_button('Merge')
+ click_merge_button
puts merge_request.short_merged_commit_sha
@@ -31,7 +31,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
visit(merge_request_path(merge_request))
- click_button('Merge')
+ click_merge_button
expect(page).to have_content("Changes merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
end
@@ -41,7 +41,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
visit(merge_request_path(merge_request))
- click_button('Merge')
+ click_merge_button
expect(page).to have_content("Changes merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
end
@@ -55,7 +55,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
it 'accepts a merge request' do
check('Delete source branch')
- click_button('Merge')
+ click_merge_button
expect(page).to have_content('Changes merged into')
expect(page).not_to have_selector('.js-remove-branch-button')
@@ -72,7 +72,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
end
it 'accepts a merge request' do
- click_button('Merge')
+ click_merge_button
expect(page).to have_content('Changes merged into')
expect(page).to have_selector('.js-remove-branch-button')
@@ -90,7 +90,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
it 'accepts a merge request' do
check('Delete source branch')
- click_button('Merge')
+ click_merge_button
expect(page).to have_content('Changes merged into')
expect(page).not_to have_selector('.js-remove-branch-button')
@@ -112,9 +112,15 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
find('[data-testid="widget_edit_commit_message"]').click
fill_in('merge-message-edit', with: 'wow such merge')
- click_button('Merge')
+ click_merge_button
expect(page).to have_selector('.gl-badge', text: 'Merged')
end
end
+
+ def click_merge_button
+ page.within('.mr-state-widget') do
+ click_button 'Merge'
+ end
+ end
end
diff --git a/spec/features/merge_request/user_comments_on_diff_spec.rb b/spec/features/merge_request/user_comments_on_diff_spec.rb
index 66b87148eb2..35e2fa2f89c 100644
--- a/spec/features/merge_request/user_comments_on_diff_spec.rb
+++ b/spec/features/merge_request/user_comments_on_diff_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe 'User comments on a diff', :js, feature_category: :code_review_wo
end
context 'in multiple files' do
- it 'toggles comments' do
+ it 'toggles comments', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/393518' do
first_line_element = find_by_scrolling("[id='#{sample_compare.changes[0][:line_code]}']").find(:xpath, "..")
first_root_element = first_line_element.ancestor('[data-path]')
click_diff_line(first_line_element)
@@ -248,7 +248,7 @@ RSpec.describe 'User comments on a diff', :js, feature_category: :code_review_wo
page.within('.diff-file:nth-of-type(1) .discussion .note') do
find('.more-actions').click
- find('.more-actions .dropdown-menu li', match: :first)
+ find('.more-actions li', match: :first)
find('.js-note-delete').click
end
diff --git a/spec/features/merge_request/user_comments_on_merge_request_spec.rb b/spec/features/merge_request/user_comments_on_merge_request_spec.rb
index 9335615b4c7..e113e305af5 100644
--- a/spec/features/merge_request/user_comments_on_merge_request_spec.rb
+++ b/spec/features/merge_request/user_comments_on_merge_request_spec.rb
@@ -30,6 +30,8 @@ RSpec.describe 'User comments on a merge request', :js, feature_category: :code_
end
end
+ it_behaves_like 'edits content using the content editor'
+
it 'replys to a new comment' do
page.within('.js-main-target-form') do
fill_in('note[note]', with: 'comment 1')
diff --git a/spec/features/merge_request/user_creates_image_diff_notes_spec.rb b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb
index 1d7a3fae371..6d3268ffe3a 100644
--- a/spec/features/merge_request/user_creates_image_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb
@@ -174,7 +174,7 @@ RSpec.describe 'Merge request > User creates image diff notes', :js, feature_cat
end
shared_examples 'onion skin' do
- it 'resets opacity when toggling between view modes' do
+ it 'resets opacity when toggling between view modes', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/393331' do
# Simulate dragging onion-skin slider
drag_and_drop_by(find('.dragger'), -30, 0)
diff --git a/spec/features/merge_request/user_creates_mr_spec.rb b/spec/features/merge_request/user_creates_mr_spec.rb
index 6ee20a08a47..f48315a1636 100644
--- a/spec/features/merge_request/user_creates_mr_spec.rb
+++ b/spec/features/merge_request/user_creates_mr_spec.rb
@@ -9,35 +9,158 @@ RSpec.describe 'Merge request > User creates MR', feature_category: :code_review
stub_licensed_features(multiple_merge_request_assignees: false)
end
- context 'non-fork merge request' do
- include_context 'merge request create context'
- it_behaves_like 'a creatable merge request'
- end
+ shared_examples 'a creatable merge request with visible selected labels' do
+ include WaitForRequests
+ include ListboxHelpers
+
+ it 'creates new merge request', :js do
+ find('[data-testid="assignee-ids-dropdown-toggle"]').click
+ page.within '.dropdown-menu-user' do
+ click_link user2.name
+ end
+
+ expect(find('input[name="merge_request[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s)
+ page.within '[data-testid="assignee-ids-dropdown-toggle"]' do
+ expect(page).to have_content user2.name
+ end
+
+ click_link 'Assign to me'
+
+ expect(find('input[name="merge_request[assignee_ids][]"]', visible: false).value).to match(user.id.to_s)
+ page.within '[data-testid="assignee-ids-dropdown-toggle"]' do
+ expect(page).to have_content user.name
+ end
+
+ click_button 'Select milestone'
+ click_button milestone.title
+ expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
+ expect(page).to have_button milestone.title
+
+ click_button _('Select label')
+ wait_for_all_requests
+ page.within '[data-testid="sidebar-labels"]' do
+ click_button label.title
+ click_button label2.title
+ click_button _('Close')
+ wait_for_requests
+ page.within('[data-testid="embedded-labels-list"]') do
+ expect(page).to have_content(label.title)
+ expect(page).to have_content(label2.title)
+ end
+ end
+
+ click_button 'Create merge request'
+
+ page.within '.issuable-sidebar' do
+ page.within '.assignee' do
+ expect(page).to have_content user.name
+ end
- context 'from a forked project' do
- let(:canonical_project) { create(:project, :public, :repository) }
+ page.within '.milestone' do
+ expect(page).to have_content milestone.title
+ end
- let(:source_project) do
- fork_project(canonical_project, user,
- repository: true,
- namespace: user.namespace)
+ page.within '.labels' do
+ expect(page).to have_content label.title
+ expect(page).to have_content label2.title
+ end
+ end
end
- context 'to canonical project' do
+ it 'updates the branches when selecting a new target project', :js do
+ target_project_member = target_project.first_owner
+ ::Branches::CreateService.new(target_project, target_project_member)
+ .execute('a-brand-new-branch-to-test', 'master')
+
+ visit project_new_merge_request_path(source_project)
+
+ first('.js-target-project').click
+ select_listbox_item(target_project.full_path)
+
+ wait_for_requests
+
+ first('.js-target-branch').click
+
+ find('.gl-listbox-search-input').set('a-brand-new-branch-to-test')
+
+ wait_for_requests
+
+ expect_listbox_item('a-brand-new-branch-to-test')
+ end
+ end
+
+ context 'with the visible_label_selection_on_metadata feature flag enabled' do
+ before do
+ stub_feature_flags(visible_label_selection_on_metadata: true)
+ end
+
+ context 'non-fork merge request' do
include_context 'merge request create context'
- it_behaves_like 'a creatable merge request'
+ it_behaves_like 'a creatable merge request with visible selected labels'
end
- context 'to another forked project' do
- let(:target_project) do
+ context 'from a forked project' do
+ let(:canonical_project) { create(:project, :public, :repository) }
+
+ let(:source_project) do
fork_project(canonical_project, user,
repository: true,
namespace: user.namespace)
end
+ context 'to canonical project' do
+ include_context 'merge request create context'
+ it_behaves_like 'a creatable merge request with visible selected labels'
+ end
+
+ context 'to another forked project' do
+ let(:target_project) do
+ fork_project(canonical_project, user,
+ repository: true,
+ namespace: user.namespace)
+ end
+
+ include_context 'merge request create context'
+ it_behaves_like 'a creatable merge request with visible selected labels'
+ end
+ end
+ end
+
+ context 'with the visible_label_selection_on_metadata feature flag disabled' do
+ before do
+ stub_feature_flags(visible_label_selection_on_metadata: false)
+ end
+
+ context 'non-fork merge request' do
include_context 'merge request create context'
it_behaves_like 'a creatable merge request'
end
+
+ context 'from a forked project' do
+ let(:canonical_project) { create(:project, :public, :repository) }
+
+ let(:source_project) do
+ fork_project(canonical_project, user,
+ repository: true,
+ namespace: user.namespace)
+ end
+
+ context 'to canonical project' do
+ include_context 'merge request create context'
+ it_behaves_like 'a creatable merge request'
+ end
+
+ context 'to another forked project' do
+ let(:target_project) do
+ fork_project(canonical_project, user,
+ repository: true,
+ namespace: user.namespace)
+ end
+
+ include_context 'merge request create context'
+ it_behaves_like 'a creatable merge request'
+ end
+ end
end
context 'source project', :js do
diff --git a/spec/features/merge_request/user_edits_assignees_sidebar_spec.rb b/spec/features/merge_request/user_edits_assignees_sidebar_spec.rb
index cf5024ad59e..faef4f6f517 100644
--- a/spec/features/merge_request/user_edits_assignees_sidebar_spec.rb
+++ b/spec/features/merge_request/user_edits_assignees_sidebar_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Merge request > User edits assignees sidebar', :js, feature_category: :code_review_workflow do
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::InviteMembersModalHelpers
let(:project) { create(:project, :public, :repository) }
let(:protected_branch) { create(:protected_branch, :maintainers_can_push, name: 'master', project: project) }
@@ -162,8 +162,6 @@ RSpec.describe 'Merge request > User edits assignees sidebar', :js, feature_cate
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite members')
- expect(page).to have_selector('[data-track-action="click_invite_members"]')
- expect(page).to have_selector('[data-track-label="edit_assignee"]')
click_link 'Invite members'
end
diff --git a/spec/features/merge_request/user_edits_merge_request_spec.rb b/spec/features/merge_request/user_edits_merge_request_spec.rb
index 839081d00dc..584a17ae33d 100644
--- a/spec/features/merge_request/user_edits_merge_request_spec.rb
+++ b/spec/features/merge_request/user_edits_merge_request_spec.rb
@@ -108,4 +108,6 @@ RSpec.describe 'User edits a merge request', :js, feature_category: :code_review
end
end
end
+
+ it_behaves_like 'edits content using the content editor'
end
diff --git a/spec/features/merge_request/user_edits_mr_spec.rb b/spec/features/merge_request/user_edits_mr_spec.rb
index 6fcbfd309e2..76588832ee1 100644
--- a/spec/features/merge_request/user_edits_mr_spec.rb
+++ b/spec/features/merge_request/user_edits_mr_spec.rb
@@ -5,19 +5,233 @@ require 'spec_helper'
RSpec.describe 'Merge request > User edits MR', feature_category: :code_review_workflow do
include ProjectForksHelper
+ shared_examples 'an editable merge request with visible selected labels' do
+ it 'updates merge request', :js do
+ find('.js-assignee-search').click
+ page.within '.dropdown-menu-user' do
+ click_link user.name
+ end
+ expect(find('input[name="merge_request[assignee_ids][]"]', visible: false).value).to match(user.id.to_s)
+ page.within '.js-assignee-search' do
+ expect(page).to have_content user.name
+ end
+
+ find('.js-reviewer-search').click
+ page.within '.dropdown-menu-user' do
+ click_link user.name
+ end
+ expect(find('input[name="merge_request[reviewer_ids][]"]', visible: false).value).to match(user.id.to_s)
+ page.within '.js-reviewer-search' do
+ expect(page).to have_content user.name
+ end
+
+ click_button 'Select milestone'
+ click_button milestone.title
+ expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
+ expect(page).to have_button milestone.title
+
+ click_button _('Select label')
+ wait_for_all_requests
+ page.within '[data-testid="sidebar-labels"]' do
+ click_button label.title
+ click_button label2.title
+ click_button _('Close')
+ wait_for_requests
+ page.within('[data-testid="embedded-labels-list"]') do
+ expect(page).to have_content(label.title)
+ expect(page).to have_content(label2.title)
+ end
+ end
+
+ click_button 'Save changes'
+
+ page.within '.issuable-sidebar' do
+ page.within '.assignee' do
+ expect(page).to have_content user.name
+ end
+
+ page.within '.reviewer' do
+ expect(page).to have_content user.name
+ end
+
+ page.within '.milestone' do
+ expect(page).to have_content milestone.title
+ end
+
+ page.within '.labels' do
+ expect(page).to have_content label.title
+ expect(page).to have_content label2.title
+ end
+ end
+ end
+
+ it 'description has autocomplete', :js do
+ find('#merge_request_description').native.send_keys('')
+ fill_in 'merge_request_description', with: user.to_reference[0..4]
+
+ page.within('.atwho-view') do
+ expect(page).to have_content(user2.name)
+ end
+ end
+
+ it 'description has quick action autocomplete', :js do
+ find('#merge_request_description').native.send_keys('/')
+
+ expect(page).to have_selector('.atwho-container')
+ end
+
+ it 'has class js-quick-submit in form' do
+ expect(page).to have_selector('.js-quick-submit')
+ end
+
+ it 'warns about version conflict', :js do
+ merge_request.update!(title: "New title")
+
+ fill_in 'merge_request_title', with: 'bug 345'
+ fill_in 'merge_request_description', with: 'bug description'
+
+ click_button _('Save changes')
+
+ expect(page).to have_content(
+ format(
+ _("Someone edited this %{model_name} at the same time you did. Please check out the %{link_to_model} and make sure your changes will not unintentionally remove theirs."), # rubocop:disable Layout/LineLength
+ model_name: _('merge request'),
+ link_to_model: _('merge request')
+ )
+ )
+ end
+
+ it 'preserves description textarea height', :js do
+ long_description = %q(
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Etiam ac ornare ligula, ut tempus arcu.
+ Etiam ultricies accumsan dolor vitae faucibus.
+ Donec at elit lacus.
+ Mauris orci ante, aliquam quis lorem eget, convallis faucibus arcu.
+ Aenean at pulvinar lacus.
+ Ut viverra quam massa, molestie ornare tortor dignissim a.
+ Suspendisse tristique pellentesque tellus, id lacinia metus elementum id.
+ Nam tristique, arcu rhoncus faucibus viverra, lacus ipsum sagittis ligula, vitae convallis odio lacus a nibh.
+ Ut tincidunt est purus, ac vestibulum augue maximus in.
+ Suspendisse vel erat et mi ultricies semper.
+ Pellentesque volutpat pellentesque consequat.
+
+ Cras congue nec ligula tristique viverra.
+ Curabitur fringilla fringilla fringilla.
+ Donec rhoncus dignissim orci ut accumsan.
+ Ut rutrum urna a rhoncus varius.
+ Maecenas blandit, mauris nec accumsan gravida, augue nibh finibus magna, sed maximus turpis libero nec neque
+ Suspendisse at semper est.
+ Nunc imperdiet dapibus dui, varius sollicitudin erat luctus non.
+ Sed pellentesque ligula eget posuere facilisis.
+ Donec dictum commodo volutpat.
+ Donec egestas dui ac magna sollicitudin bibendum.
+ Vivamus purus neque, ullamcorper ac feugiat et, tempus sit amet metus.
+ Praesent quis viverra neque.
+ Sed bibendum viverra est, eu aliquam mi ornare vitae.
+ Proin et dapibus ipsum.
+ Nunc tortor diam, malesuada nec interdum vel, placerat quis justo.
+ Ut viverra at erat eu laoreet.
+
+ Pellentesque commodo, diam sit amet dignissim condimentum, tortor justo pretium est,
+ non venenatis metus eros ut nunc.
+ Etiam ut neque eget sem dapibus aliquam.
+ Curabitur vel elit lorem.
+ Nulla nec enim elit.
+ Sed ut ex id justo facilisis convallis at ac augue.
+ Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae;
+ Nullam cursus egestas turpis non tristique.
+ Suspendisse in erat sem.
+ Fusce libero elit, fermentum gravida mauris id, auctor iaculis felis.
+ Nullam vulputate tempor laoreet.
+
+ Nam tempor et magna sed convallis.
+ Fusce sit amet sollicitudin risus, a ullamcorper lacus.
+ Morbi gravida quis sem eget porttitor.
+ Donec eu egestas mauris, in elementum tortor.
+ Sed eget ex mi.
+ Mauris iaculis tortor ut est auctor, nec dignissim quam sagittis.
+ Suspendisse vel metus non quam suscipit tincidunt.
+ Cras molestie lacus non justo finibus sodales quis vitae erat.
+ In a porttitor nisi, id sollicitudin urna.
+ Ut at felis tellus.
+ Suspendisse potenti.
+
+ Maecenas leo ligula, varius at neque vitae, ornare maximus justo.
+ Nullam convallis luctus risus et vulputate.
+ Duis suscipit faucibus iaculis.
+ Etiam quis tortor faucibus, tristique tellus sit amet, sodales neque.
+ Nulla dapibus nisi vel aliquet consequat.
+ Etiam faucibus, metus eget condimentum iaculis, enim urna lobortis sem, id efficitur eros sapien nec nisi.
+ Aenean ut finibus ex.
+ )
+
+ fill_in 'merge_request_description', with: long_description
+
+ height = get_textarea_height
+ click_button("Preview")
+ click_button("Continue editing")
+ new_height = get_textarea_height
+
+ expect(height).to eq(new_height)
+ end
+
+ context 'when "Remove source branch" is set' do
+ before do
+ merge_request.update!(merge_params: { 'force_remove_source_branch' => '1' })
+ end
+
+ it 'allows to unselect "Remove source branch"', :js do
+ expect(merge_request.merge_params['force_remove_source_branch']).to be_truthy
+
+ visit edit_project_merge_request_path(target_project, merge_request)
+ uncheck 'Delete source branch when merge request is accepted'
+
+ click_button 'Save changes'
+
+ expect(page).to have_unchecked_field 'remove-source-branch-input'
+ expect(page).to have_content 'Delete source branch'
+ end
+ end
+ end
+
before do
stub_licensed_features(multiple_merge_request_assignees: false)
end
- context 'non-fork merge request' do
- include_context 'merge request edit context'
- it_behaves_like 'an editable merge request'
+ context 'with the visible_label_selection_on_metadata feature flag enabled' do
+ before do
+ stub_feature_flags(visible_label_selection_on_metadata: true)
+ end
+
+ context 'non-fork merge request' do
+ include_context 'merge request edit context'
+ it_behaves_like 'an editable merge request with visible selected labels'
+ end
+
+ context 'for a forked project' do
+ let(:source_project) { fork_project(target_project, nil, repository: true) }
+
+ include_context 'merge request edit context'
+ it_behaves_like 'an editable merge request with visible selected labels'
+ end
end
- context 'for a forked project' do
- let(:source_project) { fork_project(target_project, nil, repository: true) }
+ context 'with the visible_label_selection_on_metadata feature flag disabled' do
+ before do
+ stub_feature_flags(visible_label_selection_on_metadata: false)
+ end
+
+ context 'non-fork merge request' do
+ include_context 'merge request edit context'
+ it_behaves_like 'an editable merge request'
+ end
+
+ context 'for a forked project' do
+ let(:source_project) { fork_project(target_project, nil, repository: true) }
- include_context 'merge request edit context'
- it_behaves_like 'an editable merge request'
+ include_context 'merge request edit context'
+ it_behaves_like 'an editable merge request'
+ end
end
end
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 26a9b955e2d..52d058aeabc 100644
--- a/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb
+++ b/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb
@@ -26,8 +26,6 @@ RSpec.describe 'Merge request > User edits reviewers sidebar', :js, feature_cate
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members')
- expect(page).to have_selector('[data-track-action="click_invite_members"]')
- expect(page).to have_selector('[data-track-label="edit_reviewer"]')
end
click_link 'Invite Members'
diff --git a/spec/features/merge_request/user_manages_subscription_spec.rb b/spec/features/merge_request/user_manages_subscription_spec.rb
index d4ccc4a93b5..3bcc8255ab7 100644
--- a/spec/features/merge_request/user_manages_subscription_spec.rb
+++ b/spec/features/merge_request/user_manages_subscription_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'User manages subscription', :js, feature_category: :code_review_workflow do
+ include CookieHelper
+
let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:user) { create(:user) }
@@ -10,7 +12,7 @@ RSpec.describe 'User manages subscription', :js, feature_category: :code_review_
before do
stub_feature_flags(moved_mr_sidebar: moved_mr_sidebar_enabled)
-
+ set_cookie('new-actions-popover-viewed', 'true')
project.add_maintainer(user)
sign_in(user)
diff --git a/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb
index cdc00017ab3..19b5ad0fa84 100644
--- a/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb
+++ b/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb
@@ -13,11 +13,29 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js, fea
context 'project does not have CI enabled' do
it 'allows MR to be merged' do
+ stub_feature_flags(auto_merge_labels_mr_widget: false)
+
visit project_merge_request_path(project, merge_request)
wait_for_requests
- expect(page).to have_button 'Merge'
+ page.within('.mr-state-widget') do
+ expect(page).to have_button 'Merge'
+ end
+ end
+ end
+
+ context 'project does not have CI enabled and auto_merge_labels_mr_widget on' do
+ it 'allows MR to be merged' do
+ stub_feature_flags(auto_merge_labels_mr_widget: true)
+
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ page.within('.mr-state-widget') do
+ expect(page).to have_button 'Merge'
+ end
end
end
@@ -33,6 +51,8 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js, fea
context 'when merge requests can only be merged if the pipeline succeeds' do
before do
project.update_attribute(:only_allow_merge_if_pipeline_succeeds, true)
+
+ stub_feature_flags(auto_merge_labels_mr_widget: false)
end
context 'when CI is running' do
@@ -56,7 +76,78 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js, fea
wait_for_requests
- expect(page).not_to have_button('Merge')
+ expect(page).not_to have_button('Merge', exact: true)
+ expect(page).to have_content('Merge blocked: pipeline must succeed. Push a commit that fixes the failure or learn about other solutions.')
+ end
+ end
+
+ context 'when CI canceled' do
+ let(:status) { :canceled }
+
+ it 'does not allow MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).not_to have_button('Merge', exact: true)
+ expect(page).to have_content('Merge blocked: pipeline must succeed. Push a commit that fixes the failure or learn about other solutions.')
+ end
+ end
+
+ context 'when CI succeeded' do
+ let(:status) { :success }
+
+ it 'allows MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button('Merge', exact: true)
+ end
+ end
+
+ context 'when CI skipped' do
+ let(:status) { :skipped }
+
+ it 'does not allow MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).not_to have_button('Merge', exact: true)
+ end
+ end
+ end
+
+ context 'when merge requests can only be merged if the pipeline succeeds with auto_merge_labels_mr_widget on' do
+ before do
+ project.update_attribute(:only_allow_merge_if_pipeline_succeeds, true)
+
+ stub_feature_flags(auto_merge_labels_mr_widget: true)
+ end
+
+ context 'when CI is running' do
+ let(:status) { :running }
+
+ it 'does not allow to merge immediately' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button 'Set to auto-merge'
+ expect(page).not_to have_button '.js-merge-moment'
+ end
+ end
+
+ context 'when CI failed' do
+ let(:status) { :failed }
+
+ it 'does not allow MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).not_to have_button('Merge', exact: true)
expect(page).to have_content('Merge blocked: pipeline must succeed. Push a commit that fixes the failure or learn about other solutions.')
end
end
@@ -69,7 +160,7 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js, fea
wait_for_requests
- expect(page).not_to have_button 'Merge'
+ expect(page).not_to have_button('Merge', exact: true)
expect(page).to have_content('Merge blocked: pipeline must succeed. Push a commit that fixes the failure or learn about other solutions.')
end
end
@@ -82,7 +173,7 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js, fea
wait_for_requests
- expect(page).to have_button 'Merge'
+ expect(page).to have_button('Merge', exact: true)
end
end
@@ -94,7 +185,7 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js, fea
wait_for_requests
- expect(page).not_to have_button 'Merge'
+ expect(page).not_to have_button('Merge', exact: true)
end
end
end
@@ -102,6 +193,8 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js, fea
context 'when merge requests can be merged when the build failed' do
before do
project.update_attribute(:only_allow_merge_if_pipeline_succeeds, false)
+
+ stub_feature_flags(auto_merge_labels_mr_widget: false)
end
context 'when CI is running' do
@@ -126,8 +219,59 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js, fea
visit project_merge_request_path(project, merge_request)
wait_for_requests
+ page.within('.mr-state-widget') do
+ expect(page).to have_button 'Merge'
+ end
+ end
+ end
- expect(page).to have_button 'Merge'
+ context 'when CI succeeded' do
+ let(:status) { :success }
+
+ it 'allows MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ page.within('.mr-state-widget') do
+ expect(page).to have_button 'Merge'
+ end
+ end
+ end
+ end
+
+ context 'when merge requests can be merged when the build failed with auto_merge_labels_mr_widget on' do
+ before do
+ project.update_attribute(:only_allow_merge_if_pipeline_succeeds, false)
+
+ stub_feature_flags(auto_merge_labels_mr_widget: true)
+ end
+
+ context 'when CI is running' do
+ let(:status) { :running }
+
+ it 'allows MR to be merged immediately' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button 'Set to auto-merge'
+
+ page.find('.js-merge-moment').click
+ expect(page).to have_content 'Merge immediately'
+ end
+ end
+
+ context 'when CI failed' do
+ let(:status) { :failed }
+
+ it 'allows MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+ page.within('.mr-state-widget') do
+ expect(page).to have_button 'Merge'
+ end
end
end
@@ -139,7 +283,9 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js, fea
wait_for_requests
- expect(page).to have_button 'Merge'
+ page.within('.mr-state-widget') do
+ expect(page).to have_button 'Merge'
+ end
end
end
end
diff --git a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
index 6d2c8f15a82..5c00da1f569 100644
--- a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
+++ b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
@@ -26,13 +26,15 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js, featur
context 'when there is active pipeline for merge request' do
before do
create(:ci_build, pipeline: pipeline)
+ stub_feature_flags(auto_merge_labels_mr_widget: false)
+
sign_in(user)
visit project_merge_request_path(project, merge_request)
end
describe 'enabling Merge when pipeline succeeds' do
shared_examples 'Merge when pipeline succeeds activator' do
- it 'activates the Merge when pipeline succeeds feature' do
+ it 'activates the Merge when pipeline succeeds feature', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410105' do
click_button "Merge when pipeline succeeds"
expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds"
@@ -98,6 +100,69 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js, featur
end
end
+ context 'when there is active pipeline for merge request with auto_merge_labels_mr_widget on' do
+ before do
+ create(:ci_build, pipeline: pipeline)
+ stub_feature_flags(auto_merge_labels_mr_widget: true)
+
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ describe 'enabling Merge when pipeline succeeds' do
+ shared_examples 'Set to auto-merge activator' do
+ it 'activates the Merge when pipeline succeeds feature' do
+ click_button "Set to auto-merge"
+
+ expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds"
+ expect(page).to have_content "Source branch will not be deleted"
+ expect(page).to have_selector ".js-cancel-auto-merge"
+ visit project_merge_request_path(project, merge_request) # Needed to refresh the page
+ expect(page).to have_content /enabled an automatic merge when the pipeline for \h{8} succeeds/i
+ end
+ end
+
+ context "when enabled immediately" do
+ it_behaves_like 'Set to auto-merge activator'
+ end
+
+ context 'when enabled after it was previously canceled' do
+ before do
+ click_button "Set to auto-merge"
+
+ wait_for_requests
+
+ click_button "Cancel auto-merge"
+
+ wait_for_requests
+
+ expect(page).to have_content 'Set to auto-merge'
+ end
+
+ it_behaves_like 'Set to auto-merge activator'
+ end
+
+ context 'when it was enabled and then canceled' do
+ let(:merge_request) do
+ create(:merge_request_with_diffs,
+ :merge_when_pipeline_succeeds,
+ source_project: project,
+ title: 'Bug NS-04',
+ author: user,
+ merge_user: user)
+ end
+
+ before do
+ merge_request.merge_params['force_remove_source_branch'] = '0'
+ merge_request.save!
+ click_button "Cancel auto-merge"
+ end
+
+ it_behaves_like 'Set to auto-merge activator'
+ end
+ end
+ end
+
context 'when merge when pipeline succeeds is enabled' do
let(:merge_request) do
create(:merge_request_with_diffs, :simple, :merge_when_pipeline_succeeds,
@@ -112,6 +177,7 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js, featur
end
before do
+ stub_feature_flags(auto_merge_labels_mr_widget: false)
sign_in user
visit project_merge_request_path(project, merge_request)
end
@@ -177,11 +243,53 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js, featur
end
end
+ context 'when merge when pipeline succeeds is enabled and auto_merge_labels_mr_widget on' do
+ let(:merge_request) do
+ create(:merge_request_with_diffs, :simple, :merge_when_pipeline_succeeds,
+ source_project: project,
+ author: user,
+ merge_user: user,
+ title: 'MepMep')
+ end
+
+ let!(:build) do
+ create(:ci_build, pipeline: pipeline)
+ end
+
+ before do
+ stub_feature_flags(auto_merge_labels_mr_widget: true)
+ sign_in user
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'allows to cancel the automatic merge', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410494' do
+ click_button "Cancel auto-merge"
+
+ expect(page).to have_button "Set to auto-merge"
+
+ refresh
+
+ expect(page).to have_content "canceled the automatic merge"
+ end
+ end
+
context 'when pipeline is not active' do
it 'does not allow to enable merge when pipeline succeeds' do
+ stub_feature_flags(auto_merge_labels_mr_widget: false)
+
visit project_merge_request_path(project, merge_request)
expect(page).not_to have_link 'Merge when pipeline succeeds'
end
end
+
+ context 'when pipeline is not active and auto_merge_labels_mr_widget on' do
+ it 'does not allow to enable merge when pipeline succeeds' do
+ stub_feature_flags(auto_merge_labels_mr_widget: true)
+
+ visit project_merge_request_path(project, merge_request)
+
+ expect(page).not_to have_link 'Set to auto-merge'
+ end
+ end
end
diff --git a/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb b/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb
index 7cb1c95f6dc..601310cbacf 100644
--- a/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb
+++ b/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Merge request > User opens checkout branch modal', :js, feature_category: :code_review_workflow do
include ProjectForksHelper
+ include CookieHelper
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
@@ -11,6 +12,7 @@ RSpec.describe 'Merge request > User opens checkout branch modal', :js, feature_
before do
project.add_maintainer(user)
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
end
describe 'for fork' do
diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb
index a74a8b1cd5a..f13c68a60ee 100644
--- a/spec/features/merge_request/user_posts_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb
@@ -103,7 +103,7 @@ RSpec.describe 'Merge request > User posts diff notes', :js, feature_category: :
should_allow_commenting(find_by_scrolling('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
accept_gl_confirm(button_text: 'Delete comment') do
- first('button.more-actions-toggle').click
+ first('.more-actions-toggle button').click
first('.js-note-delete').click
end
diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb
index f167ab8fe8a..03b01ef4b7a 100644
--- a/spec/features/merge_request/user_posts_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -62,7 +62,7 @@ RSpec.describe 'Merge request > User posts notes', :js, feature_category: :code_
before do
page.within('.js-main-target-form') do
fill_in 'note[note]', with: 'This is awesome!'
- find('.js-md-preview-button').click
+ click_button("Preview")
click_button 'Comment'
end
end
@@ -138,7 +138,7 @@ RSpec.describe 'Merge request > User posts notes', :js, feature_category: :code_
it 'hides the toolbar buttons when previewing a note' do
wait_for_requests
- find('.js-md-preview-button').click
+ click_button("Preview")
page.within('.js-main-target-form') do
expect(page).not_to have_css('.md-header-toolbar')
end
diff --git a/spec/features/merge_request/user_resolves_conflicts_spec.rb b/spec/features/merge_request/user_resolves_conflicts_spec.rb
index 7b1afd786f7..0f283f1194f 100644
--- a/spec/features/merge_request/user_resolves_conflicts_spec.rb
+++ b/spec/features/merge_request/user_resolves_conflicts_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Merge request > User resolves conflicts', :js, feature_category: :code_review_workflow do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
+ include Features::SourceEditorSpecHelpers
let(:project) { create(:project, :repository) }
let(:user) { project.creator }
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index c3b9068d708..5da9f4a1f19 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -218,7 +218,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js, feat
end
end
- it 'allows user to quickly scroll to next unresolved thread' do
+ it 'allows user to quickly scroll to next unresolved thread', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410109' do
page.within '.discussions-counter' do
page.find('.discussion-next-btn').click
end
diff --git a/spec/features/merge_request/user_reverts_merge_request_spec.rb b/spec/features/merge_request/user_reverts_merge_request_spec.rb
index 43ce473b407..8c782056aa4 100644
--- a/spec/features/merge_request/user_reverts_merge_request_spec.rb
+++ b/spec/features/merge_request/user_reverts_merge_request_spec.rb
@@ -8,12 +8,15 @@ RSpec.describe 'User reverts a merge request', :js, feature_category: :code_revi
let(:user) { create(:user) }
before do
+ stub_feature_flags(unbatch_graphql_queries: false)
project.add_developer(user)
sign_in(user)
visit(merge_request_path(merge_request))
- click_button('Merge')
+ page.within('.mr-state-widget') do
+ click_button 'Merge'
+ end
wait_for_requests
@@ -34,7 +37,7 @@ RSpec.describe 'User reverts a merge request', :js, feature_category: :code_revi
revert_commit
- expect(page).to have_content('Sorry, we cannot revert this merge request automatically.')
+ expect(page).to have_content('Merge request revert failed:')
end
it 'reverts a merge request in a new merge request', :sidekiq_might_not_need_inline do
diff --git a/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb b/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb
index ad2ceeb23e2..21c62b0d0d8 100644
--- a/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb
+++ b/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Merge request > User sees check out branch modal', :js, feature_category: :code_review_workflow do
+ include CookieHelper
+
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
let(:merge_request) { create(:merge_request, source_project: project) }
@@ -10,6 +12,7 @@ RSpec.describe 'Merge request > User sees check out branch modal', :js, feature_
before do
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_merge_request_path(project, merge_request)
wait_for_requests
diff --git a/spec/features/merge_request/user_sees_diff_spec.rb b/spec/features/merge_request/user_sees_diff_spec.rb
index 12fdcf4859e..3fb3ef12fcc 100644
--- a/spec/features/merge_request/user_sees_diff_spec.rb
+++ b/spec/features/merge_request/user_sees_diff_spec.rb
@@ -60,7 +60,7 @@ RSpec.describe 'Merge request > User sees diff', :js, feature_category: :code_re
visit diffs_project_merge_request_path(project, merge_request)
page.within('.gl-alert') do
- expect(page).to have_text("Too many changes to show. To preserve performance only 3 of 3+ files are displayed. Plain diff Email patch")
+ expect(page).to have_text("Some changes are not shown. For a faster browsing experience, only 3 of 3+ files are shown. Download one of the files below to see all changes. Plain diff Patches")
end
end
end
diff --git a/spec/features/merge_request/user_sees_discussions_navigation_spec.rb b/spec/features/merge_request/user_sees_discussions_navigation_spec.rb
index 6e6c2cddfbf..5f815bffb22 100644
--- a/spec/features/merge_request/user_sees_discussions_navigation_spec.rb
+++ b/spec/features/merge_request/user_sees_discussions_navigation_spec.rb
@@ -2,7 +2,9 @@
require 'spec_helper'
-RSpec.describe 'Merge request > User sees discussions navigation', :js, feature_category: :code_review_workflow do
+RSpec.describe 'Merge request > User sees discussions navigation',
+ :js, feature_category: :code_review_workflow,
+ quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410678' do
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:user) { project.creator }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
@@ -42,7 +44,7 @@ RSpec.describe 'Merge request > User sees discussions navigation', :js, feature_
shared_examples 'a page with a thread navigation' do
context 'with active threads' do
- it 'navigates to the first thread' do
+ it 'navigates to the first thread', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410144' do
goto_next_thread
expect(page).to have_selector(first_discussion_selector, obscured: false)
end
@@ -52,7 +54,7 @@ RSpec.describe 'Merge request > User sees discussions navigation', :js, feature_
expect(page).to have_selector(second_discussion_selector, obscured: false)
end
- it 'navigates through active threads' do
+ it 'navigates through active threads', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391912' do
goto_next_thread
goto_next_thread
expect(page).to have_selector(second_discussion_selector, obscured: false)
diff --git a/spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb b/spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb
index b83580565e4..476be5ab599 100644
--- a/spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb
@@ -21,7 +21,7 @@ feature_category: :code_review_workflow do
context 'with unresolved threads' do
it 'does not allow to merge' do
- expect(page).not_to have_button 'Merge'
+ expect(page).not_to have_button('Merge', exact: true)
expect(page).to have_content('all threads must be resolved')
end
end
@@ -33,7 +33,7 @@ feature_category: :code_review_workflow do
end
it 'allows MR to be merged' do
- expect(page).to have_button 'Merge'
+ expect(page).to have_button('Merge', exact: true)
end
end
end
@@ -46,7 +46,7 @@ feature_category: :code_review_workflow do
context 'with unresolved threads' do
it 'does not allow to merge' do
- expect(page).to have_button 'Merge'
+ expect(page).to have_button('Merge', exact: true)
end
end
@@ -57,7 +57,7 @@ feature_category: :code_review_workflow do
end
it 'allows MR to be merged' do
- expect(page).to have_button 'Merge'
+ expect(page).to have_button('Merge', exact: true)
end
end
end
diff --git a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
index 458746f0854..7d024103943 100644
--- a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
@@ -56,6 +56,8 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
end
before do
+ stub_feature_flags(auto_merge_labels_mr_widget: false)
+
visit project_merge_request_path(project, merge_request)
page.within('.merge-request-tabs') do
@@ -185,6 +187,48 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
end
end
+ context 'when a user created a merge request in the parent project with auto_merge_labels_mr_widget on' do
+ before do
+ stub_feature_flags(auto_merge_labels_mr_widget: true)
+
+ visit project_merge_request_path(project, merge_request)
+
+ page.within('.merge-request-tabs') do
+ click_link('Pipelines')
+ end
+ end
+
+ context 'when a user merges a merge request in the parent project', :sidekiq_might_not_need_inline do
+ before do
+ click_link 'Overview'
+ click_button 'Set to auto-merge'
+
+ wait_for_requests
+ end
+
+ context 'when detached merge request pipeline is pending' do
+ it 'waits the head pipeline' do
+ expect(page).to have_content('to be merged automatically when the pipeline succeeds')
+ expect(page).to have_button('Cancel auto-merge')
+ end
+ end
+
+ context 'when branch pipeline succeeds' do
+ before do
+ click_link 'Overview'
+ push_pipeline.reload.succeed!
+
+ wait_for_requests
+ end
+
+ it 'waits the head pipeline' do
+ expect(page).to have_content('to be merged automatically when the pipeline succeeds')
+ expect(page).to have_button('Cancel auto-merge')
+ end
+ end
+ end
+ end
+
context 'when there are no `merge_requests` keyword in .gitlab-ci.yml' do
let(:config) do
{
@@ -244,6 +288,8 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
before do
forked_project.add_maintainer(user2)
+ stub_feature_flags(auto_merge_labels_mr_widget: false)
+
visit project_merge_request_path(project, merge_request)
page.within('.merge-request-tabs') do
diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb
index acf2893b513..cb56e79fcc0 100644
--- a/spec/features/merge_request/user_sees_merge_widget_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb
@@ -53,6 +53,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category:
let!(:deployment) { build.deployment }
before do
+ stub_feature_flags(unbatch_graphql_queries: false)
merge_request.update!(head_pipeline: pipeline)
deployment.update!(status: :success)
visit project_merge_request_path(project, merge_request)
@@ -396,7 +397,9 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category:
end
it 'updates the MR widget', :sidekiq_might_not_need_inline do
- click_button 'Merge'
+ page.within('.mr-state-widget') do
+ click_button 'Merge'
+ end
expect(page).to have_content('An error occurred while merging')
end
@@ -452,7 +455,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category:
wait_for_requests
- expect(page).not_to have_button('Merge')
+ expect(page).not_to have_button('Merge', exact: true)
expect(page).to have_content('Merging!')
end
end
diff --git a/spec/features/merge_request/user_sees_pipelines_spec.rb b/spec/features/merge_request/user_sees_pipelines_spec.rb
index cab940ba704..f92ce3865a9 100644
--- a/spec/features/merge_request/user_sees_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_pipelines_spec.rb
@@ -15,27 +15,40 @@ RSpec.describe 'Merge request > User sees pipelines', :js, feature_category: :co
context 'with pipelines' do
let!(:pipeline) do
- create(:ci_empty_pipeline,
+ create(:ci_pipeline,
+ :success,
project: merge_request.source_project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha)
end
+ let!(:manual_job) { create(:ci_build, :manual, name: 'job1', stage: 'deploy', pipeline: pipeline) }
+
+ let!(:job) { create(:ci_build, :success, name: 'job2', stage: 'test', pipeline: pipeline) }
+
before do
merge_request.update_attribute(:head_pipeline_id, pipeline.id)
end
- it 'user visits merge request pipelines tab' do
+ it 'pipelines table displays correctly' do
visit project_merge_request_path(project, merge_request)
- expect(page.find('.ci-widget')).to have_content('pending')
+ expect(page.find('.ci-widget')).to have_content('passed')
page.within('.merge-request-tabs') do
click_link('Pipelines')
end
+
wait_for_requests
- expect(page).to have_css('[data-testid="pipeline-mini-graph"]')
+ page.within('[data-testid="pipeline-table-row"]') do
+ expect(page).to have_selector('.ci-success')
+ expect(page).to have_content(pipeline.id)
+ expect(page).to have_content('API')
+ expect(page).to have_css('[data-testid="pipeline-mini-graph"]')
+ expect(page).to have_css('[data-testid="pipelines-manual-actions-dropdown"]')
+ expect(page).to have_css('[data-testid="pipeline-multi-actions-dropdown"]')
+ end
end
context 'with a detached merge request pipeline' do
diff --git a/spec/features/merge_request/user_sees_real_time_reviewers_spec.rb b/spec/features/merge_request/user_sees_real_time_reviewers_spec.rb
new file mode 100644
index 00000000000..e967787d2c7
--- /dev/null
+++ b/spec/features/merge_request/user_sees_real_time_reviewers_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Merge request > Real-time reviewers', feature_category: :code_review_workflow do
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:merge_request) { create(:merge_request, :simple, source_project: project, author: user) }
+
+ before do
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'updates in real-time', :js do
+ wait_for_requests
+
+ # Simulate a real-time update of reviewers
+ merge_request.update!(reviewer_ids: [user.id])
+ GraphqlTriggers.merge_request_reviewers_updated(merge_request)
+
+ expect(find('.reviewer')).to have_content(user.name)
+ end
+end
diff --git a/spec/features/merge_request/user_sees_versions_spec.rb b/spec/features/merge_request/user_sees_versions_spec.rb
index f94b288300a..91f8fd13681 100644
--- a/spec/features/merge_request/user_sees_versions_spec.rb
+++ b/spec/features/merge_request/user_sees_versions_spec.rb
@@ -232,7 +232,7 @@ RSpec.describe 'Merge request > User sees versions', :js, feature_category: :cod
end
it 'only shows diffs from the commit' do
- diff_commit_ids = find_all('.diff-file [data-commit-id]').map { |diff| diff['data-commit-id'] }
+ diff_commit_ids = find_all('.diff-file [data-commit-id]').pluck('data-commit-id')
expect(diff_commit_ids).not_to be_empty
expect(diff_commit_ids).to all(eq(params[:commit_id]))
diff --git a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
index 0de59ea21c5..dae28cbb05c 100644
--- a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
+++ b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Merge request > User selects branches for new MR', :js, feature_category: :code_review_workflow do
include ListboxHelpers
+ include CookieHelper
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
@@ -17,6 +18,7 @@ RSpec.describe 'Merge request > User selects branches for new MR', :js, feature_
before do
project.add_maintainer(user)
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
end
it 'selects the source branch sha when a tag with the same name exists' do
diff --git a/spec/features/merge_request/user_uses_quick_actions_spec.rb b/spec/features/merge_request/user_uses_quick_actions_spec.rb
index 1a88918da65..1ec86948065 100644
--- a/spec/features/merge_request/user_uses_quick_actions_spec.rb
+++ b/spec/features/merge_request/user_uses_quick_actions_spec.rb
@@ -9,7 +9,7 @@ require 'spec_helper'
# for each existing quick action unless they test something not tested by existing tests.
RSpec.describe 'Merge request > User uses quick actions', :js, :use_clean_rails_redis_caching,
feature_category: :code_review_workflow do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
diff --git a/spec/features/merge_request/user_views_open_merge_request_spec.rb b/spec/features/merge_request/user_views_open_merge_request_spec.rb
index e481e3f2dfb..1a9d40ae926 100644
--- a/spec/features/merge_request/user_views_open_merge_request_spec.rb
+++ b/spec/features/merge_request/user_views_open_merge_request_spec.rb
@@ -7,6 +7,18 @@ RSpec.describe 'User views an open merge request', feature_category: :code_revie
create(:merge_request, source_project: project, target_project: project, description: '# Description header')
end
+ context 'feature flags' do
+ let_it_be(:project) { create(:project, :public, :repository) }
+
+ it 'pushes content_editor_on_issues feature flag to frontend' do
+ stub_feature_flags(content_editor_on_issues: true)
+
+ visit merge_request_path(merge_request)
+
+ expect(page).to have_pushed_frontend_feature_flags(contentEditorOnIssues: true)
+ end
+ end
+
context 'when a merge request does not have repository' do
let(:project) { create(:project, :public, :repository) }
@@ -44,25 +56,25 @@ RSpec.describe 'User views an open merge request', feature_category: :code_revie
end
it 'renders empty description preview' do
- find('.gfm-form').fill_in(:merge_request_description, with: '')
+ fill_in(:merge_request_description, with: '')
- page.within('.gfm-form') do
- click_button('Preview')
+ page.within('.js-vue-markdown-field') do
+ click_button("Preview")
- expect(find('.js-md-preview')).to have_content('Nothing to preview.')
+ expect(find('.js-vue-md-preview')).to have_content('Nothing to preview.')
end
end
it 'renders description preview' do
- find('.gfm-form').fill_in(:merge_request_description, with: ':+1: Nice')
+ fill_in(:merge_request_description, with: ':+1: Nice')
- page.within('.gfm-form') do
- click_button('Preview')
+ page.within('.js-vue-markdown-field') do
+ click_button("Preview")
- expect(find('.js-md-preview')).to have_css('gl-emoji')
+ expect(find('.js-vue-md-preview')).to have_css('gl-emoji')
end
- expect(find('.gfm-form')).to have_css('.js-md-preview').and have_button('Write')
+ expect(find('.js-vue-markdown-field')).to have_css('.js-md-preview-button')
expect(find('#merge_request_description', visible: false)).not_to be_visible
end
end
@@ -92,7 +104,7 @@ RSpec.describe 'User views an open merge request', feature_category: :code_revie
visit(merge_request_path(merge_request))
end
- it 'shows diverged commits count' do
+ it 'shows diverged commits count', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408223' do
expect(page).not_to have_content(/([0-9]+ commits? behind)/)
end
end
diff --git a/spec/features/merge_requests/filters_generic_behavior_spec.rb b/spec/features/merge_requests/filters_generic_behavior_spec.rb
index 197b9fa770d..4dbbde5168b 100644
--- a/spec/features/merge_requests/filters_generic_behavior_spec.rb
+++ b/spec/features/merge_requests/filters_generic_behavior_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe 'Merge Requests > Filters generic behavior', :js, feature_categor
context 'filter dropdown' do
it 'filters by label name' do
- init_label_search
+ filtered_search.set('label:=')
filtered_search.send_keys('~bug')
page.within '.filter-dropdown' do
diff --git a/spec/features/merge_requests/rss_spec.rb b/spec/features/merge_requests/rss_spec.rb
index 9c9f46278f6..5f697a4f79d 100644
--- a/spec/features/merge_requests/rss_spec.rb
+++ b/spec/features/merge_requests/rss_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe 'Project Merge Requests RSS', feature_category: :code_review_work
visit path
end
- it_behaves_like "it has an RSS button with current_user's feed token"
+ it_behaves_like "it has an RSS link with current_user's feed token"
it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
end
@@ -34,7 +34,7 @@ RSpec.describe 'Project Merge Requests RSS', feature_category: :code_review_work
visit path
end
- it_behaves_like "it has an RSS button without a feed token"
+ it_behaves_like "it has an RSS link without a feed token"
it_behaves_like "an autodiscoverable RSS feed without a feed token"
end
diff --git a/spec/features/merge_requests/user_exports_as_csv_spec.rb b/spec/features/merge_requests/user_exports_as_csv_spec.rb
index 23ac1b264ad..57d6ee69923 100644
--- a/spec/features/merge_requests/user_exports_as_csv_spec.rb
+++ b/spec/features/merge_requests/user_exports_as_csv_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe 'Merge Requests > Exports as CSV', :js, feature_category: :code_r
context 'button is clicked' do
before do
+ click_button 'Actions'
click_button 'Export as CSV'
end
diff --git a/spec/features/merge_requests/user_lists_merge_requests_spec.rb b/spec/features/merge_requests/user_lists_merge_requests_spec.rb
index 3171ae89fe6..371c40b40a5 100644
--- a/spec/features/merge_requests/user_lists_merge_requests_spec.rb
+++ b/spec/features/merge_requests/user_lists_merge_requests_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe 'Merge requests > User lists merge requests', feature_category: :
milestone: create(:milestone, project: project, due_date: '2013-12-11'),
created_at: 1.minute.ago,
updated_at: 1.minute.ago)
- @fix.metrics.update!(merged_at: 10.seconds.ago, latest_closed_at: 10.seconds.ago)
+ @fix.metrics.update!(merged_at: 10.seconds.ago, latest_closed_at: 20.seconds.ago)
@markdown = create(:merge_request,
title: 'markdown',
@@ -33,7 +33,8 @@ RSpec.describe 'Merge requests > User lists merge requests', feature_category: :
reviewers: [user, user2, user3, user4],
milestone: create(:milestone, project: project, due_date: '2013-12-12'),
created_at: 2.minutes.ago,
- updated_at: 2.minutes.ago)
+ updated_at: 2.minutes.ago,
+ state: 'merged')
@markdown.metrics.update!(merged_at: 10.minutes.ago, latest_closed_at: 10.seconds.ago)
@merge_test = create(:merge_request,
@@ -49,7 +50,8 @@ RSpec.describe 'Merge requests > User lists merge requests', feature_category: :
source_project: project,
source_branch: 'feautre',
created_at: 2.minutes.ago,
- updated_at: 1.minute.ago)
+ updated_at: 1.minute.ago,
+ state: 'merged')
@feature.metrics.update!(merged_at: 10.seconds.ago, latest_closed_at: 10.minutes.ago)
end
@@ -79,10 +81,9 @@ RSpec.describe 'Merge requests > User lists merge requests', feature_category: :
expect(page).to have_current_path(project_merge_requests_path(project), ignore_query: true)
expect(page).to have_content 'merge-test'
- expect(page).to have_content 'feature'
expect(page).not_to have_content 'fix'
expect(page).not_to have_content 'markdown'
- expect(count_merge_requests).to eq(2)
+ expect(count_merge_requests).to eq(1)
end
it 'filters on a specific assignee' do
@@ -90,8 +91,7 @@ RSpec.describe 'Merge requests > User lists merge requests', feature_category: :
expect(page).not_to have_content 'merge-test'
expect(page).to have_content 'fix'
- expect(page).to have_content 'markdown'
- expect(count_merge_requests).to eq(2)
+ expect(count_merge_requests).to eq(1)
end
it 'sorts by newest' do
@@ -99,35 +99,35 @@ RSpec.describe 'Merge requests > User lists merge requests', feature_category: :
expect(first_merge_request).to include('fix')
expect(last_merge_request).to include('merge-test')
- expect(count_merge_requests).to eq(4)
+ expect(count_merge_requests).to eq(2)
end
it 'sorts by last updated' do
visit_merge_requests(project, sort: sort_value_recently_updated)
expect(first_merge_request).to include('merge-test')
- expect(count_merge_requests).to eq(4)
+ expect(count_merge_requests).to eq(2)
end
it 'sorts by milestone due date' do
visit_merge_requests(project, sort: sort_value_milestone)
expect(first_merge_request).to include('fix')
- expect(count_merge_requests).to eq(4)
+ expect(count_merge_requests).to eq(2)
end
- it 'sorts by merged at' do
+ it 'ignores sorting by merged at' do
visit_merge_requests(project, sort: sort_value_merged_date)
- expect(first_merge_request).to include('markdown')
- expect(count_merge_requests).to eq(4)
+ expect(first_merge_request).to include('fix')
+ expect(count_merge_requests).to eq(2)
end
it 'sorts by closed at' do
visit_merge_requests(project, sort: sort_value_closed_date)
- expect(first_merge_request).to include('feature')
- expect(count_merge_requests).to eq(4)
+ expect(first_merge_request).to include('fix')
+ expect(count_merge_requests).to eq(2)
end
it 'filters on one label and sorts by milestone due date' do
@@ -141,6 +141,15 @@ RSpec.describe 'Merge requests > User lists merge requests', feature_category: :
expect(count_merge_requests).to eq(1)
end
+ context 'when viewing merged merge requests' do
+ it 'sorts by merged at' do
+ visit_merge_requests(project, state: 'merged', sort: sort_value_merged_date)
+
+ expect(first_merge_request).to include('markdown')
+ expect(count_merge_requests).to eq(2)
+ end
+ end
+
context 'while filtering on two labels' do
let(:label) { create(:label, project: project) }
let(:label2) { create(:label, project: project) }
diff --git a/spec/features/merge_requests/user_mass_updates_spec.rb b/spec/features/merge_requests/user_mass_updates_spec.rb
index b0be76d386a..45d57cf8374 100644
--- a/spec/features/merge_requests/user_mass_updates_spec.rb
+++ b/spec/features/merge_requests/user_mass_updates_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe 'Merge requests > User mass updates', :js, feature_category: :cod
merge_request.close
visit project_merge_requests_path(project, state: 'merged')
- click_button 'Edit merge requests'
+ click_button 'Bulk edit'
expect(page).not_to have_button 'Select status'
end
@@ -108,7 +108,7 @@ RSpec.describe 'Merge requests > User mass updates', :js, feature_category: :cod
end
def change_status(text)
- click_button 'Edit merge requests'
+ click_button 'Bulk edit'
check 'Select all'
click_button 'Select status'
click_button text
@@ -116,7 +116,7 @@ RSpec.describe 'Merge requests > User mass updates', :js, feature_category: :cod
end
def change_assignee(text)
- click_button 'Edit merge requests'
+ click_button 'Bulk edit'
check 'Select all'
within 'aside[aria-label="Bulk update"]' do
click_button 'Select assignee'
@@ -127,7 +127,7 @@ RSpec.describe 'Merge requests > User mass updates', :js, feature_category: :cod
end
def change_milestone(text)
- click_button 'Edit merge requests'
+ click_button 'Bulk edit'
check 'Select all'
click_button 'Select milestone'
click_button text
diff --git a/spec/features/merge_requests/user_sorts_merge_requests_spec.rb b/spec/features/merge_requests/user_sorts_merge_requests_spec.rb
index 58d796f8288..5ccc24ebca1 100644
--- a/spec/features/merge_requests/user_sorts_merge_requests_spec.rb
+++ b/spec/features/merge_requests/user_sorts_merge_requests_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'User sorts merge requests', :js, feature_category: :code_review_workflow do
include CookieHelper
- include Spec::Support::Helpers::Features::SortingHelpers
+ include Features::SortingHelpers
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let!(:merge_request2) do
diff --git a/spec/features/milestones/user_deletes_milestone_spec.rb b/spec/features/milestones/user_deletes_milestone_spec.rb
index 141e626c6f3..a7f2457de04 100644
--- a/spec/features/milestones/user_deletes_milestone_spec.rb
+++ b/spec/features/milestones/user_deletes_milestone_spec.rb
@@ -18,6 +18,7 @@ RSpec.describe "User deletes milestone", :js, feature_category: :team_planning d
project.add_developer(user)
visit(project_milestones_path(project))
click_link(milestone.title)
+ click_button("Milestone actions")
click_button("Delete")
click_button("Delete milestone")
@@ -38,6 +39,7 @@ RSpec.describe "User deletes milestone", :js, feature_category: :team_planning d
visit(group_milestones_path(group))
click_link(milestone_to_be_deleted.title)
+ click_button("Milestone actions")
click_button("Delete")
click_button("Delete milestone")
diff --git a/spec/features/monitor_sidebar_link_spec.rb b/spec/features/monitor_sidebar_link_spec.rb
index d5f987d15c2..6a1413c04f6 100644
--- a/spec/features/monitor_sidebar_link_spec.rb
+++ b/spec/features/monitor_sidebar_link_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Monitor dropdown sidebar', :aggregate_failures, feature_category: :not_owned do
+RSpec.describe 'Monitor dropdown sidebar', :aggregate_failures, feature_category: :shared do
let_it_be_with_reload(:project) { create(:project, :internal, :repository) }
let_it_be(:user) { create(:user) }
@@ -11,6 +11,7 @@ RSpec.describe 'Monitor dropdown sidebar', :aggregate_failures, feature_category
before do
project.add_role(user, role) if role
sign_in(user)
+ stub_feature_flags(remove_monitor_metrics: false)
end
shared_examples 'shows Monitor menu based on the access level' do
diff --git a/spec/features/nav/new_nav_invite_members_spec.rb b/spec/features/nav/new_nav_invite_members_spec.rb
new file mode 100644
index 00000000000..4c37d6b4760
--- /dev/null
+++ b/spec/features/nav/new_nav_invite_members_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'new navigation toggle', :js, feature_category: :navigation do
+ include Features::InviteMembersModalHelpers
+
+ let_it_be(:user) { create(:user, use_new_navigation: true) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when inside a group' do
+ let_it_be(:group) { create(:group).tap { |record| record.add_owner(user) } }
+
+ before do
+ visit group_path(group)
+ end
+
+ it 'the add menu contains invite members dropdown option and opens invite modal' do
+ invite_members_from_menu
+
+ page.within invite_modal_selector do
+ expect(page).to have_content("You're inviting members to the #{group.name} group")
+ end
+ end
+ end
+
+ context 'when inside a project' do
+ let_it_be(:project) { create(:project, :repository).tap { |record| record.add_owner(user) } }
+
+ before do
+ visit project_path(project)
+ end
+
+ it 'the add menu contains invite members dropdown option and opens invite modal' do
+ invite_members_from_menu
+
+ page.within invite_modal_selector do
+ expect(page).to have_content("You're inviting members to the #{project.name} project")
+ end
+ end
+ end
+
+ def invite_members_from_menu
+ page.find('[data-testid="new-menu-toggle"] button').click
+ click_button('Invite team members')
+ end
+end
diff --git a/spec/features/nav/new_nav_toggle_spec.rb b/spec/features/nav/new_nav_toggle_spec.rb
index 8e5cc7df053..2cdaf12bb15 100644
--- a/spec/features/nav/new_nav_toggle_spec.rb
+++ b/spec/features/nav/new_nav_toggle_spec.rb
@@ -60,7 +60,7 @@ RSpec.describe 'new navigation toggle', :js, feature_category: :navigation do
it 'allows to disable new nav', :aggregate_failures do
within '[data-testid="super-sidebar"] [data-testid="user-dropdown"]' do
- find('button').click
+ click_button "#{user.name} user’s menu"
expect(page).to have_content('Navigation redesign')
toggle = page.find('.gl-toggle.is-checked')
diff --git a/spec/features/nav/pinned_nav_items_spec.rb b/spec/features/nav/pinned_nav_items_spec.rb
new file mode 100644
index 00000000000..308350d5166
--- /dev/null
+++ b/spec/features/nav/pinned_nav_items_spec.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Navigation menu item pinning', :js, feature_category: :navigation do
+ let_it_be(:user) { create(:user, use_new_navigation: true) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'non-pinnable navigation menu' do
+ before do
+ visit explore_projects_path
+ end
+
+ it 'does not show the Pinned section' do
+ within '#super-sidebar' do
+ expect(page).not_to have_content 'Pinned'
+ end
+ end
+
+ it 'does not show the buttons to pin items' do
+ within '#super-sidebar' do
+ expect(page).not_to have_css 'button svg[data-testid="thumbtack-icon"]'
+ end
+ end
+ end
+
+ describe 'pinnable navigation menu' do
+ let_it_be(:project) { create(:project) }
+
+ before do
+ project.add_member(user, :owner)
+ visit project_path(project)
+ end
+
+ it 'adds sensible defaults' do
+ within '[data-testid="pinned-nav-items"]' do
+ expect(page).to have_link 'Issues'
+ end
+ end
+
+ it 'shows the Pinned section' do
+ within '#super-sidebar' do
+ expect(page).to have_content 'Pinned'
+ end
+ end
+
+ it 'allows to pin items' do
+ within '#super-sidebar' do
+ click_on 'Manage'
+ add_pin('Activity')
+ add_pin('Members')
+ end
+
+ within '[data-testid="pinned-nav-items"]' do
+ expect(page).to have_link 'Issues'
+ expect(page).to have_link 'Activity'
+ expect(page).to have_link 'Members'
+ end
+ end
+
+ describe 'when all pins are removed' do
+ before do
+ remove_pin('Issues')
+ end
+
+ it 'shows the Pinned section as expanded by default' do
+ within '#super-sidebar' do
+ expect(page).to have_content 'Your pinned items appear here.'
+ end
+ end
+
+ it 'maintains the collapsed/expanded state between page loads' do
+ within '#super-sidebar' do
+ click_on 'Pinned'
+ visit project_path(project)
+ expect(page).not_to have_content 'Your pinned items appear here.'
+
+ click_on 'Pinned'
+ visit project_path(project)
+ expect(page).to have_content 'Your pinned items appear here.'
+ end
+ end
+ end
+
+ describe 'pinned items' do
+ before do
+ within '#super-sidebar' do
+ click_on 'Operate'
+ add_pin('Package Registry')
+ add_pin('Terraform modules')
+ wait_for_requests
+ end
+ end
+
+ it 'can be unpinned from within the pinned section' do
+ within '[data-testid="pinned-nav-items"]' do
+ remove_pin('Package Registry')
+ expect(page).not_to have_content 'Package Registry'
+ end
+ end
+
+ it 'can be unpinned from within its section' do
+ section = find("button", text: 'Operate')
+
+ within(section.sibling('ul')) do
+ remove_pin('Terraform modules')
+ end
+
+ within '[data-testid="pinned-nav-items"]' do
+ expect(page).not_to have_content 'Terraform modules'
+ end
+ end
+
+ it 'can be reordered' do
+ within '[data-testid="pinned-nav-items"]' do
+ pinned_items = page.find_all('a').map(&:text)
+ item2 = page.find('a', text: 'Package Registry')
+ item3 = page.find('a', text: 'Terraform modules')
+ expect(pinned_items[1..2]).to eq [item2.text, item3.text]
+ drag_item(item3, to: item2)
+
+ pinned_items = page.find_all('a').map(&:text)
+ expect(pinned_items[1..2]).to eq [item3.text, item2.text]
+ end
+ end
+ end
+ end
+
+ describe 'reordering pins with hidden pins from non-available features' do
+ let_it_be(:project_with_repo) { create(:project, :repository) }
+ let_it_be(:project_without_repo) { create(:project, :repository_disabled) }
+
+ before do
+ project_with_repo.add_member(user, :owner)
+ project_without_repo.add_member(user, :owner)
+
+ visit project_path(project_with_repo)
+ within '#super-sidebar' do
+ click_on 'Code'
+ add_pin('Commits')
+ click_on 'Manage'
+ add_pin('Activity')
+ add_pin('Members')
+ end
+
+ visit project_path(project_without_repo)
+ within '[data-testid="pinned-nav-items"]' do
+ activity_item = page.find('a', text: 'Activity')
+ members_item = page.find('a', text: 'Members')
+ drag_item(members_item, to: activity_item)
+ end
+
+ visit project_path(project_with_repo)
+ end
+
+ it 'keeps pins of non-available features' do
+ within '[data-testid="pinned-nav-items"]' do
+ pinned_items = page.find_all('a')
+ .map(&:text)
+ .map { |text| text.split("\n").first } # to drop the counter badge text from "Issues\n0"
+ expect(pinned_items).to eq ["Issues", "Merge requests", "Commits", "Members", "Activity"]
+ end
+ end
+ end
+
+ private
+
+ def add_pin(menu_item_title)
+ menu_item = find("[data-testid=\"nav-item-link\"]", text: menu_item_title)
+ menu_item.hover
+ menu_item.find("[data-testid=\"thumbtack-icon\"]").click
+ wait_for_requests
+ end
+
+ def remove_pin(menu_item_title)
+ menu_item = find("[data-testid=\"nav-item-link\"]", text: menu_item_title)
+ menu_item.hover
+ menu_item.find("[data-testid=\"thumbtack-solid-icon\"]").click
+ wait_for_requests
+ end
+
+ def drag_item(item, to:)
+ item.hover
+ drag_handle = item.find('[data-testid="grip-icon"]')
+
+ # Reduce delay to make it less likely for draggables to
+ # change position during drag operation, which reduces
+ # flakiness.
+ drag_handle.drag_to(to, delay: 0.01)
+ wait_for_requests
+ end
+end
diff --git a/spec/features/nav/top_nav_responsive_spec.rb b/spec/features/nav/top_nav_responsive_spec.rb
index 56f9d373f00..ff8132dc087 100644
--- a/spec/features/nav/top_nav_responsive_spec.rb
+++ b/spec/features/nav/top_nav_responsive_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'top nav responsive', :js, feature_category: :navigation do
include MobileHelpers
+ include Features::InviteMembersModalHelpers
let_it_be(:user) { create(:user) }
@@ -20,8 +21,8 @@ RSpec.describe 'top nav responsive', :js, feature_category: :navigation do
context 'when menu is closed' do
it 'has page content and hides responsive menu', :aggregate_failures do
- expect(page).to have_css('.page-title', text: 'Projects')
- expect(page).to have_link('Dashboard', id: 'logo')
+ expect(page).to have_css('.page-title', text: 'Explore projects')
+ expect(page).to have_link('Homepage', id: 'logo')
expect(page).to have_no_css('.top-nav-responsive')
end
@@ -33,14 +34,15 @@ RSpec.describe 'top nav responsive', :js, feature_category: :navigation do
end
it 'hides everything and shows responsive menu', :aggregate_failures do
- expect(page).to have_no_css('.page-title', text: 'Projects')
- expect(page).to have_no_link('Dashboard', id: 'logo')
+ expect(page).to have_no_css('.page-title', text: 'Explore projects')
+ expect(page).to have_no_link('Homepage', id: 'logo')
within '.top-nav-responsive' do
expect(page).to have_link(nil, href: search_path)
expect(page).to have_button('Projects')
expect(page).to have_button('Groups')
- expect(page).to have_link('Snippets', href: dashboard_snippets_path)
+ expect(page).to have_link('Your work', href: dashboard_projects_path)
+ expect(page).to have_link('Explore', href: explore_projects_path)
end
end
@@ -61,10 +63,12 @@ RSpec.describe 'top nav responsive', :js, feature_category: :navigation do
visit project_path(project)
end
- it 'the add menu contains invite members dropdown option and goes to the members page' do
+ it 'the add menu contains invite members dropdown option and opens invite modal' do
invite_members_from_menu
- expect(page).to have_current_path(project_project_members_path(project))
+ page.within invite_modal_selector do
+ expect(page).to have_content("You're inviting members to the #{project.name} project")
+ end
end
end
@@ -75,10 +79,12 @@ RSpec.describe 'top nav responsive', :js, feature_category: :navigation do
visit group_path(group)
end
- it 'the add menu contains invite members dropdown option and goes to the members page' do
+ it 'the add menu contains invite members dropdown option and opens invite modal' do
invite_members_from_menu
- expect(page).to have_current_path(group_group_members_path(group))
+ page.within invite_modal_selector do
+ expect(page).to have_content("You're inviting members to the #{group.name} group")
+ end
end
end
@@ -86,7 +92,7 @@ RSpec.describe 'top nav responsive', :js, feature_category: :navigation do
click_button('Menu')
create_new_button.click
- click_link('Invite members')
+ click_button('Invite members')
end
def create_new_button
diff --git a/spec/features/nav/top_nav_spec.rb b/spec/features/nav/top_nav_spec.rb
index cc20b626e30..74022a4a976 100644
--- a/spec/features/nav/top_nav_spec.rb
+++ b/spec/features/nav/top_nav_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'top nav responsive', :js, feature_category: :navigation do
+ include Features::InviteMembersModalHelpers
+
let_it_be(:user) { create(:user) }
before do
@@ -16,10 +18,12 @@ RSpec.describe 'top nav responsive', :js, feature_category: :navigation do
visit project_path(project)
end
- it 'the add menu contains invite members dropdown option and goes to the members page' do
+ it 'the add menu contains invite members dropdown option and opens invite modal' do
invite_members_from_menu
- expect(page).to have_current_path(project_project_members_path(project))
+ page.within invite_modal_selector do
+ expect(page).to have_content("You're inviting members to the #{project.name} project")
+ end
end
end
@@ -30,10 +34,12 @@ RSpec.describe 'top nav responsive', :js, feature_category: :navigation do
visit group_path(group)
end
- it 'the add menu contains invite members dropdown option and goes to the members page' do
+ it 'the add menu contains invite members dropdown option and opens invite modal' do
invite_members_from_menu
- expect(page).to have_current_path(group_group_members_path(group))
+ page.within invite_modal_selector do
+ expect(page).to have_content("You're inviting members to the #{group.name} group")
+ end
end
end
diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb
index bd96d65f984..ca20a1cd81b 100644
--- a/spec/features/oauth_login_spec.rb
+++ b/spec/features/oauth_login_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'OAuth Login', :allow_forgery_protection, feature_category: :syst
end
providers = [:github, :twitter, :bitbucket, :gitlab, :google_oauth2,
- :facebook, :cas3, :auth0, :salesforce, :dingtalk, :alicloud]
+ :facebook, :auth0, :salesforce, :dingtalk, :alicloud]
around do |example|
with_omniauth_full_host { example.run }
diff --git a/spec/features/oauth_registration_spec.rb b/spec/features/oauth_registration_spec.rb
index 3c1004e452f..c88a018a592 100644
--- a/spec/features/oauth_registration_spec.rb
+++ b/spec/features/oauth_registration_spec.rb
@@ -21,7 +21,6 @@ RSpec.describe 'OAuth Registration', :js, :allow_forgery_protection, feature_cat
:gitlab | {}
:google_oauth2 | {}
:facebook | {}
- :cas3 | {}
:auth0 | {}
:salesforce | { extra: { email_verified: true } }
:dingtalk | {}
diff --git a/spec/features/populate_new_pipeline_vars_with_params_spec.rb b/spec/features/populate_new_pipeline_vars_with_params_spec.rb
index a83b5a81a41..bcda30ccb84 100644
--- a/spec/features/populate_new_pipeline_vars_with_params_spec.rb
+++ b/spec/features/populate_new_pipeline_vars_with_params_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Populate new pipeline CI variables with url params", :js, feature_category: :pipeline_authoring do
+RSpec.describe "Populate new pipeline CI variables with url params", :js, feature_category: :secrets_management do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:page_path) { new_project_pipeline_path(project) }
diff --git a/spec/features/profiles/chat_names_spec.rb b/spec/features/profiles/chat_names_spec.rb
index 299ecdb6032..aa3f4a90298 100644
--- a/spec/features/profiles/chat_names_spec.rb
+++ b/spec/features/profiles/chat_names_spec.rb
@@ -2,9 +2,8 @@
require 'spec_helper'
-RSpec.describe 'Profile > Chat', feature_category: :user_profile do
- let(:user) { create(:user) }
- let(:integration) { create(:integration) }
+RSpec.describe 'Profile > Chat', feature_category: :integrations do
+ let_it_be(:user) { create(:user) }
before do
sign_in(user)
@@ -12,7 +11,12 @@ RSpec.describe 'Profile > Chat', feature_category: :user_profile do
describe 'uses authorization link' do
let(:params) do
- { team_id: 'T00', team_domain: 'my_chat_team', user_id: 'U01', user_name: 'my_chat_user' }
+ {
+ team_id: 'f1924a8db44ff3bb41c96424cdc20676',
+ team_domain: 'my_chat_team',
+ user_id: 'ay5sq51sebfh58ktrce5ijtcwy',
+ user_name: 'my_chat_user'
+ }
end
let!(:authorize_url) { ChatNames::AuthorizeUserService.new(params).execute }
@@ -22,6 +26,36 @@ RSpec.describe 'Profile > Chat', feature_category: :user_profile do
visit authorize_path
end
+ it 'names the Mattermost integration correctly' do
+ expect(page).to have_content(
+ 'An application called Mattermost slash commands is requesting access to your GitLab account'
+ )
+ expect(page).to have_content('Authorize Mattermost slash commands')
+ end
+
+ context 'when params are of the GitLab for Slack app' do
+ let(:params) do
+ { team_id: 'T00', team_domain: 'my_chat_team', user_id: 'U01', user_name: 'my_chat_user' }
+ end
+
+ shared_examples 'names the GitLab for Slack app integration correctly' do
+ specify do
+ expect(page).to have_content(
+ 'An application called GitLab for Slack app is requesting access to your GitLab account'
+ )
+ expect(page).to have_content('Authorize GitLab for Slack app')
+ end
+ end
+
+ include_examples 'names the GitLab for Slack app integration correctly'
+
+ context 'with a Slack enterprise-enabled team' do
+ let(:params) { super().merge(user_id: 'W01') }
+
+ include_examples 'names the GitLab for Slack app integration correctly'
+ end
+ end
+
context 'clicks authorize' do
before do
click_button 'Authorize'
@@ -60,7 +94,7 @@ RSpec.describe 'Profile > Chat', feature_category: :user_profile do
end
describe 'visits chat accounts' do
- let!(:chat_name) { create(:chat_name, user: user, integration: integration) }
+ let_it_be(:chat_name) { create(:chat_name, user: user) }
before do
visit profile_chat_names_path
diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb
index 0fc59f21489..f39d9ddaf56 100644
--- a/spec/features/profiles/gpg_keys_spec.rb
+++ b/spec/features/profiles/gpg_keys_spec.rb
@@ -37,12 +37,13 @@ RSpec.describe 'Profile > GPG Keys', feature_category: :user_profile do
end
it 'user sees their key' do
- create(:gpg_key, user: user, key: GpgHelpers::User2.public_key)
+ gpg_key = create(:gpg_key, user: user, key: GpgHelpers::User2.public_key)
visit profile_gpg_keys_path
expect(page).to have_content('bette.cartwright@example.com Verified')
expect(page).to have_content('bette.cartwright@example.net Unverified')
expect(page).to have_content(GpgHelpers::User2.fingerprint)
+ expect(page).to have_selector('time.js-timeago', text: gpg_key.created_at.strftime('%b %d, %Y'))
end
it 'user removes a key via the key index' do
diff --git a/spec/features/profiles/list_users_comment_template_spec.rb b/spec/features/profiles/list_users_comment_template_spec.rb
new file mode 100644
index 00000000000..85e455ba988
--- /dev/null
+++ b/spec/features/profiles/list_users_comment_template_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Profile > Comment templates > List users comment templates', :js,
+ feature_category: :user_profile do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:saved_reply) { create(:saved_reply, user: user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'shows the user a list of their comment templates' do
+ visit profile_comment_templates_path
+
+ expect(page).to have_content('My comment templates (1)')
+ expect(page).to have_content(saved_reply.name)
+ expect(page).to have_content(saved_reply.content)
+ end
+end
diff --git a/spec/features/profiles/list_users_saved_replies_spec.rb b/spec/features/profiles/list_users_saved_replies_spec.rb
deleted file mode 100644
index 4f3678f8051..00000000000
--- a/spec/features/profiles/list_users_saved_replies_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Profile > Notifications > List users saved replies', :js,
- feature_category: :user_profile do
- let_it_be(:user) { create(:user) }
- let_it_be(:saved_reply) { create(:saved_reply, user: user) }
-
- before do
- sign_in(user)
- end
-
- it 'shows the user a list of their saved replies' do
- visit profile_saved_replies_path
-
- expect(page).to have_content('My saved replies (1)')
- expect(page).to have_content(saved_reply.name)
- expect(page).to have_content(saved_reply.content)
- end
-end
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index a050e87241b..65fe1330be2 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'Profile > Personal Access Tokens', :js, feature_category: :user_profile do
include Spec::Support::Helpers::ModalHelpers
- include Spec::Support::Helpers::AccessTokenHelpers
+ include Features::AccessTokenHelpers
let(:user) { create(:user) }
let(:pat_create_service) { double('PersonalAccessTokens::CreateService', execute: ServiceResponse.error(message: 'error', payload: { personal_access_token: PersonalAccessToken.new })) }
diff --git a/spec/features/profiles/user_creates_comment_template_spec.rb b/spec/features/profiles/user_creates_comment_template_spec.rb
new file mode 100644
index 00000000000..44e2b932c00
--- /dev/null
+++ b/spec/features/profiles/user_creates_comment_template_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Profile > Comment templates > User creates comment template', :js,
+ feature_category: :user_profile do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+
+ visit profile_comment_templates_path
+
+ wait_for_requests
+ end
+
+ it 'shows the user a list of their saved replies' do
+ find('[data-testid="comment-template-name-input"]').set('test')
+ find('[data-testid="comment-template-content-input"]').set('Test content')
+
+ click_button 'Save'
+
+ wait_for_requests
+
+ expect(page).to have_content('My comment templates (1)')
+ expect(page).to have_content('test')
+ expect(page).to have_content('Test content')
+ end
+end
diff --git a/spec/features/profiles/user_deletes_comment_template_spec.rb b/spec/features/profiles/user_deletes_comment_template_spec.rb
new file mode 100644
index 00000000000..7ef857e9622
--- /dev/null
+++ b/spec/features/profiles/user_deletes_comment_template_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Profile > Comment templates > User deletes comment template', :js,
+ feature_category: :user_profile do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:saved_reply) { create(:saved_reply, user: user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'shows the user a list of their comment template' do
+ visit profile_comment_templates_path
+
+ click_button 'Comment template actions'
+ find('[data-testid="comment-template-delete-btn"]').click
+
+ page.within('.gl-modal') do
+ click_button 'Delete'
+ end
+
+ wait_for_requests
+
+ expect(page).not_to have_content(saved_reply.name)
+ end
+end
diff --git a/spec/features/profiles/user_edit_preferences_spec.rb b/spec/features/profiles/user_edit_preferences_spec.rb
index f7a9850355a..6cc926ed017 100644
--- a/spec/features/profiles/user_edit_preferences_spec.rb
+++ b/spec/features/profiles/user_edit_preferences_spec.rb
@@ -12,22 +12,11 @@ RSpec.describe 'User edit preferences profile', :js, feature_category: :user_pro
before do
stub_languages_translation_percentage(language_percentage_levels)
- stub_feature_flags(user_time_settings: true)
stub_feature_flags(vscode_web_ide: vscode_web_ide)
sign_in(user)
visit(profile_preferences_path)
end
- it 'allows the user to toggle their time format preference' do
- field = page.find_field("user[time_format_in_24h]")
-
- expect(field).not_to be_checked
-
- field.click
-
- expect(field).to be_checked
- end
-
it 'allows the user to toggle their time display preference' do
field = page.find_field("user[time_display_relative]")
@@ -38,24 +27,6 @@ RSpec.describe 'User edit preferences profile', :js, feature_category: :user_pro
expect(field).not_to be_checked
end
- it 'allows the user to toggle using the legacy web ide' do
- field = page.find_field("user[use_legacy_web_ide]")
-
- expect(field).not_to be_checked
-
- field.click
-
- expect(field).to be_checked
- end
-
- describe 'when vscode_web_ide feature flag is disabled' do
- let(:vscode_web_ide) { false }
-
- it 'does not display the legacy web ide user preference' do
- expect(page).not_to have_field("user[use_legacy_web_ide]")
- end
- end
-
describe 'User changes tab width to acceptable value' do
it 'shows success message' do
fill_in 'Tab width', with: 9
diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb
index 3819723cc09..a6dcbc31dc4 100644
--- a/spec/features/profiles/user_edit_profile_spec.rb
+++ b/spec/features/profiles/user_edit_profile_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'User edit profile', feature_category: :user_profile do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
let_it_be(:user) { create(:user) }
@@ -97,6 +97,26 @@ RSpec.describe 'User edit profile', feature_category: :user_profile do
expect(page).to have_content('Website url is not a valid URL')
end
+ it 'validates that the discord id has a valid length', :js do
+ valid_discord_id = '123456789123456789'
+ too_short_discord_id = '123456'
+ too_long_discord_id = '123456789abcdefghijkl'
+
+ fill_in 'user_discord', with: too_short_discord_id
+ expect(page).to have_content('Discord ID is too short')
+
+ fill_in 'user_discord', with: too_long_discord_id
+ expect(page).to have_content('Discord ID is too long')
+
+ fill_in 'user_discord', with: valid_discord_id
+
+ submit_settings
+
+ expect(user.reload).to have_attributes(
+ discord: valid_discord_id
+ )
+ end
+
describe 'when I change my email', :js do
before do
user.send_reset_password_instructions
@@ -277,7 +297,7 @@ RSpec.describe 'User edit profile', feature_category: :user_profile do
end
page.within '.dropdown-menu-user' do
- expect(page).to have_content("#{user.name} (Busy)")
+ expect(page).to have_content("#{user.name} Busy")
end
end
@@ -288,7 +308,7 @@ RSpec.describe 'User edit profile', feature_category: :user_profile do
visit project_issue_path(project, issue)
wait_for_requests
- expect(page.find('.issuable-assignees')).to have_content("#{user.name} (Busy)")
+ expect(page.find('.issuable-assignees')).to have_content("#{user.name} Busy")
end
end
end
@@ -504,10 +524,6 @@ RSpec.describe 'User edit profile', feature_category: :user_profile do
let(:issue) { create(:issue, project: project) }
let(:project) { create(:project) }
- before do
- stub_feature_flags(user_time_settings: true)
- end
-
it 'shows the user time preferences form' do
expect(page).to have_content('Time settings')
end
diff --git a/spec/features/profiles/user_updates_comment_template_spec.rb b/spec/features/profiles/user_updates_comment_template_spec.rb
new file mode 100644
index 00000000000..2e6bfdcc407
--- /dev/null
+++ b/spec/features/profiles/user_updates_comment_template_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Profile > Comment templates > User updated comment template', :js,
+ feature_category: :user_profile do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:saved_reply) { create(:saved_reply, user: user) }
+
+ before do
+ sign_in(user)
+
+ visit profile_comment_templates_path
+
+ wait_for_requests
+ end
+
+ it 'shows the user a list of their comment template' do
+ click_button 'Comment template actions'
+
+ find('[data-testid="comment-template-edit-btn"]').click
+ find('[data-testid="comment-template-name-input"]').set('test')
+
+ click_button 'Save'
+
+ wait_for_requests
+
+ expect(page).to have_selector('[data-testid="comment-template-name"]', text: 'test')
+ end
+end
diff --git a/spec/features/profiles/user_uses_comment_template_spec.rb b/spec/features/profiles/user_uses_comment_template_spec.rb
new file mode 100644
index 00000000000..704d02e94f4
--- /dev/null
+++ b/spec/features/profiles/user_uses_comment_template_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User uses comment template', :js,
+ feature_category: :user_profile do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:saved_reply) { create(:saved_reply, user: user) }
+
+ before do
+ project.add_owner(user)
+
+ sign_in(user)
+ end
+
+ it 'applies comment template' do
+ visit project_merge_request_path(merge_request.project, merge_request)
+
+ find('.js-comment-template-toggle').click
+
+ wait_for_requests
+
+ find('.gl-new-dropdown-item').click
+
+ expect(find('.note-textarea').value).to eq(saved_reply.content)
+ end
+end
diff --git a/spec/features/profiles/user_visits_profile_authentication_log_spec.rb b/spec/features/profiles/user_visits_profile_authentication_log_spec.rb
index 90f24c5b866..ac0ed91468c 100644
--- a/spec/features/profiles/user_visits_profile_authentication_log_spec.rb
+++ b/spec/features/profiles/user_visits_profile_authentication_log_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe 'User visits the authentication log', feature_category: :user_pro
it 'shows correct menu item' do
visit(audit_log_profile_path)
- expect(page).to have_active_navigation('Authentication log')
+ expect(page).to have_active_navigation('Authentication Log')
end
end
diff --git a/spec/features/project_group_variables_spec.rb b/spec/features/project_group_variables_spec.rb
index 0e1e6e49c6d..966c05bb4cb 100644
--- a/spec/features/project_group_variables_spec.rb
+++ b/spec/features/project_group_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Project group variables', :js, feature_category: :pipeline_authoring do
+RSpec.describe 'Project group variables', :js, feature_category: :secrets_management do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) }
diff --git a/spec/features/project_variables_spec.rb b/spec/features/project_variables_spec.rb
index 1a951980141..c4f78bf4ea3 100644
--- a/spec/features/project_variables_spec.rb
+++ b/spec/features/project_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Project variables', :js, feature_category: :pipeline_authoring do
+RSpec.describe 'Project variables', :js, feature_category: :secrets_management do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:variable) { create(:ci_variable, key: 'test_key', value: 'test_value', masked: true) }
@@ -16,7 +16,18 @@ RSpec.describe 'Project variables', :js, feature_category: :pipeline_authoring d
wait_for_requests
end
- it_behaves_like 'variable list'
+ context 'when ci_variables_pages FF is enabled' do
+ it_behaves_like 'variable list'
+ it_behaves_like 'variable list pagination', :ci_variable
+ end
+
+ context 'when ci_variables_pages FF is disabled' do
+ before do
+ stub_feature_flags(ci_variables_pages: false)
+ end
+
+ it_behaves_like 'variable list'
+ end
it 'adds a new variable with an environment scope' do
click_button('Add variable')
diff --git a/spec/features/projects/badges/list_spec.rb b/spec/features/projects/badges/list_spec.rb
index e6bd4b22b0a..c9e4aabe72a 100644
--- a/spec/features/projects/badges/list_spec.rb
+++ b/spec/features/projects/badges/list_spec.rb
@@ -43,13 +43,46 @@ RSpec.describe 'list of badges', feature_category: :continuous_integration do
it 'user changes current ref of build status badge', :js do
page.within('.pipeline-status') do
- first('.js-project-refs-dropdown').click
+ find('.ref-selector').click
+ wait_for_requests
- page.within '.project-refs-form' do
- click_link 'improve/awesome'
+ page.within('.ref-selector') do
+ fill_in 'Search by Git revision', with: 'improve/awesome'
+ wait_for_requests
+ find('li', text: 'improve/awesome', match: :prefer_exact).click
end
expect(page).to have_content 'badges/improve/awesome/pipeline.svg'
end
end
+
+ it 'user changes current ref of coverage status badge', :js do
+ page.within('.coverage-report') do
+ find('.ref-selector').click
+ wait_for_requests
+
+ page.within('.ref-selector') do
+ fill_in 'Search by Git revision', with: 'improve/awesome'
+ wait_for_requests
+ find('li', text: 'improve/awesome', match: :prefer_exact).click
+ end
+
+ expect(page).to have_content 'badges/improve/awesome/coverage.svg'
+ end
+ end
+
+ it 'user changes current ref of latest release status badge', :js do
+ page.within('.Latest-Release') do
+ find('.ref-selector').click
+ wait_for_requests
+
+ page.within('.ref-selector') do
+ fill_in 'Search by Git revision', with: 'improve/awesome'
+ wait_for_requests
+ find('li', text: 'improve/awesome', match: :prefer_exact).click
+ end
+
+ expect(page).to have_content '-/badges/release.svg'
+ end
+ end
end
diff --git a/spec/features/projects/blobs/blame_spec.rb b/spec/features/projects/blobs/blame_spec.rb
index 27b7c6ef2d5..9f061a2ff14 100644
--- a/spec/features/projects/blobs/blame_spec.rb
+++ b/spec/features/projects/blobs/blame_spec.rb
@@ -38,13 +38,13 @@ RSpec.describe 'File blame', :js, feature_category: :projects do
within '[data-testid="blob-content-holder"]' do
expect(page).to have_css('.blame-commit')
expect(page).not_to have_css('.gl-pagination')
- expect(page).not_to have_link _('View entire blame')
+ expect(page).not_to have_link _('Show full blame')
end
end
context 'when blob length is over the blame range limit' do
before do
- stub_const('Projects::BlameService::PER_PAGE', 2)
+ stub_const('Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE', 2)
end
it 'displays two first lines of the file with pagination' do
@@ -53,7 +53,7 @@ RSpec.describe 'File blame', :js, feature_category: :projects do
within '[data-testid="blob-content-holder"]' do
expect(page).to have_css('.blame-commit')
expect(page).to have_css('.gl-pagination')
- expect(page).to have_link _('View entire blame')
+ expect(page).to have_link _('Show full blame')
expect(page).to have_css('#L1')
expect(page).not_to have_css('#L3')
@@ -85,19 +85,34 @@ RSpec.describe 'File blame', :js, feature_category: :projects do
end
end
- context 'when user clicks on View entire blame button' do
+ shared_examples 'a full blame page' do
+ context 'when user clicks on Show full blame button' do
+ before do
+ visit_blob_blame(path)
+ click_link _('Show full blame')
+ end
+
+ it 'displays the blame page without pagination' do
+ within '[data-testid="blob-content-holder"]' do
+ expect(page).to have_css('#L1')
+ expect(page).to have_css('#L667')
+ expect(page).not_to have_css('.gl-pagination')
+ end
+ end
+ end
+ end
+
+ context 'when streaming is enabled' do
before do
- visit_blob_blame(path)
+ stub_const('Gitlab::Git::BlamePagination::STREAMING_PER_PAGE', 50)
end
- it 'displays the blame page without pagination' do
- within '[data-testid="blob-content-holder"]' do
- click_link _('View entire blame')
+ it_behaves_like 'a full blame page'
- expect(page).to have_css('#L1')
- expect(page).to have_css('#L3')
- expect(page).not_to have_css('.gl-pagination')
- end
+ it 'shows loading text', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410499' do
+ visit_blob_blame(path)
+ click_link _('Show full blame')
+ expect(page).to have_text('Loading full blame...')
end
end
@@ -112,7 +127,7 @@ RSpec.describe 'File blame', :js, feature_category: :projects do
within '[data-testid="blob-content-holder"]' do
expect(page).to have_css('.blame-commit')
expect(page).not_to have_css('.gl-pagination')
- expect(page).not_to have_link _('View entire blame')
+ expect(page).not_to have_link _('Show full blame')
end
end
end
@@ -120,7 +135,7 @@ RSpec.describe 'File blame', :js, feature_category: :projects do
context 'when blob length is over global max page limit' do
before do
- stub_const('Projects::BlameService::PER_PAGE', 200)
+ stub_const('Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE', 200)
end
let(:path) { 'files/markdown/ruby-style-guide.md' }
diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb
index 7faf0e1a6b1..cd1dde55e30 100644
--- a/spec/features/projects/blobs/blob_show_spec.rb
+++ b/spec/features/projects/blobs/blob_show_spec.rb
@@ -137,11 +137,13 @@ RSpec.describe 'File blob', :js, feature_category: :projects do
context 'when ref switch' do
def switch_ref_to(ref_name)
- first('[data-testid="branches-select"]').click
+ find('.ref-selector').click
+ wait_for_requests
- page.within '.project-refs-form' do
- click_link ref_name
+ page.within('.ref-selector') do
+ fill_in 'Search by Git revision', with: ref_name
wait_for_requests
+ find('li', text: ref_name, match: :prefer_exact).click
end
end
@@ -193,10 +195,11 @@ RSpec.describe 'File blob', :js, feature_category: :projects do
end
end
- it 'successfully changes ref when the ref name matches the project name' do
- project.repository.create_branch(project.name)
+ # Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/330947
+ it 'successfully changes ref when the ref name matches the project path' do
+ project.repository.create_branch(project.path)
- visit_blob('files/js/application.js', ref: project.name)
+ visit_blob('files/js/application.js', ref: project.path)
switch_ref_to('master')
aggregate_failures do
@@ -577,7 +580,11 @@ RSpec.describe 'File blob', :js, feature_category: :projects do
end
describe '.gitlab/dashboards/custom-dashboard.yml' do
+ let(:remove_monitor_metrics) { false }
+
before do
+ stub_feature_flags(remove_monitor_metrics: remove_monitor_metrics)
+
project.add_maintainer(project.creator)
Files::CreateService.new(
@@ -605,6 +612,15 @@ RSpec.describe 'File blob', :js, feature_category: :projects do
expect(page).to have_link('Learn more')
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ let(:remove_monitor_metrics) { true }
+
+ it 'displays the blob without an auxiliary viewer' do
+ expect(page).to have_content('Environment metrics')
+ expect(page).not_to have_content('Metrics Dashboard YAML definition', wait: 0)
+ end
+ end
end
context 'invalid dashboard file' do
diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb
index 144b4ed85cd..6e335871ed1 100644
--- a/spec/features/projects/blobs/edit_spec.rb
+++ b/spec/features/projects/blobs/edit_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe 'Editing file blob', :js, feature_category: :projects do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
+ include Features::SourceEditorSpecHelpers
include TreeHelper
- include BlobSpecHelpers
+ include Features::BlobSpecHelpers
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') }
@@ -142,7 +142,7 @@ RSpec.describe 'Editing file blob', :js, feature_category: :projects do
it 'renders content with CommonMark' do
visit project_edit_blob_path(project, tree_join(branch, readme_file_path))
fill_editor(content: '1. one\\n - sublist\\n')
- click_link 'Preview'
+ click_on "Preview"
wait_for_requests
# the above generates two separate lists (not embedded) in CommonMark
diff --git a/spec/features/projects/blobs/user_views_pipeline_editor_button_spec.rb b/spec/features/projects/blobs/user_views_pipeline_editor_button_spec.rb
index 2f67e909543..3b383793de2 100644
--- a/spec/features/projects/blobs/user_views_pipeline_editor_button_spec.rb
+++ b/spec/features/projects/blobs/user_views_pipeline_editor_button_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'User views pipeline editor button on root ci config file', :js, feature_category: :projects do
- include BlobSpecHelpers
+ include Features::BlobSpecHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
diff --git a/spec/features/projects/branches/user_creates_branch_spec.rb b/spec/features/projects/branches/user_creates_branch_spec.rb
index 60bd77393e9..5aa10a8d4b0 100644
--- a/spec/features/projects/branches/user_creates_branch_spec.rb
+++ b/spec/features/projects/branches/user_creates_branch_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'User creates branch', :js, feature_category: :projects do
- include Spec::Support::Helpers::Features::BranchesHelpers
+ include Features::BranchesHelpers
let_it_be(:group) { create(:group, :public) }
let_it_be(:user) { create(:user) }
@@ -81,7 +81,9 @@ RSpec.describe 'User creates branch', :js, feature_category: :projects do
it 'does not create new branch' do
invalid_branch_name = '1.0 stable'
- create_branch(invalid_branch_name)
+ fill_in("branch_name", with: invalid_branch_name)
+ find('body').click
+ click_button("Create branch")
expect(page).to have_content('Branch name is invalid')
expect(page).to have_content("can't contain spaces")
diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb
index fc7833809b3..e1f1a63565c 100644
--- a/spec/features/projects/branches_spec.rb
+++ b/spec/features/projects/branches_spec.rb
@@ -201,6 +201,12 @@ RSpec.describe 'Branches', feature_category: :projects do
end
end
+ describe 'Link to branch rules' do
+ it 'does not have possibility to navigate to branch rules', :js do
+ expect(page).not_to have_content(s_("Branches|View branch rules"))
+ end
+ end
+
context 'on project with 0 branch' do
let(:project) { create(:project, :public, :empty_repo) }
let(:repository) { project.repository }
@@ -239,6 +245,17 @@ RSpec.describe 'Branches', feature_category: :projects do
expect(page).not_to have_content 'Merge request'
end
end
+
+ describe 'Navigate to branch rules from branches page' do
+ it 'shows repository settings page with Branch rules section expanded' do
+ visit project_branches_path(project)
+
+ view_branch_rules
+
+ expect(page).to have_content(
+ _('Define rules for who can push, merge, and the required approvals for each branch.'))
+ end
+ end
end
end
@@ -353,4 +370,11 @@ RSpec.describe 'Branches', feature_category: :projects do
click_button 'Yes, delete branch'
end
end
+
+ def view_branch_rules
+ page.within('.nav-controls') do
+ click_link s_("Branches|View branch rules")
+ end
+ wait_for_requests
+ end
end
diff --git a/spec/features/projects/ci/editor_spec.rb b/spec/features/projects/ci/editor_spec.rb
index 536152626af..9851194bd3c 100644
--- a/spec/features/projects/ci/editor_spec.rb
+++ b/spec/features/projects/ci/editor_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_authoring do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
+RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_composition do
+ include Features::SourceEditorSpecHelpers
let(:project) { create(:project_empty_repo, :public) }
let(:user) { create(:user) }
@@ -101,7 +101,7 @@ RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_authoring do
end
end
- it 'user who tries to navigate away can cancel the action and keep their changes' do
+ it 'user who tries to navigate away can cancel the action and keep their changes', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410496' do
click_link 'Pipelines'
page.driver.browser.switch_to.alert.dismiss
@@ -113,7 +113,7 @@ RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_authoring do
end
end
- it 'user who tries to navigate away can confirm the action and discard their change' do
+ it 'user who tries to navigate away can confirm the action and discard their change', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410496' do
click_link 'Pipelines'
page.driver.browser.switch_to.alert.accept
diff --git a/spec/features/projects/ci/lint_spec.rb b/spec/features/projects/ci/lint_spec.rb
index 4fea07b18bc..bc370a296e4 100644
--- a/spec/features/projects/ci/lint_spec.rb
+++ b/spec/features/projects/ci/lint_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe 'CI Lint', :js, feature_category: :pipeline_authoring do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
+RSpec.describe 'CI Lint', :js, feature_category: :pipeline_composition do
+ include Features::SourceEditorSpecHelpers
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb
index 114182982e2..f9195904ea3 100644
--- a/spec/features/projects/clusters/gcp_spec.rb
+++ b/spec/features/projects/clusters/gcp_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Gcp Cluster', :js, feature_category: :kubernetes_management do
+RSpec.describe 'Gcp Cluster', :js, feature_category: :deployment_management do
include GoogleApi::CloudPlatformHelpers
let(:project) { create(:project) }
diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb
index 34fc0a76c7f..eb2601bb85f 100644
--- a/spec/features/projects/clusters/user_spec.rb
+++ b/spec/features/projects/clusters/user_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'User Cluster', :js, feature_category: :kubernetes_management do
+RSpec.describe 'User Cluster', :js, feature_category: :deployment_management do
include GoogleApi::CloudPlatformHelpers
let(:project) { create(:project) }
diff --git a/spec/features/projects/commit/cherry_pick_spec.rb b/spec/features/projects/commit/cherry_pick_spec.rb
index 93ce851521f..b608fc953f3 100644
--- a/spec/features/projects/commit/cherry_pick_spec.rb
+++ b/spec/features/projects/commit/cherry_pick_spec.rb
@@ -56,7 +56,7 @@ RSpec.describe 'Cherry-pick Commits', :js, feature_category: :source_code_manage
cherry_pick_commit
- expect(page).to have_content('Sorry, we cannot cherry-pick this commit automatically.')
+ expect(page).to have_content('Commit cherry-pick failed:')
end
end
diff --git a/spec/features/projects/commit/comments/user_adds_comment_spec.rb b/spec/features/projects/commit/comments/user_adds_comment_spec.rb
index c53ac27bb5f..b0cb57f158d 100644
--- a/spec/features/projects/commit/comments/user_adds_comment_spec.rb
+++ b/spec/features/projects/commit/comments/user_adds_comment_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
RSpec.describe "User adds a comment on a commit", :js, feature_category: :source_code_management do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
include RepoHelpers
let(:comment_text) { "XML attached" }
@@ -36,7 +36,7 @@ RSpec.describe "User adds a comment on a commit", :js, feature_category: :source
expect(page).not_to have_css(".js-note-text")
# Check on the `Write` tab
- click_button("Write")
+ click_button("Continue editing")
expect(page).to have_field("note[note]", with: "#{comment_text} #{emoji}")
@@ -107,7 +107,7 @@ RSpec.describe "User adds a comment on a commit", :js, feature_category: :source
# Test UI elements, then submit.
page.within("form[data-line-code='#{sample_commit.line_code}']") do
expect(find(".js-note-text", visible: false).text).to eq("")
- expect(page).to have_css('.js-md-write-button')
+ expect(page).to have_css('.js-md-preview')
click_button("Comment")
end
diff --git a/spec/features/projects/commit/comments/user_deletes_comments_spec.rb b/spec/features/projects/commit/comments/user_deletes_comments_spec.rb
index a1e7ddb4d6e..e265756f930 100644
--- a/spec/features/projects/commit/comments/user_deletes_comments_spec.rb
+++ b/spec/features/projects/commit/comments/user_deletes_comments_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
RSpec.describe "User deletes comments on a commit", :js, feature_category: :source_code_management do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
include Spec::Support::Helpers::ModalHelpers
include RepoHelpers
@@ -30,7 +30,7 @@ RSpec.describe "User deletes comments on a commit", :js, feature_category: :sour
note.hover
find(".more-actions").click
- find(".more-actions .dropdown-menu li", match: :first)
+ find(".more-actions li", match: :first)
find(".js-note-delete").click
end
diff --git a/spec/features/projects/commit/comments/user_edits_comments_spec.rb b/spec/features/projects/commit/comments/user_edits_comments_spec.rb
index 9019a981a18..b0b963de91b 100644
--- a/spec/features/projects/commit/comments/user_edits_comments_spec.rb
+++ b/spec/features/projects/commit/comments/user_edits_comments_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
RSpec.describe "User edits a comment on a commit", :js, feature_category: :source_code_management do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
include RepoHelpers
let(:project) { create(:project, :repository) }
diff --git a/spec/features/projects/commit/diff_notes_spec.rb b/spec/features/projects/commit/diff_notes_spec.rb
index f29e0803f61..1f4358db9cd 100644
--- a/spec/features/projects/commit/diff_notes_spec.rb
+++ b/spec/features/projects/commit/diff_notes_spec.rb
@@ -8,18 +8,15 @@ RSpec.describe 'Commit diff', :js, feature_category: :source_code_management do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
- using RSpec::Parameterized::TableSyntax
-
- where(:view, :async_diff_file_loading) do
- 'inline' | true
- 'inline' | false
- 'parallel' | true
- 'parallel' | false
+ where(:view) do
+ [
+ ['inline'],
+ ['parallel']
+ ]
end
with_them do
before do
- stub_feature_flags(async_commit_diff_files: async_diff_file_loading)
project.add_maintainer(user)
sign_in user
visit project_commit_path(project, sample_commit.id, view: view)
diff --git a/spec/features/projects/commit/user_comments_on_commit_spec.rb b/spec/features/projects/commit/user_comments_on_commit_spec.rb
index 66a407b5ff6..709914434e7 100644
--- a/spec/features/projects/commit/user_comments_on_commit_spec.rb
+++ b/spec/features/projects/commit/user_comments_on_commit_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
RSpec.describe "User comments on commit", :js, feature_category: :source_code_management do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
include Spec::Support::Helpers::ModalHelpers
include RepoHelpers
@@ -38,7 +38,7 @@ RSpec.describe "User comments on commit", :js, feature_category: :source_code_ma
expect(page).not_to have_css(".js-note-text")
# Check on `Write` tab
- click_button("Write")
+ click_button("Continue editing")
expect(page).to have_field("note[note]", with: "#{comment_text} #{emoji_code}")
@@ -109,7 +109,7 @@ RSpec.describe "User comments on commit", :js, feature_category: :source_code_ma
note.hover
find(".more-actions").click
- find(".more-actions .dropdown-menu li", match: :first)
+ find(".more-actions li", match: :first)
find(".js-note-delete").click
end
diff --git a/spec/features/projects/commit/user_reverts_commit_spec.rb b/spec/features/projects/commit/user_reverts_commit_spec.rb
index 8c7b8e6ba32..4d2abf55675 100644
--- a/spec/features/projects/commit/user_reverts_commit_spec.rb
+++ b/spec/features/projects/commit/user_reverts_commit_spec.rb
@@ -47,7 +47,7 @@ RSpec.describe 'User reverts a commit', :js, feature_category: :source_code_mana
revert_commit
- expect(page).to have_content('Sorry, we cannot revert this commit automatically.')
+ expect(page).to have_content('Commit revert failed:')
end
end
diff --git a/spec/features/projects/commit/user_sees_pipelines_tab_spec.rb b/spec/features/projects/commit/user_sees_pipelines_tab_spec.rb
new file mode 100644
index 00000000000..da83bbcb63a
--- /dev/null
+++ b/spec/features/projects/commit/user_sees_pipelines_tab_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Commit > Pipelines tab', :js, feature_category: :source_code_management do
+ let_it_be_with_reload(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ context 'when commit has pipelines' do
+ let_it_be(:pipeline) do
+ create(:ci_pipeline,
+ :success,
+ project: project,
+ ref: project.default_branch,
+ sha: project.commit.sha)
+ end
+
+ let_it_be(:job) { create(:ci_build, :success, pipeline: pipeline) }
+ let_it_be(:manual_job) { create(:ci_build, :manual, pipeline: pipeline) }
+
+ before do
+ visit project_commit_path(project, project.commit.id)
+ wait_for_requests
+ end
+
+ it 'displays pipelines table' do
+ page.within('.commit-ci-menu') do
+ click_link('Pipelines')
+ end
+
+ wait_for_requests
+
+ page.within('[data-testid="pipeline-table-row"]') do
+ expect(page).to have_selector('.ci-success')
+ expect(page).to have_content(pipeline.id)
+ expect(page).to have_content('API')
+ expect(page).to have_css('[data-testid="pipeline-mini-graph"]')
+ expect(page).to have_css('[data-testid="pipelines-manual-actions-dropdown"]')
+ expect(page).to have_css('[data-testid="pipeline-multi-actions-dropdown"]')
+ end
+ end
+ end
+
+ context 'when commit does not have pipelines' do
+ before do
+ visit project_commit_path(project, project.commit.id)
+ end
+
+ it 'does not display pipelines tab link' do
+ expect(page).not_to have_link('Pipelines')
+ end
+ end
+end
diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb
index 8284299443f..4c13d23559b 100644
--- a/spec/features/projects/compare_spec.rb
+++ b/spec/features/projects/compare_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe "Compare", :js, feature_category: :projects do
sign_in user
end
- describe "branches" do
+ shared_examples "compare view of branches" do
shared_examples 'compares branches' do
it 'compares branches' do
visit project_compare_index_path(project, from: 'master', to: 'master')
@@ -114,7 +114,7 @@ RSpec.describe "Compare", :js, feature_category: :projects do
click_button('Compare')
page.within('[data-testid="too-many-changes-alert"]') do
- expect(page).to have_text("Too many changes to show. To preserve performance only 3 of 3+ files are displayed.")
+ expect(page).to have_text("Some changes are not shown. For a faster browsing experience, only 3 of 3+ files are shown. Download one of the files below to see all changes.")
end
end
end
@@ -148,7 +148,7 @@ RSpec.describe "Compare", :js, feature_category: :projects do
end
end
- describe "tags" do
+ shared_examples "compare view of tags" do
it "compares tags" do
visit project_compare_index_path(project, from: "master", to: "master")
@@ -182,4 +182,17 @@ RSpec.describe "Compare", :js, feature_category: :projects do
dropdown.all(".js-compare-#{dropdown_type}-dropdown .dropdown-item", text: selection).first.click
end
end
+
+ it_behaves_like "compare view of branches"
+ it_behaves_like "compare view of tags"
+
+ context "when super sidebar is enabled" do
+ before do
+ user.update!(use_new_navigation: true)
+ stub_feature_flags(super_sidebar_nav: true)
+ end
+
+ it_behaves_like "compare view of branches"
+ it_behaves_like "compare view of tags"
+ end
end
diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb
index 98cf024afa8..5306a9f15c6 100644
--- a/spec/features/projects/container_registry_spec.rb
+++ b/spec/features/projects/container_registry_spec.rb
@@ -30,6 +30,20 @@ RSpec.describe 'Container Registry', :js, feature_category: :projects do
expect(page).to have_title _('Container Registry')
end
+ it 'does not have link to settings' do
+ visit_container_registry
+
+ expect(page).not_to have_link _('Configure in settings')
+ end
+
+ it 'has link to settings when user is maintainer' do
+ project.add_maintainer(user)
+
+ visit_container_registry
+
+ expect(page).to have_link _('Configure in settings')
+ end
+
context 'when there are no image repositories' do
it 'list page has no container title' do
visit_container_registry
@@ -63,7 +77,8 @@ RSpec.describe 'Container Registry', :js, feature_category: :projects do
expect(DeleteContainerRepositoryWorker).not_to receive(:perform_async)
find('[title="Remove repository"]').click
- expect(find('.modal .modal-title')).to have_content _('Remove repository')
+ expect(find('.modal .modal-title')).to have_content _('Delete image repository?')
+ find('.modal .modal-body input').set('my/image')
find('.modal .modal-footer .btn-danger').click
end
@@ -101,7 +116,11 @@ RSpec.describe 'Container Registry', :js, feature_category: :projects do
first('[data-testid="additional-actions"]').click
first('[data-testid="single-delete-button"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
+ stub_container_registry_tags(repository: %r{my/image}, tags: ('1'..'19').to_a, with_manifest: true)
find('.modal .modal-footer .btn-danger').click
+
+ expect(page).to have_content '19 tags'
+ expect(page).not_to have_content '20 tags'
end
it('pagination navigate to the second page') do
diff --git a/spec/features/projects/environments/environment_metrics_spec.rb b/spec/features/projects/environments/environment_metrics_spec.rb
index 4a112445ab9..e212d464029 100644
--- a/spec/features/projects/environments/environment_metrics_spec.rb
+++ b/spec/features/projects/environments/environment_metrics_spec.rb
@@ -14,10 +14,13 @@ RSpec.describe 'Environment > Metrics', feature_category: :projects do
let!(:staging) { create(:environment, name: 'staging', project: project) }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
+
project.add_developer(user)
stub_any_prometheus_request
sign_in(user)
+ stub_feature_flags(remove_monitor_metrics: false)
end
around do |example|
@@ -65,6 +68,18 @@ RSpec.describe 'Environment > Metrics', feature_category: :projects do
it_behaves_like 'has environment selector'
end
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'does not provide a link to the monitoring dashboard' do
+ visit_environment(environment)
+
+ expect(page).not_to have_link('Monitoring')
+ end
+ end
+
def visit_environment(environment)
visit project_environment_path(environment.project, environment)
end
diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb
index 91401d19fd1..527a146ff73 100644
--- a/spec/features/projects/environments/environment_spec.rb
+++ b/spec/features/projects/environments/environment_spec.rb
@@ -94,6 +94,36 @@ RSpec.describe 'Environment', feature_category: :projects do
expect(page).to have_link("#{build.name} (##{build.id})")
end
end
+
+ context 'with related deployable present' do
+ let_it_be(:previous_pipeline) { create(:ci_pipeline, project: project) }
+
+ let_it_be(:previous_build) do
+ create(:ci_build, :success, pipeline: previous_pipeline, environment: environment.name)
+ end
+
+ let_it_be(:previous_deployment) do
+ create(:deployment, :success, environment: environment, deployable: previous_build)
+ end
+
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be(:build) { create(:ci_build, pipeline: pipeline, environment: environment.name) }
+
+ let_it_be(:deployment) do
+ create(:deployment, :success, environment: environment, deployable: build)
+ end
+
+ before do
+ visit_environment(environment)
+ end
+
+ it 'shows deployment information and buttons', :js do
+ wait_for_requests
+ expect(page).to have_button('Re-deploy to environment')
+ expect(page).to have_button('Rollback environment')
+ expect(page).to have_link("#{build.name} (##{build.id})")
+ end
+ end
end
end
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index 788bf6477b1..b50fc59ac32 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -31,16 +31,16 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
end
describe 'page tabs' do
- it 'shows "Available" and "Stopped" tab with links' do
- visit_environments(project)
-
- expect(page).to have_link(_('Available'))
- expect(page).to have_link(_('Stopped'))
- end
-
describe 'with one available environment' do
let!(:environment) { create(:environment, project: project, state: :available) }
+ it 'shows "Available" and "Stopped" tab with links' do
+ visit_environments(project)
+
+ expect(page).to have_link(_('Available'))
+ expect(page).to have_link(_('Stopped'))
+ end
+
describe 'in available tab page' do
it 'shows one environment' do
visit_environments(project, scope: 'available')
@@ -70,7 +70,7 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
it 'shows no environments' do
visit_environments(project, scope: 'stopped')
- expect(page).to have_content(s_('Environments|You don\'t have any stopped environments.'))
+ expect(page).to have_content(s_('Environments|Get started with environments'))
end
end
@@ -99,7 +99,7 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
it 'shows no environments' do
visit_environments(project, scope: 'available')
- expect(page).to have_content(s_('Environments|You don\'t have any environments.'))
+ expect(page).to have_content(s_('Environments|Get started with environments'))
end
end
@@ -119,11 +119,11 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
visit_environments(project)
end
- it 'does not show environments and counters are set to zero' do
- expect(page).to have_content(s_('Environments|You don\'t have any environments.'))
+ it 'does not show environments and tabs' do
+ expect(page).to have_content(s_('Environments|Get started with environments'))
- expect(page).to have_link("#{_('Available')} 0")
- expect(page).to have_link("#{_('Stopped')} 0")
+ expect(page).not_to have_link(_('Available'))
+ expect(page).not_to have_link(_('Stopped'))
end
end
@@ -175,7 +175,7 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
context 'when builds and manual actions are present' do
let!(:pipeline) { create(:ci_pipeline, project: project) }
- let!(:build) { create(:ci_build, pipeline: pipeline) }
+ let!(:build) { create(:ci_build, :success, pipeline: pipeline) }
let!(:action) do
create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production')
@@ -207,8 +207,14 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
.not_to change { Ci::Pipeline.count }
end
- it 'shows a stop button' do
+ it 'shows a stop button and dialog' do
expect(page).to have_selector(stop_button_selector)
+
+ click_button(_('Stop'))
+
+ within('.modal-body') do
+ expect(page).to have_css('.warning_message')
+ end
end
it 'does not show external link button' do
@@ -216,7 +222,6 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
end
it 'does not show terminal button' do
- expect(page).not_to have_button(_('More actions'))
expect(page).not_to have_terminal_button
end
@@ -242,8 +247,14 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
on_stop: 'close_app')
end
- it 'shows a stop button' do
+ it 'shows a stop button and dialog' do
expect(page).to have_selector(stop_button_selector)
+
+ click_button(_('Stop'))
+
+ within('.modal-body') do
+ expect(page).not_to have_css('.warning_message')
+ end
end
context 'when user is a reporter' do
@@ -273,7 +284,6 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
let(:role) { :developer }
it 'does not show terminal button' do
- expect(page).not_to have_button(_('More actions'))
expect(page).not_to have_terminal_button
end
end
@@ -344,7 +354,7 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
wait_for_requests
end
- it 'enqueues the delayed job', :js do
+ it 'enqueues the delayed job', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409990' do
expect(delayed_job.reload).to be_pending
end
end
@@ -360,7 +370,7 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
sha: project.commit.id)
end
- it 'does not show deployments' do
+ it 'does not show deployments', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409990' do
visit_environments(project)
page.click_button _('Expand')
@@ -393,7 +403,7 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
it 'does have a new environment button' do
visit_environments(project)
- expect(page).to have_link('New environment')
+ expect(page).to have_link('Create an environment')
end
describe 'creating a new environment' do
@@ -405,7 +415,7 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
let(:role) { :developer }
it 'developer creates a new environment with a valid name' do
- click_link 'New environment'
+ click_link 'Create an environment'
fill_in('Name', with: 'production')
click_on 'Save'
@@ -413,7 +423,7 @@ RSpec.describe 'Environments page', :js, feature_category: :projects do
end
it 'developer creates a new environment with invalid name' do
- click_link 'New environment'
+ click_link 'Create an environment'
fill_in('Name', with: 'name,with,commas')
click_on 'Save'
diff --git a/spec/features/projects/files/dockerfile_dropdown_spec.rb b/spec/features/projects/files/dockerfile_dropdown_spec.rb
index 1e05bdae204..ec1f03570d9 100644
--- a/spec/features/projects/files/dockerfile_dropdown_spec.rb
+++ b/spec/features/projects/files/dockerfile_dropdown_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects > Files > User wants to add a Dockerfile file', :js, feature_category: :projects do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
+ include Features::SourceEditorSpecHelpers
before do
project = create(:project, :repository)
diff --git a/spec/features/projects/files/editing_a_file_spec.rb b/spec/features/projects/files/editing_a_file_spec.rb
index 04f45de42cc..1f928da0427 100644
--- a/spec/features/projects/files/editing_a_file_spec.rb
+++ b/spec/features/projects/files/editing_a_file_spec.rb
@@ -3,7 +3,8 @@
require 'spec_helper'
RSpec.describe 'Projects > Files > User wants to edit a file', feature_category: :projects do
- let(:project) { create(:project, :repository) }
+ include ProjectForksHelper
+ let(:project) { create(:project, :repository, :public) }
let(:user) { project.first_owner }
let(:commit_params) do
{
@@ -17,17 +18,48 @@ RSpec.describe 'Projects > Files > User wants to edit a file', feature_category:
}
end
- before do
- sign_in user
- visit project_edit_blob_path(project,
- File.join(project.default_branch, '.gitignore'))
+ context 'when the user has write access' do
+ before do
+ sign_in user
+ visit project_edit_blob_path(project,
+ File.join(project.default_branch, '.gitignore'))
+ end
+
+ it 'file has been updated since the user opened the edit page' do
+ Files::UpdateService.new(project, user, commit_params).execute
+
+ click_button 'Commit changes'
+
+ expect(page).to have_content 'Someone edited the file the same time you did.'
+ end
end
- it 'file has been updated since the user opened the edit page' do
- Files::UpdateService.new(project, user, commit_params).execute
+ context 'when the user does not have write access' do
+ let(:user) { create(:user) }
+
+ context 'and the user has a fork of the project' do
+ let(:forked_project) { fork_project(project, user, namespace: user.namespace, repository: true) }
+
+ before do
+ forked_project
+ sign_in user
+ visit project_edit_blob_path(project,
+ File.join(project.default_branch, '.gitignore'))
+ end
+
+ context 'and the forked project is ahead of the upstream project' do
+ before do
+ Files::UpdateService.new(forked_project, user, commit_params).execute
+ end
- click_button 'Commit changes'
+ it 'renders an error message' do
+ click_button 'Commit changes'
- expect(page).to have_content 'Someone edited the file the same time you did.'
+ expect(page).to have_content(
+ %(Error: Can't edit this file. The fork and upstream project have diverged. Edit the file on the fork)
+ )
+ end
+ end
+ end
end
end
diff --git a/spec/features/projects/files/gitignore_dropdown_spec.rb b/spec/features/projects/files/gitignore_dropdown_spec.rb
index 5e11a94e65b..eedb79167bd 100644
--- a/spec/features/projects/files/gitignore_dropdown_spec.rb
+++ b/spec/features/projects/files/gitignore_dropdown_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects > Files > User wants to add a .gitignore file', :js, feature_category: :projects do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
+ include Features::SourceEditorSpecHelpers
before do
project = create(:project, :repository)
diff --git a/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb b/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb
index 67678a937e5..f2d657b3513 100644
--- a/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb
+++ b/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file', :js, feature_category: :projects do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
+ include Features::SourceEditorSpecHelpers
let(:params) { {} }
let(:filename) { '.gitlab-ci.yml' }
diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
index 8d64151e680..cfa55eba188 100644
--- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
+++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'Projects > Files > Project owner sees a link to create a license file in empty project', :js,
feature_category: :projects do
- include WebIdeSpecHelpers
+ include Features::WebIdeSpecHelpers
let(:project) { create(:project_empty_repo) }
let(:project_maintainer) { project.first_owner }
diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb
index 8082d1bdf63..9b9c2158432 100644
--- a/spec/features/projects/files/user_browses_files_spec.rb
+++ b/spec/features/projects/files/user_browses_files_spec.rb
@@ -11,8 +11,7 @@ RSpec.describe "User browses files", :js, feature_category: :projects do
"A fork of this project has been created that you can make changes in, so you can submit a merge request."
end
- let(:project) { create(:project, :repository, name: "Shop") }
- let(:project2) { create(:project, :repository, name: "Another Project", path: "another-project") }
+ let_it_be(:project) { create(:project, :repository) }
let(:tree_path_root_ref) { project_tree_path(project, project.repository.root_ref) }
let(:user) { project.first_owner }
@@ -142,7 +141,9 @@ RSpec.describe "User browses files", :js, feature_category: :projects do
expect(page).to have_current_path(project_blob_path(project, "markdown/doc/raketasks/maintenance.md"), ignore_query: true)
expect(page).to have_content("bundle exec rake gitlab:env:info RAILS_ENV=production")
- click_link("shop")
+ page.within(".tree-ref-container") do
+ click_link(project.path)
+ end
page.within(".tree-table") do
click_link("README.md")
diff --git a/spec/features/projects/files/user_creates_files_spec.rb b/spec/features/projects/files/user_creates_files_spec.rb
index 97ccb45dfc6..42aceef256a 100644
--- a/spec/features/projects/files/user_creates_files_spec.rb
+++ b/spec/features/projects/files/user_creates_files_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe 'Projects > Files > User creates files', :js, feature_category: :projects do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
- include BlobSpecHelpers
+ include Features::SourceEditorSpecHelpers
+ include Features::BlobSpecHelpers
let(:fork_message) do
"You're not allowed to make changes to this project directly. "\
diff --git a/spec/features/projects/files/user_edits_files_spec.rb b/spec/features/projects/files/user_edits_files_spec.rb
index 5a61aa146a2..779257b2e2b 100644
--- a/spec/features/projects/files/user_edits_files_spec.rb
+++ b/spec/features/projects/files/user_edits_files_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe 'Projects > Files > User edits files', :js, feature_category: :projects do
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
+ include Features::SourceEditorSpecHelpers
include ProjectForksHelper
- include BlobSpecHelpers
+ include Features::BlobSpecHelpers
let(:project) { create(:project, :repository, name: 'Shop') }
let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') }
diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb
index 8b484141a95..39cdc8faa85 100644
--- a/spec/features/projects/fork_spec.rb
+++ b/spec/features/projects/fork_spec.rb
@@ -119,16 +119,6 @@ RSpec.describe 'Project fork', feature_category: :projects do
end
end
- shared_examples "increments the fork counter on the source project's page" do
- specify :sidekiq_might_not_need_inline do
- create_forks
-
- visit project_path(project)
-
- expect(page).to have_css('.fork-count', text: 2)
- end
- end
-
it_behaves_like 'fork button on project page'
it_behaves_like 'create fork page', 'Fork project'
@@ -185,25 +175,17 @@ RSpec.describe 'Project fork', feature_category: :projects do
end
end
- context 'with cache_home_panel feature flag' do
+ context 'when user is a maintainer in multiple groups' do
before do
create(:group_member, :maintainer, user: user, group: group2)
end
- context 'when caching is enabled' do
- before do
- stub_feature_flags(cache_home_panel: project)
- end
-
- it_behaves_like "increments the fork counter on the source project's page"
- end
+ it "increments the fork counter on the source project's page", :sidekiq_might_not_need_inline do
+ create_forks
- context 'when caching is disabled' do
- before do
- stub_feature_flags(cache_home_panel: false)
- end
+ visit project_path(project)
- it_behaves_like "increments the fork counter on the source project's page"
+ expect(page).to have_css('.fork-count', text: 2)
end
end
end
diff --git a/spec/features/projects/import_export/export_file_spec.rb b/spec/features/projects/import_export/export_file_spec.rb
index 6630956f835..3c39d8745a4 100644
--- a/spec/features/projects/import_export/export_file_spec.rb
+++ b/spec/features/projects/import_export/export_file_spec.rb
@@ -40,59 +40,28 @@ RSpec.describe 'Import/Export - project export integration test', :js, feature_c
sign_in(user)
end
- context "with streaming serializer" do
- before do
- stub_feature_flags(project_export_as_ndjson: false)
- end
-
- it 'exports a project successfully', :sidekiq_inline do
- export_project_and_download_file(page, project)
-
- in_directory_with_expanded_export(project) do |exit_status, tmpdir|
- expect(exit_status).to eq(0)
+ it 'exports a project successfully', :sidekiq_inline do
+ export_project_and_download_file(page, project)
- project_json_path = File.join(tmpdir, 'project.json')
- expect(File).to exist(project_json_path)
+ in_directory_with_expanded_export(project) do |exit_status, tmpdir|
+ expect(exit_status).to eq(0)
- project_hash = Gitlab::Json.parse(File.read(project_json_path))
-
- sensitive_words.each do |sensitive_word|
- found = find_sensitive_attributes(sensitive_word, project_hash)
+ project_json_path = File.join(tmpdir, 'tree', 'project.json')
+ expect(File).to exist(project_json_path)
- expect(found).to be_nil, failure_message(found.try(:key_found), found.try(:parent), sensitive_word)
+ relations = []
+ relations << Gitlab::Json.parse(File.read(project_json_path))
+ Dir.glob(File.join(tmpdir, 'tree/project', '*.ndjson')) do |rb_filename|
+ File.foreach(rb_filename) do |line|
+ relations << Gitlab::Json.parse(line)
end
end
- end
- end
- context "with ndjson" do
- before do
- stub_feature_flags(project_export_as_ndjson: true)
- end
-
- it 'exports a project successfully', :sidekiq_inline do
- export_project_and_download_file(page, project)
-
- in_directory_with_expanded_export(project) do |exit_status, tmpdir|
- expect(exit_status).to eq(0)
-
- project_json_path = File.join(tmpdir, 'tree', 'project.json')
- expect(File).to exist(project_json_path)
-
- relations = []
- relations << Gitlab::Json.parse(File.read(project_json_path))
- Dir.glob(File.join(tmpdir, 'tree/project', '*.ndjson')) do |rb_filename|
- File.foreach(rb_filename) do |line|
- relations << Gitlab::Json.parse(line)
- end
- end
-
- relations.each do |relation_hash|
- sensitive_words.each do |sensitive_word|
- found = find_sensitive_attributes(sensitive_word, relation_hash)
+ relations.each do |relation_hash|
+ sensitive_words.each do |sensitive_word|
+ found = find_sensitive_attributes(sensitive_word, relation_hash)
- expect(found).to be_nil, failure_message(found.try(:key_found), found.try(:parent), sensitive_word)
- end
+ expect(found).to be_nil, failure_message(found.try(:key_found), found.try(:parent), sensitive_word)
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 8fb11f06cdd..f4ed0728402 100644
--- a/spec/features/projects/import_export/import_file_spec.rb
+++ b/spec/features/projects/import_export/import_file_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe 'Import/Export - project import integration test', :js, feature_c
let(:export_path) { "#{Dir.tmpdir}/import_file_spec" }
before do
+ stub_application_setting(import_sources: ['gitlab_project'])
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/import_export/test_project_export.tar.gz b/spec/features/projects/import_export/test_project_export.tar.gz
index b93da033aea..d34d72920dd 100644
--- a/spec/features/projects/import_export/test_project_export.tar.gz
+++ b/spec/features/projects/import_export/test_project_export.tar.gz
Binary files differ
diff --git a/spec/features/projects/integrations/apple_app_store_spec.rb b/spec/features/projects/integrations/apple_app_store_spec.rb
new file mode 100644
index 00000000000..a5ae7df4a89
--- /dev/null
+++ b/spec/features/projects/integrations/apple_app_store_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload Dropzone Field', feature_category: :integrations do
+ include_context 'project integration activation'
+
+ it 'uploads the file data to the correct form fields and updates the messaging correctly', :js, :aggregate_failures do
+ visit_project_integration('Apple App Store Connect')
+
+ expect(page).to have_content('Drag your Private Key file here or click to upload.')
+ expect(page).not_to have_content('auth_key.p8')
+
+ find("input[name='service[dropzone_file_name]']",
+ visible: false).set(Rails.root.join('spec/fixtures/auth_key.p8'))
+
+ expect(page).to have_field("service[app_store_private_key]", type: :hidden,
+ with: File.read(Rails.root.join('spec/fixtures/auth_key.p8')))
+ expect(page).to have_field("service[app_store_private_key_file_name]", type: :hidden, with: 'auth_key.p8')
+
+ expect(page).not_to have_content('Drag your Private Key file here or click to upload.')
+ expect(page).to have_content('auth_key.p8')
+ end
+end
diff --git a/spec/features/projects/integrations/google_play_spec.rb b/spec/features/projects/integrations/google_play_spec.rb
new file mode 100644
index 00000000000..db867fc40d7
--- /dev/null
+++ b/spec/features/projects/integrations/google_play_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Upload Dropzone Field', feature_category: :integrations do
+ include_context 'project integration activation'
+
+ it 'uploads the file data to the correct form fields and updates the messaging correctly', :js, :aggregate_failures do
+ visit_project_integration('Google Play')
+
+ expect(page).to have_content('Drag your key file here or click to upload.')
+ expect(page).not_to have_content('service_account.json')
+
+ find("input[name='service[dropzone_file_name]']",
+ visible: false).set(Rails.root.join('spec/fixtures/service_account.json'))
+
+ expect(page).to have_field("service[service_account_key]", type: :hidden,
+ with: File.read(Rails.root.join('spec/fixtures/service_account.json')))
+ expect(page).to have_field("service[service_account_key_file_name]", type: :hidden, with: 'service_account.json')
+
+ expect(page).not_to have_content('Drag your key file here or click to upload.')
+ expect(page).to have_content('service_account.json')
+ end
+end
diff --git a/spec/features/projects/integrations/project_integrations_spec.rb b/spec/features/projects/integrations/project_integrations_spec.rb
index d99b6ca9092..94dbc401726 100644
--- a/spec/features/projects/integrations/project_integrations_spec.rb
+++ b/spec/features/projects/integrations/project_integrations_spec.rb
@@ -12,4 +12,16 @@ RSpec.describe 'Project integrations', :js, feature_category: :integrations do
visit_project_integration(integration.title)
end
end
+
+ context 'with remove_monitor_metrics flag enabled' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns a 404 for the prometheus edit page' do
+ visit edit_project_settings_integration_path(project, :prometheus)
+
+ expect(page).to have_content "Page Not Found"
+ end
+ end
end
diff --git a/spec/features/projects/integrations/user_activates_mattermost_slash_command_spec.rb b/spec/features/projects/integrations/user_activates_mattermost_slash_command_spec.rb
index 16c7a3ff226..aea76944c7f 100644
--- a/spec/features/projects/integrations/user_activates_mattermost_slash_command_spec.rb
+++ b/spec/features/projects/integrations/user_activates_mattermost_slash_command_spec.rb
@@ -79,7 +79,7 @@ RSpec.describe 'Set up Mattermost slash commands', :js, feature_category: :integ
select_element = find('#mattermost_team_id')
- expect(select_element['disabled']).to be_falsey
+ expect(select_element['disabled']).to eq('false')
expect(select_element.all('option').count).to eq(3)
end
@@ -99,7 +99,7 @@ RSpec.describe 'Set up Mattermost slash commands', :js, feature_category: :integ
click_link 'Add to Mattermost'
- expect(find('input[type="submit"]')['disabled']).not_to eq("true")
+ expect(find('button[type="submit"]')['disabled']).not_to eq("true")
end
it 'disables the submit button if the required fields are not provided', :js do
@@ -109,7 +109,7 @@ RSpec.describe 'Set up Mattermost slash commands', :js, feature_category: :integ
fill_in('mattermost_trigger', with: '')
- expect(find('input[type="submit"]')['disabled']).to eq("true")
+ expect(find('button[type="submit"]')['disabled']).to eq("true")
end
def stub_teams(count: 0)
@@ -145,7 +145,7 @@ RSpec.describe 'Set up Mattermost slash commands', :js, feature_category: :integ
it 'shows a token placeholder' do
token_placeholder = find_field('service_token')['placeholder']
- expect(token_placeholder).to eq('XXxxXXxxXXxxXXxxXXxxXXxx')
+ expect(token_placeholder).to eq('')
end
end
end
diff --git a/spec/features/projects/integrations/user_activates_prometheus_spec.rb b/spec/features/projects/integrations/user_activates_prometheus_spec.rb
index 5b2d885410f..a47000672ca 100644
--- a/spec/features/projects/integrations/user_activates_prometheus_spec.rb
+++ b/spec/features/projects/integrations/user_activates_prometheus_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe 'User activates Prometheus', feature_category: :integrations do
include_context 'project integration activation'
before do
+ stub_feature_flags(remove_monitor_metrics: false)
stub_request(:get, /.*prometheus.example.com.*/)
end
diff --git a/spec/features/projects/integrations/user_activates_slack_notifications_spec.rb b/spec/features/projects/integrations/user_activates_slack_notifications_spec.rb
index ec00dcaf046..01c202baf70 100644
--- a/spec/features/projects/integrations/user_activates_slack_notifications_spec.rb
+++ b/spec/features/projects/integrations/user_activates_slack_notifications_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe 'User activates Slack notifications', :js, feature_category: :int
context 'when integration is not configured yet' do
before do
- stub_feature_flags(integration_slack_app_notifications: false)
visit_project_integration('Slack notifications')
end
diff --git a/spec/features/projects/integrations/user_activates_slack_slash_command_spec.rb b/spec/features/projects/integrations/user_activates_slack_slash_command_spec.rb
index 0f6d721565e..38491501c65 100644
--- a/spec/features/projects/integrations/user_activates_slack_slash_command_spec.rb
+++ b/spec/features/projects/integrations/user_activates_slack_slash_command_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe 'Slack slash commands', :js, feature_category: :integrations do
it 'shows a token placeholder' do
token_placeholder = find_field('Token')['placeholder']
- expect(token_placeholder).to eq('XXxxXXxxXXxxXXxxXXxxXXxx')
+ expect(token_placeholder).to eq('')
end
it 'shows a help message' do
diff --git a/spec/features/projects/integrations/user_uses_inherited_settings_spec.rb b/spec/features/projects/integrations/user_uses_inherited_settings_spec.rb
index e0063a9c733..9ff344bcc88 100644
--- a/spec/features/projects/integrations/user_uses_inherited_settings_spec.rb
+++ b/spec/features/projects/integrations/user_uses_inherited_settings_spec.rb
@@ -22,17 +22,17 @@ RSpec.describe 'User uses inherited settings', :js, feature_category: :integrati
expect(page).not_to have_button('Use custom settings')
expect(page).to have_field('Web URL', with: parent_settings[:url], readonly: true)
- expect(page).to have_field('Enter new password or API token', with: '', readonly: true)
+ expect(page).to have_field('New API token, password, or Jira personal access token', with: '', readonly: true)
click_on 'Use default settings'
click_on 'Use custom settings'
expect(page).not_to have_button('Use default settings')
expect(page).to have_field('Web URL', with: project_settings[:url], readonly: false)
- expect(page).to have_field('Enter new password or API token', with: '', readonly: false)
+ expect(page).to have_field('New API token, password, or Jira personal access token', with: '', readonly: false)
fill_in 'Web URL', with: 'http://custom.com'
- fill_in 'Enter new password or API token', with: 'custom'
+ fill_in 'New API token, password, or Jira personal access token', with: 'custom'
click_save_integration
@@ -53,14 +53,14 @@ RSpec.describe 'User uses inherited settings', :js, feature_category: :integrati
expect(page).not_to have_button('Use default settings')
expect(page).to have_field('URL', with: project_settings[:url], readonly: false)
- expect(page).to have_field('Enter new password or API token', with: '', readonly: false)
+ expect(page).to have_field('New API token, password, or Jira personal access token', with: '', readonly: false)
click_on 'Use custom settings'
click_on 'Use default settings'
expect(page).not_to have_button('Use custom settings')
expect(page).to have_field('URL', with: parent_settings[:url], readonly: true)
- expect(page).to have_field('Enter new password or API token', with: '', readonly: true)
+ expect(page).to have_field('New API token, password, or Jira personal access token', with: '', readonly: true)
click_save_integration
diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb
index adf410ce6e8..77f88994bfb 100644
--- a/spec/features/projects/issuable_templates_spec.rb
+++ b/spec/features/projects/issuable_templates_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'issuable templates', :js, feature_category: :projects do
include ProjectForksHelper
+ include CookieHelper
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
@@ -12,6 +13,7 @@ RSpec.describe 'issuable templates', :js, feature_category: :projects do
before do
project.add_maintainer(user)
sign_in user
+ set_cookie('new-actions-popover-viewed', 'true')
end
context 'user creates an issue using templates' do
diff --git a/spec/features/projects/issues/viewing_relocated_issues_spec.rb b/spec/features/projects/issues/viewing_relocated_issues_spec.rb
index abd36b3ceef..f86f7bfacbd 100644
--- a/spec/features/projects/issues/viewing_relocated_issues_spec.rb
+++ b/spec/features/projects/issues/viewing_relocated_issues_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'issues canonical link', feature_category: :team_planning do
- include Spec::Support::Helpers::Features::CanonicalLinkHelpers
+ include Features::CanonicalLinkHelpers
let_it_be(:original_project) { create(:project, :public) }
let_it_be(:original_issue) { create(:issue, project: original_project) }
diff --git a/spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb b/spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb
index a9e0fce1a1c..e4394010e8c 100644
--- a/spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb
+++ b/spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe 'User triggers manual job with variables', :js, feature_category:
find("[data-testid='ci-variable-value']").set('key_value')
end
- find("[data-testid='trigger-manual-job-btn']").click
+ find("[data-testid='run-manual-job-btn']").click
wait_for_requests
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index 67389fdda8a..796bac2e8e7 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -232,7 +232,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :proj
expect(page).to have_link('New issue')
end
- it 'links to issues/new with the title and description filled in' do
+ it 'links to issues/new with the title and description filled in', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408222' do
button_title = "Job Failed ##{job.id}"
job_url = project_job_url(project, job, host: page.server.host, port: page.server.port)
options = { issue: { title: button_title, description: "Job [##{job.id}](#{job_url}) failed for #{job.sha}:\n" } }
@@ -269,13 +269,15 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :proj
let(:resource_group) { create(:ci_resource_group, project: project) }
before do
+ resource_group.assign_resource_to(create(:ci_build))
+
visit project_job_path(project, job)
wait_for_requests
end
it 'shows correct UI components' do
expect(page).to have_content("This job is waiting for resource: #{resource_group.key}")
- expect(page).to have_link("Cancel this job")
+ expect(page).to have_link("View job currently using resource")
end
end
@@ -1065,16 +1067,19 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :proj
end
context "Build from other project" do
+ let(:other_job_download_path) { download_project_job_artifacts_path(project, job2) }
+
before do
create(:ci_job_artifact, :archive, file: artifacts_file, job: job2)
end
- it do
- requests = inspect_requests do
- visit download_project_job_artifacts_path(project, job2)
- end
+ it 'receive 404 from download request', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391632' do
+ requests = inspect_requests { visit other_job_download_path }
+
+ request = requests.find { |request| request.url == other_job_download_path }
- expect(requests.first.status_code).to eq(404)
+ expect(request).to be_present
+ expect(request.status_code).to eq(404)
end
end
end
diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb
index b527b8926a0..4af5dd380c1 100644
--- a/spec/features/projects/labels/update_prioritization_spec.rb
+++ b/spec/features/projects/labels/update_prioritization_spec.rb
@@ -101,7 +101,7 @@ RSpec.describe 'Prioritize labels', feature_category: :team_planning do
expect(page).to have_content 'wontfix'
# Sort labels
- drag_to(selector: '.label-list-item', from_index: 1, to_index: 2)
+ drag_to(selector: '.label-list-item .label-content', from_index: 1, to_index: 2)
page.within('.prioritized-labels') do
expect(first('.label-list-item')).to have_content('feature')
diff --git a/spec/features/projects/members/anonymous_user_sees_members_spec.rb b/spec/features/projects/members/anonymous_user_sees_members_spec.rb
index 6b92581d704..0b8661cce82 100644
--- a/spec/features/projects/members/anonymous_user_sees_members_spec.rb
+++ b/spec/features/projects/members/anonymous_user_sees_members_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects > Members > Anonymous user sees members' do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
diff --git a/spec/features/projects/members/group_members_spec.rb b/spec/features/projects/members/group_members_spec.rb
index 416b96ab668..c0257446a37 100644
--- a/spec/features/projects/members/group_members_spec.rb
+++ b/spec/features/projects/members/group_members_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects members', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
let(:user) { create(:user) }
let(:developer) { create(:user) }
diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb
index 51acba246c5..8238f95fd47 100644
--- a/spec/features/projects/members/groups_with_access_list_spec.rb
+++ b/spec/features/projects/members/groups_with_access_list_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe 'Projects > Members > Groups with access list', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
include Spec::Support::Helpers::ModalHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::InviteMembersModalHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
diff --git a/spec/features/projects/members/manage_groups_spec.rb b/spec/features/projects/members/manage_groups_spec.rb
index b78bfacf171..5efb5abefc6 100644
--- a/spec/features/projects/members/manage_groups_spec.rb
+++ b/spec/features/projects/members/manage_groups_spec.rb
@@ -4,8 +4,8 @@ require 'spec_helper'
RSpec.describe 'Project > Members > Manage groups', :js, feature_category: :subgroups do
include ActionView::Helpers::DateHelper
- include Spec::Support::Helpers::Features::MembersHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::MembersHelpers
+ include Features::InviteMembersModalHelpers
let_it_be(:maintainer) { create(:user) }
diff --git a/spec/features/projects/members/manage_members_spec.rb b/spec/features/projects/members/manage_members_spec.rb
index 615ef1b03dd..5ae6eb83b6b 100644
--- a/spec/features/projects/members/manage_members_spec.rb
+++ b/spec/features/projects/members/manage_members_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe 'Projects > Members > Manage members', :js, feature_category: :onboarding do
- include Spec::Support::Helpers::Features::MembersHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::MembersHelpers
+ include Features::InviteMembersModalHelpers
include Spec::Support::Helpers::ModalHelpers
let_it_be(:user1) { create(:user, name: 'John Doe') }
diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
index 31c8237aacc..be778def833 100644
--- a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
+++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
@@ -4,8 +4,8 @@ require 'spec_helper'
RSpec.describe 'Projects > Members > Maintainer adds member with expiration date', :js, feature_category: :subgroups do
include ActiveSupport::Testing::TimeHelpers
- include Spec::Support::Helpers::Features::MembersHelpers
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::MembersHelpers
+ include Features::InviteMembersModalHelpers
let_it_be(:maintainer) { create(:user) }
let_it_be(:project) { create(:project, :with_namespace_settings) }
diff --git a/spec/features/projects/members/member_leaves_project_spec.rb b/spec/features/projects/members/member_leaves_project_spec.rb
index 2632bc2f5bd..91e30b3396e 100644
--- a/spec/features/projects/members/member_leaves_project_spec.rb
+++ b/spec/features/projects/members/member_leaves_project_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects > Members > Member leaves project', feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
include Spec::Support::Helpers::ModalHelpers
let(:user) { create(:user) }
diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb
index 6df1e974f42..85bf381404c 100644
--- a/spec/features/projects/members/sorting_spec.rb
+++ b/spec/features/projects/members/sorting_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects > Members > Sorting', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
let(:maintainer) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) }
@@ -148,7 +148,7 @@ RSpec.describe 'Projects > Members > Sorting', :js, feature_category: :subgroups
def expect_sort_by(text, sort_direction)
within('[data-testid="members-sort-dropdown"]') do
- expect(page).to have_css('button[aria-haspopup="true"]', text: text)
+ expect(page).to have_css('button[aria-haspopup="menu"]', text: text)
expect(page).to have_button("Sorting Direction: #{sort_direction == :asc ? 'Ascending' : 'Descending'}")
end
end
diff --git a/spec/features/projects/members/tabs_spec.rb b/spec/features/projects/members/tabs_spec.rb
index 232420224fc..9ee06edc0c1 100644
--- a/spec/features/projects/members/tabs_spec.rb
+++ b/spec/features/projects/members/tabs_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects > Members > Tabs', :js, feature_category: :subgroups do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
using RSpec::Parameterized::TableSyntax
let_it_be(:user) { create(:user) }
diff --git a/spec/features/projects/milestones/milestone_editing_spec.rb b/spec/features/projects/milestones/milestone_editing_spec.rb
new file mode 100644
index 00000000000..8a03683eb35
--- /dev/null
+++ b/spec/features/projects/milestones/milestone_editing_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "Milestone editing", :js, feature_category: :team_planning do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, name: 'test', namespace: user.namespace) }
+
+ let(:milestone) { create(:milestone, project: project, start_date: Date.today, due_date: 5.days.from_now) }
+
+ before do
+ sign_in(user)
+
+ visit(edit_project_milestone_path(project, milestone))
+ end
+
+ it_behaves_like 'milestone handling version conflicts'
+end
diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb
index 6090d132e3a..532dd7d0a84 100644
--- a/spec/features/projects/navbar_spec.rb
+++ b/spec/features/projects/navbar_spec.rb
@@ -19,9 +19,12 @@ RSpec.describe 'Project navbar', :with_license, feature_category: :projects do
stub_config(registry: { enabled: false })
stub_feature_flags(harbor_registry_integration: false)
+ stub_feature_flags(ml_experiment_tracking: false)
+ stub_feature_flags(remove_monitor_metrics: false)
insert_package_nav(_('Deployments'))
insert_infrastructure_registry_nav
insert_infrastructure_google_cloud_nav
+ insert_infrastructure_aws_nav
end
it_behaves_like 'verified navigation bar' do
@@ -90,7 +93,19 @@ RSpec.describe 'Project navbar', :with_license, feature_category: :projects do
before do
stub_feature_flags(harbor_registry_integration: true)
- insert_harbor_registry_nav(_('Infrastructure Registry'))
+ insert_harbor_registry_nav(_('Terraform modules'))
+
+ visit project_path(project)
+ end
+
+ it_behaves_like 'verified navigation bar'
+ end
+
+ context 'when models experiments is available' do
+ before do
+ stub_feature_flags(ml_experiment_tracking: true)
+
+ insert_model_experiments_nav(_('Terraform modules'))
visit project_path(project)
end
diff --git a/spec/features/projects/network_graph_spec.rb b/spec/features/projects/network_graph_spec.rb
index a29c9f58195..af976b8ffb0 100644
--- a/spec/features/projects/network_graph_spec.rb
+++ b/spec/features/projects/network_graph_spec.rb
@@ -6,10 +6,13 @@ RSpec.describe 'Project Network Graph', :js, feature_category: :projects do
let(:user) { create :user }
let(:project) { create :project, :repository, namespace: user.namespace }
let(:ref_selector) { '.ref-selector' }
+ let(:ref_with_hash) { 'ref-#-hash' }
before do
sign_in(user)
+ project.repository.create_branch(ref_with_hash, 'master')
+
# Stub Graph max_size to speed up test (10 commits vs. 650)
allow(Network::Graph).to receive(:max_count).and_return(10)
end
@@ -52,6 +55,12 @@ RSpec.describe 'Project Network Graph', :js, feature_category: :projects do
end
end
+ it 'switches ref to branch containing a hash' do
+ switch_ref_to(ref_with_hash)
+
+ expect(page).to have_selector ref_selector, text: ref_with_hash
+ end
+
it 'switches ref to tag' do
switch_ref_to('v1.0.0')
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index c6a6ee68185..351662af217 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -3,7 +3,11 @@
require 'spec_helper'
RSpec.describe 'New project', :js, feature_category: :projects do
- include Spec::Support::Helpers::Features::TopNavSpecHelpers
+ include Features::TopNavSpecHelpers
+
+ before do
+ stub_application_setting(import_sources: Gitlab::ImportSources.values)
+ end
context 'as a user' do
let_it_be(:user) { create(:user) }
@@ -67,26 +71,38 @@ RSpec.describe 'New project', :js, feature_category: :projects do
context 'as an admin' do
let(:user) { create(:admin) }
- before do
- sign_in(user)
- end
+ shared_examples '"New project" page' do
+ before do
+ sign_in(user)
+ end
- it 'shows "New project" page', :js do
- visit new_project_path
- click_link 'Create blank project'
+ it 'shows "New project" page', :js do
+ visit new_project_path
+ click_link 'Create blank project'
- expect(page).to have_content('Project name')
- expect(page).to have_content('Project URL')
- expect(page).to have_content('Project slug')
+ expect(page).to have_content('Project name')
+ expect(page).to have_content('Project URL')
+ expect(page).to have_content('Project slug')
- click_link('New project')
- click_link 'Import project'
+ click_link('New project')
+ click_link 'Import project'
+
+ expect(page).to have_link('GitHub')
+ expect(page).to have_link('Bitbucket')
+ expect(page).to have_button('Repository by URL')
+ expect(page).to have_link('GitLab export')
+ end
+ end
+
+ include_examples '"New project" page'
- expect(page).to have_link('GitHub')
- expect(page).to have_link('Bitbucket')
- expect(page).to have_link('GitLab.com')
- expect(page).to have_button('Repository by URL')
- expect(page).to have_link('GitLab export')
+ context 'when the new navigation is enabled' do
+ before do
+ user.update!(use_new_navigation: true)
+ stub_feature_flags(super_sidebar_nav: true)
+ end
+
+ include_examples '"New project" page'
end
shared_examples 'renders importer link' do |params|
@@ -123,11 +139,9 @@ RSpec.describe 'New project', :js, feature_category: :projects do
'github': :new_import_github_path,
'bitbucket': :status_import_bitbucket_path,
'bitbucket server': :status_import_bitbucket_server_path,
- 'gitlab.com': :status_import_gitlab_path,
'fogbugz': :new_import_fogbugz_path,
'gitea': :new_import_gitea_path,
- 'manifest': :new_import_manifest_path,
- 'phabricator': :new_import_phabricator_path
+ 'manifest': :new_import_manifest_path
}
end
@@ -560,22 +574,52 @@ RSpec.describe 'New project', :js, feature_category: :projects do
end
end
- context 'from GitLab.com', :js do
- let(:target_link) { 'GitLab.com' }
- let(:provider) { :gitlab }
+ describe 'sidebar' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:parent_group) { create(:group) }
- context 'as a user' do
- let(:user) { create(:user) }
- let(:oauth_config_instructions) { 'To enable importing projects from GitLab.com, ask your GitLab administrator to configure OAuth integration' }
+ before do
+ parent_group.add_owner(user)
+ sign_in(user)
+ end
- it_behaves_like 'has instructions to enable OAuth'
+ context 'in the current navigation' do
+ before do
+ user.update!(use_new_navigation: false)
+ end
+
+ context 'for a new top-level project' do
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :new_project_path, :projects
+ end
+
+ context 'for a new group project' do
+ it 'shows the group sidebar of the parent group' do
+ visit new_project_path(namespace_id: parent_group.id)
+ expect(page).to have_selector(".nav-sidebar[aria-label=\"Group navigation\"] .context-header[title=\"#{parent_group.name}\"]")
+ end
+ end
end
- context 'as an admin', :do_not_mock_admin_mode_setting do
- let(:user) { create(:admin) }
- let(:oauth_config_instructions) { 'To enable importing projects from GitLab.com, as administrator you need to configure OAuth integration' }
+ context 'in the new navigation' do
+ before do
+ parent_group.add_owner(user)
+ user.update!(use_new_navigation: true)
+ sign_in(user)
+ end
- it_behaves_like 'has instructions to enable OAuth'
+ context 'for a new top-level project' do
+ it 'shows the "Your work" navigation' do
+ visit new_project_path
+ expect(page).to have_selector(".super-sidebar .context-switcher-toggle", text: "Your work")
+ end
+ end
+
+ context 'for a new group project' do
+ it 'shows the group sidebar of the parent group' do
+ visit new_project_path(namespace_id: parent_group.id)
+ expect(page).to have_selector(".super-sidebar .context-switcher-toggle", text: parent_group.name)
+ end
+ end
end
end
end
diff --git a/spec/features/projects/packages_spec.rb b/spec/features/projects/packages_spec.rb
index 31ff455d0df..5d3ebd8bec6 100644
--- a/spec/features/projects/packages_spec.rb
+++ b/spec/features/projects/packages_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe 'Packages', feature_category: :projects do
end
context 'when there are packages' do
- let_it_be(:npm_package) { create(:npm_package, project: project, name: 'zzz', created_at: 1.day.ago, version: '1.0.0') }
+ let_it_be(:npm_package) { create(:npm_package, :with_build, project: project, name: 'zzz', created_at: 1.day.ago, version: '1.0.0') }
let_it_be(:maven_package) { create(:maven_package, project: project, name: 'aaa', created_at: 2.days.ago, version: '2.0.0') }
let_it_be(:packages) { [npm_package, maven_package] }
@@ -40,6 +40,8 @@ RSpec.describe 'Packages', feature_category: :projects do
it_behaves_like 'packages list'
+ it_behaves_like 'pipelines on packages list'
+
it_behaves_like 'package details link'
context 'deleting a package' do
diff --git a/spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb b/spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb
index a7da59200e9..16e64ade665 100644
--- a/spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb
+++ b/spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb
@@ -48,13 +48,13 @@ RSpec.describe "Pages with Let's Encrypt", :https_pages_enabled, feature_categor
expect(domain.auto_ssl_enabled).to eq false
expect(find("#pages_domain_auto_ssl_enabled", visible: false).value).to eq 'false'
- expect(page).to have_selector '.card-header', text: 'Certificate'
+ expect(page).to have_selector '.gl-card-header', text: 'Certificate'
expect(page).to have_text domain.subject
find('.js-auto-ssl-toggle-container .js-project-feature-toggle button').click
expect(find("#pages_domain_auto_ssl_enabled", visible: false).value).to eq 'true'
- expect(page).not_to have_selector '.card-header', text: 'Certificate'
+ expect(page).not_to have_selector '.gl-card-header', text: 'Certificate'
expect(page).not_to have_text domain.subject
click_on 'Save Changes'
@@ -108,7 +108,7 @@ RSpec.describe "Pages with Let's Encrypt", :https_pages_enabled, feature_categor
it 'user do not see private key' do
visit project_pages_domain_path(project, domain)
- expect(page).not_to have_selector '.card-header', text: 'Certificate'
+ expect(page).not_to have_selector '.gl-card-header', text: 'Certificate'
expect(page).not_to have_text domain.subject
end
end
@@ -131,16 +131,16 @@ RSpec.describe "Pages with Let's Encrypt", :https_pages_enabled, feature_categor
it 'user sees certificate subject' do
visit project_pages_domain_path(project, domain)
- expect(page).to have_selector '.card-header', text: 'Certificate'
+ expect(page).to have_selector '.gl-card-header', text: 'Certificate'
expect(page).to have_text domain.subject
end
it 'user can delete the certificate', :js do
visit project_pages_domain_path(project, domain)
- expect(page).to have_selector '.card-header', text: 'Certificate'
+ expect(page).to have_selector '.gl-card-header', text: 'Certificate'
expect(page).to have_text domain.subject
- within('.card') { click_on 'Remove' }
+ within('.gl-card') { click_on 'Remove' }
accept_gl_confirm(button_text: 'Remove certificate')
expect(page).to have_field 'Certificate (PEM)', with: ''
expect(page).to have_field 'Key (PEM)', with: ''
diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb
index 3ede76d3360..81e003d7d1c 100644
--- a/spec/features/projects/pipeline_schedules_spec.rb
+++ b/spec/features/projects/pipeline_schedules_spec.rb
@@ -7,295 +7,394 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :projects do
let!(:project) { create(:project, :repository) }
let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project) }
- let!(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) }
+ let!(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule, project: project) }
let(:scope) { nil }
let!(:user) { create(:user) }
+ let!(:maintainer) { create(:user) }
- before do
- stub_feature_flags(pipeline_schedules_vue: false)
- end
-
- context 'logged in as the pipeline schedule owner' do
+ context 'with pipeline_schedules_vue feature flag turned off' do
before do
- project.add_developer(user)
- pipeline_schedule.update!(owner: user)
- gitlab_sign_in(user)
+ stub_feature_flags(pipeline_schedules_vue: false)
end
- describe 'GET /projects/pipeline_schedules' do
+ context 'logged in as the pipeline schedule owner' do
before do
- visit_pipelines_schedules
+ project.add_developer(user)
+ pipeline_schedule.update!(owner: user)
+ gitlab_sign_in(user)
end
- it 'edits the pipeline' do
- page.within('.pipeline-schedule-table-row') do
- click_link 'Edit'
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
end
- expect(page).to have_content('Edit Pipeline Schedule')
- end
- end
+ it 'edits the pipeline' do
+ page.within('.pipeline-schedule-table-row') do
+ click_link 'Edit'
+ end
- describe 'PATCH /projects/pipelines_schedules/:id/edit' do
- before do
- edit_pipeline_schedule
+ expect(page).to have_content('Edit Pipeline Schedule')
+ end
end
- it 'displays existing properties' do
- description = find_field('schedule_description').value
- expect(description).to eq('pipeline schedule')
- expect(page).to have_button('master')
- expect(page).to have_button('Select timezone')
- end
+ describe 'PATCH /projects/pipelines_schedules/:id/edit' do
+ before do
+ edit_pipeline_schedule
+ end
- it 'edits the scheduled pipeline' do
- fill_in 'schedule_description', with: 'my brand new description'
+ it 'displays existing properties' do
+ description = find_field('schedule_description').value
+ expect(description).to eq('pipeline schedule')
+ expect(page).to have_button('master')
+ expect(page).to have_button('Select timezone')
+ end
- save_pipeline_schedule
+ it 'edits the scheduled pipeline' do
+ fill_in 'schedule_description', with: 'my brand new description'
- expect(page).to have_content('my brand new description')
- end
+ save_pipeline_schedule
- context 'when ref is nil' do
- before do
- pipeline_schedule.update_attribute(:ref, nil)
- edit_pipeline_schedule
+ expect(page).to have_content('my brand new description')
end
- it 'shows the pipeline schedule with default ref' do
- page.within('[data-testid="schedule-target-ref"]') do
- expect(first('.gl-button-text').text).to eq('master')
+ context 'when ref is nil' do
+ before do
+ pipeline_schedule.update_attribute(:ref, nil)
+ edit_pipeline_schedule
end
- end
- end
- context 'when ref is empty' do
- before do
- pipeline_schedule.update_attribute(:ref, '')
- edit_pipeline_schedule
+ it 'shows the pipeline schedule with default ref' do
+ page.within('[data-testid="schedule-target-ref"]') do
+ expect(first('.gl-button-text').text).to eq('master')
+ end
+ end
end
- it 'shows the pipeline schedule with default ref' do
- page.within('[data-testid="schedule-target-ref"]') do
- expect(first('.gl-button-text').text).to eq('master')
+ context 'when ref is empty' do
+ before do
+ pipeline_schedule.update_attribute(:ref, '')
+ edit_pipeline_schedule
+ end
+
+ it 'shows the pipeline schedule with default ref' do
+ page.within('[data-testid="schedule-target-ref"]') do
+ expect(first('.gl-button-text').text).to eq('master')
+ end
end
end
end
end
- end
- context 'logged in as a project maintainer' do
- before do
- project.add_maintainer(user)
- gitlab_sign_in(user)
- end
-
- describe 'GET /projects/pipeline_schedules' do
+ context 'logged in as a project maintainer' do
before do
- visit_pipelines_schedules
+ project.add_maintainer(user)
+ gitlab_sign_in(user)
end
- describe 'The view' do
- it 'displays the required information description' do
- page.within('.pipeline-schedule-table-row') do
- expect(page).to have_content('pipeline schedule')
- expect(find("[data-testid='next-run-cell'] time")['title'])
- .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y'))
- expect(page).to have_link('master')
- expect(page).to have_link("##{pipeline.id}")
- end
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
end
- it 'creates a new scheduled pipeline' do
- click_link 'New schedule'
+ describe 'The view' do
+ it 'displays the required information description' do
+ page.within('.pipeline-schedule-table-row') do
+ expect(page).to have_content('pipeline schedule')
+ expect(find("[data-testid='next-run-cell'] time")['title'])
+ .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y'))
+ expect(page).to have_link('master')
+ expect(page).to have_link("##{pipeline.id}")
+ end
+ end
- expect(page).to have_content('Schedule a new pipeline')
- end
+ it 'creates a new scheduled pipeline' do
+ click_link 'New schedule'
+
+ expect(page).to have_content('Schedule a new pipeline')
+ end
- it 'changes ownership of the pipeline' do
- click_button 'Take ownership'
+ it 'changes ownership of the pipeline' do
+ click_button 'Take ownership'
- page.within('#pipeline-take-ownership-modal') do
- click_link 'Take ownership'
+ page.within('#pipeline-take-ownership-modal') do
+ click_link 'Take ownership'
+ end
+
+ page.within('.pipeline-schedule-table-row') do
+ expect(page).not_to have_content('No owner')
+ expect(page).to have_link('Sidney Jones')
+ end
end
- page.within('.pipeline-schedule-table-row') do
- expect(page).not_to have_content('No owner')
- expect(page).to have_link('Sidney Jones')
+ it 'deletes the pipeline' do
+ click_link 'Delete'
+
+ accept_gl_confirm(button_text: 'Delete pipeline schedule')
+
+ expect(page).not_to have_css(".pipeline-schedule-table-row")
end
end
- it 'deletes the pipeline' do
- click_link 'Delete'
+ context 'when ref is nil' do
+ before do
+ pipeline_schedule.update_attribute(:ref, nil)
+ visit_pipelines_schedules
+ end
+
+ it 'shows a list of the pipeline schedules with empty ref column' do
+ expect(first('.branch-name-cell').text).to eq('')
+ end
+ end
- accept_gl_confirm(button_text: 'Delete pipeline schedule')
+ context 'when ref is empty' do
+ before do
+ pipeline_schedule.update_attribute(:ref, '')
+ visit_pipelines_schedules
+ end
- expect(page).not_to have_css(".pipeline-schedule-table-row")
+ it 'shows a list of the pipeline schedules with empty ref column' do
+ expect(first('.branch-name-cell').text).to eq('')
+ end
end
end
- context 'when ref is nil' do
+ describe 'POST /projects/pipeline_schedules/new' do
before do
- pipeline_schedule.update_attribute(:ref, nil)
- visit_pipelines_schedules
+ visit_new_pipeline_schedule
+ end
+
+ it 'sets defaults for timezone and target branch' do
+ expect(page).to have_button('master')
+ expect(page).to have_button('Select timezone')
end
- it 'shows a list of the pipeline schedules with empty ref column' do
- expect(first('.branch-name-cell').text).to eq('')
+ it 'creates a new scheduled pipeline' do
+ fill_in_schedule_form
+ save_pipeline_schedule
+
+ expect(page).to have_content('my fancy description')
+ end
+
+ it 'prevents an invalid form from being submitted' do
+ save_pipeline_schedule
+
+ expect(page).to have_content('This field is required')
end
end
- context 'when ref is empty' do
+ context 'when user creates a new pipeline schedule with variables' do
before do
- pipeline_schedule.update_attribute(:ref, '')
visit_pipelines_schedules
+ click_link 'New schedule'
+ fill_in_schedule_form
+ all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA')
+ all('[name="schedule[variables_attributes][][secret_value]"]')[0].set('AAA123')
+ all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB')
+ all('[name="schedule[variables_attributes][][secret_value]"]')[1].set('BBB123')
+ save_pipeline_schedule
end
- it 'shows a list of the pipeline schedules with empty ref column' do
- expect(first('.branch-name-cell').text).to eq('')
+ it 'user sees the new variable in edit window', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/397040' do
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ page.within('.ci-variable-list') do
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('AAA')
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('AAA123')
+ expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-key").value).to eq('BBB')
+ expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-value", visible: false).value).to eq('BBB123')
+ end
end
end
- end
- describe 'POST /projects/pipeline_schedules/new' do
- before do
- visit_new_pipeline_schedule
- end
+ context 'when user edits a variable of a pipeline schedule' do
+ before do
+ create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
+ create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule)
+ end
- it 'sets defaults for timezone and target branch' do
- expect(page).to have_button('master')
- expect(page).to have_button('Select timezone')
+ visit_pipelines_schedules
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ find('.js-ci-variable-list-section .js-secret-value-reveal-button').click
+ first('.js-ci-variable-input-key').set('foo')
+ first('.js-ci-variable-input-value').set('bar')
+ click_button 'Save pipeline schedule'
+ end
+
+ it 'user sees the updated variable in edit window' do
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ page.within('.ci-variable-list') do
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('foo')
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('bar')
+ end
+ end
end
- it 'creates a new scheduled pipeline' do
- fill_in_schedule_form
- save_pipeline_schedule
+ context 'when user removes a variable of a pipeline schedule' do
+ before do
+ create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
+ create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule)
+ end
- expect(page).to have_content('my fancy description')
+ visit_pipelines_schedules
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ find('.ci-variable-list .ci-variable-row-remove-button').click
+ click_button 'Save pipeline schedule'
+ end
+
+ it 'user does not see the removed variable in edit window' do
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ page.within('.ci-variable-list') do
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('')
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('')
+ end
+ end
end
- it 'prevents an invalid form from being submitted' do
- save_pipeline_schedule
+ context 'when active is true and next_run_at is NULL' do
+ before do
+ create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
+ pipeline_schedule.update_attribute(:next_run_at, nil) # Consequently next_run_at will be nil
+ end
+ end
+
+ it 'user edit and recover the problematic pipeline schedule' do
+ visit_pipelines_schedules
+ find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
+ fill_in 'schedule_cron', with: '* 1 2 3 4'
+ click_button 'Save pipeline schedule'
- expect(page).to have_content('This field is required')
+ page.within('.pipeline-schedule-table-row:nth-child(1)') do
+ expect(page).to have_css("[data-testid='next-run-cell'] time")
+ end
+ end
end
end
- context 'when user creates a new pipeline schedule with variables' do
+ context 'logged in as non-member' do
before do
- visit_pipelines_schedules
- click_link 'New schedule'
- fill_in_schedule_form
- all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA')
- all('[name="schedule[variables_attributes][][secret_value]"]')[0].set('AAA123')
- all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB')
- all('[name="schedule[variables_attributes][][secret_value]"]')[1].set('BBB123')
- save_pipeline_schedule
+ gitlab_sign_in(user)
end
- it 'user sees the new variable in edit window' do
- find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
- page.within('.ci-variable-list') do
- expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('AAA')
- expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('AAA123')
- expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-key").value).to eq('BBB')
- expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-value", visible: false).value).to eq('BBB123')
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
+ end
+
+ describe 'The view' do
+ it 'does not show create schedule button' do
+ expect(page).not_to have_link('New schedule')
+ end
end
end
end
- context 'when user edits a variable of a pipeline schedule' do
- before do
- create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
- create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule)
+ context 'not logged in' do
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
end
- visit_pipelines_schedules
- find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
-
- find('.js-ci-variable-list-section .js-secret-value-reveal-button').click
- first('.js-ci-variable-input-key').set('foo')
- first('.js-ci-variable-input-value').set('bar')
- click_button 'Save pipeline schedule'
- end
-
- it 'user sees the updated variable in edit window' do
- find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
- page.within('.ci-variable-list') do
- expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('foo')
- expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('bar')
+ describe 'The view' do
+ it 'does not show create schedule button' do
+ expect(page).not_to have_link('New schedule')
+ end
end
end
end
+ end
- context 'when user removes a variable of a pipeline schedule' do
+ context 'with pipeline_schedules_vue feature flag turned on' do
+ context 'logged in as a project maintainer' do
before do
- create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
- create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule)
- end
-
- visit_pipelines_schedules
- find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
- find('.ci-variable-list .ci-variable-row-remove-button').click
- click_button 'Save pipeline schedule'
+ project.add_maintainer(maintainer)
+ pipeline_schedule.update!(owner: user)
+ gitlab_sign_in(maintainer)
end
- it 'user does not see the removed variable in edit window' do
- find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
- page.within('.ci-variable-list') do
- expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('')
- expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('')
- end
- end
- end
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
- context 'when active is true and next_run_at is NULL' do
- before do
- create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule|
- pipeline_schedule.update_attribute(:next_run_at, nil) # Consequently next_run_at will be nil
+ wait_for_requests
end
- end
- it 'user edit and recover the problematic pipeline schedule' do
- visit_pipelines_schedules
- find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
- fill_in 'schedule_cron', with: '* 1 2 3 4'
- click_button 'Save pipeline schedule'
+ describe 'The view' do
+ it 'displays the required information description' do
+ page.within('[data-testid="pipeline-schedule-table-row"]') do
+ expect(page).to have_content('pipeline schedule')
+ expect(find("[data-testid='next-run-cell'] time")['title'])
+ .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y'))
+ expect(page).to have_link('master')
+ expect(find("[data-testid='last-pipeline-status'] a")['href']).to include(pipeline.id.to_s)
+ end
+ end
+
+ it 'changes ownership of the pipeline' do
+ click_button 'Take ownership'
+
+ page.within('#pipeline-take-ownership-modal') do
+ click_button 'Take ownership'
+
+ wait_for_requests
+ end
+
+ page.within('[data-testid="pipeline-schedule-table-row"]') do
+ expect(page).not_to have_content('No owner')
+ expect(page).to have_link('Sidney Jones')
+ end
+ end
- page.within('.pipeline-schedule-table-row:nth-child(1)') do
- expect(page).to have_css("[data-testid='next-run-cell'] time")
+ it 'runs the pipeline' do
+ click_button 'Run pipeline schedule'
+
+ wait_for_requests
+
+ expect(page).to have_content("Successfully scheduled a pipeline to run. Go to the Pipelines page for details.")
+ end
+
+ it 'deletes the pipeline' do
+ click_button 'Delete pipeline schedule'
+
+ accept_gl_confirm(button_text: 'Delete pipeline schedule')
+
+ expect(page).not_to have_css('[data-testid="pipeline-schedule-table-row"]')
+ end
end
end
end
- end
-
- context 'logged in as non-member' do
- before do
- gitlab_sign_in(user)
- end
- describe 'GET /projects/pipeline_schedules' do
+ context 'logged in as non-member' do
before do
- visit_pipelines_schedules
+ gitlab_sign_in(user)
end
- describe 'The view' do
- it 'does not show create schedule button' do
- expect(page).not_to have_link('New schedule')
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
+
+ wait_for_requests
+ end
+
+ describe 'The view' do
+ it 'does not show create schedule button' do
+ expect(page).not_to have_link('New schedule')
+ end
end
end
end
- end
- context 'not logged in' do
- describe 'GET /projects/pipeline_schedules' do
- before do
- visit_pipelines_schedules
- end
+ context 'not logged in' do
+ describe 'GET /projects/pipeline_schedules' do
+ before do
+ visit_pipelines_schedules
+
+ wait_for_requests
+ end
- describe 'The view' do
- it 'does not show create schedule button' do
- expect(page).not_to have_link('New schedule')
+ describe 'The view' do
+ it 'does not show create schedule button' do
+ expect(page).not_to have_link('New schedule')
+ end
end
end
end
@@ -332,5 +431,6 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :projects do
select_timezone
select_target_branch
+ find('body').click # close dropdown
end
end
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index 343c7f53022..7167581eedf 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -80,7 +80,7 @@ RSpec.describe 'Pipeline', :js, feature_category: :projects do
expect(page).to have_content('test')
expect(page).to have_content('deploy')
expect(page).to have_content('Retry')
- expect(page).to have_content('Cancel running')
+ expect(page).to have_content('Cancel pipeline')
end
it 'shows link to the pipeline ref' do
@@ -113,6 +113,39 @@ RSpec.describe 'Pipeline', :js, feature_category: :projects do
end
end
+ describe 'pipeline stats text' do
+ let(:finished_pipeline) do
+ create(:ci_pipeline, :success, project: project,
+ ref: 'master', sha: project.commit.id, user: user)
+ end
+
+ before do
+ finished_pipeline.update!(started_at: "2023-01-01 01:01:05", created_at: "2023-01-01 01:01:01",
+ finished_at: "2023-01-01 01:01:10", duration: 9)
+ end
+
+ context 'pipeline has finished' do
+ it 'shows pipeline stats with flag on' do
+ visit project_pipeline_path(project, finished_pipeline)
+
+ within '.pipeline-info' do
+ expect(page).to have_content("in #{finished_pipeline.duration} seconds")
+ expect(page).to have_content("and was queued for #{finished_pipeline.queued_duration} seconds")
+ end
+ end
+ end
+
+ context 'pipeline has not finished' do
+ it 'does not show pipeline stats' do
+ visit_pipeline
+
+ within '.pipeline-info' do
+ expect(page).not_to have_selector('[data-testid="pipeline-stats-text"]')
+ end
+ end
+ end
+ end
+
describe 'related merge requests' do
context 'when there are no related merge requests' do
it 'shows a "no related merge requests" message' do
@@ -592,11 +625,11 @@ RSpec.describe 'Pipeline', :js, feature_category: :projects do
context 'when canceling' do
before do
- click_on 'Cancel running'
+ click_on 'Cancel pipeline'
end
- it 'does not show a "Cancel running" button', :sidekiq_might_not_need_inline do
- expect(page).not_to have_content('Cancel running')
+ it 'does not show a "Cancel pipeline" button', :sidekiq_might_not_need_inline do
+ expect(page).not_to have_content('Cancel pipeline')
end
end
end
@@ -814,10 +847,10 @@ RSpec.describe 'Pipeline', :js, feature_category: :projects do
expect(page).to have_content('test')
expect(page).to have_content('deploy')
expect(page).to have_content('Retry')
- expect(page).to have_content('Cancel running')
+ expect(page).to have_content('Cancel pipeline')
end
- it 'does not link to job' do
+ it 'does not link to job', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408215' do
expect(page).not_to have_selector('.js-pipeline-graph-job-link')
end
end
@@ -880,7 +913,7 @@ RSpec.describe 'Pipeline', :js, feature_category: :projects do
expect(page).to have_selector('table.ci-table > tbody > tr > td', text: 'blocked user schedule')
end
- it 'does not create a new Pipeline' do
+ it 'does not create a new Pipeline', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408215' do
visit project_pipelines_path(project)
expect(page).not_to have_selector('.ci-table')
@@ -1060,7 +1093,7 @@ RSpec.describe 'Pipeline', :js, feature_category: :projects do
expect(page).to have_content(build_running.id)
expect(page).to have_content(build_external.id)
expect(page).to have_content('Retry')
- expect(page).to have_content('Cancel running')
+ expect(page).to have_content('Cancel pipeline')
expect(page).to have_button('Play')
end
@@ -1095,11 +1128,11 @@ RSpec.describe 'Pipeline', :js, feature_category: :projects do
context 'when canceling' do
before do
- click_on 'Cancel running'
+ click_on 'Cancel pipeline'
end
- it 'does not show a "Cancel running" button', :sidekiq_might_not_need_inline do
- expect(page).not_to have_content('Cancel running')
+ it 'does not show a "Cancel pipeline" button', :sidekiq_might_not_need_inline do
+ expect(page).not_to have_content('Cancel pipeline')
end
end
end
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index b5f640f1cca..d3ccde3d2e1 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -288,16 +288,21 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
it 'has link to the manual action' do
find('[data-testid="pipelines-manual-actions-dropdown"]').click
+ wait_for_requests
+
expect(page).to have_button('manual build')
end
context 'when manual action was played' do
before do
find('[data-testid="pipelines-manual-actions-dropdown"]').click
+
+ wait_for_requests
+
click_button('manual build')
end
- it 'enqueues manual action job' do
+ it 'enqueues manual action job', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409984' do
expect(page).to have_selector('[data-testid="pipelines-manual-actions-dropdown"] .gl-dropdown-toggle:disabled')
end
end
@@ -308,7 +313,8 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
create(:ci_build, :scheduled,
pipeline: pipeline,
name: 'delayed job 1',
- stage: 'test')
+ stage: 'test',
+ scheduled_at: 2.hours.since + 2.minutes)
end
before do
@@ -322,9 +328,12 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
it "has link to the delayed job's action" do
find('[data-testid="pipelines-manual-actions-dropdown"]').click
- time_diff = [0, delayed_job.scheduled_at - Time.zone.now].max
+ wait_for_requests
+
expect(page).to have_button('delayed job 1')
- expect(page).to have_content(Time.at(time_diff).utc.strftime("%H:%M:%S"))
+
+ time_diff = [0, delayed_job.scheduled_at - Time.zone.now].max
+ expect(page).to have_content(Time.at(time_diff).utc.strftime("%H:%M"))
end
context 'when delayed job is expired already' do
@@ -338,6 +347,8 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
it "shows 00:00:00 as the remaining time" do
find('[data-testid="pipelines-manual-actions-dropdown"]').click
+ wait_for_requests
+
expect(page).to have_content("00:00:00")
end
end
@@ -358,7 +369,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
wait_for_requests
end
- it 'enqueues the delayed job', :js do
+ it 'enqueues the delayed job', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410129' do
expect(delayed_job.reload).to be_pending
end
end
@@ -675,7 +686,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
click_button project.default_branch
wait_for_requests
- find('.gl-new-dropdown-item', text: 'master').click
+ find('.gl-new-dropdown-item', text: '2-mb-file').click
wait_for_requests
end
@@ -686,7 +697,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
it 'creates a new pipeline' do
expect do
- click_on 'Run pipeline'
+ find('[data-testid="run_pipeline_button"]', text: 'Run pipeline').click
wait_for_requests
end
.to change { Ci::Pipeline.count }.by(1)
@@ -695,14 +706,14 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
end
context 'when variables are specified' do
- it 'creates a new pipeline with variables', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/375552' do
+ it 'creates a new pipeline with variables' do
page.within(find("[data-testid='ci-variable-row']")) do
find("[data-testid='pipeline-form-ci-variable-key']").set('key_name')
find("[data-testid='pipeline-form-ci-variable-value']").set('value')
end
expect do
- click_on 'Run pipeline'
+ find('[data-testid="run_pipeline_button"]', text: 'Run pipeline').click
wait_for_requests
end
.to change { Ci::Pipeline.count }.by(1)
@@ -715,17 +726,17 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
context 'without gitlab-ci.yml' do
before do
- click_on 'Run pipeline'
+ find('[data-testid="run_pipeline_button"]', text: 'Run pipeline').click
wait_for_requests
end
it { expect(page).to have_content('Missing CI config file') }
- it 'creates a pipeline after first request failed and a valid gitlab-ci.yml file is available when trying again', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/375552' do
+ it 'creates a pipeline after first request failed and a valid gitlab-ci.yml file is available when trying again' do
stub_ci_pipeline_to_return_yaml_file
expect do
- click_on 'Run pipeline'
+ find('[data-testid="run_pipeline_button"]', text: 'Run pipeline').click
wait_for_requests
end
.to change { Ci::Pipeline.count }.by(1)
@@ -787,9 +798,9 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do
describe 'find pipelines' do
it 'shows filtered pipelines', :js do
click_button project.default_branch
- send_keys('fix')
+ send_keys('2-mb-file')
- expect_listbox_item('fix')
+ expect_listbox_item('2-mb-file')
end
end
end
diff --git a/spec/features/projects/releases/user_creates_release_spec.rb b/spec/features/projects/releases/user_creates_release_spec.rb
index f678d77b002..678c8df666f 100644
--- a/spec/features/projects/releases/user_creates_release_spec.rb
+++ b/spec/features/projects/releases/user_creates_release_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'User creates release', :js, feature_category: :continuous_delivery do
- include Spec::Support::Helpers::Features::ReleasesHelpers
+ include Features::ReleasesHelpers
let_it_be(:project) { create(:project, :repository) }
let_it_be(:milestone_1) { create(:milestone, project: project, title: '1.1') }
@@ -25,17 +25,19 @@ RSpec.describe 'User creates release', :js, feature_category: :continuous_delive
it 'renders the breadcrumbs', :aggregate_failures do
within('.breadcrumbs') do
- expect(page).to have_content("#{project.creator.name} #{project.name} New Release")
+ expect(page).to have_content("#{project.creator.name} #{project.name} Releases New")
expect(page).to have_link(project.creator.name, href: user_path(project.creator))
expect(page).to have_link(project.name, href: project_path(project))
- expect(page).to have_link('New Release', href: new_project_release_path(project))
+ expect(page).to have_link('Releases', href: project_releases_path(project))
+ expect(page).to have_link('New', href: new_project_release_path(project))
end
end
it 'defaults the "Create from" dropdown to the project\'s default branch' do
select_new_tag_name(tag_name)
+ expect(page).to have_button(project.default_branch)
expect(page.find('[data-testid="create-from-field"] .ref-selector button')).to have_content(project.default_branch)
end
@@ -107,7 +109,7 @@ RSpec.describe 'User creates release', :js, feature_category: :continuous_delive
fill_release_notes('**some** _markdown_ [content](https://example.com)')
- click_on 'Preview'
+ click_button("Preview")
wait_for_all_requests
end
@@ -123,13 +125,12 @@ RSpec.describe 'User creates release', :js, feature_category: :continuous_delive
let(:new_page_url) { new_project_release_path(project, tag_name: 'v1.1.0') }
it 'creates release with preselected tag' do
- page.within '[data-testid="tag-name-field"]' do
- expect(page).to have_text('v1.1.0')
- end
+ expect(page).to have_button 'v1.1.0'
+
+ open_tag_popover 'v1.1.0'
expect(page).not_to have_selector('[data-testid="create-from-field"]')
- fill_release_title("test release")
click_button('Create release')
wait_for_all_requests
diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb
index 12e14f5193f..a38c10c6bab 100644
--- a/spec/features/projects/settings/access_tokens_spec.rb
+++ b/spec/features/projects/settings/access_tokens_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Project > Settings > Access Tokens', :js, feature_category: :credential_management do
+RSpec.describe 'Project > Settings > Access Tokens', :js, feature_category: :user_management do
include Spec::Support::Helpers::ModalHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/features/projects/settings/branch_rules_settings_spec.rb b/spec/features/projects/settings/branch_rules_settings_spec.rb
index 71d9c559b77..59609fecd93 100644
--- a/spec/features/projects/settings/branch_rules_settings_spec.rb
+++ b/spec/features/projects/settings/branch_rules_settings_spec.rb
@@ -28,6 +28,17 @@ RSpec.describe 'Projects > Settings > Repository > Branch rules settings', featu
let(:role) { :maintainer }
context 'Branch rules', :js do
+ it 'renders breadcrumbs' do
+ request
+
+ page.within '.breadcrumbs' do
+ expect(page).to have_link('Repository Settings', href: project_settings_repository_path(project))
+ expect(page).to have_link('Branch rules',
+ href: project_settings_repository_path(project, anchor: 'branch-rules'))
+ expect(page).to have_link('Details', href: '#')
+ end
+ end
+
it 'renders branch rules page' do
request
diff --git a/spec/features/projects/settings/forked_project_settings_spec.rb b/spec/features/projects/settings/forked_project_settings_spec.rb
index 28d5c080db9..6b646bcf7d3 100644
--- a/spec/features/projects/settings/forked_project_settings_spec.rb
+++ b/spec/features/projects/settings/forked_project_settings_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe 'Projects > Settings > For a forked project', :js, feature_catego
wait_for_requests
- fill_in('confirm_name_input', with: forked_project.name)
+ fill_in('confirm_name_input', with: forked_project.path)
click_button('Confirm')
wait_for_requests
diff --git a/spec/features/projects/settings/monitor_settings_spec.rb b/spec/features/projects/settings/monitor_settings_spec.rb
index 4b553b57331..1367ffb0009 100644
--- a/spec/features/projects/settings/monitor_settings_spec.rb
+++ b/spec/features/projects/settings/monitor_settings_spec.rb
@@ -3,12 +3,15 @@
require 'spec_helper'
RSpec.describe 'Projects > Settings > For a forked project', :js, feature_category: :projects do
+ include ListboxHelpers
+
let_it_be(:project) { create(:project, :repository, create_templates: :issue) }
let(:user) { project.first_owner }
before do
sign_in(user)
+ stub_feature_flags(remove_monitor_metrics: false)
end
describe 'Sidebar > Monitor' do
@@ -47,7 +50,7 @@ RSpec.describe 'Projects > Settings > For a forked project', :js, feature_catego
check(create_issue)
uncheck(send_email)
click_on('No template selected')
- click_on('bug')
+ select_listbox_item('bug')
save_form
click_settings_tab
diff --git a/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb b/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb
index d4c1fe4d43e..bdfe6a06dd1 100644
--- a/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb
+++ b/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb
@@ -32,10 +32,19 @@ feature_category: :projects do
it 'shows available section' do
subject
- expect(find('.breadcrumbs')).to have_content('Clean up image tags')
+ expect(find('.breadcrumbs')).to have_content('Cleanup policies')
section = find('[data-testid="container-expiration-policy-project-settings"]')
- expect(section).to have_text 'Clean up image tags'
+ expect(section).to have_text 'Cleanup policies'
+ end
+
+ it 'passes axe automated accessibility testing' do
+ subject
+
+ wait_for_requests
+
+ expect(page).to be_axe_clean.within('[data-testid="container-expiration-policy-project-settings"]')
+ .skipping :'link-in-text-block'
end
it 'saves cleanup policy submit the form' do
diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb
index 072b5f7f3b0..68e9b0225ea 100644
--- a/spec/features/projects/settings/registry_settings_spec.rb
+++ b/spec/features/projects/settings/registry_settings_spec.rb
@@ -21,6 +21,15 @@ feature_category: :projects do
end
context 'as owner', :js do
+ it 'passes axe automated accessibility testing' do
+ subject
+
+ wait_for_requests
+
+ expect(page).to be_axe_clean.within('[data-testid="packages-and-registries-project-settings"]')
+ .skipping :'link-in-text-block'
+ end
+
it 'shows active tab on sidebar' do
subject
@@ -33,10 +42,10 @@ feature_category: :projects do
subject
settings_block = find('[data-testid="container-expiration-policy-project-settings"]')
- expect(settings_block).to have_text 'Clean up image tags'
+ expect(settings_block).to have_text 'Cleanup policies'
end
- it 'contains link to clean up image tags page' do
+ it 'contains link to cleanup policies page' do
subject
expect(page).to have_link('Edit cleanup rules', href: cleanup_image_tags_project_settings_packages_and_registries_path(project))
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
index a0625c93b1a..08abade7d18 100644
--- a/spec/features/projects/settings/repository_settings_spec.rb
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Projects > Settings > Repository settings', feature_category: :projects do
+ include Features::MirroringHelpers
+
let(:project) { create(:project_empty_repo) }
let(:user) { create(:user) }
let(:role) { :developer }
@@ -61,6 +63,10 @@ RSpec.describe 'Projects > Settings > Repository settings', feature_category: :p
let(:new_ssh_key) { attributes_for(:key)[:key] }
+ around do |example|
+ travel_to Time.zone.local(2022, 3, 1, 1, 0, 0) { example.run }
+ end
+
it 'get list of keys' do
project.deploy_keys << private_deploy_key
project.deploy_keys << public_deploy_key
@@ -83,6 +89,21 @@ RSpec.describe 'Projects > Settings > Repository settings', feature_category: :p
expect(page).to have_content('Grant write permissions to this key')
end
+ it 'add a new deploy key with expiration' do
+ one_month = Time.zone.local(2022, 4, 1, 1, 0, 0)
+ visit project_settings_repository_path(project)
+
+ fill_in 'deploy_key_title', with: 'new_deploy_key_with_expiry'
+ fill_in 'deploy_key_key', with: new_ssh_key
+ fill_in 'deploy_key_expires_at', with: one_month.to_s
+ check 'deploy_key_deploy_keys_projects_attributes_0_can_push'
+ click_button 'Add key'
+
+ expect(page).to have_content('new_deploy_key_with_expiry')
+ expect(page).to have_content('in 1 month')
+ expect(page).to have_content('Grant write permissions to this key')
+ end
+
it 'edit an existing deploy key' do
project.deploy_keys << private_deploy_key
visit project_settings_repository_path(project)
@@ -152,10 +173,8 @@ RSpec.describe 'Projects > Settings > Repository settings', feature_category: :p
end
it 'creates a push mirror that mirrors all branches', :js do
- expect(page).to have_css('.js-mirror-protected-hidden[value="0"]', visible: false)
-
- fill_in 'url', with: ssh_url
- expect(page).to have_css(".js-mirror-url-hidden[value=\"#{ssh_url}\"]", visible: false)
+ wait_for_mirror_field_javascript('protected', '0')
+ fill_and_wait_for_mirror_url_javascript('url', ssh_url)
select 'SSH public key', from: 'Authentication method'
select_direction
@@ -172,10 +191,8 @@ RSpec.describe 'Projects > Settings > Repository settings', feature_category: :p
it 'creates a push mirror that only mirrors protected branches', :js do
find('#only_protected_branches').click
- expect(page).to have_css('.js-mirror-protected-hidden[value="1"]', visible: false)
-
- fill_in 'url', with: ssh_url
- expect(page).to have_css(".js-mirror-url-hidden[value=\"#{ssh_url}\"]", visible: false)
+ wait_for_mirror_field_javascript('protected', '1')
+ fill_and_wait_for_mirror_url_javascript('url', ssh_url)
select 'SSH public key', from: 'Authentication method'
select_direction
@@ -192,8 +209,7 @@ RSpec.describe 'Projects > Settings > Repository settings', feature_category: :p
it 'creates a push mirror that keeps divergent refs', :js do
select_direction
- fill_in 'url', with: ssh_url
- expect(page).to have_css(".js-mirror-url-hidden[value=\"#{ssh_url}\"]", visible: false)
+ fill_and_wait_for_mirror_url_javascript('url', ssh_url)
fill_in 'Password', with: 'password'
check 'Keep divergent refs'
@@ -212,8 +228,7 @@ RSpec.describe 'Projects > Settings > Repository settings', feature_category: :p
end
it 'generates an SSH public key on submission', :js do
- fill_in 'url', with: ssh_url
- expect(page).to have_css(".js-mirror-url-hidden[value=\"#{ssh_url}\"]", visible: false)
+ fill_and_wait_for_mirror_url_javascript('url', ssh_url)
select 'SSH public key', from: 'Authentication method'
diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb
index 859c738731b..74139aa0d7f 100644
--- a/spec/features/projects/settings/service_desk_setting_spec.rb
+++ b/spec/features/projects/settings/service_desk_setting_spec.rb
@@ -12,8 +12,8 @@ RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache, feature_c
sign_in(user)
allow_any_instance_of(Project).to receive(:present).with(current_user: user).and_return(presenter)
- allow(::Gitlab::IncomingEmail).to receive(:enabled?) { true }
- allow(::Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true }
+ allow(::Gitlab::Email::IncomingEmail).to receive(:enabled?) { true }
+ allow(::Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?) { true }
end
it 'shows activation checkbox' do
@@ -24,7 +24,7 @@ RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache, feature_c
context 'when service_desk_email is disabled' do
before do
- allow(::Gitlab::ServiceDeskEmail).to receive(:enabled?).and_return(false)
+ allow(::Gitlab::Email::ServiceDeskEmail).to receive(:enabled?).and_return(false)
visit edit_project_path(project)
end
@@ -43,8 +43,8 @@ RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache, feature_c
context 'when service_desk_email is enabled' do
before do
- allow(::Gitlab::ServiceDeskEmail).to receive(:enabled?) { true }
- allow(::Gitlab::ServiceDeskEmail).to receive(:address_for_key) { 'address-suffix@example.com' }
+ allow(::Gitlab::Email::ServiceDeskEmail).to receive(:enabled?) { true }
+ allow(::Gitlab::Email::ServiceDeskEmail).to receive(:address_for_key) { 'address-suffix@example.com' }
visit edit_project_path(project)
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 159a83a261d..b7463537fb2 100644
--- a/spec/features/projects/settings/user_manages_project_members_spec.rb
+++ b/spec/features/projects/settings/user_manages_project_members_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects > Settings > User manages project members', feature_category: :projects do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
include Spec::Support::Helpers::ModalHelpers
include ListboxHelpers
diff --git a/spec/features/projects/settings/user_renames_a_project_spec.rb b/spec/features/projects/settings/user_renames_a_project_spec.rb
index 2da6e760fbf..a6b72e7a297 100644
--- a/spec/features/projects/settings/user_renames_a_project_spec.rb
+++ b/spec/features/projects/settings/user_renames_a_project_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe 'Projects > Settings > User renames a project', feature_category:
it 'shows errors for invalid project path' do
change_path(project, 'foo&bar')
- expect(page).to have_field 'Path', with: 'gitlab'
+ expect(page).to have_field 'Path', with: project.path
expect(page).to have_content "Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'"
end
end
@@ -67,7 +67,7 @@ RSpec.describe 'Projects > Settings > User renames a project', feature_category:
end
context 'when changing project path' do
- let(:project) { create(:project, :repository, namespace: user.namespace, name: 'gitlabhq') }
+ let(:project) { create(:project, :repository, namespace: user.namespace, path: 'gitlabhq') }
before(:context) do
TestEnv.clean_test_path
diff --git a/spec/features/projects/settings/webhooks_settings_spec.rb b/spec/features/projects/settings/webhooks_settings_spec.rb
index 3b8b982b621..e527d0c9c74 100644
--- a/spec/features/projects/settings/webhooks_settings_spec.rb
+++ b/spec/features/projects/settings/webhooks_settings_spec.rb
@@ -83,7 +83,7 @@ RSpec.describe 'Projects > Settings > Webhook Settings', feature_category: :proj
visit webhooks_path
click_button 'Test'
- click_button 'Push events'
+ click_link 'Push events'
expect(page).to have_current_path(webhooks_path, ignore_query: true)
end
diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb
index 06e48bc82c0..a28416f3ca3 100644
--- a/spec/features/projects/snippets/create_snippet_spec.rb
+++ b/spec/features/projects/snippets/create_snippet_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'Projects > Snippets > Create Snippet', :js, feature_category: :source_code_management do
include DropzoneHelper
- include Spec::Support::Helpers::Features::SnippetSpecHelpers
+ include Features::SnippetSpecHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) do
diff --git a/spec/features/projects/snippets/user_updates_snippet_spec.rb b/spec/features/projects/snippets/user_updates_snippet_spec.rb
index 014bf63c696..dda9a556d17 100644
--- a/spec/features/projects/snippets/user_updates_snippet_spec.rb
+++ b/spec/features/projects/snippets/user_updates_snippet_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects > Snippets > User updates a snippet', :js, feature_category: :source_code_management do
- include Spec::Support::Helpers::Features::SnippetSpecHelpers
+ include Features::SnippetSpecHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) }
diff --git a/spec/features/projects/terraform_spec.rb b/spec/features/projects/terraform_spec.rb
index 5e2f65165c2..b7500b0cfb7 100644
--- a/spec/features/projects/terraform_spec.rb
+++ b/spec/features/projects/terraform_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe 'Terraform', :js, feature_category: :projects do
end
it 'displays a tab with states count' do
- expect(page).to have_content("States #{project.terraform_states.size}")
+ expect(page).to have_content("Terraform states #{project.terraform_states.size}")
end
it 'displays a table with terraform states' do
diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb
index 58f572bc021..8fae8f38025 100644
--- a/spec/features/projects/tree/create_directory_spec.rb
+++ b/spec/features/projects/tree/create_directory_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Multi-file editor new directory', :js, feature_category: :web_ide do
- include WebIdeSpecHelpers
+ include Features::WebIdeSpecHelpers
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb
index 674aef8e6f4..2f8935b9ce3 100644
--- a/spec/features/projects/tree/create_file_spec.rb
+++ b/spec/features/projects/tree/create_file_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Multi-file editor new file', :js, feature_category: :web_ide do
- include WebIdeSpecHelpers
+ include Features::WebIdeSpecHelpers
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb
index 52c6cb2192b..3becc48d450 100644
--- a/spec/features/projects/tree/tree_show_spec.rb
+++ b/spec/features/projects/tree/tree_show_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Projects tree', :js, feature_category: :web_ide do
- include WebIdeSpecHelpers
+ include Features::WebIdeSpecHelpers
include RepoHelpers
include ListboxHelpers
diff --git a/spec/features/projects/tree/upload_file_spec.rb b/spec/features/projects/tree/upload_file_spec.rb
index 42fa88a0d3e..6ec57af2590 100644
--- a/spec/features/projects/tree/upload_file_spec.rb
+++ b/spec/features/projects/tree/upload_file_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Multi-file editor upload file', :js, feature_category: :web_ide do
- include WebIdeSpecHelpers
+ include Features::WebIdeSpecHelpers
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
diff --git a/spec/features/projects/user_changes_project_visibility_spec.rb b/spec/features/projects/user_changes_project_visibility_spec.rb
index 5daa5b98b6e..64af25aea28 100644
--- a/spec/features/projects/user_changes_project_visibility_spec.rb
+++ b/spec/features/projects/user_changes_project_visibility_spec.rb
@@ -91,23 +91,4 @@ RSpec.describe 'User changes public project visibility', :js, feature_category:
it_behaves_like 'does not require confirmation'
end
-
- context 'with unlink_fork_network_upon_visibility_decrease = false' do
- let(:project) { create(:project, :empty_repo, :public) }
-
- before do
- stub_feature_flags(unlink_fork_network_upon_visibility_decrease: false)
-
- fork_project(project, project.first_owner)
-
- sign_in(project.first_owner)
-
- visit edit_project_path(project)
-
- # https://gitlab.com/gitlab-org/gitlab/-/issues/381259
- allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(110)
- end
-
- it_behaves_like 'does not require confirmation'
- end
end
diff --git a/spec/features/projects/user_sees_user_popover_spec.rb b/spec/features/projects/user_sees_user_popover_spec.rb
index 5badcd99dff..9d8d06c514e 100644
--- a/spec/features/projects/user_sees_user_popover_spec.rb
+++ b/spec/features/projects/user_sees_user_popover_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'User sees user popover', :js, feature_category: :projects do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
let_it_be(:user) { create(:user, pronouns: 'they/them') }
let_it_be(:project) { create(:project, :repository, creator: user) }
diff --git a/spec/features/projects/user_uses_shortcuts_spec.rb b/spec/features/projects/user_uses_shortcuts_spec.rb
index 05d79ea3b1b..1d4ab242308 100644
--- a/spec/features/projects/user_uses_shortcuts_spec.rb
+++ b/spec/features/projects/user_uses_shortcuts_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe 'User uses shortcuts', :js, feature_category: :projects do
before do
sign_in(user)
+ stub_feature_flags(remove_monitor_metrics: false)
visit(project_path(project))
@@ -68,11 +69,11 @@ RSpec.describe 'User uses shortcuts', :js, feature_category: :projects do
end
context 'when navigating to the Project pages' do
- it 'redirects to the project page' do
+ it 'redirects to the project overview page' do
visit project_issues_path(project)
find('body').native.send_key('g')
- find('body').native.send_key('p')
+ find('body').native.send_key('o')
expect(page).to have_active_navigation(project.name)
end
@@ -155,6 +156,14 @@ RSpec.describe 'User uses shortcuts', :js, feature_category: :projects do
end
context 'when navigating to the CI/CD pages' do
+ it 'redirects to the Pipelines page' do
+ find('body').native.send_key('g')
+ find('body').native.send_key('p')
+
+ expect(page).to have_active_navigation('CI/CD')
+ expect(page).to have_active_sub_navigation('Pipelines')
+ end
+
it 'redirects to the Jobs page' do
find('body').native.send_key('g')
find('body').native.send_key('j')
diff --git a/spec/features/projects/user_views_empty_project_spec.rb b/spec/features/projects/user_views_empty_project_spec.rb
index e2b56e8ced6..e38cfc2273a 100644
--- a/spec/features/projects/user_views_empty_project_spec.rb
+++ b/spec/features/projects/user_views_empty_project_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'User views an empty project', feature_category: :projects do
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::InviteMembersModalHelpers
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:user) { create(:user) }
diff --git a/spec/features/projects/work_items/work_item_children_spec.rb b/spec/features/projects/work_items/work_item_children_spec.rb
new file mode 100644
index 00000000000..43a6b2771f6
--- /dev/null
+++ b/spec/features/projects/work_items/work_item_children_spec.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Work item children', :js, feature_category: :team_planning do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :public, namespace: group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ context 'for signed in user' do
+ before do
+ project.add_developer(user)
+
+ sign_in(user)
+
+ stub_feature_flags(work_items: true)
+
+ visit project_issue_path(project, issue)
+
+ wait_for_requests
+ end
+
+ it 'are not displayed when issue does not have work item children', :aggregate_failures do
+ page.within('[data-testid="work-item-links"]') do
+ expect(find('[data-testid="links-empty"]')).to have_content(_('No tasks are currently assigned.'))
+ expect(page).not_to have_selector('[data-testid="add-links-form"]')
+ expect(page).not_to have_selector('[data-testid="links-child"]')
+ end
+ end
+
+ it 'toggles widget body', :aggregate_failures do
+ page.within('[data-testid="work-item-links"]') do
+ expect(page).to have_selector('[data-testid="widget-body"]')
+
+ click_button 'Collapse'
+
+ expect(page).not_to have_selector('[data-testid="widget-body"]')
+
+ click_button 'Expand'
+
+ expect(page).to have_selector('[data-testid="widget-body"]')
+ end
+ end
+
+ it 'toggles form', :aggregate_failures do
+ page.within('[data-testid="work-item-links"]') do
+ expect(page).not_to have_selector('[data-testid="add-links-form"]')
+
+ click_button 'Add'
+ click_button 'New task'
+
+ expect(page).to have_selector('[data-testid="add-links-form"]')
+
+ click_button 'Cancel'
+
+ expect(page).not_to have_selector('[data-testid="add-links-form"]')
+ end
+ end
+
+ it 'adds a new child task', :aggregate_failures do
+ page.within('[data-testid="work-item-links"]') do
+ click_button 'Add'
+ click_button 'New task'
+
+ expect(page).to have_button('Create task', disabled: true)
+ fill_in 'Add a title', with: 'Task 1'
+
+ expect(page).to have_button('Create task', disabled: false)
+
+ click_button 'Create task'
+
+ wait_for_all_requests
+
+ expect(find('[data-testid="links-child"]')).to have_content('Task 1')
+ end
+ end
+
+ it 'removes a child task and undoing', :aggregate_failures do
+ page.within('[data-testid="work-item-links"]') do
+ click_button 'Add'
+ click_button 'New task'
+ fill_in 'Add a title', with: 'Task 1'
+ click_button 'Create task'
+ wait_for_all_requests
+
+ expect(find('[data-testid="links-child"]')).to have_content('Task 1')
+ expect(find('[data-testid="children-count"]')).to have_content('1')
+
+ find('[data-testid="links-menu"]').click
+ click_button 'Remove'
+
+ wait_for_all_requests
+
+ expect(page).not_to have_content('Task 1')
+ expect(find('[data-testid="children-count"]')).to have_content('0')
+ end
+
+ page.within('.gl-toast') do
+ expect(find('.toast-body')).to have_content(_('Child removed'))
+ find('.b-toaster a', text: 'Undo').click
+ end
+
+ wait_for_all_requests
+
+ page.within('[data-testid="work-item-links"]') do
+ expect(find('[data-testid="links-child"]')).to have_content('Task 1')
+ expect(find('[data-testid="children-count"]')).to have_content('1')
+ end
+ end
+
+ context 'with existing task' do
+ let_it_be(:task) { create(:work_item, :task, project: project) }
+
+ it 'adds an existing child task', :aggregate_failures do
+ page.within('[data-testid="work-item-links"]') do
+ click_button 'Add'
+ click_button 'Existing task'
+
+ expect(page).to have_button('Add task', disabled: true)
+ find('[data-testid="work-item-token-select-input"]').set(task.title)
+ wait_for_all_requests
+ click_button task.title
+
+ expect(page).to have_button('Add task', disabled: false)
+
+ click_button 'Add task'
+
+ wait_for_all_requests
+
+ expect(find('[data-testid="links-child"]')).to have_content(task.title)
+ end
+ end
+ end
+
+ context 'in work item metadata' do
+ let_it_be(:label) { create(:label, title: 'Label 1', project: project) }
+ let_it_be(:milestone) { create(:milestone, project: project, title: 'v1') }
+ let_it_be(:task) do
+ create(
+ :work_item,
+ :task,
+ project: project,
+ labels: [label],
+ assignees: [user],
+ milestone: milestone
+ )
+ end
+
+ before do
+ visit project_issue_path(project, issue)
+
+ wait_for_requests
+ end
+
+ it 'displays labels, milestone and assignee for work item children', :aggregate_failures do
+ page.within('[data-testid="work-item-links"]') do
+ click_button 'Add'
+ click_button 'Existing task'
+
+ find('[data-testid="work-item-token-select-input"]').set(task.title)
+ wait_for_all_requests
+ click_button task.title
+
+ click_button 'Add task'
+
+ wait_for_all_requests
+ end
+
+ page.within('[data-testid="links-child"]') do
+ expect(page).to have_content(task.title)
+ expect(page).to have_content(label.title)
+ expect(page).to have_link(user.name)
+ expect(page).to have_content(milestone.title)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/work_items/work_item_spec.rb b/spec/features/projects/work_items/work_item_spec.rb
new file mode 100644
index 00000000000..b706a624fc5
--- /dev/null
+++ b/spec/features/projects/work_items/work_item_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Work item', :js, feature_category: :team_planning do
+ let_it_be_with_reload(:user) { create(:user) }
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:work_item) { create(:work_item, project: project) }
+ let_it_be(:milestone) { create(:milestone, project: project) }
+ let_it_be(:milestones) { create_list(:milestone, 25, project: project) }
+ let_it_be(:note) { create(:note, noteable: work_item, project: work_item.project) }
+ let(:work_items_path) { project_work_items_path(project, work_items_path: work_item.iid) }
+
+ context 'for signed in user' do
+ before do
+ project.add_developer(user)
+
+ sign_in(user)
+
+ visit work_items_path
+ end
+
+ it 'shows project issues link in breadcrumbs' do
+ within('[data-testid="breadcrumb-links"]') do
+ expect(page).to have_link('Issues', href: project_issues_path(project))
+ end
+ end
+
+ it 'uses IID path in breadcrumbs' do
+ within('[data-testid="breadcrumb-current-link"]') do
+ expect(page).to have_link("##{work_item.iid}", href: work_items_path)
+ end
+ end
+
+ it_behaves_like 'work items title'
+ it_behaves_like 'work items status'
+ it_behaves_like 'work items assignees'
+ it_behaves_like 'work items labels'
+ it_behaves_like 'work items comments', :issue
+ it_behaves_like 'work items description'
+ it_behaves_like 'work items milestone'
+ it_behaves_like 'work items notifications'
+ it_behaves_like 'work items todos'
+ it_behaves_like 'work items award emoji'
+ end
+
+ context 'for signed in owner' do
+ before do
+ project.add_owner(user)
+
+ sign_in(user)
+
+ visit work_items_path
+ end
+
+ it_behaves_like 'work items invite members'
+ end
+
+ context 'for guest users' do
+ before do
+ project.add_guest(user)
+
+ sign_in(user)
+
+ visit work_items_path
+ end
+
+ it_behaves_like 'work items comment actions for guest users'
+ end
+
+ context 'for user not signed in' do
+ before do
+ visit work_items_path
+ end
+
+ it 'actions dropdown is not displayed' do
+ expect(page).not_to have_selector('[data-testid="work-item-actions-dropdown"]')
+ end
+
+ it 'todos action is not displayed' do
+ expect(page).not_to have_selector('[data-testid="work-item-todos-action"]')
+ end
+
+ it 'award button is disabled and add reaction is not displayed' do
+ within('[data-testid="work-item-award-list"]') do
+ expect(page).not_to have_selector('[data-testid="emoji-picker"]')
+ expect(page).to have_selector('[data-testid="award-button"].disabled')
+ end
+ end
+ end
+end
diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb
index 04096b3e4f9..e4a64d391b0 100644
--- a/spec/features/protected_branches_spec.rb
+++ b/spec/features/protected_branches_spec.rb
@@ -96,6 +96,15 @@ RSpec.describe 'Protected Branches', :js, feature_category: :source_code_managem
expect(ProtectedBranch.last.name).to eq('some->branch')
end
+ it "shows success alert once protected branch is created" do
+ visit project_protected_branches_path(project)
+ set_defaults
+ set_protected_branch_name('some->branch')
+ click_on "Protect"
+ wait_for_requests
+ expect(page).to have_content(s_('ProtectedBranch|View protected branches as branch rules'))
+ end
+
it "displays the last commit on the matching branch if it exists" do
commit = create(:commit, project: project)
project.repository.add_branch(admin, 'some-branch', commit.id)
diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb
index c2058a5c345..45315f53fd6 100644
--- a/spec/features/protected_tags_spec.rb
+++ b/spec/features/protected_tags_spec.rb
@@ -16,8 +16,8 @@ RSpec.describe 'Protected Tags', :js, :with_license, feature_category: :source_c
it "allows creating explicit protected tags" do
visit project_protected_tags_path(project)
set_protected_tag_name('some-tag')
- set_allowed_to('create') if Gitlab.ee?
- click_on "Protect"
+ set_allowed_to('create')
+ click_on_protect
within(".protected-tags-list") { expect(page).to have_content('some-tag') }
expect(ProtectedTag.count).to eq(1)
@@ -30,8 +30,8 @@ RSpec.describe 'Protected Tags', :js, :with_license, feature_category: :source_c
visit project_protected_tags_path(project)
set_protected_tag_name('some-tag')
- set_allowed_to('create') if Gitlab.ee?
- click_on "Protect"
+ set_allowed_to('create')
+ click_on_protect
within(".protected-tags-list") { expect(page).to have_content(commit.id[0..7]) }
end
@@ -39,8 +39,8 @@ RSpec.describe 'Protected Tags', :js, :with_license, feature_category: :source_c
it "displays an error message if the named tag does not exist" do
visit project_protected_tags_path(project)
set_protected_tag_name('some-tag')
- set_allowed_to('create') if Gitlab.ee?
- click_on "Protect"
+ set_allowed_to('create')
+ click_on_protect
within(".protected-tags-list") { expect(page).to have_content('tag was removed') }
end
@@ -50,8 +50,8 @@ RSpec.describe 'Protected Tags', :js, :with_license, feature_category: :source_c
it "allows creating protected tags with a wildcard" do
visit project_protected_tags_path(project)
set_protected_tag_name('*-stable')
- set_allowed_to('create') if Gitlab.ee?
- click_on "Protect"
+ set_allowed_to('create')
+ click_on_protect
within(".protected-tags-list") { expect(page).to have_content('*-stable') }
expect(ProtectedTag.count).to eq(1)
@@ -64,8 +64,8 @@ RSpec.describe 'Protected Tags', :js, :with_license, feature_category: :source_c
visit project_protected_tags_path(project)
set_protected_tag_name('*-stable')
- set_allowed_to('create') if Gitlab.ee?
- click_on "Protect"
+ set_allowed_to('create')
+ click_on_protect
within(".protected-tags-list") do
expect(page).to have_content("Protected tags (2)")
@@ -80,8 +80,8 @@ RSpec.describe 'Protected Tags', :js, :with_license, feature_category: :source_c
visit project_protected_tags_path(project)
set_protected_tag_name('*-stable')
- set_allowed_to('create') if Gitlab.ee?
- click_on "Protect"
+ set_allowed_to('create')
+ click_on_protect
visit project_protected_tags_path(project)
click_on "2 matching tags"
@@ -101,4 +101,14 @@ RSpec.describe 'Protected Tags', :js, :with_license, feature_category: :source_c
include_examples "protected tags > access control > CE"
end
+
+ context 'when the users for protected tags feature is off' do
+ before do
+ stub_licensed_features(protected_refs_for_users: false)
+ end
+
+ include_examples 'Deploy keys with protected tags' do
+ let(:all_dropdown_sections) { ['Roles', 'Deploy Keys'] }
+ end
+ end
end
diff --git a/spec/features/reportable_note/issue_spec.rb b/spec/features/reportable_note/issue_spec.rb
index 55e7f5897bc..a18cdf27294 100644
--- a/spec/features/reportable_note/issue_spec.rb
+++ b/spec/features/reportable_note/issue_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Reportable note on issue', :js, feature_category: :team_planning do
+ include CookieHelper
+
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
@@ -11,7 +13,7 @@ RSpec.describe 'Reportable note on issue', :js, feature_category: :team_planning
before do
project.add_maintainer(user)
sign_in(user)
-
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, issue)
end
diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb
index b2ddf427c0d..452a5700e08 100644
--- a/spec/features/runners_spec.rb
+++ b/spec/features/runners_spec.rb
@@ -14,23 +14,58 @@ RSpec.describe 'Runners', feature_category: :runner_fleet do
stub_feature_flags(project_runners_vue_ui: false)
end
- context 'when user opens runners page' do
- let(:project) { create(:project) }
+ context 'when user views runners page' do
+ let_it_be(:project) { create(:project) }
before do
project.add_maintainer(user)
end
- it 'user can see a link with instructions on how to install GitLab Runner' do
- visit project_runners_path(project)
+ context 'when create_runner_workflow_for_namespace is enabled', :js do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace])
+
+ visit project_runners_path(project)
+ end
+
+ it 'user can see a link with instructions on how to install GitLab Runner' do
+ expect(page).to have_link(s_('Runners|New project runner'), href: new_project_runner_path(project))
+ end
+
+ it_behaves_like "shows and resets runner registration token" do
+ let(:dropdown_text) { s_('Runners|Register a project runner') }
+ let(:registration_token) { project.runners_token }
+ end
+ end
+
+ context 'when user views new runner page' do
+ context 'when create_runner_workflow_for_namespace is enabled', :js do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace])
+
+ visit new_project_runner_path(project)
+ end
- expect(page).to have_link('Install GitLab Runner and ensure it\'s running.', href: "https://docs.gitlab.com/runner/install/")
+ it_behaves_like 'creates runner and shows register page' do
+ let(:register_path_pattern) { register_project_runner_path(project, '.*') }
+ end
+ end
end
- describe 'runners registration token' do
- let!(:token) { project.runners_token }
+ context 'when create_runner_workflow_for_namespace is disabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: false)
+ end
+
+ it 'user can see a link with instructions on how to install GitLab Runner' do
+ visit project_runners_path(project)
+
+ expect(page).to have_link('Install GitLab Runner and ensure it\'s running.', href: "https://docs.gitlab.com/runner/install/")
+ end
+
+ describe 'runners registration token' do
+ let!(:token) { project.runners_token }
- context 'when project_runners_vue_ui is disabled' do
before do
visit project_runners_path(project)
end
diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb
index b7d06a3a962..976324a5032 100644
--- a/spec/features/search/user_searches_for_code_spec.rb
+++ b/spec/features/search/user_searches_for_code_spec.rb
@@ -99,64 +99,11 @@ RSpec.describe 'User searches for code', :js, :disable_rate_limiter, feature_cat
end
end
- context 'when :new_header_search is true' do
+ context 'when header search' do
context 'search code within refs' do
let(:ref_name) { 'v1.0.0' }
before do
- # This feature is disabled by default in spec_helper.rb.
- # We missed a feature breaking bug, so to prevent this regression, testing both scenarios for this spec.
- # This can be removed as part of closing https://gitlab.com/gitlab-org/gitlab/-/issues/339348.
- stub_feature_flags(new_header_search: true)
- visit(project_tree_path(project, ref_name))
-
- submit_search('gitlab-grack')
- select_search_scope('Code')
- end
-
- it 'shows ref switcher in code result summary' do
- expect(find('.ref-selector')).to have_text(ref_name)
- end
-
- it 'persists branch name across search' do
- find('.gl-search-box-by-click-search-button').click
- expect(find('.ref-selector')).to have_text(ref_name)
- end
-
- # this example is use to test the design that the refs is not
- # only represent the branch as well as the tags.
- it 'ref switcher list all the branches and tags' do
- find('.ref-selector').click
- wait_for_requests
-
- page.within('.ref-selector') do
- expect(page).to have_selector('li', text: 'add-ipython-files')
- expect(page).to have_selector('li', text: 'v1.0.0')
- end
- end
-
- it 'search result changes when refs switched' do
- expect(find('.results')).not_to have_content('path = gitlab-grack')
-
- find('.ref-selector').click
- wait_for_requests
-
- select_listbox_item('add-ipython-files')
-
- expect(page).to have_selector('.results', text: 'path = gitlab-grack')
- end
- end
- end
-
- context 'when :new_header_search is false' do
- context 'search code within refs' do
- let(:ref_name) { 'v1.0.0' }
-
- before do
- # This feature is disabled by default in spec_helper.rb.
- # We missed a feature breaking bug, so to prevent this regression, testing both scenarios for this spec.
- # This can be removed as part of closing https://gitlab.com/gitlab-org/gitlab/-/issues/339348.
- stub_feature_flags(new_header_search: false)
visit(project_tree_path(project, ref_name))
submit_search('gitlab-grack')
diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb
index 334a192bec4..71d0f8d6d7f 100644
--- a/spec/features/search/user_uses_header_search_field_spec.rb
+++ b/spec/features/search/user_uses_header_search_field_spec.rb
@@ -26,11 +26,21 @@ RSpec.describe 'User uses header search field', :js, :disable_rate_limiter, feat
wait_for_all_requests
end
- it 'starts searching by pressing the enter key' do
- submit_search('gitlab')
+ context 'when searching by pressing the enter key' do
+ before do
+ submit_search('gitlab')
+ end
+
+ it 'renders page title' do
+ page.within('.page-title') do
+ expect(page).to have_content('Search')
+ end
+ end
- page.within('.page-title') do
- expect(page).to have_content('Search')
+ it 'renders breadcrumbs' do
+ page.within('.breadcrumbs') do
+ expect(page).to have_content('Search')
+ end
end
end
diff --git a/spec/features/security/admin_access_spec.rb b/spec/features/security/admin_access_spec.rb
index de81444ed71..d162b24175f 100644
--- a/spec/features/security/admin_access_spec.rb
+++ b/spec/features/security/admin_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Admin::Projects", feature_category: :permissions do
+RSpec.describe "Admin::Projects", feature_category: :system_access do
include AccessMatchers
describe "GET /admin/projects" do
diff --git a/spec/features/security/dashboard_access_spec.rb b/spec/features/security/dashboard_access_spec.rb
index 948a4567624..0d60f1b1d11 100644
--- a/spec/features/security/dashboard_access_spec.rb
+++ b/spec/features/security/dashboard_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Dashboard access", feature_category: :permissions do
+RSpec.describe "Dashboard access", feature_category: :system_access do
include AccessMatchers
describe "GET /dashboard" do
diff --git a/spec/features/security/group/internal_access_spec.rb b/spec/features/security/group/internal_access_spec.rb
index ad2df4a1882..49f81600ac2 100644
--- a/spec/features/security/group/internal_access_spec.rb
+++ b/spec/features/security/group/internal_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Internal Group access', feature_category: :permissions do
+RSpec.describe 'Internal Group access', feature_category: :system_access do
include AccessMatchers
let(:group) { create(:group, :internal) }
diff --git a/spec/features/security/group/private_access_spec.rb b/spec/features/security/group/private_access_spec.rb
index 2e7b7512b45..5206667427e 100644
--- a/spec/features/security/group/private_access_spec.rb
+++ b/spec/features/security/group/private_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Private Group access', feature_category: :permissions do
+RSpec.describe 'Private Group access', feature_category: :system_access do
include AccessMatchers
let(:group) { create(:group, :private) }
diff --git a/spec/features/security/group/public_access_spec.rb b/spec/features/security/group/public_access_spec.rb
index 513c5710c8f..5c5580908aa 100644
--- a/spec/features/security/group/public_access_spec.rb
+++ b/spec/features/security/group/public_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Public Group access', feature_category: :permissions do
+RSpec.describe 'Public Group access', feature_category: :system_access do
include AccessMatchers
let(:group) { create(:group, :public) }
diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb
index e35e7ed742b..8ad4bedfdf8 100644
--- a/spec/features/security/project/internal_access_spec.rb
+++ b/spec/features/security/project/internal_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Internal Project Access", feature_category: :permissions do
+RSpec.describe "Internal Project Access", feature_category: :system_access do
include AccessMatchers
let_it_be(:project, reload: true) { create(:project, :internal, :repository, :with_namespace_settings) }
diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb
index 59ddb18ae8a..d2d74ecf5c9 100644
--- a/spec/features/security/project/private_access_spec.rb
+++ b/spec/features/security/project/private_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Private Project Access", feature_category: :permissions do
+RSpec.describe "Private Project Access", feature_category: :system_access do
include AccessMatchers
let_it_be(:project, reload: true) do
diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb
index 425691001f2..916f289b0b8 100644
--- a/spec/features/security/project/public_access_spec.rb
+++ b/spec/features/security/project/public_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Public Project Access", feature_category: :permissions do
+RSpec.describe "Public Project Access", feature_category: :system_access do
include AccessMatchers
let_it_be(:project, reload: true) do
diff --git a/spec/features/security/project/snippet/internal_access_spec.rb b/spec/features/security/project/snippet/internal_access_spec.rb
index b7dcc5f31d3..6ed0ec20210 100644
--- a/spec/features/security/project/snippet/internal_access_spec.rb
+++ b/spec/features/security/project/snippet/internal_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Internal Project Snippets Access", feature_category: :permissions do
+RSpec.describe "Internal Project Snippets Access", feature_category: :system_access do
include AccessMatchers
let_it_be(:project) { create(:project, :internal) }
diff --git a/spec/features/security/project/snippet/private_access_spec.rb b/spec/features/security/project/snippet/private_access_spec.rb
index 0ae45abb7ec..ef61f79a1b5 100644
--- a/spec/features/security/project/snippet/private_access_spec.rb
+++ b/spec/features/security/project/snippet/private_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Private Project Snippets Access", feature_category: :permissions do
+RSpec.describe "Private Project Snippets Access", feature_category: :system_access do
include AccessMatchers
let_it_be(:project) { create(:project, :private) }
diff --git a/spec/features/security/project/snippet/public_access_spec.rb b/spec/features/security/project/snippet/public_access_spec.rb
index b98f665c0dc..27fee745635 100644
--- a/spec/features/security/project/snippet/public_access_spec.rb
+++ b/spec/features/security/project/snippet/public_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe "Public Project Snippets Access", feature_category: :permissions do
+RSpec.describe "Public Project Snippets Access", feature_category: :system_access do
include AccessMatchers
let_it_be(:project) { create(:project, :public) }
diff --git a/spec/features/signed_commits_spec.rb b/spec/features/signed_commits_spec.rb
index 5d9b451cdf6..0268c8ad0d4 100644
--- a/spec/features/signed_commits_spec.rb
+++ b/spec/features/signed_commits_spec.rb
@@ -98,7 +98,8 @@ RSpec.describe 'GPG signed commits', feature_category: :source_code_management d
end
end
- it 'unverified signature: gpg key email does not match the committer_email when the committer_email belongs to the user as a unconfirmed secondary email' do
+ it 'unverified signature: gpg key email does not match the committer_email when the committer_email belongs to the user as a unconfirmed secondary email',
+ quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408233' do
user_2_key
visit project_commit_path(project, GpgHelpers::SIGNED_COMMIT_SHA)
@@ -112,7 +113,7 @@ RSpec.describe 'GPG signed commits', feature_category: :source_code_management d
end
end
- it 'unverified signature: commit contains multiple GPG signatures' do
+ it 'unverified signature: commit contains multiple GPG signatures', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408233' do
user_1_key
visit project_commit_path(project, GpgHelpers::MULTIPLE_SIGNATURES_SHA)
@@ -125,7 +126,7 @@ RSpec.describe 'GPG signed commits', feature_category: :source_code_management d
end
end
- it 'verified and the gpg user has a gitlab profile' do
+ it 'verified and the gpg user has a gitlab profile', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408233' do
user_1_key
visit project_commit_path(project, GpgHelpers::SIGNED_AND_AUTHORED_SHA)
@@ -139,7 +140,7 @@ RSpec.describe 'GPG signed commits', feature_category: :source_code_management d
end
end
- it "verified and the gpg user's profile doesn't exist anymore" do
+ it "verified and the gpg user's profile doesn't exist anymore", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/395802' do
user_1_key
visit project_commit_path(project, GpgHelpers::SIGNED_AND_AUTHORED_SHA)
diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb
index c281e5906ad..5c1ee729346 100644
--- a/spec/features/snippets/notes_on_personal_snippets_spec.rb
+++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb
@@ -81,6 +81,7 @@ RSpec.describe 'Comments on personal snippets', :js, feature_category: :source_c
it 'previews a note' do
fill_in 'note[note]', with: 'This is **awesome**!'
+
find('.js-md-preview-button').click
page.within('.new-note .md-preview-holder') do
@@ -119,20 +120,36 @@ RSpec.describe 'Comments on personal snippets', :js, feature_category: :source_c
end
context 'when editing a note' do
- it 'changes the text' do
- find('.js-note-edit').click
+ context 'when note is empty' do
+ before do
+ find('.js-note-edit').click
- page.within('.current-note-edit-form') do
- fill_in 'note[note]', with: 'new content'
- find('.btn-confirm').click
+ page.within('.current-note-edit-form') do
+ fill_in 'note[note]', with: ''
+ end
end
- page.within("#notes-list li#note_#{snippet_notes[0].id}") do
- edited_text = find('.edited-text')
+ it 'disables save button' do
+ expect(page).to have_button('Save comment', disabled: true)
+ end
+ end
+
+ context 'when note is not empty' do
+ it 'changes the text' do
+ find('.js-note-edit').click
+
+ page.within('.current-note-edit-form') do
+ fill_in 'note[note]', with: 'new content'
+ find('.btn-confirm').click
+ end
+
+ page.within("#notes-list li#note_#{snippet_notes[0].id}") do
+ edited_text = find('.edited-text')
- expect(page).to have_css('.note_edited_ago')
- expect(page).to have_content('new content')
- expect(edited_text).to have_selector('.note_edited_ago')
+ expect(page).to have_css('.note_edited_ago')
+ expect(page).to have_content('new content')
+ expect(edited_text).to have_selector('.note_edited_ago')
+ end
end
end
end
diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb
index dc2fcdd7305..2673ad5e1d7 100644
--- a/spec/features/snippets/show_spec.rb
+++ b/spec/features/snippets/show_spec.rb
@@ -25,14 +25,13 @@ RSpec.describe 'Snippet', :js, feature_category: :source_code_management do
subject { visit snippet_path(snippet) }
end
- it_behaves_like 'a dashboard page with sidebar', :dashboard_snippets_path, :snippets
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_snippets_path, :snippets
context 'when unauthenticated' do
- it 'does not have the sidebar' do
+ it 'shows the "Explore" sidebar' do
visit snippet_path(snippet)
- expect(page).to have_title _('Snippets')
- expect(page).not_to have_css('aside.nav-sidebar')
+ expect(page).to have_css('aside.nav-sidebar[aria-label="Explore"]')
end
end
@@ -43,6 +42,6 @@ RSpec.describe 'Snippet', :js, feature_category: :source_code_management do
sign_in(different_user)
end
- it_behaves_like 'a dashboard page with sidebar', :dashboard_snippets_path, :snippets
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_snippets_path, :snippets
end
end
diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb
index 03f569fe4b0..f4b6b552d46 100644
--- a/spec/features/snippets/user_creates_snippet_spec.rb
+++ b/spec/features/snippets/user_creates_snippet_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'User creates snippet', :js, feature_category: :source_code_management do
include DropzoneHelper
- include Spec::Support::Helpers::Features::SnippetSpecHelpers
+ include Features::SnippetSpecHelpers
let_it_be(:user) { create(:user) }
@@ -21,7 +21,7 @@ RSpec.describe 'User creates snippet', :js, feature_category: :source_code_manag
visit new_snippet_path
end
- it_behaves_like 'a dashboard page with sidebar', :new_snippet_path, :snippets
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :new_snippet_path, :snippets
def fill_form
snippet_fill_in_form(title: title, content: file_content, description: md_description)
@@ -128,7 +128,7 @@ RSpec.describe 'User creates snippet', :js, feature_category: :source_code_manag
expect(page).not_to have_content(files_validation_message)
end
- it 'previews a snippet with file' do
+ it 'previews a snippet with file', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408203' do
# Click placeholder first to expand full description field
snippet_fill_in_description('My Snippet')
dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
diff --git a/spec/features/snippets/user_edits_snippet_spec.rb b/spec/features/snippets/user_edits_snippet_spec.rb
index 5096472ebe1..f58fda67b59 100644
--- a/spec/features/snippets/user_edits_snippet_spec.rb
+++ b/spec/features/snippets/user_edits_snippet_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'User edits snippet', :js, feature_category: :source_code_management do
include DropzoneHelper
- include Spec::Support::Helpers::Features::SnippetSpecHelpers
+ include Features::SnippetSpecHelpers
let_it_be(:file_name) { 'test.rb' }
let_it_be(:content) { 'puts "test"' }
diff --git a/spec/features/tags/developer_creates_tag_spec.rb b/spec/features/tags/developer_creates_tag_spec.rb
index 6a1db051e87..cb59ee17514 100644
--- a/spec/features/tags/developer_creates_tag_spec.rb
+++ b/spec/features/tags/developer_creates_tag_spec.rb
@@ -62,7 +62,7 @@ RSpec.describe 'Developer creates tag', :js, feature_category: :source_code_mana
expect(ref_input.value).to eq 'master'
expect(find('.gl-button-text')).to have_content 'master'
find('.ref-selector').click
- expect(find('.gl-new-dropdown-inner')).to have_content 'test'
+ expect(find('.gl-new-dropdown-inner')).to have_content 'spooky-stuff'
end
end
end
diff --git a/spec/features/tags/developer_deletes_tag_spec.rb b/spec/features/tags/developer_deletes_tag_spec.rb
index 76cf3aa691d..19feb5b21bc 100644
--- a/spec/features/tags/developer_deletes_tag_spec.rb
+++ b/spec/features/tags/developer_deletes_tag_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Developer deletes tag', :js, feature_category: :source_code_management do
+ include Spec::Support::Helpers::ModalHelpers
+
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :repository, namespace: group) }
@@ -18,7 +20,7 @@ RSpec.describe 'Developer deletes tag', :js, feature_category: :source_code_mana
it 'deletes the tag' do
expect(page).to have_content 'v1.1.0'
- container = page.find('.content .flex-row', text: 'v1.1.0')
+ container = page.find('[data-testid="tag-row"]', text: 'v1.1.0')
delete_tag container
expect(page).not_to have_content 'v1.1.0'
@@ -28,7 +30,7 @@ RSpec.describe 'Developer deletes tag', :js, feature_category: :source_code_mana
it 'can not delete protected tags' do
expect(page).to have_content 'v1.1.1'
- container = page.find('.content .flex-row', text: 'v1.1.1')
+ container = page.find('[data-testid="tag-row"]', text: 'v1.1.1')
expect(container).to have_button('Only a project maintainer or owner can delete a protected tag',
disabled: true)
end
@@ -41,8 +43,7 @@ RSpec.describe 'Developer deletes tag', :js, feature_category: :source_code_mana
expect(page).to have_current_path(
project_tag_path(project, 'v1.0.0'), ignore_query: true)
- container = page.find('.nav-controls')
- delete_tag container
+ delete_tag
expect(page).to have_current_path(project_tags_path(project), ignore_query: true)
expect(page).not_to have_content 'v1.0.0'
@@ -58,17 +59,22 @@ RSpec.describe 'Developer deletes tag', :js, feature_category: :source_code_mana
end
it 'shows the error message' do
- container = page.find('.content .flex-row', text: 'v1.1.0')
+ container = page.find('[data-testid="tag-row"]', text: 'v1.1.0')
delete_tag container
expect(page).to have_content('Do not delete tags')
end
end
- def delete_tag(container)
- container.find('.js-delete-tag-button').click
+ def delete_tag(container = page.document)
+ within container do
+ click_button('Delete tag')
+ end
+
+ within_modal do
+ click_button('Yes, delete tag')
+ end
- page.within('.modal') { click_button('Yes, delete tag') }
wait_for_requests
end
end
diff --git a/spec/features/tags/developer_views_tags_spec.rb b/spec/features/tags/developer_views_tags_spec.rb
index dc9f38f1d83..81a41951377 100644
--- a/spec/features/tags/developer_views_tags_spec.rb
+++ b/spec/features/tags/developer_views_tags_spec.rb
@@ -60,7 +60,6 @@ RSpec.describe 'Developer views tags', feature_category: :source_code_management
expect(page).to have_current_path(
project_tag_path(project, 'v1.0.0'), ignore_query: true)
expect(page).to have_content 'v1.0.0'
- expect(page).to have_content 'This tag has no release notes.'
end
describe 'links on the tag page' do
diff --git a/spec/features/tags/maintainer_deletes_protected_tag_spec.rb b/spec/features/tags/maintainer_deletes_protected_tag_spec.rb
index ce518b962cd..67f6862502c 100644
--- a/spec/features/tags/maintainer_deletes_protected_tag_spec.rb
+++ b/spec/features/tags/maintainer_deletes_protected_tag_spec.rb
@@ -19,7 +19,10 @@ RSpec.describe 'Maintainer deletes protected tag', :js, feature_category: :sourc
it 'deletes the tag' do
expect(page).to have_content "#{tag_name} protected"
- page.find('.content .flex-row', text: tag_name).find('.js-delete-tag-button').click
+ page.within('[data-testid="tag-row"]', text: tag_name) do
+ click_button('Delete tag')
+ end
+
assert_modal_content(tag_name)
confirm_delete_tag(tag_name)
@@ -35,7 +38,7 @@ RSpec.describe 'Maintainer deletes protected tag', :js, feature_category: :sourc
it 'deletes the tag' do
expect(page).to have_current_path(project_tag_path(project, tag_name), ignore_query: true)
- page.find('.js-delete-tag-button').click
+ click_button('Delete tag')
assert_modal_content(tag_name)
confirm_delete_tag(tag_name)
diff --git a/spec/features/topic_show_spec.rb b/spec/features/topic_show_spec.rb
index d640e4e4edb..39b8782ea58 100644
--- a/spec/features/topic_show_spec.rb
+++ b/spec/features/topic_show_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe 'Topic show page', feature_category: :projects do
it 'shows title, avatar and description as markdown' do
expect(page).to have_content(topic.title)
expect(page).not_to have_content(topic.name)
- expect(page).to have_selector('.avatar-container > img.topic-avatar')
+ expect(page).to have_selector('.gl-avatar.gl-avatar-s64')
expect(find('.topic-description')).to have_selector('p > strong')
expect(find('.topic-description')).to have_selector('p > a[rel]')
expect(find('.topic-description')).to have_selector('p > gl-emoji')
diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb
deleted file mode 100644
index 9ef0626b2b2..00000000000
--- a/spec/features/u2f_spec.rb
+++ /dev/null
@@ -1,216 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js,
-feature_category: :authentication_and_authorization do
- include Spec::Support::Helpers::Features::TwoFactorHelpers
-
- before do
- stub_feature_flags(webauthn: false)
- end
-
- it_behaves_like 'hardware device for 2fa', 'U2F'
-
- describe "registration" do
- let(:user) { create(:user) }
-
- before do
- gitlab_sign_in(user)
- user.update_attribute(:otp_required_for_login, true)
- end
-
- describe 'when 2FA via OTP is enabled' do
- it 'allows registering more than one device' do
- visit profile_account_path
-
- # First device
- manage_two_factor_authentication
- first_device = register_u2f_device
- expect(page).to have_content('Your U2F device was registered')
-
- # Second device
- second_device = register_u2f_device(name: 'My other device')
- expect(page).to have_content('Your U2F device was registered')
-
- expect(page).to have_content(first_device.name)
- expect(page).to have_content(second_device.name)
- expect(U2fRegistration.count).to eq(2)
- end
- end
-
- it 'allows the same device to be registered for multiple users' do
- # U2f specs will be removed after WebAuthn migration completed
- pending('FakeU2fDevice has static key handle, '\
- 'leading to duplicate credential_xid for WebAuthn during migration, '\
- 'resulting in unique constraint violation')
-
- # First user
- visit profile_account_path
- manage_two_factor_authentication
- u2f_device = register_u2f_device
- expect(page).to have_content('Your U2F device was registered')
- gitlab_sign_out
-
- # Second user
- user = gitlab_sign_in(:user)
- user.update_attribute(:otp_required_for_login, true)
- visit profile_account_path
- manage_two_factor_authentication
- register_u2f_device(u2f_device, name: 'My other device')
- expect(page).to have_content('Your U2F device was registered')
-
- expect(U2fRegistration.count).to eq(2)
- end
-
- context "when there are form errors" do
- it "doesn't register the device if there are errors" do
- visit profile_account_path
- manage_two_factor_authentication
-
- # Have the "u2f device" respond with bad data
- page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };")
- click_on 'Set up new device'
- expect(page).to have_content('Your device was successfully set up')
- click_on 'Register device'
-
- expect(U2fRegistration.count).to eq(0)
- expect(page).to have_content("The form contains the following error")
- expect(page).to have_content("did not send a valid JSON response")
- end
-
- it "allows retrying registration" do
- visit profile_account_path
- manage_two_factor_authentication
-
- # Failed registration
- page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };")
- click_on 'Set up new device'
- expect(page).to have_content('Your device was successfully set up')
- click_on 'Register device'
- expect(page).to have_content("The form contains the following error")
-
- # Successful registration
- register_u2f_device
-
- expect(page).to have_content('Your U2F device was registered')
- expect(U2fRegistration.count).to eq(1)
- end
- end
- end
-
- describe "authentication" do
- let(:user) { create(:user) }
-
- before do
- # Register and logout
- gitlab_sign_in(user)
- user.update_attribute(:otp_required_for_login, true)
- visit profile_account_path
- manage_two_factor_authentication
- @u2f_device = register_u2f_device
- gitlab_sign_out
- end
-
- describe "when 2FA via OTP is disabled" do
- it "allows logging in with the U2F device" do
- user.update_attribute(:otp_required_for_login, false)
- gitlab_sign_in(user)
-
- @u2f_device.respond_to_u2f_authentication
-
- expect(page).to have_css('.sign-out-link', visible: false)
- end
- end
-
- describe "when 2FA via OTP is enabled" do
- it "allows logging in with the U2F device" do
- user.update_attribute(:otp_required_for_login, true)
- gitlab_sign_in(user)
-
- @u2f_device.respond_to_u2f_authentication
-
- expect(page).to have_css('.sign-out-link', visible: false)
- end
- end
-
- describe "when a given U2F device has already been registered by another user" do
- describe "but not the current user" do
- it "does not allow logging in with that particular device" do
- # Register current user with the different U2F device
- current_user = gitlab_sign_in(:user)
- current_user.update_attribute(:otp_required_for_login, true)
- visit profile_account_path
- manage_two_factor_authentication
- register_u2f_device(name: 'My other device')
- gitlab_sign_out
-
- # Try authenticating user with the old U2F device
- gitlab_sign_in(current_user)
- @u2f_device.respond_to_u2f_authentication
- expect(page).to have_content('Authentication via U2F device failed')
- end
- end
-
- describe "and also the current user" do
- it "allows logging in with that particular device" do
- # U2f specs will be removed after WebAuthn migration completed
- pending('FakeU2fDevice has static key handle, '\
- 'leading to duplicate credential_xid for WebAuthn during migration, '\
- 'resulting in unique constraint violation')
-
- # Register current user with the same U2F device
- current_user = gitlab_sign_in(:user)
- current_user.update_attribute(:otp_required_for_login, true)
- visit profile_account_path
- manage_two_factor_authentication
- register_u2f_device(@u2f_device)
- gitlab_sign_out
-
- # Try authenticating user with the same U2F device
- gitlab_sign_in(current_user)
- @u2f_device.respond_to_u2f_authentication
-
- expect(page).to have_css('.sign-out-link', visible: false)
- end
- end
- end
-
- describe "when a given U2F device has not been registered" do
- it "does not allow logging in with that particular device" do
- unregistered_device = FakeU2fDevice.new(page, 'My device')
- gitlab_sign_in(user)
- unregistered_device.respond_to_u2f_authentication
-
- expect(page).to have_content('Authentication via U2F device failed')
- end
- end
-
- describe "when more than one device has been registered by the same user" do
- it "allows logging in with either device" do
- # Register first device
- user = gitlab_sign_in(:user)
- user.update_attribute(:otp_required_for_login, true)
- visit profile_two_factor_auth_path
- expect(page).to have_content("Your device needs to be set up.")
- first_device = register_u2f_device
-
- # Register second device
- visit profile_two_factor_auth_path
- expect(page).to have_content("Your device needs to be set up.")
- second_device = register_u2f_device(name: 'My other device')
- gitlab_sign_out
-
- # Authenticate as both devices
- [first_device, second_device].each do |device|
- gitlab_sign_in(user)
- device.respond_to_u2f_authentication
-
- expect(page).to have_css('.sign-out-link', visible: false)
-
- gitlab_sign_out
- end
- end
- end
- end
-end
diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb
index 23fa6261bd5..28699bc2c24 100644
--- a/spec/features/unsubscribe_links_spec.rb
+++ b/spec/features/unsubscribe_links_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Unsubscribe links', :sidekiq_inline, feature_category: :not_owned do
+RSpec.describe 'Unsubscribe links', :sidekiq_inline, feature_category: :shared do
include Warden::Test::Helpers
let_it_be(:project) { create(:project, :public) }
diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb
index 4f6ce6e8f71..caf13c4111b 100644
--- a/spec/features/user_can_display_performance_bar_spec.rb
+++ b/spec/features/user_can_display_performance_bar_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'User can display performance bar', :js, feature_category: :continuous_verification do
+RSpec.describe 'User can display performance bar', :js, feature_category: :application_performance do
shared_examples 'performance bar cannot be displayed' do
it 'does not show the performance bar by default' do
expect(page).not_to have_css('#js-peek')
diff --git a/spec/features/user_sees_revert_modal_spec.rb b/spec/features/user_sees_revert_modal_spec.rb
index ae3158e4270..aca32d26bdb 100644
--- a/spec/features/user_sees_revert_modal_spec.rb
+++ b/spec/features/user_sees_revert_modal_spec.rb
@@ -19,9 +19,12 @@ feature_category: :code_review_workflow do
end
before do
+ stub_feature_flags(unbatch_graphql_queries: false)
sign_in(user)
visit(project_merge_request_path(project, merge_request))
- click_button('Merge')
+ page.within('.mr-state-widget') do
+ click_button 'Merge'
+ end
wait_for_requests
end
diff --git a/spec/features/user_sorts_things_spec.rb b/spec/features/user_sorts_things_spec.rb
index b45de88832c..bc377fb1f8f 100644
--- a/spec/features/user_sorts_things_spec.rb
+++ b/spec/features/user_sorts_things_spec.rb
@@ -7,7 +7,7 @@ require "spec_helper"
# The `it`s are named here by convention `starting point -> some pages -> final point`.
# All those specs are moved out to this spec intentionally to keep them all in one place.
RSpec.describe "User sorts things", :js do
- include Spec::Support::Helpers::Features::SortingHelpers
+ include Features::SortingHelpers
include DashboardHelper
let_it_be(:project) { create(:project_empty_repo, :public) }
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
index 5e683befeec..5529f0fa49e 100644
--- a/spec/features/users/login_spec.rb
+++ b/spec/features/users/login_spec.rb
@@ -109,6 +109,10 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
end
context 'within the grace period' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
+ end
+
it 'allows to login' do
expect(authentication_metrics).to increment(:user_authenticated_counter)
@@ -137,11 +141,9 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
end
context 'when resending the confirmation email' do
- it 'redirects to the "almost there" page' do
- stub_feature_flags(soft_email_confirmation: false)
-
- user = create(:user)
+ let_it_be(:user) { create(:user) }
+ it 'redirects to the "almost there" page' do
visit new_user_confirmation_path
fill_in 'user_email', with: user.email
click_button 'Resend'
@@ -206,18 +208,96 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
end
describe 'with two-factor authentication', :js do
- def enter_code(code)
+ def enter_code(code, only_two_factor_webauthn_enabled: false)
+ click_on("Sign in via 2FA code") if only_two_factor_webauthn_enabled
+
fill_in 'user_otp_attempt', with: code
click_button 'Verify code'
end
- context 'with valid username/password' do
+ shared_examples_for 'can login with recovery codes' do |only_two_factor_webauthn_enabled: false|
+ context 'using backup code' do
+ let(:codes) { user.generate_otp_backup_codes! }
+
+ before do
+ expect(codes.size).to eq 10
+
+ # Ensure the generated codes get saved
+ user.save!(touch: false)
+ end
+
+ context 'with valid code' do
+ it 'allows login' do
+ expect(authentication_metrics)
+ .to increment(:user_authenticated_counter)
+ .and increment(:user_two_factor_authenticated_counter)
+
+ enter_code(codes.sample, only_two_factor_webauthn_enabled: only_two_factor_webauthn_enabled)
+
+ expect(page).to have_current_path root_path, ignore_query: true
+ end
+
+ it 'invalidates the used code' do
+ expect(authentication_metrics)
+ .to increment(:user_authenticated_counter)
+ .and increment(:user_two_factor_authenticated_counter)
+
+ expect { enter_code(codes.sample, only_two_factor_webauthn_enabled: only_two_factor_webauthn_enabled) }
+ .to change { user.reload.otp_backup_codes.size }.by(-1)
+ end
+
+ it 'invalidates backup codes twice in a row' do
+ expect(authentication_metrics)
+ .to increment(:user_authenticated_counter).twice
+ .and increment(:user_two_factor_authenticated_counter).twice
+ .and increment(:user_session_destroyed_counter)
+
+ random_code = codes.delete(codes.sample)
+ expect { enter_code(random_code, only_two_factor_webauthn_enabled: only_two_factor_webauthn_enabled) }
+ .to change { user.reload.otp_backup_codes.size }.by(-1)
+
+ gitlab_sign_out
+ gitlab_sign_in(user)
+
+ expect { enter_code(codes.sample, only_two_factor_webauthn_enabled: only_two_factor_webauthn_enabled) }
+ .to change { user.reload.otp_backup_codes.size }.by(-1)
+ end
+
+ it 'triggers ActiveSession.cleanup for the user' do
+ expect(authentication_metrics)
+ .to increment(:user_authenticated_counter)
+ .and increment(:user_two_factor_authenticated_counter)
+ expect(ActiveSession).to receive(:cleanup).with(user).once.and_call_original
+
+ enter_code(codes.sample, only_two_factor_webauthn_enabled: only_two_factor_webauthn_enabled)
+ end
+ end
+
+ context 'with invalid code' do
+ it 'blocks login' do
+ # TODO, invalid two factor authentication does not increment
+ # metrics / counters, see gitlab-org/gitlab-ce#49785
+
+ code = codes.sample
+ expect(user.invalidate_otp_backup_code!(code)).to eq true
+
+ user.save!(touch: false)
+ expect(user.reload.otp_backup_codes.size).to eq 9
+
+ enter_code(code, only_two_factor_webauthn_enabled: only_two_factor_webauthn_enabled)
+ expect(page).to have_content('Invalid two-factor code.')
+ end
+ end
+ end
+ end
+
+ # Freeze time to prevent failures when time between code being entered and
+ # validated greater than otp_allowed_drift
+ context 'with valid username/password', :freeze_time do
let(:user) { create(:user, :two_factor) }
before do
gitlab_sign_in(user, remember: true)
-
- expect(page).to have_content('Two-factor authentication code')
end
it 'does not show a "You are already signed in." error message' do
@@ -290,78 +370,16 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
end
end
- context 'using backup code' do
- let(:codes) { user.generate_otp_backup_codes! }
-
- before do
- expect(codes.size).to eq 10
-
- # Ensure the generated codes get saved
- user.save!(touch: false)
- end
-
- context 'with valid code' do
- it 'allows login' do
- expect(authentication_metrics)
- .to increment(:user_authenticated_counter)
- .and increment(:user_two_factor_authenticated_counter)
-
- enter_code(codes.sample)
-
- expect(page).to have_current_path root_path, ignore_query: true
- end
-
- it 'invalidates the used code' do
- expect(authentication_metrics)
- .to increment(:user_authenticated_counter)
- .and increment(:user_two_factor_authenticated_counter)
-
- expect { enter_code(codes.sample) }
- .to change { user.reload.otp_backup_codes.size }.by(-1)
- end
-
- it 'invalidates backup codes twice in a row' do
- expect(authentication_metrics)
- .to increment(:user_authenticated_counter).twice
- .and increment(:user_two_factor_authenticated_counter).twice
- .and increment(:user_session_destroyed_counter)
-
- random_code = codes.delete(codes.sample)
- expect { enter_code(random_code) }
- .to change { user.reload.otp_backup_codes.size }.by(-1)
-
- gitlab_sign_out
- gitlab_sign_in(user)
-
- expect { enter_code(codes.sample) }
- .to change { user.reload.otp_backup_codes.size }.by(-1)
- end
-
- it 'triggers ActiveSession.cleanup for the user' do
- expect(authentication_metrics)
- .to increment(:user_authenticated_counter)
- .and increment(:user_two_factor_authenticated_counter)
- expect(ActiveSession).to receive(:cleanup).with(user).once.and_call_original
-
- enter_code(codes.sample)
- end
- end
+ context 'when user with TOTP enabled' do
+ let(:user) { create(:user, :two_factor) }
- context 'with invalid code' do
- it 'blocks login' do
- # TODO, invalid two factor authentication does not increment
- # metrics / counters, see gitlab-org/gitlab-ce#49785
-
- code = codes.sample
- expect(user.invalidate_otp_backup_code!(code)).to eq true
+ include_examples 'can login with recovery codes'
+ end
- user.save!(touch: false)
- expect(user.reload.otp_backup_codes.size).to eq 9
+ context 'when user with only Webauthn enabled' do
+ let(:user) { create(:user, :two_factor_via_webauthn, registrations_count: 1) }
- enter_code(code)
- expect(page).to have_content('Invalid two-factor code.')
- end
- end
+ include_examples 'can login with recovery codes', only_two_factor_webauthn_enabled: true
end
end
@@ -376,11 +394,29 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
providers: [mock_saml_config_with_upstream_two_factor_authn_contexts])
end
+ it 'displays the remember me checkbox' do
+ visit new_user_session_path
+
+ expect(page).to have_field('remember_me_omniauth')
+ end
+
+ context 'when remember me is not enabled' do
+ before do
+ stub_application_setting(remember_me_enabled: false)
+ end
+
+ it 'does not display the remember me checkbox' do
+ visit new_user_session_path
+
+ expect(page).not_to have_field('remember_me_omniauth')
+ end
+ end
+
context 'when authn_context is worth two factors' do
let(:mock_saml_response) do
File.read('spec/fixtures/authentication/saml_response.xml')
- .gsub('urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
- 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS')
+ .gsub('urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
+ 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS')
end
it 'signs user in without prompting for second factor' do
@@ -394,12 +430,14 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
sign_in_using_saml!
expect_single_session_with_authenticated_ttl
- expect(page).not_to have_content('Two-Factor Authentication')
+ expect(page).not_to have_content(_('Enter verification code'))
expect(page).to have_current_path root_path, ignore_query: true
end
end
- context 'when two factor authentication is required' do
+ # Freeze time to prevent failures when time between code being entered and
+ # validated greater than otp_allowed_drift
+ context 'when two factor authentication is required', :freeze_time do
it 'shows 2FA prompt after OAuth login' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
@@ -408,7 +446,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
sign_in_using_saml!
- expect(page).to have_content('Two-factor authentication code')
+ expect(page).to have_content('Enter verification code')
enter_code(user.current_otp)
@@ -424,6 +462,24 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
end
describe 'without two-factor authentication' do
+ it 'displays the remember me checkbox' do
+ visit new_user_session_path
+
+ expect(page).to have_content(_('Remember me'))
+ end
+
+ context 'when remember me is not enabled' do
+ before do
+ stub_application_setting(remember_me_enabled: false)
+ end
+
+ it 'does not display the remember me checkbox' do
+ visit new_user_session_path
+
+ expect(page).not_to have_content(_('Remember me'))
+ end
+ end
+
context 'with correct username and password' do
let(:user) { create(:user) }
@@ -457,6 +513,43 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
gitlab_sign_in(user)
end
+ context 'when the session expires' do
+ it 'signs the user out' do
+ expect(authentication_metrics)
+ .to increment(:user_authenticated_counter)
+
+ gitlab_sign_in(user)
+ expire_session
+ visit root_path
+
+ expect(page).to have_current_path new_user_session_path
+ end
+
+ it 'extends the session when using remember me' do
+ expect(authentication_metrics)
+ .to increment(:user_authenticated_counter).twice
+
+ gitlab_sign_in(user, remember: true)
+ expire_session
+ visit root_path
+
+ expect(page).to have_current_path root_path
+ end
+
+ it 'does not extend the session when remember me is not enabled' do
+ expect(authentication_metrics)
+ .to increment(:user_authenticated_counter)
+
+ gitlab_sign_in(user, remember: true)
+ expire_session
+ stub_application_setting(remember_me_enabled: false)
+
+ visit root_path
+
+ expect(page).to have_current_path new_user_session_path
+ end
+ end
+
context 'when the users password is expired' do
before do
user.update!(password_expires_at: Time.zone.parse('2018-05-08 11:29:46 UTC'))
@@ -591,23 +684,21 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
end
context 'within the grace period' do
- it 'redirects to two-factor configuration page' do
- freeze_time do
- expect(authentication_metrics)
- .to increment(:user_authenticated_counter)
-
- gitlab_sign_in(user)
-
- expect(page).to have_current_path profile_two_factor_auth_path, ignore_query: true
- expect(page).to have_content(
- 'The group settings for Group 1 and Group 2 require you to enable '\
- 'Two-Factor Authentication for your account. '\
- 'You can leave Group 1 and leave Group 2. '\
- 'You need to do this '\
- 'before '\
- "#{(Time.zone.now + 2.days).strftime("%a, %d %b %Y %H:%M:%S %z")}"
- )
- end
+ it 'redirects to two-factor configuration page', :freeze_time do
+ expect(authentication_metrics)
+ .to increment(:user_authenticated_counter)
+
+ gitlab_sign_in(user)
+
+ expect(page).to have_current_path profile_two_factor_auth_path, ignore_query: true
+ expect(page).to have_content(
+ 'The group settings for Group 1 and Group 2 require you to enable '\
+ 'Two-Factor Authentication for your account. '\
+ 'You can leave Group 1 and leave Group 2. '\
+ 'You need to do this '\
+ 'before '\
+ "#{(Time.zone.now + 2.days).strftime("%a, %d %b %Y %H:%M:%S %z")}"
+ )
end
it 'allows skipping two-factor configuration', :js do
@@ -732,17 +823,37 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
allow(instance).to receive(:"user_#{provider}_omniauth_callback_path")
.and_return("/users/auth/#{provider}/callback")
end
-
- visit new_user_session_path
end
it 'correctly renders tabs and panes' do
+ visit new_user_session_path
+
ensure_tab_pane_correctness(['Main LDAP', 'Standard'])
end
it 'renders link to sign up path' do
+ visit new_user_session_path
+
expect(page.body).to have_link('Register now', href: new_user_registration_path)
end
+
+ it 'displays the remember me checkbox' do
+ visit new_user_session_path
+
+ ensure_remember_me_in_tab(ldap_server_config['label'])
+ end
+
+ context 'when remember me is not enabled' do
+ before do
+ stub_application_setting(remember_me_enabled: false)
+ end
+
+ it 'does not display the remember me checkbox' do
+ visit new_user_session_path
+
+ ensure_remember_me_not_in_tab(ldap_server_config['label'])
+ end
+ end
end
context 'when crowd is enabled' do
@@ -757,13 +868,31 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
allow(instance).to receive(:user_crowd_omniauth_authorize_path)
.and_return("/users/auth/crowd/callback")
end
-
- visit new_user_session_path
end
it 'correctly renders tabs and panes' do
+ visit new_user_session_path
+
ensure_tab_pane_correctness(%w(Crowd Standard))
end
+
+ it 'displays the remember me checkbox' do
+ visit new_user_session_path
+
+ ensure_remember_me_in_tab(_('Crowd'))
+ end
+
+ context 'when remember me is not enabled' do
+ before do
+ stub_application_setting(remember_me_enabled: false)
+ end
+
+ it 'does not display the remember me checkbox' do
+ visit new_user_session_path
+
+ ensure_remember_me_not_in_tab(_('Crowd'))
+ end
+ end
end
end
@@ -928,22 +1057,22 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
it 'asks the user to accept the terms before setting an email',
quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/388049', type: :flaky } do
- expect(authentication_metrics)
- .to increment(:user_authenticated_counter)
+ expect(authentication_metrics)
+ .to increment(:user_authenticated_counter)
- gitlab_sign_in_via('saml', user, 'my-uid')
+ gitlab_sign_in_via('saml', user, 'my-uid')
- expect_to_be_on_terms_page
- click_button 'Accept terms'
+ expect_to_be_on_terms_page
+ click_button 'Accept terms'
- expect(page).to have_current_path(profile_path, ignore_query: true)
+ expect(page).to have_current_path(profile_path, ignore_query: true)
- fill_in 'Email', with: 'hello@world.com'
+ fill_in 'Email', with: 'hello@world.com'
- click_button 'Update profile settings'
+ click_button 'Update profile settings'
- expect(page).to have_content('Profile was successfully updated')
- end
+ expect(page).to have_content('Profile was successfully updated')
+ end
end
end
@@ -954,8 +1083,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_
let(:alert_message) { "To continue, you need to select the link in the confirmation email we sent to verify your email address. If you didn't get our email, select Resend confirmation email" }
before do
- stub_application_setting_enum('email_confirmation_setting', 'hard')
- stub_feature_flags(soft_email_confirmation: true)
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
stub_feature_flags(identity_verification: false)
allow(User).to receive(:allow_unconfirmed_access_for).and_return grace_period
end
diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb
index 88b2d918976..9c4a1b36ecc 100644
--- a/spec/features/users/show_spec.rb
+++ b/spec/features/users/show_spec.rb
@@ -15,6 +15,14 @@ RSpec.describe 'User page', feature_category: :user_profile do
expect(page).to have_content("User ID: #{user.id}")
end
+ it 'shows name on breadcrumbs' do
+ subject
+
+ page.within '.breadcrumbs' do
+ expect(page).to have_content(user.name)
+ end
+ end
+
context 'with public profile' do
context 'with `profile_tabs_vue` feature flag disabled' do
before do
@@ -149,7 +157,7 @@ RSpec.describe 'User page', feature_category: :user_profile do
end
end
- context 'follow/unfollow and followers/following' do
+ context 'follow/unfollow and followers/following', :js do
let_it_be(:followee) { create(:user) }
let_it_be(:follower) { create(:user) }
@@ -159,21 +167,33 @@ RSpec.describe 'User page', feature_category: :user_profile do
expect(page).not_to have_button(text: 'Follow', class: 'gl-button')
end
- it 'shows 0 followers and 0 following' do
- subject
+ shared_examples 'follower tabs with count badges' do
+ it 'shows 0 followers and 0 following' do
+ subject
+
+ expect(page).to have_content('Followers 0')
+ expect(page).to have_content('Following 0')
+ end
- expect(page).to have_content('0 followers')
- expect(page).to have_content('0 following')
+ it 'shows 1 followers and 1 following' do
+ follower.follow(user)
+ user.follow(followee)
+
+ subject
+
+ expect(page).to have_content('Followers 1')
+ expect(page).to have_content('Following 1')
+ end
end
- it 'shows 1 followers and 1 following' do
- follower.follow(user)
- user.follow(followee)
+ it_behaves_like 'follower tabs with count badges'
- subject
+ context 'with profile_tabs_vue feature flag disabled' do
+ before_all do
+ stub_feature_flags(profile_tabs_vue: false)
+ end
- expect(page).to have_content('1 follower')
- expect(page).to have_content('1 following')
+ it_behaves_like 'follower tabs with count badges'
end
it 'does show button to follow' do
@@ -526,4 +546,36 @@ RSpec.describe 'User page', feature_category: :user_profile do
end
end
end
+
+ context 'achievements' do
+ it 'renders the user achievements mount point' do
+ subject
+
+ expect(page).to have_selector('#js-user-achievements')
+ end
+
+ context 'when the user has chosen not to display achievements' do
+ let(:user) { create(:user) }
+
+ before do
+ user.update!(achievements_enabled: false)
+ end
+
+ it 'does not render the user achievements mount point' do
+ subject
+
+ expect(page).not_to have_selector('#js-user-achievements')
+ end
+ end
+
+ context 'when the profile is private' do
+ let(:user) { create(:user, private_profile: true) }
+
+ it 'does not render the user achievements mount point' do
+ subject
+
+ expect(page).not_to have_selector('#js-user-achievements')
+ end
+ end
+ end
end
diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb
index 11ff318c346..d65eea3671c 100644
--- a/spec/features/users/signup_spec.rb
+++ b/spec/features/users/signup_spec.rb
@@ -10,7 +10,7 @@ RSpec.shared_examples 'Signup name validation' do |field, max_length, label|
visit new_user_registration_path
end
- describe "#{field} validation", :js do
+ describe "#{field} validation" do
it "does not show an error border if the user's fullname length is not longer than #{max_length} characters" do
fill_in field, with: 'u' * max_length
@@ -44,7 +44,7 @@ RSpec.shared_examples 'Signup name validation' do |field, max_length, label|
end
end
-RSpec.describe 'Signup', feature_category: :user_profile do
+RSpec.describe 'Signup', :js, feature_category: :user_profile do
include TermsHelper
let(:new_user) { build_stubbed(:user) }
@@ -71,7 +71,7 @@ RSpec.describe 'Signup', feature_category: :user_profile do
stub_application_setting(require_admin_approval_after_user_signup: false)
end
- describe 'username validation', :js do
+ describe 'username validation' do
before do
visit new_user_registration_path
end
@@ -200,9 +200,8 @@ RSpec.describe 'Signup', feature_category: :user_profile do
stub_application_setting_enum('email_confirmation_setting', 'hard')
end
- context 'when soft email confirmation is not enabled' do
+ context 'when email confirmation setting is not `soft`' do
before do
- stub_feature_flags(soft_email_confirmation: false)
stub_feature_flags(identity_verification: false)
end
@@ -221,9 +220,9 @@ RSpec.describe 'Signup', feature_category: :user_profile do
end
end
- context 'when soft email confirmation is enabled' do
+ context 'when email confirmation setting is `soft`' do
before do
- stub_feature_flags(soft_email_confirmation: true)
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
end
it 'creates the user account and sends a confirmation email' do
@@ -338,6 +337,7 @@ RSpec.describe 'Signup', feature_category: :user_profile do
expect { click_button 'Register' }.not_to change { User.count }
expect(page).to have_content(_('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'))
+ expect(page).to have_content("Minimum length is #{Gitlab::CurrentSettings.minimum_password_length} characters")
end
end
@@ -357,6 +357,8 @@ RSpec.describe 'Signup', feature_category: :user_profile do
visit new_user_registration_path
fill_in_signup_form
+ wait_for_all_requests
+
click_button 'Register'
visit new_project_path
@@ -384,7 +386,7 @@ RSpec.describe 'Signup', feature_category: :user_profile do
expect(page.body).not_to match(/#{new_user.password}/)
end
- context 'with invalid email', :saas, :js do
+ context 'with invalid email' do
it_behaves_like 'user email validation' do
let(:path) { new_user_registration_path }
end
diff --git a/spec/features/webauthn_spec.rb b/spec/features/webauthn_spec.rb
index 859793d1353..5c42facfa8b 100644
--- a/spec/features/webauthn_spec.rb
+++ b/spec/features/webauthn_spec.rb
@@ -2,14 +2,121 @@
require 'spec_helper'
-RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_category: :authentication_and_authorization do
- include Spec::Support::Helpers::Features::TwoFactorHelpers
+RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_category: :system_access do
+ include Features::TwoFactorHelpers
let(:app_id) { "http://#{Capybara.current_session.server.host}:#{Capybara.current_session.server.port}" }
before do
WebAuthn.configuration.origin = app_id
end
+ context 'when the webauth_without_totp feature flag is enabled' do
+ # Some of the shared tests don't apply. After removing U2F support and the `webauthn_without_totp` feature flag, refactor the shared tests.
+ # TODO: it_behaves_like 'hardware device for 2fa', 'WebAuthn'
+
+ describe 'registration' do
+ let(:user) { create(:user) }
+
+ before do
+ gitlab_sign_in(user)
+ end
+
+ it 'shows an error when using a wrong password' do
+ visit profile_account_path
+
+ # First device
+ enable_two_factor_authentication
+ webauthn_device_registration(password: 'fake')
+ expect(page).to have_content(_('You must provide a valid current password.'))
+ end
+
+ it 'allows registering more than one device' do
+ visit profile_account_path
+
+ # First device
+ enable_two_factor_authentication
+ first_device = webauthn_device_registration(password: user.password)
+ expect(page).to have_content('Your WebAuthn device was registered!')
+ copy_recovery_codes
+ manage_two_factor_authentication
+
+ # Second device
+ second_device = webauthn_device_registration(name: 'My other device', password: user.password)
+ expect(page).to have_content('Your WebAuthn device was registered!')
+
+ expect(page).to have_content(first_device.name)
+ expect(page).to have_content(second_device.name)
+ expect(WebauthnRegistration.count).to eq(2)
+ end
+
+ it 'allows the same device to be registered for multiple users' do
+ # First user
+ visit profile_account_path
+ enable_two_factor_authentication
+ webauthn_device = webauthn_device_registration(password: user.password)
+ expect(page).to have_content('Your WebAuthn device was registered!')
+ gitlab_sign_out
+
+ # Second user
+ user = gitlab_sign_in(:user)
+ visit profile_account_path
+ enable_two_factor_authentication
+ webauthn_device_registration(webauthn_device: webauthn_device, name: 'My other device', password: user.password)
+ expect(page).to have_content('Your WebAuthn device was registered!')
+
+ expect(WebauthnRegistration.count).to eq(2)
+ end
+
+ context 'when there are form errors' do
+ let(:mock_register_js) do
+ <<~JS
+ const mockResponse = {
+ type: 'public-key',
+ id: '',
+ rawId: '',
+ response: {
+ clientDataJSON: '',
+ attestationObject: '',
+ },
+ getClientExtensionResults: () => {},
+ };
+ navigator.credentials.create = () => Promise.resolve(mockResponse);
+ JS
+ end
+
+ it "doesn't register the device if there are errors" do
+ visit profile_account_path
+ enable_two_factor_authentication
+
+ # Have the "webauthn device" respond with bad data
+ page.execute_script(mock_register_js)
+ click_on _('Set up new device')
+ webauthn_fill_form_and_submit(password: user.password)
+ expect(page).to have_content(_('Your WebAuthn device did not send a valid JSON response.'))
+
+ expect(WebauthnRegistration.count).to eq(0)
+ end
+
+ it 'allows retrying registration' do
+ visit profile_account_path
+ enable_two_factor_authentication
+
+ # Failed registration
+ page.execute_script(mock_register_js)
+ click_on _('Set up new device')
+ webauthn_fill_form_and_submit(password: user.password)
+ expect(page).to have_content(_('Your WebAuthn device did not send a valid JSON response.'))
+
+ # Successful registration
+ webauthn_device_registration(password: user.password)
+
+ expect(page).to have_content('Your WebAuthn device was registered!')
+ expect(WebauthnRegistration.count).to eq(1)
+ end
+ end
+ end
+ end
+
context 'when the webauth_without_totp feature flag is disabled' do
before do
stub_feature_flags(webauthn_without_totp: false)
@@ -114,99 +221,99 @@ RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_categor
end
end
end
+ end
- describe 'authentication' do
- let(:otp_required_for_login) { true }
- let(:user) { create(:user, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) }
- let!(:webauthn_device) do
- add_webauthn_device(app_id, user)
- end
+ describe 'authentication' do
+ let(:otp_required_for_login) { true }
+ let(:user) { create(:user, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) }
+ let!(:webauthn_device) do
+ add_webauthn_device(app_id, user)
+ end
- describe 'when 2FA via OTP is disabled' do
- let(:otp_required_for_login) { false }
+ describe 'when 2FA via OTP is disabled' do
+ let(:otp_required_for_login) { false }
- it 'allows logging in with the WebAuthn device' do
- gitlab_sign_in(user)
+ it 'allows logging in with the WebAuthn device' do
+ gitlab_sign_in(user)
- webauthn_device.respond_to_webauthn_authentication
+ webauthn_device.respond_to_webauthn_authentication
- expect(page).to have_css('.sign-out-link', visible: false)
- end
+ expect(page).to have_css('.sign-out-link', visible: false)
end
+ end
- describe 'when 2FA via OTP is enabled' do
- it 'allows logging in with the WebAuthn device' do
- gitlab_sign_in(user)
+ describe 'when 2FA via OTP is enabled' do
+ it 'allows logging in with the WebAuthn device' do
+ gitlab_sign_in(user)
- webauthn_device.respond_to_webauthn_authentication
+ webauthn_device.respond_to_webauthn_authentication
- expect(page).to have_css('.sign-out-link', visible: false)
- end
+ expect(page).to have_css('.sign-out-link', visible: false)
end
+ end
- describe 'when a given WebAuthn device has already been registered by another user' do
- describe 'but not the current user' do
- let(:other_user) { create(:user, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) }
+ describe 'when a given WebAuthn device has already been registered by another user' do
+ describe 'but not the current user' do
+ let(:other_user) { create(:user, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) }
- it 'does not allow logging in with that particular device' do
- # Register other user with a different WebAuthn device
- other_device = add_webauthn_device(app_id, other_user)
+ it 'does not allow logging in with that particular device' do
+ # Register other user with a different WebAuthn device
+ other_device = add_webauthn_device(app_id, other_user)
- # Try authenticating user with the old WebAuthn device
- gitlab_sign_in(user)
- other_device.respond_to_webauthn_authentication
- expect(page).to have_content('Authentication via WebAuthn device failed')
- end
+ # Try authenticating user with the old WebAuthn device
+ gitlab_sign_in(user)
+ other_device.respond_to_webauthn_authentication
+ expect(page).to have_content('Authentication via WebAuthn device failed')
end
+ end
+
+ describe "and also the current user" do
+ # TODO Uncomment once WebAuthn::FakeClient supports passing credential options
+ # (especially allow_credentials, as this is needed to specify which credential the
+ # fake client should use. Currently, the first credential is always used).
+ # There is an issue open for this: https://github.com/cedarcode/webauthn-ruby/issues/259
+ it "allows logging in with that particular device" do
+ pending("support for passing credential options in FakeClient")
+ # Register current user with the same WebAuthn device
+ current_user = gitlab_sign_in(:user)
+ visit profile_account_path
+ manage_two_factor_authentication
+ register_webauthn_device(webauthn_device)
+ gitlab_sign_out
+
+ # Try authenticating user with the same WebAuthn device
+ gitlab_sign_in(current_user)
+ webauthn_device.respond_to_webauthn_authentication
- describe "and also the current user" do
- # TODO Uncomment once WebAuthn::FakeClient supports passing credential options
- # (especially allow_credentials, as this is needed to specify which credential the
- # fake client should use. Currently, the first credential is always used).
- # There is an issue open for this: https://github.com/cedarcode/webauthn-ruby/issues/259
- it "allows logging in with that particular device" do
- pending("support for passing credential options in FakeClient")
- # Register current user with the same WebAuthn device
- current_user = gitlab_sign_in(:user)
- visit profile_account_path
- manage_two_factor_authentication
- register_webauthn_device(webauthn_device)
- gitlab_sign_out
-
- # Try authenticating user with the same WebAuthn device
- gitlab_sign_in(current_user)
- webauthn_device.respond_to_webauthn_authentication
-
- expect(page).to have_css('.sign-out-link', visible: false)
- end
+ expect(page).to have_css('.sign-out-link', visible: false)
end
end
+ end
- describe 'when a given WebAuthn device has not been registered' do
- it 'does not allow logging in with that particular device' do
- unregistered_device = FakeWebauthnDevice.new(page, 'My device')
- gitlab_sign_in(user)
- unregistered_device.respond_to_webauthn_authentication
+ describe 'when a given WebAuthn device has not been registered' do
+ it 'does not allow logging in with that particular device' do
+ unregistered_device = FakeWebauthnDevice.new(page, 'My device')
+ gitlab_sign_in(user)
+ unregistered_device.respond_to_webauthn_authentication
- expect(page).to have_content('Authentication via WebAuthn device failed')
- end
+ expect(page).to have_content('Authentication via WebAuthn device failed')
end
+ end
- describe 'when more than one device has been registered by the same user' do
- it 'allows logging in with either device' do
- first_device = add_webauthn_device(app_id, user)
- second_device = add_webauthn_device(app_id, user)
+ describe 'when more than one device has been registered by the same user' do
+ it 'allows logging in with either device' do
+ first_device = add_webauthn_device(app_id, user)
+ second_device = add_webauthn_device(app_id, user)
- # Authenticate as both devices
- [first_device, second_device].each do |device|
- gitlab_sign_in(user)
- # register_webauthn_device(device)
- device.respond_to_webauthn_authentication
+ # Authenticate as both devices
+ [first_device, second_device].each do |device|
+ gitlab_sign_in(user)
+ # register_webauthn_device(device)
+ device.respond_to_webauthn_authentication
- expect(page).to have_css('.sign-out-link', visible: false)
+ expect(page).to have_css('.sign-out-link', visible: false)
- gitlab_sign_out
- end
+ gitlab_sign_out
end
end
end
diff --git a/spec/features/whats_new_spec.rb b/spec/features/whats_new_spec.rb
index 6b19ab28b44..3668d90f2e9 100644
--- a/spec/features/whats_new_spec.rb
+++ b/spec/features/whats_new_spec.rb
@@ -2,13 +2,11 @@
require "spec_helper"
-RSpec.describe "renders a `whats new` dropdown item", feature_category: :not_owned do
+RSpec.describe "renders a `whats new` dropdown item", feature_category: :onboarding do
let_it_be(:user) { create(:user) }
context 'when not logged in' do
- it 'and on .com it renders' do
- allow(Gitlab).to receive(:com?).and_return(true)
-
+ it 'and on SaaS it renders', :saas do
visit user_path(user)
page.within '.header-help' do
diff --git a/spec/features/work_items/work_item_children_spec.rb b/spec/features/work_items/work_item_children_spec.rb
deleted file mode 100644
index f41fb86d13c..00000000000
--- a/spec/features/work_items/work_item_children_spec.rb
+++ /dev/null
@@ -1,136 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Work item children', :js, feature_category: :team_planning do
- let_it_be(:group) { create(:group) }
- let_it_be(:project) { create(:project, :public, namespace: group) }
- let_it_be(:user) { create(:user) }
- let_it_be(:issue) { create(:issue, project: project) }
-
- context 'for signed in user' do
- before do
- project.add_developer(user)
-
- sign_in(user)
-
- stub_feature_flags(work_items: true)
-
- visit project_issue_path(project, issue)
-
- wait_for_requests
- end
-
- it 'are not displayed when issue does not have work item children', :aggregate_failures do
- page.within('[data-testid="work-item-links"]') do
- expect(find('[data-testid="links-empty"]')).to have_content(_('No tasks are currently assigned.'))
- expect(page).not_to have_selector('[data-testid="add-links-form"]')
- expect(page).not_to have_selector('[data-testid="links-child"]')
- end
- end
-
- it 'toggles widget body', :aggregate_failures do
- page.within('[data-testid="work-item-links"]') do
- expect(page).to have_selector('[data-testid="widget-body"]')
-
- click_button 'Collapse'
-
- expect(page).not_to have_selector('[data-testid="widget-body"]')
-
- click_button 'Expand'
-
- expect(page).to have_selector('[data-testid="widget-body"]')
- end
- end
-
- it 'toggles form', :aggregate_failures do
- page.within('[data-testid="work-item-links"]') do
- expect(page).not_to have_selector('[data-testid="add-links-form"]')
-
- click_button 'Add'
- click_button 'New task'
-
- expect(page).to have_selector('[data-testid="add-links-form"]')
-
- click_button 'Cancel'
-
- expect(page).not_to have_selector('[data-testid="add-links-form"]')
- end
- end
-
- it 'adds a new child task', :aggregate_failures do
- page.within('[data-testid="work-item-links"]') do
- click_button 'Add'
- click_button 'New task'
-
- expect(page).to have_button('Create task', disabled: true)
- fill_in 'Add a title', with: 'Task 1'
-
- expect(page).to have_button('Create task', disabled: false)
-
- click_button 'Create task'
-
- wait_for_all_requests
-
- expect(find('[data-testid="links-child"]')).to have_content('Task 1')
- end
- end
-
- it 'removes a child task and undoing', :aggregate_failures do
- page.within('[data-testid="work-item-links"]') do
- click_button 'Add'
- click_button 'New task'
- fill_in 'Add a title', with: 'Task 1'
- click_button 'Create task'
- wait_for_all_requests
-
- expect(find('[data-testid="links-child"]')).to have_content('Task 1')
- expect(find('[data-testid="children-count"]')).to have_content('1')
-
- find('[data-testid="links-menu"]').click
- click_button 'Remove'
-
- wait_for_all_requests
-
- expect(page).not_to have_content('Task 1')
- expect(find('[data-testid="children-count"]')).to have_content('0')
- end
-
- page.within('.gl-toast') do
- expect(find('.toast-body')).to have_content(_('Child removed'))
- find('.b-toaster a', text: 'Undo').click
- end
-
- wait_for_all_requests
-
- page.within('[data-testid="work-item-links"]') do
- expect(find('[data-testid="links-child"]')).to have_content('Task 1')
- expect(find('[data-testid="children-count"]')).to have_content('1')
- end
- end
-
- context 'with existing task' do
- let_it_be(:task) { create(:work_item, :task, project: project) }
-
- it 'adds an existing child task', :aggregate_failures do
- page.within('[data-testid="work-item-links"]') do
- click_button 'Add'
- click_button 'Existing task'
-
- expect(page).to have_button('Add task', disabled: true)
- find('[data-testid="work-item-token-select-input"]').set(task.title)
- wait_for_all_requests
- click_button task.title
-
- expect(page).to have_button('Add task', disabled: false)
-
- click_button 'Add task'
-
- wait_for_all_requests
-
- expect(find('[data-testid="links-child"]')).to have_content(task.title)
- end
- end
- end
- end
-end
diff --git a/spec/features/work_items/work_item_spec.rb b/spec/features/work_items/work_item_spec.rb
deleted file mode 100644
index 3c71a27ff82..00000000000
--- a/spec/features/work_items/work_item_spec.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Work item', :js, feature_category: :team_planning do
- let_it_be(:project) { create(:project, :public) }
- let_it_be(:user) { create(:user) }
- let_it_be(:work_item) { create(:work_item, project: project) }
-
- context 'for signed in user' do
- before do
- project.add_developer(user)
-
- sign_in(user)
-
- visit project_work_items_path(project, work_items_path: work_item.id)
- end
-
- it_behaves_like 'work items status'
- it_behaves_like 'work items assignees'
- it_behaves_like 'work items labels'
- it_behaves_like 'work items comments'
- it_behaves_like 'work items description'
- end
-
- context 'for signed in owner' do
- before do
- project.add_owner(user)
-
- sign_in(user)
-
- visit project_work_items_path(project, work_items_path: work_item.id)
- end
-
- it_behaves_like 'work items invite members'
- end
-end
diff --git a/spec/finders/abuse_reports_finder_spec.rb b/spec/finders/abuse_reports_finder_spec.rb
index 52620b3e421..ee93d042ca2 100644
--- a/spec/finders/abuse_reports_finder_spec.rb
+++ b/spec/finders/abuse_reports_finder_spec.rb
@@ -3,25 +3,142 @@
require 'spec_helper'
RSpec.describe AbuseReportsFinder, '#execute' do
+ let_it_be(:user1) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:abuse_report_1) { create(:abuse_report, id: 20, category: 'spam', user: user1) }
+ let_it_be(:abuse_report_2) do
+ create(:abuse_report, :closed, id: 30, category: 'phishing', user: user2, reporter: reporter)
+ end
+
let(:params) { {} }
- let!(:user1) { create(:user) }
- let!(:user2) { create(:user) }
- let!(:abuse_report_1) { create(:abuse_report, user: user1) }
- let!(:abuse_report_2) { create(:abuse_report, user: user2) }
subject { described_class.new(params).execute }
- context 'empty params' do
+ context 'when params is empty' do
it 'returns all abuse reports' do
expect(subject).to match_array([abuse_report_1, abuse_report_2])
end
end
- context 'params[:user_id] is present' do
+ context 'when params[:user_id] is present' do
let(:params) { { user_id: user2 } }
it 'returns abuse reports for the specified user' do
expect(subject).to match_array([abuse_report_2])
end
end
+
+ shared_examples 'returns filtered reports' do |filter_field|
+ it "returns abuse reports filtered by #{filter_field}_id" do
+ expect(subject).to match_array(filtered_reports)
+ end
+
+ context "when no user has username = params[:#{filter_field}]" do
+ before do
+ allow(User).to receive_message_chain(:by_username, :pick)
+ .with(params[filter_field])
+ .with(:id)
+ .and_return(nil)
+ end
+
+ it 'returns all abuse reports' do
+ expect(subject).to match_array([abuse_report_1, abuse_report_2])
+ end
+ end
+ end
+
+ context 'when params[:user] is present' do
+ it_behaves_like 'returns filtered reports', :user do
+ let(:params) { { user: user1.username } }
+ let(:filtered_reports) { [abuse_report_1] }
+ end
+ end
+
+ context 'when params[:reporter] is present' do
+ it_behaves_like 'returns filtered reports', :reporter do
+ let(:params) { { reporter: reporter.username } }
+ let(:filtered_reports) { [abuse_report_2] }
+ end
+ end
+
+ context 'when params[:status] is present' do
+ context 'when value is "open"' do
+ let(:params) { { status: 'open' } }
+
+ it 'returns only open abuse reports' do
+ expect(subject).to match_array([abuse_report_1])
+ end
+ end
+
+ context 'when value is "closed"' do
+ let(:params) { { status: 'closed' } }
+
+ it 'returns only closed abuse reports' do
+ expect(subject).to match_array([abuse_report_2])
+ end
+ end
+
+ context 'when value is not a valid status' do
+ let(:params) { { status: 'partial' } }
+
+ it 'defaults to returning open abuse reports' do
+ expect(subject).to match_array([abuse_report_1])
+ end
+ end
+
+ context 'when abuse_reports_list feature flag is disabled' do
+ before do
+ stub_feature_flags(abuse_reports_list: false)
+ end
+
+ it 'does not filter by status' do
+ expect(subject).to match_array([abuse_report_1, abuse_report_2])
+ end
+ end
+ end
+
+ context 'when params[:category] is present' do
+ let(:params) { { category: 'phishing' } }
+
+ it 'returns abuse reports with the specified category' do
+ expect(subject).to match_array([abuse_report_2])
+ end
+ end
+
+ describe 'sorting' do
+ let(:params) { { sort: 'created_at_asc' } }
+
+ it 'returns reports sorted by the specified sort attribute' do
+ expect(subject).to eq [abuse_report_1, abuse_report_2]
+ end
+
+ context 'when sort is not specified' do
+ let(:params) { {} }
+
+ it "returns reports sorted by #{described_class::DEFAULT_SORT}" do
+ expect(subject).to eq [abuse_report_2, abuse_report_1]
+ end
+ end
+
+ context 'when sort is not supported' do
+ let(:params) { { sort: 'superiority' } }
+
+ it "returns reports sorted by #{described_class::DEFAULT_SORT}" do
+ expect(subject).to eq [abuse_report_2, abuse_report_1]
+ end
+ end
+
+ context 'when abuse_reports_list feature flag is disabled' do
+ let_it_be(:abuse_report_3) { create(:abuse_report, id: 10) }
+
+ before do
+ stub_feature_flags(abuse_reports_list: false)
+ end
+
+ it 'returns reports sorted by id in descending order' do
+ expect(subject).to eq [abuse_report_2, abuse_report_1, abuse_report_3]
+ end
+ end
+ end
end
diff --git a/spec/finders/access_requests_finder_spec.rb b/spec/finders/access_requests_finder_spec.rb
index b82495d55fd..5d7f35581ee 100644
--- a/spec/finders/access_requests_finder_spec.rb
+++ b/spec/finders/access_requests_finder_spec.rb
@@ -96,13 +96,4 @@ RSpec.describe AccessRequestsFinder do
it_behaves_like '#execute'
it_behaves_like '#execute!'
-
- context 'when project_members_index_by_project_namespace feature flag is disabled' do
- before do
- stub_feature_flags(project_members_index_by_project_namespace: false)
- end
-
- it_behaves_like '#execute'
- it_behaves_like '#execute!'
- end
end
diff --git a/spec/finders/achievements/achievements_finder_spec.rb b/spec/finders/achievements/achievements_finder_spec.rb
new file mode 100644
index 00000000000..3ac18c27494
--- /dev/null
+++ b/spec/finders/achievements/achievements_finder_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Achievements::AchievementsFinder, feature_category: :user_profile do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:achievements) { create_list(:achievement, 3, namespace: group) }
+
+ let(:params) { {} }
+
+ describe '#execute' do
+ subject { described_class.new(group, params).execute }
+
+ it 'returns all achievements' do
+ expect(subject).to match_array(achievements)
+ end
+
+ context 'when ids param provided' do
+ let(:params) { { ids: [achievements[0].id, achievements[1].id] } }
+
+ it 'returns specified achievements' do
+ expect(subject).to contain_exactly(achievements[0], achievements[1])
+ end
+ end
+ end
+end
diff --git a/spec/finders/alert_management/alerts_finder_spec.rb b/spec/finders/alert_management/alerts_finder_spec.rb
index 7fcbc7b20a1..3c37d52d6c3 100644
--- a/spec/finders/alert_management/alerts_finder_spec.rb
+++ b/spec/finders/alert_management/alerts_finder_spec.rb
@@ -222,14 +222,15 @@ RSpec.describe AlertManagement::AlertsFinder, '#execute' do
context 'search query given' do
let_it_be(:alert) do
- create(:alert_management_alert,
- :with_fingerprint,
- project: project,
- title: 'Title',
- description: 'Desc',
- service: 'Service',
- monitoring_tool: 'Monitor'
- )
+ create(
+ :alert_management_alert,
+ :with_fingerprint,
+ project: project,
+ title: 'Title',
+ description: 'Desc',
+ service: 'Service',
+ monitoring_tool: 'Monitor'
+ )
end
context 'searching title' do
diff --git a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb
index 6e218db1254..35effc265c4 100644
--- a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb
+++ b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb
@@ -17,8 +17,10 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
let_it_be(:forked_project) { fork_project(parent_project, nil, repository: true, target_project: create(:project, :private, :repository)) }
let(:merge_request) do
- create(:merge_request, source_project: forked_project, source_branch: 'feature',
- target_project: parent_project, target_branch: 'master')
+ create(
+ :merge_request, source_project: forked_project, source_branch: 'feature',
+ target_project: parent_project, target_branch: 'master'
+ )
end
let!(:pipeline_in_parent) do
@@ -125,8 +127,10 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
let(:merge_request) { build(:merge_request, source_project: create(:project, :repository)) }
let!(:pipeline) do
- create(:ci_empty_pipeline, project: project,
- sha: merge_request.diff_head_sha, ref: merge_request.source_branch)
+ create(
+ :ci_empty_pipeline, project: project,
+ sha: merge_request.diff_head_sha, ref: merge_request.source_branch
+ )
end
it 'returns pipelines from diff_head_sha' do
@@ -139,8 +143,10 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
let(:target_ref) { 'master' }
let!(:branch_pipeline) do
- create(:ci_pipeline, source: :push, project: project,
- ref: source_ref, sha: merge_request.merge_request_diff.head_commit_sha)
+ create(
+ :ci_pipeline, source: :push, project: project,
+ ref: source_ref, sha: merge_request.merge_request_diff.head_commit_sha
+ )
end
let!(:tag_pipeline) do
@@ -148,13 +154,17 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
end
let!(:detached_merge_request_pipeline) do
- create(:ci_pipeline, source: :merge_request_event, project: project,
- ref: source_ref, sha: shas.second, merge_request: merge_request)
+ create(
+ :ci_pipeline, source: :merge_request_event, project: project,
+ ref: source_ref, sha: shas.second, merge_request: merge_request
+ )
end
let(:merge_request) do
- create(:merge_request, source_project: project, source_branch: source_ref,
- target_project: project, target_branch: target_ref)
+ create(
+ :merge_request, source_project: project, source_branch: source_ref,
+ target_project: project, target_branch: target_ref
+ )
end
let(:project) { create(:project, :repository) }
@@ -166,13 +176,14 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
context 'when there are a branch pipeline and a merge request pipeline' do
let!(:branch_pipeline_2) do
- create(:ci_pipeline, source: :push, project: project,
- ref: source_ref, sha: shas.first)
+ create(:ci_pipeline, source: :push, project: project, ref: source_ref, sha: shas.first)
end
let!(:detached_merge_request_pipeline_2) do
- create(:ci_pipeline, source: :merge_request_event, project: project,
- ref: source_ref, sha: shas.first, merge_request: merge_request)
+ create(
+ :ci_pipeline, source: :merge_request_event, project: project,
+ ref: source_ref, sha: shas.first, merge_request: merge_request
+ )
end
it 'returns merge request pipelines first' do
@@ -183,8 +194,7 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
context 'when there are multiple merge request pipelines from the same branch' do
let!(:branch_pipeline_2) do
- create(:ci_pipeline, source: :push, project: project,
- ref: source_ref, sha: shas.first)
+ create(:ci_pipeline, source: :push, project: project, ref: source_ref, sha: shas.first)
end
let!(:branch_pipeline_with_sha_not_belonging_to_merge_request) do
@@ -192,20 +202,26 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
end
let!(:detached_merge_request_pipeline_2) do
- create(:ci_pipeline, source: :merge_request_event, project: project,
- ref: source_ref, sha: shas.first, merge_request: merge_request_2)
+ create(
+ :ci_pipeline, source: :merge_request_event, project: project,
+ ref: source_ref, sha: shas.first, merge_request: merge_request_2
+ )
end
let(:merge_request_2) do
- create(:merge_request, source_project: project, source_branch: source_ref,
- target_project: project, target_branch: 'stable')
+ create(
+ :merge_request, source_project: project, source_branch: source_ref,
+ target_project: project, target_branch: 'stable'
+ )
end
before do
shas.each.with_index do |sha, index|
- create(:merge_request_diff_commit,
- merge_request_diff: merge_request_2.merge_request_diff,
- sha: sha, relative_order: index)
+ create(
+ :merge_request_diff_commit,
+ merge_request_diff: merge_request_2.merge_request_diff,
+ sha: sha, relative_order: index
+ )
end
end
@@ -219,8 +235,10 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
context 'when detached merge request pipeline is run on head ref of the merge request' do
let!(:detached_merge_request_pipeline) do
- create(:ci_pipeline, source: :merge_request_event, project: project,
- ref: merge_request.ref_path, sha: shas.second, merge_request: merge_request)
+ create(
+ :ci_pipeline, source: :merge_request_event, project: project,
+ ref: merge_request.ref_path, sha: shas.second, merge_request: merge_request
+ )
end
it 'sets the head ref of the merge request to the pipeline ref' do
diff --git a/spec/finders/clusters/agent_authorizations_finder_spec.rb b/spec/finders/clusters/agent_authorizations_finder_spec.rb
deleted file mode 100644
index f680792d6c4..00000000000
--- a/spec/finders/clusters/agent_authorizations_finder_spec.rb
+++ /dev/null
@@ -1,140 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::AgentAuthorizationsFinder do
- describe '#execute' do
- let_it_be(:top_level_group) { create(:group) }
- let_it_be(:subgroup1) { create(:group, parent: top_level_group) }
- let_it_be(:subgroup2) { create(:group, parent: subgroup1) }
- let_it_be(:bottom_level_group) { create(:group, parent: subgroup2) }
-
- let_it_be(:non_ancestor_group) { create(:group, parent: top_level_group) }
- let_it_be(:non_ancestor_project) { create(:project, namespace: non_ancestor_group) }
- let_it_be(:non_ancestor_agent) { create(:cluster_agent, project: non_ancestor_project) }
-
- let_it_be(:agent_configuration_project) { create(:project, namespace: subgroup1) }
- let_it_be(:requesting_project, reload: true) { create(:project, namespace: bottom_level_group) }
-
- let_it_be(:staging_agent) { create(:cluster_agent, project: agent_configuration_project) }
- let_it_be(:production_agent) { create(:cluster_agent, project: agent_configuration_project) }
-
- subject { described_class.new(requesting_project).execute }
-
- shared_examples_for 'access_as' do
- let(:config) { { access_as: { access_as => {} } } }
-
- context 'agent' do
- let(:access_as) { :agent }
-
- it { is_expected.to match_array [authorization] }
- end
-
- context 'impersonate' do
- let(:access_as) { :impersonate }
-
- it { is_expected.to be_empty }
- end
-
- context 'ci_user' do
- let(:access_as) { :ci_user }
-
- it { is_expected.to be_empty }
- end
-
- context 'ci_job' do
- let(:access_as) { :ci_job }
-
- it { is_expected.to be_empty }
- end
- end
-
- describe 'project authorizations' do
- context 'agent configuration project does not share a root namespace with the given project' do
- let(:unrelated_agent) { create(:cluster_agent) }
-
- before do
- create(:agent_project_authorization, agent: unrelated_agent, project: requesting_project)
- end
-
- it { is_expected.to be_empty }
- end
-
- context 'agent configuration project shares a root namespace, but does not belong to an ancestor of the given project' do
- let!(:project_authorization) { create(:agent_project_authorization, agent: non_ancestor_agent, project: requesting_project) }
-
- it { is_expected.to match_array([project_authorization]) }
- end
-
- context 'with project authorizations present' do
- let!(:authorization) { create(:agent_project_authorization, agent: production_agent, project: requesting_project) }
-
- it { is_expected.to match_array [authorization] }
- end
-
- context 'with overlapping authorizations' do
- let!(:agent) { create(:cluster_agent, project: requesting_project) }
- let!(:project_authorization) { create(:agent_project_authorization, agent: agent, project: requesting_project) }
- let!(:group_authorization) { create(:agent_group_authorization, agent: agent, group: bottom_level_group) }
-
- it { is_expected.to match_array [project_authorization] }
- end
-
- it_behaves_like 'access_as' do
- let!(:authorization) { create(:agent_project_authorization, agent: production_agent, project: requesting_project, config: config) }
- end
- end
-
- describe 'implicit authorizations' do
- let!(:associated_agent) { create(:cluster_agent, project: requesting_project) }
-
- it 'returns authorizations for agents directly associated with the project' do
- expect(subject.count).to eq(1)
-
- authorization = subject.first
- expect(authorization).to be_a(Clusters::Agents::ImplicitAuthorization)
- expect(authorization.agent).to eq(associated_agent)
- end
- end
-
- describe 'authorized groups' do
- context 'agent configuration project is outside the requesting project hierarchy' do
- let(:unrelated_agent) { create(:cluster_agent) }
-
- before do
- create(:agent_group_authorization, agent: unrelated_agent, group: top_level_group)
- end
-
- it { is_expected.to be_empty }
- end
-
- context 'multiple agents are authorized for the same group' do
- let!(:staging_auth) { create(:agent_group_authorization, agent: staging_agent, group: bottom_level_group) }
- let!(:production_auth) { create(:agent_group_authorization, agent: production_agent, group: bottom_level_group) }
-
- it 'returns authorizations for all agents' do
- expect(subject).to contain_exactly(staging_auth, production_auth)
- end
- end
-
- context 'a single agent is authorized to more than one matching group' do
- let!(:bottom_level_auth) { create(:agent_group_authorization, agent: production_agent, group: bottom_level_group) }
- let!(:top_level_auth) { create(:agent_group_authorization, agent: production_agent, group: top_level_group) }
-
- it 'picks the authorization for the closest group to the requesting project' do
- expect(subject).to contain_exactly(bottom_level_auth)
- end
- end
-
- context 'agent configuration project does not belong to an ancestor of the authorized group' do
- let!(:group_authorization) { create(:agent_group_authorization, agent: non_ancestor_agent, group: bottom_level_group) }
-
- it { is_expected.to match_array([group_authorization]) }
- end
-
- it_behaves_like 'access_as' do
- let!(:authorization) { create(:agent_group_authorization, agent: production_agent, group: top_level_group, config: config) }
- end
- end
- end
-end
diff --git a/spec/finders/clusters/agent_tokens_finder_spec.rb b/spec/finders/clusters/agent_tokens_finder_spec.rb
index 024e567a16e..1f5bfd58e85 100644
--- a/spec/finders/clusters/agent_tokens_finder_spec.rb
+++ b/spec/finders/clusters/agent_tokens_finder_spec.rb
@@ -44,6 +44,15 @@ RSpec.describe Clusters::AgentTokensFinder do
it { is_expected.to match_array(revoked_agent_tokens) }
end
+ context 'when filtering by an unrecognised status' do
+ subject(:execute) { described_class.new(agent, user, status: 'dummy').execute }
+
+ it 'raises an error' do
+ # 'dummy' is not a valid status as defined in the AgentToken status enum
+ expect { execute.count }.to raise_error(ActiveRecord::StatementInvalid)
+ end
+ end
+
context 'when user does not have permission' do
let(:user) { create(:user) }
diff --git a/spec/finders/clusters/agents/authorizations/ci_access/finder_spec.rb b/spec/finders/clusters/agents/authorizations/ci_access/finder_spec.rb
new file mode 100644
index 00000000000..0d010729d5c
--- /dev/null
+++ b/spec/finders/clusters/agents/authorizations/ci_access/finder_spec.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::CiAccess::Finder, feature_category: :deployment_management do
+ describe '#execute' do
+ let_it_be(:top_level_group) { create(:group) }
+ let_it_be(:subgroup1) { create(:group, parent: top_level_group) }
+ let_it_be(:subgroup2) { create(:group, parent: subgroup1) }
+ let_it_be(:bottom_level_group) { create(:group, parent: subgroup2) }
+
+ let_it_be(:non_ancestor_group) { create(:group, parent: top_level_group) }
+ let_it_be(:non_ancestor_project) { create(:project, namespace: non_ancestor_group) }
+ let_it_be(:non_ancestor_agent) { create(:cluster_agent, project: non_ancestor_project) }
+
+ let_it_be(:agent_configuration_project) { create(:project, namespace: subgroup1) }
+ let_it_be(:requesting_project, reload: true) { create(:project, namespace: bottom_level_group) }
+
+ let_it_be(:staging_agent) { create(:cluster_agent, project: agent_configuration_project) }
+ let_it_be(:production_agent) { create(:cluster_agent, project: agent_configuration_project) }
+
+ subject { described_class.new(requesting_project).execute }
+
+ shared_examples_for 'access_as' do
+ let(:config) { { access_as: { access_as => {} } } }
+
+ context 'agent' do
+ let(:access_as) { :agent }
+
+ it { is_expected.to match_array [authorization] }
+ end
+
+ context 'impersonate' do
+ let(:access_as) { :impersonate }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'ci_user' do
+ let(:access_as) { :ci_user }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'ci_job' do
+ let(:access_as) { :ci_job }
+
+ it { is_expected.to be_empty }
+ end
+ end
+
+ describe 'project authorizations' do
+ context 'agent configuration project does not share a root namespace with the given project' do
+ let(:unrelated_agent) { create(:cluster_agent) }
+
+ before do
+ create(:agent_ci_access_project_authorization, agent: unrelated_agent, project: requesting_project)
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'agent configuration project shares a root namespace, but does not belong to an ancestor of the given project' do
+ let!(:project_authorization) { create(:agent_ci_access_project_authorization, agent: non_ancestor_agent, project: requesting_project) }
+
+ it { is_expected.to match_array([project_authorization]) }
+ end
+
+ context 'with project authorizations present' do
+ let!(:authorization) { create(:agent_ci_access_project_authorization, agent: production_agent, project: requesting_project) }
+
+ it { is_expected.to match_array [authorization] }
+ end
+
+ context 'with overlapping authorizations' do
+ let!(:agent) { create(:cluster_agent, project: requesting_project) }
+ let!(:project_authorization) { create(:agent_ci_access_project_authorization, agent: agent, project: requesting_project) }
+ let!(:group_authorization) { create(:agent_ci_access_group_authorization, agent: agent, group: bottom_level_group) }
+
+ it { is_expected.to match_array [project_authorization] }
+ end
+
+ it_behaves_like 'access_as' do
+ let!(:authorization) { create(:agent_ci_access_project_authorization, agent: production_agent, project: requesting_project, config: config) }
+ end
+ end
+
+ describe 'implicit authorizations' do
+ let!(:associated_agent) { create(:cluster_agent, project: requesting_project) }
+
+ it 'returns authorizations for agents directly associated with the project' do
+ expect(subject.count).to eq(1)
+
+ authorization = subject.first
+ expect(authorization).to be_a(Clusters::Agents::Authorizations::CiAccess::ImplicitAuthorization)
+ expect(authorization.agent).to eq(associated_agent)
+ end
+ end
+
+ describe 'authorized groups' do
+ context 'agent configuration project is outside the requesting project hierarchy' do
+ let(:unrelated_agent) { create(:cluster_agent) }
+
+ before do
+ create(:agent_ci_access_group_authorization, agent: unrelated_agent, group: top_level_group)
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'multiple agents are authorized for the same group' do
+ let!(:staging_auth) { create(:agent_ci_access_group_authorization, agent: staging_agent, group: bottom_level_group) }
+ let!(:production_auth) { create(:agent_ci_access_group_authorization, agent: production_agent, group: bottom_level_group) }
+
+ it 'returns authorizations for all agents' do
+ expect(subject).to contain_exactly(staging_auth, production_auth)
+ end
+ end
+
+ context 'a single agent is authorized to more than one matching group' do
+ let!(:bottom_level_auth) { create(:agent_ci_access_group_authorization, agent: production_agent, group: bottom_level_group) }
+ let!(:top_level_auth) { create(:agent_ci_access_group_authorization, agent: production_agent, group: top_level_group) }
+
+ it 'picks the authorization for the closest group to the requesting project' do
+ expect(subject).to contain_exactly(bottom_level_auth)
+ end
+ end
+
+ context 'agent configuration project does not belong to an ancestor of the authorized group' do
+ let!(:group_authorization) { create(:agent_ci_access_group_authorization, agent: non_ancestor_agent, group: bottom_level_group) }
+
+ it { is_expected.to match_array([group_authorization]) }
+ end
+
+ it_behaves_like 'access_as' do
+ let!(:authorization) { create(:agent_ci_access_group_authorization, agent: production_agent, group: top_level_group, config: config) }
+ end
+ end
+ end
+end
diff --git a/spec/finders/clusters/agents/authorizations/user_access/finder_spec.rb b/spec/finders/clusters/agents/authorizations/user_access/finder_spec.rb
new file mode 100644
index 00000000000..7e6897a723d
--- /dev/null
+++ b/spec/finders/clusters/agents/authorizations/user_access/finder_spec.rb
@@ -0,0 +1,198 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::UserAccess::Finder, feature_category: :deployment_management do
+ describe '#execute' do
+ let_it_be(:organization) { create(:group) }
+ let_it_be(:agent_configuration_project) { create(:project, namespace: organization) }
+ let_it_be(:agent) { create(:cluster_agent, project: agent_configuration_project) }
+ let_it_be(:deployment_project) { create(:project, namespace: organization) }
+ let_it_be(:deployment_maintainer) { create(:user).tap { |u| deployment_project.add_maintainer(u) } }
+ let_it_be(:deployment_developer) { create(:user).tap { |u| deployment_project.add_developer(u) } }
+ let_it_be(:deployment_guest) { create(:user).tap { |u| deployment_project.add_guest(u) } }
+
+ let(:user) { deployment_developer }
+ let(:params) { { agent: nil } }
+
+ subject { described_class.new(user, **params).execute }
+
+ it 'returns nothing' do
+ is_expected.to be_empty
+ end
+
+ context 'with project authorizations' do
+ let!(:authorization_1) do
+ create(:agent_user_access_project_authorization, agent: agent, project: deployment_project)
+ end
+
+ it 'returns authorization' do
+ is_expected.to contain_exactly(authorization_1)
+
+ expect(subject.first.access_level).to eq(Gitlab::Access::DEVELOPER)
+ end
+
+ context 'when user is maintainer' do
+ let(:user) { deployment_maintainer }
+
+ it 'returns authorization' do
+ is_expected.to contain_exactly(authorization_1)
+
+ expect(subject.first.access_level).to eq(Gitlab::Access::MAINTAINER)
+ end
+ end
+
+ context 'when user is guest' do
+ let(:user) { deployment_guest }
+
+ it 'does not return authorization' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'with multiple authorizations' do
+ let_it_be(:agent_2) { create(:cluster_agent, project: agent_configuration_project) }
+ let_it_be(:agent_3) { create(:cluster_agent, project: agent_configuration_project) }
+ let_it_be(:deployment_project_2) { create(:project, namespace: organization) }
+
+ let_it_be(:authorization_2) do
+ create(:agent_user_access_project_authorization, agent: agent_2, project: deployment_project)
+ end
+
+ let_it_be(:authorization_3) do
+ create(:agent_user_access_project_authorization, agent: agent_3, project: deployment_project_2)
+ end
+
+ before_all do
+ deployment_project_2.add_developer(deployment_developer)
+ end
+
+ it 'returns authorizations' do
+ is_expected.to contain_exactly(authorization_1, authorization_2, authorization_3)
+ end
+
+ context 'with specific agent' do
+ let(:params) { { agent: agent_2 } }
+
+ it 'returns authorization' do
+ is_expected.to contain_exactly(authorization_2)
+ end
+ end
+
+ context 'with specific project' do
+ let(:params) { { project: deployment_project_2 } }
+
+ it 'returns authorization' do
+ is_expected.to contain_exactly(authorization_3)
+ end
+ end
+
+ context 'with limit' do
+ let(:params) { { limit: 1 } }
+
+ it 'returns authorization' do
+ expect(subject.count).to eq(1)
+ end
+ end
+ end
+ end
+
+ context 'with group authorizations' do
+ let!(:authorization_1) do
+ create(:agent_user_access_group_authorization, agent: agent, group: organization)
+ end
+
+ before_all do
+ organization.add_maintainer(deployment_maintainer)
+ organization.add_developer(deployment_developer)
+ organization.add_guest(deployment_guest)
+ end
+
+ it 'returns authorization' do
+ is_expected.to contain_exactly(authorization_1)
+
+ expect(subject.first.access_level).to eq(Gitlab::Access::DEVELOPER)
+ end
+
+ context 'when user is maintainer' do
+ let(:user) { deployment_maintainer }
+
+ it 'returns authorization' do
+ is_expected.to contain_exactly(authorization_1)
+
+ expect(subject.first.access_level).to eq(Gitlab::Access::MAINTAINER)
+ end
+ end
+
+ context 'when user is guest' do
+ let(:user) { deployment_guest }
+
+ it 'does not return authorization' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'with multiple authorizations' do
+ let_it_be(:agent_2) { create(:cluster_agent, project: agent_configuration_project) }
+
+ let_it_be(:authorization_2) do
+ create(:agent_user_access_group_authorization, agent: agent_2, group: organization)
+ end
+
+ let_it_be(:authorization_3) { create(:agent_user_access_group_authorization) }
+
+ it 'returns authorizations' do
+ is_expected.to contain_exactly(authorization_1, authorization_2)
+ end
+
+ context 'with specific agent' do
+ let(:params) { { agent: agent_2 } }
+
+ it 'returns authorization' do
+ is_expected.to eq([authorization_2])
+ end
+ end
+
+ context 'with specific project' do
+ let(:params) { { project: deployment_project } }
+
+ it 'returns authorization' do
+ is_expected.to contain_exactly(authorization_1, authorization_2)
+ end
+ end
+
+ context 'with limit' do
+ let(:params) { { limit: 1 } }
+
+ it 'returns authorization' do
+ expect(subject.count).to eq(1)
+ end
+ end
+ end
+
+ context 'when sub-group is authorized' do
+ let_it_be(:subgroup_1) { create(:group, parent: organization) }
+ let_it_be(:subgroup_2) { create(:group, parent: organization) }
+ let_it_be(:deployment_project_1) { create(:project, group: subgroup_1) }
+ let_it_be(:deployment_project_2) { create(:project, group: subgroup_2) }
+
+ let!(:authorization_1) { create(:agent_user_access_group_authorization, agent: agent, group: subgroup_1) }
+ let!(:authorization_2) { create(:agent_user_access_group_authorization, agent: agent, group: subgroup_2) }
+
+ it 'returns authorization' do
+ is_expected.to contain_exactly(authorization_1, authorization_2)
+
+ expect(subject.first.access_level).to eq(Gitlab::Access::DEVELOPER)
+ end
+
+ context 'with specific deployment project' do
+ let(:params) { { project: deployment_project_1 } }
+
+ it 'returns only the authorization connected to the parent group' do
+ is_expected.to contain_exactly(authorization_1)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/concerns/finder_with_group_hierarchy_spec.rb b/spec/finders/concerns/finder_with_group_hierarchy_spec.rb
index c96e35372d6..27f5192176d 100644
--- a/spec/finders/concerns/finder_with_group_hierarchy_spec.rb
+++ b/spec/finders/concerns/finder_with_group_hierarchy_spec.rb
@@ -129,80 +129,4 @@ RSpec.describe FinderWithGroupHierarchy do
expect { run_query(private_group) }.not_to exceed_query_limit(control)
end
end
-
- context 'when preload_max_access_levels_for_labels_finder is disabled' do
- # All test cases were copied from above, these will be removed once the FF is removed.
-
- before do
- stub_feature_flags(preload_max_access_levels_for_labels_finder: false)
- end
-
- context 'when specifying group' do
- it 'returns only the group by default' do
- finder = finder_class.new(user, group: group)
-
- expect(finder.execute).to match_array([group.id])
- end
- end
-
- context 'when specifying group_id' do
- it 'returns only the group by default' do
- finder = finder_class.new(user, group_id: group.id)
-
- expect(finder.execute).to match_array([group.id])
- end
- end
-
- context 'when including items from group ancestors' do
- before do
- private_subgroup.add_developer(user)
- end
-
- it 'returns group and its ancestors' do
- private_group.add_developer(user)
-
- finder = finder_class.new(user, group: private_subgroup, include_ancestor_groups: true)
-
- expect(finder.execute).to match_array([private_group.id, private_subgroup.id])
- end
-
- it 'ignores groups which user can not read' do
- finder = finder_class.new(user, group: private_subgroup, include_ancestor_groups: true)
-
- expect(finder.execute).to match_array([private_subgroup.id])
- end
-
- it 'returns them all when skip_authorization is true' do
- finder = finder_class.new(user, group: private_subgroup, include_ancestor_groups: true)
-
- expect(finder.execute(skip_authorization: true)).to match_array([private_group.id, private_subgroup.id])
- end
- end
-
- context 'when including items from group descendants' do
- before do
- private_subgroup.add_developer(user)
- end
-
- it 'returns items from group and its descendants' do
- private_group.add_developer(user)
-
- finder = finder_class.new(user, group: private_group, include_descendant_groups: true)
-
- expect(finder.execute).to match_array([private_group.id, private_subgroup.id])
- end
-
- it 'ignores items from groups which user can not read' do
- finder = finder_class.new(user, group: private_group, include_descendant_groups: true)
-
- expect(finder.execute).to match_array([private_subgroup.id])
- end
-
- it 'returns them all when skip_authorization is true' do
- finder = finder_class.new(user, group: private_group, include_descendant_groups: true)
-
- expect(finder.execute(skip_authorization: true)).to match_array([private_group.id, private_subgroup.id])
- end
- end
- end
end
diff --git a/spec/finders/context_commits_finder_spec.rb b/spec/finders/context_commits_finder_spec.rb
index c22675bc67d..3de1d29b695 100644
--- a/spec/finders/context_commits_finder_spec.rb
+++ b/spec/finders/context_commits_finder_spec.rb
@@ -26,27 +26,30 @@ RSpec.describe ContextCommitsFinder do
end
it 'returns commits based in author filter' do
- params = { search: 'test text', author: 'Job van der Voort' }
+ params = { author: 'Job van der Voort' }
commits = described_class.new(project, merge_request, params).execute
expect(commits.length).to eq(1)
expect(commits[0].id).to eq('b83d6e391c22777fca1ed3012fce84f633d7fed0')
end
- it 'returns commits based in before filter' do
- params = { search: 'test text', committed_before: 1474828200 }
+ it 'returns commits based in committed before and after filter' do
+ params = { committed_before: 1471631400, committed_after: 1471458600 } # August 18, 2016 - # August 20, 2016
commits = described_class.new(project, merge_request, params).execute
- expect(commits.length).to eq(1)
- expect(commits[0].id).to eq('498214de67004b1da3d820901307bed2a68a8ef6')
+ expect(commits.length).to eq(2)
+ expect(commits[0].id).to eq('1b12f15a11fc6e62177bef08f47bc7b5ce50b141')
+ expect(commits[1].id).to eq('38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e')
end
- it 'returns commits based in after filter' do
- params = { search: 'test text', committed_after: 1474828200 }
- commits = described_class.new(project, merge_request, params).execute
+ it 'returns commits from target branch if no filter is applied' do
+ expect(project.repository).to receive(:commits).with(merge_request.target_branch, anything).and_call_original
- expect(commits.length).to eq(1)
+ commits = described_class.new(project, merge_request).execute
+
+ expect(commits.length).to eq(37)
expect(commits[0].id).to eq('b83d6e391c22777fca1ed3012fce84f633d7fed0')
+ expect(commits[1].id).to eq('498214de67004b1da3d820901307bed2a68a8ef6')
end
end
end
diff --git a/spec/finders/crm/contacts_finder_spec.rb b/spec/finders/crm/contacts_finder_spec.rb
index 43dcced53fd..d0339ce2b18 100644
--- a/spec/finders/crm/contacts_finder_spec.rb
+++ b/spec/finders/crm/contacts_finder_spec.rb
@@ -148,7 +148,7 @@ RSpec.describe Crm::ContactsFinder do
:contact,
group: search_test_group,
email: "a@test.com",
- organization: create(:organization, name: "Company Z")
+ organization: create(:crm_organization, name: "Company Z")
)
end
@@ -157,7 +157,7 @@ RSpec.describe Crm::ContactsFinder do
:contact,
group: search_test_group,
email: "b@test.com",
- organization: create(:organization, name: "Company A")
+ organization: create(:crm_organization, name: "Company A")
)
end
diff --git a/spec/finders/crm/organizations_finder_spec.rb b/spec/finders/crm/organizations_finder_spec.rb
index c89ac3b1cb5..bc174f927a7 100644
--- a/spec/finders/crm/organizations_finder_spec.rb
+++ b/spec/finders/crm/organizations_finder_spec.rb
@@ -12,8 +12,8 @@ RSpec.describe Crm::OrganizationsFinder do
let_it_be(:root_group) { create(:group, :crm_enabled) }
let_it_be(:group) { create(:group, parent: root_group) }
- let_it_be(:organization_1) { create(:organization, group: root_group) }
- let_it_be(:organization_2) { create(:organization, group: root_group) }
+ let_it_be(:crm_organization_1) { create(:crm_organization, group: root_group) }
+ let_it_be(:crm_organization_2) { create(:crm_organization, group: root_group) }
context 'when user does not have permissions to see organizations in the group' do
it 'returns an empty array' do
@@ -28,7 +28,7 @@ RSpec.describe Crm::OrganizationsFinder do
context 'when feature flag is enabled' do
it 'returns all group organizations' do
- expect(subject).to match_array([organization_1, organization_2])
+ expect(subject).to match_array([crm_organization_1, crm_organization_2])
end
end
end
@@ -46,7 +46,7 @@ RSpec.describe Crm::OrganizationsFinder do
context 'when customer relations feature is disabled for the group' do
let_it_be(:group) { create(:group) }
- let_it_be(:organization) { create(:organization, group: group) }
+ let_it_be(:crm_organization) { create(:crm_organization, group: group) }
before do
group.add_developer(user)
@@ -62,7 +62,7 @@ RSpec.describe Crm::OrganizationsFinder do
let_it_be(:search_test_a) do
create(
- :organization,
+ :crm_organization,
group: search_test_group,
name: "DEF",
description: "ghi_st",
@@ -72,7 +72,7 @@ RSpec.describe Crm::OrganizationsFinder do
let_it_be(:search_test_b) do
create(
- :organization,
+ :crm_organization,
group: search_test_group,
name: "ABC_st",
description: "JKL",
@@ -134,7 +134,7 @@ RSpec.describe Crm::OrganizationsFinder do
let_it_be(:sort_test_a) do
create(
- :organization,
+ :crm_organization,
group: group,
name: "ABC",
description: "1"
@@ -143,7 +143,7 @@ RSpec.describe Crm::OrganizationsFinder do
let_it_be(:sort_test_b) do
create(
- :organization,
+ :crm_organization,
group: group,
name: "DEF",
description: "2",
@@ -153,7 +153,7 @@ RSpec.describe Crm::OrganizationsFinder do
let_it_be(:sort_test_c) do
create(
- :organization,
+ :crm_organization,
group: group,
name: "GHI",
default_rate: 20
@@ -186,8 +186,8 @@ RSpec.describe Crm::OrganizationsFinder do
describe '.counts_by_state' do
let_it_be(:group) { create(:group, :crm_enabled) }
- let_it_be(:active_organizations) { create_list(:organization, 3, group: group, state: :active) }
- let_it_be(:inactive_organizations) { create_list(:organization, 2, group: group, state: :inactive) }
+ let_it_be(:active_crm_organizations) { create_list(:crm_organization, 3, group: group, state: :active) }
+ let_it_be(:inactive_crm_organizations) { create_list(:crm_organization, 2, group: group, state: :inactive) }
before do
group.add_developer(user)
diff --git a/spec/finders/data_transfer/group_data_transfer_finder_spec.rb b/spec/finders/data_transfer/group_data_transfer_finder_spec.rb
new file mode 100644
index 00000000000..0c54e6504e8
--- /dev/null
+++ b/spec/finders/data_transfer/group_data_transfer_finder_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DataTransfer::GroupDataTransferFinder, feature_category: :source_code_management do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:namespace_1) { create(:group) }
+ let_it_be(:project_1) { create(:project, group: namespace_1) }
+ let_it_be(:project_2) { create(:project, group: namespace_1) }
+ let(:from_date) { Date.new(2022, 2, 1) }
+ let(:to_date) { Date.new(2023, 1, 1) }
+
+ before_all do
+ namespace_1.add_owner(user)
+ end
+
+ describe '#execute' do
+ let(:subject) { described_class.new(group: namespace_1, from: from_date, to: to_date, user: user) }
+
+ before do
+ create(:project_data_transfer, project: project_1, date: '2022-01-01')
+ create(:project_data_transfer, project: project_1, date: '2022-02-01')
+ create(:project_data_transfer, project: project_2, date: '2022-02-01')
+ end
+
+ it 'returns the correct number of egress' do
+ expect(subject.execute.to_a.size).to eq(1)
+ end
+
+ it 'returns the correct values grouped by date' do
+ first_result = subject.execute.first
+ expect(first_result.attributes).to include(
+ {
+ 'namespace_id' => namespace_1.id,
+ 'date' => from_date,
+ 'repository_egress' => 2,
+ 'artifacts_egress' => 4,
+ 'packages_egress' => 6,
+ 'registry_egress' => 8,
+ 'total_egress' => 20
+ }
+ )
+ end
+
+ context 'when there are no results for specified namespace' do
+ let_it_be(:namespace_2) { create(:group) }
+ let(:subject) { described_class.new(group: namespace_2, from: from_date, to: to_date, user: user) }
+
+ it 'returns nothing' do
+ expect(subject.execute).to be_empty
+ end
+ end
+
+ context 'when there are no results for specified dates' do
+ let(:from_date) { Date.new(2021, 1, 1) }
+ let(:to_date) { Date.new(2021, 1, 1) }
+
+ it 'returns nothing' do
+ expect(subject.execute).to be_empty
+ end
+ end
+
+ context 'when dates are not provided' do
+ let(:from_date) { nil }
+ let(:to_date) { nil }
+
+ it 'return all values for a namespace', :aggregate_failures do
+ results = subject.execute
+ expect(results.to_a.size).to eq(2)
+ results.each do |result|
+ expect(result.namespace).to eq(namespace_1)
+ end
+ end
+ end
+
+ context 'when user does not have permissions' do
+ let(:user) { build(:user) }
+
+ it 'returns nothing' do
+ expect(subject.execute).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/finders/data_transfer/mocked_transfer_finder_spec.rb b/spec/finders/data_transfer/mocked_transfer_finder_spec.rb
new file mode 100644
index 00000000000..f60bc98f587
--- /dev/null
+++ b/spec/finders/data_transfer/mocked_transfer_finder_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DataTransfer::MockedTransferFinder, feature_category: :source_code_management do
+ describe '#execute' do
+ subject(:execute) { described_class.new.execute }
+
+ it 'returns mock data' do
+ expect(execute.first).to include(
+ date: '2023-01-01',
+ repository_egress: be_a(Integer),
+ artifacts_egress: be_a(Integer),
+ packages_egress: be_a(Integer),
+ registry_egress: be_a(Integer),
+ total_egress: be_a(Integer)
+ )
+
+ expect(execute.size).to eq(12)
+ end
+ end
+end
diff --git a/spec/finders/data_transfer/project_data_transfer_finder_spec.rb b/spec/finders/data_transfer/project_data_transfer_finder_spec.rb
new file mode 100644
index 00000000000..1d5cd0f3339
--- /dev/null
+++ b/spec/finders/data_transfer/project_data_transfer_finder_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DataTransfer::ProjectDataTransferFinder, feature_category: :source_code_management do
+ let_it_be(:project_1) { create(:project) }
+ let_it_be(:project_2) { create(:project) }
+ let_it_be(:user) { project_1.first_owner }
+ let(:from_date) { Date.new(2022, 2, 1) }
+ let(:to_date) { Date.new(2023, 1, 1) }
+
+ describe '#execute' do
+ let(:subject) { described_class.new(project: project_1, from: from_date, to: to_date, user: user) }
+
+ before do
+ create(:project_data_transfer, project: project_1, date: '2022-01-01')
+ create(:project_data_transfer, project: project_1, date: '2022-02-01')
+ create(:project_data_transfer, project: project_1, date: '2022-03-01')
+ create(:project_data_transfer, project: project_2, date: '2022-01-01')
+ end
+
+ it 'returns the correct number of egress' do
+ expect(subject.execute.size).to eq(2)
+ end
+
+ it 'returns the correct values' do
+ first_result = subject.execute.first
+ expect(first_result.attributes).to include(
+ {
+ 'project_id' => project_1.id,
+ 'date' => from_date,
+ 'repository_egress' => 1,
+ 'artifacts_egress' => 2,
+ 'packages_egress' => 3,
+ 'registry_egress' => 4,
+ 'total_egress' => 10
+ }
+ )
+ end
+
+ context 'when there are no results for specified dates' do
+ let(:from_date) { Date.new(2021, 1, 1) }
+ let(:to_date) { Date.new(2021, 1, 1) }
+
+ it 'returns nothing' do
+ expect(subject.execute).to be_empty
+ end
+ end
+
+ context 'when there are no results for specified project' do
+ let_it_be(:project_3) { create(:project, :repository) }
+ let(:subject) { described_class.new(project: project_3, from: from_date, to: to_date, user: user) }
+
+ it 'returns nothing' do
+ expect(subject.execute).to be_empty
+ end
+ end
+
+ context 'when dates are not provided' do
+ let(:from_date) { nil }
+ let(:to_date) { nil }
+
+ it 'return all values for a project', :aggregate_failures do
+ results = subject.execute
+ expect(results.size).to eq(3)
+ results.each do |result|
+ expect(result.project).to eq(project_1)
+ end
+ end
+ end
+
+ context 'when user does not have permissions' do
+ let(:user) { build(:user) }
+
+ it 'returns nothing' do
+ expect(subject.execute).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/finders/deployments_finder_spec.rb b/spec/finders/deployments_finder_spec.rb
index efb739c3d2f..86b6070a368 100644
--- a/spec/finders/deployments_finder_spec.rb
+++ b/spec/finders/deployments_finder_spec.rb
@@ -16,16 +16,6 @@ RSpec.describe DeploymentsFinder do
end
end
- context 'when updated_at filter and id sorting' do
- let(:params) { { updated_before: 1.day.ago, order_by: :id } }
-
- it 'raises an error' do
- expect { subject }.to raise_error(
- described_class::InefficientQueryError,
- '`updated_at` filter and `updated_at` sorting must be paired')
- end
- end
-
context 'when finished_at filter and id sorting' do
let(:params) { { finished_before: 1.day.ago, order_by: :id } }
@@ -178,8 +168,8 @@ RSpec.describe DeploymentsFinder do
'iid' | 'desc' | [:deployment_3, :deployment_2, :deployment_1]
'ref' | 'asc' | [:deployment_2, :deployment_1, :deployment_3]
'ref' | 'desc' | [:deployment_3, :deployment_1, :deployment_2]
- 'updated_at' | 'asc' | described_class::InefficientQueryError
- 'updated_at' | 'desc' | described_class::InefficientQueryError
+ 'updated_at' | 'asc' | [:deployment_2, :deployment_3, :deployment_1]
+ 'updated_at' | 'desc' | [:deployment_1, :deployment_3, :deployment_2]
'finished_at' | 'asc' | described_class::InefficientQueryError
'finished_at' | 'desc' | described_class::InefficientQueryError
'invalid' | 'asc' | [:deployment_1, :deployment_2, :deployment_3]
@@ -260,15 +250,52 @@ RSpec.describe DeploymentsFinder do
end
describe 'enforce sorting to `updated_at` sorting' do
- let(:params) { { **base_params, updated_before: 1.day.ago, order_by: 'id', sort: 'asc', raise_for_inefficient_updated_at_query: false } }
+ let(:params) { { **base_params, updated_before: 1.day.ago, order_by: 'id', sort: 'asc' } }
- it 'sorts by only one column' do
- expect(subject.order_values.size).to eq(2)
+ context 'when the deployments_raise_updated_at_inefficient_error FF is disabled' do
+ before do
+ stub_feature_flags(deployments_raise_updated_at_inefficient_error: false)
+ end
+
+ it 'sorts by only one column' do
+ expect(subject.order_values.size).to eq(2)
+ end
+
+ it 'sorts by `updated_at`' do
+ expect(subject.order_values.first.to_sql).to eq(Deployment.arel_table[:updated_at].asc.to_sql)
+ expect(subject.order_values.second.to_sql).to eq(Deployment.arel_table[:id].asc.to_sql)
+ end
end
- it 'sorts by `updated_at`' do
- expect(subject.order_values.first.to_sql).to eq(Deployment.arel_table[:updated_at].asc.to_sql)
- expect(subject.order_values.second.to_sql).to eq(Deployment.arel_table[:id].asc.to_sql)
+ context 'when the deployments_raise_updated_at_inefficient_error FF is enabled' do
+ before do
+ stub_feature_flags(deployments_raise_updated_at_inefficient_error: true)
+ end
+
+ context 'when the flag is overridden' do
+ before do
+ stub_feature_flags(deployments_raise_updated_at_inefficient_error_override: true)
+ end
+
+ it 'sorts by only one column' do
+ expect(subject.order_values.size).to eq(2)
+ end
+
+ it 'sorts by `updated_at`' do
+ expect(subject.order_values.first.to_sql).to eq(Deployment.arel_table[:updated_at].asc.to_sql)
+ expect(subject.order_values.second.to_sql).to eq(Deployment.arel_table[:id].asc.to_sql)
+ end
+ end
+
+ context 'when the flag is not overridden' do
+ before do
+ stub_feature_flags(deployments_raise_updated_at_inefficient_error_override: false)
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(DeploymentsFinder::InefficientQueryError)
+ end
+ end
end
end
@@ -331,9 +358,11 @@ RSpec.describe DeploymentsFinder do
with_them do
it 'returns the deployments unordered' do
- expect(subject.to_a).to contain_exactly(group_project_1_deployment,
- group_project_2_deployment,
- subgroup_project_1_deployment)
+ expect(subject.to_a).to contain_exactly(
+ group_project_1_deployment,
+ group_project_2_deployment,
+ subgroup_project_1_deployment
+ )
end
end
end
diff --git a/spec/finders/fork_targets_finder_spec.rb b/spec/finders/fork_targets_finder_spec.rb
index 41651513f18..746c48a8fab 100644
--- a/spec/finders/fork_targets_finder_spec.rb
+++ b/spec/finders/fork_targets_finder_spec.rb
@@ -29,17 +29,38 @@ RSpec.describe ForkTargetsFinder do
create(:group).tap { |g| g.add_guest(user) }
end
+ let_it_be(:shared_group_to_group_with_owner_access) do
+ create(:group)
+ end
+
before do
project.namespace.add_owner(user)
+ create(:group_group_link, :maintainer,
+ shared_with_group: owned_group,
+ shared_group: shared_group_to_group_with_owner_access
+ )
end
shared_examples 'returns namespaces and groups' do
it 'returns all user manageable namespaces' do
- expect(finder.execute).to match_array([user.namespace, maintained_group, owned_group, project.namespace, developer_group])
+ expect(finder.execute).to match_array([
+ user.namespace,
+ maintained_group,
+ owned_group,
+ project.namespace,
+ developer_group,
+ shared_group_to_group_with_owner_access
+ ])
end
it 'returns only groups when only_groups option is passed' do
- expect(finder.execute(only_groups: true)).to match_array([maintained_group, owned_group, project.namespace, developer_group])
+ expect(finder.execute(only_groups: true)).to match_array([
+ maintained_group,
+ owned_group,
+ project.namespace,
+ developer_group,
+ shared_group_to_group_with_owner_access
+ ])
end
it 'returns groups relation when only_groups option is passed' do
diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb
index 2a9e887450c..9d528355f54 100644
--- a/spec/finders/group_descendants_finder_spec.rb
+++ b/spec/finders/group_descendants_finder_spec.rb
@@ -130,8 +130,10 @@ RSpec.describe GroupDescendantsFinder do
it 'does not include projects shared with the group' do
project = create(:project, namespace: group)
other_project = create(:project)
- other_project.project_group_links.create!(group: group,
- group_access: Gitlab::Access::MAINTAINER)
+ other_project.project_group_links.create!(
+ group: group,
+ group_access: Gitlab::Access::MAINTAINER
+ )
expect(finder.execute).to contain_exactly(project)
end
@@ -140,9 +142,11 @@ RSpec.describe GroupDescendantsFinder do
context 'with shared groups' do
let_it_be(:other_group) { create(:group) }
let_it_be(:shared_group_link) do
- create(:group_group_link,
- shared_group: group,
- shared_with_group: other_group)
+ create(
+ :group_group_link,
+ shared_group: group,
+ shared_with_group: other_group
+ )
end
context 'without common ancestor' do
@@ -230,9 +234,11 @@ RSpec.describe GroupDescendantsFinder do
other_user = create(:user)
other_subgroup.add_developer(other_user)
- finder = described_class.new(current_user: other_user,
- parent_group: group,
- params: params)
+ finder = described_class.new(
+ current_user: other_user,
+ parent_group: group,
+ params: params
+ )
expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup)
end
diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb
index 4a5eb389906..4fc49289fa4 100644
--- a/spec/finders/group_members_finder_spec.rb
+++ b/spec/finders/group_members_finder_spec.rb
@@ -56,44 +56,67 @@ RSpec.describe GroupMembersFinder, '#execute', feature_category: :subgroups do
}
end
- it 'raises an error if a non-supported relation type is used' do
- expect do
- described_class.new(group).execute(include_relations: [:direct, :invalid_relation_type])
- end.to raise_error(ArgumentError, "invalid_relation_type is not a valid relation type. Valid relation types are direct, inherited, descendants, shared_from_groups.")
- end
-
- using RSpec::Parameterized::TableSyntax
-
- where(:subject_relations, :subject_group, :expected_members) do
- [] | :group | []
- GroupMembersFinder::DEFAULT_RELATIONS | :group | [:user1_group, :user2_group, :user3_group, :user4_group]
- [:direct] | :group | [:user1_group, :user2_group, :user3_group, :user4_group]
- [:inherited] | :group | []
- [:descendants] | :group | [:user1_sub_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group]
- [:shared_from_groups] | :group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group]
- [:direct, :inherited, :descendants, :shared_from_groups] | :group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_public_shared_group]
- [] | :sub_group | []
- GroupMembersFinder::DEFAULT_RELATIONS | :sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group]
- [:direct] | :sub_group | [:user1_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group]
- [:inherited] | :sub_group | [:user1_group, :user2_group, :user3_group, :user4_group]
- [:descendants] | :sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group]
- [:shared_from_groups] | :sub_group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group]
- [:direct, :inherited, :descendants, :shared_from_groups] | :sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_public_shared_group]
- [] | :sub_sub_group | []
- GroupMembersFinder::DEFAULT_RELATIONS | :sub_sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group]
- [:direct] | :sub_sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group]
- [:inherited] | :sub_sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group]
- [:descendants] | :sub_sub_group | []
- [:shared_from_groups] | :sub_sub_group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group]
- [:direct, :inherited, :descendants, :shared_from_groups] | :sub_sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_public_shared_group]
- end
-
- with_them do
- it 'returns correct members' do
- result = described_class.new(groups[subject_group]).execute(include_relations: subject_relations)
-
- expect(result.to_a).to match_array(expected_members.map { |name| members[name] })
+ shared_examples 'member relations' do
+ it 'raises an error if a non-supported relation type is used' do
+ expect do
+ described_class.new(group).execute(include_relations: [:direct, :invalid_relation_type])
+ end.to raise_error(ArgumentError, "invalid_relation_type is not a valid relation type. Valid relation types are direct, inherited, descendants, shared_from_groups.")
end
+
+ using RSpec::Parameterized::TableSyntax
+
+ where(:subject_relations, :subject_group, :expected_members) do
+ [] | :group | []
+ GroupMembersFinder::DEFAULT_RELATIONS | :group | [:user1_group, :user2_group, :user3_group, :user4_group]
+ [:direct] | :group | [:user1_group, :user2_group, :user3_group, :user4_group]
+ [:inherited] | :group | []
+ [:descendants] | :group | [:user1_sub_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group]
+ [:shared_from_groups] | :group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group]
+ [:direct, :inherited, :descendants, :shared_from_groups] | :group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_public_shared_group]
+ [] | :sub_group | []
+ GroupMembersFinder::DEFAULT_RELATIONS | :sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group]
+ [:direct] | :sub_group | [:user1_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group]
+ [:inherited] | :sub_group | [:user1_group, :user2_group, :user3_group, :user4_group]
+ [:descendants] | :sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group]
+ [:shared_from_groups] | :sub_group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group]
+ [:direct, :inherited, :descendants, :shared_from_groups] | :sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_public_shared_group]
+ [] | :sub_sub_group | []
+ GroupMembersFinder::DEFAULT_RELATIONS | :sub_sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group]
+ [:direct] | :sub_sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group]
+ [:inherited] | :sub_sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group]
+ [:descendants] | :sub_sub_group | []
+ [:shared_from_groups] | :sub_sub_group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group]
+ [:direct, :inherited, :descendants, :shared_from_groups] | :sub_sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_public_shared_group]
+ end
+
+ with_them do
+ it 'returns correct members' do
+ result = described_class.new(groups[subject_group]).execute(include_relations: subject_relations)
+
+ expect(result.to_a).to match_array(expected_members.map { |name| members[name] })
+ end
+ end
+ end
+
+ it_behaves_like 'member relations'
+
+ it 'returns the correct access level of the members shared through group sharing' do
+ shared_members_access = described_class
+ .new(groups[:group])
+ .execute(include_relations: [:shared_from_groups])
+ .to_a
+ .map(&:access_level)
+
+ correct_access_levels = ([Gitlab::Access::DEVELOPER] * 3) << Gitlab::Access::REPORTER
+ expect(shared_members_access).to match_array(correct_access_levels)
+ end
+
+ context 'when members_with_shared_group_access feature flag is disabled' do
+ before do
+ stub_feature_flags(members_with_shared_group_access: false)
+ end
+
+ it_behaves_like 'member relations'
end
end
@@ -225,4 +248,56 @@ RSpec.describe GroupMembersFinder, '#execute', feature_category: :subgroups do
end
end
end
+
+ context 'filter by user type' do
+ subject(:by_user_type) { described_class.new(group, user1, params: { user_type: user_type }).execute }
+
+ let_it_be(:service_account) { create(:user, :service_account) }
+ let_it_be(:project_bot) { create(:user, :project_bot) }
+
+ let_it_be(:service_account_member) { group.add_developer(service_account) }
+ let_it_be(:project_bot_member) { group.add_developer(project_bot) }
+
+ context 'when the user is an owner' do
+ before do
+ group.add_owner(user1)
+ end
+
+ context 'when filtering by project bots' do
+ let(:user_type) { 'project_bot' }
+
+ it 'returns filtered members' do
+ expect(by_user_type).to match_array([project_bot_member])
+ end
+ end
+
+ context 'when filtering by service accounts' do
+ let(:user_type) { 'service_account' }
+
+ it 'returns filtered members' do
+ expect(by_user_type).to match_array([service_account_member])
+ end
+ end
+ end
+
+ context 'when the user is a maintainer' do
+ let(:user_type) { 'service_account' }
+
+ let_it_be(:user1_member) { group.add_maintainer(user1) }
+
+ it 'returns unfiltered members' do
+ expect(by_user_type).to match_array([user1_member, service_account_member, project_bot_member])
+ end
+ end
+
+ context 'when the user is a developer' do
+ let(:user_type) { 'service_account' }
+
+ let_it_be(:user1_member) { group.add_developer(user1) }
+
+ it 'returns unfiltered members' do
+ expect(by_user_type).to match_array([user1_member, service_account_member, project_bot_member])
+ end
+ end
+ end
end
diff --git a/spec/finders/groups/accepting_group_transfers_finder_spec.rb b/spec/finders/groups/accepting_group_transfers_finder_spec.rb
index 06e6fa05892..18407dd0196 100644
--- a/spec/finders/groups/accepting_group_transfers_finder_spec.rb
+++ b/spec/finders/groups/accepting_group_transfers_finder_spec.rb
@@ -39,14 +39,16 @@ RSpec.describe Groups::AcceptingGroupTransfersFinder do
describe '#execute' do
before_all do
- create(:group_group_link, :owner,
- shared_with_group: group_where_user_has_owner_access,
- shared_group: shared_with_group_where_direct_owner_as_owner
+ create(
+ :group_group_link, :owner,
+ shared_with_group: group_where_user_has_owner_access,
+ shared_group: shared_with_group_where_direct_owner_as_owner
)
- create(:group_group_link, :guest,
- shared_with_group: group_where_user_has_owner_access,
- shared_group: shared_with_group_where_direct_owner_as_guest
+ create(
+ :group_group_link, :guest,
+ shared_with_group: group_where_user_has_owner_access,
+ shared_group: shared_with_group_where_direct_owner_as_guest
)
end
diff --git a/spec/finders/groups/accepting_project_creations_finder_spec.rb b/spec/finders/groups/accepting_project_creations_finder_spec.rb
new file mode 100644
index 00000000000..2ea5577dd90
--- /dev/null
+++ b/spec/finders/groups/accepting_project_creations_finder_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::AcceptingProjectCreationsFinder, feature_category: :subgroups do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group_where_direct_owner) { create(:group) }
+ let_it_be(:subgroup_of_group_where_direct_owner) { create(:group, parent: group_where_direct_owner) }
+ let_it_be(:group_where_direct_maintainer) { create(:group) }
+ let_it_be(:group_where_direct_maintainer_but_cant_create_projects) do
+ create(:group, project_creation_level: Gitlab::Access::NO_ONE_PROJECT_ACCESS)
+ end
+
+ let_it_be(:group_where_direct_developer_but_developers_cannot_create_projects) { create(:group) }
+ let_it_be(:group_where_direct_developer) do
+ create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS)
+ end
+
+ let_it_be(:shared_with_group_where_direct_owner_as_owner) { create(:group) }
+
+ let_it_be(:shared_with_group_where_direct_owner_as_developer) do
+ create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS)
+ end
+
+ let_it_be(:shared_with_group_where_direct_owner_as_developer_but_developers_cannot_create_projects) do
+ create(:group)
+ end
+
+ let_it_be(:shared_with_group_where_direct_developer_as_maintainer) do
+ create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS)
+ end
+
+ let_it_be(:shared_with_group_where_direct_owner_as_guest) { create(:group) }
+ let_it_be(:shared_with_group_where_direct_owner_as_maintainer) { create(:group) }
+ let_it_be(:shared_with_group_where_direct_developer_as_owner) do
+ create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS)
+ end
+
+ let_it_be(:subgroup_of_shared_with_group_where_direct_owner_as_maintainer) do
+ create(:group, parent: shared_with_group_where_direct_owner_as_maintainer)
+ end
+
+ before do
+ group_where_direct_owner.add_owner(user)
+ group_where_direct_maintainer.add_maintainer(user)
+ group_where_direct_developer_but_developers_cannot_create_projects.add_developer(user)
+ group_where_direct_developer.add_developer(user)
+
+ create(:group_group_link, :owner,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_owner
+ )
+
+ create(:group_group_link, :developer,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_developer_but_developers_cannot_create_projects
+ )
+
+ create(:group_group_link, :maintainer,
+ shared_with_group: group_where_direct_developer,
+ shared_group: shared_with_group_where_direct_developer_as_maintainer
+ )
+
+ create(:group_group_link, :developer,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_developer
+ )
+
+ create(:group_group_link, :guest,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_guest
+ )
+
+ create(:group_group_link, :maintainer,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_maintainer
+ )
+
+ create(:group_group_link, :owner,
+ shared_with_group: group_where_direct_developer_but_developers_cannot_create_projects,
+ shared_group: shared_with_group_where_direct_developer_as_owner
+ )
+ end
+
+ describe '#execute' do
+ subject(:result) { described_class.new(user).execute }
+
+ it 'only returns groups where the user has access to create projects' do
+ expect(result).to match_array([
+ group_where_direct_owner,
+ subgroup_of_group_where_direct_owner,
+ group_where_direct_maintainer,
+ group_where_direct_developer,
+ # groups arising from group shares
+ shared_with_group_where_direct_owner_as_owner,
+ shared_with_group_where_direct_owner_as_maintainer,
+ subgroup_of_shared_with_group_where_direct_owner_as_maintainer,
+ shared_with_group_where_direct_developer_as_owner,
+ shared_with_group_where_direct_developer_as_maintainer,
+ shared_with_group_where_direct_owner_as_developer
+ ])
+ end
+ end
+end
diff --git a/spec/finders/groups/accepting_project_imports_finder_spec.rb b/spec/finders/groups/accepting_project_imports_finder_spec.rb
new file mode 100644
index 00000000000..4e06c2cbc67
--- /dev/null
+++ b/spec/finders/groups/accepting_project_imports_finder_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::AcceptingProjectImportsFinder, feature_category: :importers do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group_where_direct_owner) { create(:group) }
+ let_it_be(:subgroup_of_group_where_direct_owner) { create(:group, parent: group_where_direct_owner) }
+ let_it_be(:group_where_direct_maintainer) { create(:group) }
+ let_it_be(:group_where_direct_maintainer_but_cant_create_projects) do
+ create(:group, project_creation_level: Gitlab::Access::NO_ONE_PROJECT_ACCESS)
+ end
+
+ let_it_be(:group_where_direct_developer_but_developers_cannot_create_projects) { create(:group) }
+ let_it_be(:group_where_direct_developer) do
+ create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS)
+ end
+
+ let_it_be(:shared_with_group_where_direct_owner_as_owner) { create(:group) }
+
+ let_it_be(:shared_with_group_where_direct_owner_as_developer) do
+ create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS)
+ end
+
+ let_it_be(:shared_with_group_where_direct_owner_as_developer_but_developers_cannot_create_projects) do
+ create(:group)
+ end
+
+ let_it_be(:shared_with_group_where_direct_developer_as_maintainer) do
+ create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS)
+ end
+
+ let_it_be(:shared_with_group_where_direct_owner_as_guest) { create(:group) }
+ let_it_be(:shared_with_group_where_direct_owner_as_maintainer) { create(:group) }
+ let_it_be(:shared_with_group_where_direct_developer_as_owner) do
+ create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS)
+ end
+
+ let_it_be(:subgroup_of_shared_with_group_where_direct_owner_as_maintainer) do
+ create(:group, parent: shared_with_group_where_direct_owner_as_maintainer)
+ end
+
+ before do
+ group_where_direct_owner.add_owner(user)
+ group_where_direct_maintainer.add_maintainer(user)
+ group_where_direct_developer_but_developers_cannot_create_projects.add_developer(user)
+ group_where_direct_developer.add_developer(user)
+
+ create(:group_group_link, :owner,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_owner
+ )
+
+ create(:group_group_link, :developer,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_developer_but_developers_cannot_create_projects
+ )
+
+ create(:group_group_link, :maintainer,
+ shared_with_group: group_where_direct_developer,
+ shared_group: shared_with_group_where_direct_developer_as_maintainer
+ )
+
+ create(:group_group_link, :developer,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_developer
+ )
+
+ create(:group_group_link, :guest,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_guest
+ )
+
+ create(:group_group_link, :maintainer,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_maintainer
+ )
+
+ create(:group_group_link, :owner,
+ shared_with_group: group_where_direct_developer_but_developers_cannot_create_projects,
+ shared_group: shared_with_group_where_direct_developer_as_owner
+ )
+ end
+
+ describe '#execute' do
+ subject(:result) { described_class.new(user).execute }
+
+ it 'only returns groups where the user has access to import projects' do
+ expect(result).to match_array([
+ group_where_direct_owner,
+ subgroup_of_group_where_direct_owner,
+ group_where_direct_maintainer,
+ # groups arising from group shares
+ shared_with_group_where_direct_owner_as_owner,
+ shared_with_group_where_direct_owner_as_maintainer,
+ subgroup_of_shared_with_group_where_direct_owner_as_maintainer
+ ])
+
+ expect(result).not_to include(group_where_direct_developer)
+ expect(result).not_to include(shared_with_group_where_direct_developer_as_owner)
+ expect(result).not_to include(shared_with_group_where_direct_developer_as_maintainer)
+ expect(result).not_to include(shared_with_group_where_direct_owner_as_developer)
+ end
+ end
+end
diff --git a/spec/finders/groups/accepting_project_shares_finder_spec.rb b/spec/finders/groups/accepting_project_shares_finder_spec.rb
new file mode 100644
index 00000000000..6af3fad2110
--- /dev/null
+++ b/spec/finders/groups/accepting_project_shares_finder_spec.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::AcceptingProjectSharesFinder, feature_category: :subgroups do
+ subject(:result) { described_class.new(current_user, project, params).execute }
+
+ let_it_be_with_reload(:current_user) { create(:user) }
+ let_it_be(:group_1) { create(:group) }
+ let_it_be(:group_1_subgroup) { create(:group, parent: group_1) }
+ let_it_be(:group_2) { create(:group, name: 'hello-world-group') }
+ let_it_be(:group_3) { create(:group) }
+ let_it_be_with_reload(:group) { create(:group) }
+ let_it_be_with_reload(:project) { create(:project, group: group) }
+
+ let(:params) { {} }
+
+ context 'when admin', :enable_admin_mode do
+ let_it_be(:current_user) { create(:admin) }
+
+ it 'returns all groups' do
+ expect(result).to match_array([group_1, group_1_subgroup, group_2, group_3])
+ end
+ end
+
+ context 'when normal user' do
+ context 'when the user has no access to the project to be shared' do
+ it 'does not return any group' do
+ expect(result).to be_empty
+ end
+ end
+
+ context 'when the user has no access to any group' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ it 'does not return any group' do
+ expect(result).to be_empty
+ end
+ end
+
+ context "when the project's group has enabled lock on group sharing" do
+ before do
+ project.add_maintainer(current_user)
+ project.namespace.update!(share_with_group_lock: true)
+ group_1.add_maintainer(current_user)
+ end
+
+ it 'does not return any group' do
+ expect(result).to be_empty
+ end
+ end
+
+ context 'when the user has access to groups' do
+ before do
+ project.add_maintainer(current_user)
+
+ group_1.add_guest(current_user)
+ group_2.add_guest(current_user)
+ end
+
+ it 'returns groups where the user has at least guest access' do
+ expect(result).to match_array([group_1, group_1_subgroup, group_2])
+ end
+
+ context 'when searching' do
+ let(:params) { { search: 'hello' } }
+
+ it 'returns groups where the search term matches' do
+ expect(result).to match_array([group_2])
+ end
+ end
+ end
+
+ context 'for sharing outside hierarchy' do
+ let_it_be_with_reload(:grandparent_group) { create(:group) }
+ let_it_be(:child_group) { create(:group, parent: grandparent_group) }
+ let_it_be(:grandchild_group) { create(:group, parent: child_group) }
+ let_it_be(:grandchild_group_subgroup) { create(:group, parent: grandchild_group) }
+ let_it_be(:unrelated_group) { create(:group) }
+ let_it_be_with_reload(:project) { create(:project, group: child_group) }
+
+ before do
+ project.add_maintainer(current_user)
+
+ grandparent_group.add_guest(current_user)
+ unrelated_group.add_guest(current_user)
+ end
+
+ context 'when sharing outside hierarchy is allowed' do
+ before do
+ grandparent_group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: false)
+ end
+
+ it 'returns all groups where the user has at least guest access' do
+ expect(result).to match_array([grandchild_group, grandchild_group_subgroup, unrelated_group])
+ end
+ end
+
+ context 'when sharing outside hierarchy is not allowed' do
+ before do
+ grandparent_group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true)
+ end
+
+ it 'returns groups where the user has at least guest access, but only from within the hierarchy' do
+ expect(result).to match_array([grandchild_group, grandchild_group_subgroup])
+ end
+
+ context 'when groups are already linked to the project' do
+ before do
+ create(:project_group_link, project: project, group: grandchild_group_subgroup)
+ end
+
+ it 'does not appear in the result' do
+ expect(result).to match_array([grandchild_group])
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/groups/accepting_project_transfers_finder_spec.rb b/spec/finders/groups/accepting_project_transfers_finder_spec.rb
index e73318c763f..bb6731abbba 100644
--- a/spec/finders/groups/accepting_project_transfers_finder_spec.rb
+++ b/spec/finders/groups/accepting_project_transfers_finder_spec.rb
@@ -25,24 +25,28 @@ RSpec.describe Groups::AcceptingProjectTransfersFinder do
group_where_direct_maintainer.add_maintainer(user)
group_where_direct_developer.add_developer(user)
- create(:group_group_link, :owner,
- shared_with_group: group_where_direct_owner,
- shared_group: shared_with_group_where_direct_owner_as_owner
+ create(
+ :group_group_link, :owner,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_owner
)
- create(:group_group_link, :guest,
- shared_with_group: group_where_direct_owner,
- shared_group: shared_with_group_where_direct_owner_as_guest
+ create(
+ :group_group_link, :guest,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_guest
)
- create(:group_group_link, :maintainer,
- shared_with_group: group_where_direct_owner,
- shared_group: shared_with_group_where_direct_owner_as_maintainer
+ create(
+ :group_group_link, :maintainer,
+ shared_with_group: group_where_direct_owner,
+ shared_group: shared_with_group_where_direct_owner_as_maintainer
)
- create(:group_group_link, :owner,
- shared_with_group: group_where_direct_developer,
- shared_group: shared_with_group_where_direct_developer_as_owner
+ create(
+ :group_group_link, :owner,
+ shared_with_group: group_where_direct_developer,
+ shared_group: shared_with_group_where_direct_developer_as_owner
)
end
@@ -51,13 +55,13 @@ RSpec.describe Groups::AcceptingProjectTransfersFinder do
it 'only returns groups where the user has access to transfer projects to' do
expect(result).to match_array([
- group_where_direct_owner,
- subgroup_of_group_where_direct_owner,
- group_where_direct_maintainer,
- shared_with_group_where_direct_owner_as_owner,
- shared_with_group_where_direct_owner_as_maintainer,
- subgroup_of_shared_with_group_where_direct_owner_as_maintainer
- ])
+ group_where_direct_owner,
+ subgroup_of_group_where_direct_owner,
+ group_where_direct_maintainer,
+ shared_with_group_where_direct_owner_as_owner,
+ shared_with_group_where_direct_owner_as_maintainer,
+ subgroup_of_shared_with_group_where_direct_owner_as_maintainer
+ ])
end
end
end
diff --git a/spec/finders/groups/user_groups_finder_spec.rb b/spec/finders/groups/user_groups_finder_spec.rb
index 999079468e5..f6df396037c 100644
--- a/spec/finders/groups/user_groups_finder_spec.rb
+++ b/spec/finders/groups/user_groups_finder_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::UserGroupsFinder do
+RSpec.describe Groups::UserGroupsFinder, feature_category: :subgroups do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:root_group) { create(:group, name: 'Root group', path: 'root-group') }
@@ -98,6 +98,24 @@ RSpec.describe Groups::UserGroupsFinder do
end
end
+ context 'when permission is :import_projects' do
+ let(:arguments) { { permission_scope: :import_projects } }
+
+ specify do
+ is_expected.to contain_exactly(
+ public_maintainer_group,
+ public_owner_group,
+ private_maintainer_group
+ )
+ end
+
+ it_behaves_like 'user group finder searching by name or path' do
+ let(:keyword_search_expected_groups) do
+ [public_maintainer_group]
+ end
+ end
+ end
+
context 'when permission is :transfer_projects' do
let(:arguments) { { permission_scope: :transfer_projects } }
diff --git a/spec/finders/issuables/crm_organization_filter_spec.rb b/spec/finders/issuables/crm_organization_filter_spec.rb
index 2a521dcf721..9a910091fd2 100644
--- a/spec/finders/issuables/crm_organization_filter_spec.rb
+++ b/spec/finders/issuables/crm_organization_filter_spec.rb
@@ -6,11 +6,11 @@ RSpec.describe Issuables::CrmOrganizationFilter do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
- let_it_be(:organization1) { create(:organization, group: group) }
- let_it_be(:organization2) { create(:organization, group: group) }
- let_it_be(:contact1) { create(:contact, group: group, organization: organization1) }
- let_it_be(:contact2) { create(:contact, group: group, organization: organization1) }
- let_it_be(:contact3) { create(:contact, group: group, organization: organization2) }
+ let_it_be(:crm_organization1) { create(:crm_organization, group: group) }
+ let_it_be(:crm_organization2) { create(:crm_organization, group: group) }
+ let_it_be(:contact1) { create(:contact, group: group, organization: crm_organization1) }
+ let_it_be(:contact2) { create(:contact, group: group, organization: crm_organization1) }
+ let_it_be(:contact3) { create(:contact, group: group, organization: crm_organization2) }
let_it_be(:contact1_issue) { create(:issue, project: project) }
let_it_be(:contact2_issue) { create(:issue, project: project) }
@@ -24,14 +24,14 @@ RSpec.describe Issuables::CrmOrganizationFilter do
end
describe 'when an organization has issues' do
- it 'returns all organization1 issues' do
- params = { crm_organization_id: organization1.id }
+ it 'returns all crm_organization1 issues' do
+ params = { crm_organization_id: crm_organization1.id }
expect(described_class.new(params: params).filter(issues)).to contain_exactly(contact1_issue, contact2_issue)
end
- it 'returns all organization2 issues' do
- params = { crm_organization_id: organization2.id }
+ it 'returns all crm_organization2 issues' do
+ params = { crm_organization_id: crm_organization2.id }
expect(described_class.new(params: params).filter(issues)).to contain_exactly(contact3_issue)
end
@@ -39,8 +39,8 @@ RSpec.describe Issuables::CrmOrganizationFilter do
describe 'when an organization has no issues' do
it 'returns no issues' do
- organization3 = create(:organization, group: group)
- params = { crm_organization_id: organization3.id }
+ crm_organization3 = create(:crm_organization, group: group)
+ params = { crm_organization_id: crm_organization3.id }
expect(described_class.new(params: params).filter(issues)).to be_empty
end
diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb
index c48a0271471..afab4514ce2 100644
--- a/spec/finders/members_finder_spec.rb
+++ b/spec/finders/members_finder_spec.rb
@@ -161,6 +161,37 @@ RSpec.describe MembersFinder, feature_category: :subgroups do
expect(result).to eq([member3, member2, member1])
end
+ context 'with :shared_into_ancestors' do
+ let_it_be(:invited_group) do
+ create(:group).tap do |invited_group|
+ create(:group_group_link, shared_group: nested_group, shared_with_group: invited_group)
+ end
+ end
+
+ let_it_be(:invited_group_member) { create(:group_member, :developer, group: invited_group, user: user1) }
+ let_it_be(:namespace_parent_member) { create(:group_member, :owner, group: group, user: user2) }
+ let_it_be(:namespace_member) { create(:group_member, :developer, group: nested_group, user: user3) }
+ let_it_be(:project_member) { create(:project_member, :developer, project: project, user: user4) }
+
+ subject(:result) { described_class.new(project, user4).execute(include_relations: include_relations) }
+
+ context 'when :shared_into_ancestors is included in the relations' do
+ let(:include_relations) { [:inherited, :direct, :invited_groups, :shared_into_ancestors] }
+
+ it "includes members of groups invited into ancestors of project's group" do
+ expect(result).to match_array([namespace_parent_member, namespace_member, invited_group_member, project_member])
+ end
+ end
+
+ context 'when :shared_into_ancestors is not included in the relations' do
+ let(:include_relations) { [:inherited, :direct, :invited_groups] }
+
+ it "does not include members of groups invited into ancestors of project's group" do
+ expect(result).to match_array([namespace_parent_member, namespace_member, project_member])
+ end
+ end
+ end
+
context 'when :invited_groups is passed' do
shared_examples 'with invited_groups param' do
subject { described_class.new(project, user2).execute(include_relations: [:inherited, :direct, :invited_groups]) }
@@ -207,12 +238,4 @@ RSpec.describe MembersFinder, feature_category: :subgroups do
end
it_behaves_like '#execute'
-
- context 'when project_members_index_by_project_namespace feature flag is disabled' do
- before do
- stub_feature_flags(project_members_index_by_project_namespace: false)
- end
-
- it_behaves_like '#execute'
- end
end
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index e8099924638..6d576bc8e38 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -493,6 +493,48 @@ RSpec.describe MergeRequestsFinder, feature_category: :code_review_workflow do
end
end
+ context 'filtering by approved' do
+ before do
+ create(:approval, merge_request: merge_request3, user: user2)
+ end
+
+ context 'when flag `mr_approved_filter` is disabled' do
+ before do
+ stub_feature_flags(mr_approved_filter: false)
+ end
+
+ it 'for approved' do
+ merge_requests = described_class.new(user, { approved: true }).execute
+
+ expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request3, merge_request4, merge_request5)
+ end
+
+ it 'for not approved' do
+ merge_requests = described_class.new(user, { approved: false }).execute
+
+ expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request3, merge_request4, merge_request5)
+ end
+ end
+
+ context 'when flag `mr_approved_filter` is enabled' do
+ before do
+ stub_feature_flags(mr_approved_filter: true)
+ end
+
+ it 'for approved' do
+ merge_requests = described_class.new(user, { approved: true }).execute
+
+ expect(merge_requests).to contain_exactly(merge_request3)
+ end
+
+ it 'for not approved' do
+ merge_requests = described_class.new(user, { approved: false }).execute
+
+ expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request4, merge_request5)
+ end
+ end
+ end
+
context 'filtering by approved by username' do
let(:params) { { approved_by_usernames: user2.username } }
@@ -564,24 +606,28 @@ RSpec.describe MergeRequestsFinder, feature_category: :code_review_workflow do
let_it_be(:new_project) { create(:project, forked_from_project: project1) }
let!(:new_merge_request) do
- create(:merge_request,
- :simple,
- author: user,
- created_at: 1.week.from_now,
- updated_at: 1.week.from_now,
- source_project: new_project,
- target_project: new_project)
+ create(
+ :merge_request,
+ :simple,
+ author: user,
+ created_at: 1.week.from_now,
+ updated_at: 1.week.from_now,
+ source_project: new_project,
+ target_project: new_project
+ )
end
let!(:old_merge_request) do
- create(:merge_request,
- :simple,
- author: user,
- source_branch: 'feature_1',
- created_at: 1.week.ago,
- updated_at: 1.week.ago,
- source_project: new_project,
- target_project: new_project)
+ create(
+ :merge_request,
+ :simple,
+ author: user,
+ source_branch: 'feature_1',
+ created_at: 1.week.ago,
+ updated_at: 1.week.ago,
+ source_project: new_project,
+ target_project: new_project
+ )
end
before_all do
diff --git a/spec/finders/milestones_finder_spec.rb b/spec/finders/milestones_finder_spec.rb
index 8dd83df3a28..c4c62e21ad9 100644
--- a/spec/finders/milestones_finder_spec.rb
+++ b/spec/finders/milestones_finder_spec.rb
@@ -62,9 +62,31 @@ RSpec.describe MilestonesFinder do
end
context 'with filters' do
- let_it_be(:milestone_1) { create(:milestone, group: group, state: 'closed', title: 'one test', start_date: now - 1.day, due_date: now) }
- let_it_be(:milestone_3) { create(:milestone, project: project_1, state: 'closed', start_date: now + 2.days, due_date: now + 3.days) }
+ let_it_be(:milestone_1) do
+ create(
+ :milestone,
+ group: group,
+ state: 'closed',
+ title: 'one test',
+ start_date: now - 1.day,
+ due_date: now,
+ updated_at: now - 3.days
+ )
+ end
+
+ let_it_be(:milestone_3) do
+ create(
+ :milestone,
+ project: project_1,
+ state: 'closed',
+ description: 'three test',
+ start_date: now + 2.days,
+ due_date: now + 3.days,
+ updated_at: now - 5.days
+ )
+ end
+ let(:result) { described_class.new(params).execute }
let(:params) do
{
project_ids: [project_1.id, project_2.id],
@@ -76,62 +98,96 @@ RSpec.describe MilestonesFinder do
it 'filters by id' do
params[:ids] = [milestone_1.id, milestone_2.id]
- result = described_class.new(params).execute
-
expect(result).to contain_exactly(milestone_1, milestone_2)
end
it 'filters by active state' do
params[:state] = 'active'
- result = described_class.new(params).execute
expect(result).to contain_exactly(milestone_2, milestone_4)
end
it 'filters by closed state' do
params[:state] = 'closed'
- result = described_class.new(params).execute
expect(result).to contain_exactly(milestone_1, milestone_3)
end
it 'filters by title' do
- result = described_class.new(params.merge(title: 'one test')).execute
+ params[:title] = 'one test'
- expect(result.to_a).to contain_exactly(milestone_1)
+ expect(result).to contain_exactly(milestone_1)
end
it 'filters by search_title' do
- result = described_class.new(params.merge(search_title: 'one t')).execute
+ params[:search_title] = 'test'
+
+ expect(result).to contain_exactly(milestone_1)
+ end
+
+ it 'filters by search (title, description)' do
+ params[:search] = 'test'
- expect(result.to_a).to contain_exactly(milestone_1)
+ expect(result).to contain_exactly(milestone_1, milestone_3)
end
context 'by timeframe' do
it 'returns milestones with start_date and due_date between timeframe' do
params.merge!(start_date: now - 1.day, end_date: now + 3.days)
- milestones = described_class.new(params).execute
-
- expect(milestones).to match_array([milestone_1, milestone_2, milestone_3])
+ expect(result).to contain_exactly(milestone_1, milestone_2, milestone_3)
end
it 'returns milestones which starts before the timeframe' do
milestone = create(:milestone, project: project_2, start_date: now - 5.days)
params.merge!(start_date: now - 3.days, end_date: now - 2.days)
- milestones = described_class.new(params).execute
-
- expect(milestones).to match_array([milestone])
+ expect(result).to contain_exactly(milestone)
end
it 'returns milestones which ends after the timeframe' do
milestone = create(:milestone, project: project_2, due_date: now + 6.days)
params.merge!(start_date: now + 6.days, end_date: now + 7.days)
- milestones = described_class.new(params).execute
+ expect(result).to contain_exactly(milestone)
+ end
+ end
+
+ context 'by updated_at' do
+ it 'returns milestones updated before a given date' do
+ params[:updated_before] = 4.days.ago.iso8601
+
+ expect(result).to contain_exactly(milestone_3)
+ end
+
+ it 'returns milestones updated after a given date' do
+ params[:updated_after] = 4.days.ago.iso8601
+
+ expect(result).to contain_exactly(milestone_1, milestone_2, milestone_4)
+ end
+
+ it 'returns milestones updated between the given dates' do
+ params.merge!(updated_after: 6.days.ago.iso8601, updated_before: 4.days.ago.iso8601)
+
+ expect(result).to contain_exactly(milestone_3)
+ end
+ end
+
+ context 'by iids' do
+ before do
+ params[:iids] = 1
+ end
- expect(milestones).to match_array([milestone])
+ it 'returns milestone for the given iids' do
+ expect(result).to contain_exactly(milestone_2, milestone_3, milestone_4)
+ end
+
+ context 'when include_parent_milestones is true' do
+ it 'ignores the iid filter' do
+ params[:include_parent_milestones] = true
+
+ expect(result).to contain_exactly(milestone_1, milestone_2, milestone_3, milestone_4)
+ end
end
end
end
diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb
index 1255a882114..e93c0c790c2 100644
--- a/spec/finders/notes_finder_spec.rb
+++ b/spec/finders/notes_finder_spec.rb
@@ -74,11 +74,13 @@ RSpec.describe NotesFinder do
context 'on restricted projects' do
let(:project) do
- create(:project,
- :public,
- :issues_private,
- :snippets_private,
- :merge_requests_private)
+ create(
+ :project,
+ :public,
+ :issues_private,
+ :snippets_private,
+ :merge_requests_private
+ )
end
it 'publicly excludes notes on merge requests' do
@@ -126,6 +128,51 @@ RSpec.describe NotesFinder do
end
end
+ context 'for notes from users who have been banned', :enable_admin_mode, feature_category: :instance_resiliency do
+ subject(:finder) { described_class.new(user, project: project).execute }
+
+ let_it_be(:banned_user) { create(:banned_user).user }
+ let!(:banned_note) { create(:note_on_issue, project: project, author: banned_user) }
+
+ context 'when :hidden_notes feature is not enabled' do
+ before do
+ stub_feature_flags(hidden_notes: false)
+ end
+
+ context 'when user is not an admin' do
+ it { is_expected.to include(banned_note) }
+ end
+
+ context 'when @current_user is nil' do
+ let(:user) { nil }
+
+ it { is_expected.to be_empty }
+ end
+ end
+
+ context 'when :hidden_notes feature is enabled' do
+ before do
+ stub_feature_flags(hidden_notes: true)
+ end
+
+ context 'when user is an admin' do
+ let(:user) { create(:admin) }
+
+ it { is_expected.to include(banned_note) }
+ end
+
+ context 'when user is not an admin' do
+ it { is_expected.not_to include(banned_note) }
+ end
+
+ context 'when @current_user is nil' do
+ let(:user) { nil }
+
+ it { is_expected.to be_empty }
+ end
+ end
+ end
+
context 'for target type' do
let(:project) { create(:project, :repository) }
let!(:note1) { create :note_on_issue, project: project }
diff --git a/spec/finders/packages/conan/package_finder_spec.rb b/spec/finders/packages/conan/package_finder_spec.rb
index f25a62225a8..787cb256486 100644
--- a/spec/finders/packages/conan/package_finder_spec.rb
+++ b/spec/finders/packages/conan/package_finder_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe ::Packages::Conan::PackageFinder do
+RSpec.describe ::Packages::Conan::PackageFinder, feature_category: :package_registry do
using RSpec::Parameterized::TableSyntax
let_it_be_with_reload(:project) { create(:project) }
@@ -15,7 +15,8 @@ RSpec.describe ::Packages::Conan::PackageFinder do
describe '#execute' do
let(:query) { "#{conan_package.name.split('/').first[0, 3]}%" }
- let(:finder) { described_class.new(user, query: query) }
+ let(:finder) { described_class.new(user, params) }
+ let(:params) { { query: query } }
subject { finder.execute }
@@ -40,7 +41,7 @@ RSpec.describe ::Packages::Conan::PackageFinder do
end
with_them do
- let(:expected_packages) { packages_visible ? [conan_package, conan_package2] : [] }
+ let(:expected_packages) { packages_visible ? [conan_package2, conan_package] : [] }
let(:user) { role == :anonymous ? nil : super() }
before do
@@ -50,5 +51,23 @@ RSpec.describe ::Packages::Conan::PackageFinder do
it { is_expected.to eq(expected_packages) }
end
+
+ context 'with project' do
+ subject { described_class.new(user, params, project: project).execute }
+
+ it { is_expected.to match_array([conan_package2, conan_package]) }
+
+ it 'respects the limit' do
+ stub_const("#{described_class}::MAX_PACKAGES_COUNT", 1)
+
+ expect(subject).to match_array([conan_package2])
+ end
+
+ context 'with a different project' do
+ let_it_be(:project) { private_project }
+
+ it { is_expected.to match_array([private_package]) }
+ end
+ end
end
end
diff --git a/spec/finders/packages/group_packages_finder_spec.rb b/spec/finders/packages/group_packages_finder_spec.rb
index f78a356b13d..e4a944eb837 100644
--- a/spec/finders/packages/group_packages_finder_spec.rb
+++ b/spec/finders/packages/group_packages_finder_spec.rb
@@ -203,7 +203,9 @@ RSpec.describe Packages::GroupPackagesFinder do
end
context 'group has package of all types' do
- package_types.each { |pt| let_it_be("package_#{pt}") { create("#{pt}_package", project: project) } }
+ package_types.each do |pt| # rubocop:disable RSpec/UselessDynamicDefinition
+ let_it_be("package_#{pt}") { create("#{pt}_package", project: project) }
+ end
package_types.each do |package_type|
it_behaves_like 'with package type', package_type
diff --git a/spec/finders/packages/npm/package_finder_spec.rb b/spec/finders/packages/npm/package_finder_spec.rb
index 8c9149a5a2d..e11b33f71e9 100644
--- a/spec/finders/packages/npm/package_finder_spec.rb
+++ b/spec/finders/packages/npm/package_finder_spec.rb
@@ -71,6 +71,14 @@ RSpec.describe ::Packages::Npm::PackageFinder do
context 'enabled' do
it { is_expected.to contain_exactly(package2) }
end
+
+ context 'with npm_allow_packages_in_multiple_projects disabled' do
+ before do
+ stub_feature_flags(npm_allow_packages_in_multiple_projects: false)
+ end
+
+ it { is_expected.to contain_exactly(package2) }
+ end
end
context 'with a project' do
diff --git a/spec/finders/pending_todos_finder_spec.rb b/spec/finders/pending_todos_finder_spec.rb
index 4f4862852f4..0cf61a0958e 100644
--- a/spec/finders/pending_todos_finder_spec.rb
+++ b/spec/finders/pending_todos_finder_spec.rb
@@ -5,49 +5,60 @@ require 'spec_helper'
RSpec.describe PendingTodosFinder do
let_it_be(:user) { create(:user) }
let_it_be(:user2) { create(:user) }
+ let_it_be(:user3) { create(:user) }
let_it_be(:issue) { create(:issue) }
+ let_it_be(:issue2) { create(:issue) }
+ let_it_be(:project) { create(:project) }
let_it_be(:note) { create(:note) }
+ let_it_be(:todo) { create(:todo, :pending, user: user, target: issue) }
+ let_it_be(:todo2) { create(:todo, :pending, user: user, target: issue2, project: project) }
+ let_it_be(:todo3) { create(:todo, :pending, user: user2, target: issue) }
+ let_it_be(:todo4) { create(:todo, :pending, user: user3, target: issue) }
+ let_it_be(:done_todo) { create(:todo, :done, user: user) }
let(:users) { [user, user2] }
describe '#execute' do
- it 'returns only pending todos' do
- create(:todo, :done, user: user)
+ it 'returns all pending todos if no params are passed' do
+ todos = described_class.new.execute
- todo = create(:todo, :pending, user: user)
- todos = described_class.new(users).execute
+ expect(todos).to match_array([todo, todo2, todo3, todo4])
+ end
- expect(todos).to eq([todo])
+ it 'supports retrieving only pending todos for chosen users' do
+ todos = described_class.new(users: users).execute
+
+ expect(todos).to match_array([todo, todo2, todo3])
end
it 'supports retrieving of todos for a specific project' do
- project1 = create(:project)
project2 = create(:project)
+ project2_todo = create(:todo, :pending, user: user, project: project2)
- create(:todo, :pending, user: user, project: project2)
+ todos = described_class.new(users: user, project_id: project.id).execute
+ expect(todos).to match_array([todo2])
- todo = create(:todo, :pending, user: user, project: project1)
- todos = described_class.new(users, project_id: project1.id).execute
-
- expect(todos).to eq([todo])
+ todos = described_class.new(users: user, project_id: project2.id).execute
+ expect(todos).to match_array([project2_todo])
end
it 'supports retrieving of todos for a specific todo target' do
- todo = create(:todo, :pending, user: user, target: issue)
+ todos = described_class.new(users: user, target_id: issue.id, target_type: 'Issue').execute
- create(:todo, :pending, user: user, target: note)
-
- todos = described_class.new(users, target_id: issue.id, target_type: 'Issue').execute
-
- expect(todos).to eq([todo])
+ expect(todos).to match_array([todo])
end
it 'supports retrieving of todos for a specific target type' do
- todo = create(:todo, :pending, user: user, target: issue)
+ todos = described_class.new(users: user, target_type: issue.class.name).execute
+
+ expect(todos).to match_array([todo, todo2])
+ end
- create(:todo, :pending, user: user, target: note)
+ it 'supports retrieving of todos from a specific author' do
+ todo = create(:todo, :pending, user: user, author: user2, target: issue)
+ create(:todo, :pending, user: user, author: user3, target: issue)
- todos = described_class.new(users, target_type: issue.class.name).execute
+ todos = described_class.new(users: users, author_id: user2.id).execute
expect(todos).to eq([todo])
end
@@ -56,7 +67,7 @@ RSpec.describe PendingTodosFinder do
create(:todo, :pending, user: user, commit_id: '456')
todo = create(:todo, :pending, user: user, commit_id: '123')
- todos = described_class.new(users, commit_id: '123').execute
+ todos = described_class.new(users: users, commit_id: '123').execute
expect(todos).to eq([todo])
end
@@ -71,7 +82,7 @@ RSpec.describe PendingTodosFinder do
discussion = Discussion.lazy_find(first_discussion_note.discussion_id)
users = [note_2.author, note_3.author, user]
- todos = described_class.new(users, discussion: discussion).execute
+ todos = described_class.new(users: users, discussion: discussion).execute
expect(todos).to contain_exactly(todo1, todo2)
end
@@ -81,7 +92,7 @@ RSpec.describe PendingTodosFinder do
create(:todo, :pending, user: user, target: issue, action: Todo::ASSIGNED)
- todos = described_class.new(users, action: Todo::MENTIONED).execute
+ todos = described_class.new(users: users, action: Todo::MENTIONED).execute
expect(todos).to contain_exactly(todo)
end
diff --git a/spec/finders/personal_access_tokens_finder_spec.rb b/spec/finders/personal_access_tokens_finder_spec.rb
index bcd5aef84f9..d91b2c8f599 100644
--- a/spec/finders/personal_access_tokens_finder_spec.rb
+++ b/spec/finders/personal_access_tokens_finder_spec.rb
@@ -110,7 +110,7 @@ RSpec.describe PersonalAccessTokensFinder, :enable_admin_mode do
lazy { [user] } | [:active, :expired, :revoked, :active_impersonation, :expired_impersonation, :revoked_impersonation]
lazy { [other_user] } | [:active_other]
lazy { [user, other_user] } | [:active, :active_other, :expired, :revoked, :active_impersonation, :expired_impersonation, :revoked_impersonation]
- [] | [] # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
+ [] | []
end
with_them do
diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb
index 297c6f84cef..c68ae443231 100644
--- a/spec/finders/projects_finder_spec.rb
+++ b/spec/finders/projects_finder_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe ProjectsFinder do
end
let_it_be(:internal_project) do
- create(:project, :internal, :merge_requests_disabled, group: group, name: 'B', path: 'B')
+ create(:project, :internal, :merge_requests_disabled, group: group, name: 'B', path: 'B', updated_at: 4.days.ago)
end
let_it_be(:public_project) do
@@ -133,6 +133,52 @@ RSpec.describe ProjectsFinder do
end
end
+ describe 'filter by updated_at' do
+ context 'when updated_before is present' do
+ let(:params) { { updated_before: 2.days.ago } }
+
+ it { is_expected.to contain_exactly(internal_project) }
+ end
+
+ context 'when updated_after is present' do
+ let(:params) { { updated_after: 2.days.ago } }
+
+ it { is_expected.not_to include(internal_project) }
+ end
+
+ context 'when both updated_before and updated_after are present' do
+ let(:params) { { updated_before: 2.days.ago, updated_after: 6.days.ago } }
+
+ it { is_expected.to contain_exactly(internal_project) }
+
+ context 'when updated_after > updated_before' do
+ let(:params) { { updated_after: 2.days.ago, updated_before: 6.days.ago } }
+
+ it { is_expected.to be_empty }
+
+ it 'does not query the DB' do
+ expect { subject.to_a }.to make_queries(0)
+ end
+ end
+
+ context 'when updated_after equals updated_before', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408387' do
+ let(:params) { { updated_after: internal_project.updated_at, updated_before: internal_project.updated_at } }
+
+ it 'allows an exact match' do
+ expect(subject).to contain_exactly(internal_project)
+ end
+ end
+
+ context 'when arguments are invalid datetimes' do
+ let(:params) { { updated_after: 'invalid', updated_before: 'inavlid' } }
+
+ it 'does not filter by updated_at' do
+ expect(subject).to contain_exactly(internal_project, public_project)
+ end
+ end
+ end
+ end
+
describe 'filter by tags (deprecated)' do
before do
public_project.reload
diff --git a/spec/finders/serverless_domain_finder_spec.rb b/spec/finders/serverless_domain_finder_spec.rb
deleted file mode 100644
index 4e6b9f07544..00000000000
--- a/spec/finders/serverless_domain_finder_spec.rb
+++ /dev/null
@@ -1,103 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ServerlessDomainFinder do
- let(:function_name) { 'test-function' }
- let(:pages_domain_name) { 'serverless.gitlab.io' }
- let(:valid_cluster_uuid) { 'aba1cdef123456f278' }
- let(:invalid_cluster_uuid) { 'aba1cdef123456f178' }
- let!(:environment) { create(:environment, name: 'test') }
-
- let(:pages_domain) do
- create(
- :pages_domain,
- :instance_serverless,
- domain: pages_domain_name
- )
- end
-
- let(:knative_with_ingress) do
- create(
- :clusters_applications_knative,
- external_ip: '10.0.0.1'
- )
- end
-
- let!(:serverless_domain_cluster) do
- create(
- :serverless_domain_cluster,
- uuid: 'abcdef12345678',
- pages_domain: pages_domain,
- knative: knative_with_ingress
- )
- end
-
- let(:valid_uri) { "https://#{function_name}-#{valid_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{pages_domain_name}" }
- let(:valid_fqdn) { "#{function_name}-#{valid_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{pages_domain_name}" }
- let(:invalid_uri) { "https://#{function_name}-#{invalid_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{pages_domain_name}" }
-
- let(:valid_finder) { described_class.new(valid_uri) }
- let(:invalid_finder) { described_class.new(invalid_uri) }
-
- describe '#serverless?' do
- context 'with a valid URI' do
- subject { valid_finder.serverless? }
-
- it { is_expected.to be_truthy }
- end
-
- context 'with an invalid URI' do
- subject { invalid_finder.serverless? }
-
- it { is_expected.to be_falsy }
- end
- end
-
- describe '#serverless_domain_cluster_uuid' do
- context 'with a valid URI' do
- subject { valid_finder.serverless_domain_cluster_uuid }
-
- it { is_expected.to eq serverless_domain_cluster.uuid }
- end
-
- context 'with an invalid URI' do
- subject { invalid_finder.serverless_domain_cluster_uuid }
-
- it { is_expected.to be_nil }
- end
- end
-
- describe '#execute' do
- context 'with a valid URI' do
- let(:serverless_domain) do
- create(
- :serverless_domain,
- function_name: function_name,
- serverless_domain_cluster: serverless_domain_cluster,
- environment: environment
- )
- end
-
- subject { valid_finder.execute }
-
- it 'has the correct function_name' do
- expect(subject.function_name).to eq function_name
- end
-
- it 'has the correct serverless_domain_cluster' do
- expect(subject.serverless_domain_cluster).to eq serverless_domain_cluster
- end
-
- it 'has the correct environment' do
- expect(subject.environment).to eq environment
- end
- end
-
- context 'with an invalid URI' do
- subject { invalid_finder.execute }
-
- it { is_expected.to be_nil }
- end
- end
-end
diff --git a/spec/finders/snippets_finder_spec.rb b/spec/finders/snippets_finder_spec.rb
index 48880ec2c1f..9f4b7612be5 100644
--- a/spec/finders/snippets_finder_spec.rb
+++ b/spec/finders/snippets_finder_spec.rb
@@ -237,25 +237,28 @@ RSpec.describe SnippetsFinder do
it 'returns all personal snippets for the admin' do
snippets = described_class.new(admin, only_personal: true).execute
- expect(snippets).to contain_exactly(admin_private_personal_snippet,
- private_personal_snippet,
- internal_personal_snippet,
- public_personal_snippet)
+ expect(snippets).to contain_exactly(
+ admin_private_personal_snippet,
+ private_personal_snippet,
+ internal_personal_snippet,
+ public_personal_snippet
+ )
end
it 'returns only personal snippets visible by user' do
snippets = described_class.new(user, only_personal: true).execute
- expect(snippets).to contain_exactly(private_personal_snippet,
- internal_personal_snippet,
- public_personal_snippet)
+ expect(snippets).to contain_exactly(
+ private_personal_snippet,
+ internal_personal_snippet,
+ public_personal_snippet
+ )
end
it 'returns only internal or public personal snippets for user without snippets' do
snippets = described_class.new(user_without_snippets, only_personal: true).execute
- expect(snippets).to contain_exactly(internal_personal_snippet,
- public_personal_snippet)
+ expect(snippets).to contain_exactly(internal_personal_snippet, public_personal_snippet)
end
end
end
diff --git a/spec/finders/template_finder_spec.rb b/spec/finders/template_finder_spec.rb
index 21fea7863ff..c466f533a61 100644
--- a/spec/finders/template_finder_spec.rb
+++ b/spec/finders/template_finder_spec.rb
@@ -103,6 +103,10 @@ RSpec.describe TemplateFinder do
describe '#build' do
let(:project) { build_stubbed(:project) }
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
where(:type, :expected_class) do
:dockerfiles | described_class
:gitignores | described_class
@@ -119,6 +123,16 @@ RSpec.describe TemplateFinder do
it { is_expected.to be_a(expected_class) }
it { expect(finder.project).to eq(project) }
end
+
+ context 'when metrics dashboard is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ subject(:finder) { described_class.build(:metrics_dashboard_ymls, project) }
+
+ it { is_expected.to be_nil }
+ end
end
describe '#execute' do
diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb
index 5cf845a87b2..2e94ca5757a 100644
--- a/spec/finders/users_finder_spec.rb
+++ b/spec/finders/users_finder_spec.rb
@@ -61,9 +61,11 @@ RSpec.describe UsersFinder do
filtered_user_before = create(:user, created_at: 3.days.ago)
filtered_user_after = create(:user, created_at: Time.now + 3.days)
- users = described_class.new(user,
- created_after: 2.days.ago,
- created_before: Time.now + 2.days).execute
+ users = described_class.new(
+ user,
+ created_after: 2.days.ago,
+ created_before: Time.now + 2.days
+ ).execute
expect(users.map(&:username)).not_to include([filtered_user_before.username, filtered_user_after.username])
end
diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json
index efc609b3c3f..0ef4d6f82a9 100644
--- a/spec/fixtures/api/schemas/cluster_status.json
+++ b/spec/fixtures/api/schemas/cluster_status.json
@@ -1,8 +1,7 @@
{
"type": "object",
"required": [
- "status",
- "applications"
+ "status"
],
"properties": {
"status": {
@@ -10,12 +9,6 @@
},
"status_reason": {
"$ref": "types/nullable_string.json"
- },
- "applications": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/application_status"
- }
}
},
"additionalProperties": false,
@@ -115,4 +108,4 @@
}
}
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/api/schemas/entities/diff_viewer.json b/spec/fixtures/api/schemas/entities/diff_viewer.json
index ae0fb32d3ac..b16f8d8b1a2 100644
--- a/spec/fixtures/api/schemas/entities/diff_viewer.json
+++ b/spec/fixtures/api/schemas/entities/diff_viewer.json
@@ -25,6 +25,12 @@
"type": [
"boolean"
]
+ },
+ "whitespace_only": {
+ "type": [
+ "boolean",
+ "null"
+ ]
}
},
"additionalProperties": false
diff --git a/spec/fixtures/api/schemas/entities/discussion.json b/spec/fixtures/api/schemas/entities/discussion.json
index 45271926547..f91571fbc48 100644
--- a/spec/fixtures/api/schemas/entities/discussion.json
+++ b/spec/fixtures/api/schemas/entities/discussion.json
@@ -103,6 +103,9 @@
"noteable_type": {
"type": "string"
},
+ "project_id": {
+ "type": "integer"
+ },
"resolved": {
"type": "boolean"
},
@@ -191,6 +194,12 @@
"boolean",
"null"
]
+ },
+ "external_author": {
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"required": [
@@ -207,4 +216,4 @@
}
}
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/api/schemas/internal/pages/lookup_path.json b/spec/fixtures/api/schemas/internal/pages/lookup_path.json
index 9d81ea495f1..fba3efc4ded 100644
--- a/spec/fixtures/api/schemas/internal/pages/lookup_path.json
+++ b/spec/fixtures/api/schemas/internal/pages/lookup_path.json
@@ -8,22 +8,62 @@
"prefix"
],
"properties": {
- "project_id": { "type": "integer" },
- "https_only": { "type": "boolean" },
- "access_control": { "type": "boolean" },
- "source": { "type": "object",
- "required": ["type", "path"],
- "properties" : {
- "type": { "type": "string", "enum": ["file", "zip"] },
- "path": { "type": "string" },
- "global_id": { "type": "string" },
- "sha256": { "type": "string" },
- "file_size": { "type": "integer" },
- "file_count": { "type": ["integer", "null"] }
+ "project_id": {
+ "type": "integer"
+ },
+ "https_only": {
+ "type": "boolean"
+ },
+ "access_control": {
+ "type": "boolean"
+ },
+ "source": {
+ "type": "object",
+ "required": [
+ "type",
+ "path"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "file",
+ "zip"
+ ]
+ },
+ "path": {
+ "type": "string"
+ },
+ "global_id": {
+ "type": "string"
+ },
+ "sha256": {
+ "type": "string"
+ },
+ "file_size": {
+ "type": "integer"
+ },
+ "file_count": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
},
"additionalProperties": false
},
- "prefix": { "type": "string" }
+ "prefix": {
+ "type": "string"
+ },
+ "unique_host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "root_directory": {
+ "type": "string"
+ }
},
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/notes.json b/spec/fixtures/api/schemas/public_api/v4/notes.json
index 1987a0f2f71..60d6bb90b79 100644
--- a/spec/fixtures/api/schemas/public_api/v4/notes.json
+++ b/spec/fixtures/api/schemas/public_api/v4/notes.json
@@ -78,6 +78,9 @@
"noteable_type": {
"type": "string"
},
+ "project_id": {
+ "type": "integer"
+ },
"resolved": {
"type": "boolean"
},
@@ -122,4 +125,4 @@
],
"additionalProperties": false
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/auth_key.p8 b/spec/fixtures/auth_key.p8
new file mode 100644
index 00000000000..1b53126536e
--- /dev/null
+++ b/spec/fixtures/auth_key.p8
@@ -0,0 +1,16 @@
+-----BEGIN PRIVATE KEY-----
+MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKS+CfS9GcRSdYSN
+SzyH5QJQBr5umRL6E+KilOV39iYFO/9oHjUdapTRWkrwnNPCp7qaeck4Jr8iv14t
+PVNDfNr76eGb6/3YknOAP0QOjLWunoC8kjU+N/JHU52NrUeX3qEy8EKV9LeCDJcB
+kBk+Yejn9nypg8c7sLsn33CB6i3bAgMBAAECgYA2D26w80T7WZvazYr86BNMePpd
+j2mIAqx32KZHzt/lhh40J/SRtX9+Kl0Y7nBoRR5Ja9u/HkAIxNxLiUjwg9r6cpg/
+uITEF5nMt7lAk391BuI+7VOZZGbJDsq2ulPd6lO+C8Kq/PI/e4kXcIjeH6KwQsuR
+5vrXfBZ3sQfflaiN4QJBANBt8JY2LIGQF8o89qwUpRL5vbnKQ4IzZ5+TOl4RLR7O
+AQpJ81tGuINghO7aunctb6rrcKJrxmEH1whzComybrMCQQDKV49nOBudRBAIgG4K
+EnLzsRKISUHMZSJiYTYnablof8cKw1JaQduw7zgrUlLwnroSaAGX88+Jw1f5n2Lh
+Vlg5AkBDdUGnrDLtYBCDEQYZHblrkc7ZAeCllDOWjxUV+uMqlCv8A4Ey6omvY57C
+m6I8DkWVAQx8VPtozhvHjUw80rZHAkB55HWHAM3h13axKG0htCt7klhPsZHpx6MH
+EPjGlXIT+aW2XiPmK3ZlCDcWIenE+lmtbOpI159Wpk8BGXs/s/xBAkEAlAY3ymgx
+63BDJEwvOb2IaP8lDDxNsXx9XJNVvQbv5n15vNsLHbjslHfAhAbxnLQ1fLhUPqSi
+nNp/xedE1YxutQ==
+-----END PRIVATE KEY-----
diff --git a/spec/fixtures/diagram.drawio.svg b/spec/fixtures/diagram.drawio.svg
new file mode 100644
index 00000000000..3eb6eb29921
--- /dev/null
+++ b/spec/fixtures/diagram.drawio.svg
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
+ width="177px" height="97px" viewBox="-0.5 -0.5 177 97"
+ content="&lt;mxfile host=&quot;embed.diagrams.net&quot; modified=&quot;2022-11-18T14:21:55.551Z&quot; agent=&quot;5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36&quot; version=&quot;20.5.3&quot; etag=&quot;cTK3wL1ch5_8VL-J45NP&quot; type=&quot;embed&quot;&gt;&lt;diagram id=&quot;mWELjHy14aEMRdjyCi3_&quot; name=&quot;Page-1&quot;&gt;jZLBcoQgDIafhrvItPVcu+1eevLQMyOpMAPGYbFqn75Ygq7d2ZmeSL4kkPyEidrNb14O+h0VWFYWambihZUlr554PFayJFI9FAl03ihK2kFjvoFgThuNgsshMSDaYIYjbLHvoQ0HJr3H6Zj2ifb46iA7uAFNK+0t/TAq6D9TrPwMptP5ZV5QxMmcTOCipcLpCokTE7VHDMlycw12FS/rkupe70S3xjz04T8FZSr4knak2ZSRnZeO2gtLntnj2CtYywomnidtAjSDbNfoFH85Mh2cjR6PJt0KPsB8tzO+zRsXBdBB8EtMoQKRNaMdiSD50644fySmr9SuiEn65G67etchGiRFdnfJf2NXiytOPw==&lt;/diagram&gt;&lt;/mxfile&gt;"
+ style="background-color: rgb(255, 255, 255);">
+ <defs />
+ <g>
+ <rect x="8" y="8" width="160" height="80" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)"
+ pointer-events="all" />
+ <g transform="translate(-0.5 -0.5)">
+ <switch>
+ <foreignObject pointer-events="none" width="100%" height="100%"
+ requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"
+ style="overflow: visible; text-align: left;">
+ <div xmlns="http://www.w3.org/1999/xhtml"
+ style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 48px; margin-left: 9px;">
+ <div data-drawio-colors="color: rgb(0, 0, 0); "
+ style="box-sizing: border-box; font-size: 0px; text-align: center;">
+ <div
+ style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
+ diagram</div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="88" y="52" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px"
+ text-anchor="middle">diagram</text>
+ </switch>
+ </g>
+ </g>
+ <switch>
+ <g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" />
+ <a transform="translate(0,-5)"
+ xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank">
+ <text text-anchor="middle" font-size="10px" x="50%" y="100%">Text is not SVG - cannot display</text>
+ </a>
+ </switch>
+</svg>
diff --git a/spec/fixtures/emails/valid_reply_with_references_in_comma.eml b/spec/fixtures/emails/valid_reply_with_references_in_comma.eml
new file mode 100644
index 00000000000..4a2d213f4cc
--- /dev/null
+++ b/spec/fixtures/emails/valid_reply_with_references_in_comma.eml
@@ -0,0 +1,42 @@
+Return-Path: <jake@example.com>
+Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
+Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
+Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
+Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
+Date: Thu, 13 Jun 2013 17:03:48 -0400
+From: Jake the Dog <jake@example.com>
+To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo
+Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
+In-Reply-To: <issue_1@localhost>
+References: "<reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>,<issue_1@localhost>,<exchange@microsoft.com>"
+Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux'
+Mime-Version: 1.0
+Content-Type: text/plain;
+ charset=ISO-8859-1
+Content-Transfer-Encoding: 7bit
+X-Sieve: CMU Sieve 2.2
+X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu,
+ 13 Jun 2013 14:03:48 -0700 (PDT)
+X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1
+
+I could not disagree more. I am obviously biased but adventure time is the
+greatest show ever created. Everyone should watch it.
+
+- Jake out
+
+
+On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta
+<reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo> wrote:
+>
+>
+>
+> eviltrout posted in 'Adventure Time Sux' on Discourse Meta:
+>
+> ---
+> hey guys everyone knows adventure time sucks!
+>
+> ---
+> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3
+>
+> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences).
+>
diff --git a/spec/fixtures/gitlab/import_export/corrupted_project_export.tar.gz b/spec/fixtures/gitlab/import_export/corrupted_project_export.tar.gz
index d6632c5121a..1ecfa5a80f9 100644
--- a/spec/fixtures/gitlab/import_export/corrupted_project_export.tar.gz
+++ b/spec/fixtures/gitlab/import_export/corrupted_project_export.tar.gz
Binary files differ
diff --git a/spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gz b/spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gz
index e5f6f195fe5..71a0ade3eba 100644
--- a/spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gz
+++ b/spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gz
Binary files differ
diff --git a/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event.yml b/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event.yml
index 704e94a04d8..1c1ad65796c 100644
--- a/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event.yml
+++ b/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event.yml
@@ -13,7 +13,6 @@ identifiers:
product_section:
product_stage:
product_group:
-product_category:
milestone: "13.11"
introduced_by_url:
distributions:
diff --git a/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event_ee.yml b/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event_ee.yml
index b20bb9702d2..174468028b8 100644
--- a/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event_ee.yml
+++ b/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event_ee.yml
@@ -13,7 +13,6 @@ identifiers:
product_section:
product_stage:
product_group:
-product_category:
milestone: "13.11"
introduced_by_url:
distributions:
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 520328f1041..42f9cc31c3a 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
@@ -5,7 +5,6 @@ description:
product_section:
product_stage:
product_group:
-product_category:
value_type: number
status: active
milestone: "13.9"
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 1942f33e043..e123056d771 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
@@ -5,7 +5,6 @@ description:
product_section:
product_stage:
product_group:
-product_category:
value_type: number
status: active
milestone: "13.9"
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 a72ba5109cc..87c4e68f19e 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
@@ -6,7 +6,6 @@ description:
product_section:
product_stage:
product_group:
-product_category:
value_type: number
status: active
milestone: "13.9"
diff --git a/spec/fixtures/lib/gitlab/email/basic.html b/spec/fixtures/lib/gitlab/email/basic.html
index 807b23c46e3..8c2c4c116b8 100644
--- a/spec/fixtures/lib/gitlab/email/basic.html
+++ b/spec/fixtures/lib/gitlab/email/basic.html
@@ -7,17 +7,17 @@
Even though it has whitespace and newlines, the e-mail converter
will handle it correctly.
- <p><em>Even</em> mismatched tags.</p>
+ <p><em class="class" style="color:red" title="strong">Even</em> mismatched tags.</p>
<div>A div</div>
<div>Another div</div>
- <div>A div<div><strong>within</strong> a div</div></div>
+ <div>A div<div><strong class="class" style="color:red" title="strong">within</strong> a div</div></div>
<p>Another line<br />Yet another line</p>
<a href="http://foo.com">A link</a>
- <p><details><summary>One</summary>Some details</details></p>
+ <p><details class="class" style="color:red" title="strong"><summary>One</summary>Some details</details></p>
<p><details><summary>Two</summary>Some details</details></p>
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json
index 0bca7b0f494..8a307af1ca7 100644
--- a/spec/fixtures/lib/gitlab/import_export/complex/project.json
+++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json
@@ -45,6 +45,12 @@
]
}
],
+ "design_management_repository": {
+ "id": 500,
+ "project_id": 30,
+ "created_at": "2019-08-07T03:57:55.007Z",
+ "updated_at": "2019-08-07T03:57:55.007Z"
+ },
"issues": [
{
"id": 40,
@@ -7327,7 +7333,7 @@
"status": 1,
"created_at": "2016-03-22T15:44:44.772Z",
"updated_at": "2016-03-29T06:44:44.634Z",
- "statuses": [
+ "builds": [
{
"id": 71,
"project_id": 5,
@@ -7364,7 +7370,41 @@
"artifacts_file_store": 1,
"artifacts_metadata_store": 1,
"artifacts_size": 10
- },
+ }
+ ],
+ "bridges": [
+ {
+ "id": 72,
+ "project_id": 5,
+ "status": "success",
+ "finished_at": null,
+ "trace": "Porro ea qui ut dolores. Labore ab nemo explicabo aspernatur quis voluptates corporis. Et quasi delectus est sit aperiam perspiciatis asperiores. Repudiandae cum aut consectetur accusantium officia sunt.\n\nQuidem dolore iusto quaerat ut aut inventore et molestiae. Libero voluptates atque nemo qui. Nulla temporibus ipsa similique facere.\n\nAliquam ipsam perferendis qui fugit accusantium omnis id voluptatum. Dignissimos aliquid dicta eos voluptatem assumenda quia. Sed autem natus unde dolor et non nisi et. Consequuntur nihil consequatur rerum est.\n\nSimilique neque est iste ducimus qui fuga cupiditate. Libero autem est aut fuga. Consectetur natus quis non ducimus ut dolore. Magni voluptatibus eius et maxime aut.\n\nAd officiis tempore voluptate vitae corrupti explicabo labore est. Consequatur expedita et sunt nihil aut. Deleniti porro iusto molestiae et beatae.\n\nDeleniti modi nulla qui et labore sequi corrupti. Qui voluptatem assumenda eum cupiditate et. Nesciunt ipsam ut ea possimus eum. Consectetur quidem suscipit atque dolore itaque voluptatibus et cupiditate.",
+ "created_at": "2016-03-22T15:20:35.777Z",
+ "updated_at": "2016-03-22T15:20:35.777Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 36,
+ "commands": "$ deploy command",
+ "job_id": null,
+ "name": "test build 2",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "deploy",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "stage_id": 12,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ }
+ ],
+ "generic_commit_statuses": [
{
"id": 72,
"project_id": 5,
@@ -7435,7 +7475,7 @@
"status": 1,
"created_at": "2016-03-22T15:44:44.772Z",
"updated_at": "2016-03-29T06:44:44.634Z",
- "statuses": [
+ "builds": [
{
"id": 74,
"project_id": 5,
@@ -7549,7 +7589,7 @@
"status": 1,
"created_at": "2016-03-22T15:44:44.772Z",
"updated_at": "2016-03-29T06:44:44.634Z",
- "statuses": [
+ "builds": [
{
"id": 76,
"project_id": 5,
@@ -7637,7 +7677,7 @@
"status": 1,
"created_at": "2016-03-22T15:44:44.772Z",
"updated_at": "2016-03-29T06:44:44.634Z",
- "statuses": [
+ "builds": [
{
"id": 78,
"project_id": 5,
@@ -7843,6 +7883,95 @@
}
}
],
+ "commit_notes": [
+ {
+ "note": "Commit note 1",
+ "noteable_type": "Commit",
+ "author_id": 1,
+ "created_at": "2023-01-30T19:27:36.585Z",
+ "updated_at": "2023-02-10T14:43:01.308Z",
+ "project_id": 5,
+ "attachment": {
+ "url": null
+ },
+ "line_code": null,
+ "commit_id": "sha-notes",
+ "system": false,
+ "st_diff": null,
+ "updated_by_id": 1,
+ "type": null,
+ "position": null,
+ "original_position": null,
+ "resolved_at": null,
+ "resolved_by_id": null,
+ "discussion_id": "e3fde7d585c6467a7a5147e83617eb6daa61aaf4",
+ "change_position": null,
+ "resolved_by_push": null,
+ "confidential": null,
+ "last_edited_at": "2023-02-10T14:43:01.306Z",
+ "author": {
+ "name": "Administrator"
+ },
+ "events": [
+ {
+ "project_id": 1,
+ "author_id": 1,
+ "created_at": "2023-01-30T19:27:36.815Z",
+ "updated_at": "2023-01-30T19:27:36.815Z",
+ "action": "commented",
+ "target_type": "Note",
+ "fingerprint": null,
+ "push_event_payload": {
+ "commit_count": 1,
+ "action": "pushed",
+ "ref_type": "branch",
+ "commit_to": "sha-notes",
+ "ref": "master"
+ }
+ }
+ ]
+ },
+ {
+ "note": "Commit note 2",
+ "noteable_type": "Commit",
+ "author_id": 1,
+ "created_at": "2023-02-10T14:44:08.138Z",
+ "updated_at": "2023-02-10T14:54:42.828Z",
+ "project_id": 1,
+ "attachment": {
+ "url": null
+ },
+ "line_code": null,
+ "commit_id": "sha-notes",
+ "system": false,
+ "st_diff": null,
+ "updated_by_id": 1,
+ "type": null,
+ "position": null,
+ "original_position": null,
+ "resolved_at": null,
+ "resolved_by_id": null,
+ "discussion_id": "53ca55a01732aff4f17daecdf076853f4ab152eb",
+ "change_position": null,
+ "resolved_by_push": null,
+ "confidential": null,
+ "last_edited_at": "2023-02-10T14:54:42.827Z",
+ "author": {
+ "name": "Administrator"
+ },
+ "events": [
+ {
+ "project_id": 1,
+ "author_id": 1,
+ "created_at": "2023-02-10T16:37:16.659Z",
+ "updated_at": "2023-02-10T16:37:16.659Z",
+ "action": "commented",
+ "target_type": "Note",
+ "fingerprint": null
+ }
+ ]
+ }
+ ],
"pipeline_schedules": [
{
"id": 1,
@@ -8186,5 +8315,38 @@
"reject_unsigned_commits": true,
"commit_committer_check": true,
"regexp_uses_re2": true
- }
+ },
+ "approval_rules": [
+ {
+ "approvals_required": 1,
+ "name": "MustContain",
+ "rule_type": "regular",
+ "scanners": [
+
+ ],
+ "vulnerabilities_allowed": 0,
+ "severity_levels": [
+ "unknown",
+ "high",
+ "critical"
+ ],
+ "report_type": null,
+ "vulnerability_states": [
+ "newly_detected"
+ ],
+ "orchestration_policy_idx": null,
+ "applies_to_all_protected_branches": false,
+ "approval_project_rules_protected_branches": [
+ {
+ "protected_branch_id": 1,
+ "branch_name": "master"
+ }
+ ],
+ "approval_project_rules_users": [
+ {
+ "user_id": 35
+ }
+ ]
+ }
+ ]
}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_pipelines.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_pipelines.ndjson
index cadaa5abfcd..348a01372ab 100644
--- a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_pipelines.ndjson
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_pipelines.ndjson
@@ -1,7 +1,7 @@
{"id":19,"project_id":5,"ref":"master","sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.763Z","updated_at":"2016-03-22T15:20:35.763Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[{"id":24,"project_id":5,"pipeline_id":40,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":79,"project_id":5,"status":"failed","finished_at":"2016-03-29T06:28:12.695Z","trace":"Sed culpa est et facere saepe vel id ab. Quas temporibus aut similique dolorem consequatur corporis aut praesentium. Cum officia molestiae sit earum excepturi.\n\nSint possimus aut ratione quia. Quis nesciunt ratione itaque illo. Tenetur est dolor assumenda possimus voluptatem quia minima. Accusamus reprehenderit ut et itaque non reiciendis incidunt.\n\nRerum suscipit quibusdam dolore nam omnis. Consequatur ipsa nihil ut enim blanditiis delectus. Nulla quis hic occaecati mollitia qui placeat. Quo rerum sed perferendis a accusantium consequatur commodi ut. Sit quae et cumque vel eius tempora nostrum.\n\nUllam dolorem et itaque sint est. Ea molestias quia provident dolorem vitae error et et. Ea expedita officiis iste non. Qui vitae odit saepe illum. Dolores enim ratione deserunt tempore expedita amet non neque.\n\nEligendi asperiores voluptatibus omnis repudiandae expedita distinctio qui aliquid. Autem aut doloremque distinctio ab. Nostrum sapiente repudiandae aspernatur ea et quae voluptas. Officiis perspiciatis nisi laudantium asperiores error eligendi ab. Eius quia amet magni omnis exercitationem voluptatum et.\n\nVoluptatem ullam labore quas dicta est ex voluptas. Pariatur ea modi voluptas consequatur dolores perspiciatis similique. Numquam in distinctio perspiciatis ut qui earum. Quidem omnis mollitia facere aut beatae. Ea est iure et voluptatem.","created_at":"2016-03-22T15:20:35.950Z","updated_at":"2016-03-29T06:28:12.696Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":40,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":80,"project_id":5,"status":"success","finished_at":null,"trace":"Impedit et optio nemo ipsa. Non ad non quis ut sequi laudantium omnis velit. Corporis a enim illo eos. Quia totam tempore inventore ad est.\n\nNihil recusandae cupiditate eaque voluptatem molestias sint. Consequatur id voluptatem cupiditate harum. Consequuntur iusto quaerat reiciendis aut autem libero est. Quisquam dolores veritatis rerum et sint maxime ullam libero. Id quas porro ut perspiciatis rem amet vitae.\n\nNemo inventore minus blanditiis magnam. Modi consequuntur nostrum aut voluptatem ex. Sunt rerum rem optio mollitia qui aliquam officiis officia. Aliquid eos et id aut minus beatae reiciendis.\n\nDolores non in temporibus dicta. Fugiat voluptatem est aspernatur expedita voluptatum nam qui. Quia et eligendi sit quae sint tempore exercitationem eos. Est sapiente corrupti quidem at. Qui magni odio repudiandae saepe tenetur optio dolore.\n\nEos placeat soluta at dolorem adipisci provident. Quo commodi id reprehenderit possimus quo tenetur. Ipsum et quae eligendi laborum. Et qui nesciunt at quasi quidem voluptatem cum rerum. Excepturi non facilis aut sunt vero sed.\n\nQui explicabo ratione ut eligendi recusandae. Quis quasi quas molestiae consequatur voluptatem et voluptatem. Ex repellat saepe occaecati aperiam ea eveniet dignissimos facilis.","created_at":"2016-03-22T15:20:35.966Z","updated_at":"2016-03-22T15:20:35.966Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":40,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}]}
{"id":20,"project_id":5,"ref":"master","sha":"ce84140e8b878ce6e7c4d298c7202ff38170e3ac","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.763Z","updated_at":"2016-03-22T15:20:35.763Z","tag":false,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[],"source":"external_pull_request_event","external_pull_request":{"id":3,"pull_request_iid":4,"source_branch":"feature","target_branch":"master","source_repository":"the-repository","target_repository":"the-repository","source_sha":"ce84140e8b878ce6e7c4d298c7202ff38170e3ac","target_sha":"a09386439ca39abe575675ffd4b89ae824fec22f","status":"open","created_at":"2016-03-22T15:20:35.763Z","updated_at":"2016-03-22T15:20:35.763Z"}}
-{"id":26,"project_id":5,"ref":"master","sha":"048721d90c449b244b7b4c53a9186b04330174ec","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.757Z","updated_at":"2016-03-22T15:20:35.757Z","tag":false,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"source":"merge_request_event","merge_request_id":27,"stages":[{"id":21,"project_id":5,"pipeline_id":37,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":74,"project_id":5,"status":"success","finished_at":null,"trace":"Ad ut quod repudiandae iste dolor doloribus. Adipisci consequuntur deserunt omnis quasi eveniet et sed fugit. Aut nemo omnis molestiae impedit ex consequatur ducimus. Voluptatum exercitationem quia aut est et hic dolorem.\n\nQuasi repellendus et eaque magni eum facilis. Dolorem aperiam nam nihil pariatur praesentium ad aliquam. Commodi enim et eos tenetur. Odio voluptatibus laboriosam mollitia rerum exercitationem magnam consequuntur. Tenetur ea vel eum corporis.\n\nVoluptatibus optio in aliquid est voluptates. Ad a ut ab placeat vero blanditiis. Earum aspernatur quia beatae expedita voluptatem dignissimos provident. Quis minima id nemo ut aut est veritatis provident.\n\nRerum voluptatem quidem eius maiores magnam veniam. Voluptatem aperiam aut voluptate et nulla deserunt voluptas. Quaerat aut accusantium laborum est dolorem architecto reiciendis. Aliquam asperiores doloribus omnis maxime enim nesciunt. Eum aut rerum repellendus debitis et ut eius.\n\nQuaerat assumenda ea sit consequatur autem in. Cum eligendi voluptatem quo sed. Ut fuga iusto cupiditate autem sint.\n\nOfficia totam officiis architecto corporis molestiae amet ut. Tempora sed dolorum rerum omnis voluptatem accusantium sit eum. Quia debitis ipsum quidem aliquam inventore sunt consequatur qui.","created_at":"2016-03-22T15:20:35.846Z","updated_at":"2016-03-22T15:20:35.846Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":37,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":73,"project_id":5,"status":"canceled","finished_at":null,"trace":null,"created_at":"2016-03-22T15:20:35.842Z","updated_at":"2016-03-22T15:20:35.842Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":37,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}],"merge_request":{"id":27,"target_branch":"feature","source_branch":"feature_conflict","source_project_id":2147483547,"author_id":1,"assignee_id":null,"title":"MR1","created_at":"2016-06-14T15:02:36.568Z","updated_at":"2016-06-14T15:02:56.815Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":9,"description":null,"position":0,"updated_by_id":null,"merge_error":null,"diff_head_sha":"HEAD","source_branch_sha":"ABCD","target_branch_sha":"DCBA","merge_params":{"force_remove_source_branch":null}}}
-{"id":36,"project_id":5,"ref":null,"sha":"sha-notes","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.755Z","updated_at":"2016-03-22T15:20:35.755Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"user_id":2147483547,"duration":null,"source":"push","merge_request_id":null,"pipeline_metadata": {"id": 2, "project_id": 5, "pipeline_id": 36, "name": "Build pipeline"},"notes":[{"id":2147483547,"note":"Natus rerum qui dolorem dolorum voluptas.","noteable_type":"Commit","author_id":1,"created_at":"2016-03-22T15:19:59.469Z","updated_at":"2016-03-22T15:19:59.469Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":"be93687618e4b132087f430a4d8fc3a609c9b77c","noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"}}],"stages":[{"id":11,"project_id":5,"pipeline_id":36,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":71,"project_id":5,"status":"failed","finished_at":"2016-03-29T06:28:12.630Z","trace":null,"created_at":"2016-03-22T15:20:35.772Z","updated_at":"2016-03-29T06:28:12.634Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":36,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":{"image":"busybox:latest"},"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"stage_id":11,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null,"type":"Ci::Build","token":"abcd","artifacts_file_store":1,"artifacts_metadata_store":1,"artifacts_size":10},{"id":72,"project_id":5,"status":"success","finished_at":null,"trace":"Porro ea qui ut dolores. Labore ab nemo explicabo aspernatur quis voluptates corporis. Et quasi delectus est sit aperiam perspiciatis asperiores. Repudiandae cum aut consectetur accusantium officia sunt.\n\nQuidem dolore iusto quaerat ut aut inventore et molestiae. Libero voluptates atque nemo qui. Nulla temporibus ipsa similique facere.\n\nAliquam ipsam perferendis qui fugit accusantium omnis id voluptatum. Dignissimos aliquid dicta eos voluptatem assumenda quia. Sed autem natus unde dolor et non nisi et. Consequuntur nihil consequatur rerum est.\n\nSimilique neque est iste ducimus qui fuga cupiditate. Libero autem est aut fuga. Consectetur natus quis non ducimus ut dolore. Magni voluptatibus eius et maxime aut.\n\nAd officiis tempore voluptate vitae corrupti explicabo labore est. Consequatur expedita et sunt nihil aut. Deleniti porro iusto molestiae et beatae.\n\nDeleniti modi nulla qui et labore sequi corrupti. Qui voluptatem assumenda eum cupiditate et. Nesciunt ipsam ut ea possimus eum. Consectetur quidem suscipit atque dolore itaque voluptatibus et cupiditate.","created_at":"2016-03-22T15:20:35.777Z","updated_at":"2016-03-22T15:20:35.777Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":36,"commands":"$ deploy command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"deploy","trigger_request_id":null,"stage_idx":1,"stage_id":12,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]},{"id":12,"project_id":5,"pipeline_id":36,"name":"deploy","status":2,"created_at":"2016-03-22T15:45:45.772Z","updated_at":"2016-03-29T06:45:45.634Z"}]}
-{"id":38,"iid":1,"project_id":5,"ref":"master","sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.759Z","updated_at":"2016-03-22T15:20:35.759Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[{"id":22,"project_id":5,"pipeline_id":38,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":76,"project_id":5,"status":"success","finished_at":null,"trace":"Et rerum quia ea cumque ut modi non. Libero eaque ipsam architecto maiores expedita deleniti. Ratione quia qui est id.\n\nQuod sit officiis sed unde inventore veniam quisquam velit. Ea harum cum quibusdam quisquam minima quo possimus non. Temporibus itaque aliquam aut rerum veritatis at.\n\nMagnam ipsum eius recusandae qui quis sit maiores eum. Et animi iusto aut itaque. Doloribus harum deleniti nobis accusantium et libero.\n\nRerum fuga perferendis magni commodi officiis id repudiandae. Consequatur ratione consequatur suscipit facilis sunt iure est dicta. Qui unde quasi facilis et quae nesciunt. Magnam iste et nobis officiis tenetur. Aspernatur quo et temporibus non in.\n\nNisi rerum velit est ad enim sint molestiae consequuntur. Quaerat nisi nesciunt quasi officiis. Possimus non blanditiis laborum quos.\n\nRerum laudantium facere animi qui. Ipsa est iusto magnam nihil. Enim omnis occaecati non dignissimos ut recusandae eum quasi. Qui maxime dolor et nemo voluptates incidunt quia.","created_at":"2016-03-22T15:20:35.882Z","updated_at":"2016-03-22T15:20:35.882Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":38,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":75,"project_id":5,"status":"failed","finished_at":null,"trace":"Sed et iste recusandae dicta corporis. Sunt alias porro fugit sunt. Fugiat omnis nihil dignissimos aperiam explicabo doloremque sit aut. Harum fugit expedita quia rerum ut consequatur laboriosam aliquam.\n\nNatus libero ut ut tenetur earum. Tempora omnis autem omnis et libero dolores illum autem. Deleniti eos sunt mollitia ipsam. Cum dolor repellendus dolorum sequi officia. Ullam sunt in aut pariatur excepturi.\n\nDolor nihil debitis et est eos. Cumque eos eum saepe ducimus autem. Alias architecto consequatur aut pariatur possimus. Aut quos aut incidunt quam velit et. Quas voluptatum ad dolorum dignissimos.\n\nUt voluptates consectetur illo et. Est commodi accusantium vel quo. Eos qui fugiat soluta porro.\n\nRatione possimus alias vel maxime sint totam est repellat. Ipsum corporis eos sint voluptatem eos odit. Temporibus libero nulla harum eligendi labore similique ratione magnam. Suscipit sequi in omnis neque.\n\nLaudantium dolor amet omnis placeat mollitia aut molestiae. Aut rerum similique ipsum quod illo quas unde. Sunt aut veritatis eos omnis porro. Rem veritatis mollitia praesentium dolorem. Consequatur sequi ad cumque earum omnis quia necessitatibus.","created_at":"2016-03-22T15:20:35.864Z","updated_at":"2016-03-22T15:20:35.864Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":38,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}]}
-{"id":39,"project_id":5,"ref":"master","sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.761Z","updated_at":"2016-03-22T15:20:35.761Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[{"id":23,"project_id":5,"pipeline_id":39,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":78,"project_id":5,"status":"success","finished_at":null,"trace":"Dolorem deserunt quas quia error hic quo cum vel. Natus voluptatem cumque expedita numquam odit. Eos expedita nostrum corporis consequatur est recusandae.\n\nCulpa blanditiis rerum repudiandae alias voluptatem. Velit iusto est ullam consequatur doloribus porro. Corporis voluptas consectetur est veniam et quia quae.\n\nEt aut magni fuga nesciunt officiis molestias. Quaerat et nam necessitatibus qui rerum. Architecto quia officiis voluptatem laborum est recusandae. Quasi ducimus soluta odit necessitatibus labore numquam dignissimos. Quia facere sint temporibus inventore sunt nihil saepe dolorum.\n\nFacere dolores quis dolores a. Est minus nostrum nihil harum. Earum laborum et ipsum unde neque sit nemo. Corrupti est consequatur minima fugit. Illum voluptatem illo error ducimus officia qui debitis.\n\nDignissimos porro a autem harum aut. Aut id reprehenderit et exercitationem. Est et quisquam ipsa temporibus molestiae. Architecto natus dolore qui fugiat incidunt. Autem odit veniam excepturi et voluptatibus culpa ipsum eos.\n\nAmet quo quisquam dignissimos soluta modi dolores. Sint omnis eius optio corporis dolor. Eligendi animi porro quia placeat ut.","created_at":"2016-03-22T15:20:35.927Z","updated_at":"2016-03-22T15:20:35.927Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":39,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":77,"project_id":5,"status":"failed","finished_at":null,"trace":"Rerum ut et suscipit est perspiciatis. Inventore debitis cum eius vitae. Ex incidunt id velit aut quo nisi. Laboriosam repellat deserunt eius reiciendis architecto et. Est harum quos nesciunt nisi consectetur.\n\nAlias esse omnis sint officia est consequatur in nobis. Dignissimos dolorum vel eligendi nesciunt dolores sit. Veniam mollitia ducimus et exercitationem molestiae libero sed. Atque omnis debitis laudantium voluptatibus qui. Repellendus tempore est commodi pariatur.\n\nExpedita voluptate illum est alias non. Modi nesciunt ab assumenda laborum nulla consequatur molestias doloremque. Magnam quod officia vel explicabo accusamus ut voluptatem incidunt. Rerum ut aliquid ullam saepe. Est eligendi debitis beatae blanditiis reiciendis.\n\nQui fuga sit dolores libero maiores et suscipit. Consectetur asperiores omnis minima impedit eos fugiat. Similique omnis nisi sed vero inventore ipsum aliquam exercitationem.\n\nBlanditiis magni iure dolorum omnis ratione delectus molestiae. Atque officia dolor voluptatem culpa quod. Incidunt suscipit quidem possimus veritatis non vel. Iusto aliquid et id quia quasi.\n\nVel facere velit blanditiis incidunt cupiditate sed maiores consequuntur. Quasi quia dicta consequuntur et quia voluptatem iste id. Incidunt et rerum fuga esse sint.","created_at":"2016-03-22T15:20:35.905Z","updated_at":"2016-03-22T15:20:35.905Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":39,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}]}
+{"id":26,"project_id":5,"ref":"master","sha":"048721d90c449b244b7b4c53a9186b04330174ec","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.757Z","updated_at":"2016-03-22T15:20:35.757Z","tag":false,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"source":"merge_request_event","merge_request_id":27,"stages":[{"id":21,"project_id":5,"pipeline_id":37,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","builds":[{"id":74,"project_id":5,"status":"success","finished_at":null,"trace":"Ad ut quod repudiandae iste dolor doloribus. Adipisci consequuntur deserunt omnis quasi eveniet et sed fugit. Aut nemo omnis molestiae impedit ex consequatur ducimus. Voluptatum exercitationem quia aut est et hic dolorem.\n\nQuasi repellendus et eaque magni eum facilis. Dolorem aperiam nam nihil pariatur praesentium ad aliquam. Commodi enim et eos tenetur. Odio voluptatibus laboriosam mollitia rerum exercitationem magnam consequuntur. Tenetur ea vel eum corporis.\n\nVoluptatibus optio in aliquid est voluptates. Ad a ut ab placeat vero blanditiis. Earum aspernatur quia beatae expedita voluptatem dignissimos provident. Quis minima id nemo ut aut est veritatis provident.\n\nRerum voluptatem quidem eius maiores magnam veniam. Voluptatem aperiam aut voluptate et nulla deserunt voluptas. Quaerat aut accusantium laborum est dolorem architecto reiciendis. Aliquam asperiores doloribus omnis maxime enim nesciunt. Eum aut rerum repellendus debitis et ut eius.\n\nQuaerat assumenda ea sit consequatur autem in. Cum eligendi voluptatem quo sed. Ut fuga iusto cupiditate autem sint.\n\nOfficia totam officiis architecto corporis molestiae amet ut. Tempora sed dolorum rerum omnis voluptatem accusantium sit eum. Quia debitis ipsum quidem aliquam inventore sunt consequatur qui.","created_at":"2016-03-22T15:20:35.846Z","updated_at":"2016-03-22T15:20:35.846Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":37,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":73,"project_id":5,"status":"canceled","finished_at":null,"trace":null,"created_at":"2016-03-22T15:20:35.842Z","updated_at":"2016-03-22T15:20:35.842Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":37,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}],"merge_request":{"id":27,"target_branch":"feature","source_branch":"feature_conflict","source_project_id":2147483547,"author_id":1,"assignee_id":null,"title":"MR1","created_at":"2016-06-14T15:02:36.568Z","updated_at":"2016-06-14T15:02:56.815Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":9,"description":null,"position":0,"updated_by_id":null,"merge_error":null,"diff_head_sha":"HEAD","source_branch_sha":"ABCD","target_branch_sha":"DCBA","merge_params":{"force_remove_source_branch":null}}}
+{"id":36,"project_id":5,"ref":null,"sha":"sha-notes","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.755Z","updated_at":"2016-03-22T15:20:35.755Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"user_id":2147483547,"duration":null,"source":"push","merge_request_id":null,"pipeline_metadata": {"id": 2, "project_id": 5, "pipeline_id": 36, "name": "Build pipeline"},"notes":[{"id":2147483547,"note":"Natus rerum qui dolorem dolorum voluptas.","noteable_type":"Commit","author_id":1,"created_at":"2016-03-22T15:19:59.469Z","updated_at":"2016-03-22T15:19:59.469Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":"be93687618e4b132087f430a4d8fc3a609c9b77c","noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"}}],"stages":[{"id":11,"project_id":5,"pipeline_id":36,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","builds":[{"id":71,"project_id":5,"status":"failed","finished_at":"2016-03-29T06:28:12.630Z","trace":null,"created_at":"2016-03-22T15:20:35.772Z","updated_at":"2016-03-29T06:28:12.634Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":36,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":{"image":"busybox:latest"},"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"stage_id":11,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null,"type":"Ci::Build","token":"abcd","artifacts_file_store":1,"artifacts_metadata_store":1,"artifacts_size":10}],"bridges":[{"id":72,"project_id":5,"status":"success","finished_at":null,"trace":"Porro ea qui ut dolores. Labore ab nemo explicabo aspernatur quis voluptates corporis. Et quasi delectus est sit aperiam perspiciatis asperiores. Repudiandae cum aut consectetur accusantium officia sunt.\n\nQuidem dolore iusto quaerat ut aut inventore et molestiae. Libero voluptates atque nemo qui. Nulla temporibus ipsa similique facere.\n\nAliquam ipsam perferendis qui fugit accusantium omnis id voluptatum. Dignissimos aliquid dicta eos voluptatem assumenda quia. Sed autem natus unde dolor et non nisi et. Consequuntur nihil consequatur rerum est.\n\nSimilique neque est iste ducimus qui fuga cupiditate. Libero autem est aut fuga. Consectetur natus quis non ducimus ut dolore. Magni voluptatibus eius et maxime aut.\n\nAd officiis tempore voluptate vitae corrupti explicabo labore est. Consequatur expedita et sunt nihil aut. Deleniti porro iusto molestiae et beatae.\n\nDeleniti modi nulla qui et labore sequi corrupti. Qui voluptatem assumenda eum cupiditate et. Nesciunt ipsam ut ea possimus eum. Consectetur quidem suscipit atque dolore itaque voluptatibus et cupiditate.","created_at":"2016-03-22T15:20:35.777Z","updated_at":"2016-03-22T15:20:35.777Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":36,"commands":"$ deploy command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"deploy","trigger_request_id":null,"stage_idx":1,"stage_id":12,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}], "generic_commit_statuses": [{"id":72,"project_id":5,"status":"success","finished_at":null,"trace":"Porro ea qui ut dolores. Labore ab nemo explicabo aspernatur quis voluptates corporis. Et quasi delectus est sit aperiam perspiciatis asperiores. Repudiandae cum aut consectetur accusantium officia sunt.\n\nQuidem dolore iusto quaerat ut aut inventore et molestiae. Libero voluptates atque nemo qui. Nulla temporibus ipsa similique facere.\n\nAliquam ipsam perferendis qui fugit accusantium omnis id voluptatum. Dignissimos aliquid dicta eos voluptatem assumenda quia. Sed autem natus unde dolor et non nisi et. Consequuntur nihil consequatur rerum est.\n\nSimilique neque est iste ducimus qui fuga cupiditate. Libero autem est aut fuga. Consectetur natus quis non ducimus ut dolore. Magni voluptatibus eius et maxime aut.\n\nAd officiis tempore voluptate vitae corrupti explicabo labore est. Consequatur expedita et sunt nihil aut. Deleniti porro iusto molestiae et beatae.\n\nDeleniti modi nulla qui et labore sequi corrupti. Qui voluptatem assumenda eum cupiditate et. Nesciunt ipsam ut ea possimus eum. Consectetur quidem suscipit atque dolore itaque voluptatibus et cupiditate.","created_at":"2016-03-22T15:20:35.777Z","updated_at":"2016-03-22T15:20:35.777Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":36,"commands":"$ deploy command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"deploy","trigger_request_id":null,"stage_idx":1,"stage_id":12,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]},{"id":12,"project_id":5,"pipeline_id":36,"name":"deploy","status":2,"created_at":"2016-03-22T15:45:45.772Z","updated_at":"2016-03-29T06:45:45.634Z"}]}
+{"id":38,"iid":1,"project_id":5,"ref":"master","sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.759Z","updated_at":"2016-03-22T15:20:35.759Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[{"id":22,"project_id":5,"pipeline_id":38,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","builds":[{"id":76,"project_id":5,"status":"success","finished_at":null,"trace":"Et rerum quia ea cumque ut modi non. Libero eaque ipsam architecto maiores expedita deleniti. Ratione quia qui est id.\n\nQuod sit officiis sed unde inventore veniam quisquam velit. Ea harum cum quibusdam quisquam minima quo possimus non. Temporibus itaque aliquam aut rerum veritatis at.\n\nMagnam ipsum eius recusandae qui quis sit maiores eum. Et animi iusto aut itaque. Doloribus harum deleniti nobis accusantium et libero.\n\nRerum fuga perferendis magni commodi officiis id repudiandae. Consequatur ratione consequatur suscipit facilis sunt iure est dicta. Qui unde quasi facilis et quae nesciunt. Magnam iste et nobis officiis tenetur. Aspernatur quo et temporibus non in.\n\nNisi rerum velit est ad enim sint molestiae consequuntur. Quaerat nisi nesciunt quasi officiis. Possimus non blanditiis laborum quos.\n\nRerum laudantium facere animi qui. Ipsa est iusto magnam nihil. Enim omnis occaecati non dignissimos ut recusandae eum quasi. Qui maxime dolor et nemo voluptates incidunt quia.","created_at":"2016-03-22T15:20:35.882Z","updated_at":"2016-03-22T15:20:35.882Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":38,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":75,"project_id":5,"status":"failed","finished_at":null,"trace":"Sed et iste recusandae dicta corporis. Sunt alias porro fugit sunt. Fugiat omnis nihil dignissimos aperiam explicabo doloremque sit aut. Harum fugit expedita quia rerum ut consequatur laboriosam aliquam.\n\nNatus libero ut ut tenetur earum. Tempora omnis autem omnis et libero dolores illum autem. Deleniti eos sunt mollitia ipsam. Cum dolor repellendus dolorum sequi officia. Ullam sunt in aut pariatur excepturi.\n\nDolor nihil debitis et est eos. Cumque eos eum saepe ducimus autem. Alias architecto consequatur aut pariatur possimus. Aut quos aut incidunt quam velit et. Quas voluptatum ad dolorum dignissimos.\n\nUt voluptates consectetur illo et. Est commodi accusantium vel quo. Eos qui fugiat soluta porro.\n\nRatione possimus alias vel maxime sint totam est repellat. Ipsum corporis eos sint voluptatem eos odit. Temporibus libero nulla harum eligendi labore similique ratione magnam. Suscipit sequi in omnis neque.\n\nLaudantium dolor amet omnis placeat mollitia aut molestiae. Aut rerum similique ipsum quod illo quas unde. Sunt aut veritatis eos omnis porro. Rem veritatis mollitia praesentium dolorem. Consequatur sequi ad cumque earum omnis quia necessitatibus.","created_at":"2016-03-22T15:20:35.864Z","updated_at":"2016-03-22T15:20:35.864Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":38,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}]}
+{"id":39,"project_id":5,"ref":"master","sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.761Z","updated_at":"2016-03-22T15:20:35.761Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[{"id":23,"project_id":5,"pipeline_id":39,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","builds":[{"id":78,"project_id":5,"status":"success","finished_at":null,"trace":"Dolorem deserunt quas quia error hic quo cum vel. Natus voluptatem cumque expedita numquam odit. Eos expedita nostrum corporis consequatur est recusandae.\n\nCulpa blanditiis rerum repudiandae alias voluptatem. Velit iusto est ullam consequatur doloribus porro. Corporis voluptas consectetur est veniam et quia quae.\n\nEt aut magni fuga nesciunt officiis molestias. Quaerat et nam necessitatibus qui rerum. Architecto quia officiis voluptatem laborum est recusandae. Quasi ducimus soluta odit necessitatibus labore numquam dignissimos. Quia facere sint temporibus inventore sunt nihil saepe dolorum.\n\nFacere dolores quis dolores a. Est minus nostrum nihil harum. Earum laborum et ipsum unde neque sit nemo. Corrupti est consequatur minima fugit. Illum voluptatem illo error ducimus officia qui debitis.\n\nDignissimos porro a autem harum aut. Aut id reprehenderit et exercitationem. Est et quisquam ipsa temporibus molestiae. Architecto natus dolore qui fugiat incidunt. Autem odit veniam excepturi et voluptatibus culpa ipsum eos.\n\nAmet quo quisquam dignissimos soluta modi dolores. Sint omnis eius optio corporis dolor. Eligendi animi porro quia placeat ut.","created_at":"2016-03-22T15:20:35.927Z","updated_at":"2016-03-22T15:20:35.927Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":39,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":77,"project_id":5,"status":"failed","finished_at":null,"trace":"Rerum ut et suscipit est perspiciatis. Inventore debitis cum eius vitae. Ex incidunt id velit aut quo nisi. Laboriosam repellat deserunt eius reiciendis architecto et. Est harum quos nesciunt nisi consectetur.\n\nAlias esse omnis sint officia est consequatur in nobis. Dignissimos dolorum vel eligendi nesciunt dolores sit. Veniam mollitia ducimus et exercitationem molestiae libero sed. Atque omnis debitis laudantium voluptatibus qui. Repellendus tempore est commodi pariatur.\n\nExpedita voluptate illum est alias non. Modi nesciunt ab assumenda laborum nulla consequatur molestias doloremque. Magnam quod officia vel explicabo accusamus ut voluptatem incidunt. Rerum ut aliquid ullam saepe. Est eligendi debitis beatae blanditiis reiciendis.\n\nQui fuga sit dolores libero maiores et suscipit. Consectetur asperiores omnis minima impedit eos fugiat. Similique omnis nisi sed vero inventore ipsum aliquam exercitationem.\n\nBlanditiis magni iure dolorum omnis ratione delectus molestiae. Atque officia dolor voluptatem culpa quod. Incidunt suscipit quidem possimus veritatis non vel. Iusto aliquid et id quia quasi.\n\nVel facere velit blanditiis incidunt cupiditate sed maiores consequuntur. Quasi quia dicta consequuntur et quia voluptatem iste id. Incidunt et rerum fuga esse sint.","created_at":"2016-03-22T15:20:35.905Z","updated_at":"2016-03-22T15:20:35.905Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":39,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}]}
{"id":41,"project_id":5,"ref":"master","sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.763Z","updated_at":"2016-03-22T15:20:35.763Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/commit_notes.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/commit_notes.ndjson
new file mode 100644
index 00000000000..b623c388b4f
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/commit_notes.ndjson
@@ -0,0 +1,2 @@
+{"note":"Commit note 1","noteable_type":"Commit","author_id":1,"created_at":"2023-01-30T19:27:36.585Z","updated_at":"2023-02-10T14:43:01.308Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":"sha-notes","system":false,"st_diff":null,"updated_by_id":1,"type":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"e3fde7d585c6467a7a5147e83617eb6daa61aaf4","change_position":null,"resolved_by_push":null,"confidential":null,"last_edited_at":"2023-02-10T14:43:01.306Z","author":{"name":"Administrator"},"events":[{"project_id":1,"author_id":1,"created_at":"2023-01-30T19:27:36.815Z","updated_at":"2023-01-30T19:27:36.815Z","action":"commented","target_type":"Note","fingerprint":null,"push_event_payload":{"commit_count":1,"action":"pushed","ref_type":"branch","commit_to":"sha-notes","ref":"master"}}]}
+{"note":"Commit note 2","noteable_type":"Commit","author_id":1,"created_at":"2023-02-10T14:44:08.138Z","updated_at":"2023-02-10T14:54:42.828Z","project_id":1,"attachment":{"url":null},"line_code":null,"commit_id":"sha-notes","system":false,"st_diff":null,"updated_by_id":1,"type":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"53ca55a01732aff4f17daecdf076853f4ab152eb","change_position":null,"resolved_by_push":null,"confidential":null,"last_edited_at":"2023-02-10T14:54:42.827Z","author":{"name":"Administrator"},"events":[{"project_id":1,"author_id":1,"created_at":"2023-02-10T16:37:16.659Z","updated_at":"2023-02-10T16:37:16.659Z","action":"commented","target_type":"Note","fingerprint":null}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/design_management_repository.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/design_management_repository.ndjson
new file mode 100644
index 00000000000..c1676157e68
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/design_management_repository.ndjson
@@ -0,0 +1 @@
+{"id":500, "project_id":30, "created_at":"2019-08-07T03:57:55.007Z", "updated_at":"2019-08-07T03:57:55.007Z"} \ No newline at end of file
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_environments.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_environments.ndjson
index 55afaa8bcf6..f87fdd860c7 100644
--- a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_environments.ndjson
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_environments.ndjson
@@ -1 +1 @@
-{ "id": 1, "project_id": 9, "created_at": "2017-10-19T15:36:23.466Z", "updated_at": "2017-10-19T15:36:23.466Z", "name": "production", "deploy_access_levels": [ { "id": 1, "protected_environment_id": 1, "created_at": "2017-10-19T15:36:23.466Z", "updated_at": "2017-10-19T15:36:23.466Z", "access_level": 40, "user_id": 1, "group_id": null } ] }
+{ "id": 1, "project_id": 9, "created_at": "2017-10-19T15:36:23.466Z", "updated_at": "2017-10-19T15:36:23.466Z", "name": "production", "deploy_access_levels": [ { "id": 1, "protected_environment_id": 1, "created_at": "2017-10-19T15:36:23.466Z", "updated_at": "2017-10-19T15:36:23.466Z", "access_level": null, "user_id": 1, "group_id": null } ] }
diff --git a/spec/fixtures/lib/gitlab/import_export/designs/tree/project.json b/spec/fixtures/lib/gitlab/import_export/designs/tree/project.json
new file mode 100644
index 00000000000..3adcb693aeb
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/designs/tree/project.json
@@ -0,0 +1,15 @@
+{
+ "description": "Nisi et repellendus ut enim quo accusamus vel magnam.",
+ "import_type": "gitlab_project",
+ "creator_id": 123,
+ "visibility_level": 10,
+ "archived": false,
+ "deploy_keys": [
+
+ ],
+ "hooks": [
+
+ ],
+ "shared_runners_enabled": true,
+ "ci_config_path": "config/path"
+}
diff --git a/spec/fixtures/lib/gitlab/import_export/designs/tree/project/issues.ndjson b/spec/fixtures/lib/gitlab/import_export/designs/tree/project/issues.ndjson
new file mode 100644
index 00000000000..3f767505bfb
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/designs/tree/project/issues.ndjson
@@ -0,0 +1,2 @@
+{"id":469,"title":"issue 1","author_id":1,"project_id":30,"created_at":"2019-08-07T03:57:55.007Z","updated_at":"2019-08-07T03:57:55.007Z","description":"","state":"opened","iid":1,"updated_by_id":null,"weight":null,"confidential":false,"due_date":null,"moved_to_id":null,"lock_version":0,"time_estimate":0,"relative_position":1073742323,"external_author":null,"last_edited_at":null,"last_edited_by_id":null,"discussion_locked":null,"closed_at":null,"closed_by_id":null,"state_id":1,"events":[{"id":1775,"project_id":30,"author_id":1,"target_id":469,"created_at":"2019-08-07T03:57:55.158Z","updated_at":"2019-08-07T03:57:55.158Z","target_type":"Issue","action":1}],"timelogs":[],"notes":[],"label_links":[],"resource_label_events":[],"issue_assignees":[],"designs":[{"id":38,"iid":1,"project_id":30,"issue_id":469,"filename":"chirrido3.jpg","notes":[]},{"id":39,"iid":2,"project_id":30,"issue_id":469,"filename":"jonathan_richman.jpg","notes":[]},{"id":40,"iid":3,"project_id":30,"issue_id":469,"filename":"mariavontrap.jpeg","notes":[]}],"design_versions":[{"id":24,"sha":"9358d1bac8ff300d3d2597adaa2572a20f7f8703","issue_id":469,"author_id":1,"actions":[{"design_id":38,"version_id":24,"event":0,"design":{"id":38,"iid":1,"project_id":30,"issue_id":469,"filename":"chirrido3.jpg"}}]},{"id":25,"sha":"e1a4a501bcb42f291f84e5d04c8f927821542fb6","issue_id":469,"author_id":2,"actions":[{"design_id":38,"version_id":25,"event":1,"design":{"id":38,"iid":1,"project_id":30,"issue_id":469,"filename":"chirrido3.jpg"}},{"design_id":39,"version_id":25,"event":0,"design":{"id":39,"iid":2,"project_id":30,"issue_id":469,"filename":"jonathan_richman.jpg"}}]},{"id":26,"sha":"27702d08f5ee021ae938737f84e8fe7c38599e85","issue_id":469,"author_id":1,"actions":[{"design_id":38,"version_id":26,"event":1,"design":{"id":38,"iid":1,"project_id":30,"issue_id":469,"filename":"chirrido3.jpg"}},{"design_id":39,"version_id":26,"event":2,"design":{"id":39,"iid":2,"project_id":30,"issue_id":469,"filename":"jonathan_richman.jpg"}},{"design_id":40,"version_id":26,"event":0,"design":{"id":40,"iid":3,"project_id":30,"issue_id":469,"filename":"mariavontrap.jpeg"}}]}]}
+{"id":470,"title":"issue 2","author_id":1,"project_id":30,"created_at":"2019-08-07T04:15:57.607Z","updated_at":"2019-08-07T04:15:57.607Z","description":"","state":"opened","iid":2,"updated_by_id":null,"weight":null,"confidential":false,"due_date":null,"moved_to_id":null,"lock_version":0,"time_estimate":0,"relative_position":1073742823,"external_author":null,"last_edited_at":null,"last_edited_by_id":null,"discussion_locked":null,"closed_at":null,"closed_by_id":null,"state_id":1,"events":[{"id":1776,"project_id":30,"author_id":1,"target_id":470,"created_at":"2019-08-07T04:15:57.789Z","updated_at":"2019-08-07T04:15:57.789Z","target_type":"Issue","action":1}],"timelogs":[],"notes":[],"label_links":[],"resource_label_events":[],"issue_assignees":[],"designs":[{"id":42,"project_id":30,"issue_id":470,"filename":"1 (1).jpeg","notes":[]},{"id":43,"project_id":30,"issue_id":470,"filename":"2099743.jpg","notes":[]},{"id":44,"project_id":30,"issue_id":470,"filename":"a screenshot (1).jpg","notes":[]},{"id":41,"project_id":30,"issue_id":470,"filename":"chirrido3.jpg","notes":[]}],"design_versions":[{"id":27,"sha":"8587e78ab6bda3bc820a9f014c3be4a21ad4fcc8","issue_id":470,"author_id":1,"actions":[{"design_id":41,"version_id":27,"event":0,"design":{"id":41,"project_id":30,"issue_id":470,"filename":"chirrido3.jpg"}}]},{"id":28,"sha":"73f871b4c8c1d65c62c460635e023179fb53abc4","issue_id":470,"author_id":2,"actions":[{"design_id":42,"version_id":28,"event":0,"design":{"id":42,"project_id":30,"issue_id":470,"filename":"1 (1).jpeg"}},{"design_id":43,"version_id":28,"event":0,"design":{"id":43,"project_id":30,"issue_id":470,"filename":"2099743.jpg"}}]},{"id":29,"sha":"c9b5f067f3e892122a4b12b0a25a8089192f3ac8","issue_id":470,"author_id":2,"actions":[{"design_id":42,"version_id":29,"event":1,"design":{"id":42,"project_id":30,"issue_id":470,"filename":"1 (1).jpeg"}},{"design_id":44,"version_id":29,"event":0,"design":{"id":44,"project_id":30,"issue_id":470,"filename":"a screenshot (1).jpg"}}]}]} \ No newline at end of file
diff --git a/spec/fixtures/lib/gitlab/import_export/designs/tree/project/project_members.ndjson b/spec/fixtures/lib/gitlab/import_export/designs/tree/project/project_members.ndjson
new file mode 100644
index 00000000000..570fd4a0c05
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/designs/tree/project/project_members.ndjson
@@ -0,0 +1,2 @@
+{"id":95,"access_level":40,"source_id":30,"source_type":"Project","user_id":1,"notification_level":3,"created_at":"2019-08-07T03:57:32.825Z","updated_at":"2019-08-07T03:57:32.825Z","created_by_id":1,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1,"public_email":"admin@example.com","username":"root"}}
+{"id":96,"access_level":40,"source_id":30,"source_type":"Project","user_id":2,"notification_level":3,"created_at":"2019-08-07T03:57:32.825Z","updated_at":"2019-08-07T03:57:32.825Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":2,"public_email":"user_2@gitlabexample.com","username":"user_2"}} \ No newline at end of file
diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb
index 979e96e6e8e..fa73cd53a66 100644
--- a/spec/fixtures/markdown.md.erb
+++ b/spec/fixtures/markdown.md.erb
@@ -194,6 +194,19 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
- Link to issue by reference: [Issue](<%= issue.to_reference %>)
- Link to issue by URL: [Issue](<%= urls.project_issue_url(issue.project, issue) %>)
+#### WorkItemReferenceFilter
+
+Note: work item references use `#`, which get built as an issue link.
+
+- Work item (counted as an issue reference): <%= work_item.to_reference %>
+- Work item in another project (counted as an issue reference): <%= xwork_item.to_reference(project) %>
+- Ignored in code: `<%= work_item.to_reference %>`
+- Ignored in links: [Link to <%= work_item.to_reference %>](#work_item-link)
+- Ignored when backslash escaped: \<%= work_item.to_reference %>
+- Work item by URL: <%= urls.project_work_item_url(work_item.project, work_item) %>
+- Link to work item by reference (counted as an issue reference): [Work item](<%= work_item.to_reference %>)
+- Link to work item by URL: [Work item](<%= urls.project_work_item_url(work_item.project, work_item) %>)
+
#### MergeRequestReferenceFilter
- Merge request: <%= merge_request.to_reference %>
@@ -299,6 +312,32 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
v^2 + w^2 = x^2
```
+Parsed correctly when between code blocks
+
+```ruby
+x = 1
+```
+
+$$
+a^2+b^2=c^2
+$$
+
+```
+plaintext
+```
+
+Parsed correctly with a mixture of HTML comments and HTML blocks
+
+<!-- sdf -->
+
+$$
+a^2+b^2=c^2
+$$
+
+<h1>
+html
+</h1>
+
### Gollum Tags
- [[linked-resource]]
diff --git a/spec/fixtures/packages/debian/README.md b/spec/fixtures/packages/debian/README.md
index e398222ce62..af8e19a2de8 100644
--- a/spec/fixtures/packages/debian/README.md
+++ b/spec/fixtures/packages/debian/README.md
@@ -10,7 +10,7 @@ Go to the `spec/fixtures/packages/debian` directory and clean up old files:
```shell
cd spec/fixtures/packages/debian
-rm -v *.tar.* *.dsc *.deb *.udeb *.buildinfo *.changes
+rm -v *.tar.* *.dsc *.deb *.udeb *.ddeb *.buildinfo *.changes
```
Go to the package source directory and build:
diff --git a/spec/fixtures/packages/debian/sample-ddeb_1.2.3~alpha2_amd64.ddeb b/spec/fixtures/packages/debian/sample-ddeb_1.2.3~alpha2_amd64.ddeb
new file mode 100644
index 00000000000..fb4631219df
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample-ddeb_1.2.3~alpha2_amd64.ddeb
Binary files differ
diff --git a/spec/fixtures/packages/debian/sample/debian/.gitignore b/spec/fixtures/packages/debian/sample/debian/.gitignore
index cb63a746c89..5eb910775ff 100644
--- a/spec/fixtures/packages/debian/sample/debian/.gitignore
+++ b/spec/fixtures/packages/debian/sample/debian/.gitignore
@@ -5,4 +5,4 @@ files
libsample0
sample-dev
sample-udeb
-
+sample-ddeb
diff --git a/spec/fixtures/packages/debian/sample/debian/control b/spec/fixtures/packages/debian/sample/debian/control
index 26d84e1c35d..f9f1b29e3a6 100644
--- a/spec/fixtures/packages/debian/sample/debian/control
+++ b/spec/fixtures/packages/debian/sample/debian/control
@@ -33,3 +33,7 @@ Package-Type: udeb
Architecture: any
Depends: installed-base
Description: Some mostly empty udeb
+
+Package: sample-ddeb
+Architecture: any
+Description: Some fake Ubuntu ddeb
diff --git a/spec/fixtures/packages/debian/sample/debian/rules b/spec/fixtures/packages/debian/sample/debian/rules
index 8ae87843489..9d55aace045 100755
--- a/spec/fixtures/packages/debian/sample/debian/rules
+++ b/spec/fixtures/packages/debian/sample/debian/rules
@@ -1,6 +1,13 @@
#!/usr/bin/make -f
%:
dh $@
+
override_dh_gencontrol:
dh_gencontrol -psample-dev -- -v'1.2.3~binary'
dh_gencontrol --remaining-packages
+
+override_dh_builddeb:
+ # Hack to mimic Ubuntu ddebs
+ dh_builddeb
+ mv ../sample-ddeb_1.2.3~alpha2_amd64.deb ../sample-ddeb_1.2.3~alpha2_amd64.ddeb
+ sed -i 's/sample-ddeb_1.2.3~alpha2_amd64.deb libs optional/sample-ddeb_1.2.3~alpha2_amd64.ddeb libs optional/' debian/files \ No newline at end of file
diff --git a/spec/fixtures/packages/debian/sample_1.2.3~alpha2.dsc b/spec/fixtures/packages/debian/sample_1.2.3~alpha2.dsc
index 4a5755cd612..21539c229e3 100644
--- a/spec/fixtures/packages/debian/sample_1.2.3~alpha2.dsc
+++ b/spec/fixtures/packages/debian/sample_1.2.3~alpha2.dsc
@@ -1,6 +1,6 @@
Format: 3.0 (native)
Source: sample
-Binary: sample-dev, libsample0, sample-udeb
+Binary: sample-dev, libsample0, sample-udeb, sample-ddeb
Architecture: any
Version: 1.2.3~alpha2
Maintainer: John Doe <john.doe@example.com>
@@ -9,11 +9,12 @@ Standards-Version: 4.5.0
Build-Depends: debhelper-compat (= 13)
Package-List:
libsample0 deb libs optional arch=any
+ sample-ddeb deb libs optional arch=any
sample-dev deb libdevel optional arch=any
sample-udeb udeb libs optional arch=any
Checksums-Sha1:
- c5cfc111ea924842a89a06d5673f07dfd07de8ca 864 sample_1.2.3~alpha2.tar.xz
+ 4a9cb2a7c77a68dc0fe54ba8ecef133a7c949e9d 964 sample_1.2.3~alpha2.tar.xz
Checksums-Sha256:
- 40e4682bb24a73251ccd7c7798c0094a649091e5625d6a14bcec9b4e7174f3da 864 sample_1.2.3~alpha2.tar.xz
+ c9d05185ca158bb804977fa9d7b922e8a0f644a2da41f99d2787dd61b1e2e2c5 964 sample_1.2.3~alpha2.tar.xz
Files:
- d5ca476e4229d135a88f9c729c7606c9 864 sample_1.2.3~alpha2.tar.xz
+ adc69e57cda38d9bb7c8d59cacfb6869 964 sample_1.2.3~alpha2.tar.xz
diff --git a/spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xz b/spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xz
index 2bad3f065b8..d4a43b01821 100644
--- a/spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xz
+++ b/spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xz
Binary files differ
diff --git a/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.buildinfo b/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.buildinfo
index 36e2390b8c7..7bbf1517a53 100644
--- a/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.buildinfo
+++ b/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.buildinfo
@@ -1,186 +1,198 @@
Format: 1.0
Source: sample
-Binary: libsample0 sample-dev sample-udeb
+Binary: libsample0 sample-ddeb sample-dev sample-udeb
Architecture: amd64 source
Version: 1.2.3~alpha2
Checksums-Md5:
- ceccb6bb3e45ce6550b24234d4023e0f 671 sample_1.2.3~alpha2.dsc
+ 629921cfc477bfa84adfd2ccaba89783 724 sample_1.2.3~alpha2.dsc
fb0842b21adc44207996296fe14439dd 1124 libsample0_1.2.3~alpha2_amd64.deb
+ 90d1107471eed48c73ad78b19ac83639 1068 sample-ddeb_1.2.3~alpha2_amd64.ddeb
5fafc04dcae1525e1367b15413e5a5c7 1164 sample-dev_1.2.3~binary_amd64.deb
72b1dd7d98229e2fb0355feda1d3a165 736 sample-udeb_1.2.3~alpha2_amd64.udeb
Checksums-Sha1:
- 375ba20ea1789e1e90d469c3454ce49a431d0442 671 sample_1.2.3~alpha2.dsc
+ 443c98a4cf4acd21e2259ae8f2d60fc9932de353 724 sample_1.2.3~alpha2.dsc
5248b95600e85bfe7f63c0dfce330a75f5777366 1124 libsample0_1.2.3~alpha2_amd64.deb
+ 9c5af97cf8dfbe8126c807f540c88757f382b307 1068 sample-ddeb_1.2.3~alpha2_amd64.ddeb
fcd5220b1501ec150ccf37f06e4da919a8612be4 1164 sample-dev_1.2.3~binary_amd64.deb
e42e8f2fe04ed1bb73b44a187674480d0e49dcba 736 sample-udeb_1.2.3~alpha2_amd64.udeb
Checksums-Sha256:
- 81fc156ba937cdb6215362cc4bf6b8dc47be9b4253ba0f1a4ab10c7ea0c4c4e5 671 sample_1.2.3~alpha2.dsc
+ f91070524a59bbb3a1f05a78409e92cb9ee86470b34018bc0b93bd5b2dd3868c 724 sample_1.2.3~alpha2.dsc
1c383a525bfcba619c7305ccd106d61db501a6bbaf0003bf8d0c429fbdb7fcc1 1124 libsample0_1.2.3~alpha2_amd64.deb
+ a6bcc8a4b010f99ce0ea566ac69088e1910e754593c77f2b4942e3473e784e4d 1068 sample-ddeb_1.2.3~alpha2_amd64.ddeb
b8aa8b73a14bc1e0012d4c5309770f5160a8ea7f9dfe6f45222ea6b8a3c35325 1164 sample-dev_1.2.3~binary_amd64.deb
2b0c152b3ab4cc07663350424de972c2b7621d69fe6df2e0b94308a191e4632f 736 sample-udeb_1.2.3~alpha2_amd64.udeb
Build-Origin: Debian
Build-Architecture: amd64
-Build-Date: Fri, 14 May 2021 16:51:32 +0200
+Build-Date: Sat, 04 Mar 2023 09:42:57 +0100
Build-Tainted-By:
merged-usr-via-aliased-dirs
usr-local-has-includes
usr-local-has-libraries
usr-local-has-programs
Installed-Build-Depends:
- autoconf (= 2.69-14),
- automake (= 1:1.16.3-2),
- autopoint (= 0.21-4),
- autotools-dev (= 20180224.1+nmu1),
- base-files (= 11),
- base-passwd (= 3.5.49),
- bash (= 5.1-2+b1),
- binutils (= 2.35.2-2),
- binutils-common (= 2.35.2-2),
- binutils-x86-64-linux-gnu (= 2.35.2-2),
- bsdextrautils (= 2.36.1-7),
+ autoconf (= 2.71-3),
+ automake (= 1:1.16.5-1.3),
+ autopoint (= 0.21-11),
+ autotools-dev (= 20220109.1),
+ base-files (= 12.3),
+ base-passwd (= 3.6.1),
+ bash (= 5.2.15-2+b1),
+ binutils (= 2.40-2),
+ binutils-common (= 2.40-2),
+ binutils-x86-64-linux-gnu (= 2.40-2),
+ bsdextrautils (= 2.38.1-4),
bsdmainutils (= 12.1.7+nmu3),
- bsdutils (= 1:2.36.1-7),
+ bsdutils (= 1:2.38.1-4),
build-essential (= 12.9),
- bzip2 (= 1.0.8-4),
- coreutils (= 8.32-4+b1),
- cpp (= 4:10.2.1-1),
- cpp-10 (= 10.2.1-6),
- cpp-9 (= 9.3.0-22),
- dash (= 0.5.11+git20200708+dd9ef66-5),
- debconf (= 1.5.75),
- debhelper (= 13.3.4),
- debianutils (= 4.11.2),
+ bzip2 (= 1.0.8-5+b1),
+ coreutils (= 9.1-1),
+ cpp (= 4:12.2.0-3),
+ cpp-10 (= 10.4.0-7),
+ cpp-12 (= 12.2.0-14),
+ dash (= 0.5.12-2),
+ debconf (= 1.5.82),
+ debhelper (= 13.11.4),
+ debianutils (= 5.7-0.4),
dh-autoreconf (= 20),
- dh-strip-nondeterminism (= 1.11.0-1),
- diffutils (= 1:3.7-5),
- dpkg (= 1.20.9),
- dpkg-dev (= 1.20.9),
- dwz (= 0.13+20210201-1),
- file (= 1:5.39-3),
- findutils (= 4.8.0-1),
- g++ (= 4:10.2.1-1),
- g++-10 (= 10.2.1-6),
- gcc (= 4:10.2.1-1),
- gcc-10 (= 10.2.1-6),
- gcc-10-base (= 10.2.1-6),
- gcc-9 (= 9.3.0-22),
- gcc-9-base (= 9.3.0-22),
- gettext (= 0.21-4),
- gettext-base (= 0.21-4),
- grep (= 3.6-1),
- groff-base (= 1.22.4-6),
- gzip (= 1.10-4),
- hostname (= 3.23),
- init-system-helpers (= 1.60),
- intltool-debian (= 0.35.0+20060710.5),
- libacl1 (= 2.2.53-10),
+ dh-strip-nondeterminism (= 1.13.1-1),
+ diffutils (= 1:3.8-4),
+ dpkg (= 1.21.20),
+ dpkg-dev (= 1.21.20),
+ dwz (= 0.15-1),
+ file (= 1:5.44-3),
+ findutils (= 4.9.0-4),
+ g++ (= 4:12.2.0-3),
+ g++-12 (= 12.2.0-14),
+ gcc (= 4:12.2.0-3),
+ gcc-10 (= 10.4.0-7),
+ gcc-10-base (= 10.4.0-7),
+ gcc-11-base (= 11.3.0-11),
+ gcc-12 (= 12.2.0-14),
+ gcc-12-base (= 12.2.0-14),
+ gettext (= 0.21-11),
+ gettext-base (= 0.21-11),
+ grep (= 3.8-5),
+ groff-base (= 1.22.4-9),
+ gzip (= 1.12-1),
+ hostname (= 3.23+nmu1),
+ init-system-helpers (= 1.65.2),
+ intltool-debian (= 0.35.0+20060710.6),
+ libacl1 (= 2.3.1-3),
libarchive-zip-perl (= 1.68-1),
- libasan5 (= 9.3.0-22),
- libasan6 (= 10.2.1-6),
- libatomic1 (= 10.2.1-6),
- libattr1 (= 1:2.4.48-6),
- libaudit-common (= 1:3.0-2),
- libaudit1 (= 1:3.0-2),
- libbinutils (= 2.35.2-2),
- libblkid1 (= 2.36.1-7),
- libbz2-1.0 (= 1.0.8-4),
- libc-bin (= 2.31-11),
- libc-dev-bin (= 2.31-11),
- libc6 (= 2.31-11),
- libc6-dev (= 2.31-11),
- libcap-ng0 (= 0.7.9-2.2+b1),
- libcc1-0 (= 10.2.1-6),
- libcom-err2 (= 1.46.2-1),
- libcrypt-dev (= 1:4.4.18-2),
- libcrypt1 (= 1:4.4.18-2),
- libctf-nobfd0 (= 2.35.2-2),
- libctf0 (= 2.35.2-2),
- libdb5.3 (= 5.3.28+dfsg1-0.8),
- libdebconfclient0 (= 0.257),
- libdebhelper-perl (= 13.3.4),
- libdpkg-perl (= 1.20.9),
- libelf1 (= 0.183-1),
- libfile-stripnondeterminism-perl (= 1.11.0-1),
- libgcc-10-dev (= 10.2.1-6),
- libgcc-9-dev (= 9.3.0-22),
- libgcc-s1 (= 10.2.1-6),
- libgcrypt20 (= 1.8.7-3),
- libgdbm-compat4 (= 1.19-2),
- libgdbm6 (= 1.19-2),
- libgmp10 (= 2:6.2.1+dfsg-1),
- libgomp1 (= 10.2.1-6),
- libgpg-error0 (= 1.38-2),
- libgssapi-krb5-2 (= 1.18.3-5),
- libicu67 (= 67.1-6),
- libisl23 (= 0.23-1),
- libitm1 (= 10.2.1-6),
- libk5crypto3 (= 1.18.3-5),
- libkeyutils1 (= 1.6.1-2),
- libkrb5-3 (= 1.18.3-5),
- libkrb5support0 (= 1.18.3-5),
- liblsan0 (= 10.2.1-6),
- liblz4-1 (= 1.9.3-1),
- liblzma5 (= 5.2.5-2),
- libmagic-mgc (= 1:5.39-3),
- libmagic1 (= 1:5.39-3),
- libmount1 (= 2.36.1-7),
- libmpc3 (= 1.2.0-1),
- libmpfr6 (= 4.1.0-3),
+ libasan6 (= 11.3.0-11),
+ libasan8 (= 12.2.0-14),
+ libatomic1 (= 12.2.0-14),
+ libattr1 (= 1:2.5.1-4),
+ libaudit-common (= 1:3.0.9-1),
+ libaudit1 (= 1:3.0.9-1),
+ libbinutils (= 2.40-2),
+ libblkid1 (= 2.38.1-4),
+ libbz2-1.0 (= 1.0.8-5+b1),
+ libc-bin (= 2.36-8),
+ libc-dev-bin (= 2.36-8),
+ libc6 (= 2.36-8),
+ libc6-dev (= 2.36-8),
+ libcap-ng0 (= 0.8.3-1+b3),
+ libcap2 (= 1:2.66-3),
+ libcc1-0 (= 12.2.0-14),
+ libcom-err2 (= 1.46.6-1),
+ libcrypt-dev (= 1:4.4.33-2),
+ libcrypt1 (= 1:4.4.33-2),
+ libctf-nobfd0 (= 2.40-2),
+ libctf0 (= 2.40-2),
+ libdb5.3 (= 5.3.28+dfsg2-1),
+ libdebconfclient0 (= 0.267),
+ libdebhelper-perl (= 13.11.4),
+ libdpkg-perl (= 1.21.20),
+ libelf1 (= 0.188-2.1),
+ libfile-find-rule-perl (= 0.34-3),
+ libfile-stripnondeterminism-perl (= 1.13.1-1),
+ libgcc-10-dev (= 10.4.0-7),
+ libgcc-12-dev (= 12.2.0-14),
+ libgcc-s1 (= 12.2.0-14),
+ libgcrypt20 (= 1.10.1-3),
+ libgdbm-compat4 (= 1.23-3),
+ libgdbm6 (= 1.23-3),
+ libgmp10 (= 2:6.2.1+dfsg1-1.1),
+ libgomp1 (= 12.2.0-14),
+ libgpg-error0 (= 1.46-1),
+ libgprofng0 (= 2.40-2),
+ libgssapi-krb5-2 (= 1.20.1-1),
+ libicu72 (= 72.1-3),
+ libisl23 (= 0.25-1),
+ libitm1 (= 12.2.0-14),
+ libjansson4 (= 2.14-2),
+ libk5crypto3 (= 1.20.1-1),
+ libkeyutils1 (= 1.6.3-2),
+ libkrb5-3 (= 1.20.1-1),
+ libkrb5support0 (= 1.20.1-1),
+ liblsan0 (= 12.2.0-14),
+ liblz4-1 (= 1.9.4-1),
+ liblzma5 (= 5.4.1-0.1),
+ libmagic-mgc (= 1:5.44-3),
+ libmagic1 (= 1:5.44-3),
+ libmd0 (= 1.0.4-2),
+ libmount1 (= 2.38.1-4),
+ libmpc3 (= 1.3.1-1),
+ libmpfr6 (= 4.2.0-1),
libnsl-dev (= 1.3.0-2),
libnsl2 (= 1.3.0-2),
- libpam-modules (= 1.4.0-7),
- libpam-modules-bin (= 1.4.0-7),
- libpam-runtime (= 1.4.0-7),
- libpam0g (= 1.4.0-7),
- libpcre2-8-0 (= 10.36-2),
- libpcre3 (= 2:8.39-13),
- libperl5.32 (= 5.32.1-4),
- libpipeline1 (= 1.5.3-1),
- libquadmath0 (= 10.2.1-6),
- libseccomp2 (= 2.5.1-1),
- libselinux1 (= 3.1-3),
- libsigsegv2 (= 2.13-1),
- libsmartcols1 (= 2.36.1-7),
- libssl1.1 (= 1.1.1k-1),
- libstdc++-10-dev (= 10.2.1-6),
- libstdc++6 (= 10.2.1-6),
- libsub-override-perl (= 0.09-2),
- libsystemd0 (= 247.3-5),
- libtinfo6 (= 6.2+20201114-2),
- libtirpc-common (= 1.3.1-1),
- libtirpc-dev (= 1.3.1-1),
- libtirpc3 (= 1.3.1-1),
- libtool (= 2.4.6-15),
- libtsan0 (= 10.2.1-6),
- libubsan1 (= 10.2.1-6),
+ libnumber-compare-perl (= 0.03-3),
+ libpam-modules (= 1.5.2-6),
+ libpam-modules-bin (= 1.5.2-6),
+ libpam-runtime (= 1.5.2-6),
+ libpam0g (= 1.5.2-6),
+ libpcre2-8-0 (= 10.42-1),
+ libperl5.36 (= 5.36.0-7),
+ libpipeline1 (= 1.5.7-1),
+ libquadmath0 (= 12.2.0-14),
+ libseccomp2 (= 2.5.4-1+b3),
+ libselinux1 (= 3.4-1+b5),
+ libsmartcols1 (= 2.38.1-4),
+ libssl3 (= 3.0.8-1),
+ libstdc++-12-dev (= 12.2.0-14),
+ libstdc++6 (= 12.2.0-14),
+ libsub-override-perl (= 0.09-4),
+ libsystemd0 (= 252.5-2),
+ libtext-glob-perl (= 0.11-3),
+ libtinfo6 (= 6.4-2),
+ libtirpc-common (= 1.3.3+ds-1),
+ libtirpc-dev (= 1.3.3+ds-1),
+ libtirpc3 (= 1.3.3+ds-1),
+ libtool (= 2.4.7-5),
+ libtsan0 (= 11.3.0-11),
+ libtsan2 (= 12.2.0-14),
+ libubsan1 (= 12.2.0-14),
libuchardet0 (= 0.0.7-1),
- libudev1 (= 247.3-5),
- libunistring2 (= 0.9.10-4),
- libuuid1 (= 2.36.1-7),
- libxml2 (= 2.9.10+dfsg-6.3+b1),
- libzstd1 (= 1.4.8+dfsg-2.1),
- linux-libc-dev (= 5.10.28-1),
- login (= 1:4.8.1-1),
- lsb-base (= 11.1.0),
- m4 (= 1.4.18-5),
+ libudev1 (= 252.5-2),
+ libunistring2 (= 1.0-2),
+ libuuid1 (= 2.38.1-4),
+ libxml2 (= 2.9.14+dfsg-1.1+b3),
+ libzstd1 (= 1.5.2+dfsg2-3),
+ linux-libc-dev (= 6.1.8-1),
+ login (= 1:4.13+dfsg1-1),
+ m4 (= 1.4.19-3),
make (= 4.3-4.1),
- man-db (= 2.9.4-2),
- mawk (= 1.3.4.20200120-2),
+ man-db (= 2.11.2-1),
+ mawk (= 1.3.4.20200120-3.1),
ncal (= 12.1.7+nmu3),
- ncurses-base (= 6.2+20201114-2),
- ncurses-bin (= 6.2+20201114-2),
+ ncurses-base (= 6.4-2),
+ ncurses-bin (= 6.4-2),
patch (= 2.7.6-7),
- perl (= 5.32.1-4),
- perl-base (= 5.32.1-4),
- perl-modules-5.32 (= 5.32.1-4),
+ perl (= 5.36.0-7),
+ perl-base (= 5.36.0-7),
+ perl-modules-5.36 (= 5.36.0-7),
po-debconf (= 1.0.21+nmu1),
- sed (= 4.7-1),
- sensible-utils (= 0.0.14),
- sysvinit-utils (= 2.96-7),
+ rpcsvc-proto (= 1.4.3-1),
+ sed (= 4.9-1),
+ sensible-utils (= 0.0.17+nmu1),
+ sysvinit-utils (= 3.06-2),
tar (= 1.34+dfsg-1),
- util-linux (= 2.36.1-7),
- xz-utils (= 5.2.5-2),
- zlib1g (= 1:1.2.11.dfsg-2)
+ usrmerge (= 35),
+ util-linux (= 2.38.1-4),
+ util-linux-extra (= 2.38.1-4),
+ xz-utils (= 5.4.1-0.1),
+ zlib1g (= 1:1.2.13.dfsg-1)
Environment:
DEB_BUILD_OPTIONS="parallel=8"
LANG="fr_FR.UTF-8"
diff --git a/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.changes b/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.changes
index 7aa4761c49c..a1d2f95e231 100644
--- a/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.changes
+++ b/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.changes
@@ -17,23 +17,26 @@ Changes:
.
* Initial release
Checksums-Sha1:
- 375ba20ea1789e1e90d469c3454ce49a431d0442 671 sample_1.2.3~alpha2.dsc
- c5cfc111ea924842a89a06d5673f07dfd07de8ca 864 sample_1.2.3~alpha2.tar.xz
+ 443c98a4cf4acd21e2259ae8f2d60fc9932de353 724 sample_1.2.3~alpha2.dsc
+ 4a9cb2a7c77a68dc0fe54ba8ecef133a7c949e9d 964 sample_1.2.3~alpha2.tar.xz
5248b95600e85bfe7f63c0dfce330a75f5777366 1124 libsample0_1.2.3~alpha2_amd64.deb
+ 9c5af97cf8dfbe8126c807f540c88757f382b307 1068 sample-ddeb_1.2.3~alpha2_amd64.ddeb
fcd5220b1501ec150ccf37f06e4da919a8612be4 1164 sample-dev_1.2.3~binary_amd64.deb
e42e8f2fe04ed1bb73b44a187674480d0e49dcba 736 sample-udeb_1.2.3~alpha2_amd64.udeb
- 661f7507efa6fdd3763c95581d0baadb978b7ef5 5507 sample_1.2.3~alpha2_amd64.buildinfo
+ bcc4ca85f17a31066b726cd4e04485ab24a682c6 6032 sample_1.2.3~alpha2_amd64.buildinfo
Checksums-Sha256:
- 81fc156ba937cdb6215362cc4bf6b8dc47be9b4253ba0f1a4ab10c7ea0c4c4e5 671 sample_1.2.3~alpha2.dsc
- 40e4682bb24a73251ccd7c7798c0094a649091e5625d6a14bcec9b4e7174f3da 864 sample_1.2.3~alpha2.tar.xz
+ f91070524a59bbb3a1f05a78409e92cb9ee86470b34018bc0b93bd5b2dd3868c 724 sample_1.2.3~alpha2.dsc
+ c9d05185ca158bb804977fa9d7b922e8a0f644a2da41f99d2787dd61b1e2e2c5 964 sample_1.2.3~alpha2.tar.xz
1c383a525bfcba619c7305ccd106d61db501a6bbaf0003bf8d0c429fbdb7fcc1 1124 libsample0_1.2.3~alpha2_amd64.deb
+ a6bcc8a4b010f99ce0ea566ac69088e1910e754593c77f2b4942e3473e784e4d 1068 sample-ddeb_1.2.3~alpha2_amd64.ddeb
b8aa8b73a14bc1e0012d4c5309770f5160a8ea7f9dfe6f45222ea6b8a3c35325 1164 sample-dev_1.2.3~binary_amd64.deb
2b0c152b3ab4cc07663350424de972c2b7621d69fe6df2e0b94308a191e4632f 736 sample-udeb_1.2.3~alpha2_amd64.udeb
- d0c169e9caa5b303a914b27b5adf69768fe6687d4925905b7d0cd9c0f9d4e56c 5507 sample_1.2.3~alpha2_amd64.buildinfo
+ 5a3dac17c4ff0d49fa5f47baa973902b59ad2ee05147062b8ed8f19d196731d1 6032 sample_1.2.3~alpha2_amd64.buildinfo
Files:
- ceccb6bb3e45ce6550b24234d4023e0f 671 libs optional sample_1.2.3~alpha2.dsc
- d5ca476e4229d135a88f9c729c7606c9 864 libs optional sample_1.2.3~alpha2.tar.xz
+ 629921cfc477bfa84adfd2ccaba89783 724 libs optional sample_1.2.3~alpha2.dsc
+ adc69e57cda38d9bb7c8d59cacfb6869 964 libs optional sample_1.2.3~alpha2.tar.xz
fb0842b21adc44207996296fe14439dd 1124 libs optional libsample0_1.2.3~alpha2_amd64.deb
+ 90d1107471eed48c73ad78b19ac83639 1068 libs optional sample-ddeb_1.2.3~alpha2_amd64.ddeb
5fafc04dcae1525e1367b15413e5a5c7 1164 libdevel optional sample-dev_1.2.3~binary_amd64.deb
72b1dd7d98229e2fb0355feda1d3a165 736 libs optional sample-udeb_1.2.3~alpha2_amd64.udeb
- 12a5ac4f16ad75f8741327ac23b4c0d7 5507 libs optional sample_1.2.3~alpha2_amd64.buildinfo
+ cc07ff4d741aec132816f9bd67c6875d 6032 libs optional sample_1.2.3~alpha2_amd64.buildinfo
diff --git a/spec/fixtures/packages/npm/metadata.json b/spec/fixtures/packages/npm/metadata.json
new file mode 100644
index 00000000000..d23da45fc26
--- /dev/null
+++ b/spec/fixtures/packages/npm/metadata.json
@@ -0,0 +1,20 @@
+{
+ "name": "@root/npm-test",
+ "dist-tags": {
+ "latest": "1.0.1"
+ },
+ "versions": {
+ "1.0.1": {
+ "name": "@root/npm-test",
+ "version": "1.0.1",
+ "main": "app.js",
+ "dependencies": {
+ "express": "^4.16.4"
+ },
+ "dist": {
+ "shasum": "f572d396fae9206628714fb2ce00f72e94f2258f",
+ "tarball": "http://localhost/npm/package.tgz"
+ }
+ }
+ }
+}
diff --git a/spec/fixtures/packages/npm/payload_with_empty_attachment.json b/spec/fixtures/packages/npm/payload_with_empty_attachment.json
new file mode 100644
index 00000000000..299ff32baf3
--- /dev/null
+++ b/spec/fixtures/packages/npm/payload_with_empty_attachment.json
@@ -0,0 +1,29 @@
+{
+ "_id": "@root/npm-test",
+ "name": "@root/npm-test",
+ "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
+ "dist-tags": {
+ "latest": "1.0.1"
+ },
+ "versions": {
+ "1.0.1": {
+ "name": "@root/npm-test",
+ "version": "1.0.1",
+ "main": "app.js",
+ "dependencies": {
+ "express": "^4.16.4"
+ },
+ "dist": {
+ "shasum": "f572d396fae9206628714fb2ce00f72e94f2258f",
+ "tarball": "http://localhost/npm/package.tgz"
+ }
+ }
+ },
+ "_attachments": {
+ "@root/npm-test-1.0.1.tgz": {
+ "data": ""
+ }
+ },
+ "id": "10",
+ "package_name": "@root/npm-test"
+}
diff --git a/spec/fixtures/pages_with_custom_root.zip b/spec/fixtures/pages_with_custom_root.zip
new file mode 100644
index 00000000000..40dea253245
--- /dev/null
+++ b/spec/fixtures/pages_with_custom_root.zip
Binary files differ
diff --git a/spec/fixtures/pages_with_custom_root.zip.meta b/spec/fixtures/pages_with_custom_root.zip.meta
new file mode 100644
index 00000000000..2cb04e0c33b
--- /dev/null
+++ b/spec/fixtures/pages_with_custom_root.zip.meta
Binary files differ
diff --git a/spec/fixtures/pages_with_custom_root.zip.meta0 b/spec/fixtures/pages_with_custom_root.zip.meta0
new file mode 100644
index 00000000000..9b348055b5f
--- /dev/null
+++ b/spec/fixtures/pages_with_custom_root.zip.meta0
Binary files differ
diff --git a/spec/fixtures/scripts/test_report.json b/spec/fixtures/scripts/test_report.json
index 29fd9a4bcb5..520ab3a8578 100644
--- a/spec/fixtures/scripts/test_report.json
+++ b/spec/fixtures/scripts/test_report.json
@@ -1,7 +1,7 @@
{
"suites": [
{
- "name": "rspec unit pg12",
+ "name": "rspec unit pg13",
"total_time": 975.6635620000018,
"total_count": 3811,
"success_count": 3800,
diff --git a/spec/fixtures/security_reports/feature-branch/gl-sast-report.json b/spec/fixtures/security_reports/feature-branch/gl-sast-report.json
index 083042e19ff..f153192fed7 100644
--- a/spec/fixtures/security_reports/feature-branch/gl-sast-report.json
+++ b/spec/fixtures/security_reports/feature-branch/gl-sast-report.json
@@ -1,7 +1,8 @@
{
- "version": "14.0.0",
+ "version": "15.0.0",
"vulnerabilities": [
{
+ "id": "1",
"category": "sast",
"name": "Predictable pseudorandom number generator",
"message": "Predictable pseudorandom number generator",
@@ -29,6 +30,7 @@
]
},
{
+ "id": "2",
"category": "sast",
"name": "Predictable pseudorandom number generator",
"message": "Predictable pseudorandom number generator",
@@ -56,6 +58,7 @@
]
},
{
+ "id": "3",
"category": "sast",
"name": "ECB mode is insecure",
"message": "ECB mode is insecure",
@@ -90,6 +93,7 @@
]
},
{
+ "id": "4",
"category": "sast",
"name": "Hard coded key",
"message": "Hard coded key",
@@ -124,6 +128,7 @@
]
},
{
+ "id": "5",
"category": "sast",
"name": "ECB mode is insecure",
"message": "ECB mode is insecure",
@@ -158,8 +163,19 @@
]
}
],
- "remediations": [],
+ "remediations": [
+
+ ],
"scan": {
+ "analyzer": {
+ "id": "find_sec_bugs_analyzer",
+ "name": "Find Security Bugs Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
"scanner": {
"id": "find_sec_bugs",
"name": "Find Security Bugs",
@@ -174,4 +190,4 @@
"start_time": "2022-08-10T22:37:00",
"end_time": "2022-08-10T22:38:00"
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/security_reports/feature-branch/gl-secret-detection-report.json b/spec/fixtures/security_reports/feature-branch/gl-secret-detection-report.json
index 4862a504cec..c75b9bfb9de 100644
--- a/spec/fixtures/security_reports/feature-branch/gl-secret-detection-report.json
+++ b/spec/fixtures/security_reports/feature-branch/gl-secret-detection-report.json
@@ -1,5 +1,33 @@
{
- "version": "14.1.2",
- "vulnerabilities": [],
- "remediations": []
-} \ No newline at end of file
+ "version": "15.0.0",
+ "vulnerabilities": [
+
+ ],
+ "remediations": [
+
+ ],
+ "scan": {
+ "analyzer": {
+ "id": "secret_detection_analyzer",
+ "name": "Secret Detection Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
+ "scanner": {
+ "id": "secret_detection",
+ "name": "Secret Detection",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "0.1.0"
+ },
+ "type": "sast",
+ "start_time": "2022-03-11T18:48:16",
+ "end_time": "2022-03-11T18:48:22",
+ "status": "success"
+ }
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-missing-scanner.json b/spec/fixtures/security_reports/master/gl-sast-missing-scanner.json
index fcfd9b831f4..16d02490156 100644
--- a/spec/fixtures/security_reports/master/gl-sast-missing-scanner.json
+++ b/spec/fixtures/security_reports/master/gl-sast-missing-scanner.json
@@ -1,7 +1,23 @@
{
- "version": "14.1.2",
+ "version": "15.0.0",
+ "scan": {
+ "analyzer": {
+ "id": "sast_analyzer",
+ "name": "SAST Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
+ "type": "sast",
+ "start_time": "2022-03-11T18:48:16",
+ "end_time": "2022-03-11T18:48:22",
+ "status": "success"
+ },
"vulnerabilities": [
{
+ "id": "1",
"category": "sast",
"message": "Probable insecure usage of temp file/directory.",
"cve": "python/hardcoded/hardcoded-tmp.py:52865813c884a507be1f152d654245af34aba8a391626d01f1ab6d3f52ec8779:B108",
@@ -26,6 +42,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b108_hardcoded_tmp_directory.html"
},
{
+ "id": "2",
"category": "sast",
"name": "Predictable pseudorandom number generator",
"message": "Predictable pseudorandom number generator",
@@ -53,6 +70,7 @@
"url": "https://find-sec-bugs.github.io/bugs.htm#PREDICTABLE_RANDOM"
},
{
+ "id": "3",
"category": "sast",
"name": "Predictable pseudorandom number generator",
"message": "Predictable pseudorandom number generator",
@@ -80,6 +98,7 @@
"url": "https://find-sec-bugs.github.io/bugs.htm#PREDICTABLE_RANDOM"
},
{
+ "id": "4",
"category": "sast",
"message": "Use of insecure MD2, MD4, or MD5 hash function.",
"cve": "python/imports/imports-aliases.py:cb203b465dffb0cb3a8e8bd8910b84b93b0a5995a938e4b903dbb0cd6ffa1254:B303",
@@ -102,6 +121,7 @@
"line": 11
},
{
+ "id": "5",
"category": "sast",
"message": "Use of insecure MD2, MD4, or MD5 hash function.",
"cve": "python/imports/imports-aliases.py:a7173c43ae66bd07466632d819d450e0071e02dbf782763640d1092981f9631b:B303",
@@ -124,6 +144,7 @@
"line": 12
},
{
+ "id": "6",
"category": "sast",
"message": "Use of insecure MD2, MD4, or MD5 hash function.",
"cve": "python/imports/imports-aliases.py:017017b77deb0b8369b6065947833eeea752a92ec8a700db590fece3e934cf0d:B303",
@@ -146,6 +167,7 @@
"line": 13
},
{
+ "id": "6",
"category": "sast",
"message": "Use of insecure MD2, MD4, or MD5 hash function.",
"cve": "python/imports/imports-aliases.py:45fc8c53aea7b84f06bc4e590cc667678d6073c4c8a1d471177ca2146fb22db2:B303",
@@ -168,6 +190,7 @@
"line": 14
},
{
+ "id": "7",
"category": "sast",
"message": "Pickle library appears to be in use, possible security issue.",
"cve": "python/imports/imports-aliases.py:5f200d47291e7bbd8352db23019b85453ca048dd98ea0c291260fa7d009963a4:B301",
@@ -190,6 +213,7 @@
"line": 15
},
{
+ "id": "8",
"category": "sast",
"name": "ECB mode is insecure",
"message": "ECB mode is insecure",
@@ -217,6 +241,7 @@
"url": "https://find-sec-bugs.github.io/bugs.htm#ECB_MODE"
},
{
+ "id": "9",
"category": "sast",
"name": "Cipher with no integrity",
"message": "Cipher with no integrity",
@@ -244,6 +269,7 @@
"url": "https://find-sec-bugs.github.io/bugs.htm#CIPHER_INTEGRITY"
},
{
+ "id": "10",
"category": "sast",
"message": "Probable insecure usage of temp file/directory.",
"cve": "python/hardcoded/hardcoded-tmp.py:63dd4d626855555b816985d82c4614a790462a0a3ada89dc58eb97f9c50f3077:B108",
@@ -268,6 +294,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b108_hardcoded_tmp_directory.html"
},
{
+ "id": "11",
"category": "sast",
"message": "Probable insecure usage of temp file/directory.",
"cve": "python/hardcoded/hardcoded-tmp.py:4ad6d4c40a8c263fc265f3384724014e0a4f8dd6200af83e51ff120420038031:B108",
@@ -292,6 +319,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b108_hardcoded_tmp_directory.html"
},
{
+ "id": "12",
"category": "sast",
"message": "Consider possible security implications associated with Popen module.",
"cve": "python/imports/imports-aliases.py:2c3e1fa1e54c3c6646e8bcfaee2518153c6799b77587ff8d9a7b0631f6d34785:B404",
@@ -314,6 +342,7 @@
"line": 1
},
{
+ "id": "13",
"category": "sast",
"message": "Consider possible security implications associated with pickle module.",
"cve": "python/imports/imports.py:af58d07f6ad519ef5287fcae65bf1a6999448a1a3a8bc1ac2a11daa80d0b96bf:B403",
@@ -336,6 +365,7 @@
"line": 2
},
{
+ "id": "14",
"category": "sast",
"message": "Consider possible security implications associated with subprocess module.",
"cve": "python/imports/imports.py:8de9bc98029d212db530785a5f6780cfa663548746ff228ab8fa96c5bb82f089:B404",
@@ -358,6 +388,7 @@
"line": 4
},
{
+ "id": "15",
"category": "sast",
"message": "Possible hardcoded password: 'blerg'",
"cve": "python/hardcoded/hardcoded-passwords.py:97c30f1d76d2a88913e3ce9ae74087874d740f87de8af697a9c455f01119f633:B106",
@@ -382,6 +413,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b106_hardcoded_password_funcarg.html"
},
{
+ "id": "16",
"category": "sast",
"message": "Possible hardcoded password: 'root'",
"cve": "python/hardcoded/hardcoded-passwords.py:7431c73a0bc16d94ece2a2e75ef38f302574d42c37ac0c3c38ad0b3bf8a59f10:B105",
@@ -406,6 +438,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b105_hardcoded_password_string.html"
},
{
+ "id": "17",
"category": "sast",
"message": "Possible hardcoded password: ''",
"cve": "python/hardcoded/hardcoded-passwords.py:d2d1857c27caedd49c57bfbcdc23afcc92bd66a22701fcdc632869aab4ca73ee:B105",
@@ -430,6 +463,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b105_hardcoded_password_string.html"
},
{
+ "id": "18",
"category": "sast",
"message": "Possible hardcoded password: 'ajklawejrkl42348swfgkg'",
"cve": "python/hardcoded/hardcoded-passwords.py:fb3866215a61393a5c9c32a3b60e2058171a23219c353f722cbd3567acab21d2:B105",
@@ -454,6 +488,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b105_hardcoded_password_string.html"
},
{
+ "id": "19",
"category": "sast",
"message": "Possible hardcoded password: 'blerg'",
"cve": "python/hardcoded/hardcoded-passwords.py:63c62a8b7e1e5224439bd26b28030585ac48741e28ca64561a6071080c560a5f:B105",
@@ -478,6 +513,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b105_hardcoded_password_string.html"
},
{
+ "id": "20",
"category": "sast",
"message": "Possible hardcoded password: 'blerg'",
"cve": "python/hardcoded/hardcoded-passwords.py:4311b06d08df8fa58229b341c531da8e1a31ec4520597bdff920cd5c098d86f9:B105",
@@ -502,6 +538,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b105_hardcoded_password_string.html"
},
{
+ "id": "21",
"category": "sast",
"message": "Consider possible security implications associated with subprocess module.",
"cve": "python/imports/imports-function.py:5858400c2f39047787702de44d03361ef8d954c9d14bd54ee1c2bef9e6a7df93:B404",
@@ -524,6 +561,7 @@
"line": 4
},
{
+ "id": "22",
"category": "sast",
"message": "Consider possible security implications associated with pickle module.",
"cve": "python/imports/imports-function.py:dbda3cf4190279d30e0aad7dd137eca11272b0b225e8af4e8bf39682da67d956:B403",
@@ -546,6 +584,7 @@
"line": 2
},
{
+ "id": "23",
"category": "sast",
"message": "Consider possible security implications associated with Popen module.",
"cve": "python/imports/imports-from.py:eb8a0db9cd1a8c1ab39a77e6025021b1261cc2a0b026b2f4a11fca4e0636d8dd:B404",
@@ -568,6 +607,7 @@
"line": 7
},
{
+ "id": "24",
"category": "sast",
"message": "subprocess call with shell=True seems safe, but may be changed in the future, consider rewriting without shell",
"cve": "python/imports/imports-aliases.py:f99f9721e27537fbcb6699a4cf39c6740d6234d2c6f06cfc2d9ea977313c483d:B602",
@@ -592,6 +632,7 @@
"url": "https://docs.openstack.org/bandit/latest/plugins/b602_subprocess_popen_with_shell_equals_true.html"
},
{
+ "id": "25",
"category": "sast",
"message": "Consider possible security implications associated with subprocess module.",
"cve": "python/imports/imports-from.py:332a12ab1146698f614a905ce6a6a5401497a12281aef200e80522711c69dcf4:B404",
@@ -614,6 +655,7 @@
"line": 6
},
{
+ "id": "26",
"category": "sast",
"message": "Consider possible security implications associated with Popen module.",
"cve": "python/imports/imports-from.py:0a48de4a3d5348853a03666cb574697e3982998355e7a095a798bd02a5947276:B404",
@@ -636,6 +678,7 @@
"line": 1
},
{
+ "id": "27",
"category": "sast",
"message": "Consider possible security implications associated with pickle module.",
"cve": "python/imports/imports-aliases.py:51b71661dff994bde3529639a727a678c8f5c4c96f00d300913f6d5be1bbdf26:B403",
@@ -658,6 +701,7 @@
"line": 7
},
{
+ "id": "28",
"category": "sast",
"message": "Consider possible security implications associated with loads module.",
"cve": "python/imports/imports-aliases.py:6ff02aeb3149c01ab68484d794a94f58d5d3e3bb0d58557ef4153644ea68ea54:B403",
@@ -680,6 +724,7 @@
"line": 6
},
{
+ "id": "29",
"category": "sast",
"message": "Statically-sized arrays can be improperly restricted, leading to potential overflows or other issues (CWE-119!/CWE-120)",
"cve": "c/subdir/utils.c:b466873101951fe96e1332f6728eb7010acbbd5dfc3b65d7d53571d091a06d9e:CWE-119!/CWE-120",
@@ -713,6 +758,7 @@
"url": "https://cwe.mitre.org/data/definitions/119.html"
},
{
+ "id": "30",
"category": "sast",
"message": "Check when opening files - can an attacker redirect it (via symlinks), force the opening of special file type (e.g., device files), move things around to create a race condition, control its ancestors, or change its contents? (CWE-362)",
"cve": "c/subdir/utils.c:bab681140fcc8fc3085b6bba74081b44ea145c1c98b5e70cf19ace2417d30770:CWE-362",
@@ -739,6 +785,7 @@
"url": "https://cwe.mitre.org/data/definitions/362.html"
},
{
+ "id": "31",
"category": "sast",
"message": "Statically-sized arrays can be improperly restricted, leading to potential overflows or other issues (CWE-119!/CWE-120)",
"cve": "cplusplus/src/hello.cpp:c8c6dd0afdae6814194cf0930b719f757ab7b379cf8f261e7f4f9f2f323a818a:CWE-119!/CWE-120",
@@ -772,6 +819,7 @@
"url": "https://cwe.mitre.org/data/definitions/119.html"
},
{
+ "id": "32",
"category": "sast",
"message": "Does not check for buffer overflows when copying to destination [MS-banned] (CWE-120)",
"cve": "cplusplus/src/hello.cpp:331c04062c4fe0c7c486f66f59e82ad146ab33cdd76ae757ca41f392d568cbd0:CWE-120",
@@ -799,4 +847,4 @@
"url": "https://cwe.mitre.org/data/definitions/120.html"
}
]
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-bandit.json b/spec/fixtures/security_reports/master/gl-sast-report-bandit.json
index d0346479b85..690c58d049b 100644
--- a/spec/fixtures/security_reports/master/gl-sast-report-bandit.json
+++ b/spec/fixtures/security_reports/master/gl-sast-report-bandit.json
@@ -1,5 +1,5 @@
{
- "version": "14.0.4",
+ "version": "15.0.4",
"vulnerabilities": [
{
"id": "985a5666dcae22adef5ac12f8a8a2dacf9b9b481ae5d87cd0ac1712b0fd64864",
@@ -26,6 +26,15 @@
}
],
"scan": {
+ "analyzer": {
+ "id": "find_sec_bugs_analyzer",
+ "name": "Find Security Bugs Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
"scanner": {
"id": "bandit",
"name": "Bandit",
@@ -40,4 +49,4 @@
"end_time": "2022-03-11T00:21:50",
"status": "success"
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-gosec.json b/spec/fixtures/security_reports/master/gl-sast-report-gosec.json
index 4c385326c8c..ef1d06d2e4f 100644
--- a/spec/fixtures/security_reports/master/gl-sast-report-gosec.json
+++ b/spec/fixtures/security_reports/master/gl-sast-report-gosec.json
@@ -1,5 +1,5 @@
{
- "version": "14.0.4",
+ "version": "15.0.4",
"vulnerabilities": [
{
"id": "2e5656ff30e2e7cc93c36b4845c8a689ddc47fdbccf45d834c67442fbaa89be0",
@@ -51,6 +51,15 @@
}
],
"scan": {
+ "analyzer": {
+ "id": "find_sec_bugs_analyzer",
+ "name": "Find Security Bugs Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
"scanner": {
"id": "gosec",
"name": "Gosec",
@@ -65,4 +74,4 @@
"end_time": "2022-03-15T20:33:17",
"status": "success"
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-minimal.json b/spec/fixtures/security_reports/master/gl-sast-report-minimal.json
index 53d15224b30..d29571638ff 100644
--- a/spec/fixtures/security_reports/master/gl-sast-report-minimal.json
+++ b/spec/fixtures/security_reports/master/gl-sast-report-minimal.json
@@ -1,7 +1,8 @@
{
- "version": "14.0.0",
+ "version": "15.0.0",
"vulnerabilities": [
{
+ "id": "1",
"category": "sast",
"name": "Cipher with no integrity",
"message": "Cipher with no integrity",
@@ -49,8 +50,19 @@
}
}
],
- "remediations": [],
+ "remediations": [
+
+ ],
"scan": {
+ "analyzer": {
+ "id": "find_sec_bugs_analyzer",
+ "name": "Find Security Bugs Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
"scanner": {
"id": "find_sec_bugs",
"name": "Find Security Bugs",
@@ -65,4 +77,4 @@
"start_time": "2022-08-10T21:37:00",
"end_time": "2022-08-10T21:38:00"
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json
index 037b9fb8d3e..c51abf46c13 100644
--- a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json
+++ b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json
@@ -1,5 +1,5 @@
{
- "version": "14.0.4",
+ "version": "15.0.4",
"vulnerabilities": [
{
"id": "985a5666dcae22adef5ac12f8a8a2dacf9b9b481ae5d87cd0ac1712b0fd64864",
@@ -54,6 +54,15 @@
}
],
"scan": {
+ "analyzer": {
+ "id": "find_sec_bugs_analyzer",
+ "name": "Find Security Bugs Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
"scanner": {
"id": "semgrep",
"name": "Semgrep",
@@ -68,4 +77,4 @@
"end_time": "2022-03-11T18:48:22",
"status": "success"
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json
index 8fa85c30b56..9a6dd4190c5 100644
--- a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json
+++ b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json
@@ -1,5 +1,5 @@
{
- "version": "14.0.4",
+ "version": "15.0.4",
"vulnerabilities": [
{
"id": "79f6537b7ec83c7717f5bd1a4f12645916caafefe2e4359148d889855505aa67",
@@ -53,6 +53,15 @@
}
],
"scan": {
+ "analyzer": {
+ "id": "find_sec_bugs_analyzer",
+ "name": "Find Security Bugs Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
"scanner": {
"id": "semgrep",
"name": "Semgrep",
@@ -74,4 +83,4 @@
"end_time": "2022-03-15T20:37:05",
"status": "success"
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-multiple-findings.json b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-multiple-findings.json
index cbdfdb86f6b..e3659c70710 100644
--- a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-multiple-findings.json
+++ b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-multiple-findings.json
@@ -1,5 +1,5 @@
{
- "version": "14.0.4",
+ "version": "15.0.4",
"vulnerabilities": [
{
"id": "985a5666dcae22adef5ac12f8a8a2dacf9b9b481ae5d87cd0ac1712b0fd64864",
@@ -104,6 +104,15 @@
}
],
"scan": {
+ "analyzer": {
+ "id": "semgrep_analyzer",
+ "name": "Semgrep Analyzer",
+ "url": "https://gitlab.com/",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
"scanner": {
"id": "semgrep",
"name": "Semgrep",
@@ -131,4 +140,4 @@
"end_time": "2022-03-15T20:37:05",
"status": "success"
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report.json b/spec/fixtures/security_reports/master/gl-sast-report.json
index 0ec31252e97..1bd1f241a6d 100644
--- a/spec/fixtures/security_reports/master/gl-sast-report.json
+++ b/spec/fixtures/security_reports/master/gl-sast-report.json
@@ -1,7 +1,8 @@
{
- "version": "14.0.0",
+ "version": "15.0.0",
"vulnerabilities": [
{
+ "id": "1_481ae5d87cd0ac1712b0fd64864",
"category": "sast",
"name": "Predictable pseudorandom number generator",
"message": "Predictable pseudorandom number generator",
@@ -39,6 +40,7 @@
]
},
{
+ "id": "2_481ae5d87cd0ac1712b0fd64864",
"category": "sast",
"name": "Predictable pseudorandom number generator",
"message": "Predictable pseudorandom number generator",
@@ -66,6 +68,7 @@
]
},
{
+ "id": "3_481ae5d87cd0ac1712b0fd64864",
"category": "sast",
"name": "ECB mode is insecure",
"message": "ECB mode is insecure",
@@ -100,6 +103,7 @@
]
},
{
+ "id": "4_481ae5d87cd0ac1712b0fd64864",
"category": "sast",
"name": "Hard coded key",
"message": "Hard coded key",
@@ -134,6 +138,7 @@
]
},
{
+ "id": "5_481ae5d87cd0ac1712b0fd64864",
"category": "sast",
"name": "Cipher with no integrity",
"message": "Cipher with no integrity",
@@ -181,8 +186,19 @@
}
}
],
- "remediations": [],
+ "remediations": [
+
+ ],
"scan": {
+ "analyzer": {
+ "id": "find_sec_bugs_analyzer",
+ "name": "Find Security Bugs Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
"scanner": {
"id": "find_sec_bugs",
"name": "Find Security Bugs",
@@ -197,4 +213,4 @@
"start_time": "2022-08-10T21:37:00",
"end_time": "2022-08-10T21:38:00"
}
-} \ No newline at end of file
+}
diff --git a/spec/fixtures/security_reports/master/gl-secret-detection-report.json b/spec/fixtures/security_reports/master/gl-secret-detection-report.json
index cb97b60ced1..43c079e8769 100644
--- a/spec/fixtures/security_reports/master/gl-secret-detection-report.json
+++ b/spec/fixtures/security_reports/master/gl-secret-detection-report.json
@@ -1,5 +1,29 @@
{
- "version": "14.1.2",
+ "version": "15.0.0",
+ "scan": {
+ "analyzer": {
+ "id": "secret_detection_analyzer",
+ "name": "Secret Detection Analyzer",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.0.0"
+ },
+ "scanner": {
+ "id": "secret_detection",
+ "name": "Secret Detection",
+ "url": "https://gitlab.com",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "0.1.0"
+ },
+ "type": "sast",
+ "start_time": "2022-03-11T18:48:16",
+ "end_time": "2022-03-11T18:48:22",
+ "status": "success"
+ },
"vulnerabilities": [
{
"id": "27d2322d519c94f803ffed1cf6d14e455df97e5a0668e229eb853fdb0d277d2c",
@@ -17,7 +41,8 @@
"location": {
"file": "aws-key.py",
"dependency": {
- "package": {}
+ "package": {
+ }
},
"commit": {
"sha": "e9c3a56590d5bed4155c0d128f1552d52fdcc7ae"
@@ -32,5 +57,7 @@
]
}
],
- "remediations": []
-} \ No newline at end of file
+ "remediations": [
+
+ ]
+}
diff --git a/spec/fixtures/service_account.json b/spec/fixtures/service_account.json
new file mode 100644
index 00000000000..31ef182f8c2
--- /dev/null
+++ b/spec/fixtures/service_account.json
@@ -0,0 +1,12 @@
+{
+ "type": "service_account",
+ "project_id": "demo-app-123",
+ "private_key_id": "47f0b1700983da548af6fcd37007f42996099999",
+ "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDJn8w20WcN+fi5\nIhO1BEFCv7ExK8J5rW5Pc8XpJgpQoL5cfv6qC6aS+x4maI7S4AG7diqXBLCfjlnA\nqBzXwCRnnPtQhu+v1ehAj5fGNa7F51f9aacRNmKdHzNmWZEPDuLqq0I/Ewcsotu+\nnb+tCYk1o2ahyPZau8JtXFZs7oZb7SrfgoSJemccxeVreGm1Dt6SM74/3qJAeHN/\niK/v0IiQP1GS4Jxgz38XQGo+jiTpNrFcf4S0RNxKcNf+tuuEBDi57LBLwdotM7E5\nF1l9pZZMWkmQKQIxeER6+2HuE56V6QPITwkQ/u9XZFQSgl4SBIw2sHr5D/xaUxjw\n+kMy2Jt9AgMBAAECggEACL7E34rRIWbP043cv3ZQs1RiWzY2mvWmCiMEzkz0rRRv\nyqNv0yXVYtzVV7KjdpY56leLgjM1Sv0PEQoUUtpWFJAXSXdKLaewSXPrpXCoz5OD\nekMgeItnQcE7nECdyAKsCSQw/SXg4t4p0a3WGsCwt3If2TwWIrov9R4zGcn1wMZn\n922WtZDmh2NqdTZIKElWZLxNlIr/1v88mAp7oSa1DLfqWkwEEnxK7GGAiwN8ARIF\nkvgiuKdsHBf5aNKg70xN6AcZx/Z4+KZxXxyKKF5VkjCtDzA97EjJqftDPwGTkela\n2bgiDSJs0Un0wQpFFRDrlfyo7rr9Ey/Gf4rR66NWeQKBgQD7qPP55xoWHCDvoK9P\nMN67qFLNDPWcKVKr8siwUlZ6/+acATXjfNUjsJLM7vBxYLjdtFxQ/vojJTQyMxHt\n80wARDk1DTu2zhltL2rKo6LfbwjQsot1MLZFXAMwqtHTLfURaj8kO1JDV/j+4a94\nP0gzNMiBYAKWm6z08akEz2TrhQKBgQDNGfFvtxo4Mf6AA3iYXCwc0CJXb+cqZkW/\n7glnV+vDqYVo23HJaKHFD+Xqaj+cUrOUNglWgT9WSCZR++Hzw1OCPZvX2V9Z6eQh\ngqOBX6D19q9jfShfxLywEAD5pk7LMINumsNm6H+6shJQK5c67bsM9/KQbSnIlWhw\n7JBe8OlFmQKBgQDREyF2mb/7ZG0ch8N9qB0zjHkV79FRZqdPQUnn6s/8KgO90eei\nUkCFARpE9bF+kBul3UTg6aSIdE0z82fO51VZ11Qrtg3JJtrK8hznsyEKPaX2NI9V\n0h1r7DCeSxw9NS4nxLwmbr4+QqUTpA3yeaiTGiQGD+y2kSkU6nxACclPPQKBgFkb\nkVqg6YJKrjB90ZIYUY3/GzxzwLIaFumpCGretu6eIvkIhiokDExqeNBccuB+ych1\npZ7wrkzVMdjinythzFFEZQXlSdjtlhC9Cj52Bp92GoMV6EmbVwMDIPlVuNvsat3N\n3WFDV+ML5IryNVUD3gVnX/pBgyrDRsnw7VRiRGbZAoGBANxZwGKZo0zpyb5O5hS6\nxVrgJtIySlV5BOEjFXKeLwzByht8HmrHhSWix6WpPejfK1RHhl3boU6t9yeC0cre\nvUI/Y9LBhHXjSwWCWlqVe9yYqsde+xf0UYRS8IoaoJjus7YVJr9yPpCboEF28ZmQ\ndVBlpZYg6oLIar6waaLMz/1B\n-----END PRIVATE KEY-----\n",
+ "client_email": "demo-app-account@demo-app-374914.iam.gserviceaccount.com",
+ "client_id": "111111116847110173051",
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": "https://oauth2.googleapis.com/token",
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/demo-app-account%40demo-app-374914.iam.gserviceaccount.com"
+}
diff --git a/spec/fixtures/structure.sql b/spec/fixtures/structure.sql
index 49061dfa8ea..11e4f754abc 100644
--- a/spec/fixtures/structure.sql
+++ b/spec/fixtures/structure.sql
@@ -13,8 +13,87 @@ CREATE INDEX index_users_on_public_email_excluding_null_and_empty ON users USING
ALTER TABLE ONLY bulk_import_configurations
ADD CONSTRAINT fk_rails_536b96bff1 FOREIGN KEY (bulk_import_id) REFERENCES bulk_imports(id) ON DELETE CASCADE;
+CREATE TABLE test_table (
+ id bigint NOT NULL,
+ integer_column integer,
+ integer_with_default_column integer DEFAULT 1,
+ smallint_column smallint,
+ smallint_with_default_column smallint DEFAULT 0 NOT NULL,
+ numeric_column numeric NOT NULL,
+ numeric_with_default_column numeric DEFAULT 1.0 NOT NULL,
+ boolean_colum boolean,
+ boolean_with_default_colum boolean DEFAULT true NOT NULL,
+ double_precision_column double precision,
+ double_precision_with_default_column double precision DEFAULT 1.0,
+ varying_column character varying,
+ varying_with_default_column character varying DEFAULT 'DEFAULT'::character varying NOT NULL,
+ varying_with_limit_column character varying(255),
+ varying_with_limit_and_default_column character varying(255) DEFAULT 'DEFAULT'::character varying,
+ text_column text NOT NULL,
+ text_with_default_column text DEFAULT ''::text NOT NULL,
+ array_column character varying(255)[] NOT NULL,
+ array_with_default_column character varying(255)[] DEFAULT '{one,two}'::character varying[] NOT NULL,
+ jsonb_column jsonb,
+ jsonb_with_default_column jsonb DEFAULT '[]'::jsonb NOT NULL,
+ timestamptz_column timestamp with time zone,
+ timestamptz_with_default_column timestamp(6) with time zone DEFAULT now(),
+ timestamp_column timestamp(6) without time zone NOT NULL,
+ timestamp_with_default_column timestamp(6) without time zone DEFAULT '2022-01-23 00:00:00+00'::timestamp without time zone NOT NULL,
+ date_column date,
+ date_with_default_column date DEFAULT '2023-04-05',
+ inet_column inet NOT NULL,
+ inet_with_default_column inet DEFAULT '0.0.0.0'::inet NOT NULL,
+ macaddr_column macaddr,
+ macaddr_with_default_column macaddr DEFAULT '00-00-00-00-00-000'::macaddr NOT NULL,
+ uuid_column uuid NOT NULL,
+ uuid_with_default_column uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL,
+ bytea_column bytea,
+ bytea_with_default_column bytea DEFAULT '\xDEADBEEF'::bytea,
+ unmapped_column_type anyarray,
+ partition_key bigint DEFAULT 1 NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL
+) PARTITION BY HASH (partition_key, created_at);
+
CREATE TABLE ci_project_mirrors (
id bigint NOT NULL,
project_id integer NOT NULL,
namespace_id integer NOT NULL
);
+
+CREATE TABLE wrong_table (
+ id bigint NOT NULL,
+ description character varying(255) NOT NULL
+);
+
+CREATE TABLE extra_table_columns (
+ id bigint NOT NULL,
+ name character varying(255) NOT NULL
+);
+
+CREATE TABLE missing_table (
+ id bigint NOT NULL,
+ description text NOT NULL
+);
+
+CREATE TABLE missing_table_columns (
+ id bigint NOT NULL,
+ email character varying(255) NOT NULL
+);
+
+CREATE TABLE operations_user_lists (
+ id bigint NOT NULL,
+ project_id bigint NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ iid integer NOT NULL,
+ name character varying(255) NOT NULL,
+ user_xids text DEFAULT ''::text NOT NULL
+);
+
+CREATE TRIGGER trigger AFTER INSERT ON public.t1 FOR EACH ROW EXECUTE FUNCTION t1();
+
+CREATE TRIGGER wrong_trigger BEFORE UPDATE ON public.t2 FOR EACH ROW EXECUTE FUNCTION my_function();
+
+CREATE TRIGGER missing_trigger_1 BEFORE INSERT OR UPDATE ON public.t3 FOR EACH ROW EXECUTE FUNCTION t3();
+
+CREATE TRIGGER projects_loose_fk_trigger AFTER DELETE ON projects REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION insert_into_loose_foreign_keys_deleted_records();
diff --git a/spec/fixtures/work_items_invalid_types.csv b/spec/fixtures/work_items_invalid_types.csv
new file mode 100644
index 00000000000..b82e9035451
--- /dev/null
+++ b/spec/fixtures/work_items_invalid_types.csv
@@ -0,0 +1,4 @@
+title,type
+Invalid issue,isssue
+Invalid Issue,issue!!
+Missing type,
diff --git a/spec/fixtures/work_items_missing_header.csv b/spec/fixtures/work_items_missing_header.csv
new file mode 100644
index 00000000000..1a2e7ed42ab
--- /dev/null
+++ b/spec/fixtures/work_items_missing_header.csv
@@ -0,0 +1,3 @@
+type,created_at
+issue,2021-01-01
+other_issue,2023-02-02
diff --git a/spec/fixtures/work_items_valid.csv b/spec/fixtures/work_items_valid.csv
new file mode 100644
index 00000000000..12f2f8bc816
--- /dev/null
+++ b/spec/fixtures/work_items_valid.csv
@@ -0,0 +1,3 @@
+title,type
+馬のスモモモモモモモモ,Issue
+Issue with alternate case,ISSUE
diff --git a/spec/fixtures/work_items_valid_types.csv b/spec/fixtures/work_items_valid_types.csv
new file mode 100644
index 00000000000..f4cca9e65f1
--- /dev/null
+++ b/spec/fixtures/work_items_valid_types.csv
@@ -0,0 +1,3 @@
+title,type
+Valid issue,issue
+Valid issue with alternate case,ISSUE
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml
index 45639f4c948..200f539fb3e 100644
--- a/spec/frontend/.eslintrc.yml
+++ b/spec/frontend/.eslintrc.yml
@@ -12,6 +12,7 @@ settings:
jest:
jestConfigFile: 'jest.config.js'
rules:
+ '@gitlab/vtu-no-explicit-wrapper-destroy': error
jest/expect-expect:
- off
- assertFunctionNames:
diff --git a/spec/frontend/__helpers__/assert_props.js b/spec/frontend/__helpers__/assert_props.js
new file mode 100644
index 00000000000..9935719580a
--- /dev/null
+++ b/spec/frontend/__helpers__/assert_props.js
@@ -0,0 +1,41 @@
+import { mount } from '@vue/test-utils';
+import { ErrorWithStack } from 'jest-util';
+
+function installConsoleHandler(method) {
+ const originalHandler = global.console[method];
+
+ global.console[method] = function throwableHandler(...args) {
+ if (args[0]?.includes('Invalid prop') || args[0]?.includes('Missing required prop')) {
+ throw new ErrorWithStack(
+ `Unexpected call of console.${method}() with:\n\n${args.join(', ')}`,
+ this[method],
+ );
+ }
+
+ originalHandler.apply(this, args);
+ };
+
+ return function restore() {
+ global.console[method] = originalHandler;
+ };
+}
+
+export function assertProps(Component, props, extraMountArgs = {}) {
+ const [restoreError, restoreWarn] = [
+ installConsoleHandler('error'),
+ installConsoleHandler('warn'),
+ ];
+ const ComponentWithoutRenderFn = {
+ ...Component,
+ render() {
+ return '';
+ },
+ };
+
+ try {
+ mount(ComponentWithoutRenderFn, { propsData: props, ...extraMountArgs });
+ } finally {
+ restoreError();
+ restoreWarn();
+ }
+}
diff --git a/spec/frontend/__helpers__/create_mock_source_editor_extension.js b/spec/frontend/__helpers__/create_mock_source_editor_extension.js
new file mode 100644
index 00000000000..fa529604d6f
--- /dev/null
+++ b/spec/frontend/__helpers__/create_mock_source_editor_extension.js
@@ -0,0 +1,12 @@
+export const createMockSourceEditorExtension = (ActualExtension) => {
+ const { extensionName } = ActualExtension;
+ const providedKeys = Object.keys(new ActualExtension().provides());
+
+ const mockedMethods = Object.fromEntries(providedKeys.map((key) => [key, jest.fn()]));
+ const MockExtension = function MockExtension() {};
+ MockExtension.extensionName = extensionName;
+ MockExtension.mockedMethods = mockedMethods;
+ MockExtension.prototype.provides = jest.fn().mockReturnValue(mockedMethods);
+
+ return MockExtension;
+};
diff --git a/spec/frontend/__helpers__/experimentation_helper.js b/spec/frontend/__helpers__/experimentation_helper.js
index d5044be88d7..7e8dd463d28 100644
--- a/spec/frontend/__helpers__/experimentation_helper.js
+++ b/spec/frontend/__helpers__/experimentation_helper.js
@@ -2,16 +2,9 @@ import { merge } from 'lodash';
// This helper is for specs that use `gitlab/experimentation` module
export function withGonExperiment(experimentKey, value = true) {
- let origGon;
-
beforeEach(() => {
- origGon = window.gon;
window.gon = merge({}, window.gon || {}, { experiments: { [experimentKey]: value } });
});
-
- afterEach(() => {
- window.gon = origGon;
- });
}
// The following helper is for specs that use `gitlab-experiment` utilities,
diff --git a/spec/frontend/__helpers__/fixtures.js b/spec/frontend/__helpers__/fixtures.js
index a6f7b37161e..c66411979e9 100644
--- a/spec/frontend/__helpers__/fixtures.js
+++ b/spec/frontend/__helpers__/fixtures.js
@@ -12,7 +12,10 @@ export function getFixture(relativePath) {
throw new ErrorWithStack(
`Fixture file ${relativePath} does not exist.
-Did you run bin/rake frontend:fixtures?`,
+Did you run bin/rake frontend:fixtures? You can also download fixtures from the gitlab-org/gitlab package registry.
+
+See https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html#download-fixtures for more info.
+`,
getFixture,
);
}
diff --git a/spec/frontend/__helpers__/gon_helper.js b/spec/frontend/__helpers__/gon_helper.js
new file mode 100644
index 00000000000..51d5ece5fc1
--- /dev/null
+++ b/spec/frontend/__helpers__/gon_helper.js
@@ -0,0 +1,5 @@
+export const createGon = (IS_EE) => {
+ return {
+ ee: IS_EE,
+ };
+};
diff --git a/spec/frontend/__helpers__/init_vue_mr_page_helper.js b/spec/frontend/__helpers__/init_vue_mr_page_helper.js
index d01affdaeac..3dccbd9fbef 100644
--- a/spec/frontend/__helpers__/init_vue_mr_page_helper.js
+++ b/spec/frontend/__helpers__/init_vue_mr_page_helper.js
@@ -6,9 +6,17 @@ import { getDiffFileMock } from '../diffs/mock_data/diff_file';
import { userDataMock, notesDataMock, noteableDataMock } from '../notes/mock_data';
export default function initVueMRPage() {
+ const contentWrapperEl = document.createElement('div');
+ contentWrapperEl.className = 'content-wrapper';
+ document.body.appendChild(contentWrapperEl);
+
+ const containerEl = document.createElement('div');
+ containerEl.className = 'container-fluid';
+ contentWrapperEl.appendChild(containerEl);
+
const mrTestEl = document.createElement('div');
mrTestEl.className = 'js-merge-request-test';
- document.body.appendChild(mrTestEl);
+ containerEl.appendChild(mrTestEl);
const diffsAppEndpoint = '/diffs/app/endpoint';
const diffsAppProjectPath = 'testproject';
diff --git a/spec/frontend/__helpers__/keep_alive_component_helper_spec.js b/spec/frontend/__helpers__/keep_alive_component_helper_spec.js
index 54d397d0997..8b6cdedfd9f 100644
--- a/spec/frontend/__helpers__/keep_alive_component_helper_spec.js
+++ b/spec/frontend/__helpers__/keep_alive_component_helper_spec.js
@@ -12,10 +12,6 @@ describe('keepAlive', () => {
wrapper = mount(keepAlive(component));
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('converts a component to a keep-alive component', async () => {
const { element } = wrapper.findComponent(component);
diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js
index 2fe9fe89a90..0217835b2a3 100644
--- a/spec/frontend/__helpers__/shared_test_setup.js
+++ b/spec/frontend/__helpers__/shared_test_setup.js
@@ -1,10 +1,12 @@
/* Common setup for both unit and integration test environments */
+import { ReadableStream, WritableStream } from 'node:stream/web';
import * as jqueryMatchers from 'custom-jquery-matchers';
import Vue from 'vue';
import { enableAutoDestroy } from '@vue/test-utils';
import 'jquery';
import Translate from '~/vue_shared/translate';
import setWindowLocation from './set_window_location_helper';
+import { createGon } from './gon_helper';
import { setGlobalDateToFakeDate } from './fake_date';
import { TEST_HOST } from './test_constants';
import * as customMatchers from './matchers';
@@ -13,6 +15,9 @@ import './dom_shims';
import './jquery';
import '~/commons/bootstrap';
+global.ReadableStream = ReadableStream;
+global.WritableStream = WritableStream;
+
enableAutoDestroy(afterEach);
// This module has some fairly decent visual test coverage in it's own repository.
@@ -67,8 +72,13 @@ beforeEach(() => {
// eslint-disable-next-line jest/no-standalone-expect
expect.hasAssertions();
- // Reset the mocked window.location. This ensures tests don't interfere with
- // each other, and removes the need to tidy up if it was changed for a given
- // test.
+ // Reset globals: This ensures tests don't interfere with
+ // each other, and removes the need to tidy up if it was
+ // changed for a given test.
+
+ // Reset the mocked window.location
setWindowLocation(TEST_HOST);
+
+ // Reset window.gon object
+ window.gon = createGon(window.IS_EE);
});
diff --git a/spec/frontend/__helpers__/vue_mock_directive.js b/spec/frontend/__helpers__/vue_mock_directive.js
index e952f258c4d..e7a2aa7f10d 100644
--- a/spec/frontend/__helpers__/vue_mock_directive.js
+++ b/spec/frontend/__helpers__/vue_mock_directive.js
@@ -2,7 +2,7 @@ export const getKey = (name) => `$_gl_jest_${name}`;
export const getBinding = (el, name) => el[getKey(name)];
-const writeBindingToElement = (el, { name, value, arg, modifiers }) => {
+const writeBindingToElement = (el, name, { value, arg, modifiers }) => {
el[getKey(name)] = {
value,
arg,
@@ -10,16 +10,24 @@ const writeBindingToElement = (el, { name, value, arg, modifiers }) => {
};
};
-export const createMockDirective = () => ({
- bind(el, binding) {
- writeBindingToElement(el, binding);
- },
+export const createMockDirective = (name) => {
+ if (!name) {
+ throw new Error(
+ 'Vue 3 no longer passes the name of the directive to its hooks, an explicit name is required',
+ );
+ }
- update(el, binding) {
- writeBindingToElement(el, binding);
- },
+ return {
+ bind(el, binding) {
+ writeBindingToElement(el, name, binding);
+ },
- unbind(el, { name }) {
- delete el[getKey(name)];
- },
-});
+ update(el, binding) {
+ writeBindingToElement(el, name, binding);
+ },
+
+ unbind(el) {
+ delete el[getKey(name)];
+ },
+ };
+};
diff --git a/spec/frontend/__helpers__/vue_test_utils_helper.js b/spec/frontend/__helpers__/vue_test_utils_helper.js
index 75bd5df8cbf..c144a256dce 100644
--- a/spec/frontend/__helpers__/vue_test_utils_helper.js
+++ b/spec/frontend/__helpers__/vue_test_utils_helper.js
@@ -83,6 +83,24 @@ export const extendedWrapper = (wrapper) => {
return this.findAll(`[data-testid="${id}"]`);
},
},
+ /*
+ * Keep in mind that there are some limitations when using `findComponent`
+ * with CSS selectors: https://v1.test-utils.vuejs.org/api/wrapper/#findcomponent
+ */
+ findComponentByTestId: {
+ value(id) {
+ return this.findComponent(`[data-testid="${id}"]`);
+ },
+ },
+ /*
+ * Keep in mind that there are some limitations when using `findAllComponents`
+ * with CSS selectors: https://v1.test-utils.vuejs.org/api/wrapper/#findallcomponents
+ */
+ findAllComponentsByTestId: {
+ value(id) {
+ return this.findAllComponents(`[data-testid="${id}"]`);
+ },
+ },
// `findBy`
...AVAILABLE_QUERIES.reduce((accumulator, query) => {
return {
diff --git a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js
index 466333f8a89..2f69a2348d9 100644
--- a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js
+++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js
@@ -128,6 +128,55 @@ describe('Vue test utils helpers', () => {
});
});
+ describe('findComponentByTestId', () => {
+ const testId = 'a-component';
+ let mockChild;
+ let mockComponent;
+
+ beforeEach(() => {
+ mockChild = {
+ template: '<div></div>',
+ };
+ mockComponent = extendedWrapper(
+ shallowMount({
+ render(h) {
+ return h('div', {}, [h(mockChild, { attrs: { 'data-testid': testId } })]);
+ },
+ }),
+ );
+ });
+
+ it('should find the element by test id', () => {
+ expect(mockComponent.findComponentByTestId(testId).exists()).toBe(true);
+ });
+ });
+
+ describe('findAllComponentsByTestId', () => {
+ const testId = 'a-component';
+ let mockComponent;
+ let mockChild;
+
+ beforeEach(() => {
+ mockChild = {
+ template: `<div></div>`,
+ };
+ mockComponent = extendedWrapper(
+ shallowMount({
+ render(h) {
+ return h('div', [
+ h(mockChild, { attrs: { 'data-testid': testId } }),
+ h(mockChild, { attrs: { 'data-testid': testId } }),
+ ]);
+ },
+ }),
+ );
+ });
+
+ it('should find all components by test id', () => {
+ expect(mockComponent.findAllComponentsByTestId(testId)).toHaveLength(2);
+ });
+ });
+
describe.each`
findMethod | expectedQuery
${'findByRole'} | ${'queryAllByRole'}
diff --git a/spec/frontend/__helpers__/vuex_action_helper.js b/spec/frontend/__helpers__/vuex_action_helper.js
index bdd5a0a9034..94164814879 100644
--- a/spec/frontend/__helpers__/vuex_action_helper.js
+++ b/spec/frontend/__helpers__/vuex_action_helper.js
@@ -78,6 +78,8 @@ export default (
}
actions.push(dispatchedAction);
+
+ return Promise.resolve();
};
const validateResults = () => {
diff --git a/spec/frontend/__helpers__/vuex_action_helper_spec.js b/spec/frontend/__helpers__/vuex_action_helper_spec.js
index 4bd21ff150a..64081ca11a3 100644
--- a/spec/frontend/__helpers__/vuex_action_helper_spec.js
+++ b/spec/frontend/__helpers__/vuex_action_helper_spec.js
@@ -83,6 +83,20 @@ describe.each([testActionFn, testActionFnWithOptionsArg])(
});
});
+ describe('given an async action (chaining off a dispatch)', () => {
+ it('mocks dispatch accurately', () => {
+ const asyncAction = ({ commit, dispatch }) => {
+ return dispatch('ACTION').then(() => {
+ commit('MUTATION');
+ });
+ };
+
+ assertion = { actions: [{ type: 'ACTION' }], mutations: [{ type: 'MUTATION' }] };
+
+ return testAction(asyncAction, null, {}, assertion.mutations, assertion.actions);
+ });
+ });
+
describe('given an async action (returning a promise)', () => {
const data = { FOO: 'BAR' };
diff --git a/spec/frontend/__helpers__/wait_for_text.js b/spec/frontend/__helpers__/wait_for_text.js
index 6bed8a90a98..991adc5d6c0 100644
--- a/spec/frontend/__helpers__/wait_for_text.js
+++ b/spec/frontend/__helpers__/wait_for_text.js
@@ -1,3 +1,3 @@
import { findByText } from '@testing-library/dom';
-export const waitForText = async (text, container = document) => findByText(container, text);
+export const waitForText = (text, container = document) => findByText(container, text);
diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js
index 4d893bcd0bd..c51f37db384 100644
--- a/spec/frontend/__mocks__/@gitlab/ui.js
+++ b/spec/frontend/__mocks__/@gitlab/ui.js
@@ -13,13 +13,18 @@ export * from '@gitlab/ui';
* are imported internally in `@gitlab/ui`.
*/
-jest.mock('@gitlab/ui/dist/directives/tooltip.js', () => ({
+/* eslint-disable global-require */
+
+jest.mock('@gitlab/ui/src/directives/tooltip.js', () => ({
GlTooltipDirective: {
bind() {},
},
}));
+jest.mock('@gitlab/ui/dist/directives/tooltip.js', () =>
+ require('@gitlab/ui/src/directives/tooltip'),
+);
-jest.mock('@gitlab/ui/dist/components/base/tooltip/tooltip.js', () => ({
+jest.mock('@gitlab/ui/src/components/base/tooltip/tooltip.vue', () => ({
props: ['target', 'id', 'triggers', 'placement', 'container', 'boundary', 'disabled', 'show'],
render(h) {
return h(
@@ -33,7 +38,11 @@ jest.mock('@gitlab/ui/dist/components/base/tooltip/tooltip.js', () => ({
},
}));
-jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({
+jest.mock('@gitlab/ui/dist/components/base/tooltip/tooltip.js', () =>
+ require('@gitlab/ui/src/components/base/tooltip/tooltip.vue'),
+);
+
+jest.mock('@gitlab/ui/src/components/base/popover/popover.vue', () => ({
props: {
cssClasses: {
type: Array,
@@ -65,3 +74,6 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({
);
},
}));
+jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () =>
+ require('@gitlab/ui/src/components/base/popover/popover.vue'),
+);
diff --git a/spec/frontend/__mocks__/file_mock.js b/spec/frontend/__mocks__/file_mock.js
index 08d725cd4e4..487d1d69de2 100644
--- a/spec/frontend/__mocks__/file_mock.js
+++ b/spec/frontend/__mocks__/file_mock.js
@@ -1 +1 @@
-export default '';
+export default 'file-mock';
diff --git a/spec/frontend/__mocks__/lodash/debounce.js b/spec/frontend/__mocks__/lodash/debounce.js
index d4fe2ce5406..15f806fc31a 100644
--- a/spec/frontend/__mocks__/lodash/debounce.js
+++ b/spec/frontend/__mocks__/lodash/debounce.js
@@ -9,9 +9,22 @@
// Further reference: https://github.com/facebook/jest/issues/3465
export default (fn) => {
- const debouncedFn = jest.fn().mockImplementation(fn);
- debouncedFn.cancel = jest.fn();
- debouncedFn.flush = jest.fn().mockImplementation(() => {
+ let id;
+ const debouncedFn = jest.fn(function run(...args) {
+ // this is calculated in runtime so beforeAll hook works in tests
+ const timeout = global.JEST_DEBOUNCE_THROTTLE_TIMEOUT;
+ if (timeout) {
+ id = setTimeout(() => {
+ fn.apply(this, args);
+ }, timeout);
+ } else {
+ fn.apply(this, args);
+ }
+ });
+ debouncedFn.cancel = jest.fn(() => {
+ clearTimeout(id);
+ });
+ debouncedFn.flush = jest.fn(() => {
const errorMessage =
"The .flush() method returned by lodash.debounce is not yet implemented/mocked by the mock in 'spec/frontend/__mocks__/lodash/debounce.js'.";
diff --git a/spec/frontend/__mocks__/lodash/throttle.js b/spec/frontend/__mocks__/lodash/throttle.js
index e8a82654c78..b1014662918 100644
--- a/spec/frontend/__mocks__/lodash/throttle.js
+++ b/spec/frontend/__mocks__/lodash/throttle.js
@@ -1,4 +1,4 @@
// Similar to `lodash/debounce`, `lodash/throttle` also causes flaky specs.
// See `./debounce.js` for more details.
-export default (fn) => fn;
+export { default } from './debounce';
diff --git a/spec/frontend/__mocks__/mousetrap/index.js b/spec/frontend/__mocks__/mousetrap/index.js
deleted file mode 100644
index 63c92fa9a09..00000000000
--- a/spec/frontend/__mocks__/mousetrap/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-/* global Mousetrap */
-// `mousetrap` uses amd which webpack understands but Jest does not
-// Thankfully it also writes to a global export so we can es6-ify it
-import 'mousetrap';
-
-export default Mousetrap;
diff --git a/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js b/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js
index ec20088c443..5de5f495f01 100644
--- a/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js
+++ b/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js
@@ -33,10 +33,6 @@ describe('AbuseCategorySelector', () => {
createComponent({ showDrawer: true });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findTitle = () => wrapper.findByTestId('category-drawer-title');
diff --git a/spec/frontend/access_tokens/components/expires_at_field_spec.js b/spec/frontend/access_tokens/components/expires_at_field_spec.js
index 491d2a0e323..6605faadc17 100644
--- a/spec/frontend/access_tokens/components/expires_at_field_spec.js
+++ b/spec/frontend/access_tokens/components/expires_at_field_spec.js
@@ -25,10 +25,6 @@ describe('~/access_tokens/components/expires_at_field', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render datepicker with input info', () => {
createComponent();
diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
index e4313bdfa26..fb92cc34ce9 100644
--- a/spec/frontend/access_tokens/components/new_access_token_app_spec.js
+++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
@@ -4,12 +4,12 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue';
import { EVENT_ERROR, EVENT_SUCCESS, FORM_SELECTOR } from '~/access_tokens/components/constants';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { __, sprintf } from '~/locale';
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('~/access_tokens/components/new_access_token_app', () => {
let wrapper;
@@ -52,7 +52,6 @@ describe('~/access_tokens/components/new_access_token_app', () => {
afterEach(() => {
resetHTMLFixture();
- wrapper.destroy();
createAlert.mockClear();
});
diff --git a/spec/frontend/access_tokens/components/token_spec.js b/spec/frontend/access_tokens/components/token_spec.js
index 1af21aaa8cd..f62f7d72e3b 100644
--- a/spec/frontend/access_tokens/components/token_spec.js
+++ b/spec/frontend/access_tokens/components/token_spec.js
@@ -23,10 +23,6 @@ describe('Token', () => {
wrapper = mountExtended(Token, { propsData: defaultPropsData, slots: defaultSlots });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders title slot', () => {
createComponent();
diff --git a/spec/frontend/access_tokens/components/tokens_app_spec.js b/spec/frontend/access_tokens/components/tokens_app_spec.js
index d7acfbb47eb..6e7dee6a2cc 100644
--- a/spec/frontend/access_tokens/components/tokens_app_spec.js
+++ b/spec/frontend/access_tokens/components/tokens_app_spec.js
@@ -54,10 +54,6 @@ describe('TokensApp', () => {
expect(container.props()).toMatchObject(expectedProps);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders all enabled tokens', () => {
createComponent();
diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js
index 1157e44f41a..c1158e0d124 100644
--- a/spec/frontend/access_tokens/index_spec.js
+++ b/spec/frontend/access_tokens/index_spec.js
@@ -112,7 +112,7 @@ describe('access tokens', () => {
);
});
- it('mounts component and sets `inputAttrs` prop', async () => {
+ it('mounts component and sets `inputAttrs` prop', () => {
wrapper = createWrapper(initExpiresAtField());
const component = wrapper.findComponent(ExpiresAtField);
diff --git a/spec/frontend/activities_spec.js b/spec/frontend/activities_spec.js
index ebace21217a..e39aae45ce8 100644
--- a/spec/frontend/activities_spec.js
+++ b/spec/frontend/activities_spec.js
@@ -1,13 +1,13 @@
/* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow */
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlEventFilter from 'test_fixtures_static/event_filter.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import Activities from '~/activities';
import Pager from '~/pager';
describe('Activities', () => {
window.gon || (window.gon = {});
- const fixtureTemplate = 'static/event_filter.html';
const filters = [
{
id: 'all',
@@ -39,7 +39,7 @@ describe('Activities', () => {
}
beforeEach(() => {
- loadHTMLFixture(fixtureTemplate);
+ setHTMLFixture(htmlEventFilter);
jest.spyOn(Pager, 'init').mockImplementation(() => {});
new Activities();
});
diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
index 2c2151bfb41..ddeab3e3b62 100644
--- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
+++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
@@ -6,8 +6,9 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = `
body-class="add-review-item pt-0"
cancel-variant="light"
dismisslabel="Close"
- modalclass=""
+ modalclass="add-review-item-modal"
modalid="add-review-item"
+ nofocusonshow="true"
ok-disabled="true"
ok-title="Save changes"
scrollable="true"
@@ -27,9 +28,14 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = `
<div
class="gl-mt-3"
>
- <gl-search-box-by-type-stub
+ <gl-filtered-search-stub
+ availabletokens="[object Object],[object Object],[object Object]"
+ class="flex-grow-1"
clearbuttontitle="Clear"
- placeholder="Search by commit title or SHA"
+ placeholder="Search or filter commits"
+ searchbuttonattributes="[object Object]"
+ searchinputattributes="[object Object]"
+ searchtextoptionlabel="Search for this text"
value=""
/>
diff --git a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js
index 1d57473943b..27fe010c354 100644
--- a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js
+++ b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js
@@ -1,4 +1,4 @@
-import { GlModal, GlSearchBoxByType } from '@gitlab/ui';
+import { GlModal, GlFilteredSearch } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
@@ -49,16 +49,12 @@ describe('AddContextCommitsModal', () => {
};
const findModal = () => wrapper.findComponent(GlModal);
- const findSearch = () => wrapper.findComponent(GlSearchBoxByType);
+ const findSearch = () => wrapper.findComponent(GlFilteredSearch);
beforeEach(() => {
wrapper = createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders modal with 2 tabs', () => {
expect(wrapper.element).toMatchSnapshot();
});
@@ -72,12 +68,29 @@ describe('AddContextCommitsModal', () => {
expect(findSearch().exists()).toBe(true);
});
- it('when user starts entering text in search box, it calls action "searchCommits" after waiting for 500s', () => {
- const searchText = 'abcd';
- findSearch().vm.$emit('input', searchText);
- expect(searchCommits).not.toHaveBeenCalled();
- jest.advanceTimersByTime(500);
- expect(searchCommits).toHaveBeenCalledWith(expect.anything(), searchText);
+ it('when user submits after entering filters in search box, then it calls action "searchCommits"', () => {
+ const search = [
+ 'abcd',
+ {
+ type: 'author',
+ value: { operator: '=', data: 'abhi' },
+ },
+ {
+ type: 'committed-before-date',
+ value: { operator: '=', data: '2022-10-31' },
+ },
+ {
+ type: 'committed-after-date',
+ value: { operator: '=', data: '2022-10-28' },
+ },
+ ];
+ findSearch().vm.$emit('submit', search);
+ expect(searchCommits).toHaveBeenCalledWith(expect.anything(), {
+ searchText: 'abcd',
+ author: 'abhi',
+ committed_before: '2022-10-31',
+ committed_after: '2022-10-28',
+ });
});
it('disabled ok button when no row is selected', () => {
diff --git a/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js
index f679576182f..975f115c4bb 100644
--- a/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js
+++ b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js
@@ -26,10 +26,6 @@ describe('ReviewTabContainer', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows loading icon when commits are being loaded', () => {
createWrapper({ isLoading: true });
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
diff --git a/spec/frontend/add_context_commits_modal/store/actions_spec.js b/spec/frontend/add_context_commits_modal/store/actions_spec.js
index 27c8d760a96..3863eee3795 100644
--- a/spec/frontend/add_context_commits_modal/store/actions_spec.js
+++ b/spec/frontend/add_context_commits_modal/store/actions_spec.js
@@ -31,10 +31,10 @@ describe('AddContextCommitsModalStoreActions', () => {
short_id: 'abcdef',
committed_date: '2020-06-12',
};
- gon.api_version = 'v4';
let mock;
beforeEach(() => {
+ gon.api_version = 'v4';
mock = new MockAdapter(axios);
});
diff --git a/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js
new file mode 100644
index 00000000000..cabbb5e1591
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js
@@ -0,0 +1,76 @@
+import { shallowMount } from '@vue/test-utils';
+import AbuseReportApp from '~/admin/abuse_report/components/abuse_report_app.vue';
+import ReportHeader from '~/admin/abuse_report/components/report_header.vue';
+import UserDetails from '~/admin/abuse_report/components/user_details.vue';
+import ReportedContent from '~/admin/abuse_report/components/reported_content.vue';
+import HistoryItems from '~/admin/abuse_report/components/history_items.vue';
+import { mockAbuseReport } from '../mock_data';
+
+describe('AbuseReportApp', () => {
+ let wrapper;
+
+ const findReportHeader = () => wrapper.findComponent(ReportHeader);
+ const findUserDetails = () => wrapper.findComponent(UserDetails);
+ const findReportedContent = () => wrapper.findComponent(ReportedContent);
+ const findHistoryItems = () => wrapper.findComponent(HistoryItems);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(AbuseReportApp, {
+ propsData: {
+ abuseReport: mockAbuseReport,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('ReportHeader', () => {
+ it('renders ReportHeader', () => {
+ expect(findReportHeader().props('user')).toBe(mockAbuseReport.user);
+ expect(findReportHeader().props('actions')).toBe(mockAbuseReport.actions);
+ });
+
+ describe('when no user is present', () => {
+ beforeEach(() => {
+ createComponent({
+ abuseReport: { ...mockAbuseReport, user: undefined },
+ });
+ });
+
+ it('does not render the ReportHeader', () => {
+ expect(findReportHeader().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('UserDetails', () => {
+ it('renders UserDetails', () => {
+ expect(findUserDetails().props('user')).toBe(mockAbuseReport.user);
+ });
+
+ describe('when no user is present', () => {
+ beforeEach(() => {
+ createComponent({
+ abuseReport: { ...mockAbuseReport, user: undefined },
+ });
+ });
+
+ it('does not render the UserDetails', () => {
+ expect(findUserDetails().exists()).toBe(false);
+ });
+ });
+ });
+
+ it('renders ReportedContent', () => {
+ expect(findReportedContent().props('report')).toBe(mockAbuseReport.report);
+ expect(findReportedContent().props('reporter')).toBe(mockAbuseReport.reporter);
+ });
+
+ it('renders HistoryItems', () => {
+ expect(findHistoryItems().props('report')).toBe(mockAbuseReport.report);
+ expect(findHistoryItems().props('reporter')).toBe(mockAbuseReport.reporter);
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/history_items_spec.js b/spec/frontend/admin/abuse_report/components/history_items_spec.js
new file mode 100644
index 00000000000..86e994fdc57
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/history_items_spec.js
@@ -0,0 +1,66 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { sprintf } from '~/locale';
+import HistoryItems from '~/admin/abuse_report/components/history_items.vue';
+import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { HISTORY_ITEMS_I18N } from '~/admin/abuse_report/constants';
+import { mockAbuseReport } from '../mock_data';
+
+describe('HistoryItems', () => {
+ let wrapper;
+
+ const { report, reporter } = mockAbuseReport;
+
+ const findHistoryItem = () => wrapper.findComponent(HistoryItem);
+ const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(HistoryItems, {
+ propsData: {
+ report,
+ reporter,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the icon', () => {
+ expect(findHistoryItem().props('icon')).toBe('warning');
+ });
+
+ describe('rendering the title', () => {
+ it('renders the reporters name and the category', () => {
+ const title = sprintf(HISTORY_ITEMS_I18N.reportedByForCategory, {
+ name: reporter.name,
+ category: report.category,
+ });
+ expect(findHistoryItem().text()).toContain(title);
+ });
+
+ describe('when the reporter is not defined', () => {
+ beforeEach(() => {
+ createComponent({ reporter: undefined });
+ });
+
+ it('renders the `No user found` as the reporters name and the category', () => {
+ const title = sprintf(HISTORY_ITEMS_I18N.reportedByForCategory, {
+ name: HISTORY_ITEMS_I18N.deletedReporter,
+ category: report.category,
+ });
+ expect(findHistoryItem().text()).toContain(title);
+ });
+ });
+ });
+
+ it('renders the time-ago tooltip', () => {
+ expect(findTimeAgo().props('time')).toBe(report.reportedAt);
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/report_header_spec.js b/spec/frontend/admin/abuse_report/components/report_header_spec.js
new file mode 100644
index 00000000000..d584cab05b3
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/report_header_spec.js
@@ -0,0 +1,59 @@
+import { GlAvatar, GlLink, GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import ReportHeader from '~/admin/abuse_report/components/report_header.vue';
+import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue';
+import { REPORT_HEADER_I18N } from '~/admin/abuse_report/constants';
+import { mockAbuseReport } from '../mock_data';
+
+describe('ReportHeader', () => {
+ let wrapper;
+
+ const { user, actions } = mockAbuseReport;
+
+ const findAvatar = () => wrapper.findComponent(GlAvatar);
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findButton = () => wrapper.findComponent(GlButton);
+ const findActions = () => wrapper.findComponent(AbuseReportActions);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ReportHeader, {
+ propsData: {
+ user,
+ actions,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the users avatar', () => {
+ expect(findAvatar().props('src')).toBe(user.avatarUrl);
+ });
+
+ it('renders the users name', () => {
+ expect(wrapper.html()).toContain(user.name);
+ });
+
+ it('renders a link to the users profile page', () => {
+ const link = findLink();
+
+ expect(link.attributes('href')).toBe(user.path);
+ expect(link.text()).toBe(`@${user.username}`);
+ });
+
+ it('renders a button with a link to the users admin path', () => {
+ const button = findButton();
+
+ expect(button.attributes('href')).toBe(user.adminPath);
+ expect(button.text()).toBe(REPORT_HEADER_I18N.adminProfile);
+ });
+
+ it('renders the actions', () => {
+ const actionsComponent = findActions();
+
+ expect(actionsComponent.props('report')).toMatchObject(actions);
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/reported_content_spec.js b/spec/frontend/admin/abuse_report/components/reported_content_spec.js
new file mode 100644
index 00000000000..ecc5ad6ad47
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/reported_content_spec.js
@@ -0,0 +1,193 @@
+import { GlSprintf, GlButton, GlModal, GlCard, GlAvatar, GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { sprintf } from '~/locale';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import ReportedContent from '~/admin/abuse_report/components/reported_content.vue';
+import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { REPORTED_CONTENT_I18N } from '~/admin/abuse_report/constants';
+import { mockAbuseReport } from '../mock_data';
+
+jest.mock('~/behaviors/markdown/render_gfm');
+
+const modalId = 'abuse-report-screenshot-modal';
+
+describe('ReportedContent', () => {
+ let wrapper;
+
+ const { report, reporter } = { ...mockAbuseReport };
+
+ const findScreenshotButton = () => wrapper.findByTestId('screenshot-button');
+ const findReportUrlButton = () => wrapper.findByTestId('report-url-button');
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findCard = () => wrapper.findComponent(GlCard);
+ const findCardHeader = () => findCard().find('.js-test-card-header');
+ const findTruncatedText = () => findCardHeader().findComponent(TruncatedText);
+ const findCardBody = () => findCard().find('.js-test-card-body');
+ const findCardFooter = () => findCard().find('.js-test-card-footer');
+ const findAvatar = () => findCardFooter().findComponent(GlAvatar);
+ const findProfileLink = () => findCardFooter().findComponent(GlLink);
+ const findTimeAgo = () => findCardFooter().findComponent(TimeAgoTooltip);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(ReportedContent, {
+ propsData: {
+ report,
+ reporter,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ GlButton,
+ GlCard,
+ TruncatedText,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the reported type', () => {
+ expect(wrapper.html()).toContain(sprintf(REPORTED_CONTENT_I18N.reportTypes[report.type]));
+ });
+
+ describe('when the type is unknown', () => {
+ beforeEach(() => {
+ createComponent({ report: { ...report, type: null } });
+ });
+
+ it('renders a header with a generic text content', () => {
+ expect(wrapper.html()).toContain(sprintf(REPORTED_CONTENT_I18N.reportTypes.unknown));
+ });
+ });
+
+ describe('showing the screenshot', () => {
+ describe('when the report contains a screenshot', () => {
+ it('renders a button to show the screenshot', () => {
+ expect(findScreenshotButton().text()).toBe(REPORTED_CONTENT_I18N.viewScreenshot);
+ });
+
+ it('renders a modal with the corrrect id and title', () => {
+ const modal = findModal();
+
+ expect(modal.props('title')).toBe(REPORTED_CONTENT_I18N.screenshotTitle);
+ expect(modal.props('modalId')).toBe(modalId);
+ });
+
+ it('contains an image with the screenshot', () => {
+ expect(findModal().find('img').attributes('src')).toBe(report.screenshot);
+ expect(findModal().find('img').attributes('alt')).toBe(
+ REPORTED_CONTENT_I18N.screenshotTitle,
+ );
+ });
+
+ it('opens the modal when clicking the button', async () => {
+ const modal = findModal();
+
+ expect(modal.props('visible')).toBe(false);
+
+ await findScreenshotButton().trigger('click');
+
+ expect(modal.props('visible')).toBe(true);
+ });
+ });
+
+ describe('when the report does not contain a screenshot', () => {
+ beforeEach(() => {
+ createComponent({ report: { ...report, screenshot: '' } });
+ });
+
+ it('does not render a button and a modal', () => {
+ expect(findScreenshotButton().exists()).toBe(false);
+ expect(findModal().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('showing a button to open the reported URL', () => {
+ describe('when the report contains a URL', () => {
+ it('renders a button with a link to the reported URL', () => {
+ expect(findReportUrlButton().text()).toBe(
+ sprintf(REPORTED_CONTENT_I18N.goToType[report.type]),
+ );
+ });
+ });
+
+ describe('when the report type is unknown', () => {
+ beforeEach(() => {
+ createComponent({ report: { ...report, type: null } });
+ });
+
+ it('renders a button with a generic text content', () => {
+ expect(findReportUrlButton().text()).toBe(sprintf(REPORTED_CONTENT_I18N.goToType.unknown));
+ });
+ });
+
+ describe('when the report contains no URL', () => {
+ beforeEach(() => {
+ createComponent({ report: { ...report, url: '' } });
+ });
+
+ it('does not render a button with a link to the reported URL', () => {
+ expect(findReportUrlButton().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('rendering the card header', () => {
+ describe('when the report contains the reported content', () => {
+ it('renders the content', () => {
+ const dummyElement = document.createElement('div');
+ dummyElement.innerHTML = report.content;
+ expect(findTruncatedText().text()).toBe(dummyElement.textContent);
+ });
+
+ it('renders gfm', () => {
+ expect(renderGFM).toHaveBeenCalled();
+ });
+ });
+
+ describe('when the report does not contain the reported content', () => {
+ beforeEach(() => {
+ createComponent({ report: { ...report, content: '' } });
+ });
+
+ it('does not render the card header', () => {
+ expect(findCardHeader().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('rendering the card body', () => {
+ it('renders the reported by', () => {
+ expect(findCardBody().text()).toBe(REPORTED_CONTENT_I18N.reportedBy);
+ });
+ });
+
+ describe('rendering the card footer', () => {
+ it('renders the reporters avatar', () => {
+ expect(findAvatar().props('src')).toBe(reporter.avatarUrl);
+ });
+
+ it('renders the users name', () => {
+ expect(findCardFooter().text()).toContain(reporter.name);
+ });
+
+ it('renders a link to the users profile page', () => {
+ const link = findProfileLink();
+
+ expect(link.attributes('href')).toBe(reporter.path);
+ expect(link.text()).toBe(`@${reporter.username}`);
+ });
+
+ it('renders the time-ago tooltip', () => {
+ expect(findTimeAgo().props('time')).toBe(report.reportedAt);
+ });
+
+ it('renders the message', () => {
+ expect(findCardFooter().text()).toContain(report.message);
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/user_detail_spec.js b/spec/frontend/admin/abuse_report/components/user_detail_spec.js
new file mode 100644
index 00000000000..d9e02bc96e2
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/user_detail_spec.js
@@ -0,0 +1,66 @@
+import { shallowMount } from '@vue/test-utils';
+import UserDetail from '~/admin/abuse_report/components/user_detail.vue';
+
+describe('UserDetail', () => {
+ let wrapper;
+
+ const label = 'user detail label';
+ const value = 'user detail value';
+
+ const createComponent = (props = {}, slots = {}) => {
+ wrapper = shallowMount(UserDetail, {
+ propsData: {
+ label,
+ value,
+ ...props,
+ },
+ slots,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('UserDetail', () => {
+ it('renders the label', () => {
+ expect(wrapper.text()).toContain(label);
+ });
+
+ describe('rendering the value', () => {
+ const slots = {
+ default: ['slot provided user detail'],
+ };
+
+ describe('when `value` property and no default slot is provided', () => {
+ it('renders the `value` as content', () => {
+ expect(wrapper.text()).toContain(value);
+ });
+ });
+
+ describe('when default slot and no `value` property is provided', () => {
+ beforeEach(() => {
+ createComponent({ label, value: null }, slots);
+ });
+
+ it('renders the content provided via the default slot', () => {
+ expect(wrapper.text()).toContain(slots.default[0]);
+ });
+ });
+
+ describe('when `value` property and default slot are both provided', () => {
+ beforeEach(() => {
+ createComponent({ label, value }, slots);
+ });
+
+ it('does not render `value` as content', () => {
+ expect(wrapper.text()).not.toContain(value);
+ });
+
+ it('renders the content provided via the default slot', () => {
+ expect(wrapper.text()).toContain(slots.default[0]);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/user_details_spec.js b/spec/frontend/admin/abuse_report/components/user_details_spec.js
new file mode 100644
index 00000000000..ca499fbaa6e
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/user_details_spec.js
@@ -0,0 +1,210 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { sprintf } from '~/locale';
+import UserDetails from '~/admin/abuse_report/components/user_details.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { USER_DETAILS_I18N } from '~/admin/abuse_report/constants';
+import { mockAbuseReport } from '../mock_data';
+
+describe('UserDetails', () => {
+ let wrapper;
+
+ const { user } = mockAbuseReport;
+
+ const findUserDetail = (attribute) => wrapper.findByTestId(attribute);
+ const findUserDetailLabel = (attribute) => findUserDetail(attribute).props('label');
+ const findUserDetailValue = (attribute) => findUserDetail(attribute).props('value');
+ const findLinkIn = (component) => component.findComponent(GlLink);
+ const findLinkFor = (attribute) => findLinkIn(findUserDetail(attribute));
+ const findTimeIn = (component) => component.findComponent(TimeAgoTooltip).props('time');
+ const findTimeFor = (attribute) => findTimeIn(findUserDetail(attribute));
+ const findOtherReport = (index) => wrapper.findByTestId(`other-report-${index}`);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(UserDetails, {
+ propsData: {
+ user,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('createdAt', () => {
+ it('renders the users createdAt with the correct label', () => {
+ expect(findUserDetailLabel('createdAt')).toBe(USER_DETAILS_I18N.createdAt);
+ expect(findTimeFor('createdAt')).toBe(user.createdAt);
+ });
+ });
+
+ describe('email', () => {
+ it('renders the users email with the correct label', () => {
+ expect(findUserDetailLabel('email')).toBe(USER_DETAILS_I18N.email);
+ expect(findLinkFor('email').attributes('href')).toBe(`mailto:${user.email}`);
+ expect(findLinkFor('email').text()).toBe(user.email);
+ });
+ });
+
+ describe('plan', () => {
+ it('renders the users plan with the correct label', () => {
+ expect(findUserDetailLabel('plan')).toBe(USER_DETAILS_I18N.plan);
+ expect(findUserDetailValue('plan')).toBe(user.plan);
+ });
+ });
+
+ describe('verification', () => {
+ it('renders the users verification with the correct label', () => {
+ expect(findUserDetailLabel('verification')).toBe(USER_DETAILS_I18N.verification);
+ expect(findUserDetailValue('verification')).toBe('Email, Credit card');
+ });
+ });
+
+ describe('creditCard', () => {
+ it('renders the correct label', () => {
+ expect(findUserDetailLabel('creditCard')).toBe(USER_DETAILS_I18N.creditCard);
+ });
+
+ it('renders the users name', () => {
+ expect(findUserDetail('creditCard').text()).toContain(
+ sprintf(USER_DETAILS_I18N.registeredWith, { ...user.creditCard }),
+ );
+
+ expect(findUserDetail('creditCard').text()).toContain(user.creditCard.name);
+ });
+
+ describe('similar credit cards', () => {
+ it('renders the number of similar records', () => {
+ expect(findUserDetail('creditCard').text()).toContain(
+ sprintf('Card matches %{similarRecordsCount} accounts', { ...user.creditCard }),
+ );
+ });
+
+ it('renders a link to the matching cards', () => {
+ expect(findLinkFor('creditCard').attributes('href')).toBe(user.creditCard.cardMatchesLink);
+
+ expect(findLinkFor('creditCard').text()).toBe(
+ sprintf('%{similarRecordsCount} accounts', { ...user.creditCard }),
+ );
+
+ expect(findLinkFor('creditCard').text()).toContain(
+ user.creditCard.similarRecordsCount.toString(),
+ );
+ });
+
+ describe('when the number of similar credit cards is less than 2', () => {
+ beforeEach(() => {
+ createComponent({
+ user: { ...user, creditCard: { ...user.creditCard, similarRecordsCount: 1 } },
+ });
+ });
+
+ it('does not render the number of similar records', () => {
+ expect(findUserDetail('creditCard').text()).not.toContain(
+ sprintf('Card matches %{similarRecordsCount} accounts', { ...user.creditCard }),
+ );
+ });
+
+ it('does not render a link to the matching cards', () => {
+ expect(findLinkFor('creditCard').exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when the users creditCard is blank', () => {
+ beforeEach(() => {
+ createComponent({
+ user: { ...user, creditCard: undefined },
+ });
+ });
+
+ it('does not render the users creditCard', () => {
+ expect(findUserDetail('creditCard').exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('otherReports', () => {
+ it('renders the correct label', () => {
+ expect(findUserDetailLabel('otherReports')).toBe(USER_DETAILS_I18N.otherReports);
+ });
+
+ describe.each(user.otherReports)('renders a line for report %#', (otherReport) => {
+ const index = user.otherReports.indexOf(otherReport);
+
+ it('renders the category', () => {
+ expect(findOtherReport(index).text()).toContain(
+ sprintf('Reported for %{category}', { ...otherReport }),
+ );
+ });
+
+ it('renders a link to the report', () => {
+ expect(findLinkIn(findOtherReport(index)).attributes('href')).toBe(otherReport.reportPath);
+ });
+
+ it('renders the time it was created', () => {
+ expect(findTimeIn(findOtherReport(index))).toBe(otherReport.createdAt);
+ });
+ });
+
+ describe('when the users otherReports is empty', () => {
+ beforeEach(() => {
+ createComponent({
+ user: { ...user, otherReports: [] },
+ });
+ });
+
+ it('does not render the users otherReports', () => {
+ expect(findUserDetail('otherReports').exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('normalLocation', () => {
+ it('renders the correct label', () => {
+ expect(findUserDetailLabel('normalLocation')).toBe(USER_DETAILS_I18N.normalLocation);
+ });
+
+ describe('when the users mostUsedIp is blank', () => {
+ it('renders the users lastSignInIp', () => {
+ expect(findUserDetailValue('normalLocation')).toBe(user.lastSignInIp);
+ });
+ });
+
+ describe('when the users mostUsedIp is not blank', () => {
+ const mostUsedIp = '127.0.0.1';
+
+ beforeEach(() => {
+ createComponent({
+ user: { ...user, mostUsedIp },
+ });
+ });
+
+ it('renders the users mostUsedIp', () => {
+ expect(findUserDetailValue('normalLocation')).toBe(mostUsedIp);
+ });
+ });
+ });
+
+ describe('lastSignInIp', () => {
+ it('renders the users lastSignInIp with the correct label', () => {
+ expect(findUserDetailLabel('lastSignInIp')).toBe(USER_DETAILS_I18N.lastSignInIp);
+ expect(findUserDetailValue('lastSignInIp')).toBe(user.lastSignInIp);
+ });
+ });
+
+ it.each(['snippets', 'groups', 'notes'])(
+ 'renders the users %s with the correct label',
+ (attribute) => {
+ expect(findUserDetailLabel(attribute)).toBe(USER_DETAILS_I18N[attribute]);
+ expect(findUserDetailValue(attribute)).toBe(
+ USER_DETAILS_I18N[`${attribute}Count`](user[`${attribute}Count`]),
+ );
+ },
+ );
+});
diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js
new file mode 100644
index 00000000000..ee0f0967735
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/mock_data.js
@@ -0,0 +1,61 @@
+export const mockAbuseReport = {
+ user: {
+ username: 'spamuser417',
+ name: 'Sp4m User',
+ createdAt: '2023-03-29T09:30:23.885Z',
+ email: 'sp4m@spam.com',
+ lastActivityOn: '2023-04-02',
+ avatarUrl: 'https://www.gravatar.com/avatar/a2579caffc69ea5d7606f9dd9d8504ba?s=80&d=identicon',
+ path: '/spamuser417',
+ adminPath: '/admin/users/spamuser417',
+ plan: 'Free',
+ verificationState: { email: true, phone: false, creditCard: true },
+ creditCard: {
+ name: 'S. User',
+ similarRecordsCount: 2,
+ cardMatchesLink: '/admin/users/spamuser417/card_match',
+ },
+ otherReports: [
+ {
+ category: 'offensive',
+ createdAt: '2023-02-28T10:09:54.982Z',
+ reportPath: '/admin/abuse_reports/29',
+ },
+ {
+ category: 'crypto',
+ createdAt: '2023-03-31T11:57:11.849Z',
+ reportPath: '/admin/abuse_reports/31',
+ },
+ ],
+ mostUsedIp: null,
+ lastSignInIp: '::1',
+ snippetsCount: 0,
+ groupsCount: 0,
+ notesCount: 6,
+ },
+ reporter: {
+ username: 'reporter',
+ name: 'R Porter',
+ avatarUrl: 'https://www.gravatar.com/avatar/a2579caffc69ea5d7606f9dd9d8504ba?s=80&d=identicon',
+ path: '/reporter',
+ },
+ report: {
+ message: 'This is obvious spam',
+ reportedAt: '2023-03-29T09:39:50.502Z',
+ category: 'spam',
+ type: 'comment',
+ content:
+ '<p data-sourcepos="1:1-1:772" dir="auto">Farmers Toy Sale ON NOW | SHOP CATALOGUE ... 50% off Kids\' Underwear by Hanes ... BUY 1 GET 1 HALF PRICE on Women\'s Clothing by Whistle, Ella Clothing Farmers Toy Sale ON <a href="http://www.farmers.com" rel="nofollow noreferrer noopener" target="_blank">www.farmers.com</a> | SHOP CATALOGUE ... 50% off Kids\' Underwear by Hanes ... BUY 1 GET 1 HALF PRICE on Women\'s Clothing by Whistle, Ella Clothing Farmers Toy Sale ON NOW | SHOP CATALOGUE ... 50% off Kids\' Underwear by Farmers Toy Sale ON NOW | SHOP CATALOGUE ... 50% off Kids\' Underwear by Hanes ... BUY 1 GET 1 HALF PRICE on Women\'s Clothing by Whistle, Ella Clothing Farmers Toy Sale ON <a href="http://www.farmers.com" rel="nofollow noreferrer noopener" target="_blank">www.farmers.com</a> | SHOP CATALOGUE ... 50% off Kids\' Underwear by Hanes ... BUY 1 GET 1 HALF PRICE on Women\'s Clothing by Whistle, Ella Clothing Farmers Toy Sale ON NOW | SHOP CATALOGUE ... 50% off Kids\' Underwear by.</p>',
+ url: 'http://localhost:3000/spamuser417/project/-/merge_requests/1#note_1375',
+ screenshot:
+ '/uploads/-/system/abuse_report/screenshot/27/Screenshot_2023-03-30_at_16.56.37.png',
+ },
+ actions: {
+ reportedUser: { name: 'Sp4m User', createdAt: '2023-03-29T09:30:23.885Z' },
+ userBlocked: false,
+ blockUserPath: '/admin/users/spamuser417/block',
+ removeReportPath: '/admin/abuse_reports/27',
+ removeUserAndReportPath: '/admin/abuse_reports/27?remove_user=true',
+ redirectPath: '/admin/abuse_reports',
+ },
+};
diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js
new file mode 100644
index 00000000000..09b6b1edc44
--- /dev/null
+++ b/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js
@@ -0,0 +1,202 @@
+import { nextTick } from 'vue';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlModal } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { sprintf } from '~/locale';
+import { ACTIONS_I18N } from '~/admin/abuse_reports/constants';
+import { mockAbuseReports } from '../mock_data';
+
+jest.mock('~/alert');
+jest.mock('~/lib/utils/url_utility');
+
+describe('AbuseReportActions', () => {
+ let wrapper;
+
+ const findRemoveUserAndReportButton = () => wrapper.findByText('Remove user & report');
+ const findBlockUserButton = () => wrapper.findByTestId('block-user-button');
+ const findRemoveReportButton = () => wrapper.findByText('Remove report');
+ const findConfirmationModal = () => wrapper.findComponent(GlModal);
+
+ const report = mockAbuseReports[0];
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(AbuseReportActions, {
+ propsData: {
+ report,
+ ...props,
+ },
+ stubs: {
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ },
+ });
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays "Block user", "Remove user & report", and "Remove report" buttons', () => {
+ expect(findRemoveUserAndReportButton().text()).toBe(ACTIONS_I18N.removeUserAndReport);
+
+ const blockButton = findBlockUserButton();
+ expect(blockButton.text()).toBe(ACTIONS_I18N.blockUser);
+ expect(blockButton.attributes('disabled')).toBeUndefined();
+
+ expect(findRemoveReportButton().text()).toBe(ACTIONS_I18N.removeReport);
+ });
+
+ it('does not show the confirmation modal initially', () => {
+ expect(findConfirmationModal().props('visible')).toBe(false);
+ });
+ });
+
+ describe('block button when user is already blocked', () => {
+ it('is disabled and has the correct text', () => {
+ createComponent({ report: { ...report, userBlocked: true } });
+
+ const button = findBlockUserButton();
+ expect(button.text()).toBe(ACTIONS_I18N.alreadyBlocked);
+ expect(button.attributes('disabled')).toBeDefined();
+ });
+ });
+
+ describe('actions', () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ createAlert.mockClear();
+ });
+
+ describe('on remove user and report', () => {
+ it('shows confirmation modal and reloads the page on success', async () => {
+ findRemoveUserAndReportButton().trigger('click');
+ await nextTick();
+
+ expect(findConfirmationModal().props()).toMatchObject({
+ visible: true,
+ title: sprintf(ACTIONS_I18N.removeUserAndReportConfirm, {
+ user: report.reportedUser.name,
+ }),
+ });
+
+ axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK);
+
+ findConfirmationModal().vm.$emit('primary');
+ await axios.waitForAll();
+
+ expect(refreshCurrentPage).toHaveBeenCalled();
+ });
+
+ describe('when a redirect path is present', () => {
+ beforeEach(() => {
+ createComponent({ report: { ...report, redirectPath: '/redirect_path' } });
+ });
+
+ it('redirects to the given path', async () => {
+ findRemoveUserAndReportButton().trigger('click');
+ await nextTick();
+
+ axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK);
+
+ findConfirmationModal().vm.$emit('primary');
+ await axios.waitForAll();
+
+ expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); // eslint-disable-line import/no-deprecated
+ });
+ });
+ });
+
+ describe('on block user', () => {
+ beforeEach(async () => {
+ findBlockUserButton().trigger('click');
+ await nextTick();
+ });
+
+ it('shows confirmation modal', () => {
+ expect(findConfirmationModal().props()).toMatchObject({
+ visible: true,
+ title: ACTIONS_I18N.blockUserConfirm,
+ });
+ });
+
+ describe.each([
+ {
+ responseData: { notice: 'Notice' },
+ createAlertArgs: { message: 'Notice', variant: VARIANT_SUCCESS },
+ blockButtonText: ACTIONS_I18N.alreadyBlocked,
+ blockButtonDisabled: 'disabled',
+ },
+ {
+ responseData: { error: 'Error' },
+ createAlertArgs: { message: 'Error' },
+ blockButtonText: ACTIONS_I18N.blockUser,
+ blockButtonDisabled: undefined,
+ },
+ ])(
+ 'when response JSON is $responseData',
+ ({ responseData, createAlertArgs, blockButtonText, blockButtonDisabled }) => {
+ beforeEach(async () => {
+ axiosMock.onPut(report.blockUserPath).reply(HTTP_STATUS_OK, responseData);
+
+ findConfirmationModal().vm.$emit('primary');
+ await axios.waitForAll();
+ });
+
+ it('updates the block button correctly', () => {
+ const button = findBlockUserButton();
+ expect(button.text()).toBe(blockButtonText);
+ expect(button.attributes('disabled')).toBe(blockButtonDisabled);
+ });
+
+ it('displays the returned message', () => {
+ expect(createAlert).toHaveBeenCalledWith(createAlertArgs);
+ });
+ },
+ );
+ });
+
+ describe('on remove report', () => {
+ it('reloads the page on success', async () => {
+ axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK);
+
+ findRemoveReportButton().trigger('click');
+
+ expect(findConfirmationModal().props('visible')).toBe(false);
+
+ await axios.waitForAll();
+
+ expect(refreshCurrentPage).toHaveBeenCalled();
+ });
+
+ describe('when a redirect path is present', () => {
+ beforeEach(() => {
+ createComponent({ report: { ...report, redirectPath: '/redirect_path' } });
+ });
+
+ it('redirects to the given path', async () => {
+ axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK);
+
+ findRemoveReportButton().trigger('click');
+
+ await axios.waitForAll();
+
+ expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); // eslint-disable-line import/no-deprecated
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js
new file mode 100644
index 00000000000..f3cced81478
--- /dev/null
+++ b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js
@@ -0,0 +1,91 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import AbuseReportRow from '~/admin/abuse_reports/components/abuse_report_row.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import { SORT_UPDATED_AT } from '~/admin/abuse_reports/constants';
+import { mockAbuseReports } from '../mock_data';
+
+describe('AbuseReportRow', () => {
+ let wrapper;
+ const mockAbuseReport = mockAbuseReports[0];
+
+ const findListItem = () => wrapper.findComponent(ListItem);
+ const findTitle = () => wrapper.findByTestId('title');
+ const findDisplayedDate = () => wrapper.findByTestId('abuse-report-date');
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(AbuseReportRow, {
+ propsData: {
+ report: mockAbuseReport,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders a ListItem', () => {
+ expect(findListItem().exists()).toBe(true);
+ });
+
+ describe('title', () => {
+ const { reporter, reportedUser, category, reportPath } = mockAbuseReport;
+
+ it('displays correctly formatted title', () => {
+ expect(findTitle().text()).toMatchInterpolatedText(
+ `${reportedUser.name} reported for ${category} by ${reporter.name}`,
+ );
+ });
+
+ it('links to the details page', () => {
+ expect(findTitle().attributes('href')).toEqual(reportPath);
+ });
+
+ describe('when the reportedUser is missing', () => {
+ beforeEach(() => {
+ createComponent({ report: { ...mockAbuseReport, reportedUser: null } });
+ });
+
+ it('displays correctly formatted title', () => {
+ expect(findTitle().text()).toMatchInterpolatedText(
+ `Deleted user reported for ${category} by ${reporter.name}`,
+ );
+ });
+ });
+
+ describe('when the reporter is missing', () => {
+ beforeEach(() => {
+ createComponent({ report: { ...mockAbuseReport, reporter: null } });
+ });
+
+ it('displays correctly formatted title', () => {
+ expect(findTitle().text()).toMatchInterpolatedText(
+ `${reportedUser.name} reported for ${category} by Deleted user`,
+ );
+ });
+ });
+ });
+
+ describe('displayed date', () => {
+ it('displays correctly formatted created at', () => {
+ expect(findDisplayedDate().text()).toMatchInterpolatedText(
+ `Created ${getTimeago().format(mockAbuseReport.createdAt)}`,
+ );
+ });
+
+ describe('when sorted by updated_at', () => {
+ it('displays correctly formatted updated at', () => {
+ setWindowLocation(`?sort=${SORT_UPDATED_AT.sortDirection.ascending}`);
+
+ createComponent();
+
+ expect(findDisplayedDate().text()).toMatchInterpolatedText(
+ `Updated ${getTimeago().format(mockAbuseReport.updatedAt)}`,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js
new file mode 100644
index 00000000000..1f3f2caa995
--- /dev/null
+++ b/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js
@@ -0,0 +1,225 @@
+import { shallowMount } from '@vue/test-utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { redirectTo, updateHistory } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import AbuseReportsFilteredSearchBar from '~/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue';
+import {
+ FILTERED_SEARCH_TOKENS,
+ FILTERED_SEARCH_TOKEN_USER,
+ FILTERED_SEARCH_TOKEN_REPORTER,
+ FILTERED_SEARCH_TOKEN_STATUS,
+ FILTERED_SEARCH_TOKEN_CATEGORY,
+ DEFAULT_SORT,
+ SORT_OPTIONS,
+} from '~/admin/abuse_reports/constants';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import { buildFilteredSearchCategoryToken } from '~/admin/abuse_reports/utils';
+
+jest.mock('~/lib/utils/url_utility', () => {
+ const urlUtility = jest.requireActual('~/lib/utils/url_utility');
+
+ return {
+ __esModule: true,
+ ...urlUtility,
+ redirectTo: jest.fn(),
+ updateHistory: jest.fn(),
+ };
+});
+
+describe('AbuseReportsFilteredSearchBar', () => {
+ let wrapper;
+
+ const CATEGORIES = ['spam', 'phishing'];
+
+ const createComponent = () => {
+ wrapper = shallowMount(AbuseReportsFilteredSearchBar, {
+ provide: { categories: CATEGORIES },
+ });
+ };
+
+ const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
+
+ beforeEach(() => {
+ setWindowLocation('https://localhost');
+ });
+
+ it('passes correct props to `FilteredSearchBar` component', () => {
+ createComponent();
+
+ const categoryToken = buildFilteredSearchCategoryToken(CATEGORIES);
+
+ expect(findFilteredSearchBar().props()).toMatchObject({
+ namespace: 'abuse_reports',
+ recentSearchesStorageKey: 'abuse_reports',
+ searchInputPlaceholder: 'Filter reports',
+ tokens: [...FILTERED_SEARCH_TOKENS, categoryToken],
+ initialSortBy: DEFAULT_SORT,
+ sortOptions: SORT_OPTIONS,
+ });
+ });
+
+ it.each([undefined, 'invalid'])(
+ 'sets status=open query when initial status query is %s',
+ (status) => {
+ if (status) {
+ setWindowLocation(`?status=${status}`);
+ }
+
+ createComponent();
+
+ expect(updateHistory).toHaveBeenCalledWith({
+ url: 'https://localhost/?status=open',
+ replace: true,
+ });
+
+ expect(findFilteredSearchBar().props('initialFilterValue')).toMatchObject([
+ {
+ type: FILTERED_SEARCH_TOKEN_STATUS.type,
+ value: { data: 'open', operator: '=' },
+ },
+ ]);
+ },
+ );
+
+ it('parses and passes search param to `FilteredSearchBar` component as `initialFilterValue` prop', () => {
+ setWindowLocation('?status=closed&user=mr_abuser&reporter=ms_nitch');
+
+ createComponent();
+
+ expect(findFilteredSearchBar().props('initialFilterValue')).toMatchObject([
+ {
+ type: FILTERED_SEARCH_TOKEN_USER.type,
+ value: { data: 'mr_abuser', operator: '=' },
+ },
+ {
+ type: FILTERED_SEARCH_TOKEN_REPORTER.type,
+ value: { data: 'ms_nitch', operator: '=' },
+ },
+ {
+ type: FILTERED_SEARCH_TOKEN_STATUS.type,
+ value: { data: 'closed', operator: '=' },
+ },
+ ]);
+ });
+
+ describe('initial sort', () => {
+ it.each(
+ SORT_OPTIONS.flatMap(({ sortDirection: { descending, ascending } }) => [
+ descending,
+ ascending,
+ ]),
+ )(
+ 'parses sort=%s query and passes it to `FilteredSearchBar` component as initialSortBy',
+ (sortBy) => {
+ setWindowLocation(`?sort=${sortBy}`);
+
+ createComponent();
+
+ expect(findFilteredSearchBar().props('initialSortBy')).toEqual(sortBy);
+ },
+ );
+
+ it(`uses ${DEFAULT_SORT} as initialSortBy when sort query param is invalid`, () => {
+ setWindowLocation(`?sort=unknown`);
+
+ createComponent();
+
+ expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT);
+ });
+ });
+
+ describe('onFilter', () => {
+ const USER_FILTER_TOKEN = {
+ type: FILTERED_SEARCH_TOKEN_USER.type,
+ value: { data: 'mr_abuser', operator: '=' },
+ };
+ const REPORTER_FILTER_TOKEN = {
+ type: FILTERED_SEARCH_TOKEN_REPORTER.type,
+ value: { data: 'ms_nitch', operator: '=' },
+ };
+ const STATUS_FILTER_TOKEN = {
+ type: FILTERED_SEARCH_TOKEN_STATUS.type,
+ value: { data: 'open', operator: '=' },
+ };
+ const CATEGORY_FILTER_TOKEN = {
+ type: FILTERED_SEARCH_TOKEN_CATEGORY.type,
+ value: { data: 'spam', operator: '=' },
+ };
+
+ const createComponentAndFilter = (filterTokens, initialLocation) => {
+ if (initialLocation) {
+ setWindowLocation(initialLocation);
+ }
+
+ createComponent();
+
+ findFilteredSearchBar().vm.$emit('onFilter', filterTokens);
+ };
+
+ it.each([USER_FILTER_TOKEN, REPORTER_FILTER_TOKEN, STATUS_FILTER_TOKEN, CATEGORY_FILTER_TOKEN])(
+ 'redirects with $type query param',
+ (filterToken) => {
+ createComponentAndFilter([filterToken]);
+ const { type, value } = filterToken;
+ expect(redirectTo).toHaveBeenCalledWith(`https://localhost/?${type}=${value.data}`); // eslint-disable-line import/no-deprecated
+ },
+ );
+
+ it('ignores search query param', () => {
+ const searchFilterToken = { type: FILTERED_SEARCH_TERM, value: { data: 'ignored' } };
+ createComponentAndFilter([USER_FILTER_TOKEN, searchFilterToken]);
+ expect(redirectTo).toHaveBeenCalledWith('https://localhost/?user=mr_abuser'); // eslint-disable-line import/no-deprecated
+ });
+
+ it('redirects without page query param', () => {
+ createComponentAndFilter([USER_FILTER_TOKEN], '?page=2');
+ expect(redirectTo).toHaveBeenCalledWith('https://localhost/?user=mr_abuser'); // eslint-disable-line import/no-deprecated
+ });
+
+ it('redirects with existing sort query param', () => {
+ createComponentAndFilter([USER_FILTER_TOKEN], `?sort=${DEFAULT_SORT}`);
+ // eslint-disable-next-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith(
+ `https://localhost/?user=mr_abuser&sort=${DEFAULT_SORT}`,
+ );
+ });
+ });
+
+ describe('onSort', () => {
+ const SORT_VALUE = 'updated_at_asc';
+ const EXISTING_QUERY = 'status=closed&user=mr_abuser';
+
+ const createComponentAndSort = (initialLocation) => {
+ setWindowLocation(initialLocation);
+ createComponent();
+ findFilteredSearchBar().vm.$emit('onSort', SORT_VALUE);
+ };
+
+ it('redirects to URL with existing query params and the sort query param', () => {
+ createComponentAndSort(`?${EXISTING_QUERY}`);
+
+ // eslint-disable-next-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith(
+ `https://localhost/?${EXISTING_QUERY}&sort=${SORT_VALUE}`,
+ );
+ });
+
+ it('redirects without page query param', () => {
+ createComponentAndSort(`?${EXISTING_QUERY}&page=2`);
+
+ // eslint-disable-next-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith(
+ `https://localhost/?${EXISTING_QUERY}&sort=${SORT_VALUE}`,
+ );
+ });
+
+ it('redirects with existing sort query param replaced with the new one', () => {
+ createComponentAndSort(`?${EXISTING_QUERY}&sort=created_at_desc`);
+
+ // eslint-disable-next-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith(
+ `https://localhost/?${EXISTING_QUERY}&sort=${SORT_VALUE}`,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_reports/components/app_spec.js b/spec/frontend/admin/abuse_reports/components/app_spec.js
new file mode 100644
index 00000000000..41728baaf33
--- /dev/null
+++ b/spec/frontend/admin/abuse_reports/components/app_spec.js
@@ -0,0 +1,104 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlEmptyState, GlPagination } from '@gitlab/ui';
+import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import AbuseReportsApp from '~/admin/abuse_reports/components/app.vue';
+import AbuseReportsFilteredSearchBar from '~/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue';
+import AbuseReportRow from '~/admin/abuse_reports/components/abuse_report_row.vue';
+import { mockAbuseReports } from '../mock_data';
+
+describe('AbuseReportsApp', () => {
+ let wrapper;
+
+ const findFilteredSearchBar = () => wrapper.findComponent(AbuseReportsFilteredSearchBar);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findAbuseReportRows = () => wrapper.findAllComponents(AbuseReportRow);
+ const findPagination = () => wrapper.findComponent(GlPagination);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(AbuseReportsApp, {
+ propsData: {
+ abuseReports: mockAbuseReports,
+ pagination: { currentPage: 1, perPage: 20, totalItems: mockAbuseReports.length },
+ ...props,
+ },
+ });
+ };
+
+ it('renders AbuseReportsFilteredSearchBar', () => {
+ createComponent();
+
+ expect(findFilteredSearchBar().exists()).toBe(true);
+ });
+
+ it('renders one AbuseReportRow for each abuse report', () => {
+ createComponent();
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findAbuseReportRows().length).toBe(mockAbuseReports.length);
+ });
+
+ it('renders empty state when there are no reports', () => {
+ createComponent({
+ abuseReports: [],
+ pagination: { currentPage: 1, perPage: 20, totalItems: 0 },
+ });
+
+ expect(findEmptyState().exists()).toBe(true);
+ });
+
+ describe('pagination', () => {
+ const pagination = {
+ currentPage: 1,
+ perPage: 1,
+ totalItems: mockAbuseReports.length,
+ };
+
+ it('renders GlPagination with the correct props when needed', () => {
+ createComponent({ pagination });
+
+ expect(findPagination().exists()).toBe(true);
+ expect(findPagination().props()).toMatchObject({
+ value: pagination.currentPage,
+ perPage: pagination.perPage,
+ totalItems: pagination.totalItems,
+ prevText: 'Prev',
+ nextText: 'Next',
+ labelNextPage: 'Go to next page',
+ labelPrevPage: 'Go to previous page',
+ align: 'center',
+ });
+ });
+
+ it('does not render GlPagination when not needed', () => {
+ createComponent({ pagination: { currentPage: 1, perPage: 2, totalItems: 2 } });
+
+ expect(findPagination().exists()).toBe(false);
+ });
+
+ describe('linkGen prop', () => {
+ const existingQuery = {
+ user: 'mr_okay',
+ status: 'closed',
+ };
+ const expectedGeneratedQuery = {
+ ...existingQuery,
+ page: '2',
+ };
+
+ beforeEach(() => {
+ setWindowLocation(`https://localhost?${objectToQuery(existingQuery)}`);
+ });
+
+ it('generates the correct page URL', () => {
+ createComponent({ pagination });
+
+ const linkGen = findPagination().props('linkGen');
+ const generatedUrl = linkGen(expectedGeneratedQuery.page);
+ const [, generatedQuery] = generatedUrl.split('?');
+
+ expect(queryToObject(generatedQuery)).toMatchObject(expectedGeneratedQuery);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_reports/mock_data.js b/spec/frontend/admin/abuse_reports/mock_data.js
new file mode 100644
index 00000000000..1ea6ea7d131
--- /dev/null
+++ b/spec/frontend/admin/abuse_reports/mock_data.js
@@ -0,0 +1,18 @@
+export const mockAbuseReports = [
+ {
+ category: 'spam',
+ createdAt: '2018-10-03T05:46:38.977Z',
+ updatedAt: '2022-12-07T06:45:39.977Z',
+ reporter: { name: 'Ms. Admin' },
+ reportedUser: { name: 'Mr. Abuser' },
+ reportPath: '/admin/abuse_reports/1',
+ },
+ {
+ category: 'phishing',
+ createdAt: '2018-10-03T05:46:38.977Z',
+ updatedAt: '2022-12-07T06:45:39.977Z',
+ reporter: { name: 'Ms. Reporter' },
+ reportedUser: { name: 'Mr. Phisher' },
+ reportPath: '/admin/abuse_reports/2',
+ },
+];
diff --git a/spec/frontend/admin/abuse_reports/utils_spec.js b/spec/frontend/admin/abuse_reports/utils_spec.js
new file mode 100644
index 00000000000..3908edd3fdd
--- /dev/null
+++ b/spec/frontend/admin/abuse_reports/utils_spec.js
@@ -0,0 +1,31 @@
+import {
+ FILTERED_SEARCH_TOKEN_CATEGORY,
+ FILTERED_SEARCH_TOKEN_STATUS,
+} from '~/admin/abuse_reports/constants';
+import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils';
+
+describe('buildFilteredSearchCategoryToken', () => {
+ it('adds correctly formatted options to FILTERED_SEARCH_TOKEN_CATEGORY', () => {
+ const categories = ['tuxedo', 'tabby'];
+
+ expect(buildFilteredSearchCategoryToken(categories)).toMatchObject({
+ ...FILTERED_SEARCH_TOKEN_CATEGORY,
+ options: categories.map((c) => ({ value: c, title: c })),
+ });
+ });
+});
+
+describe('isValidStatus', () => {
+ const validStatuses = FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value);
+
+ it.each(validStatuses)(
+ 'returns true when status is an option value of FILTERED_SEARCH_TOKEN_STATUS',
+ (status) => {
+ expect(isValidStatus(status)).toBe(true);
+ },
+ );
+
+ it('return false when status is not an option value of FILTERED_SEARCH_TOKEN_STATUS', () => {
+ expect(isValidStatus('invalid')).toBe(false);
+ });
+});
diff --git a/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js b/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js
index c9a899ab78b..06f9ffeffcd 100644
--- a/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js
+++ b/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js
@@ -19,10 +19,6 @@ describe('DevopsScoreCallout', () => {
const findBanner = () => wrapper.findComponent(GlBanner);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with no cookie set', () => {
beforeEach(() => {
utils.setCookie = jest.fn();
diff --git a/spec/frontend/admin/application_settings/inactive_project_deletion/components/form_spec.js b/spec/frontend/admin/application_settings/inactive_project_deletion/components/form_spec.js
index 2db997942a7..969844f981c 100644
--- a/spec/frontend/admin/application_settings/inactive_project_deletion/components/form_spec.js
+++ b/spec/frontend/admin/application_settings/inactive_project_deletion/components/form_spec.js
@@ -29,10 +29,6 @@ describe('Form component', () => {
wrapper = mountFn(SettingsForm, { propsData });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Enable inactive project deletion', () => {
it('has the checkbox', () => {
createComponent();
diff --git a/spec/frontend/admin/application_settings/network_outbound_spec.js b/spec/frontend/admin/application_settings/network_outbound_spec.js
new file mode 100644
index 00000000000..2c06a3fd67f
--- /dev/null
+++ b/spec/frontend/admin/application_settings/network_outbound_spec.js
@@ -0,0 +1,70 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+
+import initNetworkOutbound from '~/admin/application_settings/network_outbound';
+
+describe('initNetworkOutbound', () => {
+ const findAllowCheckboxes = () => document.querySelectorAll('.js-allow-local-requests');
+ const findDenyCheckbox = () => document.querySelector('.js-deny-all-requests');
+ const findWarningBanner = () => document.querySelector('.js-deny-all-requests-warning');
+ const clickDenyCheckbox = () => {
+ findDenyCheckbox().click();
+ };
+
+ const createFixture = (denyAll = false) => {
+ setHTMLFixture(`
+ <input class="js-deny-all-requests" type="checkbox" name="application_setting[deny_all_requests_except_allowed]" ${
+ denyAll ? 'checked="checked"' : ''
+ }/>
+ <div class="js-deny-all-requests-warning ${denyAll ? '' : 'gl-display-none'}"></div>
+ <input class="js-allow-local-requests" type="checkbox" name="application_setting[allow_local_requests_from_web_hooks_and_services]" />
+ <input class="js-allow-local-requests" type="checkbox" name="application_setting[allow_local_requests_from_system_hooks]" />
+ `);
+ };
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ describe('when the checkbox is not checked', () => {
+ beforeEach(() => {
+ createFixture();
+ initNetworkOutbound();
+ });
+
+ it('shows banner and disables allow checkboxes on change', () => {
+ expect(findDenyCheckbox().checked).toBe(false);
+ expect(findWarningBanner().classList).toContain('gl-display-none');
+
+ clickDenyCheckbox();
+
+ expect(findDenyCheckbox().checked).toBe(true);
+ expect(findWarningBanner().classList).not.toContain('gl-display-none');
+ const allowCheckboxes = findAllowCheckboxes();
+ allowCheckboxes.forEach((checkbox) => {
+ expect(checkbox.checked).toBe(false);
+ expect(checkbox.disabled).toBe(true);
+ });
+ });
+ });
+
+ describe('when the checkbox is checked', () => {
+ beforeEach(() => {
+ createFixture(true);
+ initNetworkOutbound();
+ });
+
+ it('hides banner and enables allow checkboxes on change', () => {
+ expect(findDenyCheckbox().checked).toBe(true);
+ expect(findWarningBanner().classList).not.toContain('gl-display-none');
+
+ clickDenyCheckbox();
+
+ expect(findDenyCheckbox().checked).toBe(false);
+ expect(findWarningBanner().classList).toContain('gl-display-none');
+ const allowCheckboxes = findAllowCheckboxes();
+ allowCheckboxes.forEach((checkbox) => {
+ expect(checkbox.disabled).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/applications/components/delete_application_spec.js b/spec/frontend/admin/applications/components/delete_application_spec.js
index 1a400a101b5..315c38a2bbc 100644
--- a/spec/frontend/admin/applications/components/delete_application_spec.js
+++ b/spec/frontend/admin/applications/components/delete_application_spec.js
@@ -31,7 +31,6 @@ describe('DeleteApplication', () => {
});
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
diff --git a/spec/frontend/admin/background_migrations/components/database_listbox_spec.js b/spec/frontend/admin/background_migrations/components/database_listbox_spec.js
index 212f4c0842c..d7b319a3d5e 100644
--- a/spec/frontend/admin/background_migrations/components/database_listbox_spec.js
+++ b/spec/frontend/admin/background_migrations/components/database_listbox_spec.js
@@ -26,10 +26,6 @@ describe('BackgroundMigrationsDatabaseListbox', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox);
describe('template always', () => {
diff --git a/spec/frontend/admin/broadcast_messages/components/base_spec.js b/spec/frontend/admin/broadcast_messages/components/base_spec.js
index d69bf4a22bf..80577f86e3e 100644
--- a/spec/frontend/admin/broadcast_messages/components/base_spec.js
+++ b/spec/frontend/admin/broadcast_messages/components/base_spec.js
@@ -4,15 +4,15 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import BroadcastMessagesBase from '~/admin/broadcast_messages/components/base.vue';
import MessagesTable from '~/admin/broadcast_messages/components/messages_table.vue';
import { generateMockMessages, MOCK_MESSAGES } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility');
describe('BroadcastMessagesBase', () => {
@@ -41,7 +41,6 @@ describe('BroadcastMessagesBase', () => {
afterEach(() => {
axiosMock.restore();
- wrapper.destroy();
});
it('renders the table and pagination when there are existing messages', () => {
@@ -108,6 +107,6 @@ describe('BroadcastMessagesBase', () => {
findTable().vm.$emit('delete-message', id);
await waitForPromises();
- expect(redirectTo).toHaveBeenCalledWith(`${TEST_HOST}/admin/broadcast_messages?page=1`);
+ expect(redirectTo).toHaveBeenCalledWith(`${TEST_HOST}/admin/broadcast_messages?page=1`); // eslint-disable-line import/no-deprecated
});
});
diff --git a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js
index 36c0ac303ba..212f26b8faf 100644
--- a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js
+++ b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js
@@ -1,21 +1,16 @@
import { mount } from '@vue/test-utils';
import { GlBroadcastMessage, GlForm } from '@gitlab/ui';
import AxiosMockAdapter from 'axios-mock-adapter';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST } from '~/lib/utils/http_status';
import MessageForm from '~/admin/broadcast_messages/components/message_form.vue';
-import {
- BROADCAST_MESSAGES_PATH,
- TYPE_BANNER,
- TYPE_NOTIFICATION,
- THEMES,
-} from '~/admin/broadcast_messages/constants';
+import { TYPE_BANNER, TYPE_NOTIFICATION, THEMES } from '~/admin/broadcast_messages/constants';
import waitForPromises from 'helpers/wait_for_promises';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { MOCK_TARGET_ACCESS_LEVELS } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('MessageForm', () => {
let wrapper;
@@ -32,6 +27,8 @@ describe('MessageForm', () => {
endsAt: new Date(),
};
+ const messagesPath = '_messages_path_';
+
const findPreview = () => extendedWrapper(wrapper.findComponent(GlBroadcastMessage));
const findThemeSelect = () => wrapper.findComponent('[data-testid=theme-select]');
const findDismissable = () => wrapper.findComponent('[data-testid=dismissable-checkbox]');
@@ -39,11 +36,12 @@ describe('MessageForm', () => {
const findSubmitButton = () => wrapper.findComponent('[data-testid=submit-button]');
const findForm = () => wrapper.findComponent(GlForm);
- function createComponent({ broadcastMessage = {}, glFeatures = {} }) {
+ function createComponent({ broadcastMessage = {} } = {}) {
wrapper = mount(MessageForm, {
provide: {
- glFeatures,
targetAccessLevelOptions: MOCK_TARGET_ACCESS_LEVELS,
+ messagesPath,
+ previewPath: '_preview_path_',
},
propsData: {
broadcastMessage: {
@@ -101,15 +99,10 @@ describe('MessageForm', () => {
});
describe('target roles checkboxes', () => {
- it('renders when roleTargetedBroadcastMessages feature is enabled', () => {
- createComponent({ glFeatures: { roleTargetedBroadcastMessages: true } });
+ it('renders target roles', () => {
+ createComponent();
expect(findTargetRoles().exists()).toBe(true);
});
-
- it('does not render when roleTargetedBroadcastMessages feature is disabled', () => {
- createComponent({ glFeatures: { roleTargetedBroadcastMessages: false } });
- expect(findTargetRoles().exists()).toBe(false);
- });
});
describe('form submit button', () => {
@@ -151,16 +144,16 @@ describe('MessageForm', () => {
findForm().vm.$emit('submit', { preventDefault: () => {} });
await waitForPromises();
- expect(axiosMock.history.post).toHaveLength(1);
- expect(axiosMock.history.post[0]).toMatchObject({
- url: BROADCAST_MESSAGES_PATH,
+ expect(axiosMock.history.post).toHaveLength(2);
+ expect(axiosMock.history.post[1]).toMatchObject({
+ url: messagesPath,
data: JSON.stringify(defaultPayload),
});
});
it('shows an error alert if the create request fails', async () => {
createComponent({ broadcastMessage: { id: undefined } });
- axiosMock.onPost(BROADCAST_MESSAGES_PATH).replyOnce(HTTP_STATUS_BAD_REQUEST);
+ axiosMock.onPost(messagesPath).replyOnce(HTTP_STATUS_BAD_REQUEST);
findForm().vm.$emit('submit', { preventDefault: () => {} });
await waitForPromises();
@@ -179,7 +172,7 @@ describe('MessageForm', () => {
expect(axiosMock.history.patch).toHaveLength(1);
expect(axiosMock.history.patch[0]).toMatchObject({
- url: `${BROADCAST_MESSAGES_PATH}/${id}`,
+ url: `${messagesPath}/${id}`,
data: JSON.stringify(defaultPayload),
});
});
@@ -187,7 +180,7 @@ describe('MessageForm', () => {
it('shows an error alert if the update request fails', async () => {
const id = 1337;
createComponent({ broadcastMessage: { id } });
- axiosMock.onPost(`${BROADCAST_MESSAGES_PATH}/${id}`).replyOnce(HTTP_STATUS_BAD_REQUEST);
+ axiosMock.onPost(`${messagesPath}/${id}`).replyOnce(HTTP_STATUS_BAD_REQUEST);
findForm().vm.$emit('submit', { preventDefault: () => {} });
await waitForPromises();
diff --git a/spec/frontend/admin/broadcast_messages/components/messages_table_spec.js b/spec/frontend/admin/broadcast_messages/components/messages_table_spec.js
index 349fab03853..6d536b2d0e4 100644
--- a/spec/frontend/admin/broadcast_messages/components/messages_table_spec.js
+++ b/spec/frontend/admin/broadcast_messages/components/messages_table_spec.js
@@ -9,11 +9,8 @@ describe('MessagesTable', () => {
const findTargetRoles = () => wrapper.find('[data-testid="target-roles-th"]');
const findDeleteButton = (id) => wrapper.find(`[data-testid="delete-message-${id}"]`);
- function createComponent(props = {}, glFeatures = {}) {
+ function createComponent(props = {}) {
wrapper = mount(MessagesTable, {
- provide: {
- glFeatures,
- },
propsData: {
messages: MOCK_MESSAGES,
...props,
@@ -21,24 +18,16 @@ describe('MessagesTable', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a table row for each message', () => {
createComponent();
expect(findRows()).toHaveLength(MOCK_MESSAGES.length);
});
- it('renders the "Target Roles" column when roleTargetedBroadcastMessages is enabled', () => {
- createComponent({}, { roleTargetedBroadcastMessages: true });
- expect(findTargetRoles().exists()).toBe(true);
- });
-
- it('does not render the "Target Roles" column when roleTargetedBroadcastMessages is disabled', () => {
+ it('renders the "Target Roles" column', () => {
createComponent();
- expect(findTargetRoles().exists()).toBe(false);
+
+ expect(findTargetRoles().exists()).toBe(true);
});
it('emits a delete-message event when a delete button is clicked', () => {
diff --git a/spec/frontend/admin/broadcast_messages/mock_data.js b/spec/frontend/admin/broadcast_messages/mock_data.js
index 2e20b5cf638..54596fbf977 100644
--- a/spec/frontend/admin/broadcast_messages/mock_data.js
+++ b/spec/frontend/admin/broadcast_messages/mock_data.js
@@ -4,7 +4,10 @@ const generateMockMessage = (id) => ({
edit_path: `/admin/broadcast_messages/${id}/edit`,
starts_at: new Date().toISOString(),
ends_at: new Date().toISOString(),
- preview: '<div>YEET</div>',
+ broadcast_type: 'banner',
+ dismissable: true,
+ message: 'YEET',
+ theme: 'indigo',
status: 'Expired',
target_path: '*/welcome',
target_roles: 'Maintainer, Owner',
diff --git a/spec/frontend/admin/deploy_keys/components/table_spec.js b/spec/frontend/admin/deploy_keys/components/table_spec.js
index 4d4a2caedde..a05654a1d25 100644
--- a/spec/frontend/admin/deploy_keys/components/table_spec.js
+++ b/spec/frontend/admin/deploy_keys/components/table_spec.js
@@ -9,10 +9,10 @@ import { stubComponent } from 'helpers/stub_component';
import DeployKeysTable from '~/admin/deploy_keys/components/table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Api, { DEFAULT_PER_PAGE } from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
jest.mock('~/api');
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('DeployKeysTable', () => {
@@ -91,10 +91,6 @@ describe('DeployKeysTable', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders page title', () => {
createComponent();
@@ -242,7 +238,7 @@ describe('DeployKeysTable', () => {
itRendersTheEmptyState();
- it('displays flash', () => {
+ it('displays alert', () => {
expect(createAlert).toHaveBeenCalledWith({
message: DeployKeysTable.i18n.apiErrorMessage,
captureError: true,
diff --git a/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js
index eecc21e206b..9e55716cc30 100644
--- a/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js
+++ b/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js
@@ -28,10 +28,6 @@ describe('Signup Form', () => {
const findCheckboxLabel = () => findByTestId('label');
const findHelpText = () => findByTestId('helpText');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Signup Checkbox', () => {
beforeEach(() => {
mountComponent();
diff --git a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js
index f2a951bcc76..db8c33d01cb 100644
--- a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js
+++ b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js
@@ -1,4 +1,4 @@
-import { GlButton, GlModal, GlLink } from '@gitlab/ui';
+import { GlButton, GlModal } from '@gitlab/ui';
import { within } from '@testing-library/dom';
import { shallowMount, mount, createWrapper } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
@@ -36,12 +36,9 @@ describe('Signup Form', () => {
const findDenyListRawInputGroup = () => wrapper.findByTestId('domain-denylist-raw-input-group');
const findDenyListFileInputGroup = () => wrapper.findByTestId('domain-denylist-file-input-group');
const findUserCapInput = () => wrapper.findByTestId('user-cap-input');
- const findUserCapFormGroup = () => wrapper.findByTestId('user-cap-form-group');
const findModal = () => wrapper.findComponent(GlModal);
afterEach(() => {
- wrapper.destroy();
-
formSubmitSpy = null;
});
@@ -214,19 +211,4 @@ describe('Signup Form', () => {
});
});
});
-
- describe('rendering help links within user cap description', () => {
- beforeEach(() => {
- mountComponent({ mountFn: mount });
- });
-
- it('renders projectSharingHelpLink and groupSharingHelpLink', () => {
- const [projectSharingLink, groupSharingLink] = findUserCapFormGroup().findAllComponents(
- GlLink,
- ).wrappers;
-
- expect(projectSharingLink.attributes('href')).toBe(mockData.projectSharingHelpLink);
- expect(groupSharingLink.attributes('href')).toBe(mockData.groupSharingHelpLink);
- });
- });
});
diff --git a/spec/frontend/admin/signup_restrictions/mock_data.js b/spec/frontend/admin/signup_restrictions/mock_data.js
index ce5ec2248fe..3140d7be105 100644
--- a/spec/frontend/admin/signup_restrictions/mock_data.js
+++ b/spec/frontend/admin/signup_restrictions/mock_data.js
@@ -22,8 +22,6 @@ export const rawMockData = {
passwordLowercaseRequired: 'true',
passwordUppercaseRequired: 'true',
passwordSymbolRequired: 'true',
- projectSharingHelpLink: 'project-sharing/help/link',
- groupSharingHelpLink: 'group-sharing/help/link',
};
export const mockData = {
@@ -50,6 +48,4 @@ export const mockData = {
passwordLowercaseRequired: true,
passwordUppercaseRequired: true,
passwordSymbolRequired: true,
- projectSharingHelpLink: 'project-sharing/help/link',
- groupSharingHelpLink: 'group-sharing/help/link',
};
diff --git a/spec/frontend/admin/statistics_panel/components/app_spec.js b/spec/frontend/admin/statistics_panel/components/app_spec.js
index 4c362a31068..60e46cddd7e 100644
--- a/spec/frontend/admin/statistics_panel/components/app_spec.js
+++ b/spec/frontend/admin/statistics_panel/components/app_spec.js
@@ -30,10 +30,6 @@ describe('Admin statistics app', () => {
store = createStore();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findStats = (idx) => wrapper.findAll('.js-stats').at(idx);
describe('template', () => {
diff --git a/spec/frontend/admin/topics/components/remove_avatar_spec.js b/spec/frontend/admin/topics/components/remove_avatar_spec.js
index 97d257c682c..c069203d046 100644
--- a/spec/frontend/admin/topics/components/remove_avatar_spec.js
+++ b/spec/frontend/admin/topics/components/remove_avatar_spec.js
@@ -20,7 +20,7 @@ describe('RemoveAvatar', () => {
name,
},
directives: {
- GlModal: createMockDirective(),
+ GlModal: createMockDirective('gl-modal'),
},
stubs: {
GlSprintf,
@@ -36,10 +36,6 @@ describe('RemoveAvatar', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('the button component', () => {
it('displays the remove button', () => {
const button = findButton();
diff --git a/spec/frontend/admin/topics/components/topic_select_spec.js b/spec/frontend/admin/topics/components/topic_select_spec.js
index 738cbd88c4c..113a0e3d404 100644
--- a/spec/frontend/admin/topics/components/topic_select_spec.js
+++ b/spec/frontend/admin/topics/components/topic_select_spec.js
@@ -59,7 +59,6 @@ describe('TopicSelect', () => {
}
afterEach(() => {
- wrapper.destroy();
jest.clearAllMocks();
});
diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js
index 8e9652332c1..a5e7c6ebe21 100644
--- a/spec/frontend/admin/users/components/actions/actions_spec.js
+++ b/spec/frontend/admin/users/components/actions/actions_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Actions from '~/admin/users/components/actions';
import Delete from '~/admin/users/components/actions/delete.vue';
@@ -12,7 +12,7 @@ import { paths, userDeletionObstacles } from '../../mock_data';
describe('Action components', () => {
let wrapper;
- const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
+ const findDisclosureDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
const initComponent = ({ component, props } = {}) => {
wrapper = shallowMount(component, {
@@ -22,11 +22,6 @@ describe('Action components', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('CONFIRMATION_ACTIONS', () => {
it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', (action) => {
initComponent({
@@ -37,7 +32,7 @@ describe('Action components', () => {
},
});
- expect(findDropdownItem().exists()).toBe(true);
+ expect(findDisclosureDropdownItem().exists()).toBe(true);
});
});
@@ -57,7 +52,7 @@ describe('Action components', () => {
},
});
- await findDropdownItem().vm.$emit('click');
+ await findDisclosureDropdownItem().vm.$emit('action');
expect(eventHub.$emit).toHaveBeenCalledWith(
EVENT_OPEN_DELETE_USER_MODAL,
diff --git a/spec/frontend/admin/users/components/actions/delete_with_contributions_spec.js b/spec/frontend/admin/users/components/actions/delete_with_contributions_spec.js
index 64a88aab2c2..606a5c779fb 100644
--- a/spec/frontend/admin/users/components/actions/delete_with_contributions_spec.js
+++ b/spec/frontend/admin/users/components/actions/delete_with_contributions_spec.js
@@ -1,5 +1,5 @@
import { GlLoadingIcon } from '@gitlab/ui';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { mount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import DeleteWithContributions from '~/admin/users/components/actions/delete_with_contributions.vue';
import eventHub, {
@@ -35,7 +35,7 @@ describe('DeleteWithContributions', () => {
};
const createComponent = () => {
- wrapper = mountExtended(DeleteWithContributions, { propsData: defaultPropsData });
+ wrapper = mount(DeleteWithContributions, { propsData: defaultPropsData });
};
describe('when action is clicked', () => {
@@ -47,10 +47,10 @@ describe('DeleteWithContributions', () => {
});
it('displays loading icon and disables button', async () => {
- await wrapper.trigger('click');
+ await wrapper.find('button').trigger('click');
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
- expect(wrapper.findByRole('menuitem').attributes()).toMatchObject({
+ expect(wrapper.attributes()).toMatchObject({
disabled: 'disabled',
'aria-busy': 'true',
});
@@ -67,7 +67,7 @@ describe('DeleteWithContributions', () => {
});
it('emits event with association counts', async () => {
- await wrapper.trigger('click');
+ await wrapper.find('button').trigger('click');
await waitForPromises();
expect(associationsCount).toHaveBeenCalledWith(defaultPropsData.userId);
@@ -92,7 +92,7 @@ describe('DeleteWithContributions', () => {
});
it('emits event with error', async () => {
- await wrapper.trigger('click');
+ await wrapper.find('button').trigger('click');
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith(
diff --git a/spec/frontend/admin/users/components/app_spec.js b/spec/frontend/admin/users/components/app_spec.js
index 913732aae42..d40089edc82 100644
--- a/spec/frontend/admin/users/components/app_spec.js
+++ b/spec/frontend/admin/users/components/app_spec.js
@@ -17,11 +17,6 @@ describe('AdminUsersApp component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when initialized', () => {
beforeEach(() => {
initComponent();
diff --git a/spec/frontend/admin/users/components/associations/__snapshots__/associations_list_spec.js.snap b/spec/frontend/admin/users/components/associations/__snapshots__/associations_list_spec.js.snap
deleted file mode 100644
index dc98d367af7..00000000000
--- a/spec/frontend/admin/users/components/associations/__snapshots__/associations_list_spec.js.snap
+++ /dev/null
@@ -1,34 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`AssociationsList when counts are 0 does not render items 1`] = `""`;
-
-exports[`AssociationsList when counts are plural renders plural counts 1`] = `
-"<ul class=\\"gl-mb-5\\">
- <li><strong>2</strong> groups</li>
- <li><strong>3</strong> projects</li>
- <li><strong>4</strong> issues</li>
- <li><strong>5</strong> merge requests</li>
-</ul>"
-`;
-
-exports[`AssociationsList when counts are singular renders singular counts 1`] = `
-"<ul class=\\"gl-mb-5\\">
- <li><strong>1</strong> group</li>
- <li><strong>1</strong> project</li>
- <li><strong>1</strong> issue</li>
- <li><strong>1</strong> merge request</li>
-</ul>"
-`;
-
-exports[`AssociationsList when there is an error displays an alert 1`] = `
-"<div class=\\"gl-mb-5 gl-alert gl-alert-not-dismissible gl-alert-danger\\"><svg data-testid=\\"error-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"gl-icon s16 gl-alert-icon gl-alert-icon-no-title\\">
- <use href=\\"#error\\"></use>
- </svg>
- <div role=\\"alert\\" aria-live=\\"assertive\\" class=\\"gl-alert-content\\">
- <!---->
- <div class=\\"gl-alert-body\\">An error occurred while fetching this user's contributions, and the request cannot return the number of issues, merge requests, groups, and projects linked to this user. If you proceed with deleting the user, all their contributions will still be deleted.</div>
- <!---->
- </div>
- <!---->
-</div>"
-`;
diff --git a/spec/frontend/admin/users/components/associations/associations_list_spec.js b/spec/frontend/admin/users/components/associations/associations_list_spec.js
index d77a645111f..21f9924b21a 100644
--- a/spec/frontend/admin/users/components/associations/associations_list_spec.js
+++ b/spec/frontend/admin/users/components/associations/associations_list_spec.js
@@ -1,3 +1,4 @@
+import { GlAlert } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import AssociationsList from '~/admin/users/components/associations/associations_list.vue';
@@ -13,6 +14,8 @@ describe('AssociationsList', () => {
},
};
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
const createComponent = ({ propsData = {} } = {}) => {
wrapper = mountExtended(AssociationsList, {
propsData: {
@@ -30,7 +33,18 @@ describe('AssociationsList', () => {
},
});
- expect(wrapper.html()).toMatchSnapshot();
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toContain(
+ "An error occurred while fetching this user's contributions",
+ );
+ });
+ });
+
+ describe('with no errors', () => {
+ it('does not display an alert', () => {
+ createComponent();
+
+ expect(findAlert().exists()).toBe(false);
});
});
@@ -38,24 +52,36 @@ describe('AssociationsList', () => {
it('renders singular counts', () => {
createComponent();
- expect(wrapper.html()).toMatchSnapshot();
+ expect(wrapper.text()).toContain(`${defaultPropsData.associationsCount.groups_count} group`);
+ expect(wrapper.text()).toContain(`${defaultPropsData.associationsCount.issues_count} issue`);
+ expect(wrapper.text()).toContain(
+ `${defaultPropsData.associationsCount.projects_count} project`,
+ );
+ expect(wrapper.text()).toContain(
+ `${defaultPropsData.associationsCount.merge_requests_count} merge request`,
+ );
});
});
describe('when counts are plural', () => {
it('renders plural counts', () => {
- createComponent({
- propsData: {
- associationsCount: {
- groups_count: 2,
- projects_count: 3,
- issues_count: 4,
- merge_requests_count: 5,
- },
+ const propsData = {
+ associationsCount: {
+ groups_count: 2,
+ projects_count: 3,
+ issues_count: 4,
+ merge_requests_count: 5,
},
- });
+ };
+
+ createComponent({ propsData });
- expect(wrapper.html()).toMatchSnapshot();
+ expect(wrapper.text()).toContain(`${propsData.associationsCount.groups_count} groups`);
+ expect(wrapper.text()).toContain(`${propsData.associationsCount.issues_count} issues`);
+ expect(wrapper.text()).toContain(`${propsData.associationsCount.projects_count} projects`);
+ expect(wrapper.text()).toContain(
+ `${propsData.associationsCount.merge_requests_count} merge requests`,
+ );
});
});
@@ -72,7 +98,7 @@ describe('AssociationsList', () => {
},
});
- expect(wrapper.html()).toMatchSnapshot();
+ expect(wrapper.html()).toBe('');
});
});
});
diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js
index 2e892e292d7..b2a0c201893 100644
--- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js
+++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js
@@ -73,11 +73,6 @@ describe('Delete user modal', () => {
formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders modal with form included', () => {
createComponent();
expect(findForm().element).toMatchSnapshot();
@@ -89,8 +84,8 @@ describe('Delete user modal', () => {
});
it('has disabled buttons', () => {
- expect(findPrimaryButton().attributes('disabled')).toBe('true');
- expect(findSecondaryButton().attributes('disabled')).toBe('true');
+ expect(findPrimaryButton().attributes('disabled')).toBeDefined();
+ expect(findSecondaryButton().attributes('disabled')).toBeDefined();
});
});
@@ -107,8 +102,8 @@ describe('Delete user modal', () => {
});
it('has disabled buttons', () => {
- expect(findPrimaryButton().attributes('disabled')).toBe('true');
- expect(findSecondaryButton().attributes('disabled')).toBe('true');
+ expect(findPrimaryButton().attributes('disabled')).toBeDefined();
+ expect(findSecondaryButton().attributes('disabled')).toBeDefined();
});
});
diff --git a/spec/frontend/admin/users/components/user_actions_spec.js b/spec/frontend/admin/users/components/user_actions_spec.js
index 1b080b05c95..73d8c082bb9 100644
--- a/spec/frontend/admin/users/components/user_actions_spec.js
+++ b/spec/frontend/admin/users/components/user_actions_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownDivider } from '@gitlab/ui';
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Actions from '~/admin/users/components/actions';
@@ -19,7 +19,7 @@ describe('AdminUserActions component', () => {
const findEditButton = (id = user.id) => findUserActions(id).find('[data-testid="edit"]');
const findActionsDropdown = (id = user.id) =>
findUserActions(id).find('[data-testid="dropdown-toggle"]');
- const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
+ const findDisclosureGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup);
const initComponent = ({ actions = [], showButtonLabels } = {}) => {
wrapper = shallowMountExtended(AdminUserActions, {
@@ -32,16 +32,11 @@ describe('AdminUserActions component', () => {
showButtonLabels,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('edit button', () => {
describe('when the user has an edit action attached', () => {
beforeEach(() => {
@@ -109,8 +104,8 @@ describe('AdminUserActions component', () => {
initComponent({ actions: [LDAP, ...DELETE_ACTIONS] });
});
- it('renders a dropdown divider', () => {
- expect(findDropdownDivider().exists()).toBe(true);
+ it('renders a disclosure group', () => {
+ expect(findDisclosureGroup().exists()).toBe(true);
});
it('only renders delete dropdown items for actions containing the word "delete"', () => {
@@ -131,8 +126,8 @@ describe('AdminUserActions component', () => {
});
describe('when there are no delete actions', () => {
- it('does not render a dropdown divider', () => {
- expect(findDropdownDivider().exists()).toBe(false);
+ it('does not render a disclosure group', () => {
+ expect(findDisclosureGroup().exists()).toBe(false);
});
it('does not render a delete dropdown item', () => {
diff --git a/spec/frontend/admin/users/components/user_avatar_spec.js b/spec/frontend/admin/users/components/user_avatar_spec.js
index 94fac875fbe..02e648d2b77 100644
--- a/spec/frontend/admin/users/components/user_avatar_spec.js
+++ b/spec/frontend/admin/users/components/user_avatar_spec.js
@@ -26,7 +26,7 @@ describe('AdminUserAvatar component', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
stubs: {
GlAvatarLabeled,
@@ -34,11 +34,6 @@ describe('AdminUserAvatar component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when initialized', () => {
beforeEach(() => {
initComponent();
diff --git a/spec/frontend/admin/users/components/user_date_spec.js b/spec/frontend/admin/users/components/user_date_spec.js
index 73be33d5a9d..19c1cd38a50 100644
--- a/spec/frontend/admin/users/components/user_date_spec.js
+++ b/spec/frontend/admin/users/components/user_date_spec.js
@@ -17,11 +17,6 @@ describe('FormatDate component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it.each`
date | dateFormat | output
${mockDate} | ${undefined} | ${'Nov 13, 2020'}
diff --git a/spec/frontend/admin/users/components/users_table_spec.js b/spec/frontend/admin/users/components/users_table_spec.js
index a0aec347b6b..6f658fd2e59 100644
--- a/spec/frontend/admin/users/components/users_table_spec.js
+++ b/spec/frontend/admin/users/components/users_table_spec.js
@@ -10,12 +10,12 @@ import AdminUserActions from '~/admin/users/components/user_actions.vue';
import AdminUserAvatar from '~/admin/users/components/user_avatar.vue';
import AdminUsersTable from '~/admin/users/components/users_table.vue';
import getUsersGroupCountsQuery from '~/admin/users/graphql/queries/get_users_group_counts.query.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import AdminUserDate from '~/vue_shared/components/user_date.vue';
import { users, paths, createGroupCountResponse } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -57,11 +57,6 @@ describe('AdminUsersTable component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when there are users', () => {
beforeEach(() => {
initComponent();
@@ -134,7 +129,7 @@ describe('AdminUsersTable component', () => {
await waitForPromises();
});
- it('creates a flash message and captures the error', () => {
+ it('creates an alert message and captures the error', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'Could not load user group counts. Please refresh the page to try again.',
captureError: true,
diff --git a/spec/frontend/admin/users/index_spec.js b/spec/frontend/admin/users/index_spec.js
index b51858d5129..d8a94ee5e1d 100644
--- a/spec/frontend/admin/users/index_spec.js
+++ b/spec/frontend/admin/users/index_spec.js
@@ -19,8 +19,6 @@ describe('initAdminUsersApp', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
el = null;
});
@@ -47,8 +45,6 @@ describe('initAdminUserActions', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
el = null;
});
diff --git a/spec/frontend/admin/users/new_spec.js b/spec/frontend/admin/users/new_spec.js
index 5e5763822a8..eba5c87f470 100644
--- a/spec/frontend/admin/users/new_spec.js
+++ b/spec/frontend/admin/users/new_spec.js
@@ -1,20 +1,19 @@
+import newWithInternalUserRegex from 'test_fixtures/admin/users/new_with_internal_user_regex.html';
import {
setupInternalUserRegexHandler,
ID_USER_EMAIL,
ID_USER_EXTERNAL,
ID_WARNING,
} from '~/admin/users/new';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
describe('admin/users/new', () => {
- const FIXTURE = 'admin/users/new_with_internal_user_regex.html';
-
let elExternal;
let elUserEmail;
let elWarningMessage;
beforeEach(() => {
- loadHTMLFixture(FIXTURE);
+ setHTMLFixture(newWithInternalUserRegex);
setupInternalUserRegexHandler();
elExternal = document.getElementById(ID_USER_EXTERNAL);
diff --git a/spec/frontend/airflow/dags/components/dags_spec.js b/spec/frontend/airflow/dags/components/dags_spec.js
deleted file mode 100644
index f9cf4fc87af..00000000000
--- a/spec/frontend/airflow/dags/components/dags_spec.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import { GlAlert, GlPagination, GlTableLite } from '@gitlab/ui';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { TEST_HOST } from 'helpers/test_constants';
-import AirflowDags from '~/airflow/dags/components/dags.vue';
-import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import { mockDags } from './mock_data';
-
-describe('AirflowDags', () => {
- let wrapper;
-
- const createWrapper = (
- dags = [],
- pagination = { page: 1, isLastPage: false, per_page: 2, totalItems: 0 },
- ) => {
- wrapper = mountExtended(AirflowDags, {
- propsData: {
- dags,
- pagination,
- },
- });
- };
-
- const findAlert = () => wrapper.findComponent(GlAlert);
- const findEmptyState = () => wrapper.findByText('There are no DAGs to show');
- const findPagination = () => wrapper.findComponent(GlPagination);
-
- describe('default (no dags)', () => {
- beforeEach(() => {
- createWrapper();
- });
-
- it('shows incubation warning', () => {
- expect(findAlert().exists()).toBe(true);
- });
-
- it('shows empty state', () => {
- expect(findEmptyState().exists()).toBe(true);
- });
-
- it('does not show pagination', () => {
- expect(findPagination().exists()).toBe(false);
- });
- });
-
- describe('with dags', () => {
- const createWrapperWithDags = (pagination = {}) => {
- createWrapper(mockDags, {
- page: 1,
- isLastPage: false,
- per_page: 2,
- totalItems: 5,
- ...pagination,
- });
- };
-
- const findDagsData = () => {
- return wrapper
- .findComponent(GlTableLite)
- .findAll('tbody tr')
- .wrappers.map((tr) => {
- return tr.findAll('td').wrappers.map((td) => {
- const timeAgo = td.findComponent(TimeAgo);
-
- if (timeAgo.exists()) {
- return {
- type: 'time',
- value: timeAgo.props('time'),
- };
- }
-
- return {
- type: 'text',
- value: td.text(),
- };
- });
- });
- };
-
- it('renders the table of Dags with data', () => {
- createWrapperWithDags();
-
- expect(findDagsData()).toEqual(
- mockDags.map((x) => [
- { type: 'text', value: x.dag_name },
- { type: 'text', value: x.schedule },
- { type: 'time', value: x.next_run },
- { type: 'text', value: String(x.is_active) },
- { type: 'text', value: String(x.is_paused) },
- { type: 'text', value: x.fileloc },
- ]),
- );
- });
-
- describe('Pagination behaviour', () => {
- it.each`
- pagination | expected
- ${{}} | ${{ value: 1, prevPage: null, nextPage: 2 }}
- ${{ page: 2 }} | ${{ value: 2, prevPage: 1, nextPage: 3 }}
- ${{ isLastPage: true, page: 2 }} | ${{ value: 2, prevPage: 1, nextPage: null }}
- `('with $pagination, sets pagination props', ({ pagination, expected }) => {
- createWrapperWithDags({ ...pagination });
-
- expect(findPagination().props()).toMatchObject(expected);
- });
-
- it('generates link for each page', () => {
- createWrapperWithDags();
-
- const generateLink = findPagination().props('linkGen');
-
- expect(generateLink(3)).toBe(`${TEST_HOST}/?page=3`);
- });
- });
- });
-});
diff --git a/spec/frontend/airflow/dags/components/mock_data.js b/spec/frontend/airflow/dags/components/mock_data.js
deleted file mode 100644
index 9547282517d..00000000000
--- a/spec/frontend/airflow/dags/components/mock_data.js
+++ /dev/null
@@ -1,67 +0,0 @@
-export const mockDags = [
- {
- id: 1,
- project_id: 7,
- created_at: '2023-01-05T14:07:02.975Z',
- updated_at: '2023-01-05T14:07:02.975Z',
- has_import_errors: false,
- is_active: false,
- is_paused: true,
- next_run: '2023-01-05T14:07:02.975Z',
- dag_name: 'Dag number 1',
- schedule: 'Manual',
- fileloc: '/opt/dag.py',
- },
- {
- id: 2,
- project_id: 7,
- created_at: '2023-01-05T14:07:02.975Z',
- updated_at: '2023-01-05T14:07:02.975Z',
- has_import_errors: false,
- is_active: false,
- is_paused: true,
- next_run: '2023-01-05T14:07:02.975Z',
- dag_name: 'Dag number 2',
- schedule: 'Manual',
- fileloc: '/opt/dag.py',
- },
- {
- id: 3,
- project_id: 7,
- created_at: '2023-01-05T14:07:02.975Z',
- updated_at: '2023-01-05T14:07:02.975Z',
- has_import_errors: false,
- is_active: false,
- is_paused: true,
- next_run: '2023-01-05T14:07:02.975Z',
- dag_name: 'Dag number 3',
- schedule: 'Manual',
- fileloc: '/opt/dag.py',
- },
- {
- id: 4,
- project_id: 7,
- created_at: '2023-01-05T14:07:02.975Z',
- updated_at: '2023-01-05T14:07:02.975Z',
- has_import_errors: false,
- is_active: false,
- is_paused: true,
- next_run: '2023-01-05T14:07:02.975Z',
- dag_name: 'Dag number 4',
- schedule: 'Manual',
- fileloc: '/opt/dag.py',
- },
- {
- id: 5,
- project_id: 7,
- created_at: '2023-01-05T14:07:02.975Z',
- updated_at: '2023-01-05T14:07:02.975Z',
- has_import_errors: false,
- is_active: false,
- is_paused: true,
- next_run: '2023-01-05T14:07:02.975Z',
- dag_name: 'Dag number 5',
- schedule: 'Manual',
- fileloc: '/opt/dag.py',
- },
-];
diff --git a/spec/frontend/alert_management/components/alert_management_empty_state_spec.js b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js
index 0d6bc1b74fb..b2889d429b1 100644
--- a/spec/frontend/alert_management/components/alert_management_empty_state_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js
@@ -19,12 +19,6 @@ describe('AlertManagementEmptyState', () => {
mountComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
const EmptyState = () => wrapper.findComponent(GlEmptyState);
describe('Empty state', () => {
diff --git a/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js
index 3a5fb99fdf1..3cc2d59295c 100644
--- a/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js
@@ -20,12 +20,6 @@ describe('AlertManagementList', () => {
mountComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('Alert List Wrapper', () => {
it('should show the empty state when alerts are not enabled', () => {
expect(wrapper.findComponent(AlertManagementEmptyState).exists()).toBe(true);
diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js
index 7fb4f2d2463..afd88e1a6ac 100644
--- a/spec/frontend/alert_management/components/alert_management_table_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_table_spec.js
@@ -68,7 +68,7 @@ describe('AlertManagementTable', () => {
},
stubs,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
}),
);
@@ -79,9 +79,6 @@ describe('AlertManagementTable', () => {
});
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
mock.restore();
});
diff --git a/spec/frontend/alert_spec.js b/spec/frontend/alert_spec.js
new file mode 100644
index 00000000000..1ae8373016b
--- /dev/null
+++ b/spec/frontend/alert_spec.js
@@ -0,0 +1,276 @@
+import * as Sentry from '@sentry/browser';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { createAlert, VARIANT_WARNING } from '~/alert';
+
+jest.mock('@sentry/browser');
+
+describe('Flash', () => {
+ const findTextContent = (containerSelector = '.flash-container') =>
+ document.querySelector(containerSelector).textContent.replace(/\s+/g, ' ').trim();
+
+ describe('createAlert', () => {
+ const mockMessage = 'a message';
+ let alert;
+
+ describe('no flash-container', () => {
+ it('does not add to the DOM', () => {
+ alert = createAlert({ message: mockMessage });
+
+ expect(alert).toBeNull();
+ expect(document.querySelector('.gl-alert')).toBeNull();
+ });
+ });
+
+ describe('with flash-container', () => {
+ beforeEach(() => {
+ setHTMLFixture('<div class="flash-container"></div>');
+ });
+
+ afterEach(() => {
+ if (alert) {
+ alert.$destroy();
+ }
+ resetHTMLFixture();
+ });
+
+ it('adds alert element into the document by default', () => {
+ alert = createAlert({ message: mockMessage });
+
+ expect(findTextContent()).toBe(mockMessage);
+ expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull();
+ });
+
+ it('adds flash of a warning type', () => {
+ alert = createAlert({ message: mockMessage, variant: VARIANT_WARNING });
+
+ expect(
+ document.querySelector('.flash-container .gl-alert.gl-alert-warning'),
+ ).not.toBeNull();
+ });
+
+ it('escapes text', () => {
+ alert = createAlert({ message: '<script>alert("a");</script>' });
+
+ const html = document.querySelector('.flash-container').innerHTML;
+
+ expect(html).toContain('&lt;script&gt;alert("a");&lt;/script&gt;');
+ expect(html).not.toContain('<script>alert("a");</script>');
+ });
+
+ it('adds alert into specified container', () => {
+ setHTMLFixture(`
+ <div class="my-alert-container"></div>
+ <div class="my-other-container"></div>
+ `);
+
+ alert = createAlert({ message: mockMessage, containerSelector: '.my-alert-container' });
+
+ expect(document.querySelector('.my-alert-container .gl-alert')).not.toBeNull();
+ expect(document.querySelector('.my-alert-container').innerText.trim()).toBe(mockMessage);
+
+ expect(document.querySelector('.my-other-container .gl-alert')).toBeNull();
+ expect(document.querySelector('.my-other-container').innerText.trim()).toBe('');
+ });
+
+ it('adds alert into specified parent', () => {
+ setHTMLFixture(`
+ <div id="my-parent">
+ <div class="flash-container"></div>
+ </div>
+ <div id="my-other-parent">
+ <div class="flash-container"></div>
+ </div>
+ `);
+
+ alert = createAlert({ message: mockMessage, parent: document.getElementById('my-parent') });
+
+ expect(document.querySelector('#my-parent .flash-container .gl-alert')).not.toBeNull();
+ expect(document.querySelector('#my-parent .flash-container').innerText.trim()).toBe(
+ mockMessage,
+ );
+
+ expect(document.querySelector('#my-other-parent .flash-container .gl-alert')).toBeNull();
+ expect(document.querySelector('#my-other-parent .flash-container').innerText.trim()).toBe(
+ '',
+ );
+ });
+
+ it('removes element after clicking', () => {
+ alert = createAlert({ message: mockMessage });
+
+ expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull();
+
+ document.querySelector('.gl-dismiss-btn').click();
+
+ expect(document.querySelector('.flash-container .gl-alert')).toBeNull();
+ });
+
+ it('does not capture error using Sentry', () => {
+ alert = createAlert({
+ message: mockMessage,
+ captureError: false,
+ error: new Error('Error!'),
+ });
+
+ expect(Sentry.captureException).not.toHaveBeenCalled();
+ });
+
+ it('captures error using Sentry', () => {
+ alert = createAlert({
+ message: mockMessage,
+ captureError: true,
+ error: new Error('Error!'),
+ });
+
+ expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Error!',
+ }),
+ );
+ });
+
+ describe('with title', () => {
+ const mockTitle = 'my title';
+
+ it('shows title and message', () => {
+ createAlert({
+ title: mockTitle,
+ message: mockMessage,
+ });
+
+ expect(findTextContent()).toBe(`${mockTitle} ${mockMessage}`);
+ });
+ });
+
+ describe('with buttons', () => {
+ const findAlertAction = () => document.querySelector('.flash-container .gl-alert-action');
+
+ it('adds primary button', () => {
+ alert = createAlert({
+ message: mockMessage,
+ primaryButton: {
+ text: 'Ok',
+ },
+ });
+
+ expect(findAlertAction().textContent.trim()).toBe('Ok');
+ });
+
+ it('creates link with href', () => {
+ alert = createAlert({
+ message: mockMessage,
+ primaryButton: {
+ link: '/url',
+ text: 'Ok',
+ },
+ });
+
+ const action = findAlertAction();
+
+ expect(action.textContent.trim()).toBe('Ok');
+ expect(action.nodeName).toBe('A');
+ expect(action.getAttribute('href')).toBe('/url');
+ });
+
+ it('create button as href when no href is present', () => {
+ alert = createAlert({
+ message: mockMessage,
+ primaryButton: {
+ text: 'Ok',
+ },
+ });
+
+ const action = findAlertAction();
+
+ expect(action.nodeName).toBe('BUTTON');
+ expect(action.getAttribute('href')).toBe(null);
+ });
+
+ it('escapes the title text', () => {
+ alert = createAlert({
+ message: mockMessage,
+ primaryButton: {
+ text: '<script>alert("a")</script>',
+ },
+ });
+
+ const html = findAlertAction().innerHTML;
+
+ expect(html).toContain('&lt;script&gt;alert("a")&lt;/script&gt;');
+ expect(html).not.toContain('<script>alert("a")</script>');
+ });
+
+ it('calls actionConfig clickHandler on click', () => {
+ const clickHandler = jest.fn();
+
+ alert = createAlert({
+ message: mockMessage,
+ primaryButton: {
+ text: 'Ok',
+ clickHandler,
+ },
+ });
+
+ expect(clickHandler).toHaveBeenCalledTimes(0);
+
+ findAlertAction().click();
+
+ expect(clickHandler).toHaveBeenCalledTimes(1);
+ expect(clickHandler).toHaveBeenCalledWith(expect.any(MouseEvent));
+ });
+ });
+
+ describe('Alert API', () => {
+ describe('dismiss', () => {
+ it('dismiss programmatically with .dismiss()', () => {
+ expect(document.querySelector('.gl-alert')).toBeNull();
+
+ alert = createAlert({ message: mockMessage });
+
+ expect(document.querySelector('.gl-alert')).not.toBeNull();
+
+ alert.dismiss();
+
+ expect(document.querySelector('.gl-alert')).toBeNull();
+ });
+
+ it('does not crash if calling .dismiss() twice', () => {
+ alert = createAlert({ message: mockMessage });
+
+ alert.dismiss();
+ expect(() => alert.dismiss()).not.toThrow();
+ });
+
+ it('calls onDismiss when dismissed', () => {
+ const dismissHandler = jest.fn();
+
+ alert = createAlert({ message: mockMessage, onDismiss: dismissHandler });
+
+ expect(dismissHandler).toHaveBeenCalledTimes(0);
+
+ alert.dismiss();
+
+ expect(dismissHandler).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('when called multiple times', () => {
+ it('clears previous alerts', () => {
+ createAlert({ message: 'message 1' });
+ createAlert({ message: 'message 2' });
+
+ expect(findTextContent()).toBe('message 2');
+ });
+
+ it('preserves alerts when `preservePrevious` is true', () => {
+ createAlert({ message: 'message 1' });
+ createAlert({ message: 'message 2', preservePrevious: true });
+
+ expect(findTextContent()).toBe('message 1 message 2');
+ });
+ });
+ });
+ });
+});
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 0e402e61bcc..202a0a04192 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,36 +51,24 @@ exports[`Alert integration settings form default state should match the default
</gl-link-stub>
</label>
- <gl-dropdown-stub
+ <gl-collapsible-listbox-stub
block="true"
category="primary"
- clearalltext="Clear all"
- clearalltextclass="gl-px-5"
data-qa-selector="incident_templates_dropdown"
headertext=""
- hideheaderborder="true"
- highlighteditemstitle="Selected"
- highlighteditemstitleclass="gl-px-5"
+ icon=""
id="alert-integration-settings-issue-template"
+ items="[object Object]"
+ noresultstext="No results found"
+ placement="left"
+ popperoptions="[object Object]"
+ resetbuttonlabel=""
+ searchplaceholder="Search"
+ selected="selecte_tmpl"
size="medium"
- text="selecte_tmpl"
+ toggletext=""
variant="default"
- >
- <gl-dropdown-item-stub
- avatarurl=""
- data-qa-selector="incident_templates_item"
- iconcolor=""
- iconname=""
- iconrightarialabel=""
- iconrightname=""
- ischeckitem="true"
- secondarytext=""
- >
-
- No template selected
-
- </gl-dropdown-item-stub>
- </gl-dropdown-stub>
+ />
</gl-form-group-stub>
<gl-form-group-stub
diff --git a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js
index 1e125bdfd3a..04dc0fef5da 100644
--- a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js
+++ b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js
@@ -19,13 +19,6 @@ describe('AlertMappingBuilder', () => {
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
beforeEach(() => {
mountComponent();
});
@@ -49,7 +42,7 @@ describe('AlertMappingBuilder', () => {
const fallbackColumnIcon = findColumnInRow(0, 3).findComponent(GlIcon);
expect(fallbackColumnIcon.exists()).toBe(true);
- expect(fallbackColumnIcon.attributes('name')).toBe('question');
+ expect(fallbackColumnIcon.attributes('name')).toBe('question-o');
expect(fallbackColumnIcon.attributes('title')).toBe(i18n.fallbackTooltip);
});
diff --git a/spec/frontend/alerts_settings/components/alerts_form_spec.js b/spec/frontend/alerts_settings/components/alerts_form_spec.js
index 33098282bf8..c4e5598ed39 100644
--- a/spec/frontend/alerts_settings/components/alerts_form_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_form_spec.js
@@ -22,12 +22,6 @@ describe('Alert integration settings form', () => {
});
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('default state', () => {
it('should match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
diff --git a/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js
index 9983af873c2..76d0c12e434 100644
--- a/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js
@@ -42,13 +42,6 @@ describe('AlertIntegrationsList', () => {
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
beforeEach(() => {
mountComponent();
});
diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
index e0075aa71d9..4a0c7f65493 100644
--- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
@@ -63,12 +63,6 @@ describe('AlertsSettingsForm', () => {
const findActionBtn = () => wrapper.findByTestId('payload-action-btn');
const findTabs = () => wrapper.findAllComponents(GlTab);
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
const selectOptionAtIndex = async (index) => {
const options = findSelect().findAll('option');
await options.at(index).setSelected();
@@ -97,9 +91,9 @@ describe('AlertsSettingsForm', () => {
expect(findFormFields().at(0).isVisible()).toBe(true);
});
- it('disables the dropdown and shows help text when multi integrations are not supported', async () => {
+ it('disables the dropdown and shows help text when multi integrations are not supported', () => {
createComponent({ props: { canAddIntegration: false } });
- expect(findSelect().attributes('disabled')).toBe('disabled');
+ expect(findSelect().attributes('disabled')).toBeDefined();
expect(findMultiSupportText().exists()).toBe(true);
});
@@ -433,13 +427,13 @@ describe('AlertsSettingsForm', () => {
it('should not be able to submit when no integration type is selected', async () => {
await selectOptionAtIndex(0);
- expect(findSubmitButton().attributes('disabled')).toBe('disabled');
+ expect(findSubmitButton().attributes('disabled')).toBeDefined();
});
it('should not be able to submit when HTTP integration form is invalid', async () => {
await selectOptionAtIndex(1);
await findFormFields().at(0).vm.$emit('input', '');
- expect(findSubmitButton().attributes('disabled')).toBe('disabled');
+ expect(findSubmitButton().attributes('disabled')).toBeDefined();
});
it('should be able to submit when HTTP integration form is valid', async () => {
@@ -452,7 +446,7 @@ describe('AlertsSettingsForm', () => {
await selectOptionAtIndex(2);
await findFormFields().at(0).vm.$emit('input', '');
- expect(findSubmitButton().attributes('disabled')).toBe('disabled');
+ expect(findSubmitButton().attributes('disabled')).toBeDefined();
});
it('should be able to submit when Prometheus integration form is valid', async () => {
@@ -482,7 +476,7 @@ describe('AlertsSettingsForm', () => {
});
await nextTick();
- expect(findSubmitButton().attributes('disabled')).toBe('disabled');
+ expect(findSubmitButton().attributes('disabled')).toBeDefined();
});
it('should disable submit button after click on validation failure', async () => {
@@ -490,7 +484,7 @@ describe('AlertsSettingsForm', () => {
findSubmitButton().trigger('click');
await nextTick();
- expect(findSubmitButton().attributes('disabled')).toBe('disabled');
+ expect(findSubmitButton().attributes('disabled')).toBeDefined();
});
it('should scroll to invalid field on validation failure', async () => {
diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
index a15c78cc456..8c5df06042c 100644
--- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
@@ -30,7 +30,7 @@ import {
INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR,
DELETE_INTEGRATION_ERROR,
} from '~/alerts_settings/utils/error_messages';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_FORBIDDEN, HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import {
@@ -48,7 +48,7 @@ import {
} from './mocks/apollo_mock';
import mockIntegrations from './mocks/integrations.json';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('AlertsSettingsWrapper', () => {
let wrapper;
@@ -128,10 +128,6 @@ describe('AlertsSettingsWrapper', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
beforeEach(() => {
createComponent({
@@ -433,7 +429,7 @@ describe('AlertsSettingsWrapper', () => {
});
describe('Test alert', () => {
- it('makes `updateTestAlert` service call', async () => {
+ it('makes `updateTestAlert` service call', () => {
jest.spyOn(alertsUpdateService, 'updateTestAlert').mockResolvedValueOnce();
const testPayload = '{"title":"test"}';
findAlertsSettingsForm().vm.$emit('test-alert-payload', testPayload);
@@ -478,7 +474,7 @@ describe('AlertsSettingsWrapper', () => {
expect(destroyIntegrationHandler).toHaveBeenCalled();
});
- it('displays flash if mutation had a recoverable error', async () => {
+ it('displays alert if mutation had a recoverable error', async () => {
createComponentWithApollo({
destroyHandler: jest.fn().mockResolvedValue(destroyIntegrationResponseWithErrors),
});
@@ -489,7 +485,7 @@ describe('AlertsSettingsWrapper', () => {
expect(createAlert).toHaveBeenCalledWith({ message: 'Houston, we have a problem' });
});
- it('displays flash if mutation had a non-recoverable error', async () => {
+ it('displays alert if mutation had a non-recoverable error', async () => {
createComponentWithApollo({
destroyHandler: jest.fn().mockRejectedValue('Error'),
});
diff --git a/spec/frontend/analytics/components/activity_chart_spec.js b/spec/frontend/analytics/components/activity_chart_spec.js
deleted file mode 100644
index c26407f5c1d..00000000000
--- a/spec/frontend/analytics/components/activity_chart_spec.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { GlColumnChart } from '@gitlab/ui/dist/charts';
-import { shallowMount } from '@vue/test-utils';
-import ActivityChart from '~/analytics/product_analytics/components/activity_chart.vue';
-
-describe('Activity Chart Bundle', () => {
- let wrapper;
- function mountComponent({ provide }) {
- wrapper = shallowMount(ActivityChart, {
- provide: {
- formattedData: {},
- ...provide,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- const findChart = () => wrapper.findComponent(GlColumnChart);
- const findNoData = () => wrapper.find('[data-testid="noActivityChartData"]');
-
- describe('Activity Chart', () => {
- it('renders an warning message with no data', () => {
- mountComponent({ provide: { formattedData: {} } });
- expect(findNoData().exists()).toBe(true);
- });
-
- it('renders a chart with data', () => {
- mountComponent({
- provide: { formattedData: { keys: ['key1', 'key2'], values: [5038, 2241] } },
- });
-
- expect(findNoData().exists()).toBe(false);
- expect(findChart().exists()).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/analytics/cycle_analytics/base_spec.js b/spec/frontend/analytics/cycle_analytics/base_spec.js
deleted file mode 100644
index 58588ff49ce..00000000000
--- a/spec/frontend/analytics/cycle_analytics/base_spec.js
+++ /dev/null
@@ -1,265 +0,0 @@
-import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
-import BaseComponent from '~/analytics/cycle_analytics/components/base.vue';
-import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue';
-import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
-import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
-import { NOT_ENOUGH_DATA_ERROR } from '~/analytics/cycle_analytics/constants';
-import initState from '~/analytics/cycle_analytics/store/state';
-import {
- transformedProjectStagePathData,
- selectedStage,
- issueEvents,
- createdBefore,
- createdAfter,
- currentGroup,
- stageCounts,
- initialPaginationState as pagination,
-} from './mock_data';
-
-const selectedStageEvents = issueEvents.events;
-const noDataSvgPath = 'path/to/no/data';
-const noAccessSvgPath = 'path/to/no/access';
-const selectedStageCount = stageCounts[selectedStage.id];
-const fullPath = 'full/path/to/foo';
-
-Vue.use(Vuex);
-
-let wrapper;
-
-const { id: groupId, path: groupPath } = currentGroup;
-const defaultState = {
- currentGroup,
- createdBefore,
- createdAfter,
- stageCounts,
- endpoints: { fullPath, groupId, groupPath },
-};
-
-function createStore({ initialState = {}, initialGetters = {} }) {
- return new Vuex.Store({
- state: {
- ...initState(),
- ...defaultState,
- ...initialState,
- },
- getters: {
- pathNavigationData: () => transformedProjectStagePathData,
- filterParams: () => ({
- created_after: createdAfter,
- created_before: createdBefore,
- }),
- ...initialGetters,
- },
- });
-}
-
-function createComponent({ initialState, initialGetters } = {}) {
- return extendedWrapper(
- shallowMount(BaseComponent, {
- store: createStore({ initialState, initialGetters }),
- propsData: {
- noDataSvgPath,
- noAccessSvgPath,
- },
- stubs: {
- StageTable,
- },
- }),
- );
-}
-
-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');
-const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title');
-const findPagination = () => wrapper.findByTestId('vsa-stage-pagination');
-
-const hasMetricsRequests = (reqs) => {
- const foundReqs = findOverviewMetrics().props('requests');
- expect(foundReqs.length).toEqual(reqs.length);
- expect(foundReqs.map(({ name }) => name)).toEqual(reqs);
-};
-
-describe('Value stream analytics component', () => {
- beforeEach(() => {
- wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents, pagination } });
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- it('renders the path navigation component', () => {
- expect(findPathNavigation().exists()).toBe(true);
- });
-
- it('receives the stages formatted for the path navigation', () => {
- expect(findPathNavigation().props('stages')).toBe(transformedProjectStagePathData);
- });
-
- it('renders the overview metrics', () => {
- expect(findOverviewMetrics().exists()).toBe(true);
- });
-
- it('passes requests prop to the metrics component', () => {
- hasMetricsRequests(['recent activity']);
- });
-
- it('renders the stage table', () => {
- expect(findStageTable().exists()).toBe(true);
- });
-
- it('passes the selected stage count to the stage table', () => {
- expect(findStageTable().props('stageCount')).toBe(selectedStageCount);
- });
-
- it('renders the stage table events', () => {
- 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);
- });
-
- it('renders pagination', () => {
- expect(findPagination().exists()).toBe(true);
- });
-
- describe('with `cycleAnalyticsForGroups=true` license', () => {
- beforeEach(() => {
- wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } });
- });
-
- it('passes requests prop to the metrics component', () => {
- hasMetricsRequests(['time summary', 'recent activity']);
- });
- });
-
- describe('isLoading = true', () => {
- beforeEach(() => {
- wrapper = createComponent({
- initialState: { isLoading: true },
- });
- });
-
- it('renders the path navigation component with prop `loading` set to true', () => {
- expect(findPathNavigation().props('loading')).toBe(true);
- });
-
- it('does not render the stage table', () => {
- expect(findStageTable().exists()).toBe(false);
- });
-
- it('renders the overview metrics', () => {
- expect(findOverviewMetrics().exists()).toBe(true);
- });
-
- it('renders the loading icon', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- });
- });
-
- describe('isLoadingStage = true', () => {
- beforeEach(() => {
- wrapper = createComponent({
- initialState: { isLoadingStage: true },
- });
- });
-
- it('renders the stage table with a loading icon', () => {
- const tableWrapper = findStageTable();
- expect(tableWrapper.exists()).toBe(true);
- expect(tableWrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
- });
-
- it('renders the path navigation loading state', () => {
- expect(findPathNavigation().props('loading')).toBe(true);
- });
- });
-
- describe('isEmptyStage = true', () => {
- const emptyStageParams = {
- isEmptyStage: true,
- selectedStage: { ...selectedStage, emptyStageText: 'This stage is empty' },
- };
- beforeEach(() => {
- wrapper = createComponent({ initialState: emptyStageParams });
- });
-
- it('renders the empty stage with `Not enough data` message', () => {
- expect(findEmptyStageTitle()).toBe(NOT_ENOUGH_DATA_ERROR);
- });
-
- describe('with a selectedStageError', () => {
- beforeEach(() => {
- wrapper = createComponent({
- initialState: {
- ...emptyStageParams,
- selectedStageError: 'There is too much data to calculate',
- },
- });
- });
-
- it('renders the empty stage with `There is too much data to calculate` message', () => {
- expect(findEmptyStageTitle()).toBe('There is too much data to calculate');
- });
- });
- });
-
- describe('without a selected stage', () => {
- beforeEach(() => {
- wrapper = createComponent({
- initialGetters: { pathNavigationData: () => [] },
- initialState: { selectedStage: null, isEmptyStage: true },
- });
- });
-
- it('renders the stage table', () => {
- expect(findStageTable().exists()).toBe(true);
- });
-
- it('does not render the path navigation', () => {
- expect(findPathNavigation().exists()).toBe(false);
- });
-
- it('does not render the stage table events', () => {
- expect(findStageEvents()).toHaveLength(0);
- });
-
- it('does not render the loading icon', () => {
- expect(findLoadingIcon().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/analytics/cycle_analytics/__snapshots__/total_time_spec.js.snap b/spec/frontend/analytics/cycle_analytics/components/__snapshots__/total_time_spec.js.snap
index 92927ef16ec..92927ef16ec 100644
--- a/spec/frontend/analytics/cycle_analytics/__snapshots__/total_time_spec.js.snap
+++ b/spec/frontend/analytics/cycle_analytics/components/__snapshots__/total_time_spec.js.snap
diff --git a/spec/frontend/analytics/cycle_analytics/components/base_spec.js b/spec/frontend/analytics/cycle_analytics/components/base_spec.js
new file mode 100644
index 00000000000..87f3117c7f9
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/components/base_spec.js
@@ -0,0 +1,283 @@
+import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
+import BaseComponent from '~/analytics/cycle_analytics/components/base.vue';
+import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue';
+import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
+import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
+import { NOT_ENOUGH_DATA_ERROR } from '~/analytics/cycle_analytics/constants';
+import initState from '~/analytics/cycle_analytics/store/state';
+import {
+ transformedProjectStagePathData,
+ selectedStage,
+ issueEvents,
+ createdBefore,
+ createdAfter,
+ currentGroup,
+ stageCounts,
+ initialPaginationState as pagination,
+} from '../mock_data';
+
+const selectedStageEvents = issueEvents.events;
+const noDataSvgPath = 'path/to/no/data';
+const noAccessSvgPath = 'path/to/no/access';
+const selectedStageCount = stageCounts[selectedStage.id];
+const fullPath = 'full/path/to/foo';
+
+Vue.use(Vuex);
+
+let wrapper;
+
+const { path } = currentGroup;
+const groupPath = `groups/${path}`;
+const defaultState = {
+ currentGroup,
+ createdBefore,
+ createdAfter,
+ stageCounts,
+ groupPath,
+ namespace: { fullPath },
+};
+
+function createStore({ initialState = {}, initialGetters = {} }) {
+ return new Vuex.Store({
+ state: {
+ ...initState(),
+ ...defaultState,
+ ...initialState,
+ },
+ getters: {
+ pathNavigationData: () => transformedProjectStagePathData,
+ filterParams: () => ({
+ created_after: createdAfter,
+ created_before: createdBefore,
+ }),
+ ...initialGetters,
+ },
+ });
+}
+
+function createComponent({ initialState, initialGetters } = {}) {
+ return extendedWrapper(
+ shallowMount(BaseComponent, {
+ store: createStore({ initialState, initialGetters }),
+ propsData: {
+ noDataSvgPath,
+ noAccessSvgPath,
+ },
+ stubs: {
+ StageTable,
+ },
+ }),
+ );
+}
+
+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');
+const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title');
+const findPagination = () => wrapper.findByTestId('vsa-stage-pagination');
+
+const hasMetricsRequests = (reqs) => {
+ const foundReqs = findOverviewMetrics().props('requests');
+ expect(foundReqs.length).toEqual(reqs.length);
+ expect(foundReqs.map(({ name }) => name)).toEqual(reqs);
+};
+
+describe('Value stream analytics component', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents, pagination } });
+ });
+
+ it('renders the path navigation component', () => {
+ expect(findPathNavigation().exists()).toBe(true);
+ });
+
+ it('receives the stages formatted for the path navigation', () => {
+ expect(findPathNavigation().props('stages')).toBe(transformedProjectStagePathData);
+ });
+
+ it('renders the overview metrics', () => {
+ expect(findOverviewMetrics().exists()).toBe(true);
+ });
+
+ it('passes requests prop to the metrics component', () => {
+ hasMetricsRequests(['recent activity']);
+ });
+
+ it('renders the stage table', () => {
+ expect(findStageTable().exists()).toBe(true);
+ });
+
+ it('passes the selected stage count to the stage table', () => {
+ expect(findStageTable().props('stageCount')).toBe(selectedStageCount);
+ });
+
+ it('renders the stage table events', () => {
+ 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({
+ groupPath,
+ namespacePath: groupPath,
+ endDate: createdBefore,
+ hasDateRangeFilter: true,
+ hasProjectFilter: false,
+ selectedProjects: [],
+ startDate: createdAfter,
+ });
+ });
+
+ it('does not render the loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders pagination', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('does not render a link to the value streams dashboard', () => {
+ expect(findOverviewMetrics().props('dashboardsPath')).toBeNull();
+ });
+
+ describe('with `cycleAnalyticsForGroups=true` license', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } });
+ });
+
+ it('passes requests prop to the metrics component', () => {
+ hasMetricsRequests(['time summary', 'recent activity']);
+ });
+ });
+
+ describe('with `groupLevelAnalyticsDashboard=true` license', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: {
+ features: { groupLevelAnalyticsDashboard: true },
+ },
+ });
+ });
+
+ it('renders a link to the value streams dashboard', () => {
+ expect(findOverviewMetrics().props('dashboardsPath')).toBeDefined();
+ expect(findOverviewMetrics().props('dashboardsPath')).toBe(
+ '/groups/foo/-/analytics/dashboards/value_streams_dashboard?query=full/path/to/foo',
+ );
+ });
+ });
+
+ describe('isLoading = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: { isLoading: true },
+ });
+ });
+
+ it('renders the path navigation component with prop `loading` set to true', () => {
+ expect(findPathNavigation().props('loading')).toBe(true);
+ });
+
+ it('does not render the stage table', () => {
+ expect(findStageTable().exists()).toBe(false);
+ });
+
+ it('renders the overview metrics', () => {
+ expect(findOverviewMetrics().exists()).toBe(true);
+ });
+
+ it('renders the loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('isLoadingStage = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: { isLoadingStage: true },
+ });
+ });
+
+ it('renders the stage table with a loading icon', () => {
+ const tableWrapper = findStageTable();
+ expect(tableWrapper.exists()).toBe(true);
+ expect(tableWrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('renders the path navigation loading state', () => {
+ expect(findPathNavigation().props('loading')).toBe(true);
+ });
+ });
+
+ describe('isEmptyStage = true', () => {
+ const emptyStageParams = {
+ isEmptyStage: true,
+ selectedStage: { ...selectedStage, emptyStageText: 'This stage is empty' },
+ };
+ beforeEach(() => {
+ wrapper = createComponent({ initialState: emptyStageParams });
+ });
+
+ it('renders the empty stage with `Not enough data` message', () => {
+ expect(findEmptyStageTitle()).toBe(NOT_ENOUGH_DATA_ERROR);
+ });
+
+ describe('with a selectedStageError', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: {
+ ...emptyStageParams,
+ selectedStageError: 'There is too much data to calculate',
+ },
+ });
+ });
+
+ it('renders the empty stage with `There is too much data to calculate` message', () => {
+ expect(findEmptyStageTitle()).toBe('There is too much data to calculate');
+ });
+ });
+ });
+
+ describe('without a selected stage', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialGetters: { pathNavigationData: () => [] },
+ initialState: { selectedStage: null, isEmptyStage: true },
+ });
+ });
+
+ it('renders the stage table', () => {
+ expect(findStageTable().exists()).toBe(true);
+ });
+
+ it('does not render the path navigation', () => {
+ expect(findPathNavigation().exists()).toBe(false);
+ });
+
+ it('does not render the stage table events', () => {
+ expect(findStageEvents()).toHaveLength(0);
+ });
+
+ it('does not render the loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js b/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js
new file mode 100644
index 00000000000..f1b3af39199
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js
@@ -0,0 +1,228 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import Vuex from 'vuex';
+import {
+ filterMilestones,
+ filterLabels,
+} from 'jest/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data';
+import FilterBar from '~/analytics/cycle_analytics/components/filter_bar.vue';
+import storeConfig from '~/analytics/cycle_analytics/store';
+import * as commonUtils from '~/lib/utils/common_utils';
+import * as urlUtils from '~/lib/utils/url_utility';
+import {
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_AUTHOR,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import * as utils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import initialFiltersState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+Vue.use(Vuex);
+
+const milestoneTokenType = TOKEN_TYPE_MILESTONE;
+const labelsTokenType = TOKEN_TYPE_LABEL;
+const authorTokenType = TOKEN_TYPE_AUTHOR;
+const assigneesTokenType = TOKEN_TYPE_ASSIGNEE;
+
+const initialFilterBarState = {
+ selectedMilestone: null,
+ selectedAuthor: null,
+ selectedAssigneeList: null,
+ selectedLabelList: null,
+};
+
+const defaultParams = {
+ milestone_title: null,
+ 'not[milestone_title]': null,
+ author_username: null,
+ 'not[author_username]': null,
+ assignee_username: null,
+ 'not[assignee_username]': null,
+ label_name: null,
+ 'not[label_name]': null,
+};
+
+async function shouldMergeUrlParams(wrapper, result) {
+ await nextTick();
+ expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(result, window.location.href, {
+ spreadArrays: true,
+ });
+ expect(commonUtils.historyPushState).toHaveBeenCalled();
+}
+
+describe('Filter bar', () => {
+ let wrapper;
+ let store;
+ let mock;
+
+ let setFiltersMock;
+
+ const createStore = (initialState = {}) => {
+ setFiltersMock = jest.fn();
+
+ return new Vuex.Store({
+ modules: {
+ filters: {
+ namespaced: true,
+ state: {
+ ...initialFiltersState(),
+ ...initialState,
+ },
+ actions: {
+ setFilters: setFiltersMock,
+ },
+ },
+ },
+ });
+ };
+
+ const createComponent = (initialStore) => {
+ return shallowMount(FilterBar, {
+ store: initialStore,
+ propsData: {
+ namespacePath: 'foo',
+ },
+ stubs: {
+ UrlSync,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ const selectedMilestone = [filterMilestones[0]];
+ const selectedLabelList = [filterLabels[0]];
+
+ const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBar);
+ const getSearchToken = (type) =>
+ findFilteredSearch()
+ .props('tokens')
+ .find((token) => token.type === type);
+
+ describe('default', () => {
+ beforeEach(() => {
+ store = createStore();
+ wrapper = createComponent(store);
+ });
+
+ it('renders FilteredSearchBar component', () => {
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+ });
+
+ describe('when the state has data', () => {
+ beforeEach(() => {
+ store = createStore({
+ milestones: { data: selectedMilestone },
+ labels: { data: selectedLabelList },
+ authors: { data: [] },
+ assignees: { data: [] },
+ });
+ wrapper = createComponent(store);
+ });
+
+ it('displays the milestone and label token', () => {
+ const tokens = findFilteredSearch().props('tokens');
+
+ expect(tokens).toHaveLength(4);
+ expect(tokens[0].type).toBe(milestoneTokenType);
+ expect(tokens[1].type).toBe(labelsTokenType);
+ expect(tokens[2].type).toBe(authorTokenType);
+ expect(tokens[3].type).toBe(assigneesTokenType);
+ });
+
+ it('provides the initial milestone token', () => {
+ const { initialMilestones: milestoneToken } = getSearchToken(milestoneTokenType);
+
+ expect(milestoneToken).toHaveLength(selectedMilestone.length);
+ });
+
+ it('provides the initial label token', () => {
+ const { initialLabels: labelToken } = getSearchToken(labelsTokenType);
+
+ expect(labelToken).toHaveLength(selectedLabelList.length);
+ });
+ });
+
+ describe('when the user interacts', () => {
+ beforeEach(() => {
+ store = createStore({
+ milestones: { data: filterMilestones },
+ labels: { data: filterLabels },
+ });
+ wrapper = createComponent(store);
+ jest.spyOn(utils, 'processFilters');
+ });
+
+ it('clicks on the search button, setFilters is dispatched', () => {
+ const filters = [
+ { type: TOKEN_TYPE_MILESTONE, value: { data: selectedMilestone[0].title, operator: '=' } },
+ { type: TOKEN_TYPE_LABEL, value: { data: selectedLabelList[0].title, operator: '=' } },
+ ];
+
+ findFilteredSearch().vm.$emit('onFilter', filters);
+
+ expect(utils.processFilters).toHaveBeenCalledWith(filters);
+
+ expect(setFiltersMock).toHaveBeenCalledWith(expect.anything(), {
+ selectedLabelList: [{ value: selectedLabelList[0].title, operator: '=' }],
+ selectedMilestone: { value: selectedMilestone[0].title, operator: '=' },
+ selectedAssigneeList: [],
+ selectedAuthor: null,
+ });
+ });
+ });
+
+ describe.each([
+ ['selectedMilestone', 'milestone_title', { value: '12.0', operator: '=' }, '12.0'],
+ ['selectedAuthor', 'author_username', { value: 'rootUser', operator: '=' }, 'rootUser'],
+ [
+ 'selectedLabelList',
+ 'label_name',
+ [
+ { value: 'Afternix', operator: '=' },
+ { value: 'Brouceforge', operator: '=' },
+ ],
+ ['Afternix', 'Brouceforge'],
+ ],
+ [
+ 'selectedAssigneeList',
+ 'assignee_username',
+ [
+ { value: 'rootUser', operator: '=' },
+ { value: 'secondaryUser', operator: '=' },
+ ],
+ ['rootUser', 'secondaryUser'],
+ ],
+ ])('with a %s updates the %s url parameter', (stateKey, paramKey, payload, result) => {
+ beforeEach(() => {
+ commonUtils.historyPushState = jest.fn();
+ urlUtils.mergeUrlParams = jest.fn();
+
+ mock = new MockAdapter(axios);
+ wrapper = createComponent(storeConfig);
+
+ wrapper.vm.$store.dispatch('filters/setFilters', {
+ ...initialFilterBarState,
+ [stateKey]: payload,
+ });
+ });
+ it(`sets the ${paramKey} url parameter`, () => {
+ return shouldMergeUrlParams(wrapper, {
+ ...defaultParams,
+ [paramKey]: result,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/components/formatted_stage_count_spec.js b/spec/frontend/analytics/cycle_analytics/components/formatted_stage_count_spec.js
new file mode 100644
index 00000000000..6dd7e2e6223
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/components/formatted_stage_count_spec.js
@@ -0,0 +1,30 @@
+import { shallowMount } from '@vue/test-utils';
+import Component from '~/analytics/cycle_analytics/components/formatted_stage_count.vue';
+
+describe('Formatted Stage Count', () => {
+ let wrapper = null;
+
+ const createComponent = (stageCount = null) => {
+ wrapper = shallowMount(Component, {
+ propsData: {
+ stageCount,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it.each`
+ stageCount | expectedOutput
+ ${null} | ${'-'}
+ ${1} | ${'1 item'}
+ ${10} | ${'10 items'}
+ ${1000} | ${'1,000 items'}
+ ${1001} | ${'1,000+ items'}
+ `('returns "$expectedOutput" for stageCount=$stageCount', ({ stageCount, expectedOutput }) => {
+ createComponent(stageCount);
+ expect(wrapper.text()).toContain(expectedOutput);
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/components/path_navigation_spec.js b/spec/frontend/analytics/cycle_analytics/components/path_navigation_spec.js
new file mode 100644
index 00000000000..9084cec1c53
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/components/path_navigation_spec.js
@@ -0,0 +1,148 @@
+import { GlPath, GlSkeletonLoader } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import Component from '~/analytics/cycle_analytics/components/path_navigation.vue';
+import { transformedProjectStagePathData, selectedStage } from '../mock_data';
+
+describe('Project PathNavigation', () => {
+ let wrapper = null;
+ let trackingSpy = null;
+
+ const createComponent = (props) => {
+ return extendedWrapper(
+ mount(Component, {
+ propsData: {
+ stages: transformedProjectStagePathData,
+ selectedStage,
+ loading: false,
+ ...props,
+ },
+ }),
+ );
+ };
+
+ const findPathNavigation = () => {
+ return wrapper.findByTestId('gl-path-nav');
+ };
+
+ const findPathNavigationItems = () => {
+ return findPathNavigation().findAll('li');
+ };
+
+ const findPathNavigationTitles = () => {
+ return findPathNavigation()
+ .findAll('li button')
+ .wrappers.map((w) => w.html());
+ };
+
+ const clickItemAt = (index) => {
+ findPathNavigationItems().at(index).find('button').trigger('click');
+ };
+
+ const pathItemContent = () => findPathNavigationItems().wrappers.map(extendedWrapper);
+ const firstPopover = () => wrapper.findAllByTestId('stage-item-popover').at(0);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ describe('displays correctly', () => {
+ it('has the correct props', () => {
+ expect(wrapper.findComponent(GlPath).props('items')).toMatchObject(
+ transformedProjectStagePathData,
+ );
+ });
+
+ it('contains all the expected stages', () => {
+ const stageContent = findPathNavigationTitles();
+ transformedProjectStagePathData.forEach((stage, index) => {
+ expect(stageContent[index]).toContain(stage.title);
+ });
+ });
+
+ describe('loading', () => {
+ describe('is false', () => {
+ it('displays the gl-path component', () => {
+ expect(wrapper.findComponent(GlPath).exists()).toBe(true);
+ });
+
+ it('hides the gl-skeleton-loading component', () => {
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
+ });
+
+ it('renders each stage', () => {
+ const result = findPathNavigationTitles();
+ expect(result.length).toBe(transformedProjectStagePathData.length);
+ });
+
+ it('renders each stage with its median', () => {
+ const result = findPathNavigationTitles();
+ transformedProjectStagePathData.forEach(({ title, metric }, index) => {
+ expect(result[index]).toContain(title);
+ expect(result[index]).toContain(metric.toString());
+ });
+ });
+
+ describe('popovers', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ stages: transformedProjectStagePathData });
+ });
+
+ it('renders popovers for all stages', () => {
+ pathItemContent().forEach((stage) => {
+ expect(stage.findByTestId('stage-item-popover').exists()).toBe(true);
+ });
+ });
+
+ it('shows the median stage time for the first stage item', () => {
+ expect(firstPopover().text()).toContain('Stage time (median)');
+ });
+ });
+ });
+
+ describe('is true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ loading: true });
+ });
+
+ it('hides the gl-path component', () => {
+ expect(wrapper.findComponent(GlPath).exists()).toBe(false);
+ });
+
+ it('displays the gl-skeleton-loading component', () => {
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('event handling', () => {
+ it('emits the selected event', () => {
+ expect(wrapper.emitted('selected')).toBeUndefined();
+
+ clickItemAt(0);
+ clickItemAt(1);
+ clickItemAt(2);
+
+ expect(wrapper.emitted().selected).toEqual([
+ [transformedProjectStagePathData[0]],
+ [transformedProjectStagePathData[1]],
+ [transformedProjectStagePathData[2]],
+ ]);
+ });
+
+ it('sends tracking information', () => {
+ clickItemAt(0);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_path_navigation', {
+ extra: { stage_id: selectedStage.slug },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/components/stage_table_spec.js b/spec/frontend/analytics/cycle_analytics/components/stage_table_spec.js
new file mode 100644
index 00000000000..494be641263
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/components/stage_table_spec.js
@@ -0,0 +1,366 @@
+import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
+import { PAGINATION_SORT_FIELD_DURATION } from '~/analytics/cycle_analytics/constants';
+import { issueEvents, issueStage, reviewStage, reviewEvents } from '../mock_data';
+
+let wrapper = null;
+let trackingSpy = null;
+
+const noDataSvgPath = 'path/to/no/data';
+const emptyStateTitle = 'Too much data';
+const notEnoughDataError =
+ 'There are 0 items to show in this stage, for these filters, within this time range.';
+const issueEventItems = issueEvents.events;
+const reviewEventItems = reviewEvents.events;
+const [firstIssueEvent] = issueEventItems;
+const [firstReviewEvent] = reviewEventItems;
+const pagination = { page: 1, hasNextPage: true };
+
+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 findStageEventLink = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-link');
+const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time');
+const findStageLastEvent = () => wrapper.findByTestId('vsa-stage-last-event');
+const findIcon = (name) => wrapper.findByTestId(`${name}-icon`);
+
+function createComponent(props = {}, shallow = false) {
+ const func = shallow ? shallowMount : mount;
+ return extendedWrapper(
+ func(StageTable, {
+ propsData: {
+ isLoading: false,
+ stageEvents: issueEventItems,
+ noDataSvgPath,
+ selectedStage: issueStage,
+ pagination,
+ ...props,
+ },
+ stubs: {
+ GlLoadingIcon,
+ GlEmptyState,
+ },
+ }),
+ );
+}
+
+describe('StageTable', () => {
+ describe('is loaded with data', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('will render the correct events', () => {
+ const evs = findStageEvents();
+ expect(evs).toHaveLength(issueEventItems.length);
+
+ const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(titles[index]).toBe(ev.title);
+ });
+ });
+
+ it('will not display the default data message', () => {
+ expect(wrapper.html()).not.toContain(notEnoughDataError);
+ });
+ });
+
+ describe('with minimal stage data', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ currentStage: { title: 'New stage title' } });
+ });
+
+ it('will render the correct events', () => {
+ const evs = findStageEvents();
+ expect(evs).toHaveLength(issueEventItems.length);
+
+ const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(titles[index]).toBe(ev.title);
+ });
+ });
+
+ it('will not display the project name in the record link', () => {
+ const evs = findStageEvents();
+
+ const links = evs.wrappers.map((ev) => findStageEventLink(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(links[index]).toBe(`#${ev.iid}`);
+ });
+ });
+ });
+
+ describe('default event', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ stageEvents: [{ ...firstIssueEvent }],
+ selectedStage: { ...issueStage, custom: false },
+ });
+ });
+
+ it('will render the event title', () => {
+ expect(wrapper.findByTestId('vsa-stage-event-title').text()).toBe(firstIssueEvent.title);
+ });
+
+ it('will set the workflow title to "Issues"', () => {
+ expect(findTableHead().text()).toContain('Issues');
+ });
+
+ it('does not render the fork icon', () => {
+ expect(findIcon('fork').exists()).toBe(false);
+ });
+
+ it('does not render the branch icon', () => {
+ expect(findIcon('commit').exists()).toBe(false);
+ });
+
+ it('will render the total time', () => {
+ const createdAt = firstIssueEvent.createdAt.replace(' ago', '');
+ expect(findStageTime().text()).toBe(createdAt);
+ });
+
+ it('will render the end event', () => {
+ expect(findStageLastEvent().text()).toBe(firstIssueEvent.endEventTimestamp);
+ });
+
+ it('will render the author', () => {
+ expect(wrapper.findByTestId('vsa-stage-event-author').text()).toContain(
+ firstIssueEvent.author.name,
+ );
+ });
+
+ it('will render the created at date', () => {
+ expect(wrapper.findByTestId('vsa-stage-event-date').text()).toContain(
+ firstIssueEvent.createdAt,
+ );
+ });
+ });
+
+ describe('merge request event', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ stageEvents: [{ ...firstReviewEvent }],
+ selectedStage: { ...reviewStage, custom: false },
+ });
+ });
+
+ it('will set the workflow title to "Merge requests"', () => {
+ expect(findTableHead().text()).toContain('Merge requests');
+ expect(findTableHead().text()).not.toContain('Issues');
+ });
+ });
+
+ describe('isLoading = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ isLoading: true }, true);
+ });
+
+ it('will display the loading icon', () => {
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('will not display pagination', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('with no stageEvents', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ stageEvents: [] });
+ });
+
+ it('will render the empty state', () => {
+ expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true);
+ });
+
+ it('will display the default no data message', () => {
+ expect(wrapper.html()).toContain(notEnoughDataError);
+ });
+
+ it('will not display the pagination component', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('emptyStateTitle set', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ stageEvents: [], emptyStateTitle });
+ });
+
+ it('will display the custom message', () => {
+ expect(wrapper.html()).not.toContain(notEnoughDataError);
+ expect(wrapper.html()).toContain(emptyStateTitle);
+ });
+ });
+
+ describe('includeProjectName set', () => {
+ const fakenamespace = 'some/fake/path';
+ beforeEach(() => {
+ wrapper = createComponent({ includeProjectName: true });
+ });
+
+ it('will display the project name in the record link', () => {
+ const evs = findStageEvents();
+
+ const links = evs.wrappers.map((ev) => findStageEventLink(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`);
+ });
+ });
+
+ describe.each`
+ namespaceFullPath | hasFullPath
+ ${'fake'} | ${false}
+ ${fakenamespace} | ${true}
+ `('with a namespace', ({ namespaceFullPath, hasFullPath }) => {
+ let evs = null;
+ let links = null;
+
+ beforeEach(() => {
+ wrapper = createComponent({
+ includeProjectName: true,
+ stageEvents: issueEventItems.map((ie) => ({ ...ie, namespaceFullPath })),
+ });
+
+ evs = findStageEvents();
+ links = evs.wrappers.map((ev) => findStageEventLink(ev).text());
+ });
+
+ it(`with namespaceFullPath='${namespaceFullPath}' ${
+ hasFullPath ? 'will' : 'does not'
+ } include the namespace`, () => {
+ issueEventItems.forEach((ev, index) => {
+ if (hasFullPath) {
+ expect(links[index]).toBe(`${namespaceFullPath}/${ev.projectPath}#${ev.iid}`);
+ } else {
+ expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`);
+ }
+ });
+ });
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('will display the pagination component', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('clicking prev or next will emit an event', async () => {
+ expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
+
+ findPagination().vm.$emit('input', 2);
+ await nextTick();
+
+ expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([{ page: 2 }]);
+ });
+
+ it('clicking prev or next will send tracking information', () => {
+ findPagination().vm.$emit('input', 2);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { label: 'pagination' });
+ });
+
+ describe('with `hasNextPage=false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ pagination: { page: 1, hasNextPage: false } });
+ });
+
+ it('will not display the pagination component', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Sorting', () => {
+ const triggerTableSort = (sortDesc = true) =>
+ findTable().vm.$emit('sort-changed', {
+ sortBy: PAGINATION_SORT_FIELD_DURATION,
+ sortDesc,
+ });
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('can sort the end event or duration', () => {
+ findTableHeadColumns()
+ .wrappers.slice(1)
+ .forEach((w) => {
+ expect(w.attributes('aria-sort')).toBe('none');
+ });
+ });
+
+ it('cannot be sorted by title', () => {
+ findTableHeadColumns()
+ .wrappers.slice(0, 1)
+ .forEach((w) => {
+ expect(w.attributes('aria-sort')).toBeUndefined();
+ });
+ });
+
+ it('clicking a table column will send tracking information', () => {
+ triggerTableSort();
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'sort_duration_desc',
+ });
+ });
+
+ it('clicking a table column will update the sort field', () => {
+ expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
+ triggerTableSort();
+
+ expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
+ {
+ direction: 'desc',
+ sort: 'duration',
+ },
+ ]);
+ });
+
+ it('with sortDesc=false will toggle the direction field', () => {
+ expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
+ triggerTableSort(false);
+
+ expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
+ {
+ direction: 'asc',
+ sort: 'duration',
+ },
+ ]);
+ });
+
+ 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/analytics/cycle_analytics/components/total_time_spec.js b/spec/frontend/analytics/cycle_analytics/components/total_time_spec.js
new file mode 100644
index 00000000000..6597b6fa3d5
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/components/total_time_spec.js
@@ -0,0 +1,41 @@
+import { mount } from '@vue/test-utils';
+import TotalTime from '~/analytics/cycle_analytics/components/total_time.vue';
+
+describe('TotalTime', () => {
+ let wrapper = null;
+
+ const createComponent = (propsData) => {
+ return mount(TotalTime, {
+ propsData,
+ });
+ };
+
+ describe('with a valid time object', () => {
+ it.each`
+ time
+ ${{ seconds: 35 }}
+ ${{ mins: 47, seconds: 3 }}
+ ${{ days: 3, mins: 47, seconds: 3 }}
+ ${{ hours: 23, mins: 10 }}
+ ${{ hours: 7, mins: 20, seconds: 10 }}
+ `('with $time', ({ time }) => {
+ wrapper = createComponent({
+ time,
+ });
+
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+ });
+
+ describe('with a blank object', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ time: {},
+ });
+ });
+
+ it('should render --', () => {
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/components/value_stream_filters_spec.js b/spec/frontend/analytics/cycle_analytics/components/value_stream_filters_spec.js
new file mode 100644
index 00000000000..e3bcb0ab557
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/components/value_stream_filters_spec.js
@@ -0,0 +1,89 @@
+import { shallowMount } from '@vue/test-utils';
+import Daterange from '~/analytics/shared/components/daterange.vue';
+import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
+import FilterBar from '~/analytics/cycle_analytics/components/filter_bar.vue';
+import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
+import {
+ createdAfter as startDate,
+ createdBefore as endDate,
+ currentGroup,
+ selectedProjects,
+} from '../mock_data';
+
+const { path } = currentGroup;
+const groupPath = `groups/${path}`;
+
+function createComponent(props = {}) {
+ return shallowMount(ValueStreamFilters, {
+ propsData: {
+ selectedProjects,
+ groupPath,
+ namespacePath: currentGroup.fullPath,
+ startDate,
+ endDate,
+ ...props,
+ },
+ });
+}
+
+describe('ValueStreamFilters', () => {
+ let wrapper;
+
+ const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdownFilter);
+ const findDateRangePicker = () => wrapper.findComponent(Daterange);
+ const findFilterBar = () => wrapper.findComponent(FilterBar);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('will render the filter bar', () => {
+ expect(findFilterBar().exists()).toBe(true);
+ });
+
+ it('will render the projects dropdown', () => {
+ expect(findProjectsDropdown().exists()).toBe(true);
+ expect(wrapper.findComponent(ProjectsDropdownFilter).props()).toEqual(
+ expect.objectContaining({
+ queryParams: wrapper.vm.projectsQueryParams,
+ multiSelect: wrapper.vm.$options.multiProjectSelect,
+ }),
+ );
+ });
+
+ it('will render the date range picker', () => {
+ expect(findDateRangePicker().exists()).toBe(true);
+ });
+
+ it('will emit `selectProject` when a project is selected', () => {
+ findProjectsDropdown().vm.$emit('selected');
+
+ expect(wrapper.emitted('selectProject')).not.toBeUndefined();
+ });
+
+ it('will emit `setDateRange` when the date range changes', () => {
+ findDateRangePicker().vm.$emit('change');
+
+ expect(wrapper.emitted('setDateRange')).not.toBeUndefined();
+ });
+
+ describe('hasDateRangeFilter = false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ hasDateRangeFilter: false });
+ });
+
+ it('will not render the date range picker', () => {
+ expect(findDateRangePicker().exists()).toBe(false);
+ });
+ });
+
+ describe('hasProjectFilter = false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ hasProjectFilter: false });
+ });
+
+ it('will not render the project dropdown', () => {
+ expect(findProjectsDropdown().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/components/value_stream_metrics_spec.js b/spec/frontend/analytics/cycle_analytics/components/value_stream_metrics_spec.js
new file mode 100644
index 00000000000..e1e955cec2c
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/components/value_stream_metrics_spec.js
@@ -0,0 +1,205 @@
+import { GlSkeletonLoader } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
+import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
+import { VSA_METRICS_GROUPS, METRICS_POPOVER_CONTENT } from '~/analytics/shared/constants';
+import { prepareTimeMetricsData } from '~/analytics/shared/utils';
+import MetricTile from '~/analytics/shared/components/metric_tile.vue';
+import ValueStreamsDashboardLink from '~/analytics/shared/components/value_streams_dashboard_link.vue';
+import { createAlert } from '~/alert';
+import { group } from '../mock_data';
+
+jest.mock('~/alert');
+
+describe('ValueStreamMetrics', () => {
+ let wrapper;
+ let mockGetValueStreamSummaryMetrics;
+ let mockFilterFn;
+
+ const { full_path: requestPath } = group;
+ const fakeReqName = 'Mock metrics';
+ const metricsRequestFactory = () => ({
+ request: mockGetValueStreamSummaryMetrics,
+ endpoint: METRIC_TYPE_SUMMARY,
+ name: fakeReqName,
+ });
+
+ const createComponent = (props = {}) => {
+ return shallowMountExtended(ValueStreamMetrics, {
+ propsData: {
+ requestPath,
+ requestParams: {},
+ requests: [metricsRequestFactory()],
+ ...props,
+ },
+ });
+ };
+
+ const findVSDLink = () => wrapper.findComponent(ValueStreamsDashboardLink);
+ const findMetrics = () => wrapper.findAllComponents(MetricTile);
+ const findMetricsGroups = () => wrapper.findAllByTestId('vsa-metrics-group');
+
+ const expectToHaveRequest = (fields) => {
+ expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({
+ endpoint: METRIC_TYPE_SUMMARY,
+ requestPath,
+ ...fields,
+ });
+ };
+
+ describe('with successful requests', () => {
+ beforeEach(() => {
+ mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
+ });
+
+ it('will display a loader with pending requests', async () => {
+ wrapper = createComponent();
+ await nextTick();
+
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
+ });
+
+ describe('with data loaded', () => {
+ beforeEach(async () => {
+ wrapper = createComponent();
+ await waitForPromises();
+ });
+
+ it('fetches data from the value stream analytics endpoint', () => {
+ expectToHaveRequest({ params: {} });
+ });
+
+ describe.each`
+ index | identifier | value | label
+ ${0} | ${metricsData[0].identifier} | ${metricsData[0].value} | ${metricsData[0].title}
+ ${1} | ${metricsData[1].identifier} | ${metricsData[1].value} | ${metricsData[1].title}
+ ${2} | ${metricsData[2].identifier} | ${metricsData[2].value} | ${metricsData[2].title}
+ ${3} | ${metricsData[3].identifier} | ${metricsData[3].value} | ${metricsData[3].title}
+ `('metric tiles', ({ identifier, index, value, label }) => {
+ it(`renders a metric tile component for "${label}"`, () => {
+ const metric = findMetrics().at(index);
+ expect(metric.props('metric')).toMatchObject({ identifier, value, label });
+ expect(metric.isVisible()).toBe(true);
+ });
+ });
+
+ it('will not display a loading icon', () => {
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
+ });
+
+ describe('filterFn', () => {
+ const transferredMetricsData = prepareTimeMetricsData(metricsData, METRICS_POPOVER_CONTENT);
+
+ it('with a filter function, will call the function with the metrics data', async () => {
+ const filteredData = [
+ { identifier: 'issues', value: '3', title: 'New Issues', description: 'foo' },
+ ];
+ mockFilterFn = jest.fn(() => filteredData);
+
+ wrapper = createComponent({
+ filterFn: mockFilterFn,
+ });
+
+ await waitForPromises();
+
+ expect(mockFilterFn).toHaveBeenCalledWith(transferredMetricsData);
+ expect(wrapper.vm.metrics).toEqual(filteredData);
+ });
+
+ it('without a filter function, it will only update the metrics', async () => {
+ wrapper = createComponent();
+
+ await waitForPromises();
+
+ expect(mockFilterFn).not.toHaveBeenCalled();
+ expect(wrapper.vm.metrics).toEqual(transferredMetricsData);
+ });
+ });
+
+ describe('with additional params', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({
+ requestParams: {
+ 'project_ids[]': [1],
+ created_after: '2020-01-01',
+ created_before: '2020-02-01',
+ },
+ });
+
+ await waitForPromises();
+ });
+
+ it('fetches data for the `getValueStreamSummaryMetrics` request', () => {
+ expectToHaveRequest({
+ params: {
+ 'project_ids[]': [1],
+ created_after: '2020-01-01',
+ created_before: '2020-02-01',
+ },
+ });
+ });
+ });
+
+ describe('groupBy', () => {
+ beforeEach(async () => {
+ mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
+ wrapper = createComponent({ groupBy: VSA_METRICS_GROUPS });
+ await waitForPromises();
+ });
+
+ it('renders the metrics as separate groups', () => {
+ const groups = findMetricsGroups();
+ expect(groups).toHaveLength(VSA_METRICS_GROUPS.length);
+ });
+
+ it('renders titles for each group', () => {
+ const groups = findMetricsGroups();
+ groups.wrappers.forEach((g, index) => {
+ const { title } = VSA_METRICS_GROUPS[index];
+ expect(g.html()).toContain(title);
+ });
+ });
+ });
+ });
+ });
+
+ describe('Value Streams Dashboard Link', () => {
+ it('will render when a dashboardsPath is set', async () => {
+ wrapper = createComponent({
+ groupBy: VSA_METRICS_GROUPS,
+ dashboardsPath: 'fake-group-path',
+ });
+ await waitForPromises();
+
+ const vsdLink = findVSDLink();
+
+ expect(vsdLink.exists()).toBe(true);
+ expect(vsdLink.props()).toEqual({ requestPath: 'fake-group-path' });
+ });
+
+ it('does not render without a dashboardsPath', async () => {
+ wrapper = createComponent({ groupBy: VSA_METRICS_GROUPS });
+ await waitForPromises();
+
+ expect(findVSDLink().exists()).toBe(false);
+ });
+ });
+
+ describe('with a request failing', () => {
+ beforeEach(async () => {
+ mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue();
+ wrapper = createComponent();
+
+ await waitForPromises();
+ });
+
+ it('should render an error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `There was an error while fetching value stream analytics ${fakeReqName} data.`,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js b/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js
deleted file mode 100644
index 2b26b202882..00000000000
--- a/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js
+++ /dev/null
@@ -1,229 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
-import Vuex from 'vuex';
-import {
- filterMilestones,
- filterLabels,
-} from 'jest/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data';
-import FilterBar from '~/analytics/cycle_analytics/components/filter_bar.vue';
-import storeConfig from '~/analytics/cycle_analytics/store';
-import * as commonUtils from '~/lib/utils/common_utils';
-import * as urlUtils from '~/lib/utils/url_utility';
-import {
- TOKEN_TYPE_ASSIGNEE,
- TOKEN_TYPE_AUTHOR,
- TOKEN_TYPE_LABEL,
- TOKEN_TYPE_MILESTONE,
-} from '~/vue_shared/components/filtered_search_bar/constants';
-import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import * as utils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
-import initialFiltersState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state';
-import UrlSync from '~/vue_shared/components/url_sync.vue';
-
-Vue.use(Vuex);
-
-const milestoneTokenType = TOKEN_TYPE_MILESTONE;
-const labelsTokenType = TOKEN_TYPE_LABEL;
-const authorTokenType = TOKEN_TYPE_AUTHOR;
-const assigneesTokenType = TOKEN_TYPE_ASSIGNEE;
-
-const initialFilterBarState = {
- selectedMilestone: null,
- selectedAuthor: null,
- selectedAssigneeList: null,
- selectedLabelList: null,
-};
-
-const defaultParams = {
- milestone_title: null,
- 'not[milestone_title]': null,
- author_username: null,
- 'not[author_username]': null,
- assignee_username: null,
- 'not[assignee_username]': null,
- label_name: null,
- 'not[label_name]': null,
-};
-
-async function shouldMergeUrlParams(wrapper, result) {
- await nextTick();
- expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(result, window.location.href, {
- spreadArrays: true,
- });
- expect(commonUtils.historyPushState).toHaveBeenCalled();
-}
-
-describe('Filter bar', () => {
- let wrapper;
- let store;
- let mock;
-
- let setFiltersMock;
-
- const createStore = (initialState = {}) => {
- setFiltersMock = jest.fn();
-
- return new Vuex.Store({
- modules: {
- filters: {
- namespaced: true,
- state: {
- ...initialFiltersState(),
- ...initialState,
- },
- actions: {
- setFilters: setFiltersMock,
- },
- },
- },
- });
- };
-
- const createComponent = (initialStore) => {
- return shallowMount(FilterBar, {
- store: initialStore,
- propsData: {
- groupPath: 'foo',
- },
- stubs: {
- UrlSync,
- },
- });
- };
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- wrapper.destroy();
- mock.restore();
- });
-
- const selectedMilestone = [filterMilestones[0]];
- const selectedLabelList = [filterLabels[0]];
-
- const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBar);
- const getSearchToken = (type) =>
- findFilteredSearch()
- .props('tokens')
- .find((token) => token.type === type);
-
- describe('default', () => {
- beforeEach(() => {
- store = createStore();
- wrapper = createComponent(store);
- });
-
- it('renders FilteredSearchBar component', () => {
- expect(findFilteredSearch().exists()).toBe(true);
- });
- });
-
- describe('when the state has data', () => {
- beforeEach(() => {
- store = createStore({
- milestones: { data: selectedMilestone },
- labels: { data: selectedLabelList },
- authors: { data: [] },
- assignees: { data: [] },
- });
- wrapper = createComponent(store);
- });
-
- it('displays the milestone and label token', () => {
- const tokens = findFilteredSearch().props('tokens');
-
- expect(tokens).toHaveLength(4);
- expect(tokens[0].type).toBe(milestoneTokenType);
- expect(tokens[1].type).toBe(labelsTokenType);
- expect(tokens[2].type).toBe(authorTokenType);
- expect(tokens[3].type).toBe(assigneesTokenType);
- });
-
- it('provides the initial milestone token', () => {
- const { initialMilestones: milestoneToken } = getSearchToken(milestoneTokenType);
-
- expect(milestoneToken).toHaveLength(selectedMilestone.length);
- });
-
- it('provides the initial label token', () => {
- const { initialLabels: labelToken } = getSearchToken(labelsTokenType);
-
- expect(labelToken).toHaveLength(selectedLabelList.length);
- });
- });
-
- describe('when the user interacts', () => {
- beforeEach(() => {
- store = createStore({
- milestones: { data: filterMilestones },
- labels: { data: filterLabels },
- });
- wrapper = createComponent(store);
- jest.spyOn(utils, 'processFilters');
- });
-
- it('clicks on the search button, setFilters is dispatched', () => {
- const filters = [
- { type: TOKEN_TYPE_MILESTONE, value: { data: selectedMilestone[0].title, operator: '=' } },
- { type: TOKEN_TYPE_LABEL, value: { data: selectedLabelList[0].title, operator: '=' } },
- ];
-
- findFilteredSearch().vm.$emit('onFilter', filters);
-
- expect(utils.processFilters).toHaveBeenCalledWith(filters);
-
- expect(setFiltersMock).toHaveBeenCalledWith(expect.anything(), {
- selectedLabelList: [{ value: selectedLabelList[0].title, operator: '=' }],
- selectedMilestone: { value: selectedMilestone[0].title, operator: '=' },
- selectedAssigneeList: [],
- selectedAuthor: null,
- });
- });
- });
-
- describe.each([
- ['selectedMilestone', 'milestone_title', { value: '12.0', operator: '=' }, '12.0'],
- ['selectedAuthor', 'author_username', { value: 'rootUser', operator: '=' }, 'rootUser'],
- [
- 'selectedLabelList',
- 'label_name',
- [
- { value: 'Afternix', operator: '=' },
- { value: 'Brouceforge', operator: '=' },
- ],
- ['Afternix', 'Brouceforge'],
- ],
- [
- 'selectedAssigneeList',
- 'assignee_username',
- [
- { value: 'rootUser', operator: '=' },
- { value: 'secondaryUser', operator: '=' },
- ],
- ['rootUser', 'secondaryUser'],
- ],
- ])('with a %s updates the %s url parameter', (stateKey, paramKey, payload, result) => {
- beforeEach(() => {
- commonUtils.historyPushState = jest.fn();
- urlUtils.mergeUrlParams = jest.fn();
-
- mock = new MockAdapter(axios);
- wrapper = createComponent(storeConfig);
-
- wrapper.vm.$store.dispatch('filters/setFilters', {
- ...initialFilterBarState,
- [stateKey]: payload,
- });
- });
- it(`sets the ${paramKey} url parameter`, () => {
- return shouldMergeUrlParams(wrapper, {
- ...defaultParams,
- [paramKey]: result,
- });
- });
- });
-});
diff --git a/spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js b/spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js
deleted file mode 100644
index 9be92bb92bc..00000000000
--- a/spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Component from '~/analytics/cycle_analytics/components/formatted_stage_count.vue';
-
-describe('Formatted Stage Count', () => {
- let wrapper = null;
-
- const createComponent = (stageCount = null) => {
- wrapper = shallowMount(Component, {
- propsData: {
- stageCount,
- },
- });
- };
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it.each`
- stageCount | expectedOutput
- ${null} | ${'-'}
- ${1} | ${'1 item'}
- ${10} | ${'10 items'}
- ${1000} | ${'1,000 items'}
- ${1001} | ${'1,000+ items'}
- `('returns "$expectedOutput" for stageCount=$stageCount', ({ stageCount, expectedOutput }) => {
- createComponent(stageCount);
- expect(wrapper.text()).toContain(expectedOutput);
- });
-});
diff --git a/spec/frontend/analytics/cycle_analytics/mock_data.js b/spec/frontend/analytics/cycle_analytics/mock_data.js
index f820f755400..f9587bf1967 100644
--- a/spec/frontend/analytics/cycle_analytics/mock_data.js
+++ b/spec/frontend/analytics/cycle_analytics/mock_data.js
@@ -214,11 +214,13 @@ export const group = {
id: 1,
name: 'foo',
path: 'foo',
- full_path: 'foo',
+ full_path: 'groups/foo',
avatar_url: `${TEST_HOST}/images/home/nasa.svg`,
};
export const currentGroup = convertObjectPropsToCamelCase(group, { deep: true });
+export const groupNamespace = { id: currentGroup.id, fullPath: `groups/${currentGroup.path}` };
+export const projectNamespace = { fullPath: 'some/cool/path' };
export const selectedProjects = [
{
diff --git a/spec/frontend/analytics/cycle_analytics/path_navigation_spec.js b/spec/frontend/analytics/cycle_analytics/path_navigation_spec.js
deleted file mode 100644
index 107e62035c3..00000000000
--- a/spec/frontend/analytics/cycle_analytics/path_navigation_spec.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import { GlPath, GlSkeletonLoader } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import Component from '~/analytics/cycle_analytics/components/path_navigation.vue';
-import { transformedProjectStagePathData, selectedStage } from './mock_data';
-
-describe('Project PathNavigation', () => {
- let wrapper = null;
- let trackingSpy = null;
-
- const createComponent = (props) => {
- return extendedWrapper(
- mount(Component, {
- propsData: {
- stages: transformedProjectStagePathData,
- selectedStage,
- loading: false,
- ...props,
- },
- }),
- );
- };
-
- const findPathNavigation = () => {
- return wrapper.findByTestId('gl-path-nav');
- };
-
- const findPathNavigationItems = () => {
- return findPathNavigation().findAll('li');
- };
-
- const findPathNavigationTitles = () => {
- return findPathNavigation()
- .findAll('li button')
- .wrappers.map((w) => w.html());
- };
-
- const clickItemAt = (index) => {
- findPathNavigationItems().at(index).find('button').trigger('click');
- };
-
- const pathItemContent = () => findPathNavigationItems().wrappers.map(extendedWrapper);
- const firstPopover = () => wrapper.findAllByTestId('stage-item-popover').at(0);
-
- beforeEach(() => {
- wrapper = createComponent();
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- });
-
- afterEach(() => {
- unmockTracking();
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('displays correctly', () => {
- it('has the correct props', () => {
- expect(wrapper.findComponent(GlPath).props('items')).toMatchObject(
- transformedProjectStagePathData,
- );
- });
-
- it('contains all the expected stages', () => {
- const stageContent = findPathNavigationTitles();
- transformedProjectStagePathData.forEach((stage, index) => {
- expect(stageContent[index]).toContain(stage.title);
- });
- });
-
- describe('loading', () => {
- describe('is false', () => {
- it('displays the gl-path component', () => {
- expect(wrapper.findComponent(GlPath).exists()).toBe(true);
- });
-
- it('hides the gl-skeleton-loading component', () => {
- expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
- });
-
- it('renders each stage', () => {
- const result = findPathNavigationTitles();
- expect(result.length).toBe(transformedProjectStagePathData.length);
- });
-
- it('renders each stage with its median', () => {
- const result = findPathNavigationTitles();
- transformedProjectStagePathData.forEach(({ title, metric }, index) => {
- expect(result[index]).toContain(title);
- expect(result[index]).toContain(metric.toString());
- });
- });
-
- describe('popovers', () => {
- beforeEach(() => {
- wrapper = createComponent({ stages: transformedProjectStagePathData });
- });
-
- it('renders popovers for all stages', () => {
- pathItemContent().forEach((stage) => {
- expect(stage.findByTestId('stage-item-popover').exists()).toBe(true);
- });
- });
-
- it('shows the median stage time for the first stage item', () => {
- expect(firstPopover().text()).toContain('Stage time (median)');
- });
- });
- });
-
- describe('is true', () => {
- beforeEach(() => {
- wrapper = createComponent({ loading: true });
- });
-
- it('hides the gl-path component', () => {
- expect(wrapper.findComponent(GlPath).exists()).toBe(false);
- });
-
- it('displays the gl-skeleton-loading component', () => {
- expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
- });
- });
- });
- });
-
- describe('event handling', () => {
- it('emits the selected event', () => {
- expect(wrapper.emitted('selected')).toBeUndefined();
-
- clickItemAt(0);
- clickItemAt(1);
- clickItemAt(2);
-
- expect(wrapper.emitted().selected).toEqual([
- [transformedProjectStagePathData[0]],
- [transformedProjectStagePathData[1]],
- [transformedProjectStagePathData[2]],
- ]);
- });
-
- it('sends tracking information', () => {
- clickItemAt(0);
-
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_path_navigation', {
- extra: { stage_id: selectedStage.slug },
- });
- });
- });
-});
diff --git a/spec/frontend/analytics/cycle_analytics/stage_table_spec.js b/spec/frontend/analytics/cycle_analytics/stage_table_spec.js
deleted file mode 100644
index cfccce7eae9..00000000000
--- a/spec/frontend/analytics/cycle_analytics/stage_table_spec.js
+++ /dev/null
@@ -1,371 +0,0 @@
-import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
-import { shallowMount, mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
-import { PAGINATION_SORT_FIELD_DURATION } from '~/analytics/cycle_analytics/constants';
-import { issueEvents, issueStage, reviewStage, reviewEvents } from './mock_data';
-
-let wrapper = null;
-let trackingSpy = null;
-
-const noDataSvgPath = 'path/to/no/data';
-const emptyStateTitle = 'Too much data';
-const notEnoughDataError = "We don't have enough data to show this stage.";
-const issueEventItems = issueEvents.events;
-const reviewEventItems = reviewEvents.events;
-const [firstIssueEvent] = issueEventItems;
-const [firstReviewEvent] = reviewEventItems;
-const pagination = { page: 1, hasNextPage: true };
-
-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 findStageEventLink = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-link');
-const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time');
-const findStageLastEvent = () => wrapper.findByTestId('vsa-stage-last-event');
-const findIcon = (name) => wrapper.findByTestId(`${name}-icon`);
-
-function createComponent(props = {}, shallow = false) {
- const func = shallow ? shallowMount : mount;
- return extendedWrapper(
- func(StageTable, {
- propsData: {
- isLoading: false,
- stageEvents: issueEventItems,
- noDataSvgPath,
- selectedStage: issueStage,
- pagination,
- ...props,
- },
- stubs: {
- GlLoadingIcon,
- GlEmptyState,
- },
- }),
- );
-}
-
-describe('StageTable', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('is loaded with data', () => {
- beforeEach(() => {
- wrapper = createComponent();
- });
-
- it('will render the correct events', () => {
- const evs = findStageEvents();
- expect(evs).toHaveLength(issueEventItems.length);
-
- const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text());
- issueEventItems.forEach((ev, index) => {
- expect(titles[index]).toBe(ev.title);
- });
- });
-
- it('will not display the default data message', () => {
- expect(wrapper.html()).not.toContain(notEnoughDataError);
- });
- });
-
- describe('with minimal stage data', () => {
- beforeEach(() => {
- wrapper = createComponent({ currentStage: { title: 'New stage title' } });
- });
-
- it('will render the correct events', () => {
- const evs = findStageEvents();
- expect(evs).toHaveLength(issueEventItems.length);
-
- const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text());
- issueEventItems.forEach((ev, index) => {
- expect(titles[index]).toBe(ev.title);
- });
- });
-
- it('will not display the project name in the record link', () => {
- const evs = findStageEvents();
-
- const links = evs.wrappers.map((ev) => findStageEventLink(ev).text());
- issueEventItems.forEach((ev, index) => {
- expect(links[index]).toBe(`#${ev.iid}`);
- });
- });
- });
-
- describe('default event', () => {
- beforeEach(() => {
- wrapper = createComponent({
- stageEvents: [{ ...firstIssueEvent }],
- selectedStage: { ...issueStage, custom: false },
- });
- });
-
- it('will render the event title', () => {
- expect(wrapper.findByTestId('vsa-stage-event-title').text()).toBe(firstIssueEvent.title);
- });
-
- it('will set the workflow title to "Issues"', () => {
- expect(findTableHead().text()).toContain('Issues');
- });
-
- it('does not render the fork icon', () => {
- expect(findIcon('fork').exists()).toBe(false);
- });
-
- it('does not render the branch icon', () => {
- expect(findIcon('commit').exists()).toBe(false);
- });
-
- it('will render the total time', () => {
- const createdAt = firstIssueEvent.createdAt.replace(' ago', '');
- expect(findStageTime().text()).toBe(createdAt);
- });
-
- it('will render the end event', () => {
- expect(findStageLastEvent().text()).toBe(firstIssueEvent.endEventTimestamp);
- });
-
- it('will render the author', () => {
- expect(wrapper.findByTestId('vsa-stage-event-author').text()).toContain(
- firstIssueEvent.author.name,
- );
- });
-
- it('will render the created at date', () => {
- expect(wrapper.findByTestId('vsa-stage-event-date').text()).toContain(
- firstIssueEvent.createdAt,
- );
- });
- });
-
- describe('merge request event', () => {
- beforeEach(() => {
- wrapper = createComponent({
- stageEvents: [{ ...firstReviewEvent }],
- selectedStage: { ...reviewStage, custom: false },
- });
- });
-
- it('will set the workflow title to "Merge requests"', () => {
- expect(findTableHead().text()).toContain('Merge requests');
- expect(findTableHead().text()).not.toContain('Issues');
- });
- });
-
- describe('isLoading = true', () => {
- beforeEach(() => {
- wrapper = createComponent({ isLoading: true }, true);
- });
-
- it('will display the loading icon', () => {
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
- });
-
- it('will not display pagination', () => {
- expect(findPagination().exists()).toBe(false);
- });
- });
-
- describe('with no stageEvents', () => {
- beforeEach(() => {
- wrapper = createComponent({ stageEvents: [] });
- });
-
- it('will render the empty state', () => {
- expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true);
- });
-
- it('will display the default no data message', () => {
- expect(wrapper.html()).toContain(notEnoughDataError);
- });
-
- it('will not display the pagination component', () => {
- expect(findPagination().exists()).toBe(false);
- });
- });
-
- describe('emptyStateTitle set', () => {
- beforeEach(() => {
- wrapper = createComponent({ stageEvents: [], emptyStateTitle });
- });
-
- it('will display the custom message', () => {
- expect(wrapper.html()).not.toContain(notEnoughDataError);
- expect(wrapper.html()).toContain(emptyStateTitle);
- });
- });
-
- describe('includeProjectName set', () => {
- const fakenamespace = 'some/fake/path';
- beforeEach(() => {
- wrapper = createComponent({ includeProjectName: true });
- });
-
- it('will display the project name in the record link', () => {
- const evs = findStageEvents();
-
- const links = evs.wrappers.map((ev) => findStageEventLink(ev).text());
- issueEventItems.forEach((ev, index) => {
- expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`);
- });
- });
-
- describe.each`
- namespaceFullPath | hasFullPath
- ${'fake'} | ${false}
- ${fakenamespace} | ${true}
- `('with a namespace', ({ namespaceFullPath, hasFullPath }) => {
- let evs = null;
- let links = null;
-
- beforeEach(() => {
- wrapper = createComponent({
- includeProjectName: true,
- stageEvents: issueEventItems.map((ie) => ({ ...ie, namespaceFullPath })),
- });
-
- evs = findStageEvents();
- links = evs.wrappers.map((ev) => findStageEventLink(ev).text());
- });
-
- it(`with namespaceFullPath='${namespaceFullPath}' ${
- hasFullPath ? 'will' : 'does not'
- } include the namespace`, () => {
- issueEventItems.forEach((ev, index) => {
- if (hasFullPath) {
- expect(links[index]).toBe(`${namespaceFullPath}/${ev.projectPath}#${ev.iid}`);
- } else {
- expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`);
- }
- });
- });
- });
- });
-
- describe('Pagination', () => {
- beforeEach(() => {
- wrapper = createComponent();
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- });
-
- afterEach(() => {
- unmockTracking();
- wrapper.destroy();
- });
-
- it('will display the pagination component', () => {
- expect(findPagination().exists()).toBe(true);
- });
-
- it('clicking prev or next will emit an event', async () => {
- expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
-
- findPagination().vm.$emit('input', 2);
- await nextTick();
-
- expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([{ page: 2 }]);
- });
-
- it('clicking prev or next will send tracking information', () => {
- findPagination().vm.$emit('input', 2);
-
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { label: 'pagination' });
- });
-
- describe('with `hasNextPage=false', () => {
- beforeEach(() => {
- wrapper = createComponent({ pagination: { page: 1, hasNextPage: false } });
- });
-
- it('will not display the pagination component', () => {
- expect(findPagination().exists()).toBe(false);
- });
- });
- });
-
- describe('Sorting', () => {
- const triggerTableSort = (sortDesc = true) =>
- findTable().vm.$emit('sort-changed', {
- sortBy: PAGINATION_SORT_FIELD_DURATION,
- sortDesc,
- });
-
- beforeEach(() => {
- wrapper = createComponent();
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- });
-
- afterEach(() => {
- unmockTracking();
- wrapper.destroy();
- });
-
- it('can sort the end event or duration', () => {
- findTableHeadColumns()
- .wrappers.slice(1)
- .forEach((w) => {
- expect(w.attributes('aria-sort')).toBe('none');
- });
- });
-
- it('cannot be sorted by title', () => {
- findTableHeadColumns()
- .wrappers.slice(0, 1)
- .forEach((w) => {
- expect(w.attributes('aria-sort')).toBeUndefined();
- });
- });
-
- it('clicking a table column will send tracking information', () => {
- triggerTableSort();
-
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
- label: 'sort_duration_desc',
- });
- });
-
- it('clicking a table column will update the sort field', () => {
- expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
- triggerTableSort();
-
- expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
- {
- direction: 'desc',
- sort: 'duration',
- },
- ]);
- });
-
- it('with sortDesc=false will toggle the direction field', () => {
- expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
- triggerTableSort(false);
-
- expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
- {
- direction: 'asc',
- sort: 'duration',
- },
- ]);
- });
-
- 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/analytics/cycle_analytics/store/actions_spec.js b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js
index 3030fca126b..b2ce8596c22 100644
--- a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js
+++ b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js
@@ -13,21 +13,13 @@ import {
createdBefore,
initialPaginationState,
reviewEvents,
+ projectNamespace as namespace,
} 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 { path: groupPath } = currentGroup;
+const mockMilestonesPath = `/${namespace.fullPath}/-/milestones.json`;
+const mockLabelsPath = `/${namespace.fullPath}/-/labels.json`;
const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams';
-const mockEndpoints = {
- fullPath: mockFullPath,
- requestPath: mockRequestPath,
- labelsPath: mockLabelsPath,
- milestonesPath: mockMilestonesPath,
- groupId,
- groupPath,
-};
const mockSetDateActionCommit = {
payload: { createdAfter, createdBefore },
type: 'SET_DATE_RANGE',
@@ -35,6 +27,7 @@ const mockSetDateActionCommit = {
const defaultState = {
...getters,
+ namespace,
selectedValueStream,
createdAfter,
createdBefore,
@@ -81,7 +74,8 @@ describe('Project Value Stream Analytics actions', () => {
const selectedAssigneeList = ['Assignee 1', 'Assignee 2'];
const selectedLabelList = ['Label 1', 'Label 2'];
const payload = {
- endpoints: mockEndpoints,
+ namespace,
+ groupPath,
selectedAuthor,
selectedMilestone,
selectedAssigneeList,
@@ -92,7 +86,7 @@ describe('Project Value Stream Analytics actions', () => {
groupEndpoint: 'foo',
labelsEndpoint: mockLabelsPath,
milestonesEndpoint: mockMilestonesPath,
- projectEndpoint: '/namespace/-/analytics/value_stream_analytics/value_streams',
+ projectEndpoint: namespace.fullPath,
};
it('will dispatch fetchValueStreams actions and commit SET_LOADING and INITIALIZE_VSA', () => {
@@ -193,7 +187,6 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
...defaultState,
- endpoints: mockEndpoints,
selectedStage,
};
mock = new MockAdapter(axios);
@@ -219,7 +212,6 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
...defaultState,
- endpoints: mockEndpoints,
selectedStage,
};
mock = new MockAdapter(axios);
@@ -243,7 +235,6 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
...defaultState,
- endpoints: mockEndpoints,
selectedStage,
};
mock = new MockAdapter(axios);
@@ -265,9 +256,7 @@ describe('Project Value Stream Analytics actions', () => {
const mockValueStreamPath = /\/analytics\/value_stream_analytics\/value_streams/;
beforeEach(() => {
- state = {
- endpoints: mockEndpoints,
- };
+ state = { namespace };
mock = new MockAdapter(axios);
mock.onGet(mockValueStreamPath).reply(HTTP_STATUS_OK);
});
@@ -333,7 +322,7 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
- endpoints: mockEndpoints,
+ namespace,
selectedValueStream,
};
mock = new MockAdapter(axios);
diff --git a/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js b/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js
index 567fac81e1f..70b7454f4a0 100644
--- a/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js
+++ b/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js
@@ -17,12 +17,14 @@ import {
rawStageCounts,
stageCounts,
initialPaginationState as pagination,
+ projectNamespace as mockNamespace,
} from '../mock_data';
let state;
const rawEvents = rawIssueEvents.events;
const convertedEvents = issueEvents.events;
-const mockRequestPath = 'fake/request/path';
+const mockGroupPath = 'groups/path';
+const mockFeatures = { some: 'feature' };
const mockCreatedAfter = '2020-06-18';
const mockCreatedBefore = '2020-07-18';
@@ -64,19 +66,22 @@ describe('Project Value Stream Analytics mutations', () => {
const mockSetDatePayload = { createdAfter: mockCreatedAfter, createdBefore: mockCreatedBefore };
const mockInitialPayload = {
- endpoints: { requestPath: mockRequestPath },
currentGroup: { title: 'cool-group' },
id: 1337,
+ groupPath: mockGroupPath,
+ namespace: mockNamespace,
+ features: mockFeatures,
...mockSetDatePayload,
};
const mockInitializedObj = {
- endpoints: { requestPath: mockRequestPath },
...mockSetDatePayload,
};
it.each`
mutation | stateKey | value
- ${types.INITIALIZE_VSA} | ${'endpoints'} | ${{ requestPath: mockRequestPath }}
+ ${types.INITIALIZE_VSA} | ${'features'} | ${mockFeatures}
+ ${types.INITIALIZE_VSA} | ${'namespace'} | ${mockNamespace}
+ ${types.INITIALIZE_VSA} | ${'groupPath'} | ${mockGroupPath}
${types.INITIALIZE_VSA} | ${'createdAfter'} | ${mockCreatedAfter}
${types.INITIALIZE_VSA} | ${'createdBefore'} | ${mockCreatedBefore}
`('$mutation will set $stateKey', ({ mutation, stateKey, value }) => {
diff --git a/spec/frontend/analytics/cycle_analytics/total_time_spec.js b/spec/frontend/analytics/cycle_analytics/total_time_spec.js
deleted file mode 100644
index 47ee7aad8c4..00000000000
--- a/spec/frontend/analytics/cycle_analytics/total_time_spec.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { mount } from '@vue/test-utils';
-import TotalTime from '~/analytics/cycle_analytics/components/total_time.vue';
-
-describe('TotalTime', () => {
- let wrapper = null;
-
- const createComponent = (propsData) => {
- return mount(TotalTime, {
- propsData,
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('with a valid time object', () => {
- it.each`
- time
- ${{ seconds: 35 }}
- ${{ mins: 47, seconds: 3 }}
- ${{ days: 3, mins: 47, seconds: 3 }}
- ${{ hours: 23, mins: 10 }}
- ${{ hours: 7, mins: 20, seconds: 10 }}
- `('with $time', ({ time }) => {
- wrapper = createComponent({
- time,
- });
-
- expect(wrapper.html()).toMatchSnapshot();
- });
- });
-
- describe('with a blank object', () => {
- beforeEach(() => {
- wrapper = createComponent({
- time: {},
- });
- });
-
- it('should render --', () => {
- expect(wrapper.html()).toMatchSnapshot();
- });
- });
-});
diff --git a/spec/frontend/analytics/cycle_analytics/utils_spec.js b/spec/frontend/analytics/cycle_analytics/utils_spec.js
index fe412bf7498..ab5d78bde51 100644
--- a/spec/frontend/analytics/cycle_analytics/utils_spec.js
+++ b/spec/frontend/analytics/cycle_analytics/utils_spec.js
@@ -91,9 +91,9 @@ describe('Value stream analytics utils', () => {
const projectId = '5';
const createdAfter = '2021-09-01';
const createdBefore = '2021-11-06';
- const groupId = '146';
- const groupPath = 'fake-group';
- const fullPath = 'fake-group/fake-project';
+ const groupPath = 'groups/fake-group';
+ const namespaceName = 'Fake project';
+ const namespaceFullPath = 'fake-group/fake-project';
const labelsPath = '/fake-group/fake-project/-/labels.json';
const milestonesPath = '/fake-group/fake-project/-/milestones.json';
const requestPath = '/fake-group/fake-project/-/value_stream_analytics';
@@ -102,11 +102,11 @@ describe('Value stream analytics utils', () => {
projectId,
createdBefore,
createdAfter,
- fullPath,
+ namespaceName,
+ namespaceFullPath,
requestPath,
labelsPath,
milestonesPath,
- groupId,
groupPath,
};
@@ -124,14 +124,13 @@ describe('Value stream analytics utils', () => {
expect(res.createdAfter).toEqual(new Date(createdAfter));
});
+ it('sets the namespace', () => {
+ expect(res.namespace.name).toBe(namespaceName);
+ expect(res.namespace.fullPath).toBe(namespaceFullPath);
+ });
+
it('sets the endpoints', () => {
- const { endpoints } = res;
- expect(endpoints.fullPath).toBe(fullPath);
- expect(endpoints.requestPath).toBe(requestPath);
- expect(endpoints.labelsPath).toBe(labelsPath);
- expect(endpoints.milestonesPath).toBe(milestonesPath);
- expect(endpoints.groupId).toBe(parseInt(groupId, 10));
- expect(endpoints.groupPath).toBe(groupPath);
+ expect(res.groupPath).toBe(groupPath);
});
it('returns null when there is no stage', () => {
@@ -159,12 +158,15 @@ describe('Value stream analytics utils', () => {
describe('with features set', () => {
const fakeFeatures = { cycleAnalyticsForGroups: true };
+ beforeEach(() => {
+ window.gon = { licensed_features: fakeFeatures };
+ });
+
it('sets the feature flags', () => {
res = buildCycleAnalyticsInitialData({
...rawData,
- gon: { licensed_features: fakeFeatures },
});
- expect(res.features).toEqual(fakeFeatures);
+ expect(res.features).toMatchObject(fakeFeatures);
});
});
});
diff --git a/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js b/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js
deleted file mode 100644
index 4f333e95d89..00000000000
--- a/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Daterange from '~/analytics/shared/components/daterange.vue';
-import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
-import FilterBar from '~/analytics/cycle_analytics/components/filter_bar.vue';
-import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
-import {
- createdAfter as startDate,
- createdBefore as endDate,
- currentGroup,
- selectedProjects,
-} from './mock_data';
-
-function createComponent(props = {}) {
- return shallowMount(ValueStreamFilters, {
- propsData: {
- selectedProjects,
- groupId: currentGroup.id,
- groupPath: currentGroup.fullPath,
- startDate,
- endDate,
- ...props,
- },
- });
-}
-
-describe('ValueStreamFilters', () => {
- let wrapper;
-
- const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdownFilter);
- const findDateRangePicker = () => wrapper.findComponent(Daterange);
- const findFilterBar = () => wrapper.findComponent(FilterBar);
-
- beforeEach(() => {
- wrapper = createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- it('will render the filter bar', () => {
- expect(findFilterBar().exists()).toBe(true);
- });
-
- it('will render the projects dropdown', () => {
- expect(findProjectsDropdown().exists()).toBe(true);
- expect(wrapper.findComponent(ProjectsDropdownFilter).props()).toEqual(
- expect.objectContaining({
- queryParams: wrapper.vm.projectsQueryParams,
- multiSelect: wrapper.vm.$options.multiProjectSelect,
- }),
- );
- });
-
- it('will render the date range picker', () => {
- expect(findDateRangePicker().exists()).toBe(true);
- });
-
- it('will emit `selectProject` when a project is selected', () => {
- findProjectsDropdown().vm.$emit('selected');
-
- expect(wrapper.emitted('selectProject')).not.toBeUndefined();
- });
-
- it('will emit `setDateRange` when the date range changes', () => {
- findDateRangePicker().vm.$emit('change');
-
- expect(wrapper.emitted('setDateRange')).not.toBeUndefined();
- });
-
- describe('hasDateRangeFilter = false', () => {
- beforeEach(() => {
- wrapper = createComponent({ hasDateRangeFilter: false });
- });
-
- it('will not render the date range picker', () => {
- expect(findDateRangePicker().exists()).toBe(false);
- });
- });
-
- describe('hasProjectFilter = false', () => {
- beforeEach(() => {
- wrapper = createComponent({ hasProjectFilter: false });
- });
-
- it('will not render the project dropdown', () => {
- expect(findProjectsDropdown().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js
deleted file mode 100644
index 948dc5c9be2..00000000000
--- a/spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js
+++ /dev/null
@@ -1,185 +0,0 @@
-import { GlSkeletonLoader } from '@gitlab/ui';
-import { nextTick } from 'vue';
-import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
-import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
-import { VSA_METRICS_GROUPS, METRICS_POPOVER_CONTENT } from '~/analytics/shared/constants';
-import { prepareTimeMetricsData } from '~/analytics/shared/utils';
-import MetricTile from '~/analytics/shared/components/metric_tile.vue';
-import { createAlert } from '~/flash';
-import { group } from './mock_data';
-
-jest.mock('~/flash');
-
-describe('ValueStreamMetrics', () => {
- let wrapper;
- let mockGetValueStreamSummaryMetrics;
- let mockFilterFn;
-
- const { full_path: requestPath } = group;
- const fakeReqName = 'Mock metrics';
- const metricsRequestFactory = () => ({
- request: mockGetValueStreamSummaryMetrics,
- endpoint: METRIC_TYPE_SUMMARY,
- name: fakeReqName,
- });
-
- const createComponent = (props = {}) => {
- return shallowMountExtended(ValueStreamMetrics, {
- propsData: {
- requestPath,
- requestParams: {},
- requests: [metricsRequestFactory()],
- ...props,
- },
- });
- };
-
- const findMetrics = () => wrapper.findAllComponents(MetricTile);
- const findMetricsGroups = () => wrapper.findAllByTestId('vsa-metrics-group');
-
- const expectToHaveRequest = (fields) => {
- expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({
- endpoint: METRIC_TYPE_SUMMARY,
- requestPath,
- ...fields,
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('with successful requests', () => {
- beforeEach(() => {
- mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
- });
-
- it('will display a loader with pending requests', async () => {
- wrapper = createComponent();
- await nextTick();
-
- expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
- });
-
- describe('with data loaded', () => {
- beforeEach(async () => {
- wrapper = createComponent();
- await waitForPromises();
- });
-
- it('fetches data from the value stream analytics endpoint', () => {
- expectToHaveRequest({ params: {} });
- });
-
- describe.each`
- index | identifier | value | label
- ${0} | ${metricsData[0].identifier} | ${metricsData[0].value} | ${metricsData[0].title}
- ${1} | ${metricsData[1].identifier} | ${metricsData[1].value} | ${metricsData[1].title}
- ${2} | ${metricsData[2].identifier} | ${metricsData[2].value} | ${metricsData[2].title}
- ${3} | ${metricsData[3].identifier} | ${metricsData[3].value} | ${metricsData[3].title}
- `('metric tiles', ({ identifier, index, value, label }) => {
- it(`renders a metric tile component for "${label}"`, () => {
- const metric = findMetrics().at(index);
- expect(metric.props('metric')).toMatchObject({ identifier, value, label });
- expect(metric.isVisible()).toBe(true);
- });
- });
-
- it('will not display a loading icon', () => {
- expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
- });
-
- describe('filterFn', () => {
- const transferredMetricsData = prepareTimeMetricsData(metricsData, METRICS_POPOVER_CONTENT);
-
- it('with a filter function, will call the function with the metrics data', async () => {
- const filteredData = [
- { identifier: 'issues', value: '3', title: 'New Issues', description: 'foo' },
- ];
- mockFilterFn = jest.fn(() => filteredData);
-
- wrapper = createComponent({
- filterFn: mockFilterFn,
- });
-
- await waitForPromises();
-
- expect(mockFilterFn).toHaveBeenCalledWith(transferredMetricsData);
- expect(wrapper.vm.metrics).toEqual(filteredData);
- });
-
- it('without a filter function, it will only update the metrics', async () => {
- wrapper = createComponent();
-
- await waitForPromises();
-
- expect(mockFilterFn).not.toHaveBeenCalled();
- expect(wrapper.vm.metrics).toEqual(transferredMetricsData);
- });
- });
-
- describe('with additional params', () => {
- beforeEach(async () => {
- wrapper = createComponent({
- requestParams: {
- 'project_ids[]': [1],
- created_after: '2020-01-01',
- created_before: '2020-02-01',
- },
- });
-
- await waitForPromises();
- });
-
- it('fetches data for the `getValueStreamSummaryMetrics` request', () => {
- expectToHaveRequest({
- params: {
- 'project_ids[]': [1],
- created_after: '2020-01-01',
- created_before: '2020-02-01',
- },
- });
- });
- });
-
- describe('groupBy', () => {
- beforeEach(async () => {
- mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
- wrapper = createComponent({ groupBy: VSA_METRICS_GROUPS });
- await waitForPromises();
- });
-
- it('renders the metrics as separate groups', () => {
- const groups = findMetricsGroups();
- expect(groups).toHaveLength(VSA_METRICS_GROUPS.length);
- });
-
- it('renders titles for each group', () => {
- const groups = findMetricsGroups();
- groups.wrappers.forEach((g, index) => {
- const { title } = VSA_METRICS_GROUPS[index];
- expect(g.html()).toContain(title);
- });
- });
- });
- });
- });
-
- describe('with a request failing', () => {
- beforeEach(async () => {
- mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue();
- wrapper = createComponent();
-
- await waitForPromises();
- });
-
- it('should render an error message', () => {
- expect(createAlert).toHaveBeenCalledWith({
- message: `There was an error while fetching value stream analytics ${fakeReqName} data.`,
- });
- });
- });
-});
diff --git a/spec/frontend/analytics/devops_reports/components/service_ping_disabled_spec.js b/spec/frontend/analytics/devops_reports/components/service_ping_disabled_spec.js
index c62bfb11f7b..70bfce41c82 100644
--- a/spec/frontend/analytics/devops_reports/components/service_ping_disabled_spec.js
+++ b/spec/frontend/analytics/devops_reports/components/service_ping_disabled_spec.js
@@ -6,10 +6,6 @@ import ServicePingDisabled from '~/analytics/devops_reports/components/service_p
describe('~/analytics/devops_reports/components/service_ping_disabled.vue', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
const createWrapper = ({ isAdmin = false } = {}) => {
wrapper = mountExtended(ServicePingDisabled, {
provide: {
diff --git a/spec/frontend/analytics/product_analytics/components/activity_chart_spec.js b/spec/frontend/analytics/product_analytics/components/activity_chart_spec.js
new file mode 100644
index 00000000000..4f8126aaacf
--- /dev/null
+++ b/spec/frontend/analytics/product_analytics/components/activity_chart_spec.js
@@ -0,0 +1,34 @@
+import { GlColumnChart } from '@gitlab/ui/dist/charts';
+import { shallowMount } from '@vue/test-utils';
+import ActivityChart from '~/analytics/product_analytics/components/activity_chart.vue';
+
+describe('Activity Chart Bundle', () => {
+ let wrapper;
+ function mountComponent({ provide }) {
+ wrapper = shallowMount(ActivityChart, {
+ provide: {
+ formattedData: {},
+ ...provide,
+ },
+ });
+ }
+
+ const findChart = () => wrapper.findComponent(GlColumnChart);
+ const findNoData = () => wrapper.find('[data-testid="noActivityChartData"]');
+
+ describe('Activity Chart', () => {
+ it('renders an warning message with no data', () => {
+ mountComponent({ provide: { formattedData: {} } });
+ expect(findNoData().exists()).toBe(true);
+ });
+
+ it('renders a chart with data', () => {
+ mountComponent({
+ provide: { formattedData: { keys: ['key1', 'key2'], values: [5038, 2241] } },
+ });
+
+ expect(findNoData().exists()).toBe(false);
+ expect(findChart().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/analytics/shared/components/daterange_spec.js b/spec/frontend/analytics/shared/components/daterange_spec.js
index 562e86529ee..5f0847e0db6 100644
--- a/spec/frontend/analytics/shared/components/daterange_spec.js
+++ b/spec/frontend/analytics/shared/components/daterange_spec.js
@@ -22,10 +22,6 @@ describe('Daterange component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDaterangePicker = () => wrapper.findComponent(GlDaterangePicker);
const findDateRangeIndicator = () => wrapper.findByTestId('daterange-picker-indicator');
@@ -90,18 +86,19 @@ describe('Daterange component', () => {
});
describe('set', () => {
- it('emits the change event with an object containing startDate and endDate', () => {
+ it('emits the change event with an object containing startDate and endDate', async () => {
const startDate = new Date('2019-10-01');
const endDate = new Date('2019-10-05');
- wrapper.vm.dateRange = { startDate, endDate };
- expect(wrapper.emitted().change).toEqual([[{ startDate, endDate }]]);
+ await findDaterangePicker().vm.$emit('input', { startDate, endDate });
+
+ expect(wrapper.emitted('change')).toEqual([[{ startDate, endDate }]]);
});
});
describe('get', () => {
- it("returns value of dateRange from state's startDate and endDate", () => {
- expect(wrapper.vm.dateRange).toEqual({
+ it("datepicker to have default of dateRange from state's startDate and endDate", () => {
+ expect(findDaterangePicker().props('value')).toEqual({
startDate: defaultProps.startDate,
endDate: defaultProps.endDate,
});
diff --git a/spec/frontend/analytics/shared/components/metric_popover_spec.js b/spec/frontend/analytics/shared/components/metric_popover_spec.js
index e0bfff3e664..d7e6606cdc6 100644
--- a/spec/frontend/analytics/shared/components/metric_popover_spec.js
+++ b/spec/frontend/analytics/shared/components/metric_popover_spec.js
@@ -34,10 +34,6 @@ describe('MetricPopover', () => {
const findMetricDocsLinkIcon = () => findMetricDocsLink().findComponent(GlIcon);
const findMetricDetailsIcon = () => findMetricLink().findComponent(GlIcon);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the metric label', () => {
wrapper = createComponent({ metric: MOCK_METRIC });
expect(findMetricLabel().text()).toBe(MOCK_METRIC.label);
diff --git a/spec/frontend/analytics/shared/components/metric_tile_spec.js b/spec/frontend/analytics/shared/components/metric_tile_spec.js
index 980dfad9eb0..9da5ed0fb07 100644
--- a/spec/frontend/analytics/shared/components/metric_tile_spec.js
+++ b/spec/frontend/analytics/shared/components/metric_tile_spec.js
@@ -2,7 +2,7 @@ import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
import MetricTile from '~/analytics/shared/components/metric_tile.vue';
import MetricPopover from '~/analytics/shared/components/metric_popover.vue';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
jest.mock('~/lib/utils/url_utility');
@@ -21,10 +21,6 @@ describe('MetricTile', () => {
const findSingleStat = () => wrapper.findComponent(GlSingleStat);
const findPopover = () => wrapper.findComponent(MetricPopover);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
describe('links', () => {
it('when the metric has links, it redirects the user on click', () => {
@@ -38,7 +34,7 @@ describe('MetricTile', () => {
const singleStat = findSingleStat();
singleStat.vm.$emit('click');
- expect(redirectTo).toHaveBeenCalledWith('foo/bar');
+ expect(redirectTo).toHaveBeenCalledWith('foo/bar'); // eslint-disable-line import/no-deprecated
});
it("when the metric doesn't have links, it won't the user on click", () => {
@@ -47,7 +43,7 @@ describe('MetricTile', () => {
const singleStat = findSingleStat();
singleStat.vm.$emit('click');
- expect(redirectTo).not.toHaveBeenCalled();
+ expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
});
});
diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
index 3871fd530d8..33801fb8552 100644
--- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
+++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem, GlTruncate } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlTruncate, GlSearchBoxByType } from '@gitlab/ui';
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
@@ -31,6 +31,7 @@ const projects = [
const MockGlDropdown = stubComponent(GlDropdown, {
template: `
<div>
+ <slot name="header"></slot>
<div data-testid="vsa-highlighted-items">
<slot name="highlighted-items"></slot>
</div>
@@ -70,10 +71,6 @@ describe('ProjectsDropdownFilter component', () => {
return waitForPromises();
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items');
const findUnhighlightedItems = () => wrapper.findByTestId('vsa-default-items');
const findClearAllButton = () => wrapper.findByText('Clear all');
@@ -116,6 +113,8 @@ describe('ProjectsDropdownFilter component', () => {
const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id);
+ const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
+
describe('queryParams are applied when fetching data', () => {
beforeEach(() => {
createComponent({
@@ -127,9 +126,7 @@ describe('ProjectsDropdownFilter component', () => {
});
it('applies the correct queryParams when making an api call', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ searchTerm: 'gitlab' });
+ findSearchBoxByType().vm.$emit('input', 'gitlab');
expect(spyQuery).toHaveBeenCalledTimes(1);
@@ -148,6 +145,7 @@ describe('ProjectsDropdownFilter component', () => {
describe('highlighted items', () => {
const blockDefaultProps = { multiSelect: true };
+
beforeEach(() => {
createComponent(blockDefaultProps);
});
@@ -155,6 +153,7 @@ describe('ProjectsDropdownFilter component', () => {
describe('with no project selected', () => {
it('does not render the highlighted items', async () => {
await createWithMockDropdown(blockDefaultProps);
+
expect(findSelectedDropdownItems().length).toBe(0);
});
@@ -192,8 +191,7 @@ describe('ProjectsDropdownFilter component', () => {
expect(findSelectedProjectsLabel().text()).toBe('2 projects selected');
- findClearAllButton().trigger('click');
- await nextTick();
+ await findClearAllButton().trigger('click');
expect(findSelectedProjectsLabel().text()).toBe('Select projects');
});
@@ -205,16 +203,14 @@ describe('ProjectsDropdownFilter component', () => {
await createWithMockDropdown({ multiSelect: true });
selectDropdownItemAtIndex(0);
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ searchTerm: 'this is a very long search string' });
+ findSearchBoxByType().vm.$emit('input', 'this is a very long search string');
});
- it('renders the highlighted items', async () => {
+ it('renders the highlighted items', () => {
expect(findUnhighlightedItems().findAll('li').length).toBe(1);
});
- it('hides the unhighlighted items that do not match the string', async () => {
+ it('hides the unhighlighted items that do not match the string', () => {
expect(findUnhighlightedItems().findAll('li').length).toBe(1);
expect(findUnhighlightedItems().text()).toContain('No matching results');
});
@@ -355,17 +351,19 @@ describe('ProjectsDropdownFilter component', () => {
it('should remove from selection when clicked again', () => {
selectDropdownItemAtIndex(0);
+
expect(selectedIds()).toEqual([projects[0].id]);
selectDropdownItemAtIndex(0);
+
expect(selectedIds()).toEqual([]);
});
it('renders the correct placeholder text when multiple projects are selected', async () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(1);
-
await nextTick();
+
expect(findDropdownButton().text()).toBe('2 projects selected');
});
});
diff --git a/spec/frontend/analytics/shared/utils_spec.js b/spec/frontend/analytics/shared/utils_spec.js
index b48e2d971b5..24af7b836d5 100644
--- a/spec/frontend/analytics/shared/utils_spec.js
+++ b/spec/frontend/analytics/shared/utils_spec.js
@@ -5,6 +5,7 @@ import {
extractPaginationQueryParameters,
getDataZoomOption,
prepareTimeMetricsData,
+ generateValueStreamsDashboardLink,
} from '~/analytics/shared/utils';
import { slugify } from '~/lib/utils/text_utility';
import { objectToQuery } from '~/lib/utils/url_utility';
@@ -212,3 +213,30 @@ describe('prepareTimeMetricsData', () => {
]);
});
});
+
+describe('generateValueStreamsDashboardLink', () => {
+ it.each`
+ groupPath | projectPaths | result
+ ${''} | ${[]} | ${''}
+ ${'groups/fake-group'} | ${[]} | ${'/groups/fake-group/-/analytics/dashboards/value_streams_dashboard'}
+ ${'groups/fake-group'} | ${['fake-path/project_1']} | ${'/groups/fake-group/-/analytics/dashboards/value_streams_dashboard?query=fake-path/project_1'}
+ ${'groups/fake-group'} | ${['fake-path/project_1', 'fake-path/project_2']} | ${'/groups/fake-group/-/analytics/dashboards/value_streams_dashboard?query=fake-path/project_1,fake-path/project_2'}
+ `(
+ 'generates the dashboard link when groupPath=$groupPath and projectPaths=$projectPaths',
+ ({ groupPath, projectPaths, result }) => {
+ expect(generateValueStreamsDashboardLink(groupPath, projectPaths)).toBe(result);
+ },
+ );
+
+ describe('with a relative url rool set', () => {
+ beforeEach(() => {
+ gon.relative_url_root = '/foobar';
+ });
+
+ it('with includes a relative path if one is set', () => {
+ expect(generateValueStreamsDashboardLink('groups/fake-path', ['project_1'])).toBe(
+ '/foobar/groups/fake-path/-/analytics/dashboards/value_streams_dashboard?query=project_1',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/analytics/usage_trends/components/app_spec.js b/spec/frontend/analytics/usage_trends/components/app_spec.js
index c732dc22322..f9338661ebf 100644
--- a/spec/frontend/analytics/usage_trends/components/app_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/app_spec.js
@@ -15,11 +15,6 @@ describe('UsageTrendsApp', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('displays the usage counts component', () => {
expect(wrapper.findComponent(UsageCounts).exists()).toBe(true);
});
diff --git a/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js b/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js
index f4cbc56be5c..a71ce090955 100644
--- a/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js
@@ -26,10 +26,6 @@ describe('UsageCounts', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findAllSingleStats = () => wrapper.findAllComponents(GlSingleStat);
diff --git a/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js
index ad6089f74b5..322d05e663a 100644
--- a/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js
@@ -45,11 +45,6 @@ describe('UsageTrendsCountChart', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findLoader = () => wrapper.findComponent(ChartSkeletonLoader);
const findChart = () => wrapper.findComponent(GlLineChart);
const findAlert = () => wrapper.findComponent(GlAlert);
diff --git a/spec/frontend/analytics/usage_trends/components/users_chart_spec.js b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js
index e7abd4d4323..20836d7cc70 100644
--- a/spec/frontend/analytics/usage_trends/components/users_chart_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js
@@ -42,11 +42,6 @@ describe('UsersChart', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findLoader = () => wrapper.findComponent(ChartSkeletonLoader);
const findAlert = () => wrapper.findComponent(GlAlert);
const findChart = () => wrapper.findComponent(GlAreaChart);
diff --git a/spec/frontend/api/alert_management_alerts_api_spec.js b/spec/frontend/api/alert_management_alerts_api_spec.js
index 507f659a170..86052a05b76 100644
--- a/spec/frontend/api/alert_management_alerts_api_spec.js
+++ b/spec/frontend/api/alert_management_alerts_api_spec.js
@@ -9,7 +9,6 @@ import {
describe('~/api/alert_management_alerts_api.js', () => {
let mock;
- let originalGon;
const projectId = 1;
const alertIid = 2;
@@ -19,13 +18,11 @@ describe('~/api/alert_management_alerts_api.js', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
- originalGon = window.gon;
window.gon = { api_version: 'v4' };
});
afterEach(() => {
mock.restore();
- window.gon = originalGon;
});
describe('fetchAlertMetricImages', () => {
diff --git a/spec/frontend/api/groups_api_spec.js b/spec/frontend/api/groups_api_spec.js
index 0315db02cf2..642edb33624 100644
--- a/spec/frontend/api/groups_api_spec.js
+++ b/spec/frontend/api/groups_api_spec.js
@@ -10,23 +10,18 @@ const mockUrlRoot = '/gitlab';
const mockGroupId = '99';
describe('GroupsApi', () => {
- let originalGon;
let mock;
- const dummyGon = {
- api_version: mockApiVersion,
- relative_url_root: mockUrlRoot,
- };
-
beforeEach(() => {
mock = new MockAdapter(axios);
- originalGon = window.gon;
- window.gon = { ...dummyGon };
+ window.gon = {
+ api_version: mockApiVersion,
+ relative_url_root: mockUrlRoot,
+ };
});
afterEach(() => {
mock.restore();
- window.gon = originalGon;
});
describe('updateGroup', () => {
diff --git a/spec/frontend/api/packages_api_spec.js b/spec/frontend/api/packages_api_spec.js
index 5f517bcf358..37c4b926ec2 100644
--- a/spec/frontend/api/packages_api_spec.js
+++ b/spec/frontend/api/packages_api_spec.js
@@ -6,22 +6,18 @@ import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
describe('Api', () => {
const dummyApiVersion = 'v3000';
const dummyUrlRoot = '/gitlab';
- const dummyGon = {
- api_version: dummyApiVersion,
- relative_url_root: dummyUrlRoot,
- };
- let originalGon;
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
- originalGon = window.gon;
- window.gon = { ...dummyGon };
+ window.gon = {
+ api_version: dummyApiVersion,
+ relative_url_root: dummyUrlRoot,
+ };
});
afterEach(() => {
mock.restore();
- window.gon = originalGon;
});
describe('packages', () => {
diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js
index 2d4ed39dad0..4ceed885e6e 100644
--- a/spec/frontend/api/projects_api_spec.js
+++ b/spec/frontend/api/projects_api_spec.js
@@ -7,23 +7,17 @@ import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
describe('~/api/projects_api.js', () => {
let mock;
- let originalGon;
const projectId = 1;
- const setfullPathProjectSearch = (value) => {
- window.gon.features.fullPathProjectSearch = value;
- };
beforeEach(() => {
mock = new MockAdapter(axios);
- originalGon = window.gon;
- window.gon = { api_version: 'v7', features: { fullPathProjectSearch: true } };
+ window.gon = { api_version: 'v7' };
});
afterEach(() => {
mock.restore();
- window.gon = originalGon;
});
describe('getProjects', () => {
@@ -71,17 +65,18 @@ describe('~/api/projects_api.js', () => {
expect(data.data).toEqual(expectedProjects);
});
});
+ });
- it('does not search namespaces if fullPathProjectSearch is disabled', () => {
- setfullPathProjectSearch(false);
- const expectedParams = { params: { per_page: 20, search: 'group/project1', simple: true } };
- const query = 'group/project1';
+ describe('createProject', () => {
+ it('posts to the correct URL and returns the data', () => {
+ const body = { name: 'test project' };
+ const expectedUrl = '/api/v7/projects.json';
+ const expectedRes = { id: 999, name: 'test project' };
- mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { data: expectedProjects });
+ mock.onPost(expectedUrl, body).replyOnce(HTTP_STATUS_OK, { data: expectedRes });
- return projectsApi.getProjects(query, options).then(({ data }) => {
- expect(axios.get).toHaveBeenCalledWith(expectedUrl, expectedParams);
- expect(data.data).toEqual(expectedProjects);
+ return projectsApi.createProject(body).then(({ data }) => {
+ expect(data).toStrictEqual(expectedRes);
});
});
});
diff --git a/spec/frontend/api/tags_api_spec.js b/spec/frontend/api/tags_api_spec.js
index af3533f52b7..0a1177d4f60 100644
--- a/spec/frontend/api/tags_api_spec.js
+++ b/spec/frontend/api/tags_api_spec.js
@@ -5,20 +5,17 @@ import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
describe('~/api/tags_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('getTag', () => {
diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js
index 4d0252aad23..a879c229581 100644
--- a/spec/frontend/api/user_api_spec.js
+++ b/spec/frontend/api/user_api_spec.js
@@ -1,6 +1,13 @@
import MockAdapter from 'axios-mock-adapter';
-import { followUser, unfollowUser, associationsCount, updateUserStatus } from '~/api/user_api';
+import projects from 'test_fixtures/api/users/projects/get.json';
+import {
+ followUser,
+ unfollowUser,
+ associationsCount,
+ updateUserStatus,
+ getUserProjects,
+} from '~/api/user_api';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import {
@@ -12,19 +19,16 @@ import { timeRanges } from '~/vue_shared/constants';
describe('~/api/user_api', () => {
let axiosMock;
- let originalGon;
beforeEach(() => {
axiosMock = new MockAdapter(axios);
- originalGon = window.gon;
window.gon = { api_version: 'v4' };
});
afterEach(() => {
axiosMock.restore();
axiosMock.resetHistory();
- window.gon = originalGon;
});
describe('followUser', () => {
@@ -94,4 +98,18 @@ describe('~/api/user_api', () => {
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual(expectedData);
});
});
+
+ describe('getUserProjects', () => {
+ it('calls correct URL and returns expected response', async () => {
+ const expectedUrl = '/api/v4/users/1/projects';
+ const expectedResponse = { data: projects };
+
+ axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, expectedResponse);
+
+ await expect(getUserProjects(1)).resolves.toEqual(
+ expect.objectContaining({ data: expectedResponse }),
+ );
+ expect(axiosMock.history.get[0].url).toBe(expectedUrl);
+ });
+ });
});
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index 6fd106502c4..4ef37311e51 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -10,27 +10,22 @@ import {
HTTP_STATUS_OK,
} from '~/lib/utils/http_status';
-jest.mock('~/flash');
-
describe('Api', () => {
const dummyApiVersion = 'v3000';
const dummyUrlRoot = '/gitlab';
- const dummyGon = {
- api_version: dummyApiVersion,
- relative_url_root: dummyUrlRoot,
- };
- let originalGon;
+
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
- originalGon = window.gon;
- window.gon = { ...dummyGon };
+ window.gon = {
+ api_version: dummyApiVersion,
+ relative_url_root: dummyUrlRoot,
+ };
});
afterEach(() => {
mock.restore();
- window.gon = originalGon;
});
describe('buildUrl', () => {
@@ -1423,7 +1418,7 @@ describe('Api', () => {
describe('when service data increment counter is called with feature flag disabled', () => {
beforeEach(() => {
- gon.features = { ...gon.features, usageDataApi: false };
+ gon.features = { usageDataApi: false };
});
it('returns null', () => {
@@ -1437,7 +1432,7 @@ describe('Api', () => {
describe('when service data increment counter is called', () => {
beforeEach(() => {
- gon.features = { ...gon.features, usageDataApi: true };
+ gon.features = { usageDataApi: true };
});
it('resolves the Promise', () => {
@@ -1468,7 +1463,7 @@ describe('Api', () => {
describe('when service data increment unique users is called with feature flag disabled', () => {
beforeEach(() => {
- gon.features = { ...gon.features, usageDataApi: false };
+ gon.features = { usageDataApi: false };
});
it('returns null and does not call the endpoint', () => {
@@ -1483,7 +1478,7 @@ describe('Api', () => {
describe('when service data increment unique users is called', () => {
beforeEach(() => {
- gon.features = { ...gon.features, usageDataApi: true };
+ gon.features = { usageDataApi: true };
});
it('resolves the Promise', () => {
@@ -1500,7 +1495,7 @@ describe('Api', () => {
describe('when user is not set and feature flag enabled', () => {
beforeEach(() => {
- gon.features = { ...gon.features, usageDataApi: true };
+ gon.features = { usageDataApi: true };
});
it('returns null and does not call the endpoint', () => {
diff --git a/spec/frontend/approvals/mock_data.js b/spec/frontend/approvals/mock_data.js
new file mode 100644
index 00000000000..e0e90c09791
--- /dev/null
+++ b/spec/frontend/approvals/mock_data.js
@@ -0,0 +1,10 @@
+import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json';
+
+export const createCanApproveResponse = () => {
+ const response = JSON.parse(JSON.stringify(approvedByCurrentUser));
+ response.data.project.mergeRequest.userPermissions.canApprove = true;
+ response.data.project.mergeRequest.approved = false;
+ response.data.project.mergeRequest.approvedBy.nodes = [];
+
+ return response;
+};
diff --git a/spec/frontend/artifacts/components/app_spec.js b/spec/frontend/artifacts/components/app_spec.js
deleted file mode 100644
index 931c4703e95..00000000000
--- a/spec/frontend/artifacts/components/app_spec.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import { GlSkeletonLoader } from '@gitlab/ui';
-import VueApollo from 'vue-apollo';
-import Vue from 'vue';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import ArtifactsApp from '~/artifacts/components/app.vue';
-import JobArtifactsTable from '~/artifacts/components/job_artifacts_table.vue';
-import getBuildArtifactsSizeQuery from '~/artifacts/graphql/queries/get_build_artifacts_size.query.graphql';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import { PAGE_TITLE, TOTAL_ARTIFACTS_SIZE, SIZE_UNKNOWN } from '~/artifacts/constants';
-
-const TEST_BUILD_ARTIFACTS_SIZE = 1024;
-const TEST_PROJECT_PATH = 'project/path';
-const TEST_PROJECT_ID = 'gid://gitlab/Project/22';
-
-const createBuildArtifactsSizeResponse = (buildArtifactsSize) => ({
- data: {
- project: {
- __typename: 'Project',
- id: TEST_PROJECT_ID,
- statistics: {
- __typename: 'ProjectStatistics',
- buildArtifactsSize,
- },
- },
- },
-});
-
-Vue.use(VueApollo);
-
-describe('ArtifactsApp component', () => {
- let wrapper;
- let apolloProvider;
- let getBuildArtifactsSizeSpy;
-
- const findTitle = () => wrapper.findByTestId('artifacts-page-title');
- const findBuildArtifactsSize = () => wrapper.findByTestId('build-artifacts-size');
- const findJobArtifactsTable = () => wrapper.findComponent(JobArtifactsTable);
- const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
-
- const createComponent = () => {
- wrapper = shallowMountExtended(ArtifactsApp, {
- provide: { projectPath: 'project/path' },
- apolloProvider,
- });
- };
-
- beforeEach(() => {
- getBuildArtifactsSizeSpy = jest.fn();
-
- apolloProvider = createMockApollo([[getBuildArtifactsSizeQuery, getBuildArtifactsSizeSpy]]);
- });
-
- describe('when loading', () => {
- beforeEach(() => {
- // Promise that never resolves so it's always loading
- getBuildArtifactsSizeSpy.mockReturnValue(new Promise(() => {}));
-
- createComponent();
- });
-
- it('shows the page title', () => {
- expect(findTitle().text()).toBe(PAGE_TITLE);
- });
-
- it('shows a skeleton while loading the artifacts size', () => {
- expect(findSkeletonLoader().exists()).toBe(true);
- });
-
- it('shows the job artifacts table', () => {
- expect(findJobArtifactsTable().exists()).toBe(true);
- });
-
- it('does not show message', () => {
- expect(findBuildArtifactsSize().text()).toBe('');
- });
-
- it('calls apollo query', () => {
- expect(getBuildArtifactsSizeSpy).toHaveBeenCalledWith({ projectPath: TEST_PROJECT_PATH });
- });
- });
-
- describe.each`
- buildArtifactsSize | expectedText
- ${TEST_BUILD_ARTIFACTS_SIZE} | ${numberToHumanSize(TEST_BUILD_ARTIFACTS_SIZE)}
- ${null} | ${SIZE_UNKNOWN}
- `('when buildArtifactsSize is $buildArtifactsSize', ({ buildArtifactsSize, expectedText }) => {
- beforeEach(async () => {
- getBuildArtifactsSizeSpy.mockResolvedValue(
- createBuildArtifactsSizeResponse(buildArtifactsSize),
- );
-
- createComponent();
-
- await waitForPromises();
- });
-
- it('hides loader', () => {
- expect(findSkeletonLoader().exists()).toBe(false);
- });
-
- it('shows the size', () => {
- expect(findBuildArtifactsSize().text()).toMatchInterpolatedText(
- `${TOTAL_ARTIFACTS_SIZE} ${expectedText}`,
- );
- });
- });
-});
diff --git a/spec/frontend/artifacts/components/artifact_row_spec.js b/spec/frontend/artifacts/components/artifact_row_spec.js
deleted file mode 100644
index 2a7156bf480..00000000000
--- a/spec/frontend/artifacts/components/artifact_row_spec.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import { GlBadge, GlButton, GlFriendlyWrap } from '@gitlab/ui';
-import mockGetJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import ArtifactRow from '~/artifacts/components/artifact_row.vue';
-
-describe('ArtifactRow component', () => {
- let wrapper;
-
- const artifact = mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[0];
-
- const findName = () => wrapper.findByTestId('job-artifact-row-name');
- const findBadge = () => wrapper.findComponent(GlBadge);
- const findSize = () => wrapper.findByTestId('job-artifact-row-size');
- const findDownloadButton = () => wrapper.findByTestId('job-artifact-row-download-button');
- const findDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button');
-
- const createComponent = ({ canDestroyArtifacts = true } = {}) => {
- wrapper = shallowMountExtended(ArtifactRow, {
- propsData: {
- artifact,
- isLoading: false,
- isLastRow: false,
- },
- provide: { canDestroyArtifacts },
- stubs: { GlBadge, GlButton, GlFriendlyWrap },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('artifact details', () => {
- beforeEach(async () => {
- createComponent();
-
- await waitForPromises();
- });
-
- it('displays the artifact name and type', () => {
- expect(findName().text()).toContain(artifact.name);
- expect(findBadge().text()).toBe(artifact.fileType.toLowerCase());
- });
-
- it('displays the artifact size', () => {
- expect(findSize().text()).toBe(numberToHumanSize(artifact.size));
- });
-
- it('displays the download button as a link to the download path', () => {
- expect(findDownloadButton().attributes('href')).toBe(artifact.downloadPath);
- });
- });
-
- describe('delete button', () => {
- it('does not show when user does not have permission', () => {
- createComponent({ canDestroyArtifacts: false });
-
- expect(findDeleteButton().exists()).toBe(false);
- });
-
- it('shows when user has permission', () => {
- createComponent();
-
- expect(findDeleteButton().exists()).toBe(true);
- });
-
- it('emits the delete event when clicked', async () => {
- createComponent();
-
- expect(wrapper.emitted('delete')).toBeUndefined();
-
- findDeleteButton().trigger('click');
- await waitForPromises();
-
- expect(wrapper.emitted('delete')).toBeDefined();
- });
- });
-});
diff --git a/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js b/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js
deleted file mode 100644
index d006e0285d2..00000000000
--- a/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import { GlModal } from '@gitlab/ui';
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import getJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
-import waitForPromises from 'helpers/wait_for_promises';
-import ArtifactsTableRowDetails from '~/artifacts/components/artifacts_table_row_details.vue';
-import ArtifactRow from '~/artifacts/components/artifact_row.vue';
-import ArtifactDeleteModal from '~/artifacts/components/artifact_delete_modal.vue';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import destroyArtifactMutation from '~/artifacts/graphql/mutations/destroy_artifact.mutation.graphql';
-import { I18N_DESTROY_ERROR, I18N_MODAL_TITLE } from '~/artifacts/constants';
-import { createAlert } from '~/flash';
-
-jest.mock('~/flash');
-
-const { artifacts } = getJobArtifactsResponse.data.project.jobs.nodes[0];
-const refetchArtifacts = jest.fn();
-
-Vue.use(VueApollo);
-
-describe('ArtifactsTableRowDetails component', () => {
- let wrapper;
- let requestHandlers;
-
- const findModal = () => wrapper.findComponent(GlModal);
-
- const createComponent = (
- handlers = {
- destroyArtifactMutation: jest.fn(),
- },
- ) => {
- requestHandlers = handlers;
- wrapper = mountExtended(ArtifactsTableRowDetails, {
- apolloProvider: createMockApollo([
- [destroyArtifactMutation, requestHandlers.destroyArtifactMutation],
- ]),
- propsData: {
- artifacts,
- refetchArtifacts,
- queryVariables: {},
- },
- provide: { canDestroyArtifacts: true },
- data() {
- return { deletingArtifactId: null };
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('passes correct props', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('to the artifact rows', () => {
- [0, 1, 2].forEach((index) => {
- expect(wrapper.findAllComponents(ArtifactRow).at(index).props()).toMatchObject({
- artifact: artifacts.nodes[index],
- });
- });
- });
- });
-
- describe('when the artifact row emits the delete event', () => {
- it('shows the artifact delete modal', async () => {
- createComponent();
- await waitForPromises();
-
- expect(findModal().props('visible')).toBe(false);
-
- await wrapper.findComponent(ArtifactRow).vm.$emit('delete');
-
- expect(findModal().props('visible')).toBe(true);
- expect(findModal().props('title')).toBe(I18N_MODAL_TITLE(artifacts.nodes[0].name));
- });
- });
-
- describe('when the artifact delete modal emits its primary event', () => {
- it('triggers the destroyArtifact GraphQL mutation', async () => {
- createComponent();
- await waitForPromises();
-
- wrapper.findComponent(ArtifactRow).vm.$emit('delete');
- wrapper.findComponent(ArtifactDeleteModal).vm.$emit('primary');
-
- expect(requestHandlers.destroyArtifactMutation).toHaveBeenCalledWith({
- id: artifacts.nodes[0].id,
- });
- });
-
- it('displays a flash message and refetches artifacts when the mutation fails', async () => {
- createComponent({
- destroyArtifactMutation: jest.fn().mockRejectedValue(new Error('Error!')),
- });
- await waitForPromises();
-
- expect(wrapper.emitted('refetch')).toBeUndefined();
-
- wrapper.findComponent(ArtifactRow).vm.$emit('delete');
- wrapper.findComponent(ArtifactDeleteModal).vm.$emit('primary');
- await waitForPromises();
-
- expect(createAlert).toHaveBeenCalledWith({ message: I18N_DESTROY_ERROR });
- expect(wrapper.emitted('refetch')).toBeDefined();
- });
- });
-
- describe('when the artifact delete modal is cancelled', () => {
- it('does not trigger the destroyArtifact GraphQL mutation', async () => {
- createComponent();
- await waitForPromises();
-
- wrapper.findComponent(ArtifactRow).vm.$emit('delete');
- wrapper.findComponent(ArtifactDeleteModal).vm.$emit('cancel');
-
- expect(requestHandlers.destroyArtifactMutation).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/artifacts/components/feedback_banner_spec.js b/spec/frontend/artifacts/components/feedback_banner_spec.js
deleted file mode 100644
index 3421486020a..00000000000
--- a/spec/frontend/artifacts/components/feedback_banner_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { GlBanner } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import FeedbackBanner from '~/artifacts/components/feedback_banner.vue';
-import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
-import {
- I18N_FEEDBACK_BANNER_TITLE,
- I18N_FEEDBACK_BANNER_BUTTON,
- FEEDBACK_URL,
-} from '~/artifacts/constants';
-
-const mockBannerImagePath = 'banner/image/path';
-
-describe('Artifacts management feedback banner', () => {
- let wrapper;
- let userCalloutDismissSpy;
-
- const findBanner = () => wrapper.findComponent(GlBanner);
-
- const createComponent = ({ shouldShowCallout = true } = {}) => {
- userCalloutDismissSpy = jest.fn();
-
- wrapper = shallowMount(FeedbackBanner, {
- provide: {
- artifactsManagementFeedbackImagePath: mockBannerImagePath,
- },
- stubs: {
- UserCalloutDismisser: makeMockUserCalloutDismisser({
- dismiss: userCalloutDismissSpy,
- shouldShowCallout,
- }),
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('is displayed with the correct props', () => {
- createComponent();
-
- expect(findBanner().props()).toMatchObject({
- title: I18N_FEEDBACK_BANNER_TITLE,
- buttonText: I18N_FEEDBACK_BANNER_BUTTON,
- buttonLink: FEEDBACK_URL,
- svgPath: mockBannerImagePath,
- });
- });
-
- it('dismisses the callout when closed', () => {
- createComponent();
-
- findBanner().vm.$emit('close');
-
- expect(userCalloutDismissSpy).toHaveBeenCalled();
- });
-
- it('is not displayed once it has been dismissed', () => {
- createComponent({ shouldShowCallout: false });
-
- expect(findBanner().exists()).toBe(false);
- });
-});
diff --git a/spec/frontend/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/artifacts/components/job_artifacts_table_spec.js
deleted file mode 100644
index dbe4598f599..00000000000
--- a/spec/frontend/artifacts/components/job_artifacts_table_spec.js
+++ /dev/null
@@ -1,363 +0,0 @@
-import { GlLoadingIcon, GlTable, GlLink, GlBadge, GlPagination, GlModal } from '@gitlab/ui';
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import getJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import waitForPromises from 'helpers/wait_for_promises';
-import JobArtifactsTable from '~/artifacts/components/job_artifacts_table.vue';
-import FeedbackBanner from '~/artifacts/components/feedback_banner.vue';
-import ArtifactsTableRowDetails from '~/artifacts/components/artifacts_table_row_details.vue';
-import ArtifactDeleteModal from '~/artifacts/components/artifact_delete_modal.vue';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import getJobArtifactsQuery from '~/artifacts/graphql/queries/get_job_artifacts.query.graphql';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { ARCHIVE_FILE_TYPE, JOBS_PER_PAGE, I18N_FETCH_ERROR } from '~/artifacts/constants';
-import { totalArtifactsSizeForJob } from '~/artifacts/utils';
-import { createAlert } from '~/flash';
-
-jest.mock('~/flash');
-
-Vue.use(VueApollo);
-
-describe('JobArtifactsTable component', () => {
- let wrapper;
- let requestHandlers;
-
- const findBanner = () => wrapper.findComponent(FeedbackBanner);
-
- const findLoadingState = () => wrapper.findComponent(GlLoadingIcon);
- const findTable = () => wrapper.findComponent(GlTable);
- const findDetailsRows = () => wrapper.findAllComponents(ArtifactsTableRowDetails);
- const findDetailsInRow = (i) =>
- findTable().findAll('tbody tr').at(i).findComponent(ArtifactsTableRowDetails);
-
- const findCount = () => wrapper.findByTestId('job-artifacts-count');
- const findCountAt = (i) => wrapper.findAllByTestId('job-artifacts-count').at(i);
-
- const findModal = () => wrapper.findComponent(GlModal);
-
- const findStatuses = () => wrapper.findAllByTestId('job-artifacts-job-status');
- const findSuccessfulJobStatus = () => findStatuses().at(0);
- const findFailedJobStatus = () => findStatuses().at(1);
-
- const findLinks = () => wrapper.findAllComponents(GlLink);
- const findJobLink = () => findLinks().at(0);
- const findPipelineLink = () => findLinks().at(1);
- const findRefLink = () => findLinks().at(2);
- const findCommitLink = () => findLinks().at(3);
-
- const findSize = () => wrapper.findByTestId('job-artifacts-size');
- const findCreated = () => wrapper.findByTestId('job-artifacts-created');
-
- const findDownloadButton = () => wrapper.findByTestId('job-artifacts-download-button');
- const findBrowseButton = () => wrapper.findByTestId('job-artifacts-browse-button');
- const findDeleteButton = () => wrapper.findByTestId('job-artifacts-delete-button');
- const findArtifactDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button');
-
- const findPagination = () => wrapper.findComponent(GlPagination);
- const setPage = async (page) => {
- findPagination().vm.$emit('input', page);
- await waitForPromises();
- };
-
- let enoughJobsToPaginate = [...getJobArtifactsResponse.data.project.jobs.nodes];
- while (enoughJobsToPaginate.length <= JOBS_PER_PAGE) {
- enoughJobsToPaginate = [
- ...enoughJobsToPaginate,
- ...getJobArtifactsResponse.data.project.jobs.nodes,
- ];
- }
- const getJobArtifactsResponseThatPaginates = {
- data: { project: { jobs: { nodes: enoughJobsToPaginate } } },
- };
-
- const job = getJobArtifactsResponse.data.project.jobs.nodes[0];
- const archiveArtifact = job.artifacts.nodes.find(
- (artifact) => artifact.fileType === ARCHIVE_FILE_TYPE,
- );
-
- const createComponent = (
- handlers = {
- getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse),
- },
- data = {},
- canDestroyArtifacts = true,
- ) => {
- requestHandlers = handlers;
- wrapper = mountExtended(JobArtifactsTable, {
- apolloProvider: createMockApollo([
- [getJobArtifactsQuery, requestHandlers.getJobArtifactsQuery],
- ]),
- provide: {
- projectPath: 'project/path',
- canDestroyArtifacts,
- artifactsManagementFeedbackImagePath: 'banner/image/path',
- },
- data() {
- return data;
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders feedback banner', () => {
- createComponent();
-
- expect(findBanner().exists()).toBe(true);
- });
-
- it('when loading, shows a loading state', () => {
- createComponent();
-
- expect(findLoadingState().exists()).toBe(true);
- });
-
- it('on error, shows an alert', async () => {
- createComponent({
- getJobArtifactsQuery: jest.fn().mockRejectedValue(new Error('Error!')),
- });
-
- await waitForPromises();
-
- expect(createAlert).toHaveBeenCalledWith({ message: I18N_FETCH_ERROR });
- });
-
- it('with data, renders the table', async () => {
- createComponent();
-
- await waitForPromises();
-
- expect(findTable().exists()).toBe(true);
- });
-
- describe('job details', () => {
- beforeEach(async () => {
- createComponent();
-
- await waitForPromises();
- });
-
- it('shows the artifact count', () => {
- expect(findCount().text()).toBe(`${job.artifacts.nodes.length} files`);
- });
-
- it('shows the job status as an icon for a successful job', () => {
- expect(findSuccessfulJobStatus().findComponent(CiIcon).exists()).toBe(true);
- expect(findSuccessfulJobStatus().findComponent(GlBadge).exists()).toBe(false);
- });
-
- it('shows the job status as a badge for other job statuses', () => {
- expect(findFailedJobStatus().findComponent(GlBadge).exists()).toBe(true);
- expect(findFailedJobStatus().findComponent(CiIcon).exists()).toBe(false);
- });
-
- it('shows links to the job, pipeline, ref, and commit', () => {
- expect(findJobLink().text()).toBe(job.name);
- expect(findJobLink().attributes('href')).toBe(job.webPath);
-
- expect(findPipelineLink().text()).toBe(`#${getIdFromGraphQLId(job.pipeline.id)}`);
- expect(findPipelineLink().attributes('href')).toBe(job.pipeline.path);
-
- expect(findRefLink().text()).toBe(job.refName);
- expect(findRefLink().attributes('href')).toBe(job.refPath);
-
- expect(findCommitLink().text()).toBe(job.shortSha);
- expect(findCommitLink().attributes('href')).toBe(job.commitPath);
- });
-
- it('shows the total size of artifacts', () => {
- expect(findSize().text()).toBe(totalArtifactsSizeForJob(job));
- });
-
- it('shows the created time', () => {
- expect(findCreated().text()).toBe('5 years ago');
- });
-
- describe('row expansion', () => {
- it('toggles the visibility of the row details', async () => {
- expect(findDetailsRows().length).toBe(0);
-
- findCount().trigger('click');
- await waitForPromises();
-
- expect(findDetailsRows().length).toBe(1);
-
- findCount().trigger('click');
- await waitForPromises();
-
- expect(findDetailsRows().length).toBe(0);
- });
-
- it('expands and collapses jobs', async () => {
- // both jobs start collapsed
- expect(findDetailsInRow(0).exists()).toBe(false);
- expect(findDetailsInRow(1).exists()).toBe(false);
-
- findCountAt(0).trigger('click');
- await waitForPromises();
-
- // first job is expanded, second row has its details
- expect(findDetailsInRow(0).exists()).toBe(false);
- expect(findDetailsInRow(1).exists()).toBe(true);
- expect(findDetailsInRow(2).exists()).toBe(false);
-
- findCountAt(1).trigger('click');
- await waitForPromises();
-
- // both jobs are expanded, each has details below it
- expect(findDetailsInRow(0).exists()).toBe(false);
- expect(findDetailsInRow(1).exists()).toBe(true);
- expect(findDetailsInRow(2).exists()).toBe(false);
- expect(findDetailsInRow(3).exists()).toBe(true);
-
- findCountAt(0).trigger('click');
- await waitForPromises();
-
- // first job collapsed, second job expanded
- expect(findDetailsInRow(0).exists()).toBe(false);
- expect(findDetailsInRow(1).exists()).toBe(false);
- expect(findDetailsInRow(2).exists()).toBe(true);
- });
-
- it('keeps the job expanded when an artifact is deleted', async () => {
- findCount().trigger('click');
- await waitForPromises();
-
- expect(findDetailsInRow(0).exists()).toBe(false);
- expect(findDetailsInRow(1).exists()).toBe(true);
-
- findArtifactDeleteButton().trigger('click');
- await waitForPromises();
-
- expect(findModal().props('visible')).toBe(true);
-
- wrapper.findComponent(ArtifactDeleteModal).vm.$emit('primary');
- await waitForPromises();
-
- expect(findDetailsInRow(0).exists()).toBe(false);
- expect(findDetailsInRow(1).exists()).toBe(true);
- });
- });
- });
-
- describe('download button', () => {
- it('is a link to the download path for the archive artifact', async () => {
- createComponent();
-
- await waitForPromises();
-
- expect(findDownloadButton().attributes('href')).toBe(archiveArtifact.downloadPath);
- });
-
- it('is disabled when there is no download path', async () => {
- const jobWithoutDownloadPath = {
- ...job,
- archive: { downloadPath: null },
- };
-
- createComponent(
- { getJobArtifactsQuery: jest.fn() },
- { jobArtifacts: [jobWithoutDownloadPath] },
- );
-
- await waitForPromises();
-
- expect(findDownloadButton().attributes('disabled')).toBe('disabled');
- });
- });
-
- describe('browse button', () => {
- it('is a link to the browse path for the job', async () => {
- createComponent();
-
- await waitForPromises();
-
- expect(findBrowseButton().attributes('href')).toBe(job.browseArtifactsPath);
- });
-
- it('is disabled when there is no browse path', async () => {
- const jobWithoutBrowsePath = {
- ...job,
- browseArtifactsPath: null,
- };
-
- createComponent(
- { getJobArtifactsQuery: jest.fn() },
- { jobArtifacts: [jobWithoutBrowsePath] },
- );
-
- await waitForPromises();
-
- expect(findBrowseButton().attributes('disabled')).toBe('disabled');
- });
- });
-
- describe('delete button', () => {
- it('does not show when user does not have permission', async () => {
- createComponent({}, {}, false);
-
- await waitForPromises();
-
- expect(findDeleteButton().exists()).toBe(false);
- });
-
- it('shows a disabled delete button for now (coming soon)', async () => {
- createComponent();
-
- await waitForPromises();
-
- expect(findDeleteButton().attributes('disabled')).toBe('disabled');
- });
- });
-
- describe('pagination', () => {
- const { pageInfo } = getJobArtifactsResponse.data.project.jobs;
-
- beforeEach(async () => {
- createComponent(
- {
- getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponseThatPaginates),
- },
- {
- count: enoughJobsToPaginate.length,
- pageInfo,
- },
- );
-
- await waitForPromises();
- });
-
- it('renders pagination and passes page props', () => {
- expect(findPagination().exists()).toBe(true);
- expect(findPagination().props()).toMatchObject({
- value: wrapper.vm.pagination.currentPage,
- prevPage: wrapper.vm.prevPage,
- nextPage: wrapper.vm.nextPage,
- });
- });
-
- it('updates query variables when going to previous page', () => {
- return setPage(1).then(() => {
- expect(wrapper.vm.queryVariables).toMatchObject({
- projectPath: 'project/path',
- nextPageCursor: undefined,
- prevPageCursor: pageInfo.startCursor,
- });
- });
- });
-
- it('updates query variables when going to next page', () => {
- return setPage(2).then(() => {
- expect(wrapper.vm.queryVariables).toMatchObject({
- lastPageSize: null,
- nextPageCursor: pageInfo.endCursor,
- prevPageCursor: '',
- });
- });
- });
- });
-});
diff --git a/spec/frontend/artifacts/graphql/cache_update_spec.js b/spec/frontend/artifacts/graphql/cache_update_spec.js
deleted file mode 100644
index 4d610328298..00000000000
--- a/spec/frontend/artifacts/graphql/cache_update_spec.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import getJobArtifactsQuery from '~/artifacts/graphql/queries/get_job_artifacts.query.graphql';
-import { removeArtifactFromStore } from '~/artifacts/graphql/cache_update';
-
-describe('Artifact table cache updates', () => {
- let store;
-
- const cacheMock = {
- project: {
- jobs: {
- nodes: [
- { artifacts: { nodes: [{ id: 'foo' }] } },
- { artifacts: { nodes: [{ id: 'bar' }] } },
- ],
- },
- },
- };
-
- const query = getJobArtifactsQuery;
- const variables = { fullPath: 'path/to/project' };
-
- beforeEach(() => {
- store = {
- readQuery: jest.fn().mockReturnValue(cacheMock),
- writeQuery: jest.fn(),
- };
- });
-
- describe('removeArtifactFromStore', () => {
- it('calls readQuery', () => {
- removeArtifactFromStore(store, 'foo', query, variables);
- expect(store.readQuery).toHaveBeenCalledWith({ query, variables });
- });
-
- it('writes the correct result in the cache', () => {
- removeArtifactFromStore(store, 'foo', query, variables);
- expect(store.writeQuery).toHaveBeenCalledWith({
- query,
- variables,
- data: {
- project: {
- jobs: {
- nodes: [{ artifacts: { nodes: [] } }, { artifacts: { nodes: [{ id: 'bar' }] } }],
- },
- },
- },
- });
- });
-
- it('does not remove an unknown artifact', () => {
- removeArtifactFromStore(store, 'baz', query, variables);
- expect(store.writeQuery).toHaveBeenCalledWith({
- query,
- variables,
- data: {
- project: {
- jobs: {
- nodes: [
- { artifacts: { nodes: [{ id: 'foo' }] } },
- { artifacts: { nodes: [{ id: 'bar' }] } },
- ],
- },
- },
- },
- });
- });
- });
-});
diff --git a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js
index ca94acfa444..8dafff350f2 100644
--- a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js
+++ b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js
@@ -1,7 +1,8 @@
import { GlFormCheckbox, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import UpdateKeepLatestArtifactProjectSetting from '~/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql';
import GetKeepLatestArtifactApplicationSetting from '~/artifacts_settings/graphql/queries/get_keep_latest_artifact_application_setting.query.graphql';
@@ -28,7 +29,9 @@ const keepLatestArtifactApplicationMock = {
};
const keepLatestArtifactMockResponse = {
- data: { ciCdSettingsUpdate: { errors: [], __typename: 'CiCdSettingsUpdatePayload' } },
+ data: {
+ projectCiCdSettingsUpdate: { errors: [], __typename: 'ProjectCiCdSettingsUpdatePayload' },
+ },
};
describe('Keep latest artifact checkbox', () => {
@@ -78,8 +81,6 @@ describe('Keep latest artifact checkbox', () => {
};
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
apolloProvider = null;
});
@@ -104,19 +105,16 @@ describe('Keep latest artifact checkbox', () => {
});
describe('when application keep latest artifact setting is enabled', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent();
+ await waitForPromises();
});
- it('sets correct setting value in checkbox with query result', async () => {
- await nextTick();
-
+ it('sets correct setting value in checkbox with query result', () => {
expect(wrapper.element).toMatchSnapshot();
});
- it('checkbox is enabled when application setting is enabled', async () => {
- await nextTick();
-
+ it('checkbox is enabled when application setting is enabled', () => {
expect(findCheckbox().attributes('disabled')).toBeUndefined();
});
});
diff --git a/spec/frontend/authentication/password/components/password_input_spec.js b/spec/frontend/authentication/password/components/password_input_spec.js
new file mode 100644
index 00000000000..5b2a9da993b
--- /dev/null
+++ b/spec/frontend/authentication/password/components/password_input_spec.js
@@ -0,0 +1,64 @@
+import { GlFormInput, GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PasswordInput from '~/authentication/password/components/password_input.vue';
+import { SHOW_PASSWORD, HIDE_PASSWORD } from '~/authentication/password/constants';
+
+describe('PasswordInput', () => {
+ let wrapper;
+ const propsData = {
+ title: 'This field is required',
+ id: 'new_user_password',
+ minimumPasswordLength: '8',
+ qaSelector: 'new_user_password_field',
+ testid: 'new_user_password',
+ autocomplete: 'new-password',
+ name: 'new_user',
+ };
+
+ const findPasswordInput = () => wrapper.findComponent(GlFormInput);
+ const findToggleButton = () => wrapper.findComponent(GlButton);
+
+ const createComponent = () => {
+ return shallowMount(PasswordInput, {
+ propsData,
+ });
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('sets password input attributes correctly', () => {
+ expect(findPasswordInput().attributes('id')).toBe(propsData.id);
+ expect(findPasswordInput().attributes('autocomplete')).toBe(propsData.autocomplete);
+ expect(findPasswordInput().attributes('name')).toBe(propsData.name);
+ expect(findPasswordInput().attributes('minlength')).toBe(propsData.minimumPasswordLength);
+ expect(findPasswordInput().attributes('data-qa-selector')).toBe(propsData.qaSelector);
+ expect(findPasswordInput().attributes('data-testid')).toBe(propsData.testid);
+ expect(findPasswordInput().attributes('title')).toBe(propsData.title);
+ });
+
+ describe('when the show password button is clicked', () => {
+ beforeEach(() => {
+ findToggleButton().vm.$emit('click');
+ });
+
+ it('displays hide password button', () => {
+ expect(findPasswordInput().attributes('type')).toBe('text');
+ expect(findToggleButton().attributes('icon')).toBe('eye-slash');
+ expect(findToggleButton().attributes('aria-label')).toBe(HIDE_PASSWORD);
+ });
+
+ describe('when the hide password button is clicked', () => {
+ beforeEach(() => {
+ findToggleButton().vm.$emit('click');
+ });
+
+ it('displays show password button', () => {
+ expect(findPasswordInput().attributes('type')).toBe('password');
+ expect(findToggleButton().attributes('icon')).toBe('eye');
+ expect(findToggleButton().attributes('aria-label')).toBe(SHOW_PASSWORD);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js
index 694c16a85c4..8ecef710e03 100644
--- a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js
+++ b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js
@@ -19,7 +19,6 @@ describe('ManageTwoFactorForm', () => {
wrapper = mountExtended(ManageTwoFactorForm, {
provide: {
...defaultProvide,
- webauthnEnabled: options?.webauthnEnabled ?? false,
isCurrentPasswordRequired: options?.currentPasswordRequired ?? true,
},
stubs: {
@@ -41,16 +40,6 @@ describe('ManageTwoFactorForm', () => {
const findRegenerateCodesButton = () => wrapper.findByTestId('test-2fa-regenerate-codes-button');
const findConfirmationModal = () => wrapper.findComponent(GlModal);
- const itShowsConfirmationModal = (confirmText) => {
- it('shows confirmation modal', async () => {
- await wrapper.findByLabelText('Current password').setValue('foo bar');
- await findDisableButton().trigger('click');
-
- expect(findConfirmationModal().props('visible')).toBe(true);
- expect(findConfirmationModal().html()).toContain(confirmText);
- });
- };
-
const itShowsValidationMessageIfCurrentPasswordFieldIsEmpty = (findButtonFunction) => {
it('shows validation message if `Current password` is empty', async () => {
await findButtonFunction().trigger('click');
@@ -91,16 +80,12 @@ describe('ManageTwoFactorForm', () => {
describe('when clicked', () => {
itShowsValidationMessageIfCurrentPasswordFieldIsEmpty(findDisableButton);
- itShowsConfirmationModal(i18n.confirm);
-
- describe('when webauthnEnabled', () => {
- beforeEach(() => {
- createComponent({
- webauthnEnabled: true,
- });
- });
+ it('shows confirmation modal', async () => {
+ await wrapper.findByLabelText('Current password').setValue('foo bar');
+ await findDisableButton().trigger('click');
- itShowsConfirmationModal(i18n.confirmWebAuthn);
+ expect(findConfirmationModal().props('visible')).toBe(true);
+ expect(findConfirmationModal().html()).toContain(i18n.confirmWebAuthn);
});
it('modifies the form action and method when submitted through the button', async () => {
diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js
deleted file mode 100644
index 3ae7fcf1c49..00000000000
--- a/spec/frontend/authentication/u2f/authenticate_spec.js
+++ /dev/null
@@ -1,104 +0,0 @@
-import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import U2FAuthenticate from '~/authentication/u2f/authenticate';
-import 'vendor/u2f';
-import MockU2FDevice from './mock_u2f_device';
-
-describe('U2FAuthenticate', () => {
- let u2fDevice;
- let container;
- let component;
-
- beforeEach(() => {
- loadHTMLFixture('u2f/authenticate.html');
- u2fDevice = new MockU2FDevice();
- container = $('#js-authenticate-token-2fa');
- component = new U2FAuthenticate(
- container,
- '#js-login-token-2fa-form',
- {
- sign_requests: [],
- },
- document.querySelector('#js-login-2fa-device'),
- document.querySelector('.js-2fa-form'),
- );
- });
-
- afterEach(() => {
- resetHTMLFixture();
- });
-
- describe('with u2f unavailable', () => {
- let oldu2f;
-
- beforeEach(() => {
- jest.spyOn(component, 'switchToFallbackUI').mockImplementation(() => {});
- oldu2f = window.u2f;
- window.u2f = null;
- });
-
- afterEach(() => {
- window.u2f = oldu2f;
- });
-
- it('falls back to normal 2fa', async () => {
- await component.start();
- expect(component.switchToFallbackUI).toHaveBeenCalled();
- });
- });
-
- describe('with u2f available', () => {
- beforeEach(() => {
- // bypass automatic form submission within renderAuthenticated
- jest.spyOn(component, 'renderAuthenticated').mockReturnValue(true);
- u2fDevice = new MockU2FDevice();
-
- return component.start();
- });
-
- it('allows authenticating via a U2F device', () => {
- const inProgressMessage = container.find('p');
-
- expect(inProgressMessage.text()).toContain('Trying to communicate with your device');
- u2fDevice.respondToAuthenticateRequest({
- deviceData: 'this is data from the device',
- });
-
- expect(component.renderAuthenticated).toHaveBeenCalledWith(
- '{"deviceData":"this is data from the device"}',
- );
- });
-
- describe('errors', () => {
- it('displays an error message', () => {
- const setupButton = container.find('#js-login-2fa-device');
- setupButton.trigger('click');
- u2fDevice.respondToAuthenticateRequest({
- errorCode: 'error!',
- });
- const errorMessage = container.find('p');
-
- expect(errorMessage.text()).toContain('There was a problem communicating with your device');
- });
-
- it('allows retrying authentication after an error', () => {
- let setupButton = container.find('#js-login-2fa-device');
- setupButton.trigger('click');
- u2fDevice.respondToAuthenticateRequest({
- errorCode: 'error!',
- });
- const retryButton = container.find('#js-token-2fa-try-again');
- retryButton.trigger('click');
- setupButton = container.find('#js-login-2fa-device');
- setupButton.trigger('click');
- u2fDevice.respondToAuthenticateRequest({
- deviceData: 'this is data from the device',
- });
-
- expect(component.renderAuthenticated).toHaveBeenCalledWith(
- '{"deviceData":"this is data from the device"}',
- );
- });
- });
- });
-});
diff --git a/spec/frontend/authentication/u2f/mock_u2f_device.js b/spec/frontend/authentication/u2f/mock_u2f_device.js
deleted file mode 100644
index ec8425a4e3e..00000000000
--- a/spec/frontend/authentication/u2f/mock_u2f_device.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable no-unused-expressions */
-
-export default class MockU2FDevice {
- constructor() {
- this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this);
- this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this);
- window.u2f || (window.u2f = {});
- window.u2f.register = (appId, registerRequests, signRequests, callback) => {
- this.registerCallback = callback;
- };
- window.u2f.sign = (appId, challenges, signRequests, callback) => {
- this.authenticateCallback = callback;
- };
- }
-
- respondToRegisterRequest(params) {
- return this.registerCallback(params);
- }
-
- respondToAuthenticateRequest(params) {
- return this.authenticateCallback(params);
- }
-}
diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js
deleted file mode 100644
index 23d1e5c7dee..00000000000
--- a/spec/frontend/authentication/u2f/register_spec.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import { trimText } from 'helpers/text_helper';
-import U2FRegister from '~/authentication/u2f/register';
-import 'vendor/u2f';
-import MockU2FDevice from './mock_u2f_device';
-
-describe('U2FRegister', () => {
- let u2fDevice;
- let container;
- let component;
-
- beforeEach(() => {
- loadHTMLFixture('u2f/register.html');
- u2fDevice = new MockU2FDevice();
- container = $('#js-register-token-2fa');
- component = new U2FRegister(container, {});
- return component.start();
- });
-
- afterEach(() => {
- resetHTMLFixture();
- });
-
- it('allows registering a U2F device', () => {
- const setupButton = container.find('#js-setup-token-2fa-device');
-
- expect(trimText(setupButton.text())).toBe('Set up new device');
- setupButton.trigger('click');
- const inProgressMessage = container.children('p');
-
- expect(inProgressMessage.text()).toContain('Trying to communicate with your device');
- u2fDevice.respondToRegisterRequest({
- deviceData: 'this is data from the device',
- });
- const registeredMessage = container.find('p');
- const deviceResponse = container.find('#js-device-response');
-
- expect(registeredMessage.text()).toContain('Your device was successfully set up!');
- expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}');
- });
-
- describe('errors', () => {
- it("doesn't allow the same device to be registered twice (for the same user", () => {
- const setupButton = container.find('#js-setup-token-2fa-device');
- setupButton.trigger('click');
- u2fDevice.respondToRegisterRequest({
- errorCode: 4,
- });
- const errorMessage = container.find('p');
-
- expect(errorMessage.text()).toContain('already been registered with us');
- });
-
- it('displays an error message for other errors', () => {
- const setupButton = container.find('#js-setup-token-2fa-device');
- setupButton.trigger('click');
- u2fDevice.respondToRegisterRequest({
- errorCode: 'error!',
- });
- const errorMessage = container.find('p');
-
- expect(errorMessage.text()).toContain('There was a problem communicating with your device');
- });
-
- it('allows retrying registration after an error', () => {
- let setupButton = container.find('#js-setup-token-2fa-device');
- setupButton.trigger('click');
- u2fDevice.respondToRegisterRequest({
- errorCode: 'error!',
- });
- const retryButton = container.find('#js-token-2fa-try-again');
- retryButton.trigger('click');
- setupButton = container.find('#js-setup-token-2fa-device');
- setupButton.trigger('click');
- u2fDevice.respondToRegisterRequest({
- deviceData: 'this is data from the device',
- });
- const registeredMessage = container.find('p');
-
- expect(registeredMessage.text()).toContain('Your device was successfully set up!');
- });
- });
-});
diff --git a/spec/frontend/authentication/u2f/util_spec.js b/spec/frontend/authentication/u2f/util_spec.js
deleted file mode 100644
index 67fd4c73243..00000000000
--- a/spec/frontend/authentication/u2f/util_spec.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { canInjectU2fApi } from '~/authentication/u2f/util';
-
-describe('U2F Utils', () => {
- describe('canInjectU2fApi', () => {
- it('returns false for Chrome < 41', () => {
- const userAgent =
- 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.28 Safari/537.36';
-
- expect(canInjectU2fApi(userAgent)).toBe(false);
- });
-
- it('returns true for Chrome >= 41', () => {
- const userAgent =
- 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36';
-
- expect(canInjectU2fApi(userAgent)).toBe(true);
- });
-
- it('returns false for Opera < 40', () => {
- const userAgent =
- 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36 OPR/32.0.1948.25';
-
- expect(canInjectU2fApi(userAgent)).toBe(false);
- });
-
- it('returns true for Opera >= 40', () => {
- const userAgent =
- 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36 OPR/43.0.2442.991';
-
- expect(canInjectU2fApi(userAgent)).toBe(true);
- });
-
- it('returns false for Safari', () => {
- const userAgent =
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/603.2.4 (KHTML, like Gecko) Version/10.1.1 Safari/603.2.4';
-
- expect(canInjectU2fApi(userAgent)).toBe(false);
- });
-
- it('returns false for Chrome on Android', () => {
- const userAgent =
- 'Mozilla/5.0 (Linux; Android 7.0; VS988 Build/NRD90U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3145.0 Mobile Safari/537.36';
-
- expect(canInjectU2fApi(userAgent)).toBe(false);
- });
-
- it('returns false for Chrome on iOS', () => {
- const userAgent =
- 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1';
-
- expect(canInjectU2fApi(userAgent)).toBe(false);
- });
-
- it('returns false for Safari on iOS', () => {
- const userAgent =
- 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A356 Safari/604.1';
-
- expect(canInjectU2fApi(userAgent)).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js
index b1f4e43e56d..b3a634fb072 100644
--- a/spec/frontend/authentication/webauthn/authenticate_spec.js
+++ b/spec/frontend/authentication/webauthn/authenticate_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlWebauthnAuthenticate from 'test_fixtures/webauthn/authenticate.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate';
import MockWebAuthnDevice from './mock_webauthn_device';
@@ -35,7 +36,7 @@ describe('WebAuthnAuthenticate', () => {
};
beforeEach(() => {
- loadHTMLFixture('webauthn/authenticate.html');
+ setHTMLFixture(htmlWebauthnAuthenticate);
fallbackElement = document.createElement('div');
fallbackElement.classList.add('js-2fa-form');
webAuthnDevice = new MockWebAuthnDevice();
diff --git a/spec/frontend/authentication/webauthn/components/registration_spec.js b/spec/frontend/authentication/webauthn/components/registration_spec.js
new file mode 100644
index 00000000000..e4ca1ac8c38
--- /dev/null
+++ b/spec/frontend/authentication/webauthn/components/registration_spec.js
@@ -0,0 +1,255 @@
+import { nextTick } from 'vue';
+import { GlAlert, GlButton, GlForm, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import Registration from '~/authentication/webauthn/components/registration.vue';
+import {
+ I18N_BUTTON_REGISTER,
+ I18N_BUTTON_SETUP,
+ I18N_BUTTON_TRY_AGAIN,
+ I18N_ERROR_HTTP,
+ I18N_ERROR_UNSUPPORTED_BROWSER,
+ I18N_INFO_TEXT,
+ I18N_STATUS_SUCCESS,
+ I18N_STATUS_WAITING,
+ STATE_ERROR,
+ STATE_READY,
+ STATE_SUCCESS,
+ STATE_UNSUPPORTED,
+ STATE_WAITING,
+ WEBAUTHN_REGISTER,
+} from '~/authentication/webauthn/constants';
+import * as WebAuthnUtils from '~/authentication/webauthn/util';
+import WebAuthnError from '~/authentication/webauthn/error';
+
+const csrfToken = 'mock-csrf-token';
+jest.mock('~/lib/utils/csrf', () => ({ token: csrfToken }));
+jest.mock('~/authentication/webauthn/util');
+jest.mock('~/authentication/webauthn/error');
+
+describe('Registration', () => {
+ const initialError = null;
+ const passwordRequired = true;
+ const targetPath = '/-/profile/two_factor_auth/create_webauthn';
+ let wrapper;
+
+ const createComponent = (provide = {}) => {
+ wrapper = shallowMountExtended(Registration, {
+ provide: { initialError, passwordRequired, targetPath, ...provide },
+ });
+ };
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ describe(`when ${STATE_UNSUPPORTED} state`, () => {
+ it('shows an error if using unsecure scheme (HTTP)', () => {
+ // `supported` function returns false for HTTP because `navigator.credentials` is undefined.
+ WebAuthnUtils.supported.mockReturnValue(false);
+ WebAuthnUtils.isHTTPS.mockReturnValue(false);
+ createComponent();
+
+ const alert = wrapper.findComponent(GlAlert);
+ expect(alert.props('variant')).toBe('danger');
+ expect(alert.text()).toBe(I18N_ERROR_HTTP);
+ });
+
+ it('shows an error if using unsupported browser', () => {
+ WebAuthnUtils.supported.mockReturnValue(false);
+ WebAuthnUtils.isHTTPS.mockReturnValue(true);
+ createComponent();
+
+ const alert = wrapper.findComponent(GlAlert);
+ expect(alert.props('variant')).toBe('danger');
+ expect(alert.text()).toBe(I18N_ERROR_UNSUPPORTED_BROWSER);
+ });
+ });
+
+ describe('when scheme or browser are supported', () => {
+ const mockCreate = jest.fn();
+
+ const clickSetupDeviceButton = () => {
+ findButton().vm.$emit('click');
+ return nextTick();
+ };
+
+ const setupDevice = () => {
+ clickSetupDeviceButton();
+ return waitForPromises();
+ };
+
+ beforeEach(() => {
+ WebAuthnUtils.isHTTPS.mockReturnValue(true);
+ WebAuthnUtils.supported.mockReturnValue(true);
+ global.navigator.credentials = { create: mockCreate };
+ gon.webauthn = { options: {} };
+ });
+
+ afterEach(() => {
+ global.navigator.credentials = undefined;
+ });
+
+ describe(`when ${STATE_READY} state`, () => {
+ it('shows button and explanation text', () => {
+ createComponent();
+
+ expect(findButton().text()).toBe(I18N_BUTTON_SETUP);
+ expect(wrapper.text()).toContain(I18N_INFO_TEXT);
+ });
+ });
+
+ describe(`when ${STATE_WAITING} state`, () => {
+ it('shows loading icon and message after pressing the button', async () => {
+ createComponent();
+
+ await clickSetupDeviceButton();
+
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.text()).toContain(I18N_STATUS_WAITING);
+ });
+ });
+
+ describe(`when ${STATE_SUCCESS} state`, () => {
+ const credentials = 1;
+
+ const findCurrentPasswordInput = () => wrapper.findByTestId('current-password-input');
+ const findDeviceNameInput = () => wrapper.findByTestId('device-name-input');
+
+ beforeEach(() => {
+ mockCreate.mockResolvedValueOnce(true);
+ WebAuthnUtils.convertCreateResponse.mockReturnValue(credentials);
+ });
+
+ describe('registration form', () => {
+ it('has correct action', async () => {
+ createComponent();
+
+ await setupDevice();
+
+ expect(wrapper.findComponent(GlForm).attributes('action')).toBe(targetPath);
+ });
+
+ describe('when password is required', () => {
+ it('shows device name and password fields', async () => {
+ createComponent();
+
+ await setupDevice();
+
+ expect(wrapper.text()).toContain(I18N_STATUS_SUCCESS);
+
+ // Visible inputs
+ expect(findCurrentPasswordInput().attributes('name')).toBe('current_password');
+ expect(findDeviceNameInput().attributes('name')).toBe('device_registration[name]');
+
+ // Hidden inputs
+ expect(
+ wrapper
+ .find('input[name="device_registration[device_response]"]')
+ .attributes('value'),
+ ).toBe(`${credentials}`);
+ expect(wrapper.find('input[name=authenticity_token]').attributes('value')).toBe(
+ csrfToken,
+ );
+
+ expect(findButton().text()).toBe(I18N_BUTTON_REGISTER);
+ });
+
+ it('enables the register device button when device name and password are filled', async () => {
+ createComponent();
+
+ await setupDevice();
+
+ expect(findButton().props('disabled')).toBe(true);
+
+ // Visible inputs
+ findCurrentPasswordInput().vm.$emit('input', 'my current password');
+ findDeviceNameInput().vm.$emit('input', 'my device name');
+ await nextTick();
+
+ expect(findButton().props('disabled')).toBe(false);
+ });
+ });
+
+ describe('when password is not required', () => {
+ it('shows a device name field', async () => {
+ createComponent({ passwordRequired: false });
+
+ await setupDevice();
+
+ expect(wrapper.text()).toContain(I18N_STATUS_SUCCESS);
+
+ // Visible inputs
+ expect(findCurrentPasswordInput().exists()).toBe(false);
+ expect(findDeviceNameInput().attributes('name')).toBe('device_registration[name]');
+
+ // Hidden inputs
+ expect(
+ wrapper
+ .find('input[name="device_registration[device_response]"]')
+ .attributes('value'),
+ ).toBe(`${credentials}`);
+ expect(wrapper.find('input[name=authenticity_token]').attributes('value')).toBe(
+ csrfToken,
+ );
+
+ expect(findButton().text()).toBe(I18N_BUTTON_REGISTER);
+ });
+
+ it('enables the register device button when device name is filled', async () => {
+ createComponent({ passwordRequired: false });
+
+ await setupDevice();
+
+ expect(findButton().props('disabled')).toBe(true);
+
+ findDeviceNameInput().vm.$emit('input', 'my device name');
+ await nextTick();
+
+ expect(findButton().props('disabled')).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe(`when ${STATE_ERROR} state`, () => {
+ it('shows an initial error message and a retry button', () => {
+ const myError = 'my error';
+ createComponent({ initialError: myError });
+
+ const alert = wrapper.findComponent(GlAlert);
+ expect(alert.props()).toMatchObject({
+ variant: 'danger',
+ secondaryButtonText: I18N_BUTTON_TRY_AGAIN,
+ });
+ expect(alert.text()).toContain(myError);
+ });
+
+ it('shows an error message and a retry button', async () => {
+ createComponent();
+ const error = new Error();
+ mockCreate.mockRejectedValueOnce(error);
+
+ await setupDevice();
+
+ expect(WebAuthnError).toHaveBeenCalledWith(error, WEBAUTHN_REGISTER);
+ expect(wrapper.findComponent(GlAlert).props()).toMatchObject({
+ variant: 'danger',
+ secondaryButtonText: I18N_BUTTON_TRY_AGAIN,
+ });
+ });
+
+ it('recovers after an error (error to success state)', async () => {
+ createComponent();
+ mockCreate.mockRejectedValueOnce(new Error()).mockResolvedValueOnce(true);
+
+ await setupDevice();
+
+ expect(wrapper.findComponent(GlAlert).props('variant')).toBe('danger');
+
+ wrapper.findComponent(GlAlert).vm.$emit('secondaryAction');
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GlAlert).props('variant')).toBe('info');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/authentication/webauthn/error_spec.js b/spec/frontend/authentication/webauthn/error_spec.js
index 9b71f77dde2..b979173edc6 100644
--- a/spec/frontend/authentication/webauthn/error_spec.js
+++ b/spec/frontend/authentication/webauthn/error_spec.js
@@ -1,16 +1,17 @@
import setWindowLocation from 'helpers/set_window_location_helper';
import WebAuthnError from '~/authentication/webauthn/error';
+import { WEBAUTHN_AUTHENTICATE, WEBAUTHN_REGISTER } from '~/authentication/webauthn/constants';
describe('WebAuthnError', () => {
it.each([
[
'NotSupportedError',
'Your device is not compatible with GitLab. Please try another device',
- 'authenticate',
+ WEBAUTHN_AUTHENTICATE,
],
- ['InvalidStateError', 'This device has not been registered with us.', 'authenticate'],
- ['InvalidStateError', 'This device has already been registered with us.', 'register'],
- ['UnknownError', 'There was a problem communicating with your device.', 'register'],
+ ['InvalidStateError', 'This device has not been registered with us.', WEBAUTHN_AUTHENTICATE],
+ ['InvalidStateError', 'This device has already been registered with us.', WEBAUTHN_REGISTER],
+ ['UnknownError', 'There was a problem communicating with your device.', WEBAUTHN_REGISTER],
])('exception %s will have message %s, flow type: %s', (exception, expectedMessage, flowType) => {
expect(new WebAuthnError(new DOMException('', exception), flowType).message()).toEqual(
expectedMessage,
@@ -24,7 +25,7 @@ describe('WebAuthnError', () => {
const expectedMessage =
'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.';
expect(
- new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(),
+ new WebAuthnError(new DOMException('', 'SecurityError'), WEBAUTHN_AUTHENTICATE).message(),
).toEqual(expectedMessage);
});
@@ -33,7 +34,7 @@ describe('WebAuthnError', () => {
const expectedMessage = 'There was a problem communicating with your device.';
expect(
- new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(),
+ new WebAuthnError(new DOMException('', 'SecurityError'), WEBAUTHN_AUTHENTICATE).message(),
).toEqual(expectedMessage);
});
});
diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js
index 773481346fc..5f0691782a7 100644
--- a/spec/frontend/authentication/webauthn/register_spec.js
+++ b/spec/frontend/authentication/webauthn/register_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlWebauthnRegister from 'test_fixtures/webauthn/register.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { trimText } from 'helpers/text_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -25,7 +26,7 @@ describe('WebAuthnRegister', () => {
let component;
beforeEach(() => {
- loadHTMLFixture('webauthn/register.html');
+ setHTMLFixture(htmlWebauthnRegister);
webAuthnDevice = new MockWebAuthnDevice();
container = $('#js-register-token-2fa');
component = new WebAuthnRegister(container, {
diff --git a/spec/frontend/authentication/webauthn/util_spec.js b/spec/frontend/authentication/webauthn/util_spec.js
index bc44b47d0ba..831d1636b8c 100644
--- a/spec/frontend/authentication/webauthn/util_spec.js
+++ b/spec/frontend/authentication/webauthn/util_spec.js
@@ -1,4 +1,9 @@
-import { base64ToBuffer, bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util';
+import {
+ base64ToBuffer,
+ bufferToBase64,
+ base64ToBase64Url,
+ supported,
+} from '~/authentication/webauthn/util';
const encodedString = 'SGVsbG8gd29ybGQh';
const stringBytes = [72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33];
@@ -31,4 +36,28 @@ describe('Webauthn utils', () => {
expect(base64ToBase64Url(argument)).toBe(expectedResult);
});
});
+
+ describe('supported', () => {
+ afterEach(() => {
+ global.navigator.credentials = undefined;
+ window.PublicKeyCredential = undefined;
+ });
+
+ it.each`
+ credentials | PublicKeyCredential | expected
+ ${undefined} | ${undefined} | ${false}
+ ${{}} | ${undefined} | ${false}
+ ${{ create: true }} | ${undefined} | ${false}
+ ${{ create: true, get: true }} | ${undefined} | ${false}
+ ${{ create: true, get: true }} | ${true} | ${true}
+ `(
+ 'returns $expected when credentials is $credentials and PublicKeyCredential is $PublicKeyCredential',
+ ({ credentials, PublicKeyCredential, expected }) => {
+ global.navigator.credentials = credentials;
+ window.PublicKeyCredential = PublicKeyCredential;
+
+ expect(supported()).toBe(expected);
+ },
+ );
+ });
});
diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js
index 1a54b9909ba..c2b7906d0d6 100644
--- a/spec/frontend/awards_handler_spec.js
+++ b/spec/frontend/awards_handler_spec.js
@@ -1,15 +1,14 @@
import $ from 'jquery';
+import htmlSnippetsShow from 'test_fixtures/snippets/show.html';
import Cookies from '~/lib/utils/cookies';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import loadAwardsHandler from '~/awards_handler';
window.gl = window.gl || {};
-window.gon = window.gon || {};
let awardsHandler = null;
-const urlRoot = gon.relative_url_root;
describe('AwardsHandler', () => {
useFakeRequestAnimationFrame();
@@ -88,16 +87,13 @@ describe('AwardsHandler', () => {
beforeEach(async () => {
await initEmojiMock(emojiData);
- loadHTMLFixture('snippets/show.html');
+ setHTMLFixture(htmlSnippetsShow);
awardsHandler = await loadAwardsHandler(true);
jest.spyOn(awardsHandler, 'postEmoji').mockImplementation((button, url, emoji, cb) => cb());
});
afterEach(() => {
- // restore original url root value
- gon.relative_url_root = urlRoot;
-
clearEmojiMock();
// Undo what we did to the shared <body>
diff --git a/spec/frontend/badges/components/badge_form_spec.js b/spec/frontend/badges/components/badge_form_spec.js
index 0a736df7075..d7519f1f80d 100644
--- a/spec/frontend/badges/components/badge_form_spec.js
+++ b/spec/frontend/badges/components/badge_form_spec.js
@@ -43,7 +43,6 @@ describe('BadgeForm component', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
});
diff --git a/spec/frontend/badges/components/badge_list_row_spec.js b/spec/frontend/badges/components/badge_list_row_spec.js
index ee7ccac974a..cbbeb36ff33 100644
--- a/spec/frontend/badges/components/badge_list_row_spec.js
+++ b/spec/frontend/badges/components/badge_list_row_spec.js
@@ -43,7 +43,6 @@ describe('BadgeListRow component', () => {
};
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
diff --git a/spec/frontend/badges/components/badge_list_spec.js b/spec/frontend/badges/components/badge_list_spec.js
index 606b1bc9cce..374b7b50af4 100644
--- a/spec/frontend/badges/components/badge_list_spec.js
+++ b/spec/frontend/badges/components/badge_list_spec.js
@@ -38,10 +38,6 @@ describe('BadgeList component', () => {
wrapper = mount(BadgeList, { store });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('for project badges', () => {
it('renders a header with the badge count', () => {
createComponent({
diff --git a/spec/frontend/badges/components/badge_settings_spec.js b/spec/frontend/badges/components/badge_settings_spec.js
index bddb6d3801c..7ad2c99869c 100644
--- a/spec/frontend/badges/components/badge_settings_spec.js
+++ b/spec/frontend/badges/components/badge_settings_spec.js
@@ -32,10 +32,6 @@ describe('BadgeSettings component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays modal if button for deleting a badge is clicked', async () => {
const button = wrapper.find('[data-testid="delete-badge"]');
diff --git a/spec/frontend/badges/components/badge_spec.js b/spec/frontend/badges/components/badge_spec.js
index b468e38f19e..c933c1b5434 100644
--- a/spec/frontend/badges/components/badge_spec.js
+++ b/spec/frontend/badges/components/badge_spec.js
@@ -24,10 +24,6 @@ describe('Badge component', () => {
wrapper = mount(Badge, { propsData });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
beforeEach(() => {
return createComponent({ ...dummyProps }, '#dummy-element');
});
diff --git a/spec/frontend/batch_comments/components/diff_file_drafts_spec.js b/spec/frontend/batch_comments/components/diff_file_drafts_spec.js
index c922d6a9809..f667ebc0fcb 100644
--- a/spec/frontend/batch_comments/components/diff_file_drafts_spec.js
+++ b/spec/frontend/batch_comments/components/diff_file_drafts_spec.js
@@ -28,10 +28,6 @@ describe('Batch comments diff file drafts component', () => {
});
}
- afterEach(() => {
- vm.destroy();
- });
-
it('renders list of draft notes', () => {
factory();
diff --git a/spec/frontend/batch_comments/components/draft_note_spec.js b/spec/frontend/batch_comments/components/draft_note_spec.js
index 924d88866ee..159e36c1364 100644
--- a/spec/frontend/batch_comments/components/draft_note_spec.js
+++ b/spec/frontend/batch_comments/components/draft_note_spec.js
@@ -49,10 +49,6 @@ describe('Batch comments draft note component', () => {
draft = createDraft();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders template', () => {
createComponent();
expect(wrapper.findComponent(GlBadge).exists()).toBe(true);
diff --git a/spec/frontend/batch_comments/components/drafts_count_spec.js b/spec/frontend/batch_comments/components/drafts_count_spec.js
index c3a7946c85c..850a7efb4ed 100644
--- a/spec/frontend/batch_comments/components/drafts_count_spec.js
+++ b/spec/frontend/batch_comments/components/drafts_count_spec.js
@@ -15,10 +15,6 @@ describe('Batch comments drafts count component', () => {
wrapper = mount(DraftsCount, { store });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders count', () => {
expect(wrapper.text()).toContain('1');
});
diff --git a/spec/frontend/batch_comments/components/preview_dropdown_spec.js b/spec/frontend/batch_comments/components/preview_dropdown_spec.js
index f86e003ab5f..3a28bf4ade8 100644
--- a/spec/frontend/batch_comments/components/preview_dropdown_spec.js
+++ b/spec/frontend/batch_comments/components/preview_dropdown_spec.js
@@ -1,7 +1,6 @@
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
-import { GlDisclosureDropdown } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { visitUrl } from '~/lib/utils/url_utility';
import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue';
@@ -46,9 +45,11 @@ function factory({ viewDiffsFileByFile = false, draftsCount = 1, sortedDrafts =
},
});
- wrapper = shallowMount(PreviewDropdown, {
+ wrapper = mount(PreviewDropdown, {
store,
- stubs: { GlDisclosureDropdown },
+ stubs: {
+ PreviewItem: true,
+ },
});
}
@@ -59,12 +60,12 @@ describe('Batch comments preview dropdown', () => {
viewDiffsFileByFile: true,
sortedDrafts: [{ id: 1, file_hash: 'hash' }],
});
-
- findPreviewItem().vm.$emit('click');
-
+ findPreviewItem().trigger('click');
await nextTick();
expect(setCurrentFileHash).toHaveBeenCalledWith(expect.anything(), 'hash');
+
+ await nextTick();
expect(scrollToDraft).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ id: 1, file_hash: 'hash' }),
@@ -77,7 +78,7 @@ describe('Batch comments preview dropdown', () => {
sortedDrafts: [{ id: 1 }],
});
- findPreviewItem().vm.$emit('click');
+ findPreviewItem().trigger('click');
await nextTick();
@@ -93,7 +94,7 @@ describe('Batch comments preview dropdown', () => {
sortedDrafts: [{ id: 1, position: { head_sha: '1234' } }],
});
- findPreviewItem().vm.$emit('click');
+ findPreviewItem().trigger('click');
await nextTick();
diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js
index 6a99294f855..a19a72af813 100644
--- a/spec/frontend/batch_comments/components/preview_item_spec.js
+++ b/spec/frontend/batch_comments/components/preview_item_spec.js
@@ -26,10 +26,6 @@ describe('Batch comments draft preview item component', () => {
wrapper = mount(PreviewItem, { store, propsData: { draft, isLast } });
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders text content', () => {
createComponent(false, { note_html: '<img src="" /><p>Hello world</p>' });
diff --git a/spec/frontend/batch_comments/components/review_bar_spec.js b/spec/frontend/batch_comments/components/review_bar_spec.js
index 0a4c9ff62e4..ea4b015ea39 100644
--- a/spec/frontend/batch_comments/components/review_bar_spec.js
+++ b/spec/frontend/batch_comments/components/review_bar_spec.js
@@ -20,11 +20,7 @@ describe('Batch comments review bar component', () => {
document.body.className = '';
});
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('adds review-bar-visible class to body when review bar is mounted', async () => {
+ it('adds review-bar-visible class to body when review bar is mounted', () => {
expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(false);
createComponent();
@@ -32,7 +28,7 @@ describe('Batch comments review bar component', () => {
expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(true);
});
- it('removes review-bar-visible class to body when review bar is destroyed', async () => {
+ it('removes review-bar-visible class to body when review bar is destroyed', () => {
createComponent();
wrapper.destroy();
diff --git a/spec/frontend/batch_comments/components/submit_dropdown_spec.js b/spec/frontend/batch_comments/components/submit_dropdown_spec.js
index 003a6d86371..5c33df882bf 100644
--- a/spec/frontend/batch_comments/components/submit_dropdown_spec.js
+++ b/spec/frontend/batch_comments/components/submit_dropdown_spec.js
@@ -1,3 +1,4 @@
+import { GlDropdown } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
import { mountExtended } from 'helpers/vue_test_utils_helper';
@@ -10,7 +11,7 @@ Vue.use(Vuex);
let wrapper;
let publishReview;
-function factory({ canApprove = true } = {}) {
+function factory({ canApprove = true, shouldAnimateReviewButton = false } = {}) {
publishReview = jest.fn();
const store = new Vuex.Store({
@@ -30,6 +31,7 @@ function factory({ canApprove = true } = {}) {
modules: {
batchComments: {
namespaced: true,
+ state: { shouldAnimateReviewButton },
actions: {
publishReview,
},
@@ -44,10 +46,10 @@ function factory({ canApprove = true } = {}) {
const findCommentTextarea = () => wrapper.findByTestId('comment-textarea');
const findSubmitButton = () => wrapper.findByTestId('submit-review-button');
const findForm = () => wrapper.findByTestId('submit-gl-form');
+const findSubmitDropdown = () => wrapper.findComponent(GlDropdown);
describe('Batch comments submit dropdown', () => {
afterEach(() => {
- wrapper.destroy();
window.mrTabs = null;
});
@@ -99,4 +101,19 @@ describe('Batch comments submit dropdown', () => {
expect(wrapper.findByTestId('approve_merge_request').exists()).toBe(exists);
});
+
+ it.each`
+ shouldAnimateReviewButton | animationClassApplied | classText
+ ${true} | ${true} | ${'applies'}
+ ${false} | ${false} | ${'does not apply'}
+ `(
+ '$classText animation class to `Finish review` button if `shouldAnimateReviewButton` is $shouldAnimateReviewButton',
+ ({ shouldAnimateReviewButton, animationClassApplied }) => {
+ factory({ shouldAnimateReviewButton });
+
+ expect(findSubmitDropdown().classes('submit-review-dropdown-animated')).toBe(
+ animationClassApplied,
+ );
+ },
+ );
});
diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
index 20eedcbb25b..57bafb51cd6 100644
--- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
+++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
@@ -317,4 +317,10 @@ describe('Batch comments store actions', () => {
expect(window.mrTabs.tabShown).toHaveBeenCalledWith('diffs');
});
});
+
+ describe('clearDrafts', () => {
+ it('commits CLEAR_DRAFTS', () => {
+ return testAction(actions.clearDrafts, null, null, [{ type: 'CLEAR_DRAFTS' }], []);
+ });
+ });
});
diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js
index fe01de638c2..fc00083987e 100644
--- a/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js
+++ b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js
@@ -10,9 +10,8 @@ describe('Batch comments mutations', () => {
});
describe(types.ADD_NEW_DRAFT, () => {
+ const draft = { id: 1, note: 'test' };
it('adds processed object into drafts array', () => {
- const draft = { id: 1, note: 'test' };
-
mutations[types.ADD_NEW_DRAFT](state, draft);
expect(state.drafts).toEqual([
@@ -22,6 +21,19 @@ describe('Batch comments mutations', () => {
},
]);
});
+
+ it('sets `shouldAnimateReviewButton` to true if it is a first draft', () => {
+ mutations[types.ADD_NEW_DRAFT](state, draft);
+
+ expect(state.shouldAnimateReviewButton).toBe(true);
+ });
+
+ it('does not set `shouldAnimateReviewButton` to true if it is not a first draft', () => {
+ state.drafts.push({ id: 1 }, { id: 2 });
+ mutations[types.ADD_NEW_DRAFT](state, { id: 2, note: 'test2' });
+
+ expect(state.shouldAnimateReviewButton).toBe(false);
+ });
});
describe(types.DELETE_DRAFT, () => {
@@ -104,4 +116,14 @@ describe('Batch comments mutations', () => {
]);
});
});
+
+ describe(types.CLEAR_DRAFTS, () => {
+ it('clears drafts array', () => {
+ state.drafts.push({ id: 1 });
+
+ mutations[types.CLEAR_DRAFTS](state);
+
+ expect(state.drafts).toEqual([]);
+ });
+ });
});
diff --git a/spec/frontend/behaviors/components/diagram_performance_warning_spec.js b/spec/frontend/behaviors/components/diagram_performance_warning_spec.js
index c58c2bc55a9..7e6b20da4d4 100644
--- a/spec/frontend/behaviors/components/diagram_performance_warning_spec.js
+++ b/spec/frontend/behaviors/components/diagram_performance_warning_spec.js
@@ -11,10 +11,6 @@ describe('DiagramPerformanceWarning component', () => {
wrapper = shallowMount(DiagramPerformanceWarning);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders warning alert with button', () => {
expect(findAlert().props()).toMatchObject({
primaryButtonText: DiagramPerformanceWarning.i18n.buttonText,
diff --git a/spec/frontend/behaviors/components/json_table_spec.js b/spec/frontend/behaviors/components/json_table_spec.js
index 42b4a051d4d..ae62d28d6c0 100644
--- a/spec/frontend/behaviors/components/json_table_spec.js
+++ b/spec/frontend/behaviors/components/json_table_spec.js
@@ -12,6 +12,7 @@ const TEST_FIELDS = [
label: 'Second',
sortable: true,
other: 'foo',
+ class: 'someClass',
},
{
key: 'C',
@@ -59,10 +60,6 @@ describe('behaviors/components/json_table', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findTable = () => wrapper.findComponent(GlTable);
const findTableCaption = () => wrapper.findByTestId('slot-table-caption');
const findFilterInput = () => wrapper.findComponent(GlFormInput);
@@ -131,11 +128,13 @@ describe('behaviors/components/json_table', () => {
key: 'B',
label: 'Second',
sortable: true,
+ class: 'someClass',
},
{
key: 'C',
label: 'Third',
sortable: false,
+ class: [],
},
'D',
],
diff --git a/spec/frontend/behaviors/copy_to_clipboard_spec.js b/spec/frontend/behaviors/copy_to_clipboard_spec.js
index c5beaa0ba5d..74a396eb8cb 100644
--- a/spec/frontend/behaviors/copy_to_clipboard_spec.js
+++ b/spec/frontend/behaviors/copy_to_clipboard_spec.js
@@ -31,7 +31,7 @@ describe('initCopyToClipboard', () => {
const defaultButtonAttributes = {
'data-clipboard-text': 'foo bar',
title,
- 'data-title': title,
+ 'data-original-title': title,
};
const createButton = (attributes = {}) => {
const combinedAttributes = { ...defaultButtonAttributes, ...attributes };
diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js
index 722327e94ba..995e4219ae3 100644
--- a/spec/frontend/behaviors/gl_emoji_spec.js
+++ b/spec/frontend/behaviors/gl_emoji_spec.js
@@ -51,13 +51,13 @@ describe('gl_emoji', () => {
'bomb emoji just with name attribute',
'<gl-emoji data-name="bomb"></gl-emoji>',
'<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>',
- `<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="20" height="20" align="absmiddle"></gl-emoji>`,
+ `<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="16" height="16" align="absmiddle"></gl-emoji>`,
],
[
'bomb emoji with name attribute and unicode version',
'<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>',
'<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>',
- `<gl-emoji data-name="bomb" data-unicode-version="6.0"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="20" height="20" align="absmiddle"></gl-emoji>`,
+ `<gl-emoji data-name="bomb" data-unicode-version="6.0"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="16" height="16" align="absmiddle"></gl-emoji>`,
],
[
'bomb emoji with sprite fallback',
@@ -69,19 +69,19 @@ describe('gl_emoji', () => {
'bomb emoji with image fallback',
'<gl-emoji data-fallback-src="/bomb.png" data-name="bomb"></gl-emoji>',
'<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>',
- '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/bomb.png" width="20" height="20" align="absmiddle"></gl-emoji>',
+ '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/bomb.png" width="16" height="16" align="absmiddle"></gl-emoji>',
],
[
'invalid emoji',
'<gl-emoji data-name="invalid_emoji"></gl-emoji>',
'<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>',
- `<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>`,
+ `<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="16" height="16" align="absmiddle"></gl-emoji>`,
],
[
'custom emoji with image fallback',
'<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"></gl-emoji>',
- '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>',
- '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>',
+ '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="16" height="16" align="absmiddle"></gl-emoji>',
+ '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="16" height="16" align="absmiddle"></gl-emoji>',
],
])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => {
it(`renders correctly with emoji support`, async () => {
@@ -111,7 +111,7 @@ describe('gl_emoji', () => {
await waitForPromises();
expect(glEmojiElement.outerHTML).toBe(
- '<gl-emoji data-name="&quot;x=&quot;y&quot; onload=&quot;alert(document.location.href)&quot;" data-unicode-version="x"><img class="emoji" title=":&quot;x=&quot;y&quot; onload=&quot;alert(document.location.href)&quot;:" alt=":&quot;x=&quot;y&quot; onload=&quot;alert(document.location.href)&quot;:" src="/-/emojis/2/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>',
+ '<gl-emoji data-name="&quot;x=&quot;y&quot; onload=&quot;alert(document.location.href)&quot;" data-unicode-version="x"><img class="emoji" title=":&quot;x=&quot;y&quot; onload=&quot;alert(document.location.href)&quot;:" alt=":&quot;x=&quot;y&quot; onload=&quot;alert(document.location.href)&quot;:" src="/-/emojis/2/grey_question.png" width="16" height="16" align="absmiddle"></gl-emoji>',
);
});
diff --git a/spec/frontend/behaviors/markdown/highlight_current_user_spec.js b/spec/frontend/behaviors/markdown/highlight_current_user_spec.js
index 38d19ac3808..ad70efdf7c3 100644
--- a/spec/frontend/behaviors/markdown/highlight_current_user_spec.js
+++ b/spec/frontend/behaviors/markdown/highlight_current_user_spec.js
@@ -22,14 +22,9 @@ describe('highlightCurrentUser', () => {
describe('without current user', () => {
beforeEach(() => {
- window.gon = window.gon || {};
window.gon.current_user_id = null;
});
- afterEach(() => {
- delete window.gon.current_user_id;
- });
-
it('does not highlight the user', () => {
const initialHtml = rootElement.outerHTML;
@@ -41,14 +36,9 @@ describe('highlightCurrentUser', () => {
describe('with current user', () => {
beforeEach(() => {
- window.gon = window.gon || {};
window.gon.current_user_id = 2;
});
- afterEach(() => {
- delete window.gon.current_user_id;
- });
-
it('highlights current user', () => {
highlightCurrentUser(elements);
diff --git a/spec/frontend/behaviors/markdown/render_gfm_spec.js b/spec/frontend/behaviors/markdown/render_gfm_spec.js
index 0bbb92282e5..220ad874b47 100644
--- a/spec/frontend/behaviors/markdown/render_gfm_spec.js
+++ b/spec/frontend/behaviors/markdown/render_gfm_spec.js
@@ -1,4 +1,7 @@
import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import renderMetrics from '~/behaviors/markdown/render_metrics';
+
+jest.mock('~/behaviors/markdown/render_metrics');
describe('renderGFM', () => {
it('handles a missing element', () => {
@@ -6,4 +9,27 @@ describe('renderGFM', () => {
renderGFM();
}).not.toThrow();
});
+
+ describe('remove_monitor_metrics flag', () => {
+ let metricsElement;
+
+ beforeEach(() => {
+ window.gon = { features: { removeMonitorMetrics: true } };
+ metricsElement = document.createElement('div');
+ metricsElement.setAttribute('class', '.js-render-metrics');
+ });
+
+ it('renders metrics when the flag is disabled', () => {
+ window.gon.features = { features: { removeMonitorMetrics: false } };
+ renderGFM(metricsElement);
+
+ expect(renderMetrics).toHaveBeenCalled();
+ });
+
+ it('does not render metrics when the flag is enabled', () => {
+ renderGFM(metricsElement);
+
+ expect(renderMetrics).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/behaviors/markdown/render_observability_spec.js b/spec/frontend/behaviors/markdown/render_observability_spec.js
index 03a0cb2fcc2..f464c01ac15 100644
--- a/spec/frontend/behaviors/markdown/render_observability_spec.js
+++ b/spec/frontend/behaviors/markdown/render_observability_spec.js
@@ -1,46 +1,43 @@
+import Vue from 'vue';
+import { createWrapper } from '@vue/test-utils';
import renderObservability from '~/behaviors/markdown/render_observability';
-import * as ColorUtils from '~/lib/utils/color_utils';
+import { INLINE_EMBED_DIMENSIONS, SKELETON_VARIANT_EMBED } from '~/observability/constants';
+import ObservabilityApp from '~/observability/components/observability_app.vue';
-describe('Observability iframe renderer', () => {
- const findObservabilityIframes = (theme = 'light') =>
- document.querySelectorAll(`iframe[src="https://observe.gitlab.com/?theme=${theme}&kiosk"]`);
-
- const renderEmbeddedObservability = () => {
- renderObservability([...document.querySelectorAll('.js-render-observability')]);
- jest.runAllTimers();
- };
+describe('renderObservability', () => {
+ let subject;
beforeEach(() => {
- document.body.dataset.page = '';
- document.body.innerHTML = '';
+ subject = document.createElement('div');
+ subject.classList.add('js-render-observability');
+ subject.dataset.frameUrl = 'https://observe.gitlab.com/';
+ document.body.appendChild(subject);
});
- it('renders an observability iframe', () => {
- document.body.innerHTML = `<div class="js-render-observability" data-frame-url="https://observe.gitlab.com/" data-observability-url="https://observe.gitlab.com/" ></div>`;
-
- expect(findObservabilityIframes()).toHaveLength(0);
-
- renderEmbeddedObservability();
-
- expect(findObservabilityIframes()).toHaveLength(1);
+ afterEach(() => {
+ subject.remove();
});
- it('renders iframe with dark param when GL has dark theme', () => {
- document.body.innerHTML = `<div class="js-render-observability" data-frame-url="https://observe.gitlab.com/" data-observability-url="https://observe.gitlab.com/"></div>`;
- jest.spyOn(ColorUtils, 'darkModeEnabled').mockImplementation(() => true);
-
- expect(findObservabilityIframes('dark')).toHaveLength(0);
-
- renderEmbeddedObservability();
-
- expect(findObservabilityIframes('dark')).toHaveLength(1);
+ it('should return an array of Vue instances', () => {
+ const vueInstances = renderObservability([
+ ...document.querySelectorAll('.js-render-observability'),
+ ]);
+ expect(vueInstances).toEqual([expect.any(Vue)]);
});
- it('does not render if url is different from observability url', () => {
- document.body.innerHTML = `<div class="js-render-observability" data-frame-url="https://example.com/" data-observability-url="https://observe.gitlab.com/"></div>`;
+ it('should correctly pass props to the ObservabilityApp component', () => {
+ const vueInstances = renderObservability([
+ ...document.querySelectorAll('.js-render-observability'),
+ ]);
- renderEmbeddedObservability();
+ const wrapper = createWrapper(vueInstances[0]);
- expect(findObservabilityIframes()).toHaveLength(0);
+ expect(wrapper.findComponent(ObservabilityApp).props()).toMatchObject({
+ observabilityIframeSrc: 'https://observe.gitlab.com/',
+ skeletonVariant: SKELETON_VARIANT_EMBED,
+ inlineEmbed: true,
+ height: INLINE_EMBED_DIMENSIONS.HEIGHT,
+ width: INLINE_EMBED_DIMENSIONS.WIDTH,
+ });
});
});
diff --git a/spec/frontend/behaviors/quick_submit_spec.js b/spec/frontend/behaviors/quick_submit_spec.js
index 317c671cd2b..81eeb3f153e 100644
--- a/spec/frontend/behaviors/quick_submit_spec.js
+++ b/spec/frontend/behaviors/quick_submit_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlSnippetsShow from 'test_fixtures/snippets/show.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import '~/behaviors/quick_submit';
describe('Quick Submit behavior', () => {
@@ -8,7 +9,7 @@ describe('Quick Submit behavior', () => {
const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options);
beforeEach(() => {
- loadHTMLFixture('snippets/show.html');
+ setHTMLFixture(htmlSnippetsShow);
testContext = {};
@@ -60,22 +61,15 @@ describe('Quick Submit behavior', () => {
expect(testContext.spies.submit).not.toHaveBeenCalled();
});
- it('disables input of type submit', () => {
- const submitButton = $('.js-quick-submit input[type=submit]');
- testContext.textarea.trigger(keydownEvent());
-
- expect(submitButton).toBeDisabled();
- });
-
- it('disables button of type submit', () => {
- const submitButton = $('.js-quick-submit input[type=submit]');
+ it('disables submit', () => {
+ const submitButton = $('.js-quick-submit [type=submit]');
testContext.textarea.trigger(keydownEvent());
expect(submitButton).toBeDisabled();
});
it('only clicks one submit', () => {
- const existingSubmit = $('.js-quick-submit input[type=submit]');
+ const existingSubmit = $('.js-quick-submit [type=submit]');
// Add an extra submit button
const newSubmit = $('<button type="submit">Submit it</button>');
newSubmit.insertAfter(testContext.textarea);
diff --git a/spec/frontend/behaviors/requires_input_spec.js b/spec/frontend/behaviors/requires_input_spec.js
index f2f68f17d1c..68fa980216a 100644
--- a/spec/frontend/behaviors/requires_input_spec.js
+++ b/spec/frontend/behaviors/requires_input_spec.js
@@ -1,12 +1,13 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlNewBranch from 'test_fixtures/branches/new_branch.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import '~/behaviors/requires_input';
describe('requiresInput', () => {
let submitButton;
beforeEach(() => {
- loadHTMLFixture('branches/new_branch.html');
+ setHTMLFixture(htmlNewBranch);
submitButton = $('button[type="submit"]');
});
diff --git a/spec/frontend/behaviors/shortcuts/keybindings_spec.js b/spec/frontend/behaviors/shortcuts/keybindings_spec.js
index 1f7e1b24e78..65ef6a18864 100644
--- a/spec/frontend/behaviors/shortcuts/keybindings_spec.js
+++ b/spec/frontend/behaviors/shortcuts/keybindings_spec.js
@@ -7,7 +7,7 @@ import {
TOGGLE_PERFORMANCE_BAR,
HIDE_APPEARING_CONTENT,
LOCAL_STORAGE_KEY,
- WEB_IDE_COMMIT,
+ BOLD_TEXT,
} from '~/behaviors/shortcuts/keybindings';
describe('~/behaviors/shortcuts/keybindings', () => {
@@ -67,11 +67,11 @@ describe('~/behaviors/shortcuts/keybindings', () => {
const customization = ['mod+shift+c'];
beforeEach(() => {
- setupCustomizations(JSON.stringify({ [WEB_IDE_COMMIT.id]: customization }));
+ setupCustomizations(JSON.stringify({ [BOLD_TEXT.id]: customization }));
});
it('returns the default keybinding for the command', () => {
- expect(keysFor(WEB_IDE_COMMIT)).toEqual(['mod+enter']);
+ expect(keysFor(BOLD_TEXT)).toEqual(['mod+b']);
});
});
diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
index e6e587ff44b..ae7f5416c0c 100644
--- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
+++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlSnippetsShow from 'test_fixtures/snippets/show.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
@@ -11,8 +12,6 @@ jest.mock('~/lib/utils/common_utils', () => ({
}));
describe('ShortcutsIssuable', () => {
- const snippetShowFixtureName = 'snippets/show.html';
-
beforeAll(() => {
initCopyAsGFM();
@@ -24,7 +23,7 @@ describe('ShortcutsIssuable', () => {
const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
beforeEach(() => {
- loadHTMLFixture(snippetShowFixtureName);
+ setHTMLFixture(htmlSnippetsShow);
$('body').append(
`<div class="js-main-target-form">
<textarea class="js-vue-comment-form"></textarea>
diff --git a/spec/frontend/blame/blame_redirect_spec.js b/spec/frontend/blame/blame_redirect_spec.js
index beb10139b3a..5cd91ec5f1f 100644
--- a/spec/frontend/blame/blame_redirect_spec.js
+++ b/spec/frontend/blame/blame_redirect_spec.js
@@ -1,12 +1,11 @@
import redirectToCorrectPage from '~/blame/blame_redirect';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Blame page redirect', () => {
beforeEach(() => {
- global.window = Object.create(window);
const url = 'https://gitlab.com/flightjs/Flight/-/blame/master/file.json';
Object.defineProperty(window, 'location', {
writable: true,
diff --git a/spec/frontend/blame/streaming/index_spec.js b/spec/frontend/blame/streaming/index_spec.js
new file mode 100644
index 00000000000..e048ce3f70e
--- /dev/null
+++ b/spec/frontend/blame/streaming/index_spec.js
@@ -0,0 +1,110 @@
+import waitForPromises from 'helpers/wait_for_promises';
+import { renderBlamePageStreams } from '~/blame/streaming';
+import { setHTMLFixture } from 'helpers/fixtures';
+import { renderHtmlStreams } from '~/streaming/render_html_streams';
+import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests';
+import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link';
+import { toPolyfillReadable } from '~/streaming/polyfills';
+import { createAlert } from '~/alert';
+
+jest.mock('~/streaming/render_html_streams');
+jest.mock('~/streaming/rate_limit_stream_requests');
+jest.mock('~/streaming/handle_streamed_anchor_link');
+jest.mock('~/streaming/polyfills');
+jest.mock('~/sentry');
+jest.mock('~/alert');
+
+global.fetch = jest.fn();
+
+describe('renderBlamePageStreams', () => {
+ let stopAnchor;
+ const PAGES_URL = 'https://example.com/';
+ const findStreamContainer = () => document.querySelector('#blame-stream-container');
+ const findStreamLoadingIndicator = () => document.querySelector('#blame-stream-loading');
+
+ const setupHtml = (totalExtraPages = 0) => {
+ setHTMLFixture(`
+ <div id="blob-content-holder"
+ data-total-extra-pages="${totalExtraPages}"
+ data-pages-url="${PAGES_URL}"
+ ></div>
+ <div id="blame-stream-container"></div>
+ <div id="blame-stream-loading"></div>
+ `);
+ };
+
+ handleStreamedAnchorLink.mockImplementation(() => stopAnchor);
+ rateLimitStreamRequests.mockImplementation(({ factory, total }) => {
+ return Array.from({ length: total }, (_, i) => {
+ return Promise.resolve(factory(i));
+ });
+ });
+ toPolyfillReadable.mockImplementation((obj) => obj);
+
+ beforeEach(() => {
+ stopAnchor = jest.fn();
+ fetch.mockClear();
+ });
+
+ it('does nothing for an empty page', async () => {
+ await renderBlamePageStreams();
+
+ expect(handleStreamedAnchorLink).not.toHaveBeenCalled();
+ expect(renderHtmlStreams).not.toHaveBeenCalled();
+ });
+
+ it('renders a single stream', async () => {
+ let res;
+ const stream = new Promise((resolve) => {
+ res = resolve;
+ });
+ renderHtmlStreams.mockImplementationOnce(() => stream);
+ setupHtml();
+
+ renderBlamePageStreams(stream);
+
+ expect(handleStreamedAnchorLink).toHaveBeenCalledTimes(1);
+ expect(stopAnchor).toHaveBeenCalledTimes(0);
+ expect(renderHtmlStreams).toHaveBeenCalledWith([stream], findStreamContainer());
+ expect(findStreamLoadingIndicator()).not.toBe(null);
+
+ res();
+ await waitForPromises();
+
+ expect(stopAnchor).toHaveBeenCalledTimes(1);
+ expect(findStreamLoadingIndicator()).toBe(null);
+ });
+
+ it('renders rest of the streams', async () => {
+ const stream = Promise.resolve();
+ const stream2 = Promise.resolve({ body: null });
+ fetch.mockImplementationOnce(() => stream2);
+ setupHtml(1);
+
+ await renderBlamePageStreams(stream);
+
+ expect(fetch.mock.calls[0][0].toString()).toBe(`${PAGES_URL}?page=3`);
+ expect(renderHtmlStreams).toHaveBeenCalledWith([stream, stream2], findStreamContainer());
+ });
+
+ it('shows an error message when failed', async () => {
+ const stream = Promise.resolve();
+ const error = new Error();
+ renderHtmlStreams.mockImplementationOnce(() => Promise.reject(error));
+ setupHtml();
+
+ try {
+ await renderBlamePageStreams(stream);
+ } catch (err) {
+ expect(err).toBe(error);
+ }
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Blame could not be loaded as a single page.',
+ primaryButton: {
+ text: 'View blame as separate pages',
+ clickHandler: expect.any(Function),
+ },
+ });
+ });
+});
diff --git a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap
index a5690844053..1733c4d4bb4 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap
@@ -10,7 +10,7 @@ exports[`Blob Header Editing rendering matches the snapshot 1`] = `
<gl-form-input-stub
class="form-control js-snippet-file-name"
name="snippet_file_name"
- placeholder="Give your file a name to add code highlighting, e.g. example.rb for Ruby"
+ placeholder="File name (e.g. test.rb)"
type="text"
value="foo.md"
/>
diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
index fdbb9bdd0d0..4ae55f34e4c 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
@@ -22,7 +22,7 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = `
<clipboard-button-stub
category="tertiary"
- cssclass="btn-clipboard btn-transparent lh-100 position-static"
+ cssclass="gl-mr-2"
gfm="\`foo/bar/dummy.md\`"
size="medium"
text="foo/bar/dummy.md"
diff --git a/spec/frontend/blob/components/blob_content_error_spec.js b/spec/frontend/blob/components/blob_content_error_spec.js
index 0f5885c2acf..203fab94a5c 100644
--- a/spec/frontend/blob/components/blob_content_error_spec.js
+++ b/spec/frontend/blob/components/blob_content_error_spec.js
@@ -18,10 +18,6 @@ describe('Blob Content Error component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('collapsed and too large blobs', () => {
it.each`
error | reason | options
diff --git a/spec/frontend/blob/components/blob_content_spec.js b/spec/frontend/blob/components/blob_content_spec.js
index f7b819b6e94..91af5f7bfed 100644
--- a/spec/frontend/blob/components/blob_content_spec.js
+++ b/spec/frontend/blob/components/blob_content_spec.js
@@ -29,10 +29,6 @@ describe('Blob Content component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('rendering', () => {
it('renders loader if `loading: true`', () => {
createComponent({ loading: true });
diff --git a/spec/frontend/blob/components/blob_edit_header_spec.js b/spec/frontend/blob/components/blob_edit_header_spec.js
index c84b5896348..b0ce5f40d95 100644
--- a/spec/frontend/blob/components/blob_edit_header_spec.js
+++ b/spec/frontend/blob/components/blob_edit_header_spec.js
@@ -1,6 +1,5 @@
import { GlFormInput, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import BlobEditHeader from '~/blob/components/blob_edit_header.vue';
describe('Blob Header Editing', () => {
@@ -15,24 +14,22 @@ describe('Blob Header Editing', () => {
},
});
};
+
const findDeleteButton = () =>
wrapper.findAllComponents(GlButton).wrappers.find((x) => x.text() === 'Delete file');
+ const findFormInput = () => wrapper.findComponent(GlFormInput);
beforeEach(() => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('rendering', () => {
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('contains a form input field', () => {
- expect(wrapper.findComponent(GlFormInput).exists()).toBe(true);
+ expect(findFormInput().exists()).toBe(true);
});
it('does not show delete button', () => {
@@ -41,19 +38,16 @@ describe('Blob Header Editing', () => {
});
describe('functionality', () => {
- it('emits input event when the blob name is changed', async () => {
- const inputComponent = wrapper.findComponent(GlFormInput);
+ it('emits input event when the blob name is changed', () => {
+ const inputComponent = findFormInput();
const newValue = 'bar.txt';
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- name: newValue,
- });
+ // update `name` with `newValue`
+ inputComponent.vm.$emit('input', newValue);
+ // trigger change event which emits input event on wrapper
inputComponent.vm.$emit('change');
- await nextTick();
- expect(wrapper.emitted().input[0]).toEqual([newValue]);
+ expect(wrapper.emitted().input).toEqual([[newValue]]);
});
});
diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js
index 0f015715dc2..4c8c256121f 100644
--- a/spec/frontend/blob/components/blob_header_default_actions_spec.js
+++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js
@@ -34,10 +34,6 @@ describe('Blob Header Default Actions', () => {
buttons = wrapper.findAllComponents(GlButton);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('renders', () => {
const findCopyButton = () => wrapper.findByTestId('copyContentsButton');
const findViewRawButton = () => wrapper.findByTestId('viewRawButton');
@@ -49,7 +45,7 @@ describe('Blob Header Default Actions', () => {
it('exactly 3 buttons with predefined actions', () => {
expect(buttons.length).toBe(3);
[BTN_COPY_CONTENTS_TITLE, BTN_RAW_TITLE, BTN_DOWNLOAD_TITLE].forEach((title, i) => {
- expect(buttons.at(i).vm.$el.title).toBe(title);
+ expect(buttons.at(i).attributes('title')).toBe(title);
});
});
@@ -71,7 +67,7 @@ describe('Blob Header Default Actions', () => {
});
buttons = wrapper.findAllComponents(GlButton);
- expect(buttons.at(0).attributes('disabled')).toBe('true');
+ expect(buttons.at(0).attributes('disabled')).toBeDefined();
});
it('does not render the copy button if a rendering error is set', () => {
@@ -91,10 +87,9 @@ describe('Blob Header Default Actions', () => {
it('emits a copy event if overrideCopy is set to true', () => {
createComponent({ overrideCopy: true });
- jest.spyOn(wrapper.vm, '$emit');
findCopyButton().vm.$emit('click');
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('copy');
+ expect(wrapper.emitted('copy')).toHaveLength(1);
});
});
diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js
index 8c32cba1ba4..be49146ff8a 100644
--- a/spec/frontend/blob/components/blob_header_filepath_spec.js
+++ b/spec/frontend/blob/components/blob_header_filepath_spec.js
@@ -21,10 +21,6 @@ describe('Blob Header Filepath', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findBadge = () => wrapper.findComponent(GlBadge);
describe('rendering', () => {
diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js
index 46740958090..47e09bb38bc 100644
--- a/spec/frontend/blob/components/blob_header_spec.js
+++ b/spec/frontend/blob/components/blob_header_spec.js
@@ -1,9 +1,14 @@
import { shallowMount, mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import BlobHeader from '~/blob/components/blob_header.vue';
import DefaultActions from '~/blob/components/blob_header_default_actions.vue';
import BlobFilepath from '~/blob/components/blob_header_filepath.vue';
import ViewerSwitcher from '~/blob/components/blob_header_viewer_switcher.vue';
+import {
+ RICH_BLOB_VIEWER_TITLE,
+ SIMPLE_BLOB_VIEWER,
+ SIMPLE_BLOB_VIEWER_TITLE,
+} from '~/blob/components/constants';
import TableContents from '~/blob/components/table_contents.vue';
import { Blob } from './mock_data';
@@ -11,12 +16,26 @@ import { Blob } from './mock_data';
describe('Blob Header Default Actions', () => {
let wrapper;
- function createComponent(blobProps = {}, options = {}, propsData = {}, shouldMount = false) {
- const method = shouldMount ? mount : shallowMount;
- const blobHash = 'foo-bar';
- wrapper = method.call(this, BlobHeader, {
+ const defaultProvide = {
+ blobHash: 'foo-bar',
+ };
+
+ const findDefaultActions = () => wrapper.findComponent(DefaultActions);
+ const findTableContents = () => wrapper.findComponent(TableContents);
+ const findViewSwitcher = () => wrapper.findComponent(ViewerSwitcher);
+ const findBlobFilePath = () => wrapper.findComponent(BlobFilepath);
+ const findRichTextEditorBtn = () => wrapper.findByLabelText(RICH_BLOB_VIEWER_TITLE);
+ const findSimpleTextEditorBtn = () => wrapper.findByLabelText(SIMPLE_BLOB_VIEWER_TITLE);
+
+ function createComponent({
+ blobProps = {},
+ options = {},
+ propsData = {},
+ mountFn = shallowMount,
+ } = {}) {
+ wrapper = mountFn(BlobHeader, {
provide: {
- blobHash,
+ ...defaultProvide,
},
propsData: {
blob: { ...Blob, ...blobProps },
@@ -26,143 +45,123 @@ describe('Blob Header Default Actions', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('rendering', () => {
- const findDefaultActions = () => wrapper.findComponent(DefaultActions);
-
- const slots = {
- prepend: 'Foo Prepend',
- actions: 'Actions Bar',
- };
-
it('matches the snapshot', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
- it('renders all components', () => {
- createComponent();
- expect(wrapper.findComponent(TableContents).exists()).toBe(true);
- expect(wrapper.findComponent(ViewerSwitcher).exists()).toBe(true);
- expect(findDefaultActions().exists()).toBe(true);
- expect(wrapper.findComponent(BlobFilepath).exists()).toBe(true);
+ describe('default render', () => {
+ it.each`
+ findComponent | componentName
+ ${findTableContents} | ${'TableContents'}
+ ${findViewSwitcher} | ${'ViewSwitcher'}
+ ${findDefaultActions} | ${'DefaultActions'}
+ ${findBlobFilePath} | ${'BlobFilePath'}
+ `('renders $componentName component by default', ({ findComponent }) => {
+ createComponent();
+
+ expect(findComponent().exists()).toBe(true);
+ });
});
it('does not render viewer switcher if the blob has only the simple viewer', () => {
createComponent({
- richViewer: null,
+ blobProps: {
+ richViewer: null,
+ },
});
- expect(wrapper.findComponent(ViewerSwitcher).exists()).toBe(false);
+ expect(findViewSwitcher().exists()).toBe(false);
});
it('does not render viewer switcher if a corresponding prop is passed', () => {
- createComponent(
- {},
- {},
- {
+ createComponent({
+ propsData: {
hideViewerSwitcher: true,
},
- );
- expect(wrapper.findComponent(ViewerSwitcher).exists()).toBe(false);
+ });
+ expect(findViewSwitcher().exists()).toBe(false);
});
it('does not render default actions is corresponding prop is passed', () => {
- createComponent(
- {},
- {},
- {
+ createComponent({
+ propsData: {
hideDefaultActions: true,
},
- );
- expect(wrapper.findComponent(DefaultActions).exists()).toBe(false);
+ });
+ expect(findDefaultActions().exists()).toBe(false);
});
- Object.keys(slots).forEach((slot) => {
- it('renders the slots', () => {
- const slotContent = slots[slot];
- createComponent(
- {},
- {
- scopedSlots: {
- [slot]: `<span>${slotContent}</span>`,
- },
+ it.each`
+ slotContent | key
+ ${'Foo Prepend'} | ${'prepend'}
+ ${'Actions Bar'} | ${'actions'}
+ `('renders the slot $key', ({ key, slotContent }) => {
+ createComponent({
+ options: {
+ scopedSlots: {
+ [key]: `<span>${slotContent}</span>`,
},
- {},
- true,
- );
- expect(wrapper.text()).toContain(slotContent);
+ },
+ mountFn: mount,
});
+ expect(wrapper.text()).toContain(slotContent);
});
it('passes information about render error down to default actions', () => {
- createComponent(
- {},
- {},
- {
+ createComponent({
+ propsData: {
hasRenderError: true,
},
- );
+ });
expect(findDefaultActions().props('hasRenderError')).toBe(true);
});
it('passes the correct isBinary value to default actions when viewing a binary file', () => {
- createComponent({}, {}, { isBinary: true });
+ createComponent({ propsData: { isBinary: true } });
expect(findDefaultActions().props('isBinary')).toBe(true);
});
});
describe('functionality', () => {
- const newViewer = 'Foo Bar';
- const activeViewerType = 'Alpha Beta';
-
const factory = (hideViewerSwitcher = false) => {
- createComponent(
- {},
- {},
- {
- activeViewerType,
+ createComponent({
+ propsData: {
+ activeViewerType: SIMPLE_BLOB_VIEWER,
hideViewerSwitcher,
},
- );
+ mountFn: mountExtended,
+ });
};
- it('by default sets viewer data based on activeViewerType', () => {
+ it('shows the correctly selected view by default', () => {
factory();
- expect(wrapper.vm.viewer).toBe(activeViewerType);
+
+ expect(findViewSwitcher().exists()).toBe(true);
+ expect(findRichTextEditorBtn().props().selected).toBe(false);
+ expect(findSimpleTextEditorBtn().props().selected).toBe(true);
});
- it('sets viewer to null if the viewer switcher should be hidden', () => {
+ it('Does not show the viewer switcher should be hidden', () => {
factory(true);
- expect(wrapper.vm.viewer).toBe(null);
+
+ expect(findViewSwitcher().exists()).toBe(false);
});
it('watches the changes in viewer data and emits event when the change is registered', async () => {
factory();
- jest.spyOn(wrapper.vm, '$emit');
- wrapper.vm.viewer = newViewer;
- await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('viewer-changed', newViewer);
- });
-
- it('does not emit event if the switcher is not rendered', async () => {
- factory(true);
-
- expect(wrapper.vm.showViewerSwitcher).toBe(false);
- jest.spyOn(wrapper.vm, '$emit');
- wrapper.vm.viewer = newViewer;
+ await findRichTextEditorBtn().trigger('click');
- await nextTick();
- expect(wrapper.vm.$emit).not.toHaveBeenCalled();
+ expect(wrapper.emitted('viewer-changed')).toBeDefined();
});
it('sets different icons depending on the blob file type', async () => {
factory();
- expect(wrapper.vm.blobSwitcherDocIcon).toBe('document');
+
+ expect(findViewSwitcher().props('docIcon')).toBe('document');
+
await wrapper.setProps({
blob: {
...Blob,
@@ -172,7 +171,8 @@ describe('Blob Header Default Actions', () => {
},
},
});
- expect(wrapper.vm.blobSwitcherDocIcon).toBe('table');
+
+ expect(findViewSwitcher().props('docIcon')).toBe('table');
});
});
});
diff --git a/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js b/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js
index 1eac0733646..2ef87f6664b 100644
--- a/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js
+++ b/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js
@@ -18,14 +18,14 @@ describe('Blob Header Viewer Switcher', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
+ const findSimpleViewerButton = () => wrapper.findComponent('[data-viewer="simple"]');
+ const findRichViewerButton = () => wrapper.findComponent('[data-viewer="rich"]');
describe('intiialization', () => {
it('is initialized with simple viewer as active', () => {
createComponent();
- expect(wrapper.vm.value).toBe(SIMPLE_BLOB_VIEWER);
+ expect(findSimpleViewerButton().props('selected')).toBe(true);
+ expect(findRichViewerButton().props('selected')).toBe(false);
});
});
@@ -52,45 +52,34 @@ describe('Blob Header Viewer Switcher', () => {
});
describe('viewer changes', () => {
- let buttons;
- let simpleBtn;
- let richBtn;
+ it('does not switch the viewer if the selected one is already active', async () => {
+ createComponent();
+ expect(findSimpleViewerButton().props('selected')).toBe(true);
- function factory(propsData = {}) {
- createComponent(propsData);
- buttons = wrapper.findAllComponents(GlButton);
- simpleBtn = buttons.at(0);
- richBtn = buttons.at(1);
-
- jest.spyOn(wrapper.vm, '$emit');
- }
-
- it('does not switch the viewer if the selected one is already active', () => {
- factory();
- expect(wrapper.vm.value).toBe(SIMPLE_BLOB_VIEWER);
- simpleBtn.vm.$emit('click');
- expect(wrapper.vm.value).toBe(SIMPLE_BLOB_VIEWER);
- expect(wrapper.vm.$emit).not.toHaveBeenCalled();
+ findSimpleViewerButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findSimpleViewerButton().props('selected')).toBe(true);
+ expect(wrapper.emitted('input')).toBe(undefined);
});
it('emits an event when a Rich Viewer button is clicked', async () => {
- factory();
- expect(wrapper.vm.value).toBe(SIMPLE_BLOB_VIEWER);
-
- richBtn.vm.$emit('click');
+ createComponent();
+ expect(findSimpleViewerButton().props('selected')).toBe(true);
+ findRichViewerButton().vm.$emit('click');
await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', RICH_BLOB_VIEWER);
+
+ expect(wrapper.emitted('input')).toEqual([[RICH_BLOB_VIEWER]]);
});
it('emits an event when a Simple Viewer button is clicked', async () => {
- factory({
- value: RICH_BLOB_VIEWER,
- });
- simpleBtn.vm.$emit('click');
+ createComponent({ value: RICH_BLOB_VIEWER });
+ findSimpleViewerButton().vm.$emit('click');
await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', SIMPLE_BLOB_VIEWER);
+
+ expect(wrapper.emitted('input')).toEqual([[SIMPLE_BLOB_VIEWER]]);
});
});
});
diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js
index b5803bf0cbc..6ecf5091591 100644
--- a/spec/frontend/blob/components/mock_data.js
+++ b/spec/frontend/blob/components/mock_data.js
@@ -47,11 +47,13 @@ export const BinaryBlob = {
};
export const RichBlobContentMock = {
+ __typename: 'Blob',
path: 'foo.md',
richData: '<h1>Rich</h1>',
};
export const SimpleBlobContentMock = {
+ __typename: 'Blob',
path: 'foo.js',
plainData: 'Plain',
};
diff --git a/spec/frontend/blob/components/table_contents_spec.js b/spec/frontend/blob/components/table_contents_spec.js
index 6af9cdcae7d..acfcef9704c 100644
--- a/spec/frontend/blob/components/table_contents_spec.js
+++ b/spec/frontend/blob/components/table_contents_spec.js
@@ -31,7 +31,6 @@ describe('Markdown table of contents component', () => {
});
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
diff --git a/spec/frontend/blob/csv/csv_viewer_spec.js b/spec/frontend/blob/csv/csv_viewer_spec.js
index 9364f76da5e..8f105f04aa7 100644
--- a/spec/frontend/blob/csv/csv_viewer_spec.js
+++ b/spec/frontend/blob/csv/csv_viewer_spec.js
@@ -29,10 +29,6 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAlert = () => wrapper.findComponent(PapaParseAlert);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render loading spinner', () => {
createComponent();
diff --git a/spec/frontend/blob/file_template_selector_spec.js b/spec/frontend/blob/file_template_selector_spec.js
index 65444e86efd..123475f8d62 100644
--- a/spec/frontend/blob/file_template_selector_spec.js
+++ b/spec/frontend/blob/file_template_selector_spec.js
@@ -53,7 +53,7 @@ describe('FileTemplateSelector', () => {
expect(subject.wrapper.classList.contains('hidden')).toBe(false);
});
- it('sets the focus on the dropdown', async () => {
+ it('sets the focus on the dropdown', () => {
subject.show();
jest.spyOn(subject.dropdown, 'focus');
jest.runAllTimers();
diff --git a/spec/frontend/blob/line_highlighter_spec.js b/spec/frontend/blob/line_highlighter_spec.js
index 21d4e8db503..b2e1a29b84f 100644
--- a/spec/frontend/blob/line_highlighter_spec.js
+++ b/spec/frontend/blob/line_highlighter_spec.js
@@ -1,7 +1,7 @@
/* eslint-disable no-return-assign, no-new, no-underscore-dangle */
-
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlStaticLineHighlighter from 'test_fixtures_static/line_highlighter.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import LineHighlighter from '~/blob/line_highlighter';
import * as utils from '~/lib/utils/common_utils';
@@ -17,7 +17,7 @@ describe('LineHighlighter', () => {
};
beforeEach(() => {
- loadHTMLFixture('static/line_highlighter.html');
+ setHTMLFixture(htmlStaticLineHighlighter);
testContext.class = new LineHighlighter();
testContext.css = testContext.class.highlightLineClass;
return (testContext.spies = {
diff --git a/spec/frontend/blob/notebook/notebook_viever_spec.js b/spec/frontend/blob/notebook/notebook_viever_spec.js
index 2e7eadc912d..97b32a42afe 100644
--- a/spec/frontend/blob/notebook/notebook_viever_spec.js
+++ b/spec/frontend/blob/notebook/notebook_viever_spec.js
@@ -42,8 +42,6 @@ describe('iPython notebook renderer', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
mock.restore();
});
diff --git a/spec/frontend/blob/pdf/pdf_viewer_spec.js b/spec/frontend/blob/pdf/pdf_viewer_spec.js
index 23227df6357..19d404f504b 100644
--- a/spec/frontend/blob/pdf/pdf_viewer_spec.js
+++ b/spec/frontend/blob/pdf/pdf_viewer_spec.js
@@ -26,11 +26,6 @@ describe('PDF renderer', () => {
mountComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('shows loading icon', () => {
expect(findLoading().exists()).toBe(true);
});
diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
index 81b38cfc278..84efa6041e4 100644
--- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js
+++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
@@ -38,7 +38,6 @@ describe('PipelineTourSuccessModal', () => {
});
afterEach(() => {
- wrapper.destroy();
unmockTracking();
Cookies.remove(modalProps.commitCookie);
});
diff --git a/spec/frontend/blob/sketch/index_spec.js b/spec/frontend/blob/sketch/index_spec.js
index 4b6cb79791c..64b6152a07d 100644
--- a/spec/frontend/blob/sketch/index_spec.js
+++ b/spec/frontend/blob/sketch/index_spec.js
@@ -1,10 +1,11 @@
import SketchLoader from '~/blob/sketch';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
+import htmlSketchViewer from 'test_fixtures_static/sketch_viewer.html';
describe('Sketch viewer', () => {
beforeEach(() => {
- loadHTMLFixture('static/sketch_viewer.html');
+ setHTMLFixture(htmlSketchViewer);
});
afterEach(() => {
diff --git a/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js b/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js
index 6b329dc078a..b30b0287a34 100644
--- a/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js
+++ b/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js
@@ -36,11 +36,6 @@ describe('Suggest gitlab-ci.yml Popover', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when no dismiss cookie is set', () => {
beforeEach(() => {
createWrapper(defaultTrackLabel);
diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js
index ed42322b0e6..6a7ca3288cb 100644
--- a/spec/frontend/blob_edit/blob_bundle_spec.js
+++ b/spec/frontend/blob_edit/blob_bundle_spec.js
@@ -5,12 +5,19 @@ import waitForPromises from 'helpers/wait_for_promises';
import blobBundle from '~/blob_edit/blob_bundle';
import SourceEditor from '~/blob_edit/edit_blob';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
jest.mock('~/blob_edit/edit_blob');
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('BlobBundle', () => {
+ beforeAll(() => {
+ // HACK: Workaround readonly property in Jest
+ Object.defineProperty(window, 'onbeforeunload', {
+ writable: true,
+ });
+ });
+
it('does not load SourceEditor by default', () => {
blobBundle();
expect(SourceEditor).not.toHaveBeenCalled();
diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js
index dda46e97b85..9ab20fc2cd7 100644
--- a/spec/frontend/blob_edit/edit_blob_spec.js
+++ b/spec/frontend/blob_edit/edit_blob_spec.js
@@ -20,9 +20,9 @@ jest.mock('~/editor/extensions/source_editor_toolbar_ext');
const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
const defaultExtensions = [
+ { definition: ToolbarExtension },
{ definition: SourceEditorExtension },
{ definition: FileTemplateExtension },
- { definition: ToolbarExtension },
];
const markdownExtensions = [
{ definition: EditorMarkdownExtension },
diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js
index 1e823e3321a..a925f752f5e 100644
--- a/spec/frontend/boards/board_card_inner_spec.js
+++ b/spec/frontend/boards/board_card_inner_spec.js
@@ -84,7 +84,7 @@ describe('Board card component', () => {
BoardCardMoveToPosition: true,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
provide: {
rootPath: '/',
@@ -110,8 +110,6 @@ describe('Board card component', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
store = null;
jest.clearAllMocks();
});
@@ -170,7 +168,7 @@ describe('Board card component', () => {
});
describe('blocked', () => {
- it('renders blocked icon if issue is blocked', async () => {
+ it('renders blocked icon if issue is blocked', () => {
createWrapper({
props: {
item: {
@@ -314,10 +312,6 @@ describe('Board card component', () => {
});
});
- 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(
@@ -493,7 +487,7 @@ describe('Board card component', () => {
});
describe('loading', () => {
- it('renders loading icon', async () => {
+ it('renders loading icon', () => {
createWrapper({
props: {
item: {
diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js
index d882ff071b7..43cf6ead1c1 100644
--- a/spec/frontend/boards/board_list_helper.js
+++ b/spec/frontend/boards/board_list_helper.js
@@ -92,6 +92,7 @@ export default function createComponent({
boardItems: [issue],
canAdminList: true,
boardId: 'gid://gitlab/Board/1',
+ filterParams: {},
...componentProps,
},
provide: {
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index fc8dbf8dc3a..e0a110678b1 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -1,3 +1,4 @@
+import { GlIntersectionObserver } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { nextTick } from 'vue';
import { DraggableItemTypes, ListType } from 'ee_else_ce/boards/constants';
@@ -8,15 +9,18 @@ import BoardCard from '~/boards/components/board_card.vue';
import eventHub from '~/boards/eventhub';
import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
-import { mockIssues } from './mock_data';
+import { mockIssues, mockList, mockIssuesMore } from './mock_data';
describe('Board list component', () => {
let wrapper;
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
- const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]');
const findDraggable = () => wrapper.findComponent(Draggable);
const findMoveToPositionComponent = () => wrapper.findComponent(BoardCardMoveToPosition);
+ const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
+ const findBoardListCount = () => wrapper.find('.board-list-count');
+
+ const triggerInfiniteScroll = () => findIntersectionObserver().vm.$emit('appear');
const startDrag = (
params = {
@@ -36,10 +40,6 @@ describe('Board list component', () => {
useFakeRequestAnimationFrame();
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('When Expanded', () => {
beforeEach(() => {
wrapper = createComponent({ issuesCount: 1 });
@@ -65,41 +65,25 @@ describe('Board list component', () => {
expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('1');
});
- it('shows new issue form', async () => {
- wrapper.vm.toggleForm();
-
- await nextTick();
- expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
- });
-
it('shows new issue form after eventhub event', async () => {
- eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`);
+ eventHub.$emit(`toggle-issue-form-${mockList.id}`);
await nextTick();
expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
});
- it('does not show new issue form for closed list', () => {
- wrapper.setProps({ list: { type: 'closed' } });
- wrapper.vm.toggleForm();
-
- expect(wrapper.find('.board-new-issue-form').exists()).toBe(false);
- });
-
- it('shows count list item', async () => {
- wrapper.vm.showCount = true;
-
- await nextTick();
- expect(wrapper.find('.board-list-count').exists()).toBe(true);
-
- expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues');
- });
+ it('does not show new issue form for closed list', async () => {
+ wrapper = createComponent({
+ listProps: {
+ listType: ListType.closed,
+ },
+ });
+ await waitForPromises();
- it('sets data attribute with invalid id', async () => {
- wrapper.vm.showCount = true;
+ eventHub.$emit(`toggle-issue-form-${mockList.id}`);
await nextTick();
- expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1');
+ expect(wrapper.find('.board-new-issue-form').exists()).toBe(false);
});
it('renders the move to position icon', () => {
@@ -122,61 +106,41 @@ describe('Board list component', () => {
});
describe('load more issues', () => {
- const actions = {
- fetchItemsForList: jest.fn(),
- };
-
- it('does not load issues if already loading', () => {
- wrapper = createComponent({
- actions,
- state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
+ describe('when loading is not in progress', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ listProps: {
+ id: 'gid://gitlab/List/1',
+ },
+ componentProps: {
+ boardItems: mockIssuesMore,
+ },
+ actions: {
+ fetchItemsForList: jest.fn(),
+ },
+ state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: false } } },
+ });
});
- wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
-
- expect(actions.fetchItemsForList).not.toHaveBeenCalled();
- });
- it('shows loading more spinner', async () => {
- wrapper = createComponent({
- state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
- data: {
- showCount: true,
- },
+ it('has intersection observer when the number of board list items are more than 5', () => {
+ expect(findIntersectionObserver().exists()).toBe(true);
});
- await nextTick();
-
- expect(findIssueCountLoadingIcon().exists()).toBe(true);
- });
-
- it('shows how many more issues to load', async () => {
- wrapper = createComponent({
- data: {
- showCount: true,
- },
+ it('shows count when loaded more items and correct data attribute', async () => {
+ triggerInfiniteScroll();
+ await waitForPromises();
+ expect(findBoardListCount().exists()).toBe(true);
+ expect(findBoardListCount().attributes('data-issue-id')).toBe('-1');
});
-
- await nextTick();
- await waitForPromises();
- await nextTick();
- await nextTick();
-
- expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
});
});
describe('max issue count warning', () => {
- beforeEach(() => {
- wrapper = createComponent({
- listProps: { issuesCount: 50 },
- });
- });
-
describe('when issue count exceeds max issue count', () => {
it('sets background to gl-bg-red-100', async () => {
- wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } });
+ wrapper = createComponent({ listProps: { issuesCount: 4, maxIssueCount: 3 } });
- await nextTick();
+ await waitForPromises();
const block = wrapper.find('.gl-bg-red-100');
expect(block.exists()).toBe(true);
@@ -187,16 +151,18 @@ describe('Board list component', () => {
});
describe('when list issue count does NOT exceed list max issue count', () => {
- it('does not sets background to gl-bg-red-100', () => {
- wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } });
+ it('does not sets background to gl-bg-red-100', async () => {
+ wrapper = createComponent({ list: { issuesCount: 2, maxIssueCount: 3 } });
+ await waitForPromises();
expect(wrapper.find('.gl-bg-red-100').exists()).toBe(false);
});
});
describe('when list max issue count is 0', () => {
- it('does not sets background to gl-bg-red-100', () => {
- wrapper.setProps({ list: { maxIssueCount: 0 } });
+ it('does not sets background to gl-bg-red-100', async () => {
+ wrapper = createComponent({ list: { maxIssueCount: 0 } });
+ await waitForPromises();
expect(wrapper.find('.gl-bg-red-100').exists()).toBe(false);
});
diff --git a/spec/frontend/boards/components/board_add_new_column_form_spec.js b/spec/frontend/boards/components/board_add_new_column_form_spec.js
index 0b3c6cb24c4..4fc9a6859a6 100644
--- a/spec/frontend/boards/components/board_add_new_column_form_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_form_spec.js
@@ -1,15 +1,13 @@
-import { GlDropdown, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import defaultState from '~/boards/stores/state';
import { mockLabelList } from '../mock_data';
Vue.use(Vuex);
-describe('Board card layout', () => {
+describe('BoardAddNewColumnForm', () => {
let wrapper;
const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
@@ -23,56 +21,30 @@ describe('Board card layout', () => {
});
};
- const mountComponent = ({
- loading = false,
- noneSelected = '',
- searchLabel = '',
- searchPlaceholder = '',
- selectedId,
- actions,
- slots,
- } = {}) => {
- wrapper = extendedWrapper(
- shallowMount(BoardAddNewColumnForm, {
- propsData: {
- loading,
- noneSelected,
- searchLabel,
- searchPlaceholder,
- selectedId,
- },
- slots,
- store: createStore({
- actions: {
- setAddColumnFormVisibility: jest.fn(),
- ...actions,
- },
- }),
- stubs: {
- GlDropdown,
+ const mountComponent = ({ searchLabel = '', selectedIdValid = true, actions, slots } = {}) => {
+ wrapper = shallowMountExtended(BoardAddNewColumnForm, {
+ propsData: {
+ searchLabel,
+ selectedIdValid,
+ },
+ slots,
+ store: createStore({
+ actions: {
+ setAddColumnFormVisibility: jest.fn(),
+ ...actions,
},
}),
- );
+ });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const formTitle = () => wrapper.findByTestId('board-add-column-form-title').text();
- const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
- const findSearchLabelFormGroup = () => wrapper.findComponent(GlFormGroup);
const cancelButton = () => wrapper.findByTestId('cancelAddNewColumn');
const submitButton = () => wrapper.findByTestId('addNewColumnButton');
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- it('shows form title & search input', () => {
+ it('shows form title', () => {
mountComponent();
- findDropdown().vm.$emit('show');
-
expect(formTitle()).toEqual(BoardAddNewColumnForm.i18n.newList);
- expect(findSearchInput().exists()).toBe(true);
});
it('clicking cancel hides the form', () => {
@@ -88,61 +60,6 @@ describe('Board card layout', () => {
expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false);
});
- describe('items', () => {
- const mountWithItems = (loading) =>
- mountComponent({
- loading,
- slots: {
- items: '<div class="item-slot">Some kind of list</div>',
- },
- });
-
- it('hides items slot and shows skeleton while loading', () => {
- mountWithItems(true);
-
- expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
- expect(wrapper.find('.item-slot').exists()).toBe(false);
- });
-
- it('shows items slot and hides skeleton while not loading', () => {
- mountWithItems(false);
-
- expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
- expect(wrapper.find('.item-slot').exists()).toBe(true);
- });
- });
-
- describe('search box', () => {
- it('sets label and placeholder text from props', () => {
- const props = {
- searchLabel: 'Some items',
- searchPlaceholder: 'Search for an item',
- };
-
- mountComponent(props);
-
- expect(findSearchLabelFormGroup().attributes('label')).toEqual(props.searchLabel);
- expect(findSearchInput().attributes('placeholder')).toEqual(props.searchPlaceholder);
- });
-
- it('does not show the dropdown as invalid by default', () => {
- mountComponent();
-
- expect(findSearchLabelFormGroup().attributes('state')).toBe('true');
- expect(findDropdown().props('toggleClass')).not.toContain('gl-inset-border-1-red-400!');
- });
-
- it('emits filter event on input', () => {
- mountComponent();
-
- const searchText = 'some text';
-
- findSearchInput().vm.$emit('input', searchText);
-
- expect(wrapper.emitted('filter-items')).toEqual([[searchText]]);
- });
- });
-
describe('Add list button', () => {
it('is enabled by default', () => {
mountComponent();
@@ -159,16 +76,5 @@ describe('Board card layout', () => {
expect(wrapper.emitted('add-list')).toEqual([[]]);
});
-
- it('does not emit the add-list event on click and shows the dropdown as invalid when no ID is selected', async () => {
- mountComponent();
-
- await submitButton().vm.$emit('click');
-
- expect(findSearchLabelFormGroup().attributes('state')).toBeUndefined();
- expect(findDropdown().props('toggleClass')).toContain('gl-inset-border-1-red-400!');
-
- expect(wrapper.emitted('add-list')).toBeUndefined();
- });
});
});
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 a3b2988ce75..a09c3aaa55e 100644
--- a/spec/frontend/boards/components/board_add_new_column_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_spec.js
@@ -1,8 +1,7 @@
-import { GlFormRadioGroup } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardAddNewColumn from '~/boards/components/board_add_new_column.vue';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import defaultState from '~/boards/stores/state';
@@ -13,8 +12,9 @@ Vue.use(Vuex);
describe('Board card layout', () => {
let wrapper;
+ const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
const selectLabel = (id) => {
- wrapper.findComponent(GlFormRadioGroup).vm.$emit('change', id);
+ findDropdown().vm.$emit('select', id);
};
const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
@@ -34,33 +34,34 @@ describe('Board card layout', () => {
getListByLabelId = jest.fn(),
actions = {},
} = {}) => {
- wrapper = extendedWrapper(
- shallowMount(BoardAddNewColumn, {
- data() {
- return {
- selectedId,
- };
+ wrapper = shallowMountExtended(BoardAddNewColumn, {
+ data() {
+ return {
+ selectedId,
+ };
+ },
+ store: createStore({
+ actions: {
+ fetchLabels: jest.fn(),
+ setAddColumnFormVisibility: jest.fn(),
+ ...actions,
},
- store: createStore({
- actions: {
- fetchLabels: jest.fn(),
- setAddColumnFormVisibility: jest.fn(),
- ...actions,
- },
- getters: {
- getListByLabelId: () => getListByLabelId,
- },
- state: {
- labels,
- labelsLoading: false,
- },
- }),
- provide: {
- scopedLabelsAvailable: true,
- isEpicBoard: false,
+ getters: {
+ getListByLabelId: () => getListByLabelId,
+ },
+ state: {
+ labels,
+ labelsLoading: false,
},
}),
- );
+ provide: {
+ scopedLabelsAvailable: true,
+ isEpicBoard: false,
+ },
+ stubs: {
+ GlCollapsibleListbox,
+ },
+ });
// trigger change event
if (selectedId) {
@@ -68,10 +69,6 @@ describe('Board card layout', () => {
}
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Add list button', () => {
it('calls addList', async () => {
const getListByLabelId = jest.fn().mockReturnValue(null);
diff --git a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js
index 354eb7bff16..d8b93e1f3b6 100644
--- a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js
@@ -17,7 +17,7 @@ describe('BoardAddNewColumnTrigger', () => {
const mountComponent = () => {
wrapper = mountExtended(BoardAddNewColumnTrigger, {
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
store: createStore(),
});
@@ -27,10 +27,6 @@ describe('BoardAddNewColumnTrigger', () => {
mountComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when button is active', () => {
it('does not show the tooltip', () => {
const tooltip = findTooltipText();
diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js
index 12318fb5d16..3d6e4c18f51 100644
--- a/spec/frontend/boards/components/board_app_spec.js
+++ b/spec/frontend/boards/components/board_app_spec.js
@@ -1,14 +1,22 @@
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
+import createMockApollo from 'helpers/mock_apollo_helper';
import BoardApp from '~/boards/components/board_app.vue';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
+import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
+import { rawIssue, boardListsQueryResponse } from '../mock_data';
describe('BoardApp', () => {
let wrapper;
let store;
+ const boardListQueryHandler = jest.fn().mockResolvedValue(boardListsQueryResponse);
+ const mockApollo = createMockApollo([[boardListsQuery, boardListQueryHandler]]);
Vue.use(Vuex);
+ Vue.use(VueApollo);
const createStore = ({ mockGetters = {} } = {}) => {
store = new Vuex.Store({
@@ -23,18 +31,31 @@ describe('BoardApp', () => {
});
};
- const createComponent = () => {
+ const createComponent = ({ isApolloBoard = false, issue = rawIssue } = {}) => {
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: activeBoardItemQuery,
+ data: {
+ activeBoardItem: issue,
+ },
+ });
+
wrapper = shallowMount(BoardApp, {
+ apolloProvider: mockApollo,
store,
provide: {
+ fullPath: 'gitlab-org',
initialBoardId: 'gid://gitlab/Board/1',
+ initialFilterParams: {},
+ issuableType: 'issue',
+ boardType: 'group',
+ isIssueBoard: true,
+ isGroupBoard: true,
+ isApolloBoard,
},
});
};
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
store = null;
});
@@ -51,4 +72,26 @@ describe('BoardApp', () => {
expect(wrapper.classes()).not.toContain('is-compact');
});
+
+ describe('Apollo boards', () => {
+ beforeEach(async () => {
+ createComponent({ isApolloBoard: true });
+ await nextTick();
+ });
+
+ it('fetches lists', () => {
+ expect(boardListQueryHandler).toHaveBeenCalled();
+ });
+
+ it('should have is-compact class when a card is selected', () => {
+ expect(wrapper.classes()).toContain('is-compact');
+ });
+
+ it('should not have is-compact class when no card is selected', async () => {
+ createComponent({ isApolloBoard: true, issue: {} });
+ await nextTick();
+
+ expect(wrapper.classes()).not.toContain('is-compact');
+ });
+ });
});
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index 84e6318d98e..897219303b5 100644
--- a/spec/frontend/boards/components/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -1,8 +1,10 @@
import { GlLabel } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardCard from '~/boards/components/board_card.vue';
import BoardCardInner from '~/boards/components/board_card_inner.vue';
import { inactiveId } from '~/boards/constants';
@@ -14,6 +16,14 @@ describe('Board card', () => {
let mockActions;
Vue.use(Vuex);
+ Vue.use(VueApollo);
+
+ const mockSetActiveBoardItemResolver = jest.fn();
+ const mockApollo = createMockApollo([], {
+ Mutation: {
+ setActiveBoardItem: mockSetActiveBoardItemResolver,
+ },
+ });
const createStore = ({ initialState = {} } = {}) => {
mockActions = {
@@ -36,11 +46,11 @@ describe('Board card', () => {
const mountComponent = ({
propsData = {},
provide = {},
- mountFn = shallowMount,
stubs = { BoardCardInner },
item = mockIssue,
} = {}) => {
- wrapper = mountFn(BoardCard, {
+ wrapper = shallowMountExtended(BoardCard, {
+ apolloProvider: mockApollo,
stubs: {
...stubs,
BoardCardInner,
@@ -56,9 +66,9 @@ describe('Board card', () => {
groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
+ isIssueBoard: true,
isEpicBoard: false,
issuableType: 'issue',
- isProjectBoard: false,
isGroupBoard: true,
disabled: false,
isApolloBoard: false,
@@ -82,8 +92,6 @@ describe('Board card', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
store = null;
});
@@ -98,7 +106,7 @@ describe('Board card', () => {
});
});
- it('should not highlight the card by default', async () => {
+ it('should not highlight the card by default', () => {
createStore();
mountComponent();
@@ -106,7 +114,7 @@ describe('Board card', () => {
expect(wrapper.classes()).not.toContain('multi-select');
});
- it('should highlight the card with a correct style when selected', async () => {
+ it('should highlight the card with a correct style when selected', () => {
createStore({
initialState: {
activeId: mockIssue.id,
@@ -118,7 +126,7 @@ describe('Board card', () => {
expect(wrapper.classes()).not.toContain('multi-select');
});
- it('should highlight the card with a correct style when multi-selected', async () => {
+ it('should highlight the card with a correct style when multi-selected', () => {
createStore({
initialState: {
activeId: inactiveId,
@@ -220,4 +228,25 @@ describe('Board card', () => {
expect(wrapper.attributes('style')).toBeUndefined();
});
});
+
+ describe('Apollo boards', () => {
+ beforeEach(async () => {
+ createStore();
+ mountComponent({ provide: { isApolloBoard: true } });
+ await nextTick();
+ });
+
+ it('set active board item on client when clicking on card', async () => {
+ await selectCard();
+
+ expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith(
+ {},
+ {
+ boardItem: mockIssue,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
});
diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js
index c0bb51620f2..5717031be20 100644
--- a/spec/frontend/boards/components/board_column_spec.js
+++ b/spec/frontend/boards/components/board_column_spec.js
@@ -10,11 +10,6 @@ describe('Board Column Component', () => {
let wrapper;
let store;
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const initStore = () => {
store = createStore();
};
@@ -36,6 +31,7 @@ describe('Board Column Component', () => {
propsData: {
list: listMock,
boardId: 'gid://gitlab/Board/1',
+ filters: {},
},
provide: {
isApolloBoard: false,
@@ -85,7 +81,7 @@ describe('Board Column Component', () => {
});
describe('on mount', () => {
- beforeEach(async () => {
+ beforeEach(() => {
initStore();
jest.spyOn(store, 'dispatch').mockImplementation();
});
diff --git a/spec/frontend/boards/components/board_configuration_options_spec.js b/spec/frontend/boards/components/board_configuration_options_spec.js
index 6f0971a9458..199a08c5d83 100644
--- a/spec/frontend/boards/components/board_configuration_options_spec.js
+++ b/spec/frontend/boards/components/board_configuration_options_spec.js
@@ -16,10 +16,6 @@ describe('BoardConfigurationOptions', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const backlogListCheckbox = () => wrapper.find('[data-testid="backlog-list-checkbox"]');
const closedListCheckbox = () => wrapper.find('[data-testid="closed-list-checkbox"]');
@@ -66,8 +62,8 @@ describe('BoardConfigurationOptions', () => {
it('renders checkboxes disabled when user does not have edit rights', () => {
createComponent({ readonly: true });
- expect(closedListCheckbox().attributes('disabled')).toBe('true');
- expect(backlogListCheckbox().attributes('disabled')).toBe('true');
+ expect(closedListCheckbox().attributes('disabled')).toBeDefined();
+ expect(backlogListCheckbox().attributes('disabled')).toBeDefined();
});
it('renders checkboxes enabled when user has edit rights', () => {
diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js
index 955267a415c..9be2696de56 100644
--- a/spec/frontend/boards/components/board_content_sidebar_spec.js
+++ b/spec/frontend/boards/components/board_content_sidebar_spec.js
@@ -1,10 +1,15 @@
import { GlDrawer } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import { MountingPortal } from 'portal-vue';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
@@ -14,13 +19,21 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
-import { mockActiveIssue, mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data';
+import { mockActiveIssue, mockIssue, rawIssue } from '../mock_data';
Vue.use(Vuex);
+Vue.use(VueApollo);
describe('BoardContentSidebar', () => {
let wrapper;
let store;
+ const mockSetActiveBoardItemResolver = jest.fn();
+ const mockApollo = createMockApollo([], {
+ Mutation: {
+ setActiveBoardItem: mockSetActiveBoardItemResolver,
+ },
+ });
+
const createStore = ({ mockGetters = {}, mockActions = {} } = {}) => {
store = new Vuex.Store({
state: {
@@ -32,30 +45,29 @@ describe('BoardContentSidebar', () => {
activeBoardItem: () => {
return { ...mockActiveIssue, epic: null };
},
- groupPathForActiveIssue: () => mockIssueGroupPath,
- projectPathForActiveIssue: () => mockIssueProjectPath,
- isSidebarOpen: () => true,
...mockGetters,
},
actions: mockActions,
});
};
- const createComponent = () => {
- /*
- Dynamically imported components (in our case ee imports)
- aren't stubbed automatically in VTU v1:
- https://github.com/vuejs/vue-test-utils/issues/1279.
+ const createComponent = ({ isApolloBoard = false } = {}) => {
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: activeBoardItemQuery,
+ data: {
+ activeBoardItem: rawIssue,
+ },
+ });
- This requires us to additionally mock apollo or vuex stores.
- */
- wrapper = shallowMount(BoardContentSidebar, {
+ wrapper = shallowMountExtended(BoardContentSidebar, {
+ apolloProvider: mockApollo,
provide: {
canUpdate: true,
rootPath: '/',
groupId: 1,
issuableType: TYPE_ISSUE,
isGroupBoard: false,
+ isApolloBoard,
},
store,
stubs: {
@@ -63,24 +75,6 @@ describe('BoardContentSidebar', () => {
template: '<div><slot name="header"></slot><slot></slot></div>',
}),
},
- mocks: {
- $apollo: {
- queries: {
- participants: {
- loading: false,
- },
- currentIteration: {
- loading: false,
- },
- iterations: {
- loading: false,
- },
- attributesList: {
- loading: false,
- },
- },
- },
- },
});
};
@@ -89,10 +83,6 @@ describe('BoardContentSidebar', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('confirms we render GlDrawer', () => {
expect(wrapper.findComponent(GlDrawer).exists()).toBe(true);
});
@@ -105,10 +95,12 @@ describe('BoardContentSidebar', () => {
});
});
- it('does not render GlDrawer when isSidebarOpen is false', () => {
- createStore({ mockGetters: { isSidebarOpen: () => false } });
+ it('does not render GlDrawer when no active item is set', async () => {
+ createStore({ mockGetters: { activeBoardItem: () => ({ id: '', iid: '' }) } });
createComponent();
+ await nextTick();
+
expect(wrapper.findComponent(GlDrawer).props('open')).toBe(false);
});
@@ -170,7 +162,7 @@ describe('BoardContentSidebar', () => {
createComponent();
});
- it('calls toggleBoardItem with correct parameters', async () => {
+ it('calls toggleBoardItem with correct parameters', () => {
wrapper.findComponent(GlDrawer).vm.$emit('close');
expect(toggleBoardItem).toHaveBeenCalledTimes(1);
@@ -193,4 +185,27 @@ describe('BoardContentSidebar', () => {
expect(wrapper.findComponent(SidebarSeverityWidget).exists()).toBe(true);
});
});
+
+ describe('Apollo boards', () => {
+ beforeEach(async () => {
+ createStore();
+ createComponent({ isApolloBoard: true });
+ await nextTick();
+ });
+
+ it('calls setActiveBoardItemMutation on close', async () => {
+ wrapper.findComponent(GlDrawer).vm.$emit('close');
+
+ await waitForPromises();
+
+ expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith(
+ {},
+ {
+ boardItem: null,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
});
diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js
index 97596c86198..e14f661a8bd 100644
--- a/spec/frontend/boards/components/board_content_spec.js
+++ b/spec/frontend/boards/components/board_content_spec.js
@@ -1,20 +1,18 @@
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
+import Vue from 'vue';
import Draggable from 'vuedraggable';
import Vuex from 'vuex';
+import eventHub from '~/boards/eventhub';
+import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
-import createMockApollo from 'helpers/mock_apollo_helper';
import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
import getters from 'ee_else_ce/boards/stores/getters';
-import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import BoardColumn from '~/boards/components/board_column.vue';
import BoardContent from '~/boards/components/board_content.vue';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
-import { mockLists, boardListsQueryResponse } from '../mock_data';
+import { mockLists, mockListsById } from '../mock_data';
-Vue.use(VueApollo);
Vue.use(Vuex);
const actions = {
@@ -23,8 +21,6 @@ const actions = {
describe('BoardContent', () => {
let wrapper;
- let fakeApollo;
- window.gon = {};
const defaultState = {
isShowingEpicsSwimlanes: false,
@@ -49,24 +45,21 @@ describe('BoardContent', () => {
issuableType = 'issue',
isIssueBoard = true,
isEpicBoard = false,
- boardListQueryHandler = jest.fn().mockResolvedValue(boardListsQueryResponse),
} = {}) => {
- fakeApollo = createMockApollo([[boardListsQuery, boardListQueryHandler]]);
-
const store = createStore({
...defaultState,
...state,
});
wrapper = shallowMount(BoardContent, {
- apolloProvider: fakeApollo,
propsData: {
boardId: 'gid://gitlab/Board/1',
+ filterParams: {},
+ isSwimlanesOn: false,
+ boardListsApollo: mockListsById,
...props,
},
provide: {
canAdminList,
- boardType: 'group',
- fullPath: 'gitlab-org/gitlab',
issuableType,
isIssueBoard,
isEpicBoard,
@@ -75,37 +68,14 @@ describe('BoardContent', () => {
isApolloBoard,
},
store,
+ stubs: {
+ BoardContentSidebar: stubComponent(BoardContentSidebar, {
+ template: '<div></div>',
+ }),
+ },
});
};
- beforeAll(() => {
- global.ResizeObserver = class MockResizeObserver {
- constructor(callback) {
- this.callback = callback;
-
- this.entries = [];
- }
-
- observe(entry) {
- this.entries.push(entry);
- }
-
- disconnect() {
- this.entries = [];
- this.callback = null;
- }
-
- trigger() {
- this.callback(this.entries);
- }
- };
- });
-
- afterEach(() => {
- wrapper.destroy();
- fakeApollo = null;
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
@@ -124,34 +94,6 @@ describe('BoardContent', () => {
expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
});
- it('on small screens, sets board container height to full height', async () => {
- window.innerHeight = 1000;
- window.innerWidth = 767;
- jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ top: 100 });
-
- wrapper.vm.resizeObserver.trigger();
-
- await nextTick();
-
- const style = wrapper.findComponent({ ref: 'list' }).attributes('style');
-
- expect(style).toBe('height: 1000px;');
- });
-
- it('on large screens, sets board container height fill area below filters', async () => {
- window.innerHeight = 1000;
- window.innerWidth = 768;
- jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ top: 100 });
-
- wrapper.vm.resizeObserver.trigger();
-
- await nextTick();
-
- const style = wrapper.findComponent({ ref: 'list' }).attributes('style');
-
- expect(style).toBe('height: 900px;');
- });
-
it('sets delay and delayOnTouchOnly attributes on board list', () => {
const listEl = wrapper.findComponent({ ref: 'list' });
@@ -203,5 +145,14 @@ describe('BoardContent', () => {
it('renders BoardContentSidebar', () => {
expect(wrapper.findComponent(BoardContentSidebar).exists()).toBe(true);
});
+
+ it('refetches lists when updateBoard event is received', async () => {
+ jest.spyOn(eventHub, '$on').mockImplementation(() => {});
+
+ createComponent({ isApolloBoard: true });
+ await waitForPromises();
+
+ expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists);
+ });
});
});
diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js
index 4c0cc36889c..5a976816f74 100644
--- a/spec/frontend/boards/components/board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/board_filtered_search_spec.js
@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
-import * as urlUtility from '~/lib/utils/url_utility';
+import { updateHistory } from '~/lib/utils/url_utility';
import {
TOKEN_TITLE_AUTHOR,
TOKEN_TITLE_LABEL,
@@ -23,6 +23,12 @@ import { createStore } from '~/boards/stores';
Vue.use(Vuex);
+jest.mock('~/lib/utils/url_utility', () => ({
+ updateHistory: jest.fn(),
+ setUrlParams: jest.requireActual('~/lib/utils/url_utility').setUrlParams,
+ queryToObject: jest.requireActual('~/lib/utils/url_utility').queryToObject,
+}));
+
describe('BoardFilteredSearch', () => {
let wrapper;
let store;
@@ -55,10 +61,10 @@ describe('BoardFilteredSearch', () => {
},
];
- const createComponent = ({ initialFilterParams = {}, props = {} } = {}) => {
+ const createComponent = ({ initialFilterParams = {}, props = {}, provide = {} } = {}) => {
store = createStore();
wrapper = shallowMount(BoardFilteredSearch, {
- provide: { initialFilterParams, fullPath: '' },
+ provide: { initialFilterParams, fullPath: '', isApolloBoard: false, ...provide },
store,
propsData: {
...props,
@@ -69,10 +75,6 @@ describe('BoardFilteredSearch', () => {
const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBarRoot);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
@@ -92,10 +94,9 @@ describe('BoardFilteredSearch', () => {
});
it('calls historyPushState', () => {
- jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
- expect(urlUtility.updateHistory).toHaveBeenCalledWith({
+ expect(updateHistory).toHaveBeenCalledWith({
replace: true,
title: '',
url: 'http://test.host/',
@@ -124,10 +125,10 @@ describe('BoardFilteredSearch', () => {
beforeEach(() => {
createComponent();
- jest.spyOn(wrapper.vm, 'performSearch').mockImplementation();
+ jest.spyOn(store, 'dispatch').mockImplementation();
});
- it('sets the url params to the correct results', async () => {
+ it('sets the url params to the correct results', () => {
const mockFilters = [
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'root', operator: '=' } },
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'root', operator: '=' } },
@@ -141,10 +142,11 @@ describe('BoardFilteredSearch', () => {
{ type: TOKEN_TYPE_HEALTH, value: { data: 'onTrack', operator: '=' } },
{ type: TOKEN_TYPE_HEALTH, value: { data: 'atRisk', operator: '!=' } },
];
- jest.spyOn(urlUtility, 'updateHistory');
+
findFilteredSearch().vm.$emit('onFilter', mockFilters);
- expect(urlUtility.updateHistory).toHaveBeenCalledWith({
+ expect(store.dispatch).toHaveBeenCalledWith('performSearch');
+ expect(updateHistory).toHaveBeenCalledWith({
title: '',
replace: true,
url:
@@ -162,10 +164,10 @@ describe('BoardFilteredSearch', () => {
const mockFilters = [
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: assigneeParam, operator: '=' } },
];
- jest.spyOn(urlUtility, 'updateHistory');
+
findFilteredSearch().vm.$emit('onFilter', mockFilters);
- expect(urlUtility.updateHistory).toHaveBeenCalledWith({
+ expect(updateHistory).toHaveBeenCalledWith({
title: '',
replace: true,
url: expected,
@@ -179,8 +181,6 @@ describe('BoardFilteredSearch', () => {
createComponent({
initialFilterParams: { authorUsername: 'root', labelName: ['label'], healthStatus: 'Any' },
});
-
- jest.spyOn(store, 'dispatch');
});
it('passes the correct props to FilterSearchBar', () => {
@@ -191,4 +191,22 @@ describe('BoardFilteredSearch', () => {
]);
});
});
+
+ describe('when Apollo boards FF is on', () => {
+ beforeEach(() => {
+ createComponent({ provide: { isApolloBoard: true } });
+ });
+
+ it('emits setFilters and updates URL when onFilter is emitted', () => {
+ findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]);
+
+ expect(updateHistory).toHaveBeenCalledWith({
+ title: '',
+ replace: true,
+ url: 'http://test.host/',
+ });
+
+ expect(wrapper.emitted('setFilters')).toHaveLength(1);
+ });
+ });
});
diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js
index f8154145d43..f340dfab359 100644
--- a/spec/frontend/boards/components/board_form_spec.js
+++ b/spec/frontend/boards/components/board_form_spec.js
@@ -10,12 +10,14 @@ import { formType } from '~/boards/constants';
import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql';
import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql';
import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql';
+import eventHub from '~/boards/eventhub';
import { visitUrl } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
visitUrl: jest.fn().mockName('visitUrlMock'),
}));
+jest.mock('~/boards/eventhub');
Vue.use(Vuex);
@@ -59,18 +61,14 @@ describe('BoardForm', () => {
},
});
- const createComponent = (props, data) => {
+ const createComponent = (props, provide) => {
wrapper = shallowMountExtended(BoardForm, {
propsData: { ...defaultProps, ...props },
- data() {
- return {
- ...data,
- };
- },
provide: {
boardBaseUrl: 'root',
isGroupBoard: true,
isProjectBoard: false,
+ ...provide,
},
mocks: {
$apollo: {
@@ -83,8 +81,6 @@ describe('BoardForm', () => {
};
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
mutate = null;
});
@@ -119,7 +115,7 @@ describe('BoardForm', () => {
expect(findForm().exists()).toBe(true);
});
- it('focuses an input field', async () => {
+ it('focuses an input field', () => {
expect(document.activeElement).toBe(wrapper.vm.$refs.name);
});
});
@@ -140,7 +136,7 @@ describe('BoardForm', () => {
it('passes correct primary action text and variant', () => {
expect(findModalActionPrimary().text).toBe('Create board');
- expect(findModalActionPrimary().attributes[0].variant).toBe('confirm');
+ expect(findModalActionPrimary().attributes.variant).toBe('confirm');
});
it('does not render delete confirmation message', () => {
@@ -209,6 +205,30 @@ describe('BoardForm', () => {
expect(setBoardMock).not.toHaveBeenCalled();
expect(setErrorMock).toHaveBeenCalled();
});
+
+ describe('when Apollo boards FF is on', () => {
+ it('calls a correct GraphQL mutation and emits addBoard event when creating a board', async () => {
+ createComponent(
+ { canAdminBoard: true, currentPage: formType.new },
+ { isApolloBoard: true },
+ );
+ fillForm();
+
+ await waitForPromises();
+
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: createBoardMutation,
+ variables: {
+ input: expect.objectContaining({
+ name: 'test',
+ }),
+ },
+ });
+
+ await waitForPromises();
+ expect(wrapper.emitted('addBoard')).toHaveLength(1);
+ });
+ });
});
});
@@ -228,7 +248,7 @@ describe('BoardForm', () => {
it('passes correct primary action text and variant', () => {
expect(findModalActionPrimary().text).toBe('Save changes');
- expect(findModalActionPrimary().attributes[0].variant).toBe('confirm');
+ expect(findModalActionPrimary().attributes.variant).toBe('confirm');
});
it('does not render delete confirmation message', () => {
@@ -308,13 +328,48 @@ describe('BoardForm', () => {
expect(setBoardMock).not.toHaveBeenCalled();
expect(setErrorMock).toHaveBeenCalled();
});
+
+ describe('when Apollo boards FF is on', () => {
+ it('calls a correct GraphQL mutation and emits updateBoard event when updating a board', async () => {
+ mutate = jest.fn().mockResolvedValue({
+ data: {
+ updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } },
+ },
+ });
+ setWindowLocation('https://test/boards/1');
+
+ createComponent(
+ { canAdminBoard: true, currentPage: formType.edit },
+ { isApolloBoard: true },
+ );
+ findInput().trigger('keyup.enter', { metaKey: true });
+
+ await waitForPromises();
+
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: updateBoardMutation,
+ variables: {
+ input: expect.objectContaining({
+ id: currentBoard.id,
+ }),
+ },
+ });
+
+ await waitForPromises();
+ expect(eventHub.$emit).toHaveBeenCalledTimes(1);
+ expect(eventHub.$emit).toHaveBeenCalledWith('updateBoard', {
+ id: 'gid://gitlab/Board/321',
+ webPath: 'test-path',
+ });
+ });
+ });
});
describe('when deleting a board', () => {
it('passes correct primary action text and variant', () => {
createComponent({ canAdminBoard: true, currentPage: formType.delete });
expect(findModalActionPrimary().text).toBe('Delete');
- expect(findModalActionPrimary().attributes[0].variant).toBe('danger');
+ expect(findModalActionPrimary().attributes.variant).toBe('danger');
});
it('renders delete confirmation message', () => {
diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js
index 9e65e900440..d4489b3c535 100644
--- a/spec/frontend/boards/components/board_list_header_spec.js
+++ b/spec/frontend/boards/components/board_list_header_spec.js
@@ -1,12 +1,16 @@
-import { shallowMount } from '@vue/test-utils';
+import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-
-import { boardListQueryResponse, mockLabelList } from 'jest/boards/mock_data';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ boardListQueryResponse,
+ mockLabelList,
+ updateBoardListResponse,
+} from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header.vue';
+import updateBoardListMutation from '~/boards/graphql/board_list_update.mutation.graphql';
import { ListType } from '~/boards/constants';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
@@ -20,10 +24,10 @@ describe('Board List Header Component', () => {
const updateListSpy = jest.fn();
const toggleListCollapsedSpy = jest.fn();
+ const mockClientToggleListCollapsedResolver = jest.fn();
+ const updateListHandler = jest.fn().mockResolvedValue(updateBoardListResponse);
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
fakeApollo = null;
localStorage.clear();
@@ -37,7 +41,7 @@ describe('Board List Header Component', () => {
listQueryHandler = jest.fn().mockResolvedValue(boardListQueryResponse()),
injectedProps = {},
} = {}) => {
- const boardId = '1';
+ const boardId = 'gid://gitlab/Board/1';
const listMock = {
...mockLabelList,
@@ -61,34 +65,46 @@ describe('Board List Header Component', () => {
state: {},
actions: { updateList: updateListSpy, toggleListCollapsed: toggleListCollapsedSpy },
});
-
- fakeApollo = createMockApollo([[listQuery, listQueryHandler]]);
-
- wrapper = extendedWrapper(
- shallowMount(BoardListHeader, {
- apolloProvider: fakeApollo,
- store,
- propsData: {
- list: listMock,
- },
- provide: {
- boardId,
- weightFeatureAvailable: false,
- currentUserId,
- isEpicBoard: false,
- disabled: false,
- ...injectedProps,
+ fakeApollo = createMockApollo(
+ [
+ [listQuery, listQueryHandler],
+ [updateBoardListMutation, updateListHandler],
+ ],
+ {
+ Mutation: {
+ clientToggleListCollapsed: mockClientToggleListCollapsedResolver,
},
- }),
+ },
);
+
+ wrapper = shallowMountExtended(BoardListHeader, {
+ apolloProvider: fakeApollo,
+ store,
+ propsData: {
+ list: listMock,
+ filterParams: {},
+ boardId,
+ },
+ provide: {
+ weightFeatureAvailable: false,
+ currentUserId,
+ isEpicBoard: false,
+ disabled: false,
+ ...injectedProps,
+ },
+ stubs: {
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ },
+ });
};
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const isCollapsed = () => wrapper.vm.list.collapsed;
-
- const findAddIssueButton = () => wrapper.findComponent({ ref: 'newIssueBtn' });
const findTitle = () => wrapper.find('.board-title');
const findCaret = () => wrapper.findByTestId('board-title-caret');
- const findSettingsButton = () => wrapper.findComponent({ ref: 'settingsBtn' });
+ const findNewIssueButton = () => wrapper.findByTestId('newIssueBtn');
+ const findSettingsButton = () => wrapper.findByTestId('settingsBtn');
describe('Add issue button', () => {
const hasNoAddButton = [ListType.closed];
@@ -100,64 +116,54 @@ describe('Board List Header Component', () => {
ListType.assignee,
];
- it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => {
+ it.each(hasNoAddButton)('does not render dropdown when List Type is `%s`', (listType) => {
createComponent({ listType });
- expect(findAddIssueButton().exists()).toBe(false);
+ expect(findDropdown().exists()).toBe(false);
});
it.each(hasAddButton)('does render when List Type is `%s`', (listType) => {
createComponent({ listType });
- expect(findAddIssueButton().exists()).toBe(true);
+ expect(findDropdown().exists()).toBe(true);
+ expect(findNewIssueButton().exists()).toBe(true);
});
- it('has a test for each list type', () => {
- createComponent();
-
- Object.values(ListType).forEach((value) => {
- expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
- });
- });
-
- it('does not render when logged out', () => {
+ it('does not render dropdown when logged out', () => {
createComponent({
currentUserId: null,
});
- expect(findAddIssueButton().exists()).toBe(false);
+ expect(findDropdown().exists()).toBe(false);
});
});
describe('Settings Button', () => {
- describe('with disabled=true', () => {
- const hasSettings = [
- ListType.assignee,
- ListType.milestone,
- ListType.iteration,
- ListType.label,
- ];
- const hasNoSettings = [ListType.backlog, ListType.closed];
-
- it.each(hasSettings)('does render for List Type `%s` when disabled=true', (listType) => {
- createComponent({ listType, injectedProps: { disabled: true } });
-
- expect(findSettingsButton().exists()).toBe(true);
- });
+ const hasSettings = [ListType.assignee, ListType.milestone, ListType.iteration, ListType.label];
- it.each(hasNoSettings)(
- 'does not render for List Type `%s` when disabled=true',
- (listType) => {
- createComponent({ listType });
+ it.each(hasSettings)('does render for List Type `%s`', (listType) => {
+ createComponent({ listType });
- expect(findSettingsButton().exists()).toBe(false);
- },
- );
+ expect(findDropdown().exists()).toBe(true);
+ expect(findSettingsButton().exists()).toBe(true);
+ });
+
+ it('does not render dropdown when ListType `closed`', () => {
+ createComponent({ listType: ListType.closed });
+
+ expect(findDropdown().exists()).toBe(false);
+ });
+
+ it('renders dropdown but not the Settings button when ListType `backlog`', () => {
+ createComponent({ listType: ListType.backlog });
+
+ expect(findDropdown().exists()).toBe(true);
+ expect(findSettingsButton().exists()).toBe(false);
});
});
describe('expanding / collapsing the column', () => {
- it('should display collapse icon when column is expanded', async () => {
+ it('should display collapse icon when column is expanded', () => {
createComponent();
const icon = findCaret();
@@ -165,7 +171,7 @@ describe('Board List Header Component', () => {
expect(icon.props('icon')).toBe('chevron-lg-down');
});
- it('should display expand icon when column is collapsed', async () => {
+ it('should display expand icon when column is collapsed', () => {
createComponent({ collapsed: true });
const icon = findCaret();
@@ -201,7 +207,9 @@ describe('Board List Header Component', () => {
await nextTick();
expect(updateListSpy).not.toHaveBeenCalled();
- expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(String(isCollapsed()));
+ expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(
+ String(!isCollapsed()),
+ );
});
});
@@ -224,4 +232,44 @@ describe('Board List Header Component', () => {
expect(findTitle().classes()).toContain('gl-cursor-grab');
});
});
+
+ describe('Apollo boards', () => {
+ beforeEach(async () => {
+ createComponent({ listType: ListType.label, injectedProps: { isApolloBoard: true } });
+ await nextTick();
+ });
+
+ it('set active board item on client when clicking on card', async () => {
+ findCaret().vm.$emit('click');
+ await nextTick();
+
+ expect(mockClientToggleListCollapsedResolver).toHaveBeenCalledWith(
+ {},
+ {
+ list: mockLabelList,
+ collapsed: true,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('does not call update list mutation when user is not logged in', async () => {
+ createComponent({ currentUserId: null, injectedProps: { isApolloBoard: true } });
+
+ findCaret().vm.$emit('click');
+ await nextTick();
+
+ expect(updateListHandler).not.toHaveBeenCalled();
+ });
+
+ it('calls update list mutation when user is logged in', async () => {
+ createComponent({ currentUserId: 1, injectedProps: { isApolloBoard: true } });
+
+ findCaret().vm.$emit('click');
+ await nextTick();
+
+ expect(updateListHandler).toHaveBeenCalledWith({ listId: mockLabelList.id, collapsed: true });
+ });
+ });
});
diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js
index c3e69ba0e40..651d1daee52 100644
--- a/spec/frontend/boards/components/board_new_issue_spec.js
+++ b/spec/frontend/boards/components/board_new_issue_spec.js
@@ -51,10 +51,6 @@ describe('Issue boards new issue form', () => {
await nextTick();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders board-new-item component', () => {
const boardNewItem = findBoardNewItem();
expect(boardNewItem.exists()).toBe(true);
diff --git a/spec/frontend/boards/components/board_new_item_spec.js b/spec/frontend/boards/components/board_new_item_spec.js
index f4e9901aad2..f11eb2baca7 100644
--- a/spec/frontend/boards/components/board_new_item_spec.js
+++ b/spec/frontend/boards/components/board_new_item_spec.js
@@ -35,10 +35,6 @@ describe('BoardNewItem', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
describe('when the user provides a valid input', () => {
it('finds an enabled create button', async () => {
diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js
index 7d602042685..b1e14be8ceb 100644
--- a/spec/frontend/boards/components/board_settings_sidebar_spec.js
+++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js
@@ -2,32 +2,42 @@ import { GlDrawer, GlLabel, GlModal, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { MountingPortal } from 'portal-vue';
import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
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 destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql';
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';
+import { mockLabelList, destroyBoardListMutationResponse } from '../mock_data';
+Vue.use(VueApollo);
Vue.use(Vuex);
describe('BoardSettingsSidebar', () => {
let wrapper;
+ let mockApollo;
const labelTitle = mockLabelList.label.title;
const labelColor = mockLabelList.label.color;
const listId = mockLabelList.id;
const modalID = 'board-settings-sidebar-modal';
+ const destroyBoardListMutationHandlerSuccess = jest
+ .fn()
+ .mockResolvedValue(destroyBoardListMutationResponse);
+
const createComponent = ({
canAdminList = false,
list = {},
sidebarType = LIST,
activeId = inactiveId,
+ isApolloBoard = false,
} = {}) => {
const boardLists = {
[listId]: list,
@@ -39,16 +49,30 @@ describe('BoardSettingsSidebar', () => {
actions,
});
+ mockApollo = createMockApollo([
+ [destroyBoardListMutation, destroyBoardListMutationHandlerSuccess],
+ ]);
+
wrapper = extendedWrapper(
shallowMount(BoardSettingsSidebar, {
store,
+ apolloProvider: mockApollo,
provide: {
canAdminList,
scopedLabelsAvailable: false,
isIssueBoard: true,
+ boardType: 'group',
+ issuableType: 'issue',
+ isApolloBoard,
+ },
+ propsData: {
+ listId: list.id || '',
+ boardId: 'gid://gitlab/Board/1',
+ list,
+ queryVariables: {},
},
directives: {
- GlModal: createMockDirective(),
+ GlModal: createMockDirective('gl-modal'),
},
stubs: {
GlDrawer: stubComponent(GlDrawer, {
@@ -57,6 +81,9 @@ describe('BoardSettingsSidebar', () => {
},
}),
);
+
+ // Necessary for cache update
+ mockApollo.clients.defaultClient.cache.updateQuery = jest.fn();
};
const findLabel = () => wrapper.findComponent(GlLabel);
const findDrawer = () => wrapper.findComponent(GlDrawer);
@@ -65,8 +92,6 @@ describe('BoardSettingsSidebar', () => {
afterEach(() => {
jest.restoreAllMocks();
- wrapper.destroy();
- wrapper = null;
});
it('finds a MountingPortal component', () => {
@@ -168,6 +193,21 @@ describe('BoardSettingsSidebar', () => {
expect(findRemoveButton().exists()).toBe(true);
});
+ it('removes the list', () => {
+ createComponent({
+ canAdminList: true,
+ activeId: listId,
+ list: mockLabelList,
+ isApolloBoard: true,
+ });
+
+ findRemoveButton().vm.$emit('click');
+
+ wrapper.findComponent(GlModal).vm.$emit('primary');
+
+ expect(destroyBoardListMutationHandlerSuccess).toHaveBeenCalled();
+ });
+
it('has the correct ID on the button', () => {
createComponent({ canAdminList: true, activeId: listId, list: mockLabelList });
const binding = getBinding(findRemoveButton().element, 'gl-modal');
diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js
index 8258d9fe7f4..d97a1dbff47 100644
--- a/spec/frontend/boards/components/board_top_bar_spec.js
+++ b/spec/frontend/boards/components/board_top_bar_spec.js
@@ -11,7 +11,7 @@ import ConfigToggle from '~/boards/components/config_toggle.vue';
import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue';
import NewBoardButton from '~/boards/components/new_board_button.vue';
import ToggleFocus from '~/boards/components/toggle_focus.vue';
-import { BoardType } from '~/boards/constants';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import groupBoardQuery from '~/boards/graphql/group_board.query.graphql';
import projectBoardQuery from '~/boards/graphql/project_board.query.graphql';
@@ -43,8 +43,9 @@ describe('BoardTopBar', () => {
wrapper = shallowMount(BoardTopBar, {
store,
apolloProvider: mockApollo,
- props: {
+ propsData: {
boardId: 'gid://gitlab/Board/1',
+ isSwimlanesOn: false,
},
provide: {
swimlanesFeatureAvailable: false,
@@ -64,7 +65,6 @@ describe('BoardTopBar', () => {
};
afterEach(() => {
- wrapper.destroy();
mockApollo = null;
});
@@ -96,6 +96,11 @@ describe('BoardTopBar', () => {
it('does not render BoardAddNewColumnTrigger component', () => {
expect(wrapper.findComponent(BoardAddNewColumnTrigger).exists()).toBe(false);
});
+
+ it('emits setFilters when setFilters is emitted by filtered search', () => {
+ wrapper.findComponent(IssueBoardFilteredSearch).vm.$emit('setFilters');
+ expect(wrapper.emitted('setFilters')).toHaveLength(1);
+ });
});
describe('when user can admin list', () => {
@@ -111,14 +116,14 @@ describe('BoardTopBar', () => {
describe('Apollo boards', () => {
it.each`
boardType | queryHandler | notCalledHandler
- ${BoardType.group} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess}
- ${BoardType.project} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess}
+ ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess}
+ ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess}
`('fetches $boardType boards', async ({ boardType, queryHandler, notCalledHandler }) => {
createComponent({
provide: {
boardType,
- isProjectBoard: boardType === BoardType.project,
- isGroupBoard: boardType === BoardType.group,
+ isProjectBoard: boardType === WORKSPACE_PROJECT,
+ isGroupBoard: boardType === WORKSPACE_GROUP,
isApolloBoard: true,
},
});
diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js
index 28f51e0ecbf..13c017706ef 100644
--- a/spec/frontend/boards/components/boards_selector_spec.js
+++ b/spec/frontend/boards/components/boards_selector_spec.js
@@ -5,11 +5,11 @@ import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector.vue';
-import { BoardType } from '~/boards/constants';
import groupBoardsQuery from '~/boards/graphql/group_boards.query.graphql';
import projectBoardsQuery from '~/boards/graphql/project_boards.query.graphql';
import groupRecentBoardsQuery from '~/boards/graphql/group_recent_boards.query.graphql';
import projectRecentBoardsQuery from '~/boards/graphql/project_recent_boards.query.graphql';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import {
@@ -116,7 +116,6 @@ describe('BoardsSelector', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -228,13 +227,13 @@ describe('BoardsSelector', () => {
describe('fetching all boards', () => {
it.each`
boardType | queryHandler | notCalledHandler
- ${BoardType.group} | ${groupBoardsQueryHandlerSuccess} | ${projectBoardsQueryHandlerSuccess}
- ${BoardType.project} | ${projectBoardsQueryHandlerSuccess} | ${groupBoardsQueryHandlerSuccess}
+ ${WORKSPACE_GROUP} | ${groupBoardsQueryHandlerSuccess} | ${projectBoardsQueryHandlerSuccess}
+ ${WORKSPACE_PROJECT} | ${projectBoardsQueryHandlerSuccess} | ${groupBoardsQueryHandlerSuccess}
`('fetches $boardType boards', async ({ boardType, queryHandler, notCalledHandler }) => {
createStore();
createComponent({
- isGroupBoard: boardType === BoardType.group,
- isProjectBoard: boardType === BoardType.project,
+ isGroupBoard: boardType === WORKSPACE_GROUP,
+ isProjectBoard: boardType === WORKSPACE_PROJECT,
});
await nextTick();
@@ -251,7 +250,7 @@ describe('BoardsSelector', () => {
describe('dropdown visibility', () => {
describe('when multipleIssueBoardsAvailable is enabled', () => {
- it('show dropdown', async () => {
+ it('show dropdown', () => {
createStore();
createComponent({ provide: { multipleIssueBoardsAvailable: true } });
expect(findDropdown().exists()).toBe(true);
@@ -259,7 +258,7 @@ describe('BoardsSelector', () => {
});
describe('when multipleIssueBoardsAvailable is disabled but it hasMissingBoards', () => {
- it('show dropdown', async () => {
+ it('show dropdown', () => {
createStore();
createComponent({
provide: { multipleIssueBoardsAvailable: false, hasMissingBoards: true },
@@ -269,7 +268,7 @@ describe('BoardsSelector', () => {
});
describe("when multipleIssueBoardsAvailable is disabled and it dosn't hasMissingBoards", () => {
- it('hide dropdown', async () => {
+ it('hide dropdown', () => {
createStore();
createComponent({
provide: { multipleIssueBoardsAvailable: false, hasMissingBoards: false },
diff --git a/spec/frontend/boards/components/config_toggle_spec.js b/spec/frontend/boards/components/config_toggle_spec.js
index 47d4692453d..5330721451e 100644
--- a/spec/frontend/boards/components/config_toggle_spec.js
+++ b/spec/frontend/boards/components/config_toggle_spec.js
@@ -23,10 +23,6 @@ describe('ConfigToggle', () => {
const findButton = () => wrapper.findComponent(GlButton);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a button with label `View scope` when `canAdminList` is `false`', () => {
wrapper = createComponent({ canAdminList: false });
expect(findButton().text()).toBe('View scope');
diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js
index 57a30ddc512..5b5b68d5dbe 100644
--- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js
@@ -2,10 +2,10 @@ import { orderBy } from 'lodash';
import { shallowMount } from '@vue/test-utils';
import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue';
import IssueBoardFilteredSpec from '~/boards/components/issue_board_filtered_search.vue';
-import issueBoardFilters from '~/boards/issue_board_filters';
+import issueBoardFilters from 'ee_else_ce/boards/issue_board_filters';
import { mockTokens } from '../mock_data';
-jest.mock('~/boards/issue_board_filters');
+jest.mock('ee_else_ce/boards/issue_board_filters');
describe('IssueBoardFilter', () => {
let wrapper;
@@ -14,6 +14,9 @@ describe('IssueBoardFilter', () => {
const createComponent = ({ isSignedIn = false } = {}) => {
wrapper = shallowMount(IssueBoardFilteredSpec, {
+ propsData: {
+ boardId: 'gid://gitlab/Board/1',
+ },
provide: {
isSignedIn,
releasesFetchPath: '/releases',
@@ -35,10 +38,6 @@ describe('IssueBoardFilter', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
@@ -48,6 +47,11 @@ describe('IssueBoardFilter', () => {
expect(findBoardsFilteredSearch().exists()).toBe(true);
});
+ it('emits setFilters when setFilters is emitted', () => {
+ findBoardsFilteredSearch().vm.$emit('setFilters');
+ expect(wrapper.emitted('setFilters')).toHaveLength(1);
+ });
+
it.each`
isSignedIn
${true}
diff --git a/spec/frontend/boards/components/issue_due_date_spec.js b/spec/frontend/boards/components/issue_due_date_spec.js
index 45fa10bf03a..dee8febfe4d 100644
--- a/spec/frontend/boards/components/issue_due_date_spec.js
+++ b/spec/frontend/boards/components/issue_due_date_spec.js
@@ -20,10 +20,6 @@ describe('Issue Due Date component', () => {
date = new Date();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render "Today" if the due date is today', () => {
wrapper = createComponent();
diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js
index 948a7a20f7f..42507ef560b 100644
--- a/spec/frontend/boards/components/issue_time_estimate_spec.js
+++ b/spec/frontend/boards/components/issue_time_estimate_spec.js
@@ -7,10 +7,6 @@ describe('Issue Time Estimate component', () => {
const findIssueTimeEstimate = () => wrapper.find('[data-testid="issue-time-estimate"]');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when limitToHours is false', () => {
beforeEach(() => {
wrapper = shallowMount(IssueTimeEstimate, {
diff --git a/spec/frontend/boards/components/item_count_spec.js b/spec/frontend/boards/components/item_count_spec.js
index 0c0c7f66933..f2cc8eb1167 100644
--- a/spec/frontend/boards/components/item_count_spec.js
+++ b/spec/frontend/boards/components/item_count_spec.js
@@ -41,10 +41,6 @@ describe('IssueCount', () => {
createComponent({ maxIssueCount, itemsSize });
});
- afterEach(() => {
- vm.destroy();
- });
-
it('contains issueSize in the template', () => {
expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
@@ -66,10 +62,6 @@ describe('IssueCount', () => {
createComponent({ maxIssueCount, itemsSize });
});
- afterEach(() => {
- vm.destroy();
- });
-
it('contains issueSize in the template', () => {
expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
diff --git a/spec/frontend/boards/components/new_board_button_spec.js b/spec/frontend/boards/components/new_board_button_spec.js
index 2bbd3797abf..7ec35d1b796 100644
--- a/spec/frontend/boards/components/new_board_button_spec.js
+++ b/spec/frontend/boards/components/new_board_button_spec.js
@@ -21,12 +21,6 @@ describe('NewBoardButton', () => {
}),
);
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('control variant', () => {
beforeAll(() => {
stubExperiments({ [FEATURE]: 'control' });
diff --git a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
index 5e2222ac3d7..6dbeac3864f 100644
--- a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
@@ -21,11 +21,6 @@ describe('boards sidebar remove issue', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('template', () => {
it('renders title', () => {
const title = 'Sidebar item title';
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js
index e2e4baefad0..b01ee01120e 100644
--- a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js
@@ -37,11 +37,6 @@ describe('BoardSidebarTimeTracker', () => {
store.state.activeId = '1';
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it.each`
timeTrackingLimitToHours | canUpdate
${true} | ${false}
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js
index bc66a0515aa..1b526e6fbec 100644
--- a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js
@@ -1,9 +1,17 @@
import { GlAlert, GlFormInput, GlForm, GlLink } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } 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';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { createStore } from '~/boards/stores';
+import issueSetTitleMutation from '~/boards/graphql/issue_set_title.mutation.graphql';
+import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
+import { updateIssueTitleResponse, updateEpicTitleResponse } from '../../mock_data';
+
+Vue.use(VueApollo);
const TEST_TITLE = 'New item title';
const TEST_ISSUE_A = {
@@ -21,26 +29,45 @@ const TEST_ISSUE_B = {
webUrl: 'webUrl',
};
-describe('~/boards/components/sidebar/board_sidebar_title.vue', () => {
+describe('BoardSidebarTitle', () => {
let wrapper;
let store;
+ let storeDispatch;
+ let mockApollo;
+
+ const issueSetTitleMutationHandlerSuccess = jest.fn().mockResolvedValue(updateIssueTitleResponse);
+ const updateEpicTitleMutationHandlerSuccess = jest
+ .fn()
+ .mockResolvedValue(updateEpicTitleResponse);
afterEach(() => {
localStorage.clear();
- wrapper.destroy();
store = null;
- wrapper = null;
});
- const createWrapper = (item = TEST_ISSUE_A) => {
+ const createWrapper = ({ item = TEST_ISSUE_A, provide = {} } = {}) => {
store = createStore();
store.state.boardItems = { [item.id]: { ...item } };
store.dispatch('setActiveId', { id: item.id });
+ mockApollo = createMockApollo([
+ [issueSetTitleMutation, issueSetTitleMutationHandlerSuccess],
+ [updateEpicTitleMutation, updateEpicTitleMutationHandlerSuccess],
+ ]);
+ storeDispatch = jest.spyOn(store, 'dispatch');
- wrapper = shallowMount(BoardSidebarTitle, {
+ wrapper = shallowMountExtended(BoardSidebarTitle, {
store,
+ apolloProvider: mockApollo,
provide: {
canUpdate: true,
+ fullPath: 'gitlab-org',
+ issuableType: 'issue',
+ isEpicBoard: false,
+ isApolloBoard: false,
+ ...provide,
+ },
+ propsData: {
+ activeItem: item,
},
stubs: {
'board-editable-item': BoardEditableItem,
@@ -53,9 +80,9 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => {
const findFormInput = () => wrapper.findComponent(GlFormInput);
const findGlLink = () => wrapper.findComponent(GlLink);
const findEditableItem = () => wrapper.findComponent(BoardEditableItem);
- const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
- const findTitle = () => wrapper.find('[data-testid="item-title"]');
- const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
+ const findCancelButton = () => wrapper.findByTestId('cancel-button');
+ const findTitle = () => wrapper.findByTestId('item-title');
+ const findCollapsed = () => wrapper.findByTestId('collapsed-content');
it('renders title and reference', () => {
createWrapper();
@@ -80,39 +107,42 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => {
beforeEach(async () => {
createWrapper();
- jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {
- store.state.boardItems[TEST_ISSUE_A.id].title = TEST_TITLE;
- });
findFormInput().vm.$emit('input', TEST_TITLE);
findForm().vm.$emit('submit', { preventDefault: () => {} });
await nextTick();
});
- it('collapses sidebar and renders new title', () => {
+ it('collapses sidebar and renders new title', async () => {
+ await waitForPromises();
expect(findCollapsed().isVisible()).toBe(true);
- expect(findTitle().text()).toContain(TEST_TITLE);
});
it('commits change to the server', () => {
- expect(wrapper.vm.setActiveItemTitle).toHaveBeenCalledWith({
- title: TEST_TITLE,
+ expect(storeDispatch).toHaveBeenCalledWith('setActiveItemTitle', {
projectPath: 'h/b',
+ title: 'New item title',
});
});
+
+ it('renders correct title', async () => {
+ createWrapper({ item: { ...TEST_ISSUE_A, title: TEST_TITLE } });
+ await waitForPromises();
+
+ expect(findTitle().text()).toContain(TEST_TITLE);
+ });
});
describe('when submitting and invalid title', () => {
beforeEach(async () => {
createWrapper();
- jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {});
findFormInput().vm.$emit('input', '');
findForm().vm.$emit('submit', { preventDefault: () => {} });
await nextTick();
});
it('commits change to the server', () => {
- expect(wrapper.vm.setActiveItemTitle).not.toHaveBeenCalled();
+ expect(storeDispatch).not.toHaveBeenCalled();
});
});
@@ -142,8 +172,8 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => {
createWrapper();
});
- it('sets title, expands item and shows alert', async () => {
- expect(wrapper.vm.title).toBe(TEST_TITLE);
+ it('sets title, expands item and shows alert', () => {
+ expect(findFormInput().attributes('value')).toBe(TEST_TITLE);
expect(findCollapsed().isVisible()).toBe(false);
expect(findAlert().exists()).toBe(true);
});
@@ -151,18 +181,15 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => {
describe('when cancel button is clicked', () => {
beforeEach(async () => {
- createWrapper(TEST_ISSUE_B);
+ createWrapper({ item: TEST_ISSUE_B });
- jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {
- store.state.boardItems[TEST_ISSUE_B.id].title = TEST_TITLE;
- });
findFormInput().vm.$emit('input', TEST_TITLE);
findCancelButton().vm.$emit('click');
await nextTick();
});
it('collapses sidebar and render former title', () => {
- expect(wrapper.vm.setActiveItemTitle).not.toHaveBeenCalled();
+ expect(storeDispatch).not.toHaveBeenCalled();
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toBe(TEST_ISSUE_B.title);
});
@@ -170,12 +197,8 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => {
describe('when the mutation fails', () => {
beforeEach(async () => {
- createWrapper(TEST_ISSUE_B);
+ createWrapper({ item: TEST_ISSUE_B });
- jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {
- throw new Error(['failed mutation']);
- });
- jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findFormInput().vm.$emit('input', 'Invalid title');
findForm().vm.$emit('submit', { preventDefault: () => {} });
await nextTick();
@@ -184,7 +207,38 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => {
it('collapses sidebar and renders former item title', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toContain(TEST_ISSUE_B.title);
- expect(wrapper.vm.setError).toHaveBeenCalled();
+ expect(storeDispatch).toHaveBeenCalledWith(
+ 'setError',
+ expect.objectContaining({ message: 'An error occurred when updating the title' }),
+ );
});
});
+
+ describe('Apollo boards', () => {
+ it.each`
+ issuableType | isEpicBoard | queryHandler | notCalledHandler
+ ${'issue'} | ${false} | ${issueSetTitleMutationHandlerSuccess} | ${updateEpicTitleMutationHandlerSuccess}
+ ${'epic'} | ${true} | ${updateEpicTitleMutationHandlerSuccess} | ${issueSetTitleMutationHandlerSuccess}
+ `(
+ 'updates $issuableType title',
+ async ({ issuableType, isEpicBoard, queryHandler, notCalledHandler }) => {
+ createWrapper({
+ provide: {
+ issuableType,
+ isEpicBoard,
+ isApolloBoard: true,
+ },
+ });
+
+ await nextTick();
+
+ findFormInput().vm.$emit('input', TEST_TITLE);
+ findForm().vm.$emit('submit', { preventDefault: () => {} });
+ await nextTick();
+
+ expect(queryHandler).toHaveBeenCalled();
+ expect(notCalledHandler).not.toHaveBeenCalled();
+ },
+ );
+ });
});
diff --git a/spec/frontend/boards/components/toggle_focus_spec.js b/spec/frontend/boards/components/toggle_focus_spec.js
index 3cbaac91f8d..cad287954d7 100644
--- a/spec/frontend/boards/components/toggle_focus_spec.js
+++ b/spec/frontend/boards/components/toggle_focus_spec.js
@@ -10,7 +10,7 @@ describe('ToggleFocus', () => {
const createComponent = () => {
wrapper = shallowMountExtended(ToggleFocus, {
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
attachTo: document.body,
});
@@ -18,10 +18,6 @@ describe('ToggleFocus', () => {
const findButton = () => wrapper.findComponent(GlButton);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a button with `maximize` icon', () => {
createComponent();
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 1d011eacf1c..ec3ae27b6a1 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -1,7 +1,6 @@
import { GlFilteredSearchToken } from '@gitlab/ui';
import { keyBy } from 'lodash';
import { ListType } from '~/boards/constants';
-import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import {
OPERATORS_IS,
OPERATORS_IS_NOT,
@@ -277,6 +276,9 @@ export const labels = [
},
];
+export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test';
+export const mockEpicFullPath = 'gitlab-org/test-subgroup';
+
export const rawIssue = {
title: 'Issue 1',
id: 'gid://gitlab/Issue/436',
@@ -302,12 +304,24 @@ export const rawIssue = {
epic: {
id: 'gid://gitlab/Epic/41',
},
+ totalTimeSpent: 0,
+ humanTimeEstimate: null,
+ humanTotalTimeSpent: null,
+ emailsDisabled: false,
+ hidden: false,
+ webUrl: `${mockIssueFullPath}/-/issue/27`,
+ relativePosition: null,
+ severity: null,
+ milestone: null,
+ weight: null,
+ blocked: false,
+ blockedByCount: 0,
+ iteration: null,
+ healthStatus: null,
type: 'ISSUE',
+ __typename: 'Issue',
};
-export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test';
-export const mockEpicFullPath = 'gitlab-org/test-subgroup';
-
export const mockIssue = {
id: 'gid://gitlab/Issue/436',
iid: '27',
@@ -329,7 +343,22 @@ export const mockIssue = {
epic: {
id: 'gid://gitlab/Epic/41',
},
+ totalTimeSpent: 0,
+ humanTimeEstimate: null,
+ humanTotalTimeSpent: null,
+ emailsDisabled: false,
+ hidden: false,
+ webUrl: `${mockIssueFullPath}/-/issue/27`,
+ relativePosition: null,
+ severity: null,
+ milestone: null,
+ weight: null,
+ blocked: false,
+ blockedByCount: 0,
+ iteration: null,
+ healthStatus: null,
type: 'ISSUE',
+ __typename: 'Issue',
};
export const mockEpic = {
@@ -425,45 +454,59 @@ export const mockIssue4 = {
epic: null,
};
-export const mockIssues = [mockIssue, mockIssue2];
+export const mockIssue5 = {
+ id: 'gid://gitlab/Issue/440',
+ iid: 40,
+ title: 'Issue 5',
+ referencePath: '#40',
+ dueDate: null,
+ timeEstimate: 0,
+ confidential: false,
+ path: '/gitlab-org/gitlab-test/-/issues/40',
+ assignees,
+ labels,
+ epic: null,
+};
-export const BoardsMockData = {
- GET: {
- '/test/-/boards/1/lists/300/issues?id=300&page=1': {
- issues: [
- {
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- labels: [],
- assignees: [],
- },
- ],
- },
- '/test/issue-boards/-/milestones.json': [
- {
- id: 1,
- title: 'test',
- },
- ],
- },
- POST: {
- '/test/-/boards/1/lists': listObj,
- },
- PUT: {
- '/test/issue-boards/-/board/1/lists{/id}': {},
- },
- DELETE: {
- '/test/issue-boards/-/board/1/lists{/id}': {},
- },
+export const mockIssue6 = {
+ id: 'gid://gitlab/Issue/441',
+ iid: 41,
+ title: 'Issue 6',
+ referencePath: '#41',
+ dueDate: null,
+ timeEstimate: 0,
+ confidential: false,
+ path: '/gitlab-org/gitlab-test/-/issues/41',
+ assignees,
+ labels,
+ epic: null,
};
-export const boardsMockInterceptor = (config) => {
- const body = BoardsMockData[config.method.toUpperCase()][config.url];
- return [HTTP_STATUS_OK, body];
+export const mockIssue7 = {
+ id: 'gid://gitlab/Issue/442',
+ iid: 42,
+ title: 'Issue 6',
+ referencePath: '#42',
+ dueDate: null,
+ timeEstimate: 0,
+ confidential: false,
+ path: '/gitlab-org/gitlab-test/-/issues/42',
+ assignees,
+ labels,
+ epic: null,
};
+export const mockIssues = [mockIssue, mockIssue2];
+export const mockIssuesMore = [
+ mockIssue,
+ mockIssue2,
+ mockIssue3,
+ mockIssue4,
+ mockIssue5,
+ mockIssue6,
+ mockIssue7,
+];
+
export const mockList = {
id: 'gid://gitlab/List/1',
title: 'Open',
@@ -477,6 +520,9 @@ export const mockList = {
loading: false,
issuesCount: 1,
maxIssueCount: 0,
+ metadata: {
+ epicsCount: 1,
+ },
__typename: 'BoardList',
};
@@ -552,23 +598,6 @@ export const issues = {
[mockIssue4.id]: mockIssue4,
};
-// The response from group project REST API
-export const mockRawGroupProjects = [
- {
- id: 0,
- name: 'Example Project',
- name_with_namespace: 'Awesome Group / Example Project',
- path_with_namespace: 'awesome-group/example-project',
- },
- {
- id: 1,
- name: 'Foobar Project',
- name_with_namespace: 'Awesome Group / Foobar Project',
- path_with_namespace: 'awesome-group/foobar-project',
- },
-];
-
-// The response from GraphQL endpoint
export const mockGroupProject1 = {
id: 0,
name: 'Example Project',
@@ -898,6 +927,22 @@ export const boardListsQueryResponse = {
},
};
+export const issueBoardListsQueryResponse = {
+ data: {
+ group: {
+ id: 'gid://gitlab/Group/1',
+ board: {
+ id: 'gid://gitlab/Board/1',
+ hideBacklogList: false,
+ lists: {
+ nodes: [mockLabelList],
+ },
+ },
+ __typename: 'Group',
+ },
+ },
+};
+
export const boardListQueryResponse = (issuesCount = 20) => ({
data: {
boardList: {
@@ -915,10 +960,50 @@ export const epicBoardListQueryResponse = (totalWeight = 5) => ({
__typename: 'EpicList',
id: 'gid://gitlab/Boards::EpicList/3',
metadata: {
+ epicsCount: 1,
totalWeight,
},
},
},
});
+export const updateIssueTitleResponse = {
+ data: {
+ updateIssuableTitle: {
+ issue: {
+ id: 'gid://gitlab/Issue/436',
+ title: 'Issue 1 edit',
+ },
+ },
+ },
+};
+
+export const updateEpicTitleResponse = {
+ data: {
+ updateIssuableTitle: {
+ epic: {
+ id: 'gid://gitlab/Epic/426',
+ title: 'Epic 1 edit',
+ },
+ },
+ },
+};
+
+export const updateBoardListResponse = {
+ data: {
+ updateBoardList: {
+ list: mockList,
+ },
+ },
+};
+
+export const destroyBoardListMutationResponse = {
+ data: {
+ destroyBoardList: {
+ errors: [],
+ __typename: 'DestroyBoardListPayload',
+ },
+ },
+};
+
export const DEFAULT_COLOR = '#1068bf';
diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js
index 4324e7068e0..74ce4b6b786 100644
--- a/spec/frontend/boards/project_select_spec.js
+++ b/spec/frontend/boards/project_select_spec.js
@@ -71,11 +71,6 @@ describe('ProjectSelect component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('displays a header title', () => {
createWrapper();
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index ab959abaa99..b8d3be28ca6 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -2,13 +2,7 @@ import * as Sentry from '@sentry/browser';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import Vuex from 'vuex';
-import {
- inactiveId,
- ISSUABLE,
- ListType,
- BoardType,
- DraggableItemTypes,
-} from 'ee_else_ce/boards/constants';
+import { inactiveId, ISSUABLE, ListType, DraggableItemTypes } from 'ee_else_ce/boards/constants';
import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import testAction from 'helpers/vuex_action_helper';
import {
@@ -26,7 +20,7 @@ import actions 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 { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import projectBoardMilestones from '~/boards/graphql/project_board_milestones.query.graphql';
import groupBoardMilestones from '~/boards/graphql/group_board_milestones.query.graphql';
@@ -49,7 +43,7 @@ import {
mockMilestones,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
// We need this helper to make sure projectPath is including
// subgroups when the movIssue action is called.
@@ -300,8 +294,8 @@ describe('fetchLists', () => {
it.each`
issuableType | boardType | fullBoardId | isGroup | isProject
- ${TYPE_ISSUE} | ${BoardType.group} | ${'gid://gitlab/Board/1'} | ${true} | ${false}
- ${TYPE_ISSUE} | ${BoardType.project} | ${'gid://gitlab/Board/1'} | ${false} | ${true}
+ ${TYPE_ISSUE} | ${WORKSPACE_GROUP} | ${'gid://gitlab/Board/1'} | ${true} | ${false}
+ ${TYPE_ISSUE} | ${WORKSPACE_PROJECT} | ${'gid://gitlab/Board/1'} | ${false} | ${true}
`(
'calls $issuableType query with correct variables',
async ({ issuableType, boardType, fullBoardId, isGroup, isProject }) => {
@@ -336,7 +330,7 @@ describe('fetchLists', () => {
describe('fetchMilestones', () => {
const queryResponse = {
data: {
- project: {
+ workspace: {
milestones: {
nodes: mockMilestones,
},
@@ -346,7 +340,7 @@ describe('fetchMilestones', () => {
const queryErrors = {
data: {
- project: {
+ workspace: {
errors: ['You cannot view these milestones'],
milestones: {},
},
@@ -407,7 +401,7 @@ describe('fetchMilestones', () => {
},
);
- it('sets milestonesLoading to true', async () => {
+ it('sets milestonesLoading to true', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js
index c86a256bd96..944a7493504 100644
--- a/spec/frontend/boards/stores/getters_spec.js
+++ b/spec/frontend/boards/stores/getters_spec.js
@@ -31,10 +31,6 @@ describe('Boards - Getters', () => {
});
describe('isSwimlanesOn', () => {
- afterEach(() => {
- window.gon = { features: {} };
- });
-
it('returns false', () => {
expect(getters.isSwimlanesOn()).toBe(false);
});
@@ -171,10 +167,6 @@ describe('Boards - Getters', () => {
});
describe('isEpicBoard', () => {
- afterEach(() => {
- window.gon = { features: {} };
- });
-
it('returns false', () => {
expect(getters.isEpicBoard()).toBe(false);
});
diff --git a/spec/frontend/bootstrap_linked_tabs_spec.js b/spec/frontend/bootstrap_linked_tabs_spec.js
index 5ee1ca32141..f900cd9da3b 100644
--- a/spec/frontend/bootstrap_linked_tabs_spec.js
+++ b/spec/frontend/bootstrap_linked_tabs_spec.js
@@ -1,9 +1,10 @@
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlLinkedTabs from 'test_fixtures_static/linked_tabs.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
describe('Linked Tabs', () => {
beforeEach(() => {
- loadHTMLFixture('static/linked_tabs.html');
+ setHTMLFixture(htmlLinkedTabs);
});
afterEach(() => {
diff --git a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap
index 6aab3b51806..300b6f4a39a 100644
--- a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap
+++ b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap
@@ -2,25 +2,34 @@
exports[`Delete merged branches component Delete merged branches confirmation modal matches snapshot 1`] = `
<div>
- <b-button-stub
- class="gl-mr-3 gl-button btn-danger-secondary"
- data-qa-selector="delete_merged_branches_button"
- size="md"
- tag="button"
- type="button"
- variant="danger"
+ <gl-base-dropdown-stub
+ category="tertiary"
+ class="gl-disclosure-dropdown"
+ data-qa-selector="delete_merged_branches_dropdown_button"
+ icon="ellipsis_v"
+ nocaret="true"
+ placement="right"
+ popperoptions="[object Object]"
+ size="medium"
+ textsronly="true"
+ toggleid="dropdown-toggle-btn-25"
+ toggletext=""
+ variant="default"
>
- <!---->
-
- <!---->
- <span
- class="gl-button-text"
+ <ul
+ aria-labelledby="dropdown-toggle-btn-25"
+ class="gl-new-dropdown-contents"
+ data-testid="disclosure-content"
+ id="disclosure-26"
+ tabindex="-1"
>
- Delete merged branches
-
- </span>
- </b-button-stub>
+ <gl-disclosure-dropdown-item-stub
+ item="[object Object]"
+ />
+ </ul>
+
+ </gl-base-dropdown-stub>
<div>
<form
diff --git a/spec/frontend/branches/components/delete_branch_button_spec.js b/spec/frontend/branches/components/delete_branch_button_spec.js
index b029f34c3d7..5b2ec443c59 100644
--- a/spec/frontend/branches/components/delete_branch_button_spec.js
+++ b/spec/frontend/branches/components/delete_branch_button_spec.js
@@ -25,10 +25,6 @@ describe('Delete branch button', () => {
eventHubSpy = jest.spyOn(eventHub, '$emit');
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the button with default tooltip, style, and icon', () => {
createComponent();
diff --git a/spec/frontend/branches/components/delete_branch_modal_spec.js b/spec/frontend/branches/components/delete_branch_modal_spec.js
index c977868ca93..7851d86466f 100644
--- a/spec/frontend/branches/components/delete_branch_modal_spec.js
+++ b/spec/frontend/branches/components/delete_branch_modal_spec.js
@@ -7,6 +7,8 @@ import DeleteBranchModal from '~/branches/components/delete_branch_modal.vue';
import eventHub from '~/branches/event_hub';
let wrapper;
+let showMock;
+let hideMock;
const branchName = 'test_modal';
const defaultBranchName = 'default';
@@ -14,23 +16,20 @@ const deletePath = '/path/to/branch';
const merged = false;
const isProtectedBranch = false;
-const createComponent = (data = {}) => {
+const createComponent = () => {
+ showMock = jest.fn();
+ hideMock = jest.fn();
+
wrapper = extendedWrapper(
shallowMount(DeleteBranchModal, {
- data() {
- return {
- branchName,
- deletePath,
- defaultBranchName,
- merged,
- isProtectedBranch,
- ...data,
- };
- },
stubs: {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
+ methods: {
+ show: showMock,
+ hide: hideMock,
+ },
}),
GlButton,
GlFormInput,
@@ -46,14 +45,29 @@ const findDeleteButton = () => wrapper.findByTestId('delete-branch-confirmation-
const findCancelButton = () => wrapper.findByTestId('delete-branch-cancel-button');
const findFormInput = () => wrapper.findComponent(GlFormInput);
const findForm = () => wrapper.find('form');
-const submitFormSpy = () => jest.spyOn(wrapper.vm.$refs.form, 'submit');
+const createSubmitFormSpy = () => jest.spyOn(findForm().element, 'submit');
+
+const emitOpenModal = (data = {}) =>
+ eventHub.$emit('openModal', {
+ isProtectedBranch,
+ branchName,
+ defaultBranchName,
+ deletePath,
+ merged,
+ ...data,
+ });
describe('Delete branch modal', () => {
const expectedUnmergedWarning =
"This branch hasn't been merged into default. To avoid data loss, consider merging this branch before deleting it.";
- afterEach(() => {
- wrapper.destroy();
+ beforeEach(() => {
+ createComponent();
+
+ emitOpenModal();
+
+ showMock.mockClear();
+ hideMock.mockClear();
});
describe('Deleting a regular branch', () => {
@@ -61,10 +75,6 @@ describe('Delete branch modal', () => {
const expectedWarning = "You're about to permanently delete the branch test_modal.";
const expectedMessage = `${expectedWarning} ${expectedUnmergedWarning}`;
- beforeEach(() => {
- createComponent();
- });
-
it('renders the modal correctly', () => {
expect(findModal().props('title')).toBe(expectedTitle);
expect(findModalMessage().text()).toMatchInterpolatedText(expectedMessage);
@@ -74,32 +84,30 @@ describe('Delete branch modal', () => {
});
it('submits the form when the delete button is clicked', () => {
+ const submitSpy = createSubmitFormSpy();
+
+ expect(submitSpy).not.toHaveBeenCalled();
+
findDeleteButton().trigger('click');
expect(findForm().attributes('action')).toBe(deletePath);
- expect(submitFormSpy()).toHaveBeenCalled();
+ expect(submitSpy).toHaveBeenCalled();
});
- it('calls show on the modal when a `openModal` event is received through the event hub', async () => {
- const showSpy = jest.spyOn(wrapper.vm.$refs.modal, 'show');
+ it('calls show on the modal when a `openModal` event is received through the event hub', () => {
+ expect(showMock).not.toHaveBeenCalled();
- eventHub.$emit('openModal', {
- isProtectedBranch,
- branchName,
- defaultBranchName,
- deletePath,
- merged,
- });
+ emitOpenModal();
- expect(showSpy).toHaveBeenCalled();
+ expect(showMock).toHaveBeenCalled();
});
it('calls hide on the modal when cancel button is clicked', () => {
- const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide');
+ expect(hideMock).not.toHaveBeenCalled();
findCancelButton().trigger('click');
- expect(closeModalSpy).toHaveBeenCalled();
+ expect(hideMock).toHaveBeenCalled();
});
});
@@ -112,7 +120,9 @@ describe('Delete branch modal', () => {
'After you confirm and select Yes, delete protected branch, you cannot recover this branch. Please type the following to confirm: test_modal';
beforeEach(() => {
- createComponent({ isProtectedBranch: true });
+ emitOpenModal({
+ isProtectedBranch: true,
+ });
});
describe('rendering the modal correctly for a protected branch', () => {
@@ -142,8 +152,11 @@ describe('Delete branch modal', () => {
await waitForPromises();
+ const submitSpy = createSubmitFormSpy();
+
findDeleteButton().trigger('click');
- expect(submitFormSpy()).not.toHaveBeenCalled();
+
+ expect(submitSpy).not.toHaveBeenCalled();
});
it('opens with the delete button disabled and enables it when branch name is confirmed and fires submit', async () => {
@@ -155,16 +168,23 @@ describe('Delete branch modal', () => {
expect(findDeleteButton().props('disabled')).not.toBe(true);
+ const submitSpy = createSubmitFormSpy();
+
+ expect(submitSpy).not.toHaveBeenCalled();
+
findDeleteButton().trigger('click');
- expect(submitFormSpy()).toHaveBeenCalled();
+
+ expect(submitSpy).toHaveBeenCalled();
});
});
describe('Deleting a merged branch', () => {
- it('does not include the unmerged branch warning when merged is true', () => {
- createComponent({ merged: true });
+ beforeEach(() => {
+ emitOpenModal({ merged: true });
+ });
- expect(findModalMessage().html()).not.toContain(expectedUnmergedWarning);
+ it('does not include the unmerged branch warning when merged is true', () => {
+ expect(findModalMessage().text()).not.toContain(expectedUnmergedWarning);
});
});
});
diff --git a/spec/frontend/branches/components/delete_merged_branches_spec.js b/spec/frontend/branches/components/delete_merged_branches_spec.js
index 4f1e772f4a4..4d8b887efd3 100644
--- a/spec/frontend/branches/components/delete_merged_branches_spec.js
+++ b/spec/frontend/branches/components/delete_merged_branches_spec.js
@@ -1,21 +1,27 @@
-import { GlButton, GlModal, GlFormInput, GlSprintf } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlButton, GlFormInput, GlModal, GlSprintf } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import DeleteMergedBranches, { i18n } from '~/branches/components/delete_merged_branches.vue';
import { formPath, propsDataMock } from '../mock_data';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
let wrapper;
+const modalShowSpy = jest.fn();
+const modalHideSpy = jest.fn();
const stubsData = {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
+ methods: {
+ show: modalShowSpy,
+ hide: modalHideSpy,
+ },
}),
+ GlDisclosureDropdown,
GlButton,
GlFormInput,
GlSprintf,
@@ -26,14 +32,12 @@ const createComponent = (mountFn = shallowMountExtended, stubs = {}) => {
propsData: {
...propsDataMock,
},
- directives: {
- GlTooltip: createMockDirective(),
- },
stubs,
});
};
-const findDeleteButton = () => wrapper.findComponent(GlButton);
+const findDeleteButton = () =>
+ wrapper.findComponent('[data-qa-selector="delete_merged_branches_button"]');
const findModal = () => wrapper.findComponent(GlModal);
const findConfirmationButton = () =>
wrapper.findByTestId('delete-merged-branches-confirmation-button');
@@ -48,28 +52,16 @@ describe('Delete merged branches component', () => {
});
describe('Delete merged branches button', () => {
- it('has correct attributes, text and tooltip', () => {
- expect(findDeleteButton().attributes()).toMatchObject({
- category: 'secondary',
- variant: 'danger',
- });
-
+ it('has correct text', () => {
+ createComponent(mount, stubsData);
expect(findDeleteButton().text()).toBe(i18n.deleteButtonText);
});
- it('displays a tooltip', () => {
- const tooltip = getBinding(findDeleteButton().element, 'gl-tooltip');
-
- expect(tooltip).toBeDefined();
- expect(tooltip.value).toBe(wrapper.vm.buttonTooltipText);
- });
-
it('opens modal when clicked', () => {
- createComponent(mount);
- jest.spyOn(wrapper.vm.$refs.modal, 'show');
+ createComponent(mount, stubsData);
findDeleteButton().trigger('click');
- expect(wrapper.vm.$refs.modal.show).toHaveBeenCalled();
+ expect(modalShowSpy).toHaveBeenCalled();
});
});
@@ -78,10 +70,6 @@ describe('Delete merged branches component', () => {
createComponent(shallowMountExtended, stubsData);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders correct modal title and text', () => {
const modalText = findModal().text();
expect(findModal().props('title')).toBe(i18n.modalTitle);
@@ -129,15 +117,13 @@ describe('Delete merged branches component', () => {
it('submits form when correct amount is provided and the confirm button is clicked', async () => {
findFormInput().vm.$emit('input', 'delete');
await waitForPromises();
- expect(findDeleteButton().props('disabled')).not.toBe(true);
findConfirmationButton().trigger('click');
expect(submitFormSpy()).toHaveBeenCalled();
});
it('calls hide on the modal when cancel button is clicked', () => {
- const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide');
findCancelButton().trigger('click');
- expect(closeModalSpy).toHaveBeenCalled();
+ expect(modalHideSpy).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/branches/components/divergence_graph_spec.js b/spec/frontend/branches/components/divergence_graph_spec.js
index 9429a6e982c..66193c2ebf0 100644
--- a/spec/frontend/branches/components/divergence_graph_spec.js
+++ b/spec/frontend/branches/components/divergence_graph_spec.js
@@ -9,10 +9,6 @@ function factory(propsData = {}) {
}
describe('Branch divergence graph component', () => {
- afterEach(() => {
- vm.destroy();
- });
-
it('renders ahead and behind count', () => {
factory({
defaultBranch: 'main',
diff --git a/spec/frontend/branches/components/graph_bar_spec.js b/spec/frontend/branches/components/graph_bar_spec.js
index 61c051b49c6..585b376081b 100644
--- a/spec/frontend/branches/components/graph_bar_spec.js
+++ b/spec/frontend/branches/components/graph_bar_spec.js
@@ -8,10 +8,6 @@ function factory(propsData = {}) {
}
describe('Branch divergence graph bar component', () => {
- afterEach(() => {
- vm.destroy();
- });
-
it.each`
position | positionClass
${'left'} | ${'position-right-0'}
diff --git a/spec/frontend/branches/components/sort_dropdown_spec.js b/spec/frontend/branches/components/sort_dropdown_spec.js
index bd41b0daaaa..64ef30bb8a8 100644
--- a/spec/frontend/branches/components/sort_dropdown_spec.js
+++ b/spec/frontend/branches/components/sort_dropdown_spec.js
@@ -29,12 +29,6 @@ describe('Branches Sort Dropdown', () => {
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByClick);
const findBranchesDropdown = () => wrapper.findByTestId('branches-dropdown');
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('When in overview mode', () => {
beforeEach(() => {
wrapper = createWrapper();
diff --git a/spec/frontend/captcha/captcha_modal_spec.js b/spec/frontend/captcha/captcha_modal_spec.js
index 20e69b5a834..4bbed8ab3bb 100644
--- a/spec/frontend/captcha/captcha_modal_spec.js
+++ b/spec/frontend/captcha/captcha_modal_spec.js
@@ -1,6 +1,5 @@
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import { stubComponent } from 'helpers/stub_component';
import CaptchaModal from '~/captcha/captcha_modal.vue';
import { initRecaptchaScript } from '~/captcha/init_recaptcha_script';
@@ -9,10 +8,11 @@ jest.mock('~/captcha/init_recaptcha_script');
describe('Captcha Modal', () => {
let wrapper;
- let modal;
let grecaptcha;
const captchaSiteKey = 'abc123';
+ const showSpy = jest.fn();
+ const hideSpy = jest.fn();
function createComponent({ props = {} } = {}) {
wrapper = shallowMount(CaptchaModal, {
@@ -21,11 +21,18 @@ describe('Captcha Modal', () => {
...props,
},
stubs: {
- GlModal: stubComponent(GlModal),
+ GlModal: stubComponent(GlModal, {
+ methods: {
+ show: showSpy,
+ hide: hideSpy,
+ },
+ }),
},
});
}
+ const findGlModal = () => wrapper.findComponent(GlModal);
+
beforeEach(() => {
grecaptcha = {
render: jest.fn(),
@@ -34,38 +41,17 @@ describe('Captcha Modal', () => {
initRecaptchaScript.mockResolvedValue(grecaptcha);
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- const findGlModal = () => {
- const glModal = wrapper.findComponent(GlModal);
-
- jest.spyOn(glModal.vm, 'show').mockImplementation(() => glModal.vm.$emit('shown'));
- jest
- .spyOn(glModal.vm, 'hide')
- .mockImplementation(() => glModal.vm.$emit('hide', { trigger: '' }));
-
- return glModal;
- };
-
- const showModal = () => {
- wrapper.setProps({ needsCaptchaResponse: true });
- };
-
- beforeEach(() => {
- createComponent();
- modal = findGlModal();
- });
-
describe('rendering', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
it('renders', () => {
- expect(modal.exists()).toBe(true);
+ expect(findGlModal().exists()).toBe(true);
});
it('assigns the modal a unique ID', () => {
- const firstInstanceModalId = modal.props('modalId');
+ const firstInstanceModalId = findGlModal().props('modalId');
createComponent();
const secondInstanceModalId = findGlModal().props('modalId');
expect(firstInstanceModalId).not.toEqual(secondInstanceModalId);
@@ -75,14 +61,13 @@ describe('Captcha Modal', () => {
describe('functionality', () => {
describe('when modal is shown', () => {
describe('when initRecaptchaScript promise resolves successfully', () => {
- beforeEach(async () => {
- showModal();
-
- await nextTick();
+ beforeEach(() => {
+ createComponent({ props: { needsCaptchaResponse: true } });
+ findGlModal().vm.$emit('shown');
});
- it('shows modal', async () => {
- expect(findGlModal().vm.show).toHaveBeenCalled();
+ it('shows modal', () => {
+ expect(showSpy).toHaveBeenCalled();
});
it('renders window.grecaptcha', () => {
@@ -105,10 +90,10 @@ describe('Captcha Modal', () => {
expect(wrapper.emitted('receivedCaptchaResponse')).toEqual([[captchaResponse]]);
});
- it('hides modal with null trigger', async () => {
+ it('hides modal with null trigger', () => {
// Assert that hide is called with zero args, so that we don't trigger the logic
// for hiding the modal via cancel, esc, headerclose, etc, without a captcha response
- expect(modal.vm.hide).toHaveBeenCalledWith();
+ expect(hideSpy).toHaveBeenCalledWith();
});
});
@@ -127,7 +112,7 @@ describe('Captcha Modal', () => {
const bvModalEvent = {
trigger,
};
- modal.vm.$emit('hide', bvModalEvent);
+ findGlModal().vm.$emit('hide', bvModalEvent);
});
it(`emits receivedCaptchaResponse with ${JSON.stringify(expected)}`, () => {
@@ -141,21 +126,24 @@ describe('Captcha Modal', () => {
const fakeError = {};
beforeEach(() => {
- initRecaptchaScript.mockImplementation(() => Promise.reject(fakeError));
+ createComponent({
+ props: { needsCaptchaResponse: true },
+ });
+ initRecaptchaScript.mockImplementation(() => Promise.reject(fakeError));
jest.spyOn(console, 'error').mockImplementation();
- showModal();
+ findGlModal().vm.$emit('shown');
});
it('emits receivedCaptchaResponse exactly once with null', () => {
expect(wrapper.emitted('receivedCaptchaResponse')).toEqual([[null]]);
});
- it('hides modal with null trigger', async () => {
+ it('hides modal with null trigger', () => {
// Assert that hide is called with zero args, so that we don't trigger the logic
// for hiding the modal via cancel, esc, headerclose, etc, without a captcha response
- expect(modal.vm.hide).toHaveBeenCalledWith();
+ expect(hideSpy).toHaveBeenCalledWith();
});
it('calls console.error with a message and the exception', () => {
diff --git a/spec/frontend/captcha/init_recaptcha_script_spec.js b/spec/frontend/captcha/init_recaptcha_script_spec.js
index 78480821d95..3e2d7ba00ee 100644
--- a/spec/frontend/captcha/init_recaptcha_script_spec.js
+++ b/spec/frontend/captcha/init_recaptcha_script_spec.js
@@ -50,7 +50,7 @@ describe('initRecaptchaScript', () => {
await expect(result).resolves.toBe(window.grecaptcha);
});
- it('sets window[RECAPTCHA_ONLOAD_CALLBACK_NAME] to undefined', async () => {
+ it('sets window[RECAPTCHA_ONLOAD_CALLBACK_NAME] to undefined', () => {
expect(getScriptOnload()).toBeUndefined();
});
});
diff --git a/spec/frontend/ci/artifacts/components/app_spec.js b/spec/frontend/ci/artifacts/components/app_spec.js
new file mode 100644
index 00000000000..c6874428e2a
--- /dev/null
+++ b/spec/frontend/ci/artifacts/components/app_spec.js
@@ -0,0 +1,118 @@
+import { GlSkeletonLoader } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import Vue from 'vue';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import ArtifactsApp from '~/ci/artifacts/components/app.vue';
+import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue';
+import getBuildArtifactsSizeQuery from '~/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { PAGE_TITLE, TOTAL_ARTIFACTS_SIZE, SIZE_UNKNOWN } from '~/ci/artifacts/constants';
+
+const TEST_BUILD_ARTIFACTS_SIZE = 1024;
+const TEST_PROJECT_PATH = 'project/path';
+const TEST_PROJECT_ID = 'gid://gitlab/Project/22';
+
+const createBuildArtifactsSizeResponse = ({
+ buildArtifactsSize = TEST_BUILD_ARTIFACTS_SIZE,
+ nullStatistics = false,
+}) => ({
+ data: {
+ project: {
+ __typename: 'Project',
+ id: TEST_PROJECT_ID,
+ statistics: nullStatistics
+ ? null
+ : {
+ __typename: 'ProjectStatistics',
+ buildArtifactsSize,
+ },
+ },
+ },
+});
+
+Vue.use(VueApollo);
+
+describe('ArtifactsApp component', () => {
+ let wrapper;
+ let apolloProvider;
+ let getBuildArtifactsSizeSpy;
+
+ const findTitle = () => wrapper.findByTestId('artifacts-page-title');
+ const findBuildArtifactsSize = () => wrapper.findByTestId('build-artifacts-size');
+ const findJobArtifactsTable = () => wrapper.findComponent(JobArtifactsTable);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ArtifactsApp, {
+ provide: { projectPath: 'project/path' },
+ apolloProvider,
+ });
+ };
+
+ beforeEach(() => {
+ getBuildArtifactsSizeSpy = jest.fn();
+
+ apolloProvider = createMockApollo([[getBuildArtifactsSizeQuery, getBuildArtifactsSizeSpy]]);
+ });
+
+ describe('when loading', () => {
+ beforeEach(() => {
+ // Promise that never resolves so it's always loading
+ getBuildArtifactsSizeSpy.mockReturnValue(new Promise(() => {}));
+
+ createComponent();
+ });
+
+ it('shows the page title', () => {
+ expect(findTitle().text()).toBe(PAGE_TITLE);
+ });
+
+ it('shows a skeleton while loading the artifacts size', () => {
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('shows the job artifacts table', () => {
+ expect(findJobArtifactsTable().exists()).toBe(true);
+ });
+
+ it('does not show message', () => {
+ expect(findBuildArtifactsSize().text()).toBe('');
+ });
+
+ it('calls apollo query', () => {
+ expect(getBuildArtifactsSizeSpy).toHaveBeenCalledWith({ projectPath: TEST_PROJECT_PATH });
+ });
+ });
+
+ describe.each`
+ buildArtifactsSize | nullStatistics | expectedText
+ ${TEST_BUILD_ARTIFACTS_SIZE} | ${false} | ${numberToHumanSize(TEST_BUILD_ARTIFACTS_SIZE)}
+ ${null} | ${false} | ${SIZE_UNKNOWN}
+ ${null} | ${true} | ${SIZE_UNKNOWN}
+ `(
+ 'when buildArtifactsSize is $buildArtifactsSize',
+ ({ buildArtifactsSize, nullStatistics, expectedText }) => {
+ beforeEach(async () => {
+ getBuildArtifactsSizeSpy.mockResolvedValue(
+ createBuildArtifactsSizeResponse({ buildArtifactsSize, nullStatistics }),
+ );
+
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('hides loader', () => {
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('shows the size', () => {
+ expect(findBuildArtifactsSize().text()).toMatchInterpolatedText(
+ `${TOTAL_ARTIFACTS_SIZE} ${expectedText}`,
+ );
+ });
+ },
+ );
+});
diff --git a/spec/frontend/ci/artifacts/components/artifact_row_spec.js b/spec/frontend/ci/artifacts/components/artifact_row_spec.js
new file mode 100644
index 00000000000..96ddedc3a9d
--- /dev/null
+++ b/spec/frontend/ci/artifacts/components/artifact_row_spec.js
@@ -0,0 +1,127 @@
+import { GlBadge, GlFriendlyWrap, GlFormCheckbox } from '@gitlab/ui';
+import mockGetJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ArtifactRow from '~/ci/artifacts/components/artifact_row.vue';
+import { BULK_DELETE_FEATURE_FLAG, I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants';
+
+describe('ArtifactRow component', () => {
+ let wrapper;
+
+ const artifact = mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[0];
+
+ const findName = () => wrapper.findByTestId('job-artifact-row-name');
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const findSize = () => wrapper.findByTestId('job-artifact-row-size');
+ const findDownloadButton = () => wrapper.findByTestId('job-artifact-row-download-button');
+ const findDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button');
+ const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+
+ const createComponent = ({ canDestroyArtifacts = true, glFeatures = {}, props = {} } = {}) => {
+ wrapper = shallowMountExtended(ArtifactRow, {
+ propsData: {
+ artifact,
+ isSelected: false,
+ isLoading: false,
+ isLastRow: false,
+ isSelectedArtifactsLimitReached: false,
+ ...props,
+ },
+ provide: { canDestroyArtifacts, glFeatures },
+ stubs: { GlBadge, GlFriendlyWrap },
+ });
+ };
+
+ describe('artifact details', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('displays the artifact name and type', () => {
+ expect(findName().text()).toContain(artifact.name);
+ expect(findBadge().text()).toBe(artifact.fileType.toLowerCase());
+ });
+
+ it('displays the artifact size', () => {
+ expect(findSize().text()).toBe(numberToHumanSize(artifact.size));
+ });
+
+ it('displays the download button as a link to the download path', () => {
+ expect(findDownloadButton().attributes('href')).toBe(artifact.downloadPath);
+ });
+ });
+
+ describe('delete button', () => {
+ it('does not show when user does not have permission', () => {
+ createComponent({ canDestroyArtifacts: false });
+
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+
+ it('shows when user has permission', () => {
+ createComponent();
+
+ expect(findDeleteButton().exists()).toBe(true);
+ });
+
+ it('emits the delete event when clicked', async () => {
+ createComponent();
+
+ expect(wrapper.emitted('delete')).toBeUndefined();
+
+ findDeleteButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(wrapper.emitted('delete')).toBeDefined();
+ });
+ });
+
+ describe('bulk delete checkbox', () => {
+ describe('with permission and feature flag enabled', () => {
+ it('emits selectArtifact when toggled', () => {
+ createComponent({ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true } });
+
+ findCheckbox().vm.$emit('input', true);
+
+ expect(wrapper.emitted('selectArtifact')).toStrictEqual([[artifact, true]]);
+ });
+
+ describe('when the selected artifacts limit is reached', () => {
+ it('remains enabled if the artifact was selected', () => {
+ createComponent({
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ props: { isSelected: true, isSelectedArtifactsLimitReached: true },
+ });
+
+ expect(findCheckbox().attributes('disabled')).toBeUndefined();
+ expect(findCheckbox().attributes('title')).toBe('');
+ });
+
+ it('is disabled if the artifact was not selected', () => {
+ createComponent({
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ props: { isSelected: false, isSelectedArtifactsLimitReached: true },
+ });
+
+ expect(findCheckbox().attributes('disabled')).toBeDefined();
+ expect(findCheckbox().attributes('title')).toBe(I18N_BULK_DELETE_MAX_SELECTED);
+ });
+ });
+ });
+
+ it('is not shown without permission', () => {
+ createComponent({ canDestroyArtifacts: false });
+
+ expect(findCheckbox().exists()).toBe(false);
+ });
+
+ it('is not shown with feature flag disabled', () => {
+ createComponent();
+
+ expect(findCheckbox().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js b/spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js
new file mode 100644
index 00000000000..549f6e1e375
--- /dev/null
+++ b/spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js
@@ -0,0 +1,58 @@
+import { GlSprintf, GlAlert } from '@gitlab/ui';
+import mockGetJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ArtifactsBulkDelete from '~/ci/artifacts/components/artifacts_bulk_delete.vue';
+import { I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants';
+
+describe('ArtifactsBulkDelete component', () => {
+ let wrapper;
+
+ const selectedArtifacts = [
+ mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[0].id,
+ mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[1].id,
+ ];
+
+ const findText = () => wrapper.findComponent(GlSprintf).text();
+ const findDeleteButton = () => wrapper.findByTestId('bulk-delete-delete-button');
+ const findClearButton = () => wrapper.findByTestId('bulk-delete-clear-button');
+ const findAlertText = () => wrapper.findComponent(GlAlert).text();
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(ArtifactsBulkDelete, {
+ propsData: {
+ selectedArtifacts,
+ isSelectedArtifactsLimitReached: false,
+ ...props,
+ },
+ stubs: { GlSprintf },
+ });
+ };
+
+ describe('selected artifacts box', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays selected artifacts count', () => {
+ expect(findText()).toContain(String(selectedArtifacts.length));
+ });
+
+ it('emits showBulkDeleteModal event when the delete button is clicked', () => {
+ findDeleteButton().vm.$emit('click');
+
+ expect(wrapper.emitted('showBulkDeleteModal')).toBeDefined();
+ });
+
+ it('emits clearSelectedArtifacts event when the clear button is clicked', () => {
+ findClearButton().vm.$emit('click');
+
+ expect(wrapper.emitted('clearSelectedArtifacts')).toBeDefined();
+ });
+ });
+
+ it('shows an alert when the selected artifacts limit is reached', () => {
+ createComponent({ isSelectedArtifactsLimitReached: true });
+
+ expect(findAlertText()).toBe(I18N_BULK_DELETE_MAX_SELECTED);
+ });
+});
diff --git a/spec/frontend/ci/artifacts/components/artifacts_table_row_details_spec.js b/spec/frontend/ci/artifacts/components/artifacts_table_row_details_spec.js
new file mode 100644
index 00000000000..479ecf6b183
--- /dev/null
+++ b/spec/frontend/ci/artifacts/components/artifacts_table_row_details_spec.js
@@ -0,0 +1,138 @@
+import { GlModal } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
+import waitForPromises from 'helpers/wait_for_promises';
+import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue';
+import ArtifactRow from '~/ci/artifacts/components/artifact_row.vue';
+import ArtifactDeleteModal from '~/ci/artifacts/components/artifact_delete_modal.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import destroyArtifactMutation from '~/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql';
+import { I18N_DESTROY_ERROR, I18N_MODAL_TITLE } from '~/ci/artifacts/constants';
+import { createAlert } from '~/alert';
+
+jest.mock('~/alert');
+
+const { artifacts } = getJobArtifactsResponse.data.project.jobs.nodes[0];
+const refetchArtifacts = jest.fn();
+
+Vue.use(VueApollo);
+
+describe('ArtifactsTableRowDetails component', () => {
+ let wrapper;
+ let requestHandlers;
+
+ const findModal = () => wrapper.findComponent(GlModal);
+
+ const createComponent = ({
+ handlers = {
+ destroyArtifactMutation: jest.fn(),
+ },
+ selectedArtifacts = [],
+ } = {}) => {
+ requestHandlers = handlers;
+ wrapper = mountExtended(ArtifactsTableRowDetails, {
+ apolloProvider: createMockApollo([
+ [destroyArtifactMutation, requestHandlers.destroyArtifactMutation],
+ ]),
+ propsData: {
+ artifacts,
+ selectedArtifacts,
+ refetchArtifacts,
+ queryVariables: {},
+ isSelectedArtifactsLimitReached: false,
+ },
+ provide: { canDestroyArtifacts: true },
+ data() {
+ return { deletingArtifactId: null };
+ },
+ });
+ };
+
+ describe('passes correct props', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('to the artifact rows', () => {
+ [0, 1, 2].forEach((index) => {
+ expect(wrapper.findAllComponents(ArtifactRow).at(index).props()).toMatchObject({
+ artifact: artifacts.nodes[index],
+ });
+ });
+ });
+ });
+
+ describe('when the artifact row emits the delete event', () => {
+ it('shows the artifact delete modal', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(findModal().props('visible')).toBe(false);
+
+ await wrapper.findComponent(ArtifactRow).vm.$emit('delete');
+
+ expect(findModal().props('visible')).toBe(true);
+ expect(findModal().props('title')).toBe(I18N_MODAL_TITLE(artifacts.nodes[0].name));
+ });
+ });
+
+ describe('when the artifact delete modal emits its primary event', () => {
+ it('triggers the destroyArtifact GraphQL mutation', async () => {
+ createComponent();
+ await waitForPromises();
+
+ wrapper.findComponent(ArtifactRow).vm.$emit('delete');
+ wrapper.findComponent(ArtifactDeleteModal).vm.$emit('primary');
+
+ expect(requestHandlers.destroyArtifactMutation).toHaveBeenCalledWith({
+ id: artifacts.nodes[0].id,
+ });
+ });
+
+ it('displays an alert message and refetches artifacts when the mutation fails', async () => {
+ createComponent({
+ destroyArtifactMutation: jest.fn().mockRejectedValue(new Error('Error!')),
+ });
+ await waitForPromises();
+
+ expect(wrapper.emitted('refetch')).toBeUndefined();
+
+ wrapper.findComponent(ArtifactRow).vm.$emit('delete');
+ wrapper.findComponent(ArtifactDeleteModal).vm.$emit('primary');
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({ message: I18N_DESTROY_ERROR });
+ expect(wrapper.emitted('refetch')).toBeDefined();
+ });
+ });
+
+ describe('when the artifact delete modal is cancelled', () => {
+ it('does not trigger the destroyArtifact GraphQL mutation', async () => {
+ createComponent();
+ await waitForPromises();
+
+ wrapper.findComponent(ArtifactRow).vm.$emit('delete');
+ wrapper.findComponent(ArtifactDeleteModal).vm.$emit('cancel');
+
+ expect(requestHandlers.destroyArtifactMutation).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('bulk delete selection', () => {
+ it('is not selected for unselected artifact', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(wrapper.findAllComponents(ArtifactRow).at(0).props('isSelected')).toBe(false);
+ });
+
+ it('is selected for selected artifacts', async () => {
+ createComponent({ selectedArtifacts: [artifacts.nodes[0].id] });
+ await waitForPromises();
+
+ expect(wrapper.findAllComponents(ArtifactRow).at(0).props('isSelected')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/ci/artifacts/components/feedback_banner_spec.js b/spec/frontend/ci/artifacts/components/feedback_banner_spec.js
new file mode 100644
index 00000000000..53e0fdac6f6
--- /dev/null
+++ b/spec/frontend/ci/artifacts/components/feedback_banner_spec.js
@@ -0,0 +1,59 @@
+import { GlBanner } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import FeedbackBanner from '~/ci/artifacts/components/feedback_banner.vue';
+import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
+import {
+ I18N_FEEDBACK_BANNER_TITLE,
+ I18N_FEEDBACK_BANNER_BUTTON,
+ FEEDBACK_URL,
+} from '~/ci/artifacts/constants';
+
+const mockBannerImagePath = 'banner/image/path';
+
+describe('Artifacts management feedback banner', () => {
+ let wrapper;
+ let userCalloutDismissSpy;
+
+ const findBanner = () => wrapper.findComponent(GlBanner);
+
+ const createComponent = ({ shouldShowCallout = true } = {}) => {
+ userCalloutDismissSpy = jest.fn();
+
+ wrapper = shallowMount(FeedbackBanner, {
+ provide: {
+ artifactsManagementFeedbackImagePath: mockBannerImagePath,
+ },
+ stubs: {
+ UserCalloutDismisser: makeMockUserCalloutDismisser({
+ dismiss: userCalloutDismissSpy,
+ shouldShowCallout,
+ }),
+ },
+ });
+ };
+
+ it('is displayed with the correct props', () => {
+ createComponent();
+
+ expect(findBanner().props()).toMatchObject({
+ title: I18N_FEEDBACK_BANNER_TITLE,
+ buttonText: I18N_FEEDBACK_BANNER_BUTTON,
+ buttonLink: FEEDBACK_URL,
+ svgPath: mockBannerImagePath,
+ });
+ });
+
+ it('dismisses the callout when closed', () => {
+ createComponent();
+
+ findBanner().vm.$emit('close');
+
+ expect(userCalloutDismissSpy).toHaveBeenCalled();
+ });
+
+ it('is not displayed once it has been dismissed', () => {
+ createComponent({ shouldShowCallout: false });
+
+ expect(findBanner().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js
new file mode 100644
index 00000000000..514644a92f2
--- /dev/null
+++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js
@@ -0,0 +1,684 @@
+import {
+ GlLoadingIcon,
+ GlTable,
+ GlLink,
+ GlBadge,
+ GlPagination,
+ GlModal,
+ GlFormCheckbox,
+} from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue';
+import FeedbackBanner from '~/ci/artifacts/components/feedback_banner.vue';
+import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue';
+import ArtifactDeleteModal from '~/ci/artifacts/components/artifact_delete_modal.vue';
+import ArtifactsBulkDelete from '~/ci/artifacts/components/artifacts_bulk_delete.vue';
+import BulkDeleteModal from '~/ci/artifacts/components/bulk_delete_modal.vue';
+import JobCheckbox from '~/ci/artifacts/components/job_checkbox.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import getJobArtifactsQuery from '~/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql';
+import bulkDestroyArtifactsMutation from '~/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
+import {
+ ARCHIVE_FILE_TYPE,
+ JOBS_PER_PAGE,
+ I18N_FETCH_ERROR,
+ INITIAL_CURRENT_PAGE,
+ BULK_DELETE_FEATURE_FLAG,
+ I18N_BULK_DELETE_ERROR,
+ SELECTED_ARTIFACTS_MAX_COUNT,
+} from '~/ci/artifacts/constants';
+import { totalArtifactsSizeForJob } from '~/ci/artifacts/utils';
+import { createAlert } from '~/alert';
+
+jest.mock('~/alert');
+
+Vue.use(VueApollo);
+
+describe('JobArtifactsTable component', () => {
+ let wrapper;
+ let requestHandlers;
+
+ const mockToastShow = jest.fn();
+
+ const findBanner = () => wrapper.findComponent(FeedbackBanner);
+
+ const findLoadingState = () => wrapper.findComponent(GlLoadingIcon);
+ const findTable = () => wrapper.findComponent(GlTable);
+ const findDetailsRows = () => wrapper.findAllComponents(ArtifactsTableRowDetails);
+ const findDetailsInRow = (i) =>
+ findTable().findAll('tbody tr').at(i).findComponent(ArtifactsTableRowDetails);
+
+ const findCount = () => wrapper.findByTestId('job-artifacts-count');
+ const findCountAt = (i) => wrapper.findAllByTestId('job-artifacts-count').at(i);
+
+ const findDeleteModal = () => wrapper.findComponent(ArtifactDeleteModal);
+ const findBulkDeleteModal = () => wrapper.findComponent(BulkDeleteModal);
+
+ const findStatuses = () => wrapper.findAllByTestId('job-artifacts-job-status');
+ const findSuccessfulJobStatus = () => findStatuses().at(0);
+ const findFailedJobStatus = () => findStatuses().at(1);
+
+ const findLinks = () => wrapper.findAllComponents(GlLink);
+ const findJobLink = () => findLinks().at(0);
+ const findPipelineLink = () => findLinks().at(1);
+ const findRefLink = () => findLinks().at(2);
+ const findCommitLink = () => findLinks().at(3);
+
+ const findSize = () => wrapper.findByTestId('job-artifacts-size');
+ const findCreated = () => wrapper.findByTestId('job-artifacts-created');
+
+ const findDownloadButton = () => wrapper.findByTestId('job-artifacts-download-button');
+ const findBrowseButton = () => wrapper.findByTestId('job-artifacts-browse-button');
+ const findDeleteButton = () => wrapper.findByTestId('job-artifacts-delete-button');
+ const findArtifactDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button');
+
+ // first checkbox is a "select all", this finder should get the first job checkbox
+ const findJobCheckbox = (i = 1) => wrapper.findAllComponents(GlFormCheckbox).at(i);
+ const findAnyCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+ const findBulkDelete = () => wrapper.findComponent(ArtifactsBulkDelete);
+ const findBulkDeleteContainer = () => wrapper.findByTestId('bulk-delete-container');
+
+ const findPagination = () => wrapper.findComponent(GlPagination);
+ const setPage = async (page) => {
+ findPagination().vm.$emit('input', page);
+ await waitForPromises();
+ };
+
+ const projectId = 'some/project/id';
+
+ let enoughJobsToPaginate = [...getJobArtifactsResponse.data.project.jobs.nodes];
+ while (enoughJobsToPaginate.length <= JOBS_PER_PAGE) {
+ enoughJobsToPaginate = [
+ ...enoughJobsToPaginate,
+ ...getJobArtifactsResponse.data.project.jobs.nodes,
+ ];
+ }
+ const getJobArtifactsResponseThatPaginates = {
+ data: {
+ project: {
+ jobs: {
+ nodes: enoughJobsToPaginate,
+ pageInfo: { ...getJobArtifactsResponse.data.project.jobs.pageInfo, hasNextPage: true },
+ },
+ },
+ },
+ };
+
+ const job = getJobArtifactsResponse.data.project.jobs.nodes[0];
+ const archiveArtifact = job.artifacts.nodes.find(
+ (artifact) => artifact.fileType === ARCHIVE_FILE_TYPE,
+ );
+ const job2 = getJobArtifactsResponse.data.project.jobs.nodes[1];
+
+ const destroyedCount = job.artifacts.nodes.length;
+ const destroyedIds = job.artifacts.nodes.map((node) => node.id);
+ const bulkDestroyMutationHandler = jest.fn().mockResolvedValue({
+ data: {
+ bulkDestroyJobArtifacts: { errors: [], destroyedCount, destroyedIds },
+ },
+ });
+
+ const maxSelectedArtifacts = new Array(SELECTED_ARTIFACTS_MAX_COUNT).fill({});
+
+ const createComponent = ({
+ handlers = {
+ getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse),
+ bulkDestroyArtifactsMutation: bulkDestroyMutationHandler,
+ },
+ data = {},
+ canDestroyArtifacts = true,
+ glFeatures = {},
+ } = {}) => {
+ requestHandlers = handlers;
+ wrapper = mountExtended(JobArtifactsTable, {
+ apolloProvider: createMockApollo([
+ [getJobArtifactsQuery, requestHandlers.getJobArtifactsQuery],
+ [bulkDestroyArtifactsMutation, requestHandlers.bulkDestroyArtifactsMutation],
+ ]),
+ provide: {
+ projectPath: 'project/path',
+ projectId,
+ canDestroyArtifacts,
+ artifactsManagementFeedbackImagePath: 'banner/image/path',
+ glFeatures,
+ },
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
+ },
+ data() {
+ return data;
+ },
+ });
+ };
+
+ it('renders feedback banner', () => {
+ createComponent();
+
+ expect(findBanner().exists()).toBe(true);
+ });
+
+ it('when loading, shows a loading state', () => {
+ createComponent();
+
+ expect(findLoadingState().exists()).toBe(true);
+ });
+
+ it('on error, shows an alert', async () => {
+ createComponent({
+ handlers: {
+ getJobArtifactsQuery: jest.fn().mockRejectedValue(new Error('Error!')),
+ },
+ });
+
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({ message: I18N_FETCH_ERROR });
+ });
+
+ it('with data, renders the table', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findTable().exists()).toBe(true);
+ });
+
+ describe('job details', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('shows the artifact count', () => {
+ expect(findCount().text()).toBe(`${job.artifacts.nodes.length} files`);
+ });
+
+ it('shows the job status as an icon for a successful job', () => {
+ expect(findSuccessfulJobStatus().findComponent(CiIcon).exists()).toBe(true);
+ expect(findSuccessfulJobStatus().findComponent(GlBadge).exists()).toBe(false);
+ });
+
+ it('shows the job status as a badge for other job statuses', () => {
+ expect(findFailedJobStatus().findComponent(GlBadge).exists()).toBe(true);
+ expect(findFailedJobStatus().findComponent(CiIcon).exists()).toBe(false);
+ });
+
+ it('shows links to the job, pipeline, ref, and commit', () => {
+ expect(findJobLink().text()).toBe(job.name);
+ expect(findJobLink().attributes('href')).toBe(job.webPath);
+
+ expect(findPipelineLink().text()).toBe(`#${getIdFromGraphQLId(job.pipeline.id)}`);
+ expect(findPipelineLink().attributes('href')).toBe(job.pipeline.path);
+
+ expect(findRefLink().text()).toBe(job.refName);
+ expect(findRefLink().attributes('href')).toBe(job.refPath);
+
+ expect(findCommitLink().text()).toBe(job.shortSha);
+ expect(findCommitLink().attributes('href')).toBe(job.commitPath);
+ });
+
+ it('shows the total size of artifacts', () => {
+ expect(findSize().text()).toBe(totalArtifactsSizeForJob(job));
+ });
+
+ it('shows the created time', () => {
+ expect(findCreated().text()).toBe('5 years ago');
+ });
+
+ describe('row expansion', () => {
+ it('toggles the visibility of the row details', async () => {
+ expect(findDetailsRows().length).toBe(0);
+
+ findCount().trigger('click');
+ await nextTick();
+
+ expect(findDetailsRows().length).toBe(1);
+
+ findCount().trigger('click');
+ await nextTick();
+
+ expect(findDetailsRows().length).toBe(0);
+ });
+
+ it('expands and collapses jobs', async () => {
+ // both jobs start collapsed
+ expect(findDetailsInRow(0).exists()).toBe(false);
+ expect(findDetailsInRow(1).exists()).toBe(false);
+
+ findCountAt(0).trigger('click');
+ await nextTick();
+
+ // first job is expanded, second row has its details
+ expect(findDetailsInRow(0).exists()).toBe(false);
+ expect(findDetailsInRow(1).exists()).toBe(true);
+ expect(findDetailsInRow(2).exists()).toBe(false);
+
+ findCountAt(1).trigger('click');
+ await nextTick();
+
+ // both jobs are expanded, each has details below it
+ expect(findDetailsInRow(0).exists()).toBe(false);
+ expect(findDetailsInRow(1).exists()).toBe(true);
+ expect(findDetailsInRow(2).exists()).toBe(false);
+ expect(findDetailsInRow(3).exists()).toBe(true);
+
+ findCountAt(0).trigger('click');
+ await nextTick();
+
+ // first job collapsed, second job expanded
+ expect(findDetailsInRow(0).exists()).toBe(false);
+ expect(findDetailsInRow(1).exists()).toBe(false);
+ expect(findDetailsInRow(2).exists()).toBe(true);
+ });
+
+ it('keeps the job expanded when an artifact is deleted', async () => {
+ findCount().trigger('click');
+ await waitForPromises();
+
+ expect(findDetailsInRow(0).exists()).toBe(false);
+ expect(findDetailsInRow(1).exists()).toBe(true);
+
+ findArtifactDeleteButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findDeleteModal().findComponent(GlModal).props('visible')).toBe(true);
+
+ findDeleteModal().vm.$emit('primary');
+ await waitForPromises();
+
+ expect(findDetailsInRow(0).exists()).toBe(false);
+ expect(findDetailsInRow(1).exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('download button', () => {
+ it('is a link to the download path for the archive artifact', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findDownloadButton().attributes('href')).toBe(archiveArtifact.downloadPath);
+ });
+
+ it('is disabled when there is no download path', async () => {
+ const jobWithoutDownloadPath = {
+ ...job,
+ archive: { downloadPath: null },
+ };
+
+ createComponent({
+ handlers: { getJobArtifactsQuery: jest.fn() },
+ data: { jobArtifacts: [jobWithoutDownloadPath] },
+ });
+
+ await waitForPromises();
+
+ expect(findDownloadButton().attributes('disabled')).toBeDefined();
+ });
+ });
+
+ describe('browse button', () => {
+ it('is a link to the browse path for the job', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findBrowseButton().attributes('href')).toBe(job.browseArtifactsPath);
+ });
+
+ it('is disabled when there is no browse path', async () => {
+ const jobWithoutBrowsePath = {
+ ...job,
+ browseArtifactsPath: null,
+ };
+
+ createComponent({
+ handlers: { getJobArtifactsQuery: jest.fn() },
+ data: { jobArtifacts: [jobWithoutBrowsePath] },
+ });
+
+ await waitForPromises();
+
+ expect(findBrowseButton().attributes('disabled')).toBeDefined();
+ });
+ });
+
+ describe('delete button', () => {
+ const artifactsFromJob = job.artifacts.nodes.map((node) => node.id);
+
+ describe('with delete permission and bulk delete feature flag enabled', () => {
+ beforeEach(async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ });
+
+ await waitForPromises();
+ });
+
+ it('opens the confirmation modal with the artifacts from the job', async () => {
+ await findDeleteButton().vm.$emit('click');
+
+ expect(findBulkDeleteModal().props()).toMatchObject({
+ visible: true,
+ artifactsToDelete: artifactsFromJob,
+ });
+ });
+
+ it('on confirm, deletes the artifacts from the job and shows a toast', async () => {
+ findDeleteButton().vm.$emit('click');
+ findBulkDeleteModal().vm.$emit('primary');
+
+ expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId),
+ ids: artifactsFromJob,
+ });
+
+ await waitForPromises();
+
+ expect(mockToastShow).toHaveBeenCalledWith(
+ `${artifactsFromJob.length} selected artifacts deleted`,
+ );
+ });
+
+ it('does not clear selected artifacts on success', async () => {
+ // select job 2 via checkbox
+ findJobCheckbox(2).vm.$emit('input', true);
+
+ // click delete button job 1
+ findDeleteButton().vm.$emit('click');
+
+ // job 2's artifacts should still be selected
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(
+ job2.artifacts.nodes.map((node) => node.id),
+ );
+
+ // confirm delete
+ findBulkDeleteModal().vm.$emit('primary');
+
+ // job 1's artifacts should be deleted
+ expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId),
+ ids: artifactsFromJob,
+ });
+
+ await waitForPromises();
+
+ // job 2's artifacts should still be selected
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(
+ job2.artifacts.nodes.map((node) => node.id),
+ );
+ });
+ });
+
+ it('shows an alert and does not clear selected artifacts on error', async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ handlers: {
+ getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse),
+ bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(),
+ },
+ });
+ await waitForPromises();
+
+ // select job 2 via checkbox
+ findJobCheckbox(2).vm.$emit('input', true);
+
+ // click delete button job 1
+ findDeleteButton().vm.$emit('click');
+
+ // confirm delete
+ findBulkDeleteModal().vm.$emit('primary');
+
+ await waitForPromises();
+
+ // job 2's artifacts should still be selected
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(
+ job2.artifacts.nodes.map((node) => node.id),
+ );
+ expect(createAlert).toHaveBeenCalledWith({
+ captureError: true,
+ error: expect.any(Error),
+ message: I18N_BULK_DELETE_ERROR,
+ });
+ });
+
+ it('is disabled when bulk delete feature flag is disabled', async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false },
+ });
+
+ await waitForPromises();
+
+ expect(findDeleteButton().attributes('disabled')).toBeDefined();
+ });
+
+ it('is hidden when user does not have delete permission', async () => {
+ createComponent({
+ canDestroyArtifacts: false,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false },
+ });
+
+ await waitForPromises();
+
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+ });
+
+ describe('bulk delete', () => {
+ const selectedArtifacts = job.artifacts.nodes.map((node) => node.id);
+
+ describe('with permission and feature flag enabled', () => {
+ beforeEach(async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ });
+
+ await waitForPromises();
+ });
+
+ it('shows selected artifacts when a job is checked', async () => {
+ expect(findBulkDeleteContainer().exists()).toBe(false);
+
+ await findJobCheckbox().vm.$emit('input', true);
+
+ expect(findBulkDeleteContainer().exists()).toBe(true);
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts);
+ });
+
+ it('disappears when selected artifacts are cleared', async () => {
+ await findJobCheckbox().vm.$emit('input', true);
+
+ expect(findBulkDeleteContainer().exists()).toBe(true);
+
+ await findBulkDelete().vm.$emit('clearSelectedArtifacts');
+
+ expect(findBulkDeleteContainer().exists()).toBe(false);
+ });
+
+ it('shows a modal to confirm bulk delete', async () => {
+ findJobCheckbox().vm.$emit('input', true);
+ findBulkDelete().vm.$emit('showBulkDeleteModal');
+
+ await nextTick();
+
+ expect(findBulkDeleteModal().props('visible')).toBe(true);
+ });
+
+ it('deletes the selected artifacts and shows a toast', async () => {
+ findJobCheckbox().vm.$emit('input', true);
+ findBulkDelete().vm.$emit('showBulkDeleteModal');
+ findBulkDeleteModal().vm.$emit('primary');
+
+ expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId),
+ ids: selectedArtifacts,
+ });
+
+ await waitForPromises();
+
+ expect(mockToastShow).toHaveBeenCalledWith(
+ `${selectedArtifacts.length} selected artifacts deleted`,
+ );
+ });
+
+ it('clears selected artifacts on success', async () => {
+ findJobCheckbox().vm.$emit('input', true);
+ findBulkDelete().vm.$emit('showBulkDeleteModal');
+ findBulkDeleteModal().vm.$emit('primary');
+
+ await waitForPromises();
+
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]);
+ });
+ });
+
+ describe('when the selected artifacts limit is reached', () => {
+ beforeEach(async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ data: { selectedArtifacts: maxSelectedArtifacts },
+ });
+
+ await nextTick();
+ });
+
+ it('passes isSelectedArtifactsLimitReached to bulk delete', () => {
+ expect(findBulkDelete().props('isSelectedArtifactsLimitReached')).toBe(true);
+ });
+
+ it('passes isSelectedArtifactsLimitReached to job checkbox', () => {
+ expect(wrapper.findComponent(JobCheckbox).props('isSelectedArtifactsLimitReached')).toBe(
+ true,
+ );
+ });
+
+ it('passes isSelectedArtifactsLimitReached to table row details', async () => {
+ findCount().trigger('click');
+ await nextTick();
+
+ expect(findDetailsInRow(1).props('isSelectedArtifactsLimitReached')).toBe(true);
+ });
+ });
+
+ it('shows an alert and does not clear selected artifacts on error', async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ handlers: {
+ getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse),
+ bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(),
+ },
+ });
+
+ await waitForPromises();
+
+ findJobCheckbox().vm.$emit('input', true);
+ findBulkDelete().vm.$emit('showBulkDeleteModal');
+ findBulkDeleteModal().vm.$emit('primary');
+
+ await waitForPromises();
+
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts);
+ expect(createAlert).toHaveBeenCalledWith({
+ captureError: true,
+ error: expect.any(Error),
+ message: I18N_BULK_DELETE_ERROR,
+ });
+ });
+
+ it('shows no checkboxes without permission', async () => {
+ createComponent({
+ canDestroyArtifacts: false,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ });
+
+ await waitForPromises();
+
+ expect(findAnyCheckbox().exists()).toBe(false);
+ });
+
+ it('shows no checkboxes with feature flag disabled', async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false },
+ });
+
+ await waitForPromises();
+
+ expect(findAnyCheckbox().exists()).toBe(false);
+ });
+ });
+
+ describe('pagination', () => {
+ const { pageInfo } = getJobArtifactsResponseThatPaginates.data.project.jobs;
+ const query = jest.fn().mockResolvedValue(getJobArtifactsResponseThatPaginates);
+
+ beforeEach(async () => {
+ createComponent({
+ handlers: {
+ getJobArtifactsQuery: query,
+ },
+ data: { pageInfo },
+ });
+
+ await nextTick();
+ });
+
+ it('renders pagination and passes page props', () => {
+ expect(findPagination().props()).toMatchObject({
+ value: INITIAL_CURRENT_PAGE,
+ prevPage: Number(pageInfo.hasPreviousPage),
+ nextPage: Number(pageInfo.hasNextPage),
+ });
+
+ expect(query).toHaveBeenCalledWith({
+ projectPath: 'project/path',
+ firstPageSize: JOBS_PER_PAGE,
+ lastPageSize: null,
+ nextPageCursor: '',
+ prevPageCursor: '',
+ });
+ });
+
+ it('updates query variables when going to previous page', async () => {
+ await setPage(1);
+
+ expect(query).toHaveBeenLastCalledWith({
+ projectPath: 'project/path',
+ firstPageSize: null,
+ lastPageSize: JOBS_PER_PAGE,
+ prevPageCursor: pageInfo.startCursor,
+ });
+ expect(findPagination().props('value')).toEqual(1);
+ });
+
+ it('updates query variables when going to next page', async () => {
+ await setPage(2);
+
+ expect(query).toHaveBeenLastCalledWith({
+ projectPath: 'project/path',
+ firstPageSize: JOBS_PER_PAGE,
+ lastPageSize: null,
+ prevPageCursor: '',
+ nextPageCursor: pageInfo.endCursor,
+ });
+ expect(findPagination().props('value')).toEqual(2);
+ });
+ });
+});
diff --git a/spec/frontend/ci/artifacts/components/job_checkbox_spec.js b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js
new file mode 100644
index 00000000000..8b47571239c
--- /dev/null
+++ b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js
@@ -0,0 +1,132 @@
+import { GlFormCheckbox } from '@gitlab/ui';
+import mockGetJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import JobCheckbox from '~/ci/artifacts/components/job_checkbox.vue';
+import { I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants';
+
+describe('JobCheckbox component', () => {
+ let wrapper;
+
+ const mockArtifactNodes = mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes;
+ const mockSelectedArtifacts = [mockArtifactNodes[0], mockArtifactNodes[1]];
+ const mockUnselectedArtifacts = [mockArtifactNodes[2]];
+
+ const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+
+ const createComponent = ({
+ hasArtifacts = true,
+ selectedArtifacts = mockSelectedArtifacts,
+ unselectedArtifacts = mockUnselectedArtifacts,
+ isSelectedArtifactsLimitReached = false,
+ } = {}) => {
+ wrapper = shallowMountExtended(JobCheckbox, {
+ propsData: {
+ hasArtifacts,
+ selectedArtifacts,
+ unselectedArtifacts,
+ isSelectedArtifactsLimitReached,
+ },
+ mocks: { GlFormCheckbox },
+ });
+ };
+
+ it('is disabled when the job has no artifacts', () => {
+ createComponent({ hasArtifacts: false });
+
+ expect(findCheckbox().attributes('disabled')).toBeDefined();
+ });
+
+ describe('when some artifacts from this job are selected', () => {
+ describe('when the selected artifacts limit has not been reached', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('is indeterminate', () => {
+ expect(findCheckbox().attributes('indeterminate')).toBe('true');
+ expect(findCheckbox().attributes('checked')).toBeUndefined();
+ });
+
+ it('selects the unselected artifacts on click', () => {
+ findCheckbox().vm.$emit('input', true);
+
+ expect(wrapper.emitted('selectArtifact')).toMatchObject([
+ [mockUnselectedArtifacts[0], true],
+ ]);
+ });
+ });
+
+ describe('when the selected artifacts limit has been reached', () => {
+ beforeEach(() => {
+ // limit has been reached by selecting artifacts from this job
+ createComponent({
+ selectedArtifacts: mockSelectedArtifacts,
+ isSelectedArtifactsLimitReached: true,
+ });
+ });
+
+ it('remains enabled', () => {
+ // job checkbox remains enabled to allow de-selection
+ expect(findCheckbox().attributes('disabled')).toBeUndefined();
+ expect(findCheckbox().attributes('title')).not.toBe(I18N_BULK_DELETE_MAX_SELECTED);
+ });
+ });
+ });
+
+ describe('when all artifacts from this job are selected', () => {
+ beforeEach(() => {
+ createComponent({ unselectedArtifacts: [] });
+ });
+
+ it('is checked', () => {
+ expect(findCheckbox().attributes('checked')).toBe('true');
+ });
+
+ it('deselects the selected artifacts on click', () => {
+ findCheckbox().vm.$emit('input', false);
+
+ expect(wrapper.emitted('selectArtifact')).toMatchObject([
+ [mockSelectedArtifacts[0], false],
+ [mockSelectedArtifacts[1], false],
+ ]);
+ });
+ });
+
+ describe('when no artifacts from this job are selected', () => {
+ describe('when the selected artifacts limit has not been reached', () => {
+ beforeEach(() => {
+ createComponent({ selectedArtifacts: [] });
+ });
+
+ it('is enabled and not checked', () => {
+ expect(findCheckbox().attributes('checked')).toBeUndefined();
+ expect(findCheckbox().attributes('disabled')).toBeUndefined();
+ expect(findCheckbox().attributes('title')).toBe('');
+ });
+
+ it('selects the artifacts on click', () => {
+ findCheckbox().vm.$emit('input', true);
+
+ expect(wrapper.emitted('selectArtifact')).toMatchObject([
+ [mockUnselectedArtifacts[0], true],
+ ]);
+ });
+ });
+
+ describe('when the selected artifacts limit has been reached', () => {
+ beforeEach(() => {
+ // limit has been reached by selecting artifacts from other jobs
+ createComponent({
+ selectedArtifacts: [],
+ isSelectedArtifactsLimitReached: true,
+ });
+ });
+
+ it('is disabled when the selected artifacts limit has been reached', () => {
+ // job checkbox is disabled to block further selection
+ expect(findCheckbox().attributes('disabled')).toBeDefined();
+ expect(findCheckbox().attributes('title')).toBe(I18N_BULK_DELETE_MAX_SELECTED);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/artifacts/graphql/cache_update_spec.js b/spec/frontend/ci/artifacts/graphql/cache_update_spec.js
new file mode 100644
index 00000000000..3c415534c7c
--- /dev/null
+++ b/spec/frontend/ci/artifacts/graphql/cache_update_spec.js
@@ -0,0 +1,67 @@
+import getJobArtifactsQuery from '~/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql';
+import { removeArtifactFromStore } from '~/ci/artifacts/graphql/cache_update';
+
+describe('Artifact table cache updates', () => {
+ let store;
+
+ const cacheMock = {
+ project: {
+ jobs: {
+ nodes: [
+ { artifacts: { nodes: [{ id: 'foo' }] } },
+ { artifacts: { nodes: [{ id: 'bar' }] } },
+ ],
+ },
+ },
+ };
+
+ const query = getJobArtifactsQuery;
+ const variables = { fullPath: 'path/to/project' };
+
+ beforeEach(() => {
+ store = {
+ readQuery: jest.fn().mockReturnValue(cacheMock),
+ writeQuery: jest.fn(),
+ };
+ });
+
+ describe('removeArtifactFromStore', () => {
+ it('calls readQuery', () => {
+ removeArtifactFromStore(store, 'foo', query, variables);
+ expect(store.readQuery).toHaveBeenCalledWith({ query, variables });
+ });
+
+ it('writes the correct result in the cache', () => {
+ removeArtifactFromStore(store, 'foo', query, variables);
+ expect(store.writeQuery).toHaveBeenCalledWith({
+ query,
+ variables,
+ data: {
+ project: {
+ jobs: {
+ nodes: [{ artifacts: { nodes: [] } }, { artifacts: { nodes: [{ id: 'bar' }] } }],
+ },
+ },
+ },
+ });
+ });
+
+ it('does not remove an unknown artifact', () => {
+ removeArtifactFromStore(store, 'baz', query, variables);
+ expect(store.writeQuery).toHaveBeenCalledWith({
+ query,
+ variables,
+ data: {
+ project: {
+ jobs: {
+ nodes: [
+ { artifacts: { nodes: [{ id: 'foo' }] } },
+ { artifacts: { nodes: [{ id: 'bar' }] } },
+ ],
+ },
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js
index d4f588a0e09..4b7ca36f331 100644
--- a/spec/frontend/ci/ci_lint/components/ci_lint_spec.js
+++ b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js
@@ -48,7 +48,6 @@ describe('CI Lint', () => {
afterEach(() => {
mockMutate.mockClear();
- wrapper.destroy();
});
it('displays the editor', () => {
diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
index e4abedb412f..8990a70d4ef 100644
--- a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
+++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
@@ -1,5 +1,7 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlPipelineSchedulesEdit from 'test_fixtures/pipeline_schedules/edit.html';
+import htmlPipelineSchedulesEditWithVariables from 'test_fixtures/pipeline_schedules/edit_with_variables.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import VariableList from '~/ci/ci_variable_list/ci_variable_list';
const HIDE_CLASS = 'hide';
@@ -11,7 +13,7 @@ describe('VariableList', () => {
describe('with only key/value inputs', () => {
describe('with no variables', () => {
beforeEach(() => {
- loadHTMLFixture('pipeline_schedules/edit.html');
+ setHTMLFixture(htmlPipelineSchedulesEdit);
$wrapper = $('.js-ci-variable-list-section');
variableList = new VariableList({
@@ -69,7 +71,7 @@ describe('VariableList', () => {
describe('with persisted variables', () => {
beforeEach(() => {
- loadHTMLFixture('pipeline_schedules/edit_with_variables.html');
+ setHTMLFixture(htmlPipelineSchedulesEditWithVariables);
$wrapper = $('.js-ci-variable-list-section');
variableList = new VariableList({
@@ -106,7 +108,7 @@ describe('VariableList', () => {
describe('toggleEnableRow method', () => {
beforeEach(() => {
- loadHTMLFixture('pipeline_schedules/edit_with_variables.html');
+ setHTMLFixture(htmlPipelineSchedulesEditWithVariables);
$wrapper = $('.js-ci-variable-list-section');
variableList = new VariableList({
diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
index 71e8e6d3afb..3ef5427f288 100644
--- a/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
+++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
@@ -1,12 +1,13 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlPipelineSchedulesEdit from 'test_fixtures/pipeline_schedules/edit.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import setupNativeFormVariableList from '~/ci/ci_variable_list/native_form_variable_list';
describe('NativeFormVariableList', () => {
let $wrapper;
beforeEach(() => {
- loadHTMLFixture('pipeline_schedules/edit.html');
+ setHTMLFixture(htmlPipelineSchedulesEdit);
$wrapper = $('.js-ci-variable-list-section');
setupNativeFormVariableList({
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js
index 5e0c35c9f90..1d0dcf242a4 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js
@@ -1,7 +1,16 @@
import { shallowMount } from '@vue/test-utils';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+} from '~/ci/ci_variable_list/constants';
import ciAdminVariables from '~/ci/ci_variable_list/components/ci_admin_variables.vue';
import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue';
+import addAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql';
+import deleteAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql';
+import updateAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql';
+import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql';
describe('Ci Project Variable wrapper', () => {
let wrapper;
@@ -16,18 +25,23 @@ describe('Ci Project Variable wrapper', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Passes down the correct props to ci_variable_shared', () => {
expect(findCiShared().props()).toEqual({
areScopedVariablesAvailable: false,
componentName: 'InstanceVariables',
entity: '',
hideEnvironmentScope: true,
- mutationData: wrapper.vm.$options.mutationData,
- queryData: wrapper.vm.$options.queryData,
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addAdminVariable,
+ [UPDATE_MUTATION_ACTION]: updateAdminVariable,
+ [DELETE_MUTATION_ACTION]: deleteAdminVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: expect.any(Function),
+ query: getAdminVariables,
+ },
+ },
refetchAfterMutation: true,
fullPath: null,
id: null,
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js
index 2fd395a1230..1937e3b34b7 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js
@@ -1,13 +1,17 @@
import { GlListboxItem, GlCollapsibleListbox, GlDropdownItem, GlIcon } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import { allEnvironments } from '~/ci/ci_variable_list/constants';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { allEnvironments, ENVIRONMENT_QUERY_LIMIT } from '~/ci/ci_variable_list/constants';
import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue';
describe('Ci environments dropdown', () => {
let wrapper;
const envs = ['dev', 'prod', 'staging'];
- const defaultProps = { environments: envs, selectedEnvironmentScope: '' };
+ const defaultProps = {
+ areEnvironmentsLoading: false,
+ environments: envs,
+ selectedEnvironmentScope: '',
+ };
const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem);
const findListboxItemByIndex = (index) => wrapper.findAllComponents(GlListboxItem).at(index);
@@ -15,22 +19,24 @@ describe('Ci environments dropdown', () => {
const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
const findListboxText = () => findListbox().props('toggleText');
const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem);
+ const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice');
- const createComponent = ({ props = {}, searchTerm = '' } = {}) => {
- wrapper = mount(CiEnvironmentsDropdown, {
+ const createComponent = ({ props = {}, searchTerm = '', enableFeatureFlag = false } = {}) => {
+ wrapper = mountExtended(CiEnvironmentsDropdown, {
propsData: {
...defaultProps,
...props,
},
+ provide: {
+ glFeatures: {
+ ciLimitEnvironmentScope: enableFeatureFlag,
+ },
+ },
});
findListbox().vm.$emit('search', searchTerm);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('No environments found', () => {
beforeEach(() => {
createComponent({ searchTerm: 'stable' });
@@ -44,19 +50,32 @@ describe('Ci environments dropdown', () => {
});
describe('Search term is empty', () => {
- beforeEach(() => {
- createComponent({ props: { environments: envs } });
- });
+ describe.each`
+ featureFlag | flagStatus | defaultEnvStatus | firstItemValue | envIndices
+ ${true} | ${'enabled'} | ${'prepends'} | ${'*'} | ${[1, 2, 3]}
+ ${false} | ${'disabled'} | ${'does not prepend'} | ${envs[0]} | ${[0, 1, 2]}
+ `(
+ 'when ciLimitEnvironmentScope feature flag is $flagStatus',
+ ({ featureFlag, defaultEnvStatus, firstItemValue, envIndices }) => {
+ beforeEach(() => {
+ createComponent({ props: { environments: envs }, enableFeatureFlag: featureFlag });
+ });
- it('renders all environments when search term is empty', () => {
- expect(findListboxItemByIndex(0).text()).toBe(envs[0]);
- expect(findListboxItemByIndex(1).text()).toBe(envs[1]);
- expect(findListboxItemByIndex(2).text()).toBe(envs[2]);
- });
+ it(`${defaultEnvStatus} * in listbox`, () => {
+ expect(findListboxItemByIndex(0).text()).toBe(firstItemValue);
+ });
- it('does not display active checkmark on the inactive stage', () => {
- expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true);
- });
+ it('renders all environments', () => {
+ expect(findListboxItemByIndex(envIndices[0]).text()).toBe(envs[0]);
+ expect(findListboxItemByIndex(envIndices[1]).text()).toBe(envs[1]);
+ expect(findListboxItemByIndex(envIndices[2]).text()).toBe(envs[2]);
+ });
+
+ it('does not display active checkmark', () => {
+ expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true);
+ });
+ },
+ );
});
describe('when `*` is the value of selectedEnvironmentScope props', () => {
@@ -72,46 +91,92 @@ describe('Ci environments dropdown', () => {
});
});
- describe('Environments found', () => {
+ describe('When ciLimitEnvironmentScope feature flag is disabled', () => {
const currentEnv = envs[2];
beforeEach(() => {
- createComponent({ searchTerm: currentEnv });
+ createComponent();
});
- it('renders only the environment searched for', () => {
+ it('filters on the frontend and renders only the environment searched for', async () => {
+ await findListbox().vm.$emit('search', currentEnv);
+
expect(findAllListboxItems()).toHaveLength(1);
expect(findListboxItemByIndex(0).text()).toBe(currentEnv);
});
- it('does not display create button', () => {
- expect(findCreateWildcardButton().exists()).toBe(false);
+ it('does not emit event when searching', async () => {
+ expect(wrapper.emitted('search-environment-scope')).toBeUndefined();
+
+ await findListbox().vm.$emit('search', currentEnv);
+
+ expect(wrapper.emitted('search-environment-scope')).toBeUndefined();
});
- describe('Custom events', () => {
- describe('when selecting an environment', () => {
- const itemIndex = 0;
+ it('does not display note about max environments shown', () => {
+ expect(findMaxEnvNote().exists()).toBe(false);
+ });
+ });
- beforeEach(() => {
- createComponent();
- });
+ describe('When ciLimitEnvironmentScope feature flag is enabled', () => {
+ const currentEnv = envs[2];
- it('emits `select-environment` when an environment is clicked', () => {
- findListbox().vm.$emit('select', envs[itemIndex]);
- expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]);
- });
+ beforeEach(() => {
+ createComponent({ enableFeatureFlag: true });
+ });
+
+ it('renders environments passed down to it', async () => {
+ await findListbox().vm.$emit('search', currentEnv);
+
+ expect(findAllListboxItems()).toHaveLength(envs.length);
+ });
+
+ it('emits event when searching', async () => {
+ expect(wrapper.emitted('search-environment-scope')).toHaveLength(1);
+
+ await findListbox().vm.$emit('search', currentEnv);
+
+ expect(wrapper.emitted('search-environment-scope')).toHaveLength(2);
+ expect(wrapper.emitted('search-environment-scope')[1]).toEqual([currentEnv]);
+ });
+
+ it('renders loading icon while search query is loading', () => {
+ createComponent({ enableFeatureFlag: true, props: { areEnvironmentsLoading: true } });
+
+ expect(findListbox().props('searching')).toBe(true);
+ });
+
+ it('displays note about max environments shown', () => {
+ expect(findMaxEnvNote().exists()).toBe(true);
+ expect(findMaxEnvNote().text()).toContain(String(ENVIRONMENT_QUERY_LIMIT));
+ });
+ });
+
+ describe('Custom events', () => {
+ describe('when selecting an environment', () => {
+ const itemIndex = 0;
+
+ beforeEach(() => {
+ createComponent();
});
- describe('when creating a new environment from a search term', () => {
- const search = 'new-env';
- beforeEach(() => {
- createComponent({ searchTerm: search });
- });
+ it('emits `select-environment` when an environment is clicked', () => {
+ findListbox().vm.$emit('select', envs[itemIndex]);
- it('emits create-environment-scope', () => {
- findCreateWildcardButton().vm.$emit('click');
- expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]);
- });
+ expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]);
+ });
+ });
+
+ describe('when creating a new environment from a search term', () => {
+ const search = 'new-env';
+ beforeEach(() => {
+ createComponent({ searchTerm: search });
+ });
+
+ it('emits create-environment-scope', () => {
+ findCreateWildcardButton().vm.$emit('click');
+
+ expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]);
});
});
});
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js
index c0fb133b9b1..7436210fe70 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js
@@ -4,6 +4,15 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import ciGroupVariables from '~/ci/ci_variable_list/components/ci_group_variables.vue';
import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+} from '~/ci/ci_variable_list/constants';
+import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql';
+import addGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql';
+import deleteGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql';
+import updateGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql';
const mockProvide = {
glFeatures: {
@@ -24,10 +33,6 @@ describe('Ci Group Variable wrapper', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Props', () => {
beforeEach(() => {
createComponent();
@@ -41,8 +46,17 @@ describe('Ci Group Variable wrapper', () => {
entity: 'group',
fullPath: mockProvide.groupPath,
hideEnvironmentScope: false,
- mutationData: wrapper.vm.$options.mutationData,
- queryData: wrapper.vm.$options.queryData,
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addGroupVariable,
+ [UPDATE_MUTATION_ACTION]: updateGroupVariable,
+ [DELETE_MUTATION_ACTION]: deleteGroupVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: expect.any(Function),
+ query: getGroupVariables,
+ },
+ },
refetchAfterMutation: false,
});
});
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js
index bd1e6b17d6b..69b0d4261b2 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js
@@ -4,6 +4,16 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import ciProjectVariables from '~/ci/ci_variable_list/components/ci_project_variables.vue';
import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+} from '~/ci/ci_variable_list/constants';
+import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql';
+import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql';
+import addProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql';
+import deleteProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql';
+import updateProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql';
const mockProvide = {
projectFullPath: '/namespace/project',
@@ -25,10 +35,6 @@ describe('Ci Project Variable wrapper', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Passes down the correct props to ci_variable_shared', () => {
expect(findCiShared().props()).toEqual({
id: convertToGraphQLId(TYPENAME_PROJECT, mockProvide.projectId),
@@ -37,8 +43,21 @@ describe('Ci Project Variable wrapper', () => {
entity: 'project',
fullPath: mockProvide.projectFullPath,
hideEnvironmentScope: false,
- mutationData: wrapper.vm.$options.mutationData,
- queryData: wrapper.vm.$options.queryData,
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addProjectVariable,
+ [UPDATE_MUTATION_ACTION]: updateProjectVariable,
+ [DELETE_MUTATION_ACTION]: deleteProjectVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: expect.any(Function),
+ query: getProjectVariables,
+ },
+ environments: {
+ lookup: expect.any(Function),
+ query: getProjectEnvironments,
+ },
+ },
refetchAfterMutation: false,
});
});
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js
index 508af964ca3..b6ffde9b33f 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js
@@ -10,10 +10,12 @@ import {
EVENT_LABEL,
EVENT_ACTION,
ENVIRONMENT_SCOPE_LINK_TITLE,
+ groupString,
instanceString,
+ projectString,
variableOptions,
} from '~/ci/ci_variable_list/constants';
-import { mockVariablesWithScopes } from '../mocks';
+import { mockEnvs, mockVariablesWithScopes, mockVariablesWithUniqueScopes } from '../mocks';
import ModalStub from '../stubs';
describe('Ci variable modal', () => {
@@ -42,12 +44,13 @@ describe('Ci variable modal', () => {
};
const defaultProps = {
+ areEnvironmentsLoading: false,
areScopedVariablesAvailable: true,
environments: [],
hideEnvironmentScope: false,
mode: ADD_VARIABLE_ACTION,
selectedVariable: {},
- variable: [],
+ variables: [],
};
const createComponent = ({ mountFn = shallowMountExtended, props = {}, provide = {} } = {}) => {
@@ -85,10 +88,6 @@ describe('Ci variable modal', () => {
const findVariableTypeDropdown = () => wrapper.find('#ci-variable-type');
const findEnvironmentScopeText = () => wrapper.findByText('Environment scope');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Adding a variable', () => {
describe('when no key/value pair are present', () => {
beforeEach(() => {
@@ -96,7 +95,7 @@ describe('Ci variable modal', () => {
});
it('shows the submit button as disabled', () => {
- expect(findAddorUpdateButton().attributes('disabled')).toBe('true');
+ expect(findAddorUpdateButton().attributes('disabled')).toBeDefined();
});
});
@@ -115,7 +114,6 @@ describe('Ci variable modal', () => {
beforeEach(() => {
createComponent({ props: { selectedVariable: currentVariable } });
- jest.spyOn(wrapper.vm, '$emit');
});
it('Dispatches `add-variable` action on submit', () => {
@@ -156,7 +154,7 @@ describe('Ci variable modal', () => {
findModal().vm.$emit('shown');
});
- it('keeps the value as false', async () => {
+ it('keeps the value as false', () => {
expect(
findProtectedVariableCheckbox().attributes('data-is-protected-checked'),
).toBeUndefined();
@@ -241,7 +239,6 @@ describe('Ci variable modal', () => {
it('defaults to expanded and raw:false when adding a variable', () => {
createComponent({ props: { selectedVariable: variable } });
- jest.spyOn(wrapper.vm, '$emit');
findModal().vm.$emit('shown');
@@ -266,7 +263,6 @@ describe('Ci variable modal', () => {
mode: EDIT_VARIABLE_ACTION,
},
});
- jest.spyOn(wrapper.vm, '$emit');
findModal().vm.$emit('shown');
await findExpandedVariableCheckbox().vm.$emit('change');
@@ -305,7 +301,6 @@ describe('Ci variable modal', () => {
beforeEach(() => {
createComponent({ props: { selectedVariable: variable, mode: EDIT_VARIABLE_ACTION } });
- jest.spyOn(wrapper.vm, '$emit');
});
it('button text is Update variable when updating', () => {
@@ -353,6 +348,42 @@ describe('Ci variable modal', () => {
expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE);
expect(link.attributes('href')).toBe(defaultProvide.environmentScopeLink);
});
+
+ describe('when feature flag is enabled', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ environments: mockEnvs,
+ variables: mockVariablesWithUniqueScopes(projectString),
+ },
+ provide: { glFeatures: { ciLimitEnvironmentScope: true } },
+ });
+ });
+
+ it('does not merge environment scope sources', () => {
+ const expectedLength = mockEnvs.length;
+
+ expect(findCiEnvironmentsDropdown().props('environments')).toHaveLength(expectedLength);
+ });
+ });
+
+ describe('when feature flag is disabled', () => {
+ const mockGroupVariables = mockVariablesWithUniqueScopes(groupString);
+ beforeEach(() => {
+ createComponent({
+ props: {
+ environments: mockEnvs,
+ variables: mockGroupVariables,
+ },
+ });
+ });
+
+ it('merges environment scope sources', () => {
+ const expectedLength = mockGroupVariables.length + mockEnvs.length;
+
+ expect(findCiEnvironmentsDropdown().props('environments')).toHaveLength(expectedLength);
+ });
+ });
});
describe('and section is hidden', () => {
@@ -476,7 +507,7 @@ describe('Ci variable modal', () => {
});
it('disables the submit button', () => {
- expect(findAddorUpdateButton().attributes('disabled')).toBe('disabled');
+ expect(findAddorUpdateButton().attributes('disabled')).toBeDefined();
});
it('shows the correct error text', () => {
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js
index 32af2ec4de9..12ca9a78369 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js
@@ -1,4 +1,3 @@
-import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import CiVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue';
import ciVariableModal from '~/ci/ci_variable_list/components/ci_variable_modal.vue';
@@ -16,12 +15,14 @@ describe('Ci variable table', () => {
let wrapper;
const defaultProps = {
+ areEnvironmentsLoading: false,
areScopedVariablesAvailable: true,
entity: 'project',
environments: mapEnvironmentNames(mockEnvs),
hideEnvironmentScope: false,
isLoading: false,
maxVariableLimit: 5,
+ pageInfo: { after: '' },
variables: mockVariablesWithScopes(projectString),
};
@@ -37,10 +38,6 @@ describe('Ci variable table', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('props passing', () => {
it('passes props down correctly to the ci table', () => {
createComponent();
@@ -49,6 +46,7 @@ describe('Ci variable table', () => {
entity: 'project',
isLoading: defaultProps.isLoading,
maxVariableLimit: defaultProps.maxVariableLimit,
+ pageInfo: defaultProps.pageInfo,
variables: defaultProps.variables,
});
});
@@ -56,10 +54,10 @@ describe('Ci variable table', () => {
it('passes props down correctly to the ci modal', async () => {
createComponent();
- findCiVariableTable().vm.$emit('set-selected-variable');
- await nextTick();
+ await findCiVariableTable().vm.$emit('set-selected-variable');
expect(findCiVariableModal().props()).toEqual({
+ areEnvironmentsLoading: defaultProps.areEnvironmentsLoading,
areScopedVariablesAvailable: defaultProps.areScopedVariablesAvailable,
environments: defaultProps.environments,
hideEnvironmentScope: defaultProps.hideEnvironmentScope,
@@ -76,15 +74,13 @@ describe('Ci variable table', () => {
});
it('passes down ADD mode when receiving an empty variable', async () => {
- findCiVariableTable().vm.$emit('set-selected-variable');
- await nextTick();
+ await findCiVariableTable().vm.$emit('set-selected-variable');
expect(findCiVariableModal().props('mode')).toBe(ADD_VARIABLE_ACTION);
});
it('passes down EDIT mode when receiving a variable', async () => {
- findCiVariableTable().vm.$emit('set-selected-variable', newVariable);
- await nextTick();
+ await findCiVariableTable().vm.$emit('set-selected-variable', newVariable);
expect(findCiVariableModal().props('mode')).toBe(EDIT_VARIABLE_ACTION);
});
@@ -100,25 +96,21 @@ describe('Ci variable table', () => {
});
it('shows modal when adding a new variable', async () => {
- findCiVariableTable().vm.$emit('set-selected-variable');
- await nextTick();
+ await findCiVariableTable().vm.$emit('set-selected-variable');
expect(findCiVariableModal().exists()).toBe(true);
});
it('shows modal when updating a variable', async () => {
- findCiVariableTable().vm.$emit('set-selected-variable', newVariable);
- await nextTick();
+ await findCiVariableTable().vm.$emit('set-selected-variable', newVariable);
expect(findCiVariableModal().exists()).toBe(true);
});
it('hides modal when receiving the event from the modal', async () => {
- findCiVariableTable().vm.$emit('set-selected-variable');
- await nextTick();
+ await findCiVariableTable().vm.$emit('set-selected-variable');
- findCiVariableModal().vm.$emit('hideModal');
- await nextTick();
+ await findCiVariableModal().vm.$emit('hideModal');
expect(findCiVariableModal().exists()).toBe(false);
});
@@ -135,13 +127,42 @@ describe('Ci variable table', () => {
${'update-variable'}
${'delete-variable'}
`('bubbles up the $eventName event', async ({ eventName }) => {
- findCiVariableTable().vm.$emit('set-selected-variable');
- await nextTick();
+ await findCiVariableTable().vm.$emit('set-selected-variable');
- findCiVariableModal().vm.$emit(eventName, newVariable);
- await nextTick();
+ await findCiVariableModal().vm.$emit(eventName, newVariable);
expect(wrapper.emitted(eventName)).toEqual([[newVariable]]);
});
});
+
+ describe('pages events', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it.each`
+ eventName | args
+ ${'handle-prev-page'} | ${undefined}
+ ${'handle-next-page'} | ${undefined}
+ ${'sort-changed'} | ${{ sortDesc: true }}
+ `('bubbles up the $eventName event', async ({ args, eventName }) => {
+ await findCiVariableTable().vm.$emit(eventName, args);
+
+ expect(wrapper.emitted(eventName)).toEqual([[args]]);
+ });
+ });
+
+ describe('environment events', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('bubbles up the search event', async () => {
+ await findCiVariableTable().vm.$emit('set-selected-variable');
+
+ await findCiVariableModal().vm.$emit('search-environment-scope', 'staging');
+
+ expect(wrapper.emitted('search-environment-scope')).toEqual([['staging']]);
+ });
+ });
});
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js
index c977ae773db..a25d325f7a1 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js
@@ -1,13 +1,12 @@
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { assertProps } from 'helpers/assert_props';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { resolvers } from '~/ci/ci_variable_list/graphql/settings';
-import { TYPENAME_GROUP } from '~/graphql_shared/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue';
import ciVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue';
@@ -18,12 +17,11 @@ import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_varia
import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql';
import {
- ADD_MUTATION_ACTION,
- DELETE_MUTATION_ACTION,
- UPDATE_MUTATION_ACTION,
+ ENVIRONMENT_QUERY_LIMIT,
environmentFetchErrorText,
genericMutationErrorText,
variableFetchErrorText,
+ mapMutationActionToToast,
} from '~/ci/ci_variable_list/constants';
import {
@@ -41,7 +39,7 @@ import {
mockAdminVariables,
} from '../mocks';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -53,6 +51,7 @@ const mockProvide = {
const defaultProps = {
areScopedVariablesAvailable: true,
+ pageInfo: {},
hideEnvironmentScope: false,
refetchAfterMutation: false,
};
@@ -62,15 +61,22 @@ describe('Ci Variable Shared Component', () => {
let mockApollo;
let mockEnvironments;
+ let mockMutation;
+ let mockAddMutation;
+ let mockUpdateMutation;
+ let mockDeleteMutation;
let mockVariables;
+ const mockToastShow = jest.fn();
+
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findCiTable = () => wrapper.findComponent(GlTable);
const findCiSettings = () => wrapper.findComponent(ciVariableSettings);
// eslint-disable-next-line consistent-return
- async function createComponentWithApollo({
+ function createComponentWithApollo({
customHandlers = null,
+ customResolvers = null,
isLoading = false,
props = { ...createProjectProps() },
provide = {},
@@ -80,7 +86,9 @@ describe('Ci Variable Shared Component', () => {
[getProjectVariables, mockVariables],
];
- mockApollo = createMockApollo(handlers, resolvers);
+ const mutationResolvers = customResolvers || resolvers;
+
+ mockApollo = createMockApollo(handlers, mutationResolvers);
wrapper = shallowMount(ciVariableShared, {
propsData: {
@@ -93,6 +101,11 @@ describe('Ci Variable Shared Component', () => {
},
apolloProvider: mockApollo,
stubs: { ciVariableSettings, ciVariableTable },
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
+ },
});
if (!isLoading) {
@@ -103,347 +116,525 @@ describe('Ci Variable Shared Component', () => {
beforeEach(() => {
mockEnvironments = jest.fn();
mockVariables = jest.fn();
+ mockMutation = jest.fn();
+ mockAddMutation = jest.fn();
+ mockUpdateMutation = jest.fn();
+ mockDeleteMutation = jest.fn();
});
- describe('while queries are being fetch', () => {
- beforeEach(() => {
- createComponentWithApollo({ isLoading: true });
- });
+ describe.each`
+ isVariablePagesEnabled | text
+ ${true} | ${'enabled'}
+ ${false} | ${'disabled'}
+ `('When Pages FF is $text', ({ isVariablePagesEnabled }) => {
+ const pagesFeatureFlagProvide = isVariablePagesEnabled
+ ? { glFeatures: { ciVariablesPages: true } }
+ : {};
+
+ describe('while queries are being fetched', () => {
+ beforeEach(() => {
+ createComponentWithApollo({ isLoading: true });
+ });
- it('shows a loading icon', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- expect(findCiTable().exists()).toBe(false);
+ it('shows a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findCiTable().exists()).toBe(false);
+ });
});
- });
- describe('when queries are resolved', () => {
- describe('successfully', () => {
- beforeEach(async () => {
- mockEnvironments.mockResolvedValue(mockProjectEnvironments);
- mockVariables.mockResolvedValue(mockProjectVariables);
+ describe('when queries are resolved', () => {
+ describe('successfully', () => {
+ beforeEach(async () => {
+ mockEnvironments.mockResolvedValue(mockProjectEnvironments);
+ mockVariables.mockResolvedValue(mockProjectVariables);
- await createComponentWithApollo({ provide: createProjectProvide() });
- });
+ await createComponentWithApollo({
+ provide: { ...createProjectProvide(), ...pagesFeatureFlagProvide },
+ });
+ });
- it('passes down the expected max variable limit as props', () => {
- expect(findCiSettings().props('maxVariableLimit')).toBe(
- mockProjectVariables.data.project.ciVariables.limit,
- );
- });
+ it('passes down the expected max variable limit as props', () => {
+ expect(findCiSettings().props('maxVariableLimit')).toBe(
+ mockProjectVariables.data.project.ciVariables.limit,
+ );
+ });
- it('passes down the expected environments as props', () => {
- expect(findCiSettings().props('environments')).toEqual([prodName, devName]);
- });
+ it('passes down the expected environments as props', () => {
+ expect(findCiSettings().props('environments')).toEqual([prodName, devName]);
+ });
- it('passes down the expected variables as props', () => {
- expect(findCiSettings().props('variables')).toEqual(
- mockProjectVariables.data.project.ciVariables.nodes,
- );
- });
+ it('passes down the expected variables as props', () => {
+ expect(findCiSettings().props('variables')).toEqual(
+ mockProjectVariables.data.project.ciVariables.nodes,
+ );
+ });
- it('createAlert was not called', () => {
- expect(createAlert).not.toHaveBeenCalled();
+ it('createAlert was not called', () => {
+ expect(createAlert).not.toHaveBeenCalled();
+ });
});
- });
- describe('with an error for variables', () => {
- beforeEach(async () => {
- mockEnvironments.mockResolvedValue(mockProjectEnvironments);
- mockVariables.mockRejectedValue();
+ describe('with an error for variables', () => {
+ beforeEach(async () => {
+ mockEnvironments.mockResolvedValue(mockProjectEnvironments);
+ mockVariables.mockRejectedValue();
- await createComponentWithApollo();
- });
+ await createComponentWithApollo({ provide: pagesFeatureFlagProvide });
+ });
- it('calls createAlert with the expected error message', () => {
- expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText });
+ it('calls createAlert with the expected error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText });
+ });
});
- });
- describe('with an error for environments', () => {
- beforeEach(async () => {
- mockEnvironments.mockRejectedValue();
- mockVariables.mockResolvedValue(mockProjectVariables);
+ describe('with an error for environments', () => {
+ beforeEach(async () => {
+ mockEnvironments.mockRejectedValue();
+ mockVariables.mockResolvedValue(mockProjectVariables);
- await createComponentWithApollo();
- });
+ await createComponentWithApollo({ provide: pagesFeatureFlagProvide });
+ });
- it('calls createAlert with the expected error message', () => {
- expect(createAlert).toHaveBeenCalledWith({ message: environmentFetchErrorText });
+ it('calls createAlert with the expected error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: environmentFetchErrorText });
+ });
});
});
- });
- describe('environment query', () => {
- describe('when there is an environment key in queryData', () => {
- beforeEach(async () => {
- mockEnvironments.mockResolvedValue(mockProjectEnvironments);
- mockVariables.mockResolvedValue(mockProjectVariables);
+ describe('environment query', () => {
+ describe('when there is an environment key in queryData', () => {
+ beforeEach(() => {
+ mockEnvironments.mockResolvedValue(mockProjectEnvironments);
- await createComponentWithApollo({ props: { ...createProjectProps() } });
- });
+ mockVariables.mockResolvedValue(mockProjectVariables);
+ });
- it('is executed', () => {
- expect(mockVariables).toHaveBeenCalled();
- });
- });
+ it('environments are fetched', async () => {
+ await createComponentWithApollo({
+ props: { ...createProjectProps() },
+ provide: pagesFeatureFlagProvide,
+ });
- describe('when there isnt an environment key in queryData', () => {
- beforeEach(async () => {
- mockVariables.mockResolvedValue(mockGroupVariables);
+ expect(mockEnvironments).toHaveBeenCalled();
+ });
- await createComponentWithApollo({ props: { ...createGroupProps() } });
- });
+ describe('when Limit Environment Scope FF is enabled', () => {
+ beforeEach(async () => {
+ await createComponentWithApollo({
+ props: { ...createProjectProps() },
+ provide: {
+ glFeatures: {
+ ciLimitEnvironmentScope: true,
+ ciVariablesPages: isVariablePagesEnabled,
+ },
+ },
+ });
+ });
- it('is skipped', () => {
- expect(mockVariables).not.toHaveBeenCalled();
- });
- });
- });
+ it('initial query is called with the correct variables', () => {
+ expect(mockEnvironments).toHaveBeenCalledWith({
+ first: ENVIRONMENT_QUERY_LIMIT,
+ fullPath: '/namespace/project/',
+ search: '',
+ });
+ });
- describe('mutations', () => {
- const groupProps = createGroupProps();
+ it(`refetches environments when search term is present`, async () => {
+ expect(mockEnvironments).toHaveBeenCalledTimes(1);
+ expect(mockEnvironments).toHaveBeenCalledWith(expect.objectContaining({ search: '' }));
- beforeEach(async () => {
- mockVariables.mockResolvedValue(mockGroupVariables);
+ await findCiSettings().vm.$emit('search-environment-scope', 'staging');
- await createComponentWithApollo({
- customHandlers: [[getGroupVariables, mockVariables]],
- props: groupProps,
- });
- });
- it.each`
- actionName | mutation | event
- ${'add'} | ${groupProps.mutationData[ADD_MUTATION_ACTION]} | ${'add-variable'}
- ${'update'} | ${groupProps.mutationData[UPDATE_MUTATION_ACTION]} | ${'update-variable'}
- ${'delete'} | ${groupProps.mutationData[DELETE_MUTATION_ACTION]} | ${'delete-variable'}
- `(
- 'calls the right mutation from propsData when user performs $actionName variable',
- async ({ event, mutation }) => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
-
- await findCiSettings().vm.$emit(event, newVariable);
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation,
- variables: {
- endpoint: mockProvide.endpoint,
- fullPath: groupProps.fullPath,
- id: convertToGraphQLId(TYPENAME_GROUP, groupProps.id),
- variable: newVariable,
- },
- });
- },
- );
-
- it.each`
- actionName | event
- ${'add'} | ${'add-variable'}
- ${'update'} | ${'update-variable'}
- ${'delete'} | ${'delete-variable'}
- `(
- 'throws with the specific graphql error if present when user performs $actionName variable',
- async ({ event }) => {
- const graphQLErrorMessage = 'There is a problem with this graphQL action';
- jest
- .spyOn(wrapper.vm.$apollo, 'mutate')
- .mockResolvedValue({ data: { ciVariableMutation: { errors: [graphQLErrorMessage] } } });
- await findCiSettings().vm.$emit(event, newVariable);
- await nextTick();
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
- expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage });
- },
- );
-
- it.each`
- actionName | event
- ${'add'} | ${'add-variable'}
- ${'update'} | ${'update-variable'}
- ${'delete'} | ${'delete-variable'}
- `(
- 'throws generic error on failure with no graphql errors and user performs $actionName variable',
- async ({ event }) => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => {
- throw new Error();
+ expect(mockEnvironments).toHaveBeenCalledTimes(2);
+ expect(mockEnvironments).toHaveBeenCalledWith(
+ expect.objectContaining({ search: 'staging' }),
+ );
+ });
});
- await findCiSettings().vm.$emit(event, newVariable);
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
- expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText });
- },
- );
+ describe('when Limit Environment Scope FF is disabled', () => {
+ beforeEach(async () => {
+ await createComponentWithApollo({
+ props: { ...createProjectProps() },
+ provide: pagesFeatureFlagProvide,
+ });
+ });
+
+ it('initial query is called with the correct variables', () => {
+ expect(mockEnvironments).toHaveBeenCalledWith({ fullPath: '/namespace/project/' });
+ });
- describe('without fullpath and ID props', () => {
- beforeEach(async () => {
- mockVariables.mockResolvedValue(mockAdminVariables);
+ it(`does not refetch environments when search term is present`, async () => {
+ expect(mockEnvironments).toHaveBeenCalledTimes(1);
- await createComponentWithApollo({
- customHandlers: [[getAdminVariables, mockVariables]],
- props: createInstanceProps(),
+ await findCiSettings().vm.$emit('search-environment-scope', 'staging');
+
+ expect(mockEnvironments).toHaveBeenCalledTimes(1);
+ });
});
});
- it('does not pass fullPath and ID to the mutation', async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
+ describe("when there isn't an environment key in queryData", () => {
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockGroupVariables);
- await findCiSettings().vm.$emit('add-variable', newVariable);
+ await createComponentWithApollo({
+ props: { ...createGroupProps() },
+ provide: pagesFeatureFlagProvide,
+ });
+ });
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: wrapper.props().mutationData[ADD_MUTATION_ACTION],
- variables: {
- endpoint: mockProvide.endpoint,
- variable: newVariable,
- },
+ it('fetching environments is skipped', () => {
+ expect(mockEnvironments).not.toHaveBeenCalled();
});
});
});
- });
- describe('Props', () => {
- const mockGroupCiVariables = mockGroupVariables.data.group.ciVariables;
- const mockProjectCiVariables = mockProjectVariables.data.project.ciVariables;
+ describe('mutations', () => {
+ const groupProps = createGroupProps();
+ const instanceProps = createInstanceProps();
+ const projectProps = createProjectProps();
- describe('in a specific context as', () => {
- it.each`
- name | mockVariablesValue | mockEnvironmentsValue | withEnvironments | expectedEnvironments | propsFn | provideFn | mutation | maxVariableLimit
- ${'project'} | ${mockProjectVariables} | ${mockProjectEnvironments} | ${true} | ${['prod', 'dev']} | ${createProjectProps} | ${createProjectProvide} | ${null} | ${mockProjectCiVariables.limit}
- ${'group'} | ${mockGroupVariables} | ${[]} | ${false} | ${[]} | ${createGroupProps} | ${createGroupProvide} | ${getGroupVariables} | ${mockGroupCiVariables.limit}
- ${'instance'} | ${mockAdminVariables} | ${[]} | ${false} | ${[]} | ${createInstanceProps} | ${() => {}} | ${getAdminVariables} | ${0}
- `(
- 'passes down all the required props when its a $name component',
- async ({
- mutation,
- maxVariableLimit,
- mockVariablesValue,
- mockEnvironmentsValue,
- withEnvironments,
- expectedEnvironments,
- propsFn,
- provideFn,
- }) => {
- const props = propsFn();
- const provide = provideFn();
+ let mockMutationMap;
- mockVariables.mockResolvedValue(mockVariablesValue);
+ describe('error handling and feedback', () => {
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockGroupVariables);
+ mockMutation.mockResolvedValue({ ...mockGroupVariables.data, errors: [] });
+
+ await createComponentWithApollo({
+ customHandlers: [[getGroupVariables, mockVariables]],
+ customResolvers: {
+ Mutation: {
+ ...resolvers.Mutation,
+ addGroupVariable: mockMutation,
+ updateGroupVariable: mockMutation,
+ deleteGroupVariable: mockMutation,
+ },
+ },
+ props: groupProps,
+ provide: pagesFeatureFlagProvide,
+ });
+ });
- if (withEnvironments) {
- mockEnvironments.mockResolvedValue(mockEnvironmentsValue);
- }
+ it.each`
+ actionName | event
+ ${'add'} | ${'add-variable'}
+ ${'update'} | ${'update-variable'}
+ ${'delete'} | ${'delete-variable'}
+ `(
+ 'throws the specific graphql error if present when user performs $actionName variable',
+ async ({ event }) => {
+ const graphQLErrorMessage = 'There is a problem with this graphQL action';
+ mockMutation.mockResolvedValue({
+ ...mockGroupVariables.data,
+ errors: [graphQLErrorMessage],
+ });
- let customHandlers = null;
+ await findCiSettings().vm.$emit(event, newVariable);
+ await waitForPromises();
- if (mutation) {
- customHandlers = [[mutation, mockVariables]];
- }
+ expect(mockMutation).toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage });
+ },
+ );
- await createComponentWithApollo({ customHandlers, props, provide });
+ it.each`
+ actionName | event
+ ${'add'} | ${'add-variable'}
+ ${'update'} | ${'update-variable'}
+ ${'delete'} | ${'delete-variable'}
+ `(
+ 'throws generic error on failure with no graphql errors and user performs $actionName variable',
+ async ({ event }) => {
+ mockMutation.mockRejectedValue();
+
+ await findCiSettings().vm.$emit(event, newVariable);
+ await waitForPromises();
+
+ expect(mockMutation).toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText });
+ },
+ );
- expect(findCiSettings().props()).toEqual({
- areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable,
- hideEnvironmentScope: defaultProps.hideEnvironmentScope,
- isLoading: false,
- maxVariableLimit,
- variables: wrapper.props().queryData.ciVariables.lookup(mockVariablesValue.data)?.nodes,
- entity: props.entity,
- environments: expectedEnvironments,
+ it.each`
+ actionName | event
+ ${'add'} | ${'add-variable'}
+ ${'update'} | ${'update-variable'}
+ ${'delete'} | ${'delete-variable'}
+ `(
+ 'displays toast message after user performs $actionName variable',
+ async ({ actionName, event }) => {
+ await findCiSettings().vm.$emit(event, newVariable);
+ await waitForPromises();
+
+ expect(mockMutation).toHaveBeenCalled();
+ expect(mockToastShow).toHaveBeenCalledWith(
+ mapMutationActionToToast[actionName](newVariable.key),
+ );
+ },
+ );
+ });
+
+ const setupMockMutations = (mockResolvedMutation) => {
+ mockAddMutation.mockResolvedValue(mockResolvedMutation);
+ mockUpdateMutation.mockResolvedValue(mockResolvedMutation);
+ mockDeleteMutation.mockResolvedValue(mockResolvedMutation);
+
+ return {
+ add: mockAddMutation,
+ update: mockUpdateMutation,
+ delete: mockDeleteMutation,
+ };
+ };
+
+ describe.each`
+ scope | mockVariablesResolvedValue | getVariablesHandler | addMutationName | updateMutationName | deleteMutationName | props
+ ${'instance'} | ${mockVariables} | ${getAdminVariables} | ${'addAdminVariable'} | ${'updateAdminVariable'} | ${'deleteAdminVariable'} | ${instanceProps}
+ ${'group'} | ${mockGroupVariables} | ${getGroupVariables} | ${'addGroupVariable'} | ${'updateGroupVariable'} | ${'deleteGroupVariable'} | ${groupProps}
+ ${'project'} | ${mockProjectVariables} | ${getProjectVariables} | ${'addProjectVariable'} | ${'updateProjectVariable'} | ${'deleteProjectVariable'} | ${projectProps}
+ `(
+ '$scope variable mutations',
+ ({
+ addMutationName,
+ deleteMutationName,
+ getVariablesHandler,
+ mockVariablesResolvedValue,
+ updateMutationName,
+ props,
+ }) => {
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockVariablesResolvedValue);
+ mockMutationMap = setupMockMutations({ ...mockVariables.data, errors: [] });
+
+ await createComponentWithApollo({
+ customHandlers: [[getVariablesHandler, mockVariables]],
+ customResolvers: {
+ Mutation: {
+ ...resolvers.Mutation,
+ [addMutationName]: mockAddMutation,
+ [updateMutationName]: mockUpdateMutation,
+ [deleteMutationName]: mockDeleteMutation,
+ },
+ },
+ props,
+ provide: pagesFeatureFlagProvide,
+ });
});
+
+ it.each`
+ actionName | event
+ ${'add'} | ${'add-variable'}
+ ${'update'} | ${'update-variable'}
+ ${'delete'} | ${'delete-variable'}
+ `(
+ 'calls the right mutation when user performs $actionName variable',
+ async ({ event, actionName }) => {
+ await findCiSettings().vm.$emit(event, newVariable);
+ await waitForPromises();
+
+ expect(mockMutationMap[actionName]).toHaveBeenCalledWith(
+ expect.anything(),
+ {
+ endpoint: mockProvide.endpoint,
+ fullPath: props.fullPath,
+ id: props.id,
+ variable: newVariable,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
+ },
+ );
},
);
- });
- describe('refetchAfterMutation', () => {
- it.each`
- bool | text
- ${true} | ${'refetches the variables'}
- ${false} | ${'does not refetch the variables'}
- `('when $bool it $text', async ({ bool }) => {
- await createComponentWithApollo({
- props: { ...createInstanceProps(), refetchAfterMutation: bool },
+ describe('without fullpath and ID props', () => {
+ beforeEach(async () => {
+ mockMutation.mockResolvedValue({ ...mockAdminVariables.data, errors: [] });
+ mockVariables.mockResolvedValue(mockAdminVariables);
+
+ await createComponentWithApollo({
+ customHandlers: [[getAdminVariables, mockVariables]],
+ customResolvers: {
+ Mutation: {
+ ...resolvers.Mutation,
+ addAdminVariable: mockMutation,
+ },
+ },
+ props: createInstanceProps(),
+ provide: pagesFeatureFlagProvide,
+ });
});
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: {} });
- jest.spyOn(wrapper.vm.$apollo.queries.ciVariables, 'refetch').mockImplementation(jest.fn());
+ it('does not pass fullPath and ID to the mutation', async () => {
+ await findCiSettings().vm.$emit('add-variable', newVariable);
+ await waitForPromises();
+
+ expect(mockMutation).toHaveBeenCalledWith(
+ expect.anything(),
+ {
+ endpoint: mockProvide.endpoint,
+ variable: newVariable,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+ });
- await findCiSettings().vm.$emit('add-variable', newVariable);
+ describe('Props', () => {
+ const mockGroupCiVariables = mockGroupVariables.data.group.ciVariables;
+ const mockProjectCiVariables = mockProjectVariables.data.project.ciVariables;
+
+ describe('in a specific context as', () => {
+ it.each`
+ name | mockVariablesValue | mockEnvironmentsValue | withEnvironments | expectedEnvironments | propsFn | provideFn | mutation | maxVariableLimit
+ ${'project'} | ${mockProjectVariables} | ${mockProjectEnvironments} | ${true} | ${['prod', 'dev']} | ${createProjectProps} | ${createProjectProvide} | ${null} | ${mockProjectCiVariables.limit}
+ ${'group'} | ${mockGroupVariables} | ${[]} | ${false} | ${[]} | ${createGroupProps} | ${createGroupProvide} | ${getGroupVariables} | ${mockGroupCiVariables.limit}
+ ${'instance'} | ${mockAdminVariables} | ${[]} | ${false} | ${[]} | ${createInstanceProps} | ${() => {}} | ${getAdminVariables} | ${0}
+ `(
+ 'passes down all the required props when its a $name component',
+ async ({
+ mutation,
+ maxVariableLimit,
+ mockVariablesValue,
+ mockEnvironmentsValue,
+ withEnvironments,
+ expectedEnvironments,
+ propsFn,
+ provideFn,
+ }) => {
+ const props = propsFn();
+ const provide = provideFn();
- await nextTick();
+ mockVariables.mockResolvedValue(mockVariablesValue);
- if (bool) {
- expect(wrapper.vm.$apollo.queries.ciVariables.refetch).toHaveBeenCalled();
- } else {
- expect(wrapper.vm.$apollo.queries.ciVariables.refetch).not.toHaveBeenCalled();
- }
- });
- });
+ if (withEnvironments) {
+ mockEnvironments.mockResolvedValue(mockEnvironmentsValue);
+ }
- describe('Validators', () => {
- describe('queryData', () => {
- let error;
+ let customHandlers = null;
- beforeEach(async () => {
- mockVariables.mockResolvedValue(mockGroupVariables);
- });
+ if (mutation) {
+ customHandlers = [[mutation, mockVariables]];
+ }
- it('will mount component with right data', async () => {
- try {
await createComponentWithApollo({
- customHandlers: [[getGroupVariables, mockVariables]],
- props: { ...createGroupProps() },
+ customHandlers,
+ props,
+ provide: { ...provide, ...pagesFeatureFlagProvide },
});
- } catch (e) {
- error = e;
- } finally {
- expect(wrapper.exists()).toBe(true);
- expect(error).toBeUndefined();
- }
- });
- it('will not mount component with wrong data', async () => {
- try {
- await createComponentWithApollo({
- customHandlers: [[getGroupVariables, mockVariables]],
- props: { ...createGroupProps(), queryData: { wrongKey: {} } },
+ expect(findCiSettings().props()).toEqual({
+ areEnvironmentsLoading: false,
+ areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable,
+ hideEnvironmentScope: defaultProps.hideEnvironmentScope,
+ pageInfo: defaultProps.pageInfo,
+ isLoading: false,
+ maxVariableLimit,
+ variables: wrapper.props().queryData.ciVariables.lookup(mockVariablesValue.data)
+ ?.nodes,
+ entity: props.entity,
+ environments: expectedEnvironments,
});
- } catch (e) {
- error = e;
- } finally {
- expect(wrapper.exists()).toBe(false);
- expect(error.toString()).toContain('custom validator check failed for prop');
- }
- });
+ },
+ );
});
- describe('mutationData', () => {
- let error;
+ describe('refetchAfterMutation', () => {
+ it.each`
+ bool | text | timesQueryCalled
+ ${true} | ${'refetches the variables'} | ${2}
+ ${false} | ${'does not refetch the variables'} | ${1}
+ `('when $bool it $text', async ({ bool, timesQueryCalled }) => {
+ mockMutation.mockResolvedValue({ ...mockAdminVariables.data, errors: [] });
+ mockVariables.mockResolvedValue(mockAdminVariables);
+
+ await createComponentWithApollo({
+ customHandlers: [[getAdminVariables, mockVariables]],
+ customResolvers: {
+ Mutation: {
+ ...resolvers.Mutation,
+ addAdminVariable: mockMutation,
+ },
+ },
+ props: { ...createInstanceProps(), refetchAfterMutation: bool },
+ provide: pagesFeatureFlagProvide,
+ });
+
+ await findCiSettings().vm.$emit('add-variable', newVariable);
+ await waitForPromises();
- beforeEach(async () => {
- mockVariables.mockResolvedValue(mockGroupVariables);
+ expect(mockVariables).toHaveBeenCalledTimes(timesQueryCalled);
});
+ });
- it('will mount component with right data', async () => {
- try {
- await createComponentWithApollo({
- props: { ...createGroupProps() },
- });
- } catch (e) {
- error = e;
- } finally {
- expect(wrapper.exists()).toBe(true);
- expect(error).toBeUndefined();
- }
+ describe('Validators', () => {
+ describe('queryData', () => {
+ let error;
+
+ beforeEach(() => {
+ mockVariables.mockResolvedValue(mockGroupVariables);
+ });
+
+ it('will mount component with right data', async () => {
+ try {
+ await createComponentWithApollo({
+ customHandlers: [[getGroupVariables, mockVariables]],
+ props: { ...createGroupProps() },
+ provide: pagesFeatureFlagProvide,
+ });
+ } catch (e) {
+ error = e;
+ } finally {
+ expect(wrapper.exists()).toBe(true);
+ expect(error).toBeUndefined();
+ }
+ });
+
+ it('report custom validator error on wrong data', () => {
+ expect(() =>
+ assertProps(
+ ciVariableShared,
+ { ...defaultProps, ...createGroupProps(), queryData: { wrongKey: {} } },
+ { provide: mockProvide },
+ ),
+ ).toThrow('custom validator check failed for prop');
+ });
});
- it('will not mount component with wrong data', async () => {
- try {
- await createComponentWithApollo({
- props: { ...createGroupProps(), mutationData: { wrongKey: {} } },
- });
- } catch (e) {
- error = e;
- } finally {
- expect(wrapper.exists()).toBe(false);
- expect(error.toString()).toContain('custom validator check failed for prop');
- }
+ describe('mutationData', () => {
+ let error;
+
+ beforeEach(() => {
+ mockVariables.mockResolvedValue(mockGroupVariables);
+ });
+
+ it('will mount component with right data', async () => {
+ try {
+ await createComponentWithApollo({
+ props: { ...createGroupProps() },
+ provide: pagesFeatureFlagProvide,
+ });
+ } catch (e) {
+ error = e;
+ } finally {
+ expect(wrapper.exists()).toBe(true);
+ expect(error).toBeUndefined();
+ }
+ });
+
+ it('report custom validator error on wrong data', () => {
+ expect(() =>
+ assertProps(
+ ciVariableShared,
+ { ...defaultProps, ...createGroupProps(), mutationData: { wrongKey: {} } },
+ { provide: { ...mockProvide, ...pagesFeatureFlagProvide } },
+ ),
+ ).toThrow('custom validator check failed for prop');
+ });
});
});
});
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js
index 9e2508c56ee..0b28cb06cec 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js
@@ -12,18 +12,25 @@ describe('Ci variable table', () => {
entity: 'project',
isLoading: false,
maxVariableLimit: mockVariables(projectString).length + 1,
+ pageInfo: {},
variables: mockVariables(projectString),
};
const mockMaxVariableLimit = defaultProps.variables.length;
- const createComponent = ({ props = {} } = {}) => {
+ const createComponent = ({ props = {}, provide = {} } = {}) => {
wrapper = mountExtended(CiVariableTable, {
attachTo: document.body,
propsData: {
...defaultProps,
...props,
},
+ provide: {
+ glFeatures: {
+ ciVariablesPages: false,
+ },
+ ...provide,
+ },
});
};
@@ -41,132 +48,136 @@ describe('Ci variable table', () => {
return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { entity, currentVariableCount, maxVariableLimit });
};
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('When table is empty', () => {
- beforeEach(() => {
- createComponent({ props: { variables: [] } });
- });
+ describe.each`
+ isVariablePagesEnabled | text
+ ${true} | ${'enabled'}
+ ${false} | ${'disabled'}
+ `('When Pages FF is $text', ({ isVariablePagesEnabled }) => {
+ const provide = isVariablePagesEnabled ? { glFeatures: { ciVariablesPages: true } } : {};
- it('displays empty message', () => {
- expect(findEmptyVariablesPlaceholder().exists()).toBe(true);
- });
-
- it('hides the reveal button', () => {
- expect(findRevealButton().exists()).toBe(false);
- });
- });
+ describe('When table is empty', () => {
+ beforeEach(() => {
+ createComponent({ props: { variables: [] }, provide });
+ });
- describe('When table has variables', () => {
- beforeEach(() => {
- createComponent();
- });
+ it('displays empty message', () => {
+ expect(findEmptyVariablesPlaceholder().exists()).toBe(true);
+ });
- it('does not display the empty message', () => {
- expect(findEmptyVariablesPlaceholder().exists()).toBe(false);
+ it('hides the reveal button', () => {
+ expect(findRevealButton().exists()).toBe(false);
+ });
});
- it('displays the reveal button', () => {
- expect(findRevealButton().exists()).toBe(true);
- });
+ describe('When table has variables', () => {
+ beforeEach(() => {
+ createComponent({ provide });
+ });
- it('displays the correct amount of variables', async () => {
- expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length);
- });
+ it('does not display the empty message', () => {
+ expect(findEmptyVariablesPlaceholder().exists()).toBe(false);
+ });
- it('displays the correct variable options', async () => {
- expect(findOptionsValues(0)).toBe('Protected, Expanded');
- expect(findOptionsValues(1)).toBe('Masked');
- });
+ it('displays the reveal button', () => {
+ expect(findRevealButton().exists()).toBe(true);
+ });
- it('enables the Add Variable button', () => {
- expect(findAddButton().props('disabled')).toBe(false);
- });
- });
+ it('displays the correct amount of variables', () => {
+ expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length);
+ });
- describe('When variables have exceeded the max limit', () => {
- beforeEach(() => {
- createComponent({ props: { maxVariableLimit: mockVariables(projectString).length } });
- });
+ it('displays the correct variable options', () => {
+ expect(findOptionsValues(0)).toBe('Protected, Expanded');
+ expect(findOptionsValues(1)).toBe('Masked');
+ });
- it('disables the Add Variable button', () => {
- expect(findAddButton().props('disabled')).toBe(true);
+ it('enables the Add Variable button', () => {
+ expect(findAddButton().props('disabled')).toBe(false);
+ });
});
- });
- describe('max limit reached alert', () => {
- describe('when there is no variable limit', () => {
+ describe('When variables have exceeded the max limit', () => {
beforeEach(() => {
createComponent({
- props: { maxVariableLimit: 0 },
+ props: { maxVariableLimit: mockVariables(projectString).length },
+ provide,
});
});
- it('hides alert', () => {
- expect(findLimitReachedAlerts().length).toBe(0);
+ it('disables the Add Variable button', () => {
+ expect(findAddButton().props('disabled')).toBe(true);
});
});
- describe('when variable limit exists', () => {
- it('hides alert when limit has not been reached', () => {
- createComponent();
+ describe('max limit reached alert', () => {
+ describe('when there is no variable limit', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { maxVariableLimit: 0 },
+ provide,
+ });
+ });
- expect(findLimitReachedAlerts().length).toBe(0);
+ it('hides alert', () => {
+ expect(findLimitReachedAlerts().length).toBe(0);
+ });
});
- it('shows alert when limit has been reached', () => {
- const exceedsVariableLimitText = generateExceedsVariableLimitText(
- defaultProps.entity,
- defaultProps.variables.length,
- mockMaxVariableLimit,
- );
+ describe('when variable limit exists', () => {
+ it('hides alert when limit has not been reached', () => {
+ createComponent({ provide });
- createComponent({
- props: { maxVariableLimit: mockMaxVariableLimit },
+ expect(findLimitReachedAlerts().length).toBe(0);
});
- expect(findLimitReachedAlerts().length).toBe(2);
+ it('shows alert when limit has been reached', () => {
+ const exceedsVariableLimitText = generateExceedsVariableLimitText(
+ defaultProps.entity,
+ defaultProps.variables.length,
+ mockMaxVariableLimit,
+ );
+
+ createComponent({
+ props: { maxVariableLimit: mockMaxVariableLimit },
+ });
- expect(findLimitReachedAlerts().at(0).props('dismissible')).toBe(false);
- expect(findLimitReachedAlerts().at(0).text()).toContain(exceedsVariableLimitText);
+ expect(findLimitReachedAlerts().length).toBe(2);
- expect(findLimitReachedAlerts().at(1).props('dismissible')).toBe(false);
- expect(findLimitReachedAlerts().at(1).text()).toContain(exceedsVariableLimitText);
+ expect(findLimitReachedAlerts().at(0).props('dismissible')).toBe(false);
+ expect(findLimitReachedAlerts().at(0).text()).toContain(exceedsVariableLimitText);
+
+ expect(findLimitReachedAlerts().at(1).props('dismissible')).toBe(false);
+ expect(findLimitReachedAlerts().at(1).text()).toContain(exceedsVariableLimitText);
+ });
});
});
- });
- describe('Table click actions', () => {
- beforeEach(() => {
- createComponent();
- });
+ describe('Table click actions', () => {
+ beforeEach(() => {
+ createComponent({ provide });
+ });
- it('reveals secret values when button is clicked', async () => {
- expect(findHiddenValues()).toHaveLength(defaultProps.variables.length);
- expect(findRevealedValues()).toHaveLength(0);
+ it('reveals secret values when button is clicked', async () => {
+ expect(findHiddenValues()).toHaveLength(defaultProps.variables.length);
+ expect(findRevealedValues()).toHaveLength(0);
- await findRevealButton().trigger('click');
+ await findRevealButton().trigger('click');
- expect(findHiddenValues()).toHaveLength(0);
- expect(findRevealedValues()).toHaveLength(defaultProps.variables.length);
- });
+ expect(findHiddenValues()).toHaveLength(0);
+ expect(findRevealedValues()).toHaveLength(defaultProps.variables.length);
+ });
- it('dispatches `setSelectedVariable` with correct variable to edit', async () => {
- await findEditButton().trigger('click');
+ it('dispatches `setSelectedVariable` with correct variable to edit', async () => {
+ await findEditButton().trigger('click');
- expect(wrapper.emitted('set-selected-variable')).toEqual([[defaultProps.variables[0]]]);
- });
+ expect(wrapper.emitted('set-selected-variable')).toEqual([[defaultProps.variables[0]]]);
+ });
- it('dispatches `setSelectedVariable` with no variable when adding a new one', async () => {
- await findAddButton().trigger('click');
+ it('dispatches `setSelectedVariable` with no variable when adding a new one', async () => {
+ await findAddButton().trigger('click');
- expect(wrapper.emitted('set-selected-variable')).toEqual([[null]]);
+ expect(wrapper.emitted('set-selected-variable')).toEqual([[null]]);
+ });
});
});
});
diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js
index 4da4f53f69f..f9450803308 100644
--- a/spec/frontend/ci/ci_variable_list/mocks.js
+++ b/spec/frontend/ci/ci_variable_list/mocks.js
@@ -56,6 +56,11 @@ export const mockVariablesWithScopes = (kind) =>
return { ...variable, environmentScope: '*' };
});
+export const mockVariablesWithUniqueScopes = (kind) =>
+ mockVariables(kind).map((variable) => {
+ return { ...variable, environmentScope: variable.value };
+ });
+
const createDefaultVars = ({ withScope = true, kind } = {}) => {
let base = mockVariables(kind);
diff --git a/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js b/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js
index b00e1adab63..48a85eba433 100644
--- a/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js
@@ -41,10 +41,6 @@ describe('EE - CodeSnippetAlert', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it("provides a link to the feature's documentation", () => {
const docsLink = findDocsLink();
diff --git a/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js
index 8e1d8081dd8..4b0ddacef93 100644
--- a/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js
@@ -33,12 +33,8 @@ describe('Pipeline Editor | Commit Form', () => {
const findSubmitBtn = () => wrapper.find('[type="submit"]');
const findCancelBtn = () => wrapper.find('[type="reset"]');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when the form is displayed', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
});
@@ -61,7 +57,7 @@ describe('Pipeline Editor | Commit Form', () => {
});
describe('when buttons are clicked', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({}, mount);
});
@@ -97,7 +93,7 @@ describe('Pipeline Editor | Commit Form', () => {
createComponent({ props: { hasUnsavedChanges, isNewCiConfigFile } });
if (isDisabled) {
- expect(findSubmitBtn().attributes('disabled')).toBe('true');
+ expect(findSubmitBtn().attributes('disabled')).toBeDefined();
} else {
expect(findSubmitBtn().attributes('disabled')).toBeUndefined();
}
@@ -136,7 +132,7 @@ describe('Pipeline Editor | Commit Form', () => {
it('when the commit message is empty, submit button is disabled', async () => {
await findCommitTextarea().setValue('');
- expect(findSubmitBtn().attributes('disabled')).toBe('disabled');
+ expect(findSubmitBtn().attributes('disabled')).toBeDefined();
});
});
diff --git a/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js
index f6e93c55bbb..8834231aaef 100644
--- a/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js
@@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils';
import Vue from 'vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { mockTracking } from 'helpers/tracking_helper';
import CommitForm from '~/ci/pipeline_editor/components/commit/commit_form.vue';
import CommitSection from '~/ci/pipeline_editor/components/commit/commit_section.vue';
import {
@@ -11,12 +12,12 @@ import {
COMMIT_ACTION_UPDATE,
COMMIT_SUCCESS,
COMMIT_SUCCESS_WITH_REDIRECT,
+ pipelineEditorTrackingOptions,
} from '~/ci/pipeline_editor/constants';
import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers';
import commitCreate from '~/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql';
import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql';
import updatePipelineEtag from '~/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql';
-
import {
mockCiConfigPath,
mockCiYml,
@@ -113,10 +114,6 @@ describe('Pipeline Editor | Commit section', () => {
await waitForPromises();
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when the user commits a new file', () => {
beforeEach(async () => {
mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse);
@@ -284,4 +281,43 @@ describe('Pipeline Editor | Commit section', () => {
createComponent({ props: { 'scroll-to-commit-form': true } });
expect(findCommitForm().props('scrollToCommitForm')).toBe(true);
});
+
+ describe('tracking', () => {
+ let trackingSpy;
+ const { actions, label } = pipelineEditorTrackingOptions;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ describe('when user commit a new file', () => {
+ beforeEach(async () => {
+ mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse);
+ createComponentWithApollo({ props: { isNewCiConfigFile: true } });
+ await submitCommit();
+ });
+
+ it('calls tracking event with the CREATE property', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, actions.commitCiConfig, {
+ label,
+ property: COMMIT_ACTION_CREATE,
+ });
+ });
+ });
+
+ describe('when user commit an update to the CI file', () => {
+ beforeEach(async () => {
+ mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse);
+ createComponentWithApollo({ props: { isNewCiConfigFile: false } });
+ await submitCommit();
+ });
+
+ it('calls the tracking event with the UPDATE property', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, actions.commitCiConfig, {
+ label,
+ property: COMMIT_ACTION_UPDATE,
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js
index 137137ec657..0ecb77674d5 100644
--- a/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js
@@ -21,10 +21,6 @@ describe('First pipeline card', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the title', () => {
expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title);
});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js
index cdce757ce7c..417597eaf1f 100644
--- a/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js
@@ -12,10 +12,6 @@ describe('Getting started card', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the title', () => {
expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title);
});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js
index 6909916c3e6..0296ab5a65c 100644
--- a/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js
@@ -33,10 +33,6 @@ describe('Pipeline config reference card', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the title', () => {
expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title);
});
@@ -72,7 +68,7 @@ describe('Pipeline config reference card', () => {
});
};
- it('tracks help page links', async () => {
+ it('tracks help page links', () => {
const {
CI_EXAMPLES_LINK,
CI_HELP_LINK,
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js
index 0c6879020de..547ba3cbd8b 100644
--- a/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js
@@ -12,10 +12,6 @@ describe('Visual and Lint card', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the title', () => {
expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title);
});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
index 42e372cc1db..b07d63dd5d9 100644
--- a/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
@@ -11,10 +11,6 @@ describe('Pipeline editor drawer', () => {
wrapper = shallowMount(PipelineEditorDrawer);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('emits close event when closing the drawer', () => {
createComponent();
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js
index f510c61ee74..b0c889cfc9f 100644
--- a/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js
@@ -17,10 +17,6 @@ describe('Demo job pill', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the jobName', () => {
expect(wrapper.text()).toContain(jobName);
});
diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js
index 2a2bc2547cc..2182b6e9cc6 100644
--- a/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js
@@ -34,10 +34,6 @@ describe('Text editor component', () => {
const findIcon = () => wrapper.findComponent(GlIcon);
const findEditor = () => wrapper.findComponent(MockSourceEditor);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when status is valid', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js
index dc72694d26f..f1a5c4169fb 100644
--- a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js
@@ -11,12 +11,25 @@ describe('CI Editor Header', () => {
let wrapper;
let trackingSpy = null;
- const createComponent = ({ showDrawer = false, showJobAssistantDrawer = false } = {}) => {
+ const createComponent = ({
+ showDrawer = false,
+ showJobAssistantDrawer = false,
+ showAiAssistantDrawer = false,
+ aiChatAvailable = false,
+ aiCiConfigGenerator = false,
+ } = {}) => {
wrapper = extendedWrapper(
shallowMount(CiEditorHeader, {
+ provide: {
+ aiChatAvailable,
+ glFeatures: {
+ aiCiConfigGenerator,
+ },
+ },
propsData: {
showDrawer,
showJobAssistantDrawer,
+ showAiAssistantDrawer,
},
}),
);
@@ -24,9 +37,9 @@ describe('CI Editor Header', () => {
const findLinkBtn = () => wrapper.findByTestId('template-repo-link');
const findHelpBtn = () => wrapper.findByTestId('drawer-toggle');
+ const findAiAssistnantBtn = () => wrapper.findByTestId('ai-assistant-drawer-toggle');
afterEach(() => {
- wrapper.destroy();
unmockTracking();
});
@@ -40,7 +53,29 @@ describe('CI Editor Header', () => {
label,
});
};
+ describe('Ai Assistant toggle button', () => {
+ describe('when feature is unavailable', () => {
+ it('should not show ai button when feature toggle is off', () => {
+ createComponent({ aiChatAvailable: true });
+ mockTracking(undefined, wrapper.element, jest.spyOn);
+ expect(findAiAssistnantBtn().exists()).toBe(false);
+ });
+
+ it('should not show ai button when feature is unavailable', () => {
+ createComponent({ aiCiConfigGenerator: true });
+ mockTracking(undefined, wrapper.element, jest.spyOn);
+ expect(findAiAssistnantBtn().exists()).toBe(false);
+ });
+ });
+ describe('when feature is available', () => {
+ it('should show ai button', () => {
+ createComponent({ aiCiConfigGenerator: true, aiChatAvailable: true });
+ mockTracking(undefined, wrapper.element, jest.spyOn);
+ expect(findAiAssistnantBtn().exists()).toBe(true);
+ });
+ });
+ });
describe('link button', () => {
beforeEach(() => {
createComponent();
@@ -59,7 +94,7 @@ describe('CI Editor Header', () => {
expect(findLinkBtn().props('icon')).toBe('external-link');
});
- it('tracks the click on the browse button', async () => {
+ it('tracks the click on the browse button', () => {
const { browseTemplates } = pipelineEditorTrackingOptions.actions;
testTracker(findLinkBtn(), browseTemplates);
@@ -92,7 +127,7 @@ describe('CI Editor Header', () => {
expect(wrapper.emitted('open-drawer')).toHaveLength(1);
});
- it('tracks open help drawer action', async () => {
+ it('tracks open help drawer action', () => {
const { actions } = pipelineEditorTrackingOptions;
testTracker(findHelpBtn(), actions.openHelpDrawer);
diff --git a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js
index ec987be8cb8..0be26570fbf 100644
--- a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js
@@ -1,7 +1,11 @@
import { shallowMount } from '@vue/test-utils';
+import { editor as monacoEditor } from 'monaco-editor';
+import SourceEditor from '~/vue_shared/components/source_editor.vue';
import { EDITOR_READY_EVENT } from '~/editor/constants';
+import { CiSchemaExtension as MockedCiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
import { SOURCE_EDITOR_DEBOUNCE } from '~/ci/pipeline_editor/constants';
+import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
import TextEditor from '~/ci/pipeline_editor/components/editor/text_editor.vue';
import {
mockCiConfigPath,
@@ -12,19 +16,26 @@ import {
mockDefaultBranch,
} from '../../mock_data';
+jest.mock('monaco-editor');
+jest.mock('~/editor/extensions/source_editor_ci_schema_ext', () => {
+ const { createMockSourceEditorExtension } = jest.requireActual(
+ 'helpers/create_mock_source_editor_extension',
+ );
+ const { CiSchemaExtension } = jest.requireActual(
+ '~/editor/extensions/source_editor_ci_schema_ext',
+ );
+
+ return {
+ CiSchemaExtension: createMockSourceEditorExtension(CiSchemaExtension),
+ };
+});
+
describe('Pipeline Editor | Text editor component', () => {
let wrapper;
let editorReadyListener;
- let mockUse;
- let mockRegisterCiSchema;
- let mockEditorInstance;
- let editorInstanceDetail;
-
- const MockSourceEditor = {
- template: '<div/>',
- props: ['value', 'fileName', 'editorOptions', 'debounceValue'],
- };
+
+ const getMonacoEditor = () => monacoEditor.create.mock.results[0].value;
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(TextEditor, {
@@ -44,33 +55,17 @@ describe('Pipeline Editor | Text editor component', () => {
[EDITOR_READY_EVENT]: editorReadyListener,
},
stubs: {
- SourceEditor: MockSourceEditor,
+ SourceEditor,
},
});
};
- const findEditor = () => wrapper.findComponent(MockSourceEditor);
+ const findEditor = () => wrapper.findComponent(SourceEditor);
beforeEach(() => {
- editorReadyListener = jest.fn();
- mockUse = jest.fn();
- mockRegisterCiSchema = jest.fn();
- mockEditorInstance = {
- use: mockUse,
- registerCiSchema: mockRegisterCiSchema,
- };
- editorInstanceDetail = {
- detail: {
- instance: mockEditorInstance,
- },
- };
- });
+ jest.spyOn(monacoEditor, 'create');
- afterEach(() => {
- wrapper.destroy();
-
- mockUse.mockClear();
- mockRegisterCiSchema.mockClear();
+ editorReadyListener = jest.fn();
});
describe('template', () => {
@@ -99,21 +94,34 @@ describe('Pipeline Editor | Text editor component', () => {
});
it('bubbles up events', () => {
- findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail);
-
expect(editorReadyListener).toHaveBeenCalled();
});
+
+ it('scrolls editor to bottom on scroll editor to bottom event', () => {
+ const setScrollTop = jest.spyOn(getMonacoEditor(), 'setScrollTop');
+
+ eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM);
+
+ expect(setScrollTop).toHaveBeenCalledWith(getMonacoEditor().getScrollHeight());
+ });
+
+ it('when destroyed, destroys scroll listener', () => {
+ const setScrollTop = jest.spyOn(getMonacoEditor(), 'setScrollTop');
+
+ wrapper.destroy();
+ eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM);
+
+ expect(setScrollTop).not.toHaveBeenCalled();
+ });
});
describe('CI schema', () => {
beforeEach(() => {
createComponent();
- findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail);
});
it('configures editor with syntax highlight', () => {
- expect(mockUse).toHaveBeenCalledTimes(1);
- expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1);
+ expect(MockedCiSchemaExtension.mockedMethods.registerCiSchema).toHaveBeenCalledTimes(1);
});
});
});
diff --git a/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js
index a26232df58f..3a99949413b 100644
--- a/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js
@@ -133,10 +133,6 @@ describe('Pipeline editor branch switcher', () => {
mockAvailableBranchQuery = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const testErrorHandling = () => {
expect(wrapper.emitted('showError')).toBeDefined();
expect(wrapper.emitted('showError')[0]).toEqual([
@@ -292,7 +288,7 @@ describe('Pipeline editor branch switcher', () => {
});
describe('with a search term', () => {
- beforeEach(async () => {
+ beforeEach(() => {
mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches);
});
diff --git a/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
index 907db16913c..19c113689c2 100644
--- a/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
@@ -48,10 +48,6 @@ describe('Pipeline editor file nav', () => {
const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle');
const findPopoverContainer = () => wrapper.findComponent(FileTreePopover);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js
index 11ba517e0eb..f2effcb2966 100644
--- a/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js
@@ -22,7 +22,7 @@ describe('Pipeline editor file nav', () => {
includes,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
stubs,
}),
@@ -35,7 +35,6 @@ describe('Pipeline editor file nav', () => {
afterEach(() => {
localStorage.clear();
- wrapper.destroy();
});
describe('template', () => {
@@ -61,11 +60,11 @@ describe('Pipeline editor file nav', () => {
expect(fileTreeItems().exists()).toBe(false);
});
- it('renders alert tip', async () => {
+ it('renders alert tip', () => {
expect(findTip().exists()).toBe(true);
});
- it('renders learn more link', async () => {
+ it('renders learn more link', () => {
expect(findTip().props('secondaryButtonLink')).toBe(mockIncludesHelpPagePath);
});
@@ -88,7 +87,7 @@ describe('Pipeline editor file nav', () => {
});
});
- it('does not render alert tip', async () => {
+ it('does not render alert tip', () => {
expect(findTip().exists()).toBe(false);
});
});
diff --git a/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js b/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js
index bceb741f91c..80737e9a8ab 100644
--- a/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js
@@ -18,10 +18,6 @@ describe('Pipeline editor file nav', () => {
const fileIcon = () => wrapper.findComponent(FileIcon);
const link = () => wrapper.findComponent(GlLink);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js
index 555b9f29fbf..a651664851e 100644
--- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js
@@ -26,11 +26,6 @@ describe('Pipeline editor header', () => {
const findPipelineStatus = () => wrapper.findComponent(PipelineStatus);
const findValidationSegment = () => wrapper.findComponent(ValidationSegment);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('template', () => {
it('hides the pipeline status for new projects without a CI file', () => {
createComponent({ props: { isNewCiConfigFile: true } });
diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js
index 7bf955012c7..b8526e569ec 100644
--- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js
@@ -96,7 +96,7 @@ describe('Pipeline Status', () => {
await waitForPromises();
});
- it('should emit an error event when query fails', async () => {
+ it('should emit an error event when query fails', () => {
expect(wrapper.emitted('showError')).toHaveLength(1);
expect(wrapper.emitted('showError')[0]).toEqual([
{
diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js
index a62c51ffb59..8ca88472bf1 100644
--- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js
@@ -48,7 +48,6 @@ describe('Pipeline Status', () => {
afterEach(() => {
mockPipelineQuery.mockReset();
- wrapper.destroy();
});
describe('loading icon', () => {
@@ -78,7 +77,7 @@ describe('Pipeline Status', () => {
await waitForPromises();
});
- it('query is called with correct variables', async () => {
+ it('query is called with correct variables', () => {
expect(mockPipelineQuery).toHaveBeenCalledTimes(1);
expect(mockPipelineQuery).toHaveBeenCalledWith({
fullPath: mockProjectFullPath,
diff --git a/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js b/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js
index 0853a6f4ca4..a107a626c6d 100644
--- a/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js
@@ -1,11 +1,10 @@
import VueApollo from 'vue-apollo';
-import { GlIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import Vue from 'vue';
import { escape } from 'lodash';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import createMockApollo from 'helpers/mock_apollo_helper';
import { sprintf } from '~/locale';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
import ValidationSegment, {
i18n,
} from '~/ci/pipeline_editor/components/header/validation_segment.vue';
@@ -20,8 +19,8 @@ import {
} from '~/ci/pipeline_editor/constants';
import {
mergeUnwrappedCiConfig,
+ mockCiTroubleshootingPath,
mockCiYml,
- mockLintUnavailableHelpPagePath,
mockYmlHelpPagePath,
} from '../../mock_data';
@@ -43,29 +42,27 @@ describe('Validation segment component', () => {
},
});
- wrapper = extendedWrapper(
- shallowMount(ValidationSegment, {
- apolloProvider: mockApollo,
- provide: {
- ymlHelpPagePath: mockYmlHelpPagePath,
- lintUnavailableHelpPagePath: mockLintUnavailableHelpPagePath,
- },
- propsData: {
- ciConfig: mergeUnwrappedCiConfig(),
- ciFileContent: mockCiYml,
- ...props,
- },
- }),
- );
+ wrapper = shallowMountExtended(ValidationSegment, {
+ apolloProvider: mockApollo,
+ provide: {
+ ymlHelpPagePath: mockYmlHelpPagePath,
+ ciTroubleshootingPath: mockCiTroubleshootingPath,
+ },
+ propsData: {
+ ciConfig: mergeUnwrappedCiConfig(),
+ ciFileContent: mockCiYml,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
};
const findIcon = () => wrapper.findComponent(GlIcon);
- const findLearnMoreLink = () => wrapper.findByTestId('learnMoreLink');
- const findValidationMsg = () => wrapper.findByTestId('validationMsg');
-
- afterEach(() => {
- wrapper.destroy();
- });
+ const findHelpLink = () => wrapper.findComponent(GlLink);
+ const findValidationMsg = () => wrapper.findComponent(GlSprintf);
+ const findValidationSegment = () => wrapper.findByTestId('validation-segment');
it('shows the loading state', () => {
createComponent({ appStatus: EDITOR_APP_STATUS_LOADING });
@@ -82,8 +79,12 @@ describe('Validation segment component', () => {
expect(findIcon().props('name')).toBe('check');
});
+ it('does not render a link', () => {
+ expect(findHelpLink().exists()).toBe(false);
+ });
+
it('shows a message for empty state', () => {
- expect(findValidationMsg().text()).toBe(i18n.empty);
+ expect(findValidationSegment().text()).toBe(i18n.empty);
});
});
@@ -97,12 +98,15 @@ describe('Validation segment component', () => {
});
it('shows a message for valid state', () => {
- expect(findValidationMsg().text()).toContain(i18n.valid);
+ expect(findValidationSegment().text()).toBe(
+ sprintf(i18n.valid, { linkStart: '', linkEnd: '' }),
+ );
});
it('shows the learn more link', () => {
- expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath);
- expect(findLearnMoreLink().text()).toBe(i18n.learnMore);
+ expect(findValidationMsg().exists()).toBe(true);
+ expect(findValidationMsg().text()).toBe('Learn more');
+ expect(findHelpLink().attributes('href')).toBe(mockYmlHelpPagePath);
});
});
@@ -117,13 +121,16 @@ describe('Validation segment component', () => {
expect(findIcon().props('name')).toBe('warning-solid');
});
- it('has message for invalid state', () => {
- expect(findValidationMsg().text()).toBe(i18n.invalid);
+ it('shows a message for invalid state', () => {
+ expect(findValidationSegment().text()).toBe(
+ sprintf(i18n.invalid, { linkStart: '', linkEnd: '' }),
+ );
});
it('shows the learn more link', () => {
- expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath);
- expect(findLearnMoreLink().text()).toBe('Learn more');
+ expect(findValidationMsg().exists()).toBe(true);
+ expect(findValidationMsg().text()).toBe('Learn more');
+ expect(findHelpLink().attributes('href')).toBe(mockYmlHelpPagePath);
});
describe('with multiple errors', () => {
@@ -140,11 +147,16 @@ describe('Validation segment component', () => {
},
});
});
+
+ it('shows the learn more link', () => {
+ expect(findValidationMsg().exists()).toBe(true);
+ expect(findValidationMsg().text()).toBe('Learn more');
+ expect(findHelpLink().attributes('href')).toBe(mockYmlHelpPagePath);
+ });
+
it('shows an invalid state with an error', () => {
- // Test the error is shown _and_ the string matches
- expect(findValidationMsg().text()).toContain(firstError);
- expect(findValidationMsg().text()).toBe(
- sprintf(i18n.invalidWithReason, { reason: firstError }),
+ expect(findValidationSegment().text()).toBe(
+ sprintf(i18n.invalidWithReason, { reason: firstError, linkStart: '', linkEnd: '' }),
);
});
});
@@ -163,10 +175,8 @@ describe('Validation segment component', () => {
});
});
it('shows an invalid state with an error while preventing XSS', () => {
- const { innerHTML } = findValidationMsg().element;
-
- expect(innerHTML).not.toContain(evilError);
- expect(innerHTML).toContain(escape(evilError));
+ expect(findValidationSegment().html()).not.toContain(evilError);
+ expect(findValidationSegment().html()).toContain(escape(evilError));
});
});
});
@@ -182,16 +192,18 @@ describe('Validation segment component', () => {
});
it('show a message that the service is unavailable', () => {
- expect(findValidationMsg().text()).toBe(i18n.unavailableValidation);
+ expect(findValidationSegment().text()).toBe(
+ sprintf(i18n.unavailableValidation, { linkStart: '', linkEnd: '' }),
+ );
});
it('shows the time-out icon', () => {
expect(findIcon().props('name')).toBe('time-out');
});
- it('shows the learn more link', () => {
- expect(findLearnMoreLink().attributes('href')).toBe(mockLintUnavailableHelpPagePath);
- expect(findLearnMoreLink().text()).toBe(i18n.learnMore);
+ it('shows the link to ci troubleshooting', () => {
+ expect(findValidationMsg().exists()).toBe(true);
+ expect(findHelpLink().attributes('href')).toBe(mockCiTroubleshootingPath);
});
});
});
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js
new file mode 100644
index 00000000000..9046be4a45e
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js
@@ -0,0 +1,127 @@
+import ArtifactsAndCacheItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+
+describe('Artifacts and cache item', () => {
+ let wrapper;
+
+ const findArtifactsPathsInputByIndex = (index) =>
+ wrapper.findByTestId(`artifacts-paths-input-${index}`);
+ const findArtifactsExcludeInputByIndex = (index) =>
+ wrapper.findByTestId(`artifacts-exclude-input-${index}`);
+ const findCachePathsInputByIndex = (index) => wrapper.findByTestId(`cache-paths-input-${index}`);
+ const findCacheKeyInput = () => wrapper.findByTestId('cache-key-input');
+ const findDeleteArtifactsPathsButtonByIndex = (index) =>
+ wrapper.findByTestId(`delete-artifacts-paths-button-${index}`);
+ const findDeleteArtifactsExcludeButtonByIndex = (index) =>
+ wrapper.findByTestId(`delete-artifacts-exclude-button-${index}`);
+ const findDeleteCachePathsButtonByIndex = (index) =>
+ wrapper.findByTestId(`delete-cache-paths-button-${index}`);
+ const findAddArtifactsPathsButton = () => wrapper.findByTestId('add-artifacts-paths-button');
+ const findAddArtifactsExcludeButton = () => wrapper.findByTestId('add-artifacts-exclude-button');
+ const findAddCachePathsButton = () => wrapper.findByTestId('add-cache-paths-button');
+
+ const dummyArtifactsPath = 'dummyArtifactsPath';
+ const dummyArtifactsExclude = 'dummyArtifactsExclude';
+ const dummyCachePath = 'dummyCachePath';
+ const dummyCacheKey = 'dummyCacheKey';
+
+ const createComponent = ({ job = JSON.parse(JSON.stringify(JOB_TEMPLATE)) } = {}) => {
+ wrapper = shallowMountExtended(ArtifactsAndCacheItem, {
+ propsData: {
+ job,
+ },
+ });
+ };
+
+ it('should emit update job event when filling inputs', () => {
+ createComponent();
+
+ expect(wrapper.emitted('update-job')).toBeUndefined();
+
+ findArtifactsPathsInputByIndex(0).vm.$emit('input', dummyArtifactsPath);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toStrictEqual([
+ 'artifacts.paths[0]',
+ dummyArtifactsPath,
+ ]);
+
+ findArtifactsExcludeInputByIndex(0).vm.$emit('input', dummyArtifactsExclude);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(2);
+ expect(wrapper.emitted('update-job')[1]).toStrictEqual([
+ 'artifacts.exclude[0]',
+ dummyArtifactsExclude,
+ ]);
+
+ findCachePathsInputByIndex(0).vm.$emit('input', dummyCachePath);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(3);
+ expect(wrapper.emitted('update-job')[2]).toStrictEqual(['cache.paths[0]', dummyCachePath]);
+
+ findCacheKeyInput().vm.$emit('input', dummyCacheKey);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(4);
+ expect(wrapper.emitted('update-job')[3]).toStrictEqual(['cache.key', dummyCacheKey]);
+ });
+
+ it('should emit update job event when click add item button', () => {
+ createComponent();
+
+ findAddArtifactsPathsButton().vm.$emit('click');
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toStrictEqual(['artifacts.paths[1]', '']);
+
+ findAddArtifactsExcludeButton().vm.$emit('click');
+
+ expect(wrapper.emitted('update-job')).toHaveLength(2);
+ expect(wrapper.emitted('update-job')[1]).toStrictEqual(['artifacts.exclude[1]', '']);
+
+ findAddCachePathsButton().vm.$emit('click');
+
+ expect(wrapper.emitted('update-job')).toHaveLength(3);
+ expect(wrapper.emitted('update-job')[2]).toStrictEqual(['cache.paths[1]', '']);
+ });
+
+ it('should emit update job event when click delete item button', () => {
+ createComponent({
+ job: {
+ artifacts: {
+ paths: ['0', '1'],
+ exclude: ['0', '1'],
+ },
+ cache: {
+ paths: ['0', '1'],
+ key: '',
+ },
+ },
+ });
+
+ findDeleteArtifactsPathsButtonByIndex(0).vm.$emit('click');
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toStrictEqual(['artifacts.paths[0]']);
+
+ findDeleteArtifactsExcludeButtonByIndex(0).vm.$emit('click');
+
+ expect(wrapper.emitted('update-job')).toHaveLength(2);
+ expect(wrapper.emitted('update-job')[1]).toStrictEqual(['artifacts.exclude[0]']);
+
+ findDeleteCachePathsButtonByIndex(0).vm.$emit('click');
+
+ expect(wrapper.emitted('update-job')).toHaveLength(3);
+ expect(wrapper.emitted('update-job')[2]).toStrictEqual(['cache.paths[0]']);
+ });
+
+ it('should not emit update job event when click the only one delete item button', () => {
+ createComponent();
+
+ findDeleteArtifactsPathsButtonByIndex(0).vm.$emit('click');
+ findDeleteArtifactsExcludeButtonByIndex(0).vm.$emit('click');
+ findDeleteCachePathsButtonByIndex(0).vm.$emit('click');
+
+ expect(wrapper.emitted('update-job')).toBeUndefined();
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js
new file mode 100644
index 00000000000..f99d7277612
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js
@@ -0,0 +1,39 @@
+import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+
+describe('Image item', () => {
+ let wrapper;
+
+ const findImageNameInput = () => wrapper.findByTestId('image-name-input');
+ const findImageEntrypointInput = () => wrapper.findByTestId('image-entrypoint-input');
+
+ const dummyImageName = 'a';
+ const dummyImageEntrypoint = ['b', 'c'];
+
+ const createComponent = ({ job = JSON.parse(JSON.stringify(JOB_TEMPLATE)) } = {}) => {
+ wrapper = shallowMountExtended(ImageItem, {
+ propsData: {
+ job,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should emit update job event when filling inputs', () => {
+ expect(wrapper.emitted('update-job')).toBeUndefined();
+
+ findImageNameInput().vm.$emit('input', dummyImageName);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toEqual(['image.name', dummyImageName]);
+
+ findImageEntrypointInput().vm.$emit('input', dummyImageEntrypoint.join('\n'));
+
+ expect(wrapper.emitted('update-job')).toHaveLength(2);
+ expect(wrapper.emitted('update-job')[1]).toEqual(['image.entrypoint', dummyImageEntrypoint]);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js
new file mode 100644
index 00000000000..373fb1b70c7
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js
@@ -0,0 +1,60 @@
+import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+
+describe('Job setup item', () => {
+ let wrapper;
+
+ const findJobNameInput = () => wrapper.findByTestId('job-name-input');
+ const findJobScriptInput = () => wrapper.findByTestId('job-script-input');
+ const findJobTagsInput = () => wrapper.findByTestId('job-tags-input');
+ const findJobStageInput = () => wrapper.findByTestId('job-stage-input');
+
+ const dummyJobName = 'dummyJobName';
+ const dummyJobScript = 'dummyJobScript';
+ const dummyJobStage = 'dummyJobStage';
+ const dummyJobTags = ['tag1'];
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(JobSetupItem, {
+ propsData: {
+ availableStages: ['.pre', dummyJobStage, '.post'],
+ tagOptions: [
+ { id: 'tag1', name: 'tag1' },
+ { id: 'tag2', name: 'tag2' },
+ ],
+ isNameValid: true,
+ isScriptValid: true,
+ job: JSON.parse(JSON.stringify(JOB_TEMPLATE)),
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should emit update job event when filling inputs', () => {
+ expect(wrapper.emitted('update-job')).toBeUndefined();
+
+ findJobNameInput().vm.$emit('input', dummyJobName);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toEqual(['name', dummyJobName]);
+
+ findJobScriptInput().vm.$emit('input', dummyJobScript);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(2);
+ expect(wrapper.emitted('update-job')[1]).toEqual(['script', dummyJobScript]);
+
+ findJobStageInput().vm.$emit('input', dummyJobStage);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(3);
+ expect(wrapper.emitted('update-job')[2]).toEqual(['stage', dummyJobStage]);
+
+ findJobTagsInput().vm.$emit('input', dummyJobTags);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(4);
+ expect(wrapper.emitted('update-job')[3]).toEqual(['tags', dummyJobTags]);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js
new file mode 100644
index 00000000000..659ccb25996
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js
@@ -0,0 +1,70 @@
+import RulesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ JOB_TEMPLATE,
+ JOB_RULES_WHEN,
+ JOB_RULES_START_IN,
+} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+
+describe('Rules item', () => {
+ let wrapper;
+
+ const findRulesWhenSelect = () => wrapper.findByTestId('rules-when-select');
+ const findRulesStartInNumberInput = () => wrapper.findByTestId('rules-start-in-number-input');
+ const findRulesStartInUnitSelect = () => wrapper.findByTestId('rules-start-in-unit-select');
+ const findRulesAllowFailureCheckBox = () => wrapper.findByTestId('rules-allow-failure-checkbox');
+
+ const dummyRulesWhen = JOB_RULES_WHEN.delayed.value;
+ const dummyRulesStartInNumber = 2;
+ const dummyRulesStartInUnit = JOB_RULES_START_IN.week.value;
+ const dummyRulesAllowFailure = true;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(RulesItem, {
+ propsData: {
+ isStartValid: true,
+ job: JSON.parse(JSON.stringify(JOB_TEMPLATE)),
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should emit update job event when filling inputs', () => {
+ expect(wrapper.emitted('update-job')).toBeUndefined();
+
+ findRulesWhenSelect().vm.$emit('input', dummyRulesWhen);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toEqual([
+ 'rules[0].when',
+ JOB_RULES_WHEN.delayed.value,
+ ]);
+
+ findRulesStartInNumberInput().vm.$emit('input', dummyRulesStartInNumber);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(2);
+ expect(wrapper.emitted('update-job')[1]).toEqual([
+ 'rules[0].start_in',
+ `2 ${JOB_RULES_START_IN.second.value}s`,
+ ]);
+
+ findRulesStartInUnitSelect().vm.$emit('input', dummyRulesStartInUnit);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(3);
+ expect(wrapper.emitted('update-job')[2]).toEqual([
+ 'rules[0].start_in',
+ `2 ${dummyRulesStartInUnit}s`,
+ ]);
+
+ findRulesAllowFailureCheckBox().vm.$emit('input', dummyRulesAllowFailure);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(4);
+ expect(wrapper.emitted('update-job')[3]).toEqual([
+ 'rules[0].allow_failure',
+ dummyRulesAllowFailure,
+ ]);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js
new file mode 100644
index 00000000000..284d639c77f
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js
@@ -0,0 +1,79 @@
+import ServicesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+
+describe('Services item', () => {
+ let wrapper;
+
+ const findServiceNameInputByIndex = (index) =>
+ wrapper.findByTestId(`service-name-input-${index}`);
+ const findServiceEntrypointInputByIndex = (index) =>
+ wrapper.findByTestId(`service-entrypoint-input-${index}`);
+ const findDeleteItemButtonByIndex = (index) =>
+ wrapper.findByTestId(`delete-job-service-button-${index}`);
+ const findAddItemButton = () => wrapper.findByTestId('add-job-service-button');
+
+ const dummyServiceName = 'a';
+ const dummyServiceEntrypoint = ['b', 'c'];
+
+ const createComponent = ({ job = JSON.parse(JSON.stringify(JOB_TEMPLATE)) } = {}) => {
+ wrapper = shallowMountExtended(ServicesItem, {
+ propsData: {
+ job,
+ },
+ });
+ };
+
+ it('should emit update job event when filling inputs', () => {
+ createComponent();
+
+ expect(wrapper.emitted('update-job')).toBeUndefined();
+
+ findServiceNameInputByIndex(0).vm.$emit('input', dummyServiceName);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toEqual(['services[0].name', dummyServiceName]);
+
+ findServiceEntrypointInputByIndex(0).vm.$emit('input', dummyServiceEntrypoint.join('\n'));
+
+ expect(wrapper.emitted('update-job')).toHaveLength(2);
+ expect(wrapper.emitted('update-job')[1]).toEqual([
+ 'services[0].entrypoint',
+ dummyServiceEntrypoint,
+ ]);
+ });
+
+ it('should emit update job event when click add item button', () => {
+ createComponent();
+
+ findAddItemButton().vm.$emit('click');
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toEqual([
+ 'services[1]',
+ { name: '', entrypoint: [''] },
+ ]);
+ });
+
+ it('should emit update job event when click delete item button', () => {
+ createComponent({
+ job: {
+ services: [
+ { name: 'a', entrypoint: ['a'] },
+ { name: 'b', entrypoint: ['b'] },
+ ],
+ },
+ });
+
+ findDeleteItemButtonByIndex(0).vm.$emit('click');
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toEqual(['services[0]']);
+ });
+
+ it('should not show delete item button when there is only one service', () => {
+ createComponent();
+
+ expect(findDeleteItemButtonByIndex(0).exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
index 79200d92598..0258a1a8c7f 100644
--- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
@@ -1,24 +1,64 @@
import { GlDrawer } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
+import { stringify } from 'yaml';
import JobAssistantDrawer from '~/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue';
+import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue';
+import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue';
+import ServicesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue';
+import ArtifactsAndCacheItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue';
+import RulesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue';
+import { JOB_RULES_WHEN } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+import getRunnerTags from '~/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
+import { mockRunnersTagsQueryResponse, mockLintResponse, mockCiYml } from '../../mock_data';
Vue.use(VueApollo);
describe('Job assistant drawer', () => {
let wrapper;
+ let mockApollo;
+
+ const dummyJobName = 'dummyJobName';
+ const dummyJobScript = 'dummyJobScript';
+ const dummyImageName = 'dummyImageName';
+ const dummyImageEntrypoint = 'dummyImageEntrypoint';
+ const dummyServicesName = 'dummyServicesName';
+ const dummyServicesEntrypoint = 'dummyServicesEntrypoint';
+ const dummyArtifactsPath = 'dummyArtifactsPath';
+ const dummyArtifactsExclude = 'dummyArtifactsExclude';
+ const dummyCachePath = 'dummyCachePath';
+ const dummyCacheKey = 'dummyCacheKey';
+ const dummyRulesWhen = JOB_RULES_WHEN.delayed.value;
+ const dummyRulesStartIn = '1 second';
+ const dummyRulesAllowFailure = true;
const findDrawer = () => wrapper.findComponent(GlDrawer);
+ const findJobSetupItem = () => wrapper.findComponent(JobSetupItem);
+ const findImageItem = () => wrapper.findComponent(ImageItem);
+ const findServicesItem = () => wrapper.findComponent(ServicesItem);
+ const findArtifactsAndCacheItem = () => wrapper.findComponent(ArtifactsAndCacheItem);
+ const findRulesItem = () => wrapper.findComponent(RulesItem);
+ const findConfirmButton = () => wrapper.findByTestId('confirm-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const createComponent = () => {
+ mockApollo = createMockApollo([
+ [getRunnerTags, jest.fn().mockResolvedValue(mockRunnersTagsQueryResponse)],
+ ]);
+
wrapper = mountExtended(JobAssistantDrawer, {
propsData: {
+ ciConfigData: mockLintResponse,
+ ciFileContent: mockCiYml,
isVisible: true,
},
+ apolloProvider: mockApollo,
});
};
@@ -27,6 +67,35 @@ describe('Job assistant drawer', () => {
await waitForPromises();
});
+ it('should contain job setup accordion', () => {
+ expect(findJobSetupItem().exists()).toBe(true);
+ });
+
+ it('job setup item should have tag options', () => {
+ expect(findJobSetupItem().props('tagOptions')).toEqual([
+ { id: 'tag1', name: 'tag1' },
+ { id: 'tag2', name: 'tag2' },
+ { id: 'tag3', name: 'tag3' },
+ { id: 'tag4', name: 'tag4' },
+ ]);
+ });
+
+ it('should contain image accordion', () => {
+ expect(findImageItem().exists()).toBe(true);
+ });
+
+ it('should contain services accordion', () => {
+ expect(findServicesItem().exists()).toBe(true);
+ });
+
+ it('should contain artifacts and cache item accordion', () => {
+ expect(findArtifactsAndCacheItem().exists()).toBe(true);
+ });
+
+ it('should contain rules accordion', () => {
+ expect(findRulesItem().exists()).toBe(true);
+ });
+
it('should emit close job assistant drawer event when closing the drawer', () => {
expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined();
@@ -42,4 +111,185 @@ describe('Job assistant drawer', () => {
expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1);
});
+
+ it('should block submit if job name is empty', async () => {
+ findJobSetupItem().vm.$emit('update-job', 'script', 'b');
+ findConfirmButton().trigger('click');
+
+ await nextTick();
+
+ expect(findJobSetupItem().props('isNameValid')).toBe(false);
+ expect(findJobSetupItem().props('isScriptValid')).toBe(true);
+ expect(wrapper.emitted('updateCiConfig')).toBeUndefined();
+ });
+
+ it('should block submit if rules when is delayed and start in is out of range', async () => {
+ findRulesItem().vm.$emit('update-job', 'rules[0].when', JOB_RULES_WHEN.delayed.value);
+ findRulesItem().vm.$emit('update-job', 'rules[0].start_in', '2 weeks');
+ findConfirmButton().trigger('click');
+
+ await nextTick();
+
+ expect(wrapper.emitted('updateCiConfig')).toBeUndefined();
+ });
+
+ describe('when enter valid input', () => {
+ beforeEach(() => {
+ findJobSetupItem().vm.$emit('update-job', 'name', dummyJobName);
+ findJobSetupItem().vm.$emit('update-job', 'script', dummyJobScript);
+ findImageItem().vm.$emit('update-job', 'image.name', dummyImageName);
+ findImageItem().vm.$emit('update-job', 'image.entrypoint', [dummyImageEntrypoint]);
+ findServicesItem().vm.$emit('update-job', 'services[0].name', dummyServicesName);
+ findServicesItem().vm.$emit('update-job', 'services[0].entrypoint', [
+ dummyServicesEntrypoint,
+ ]);
+ findArtifactsAndCacheItem().vm.$emit('update-job', 'artifacts.paths', [dummyArtifactsPath]);
+ findArtifactsAndCacheItem().vm.$emit('update-job', 'artifacts.exclude', [
+ dummyArtifactsExclude,
+ ]);
+ findArtifactsAndCacheItem().vm.$emit('update-job', 'cache.paths', [dummyCachePath]);
+ findArtifactsAndCacheItem().vm.$emit('update-job', 'cache.key', dummyCacheKey);
+ findRulesItem().vm.$emit('update-job', 'rules[0].allow_failure', dummyRulesAllowFailure);
+ findRulesItem().vm.$emit('update-job', 'rules[0].when', dummyRulesWhen);
+ findRulesItem().vm.$emit('update-job', 'rules[0].start_in', dummyRulesStartIn);
+ });
+
+ it('passes correct prop to accordions', () => {
+ const accordions = [
+ findJobSetupItem(),
+ findImageItem(),
+ findServicesItem(),
+ findArtifactsAndCacheItem(),
+ findRulesItem(),
+ ];
+ accordions.forEach((accordion) => {
+ expect(accordion.props('job')).toMatchObject({
+ name: dummyJobName,
+ script: dummyJobScript,
+ image: {
+ name: dummyImageName,
+ entrypoint: [dummyImageEntrypoint],
+ },
+ services: [
+ {
+ name: dummyServicesName,
+ entrypoint: [dummyServicesEntrypoint],
+ },
+ ],
+ artifacts: {
+ paths: [dummyArtifactsPath],
+ exclude: [dummyArtifactsExclude],
+ },
+ cache: {
+ paths: [dummyCachePath],
+ key: dummyCacheKey,
+ },
+ rules: [
+ {
+ allow_failure: dummyRulesAllowFailure,
+ when: dummyRulesWhen,
+ start_in: dummyRulesStartIn,
+ },
+ ],
+ });
+ });
+ });
+
+ it('job name and script state should be valid', () => {
+ expect(findJobSetupItem().props('isNameValid')).toBe(true);
+ expect(findJobSetupItem().props('isScriptValid')).toBe(true);
+ });
+
+ it('should clear job data when click confirm button', async () => {
+ findConfirmButton().trigger('click');
+
+ await nextTick();
+
+ expect(findJobSetupItem().props('job')).toMatchObject({ name: '', script: '' });
+ });
+
+ it('should clear job data when click cancel button', async () => {
+ findCancelButton().trigger('click');
+
+ await nextTick();
+
+ expect(findJobSetupItem().props('job')).toMatchObject({ name: '', script: '' });
+ });
+
+ it('should omit keys with default value when click add button', () => {
+ findRulesItem().vm.$emit('update-job', 'rules[0].allow_failure', false);
+ findRulesItem().vm.$emit('update-job', 'rules[0].when', JOB_RULES_WHEN.onSuccess.value);
+ findRulesItem().vm.$emit('update-job', 'rules[0].start_in', dummyRulesStartIn);
+ findConfirmButton().trigger('click');
+
+ expect(wrapper.emitted('updateCiConfig')).toStrictEqual([
+ [
+ `${wrapper.props('ciFileContent')}\n${stringify({
+ [dummyJobName]: {
+ script: dummyJobScript,
+ image: { name: dummyImageName, entrypoint: [dummyImageEntrypoint] },
+ services: [
+ {
+ name: dummyServicesName,
+ entrypoint: [dummyServicesEntrypoint],
+ },
+ ],
+ artifacts: {
+ paths: [dummyArtifactsPath],
+ exclude: [dummyArtifactsExclude],
+ },
+ cache: {
+ paths: [dummyCachePath],
+ key: dummyCacheKey,
+ },
+ },
+ })}`,
+ ],
+ ]);
+ });
+
+ it('should update correct ci content when click add button', () => {
+ findConfirmButton().trigger('click');
+
+ expect(wrapper.emitted('updateCiConfig')).toStrictEqual([
+ [
+ `${wrapper.props('ciFileContent')}\n${stringify({
+ [dummyJobName]: {
+ script: dummyJobScript,
+ image: { name: dummyImageName, entrypoint: [dummyImageEntrypoint] },
+ services: [
+ {
+ name: dummyServicesName,
+ entrypoint: [dummyServicesEntrypoint],
+ },
+ ],
+ artifacts: {
+ paths: [dummyArtifactsPath],
+ exclude: [dummyArtifactsExclude],
+ },
+ cache: {
+ paths: [dummyCachePath],
+ key: dummyCacheKey,
+ },
+ rules: [
+ {
+ allow_failure: dummyRulesAllowFailure,
+ when: dummyRulesWhen,
+ start_in: dummyRulesStartIn,
+ },
+ ],
+ },
+ })}`,
+ ],
+ ]);
+ });
+
+ it('should emit scroll editor to button event when click add button', () => {
+ const eventHubSpy = jest.spyOn(eventHub, '$emit');
+
+ findConfirmButton().trigger('click');
+
+ expect(eventHubSpy).toHaveBeenCalledWith(SCROLL_EDITOR_TO_BOTTOM);
+ });
+ });
});
diff --git a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js
index d43bdec3a33..cc9a77ae525 100644
--- a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js
@@ -40,10 +40,6 @@ describe('CI Lint Results', () => {
const findAfterScripts = findAllByTestId('after-script');
const filterEmptyScripts = (property) => mockJobs.filter((job) => job[property].length !== 0);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Empty results', () => {
it('renders with no jobs, errors or warnings defined', () => {
createComponent({ jobs: undefined, errors: undefined, warnings: undefined }, shallowMount);
diff --git a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js
index b5e3ea06c2c..d09e22898cd 100644
--- a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js
@@ -21,11 +21,6 @@ describe('CI lint warnings', () => {
const findWarnings = () => wrapper.findAll('[data-testid="ci-lint-warning"]');
const findWarningMessage = () => trimText(wrapper.findComponent(GlSprintf).text());
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('displays the warning alert', () => {
createComponent();
diff --git a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js
index f40db50aab7..471b033913b 100644
--- a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js
@@ -57,6 +57,7 @@ describe('Pipeline editor tabs component', () => {
isNewCiConfigFile: true,
showDrawer: false,
showJobAssistantDrawer: false,
+ showAiAssistantDrawer: false,
...props,
},
data() {
@@ -65,6 +66,7 @@ describe('Pipeline editor tabs component', () => {
};
},
provide: {
+ aiChatAvailable: false,
ciConfigPath: '/path/to/ci-config',
ciLintPath: mockCiLintPath,
currentBranch: 'main',
@@ -119,6 +121,7 @@ describe('Pipeline editor tabs component', () => {
});
afterEach(() => {
+ // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy
wrapper.destroy();
});
@@ -313,13 +316,13 @@ describe('Pipeline editor tabs component', () => {
createComponent();
});
- it('shows walkthrough popover', async () => {
+ it('shows walkthrough popover', () => {
expect(findWalkthroughPopover().exists()).toBe(true);
});
});
describe('when isNewCiConfigFile prop is false', () => {
- it('does not show walkthrough popover', async () => {
+ it('does not show walkthrough popover', () => {
createComponent({ props: { isNewCiConfigFile: false } });
expect(findWalkthroughPopover().exists()).toBe(false);
});
diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js
index 63ebfc0559d..3d84f06967a 100644
--- a/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js
@@ -22,11 +22,10 @@ describe('FileTreePopover component', () => {
afterEach(() => {
localStorage.clear();
- wrapper.destroy();
});
describe('default', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({ stubs: { GlSprintf } });
});
@@ -46,7 +45,7 @@ describe('FileTreePopover component', () => {
});
describe('when popover has already been dismissed before', () => {
- it('does not render popover', async () => {
+ it('does not render popover', () => {
localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true');
createComponent();
diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js
index cf0b974081e..18eec48ad83 100644
--- a/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js
@@ -19,12 +19,8 @@ describe('ValidatePopover component', () => {
const findHelpLink = () => wrapper.findByTestId('help-link');
const findFeedbackLink = () => wrapper.findByTestId('feedback-link');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({
stubs: { GlLink, GlSprintf },
});
diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js
index ca6033f2ff5..37339b1c422 100644
--- a/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js
@@ -12,17 +12,13 @@ describe('WalkthroughPopover component', () => {
return extendedWrapper(mountFn(WalkthroughPopover));
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('CTA button clicked', () => {
beforeEach(async () => {
wrapper = createComponent(mount);
await wrapper.findByTestId('ctaBtn').trigger('click');
});
- it('emits "walkthrough-popover-cta-clicked" event', async () => {
+ it('emits "walkthrough-popover-cta-clicked" event', () => {
expect(wrapper.emitted()['walkthrough-popover-cta-clicked']).toHaveLength(1);
});
});
diff --git a/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js
index b22c98e5544..8b8dd4d22c2 100644
--- a/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js
@@ -4,10 +4,9 @@ import ConfirmDialog from '~/ci/pipeline_editor/components/ui/confirm_unsaved_ch
describe('pipeline_editor/components/ui/confirm_unsaved_changes_dialog', () => {
let beforeUnloadEvent;
let setDialogContent;
- let wrapper;
const createComponent = (propsData = {}) => {
- wrapper = shallowMount(ConfirmDialog, {
+ shallowMount(ConfirmDialog, {
propsData,
});
};
@@ -21,7 +20,6 @@ describe('pipeline_editor/components/ui/confirm_unsaved_changes_dialog', () => {
afterEach(() => {
beforeUnloadEvent.preventDefault.mockRestore();
setDialogContent.mockRestore();
- wrapper.destroy();
});
it('shows confirmation dialog when there are unsaved changes', () => {
diff --git a/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js
index a4e7abba7b0..f02b1f5efbc 100644
--- a/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js
@@ -64,7 +64,7 @@ describe('~/ci/pipeline_editor/components/ui/editor_tab.vue', () => {
mockChildMounted = jest.fn();
});
- it('tabs are mounted lazily', async () => {
+ it('tabs are mounted lazily', () => {
createMockedWrapper();
expect(mockChildMounted).toHaveBeenCalledTimes(0);
@@ -192,7 +192,7 @@ describe('~/ci/pipeline_editor/components/ui/editor_tab.vue', () => {
createMockedWrapper();
});
- it('renders correct number of badges', async () => {
+ it('renders correct number of badges', () => {
expect(findBadges()).toHaveLength(1);
expect(findBadges().at(0).text()).toBe('NEW');
});
diff --git a/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js
index 3c68f74af43..e636a89c6d9 100644
--- a/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js
@@ -23,10 +23,6 @@ describe('Pipeline editor empty state', () => {
const findConfirmButton = () => wrapper.findComponent(GlButton);
const findDescription = () => wrapper.findComponent(GlSprintf);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when project uses an external CI config', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js
index ae25142b455..2349816fa86 100644
--- a/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui';
+import { GlAlert, GlDisclosureDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui';
import { nextTick } from 'vue';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
@@ -90,7 +90,7 @@ describe('Pipeline Editor Validate Tab', () => {
const findHelpIcon = () => wrapper.findComponent(GlIcon);
const findIllustration = () => wrapper.findByRole('img');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findPipelineSource = () => wrapper.findComponent(GlDropdown);
+ const findPipelineSource = () => wrapper.findComponent(GlDisclosureDropdown);
const findPopover = () => wrapper.findComponent(GlPopover);
const findCiLintResults = () => wrapper.findComponent(CiLintResults);
const findResultsCta = () => wrapper.findByTestId('resimulate-pipeline-button');
@@ -99,10 +99,6 @@ describe('Pipeline Editor Validate Tab', () => {
mockBlobContentData = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('while initial CI content is loading', () => {
beforeEach(() => {
createComponent({ isBlobLoading: true });
@@ -122,7 +118,7 @@ describe('Pipeline Editor Validate Tab', () => {
it('renders disabled pipeline source dropdown', () => {
expect(findPipelineSource().exists()).toBe(true);
- expect(findPipelineSource().attributes('text')).toBe(i18n.pipelineSourceDefault);
+ expect(findPipelineSource().attributes('toggletext')).toBe(i18n.pipelineSourceDefault);
expect(findPipelineSource().props('disabled')).toBe(true);
});
diff --git a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js
index 6a6cc3a14de..893f6775ac5 100644
--- a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js
+++ b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js
@@ -34,7 +34,7 @@ describe('~/ci/pipeline_editor/graphql/resolvers', () => {
});
/* eslint-disable no-underscore-dangle */
- it('lint data has correct type names', async () => {
+ it('lint data has correct type names', () => {
expect(result.__typename).toBe('CiLintContent');
expect(result.jobs[0].__typename).toBe('CiLintJob');
diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js
index 541123d7efc..865dd34fbfe 100644
--- a/spec/frontend/ci/pipeline_editor/mock_data.js
+++ b/spec/frontend/ci/pipeline_editor/mock_data.js
@@ -12,7 +12,7 @@ export const mockCommitSha = 'aabbccdd';
export const mockCommitNextSha = 'eeffgghh';
export const mockIncludesHelpPagePath = '/-/includes/help';
export const mockLintHelpPagePath = '/-/lint-help';
-export const mockLintUnavailableHelpPagePath = '/-/pipeline-editor/troubleshoot';
+export const mockCiTroubleshootingPath = '/-/pipeline-editor/troubleshoot';
export const mockSimulatePipelineHelpPagePath = '/-/simulate-pipeline-help';
export const mockYmlHelpPagePath = '/-/yml-help';
export const mockCommitMessage = 'My commit message';
@@ -583,6 +583,36 @@ export const mockCommitCreateResponse = {
},
};
+export const mockRunnersTagsQueryResponse = {
+ data: {
+ runners: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Ci::Runner/1',
+ tagList: ['tag1', 'tag2'],
+ __typename: 'CiRunner',
+ },
+ {
+ id: 'gid://gitlab/Ci::Runner/2',
+ tagList: ['tag2', 'tag3'],
+ __typename: 'CiRunner',
+ },
+ {
+ id: 'gid://gitlab/Ci::Runner/3',
+ tagList: ['tag2', 'tag4'],
+ __typename: 'CiRunner',
+ },
+ {
+ id: 'gid://gitlab/Ci::Runner/4',
+ tagList: [],
+ __typename: 'CiRunner',
+ },
+ ],
+ __typename: 'CiRunnerConnection',
+ },
+ },
+};
+
export const mockCommitCreateResponseNewEtag = {
data: {
commitCreate: {
diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js
index a103acb33bc..cc4a022c2df 100644
--- a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js
+++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlButton, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -6,7 +6,7 @@ import setWindowLocation from 'helpers/set_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
-import { objectToQuery, redirectTo } from '~/lib/utils/url_utility';
+import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers';
import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue';
import PipelineEditorEmptyState from '~/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
@@ -96,7 +96,7 @@ describe('Pipeline editor app component', () => {
});
};
- const createComponentWithApollo = async ({
+ const createComponentWithApollo = ({
provide = {},
stubs = {},
withUndefinedBranch = false,
@@ -162,10 +162,6 @@ describe('Pipeline editor app component', () => {
mockPipelineQuery = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('loading state', () => {
it('displays a loading icon if the blob query is loading', () => {
createComponent({ blobLoading: true });
@@ -264,7 +260,7 @@ describe('Pipeline editor app component', () => {
expect(findAlert().exists()).toBe(false);
});
- it('ci config query is called with correct variables', async () => {
+ it('ci config query is called with correct variables', () => {
expect(mockCiConfigData).toHaveBeenCalledWith({
content: mockCiYml,
projectPath: mockProjectFullPath,
@@ -291,7 +287,7 @@ describe('Pipeline editor app component', () => {
.mockImplementation(jest.fn());
});
- it('shows an empty state and does not show editor home component', async () => {
+ it('shows an empty state and does not show editor home component', () => {
expect(findEmptyState().exists()).toBe(true);
expect(findAlert().exists()).toBe(false);
expect(findEditorHome().exists()).toBe(false);
@@ -351,7 +347,9 @@ describe('Pipeline editor app component', () => {
});
it('shows that the lint service is down', () => {
- expect(findValidationSegment().text()).toContain(
+ const validationMessage = findValidationSegment().findComponent(GlSprintf);
+
+ expect(validationMessage.attributes('message')).toContain(
validationSegmenti18n.unavailableValidation,
);
});
@@ -436,7 +434,7 @@ describe('Pipeline editor app component', () => {
'merge_request[target_branch]': mockDefaultBranch,
});
- expect(redirectTo).toHaveBeenCalledWith(`${mockNewMergeRequestPath}?${branchesQuery}`);
+ expect(redirectTo).toHaveBeenCalledWith(`${mockNewMergeRequestPath}?${branchesQuery}`); // eslint-disable-line import/no-deprecated
});
});
diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js
index 4f8f2112abe..576263d5418 100644
--- a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js
+++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js
@@ -41,6 +41,7 @@ describe('Pipeline editor home wrapper', () => {
...props,
},
provide: {
+ aiChatAvailable: false,
projectFullPath: '',
totalBranches: 19,
glFeatures: {
@@ -67,7 +68,6 @@ describe('Pipeline editor home wrapper', () => {
afterEach(() => {
localStorage.clear();
- wrapper.destroy();
});
describe('renders', () => {
diff --git a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js
index 6f18899ebac..1d4ae33c667 100644
--- a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js
+++ b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js
@@ -4,7 +4,7 @@ import { GlForm, GlDropdownItem, GlSprintf, GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import CreditCardValidationRequiredAlert from 'ee_component/billings/components/cc_validation_required_alert.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
@@ -13,7 +13,7 @@ import {
HTTP_STATUS_INTERNAL_SERVER_ERROR,
HTTP_STATUS_OK,
} from '~/lib/utils/http_status';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import PipelineNewForm, {
POLLING_INTERVAL,
} from '~/ci/pipeline_new/components/pipeline_new_form.vue';
@@ -30,8 +30,8 @@ import {
mockQueryParams,
mockPostParams,
mockProjectId,
- mockRefs,
mockYamlVariables,
+ mockPipelineConfigButtonText,
} from '../mock_data';
Vue.use(VueApollo);
@@ -40,8 +40,8 @@ jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
-const projectRefsEndpoint = '/root/project/refs';
const pipelinesPath = '/root/project/-/pipelines';
+const pipelinesEditorPath = '/root/project/-/ci/editor';
const projectPath = '/root/project/-/pipelines/config_variables';
const newPipelinePostResponse = { id: 1 };
const defaultBranch = 'main';
@@ -65,6 +65,7 @@ describe('Pipeline New Form', () => {
wrapper.findAllByTestId('pipeline-form-ci-variable-value-dropdown');
const findValueDropdownItems = (dropdown) => dropdown.findAllComponents(GlDropdownItem);
const findErrorAlert = () => wrapper.findByTestId('run-pipeline-error-alert');
+ const findPipelineConfigButton = () => wrapper.findByTestId('ci-cd-pipeline-configuration');
const findWarningAlert = () => wrapper.findByTestId('run-pipeline-warning-alert');
const findWarningAlertSummary = () => findWarningAlert().findComponent(GlSprintf);
const findWarnings = () => wrapper.findAllByTestId('run-pipeline-warning');
@@ -88,24 +89,23 @@ describe('Pipeline New Form', () => {
const changeKeyInputValue = async (keyInputIndex, value) => {
const input = findKeyInputs().at(keyInputIndex);
- input.element.value = value;
- input.trigger('change');
+ input.vm.$emit('input', value);
+ input.vm.$emit('change');
await nextTick();
};
- const createComponentWithApollo = ({ method = shallowMountExtended, props = {} } = {}) => {
+ const createComponentWithApollo = ({ props = {} } = {}) => {
const handlers = [[ciConfigVariablesQuery, mockCiConfigVariables]];
mockApollo = createMockApollo(handlers, resolvers);
- wrapper = method(PipelineNewForm, {
+ wrapper = shallowMountExtended(PipelineNewForm, {
apolloProvider: mockApollo,
- provide: {
- projectRefsEndpoint,
- },
propsData: {
projectId: mockProjectId,
pipelinesPath,
+ pipelinesEditorPath,
+ canViewPipelineEditor: true,
projectPath,
defaultBranch,
refParam: defaultBranch,
@@ -119,7 +119,6 @@ describe('Pipeline New Form', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mockCiConfigVariables = jest.fn();
- mock.onGet(projectRefsEndpoint).reply(HTTP_STATUS_OK, mockRefs);
dummySubmitEvent = {
preventDefault: jest.fn(),
@@ -128,17 +127,16 @@ describe('Pipeline New Form', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('Form', () => {
beforeEach(async () => {
mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse);
- createComponentWithApollo({ props: mockQueryParams, method: mountExtended });
+ createComponentWithApollo({ props: mockQueryParams });
await waitForPromises();
});
- it('displays the correct values for the provided query params', async () => {
+ it('displays the correct values for the provided query params', () => {
expect(findVariableTypes().at(0).props('text')).toBe('Variable');
expect(findVariableTypes().at(1).props('text')).toBe('File');
expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' });
@@ -146,13 +144,13 @@ describe('Pipeline New Form', () => {
});
it('displays a variable from provided query params', () => {
- expect(findKeyInputs().at(0).element.value).toBe('test_var');
- expect(findValueInputs().at(0).element.value).toBe('test_var_val');
+ expect(findKeyInputs().at(0).attributes('value')).toBe('test_var');
+ expect(findValueInputs().at(0).attributes('value')).toBe('test_var_val');
});
- it('displays an empty variable for the user to fill out', async () => {
- expect(findKeyInputs().at(2).element.value).toBe('');
- expect(findValueInputs().at(2).element.value).toBe('');
+ it('displays an empty variable for the user to fill out', () => {
+ expect(findKeyInputs().at(2).attributes('value')).toBe('');
+ expect(findValueInputs().at(2).attributes('value')).toBe('');
expect(findVariableTypes().at(2).props('text')).toBe('Variable');
});
@@ -161,7 +159,7 @@ describe('Pipeline New Form', () => {
});
it('removes ci variable row on remove icon button click', async () => {
- findRemoveIcons().at(1).trigger('click');
+ findRemoveIcons().at(1).vm.$emit('click');
await nextTick();
@@ -170,24 +168,25 @@ describe('Pipeline New Form', () => {
it('creates blank variable on input change event', async () => {
const input = findKeyInputs().at(2);
- input.element.value = 'test_var_2';
- input.trigger('change');
+
+ input.vm.$emit('input', 'test_var_2');
+ input.vm.$emit('change');
await nextTick();
expect(findVariableRows()).toHaveLength(4);
- expect(findKeyInputs().at(3).element.value).toBe('');
- expect(findValueInputs().at(3).element.value).toBe('');
+ expect(findKeyInputs().at(3).attributes('value')).toBe('');
+ expect(findValueInputs().at(3).attributes('value')).toBe('');
});
});
describe('Pipeline creation', () => {
- beforeEach(async () => {
+ beforeEach(() => {
mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse);
mock.onPost(pipelinesPath).reply(HTTP_STATUS_OK, newPipelinePostResponse);
});
- it('does not submit the native HTML form', async () => {
+ it('does not submit the native HTML form', () => {
createComponentWithApollo();
findForm().vm.$emit('submit', dummySubmitEvent);
@@ -213,7 +212,7 @@ describe('Pipeline New Form', () => {
await waitForPromises();
expect(getFormPostParams().ref).toEqual(`refs/heads/${defaultBranch}`);
- expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`);
+ expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); // eslint-disable-line import/no-deprecated
});
it('creates a pipeline with short ref and variables from the query params', async () => {
@@ -226,14 +225,14 @@ describe('Pipeline New Form', () => {
await waitForPromises();
expect(getFormPostParams()).toEqual(mockPostParams);
- expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`);
+ expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); // eslint-disable-line import/no-deprecated
});
});
describe('When the ref has been changed', () => {
beforeEach(async () => {
mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse);
- createComponentWithApollo({ method: mountExtended });
+ createComponentWithApollo();
await waitForPromises();
});
@@ -247,12 +246,12 @@ describe('Pipeline New Form', () => {
await selectBranch('main');
- expect(findKeyInputs().at(0).element.value).toBe('build_var');
+ expect(findKeyInputs().at(0).attributes('value')).toBe('build_var');
expect(findVariableRows().length).toBe(2);
await selectBranch('branch-1');
- expect(findKeyInputs().at(0).element.value).toBe('deploy_var');
+ expect(findKeyInputs().at(0).attributes('value')).toBe('deploy_var');
expect(findVariableRows().length).toBe(2);
});
@@ -276,7 +275,7 @@ describe('Pipeline New Form', () => {
describe('When there are no variables in the API cache', () => {
beforeEach(async () => {
mockCiConfigVariables.mockResolvedValue(mockNoCachedCiConfigVariablesResponse);
- createComponentWithApollo({ method: mountExtended });
+ createComponentWithApollo();
await waitForPromises();
});
@@ -324,9 +323,9 @@ describe('Pipeline New Form', () => {
});
const testBehaviorWhenCacheIsPopulated = (queryResponse) => {
- beforeEach(async () => {
+ beforeEach(() => {
mockCiConfigVariables.mockResolvedValue(queryResponse);
- createComponentWithApollo({ method: mountExtended });
+ createComponentWithApollo();
});
it('does not poll for new values', async () => {
@@ -341,6 +340,9 @@ describe('Pipeline New Form', () => {
});
it('loading icon is shown when content is requested and hidden when received', async () => {
+ mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse);
+ createComponentWithApollo({ props: mockQueryParams });
+
expect(findLoadingIcon().exists()).toBe(true);
await waitForPromises();
@@ -354,11 +356,11 @@ describe('Pipeline New Form', () => {
it('displays an empty form', async () => {
mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse);
- createComponentWithApollo({ method: mountExtended });
+ createComponentWithApollo();
await waitForPromises();
- expect(findKeyInputs().at(0).element.value).toBe('');
- expect(findValueInputs().at(0).element.value).toBe('');
+ expect(findKeyInputs().at(0).attributes('value')).toBe('');
+ expect(findValueInputs().at(0).attributes('value')).toBe('');
expect(findVariableTypes().at(0).props('text')).toBe('Variable');
});
});
@@ -369,12 +371,12 @@ describe('Pipeline New Form', () => {
describe('with different predefined values', () => {
beforeEach(async () => {
mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse);
- createComponentWithApollo({ method: mountExtended });
+ createComponentWithApollo();
await waitForPromises();
});
it('multi-line strings are added to the value field without removing line breaks', () => {
- expect(findValueInputs().at(1).element.value).toBe(mockYamlVariables[1].value);
+ expect(findValueInputs().at(1).attributes('value')).toBe(mockYamlVariables[1].value);
});
it('multiple predefined values are rendered as a dropdown', () => {
@@ -398,24 +400,24 @@ describe('Pipeline New Form', () => {
describe('with description', () => {
beforeEach(async () => {
mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse);
- createComponentWithApollo({ props: mockQueryParams, method: mountExtended });
+ createComponentWithApollo({ props: mockQueryParams });
await waitForPromises();
});
- it('displays all the variables', async () => {
+ it('displays all the variables', () => {
expect(findVariableRows()).toHaveLength(6);
});
it('displays a variable from yml', () => {
- expect(findKeyInputs().at(0).element.value).toBe(mockYamlVariables[0].key);
- expect(findValueInputs().at(0).element.value).toBe(mockYamlVariables[0].value);
+ expect(findKeyInputs().at(0).attributes('value')).toBe(mockYamlVariables[0].key);
+ expect(findValueInputs().at(0).attributes('value')).toBe(mockYamlVariables[0].value);
});
it('displays a variable from provided query params', () => {
- expect(findKeyInputs().at(3).element.value).toBe(
+ expect(findKeyInputs().at(3).attributes('value')).toBe(
Object.keys(mockQueryParams.variableParams)[0],
);
- expect(findValueInputs().at(3).element.value).toBe(
+ expect(findValueInputs().at(3).attributes('value')).toBe(
Object.values(mockQueryParams.fileParams)[0],
);
});
@@ -425,7 +427,7 @@ describe('Pipeline New Form', () => {
});
it('removes the description when a variable key changes', async () => {
- findKeyInputs().at(0).element.value = 'yml_var_modified';
+ findKeyInputs().at(0).vm.$emit('input', 'yml_var_modified');
findKeyInputs().at(0).trigger('change');
await nextTick();
@@ -437,11 +439,11 @@ describe('Pipeline New Form', () => {
describe('without description', () => {
beforeEach(async () => {
mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponseWithoutDesc);
- createComponentWithApollo({ method: mountExtended });
+ createComponentWithApollo();
await waitForPromises();
});
- it('displays variables with description only', async () => {
+ it('displays variables with description only', () => {
expect(findVariableRows()).toHaveLength(2); // extra empty variable is added at the end
});
});
@@ -456,7 +458,7 @@ describe('Pipeline New Form', () => {
describe('when the refs cannot be loaded', () => {
beforeEach(() => {
mock
- .onGet(projectRefsEndpoint, { params: { search: '' } })
+ .onGet('/api/v4/projects/8/repository/branches', { params: { search: '' } })
.reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
findRefsDropdown().vm.$emit('loadingError');
@@ -500,6 +502,17 @@ describe('Pipeline New Form', () => {
expect(findSubmitButton().props('disabled')).toBe(false);
});
+ it('shows pipeline configuration button for user who can view', () => {
+ expect(findPipelineConfigButton().exists()).toBe(true);
+ expect(findPipelineConfigButton().text()).toBe(mockPipelineConfigButtonText);
+ });
+
+ it('does not show pipeline configuration button for user who can not view', () => {
+ createComponentWithApollo({ props: { canViewPipelineEditor: false } });
+
+ expect(findPipelineConfigButton().exists()).toBe(false);
+ });
+
it('does not show the credit card validation required alert', () => {
expect(findCCAlert().exists()).toBe(false);
});
diff --git a/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js
index cf8009e388f..01c7dd7eb84 100644
--- a/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js
+++ b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js
@@ -1,35 +1,22 @@
-import { GlListbox, GlListboxItem } from '@gitlab/ui';
-import MockAdapter from 'axios-mock-adapter';
-import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { shallowMount } from '@vue/test-utils';
+import RefSelector from '~/ref/components/ref_selector.vue';
import RefsDropdown from '~/ci/pipeline_new/components/refs_dropdown.vue';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
-import { mockBranches, mockRefs, mockFilteredRefs, mockTags } from '../mock_data';
-
-const projectRefsEndpoint = '/root/project/refs';
+const projectId = '8';
const refShortName = 'main';
const refFullName = 'refs/heads/main';
-jest.mock('~/flash');
-
describe('Pipeline New Form', () => {
let wrapper;
- let mock;
- const findDropdown = () => wrapper.findComponent(GlListbox);
- const findRefsDropdownItems = () => wrapper.findAllComponents(GlListboxItem);
- const findSearchBox = () => wrapper.findByTestId('listbox-search-input');
- const findListboxGroups = () => wrapper.findAll('ul[role="group"]');
+ const findRefSelector = () => wrapper.findComponent(RefSelector);
- const createComponent = (props = {}, mountFn = shallowMountExtended) => {
- wrapper = mountFn(RefsDropdown, {
- provide: {
- projectRefsEndpoint,
- },
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(RefsDropdown, {
propsData: {
+ projectId,
value: {
shortName: refShortName,
fullName: refFullName,
@@ -39,163 +26,54 @@ describe('Pipeline New Form', () => {
});
};
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(HTTP_STATUS_OK, mockRefs);
- });
-
- beforeEach(() => {
- createComponent();
- });
-
- it('displays empty dropdown initially', () => {
- findDropdown().vm.$emit('shown');
-
- expect(findRefsDropdownItems()).toHaveLength(0);
- });
-
- it('does not make requests immediately', async () => {
- expect(mock.history.get).toHaveLength(0);
- });
-
describe('when user opens dropdown', () => {
- beforeEach(async () => {
- createComponent({}, mountExtended);
- findDropdown().vm.$emit('shown');
- await waitForPromises();
+ beforeEach(() => {
+ createComponent();
});
- it('requests unfiltered tags and branches', () => {
- expect(mock.history.get).toHaveLength(1);
- expect(mock.history.get[0].url).toBe(projectRefsEndpoint);
- expect(mock.history.get[0].params).toEqual({ search: '' });
+ it('has default selected branch', () => {
+ expect(findRefSelector().props('value')).toBe('main');
});
- it('displays dropdown with branches and tags', () => {
- const refLength = mockRefs.Tags.length + mockRefs.Branches.length;
- expect(findRefsDropdownItems()).toHaveLength(refLength);
- });
-
- it('displays the names of refs', () => {
- // Branches
- expect(findRefsDropdownItems().at(0).text()).toBe(mockRefs.Branches[0]);
-
- // Tags (appear after branches)
- const firstTag = mockRefs.Branches.length;
- expect(findRefsDropdownItems().at(firstTag).text()).toBe(mockRefs.Tags[0]);
- });
-
- it('when user shows dropdown a second time, only one request is done', () => {
- expect(mock.history.get).toHaveLength(1);
+ it('has ref selector for branches and tags', () => {
+ expect(findRefSelector().props('enabledRefTypes')).toEqual([
+ REF_TYPE_BRANCHES,
+ REF_TYPE_TAGS,
+ ]);
});
describe('when user selects a value', () => {
- const selectedIndex = 1;
-
- beforeEach(async () => {
- findRefsDropdownItems().at(selectedIndex).vm.$emit('select', 'refs/heads/branch-1');
- await waitForPromises();
- });
+ const fullName = `refs/heads/conflict-contains-conflict-markers`;
it('component emits @input', () => {
+ findRefSelector().vm.$emit('input', fullName);
+
const inputs = wrapper.emitted('input');
expect(inputs).toHaveLength(1);
- expect(inputs[0]).toEqual([{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' }]);
- });
- });
-
- describe('when user types searches for a tag', () => {
- const mockSearchTerm = 'my-search';
-
- beforeEach(async () => {
- mock
- .onGet(projectRefsEndpoint, { params: { search: mockSearchTerm } })
- .reply(HTTP_STATUS_OK, mockFilteredRefs);
-
- await findSearchBox().vm.$emit('input', mockSearchTerm);
- await waitForPromises();
- });
-
- it('requests filtered tags and branches', async () => {
- expect(mock.history.get).toHaveLength(2);
- expect(mock.history.get[1].params).toEqual({
- search: mockSearchTerm,
- });
- });
-
- it('displays dropdown with branches and tags', async () => {
- const filteredRefLength = mockFilteredRefs.Tags.length + mockFilteredRefs.Branches.length;
-
- expect(findRefsDropdownItems()).toHaveLength(filteredRefLength);
+ expect(inputs[0]).toEqual([
+ {
+ shortName: 'conflict-contains-conflict-markers',
+ fullName: 'refs/heads/conflict-contains-conflict-markers',
+ },
+ ]);
});
});
});
describe('when user has selected a value', () => {
- const selectedIndex = 1;
- const mockShortName = mockRefs.Branches[selectedIndex];
+ const mockShortName = 'conflict-contains-conflict-markers';
const mockFullName = `refs/heads/${mockShortName}`;
- beforeEach(async () => {
- mock
- .onGet(projectRefsEndpoint, {
- params: { ref: mockFullName },
- })
- .reply(HTTP_STATUS_OK, mockRefs);
-
- createComponent(
- {
- value: {
- shortName: mockShortName,
- fullName: mockFullName,
- },
- },
- mountExtended,
- );
- findDropdown().vm.$emit('shown');
- await waitForPromises();
- });
-
it('branch is checked', () => {
- expect(findRefsDropdownItems().at(selectedIndex).props('isSelected')).toBe(true);
- });
- });
-
- describe('when server returns an error', () => {
- beforeEach(async () => {
- mock
- .onGet(projectRefsEndpoint, { params: { search: '' } })
- .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
-
- findDropdown().vm.$emit('shown');
- await waitForPromises();
- });
+ createComponent({
+ value: {
+ shortName: mockShortName,
+ fullName: mockFullName,
+ },
+ });
- it('loading error event is emitted', () => {
- expect(wrapper.emitted('loadingError')).toHaveLength(1);
- expect(wrapper.emitted('loadingError')[0]).toEqual([expect.any(Error)]);
+ expect(findRefSelector().props('value')).toBe(mockShortName);
});
});
-
- describe('should display branches and tags based on its length', () => {
- it.each`
- mockData | expectedGroupLength | expectedListboxItemsLength
- ${{ ...mockBranches, Tags: [] }} | ${1} | ${mockBranches.Branches.length}
- ${{ Branches: [], ...mockTags }} | ${1} | ${mockTags.Tags.length}
- ${{ ...mockRefs }} | ${2} | ${mockBranches.Branches.length + mockTags.Tags.length}
- ${{ Branches: undefined, Tags: undefined }} | ${0} | ${0}
- `(
- 'should render branches and tags based on presence',
- async ({ mockData, expectedGroupLength, expectedListboxItemsLength }) => {
- mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(HTTP_STATUS_OK, mockData);
- createComponent({}, mountExtended);
- findDropdown().vm.$emit('shown');
- await waitForPromises();
-
- expect(findListboxGroups()).toHaveLength(expectedGroupLength);
- expect(findRefsDropdownItems()).toHaveLength(expectedListboxItemsLength);
- },
- );
- });
});
diff --git a/spec/frontend/ci/pipeline_new/mock_data.js b/spec/frontend/ci/pipeline_new/mock_data.js
index 5b935c0c819..76a88f63298 100644
--- a/spec/frontend/ci/pipeline_new/mock_data.js
+++ b/spec/frontend/ci/pipeline_new/mock_data.js
@@ -1,16 +1,3 @@
-export const mockBranches = {
- Branches: ['main', 'branch-1', 'branch-2'],
-};
-
-export const mockTags = {
- Tags: ['1.0.0', '1.1.0', '1.2.0'],
-};
-
-export const mockRefs = {
- ...mockBranches,
- ...mockTags,
-};
-
export const mockFilteredRefs = {
Branches: ['branch-1'],
Tags: ['1.0.0', '1.1.0'],
@@ -133,3 +120,5 @@ export const mockCiConfigVariablesResponseWithoutDesc = mockCiConfigVariablesQue
mockYamlVariablesWithoutDesc,
);
export const mockNoCachedCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse(null);
+
+export const mockPipelineConfigButtonText = 'Go to the pipeline editor';
diff --git a/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js b/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js
index ba948f12b33..e48f556c246 100644
--- a/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js
@@ -20,17 +20,13 @@ describe('Delete pipeline schedule modal', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('emits the deleteSchedule event', async () => {
+ it('emits the deleteSchedule event', () => {
findModal().vm.$emit('primary');
expect(wrapper.emitted()).toEqual({ deleteSchedule: [[]] });
});
- it('emits the hideModal event', async () => {
+ it('emits the hideModal event', () => {
findModal().vm.$emit('hide');
expect(wrapper.emitted()).toEqual({ hideModal: [[]] });
diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js
index 611993556e3..50008cedd9c 100644
--- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js
@@ -16,6 +16,7 @@ import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/g
import {
mockGetPipelineSchedulesGraphQLResponse,
mockPipelineScheduleNodes,
+ mockPipelineScheduleCurrentUser,
deleteMutationResponse,
playMutationResponse,
takeOwnershipMutationResponse,
@@ -79,10 +80,6 @@ describe('Pipeline schedules app', () => {
const findSchedulesCharacteristics = () =>
wrapper.findByTestId('pipeline-schedules-characteristics');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
@@ -115,6 +112,7 @@ describe('Pipeline schedules app', () => {
await waitForPromises();
expect(findTable().props('schedules')).toEqual(mockPipelineScheduleNodes);
+ expect(findTable().props('currentUser')).toEqual(mockPipelineScheduleCurrentUser);
});
it('shows query error alert', async () => {
diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js
index 6fb6a8bc33b..be0052fc7cf 100644
--- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js
@@ -3,6 +3,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import PipelineScheduleActions from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue';
import {
mockPipelineScheduleNodes,
+ mockPipelineScheduleCurrentUser,
mockPipelineScheduleAsGuestNodes,
mockTakeOwnershipNodes,
} from '../../../mock_data';
@@ -12,6 +13,7 @@ describe('Pipeline schedule actions', () => {
const defaultProps = {
schedule: mockPipelineScheduleNodes[0],
+ currentUser: mockPipelineScheduleCurrentUser,
};
const createComponent = (props = defaultProps) => {
@@ -27,18 +29,17 @@ describe('Pipeline schedule actions', () => {
const findTakeOwnershipBtn = () => wrapper.findByTestId('take-ownership-pipeline-schedule-btn');
const findPlayScheduleBtn = () => wrapper.findByTestId('play-pipeline-schedule-btn');
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('displays action buttons', () => {
+ it('displays buttons when user is the owner of schedule and has adminPipelineSchedule permissions', () => {
createComponent();
expect(findAllButtons()).toHaveLength(3);
});
- it('does not display action buttons', () => {
- createComponent({ schedule: mockPipelineScheduleAsGuestNodes[0] });
+ it('does not display action buttons when user is not owner and does not have adminPipelineSchedule permission', () => {
+ createComponent({
+ schedule: mockPipelineScheduleAsGuestNodes[0],
+ currentUser: mockPipelineScheduleCurrentUser,
+ });
expect(findAllButtons()).toHaveLength(0);
});
@@ -54,7 +55,10 @@ describe('Pipeline schedule actions', () => {
});
it('take ownership button emits showTakeOwnershipModal event and schedule id', () => {
- createComponent({ schedule: mockTakeOwnershipNodes[0] });
+ createComponent({
+ schedule: mockTakeOwnershipNodes[0],
+ currentUser: mockPipelineScheduleCurrentUser,
+ });
findTakeOwnershipBtn().vm.$emit('click');
diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js
index 0821c59c8a0..ae069145292 100644
--- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js
@@ -21,10 +21,6 @@ describe('Pipeline schedule last pipeline', () => {
const findCIBadgeLink = () => wrapper.findComponent(CiBadgeLink);
const findStatusText = () => wrapper.findByTestId('pipeline-schedule-status-text');
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays pipeline status', () => {
createComponent();
diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js
index 1c06c411097..3bdbb371ddc 100644
--- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js
@@ -21,10 +21,6 @@ describe('Pipeline schedule next run', () => {
const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip);
const findInactive = () => wrapper.findByTestId('pipeline-schedule-inactive');
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays time ago', () => {
createComponent();
diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js
index 6c1991cb4ac..849bef80f42 100644
--- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js
@@ -25,10 +25,6 @@ describe('Pipeline schedule owner', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays avatar', () => {
expect(findAvatar().exists()).toBe(true);
expect(findAvatar().props('src')).toBe(defaultProps.schedule.owner.avatarUrl);
diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js
index f531f04a736..5cc3829efbd 100644
--- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js
@@ -25,10 +25,6 @@ describe('Pipeline schedule target', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays icon', () => {
expect(findIcon().exists()).toBe(true);
expect(findIcon().props('name')).toBe('fork');
diff --git a/spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js
index 316b3bcf926..e488a36f3dc 100644
--- a/spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js
@@ -1,13 +1,14 @@
import { GlTableLite } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import PipelineSchedulesTable from '~/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue';
-import { mockPipelineScheduleNodes } from '../../mock_data';
+import { mockPipelineScheduleNodes, mockPipelineScheduleCurrentUser } from '../../mock_data';
describe('Pipeline schedules table', () => {
let wrapper;
const defaultProps = {
schedules: mockPipelineScheduleNodes,
+ currentUser: mockPipelineScheduleCurrentUser,
};
const createComponent = (props = defaultProps) => {
@@ -25,10 +26,6 @@ describe('Pipeline schedules table', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays table', () => {
expect(findTable().exists()).toBe(true);
});
diff --git a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js
index 7e6d4ec4bf8..e4ff9a0545b 100644
--- a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js
@@ -25,14 +25,12 @@ describe('Take ownership modal', () => {
const actionPrimary = findModal().props('actionPrimary');
expect(actionPrimary.attributes).toEqual(
- expect.objectContaining([
- {
- category: 'primary',
- variant: 'confirm',
- href: url,
- 'data-method': 'post',
- },
- ]),
+ expect.objectContaining({
+ category: 'primary',
+ variant: 'confirm',
+ href: url,
+ 'data-method': 'post',
+ }),
);
});
diff --git a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js
index e3965d13c19..7cc254b7653 100644
--- a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js
@@ -26,13 +26,13 @@ describe('Take ownership modal', () => {
);
});
- it('emits the takeOwnership event', async () => {
+ it('emits the takeOwnership event', () => {
findModal().vm.$emit('primary');
expect(wrapper.emitted()).toEqual({ takeOwnership: [[]] });
});
- it('emits the hideModal event', async () => {
+ it('emits the hideModal event', () => {
findModal().vm.$emit('hide');
expect(wrapper.emitted()).toEqual({ hideModal: [[]] });
diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js
index 2826c054249..1485f6beea4 100644
--- a/spec/frontend/ci/pipeline_schedules/mock_data.js
+++ b/spec/frontend/ci/pipeline_schedules/mock_data.js
@@ -5,6 +5,7 @@ import mockGetPipelineSchedulesTakeOwnershipGraphQLResponse from 'test_fixtures/
const {
data: {
+ currentUser,
project: {
pipelineSchedules: { nodes },
},
@@ -28,6 +29,7 @@ const {
} = mockGetPipelineSchedulesTakeOwnershipGraphQLResponse;
export const mockPipelineScheduleNodes = nodes;
+export const mockPipelineScheduleCurrentUser = currentUser;
export const mockPipelineScheduleAsGuestNodes = guestNodes;
diff --git a/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js
index 90ca2a07266..847862be183 100644
--- a/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js
+++ b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js
@@ -30,22 +30,17 @@ describe('code quality issue body issue body', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('severity rating', () => {
it.each`
severity | iconClass | iconName
${'INFO'} | ${'gl-text-blue-400'} | ${'severity-info'}
- ${'MINOR'} | ${'gl-text-orange-200'} | ${'severity-low'}
+ ${'MINOR'} | ${'gl-text-orange-300'} | ${'severity-low'}
${'CRITICAL'} | ${'gl-text-red-600'} | ${'severity-high'}
${'BLOCKER'} | ${'gl-text-red-800'} | ${'severity-critical'}
${'UNKNOWN'} | ${'gl-text-gray-400'} | ${'severity-unknown'}
${'INVALID'} | ${'gl-text-gray-400'} | ${'severity-unknown'}
${'info'} | ${'gl-text-blue-400'} | ${'severity-info'}
- ${'minor'} | ${'gl-text-orange-200'} | ${'severity-low'}
+ ${'minor'} | ${'gl-text-orange-300'} | ${'severity-low'}
${'major'} | ${'gl-text-orange-400'} | ${'severity-medium'}
${'critical'} | ${'gl-text-red-600'} | ${'severity-high'}
${'blocker'} | ${'gl-text-red-800'} | ${'severity-critical'}
diff --git a/spec/frontend/ci/reports/components/grouped_issues_list_spec.js b/spec/frontend/ci/reports/components/grouped_issues_list_spec.js
index 3e4adfc7794..8beec220802 100644
--- a/spec/frontend/ci/reports/components/grouped_issues_list_spec.js
+++ b/spec/frontend/ci/reports/components/grouped_issues_list_spec.js
@@ -15,10 +15,6 @@ describe('Grouped Issues List', () => {
const findHeading = (groupName) => wrapper.find(`[data-testid="${groupName}Heading"`);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a smart virtual list with the correct props', () => {
createComponent({
propsData: {
diff --git a/spec/frontend/ci/reports/components/issue_status_icon_spec.js b/spec/frontend/ci/reports/components/issue_status_icon_spec.js
index fb13d4407e2..82b655dd598 100644
--- a/spec/frontend/ci/reports/components/issue_status_icon_spec.js
+++ b/spec/frontend/ci/reports/components/issue_status_icon_spec.js
@@ -13,11 +13,6 @@ describe('IssueStatusIcon', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it.each([STATUS_SUCCESS, STATUS_NEUTRAL, STATUS_FAILED])(
'renders "%s" state correctly',
(status) => {
diff --git a/spec/frontend/ci/reports/components/report_link_spec.js b/spec/frontend/ci/reports/components/report_link_spec.js
index ba541ba0303..4a97afd77df 100644
--- a/spec/frontend/ci/reports/components/report_link_spec.js
+++ b/spec/frontend/ci/reports/components/report_link_spec.js
@@ -4,10 +4,6 @@ import ReportLink from '~/ci/reports/components/report_link.vue';
describe('app/assets/javascripts/ci/reports/components/report_link.vue', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
const defaultProps = {
issue: {},
};
diff --git a/spec/frontend/ci/reports/components/report_section_spec.js b/spec/frontend/ci/reports/components/report_section_spec.js
index f032b210184..f4012fe0215 100644
--- a/spec/frontend/ci/reports/components/report_section_spec.js
+++ b/spec/frontend/ci/reports/components/report_section_spec.js
@@ -49,10 +49,6 @@ describe('ReportSection component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('computed', () => {
describe('isCollapsible', () => {
const testMatrix = [
diff --git a/spec/frontend/ci/reports/components/summary_row_spec.js b/spec/frontend/ci/reports/components/summary_row_spec.js
index fb2ae5371d5..b1ae9e26b5b 100644
--- a/spec/frontend/ci/reports/components/summary_row_spec.js
+++ b/spec/frontend/ci/reports/components/summary_row_spec.js
@@ -31,11 +31,6 @@ describe('Summary row', () => {
const findStatusIcon = () => wrapper.findByTestId('summary-row-icon');
const findHelpPopover = () => wrapper.findComponent(HelpPopover);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders provided summary', () => {
createComponent();
expect(findSummary().text()).toContain(summary);
diff --git a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js
index edf3d1706cc..4c56dd74f1a 100644
--- a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js
+++ b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js
@@ -1,40 +1,47 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import AdminNewRunnerApp from '~/ci/runner/admin_new_runner/admin_new_runner_app.vue';
-import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue';
+import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage';
import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
-import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
-import { DEFAULT_PLATFORM } from '~/ci/runner/constants';
+import {
+ PARAM_KEY_PLATFORM,
+ INSTANCE_TYPE,
+ DEFAULT_PLATFORM,
+ WINDOWS_PLATFORM,
+} from '~/ci/runner/constants';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { runnerCreateResult } from '../mock_data';
-const mockLegacyRegistrationToken = 'LEGACY_REGISTRATION_TOKEN';
+jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
+jest.mock('~/alert');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ redirectTo: jest.fn(),
+}));
-Vue.use(VueApollo);
+const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner;
describe('AdminNewRunnerApp', () => {
let wrapper;
- const findLegacyInstructionsLink = () => wrapper.findByTestId('legacy-instructions-link');
- const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal);
const findRunnerPlatformsRadioGroup = () => wrapper.findComponent(RunnerPlatformsRadioGroup);
- const findRunnerFormFields = () => wrapper.findComponent(RunnerFormFields);
+ const findRegistrationFeedbackBanner = () => wrapper.findComponent(RegistrationFeedbackBanner);
+ const findRegistrationCompatibilityAlert = () =>
+ wrapper.findComponent(RegistrationCompatibilityAlert);
+ const findRunnerCreateForm = () => wrapper.findComponent(RunnerCreateForm);
- const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => {
- wrapper = mountFn(AdminNewRunnerApp, {
- propsData: {
- legacyRegistrationToken: mockLegacyRegistrationToken,
- ...props,
- },
- directives: {
- GlModal: createMockDirective(),
- },
+ const createComponent = () => {
+ wrapper = shallowMountExtended(AdminNewRunnerApp, {
stubs: {
GlSprintf,
},
- ...options,
});
};
@@ -42,39 +49,71 @@ describe('AdminNewRunnerApp', () => {
createComponent();
});
- describe('Shows legacy modal', () => {
- it('passes legacy registration to modal', () => {
- expect(findRunnerInstructionsModal().props('registrationToken')).toEqual(
- mockLegacyRegistrationToken,
- );
- });
+ it('shows a registration feedback banner', () => {
+ expect(findRegistrationFeedbackBanner().exists()).toBe(true);
+ });
- it('opens a modal with the legacy instructions', () => {
- const modalId = getBinding(findLegacyInstructionsLink().element, 'gl-modal').value;
+ it('shows a registration compatibility alert', () => {
+ expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(INSTANCE_TYPE);
+ });
- expect(findRunnerInstructionsModal().props('modalId')).toBe(modalId);
+ describe('Platform', () => {
+ it('shows the platforms radio group', () => {
+ expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM);
});
});
- describe('New runner form fields', () => {
- describe('Platform', () => {
- it('shows the platforms radio group', () => {
- expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM);
+ describe('Runner form', () => {
+ it('shows the runner create form for an instance runner', () => {
+ expect(findRunnerCreateForm().props()).toEqual({
+ runnerType: INSTANCE_TYPE,
+ groupId: null,
+ projectId: null,
});
});
- describe('Runner', () => {
- it('shows the runners fields', () => {
- expect(findRunnerFormFields().props('value')).toEqual({
- accessLevel: 'NOT_PROTECTED',
- paused: false,
- description: '',
- maintenanceNote: '',
- maximumTimeout: ' ',
- runUntagged: false,
- tagList: '',
+ describe('When a runner is saved', () => {
+ beforeEach(() => {
+ findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner);
+ });
+
+ it('pushes an alert to be shown after redirection', () => {
+ expect(saveAlertToLocalStorage).toHaveBeenCalledWith({
+ message: s__('Runners|Runner created.'),
+ variant: VARIANT_SUCCESS,
});
});
+
+ it('redirects to the registration page', () => {
+ const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`;
+
+ expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ });
+ });
+
+ describe('When another platform is selected and a runner is saved', () => {
+ beforeEach(() => {
+ findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM);
+ findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner);
+ });
+
+ it('redirects to the registration page with the platform', () => {
+ const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`;
+
+ expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ });
+ });
+
+ describe('When runner fails to save', () => {
+ const ERROR_MSG = 'Cannot save!';
+
+ beforeEach(() => {
+ findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG));
+ });
+
+ it('shows an error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG });
+ });
});
});
});
diff --git a/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js b/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js
new file mode 100644
index 00000000000..60244ba5bc2
--- /dev/null
+++ b/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js
@@ -0,0 +1,122 @@
+import { nextTick } from 'vue';
+import { GlButton } from '@gitlab/ui';
+
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+
+import { updateHistory } from '~/lib/utils/url_utility';
+import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM, WINDOWS_PLATFORM } from '~/ci/runner/constants';
+import AdminRegisterRunnerApp from '~/ci/runner/admin_register_runner/admin_register_runner_app.vue';
+import RegistrationInstructions from '~/ci/runner/components/registration/registration_instructions.vue';
+import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawer.vue';
+import { runnerForRegistration } from '../mock_data';
+
+const mockRunnerId = runnerForRegistration.data.runner.id;
+const mockRunnersPath = '/admin/runners';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ updateHistory: jest.fn(),
+}));
+
+describe('AdminRegisterRunnerApp', () => {
+ let wrapper;
+
+ const findRegistrationInstructions = () => wrapper.findComponent(RegistrationInstructions);
+ const findPlatformsDrawer = () => wrapper.findComponent(PlatformsDrawer);
+ const findBtn = () => wrapper.findComponent(GlButton);
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(AdminRegisterRunnerApp, {
+ propsData: {
+ runnerId: mockRunnerId,
+ runnersPath: mockRunnersPath,
+ },
+ });
+ };
+
+ describe('When showing runner details', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('when runner token is available', () => {
+ it('shows registration instructions', () => {
+ expect(findRegistrationInstructions().props()).toEqual({
+ platform: DEFAULT_PLATFORM,
+ runnerId: mockRunnerId,
+ });
+ });
+
+ it('configures platform drawer', () => {
+ expect(findPlatformsDrawer().props()).toEqual({
+ open: false,
+ platform: DEFAULT_PLATFORM,
+ });
+ });
+
+ it('shows runner list button', () => {
+ expect(findBtn().attributes('href')).toBe(mockRunnersPath);
+ expect(findBtn().props('variant')).toBe('confirm');
+ });
+ });
+ });
+
+ describe('When another platform has been selected', () => {
+ beforeEach(() => {
+ setWindowLocation(`?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`);
+
+ createComponent();
+ });
+
+ it('shows registration instructions for the platform', () => {
+ expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM);
+ });
+ });
+
+ describe('When opening install instructions', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ findRegistrationInstructions().vm.$emit('toggleDrawer');
+ await nextTick();
+ });
+
+ it('opens platform drawer', () => {
+ expect(findPlatformsDrawer().props('open')).toBe(true);
+ });
+
+ it('closes platform drawer', async () => {
+ findRegistrationInstructions().vm.$emit('toggleDrawer');
+ await nextTick();
+
+ expect(findPlatformsDrawer().props('open')).toBe(false);
+ });
+
+ it('closes platform drawer from drawer', async () => {
+ findPlatformsDrawer().vm.$emit('close');
+ await nextTick();
+
+ expect(findPlatformsDrawer().props('open')).toBe(false);
+ });
+
+ describe('when selecting a platform', () => {
+ beforeEach(async () => {
+ findPlatformsDrawer().vm.$emit('selectPlatform', WINDOWS_PLATFORM);
+ await nextTick();
+ });
+
+ it('updates the url', () => {
+ expect(updateHistory).toHaveBeenCalledTimes(1);
+ expect(updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`,
+ });
+ });
+
+ it('updates the registration instructions', () => {
+ expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js
index ed4f43c12d8..9787b1ef83f 100644
--- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js
+++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js
@@ -4,8 +4,8 @@ import VueApollo from 'vue-apollo';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/ci/runner/components/runner_header.vue';
@@ -24,7 +24,7 @@ import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_al
import { runnerData } from '../mock_data';
jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility');
@@ -72,7 +72,6 @@ describe('AdminRunnerShowApp', () => {
afterEach(() => {
mockRunnerQuery.mockReset();
- wrapper.destroy();
});
describe('When showing runner details', () => {
@@ -82,7 +81,7 @@ describe('AdminRunnerShowApp', () => {
await createComponent({ mountFn: mountExtended });
});
- it('expect GraphQL ID to be requested', async () => {
+ it('expect GraphQL ID to be requested', () => {
expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId });
});
@@ -90,7 +89,7 @@ describe('AdminRunnerShowApp', () => {
expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`);
});
- it('displays the runner edit and pause buttons', async () => {
+ it('displays the runner edit and pause buttons', () => {
expect(findRunnerEditButton().attributes('href')).toBe(mockRunner.editAdminUrl);
expect(findRunnerPauseButton().exists()).toBe(true);
expect(findRunnerDeleteButton().exists()).toBe(true);
@@ -100,7 +99,7 @@ describe('AdminRunnerShowApp', () => {
expect(findRunnerDetailsTabs().props('runner')).toEqual(mockRunner);
});
- it('shows basic runner details', async () => {
+ it('shows basic runner details', () => {
const expected = `Description My Runner
Last contact Never contacted
Version 1.0.0
@@ -181,7 +180,7 @@ describe('AdminRunnerShowApp', () => {
message: 'Runner deleted',
variant: VARIANT_SUCCESS,
});
- expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath);
+ expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); // eslint-disable-line import/no-deprecated
});
});
diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js
index 7fc240e520b..c3d33c88422 100644
--- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js
@@ -9,7 +9,7 @@ import {
mountExtended,
} from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -57,20 +57,18 @@ import {
allRunnersDataPaginated,
onlineContactTimeoutSecs,
staleTimeoutSecs,
+ mockRegistrationToken,
newRunnerPath,
emptyPageInfo,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
} from '../mock_data';
-const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockRunners = allRunnersData.data.runners.nodes;
const mockRunnersCount = runnersCountData.data.runners.count;
const mockRunnersHandler = jest.fn();
const mockRunnersCountHandler = jest.fn();
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
@@ -122,8 +120,6 @@ describe('AdminRunnersApp', () => {
localMutations,
onlineContactTimeoutSecs,
staleTimeoutSecs,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
...provide,
},
...options,
@@ -143,7 +139,6 @@ describe('AdminRunnersApp', () => {
mockRunnersHandler.mockReset();
mockRunnersCountHandler.mockReset();
showToast.mockReset();
- wrapper.destroy();
});
it('shows the runner setup instructions', () => {
@@ -209,13 +204,13 @@ describe('AdminRunnersApp', () => {
it('runner item links to the runner admin page', async () => {
await createComponent({ mountFn: mountExtended });
- const { id, shortSha } = mockRunners[0];
+ const { id, shortSha, adminUrl } = mockRunners[0];
const numericId = getIdFromGraphQLId(id);
const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink);
expect(runnerLink.text()).toBe(`#${numericId} (${shortSha})`);
- expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`);
+ expect(runnerLink.attributes('href')).toBe(adminUrl);
});
it('renders runner actions for each runner', async () => {
@@ -265,7 +260,7 @@ describe('AdminRunnersApp', () => {
});
describe('Single runner row', () => {
- const { id: graphqlId, shortSha } = mockRunners[0];
+ const { id: graphqlId, shortSha, adminUrl } = mockRunners[0];
const id = getIdFromGraphQLId(graphqlId);
beforeEach(async () => {
@@ -274,11 +269,11 @@ describe('AdminRunnersApp', () => {
await createComponent({ mountFn: mountExtended });
});
- it('Links to the runner page', async () => {
+ it('Links to the runner page', () => {
const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink);
expect(runnerLink.text()).toBe(`#${id} (${shortSha})`);
- expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`);
+ expect(runnerLink.attributes('href')).toBe(adminUrl);
});
it('Shows job status and links to jobs', () => {
@@ -287,13 +282,10 @@ describe('AdminRunnersApp', () => {
.findComponent(RunnerJobStatusBadge);
expect(badge.props('jobStatus')).toBe(mockRunners[0].jobExecutionStatus);
-
- const badgeHref = new URL(badge.attributes('href'));
- expect(badgeHref.pathname).toBe(`/admin/runners/${id}`);
- expect(badgeHref.hash).toBe(`#${JOBS_ROUTE_PATH}`);
+ expect(badge.attributes('href')).toBe(`${adminUrl}#${JOBS_ROUTE_PATH}`);
});
- it('When runner is paused or unpaused, some data is refetched', async () => {
+ it('When runner is paused or unpaused, some data is refetched', () => {
expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES);
findRunnerActionsCell().vm.$emit('toggledPaused');
@@ -302,7 +294,7 @@ describe('AdminRunnersApp', () => {
expect(showToast).toHaveBeenCalledTimes(0);
});
- it('When runner is deleted, data is refetched and a toast message is shown', async () => {
+ it('When runner is deleted, data is refetched and a toast message is shown', () => {
findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' });
expect(showToast).toHaveBeenCalledTimes(1);
@@ -325,7 +317,7 @@ describe('AdminRunnersApp', () => {
{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } },
{ type: PARAM_KEY_PAUSED, value: { data: 'true', operator: '=' } },
],
- sort: 'CREATED_DESC',
+ sort: DEFAULT_SORT,
pagination: {},
});
});
@@ -392,7 +384,7 @@ describe('AdminRunnersApp', () => {
it('when runners have not loaded, shows a loading state', () => {
createComponent();
expect(findRunnerList().props('loading')).toBe(true);
- expect(findRunnerPagination().attributes('disabled')).toBe('true');
+ expect(findRunnerPagination().attributes('disabled')).toBeDefined();
});
describe('Bulk delete', () => {
@@ -411,7 +403,7 @@ describe('AdminRunnersApp', () => {
await createComponent({ mountFn: mountExtended });
});
- it('count data is refetched', async () => {
+ it('count data is refetched', () => {
expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES);
findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' });
@@ -419,7 +411,7 @@ describe('AdminRunnersApp', () => {
expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2);
});
- it('toast is shown', async () => {
+ it('toast is shown', () => {
expect(showToast).toHaveBeenCalledTimes(0);
findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' });
@@ -452,9 +444,7 @@ describe('AdminRunnersApp', () => {
expect(findRunnerListEmptyState().props()).toEqual({
newRunnerPath,
isSearchFiltered: false,
- filteredSvgPath: emptyStateFilteredSvgPath,
registrationToken: mockRegistrationToken,
- svgPath: emptyStateSvgPath,
});
});
@@ -481,11 +471,11 @@ describe('AdminRunnersApp', () => {
await createComponent();
});
- it('error is shown to the user', async () => {
+ it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
- it('error is reported to sentry', async () => {
+ it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error('Error!'),
component: 'AdminRunnersApp',
diff --git a/spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js
index 82e262d1b73..8ac0c5a61f8 100644
--- a/spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js
+++ b/spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js
@@ -31,10 +31,6 @@ describe('RunnerActionsCell', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Edit Action', () => {
it('Displays the runner edit link with the correct href', () => {
createComponent();
diff --git a/spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js
index 3097e43e583..03f1ace3897 100644
--- a/spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js
+++ b/spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js
@@ -16,7 +16,7 @@ describe('RunnerOwnerCell', () => {
const createComponent = ({ runner } = {}) => {
wrapper = shallowMount(RunnerOwnerCell, {
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
propsData: {
runner,
@@ -24,10 +24,6 @@ describe('RunnerOwnerCell', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('When its an instance runner', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js
index 1ff60ff1a9d..c435dd57de2 100644
--- a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js
+++ b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js
@@ -1,4 +1,4 @@
-import { mount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import RunnerStatusCell from '~/ci/runner/components/cells/runner_status_cell.vue';
import RunnerStatusBadge from '~/ci/runner/components/runner_status_badge.vue';
@@ -20,7 +20,7 @@ describe('RunnerStatusCell', () => {
const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge);
const createComponent = ({ runner = {}, ...options } = {}) => {
- wrapper = mount(RunnerStatusCell, {
+ wrapper = shallowMount(RunnerStatusCell, {
propsData: {
runner: {
runnerType: INSTANCE_TYPE,
@@ -30,14 +30,14 @@ describe('RunnerStatusCell', () => {
...runner,
},
},
+ stubs: {
+ RunnerStatusBadge,
+ RunnerPausedBadge,
+ },
...options,
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays online status', () => {
createComponent();
diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js
index 1711df42491..64e9c11a584 100644
--- a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js
+++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js
@@ -1,5 +1,6 @@
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerTags from '~/ci/runner/components/runner_tags.vue';
@@ -11,11 +12,13 @@ import {
I18N_INSTANCE_TYPE,
PROJECT_TYPE,
I18N_NO_DESCRIPTION,
+ I18N_CREATED_AT_LABEL,
+ I18N_CREATED_AT_BY_LABEL,
} from '~/ci/runner/constants';
-import { allRunnersData } from '../../mock_data';
+import { allRunnersWithCreatorData } from '../../mock_data';
-const mockRunner = allRunnersData.data.runners.nodes[0];
+const mockRunner = allRunnersWithCreatorData.data.runners.nodes[0];
describe('RunnerTypeCell', () => {
let wrapper;
@@ -45,10 +48,6 @@ describe('RunnerTypeCell', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays the runner name as id and short token', () => {
expect(wrapper.text()).toContain(
`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`,
@@ -83,14 +82,15 @@ describe('RunnerTypeCell', () => {
it('Displays the runner description', () => {
expect(wrapper.text()).toContain(mockRunner.description);
+ expect(wrapper.findByText(I18N_NO_DESCRIPTION).exists()).toBe(false);
});
- it('Displays the no runner description', () => {
+ it('Displays "No description" for missing runner description', () => {
createComponent({
description: null,
});
- expect(wrapper.text()).toContain(I18N_NO_DESCRIPTION);
+ expect(wrapper.findByText(I18N_NO_DESCRIPTION).classes()).toContain('gl-text-secondary');
});
it('Displays last contact', () => {
@@ -146,10 +146,42 @@ describe('RunnerTypeCell', () => {
expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+');
});
- it('Displays created at', () => {
- expect(findRunnerSummaryField('calendar').findComponent(TimeAgo).props('time')).toBe(
- mockRunner.createdAt,
- );
+ describe('Displays creation info', () => {
+ const findCreatedTime = () => findRunnerSummaryField('calendar').findComponent(TimeAgo);
+
+ it('Displays created at ...', () => {
+ createComponent({
+ createdBy: null,
+ });
+
+ expect(findRunnerSummaryField('calendar').text()).toMatchInterpolatedText(
+ sprintf(I18N_CREATED_AT_LABEL, {
+ timeAgo: findCreatedTime().text(),
+ }),
+ );
+ expect(findCreatedTime().props('time')).toBe(mockRunner.createdAt);
+ });
+
+ it('Displays created at ... by ...', () => {
+ expect(findRunnerSummaryField('calendar').text()).toMatchInterpolatedText(
+ sprintf(I18N_CREATED_AT_BY_LABEL, {
+ timeAgo: findCreatedTime().text(),
+ avatar: mockRunner.createdBy.username,
+ }),
+ );
+ expect(findCreatedTime().props('time')).toBe(mockRunner.createdAt);
+ });
+
+ it('Displays creator avatar', () => {
+ const { name, avatarUrl, webUrl, username } = mockRunner.createdBy;
+
+ expect(wrapper.findComponent(UserAvatarLink).props()).toMatchObject({
+ imgAlt: expect.stringContaining(name),
+ imgSrc: avatarUrl,
+ linkHref: webUrl,
+ tooltipText: username,
+ });
+ });
});
it('Displays tag list', () => {
diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js
index f536e0dcbcf..7748890cf77 100644
--- a/spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js
+++ b/spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js
@@ -17,16 +17,12 @@ describe('RunnerSummaryField', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
...options,
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows content in slot', () => {
createComponent({
slots: { default: 'content' },
diff --git a/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap b/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap
new file mode 100644
index 00000000000..5eb7ffaacd6
--- /dev/null
+++ b/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap
@@ -0,0 +1,201 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`registration utils for "linux" platform commandPrompt is correct 1`] = `"$"`;
+
+exports[`registration utils for "linux" platform installScript is correct for "386" architecture 1`] = `
+"# Download the binary for your system
+sudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-386
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# Create a GitLab Runner user
+sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
+
+# Install and run as a service
+sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
+sudo gitlab-runner start"
+`;
+
+exports[`registration utils for "linux" platform installScript is correct for "amd64" architecture 1`] = `
+"# Download the binary for your system
+sudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# Create a GitLab Runner user
+sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
+
+# Install and run as a service
+sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
+sudo gitlab-runner start"
+`;
+
+exports[`registration utils for "linux" platform installScript is correct for "arm" architecture 1`] = `
+"# Download the binary for your system
+sudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# Create a GitLab Runner user
+sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
+
+# Install and run as a service
+sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
+sudo gitlab-runner start"
+`;
+
+exports[`registration utils for "linux" platform installScript is correct for "arm64" architecture 1`] = `
+"# Download the binary for your system
+sudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm64
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# Create a GitLab Runner user
+sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
+
+# Install and run as a service
+sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
+sudo gitlab-runner start"
+`;
+
+exports[`registration utils for "linux" platform platformArchitectures returns correct list of architectures 1`] = `
+Array [
+ "amd64",
+ "386",
+ "arm",
+ "arm64",
+]
+`;
+
+exports[`registration utils for "linux" platform registerCommand is correct 1`] = `
+Array [
+ "gitlab-runner register",
+ " --url http://test.host",
+ " --token MOCK_AUTHENTICATION_TOKEN",
+]
+`;
+
+exports[`registration utils for "linux" platform registerCommand is correct 2`] = `
+Array [
+ "gitlab-runner register",
+ " --url http://test.host",
+]
+`;
+
+exports[`registration utils for "linux" platform runCommand is correct 1`] = `"gitlab-runner run"`;
+
+exports[`registration utils for "osx" platform commandPrompt is correct 1`] = `"$"`;
+
+exports[`registration utils for "osx" platform installScript is correct for "amd64" architecture 1`] = `
+"# Download the binary for your system
+sudo curl --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# The rest of the commands execute as the user who will run the runner
+# Register the runner (steps below), then run
+cd ~
+gitlab-runner install
+gitlab-runner start"
+`;
+
+exports[`registration utils for "osx" platform installScript is correct for "arm64" architecture 1`] = `
+"# Download the binary for your system
+sudo curl --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-arm64
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# The rest of the commands execute as the user who will run the runner
+# Register the runner (steps below), then run
+cd ~
+gitlab-runner install
+gitlab-runner start"
+`;
+
+exports[`registration utils for "osx" platform platformArchitectures returns correct list of architectures 1`] = `
+Array [
+ "amd64",
+ "arm64",
+]
+`;
+
+exports[`registration utils for "osx" platform registerCommand is correct 1`] = `
+Array [
+ "gitlab-runner register",
+ " --url http://test.host",
+ " --token MOCK_AUTHENTICATION_TOKEN",
+]
+`;
+
+exports[`registration utils for "osx" platform registerCommand is correct 2`] = `
+Array [
+ "gitlab-runner register",
+ " --url http://test.host",
+]
+`;
+
+exports[`registration utils for "osx" platform runCommand is correct 1`] = `"gitlab-runner run"`;
+
+exports[`registration utils for "windows" platform commandPrompt is correct 1`] = `">"`;
+
+exports[`registration utils for "windows" platform installScript is correct for "386" architecture 1`] = `
+"# Run PowerShell: https://docs.microsoft.com/en-us/powershell/scripting/windows-powershell/starting-windows-powershell?view=powershell-7#with-administrative-privileges-run-as-administrator
+# Create a folder somewhere on your system, for example: C:\\\\GitLab-Runner
+New-Item -Path 'C:\\\\GitLab-Runner' -ItemType Directory
+
+# Change to the folder
+cd 'C:\\\\GitLab-Runner'
+
+# Download binary
+Invoke-WebRequest -Uri \\"https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-386.exe\\" -OutFile \\"gitlab-runner.exe\\"
+
+# Register the runner (steps below), then run
+.\\\\gitlab-runner.exe install
+.\\\\gitlab-runner.exe start"
+`;
+
+exports[`registration utils for "windows" platform installScript is correct for "amd64" architecture 1`] = `
+"# Run PowerShell: https://docs.microsoft.com/en-us/powershell/scripting/windows-powershell/starting-windows-powershell?view=powershell-7#with-administrative-privileges-run-as-administrator
+# Create a folder somewhere on your system, for example: C:\\\\GitLab-Runner
+New-Item -Path 'C:\\\\GitLab-Runner' -ItemType Directory
+
+# Change to the folder
+cd 'C:\\\\GitLab-Runner'
+
+# Download binary
+Invoke-WebRequest -Uri \\"https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe\\" -OutFile \\"gitlab-runner.exe\\"
+
+# Register the runner (steps below), then run
+.\\\\gitlab-runner.exe install
+.\\\\gitlab-runner.exe start"
+`;
+
+exports[`registration utils for "windows" platform platformArchitectures returns correct list of architectures 1`] = `
+Array [
+ "amd64",
+ "386",
+]
+`;
+
+exports[`registration utils for "windows" platform registerCommand is correct 1`] = `
+Array [
+ ".\\\\gitlab-runner.exe register",
+ " --url http://test.host",
+ " --token MOCK_AUTHENTICATION_TOKEN",
+]
+`;
+
+exports[`registration utils for "windows" platform registerCommand is correct 2`] = `
+Array [
+ ".\\\\gitlab-runner.exe register",
+ " --url http://test.host",
+]
+`;
+
+exports[`registration utils for "windows" platform runCommand is correct 1`] = `".\\\\gitlab-runner.exe run"`;
diff --git a/spec/frontend/ci/runner/components/registration/cli_command_spec.js b/spec/frontend/ci/runner/components/registration/cli_command_spec.js
new file mode 100644
index 00000000000..78c2b94c3ea
--- /dev/null
+++ b/spec/frontend/ci/runner/components/registration/cli_command_spec.js
@@ -0,0 +1,39 @@
+import CliCommand from '~/ci/runner/components/registration/cli_command.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+describe('CliCommand', () => {
+ let wrapper;
+
+ // use .textContent instead of .text() to capture whitespace that's visible in <pre>
+ const getPreTextContent = () => wrapper.find('pre').element.textContent;
+ const getClipboardText = () => wrapper.findComponent(ClipboardButton).props('text');
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(CliCommand, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ it('when rendering a command', () => {
+ createComponent({
+ prompt: '#',
+ command: 'echo hi',
+ });
+
+ expect(getPreTextContent()).toBe('# echo hi');
+ expect(getClipboardText()).toBe('echo hi');
+ });
+
+ it('when rendering a multi-line command', () => {
+ createComponent({
+ prompt: '#',
+ command: ['git', ' --version'],
+ });
+
+ expect(getPreTextContent()).toBe('# git --version');
+ expect(getClipboardText()).toBe('git --version');
+ });
+});
diff --git a/spec/frontend/ci/runner/components/registration/platforms_drawer_spec.js b/spec/frontend/ci/runner/components/registration/platforms_drawer_spec.js
new file mode 100644
index 00000000000..0b438455b5b
--- /dev/null
+++ b/spec/frontend/ci/runner/components/registration/platforms_drawer_spec.js
@@ -0,0 +1,108 @@
+import { nextTick } from 'vue';
+import { GlDrawer, GlLink, GlIcon, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+
+import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawer.vue';
+import CliCommand from '~/ci/runner/components/registration/cli_command.vue';
+import {
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+ INSTALL_HELP_URL,
+} from '~/ci/runner/constants';
+import { installScript, platformArchitectures } from '~/ci/runner/components/registration/utils';
+
+const MOCK_WRAPPER_HEIGHT = '99px';
+const LINUX_ARCHS = platformArchitectures({ platform: LINUX_PLATFORM });
+const MACOS_ARCHS = platformArchitectures({ platform: MACOS_PLATFORM });
+
+jest.mock('~/lib/utils/dom_utils', () => ({
+ getContentWrapperHeight: () => MOCK_WRAPPER_HEIGHT,
+}));
+
+describe('RegistrationInstructions', () => {
+ let wrapper;
+
+ const findDrawer = () => wrapper.findComponent(GlDrawer);
+ const findEnvironmentOptions = () =>
+ wrapper.findByLabelText(s__('Runners|Environment')).findAll('option');
+ const findArchitectureOptions = () =>
+ wrapper.findByLabelText(s__('Runners|Architecture')).findAll('option');
+ const findCliCommand = () => wrapper.findComponent(CliCommand);
+ const findLink = () => wrapper.findComponent(GlLink);
+
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
+ wrapper = mountFn(PlatformsDrawer, {
+ propsData: {
+ open: true,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ it('shows drawer', () => {
+ createComponent();
+
+ expect(findDrawer().props()).toMatchObject({
+ open: true,
+ headerHeight: MOCK_WRAPPER_HEIGHT,
+ });
+ });
+
+ it('closes drawer', () => {
+ createComponent();
+ findDrawer().vm.$emit('close');
+
+ expect(wrapper.emitted('close')).toHaveLength(1);
+ });
+
+ it('shows selection options', () => {
+ createComponent({ mountFn: mountExtended });
+
+ expect(findEnvironmentOptions().wrappers.map((w) => w.attributes('value'))).toEqual([
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+ ]);
+
+ expect(findArchitectureOptions().wrappers.map((w) => w.attributes('value'))).toEqual(
+ LINUX_ARCHS,
+ );
+ });
+
+ it('shows script', () => {
+ createComponent();
+
+ expect(findCliCommand().props('command')).toBe(
+ installScript({ platform: LINUX_PLATFORM, architecture: LINUX_ARCHS[0] }),
+ );
+ });
+
+ it('shows selection options for another platform', async () => {
+ createComponent({ mountFn: mountExtended });
+
+ findEnvironmentOptions().at(1).setSelected(); // macos
+ await nextTick();
+
+ expect(wrapper.emitted('selectPlatform')).toEqual([[MACOS_PLATFORM]]);
+
+ expect(findArchitectureOptions().wrappers.map((w) => w.attributes('value'))).toEqual(
+ MACOS_ARCHS,
+ );
+
+ expect(findCliCommand().props('command')).toBe(
+ installScript({ platform: MACOS_PLATFORM, architecture: MACOS_ARCHS[0] }),
+ );
+ });
+
+ it('shows external link for more information', () => {
+ createComponent();
+
+ expect(findLink().attributes('href')).toBe(INSTALL_HELP_URL);
+ expect(findLink().findComponent(GlIcon).props('name')).toBe('external-link');
+ });
+});
diff --git a/spec/frontend/ci/runner/components/registration/registration_compatibility_alert_spec.js b/spec/frontend/ci/runner/components/registration/registration_compatibility_alert_spec.js
new file mode 100644
index 00000000000..75658270104
--- /dev/null
+++ b/spec/frontend/ci/runner/components/registration/registration_compatibility_alert_spec.js
@@ -0,0 +1,53 @@
+import { GlAlert, GlLink } from '@gitlab/ui';
+import DismissibleFeedbackAlert from '~/vue_shared/components/dismissible_feedback_alert.vue';
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import { CHANGELOG_URL } from '~/ci/runner/constants';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+
+const ALERT_KEY = 'ALERT_KEY';
+
+describe('RegistrationCompatibilityAlert', () => {
+ let wrapper;
+
+ const findDismissibleFeedbackAlert = () => wrapper.findComponent(DismissibleFeedbackAlert);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLink = () => wrapper.findComponent(GlLink);
+
+ const createComponent = ({ mountFn = shallowMountExtended, ...options } = {}) => {
+ wrapper = mountFn(RegistrationCompatibilityAlert, {
+ propsData: {
+ alertKey: ALERT_KEY,
+ },
+ ...options,
+ });
+ };
+
+ it('configures a featureName', () => {
+ createComponent();
+
+ expect(findDismissibleFeedbackAlert().props('featureName')).toBe(
+ `new_runner_compatibility_${ALERT_KEY}`,
+ );
+ });
+
+ it('alert has warning appearance', () => {
+ createComponent({
+ stubs: {
+ DismissibleFeedbackAlert,
+ },
+ });
+
+ expect(findAlert().props()).toMatchObject({
+ dismissible: true,
+ variant: 'warning',
+ title: expect.any(String),
+ });
+ });
+
+ it('shows alert content and link', () => {
+ createComponent({ mountFn: mountExtended });
+
+ expect(findAlert().text()).not.toBe('');
+ expect(findLink().attributes('href')).toBe(CHANGELOG_URL);
+ });
+});
diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js
index 0daaca9c4ff..e564cf49ca0 100644
--- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js
+++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js
@@ -1,10 +1,10 @@
-import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui';
-import { mount, shallowMount, createWrapper } from '@vue/test-utils';
+import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm, GlIcon } from '@gitlab/ui';
+import { createWrapper } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { s__ } from '~/locale';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -12,7 +12,14 @@ import RegistrationDropdown from '~/ci/runner/components/registration/registrati
import RegistrationToken from '~/ci/runner/components/registration/registration_token.vue';
import RegistrationTokenResetDropdownItem from '~/ci/runner/components/registration/registration_token_reset_dropdown_item.vue';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/ci/runner/constants';
+import {
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ I18N_REGISTER_INSTANCE_TYPE,
+ I18N_REGISTER_GROUP_TYPE,
+ I18N_REGISTER_PROJECT_TYPE,
+} from '~/ci/runner/constants';
import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql';
import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql';
@@ -21,9 +28,7 @@ import {
mockRunnerPlatforms,
mockInstructions,
} from 'jest/vue_shared/components/runner_instructions/mock_data';
-
-const mockToken = '0123456789';
-const maskToken = '**********';
+import { mockRegistrationToken } from '../../mock_data';
Vue.use(VueApollo);
@@ -31,7 +36,7 @@ describe('RegistrationDropdown', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
-
+ const findDropdownBtn = () => findDropdown().find('button');
const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem);
const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm);
const findRegistrationToken = () => wrapper.findComponent(RegistrationToken);
@@ -53,17 +58,15 @@ describe('RegistrationDropdown', () => {
await waitForPromises();
};
- const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => {
- wrapper = extendedWrapper(
- mountFn(RegistrationDropdown, {
- propsData: {
- registrationToken: mockToken,
- type: INSTANCE_TYPE,
- ...props,
- },
- ...options,
- }),
- );
+ const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => {
+ wrapper = mountFn(RegistrationDropdown, {
+ propsData: {
+ registrationToken: mockRegistrationToken,
+ type: INSTANCE_TYPE,
+ ...props,
+ },
+ ...options,
+ });
};
const createComponentWithModal = () => {
@@ -79,27 +82,40 @@ describe('RegistrationDropdown', () => {
// Use `attachTo` to find the modal
attachTo: document.body,
},
- mount,
+ mountExtended,
);
};
it.each`
type | text
- ${INSTANCE_TYPE} | ${s__('Runners|Register an instance runner')}
- ${GROUP_TYPE} | ${s__('Runners|Register a group runner')}
- ${PROJECT_TYPE} | ${s__('Runners|Register a project runner')}
- `('Dropdown text for type $type is "$text"', () => {
- createComponent({ props: { type: INSTANCE_TYPE } }, mount);
+ ${INSTANCE_TYPE} | ${I18N_REGISTER_INSTANCE_TYPE}
+ ${GROUP_TYPE} | ${I18N_REGISTER_GROUP_TYPE}
+ ${PROJECT_TYPE} | ${I18N_REGISTER_PROJECT_TYPE}
+ `('Dropdown text for type $type is "$text"', ({ type, text }) => {
+ createComponent({ props: { type } }, mountExtended);
- expect(wrapper.text()).toContain('Register an instance runner');
+ expect(wrapper.text()).toContain(text);
});
- it('Passes attributes to the dropdown component', () => {
+ it('Passes attributes to dropdown', () => {
createComponent({ attrs: { right: true } });
expect(findDropdown().attributes()).toMatchObject({ right: 'true' });
});
+ it('Passes default props and attributes to dropdown', () => {
+ createComponent();
+
+ expect(findDropdown().props()).toMatchObject({
+ category: 'primary',
+ variant: 'confirm',
+ });
+
+ expect(findDropdown().attributes()).toMatchObject({
+ toggleclass: '',
+ });
+ });
+
describe('Instructions dropdown item', () => {
it('Displays "Show runner" dropdown item', () => {
createComponent();
@@ -111,15 +127,11 @@ describe('RegistrationDropdown', () => {
describe('When the dropdown item is clicked', () => {
beforeEach(async () => {
- createComponentWithModal({}, mount);
+ createComponentWithModal({}, mountExtended);
await openModal();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('opens the modal with contents', () => {
const modalText = findModalContent();
@@ -146,7 +158,15 @@ describe('RegistrationDropdown', () => {
});
it('Displays masked value by default', () => {
- createComponent({}, mount);
+ const mockToken = '0123456789';
+ const maskToken = '**********';
+
+ createComponent(
+ {
+ props: { registrationToken: mockToken },
+ },
+ mountExtended,
+ );
expect(findRegistrationTokenInput().element.value).toBe(maskToken);
});
@@ -175,7 +195,7 @@ describe('RegistrationDropdown', () => {
};
it('Updates token input', async () => {
- createComponent({}, mount);
+ createComponent({}, mountExtended);
expect(findRegistrationToken().props('value')).not.toBe(newToken);
@@ -185,15 +205,72 @@ describe('RegistrationDropdown', () => {
});
it('Updates token in modal', async () => {
- createComponentWithModal({}, mount);
+ createComponentWithModal({}, mountExtended);
await openModal();
- expect(findModalContent()).toContain(mockToken);
+ expect(findModalContent()).toContain(mockRegistrationToken);
await resetToken();
expect(findModalContent()).toContain(newToken);
});
});
+
+ describe.each([
+ { createRunnerWorkflowForAdmin: true },
+ { createRunnerWorkflowForNamespace: true },
+ ])('When showing a "deprecated" warning', (glFeatures) => {
+ it('passes deprecated variant props and attributes to dropdown', () => {
+ createComponent({
+ provide: { glFeatures },
+ });
+
+ expect(findDropdown().props()).toMatchObject({
+ category: 'tertiary',
+ variant: 'default',
+ text: '',
+ });
+
+ expect(findDropdown().attributes()).toMatchObject({
+ toggleclass: 'gl-px-3!',
+ });
+ });
+
+ it.each`
+ type | text
+ ${INSTANCE_TYPE} | ${I18N_REGISTER_INSTANCE_TYPE}
+ ${GROUP_TYPE} | ${I18N_REGISTER_GROUP_TYPE}
+ ${PROJECT_TYPE} | ${I18N_REGISTER_PROJECT_TYPE}
+ `('dropdown text for type $type is "$text"', ({ type, text }) => {
+ createComponent({ props: { type } }, mountExtended);
+
+ expect(wrapper.text()).toContain(text);
+ });
+
+ it('shows warning text', () => {
+ createComponent(
+ {
+ provide: { glFeatures },
+ },
+ mountExtended,
+ );
+
+ const text = wrapper.findByText(s__('Runners|Support for registration tokens is deprecated'));
+
+ expect(text.exists()).toBe(true);
+ });
+
+ it('button shows ellipsis icon', () => {
+ createComponent(
+ {
+ provide: { glFeatures },
+ },
+ mountExtended,
+ );
+
+ expect(findDropdownBtn().findComponent(GlIcon).props('name')).toBe('ellipsis_v');
+ expect(findDropdownBtn().findAllComponents(GlIcon)).toHaveLength(1);
+ });
+ });
});
diff --git a/spec/frontend/ci/runner/components/registration/registration_feedback_banner_spec.js b/spec/frontend/ci/runner/components/registration/registration_feedback_banner_spec.js
new file mode 100644
index 00000000000..fa6b7ad7c63
--- /dev/null
+++ b/spec/frontend/ci/runner/components/registration/registration_feedback_banner_spec.js
@@ -0,0 +1,52 @@
+import { GlBanner } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
+
+describe('Runner registration feeback banner', () => {
+ let wrapper;
+ let userCalloutDismissSpy;
+
+ const findUserCalloutDismisser = () => wrapper.findComponent(UserCalloutDismisser);
+ const findBanner = () => wrapper.findComponent(GlBanner);
+
+ const createComponent = ({ shouldShowCallout = true } = {}) => {
+ userCalloutDismissSpy = jest.fn();
+
+ wrapper = shallowMount(RegistrationFeedbackBanner, {
+ stubs: {
+ UserCalloutDismisser: makeMockUserCalloutDismisser({
+ dismiss: userCalloutDismissSpy,
+ shouldShowCallout,
+ }),
+ },
+ });
+ };
+
+ it('banner is shown', () => {
+ createComponent();
+
+ expect(findBanner().exists()).toBe(true);
+ });
+
+ it('dismisses the callout when closed', () => {
+ createComponent();
+
+ findBanner().vm.$emit('close');
+
+ expect(userCalloutDismissSpy).toHaveBeenCalled();
+ });
+
+ it('sets feature name to create_runner_workflow_banner', () => {
+ createComponent();
+
+ expect(findUserCalloutDismisser().props('featureName')).toBe('create_runner_workflow_banner');
+ });
+
+ it('is not displayed once it has been dismissed', () => {
+ createComponent({ shouldShowCallout: false });
+
+ expect(findBanner().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js b/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js
new file mode 100644
index 00000000000..8c196d7b5e3
--- /dev/null
+++ b/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js
@@ -0,0 +1,326 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+
+import { s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { extendedWrapper, shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import RegistrationInstructions from '~/ci/runner/components/registration/registration_instructions.vue';
+import runnerForRegistrationQuery from '~/ci/runner/graphql/register/runner_for_registration.query.graphql';
+import CliCommand from '~/ci/runner/components/registration/cli_command.vue';
+import {
+ DEFAULT_PLATFORM,
+ EXECUTORS_HELP_URL,
+ SERVICE_COMMANDS_HELP_URL,
+ STATUS_NEVER_CONTACTED,
+ STATUS_ONLINE,
+ RUNNER_REGISTRATION_POLLING_INTERVAL_MS,
+ I18N_REGISTRATION_SUCCESS,
+} from '~/ci/runner/constants';
+import { runnerForRegistration, mockAuthenticationToken } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+const mockRunner = {
+ ...runnerForRegistration.data.runner,
+ ephemeralAuthenticationToken: mockAuthenticationToken,
+};
+const mockRunnerWithoutToken = {
+ ...runnerForRegistration.data.runner,
+ ephemeralAuthenticationToken: null,
+};
+
+const mockRunnerId = `${getIdFromGraphQLId(mockRunner.id)}`;
+
+describe('RegistrationInstructions', () => {
+ let wrapper;
+ let mockRunnerQuery;
+
+ const findHeading = () => wrapper.find('h1');
+ const findStepAt = (i) => extendedWrapper(wrapper.findAll('section').at(i));
+ const findByText = (text, container = wrapper) => container.findByText(text);
+
+ const waitForPolling = async () => {
+ jest.advanceTimersByTime(RUNNER_REGISTRATION_POLLING_INTERVAL_MS);
+ await waitForPromises();
+ };
+
+ const mockBeforeunload = () => {
+ const event = new Event('beforeunload');
+ const preventDefault = jest.spyOn(event, 'preventDefault');
+ const returnValueSetter = jest.spyOn(event, 'returnValue', 'set');
+
+ return {
+ event,
+ preventDefault,
+ returnValueSetter,
+ };
+ };
+
+ const mockResolvedRunner = (runner = mockRunner) => {
+ mockRunnerQuery.mockResolvedValue({
+ data: {
+ runner,
+ },
+ });
+ };
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(RegistrationInstructions, {
+ apolloProvider: createMockApollo([[runnerForRegistrationQuery, mockRunnerQuery]]),
+ propsData: {
+ runnerId: mockRunnerId,
+ platform: DEFAULT_PLATFORM,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mockRunnerQuery = jest.fn();
+ mockResolvedRunner();
+ });
+
+ beforeEach(() => {
+ window.gon.gitlab_url = TEST_HOST;
+ });
+
+ it('loads runner with id', () => {
+ createComponent();
+
+ expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunner.id });
+ });
+
+ describe('heading', () => {
+ it('when runner is loaded, shows heading', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(findHeading().text()).toContain(mockRunner.description);
+ });
+
+ it('when runner is loaded, shows heading safely', async () => {
+ mockResolvedRunner({
+ ...mockRunner,
+ description: '<script>hacked();</script>',
+ });
+
+ createComponent();
+ await waitForPromises();
+
+ expect(findHeading().text()).toBe('Register "<script>hacked();</script>" runner');
+ expect(findHeading().element.innerHTML).toBe(
+ 'Register "&lt;script&gt;hacked();&lt;/script&gt;" runner',
+ );
+ });
+
+ it('when runner is loading, shows default heading', () => {
+ createComponent();
+
+ expect(findHeading().text()).toBe(s__('Runners|Register runner'));
+ });
+ });
+
+ it('renders legacy instructions', () => {
+ createComponent();
+
+ findByText('How do I install GitLab Runner?').vm.$emit('click');
+
+ expect(wrapper.emitted('toggleDrawer')).toHaveLength(1);
+ });
+
+ describe('step 1', () => {
+ it('renders step 1', async () => {
+ createComponent();
+ await waitForPromises();
+
+ const step1 = findStepAt(0);
+
+ expect(step1.findComponent(CliCommand).props()).toEqual({
+ command: [
+ 'gitlab-runner register',
+ ` --url ${TEST_HOST}`,
+ ` --token ${mockAuthenticationToken}`,
+ ],
+ prompt: '$',
+ });
+ expect(step1.findByTestId('runner-token').text()).toBe(mockAuthenticationToken);
+ expect(step1.findComponent(ClipboardButton).props('text')).toBe(mockAuthenticationToken);
+ });
+
+ it('renders step 1 in loading state', () => {
+ createComponent();
+
+ const step1 = findStepAt(0);
+
+ expect(step1.findComponent(GlSkeletonLoader).exists()).toBe(true);
+ expect(step1.find('code').exists()).toBe(false);
+ expect(step1.findComponent(ClipboardButton).exists()).toBe(false);
+ });
+
+ it('render step 1 after token is not visible', async () => {
+ mockResolvedRunner(mockRunnerWithoutToken);
+
+ createComponent();
+ await waitForPromises();
+
+ const step1 = findStepAt(0);
+
+ expect(step1.findComponent(CliCommand).props('command')).toEqual([
+ 'gitlab-runner register',
+ ` --url ${TEST_HOST}`,
+ ]);
+ expect(step1.findByTestId('runner-token').exists()).toBe(false);
+ expect(step1.findComponent(ClipboardButton).exists()).toBe(false);
+ });
+
+ describe('polling for changes', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('fetches data', () => {
+ expect(mockRunnerQuery).toHaveBeenCalledTimes(1);
+ });
+
+ it('polls', async () => {
+ await waitForPolling();
+ expect(mockRunnerQuery).toHaveBeenCalledTimes(2);
+
+ await waitForPolling();
+ expect(mockRunnerQuery).toHaveBeenCalledTimes(3);
+ });
+
+ it('when runner is online, stops polling', async () => {
+ mockResolvedRunner({ ...mockRunner, status: STATUS_ONLINE });
+ await waitForPolling();
+
+ expect(mockRunnerQuery).toHaveBeenCalledTimes(2);
+ await waitForPolling();
+
+ expect(mockRunnerQuery).toHaveBeenCalledTimes(2);
+ });
+
+ it('when token is no longer visible in the API, it is still visible in the UI', async () => {
+ mockResolvedRunner(mockRunnerWithoutToken);
+ await waitForPolling();
+
+ const step1 = findStepAt(0);
+ expect(step1.findComponent(CliCommand).props('command')).toEqual([
+ 'gitlab-runner register',
+ ` --url ${TEST_HOST}`,
+ ` --token ${mockAuthenticationToken}`,
+ ]);
+ expect(step1.findByTestId('runner-token').text()).toBe(mockAuthenticationToken);
+ expect(step1.findComponent(ClipboardButton).props('text')).toBe(mockAuthenticationToken);
+ });
+
+ it('when runner is not available (e.g. deleted), the UI does not update', async () => {
+ mockResolvedRunner(null);
+ await waitForPolling();
+
+ const step1 = findStepAt(0);
+ expect(step1.findComponent(CliCommand).props('command')).toEqual([
+ 'gitlab-runner register',
+ ` --url ${TEST_HOST}`,
+ ` --token ${mockAuthenticationToken}`,
+ ]);
+ expect(step1.findByTestId('runner-token').text()).toBe(mockAuthenticationToken);
+ expect(step1.findComponent(ClipboardButton).props('text')).toBe(mockAuthenticationToken);
+ });
+ });
+ });
+
+ it('renders step 2', () => {
+ createComponent();
+ const step2 = findStepAt(1);
+
+ expect(findByText('Not sure which one to select?', step2).attributes('href')).toBe(
+ EXECUTORS_HELP_URL,
+ );
+ });
+
+ it('renders step 3', () => {
+ createComponent();
+ const step3 = findStepAt(2);
+
+ expect(step3.findComponent(CliCommand).props()).toEqual({
+ command: 'gitlab-runner run',
+ prompt: '$',
+ });
+
+ expect(findByText('system or user service', step3).attributes('href')).toBe(
+ SERVICE_COMMANDS_HELP_URL,
+ );
+ });
+
+ describe('success state', () => {
+ describe('when the runner has not been registered', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await waitForPolling();
+
+ mockResolvedRunner({ ...mockRunner, status: STATUS_NEVER_CONTACTED });
+
+ await waitForPolling();
+ });
+
+ it('does not show success message', () => {
+ expect(wrapper.text()).not.toContain(I18N_REGISTRATION_SUCCESS);
+ });
+
+ describe('when the page is closing', () => {
+ it('warns the user against closing', () => {
+ const { event, preventDefault, returnValueSetter } = mockBeforeunload();
+
+ expect(preventDefault).not.toHaveBeenCalled();
+ expect(returnValueSetter).not.toHaveBeenCalled();
+
+ window.dispatchEvent(event);
+
+ expect(preventDefault).toHaveBeenCalledWith();
+ expect(returnValueSetter).toHaveBeenCalledWith(expect.any(String));
+ });
+ });
+ });
+
+ describe('when the runner has been registered', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPolling();
+
+ mockResolvedRunner({ ...mockRunner, status: STATUS_ONLINE });
+ await waitForPolling();
+ });
+
+ it('shows success message', () => {
+ expect(wrapper.text()).toContain('🎉');
+ expect(wrapper.text()).toContain(I18N_REGISTRATION_SUCCESS);
+ });
+
+ describe('when the page is closing', () => {
+ it('does not warn the user against closing', () => {
+ const { event, preventDefault, returnValueSetter } = mockBeforeunload();
+
+ expect(preventDefault).not.toHaveBeenCalled();
+ expect(returnValueSetter).not.toHaveBeenCalled();
+
+ window.dispatchEvent(event);
+
+ expect(preventDefault).not.toHaveBeenCalled();
+ expect(returnValueSetter).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js
index 783a4d9252a..bfdde922e17 100644
--- a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js
+++ b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js
@@ -5,20 +5,20 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import RegistrationTokenResetDropdownItem from '~/ci/runner/components/registration/registration_token_reset_dropdown_item.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/ci/runner/constants';
import runnersRegistrationTokenResetMutation from '~/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/ci/runner/sentry_utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
Vue.use(VueApollo);
Vue.use(GlToast);
-const mockNewToken = 'NEW_TOKEN';
+const mockNewRegistrationToken = 'MOCK_NEW_REGISTRATION_TOKEN';
const modalID = 'token-reset-modal';
describe('RegistrationTokenResetDropdownItem', () => {
@@ -43,7 +43,7 @@ describe('RegistrationTokenResetDropdownItem', () => {
[runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler],
]),
directives: {
- GlModal: createMockDirective(),
+ GlModal: createMockDirective('gl-modal'),
},
});
@@ -54,7 +54,7 @@ describe('RegistrationTokenResetDropdownItem', () => {
runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({
data: {
runnersRegistrationTokenReset: {
- token: mockNewToken,
+ token: mockNewRegistrationToken,
errors: [],
},
},
@@ -63,10 +63,6 @@ describe('RegistrationTokenResetDropdownItem', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays reset button', () => {
expect(findDropdownItem().exists()).toBe(true);
});
@@ -113,7 +109,7 @@ describe('RegistrationTokenResetDropdownItem', () => {
it('emits result', () => {
expect(wrapper.emitted('tokenReset')).toHaveLength(1);
- expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]);
+ expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewRegistrationToken]);
});
it('does not show a loading state', () => {
diff --git a/spec/frontend/ci/runner/components/registration/registration_token_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_spec.js
index d2a51c0d910..869c032c0b5 100644
--- a/spec/frontend/ci/runner/components/registration/registration_token_spec.js
+++ b/spec/frontend/ci/runner/components/registration/registration_token_spec.js
@@ -3,9 +3,7 @@ import Vue from 'vue';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RegistrationToken from '~/ci/runner/components/registration/registration_token.vue';
import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
-
-const mockToken = '01234567890';
-const mockMasked = '***********';
+import { mockRegistrationToken } from '../../mock_data';
describe('RegistrationToken', () => {
let wrapper;
@@ -15,26 +13,23 @@ describe('RegistrationToken', () => {
const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility);
- const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => {
wrapper = mountFn(RegistrationToken, {
propsData: {
- value: mockToken,
+ value: mockRegistrationToken,
inputId: 'token-value',
...props,
},
+ ...options,
});
showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null;
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays value and copy button', () => {
createComponent();
- expect(findInputCopyToggleVisibility().props('value')).toBe(mockToken);
+ expect(findInputCopyToggleVisibility().props('value')).toBe(mockRegistrationToken);
expect(findInputCopyToggleVisibility().props('copyButtonTitle')).toBe(
'Copy registration token',
);
@@ -42,9 +37,17 @@ describe('RegistrationToken', () => {
// Component integration test to ensure secure masking
it('Displays masked value by default', () => {
- createComponent({ mountFn: mountExtended });
+ const mockToken = '0123456789';
+ const maskToken = '**********';
+
+ createComponent({
+ props: {
+ value: mockToken,
+ },
+ mountFn: mountExtended,
+ });
- expect(wrapper.find('input').element.value).toBe(mockMasked);
+ expect(wrapper.find('input').element.value).toBe(maskToken);
});
describe('When the copy to clipboard button is clicked', () => {
@@ -59,4 +62,23 @@ describe('RegistrationToken', () => {
expect(showToast).toHaveBeenCalledWith('Registration token copied!');
});
});
+
+ describe('When slots are used', () => {
+ const slotName = 'label-description';
+ const slotContent = 'Label Description';
+
+ beforeEach(() => {
+ createComponent({
+ slots: {
+ [slotName]: slotContent,
+ },
+ });
+ });
+
+ it('passes slots to the input component', () => {
+ const slot = findInputCopyToggleVisibility().vm.$scopedSlots[slotName];
+
+ expect(slot()[0].text).toBe(slotContent);
+ });
+ });
});
diff --git a/spec/frontend/ci/runner/components/registration/utils_spec.js b/spec/frontend/ci/runner/components/registration/utils_spec.js
new file mode 100644
index 00000000000..997cc5769ee
--- /dev/null
+++ b/spec/frontend/ci/runner/components/registration/utils_spec.js
@@ -0,0 +1,94 @@
+import { TEST_HOST } from 'helpers/test_constants';
+import {
+ DEFAULT_PLATFORM,
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+} from '~/ci/runner/constants';
+
+import {
+ commandPrompt,
+ registerCommand,
+ runCommand,
+ installScript,
+ platformArchitectures,
+} from '~/ci/runner/components/registration/utils';
+
+import { mockAuthenticationToken } from '../../mock_data';
+
+describe('registration utils', () => {
+ beforeEach(() => {
+ window.gon.gitlab_url = TEST_HOST;
+ });
+
+ describe.each([LINUX_PLATFORM, MACOS_PLATFORM, WINDOWS_PLATFORM])(
+ 'for "%s" platform',
+ (platform) => {
+ it('commandPrompt is correct', () => {
+ expect(commandPrompt({ platform })).toMatchSnapshot();
+ });
+
+ it('registerCommand is correct', () => {
+ expect(
+ registerCommand({
+ platform,
+ token: mockAuthenticationToken,
+ }),
+ ).toMatchSnapshot();
+
+ expect(registerCommand({ platform })).toMatchSnapshot();
+ });
+
+ it('runCommand is correct', () => {
+ expect(runCommand({ platform })).toMatchSnapshot();
+ });
+ },
+ );
+
+ describe('for missing platform', () => {
+ it('commandPrompt uses the default', () => {
+ const expected = commandPrompt({ platform: DEFAULT_PLATFORM });
+
+ expect(commandPrompt({ platform: null })).toEqual(expected);
+ expect(commandPrompt({ platform: undefined })).toEqual(expected);
+ });
+
+ it('registerCommand uses the default', () => {
+ const expected = registerCommand({
+ platform: DEFAULT_PLATFORM,
+ token: mockAuthenticationToken,
+ });
+
+ expect(registerCommand({ platform: null, token: mockAuthenticationToken })).toEqual(expected);
+ expect(registerCommand({ platform: undefined, token: mockAuthenticationToken })).toEqual(
+ expected,
+ );
+ });
+
+ it('runCommand uses the default', () => {
+ const expected = runCommand({ platform: DEFAULT_PLATFORM });
+
+ expect(runCommand({ platform: null })).toEqual(expected);
+ expect(runCommand({ platform: undefined })).toEqual(expected);
+ });
+ });
+
+ describe.each([LINUX_PLATFORM, MACOS_PLATFORM, WINDOWS_PLATFORM])(
+ 'for "%s" platform',
+ (platform) => {
+ describe('platformArchitectures', () => {
+ it('returns correct list of architectures', () => {
+ expect(platformArchitectures({ platform })).toMatchSnapshot();
+ });
+ });
+
+ describe('installScript', () => {
+ const architectures = platformArchitectures({ platform });
+
+ it.each(architectures)('is correct for "%s" architecture', (architecture) => {
+ expect(installScript({ platform, architecture })).toMatchSnapshot();
+ });
+ });
+ },
+ );
+});
diff --git a/spec/frontend/ci/runner/components/runner_assigned_item_spec.js b/spec/frontend/ci/runner/components/runner_assigned_item_spec.js
index 5df2e04c340..a1fd9e4c1aa 100644
--- a/spec/frontend/ci/runner/components/runner_assigned_item_spec.js
+++ b/spec/frontend/ci/runner/components/runner_assigned_item_spec.js
@@ -33,10 +33,6 @@ describe('RunnerAssignedItem', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Shows an avatar', () => {
const avatar = findAvatar();
diff --git a/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js
index 0dc5a90fb83..7bd4b701002 100644
--- a/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js
+++ b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js
@@ -2,12 +2,13 @@ import Vue from 'vue';
import { makeVar } from '@apollo/client/core';
import { GlModal, GlSprintf } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { s__ } from '~/locale';
import RunnerBulkDelete from '~/ci/runner/components/runner_bulk_delete.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { stubComponent } from 'helpers/stub_component';
import BulkRunnerDeleteMutation from '~/ci/runner/graphql/list/bulk_runner_delete.mutation.graphql';
import { createLocalState } from '~/ci/runner/graphql/list/local_state';
import waitForPromises from 'helpers/wait_for_promises';
@@ -15,7 +16,7 @@ import { allRunnersData } from '../mock_data';
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('RunnerBulkDelete', () => {
let wrapper;
@@ -34,7 +35,7 @@ describe('RunnerBulkDelete', () => {
const bulkRunnerDeleteHandler = jest.fn();
- const createComponent = () => {
+ const createComponent = ({ stubs } = {}) => {
const { cacheConfig, localMutations } = mockState;
const apolloProvider = createMockApollo(
[[BulkRunnerDeleteMutation, bulkRunnerDeleteHandler]],
@@ -51,11 +52,12 @@ describe('RunnerBulkDelete', () => {
runners: mockRunners,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
stubs: {
GlSprintf,
GlModal,
+ ...stubs,
},
});
@@ -135,11 +137,15 @@ describe('RunnerBulkDelete', () => {
beforeEach(() => {
mockCheckedRunnerIds([mockId1, mockId2]);
+ mockHideModal = jest.fn();
- createComponent();
+ createComponent({
+ stubs: {
+ GlModal: stubComponent(GlModal, { methods: { hide: mockHideModal } }),
+ },
+ });
jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {});
- mockHideModal = jest.spyOn(findModal().vm, 'hide').mockImplementation(() => {});
});
describe('when deletion is confirmed', () => {
diff --git a/spec/frontend/ci/runner/components/runner_create_form_spec.js b/spec/frontend/ci/runner/components/runner_create_form_spec.js
new file mode 100644
index 00000000000..329dd2f73ee
--- /dev/null
+++ b/spec/frontend/ci/runner/components/runner_create_form_spec.js
@@ -0,0 +1,189 @@
+import Vue from 'vue';
+import { GlForm } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
+import {
+ DEFAULT_ACCESS_LEVEL,
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+} from '~/ci/runner/constants';
+import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql';
+import { captureException } from '~/ci/runner/sentry_utils';
+import { runnerCreateResult } from '../mock_data';
+
+jest.mock('~/ci/runner/sentry_utils');
+
+const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner;
+
+const defaultRunnerModel = {
+ description: '',
+ accessLevel: DEFAULT_ACCESS_LEVEL,
+ paused: false,
+ maintenanceNote: '',
+ maximumTimeout: '',
+ runUntagged: false,
+ tagList: '',
+};
+
+Vue.use(VueApollo);
+
+describe('RunnerCreateForm', () => {
+ let wrapper;
+ let runnerCreateHandler;
+
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findRunnerFormFields = () => wrapper.findComponent(RunnerFormFields);
+ const findSubmitBtn = () => wrapper.find('[type="submit"]');
+
+ const createComponent = ({ props } = {}) => {
+ wrapper = shallowMountExtended(RunnerCreateForm, {
+ propsData: {
+ runnerType: INSTANCE_TYPE,
+ ...props,
+ },
+ apolloProvider: createMockApollo([[runnerCreateMutation, runnerCreateHandler]]),
+ });
+ };
+
+ beforeEach(() => {
+ runnerCreateHandler = jest.fn().mockResolvedValue(runnerCreateResult);
+ });
+
+ it('shows default runner values', () => {
+ createComponent();
+
+ expect(findRunnerFormFields().props('value')).toEqual(defaultRunnerModel);
+ });
+
+ it('shows a submit button', () => {
+ createComponent();
+
+ expect(findSubmitBtn().exists()).toBe(true);
+ });
+
+ describe.each`
+ typeName | props | scopeData
+ ${'an instance runner'} | ${{ runnerType: INSTANCE_TYPE }} | ${{ runnerType: INSTANCE_TYPE }}
+ ${'a group runner'} | ${{ runnerType: GROUP_TYPE, groupId: 'gid://gitlab/Group/72' }} | ${{ runnerType: GROUP_TYPE, groupId: 'gid://gitlab/Group/72' }}
+ ${'a project runner'} | ${{ runnerType: PROJECT_TYPE, projectId: 'gid://gitlab/Project/42' }} | ${{ runnerType: PROJECT_TYPE, projectId: 'gid://gitlab/Project/42' }}
+ `('when user submits $typeName', ({ props, scopeData }) => {
+ let preventDefault;
+
+ beforeEach(() => {
+ createComponent({ props });
+
+ preventDefault = jest.fn();
+
+ findRunnerFormFields().vm.$emit('input', {
+ ...defaultRunnerModel,
+ description: 'My runner',
+ maximumTimeout: 0,
+ tagList: 'tag1, tag2',
+ });
+ });
+
+ describe('immediately after submit', () => {
+ beforeEach(() => {
+ findForm().vm.$emit('submit', { preventDefault });
+ });
+
+ it('prevents default form submission', () => {
+ expect(preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows a saving state', () => {
+ expect(findSubmitBtn().props('loading')).toBe(true);
+ });
+
+ it('saves runner', () => {
+ expect(runnerCreateHandler).toHaveBeenCalledWith({
+ input: {
+ ...defaultRunnerModel,
+ ...scopeData,
+ description: 'My runner',
+ maximumTimeout: 0,
+ tagList: ['tag1', 'tag2'],
+ },
+ });
+ });
+ });
+
+ describe('when saved successfully', () => {
+ beforeEach(async () => {
+ findForm().vm.$emit('submit', { preventDefault });
+ await waitForPromises();
+ });
+
+ it('emits "saved" result', () => {
+ expect(wrapper.emitted('saved')[0]).toEqual([mockCreatedRunner]);
+ });
+
+ it('does not show a saving state', () => {
+ expect(findSubmitBtn().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when a server error occurs', () => {
+ const error = new Error('Error!');
+
+ beforeEach(async () => {
+ runnerCreateHandler.mockRejectedValue(error);
+
+ findForm().vm.$emit('submit', { preventDefault });
+ await waitForPromises();
+ });
+
+ it('emits "error" result', () => {
+ expect(wrapper.emitted('error')[0]).toEqual([error]);
+ });
+
+ it('does not show a saving state', () => {
+ expect(findSubmitBtn().props('loading')).toBe(false);
+ });
+
+ it('reports error', () => {
+ expect(captureException).toHaveBeenCalledTimes(1);
+ expect(captureException).toHaveBeenCalledWith({
+ component: 'RunnerCreateForm',
+ error,
+ });
+ });
+ });
+
+ describe('when a validation error occurs', () => {
+ const errorMsg1 = 'Issue1!';
+ const errorMsg2 = 'Issue2!';
+
+ beforeEach(async () => {
+ runnerCreateHandler.mockResolvedValue({
+ data: {
+ runnerCreate: {
+ errors: [errorMsg1, errorMsg2],
+ runner: null,
+ },
+ },
+ });
+
+ findForm().vm.$emit('submit', { preventDefault });
+ await waitForPromises();
+ });
+
+ it('emits "error" results', () => {
+ expect(wrapper.emitted('error')[0]).toEqual([new Error(`${errorMsg1} ${errorMsg2}`)]);
+ });
+
+ it('does not show a saving state', () => {
+ expect(findSubmitBtn().props('loading')).toBe(false);
+ });
+
+ it('does not report error', () => {
+ expect(captureException).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/runner/components/runner_delete_button_spec.js b/spec/frontend/ci/runner/components/runner_delete_button_spec.js
index 02960ad427e..3123f2894fb 100644
--- a/spec/frontend/ci/runner/components/runner_delete_button_spec.js
+++ b/spec/frontend/ci/runner/components/runner_delete_button_spec.js
@@ -8,7 +8,7 @@ import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutat
import waitForPromises from 'helpers/wait_for_promises';
import { captureException } from '~/ci/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { I18N_DELETE_RUNNER } from '~/ci/runner/constants';
import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue';
@@ -21,7 +21,7 @@ const mockRunnerName = `#${mockRunnerId} (${mockRunner.shortSha})`;
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
describe('RunnerDeleteButton', () => {
@@ -53,8 +53,8 @@ describe('RunnerDeleteButton', () => {
},
apolloProvider,
directives: {
- GlTooltip: createMockDirective(),
- GlModal: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
+ GlModal: createMockDirective('gl-modal'),
},
});
};
@@ -83,10 +83,6 @@ describe('RunnerDeleteButton', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays a delete button without an icon', () => {
expect(findBtn().props()).toMatchObject({
loading: false,
@@ -128,15 +124,15 @@ describe('RunnerDeleteButton', () => {
});
describe('Immediately after the delete button is clicked', () => {
- beforeEach(async () => {
+ beforeEach(() => {
findModal().vm.$emit('primary');
});
- it('The button has a loading state', async () => {
+ it('The button has a loading state', () => {
expect(findBtn().props('loading')).toBe(true);
});
- it('The stale tooltip is removed', async () => {
+ it('The stale tooltip is removed', () => {
expect(getTooltip()).toBe('');
});
});
@@ -259,15 +255,15 @@ describe('RunnerDeleteButton', () => {
});
describe('Immediately after the button is clicked', () => {
- beforeEach(async () => {
+ beforeEach(() => {
findModal().vm.$emit('primary');
});
- it('The button has a loading state', async () => {
+ it('The button has a loading state', () => {
expect(findBtn().props('loading')).toBe(true);
});
- it('The stale tooltip is removed', async () => {
+ it('The stale tooltip is removed', () => {
expect(getTooltip()).toBe('');
});
});
diff --git a/spec/frontend/ci/runner/components/runner_details_spec.js b/spec/frontend/ci/runner/components/runner_details_spec.js
index 65a81973869..c2d9e86aa91 100644
--- a/spec/frontend/ci/runner/components/runner_details_spec.js
+++ b/spec/frontend/ci/runner/components/runner_details_spec.js
@@ -37,10 +37,6 @@ describe('RunnerDetails', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Details tab', () => {
describe.each`
field | runner | expectedValue
diff --git a/spec/frontend/ci/runner/components/runner_edit_button_spec.js b/spec/frontend/ci/runner/components/runner_edit_button_spec.js
index 907cdc90100..5cc1ee049f4 100644
--- a/spec/frontend/ci/runner/components/runner_edit_button_spec.js
+++ b/spec/frontend/ci/runner/components/runner_edit_button_spec.js
@@ -11,7 +11,7 @@ describe('RunnerEditButton', () => {
wrapper = mountFn(RunnerEditButton, {
attrs,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -20,10 +20,6 @@ describe('RunnerEditButton', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays Edit text', () => {
expect(wrapper.attributes('aria-label')).toBe('Edit');
});
diff --git a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js
index 408750e646f..7572122a5f3 100644
--- a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js
+++ b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js
@@ -1,5 +1,6 @@
import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { assertProps } from 'helpers/assert_props';
import RunnerFilteredSearchBar from '~/ci/runner/components/runner_filtered_search_bar.vue';
import { statusTokenConfig } from '~/ci/runner/components/search_tokens/status_token_config';
import TagToken from '~/ci/runner/components/search_tokens/tag_token.vue';
@@ -43,12 +44,12 @@ describe('RunnerList', () => {
expect(inputs[inputs.length - 1][0]).toEqual(value);
};
+ const defaultProps = { namespace: 'runners', tokens: [], value: mockSearch };
+
const createComponent = ({ props = {}, options = {} } = {}) => {
wrapper = shallowMountExtended(RunnerFilteredSearchBar, {
propsData: {
- namespace: 'runners',
- tokens: [],
- value: mockSearch,
+ ...defaultProps,
...props,
},
stubs: {
@@ -65,10 +66,6 @@ describe('RunnerList', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('binds a namespace to the filtered search', () => {
expect(findFilteredSearch().props('namespace')).toBe('runners');
});
@@ -113,11 +110,14 @@ describe('RunnerList', () => {
it('fails validation for v-model with the wrong shape', () => {
expect(() => {
- createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } });
+ assertProps(RunnerFilteredSearchBar, {
+ ...defaultProps,
+ value: { filters: 'wrong_filters', sort: 'sort' },
+ });
}).toThrow('Invalid prop: custom validator check failed');
expect(() => {
- createComponent({ props: { value: { sort: 'sort' } } });
+ assertProps(RunnerFilteredSearchBar, { ...defaultProps, value: { sort: 'sort' } });
}).toThrow('Invalid prop: custom validator check failed');
});
diff --git a/spec/frontend/ci/runner/components/runner_groups_spec.js b/spec/frontend/ci/runner/components/runner_groups_spec.js
index 0991feb2e55..e4f5f55ab4b 100644
--- a/spec/frontend/ci/runner/components/runner_groups_spec.js
+++ b/spec/frontend/ci/runner/components/runner_groups_spec.js
@@ -23,10 +23,6 @@ describe('RunnerGroups', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Shows a heading', () => {
createComponent();
diff --git a/spec/frontend/ci/runner/components/runner_header_spec.js b/spec/frontend/ci/runner/components/runner_header_spec.js
index abe3b47767e..c851966431d 100644
--- a/spec/frontend/ci/runner/components/runner_header_spec.js
+++ b/spec/frontend/ci/runner/components/runner_header_spec.js
@@ -42,10 +42,6 @@ describe('RunnerHeader', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays the runner status', () => {
createComponent({
mountFn: mountExtended,
diff --git a/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js
new file mode 100644
index 00000000000..59c9383cb31
--- /dev/null
+++ b/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js
@@ -0,0 +1,35 @@
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url';
+
+import { shallowMount } from '@vue/test-utils';
+import { GlEmptyState } from '@gitlab/ui';
+import RunnerJobsEmptyState from '~/ci/runner/components/runner_jobs_empty_state.vue';
+
+const DEFAULT_PROPS = {
+ emptyTitle: 'This runner has not run any jobs',
+ emptyDescription:
+ 'Make sure the runner is online and available to run jobs (not paused). Jobs display here when the runner picks them up.',
+};
+
+describe('RunnerJobsEmptyStateComponent', () => {
+ let wrapper;
+
+ const mountComponent = () => {
+ wrapper = shallowMount(RunnerJobsEmptyState);
+ };
+
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ describe('empty', () => {
+ it('should show an empty state if it is empty', () => {
+ const emptyState = findEmptyState();
+
+ expect(emptyState.props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
+ expect(emptyState.props('title')).toBe(DEFAULT_PROPS.emptyTitle);
+ expect(emptyState.text()).toContain(DEFAULT_PROPS.emptyDescription);
+ });
+ });
+});
diff --git a/spec/frontend/ci/runner/components/runner_jobs_spec.js b/spec/frontend/ci/runner/components/runner_jobs_spec.js
index bdb8a4a31a3..179b37cfa21 100644
--- a/spec/frontend/ci/runner/components/runner_jobs_spec.js
+++ b/spec/frontend/ci/runner/components/runner_jobs_spec.js
@@ -4,18 +4,19 @@ import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import RunnerJobs from '~/ci/runner/components/runner_jobs.vue';
import RunnerJobsTable from '~/ci/runner/components/runner_jobs_table.vue';
import RunnerPagination from '~/ci/runner/components/runner_pagination.vue';
+import RunnerJobsEmptyState from '~/ci/runner/components/runner_jobs_empty_state.vue';
import { captureException } from '~/ci/runner/sentry_utils';
-import { I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/ci/runner/constants';
+import { RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/ci/runner/constants';
import runnerJobsQuery from '~/ci/runner/graphql/show/runner_jobs.query.graphql';
import { runnerData, runnerJobsData } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
const mockRunner = runnerData.data.runner;
@@ -31,7 +32,7 @@ describe('RunnerJobs', () => {
const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoader);
const findRunnerJobsTable = () => wrapper.findComponent(RunnerJobsTable);
const findRunnerPagination = () => wrapper.findComponent(RunnerPagination);
-
+ const findEmptyState = () => wrapper.findComponent(RunnerJobsEmptyState);
const createComponent = ({ mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerJobs, {
apolloProvider: createMockApollo([[runnerJobsQuery, mockRunnerJobsQuery]]),
@@ -47,7 +48,6 @@ describe('RunnerJobs', () => {
afterEach(() => {
mockRunnerJobsQuery.mockReset();
- wrapper.destroy();
});
it('Requests runner jobs', async () => {
@@ -100,7 +100,7 @@ describe('RunnerJobs', () => {
expect(findGlSkeletonLoading().exists()).toBe(true);
expect(findRunnerJobsTable().exists()).toBe(false);
- expect(findRunnerPagination().attributes('disabled')).toBe('true');
+ expect(findRunnerPagination().attributes('disabled')).toBeDefined();
});
});
@@ -128,8 +128,8 @@ describe('RunnerJobs', () => {
await waitForPromises();
});
- it('Shows a "None" label', () => {
- expect(wrapper.text()).toBe(I18N_NO_JOBS_FOUND);
+ it('should render empty state', () => {
+ expect(findEmptyState().exists()).toBe(true);
});
});
diff --git a/spec/frontend/ci/runner/components/runner_jobs_table_spec.js b/spec/frontend/ci/runner/components/runner_jobs_table_spec.js
index 281aa1aeb77..694c5a6ed17 100644
--- a/spec/frontend/ci/runner/components/runner_jobs_table_spec.js
+++ b/spec/frontend/ci/runner/components/runner_jobs_table_spec.js
@@ -37,10 +37,6 @@ describe('RunnerJobsTable', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Sets job id as a row key', () => {
createComponent();
diff --git a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js
index 6aea3ddf58c..0de2759ea8a 100644
--- a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js
+++ b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js
@@ -1,19 +1,15 @@
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url';
+import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/magnifying-glass.svg?url';
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
-import {
- newRunnerPath,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
-} from 'jest/ci/runner/mock_data';
+import { mockRegistrationToken, newRunnerPath } from 'jest/ci/runner/mock_data';
import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue';
-const mockRegistrationToken = 'REGISTRATION_TOKEN';
-
describe('RunnerListEmptyState', () => {
let wrapper;
@@ -24,14 +20,12 @@ describe('RunnerListEmptyState', () => {
const createComponent = ({ props, mountFn = shallowMountExtended, ...options } = {}) => {
wrapper = mountFn(RunnerListEmptyState, {
propsData: {
- svgPath: emptyStateSvgPath,
- filteredSvgPath: emptyStateFilteredSvgPath,
registrationToken: mockRegistrationToken,
newRunnerPath,
...props,
},
directives: {
- GlModal: createMockDirective(),
+ GlModal: createMockDirective('gl-modal'),
},
stubs: {
GlEmptyState,
@@ -51,7 +45,7 @@ describe('RunnerListEmptyState', () => {
});
it('renders an illustration', () => {
- expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath);
+ expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
});
it('displays "no results" text with instructions', () => {
@@ -62,44 +56,52 @@ describe('RunnerListEmptyState', () => {
expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`);
});
- describe('when create_runner_workflow is enabled', () => {
- beforeEach(() => {
- createComponent({
- provide: {
- glFeatures: { createRunnerWorkflow: true },
- },
+ describe.each([
+ { createRunnerWorkflowForAdmin: true },
+ { createRunnerWorkflowForNamespace: true },
+ ])('when %o', (glFeatures) => {
+ describe('when newRunnerPath is defined', () => {
+ beforeEach(() => {
+ createComponent({
+ provide: {
+ glFeatures,
+ },
+ });
});
- });
- it('shows a link to the new runner page', () => {
- expect(findLink().attributes('href')).toBe(newRunnerPath);
+ it('shows a link to the new runner page', () => {
+ expect(findLink().attributes('href')).toBe(newRunnerPath);
+ });
});
- });
- describe('when create_runner_workflow is enabled and newRunnerPath not defined', () => {
- beforeEach(() => {
- createComponent({
- props: {
- newRunnerPath: null,
- },
- provide: {
- glFeatures: { createRunnerWorkflow: true },
- },
+ describe('when newRunnerPath not defined', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ newRunnerPath: null,
+ },
+ provide: {
+ glFeatures,
+ },
+ });
});
- });
- it('opens a runner registration instructions modal with a link', () => {
- const { value } = getBinding(findLink().element, 'gl-modal');
+ it('opens a runner registration instructions modal with a link', () => {
+ const { value } = getBinding(findLink().element, 'gl-modal');
- expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
+ expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
+ });
});
});
- describe('when create_runner_workflow is disabled', () => {
+ describe.each([
+ { createRunnerWorkflowForAdmin: false },
+ { createRunnerWorkflowForNamespace: false },
+ ])('when %o', (glFeatures) => {
beforeEach(() => {
createComponent({
provide: {
- glFeatures: { createRunnerWorkflow: false },
+ glFeatures,
},
});
});
@@ -118,7 +120,7 @@ describe('RunnerListEmptyState', () => {
});
it('renders an illustration', () => {
- expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath);
+ expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
});
it('displays "no results" text', () => {
@@ -141,7 +143,7 @@ describe('RunnerListEmptyState', () => {
});
it('renders a "filtered search" illustration', () => {
- expect(findEmptyState().props('svgPath')).toBe(emptyStateFilteredSvgPath);
+ expect(findEmptyState().props('svgPath')).toBe(FILTERED_SVG_URL);
});
it('displays "no filtered results" text', () => {
diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js
index 2e5d1dbd063..0f4ec717c3e 100644
--- a/spec/frontend/ci/runner/components/runner_list_spec.js
+++ b/spec/frontend/ci/runner/components/runner_list_spec.js
@@ -57,10 +57,6 @@ describe('RunnerList', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays headers', () => {
createComponent(
{
@@ -168,7 +164,7 @@ describe('RunnerList', () => {
});
});
- it('Emits a deleted event', async () => {
+ it('Emits a deleted event', () => {
const event = { message: 'Deleted!' };
findRunnerBulkDelete().vm.$emit('deleted', event);
diff --git a/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js b/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js
index f089becd400..7ff3ec92042 100644
--- a/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js
+++ b/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js
@@ -18,10 +18,6 @@ describe('RunnerMembershipToggle', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays text', () => {
createComponent({ mountFn: mount });
diff --git a/spec/frontend/ci/runner/components/runner_pagination_spec.js b/spec/frontend/ci/runner/components/runner_pagination_spec.js
index f835ee4514d..6d84eb810f8 100644
--- a/spec/frontend/ci/runner/components/runner_pagination_spec.js
+++ b/spec/frontend/ci/runner/components/runner_pagination_spec.js
@@ -16,10 +16,6 @@ describe('RunnerPagination', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('When in between pages', () => {
const mockPageInfo = {
startCursor: mockStartCursor,
diff --git a/spec/frontend/ci/runner/components/runner_pause_button_spec.js b/spec/frontend/ci/runner/components/runner_pause_button_spec.js
index 12680e01b98..350d029f3fc 100644
--- a/spec/frontend/ci/runner/components/runner_pause_button_spec.js
+++ b/spec/frontend/ci/runner/components/runner_pause_button_spec.js
@@ -7,7 +7,7 @@ import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_help
import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import { captureException } from '~/ci/runner/sentry_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
I18N_PAUSE,
I18N_PAUSE_TOOLTIP,
@@ -22,7 +22,7 @@ const mockRunner = allRunnersData.data.runners.nodes[0];
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
describe('RunnerPauseButton', () => {
@@ -46,7 +46,7 @@ describe('RunnerPauseButton', () => {
},
apolloProvider: createMockApollo([[runnerToggleActiveMutation, runnerToggleActiveHandler]]),
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -74,10 +74,6 @@ describe('RunnerPauseButton', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Pause/Resume action', () => {
describe.each`
runnerState | icon | content | tooltip | isActive | newActiveValue
@@ -138,7 +134,7 @@ describe('RunnerPauseButton', () => {
await clickAndWait();
});
- it(`The mutation to that sets active to ${newActiveValue} is called`, async () => {
+ it(`The mutation to that sets active to ${newActiveValue} is called`, () => {
expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(1);
expect(runnerToggleActiveHandler).toHaveBeenCalledWith({
input: {
diff --git a/spec/frontend/ci/runner/components/runner_paused_badge_spec.js b/spec/frontend/ci/runner/components/runner_paused_badge_spec.js
index b051ebe99a7..54768ea50da 100644
--- a/spec/frontend/ci/runner/components/runner_paused_badge_spec.js
+++ b/spec/frontend/ci/runner/components/runner_paused_badge_spec.js
@@ -16,7 +16,7 @@ describe('RunnerTypeBadge', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -25,10 +25,6 @@ describe('RunnerTypeBadge', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders paused state', () => {
expect(wrapper.text()).toBe(I18N_PAUSED);
expect(findBadge().props('variant')).toBe('warning');
diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js
index db6fd2c369b..eddc1438fff 100644
--- a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js
+++ b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js
@@ -6,19 +6,12 @@ import {
LINUX_PLATFORM,
MACOS_PLATFORM,
WINDOWS_PLATFORM,
- AWS_PLATFORM,
DOCKER_HELP_URL,
KUBERNETES_HELP_URL,
} from '~/ci/runner/constants';
import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
-const mockProvide = {
- awsImgPath: 'awsLogo.svg',
- dockerImgPath: 'dockerLogo.svg',
- kubernetesImgPath: 'kubernetesLogo.svg',
-};
-
describe('RunnerPlatformsRadioGroup', () => {
let wrapper;
@@ -35,7 +28,6 @@ describe('RunnerPlatformsRadioGroup', () => {
value: null,
...props,
},
- provide: mockProvide,
...options,
});
};
@@ -48,10 +40,9 @@ describe('RunnerPlatformsRadioGroup', () => {
const labels = findFormRadios().map((w) => [w.text(), w.props('image')]);
expect(labels).toEqual([
- ['Linux', null],
+ ['Linux', expect.any(String)],
['macOS', null],
['Windows', null],
- ['AWS', expect.any(String)],
['Docker', expect.any(String)],
['Kubernetes', expect.any(String)],
]);
@@ -69,7 +60,6 @@ describe('RunnerPlatformsRadioGroup', () => {
${'Linux'} | ${LINUX_PLATFORM}
${'macOS'} | ${MACOS_PLATFORM}
${'Windows'} | ${WINDOWS_PLATFORM}
- ${'AWS'} | ${AWS_PLATFORM}
`('user can select "$text"', async ({ text, value }) => {
const radio = findFormRadioByText(text);
expect(radio.props('value')).toBe(value);
@@ -84,7 +74,7 @@ describe('RunnerPlatformsRadioGroup', () => {
text | href
${'Docker'} | ${DOCKER_HELP_URL}
${'Kubernetes'} | ${KUBERNETES_HELP_URL}
- `('provides link to "$text" docs', async ({ text, href }) => {
+ `('provides link to "$text" docs', ({ text, href }) => {
const radio = findFormRadioByText(text);
expect(radio.findComponent(GlLink).attributes()).toEqual({
diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js
index fb81edd1ae2..340b04637f8 100644
--- a/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js
+++ b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js
@@ -41,7 +41,7 @@ describe('RunnerPlatformsRadio', () => {
expect(findFormRadio().attributes('value')).toBe(mockValue);
});
- it('emits when item is clicked', async () => {
+ it('emits when item is clicked', () => {
findDiv().trigger('click');
expect(wrapper.emitted('input')).toEqual([[mockValue]]);
@@ -94,7 +94,7 @@ describe('RunnerPlatformsRadio', () => {
expect(wrapper.classes('gl-cursor-pointer')).toBe(false);
});
- it('does not emit when item is clicked', async () => {
+ it('does not emit when item is clicked', () => {
findDiv().trigger('click');
expect(wrapper.emitted('input')).toBe(undefined);
diff --git a/spec/frontend/ci/runner/components/runner_projects_spec.js b/spec/frontend/ci/runner/components/runner_projects_spec.js
index 17517c4db66..736a1f7d3ce 100644
--- a/spec/frontend/ci/runner/components/runner_projects_spec.js
+++ b/spec/frontend/ci/runner/components/runner_projects_spec.js
@@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sprintf } from '~/locale';
import {
I18N_ASSIGNED_PROJECTS,
@@ -22,7 +22,7 @@ import runnerProjectsQuery from '~/ci/runner/graphql/show/runner_projects.query.
import { runnerData, runnerProjectsData } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
const mockRunner = runnerData.data.runner;
@@ -56,7 +56,6 @@ describe('RunnerProjects', () => {
afterEach(() => {
mockRunnerProjectsQuery.mockReset();
- wrapper.destroy();
});
it('Requests runner projects', async () => {
@@ -90,7 +89,7 @@ describe('RunnerProjects', () => {
await waitForPromises();
});
- it('Shows a heading', async () => {
+ it('Shows a heading', () => {
const expected = sprintf(I18N_ASSIGNED_PROJECTS, { projectCount: mockProjects.length });
expect(findHeading().text()).toBe(expected);
@@ -195,7 +194,7 @@ describe('RunnerProjects', () => {
expect(wrapper.findByText(I18N_NO_PROJECTS_FOUND).exists()).toBe(false);
expect(findRunnerAssignedItems().length).toBe(0);
- expect(findRunnerPagination().attributes('disabled')).toBe('true');
+ expect(findRunnerPagination().attributes('disabled')).toBeDefined();
expect(findGlSearchBoxByType().props('isLoading')).toBe(true);
});
});
diff --git a/spec/frontend/ci/runner/components/runner_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_status_badge_spec.js
index 45b410df2d4..e1eb81f2d23 100644
--- a/spec/frontend/ci/runner/components/runner_status_badge_spec.js
+++ b/spec/frontend/ci/runner/components/runner_status_badge_spec.js
@@ -31,7 +31,7 @@ describe('RunnerTypeBadge', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -43,8 +43,6 @@ describe('RunnerTypeBadge', () => {
afterEach(() => {
jest.useFakeTimers({ legacyFakeTimers: true });
-
- wrapper.destroy();
});
it('renders online state', () => {
diff --git a/spec/frontend/ci/runner/components/runner_tag_spec.js b/spec/frontend/ci/runner/components/runner_tag_spec.js
index 7bcb046ae43..e3d46e5d6df 100644
--- a/spec/frontend/ci/runner/components/runner_tag_spec.js
+++ b/spec/frontend/ci/runner/components/runner_tag_spec.js
@@ -29,8 +29,8 @@ describe('RunnerTag', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
- GlResizeObserver: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
+ GlResizeObserver: createMockDirective('gl-resize-observer'),
},
});
};
@@ -39,10 +39,6 @@ describe('RunnerTag', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays tag text', () => {
expect(wrapper.text()).toBe(mockTag);
});
diff --git a/spec/frontend/ci/runner/components/runner_tags_spec.js b/spec/frontend/ci/runner/components/runner_tags_spec.js
index 96bec00302b..bcb1d1f9e13 100644
--- a/spec/frontend/ci/runner/components/runner_tags_spec.js
+++ b/spec/frontend/ci/runner/components/runner_tags_spec.js
@@ -21,10 +21,6 @@ describe('RunnerTags', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays tags text', () => {
expect(wrapper.text()).toMatchInterpolatedText('tag1 tag2');
diff --git a/spec/frontend/ci/runner/components/runner_type_badge_spec.js b/spec/frontend/ci/runner/components/runner_type_badge_spec.js
index 58f09362759..f7ecd108967 100644
--- a/spec/frontend/ci/runner/components/runner_type_badge_spec.js
+++ b/spec/frontend/ci/runner/components/runner_type_badge_spec.js
@@ -2,6 +2,7 @@ import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTypeBadge from '~/ci/runner/components/runner_type_badge.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { assertProps } from 'helpers/assert_props';
import {
INSTANCE_TYPE,
GROUP_TYPE,
@@ -23,15 +24,11 @@ describe('RunnerTypeBadge', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each`
type | text
${INSTANCE_TYPE} | ${I18N_INSTANCE_TYPE}
@@ -54,7 +51,7 @@ describe('RunnerTypeBadge', () => {
it('validation fails for an incorrect type', () => {
expect(() => {
- createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } });
+ assertProps(RunnerTypeBadge, { type: 'AN_UNKNOWN_VALUE' });
}).toThrow();
});
diff --git a/spec/frontend/ci/runner/components/runner_type_tabs_spec.js b/spec/frontend/ci/runner/components/runner_type_tabs_spec.js
index 3347c190083..71dcc5b4226 100644
--- a/spec/frontend/ci/runner/components/runner_type_tabs_spec.js
+++ b/spec/frontend/ci/runner/components/runner_type_tabs_spec.js
@@ -8,6 +8,7 @@ import {
PROJECT_TYPE,
DEFAULT_MEMBERSHIP,
DEFAULT_SORT,
+ STATUS_ONLINE,
} from '~/ci/runner/constants';
const mockSearch = {
@@ -63,10 +64,6 @@ describe('RunnerTypeTabs', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Renders all options to filter runners by default', () => {
createComponent();
@@ -115,7 +112,7 @@ describe('RunnerTypeTabs', () => {
it('Renders a count next to each tab', () => {
const mockVariables = {
paused: true,
- status: 'ONLINE',
+ status: STATUS_ONLINE,
};
createComponent({
diff --git a/spec/frontend/ci/runner/components/runner_update_form_spec.js b/spec/frontend/ci/runner/components/runner_update_form_spec.js
index a0e51ebf958..db4c236bfff 100644
--- a/spec/frontend/ci/runner/components/runner_update_form_spec.js
+++ b/spec/frontend/ci/runner/components/runner_update_form_spec.js
@@ -5,8 +5,8 @@ import { __ } from '~/locale';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import RunnerUpdateForm from '~/ci/runner/components/runner_update_form.vue';
import {
INSTANCE_TYPE,
@@ -21,7 +21,7 @@ import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_al
import { runnerFormData } from '../mock_data';
jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility');
@@ -86,7 +86,7 @@ describe('RunnerUpdateForm', () => {
variant: VARIANT_SUCCESS,
}),
);
- expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath);
+ expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath); // eslint-disable-line import/no-deprecated
};
beforeEach(() => {
@@ -107,10 +107,6 @@ describe('RunnerUpdateForm', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Form has a submit button', () => {
expect(findSubmit().exists()).toBe(true);
});
@@ -282,7 +278,7 @@ describe('RunnerUpdateForm', () => {
expect(captureException).not.toHaveBeenCalled();
expect(saveAlertToLocalStorage).not.toHaveBeenCalled();
- expect(redirectTo).not.toHaveBeenCalled();
+ expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
});
});
});
diff --git a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js
index b7d9d3ad23e..e9f2e888b9a 100644
--- a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js
+++ b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js
@@ -3,14 +3,14 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import TagToken, { TAG_SUGGESTIONS_PATH } from '~/ci/runner/components/search_tokens/tag_token.vue';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import { getRecentlyUsedSuggestions } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({
...jest.requireActual('~/vue_shared/components/filtered_search_bar/filtered_search_utils'),
@@ -90,7 +90,6 @@ describe('TagToken', () => {
afterEach(() => {
getRecentlyUsedSuggestions.mockReset();
- wrapper.destroy();
});
describe('when the tags token is displayed', () => {
diff --git a/spec/frontend/ci/runner/components/stat/runner_count_spec.js b/spec/frontend/ci/runner/components/stat/runner_count_spec.js
index 42d8c9a1080..df774ba3e57 100644
--- a/spec/frontend/ci/runner/components/stat/runner_count_spec.js
+++ b/spec/frontend/ci/runner/components/stat/runner_count_spec.js
@@ -2,7 +2,7 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
import RunnerCount from '~/ci/runner/components/stat/runner_count.vue';
-import { INSTANCE_TYPE, GROUP_TYPE } from '~/ci/runner/constants';
+import { INSTANCE_TYPE, GROUP_TYPE, STATUS_ONLINE } from '~/ci/runner/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { captureException } from '~/ci/runner/sentry_utils';
@@ -47,7 +47,7 @@ describe('RunnerCount', () => {
});
describe('in admin scope', () => {
- const mockVariables = { status: 'ONLINE' };
+ const mockVariables = { status: STATUS_ONLINE };
beforeEach(async () => {
await createComponent({ props: { scope: INSTANCE_TYPE } });
@@ -67,7 +67,7 @@ describe('RunnerCount', () => {
expect(wrapper.html()).toBe(`<strong>${runnersCountData.data.runners.count}</strong>`);
});
- it('does not fetch from the group query', async () => {
+ it('does not fetch from the group query', () => {
expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled();
});
@@ -89,7 +89,7 @@ describe('RunnerCount', () => {
await createComponent({ props: { scope: INSTANCE_TYPE, skip: true } });
});
- it('does not fetch data', async () => {
+ it('does not fetch data', () => {
expect(mockRunnersCountHandler).not.toHaveBeenCalled();
expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled();
@@ -106,7 +106,7 @@ describe('RunnerCount', () => {
await createComponent({ props: { scope: INSTANCE_TYPE } });
});
- it('data is not shown and error is reported', async () => {
+ it('data is not shown and error is reported', () => {
expect(wrapper.html()).toBe('<strong></strong>');
expect(captureException).toHaveBeenCalledWith({
@@ -121,7 +121,7 @@ describe('RunnerCount', () => {
await createComponent({ props: { scope: GROUP_TYPE } });
});
- it('fetches data from the group query', async () => {
+ it('fetches data from the group query', () => {
expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(1);
expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({});
@@ -141,7 +141,7 @@ describe('RunnerCount', () => {
wrapper.vm.refetch();
});
- it('data is not shown and error is reported', async () => {
+ it('data is not shown and error is reported', () => {
expect(mockRunnersCountHandler).toHaveBeenCalledTimes(2);
});
});
diff --git a/spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js b/spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js
index cad61f26012..f30b75ee614 100644
--- a/spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js
+++ b/spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js
@@ -32,10 +32,6 @@ describe('RunnerStats', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
case | count | value
${'number'} | ${99} | ${'99'}
diff --git a/spec/frontend/ci/runner/components/stat/runner_stats_spec.js b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js
index 3d45674d106..13366a788d5 100644
--- a/spec/frontend/ci/runner/components/stat/runner_stats_spec.js
+++ b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js
@@ -47,10 +47,6 @@ describe('RunnerStats', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Displays all the stats', () => {
createComponent({
mountFn: mount,
diff --git a/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js
new file mode 100644
index 00000000000..1c052b00fc3
--- /dev/null
+++ b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js
@@ -0,0 +1,124 @@
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+
+import GroupRunnerRunnerApp from '~/ci/runner/group_new_runner/group_new_runner_app.vue';
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue';
+import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage';
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+import {
+ PARAM_KEY_PLATFORM,
+ GROUP_TYPE,
+ DEFAULT_PLATFORM,
+ WINDOWS_PLATFORM,
+} from '~/ci/runner/constants';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { runnerCreateResult } from '../mock_data';
+
+const mockGroupId = 'gid://gitlab/Group/72';
+
+jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
+jest.mock('~/alert');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ redirectTo: jest.fn(),
+}));
+
+const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner;
+
+describe('GroupRunnerRunnerApp', () => {
+ let wrapper;
+
+ const findRunnerPlatformsRadioGroup = () => wrapper.findComponent(RunnerPlatformsRadioGroup);
+ const findRegistrationFeedbackBanner = () => wrapper.findComponent(RegistrationFeedbackBanner);
+ const findRegistrationCompatibilityAlert = () =>
+ wrapper.findComponent(RegistrationCompatibilityAlert);
+ const findRunnerCreateForm = () => wrapper.findComponent(RunnerCreateForm);
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(GroupRunnerRunnerApp, {
+ propsData: {
+ groupId: mockGroupId,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows a registration feedback banner', () => {
+ expect(findRegistrationFeedbackBanner().exists()).toBe(true);
+ });
+
+ it('shows a registration compatibility alert', () => {
+ expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(mockGroupId);
+ });
+
+ describe('Platform', () => {
+ it('shows the platforms radio group', () => {
+ expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM);
+ });
+ });
+
+ describe('Runner form', () => {
+ it('shows the runner create form for an instance runner', () => {
+ expect(findRunnerCreateForm().props()).toEqual({
+ runnerType: GROUP_TYPE,
+ groupId: mockGroupId,
+ projectId: null,
+ });
+ });
+
+ describe('When a runner is saved', () => {
+ beforeEach(() => {
+ findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner);
+ });
+
+ it('pushes an alert to be shown after redirection', () => {
+ expect(saveAlertToLocalStorage).toHaveBeenCalledWith({
+ message: s__('Runners|Runner created.'),
+ variant: VARIANT_SUCCESS,
+ });
+ });
+
+ it('redirects to the registration page', () => {
+ const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`;
+
+ expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ });
+ });
+
+ describe('When another platform is selected and a runner is saved', () => {
+ beforeEach(() => {
+ findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM);
+ findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner);
+ });
+
+ it('redirects to the registration page with the platform', () => {
+ const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`;
+
+ expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ });
+ });
+
+ describe('When runner fails to save', () => {
+ const ERROR_MSG = 'Cannot save!';
+
+ beforeEach(() => {
+ findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG));
+ });
+
+ it('shows an error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js b/spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js
new file mode 100644
index 00000000000..2f0807c700c
--- /dev/null
+++ b/spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js
@@ -0,0 +1,120 @@
+import { nextTick } from 'vue';
+import { GlButton } from '@gitlab/ui';
+
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+
+import { updateHistory } from '~/lib/utils/url_utility';
+import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM, WINDOWS_PLATFORM } from '~/ci/runner/constants';
+import GroupRegisterRunnerApp from '~/ci/runner/group_register_runner/group_register_runner_app.vue';
+import RegistrationInstructions from '~/ci/runner/components/registration/registration_instructions.vue';
+import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawer.vue';
+import { runnerForRegistration } from '../mock_data';
+
+const mockRunnerId = runnerForRegistration.data.runner.id;
+const mockRunnersPath = '/groups/group1/-/runners';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ updateHistory: jest.fn(),
+}));
+
+describe('GroupRegisterRunnerApp', () => {
+ let wrapper;
+
+ const findRegistrationInstructions = () => wrapper.findComponent(RegistrationInstructions);
+ const findPlatformsDrawer = () => wrapper.findComponent(PlatformsDrawer);
+ const findBtn = () => wrapper.findComponent(GlButton);
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(GroupRegisterRunnerApp, {
+ propsData: {
+ runnerId: mockRunnerId,
+ runnersPath: mockRunnersPath,
+ },
+ });
+ };
+
+ describe('When showing runner details', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('when runner token is available', () => {
+ it('shows registration instructions', () => {
+ expect(findRegistrationInstructions().props()).toEqual({
+ platform: DEFAULT_PLATFORM,
+ runnerId: mockRunnerId,
+ });
+ });
+
+ it('configures platform drawer', () => {
+ expect(findPlatformsDrawer().props()).toEqual({
+ open: false,
+ platform: DEFAULT_PLATFORM,
+ });
+ });
+
+ it('shows runner list button', () => {
+ expect(findBtn().attributes('href')).toBe(mockRunnersPath);
+ expect(findBtn().props('variant')).toBe('confirm');
+ });
+ });
+ });
+
+ describe('When another platform has been selected', () => {
+ beforeEach(() => {
+ setWindowLocation(`?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`);
+
+ createComponent();
+ });
+
+ it('shows registration instructions for the platform', () => {
+ expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM);
+ });
+ });
+
+ describe('When opening install instructions', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findRegistrationInstructions().vm.$emit('toggleDrawer');
+ });
+
+ it('opens platform drawer', () => {
+ expect(findPlatformsDrawer().props('open')).toBe(true);
+ });
+
+ it('closes platform drawer', async () => {
+ findRegistrationInstructions().vm.$emit('toggleDrawer');
+ await nextTick();
+
+ expect(findPlatformsDrawer().props('open')).toBe(false);
+ });
+
+ it('closes platform drawer from drawer', async () => {
+ findPlatformsDrawer().vm.$emit('close');
+ await nextTick();
+
+ expect(findPlatformsDrawer().props('open')).toBe(false);
+ });
+
+ describe('when selecting a platform', () => {
+ beforeEach(() => {
+ findPlatformsDrawer().vm.$emit('selectPlatform', WINDOWS_PLATFORM);
+ });
+
+ it('updates the url', () => {
+ expect(updateHistory).toHaveBeenCalledTimes(1);
+ expect(updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`,
+ });
+ });
+
+ it('updates the registration instructions', () => {
+ expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js
index 2ad31dea774..0c594e8005c 100644
--- a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js
+++ b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js
@@ -4,8 +4,8 @@ import VueApollo from 'vue-apollo';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/ci/runner/components/runner_header.vue';
@@ -24,7 +24,7 @@ import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_al
import { runnerData } from '../mock_data';
jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility');
@@ -74,7 +74,6 @@ describe('GroupRunnerShowApp', () => {
afterEach(() => {
mockRunnerQuery.mockReset();
- wrapper.destroy();
});
describe('When showing runner details', () => {
@@ -84,7 +83,7 @@ describe('GroupRunnerShowApp', () => {
await createComponent({ mountFn: mountExtended });
});
- it('expect GraphQL ID to be requested', async () => {
+ it('expect GraphQL ID to be requested', () => {
expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId });
});
@@ -92,7 +91,7 @@ describe('GroupRunnerShowApp', () => {
expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`);
});
- it('displays the runner edit and pause buttons', async () => {
+ it('displays the runner edit and pause buttons', () => {
expect(findRunnerEditButton().attributes('href')).toBe(mockEditGroupRunnerPath);
expect(findRunnerPauseButton().exists()).toBe(true);
expect(findRunnerDeleteButton().exists()).toBe(true);
@@ -186,7 +185,7 @@ describe('GroupRunnerShowApp', () => {
message: 'Runner deleted',
variant: VARIANT_SUCCESS,
});
- expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath);
+ expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); // eslint-disable-line import/no-deprecated
});
});
});
diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js
index 39ea5cade28..41be72b1645 100644
--- a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js
@@ -9,7 +9,7 @@ import {
mountExtended,
} from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -58,23 +58,22 @@ import {
groupRunnersCountData,
onlineContactTimeoutSecs,
staleTimeoutSecs,
+ mockRegistrationToken,
+ newRunnerPath,
emptyPageInfo,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
} from '../mock_data';
Vue.use(VueApollo);
Vue.use(GlToast);
const mockGroupFullPath = 'group1';
-const mockRegistrationToken = 'AABBCC';
const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges;
const mockGroupRunnersCount = mockGroupRunnersEdges.length;
const mockGroupRunnersHandler = jest.fn();
const mockGroupRunnersCountHandler = jest.fn();
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
@@ -87,6 +86,7 @@ describe('GroupRunnersApp', () => {
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
+ const findNewRunnerBtn = () => wrapper.findByText(s__('Runners|New group runner'));
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState);
@@ -114,14 +114,13 @@ describe('GroupRunnersApp', () => {
propsData: {
registrationToken: mockRegistrationToken,
groupFullPath: mockGroupFullPath,
+ newRunnerPath,
...props,
},
provide: {
localMutations,
onlineContactTimeoutSecs,
staleTimeoutSecs,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
...provide,
},
...options,
@@ -138,7 +137,6 @@ describe('GroupRunnersApp', () => {
afterEach(() => {
mockGroupRunnersHandler.mockReset();
mockGroupRunnersCountHandler.mockReset();
- wrapper.destroy();
});
it('shows the runner tabs with a runner count for each type', async () => {
@@ -288,7 +286,7 @@ describe('GroupRunnersApp', () => {
});
});
- it('When runner is paused or unpaused, some data is refetched', async () => {
+ it('When runner is paused or unpaused, some data is refetched', () => {
expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES);
findRunnerActionsCell().vm.$emit('toggledPaused');
@@ -300,7 +298,7 @@ describe('GroupRunnersApp', () => {
expect(showToast).toHaveBeenCalledTimes(0);
});
- it('When runner is deleted, data is refetched and a toast message is shown', async () => {
+ it('When runner is deleted, data is refetched and a toast message is shown', () => {
findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' });
expect(showToast).toHaveBeenCalledTimes(1);
@@ -389,7 +387,7 @@ describe('GroupRunnersApp', () => {
it('when runners have not loaded, shows a loading state', () => {
createComponent();
expect(findRunnerList().props('loading')).toBe(true);
- expect(findRunnerPagination().attributes('disabled')).toBe('true');
+ expect(findRunnerPagination().attributes('disabled')).toBeDefined();
});
it('runners can be deleted in bulk', () => {
@@ -417,8 +415,12 @@ describe('GroupRunnersApp', () => {
expect(createAlert).not.toHaveBeenCalled();
});
- it('shows an empty state', async () => {
- expect(findRunnerListEmptyState().exists()).toBe(true);
+ it('shows an empty state', () => {
+ expect(findRunnerListEmptyState().props()).toMatchObject({
+ isSearchFiltered: false,
+ newRunnerPath,
+ registrationToken: mockRegistrationToken,
+ });
});
});
@@ -428,11 +430,11 @@ describe('GroupRunnersApp', () => {
await createComponent();
});
- it('error is shown to the user', async () => {
+ it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
- it('error is reported to sentry', async () => {
+ it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error('Error!'),
component: 'GroupRunnersApp',
@@ -469,32 +471,69 @@ describe('GroupRunnersApp', () => {
});
describe('when user has permission to register group runner', () => {
- beforeEach(() => {
+ it('shows the register group runner button', () => {
createComponent({
- propsData: {
+ props: {
registrationToken: mockRegistrationToken,
- groupFullPath: mockGroupFullPath,
},
});
+ expect(findRegistrationDropdown().exists()).toBe(true);
});
- it('shows the register group runner button', () => {
- expect(findRegistrationDropdown().exists()).toBe(true);
+ it('when create_runner_workflow_for_namespace is enabled', () => {
+ createComponent({
+ props: {
+ newRunnerPath,
+ },
+ provide: {
+ glFeatures: {
+ createRunnerWorkflowForNamespace: true,
+ },
+ },
+ });
+
+ expect(findNewRunnerBtn().attributes('href')).toBe(newRunnerPath);
+ });
+
+ it('when create_runner_workflow_for_namespace is disabled', () => {
+ createComponent({
+ props: {
+ newRunnerPath,
+ },
+ provide: {
+ glFeatures: {
+ createRunnerWorkflowForNamespace: false,
+ },
+ },
+ });
+
+ expect(findNewRunnerBtn().exists()).toBe(false);
});
});
describe('when user has no permission to register group runner', () => {
- beforeEach(() => {
+ it('does not show the register group runner button', () => {
createComponent({
- propsData: {
+ props: {
registrationToken: null,
- groupFullPath: mockGroupFullPath,
},
});
+ expect(findRegistrationDropdown().exists()).toBe(false);
});
- it('does not show the register group runner button', () => {
- expect(findRegistrationDropdown().exists()).toBe(false);
+ it('when create_runner_workflow_for_namespace is enabled', () => {
+ createComponent({
+ props: {
+ newRunnerPath: null,
+ },
+ provide: {
+ glFeatures: {
+ createRunnerWorkflowForNamespace: true,
+ },
+ },
+ });
+
+ expect(findNewRunnerBtn().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js b/spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js
index 03908891cfd..30e49fc7644 100644
--- a/spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js
+++ b/spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js
@@ -2,9 +2,9 @@ import AccessorUtilities from '~/lib/utils/accessor';
import { showAlertFromLocalStorage } from '~/ci/runner/local_storage_alert/show_alert_from_local_storage';
import { LOCAL_STORAGE_ALERT_KEY } from '~/ci/runner/local_storage_alert/constants';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('showAlertFromLocalStorage', () => {
useLocalStorageSpy();
diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js
index 5cdf0ea4e3b..223a156795c 100644
--- a/spec/frontend/ci/runner/mock_data.js
+++ b/spec/frontend/ci/runner/mock_data.js
@@ -1,6 +1,19 @@
// Fixtures generated by: spec/frontend/fixtures/runner.rb
+// List queries
+import allRunnersWithCreatorData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.with_creator.json';
+import allRunnersData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.json';
+import allRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.paginated.json';
+import runnersCountData from 'test_fixtures/graphql/ci/runner/list/all_runners_count.query.graphql.json';
+import groupRunnersData from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.json';
+import groupRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.paginated.json';
+import groupRunnersCountData from 'test_fixtures/graphql/ci/runner/list/group_runners_count.query.graphql.json';
+
+// Register runner queries
+import runnerForRegistration from 'test_fixtures/graphql/ci/runner/register/runner_for_registration.query.graphql.json';
+
// Show runner queries
+import runnerCreateResult from 'test_fixtures/graphql/ci/runner/new/runner_create.mutation.graphql.json';
import runnerData from 'test_fixtures/graphql/ci/runner/show/runner.query.graphql.json';
import runnerWithGroupData from 'test_fixtures/graphql/ci/runner/show/runner.query.graphql.with_group.json';
import runnerProjectsData from 'test_fixtures/graphql/ci/runner/show/runner_projects.query.graphql.json';
@@ -9,15 +22,16 @@ import runnerJobsData from 'test_fixtures/graphql/ci/runner/show/runner_jobs.que
// Edit runner queries
import runnerFormData from 'test_fixtures/graphql/ci/runner/edit/runner_form.query.graphql.json';
-// List queries
-import allRunnersData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.json';
-import allRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.paginated.json';
-import runnersCountData from 'test_fixtures/graphql/ci/runner/list/all_runners_count.query.graphql.json';
-import groupRunnersData from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.json';
-import groupRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.paginated.json';
-import groupRunnersCountData from 'test_fixtures/graphql/ci/runner/list/group_runners_count.query.graphql.json';
-
-import { DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE } from '~/ci/runner/constants';
+// New runner queries
+import {
+ DEFAULT_MEMBERSHIP,
+ INSTANCE_TYPE,
+ CREATED_DESC,
+ CREATED_ASC,
+ STATUS_ONLINE,
+ STATUS_STALE,
+ RUNNER_PAGE_SIZE,
+} from '~/ci/runner/constants';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
const emptyPageInfo = {
@@ -40,29 +54,29 @@ export const mockSearchExamples = [
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
},
isDefault: true,
},
{
name: 'a single status',
- urlQuery: '?status[]=ACTIVE',
+ urlQuery: '?status[]=ONLINE',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
- filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
+ filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
- status: 'ACTIVE',
- sort: 'CREATED_DESC',
+ status: STATUS_ONLINE,
+ sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
},
},
@@ -79,12 +93,12 @@ export const mockSearchExamples = [
},
],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
search: 'something',
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
},
},
@@ -105,12 +119,12 @@ export const mockSearchExamples = [
},
],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
search: 'something else',
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
},
},
@@ -118,54 +132,54 @@ export const mockSearchExamples = [
name: 'single instance type',
urlQuery: '?runner_type[]=INSTANCE_TYPE',
search: {
- runnerType: 'INSTANCE_TYPE',
+ runnerType: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
- type: 'INSTANCE_TYPE',
+ type: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'multiple runner status',
- urlQuery: '?status[]=ACTIVE&status[]=PAUSED',
+ urlQuery: '?status[]=ONLINE&status[]=STALE',
search: {
runnerType: null,
membership: DEFAULT_MEMBERSHIP,
filters: [
- { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
- { type: 'status', value: { data: 'PAUSED', operator: '=' } },
+ { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } },
+ { type: 'status', value: { data: STATUS_STALE, operator: '=' } },
],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
- status: 'ACTIVE',
+ status: STATUS_ONLINE,
membership: DEFAULT_MEMBERSHIP,
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'multiple status, a single instance type and a non default sort',
- urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC',
+ urlQuery: '?status[]=ONLINE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC',
search: {
- runnerType: 'INSTANCE_TYPE',
+ runnerType: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
- filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
+ filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }],
pagination: {},
- sort: 'CREATED_ASC',
+ sort: CREATED_ASC,
},
graphqlVariables: {
- status: 'ACTIVE',
- type: 'INSTANCE_TYPE',
+ status: STATUS_ONLINE,
+ type: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
- sort: 'CREATED_ASC',
+ sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
},
},
@@ -177,13 +191,13 @@ export const mockSearchExamples = [
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
tagList: ['tag-1'],
- first: 20,
- sort: 'CREATED_DESC',
+ first: RUNNER_PAGE_SIZE,
+ sort: CREATED_DESC,
},
},
{
@@ -197,13 +211,13 @@ export const mockSearchExamples = [
{ type: 'tag', value: { data: 'tag-2', operator: '=' } },
],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
tagList: ['tag-1', 'tag-2'],
- first: 20,
- sort: 'CREATED_DESC',
+ first: RUNNER_PAGE_SIZE,
+ sort: CREATED_DESC,
},
},
{
@@ -214,11 +228,11 @@ export const mockSearchExamples = [
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: { after: 'AFTER_CURSOR' },
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
after: 'AFTER_CURSOR',
first: RUNNER_PAGE_SIZE,
},
@@ -231,11 +245,11 @@ export const mockSearchExamples = [
membership: DEFAULT_MEMBERSHIP,
filters: [],
pagination: { before: 'BEFORE_CURSOR' },
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
membership: DEFAULT_MEMBERSHIP,
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
before: 'BEFORE_CURSOR',
last: RUNNER_PAGE_SIZE,
},
@@ -243,24 +257,24 @@ export const mockSearchExamples = [
{
name: 'the next page filtered by a status, an instance type, tags and a non default sort',
urlQuery:
- '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&after=AFTER_CURSOR',
+ '?status[]=ONLINE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&after=AFTER_CURSOR',
search: {
- runnerType: 'INSTANCE_TYPE',
+ runnerType: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
filters: [
- { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
+ { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } },
{ type: 'tag', value: { data: 'tag-1', operator: '=' } },
{ type: 'tag', value: { data: 'tag-2', operator: '=' } },
],
pagination: { after: 'AFTER_CURSOR' },
- sort: 'CREATED_ASC',
+ sort: CREATED_ASC,
},
graphqlVariables: {
- status: 'ACTIVE',
- type: 'INSTANCE_TYPE',
+ status: STATUS_ONLINE,
+ type: INSTANCE_TYPE,
membership: DEFAULT_MEMBERSHIP,
tagList: ['tag-1', 'tag-2'],
- sort: 'CREATED_ASC',
+ sort: CREATED_ASC,
after: 'AFTER_CURSOR',
first: RUNNER_PAGE_SIZE,
},
@@ -273,12 +287,12 @@ export const mockSearchExamples = [
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
paused: true,
membership: DEFAULT_MEMBERSHIP,
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
},
},
@@ -290,12 +304,12 @@ export const mockSearchExamples = [
membership: DEFAULT_MEMBERSHIP,
filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }],
pagination: {},
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
},
graphqlVariables: {
paused: false,
membership: DEFAULT_MEMBERSHIP,
- sort: 'CREATED_DESC',
+ sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
},
},
@@ -304,12 +318,14 @@ export const mockSearchExamples = [
export const onlineContactTimeoutSecs = 2 * 60 * 60;
export const staleTimeoutSecs = 7889238; // Ruby's `3.months`
+export const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
+export const mockAuthenticationToken = 'MOCK_AUTHENTICATION_TOKEN';
+
export const newRunnerPath = '/runners/new';
-export const emptyStateSvgPath = 'emptyStateSvgPath.svg';
-export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg';
export {
allRunnersData,
+ allRunnersWithCreatorData,
allRunnersDataPaginated,
runnersCountData,
groupRunnersData,
@@ -321,4 +337,6 @@ export {
runnerProjectsData,
runnerJobsData,
runnerFormData,
+ runnerCreateResult,
+ runnerForRegistration,
};
diff --git a/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js
new file mode 100644
index 00000000000..5bfbbfdc074
--- /dev/null
+++ b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js
@@ -0,0 +1,125 @@
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+
+import ProjectRunnerRunnerApp from '~/ci/runner/project_new_runner/project_new_runner_app.vue';
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue';
+import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage';
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+import {
+ PARAM_KEY_PLATFORM,
+ PROJECT_TYPE,
+ DEFAULT_PLATFORM,
+ WINDOWS_PLATFORM,
+} from '~/ci/runner/constants';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { runnerCreateResult, mockRegistrationToken } from '../mock_data';
+
+const mockProjectId = 'gid://gitlab/Project/72';
+
+jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
+jest.mock('~/alert');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ redirectTo: jest.fn(),
+}));
+
+const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner;
+
+describe('ProjectRunnerRunnerApp', () => {
+ let wrapper;
+
+ const findRunnerPlatformsRadioGroup = () => wrapper.findComponent(RunnerPlatformsRadioGroup);
+ const findRegistrationFeedbackBanner = () => wrapper.findComponent(RegistrationFeedbackBanner);
+ const findRegistrationCompatibilityAlert = () =>
+ wrapper.findComponent(RegistrationCompatibilityAlert);
+ const findRunnerCreateForm = () => wrapper.findComponent(RunnerCreateForm);
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ProjectRunnerRunnerApp, {
+ propsData: {
+ projectId: mockProjectId,
+ legacyRegistrationToken: mockRegistrationToken,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows a registration feedback banner', () => {
+ expect(findRegistrationFeedbackBanner().exists()).toBe(true);
+ });
+
+ it('shows a registration compatibility alert', () => {
+ expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(mockProjectId);
+ });
+
+ describe('Platform', () => {
+ it('shows the platforms radio group', () => {
+ expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM);
+ });
+ });
+
+ describe('Runner form', () => {
+ it('shows the runner create form for an instance runner', () => {
+ expect(findRunnerCreateForm().props()).toEqual({
+ runnerType: PROJECT_TYPE,
+ projectId: mockProjectId,
+ groupId: null,
+ });
+ });
+
+ describe('When a runner is saved', () => {
+ beforeEach(() => {
+ findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner);
+ });
+
+ it('pushes an alert to be shown after redirection', () => {
+ expect(saveAlertToLocalStorage).toHaveBeenCalledWith({
+ message: s__('Runners|Runner created.'),
+ variant: VARIANT_SUCCESS,
+ });
+ });
+
+ it('redirects to the registration page', () => {
+ const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`;
+
+ expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ });
+ });
+
+ describe('When another platform is selected and a runner is saved', () => {
+ beforeEach(() => {
+ findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM);
+ findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner);
+ });
+
+ it('redirects to the registration page with the platform', () => {
+ const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`;
+
+ expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ });
+ });
+
+ describe('When runner fails to save', () => {
+ const ERROR_MSG = 'Cannot save!';
+
+ beforeEach(() => {
+ findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG));
+ });
+
+ it('shows an error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/runner/project_register_runner_app/project_register_runner_app_spec.js b/spec/frontend/ci/runner/project_register_runner_app/project_register_runner_app_spec.js
new file mode 100644
index 00000000000..240fd82fb3b
--- /dev/null
+++ b/spec/frontend/ci/runner/project_register_runner_app/project_register_runner_app_spec.js
@@ -0,0 +1,120 @@
+import { nextTick } from 'vue';
+import { GlButton } from '@gitlab/ui';
+
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+
+import { updateHistory } from '~/lib/utils/url_utility';
+import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM, WINDOWS_PLATFORM } from '~/ci/runner/constants';
+import ProjectRegisterRunnerApp from '~/ci/runner/project_register_runner/project_register_runner_app.vue';
+import RegistrationInstructions from '~/ci/runner/components/registration/registration_instructions.vue';
+import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawer.vue';
+import { runnerForRegistration } from '../mock_data';
+
+const mockRunnerId = runnerForRegistration.data.runner.id;
+const mockRunnersPath = '/group1/project1/-/settings/ci_cd';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ updateHistory: jest.fn(),
+}));
+
+describe('ProjectRegisterRunnerApp', () => {
+ let wrapper;
+
+ const findRegistrationInstructions = () => wrapper.findComponent(RegistrationInstructions);
+ const findPlatformsDrawer = () => wrapper.findComponent(PlatformsDrawer);
+ const findBtn = () => wrapper.findComponent(GlButton);
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ProjectRegisterRunnerApp, {
+ propsData: {
+ runnerId: mockRunnerId,
+ runnersPath: mockRunnersPath,
+ },
+ });
+ };
+
+ describe('When showing runner details', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('when runner token is available', () => {
+ it('shows registration instructions', () => {
+ expect(findRegistrationInstructions().props()).toEqual({
+ platform: DEFAULT_PLATFORM,
+ runnerId: mockRunnerId,
+ });
+ });
+
+ it('configures platform drawer', () => {
+ expect(findPlatformsDrawer().props()).toEqual({
+ open: false,
+ platform: DEFAULT_PLATFORM,
+ });
+ });
+
+ it('shows runner list button', () => {
+ expect(findBtn().attributes('href')).toBe(mockRunnersPath);
+ expect(findBtn().props('variant')).toBe('confirm');
+ });
+ });
+ });
+
+ describe('When another platform has been selected', () => {
+ beforeEach(() => {
+ setWindowLocation(`?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`);
+
+ createComponent();
+ });
+
+ it('shows registration instructions for the platform', () => {
+ expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM);
+ });
+ });
+
+ describe('When opening install instructions', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findRegistrationInstructions().vm.$emit('toggleDrawer');
+ });
+
+ it('opens platform drawer', () => {
+ expect(findPlatformsDrawer().props('open')).toBe(true);
+ });
+
+ it('closes platform drawer', async () => {
+ findRegistrationInstructions().vm.$emit('toggleDrawer');
+ await nextTick();
+
+ expect(findPlatformsDrawer().props('open')).toBe(false);
+ });
+
+ it('closes platform drawer from drawer', async () => {
+ findPlatformsDrawer().vm.$emit('close');
+ await nextTick();
+
+ expect(findPlatformsDrawer().props('open')).toBe(false);
+ });
+
+ describe('when selecting a platform', () => {
+ beforeEach(() => {
+ findPlatformsDrawer().vm.$emit('selectPlatform', WINDOWS_PLATFORM);
+ });
+
+ it('updates the url', () => {
+ expect(updateHistory).toHaveBeenCalledTimes(1);
+ expect(updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`,
+ });
+ });
+
+ it('updates the registration instructions', () => {
+ expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js b/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js
index a9369a5e626..79bbf95f8f0 100644
--- a/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js
+++ b/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/ci/runner/components/runner_header.vue';
@@ -15,7 +15,7 @@ import { I18N_STATUS_NEVER_CONTACTED, I18N_INSTANCE_TYPE } from '~/ci/runner/con
import { runnerFormData } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
const mockRunner = runnerFormData.data.runner;
@@ -51,7 +51,6 @@ describe('RunnerEditApp', () => {
afterEach(() => {
mockRunnerQuery.mockReset();
- wrapper.destroy();
});
it('expect GraphQL ID to be requested', async () => {
diff --git a/spec/frontend/ci/runner/runner_search_utils_spec.js b/spec/frontend/ci/runner/runner_search_utils_spec.js
index f64b89d47fd..9a4a6139198 100644
--- a/spec/frontend/ci/runner/runner_search_utils_spec.js
+++ b/spec/frontend/ci/runner/runner_search_utils_spec.js
@@ -7,6 +7,7 @@ import {
isSearchFiltered,
} from 'ee_else_ce/ci/runner/runner_search_utils';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import { DEFAULT_SORT } from '~/ci/runner/constants';
import { mockSearchExamples } from './mock_data';
describe('search_params.js', () => {
@@ -68,7 +69,7 @@ describe('search_params.js', () => {
'http://test.host/?paused[]=true',
'http://test.host/?search=my_text',
])('When a filter is removed, it is removed from the URL', (initialUrl) => {
- const search = { filters: [], sort: 'CREATED_DESC' };
+ const search = { filters: [], sort: DEFAULT_SORT };
const expectedUrl = `http://test.host/`;
expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl);
@@ -76,7 +77,7 @@ describe('search_params.js', () => {
it('When unrelated search parameter is present, it does not get removed', () => {
const initialUrl = `http://test.host/?unrelated=UNRELATED&status[]=ACTIVE`;
- const search = { filters: [], sort: 'CREATED_DESC' };
+ const search = { filters: [], sort: DEFAULT_SORT };
const expectedUrl = `http://test.host/?unrelated=UNRELATED`;
expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl);
diff --git a/spec/frontend/ci/runner/sentry_utils_spec.js b/spec/frontend/ci/runner/sentry_utils_spec.js
index f7b689272ce..2f17cc43ac5 100644
--- a/spec/frontend/ci/runner/sentry_utils_spec.js
+++ b/spec/frontend/ci/runner/sentry_utils_spec.js
@@ -6,7 +6,7 @@ jest.mock('@sentry/browser');
describe('~/ci/runner/sentry_utils', () => {
let mockSetTag;
- beforeEach(async () => {
+ beforeEach(() => {
mockSetTag = jest.fn();
Sentry.withScope.mockImplementation((fn) => {
diff --git a/spec/frontend/ci_secure_files/components/metadata/__snapshots__/modal_spec.js.snap b/spec/frontend/ci_secure_files/components/metadata/__snapshots__/modal_spec.js.snap
index b2084e3a7de..79194c20ff5 100644
--- a/spec/frontend/ci_secure_files/components/metadata/__snapshots__/modal_spec.js.snap
+++ b/spec/frontend/ci_secure_files/components/metadata/__snapshots__/modal_spec.js.snap
@@ -15,7 +15,7 @@ exports[`Secure File Metadata Modal when a .cer file is supplied matches cer the
>
<table
- aria-busy="false"
+ aria-busy=""
aria-colcount="2"
class="table b-table gl-table"
role="table"
@@ -168,7 +168,7 @@ exports[`Secure File Metadata Modal when a .cer file is supplied matches cer the
role="cell"
>
- April 26, 2022 at 7:20:40 PM GMT
+ April 26, 2023 at 7:20:39 PM GMT
</td>
</tr>
@@ -196,7 +196,7 @@ exports[`Secure File Metadata Modal when a .mobileprovision file is supplied mat
>
<table
- aria-busy="false"
+ aria-busy=""
aria-colcount="2"
class="table b-table gl-table"
role="table"
diff --git a/spec/frontend/ci_secure_files/components/metadata/button_spec.js b/spec/frontend/ci_secure_files/components/metadata/button_spec.js
index 4ac5b3325d4..5bd4bab25af 100644
--- a/spec/frontend/ci_secure_files/components/metadata/button_spec.js
+++ b/spec/frontend/ci_secure_files/components/metadata/button_spec.js
@@ -12,10 +12,6 @@ describe('Secure File Metadata Button', () => {
const findButton = () => wrapper.findComponent(GlButton);
- afterEach(() => {
- wrapper.destroy();
- });
-
const createWrapper = (secureFile = {}, admin = false) => {
wrapper = mount(Button, {
propsData: {
diff --git a/spec/frontend/ci_secure_files/components/metadata/modal_spec.js b/spec/frontend/ci_secure_files/components/metadata/modal_spec.js
index 230507d32d7..e181d15f2f9 100644
--- a/spec/frontend/ci_secure_files/components/metadata/modal_spec.js
+++ b/spec/frontend/ci_secure_files/components/metadata/modal_spec.js
@@ -37,7 +37,6 @@ describe('Secure File Metadata Modal', () => {
afterEach(() => {
unmockTracking();
- wrapper.destroy();
});
describe('when a .cer file is supplied', () => {
diff --git a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
index ab6200ca6f4..17b5fdc4dde 100644
--- a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
+++ b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
@@ -15,11 +15,6 @@ const dummyApiVersion = 'v3000';
const dummyProjectId = 1;
const fileSizeLimit = 5;
const dummyUrlRoot = '/gitlab';
-const dummyGon = {
- api_version: dummyApiVersion,
- relative_url_root: dummyUrlRoot,
-};
-let originalGon;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProjectId}/secure_files`;
describe('SecureFilesList', () => {
@@ -28,16 +23,16 @@ describe('SecureFilesList', () => {
let trackingSpy;
beforeEach(() => {
- originalGon = window.gon;
trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
- window.gon = { ...dummyGon };
+ window.gon = {
+ api_version: dummyApiVersion,
+ relative_url_root: dummyUrlRoot,
+ };
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
unmockTracking();
- window.gon = originalGon;
});
const createWrapper = (admin = true, props = {}) => {
diff --git a/spec/frontend/ci_secure_files/mock_data.js b/spec/frontend/ci_secure_files/mock_data.js
index f532b468fb9..b3db0f7dd64 100644
--- a/spec/frontend/ci_secure_files/mock_data.js
+++ b/spec/frontend/ci_secure_files/mock_data.js
@@ -24,7 +24,7 @@ export const secureFiles = [
checksum_algorithm: 'sha256',
created_at: '2022-02-22T22:22:22.222Z',
file_extension: 'cer',
- expires_at: '2022-04-26T19:20:40.000Z',
+ expires_at: '2023-04-26T19:20:39.000Z',
metadata: {
id: '33669367788748363528491290218354043267',
issuer: {
@@ -40,7 +40,7 @@ export const secureFiles = [
OU: 'ABC123XYZ',
UID: 'ABC123XYZ',
},
- expires_at: '2022-04-26T19:20:40.000Z',
+ expires_at: '2023-04-26T19:20:39.000Z',
},
},
{
diff --git a/spec/frontend/clusters/agents/components/activity_events_list_spec.js b/spec/frontend/clusters/agents/components/activity_events_list_spec.js
index 6b374b6620d..770815a9403 100644
--- a/spec/frontend/clusters/agents/components/activity_events_list_spec.js
+++ b/spec/frontend/clusters/agents/components/activity_events_list_spec.js
@@ -44,10 +44,6 @@ describe('ActivityEvents', () => {
const findAllActivityHistoryItems = () => wrapper.findAllComponents(ActivityHistoryItem);
const findSectionTitle = (at) => wrapper.findAllByTestId('activity-section-title').at(at);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('while the agentEvents query is loading', () => {
it('displays a loading icon', async () => {
createWrapper();
diff --git a/spec/frontend/clusters/agents/components/activity_history_item_spec.js b/spec/frontend/clusters/agents/components/activity_history_item_spec.js
index 68f6f11aa8f..48460519c6c 100644
--- a/spec/frontend/clusters/agents/components/activity_history_item_spec.js
+++ b/spec/frontend/clusters/agents/components/activity_history_item_spec.js
@@ -25,10 +25,6 @@ describe('ActivityHistoryItem', () => {
const findHistoryItem = () => wrapper.findComponent(HistoryItem);
const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each`
kind | icon | title | lineNumber
${'token_created'} | ${EVENT_DETAILS.token_created.eventTypeIcon} | ${sprintf(EVENT_DETAILS.token_created.title, { tokenName: agentName })} | ${0}
diff --git a/spec/frontend/clusters/agents/components/agent_integration_status_row_spec.js b/spec/frontend/clusters/agents/components/agent_integration_status_row_spec.js
index db1219ccb41..ac0ce89f334 100644
--- a/spec/frontend/clusters/agents/components/agent_integration_status_row_spec.js
+++ b/spec/frontend/clusters/agents/components/agent_integration_status_row_spec.js
@@ -25,10 +25,6 @@ describe('IntegrationStatus', () => {
const findIcon = () => wrapper.findComponent(GlIcon);
const findBadge = () => wrapper.findComponent(GlBadge);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('icon', () => {
const icon = 'status-success';
const iconClass = 'gl-text-green-500';
diff --git a/spec/frontend/clusters/agents/components/create_token_button_spec.js b/spec/frontend/clusters/agents/components/create_token_button_spec.js
index 73856b74a8d..2bbde33d6f4 100644
--- a/spec/frontend/clusters/agents/components/create_token_button_spec.js
+++ b/spec/frontend/clusters/agents/components/create_token_button_spec.js
@@ -21,7 +21,7 @@ describe('CreateTokenButton', () => {
...provideData,
},
directives: {
- GlModalDirective: createMockDirective(),
+ GlModalDirective: createMockDirective('gl-modal-directive'),
},
stubs: {
GlTooltip,
@@ -29,10 +29,6 @@ describe('CreateTokenButton', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when user can create token', () => {
beforeEach(() => {
createWrapper();
@@ -59,7 +55,7 @@ describe('CreateTokenButton', () => {
});
it('disabled the button', () => {
- expect(findButton().attributes('disabled')).toBe('true');
+ expect(findButton().attributes('disabled')).toBeDefined();
});
it('shows a disabled tooltip', () => {
diff --git a/spec/frontend/clusters/agents/components/create_token_modal_spec.js b/spec/frontend/clusters/agents/components/create_token_modal_spec.js
index 0d10801e80e..f0fded7b7b2 100644
--- a/spec/frontend/clusters/agents/components/create_token_modal_spec.js
+++ b/spec/frontend/clusters/agents/components/create_token_modal_spec.js
@@ -9,7 +9,6 @@ import {
EVENT_LABEL_MODAL,
EVENT_ACTIONS_OPEN,
TOKEN_NAME_LIMIT,
- TOKEN_STATUS_ACTIVE,
MAX_LIST_COUNT,
CREATE_TOKEN_MODAL,
} from '~/clusters/agents/constants';
@@ -62,7 +61,7 @@ describe('CreateTokenModal', () => {
const expectDisabledAttribute = (element, disabled) => {
if (disabled) {
- expect(element.attributes('disabled')).toBe('true');
+ expect(element.attributes('disabled')).toBeDefined();
} else {
expect(element.attributes('disabled')).toBeUndefined();
}
@@ -81,7 +80,6 @@ describe('CreateTokenModal', () => {
variables: {
agentName,
projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
...cursor,
},
});
@@ -119,7 +117,6 @@ describe('CreateTokenModal', () => {
});
afterEach(() => {
- wrapper.destroy();
apolloProvider = null;
createResponse = null;
});
@@ -214,7 +211,7 @@ describe('CreateTokenModal', () => {
await mockCreatedResponse(createAgentTokenErrorResponse);
});
- it('displays the error message', async () => {
+ it('displays the error message', () => {
expect(findAlert().text()).toBe(
createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0],
);
diff --git a/spec/frontend/clusters/agents/components/integration_status_spec.js b/spec/frontend/clusters/agents/components/integration_status_spec.js
index 36f0e622452..28a59391578 100644
--- a/spec/frontend/clusters/agents/components/integration_status_spec.js
+++ b/spec/frontend/clusters/agents/components/integration_status_spec.js
@@ -27,10 +27,6 @@ describe('IntegrationStatus', () => {
const findAgentStatus = () => wrapper.findByTestId('agent-status');
const findAgentIntegrationStatusRows = () => wrapper.findAllComponents(AgentIntegrationStatusRow);
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
lastUsedAt | status | iconName
${null} | ${'Never connected'} | ${'status-neutral'}
diff --git a/spec/frontend/clusters/agents/components/revoke_token_button_spec.js b/spec/frontend/clusters/agents/components/revoke_token_button_spec.js
index 6521221cbd7..970782a8e58 100644
--- a/spec/frontend/clusters/agents/components/revoke_token_button_spec.js
+++ b/spec/frontend/clusters/agents/components/revoke_token_button_spec.js
@@ -8,7 +8,7 @@ import { ENTER_KEY } from '~/lib/utils/keys';
import RevokeTokenButton from '~/clusters/agents/components/revoke_token_button.vue';
import getClusterAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql';
import revokeTokenMutation from '~/clusters/agents/graphql/mutations/revoke_token.mutation.graphql';
-import { TOKEN_STATUS_ACTIVE, MAX_LIST_COUNT } from '~/clusters/agents/constants';
+import { MAX_LIST_COUNT } from '~/clusters/agents/constants';
import { getTokenResponse, mockRevokeResponse, mockErrorRevokeResponse } from '../../mock_data';
Vue.use(VueApollo);
@@ -45,7 +45,7 @@ describe('RevokeTokenButton', () => {
const findInput = () => wrapper.findComponent(GlFormInput);
const findTooltip = () => wrapper.findComponent(GlTooltip);
const findPrimaryAction = () => findModal().props('actionPrimary');
- const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr];
+ const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[attr];
const createMockApolloProvider = ({ mutationResponse }) => {
revokeSpy = jest.fn().mockResolvedValue(mutationResponse);
@@ -59,7 +59,6 @@ describe('RevokeTokenButton', () => {
variables: {
agentName,
projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
...cursor,
},
data: getTokenResponse.data,
@@ -105,7 +104,6 @@ describe('RevokeTokenButton', () => {
});
afterEach(() => {
- wrapper.destroy();
apolloProvider = null;
revokeSpy = null;
});
@@ -121,7 +119,7 @@ describe('RevokeTokenButton', () => {
});
it('disabled the button', () => {
- expect(findRevokeBtn().attributes('disabled')).toBe('true');
+ expect(findRevokeBtn().attributes('disabled')).toBeDefined();
});
it('shows a disabled tooltip', () => {
@@ -219,7 +217,7 @@ describe('RevokeTokenButton', () => {
it('reenables the button', async () => {
expect(findPrimaryActionAttributes('loading')).toBe(true);
- expect(findRevokeBtn().attributes('disabled')).toBe('true');
+ expect(findRevokeBtn().attributes('disabled')).toBeDefined();
await findModal().vm.$emit('hide');
diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js
index efa85136b17..019f789d875 100644
--- a/spec/frontend/clusters/agents/components/show_spec.js
+++ b/spec/frontend/clusters/agents/components/show_spec.js
@@ -12,7 +12,7 @@ import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { MAX_LIST_COUNT, TOKEN_STATUS_ACTIVE } from '~/clusters/agents/constants';
+import { MAX_LIST_COUNT } from '~/clusters/agents/constants';
const localVue = createLocalVue();
localVue.use(VueApollo);
@@ -79,10 +79,6 @@ describe('ClusterAgentShow', () => {
const findActivity = () => wrapper.findComponent(ActivityEvents);
const findIntegrationStatus = () => wrapper.findComponent(IntegrationStatus);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default behaviour', () => {
beforeEach(async () => {
createWrapper({ clusterAgent: defaultClusterAgent });
@@ -93,7 +89,6 @@ describe('ClusterAgentShow', () => {
const variables = {
agentName: provide.agentName,
projectPath: provide.projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
first: MAX_LIST_COUNT,
last: null,
};
diff --git a/spec/frontend/clusters/agents/components/token_table_spec.js b/spec/frontend/clusters/agents/components/token_table_spec.js
index 334615f1818..1a6aeedb694 100644
--- a/spec/frontend/clusters/agents/components/token_table_spec.js
+++ b/spec/frontend/clusters/agents/components/token_table_spec.js
@@ -57,10 +57,6 @@ describe('ClusterAgentTokenTable', () => {
return createComponent(defaultTokens);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays the create token button', () => {
expect(findCreateTokenBtn().exists()).toBe(true);
});
diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js
index a2ec19c5b4a..d657566713f 100644
--- a/spec/frontend/clusters/clusters_bundle_spec.js
+++ b/spec/frontend/clusters/clusters_bundle_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlShowCluster from 'test_fixtures/clusters/show_cluster.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import Clusters from '~/clusters/clusters_bundle';
import axios from '~/lib/utils/axios_utils';
@@ -24,7 +25,7 @@ describe('Clusters', () => {
};
beforeEach(() => {
- loadHTMLFixture('clusters/show_cluster.html');
+ setHTMLFixture(htmlShowCluster);
mockGetClusterStatusRequest();
diff --git a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
index 656e72baf77..21ffda8578a 100644
--- a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
+++ b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
@@ -3,7 +3,7 @@
exports[`NewCluster renders the cluster component correctly 1`] = `
"<div class=\\"gl-pt-4\\">
<h4>Enter your Kubernetes cluster certificate details</h4>
- <p>Enter details about your cluster. <b-link-stub href=\\"/help/user/project/clusters/add_existing_cluster\\" event=\\"click\\" routertag=\\"a\\" class=\\"gl-link\\">How do I use a certificate to connect to my cluster?</b-link-stub>
+ <p>Enter details about your cluster. <b-link-stub href=\\"/help/user/project/clusters/add_existing_cluster\\" class=\\"gl-link\\">How do I use a certificate to connect to my cluster?</b-link-stub>
</p>
</div>"
`;
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 46ee123a12d..67b0ecdf7eb 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
@@ -43,3 +43,212 @@ exports[`Remove cluster confirmation modal renders buttons with modal included 1
<!---->
</div>
`;
+
+exports[`Remove cluster confirmation modal two buttons open modal with "cleanup" option 1`] = `
+<div
+ class="gl-display-flex"
+>
+ <button
+ class="btn gl-mr-3 btn-danger btn-md gl-button"
+ data-testid="remove-integration-and-resources-button"
+ type="button"
+ >
+ <!---->
+
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+
+ Remove integration and resources
+
+ </span>
+ </button>
+
+ <button
+ class="btn btn-danger btn-md gl-button btn-danger-secondary"
+ data-testid="remove-integration-button"
+ type="button"
+ >
+ <!---->
+
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+
+ Remove integration
+
+ </span>
+ </button>
+
+ <div
+ kind="danger"
+ >
+ <p>
+ You are about to remove your cluster integration and all GitLab-created resources associated with this cluster.
+ </p>
+
+ <div>
+
+ This will permanently delete the following resources:
+
+ <ul>
+ <li>
+ Any project namespaces
+ </li>
+
+ <li>
+ <code>
+ clusterroles
+ </code>
+ </li>
+
+ <li>
+ <code>
+ clusterrolebindings
+ </code>
+ </li>
+ </ul>
+ </div>
+
+ <strong>
+ To remove your integration and resources, type
+ <code>
+ my-test-cluster
+ </code>
+ to confirm:
+ </strong>
+
+ <form
+ action="clusterPath"
+ class="gl-mb-5"
+ method="post"
+ >
+ <input
+ name="_method"
+ type="hidden"
+ value="delete"
+ />
+
+ <input
+ name="authenticity_token"
+ type="hidden"
+ />
+
+ <input
+ name="cleanup"
+ type="hidden"
+ value="true"
+ />
+
+ <input
+ autocomplete="off"
+ class="gl-form-input form-control"
+ id="__BVID__14"
+ name="confirm_cluster_name_input"
+ type="text"
+ />
+ </form>
+
+ <span>
+ If you do not wish to delete all associated GitLab resources, you can simply remove the integration.
+ </span>
+ </div>
+</div>
+`;
+
+exports[`Remove cluster confirmation modal two buttons open modal without "cleanup" option 1`] = `
+<div
+ class="gl-display-flex"
+>
+ <button
+ class="btn gl-mr-3 btn-danger btn-md gl-button"
+ data-testid="remove-integration-and-resources-button"
+ type="button"
+ >
+ <!---->
+
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+
+ Remove integration and resources
+
+ </span>
+ </button>
+
+ <button
+ class="btn btn-danger btn-md gl-button btn-danger-secondary"
+ data-testid="remove-integration-button"
+ type="button"
+ >
+ <!---->
+
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+
+ Remove integration
+
+ </span>
+ </button>
+
+ <div
+ kind="danger"
+ >
+ <p>
+ You are about to remove your cluster integration.
+ </p>
+
+ <!---->
+
+ <strong>
+ To remove your integration, type
+ <code>
+ my-test-cluster
+ </code>
+ to confirm:
+ </strong>
+
+ <form
+ action="clusterPath"
+ class="gl-mb-5"
+ method="post"
+ >
+ <input
+ name="_method"
+ type="hidden"
+ value="delete"
+ />
+
+ <input
+ name="authenticity_token"
+ type="hidden"
+ />
+
+ <input
+ name="cleanup"
+ type="hidden"
+ value="true"
+ />
+
+ <input
+ autocomplete="off"
+ class="gl-form-input form-control"
+ id="__BVID__21"
+ name="confirm_cluster_name_input"
+ type="text"
+ />
+ </form>
+
+ <!---->
+ </div>
+</div>
+`;
diff --git a/spec/frontend/clusters/components/new_cluster_spec.js b/spec/frontend/clusters/components/new_cluster_spec.js
index ef39c90aaef..398b472a3a7 100644
--- a/spec/frontend/clusters/components/new_cluster_spec.js
+++ b/spec/frontend/clusters/components/new_cluster_spec.js
@@ -20,10 +20,6 @@ describe('NewCluster', () => {
return createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the cluster component correctly', () => {
expect(wrapper.html()).toMatchSnapshot();
});
diff --git a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js
index 53683af893a..04b7909b534 100644
--- a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js
+++ b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js
@@ -6,6 +6,7 @@ import RemoveClusterConfirmation from '~/clusters/components/remove_cluster_conf
describe('Remove cluster confirmation modal', () => {
let wrapper;
+ const showMock = jest.fn();
const createComponent = ({ props = {}, stubs = {} } = {}) => {
wrapper = mount(RemoveClusterConfirmation, {
@@ -18,11 +19,6 @@ describe('Remove cluster confirmation modal', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders buttons with modal included', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
@@ -38,9 +34,13 @@ describe('Remove cluster confirmation modal', () => {
beforeEach(() => {
createComponent({
props: { clusterName: 'my-test-cluster' },
- stubs: { GlSprintf, GlModal: stubComponent(GlModal) },
+ stubs: {
+ GlSprintf,
+ GlModal: stubComponent(GlModal, {
+ methods: { show: showMock },
+ }),
+ },
});
- jest.spyOn(findModal().vm, 'show').mockReturnValue();
});
it('open modal with "cleanup" option', async () => {
@@ -48,8 +48,8 @@ describe('Remove cluster confirmation modal', () => {
await nextTick();
- expect(findModal().vm.show).toHaveBeenCalled();
- expect(wrapper.vm.confirmCleanup).toEqual(true);
+ expect(showMock).toHaveBeenCalled();
+ expect(wrapper.element).toMatchSnapshot();
expect(findModal().html()).toContain(
'<strong>To remove your integration and resources, type <code>my-test-cluster</code> to confirm:</strong>',
);
@@ -60,8 +60,8 @@ describe('Remove cluster confirmation modal', () => {
await nextTick();
- expect(findModal().vm.show).toHaveBeenCalled();
- expect(wrapper.vm.confirmCleanup).toEqual(false);
+ expect(showMock).toHaveBeenCalled();
+ expect(wrapper.element).toMatchSnapshot();
expect(findModal().html()).toContain(
'<strong>To remove your integration, type <code>my-test-cluster</code> to confirm:</strong>',
);
diff --git a/spec/frontend/clusters/forms/components/integration_form_spec.js b/spec/frontend/clusters/forms/components/integration_form_spec.js
index b17886a5826..396f8215b9f 100644
--- a/spec/frontend/clusters/forms/components/integration_form_spec.js
+++ b/spec/frontend/clusters/forms/components/integration_form_spec.js
@@ -1,6 +1,6 @@
import { GlToggle, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import Vuex from 'vuex';
import IntegrationForm from '~/clusters/forms/components/integration_form.vue';
import { createStore } from '~/clusters/forms/stores/index';
@@ -27,17 +27,9 @@ describe('ClusterIntegrationForm', () => {
});
};
- const destroyWrapper = () => {
- wrapper.destroy();
- wrapper = null;
- };
-
const findSubmitButton = () => wrapper.findComponent(GlButton);
const findGlToggle = () => wrapper.findComponent(GlToggle);
-
- afterEach(() => {
- destroyWrapper();
- });
+ const findClusterEnvironmentScopeInput = () => wrapper.find('[id="cluster_environment_scope"]');
describe('rendering', () => {
beforeEach(() => createWrapper());
@@ -50,7 +42,9 @@ describe('ClusterIntegrationForm', () => {
});
it('sets the envScope to default', () => {
- expect(wrapper.find('[id="cluster_environment_scope"]').attributes('value')).toBe('*');
+ expect(findClusterEnvironmentScopeInput().attributes('value')).toBe(
+ defaultStoreValues.environmentScope,
+ );
});
it('sets the baseDomain to default', () => {
@@ -76,20 +70,15 @@ describe('ClusterIntegrationForm', () => {
beforeEach(() => createWrapper());
it('enables the submit button on changing toggle to different value', async () => {
- await nextTick();
- // setData is a bad approach because it changes the internal implementation which we should not touch
- // but our GlFormInput lacks the ability to set a new value.
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({ toggleEnabled: !defaultStoreValues.enabled });
+ await findGlToggle().vm.$emit('change', false);
expect(findSubmitButton().props('disabled')).toBe(false);
});
it('enables the submit button on changing input values', async () => {
- await nextTick();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({ envScope: `${defaultStoreValues.environmentScope}1` });
+ await findClusterEnvironmentScopeInput().vm.$emit(
+ 'input',
+ `${defaultStoreValues.environmentScope}1`,
+ );
expect(findSubmitButton().props('disabled')).toBe(false);
});
});
diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js
index 22775aa6603..2e52d16c739 100644
--- a/spec/frontend/clusters_list/components/agent_empty_state_spec.js
+++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js
@@ -22,12 +22,6 @@ describe('AgentEmptyStateComponent', () => {
});
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
it('renders the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js
index 9cbb83eedd2..0f68a69458e 100644
--- a/spec/frontend/clusters_list/components/agent_table_spec.js
+++ b/spec/frontend/clusters_list/components/agent_table_spec.js
@@ -13,9 +13,9 @@ const defaultConfigHelpUrl =
const provideData = {
gitlabVersion: '14.8',
- kasVersion: '14.8',
+ kasVersion: '14.8.0',
};
-const propsData = {
+const defaultProps = {
agents: clusterAgents,
};
@@ -26,9 +26,6 @@ const DeleteAgentButtonStub = stubComponent(DeleteAgentButton, {
const outdatedTitle = I18N_AGENT_TABLE.versionOutdatedTitle;
const mismatchTitle = I18N_AGENT_TABLE.versionMismatchTitle;
const mismatchOutdatedTitle = I18N_AGENT_TABLE.versionMismatchOutdatedTitle;
-const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, {
- version: provideData.kasVersion,
-});
const mismatchText = I18N_AGENT_TABLE.versionMismatchText;
describe('AgentTable', () => {
@@ -39,127 +36,150 @@ describe('AgentTable', () => {
const findStatusIcon = (at) => findStatusText(at).findComponent(GlIcon);
const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at);
const findVersionText = (at) => wrapper.findAllByTestId('cluster-agent-version').at(at);
+ const findAgentId = (at) => wrapper.findAllByTestId('cluster-agent-id').at(at);
const findConfiguration = (at) =>
wrapper.findAllByTestId('cluster-agent-configuration-link').at(at);
const findDeleteAgentButton = () => wrapper.findAllComponents(DeleteAgentButton);
- beforeEach(() => {
+ const createWrapper = ({ provide = provideData, propsData = defaultProps } = {}) => {
wrapper = mountExtended(AgentTable, {
propsData,
- provide: provideData,
+ provide,
stubs: {
DeleteAgentButton: DeleteAgentButtonStub,
},
});
- });
-
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
+ };
describe('agent table', () => {
- it.each`
- agentName | link | lineNumber
- ${'agent-1'} | ${'/agent-1'} | ${0}
- ${'agent-2'} | ${'/agent-2'} | ${1}
- `('displays agent link for $agentName', ({ agentName, link, lineNumber }) => {
- expect(findAgentLink(lineNumber).text()).toBe(agentName);
- expect(findAgentLink(lineNumber).attributes('href')).toBe(link);
+ describe('default', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it.each`
+ agentName | link | lineNumber
+ ${'agent-1'} | ${'/agent-1'} | ${0}
+ ${'agent-2'} | ${'/agent-2'} | ${1}
+ `('displays agent link for $agentName', ({ agentName, link, lineNumber }) => {
+ expect(findAgentLink(lineNumber).text()).toBe(agentName);
+ expect(findAgentLink(lineNumber).attributes('href')).toBe(link);
+ });
+
+ it.each`
+ agentGraphQLId | agentId | lineNumber
+ ${'gid://gitlab/Clusters::Agent/1'} | ${'1'} | ${0}
+ ${'gid://gitlab/Clusters::Agent/2'} | ${'2'} | ${1}
+ `(
+ 'displays agent id as "$agentId" for "$agentGraphQLId" at line $lineNumber',
+ ({ agentId, lineNumber }) => {
+ expect(findAgentId(lineNumber).text()).toBe(agentId);
+ },
+ );
+
+ it.each`
+ status | iconName | lineNumber
+ ${'Never connected'} | ${'status-neutral'} | ${0}
+ ${'Connected'} | ${'status-success'} | ${1}
+ ${'Not connected'} | ${'status-alert'} | ${2}
+ `(
+ 'displays agent connection status as "$status" at line $lineNumber',
+ ({ status, iconName, lineNumber }) => {
+ expect(findStatusText(lineNumber).text()).toBe(status);
+ expect(findStatusIcon(lineNumber).props('name')).toBe(iconName);
+ },
+ );
+
+ it.each`
+ lastContact | lineNumber
+ ${'Never'} | ${0}
+ ${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1}
+ ${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2}
+ `(
+ 'displays agent last contact time as "$lastContact" at line $lineNumber',
+ ({ lastContact, lineNumber }) => {
+ expect(findLastContactText(lineNumber).text()).toBe(lastContact);
+ },
+ );
+
+ it.each`
+ agentConfig | link | lineNumber
+ ${'.gitlab/agents/agent-1'} | ${'/agent/full/path'} | ${0}
+ ${'Default configuration'} | ${defaultConfigHelpUrl} | ${1}
+ `(
+ 'displays config file path as "$agentPath" at line $lineNumber',
+ ({ agentConfig, link, lineNumber }) => {
+ const findLink = findConfiguration(lineNumber).findComponent(GlLink);
+
+ expect(findLink.attributes('href')).toBe(link);
+ expect(findConfiguration(lineNumber).text()).toBe(agentConfig);
+ },
+ );
+
+ it('displays actions menu for each agent', () => {
+ expect(findDeleteAgentButton()).toHaveLength(clusterAgents.length);
+ });
});
- it.each`
- status | iconName | lineNumber
- ${'Never connected'} | ${'status-neutral'} | ${0}
- ${'Connected'} | ${'status-success'} | ${1}
- ${'Not connected'} | ${'status-alert'} | ${2}
+ describe.each`
+ agentMockIdx | agentVersion | kasVersion | versionMismatch | versionOutdated | title
+ ${0} | ${''} | ${'14.8.0'} | ${false} | ${false} | ${''}
+ ${1} | ${'14.8.0'} | ${'14.8.0'} | ${false} | ${false} | ${''}
+ ${2} | ${'14.6.0'} | ${'14.8.0'} | ${false} | ${true} | ${outdatedTitle}
+ ${3} | ${'14.7.0'} | ${'14.8.0'} | ${true} | ${false} | ${mismatchTitle}
+ ${4} | ${'14.3.0'} | ${'14.8.0'} | ${true} | ${true} | ${mismatchOutdatedTitle}
+ ${5} | ${'14.6.0'} | ${'14.8.0-rc1'} | ${false} | ${false} | ${''}
+ ${6} | ${'14.8.0'} | ${'15.0.0'} | ${false} | ${true} | ${outdatedTitle}
+ ${7} | ${'14.8.0'} | ${'15.0.0-rc1'} | ${false} | ${true} | ${outdatedTitle}
+ ${8} | ${'14.8.0'} | ${'14.8.10'} | ${false} | ${false} | ${''}
`(
- 'displays agent connection status as "$status" at line $lineNumber',
- ({ status, iconName, lineNumber }) => {
- expect(findStatusText(lineNumber).text()).toBe(status);
- expect(findStatusIcon(lineNumber).props('name')).toBe(iconName);
- },
- );
+ 'when agent version is "$agentVersion", KAS version is "$kasVersion" and version mismatch is "$versionMismatch"',
+ ({ agentMockIdx, agentVersion, kasVersion, versionMismatch, versionOutdated, title }) => {
+ const currentAgent = clusterAgents[agentMockIdx];
- it.each`
- lastContact | lineNumber
- ${'Never'} | ${0}
- ${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1}
- ${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2}
- `(
- 'displays agent last contact time as "$lastContact" at line $lineNumber',
- ({ lastContact, lineNumber }) => {
- expect(findLastContactText(lineNumber).text()).toBe(lastContact);
- },
- );
+ const findIcon = () => findVersionText(0).findComponent(GlIcon);
+ const findPopover = () => wrapper.findByTestId(`popover-${currentAgent.name}`);
- describe.each`
- agent | version | podsNumber | versionMismatch | versionOutdated | title | texts | lineNumber
- ${'agent-1'} | ${''} | ${1} | ${false} | ${false} | ${''} | ${''} | ${0}
- ${'agent-2'} | ${'14.8'} | ${2} | ${false} | ${false} | ${''} | ${''} | ${1}
- ${'agent-3'} | ${'14.5'} | ${1} | ${false} | ${true} | ${outdatedTitle} | ${[outdatedText]} | ${2}
- ${'agent-4'} | ${'14.7'} | ${2} | ${true} | ${false} | ${mismatchTitle} | ${[mismatchText]} | ${3}
- ${'agent-5'} | ${'14.3'} | ${2} | ${true} | ${true} | ${mismatchOutdatedTitle} | ${[mismatchText, outdatedText]} | ${4}
- `(
- 'agent version column at line $lineNumber',
- ({
- agent,
- version,
- podsNumber,
- versionMismatch,
- versionOutdated,
- title,
- texts,
- lineNumber,
- }) => {
- const findIcon = () => findVersionText(lineNumber).findComponent(GlIcon);
- const findPopover = () => wrapper.findByTestId(`popover-${agent}`);
const versionWarning = versionMismatch || versionOutdated;
+ const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, {
+ version: kasVersion,
+ });
- it('shows the correct agent version', () => {
- expect(findVersionText(lineNumber).text()).toBe(version);
+ beforeEach(() => {
+ createWrapper({
+ provide: { gitlabVersion: '14.8', kasVersion },
+ propsData: { agents: [currentAgent] },
+ });
+ });
+
+ it('shows the correct agent version text', () => {
+ expect(findVersionText(0).text()).toBe(agentVersion);
});
if (versionWarning) {
- it(`shows a warning icon when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated} and the number of pods is ${podsNumber}`, () => {
+ it('shows a warning icon', () => {
expect(findIcon().props('name')).toBe('warning');
});
-
it(`renders correct title for the popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => {
expect(findPopover().props('title')).toBe(title);
});
-
- it(`renders correct text for the popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => {
- texts.forEach((text) => {
- expect(findPopover().text()).toContain(text);
+ if (versionMismatch) {
+ it(`renders correct text for the popover when agent versions mismatch is ${versionMismatch}`, () => {
+ expect(findPopover().text()).toContain(mismatchText);
});
- });
+ }
+ if (versionOutdated) {
+ it(`renders correct text for the popover when agent versions outdated is ${versionOutdated}`, () => {
+ expect(findPopover().text()).toContain(outdatedText);
+ });
+ }
} else {
- it(`doesn't show a warning icon with a popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated} and the number of pods is ${podsNumber}`, () => {
+ it(`doesn't show a warning icon with a popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => {
expect(findIcon().exists()).toBe(false);
expect(findPopover().exists()).toBe(false);
});
}
},
);
-
- it.each`
- agentConfig | link | lineNumber
- ${'.gitlab/agents/agent-1'} | ${'/agent/full/path'} | ${0}
- ${'Default configuration'} | ${defaultConfigHelpUrl} | ${1}
- `(
- 'displays config file path as "$agentPath" at line $lineNumber',
- ({ agentConfig, link, lineNumber }) => {
- const findLink = findConfiguration(lineNumber).findComponent(GlLink);
-
- expect(findLink.attributes('href')).toBe(link);
- expect(findConfiguration(lineNumber).text()).toBe(agentConfig);
- },
- );
-
- it('displays actions menu for each agent', () => {
- expect(findDeleteAgentButton()).toHaveLength(5);
- });
});
});
diff --git a/spec/frontend/clusters_list/components/agent_token_spec.js b/spec/frontend/clusters_list/components/agent_token_spec.js
index a92a03fedb6..edb8b22d79e 100644
--- a/spec/frontend/clusters_list/components/agent_token_spec.js
+++ b/spec/frontend/clusters_list/components/agent_token_spec.js
@@ -53,10 +53,6 @@ describe('InstallAgentModal', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('initial state', () => {
it('shows basic agent installation instructions', () => {
expect(wrapper.text()).toContain(I18N_AGENT_TOKEN.basicInstallTitle);
diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js
index 2372ab30300..d91245ba9b4 100644
--- a/spec/frontend/clusters_list/components/agents_spec.js
+++ b/spec/frontend/clusters_list/components/agents_spec.js
@@ -83,8 +83,6 @@ describe('Agents', () => {
const findBanner = () => wrapper.findComponent(GlBanner);
afterEach(() => {
- wrapper.destroy();
-
localStorage.removeItem(AGENT_FEEDBACK_KEY);
});
diff --git a/spec/frontend/clusters_list/components/ancestor_notice_spec.js b/spec/frontend/clusters_list/components/ancestor_notice_spec.js
index 758f6586e1a..4a2effa3463 100644
--- a/spec/frontend/clusters_list/components/ancestor_notice_spec.js
+++ b/spec/frontend/clusters_list/components/ancestor_notice_spec.js
@@ -18,10 +18,6 @@ describe('ClustersAncestorNotice', () => {
return createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when cluster does not have ancestors', () => {
beforeEach(async () => {
store.state.hasAncestorClusters = false;
diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js
index f4ee3f93cb5..e4e1986f705 100644
--- a/spec/frontend/clusters_list/components/clusters_actions_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js
@@ -35,7 +35,7 @@ describe('ClustersActionsComponent', () => {
...provideData,
},
directives: {
- GlModalDirective: createMockDirective(),
+ GlModalDirective: createMockDirective('gl-modal-directive'),
},
});
};
@@ -44,9 +44,6 @@ describe('ClustersActionsComponent', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
describe('when the certificate based clusters are enabled', () => {
it('renders actions menu', () => {
expect(findDropdown().exists()).toBe(true);
diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
index 2c3a224f3c8..5a5006d24c4 100644
--- a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
@@ -21,10 +21,6 @@ describe('ClustersEmptyStateComponent', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when the help text is not provided', () => {
beforeEach(() => {
createWrapper();
diff --git a/spec/frontend/clusters_list/components/clusters_main_view_spec.js b/spec/frontend/clusters_list/components/clusters_main_view_spec.js
index 6f23ed47d2a..af8d3b59869 100644
--- a/spec/frontend/clusters_list/components/clusters_main_view_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_main_view_spec.js
@@ -40,10 +40,6 @@ describe('ClustersMainViewComponent', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findTabs = () => wrapper.findComponent(GlTabs);
const findAllTabs = () => wrapper.findAllComponents(GlTab);
const findGlTabAtIndex = (index) => findAllTabs().at(index);
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index 20dbff9df15..207bfddcb4f 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -75,7 +75,6 @@ describe('Clusters', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
captureException.mockRestore();
});
@@ -271,9 +270,7 @@ describe('Clusters', () => {
describe('when updating currentPage', () => {
beforeEach(() => {
mockPollingApi(HTTP_STATUS_OK, apiData, paginationHeader(totalSecondPage, perPage, 2));
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ currentPage: 2 });
+ findPaginatedButtons().vm.$emit('input', 2);
return axios.waitForAll();
});
diff --git a/spec/frontend/clusters_list/components/clusters_view_all_spec.js b/spec/frontend/clusters_list/components/clusters_view_all_spec.js
index b4eb9242003..e81b242dd90 100644
--- a/spec/frontend/clusters_list/components/clusters_view_all_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_view_all_spec.js
@@ -60,10 +60,6 @@ describe('ClustersViewAllComponent', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when agents and clusters are not loaded', () => {
const initialState = {
loadingClusters: true,
diff --git a/spec/frontend/clusters_list/components/delete_agent_button_spec.js b/spec/frontend/clusters_list/components/delete_agent_button_spec.js
index 82850b9dea4..2c9a6b11671 100644
--- a/spec/frontend/clusters_list/components/delete_agent_button_spec.js
+++ b/spec/frontend/clusters_list/components/delete_agent_button_spec.js
@@ -33,7 +33,7 @@ describe('DeleteAgentButton', () => {
const findDeleteBtn = () => wrapper.findComponent(GlButton);
const findInput = () => wrapper.findComponent(GlFormInput);
const findPrimaryAction = () => findModal().props('actionPrimary');
- const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr];
+ const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[attr];
const findDeleteAgentButtonTooltip = () => wrapper.findByTestId('delete-agent-button-tooltip');
const getTooltipText = (el) => {
const binding = getBinding(el, 'gl-tooltip');
@@ -84,7 +84,7 @@ describe('DeleteAgentButton', () => {
...provideData,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
propsData,
mocks: { $toast: { show: toast } },
@@ -108,7 +108,6 @@ describe('DeleteAgentButton', () => {
});
afterEach(() => {
- wrapper.destroy();
apolloProvider = null;
deleteResponse = null;
toast = null;
@@ -141,7 +140,7 @@ describe('DeleteAgentButton', () => {
});
it('disables the button', () => {
- expect(findDeleteBtn().attributes('disabled')).toBe('true');
+ expect(findDeleteBtn().attributes('disabled')).toBeDefined();
});
it('shows a disabled tooltip', () => {
@@ -231,7 +230,7 @@ describe('DeleteAgentButton', () => {
it('reenables the button', async () => {
expect(findPrimaryActionAttributes('loading')).toBe(true);
- expect(findDeleteBtn().attributes('disabled')).toBe('true');
+ expect(findDeleteBtn().attributes('disabled')).toBeDefined();
await findModal().vm.$emit('hide');
diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js
index 10264d6a011..e1306e2738f 100644
--- a/spec/frontend/clusters_list/components/install_agent_modal_spec.js
+++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js
@@ -74,7 +74,7 @@ describe('InstallAgentModal', () => {
const expectDisabledAttribute = (element, disabled) => {
if (disabled) {
- expect(element.attributes('disabled')).toBe('true');
+ expect(element.attributes('disabled')).toBeDefined();
} else {
expect(element.attributes('disabled')).toBeUndefined();
}
@@ -139,7 +139,6 @@ describe('InstallAgentModal', () => {
});
afterEach(() => {
- wrapper.destroy();
apolloProvider = null;
});
@@ -257,7 +256,7 @@ describe('InstallAgentModal', () => {
return mockSelectedAgentResponse();
});
- it('displays the error message', async () => {
+ it('displays the error message', () => {
expect(findAlert().text()).toBe(
createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0],
);
diff --git a/spec/frontend/clusters_list/components/mock_data.js b/spec/frontend/clusters_list/components/mock_data.js
index 3d18b22d727..af1fb496118 100644
--- a/spec/frontend/clusters_list/components/mock_data.js
+++ b/spec/frontend/clusters_list/components/mock_data.js
@@ -19,7 +19,7 @@ export const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIV
export const clusterAgents = [
{
name: 'agent-1',
- id: 'agent-1-id',
+ id: 'gid://gitlab/Clusters::Agent/1',
configFolder: {
webPath: '/agent/full/path',
},
@@ -30,17 +30,17 @@ export const clusterAgents = [
},
{
name: 'agent-2',
- id: 'agent-2-id',
+ id: 'gid://gitlab/Clusters::Agent/2',
webPath: '/agent-2',
status: 'active',
lastContact: connectedTimeNow.getTime(),
connections: {
nodes: [
{
- metadata: { version: 'v14.8' },
+ metadata: { version: 'v14.8.0' },
},
{
- metadata: { version: 'v14.8' },
+ metadata: { version: 'v14.8.0' },
},
],
},
@@ -54,14 +54,14 @@ export const clusterAgents = [
},
{
name: 'agent-3',
- id: 'agent-3-id',
+ id: 'gid://gitlab/Clusters::Agent/3',
webPath: '/agent-3',
status: 'inactive',
lastContact: connectedTimeInactive.getTime(),
connections: {
nodes: [
{
- metadata: { version: 'v14.5' },
+ metadata: { version: 'v14.6.0' },
},
],
},
@@ -75,17 +75,17 @@ export const clusterAgents = [
},
{
name: 'agent-4',
- id: 'agent-4-id',
+ id: 'gid://gitlab/Clusters::Agent/4',
webPath: '/agent-4',
status: 'inactive',
lastContact: connectedTimeInactive.getTime(),
connections: {
nodes: [
{
- metadata: { version: 'v14.7' },
+ metadata: { version: 'v14.7.0' },
},
{
- metadata: { version: 'v14.8' },
+ metadata: { version: 'v14.8.0' },
},
],
},
@@ -99,17 +99,101 @@ export const clusterAgents = [
},
{
name: 'agent-5',
- id: 'agent-5-id',
+ id: 'gid://gitlab/Clusters::Agent/5',
webPath: '/agent-5',
status: 'inactive',
lastContact: connectedTimeInactive.getTime(),
connections: {
nodes: [
{
- metadata: { version: 'v14.5' },
+ metadata: { version: 'v14.5.0' },
},
{
- metadata: { version: 'v14.3' },
+ metadata: { version: 'v14.3.0' },
+ },
+ ],
+ },
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeInactive,
+ },
+ ],
+ },
+ },
+ {
+ name: 'agent-6',
+ id: 'gid://gitlab/Clusters::Agent/6',
+ webPath: '/agent-6',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ connections: {
+ nodes: [
+ {
+ metadata: { version: 'v14.6.0' },
+ },
+ ],
+ },
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeInactive,
+ },
+ ],
+ },
+ },
+ {
+ name: 'agent-7',
+ id: 'gid://gitlab/Clusters::Agent/7',
+ webPath: '/agent-7',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ connections: {
+ nodes: [
+ {
+ metadata: { version: 'v14.8.0' },
+ },
+ ],
+ },
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeInactive,
+ },
+ ],
+ },
+ },
+ {
+ name: 'agent-8',
+ id: 'gid://gitlab/Clusters::Agent/8',
+ webPath: '/agent-8',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ connections: {
+ nodes: [
+ {
+ metadata: { version: 'v14.8.0' },
+ },
+ ],
+ },
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeInactive,
+ },
+ ],
+ },
+ },
+ {
+ name: 'agent-9',
+ id: 'gid://gitlab/Clusters::Agent/9',
+ webPath: '/agent-9',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ connections: {
+ nodes: [
+ {
+ metadata: { version: 'v14.8.0' },
},
],
},
diff --git a/spec/frontend/clusters_list/components/node_error_help_text_spec.js b/spec/frontend/clusters_list/components/node_error_help_text_spec.js
index 3211ba44eff..a3dfc848fc8 100644
--- a/spec/frontend/clusters_list/components/node_error_help_text_spec.js
+++ b/spec/frontend/clusters_list/components/node_error_help_text_spec.js
@@ -13,10 +13,6 @@ describe('NodeErrorHelpText', () => {
const findPopover = () => wrapper.findComponent(GlPopover);
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
errorType | wrapperText | popoverText
${'authentication_error'} | ${'Unable to Authenticate'} | ${'GitLab failed to authenticate'}
diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js
index 360fd3b2842..6d23db0517d 100644
--- a/spec/frontend/clusters_list/store/actions_spec.js
+++ b/spec/frontend/clusters_list/store/actions_spec.js
@@ -5,13 +5,13 @@ import waitForPromises from 'helpers/wait_for_promises';
import { MAX_REQUESTS } from '~/clusters_list/constants';
import * as actions from '~/clusters_list/store/actions';
import * as types from '~/clusters_list/store/mutation_types';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { apiData } from '../mock_data';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
describe('Clusters store actions', () => {
let captureException;
@@ -81,7 +81,7 @@ describe('Clusters store actions', () => {
);
});
- it('should show flash on API error', async () => {
+ it('should show alert on API error', async () => {
mock.onGet().reply(HTTP_STATUS_BAD_REQUEST, 'Not Found');
await testAction(
diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js
index b9be262efd0..88861b0d08a 100644
--- a/spec/frontend/code_navigation/components/app_spec.js
+++ b/spec/frontend/code_navigation/components/app_spec.js
@@ -32,10 +32,6 @@ function factory(initialState = {}, props = {}) {
}
describe('Code navigation app component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('sets initial data on mount if the correct props are passed', () => {
const codeNavigationPath = 'code/nav/path.js';
const path = 'blob/path.js';
diff --git a/spec/frontend/code_navigation/components/popover_spec.js b/spec/frontend/code_navigation/components/popover_spec.js
index 874263e046a..1bfaf7e959e 100644
--- a/spec/frontend/code_navigation/components/popover_spec.js
+++ b/spec/frontend/code_navigation/components/popover_spec.js
@@ -61,10 +61,6 @@ function factory({ position, data, definitionPathPrefix, blobPath = 'index.js' }
}
describe('Code navigation popover component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders popover', () => {
factory({
position: { x: 0, y: 0, height: 0 },
diff --git a/spec/frontend/code_review/signals_spec.js b/spec/frontend/code_review/signals_spec.js
new file mode 100644
index 00000000000..03c3580860e
--- /dev/null
+++ b/spec/frontend/code_review/signals_spec.js
@@ -0,0 +1,145 @@
+import { start } from '~/code_review/signals';
+
+import diffsEventHub from '~/diffs/event_hub';
+import { EVT_MR_PREPARED } from '~/diffs/constants';
+import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request';
+
+jest.mock('~/diffs/utils/merge_request');
+
+describe('~/code_review', () => {
+ const io = diffsEventHub;
+
+ beforeAll(() => {
+ getDerivedMergeRequestInformation.mockImplementation(() => ({
+ namespace: 'x',
+ project: 'y',
+ id: '1',
+ }));
+ });
+
+ describe('start', () => {
+ it.each`
+ description | argument
+ ${'no event hub is provided'} | ${{}}
+ ${'no parameters are provided'} | ${undefined}
+ `('throws an error if $description', async ({ argument }) => {
+ await expect(() => start(argument)).rejects.toThrow('signalBus is a required argument');
+ });
+
+ describe('observeMergeRequestFinishingPreparation', () => {
+ const callArgs = {};
+ const apollo = {};
+ let querySpy;
+ let apolloSubscribeSpy;
+ let subscribeSpy;
+ let nextSpy;
+ let unsubscribeSpy;
+ let observable;
+
+ beforeEach(() => {
+ querySpy = jest.fn();
+ apolloSubscribeSpy = jest.fn();
+ subscribeSpy = jest.fn();
+ unsubscribeSpy = jest.fn();
+ nextSpy = jest.fn();
+ observable = {
+ next: nextSpy,
+ subscribe: subscribeSpy.mockReturnValue({
+ unsubscribe: unsubscribeSpy,
+ }),
+ };
+
+ querySpy.mockResolvedValue({
+ data: { project: { mergeRequest: { id: 'gql:id:1', preparedAt: 'x' } } },
+ });
+ apolloSubscribeSpy.mockReturnValue(observable);
+
+ apollo.query = querySpy;
+ apollo.subscribe = apolloSubscribeSpy;
+
+ callArgs.signalBus = io;
+ callArgs.apolloClient = apollo;
+ });
+
+ it('does not query at all if the page does not seem like a merge request', async () => {
+ getDerivedMergeRequestInformation.mockImplementationOnce(() => ({}));
+
+ await start(callArgs);
+
+ expect(querySpy).not.toHaveBeenCalled();
+ expect(apolloSubscribeSpy).not.toHaveBeenCalled();
+ });
+
+ describe('on a merge request page', () => {
+ it('requests the preparedAt (and id) for the current merge request', async () => {
+ await start(callArgs);
+
+ expect(querySpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variables: {
+ projectPath: 'x/y',
+ iid: '1',
+ },
+ }),
+ );
+ });
+
+ it('does not subscribe to any updates if the preparedAt value is already populated', async () => {
+ await start(callArgs);
+
+ expect(apolloSubscribeSpy).not.toHaveBeenCalled();
+ });
+
+ describe('if the merge request is still asynchronously preparing', () => {
+ beforeEach(() => {
+ querySpy.mockResolvedValue({
+ data: { project: { mergeRequest: { id: 'gql:id:1', preparedAt: null } } },
+ });
+ });
+
+ it('subscribes to updates', async () => {
+ await start(callArgs);
+
+ expect(apolloSubscribeSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ variables: { issuableId: 'gql:id:1' } }),
+ );
+ expect(observable.subscribe).toHaveBeenCalled();
+ });
+
+ describe('when the MR has been updated', () => {
+ let emitSpy;
+ let behavior;
+
+ beforeEach(() => {
+ emitSpy = jest.spyOn(diffsEventHub, '$emit');
+ nextSpy.mockImplementation((data) => behavior?.(data));
+ subscribeSpy.mockImplementation((handler) => {
+ behavior = handler;
+
+ return { unsubscribe: unsubscribeSpy };
+ });
+ });
+
+ it('does nothing if the MR has not yet finished preparing', async () => {
+ await start(callArgs);
+
+ observable.next({ data: { mergeRequestMergeStatusUpdated: { preparedAt: null } } });
+
+ expect(unsubscribeSpy).not.toHaveBeenCalled();
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits an event and unsubscribes when the MR is prepared', async () => {
+ await start(callArgs);
+
+ observable.next({ data: { mergeRequestMergeStatusUpdated: { preparedAt: 'x' } } });
+
+ expect(unsubscribeSpy).toHaveBeenCalled();
+ expect(emitSpy).toHaveBeenCalledWith(EVT_MR_PREPARED);
+ });
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap
new file mode 100644
index 00000000000..0f158df6c05
--- /dev/null
+++ b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap
@@ -0,0 +1,140 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Comment templates list item component renders list item 1`] = `
+<li
+ class="gl-pt-4 gl-pb-5 gl-border-b"
+>
+ <div
+ class="gl-display-flex gl-align-items-center"
+ >
+ <h6
+ class="gl-mr-3 gl-my-0"
+ data-testid="comment-template-name"
+ >
+ test
+ </h6>
+
+ <div
+ class="gl-ml-auto"
+ >
+ <div
+ class="gl-new-dropdown gl-disclosure-dropdown"
+ >
+ <button
+ aria-controls="base-dropdown-7"
+ aria-labelledby="actions-toggle-3"
+ class="btn btn-default btn-md gl-button btn-default-tertiary gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret"
+ data-testid="base-dropdown-toggle"
+ id="actions-toggle-3"
+ listeners="[object Object]"
+ type="button"
+ >
+ <!---->
+
+ <svg
+ aria-hidden="true"
+ class="gl-button-icon gl-icon s16"
+ data-testid="ellipsis_v-icon"
+ role="img"
+ >
+ <use
+ href="file-mock#ellipsis_v"
+ />
+ </svg>
+
+ <span
+ class="gl-button-text"
+ >
+ <span
+ class="gl-new-dropdown-button-text gl-sr-only"
+ >
+
+ Comment template actions
+
+ </span>
+
+ <!---->
+ </span>
+ </button>
+
+ <div
+ class="gl-new-dropdown-panel gl-w-31"
+ data-testid="base-dropdown-menu"
+ id="base-dropdown-7"
+ >
+ <div
+ class="gl-new-dropdown-inner"
+ >
+
+ <ul
+ aria-labelledby="actions-toggle-3"
+ class="gl-new-dropdown-contents"
+ data-testid="disclosure-content"
+ id="disclosure-4"
+ tabindex="-1"
+ >
+ <li
+ class="gl-new-dropdown-item"
+ data-testid="disclosure-dropdown-item"
+ tabindex="0"
+ >
+ <button
+ class="gl-new-dropdown-item-content"
+ data-testid="comment-template-edit-btn"
+ tabindex="-1"
+ type="button"
+ >
+ <span
+ class="gl-new-dropdown-item-text-wrapper"
+ >
+
+ Edit
+
+ </span>
+ </button>
+ </li>
+ <li
+ class="gl-new-dropdown-item"
+ data-testid="disclosure-dropdown-item"
+ tabindex="0"
+ >
+ <button
+ class="gl-new-dropdown-item-content gl-text-red-500!"
+ data-testid="comment-template-delete-btn"
+ tabindex="-1"
+ type="button"
+ >
+ <span
+ class="gl-new-dropdown-item-text-wrapper"
+ >
+
+ Delete
+
+ </span>
+ </button>
+ </li>
+ </ul>
+
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="gl-tooltip"
+ >
+
+ Comment template actions
+
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="gl-mt-3 gl-font-monospace"
+ >
+ /assign_reviewer
+ </div>
+
+ <!---->
+</li>
+`;
diff --git a/spec/frontend/comment_templates/components/form_spec.js b/spec/frontend/comment_templates/components/form_spec.js
new file mode 100644
index 00000000000..053a5099c37
--- /dev/null
+++ b/spec/frontend/comment_templates/components/form_spec.js
@@ -0,0 +1,145 @@
+import Vue, { nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createdSavedReplyResponse from 'test_fixtures/graphql/comment_templates/create_saved_reply.mutation.graphql.json';
+import createdSavedReplyErrorResponse from 'test_fixtures/graphql/comment_templates/create_saved_reply_with_errors.mutation.graphql.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import Form from '~/comment_templates/components/form.vue';
+import createSavedReplyMutation from '~/comment_templates/queries/create_saved_reply.mutation.graphql';
+import updateSavedReplyMutation from '~/comment_templates/queries/update_saved_reply.mutation.graphql';
+
+let wrapper;
+let createSavedReplyResponseSpy;
+let updateSavedReplyResponseSpy;
+
+function createMockApolloProvider(response) {
+ Vue.use(VueApollo);
+
+ createSavedReplyResponseSpy = jest.fn().mockResolvedValue(response);
+ updateSavedReplyResponseSpy = jest.fn().mockResolvedValue(response);
+
+ const requestHandlers = [
+ [createSavedReplyMutation, createSavedReplyResponseSpy],
+ [updateSavedReplyMutation, updateSavedReplyResponseSpy],
+ ];
+
+ return createMockApollo(requestHandlers);
+}
+
+function createComponent(id = null, response = createdSavedReplyResponse) {
+ const mockApollo = createMockApolloProvider(response);
+
+ return mount(Form, {
+ propsData: {
+ id,
+ },
+ apolloProvider: mockApollo,
+ });
+}
+
+const findSavedReplyNameInput = () => wrapper.find('[data-testid="comment-template-name-input"]');
+const findSavedReplyNameFormGroup = () =>
+ wrapper.find('[data-testid="comment-template-name-form-group"]');
+const findSavedReplyContentInput = () =>
+ wrapper.find('[data-testid="comment-template-content-input"]');
+const findSavedReplyContentFormGroup = () =>
+ wrapper.find('[data-testid="comment-template-content-form-group"]');
+const findSavedReplyFrom = () => wrapper.find('[data-testid="comment-template-form"]');
+const findAlerts = () => wrapper.findAllComponents(GlAlert);
+const findSubmitBtn = () => wrapper.find('[data-testid="comment-template-form-submit-btn"]');
+
+describe('Comment templates form component', () => {
+ describe('creates comment template', () => {
+ it('calls apollo mutation', async () => {
+ wrapper = createComponent();
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(createSavedReplyResponseSpy).toHaveBeenCalledWith({
+ id: null,
+ content: 'Test content',
+ name: 'Test',
+ });
+ });
+
+ it('does not submit when form validation fails', async () => {
+ wrapper = createComponent();
+
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(createSavedReplyResponseSpy).not.toHaveBeenCalled();
+ });
+
+ it.each`
+ findFormGroup | findInput | fieldName
+ ${findSavedReplyNameFormGroup} | ${findSavedReplyContentInput} | ${'name'}
+ ${findSavedReplyContentFormGroup} | ${findSavedReplyNameInput} | ${'content'}
+ `('shows errors for empty $fieldName input', async ({ findFormGroup, findInput }) => {
+ wrapper = createComponent(null, createdSavedReplyErrorResponse);
+
+ findInput().setValue('Test');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(findFormGroup().classes('is-invalid')).toBe(true);
+ });
+
+ it('displays errors when mutation fails', async () => {
+ wrapper = createComponent(null, createdSavedReplyErrorResponse);
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ const { errors } = createdSavedReplyErrorResponse;
+ const alertMessages = findAlerts().wrappers.map((x) => x.text());
+
+ expect(alertMessages).toEqual(errors.map((x) => x.message));
+ });
+
+ it('shows loading state when saving', async () => {
+ wrapper = createComponent();
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await nextTick();
+
+ expect(findSubmitBtn().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findSubmitBtn().props('loading')).toBe(false);
+ });
+ });
+
+ describe('updates saved reply', () => {
+ it('calls apollo mutation', async () => {
+ wrapper = createComponent('1');
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(updateSavedReplyResponseSpy).toHaveBeenCalledWith({
+ id: '1',
+ content: 'Test content',
+ name: 'Test',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/comment_templates/components/list_item_spec.js b/spec/frontend/comment_templates/components/list_item_spec.js
new file mode 100644
index 00000000000..925d78da4ad
--- /dev/null
+++ b/spec/frontend/comment_templates/components/list_item_spec.js
@@ -0,0 +1,154 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { mount } from '@vue/test-utils';
+import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createMockDirective } from 'helpers/vue_mock_directive';
+import ListItem from '~/comment_templates/components/list_item.vue';
+import deleteSavedReplyMutation from '~/comment_templates/queries/delete_saved_reply.mutation.graphql';
+
+function createMockApolloProvider(requestHandlers = [deleteSavedReplyMutation]) {
+ Vue.use(VueApollo);
+
+ return createMockApollo([requestHandlers]);
+}
+
+describe('Comment templates list item component', () => {
+ let wrapper;
+ let $router;
+
+ function createComponent(propsData = {}, apolloProvider = createMockApolloProvider) {
+ $router = {
+ push: jest.fn(),
+ };
+
+ return mount(ListItem, {
+ propsData,
+ directives: {
+ GlModal: createMockDirective('gl-modal'),
+ },
+ apolloProvider,
+ mocks: {
+ $router,
+ },
+ });
+ }
+
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
+ const findModal = () => wrapper.findComponent(GlModal);
+
+ it('renders list item', () => {
+ wrapper = createComponent({ template: { name: 'test', content: '/assign_reviewer' } });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('comment template actions dropdown', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ template: { name: 'test', content: '/assign_reviewer' } });
+ });
+
+ it('exists', () => {
+ expect(findDropdown().exists()).toBe(true);
+ });
+
+ it('has correct toggle text', () => {
+ expect(findDropdown().props('toggleText')).toBe(__('Comment template actions'));
+ });
+
+ it('has correct amount of dropdown items', () => {
+ const items = findDropdownItems();
+
+ expect(items.exists()).toBe(true);
+ expect(items).toHaveLength(2);
+ });
+
+ describe('edit option', () => {
+ it('exists', () => {
+ const items = findDropdownItems();
+
+ const editItem = items.filter((item) => item.text() === __('Edit'));
+
+ expect(editItem.exists()).toBe(true);
+ });
+
+ it('shows as first dropdown item', () => {
+ const items = findDropdownItems();
+
+ expect(items.at(0).text()).toBe(__('Edit'));
+ });
+ });
+
+ describe('delete option', () => {
+ it('exists', () => {
+ const items = findDropdownItems();
+
+ const deleteItem = items.filter((item) => item.text() === __('Delete'));
+
+ expect(deleteItem.exists()).toBe(true);
+ });
+
+ it('shows as first dropdown item', () => {
+ const items = findDropdownItems();
+
+ expect(items.at(1).text()).toBe(__('Delete'));
+ });
+ });
+ });
+
+ describe('Delete modal', () => {
+ let deleteSavedReplyMutationResponse;
+
+ beforeEach(() => {
+ deleteSavedReplyMutationResponse = jest
+ .fn()
+ .mockResolvedValue({ data: { savedReplyDestroy: { errors: [] } } });
+
+ const apolloProvider = createMockApolloProvider([
+ deleteSavedReplyMutation,
+ deleteSavedReplyMutationResponse,
+ ]);
+
+ wrapper = createComponent(
+ { template: { name: 'test', content: '/assign_reviewer', id: 1 } },
+ apolloProvider,
+ );
+ });
+
+ it('exists', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('has correct title', () => {
+ expect(findModal().props('title')).toBe(__('Delete comment template'));
+ });
+
+ it('delete button calls Apollo mutate', async () => {
+ await findModal().vm.$emit('primary');
+
+ expect(deleteSavedReplyMutationResponse).toHaveBeenCalledWith({ id: 1 });
+ });
+
+ it('cancel button does not trigger Apollo mutation', async () => {
+ await findModal().vm.$emit('secondary');
+
+ expect(deleteSavedReplyMutationResponse).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Dropdown Edit', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ template: { name: 'test', content: '/assign_reviewer' } });
+ });
+
+ it('click triggers router push', async () => {
+ const editComponent = findDropdownItems().at(0);
+
+ await editComponent.find('button').trigger('click');
+
+ expect($router.push).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/comment_templates/components/list_spec.js b/spec/frontend/comment_templates/components/list_spec.js
new file mode 100644
index 00000000000..8b0daf2fe2f
--- /dev/null
+++ b/spec/frontend/comment_templates/components/list_spec.js
@@ -0,0 +1,46 @@
+import { mount } from '@vue/test-utils';
+import noSavedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies_empty.query.graphql.json';
+import savedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies.query.graphql.json';
+import List from '~/comment_templates/components/list.vue';
+import ListItem from '~/comment_templates/components/list_item.vue';
+
+let wrapper;
+
+function createComponent(res = {}) {
+ const { savedReplies } = res.data.currentUser;
+
+ return mount(List, {
+ propsData: {
+ savedReplies: savedReplies.nodes,
+ pageInfo: savedReplies.pageInfo,
+ count: savedReplies.count,
+ },
+ });
+}
+
+describe('Comment templates list component', () => {
+ it('does not render any list items when response is empty', () => {
+ wrapper = createComponent(noSavedRepliesResponse);
+
+ expect(wrapper.findAllComponents(ListItem).length).toBe(0);
+ });
+
+ it('render comment templates count', () => {
+ wrapper = createComponent(savedRepliesResponse);
+
+ expect(wrapper.find('[data-testid="title"]').text()).toEqual('My comment templates (2)');
+ });
+
+ it('renders list of comment templates', () => {
+ const savedReplies = savedRepliesResponse.data.currentUser.savedReplies.nodes;
+ wrapper = createComponent(savedRepliesResponse);
+
+ expect(wrapper.findAllComponents(ListItem).length).toBe(2);
+ expect(wrapper.findAllComponents(ListItem).at(0).props('template')).toEqual(
+ expect.objectContaining(savedReplies[0]),
+ );
+ expect(wrapper.findAllComponents(ListItem).at(1).props('template')).toEqual(
+ expect.objectContaining(savedReplies[1]),
+ );
+ });
+});
diff --git a/spec/frontend/comment_templates/pages/index_spec.js b/spec/frontend/comment_templates/pages/index_spec.js
new file mode 100644
index 00000000000..6dbec3ef4a4
--- /dev/null
+++ b/spec/frontend/comment_templates/pages/index_spec.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import { mount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import savedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies.query.graphql.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import IndexPage from '~/comment_templates/pages/index.vue';
+import ListItem from '~/comment_templates/components/list_item.vue';
+import savedRepliesQuery from '~/comment_templates/queries/saved_replies.query.graphql';
+
+let wrapper;
+
+function createMockApolloProvider(response) {
+ Vue.use(VueApollo);
+
+ const requestHandlers = [[savedRepliesQuery, jest.fn().mockResolvedValue(response)]];
+
+ return createMockApollo(requestHandlers);
+}
+
+function createComponent(options = {}) {
+ const { mockApollo } = options;
+
+ return mount(IndexPage, {
+ apolloProvider: mockApollo,
+ });
+}
+
+describe('Comment templates index page component', () => {
+ it('renders list of comment templates', async () => {
+ const mockApollo = createMockApolloProvider(savedRepliesResponse);
+ const savedReplies = savedRepliesResponse.data.currentUser.savedReplies.nodes;
+ wrapper = createComponent({ mockApollo });
+
+ await waitForPromises();
+
+ expect(wrapper.findAllComponents(ListItem).length).toBe(2);
+ expect(wrapper.findAllComponents(ListItem).at(0).props('template')).toEqual(
+ expect.objectContaining(savedReplies[0]),
+ );
+ expect(wrapper.findAllComponents(ListItem).at(1).props('template')).toEqual(
+ expect.objectContaining(savedReplies[1]),
+ );
+ });
+});
diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
index debd10de118..7be68df61de 100644
--- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
+++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
@@ -5,13 +5,13 @@ import { shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import { COMMIT_BOX_POLL_INTERVAL } from '~/projects/commit_box/info/constants';
import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
import getPipelineStagesQuery from '~/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql';
-import * as graphQlUtils from '~/pipelines/components/graph/utils';
+import * as sharedGraphQlUtils from '~/graphql_shared/utils';
import {
mockDownstreamQueryResponse,
mockPipelineStagesQueryResponse,
@@ -20,7 +20,7 @@ import {
mockUpstreamQueryResponse,
} from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -69,10 +69,6 @@ describe('Commit box pipeline mini graph', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('loading state', () => {
it('should display loading state when loading', () => {
createComponent();
@@ -87,7 +83,7 @@ describe('Commit box pipeline mini graph', () => {
await createComponent();
});
- it('should not display loading state after the query is resolved', async () => {
+ it('should not display loading state after the query is resolved', () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findPipelineMiniGraph().exists()).toBe(true);
});
@@ -245,16 +241,16 @@ describe('Commit box pipeline mini graph', () => {
});
it('toggles query polling with visibility check', async () => {
- jest.spyOn(graphQlUtils, 'toggleQueryPollingByVisibility');
+ jest.spyOn(sharedGraphQlUtils, 'toggleQueryPollingByVisibility');
createComponent();
await waitForPromises();
- expect(graphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith(
+ expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith(
wrapper.vm.$apollo.queries.pipelineStages,
);
- expect(graphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith(
+ expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith(
wrapper.vm.$apollo.queries.pipeline,
);
});
diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_component_spec.js
index e75fb697a7b..e474ef9c635 100644
--- a/spec/frontend/commit/commit_pipeline_status_component_spec.js
+++ b/spec/frontend/commit/commit_pipeline_status_component_spec.js
@@ -3,14 +3,14 @@ import { shallowMount } from '@vue/test-utils';
import Visibility from 'visibilityjs';
import { nextTick } from 'vue';
import fixture from 'test_fixtures/pipelines/pipelines.json';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
jest.mock('~/lib/utils/poll');
jest.mock('visibilityjs');
-jest.mock('~/flash');
+jest.mock('~/alert');
const mockFetchData = jest.fn();
jest.mock('~/projects/tree/services/commit_pipeline_service', () =>
@@ -41,11 +41,6 @@ describe('Commit pipeline status component', () => {
const findLink = () => wrapper.find('a');
const findCiIcon = () => findLink().findComponent(CiIcon);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('Visibility management', () => {
describe('when component is hidden', () => {
beforeEach(() => {
@@ -169,7 +164,7 @@ describe('Commit pipeline status component', () => {
});
});
- it('displays flash error message', () => {
+ it('displays alert error message', () => {
expect(createAlert).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js
index 8d455f8a3d7..80b75a0a65e 100644
--- a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js
+++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js
@@ -4,7 +4,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import CommitBoxPipelineStatus from '~/projects/commit_box/info/components/commit_box_pipeline_status.vue';
import {
@@ -12,7 +12,7 @@ import {
PIPELINE_STATUS_FETCH_ERROR,
} from '~/projects/commit_box/info/constants';
import getLatestPipelineStatusQuery from '~/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql';
-import * as graphQlUtils from '~/pipelines/components/graph/utils';
+import * as sharedGraphQlUtils from '~/graphql_shared/utils';
import { mockPipelineStatusResponse } from '../mock_data';
const mockProvide = {
@@ -23,7 +23,7 @@ const mockProvide = {
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Commit box pipeline status', () => {
let wrapper;
@@ -54,10 +54,6 @@ describe('Commit box pipeline status', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('loading state', () => {
it('should display loading state when loading', () => {
createComponent();
@@ -74,7 +70,7 @@ describe('Commit box pipeline status', () => {
await waitForPromises();
});
- it('should display pipeline status after the query is resolved successfully', async () => {
+ it('should display pipeline status after the query is resolved successfully', () => {
expect(findStatusIcon().exists()).toBe(true);
expect(findLoadingIcon().exists()).toBe(false);
@@ -136,13 +132,13 @@ describe('Commit box pipeline status', () => {
});
it('toggles pipelineStatus polling with visibility check', async () => {
- jest.spyOn(graphQlUtils, 'toggleQueryPollingByVisibility');
+ jest.spyOn(sharedGraphQlUtils, 'toggleQueryPollingByVisibility');
createComponent();
await waitForPromises();
- expect(graphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith(
+ expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith(
wrapper.vm.$apollo.queries.pipelineStatus,
);
});
diff --git a/spec/frontend/commit/components/signature_badge_spec.js b/spec/frontend/commit/components/signature_badge_spec.js
new file mode 100644
index 00000000000..d52ad2b43e2
--- /dev/null
+++ b/spec/frontend/commit/components/signature_badge_spec.js
@@ -0,0 +1,134 @@
+import { GlBadge, GlLink, GlPopover } from '@gitlab/ui';
+import { stubComponent, RENDER_ALL_SLOTS_TEMPLATE } from 'helpers/stub_component';
+import SignatureBadge from '~/commit/components/signature_badge.vue';
+import X509CertificateDetails from '~/commit/components/x509_certificate_details.vue';
+import { typeConfig, statusConfig, verificationStatuses, signatureTypes } from '~/commit/constants';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { sshSignatureProp, gpgSignatureProp, x509SignatureProp } from '../mock_data';
+
+describe('Commit signature', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = mountExtended(SignatureBadge, {
+ propsData: {
+ signature: {
+ ...props,
+ },
+ stubs: {
+ GlBadge,
+ GlLink,
+ X509CertificateDetails,
+ GlPopover: stubComponent(GlPopover, { template: RENDER_ALL_SLOTS_TEMPLATE }),
+ },
+ },
+ });
+ };
+
+ const signatureBadge = () => wrapper.findComponent(GlBadge);
+ const signaturePopover = () => wrapper.findComponent(GlPopover);
+ const signatureDescription = () => wrapper.findByTestId('signature-description');
+ const signatureKeyLabel = () => wrapper.findByTestId('signature-key-label');
+ const signatureKey = () => wrapper.findByTestId('signature-key');
+ const helpLink = () => wrapper.findComponent(GlLink);
+ const X509CertificateDetailsComponents = () => wrapper.findAllComponents(X509CertificateDetails);
+
+ describe.each`
+ signatureType | verificationStatus
+ ${signatureTypes.GPG} | ${verificationStatuses.VERIFIED}
+ ${signatureTypes.GPG} | ${verificationStatuses.UNVERIFIED}
+ ${signatureTypes.GPG} | ${verificationStatuses.UNVERIFIED_KEY}
+ ${signatureTypes.GPG} | ${verificationStatuses.UNKNOWN_KEY}
+ ${signatureTypes.GPG} | ${verificationStatuses.OTHER_USER}
+ ${signatureTypes.GPG} | ${verificationStatuses.SAME_USER_DIFFERENT_EMAIL}
+ ${signatureTypes.GPG} | ${verificationStatuses.MULTIPLE_SIGNATURES}
+ ${signatureTypes.X509} | ${verificationStatuses.VERIFIED}
+ ${signatureTypes.SSH} | ${verificationStatuses.VERIFIED}
+ ${signatureTypes.SSH} | ${verificationStatuses.REVOKED_KEY}
+ `(
+ 'For a specified `$signatureType` and `$verificationStatus` it renders component correctly',
+ ({ signatureType, verificationStatus }) => {
+ beforeEach(() => {
+ createComponent({ __typename: signatureType, verificationStatus });
+ });
+ it('renders correct badge class', () => {
+ expect(signatureBadge().props('variant')).toBe(statusConfig[verificationStatus].variant);
+ });
+ it('renders badge text', () => {
+ expect(signatureBadge().text()).toBe(statusConfig[verificationStatus].label);
+ });
+ it('renders popover header text', () => {
+ expect(signaturePopover().text()).toMatch(statusConfig[verificationStatus].title);
+ });
+ it('renders signature description', () => {
+ expect(signatureDescription().text()).toBe(statusConfig[verificationStatus].description);
+ });
+ it('renders help link with correct path', () => {
+ expect(helpLink().text()).toBe(typeConfig[signatureType].helpLink.label);
+ expect(helpLink().attributes('href')).toBe(
+ helpPagePath(typeConfig[signatureType].helpLink.path),
+ );
+ });
+ },
+ );
+
+ describe('SSH signature', () => {
+ beforeEach(() => {
+ createComponent(sshSignatureProp);
+ });
+
+ it('renders key label', () => {
+ expect(signatureKeyLabel().text()).toMatch(typeConfig[signatureTypes.SSH].keyLabel);
+ });
+
+ it('renders key signature', () => {
+ expect(signatureKey().text()).toBe(sshSignatureProp.keyFingerprintSha256);
+ });
+ });
+
+ describe('GPG signature', () => {
+ beforeEach(() => {
+ createComponent(gpgSignatureProp);
+ });
+
+ it('renders key label', () => {
+ expect(signatureKeyLabel().text()).toMatch(typeConfig[signatureTypes.GPG].keyLabel);
+ });
+
+ it('renders key signature for GGP signature', () => {
+ expect(signatureKey().text()).toBe(gpgSignatureProp.gpgKeyPrimaryKeyid);
+ });
+ });
+
+ describe('X509 signature', () => {
+ beforeEach(() => {
+ createComponent(x509SignatureProp);
+ });
+
+ it('does not render key label', () => {
+ expect(signatureKeyLabel().exists()).toBe(false);
+ });
+
+ it('renders X509 certificate details components', () => {
+ expect(X509CertificateDetailsComponents()).toHaveLength(2);
+ });
+
+ it('passes correct props', () => {
+ expect(X509CertificateDetailsComponents().at(0).props()).toStrictEqual({
+ subject: x509SignatureProp.x509Certificate.subject,
+ title: typeConfig[signatureTypes.X509].subjectTitle,
+ subjectKeyIdentifier: wrapper.vm.getSubjectKeyIdentifierToDisplay(
+ x509SignatureProp.x509Certificate.subjectKeyIdentifier,
+ ),
+ });
+ expect(X509CertificateDetailsComponents().at(1).props()).toStrictEqual({
+ subject: x509SignatureProp.x509Certificate.x509Issuer.subject,
+ title: typeConfig[signatureTypes.X509].issuerTitle,
+ subjectKeyIdentifier: wrapper.vm.getSubjectKeyIdentifierToDisplay(
+ x509SignatureProp.x509Certificate.x509Issuer.subjectKeyIdentifier,
+ ),
+ });
+ });
+ });
+});
diff --git a/spec/frontend/commit/components/x509_certificate_details_spec.js b/spec/frontend/commit/components/x509_certificate_details_spec.js
new file mode 100644
index 00000000000..5d9398b572b
--- /dev/null
+++ b/spec/frontend/commit/components/x509_certificate_details_spec.js
@@ -0,0 +1,36 @@
+import { shallowMount } from '@vue/test-utils';
+import X509CertificateDetails from '~/commit/components/x509_certificate_details.vue';
+import { X509_CERTIFICATE_KEY_IDENTIFIER_TITLE } from '~/commit/constants';
+import { x509CertificateDetailsProp } from '../mock_data';
+
+describe('X509 certificate details', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(X509CertificateDetails, {
+ propsData: x509CertificateDetailsProp,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ const findTitle = () => wrapper.find('strong');
+ const findSubjectValues = () => wrapper.findAll("[data-testid='subject-value']");
+ const findKeyIdentifier = () => wrapper.find("[data-testid='key-identifier']");
+
+ it('renders a title', () => {
+ expect(findTitle().text()).toBe(x509CertificateDetailsProp.title);
+ });
+
+ it('renders subject values', () => {
+ expect(findSubjectValues()).toHaveLength(3);
+ });
+
+ it('renders key identifier', () => {
+ expect(findKeyIdentifier().text()).toBe(
+ `${X509_CERTIFICATE_KEY_IDENTIFIER_TITLE} ${x509CertificateDetailsProp.subjectKeyIdentifier}`,
+ );
+ });
+});
diff --git a/spec/frontend/commit/mock_data.js b/spec/frontend/commit/mock_data.js
index a13ef9c563e..3b6971d9607 100644
--- a/spec/frontend/commit/mock_data.js
+++ b/spec/frontend/commit/mock_data.js
@@ -201,3 +201,34 @@ export const mockUpstreamQueryResponse = {
},
},
};
+
+export const sshSignatureProp = {
+ __typename: 'SshSignature',
+ verificationStatus: 'VERIFIED',
+ keyFingerprintSha256: 'xxx',
+};
+
+export const gpgSignatureProp = {
+ __typename: 'GpgSignature',
+ verificationStatus: 'VERIFIED',
+ gpgKeyPrimaryKeyid: 'yyy',
+};
+
+export const x509SignatureProp = {
+ __typename: 'X509Signature',
+ verificationStatus: 'VERIFIED',
+ x509Certificate: {
+ subject: 'CN=gitlab@example.org,OU=Example,O=World',
+ subjectKeyIdentifier: 'BC:BC:BC:BC:BC:BC:BC:BC',
+ x509Issuer: {
+ subject: 'CN=PKI,OU=Example,O=World',
+ subjectKeyIdentifier: 'AB:AB:AB:AB:AB:AB:AB:AB:',
+ },
+ },
+};
+
+export const x509CertificateDetailsProp = {
+ title: 'Title',
+ subject: 'CN=gitlab@example.org,OU=Example,O=World',
+ subjectKeyIdentifier: 'BC BC BC BC BC BC BC BC',
+};
diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js
index 4bffb6a0fd3..009ec68ddcf 100644
--- a/spec/frontend/commit/pipelines/pipelines_table_spec.js
+++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js
@@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import fixture from 'test_fixtures/pipelines/pipelines.json';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import PipelinesTable from '~/commit/pipelines/pipelines_table.vue';
@@ -13,7 +14,7 @@ import {
HTTP_STATUS_OK,
HTTP_STATUS_UNAUTHORIZED,
} from '~/lib/utils/http_status';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TOAST_MESSAGE } from '~/pipelines/constants';
import axios from '~/lib/utils/axios_utils';
@@ -21,12 +22,13 @@ const $toast = {
show: jest.fn(),
};
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Pipelines table in Commits and Merge requests', () => {
let wrapper;
let pipeline;
let mock;
+ const showMock = jest.fn();
const findRunPipelineBtn = () => wrapper.findByTestId('run_pipeline_button');
const findRunPipelineBtnMobile = () => wrapper.findByTestId('run_pipeline_button_mobile');
@@ -38,7 +40,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findMrPipelinesDocsLink = () => wrapper.findByTestId('mr-pipelines-docs-link');
- const createComponent = (props = {}) => {
+ const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
mount(PipelinesTable, {
propsData: {
@@ -50,6 +52,12 @@ describe('Pipelines table in Commits and Merge requests', () => {
mocks: {
$toast,
},
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ template: '<div />',
+ methods: { show: showMock },
+ }),
+ },
}),
);
};
@@ -62,11 +70,6 @@ describe('Pipelines table in Commits and Merge requests', () => {
pipeline = pipelines.find((p) => p.user !== null && p.commit !== null);
});
- afterEach(() => {
- wrapper.destroy();
- mock.restore();
- });
-
describe('successful request', () => {
describe('without pipelines', () => {
beforeEach(async () => {
@@ -95,6 +98,35 @@ describe('Pipelines table in Commits and Merge requests', () => {
});
});
+ describe('with pagination', () => {
+ beforeEach(async () => {
+ mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipeline], {
+ 'X-TOTAL': 10,
+ 'X-PER-PAGE': 2,
+ 'X-PAGE': 1,
+ 'X-TOTAL-PAGES': 5,
+ 'X-NEXT-PAGE': 2,
+ 'X-PREV-PAGE': 2,
+ });
+
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('should make an API request when using pagination', async () => {
+ expect(mock.history.get).toHaveLength(1);
+ expect(mock.history.get[0].params.page).toBe('1');
+
+ wrapper.find('.next-page-item').trigger('click');
+
+ await waitForPromises();
+
+ expect(mock.history.get).toHaveLength(2);
+ expect(mock.history.get[1].params.page).toBe('2');
+ });
+ });
+
describe('with pipelines', () => {
beforeEach(async () => {
mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipeline], { 'x-total': 10 });
@@ -111,32 +143,6 @@ describe('Pipelines table in Commits and Merge requests', () => {
expect(findErrorEmptyState().exists()).toBe(false);
});
- describe('with pagination', () => {
- it('should make an API request when using pagination', async () => {
- jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({
- store: {
- state: {
- pageInfo: {
- page: 1,
- total: 10,
- perPage: 2,
- nextPage: 2,
- totalPages: 5,
- },
- },
- },
- });
-
- wrapper.find('.next-page-item').trigger('click');
-
- expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ page: '2' });
- });
- });
-
describe('pipeline badge counts', () => {
it('should receive update-pipelines-count event', () => {
const element = document.createElement('div');
@@ -203,16 +209,18 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]);
createComponent({
- canRunPipeline: true,
- projectId: '5',
- mergeRequestId: 3,
+ props: {
+ canRunPipeline: true,
+ projectId: '5',
+ mergeRequestId: 3,
+ },
});
await waitForPromises();
});
describe('success', () => {
beforeEach(() => {
- jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve());
+ jest.spyOn(Api, 'postMergeRequestPipeline').mockResolvedValue();
});
it('displays a toast message during pipeline creation', async () => {
await findRunPipelineBtn().trigger('click');
@@ -255,9 +263,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
`('displays permissions error message', async ({ status, message }) => {
const response = { response: { status } };
- jest
- .spyOn(Api, 'postMergeRequestPipeline')
- .mockImplementation(() => Promise.reject(response));
+ jest.spyOn(Api, 'postMergeRequestPipeline').mockRejectedValue(response);
await findRunPipelineBtn().trigger('click');
@@ -281,14 +287,16 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]);
createComponent({
- projectId: '5',
- mergeRequestId: 3,
- canCreatePipelineInTargetProject: true,
- sourceProjectFullPath: 'test/parent-project',
- targetProjectFullPath: 'test/fork-project',
+ props: {
+ projectId: '5',
+ mergeRequestId: 3,
+ canCreatePipelineInTargetProject: true,
+ sourceProjectFullPath: 'test/parent-project',
+ targetProjectFullPath: 'test/fork-project',
+ },
});
- jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve());
+ jest.spyOn(Api, 'postMergeRequestPipeline').mockResolvedValue();
await waitForPromises();
});
@@ -313,15 +321,15 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, []);
createComponent({
- projectId: '5',
- mergeRequestId: 3,
- canCreatePipelineInTargetProject: true,
- sourceProjectFullPath: 'test/parent-project',
- targetProjectFullPath: 'test/fork-project',
+ props: {
+ projectId: '5',
+ mergeRequestId: 3,
+ canCreatePipelineInTargetProject: true,
+ sourceProjectFullPath: 'test/parent-project',
+ targetProjectFullPath: 'test/fork-project',
+ },
});
- jest.spyOn(findModal().vm, 'show').mockReturnValue();
-
await waitForPromises();
});
@@ -331,7 +339,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
findRunPipelineBtn().trigger('click');
- expect(findModal().vm.show).toHaveBeenCalled();
+ expect(showMock).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/commons/nav/user_merge_requests_spec.js b/spec/frontend/commons/nav/user_merge_requests_spec.js
index f660cc8e9de..114cbbf812c 100644
--- a/spec/frontend/commons/nav/user_merge_requests_spec.js
+++ b/spec/frontend/commons/nav/user_merge_requests_spec.js
@@ -16,7 +16,10 @@ describe('User Merge Requests', () => {
let newBroadcastChannelMock;
beforeEach(() => {
+ jest.spyOn(document, 'dispatchEvent').mockReturnValue(false);
+
global.gon.current_user_id = 123;
+ global.gon.use_new_navigation = false;
channelMock = {
postMessage: jest.fn(),
@@ -73,6 +76,10 @@ describe('User Merge Requests', () => {
expect(channelMock.postMessage).not.toHaveBeenCalled();
});
});
+
+ it('does not emit event to refetch counts', () => {
+ expect(document.dispatchEvent).not.toHaveBeenCalled();
+ });
});
describe('openUserCountsBroadcast', () => {
@@ -85,6 +92,7 @@ describe('User Merge Requests', () => {
channelMock.onmessage({ data: TEST_COUNT });
+ expect(newBroadcastChannelMock).toHaveBeenCalled();
expect(findMRCountText()).toEqual(TEST_COUNT.toLocaleString());
});
@@ -93,6 +101,7 @@ describe('User Merge Requests', () => {
openUserCountsBroadcast();
+ expect(newBroadcastChannelMock).toHaveBeenCalled();
expect(channelMock.close).toHaveBeenCalled();
});
});
@@ -118,4 +127,28 @@ describe('User Merge Requests', () => {
});
});
});
+
+ describe('if new navigation is enabled', () => {
+ beforeEach(() => {
+ global.gon.use_new_navigation = true;
+ jest.spyOn(UserApi, 'getUserCounts');
+ });
+
+ it('openUserCountsBroadcast is a noop', () => {
+ openUserCountsBroadcast();
+ expect(newBroadcastChannelMock).not.toHaveBeenCalled();
+ });
+
+ describe('refreshUserMergeRequestCounts', () => {
+ it('does not call api', async () => {
+ await refreshUserMergeRequestCounts();
+ expect(UserApi.getUserCounts).not.toHaveBeenCalled();
+ });
+
+ it('emits event to refetch counts', async () => {
+ await refreshUserMergeRequestCounts();
+ expect(document.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('todo:toggle'));
+ });
+ });
+ });
});
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 d6f16f1a644..a7ae07a36d9 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
@@ -46,7 +46,6 @@ function factory(projects = mockData) {
describe('Confidential merge request project form group component', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
it('renders fork dropdown', async () => {
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
index a63cca006da..a328f79e4e7 100644
--- a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
+++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`content_editor/components/toolbar_button displays tertiary, medium button with a provided label and icon 1`] = `
-"<b-button-stub size=\\"md\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-button btn-default-tertiary btn-icon\\">
+"<b-button-stub size=\\"sm\\" tag=\\"button\\" type=\\"button\\" variant=\\"default\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-mr-3 gl-button btn-default-tertiary btn-icon\\">
<!---->
<gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub>
<!---->
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
deleted file mode 100644
index 331a0a474a3..00000000000
--- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
+++ /dev/null
@@ -1,33 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`content_editor/components/toolbar_link_button renders dropdown component 1`] = `
-"<div title=\\"Insert link\\" lazy=\\"\\">
- <li role=\\"presentation\\" class=\\"gl-px-3!\\">
- <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\">
- <div role=\\"group\\" class=\\"input-group\\" placeholder=\\"Link URL\\">
- <!---->
- <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\">
- <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\">
- <!---->
- <!----> <span class=\\"gl-button-text\\">Apply</span></button></div>
- <!---->
- </div>
- </form>
- </li>
- <li role=\\"presentation\\" class=\\"gl-dropdown-divider\\">
- <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\">
- </li>
- <li role=\\"presentation\\" class=\\"gl-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\">
- <!---->
- <!---->
- <!---->
- <div class=\\"gl-dropdown-item-text-wrapper\\">
- <p class=\\"gl-dropdown-item-text-primary\\">
- Upload file
- </p>
- <!---->
- </div>
- <!---->
- </button></li>
-</div>"
-`;
diff --git a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js
index 0700cf5d529..97716ce848c 100644
--- a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js
@@ -51,10 +51,6 @@ describe('content_editor/components/bubble_menus/bubble_menu', () => {
setupMocks();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('initializes BubbleMenuPlugin', async () => {
createWrapper({});
@@ -68,10 +64,12 @@ describe('content_editor/components/bubble_menus/bubble_menu', () => {
tippyOptions: expect.objectContaining({
onHidden: expect.any(Function),
onShow: expect.any(Function),
+ appendTo: expect.any(Function),
...tippyOptions,
}),
});
+ expect(BubbleMenuPlugin.mock.calls[0][0].tippyOptions.appendTo()).toBe(document.body);
expect(tiptapEditor.registerPlugin).toHaveBeenCalledWith(pluginInitializationResult);
});
diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js
index 378b11f4ae9..2a6ab75227c 100644
--- a/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js
@@ -59,15 +59,11 @@ describe('content_editor/components/bubble_menus/code_block_bubble_menu', () =>
checked: x.props('isChecked'),
}));
- beforeEach(async () => {
+ beforeEach(() => {
buildEditor();
buildWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders bubble menu component', async () => {
tiptapEditor.commands.insertContent(preTag());
bubbleMenu = wrapper.findComponent(BubbleMenu);
@@ -137,7 +133,7 @@ describe('content_editor/components/bubble_menus/code_block_bubble_menu', () =>
});
describe('preview button', () => {
- it('does not appear for a regular code block', async () => {
+ it('does not appear for a regular code block', () => {
tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
expect(wrapper.findByTestId('preview-diagram').exists()).toBe(false);
@@ -273,7 +269,7 @@ describe('content_editor/components/bubble_menus/code_block_bubble_menu', () =>
await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
- it('hides the custom language input form and shows dropdown items', async () => {
+ it('hides the custom language input form and shows dropdown items', () => {
expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true);
expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true);
expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false);
diff --git a/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js
deleted file mode 100644
index 98001858851..00000000000
--- a/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import { mockTracking } from 'helpers/tracking_helper';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting_bubble_menu.vue';
-import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
-import { stubComponent } from 'helpers/stub_component';
-
-import {
- BUBBLE_MENU_TRACKING_ACTION,
- CONTENT_EDITOR_TRACKING_LABEL,
-} from '~/content_editor/constants';
-import { createTestEditor } from '../../test_utils';
-
-describe('content_editor/components/bubble_menus/formatting_bubble_menu', () => {
- let wrapper;
- let trackingSpy;
- let tiptapEditor;
-
- const buildEditor = () => {
- tiptapEditor = createTestEditor();
-
- jest.spyOn(tiptapEditor, 'isActive');
- };
-
- const buildWrapper = () => {
- wrapper = shallowMountExtended(FormattingBubbleMenu, {
- provide: {
- tiptapEditor,
- },
- stubs: {
- BubbleMenu: stubComponent(BubbleMenu),
- },
- });
- };
-
- beforeEach(() => {
- trackingSpy = mockTracking(undefined, null, jest.spyOn);
- buildEditor();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders bubble menu component', () => {
- buildWrapper();
- const bubbleMenu = wrapper.findComponent(BubbleMenu);
-
- expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
- });
-
- describe.each`
- testId | controlProps
- ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }}
- ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }}
- ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }}
- ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
- ${'superscript'} | ${{ contentType: 'superscript', iconName: 'superscript', label: 'Superscript', editorCommand: 'toggleSuperscript' }}
- ${'subscript'} | ${{ contentType: 'subscript', iconName: 'subscript', label: 'Subscript', editorCommand: 'toggleSubscript' }}
- ${'highlight'} | ${{ contentType: 'highlight', iconName: 'highlight', label: 'Highlight', editorCommand: 'toggleHighlight' }}
- ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'toggleLink', editorCommandParams: { href: '' } }}
- `('given a $testId toolbar control', ({ testId, controlProps }) => {
- beforeEach(() => {
- buildWrapper();
- });
-
- it('renders the toolbar control with the provided properties', () => {
- expect(wrapper.findByTestId(testId).exists()).toBe(true);
-
- expect(wrapper.findByTestId(testId).props()).toEqual(
- expect.objectContaining({
- ...controlProps,
- size: 'medium',
- category: 'tertiary',
- }),
- );
- });
-
- it('tracks the execution of toolbar controls', () => {
- const eventData = { contentType: 'italic', value: 1 };
- const { contentType, value } = eventData;
-
- wrapper.findByTestId(testId).vm.$emit('execute', eventData);
-
- expect(trackingSpy).toHaveBeenCalledWith(undefined, BUBBLE_MENU_TRACKING_ACTION, {
- label: CONTENT_EDITOR_TRACKING_LABEL,
- property: contentType,
- value,
- });
- });
- });
-});
diff --git a/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js
index 9aa9c6483f4..c79df9c9ed8 100644
--- a/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js
@@ -7,7 +7,7 @@ import eventHubFactory from '~/helpers/event_hub_factory';
import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
import { stubComponent } from 'helpers/stub_component';
import Link from '~/content_editor/extensions/link';
-import { createTestEditor } from '../../test_utils';
+import { createTestEditor, emitEditorEvent, createTransactionWithMeta } from '../../test_utils';
const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() });
@@ -59,22 +59,18 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => {
expect(wrapper.findByTestId('remove-link').exists()).toBe(exist);
};
- beforeEach(async () => {
+ beforeEach(() => {
buildEditor();
tiptapEditor
.chain()
- .insertContent(
- 'Download <a href="/path/to/project/-/wikis/uploads/my_file.pdf" data-canonical-src="uploads/my_file.pdf" title="Click here to download">PDF File</a>',
+ .setContent(
+ 'Download <a href="/path/to/project/-/wikis/uploads/my_file.pdf" data-canonical-src="uploads/my_file.pdf">PDF File</a>',
)
.setTextSelection(14) // put cursor in the middle of the link
.run();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders bubble menu component', async () => {
await buildWrapperAndDisplayMenu();
@@ -88,13 +84,42 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => {
expect.objectContaining({
href: '/path/to/project/-/wikis/uploads/my_file.pdf',
'aria-label': 'uploads/my_file.pdf',
- title: 'uploads/my_file.pdf',
target: '_blank',
}),
);
expect(findLink().text()).toBe('uploads/my_file.pdf');
});
+ it('shows a loading percentage for a file being uploaded', async () => {
+ const setUploadProgress = async (progress) => {
+ const transaction = createTransactionWithMeta('uploadProgress', {
+ filename: 'my_file.pdf',
+ progress,
+ });
+ await emitEditorEvent({ event: 'transaction', tiptapEditor, params: { transaction } });
+ };
+
+ tiptapEditor
+ .chain()
+ .extendMarkRange('link')
+ .updateAttributes('link', { uploading: 'my_file.pdf' })
+ .run();
+
+ await buildWrapperAndDisplayMenu();
+
+ expect(findLink().exists()).toBe(false);
+ expect(wrapper.text()).toContain('Uploading: 0%');
+
+ await setUploadProgress(0.4);
+ expect(wrapper.text()).toContain('Uploading: 40%');
+
+ await setUploadProgress(0.7);
+ expect(wrapper.text()).toContain('Uploading: 70%');
+
+ await setUploadProgress(1);
+ expect(wrapper.text()).toContain('Uploading: 100%');
+ });
+
it('updates the bubble menu state when @selectionUpdate event is triggered', async () => {
const linkUrl = 'https://gitlab.com';
@@ -185,52 +210,17 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => {
});
});
- describe('for a placeholder link', () => {
- beforeEach(async () => {
- tiptapEditor
- .chain()
- .clearContent()
- .insertContent('Dummy link')
- .selectAll()
- .setLink({ href: '' })
- .setTextSelection(4)
- .run();
-
- await buildWrapperAndDisplayMenu();
- });
-
- it('directly opens the edit form for a placeholder link', async () => {
- expectLinkButtonsToExist(false);
-
- expect(wrapper.findComponent(GlForm).exists()).toBe(true);
- });
-
- it('removes the link on clicking apply (if no change)', async () => {
- await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent());
-
- expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>');
- });
-
- it('removes the link on clicking cancel', async () => {
- await wrapper.findByTestId('cancel-link').vm.$emit('click');
-
- expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>');
- });
- });
-
describe('edit button', () => {
let linkHrefInput;
- let linkTitleInput;
beforeEach(async () => {
await buildWrapperAndDisplayMenu();
await wrapper.findByTestId('edit-link').vm.$emit('click');
linkHrefInput = wrapper.findByTestId('link-href');
- linkTitleInput = wrapper.findByTestId('link-title');
});
- it('hides the link and copy/edit/remove link buttons', async () => {
+ it('hides the link and copy/edit/remove link buttons', () => {
expectLinkButtonsToExist(false);
});
@@ -238,7 +228,6 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => {
expect(wrapper.findComponent(GlForm).exists()).toBe(true);
expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf');
- expect(linkTitleInput.element.value).toBe('Click here to download');
});
it('extends selection to select the entire link', () => {
@@ -251,26 +240,18 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => {
describe('after making changes in the form and clicking apply', () => {
beforeEach(async () => {
linkHrefInput.setValue('https://google.com');
- linkTitleInput.setValue('Search Google');
contentEditor.resolveUrl.mockResolvedValue('https://google.com');
await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent());
});
- it('updates prosemirror doc with new link', async () => {
- expect(tiptapEditor.getHTML()).toBe(
- '<p>Download <a target="_blank" rel="noopener noreferrer nofollow" href="https://google.com" title="Search Google">PDF File</a></p>',
- );
- });
-
it('updates the link in the bubble menu', () => {
const link = wrapper.findComponent(GlLink);
expect(link.attributes()).toEqual(
expect.objectContaining({
href: 'https://google.com',
'aria-label': 'https://google.com',
- title: 'https://google.com',
target: '_blank',
}),
);
@@ -281,7 +262,6 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => {
describe('after making changes in the form and clicking cancel', () => {
beforeEach(async () => {
linkHrefInput.setValue('https://google.com');
- linkTitleInput.setValue('Search Google');
await wrapper.findByTestId('cancel-link').vm.$emit('click');
});
@@ -289,17 +269,6 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => {
it('hides the form and shows the copy/edit/remove link buttons', () => {
expectLinkButtonsToExist();
});
-
- it('resets the form with old values of the link from prosemirror', async () => {
- // click edit once again to show the form back
- await wrapper.findByTestId('edit-link').vm.$emit('click');
-
- linkHrefInput = wrapper.findByTestId('link-href');
- linkTitleInput = wrapper.findByTestId('link-title');
-
- expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf');
- expect(linkTitleInput.element.value).toBe('Click here to download');
- });
});
});
});
diff --git a/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js
index 13c6495ac41..89beb76a6f2 100644
--- a/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js
@@ -1,49 +1,61 @@
+import { nextTick } from 'vue';
import { GlLink, GlForm } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue';
import { stubComponent } from 'helpers/stub_component';
import eventHubFactory from '~/helpers/event_hub_factory';
-import Image from '~/content_editor/extensions/image';
import Audio from '~/content_editor/extensions/audio';
+import DrawioDiagram from '~/content_editor/extensions/drawio_diagram';
+import Image from '~/content_editor/extensions/image';
import Video from '~/content_editor/extensions/video';
-import { createTestEditor, emitEditorEvent, mockChainedCommands } from '../../test_utils';
+import {
+ createTestEditor,
+ emitEditorEvent,
+ mockChainedCommands,
+ createTransactionWithMeta,
+} from '../../test_utils';
import {
PROJECT_WIKI_ATTACHMENT_IMAGE_HTML,
PROJECT_WIKI_ATTACHMENT_AUDIO_HTML,
PROJECT_WIKI_ATTACHMENT_VIDEO_HTML,
+ PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML,
} from '../../test_constants';
-const TIPTAP_IMAGE_HTML = `<p>
+const TIPTAP_AUDIO_HTML = `<p>
+ <span class="media-container audio-container"><audio src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></audio><a href="https://gitlab.com/favicon.png" class="with-attachment-icon">gitlab favicon</a></span>
+</p>`;
+
+const TIPTAP_DIAGRAM_HTML = `<p>
<img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon">
</p>`;
-const TIPTAP_AUDIO_HTML = `<p>
- <span class="media-container audio-container"><audio src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></audio><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span>
+const TIPTAP_IMAGE_HTML = `<p>
+ <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon">
</p>`;
const TIPTAP_VIDEO_HTML = `<p>
- <span class="media-container video-container"><video src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></video><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span>
+ <span class="media-container video-container"><video src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></video><a href="https://gitlab.com/favicon.png" class="with-attachment-icon">gitlab favicon</a></span>
</p>`;
const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() });
describe.each`
- mediaType | mediaHTML | filePath | mediaOutputHTML
- ${'image'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${'test-file.png'} | ${TIPTAP_IMAGE_HTML}
- ${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${'test-file.mp3'} | ${TIPTAP_AUDIO_HTML}
- ${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${'test-file.mp4'} | ${TIPTAP_VIDEO_HTML}
+ mediaType | mediaHTML | filePath | mediaOutputHTML
+ ${'image'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${'test-file.png'} | ${TIPTAP_IMAGE_HTML}
+ ${'drawioDiagram'} | ${PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML} | ${'test-file.drawio.svg'} | ${TIPTAP_DIAGRAM_HTML}
+ ${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${'test-file.mp3'} | ${TIPTAP_AUDIO_HTML}
+ ${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${'test-file.mp4'} | ${TIPTAP_VIDEO_HTML}
`(
'content_editor/components/bubble_menus/media_bubble_menu ($mediaType)',
({ mediaType, mediaHTML, filePath, mediaOutputHTML }) => {
let wrapper;
let tiptapEditor;
let contentEditor;
- let bubbleMenu;
let eventHub;
const buildEditor = () => {
- tiptapEditor = createTestEditor({ extensions: [Image, Audio, Video] });
+ tiptapEditor = createTestEditor({ extensions: [Image, Audio, Video, DrawioDiagram] });
contentEditor = { resolveUrl: jest.fn() };
eventHub = eventHubFactory();
};
@@ -61,6 +73,24 @@ describe.each`
});
};
+ const findBubbleMenu = () => wrapper.findComponent(BubbleMenu);
+
+ const showMenu = async () => {
+ findBubbleMenu().vm.$emit('show');
+ await emitEditorEvent({
+ event: 'transaction',
+ tiptapEditor,
+ params: { transaction: createTransactionWithMeta() },
+ });
+ await nextTick();
+ };
+
+ const buildWrapperAndDisplayMenu = () => {
+ buildWrapper();
+
+ return showMenu();
+ };
+
const selectFile = async (file) => {
const input = wrapper.findComponent({ ref: 'fileSelector' });
@@ -76,9 +106,8 @@ describe.each`
expect(wrapper.findByTestId('delete-media').exists()).toBe(exist);
};
- beforeEach(async () => {
+ beforeEach(() => {
buildEditor();
- buildWrapper();
tiptapEditor
.chain()
@@ -87,21 +116,17 @@ describe.each`
.run();
contentEditor.resolveUrl.mockResolvedValue(`/group1/project1/-/wikis/${filePath}`);
-
- await emitEditorEvent({ event: 'transaction', tiptapEditor });
-
- bubbleMenu = wrapper.findComponent(BubbleMenu);
- });
-
- afterEach(() => {
- wrapper.destroy();
});
it('renders bubble menu component', async () => {
- expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
+ await buildWrapperAndDisplayMenu();
+
+ expect(findBubbleMenu().classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
});
it('shows a clickable link to the image', async () => {
+ await buildWrapperAndDisplayMenu();
+
const link = wrapper.findComponent(GlLink);
expect(link.attributes()).toEqual(
expect.objectContaining({
@@ -114,8 +139,61 @@ describe.each`
expect(link.text()).toBe(filePath);
});
+ it('shows a loading percentage for a file being uploaded', async () => {
+ jest.spyOn(tiptapEditor, 'isActive').mockImplementation((name) => name === mediaType);
+
+ await buildWrapperAndDisplayMenu();
+
+ const setUploadProgress = async (progress) => {
+ const transaction = createTransactionWithMeta('uploadProgress', {
+ filename: filePath,
+ progress,
+ });
+ await emitEditorEvent({ event: 'transaction', tiptapEditor, params: { transaction } });
+ };
+
+ tiptapEditor.chain().selectAll().updateAttributes(mediaType, { uploading: filePath }).run();
+
+ await emitEditorEvent({ event: 'selectionUpdate', tiptapEditor });
+
+ expect(wrapper.findComponent(GlLink).exists()).toBe(false);
+ expect(wrapper.text()).toContain('Uploading: 0%');
+
+ await setUploadProgress(0.4);
+
+ expect(wrapper.text()).toContain('Uploading: 40%');
+
+ await setUploadProgress(0.7);
+ expect(wrapper.text()).toContain('Uploading: 70%');
+
+ await setUploadProgress(1);
+ expect(wrapper.text()).toContain('Uploading: 100%');
+ });
+
+ describe('when BubbleMenu emits hidden event', () => {
+ it('resets media bubble menu state', async () => {
+ await buildWrapperAndDisplayMenu();
+
+ // Switch to edit mode to access component state in form fields
+ await wrapper.findByTestId('edit-media').vm.$emit('click');
+
+ const mediaSrcInput = wrapper.findByTestId('media-src').vm.$el;
+ const mediaAltInput = wrapper.findByTestId('media-alt').vm.$el;
+
+ expect(mediaSrcInput.value).not.toBe('');
+ expect(mediaAltInput.value).not.toBe('');
+
+ await wrapper.findComponent(BubbleMenu).vm.$emit('hidden');
+
+ expect(mediaSrcInput.value).toBe('');
+ expect(mediaAltInput.value).toBe('');
+ });
+ });
+
describe('copy button', () => {
it(`copies the canonical link to the ${mediaType} to clipboard`, async () => {
+ await buildWrapperAndDisplayMenu();
+
jest.spyOn(navigator.clipboard, 'writeText');
await wrapper.findByTestId('copy-media-src').vm.$emit('click');
@@ -126,6 +204,8 @@ describe.each`
describe(`remove ${mediaType} button`, () => {
it(`removes the ${mediaType}`, async () => {
+ await buildWrapperAndDisplayMenu();
+
await wrapper.findByTestId('delete-media').vm.$emit('click');
expect(tiptapEditor.getHTML()).toBe('<p>\n \n</p>');
@@ -133,23 +213,41 @@ describe.each`
});
describe(`replace ${mediaType} button`, () => {
- it('uploads and replaces the selected image when file input changes', async () => {
- const commands = mockChainedCommands(tiptapEditor, [
- 'focus',
- 'deleteSelection',
- 'uploadAttachment',
- 'run',
- ]);
- const file = new File(['foo'], 'foo.png', { type: 'image/png' });
-
- await wrapper.findByTestId('replace-media').vm.$emit('click');
- await selectFile(file);
-
- expect(commands.focus).toHaveBeenCalled();
- expect(commands.deleteSelection).toHaveBeenCalled();
- expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
- expect(commands.run).toHaveBeenCalled();
- });
+ beforeEach(buildWrapperAndDisplayMenu);
+
+ if (mediaType !== 'drawioDiagram') {
+ it('uploads and replaces the selected image when file input changes', async () => {
+ const commands = mockChainedCommands(tiptapEditor, [
+ 'focus',
+ 'deleteSelection',
+ 'uploadAttachment',
+ 'run',
+ ]);
+ const file = new File(['foo'], 'foo.png', { type: 'image/png' });
+
+ await wrapper.findByTestId('replace-media').vm.$emit('click');
+ await selectFile(file);
+
+ expect(commands.focus).toHaveBeenCalled();
+ expect(commands.deleteSelection).toHaveBeenCalled();
+ expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
+ expect(commands.run).toHaveBeenCalled();
+ });
+ } else {
+ // draw.io diagrams are replaced using the edit diagram button
+ it('invokes editDiagram command', async () => {
+ const commands = mockChainedCommands(tiptapEditor, [
+ 'focus',
+ 'createOrEditDiagram',
+ 'run',
+ ]);
+ await wrapper.findByTestId('edit-diagram').vm.$emit('click');
+
+ expect(commands.focus).toHaveBeenCalled();
+ expect(commands.createOrEditDiagram).toHaveBeenCalled();
+ expect(commands.run).toHaveBeenCalled();
+ });
+ }
});
describe('edit button', () => {
@@ -158,6 +256,8 @@ describe.each`
let mediaAltInput;
beforeEach(async () => {
+ await buildWrapperAndDisplayMenu();
+
await wrapper.findByTestId('edit-media').vm.$emit('click');
mediaSrcInput = wrapper.findByTestId('media-src');
@@ -165,7 +265,7 @@ describe.each`
mediaAltInput = wrapper.findByTestId('media-alt');
});
- it('hides the link and copy/edit/remove link buttons', async () => {
+ it('hides the link and copy/edit/remove link buttons', () => {
expectLinkButtonsToExist(false);
});
@@ -188,7 +288,7 @@ describe.each`
await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent());
});
- it(`updates prosemirror doc with new src to the ${mediaType}`, async () => {
+ it(`updates prosemirror doc with new src to the ${mediaType}`, () => {
expect(tiptapEditor.getHTML()).toBe(mediaOutputHTML);
});
diff --git a/spec/frontend/content_editor/components/content_editor_alert_spec.js b/spec/frontend/content_editor/components/content_editor_alert_spec.js
index ee9ead8f8a7..e6873e2cf96 100644
--- a/spec/frontend/content_editor/components/content_editor_alert_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_alert_spec.js
@@ -14,7 +14,7 @@ describe('content_editor/components/content_editor_alert', () => {
const findErrorAlert = () => wrapper.findComponent(GlAlert);
- const createWrapper = async () => {
+ const createWrapper = () => {
tiptapEditor = createTestEditor();
eventHub = eventHubFactory();
@@ -29,10 +29,6 @@ describe('content_editor/components/content_editor_alert', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
variant | message
${'danger'} | ${'An error occurred'}
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index 1a3cd36a8bb..852c8a9591a 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert } from '@gitlab/ui';
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { EditorContent, Editor } from '@tiptap/vue-2';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -6,7 +6,6 @@ import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.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/bubble_menus/formatting_bubble_menu.vue';
import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue';
import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue';
import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue';
@@ -27,19 +26,22 @@ describe('ContentEditor', () => {
const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
const findLoadingIndicator = () => wrapper.findComponent(LoadingIndicator);
const findContentEditorAlert = () => wrapper.findComponent(ContentEditorAlert);
- const createWrapper = ({ markdown, autofocus, useBottomToolbar } = {}) => {
+ const createWrapper = ({ markdown, autofocus, ...props } = {}) => {
wrapper = shallowMountExtended(ContentEditor, {
propsData: {
renderMarkdown,
uploadsPath,
markdown,
autofocus,
- useBottomToolbar,
+ placeholder: 'Enter some text here...',
+ ...props,
},
stubs: {
EditorStateObserver,
ContentEditorProvider,
ContentEditorAlert,
+ GlLink,
+ GlSprintf,
},
});
};
@@ -48,10 +50,6 @@ describe('ContentEditor', () => {
renderMarkdown = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('triggers initialized event', () => {
createWrapper();
@@ -87,22 +85,23 @@ describe('ContentEditor', () => {
expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true);
});
- it('renders top toolbar component', () => {
+ it('renders toolbar component', () => {
createWrapper();
expect(wrapper.findComponent(FormattingToolbar).exists()).toBe(true);
- expect(wrapper.findComponent(FormattingToolbar).classes('gl-border-t')).toBe(false);
- expect(wrapper.findComponent(FormattingToolbar).classes('gl-border-b')).toBe(true);
});
- it('renders bottom toolbar component', () => {
- createWrapper({
- useBottomToolbar: true,
- });
+ it('renders footer containing quick actions help text if quick actions docs path is defined', () => {
+ createWrapper({ quickActionsDocsPath: '/foo/bar' });
- expect(wrapper.findComponent(FormattingToolbar).exists()).toBe(true);
- expect(wrapper.findComponent(FormattingToolbar).classes('gl-border-t')).toBe(true);
- expect(wrapper.findComponent(FormattingToolbar).classes('gl-border-b')).toBe(false);
+ expect(findEditorElement().text()).toContain('For quick actions, type /');
+ expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/foo/bar');
+ });
+
+ it('does not render footer containing quick actions help text if quick actions docs path is not defined', () => {
+ createWrapper();
+
+ expect(findEditorElement().text()).not.toContain('For quick actions, type /');
});
describe('when setting initial content', () => {
@@ -124,9 +123,9 @@ describe('ContentEditor', () => {
describe('succeeds', () => {
beforeEach(async () => {
- renderMarkdown.mockResolvedValueOnce('hello world');
+ renderMarkdown.mockResolvedValueOnce('');
- createWrapper({ markddown: 'hello world' });
+ createWrapper({ markddown: '' });
await nextTick();
});
@@ -138,13 +137,17 @@ describe('ContentEditor', () => {
it('emits loadingSuccess event', () => {
expect(wrapper.emitted('loadingSuccess')).toHaveLength(1);
});
+
+ it('shows placeholder text', () => {
+ expect(wrapper.text()).toContain('Enter some text here...');
+ });
});
describe('fails', () => {
beforeEach(async () => {
renderMarkdown.mockRejectedValueOnce(new Error());
- createWrapper({ markddown: 'hello world' });
+ createWrapper({ markdown: 'hello world' });
await nextTick();
});
@@ -209,11 +212,17 @@ describe('ContentEditor', () => {
expect(findEditorElement().classes()).not.toContain('is-focused');
});
+
+ it('hides placeholder text', () => {
+ expect(wrapper.text()).not.toContain('Enter some text here...');
+ });
});
describe('when editorStateObserver emits docUpdate event', () => {
- it('emits change event with the latest markdown', async () => {
- const markdown = 'Loaded content';
+ let markdown;
+
+ beforeEach(async () => {
+ markdown = 'Loaded content';
renderMarkdown.mockResolvedValueOnce(markdown);
@@ -223,7 +232,9 @@ describe('ContentEditor', () => {
await waitForPromises();
findEditorStateObserver().vm.$emit('docUpdate');
+ });
+ it('emits change event with the latest markdown', () => {
expect(wrapper.emitted('change')).toEqual([
[
{
@@ -234,6 +245,10 @@ describe('ContentEditor', () => {
],
]);
});
+
+ it('hides the placeholder text', () => {
+ expect(wrapper.text()).not.toContain('Enter some text here...');
+ });
});
describe('when editorStateObserver emits keydown event', () => {
@@ -248,11 +263,10 @@ describe('ContentEditor', () => {
});
it.each`
- name | component
- ${'formatting'} | ${FormattingBubbleMenu}
- ${'link'} | ${LinkBubbleMenu}
- ${'media'} | ${MediaBubbleMenu}
- ${'codeBlock'} | ${CodeBlockBubbleMenu}
+ name | component
+ ${'link'} | ${LinkBubbleMenu}
+ ${'media'} | ${MediaBubbleMenu}
+ ${'codeBlock'} | ${CodeBlockBubbleMenu}
`('renders formatting bubble menu', ({ component }) => {
createWrapper();
diff --git a/spec/frontend/content_editor/components/editor_state_observer_spec.js b/spec/frontend/content_editor/components/editor_state_observer_spec.js
index 9b42f61c98c..80fb20e5258 100644
--- a/spec/frontend/content_editor/components/editor_state_observer_spec.js
+++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js
@@ -45,10 +45,6 @@ describe('content_editor/components/editor_state_observer', () => {
buildEditor();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when editor content changes', () => {
it('emits update, selectionUpdate, and transaction events', () => {
const content = '<p>My paragraph</p>';
diff --git a/spec/frontend/content_editor/components/formatting_toolbar_spec.js b/spec/frontend/content_editor/components/formatting_toolbar_spec.js
index c4bf21ba813..e04c6a00765 100644
--- a/spec/frontend/content_editor/components/formatting_toolbar_spec.js
+++ b/spec/frontend/content_editor/components/formatting_toolbar_spec.js
@@ -1,3 +1,4 @@
+import { GlTabs, GlTab } from '@gitlab/ui';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vue';
@@ -5,35 +6,39 @@ import {
TOOLBAR_CONTROL_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
+import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
-describe('content_editor/components/top_toolbar', () => {
+describe('content_editor/components/formatting_toolbar', () => {
let wrapper;
let trackingSpy;
const buildWrapper = () => {
- wrapper = shallowMountExtended(FormattingToolbar);
+ wrapper = shallowMountExtended(FormattingToolbar, {
+ stubs: {
+ GlTabs,
+ GlTab,
+ EditorModeSwitcher,
+ },
+ });
};
beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each`
testId | controlProps
${'text-styles'} | ${{}}
${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }}
${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }}
+ ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }}
${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
${'link'} | ${{}}
${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }}
${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a checklist', editorCommand: 'toggleTaskList' }}
- ${'image'} | ${{}}
+ ${'attachment'} | ${{}}
${'table'} | ${{}}
${'more'} | ${{}}
`('given a $testId toolbar control', ({ testId, controlProps }) => {
@@ -62,4 +67,10 @@ describe('content_editor/components/top_toolbar', () => {
});
});
});
+
+ it('renders an editor mode dropdown', () => {
+ buildWrapper();
+
+ expect(wrapper.findComponent(EditorModeSwitcher).exists()).toBe(true);
+ });
});
diff --git a/spec/frontend/content_editor/components/loading_indicator_spec.js b/spec/frontend/content_editor/components/loading_indicator_spec.js
index 0065103d01b..1b0ffaee6c6 100644
--- a/spec/frontend/content_editor/components/loading_indicator_spec.js
+++ b/spec/frontend/content_editor/components/loading_indicator_spec.js
@@ -11,10 +11,6 @@ describe('content_editor/components/loading_indicator', () => {
wrapper = shallowMountExtended(LoadingIndicator);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when loading content', () => {
beforeEach(() => {
createWrapper();
diff --git a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js
index e72eb892e74..9d34d9d0e9e 100644
--- a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlAvatarLabeled, GlDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import SuggestionsDropdown from '~/content_editor/components/suggestions_dropdown.vue';
@@ -75,6 +75,26 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
unicodeVersion: '6.0',
};
+ it.each`
+ loading | description
+ ${false} | ${'does not show a loading indicator'}
+ ${true} | ${'shows a loading indicator'}
+ `('$description if loading=$loading', ({ loading }) => {
+ buildWrapper({
+ propsData: {
+ loading,
+ char: '@',
+ nodeType: 'reference',
+ nodeProps: {
+ referenceType: 'member',
+ },
+ items: [exampleUser],
+ },
+ });
+
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(loading);
+ });
+
describe('on item select', () => {
it.each`
nodeType | referenceType | char | reference | insertedText | insertedProps
diff --git a/spec/frontend/content_editor/components/toolbar_attachment_button_spec.js b/spec/frontend/content_editor/components/toolbar_attachment_button_spec.js
new file mode 100644
index 00000000000..c6793d5b01b
--- /dev/null
+++ b/spec/frontend/content_editor/components/toolbar_attachment_button_spec.js
@@ -0,0 +1,60 @@
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import ToolbarAttachmentButton from '~/content_editor/components/toolbar_attachment_button.vue';
+import Attachment from '~/content_editor/extensions/attachment';
+import Link from '~/content_editor/extensions/link';
+import { createTestEditor, mockChainedCommands } from '../test_utils';
+
+describe('content_editor/components/toolbar_attachment_button', () => {
+ let wrapper;
+ let editor;
+
+ const buildWrapper = () => {
+ wrapper = mountExtended(ToolbarAttachmentButton, {
+ provide: {
+ tiptapEditor: editor,
+ },
+ });
+ };
+
+ const selectFiles = async (...files) => {
+ const input = wrapper.findComponent({ ref: 'fileSelector' });
+
+ // override the property definition because `input.files` isn't directly modifyable
+ Object.defineProperty(input.element, 'files', { value: files, writable: true });
+ await input.trigger('change');
+ };
+
+ beforeEach(() => {
+ editor = createTestEditor({
+ extensions: [
+ Link,
+ Image,
+ Attachment.configure({
+ renderMarkdown: jest.fn(),
+ uploadsPath: '/uploads/',
+ }),
+ ],
+ });
+
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ editor.destroy();
+ });
+
+ it('uploads the selected attachment when file input changes', async () => {
+ const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']);
+ const file1 = new File(['foo'], 'foo.png', { type: 'image/png' });
+ const file2 = new File(['bar'], 'bar.png', { type: 'image/png' });
+
+ await selectFiles(file1, file2);
+
+ expect(commands.focus).toHaveBeenCalled();
+ expect(commands.uploadAttachment).toHaveBeenCalledWith({ file: file1 });
+ expect(commands.uploadAttachment).toHaveBeenCalledWith({ file: file2 });
+ expect(commands.run).toHaveBeenCalled();
+
+ expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link', value: 'upload' }]);
+ });
+});
diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js
index 1f1f7b338c6..ffe1ae20ee9 100644
--- a/spec/frontend/content_editor/components/toolbar_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_button_spec.js
@@ -42,10 +42,6 @@ describe('content_editor/components/toolbar_button', () => {
buildEditor();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays tertiary, medium button with a provided label and icon', () => {
buildWrapper();
@@ -85,7 +81,7 @@ describe('content_editor/components/toolbar_button', () => {
await emitEditorEvent({ event: 'transaction', tiptapEditor });
- expect(findButton().classes().includes('active')).toBe(outcome);
+ expect(findButton().classes().includes('gl-bg-gray-100!')).toBe(outcome);
expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE);
},
);
diff --git a/spec/frontend/content_editor/components/toolbar_image_button_spec.js b/spec/frontend/content_editor/components/toolbar_image_button_spec.js
deleted file mode 100644
index 5473d43f5a1..00000000000
--- a/spec/frontend/content_editor/components/toolbar_image_button_spec.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import { GlButton, GlFormInputGroup, GlDropdown } from '@gitlab/ui';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue';
-import Attachment from '~/content_editor/extensions/attachment';
-import Image from '~/content_editor/extensions/image';
-import { stubComponent } from 'helpers/stub_component';
-import { createTestEditor, mockChainedCommands } from '../test_utils';
-
-describe('content_editor/components/toolbar_image_button', () => {
- let wrapper;
- let editor;
-
- const buildWrapper = () => {
- wrapper = mountExtended(ToolbarImageButton, {
- provide: {
- tiptapEditor: editor,
- },
- stubs: {
- GlDropdown: stubComponent(GlDropdown),
- },
- });
- };
-
- const findImageURLInput = () =>
- wrapper.findComponent(GlFormInputGroup).find('input[type="text"]');
- const findApplyImageButton = () => wrapper.findComponent(GlButton);
- const findDropdown = () => wrapper.findComponent(GlDropdown);
-
- const selectFile = async (file) => {
- const input = wrapper.findComponent({ ref: 'fileSelector' });
-
- // override the property definition because `input.files` isn't directly modifyable
- Object.defineProperty(input.element, 'files', { value: [file], writable: true });
- await input.trigger('change');
- };
-
- beforeEach(() => {
- editor = createTestEditor({
- extensions: [
- Image,
- Attachment.configure({
- renderMarkdown: jest.fn(),
- uploadsPath: '/uploads/',
- }),
- ],
- });
-
- buildWrapper();
- });
-
- afterEach(() => {
- editor.destroy();
- wrapper.destroy();
- });
-
- it('sets the image to the value in the URL input when "Insert" button is clicked', async () => {
- const commands = mockChainedCommands(editor, ['focus', 'setImage', 'run']);
-
- await findImageURLInput().setValue('https://example.com/img.jpg');
- await findApplyImageButton().trigger('click');
-
- expect(commands.focus).toHaveBeenCalled();
- expect(commands.setImage).toHaveBeenCalledWith({
- alt: 'img',
- src: 'https://example.com/img.jpg',
- canonicalSrc: 'https://example.com/img.jpg',
- });
- expect(commands.run).toHaveBeenCalled();
-
- expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'url' }]);
- });
-
- it('uploads the selected image when file input changes', async () => {
- const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']);
- const file = new File(['foo'], 'foo.png', { type: 'image/png' });
-
- await selectFile(file);
-
- expect(commands.focus).toHaveBeenCalled();
- expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
- expect(commands.run).toHaveBeenCalled();
-
- expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]);
- });
-
- describe('a11y tests', () => {
- it('sets text, title, and text-sr-only properties to the table button dropdown', () => {
- buildWrapper();
-
- expect(findDropdown().props()).toMatchObject({
- text: 'Insert image',
- textSrOnly: true,
- });
- expect(findDropdown().attributes('title')).toBe('Insert image');
- });
- });
-});
diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js
deleted file mode 100644
index 40e859e96af..00000000000
--- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js
+++ /dev/null
@@ -1,224 +0,0 @@
-import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
-import eventHubFactory from '~/helpers/event_hub_factory';
-import Link from '~/content_editor/extensions/link';
-import { hasSelection } from '~/content_editor/services/utils';
-import { stubComponent } from 'helpers/stub_component';
-import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
-
-jest.mock('~/content_editor/services/utils');
-
-describe('content_editor/components/toolbar_link_button', () => {
- let wrapper;
- let editor;
-
- const buildWrapper = () => {
- wrapper = mountExtended(ToolbarLinkButton, {
- provide: {
- tiptapEditor: editor,
- eventHub: eventHubFactory(),
- },
- stubs: {
- GlDropdown: stubComponent(GlDropdown),
- },
- });
- };
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]');
- const findApplyLinkButton = () => wrapper.findComponent(GlButton);
- const findRemoveLinkButton = () => wrapper.findByText('Remove link');
-
- const selectFile = async (file) => {
- const input = wrapper.findComponent({ ref: 'fileSelector' });
-
- // override the property definition because `input.files` isn't directly modifyable
- Object.defineProperty(input.element, 'files', { value: [file], writable: true });
- await input.trigger('change');
- };
-
- beforeEach(() => {
- editor = createTestEditor();
- });
-
- afterEach(() => {
- editor.destroy();
- wrapper.destroy();
- });
-
- it('renders dropdown component', () => {
- buildWrapper();
-
- expect(findDropdown().html()).toMatchSnapshot();
- });
-
- describe('when there is an active link', () => {
- beforeEach(async () => {
- jest.spyOn(editor, 'isActive').mockReturnValueOnce(true);
- buildWrapper();
-
- await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
- });
-
- it('sets dropdown as active when link extension is active', () => {
- expect(findDropdown().props('toggleClass')).toEqual({ active: true });
- });
-
- it('does not display the upload file option', () => {
- expect(wrapper.findByText('Upload file').exists()).toBe(false);
- });
-
- it('displays a remove link dropdown option', () => {
- expect(wrapper.findByText('Remove link').exists()).toBe(true);
- });
-
- it('executes removeLink command when the remove link option is clicked', async () => {
- const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'run']);
-
- await findRemoveLinkButton().trigger('click');
-
- expect(commands.unsetLink).toHaveBeenCalled();
- expect(commands.focus).toHaveBeenCalled();
- expect(commands.run).toHaveBeenCalled();
- });
-
- it('updates the link with a new link when "Apply" button is clicked', async () => {
- const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']);
-
- await findLinkURLInput().setValue('https://example');
- await findApplyLinkButton().trigger('click');
-
- expect(commands.focus).toHaveBeenCalled();
- expect(commands.unsetLink).toHaveBeenCalled();
- expect(commands.setLink).toHaveBeenCalledWith({
- href: 'https://example',
- canonicalSrc: 'https://example',
- });
- expect(commands.run).toHaveBeenCalled();
-
- expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
- });
-
- describe('on selection update', () => {
- it('updates link input box with canonical-src if present', async () => {
- jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({
- canonicalSrc: 'uploads/my-file.zip',
- href: '/username/my-project/uploads/abcdefgh133535/my-file.zip',
- });
-
- await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
-
- expect(findLinkURLInput().element.value).toEqual('uploads/my-file.zip');
- });
-
- it('updates link input box with link href otherwise', async () => {
- jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({
- href: 'https://gitlab.com',
- });
-
- await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
-
- expect(findLinkURLInput().element.value).toEqual('https://gitlab.com');
- });
- });
- });
-
- describe('when there is no active link', () => {
- beforeEach(() => {
- jest.spyOn(editor, 'isActive');
- editor.isActive.mockReturnValueOnce(false);
- buildWrapper();
- });
-
- it('does not set dropdown as active', () => {
- expect(findDropdown().props('toggleClass')).toEqual({ active: false });
- });
-
- it('displays the upload file option', () => {
- expect(wrapper.findByText('Upload file').exists()).toBe(true);
- });
-
- it('does not display a remove link dropdown option', () => {
- expect(wrapper.findByText('Remove link').exists()).toBe(false);
- });
-
- it('sets the link to the value in the URL input when "Apply" button is clicked', async () => {
- const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']);
-
- await findLinkURLInput().setValue('https://example');
- await findApplyLinkButton().trigger('click');
-
- expect(commands.focus).toHaveBeenCalled();
- expect(commands.setLink).toHaveBeenCalledWith({
- href: 'https://example',
- canonicalSrc: 'https://example',
- });
- expect(commands.run).toHaveBeenCalled();
-
- expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
- });
-
- it('uploads the selected image when file input changes', async () => {
- const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']);
- const file = new File(['foo'], 'foo.png', { type: 'image/png' });
-
- await selectFile(file);
-
- expect(commands.focus).toHaveBeenCalled();
- expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
- expect(commands.run).toHaveBeenCalled();
-
- expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
- });
- });
-
- describe('when the user displays the dropdown', () => {
- let commands;
-
- beforeEach(() => {
- commands = mockChainedCommands(editor, ['focus', 'extendMarkRange', 'run']);
- });
-
- describe('given the user has not selected text', () => {
- beforeEach(() => {
- hasSelection.mockReturnValueOnce(false);
- });
-
- it('the editor selection is extended to the current mark extent', () => {
- buildWrapper();
-
- findDropdown().vm.$emit('show');
- expect(commands.extendMarkRange).toHaveBeenCalledWith(Link.name);
- expect(commands.focus).toHaveBeenCalled();
- expect(commands.run).toHaveBeenCalled();
- });
- });
-
- describe('given the user has selected text', () => {
- beforeEach(() => {
- hasSelection.mockReturnValueOnce(true);
- });
-
- it('the editor does not modify the current selection', () => {
- buildWrapper();
-
- findDropdown().vm.$emit('show');
- expect(commands.extendMarkRange).not.toHaveBeenCalled();
- expect(commands.focus).not.toHaveBeenCalled();
- expect(commands.run).not.toHaveBeenCalled();
- });
- });
- });
-
- describe('a11y tests', () => {
- it('sets text, title, and text-sr-only properties to the table button dropdown', () => {
- buildWrapper();
-
- expect(findDropdown().props()).toMatchObject({
- text: 'Insert link',
- textSrOnly: true,
- });
- expect(findDropdown().attributes('title')).toBe('Insert link');
- });
- });
-});
diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
index d4fc47601cf..78b02744d51 100644
--- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
@@ -9,12 +9,14 @@ import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_
describe('content_editor/components/toolbar_more_dropdown', () => {
let wrapper;
let tiptapEditor;
+ let contentEditor;
let eventHub;
const buildEditor = () => {
tiptapEditor = createTestEditor({
extensions: [Diagram, HorizontalRule],
});
+ contentEditor = { drawioEnabled: true };
eventHub = eventHubFactory();
};
@@ -22,6 +24,7 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
wrapper = mountExtended(ToolbarMoreDropdown, {
provide: {
tiptapEditor,
+ contentEditor,
eventHub,
},
propsData,
@@ -32,29 +35,27 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
beforeEach(() => {
buildEditor();
- buildWrapper();
- });
-
- afterEach(() => {
- wrapper.destroy();
});
describe.each`
- name | contentType | command | params
- ${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']}
- ${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']}
- ${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']}
- ${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']}
- ${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']}
- ${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]}
- ${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]}
- ${'Table of contents'} | ${'tableOfContents'} | ${'insertTableOfContents'} | ${[]}
- ${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]}
+ name | contentType | command | params
+ ${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']}
+ ${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']}
+ ${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']}
+ ${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']}
+ ${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']}
+ ${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]}
+ ${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]}
+ ${'Table of contents'} | ${'tableOfContents'} | ${'insertTableOfContents'} | ${[]}
+ ${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]}
+ ${'Create or edit diagram'} | ${'drawioDiagram'} | ${'createOrEditDiagram'} | ${[]}
`('when option $name is clicked', ({ name, command, contentType, params }) => {
let commands;
let btn;
- beforeEach(async () => {
+ beforeEach(() => {
+ buildWrapper();
+
commands = mockChainedCommands(tiptapEditor, [command, 'focus', 'run']);
btn = wrapper.findByRole('button', { name });
});
@@ -71,8 +72,17 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
});
});
+ it('does not show drawio option when drawio is disabled', () => {
+ contentEditor.drawioEnabled = false;
+ buildWrapper();
+
+ expect(wrapper.findByRole('button', { name: 'Create or edit diagram' }).exists()).toBe(false);
+ });
+
describe('a11y tests', () => {
it('sets toggleText and text-sr-only properties to the table button dropdown', () => {
+ buildWrapper();
+
expect(findDropdown().props()).toMatchObject({
textSrOnly: true,
toggleText: 'More options',
diff --git a/spec/frontend/content_editor/components/toolbar_table_button_spec.js b/spec/frontend/content_editor/components/toolbar_table_button_spec.js
index aa4604661e5..35741971488 100644
--- a/spec/frontend/content_editor/components/toolbar_table_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js
@@ -30,7 +30,6 @@ describe('content_editor/components/toolbar_table_button', () => {
afterEach(() => {
editor.destroy();
- wrapper.destroy();
});
it('renders a grid of 5x5 buttons to create a table', () => {
diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
index 5a725ac1ca4..97f6bdaf778 100644
--- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
@@ -39,10 +39,6 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
buildEditor();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders all text styles as dropdown items', () => {
buildWrapper();
@@ -121,7 +117,6 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
},
],
]);
- wrapper.destroy();
});
});
});
diff --git a/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap b/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap
index fb091419ad9..a9d42769789 100644
--- a/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap
+++ b/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap
@@ -8,7 +8,9 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
Table of contents
- <li>
+ <li
+ dir="auto"
+ >
<a
href="#"
>
@@ -17,8 +19,12 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
</a>
- <ul>
- <li>
+ <ul
+ dir="auto"
+ >
+ <li
+ dir="auto"
+ >
<a
href="#"
>
@@ -27,8 +33,12 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
</a>
- <ul>
- <li>
+ <ul
+ dir="auto"
+ >
+ <li
+ dir="auto"
+ >
<a
href="#"
>
@@ -41,7 +51,9 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
</li>
</ul>
</li>
- <li>
+ <li
+ dir="auto"
+ >
<a
href="#"
>
@@ -50,8 +62,12 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
</a>
- <ul>
- <li>
+ <ul
+ dir="auto"
+ >
+ <li
+ dir="auto"
+ >
<a
href="#"
>
@@ -64,7 +80,9 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
</li>
</ul>
</li>
- <li>
+ <li
+ dir="auto"
+ >
<a
href="#"
>
@@ -75,7 +93,9 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
<!---->
</li>
- <li>
+ <li
+ dir="auto"
+ >
<a
href="#"
>
@@ -84,8 +104,12 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
</a>
- <ul>
- <li>
+ <ul
+ dir="auto"
+ >
+ <li
+ dir="auto"
+ >
<a
href="#"
>
@@ -100,7 +124,9 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
</li>
</ul>
</li>
- <li>
+ <li
+ dir="auto"
+ >
<a
href="#"
>
diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
index a5ef19fb8e8..cbeea90dcb4 100644
--- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
@@ -26,7 +26,7 @@ describe('content/components/wrappers/code_block', () => {
eventHub = eventHubFactory();
};
- const createWrapper = async (nodeAttrs = { language }) => {
+ const createWrapper = (nodeAttrs = { language }) => {
updateAttributesFn = jest.fn();
wrapper = mountExtended(CodeBlockWrapper, {
@@ -55,10 +55,6 @@ describe('content/components/wrappers/code_block', () => {
codeBlockLanguageLoader.findOrCreateLanguageBySyntax.mockReturnValue({ syntax: language });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a node-view-wrapper as a pre element', () => {
createWrapper();
@@ -101,7 +97,7 @@ describe('content/components/wrappers/code_block', () => {
jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(true);
});
- it('does not render a preview if showPreview: false', async () => {
+ it('does not render a preview if showPreview: false', () => {
createWrapper({ language: 'plantuml', isDiagram: true, showPreview: false });
expect(wrapper.findComponent({ ref: 'diagramContainer' }).exists()).toBe(false);
diff --git a/spec/frontend/content_editor/components/wrappers/details_spec.js b/spec/frontend/content_editor/components/wrappers/details_spec.js
index d746b9fa2f1..e35b04636f7 100644
--- a/spec/frontend/content_editor/components/wrappers/details_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/details_spec.js
@@ -5,7 +5,7 @@ import DetailsWrapper from '~/content_editor/components/wrappers/details.vue';
describe('content/components/wrappers/details', () => {
let wrapper;
- const createWrapper = async () => {
+ const createWrapper = () => {
wrapper = shallowMountExtended(DetailsWrapper, {
propsData: {
node: {},
@@ -13,10 +13,6 @@ describe('content/components/wrappers/details', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a node-view-content as a ul element', () => {
createWrapper();
diff --git a/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js b/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js
index 1ff750eb2ac..b5b118a2d9a 100644
--- a/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js
@@ -4,7 +4,7 @@ import FootnoteDefinitionWrapper from '~/content_editor/components/wrappers/foot
describe('content/components/wrappers/footnote_definition', () => {
let wrapper;
- const createWrapper = async (node = {}) => {
+ const createWrapper = (node = {}) => {
wrapper = shallowMountExtended(FootnoteDefinitionWrapper, {
propsData: {
node,
@@ -12,10 +12,6 @@ describe('content/components/wrappers/footnote_definition', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders footnote label as a readyonly element', () => {
const label = 'footnote';
diff --git a/spec/frontend/content_editor/components/wrappers/label_spec.js b/spec/frontend/content_editor/components/wrappers/label_spec.js
deleted file mode 100644
index 9e58669b0ea..00000000000
--- a/spec/frontend/content_editor/components/wrappers/label_spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { GlLabel } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import LabelWrapper from '~/content_editor/components/wrappers/label.vue';
-
-describe('content/components/wrappers/label', () => {
- let wrapper;
-
- const createWrapper = async (node = {}) => {
- wrapper = shallowMountExtended(LabelWrapper, {
- propsData: { node },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it("renders a GlLabel with the node's text and color", () => {
- createWrapper({ attrs: { color: '#ff0000', text: 'foo bar', originalText: '~"foo bar"' } });
-
- const glLabel = wrapper.findComponent(GlLabel);
-
- expect(glLabel.props()).toMatchObject(
- expect.objectContaining({
- title: 'foo bar',
- backgroundColor: '#ff0000',
- }),
- );
- });
-
- it('renders a scoped label if there is a "::" in the label', () => {
- createWrapper({ attrs: { color: '#ff0000', text: 'foo::bar', originalText: '~"foo::bar"' } });
-
- expect(wrapper.findComponent(GlLabel).props().scoped).toBe(true);
- });
-});
diff --git a/spec/frontend/content_editor/components/wrappers/reference_label_spec.js b/spec/frontend/content_editor/components/wrappers/reference_label_spec.js
new file mode 100644
index 00000000000..f57caee911b
--- /dev/null
+++ b/spec/frontend/content_editor/components/wrappers/reference_label_spec.js
@@ -0,0 +1,32 @@
+import { GlLabel } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ReferenceLabelWrapper from '~/content_editor/components/wrappers/reference_label.vue';
+
+describe('content/components/wrappers/reference_label', () => {
+ let wrapper;
+
+ const createWrapper = (node = {}) => {
+ wrapper = shallowMountExtended(ReferenceLabelWrapper, {
+ propsData: { node },
+ });
+ };
+
+ it("renders a GlLabel with the node's text and color", () => {
+ createWrapper({ attrs: { color: '#ff0000', text: 'foo bar', originalText: '~"foo bar"' } });
+
+ const glLabel = wrapper.findComponent(GlLabel);
+
+ expect(glLabel.props()).toMatchObject(
+ expect.objectContaining({
+ title: 'foo bar',
+ backgroundColor: '#ff0000',
+ }),
+ );
+ });
+
+ it('renders a scoped label if there is a "::" in the label', () => {
+ createWrapper({ attrs: { color: '#ff0000', text: 'foo::bar', originalText: '~"foo::bar"' } });
+
+ expect(wrapper.findComponent(GlLabel).props().scoped).toBe(true);
+ });
+});
diff --git a/spec/frontend/content_editor/components/wrappers/reference_spec.js b/spec/frontend/content_editor/components/wrappers/reference_spec.js
new file mode 100644
index 00000000000..828b92a6b1e
--- /dev/null
+++ b/spec/frontend/content_editor/components/wrappers/reference_spec.js
@@ -0,0 +1,46 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ReferenceWrapper from '~/content_editor/components/wrappers/reference.vue';
+
+describe('content/components/wrappers/reference', () => {
+ let wrapper;
+
+ const createWrapper = (node = {}) => {
+ wrapper = shallowMountExtended(ReferenceWrapper, {
+ propsData: { node },
+ });
+ };
+
+ it('renders a span for commands', () => {
+ createWrapper({ attrs: { referenceType: 'command', text: '/assign' } });
+
+ const span = wrapper.find('span');
+ expect(span.text()).toBe('/assign');
+ });
+
+ it('renders an anchor for everything else', () => {
+ createWrapper({ attrs: { referenceType: 'issue', text: '#252522' } });
+
+ const link = wrapper.findComponent(GlLink);
+ expect(link.text()).toBe('#252522');
+ });
+
+ it('adds gfm-project_member class for project members', () => {
+ createWrapper({ attrs: { referenceType: 'user', text: '@root' } });
+
+ const link = wrapper.findComponent(GlLink);
+ expect(link.text()).toBe('@root');
+ expect(link.classes('gfm-project_member')).toBe(true);
+ expect(link.classes('current-user')).toBe(false);
+ });
+
+ it('adds a current-user class if the project member is current user', () => {
+ window.gon = { current_username: 'root' };
+
+ createWrapper({ attrs: { referenceType: 'user', text: '@root' } });
+
+ const link = wrapper.findComponent(GlLink);
+ expect(link.text()).toBe('@root');
+ expect(link.classes('current-user')).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
index 1fdddce3962..0d56280d630 100644
--- a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js
@@ -1,25 +1,33 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { NodeViewWrapper } from '@tiptap/vue-2';
-import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables';
+import { selectedRect as getSelectedRect } from '@tiptap/pm/tables';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils';
-jest.mock('@_ueberdosis/prosemirror-tables');
+jest.mock('@tiptap/pm/tables');
describe('content/components/wrappers/table_cell_base', () => {
let wrapper;
let editor;
let node;
- const createWrapper = async (propsData = { cellType: 'td' }) => {
+ const createWrapper = (propsData = { cellType: 'td' }) => {
wrapper = shallowMountExtended(TableCellBaseWrapper, {
propsData: {
editor,
node,
...propsData,
},
+ stubs: {
+ GlDropdown: stubComponent(GlDropdown, {
+ methods: {
+ hide: jest.fn(),
+ },
+ }),
+ },
});
};
@@ -38,24 +46,12 @@ describe('content/components/wrappers/table_cell_base', () => {
jest.spyOn($cursor, 'node').mockReturnValue(node);
};
- 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(() => {
node = {};
editor = createTestEditor({});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a td node-view-wrapper with relative position', () => {
createWrapper();
expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative');
@@ -100,8 +96,6 @@ describe('content/components/wrappers/table_cell_base', () => {
createWrapper();
await nextTick();
-
- mockDropdownHide();
});
it.each`
@@ -122,7 +116,7 @@ describe('content/components/wrappers/table_cell_base', () => {
},
);
- it('does not allow deleting rows and columns', async () => {
+ it('does not allow deleting rows and columns', () => {
expect(findDropdownItemWithLabelExists('Delete row')).toBe(false);
expect(findDropdownItemWithLabelExists('Delete column')).toBe(false);
});
@@ -177,7 +171,7 @@ describe('content/components/wrappers/table_cell_base', () => {
await nextTick();
});
- it('does not allow adding a row before the header', async () => {
+ it('does not allow adding a row before the header', () => {
expect(findDropdownItemWithLabelExists('Insert row before')).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
index 2aefbc77545..4c91573e0c7 100644
--- a/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js
@@ -8,7 +8,7 @@ describe('content/components/wrappers/table_cell_body', () => {
let editor;
let node;
- const createWrapper = async () => {
+ const createWrapper = () => {
wrapper = shallowMount(TableCellBodyWrapper, {
propsData: {
editor,
@@ -22,10 +22,6 @@ describe('content/components/wrappers/table_cell_body', () => {
editor = createTestEditor({});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a TableCellBase component', () => {
createWrapper();
expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({
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
index e48df8734a6..689a8bc32bb 100644
--- a/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js
@@ -8,7 +8,7 @@ describe('content/components/wrappers/table_cell_header', () => {
let editor;
let node;
- const createWrapper = async () => {
+ const createWrapper = () => {
wrapper = shallowMount(TableCellHeaderWrapper, {
propsData: {
editor,
@@ -22,10 +22,6 @@ describe('content/components/wrappers/table_cell_header', () => {
editor = createTestEditor({});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a TableCellBase component', () => {
createWrapper();
expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({
diff --git a/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js b/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js
index bfda89a8b09..037da7678bb 100644
--- a/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js
@@ -20,7 +20,7 @@ describe('content/components/wrappers/table_of_contents', () => {
eventHub = eventHubFactory();
};
- const createWrapper = async () => {
+ const createWrapper = () => {
wrapper = mountExtended(TableOfContentsWrapper, {
propsData: {
editor: tiptapEditor,
@@ -70,10 +70,6 @@ describe('content/components/wrappers/table_of_contents', () => {
await nextTick();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a node-view-wrapper as a ul element', () => {
expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('ul');
});
diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js
index 6b804b3b4c6..f037ac520fe 100644
--- a/spec/frontend/content_editor/extensions/attachment_spec.js
+++ b/spec/frontend/content_editor/extensions/attachment_spec.js
@@ -1,21 +1,22 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import waitForPromises from 'helpers/wait_for_promises';
import Attachment from '~/content_editor/extensions/attachment';
+import DrawioDiagram from '~/content_editor/extensions/drawio_diagram';
import Image from '~/content_editor/extensions/image';
import Audio from '~/content_editor/extensions/audio';
import Video from '~/content_editor/extensions/video';
import Link from '~/content_editor/extensions/link';
-import Loading from '~/content_editor/extensions/loading';
-import { VARIANT_DANGER } from '~/flash';
+import { VARIANT_DANGER } from '~/alert';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import eventHubFactory from '~/helpers/event_hub_factory';
-import { createTestEditor, createDocBuilder } from '../test_utils';
+import { createTestEditor, createDocBuilder, expectDocumentAfterTransaction } from '../test_utils';
import {
PROJECT_WIKI_ATTACHMENT_IMAGE_HTML,
+ PROJECT_WIKI_ATTACHMENT_IMAGE_SVG_HTML,
PROJECT_WIKI_ATTACHMENT_AUDIO_HTML,
PROJECT_WIKI_ATTACHMENT_VIDEO_HTML,
PROJECT_WIKI_ATTACHMENT_LINK_HTML,
+ PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML,
} from '../test_constants';
describe('content_editor/extensions/attachment', () => {
@@ -24,8 +25,8 @@ describe('content_editor/extensions/attachment', () => {
let p;
let image;
let audio;
+ let drawioDiagram;
let video;
- let loading;
let link;
let renderMarkdown;
let mock;
@@ -33,54 +34,57 @@ describe('content_editor/extensions/attachment', () => {
const uploadsPath = '/uploads/';
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
+ const imageFileSvg = new File(['foo'], 'test-file.svg', { type: 'image/svg+xml' });
const audioFile = new File(['foo'], 'test-file.mp3', { type: 'audio/mpeg' });
const videoFile = new File(['foo'], 'test-file.mp4', { type: 'video/mp4' });
+ const videoFile1 = new File(['foo'], 'test-file1.mp4', { type: 'video/mp4' });
+ const drawioDiagramFile = new File(['foo'], 'test-file.drawio.svg', { type: 'image/svg+xml' });
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 = async () => {
- if (counter === number) {
- expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
- tiptapEditor.off('update', handleTransaction);
- await waitForPromises();
- resolve();
- }
-
- counter += 1;
- };
-
- tiptapEditor.on('update', handleTransaction);
- action();
- });
+ const attachmentFile1 = new File(['foo'], 'test-file1.zip', { type: 'application/zip' });
+ const attachmentFile2 = new File(['foo'], 'test-file2.zip', { type: 'application/zip' });
+
+ const markdownApiResult = {
+ 'test-file.png': PROJECT_WIKI_ATTACHMENT_IMAGE_HTML,
+ 'test-file.svg': PROJECT_WIKI_ATTACHMENT_IMAGE_SVG_HTML,
+ 'test-file.mp3': PROJECT_WIKI_ATTACHMENT_AUDIO_HTML,
+ 'test-file.mp4': PROJECT_WIKI_ATTACHMENT_VIDEO_HTML,
+ 'test-file1.mp4': PROJECT_WIKI_ATTACHMENT_VIDEO_HTML.replace(/test-file/g, 'test-file1'),
+ 'test-file.zip': PROJECT_WIKI_ATTACHMENT_LINK_HTML,
+ 'test-file1.zip': PROJECT_WIKI_ATTACHMENT_LINK_HTML.replace(/test-file/g, 'test-file1'),
+ 'test-file2.zip': PROJECT_WIKI_ATTACHMENT_LINK_HTML.replace(/test-file/g, 'test-file2'),
+ 'test-file.drawio.svg': PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML,
};
+ const [, group, project] = markdownApiResult[attachmentFile.name].match(
+ /\/(group[0-9]+)\/(project[0-9]+)\//,
+ );
+ const blobUrl = 'blob:https://gitlab.com/048c7ac1-98de-4a37-ab1b-0206d0ea7e1b';
+
beforeEach(() => {
renderMarkdown = jest.fn();
eventHub = eventHubFactory();
tiptapEditor = createTestEditor({
extensions: [
- Loading,
Link,
Image,
Audio,
Video,
+ DrawioDiagram,
Attachment.configure({ renderMarkdown, uploadsPath, eventHub }),
],
});
({
- builders: { doc, p, image, audio, video, loading, link },
+ builders: { doc, p, image, audio, video, link, drawioDiagram },
} = createDocBuilder({
tiptapEditor,
names: {
- loading: { markType: Loading.name },
image: { nodeType: Image.name },
link: { nodeType: Link.name },
audio: { nodeType: Audio.name },
video: { nodeType: Video.name },
+ drawioDiagram: { nodeType: DrawioDiagram.name },
},
}));
@@ -97,6 +101,14 @@ describe('content_editor/extensions/attachment', () => {
${'paste'} | ${'handlePaste'} | ${{ clipboardData: { getData: jest.fn(), files: [] } }} | ${undefined}
${'drop'} | ${'handleDrop'} | ${{ dataTransfer: { getData: jest.fn(), files: [attachmentFile] } }} | ${true}
`('handles $eventType properly', ({ eventType, propName, eventData, output }) => {
+ mock.onPost().reply(HTTP_STATUS_OK, {
+ link: {
+ markdown: `![test-file](test-file.png)`,
+ },
+ });
+
+ renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML);
+
const event = Object.assign(new Event(eventType), eventData);
const handled = tiptapEditor.view.someProp(propName, (eventHandler) => {
return eventHandler(tiptapEditor.view, event);
@@ -113,13 +125,13 @@ describe('content_editor/extensions/attachment', () => {
});
describe.each`
- nodeType | mimeType | html | file | mediaType
- ${'image'} | ${'image/png'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)}
- ${'audio'} | ${'audio/mpeg'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)}
- ${'video'} | ${'video/mp4'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)}
- `('when the file has $nodeType mime type', ({ mimeType, html, file, mediaType }) => {
- const base64EncodedFile = `data:${mimeType};base64,Zm9v`;
-
+ nodeType | html | file | mediaType
+ ${'image (png)'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)}
+ ${'image (svg)'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_SVG_HTML} | ${imageFileSvg} | ${(attrs) => image(attrs)}
+ ${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)}
+ ${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)}
+ ${'drawioDiagram'} | ${PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML} | ${drawioDiagramFile} | ${(attrs) => drawioDiagram(attrs)}
+ `('when the file is $nodeType', ({ html, file, mediaType }) => {
beforeEach(() => {
renderMarkdown.mockResolvedValue(html);
});
@@ -135,10 +147,13 @@ describe('content_editor/extensions/attachment', () => {
mock.onPost().reply(HTTP_STATUS_OK, successResponse);
});
- it('inserts a media content with src set to the encoded content and uploading true', async () => {
- const expectedDoc = doc(p(mediaType({ uploading: true, src: base64EncodedFile })));
+ it('inserts a media content with src set to the encoded content and uploading=file_name', async () => {
+ const expectedDoc = doc(
+ p(mediaType({ uploading: file.name, src: blobUrl, alt: file.name })),
+ );
await expectDocumentAfterTransaction({
+ tiptapEditor,
number: 1,
expectedDoc,
action: () => tiptapEditor.commands.uploadAttachment({ file }),
@@ -150,14 +165,15 @@ describe('content_editor/extensions/attachment', () => {
p(
mediaType({
canonicalSrc: file.name,
- src: base64EncodedFile,
- alt: 'test-file',
+ src: blobUrl,
+ alt: expect.stringContaining('test-file'),
uploading: false,
}),
),
);
await expectDocumentAfterTransaction({
+ tiptapEditor,
number: 2,
expectedDoc,
action: () => tiptapEditor.commands.uploadAttachment({ file }),
@@ -165,6 +181,25 @@ describe('content_editor/extensions/attachment', () => {
});
});
+ describe('when uploading a large file', () => {
+ beforeEach(() => {
+ // Set max file size to 1 byte, our file is 3 bytes
+ gon.max_file_size = 1 / 1024 / 1024;
+ });
+
+ it('emits an alert event that includes an error message', () => {
+ tiptapEditor.commands.uploadAttachment({ file });
+
+ return new Promise((resolve) => {
+ eventHub.$on('alert', ({ message, variant }) => {
+ expect(variant).toBe(VARIANT_DANGER);
+ expect(message).toContain('File is too big');
+ resolve();
+ });
+ });
+ });
+ });
+
describe('when uploading request fails', () => {
beforeEach(() => {
mock.onPost().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
@@ -174,6 +209,7 @@ describe('content_editor/extensions/attachment', () => {
const expectedDoc = doc(p(''));
await expectDocumentAfterTransaction({
+ tiptapEditor,
number: 2,
expectedDoc,
action: () => tiptapEditor.commands.uploadAttachment({ file }),
@@ -195,10 +231,8 @@ describe('content_editor/extensions/attachment', () => {
});
describe('when the file has a zip (or any other attachment) mime type', () => {
- const markdownApiResult = PROJECT_WIKI_ATTACHMENT_LINK_HTML;
-
beforeEach(() => {
- renderMarkdown.mockResolvedValue(markdownApiResult);
+ renderMarkdown.mockResolvedValue(markdownApiResult[attachmentFile.name]);
});
describe('when uploading succeeds', () => {
@@ -212,18 +246,20 @@ describe('content_editor/extensions/attachment', () => {
mock.onPost().reply(HTTP_STATUS_OK, successResponse);
});
- it('inserts a loading mark', async () => {
- const expectedDoc = doc(p(loading({ label: 'test-file' })));
+ it('inserts a link with a blob url', async () => {
+ const expectedDoc = doc(
+ p(link({ uploading: attachmentFile.name, href: blobUrl }, 'test-file.zip')),
+ );
await expectDocumentAfterTransaction({
+ tiptapEditor,
number: 1,
expectedDoc,
action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }),
});
});
- it('updates the loading mark with a link with canonicalSrc and href attrs', async () => {
- const [, group, project] = markdownApiResult.match(/\/(group[0-9]+)\/(project[0-9]+)\//);
+ it('updates the blob url link with an actual link with canonicalSrc and href attrs', async () => {
const expectedDoc = doc(
p(
link(
@@ -231,12 +267,13 @@ describe('content_editor/extensions/attachment', () => {
canonicalSrc: 'test-file.zip',
href: `/${group}/${project}/-/wikis/test-file.zip`,
},
- 'test-file',
+ 'test-file.zip',
),
),
);
await expectDocumentAfterTransaction({
+ tiptapEditor,
number: 2,
expectedDoc,
action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }),
@@ -253,6 +290,7 @@ describe('content_editor/extensions/attachment', () => {
const expectedDoc = doc(p(''));
await expectDocumentAfterTransaction({
+ tiptapEditor,
number: 2,
expectedDoc,
action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }),
@@ -269,5 +307,433 @@ describe('content_editor/extensions/attachment', () => {
});
});
});
+
+ describe('uploading multiple files', () => {
+ const uploadMultipleFiles = () => {
+ const files = [
+ attachmentFile,
+ imageFile,
+ videoFile,
+ attachmentFile1,
+ attachmentFile2,
+ videoFile1,
+ audioFile,
+ ];
+
+ for (const file of files) {
+ renderMarkdown.mockImplementation((markdown) =>
+ Promise.resolve(markdownApiResult[markdown.match(/\((.+?)\)$/)[1]]),
+ );
+
+ mock
+ .onPost()
+ .replyOnce(HTTP_STATUS_OK, { link: { markdown: `![test-file](${file.name})` } });
+
+ tiptapEditor.commands.uploadAttachment({ file });
+ }
+ };
+
+ it.each([
+ [1, () => doc(p(link({ href: blobUrl, uploading: 'test-file.zip' }, 'test-file.zip')))],
+ [
+ 2,
+ () =>
+ doc(
+ p(link({ href: blobUrl, uploading: 'test-file.zip' }, 'test-file.zip')),
+ p(image({ alt: 'test-file.png', src: blobUrl, uploading: 'test-file.png' })),
+ ),
+ ],
+ [
+ 3,
+ () =>
+ doc(
+ p(link({ href: blobUrl, uploading: 'test-file.zip' }, 'test-file.zip')),
+ p(image({ alt: 'test-file.png', src: blobUrl, uploading: 'test-file.png' })),
+ p(video({ alt: 'test-file.mp4', src: blobUrl, uploading: 'test-file.mp4' })),
+ ),
+ ],
+ [
+ 4,
+ () =>
+ doc(
+ p(link({ href: blobUrl, uploading: 'test-file.zip' }, 'test-file.zip')),
+ p(image({ alt: 'test-file.png', src: blobUrl, uploading: 'test-file.png' })),
+ p(video({ alt: 'test-file.mp4', src: blobUrl, uploading: 'test-file.mp4' })),
+ p(link({ href: blobUrl, uploading: 'test-file1.zip' }, 'test-file1.zip')),
+ ),
+ ],
+ [
+ 5,
+ () =>
+ doc(
+ p(link({ href: blobUrl, uploading: 'test-file.zip' }, 'test-file.zip')),
+ p(image({ alt: 'test-file.png', src: blobUrl, uploading: 'test-file.png' })),
+ p(video({ alt: 'test-file.mp4', src: blobUrl, uploading: 'test-file.mp4' })),
+ p(link({ href: blobUrl, uploading: 'test-file1.zip' }, 'test-file1.zip')),
+ p(link({ href: blobUrl, uploading: 'test-file2.zip' }, 'test-file2.zip')),
+ ),
+ ],
+ [
+ 6,
+ () =>
+ doc(
+ p(link({ href: blobUrl, uploading: 'test-file.zip' }, 'test-file.zip')),
+ p(image({ alt: 'test-file.png', src: blobUrl, uploading: 'test-file.png' })),
+ p(video({ alt: 'test-file.mp4', src: blobUrl, uploading: 'test-file.mp4' })),
+ p(link({ href: blobUrl, uploading: 'test-file1.zip' }, 'test-file1.zip')),
+ p(link({ href: blobUrl, uploading: 'test-file2.zip' }, 'test-file2.zip')),
+ p(video({ alt: 'test-file1.mp4', src: blobUrl, uploading: 'test-file1.mp4' })),
+ ),
+ ],
+ [
+ 7,
+ () =>
+ doc(
+ p(link({ href: blobUrl, uploading: 'test-file.zip' }, 'test-file.zip')),
+ p(image({ alt: 'test-file.png', src: blobUrl, uploading: 'test-file.png' })),
+ p(video({ alt: 'test-file.mp4', src: blobUrl, uploading: 'test-file.mp4' })),
+ p(link({ href: blobUrl, uploading: 'test-file1.zip' }, 'test-file1.zip')),
+ p(link({ href: blobUrl, uploading: 'test-file2.zip' }, 'test-file2.zip')),
+ p(video({ alt: 'test-file1.mp4', src: blobUrl, uploading: 'test-file1.mp4' })),
+ p(audio({ alt: 'test-file.mp3', src: blobUrl, uploading: 'test-file.mp3' })),
+ ),
+ ],
+ [
+ 8,
+ () =>
+ doc(
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file.zip`,
+ canonicalSrc: 'test-file.zip',
+ uploading: false,
+ },
+ 'test-file.zip',
+ ),
+ ),
+ p(image({ alt: 'test-file.png', src: blobUrl, uploading: 'test-file.png' })),
+ p(video({ alt: 'test-file.mp4', src: blobUrl, uploading: 'test-file.mp4' })),
+ p(link({ href: blobUrl, uploading: 'test-file1.zip' }, 'test-file1.zip')),
+ p(link({ href: blobUrl, uploading: 'test-file2.zip' }, 'test-file2.zip')),
+ p(video({ alt: 'test-file1.mp4', src: blobUrl, uploading: 'test-file1.mp4' })),
+ p(audio({ alt: 'test-file.mp3', src: blobUrl, uploading: 'test-file.mp3' })),
+ ),
+ ],
+ [
+ 9,
+ () =>
+ doc(
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file.zip`,
+ canonicalSrc: 'test-file.zip',
+ uploading: false,
+ },
+ 'test-file.zip',
+ ),
+ ),
+ p(
+ image({
+ alt: 'test-file.png',
+ src: blobUrl,
+ canonicalSrc: 'test-file.png',
+ uploading: false,
+ }),
+ ),
+ p(video({ alt: 'test-file.mp4', src: blobUrl, uploading: 'test-file.mp4' })),
+ p(link({ href: blobUrl, uploading: 'test-file1.zip' }, 'test-file1.zip')),
+ p(link({ href: blobUrl, uploading: 'test-file2.zip' }, 'test-file2.zip')),
+ p(video({ alt: 'test-file1.mp4', src: blobUrl, uploading: 'test-file1.mp4' })),
+ p(audio({ alt: 'test-file.mp3', src: blobUrl, uploading: 'test-file.mp3' })),
+ ),
+ ],
+ [
+ 10,
+ () =>
+ doc(
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file.zip`,
+ canonicalSrc: 'test-file.zip',
+ uploading: false,
+ },
+ 'test-file.zip',
+ ),
+ ),
+ p(
+ image({
+ alt: 'test-file.png',
+ src: blobUrl,
+ canonicalSrc: 'test-file.png',
+ uploading: false,
+ }),
+ ),
+ p(
+ video({
+ alt: 'test-file.mp4',
+ src: blobUrl,
+ canonicalSrc: 'test-file.mp4',
+ uploading: false,
+ }),
+ ),
+ p(link({ href: blobUrl, uploading: 'test-file1.zip' }, 'test-file1.zip')),
+ p(link({ href: blobUrl, uploading: 'test-file2.zip' }, 'test-file2.zip')),
+ p(video({ alt: 'test-file1.mp4', src: blobUrl, uploading: 'test-file1.mp4' })),
+ p(audio({ alt: 'test-file.mp3', src: blobUrl, uploading: 'test-file.mp3' })),
+ ),
+ ],
+ [
+ 11,
+ () =>
+ doc(
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file.zip`,
+ canonicalSrc: 'test-file.zip',
+ uploading: false,
+ },
+ 'test-file.zip',
+ ),
+ ),
+ p(
+ image({
+ alt: 'test-file.png',
+ src: blobUrl,
+ canonicalSrc: 'test-file.png',
+ uploading: false,
+ }),
+ ),
+ p(
+ video({
+ alt: 'test-file.mp4',
+ src: blobUrl,
+ canonicalSrc: 'test-file.mp4',
+ uploading: false,
+ }),
+ ),
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file1.zip`,
+ canonicalSrc: 'test-file1.zip',
+ uploading: false,
+ },
+ 'test-file1.zip',
+ ),
+ ),
+ p(link({ href: blobUrl, uploading: 'test-file2.zip' }, 'test-file2.zip')),
+ p(video({ alt: 'test-file1.mp4', src: blobUrl, uploading: 'test-file1.mp4' })),
+ p(audio({ alt: 'test-file.mp3', src: blobUrl, uploading: 'test-file.mp3' })),
+ ),
+ ],
+ [
+ 12,
+ () =>
+ doc(
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file.zip`,
+ canonicalSrc: 'test-file.zip',
+ uploading: false,
+ },
+ 'test-file.zip',
+ ),
+ ),
+ p(
+ image({
+ alt: 'test-file.png',
+ src: blobUrl,
+ canonicalSrc: 'test-file.png',
+ uploading: false,
+ }),
+ ),
+ p(
+ video({
+ alt: 'test-file.mp4',
+ src: blobUrl,
+ canonicalSrc: 'test-file.mp4',
+ uploading: false,
+ }),
+ ),
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file1.zip`,
+ canonicalSrc: 'test-file1.zip',
+ uploading: false,
+ },
+ 'test-file1.zip',
+ ),
+ ),
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file2.zip`,
+ canonicalSrc: 'test-file2.zip',
+ uploading: false,
+ },
+ 'test-file2.zip',
+ ),
+ ),
+ p(video({ alt: 'test-file1.mp4', src: blobUrl, uploading: 'test-file1.mp4' })),
+ p(audio({ alt: 'test-file.mp3', src: blobUrl, uploading: 'test-file.mp3' })),
+ ),
+ ],
+ [
+ 13,
+ () =>
+ doc(
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file.zip`,
+ canonicalSrc: 'test-file.zip',
+ uploading: false,
+ },
+ 'test-file.zip',
+ ),
+ ),
+ p(
+ image({
+ alt: 'test-file.png',
+ src: blobUrl,
+ canonicalSrc: 'test-file.png',
+ uploading: false,
+ }),
+ ),
+ p(
+ video({
+ alt: 'test-file.mp4',
+ src: blobUrl,
+ canonicalSrc: 'test-file.mp4',
+ uploading: false,
+ }),
+ ),
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file1.zip`,
+ canonicalSrc: 'test-file1.zip',
+ uploading: false,
+ },
+ 'test-file1.zip',
+ ),
+ ),
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file2.zip`,
+ canonicalSrc: 'test-file2.zip',
+ uploading: false,
+ },
+ 'test-file2.zip',
+ ),
+ ),
+ p(
+ video({
+ alt: 'test-file1.mp4',
+ src: blobUrl,
+ canonicalSrc: 'test-file1.mp4',
+ uploading: false,
+ }),
+ ),
+ p(audio({ alt: 'test-file.mp3', src: blobUrl, uploading: 'test-file.mp3' })),
+ ),
+ ],
+ [
+ 14,
+ () =>
+ doc(
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file.zip`,
+ canonicalSrc: 'test-file.zip',
+ uploading: false,
+ },
+ 'test-file.zip',
+ ),
+ ),
+ p(
+ image({
+ alt: 'test-file.png',
+ src: blobUrl,
+ canonicalSrc: 'test-file.png',
+ uploading: false,
+ }),
+ ),
+ p(
+ video({
+ alt: 'test-file.mp4',
+ src: blobUrl,
+ canonicalSrc: 'test-file.mp4',
+ uploading: false,
+ }),
+ ),
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file1.zip`,
+ canonicalSrc: 'test-file1.zip',
+ uploading: false,
+ },
+ 'test-file1.zip',
+ ),
+ ),
+ p(
+ link(
+ {
+ href: `/${group}/${project}/-/wikis/test-file2.zip`,
+ canonicalSrc: 'test-file2.zip',
+ uploading: false,
+ },
+ 'test-file2.zip',
+ ),
+ ),
+ p(
+ video({
+ alt: 'test-file1.mp4',
+ src: blobUrl,
+ canonicalSrc: 'test-file1.mp4',
+ uploading: false,
+ }),
+ ),
+ p(
+ audio({
+ alt: 'test-file.mp3',
+ src: blobUrl,
+ canonicalSrc: 'test-file.mp3',
+ uploading: false,
+ }),
+ ),
+ ),
+ ],
+ ])('uploads all files of mixed types successfully (tx %i)', async (n, document) => {
+ await expectDocumentAfterTransaction({
+ tiptapEditor,
+ number: n,
+ expectedDoc: document(),
+ action: uploadMultipleFiles,
+ });
+ });
+
+ it('cleans up the state if all uploads fail', async () => {
+ await expectDocumentAfterTransaction({
+ tiptapEditor,
+ number: 14,
+ expectedDoc: doc(p(), p(), p(), p(), p(), p(), p()),
+ action: () => {
+ // Set max file size to 1 byte, our file is 3 bytes
+ gon.max_file_size = 1 / 1024 / 1024;
+ uploadMultipleFiles();
+ },
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js
new file mode 100644
index 00000000000..61dc164c99a
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js
@@ -0,0 +1,103 @@
+import DrawioDiagram from '~/content_editor/extensions/drawio_diagram';
+import Image from '~/content_editor/extensions/image';
+import createAssetResolver from '~/content_editor/services/asset_resolver';
+import { create } from '~/drawio/content_editor_facade';
+import { launchDrawioEditor } from '~/drawio/drawio_editor';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+import {
+ PROJECT_WIKI_ATTACHMENT_IMAGE_HTML,
+ PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML,
+} from '../test_constants';
+
+jest.mock('~/content_editor/services/asset_resolver');
+jest.mock('~/drawio/content_editor_facade');
+jest.mock('~/drawio/drawio_editor');
+
+describe('content_editor/extensions/drawio_diagram', () => {
+ let tiptapEditor;
+ let doc;
+ let paragraph;
+ let image;
+ let drawioDiagram;
+ const uploadsPath = '/uploads';
+ const renderMarkdown = () => {};
+
+ beforeEach(() => {
+ tiptapEditor = createTestEditor({
+ extensions: [Image, DrawioDiagram.configure({ uploadsPath, renderMarkdown })],
+ });
+ const { builders } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ image: { nodeType: Image.name },
+ drawioDiagram: { nodeType: DrawioDiagram.name },
+ },
+ });
+
+ doc = builders.doc;
+ paragraph = builders.paragraph;
+ image = builders.image;
+ drawioDiagram = builders.drawioDiagram;
+ });
+
+ describe('parsing', () => {
+ it('distinguishes a drawio diagram from an image', () => {
+ const expectedDocWithDiagram = doc(
+ paragraph(
+ drawioDiagram({
+ alt: 'test-file',
+ canonicalSrc: 'test-file.drawio.svg',
+ src: '/group1/project1/-/wikis/test-file.drawio.svg',
+ }),
+ ),
+ );
+ const expectedDocWithImage = doc(
+ paragraph(
+ image({
+ alt: 'test-file',
+ canonicalSrc: 'test-file.png',
+ src: '/group1/project1/-/wikis/test-file.png',
+ }),
+ ),
+ );
+ tiptapEditor.commands.setContent(PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML);
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDocWithDiagram.toJSON());
+
+ tiptapEditor.commands.setContent(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML);
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDocWithImage.toJSON());
+ });
+ });
+
+ describe('createOrEditDiagram command', () => {
+ let editorFacade;
+ let assetResolver;
+
+ beforeEach(() => {
+ editorFacade = {};
+ assetResolver = {};
+ tiptapEditor.commands.createOrEditDiagram();
+
+ create.mockReturnValueOnce(editorFacade);
+ createAssetResolver.mockReturnValueOnce(assetResolver);
+ });
+
+ it('creates a new instance of asset resolver', () => {
+ expect(createAssetResolver).toHaveBeenCalledWith({ renderMarkdown });
+ });
+
+ it('creates a new instance of the content_editor_facade', () => {
+ expect(create).toHaveBeenCalledWith({
+ tiptapEditor,
+ drawioNodeName: DrawioDiagram.name,
+ uploadsPath,
+ assetResolver,
+ });
+ });
+
+ it('calls launchDrawioEditor and provides content_editor_facade', () => {
+ expect(launchDrawioEditor).toHaveBeenCalledWith({ editorFacade });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/link_spec.js b/spec/frontend/content_editor/extensions/link_spec.js
index ead898554d1..3c6f28f0c32 100644
--- a/spec/frontend/content_editor/extensions/link_spec.js
+++ b/spec/frontend/content_editor/extensions/link_spec.js
@@ -31,11 +31,6 @@ describe('content_editor/extensions/link', () => {
${'[link 123](read me.md)'} | ${() => p(link({ href: 'read me.md' }, 'link 123'))}
${'text'} | ${() => p('text')}
${'documentation](readme.md'} | ${() => p('documentation](readme.md')}
- ${'http://example.com '} | ${() => p(link({ href: 'http://example.com' }, 'http://example.com'))}
- ${'https://example.com '} | ${() => p(link({ href: 'https://example.com' }, 'https://example.com'))}
- ${'www.example.com '} | ${() => p(link({ href: 'www.example.com' }, 'www.example.com'))}
- ${'example.com/ab.html '} | ${() => p('example.com/ab.html')}
- ${'https://www.google.com '} | ${() => p(link({ href: 'https://www.google.com' }, 'https://www.google.com'))}
`('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => {
const expectedDoc = doc(insertedNode());
diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
index 30e798e8817..c9997e3c58f 100644
--- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js
+++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
@@ -2,8 +2,9 @@ import PasteMarkdown from '~/content_editor/extensions/paste_markdown';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import Diagram from '~/content_editor/extensions/diagram';
import Frontmatter from '~/content_editor/extensions/frontmatter';
+import Heading from '~/content_editor/extensions/heading';
import Bold from '~/content_editor/extensions/bold';
-import { VARIANT_DANGER } from '~/flash';
+import { VARIANT_DANGER } from '~/alert';
import eventHubFactory from '~/helpers/event_hub_factory';
import { ALERT_EVENT } from '~/content_editor/constants';
import waitForPromises from 'helpers/wait_for_promises';
@@ -20,6 +21,7 @@ describe('content_editor/extensions/paste_markdown', () => {
let doc;
let p;
let bold;
+ let heading;
let renderMarkdown;
let eventHub;
const defaultData = { 'text/plain': '**bold text**' };
@@ -36,16 +38,18 @@ describe('content_editor/extensions/paste_markdown', () => {
CodeBlockHighlight,
Diagram,
Frontmatter,
+ Heading,
PasteMarkdown.configure({ renderMarkdown, eventHub }),
],
});
({
- builders: { doc, p, bold },
+ builders: { doc, p, bold, heading },
} = createDocBuilder({
tiptapEditor,
names: {
bold: { markType: Bold.name },
+ heading: { nodeType: Heading.name },
},
}));
});
@@ -110,6 +114,52 @@ describe('content_editor/extensions/paste_markdown', () => {
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
});
+
+ describe('when pasting inline content in an existing paragraph', () => {
+ it('inserts the inline content next to the existing paragraph content', async () => {
+ const expectedDoc = doc(p('Initial text and', bold('bold text')));
+
+ tiptapEditor.commands.setContent('Initial text and ');
+
+ await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ });
+ });
+
+ describe('when pasting inline content and there is text selected', () => {
+ it('inserts the block content after the existing paragraph', async () => {
+ const expectedDoc = doc(p('Initial text', bold('bold text')));
+
+ tiptapEditor.commands.setContent('Initial text and ');
+ tiptapEditor.commands.setTextSelection({ from: 13, to: 17 });
+
+ await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ });
+ });
+
+ describe('when pasting block content in an existing paragraph', () => {
+ beforeEach(() => {
+ renderMarkdown.mockReset();
+ renderMarkdown.mockResolvedValueOnce('<h1>Heading</h1><p><strong>bold text</strong></p>');
+ });
+
+ it('inserts the block content after the existing paragraph', async () => {
+ const expectedDoc = doc(
+ p('Initial text and'),
+ heading({ level: 1 }, 'Heading'),
+ p(bold('bold text')),
+ );
+
+ tiptapEditor.commands.setContent('Initial text and ');
+
+ await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ });
+ });
});
describe('when rendering markdown fails', () => {
diff --git a/spec/frontend/content_editor/markdown_snapshot_spec.js b/spec/frontend/content_editor/markdown_snapshot_spec.js
index fd64003420e..49b466fd7f5 100644
--- a/spec/frontend/content_editor/markdown_snapshot_spec.js
+++ b/spec/frontend/content_editor/markdown_snapshot_spec.js
@@ -42,7 +42,7 @@ describe('markdown example snapshots in ContentEditor', () => {
const expectedProseMirrorJsonExamples = loadExamples(prosemirrorJsonYml);
const exampleNames = Object.keys(markdownExamples);
- beforeAll(async () => {
+ beforeAll(() => {
return renderHtmlAndJsonForAllExamples(markdownExamples).then((examples) => {
actualHtmlAndJsonExamples = examples;
});
@@ -60,7 +60,7 @@ describe('markdown example snapshots in ContentEditor', () => {
if (skipRunningSnapshotWysiwygHtmlTests) {
it.todo(`${exampleNamePrefix} HTML: ${skipRunningSnapshotWysiwygHtmlTests}`);
} else {
- it(`${exampleNamePrefix} HTML`, async () => {
+ it(`${exampleNamePrefix} HTML`, () => {
const expectedHtml = expectedHtmlExamples[name].wysiwyg;
const { html: actualHtml } = actualHtmlAndJsonExamples[name];
@@ -78,7 +78,7 @@ describe('markdown example snapshots in ContentEditor', () => {
if (skipRunningSnapshotProsemirrorJsonTests) {
it.todo(`${exampleNamePrefix} ProseMirror JSON: ${skipRunningSnapshotProsemirrorJsonTests}`);
} else {
- it(`${exampleNamePrefix} ProseMirror JSON`, async () => {
+ it(`${exampleNamePrefix} ProseMirror JSON`, () => {
const expectedJson = expectedProseMirrorJsonExamples[name];
const { json: actualJson } = actualHtmlAndJsonExamples[name];
diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js
index bc43af9bd8b..359e69c083a 100644
--- a/spec/frontend/content_editor/remark_markdown_processing_spec.js
+++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js
@@ -1349,7 +1349,7 @@ alert("Hello world")
markdown: `
<h1 class="heading-with-class">Header</h1>
`,
- expectedHtml: '<h1>Header</h1>',
+ expectedHtml: '<h1 dir="auto">Header</h1>',
},
{
markdown: `
diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
index 5df901e0f15..bf29d4bdf23 100644
--- a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
+++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
@@ -1,4 +1,4 @@
-import { DOMSerializer } from 'prosemirror-model';
+import { DOMSerializer } from '@tiptap/pm/model';
import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
import { createTiptapEditor } from 'jest/content_editor/test_utils';
diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
index 6175cbdd3d4..5dfe9c06923 100644
--- a/spec/frontend/content_editor/services/content_editor_spec.js
+++ b/spec/frontend/content_editor/services/content_editor_spec.js
@@ -64,13 +64,13 @@ describe('content_editor/services/content_editor', () => {
});
describe('editable', () => {
- it('returns true when tiptapEditor is editable', async () => {
+ it('returns true when tiptapEditor is editable', () => {
contentEditor.setEditable(true);
expect(contentEditor.editable).toBe(true);
});
- it('returns false when tiptapEditor is readonly', async () => {
+ it('returns false when tiptapEditor is readonly', () => {
contentEditor.setEditable(false);
expect(contentEditor.editable).toBe(false);
diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js
index e1a30819ac8..53cd51b8c5f 100644
--- a/spec/frontend/content_editor/services/create_content_editor_spec.js
+++ b/spec/frontend/content_editor/services/create_content_editor_spec.js
@@ -20,7 +20,7 @@ describe('content_editor/services/create_content_editor', () => {
preserveUnchangedMarkdown: false,
},
};
- editor = createContentEditor({ renderMarkdown, uploadsPath });
+ editor = createContentEditor({ renderMarkdown, uploadsPath, drawioEnabled: true });
});
describe('when preserveUnchangedMarkdown feature is on', () => {
@@ -45,15 +45,15 @@ describe('content_editor/services/create_content_editor', () => {
});
});
- it('sets gl-outline-0! class selector to the tiptapEditor instance', () => {
+ it('sets gl-shadow-none! class selector to the tiptapEditor instance', () => {
expect(editor.tiptapEditor.options.editorProps).toMatchObject({
attributes: {
- class: 'gl-outline-0!',
+ class: 'gl-shadow-none!',
},
});
});
- it('allows providing external content editor extensions', async () => {
+ it('allows providing external content editor extensions', () => {
const labelReference = 'this is a ~group::editor';
const { tiptapExtension, serializer } = createTestContentEditorExtension();
@@ -82,4 +82,14 @@ describe('content_editor/services/create_content_editor', () => {
renderMarkdown,
});
});
+
+ it('provides uploadsPath and renderMarkdown function to DrawioDiagram extension', () => {
+ expect(
+ editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'drawioDiagram')
+ .options,
+ ).toMatchObject({
+ uploadsPath,
+ renderMarkdown,
+ });
+ });
});
diff --git a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
index 90d83820c70..a9960918e62 100644
--- a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
+++ b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
@@ -35,17 +35,15 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => {
beforeEach(async () => {
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- renderMarkdown.mockResolvedValueOnce(
- `<p><strong>${text}</strong></p><pre lang="javascript"></pre><!-- some comment -->`,
- );
+ renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p><!-- some comment -->`);
result = await deserializer.deserialize({
- content: 'content',
+ markdown: '**Bold text**\n<!-- some comment -->',
schema: tiptapEditor.schema,
});
});
- it('transforms HTML returned by render function to a ProseMirror document', async () => {
+ it('transforms HTML returned by render function to a ProseMirror document', () => {
const document = doc(p(bold(text)), comment(' some comment '));
expect(result.document.toJSON()).toEqual(document.toJSON());
@@ -53,12 +51,22 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => {
});
describe('when the render function returns an empty value', () => {
- it('returns an empty object', async () => {
- const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
+ it('returns an empty prosemirror document', async () => {
+ const deserializer = createMarkdownDeserializer({
+ render: renderMarkdown,
+ schema: tiptapEditor.schema,
+ });
renderMarkdown.mockResolvedValueOnce(null);
- expect(await deserializer.deserialize({ content: 'content' })).toEqual({});
+ const result = await deserializer.deserialize({
+ markdown: '',
+ schema: tiptapEditor.schema,
+ });
+
+ const document = doc(p());
+
+ expect(result.document.toJSON()).toEqual(document.toJSON());
});
});
});
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
index 2cd8b8a0d6f..3729b303cc6 100644
--- a/spec/frontend/content_editor/services/markdown_serializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -8,6 +8,7 @@ import DescriptionItem from '~/content_editor/extensions/description_item';
import DescriptionList from '~/content_editor/extensions/description_list';
import Details from '~/content_editor/extensions/details';
import DetailsContent from '~/content_editor/extensions/details_content';
+import DrawioDiagram from '~/content_editor/extensions/drawio_diagram';
import Emoji from '~/content_editor/extensions/emoji';
import Figure from '~/content_editor/extensions/figure';
import FigureCaption from '~/content_editor/extensions/figure_caption';
@@ -57,6 +58,7 @@ const {
div,
descriptionItem,
descriptionList,
+ drawioDiagram,
emoji,
footnoteDefinition,
footnoteReference,
@@ -96,6 +98,7 @@ const {
detailsContent: { nodeType: DetailsContent.name },
descriptionItem: { nodeType: DescriptionItem.name },
descriptionList: { nodeType: DescriptionList.name },
+ drawioDiagram: { nodeType: DrawioDiagram.name },
emoji: { markType: Emoji.name },
figure: { nodeType: Figure.name },
figureCaption: { nodeType: FigureCaption.name },
@@ -183,6 +186,19 @@ comment -->
);
});
+ it('correctly renders a comment with markdown in it without adding any slashes', () => {
+ expect(serialize(paragraph('hi'), comment('this is a list\n- a\n- b\n- c'))).toBe(
+ `
+hi
+
+<!--this is a list
+- a
+- b
+- c-->
+ `.trim(),
+ );
+ });
+
it('correctly serializes a line break', () => {
expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld');
});
@@ -265,6 +281,20 @@ comment -->
).toBe('![GitLab][gitlab-url]');
});
+ it.each`
+ src
+ ${'data:image/png;base64,iVBORw0KGgoAAAAN'}
+ ${'blob:https://gitlab.com/1234-5678-9012-3456'}
+ `('omits images with data/blob urls when serializing', ({ src }) => {
+ expect(serialize(paragraph(image({ src, alt: 'image' })))).toBe('');
+ });
+
+ it('does not escape url in an image', () => {
+ expect(
+ serialize(paragraph(image({ src: 'https://example.com/image__1_.png', alt: 'image' }))),
+ ).toBe('![image](https://example.com/image__1_.png)');
+ });
+
it('correctly serializes strikethrough', () => {
expect(serialize(paragraph(strike('deleted content')))).toBe('~~deleted content~~');
});
@@ -397,6 +427,12 @@ this is not really json:table but just trying out whether this case works or not
);
});
+ it('correctly serializes a drawio_diagram', () => {
+ expect(
+ serialize(paragraph(drawioDiagram({ src: 'diagram.drawio.svg', alt: 'Draw.io Diagram' }))),
+ ).toBe('![Draw.io Diagram](diagram.drawio.svg)');
+ });
+
it.each`
width | height | outputAttributes
${300} | ${undefined} | ${'width=300'}
@@ -876,6 +912,59 @@ _An elephant at sunset_
);
});
+ it('correctly renders a table with checkboxes', () => {
+ expect(
+ serialize(
+ table(
+ // each table cell must contain at least one paragraph
+ tableRow(
+ tableHeader(paragraph('')),
+ tableHeader(paragraph('Item')),
+ tableHeader(paragraph('Description')),
+ ),
+ tableRow(
+ tableCell(taskList(taskItem(paragraph('')))),
+ tableCell(paragraph('Item 1')),
+ tableCell(paragraph('Description 1')),
+ ),
+ tableRow(
+ tableCell(taskList(taskItem(paragraph('some text')))),
+ tableCell(paragraph('Item 2')),
+ tableCell(paragraph('Description 2')),
+ ),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+<table>
+<tr>
+<th>
+
+</th>
+<th>Item</th>
+<th>Description</th>
+</tr>
+<tr>
+<td>
+
+* [ ] &nbsp;
+</td>
+<td>Item 1</td>
+<td>Description 1</td>
+</tr>
+<tr>
+<td>
+
+* [ ] some text
+</td>
+<td>Item 2</td>
+<td>Description 2</td>
+</tr>
+</table>
+ `.trim(),
+ );
+ });
+
it('correctly serializes a table with line breaks', () => {
expect(
serialize(
@@ -1300,6 +1389,25 @@ paragraph
.run();
};
+ const editNonInclusiveMarkAction = (initialContent) => {
+ tiptapEditor.commands.setContent(initialContent.toJSON());
+ tiptapEditor.commands.selectTextblockEnd();
+
+ let { from } = tiptapEditor.state.selection;
+ tiptapEditor.commands.setTextSelection({
+ from: from - 1,
+ to: from - 1,
+ });
+
+ const sel = tiptapEditor.state.doc.textBetween(from - 1, from, ' ');
+ tiptapEditor.commands.insertContent(`${sel} modified`);
+
+ tiptapEditor.commands.selectTextblockEnd();
+ from = tiptapEditor.state.selection.from;
+
+ tiptapEditor.commands.deleteRange({ from: from - 1, to: from });
+ };
+
it.each`
mark | markdown | modifiedMarkdown | editAction
${'bold'} | ${'**bold**'} | ${'**bold modified**'} | ${defaultEditAction}
@@ -1310,8 +1418,8 @@ paragraph
${'italic'} | ${'*italic*'} | ${'*italic modified*'} | ${defaultEditAction}
${'italic'} | ${'<em>italic</em>'} | ${'<em>italic modified</em>'} | ${defaultEditAction}
${'italic'} | ${'<i>italic</i>'} | ${'<i>italic modified</i>'} | ${defaultEditAction}
- ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} | ${defaultEditAction}
- ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} | ${defaultEditAction}
+ ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} | ${editNonInclusiveMarkAction}
+ ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} | ${editNonInclusiveMarkAction}
${'link'} | ${'link www.gitlab.com'} | ${'modified link www.gitlab.com'} | ${prependContentEditAction}
${'link'} | ${'link https://www.gitlab.com'} | ${'modified link https://www.gitlab.com'} | ${prependContentEditAction}
${'link'} | ${'link(https://www.gitlab.com)'} | ${'modified link(https://www.gitlab.com)'} | ${prependContentEditAction}
diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js
index 8c1a3831a74..1459988cf8f 100644
--- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js
+++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js
@@ -43,7 +43,7 @@ describe('content_editor/services/track_input_rules_and_shortcuts', () => {
});
describe('when creating a heading using an keyboard shortcut', () => {
- it('sends a tracking event indicating that a heading was created using an input rule', async () => {
+ it('sends a tracking event indicating that a heading was created using an input rule', () => {
const shortcuts = Heading.parent.config.addKeyboardShortcuts.call(Heading);
const [firstShortcut] = Object.keys(shortcuts);
const nodeName = Heading.name;
@@ -68,7 +68,7 @@ describe('content_editor/services/track_input_rules_and_shortcuts', () => {
});
describe('when creating a heading using an input rule', () => {
- it('sends a tracking event indicating that a heading was created using an input rule', async () => {
+ it('sends a tracking event indicating that a heading was created using an input rule', () => {
const nodeName = Heading.name;
triggerNodeInputRule({ tiptapEditor: editor, inputRuleText: '## ' });
expect(trackingSpy).toHaveBeenCalledWith(undefined, INPUT_RULE_TRACKING_ACTION, {
diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js
index 45a0e4a8bd1..749f1234de0 100644
--- a/spec/frontend/content_editor/test_constants.js
+++ b/spec/frontend/content_editor/test_constants.js
@@ -4,6 +4,12 @@ export const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27"
</a>
</p>`;
+export const PROJECT_WIKI_ATTACHMENT_IMAGE_SVG_HTML = `<p data-sourcepos="1:1-1:27" dir="auto">
+ <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.svg" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.svg">
+ <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.svg" data-canonical-src="test-file.png">
+ </a>
+</p>`;
+
export const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto">
<span class="media-container video-container">
<video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4">
@@ -20,6 +26,12 @@ export const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74"
</span>
</p>`;
+export const PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML = `<p data-sourcepos="1:1-1:27" dir="auto">
+ <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.drawio.svg" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.drawio.svg">
+ <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.drawio.svg" data-canonical-src="test-file.drawio.svg">
+ </a>
+</p>`;
+
export 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>`;
diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js
index 0fa0e65cd26..1f4a367e46c 100644
--- a/spec/frontend/content_editor/test_utils.js
+++ b/spec/frontend/content_editor/test_utils.js
@@ -5,6 +5,7 @@ import { Text } from '@tiptap/extension-text';
import { Editor } from '@tiptap/vue-2';
import { builders, eq } from 'prosemirror-test-builder';
import { nextTick } from 'vue';
+import waitForPromises from 'helpers/wait_for_promises';
import Audio from '~/content_editor/extensions/audio';
import Blockquote from '~/content_editor/extensions/blockquote';
import Bold from '~/content_editor/extensions/bold';
@@ -17,6 +18,7 @@ import DescriptionList from '~/content_editor/extensions/description_list';
import Details from '~/content_editor/extensions/details';
import DetailsContent from '~/content_editor/extensions/details_content';
import Diagram from '~/content_editor/extensions/diagram';
+import DrawioDiagram from '~/content_editor/extensions/drawio_diagram';
import Emoji from '~/content_editor/extensions/emoji';
import FootnoteDefinition from '~/content_editor/extensions/footnote_definition';
import FootnoteReference from '~/content_editor/extensions/footnote_reference';
@@ -62,6 +64,12 @@ export const emitEditorEvent = ({ tiptapEditor, event, params = {} }) => {
return nextTick();
};
+export const createTransactionWithMeta = (metaKey, metaValue) => {
+ return {
+ getMeta: (key) => (key === metaKey ? metaValue : null),
+ };
+};
+
/**
* Creates an instance of the Tiptap Editor class
* with a minimal configuration for testing purposes.
@@ -204,6 +212,24 @@ export const waitUntilNextDocTransaction = ({ tiptapEditor, action = () => {} })
});
};
+export const expectDocumentAfterTransaction = ({ tiptapEditor, number, expectedDoc, action }) => {
+ return new Promise((resolve) => {
+ let counter = 0;
+ const handleTransaction = async () => {
+ counter += 1;
+ if (counter === number) {
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ tiptapEditor.off('update', handleTransaction);
+ await waitForPromises();
+ resolve();
+ }
+ };
+
+ tiptapEditor.on('update', handleTransaction);
+ action();
+ });
+};
+
export const createTiptapEditor = (extensions = []) =>
createTestEditor({
extensions: [
@@ -218,6 +244,7 @@ export const createTiptapEditor = (extensions = []) =>
DescriptionList,
Details,
DetailsContent,
+ DrawioDiagram,
Diagram,
Emoji,
FootnoteDefinition,
diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap
index 2f441f0f747..5cfb4702be7 100644
--- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap
+++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap
@@ -53,23 +53,23 @@ exports[`Contributors charts should render charts and a RefSelector when loading
Excluding merge commits. Limited to 6,000 commits.
</span>
- <div>
- <glareachart-stub
- annotations=""
- class="gl-mb-5"
- data="[object Object]"
- height="264"
- includelegendavgmax="true"
- legendaveragetext="Avg"
- legendcurrenttext="Current"
- legendlayout="inline"
- legendmaxtext="Max"
- legendmintext="Min"
- option="[object Object]"
- thresholds=""
- width="0"
- />
- </div>
+ <glareachart-stub
+ annotations=""
+ class="gl-mb-5"
+ data="[object Object]"
+ height="264"
+ includelegendavgmax="true"
+ legendaveragetext="Avg"
+ legendcurrenttext="Current"
+ legendlayout="inline"
+ legendmaxtext="Max"
+ legendmintext="Min"
+ legendseriesinfo=""
+ option="[object Object]"
+ responsive=""
+ thresholds=""
+ width="auto"
+ />
<div
class="row"
@@ -91,22 +91,22 @@ exports[`Contributors charts should render charts and a RefSelector when loading
</p>
- <div>
- <glareachart-stub
- annotations=""
- data="[object Object]"
- height="216"
- includelegendavgmax="true"
- legendaveragetext="Avg"
- legendcurrenttext="Current"
- legendlayout="inline"
- legendmaxtext="Max"
- legendmintext="Min"
- option="[object Object]"
- thresholds=""
- width="0"
- />
- </div>
+ <glareachart-stub
+ annotations=""
+ data="[object Object]"
+ height="216"
+ includelegendavgmax="true"
+ legendaveragetext="Avg"
+ legendcurrenttext="Current"
+ legendlayout="inline"
+ legendmaxtext="Max"
+ legendmintext="Min"
+ legendseriesinfo=""
+ option="[object Object]"
+ responsive=""
+ thresholds=""
+ width="auto"
+ />
</div>
</div>
</div>
diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js
index 03b1e977548..f915b834aff 100644
--- a/spec/frontend/contributors/component/contributors_spec.js
+++ b/spec/frontend/contributors/component/contributors_spec.js
@@ -1,5 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
-import Vue, { nextTick } from 'vue';
+import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ContributorsCharts from '~/contributors/components/contributors.vue';
import { createStore } from '~/contributors/stores';
@@ -16,7 +16,6 @@ jest.mock('~/lib/utils/url_utility', () => ({
let wrapper;
let mock;
let store;
-const Component = Vue.extend(ContributorsCharts);
const endpoint = 'contributors/-/graphs';
const branch = 'main';
const chartData = [
@@ -32,7 +31,7 @@ function factory() {
mock.onGet().reply(HTTP_STATUS_OK, chartData);
store = createStore();
- wrapper = mountExtended(Component, {
+ wrapper = mountExtended(ContributorsCharts, {
propsData: {
endpoint,
branch,
@@ -60,7 +59,6 @@ describe('Contributors charts', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
it('should fetch chart data when mounted', () => {
diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js
index b2ebdf2f53c..a15b9ad2978 100644
--- a/spec/frontend/contributors/store/actions_spec.js
+++ b/spec/frontend/contributors/store/actions_spec.js
@@ -2,11 +2,11 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/contributors/stores/actions';
import * as types from '~/contributors/stores/mutation_types';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
describe('Contributors store actions', () => {
describe('fetchChartData', () => {
@@ -38,7 +38,7 @@ describe('Contributors store actions', () => {
);
});
- it('should show flash on API error', async () => {
+ it('should show alert on API error', async () => {
mock.onGet().reply(HTTP_STATUS_BAD_REQUEST, 'Not Found');
await testAction(
diff --git a/spec/frontend/create_item_dropdown_spec.js b/spec/frontend/create_item_dropdown_spec.js
index aea4bc6017d..df4bfdb4ad0 100644
--- a/spec/frontend/create_item_dropdown_spec.js
+++ b/spec/frontend/create_item_dropdown_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlCreateItemDropdown from 'test_fixtures_static/create_item_dropdown.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import CreateItemDropdown from '~/create_item_dropdown';
const DROPDOWN_ITEM_DATA = [
@@ -42,7 +43,7 @@ describe('CreateItemDropdown', () => {
}
beforeEach(() => {
- loadHTMLFixture('static/create_item_dropdown.html');
+ setHTMLFixture(htmlCreateItemDropdown);
$wrapperEl = $('.js-create-item-dropdown-fixture-root');
});
diff --git a/spec/frontend/crm/contact_form_wrapper_spec.js b/spec/frontend/crm/contact_form_wrapper_spec.js
index 50b432943fb..2fb6940a415 100644
--- a/spec/frontend/crm/contact_form_wrapper_spec.js
+++ b/spec/frontend/crm/contact_form_wrapper_spec.js
@@ -47,7 +47,6 @@ describe('Customer relations contact form wrapper', () => {
});
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js
index ec7172434bf..63b64a6c984 100644
--- a/spec/frontend/crm/contacts_root_spec.js
+++ b/spec/frontend/crm/contacts_root_spec.js
@@ -61,7 +61,6 @@ describe('Customer relations contacts root app', () => {
});
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
router = null;
});
diff --git a/spec/frontend/crm/crm_form_spec.js b/spec/frontend/crm/crm_form_spec.js
index eabcf5b1b1b..fabf43ceb9d 100644
--- a/spec/frontend/crm/crm_form_spec.js
+++ b/spec/frontend/crm/crm_form_spec.js
@@ -188,10 +188,6 @@ describe('Reusable form component', () => {
};
const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT))(
'%s form save button',
(name, { mountFunction }) => {
diff --git a/spec/frontend/crm/organization_form_wrapper_spec.js b/spec/frontend/crm/organization_form_wrapper_spec.js
index d795c585622..8408c1920a9 100644
--- a/spec/frontend/crm/organization_form_wrapper_spec.js
+++ b/spec/frontend/crm/organization_form_wrapper_spec.js
@@ -40,10 +40,6 @@ describe('Customer relations organization form wrapper', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('in edit mode', () => {
it('should render organization form with correct props', () => {
mountComponent({ isEditMode: true });
diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js
index 1fcf6aa8f50..0b26a49a6b3 100644
--- a/spec/frontend/crm/organizations_root_spec.js
+++ b/spec/frontend/crm/organizations_root_spec.js
@@ -65,7 +65,6 @@ describe('Customer relations organizations root app', () => {
});
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
router = null;
});
diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
index 7d9ae548c9a..d3cdd0d16ef 100644
--- a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
+++ b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
@@ -42,7 +42,6 @@ describe('custom metrics form fields component', () => {
});
afterEach(() => {
- wrapper.destroy();
mockAxios.restore();
});
@@ -174,7 +173,7 @@ describe('custom metrics form fields component', () => {
return axios.waitForAll();
});
- it('shows invalid query message', async () => {
+ it('shows invalid query message', () => {
expect(wrapper.text()).toContain(errorMessage);
});
});
diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_spec.js
index af56b94f90b..c633583f2cb 100644
--- a/spec/frontend/custom_metrics/components/custom_metrics_form_spec.js
+++ b/spec/frontend/custom_metrics/components/custom_metrics_form_spec.js
@@ -26,10 +26,6 @@ describe('CustomMetricsForm', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Computed', () => {
it('Form button and title text indicate the custom metric is being edited', () => {
mountComponent({ metricPersisted: true });
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js
index 113e0d8f60d..1cd16e39417 100644
--- a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js
@@ -46,14 +46,9 @@ describe('Deploy freeze modal', () => {
wrapper.findComponent(TimezoneDropdown).trigger('input');
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('Basic interactions', () => {
it('button is disabled when freeze period is invalid', () => {
- expect(submitDeployFreezeButton().attributes('disabled')).toBe('true');
+ expect(submitDeployFreezeButton().attributes('disabled')).toBeDefined();
});
});
@@ -93,7 +88,7 @@ describe('Deploy freeze modal', () => {
});
it('disables the add deploy freeze button', () => {
- expect(submitDeployFreezeButton().attributes('disabled')).toBe('true');
+ expect(submitDeployFreezeButton().attributes('disabled')).toBeDefined();
});
});
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js
index 27d8fea9d5e..883cc6a344a 100644
--- a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js
@@ -24,11 +24,6 @@ describe('Deploy freeze settings', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('Deploy freeze table contains components', () => {
it('contains deploy freeze table', () => {
expect(wrapper.findComponent(DeployFreezeTable).exists()).toBe(true);
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 c2d6eb399bc..6a9e482a184 100644
--- a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
@@ -37,11 +37,6 @@ describe('Deploy freeze table', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('dispatches fetchFreezePeriods when mounted', () => {
expect(store.dispatch).toHaveBeenCalledWith('fetchFreezePeriods');
});
diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js
index 9b96ce5d252..d39577baa59 100644
--- a/spec/frontend/deploy_freeze/store/actions_spec.js
+++ b/spec/frontend/deploy_freeze/store/actions_spec.js
@@ -4,14 +4,14 @@ import Api from '~/api';
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 { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import * as logger from '~/lib/logger';
import axios from '~/lib/utils/axios_utils';
import { freezePeriodsFixture } from '../helpers';
import { timezoneDataFixture } from '../../vue_shared/components/timezone_dropdown/helpers';
jest.mock('~/api.js');
-jest.mock('~/flash.js');
+jest.mock('~/alert');
describe('deploy freeze store actions', () => {
const freezePeriodFixture = freezePeriodsFixture[0];
diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js
index d11ecf95de6..3dfb828b449 100644
--- a/spec/frontend/deploy_keys/components/app_spec.js
+++ b/spec/frontend/deploy_keys/components/app_spec.js
@@ -33,7 +33,6 @@ describe('Deploy keys app component', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js
index 8599c55c908..3c4fa2a6de6 100644
--- a/spec/frontend/deploy_keys/components/key_spec.js
+++ b/spec/frontend/deploy_keys/components/key_spec.js
@@ -1,9 +1,10 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import data from 'test_fixtures/deploy_keys/keys.json';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import key from '~/deploy_keys/components/key.vue';
import DeployKeysStore from '~/deploy_keys/store';
-import { getTimeago } from '~/lib/utils/datetime_utility';
+import { getTimeago, formatDate } from '~/lib/utils/datetime_utility';
describe('Deploy keys key', () => {
let wrapper;
@@ -18,6 +19,9 @@ describe('Deploy keys key', () => {
endpoint: 'https://test.host/dummy/endpoint',
...propsData,
},
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
};
@@ -26,11 +30,6 @@ describe('Deploy keys key', () => {
store.keys = data;
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('enabled key', () => {
const deployKey = data.enabled_keys[0];
@@ -48,6 +47,33 @@ describe('Deploy keys key', () => {
);
});
+ it('renders human friendly expiration date', () => {
+ const expiresAt = new Date();
+ createComponent({
+ deployKey: { ...deployKey, expires_at: expiresAt },
+ });
+
+ expect(findTextAndTrim('.key-expires-at')).toBe(`${getTimeago().format(expiresAt)}`);
+ });
+ it('shows tooltip for expiration date', () => {
+ const expiresAt = new Date();
+ createComponent({
+ deployKey: { ...deployKey, expires_at: expiresAt },
+ });
+
+ const expiryComponent = wrapper.find('[data-testid="expires-at-tooltip"]');
+ const tooltip = getBinding(expiryComponent.element, 'gl-tooltip');
+ expect(tooltip).toBeDefined();
+ expect(expiryComponent.attributes('title')).toBe(`${formatDate(expiresAt)}`);
+ });
+ it('renders never when no expiration date', () => {
+ createComponent({
+ deployKey: { ...deployKey, expires_at: null },
+ });
+
+ expect(wrapper.find('[data-testid="expires-never"]').exists()).toBe(true);
+ });
+
it('shows pencil button for editing', () => {
createComponent({ deployKey });
diff --git a/spec/frontend/deploy_keys/components/keys_panel_spec.js b/spec/frontend/deploy_keys/components/keys_panel_spec.js
index f5f76d5d493..e0f86aadad4 100644
--- a/spec/frontend/deploy_keys/components/keys_panel_spec.js
+++ b/spec/frontend/deploy_keys/components/keys_panel_spec.js
@@ -23,11 +23,6 @@ describe('Deploy keys panel', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders list of keys', () => {
mountComponent();
expect(wrapper.findAll('.deploy-key').length).toBe(wrapper.vm.keys.length);
diff --git a/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js b/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js
index 46f7b2f3604..a3fdab88270 100644
--- a/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js
+++ b/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js
@@ -7,20 +7,12 @@ import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/h
import { TEST_HOST } from 'helpers/test_constants';
import NewDeployToken from '~/deploy_tokens/components/new_deploy_token.vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
const createNewTokenPath = `${TEST_HOST}/create`;
const deployTokensHelpUrl = `${TEST_HOST}/help`;
-jest.mock('~/flash', () => {
- const original = jest.requireActual('~/flash');
-
- return {
- __esModule: true,
- ...original,
- createAlert: jest.fn(),
- };
-});
+jest.mock('~/alert');
describe('New Deploy Token', () => {
let wrapper;
@@ -43,13 +35,12 @@ describe('New Deploy Token', () => {
createNewTokenPath,
tokenType,
},
+ stubs: {
+ GlFormCheckbox,
+ },
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('without a container registry', () => {
beforeEach(() => {
wrapper = factory({ containerRegistryEnabled: false });
@@ -69,7 +60,7 @@ describe('New Deploy Token', () => {
it('should show the read registry scope', () => {
const checkbox = wrapper.findAllComponents(GlFormCheckbox).at(1);
- expect(checkbox.text()).toBe('read_registry');
+ expect(checkbox.text()).toContain('read_registry');
});
function submitTokenThenCheck() {
@@ -91,7 +82,7 @@ describe('New Deploy Token', () => {
});
}
- it('should flash error message if token creation fails', async () => {
+ it('should alert error message if token creation fails', async () => {
const mockAxios = new MockAdapter(axios);
const date = new Date();
@@ -222,4 +213,32 @@ describe('New Deploy Token', () => {
return submitTokenThenCheck();
});
});
+
+ describe('help text for write_package_registry scope', () => {
+ const findWriteRegistryScopeCheckbox = () => wrapper.findAllComponents(GlFormCheckbox).at(4);
+
+ describe('with project tokenType', () => {
+ beforeEach(() => {
+ wrapper = factory();
+ });
+
+ it('should show the correct help text', () => {
+ expect(findWriteRegistryScopeCheckbox().text()).toContain(
+ 'Allows read, write and delete access to the package registry.',
+ );
+ });
+ });
+
+ describe('with group tokenType', () => {
+ beforeEach(() => {
+ wrapper = factory({ tokenType: 'group' });
+ });
+
+ it('should show the correct help text', () => {
+ expect(findWriteRegistryScopeCheckbox().text()).toContain(
+ 'Allows read and write access to the package registry.',
+ );
+ });
+ });
+ });
});
diff --git a/spec/frontend/deploy_tokens/components/revoke_button_spec.js b/spec/frontend/deploy_tokens/components/revoke_button_spec.js
index fa2a7d9b155..6e81205d1c1 100644
--- a/spec/frontend/deploy_tokens/components/revoke_button_spec.js
+++ b/spec/frontend/deploy_tokens/components/revoke_button_spec.js
@@ -52,10 +52,6 @@ describe('RevokeButton', () => {
);
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findRevokeButton = () => wrapper.findByTestId('revoke-button');
const findModal = () => wrapper.findComponent(GlModal);
const findPrimaryModalButton = () => wrapper.findByTestId('primary-revoke-btn');
diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js
index 439c20e0fb5..44279ec7915 100644
--- a/spec/frontend/deprecated_jquery_dropdown_spec.js
+++ b/spec/frontend/deprecated_jquery_dropdown_spec.js
@@ -1,8 +1,9 @@
/* eslint-disable no-param-reassign */
import $ from 'jquery';
+import htmlDeprecatedJqueryDropdown from 'test_fixtures_static/deprecated_jquery_dropdown.html';
import mockProjects from 'test_fixtures_static/projects.json';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import '~/lib/utils/common_utils';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -65,7 +66,7 @@ describe('deprecatedJQueryDropdown', () => {
}
beforeEach(() => {
- loadHTMLFixture('static/deprecated_jquery_dropdown.html');
+ setHTMLFixture(htmlDeprecatedJqueryDropdown);
test.dropdownContainerElement = $('.dropdown.inline');
test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement);
test.projectsData = JSON.parse(JSON.stringify(mockProjects));
diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js
index 426a61f5a47..cacda9a475e 100644
--- a/spec/frontend/design_management/components/delete_button_spec.js
+++ b/spec/frontend/design_management/components/delete_button_spec.js
@@ -21,10 +21,6 @@ describe('Batch delete button component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders non-disabled button by default', () => {
createComponent();
@@ -34,7 +30,7 @@ describe('Batch delete button component', () => {
it('renders disabled button when design is deleting', () => {
createComponent({ isDeleting: true });
- expect(findButton().attributes('disabled')).toBe('true');
+ expect(findButton().attributes('disabled')).toBeDefined();
});
it('emits `delete-selected-designs` event on modal ok click', async () => {
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
index 402e55347af..3b407d11041 100644
--- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
@@ -58,6 +58,7 @@ exports[`Design note component should match the snapshot 1`] = `
>
<time-ago-tooltip-stub
cssclass=""
+ datetimeformat="DATE_WITH_TIME_FORMAT"
time="2019-07-26T15:02:20Z"
tooltipplacement="bottom"
/>
@@ -70,6 +71,8 @@ exports[`Design note component should match the snapshot 1`] = `
>
<!---->
+
+ <!---->
</div>
</div>
diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
index 2091e1e08dd..a6ab147884f 100644
--- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
@@ -1,18 +1,22 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { ApolloMutation } from 'vue-apollo';
import { nextTick } from 'vue';
+import waitForPromises from 'helpers/wait_for_promises';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
import DesignNote from '~/design_management/components/design_notes/design_note.vue';
import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue';
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue';
-import createNoteMutation from '~/design_management/graphql/mutations/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '~/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import destroyNoteMutation from '~/design_management/graphql/mutations/destroy_note.mutation.graphql';
+import { DELETE_NOTE_ERROR_MSG } from '~/design_management/constants';
import mockDiscussion from '../../mock_data/discussion';
import notes from '../../mock_data/notes';
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
+
const defaultMockDiscussion = {
id: '0',
resolved: false,
@@ -23,7 +27,6 @@ const defaultMockDiscussion = {
const DEFAULT_TODO_COUNT = 2;
describe('Design discussions component', () => {
- const originalGon = window.gon;
let wrapper;
const findDesignNotes = () => wrapper.findAllComponents(DesignNote);
@@ -34,18 +37,7 @@ describe('Design discussions component', () => {
const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]');
const findResolveLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]');
- const findApolloMutation = () => wrapper.findComponent(ApolloMutation);
- const mutationVariables = {
- mutation: createNoteMutation,
- variables: {
- input: {
- noteableId: 'noteable-id',
- body: 'test',
- discussionId: '0',
- },
- },
- };
const registerPath = '/users/sign_up?redirect_to_referer=yes';
const signInPath = '/users/sign_in?redirect_to_referer=yes';
const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } });
@@ -59,7 +51,7 @@ describe('Design discussions component', () => {
provider: { clients: { defaultClient: { readQuery } } },
};
- function createComponent(props = {}, data = {}) {
+ function createComponent({ props = {}, data = {}, apolloConfig = {} } = {}) {
wrapper = mount(DesignDiscussion, {
propsData: {
resolvedDiscussionsExpanded: true,
@@ -82,7 +74,10 @@ describe('Design discussions component', () => {
issueIid: '1',
},
mocks: {
- $apollo,
+ $apollo: {
+ ...$apollo,
+ ...apolloConfig,
+ },
$route: {
hash: '#note_1',
params: {
@@ -101,16 +96,17 @@ describe('Design discussions component', () => {
});
afterEach(() => {
- wrapper.destroy();
- window.gon = originalGon;
+ confirmAction.mockReset();
});
describe('when discussion is not resolvable', () => {
beforeEach(() => {
createComponent({
- discussion: {
- ...defaultMockDiscussion,
- resolvable: false,
+ props: {
+ discussion: {
+ ...defaultMockDiscussion,
+ resolvable: false,
+ },
},
});
});
@@ -171,11 +167,13 @@ describe('Design discussions component', () => {
innerText: DEFAULT_TODO_COUNT,
});
createComponent({
- discussion: {
- ...defaultMockDiscussion,
- resolved: true,
- resolvedBy: notes[0].author,
- resolvedAt: '2020-05-08T07:10:45Z',
+ props: {
+ discussion: {
+ ...defaultMockDiscussion,
+ resolved: true,
+ resolvedBy: notes[0].author,
+ resolvedAt: '2020-05-08T07:10:45Z',
+ },
},
});
});
@@ -206,10 +204,10 @@ describe('Design discussions component', () => {
});
it('emit todo:toggle when discussion is resolved', async () => {
- createComponent(
- { discussionWithOpenForm: defaultMockDiscussion.id },
- { discussionComment: 'test', isFormRendered: true },
- );
+ createComponent({
+ props: { discussionWithOpenForm: defaultMockDiscussion.id },
+ data: { isFormRendered: true },
+ });
findResolveButton().trigger('click');
findReplyForm().vm.$emit('submitForm');
@@ -261,32 +259,28 @@ describe('Design discussions component', () => {
expect(findReplyForm().exists()).toBe(true);
});
- it('calls mutation on submitting form and closes the form', async () => {
- createComponent(
- { discussionWithOpenForm: defaultMockDiscussion.id },
- { discussionComment: 'test', isFormRendered: true },
- );
+ it('closes the form when note submit mutation is completed', async () => {
+ createComponent({
+ props: { discussionWithOpenForm: defaultMockDiscussion.id },
+ data: { isFormRendered: true },
+ });
- findReplyForm().vm.$emit('submit-form');
- expect(mutate).toHaveBeenCalledWith(mutationVariables);
+ findReplyForm().vm.$emit('note-submit-complete', { data: { createNote: {} } });
- await mutate();
await nextTick();
expect(findReplyForm().exists()).toBe(false);
});
it('clears the discussion comment on closing comment form', async () => {
- createComponent(
- { discussionWithOpenForm: defaultMockDiscussion.id },
- { discussionComment: 'test', isFormRendered: true },
- );
+ createComponent({
+ props: { discussionWithOpenForm: defaultMockDiscussion.id },
+ data: { isFormRendered: true },
+ });
await nextTick();
findReplyForm().vm.$emit('cancel-form');
- expect(wrapper.vm.discussionComment).toBe('');
-
await nextTick();
expect(findReplyForm().exists()).toBe(false);
});
@@ -295,15 +289,15 @@ describe('Design discussions component', () => {
it.each([notes[0], notes[0].discussion.notes.nodes[1]])(
'applies correct class to all notes in the active discussion',
(note) => {
- createComponent(
- { discussion: mockDiscussion },
- {
+ createComponent({
+ props: { discussion: mockDiscussion },
+ data: {
activeDiscussion: {
id: note.id,
source: 'pin',
},
},
- );
+ });
expect(
wrapper
@@ -329,10 +323,10 @@ describe('Design discussions component', () => {
});
it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => {
- createComponent(
- { discussionWithOpenForm: defaultMockDiscussion.id },
- { discussionComment: 'test', isFormRendered: true },
- );
+ createComponent({
+ props: { discussionWithOpenForm: defaultMockDiscussion.id },
+ data: { isFormRendered: true },
+ });
findResolveButton().trigger('click');
findReplyForm().vm.$emit('submitForm');
@@ -359,15 +353,15 @@ describe('Design discussions component', () => {
beforeEach(() => {
window.gon = { current_user_id: null };
- createComponent(
- {
+ createComponent({
+ props: {
discussion: {
...defaultMockDiscussion,
},
discussionWithOpenForm: defaultMockDiscussion.id,
},
- { discussionComment: 'test', isFormRendered: true },
- );
+ data: { isFormRendered: true },
+ });
});
it('does not render resolve discussion button', () => {
@@ -378,10 +372,6 @@ describe('Design discussions component', () => {
expect(findReplyPlaceholder().exists()).toBe(false);
});
- it('does not render apollo-mutation component', () => {
- expect(findApolloMutation().exists()).toBe(false);
- });
-
it('renders design-note-signed-out component', () => {
expect(findDesignNoteSignedOut().exists()).toBe(true);
expect(findDesignNoteSignedOut().props()).toMatchObject({
@@ -390,4 +380,64 @@ describe('Design discussions component', () => {
});
});
});
+
+ it('should open confirmation modal when the note emits `delete-note` event', () => {
+ createComponent();
+
+ findDesignNotes().at(0).vm.$emit('delete-note', { id: '1' });
+ expect(confirmAction).toHaveBeenCalled();
+ });
+
+ describe('when confirmation modal is opened', () => {
+ const noteId = 'note-test-id';
+
+ it('sends the mutation with correct variables', async () => {
+ confirmAction.mockResolvedValueOnce(true);
+ const destroyNoteMutationSuccess = jest.fn().mockResolvedValue({
+ data: { destroyNote: { note: null, __typename: 'DestroyNote', errors: [] } },
+ });
+ createComponent({ apolloConfig: { mutate: destroyNoteMutationSuccess } });
+
+ findDesignNotes().at(0).vm.$emit('delete-note', { id: noteId });
+
+ expect(confirmAction).toHaveBeenCalled();
+
+ await waitForPromises();
+
+ expect(destroyNoteMutationSuccess).toHaveBeenCalledWith({
+ update: expect.any(Function),
+ mutation: destroyNoteMutation,
+ variables: {
+ input: {
+ id: noteId,
+ },
+ },
+ optimisticResponse: {
+ destroyNote: {
+ note: null,
+ errors: [],
+ __typename: 'DestroyNotePayload',
+ },
+ },
+ });
+ });
+
+ it('emits `delete-note-error` event if GraphQL mutation fails', async () => {
+ confirmAction.mockResolvedValueOnce(true);
+ const destroyNoteMutationError = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+ createComponent({ apolloConfig: { mutate: destroyNoteMutationError } });
+
+ findDesignNotes().at(0).vm.$emit('delete-note', { id: noteId });
+
+ await waitForPromises();
+
+ expect(destroyNoteMutationError).toHaveBeenCalled();
+
+ await waitForPromises();
+
+ expect(wrapper.emitted()).toEqual({
+ 'delete-note-error': [[DELETE_NOTE_ERROR_MSG]],
+ });
+ });
+ });
});
diff --git a/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js b/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js
index e71bb5ab520..95b08b89809 100644
--- a/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js
@@ -18,10 +18,6 @@ function createComponent(isAddDiscussion = false) {
describe('DesignNoteSignedOut', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders message containing register and sign-in links while user wants to reply to a discussion', () => {
wrapper = createComponent();
diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js
index df511586c10..6f5b282fa3b 100644
--- a/spec/frontend/design_management/components/design_notes/design_note_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js
@@ -1,6 +1,6 @@
import { ApolloMutation } from 'vue-apollo';
import { nextTick } from 'vue';
-import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
+import { GlAvatar, GlAvatarLink, GlDropdown } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import DesignNote from '~/design_management/components/design_notes/design_note.vue';
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
@@ -38,6 +38,8 @@ describe('Design note component', () => {
const findReplyForm = () => wrapper.findComponent(DesignReplyForm);
const findEditButton = () => wrapper.findByTestId('note-edit');
const findNoteContent = () => wrapper.findByTestId('note-text');
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-button"]');
function createComponent(props = {}, data = { isEditing: false }) {
wrapper = shallowMountExtended(DesignNote, {
@@ -63,10 +65,6 @@ describe('Design note component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should match the snapshot', () => {
createComponent({
note,
@@ -112,6 +110,14 @@ describe('Design note component', () => {
expect(findEditButton().exists()).toBe(false);
});
+ it('should not display a dropdown if user does not have a permission to delete note', () => {
+ createComponent({
+ note,
+ });
+
+ expect(findDropdown().exists()).toBe(false);
+ });
+
describe('when user has a permission to edit note', () => {
it('should open an edit form on edit button click', async () => {
createComponent({
@@ -158,15 +164,47 @@ describe('Design note component', () => {
expect(findNoteContent().exists()).toBe(true);
});
- it('calls a mutation on submit-form event and hides a form', async () => {
- findReplyForm().vm.$emit('submit-form');
- expect(mutate).toHaveBeenCalled();
+ it('hides a form after update mutation is completed', async () => {
+ findReplyForm().vm.$emit('note-submit-complete', { data: { updateNote: { errors: [] } } });
- await mutate();
await nextTick();
expect(findReplyForm().exists()).toBe(false);
expect(findNoteContent().exists()).toBe(true);
});
});
});
+
+ describe('when user has a permission to delete note', () => {
+ it('should display a dropdown', () => {
+ createComponent({
+ note: {
+ ...note,
+ userPermissions: {
+ adminNote: true,
+ },
+ },
+ });
+
+ expect(findDropdown().exists()).toBe(true);
+ });
+ });
+
+ it('should emit `delete-note` event with proper payload when delete note button is clicked', () => {
+ const payload = {
+ ...note,
+ userPermissions: {
+ adminNote: true,
+ },
+ };
+
+ createComponent({
+ note: {
+ ...payload,
+ },
+ });
+
+ findDeleteNoteButton().vm.$emit('click');
+
+ expect(wrapper.emitted()).toEqual({ 'delete-note': [[{ ...payload }]] });
+ });
});
diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
index f4d4f9cf896..f08efc0c685 100644
--- a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
@@ -1,46 +1,95 @@
+import { GlAlert } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import Autosave from '~/autosave';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import createNoteMutation from '~/design_management/graphql/mutations/create_note.mutation.graphql';
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+import {
+ ADD_DISCUSSION_COMMENT_ERROR,
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_NOTE_ERROR,
+} from '~/design_management/utils/error_messages';
+import {
+ mockNoteSubmitSuccessMutationResponse,
+ mockNoteSubmitFailureMutationResponse,
+} from '../../mock_data/apollo_mock';
+
+Vue.use(VueApollo);
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
jest.mock('~/autosave');
describe('Design reply form component', () => {
let wrapper;
- let originalGon;
+ let mockApollo;
const findTextarea = () => wrapper.find('textarea');
const findSubmitButton = () => wrapper.findComponent({ ref: 'submitButton' });
const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' });
-
- function createComponent(props = {}, mountOptions = {}) {
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ const mockNoteableId = 'gid://gitlab/DesignManagement::Design/6';
+ const mockComment = 'New comment';
+ const mockDiscussionId = 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8';
+ const createNoteMutationData = {
+ input: {
+ noteableId: mockNoteableId,
+ discussionId: mockDiscussionId,
+ body: mockComment,
+ },
+ };
+
+ const ctrlKey = {
+ ctrlKey: true,
+ };
+ const metaKey = {
+ metaKey: true,
+ };
+ const mockMutationHandler = jest.fn().mockResolvedValue(mockNoteSubmitSuccessMutationResponse);
+
+ function createComponent({
+ props = {},
+ mountOptions = {},
+ data = {},
+ mutationHandler = mockMutationHandler,
+ } = {}) {
+ mockApollo = createMockApollo([[createNoteMutation, mutationHandler]]);
wrapper = mount(DesignReplyForm, {
propsData: {
+ designNoteMutation: createNoteMutation,
+ noteableId: mockNoteableId,
+ markdownDocsPath: 'path/to/markdown/docs',
+ markdownPreviewPath: 'path/to/markdown/preview',
value: '',
- isSaving: false,
- noteableId: 'gid://gitlab/DesignManagement::Design/6',
...props,
},
...mountOptions,
+ apolloProvider: mockApollo,
+ data() {
+ return {
+ ...data,
+ };
+ },
});
}
beforeEach(() => {
- originalGon = window.gon;
window.gon.current_user_id = 1;
});
afterEach(() => {
- wrapper.destroy();
- window.gon = originalGon;
+ mockApollo = null;
confirmAction.mockReset();
});
it('textarea has focus after component mount', () => {
// We need to attach to document, so that `document.activeElement` is properly set in jsdom
- createComponent({}, { attachTo: document.body });
+ createComponent({ mountOptions: { attachTo: document.body } });
expect(findTextarea().element).toEqual(document.activeElement);
});
@@ -64,7 +113,7 @@ describe('Design reply form component', () => {
});
it('renders button text as "Save comment" when creating a comment', () => {
- createComponent({ isNewComment: false });
+ createComponent({ props: { isNewComment: false } });
expect(findSubmitButton().html()).toMatchSnapshot();
});
@@ -75,9 +124,8 @@ describe('Design reply form component', () => {
${'gid://gitlab/DiffDiscussion/123'} | ${123}
`(
'initializes autosave support on discussion with proper key',
- async ({ discussionId, shortDiscussionId }) => {
- createComponent({ discussionId });
- await nextTick();
+ ({ discussionId, shortDiscussionId }) => {
+ createComponent({ props: { discussionId } });
expect(Autosave).toHaveBeenCalledWith(expect.any(Element), [
'Discussion',
@@ -89,31 +137,21 @@ describe('Design reply form component', () => {
describe('when form has no text', () => {
beforeEach(() => {
- createComponent({
- value: '',
- });
+ createComponent();
});
it('submit button is disabled', () => {
expect(findSubmitButton().attributes().disabled).toBe('disabled');
});
- it('does not emit submitForm event on textarea ctrl+enter keydown', async () => {
- findTextarea().trigger('keydown.enter', {
- ctrlKey: true,
- });
-
- await nextTick();
- expect(wrapper.emitted('submit-form')).toBeUndefined();
- });
-
- it('does not emit submitForm event on textarea meta+enter keydown', async () => {
- findTextarea().trigger('keydown.enter', {
- metaKey: true,
- });
+ it.each`
+ key | keyData
+ ${'ctrl'} | ${ctrlKey}
+ ${'meta'} | ${metaKey}
+ `('does not perform mutation on textarea $key+enter keydown', ({ keyData }) => {
+ findTextarea().trigger('keydown.enter', keyData);
- await nextTick();
- expect(wrapper.emitted('submit-form')).toBeUndefined();
+ expect(mockMutationHandler).not.toHaveBeenCalled();
});
it('emits cancelForm event on pressing escape button on textarea', () => {
@@ -129,118 +167,150 @@ describe('Design reply form component', () => {
});
});
- describe('when form has text', () => {
- beforeEach(() => {
- createComponent({
- value: 'test',
- });
- });
-
+ describe('when the form has text', () => {
it('submit button is enabled', () => {
+ createComponent({ props: { value: mockComment } });
expect(findSubmitButton().attributes().disabled).toBeUndefined();
});
- it('emits submitForm event on Comment button click', async () => {
- const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset');
+ it('calls a mutation on submit button click event', async () => {
+ const mockMutationVariables = {
+ noteableId: mockNoteableId,
+ discussionId: mockDiscussionId,
+ };
- findSubmitButton().vm.$emit('click');
+ createComponent({
+ props: {
+ mutationVariables: mockMutationVariables,
+ value: mockComment,
+ },
+ });
- await nextTick();
- expect(wrapper.emitted('submit-form')).toHaveLength(1);
- expect(autosaveResetSpy).toHaveBeenCalled();
- });
+ findSubmitButton().vm.$emit('click');
- it('emits submitForm event on textarea ctrl+enter keydown', async () => {
- const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset');
+ expect(mockMutationHandler).toHaveBeenCalledWith(createNoteMutationData);
- findTextarea().trigger('keydown.enter', {
- ctrlKey: true,
- });
+ await waitForPromises();
- await nextTick();
- expect(wrapper.emitted('submit-form')).toHaveLength(1);
- expect(autosaveResetSpy).toHaveBeenCalled();
+ expect(wrapper.emitted('note-submit-complete')).toEqual([
+ [mockNoteSubmitSuccessMutationResponse],
+ ]);
});
- it('emits submitForm event on textarea meta+enter keydown', async () => {
- const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset');
+ it.each`
+ key | keyData
+ ${'ctrl'} | ${ctrlKey}
+ ${'meta'} | ${metaKey}
+ `('does perform mutation on textarea $key+enter keydown', async ({ keyData }) => {
+ const mockMutationVariables = {
+ noteableId: mockNoteableId,
+ discussionId: mockDiscussionId,
+ };
- findTextarea().trigger('keydown.enter', {
- metaKey: true,
+ createComponent({
+ props: {
+ mutationVariables: mockMutationVariables,
+ value: mockComment,
+ },
});
- await nextTick();
- expect(wrapper.emitted('submit-form')).toHaveLength(1);
- expect(autosaveResetSpy).toHaveBeenCalled();
- });
+ findTextarea().trigger('keydown.enter', keyData);
- it('emits input event on changing textarea content', async () => {
- findTextarea().setValue('test2');
+ expect(mockMutationHandler).toHaveBeenCalledWith(createNoteMutationData);
- await nextTick();
- expect(wrapper.emitted('input')).toEqual([['test2']]);
+ await waitForPromises();
+ expect(wrapper.emitted('note-submit-complete')).toEqual([
+ [mockNoteSubmitSuccessMutationResponse],
+ ]);
});
- it('emits cancelForm event on Escape key if text was not changed', () => {
- findTextarea().trigger('keyup.esc');
+ it('shows error message when mutation fails', async () => {
+ const failedMutation = jest.fn().mockRejectedValue(mockNoteSubmitFailureMutationResponse);
+ createComponent({
+ props: {
+ designNoteMutation: createNoteMutation,
+ value: mockComment,
+ },
+ mutationHandler: failedMutation,
+ data: {
+ errorMessage: 'error',
+ },
+ });
- expect(wrapper.emitted('cancel-form')).toHaveLength(1);
+ findSubmitButton().vm.$emit('click');
+
+ await waitForPromises();
+ expect(findAlert().exists()).toBe(true);
});
- it('opens confirmation modal on Escape key when text has changed', async () => {
- wrapper.setProps({ value: 'test2' });
+ it.each`
+ isDiscussion | isNewComment | errorMessage
+ ${true} | ${true} | ${ADD_IMAGE_DIFF_NOTE_ERROR}
+ ${true} | ${false} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR}
+ ${false} | ${true} | ${ADD_DISCUSSION_COMMENT_ERROR}
+ ${false} | ${false} | ${UPDATE_NOTE_ERROR}
+ `(
+ 'return proper error message on error in case of isDiscussion is $isDiscussion and isNewComment is $isNewComment',
+ ({ isDiscussion, isNewComment, errorMessage }) => {
+ createComponent({ props: { isDiscussion, isNewComment } });
+
+ expect(wrapper.vm.getErrorMessage()).toBe(errorMessage);
+ },
+ );
- await nextTick();
- findTextarea().trigger('keyup.esc');
- expect(confirmAction).toHaveBeenCalled();
- });
+ it('emits cancelForm event on Escape key if text was not changed', () => {
+ createComponent();
- it('emits cancelForm event on Cancel button click if text was not changed', () => {
- findCancelButton().trigger('click');
+ findTextarea().trigger('keyup.esc');
expect(wrapper.emitted('cancel-form')).toHaveLength(1);
});
- it('opens confirmation modal on Cancel button click when text has changed', async () => {
- wrapper.setProps({ value: 'test2' });
+ it('opens confirmation modal on Escape key when text has changed', () => {
+ createComponent();
+
+ findTextarea().setValue(mockComment);
+
+ findTextarea().trigger('keyup.esc');
- await nextTick();
- findCancelButton().trigger('click');
expect(confirmAction).toHaveBeenCalled();
});
it('emits cancelForm event when confirmed', async () => {
confirmAction.mockResolvedValueOnce(true);
- const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset');
- wrapper.setProps({ value: 'test3' });
- await nextTick();
+ createComponent({ props: { value: mockComment } });
+ findTextarea().setValue('Comment changed');
findTextarea().trigger('keyup.esc');
- await nextTick();
expect(confirmAction).toHaveBeenCalled();
- await nextTick();
+ await waitForPromises();
expect(wrapper.emitted('cancel-form')).toHaveLength(1);
- expect(autosaveResetSpy).toHaveBeenCalled();
});
- it("doesn't emit cancelForm event when not confirmed", async () => {
+ it('does not emit cancelForm event when not confirmed', async () => {
confirmAction.mockResolvedValueOnce(false);
- const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset');
- wrapper.setProps({ value: 'test3' });
- await nextTick();
+ createComponent({ props: { value: mockComment } });
+ findTextarea().setValue('Comment changed');
findTextarea().trigger('keyup.esc');
- await nextTick();
expect(confirmAction).toHaveBeenCalled();
- await nextTick();
+ await waitForPromises();
expect(wrapper.emitted('cancel-form')).toBeUndefined();
- expect(autosaveResetSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when component is destroyed', () => {
+ it('calls autosave.reset', async () => {
+ const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset');
+ createComponent();
+ await wrapper.destroy();
+ expect(autosaveResetSpy).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/design_management/components/design_notes/toggle_replies_widget_spec.js b/spec/frontend/design_management/components/design_notes/toggle_replies_widget_spec.js
index 41129e2b58d..eaa5a620fa6 100644
--- a/spec/frontend/design_management/components/design_notes/toggle_replies_widget_spec.js
+++ b/spec/frontend/design_management/components/design_notes/toggle_replies_widget_spec.js
@@ -23,10 +23,6 @@ describe('Toggle replies widget component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when replies are collapsed', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js
index 2807fe7727f..3eb47fdb97e 100644
--- a/spec/frontend/design_management/components/design_overlay_spec.js
+++ b/spec/frontend/design_management/components/design_overlay_spec.js
@@ -1,6 +1,6 @@
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DesignOverlay from '~/design_management/components/design_overlay.vue';
@@ -16,22 +16,20 @@ describe('Design overlay component', () => {
const mockDimensions = { width: 100, height: 100 };
- const findOverlay = () => wrapper.find('[data-testid="design-overlay"]');
- const findAllNotes = () => wrapper.findAll('[data-testid="note-pin"]');
- const findCommentBadge = () => wrapper.find('[data-testid="comment-badge"]');
+ const findOverlay = () => wrapper.findByTestId('design-overlay');
+ const findAllNotes = () => wrapper.findAllByTestId('note-pin');
+ const findCommentBadge = () => wrapper.findByTestId('comment-badge');
const findBadgeAtIndex = (noteIndex) => findAllNotes().at(noteIndex);
const findFirstBadge = () => findBadgeAtIndex(0);
const findSecondBadge = () => findBadgeAtIndex(1);
- const clickAndDragBadge = async (elem, fromPoint, toPoint) => {
+ const clickAndDragBadge = (elem, fromPoint, toPoint) => {
elem.vm.$emit(
'mousedown',
new MouseEvent('click', { clientX: fromPoint.x, clientY: fromPoint.y }),
);
findOverlay().trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y });
- await nextTick();
elem.vm.$emit('mouseup', new MouseEvent('click', { clientX: toPoint.x, clientY: toPoint.y }));
- await nextTick();
};
function createComponent(props = {}, data = {}) {
@@ -47,7 +45,7 @@ describe('Design overlay component', () => {
},
});
- wrapper = shallowMount(DesignOverlay, {
+ wrapper = shallowMountExtended(DesignOverlay, {
apolloProvider,
propsData: {
dimensions: mockDimensions,
@@ -80,7 +78,7 @@ describe('Design overlay component', () => {
expect(wrapper.attributes().style).toBe('width: 100px; height: 100px; top: 0px; left: 0px;');
});
- it('should emit `openCommentForm` when clicking on overlay', async () => {
+ it('should emit `openCommentForm` when clicking on overlay', () => {
createComponent();
const newCoordinates = {
x: 10,
@@ -90,7 +88,7 @@ describe('Design overlay component', () => {
wrapper
.find('[data-qa-selector="design_image_button"]')
.trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y });
- await nextTick();
+
expect(wrapper.emitted('openCommentForm')).toEqual([
[{ x: newCoordinates.x, y: newCoordinates.y }],
]);
@@ -175,25 +173,15 @@ describe('Design overlay component', () => {
});
});
- it('should recalculate badges positions on window resize', async () => {
+ it('should calculate badges positions based on dimensions', () => {
createComponent({
notes,
dimensions: {
- width: 400,
- height: 400,
- },
- });
-
- expect(findFirstBadge().props('position')).toEqual({ left: '40px', top: '60px' });
-
- wrapper.setProps({
- dimensions: {
width: 200,
height: 200,
},
});
- await nextTick();
expect(findFirstBadge().props('position')).toEqual({ left: '20px', top: '30px' });
});
@@ -216,7 +204,6 @@ describe('Design overlay component', () => {
new MouseEvent('click', { clientX: position.x, clientY: position.y }),
);
- await nextTick();
findFirstBadge().vm.$emit(
'mouseup',
new MouseEvent('click', { clientX: position.x, clientY: position.y }),
@@ -290,7 +277,7 @@ describe('Design overlay component', () => {
});
describe('when moving the comment badge', () => {
- it('should update badge style when note-moving action ends', async () => {
+ it('should update badge style when note-moving action ends', () => {
const { position } = notes[0];
createComponent({
currentCommentForm: {
@@ -298,19 +285,15 @@ describe('Design overlay component', () => {
},
});
- const commentBadge = findCommentBadge();
+ expect(findCommentBadge().props('position')).toEqual({ left: '10px', top: '15px' });
+
const toPoint = { x: 20, y: 20 };
- await clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint);
- commentBadge.vm.$emit('mouseup', new MouseEvent('click'));
- // simulates the currentCommentForm being updated in index.vue component, and
- // propagated back down to this prop
- wrapper.setProps({
+ createComponent({
currentCommentForm: { height: position.height, width: position.width, ...toPoint },
});
- await nextTick();
- expect(commentBadge.props('position')).toEqual({ left: '20px', top: '20px' });
+ expect(findCommentBadge().props('position')).toEqual({ left: '20px', top: '20px' });
});
it('should emit `openCommentForm` event when mouseleave fired on overlay element', async () => {
@@ -330,8 +313,7 @@ describe('Design overlay component', () => {
newCoordinates,
);
- wrapper.trigger('mouseleave');
- await nextTick();
+ findOverlay().vm.$emit('mouseleave');
expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]);
});
diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js
index 4a339899473..fdcea6d88c0 100644
--- a/spec/frontend/design_management/components/design_presentation_spec.js
+++ b/spec/frontend/design_management/components/design_presentation_spec.js
@@ -15,7 +15,6 @@ const mockOverlayData = {
};
describe('Design management design presentation component', () => {
- const originalGon = window.gon;
let wrapper;
function createComponent(
@@ -114,11 +113,6 @@ describe('Design management design presentation component', () => {
window.gon = { current_user_id: 1 };
});
- afterEach(() => {
- wrapper.destroy();
- window.gon = originalGon;
- });
-
it('renders image and overlay when image provided', async () => {
createComponent(
{
diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js
index e1a66cea329..b29448b4471 100644
--- a/spec/frontend/design_management/components/design_scaler_spec.js
+++ b/spec/frontend/design_management/components/design_scaler_spec.js
@@ -25,11 +25,6 @@ describe('Design management design scaler component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when `scale` value is greater than 1', () => {
beforeEach(async () => {
setScale(1.6);
@@ -41,7 +36,7 @@ describe('Design management design scaler component', () => {
expect(wrapper.emitted('scale')[1]).toEqual([1]);
});
- it('emits @scale event when "decrement" button clicked', async () => {
+ it('emits @scale event when "decrement" button clicked', () => {
getDecreaseScaleButton().vm.$emit('click');
expect(wrapper.emitted('scale')[1]).toEqual([1.4]);
});
diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js
index af995f75ddc..90424175417 100644
--- a/spec/frontend/design_management/components/design_sidebar_spec.js
+++ b/spec/frontend/design_management/components/design_sidebar_spec.js
@@ -29,7 +29,6 @@ const $route = {
const mutate = jest.fn().mockResolvedValue();
describe('Design management design sidebar component', () => {
- const originalGon = window.gon;
let wrapper;
const findDiscussions = () => wrapper.findAllComponents(DesignDiscussion);
@@ -67,11 +66,6 @@ describe('Design management design sidebar component', () => {
window.gon = { current_user_id: 1 };
});
- afterEach(() => {
- wrapper.destroy();
- window.gon = originalGon;
- });
-
it('renders participants', () => {
createComponent();
@@ -143,8 +137,8 @@ describe('Design management design sidebar component', () => {
expect(findResolvedCommentsToggle().props('visible')).toBe(true);
});
- it('sends a mutation to set an active discussion when clicking on a discussion', () => {
- findFirstDiscussion().trigger('click');
+ it('emits correct event to send a mutation to set an active discussion when clicking on a discussion', () => {
+ findFirstDiscussion().vm.$emit('update-active-discussion');
expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables);
});
diff --git a/spec/frontend/design_management/components/design_todo_button_spec.js b/spec/frontend/design_management/components/design_todo_button_spec.js
index ac26873b692..698535d8937 100644
--- a/spec/frontend/design_management/components/design_todo_button_spec.js
+++ b/spec/frontend/design_management/components/design_todo_button_spec.js
@@ -51,8 +51,6 @@ describe('Design management design todo button', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
jest.clearAllMocks();
});
@@ -83,7 +81,7 @@ describe('Design management design todo button', () => {
await nextTick();
});
- it('calls `$apollo.mutate` with the `todoMarkDone` mutation and variables containing `id`', async () => {
+ it('calls `$apollo.mutate` with the `todoMarkDone` mutation and variables containing `id`', () => {
const todoMarkDoneMutationVariables = {
mutation: todoMarkDoneMutation,
update: expect.anything(),
@@ -129,7 +127,7 @@ describe('Design management design todo button', () => {
await nextTick();
});
- it('calls `$apollo.mutate` with the `createDesignTodoMutation` mutation and variables containing `issuable_id`, `issue_id`, & `projectPath`', async () => {
+ it('calls `$apollo.mutate` with the `createDesignTodoMutation` mutation and variables containing `issuable_id`, `issue_id`, & `projectPath`', () => {
const createDesignTodoMutationVariables = {
mutation: createDesignTodoMutation,
update: expect.anything(),
diff --git a/spec/frontend/design_management/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js
index 95d2ad504de..53abcc559d8 100644
--- a/spec/frontend/design_management/components/image_spec.js
+++ b/spec/frontend/design_management/components/image_spec.js
@@ -20,10 +20,6 @@ describe('Design management large image component', () => {
stubPerformanceWebAPI();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders loading state', () => {
createComponent({
isLoading: true,
diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
index 3517c0f7a44..9451f35ac5b 100644
--- a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
+++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
@@ -38,13 +38,13 @@ exports[`Design management list item component with notes renders item with mult
</div>
<div
- class="card-footer gl-display-flex gl-w-full"
+ class="card-footer gl-display-flex gl-w-full gl-bg-white gl-py-3 gl-px-4"
>
<div
class="gl-display-flex gl-flex-direction-column str-truncated-100"
>
<span
- class="gl-font-weight-bold str-truncated-100"
+ class="gl-font-weight-semibold str-truncated-100"
data-qa-selector="design_file_name"
data-testid="design-img-filename-1"
title="test"
@@ -59,6 +59,7 @@ exports[`Design management list item component with notes renders item with mult
Updated
<timeago-stub
cssclass=""
+ datetimeformat="DATE_WITH_TIME_FORMAT"
time="01-01-2019"
tooltipplacement="bottom"
/>
@@ -117,13 +118,13 @@ exports[`Design management list item component with notes renders item with sing
</div>
<div
- class="card-footer gl-display-flex gl-w-full"
+ class="card-footer gl-display-flex gl-w-full gl-bg-white gl-py-3 gl-px-4"
>
<div
class="gl-display-flex gl-flex-direction-column str-truncated-100"
>
<span
- class="gl-font-weight-bold str-truncated-100"
+ class="gl-font-weight-semibold str-truncated-100"
data-qa-selector="design_file_name"
data-testid="design-img-filename-1"
title="test"
@@ -138,6 +139,7 @@ exports[`Design management list item component with notes renders item with sing
Updated
<timeago-stub
cssclass=""
+ datetimeformat="DATE_WITH_TIME_FORMAT"
time="01-01-2019"
tooltipplacement="bottom"
/>
diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js
index e907e2e4ac5..4a0ad5a045b 100644
--- a/spec/frontend/design_management/components/list/item_spec.js
+++ b/spec/frontend/design_management/components/list/item_spec.js
@@ -54,10 +54,6 @@ describe('Design management list item component', () => {
);
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when item is not in view', () => {
it('image is not rendered', () => {
createComponent();
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap
deleted file mode 100644
index b5a69b28a88..00000000000
--- a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap
+++ /dev/null
@@ -1,39 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management pagination component hides components when designs are empty 1`] = `<!---->`;
-
-exports[`Design management pagination component renders navigation buttons 1`] = `
-<div
- class="gl-display-flex gl-align-items-center"
->
-
- 0 of 2
-
- <gl-button-group-stub
- class="gl-mx-5"
- >
- <gl-button-stub
- aria-label="Go to previous design"
- buttontextclasses=""
- category="primary"
- class="js-previous-design"
- disabled="true"
- icon="chevron-lg-left"
- size="medium"
- title="Go to previous design"
- variant="default"
- />
-
- <gl-button-stub
- aria-label="Go to next design"
- buttontextclasses=""
- category="primary"
- class="js-next-design"
- icon="chevron-lg-right"
- size="medium"
- title="Go to next design"
- variant="default"
- />
- </gl-button-group-stub>
-</div>
-`;
diff --git a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js
index 38a7fadee79..cee05bafeb6 100644
--- a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js
+++ b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js
@@ -1,9 +1,17 @@
-/* global Mousetrap */
-import 'mousetrap';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlButtonGroup } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import DesignNavigation from '~/design_management/components/toolbar/design_navigation.vue';
import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants';
+import { Mousetrap } from '~/lib/mousetrap';
+import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import {
+ getDesignListQueryResponse,
+ designListQueryResponseNodes,
+} from '../../mock_data/apollo_mock';
const push = jest.fn();
const $router = {
@@ -18,11 +26,23 @@ const $route = {
describe('Design management pagination component', () => {
let wrapper;
- function createComponent() {
+ const buildMockHandler = (nodes = designListQueryResponseNodes) => {
+ return jest.fn().mockResolvedValue(getDesignListQueryResponse({ designs: nodes }));
+ };
+
+ const createMockApolloProvider = (handler) => {
+ Vue.use(VueApollo);
+
+ return createMockApollo([[getDesignListQuery, handler]]);
+ };
+
+ function createComponent({ propsData = {}, handler = buildMockHandler() } = {}) {
wrapper = shallowMount(DesignNavigation, {
propsData: {
id: '2',
+ ...propsData,
},
+ apolloProvider: createMockApolloProvider(handler),
mocks: {
$router,
$route,
@@ -30,52 +50,43 @@ describe('Design management pagination component', () => {
});
}
- beforeEach(() => {
- createComponent();
- });
+ const findGlButtonGroup = () => wrapper.findComponent(GlButtonGroup);
- afterEach(() => {
- wrapper.destroy();
- });
+ it('hides components when designs are empty', async () => {
+ createComponent({ handler: buildMockHandler([]) });
+ await waitForPromises();
- it('hides components when designs are empty', () => {
- expect(wrapper.element).toMatchSnapshot();
+ expect(findGlButtonGroup().exists()).toBe(false);
});
it('renders navigation buttons', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- designCollection: { designs: [{ id: '1' }, { id: '2' }] },
- });
+ createComponent({ handler: buildMockHandler() });
+ await waitForPromises();
- await nextTick();
- expect(wrapper.element).toMatchSnapshot();
+ expect(findGlButtonGroup().exists()).toBe(true);
});
describe('keyboard buttons navigation', () => {
- beforeEach(() => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- designCollection: { designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }] },
- });
- });
+ it('routes to previous design on Left button', async () => {
+ createComponent({ propsData: { id: designListQueryResponseNodes[1].filename } });
+ await waitForPromises();
- it('routes to previous design on Left button', () => {
Mousetrap.trigger('left');
expect(push).toHaveBeenCalledWith({
name: DESIGN_ROUTE_NAME,
- params: { id: '1' },
+ params: { id: designListQueryResponseNodes[0].filename },
query: {},
});
});
- it('routes to next design on Right button', () => {
+ it('routes to next design on Right button', async () => {
+ createComponent({ propsData: { id: designListQueryResponseNodes[1].filename } });
+ await waitForPromises();
+
Mousetrap.trigger('right');
expect(push).toHaveBeenCalledWith({
name: DESIGN_ROUTE_NAME,
- params: { id: '3' },
+ params: { id: designListQueryResponseNodes[2].filename },
query: {},
});
});
diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js
index 1776405ece9..764ad73805f 100644
--- a/spec/frontend/design_management/components/toolbar/index_spec.js
+++ b/spec/frontend/design_management/components/toolbar/index_spec.js
@@ -1,12 +1,18 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import DeleteButton from '~/design_management/components/delete_button.vue';
import Toolbar from '~/design_management/components/toolbar/index.vue';
import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
+import { getPermissionsQueryResponse } from '../../mock_data/apollo_mock';
Vue.use(VueRouter);
+Vue.use(VueApollo);
const router = new VueRouter();
const RouterLinkStub = {
@@ -27,7 +33,12 @@ describe('Design management toolbar component', () => {
const updatedAt = new Date();
updatedAt.setHours(updatedAt.getHours() - 1);
+ const mockApollo = createMockApollo([
+ [permissionsQuery, jest.fn().mockResolvedValue(getPermissionsQueryResponse(createDesign))],
+ ]);
+
wrapper = shallowMount(Toolbar, {
+ apolloProvider: mockApollo,
router,
propsData: {
id: '1',
@@ -46,31 +57,20 @@ describe('Design management toolbar component', () => {
'router-link': RouterLinkStub,
},
});
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- permissions: {
- createDesign,
- },
- });
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders design and updated data', async () => {
createComponent();
- await nextTick();
+ await waitForPromises();
+
expect(wrapper.element).toMatchSnapshot();
});
it('links back to designs list', async () => {
createComponent();
- await nextTick();
+ await waitForPromises();
const link = wrapper.find('a');
expect(link.props('to')).toEqual({
@@ -84,35 +84,41 @@ describe('Design management toolbar component', () => {
it('renders delete button on latest designs version with logged in user', async () => {
createComponent();
- await nextTick();
+ await waitForPromises();
+
expect(wrapper.findComponent(DeleteButton).exists()).toBe(true);
});
it('does not render delete button on non-latest version', async () => {
createComponent(false, true, { isLatestVersion: false });
- await nextTick();
+ await waitForPromises();
+
expect(wrapper.findComponent(DeleteButton).exists()).toBe(false);
});
it('does not render delete button when user is not logged in', async () => {
createComponent(false, false);
- await nextTick();
+ await waitForPromises();
+
expect(wrapper.findComponent(DeleteButton).exists()).toBe(false);
});
it('emits `delete` event on deleteButton `delete-selected-designs` event', async () => {
createComponent();
- await nextTick();
+ await waitForPromises();
+
wrapper.findComponent(DeleteButton).vm.$emit('delete-selected-designs');
expect(wrapper.emitted().delete).toHaveLength(1);
});
- it('renders download button with correct link', () => {
+ it('renders download button with correct link', async () => {
createComponent();
+ await waitForPromises();
+
expect(wrapper.findComponent(GlButton).attributes('href')).toBe(
'/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d',
);
diff --git a/spec/frontend/design_management/components/upload/button_spec.js b/spec/frontend/design_management/components/upload/button_spec.js
index 59821218ab8..ceae7920e0d 100644
--- a/spec/frontend/design_management/components/upload/button_spec.js
+++ b/spec/frontend/design_management/components/upload/button_spec.js
@@ -14,10 +14,6 @@ describe('Design management upload button component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders upload design button', () => {
createComponent();
diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
index 6ad10e707ab..3ee68f80538 100644
--- a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
+++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
@@ -1,9 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { GlAvatar, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import mockAllVersions from './mock_data/all_versions';
+import mockAllVersions from '../../mock_data/all_versions';
+import { getDesignListQueryResponse } from '../../mock_data/apollo_mock';
const LATEST_VERSION_ID = 1;
const PREVIOUS_VERSION_ID = 2;
@@ -20,11 +25,20 @@ const MOCK_ROUTE = {
query: {},
};
+Vue.use(VueApollo);
+
describe('Design management design version dropdown component', () => {
let wrapper;
function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) {
+ const designVersions =
+ maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions;
+ const designListHandler = jest
+ .fn()
+ .mockResolvedValue(getDesignListQueryResponse({ versions: designVersions }));
+
wrapper = shallowMount(DesignVersionDropdown, {
+ apolloProvider: createMockApollo([[getDesignListQuery, designListHandler]]),
propsData: {
projectPath: '',
issueIid: '',
@@ -34,18 +48,8 @@ describe('Design management design version dropdown component', () => {
},
stubs: { GlAvatar: true, GlCollapsibleListbox },
});
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions,
- });
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem);
const findVersionLink = (index) => wrapper.findAllComponents(GlListboxItem).at(index);
@@ -56,7 +60,7 @@ describe('Design management design version dropdown component', () => {
beforeEach(async () => {
createComponent();
- await nextTick();
+ await waitForPromises();
listItem = findAllListboxItems().at(0);
});
@@ -78,7 +82,8 @@ describe('Design management design version dropdown component', () => {
it('has "latest" on most recent version item', async () => {
createComponent();
- await nextTick();
+ await waitForPromises();
+
expect(findVersionLink(0).text()).toContain('latest');
});
});
@@ -87,7 +92,7 @@ describe('Design management design version dropdown component', () => {
it('displays latest version text by default', async () => {
createComponent();
- await nextTick();
+ await waitForPromises();
expect(findListbox().props('toggleText')).toBe('Showing latest version');
});
@@ -95,35 +100,39 @@ describe('Design management design version dropdown component', () => {
it('displays latest version text when only 1 version is present', async () => {
createComponent({ maxVersions: 1 });
- await nextTick();
+ await waitForPromises();
+
expect(findListbox().props('toggleText')).toBe('Showing latest version');
});
it('displays version text when the current version is not the latest', async () => {
createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) });
- await nextTick();
+ await waitForPromises();
+
expect(findListbox().props('toggleText')).toBe(`Showing version #1`);
});
it('displays latest version text when the current version is the latest', async () => {
createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) });
- await nextTick();
+ await waitForPromises();
+
expect(findListbox().props('toggleText')).toBe('Showing latest version');
});
it('should have the same length as apollo query', async () => {
createComponent();
- await nextTick();
+ await waitForPromises();
+
expect(findAllListboxItems()).toHaveLength(wrapper.vm.allVersions.length);
});
it('should render TimeAgo', async () => {
createComponent();
- await nextTick();
+ await waitForPromises();
expect(wrapper.findAllComponents(TimeAgo)).toHaveLength(wrapper.vm.allVersions.length);
});
diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js
deleted file mode 100644
index 24c59ce1a75..00000000000
--- a/spec/frontend/design_management/components/upload/mock_data/all_versions.js
+++ /dev/null
@@ -1,20 +0,0 @@
-export default [
- {
- id: 'gid://gitlab/DesignManagement::Version/1',
- sha: 'b389071a06c153509e11da1f582005b316667001',
- createdAt: '2021-08-09T06:05:00Z',
- author: {
- id: 'gid://gitlab/User/1',
- name: 'Adminstrator',
- },
- },
- {
- id: 'gid://gitlab/DesignManagement::Version/2',
- sha: 'b389071a06c153509e11da1f582005b316667021',
- createdAt: '2021-08-09T06:05:00Z',
- author: {
- id: 'gid://gitlab/User/1',
- name: 'Adminstrator',
- },
- },
-];
diff --git a/spec/frontend/design_management/mock_data/all_versions.js b/spec/frontend/design_management/mock_data/all_versions.js
index f4026da7dfd..36f611247a9 100644
--- a/spec/frontend/design_management/mock_data/all_versions.js
+++ b/spec/frontend/design_management/mock_data/all_versions.js
@@ -1,20 +1,26 @@
export default [
{
+ __typename: 'DesignVersion',
id: 'gid://gitlab/DesignManagement::Version/1',
sha: 'b389071a06c153509e11da1f582005b316667001',
createdAt: '2021-08-09T06:05:00Z',
author: {
+ __typename: 'UserCore',
id: 'gid://gitlab/User/1',
name: 'Adminstrator',
+ avatarUrl: 'avatar.png',
},
},
{
- id: 'gid://gitlab/DesignManagement::Version/1',
+ __typename: 'DesignVersion',
+ id: 'gid://gitlab/DesignManagement::Version/2',
sha: 'b389071a06c153509e11da1f582005b316667021',
createdAt: '2021-08-09T06:05:00Z',
author: {
+ __typename: 'UserCore',
id: 'gid://gitlab/User/1',
name: 'Adminstrator',
+ avatarUrl: 'avatar.png',
},
},
];
diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js
index 2a43b5debee..18e08ecd729 100644
--- a/spec/frontend/design_management/mock_data/apollo_mock.js
+++ b/spec/frontend/design_management/mock_data/apollo_mock.js
@@ -1,4 +1,49 @@
-export const designListQueryResponse = {
+export const designListQueryResponseNodes = [
+ {
+ __typename: 'Design',
+ id: '1',
+ event: 'NONE',
+ filename: 'fox_1.jpg',
+ notesCount: 3,
+ image: 'image-1',
+ imageV432x230: 'image-1',
+ currentUserTodos: {
+ __typename: 'ToDo',
+ nodes: [],
+ },
+ },
+ {
+ __typename: 'Design',
+ id: '2',
+ event: 'NONE',
+ filename: 'fox_2.jpg',
+ notesCount: 2,
+ image: 'image-2',
+ imageV432x230: 'image-2',
+ currentUserTodos: {
+ __typename: 'ToDo',
+ nodes: [],
+ },
+ },
+ {
+ __typename: 'Design',
+ id: '3',
+ event: 'NONE',
+ filename: 'fox_3.jpg',
+ notesCount: 1,
+ image: 'image-3',
+ imageV432x230: 'image-3',
+ currentUserTodos: {
+ __typename: 'ToDo',
+ nodes: [],
+ },
+ },
+];
+
+export const getDesignListQueryResponse = ({
+ versions = [],
+ designs = designListQueryResponseNodes,
+} = {}) => ({
data: {
project: {
__typename: 'Project',
@@ -11,57 +56,17 @@ export const designListQueryResponse = {
copyState: 'READY',
designs: {
__typename: 'DesignConnection',
- nodes: [
- {
- __typename: 'Design',
- id: '1',
- event: 'NONE',
- filename: 'fox_1.jpg',
- notesCount: 3,
- image: 'image-1',
- imageV432x230: 'image-1',
- currentUserTodos: {
- __typename: 'ToDo',
- nodes: [],
- },
- },
- {
- __typename: 'Design',
- id: '2',
- event: 'NONE',
- filename: 'fox_2.jpg',
- notesCount: 2,
- image: 'image-2',
- imageV432x230: 'image-2',
- currentUserTodos: {
- __typename: 'ToDo',
- nodes: [],
- },
- },
- {
- __typename: 'Design',
- id: '3',
- event: 'NONE',
- filename: 'fox_3.jpg',
- notesCount: 1,
- image: 'image-3',
- imageV432x230: 'image-3',
- currentUserTodos: {
- __typename: 'ToDo',
- nodes: [],
- },
- },
- ],
+ nodes: designs,
},
versions: {
- __typename: 'DesignVersion',
- nodes: [],
+ __typename: 'DesignVersionConnection',
+ nodes: versions,
},
},
},
},
},
-};
+});
export const designUploadMutationCreatedResponse = {
data: {
@@ -91,7 +96,7 @@ export const designUploadMutationUpdatedResponse = {
},
};
-export const permissionsQueryResponse = {
+export const getPermissionsQueryResponse = (createDesign = true) => ({
data: {
project: {
__typename: 'Project',
@@ -99,11 +104,11 @@ export const permissionsQueryResponse = {
issue: {
__typename: 'Issue',
id: 'issue-1',
- userPermissions: { __typename: 'UserPermissions', createDesign: true },
+ userPermissions: { __typename: 'UserPermissions', createDesign },
},
},
},
-};
+});
export const reorderedDesigns = [
{
@@ -211,3 +216,107 @@ export const getDesignQueryResponse = {
},
},
};
+
+export const mockNoteSubmitSuccessMutationResponse = {
+ data: {
+ createNote: {
+ note: {
+ id: 'gid://gitlab/DiffNote/468',
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ body: 'New comment',
+ bodyHtml: "<p data-sourcepos='1:1-1:4' dir='auto'>asdd</p>",
+ createdAt: '2023-02-24T06:49:20Z',
+ resolved: false,
+ position: {
+ diffRefs: {
+ baseSha: 'f63ae53ed82d8765477c191383e1e6a000c10375',
+ startSha: 'f63ae53ed82d8765477c191383e1e6a000c10375',
+ headSha: 'f348c652f1a737151fc79047895e695fbe81464c',
+ __typename: 'DiffRefs',
+ },
+ x: 441,
+ y: 128,
+ height: 152,
+ width: 695,
+ __typename: 'DiffPosition',
+ },
+ userPermissions: {
+ adminNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ discussion: {
+ id: 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/DiffNote/459',
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
+ },
+ __typename: 'Discussion',
+ },
+ __typename: 'Note',
+ },
+ errors: [],
+ __typename: 'CreateNotePayload',
+ },
+ },
+};
+
+export const mockNoteSubmitFailureMutationResponse = [
+ {
+ errors: [
+ {
+ message:
+ 'Variable $input of type CreateNoteInput! was provided invalid value for bodyaa (Field is not defined on CreateNoteInput), body (Expected value to not be null)',
+ locations: [
+ {
+ line: 1,
+ column: 21,
+ },
+ ],
+ extensions: {
+ value: {
+ noteableId: 'gid://gitlab/DesignManagement::Design/10',
+ discussionId: 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8',
+ bodyaa: 'df',
+ },
+ problems: [
+ {
+ path: ['bodyaa'],
+ explanation: 'Field is not defined on CreateNoteInput',
+ },
+ {
+ path: ['body'],
+ explanation: 'Expected value to not be null',
+ },
+ ],
+ },
+ },
+ ],
+ },
+];
+
+export const mockCreateImageNoteDiffResponse = {
+ data: {
+ createImageDiffNote: {
+ note: {
+ author: {
+ username: '',
+ },
+ discussion: {},
+ },
+ },
+ },
+};
diff --git a/spec/frontend/design_management/mock_data/project.js b/spec/frontend/design_management/mock_data/project.js
new file mode 100644
index 00000000000..e1c2057d8d1
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/project.js
@@ -0,0 +1,17 @@
+import design from './design';
+
+export default {
+ project: {
+ issue: {
+ designCollection: {
+ designs: {
+ nodes: [
+ {
+ ...design,
+ },
+ ],
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
index ef1ed9bee51..7da0652faba 100644
--- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
@@ -9,7 +9,9 @@ exports[`Design management index page designs renders error 1`] = `
<!---->
- <div>
+ <div
+ class="gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5"
+ >
<gl-alert-stub
dismisslabel="Dismiss"
primarybuttonlink=""
@@ -41,7 +43,9 @@ exports[`Design management index page designs renders loading icon 1`] = `
<!---->
- <div>
+ <div
+ class="gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5"
+ >
<gl-loading-icon-stub
color="dark"
label="Loading"
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 d86fbf81d20..18b63082e4a 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
@@ -2,7 +2,7 @@
exports[`Design management design index page renders design index 1`] = `
<div
- class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
+ class="design-detail js-design-detail fixed-top gl-w-full gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
>
<div
class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative"
@@ -115,7 +115,7 @@ exports[`Design management design index page renders design index 1`] = `
exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = `
<div
- class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
+ class="design-detail js-design-detail fixed-top gl-w-full gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
>
<div
class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative"
diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js
index a11463ab663..fcb03ea3700 100644
--- a/spec/frontend/design_management/pages/design/index_spec.js
+++ b/spec/frontend/design_management/pages/design/index_spec.js
@@ -1,15 +1,14 @@
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
-import { ApolloMutation } from 'vue-apollo';
import VueRouter from 'vue-router';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import Api from '~/api';
import DesignPresentation from '~/design_management/components/design_presentation.vue';
import DesignSidebar from '~/design_management/components/design_sidebar.vue';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants';
-import createImageDiffNoteMutation from '~/design_management/graphql/mutations/create_image_diff_note.mutation.graphql';
import updateActiveDiscussion from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql';
+import getDesignQuery from '~/design_management/graphql/queries/get_design.query.graphql';
import DesignIndex from '~/design_management/pages/design/index.vue';
import createRouter from '~/design_management/router';
import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from '~/design_management/router/constants';
@@ -23,16 +22,23 @@ import {
DESIGN_SNOWPLOW_EVENT_TYPES,
DESIGN_SERVICE_PING_EVENT_TYPES,
} from '~/design_management/utils/tracking';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import * as cacheUpdate from '~/design_management/utils/cache_update';
import mockAllVersions from '../../mock_data/all_versions';
import design from '../../mock_data/design';
+import mockProject from '../../mock_data/project';
import mockResponseWithDesigns from '../../mock_data/designs';
import mockResponseNoDesigns from '../../mock_data/no_designs';
+import { mockCreateImageNoteDiffResponse } from '../../mock_data/apollo_mock';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/api.js');
const focusInput = jest.fn();
+const mockCacheObject = {
+ readQuery: jest.fn().mockReturnValue(mockProject),
+ writeQuery: jest.fn(),
+};
const mutate = jest.fn().mockResolvedValue();
const mockPageLayoutElement = {
classList: {
@@ -52,32 +58,13 @@ const mockDesignNoDiscussions = {
nodes: [],
},
};
-const newComment = 'new comment';
+
const annotationCoordinates = {
x: 10,
y: 10,
width: 100,
height: 100,
};
-const createDiscussionMutationVariables = {
- mutation: createImageDiffNoteMutation,
- update: expect.anything(),
- variables: {
- input: {
- body: newComment,
- noteableId: design.id,
- position: {
- headSha: 'headSha',
- baseSha: 'baseSha',
- startSha: 'startSha',
- paths: {
- newPath: 'full-design-path',
- },
- ...annotationCoordinates,
- },
- },
- },
-};
Vue.use(VueRouter);
@@ -85,7 +72,7 @@ describe('Design management design index page', () => {
let wrapper;
let router;
- const findDiscussionForm = () => wrapper.findComponent(DesignReplyForm);
+ const findDesignReplyForm = () => wrapper.findComponent(DesignReplyForm);
const findSidebar = () => wrapper.findComponent(DesignSidebar);
const findDesignPresentation = () => wrapper.findComponent(DesignPresentation);
@@ -95,7 +82,7 @@ describe('Design management design index page', () => {
data = {},
intialRouteOptions = {},
provide = {},
- stubs = { ApolloMutation, DesignSidebar, DesignReplyForm },
+ stubs = { DesignSidebar, DesignReplyForm },
} = {},
) {
const $apollo = {
@@ -105,6 +92,11 @@ describe('Design management design index page', () => {
},
},
mutate,
+ getClient() {
+ return {
+ cache: mockCacheObject,
+ };
+ },
};
router = createRouter();
@@ -133,10 +125,6 @@ describe('Design management design index page', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when navigating to component', () => {
it('applies fullscreen layout class', () => {
jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement);
@@ -165,7 +153,7 @@ describe('Design management design index page', () => {
});
describe('when navigating away from component', () => {
- it('removes fullscreen layout class', async () => {
+ it('removes fullscreen layout class', () => {
jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement);
createComponent({ loading: true });
@@ -216,7 +204,7 @@ describe('Design management design index page', () => {
findDesignPresentation().vm.$emit('openCommentForm', { x: 0, y: 0 });
await nextTick();
- expect(findDiscussionForm().exists()).toBe(true);
+ expect(findDesignReplyForm().exists()).toBe(true);
});
it('keeps new discussion form focused', () => {
@@ -235,24 +223,36 @@ describe('Design management design index page', () => {
expect(focusInput).toHaveBeenCalled();
});
- it('sends a mutation on submitting form and closes form', async () => {
+ it('sends a update and closes the form when mutation is completed', async () => {
createComponent(
{ loading: false },
{
data: {
design,
annotationCoordinates,
- comment: newComment,
},
},
);
- findDiscussionForm().vm.$emit('submit-form');
- expect(mutate).toHaveBeenCalledWith(createDiscussionMutationVariables);
+ const addImageDiffNoteToStore = jest.spyOn(cacheUpdate, 'updateStoreAfterAddImageDiffNote');
+
+ const mockDesignVariables = {
+ fullPath: 'project-path',
+ iid: '1',
+ filenames: ['gid::/gitlab/Design/1'],
+ atVersion: null,
+ };
+
+ findDesignReplyForm().vm.$emit('note-submit-complete', mockCreateImageNoteDiffResponse);
await nextTick();
- await mutate({ variables: createDiscussionMutationVariables });
- expect(findDiscussionForm().exists()).toBe(false);
+ expect(addImageDiffNoteToStore).toHaveBeenCalledWith(
+ mockCacheObject,
+ mockCreateImageNoteDiffResponse.data.createImageDiffNote,
+ getDesignQuery,
+ mockDesignVariables,
+ );
+ expect(findDesignReplyForm().exists()).toBe(false);
});
it('closes the form and clears the comment on canceling form', async () => {
@@ -262,17 +262,14 @@ describe('Design management design index page', () => {
data: {
design,
annotationCoordinates,
- comment: newComment,
},
},
);
- findDiscussionForm().vm.$emit('cancel-form');
-
- expect(wrapper.vm.comment).toBe('');
+ findDesignReplyForm().vm.$emit('cancel-form');
await nextTick();
- expect(findDiscussionForm().exists()).toBe(false);
+ expect(findDesignReplyForm().exists()).toBe(false);
});
describe('with error', () => {
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index 76ece922ded..1a6403d3b87 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -29,19 +29,19 @@ import {
DESIGN_TRACKING_PAGE_NAME,
DESIGN_SNOWPLOW_EVENT_TYPES,
} from '~/design_management/utils/tracking';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import {
- designListQueryResponse,
+ getDesignListQueryResponse,
designUploadMutationCreatedResponse,
designUploadMutationUpdatedResponse,
- permissionsQueryResponse,
+ getPermissionsQueryResponse,
moveDesignMutationResponse,
reorderedDesigns,
moveDesignMutationResponseWithErrors,
} from '../mock_data/apollo_mock';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
const mockPageEl = {
classList: {
remove: jest.fn(),
@@ -100,6 +100,7 @@ describe('Design management index page', () => {
let wrapper;
let fakeApollo;
let moveDesignHandler;
+ let permissionsQueryHandler;
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
const findSelectAllButton = () => wrapper.findByTestId('select-all-designs-button');
@@ -174,14 +175,16 @@ describe('Design management index page', () => {
}
function createComponentWithApollo({
+ permissionsHandler = jest.fn().mockResolvedValue(getPermissionsQueryResponse()),
moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
}) {
Vue.use(VueApollo);
+ permissionsQueryHandler = permissionsHandler;
moveDesignHandler = moveHandler;
const requestHandlers = [
- [getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
- [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
+ [getDesignListQuery, jest.fn().mockResolvedValue(getDesignListQueryResponse())],
+ [permissionsQuery, permissionsQueryHandler],
[moveDesignMutation, moveDesignHandler],
];
@@ -197,11 +200,6 @@ describe('Design management index page', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('designs', () => {
it('renders loading icon', () => {
createComponent({ loading: true });
@@ -235,13 +233,6 @@ describe('Design management index page', () => {
expect(findDesignUploadButton().exists()).toBe(true);
});
- it('does not render toolbar when there is no permission', () => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false });
-
- expect(findDesignToolbarWrapper().exists()).toBe(false);
- expect(findDesignUploadButton().exists()).toBe(false);
- });
-
it('has correct classes applied to design dropzone', () => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
expect(dropzoneClasses()).toContain('design-list-item');
@@ -728,7 +719,7 @@ describe('Design management index page', () => {
expect(mockMutate).not.toHaveBeenCalled();
});
- it('removes onPaste listener after mouseleave event', async () => {
+ it('removes onPaste listener after mouseleave event', () => {
findDesignsWrapper().trigger('mouseleave');
document.dispatchEvent(event);
@@ -749,6 +740,17 @@ describe('Design management index page', () => {
});
});
+ describe('when there is no permission to create a design', () => {
+ beforeEach(() => {
+ createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false });
+ });
+
+ it("doesn't render the design toolbar and dropzone", () => {
+ expect(findToolbar().exists()).toBe(false);
+ expect(findDropzoneWrapper().exists()).toBe(false);
+ });
+ });
+
describe('with mocked Apollo client', () => {
it('has a design with id 1 as a first one', async () => {
createComponentWithApollo({});
@@ -800,7 +802,7 @@ describe('Design management index page', () => {
expect(draggableAttributes().disabled).toBe(false);
});
- it('displays flash if mutation had a recoverable error', async () => {
+ it('displays alert if mutation had a recoverable error', async () => {
createComponentWithApollo({
moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors),
});
@@ -824,5 +826,17 @@ describe('Design management index page', () => {
'Something went wrong when reordering designs. Please try again',
);
});
+
+ it("doesn't render the design toolbar and dropzone if the user can't edit", async () => {
+ createComponentWithApollo({
+ permissionsHandler: jest.fn().mockResolvedValue(getPermissionsQueryResponse(false)),
+ });
+
+ await waitForPromises();
+
+ expect(permissionsQueryHandler).toHaveBeenCalled();
+ expect(findToolbar().exists()).toBe(false);
+ expect(findDropzoneWrapper().exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js
index b9edde559c8..3503725f741 100644
--- a/spec/frontend/design_management/router_spec.js
+++ b/spec/frontend/design_management/router_spec.js
@@ -11,8 +11,6 @@ import '~/commons/bootstrap';
function factory(routeArg) {
Vue.use(VueRouter);
- window.gon = { sprite_icons: '' };
-
const router = createRouter('/');
if (routeArg !== undefined) {
router.push(routeArg);
@@ -36,10 +34,6 @@ function factory(routeArg) {
}
describe('Design management router', () => {
- afterEach(() => {
- window.location.hash = '';
- });
-
describe.each([['/'], [{ name: DESIGNS_ROUTE_NAME }]])('root route', (routeArg) => {
it('pushes home component', () => {
const wrapper = factory(routeArg);
diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js
index 42777adfd58..e89dfe9f860 100644
--- a/spec/frontend/design_management/utils/cache_update_spec.js
+++ b/spec/frontend/design_management/utils/cache_update_spec.js
@@ -10,10 +10,10 @@ import {
ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
} from '~/design_management/utils/error_messages';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import design from '../mock_data/design';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
describe('Design Management cache update', () => {
const mockErrors = ['code red!'];
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 513e67ea247..42eec0af961 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -1,7 +1,6 @@
import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
-import Mousetrap from 'mousetrap';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import setWindowLocation from 'helpers/set_window_location_helper';
@@ -11,6 +10,7 @@ import CommitWidget from '~/diffs/components/commit_widget.vue';
import CompareVersions from '~/diffs/components/compare_versions.vue';
import DiffFile from '~/diffs/components/diff_file.vue';
import NoChanges from '~/diffs/components/no_changes.vue';
+import findingsDrawer from '~/diffs/components/shared/findings_drawer.vue';
import TreeList from '~/diffs/components/tree_list.vue';
import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue';
@@ -18,6 +18,7 @@ import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { Mousetrap } from '~/lib/mousetrap';
import * as urlUtils from '~/lib/utils/url_utility';
import { stubPerformanceWebAPI } from 'helpers/performance';
import createDiffsStore from '../create_diffs_store';
@@ -30,6 +31,8 @@ const UPDATED_COMMIT_URL = `${TEST_HOST}/COMMIT/NEW`;
Vue.use(Vuex);
+Vue.config.ignoredElements = ['copy-code'];
+
function getCollapsedFilesWarning(wrapper) {
return wrapper.findComponent(CollapsedFilesWarning);
}
@@ -59,6 +62,7 @@ describe('diffs/components/app', () => {
endpoint: TEST_ENDPOINT,
endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`,
endpointBatch: `${TEST_HOST}/diff/endpointBatch`,
+ endpointDiffForPath: TEST_ENDPOINT,
endpointCoverage: `${TEST_HOST}/diff/endpointCoverage`,
endpointCodequality: '',
projectPath: 'namespace/project',
@@ -71,12 +75,6 @@ describe('diffs/components/app', () => {
},
provide,
store,
- stubs: {
- DynamicScroller: {
- template: `<div><slot :item="$store.state.diffs.diffFiles[0]"></slot></div>`,
- },
- DynamicScrollerItem: true,
- },
});
}
@@ -95,12 +93,6 @@ describe('diffs/components/app', () => {
// reset globals
window.mrTabs = oldMrTabs;
- // reset component
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
-
mock.restore();
});
@@ -179,7 +171,7 @@ describe('diffs/components/app', () => {
});
describe('codequality diff', () => {
- it('does not fetch code quality data on FOSS', async () => {
+ it('does not fetch code quality data on FOSS', () => {
createComponent();
jest.spyOn(wrapper.vm, 'fetchCodequality');
wrapper.vm.fetchData(false);
@@ -265,7 +257,7 @@ describe('diffs/components/app', () => {
it('sets width of tree list', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }];
+ state.diffs.treeEntries = { 111: { type: 'blob', fileHash: '111', path: '111.js' } };
});
expect(wrapper.find('.js-diff-tree-list').element.style.width).toEqual('320px');
@@ -294,13 +286,14 @@ describe('diffs/components/app', () => {
it('does not render empty state when diff files exist', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles.push({
- id: 1,
- });
+ state.diffs.diffFiles = ['anything'];
+ state.diffs.treeEntries['1'] = { type: 'blob', id: 1 };
});
expect(wrapper.findComponent(NoChanges).exists()).toBe(false);
- expect(wrapper.findAllComponents(DiffFile).length).toBe(1);
+ expect(wrapper.findComponent({ name: 'DynamicScroller' }).props('items')).toBe(
+ store.state.diffs.diffFiles,
+ );
});
});
@@ -388,19 +381,15 @@ describe('diffs/components/app', () => {
beforeEach(() => {
createComponent({}, () => {
- store.state.diffs.diffFiles = [
- { file_hash: '111', file_path: '111.js' },
- { file_hash: '222', file_path: '222.js' },
- { file_hash: '333', file_path: '333.js' },
+ store.state.diffs.treeEntries = [
+ { type: 'blob', fileHash: '111', path: '111.js' },
+ { type: 'blob', fileHash: '222', path: '222.js' },
+ { type: 'blob', fileHash: '333', path: '333.js' },
];
});
spy = jest.spyOn(store, 'dispatch');
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('jumps to next and previous files in the list', async () => {
await nextTick();
@@ -507,7 +496,6 @@ describe('diffs/components/app', () => {
describe('diffs', () => {
it('should render compare versions component', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }];
state.diffs.mergeRequestDiffs = diffsMockData;
state.diffs.targetBranchName = 'target-branch';
state.diffs.mergeRequestDiff = mergeRequestDiff;
@@ -578,10 +566,18 @@ describe('diffs/components/app', () => {
it('should display diff file if there are diff files', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles.push({ sha: '123' });
+ state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }];
+ state.diffs.treeEntries = {
+ 111: { type: 'blob', fileHash: '111', path: '111.js' },
+ 123: { type: 'blob', fileHash: '123', path: '123.js' },
+ 312: { type: 'blob', fileHash: '312', path: '312.js' },
+ };
});
- expect(wrapper.findComponent(DiffFile).exists()).toBe(true);
+ expect(wrapper.findComponent({ name: 'DynamicScroller' }).exists()).toBe(true);
+ expect(wrapper.findComponent({ name: 'DynamicScroller' }).props('items')).toBe(
+ store.state.diffs.diffFiles,
+ );
});
it("doesn't render tree list when no changes exist", () => {
@@ -592,7 +588,7 @@ describe('diffs/components/app', () => {
it('should render tree list', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }];
+ state.diffs.treeEntries = { 111: { type: 'blob', fileHash: '111', path: '111.js' } };
});
expect(wrapper.findComponent(TreeList).exists()).toBe(true);
@@ -606,7 +602,7 @@ describe('diffs/components/app', () => {
it('calls setShowTreeList when only 1 file', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles.push({ sha: '123' });
+ state.diffs.treeEntries = { 123: { type: 'blob', fileHash: '123' } };
});
jest.spyOn(store, 'dispatch');
wrapper.vm.setTreeDisplay();
@@ -617,10 +613,12 @@ describe('diffs/components/app', () => {
});
});
- it('calls setShowTreeList with true when more than 1 file is in diffs array', () => {
+ it('calls setShowTreeList with true when more than 1 file is in tree entries map', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles.push({ sha: '123' });
- state.diffs.diffFiles.push({ sha: '124' });
+ state.diffs.treeEntries = {
+ 111: { type: 'blob', fileHash: '111', path: '111.js' },
+ 123: { type: 'blob', fileHash: '123', path: '123.js' },
+ };
});
jest.spyOn(store, 'dispatch');
@@ -640,7 +638,7 @@ describe('diffs/components/app', () => {
localStorage.setItem('mr_tree_show', showTreeList);
createComponent({}, ({ state }) => {
- state.diffs.diffFiles.push({ sha: '123' });
+ state.diffs.treeEntries['123'] = { sha: '123' };
});
jest.spyOn(store, 'dispatch');
@@ -656,7 +654,10 @@ describe('diffs/components/app', () => {
describe('file-by-file', () => {
it('renders a single diff', async () => {
createComponent({ fileByFileUserPreference: true }, ({ state }) => {
- state.diffs.diffFiles.push({ file_hash: '123' });
+ state.diffs.treeEntries = {
+ 123: { type: 'blob', fileHash: '123' },
+ 312: { type: 'blob', fileHash: '312' },
+ };
state.diffs.diffFiles.push({ file_hash: '312' });
});
@@ -671,7 +672,10 @@ describe('diffs/components/app', () => {
it('sets previous button as disabled', async () => {
createComponent({ fileByFileUserPreference: true }, ({ state }) => {
- state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' });
+ state.diffs.treeEntries = {
+ 123: { type: 'blob', fileHash: '123' },
+ 312: { type: 'blob', fileHash: '312' },
+ };
});
await nextTick();
@@ -682,7 +686,10 @@ describe('diffs/components/app', () => {
it('sets next button as disabled', async () => {
createComponent({ fileByFileUserPreference: true }, ({ state }) => {
- state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' });
+ state.diffs.treeEntries = {
+ 123: { type: 'blob', fileHash: '123' },
+ 312: { type: 'blob', fileHash: '312' },
+ };
state.diffs.currentDiffFileId = '312';
});
@@ -694,7 +701,7 @@ describe('diffs/components/app', () => {
it("doesn't display when there's fewer than 2 files", async () => {
createComponent({ fileByFileUserPreference: true }, ({ state }) => {
- state.diffs.diffFiles.push({ file_hash: '123' });
+ state.diffs.treeEntries = { 123: { type: 'blob', fileHash: '123' } };
state.diffs.currentDiffFileId = '123';
});
@@ -704,16 +711,27 @@ describe('diffs/components/app', () => {
});
it.each`
- currentDiffFileId | targetFile
- ${'123'} | ${2}
- ${'312'} | ${1}
+ currentDiffFileId | targetFile | newFileByFile
+ ${'123'} | ${2} | ${false}
+ ${'312'} | ${1} | ${true}
`(
'calls navigateToDiffFileIndex with $index when $link is clicked',
- async ({ currentDiffFileId, targetFile }) => {
- createComponent({ fileByFileUserPreference: true }, ({ state }) => {
- state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' });
- state.diffs.currentDiffFileId = currentDiffFileId;
- });
+ async ({ currentDiffFileId, targetFile, newFileByFile }) => {
+ createComponent(
+ { fileByFileUserPreference: true },
+ ({ state }) => {
+ state.diffs.treeEntries = {
+ 123: { type: 'blob', fileHash: '123', filePaths: { old: '1234', new: '123' } },
+ 312: { type: 'blob', fileHash: '312', filePaths: { old: '3124', new: '312' } },
+ };
+ state.diffs.currentDiffFileId = currentDiffFileId;
+ },
+ {
+ glFeatures: {
+ singleFileFileByFile: newFileByFile,
+ },
+ },
+ );
await nextTick();
@@ -723,9 +741,28 @@ describe('diffs/components/app', () => {
await nextTick();
- expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith(targetFile - 1);
+ expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith({
+ index: targetFile - 1,
+ singleFile: newFileByFile,
+ });
},
);
});
});
+
+ describe('findings-drawer', () => {
+ it('does not render findings-drawer when codeQualityInlineDrawer flag is off', () => {
+ createComponent();
+ expect(wrapper.findComponent(findingsDrawer).exists()).toBe(false);
+ });
+
+ it('does render findings-drawer when codeQualityInlineDrawer flag is on', () => {
+ createComponent({}, () => {}, {
+ glFeatures: {
+ codeQualityInlineDrawer: true,
+ },
+ });
+ expect(wrapper.findComponent(findingsDrawer).exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
index eca5b536a35..ae40f6c898d 100644
--- a/spec/frontend/diffs/components/collapsed_files_warning_spec.js
+++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
@@ -45,10 +45,6 @@ describe('CollapsedFilesWarning', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when there is more than one file', () => {
it.each`
present | dismissed
diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js
index 08be3fa2745..3c092296130 100644
--- a/spec/frontend/diffs/components/commit_item_spec.js
+++ b/spec/frontend/diffs/components/commit_item_spec.js
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
+import { GlFormCheckbox } from '@gitlab/ui';
import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json';
import { TEST_HOST } from 'helpers/test_constants';
import { trimText } from 'helpers/text_helper';
@@ -28,6 +29,7 @@ describe('diffs/components/commit_item', () => {
const getCommitterElement = () => wrapper.find('.committer');
const getCommitActionsElement = () => wrapper.find('.commit-actions');
const getCommitPipelineStatus = () => wrapper.findComponent(CommitPipelineStatus);
+ const getCommitCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const mountComponent = (propsData) => {
wrapper = mount(Component, {
@@ -41,11 +43,6 @@ describe('diffs/components/commit_item', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('default state', () => {
beforeEach(() => {
mountComponent();
@@ -173,4 +170,24 @@ describe('diffs/components/commit_item', () => {
expect(getCommitPipelineStatus().exists()).toBe(true);
});
});
+
+ describe('when commit is selectable', () => {
+ beforeEach(() => {
+ mountComponent({
+ commit: { ...commit },
+ isSelectable: true,
+ });
+ });
+
+ it('renders checkbox', () => {
+ expect(getCommitCheckbox().exists()).toBe(true);
+ });
+
+ it('emits "handleCheckboxChange" event on change', () => {
+ expect(wrapper.emitted('handleCheckboxChange')).toBeUndefined();
+ getCommitCheckbox().vm.$emit('change');
+
+ expect(wrapper.emitted('handleCheckboxChange')[0]).toEqual([true]);
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/compare_dropdown_layout_spec.js b/spec/frontend/diffs/components/compare_dropdown_layout_spec.js
index 09128b04caa..785ff537777 100644
--- a/spec/frontend/diffs/components/compare_dropdown_layout_spec.js
+++ b/spec/frontend/diffs/components/compare_dropdown_layout_spec.js
@@ -38,11 +38,6 @@ describe('CompareDropdownLayout', () => {
isActive: listItem.classes().includes('is-active'),
}));
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('with versions', () => {
beforeEach(() => {
const versions = [
diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js
index 21f3ee26bf8..47a266c2e36 100644
--- a/spec/frontend/diffs/components/compare_versions_spec.js
+++ b/spec/frontend/diffs/components/compare_versions_spec.js
@@ -21,6 +21,7 @@ beforeEach(() => {
describe('CompareVersions', () => {
let wrapper;
let store;
+ let dispatchMock;
const targetBranchName = 'tmp-wine-dev';
const { commit } = getDiffWithCommit;
@@ -29,6 +30,8 @@ describe('CompareVersions', () => {
store.state.diffs.commit = { ...store.state.diffs.commit, ...commitArgs };
}
+ dispatchMock = jest.spyOn(store, 'dispatch');
+
wrapper = mount(CompareVersionsComponent, {
store,
propsData: {
@@ -58,11 +61,6 @@ describe('CompareVersions', () => {
store.state.diffs.mergeRequestDiffs = diffsMockData;
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('template', () => {
beforeEach(() => {
createWrapper({}, {}, false);
@@ -151,7 +149,7 @@ describe('CompareVersions', () => {
it('renders short commit ID', () => {
expect(wrapper.text()).toContain('Viewing commit');
- expect(wrapper.text()).toContain(wrapper.vm.commit.short_id);
+ expect(wrapper.text()).toContain(commit.short_id);
});
});
@@ -209,10 +207,6 @@ describe('CompareVersions', () => {
setWindowLocation(`?commit_id=${mrCommit.id}`);
});
- beforeEach(() => {
- jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
- });
-
it('uses the correct href', () => {
const link = getPrevCommitNavElement();
@@ -224,7 +218,7 @@ describe('CompareVersions', () => {
link.trigger('click');
await nextTick();
- expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({
+ expect(dispatchMock).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', {
direction: 'previous',
});
});
@@ -243,10 +237,6 @@ describe('CompareVersions', () => {
setWindowLocation(`?commit_id=${mrCommit.id}`);
});
- beforeEach(() => {
- jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
- });
-
it('uses the correct href', () => {
const link = getNextCommitNavElement();
@@ -258,7 +248,9 @@ describe('CompareVersions', () => {
link.trigger('click');
await nextTick();
- expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ direction: 'next' });
+ expect(dispatchMock).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', {
+ direction: 'next',
+ });
});
it('renders a disabled button when there is no next commit', () => {
diff --git a/spec/frontend/diffs/components/diff_code_quality_item_spec.js b/spec/frontend/diffs/components/diff_code_quality_item_spec.js
new file mode 100644
index 00000000000..be9fb61a77d
--- /dev/null
+++ b/spec/frontend/diffs/components/diff_code_quality_item_spec.js
@@ -0,0 +1,66 @@
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import DiffCodeQualityItem from '~/diffs/components/diff_code_quality_item.vue';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import { multipleFindingsArr } from '../mock_data/diff_code_quality';
+
+let wrapper;
+
+const findIcon = () => wrapper.findComponent(GlIcon);
+const findButton = () => wrapper.findComponent(GlLink);
+const findDescriptionPlainText = () => wrapper.findByTestId('description-plain-text');
+const findDescriptionLinkSection = () => wrapper.findByTestId('description-button-section');
+
+describe('DiffCodeQuality', () => {
+ const createWrapper = ({ glFeatures = {} } = {}) => {
+ return shallowMountExtended(DiffCodeQualityItem, {
+ propsData: {
+ finding: multipleFindingsArr[0],
+ },
+ provide: {
+ glFeatures,
+ },
+ });
+ };
+
+ it('shows icon for given degradation', () => {
+ wrapper = createWrapper();
+ expect(findIcon().exists()).toBe(true);
+
+ expect(findIcon().attributes()).toMatchObject({
+ class: `codequality-severity-icon ${SEVERITY_CLASSES[multipleFindingsArr[0].severity]}`,
+ name: SEVERITY_ICONS[multipleFindingsArr[0].severity],
+ size: '12',
+ });
+ });
+
+ describe('with codeQualityInlineDrawer flag false', () => {
+ it('should render severity + description in plain text', () => {
+ wrapper = createWrapper({
+ glFeatures: {
+ codeQualityInlineDrawer: false,
+ },
+ });
+ expect(findDescriptionPlainText().text()).toContain(multipleFindingsArr[0].severity);
+ expect(findDescriptionPlainText().text()).toContain(multipleFindingsArr[0].description);
+ });
+ });
+
+ describe('with codeQualityInlineDrawer flag true', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ glFeatures: {
+ codeQualityInlineDrawer: true,
+ },
+ });
+ });
+
+ it('should render severity as plain text', () => {
+ expect(findDescriptionLinkSection().text()).toContain(multipleFindingsArr[0].severity);
+ });
+
+ it('should render button with description text', () => {
+ expect(findButton().text()).toContain(multipleFindingsArr[0].description);
+ });
+ });
+});
diff --git a/spec/frontend/diffs/components/diff_code_quality_spec.js b/spec/frontend/diffs/components/diff_code_quality_spec.js
index 7bd9afab648..9ecfb62e1c5 100644
--- a/spec/frontend/diffs/components/diff_code_quality_spec.js
+++ b/spec/frontend/diffs/components/diff_code_quality_spec.js
@@ -1,20 +1,15 @@
-import { GlIcon } from '@gitlab/ui';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue';
-import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import DiffCodeQualityItem from '~/diffs/components/diff_code_quality_item.vue';
import { NEW_CODE_QUALITY_FINDINGS } from '~/diffs/i18n';
import { multipleFindingsArr } from '../mock_data/diff_code_quality';
let wrapper;
-const findIcon = () => wrapper.findComponent(GlIcon);
+const diffItems = () => wrapper.findAllComponents(DiffCodeQualityItem);
const findHeading = () => wrapper.findByTestId(`diff-codequality-findings-heading`);
describe('DiffCodeQuality', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
const createWrapper = (codeQuality, mountFunction = mountExtended) => {
return mountFunction(DiffCodeQuality, {
propsData: {
@@ -32,37 +27,12 @@ describe('DiffCodeQuality', () => {
expect(wrapper.emitted('hideCodeQualityFindings').length).toBe(1);
});
- it('renders heading and correct amount of list items for codequality array and their description', async () => {
- wrapper = createWrapper(multipleFindingsArr);
- expect(findHeading().text()).toEqual(NEW_CODE_QUALITY_FINDINGS);
-
- const listItems = wrapper.findAll('li');
- expect(wrapper.findAll('li').length).toBe(5);
+ it('renders heading and correct amount of list items for codequality array and their description', () => {
+ wrapper = createWrapper(multipleFindingsArr, shallowMountExtended);
- listItems.wrappers.map((e, i) => {
- return expect(e.text()).toContain(
- `${multipleFindingsArr[i].severity} - ${multipleFindingsArr[i].description}`,
- );
- });
- });
-
- it.each`
- severity
- ${'info'}
- ${'minor'}
- ${'major'}
- ${'critical'}
- ${'blocker'}
- ${'unknown'}
- `('shows icon for $severity degradation', ({ severity }) => {
- wrapper = createWrapper([{ severity }], shallowMountExtended);
-
- expect(findIcon().exists()).toBe(true);
+ expect(findHeading().text()).toEqual(NEW_CODE_QUALITY_FINDINGS);
- expect(findIcon().attributes()).toMatchObject({
- class: `codequality-severity-icon ${SEVERITY_CLASSES[severity]}`,
- name: SEVERITY_ICONS[severity],
- size: '12',
- });
+ expect(diffItems()).toHaveLength(multipleFindingsArr.length);
+ expect(diffItems().at(0).props().finding).toEqual(multipleFindingsArr[0]);
});
});
diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js
index 0bce6451ce4..3524973278c 100644
--- a/spec/frontend/diffs/components/diff_content_spec.js
+++ b/spec/frontend/diffs/components/diff_content_spec.js
@@ -93,11 +93,6 @@ describe('DiffContent', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('with text based files', () => {
afterEach(() => {
[isParallelViewGetterMock, isInlineViewGetterMock].forEach((m) => m.mockRestore());
diff --git a/spec/frontend/diffs/components/diff_discussion_reply_spec.js b/spec/frontend/diffs/components/diff_discussion_reply_spec.js
index bf4a1a1c1f7..348439d6006 100644
--- a/spec/frontend/diffs/components/diff_discussion_reply_spec.js
+++ b/spec/frontend/diffs/components/diff_discussion_reply_spec.js
@@ -26,10 +26,6 @@ describe('DiffDiscussionReply', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('if user can reply', () => {
beforeEach(() => {
getters = {
diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js
index 5092ae6ab6e..73d9f2d6d45 100644
--- a/spec/frontend/diffs/components/diff_discussions_spec.js
+++ b/spec/frontend/diffs/components/diff_discussions_spec.js
@@ -25,10 +25,6 @@ describe('DiffDiscussions', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
it('should have notes list', () => {
createComponent();
diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js
index c23eb2f3d24..900aa8d1469 100644
--- a/spec/frontend/diffs/components/diff_file_header_spec.js
+++ b/spec/frontend/diffs/components/diff_file_header_spec.js
@@ -72,8 +72,6 @@ describe('DiffFileHeader component', () => {
diffHasExpandedDiscussionsResultMock,
...Object.values(mockStoreConfig.modules.diffs.actions),
].forEach((mock) => mock.mockReset());
-
- wrapper.destroy();
});
const findHeader = () => wrapper.findComponent({ ref: 'header' });
@@ -87,7 +85,7 @@ describe('DiffFileHeader component', () => {
const findExternalLink = () => wrapper.findComponent({ ref: 'externalLink' });
const findReplacedFileButton = () => wrapper.findComponent({ ref: 'replacedFileButton' });
const findViewFileButton = () => wrapper.findComponent({ ref: 'viewButton' });
- const findCollapseIcon = () => wrapper.findComponent({ ref: 'collapseIcon' });
+ const findCollapseButton = () => wrapper.findComponent({ ref: 'collapseButton' });
const findEditButton = () => wrapper.findComponent({ ref: 'editButton' });
const findReviewFileCheckbox = () => wrapper.find("[data-testid='fileReviewCheckbox']");
@@ -113,7 +111,7 @@ describe('DiffFileHeader component', () => {
${'hidden'} | ${false}
`('collapse toggle is $visibility if collapsible is $collapsible', ({ collapsible }) => {
createComponent({ props: { collapsible } });
- expect(findCollapseIcon().exists()).toBe(collapsible);
+ expect(findCollapseButton().exists()).toBe(collapsible);
});
it.each`
@@ -122,7 +120,7 @@ describe('DiffFileHeader component', () => {
${false} | ${'chevron-right'}
`('collapse icon is $icon if expanded is $expanded', ({ icon, expanded }) => {
createComponent({ props: { expanded, collapsible: true } });
- expect(findCollapseIcon().props('name')).toBe(icon);
+ expect(findCollapseButton().props('icon')).toBe(icon);
});
it('when header is clicked emits toggleFile', async () => {
@@ -135,7 +133,7 @@ describe('DiffFileHeader component', () => {
it('when collapseIcon is clicked emits toggleFile', async () => {
createComponent({ props: { collapsible: true } });
- findCollapseIcon().vm.$emit('click', new Event('click'));
+ findCollapseButton().vm.$emit('click', new Event('click'));
await nextTick();
expect(wrapper.emitted().toggleFile).toBeDefined();
});
diff --git a/spec/frontend/diffs/components/diff_file_row_spec.js b/spec/frontend/diffs/components/diff_file_row_spec.js
index c5b76551fcc..66ee4e955b8 100644
--- a/spec/frontend/diffs/components/diff_file_row_spec.js
+++ b/spec/frontend/diffs/components/diff_file_row_spec.js
@@ -13,10 +13,6 @@ describe('Diff File Row component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders file row component', () => {
const sharedProps = {
level: 4,
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index ccfc36f8f16..389b192a515 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -129,8 +129,6 @@ describe('DiffFile', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
axiosMock.restore();
});
@@ -168,6 +166,23 @@ describe('DiffFile', () => {
});
},
);
+
+ it('emits the "first file shown" and "files end" events when in File-by-File mode', async () => {
+ ({ wrapper, store } = createComponent({
+ file: getReadableFile(),
+ first: false,
+ last: false,
+ props: {
+ viewDiffsFileByFile: true,
+ },
+ }));
+
+ await nextTick();
+
+ expect(eventHub.$emit).toHaveBeenCalledTimes(2);
+ expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN);
+ expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_DIFF_FILES_END);
+ });
});
describe('after loading the diff', () => {
@@ -222,21 +237,10 @@ describe('DiffFile', () => {
describe('computed', () => {
describe('showLocalFileReviews', () => {
- let gon;
-
function setLoggedIn(bool) {
window.gon.current_user_id = bool;
}
- beforeAll(() => {
- gon = window.gon;
- window.gon = {};
- });
-
- afterEach(() => {
- window.gon = gon;
- });
-
it.each`
loggedIn | bool
${true} | ${true}
@@ -319,7 +323,7 @@ describe('DiffFile', () => {
markFileToBeRendered(store);
});
- it('should have the file content', async () => {
+ it('should have the file content', () => {
expect(wrapper.findComponent(DiffContentComponent).exists()).toBe(true);
});
@@ -329,7 +333,7 @@ describe('DiffFile', () => {
});
describe('toggle', () => {
- it('should update store state', async () => {
+ it('should update store state', () => {
jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(() => {});
toggleFile(wrapper);
@@ -507,8 +511,6 @@ describe('DiffFile', () => {
});
it('loads collapsed file on mounted when single file mode is enabled', async () => {
- wrapper.destroy();
-
const file = {
...getReadableFile(),
load_collapsed_diff_url: '/diff_for_path',
@@ -527,10 +529,6 @@ describe('DiffFile', () => {
});
describe('merge conflicts', () => {
- beforeEach(() => {
- wrapper.destroy();
- });
-
it('does not render conflict alert', () => {
const file = {
...getReadableFile(),
diff --git a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js
index f13988fc11f..5f2b1a81b91 100644
--- a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js
+++ b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js
@@ -21,10 +21,6 @@ describe('DiffGutterAvatars', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when expanded', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js
index bd0e3455872..eb895bd9057 100644
--- a/spec/frontend/diffs/components/diff_line_note_form_spec.js
+++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js
@@ -1,7 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex';
-import Autosave from '~/autosave';
import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue';
import { createModules } from '~/mr_notes/stores';
import NoteForm from '~/notes/components/note_form.vue';
@@ -11,7 +10,6 @@ import { noteableDataMock } from 'jest/notes/mock_data';
import { getDiffFileMock } from '../mock_data/diff_file';
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
-jest.mock('~/autosave');
describe('DiffLineNoteForm', () => {
let wrapper;
@@ -77,7 +75,6 @@ describe('DiffLineNoteForm', () => {
const findCommentForm = () => wrapper.findComponent(MultilineCommentForm);
beforeEach(() => {
- Autosave.mockClear();
createComponent();
});
@@ -100,19 +97,6 @@ describe('DiffLineNoteForm', () => {
});
});
- it('should init autosave', () => {
- // we're using shallow mount here so there's no element to pass to Autosave
- expect(Autosave).toHaveBeenCalledWith(undefined, [
- 'Note',
- 'Issue',
- 98,
- undefined,
- 'DiffNote',
- undefined,
- '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2',
- ]);
- });
-
describe('when cancelling form', () => {
afterEach(() => {
confirmAction.mockReset();
@@ -146,7 +130,6 @@ describe('DiffLineNoteForm', () => {
await nextTick();
expect(getSelectedLine().hasForm).toBe(false);
- expect(Autosave.mock.instances[0].reset).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js
index a7a95ed2f35..356c7ef925a 100644
--- a/spec/frontend/diffs/components/diff_row_spec.js
+++ b/spec/frontend/diffs/components/diff_row_spec.js
@@ -89,10 +89,6 @@ describe('DiffRow', () => {
};
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
-
- window.gon = {};
showCommentForm.mockReset();
enterdragging.mockReset();
stopdragging.mockReset();
diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js
index 9bff6bd14f1..cfc80e61b30 100644
--- a/spec/frontend/diffs/components/diff_view_spec.js
+++ b/spec/frontend/diffs/components/diff_view_spec.js
@@ -14,7 +14,7 @@ describe('DiffView', () => {
const setSelectedCommentPosition = jest.fn();
const getDiffRow = (wrapper) => wrapper.findComponent(DiffRow).vm;
- const createWrapper = (props, provide = {}) => {
+ const createWrapper = (props) => {
Vue.use(Vuex);
const batchComments = {
@@ -48,7 +48,7 @@ describe('DiffView', () => {
...props,
};
const stubs = { DiffExpansionCell, DiffRow, DiffCommentCell, DraftNote };
- return shallowMount(DiffView, { propsData, store, stubs, provide });
+ return shallowMount(DiffView, { propsData, store, stubs });
};
it('does not render a diff-line component when there is no finding', () => {
@@ -56,24 +56,13 @@ describe('DiffView', () => {
expect(wrapper.findComponent(DiffLine).exists()).toBe(false);
});
- it('does render a diff-line component with the correct props when there is a finding & refactorCodeQualityInlineFindings flag is true', async () => {
- const wrapper = createWrapper(diffCodeQuality, {
- glFeatures: { refactorCodeQualityInlineFindings: true },
- });
+ it('does render a diff-line component with the correct props when there is a finding', async () => {
+ const wrapper = createWrapper(diffCodeQuality);
wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2);
await nextTick();
expect(wrapper.findComponent(DiffLine).props('line')).toBe(diffCodeQuality.diffLines[2]);
});
- it('does not render a diff-line component when there is a finding & refactorCodeQualityInlineFindings flag is false', async () => {
- const wrapper = createWrapper(diffCodeQuality, {
- glFeatures: { refactorCodeQualityInlineFindings: false },
- });
- wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2);
- await nextTick();
- expect(wrapper.findComponent(DiffLine).exists()).toBe(false);
- });
-
it.each`
type | side | container | sides | total
${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true }, right: { lineDrafts: [], renderDiscussion: true } }} | ${2}
diff --git a/spec/frontend/diffs/components/hidden_files_warning_spec.js b/spec/frontend/diffs/components/hidden_files_warning_spec.js
index bbd4f5faeec..9b748a3ed6f 100644
--- a/spec/frontend/diffs/components/hidden_files_warning_spec.js
+++ b/spec/frontend/diffs/components/hidden_files_warning_spec.js
@@ -23,10 +23,6 @@ describe('HiddenFilesWarning', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has a correct plain diff URL', () => {
const plainDiffLink = wrapper.findAllComponents(GlButton).at(0);
@@ -41,7 +37,9 @@ describe('HiddenFilesWarning', () => {
it('has a correct visible/total files text', () => {
expect(wrapper.text()).toContain(
- __('To preserve performance only 5 of 10 files are displayed.'),
+ __(
+ 'For a faster browsing experience, only 5 of 10 files are shown. Download one of the files below to see all changes',
+ ),
);
});
});
diff --git a/spec/frontend/diffs/components/image_diff_overlay_spec.js b/spec/frontend/diffs/components/image_diff_overlay_spec.js
index ccf942bdcef..18901781587 100644
--- a/spec/frontend/diffs/components/image_diff_overlay_spec.js
+++ b/spec/frontend/diffs/components/image_diff_overlay_spec.js
@@ -36,10 +36,6 @@ describe('Diffs image diff overlay component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders comment badges', () => {
createComponent();
diff --git a/spec/frontend/diffs/components/merge_conflict_warning_spec.js b/spec/frontend/diffs/components/merge_conflict_warning_spec.js
index 4e47249f5b4..715912b361f 100644
--- a/spec/frontend/diffs/components/merge_conflict_warning_spec.js
+++ b/spec/frontend/diffs/components/merge_conflict_warning_spec.js
@@ -25,10 +25,6 @@ describe('MergeConflictWarning', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
present | resolutionPath
${false} | ${''}
diff --git a/spec/frontend/diffs/components/no_changes_spec.js b/spec/frontend/diffs/components/no_changes_spec.js
index dbfe9770e07..e637b1dd43d 100644
--- a/spec/frontend/diffs/components/no_changes_spec.js
+++ b/spec/frontend/diffs/components/no_changes_spec.js
@@ -34,11 +34,6 @@ describe('Diff no changes empty state', () => {
store.state.diffs.mergeRequestDiffs = diffsMockData;
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findMessage = () => wrapper.find('[data-testid="no-changes-message"]');
it('prevents XSS', () => {
diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js
index 2ec11ba86fd..3d2bbe43746 100644
--- a/spec/frontend/diffs/components/settings_dropdown_spec.js
+++ b/spec/frontend/diffs/components/settings_dropdown_spec.js
@@ -39,7 +39,6 @@ describe('Diff settings dropdown component', () => {
afterEach(() => {
store.dispatch.mockRestore();
- wrapper.destroy();
});
describe('tree view buttons', () => {
diff --git a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap
new file mode 100644
index 00000000000..e82687aa146
--- /dev/null
+++ b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap
@@ -0,0 +1,126 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FindingsDrawer matches the snapshot 1`] = `
+<gl-drawer-stub
+ class="findings-drawer"
+ headerheight=""
+ open="true"
+ variant="default"
+ zindex="252"
+>
+ <h2
+ class="gl-font-size-h2 gl-mt-0 gl-mb-0"
+ data-testid="findings-drawer-heading"
+ >
+
+ Unused method argument - \`c\`. If it's necessary, use \`_\` or \`_c\` as an argument name to indicate that it won't be used.
+
+ </h2>
+ <ul
+ class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!"
+ >
+ <li
+ class="gl-mb-4"
+ data-testid="findings-drawer-severity"
+ >
+ <span
+ class="gl-font-weight-bold"
+ >
+ Severity:
+ </span>
+
+ <gl-icon-stub
+ class="codequality-severity-icon gl-text-orange-300"
+ data-testid="findings-drawer-severity-icon"
+ name="severity-low"
+ size="12"
+ />
+
+
+ minor
+
+ </li>
+
+ <li
+ class="gl-mb-4"
+ data-testid="findings-drawer-engine"
+ >
+ <span
+ class="gl-font-weight-bold"
+ >
+ Engine:
+ </span>
+
+ testengine name
+
+ </li>
+
+ <li
+ class="gl-mb-4"
+ data-testid="findings-drawer-category"
+ >
+ <span
+ class="gl-font-weight-bold"
+ >
+ Category:
+ </span>
+
+ testcategory 1
+
+ </li>
+
+ <li
+ class="gl-mb-4"
+ data-testid="findings-drawer-other-locations"
+ >
+ <span
+ class="gl-font-weight-bold gl-mb-3 gl-display-block"
+ >
+ Other locations:
+ </span>
+
+ <ul
+ class="gl-pl-6"
+ >
+ <li
+ class="gl-mb-1"
+ >
+ <gl-link-stub
+ data-testid="findings-drawer-other-locations-link"
+ href="http://testlink.com"
+ >
+ testpath
+ </gl-link-stub>
+ </li>
+ <li
+ class="gl-mb-1"
+ >
+ <gl-link-stub
+ data-testid="findings-drawer-other-locations-link"
+ href="http://testlink.com"
+ >
+ testpath 1
+ </gl-link-stub>
+ </li>
+ <li
+ class="gl-mb-1"
+ >
+ <gl-link-stub
+ data-testid="findings-drawer-other-locations-link"
+ href="http://testlink.com"
+ >
+ testpath2
+ </gl-link-stub>
+ </li>
+ </ul>
+ </li>
+ </ul>
+
+ <span
+ class="drawer-body gl-display-block gl-px-3 gl-py-0!"
+ data-testid="findings-drawer-body"
+ >
+ Duplicated Code Duplicated code
+ </span>
+</gl-drawer-stub>
+`;
diff --git a/spec/frontend/diffs/components/shared/findings_drawer_spec.js b/spec/frontend/diffs/components/shared/findings_drawer_spec.js
new file mode 100644
index 00000000000..0af6e0f0e96
--- /dev/null
+++ b/spec/frontend/diffs/components/shared/findings_drawer_spec.js
@@ -0,0 +1,19 @@
+import FindingsDrawer from '~/diffs/components/shared/findings_drawer.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import mockFinding from '../../mock_data/findings_drawer';
+
+let wrapper;
+describe('FindingsDrawer', () => {
+ const createWrapper = () => {
+ return shallowMountExtended(FindingsDrawer, {
+ propsData: {
+ drawer: mockFinding,
+ },
+ });
+ };
+
+ it('matches the snapshot', () => {
+ wrapper = createWrapper();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js
index 1656eaf8ba0..87c638d065a 100644
--- a/spec/frontend/diffs/components/tree_list_spec.js
+++ b/spec/frontend/diffs/components/tree_list_spec.js
@@ -1,20 +1,36 @@
-import { shallowMount, mount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import TreeList from '~/diffs/components/tree_list.vue';
import createStore from '~/diffs/store/modules';
-import FileTree from '~/vue_shared/components/file_tree.vue';
+import DiffFileRow from '~/diffs/components//diff_file_row.vue';
+import { stubComponent } from 'helpers/stub_component';
describe('Diffs tree list component', () => {
let wrapper;
let store;
- const getFileRows = () => wrapper.findAll('.file-row');
+ const getScroller = () => wrapper.findComponent({ name: 'RecycleScroller' });
+ const getFileRow = () => wrapper.findComponent(DiffFileRow);
Vue.use(Vuex);
- const createComponent = (mountFn = mount) => {
- wrapper = mountFn(TreeList, {
+ const createComponent = () => {
+ wrapper = shallowMount(TreeList, {
store,
propsData: { hideFileStats: false },
+ stubs: {
+ // eslint will fail if we import the real component
+ RecycleScroller: stubComponent(
+ {
+ name: 'RecycleScroller',
+ props: {
+ items: null,
+ },
+ },
+ {
+ template: '<div><slot :item="{ tree: [] }"></slot></div>',
+ },
+ ),
+ },
});
};
@@ -80,10 +96,6 @@ describe('Diffs tree list component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
@@ -101,26 +113,32 @@ describe('Diffs tree list component', () => {
});
describe('search by file extension', () => {
+ it('hides scroller for no matches', async () => {
+ wrapper.find('[data-testid="diff-tree-search"]').setValue('*.md');
+
+ await nextTick();
+
+ expect(getScroller().exists()).toBe(false);
+ expect(wrapper.text()).toContain('No files found');
+ });
+
it.each`
extension | itemSize
- ${'*.md'} | ${0}
- ${'*.js'} | ${1}
- ${'index.js'} | ${1}
- ${'app/*.js'} | ${1}
- ${'*.js, *.rb'} | ${2}
+ ${'*.js'} | ${2}
+ ${'index.js'} | ${2}
+ ${'app/*.js'} | ${2}
+ ${'*.js, *.rb'} | ${3}
`('returns $itemSize item for $extension', async ({ extension, itemSize }) => {
wrapper.find('[data-testid="diff-tree-search"]').setValue(extension);
await nextTick();
- expect(getFileRows()).toHaveLength(itemSize);
+ expect(getScroller().props('items')).toHaveLength(itemSize);
});
});
it('renders tree', () => {
- expect(getFileRows()).toHaveLength(2);
- expect(getFileRows().at(0).html()).toContain('index.js');
- expect(getFileRows().at(1).html()).toContain('app');
+ expect(getScroller().props('items')).toHaveLength(2);
});
it('hides file stats', async () => {
@@ -133,33 +151,16 @@ describe('Diffs tree list component', () => {
it('calls toggleTreeOpen when clicking folder', () => {
jest.spyOn(wrapper.vm.$store, 'dispatch').mockReturnValue(undefined);
- getFileRows().at(1).trigger('click');
+ getFileRow().vm.$emit('toggleTreeOpen', 'app');
expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/toggleTreeOpen', 'app');
});
- it('calls scrollToFile when clicking blob', () => {
- jest.spyOn(wrapper.vm.$store, 'dispatch').mockReturnValue(undefined);
-
- wrapper.find('.file-row').trigger('click');
-
- expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/scrollToFile', {
- path: 'app/index.js',
- });
- });
-
- it('renders as file list when renderTreeList is false', async () => {
- wrapper.vm.$store.state.diffs.renderTreeList = false;
-
- await nextTick();
- expect(getFileRows()).toHaveLength(2);
- });
-
- it('renders file paths when renderTreeList is false', async () => {
+ it('renders when renderTreeList is false', async () => {
wrapper.vm.$store.state.diffs.renderTreeList = false;
await nextTick();
- expect(wrapper.find('.file-row').html()).toContain('index.js');
+ expect(getScroller().props('items')).toHaveLength(3);
});
});
@@ -172,12 +173,10 @@ describe('Diffs tree list component', () => {
});
it('passes the viewedDiffFileIds to the FileTree', async () => {
- createComponent(shallowMount);
+ createComponent();
await nextTick();
- // Have to use $attrs['viewed-files'] because we are passing down an object
- // and attributes('') stringifies values (e.g. [object])...
- expect(wrapper.findComponent(FileTree).vm.$attrs['viewed-files']).toBe(viewedDiffFileIds);
+ expect(wrapper.findComponent(DiffFileRow).props('viewedFiles')).toBe(viewedDiffFileIds);
});
});
});
diff --git a/spec/frontend/diffs/create_diffs_store.js b/spec/frontend/diffs/create_diffs_store.js
index 307ebdaa4ac..92f38858ca5 100644
--- a/spec/frontend/diffs/create_diffs_store.js
+++ b/spec/frontend/diffs/create_diffs_store.js
@@ -3,6 +3,7 @@ import Vuex from 'vuex';
import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments';
import diffsModule from '~/diffs/store/modules';
import notesModule from '~/notes/stores/modules';
+import findingsDrawer from '~/mr_notes/stores/drawer';
Vue.use(Vuex);
@@ -18,6 +19,7 @@ export default function createDiffsStore() {
diffs: diffsModule(),
notes: notesModule(),
batchComments: batchCommentsModule(),
+ findingsDrawer: findingsDrawer(),
},
});
}
diff --git a/spec/frontend/diffs/mock_data/diff_code_quality.js b/spec/frontend/diffs/mock_data/diff_code_quality.js
index 7558592f6a4..29f16da8d89 100644
--- a/spec/frontend/diffs/mock_data/diff_code_quality.js
+++ b/spec/frontend/diffs/mock_data/diff_code_quality.js
@@ -24,6 +24,11 @@ export const multipleFindingsArr = [
description: 'mocked blocker Issue',
line: 3,
},
+ {
+ severity: 'unknown',
+ description: 'mocked unknown Issue',
+ line: 3,
+ },
];
export const fiveFindings = {
diff --git a/spec/frontend/diffs/mock_data/findings_drawer.js b/spec/frontend/diffs/mock_data/findings_drawer.js
new file mode 100644
index 00000000000..d7e7e957c83
--- /dev/null
+++ b/spec/frontend/diffs/mock_data/findings_drawer.js
@@ -0,0 +1,21 @@
+export default {
+ line: 7,
+ description:
+ "Unused method argument - `c`. If it's necessary, use `_` or `_c` as an argument name to indicate that it won't be used.",
+ severity: 'minor',
+ engineName: 'testengine name',
+ categories: ['testcategory 1', 'testcategory 2'],
+ content: {
+ body: 'Duplicated Code Duplicated code',
+ },
+ location: {
+ path: 'workhorse/config_test.go',
+ lines: { begin: 221, end: 284 },
+ },
+ otherLocations: [
+ { path: 'testpath', href: 'http://testlink.com' },
+ { path: 'testpath 1', href: 'http://testlink.com' },
+ { path: 'testpath2', href: 'http://testlink.com' },
+ ],
+ type: 'issue',
+};
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index 78765204322..f883aea764f 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import Cookies from '~/lib/utils/cookies';
+import waitForPromises from 'helpers/wait_for_promises';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
@@ -8,12 +9,14 @@ import {
DIFF_VIEW_COOKIE_NAME,
INLINE_DIFF_VIEW_TYPE,
PARALLEL_DIFF_VIEW_TYPE,
+ EVT_MR_PREPARED,
} from '~/diffs/constants';
+import { LOAD_SINGLE_DIFF_FAILED } from '~/diffs/i18n';
import * as diffActions from '~/diffs/store/actions';
import * as types from '~/diffs/store/mutation_types';
import * as utils from '~/diffs/store/utils';
import * as treeWorkerUtils from '~/diffs/utils/tree_worker_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import * as commonUtils from '~/lib/utils/common_utils';
import {
@@ -24,9 +27,15 @@ import {
} from '~/lib/utils/http_status';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '~/notes/event_hub';
+import diffsEventHub from '~/diffs/event_hub';
import { diffMetadata } from '../mock_data/diff_metadata';
-jest.mock('~/flash');
+jest.mock('~/alert');
+
+jest.mock('~/lib/utils/secret_detection', () => ({
+ confirmSensitiveAction: jest.fn(() => Promise.resolve(false)),
+ containsSensitiveToken: jest.requireActual('~/lib/utils/secret_detection').containsSensitiveToken,
+}));
describe('DiffsStoreActions', () => {
let mock;
@@ -69,6 +78,7 @@ describe('DiffsStoreActions', () => {
const endpoint = '/diffs/set/endpoint';
const endpointMetadata = '/diffs/set/endpoint/metadata';
const endpointBatch = '/diffs/set/endpoint/batch';
+ const endpointDiffForPath = '/diffs/set/endpoint/path';
const endpointCoverage = '/diffs/set/coverage_reports';
const projectPath = '/root/project';
const dismissEndpoint = '/-/user_callouts';
@@ -83,6 +93,7 @@ describe('DiffsStoreActions', () => {
{
endpoint,
endpointBatch,
+ endpointDiffForPath,
endpointMetadata,
endpointCoverage,
projectPath,
@@ -93,6 +104,7 @@ describe('DiffsStoreActions', () => {
{
endpoint: '',
endpointBatch: '',
+ endpointDiffForPath: '',
endpointMetadata: '',
endpointCoverage: '',
projectPath: '',
@@ -106,6 +118,7 @@ describe('DiffsStoreActions', () => {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
projectPath,
dismissEndpoint,
@@ -131,6 +144,177 @@ describe('DiffsStoreActions', () => {
});
});
+ describe('fetchFileByFile', () => {
+ beforeEach(() => {
+ window.location.hash = 'e334a2a10f036c00151a04cea7938a5d4213a818';
+ });
+
+ it('should do nothing if there is no tree entry for the file ID', () => {
+ return testAction(diffActions.fetchFileByFile, {}, { flatBlobsList: [] }, [], []);
+ });
+
+ it('should do nothing if the tree entry for the file ID has already been marked as loaded', () => {
+ return testAction(
+ diffActions.fetchFileByFile,
+ {},
+ {
+ flatBlobsList: [
+ { fileHash: 'e334a2a10f036c00151a04cea7938a5d4213a818', diffLoaded: true },
+ ],
+ },
+ [],
+ [],
+ );
+ });
+
+ describe('when a tree entry exists for the file, but it has not been marked as loaded', () => {
+ let state;
+ let getters;
+ let commit;
+ let hubSpy;
+ const defaultParams = {
+ old_path: 'old/123',
+ new_path: 'new/123',
+ w: '1',
+ view: 'inline',
+ };
+ const endpointDiffForPath = '/diffs/set/endpoint/path';
+ const diffForPath = mergeUrlParams(defaultParams, endpointDiffForPath);
+ const treeEntry = {
+ fileHash: 'e334a2a10f036c00151a04cea7938a5d4213a818',
+ filePaths: { old: 'old/123', new: 'new/123' },
+ };
+ const fileResult = {
+ diff_files: [{ file_hash: 'e334a2a10f036c00151a04cea7938a5d4213a818' }],
+ };
+
+ beforeEach(() => {
+ commit = jest.fn();
+ state = {
+ endpointDiffForPath,
+ diffFiles: [],
+ };
+ getters = {
+ flatBlobsList: [treeEntry],
+ getDiffFileByHash(hash) {
+ return state.diffFiles?.find((entry) => entry.file_hash === hash);
+ },
+ };
+ hubSpy = jest.spyOn(diffsEventHub, '$emit');
+ });
+
+ it('does nothing if the file already exists in the loaded diff files', () => {
+ state.diffFiles = fileResult.diff_files;
+
+ return testAction(diffActions.fetchFileByFile, state, getters, [], []);
+ });
+
+ it('does some standard work every time', async () => {
+ mock.onGet(diffForPath).reply(HTTP_STATUS_OK, fileResult);
+
+ await diffActions.fetchFileByFile({ state, getters, commit });
+
+ expect(commit).toHaveBeenCalledWith(types.SET_BATCH_LOADING_STATE, 'loading');
+ expect(commit).toHaveBeenCalledWith(types.SET_RETRIEVING_BATCHES, true);
+
+ // wait for the mocked network request to return and start processing the .then
+ await waitForPromises();
+
+ expect(commit).toHaveBeenCalledWith(types.SET_DIFF_DATA_BATCH, fileResult);
+ expect(commit).toHaveBeenCalledWith(types.SET_BATCH_LOADING_STATE, 'loaded');
+
+ expect(hubSpy).toHaveBeenCalledWith('diffFilesModified');
+ });
+
+ it.each`
+ urlHash | diffFiles | expected
+ ${treeEntry.fileHash} | ${[]} | ${''}
+ ${'abcdef1234567890'} | ${fileResult.diff_files} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'}
+ `(
+ "sets the current file to the first diff file ('$id') if it's not a note hash and there isn't a current ID set",
+ async ({ urlHash, diffFiles, expected }) => {
+ window.location.hash = urlHash;
+ mock.onGet(diffForPath).reply(HTTP_STATUS_OK, fileResult);
+ state.diffFiles = diffFiles;
+
+ await diffActions.fetchFileByFile({ state, getters, commit });
+
+ // wait for the mocked network request to return and start processing the .then
+ await waitForPromises();
+
+ expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, expected);
+ },
+ );
+
+ it('should fetch data without commit ID', async () => {
+ getters.commitId = null;
+ mock.onGet(diffForPath).reply(HTTP_STATUS_OK, fileResult);
+
+ await diffActions.fetchFileByFile({ state, getters, commit });
+
+ // wait for the mocked network request to return and start processing the .then
+ await waitForPromises();
+
+ // This tests that commit_id is NOT added, if there isn't one in the store
+ expect(mock.history.get[0].url).toEqual(diffForPath);
+ });
+
+ it('should fetch data with commit ID', async () => {
+ const finalPath = mergeUrlParams(
+ { ...defaultParams, commit_id: '123' },
+ endpointDiffForPath,
+ );
+
+ getters.commitId = '123';
+ mock.onGet(finalPath).reply(HTTP_STATUS_OK, fileResult);
+
+ await diffActions.fetchFileByFile({ state, getters, commit });
+
+ // wait for the mocked network request to return and start processing the .then
+ await waitForPromises();
+
+ expect(mock.history.get[0].url).toEqual(finalPath);
+ });
+
+ describe('version parameters', () => {
+ const diffId = '4';
+ const startSha = 'abc';
+ const pathRoot = 'a/a/-/merge_requests/1';
+
+ it('fetches the data when there is no mergeRequestDiff', async () => {
+ diffActions.fetchFileByFile({ state, getters, commit });
+
+ // wait for the mocked network request to return and start processing the .then
+ await waitForPromises();
+
+ expect(mock.history.get[0].url).toEqual(diffForPath);
+ });
+
+ it.each`
+ desc | versionPath | start_sha | diff_id
+ ${'no additional version information'} | ${`${pathRoot}?search=terms`} | ${undefined} | ${undefined}
+ ${'the diff_id'} | ${`${pathRoot}?diff_id=${diffId}`} | ${undefined} | ${diffId}
+ ${'the start_sha'} | ${`${pathRoot}?start_sha=${startSha}`} | ${startSha} | ${undefined}
+ ${'all available version information'} | ${`${pathRoot}?diff_id=${diffId}&start_sha=${startSha}`} | ${startSha} | ${diffId}
+ `('fetches the data and includes $desc', async ({ versionPath, start_sha, diff_id }) => {
+ const finalPath = mergeUrlParams(
+ { ...defaultParams, diff_id, start_sha },
+ endpointDiffForPath,
+ );
+ state.mergeRequestDiff = { version_path: versionPath };
+ mock.onGet(finalPath).reply(HTTP_STATUS_OK, fileResult);
+
+ diffActions.fetchFileByFile({ state, getters, commit });
+
+ // wait for the mocked network request to return and start processing the .then
+ await waitForPromises();
+
+ expect(mock.history.get[0].url).toEqual(finalPath);
+ });
+ });
+ });
+ });
+
describe('fetchDiffFilesBatch', () => {
it('should fetch batch diff files', () => {
const endpointBatch = '/fetch/diffs_batch';
@@ -213,36 +397,63 @@ describe('DiffsStoreActions', () => {
);
});
- it('should show a warning on 404 reponse', async () => {
- mock.onGet(endpointMetadata).reply(HTTP_STATUS_NOT_FOUND);
+ describe('on a 404 response', () => {
+ let dismissAlert;
- await testAction(
- diffActions.fetchDiffFilesMeta,
- {},
- { endpointMetadata, diffViewType: 'inline', showWhitespace: true },
- [{ type: types.SET_LOADING, payload: true }],
- [],
- );
+ beforeAll(() => {
+ dismissAlert = jest.fn();
- expect(createAlert).toHaveBeenCalledTimes(1);
- expect(createAlert).toHaveBeenCalledWith({
- message: expect.stringMatching(
- 'Building your merge request. Wait a few moments, then refresh this page.',
- ),
- variant: 'warning',
+ mock.onGet(endpointMetadata).reply(HTTP_STATUS_NOT_FOUND);
+ createAlert.mockImplementation(() => ({ dismiss: dismissAlert }));
+ });
+
+ it('should show a warning', async () => {
+ await testAction(
+ diffActions.fetchDiffFilesMeta,
+ {},
+ { endpointMetadata, diffViewType: 'inline', showWhitespace: true },
+ [{ type: types.SET_LOADING, payload: true }],
+ [],
+ );
+
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: expect.stringMatching(
+ 'Building your merge request… This page will update when the build is complete.',
+ ),
+ variant: 'warning',
+ });
+ });
+
+ it("should attempt to close the alert if the MR reports that it's been prepared", async () => {
+ await testAction(
+ diffActions.fetchDiffFilesMeta,
+ {},
+ { endpointMetadata, diffViewType: 'inline', showWhitespace: true },
+ [{ type: types.SET_LOADING, payload: true }],
+ [],
+ );
+
+ diffsEventHub.$emit(EVT_MR_PREPARED);
+
+ expect(dismissAlert).toHaveBeenCalled();
});
});
it('should show no warning on any other status code', async () => {
mock.onGet(endpointMetadata).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
- await testAction(
- diffActions.fetchDiffFilesMeta,
- {},
- { endpointMetadata, diffViewType: 'inline', showWhitespace: true },
- [{ type: types.SET_LOADING, payload: true }],
- [],
- );
+ try {
+ await testAction(
+ diffActions.fetchDiffFilesMeta,
+ {},
+ { endpointMetadata, diffViewType: 'inline', showWhitespace: true },
+ [{ type: types.SET_LOADING, payload: true }],
+ [],
+ );
+ } catch (error) {
+ expect(error.response.status).toBe(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ }
expect(createAlert).not.toHaveBeenCalled();
});
@@ -265,7 +476,7 @@ describe('DiffsStoreActions', () => {
);
});
- it('should show flash on API error', async () => {
+ it('should show alert on API error', async () => {
mock.onGet(endpointCoverage).reply(HTTP_STATUS_BAD_REQUEST);
await testAction(diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [], []);
@@ -389,7 +600,7 @@ describe('DiffsStoreActions', () => {
return testAction(
diffActions.assignDiscussionsToDiff,
[],
- { diffFiles: [] },
+ { diffFiles: [], flatBlobsList: [] },
[],
[{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }],
);
@@ -810,31 +1021,32 @@ describe('DiffsStoreActions', () => {
});
describe('saveDiffDiscussion', () => {
- it('dispatches actions', () => {
- const commitId = 'something';
- const formData = {
- diffFile: getDiffFileMock(),
- noteableData: {},
- };
- const note = {};
- const state = {
- commit: {
- id: commitId,
- },
- };
- const dispatch = jest.fn((name) => {
- switch (name) {
- case 'saveNote':
- return Promise.resolve({
- discussion: 'test',
- });
- case 'updateDiscussion':
- return Promise.resolve('discussion');
- default:
- return Promise.resolve({});
- }
- });
+ const dispatch = jest.fn((name) => {
+ switch (name) {
+ case 'saveNote':
+ return Promise.resolve({
+ discussion: 'test',
+ });
+ case 'updateDiscussion':
+ return Promise.resolve('discussion');
+ default:
+ return Promise.resolve({});
+ }
+ });
+
+ const commitId = 'something';
+ const formData = {
+ diffFile: getDiffFileMock(),
+ noteableData: {},
+ };
+ const note = {};
+ const state = {
+ commit: {
+ id: commitId,
+ },
+ };
+ it('dispatches actions', () => {
return diffActions.saveDiffDiscussion({ state, dispatch }, { note, formData }).then(() => {
expect(dispatch).toHaveBeenCalledTimes(5);
expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), {
@@ -848,6 +1060,16 @@ describe('DiffsStoreActions', () => {
expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']);
});
});
+
+ it('should not add note with sensitive token', async () => {
+ const sensitiveMessage = 'token: glpat-1234567890abcdefghij';
+
+ await diffActions.saveDiffDiscussion(
+ { state, dispatch },
+ { note: sensitiveMessage, formData },
+ );
+ expect(dispatch).not.toHaveBeenCalled();
+ });
});
describe('toggleTreeOpen', () => {
@@ -862,6 +1084,104 @@ describe('DiffsStoreActions', () => {
});
});
+ describe('goToFile', () => {
+ const getters = {};
+ const file = { path: 'path' };
+ const fileHash = 'test';
+ let state;
+ let dispatch;
+ let commit;
+
+ beforeEach(() => {
+ getters.isTreePathLoaded = () => false;
+ state = {
+ viewDiffsFileByFile: true,
+ treeEntries: {
+ path: {
+ fileHash,
+ },
+ },
+ };
+ commit = jest.fn();
+ dispatch = jest.fn().mockResolvedValue();
+ });
+
+ it('immediately defers to scrollToFile if the app is not in file-by-file mode', () => {
+ state.viewDiffsFileByFile = false;
+
+ diffActions.goToFile({ state, dispatch }, file);
+
+ expect(dispatch).toHaveBeenCalledWith('scrollToFile', file);
+ });
+
+ describe('when the app is in fileByFile mode', () => {
+ describe('when the singleFileFileByFile feature flag is enabled', () => {
+ it('commits SET_CURRENT_DIFF_FILE', () => {
+ diffActions.goToFile(
+ { state, commit, dispatch, getters },
+ { path: file.path, singleFile: true },
+ );
+
+ expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash);
+ });
+
+ it('does nothing more if the path has already been loaded', () => {
+ getters.isTreePathLoaded = () => true;
+
+ diffActions.goToFile(
+ { state, dispatch, getters, commit },
+ { path: file.path, singleFile: true },
+ );
+
+ expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash);
+ expect(dispatch).toHaveBeenCalledTimes(0);
+ });
+
+ describe('when the tree entry has not been loaded', () => {
+ it('updates location hash', () => {
+ diffActions.goToFile(
+ { state, commit, getters, dispatch },
+ { path: file.path, singleFile: true },
+ );
+
+ expect(document.location.hash).toBe('#test');
+ });
+
+ it('loads the file and then scrolls to it', async () => {
+ diffActions.goToFile(
+ { state, commit, getters, dispatch },
+ { path: file.path, singleFile: true },
+ );
+
+ // Wait for the fetchFileByFile dispatch to return, to trigger scrollToFile
+ await waitForPromises();
+
+ expect(dispatch).toHaveBeenCalledWith('fetchFileByFile');
+ expect(dispatch).toHaveBeenCalledWith('scrollToFile', file);
+ expect(dispatch).toHaveBeenCalledTimes(2);
+ });
+
+ it('shows an alert when there was an error fetching the file', async () => {
+ dispatch = jest.fn().mockRejectedValue();
+
+ diffActions.goToFile(
+ { state, commit, getters, dispatch },
+ { path: file.path, singleFile: true },
+ );
+
+ // Wait for the fetchFileByFile dispatch to return, to trigger the catch
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: expect.stringMatching(LOAD_SINGLE_DIFF_FAILED),
+ });
+ });
+ });
+ });
+ });
+ });
+
describe('scrollToFile', () => {
let commit;
const getters = { isVirtualScrollingEnabled: false };
@@ -1007,20 +1327,14 @@ describe('DiffsStoreActions', () => {
describe('setShowWhitespace', () => {
const endpointUpdateUser = 'user/prefs';
let putSpy;
- let gon;
beforeEach(() => {
putSpy = jest.spyOn(axios, 'put');
- gon = window.gon;
mock.onPut(endpointUpdateUser).reply(HTTP_STATUS_OK, {});
jest.spyOn(eventHub, '$emit').mockImplementation();
});
- afterEach(() => {
- window.gon = gon;
- });
-
it('commits SET_SHOW_WHITESPACE', () => {
return testAction(
diffActions.setShowWhitespace,
@@ -1390,42 +1704,89 @@ describe('DiffsStoreActions', () => {
);
});
+ describe('rereadNoteHash', () => {
+ beforeEach(() => {
+ window.location.hash = 'note_123';
+ });
+
+ it('dispatches setCurrentDiffFileIdFromNote if the hash is a note URL', () => {
+ window.location.hash = 'note_123';
+
+ return testAction(
+ diffActions.rereadNoteHash,
+ {},
+ {},
+ [],
+ [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }],
+ );
+ });
+
+ it('dispatches fetchFileByFile if the app is in fileByFile mode', () => {
+ window.location.hash = 'note_123';
+
+ return testAction(
+ diffActions.rereadNoteHash,
+ {},
+ { viewDiffsFileByFile: true },
+ [],
+ [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }, { type: 'fetchFileByFile' }],
+ );
+ });
+
+ it('does not try to fetch the diff file if the app is not in fileByFile mode', () => {
+ window.location.hash = 'note_123';
+
+ return testAction(
+ diffActions.rereadNoteHash,
+ {},
+ { viewDiffsFileByFile: false },
+ [],
+ [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }],
+ );
+ });
+
+ it('does nothing if the hash is not a note URL', () => {
+ window.location.hash = 'abcdef1234567890';
+
+ return testAction(diffActions.rereadNoteHash, {}, {}, [], []);
+ });
+ });
+
describe('setCurrentDiffFileIdFromNote', () => {
it('commits SET_CURRENT_DIFF_FILE', () => {
const commit = jest.fn();
- const state = { diffFiles: [{ file_hash: '123' }] };
+ const getters = { flatBlobsList: [{ fileHash: '123' }] };
const rootGetters = {
getDiscussion: () => ({ diff_file: { file_hash: '123' } }),
notesById: { 1: { discussion_id: '2' } },
};
- diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
+ diffActions.setCurrentDiffFileIdFromNote({ commit, getters, rootGetters }, '1');
expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, '123');
});
it('does not commit SET_CURRENT_DIFF_FILE when discussion has no diff_file', () => {
const commit = jest.fn();
- const state = { diffFiles: [{ file_hash: '123' }] };
const rootGetters = {
getDiscussion: () => ({ id: '1' }),
notesById: { 1: { discussion_id: '2' } },
};
- diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
+ diffActions.setCurrentDiffFileIdFromNote({ commit, rootGetters }, '1');
expect(commit).not.toHaveBeenCalled();
});
it('does not commit SET_CURRENT_DIFF_FILE when diff file does not exist', () => {
const commit = jest.fn();
- const state = { diffFiles: [{ file_hash: '123' }] };
+ const getters = { flatBlobsList: [{ fileHash: '123' }] };
const rootGetters = {
getDiscussion: () => ({ diff_file: { file_hash: '124' } }),
notesById: { 1: { discussion_id: '2' } },
};
- diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
+ diffActions.setCurrentDiffFileIdFromNote({ commit, getters, rootGetters }, '1');
expect(commit).not.toHaveBeenCalled();
});
@@ -1435,12 +1796,22 @@ describe('DiffsStoreActions', () => {
it('commits SET_CURRENT_DIFF_FILE', () => {
return testAction(
diffActions.navigateToDiffFileIndex,
- 0,
- { diffFiles: [{ file_hash: '123' }] },
+ { index: 0, singleFile: false },
+ { flatBlobsList: [{ fileHash: '123' }] },
[{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }],
[],
);
});
+
+ it('dispatches the fetchFileByFile action when the state value viewDiffsFileByFile is true and the single-file file-by-file feature flag is enabled', () => {
+ return testAction(
+ diffActions.navigateToDiffFileIndex,
+ { index: 0, singleFile: true },
+ { viewDiffsFileByFile: true, flatBlobsList: [{ fileHash: '123' }] },
+ [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }],
+ [{ type: 'fetchFileByFile' }],
+ );
+ });
});
describe('setFileByFile', () => {
diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js
index 2e3a66d5b01..ed7b6699e2c 100644
--- a/spec/frontend/diffs/store/getters_spec.js
+++ b/spec/frontend/diffs/store/getters_spec.js
@@ -288,6 +288,19 @@ describe('Diffs Module Getters', () => {
});
});
+ describe('isTreePathLoaded', () => {
+ it.each`
+ desc | loaded | path | bool
+ ${'the file exists and has been loaded'} | ${true} | ${'path/tofile'} | ${true}
+ ${'the file exists and has not been loaded'} | ${false} | ${'path/tofile'} | ${false}
+ ${'the file does not exist'} | ${false} | ${'tofile/path'} | ${false}
+ `('returns $bool when $desc', ({ loaded, path, bool }) => {
+ localState.treeEntries['path/tofile'] = { diffLoaded: loaded };
+
+ expect(getters.isTreePathLoaded(localState)(path)).toBe(bool);
+ });
+ });
+
describe('allBlobs', () => {
it('returns an array of blobs', () => {
localState.treeEntries = {
@@ -328,7 +341,11 @@ describe('Diffs Module Getters', () => {
describe('currentDiffIndex', () => {
it('returns index of currently selected diff in diffList', () => {
- localState.diffFiles = [{ file_hash: '111' }, { file_hash: '222' }, { file_hash: '333' }];
+ localState.treeEntries = [
+ { type: 'blob', fileHash: '111' },
+ { type: 'blob', fileHash: '222' },
+ { type: 'blob', fileHash: '333' },
+ ];
localState.currentDiffFileId = '222';
expect(getters.currentDiffIndex(localState)).toEqual(1);
@@ -339,7 +356,11 @@ describe('Diffs Module Getters', () => {
});
it('returns 0 if no diff is selected yet or diff is not found', () => {
- localState.diffFiles = [{ file_hash: '111' }, { file_hash: '222' }, { file_hash: '333' }];
+ localState.treeEntries = [
+ { type: 'blob', fileHash: '111' },
+ { type: 'blob', fileHash: '222' },
+ { type: 'blob', fileHash: '333' },
+ ];
localState.currentDiffFileId = '';
expect(getters.currentDiffIndex(localState)).toEqual(0);
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index 031e4fe2be2..ed8d7397bbc 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -93,15 +93,20 @@ describe('DiffsStoreMutations', () => {
describe('SET_DIFF_DATA_BATCH_DATA', () => {
it('should set diff data batch type properly', () => {
- const state = { diffFiles: [] };
+ const mockFile = getDiffFileMock();
+ const state = {
+ diffFiles: [],
+ treeEntries: { [mockFile.file_path]: { fileHash: mockFile.file_hash } },
+ };
const diffMock = {
- diff_files: [getDiffFileMock()],
+ diff_files: [mockFile],
};
mutations[types.SET_DIFF_DATA_BATCH](state, diffMock);
expect(state.diffFiles[0].renderIt).toEqual(true);
expect(state.diffFiles[0].collapsed).toEqual(false);
+ expect(state.treeEntries[mockFile.file_path].diffLoaded).toBe(true);
});
});
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index b5c44b084d8..4760a8b7166 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -892,4 +892,61 @@ describe('DiffsStoreUtils', () => {
expect(files[6].right).toBeNull();
});
});
+
+ describe('isUrlHashNoteLink', () => {
+ it.each`
+ input | bool
+ ${'#note_12345'} | ${true}
+ ${'#12345'} | ${false}
+ ${'note_12345'} | ${true}
+ ${'12345'} | ${false}
+ `('returns $bool for $input', ({ bool, input }) => {
+ expect(utils.isUrlHashNoteLink(input)).toBe(bool);
+ });
+ });
+
+ describe('isUrlHashFileHeader', () => {
+ it.each`
+ input | bool
+ ${'#diff-content-12345'} | ${true}
+ ${'#12345'} | ${false}
+ ${'diff-content-12345'} | ${true}
+ ${'12345'} | ${false}
+ `('returns $bool for $input', ({ bool, input }) => {
+ expect(utils.isUrlHashFileHeader(input)).toBe(bool);
+ });
+ });
+
+ describe('parseUrlHashAsFileHash', () => {
+ it.each`
+ input | currentDiffId | resultId
+ ${'#note_12345'} | ${'1A2B3C'} | ${'1A2B3C'}
+ ${'note_12345'} | ${'1A2B3C'} | ${'1A2B3C'}
+ ${'#note_12345'} | ${undefined} | ${null}
+ ${'note_12345'} | ${undefined} | ${null}
+ ${'#diff-content-12345'} | ${undefined} | ${'12345'}
+ ${'diff-content-12345'} | ${undefined} | ${'12345'}
+ ${'#diff-content-12345'} | ${'98765'} | ${'12345'}
+ ${'diff-content-12345'} | ${'98765'} | ${'12345'}
+ ${'#e334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'}
+ ${'e334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'}
+ ${'#Z334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${null}
+ ${'Z334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${null}
+ `('returns $resultId for $input and $currentDiffId', ({ input, currentDiffId, resultId }) => {
+ expect(utils.parseUrlHashAsFileHash(input, currentDiffId)).toBe(resultId);
+ });
+ });
+
+ describe('markTreeEntriesLoaded', () => {
+ it.each`
+ desc | entries | loaded | outcome
+ ${'marks an existing entry as loaded'} | ${{ abc: {} }} | ${[{ new_path: 'abc' }]} | ${{ abc: { diffLoaded: true } }}
+ ${'does nothing if the new file is not found in the tree entries'} | ${{ abc: {} }} | ${[{ new_path: 'def' }]} | ${{ abc: {} }}
+ ${'leaves entries unmodified if they are not in the loaded files'} | ${{ abc: {}, def: { diffLoaded: true }, ghi: {} }} | ${[{ new_path: 'ghi' }]} | ${{ abc: {}, def: { diffLoaded: true }, ghi: { diffLoaded: true } }}
+ `('$desc', ({ entries, loaded, outcome }) => {
+ expect(utils.markTreeEntriesLoaded({ priorEntries: entries, loadedFiles: loaded })).toEqual(
+ outcome,
+ );
+ });
+ });
});
diff --git a/spec/frontend/diffs/utils/merge_request_spec.js b/spec/frontend/diffs/utils/merge_request_spec.js
index c070e8c004d..11c0efb9a9c 100644
--- a/spec/frontend/diffs/utils/merge_request_spec.js
+++ b/spec/frontend/diffs/utils/merge_request_spec.js
@@ -1,10 +1,14 @@
-import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request';
+import {
+ updateChangesTabCount,
+ getDerivedMergeRequestInformation,
+} from '~/diffs/utils/merge_request';
+import { ZERO_CHANGES_ALT_DISPLAY } from '~/diffs/constants';
import { diffMetadata } from '../mock_data/diff_metadata';
describe('Merge Request utilities', () => {
const derivedBaseInfo = {
mrPath: '/gitlab-org/gitlab-test/-/merge_requests/4',
- userOrGroup: 'gitlab-org',
+ namespace: 'gitlab-org',
project: 'gitlab-test',
id: '4',
};
@@ -18,36 +22,98 @@ describe('Merge Request utilities', () => {
};
const unparseableEndpoint = {
mrPath: undefined,
- userOrGroup: undefined,
+ namespace: undefined,
project: undefined,
id: undefined,
...noVersion,
};
+ describe('updateChangesTabCount', () => {
+ let dummyTab;
+ let badge;
+
+ beforeEach(() => {
+ dummyTab = document.createElement('div');
+ dummyTab.classList.add('js-diffs-tab');
+ dummyTab.insertAdjacentHTML('afterbegin', '<span class="gl-badge">ERROR</span>');
+ badge = dummyTab.querySelector('.gl-badge');
+ });
+
+ afterEach(() => {
+ dummyTab.remove();
+ dummyTab = null;
+ badge = null;
+ });
+
+ it('uses the alt hyphen display when the new changes are falsey', () => {
+ updateChangesTabCount({ count: 0, badge });
+
+ expect(dummyTab.textContent).toBe(ZERO_CHANGES_ALT_DISPLAY);
+
+ updateChangesTabCount({ badge });
+
+ expect(dummyTab.textContent).toBe(ZERO_CHANGES_ALT_DISPLAY);
+
+ updateChangesTabCount({ count: false, badge });
+
+ expect(dummyTab.textContent).toBe(ZERO_CHANGES_ALT_DISPLAY);
+ });
+
+ it('uses the actual value for display when the value is truthy', () => {
+ updateChangesTabCount({ count: 42, badge });
+
+ expect(dummyTab.textContent).toBe('42');
+
+ updateChangesTabCount({ count: '999+', badge });
+
+ expect(dummyTab.textContent).toBe('999+');
+ });
+
+ it('selects the proper element to modify by default', () => {
+ document.body.insertAdjacentElement('afterbegin', dummyTab);
+
+ updateChangesTabCount({ count: 42 });
+
+ expect(dummyTab.textContent).toBe('42');
+ });
+ });
+
describe('getDerivedMergeRequestInformation', () => {
- let endpoint = `${diffMetadata.latest_version_path}.json?searchParam=irrelevant`;
+ const bare = diffMetadata.latest_version_path;
it.each`
- argument | response
- ${{ endpoint }} | ${{ ...derivedBaseInfo, ...noVersion }}
- ${{}} | ${unparseableEndpoint}
- ${{ endpoint: undefined }} | ${unparseableEndpoint}
- ${{ endpoint: null }} | ${unparseableEndpoint}
+ argument | response
+ ${{ endpoint: `${bare}.json?searchParam=irrelevant` }} | ${{ ...derivedBaseInfo, ...noVersion }}
+ ${{}} | ${unparseableEndpoint}
+ ${{ endpoint: undefined }} | ${unparseableEndpoint}
+ ${{ endpoint: null }} | ${unparseableEndpoint}
`('generates the correct derived results based on $argument', ({ argument, response }) => {
expect(getDerivedMergeRequestInformation(argument)).toStrictEqual(response);
});
- describe('version information', () => {
- const bare = diffMetadata.latest_version_path;
- endpoint = diffMetadata.merge_request_diffs[0].compare_path;
+ describe('sub-group namespace', () => {
+ it('extracts the entire namespace plus the project name', () => {
+ const { namespace, project } = getDerivedMergeRequestInformation({
+ endpoint: `/some/deep/path/of/groups${bare}`,
+ });
+
+ expect(namespace).toBe('some/deep/path/of/groups/gitlab-org');
+ expect(project).toBe('gitlab-test');
+ });
+ });
+ describe('version information', () => {
it('still gets the correct derived information', () => {
- expect(getDerivedMergeRequestInformation({ endpoint })).toMatchObject(derivedBaseInfo);
+ expect(
+ getDerivedMergeRequestInformation({
+ endpoint: diffMetadata.merge_request_diffs[0].compare_path,
+ }),
+ ).toMatchObject(derivedBaseInfo);
});
it.each`
url | versionPart
- ${endpoint} | ${derivedVersionInfo}
+ ${diffMetadata.merge_request_diffs[0].compare_path} | ${derivedVersionInfo}
${`${bare}?diff_id=${derivedVersionInfo.diffId}`} | ${{ ...derivedVersionInfo, startSha: undefined }}
${`${bare}?start_sha=${derivedVersionInfo.startSha}`} | ${{ ...derivedVersionInfo, diffId: undefined }}
`(
diff --git a/spec/frontend/diffs/utils/tree_worker_utils_spec.js b/spec/frontend/diffs/utils/tree_worker_utils_spec.js
index 4df5fe75004..b8bd4fcd081 100644
--- a/spec/frontend/diffs/utils/tree_worker_utils_spec.js
+++ b/spec/frontend/diffs/utils/tree_worker_utils_spec.js
@@ -75,8 +75,13 @@ describe('~/diffs/utils/tree_worker_utils', () => {
{
addedLines: 0,
changed: true,
+ diffLoaded: false,
deleted: false,
fileHash: 'test',
+ filePaths: {
+ new: 'app/index.js',
+ old: undefined,
+ },
key: 'app/index.js',
name: 'index.js',
parentPath: 'app/',
@@ -97,8 +102,13 @@ describe('~/diffs/utils/tree_worker_utils', () => {
{
addedLines: 0,
changed: true,
+ diffLoaded: false,
deleted: false,
fileHash: 'test',
+ filePaths: {
+ new: 'app/test/index.js',
+ old: undefined,
+ },
key: 'app/test/index.js',
name: 'index.js',
parentPath: 'app/test/',
@@ -112,8 +122,13 @@ describe('~/diffs/utils/tree_worker_utils', () => {
{
addedLines: 0,
changed: true,
+ diffLoaded: false,
deleted: false,
fileHash: 'test',
+ filePaths: {
+ new: 'app/test/filepathneedstruncating.js',
+ old: undefined,
+ },
key: 'app/test/filepathneedstruncating.js',
name: 'filepathneedstruncating.js',
parentPath: 'app/test/',
@@ -138,8 +153,13 @@ describe('~/diffs/utils/tree_worker_utils', () => {
{
addedLines: 42,
changed: true,
+ diffLoaded: false,
deleted: false,
fileHash: 'test',
+ filePaths: {
+ new: 'constructor/test/aFile.js',
+ old: undefined,
+ },
key: 'constructor/test/aFile.js',
name: 'aFile.js',
parentPath: 'constructor/test/',
@@ -160,10 +180,15 @@ describe('~/diffs/utils/tree_worker_utils', () => {
name: 'submodule @ abcdef123',
type: 'blob',
changed: true,
+ diffLoaded: false,
tempFile: true,
submodule: true,
deleted: false,
fileHash: 'test',
+ filePaths: {
+ new: 'submodule @ abcdef123',
+ old: undefined,
+ },
addedLines: 1,
removedLines: 0,
tree: [],
@@ -175,10 +200,15 @@ describe('~/diffs/utils/tree_worker_utils', () => {
name: 'package.json',
type: 'blob',
changed: true,
+ diffLoaded: false,
tempFile: false,
submodule: undefined,
deleted: true,
fileHash: 'test',
+ filePaths: {
+ new: 'package.json',
+ old: undefined,
+ },
addedLines: 0,
removedLines: 0,
tree: [],
diff --git a/spec/frontend/drawio/content_editor_facade_spec.js b/spec/frontend/drawio/content_editor_facade_spec.js
new file mode 100644
index 00000000000..673968bac9f
--- /dev/null
+++ b/spec/frontend/drawio/content_editor_facade_spec.js
@@ -0,0 +1,138 @@
+import AxiosMockAdapter from 'axios-mock-adapter';
+import { create } from '~/drawio/content_editor_facade';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import DrawioDiagram from '~/content_editor/extensions/drawio_diagram';
+import axios from '~/lib/utils/axios_utils';
+import { PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML } from '../content_editor/test_constants';
+import { createTestEditor } from '../content_editor/test_utils';
+
+describe('drawio/contentEditorFacade', () => {
+ let tiptapEditor;
+ let axiosMock;
+ let contentEditorFacade;
+ let assetResolver;
+ const imageURL = '/group1/project1/-/wikis/test-file.drawio.svg';
+ const diagramSvg = '<svg></svg>';
+ const contentType = 'image/svg+xml';
+ const filename = 'test-file.drawio.svg';
+ const uploadsPath = '/uploads';
+ const canonicalSrc = '/new-diagram.drawio.svg';
+ const src = `/uploads${canonicalSrc}`;
+
+ beforeEach(() => {
+ assetResolver = {
+ resolveUrl: jest.fn(),
+ };
+ tiptapEditor = createTestEditor({ extensions: [DrawioDiagram] });
+ contentEditorFacade = create({
+ tiptapEditor,
+ drawioNodeName: DrawioDiagram.name,
+ uploadsPath,
+ assetResolver,
+ });
+ });
+ beforeEach(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ tiptapEditor.destroy();
+ });
+
+ describe('getDiagram', () => {
+ describe('when there is a selected diagram', () => {
+ beforeEach(() => {
+ tiptapEditor
+ .chain()
+ .setContent(PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML)
+ .setNodeSelection(1)
+ .run();
+ axiosMock
+ .onGet(imageURL)
+ .reply(HTTP_STATUS_OK, diagramSvg, { 'content-type': contentType });
+ });
+
+ it('returns diagram information', async () => {
+ const diagram = await contentEditorFacade.getDiagram();
+
+ expect(diagram).toEqual({
+ diagramURL: imageURL,
+ filename,
+ diagramSvg,
+ contentType,
+ });
+ });
+ });
+
+ describe('when there is not a selected diagram', () => {
+ beforeEach(() => {
+ tiptapEditor.chain().setContent('<p>text</p>').setNodeSelection(1).run();
+ });
+
+ it('returns null', async () => {
+ const diagram = await contentEditorFacade.getDiagram();
+
+ expect(diagram).toBe(null);
+ });
+ });
+ });
+
+ describe('updateDiagram', () => {
+ beforeEach(() => {
+ tiptapEditor
+ .chain()
+ .setContent(PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML)
+ .setNodeSelection(1)
+ .run();
+
+ assetResolver.resolveUrl.mockReturnValueOnce(src);
+ contentEditorFacade.updateDiagram({ uploadResults: { file_path: canonicalSrc } });
+ });
+
+ it('updates selected diagram diagram node src and canonicalSrc', () => {
+ tiptapEditor.commands.setNodeSelection(1);
+ expect(tiptapEditor.state.selection.node.attrs).toMatchObject({
+ src,
+ canonicalSrc,
+ });
+ });
+ });
+
+ describe('insertDiagram', () => {
+ beforeEach(() => {
+ tiptapEditor.chain().setContent('<p></p>').run();
+
+ assetResolver.resolveUrl.mockReturnValueOnce(src);
+ contentEditorFacade.insertDiagram({ uploadResults: { file_path: canonicalSrc } });
+ });
+
+ it('inserts a new draw.io diagram in the document', () => {
+ tiptapEditor.commands.setNodeSelection(1);
+ expect(tiptapEditor.state.selection.node.attrs).toMatchObject({
+ src,
+ canonicalSrc,
+ });
+ });
+ });
+
+ describe('uploadDiagram', () => {
+ it('sends a post request to the uploadsPath containing the diagram svg', async () => {
+ const link = { markdown: '![](diagram.drawio.svg)' };
+ const blob = new Blob([diagramSvg], { type: 'image/svg+xml' });
+ const formData = new FormData();
+
+ formData.append('file', blob, filename);
+
+ axiosMock.onPost(uploadsPath, formData).reply(HTTP_STATUS_OK, {
+ data: {
+ link,
+ },
+ });
+
+ const response = await contentEditorFacade.uploadDiagram({ diagramSvg, filename });
+
+ expect(response).not.toBe(link);
+ });
+ });
+});
diff --git a/spec/frontend/drawio/drawio_editor_spec.js b/spec/frontend/drawio/drawio_editor_spec.js
new file mode 100644
index 00000000000..d7d75922e1e
--- /dev/null
+++ b/spec/frontend/drawio/drawio_editor_spec.js
@@ -0,0 +1,479 @@
+import { launchDrawioEditor } from '~/drawio/drawio_editor';
+import {
+ DRAWIO_EDITOR_URL,
+ DRAWIO_FRAME_ID,
+ DIAGRAM_BACKGROUND_COLOR,
+ DRAWIO_IFRAME_TIMEOUT,
+ DIAGRAM_MAX_SIZE,
+} from '~/drawio/constants';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+
+jest.mock('~/alert');
+
+jest.useFakeTimers();
+
+describe('drawio/drawio_editor', () => {
+ let editorFacade;
+ let drawioIFrameReceivedMessages;
+ const diagramURL = `${window.location.origin}/uploads/diagram.drawio.svg`;
+ const testSvg = '<svg></svg>';
+ const testEncodedSvg = `data:image/svg+xml;base64,${btoa(testSvg)}`;
+ const filename = 'diagram.drawio.svg';
+
+ const findDrawioIframe = () => document.getElementById(DRAWIO_FRAME_ID);
+ const waitForDrawioIFrameMessage = ({ messageNumber = 1 } = {}) =>
+ new Promise((resolve) => {
+ let messageCounter = 0;
+ const iframe = findDrawioIframe();
+
+ iframe?.contentWindow.addEventListener('message', (event) => {
+ drawioIFrameReceivedMessages.push(event);
+
+ messageCounter += 1;
+
+ if (messageCounter === messageNumber) {
+ resolve();
+ }
+ });
+ });
+ const expectDrawioIframeMessage = ({ expectation, messageNumber = 1 }) => {
+ expect(drawioIFrameReceivedMessages).toHaveLength(messageNumber);
+ expect(JSON.parse(drawioIFrameReceivedMessages[messageNumber - 1].data)).toEqual(expectation);
+ };
+ const postMessageToParentWindow = (data) => {
+ const event = new Event('message');
+
+ Object.setPrototypeOf(event, {
+ source: findDrawioIframe().contentWindow,
+ data: JSON.stringify(data),
+ });
+
+ window.dispatchEvent(event);
+ };
+
+ beforeEach(() => {
+ editorFacade = {
+ getDiagram: jest.fn(),
+ uploadDiagram: jest.fn(),
+ insertDiagram: jest.fn(),
+ updateDiagram: jest.fn(),
+ };
+ drawioIFrameReceivedMessages = [];
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ findDrawioIframe()?.remove();
+ });
+
+ describe('initializing', () => {
+ beforeEach(() => {
+ launchDrawioEditor({ editorFacade });
+ });
+
+ it('creates the drawio editor iframe and attaches it to the body', () => {
+ expect(findDrawioIframe().getAttribute('src')).toBe(DRAWIO_EDITOR_URL);
+ });
+
+ it('sets drawio-editor classname to the iframe', () => {
+ expect(findDrawioIframe().classList).toContain('drawio-editor');
+ });
+ });
+
+ describe(`when parent window does not receive configure event after ${DRAWIO_IFRAME_TIMEOUT} ms`, () => {
+ beforeEach(() => {
+ launchDrawioEditor({ editorFacade });
+ });
+
+ it('disposes draw.io iframe', () => {
+ expect(findDrawioIframe()).not.toBe(null);
+ jest.runAllTimers();
+ expect(findDrawioIframe()).toBe(null);
+ });
+
+ it('displays an alert indicating that the draw.io editor could not be loaded', () => {
+ jest.runAllTimers();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'The diagrams.net editor could not be loaded.',
+ });
+ });
+ });
+
+ describe('when parent window receives configure event', () => {
+ beforeEach(async () => {
+ launchDrawioEditor({ editorFacade });
+ postMessageToParentWindow({ event: 'configure' });
+
+ await waitForDrawioIFrameMessage();
+ });
+
+ it('sends configure action to the draw.io iframe', () => {
+ expectDrawioIframeMessage({
+ expectation: {
+ action: 'configure',
+ config: {
+ darkColor: '#202020',
+ settingsName: 'gitlab',
+ },
+ colorSchemeMeta: false,
+ },
+ });
+ });
+
+ it('does not remove the iframe after the load error timeouts run', () => {
+ jest.runAllTimers();
+
+ expect(findDrawioIframe()).not.toBe(null);
+ });
+ });
+
+ describe('when parent window receives init event', () => {
+ describe('when there isn’t a diagram selected', () => {
+ beforeEach(() => {
+ editorFacade.getDiagram.mockResolvedValueOnce(null);
+
+ launchDrawioEditor({ editorFacade });
+
+ postMessageToParentWindow({ event: 'init' });
+ });
+
+ it('sends load action to the draw.io iframe with empty svg and title', async () => {
+ await waitForDrawioIFrameMessage();
+
+ expectDrawioIframeMessage({
+ expectation: {
+ action: 'load',
+ xml: null,
+ border: 8,
+ background: DIAGRAM_BACKGROUND_COLOR,
+ dark: false,
+ title: null,
+ },
+ });
+ });
+ });
+
+ describe('when there is a diagram selected', () => {
+ const diagramSvg = '<svg></svg>';
+
+ beforeEach(() => {
+ editorFacade.getDiagram.mockResolvedValueOnce({
+ diagramURL,
+ diagramSvg,
+ filename,
+ contentType: 'image/svg+xml',
+ });
+
+ launchDrawioEditor({ editorFacade });
+ postMessageToParentWindow({ event: 'init' });
+ });
+
+ it('sends load action to the draw.io iframe with the selected diagram svg and filename', async () => {
+ await waitForDrawioIFrameMessage();
+
+ // Step 5: The draw.io editor will send the downloaded diagram to the iframe
+ expectDrawioIframeMessage({
+ expectation: {
+ action: 'load',
+ xml: diagramSvg,
+ border: 8,
+ background: DIAGRAM_BACKGROUND_COLOR,
+ dark: false,
+ title: filename,
+ },
+ });
+ });
+
+ it('sets the drawio iframe as visible and resets cursor', async () => {
+ await waitForDrawioIFrameMessage();
+
+ expect(findDrawioIframe().style.visibility).toBe('visible');
+ expect(findDrawioIframe().style.cursor).toBe('');
+ });
+
+ it('scrolls window to the top', async () => {
+ await waitForDrawioIFrameMessage();
+
+ expect(window.scrollX).toBe(0);
+ });
+ });
+
+ describe.each`
+ description | errorMessage | diagram
+ ${'when there is an image selected that is not an svg file'} | ${'The selected image is not a valid SVG diagram'} | ${{
+ diagramURL,
+ contentType: 'image/png',
+ filename: 'image.png',
+}}
+ ${'when the selected image is not an asset upload'} | ${'The selected image is not an asset uploaded in the application'} | ${{
+ diagramSvg: '<svg></svg>',
+ filename,
+ contentType: 'image/svg+xml',
+ diagramURL: 'https://example.com/image.drawio.svg',
+}}
+ ${'when the selected image is too large'} | ${'The selected image is too large.'} | ${{
+ diagramSvg: 'x'.repeat(DIAGRAM_MAX_SIZE + 1),
+ filename,
+ contentType: 'image/svg+xml',
+ diagramURL,
+}}
+ `('$description', ({ errorMessage, diagram }) => {
+ beforeEach(() => {
+ editorFacade.getDiagram.mockResolvedValueOnce(diagram);
+
+ launchDrawioEditor({ editorFacade });
+
+ postMessageToParentWindow({ event: 'init' });
+ });
+
+ it('displays an error alert indicating that the image is not a diagram', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: errorMessage,
+ error: expect.any(Error),
+ });
+ });
+
+ it('disposes the draw.io diagram iframe', () => {
+ expect(findDrawioIframe()).toBe(null);
+ });
+ });
+
+ describe('when loading a diagram fails', () => {
+ beforeEach(() => {
+ editorFacade.getDiagram.mockRejectedValueOnce(new Error());
+
+ launchDrawioEditor({ editorFacade });
+
+ postMessageToParentWindow({ event: 'init' });
+ });
+
+ it('displays an error alert indicating the failure', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Cannot load the diagram into the diagrams.net editor',
+ error: expect.any(Error),
+ });
+ });
+
+ it('disposes the draw.io diagram iframe', () => {
+ expect(findDrawioIframe()).toBe(null);
+ });
+ });
+ });
+
+ describe('when parent window receives prompt event', () => {
+ describe('when the filename is empty', () => {
+ beforeEach(() => {
+ launchDrawioEditor({ editorFacade });
+
+ postMessageToParentWindow({ event: 'prompt', value: '' });
+ });
+
+ it('sends prompt action to the draw.io iframe requesting a filename', async () => {
+ await waitForDrawioIFrameMessage({ messageNumber: 1 });
+
+ expectDrawioIframeMessage({
+ expectation: {
+ action: 'prompt',
+ titleKey: 'filename',
+ okKey: 'save',
+ defaultValue: 'diagram.drawio.svg',
+ },
+ messageNumber: 1,
+ });
+ });
+
+ it('sends dialog action to the draw.io iframe indicating that the filename cannot be empty', async () => {
+ await waitForDrawioIFrameMessage({ messageNumber: 2 });
+
+ expectDrawioIframeMessage({
+ expectation: {
+ action: 'dialog',
+ titleKey: 'error',
+ messageKey: 'filenameShort',
+ buttonKey: 'ok',
+ },
+ messageNumber: 2,
+ });
+ });
+ });
+
+ describe('when the event data is not empty', () => {
+ beforeEach(async () => {
+ launchDrawioEditor({ editorFacade });
+ postMessageToParentWindow({ event: 'prompt', value: 'diagram.drawio.svg' });
+
+ await waitForDrawioIFrameMessage();
+ });
+
+ it('starts the saving file process', () => {
+ expectDrawioIframeMessage({
+ expectation: {
+ action: 'spinner',
+ show: true,
+ messageKey: 'saving',
+ },
+ });
+ });
+ });
+ });
+
+ describe('when parent receives export event', () => {
+ beforeEach(() => {
+ editorFacade.uploadDiagram.mockResolvedValueOnce({});
+ });
+
+ it('reloads diagram in the draw.io editor', async () => {
+ launchDrawioEditor({ editorFacade });
+ postMessageToParentWindow({ event: 'export', data: testEncodedSvg });
+
+ await waitForDrawioIFrameMessage();
+
+ expectDrawioIframeMessage({
+ expectation: expect.objectContaining({
+ action: 'load',
+ xml: expect.stringContaining(testSvg),
+ }),
+ });
+ });
+
+ it('marks the diagram as modified in the draw.io editor', async () => {
+ launchDrawioEditor({ editorFacade });
+ postMessageToParentWindow({ event: 'export', data: testEncodedSvg });
+
+ await waitForDrawioIFrameMessage({ messageNumber: 2 });
+
+ expectDrawioIframeMessage({
+ expectation: expect.objectContaining({
+ action: 'status',
+ modified: true,
+ }),
+ messageNumber: 2,
+ });
+ });
+
+ describe('when the diagram filename is set', () => {
+ const TEST_FILENAME = 'diagram.drawio.svg';
+
+ beforeEach(() => {
+ launchDrawioEditor({ editorFacade, filename: TEST_FILENAME });
+ });
+
+ it('displays loading spinner in the draw.io editor', async () => {
+ postMessageToParentWindow({ event: 'export', data: testEncodedSvg });
+
+ await waitForDrawioIFrameMessage({ messageNumber: 3 });
+
+ expectDrawioIframeMessage({
+ expectation: {
+ action: 'spinner',
+ show: true,
+ messageKey: 'saving',
+ },
+ messageNumber: 3,
+ });
+ });
+
+ it('uploads exported diagram', async () => {
+ postMessageToParentWindow({ event: 'export', data: testEncodedSvg });
+
+ await waitForDrawioIFrameMessage({ messageNumber: 3 });
+
+ expect(editorFacade.uploadDiagram).toHaveBeenCalledWith({
+ filename: TEST_FILENAME,
+ diagramSvg: expect.stringContaining(testSvg),
+ });
+ });
+
+ describe('when uploading the exported diagram succeeds', () => {
+ it('displays an alert indicating that the diagram was uploaded successfully', async () => {
+ postMessageToParentWindow({ event: 'export', data: testEncodedSvg });
+
+ await waitForDrawioIFrameMessage({ messageNumber: 3 });
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: expect.any(String),
+ variant: VARIANT_SUCCESS,
+ fadeTransition: true,
+ });
+ });
+
+ it('disposes iframe', () => {
+ jest.runAllTimers();
+
+ expect(findDrawioIframe()).toBe(null);
+ });
+ });
+
+ describe('when uploading the exported diagram fails', () => {
+ const uploadError = new Error();
+
+ beforeEach(() => {
+ editorFacade.uploadDiagram.mockReset();
+ editorFacade.uploadDiagram.mockRejectedValue(uploadError);
+
+ postMessageToParentWindow({ event: 'export', data: testEncodedSvg });
+ });
+
+ it('hides loading indicator in the draw.io editor', async () => {
+ await waitForDrawioIFrameMessage({ messageNumber: 4 });
+
+ expectDrawioIframeMessage({
+ expectation: {
+ action: 'spinner',
+ show: false,
+ },
+ messageNumber: 4,
+ });
+ });
+
+ it('displays an error dialog in the draw.io editor', async () => {
+ await waitForDrawioIFrameMessage({ messageNumber: 5 });
+
+ expectDrawioIframeMessage({
+ expectation: {
+ action: 'dialog',
+ titleKey: 'error',
+ modified: true,
+ buttonKey: 'close',
+ messageKey: 'errorSavingFile',
+ },
+ messageNumber: 5,
+ });
+ });
+ });
+ });
+
+ describe('when diagram filename is not set', () => {
+ it('sends prompt action to the draw.io iframe', async () => {
+ launchDrawioEditor({ editorFacade });
+ postMessageToParentWindow({ event: 'export', data: testEncodedSvg });
+
+ await waitForDrawioIFrameMessage({ messageNumber: 3 });
+
+ expect(drawioIFrameReceivedMessages[2].data).toEqual(
+ JSON.stringify({
+ action: 'prompt',
+ titleKey: 'filename',
+ okKey: 'save',
+ defaultValue: 'diagram.drawio.svg',
+ }),
+ );
+ });
+ });
+ });
+
+ describe('when parent window receives exit event', () => {
+ beforeEach(() => {
+ launchDrawioEditor({ editorFacade });
+ });
+
+ it('disposes the the draw.io iframe', () => {
+ expect(findDrawioIframe()).not.toBe(null);
+
+ postMessageToParentWindow({ event: 'exit' });
+
+ expect(findDrawioIframe()).toBe(null);
+ });
+ });
+});
diff --git a/spec/frontend/drawio/markdown_field_editor_facade_spec.js b/spec/frontend/drawio/markdown_field_editor_facade_spec.js
new file mode 100644
index 00000000000..e3eafc63839
--- /dev/null
+++ b/spec/frontend/drawio/markdown_field_editor_facade_spec.js
@@ -0,0 +1,148 @@
+import AxiosMockAdapter from 'axios-mock-adapter';
+import { create } from '~/drawio/markdown_field_editor_facade';
+import * as textMarkdown from '~/lib/utils/text_markdown';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import axios from '~/lib/utils/axios_utils';
+
+jest.mock('~/lib/utils/text_markdown');
+
+describe('drawio/textareaMarkdownEditor', () => {
+ let textArea;
+ let textareaMarkdownEditor;
+ let axiosMock;
+
+ const markdownPreviewPath = '/markdown/preview';
+ const imageURL = '/assets/image.png';
+ const diagramMarkdown = '![](image.png)';
+ const diagramSvg = '<svg></svg>';
+ const contentType = 'image/svg+xml';
+ const filename = 'image.png';
+ const newDiagramMarkdown = '![](newdiagram.svg)';
+ const uploadsPath = '/uploads';
+
+ beforeEach(() => {
+ textArea = document.createElement('textarea');
+ textareaMarkdownEditor = create({ textArea, markdownPreviewPath, uploadsPath });
+
+ document.body.appendChild(textArea);
+ });
+ beforeEach(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ textArea.remove();
+ });
+
+ describe('getDiagram', () => {
+ describe('when there is a selected diagram', () => {
+ beforeEach(() => {
+ textMarkdown.resolveSelectedImage.mockReturnValueOnce({
+ imageURL,
+ imageMarkdown: diagramMarkdown,
+ filename,
+ });
+ axiosMock
+ .onGet(imageURL)
+ .reply(HTTP_STATUS_OK, diagramSvg, { 'content-type': contentType });
+ });
+
+ it('returns diagram information', async () => {
+ const diagram = await textareaMarkdownEditor.getDiagram();
+
+ expect(textMarkdown.resolveSelectedImage).toHaveBeenCalledWith(
+ textArea,
+ markdownPreviewPath,
+ );
+
+ expect(diagram).toEqual({
+ diagramURL: imageURL,
+ diagramMarkdown,
+ filename,
+ diagramSvg,
+ contentType,
+ });
+ });
+ });
+
+ describe('when there is not a selected diagram', () => {
+ beforeEach(() => {
+ textMarkdown.resolveSelectedImage.mockReturnValueOnce(null);
+ });
+
+ it('returns null', async () => {
+ const diagram = await textareaMarkdownEditor.getDiagram();
+
+ expect(textMarkdown.resolveSelectedImage).toHaveBeenCalledWith(
+ textArea,
+ markdownPreviewPath,
+ );
+
+ expect(diagram).toBe(null);
+ });
+ });
+ });
+
+ describe('updateDiagram', () => {
+ beforeEach(() => {
+ jest.spyOn(textArea, 'focus');
+ jest.spyOn(textArea, 'dispatchEvent');
+
+ textArea.value = `diagram ${diagramMarkdown}`;
+
+ textareaMarkdownEditor.updateDiagram({
+ diagramMarkdown,
+ uploadResults: { link: { markdown: newDiagramMarkdown } },
+ });
+ });
+
+ it('focuses the textarea', () => {
+ expect(textArea.focus).toHaveBeenCalled();
+ });
+
+ it('replaces previous diagram markdown with new diagram markdown', () => {
+ expect(textArea.value).toBe(`diagram ${newDiagramMarkdown}`);
+ });
+
+ it('dispatches input event in the textarea', () => {
+ expect(textArea.dispatchEvent).toHaveBeenCalledWith(new Event('input'));
+ });
+ });
+
+ describe('insertDiagram', () => {
+ it('inserts markdown text and replaces any selected markdown in the textarea', () => {
+ textArea.value = `diagram ${diagramMarkdown}`;
+ textArea.setSelectionRange(0, 8);
+
+ textareaMarkdownEditor.insertDiagram({
+ uploadResults: { link: { markdown: newDiagramMarkdown } },
+ });
+
+ expect(textMarkdown.insertMarkdownText).toHaveBeenCalledWith({
+ textArea,
+ text: textArea.value,
+ tag: newDiagramMarkdown,
+ selected: textArea.value.substring(0, 8),
+ });
+ });
+ });
+
+ describe('uploadDiagram', () => {
+ it('sends a post request to the uploadsPath containing the diagram svg', async () => {
+ const link = { markdown: '![](diagram.drawio.svg)' };
+ const blob = new Blob([diagramSvg], { type: 'image/svg+xml' });
+ const formData = new FormData();
+
+ formData.append('file', blob, filename);
+
+ axiosMock.onPost(uploadsPath, formData).reply(HTTP_STATUS_OK, {
+ link,
+ });
+
+ const response = await textareaMarkdownEditor.uploadDiagram({ diagramSvg, filename });
+
+ expect(response).toEqual({ link });
+ });
+ });
+});
diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js
index fdd157dd09f..57debf79c7b 100644
--- a/spec/frontend/dropzone_input_spec.js
+++ b/spec/frontend/dropzone_input_spec.js
@@ -1,7 +1,8 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import htmlNewMilestone from 'test_fixtures/milestones/new-milestone.html';
import mock from 'xhr-mock';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table';
@@ -48,9 +49,9 @@ describe('dropzone_input', () => {
};
beforeEach(() => {
- loadHTMLFixture('issues/new-issue.html');
+ setHTMLFixture(htmlNewMilestone);
- form = $('#new_issue');
+ form = $('#new_milestone');
form.data('uploads-path', TEST_UPLOAD_PATH);
dropzoneInput(form);
});
diff --git a/spec/frontend/editor/components/helpers.js b/spec/frontend/editor/components/helpers.js
index 12f90390c18..5cc66dd2ae0 100644
--- a/spec/frontend/editor/components/helpers.js
+++ b/spec/frontend/editor/components/helpers.js
@@ -1,4 +1,3 @@
-import { EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants';
import { apolloProvider } from '~/editor/components/source_editor_toolbar_graphql';
import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql';
@@ -9,7 +8,7 @@ export const buildButton = (id = 'foo-bar-btn', options = {}) => {
label: options.label || 'Foo Bar Button',
icon: options.icon || 'check',
selected: options.selected || false,
- group: options.group || EDITOR_TOOLBAR_RIGHT_GROUP,
+ group: options.group,
onClick: options.onClick || (() => {}),
category: options.category || 'primary',
selectedLabel: options.selectedLabel || 'smth',
diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
index ff377494312..b5944a52af7 100644
--- a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
+++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
@@ -21,11 +21,6 @@ describe('Source Editor Toolbar button', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('default', () => {
const defaultProps = {
category: 'primary',
@@ -38,17 +33,17 @@ describe('Source Editor Toolbar button', () => {
it('does not render the button if the props have not been passed', () => {
createComponent({});
- expect(findButton().vm).toBeUndefined();
+ expect(findButton().exists()).toBe(false);
});
- it('renders a default button without props', async () => {
+ it('renders a default button without props', () => {
createComponent();
const btn = findButton();
expect(btn.exists()).toBe(true);
expect(btn.props()).toMatchObject(defaultProps);
});
- it('renders a button based on the props passed', async () => {
+ it('renders a button based on the props passed', () => {
createComponent({
button: customProps,
});
@@ -112,34 +107,31 @@ describe('Source Editor Toolbar button', () => {
});
describe('click handler', () => {
- let clickEvent;
-
- beforeEach(() => {
- clickEvent = new Event('click');
- });
-
it('fires the click handler on the button when available', async () => {
- const spy = jest.fn();
+ const clickSpy = jest.fn();
+ const clickEvent = new Event('click');
createComponent({
button: {
- onClick: spy,
+ onClick: clickSpy,
},
});
- expect(spy).not.toHaveBeenCalled();
+ expect(wrapper.emitted('click')).toEqual(undefined);
findButton().vm.$emit('click', clickEvent);
await nextTick();
- expect(spy).toHaveBeenCalledWith(clickEvent);
+
+ expect(wrapper.emitted('click')).toEqual([[clickEvent]]);
+ expect(clickSpy).toHaveBeenCalledWith(clickEvent);
});
+
it('emits the "click" event, passing the event itself', async () => {
createComponent();
- jest.spyOn(wrapper.vm, '$emit');
- expect(wrapper.vm.$emit).not.toHaveBeenCalled();
+ expect(wrapper.emitted('click')).toEqual(undefined);
- findButton().vm.$emit('click', clickEvent);
+ findButton().vm.$emit('click');
await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', clickEvent);
+ expect(wrapper.emitted('click')).toHaveLength(1);
});
});
});
diff --git a/spec/frontend/editor/components/source_editor_toolbar_spec.js b/spec/frontend/editor/components/source_editor_toolbar_spec.js
index bead39ca744..95dc29c7916 100644
--- a/spec/frontend/editor/components/source_editor_toolbar_spec.js
+++ b/spec/frontend/editor/components/source_editor_toolbar_spec.js
@@ -5,7 +5,7 @@ import { shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import SourceEditorToolbar from '~/editor/components/source_editor_toolbar.vue';
import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue';
-import { EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants';
+import { EDITOR_TOOLBAR_BUTTON_GROUPS } from '~/editor/constants';
import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql';
import { buildButton } from './helpers';
@@ -40,24 +40,24 @@ describe('Source Editor Toolbar', () => {
};
afterEach(() => {
- wrapper.destroy();
mockApollo = null;
});
describe('groups', () => {
it.each`
- group | expectedGroup
- ${EDITOR_TOOLBAR_LEFT_GROUP} | ${EDITOR_TOOLBAR_LEFT_GROUP}
- ${EDITOR_TOOLBAR_RIGHT_GROUP} | ${EDITOR_TOOLBAR_RIGHT_GROUP}
- ${undefined} | ${EDITOR_TOOLBAR_RIGHT_GROUP}
- ${'non-existing'} | ${EDITOR_TOOLBAR_RIGHT_GROUP}
+ group | expectedGroup
+ ${EDITOR_TOOLBAR_BUTTON_GROUPS.file} | ${EDITOR_TOOLBAR_BUTTON_GROUPS.file}
+ ${EDITOR_TOOLBAR_BUTTON_GROUPS.edit} | ${EDITOR_TOOLBAR_BUTTON_GROUPS.edit}
+ ${EDITOR_TOOLBAR_BUTTON_GROUPS.settings} | ${EDITOR_TOOLBAR_BUTTON_GROUPS.settings}
+ ${undefined} | ${EDITOR_TOOLBAR_BUTTON_GROUPS.settings}
+ ${'non-existing'} | ${EDITOR_TOOLBAR_BUTTON_GROUPS.settings}
`('puts item with group="$group" into $expectedGroup group', ({ group, expectedGroup }) => {
const item = buildButton('first', {
group,
});
createComponentWithApollo([item]);
expect(findButtons()).toHaveLength(1);
- [EDITOR_TOOLBAR_RIGHT_GROUP, EDITOR_TOOLBAR_LEFT_GROUP].forEach((g) => {
+ Object.keys(EDITOR_TOOLBAR_BUTTON_GROUPS).forEach((g) => {
if (g === expectedGroup) {
expect(wrapper.vm.getGroupItems(g)).toEqual([expect.objectContaining({ id: 'first' })]);
} else {
@@ -70,7 +70,7 @@ describe('Source Editor Toolbar', () => {
describe('buttons update', () => {
it('properly updates buttons on Apollo cache update', async () => {
const item = buildButton('first', {
- group: EDITOR_TOOLBAR_RIGHT_GROUP,
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.edit,
});
createComponentWithApollo();
@@ -95,22 +95,25 @@ describe('Source Editor Toolbar', () => {
describe('click handler', () => {
it('emits the "click" event when a button is clicked', () => {
const item1 = buildButton('first', {
- group: EDITOR_TOOLBAR_LEFT_GROUP,
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.file,
});
const item2 = buildButton('second', {
- group: EDITOR_TOOLBAR_RIGHT_GROUP,
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.edit,
});
- createComponentWithApollo([item1, item2]);
- jest.spyOn(wrapper.vm, '$emit');
- expect(wrapper.vm.$emit).not.toHaveBeenCalled();
+ const item3 = buildButton('third', {
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.settings,
+ });
+ createComponentWithApollo([item1, item2, item3]);
+ expect(wrapper.emitted('click')).toEqual(undefined);
findButtons().at(0).vm.$emit('click');
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item1);
+ expect(wrapper.emitted('click')).toEqual([[item1]]);
findButtons().at(1).vm.$emit('click');
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item2);
+ expect(wrapper.emitted('click')).toEqual([[item1], [item2]]);
- expect(wrapper.vm.$emit.mock.calls).toHaveLength(2);
+ findButtons().at(2).vm.$emit('click');
+ expect(wrapper.emitted('click')).toEqual([[item1], [item2], [item3]]);
});
});
});
diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js
index c822a0bfeaf..51fcf26c39a 100644
--- a/spec/frontend/editor/schema/ci/ci_schema_spec.js
+++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js
@@ -27,12 +27,14 @@ import CacheYaml from './yaml_tests/positive_tests/cache.yml';
import FilterYaml from './yaml_tests/positive_tests/filter.yml';
import IncludeYaml from './yaml_tests/positive_tests/include.yml';
import RulesYaml from './yaml_tests/positive_tests/rules.yml';
+import RulesNeedsYaml from './yaml_tests/positive_tests/rules_needs.yml';
import ProjectPathYaml from './yaml_tests/positive_tests/project_path.yml';
import VariablesYaml from './yaml_tests/positive_tests/variables.yml';
import JobWhenYaml from './yaml_tests/positive_tests/job_when.yml';
import IdTokensYaml from './yaml_tests/positive_tests/id_tokens.yml';
import HooksYaml from './yaml_tests/positive_tests/hooks.yml';
import SecretsYaml from './yaml_tests/positive_tests/secrets.yml';
+import ServicesYaml from './yaml_tests/positive_tests/services.yml';
// YAML NEGATIVE TEST
import ArtifactsNegativeYaml from './yaml_tests/negative_tests/artifacts.yml';
@@ -45,6 +47,7 @@ import ProjectPathIncludeLeadSlashYaml from './yaml_tests/negative_tests/project
import ProjectPathIncludeNoSlashYaml from './yaml_tests/negative_tests/project_path/include/no_slash.yml';
import ProjectPathIncludeTailSlashYaml from './yaml_tests/negative_tests/project_path/include/tailing_slash.yml';
import RulesNegativeYaml from './yaml_tests/negative_tests/rules.yml';
+import RulesNeedsNegativeYaml from './yaml_tests/negative_tests/rules_needs.yml';
import TriggerNegative from './yaml_tests/negative_tests/trigger.yml';
import VariablesInvalidOptionsYaml from './yaml_tests/negative_tests/variables/invalid_options.yml';
import VariablesInvalidSyntaxDescYaml from './yaml_tests/negative_tests/variables/invalid_syntax_desc.yml';
@@ -52,6 +55,7 @@ import VariablesWrongSyntaxUsageExpand from './yaml_tests/negative_tests/variabl
import IdTokensNegativeYaml from './yaml_tests/negative_tests/id_tokens.yml';
import HooksNegative from './yaml_tests/negative_tests/hooks.yml';
import SecretsNegativeYaml from './yaml_tests/negative_tests/secrets.yml';
+import ServicesNegativeYaml from './yaml_tests/negative_tests/services.yml';
const ajv = new Ajv({
strictTypes: false,
@@ -86,9 +90,11 @@ describe('positive tests', () => {
JobWhenYaml,
HooksYaml,
RulesYaml,
+ RulesNeedsYaml,
VariablesYaml,
ProjectPathYaml,
IdTokensYaml,
+ ServicesYaml,
SecretsYaml,
}),
)('schema validates %s', (_, input) => {
@@ -113,10 +119,13 @@ describe('negative tests', () => {
// YAML
ArtifactsNegativeYaml,
CacheKeyNeative,
+ HooksNegative,
IdTokensNegativeYaml,
IncludeNegativeYaml,
JobWhenNegativeYaml,
RulesNegativeYaml,
+ RulesNeedsNegativeYaml,
+ TriggerNegative,
VariablesInvalidOptionsYaml,
VariablesInvalidSyntaxDescYaml,
VariablesWrongSyntaxUsageExpand,
@@ -126,8 +135,7 @@ describe('negative tests', () => {
ProjectPathIncludeNoSlashYaml,
ProjectPathIncludeTailSlashYaml,
SecretsNegativeYaml,
- TriggerNegative,
- HooksNegative,
+ ServicesNegativeYaml,
}),
)('schema validates %s', (_, input) => {
// We construct a new "JSON" from each main key that is inside a
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/rules_needs.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/rules_needs.yml
new file mode 100644
index 00000000000..f2f1eb118f8
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/rules_needs.yml
@@ -0,0 +1,46 @@
+# invalid rules:needs
+lint_job:
+ script: exit 0
+ rules:
+ - if: $var == null
+ needs:
+
+# invalid rules:needs
+lint_job_2:
+ script: exit 0
+ rules:
+ - if: $var == null
+ needs: [20]
+
+# invalid rules:needs
+lint_job_3:
+ script: exit 0
+ rules:
+ - if: $var == null
+ needs:
+ - job:
+
+# invalid rules:needs
+lint_job_5:
+ script: exit 0
+ rules:
+ - if: $var == null
+ needs:
+ - pipeline: 5
+
+# invalid rules:needs
+lint_job_6:
+ script: exit 0
+ rules:
+ - if: $var == null
+ needs:
+ - project: namespace/group/project-name
+
+# invalid rules:needs
+lint_job_7:
+ script: exit 0
+ rules:
+ - if: $var == null
+ needs:
+ - pipeline: 5
+ job: lint_job_6
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml
new file mode 100644
index 00000000000..6761a603a0a
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml
@@ -0,0 +1,38 @@
+empty_services:
+ services:
+
+without_name:
+ services:
+ - alias: db-postgres
+ entrypoint: ["/usr/local/bin/db-postgres"]
+ command: ["start"]
+
+invalid_entrypoint:
+ services:
+ - name: my-postgres:11.7
+ alias: db-postgres
+ entrypoint: "/usr/local/bin/db-postgres" # must be array
+
+empty_entrypoint:
+ services:
+ - name: my-postgres:11.7
+ alias: db-postgres
+ entrypoint: []
+
+invalid_command:
+ services:
+ - name: my-postgres:11.7
+ alias: db-postgres
+ command: "start" # must be array
+
+empty_command:
+ services:
+ - name: my-postgres:11.7
+ alias: db-postgres
+ command: []
+
+empty_pull_policy:
+ script: echo "Multiple pull policies."
+ services:
+ - name: postgres:11.6
+ pull_policy: []
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules_needs.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules_needs.yml
new file mode 100644
index 00000000000..a4a5183dcf4
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules_needs.yml
@@ -0,0 +1,32 @@
+# valid workflow:rules:needs
+pre_lint_job:
+ script: exit 0
+ rules:
+ - if: $var == null
+
+lint_job:
+ script: exit 0
+ rules:
+ - if: $var == null
+
+rspec_job:
+ script: exit 0
+ rules:
+ - if: $var == null
+ needs: [lint_job]
+
+job:
+ needs: [rspec_job]
+ script: exit 0
+ rules:
+ - if: $var == null
+ needs:
+ - job: lint_job
+ artifacts: false
+ optional: true
+ - job: pre_lint_job
+ artifacts: true
+ optional: false
+ - rspec_job
+ - if: $var == true
+ needs: [lint_job, pre_lint_job] \ No newline at end of file
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml
new file mode 100644
index 00000000000..8a0f59d1dfd
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml
@@ -0,0 +1,31 @@
+valid_services_list:
+ services:
+ - php:7
+ - node:latest
+ - golang:1.10
+
+valid_services_object:
+ services:
+ - name: my-postgres:11.7
+ alias: db-postgres
+ entrypoint: ["/usr/local/bin/db-postgres"]
+ command: ["start"]
+
+services_with_variables:
+ services:
+ - name: bitnami/nginx
+ alias: nginx
+ variables:
+ NGINX_HTTP_PORT_NUMBER: ${NGINX_HTTP_PORT_NUMBER}
+
+pull_policy_string:
+ script: echo "A single pull policy."
+ services:
+ - name: postgres:11.6
+ pull_policy: if-not-present
+
+pull_policy_array:
+ script: echo "Multiple pull policies."
+ services:
+ - name: postgres:11.6
+ pull_policy: [always, if-not-present]
diff --git a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
index 21f8979f1a9..e515285601b 100644
--- a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
+++ b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
@@ -17,7 +17,6 @@ describe('~/editor/editor_ci_config_ext', () => {
let editor;
let instance;
let editorEl;
- let originalGitlabUrl;
const createMockEditor = ({ blobPath = defaultBlobPath } = {}) => {
setHTMLFixture('<div id="editor"></div>');
@@ -31,16 +30,8 @@ describe('~/editor/editor_ci_config_ext', () => {
instance.use({ definition: CiSchemaExtension });
};
- beforeAll(() => {
- originalGitlabUrl = gon.gitlab_url;
- gon.gitlab_url = TEST_HOST;
- });
-
- afterAll(() => {
- gon.gitlab_url = originalGitlabUrl;
- });
-
beforeEach(() => {
+ gon.gitlab_url = TEST_HOST;
createMockEditor();
});
diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js
index eab39ccaba1..b1b8173188c 100644
--- a/spec/frontend/editor/source_editor_extension_base_spec.js
+++ b/spec/frontend/editor/source_editor_extension_base_spec.js
@@ -7,6 +7,7 @@ import {
EDITOR_TYPE_DIFF,
EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
EXTENSION_BASE_LINE_NUMBERS_CLASS,
+ EXTENSION_SOFTWRAP_ID,
} from '~/editor/constants';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import EditorInstance from '~/editor/source_editor_instance';
@@ -35,8 +36,18 @@ describe('The basis for an Source Editor extension', () => {
},
};
};
- const createInstance = (baseInstance = {}) => {
- return new EditorInstance(baseInstance);
+ const baseInstance = {
+ getOption: jest.fn(),
+ };
+
+ const createInstance = (base = baseInstance) => {
+ return new EditorInstance(base);
+ };
+
+ const toolbar = {
+ addItems: jest.fn(),
+ updateItem: jest.fn(),
+ removeItems: jest.fn(),
};
beforeEach(() => {
@@ -49,6 +60,66 @@ describe('The basis for an Source Editor extension', () => {
resetHTMLFixture();
});
+ describe('onSetup callback', () => {
+ let instance;
+ beforeEach(() => {
+ instance = createInstance();
+
+ instance.toolbar = toolbar;
+ });
+
+ it('adds correct buttons to the toolbar', () => {
+ instance.use({ definition: SourceEditorExtension });
+ expect(instance.toolbar.addItems).toHaveBeenCalledWith([
+ expect.objectContaining({
+ id: EXTENSION_SOFTWRAP_ID,
+ }),
+ ]);
+ });
+
+ it('does not fail if toolbar is not available', () => {
+ instance.toolbar = null;
+ expect(() => instance.use({ definition: SourceEditorExtension })).not.toThrow();
+ });
+
+ it.each`
+ optionValue | expectSelected
+ ${'on'} | ${true}
+ ${'off'} | ${false}
+ ${'foo'} | ${false}
+ ${undefined} | ${false}
+ ${null} | ${false}
+ `(
+ 'correctly sets the initial state of the button when wordWrap option is "$optionValue"',
+ ({ optionValue, expectSelected }) => {
+ instance.getOption.mockReturnValue(optionValue);
+ instance.use({ definition: SourceEditorExtension });
+ expect(instance.toolbar.addItems).toHaveBeenCalledWith([
+ expect.objectContaining({
+ selected: expectSelected,
+ }),
+ ]);
+ },
+ );
+ });
+
+ describe('onBeforeUnuse', () => {
+ let instance;
+ let extension;
+
+ beforeEach(() => {
+ instance = createInstance();
+
+ instance.toolbar = toolbar;
+ extension = instance.use({ definition: SourceEditorExtension });
+ });
+ it('removes the registered buttons from the toolbar', () => {
+ expect(instance.toolbar.removeItems).not.toHaveBeenCalled();
+ instance.unuse(extension);
+ expect(instance.toolbar.removeItems).toHaveBeenCalledWith([EXTENSION_SOFTWRAP_ID]);
+ });
+ });
+
describe('onUse callback', () => {
it('initializes the line highlighting', () => {
const instance = createInstance();
@@ -66,6 +137,7 @@ describe('The basis for an Source Editor extension', () => {
'$description the line linking for $instanceType instance',
({ instanceType, shouldBeCalled }) => {
const instance = createInstance({
+ ...baseInstance,
getEditorType: jest.fn().mockReturnValue(instanceType),
onMouseMove: jest.fn(),
onMouseDown: jest.fn(),
@@ -82,10 +154,44 @@ describe('The basis for an Source Editor extension', () => {
);
});
+ describe('toggleSoftwrap', () => {
+ let instance;
+
+ beforeEach(() => {
+ instance = createInstance();
+
+ instance.toolbar = toolbar;
+ instance.use({ definition: SourceEditorExtension });
+ });
+
+ it.each`
+ currentWordWrap | newWordWrap | expectSelected
+ ${'on'} | ${'off'} | ${false}
+ ${'off'} | ${'on'} | ${true}
+ ${'foo'} | ${'on'} | ${true}
+ ${undefined} | ${'on'} | ${true}
+ ${null} | ${'on'} | ${true}
+ `(
+ 'correctly updates wordWrap option in editor and the state of the button when currentWordWrap is "$currentWordWrap"',
+ ({ currentWordWrap, newWordWrap, expectSelected }) => {
+ instance.getOption.mockReturnValue(currentWordWrap);
+ instance.updateOptions = jest.fn();
+ instance.toggleSoftwrap();
+ expect(instance.updateOptions).toHaveBeenCalledWith({
+ wordWrap: newWordWrap,
+ });
+ expect(instance.toolbar.updateItem).toHaveBeenCalledWith(EXTENSION_SOFTWRAP_ID, {
+ selected: expectSelected,
+ });
+ },
+ );
+ });
+
describe('highlightLines', () => {
const revealSpy = jest.fn();
const decorationsSpy = jest.fn();
const instance = createInstance({
+ ...baseInstance,
revealLineInCenter: revealSpy,
deltaDecorations: decorationsSpy,
});
@@ -174,6 +280,7 @@ describe('The basis for an Source Editor extension', () => {
beforeEach(() => {
instance = createInstance({
+ ...baseInstance,
deltaDecorations: decorationsSpy,
lineDecorations,
});
@@ -188,6 +295,7 @@ describe('The basis for an Source Editor extension', () => {
describe('setupLineLinking', () => {
const instance = {
+ ...baseInstance,
onMouseMove: jest.fn(),
onMouseDown: jest.fn(),
deltaDecorations: jest.fn(),
diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js
index 33e4b4bfc8e..b226ef03b33 100644
--- a/spec/frontend/editor/source_editor_markdown_ext_spec.js
+++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js
@@ -17,6 +17,7 @@ describe('Markdown Extension for Source Editor', () => {
const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
const markdownPath = 'foo.md';
+ let extensions;
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn);
@@ -38,7 +39,10 @@ describe('Markdown Extension for Source Editor', () => {
blobPath: markdownPath,
blobContent: text,
});
- instance.use([{ definition: ToolbarExtension }, { definition: EditorMarkdownExtension }]);
+ extensions = instance.use([
+ { definition: ToolbarExtension },
+ { definition: EditorMarkdownExtension },
+ ]);
});
afterEach(() => {
@@ -59,6 +63,25 @@ describe('Markdown Extension for Source Editor', () => {
});
});
+ describe('markdown keystrokes', () => {
+ it('registers all keystrokes as actions', () => {
+ EXTENSION_MARKDOWN_BUTTONS.forEach((button) => {
+ if (button.data.mdShortcuts) {
+ expect(instance.getAction(button.id)).toBeDefined();
+ }
+ });
+ });
+
+ it('disposes all keystrokes on unuse', () => {
+ instance.unuse(extensions[1]);
+ EXTENSION_MARKDOWN_BUTTONS.forEach((button) => {
+ if (button.data.mdShortcuts) {
+ expect(instance.getAction(button.id)).toBeNull();
+ }
+ });
+ });
+ });
+
describe('getSelectedText', () => {
it('does not fail if there is no selection and returns the empty string', () => {
jest.spyOn(instance, 'getSelection');
diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
index c42ac28c498..fb5fce92482 100644
--- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
+++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
@@ -1,6 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
import { Emitter } from 'monaco-editor';
-import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import {
@@ -12,14 +11,14 @@ import {
} from '~/editor/constants';
import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import SourceEditor from '~/editor/source_editor';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import syntaxHighlight from '~/syntax_highlight';
import { spyOnApi } from './helpers';
jest.mock('~/syntax_highlight');
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Markdown Live Preview Extension for Source Editor', () => {
let editor;
@@ -28,6 +27,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
let panelSpy;
let mockAxios;
let extension;
+ let resizeCallback;
const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown';
const firstLine = 'This is a';
const secondLine = 'multiline';
@@ -35,6 +35,8 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
const markdownPath = 'foo.md';
const responseData = '<div>FooBar</div>';
+ const observeSpy = jest.fn();
+ const disconnectSpy = jest.fn();
const togglePreview = async () => {
instance.togglePreview();
@@ -43,8 +45,22 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
beforeEach(() => {
mockAxios = new MockAdapter(axios);
- setHTMLFixture('<div id="editor" data-editor-loading></div>');
+ setHTMLFixture(
+ '<div style="width: 500px; height: 500px"><div id="editor" data-editor-loading></div></div>',
+ );
editorEl = document.getElementById('editor');
+ global.ResizeObserver = class {
+ constructor(callback) {
+ resizeCallback = callback;
+ this.observe = (node) => {
+ return observeSpy(node);
+ };
+ this.disconnect = () => {
+ return disconnectSpy();
+ };
+ }
+ };
+
editor = new SourceEditor();
instance = editor.createInstance({
el: editorEl,
@@ -77,9 +93,6 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
actions: expect.any(Object),
shown: false,
modelChangeListener: undefined,
- layoutChangeListener: {
- dispose: expect.anything(),
- },
path: previewMarkdownPath,
actionShowPreviewCondition: expect.any(Object),
eventEmitter: expect.any(Object),
@@ -94,36 +107,64 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
expect(panelSpy).toHaveBeenCalled();
});
- describe('onDidLayoutChange', () => {
- const emitter = new Emitter();
- let layoutSpy;
+ describe('ResizeObserver handler', () => {
+ it('sets a ResizeObserver to observe the container DOM node', () => {
+ observeSpy.mockClear();
+ instance.togglePreview();
+ expect(observeSpy).toHaveBeenCalledWith(instance.getContainerDomNode());
+ });
- useFakeRequestAnimationFrame();
+ describe('disconnects the ResizeObserver when…', () => {
+ beforeEach(() => {
+ instance.togglePreview();
+ instance.markdownPreview.modelChangeListener = {
+ dispose: jest.fn(),
+ };
+ });
- beforeEach(() => {
- instance.unuse(extension);
- instance.onDidLayoutChange = emitter.event;
- extension = instance.use({
- definition: EditorMarkdownPreviewExtension,
- setupOptions: { previewMarkdownPath },
+ it('the preview gets closed', () => {
+ expect(disconnectSpy).not.toHaveBeenCalled();
+ instance.togglePreview();
+ expect(disconnectSpy).toHaveBeenCalled();
});
- layoutSpy = jest.spyOn(instance, 'layout');
- });
- it('does not trigger the layout when the preview is not active [default]', async () => {
- expect(instance.markdownPreview.shown).toBe(false);
- expect(layoutSpy).not.toHaveBeenCalled();
- await emitter.fire();
- expect(layoutSpy).not.toHaveBeenCalled();
+ it('the extension is unused', () => {
+ expect(disconnectSpy).not.toHaveBeenCalled();
+ instance.unuse(extension);
+ expect(disconnectSpy).toHaveBeenCalled();
+ });
});
- it('triggers the layout if the preview panel is opened', async () => {
- expect(layoutSpy).not.toHaveBeenCalled();
- instance.togglePreview();
- layoutSpy.mockReset();
+ describe('layout behavior', () => {
+ let layoutSpy;
+ let instanceDimensions;
+ let newInstanceWidth;
- await emitter.fire();
- expect(layoutSpy).toHaveBeenCalledTimes(1);
+ beforeEach(() => {
+ instanceDimensions = instance.getLayoutInfo();
+ });
+
+ it('does not trigger the layout if the preview panel is closed', () => {
+ layoutSpy = jest.spyOn(instance, 'layout');
+ newInstanceWidth = instanceDimensions.width + 100;
+
+ // Manually trigger the resize event
+ resizeCallback([{ contentRect: { width: newInstanceWidth } }]);
+ expect(layoutSpy).not.toHaveBeenCalled();
+ });
+
+ it('triggers the layout if the preview panel is opened, and width of the editor has changed', () => {
+ instance.togglePreview();
+ layoutSpy = jest.spyOn(instance, 'layout');
+ newInstanceWidth = instanceDimensions.width + 100;
+
+ // Manually trigger the resize event
+ resizeCallback([{ contentRect: { width: newInstanceWidth } }]);
+ expect(layoutSpy).toHaveBeenCalledWith({
+ width: newInstanceWidth * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
+ height: instanceDimensions.height,
+ });
+ });
});
});
@@ -226,11 +267,10 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
});
- it('disposes the layoutChange listener and does not re-layout on layout changes', () => {
- expect(instance.markdownPreview.layoutChangeListener).toBeDefined();
+ it('disconnects the ResizeObserver', () => {
instance.unuse(extension);
- expect(instance.markdownPreview?.layoutChangeListener).toBeUndefined();
+ expect(disconnectSpy).toHaveBeenCalled();
});
it('does not trigger the re-layout after instance is unused', async () => {
diff --git a/spec/frontend/editor/source_editor_webide_ext_spec.js b/spec/frontend/editor/source_editor_webide_ext_spec.js
index f418eab668a..7e4079c17f7 100644
--- a/spec/frontend/editor/source_editor_webide_ext_spec.js
+++ b/spec/frontend/editor/source_editor_webide_ext_spec.js
@@ -13,7 +13,6 @@ describe('Source Editor Web IDE Extension', () => {
editorEl = document.getElementById('editor');
editor = new SourceEditor();
});
- afterEach(() => {});
describe('onSetup', () => {
it.each`
diff --git a/spec/frontend/editor/utils_spec.js b/spec/frontend/editor/utils_spec.js
index e561cad1086..c9d6cbcaaa6 100644
--- a/spec/frontend/editor/utils_spec.js
+++ b/spec/frontend/editor/utils_spec.js
@@ -1,6 +1,8 @@
import { editor as monacoEditor } from 'monaco-editor';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import * as utils from '~/editor/utils';
+import languages from '~/ide/lib/languages';
+import { registerLanguages } from '~/ide/utils';
import { DEFAULT_THEME } from '~/ide/lib/themes';
describe('Source Editor utils', () => {
@@ -53,20 +55,24 @@ describe('Source Editor utils', () => {
});
describe('getBlobLanguage', () => {
+ beforeEach(() => {
+ registerLanguages(...languages);
+ });
+
it.each`
- path | expectedLanguage
- ${'foo.js'} | ${'javascript'}
- ${'foo.js.rb'} | ${'ruby'}
- ${'foo.bar'} | ${'plaintext'}
- ${undefined} | ${'plaintext'}
- `(
- 'sets the $expectedThemeName theme when $themeName is set in the user preference',
- ({ path, expectedLanguage }) => {
- const language = utils.getBlobLanguage(path);
+ path | expectedLanguage
+ ${'foo.js'} | ${'javascript'}
+ ${'foo.js.rb'} | ${'ruby'}
+ ${'foo.bar'} | ${'plaintext'}
+ ${undefined} | ${'plaintext'}
+ ${'foo/bar/foo.js'} | ${'javascript'}
+ ${'CODEOWNERS'} | ${'codeowners'}
+ ${'.gitlab/CODEOWNERS'} | ${'codeowners'}
+ `(`returns '$expectedLanguage' for '$path' path`, ({ path, expectedLanguage }) => {
+ const language = utils.getBlobLanguage(path);
- expect(language).toEqual(expectedLanguage);
- },
- );
+ expect(language).toEqual(expectedLanguage);
+ });
});
describe('setupCodeSnipet', () => {
diff --git a/spec/frontend/emoji/awards_app/store/actions_spec.js b/spec/frontend/emoji/awards_app/store/actions_spec.js
index 3e9b49707ed..65f2e813f19 100644
--- a/spec/frontend/emoji/awards_app/store/actions_spec.js
+++ b/spec/frontend/emoji/awards_app/store/actions_spec.js
@@ -119,7 +119,7 @@ describe('Awards app actions', () => {
mock.onPost(`${relativeRootUrl || ''}/awards`).reply(HTTP_STATUS_OK, { id: 1 });
});
- it('adds an optimistic award, removes it, and then commits ADD_NEW_AWARD', async () => {
+ it('adds an optimistic award, removes it, and then commits ADD_NEW_AWARD', () => {
testAction(actions.toggleAward, null, { path: '/awards', awards: [] }, [
makeOptimisticAddMutation(),
makeOptimisticRemoveMutation(),
@@ -156,7 +156,7 @@ describe('Awards app actions', () => {
mock.onDelete(`${relativeRootUrl || ''}/awards/1`).reply(HTTP_STATUS_OK);
});
- it('commits REMOVE_AWARD', async () => {
+ it('commits REMOVE_AWARD', () => {
testAction(
actions.toggleAward,
'thumbsup',
diff --git a/spec/frontend/emoji/components/category_spec.js b/spec/frontend/emoji/components/category_spec.js
index 90816f28d5b..272c1a09a69 100644
--- a/spec/frontend/emoji/components/category_spec.js
+++ b/spec/frontend/emoji/components/category_spec.js
@@ -9,11 +9,12 @@ function factory(propsData = {}) {
wrapper = shallowMount(Category, { propsData });
}
-describe('Emoji category component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
+const triggerGlIntersectionObserver = () => {
+ wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
+ return nextTick();
+};
+describe('Emoji category component', () => {
beforeEach(() => {
factory({
category: 'Activity',
@@ -26,25 +27,19 @@ describe('Emoji category component', () => {
});
it('renders group', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({ renderGroup: true });
+ await triggerGlIntersectionObserver();
expect(wrapper.findComponent(EmojiGroup).attributes('rendergroup')).toBe('true');
});
it('renders group on appear', async () => {
- wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
-
- await nextTick();
+ await triggerGlIntersectionObserver();
expect(wrapper.findComponent(EmojiGroup).attributes('rendergroup')).toBe('true');
});
it('emits appear event on appear', async () => {
- wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
-
- await nextTick();
+ await triggerGlIntersectionObserver();
expect(wrapper.emitted().appear[0]).toEqual(['Activity']);
});
diff --git a/spec/frontend/emoji/components/emoji_group_spec.js b/spec/frontend/emoji/components/emoji_group_spec.js
index 1aca2fbb8fc..75397ce25ff 100644
--- a/spec/frontend/emoji/components/emoji_group_spec.js
+++ b/spec/frontend/emoji/components/emoji_group_spec.js
@@ -15,10 +15,6 @@ function factory(propsData = {}) {
}
describe('Emoji group component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('does not render any buttons', () => {
factory({
emojis: [],
diff --git a/spec/frontend/emoji/components/emoji_list_spec.js b/spec/frontend/emoji/components/emoji_list_spec.js
index a72ba614d9f..f6f6062f8e8 100644
--- a/spec/frontend/emoji/components/emoji_list_spec.js
+++ b/spec/frontend/emoji/components/emoji_list_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import EmojiList from '~/emoji/components/emoji_list.vue';
+import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/emoji', () => ({
initEmojiMap: jest.fn(() => Promise.resolve()),
@@ -14,7 +14,8 @@ jest.mock('~/emoji', () => ({
}));
let wrapper;
-async function factory(render, propsData = { searchValue: '' }) {
+
+function factory(propsData = { searchValue: '' }) {
wrapper = extendedWrapper(
shallowMount(EmojiList, {
propsData,
@@ -23,35 +24,23 @@ async function factory(render, propsData = { searchValue: '' }) {
},
}),
);
-
- // Wait for categories to be set
- await nextTick();
-
- if (render) {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ render: true });
-
- // Wait for component to render
- await nextTick();
- }
}
const findDefaultSlot = () => wrapper.findByTestId('default-slot');
describe('Emoji list component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('does not render until render is set', async () => {
- await factory(false);
+ factory();
expect(findDefaultSlot().exists()).toBe(false);
+ await waitForPromises();
+ expect(findDefaultSlot().exists()).toBe(true);
});
it('renders with none filtered list', async () => {
- await factory(true);
+ factory();
+
+ await waitForPromises();
expect(JSON.parse(findDefaultSlot().text())).toEqual({
activity: {
@@ -63,7 +52,9 @@ describe('Emoji list component', () => {
});
it('renders filtered list of emojis', async () => {
- await factory(true, { searchValue: 'smile' });
+ factory({ searchValue: 'smile' });
+
+ await waitForPromises();
expect(JSON.parse(findDefaultSlot().text())).toEqual({
search: {
diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js
index 82e3b50aeb8..4e341b2bb2f 100644
--- a/spec/frontend/environment.js
+++ b/spec/frontend/environment.js
@@ -8,6 +8,7 @@ const {
setGlobalDateToRealDate,
} = require('./__helpers__/fake_date/fake_date');
const { TEST_HOST } = require('./__helpers__/test_constants');
+const { createGon } = require('./__helpers__/gon_helper');
const ROOT_PATH = path.resolve(__dirname, '../..');
@@ -20,8 +21,17 @@ class CustomEnvironment extends TestEnvironment {
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39496#note_503084332
setGlobalDateToFakeDate();
+ const { error: originalErrorFn } = context.console;
Object.assign(context.console, {
error(...args) {
+ if (
+ args?.[0]?.includes('[Vue warn]: Missing required prop') ||
+ args?.[0]?.includes('[Vue warn]: Invalid prop')
+ ) {
+ originalErrorFn.apply(context.console, args);
+ return;
+ }
+
throw new ErrorWithStack(
`Unexpected call of console.error() with:\n\n${args.join(', ')}`,
this.error,
@@ -29,7 +39,7 @@ class CustomEnvironment extends TestEnvironment {
},
warn(...args) {
- if (args[0].includes('The updateQuery callback for fetchMore is deprecated')) {
+ if (args?.[0]?.includes('The updateQuery callback for fetchMore is deprecated')) {
return;
}
throw new ErrorWithStack(
@@ -40,11 +50,12 @@ class CustomEnvironment extends TestEnvironment {
});
const { IS_EE } = projectConfig.testEnvironmentOptions;
- this.global.gon = {
- ee: IS_EE,
- };
+
this.global.IS_EE = IS_EE;
+ // Set up global `gon` object
+ this.global.gon = createGon(IS_EE);
+
// Set up global `gl` object
this.global.gl = {};
diff --git a/spec/frontend/environments/canary_ingress_spec.js b/spec/frontend/environments/canary_ingress_spec.js
index 340740e6499..e0247731b63 100644
--- a/spec/frontend/environments/canary_ingress_spec.js
+++ b/spec/frontend/environments/canary_ingress_spec.js
@@ -23,7 +23,7 @@ describe('/environments/components/canary_ingress.vue', () => {
...props,
},
directives: {
- GlModal: createMockDirective(),
+ GlModal: createMockDirective('gl-modal'),
},
...options,
});
@@ -33,14 +33,6 @@ describe('/environments/components/canary_ingress.vue', () => {
createComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
-
- wrapper = null;
- });
-
describe('stable weight', () => {
let stableWeightDropdown;
diff --git a/spec/frontend/environments/canary_update_modal_spec.js b/spec/frontend/environments/canary_update_modal_spec.js
index 31b1770da59..4fa7b34d817 100644
--- a/spec/frontend/environments/canary_update_modal_spec.js
+++ b/spec/frontend/environments/canary_update_modal_spec.js
@@ -30,14 +30,6 @@ describe('/environments/components/canary_update_modal.vue', () => {
modal = wrapper.findComponent(GlModal);
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
-
- wrapper = null;
- });
-
beforeEach(() => {
createComponent();
});
@@ -47,7 +39,7 @@ describe('/environments/components/canary_update_modal.vue', () => {
modalId: 'confirm-canary-change',
actionPrimary: {
text: 'Change ratio',
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
actionCancel: { text: 'Cancel' },
});
diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js
index 2163814528a..d6601447cff 100644
--- a/spec/frontend/environments/confirm_rollback_modal_spec.js
+++ b/spec/frontend/environments/confirm_rollback_modal_spec.js
@@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo';
import { trimText } from 'helpers/text_helper';
import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import eventHub from '~/environments/event_hub';
describe('Confirm Rollback Modal Component', () => {
@@ -53,6 +54,8 @@ describe('Confirm Rollback Modal Component', () => {
});
};
+ const findModal = () => component.findComponent(GlModal);
+
describe.each`
hasMultipleCommits | environmentData | retryUrl | primaryPropsAttrs
${true} | ${envWithLastDeployment} | ${null} | ${[{ variant: 'danger' }]}
@@ -73,7 +76,7 @@ describe('Confirm Rollback Modal Component', () => {
hasMultipleCommits,
retryUrl,
});
- const modal = component.findComponent(GlModal);
+ const modal = findModal();
expect(modal.attributes('title')).toContain('Rollback');
expect(modal.attributes('title')).toContain('test');
@@ -92,7 +95,7 @@ describe('Confirm Rollback Modal Component', () => {
hasMultipleCommits,
});
- const modal = component.findComponent(GlModal);
+ const modal = findModal();
expect(modal.attributes('title')).toContain('Re-deploy');
expect(modal.attributes('title')).toContain('test');
@@ -110,7 +113,7 @@ describe('Confirm Rollback Modal Component', () => {
});
const eventHubSpy = jest.spyOn(eventHub, '$emit');
- const modal = component.findComponent(GlModal);
+ const modal = findModal();
modal.vm.$emit('ok');
expect(eventHubSpy).toHaveBeenCalledWith('rollbackEnvironment', env);
@@ -155,7 +158,7 @@ describe('Confirm Rollback Modal Component', () => {
},
{ apolloProvider },
);
- const modal = component.findComponent(GlModal);
+ const modal = findModal();
expect(trimText(modal.text())).toContain('commit abc0123');
expect(modal.text()).toContain('Are you sure you want to continue?');
@@ -177,7 +180,7 @@ describe('Confirm Rollback Modal Component', () => {
},
{ apolloProvider },
);
- const modal = component.findComponent(GlModal);
+ const modal = findModal();
expect(modal.attributes('title')).toContain('Rollback');
expect(modal.attributes('title')).toContain('test');
@@ -201,7 +204,7 @@ describe('Confirm Rollback Modal Component', () => {
{ apolloProvider },
);
- const modal = component.findComponent(GlModal);
+ const modal = findModal();
expect(modal.attributes('title')).toContain('Re-deploy');
expect(modal.attributes('title')).toContain('test');
@@ -220,7 +223,7 @@ describe('Confirm Rollback Modal Component', () => {
{ apolloProvider },
);
- const modal = component.findComponent(GlModal);
+ const modal = findModal();
modal.vm.$emit('ok');
await nextTick();
@@ -231,6 +234,25 @@ describe('Confirm Rollback Modal Component', () => {
expect.anything(),
);
});
+
+ it('should emit the "rollback" event when "ok" is clicked', async () => {
+ const env = { ...environmentData, isLastDeployment: true };
+
+ createComponent(
+ {
+ environment: env,
+ hasMultipleCommits,
+ graphql: true,
+ },
+ { apolloProvider },
+ );
+
+ const modal = findModal();
+ modal.vm.$emit('ok');
+
+ await waitForPromises();
+ expect(component.emitted('rollback')).toEqual([[]]);
+ });
},
);
});
diff --git a/spec/frontend/environments/delete_environment_modal_spec.js b/spec/frontend/environments/delete_environment_modal_spec.js
index cc18bf754eb..96f6ce52a9c 100644
--- a/spec/frontend/environments/delete_environment_modal_spec.js
+++ b/spec/frontend/environments/delete_environment_modal_spec.js
@@ -6,10 +6,10 @@ import { s__, sprintf } from '~/locale';
import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { resolvedEnvironment } from './graphql/mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
describe('~/environments/components/delete_environment_modal.vue', () => {
@@ -67,7 +67,7 @@ describe('~/environments/components/delete_environment_modal.vue', () => {
);
});
- it('should flash a message on error', async () => {
+ it('should alert a message on error', async () => {
createComponent({ apolloProvider: mockApollo });
deleteResolver.mockRejectedValue();
diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js
index 73a366457fb..f50efada91a 100644
--- a/spec/frontend/environments/deploy_board_component_spec.js
+++ b/spec/frontend/environments/deploy_board_component_spec.js
@@ -61,7 +61,7 @@ describe('Deploy Board', () => {
const icon = iconSpan.findComponent(GlIcon);
expect(tooltip.props('target')()).toBe(iconSpan.element);
- expect(icon.props('name')).toBe('question');
+ expect(icon.props('name')).toBe('question-o');
});
it('renders the canary weight selector', () => {
@@ -116,7 +116,7 @@ describe('Deploy Board', () => {
const icon = iconSpan.findComponent(GlIcon);
expect(tooltip.props('target')()).toBe(iconSpan.element);
- expect(icon.props('name')).toBe('question');
+ expect(icon.props('name')).toBe('question-o');
});
it('renders the canary weight selector', () => {
diff --git a/spec/frontend/environments/deploy_freeze_alert_spec.js b/spec/frontend/environments/deploy_freeze_alert_spec.js
new file mode 100644
index 00000000000..b7202253e61
--- /dev/null
+++ b/spec/frontend/environments/deploy_freeze_alert_spec.js
@@ -0,0 +1,111 @@
+import { GlAlert, GlLink } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import DeployFreezeAlert from '~/environments/components/deploy_freeze_alert.vue';
+import deployFreezesQuery from '~/environments/graphql/queries/deploy_freezes.query.graphql';
+import { formatDate } from '~/lib/utils/datetime/date_format_utility';
+
+const ENVIRONMENT_NAME = 'staging';
+
+Vue.use(VueApollo);
+describe('~/environments/components/deploy_freeze_alert.vue', () => {
+ let wrapper;
+
+ const createWrapper = (deployFreezes = []) => {
+ const mockApollo = createMockApollo([
+ [
+ deployFreezesQuery,
+ jest.fn().mockResolvedValue({
+ data: {
+ project: {
+ id: '1',
+ __typename: 'Project',
+ environment: {
+ id: '1',
+ __typename: 'Environment',
+ deployFreezes,
+ },
+ },
+ },
+ }),
+ ],
+ ]);
+ wrapper = mountExtended(DeployFreezeAlert, {
+ apolloProvider: mockApollo,
+ provide: {
+ projectFullPath: 'gitlab-org/gitlab',
+ },
+ propsData: {
+ name: ENVIRONMENT_NAME,
+ },
+ });
+ };
+
+ describe('with deploy freezes', () => {
+ let deployFreezes;
+ let alert;
+
+ beforeEach(async () => {
+ deployFreezes = [
+ {
+ __typename: 'CiFreezePeriod',
+ startTime: new Date('2020-02-01'),
+ endTime: new Date('2020-02-02'),
+ },
+ {
+ __typename: 'CiFreezePeriod',
+ startTime: new Date('2020-01-01'),
+ endTime: new Date('2020-01-02'),
+ },
+ ];
+
+ createWrapper(deployFreezes);
+
+ await waitForPromises();
+
+ alert = wrapper.findComponent(GlAlert);
+ });
+
+ it('shows an alert', () => {
+ expect(alert.exists()).toBe(true);
+ });
+
+ it('shows the start time of the most recent freeze period', () => {
+ expect(alert.text()).toContain(`from ${formatDate(deployFreezes[1].startTime)}`);
+ });
+
+ it('shows the end time of the most recent freeze period', () => {
+ expect(alert.text()).toContain(`to ${formatDate(deployFreezes[1].endTime)}`);
+ });
+
+ it('shows a link to the docs', () => {
+ const link = alert.findComponent(GlLink);
+ expect(link.attributes('href')).toBe(
+ '/help/user/project/releases/index#prevent-unintentional-releases-by-setting-a-deploy-freeze',
+ );
+ expect(link.text()).toBe('deploy freeze documentation');
+ });
+ });
+
+ describe('without deploy freezes', () => {
+ let deployFreezes;
+ let alert;
+
+ beforeEach(async () => {
+ deployFreezes = [];
+
+ createWrapper(deployFreezes);
+
+ await waitForPromises();
+
+ alert = wrapper.findComponent(GlAlert);
+ });
+
+ it('does not show an alert', () => {
+ expect(alert.exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js
index fb1a8b8c00a..34f338fabe6 100644
--- a/spec/frontend/environments/edit_environment_spec.js
+++ b/spec/frontend/environments/edit_environment_spec.js
@@ -3,13 +3,13 @@ import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import EditEnvironment from '~/environments/components/edit_environment.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility');
-jest.mock('~/flash');
+jest.mock('~/alert');
const DEFAULT_OPTS = {
provide: {
@@ -37,7 +37,6 @@ describe('~/environments/components/edit.vue', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
const findNameInput = () => wrapper.findByLabelText('Name');
diff --git a/spec/frontend/environments/empty_state_spec.js b/spec/frontend/environments/empty_state_spec.js
index 02cf2dc3c68..593200859e4 100644
--- a/spec/frontend/environments/empty_state_spec.js
+++ b/spec/frontend/environments/empty_state_spec.js
@@ -11,12 +11,17 @@ describe('~/environments/components/empty_state.vue', () => {
const findNewEnvironmentLink = () =>
wrapper.findByRole('link', {
- name: s__('Environments|New environment'),
+ name: s__('Environments|Create an environment'),
});
const findDocsLink = () =>
wrapper.findByRole('link', {
- name: s__('Environments|How do I create an environment?'),
+ name: 'Learn more',
+ });
+
+ const finfEnablingReviewButton = () =>
+ wrapper.findByRole('button', {
+ name: s__('Environments|Enable review apps'),
});
const createWrapper = ({ propsData = {} } = {}) =>
@@ -29,42 +34,44 @@ describe('~/environments/components/empty_state.vue', () => {
provide: { newEnvironmentPath: NEW_PATH },
});
- afterEach(() => {
- wrapper.destroy();
- });
+ describe('without search term', () => {
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
- it('shows an empty state for available environments', () => {
- wrapper = createWrapper();
+ it('shows an empty state environments', () => {
+ const title = wrapper.findByRole('heading', {
+ name: s__('Environments|Get started with environments'),
+ });
- const title = wrapper.findByRole('heading', {
- name: s__("Environments|You don't have any environments."),
+ expect(title.exists()).toBe(true);
});
- expect(title.exists()).toBe(true);
- });
-
- it('shows an empty state for stopped environments', () => {
- wrapper = createWrapper({ propsData: { scope: ENVIRONMENTS_SCOPE.STOPPED } });
+ it('shows a link to the the help path', () => {
+ const link = findDocsLink();
- const title = wrapper.findByRole('heading', {
- name: s__("Environments|You don't have any stopped environments."),
+ expect(link.attributes('href')).toBe(HELP_PATH);
});
- expect(title.exists()).toBe(true);
- });
+ it('shows a link to creating a new environment', () => {
+ const link = findNewEnvironmentLink();
- it('shows a link to the the help path', () => {
- wrapper = createWrapper();
+ expect(link.attributes('href')).toBe(NEW_PATH);
+ });
- const link = findDocsLink();
+ it('shows a button to enable review apps', () => {
+ const button = finfEnablingReviewButton();
- expect(link.attributes('href')).toBe(HELP_PATH);
- });
+ expect(button.exists()).toBe(true);
+ });
+
+ it('should emit enable review', () => {
+ const button = finfEnablingReviewButton();
- it('hides a link to creating a new environment', () => {
- const link = findNewEnvironmentLink();
+ button.vm.$emit('click');
- expect(link.exists()).toBe(false);
+ expect(wrapper.emitted('enable-review')).toBeDefined();
+ });
});
describe('with search term', () => {
@@ -90,10 +97,16 @@ describe('~/environments/components/empty_state.vue', () => {
expect(link.exists()).toBe(false);
});
- it('shows a link to create a new environment', () => {
+ it('hide a link to create a new environment', () => {
const link = findNewEnvironmentLink();
- expect(link.attributes('href')).toBe(NEW_PATH);
+ expect(link.exists()).toBe(false);
+ });
+
+ it('hide a button to enable review apps', () => {
+ const button = finfEnablingReviewButton();
+
+ expect(button.exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/environments/enable_review_app_modal_spec.js b/spec/frontend/environments/enable_review_app_modal_spec.js
index 7939bd600dc..f5571609931 100644
--- a/spec/frontend/environments/enable_review_app_modal_spec.js
+++ b/spec/frontend/environments/enable_review_app_modal_spec.js
@@ -10,7 +10,7 @@ jest.mock('lodash/uniqueId', () => (x) => `${x}77`);
const EXPECTED_COPY_PRE_ID = 'enable-review-app-copy-string-77';
-describe('Enable Review App Modal', () => {
+describe('Enable Review Apps Modal', () => {
let wrapper;
let modal;
@@ -18,10 +18,6 @@ describe('Enable Review App Modal', () => {
const findInstructionAt = (i) => wrapper.findAll('ol li').at(i);
const findCopyString = () => wrapper.find(`#${EXPECTED_COPY_PRE_ID}`);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('renders the modal', () => {
beforeEach(() => {
wrapper = extendedWrapper(
diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js
index 48483152f7a..b7e192839da 100644
--- a/spec/frontend/environments/environment_actions_spec.js
+++ b/spec/frontend/environments/environment_actions_spec.js
@@ -1,14 +1,8 @@
-import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { shallowMount, mount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
+import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import EnvironmentActions from '~/environments/components/environment_actions.vue';
-import eventHub from '~/environments/event_hub';
-import actionMutation from '~/environments/graphql/mutations/action.mutation.graphql';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
-import createMockApollo from 'helpers/mock_apollo_helper';
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
@@ -29,15 +23,9 @@ const expiredJobAction = {
describe('EnvironmentActions Component', () => {
let wrapper;
- const findEnvironmentActionsButton = () =>
- wrapper.find('[data-testid="environment-actions-button"]');
-
- function createComponent(props, { mountFn = shallowMount, options = {} } = {}) {
- wrapper = mountFn(EnvironmentActions, {
+ function createComponent(props, { options = {} } = {}) {
+ wrapper = mount(EnvironmentActions, {
propsData: { actions: [], ...props },
- directives: {
- GlTooltip: createMockDirective(),
- },
...options,
});
}
@@ -46,30 +34,26 @@ describe('EnvironmentActions Component', () => {
return createComponent({ actions: [scheduledJobAction, expiredJobAction] }, opts);
}
+ const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
const findDropdownItem = (action) => {
- const buttons = wrapper.findAllComponents(GlDropdownItem);
- return buttons.filter((button) => button.text().startsWith(action.name)).at(0);
+ const items = findDropdownItems();
+ return items.filter((item) => item.text().startsWith(action.name)).at(0);
};
afterEach(() => {
- wrapper.destroy();
confirmAction.mockReset();
});
it('should render a dropdown button with 2 icons', () => {
- createComponent({}, { mountFn: mount });
- expect(wrapper.findComponent(GlDropdown).findAllComponents(GlIcon).length).toBe(2);
- });
-
- it('should render a dropdown button with aria-label description', () => {
createComponent();
- expect(wrapper.findComponent(GlDropdown).attributes('aria-label')).toBe('Deploy to...');
+ expect(wrapper.findComponent(GlDisclosureDropdown).findAllComponents(GlIcon).length).toBe(2);
});
- it('should render a tooltip', () => {
+ it('should render a dropdown button with aria-label description', () => {
createComponent();
- const tooltip = getBinding(findEnvironmentActionsButton().element, 'gl-tooltip');
- expect(tooltip).toBeDefined();
+ expect(wrapper.findComponent(GlDisclosureDropdown).attributes('aria-label')).toBe(
+ 'Deploy to...',
+ );
});
describe('manual actions', () => {
@@ -94,96 +78,31 @@ describe('EnvironmentActions Component', () => {
});
it('should render a dropdown with the provided list of actions', () => {
- expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(actions.length);
+ expect(findDropdownItems()).toHaveLength(actions.length);
});
it("should render a disabled action when it's not playable", () => {
- const dropdownItems = wrapper.findAllComponents(GlDropdownItem);
+ const dropdownItems = findDropdownItems();
const lastDropdownItem = dropdownItems.at(dropdownItems.length - 1);
- expect(lastDropdownItem.attributes('disabled')).toBe('true');
+ expect(lastDropdownItem.find('button').attributes('disabled')).toBeDefined();
});
});
describe('scheduled jobs', () => {
- let emitSpy;
-
- const clickAndConfirm = async ({ confirm = true } = {}) => {
- confirmAction.mockResolvedValueOnce(confirm);
-
- findDropdownItem(scheduledJobAction).vm.$emit('click');
- await nextTick();
- };
-
beforeEach(() => {
- emitSpy = jest.fn();
- eventHub.$on('postAction', emitSpy);
jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime());
});
- describe('when postAction event is confirmed', () => {
- beforeEach(async () => {
- createComponentWithScheduledJobs({ mountFn: mount });
- clickAndConfirm();
- });
-
- it('emits postAction event', () => {
- expect(confirmAction).toHaveBeenCalled();
- expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath });
- });
-
- it('should render a dropdown button with a loading icon', () => {
- expect(wrapper.findComponent(GlLoadingIcon).isVisible()).toBe(true);
- });
- });
-
- describe('when postAction event is denied', () => {
- beforeEach(async () => {
- createComponentWithScheduledJobs({ mountFn: mount });
- clickAndConfirm({ confirm: false });
- });
-
- it('does not emit postAction event if confirmation is cancelled', () => {
- expect(confirmAction).toHaveBeenCalled();
- expect(emitSpy).not.toHaveBeenCalled();
- });
- });
-
it('displays the remaining time in the dropdown', () => {
+ confirmAction.mockResolvedValueOnce(true);
createComponentWithScheduledJobs();
expect(findDropdownItem(scheduledJobAction).text()).toContain('24:00:00');
});
it('displays 00:00:00 for expired jobs in the dropdown', () => {
+ confirmAction.mockResolvedValueOnce(true);
createComponentWithScheduledJobs();
expect(findDropdownItem(expiredJobAction).text()).toContain('00:00:00');
});
});
-
- describe('graphql', () => {
- Vue.use(VueApollo);
-
- const action = {
- name: 'bar',
- play_path: 'https://gitlab.com/play',
- };
-
- let mockApollo;
-
- beforeEach(() => {
- mockApollo = createMockApollo();
- createComponent(
- { actions: [action], graphql: true },
- { options: { apolloProvider: mockApollo } },
- );
- });
-
- it('should trigger a graphql mutation on click', () => {
- jest.spyOn(mockApollo.defaultClient, 'mutate');
- findDropdownItem(action).vm.$emit('click');
- expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({
- mutation: actionMutation,
- variables: { action },
- });
- });
- });
});
diff --git a/spec/frontend/environments/environment_details/components/deployment_actions_spec.js b/spec/frontend/environments/environment_details/components/deployment_actions_spec.js
index 725c8c6479e..a0eb4c494e6 100644
--- a/spec/frontend/environments/environment_details/components/deployment_actions_spec.js
+++ b/spec/frontend/environments/environment_details/components/deployment_actions_spec.js
@@ -1,8 +1,15 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlButton } from '@gitlab/ui';
import DeploymentActions from '~/environments/environment_details/components/deployment_actions.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { translations } from '~/environments/environment_details/constants';
import ActionsComponent from '~/environments/components/environment_actions.vue';
describe('~/environments/environment_details/components/deployment_actions.vue', () => {
+ Vue.use(VueApollo);
let wrapper;
const actionsData = [
@@ -14,34 +21,116 @@ describe('~/environments/environment_details/components/deployment_actions.vue',
},
];
- const createWrapper = ({ actions }) => {
+ const rollbackData = {
+ id: '123',
+ name: 'enironment-name',
+ lastDeployment: {
+ commit: {
+ shortSha: 'abcd1234',
+ },
+ isLast: true,
+ },
+ retryUrl: 'deployment/retry',
+ };
+
+ const mockSetEnvironmentToRollback = jest.fn();
+ const mockResolvers = {
+ Mutation: {
+ setEnvironmentToRollback: mockSetEnvironmentToRollback,
+ },
+ };
+ const createWrapper = ({ actions, rollback, approvalEnvironment }) => {
+ const mockApollo = createMockApollo([], mockResolvers);
return mountExtended(DeploymentActions, {
+ apolloProvider: mockApollo,
+ provide: {
+ projectPath: 'fullProjectPath',
+ },
propsData: {
actions,
+ rollback,
+ approvalEnvironment,
},
});
};
- describe('when there is no actions provided', () => {
- beforeEach(() => {
- wrapper = createWrapper({ actions: [] });
+ const findRollbackButton = () => wrapper.findComponent(GlButton);
+
+ describe('deployment actions', () => {
+ describe('when there is no actions provided', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({ actions: [] });
+ });
+
+ it('should not render actions component', () => {
+ const actionsComponent = wrapper.findComponent(ActionsComponent);
+ expect(actionsComponent.exists()).toBe(false);
+ });
});
- it('should not render actions component', () => {
- const actionsComponent = wrapper.findComponent(ActionsComponent);
- expect(actionsComponent.exists()).toBe(false);
+ describe('when there are actions provided', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({ actions: actionsData });
+ });
+
+ it('should render actions component', () => {
+ const actionsComponent = wrapper.findComponent(ActionsComponent);
+ expect(actionsComponent.exists()).toBe(true);
+ expect(actionsComponent.props().actions).toBe(actionsData);
+ });
});
});
- describe('when there are actions provided', () => {
- beforeEach(() => {
- wrapper = createWrapper({ actions: actionsData });
+ describe('rollback action', () => {
+ describe('when there is no rollback data available', () => {
+ it('should not show a rollback button', () => {
+ wrapper = createWrapper({ actions: [] });
+ const button = findRollbackButton();
+ expect(button.exists()).toBe(false);
+ });
});
- it('should render actions component', () => {
- const actionsComponent = wrapper.findComponent(ActionsComponent);
- expect(actionsComponent.exists()).toBe(true);
- expect(actionsComponent.props().actions).toBe(actionsData);
- });
+ describe.each([
+ { isLast: true, buttonTitle: translations.redeployButtonTitle, icon: 'repeat' },
+ { isLast: false, buttonTitle: translations.rollbackButtonTitle, icon: 'redo' },
+ ])(
+ `when there is a rollback data available and the deployment isLast=$isLast`,
+ ({ isLast, buttonTitle, icon }) => {
+ let rollback;
+ beforeEach(() => {
+ const lastDeployment = { ...rollbackData.lastDeployment, isLast };
+ rollback = { ...rollbackData };
+ rollback.lastDeployment = lastDeployment;
+ wrapper = createWrapper({ actions: [], rollback });
+ });
+
+ it('should show the rollback button', () => {
+ const button = findRollbackButton();
+ expect(button.exists()).toBe(true);
+ });
+
+ it(`the rollback button should have "${icon}" icon`, () => {
+ const button = findRollbackButton();
+ expect(button.props().icon).toBe(icon);
+ });
+
+ it(`the rollback button should have "${buttonTitle}" title`, () => {
+ const button = findRollbackButton();
+ expect(button.attributes().title).toBe(buttonTitle);
+ });
+
+ it(`the rollback button click should send correct mutation`, async () => {
+ const button = findRollbackButton();
+ button.vm.$emit('click');
+ await waitForPromises();
+ expect(mockSetEnvironmentToRollback).toHaveBeenCalledWith(
+ expect.anything(),
+ { environment: rollback },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ },
+ );
});
});
diff --git a/spec/frontend/environments/environment_details/deployments_table_spec.js b/spec/frontend/environments/environment_details/deployments_table_spec.js
new file mode 100644
index 00000000000..7dad5617383
--- /dev/null
+++ b/spec/frontend/environments/environment_details/deployments_table_spec.js
@@ -0,0 +1,58 @@
+import resolvedEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.json';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import Commit from '~/vue_shared/components/commit.vue';
+import DeploymentStatusLink from '~/environments/environment_details/components/deployment_status_link.vue';
+import DeploymentJob from '~/environments/environment_details/components/deployment_job.vue';
+import DeploymentTriggerer from '~/environments/environment_details/components/deployment_triggerer.vue';
+import DeploymentActions from '~/environments/environment_details/components/deployment_actions.vue';
+import DeploymentsTable from '~/environments/environment_details/deployments_table.vue';
+import { convertToDeploymentTableRow } from '~/environments/helpers/deployment_data_transformation_helper';
+
+const { environment } = resolvedEnvironmentDetails.data.project;
+const deployments = environment.deployments.nodes.map((d) =>
+ convertToDeploymentTableRow(d, environment),
+);
+
+describe('~/environments/environment_details/index.vue', () => {
+ let wrapper;
+
+ const createWrapper = (propsData = {}) => {
+ wrapper = mountExtended(DeploymentsTable, {
+ propsData: {
+ deployments,
+ ...propsData,
+ },
+ });
+ };
+
+ describe('deployment row', () => {
+ const [, , deployment] = deployments;
+
+ let row;
+
+ beforeEach(() => {
+ createWrapper();
+
+ row = wrapper.find('tr:nth-child(3)');
+ });
+
+ it.each`
+ cell | component | props
+ ${'status'} | ${DeploymentStatusLink} | ${{ deploymentJob: deployment.job, status: deployment.status }}
+ ${'triggerer'} | ${DeploymentTriggerer} | ${{ triggerer: deployment.triggerer }}
+ ${'commit'} | ${Commit} | ${deployment.commit}
+ ${'job'} | ${DeploymentJob} | ${{ job: deployment.job }}
+ ${'created date'} | ${'[data-testid="deployment-created-at"]'} | ${{ time: deployment.created }}
+ ${'deployed date'} | ${'[data-testid="deployment-deployed-at"]'} | ${{ time: deployment.deployed }}
+ ${'deployment actions'} | ${DeploymentActions} | ${{ actions: deployment.actions, rollback: deployment.rollback, approvalEnvironment: deployment.deploymentApproval }}
+ `('should show the correct component for $cell', ({ component, props }) => {
+ expect(row.findComponent(component).props()).toMatchObject(props);
+ });
+
+ it('hides the deployed at timestamp for not-finished deployments', () => {
+ row = wrapper.find('tr');
+
+ expect(row.find('[data-testid="deployment-deployed-at"]').exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/environments/environment_details/index_spec.js b/spec/frontend/environments/environment_details/index_spec.js
new file mode 100644
index 00000000000..4bf5194b86e
--- /dev/null
+++ b/spec/frontend/environments/environment_details/index_spec.js
@@ -0,0 +1,109 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlLoadingIcon, GlTableLite } from '@gitlab/ui';
+import resolvedEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.json';
+import emptyEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.empty.json';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import EnvironmentsDetailPage from '~/environments/environment_details/index.vue';
+import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
+import EmptyState from '~/environments/environment_details/empty_state.vue';
+import getEnvironmentDetails from '~/environments/graphql/queries/environment_details.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+const GRAPHQL_ETAG_KEY = '/graphql/environments';
+
+describe('~/environments/environment_details/index.vue', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+ let routerMock;
+
+ const emptyEnvironmentToRollbackData = { id: '', name: '', lastDeployment: null, retryUrl: '' };
+ const environmentToRollbackMock = jest.fn();
+
+ const mockResolvers = {
+ Query: {
+ environmentToRollback: environmentToRollbackMock,
+ },
+ };
+
+ const defaultWrapperParameters = {
+ resolvedData: resolvedEnvironmentDetails,
+ environmentToRollbackData: emptyEnvironmentToRollbackData,
+ };
+
+ const createWrapper = ({
+ resolvedData,
+ environmentToRollbackData,
+ } = defaultWrapperParameters) => {
+ const mockApollo = createMockApollo(
+ [[getEnvironmentDetails, jest.fn().mockResolvedValue(resolvedData)]],
+ mockResolvers,
+ );
+ environmentToRollbackMock.mockReturnValue(
+ environmentToRollbackData || emptyEnvironmentToRollbackData,
+ );
+ const projectFullPath = 'gitlab-group/test-project';
+ routerMock = {
+ push: jest.fn(),
+ };
+
+ return mountExtended(EnvironmentsDetailPage, {
+ apolloProvider: mockApollo,
+ provide: {
+ projectPath: projectFullPath,
+ graphqlEtagKey: GRAPHQL_ETAG_KEY,
+ },
+ propsData: {
+ projectFullPath,
+ environmentName: 'test-environment-name',
+ },
+ mocks: {
+ $router: routerMock,
+ },
+ });
+ };
+
+ describe('when fetching data', () => {
+ it('should show a loading indicator', () => {
+ wrapper = createWrapper();
+
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.findComponent(GlTableLite).exists()).not.toBe(true);
+ });
+ });
+
+ describe('when data is fetched', () => {
+ describe('and there are deployments', () => {
+ beforeEach(async () => {
+ wrapper = createWrapper();
+ await waitForPromises();
+ });
+ it('should render a table when query is loaded', () => {
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).not.toBe(true);
+ expect(wrapper.findComponent(GlTableLite).exists()).toBe(true);
+ });
+
+ describe('on rollback', () => {
+ it('sets the page back to default', () => {
+ wrapper.findComponent(ConfirmRollbackModal).vm.$emit('rollback');
+
+ expect(routerMock.push).toHaveBeenCalledWith({ query: {} });
+ });
+ });
+ });
+
+ describe('and there are no deployments', () => {
+ beforeEach(async () => {
+ wrapper = createWrapper({ resolvedData: emptyEnvironmentDetails });
+ await waitForPromises();
+ });
+
+ it('should render empty state component', () => {
+ expect(wrapper.findComponent(GlTableLite).exists()).toBe(false);
+ expect(wrapper.findComponent(EmptyState).exists()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/environments/environment_details/page_spec.js b/spec/frontend/environments/environment_details/page_spec.js
deleted file mode 100644
index 3a1a3238abe..00000000000
--- a/spec/frontend/environments/environment_details/page_spec.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { GlLoadingIcon, GlTableLite } from '@gitlab/ui';
-import resolvedEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.json';
-import emptyEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.empty.json';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import EnvironmentsDetailPage from '~/environments/environment_details/index.vue';
-import EmptyState from '~/environments/environment_details/empty_state.vue';
-import getEnvironmentDetails from '~/environments/graphql/queries/environment_details.query.graphql';
-import createMockApollo from '../../__helpers__/mock_apollo_helper';
-import waitForPromises from '../../__helpers__/wait_for_promises';
-
-describe('~/environments/environment_details/page.vue', () => {
- Vue.use(VueApollo);
-
- let wrapper;
-
- const defaultWrapperParameters = {
- resolvedData: resolvedEnvironmentDetails,
- };
-
- const createWrapper = ({ resolvedData } = defaultWrapperParameters) => {
- const mockApollo = createMockApollo([
- [getEnvironmentDetails, jest.fn().mockResolvedValue(resolvedData)],
- ]);
-
- return mountExtended(EnvironmentsDetailPage, {
- apolloProvider: mockApollo,
- propsData: {
- projectFullPath: 'gitlab-group/test-project',
- environmentName: 'test-environment-name',
- },
- });
- };
-
- describe('when fetching data', () => {
- it('should show a loading indicator', () => {
- wrapper = createWrapper();
-
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
- expect(wrapper.findComponent(GlTableLite).exists()).not.toBe(true);
- });
- });
-
- describe('when data is fetched', () => {
- describe('and there are deployments', () => {
- beforeEach(async () => {
- wrapper = createWrapper();
- await waitForPromises();
- });
- it('should render a table when query is loaded', async () => {
- expect(wrapper.findComponent(GlLoadingIcon).exists()).not.toBe(true);
- expect(wrapper.findComponent(GlTableLite).exists()).toBe(true);
- });
- });
-
- describe('and there are no deployments', () => {
- beforeEach(async () => {
- wrapper = createWrapper({ resolvedData: emptyEnvironmentDetails });
- await waitForPromises();
- });
-
- it('should render empty state component', async () => {
- expect(wrapper.findComponent(GlTableLite).exists()).toBe(false);
- expect(wrapper.findComponent(EmptyState).exists()).toBe(true);
- });
- });
- });
-});
diff --git a/spec/frontend/environments/environment_external_url_spec.js b/spec/frontend/environments/environment_external_url_spec.js
index 5966993166b..2cccbb3b63c 100644
--- a/spec/frontend/environments/environment_external_url_spec.js
+++ b/spec/frontend/environments/environment_external_url_spec.js
@@ -1,35 +1,18 @@
import { mount } from '@vue/test-utils';
-import { s__, __ } from '~/locale';
+import { GlButton } from '@gitlab/ui';
import ExternalUrlComp from '~/environments/components/environment_external_url.vue';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
describe('External URL Component', () => {
let wrapper;
- let externalUrl;
+ const externalUrl = 'https://gitlab.com';
- describe('with safe link', () => {
- beforeEach(() => {
- externalUrl = 'https://gitlab.com';
- wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } });
- });
-
- it('should link to the provided externalUrl prop', () => {
- expect(wrapper.attributes('href')).toBe(externalUrl);
- expect(wrapper.find('a').exists()).toBe(true);
- });
+ beforeEach(() => {
+ wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } });
});
- describe('with unsafe link', () => {
- beforeEach(() => {
- externalUrl = 'postgres://gitlab';
- wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } });
- });
-
- it('should show a copy button instead', () => {
- const button = wrapper.findComponent(ModalCopyButton);
- expect(button.props('text')).toBe(externalUrl);
- expect(button.text()).toBe(__('Copy URL'));
- expect(button.props('title')).toBe(s__('Environments|Copy live environment URL'));
- });
+ it('should link to the provided externalUrl prop', () => {
+ const button = wrapper.findComponent(GlButton);
+ expect(button.attributes('href')).toEqual(externalUrl);
+ expect(button.props('isUnsafeLink')).toBe(true);
});
});
diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js
index a37515bc3f7..4716f807657 100644
--- a/spec/frontend/environments/environment_folder_spec.js
+++ b/spec/frontend/environments/environment_folder_spec.js
@@ -35,10 +35,10 @@ describe('~/environments/components/environments_folder.vue', () => {
...propsData,
},
stubs: { transition: stubTransition() },
- provide: { helpPagePath: '/help' },
+ provide: { helpPagePath: '/help', projectId: '1' },
});
- beforeEach(async () => {
+ beforeEach(() => {
environmentFolderMock = jest.fn();
[nestedEnvironment] = resolvedEnvironmentsApp.environments;
environmentFolderMock.mockReturnValue(resolvedFolder);
diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js
index b9b34bee80f..50e4e637aa3 100644
--- a/spec/frontend/environments/environment_form_spec.js
+++ b/spec/frontend/environments/environment_form_spec.js
@@ -15,19 +15,16 @@ const PROVIDE = { protectedEnvironmentSettingsPath: '/projects/not_real/settings
describe('~/environments/components/form.vue', () => {
let wrapper;
- const createWrapper = (propsData = {}) =>
+ const createWrapper = (propsData = {}, options = {}) =>
mountExtended(EnvironmentForm, {
provide: PROVIDE,
+ ...options,
propsData: {
...DEFAULT_PROPS,
...propsData,
},
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
wrapper = createWrapper();
@@ -105,6 +102,7 @@ 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({
@@ -133,6 +131,18 @@ describe('~/environments/components/form.vue', () => {
});
});
+ describe('when no protected environment link is provided', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ provide: {},
+ });
+ });
+
+ it('does not show protected environment documentation', () => {
+ expect(wrapper.findByRole('link', { name: 'Protected environments' }).exists()).toBe(false);
+ });
+ });
+
describe('when an existing environment is being edited', () => {
beforeEach(() => {
wrapper = createWrapper({
diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js
index dd909cf4473..e2b184adc8a 100644
--- a/spec/frontend/environments/environment_item_spec.js
+++ b/spec/frontend/environments/environment_item_spec.js
@@ -19,10 +19,6 @@ describe('Environment item', () => {
let tracking;
const factory = (options = {}) => {
- // This destroys any wrappers created before a nested call to factory reassigns it
- if (wrapper && wrapper.destroy) {
- wrapper.destroy();
- }
wrapper = mount(EnvironmentItem, {
...options,
});
@@ -55,10 +51,7 @@ describe('Environment item', () => {
const findUpcomingDeploymentAvatarLink = () =>
findUpcomingDeployment().findComponent(GlAvatarLink);
const findUpcomingDeploymentAvatar = () => findUpcomingDeployment().findComponent(GlAvatar);
-
- afterEach(() => {
- wrapper.destroy();
- });
+ const findMonitoringLink = () => wrapper.find('[data-testid="environment-monitoring"]');
describe('when item is not folder', () => {
it('should render environment name', () => {
@@ -390,10 +383,6 @@ describe('Environment item', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render folder icon and name', () => {
expect(wrapper.find('.folder-name').text()).toContain(folder.name);
expect(wrapper.find('.folder-icon')).toBeDefined();
@@ -446,4 +435,25 @@ describe('Environment item', () => {
});
});
});
+
+ describe.each([true, false])(
+ 'when `remove_monitor_metrics` flag is %p',
+ (removeMonitorMetrics) => {
+ beforeEach(() => {
+ factory({
+ propsData: {
+ model: {
+ metrics_path: 'http://0.0.0.0:3000/flightjs/Flight/-/metrics?environment=6',
+ },
+ tableData,
+ },
+ provide: { glFeatures: { removeMonitorMetrics } },
+ });
+ });
+
+ it(`${removeMonitorMetrics ? 'does not render' : 'renders'} link to metrics`, () => {
+ expect(findMonitoringLink().exists()).toBe(!removeMonitorMetrics);
+ });
+ },
+ );
});
diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js
index 170036b5b00..ee195b41bc8 100644
--- a/spec/frontend/environments/environment_pin_spec.js
+++ b/spec/frontend/environments/environment_pin_spec.js
@@ -11,10 +11,6 @@ describe('Pin Component', () => {
let wrapper;
const factory = (options = {}) => {
- // This destroys any wrappers created before a nested call to factory reassigns it
- if (wrapper && wrapper.destroy) {
- wrapper.destroy();
- }
wrapper = shallowMount(PinComponent, {
...options,
});
@@ -31,10 +27,6 @@ describe('Pin Component', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render the component with descriptive text', () => {
expect(wrapper.text()).toBe('Prevent auto-stopping');
});
@@ -64,10 +56,6 @@ describe('Pin Component', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render the component with descriptive text', () => {
expect(wrapper.text()).toBe('Prevent auto-stopping');
});
diff --git a/spec/frontend/environments/environment_stop_spec.js b/spec/frontend/environments/environment_stop_spec.js
index 851e24c22cc..3e27b8822e1 100644
--- a/spec/frontend/environments/environment_stop_spec.js
+++ b/spec/frontend/environments/environment_stop_spec.js
@@ -73,7 +73,7 @@ describe('Stop Component', () => {
});
});
- it('should show a loading icon if the environment is currently stopping', async () => {
+ it('should show a loading icon if the environment is currently stopping', () => {
expect(findButton().props('loading')).toBe(true);
});
});
diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js
index a86cfdd56ba..f41d1324b81 100644
--- a/spec/frontend/environments/environment_table_spec.js
+++ b/spec/frontend/environments/environment_table_spec.js
@@ -16,10 +16,6 @@ describe('Environment table', () => {
let wrapper;
const factory = (options = {}) => {
- // This destroys any wrappers created before a nested call to factory reassigns it
- if (wrapper && wrapper.destroy) {
- wrapper.destroy();
- }
wrapper = mount(EnvironmentTable, {
...options,
});
@@ -34,10 +30,6 @@ describe('Environment table', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('Should render a table', async () => {
const mockItem = {
name: 'review',
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index 986ecca4e84..dc450eb2aa7 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -96,10 +96,6 @@ describe('~/environments/components/environments_app.vue', () => {
paginationMock = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should request available environments if the scope is invalid', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
@@ -174,12 +170,8 @@ describe('~/environments/components/environments_app.vue', () => {
folder: resolvedFolder,
});
- const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
- button.trigger('click');
-
- await nextTick();
-
- expect(wrapper.findByText(s__('ReviewApp|Enable Review App')).exists()).toBe(true);
+ const button = wrapper.findByRole('button', { name: s__('Environments|Enable review apps') });
+ expect(button.exists()).toBe(true);
});
it('should not show a button to open the review app modal if review apps are configured', async () => {
@@ -191,7 +183,7 @@ describe('~/environments/components/environments_app.vue', () => {
folder: resolvedFolder,
});
- const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
+ const button = wrapper.findByRole('button', { name: s__('Environments|Enable review apps') });
expect(button.exists()).toBe(false);
});
@@ -426,7 +418,7 @@ describe('~/environments/components/environments_app.vue', () => {
);
});
- it('should sync search term from query params on load', async () => {
+ it('should sync search term from query params on load', () => {
expect(searchBox.element.value).toBe('prod');
});
});
diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js
index 1f233c05fbf..9464aeff028 100644
--- a/spec/frontend/environments/environments_detail_header_spec.js
+++ b/spec/frontend/environments/environments_detail_header_spec.js
@@ -1,12 +1,11 @@
import { GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import { __, s__ } from '~/locale';
import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue';
import EnvironmentsDetailHeader from '~/environments/components/environments_detail_header.vue';
import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import DeployFreezeAlert from '~/environments/components/deploy_freeze_alert.vue';
import { createEnvironment } from './mock_data';
describe('Environments detail header component', () => {
@@ -22,13 +21,14 @@ describe('Environments detail header component', () => {
const findCancelAutoStopAtButton = () => wrapper.findByTestId('cancel-auto-stop-button');
const findCancelAutoStopAtForm = () => wrapper.findByTestId('cancel-auto-stop-form');
const findTerminalButton = () => wrapper.findByTestId('terminal-button');
- const findExternalUrlButton = () => wrapper.findByTestId('external-url-button');
+ const findExternalUrlButton = () => wrapper.findComponentByTestId('external-url-button');
const findMetricsButton = () => wrapper.findByTestId('metrics-button');
const findEditButton = () => wrapper.findByTestId('edit-button');
const findStopButton = () => wrapper.findByTestId('stop-button');
const findDestroyButton = () => wrapper.findByTestId('destroy-button');
const findStopEnvironmentModal = () => wrapper.findComponent(StopEnvironmentModal);
const findDeleteEnvironmentModal = () => wrapper.findComponent(DeleteEnvironmentModal);
+ const findDeployFreezeAlert = () => wrapper.findComponent(DeployFreezeAlert);
const buttons = [
['Cancel Auto Stop At', findCancelAutoStopAtButton],
@@ -40,14 +40,17 @@ describe('Environments detail header component', () => {
['Destroy', findDestroyButton],
];
- const createWrapper = ({ props }) => {
+ const createWrapper = ({ props, glFeatures = {} }) => {
wrapper = shallowMountExtended(EnvironmentsDetailHeader, {
stubs: {
GlSprintf,
TimeAgo,
},
+ provide: {
+ glFeatures,
+ },
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
propsData: {
canAdminEnvironment: false,
@@ -59,10 +62,6 @@ describe('Environments detail header component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default state with minimal access', () => {
beforeEach(() => {
createWrapper({ props: { environment: createEnvironment({ externalUrl: null }) } });
@@ -175,6 +174,7 @@ describe('Environments detail header component', () => {
it('displays the external url button with correct path', () => {
expect(findExternalUrlButton().attributes('href')).toBe(externalUrl);
+ expect(findExternalUrlButton().props('isUnsafeLink')).toBe(true);
});
});
@@ -199,6 +199,25 @@ describe('Environments detail header component', () => {
expect(tooltip).toBeDefined();
expect(button.attributes('title')).toBe('See metrics');
});
+
+ describe.each([true, false])(
+ 'and `remove_monitor_metrics` flag is %p',
+ (removeMonitorMetrics) => {
+ beforeEach(() => {
+ createWrapper({
+ props: {
+ environment: createEnvironment({ metricsUrl: 'my metrics url' }),
+ metricsPath,
+ },
+ glFeatures: { removeMonitorMetrics },
+ });
+ });
+
+ it(`${removeMonitorMetrics ? 'does not render' : 'renders'} Metrics button`, () => {
+ expect(findMetricsButton().exists()).toBe(!removeMonitorMetrics);
+ });
+ },
+ );
});
describe('when has all admin rights', () => {
@@ -246,22 +265,12 @@ describe('Environments detail header component', () => {
});
});
- describe('when the environment has an unsafe external url', () => {
- const externalUrl = 'postgres://staging';
-
- beforeEach(() => {
- createWrapper({
- props: {
- environment: createEnvironment({ externalUrl }),
- },
- });
- });
+ describe('deploy freeze alert', () => {
+ it('passes the environment name to the alert', () => {
+ const environment = createEnvironment();
+ createWrapper({ props: { environment } });
- it('should show a copy button instead', () => {
- const button = wrapper.findComponent(ModalCopyButton);
- expect(button.props('title')).toBe(s__('Environments|Copy live environment URL'));
- expect(button.props('text')).toBe(externalUrl);
- expect(button.text()).toBe(__('Copy URL'));
+ expect(findDeployFreezeAlert().props('name')).toBe(environment.name);
});
});
});
diff --git a/spec/frontend/environments/environments_folder_view_spec.js b/spec/frontend/environments/environments_folder_view_spec.js
index a87060f83d8..75fb3a31120 100644
--- a/spec/frontend/environments/environments_folder_view_spec.js
+++ b/spec/frontend/environments/environments_folder_view_spec.js
@@ -24,7 +24,6 @@ describe('Environments Folder View', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('successful request', () => {
diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js
index 23506eb018d..6a40c68397b 100644
--- a/spec/frontend/environments/folder/environments_folder_view_spec.js
+++ b/spec/frontend/environments/folder/environments_folder_view_spec.js
@@ -123,22 +123,4 @@ describe('Environments Folder View', () => {
expect(tabTable.find('.badge').text()).toContain('0');
});
});
-
- describe('methods', () => {
- beforeEach(() => {
- mockEnvironments([]);
- createWrapper();
- jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
- return axios.waitForAll();
- });
-
- describe('updateContent', () => {
- it('should set given parameters', () =>
- wrapper.vm.updateContent({ scope: 'stopped', page: '4' }).then(() => {
- expect(wrapper.vm.page).toEqual('4');
- expect(wrapper.vm.scope).toEqual('stopped');
- expect(wrapper.vm.requestData.page).toEqual('4');
- }));
- });
- });
});
diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js
index 5ea0be41614..addbf2c21dc 100644
--- a/spec/frontend/environments/graphql/mock_data.js
+++ b/spec/frontend/environments/graphql/mock_data.js
@@ -798,3 +798,112 @@ export const resolvedDeploymentDetails = {
},
},
};
+
+export const agent = {
+ project: 'agent-project',
+ id: 'gid://gitlab/ClusterAgent/1',
+ name: 'agent-name',
+ kubernetesNamespace: 'agent-namespace',
+};
+
+const runningPod = { status: { phase: 'Running' } };
+const pendingPod = { status: { phase: 'Pending' } };
+const succeededPod = { status: { phase: 'Succeeded' } };
+const failedPod = { status: { phase: 'Failed' } };
+
+export const k8sPodsMock = [runningPod, runningPod, pendingPod, succeededPod, failedPod, failedPod];
+
+export const k8sServicesMock = [
+ {
+ metadata: {
+ name: 'my-first-service',
+ namespace: 'default',
+ creationTimestamp: new Date(),
+ },
+ spec: {
+ ports: [
+ {
+ name: 'https',
+ protocol: 'TCP',
+ port: 443,
+ targetPort: 8443,
+ },
+ ],
+ clusterIP: '10.96.0.1',
+ externalIP: '-',
+ type: 'ClusterIP',
+ },
+ },
+ {
+ metadata: {
+ name: 'my-second-service',
+ namespace: 'default',
+ creationTimestamp: '2020-07-03T14:06:04Z',
+ },
+ spec: {
+ ports: [
+ {
+ name: 'http',
+ protocol: 'TCP',
+ appProtocol: 'http',
+ port: 80,
+ targetPort: 'http',
+ nodePort: 31989,
+ },
+ {
+ name: 'https',
+ protocol: 'TCP',
+ appProtocol: 'https',
+ port: 443,
+ targetPort: 'https',
+ nodePort: 32679,
+ },
+ ],
+ clusterIP: '10.105.219.238',
+ externalIP: '-',
+ type: 'NodePort',
+ },
+ },
+];
+
+const readyDeployment = {
+ status: {
+ conditions: [
+ { type: 'Available', status: 'True' },
+ { type: 'Progressing', status: 'True' },
+ ],
+ },
+};
+const failedDeployment = {
+ status: {
+ conditions: [
+ { type: 'Available', status: 'False' },
+ { type: 'Progressing', status: 'False' },
+ ],
+ },
+};
+const readyDaemonSet = {
+ status: { numberReady: 1, desiredNumberScheduled: 1, numberMisscheduled: 0 },
+};
+const failedDaemonSet = {
+ status: { numberMisscheduled: 1, numberReady: 0, desiredNumberScheduled: 1 },
+};
+const readySet = { spec: { replicas: 2 }, status: { readyReplicas: 2 } };
+const failedSet = { spec: { replicas: 2 }, status: { readyReplicas: 1 } };
+const completedJob = { spec: { completions: 1 }, status: { succeeded: 1, failed: 0 } };
+const failedJob = { spec: { completions: 1 }, status: { succeeded: 0, failed: 1 } };
+const completedCronJob = {
+ spec: { suspend: 0 },
+ status: { active: 0, lastScheduleTime: new Date().toString() },
+};
+const suspendedCronJob = { spec: { suspend: 1 }, status: { active: 0, lastScheduleTime: '' } };
+const failedCronJob = { spec: { suspend: 0 }, status: { active: 2, lastScheduleTime: '' } };
+
+export const k8sWorkloadsMock = {
+ DeploymentList: [readyDeployment, failedDeployment],
+ DaemonSetList: [readyDaemonSet, failedDaemonSet, failedDaemonSet],
+ StatefulSetList: [readySet, readySet, failedSet],
+ ReplicaSetList: [readySet, failedSet],
+ JobList: [completedJob, completedJob, failedJob],
+ CronJobList: [completedCronJob, suspendedCronJob, failedCronJob],
+};
diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js
index 2c223d3a1a7..edffc00e185 100644
--- a/spec/frontend/environments/graphql/resolvers_spec.js
+++ b/spec/frontend/environments/graphql/resolvers_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { CoreV1Api, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client';
import { s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
@@ -17,6 +18,8 @@ import {
resolvedEnvironment,
folder,
resolvedFolder,
+ k8sPodsMock,
+ k8sServicesMock,
} from './mock_data';
const ENDPOINT = `${TEST_HOST}/environments`;
@@ -27,6 +30,14 @@ describe('~/frontend/environments/graphql/resolvers', () => {
let mockApollo;
let localState;
+ const configuration = {
+ basePath: 'kas-proxy/',
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+ const namespace = 'default';
+
beforeEach(() => {
mockResolvers = resolvers(ENDPOINT);
mock = new MockAdapter(axios);
@@ -143,13 +154,178 @@ describe('~/frontend/environments/graphql/resolvers', () => {
expect(environmentFolder).toEqual(resolvedFolder);
});
});
- describe('stopEnvironment', () => {
+ describe('k8sPods', () => {
+ const mockPodsListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ data: {
+ items: k8sPodsMock,
+ },
+ });
+ });
+
+ const mockNamespacedPodsListFn = jest.fn().mockImplementation(mockPodsListFn);
+ const mockAllPodsListFn = jest.fn().mockImplementation(mockPodsListFn);
+
+ beforeEach(() => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedPod')
+ .mockImplementation(mockNamespacedPodsListFn);
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces')
+ .mockImplementation(mockAllPodsListFn);
+ });
+
+ it('should request namespaced pods from the cluster_client library if namespace is specified', async () => {
+ const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace });
+
+ expect(mockNamespacedPodsListFn).toHaveBeenCalledWith(namespace);
+ expect(mockAllPodsListFn).not.toHaveBeenCalled();
+
+ expect(pods).toEqual(k8sPodsMock);
+ });
+ it('should request all pods from the cluster_client library if namespace is not specified', async () => {
+ const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace: '' });
+
+ expect(mockAllPodsListFn).toHaveBeenCalled();
+ expect(mockNamespacedPodsListFn).not.toHaveBeenCalled();
+
+ expect(pods).toEqual(k8sPodsMock);
+ });
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(mockResolvers.Query.k8sPods(null, { configuration })).rejects.toThrow(
+ 'API error',
+ );
+ });
+ });
+ describe('k8sServices', () => {
+ const mockServicesListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ data: {
+ items: k8sServicesMock,
+ },
+ });
+ });
+
+ beforeEach(() => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
+ .mockImplementation(mockServicesListFn);
+ });
+
+ it('should request services from the cluster_client library', async () => {
+ const services = await mockResolvers.Query.k8sServices(null, { configuration });
+
+ expect(mockServicesListFn).toHaveBeenCalled();
+
+ expect(services).toEqual(k8sServicesMock);
+ });
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(mockResolvers.Query.k8sServices(null, { configuration })).rejects.toThrow(
+ 'API error',
+ );
+ });
+ });
+ describe('k8sWorkloads', () => {
+ const emptyImplementation = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ data: {
+ items: [],
+ },
+ });
+ });
+
+ const [
+ mockNamespacedDeployment,
+ mockNamespacedDaemonSet,
+ mockNamespacedStatefulSet,
+ mockNamespacedReplicaSet,
+ mockNamespacedJob,
+ mockNamespacedCronJob,
+ mockAllDeployment,
+ mockAllDaemonSet,
+ mockAllStatefulSet,
+ mockAllReplicaSet,
+ mockAllJob,
+ mockAllCronJob,
+ ] = Array(12).fill(emptyImplementation);
+
+ const namespacedMocks = [
+ { method: 'listAppsV1NamespacedDeployment', api: AppsV1Api, spy: mockNamespacedDeployment },
+ { method: 'listAppsV1NamespacedDaemonSet', api: AppsV1Api, spy: mockNamespacedDaemonSet },
+ { method: 'listAppsV1NamespacedStatefulSet', api: AppsV1Api, spy: mockNamespacedStatefulSet },
+ { method: 'listAppsV1NamespacedReplicaSet', api: AppsV1Api, spy: mockNamespacedReplicaSet },
+ { method: 'listBatchV1NamespacedJob', api: BatchV1Api, spy: mockNamespacedJob },
+ { method: 'listBatchV1NamespacedCronJob', api: BatchV1Api, spy: mockNamespacedCronJob },
+ ];
+
+ const allMocks = [
+ { method: 'listAppsV1DeploymentForAllNamespaces', api: AppsV1Api, spy: mockAllDeployment },
+ { method: 'listAppsV1DaemonSetForAllNamespaces', api: AppsV1Api, spy: mockAllDaemonSet },
+ { method: 'listAppsV1StatefulSetForAllNamespaces', api: AppsV1Api, spy: mockAllStatefulSet },
+ { method: 'listAppsV1ReplicaSetForAllNamespaces', api: AppsV1Api, spy: mockAllReplicaSet },
+ { method: 'listBatchV1JobForAllNamespaces', api: BatchV1Api, spy: mockAllJob },
+ { method: 'listBatchV1CronJobForAllNamespaces', api: BatchV1Api, spy: mockAllCronJob },
+ ];
+
+ beforeEach(() => {
+ [...namespacedMocks, ...allMocks].forEach((workloadMock) => {
+ jest
+ .spyOn(workloadMock.api.prototype, workloadMock.method)
+ .mockImplementation(workloadMock.spy);
+ });
+ });
+
+ it('should request namespaced workload types from the cluster_client library if namespace is specified', async () => {
+ await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace });
+
+ namespacedMocks.forEach((workloadMock) => {
+ expect(workloadMock.spy).toHaveBeenCalledWith(namespace);
+ });
+ });
+
+ it('should request all workload types from the cluster_client library if namespace is not specified', async () => {
+ await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace: '' });
+
+ allMocks.forEach((workloadMock) => {
+ expect(workloadMock.spy).toHaveBeenCalled();
+ });
+ });
+ it('should pass fulfilled calls data if one of the API calls fail', async () => {
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sWorkloads(null, { configuration }),
+ ).resolves.toBeDefined();
+ });
+ it('should throw an error if all the API calls fail', async () => {
+ [...allMocks].forEach((workloadMock) => {
+ jest
+ .spyOn(workloadMock.api.prototype, workloadMock.method)
+ .mockRejectedValue(new Error('API error'));
+ });
+
+ await expect(mockResolvers.Query.k8sWorkloads(null, { configuration })).rejects.toThrow(
+ 'API error',
+ );
+ });
+ });
+ describe('stopEnvironmentREST', () => {
it('should post to the stop environment path', async () => {
mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK);
const client = { writeQuery: jest.fn() };
const environment = { stopPath: ENDPOINT };
- await mockResolvers.Mutation.stopEnvironment(null, { environment }, { client });
+ await mockResolvers.Mutation.stopEnvironmentREST(null, { environment }, { client });
expect(mock.history.post).toContainEqual(
expect.objectContaining({ url: ENDPOINT, method: 'post' }),
@@ -166,7 +342,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
const client = { writeQuery: jest.fn() };
const environment = { stopPath: ENDPOINT };
- await mockResolvers.Mutation.stopEnvironment(null, { environment }, { client });
+ await mockResolvers.Mutation.stopEnvironmentREST(null, { environment }, { client });
expect(mock.history.post).toContainEqual(
expect.objectContaining({ url: ENDPOINT, method: 'post' }),
diff --git a/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap b/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap
index 326a28bd769..ec0fe0c5541 100644
--- a/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap
+++ b/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap
@@ -26,11 +26,37 @@ Object {
},
"created": "2022-10-17T07:44:17Z",
"deployed": "2022-10-17T07:44:43Z",
+ "deploymentApproval": Object {
+ "isApprovalActionAvailable": false,
+ },
"id": "31",
"job": Object {
"label": "deploy-prod (#860)",
"webPath": "/gitlab-org/pipelinestest/-/jobs/860",
},
+ "rollback": Object {
+ "id": "gid://gitlab/Deployment/76",
+ "lastDeployment": Object {
+ "commit": Object {
+ "author": Object {
+ "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png",
+ "id": "gid://gitlab/User/1",
+ "name": "Administrator",
+ "webUrl": "http://gdk.test:3000/root",
+ },
+ "authorEmail": "admin@example.com",
+ "authorGravatar": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "authorName": "Administrator",
+ "id": "gid://gitlab/CommitPresenter/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74",
+ "message": "Update .gitlab-ci.yml file",
+ "shortId": "0cb48dd5",
+ "webUrl": "http://gdk.test:3000/gitlab-org/pipelinestest/-/commit/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74",
+ },
+ "isLast": false,
+ },
+ "name": undefined,
+ "retryUrl": "/gitlab-org/pipelinestest/-/jobs/860/retry",
+ },
"status": "success",
"triggerer": Object {
"avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png",
@@ -60,8 +86,12 @@ Object {
},
"created": "2022-10-17T07:44:17Z",
"deployed": "2022-10-17T07:44:43Z",
+ "deploymentApproval": Object {
+ "isApprovalActionAvailable": false,
+ },
"id": "31",
"job": undefined,
+ "rollback": null,
"status": "success",
"triggerer": Object {
"avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png",
@@ -91,8 +121,12 @@ Object {
},
"created": "2022-10-17T07:44:17Z",
"deployed": "",
+ "deploymentApproval": Object {
+ "isApprovalActionAvailable": false,
+ },
"id": "31",
"job": null,
+ "rollback": null,
"status": "success",
"triggerer": Object {
"avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png",
diff --git a/spec/frontend/environments/kubernetes_agent_info_spec.js b/spec/frontend/environments/kubernetes_agent_info_spec.js
new file mode 100644
index 00000000000..b1795065281
--- /dev/null
+++ b/spec/frontend/environments/kubernetes_agent_info_spec.js
@@ -0,0 +1,124 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlIcon, GlLink, GlSprintf, GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue';
+import { AGENT_STATUSES, ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import getK8sClusterAgentQuery from '~/environments/graphql/queries/k8s_cluster_agent.query.graphql';
+
+Vue.use(VueApollo);
+
+const propsData = {
+ agentName: 'my-agent',
+ agentId: '1',
+ agentProjectPath: 'path/to/agent-config-project',
+};
+
+const mockClusterAgent = {
+ id: '1',
+ name: 'token-1',
+ webPath: 'path/to/agent-page',
+};
+
+const connectedTimeNow = new Date();
+const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNECTION_TIME);
+
+describe('~/environments/components/kubernetes_agent_info.vue', () => {
+ let wrapper;
+ let agentQueryResponse;
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findAgentLink = () => wrapper.findComponent(GlLink);
+ const findAgentStatus = () => wrapper.findByTestId('agent-status');
+ const findAgentStatusIcon = () => findAgentStatus().findComponent(GlIcon);
+ const findAgentLastUsedDate = () => wrapper.findByTestId('agent-last-used-date');
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ const createWrapper = ({ tokens = [], queryResponse = null } = {}) => {
+ const clusterAgent = { ...mockClusterAgent, tokens: { nodes: tokens } };
+
+ agentQueryResponse =
+ queryResponse ||
+ jest.fn().mockResolvedValue({ data: { project: { id: 'project-1', clusterAgent } } });
+ const apolloProvider = createMockApollo([[getK8sClusterAgentQuery, agentQueryResponse]]);
+
+ wrapper = extendedWrapper(
+ shallowMount(KubernetesAgentInfo, {
+ apolloProvider,
+ propsData,
+ stubs: { TimeAgoTooltip, GlSprintf },
+ }),
+ );
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('shows loading icon while fetching the agent details', async () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ await waitForPromises();
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('sends expected params', async () => {
+ await waitForPromises();
+
+ const variables = {
+ agentName: propsData.agentName,
+ projectPath: propsData.agentProjectPath,
+ };
+
+ expect(agentQueryResponse).toHaveBeenCalledWith(variables);
+ });
+
+ it('renders the agent name with the link', async () => {
+ await waitForPromises();
+
+ expect(findAgentLink().attributes('href')).toBe(mockClusterAgent.webPath);
+ expect(findAgentLink().text()).toContain(mockClusterAgent.id);
+ });
+ });
+
+ describe.each`
+ lastUsedAt | status | lastUsedText
+ ${null} | ${'unused'} | ${KubernetesAgentInfo.i18n.neverConnectedText}
+ ${connectedTimeNow} | ${'active'} | ${'just now'}
+ ${connectedTimeInactive} | ${'inactive'} | ${'8 minutes ago'}
+ `('when agent connection status is "$status"', ({ lastUsedAt, status, lastUsedText }) => {
+ beforeEach(async () => {
+ const tokens = [{ id: 'token-id', lastUsedAt }];
+ createWrapper({ tokens });
+ await waitForPromises();
+ });
+
+ it('displays correct status text', () => {
+ expect(findAgentStatus().text()).toBe(AGENT_STATUSES[status].name);
+ });
+
+ it('displays correct status icon', () => {
+ expect(findAgentStatusIcon().props('name')).toBe(AGENT_STATUSES[status].icon);
+ expect(findAgentStatusIcon().attributes('class')).toBe(AGENT_STATUSES[status].class);
+ });
+
+ it('displays correct last used date status', () => {
+ expect(findAgentLastUsedDate().text()).toBe(lastUsedText);
+ });
+ });
+
+ describe('when the agent query has errored', () => {
+ beforeEach(() => {
+ createWrapper({ clusterAgent: null, queryResponse: jest.fn().mockRejectedValue() });
+ return waitForPromises();
+ });
+
+ it('displays an alert message', () => {
+ expect(findAlert().text()).toBe(KubernetesAgentInfo.i18n.loadingError);
+ });
+ });
+});
diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js
new file mode 100644
index 00000000000..394fd200edf
--- /dev/null
+++ b/spec/frontend/environments/kubernetes_overview_spec.js
@@ -0,0 +1,131 @@
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui';
+import KubernetesOverview from '~/environments/components/kubernetes_overview.vue';
+import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue';
+import KubernetesPods from '~/environments/components/kubernetes_pods.vue';
+import KubernetesTabs from '~/environments/components/kubernetes_tabs.vue';
+import { agent } from './graphql/mock_data';
+import { mockKasTunnelUrl } from './mock_data';
+
+const propsData = {
+ agentId: agent.id,
+ agentName: agent.name,
+ agentProjectPath: agent.project,
+ namespace: agent.kubernetesNamespace,
+};
+
+const provide = {
+ kasTunnelUrl: mockKasTunnelUrl,
+};
+
+const configuration = {
+ basePath: provide.kasTunnelUrl.replace(/\/$/, ''),
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+};
+
+describe('~/environments/components/kubernetes_overview.vue', () => {
+ let wrapper;
+
+ const findCollapse = () => wrapper.findComponent(GlCollapse);
+ const findCollapseButton = () => wrapper.findComponent(GlButton);
+ const findAgentInfo = () => wrapper.findComponent(KubernetesAgentInfo);
+ const findKubernetesPods = () => wrapper.findComponent(KubernetesPods);
+ const findKubernetesTabs = () => wrapper.findComponent(KubernetesTabs);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ const createWrapper = () => {
+ wrapper = shallowMount(KubernetesOverview, {
+ propsData,
+ provide,
+ });
+ };
+
+ const toggleCollapse = async () => {
+ findCollapseButton().vm.$emit('click');
+ await nextTick();
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders the kubernetes overview title', () => {
+ expect(wrapper.text()).toBe(KubernetesOverview.i18n.sectionTitle);
+ });
+ });
+
+ describe('collapse', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('is collapsed by default', () => {
+ expect(findCollapse().props('visible')).toBeUndefined();
+ expect(findCollapseButton().attributes('aria-label')).toBe(KubernetesOverview.i18n.expand);
+ expect(findCollapseButton().props('icon')).toBe('chevron-right');
+ });
+
+ it("doesn't render components when the collapse is not visible", () => {
+ expect(findAgentInfo().exists()).toBe(false);
+ expect(findKubernetesPods().exists()).toBe(false);
+ });
+
+ it('opens on click', async () => {
+ findCollapseButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findCollapse().attributes('visible')).toBe('true');
+ expect(findCollapseButton().attributes('aria-label')).toBe(KubernetesOverview.i18n.collapse);
+ expect(findCollapseButton().props('icon')).toBe('chevron-down');
+ });
+ });
+
+ describe('when section is expanded', () => {
+ beforeEach(() => {
+ createWrapper();
+ toggleCollapse();
+ });
+
+ it('renders kubernetes agent info', () => {
+ expect(findAgentInfo().props()).toEqual({
+ agentName: agent.name,
+ agentId: agent.id,
+ agentProjectPath: agent.project,
+ });
+ });
+
+ it('renders kubernetes pods', () => {
+ expect(findKubernetesPods().props()).toEqual({
+ namespace: agent.kubernetesNamespace,
+ configuration,
+ });
+ });
+
+ it('renders kubernetes tabs', () => {
+ expect(findKubernetesTabs().props()).toEqual({
+ namespace: agent.kubernetesNamespace,
+ configuration,
+ });
+ });
+ });
+
+ describe('on cluster error', () => {
+ beforeEach(() => {
+ createWrapper();
+ toggleCollapse();
+ });
+
+ it('shows alert with the error message', async () => {
+ const error = 'Error message from pods';
+
+ findKubernetesPods().vm.$emit('cluster-error', error);
+ await nextTick();
+
+ expect(findAlert().text()).toBe(error);
+ });
+ });
+});
diff --git a/spec/frontend/environments/kubernetes_pods_spec.js b/spec/frontend/environments/kubernetes_pods_spec.js
new file mode 100644
index 00000000000..137309d7853
--- /dev/null
+++ b/spec/frontend/environments/kubernetes_pods_spec.js
@@ -0,0 +1,114 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import KubernetesPods from '~/environments/components/kubernetes_pods.vue';
+import { mockKasTunnelUrl } from './mock_data';
+import { k8sPodsMock } from './graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('~/environments/components/kubernetes_pods.vue', () => {
+ let wrapper;
+
+ const namespace = 'my-kubernetes-namespace';
+ const configuration = {
+ basePath: mockKasTunnelUrl,
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findAllStats = () => wrapper.findAllComponents(GlSingleStat);
+ const findSingleStat = (at) => findAllStats().at(at);
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sPods: jest.fn().mockReturnValue(k8sPodsMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMount(KubernetesPods, {
+ propsData: { namespace, configuration },
+ apolloProvider,
+ });
+ };
+
+ describe('mounted', () => {
+ it('shows the loading icon', () => {
+ createWrapper();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('hides the loading icon when the list of pods loaded', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('when gets pods data', () => {
+ it('renders stats', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findAllStats()).toHaveLength(4);
+ });
+
+ it.each`
+ count | title | index
+ ${2} | ${KubernetesPods.i18n.runningPods} | ${0}
+ ${1} | ${KubernetesPods.i18n.pendingPods} | ${1}
+ ${1} | ${KubernetesPods.i18n.succeededPods} | ${2}
+ ${2} | ${KubernetesPods.i18n.failedPods} | ${3}
+ `(
+ 'renders stat with title "$title" and count "$count" at index $index',
+ async ({ count, title, index }) => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findSingleStat(index).props()).toMatchObject({
+ value: count,
+ title,
+ });
+ },
+ );
+ });
+
+ describe('when gets an error from the cluster_client API', () => {
+ const error = new Error('Error from the cluster_client API');
+ const createErroredApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sPods: jest.fn().mockRejectedValueOnce(error),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ beforeEach(async () => {
+ createWrapper(createErroredApolloProvider());
+ await waitForPromises();
+ });
+
+ it("doesn't show pods stats", () => {
+ expect(findAllStats()).toHaveLength(0);
+ });
+
+ it('emits an error message', () => {
+ expect(wrapper.emitted('cluster-error')).toMatchObject([[error]]);
+ });
+ });
+});
diff --git a/spec/frontend/environments/kubernetes_summary_spec.js b/spec/frontend/environments/kubernetes_summary_spec.js
new file mode 100644
index 00000000000..53b83079486
--- /dev/null
+++ b/spec/frontend/environments/kubernetes_summary_spec.js
@@ -0,0 +1,115 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlLoadingIcon, GlTab, GlBadge } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import KubernetesSummary from '~/environments/components/kubernetes_summary.vue';
+import { mockKasTunnelUrl } from './mock_data';
+import { k8sWorkloadsMock } from './graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('~/environments/components/kubernetes_summary.vue', () => {
+ let wrapper;
+
+ const namespace = 'my-kubernetes-namespace';
+ const configuration = {
+ basePath: mockKasTunnelUrl,
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findTab = () => wrapper.findComponent(GlTab);
+ const findSummaryListItem = (at) => wrapper.findAllByTestId('summary-list-item').at(at);
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sWorkloads: jest.fn().mockReturnValue(k8sWorkloadsMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMountExtended(KubernetesSummary, {
+ propsData: { configuration, namespace },
+ apolloProvider,
+ stubs: {
+ GlTab,
+ GlBadge,
+ },
+ });
+ };
+
+ describe('mounted', () => {
+ it('renders summary tab', () => {
+ createWrapper();
+
+ expect(findTab().text()).toMatchInterpolatedText(`${KubernetesSummary.i18n.summaryTitle} 0`);
+ });
+
+ it('shows the loading icon', () => {
+ createWrapper();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ describe('when workloads data is loaded', () => {
+ beforeEach(async () => {
+ await createWrapper();
+ await waitForPromises();
+ });
+
+ it('hides the loading icon when the list of workload types loaded', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it.each`
+ type | successText | successCount | failedCount | suspendedCount | index
+ ${'Deployments'} | ${'ready'} | ${1} | ${1} | ${0} | ${0}
+ ${'DaemonSets'} | ${'ready'} | ${1} | ${2} | ${0} | ${1}
+ ${'StatefulSets'} | ${'ready'} | ${2} | ${1} | ${0} | ${2}
+ ${'ReplicaSets'} | ${'ready'} | ${1} | ${1} | ${0} | ${3}
+ ${'Jobs'} | ${'completed'} | ${2} | ${1} | ${0} | ${4}
+ ${'CronJobs'} | ${'ready'} | ${1} | ${1} | ${1} | ${5}
+ `(
+ 'populates view with the correct badges for workload type $type',
+ ({ type, successText, successCount, failedCount, suspendedCount, index }) => {
+ const findAllBadges = () => findSummaryListItem(index).findAllComponents(GlBadge);
+ const findBadgeByVariant = (variant) =>
+ findAllBadges().wrappers.find((badge) => badge.props('variant') === variant);
+
+ expect(findSummaryListItem(index).text()).toContain(type);
+ expect(findBadgeByVariant('success').text()).toBe(`${successCount} ${successText}`);
+ expect(findBadgeByVariant('danger').text()).toBe(`${failedCount} failed`);
+ if (suspendedCount > 0) {
+ expect(findBadgeByVariant('neutral').text()).toBe(`${suspendedCount} suspended`);
+ }
+ },
+ );
+ });
+
+ it('emits an error message when gets an error from the cluster_client API', async () => {
+ const error = new Error('Error from the cluster_client API');
+ const createErroredApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sWorkloads: jest.fn().mockRejectedValueOnce(error),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ createWrapper(createErroredApolloProvider());
+ await waitForPromises();
+
+ expect(wrapper.emitted('cluster-error')).toEqual([[error]]);
+ });
+ });
+});
diff --git a/spec/frontend/environments/kubernetes_tabs_spec.js b/spec/frontend/environments/kubernetes_tabs_spec.js
new file mode 100644
index 00000000000..429f267347b
--- /dev/null
+++ b/spec/frontend/environments/kubernetes_tabs_spec.js
@@ -0,0 +1,168 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlLoadingIcon, GlTabs, GlTab, GlTable, GlPagination, GlBadge } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
+import { useFakeDate } from 'helpers/fake_date';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import KubernetesTabs from '~/environments/components/kubernetes_tabs.vue';
+import KubernetesSummary from '~/environments/components/kubernetes_summary.vue';
+import { SERVICES_LIMIT_PER_PAGE } from '~/environments/constants';
+import { mockKasTunnelUrl } from './mock_data';
+import { k8sServicesMock } from './graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('~/environments/components/kubernetes_tabs.vue', () => {
+ let wrapper;
+
+ const namespace = 'my-kubernetes-namespace';
+ const configuration = {
+ basePath: mockKasTunnelUrl,
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findTabs = () => wrapper.findComponent(GlTabs);
+ const findTab = () => wrapper.findComponent(GlTab);
+ const findTable = () => wrapper.findComponent(GlTable);
+ const findPagination = () => wrapper.findComponent(GlPagination);
+ const findKubernetesSummary = () => wrapper.findComponent(KubernetesSummary);
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sServices: jest.fn().mockReturnValue(k8sServicesMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMountExtended(KubernetesTabs, {
+ propsData: { configuration, namespace },
+ apolloProvider,
+ stubs: {
+ GlTab,
+ GlTable: stubComponent(GlTable, {
+ props: ['items', 'per-page'],
+ }),
+ GlBadge,
+ },
+ });
+ };
+
+ describe('mounted', () => {
+ it('shows tabs', () => {
+ createWrapper();
+
+ expect(findTabs().exists()).toBe(true);
+ });
+
+ it('renders summary tab', () => {
+ createWrapper();
+
+ expect(findKubernetesSummary().props()).toEqual({ namespace, configuration });
+ });
+
+ it('renders services tab', () => {
+ createWrapper();
+
+ expect(findTab().text()).toMatchInterpolatedText(`${KubernetesTabs.i18n.servicesTitle} 0`);
+ });
+ });
+
+ describe('services tab', () => {
+ useFakeDate(2020, 6, 6);
+ it('shows the loading icon', () => {
+ createWrapper();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ describe('when services data is loaded', () => {
+ beforeEach(async () => {
+ createWrapper();
+ await waitForPromises();
+ });
+
+ it('hides the loading icon when the list of services loaded', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders services table when gets services data', () => {
+ expect(findTable().props('perPage')).toBe(SERVICES_LIMIT_PER_PAGE);
+ expect(findTable().props('items')).toMatchObject([
+ {
+ name: 'my-first-service',
+ namespace: 'default',
+ type: 'ClusterIP',
+ clusterIP: '10.96.0.1',
+ externalIP: '-',
+ ports: '443/TCP',
+ age: '0s',
+ },
+ {
+ name: 'my-second-service',
+ namespace: 'default',
+ type: 'NodePort',
+ clusterIP: '10.105.219.238',
+ externalIP: '-',
+ ports: '80:31989/TCP, 443:32679/TCP',
+ age: '2d',
+ },
+ ]);
+ });
+
+ it("doesn't render pagination when services are less then SERVICES_LIMIT_PER_PAGE", async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+
+ it('shows pagination when services are more then SERVICES_LIMIT_PER_PAGE', async () => {
+ const createApolloProviderWithPagination = () => {
+ const mockResolvers = {
+ Query: {
+ k8sServices: jest
+ .fn()
+ .mockReturnValue(
+ Array.from({ length: 6 }, () => k8sServicesMock).flatMap((array) => array),
+ ),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ createWrapper(createApolloProviderWithPagination());
+ await waitForPromises();
+
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('emits an error message when gets an error from the cluster_client API', async () => {
+ const error = new Error('Error from the cluster_client API');
+ const createErroredApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sServices: jest.fn().mockRejectedValueOnce(error),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ createWrapper(createErroredApolloProvider());
+ await waitForPromises();
+
+ expect(wrapper.emitted('cluster-error')).toEqual([[error]]);
+ });
+ });
+});
diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js
index a6d67c26304..bd2c6b7c892 100644
--- a/spec/frontend/environments/mock_data.js
+++ b/spec/frontend/environments/mock_data.js
@@ -313,6 +313,8 @@ const createEnvironment = (data = {}) => ({
...data,
});
+const mockKasTunnelUrl = 'https://kas.gitlab.com/k8s-proxy';
+
export {
environment,
environmentsList,
@@ -321,4 +323,5 @@ export {
tableData,
deployBoardMockData,
createEnvironment,
+ mockKasTunnelUrl,
};
diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js
index 76cd09cfb4e..5583e737dd8 100644
--- a/spec/frontend/environments/new_environment_item_spec.js
+++ b/spec/frontend/environments/new_environment_item_spec.js
@@ -7,9 +7,12 @@ import { stubTransition } from 'helpers/stub_transition';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
import EnvironmentItem from '~/environments/components/new_environment_item.vue';
+import EnvironmentActions from '~/environments/components/environment_actions.vue';
import Deployment from '~/environments/components/deployment.vue';
import DeployBoardWrapper from '~/environments/components/deploy_board_wrapper.vue';
-import { resolvedEnvironment, rolloutStatus } from './graphql/mock_data';
+import KubernetesOverview from '~/environments/components/kubernetes_overview.vue';
+import { resolvedEnvironment, rolloutStatus, agent } from './graphql/mock_data';
+import { mockKasTunnelUrl } from './mock_data';
Vue.use(VueApollo);
@@ -20,15 +23,24 @@ describe('~/environments/components/new_environment_item.vue', () => {
return createMockApollo();
};
- const createWrapper = ({ propsData = {}, apolloProvider } = {}) =>
+ const createWrapper = ({ propsData = {}, provideData = {}, apolloProvider } = {}) =>
mountExtended(EnvironmentItem, {
apolloProvider,
propsData: { environment: resolvedEnvironment, ...propsData },
- provide: { helpPagePath: '/help', projectId: '1', projectPath: '/1' },
+ provide: {
+ helpPagePath: '/help',
+ projectId: '1',
+ projectPath: '/1',
+ kasTunnelUrl: mockKasTunnelUrl,
+ ...provideData,
+ },
stubs: { transition: stubTransition() },
});
const findDeployment = () => wrapper.findComponent(Deployment);
+ const findActions = () => wrapper.findComponent(EnvironmentActions);
+ const findKubernetesOverview = () => wrapper.findComponent(KubernetesOverview);
+ const findMonitoringLink = () => wrapper.find('[data-testid="environment-monitoring"]');
const expandCollapsedSection = async () => {
const button = wrapper.findByRole('button', { name: __('Expand') });
@@ -37,10 +49,6 @@ describe('~/environments/components/new_environment_item.vue', () => {
return button;
};
- afterEach(() => {
- wrapper?.destroy();
- });
-
it('displays the name when not in a folder', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
@@ -126,9 +134,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
it('shows a dropdown if there are actions to perform', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
- const actions = wrapper.findByRole('button', { name: __('Deploy to...') });
-
- expect(actions.exists()).toBe(true);
+ expect(findActions().exists()).toBe(true);
});
it('does not show a dropdown if there are no actions to perform', () => {
@@ -142,22 +148,20 @@ describe('~/environments/components/new_environment_item.vue', () => {
},
});
- const actions = wrapper.findByRole('button', { name: __('Deploy to...') });
-
- expect(actions.exists()).toBe(false);
+ expect(findActions().exists()).toBe(false);
});
it('passes all the actions down to the action component', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
- const action = wrapper.findByRole('menuitem', { name: 'deploy-staging' });
-
- expect(action.exists()).toBe(true);
+ expect(findActions().props('actions')).toMatchObject(
+ resolvedEnvironment.lastDeployment.manualActions,
+ );
});
});
describe('stop', () => {
- it('shows a buton to stop the environment if the environment is available', () => {
+ it('shows a button to stop the environment if the environment is available', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
const stop = wrapper.findByRole('button', { name: s__('Environments|Stop environment') });
@@ -165,7 +169,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
expect(stop.exists()).toBe(true);
});
- it('does not show a buton to stop the environment if the environment is stopped', () => {
+ it('does not show a button to stop the environment if the environment is stopped', () => {
wrapper = createWrapper({
propsData: { environment: { ...resolvedEnvironment, canStop: false } },
apolloProvider: createApolloProvider(),
@@ -311,7 +315,25 @@ describe('~/environments/components/new_environment_item.vue', () => {
expect(rollback.exists()).toBe(false);
});
+
+ describe.each([true, false])(
+ 'when `remove_monitor_metrics` flag is %p',
+ (removeMonitorMetrics) => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ propsData: { environment: { ...resolvedEnvironment, metricsPath: '/metrics' } },
+ apolloProvider: createApolloProvider(),
+ provideData: { glFeatures: { removeMonitorMetrics } },
+ });
+ });
+
+ it(`${removeMonitorMetrics ? 'does not render' : 'renders'} link to metrics`, () => {
+ expect(findMonitoringLink().exists()).toBe(!removeMonitorMetrics);
+ });
+ },
+ );
});
+
describe('terminal', () => {
it('shows the link to the terminal if set up', () => {
wrapper = createWrapper({
@@ -384,6 +406,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
const button = await expandCollapsedSection();
expect(button.attributes('aria-label')).toBe(__('Collapse'));
+ expect(button.props('category')).toBe('secondary');
expect(collapse.attributes('visible')).toBe('visible');
expect(icon.props('name')).toBe('chevron-lg-down');
expect(environmentName.classes('gl-font-weight-bold')).toBe(true);
@@ -515,4 +538,72 @@ describe('~/environments/components/new_environment_item.vue', () => {
expect(deployBoard.exists()).toBe(false);
});
});
+
+ describe('kubernetes overview', () => {
+ const environmentWithAgent = {
+ ...resolvedEnvironment,
+ agent,
+ };
+
+ it('should render if the feature flag is enabled and the environment has an agent object with the required data specified', () => {
+ wrapper = createWrapper({
+ propsData: { environment: environmentWithAgent },
+ provideData: {
+ glFeatures: {
+ kasUserAccessProject: true,
+ },
+ },
+ apolloProvider: createApolloProvider(),
+ });
+
+ expandCollapsedSection();
+
+ expect(findKubernetesOverview().props()).toMatchObject({
+ agentProjectPath: agent.project,
+ agentName: agent.name,
+ agentId: agent.id,
+ namespace: agent.kubernetesNamespace,
+ });
+ });
+
+ it('should not render if the feature flag is not enabled', () => {
+ wrapper = createWrapper({
+ propsData: { environment: environmentWithAgent },
+ apolloProvider: createApolloProvider(),
+ });
+
+ expandCollapsedSection();
+
+ expect(findKubernetesOverview().exists()).toBe(false);
+ });
+
+ it('should not render if the environment has no agent object', () => {
+ wrapper = createWrapper({
+ apolloProvider: createApolloProvider(),
+ });
+
+ expandCollapsedSection();
+
+ expect(findKubernetesOverview().exists()).toBe(false);
+ });
+
+ it('should not render if the environment has an agent object without agent id specified', () => {
+ const environment = {
+ ...resolvedEnvironment,
+ agent: {
+ project: agent.project,
+ name: agent.name,
+ },
+ };
+
+ wrapper = createWrapper({
+ propsData: { environment },
+ apolloProvider: createApolloProvider(),
+ });
+
+ expandCollapsedSection();
+
+ expect(findKubernetesOverview().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/environments/new_environment_spec.js b/spec/frontend/environments/new_environment_spec.js
index a8cc05b297b..743f4ad6786 100644
--- a/spec/frontend/environments/new_environment_spec.js
+++ b/spec/frontend/environments/new_environment_spec.js
@@ -3,13 +3,13 @@ import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import NewEnvironment from '~/environments/components/new_environment.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility');
-jest.mock('~/flash');
+jest.mock('~/alert');
const DEFAULT_OPTS = {
provide: {
@@ -41,7 +41,6 @@ describe('~/environments/components/new.vue', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
const showsLoading = () => wrapper.findComponent(GlLoadingIcon).exists();
diff --git a/spec/frontend/environments/stop_stale_environments_modal_spec.js b/spec/frontend/environments/stop_stale_environments_modal_spec.js
index a2ab4f707b5..3d28ceba318 100644
--- a/spec/frontend/environments/stop_stale_environments_modal_spec.js
+++ b/spec/frontend/environments/stop_stale_environments_modal_spec.js
@@ -18,7 +18,6 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => {
let wrapper;
let mock;
let before;
- let originalGon;
const createWrapper = (opts = {}) =>
shallowMount(StopStaleEnvironmentsModal, {
@@ -28,8 +27,7 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => {
});
beforeEach(() => {
- originalGon = window.gon;
- window.gon = { api_version: 'v4' };
+ window.gon.api_version = 'v4';
mock = new MockAdapter(axios);
jest.spyOn(axios, 'post');
@@ -39,17 +37,15 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
jest.resetAllMocks();
- window.gon = originalGon;
});
- it('sets the correct min and max dates', async () => {
+ it('sets the correct min and max dates', () => {
expect(before.props().minDate.toISOString()).toBe(TEN_YEARS_AGO.toISOString());
expect(before.props().maxDate.toISOString()).toBe(ONE_WEEK_AGO.toISOString());
});
- it('requests cleanup when submit is clicked', async () => {
+ it('requests cleanup when submit is clicked', () => {
mock.onPost().replyOnce(HTTP_STATUS_OK);
wrapper.findComponent(GlModal).vm.$emit('primary');
const url = STOP_STALE_ENVIRONMENTS_PATH.replace(':id', 1).replace(':version', 'v4');
diff --git a/spec/frontend/error_tracking/components/error_details_info_spec.js b/spec/frontend/error_tracking/components/error_details_info_spec.js
new file mode 100644
index 00000000000..4a741a4c31e
--- /dev/null
+++ b/spec/frontend/error_tracking/components/error_details_info_spec.js
@@ -0,0 +1,190 @@
+import { GlLink, GlCard } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import ErrorDetailsInfo from '~/error_tracking/components/error_details_info.vue';
+import { trackClickErrorLinkToSentryOptions } from '~/error_tracking/events_tracking';
+import Tracking from '~/tracking';
+
+jest.mock('~/tracking');
+
+describe('ErrorDetails', () => {
+ let wrapper;
+
+ const MOCK_DEFAULT_ERROR = {
+ id: 'gid://gitlab/Gitlab::ErrorTracking::DetailedError/129381',
+ sentryId: 129381,
+ title: 'Issue title',
+ externalUrl: 'http://sentry.gitlab.net/gitlab',
+ firstSeen: '2017-05-26T13:32:48Z',
+ lastSeen: '2018-05-26T13:32:48Z',
+ count: 12,
+ userCount: 2,
+ integrated: false,
+ };
+
+ function mountComponent(error = {}) {
+ wrapper = shallowMountExtended(ErrorDetailsInfo, {
+ stubs: { GlCard },
+ propsData: {
+ error: {
+ ...MOCK_DEFAULT_ERROR,
+ ...error,
+ },
+ },
+ });
+ }
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('should render a card with error counts', () => {
+ expect(wrapper.findByTestId('error-count-card').text()).toContain('Events 12');
+ });
+
+ it('should render a card with user counts', () => {
+ expect(wrapper.findByTestId('user-count-card').text()).toContain('Users 2');
+ });
+
+ describe('release links', () => {
+ it('if firstReleaseVersion is missing, does not render a card', () => {
+ expect(wrapper.findByTestId('first-release-card').exists()).toBe(false);
+ });
+
+ describe('if firstReleaseVersion link exists', () => {
+ it('renders the first release card', () => {
+ mountComponent({
+ firstReleaseVersion: 'first-release-version',
+ });
+ const card = wrapper.findByTestId('first-release-card');
+ expect(card.exists()).toBe(true);
+ expect(card.text()).toContain('First seen');
+ expect(card.findComponent(GlLink).exists()).toBe(true);
+ expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true);
+ });
+
+ it('renders a link to the commit if error is integrated', () => {
+ mountComponent({
+ externalBaseUrl: 'external-base-url',
+ firstReleaseVersion: 'first-release-version',
+ firstSeen: '2023-04-20T17:02:06+00:00',
+ integrated: true,
+ });
+ expect(
+ wrapper.findByTestId('first-release-card').findComponent(GlLink).attributes('href'),
+ ).toBe('external-base-url/-/commit/first-release-version');
+ });
+
+ it('renders a link to the release if error is not integrated', () => {
+ mountComponent({
+ externalBaseUrl: 'external-base-url',
+ firstReleaseVersion: 'first-release-version',
+ firstSeen: '2023-04-20T17:02:06+00:00',
+ integrated: false,
+ });
+ expect(
+ wrapper.findByTestId('first-release-card').findComponent(GlLink).attributes('href'),
+ ).toBe('external-base-url/releases/first-release-version');
+ });
+ });
+
+ it('if lastReleaseVersion is missing, does not render a card', () => {
+ expect(wrapper.findByTestId('last-release-card').exists()).toBe(false);
+ });
+
+ describe('if lastReleaseVersion link exists', () => {
+ it('renders the last release card', () => {
+ mountComponent({
+ lastReleaseVersion: 'last-release-version',
+ });
+ const card = wrapper.findByTestId('last-release-card');
+ expect(card.exists()).toBe(true);
+ expect(card.text()).toContain('Last seen');
+ expect(card.findComponent(GlLink).exists()).toBe(true);
+ expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true);
+ });
+
+ it('renders a link to the commit if error is integrated', () => {
+ mountComponent({
+ externalBaseUrl: 'external-base-url',
+ lastReleaseVersion: 'last-release-version',
+ lastSeen: '2023-04-20T17:02:06+00:00',
+ integrated: true,
+ });
+ expect(
+ wrapper.findByTestId('last-release-card').findComponent(GlLink).attributes('href'),
+ ).toBe('external-base-url/-/commit/last-release-version');
+ });
+
+ it('renders a link to the release if error is integrated', () => {
+ mountComponent({
+ externalBaseUrl: 'external-base-url',
+ lastReleaseVersion: 'last-release-version',
+ lastSeen: '2023-04-20T17:02:06+00:00',
+ integrated: false,
+ });
+ expect(
+ wrapper.findByTestId('last-release-card').findComponent(GlLink).attributes('href'),
+ ).toBe('external-base-url/releases/last-release-version');
+ });
+ });
+ });
+
+ describe('gitlab commit link', () => {
+ it('does not render a card with gitlab commit link, if gitlabCommitPath does not exist', () => {
+ expect(wrapper.findByTestId('gitlab-commit-card').exists()).toBe(false);
+ });
+
+ it('should render a card with gitlab commit link, if gitlabCommitPath exists', () => {
+ mountComponent({
+ gitlabCommit: 'gitlab-long-commit',
+ gitlabCommitPath: 'commit-path',
+ });
+ const card = wrapper.findByTestId('gitlab-commit-card');
+ expect(card.exists()).toBe(true);
+ expect(card.text()).toContain('GitLab commit');
+ const link = card.findComponent(GlLink);
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe('commit-path');
+ expect(link.text()).toBe('gitlab-lon');
+ });
+ });
+
+ describe('external url link', () => {
+ const findExternalUrlLink = () => wrapper.findByTestId('external-url-link');
+
+ it('should not render an external link if integrated', () => {
+ mountComponent({
+ integrated: true,
+ externalUrl: 'external-url',
+ });
+ expect(findExternalUrlLink().exists()).toBe(false);
+ });
+
+ it('should render an external link if not integrated', () => {
+ mountComponent({
+ integrated: false,
+ externalUrl: 'external-url',
+ });
+ const link = findExternalUrlLink();
+ expect(link.exists()).toBe(true);
+ expect(link.text()).toContain('external-url');
+ });
+
+ it('should track external Sentry link views', async () => {
+ Tracking.event.mockClear();
+
+ mountComponent({
+ integrated: false,
+ externalUrl: 'external-url',
+ });
+ await findExternalUrlLink().trigger('click');
+
+ const { category, action, label, property } = trackClickErrorLinkToSentryOptions(
+ 'external-url',
+ );
+ expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property });
+ });
+ });
+});
diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js
index 9d6e46be8c4..8700301ef73 100644
--- a/spec/frontend/error_tracking/components/error_details_spec.js
+++ b/spec/frontend/error_tracking/components/error_details_spec.js
@@ -13,16 +13,17 @@ import Vuex from 'vuex';
import { severityLevel, severityLevelVariant, errorStatus } from '~/error_tracking/constants';
import ErrorDetails from '~/error_tracking/components/error_details.vue';
import Stacktrace from '~/error_tracking/components/stacktrace.vue';
+import ErrorDetailsInfo from '~/error_tracking/components/error_details_info.vue';
import {
- trackClickErrorLinkToSentryOptions,
trackErrorDetailsViewsOptions,
trackErrorStatusUpdateOptions,
-} from '~/error_tracking/utils';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+ trackCreateIssueFromError,
+} from '~/error_tracking/events_tracking';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import { __ } from '~/locale';
import Tracking from '~/tracking';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(Vuex);
@@ -45,7 +46,6 @@ describe('ErrorDetails', () => {
wrapper.find('[data-testid="update-ignore-status-btn"]');
const findUpdateResolveStatusButton = () =>
wrapper.find('[data-testid="update-resolve-status-btn"]');
- const findExternalUrl = () => wrapper.find('[data-testid="external-url-link"]');
const findAlert = () => wrapper.findComponent(GlAlert);
function mountComponent() {
@@ -109,12 +109,6 @@ describe('ErrorDetails', () => {
};
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('loading', () => {
beforeEach(() => {
mountComponent();
@@ -148,7 +142,7 @@ describe('ErrorDetails', () => {
expect(mocks.$apollo.queries.error.stopPolling).not.toHaveBeenCalled();
});
- it('when timeout is hit and no apollo result, stops loading and shows flash', async () => {
+ it('when timeout is hit and no apollo result, stops loading and shows alert', async () => {
Date.now.mockReturnValue(endTime + 1);
wrapper.vm.onNoApolloResult();
@@ -187,14 +181,6 @@ describe('ErrorDetails', () => {
});
});
- it('should show Sentry error details without stacktrace', () => {
- expect(wrapper.findComponent(GlLink).exists()).toBe(true);
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
- expect(wrapper.findComponent(Stacktrace).exists()).toBe(false);
- expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
- expect(wrapper.findAllComponents(GlButton)).toHaveLength(3);
- });
-
describe('unsafe chars for culprit field', () => {
const findReportedText = () => wrapper.find('[data-qa-selector="reported_text"]');
const culprit = '<script>console.log("surprise!")</script>';
@@ -276,6 +262,16 @@ describe('ErrorDetails', () => {
});
});
+ describe('ErrorDetailsInfo', () => {
+ it('should show ErrorDetailsInfo', async () => {
+ store.state.details.loadingStacktrace = false;
+ await nextTick();
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.findComponent(ErrorDetailsInfo).exists()).toBe(true);
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
describe('Stacktrace', () => {
it('should show stacktrace', async () => {
store.state.details.loadingStacktrace = false;
@@ -477,91 +473,6 @@ describe('ErrorDetails', () => {
});
});
});
-
- describe('GitLab commit link', () => {
- const gitlabCommit = '7975be0116940bf2ad4321f79d02a55c5f7779aa';
- const gitlabCommitPath =
- '/gitlab-org/gitlab-test/commit/7975be0116940bf2ad4321f79d02a55c5f7779aa';
- const findGitLabCommitLink = () => wrapper.find(`[href$="${gitlabCommitPath}"]`);
-
- it('should display a link', async () => {
- mocks.$apollo.queries.error.loading = false;
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- error: {
- gitlabCommit,
- gitlabCommitPath,
- },
- });
- await nextTick();
- expect(findGitLabCommitLink().exists()).toBe(true);
- });
-
- it('should not display a link', async () => {
- mocks.$apollo.queries.error.loading = false;
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- error: {
- gitlabCommit: null,
- },
- });
- await nextTick();
- expect(findGitLabCommitLink().exists()).toBe(false);
- });
- });
-
- describe('Release links', () => {
- const firstReleaseVersion = '7975be01';
- const firstCommitLink = '/gitlab/-/commit/7975be01';
- const firstReleaseLink = '/sentry/releases/7975be01';
- const findFirstCommitLink = () => wrapper.find(`[href$="${firstCommitLink}"]`);
- const findFirstReleaseLink = () => wrapper.find(`[href$="${firstReleaseLink}"]`);
-
- const lastReleaseVersion = '6ca5a5c1';
- const lastCommitLink = '/gitlab/-/commit/6ca5a5c1';
- const lastReleaseLink = '/sentry/releases/6ca5a5c1';
- const findLastCommitLink = () => wrapper.find(`[href$="${lastCommitLink}"]`);
- const findLastReleaseLink = () => wrapper.find(`[href$="${lastReleaseLink}"]`);
-
- it('should display links to Sentry', async () => {
- mocks.$apollo.queries.error.loading = false;
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({
- error: {
- firstReleaseVersion,
- lastReleaseVersion,
- externalBaseUrl: '/sentry',
- },
- });
-
- expect(findFirstReleaseLink().exists()).toBe(true);
- expect(findLastReleaseLink().exists()).toBe(true);
- expect(findFirstCommitLink().exists()).toBe(false);
- expect(findLastCommitLink().exists()).toBe(false);
- });
-
- it('should display links to GitLab when integrated', async () => {
- mocks.$apollo.queries.error.loading = false;
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({
- error: {
- firstReleaseVersion,
- lastReleaseVersion,
- integrated: true,
- externalBaseUrl: '/gitlab',
- },
- });
-
- expect(findFirstCommitLink().exists()).toBe(true);
- expect(findLastCommitLink().exists()).toBe(true);
- expect(findFirstReleaseLink().exists()).toBe(false);
- expect(findLastReleaseLink().exists()).toBe(false);
- });
- });
});
describe('Snowplow tracking', () => {
@@ -582,24 +493,21 @@ describe('ErrorDetails', () => {
});
it('should track IGNORE status update', async () => {
- Tracking.event.mockClear();
await findUpdateIgnoreStatusButton().trigger('click');
const { category, action } = trackErrorStatusUpdateOptions('ignored');
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
it('should track RESOLVE status update', async () => {
- Tracking.event.mockClear();
await findUpdateResolveStatusButton().trigger('click');
const { category, action } = trackErrorStatusUpdateOptions('resolved');
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
- it('should track external Sentry link views', async () => {
- Tracking.event.mockClear();
- await findExternalUrl().trigger('click');
- const { category, action, label, property } = trackClickErrorLinkToSentryOptions(externalUrl);
- expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property });
+ it('should track create issue button click', async () => {
+ await wrapper.find('[data-qa-selector="create_issue_button"]').vm.$emit('click');
+ const { category, action } = trackCreateIssueFromError;
+ expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
});
});
diff --git a/spec/frontend/error_tracking/components/error_tracking_actions_spec.js b/spec/frontend/error_tracking/components/error_tracking_actions_spec.js
index 5f6c9ddb4d7..d959d73c86b 100644
--- a/spec/frontend/error_tracking/components/error_tracking_actions_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_actions_spec.js
@@ -29,12 +29,6 @@ describe('Error Tracking Actions', () => {
mountComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
const findButtons = () => wrapper.findAllComponents(GlButton);
describe('when error status is unresolved', () => {
diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
index 31473899145..6d4e92cf91f 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -5,7 +5,12 @@ import Vuex from 'vuex';
import stubChildren from 'helpers/stub_children';
import ErrorTrackingActions from '~/error_tracking/components/error_tracking_actions.vue';
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
-import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '~/error_tracking/utils';
+import {
+ trackErrorListViewsOptions,
+ trackErrorStatusUpdateOptions,
+ trackErrorStatusFilterOptions,
+ trackErrorSortedByField,
+} from '~/error_tracking/events_tracking';
import Tracking from '~/tracking';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import errorsList from './list_mock.json';
@@ -98,12 +103,6 @@ describe('ErrorTrackingList', () => {
});
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('loading', () => {
beforeEach(() => {
store.state.list.loading = true;
@@ -452,32 +451,34 @@ describe('ErrorTrackingList', () => {
describe('When pagination is required', () => {
describe('and previous cursor is not available', () => {
- beforeEach(async () => {
+ beforeEach(() => {
store.state.list.loading = false;
delete store.state.list.pagination.previous;
mountComponent();
});
- it('disables Prev button in the pagination', async () => {
+ it('disables Prev button in the pagination', () => {
expect(findPagination().props('prevPage')).toBe(null);
expect(findPagination().props('nextPage')).not.toBe(null);
});
});
describe('and next cursor is not available', () => {
- beforeEach(async () => {
+ beforeEach(() => {
store.state.list.loading = false;
delete store.state.list.pagination.next;
mountComponent();
});
- it('disables Next button in the pagination', async () => {
+ it('disables Next button in the pagination', () => {
expect(findPagination().props('prevPage')).not.toBe(null);
expect(findPagination().props('nextPage')).toBe(null);
});
});
describe('and the user is not on the first page', () => {
describe('and the previous button is clicked', () => {
- beforeEach(async () => {
+ const currentPage = 2;
+
+ beforeEach(() => {
store.state.list.loading = false;
mountComponent({
stubs: {
@@ -485,15 +486,12 @@ describe('ErrorTrackingList', () => {
GlPagination: false,
},
});
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ pageValue: 2 });
- await nextTick();
+ findPagination().vm.$emit('input', currentPage);
});
it('fetches the previous page of results', () => {
expect(wrapper.find('.prev-page-item').attributes('aria-disabled')).toBe(undefined);
- wrapper.vm.goToPrevPage();
+ findPagination().vm.$emit('input', currentPage - 1);
expect(actions.fetchPaginatedResults).toHaveBeenCalled();
expect(actions.fetchPaginatedResults).toHaveBeenLastCalledWith(
expect.anything(),
@@ -531,6 +529,8 @@ describe('ErrorTrackingList', () => {
stubs: {
GlTable: false,
GlLink: false,
+ GlDropdown: false,
+ GlDropdownItem: false,
},
});
});
@@ -541,7 +541,6 @@ describe('ErrorTrackingList', () => {
});
it('should track status updates', async () => {
- Tracking.event.mockClear();
const status = 'ignored';
findErrorActions().vm.$emit('update-issue-status', {
errorId: 1,
@@ -553,5 +552,19 @@ describe('ErrorTrackingList', () => {
const { category, action } = trackErrorStatusUpdateOptions(status);
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
+
+ it('should track error filter', () => {
+ const findStatusFilter = () => findStatusFilterDropdown().find('.dropdown-item');
+ findStatusFilter().trigger('click');
+ const { category, action } = trackErrorStatusFilterOptions('unresolved');
+ expect(Tracking.event).toHaveBeenCalledWith(category, action);
+ });
+
+ it('should track error sorting', () => {
+ const findSortItem = () => findSortDropdown().find('.dropdown-item');
+ findSortItem().trigger('click');
+ const { category, action } = trackErrorSortedByField('last_seen');
+ expect(Tracking.event).toHaveBeenCalledWith(category, action);
+ });
});
});
diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
index 0de4277b08a..45fc1ad04ff 100644
--- a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
+++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
@@ -28,12 +28,6 @@ describe('Stacktrace Entry', () => {
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
it('should render stacktrace entry collapsed', () => {
mountComponent({ lines });
expect(wrapper.findComponent(StackTraceEntry).exists()).toBe(true);
diff --git a/spec/frontend/error_tracking/components/stacktrace_spec.js b/spec/frontend/error_tracking/components/stacktrace_spec.js
index cd5a57f5683..29301c3e5ee 100644
--- a/spec/frontend/error_tracking/components/stacktrace_spec.js
+++ b/spec/frontend/error_tracking/components/stacktrace_spec.js
@@ -25,12 +25,6 @@ describe('ErrorDetails', () => {
}
describe('Stacktrace', () => {
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
it('should render single Stacktrace entry', () => {
mountComponent([stackTraceEntry]);
expect(wrapper.findAllComponents(StackTraceEntry).length).toBe(1);
diff --git a/spec/frontend/error_tracking/events_tracking_spec.js b/spec/frontend/error_tracking/events_tracking_spec.js
new file mode 100644
index 00000000000..10479d863cf
--- /dev/null
+++ b/spec/frontend/error_tracking/events_tracking_spec.js
@@ -0,0 +1,16 @@
+import * as errorTrackingUtils from '~/error_tracking/events_tracking';
+
+const externalUrl = 'https://sentry.io/organizations/test-sentry-nk/issues/1/?project=1';
+
+describe('Error Tracking Events', () => {
+ describe('trackClickErrorLinkToSentryOptions', () => {
+ it('should return correct event options', () => {
+ expect(errorTrackingUtils.trackClickErrorLinkToSentryOptions(externalUrl)).toEqual({
+ category: 'Error Tracking',
+ action: 'click_error_link_to_sentry',
+ label: 'Error Link',
+ property: externalUrl,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/error_tracking/store/actions_spec.js b/spec/frontend/error_tracking/store/actions_spec.js
index 3ec43010d80..44db4780ba9 100644
--- a/spec/frontend/error_tracking/store/actions_spec.js
+++ b/spec/frontend/error_tracking/store/actions_spec.js
@@ -2,12 +2,12 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/error_tracking/store/actions';
import * as types from '~/error_tracking/store/mutation_types';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility');
let mock;
diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js
index 383d8aaeb20..0aeb8b19a9e 100644
--- a/spec/frontend/error_tracking/store/details/actions_spec.js
+++ b/spec/frontend/error_tracking/store/details/actions_spec.js
@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/error_tracking/store/details/actions';
import * as types from '~/error_tracking/store/details/mutation_types';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import {
HTTP_STATUS_BAD_REQUEST,
@@ -14,7 +14,7 @@ import Poll from '~/lib/utils/poll';
let mockedAdapter;
let mockedRestart;
-jest.mock('~/flash.js');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility');
describe('Sentry error details store actions', () => {
@@ -48,7 +48,7 @@ describe('Sentry error details store actions', () => {
);
});
- it('should show flash on API error', async () => {
+ it('should show alert on API error', async () => {
mockedAdapter.onGet().reply(HTTP_STATUS_BAD_REQUEST);
await testAction(
diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js
index 590983bd93d..24a26476455 100644
--- a/spec/frontend/error_tracking/store/list/actions_spec.js
+++ b/spec/frontend/error_tracking/store/list/actions_spec.js
@@ -2,11 +2,11 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/error_tracking/store/list/actions';
import * as types from '~/error_tracking/store/list/mutation_types';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
describe('error tracking actions', () => {
let mock;
@@ -38,7 +38,7 @@ describe('error tracking actions', () => {
);
});
- it('should show flash on API error', async () => {
+ it('should show alert on API error', async () => {
mock.onGet().reply(HTTP_STATUS_BAD_REQUEST);
await testAction(
diff --git a/spec/frontend/error_tracking/utils_spec.js b/spec/frontend/error_tracking/utils_spec.js
deleted file mode 100644
index a0d6f7f009d..00000000000
--- a/spec/frontend/error_tracking/utils_spec.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as errorTrackingUtils from '~/error_tracking/utils';
-
-const externalUrl = 'https://sentry.io/organizations/test-sentry-nk/issues/1/?project=1';
-
-describe('Error Tracking Events', () => {
- describe('trackClickErrorLinkToSentryOptions', () => {
- it('should return correct event options', () => {
- expect(errorTrackingUtils.trackClickErrorLinkToSentryOptions(externalUrl)).toEqual({
- category: 'Error Tracking',
- action: 'click_error_link_to_sentry',
- label: 'Error Link',
- property: externalUrl,
- });
- });
- });
-});
diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js
index 7a714cc1ebc..9b7701d46bc 100644
--- a/spec/frontend/error_tracking_settings/components/app_spec.js
+++ b/spec/frontend/error_tracking_settings/components/app_spec.js
@@ -68,12 +68,6 @@ describe('error tracking settings app', () => {
mountComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('section', () => {
it('renders the form and dropdown', () => {
expect(wrapper.findComponent(ErrorTrackingForm).exists()).toBe(true);
@@ -92,7 +86,7 @@ describe('error tracking settings app', () => {
store.state.settingsLoading = true;
await nextTick();
- expect(wrapper.find('.js-error-tracking-button').attributes('disabled')).toBe('true');
+ expect(wrapper.find('.js-error-tracking-button').attributes('disabled')).toBeDefined();
});
});
diff --git a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
index 69d684faec2..b1cf5d673f1 100644
--- a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
+++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
@@ -24,12 +24,6 @@ describe('error tracking settings form', () => {
mountComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('an empty form', () => {
it('is rendered', () => {
expect(wrapper.findAllComponents(GlFormInput).length).toBe(2);
diff --git a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js
index 8653ebac20d..03d090c5314 100644
--- a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js
+++ b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js
@@ -33,12 +33,6 @@ describe('error tracking settings project dropdown', () => {
mountComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('empty project list', () => {
it('renders the dropdown', () => {
expect(wrapper.find('#project-dropdown').exists()).toBe(true);
diff --git a/spec/frontend/experimentation/components/gitlab_experiment_spec.js b/spec/frontend/experimentation/components/gitlab_experiment_spec.js
index f52ebf0f3c4..73db4b9503c 100644
--- a/spec/frontend/experimentation/components/gitlab_experiment_spec.js
+++ b/spec/frontend/experimentation/components/gitlab_experiment_spec.js
@@ -9,7 +9,6 @@ const defaultSlots = {
};
describe('ExperimentComponent', () => {
- const oldGon = window.gon;
let wrapper;
const createComponent = (propsData = defaultProps, slots = defaultSlots) => {
@@ -20,12 +19,6 @@ describe('ExperimentComponent', () => {
window.gon = { experiment: { experiment_name: { variant: expectedVariant } } };
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- window.gon = oldGon;
- });
-
describe('when variant and experiment is set', () => {
it('renders control when it is the active variant', () => {
mockVariant('control');
diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js
index 0d663fd055e..6d9c9dfe65a 100644
--- a/spec/frontend/experimentation/utils_spec.js
+++ b/spec/frontend/experimentation/utils_spec.js
@@ -10,18 +10,15 @@ describe('experiment Utilities', () => {
const ABC_KEY = 'abc';
const DEF_KEY = 'def';
- let origGon;
let origGl;
beforeEach(() => {
- origGon = window.gon;
origGl = window.gl;
window.gon.experiment = {};
window.gl.experiments = {};
});
afterEach(() => {
- window.gon = origGon;
window.gl = origGl;
});
diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
index c1051a14a08..b75e2f653e9 100644
--- a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
+++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
@@ -42,7 +42,6 @@ describe('Configure Feature Flags Modal', () => {
wrapper.findAllComponents(GlAlert).filter((c) => c.props('variant') === 'danger');
describe('idle', () => {
- afterEach(() => wrapper.destroy());
beforeEach(factory);
it('should have Primary and Secondary actions', () => {
@@ -51,7 +50,7 @@ describe('Configure Feature Flags Modal', () => {
});
it('should default disable the primary action', () => {
- const [{ disabled }] = findSecondaryAction().attributes;
+ const { disabled } = findSecondaryAction().attributes;
expect(disabled).toBe(true);
});
@@ -112,52 +111,48 @@ describe('Configure Feature Flags Modal', () => {
});
describe('verified', () => {
- afterEach(() => wrapper.destroy());
beforeEach(factory);
it('should enable the secondary action', async () => {
findProjectNameInput().vm.$emit('input', provide.projectName);
await nextTick();
- const [{ disabled }] = findSecondaryAction().attributes;
+ const { disabled } = findSecondaryAction().attributes;
expect(disabled).toBe(false);
});
});
describe('cannot rotate token', () => {
- afterEach(() => wrapper.destroy());
beforeEach(factory.bind(null, { canUserRotateToken: false }));
it('should not display the primary action', () => {
expect(findSecondaryAction()).toBe(null);
});
- it('should not display regenerating instance ID', async () => {
+ it('should not display regenerating instance ID', () => {
expect(findDangerGlAlert().exists()).toBe(false);
});
- it('should disable the project name input', async () => {
+ it('should disable the project name input', () => {
expect(findProjectNameInput().exists()).toBe(false);
});
});
describe('has rotate error', () => {
- afterEach(() => wrapper.destroy());
beforeEach(() => {
factory({ hasRotateError: true });
});
- it('should display an error', async () => {
+ it('should display an error', () => {
expect(wrapper.findByTestId('rotate-error').exists()).toBe(true);
expect(wrapper.find('[name="warning"]').exists()).toBe(true);
});
});
describe('is rotating', () => {
- afterEach(() => wrapper.destroy());
beforeEach(factory.bind(null, { isRotating: true }));
- it('should disable the project name input', async () => {
- expect(findProjectNameInput().attributes('disabled')).toBe('true');
+ it('should disable the project name input', () => {
+ expect(findProjectNameInput().attributes('disabled')).toBeDefined();
});
});
});
diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
index cf4605e21ea..b8d058e7bc5 100644
--- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
+++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
@@ -24,10 +24,6 @@ describe('Edit feature flag form', () => {
});
const factory = (provide = { searchPath: '/search' }) => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
wrapper = shallowMount(EditFeatureFlag, {
store,
provide,
@@ -53,7 +49,6 @@ describe('Edit feature flag form', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
diff --git a/spec/frontend/feature_flags/components/empty_state_spec.js b/spec/frontend/feature_flags/components/empty_state_spec.js
index e3cc6f703c4..4aa0b261e2a 100644
--- a/spec/frontend/feature_flags/components/empty_state_spec.js
+++ b/spec/frontend/feature_flags/components/empty_state_spec.js
@@ -44,14 +44,6 @@ describe('feature_flags/components/feature_flags_tab.vue', () => {
},
);
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
-
- wrapper = null;
- });
-
describe('alerts', () => {
let alerts;
diff --git a/spec/frontend/feature_flags/components/environments_dropdown_spec.js b/spec/frontend/feature_flags/components/environments_dropdown_spec.js
index a4738fed37e..9fc0119a6c8 100644
--- a/spec/frontend/feature_flags/components/environments_dropdown_spec.js
+++ b/spec/frontend/feature_flags/components/environments_dropdown_spec.js
@@ -27,7 +27,6 @@ describe('Feature flags > Environments dropdown', () => {
const findDropdownMenu = () => wrapper.find('.dropdown-menu');
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
diff --git a/spec/frontend/feature_flags/components/feature_flags_spec.js b/spec/frontend/feature_flags/components/feature_flags_spec.js
index e80f9c559c4..c0cfec384f0 100644
--- a/spec/frontend/feature_flags/components/feature_flags_spec.js
+++ b/spec/frontend/feature_flags/components/feature_flags_spec.js
@@ -1,9 +1,9 @@
import { GlAlert, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'spec/test_constants';
import ConfigureFeatureFlagsModal from '~/feature_flags/components/configure_feature_flags_modal.vue';
import EmptyState from '~/feature_flags/components/empty_state.vue';
@@ -11,7 +11,7 @@ import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue'
import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue';
import createStore from '~/feature_flags/store/index';
import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import { getRequestData } from '../mock_data';
@@ -43,7 +43,7 @@ describe('Feature flags', () => {
let mock;
let store;
- const factory = (provide = mockData, fn = mount) => {
+ const factory = (provide = mockData, fn = mountExtended) => {
store = createStore(mockState);
wrapper = fn(FeatureFlagsComponent, {
store,
@@ -54,10 +54,13 @@ describe('Feature flags', () => {
});
};
- const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]');
- const newButton = () => wrapper.find('[data-testid="ff-new-button"]');
- const userListButton = () => wrapper.find('[data-testid="ff-user-list-button"]');
+ const configureButton = () => wrapper.findByTestId('ff-configure-button');
+ const newButton = () => wrapper.findByTestId('ff-new-button');
+ const userListButton = () => wrapper.findByTestId('ff-user-list-button');
const limitAlert = () => wrapper.findComponent(GlAlert);
+ const findTablePagination = () => wrapper.findComponent(TablePagination);
+ const findFeatureFlagsTable = () => wrapper.findComponent(FeatureFlagsTable);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -65,8 +68,6 @@ describe('Feature flags', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
- wrapper = null;
});
describe('when limit exceeded', () => {
@@ -83,7 +84,7 @@ describe('Feature flags', () => {
it('makes the new feature flag button do nothing if clicked', () => {
expect(newButton().exists()).toBe(true);
expect(newButton().props('disabled')).toBe(false);
- expect(newButton().props('href')).toBe(undefined);
+ expect(newButton().props('href')).toBeUndefined();
});
it('shows a feature flags limit reached alert', () => {
@@ -96,7 +97,7 @@ describe('Feature flags', () => {
await limitAlert().vm.$emit('dismiss');
});
- it('hides the alert', async () => {
+ it('hides the alert', () => {
expect(limitAlert().exists()).toBe(false);
});
@@ -173,12 +174,11 @@ describe('Feature flags', () => {
factory();
await waitForPromises();
- await nextTick();
- emptyState = wrapper.findComponent(GlEmptyState);
+ emptyState = findEmptyState();
});
- it('should render the empty state', async () => {
+ it('should render the empty state', () => {
expect(emptyState.exists()).toBe(true);
});
@@ -221,7 +221,7 @@ describe('Feature flags', () => {
});
it('should render a table with feature flags', () => {
- const table = wrapper.findComponent(FeatureFlagsTable);
+ const table = findFeatureFlagsTable();
expect(table.exists()).toBe(true);
expect(table.props('featureFlags')).toEqual(
expect.arrayContaining([
@@ -234,7 +234,7 @@ describe('Feature flags', () => {
});
it('should toggle a flag when receiving the toggle-flag event', () => {
- const table = wrapper.findComponent(FeatureFlagsTable);
+ const table = findFeatureFlagsTable();
const [flag] = table.props('featureFlags');
table.vm.$emit('toggle-flag', flag);
@@ -257,15 +257,15 @@ describe('Feature flags', () => {
describe('pagination', () => {
it('should render pagination', () => {
- expect(wrapper.findComponent(TablePagination).exists()).toBe(true);
+ expect(findTablePagination().exists()).toBe(true);
});
it('should make an API request when page is clicked', () => {
- jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions');
- wrapper.findComponent(TablePagination).vm.change(4);
+ const axiosGet = jest.spyOn(axios, 'get');
+ findTablePagination().vm.change(4);
- expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({
- page: '4',
+ expect(axiosGet).toHaveBeenCalledWith('http://test.host/endpoint.json', {
+ params: { page: '4' },
});
});
});
@@ -274,16 +274,12 @@ describe('Feature flags', () => {
describe('unsuccessful request', () => {
beforeEach(() => {
- mock
- .onGet(mockState.endpoint, { params: { page: '1' } })
- .replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR, {});
-
factory();
return waitForPromises();
});
it('should render error state', () => {
- const emptyState = wrapper.findComponent(GlEmptyState);
+ const emptyState = findEmptyState();
expect(emptyState.props('title')).toEqual('There was an error fetching the feature flags.');
expect(emptyState.props('description')).toEqual(
'Try again in a few moments or contact your support team.',
@@ -305,20 +301,12 @@ describe('Feature flags', () => {
});
describe('rotate instance id', () => {
- beforeEach(() => {
- mock
- .onGet(`${TEST_HOST}/endpoint.json`, { params: { page: '1' } })
- .reply(HTTP_STATUS_OK, getRequestData, {});
- factory();
- return waitForPromises();
- });
-
it('should fire the rotate action when a `token` event is received', () => {
- const actionSpy = jest.spyOn(wrapper.vm, 'rotateInstanceId');
- const modal = wrapper.findComponent(ConfigureFeatureFlagsModal);
- modal.vm.$emit('token');
+ factory();
+ const axiosPost = jest.spyOn(axios, 'post');
+ wrapper.findComponent(ConfigureFeatureFlagsModal).vm.$emit('token');
- expect(actionSpy).toHaveBeenCalled();
+ expect(axiosPost).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js
index f23bca54b55..02a8e38dc2a 100644
--- a/spec/frontend/feature_flags/components/feature_flags_table_spec.js
+++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js
@@ -1,5 +1,6 @@
-import { GlToggle } from '@gitlab/ui';
+import { GlIcon, GlToggle } from '@gitlab/ui';
import { nextTick } from 'vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
import { mockTracking } from 'helpers/tracking_helper';
@@ -46,6 +47,13 @@ const getDefaultProps = () => ({
},
],
},
+ {
+ id: 2,
+ iid: 2,
+ active: true,
+ name: 'flag without description',
+ description: '',
+ },
],
});
@@ -61,6 +69,9 @@ describe('Feature flag table', () => {
csrfToken: 'fakeToken',
},
...opts,
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
};
@@ -105,10 +116,6 @@ describe('Feature flag table', () => {
it('Should render a feature flag column', () => {
expect(wrapper.find('.js-feature-flag-title').exists()).toBe(true);
expect(trimText(wrapper.find('.feature-flag-name').text())).toEqual('flag name');
-
- expect(trimText(wrapper.find('.feature-flag-description').text())).toEqual(
- 'flag description',
- );
});
it('should render an environments specs label', () => {
@@ -125,6 +132,37 @@ describe('Feature flag table', () => {
});
});
+ describe.each(getDefaultProps().featureFlags)('description tooltip', (featureFlag) => {
+ beforeEach(() => {
+ createWrapper(props);
+ });
+
+ const haveInfoIcon = Boolean(featureFlag.description);
+
+ it(`${haveInfoIcon ? 'displays' : "doesn't display"} an information icon`, () => {
+ expect(
+ wrapper
+ .findByTestId(featureFlag.id)
+ .find('.feature-flag-description')
+ .findComponent(GlIcon)
+ .exists(),
+ ).toBe(haveInfoIcon);
+ });
+
+ if (haveInfoIcon) {
+ it('includes a tooltip', () => {
+ const icon = wrapper
+ .findByTestId(featureFlag.id)
+ .find('.feature-flag-description')
+ .findComponent(GlIcon);
+ const tooltip = getBinding(icon.element, 'gl-tooltip');
+
+ expect(tooltip).toBeDefined();
+ expect(tooltip.value).toBe(featureFlag.description);
+ });
+ }
+ });
+
describe('when active and with an update toggle', () => {
let toggle;
let spy;
diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js
index 7dd7c709c94..f66e25698e6 100644
--- a/spec/frontend/feature_flags/components/form_spec.js
+++ b/spec/frontend/feature_flags/components/form_spec.js
@@ -42,10 +42,6 @@ describe('feature flag form', () => {
Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [] });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render provided submitText', () => {
factory(requiredProps);
diff --git a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js
index 14e1f34bc59..6156addd63f 100644
--- a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js
+++ b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js
@@ -22,10 +22,6 @@ describe('New Environments Dropdown', () => {
afterEach(() => {
axiosMock.restore();
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
});
describe('before results', () => {
diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
index 300d0e47082..c5418477661 100644
--- a/spec/frontend/feature_flags/components/new_feature_flag_spec.js
+++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
@@ -22,10 +22,6 @@ describe('New feature flag form', () => {
});
const factory = (opts = {}) => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
wrapper = shallowMount(NewFeatureFlag, {
store,
provide: {
@@ -46,10 +42,6 @@ describe('New feature flag form', () => {
factory();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with error', () => {
it('should render the error', async () => {
store.dispatch('receiveCreateFeatureFlagError', { message: ['The name is required'] });
diff --git a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js
index 70a9156b5a9..a6eb81ef6f0 100644
--- a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js
+++ b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js
@@ -20,14 +20,6 @@ describe('feature_flags/components/strategies/flexible_rollout.vue', () => {
const factory = (props = {}) =>
mount(FlexibleRollout, { propsData: { ...DEFAULT_PROPS, ...props } });
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
-
- wrapper = null;
- });
-
describe('with valid percentage', () => {
beforeEach(() => {
wrapper = factory();
diff --git a/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js b/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js
index 23ad0d3a08d..8ad70466e90 100644
--- a/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js
+++ b/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js
@@ -24,14 +24,6 @@ describe('~/feature_flags/strategies/parameter_form_group.vue', () => {
slot = wrapper.find('[data-testid="slot"]');
});
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
-
- wrapper = null;
- });
-
it('should display the default slot', () => {
expect(slot.exists()).toBe(true);
});
diff --git a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js
index cb422a018f9..e00869fdd09 100644
--- a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js
+++ b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js
@@ -18,14 +18,6 @@ describe('~/feature_flags/components/strategies/percent_rollout.vue', () => {
const factory = (props = {}) =>
mount(PercentRollout, { propsData: { ...DEFAULT_PROPS, ...props } });
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
-
- wrapper = null;
- });
-
describe('with valid percentage', () => {
beforeEach(() => {
wrapper = factory();
diff --git a/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js b/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js
index 0a72714c22a..f3b8535a650 100644
--- a/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js
+++ b/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js
@@ -18,14 +18,6 @@ describe('~/feature_flags/components/users_with_id.vue', () => {
textarea = wrapper.findComponent(GlFormTextarea);
});
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
-
- wrapper = null;
- });
-
it('should display the current value of the parameters', () => {
expect(textarea.element.value).toBe(usersWithIdStrategy.parameters.userIds);
});
diff --git a/spec/frontend/feature_flags/components/strategy_parameters_spec.js b/spec/frontend/feature_flags/components/strategy_parameters_spec.js
index d0f1f7d0e2a..bc34888d1c1 100644
--- a/spec/frontend/feature_flags/components/strategy_parameters_spec.js
+++ b/spec/frontend/feature_flags/components/strategy_parameters_spec.js
@@ -28,14 +28,6 @@ describe('~/feature_flags/components/strategy_parameters.vue', () => {
},
});
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
-
- wrapper = null;
- });
-
describe.each`
name | component
${ROLLOUT_STRATEGY_ALL_USERS} | ${Default}
diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js
index 84d4180fe63..1428d99aa76 100644
--- a/spec/frontend/feature_flags/components/strategy_spec.js
+++ b/spec/frontend/feature_flags/components/strategy_spec.js
@@ -44,10 +44,6 @@ describe('Feature flags strategy', () => {
provide,
},
) => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
wrapper = mount(Strategy, { store: createStore({ projectId: '1' }), ...opts });
};
@@ -55,13 +51,6 @@ describe('Feature flags strategy', () => {
Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
describe('helper links', () => {
const propsData = { strategy: {}, index: 0, userLists: [userList] };
factory({ propsData, provide });
diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
index 4d5cb26810e..4609bfc23d7 100644
--- a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
+++ b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
@@ -1,10 +1,10 @@
import MockAdapter from 'axios-mock-adapter';
import { dismiss } from '~/feature_highlight/feature_highlight_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_CREATED, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('feature highlight helper', () => {
describe('dismiss', () => {
@@ -26,7 +26,7 @@ describe('feature highlight helper', () => {
await expect(dismiss(endpoint, highlightId)).resolves.toEqual(expect.anything());
});
- it('triggers flash when dismiss request fails', async () => {
+ it('triggers an alert when dismiss request fails', async () => {
mockAxios
.onPost(endpoint, { feature_name: highlightId })
.replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
diff --git a/spec/frontend/feature_highlight/feature_highlight_popover_spec.js b/spec/frontend/feature_highlight/feature_highlight_popover_spec.js
index 650f9eb1bbc..66ea22cece3 100644
--- a/spec/frontend/feature_highlight/feature_highlight_popover_spec.js
+++ b/spec/frontend/feature_highlight/feature_highlight_popover_spec.js
@@ -29,11 +29,6 @@ describe('feature_highlight/feature_highlight_popover', () => {
buildWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders popover target', () => {
expect(findPopoverTarget().exists()).toBe(true);
});
diff --git a/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js
index ebed477fa2f..5f0e928e1fe 100644
--- a/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js
+++ b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js
@@ -22,11 +22,6 @@ describe('Recent Searches Dropdown Content', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when local storage is not available', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js
index 26f12673f68..8ddf8390431 100644
--- a/spec/frontend/filtered_search/dropdown_user_spec.js
+++ b/spec/frontend/filtered_search/dropdown_user_spec.js
@@ -1,4 +1,5 @@
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlMergeRequestList from 'test_fixtures/merge_requests/merge_request_list.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import DropdownUser from '~/filtered_search/dropdown_user';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
@@ -68,20 +69,14 @@ describe('Dropdown User', () => {
'/gitlab_directory/-/autocomplete/users.json',
);
});
-
- afterEach(() => {
- window.gon = {};
- });
});
describe('hideCurrentUser', () => {
- const fixtureTemplate = 'merge_requests/merge_request_list.html';
-
let dropdown;
let authorFilterDropdownElement;
beforeEach(() => {
- loadHTMLFixture(fixtureTemplate);
+ setHTMLFixture(htmlMergeRequestList);
authorFilterDropdownElement = document.querySelector('#js-dropdown-author');
const dummyInput = document.createElement('div');
dropdown = new DropdownUser({
diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js
index 2030b45b44c..d8a5b493b7a 100644
--- a/spec/frontend/filtered_search/dropdown_utils_spec.js
+++ b/spec/frontend/filtered_search/dropdown_utils_spec.js
@@ -1,12 +1,11 @@
-import { loadHTMLFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlMergeRequestList from 'test_fixtures/merge_requests/merge_request_list.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
describe('Dropdown Utils', () => {
- const issuableListFixture = 'merge_requests/merge_request_list.html';
-
describe('getEscapedText', () => {
it('should return same word when it has no space', () => {
const escaped = DropdownUtils.getEscapedText('textWithoutSpace');
@@ -355,7 +354,7 @@ describe('Dropdown Utils', () => {
let authorToken;
beforeEach(() => {
- loadHTMLFixture(issuableListFixture);
+ setHTMLFixture(htmlMergeRequestList);
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js
index 26af7af701b..8c16ff100eb 100644
--- a/spec/frontend/filtered_search/filtered_search_manager_spec.js
+++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js
@@ -8,11 +8,11 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes';
import { visitUrl, getParameterByName } from '~/lib/utils/url_utility';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
getParameterByName: jest.fn(),
diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js
index d3fa8fae9ab..138a4e183a9 100644
--- a/spec/frontend/filtered_search/visual_token_value_spec.js
+++ b/spec/frontend/filtered_search/visual_token_value_spec.js
@@ -5,11 +5,11 @@ import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper';
import { TEST_HOST } from 'helpers/test_constants';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import VisualTokenValue from '~/filtered_search/visual_token_value';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import AjaxCache from '~/lib/utils/ajax_cache';
import UsersCache from '~/lib/utils/users_cache';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Filtered Search Visual Tokens', () => {
const findElements = (tokenElement) => {
diff --git a/spec/frontend/fixtures/abuse_reports.rb b/spec/frontend/fixtures/abuse_reports.rb
index d8c8737b125..ad0fb9be8dc 100644
--- a/spec/frontend/fixtures/abuse_reports.rb
+++ b/spec/frontend/fixtures/abuse_reports.rb
@@ -14,6 +14,8 @@ RSpec.describe Admin::AbuseReportsController, '(JavaScript fixtures)', type: :co
render_views
before do
+ stub_feature_flags(abuse_reports_list: false)
+
sign_in(admin)
enable_admin_mode!(admin)
end
diff --git a/spec/frontend/fixtures/api_deploy_keys.rb b/spec/frontend/fixtures/api_deploy_keys.rb
index 5ffc726f086..8c926296817 100644
--- a/spec/frontend/fixtures/api_deploy_keys.rb
+++ b/spec/frontend/fixtures/api_deploy_keys.rb
@@ -7,6 +7,7 @@ RSpec.describe API::DeployKeys, '(JavaScript fixtures)', type: :request do
include JavaScriptFixturesHelpers
let_it_be(:admin) { create(:admin) }
+ let_it_be(:path) { "/deploy_keys" }
let_it_be(:project) { create(:project) }
let_it_be(:project2) { create(:project) }
let_it_be(:deploy_key) { create(:deploy_key, public: true) }
@@ -17,8 +18,10 @@ RSpec.describe API::DeployKeys, '(JavaScript fixtures)', type: :request do
let_it_be(:deploy_keys_project3) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key2) }
let_it_be(:deploy_keys_project4) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key2) }
+ it_behaves_like 'GET request permissions for admin mode'
+
it 'api/deploy_keys/index.json' do
- get api("/deploy_keys", admin)
+ get api("/deploy_keys", admin, admin_mode: true)
expect(response).to be_successful
end
diff --git a/spec/frontend/fixtures/api_projects.rb b/spec/frontend/fixtures/api_projects.rb
index d1dfd223419..24c47d8d139 100644
--- a/spec/frontend/fixtures/api_projects.rb
+++ b/spec/frontend/fixtures/api_projects.rb
@@ -6,10 +6,11 @@ RSpec.describe API::Projects, '(JavaScript fixtures)', type: :request do
include ApiHelpers
include JavaScriptFixturesHelpers
- let(:namespace) { create(:namespace, name: 'gitlab-test') }
- let(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') }
- let(:project_empty) { create(:project_empty_repo, namespace: namespace, path: 'lorem-ipsum-empty') }
- let(:user) { project.owner }
+ let_it_be(:namespace) { create(:namespace, name: 'gitlab-test') }
+ let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') }
+ let_it_be(:project_empty) { create(:project_empty_repo, namespace: namespace, path: 'lorem-ipsum-empty') }
+ let_it_be(:user) { project.owner }
+ let_it_be(:personal_projects) { create_list(:project, 3, namespace: user.namespace, topics: create_list(:topic, 5)) }
it 'api/projects/get.json' do
get api("/projects/#{project.id}", user)
@@ -28,4 +29,10 @@ RSpec.describe API::Projects, '(JavaScript fixtures)', type: :request do
expect(response).to be_successful
end
+
+ it 'api/users/projects/get.json' do
+ get api("/users/#{user.id}/projects", user)
+
+ expect(response).to be_successful
+ end
end
diff --git a/spec/frontend/fixtures/comment_templates.rb b/spec/frontend/fixtures/comment_templates.rb
new file mode 100644
index 00000000000..32f425d7ebd
--- /dev/null
+++ b/spec/frontend/fixtures/comment_templates.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GraphQL::Query, type: :request, feature_category: :user_profile do
+ include JavaScriptFixturesHelpers
+ include ApiHelpers
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+
+ before do
+ sign_in(current_user)
+ end
+
+ context 'when user has no comment templates' do
+ base_input_path = 'comment_templates/queries/'
+ base_output_path = 'graphql/comment_templates/'
+ query_name = 'saved_replies.query.graphql'
+
+ it "#{base_output_path}saved_replies_empty.query.graphql.json" do
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
+
+ post_graphql(query, current_user: current_user)
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'when user has comment templates' do
+ base_input_path = 'comment_templates/queries/'
+ base_output_path = 'graphql/comment_templates/'
+ query_name = 'saved_replies.query.graphql'
+
+ it "#{base_output_path}saved_replies.query.graphql.json" do
+ create(:saved_reply, user: current_user)
+ create(:saved_reply, user: current_user)
+
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
+
+ post_graphql(query, current_user: current_user)
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'when user creates comment template' do
+ base_input_path = 'comment_templates/queries/'
+ base_output_path = 'graphql/comment_templates/'
+ query_name = 'create_saved_reply.mutation.graphql'
+
+ it "#{base_output_path}#{query_name}.json" do
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
+
+ post_graphql(query, current_user: current_user, variables: { name: "Test", content: "Test content" })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'when user creates comment template and it errors' do
+ base_input_path = 'comment_templates/queries/'
+ base_output_path = 'graphql/comment_templates/'
+ query_name = 'create_saved_reply.mutation.graphql'
+
+ it "#{base_output_path}create_saved_reply_with_errors.mutation.graphql.json" do
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
+
+ post_graphql(query, current_user: current_user, variables: { name: nil, content: nil })
+
+ expect(flattened_errors).not_to be_empty
+ end
+ end
+end
diff --git a/spec/frontend/fixtures/environments.rb b/spec/frontend/fixtures/environments.rb
index 77e2a96b328..81f1eb11e3e 100644
--- a/spec/frontend/fixtures/environments.rb
+++ b/spec/frontend/fixtures/environments.rb
@@ -44,7 +44,7 @@ RSpec.describe 'Environments (JavaScript fixtures)', feature_category: :environm
end
let_it_be(:deployment_success) do
- create(:deployment, :success, environment: environment, deployable: build)
+ create(:deployment, :success, environment: environment, deployable: build, finished_at: 1.hour.since)
end
let_it_be(:deployment_failed) do
diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb
index 1e6baf30a76..e85e683b599 100644
--- a/spec/frontend/fixtures/issues.rb
+++ b/spec/frontend/fixtures/issues.rb
@@ -20,15 +20,6 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', :with_licens
remove_repository(project)
end
- it 'issues/new-issue.html' do
- get :new, params: {
- namespace_id: project.namespace.to_param,
- project_id: project
- }
-
- expect(response).to be_successful
- end
-
it 'issues/open-issue.html' do
render_issue(create(:issue, project: project))
end
diff --git a/spec/frontend/fixtures/job_artifacts.rb b/spec/frontend/fixtures/job_artifacts.rb
index e53cdbbaaa5..6dadd6750f1 100644
--- a/spec/frontend/fixtures/job_artifacts.rb
+++ b/spec/frontend/fixtures/job_artifacts.rb
@@ -12,7 +12,7 @@ RSpec.describe 'Job Artifacts (GraphQL fixtures)' do
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
let_it_be(:user) { create(:user) }
- job_artifacts_query_path = 'artifacts/graphql/queries/get_job_artifacts.query.graphql'
+ job_artifacts_query_path = 'ci/artifacts/graphql/queries/get_job_artifacts.query.graphql'
it "graphql/#{job_artifacts_query_path}.json" do
create(:ci_build, :failed, :artifacts, :trace_artifact, pipeline: pipeline)
diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb
index 6d452bf1bff..6c0b87c5a68 100644
--- a/spec/frontend/fixtures/jobs.rb
+++ b/spec/frontend/fixtures/jobs.rb
@@ -48,49 +48,71 @@ RSpec.describe 'Jobs (JavaScript fixtures)' do
let!(:with_artifact) { create(:ci_build, :success, name: 'with_artifact', job_artifacts: [artifact], pipeline: pipeline) }
let!(:with_coverage) { create(:ci_build, :success, name: 'with_coverage', coverage: 40.0, pipeline: pipeline) }
- fixtures_path = 'graphql/jobs/'
- get_jobs_query = 'get_jobs.query.graphql'
- full_path = 'frontend-fixtures/builds-project'
+ shared_examples 'graphql queries' do |path, jobs_query, skip_non_defaults = false|
+ let_it_be(:variables) { {} }
+ let_it_be(:success_path) { '' }
- let_it_be(:query) do
- get_graphql_query_as_string("jobs/components/table/graphql/queries/#{get_jobs_query}")
- end
+ let_it_be(:query) do
+ get_graphql_query_as_string("#{path}/#{jobs_query}")
+ end
- it "#{fixtures_path}#{get_jobs_query}.json" do
- post_graphql(query, current_user: user, variables: {
- fullPath: full_path
- })
+ fixtures_path = 'graphql/jobs/'
- expect_graphql_errors_to_be_empty
- end
+ it "#{fixtures_path}#{jobs_query}.json", :aggregate_failures do
+ post_graphql(query, current_user: user, variables: variables)
+
+ expect(graphql_data.dig(*success_path)).not_to be_nil
+ expect_graphql_errors_to_be_empty
+ end
+
+ context 'with non default fixtures', if: !skip_non_defaults do
+ it "#{fixtures_path}#{jobs_query}.as_guest.json" do
+ guest = create(:user)
+ project.add_guest(guest)
+
+ post_graphql(query, current_user: guest, variables: variables)
+
+ expect_graphql_errors_to_be_empty
+ end
- it "#{fixtures_path}#{get_jobs_query}.as_guest.json" do
- guest = create(:user)
- project.add_guest(guest)
+ it "#{fixtures_path}#{jobs_query}.paginated.json" do
+ post_graphql(query, current_user: user, variables: variables.merge({ first: 2 }))
- post_graphql(query, current_user: guest, variables: {
- fullPath: full_path
- })
+ expect_graphql_errors_to_be_empty
+ end
- expect_graphql_errors_to_be_empty
+ it "#{fixtures_path}#{jobs_query}.empty.json" do
+ post_graphql(query, current_user: user, variables: variables.merge({ first: 0 }))
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
end
- it "#{fixtures_path}#{get_jobs_query}.paginated.json" do
- post_graphql(query, current_user: user, variables: {
- fullPath: full_path,
- first: 2
- })
+ it_behaves_like 'graphql queries', 'jobs/components/table/graphql/queries', 'get_jobs.query.graphql' do
+ let(:variables) { { fullPath: 'frontend-fixtures/builds-project' } }
+ let(:success_path) { %w[project jobs] }
+ end
- expect_graphql_errors_to_be_empty
+ it_behaves_like 'graphql queries', 'jobs/components/table/graphql/queries', 'get_jobs_count.query.graphql', true do
+ let(:variables) { { fullPath: 'frontend-fixtures/builds-project' } }
+ let(:success_path) { %w[project jobs] }
end
- it "#{fixtures_path}#{get_jobs_query}.empty.json" do
- post_graphql(query, current_user: user, variables: {
- fullPath: full_path,
- first: 0
- })
+ it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_all_jobs.query.graphql' do
+ let(:user) { create(:admin) }
+ let(:success_path) { 'jobs' }
+ end
+
+ it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_cancelable_jobs_count.query.graphql', true do
+ let(:variables) { { statuses: %w[PENDING RUNNING] } }
+ let(:user) { create(:admin) }
+ let(:success_path) { %w[cancelable count] }
+ end
- expect_graphql_errors_to_be_empty
+ it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_all_jobs_count.query.graphql', true do
+ let(:user) { create(:admin) }
+ let(:success_path) { 'jobs' }
end
end
end
diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb
index 7ee89ca3694..b6f6d149756 100644
--- a/spec/frontend/fixtures/merge_requests.rb
+++ b/spec/frontend/fixtures/merge_requests.rb
@@ -151,7 +151,7 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type:
context 'merge request with no approvals' do
base_input_path = 'vue_merge_request_widget/components/approvals/queries/'
base_output_path = 'graphql/merge_requests/approvals/'
- query_name = 'approved_by.query.graphql'
+ query_name = 'approvals.query.graphql'
it "#{base_output_path}#{query_name}_no_approvals.json" do
query = get_graphql_query_as_string("#{base_input_path}#{query_name}", ee: Gitlab.ee?)
@@ -165,7 +165,7 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type:
context 'merge request approved by current user' do
base_input_path = 'vue_merge_request_widget/components/approvals/queries/'
base_output_path = 'graphql/merge_requests/approvals/'
- query_name = 'approved_by.query.graphql'
+ query_name = 'approvals.query.graphql'
it "#{base_output_path}#{query_name}.json" do
merge_request.approved_by_users << user
@@ -181,7 +181,7 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type:
context 'merge request approved by multiple users' do
base_input_path = 'vue_merge_request_widget/components/approvals/queries/'
base_output_path = 'graphql/merge_requests/approvals/'
- query_name = 'approved_by.query.graphql'
+ query_name = 'approvals.query.graphql'
it "#{base_output_path}#{query_name}_multiple_users.json" do
merge_request.approved_by_users << user
diff --git a/spec/frontend/fixtures/metrics_dashboard.rb b/spec/frontend/fixtures/metrics_dashboard.rb
index 109b016d980..036ce9eea3a 100644
--- a/spec/frontend/fixtures/metrics_dashboard.rb
+++ b/spec/frontend/fixtures/metrics_dashboard.rb
@@ -17,6 +17,7 @@ RSpec.describe MetricsDashboard, '(JavaScript fixtures)', type: :controller do
end
before do
+ stub_feature_flags(remove_monitor_metrics: false)
sign_in(user)
project.add_maintainer(user)
diff --git a/spec/frontend/fixtures/milestones.rb b/spec/frontend/fixtures/milestones.rb
new file mode 100644
index 00000000000..5e39dcf190a
--- /dev/null
+++ b/spec/frontend/fixtures/milestones.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::MilestonesController, '(JavaScript fixtures)', :with_license, feature_category: :team_planning, type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let_it_be(:user) { create(:user, feed_token: 'feedtoken:coldfeed') }
+ let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') }
+ let_it_be(:project) { create(:project_empty_repo, namespace: namespace, path: 'milestones-project') }
+
+ render_views
+
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ after do
+ remove_repository(project)
+ end
+
+ it 'milestones/new-milestone.html' do
+ get :new, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project
+ }
+
+ expect(response).to be_successful
+ end
+
+ private
+
+ def render_milestone(milestone)
+ get :show, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: milestone.to_param
+ }
+
+ expect(response).to be_successful
+ end
+end
diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb
index 768934d6278..24a6f6f7de6 100644
--- a/spec/frontend/fixtures/pipelines.rb
+++ b/spec/frontend/fixtures/pipelines.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do
+ include ApiHelpers
+ include GraphqlHelpers
include JavaScriptFixturesHelpers
let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') }
@@ -56,4 +58,27 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co
expect(response).to be_successful
end
+
+ describe GraphQL::Query, type: :request do
+ fixtures_path = 'graphql/pipelines/'
+ get_pipeline_actions_query = 'get_pipeline_actions.query.graphql'
+
+ let!(:pipeline_with_manual_actions) { create(:ci_pipeline, project: project, user: user) }
+ let!(:build_scheduled) { create(:ci_build, :scheduled, pipeline: pipeline_with_manual_actions, stage: 'test') }
+ let!(:build_manual) { create(:ci_build, :manual, pipeline: pipeline_with_manual_actions, stage: 'build') }
+ let!(:build_manual_cannot_play) do
+ create(:ci_build, :manual, :skipped, pipeline: pipeline_with_manual_actions, stage: 'build')
+ end
+
+ let_it_be(:query) do
+ get_graphql_query_as_string("pipelines/graphql/queries/#{get_pipeline_actions_query}")
+ end
+
+ it "#{fixtures_path}#{get_pipeline_actions_query}.json" do
+ post_graphql(query, current_user: user,
+ variables: { fullPath: project.full_path, iid: pipeline_with_manual_actions.iid })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
end
diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb
index 2ccf2c0392f..8cd651c5b36 100644
--- a/spec/frontend/fixtures/projects.rb
+++ b/spec/frontend/fixtures/projects.rb
@@ -67,7 +67,7 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
end
end
- describe 'Storage', feature_category: :subscription_cost_management do
+ describe 'Storage', feature_category: :consumables_cost_management do
describe GraphQL::Query, type: :request do
include GraphqlHelpers
context 'project storage statistics query' do
diff --git a/spec/frontend/fixtures/prometheus_integration.rb b/spec/frontend/fixtures/prometheus_integration.rb
index 13130c00118..fcba8b596a8 100644
--- a/spec/frontend/fixtures/prometheus_integration.rb
+++ b/spec/frontend/fixtures/prometheus_integration.rb
@@ -14,6 +14,7 @@ RSpec.describe Projects::Settings::IntegrationsController, '(JavaScript fixtures
before do
sign_in(user)
+ stub_feature_flags(remove_monitor_metrics: false)
end
after do
diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb
index f60e4991292..099df607487 100644
--- a/spec/frontend/fixtures/runner.rb
+++ b/spec/frontend/fixtures/runner.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Runner (JavaScript fixtures)' do
+RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :runner_fleet do
include AdminModeHelper
include ApiHelpers
include JavaScriptFixturesHelpers
@@ -13,7 +13,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:project_2) { create(:project, :repository, :public) }
- let_it_be(:runner) { create(:ci_runner, :instance, description: 'My Runner', version: '1.0.0') }
+ let_it_be(:runner) { create(:ci_runner, :instance, description: 'My Runner', creator: admin, version: '1.0.0') }
let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], version: '2.0.0') }
let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], version: '2.0.0') }
let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project, project_2], version: '2.0.0') }
@@ -58,6 +58,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
expect_graphql_errors_to_be_empty
end
+
+ it "#{fixtures_path}#{all_runners_query}.with_creator.json" do
+ # "last: 1" fetches the first runner created, with admin as "creator"
+ post_graphql(query, current_user: admin, variables: { last: 1 })
+
+ expect_graphql_errors_to_be_empty
+ end
end
describe 'all_runners_count.query.graphql', type: :request do
@@ -145,6 +152,43 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
expect_graphql_errors_to_be_empty
end
end
+
+ describe 'runner_for_registration.query.graphql', :freeze_time, type: :request do
+ runner_for_registration_query = 'register/runner_for_registration.query.graphql'
+
+ let_it_be(:query) do
+ get_graphql_query_as_string("#{query_path}#{runner_for_registration_query}")
+ end
+
+ it "#{fixtures_path}#{runner_for_registration_query}.json" do
+ post_graphql(query, current_user: admin, variables: {
+ id: runner.to_global_id.to_s
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ describe 'runner_create.mutation.graphql', type: :request do
+ runner_create_mutation = 'new/runner_create.mutation.graphql'
+
+ let_it_be(:query) do
+ get_graphql_query_as_string("#{query_path}#{runner_create_mutation}")
+ end
+
+ context 'with runnerType set to INSTANCE_TYPE' do
+ it "#{fixtures_path}#{runner_create_mutation}.json" do
+ post_graphql(query, current_user: admin, variables: {
+ input: {
+ runnerType: 'INSTANCE_TYPE',
+ description: 'My dummy runner'
+ }
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+ end
end
describe 'as group owner', GraphQL::Query do
diff --git a/spec/frontend/fixtures/saved_replies.rb b/spec/frontend/fixtures/saved_replies.rb
deleted file mode 100644
index c80ba06bca1..00000000000
--- a/spec/frontend/fixtures/saved_replies.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe GraphQL::Query, type: :request, feature_category: :user_profile do
- include JavaScriptFixturesHelpers
- include ApiHelpers
- include GraphqlHelpers
-
- let_it_be(:current_user) { create(:user) }
-
- before do
- sign_in(current_user)
- end
-
- context 'when user has no saved replies' do
- base_input_path = 'saved_replies/queries/'
- base_output_path = 'graphql/saved_replies/'
- query_name = 'saved_replies.query.graphql'
-
- it "#{base_output_path}saved_replies_empty.query.graphql.json" do
- query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
-
- post_graphql(query, current_user: current_user)
-
- expect_graphql_errors_to_be_empty
- end
- end
-
- context 'when user has saved replies' do
- base_input_path = 'saved_replies/queries/'
- base_output_path = 'graphql/saved_replies/'
- query_name = 'saved_replies.query.graphql'
-
- it "#{base_output_path}saved_replies.query.graphql.json" do
- create(:saved_reply, user: current_user)
- create(:saved_reply, user: current_user)
-
- query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
-
- post_graphql(query, current_user: current_user)
-
- 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 bd2d63a1827..5b09e1c9495 100644
--- a/spec/frontend/fixtures/startup_css.rb
+++ b/spec/frontend/fixtures/startup_css.rb
@@ -16,7 +16,6 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
before do
# We want vNext badge to be included and com/canary don't remove/hide any other elements.
# This is why we're turning com and canary on by default for now.
- allow(Gitlab).to receive(:com?).and_return(true)
allow(Gitlab).to receive(:canary?).and_return(true)
sign_in(user)
end
@@ -41,12 +40,12 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
expect(response).to be_successful
end
- # This Feature Flag is on by default
- # This ensures that the correct css is generated
- # When the feature flag is on, the general startup will capture it
- # This will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/339348
- it "startup_css/project-#{type}-search-ff-off.html" do
- stub_feature_flags(new_header_search: false)
+ # This Feature Flag is off by default
+ # This ensures that the correct css is generated for super sidebar
+ # When the feature flag is off, the general startup will capture it
+ it "startup_css/project-#{type}-super-sidebar.html" do
+ stub_feature_flags(super_sidebar_nav: true)
+ user.update!(use_new_navigation: true)
get :show, params: {
namespace_id: project.namespace.to_param,
@@ -57,11 +56,11 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
end
end
- describe ProjectsController, '(Startup CSS fixtures)', type: :controller do
+ describe ProjectsController, '(Startup CSS fixtures)', :saas, type: :controller do
it_behaves_like 'startup css project fixtures', 'general'
end
- describe ProjectsController, '(Startup CSS fixtures)', type: :controller do
+ describe ProjectsController, '(Startup CSS fixtures)', :saas, type: :controller do
before do
user.update!(theme_id: 11)
end
diff --git a/spec/frontend/fixtures/static/oauth_remember_me.html b/spec/frontend/fixtures/static/oauth_remember_me.html
index 0b4d482925d..60277ecf66e 100644
--- a/spec/frontend/fixtures/static/oauth_remember_me.html
+++ b/spec/frontend/fixtures/static/oauth_remember_me.html
@@ -1,5 +1,5 @@
<div id="oauth-container">
- <input id="remember_me" type="checkbox" />
+ <input id="remember_me_omniauth" type="checkbox" />
<form method="post" action="http://example.com/">
<button class="js-oauth-login twitter" type="submit">
diff --git a/spec/frontend/fixtures/static/search_autocomplete.html b/spec/frontend/fixtures/static/search_autocomplete.html
deleted file mode 100644
index 29db9020424..00000000000
--- a/spec/frontend/fixtures/static/search_autocomplete.html
+++ /dev/null
@@ -1,15 +0,0 @@
-<div class="search search-form">
-<form class="form-inline">
-<div class="search-input-container">
-<div class="search-input-wrap">
-<div class="dropdown">
-<input class="search-input dropdown-menu-toggle" id="search">
-<div class="dropdown-menu dropdown-select">
-<div class="dropdown-content"></div>
-</div>
-</div>
-</div>
-</div>
-<input class="js-search-project-options" type="hidden">
-</form>
-</div>
diff --git a/spec/frontend/fixtures/timelogs.rb b/spec/frontend/fixtures/timelogs.rb
new file mode 100644
index 00000000000..c66e2447ea6
--- /dev/null
+++ b/spec/frontend/fixtures/timelogs.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Timelogs (GraphQL fixtures)', feature_category: :team_planning do
+ describe GraphQL::Query, type: :request do
+ include ApiHelpers
+ include GraphqlHelpers
+ include JavaScriptFixturesHelpers
+
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+
+ context 'for time tracking timelogs' do
+ let_it_be(:project) { create(:project_empty_repo, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ let(:query_path) { 'time_tracking/components/queries/get_timelogs.query.graphql' }
+ let(:query) { get_graphql_query_as_string(query_path) }
+
+ before_all do
+ project.add_guest(guest)
+ project.add_developer(developer)
+ end
+
+ it "graphql/get_timelogs_empty_response.json" do
+ post_graphql(query, current_user: guest, variables: { username: guest.username })
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ context 'with 20 or less timelogs' do
+ let_it_be(:timelogs) { create_list(:timelog, 6, user: developer, issue: issue, time_spent: 4 * 60 * 60) }
+
+ it "graphql/get_non_paginated_timelogs_response.json" do
+ post_graphql(query, current_user: guest, variables: { username: developer.username })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'with more than 20 timelogs' do
+ let_it_be(:timelogs) { create_list(:timelog, 30, user: developer, issue: issue, time_spent: 4 * 60 * 60) }
+
+ it "graphql/get_paginated_timelogs_response.json" do
+ post_graphql(query, current_user: guest, variables: { username: developer.username, first: 25 })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+ end
+ end
+end
diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb
deleted file mode 100644
index 96820c9ae80..00000000000
--- a/spec/frontend/fixtures/u2f.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.context 'U2F' do
- include JavaScriptFixturesHelpers
-
- let(:user) { create(:user, :two_factor_via_u2f, otp_secret: 'otpsecret:coolkids') }
-
- before do
- stub_feature_flags(webauthn: false)
- end
-
- describe SessionsController, '(JavaScript fixtures)', type: :controller do
- include DeviseHelpers
-
- render_views
-
- before do
- set_devise_mapping(context: @request)
- end
-
- it 'u2f/authenticate.html' do
- allow(controller).to receive(:find_user).and_return(user)
-
- post :create, params: { user: { login: user.username, password: user.password } }
-
- expect(response).to be_successful
- end
- end
-
- describe Profiles::TwoFactorAuthsController, '(JavaScript fixtures)', type: :controller do
- render_views
-
- before do
- sign_in(user)
- allow_next_instance_of(Profiles::TwoFactorAuthsController) do |instance|
- allow(instance).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares')
- end
- end
-
- it 'u2f/register.html' do
- get :show
-
- expect(response).to be_successful
- end
- end
-end
diff --git a/spec/frontend/fixtures/users.rb b/spec/frontend/fixtures/users.rb
new file mode 100644
index 00000000000..0e9d7475bf9
--- /dev/null
+++ b/spec/frontend/fixtures/users.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Users (GraphQL fixtures)', feature_category: :user_profile do
+ describe GraphQL::Query, type: :request do
+ include ApiHelpers
+ include GraphqlHelpers
+ include JavaScriptFixturesHelpers
+
+ let_it_be(:user) { create(:user) }
+
+ context 'for user achievements' do
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:private_group) { create(:group, :private) }
+ let_it_be(:achievement1) { create(:achievement, namespace: group) }
+ let_it_be(:achievement2) { create(:achievement, namespace: group) }
+ let_it_be(:achievement3) { create(:achievement, namespace: group) }
+ let_it_be(:achievement_from_private_group) { create(:achievement, namespace: private_group) }
+ let_it_be(:achievement_with_avatar_and_description) do
+ create(:achievement,
+ namespace: group,
+ description: 'Description',
+ avatar: File.new(Rails.root.join('db/fixtures/development/rocket.jpg'), 'r'))
+ end
+
+ let(:user_achievements_query_path) { 'profile/components/graphql/get_user_achievements.query.graphql' }
+ let(:query) { get_graphql_query_as_string(user_achievements_query_path) }
+
+ before_all do
+ group.add_guest(user)
+ end
+
+ it "graphql/get_user_achievements_empty_response.json" do
+ post_graphql(query, current_user: user, variables: { id: user.to_global_id })
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ it "graphql/get_user_achievements_with_avatar_and_description_response.json" do
+ create(:user_achievement, user: user, achievement: achievement_with_avatar_and_description)
+
+ post_graphql(query, current_user: user, variables: { id: user.to_global_id })
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ it "graphql/get_user_achievements_without_avatar_or_description_response.json" do
+ create(:user_achievement, user: user, achievement: achievement1)
+
+ post_graphql(query, current_user: user, variables: { id: user.to_global_id })
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ it 'graphql/get_user_achievements_from_private_group.json' do
+ create(:user_achievement, user: user, achievement: achievement_from_private_group)
+
+ post_graphql(query, current_user: user, variables: { id: user.to_global_id })
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ it "graphql/get_user_achievements_long_response.json" do
+ [achievement1, achievement2, achievement3, achievement_with_avatar_and_description].each do |achievement|
+ create(:user_achievement, user: user, achievement: achievement)
+ end
+
+ post_graphql(query, current_user: user, variables: { id: user.to_global_id })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+ end
+end
diff --git a/spec/frontend/fixtures/webauthn.rb b/spec/frontend/fixtures/webauthn.rb
index c6e9b41b584..ed6180118f0 100644
--- a/spec/frontend/fixtures/webauthn.rb
+++ b/spec/frontend/fixtures/webauthn.rb
@@ -32,6 +32,7 @@ RSpec.context 'WebAuthn' do
allow_next_instance_of(Profiles::TwoFactorAuthsController) do |instance|
allow(instance).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares')
end
+ stub_feature_flags(webauthn_without_totp: false)
end
it 'webauthn/register.html' do
diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js
deleted file mode 100644
index 17d6cea23df..00000000000
--- a/spec/frontend/flash_spec.js
+++ /dev/null
@@ -1,276 +0,0 @@
-import * as Sentry from '@sentry/browser';
-import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import { createAlert, VARIANT_WARNING } from '~/flash';
-
-jest.mock('@sentry/browser');
-
-describe('Flash', () => {
- const findTextContent = (containerSelector = '.flash-container') =>
- document.querySelector(containerSelector).textContent.replace(/\s+/g, ' ').trim();
-
- describe('createAlert', () => {
- const mockMessage = 'a message';
- let alert;
-
- describe('no flash-container', () => {
- it('does not add to the DOM', () => {
- alert = createAlert({ message: mockMessage });
-
- expect(alert).toBeNull();
- expect(document.querySelector('.gl-alert')).toBeNull();
- });
- });
-
- describe('with flash-container', () => {
- beforeEach(() => {
- setHTMLFixture('<div class="flash-container"></div>');
- });
-
- afterEach(() => {
- if (alert) {
- alert.$destroy();
- }
- resetHTMLFixture();
- });
-
- it('adds alert element into the document by default', () => {
- alert = createAlert({ message: mockMessage });
-
- expect(findTextContent()).toBe(mockMessage);
- expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull();
- });
-
- it('adds flash of a warning type', () => {
- alert = createAlert({ message: mockMessage, variant: VARIANT_WARNING });
-
- expect(
- document.querySelector('.flash-container .gl-alert.gl-alert-warning'),
- ).not.toBeNull();
- });
-
- it('escapes text', () => {
- alert = createAlert({ message: '<script>alert("a");</script>' });
-
- const html = document.querySelector('.flash-container').innerHTML;
-
- expect(html).toContain('&lt;script&gt;alert("a");&lt;/script&gt;');
- expect(html).not.toContain('<script>alert("a");</script>');
- });
-
- it('adds alert into specified container', () => {
- setHTMLFixture(`
- <div class="my-alert-container"></div>
- <div class="my-other-container"></div>
- `);
-
- alert = createAlert({ message: mockMessage, containerSelector: '.my-alert-container' });
-
- expect(document.querySelector('.my-alert-container .gl-alert')).not.toBeNull();
- expect(document.querySelector('.my-alert-container').innerText.trim()).toBe(mockMessage);
-
- expect(document.querySelector('.my-other-container .gl-alert')).toBeNull();
- expect(document.querySelector('.my-other-container').innerText.trim()).toBe('');
- });
-
- it('adds alert into specified parent', () => {
- setHTMLFixture(`
- <div id="my-parent">
- <div class="flash-container"></div>
- </div>
- <div id="my-other-parent">
- <div class="flash-container"></div>
- </div>
- `);
-
- alert = createAlert({ message: mockMessage, parent: document.getElementById('my-parent') });
-
- expect(document.querySelector('#my-parent .flash-container .gl-alert')).not.toBeNull();
- expect(document.querySelector('#my-parent .flash-container').innerText.trim()).toBe(
- mockMessage,
- );
-
- expect(document.querySelector('#my-other-parent .flash-container .gl-alert')).toBeNull();
- expect(document.querySelector('#my-other-parent .flash-container').innerText.trim()).toBe(
- '',
- );
- });
-
- it('removes element after clicking', () => {
- alert = createAlert({ message: mockMessage });
-
- expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull();
-
- document.querySelector('.gl-dismiss-btn').click();
-
- expect(document.querySelector('.flash-container .gl-alert')).toBeNull();
- });
-
- it('does not capture error using Sentry', () => {
- alert = createAlert({
- message: mockMessage,
- captureError: false,
- error: new Error('Error!'),
- });
-
- expect(Sentry.captureException).not.toHaveBeenCalled();
- });
-
- it('captures error using Sentry', () => {
- alert = createAlert({
- message: mockMessage,
- captureError: true,
- error: new Error('Error!'),
- });
-
- expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
- expect(Sentry.captureException).toHaveBeenCalledWith(
- expect.objectContaining({
- message: 'Error!',
- }),
- );
- });
-
- describe('with title', () => {
- const mockTitle = 'my title';
-
- it('shows title and message', () => {
- createAlert({
- title: mockTitle,
- message: mockMessage,
- });
-
- expect(findTextContent()).toBe(`${mockTitle} ${mockMessage}`);
- });
- });
-
- describe('with buttons', () => {
- const findAlertAction = () => document.querySelector('.flash-container .gl-alert-action');
-
- it('adds primary button', () => {
- alert = createAlert({
- message: mockMessage,
- primaryButton: {
- text: 'Ok',
- },
- });
-
- expect(findAlertAction().textContent.trim()).toBe('Ok');
- });
-
- it('creates link with href', () => {
- alert = createAlert({
- message: mockMessage,
- primaryButton: {
- link: '/url',
- text: 'Ok',
- },
- });
-
- const action = findAlertAction();
-
- expect(action.textContent.trim()).toBe('Ok');
- expect(action.nodeName).toBe('A');
- expect(action.getAttribute('href')).toBe('/url');
- });
-
- it('create button as href when no href is present', () => {
- alert = createAlert({
- message: mockMessage,
- primaryButton: {
- text: 'Ok',
- },
- });
-
- const action = findAlertAction();
-
- expect(action.nodeName).toBe('BUTTON');
- expect(action.getAttribute('href')).toBe(null);
- });
-
- it('escapes the title text', () => {
- alert = createAlert({
- message: mockMessage,
- primaryButton: {
- text: '<script>alert("a")</script>',
- },
- });
-
- const html = findAlertAction().innerHTML;
-
- expect(html).toContain('&lt;script&gt;alert("a")&lt;/script&gt;');
- expect(html).not.toContain('<script>alert("a")</script>');
- });
-
- it('calls actionConfig clickHandler on click', () => {
- const clickHandler = jest.fn();
-
- alert = createAlert({
- message: mockMessage,
- primaryButton: {
- text: 'Ok',
- clickHandler,
- },
- });
-
- expect(clickHandler).toHaveBeenCalledTimes(0);
-
- findAlertAction().click();
-
- expect(clickHandler).toHaveBeenCalledTimes(1);
- expect(clickHandler).toHaveBeenCalledWith(expect.any(MouseEvent));
- });
- });
-
- describe('Alert API', () => {
- describe('dismiss', () => {
- it('dismiss programmatically with .dismiss()', () => {
- expect(document.querySelector('.gl-alert')).toBeNull();
-
- alert = createAlert({ message: mockMessage });
-
- expect(document.querySelector('.gl-alert')).not.toBeNull();
-
- alert.dismiss();
-
- expect(document.querySelector('.gl-alert')).toBeNull();
- });
-
- it('does not crash if calling .dismiss() twice', () => {
- alert = createAlert({ message: mockMessage });
-
- alert.dismiss();
- expect(() => alert.dismiss()).not.toThrow();
- });
-
- it('calls onDismiss when dismissed', () => {
- const dismissHandler = jest.fn();
-
- alert = createAlert({ message: mockMessage, onDismiss: dismissHandler });
-
- expect(dismissHandler).toHaveBeenCalledTimes(0);
-
- alert.dismiss();
-
- expect(dismissHandler).toHaveBeenCalledTimes(1);
- });
- });
- });
-
- describe('when called multiple times', () => {
- it('clears previous alerts', () => {
- createAlert({ message: 'message 1' });
- createAlert({ message: 'message 2' });
-
- expect(findTextContent()).toBe('message 2');
- });
-
- it('preserves alerts when `preservePrevious` is true', () => {
- createAlert({ message: 'message 1' });
- createAlert({ message: 'message 2', preservePrevious: true });
-
- expect(findTextContent()).toBe('message 1 message 2');
- });
- });
- });
- });
-});
diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js
index e1890555de0..a8ae72eb4b3 100644
--- a/spec/frontend/frequent_items/components/app_spec.js
+++ b/spec/frontend/frequent_items/components/app_spec.js
@@ -33,7 +33,6 @@ describe('Frequent Items App Component', () => {
const createComponent = (props = {}) => {
const session = currentSession[TEST_NAMESPACE];
gon.api_version = session.apiVersion;
- gon.features = { fullPathProjectSearch: true };
wrapper = mountExtended(App, {
store,
@@ -69,7 +68,6 @@ describe('Frequent Items App Component', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('default', () => {
diff --git a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js
index c54a2a1d039..7c8592fdf0c 100644
--- a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js
+++ b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js
@@ -59,8 +59,6 @@ describe('FrequentItemsListItemComponent', () => {
afterEach(() => {
unmockTracking();
- wrapper.destroy();
- wrapper = null;
});
describe('computed', () => {
diff --git a/spec/frontend/frequent_items/components/frequent_items_list_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_spec.js
index d024925f62b..dd6dd80af4f 100644
--- a/spec/frontend/frequent_items/components/frequent_items_list_spec.js
+++ b/spec/frontend/frequent_items/components/frequent_items_list_spec.js
@@ -29,10 +29,6 @@ describe('FrequentItemsListComponent', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('computed', () => {
describe('isListEmpty', () => {
it('should return `true` or `false` representing whether if `items` is empty or not with projects', async () => {
@@ -52,7 +48,7 @@ describe('FrequentItemsListComponent', () => {
});
describe('fetched item messages', () => {
- it('should show default empty list message', async () => {
+ it('should show default empty list message', () => {
createComponent({
items: [],
});
diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js
index c228bca4973..2feb488da2c 100644
--- a/spec/frontend/frequent_items/store/actions_spec.js
+++ b/spec/frontend/frequent_items/store/actions_spec.js
@@ -25,7 +25,6 @@ describe('Frequent Items Dropdown Store Actions', () => {
mockedState.namespace = mockNamespace;
mockedState.storageKey = mockStorageKey;
- gon.features = { fullPathProjectSearch: true };
});
afterEach(() => {
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index e4fd8649263..73284fbe5e5 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -666,10 +666,11 @@ describe('GfmAutoComplete', () => {
username: 'my-group',
title: '',
icon: '<i class="icon"/>',
- availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>',
+ availabilityStatus:
+ '<span class="badge badge-warning badge-pill gl-badge sm gl-ml-2">Busy</span>',
}),
).toBe(
- '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>',
+ '<li>IMG my-group <small><span class="badge badge-warning badge-pill gl-badge sm gl-ml-2">Busy</span></small> <i class="icon"/></li>',
);
});
diff --git a/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js b/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js
index 685b5144a95..289702a4263 100644
--- a/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js
+++ b/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js
@@ -7,7 +7,7 @@ import PagesPipelineWizard, { i18n } from '~/gitlab_pages/components/pages_pipel
import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue';
import pagesTemplate from '~/pipeline_wizard/templates/pages.yml';
import pagesMarkOnboardingComplete from '~/gitlab_pages/queries/mark_onboarding_complete.graphql';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
Vue.use(VueApollo);
@@ -50,10 +50,6 @@ describe('PagesPipelineWizard', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows the pipeline wizard', () => {
expect(findPipelineWizardWrapper().exists()).toBe(true);
});
@@ -96,7 +92,7 @@ describe('PagesPipelineWizard', () => {
await waitForPromises();
- expect(redirectTo).toHaveBeenCalledWith(props.redirectToWhenDone);
+ expect(redirectTo).toHaveBeenCalledWith(props.redirectToWhenDone); // eslint-disable-line import/no-deprecated
});
});
});
diff --git a/spec/frontend/gitlab_version_check/components/gitlab_version_check_badge_spec.js b/spec/frontend/gitlab_version_check/components/gitlab_version_check_badge_spec.js
index 949bcf71ff5..e87f7e950cd 100644
--- a/spec/frontend/gitlab_version_check/components/gitlab_version_check_badge_spec.js
+++ b/spec/frontend/gitlab_version_check/components/gitlab_version_check_badge_spec.js
@@ -25,7 +25,6 @@ describe('GitlabVersionCheckBadge', () => {
afterEach(() => {
unmockTracking();
- wrapper.destroy();
});
const findGlBadgeClickWrapper = () => wrapper.findByTestId('badge-click-wrapper');
diff --git a/spec/frontend/gl_field_errors_spec.js b/spec/frontend/gl_field_errors_spec.js
index 1f6929baa75..91d0674dfcb 100644
--- a/spec/frontend/gl_field_errors_spec.js
+++ b/spec/frontend/gl_field_errors_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlGlFieldErrors from 'test_fixtures_static/gl_field_errors.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import GlFieldErrors from '~/gl_field_errors';
describe('GL Style Field Errors', () => {
@@ -10,7 +11,7 @@ describe('GL Style Field Errors', () => {
});
beforeEach(() => {
- loadHTMLFixture('static/gl_field_errors.html');
+ setHTMLFixture(htmlGlFieldErrors);
const $form = $('form.gl-show-field-errors');
testContext.$form = $form;
diff --git a/spec/frontend/google_cloud/aiml/panel_spec.js b/spec/frontend/google_cloud/aiml/panel_spec.js
new file mode 100644
index 00000000000..374e125c509
--- /dev/null
+++ b/spec/frontend/google_cloud/aiml/panel_spec.js
@@ -0,0 +1,43 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import Panel from '~/google_cloud/aiml/panel.vue';
+import IncubationBanner from '~/google_cloud/components/incubation_banner.vue';
+import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue';
+import ServiceTable from '~/google_cloud/aiml/service_table.vue';
+
+describe('google_cloud/databases/panel', () => {
+ let wrapper;
+
+ const props = {
+ configurationUrl: 'configuration-url',
+ deploymentsUrl: 'deployments-url',
+ databasesUrl: 'databases-url',
+ aimlUrl: 'aiml-url',
+ visionAiUrl: 'vision-ai-url',
+ translationAiUrl: 'translation-ai-url',
+ languageAiUrl: 'language-ai-url',
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMountExtended(Panel, { propsData: props });
+ });
+
+ it('contains incubation banner', () => {
+ const target = wrapper.findComponent(IncubationBanner);
+ expect(target.exists()).toBe(true);
+ });
+
+ it('contains google cloud menu with `aiml` active', () => {
+ const target = wrapper.findComponent(GoogleCloudMenu);
+ expect(target.exists()).toBe(true);
+ expect(target.props('active')).toBe('aiml');
+ expect(target.props('configurationUrl')).toBe(props.configurationUrl);
+ expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl);
+ expect(target.props('databasesUrl')).toBe(props.databasesUrl);
+ expect(target.props('aimlUrl')).toBe(props.aimlUrl);
+ });
+
+ it('contains service table', () => {
+ const target = wrapper.findComponent(ServiceTable);
+ expect(target.exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/google_cloud/aiml/service_table_spec.js b/spec/frontend/google_cloud/aiml/service_table_spec.js
new file mode 100644
index 00000000000..b14db064a7f
--- /dev/null
+++ b/spec/frontend/google_cloud/aiml/service_table_spec.js
@@ -0,0 +1,34 @@
+import { GlTable } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import ServiceTable from '~/google_cloud/aiml/service_table.vue';
+
+describe('google_cloud/aiml/service_table', () => {
+ let wrapper;
+
+ const findTable = () => wrapper.findComponent(GlTable);
+
+ beforeEach(() => {
+ const propsData = {
+ visionAiUrl: '#url-vision-ai',
+ languageAiUrl: '#url-language-ai',
+ translationAiUrl: '#url-translate-ai',
+ };
+ wrapper = mountExtended(ServiceTable, { propsData });
+ });
+
+ it('should contain a table', () => {
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it.each`
+ name | testId | url
+ ${'key-vision-ai'} | ${'button-vision-ai'} | ${'#url-vision-ai'}
+ ${'key-natural-language-ai'} | ${'button-natural-language-ai'} | ${'#url-language-ai'}
+ ${'key-translation-ai'} | ${'button-translation-ai'} | ${'#url-translate-ai'}
+ `('renders $name button with correct url', ({ testId, url }) => {
+ const button = wrapper.findByTestId(testId);
+
+ expect(button.exists()).toBe(true);
+ expect(button.attributes('href')).toBe(url);
+ });
+});
diff --git a/spec/frontend/google_cloud/components/google_cloud_menu_spec.js b/spec/frontend/google_cloud/components/google_cloud_menu_spec.js
index 4809ea37045..f1ee96ff870 100644
--- a/spec/frontend/google_cloud/components/google_cloud_menu_spec.js
+++ b/spec/frontend/google_cloud/components/google_cloud_menu_spec.js
@@ -9,16 +9,13 @@ describe('google_cloud/components/google_cloud_menu', () => {
configurationUrl: 'configuration-url',
deploymentsUrl: 'deployments-url',
databasesUrl: 'databases-url',
+ aimlUrl: 'aiml-url',
};
beforeEach(() => {
wrapper = mountExtended(GoogleCloudMenu, { propsData: props });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains active configuration link', () => {
const link = wrapper.findByTestId('configurationLink');
expect(link.text()).toBe(GoogleCloudMenu.i18n.configuration.title);
@@ -37,4 +34,10 @@ describe('google_cloud/components/google_cloud_menu', () => {
expect(link.text()).toBe(GoogleCloudMenu.i18n.databases.title);
expect(link.attributes('href')).toBe(props.databasesUrl);
});
+
+ it('contains ai/ml link', () => {
+ const link = wrapper.findByTestId('aimlLink');
+ expect(link.text()).toBe(GoogleCloudMenu.i18n.aiml.title);
+ expect(link.attributes('href')).toBe(props.aimlUrl);
+ });
});
diff --git a/spec/frontend/google_cloud/components/incubation_banner_spec.js b/spec/frontend/google_cloud/components/incubation_banner_spec.js
index 09a4d92dca2..92bc39bdff9 100644
--- a/spec/frontend/google_cloud/components/incubation_banner_spec.js
+++ b/spec/frontend/google_cloud/components/incubation_banner_spec.js
@@ -15,10 +15,6 @@ describe('google_cloud/components/incubation_banner', () => {
wrapper = mount(IncubationBanner);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains alert', () => {
expect(findAlert().exists()).toBe(true);
});
diff --git a/spec/frontend/google_cloud/components/revoke_oauth_spec.js b/spec/frontend/google_cloud/components/revoke_oauth_spec.js
index faaec07fc35..2b39bb9ca74 100644
--- a/spec/frontend/google_cloud/components/revoke_oauth_spec.js
+++ b/spec/frontend/google_cloud/components/revoke_oauth_spec.js
@@ -20,10 +20,6 @@ describe('google_cloud/components/revoke_oauth', () => {
wrapper = shallowMount(RevokeOauth, { propsData });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains title', () => {
const title = findTitle();
expect(title.text()).toContain('Revoke authorizations');
diff --git a/spec/frontend/google_cloud/configuration/panel_spec.js b/spec/frontend/google_cloud/configuration/panel_spec.js
index 79eb4cb4918..dd85b4c90a7 100644
--- a/spec/frontend/google_cloud/configuration/panel_spec.js
+++ b/spec/frontend/google_cloud/configuration/panel_spec.js
@@ -25,10 +25,6 @@ describe('google_cloud/configuration/panel', () => {
wrapper = shallowMountExtended(Panel, { propsData: props });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains incubation banner', () => {
const target = wrapper.findComponent(IncubationBanner);
expect(target.exists()).toBe(true);
diff --git a/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js b/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js
index 48e4b0ca1ad..6e2d3147a54 100644
--- a/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js
+++ b/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js
@@ -25,10 +25,6 @@ describe('google_cloud/databases/cloudsql/create_instance_form', () => {
wrapper = shallowMountExtended(InstanceForm, { propsData, stubs: { GlFormCheckbox } });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains header', () => {
expect(findHeader().exists()).toBe(true);
});
diff --git a/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js b/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js
index a5736d0a524..a2ee75f9fbf 100644
--- a/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js
+++ b/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js
@@ -8,10 +8,6 @@ describe('google_cloud/databases/cloudsql/instance_table', () => {
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findTable = () => wrapper.findComponent(GlTable);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when there are no instances', () => {
beforeEach(() => {
const propsData = {
diff --git a/spec/frontend/google_cloud/databases/panel_spec.js b/spec/frontend/google_cloud/databases/panel_spec.js
index e6a0d74f348..779258bbdbb 100644
--- a/spec/frontend/google_cloud/databases/panel_spec.js
+++ b/spec/frontend/google_cloud/databases/panel_spec.js
@@ -23,10 +23,6 @@ describe('google_cloud/databases/panel', () => {
wrapper = shallowMountExtended(Panel, { propsData: props });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains incubation banner', () => {
const target = wrapper.findComponent(IncubationBanner);
expect(target.exists()).toBe(true);
diff --git a/spec/frontend/google_cloud/databases/service_table_spec.js b/spec/frontend/google_cloud/databases/service_table_spec.js
index 4a622e544e1..4594e1758ad 100644
--- a/spec/frontend/google_cloud/databases/service_table_spec.js
+++ b/spec/frontend/google_cloud/databases/service_table_spec.js
@@ -19,10 +19,6 @@ describe('google_cloud/databases/service_table', () => {
wrapper = mountExtended(ServiceTable, { propsData });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should contain a table', () => {
expect(findTable().exists()).toBe(true);
});
diff --git a/spec/frontend/google_cloud/deployments/panel_spec.js b/spec/frontend/google_cloud/deployments/panel_spec.js
index 729db1707a7..0748d8f9377 100644
--- a/spec/frontend/google_cloud/deployments/panel_spec.js
+++ b/spec/frontend/google_cloud/deployments/panel_spec.js
@@ -19,10 +19,6 @@ describe('google_cloud/deployments/panel', () => {
wrapper = shallowMountExtended(Panel, { propsData: props });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains incubation banner', () => {
const target = wrapper.findComponent(IncubationBanner);
expect(target.exists()).toBe(true);
diff --git a/spec/frontend/google_cloud/deployments/service_table_spec.js b/spec/frontend/google_cloud/deployments/service_table_spec.js
index 8faad64e313..49220a6007e 100644
--- a/spec/frontend/google_cloud/deployments/service_table_spec.js
+++ b/spec/frontend/google_cloud/deployments/service_table_spec.js
@@ -18,10 +18,6 @@ describe('google_cloud/deployments/service_table', () => {
wrapper = mount(DeploymentsServiceTable, { propsData });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should contain a table', () => {
expect(findTable().exists()).toBe(true);
});
diff --git a/spec/frontend/google_cloud/gcp_regions/form_spec.js b/spec/frontend/google_cloud/gcp_regions/form_spec.js
index 1030e9c8a18..be37ff092f0 100644
--- a/spec/frontend/google_cloud/gcp_regions/form_spec.js
+++ b/spec/frontend/google_cloud/gcp_regions/form_spec.js
@@ -16,10 +16,6 @@ describe('google_cloud/gcp_regions/form', () => {
wrapper = shallowMount(GcpRegionsForm, { propsData });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains header', () => {
expect(findHeader().exists()).toBe(true);
});
diff --git a/spec/frontend/google_cloud/gcp_regions/list_spec.js b/spec/frontend/google_cloud/gcp_regions/list_spec.js
index 6d8c389e5a1..74a54b93183 100644
--- a/spec/frontend/google_cloud/gcp_regions/list_spec.js
+++ b/spec/frontend/google_cloud/gcp_regions/list_spec.js
@@ -18,10 +18,6 @@ describe('google_cloud/gcp_regions/list', () => {
wrapper = mount(GcpRegionsList, { propsData });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows the empty state component', () => {
expect(findEmptyState().exists()).toBe(true);
});
diff --git a/spec/frontend/google_cloud/service_accounts/form_spec.js b/spec/frontend/google_cloud/service_accounts/form_spec.js
index 8be481774fa..c86c8876b15 100644
--- a/spec/frontend/google_cloud/service_accounts/form_spec.js
+++ b/spec/frontend/google_cloud/service_accounts/form_spec.js
@@ -17,10 +17,6 @@ describe('google_cloud/service_accounts/form', () => {
wrapper = shallowMount(ServiceAccountsForm, { propsData, stubs: { GlFormCheckbox } });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains header', () => {
expect(findHeader().exists()).toBe(true);
});
diff --git a/spec/frontend/google_cloud/service_accounts/list_spec.js b/spec/frontend/google_cloud/service_accounts/list_spec.js
index c2bd2005b5d..ae5776081d7 100644
--- a/spec/frontend/google_cloud/service_accounts/list_spec.js
+++ b/spec/frontend/google_cloud/service_accounts/list_spec.js
@@ -18,10 +18,6 @@ describe('google_cloud/service_accounts/list', () => {
wrapper = mount(ServiceAccountsList, { propsData });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows the empty state component', () => {
expect(findEmptyState().exists()).toBe(true);
});
diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js
index ec9e1ef8e5f..dd8e886e6bc 100644
--- a/spec/frontend/google_tag_manager/index_spec.js
+++ b/spec/frontend/google_tag_manager/index_spec.js
@@ -6,7 +6,6 @@ import {
trackProjectImport,
trackNewRegistrations,
trackSaasTrialSubmit,
- trackSaasTrialSkip,
trackSaasTrialGroup,
trackSaasTrialGetStarted,
trackTrialAcceptTerms,
@@ -143,9 +142,6 @@ describe('~/google_tag_manager/index', () => {
describe.each([
createOmniAuthTestCase(trackFreeTrialAccountSubmissions, 'freeThirtyDayTrial'),
createOmniAuthTestCase(trackNewRegistrations, 'standardSignUp'),
- createTestCase(trackSaasTrialSkip, {
- links: [{ cls: 'js-skip-trial', expectation: { event: 'saasTrialSkip' } }],
- }),
createTestCase(trackSaasTrialGroup, {
forms: [{ cls: 'js-saas-trial-group', expectation: { event: 'saasTrialGroup' } }],
}),
diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js
index 021a3aa41ed..540fc597aa9 100644
--- a/spec/frontend/grafana_integration/components/grafana_integration_spec.js
+++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js
@@ -3,14 +3,14 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import GrafanaIntegration from '~/grafana_integration/components/grafana_integration.vue';
import { createStore } from '~/grafana_integration/store';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility');
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('grafana integration component', () => {
let wrapper;
@@ -28,11 +28,8 @@ describe('grafana integration component', () => {
});
afterEach(() => {
- if (wrapper.destroy) {
- wrapper.destroy();
- createAlert.mockReset();
- refreshCurrentPage.mockReset();
- }
+ createAlert.mockReset();
+ refreshCurrentPage.mockReset();
});
describe('default state', () => {
@@ -103,7 +100,7 @@ describe('grafana integration component', () => {
expect(refreshCurrentPage).toHaveBeenCalled();
});
- it('creates flash banner on error', async () => {
+ it('creates alert banner on error', async () => {
const message = 'mockErrorMessage';
axios.patch.mockRejectedValue({ response: { data: { message } } });
diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js
index cd334ef0d97..f03856e5f75 100644
--- a/spec/frontend/graphql_shared/utils_spec.js
+++ b/spec/frontend/graphql_shared/utils_spec.js
@@ -1,11 +1,16 @@
+import Visibility from 'visibilityjs';
+
import {
isGid,
getIdFromGraphQLId,
+ getTypeFromGraphQLId,
convertToGraphQLId,
convertToGraphQLIds,
convertFromGraphQLIds,
convertNodeIdsFromGraphQLIds,
getNodesOrDefault,
+ toggleQueryPollingByVisibility,
+ etagQueryHeaders,
} from '~/graphql_shared/utils';
const mockType = 'Group';
@@ -22,52 +27,30 @@ describe('isGid', () => {
});
});
-describe('getIdFromGraphQLId', () => {
- [
- {
- input: '',
- output: null,
- },
- {
- input: null,
- output: null,
- },
- {
- input: 2,
- output: 2,
- },
- {
- input: 'gid://',
- output: null,
- },
- {
- input: 'gid://gitlab/',
- output: null,
- },
- {
- input: 'gid://gitlab/Environments',
- output: null,
- },
- {
- input: 'gid://gitlab/Environments/',
- output: null,
- },
- {
- input: 'gid://gitlab/Environments/0',
- output: 0,
- },
- {
- input: 'gid://gitlab/Environments/123',
- output: 123,
- },
- {
- input: 'gid://gitlab/DesignManagement::Version/2',
- output: 2,
- },
- ].forEach(({ input, output }) => {
- it(`getIdFromGraphQLId returns ${output} when passed ${input}`, () => {
- expect(getIdFromGraphQLId(input)).toBe(output);
- });
+describe.each`
+ input | id | type
+ ${''} | ${null} | ${null}
+ ${null} | ${null} | ${null}
+ ${0} | ${0} | ${null}
+ ${'0'} | ${0} | ${null}
+ ${2} | ${2} | ${null}
+ ${'2'} | ${2} | ${null}
+ ${'gid://'} | ${null} | ${null}
+ ${'gid://gitlab'} | ${null} | ${null}
+ ${'gid://gitlab/'} | ${null} | ${null}
+ ${'gid://gitlab/Environments'} | ${null} | ${'Environments'}
+ ${'gid://gitlab/Environments/'} | ${null} | ${'Environments'}
+ ${'gid://gitlab/Environments/0'} | ${0} | ${'Environments'}
+ ${'gid://gitlab/Environments/123'} | ${123} | ${'Environments'}
+ ${'gid://gitlab/Environments/123/test'} | ${123} | ${'Environments'}
+ ${'gid://gitlab/DesignManagement::Version/123'} | ${123} | ${'DesignManagement::Version'}
+`('parses GraphQL ID `$input`', ({ input, id, type }) => {
+ it(`getIdFromGraphQLId returns ${id}`, () => {
+ expect(getIdFromGraphQLId(input)).toBe(id);
+ });
+
+ it(`getTypeFromGraphQLId returns ${type}`, () => {
+ expect(getTypeFromGraphQLId(input)).toBe(type);
});
});
@@ -160,3 +143,52 @@ describe('getNodesOrDefault', () => {
expect(result).toEqual(expected);
});
});
+
+describe('toggleQueryPollingByVisibility', () => {
+ let query;
+ let changeFn;
+ let interval;
+ let hidden;
+
+ beforeEach(() => {
+ hidden = jest.spyOn(Visibility, 'hidden').mockReturnValue(true);
+ jest.spyOn(Visibility, 'change').mockImplementation((fn) => {
+ changeFn = fn;
+ });
+
+ query = { startPolling: jest.fn(), stopPolling: jest.fn() };
+ interval = 5000;
+
+ toggleQueryPollingByVisibility(query, 5000);
+ });
+
+ it('starts polling not hidden', () => {
+ hidden.mockReturnValue(false);
+
+ changeFn();
+ expect(query.startPolling).toHaveBeenCalledWith(interval);
+ });
+
+ it('stops polling when hidden', () => {
+ query.stopPolling.mockReset();
+ hidden.mockReturnValue(true);
+
+ changeFn();
+ expect(query.stopPolling).toHaveBeenCalled();
+ });
+});
+
+describe('etagQueryHeaders', () => {
+ it('returns headers necessary for etag caching', () => {
+ expect(etagQueryHeaders('myFeature', 'myResource')).toEqual({
+ fetchOptions: {
+ method: 'GET',
+ },
+ headers: {
+ 'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'myFeature',
+ 'X-GITLAB-GRAPHQL-RESOURCE-ETAG': 'myResource',
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ });
+ });
+});
diff --git a/spec/frontend/group_settings/components/shared_runners_form_spec.js b/spec/frontend/group_settings/components/shared_runners_form_spec.js
index 85475c749b0..5daa21fd618 100644
--- a/spec/frontend/group_settings/components/shared_runners_form_spec.js
+++ b/spec/frontend/group_settings/components/shared_runners_form_spec.js
@@ -2,12 +2,17 @@ import { GlAlert } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import SharedRunnersForm from '~/group_settings/components/shared_runners_form.vue';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { updateGroup } from '~/api/groups_api';
+import SharedRunnersForm from '~/group_settings/components/shared_runners_form.vue';
+import { I18N_CONFIRM_MESSAGE } from '~/group_settings/constants';
+
jest.mock('~/api/groups_api');
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
const GROUP_ID = '99';
+const GROUP_NAME = 'My group';
const RUNNER_ENABLED_VALUE = 'enabled';
const RUNNER_DISABLED_VALUE = 'disabled_and_unoverridable';
const RUNNER_ALLOW_OVERRIDE_VALUE = 'disabled_and_overridable';
@@ -19,6 +24,8 @@ describe('group_settings/components/shared_runners_form', () => {
wrapper = shallowMountExtended(SharedRunnersForm, {
provide: {
groupId: GROUP_ID,
+ groupName: GROUP_NAME,
+ groupIsEmpty: false,
sharedRunnersSetting: RUNNER_ENABLED_VALUE,
parentSharedRunnersSetting: null,
runnerEnabledValue: RUNNER_ENABLED_VALUE,
@@ -41,13 +48,12 @@ describe('group_settings/components/shared_runners_form', () => {
};
beforeEach(() => {
+ confirmAction.mockResolvedValue(true);
updateGroup.mockResolvedValue({});
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
-
+ confirmAction.mockReset();
updateGroup.mockReset();
});
@@ -113,8 +119,9 @@ describe('group_settings/components/shared_runners_form', () => {
it('does not update settings while loading', async () => {
findSharedRunnersToggle().vm.$emit('change', true);
+ await nextTick();
findSharedRunnersToggle().vm.$emit('change', false);
- await waitForPromises();
+ await nextTick();
expect(updateGroup).toHaveBeenCalledTimes(1);
});
@@ -137,6 +144,8 @@ describe('group_settings/components/shared_runners_form', () => {
findSharedRunnersToggle().vm.$emit('change', true);
await waitForPromises();
+ expect(confirmAction).not.toHaveBeenCalled();
+
expect(getSharedRunnersSetting()).toEqual(RUNNER_ENABLED_VALUE);
expect(findOverrideToggle().props('disabled')).toBe(true);
});
@@ -145,17 +154,59 @@ describe('group_settings/components/shared_runners_form', () => {
findSharedRunnersToggle().vm.$emit('change', false);
await waitForPromises();
+ expect(confirmAction).toHaveBeenCalledTimes(1);
+ expect(confirmAction).toHaveBeenCalledWith(
+ I18N_CONFIRM_MESSAGE,
+ expect.objectContaining({
+ title: expect.stringContaining(GROUP_NAME),
+ }),
+ );
+
expect(getSharedRunnersSetting()).toEqual(RUNNER_DISABLED_VALUE);
expect(findOverrideToggle().props('disabled')).toBe(false);
});
+
+ describe('when user cancels operation', () => {
+ beforeEach(() => {
+ confirmAction.mockResolvedValue(false);
+ });
+
+ it('sends no payload when turned off', async () => {
+ findSharedRunnersToggle().vm.$emit('change', false);
+ await waitForPromises();
+
+ expect(confirmAction).toHaveBeenCalledTimes(1);
+ expect(confirmAction).toHaveBeenCalledWith(
+ I18N_CONFIRM_MESSAGE,
+ expect.objectContaining({
+ title: expect.stringContaining(GROUP_NAME),
+ }),
+ );
+
+ expect(updateGroup).not.toHaveBeenCalled();
+ expect(findOverrideToggle().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('when group is empty', () => {
+ beforeEach(() => {
+ createComponent({ groupIsEmpty: true });
+ });
+
+ it('confirmation is not shown when turned off', async () => {
+ findSharedRunnersToggle().vm.$emit('change', false);
+ await waitForPromises();
+
+ expect(confirmAction).not.toHaveBeenCalled();
+ expect(getSharedRunnersSetting()).toEqual(RUNNER_DISABLED_VALUE);
+ });
+ });
});
describe('"Override the group setting" toggle', () => {
- beforeEach(() => {
+ it('enabling the override toggle sends correct payload', async () => {
createComponent({ sharedRunnersSetting: RUNNER_ALLOW_OVERRIDE_VALUE });
- });
- it('enabling the override toggle sends correct payload', async () => {
findOverrideToggle().vm.$emit('change', true);
await waitForPromises();
@@ -163,6 +214,8 @@ describe('group_settings/components/shared_runners_form', () => {
});
it('disabling the override toggle sends correct payload', async () => {
+ createComponent({ sharedRunnersSetting: RUNNER_ALLOW_OVERRIDE_VALUE });
+
findOverrideToggle().vm.$emit('change', false);
await waitForPromises();
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
index 4e6ddd89a55..7b42e50fee5 100644
--- a/spec/frontend/groups/components/app_spec.js
+++ b/spec/frontend/groups/components/app_spec.js
@@ -3,7 +3,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import appComponent from '~/groups/components/app.vue';
import groupFolderComponent from '~/groups/components/group_folder.vue';
import groupItemComponent from '~/groups/components/group_item.vue';
@@ -34,7 +34,7 @@ import {
const $toast = {
show: jest.fn(),
};
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('AppComponent', () => {
let wrapper;
@@ -65,11 +65,6 @@ describe('AppComponent', () => {
vm = wrapper.vm;
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
beforeEach(async () => {
mock = new AxiosMockAdapter(axios);
mock.onGet('/dashboard/groups.json').reply(HTTP_STATUS_OK, mockGroups);
@@ -117,7 +112,7 @@ describe('AppComponent', () => {
});
});
- it('should show flash error when request fails', () => {
+ it('should show an alert when request fails', () => {
mock.onGet('/dashboard/groups.json').reply(HTTP_STATUS_BAD_REQUEST);
jest.spyOn(window, 'scrollTo').mockImplementation(() => {});
@@ -325,7 +320,7 @@ describe('AppComponent', () => {
});
});
- it('should show error flash message if request failed to leave group', () => {
+ it('should show error alert if request failed to leave group', () => {
const message = 'An error occurred. Please try again.';
jest
.spyOn(vm.service, 'leaveGroup')
@@ -342,7 +337,7 @@ describe('AppComponent', () => {
});
});
- it('should show appropriate error flash message if request forbids to leave group', () => {
+ it('shows appropriate error alert if request forbids to leave group', () => {
const message = 'Failed to leave the group. Please make sure you are not the only owner.';
jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: HTTP_STATUS_FORBIDDEN });
jest.spyOn(vm.store, 'removeGroup');
diff --git a/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js
index be61ffa92b4..bb3c0bc1526 100644
--- a/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js
+++ b/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js
@@ -6,7 +6,7 @@ import ArchivedProjectsEmptyState from '~/groups/components/empty_states/archive
let wrapper;
const defaultProvide = {
- newProjectIllustration: '/assets/illustrations/project-create-new-sm.svg',
+ emptyProjectsIllustration: '/assets/llustrations/empty-state/empty-projects-md.svg',
};
const createComponent = () => {
@@ -21,7 +21,7 @@ describe('ArchivedProjectsEmptyState', () => {
expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
title: ArchivedProjectsEmptyState.i18n.title,
- svgPath: defaultProvide.newProjectIllustration,
+ svgPath: defaultProvide.emptyProjectsIllustration,
});
});
});
diff --git a/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js
index c4ace1be1f3..8ba1c480d5e 100644
--- a/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js
+++ b/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js
@@ -6,7 +6,7 @@ import SharedProjectsEmptyState from '~/groups/components/empty_states/shared_pr
let wrapper;
const defaultProvide = {
- newProjectIllustration: '/assets/illustrations/project-create-new-sm.svg',
+ emptyProjectsIllustration: '/assets/illustrations/empty-state/empty-projects-md.svg',
};
const createComponent = () => {
@@ -21,7 +21,7 @@ describe('SharedProjectsEmptyState', () => {
expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
title: SharedProjectsEmptyState.i18n.title,
- svgPath: defaultProvide.newProjectIllustration,
+ svgPath: defaultProvide.emptyProjectsIllustration,
});
});
});
diff --git a/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js
index 75edc602fbf..5ae4d0be7d6 100644
--- a/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js
+++ b/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js
@@ -10,6 +10,7 @@ const defaultProvide = {
newProjectPath: '/projects/new?namespace_id=231',
newSubgroupIllustration: '/assets/illustrations/group-new.svg',
newSubgroupPath: '/groups/new?parent_id=231',
+ emptyProjectsIllustration: '/assets/illustrations/empty-state/empty-projects-md.svg',
emptySubgroupIllustration: '/assets/illustrations/empty-state/empty-subgroup-md.svg',
canCreateSubgroups: true,
canCreateProjects: true,
@@ -24,10 +25,6 @@ const createComponent = ({ provide = {} } = {}) => {
});
};
-afterEach(() => {
- wrapper.destroy();
-});
-
const findNewSubgroupLink = () =>
wrapper.findByRole('link', {
name: new RegExp(SubgroupsAndProjectsEmptyState.i18n.withLinks.subgroup.title),
diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js
index f223333360d..da31fb02f69 100644
--- a/spec/frontend/groups/components/group_folder_spec.js
+++ b/spec/frontend/groups/components/group_folder_spec.js
@@ -20,10 +20,6 @@ describe('GroupFolder component', () => {
},
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('does not render more children stats link when children count of group is under limit', () => {
wrapper = createComponent();
diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js
index 4570aa33a6c..663dd341a58 100644
--- a/spec/frontend/groups/components/group_item_spec.js
+++ b/spec/frontend/groups/components/group_item_spec.js
@@ -37,10 +37,6 @@ describe('GroupItemComponent', () => {
return waitForPromises();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const withMicrodata = (group) => ({
...group,
microdata: getGroupItemMicrodata(group),
diff --git a/spec/frontend/groups/components/group_name_and_path_spec.js b/spec/frontend/groups/components/group_name_and_path_spec.js
index 9965b608f27..0a18e657c94 100644
--- a/spec/frontend/groups/components/group_name_and_path_spec.js
+++ b/spec/frontend/groups/components/group_name_and_path_spec.js
@@ -7,11 +7,11 @@ import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import GroupNameAndPath from '~/groups/components/group_name_and_path.vue';
import { getGroupPathAvailability } from '~/rest_api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { helpPagePath } from '~/helpers/help_page_helper';
import searchGroupsWhereUserCanCreateSubgroups from '~/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/rest_api', () => ({
getGroupPathAvailability: jest.fn(),
}));
diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js
index cae29a8f15a..c04eaa501ba 100644
--- a/spec/frontend/groups/components/groups_spec.js
+++ b/spec/frontend/groups/components/groups_spec.js
@@ -32,15 +32,11 @@ describe('GroupsComponent', () => {
const findPaginationLinks = () => wrapper.findComponent(PaginationLinks);
- beforeEach(async () => {
+ beforeEach(() => {
Vue.component('GroupFolder', GroupFolderComponent);
Vue.component('GroupItem', GroupItemComponent);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('methods', () => {
describe('change', () => {
it('should emit `fetchPage` event when page is changed via pagination', () => {
diff --git a/spec/frontend/groups/components/invite_members_banner_spec.js b/spec/frontend/groups/components/invite_members_banner_spec.js
index 4a385cb00ee..c4bc35dcd57 100644
--- a/spec/frontend/groups/components/invite_members_banner_spec.js
+++ b/spec/frontend/groups/components/invite_members_banner_spec.js
@@ -42,8 +42,6 @@ describe('InviteMembersBanner', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
mockAxios.restore();
unmockTracking();
});
@@ -59,7 +57,6 @@ describe('InviteMembersBanner', () => {
});
const trackCategory = undefined;
- const buttonClickEvent = 'invite_members_banner_button_clicked';
it('sends the displayEvent when the banner is displayed', () => {
const displayEvent = 'invite_members_banner_displayed';
@@ -80,12 +77,6 @@ describe('InviteMembersBanner', () => {
source: 'invite_members_banner',
});
});
-
- it('sends the buttonClickEvent with correct trackCategory and trackLabel', () => {
- expect(trackingSpy).toHaveBeenCalledWith(trackCategory, buttonClickEvent, {
- label: provide.trackLabel,
- });
- });
});
it('sends the dismissEvent when the banner is dismissed', () => {
diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js
index 3ceb038dd3c..fac6fb77709 100644
--- a/spec/frontend/groups/components/item_actions_spec.js
+++ b/spec/frontend/groups/components/item_actions_spec.js
@@ -18,11 +18,6 @@ describe('ItemActions', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findEditGroupBtn = () => wrapper.findByTestId(`edit-group-${mockParentGroupItem.id}-btn`);
const findLeaveGroupBtn = () => wrapper.findByTestId(`leave-group-${mockParentGroupItem.id}-btn`);
const findRemoveGroupBtn = () =>
diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js
index 2333f04bb2e..ff273fcf6da 100644
--- a/spec/frontend/groups/components/item_caret_spec.js
+++ b/spec/frontend/groups/components/item_caret_spec.js
@@ -15,13 +15,6 @@ describe('ItemCaret', () => {
});
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findAllGlIcons = () => wrapper.findAllComponents(GlIcon);
const findGlIcon = () => wrapper.findComponent(GlIcon);
diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js
index 0c2912adc66..b98e60bed63 100644
--- a/spec/frontend/groups/components/item_stats_spec.js
+++ b/spec/frontend/groups/components/item_stats_spec.js
@@ -17,13 +17,6 @@ describe('ItemStats', () => {
});
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findItemStatsValue = () => wrapper.findComponent(ItemStatsValue);
describe('template', () => {
diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js
index b9db83c7dd7..e110004dbac 100644
--- a/spec/frontend/groups/components/item_stats_value_spec.js
+++ b/spec/frontend/groups/components/item_stats_value_spec.js
@@ -18,13 +18,6 @@ describe('ItemStatsValue', () => {
});
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findGlIcon = () => wrapper.findComponent(GlIcon);
const findStatValue = () => wrapper.find('[data-testid="itemStatValue"]');
diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js
index aa00e82150b..c269dc98a45 100644
--- a/spec/frontend/groups/components/item_type_icon_spec.js
+++ b/spec/frontend/groups/components/item_type_icon_spec.js
@@ -16,13 +16,6 @@ describe('ItemTypeIcon', () => {
});
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findGlIcon = () => wrapper.findComponent(GlIcon);
describe('template', () => {
diff --git a/spec/frontend/groups/components/new_top_level_group_alert_spec.js b/spec/frontend/groups/components/new_top_level_group_alert_spec.js
index db9a5c7b16b..060663747e4 100644
--- a/spec/frontend/groups/components/new_top_level_group_alert_spec.js
+++ b/spec/frontend/groups/components/new_top_level_group_alert_spec.js
@@ -30,10 +30,6 @@ describe('NewTopLevelGroupAlert', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when the component is created', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js
index d1ae2c4be17..101dd06d578 100644
--- a/spec/frontend/groups/components/overview_tabs_spec.js
+++ b/spec/frontend/groups/components/overview_tabs_spec.js
@@ -39,6 +39,7 @@ describe('OverviewTabs', () => {
newProjectPath: 'projects/new',
newSubgroupIllustration: '',
newProjectIllustration: '',
+ emptyProjectsIllustration: '',
emptySubgroupIllustration: '',
canCreateSubgroups: false,
canCreateProjects: false,
@@ -76,7 +77,6 @@ describe('OverviewTabs', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
});
diff --git a/spec/frontend/groups/components/transfer_group_form_spec.js b/spec/frontend/groups/components/transfer_group_form_spec.js
index 0065820f78f..4d4de1ae3d5 100644
--- a/spec/frontend/groups/components/transfer_group_form_spec.js
+++ b/spec/frontend/groups/components/transfer_group_form_spec.js
@@ -48,10 +48,6 @@ describe('Transfer group form', () => {
const findTransferLocations = () => wrapper.findComponent(TransferLocations);
const findHiddenInput = () => wrapper.find('[name="new_parent_group_id"]');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
@@ -73,7 +69,7 @@ describe('Transfer group form', () => {
expect(findHiddenInput().attributes('value')).toBeUndefined();
});
- it('does not render the alert message', () => {
+ it('does not render the alert', () => {
expect(findAlert().exists()).toBe(false);
});
diff --git a/spec/frontend/groups/settings/components/group_settings_readme_spec.js b/spec/frontend/groups/settings/components/group_settings_readme_spec.js
new file mode 100644
index 00000000000..8d4da73934f
--- /dev/null
+++ b/spec/frontend/groups/settings/components/group_settings_readme_spec.js
@@ -0,0 +1,112 @@
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import GroupSettingsReadme from '~/groups/settings/components/group_settings_readme.vue';
+import { GITLAB_README_PROJECT } from '~/groups/settings/constants';
+import {
+ MOCK_GROUP_PATH,
+ MOCK_GROUP_ID,
+ MOCK_PATH_TO_GROUP_README,
+ MOCK_PATH_TO_README_PROJECT,
+} from '../mock_data';
+
+describe('GroupSettingsReadme', () => {
+ let wrapper;
+
+ const defaultProps = {
+ groupPath: MOCK_GROUP_PATH,
+ groupId: MOCK_GROUP_ID,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(GroupSettingsReadme, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ stubs: {
+ GlModal,
+ GlSprintf,
+ },
+ });
+ };
+
+ const findHasReadmeButtonLink = () => wrapper.findByText('README');
+ const findAddReadmeButton = () => wrapper.findByTestId('group-settings-add-readme-button');
+ const findModalBody = () => wrapper.findByTestId('group-settings-modal-readme-body');
+ const findModalCreateReadmeButton = () =>
+ wrapper.findByTestId('group-settings-modal-create-readme-button');
+
+ describe('Group has existing README', () => {
+ beforeEach(() => {
+ createComponent({
+ groupReadmePath: MOCK_PATH_TO_GROUP_README,
+ readmeProjectPath: MOCK_PATH_TO_README_PROJECT,
+ });
+ });
+
+ describe('template', () => {
+ it('renders README Button Link with correct path and text', () => {
+ expect(findHasReadmeButtonLink().exists()).toBe(true);
+ expect(findHasReadmeButtonLink().attributes('href')).toBe(MOCK_PATH_TO_GROUP_README);
+ });
+
+ it('does not render Add README Button', () => {
+ expect(findAddReadmeButton().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Group has README project without README file', () => {
+ beforeEach(() => {
+ createComponent({ readmeProjectPath: MOCK_PATH_TO_README_PROJECT });
+ });
+
+ describe('template', () => {
+ it('does not render README', () => {
+ expect(findHasReadmeButtonLink().exists()).toBe(false);
+ });
+
+ it('does render Add Readme Button with correct text', () => {
+ expect(findAddReadmeButton().exists()).toBe(true);
+ expect(findAddReadmeButton().text()).toBe('Add README');
+ });
+
+ it('generates a hidden modal with correct body text', () => {
+ expect(findModalBody().text()).toMatchInterpolatedText(
+ `This will create a README.md for project ${MOCK_PATH_TO_README_PROJECT}.`,
+ );
+ });
+
+ it('generates a hidden modal with correct button text', () => {
+ expect(findModalCreateReadmeButton().text()).toBe('Add README');
+ });
+ });
+ });
+
+ describe('Group does not have README project', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('template', () => {
+ it('does not render README', () => {
+ expect(findHasReadmeButtonLink().exists()).toBe(false);
+ });
+
+ it('does render Add Readme Button with correct text', () => {
+ expect(findAddReadmeButton().exists()).toBe(true);
+ expect(findAddReadmeButton().text()).toBe('Add README');
+ });
+
+ it('generates a hidden modal with correct body text', () => {
+ expect(findModalBody().text()).toMatchInterpolatedText(
+ `This will create a project ${MOCK_GROUP_PATH}/${GITLAB_README_PROJECT} and add a README.md.`,
+ );
+ });
+
+ it('generates a hidden modal with correct button text', () => {
+ expect(findModalCreateReadmeButton().text()).toBe('Create and add README');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/groups/settings/mock_data.js b/spec/frontend/groups/settings/mock_data.js
new file mode 100644
index 00000000000..4551ee3318b
--- /dev/null
+++ b/spec/frontend/groups/settings/mock_data.js
@@ -0,0 +1,6 @@
+export const MOCK_GROUP_PATH = 'test-group';
+export const MOCK_GROUP_ID = '999';
+
+export const MOCK_PATH_TO_GROUP_README = '/group/project/-/blob/main/README.md';
+
+export const MOCK_PATH_TO_README_PROJECT = 'group/project';
diff --git a/spec/frontend/groups_projects/components/transfer_locations_spec.js b/spec/frontend/groups_projects/components/transfer_locations_spec.js
index 77c0966ba1e..86913bb4c09 100644
--- a/spec/frontend/groups_projects/components/transfer_locations_spec.js
+++ b/spec/frontend/groups_projects/components/transfer_locations_spec.js
@@ -109,10 +109,6 @@ describe('TransferLocations', () => {
const intersectionObserverEmitAppear = () => findIntersectionObserver().vm.$emit('appear');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when `GlDropdown` is opened', () => {
it('shows loading icon', async () => {
getTransferLocations.mockReturnValueOnce(new Promise(() => {}));
diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js
index d6263c663d2..ad56b2dde24 100644
--- a/spec/frontend/header_search/components/app_spec.js
+++ b/spec/frontend/header_search/components/app_spec.js
@@ -20,6 +20,7 @@ import {
IS_NOT_FOCUSED,
IS_FOCUSED,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
+ DROPDOWN_CLOSE_TIMEOUT,
} from '~/header_search/constants';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import { ENTER_KEY } from '~/lib/utils/keys';
@@ -43,6 +44,9 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('HeaderSearchApp', () => {
let wrapper;
+ jest.useFakeTimers();
+ jest.spyOn(global, 'setTimeout');
+
const actionSpies = {
setSearch: jest.fn(),
fetchAutocompleteOptions: jest.fn(),
@@ -80,10 +84,6 @@ describe('HeaderSearchApp', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form');
const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findScopeToken = () => wrapper.findComponent(GlToken);
@@ -135,7 +135,7 @@ describe('HeaderSearchApp', () => {
beforeEach(() => {
window.gon.current_username = username;
createComponent();
- findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
+ findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : '');
});
it(`should${showSearchDropdown ? '' : ' not'} render`, () => {
@@ -157,7 +157,7 @@ describe('HeaderSearchApp', () => {
beforeEach(() => {
window.gon.current_username = MOCK_USERNAME;
createComponent({ search }, {});
- findHeaderSearchInput().vm.$emit('click');
+ findHeaderSearchInput().vm.$emit('focusin');
});
it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
@@ -178,6 +178,7 @@ describe('HeaderSearchApp', () => {
it(`should close the dropdown when press escape key`, async () => {
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: 27 }));
+ jest.runAllTimers();
await nextTick();
expect(findHeaderSearchDropdown().exists()).toBe(false);
expect(wrapper.emitted().expandSearchBar.length).toBe(1);
@@ -187,16 +188,16 @@ describe('HeaderSearchApp', () => {
describe.each`
username | showDropdown | expectedDesc
- ${null} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown}
- ${null} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown}
- ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown}
- ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown}
+ ${null} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN}
+ ${null} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN}
+ ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN}
+ ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN}
`('Search Input Description', ({ username, showDropdown, expectedDesc }) => {
describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => {
beforeEach(() => {
window.gon.current_username = username;
createComponent();
- findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
+ findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : '');
});
it(`sets description to ${expectedDesc}`, () => {
@@ -212,7 +213,7 @@ describe('HeaderSearchApp', () => {
${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`}
- ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.searchResultsLoading}
+ ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.SEARCH_RESULTS_LOADING}
`(
'Search Results Description',
({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => {
@@ -228,7 +229,7 @@ describe('HeaderSearchApp', () => {
searchOptions: () => searchOptions,
},
);
- findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
+ findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : '');
});
it(`sets description to ${expectedDesc}`, () => {
@@ -257,7 +258,7 @@ describe('HeaderSearchApp', () => {
searchOptions: () => searchOptions,
},
);
- findHeaderSearchInput().vm.$emit('click');
+ findHeaderSearchInput().vm.$emit('focusin');
});
it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${
@@ -291,7 +292,7 @@ describe('HeaderSearchApp', () => {
window.gon.current_username = MOCK_USERNAME;
createComponent({ search, searchContext }, { searchOptions: () => searchOptions });
if (isFocused) {
- findHeaderSearchInput().vm.$emit('click');
+ findHeaderSearchInput().vm.$emit('focusin');
}
});
@@ -332,7 +333,7 @@ describe('HeaderSearchApp', () => {
searchOptions: () => searchOptions,
},
);
- findHeaderSearchInput().vm.$emit('click');
+ findHeaderSearchInput().vm.$emit('focusin');
});
it(`icon for data set type "${searchOptions[0]?.html_id}" ${
@@ -353,12 +354,12 @@ describe('HeaderSearchApp', () => {
});
describe('events', () => {
- beforeEach(() => {
- createComponent();
- window.gon.current_username = MOCK_USERNAME;
- });
-
describe('Header Search Input', () => {
+ beforeEach(() => {
+ window.gon.current_username = MOCK_USERNAME;
+ createComponent();
+ });
+
describe('when dropdown is closed', () => {
let trackingSpy;
@@ -366,9 +367,9 @@ describe('HeaderSearchApp', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
- it('onFocus opens dropdown and triggers snowplow event', async () => {
+ it('onFocusin opens dropdown and triggers snowplow event', async () => {
expect(findHeaderSearchDropdown().exists()).toBe(false);
- findHeaderSearchInput().vm.$emit('focus');
+ findHeaderSearchInput().vm.$emit('focusin');
await nextTick();
@@ -379,25 +380,19 @@ describe('HeaderSearchApp', () => {
});
});
- it('onClick opens dropdown and triggers snowplow event', async () => {
+ it('onFocusout closes dropdown and triggers snowplow event', async () => {
expect(findHeaderSearchDropdown().exists()).toBe(false);
- findHeaderSearchInput().vm.$emit('click');
+ findHeaderSearchInput().vm.$emit('focusout');
+ jest.runAllTimers();
await nextTick();
- expect(findHeaderSearchDropdown().exists()).toBe(true);
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', {
+ expect(findHeaderSearchDropdown().exists()).toBe(false);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'blur_input', {
label: 'global_search',
property: 'navigation_top',
});
});
-
- it('onClick followed by onFocus only triggers a single snowplow event', async () => {
- findHeaderSearchInput().vm.$emit('click');
- findHeaderSearchInput().vm.$emit('focus');
-
- expect(trackingSpy).toHaveBeenCalledTimes(1);
- });
});
describe('onInput', () => {
@@ -439,18 +434,18 @@ describe('HeaderSearchApp', () => {
});
});
- describe('Dropdown Keyboard Navigation', () => {
+ describe('onFocusout dropdown', () => {
beforeEach(() => {
- findHeaderSearchInput().vm.$emit('click');
+ window.gon.current_username = MOCK_USERNAME;
+ createComponent({ search: 'tes' }, {});
+ findHeaderSearchInput().vm.$emit('focusin');
});
- it('closes dropdown when @tab is emitted', async () => {
- expect(findHeaderSearchDropdown().exists()).toBe(true);
- findDropdownKeyboardNavigation().vm.$emit('tab');
-
- await nextTick();
+ it('closes with timeout so click event gets emited', () => {
+ findHeaderSearchInput().vm.$emit('focusout');
- expect(findHeaderSearchDropdown().exists()).toBe(false);
+ expect(setTimeout).toHaveBeenCalledTimes(1);
+ expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), DROPDOWN_CLOSE_TIMEOUT);
});
});
});
@@ -463,9 +458,9 @@ describe('HeaderSearchApp', () => {
${2} | ${'test1'}
`('currentFocusedOption', ({ MOCK_INDEX, search }) => {
beforeEach(() => {
- createComponent({ search });
window.gon.current_username = MOCK_USERNAME;
- findHeaderSearchInput().vm.$emit('click');
+ createComponent({ search });
+ findHeaderSearchInput().vm.$emit('focusin');
});
it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => {
@@ -504,12 +499,13 @@ describe('HeaderSearchApp', () => {
const MOCK_INDEX = 1;
beforeEach(() => {
- createComponent();
window.gon.current_username = MOCK_USERNAME;
- findHeaderSearchInput().vm.$emit('click');
+ createComponent();
+ findHeaderSearchInput().vm.$emit('focusin');
});
- it('onKey-enter clicks the selected dropdown item rather than submitting a search', () => {
+ it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => {
+ await nextTick();
findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
index 7952661e2d2..e77a9231b7a 100644
--- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
@@ -3,15 +3,14 @@ import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
+import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '~/header_search/constants';
import {
- GROUPS_CATEGORY,
- LARGE_AVATAR_PX,
PROJECTS_CATEGORY,
- SMALL_AVATAR_PX,
+ GROUPS_CATEGORY,
ISSUES_CATEGORY,
MERGE_REQUEST_CATEGORY,
RECENT_EPICS_CATEGORY,
-} from '~/header_search/constants';
+} from '~/vue_shared/global_search/constants';
import {
MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
@@ -46,10 +45,6 @@ describe('HeaderSearchAutocompleteItems', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findGlDropdownDividers = () => wrapper.findAllComponents(GlDropdownDivider);
const findFirstDropdownItem = () => findDropdownItems().at(0);
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
index abcacc487df..3768862d83e 100644
--- a/spec/frontend/header_search/components/header_search_default_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_default_items_spec.js
@@ -29,10 +29,6 @@ describe('HeaderSearchDefaultItems', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstDropdownItem = () => findDropdownItems().at(0);
diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
index 2db9f71d702..51d67198f04 100644
--- a/spec/frontend/header_search/components/header_search_scoped_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
@@ -5,7 +5,8 @@ import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
import { truncate } from '~/lib/utils/text_utility';
-import { MSG_IN_ALL_GITLAB, SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants';
+import { SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants';
+import { MSG_IN_ALL_GITLAB } from '~/vue_shared/global_search/constants';
import {
MOCK_SEARCH,
MOCK_SCOPED_SEARCH_OPTIONS,
@@ -38,10 +39,6 @@ describe('HeaderSearchScopedItems', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
diff --git a/spec/frontend/header_search/init_spec.js b/spec/frontend/header_search/init_spec.js
index 40c1843d461..9ccc6919b81 100644
--- a/spec/frontend/header_search/init_spec.js
+++ b/spec/frontend/header_search/init_spec.js
@@ -33,7 +33,6 @@ describe('Header Search EventListener', () => {
jest.mock('~/header_search', () => ({ initHeaderSearchApp: jest.fn() }));
await eventHandler.apply(
{
- newHeaderSearchFeatureFlag: true,
searchInputBox: document.querySelector('#search'),
},
[cleanEventListeners],
@@ -47,7 +46,6 @@ describe('Header Search EventListener', () => {
jest.mock('~/header_search', () => ({ initHeaderSearchApp: mockVueApp }));
await eventHandler.apply(
{
- newHeaderSearchFeatureFlag: true,
searchInputBox: document.querySelector('#search'),
},
() => {},
@@ -55,20 +53,4 @@ describe('Header Search EventListener', () => {
expect(mockVueApp).toHaveBeenCalled();
});
-
- it('attaches old vue dropdown when feature flag is disabled', async () => {
- const mockLegacyApp = jest.fn(() => ({
- onSearchInputFocus: jest.fn(),
- }));
- jest.mock('~/search_autocomplete', () => mockLegacyApp);
- await eventHandler.apply(
- {
- newHeaderSearchFeatureFlag: false,
- searchInputBox: document.querySelector('#search'),
- },
- () => {},
- );
-
- expect(mockLegacyApp).toHaveBeenCalled();
- });
});
diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js
index 3a8624ad9dd..2218c81efc3 100644
--- a/spec/frontend/header_search/mock_data.js
+++ b/spec/frontend/header_search/mock_data.js
@@ -1,16 +1,14 @@
+import { ICON_PROJECT, ICON_GROUP, ICON_SUBGROUP } from '~/header_search/constants';
import {
+ PROJECTS_CATEGORY,
+ GROUPS_CATEGORY,
MSG_ISSUES_ASSIGNED_TO_ME,
MSG_ISSUES_IVE_CREATED,
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
MSG_IN_ALL_GITLAB,
- PROJECTS_CATEGORY,
- ICON_PROJECT,
- GROUPS_CATEGORY,
- ICON_GROUP,
- ICON_SUBGROUP,
-} from '~/header_search/constants';
+} from '~/vue_shared/global_search/constants';
export const MOCK_USERNAME = 'anyone';
diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js
index bd93b0edadf..95a619ebeca 100644
--- a/spec/frontend/header_search/store/actions_spec.js
+++ b/spec/frontend/header_search/store/actions_spec.js
@@ -16,7 +16,7 @@ import {
MOCK_ISSUE_PATH,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Header Search Store Actions', () => {
let state;
diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js
index a1d9481b5cc..7a7a00178f1 100644
--- a/spec/frontend/header_search/store/getters_spec.js
+++ b/spec/frontend/header_search/store/getters_spec.js
@@ -241,6 +241,13 @@ describe('Header Search Store Getters', () => {
MOCK_DEFAULT_SEARCH_OPTIONS,
);
});
+
+ it('returns the correct array if issues path is false', () => {
+ mockGetters.scopedIssuesPath = undefined;
+ expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual(
+ MOCK_DEFAULT_SEARCH_OPTIONS.slice(2, MOCK_DEFAULT_SEARCH_OPTIONS.length),
+ );
+ });
});
describe('scopedSearchOptions', () => {
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
index 4e2fb70a2cb..4907dc09a3c 100644
--- a/spec/frontend/header_spec.js
+++ b/spec/frontend/header_spec.js
@@ -1,11 +1,11 @@
+import htmlOpenIssue from 'test_fixtures/issues/open-issue.html';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import initTodoToggle, { initNavUserDropdownTracking } from '~/header';
-import { loadHTMLFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
describe('Header', () => {
describe('Todos notification', () => {
const todosPendingCount = '.js-todos-count';
- const fixtureTemplate = 'issues/open-issue.html';
function isTodosCountHidden() {
return document.querySelector(todosPendingCount).classList.contains('hidden');
@@ -23,7 +23,7 @@ describe('Header', () => {
beforeEach(() => {
initTodoToggle();
- loadHTMLFixture(fixtureTemplate);
+ setHTMLFixture(htmlOpenIssue);
});
afterEach(() => {
diff --git a/spec/frontend/helpers/init_simple_app_helper_spec.js b/spec/frontend/helpers/init_simple_app_helper_spec.js
index 8dd3745e0ac..7938e3851d0 100644
--- a/spec/frontend/helpers/init_simple_app_helper_spec.js
+++ b/spec/frontend/helpers/init_simple_app_helper_spec.js
@@ -38,19 +38,19 @@ describe('helpers/init_simple_app_helper/initSimpleApp', () => {
resetHTMLFixture();
});
- it('mounts the component if the selector exists', async () => {
+ it('mounts the component if the selector exists', () => {
initMock('<div id="mount-here"></div>');
expect(findMock().exists()).toBe(true);
});
- it('does not mount the component if selector does not exist', async () => {
+ it('does not mount the component if selector does not exist', () => {
initMock('<div id="do-not-mount-here"></div>');
expect(didCreateApp()).toBe(false);
});
- it('passes the prop to the component if the prop exists', async () => {
+ it('passes the prop to the component if the prop exists', () => {
initMock(`<div id="mount-here" data-view-model={"someKey":"thing","count":123}></div>`);
expect(findMock().props()).toEqual({
diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js
index 05161437c22..28c742386cc 100644
--- a/spec/frontend/helpers/startup_css_helper_spec.js
+++ b/spec/frontend/helpers/startup_css_helper_spec.js
@@ -21,17 +21,10 @@ describe('waitForCSSLoaded', () => {
});
describe('when gon features is not provided', () => {
- let originalGon;
-
beforeEach(() => {
- originalGon = window.gon;
window.gon = null;
});
- afterEach(() => {
- window.gon = originalGon;
- });
-
it('should invoke the action right away', async () => {
const events = waitForCSSLoaded(mockedCallback);
await events;
diff --git a/spec/frontend/ide/components/activity_bar_spec.js b/spec/frontend/ide/components/activity_bar_spec.js
index a97e883a8bf..95582aca8fd 100644
--- a/spec/frontend/ide/components/activity_bar_spec.js
+++ b/spec/frontend/ide/components/activity_bar_spec.js
@@ -1,14 +1,20 @@
+import { nextTick } from 'vue';
import { GlBadge } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ActivityBar from '~/ide/components/activity_bar.vue';
import { leftSidebarViews } from '~/ide/constants';
import { createStore } from '~/ide/stores';
+const { edit, ...VIEW_OBJECTS_WITHOUT_EDIT } = leftSidebarViews;
+const MODES_WITHOUT_EDIT = Object.keys(VIEW_OBJECTS_WITHOUT_EDIT);
+const MODES = Object.keys(leftSidebarViews);
+
describe('IDE ActivityBar component', () => {
let wrapper;
let store;
const findChangesBadge = () => wrapper.findComponent(GlBadge);
+ const findModeButton = (mode) => wrapper.findByTestId(`${mode}-mode-button`);
const mountComponent = (state) => {
store = createStore();
@@ -19,49 +25,43 @@ describe('IDE ActivityBar component', () => {
...state,
});
- wrapper = shallowMount(ActivityBar, { store });
+ wrapper = shallowMountExtended(ActivityBar, { store });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('updateActivityBarView', () => {
- beforeEach(() => {
- mountComponent();
- jest.spyOn(wrapper.vm, 'updateActivityBarView').mockImplementation(() => {});
- });
+ describe('active item', () => {
+ // Test that mode button does not have 'active' class before click,
+ // and does have 'active' class after click
+ const testSettingActiveItem = async (mode) => {
+ const button = findModeButton(mode);
- it('calls updateActivityBarView with edit value on click', () => {
- wrapper.find('.js-ide-edit-mode').trigger('click');
+ expect(button.classes('active')).toBe(false);
- expect(wrapper.vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.edit.name);
- });
+ button.trigger('click');
+ await nextTick();
- it('calls updateActivityBarView with commit value on click', () => {
- wrapper.find('.js-ide-commit-mode').trigger('click');
+ expect(button.classes('active')).toBe(true);
+ };
- expect(wrapper.vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.commit.name);
- });
+ it.each(MODES)('is initially set to %s mode', (mode) => {
+ mountComponent({ currentActivityView: leftSidebarViews[mode].name });
- it('calls updateActivityBarView with review value on click', () => {
- wrapper.find('.js-ide-review-mode').trigger('click');
+ const button = findModeButton(mode);
- expect(wrapper.vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.review.name);
+ expect(button.classes('active')).toBe(true);
});
- });
- describe('active item', () => {
- it('sets edit item active', () => {
+ it.each(MODES_WITHOUT_EDIT)('is correctly set after clicking %s mode button', (mode) => {
mountComponent();
- expect(wrapper.find('.js-ide-edit-mode').classes()).toContain('active');
+ testSettingActiveItem(mode);
});
- it('sets commit item active', () => {
- mountComponent({ currentActivityView: leftSidebarViews.commit.name });
+ it('is correctly set after clicking edit mode button', () => {
+ // The default currentActivityView is leftSidebarViews.edit.name,
+ // so for the 'edit' mode, we pass a different currentActivityView.
+ mountComponent({ currentActivityView: leftSidebarViews.review.name });
- expect(wrapper.find('.js-ide-commit-mode').classes()).toContain('active');
+ testSettingActiveItem('edit');
});
});
@@ -69,7 +69,6 @@ describe('IDE ActivityBar component', () => {
it('is rendered when files are staged', () => {
mountComponent({ stagedFiles: [{ path: '/path/to/file' }] });
- expect(findChangesBadge().exists()).toBe(true);
expect(findChangesBadge().text()).toBe('1');
});
diff --git a/spec/frontend/ide/components/branches/item_spec.js b/spec/frontend/ide/components/branches/item_spec.js
index 3dbd1210916..4cae146cbd2 100644
--- a/spec/frontend/ide/components/branches/item_spec.js
+++ b/spec/frontend/ide/components/branches/item_spec.js
@@ -34,10 +34,6 @@ describe('IDE branch item', () => {
router = createRouter(store);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('if not active', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/ide/components/branches/search_list_spec.js b/spec/frontend/ide/components/branches/search_list_spec.js
index bbde45d700f..eeab26f7559 100644
--- a/spec/frontend/ide/components/branches/search_list_spec.js
+++ b/spec/frontend/ide/components/branches/search_list_spec.js
@@ -35,11 +35,6 @@ describe('IDE branches search list', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('calls fetch on mounted', () => {
createComponent();
expect(fetchBranchesMock).toHaveBeenCalled();
diff --git a/spec/frontend/ide/components/cannot_push_code_alert_spec.js b/spec/frontend/ide/components/cannot_push_code_alert_spec.js
index ff659ecdf3f..c72d8c5fccd 100644
--- a/spec/frontend/ide/components/cannot_push_code_alert_spec.js
+++ b/spec/frontend/ide/components/cannot_push_code_alert_spec.js
@@ -10,10 +10,6 @@ const TEST_BUTTON_TEXT = 'Fork text';
describe('ide/components/cannot_push_code_alert', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
const createComponent = (props = {}) => {
wrapper = shallowMount(CannotPushCodeAlert, {
propsData: {
@@ -49,7 +45,7 @@ describe('ide/components/cannot_push_code_alert', () => {
createComponent();
});
- it('shows alert with message', () => {
+ it('shows an alert with message', () => {
expect(findAlert().props()).toMatchObject({ dismissible: false });
expect(findAlert().text()).toBe(TEST_MESSAGE);
});
diff --git a/spec/frontend/ide/components/commit_sidebar/actions_spec.js b/spec/frontend/ide/components/commit_sidebar/actions_spec.js
index dc103fec5d0..019469cbf87 100644
--- a/spec/frontend/ide/components/commit_sidebar/actions_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/actions_spec.js
@@ -46,10 +46,6 @@ describe('IDE commit sidebar actions', () => {
jest.spyOn(store, 'dispatch').mockImplementation(() => {});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findText = () => wrapper.text();
const findRadios = () => wrapper.findAll('input[type="radio"]');
diff --git a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
index f6d5833edee..ce43e648b43 100644
--- a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
@@ -1,7 +1,9 @@
-import { mount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import EditorHeader from '~/ide/components/commit_sidebar/editor_header.vue';
+import { stubComponent } from 'helpers/stub_component';
import { createStore } from '~/ide/stores';
import { file } from '../../helpers';
@@ -12,9 +14,10 @@ const TEST_FILE_PATH = 'test/file/path';
describe('IDE commit editor header', () => {
let wrapper;
let store;
+ const showMock = jest.fn();
const createComponent = (fileProps = {}) => {
- wrapper = mount(EditorHeader, {
+ wrapper = shallowMount(EditorHeader, {
store,
propsData: {
activeFile: {
@@ -23,22 +26,17 @@ describe('IDE commit editor header', () => {
...fileProps,
},
},
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ methods: { show: showMock },
+ }),
+ },
});
};
const findDiscardModal = () => wrapper.findComponent({ ref: 'discardModal' });
const findDiscardButton = () => wrapper.findComponent({ ref: 'discardButton' });
- beforeEach(() => {
- store = createStore();
- jest.spyOn(store, 'dispatch').mockImplementation();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it.each`
fileProps | shouldExist
${{ staged: false, changed: false }} | ${false}
@@ -52,20 +50,19 @@ describe('IDE commit editor header', () => {
});
describe('discard button', () => {
- beforeEach(() => {
+ it('opens a dialog confirming discard', () => {
createComponent();
+ findDiscardButton().vm.$emit('click');
- const modal = findDiscardModal();
- jest.spyOn(modal.vm, 'show');
-
- findDiscardButton().trigger('click');
- });
-
- it('opens a dialog confirming discard', () => {
- expect(findDiscardModal().vm.show).toHaveBeenCalled();
+ expect(showMock).toHaveBeenCalled();
});
it('calls discardFileChanges if dialog result is confirmed', () => {
+ store = createStore();
+ jest.spyOn(store, 'dispatch').mockImplementation();
+
+ createComponent();
+
expect(store.dispatch).not.toHaveBeenCalled();
findDiscardModal().vm.$emit('primary');
diff --git a/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js b/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js
index 7c48c0e6f95..4a6aafe42ae 100644
--- a/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js
@@ -11,10 +11,6 @@ describe('IDE commit panel EmptyState component', () => {
wrapper = shallowMount(EmptyState, { store });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders no changes text when last commit message is empty', () => {
expect(wrapper.find('h4').text()).toBe('No changes');
});
diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js
index a8ee81afa0b..04dd81d9fda 100644
--- a/spec/frontend/ide/components/commit_sidebar/form_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js
@@ -21,15 +21,20 @@ import { COMMIT_TO_NEW_BRANCH } from '~/ide/stores/modules/commit/constants';
describe('IDE commit form', () => {
let wrapper;
let store;
+ const showModalSpy = jest.fn();
const createComponent = () => {
wrapper = shallowMount(CommitForm, {
store,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
stubs: {
- GlModal: stubComponent(GlModal),
+ GlModal: stubComponent(GlModal, {
+ methods: {
+ show: showModalSpy,
+ },
+ }),
},
});
};
@@ -57,6 +62,7 @@ describe('IDE commit form', () => {
tooltip: getBinding(findCommitButtonTooltip().element, 'gl-tooltip').value.title,
});
const findForm = () => wrapper.find('form');
+ const findModal = () => wrapper.findComponent(GlModal);
const submitForm = () => findForm().trigger('submit');
const findCommitMessageInput = () => wrapper.findComponent(CommitMessageField);
const setCommitMessageInput = (val) => findCommitMessageInput().vm.$emit('input', val);
@@ -73,10 +79,6 @@ describe('IDE commit form', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
// Notes:
// - When there are no changes, there is no commit button so there's nothing to test :)
describe.each`
@@ -87,7 +89,7 @@ describe('IDE commit form', () => {
${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE}
${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToCommitView} | ${findCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE}
`('$desc', ({ stagedFiles, userPermissions, viewFn, buttonFn, disabled, tooltip }) => {
- beforeEach(async () => {
+ beforeEach(() => {
store.state.stagedFiles = stagedFiles;
store.state.projects.abcproject.userPermissions = userPermissions;
@@ -302,22 +304,19 @@ describe('IDE commit form', () => {
${() => createCodeownersCommitError('test message')} | ${{ actionPrimary: { text: 'Create new branch' } }}
${createUnexpectedCommitError} | ${{ actionPrimary: null }}
`('opens error modal if commitError with $error', async ({ createError, props }) => {
- const modal = wrapper.findComponent(GlModal);
- modal.vm.show = jest.fn();
-
const error = createError();
store.state.commit.commitError = error;
await nextTick();
- expect(modal.vm.show).toHaveBeenCalled();
- expect(modal.props()).toMatchObject({
+ expect(showModalSpy).toHaveBeenCalled();
+ expect(findModal().props()).toMatchObject({
actionCancel: { text: 'Cancel' },
...props,
});
// Because of the legacy 'mountComponent' approach here, the only way to
// test the text of the modal is by viewing the content of the modal added to the document.
- expect(modal.html()).toContain(error.messageHTML);
+ expect(findModal().html()).toContain(error.messageHTML);
});
});
@@ -343,7 +342,7 @@ describe('IDE commit form', () => {
await nextTick();
- wrapper.findComponent(GlModal).vm.$emit('ok');
+ findModal().vm.$emit('ok');
await waitForPromises();
diff --git a/spec/frontend/ide/components/commit_sidebar/list_item_spec.js b/spec/frontend/ide/components/commit_sidebar/list_item_spec.js
index c9571d39acb..c2a33c0d71e 100644
--- a/spec/frontend/ide/components/commit_sidebar/list_item_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/list_item_spec.js
@@ -36,10 +36,6 @@ describe('Multi-file editor commit sidebar list item', () => {
findPathEl = wrapper.find('.multi-file-commit-list-path');
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findPathText = () => trimText(findPathEl.text());
it('renders file path', () => {
diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js
index 4406d14d990..c0b0cb0b732 100644
--- a/spec/frontend/ide/components/commit_sidebar/list_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js
@@ -19,12 +19,8 @@ describe('Multi-file editor commit sidebar list', () => {
},
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with a list of files', () => {
- beforeEach(async () => {
+ beforeEach(() => {
const f = file('file name');
f.changed = true;
wrapper = mountComponent({ fileList: [f] });
diff --git a/spec/frontend/ide/components/commit_sidebar/message_field_spec.js b/spec/frontend/ide/components/commit_sidebar/message_field_spec.js
index c2ef29c1059..3403a7b8ad9 100644
--- a/spec/frontend/ide/components/commit_sidebar/message_field_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/message_field_spec.js
@@ -15,10 +15,6 @@ describe('IDE commit message field', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findMessage = () => wrapper.find('textarea');
const findHighlights = () => wrapper.findAll('.highlights span');
const findMarks = () => wrapper.findAll('mark');
diff --git a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js
index 2a455c9d7c1..ce26519abc9 100644
--- a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js
@@ -33,15 +33,11 @@ describe('NewMergeRequestOption component', () => {
},
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when the `shouldHideNewMrOption` getter returns false', () => {
beforeEach(() => {
createComponent();
@@ -70,7 +66,7 @@ describe('NewMergeRequestOption component', () => {
});
it('disables the new MR checkbox', () => {
- expect(findCheckbox().attributes('disabled')).toBe('true');
+ expect(findCheckbox().attributes('disabled')).toBeDefined();
});
it('adds `is-disabled` class to the fieldset', () => {
diff --git a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js
index a3fa03a4aa5..cdf14056523 100644
--- a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js
@@ -19,15 +19,11 @@ describe('IDE commit sidebar radio group', () => {
propsData: config.props,
slots: config.slots,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('without input', () => {
const props = {
value: '1',
diff --git a/spec/frontend/ide/components/commit_sidebar/success_message_spec.js b/spec/frontend/ide/components/commit_sidebar/success_message_spec.js
index 63d51953915..d1a81dd1639 100644
--- a/spec/frontend/ide/components/commit_sidebar/success_message_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/success_message_spec.js
@@ -12,10 +12,6 @@ describe('IDE commit panel successful commit state', () => {
wrapper = shallowMount(SuccessMessage, { store });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders last commit message when it exists', () => {
expect(wrapper.text()).toContain('testing commit message');
});
diff --git a/spec/frontend/ide/components/error_message_spec.js b/spec/frontend/ide/components/error_message_spec.js
index 204d39de741..5f6579654bc 100644
--- a/spec/frontend/ide/components/error_message_spec.js
+++ b/spec/frontend/ide/components/error_message_spec.js
@@ -32,11 +32,6 @@ describe('IDE error message component', () => {
setErrorMessageMock.mockReset();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findDismissButton = () => wrapper.find('button[aria-label=Dismiss]');
const findActionButton = () => wrapper.find('button.gl-alert-action');
diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js
index 281c549a1b4..f5a6e7222f9 100644
--- a/spec/frontend/ide/components/file_row_extra_spec.js
+++ b/spec/frontend/ide/components/file_row_extra_spec.js
@@ -37,8 +37,6 @@ describe('IDE extra file row component', () => {
};
afterEach(() => {
- wrapper.destroy();
-
stagedFilesCount = 0;
unstagedFilesCount = 0;
changesCount = 0;
diff --git a/spec/frontend/ide/components/file_templates/bar_spec.js b/spec/frontend/ide/components/file_templates/bar_spec.js
index 60f37260393..b8c850fdd13 100644
--- a/spec/frontend/ide/components/file_templates/bar_spec.js
+++ b/spec/frontend/ide/components/file_templates/bar_spec.js
@@ -21,10 +21,6 @@ describe('IDE file templates bar component', () => {
wrapper = mount(Bar, { store });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template type dropdown', () => {
it('renders dropdown component', () => {
expect(wrapper.find('.dropdown').text()).toContain('Choose a type');
diff --git a/spec/frontend/ide/components/file_templates/dropdown_spec.js b/spec/frontend/ide/components/file_templates/dropdown_spec.js
index ee90d87357c..72fdd05eb2c 100644
--- a/spec/frontend/ide/components/file_templates/dropdown_spec.js
+++ b/spec/frontend/ide/components/file_templates/dropdown_spec.js
@@ -49,11 +49,6 @@ describe('IDE file templates dropdown component', () => {
({ element } = wrapper);
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('calls clickItem on click', async () => {
const itemData = { name: 'test.yml ' };
createComponent({ props: { data: [itemData] } });
diff --git a/spec/frontend/ide/components/ide_file_row_spec.js b/spec/frontend/ide/components/ide_file_row_spec.js
index aa66224fa19..331877ff112 100644
--- a/spec/frontend/ide/components/ide_file_row_spec.js
+++ b/spec/frontend/ide/components/ide_file_row_spec.js
@@ -34,11 +34,6 @@ describe('Ide File Row component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findFileRowExtra = () => wrapper.findComponent(FileRowExtra);
const findFileRow = () => wrapper.findComponent(FileRow);
const hasDropdownOpen = () => findFileRowExtra().props('dropdownOpen');
diff --git a/spec/frontend/ide/components/ide_project_header_spec.js b/spec/frontend/ide/components/ide_project_header_spec.js
index d0636352a3f..7613f407e45 100644
--- a/spec/frontend/ide/components/ide_project_header_spec.js
+++ b/spec/frontend/ide/components/ide_project_header_spec.js
@@ -20,10 +20,6 @@ describe('IDE project header', () => {
wrapper = shallowMount(IDEProjectHeader, { propsData: { project: mockProject } });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js
index 0759f957374..7ae8cfac935 100644
--- a/spec/frontend/ide/components/ide_review_spec.js
+++ b/spec/frontend/ide/components/ide_review_spec.js
@@ -30,10 +30,6 @@ describe('IDE review mode', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders list of files', () => {
expect(wrapper.text()).toContain('fileName');
});
@@ -67,7 +63,7 @@ describe('IDE review mode', () => {
await wrapper.vm.reactivate();
});
- it('updates viewer to "mrdiff"', async () => {
+ it('updates viewer to "mrdiff"', () => {
expect(store.state.viewer).toBe('mrdiff');
});
});
diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js
index 4784d6c516f..c258c5312d8 100644
--- a/spec/frontend/ide/components/ide_side_bar_spec.js
+++ b/spec/frontend/ide/components/ide_side_bar_spec.js
@@ -29,11 +29,6 @@ describe('IdeSidebar', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders a sidebar', () => {
wrapper = createComponent();
diff --git a/spec/frontend/ide/components/ide_sidebar_nav_spec.js b/spec/frontend/ide/components/ide_sidebar_nav_spec.js
index 80e8aba4072..4ee24f63f76 100644
--- a/spec/frontend/ide/components/ide_sidebar_nav_spec.js
+++ b/spec/frontend/ide/components/ide_sidebar_nav_spec.js
@@ -25,10 +25,6 @@ describe('ide/components/ide_sidebar_nav', () => {
let wrapper;
const createComponent = (props = {}) => {
- if (wrapper) {
- throw new Error('wrapper already exists');
- }
-
wrapper = shallowMount(IdeSidebarNav, {
propsData: {
tabs: TEST_TABS,
@@ -37,16 +33,11 @@ describe('ide/components/ide_sidebar_nav', () => {
...props,
},
directives: {
- tooltip: createMockDirective(),
+ tooltip: createMockDirective('tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findButtons = () => wrapper.findAll('li button');
const findButtonsData = () =>
findButtons().wrappers.map((button) => {
diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js
index a575f428a69..eb8f2a5e4ac 100644
--- a/spec/frontend/ide/components/ide_spec.js
+++ b/spec/frontend/ide/components/ide_spec.js
@@ -45,6 +45,13 @@ describe('WebIDE', () => {
const callOnBeforeUnload = (e = {}) => window.onbeforeunload(e);
+ beforeAll(() => {
+ // HACK: Workaround readonly property in Jest
+ Object.defineProperty(window, 'onbeforeunload', {
+ writable: true,
+ });
+ });
+
beforeEach(() => {
stubPerformanceWebAPI();
@@ -52,8 +59,6 @@ describe('WebIDE', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
window.onbeforeunload = null;
});
@@ -66,7 +71,7 @@ describe('WebIDE', () => {
});
});
- it('renders "New file" button in empty repo', async () => {
+ it('renders "New file" button in empty repo', () => {
expect(wrapper.find('[title="New file"]').exists()).toBe(true);
});
});
@@ -171,7 +176,7 @@ describe('WebIDE', () => {
});
});
- it('when user cannot push code, shows alert', () => {
+ it('when user cannot push code, shows an alert', () => {
store.state.links = {
forkInfo: {
ide_path: TEST_FORK_IDE_PATH,
diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js
index e6e0ebaf1e8..0ee16f98e7e 100644
--- a/spec/frontend/ide/components/ide_status_bar_spec.js
+++ b/spec/frontend/ide/components/ide_status_bar_spec.js
@@ -34,10 +34,6 @@ describe('IdeStatusBar component', () => {
wrapper = mount(IdeStatusBar, { store });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
it('triggers a setInterval', () => {
mountComponent();
diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js
index 0b54e8b6afb..344a1fbc4f6 100644
--- a/spec/frontend/ide/components/ide_status_list_spec.js
+++ b/spec/frontend/ide/components/ide_status_list_spec.js
@@ -53,10 +53,7 @@ describe('ide/components/ide_status_list', () => {
});
afterEach(() => {
- wrapper.destroy();
-
store = null;
- wrapper = null;
});
describe('with regular file', () => {
diff --git a/spec/frontend/ide/components/ide_status_mr_spec.js b/spec/frontend/ide/components/ide_status_mr_spec.js
index 0b9111c0e2a..3501ecce061 100644
--- a/spec/frontend/ide/components/ide_status_mr_spec.js
+++ b/spec/frontend/ide/components/ide_status_mr_spec.js
@@ -17,10 +17,6 @@ describe('ide/components/ide_status_mr', () => {
const findIcon = () => wrapper.findComponent(GlIcon);
const findLink = () => wrapper.findComponent(GlLink);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when mounted', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js
index 0f61aa80e53..427daa57324 100644
--- a/spec/frontend/ide/components/ide_tree_list_spec.js
+++ b/spec/frontend/ide/components/ide_tree_list_spec.js
@@ -25,10 +25,6 @@ describe('IdeTreeList component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('normal branch', () => {
const tree = [file('fileName')];
diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js
index f00017a2736..bcfa6809eca 100644
--- a/spec/frontend/ide/components/ide_tree_spec.js
+++ b/spec/frontend/ide/components/ide_tree_spec.js
@@ -1,9 +1,9 @@
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
-import { keepAlive } from 'helpers/keep_alive_component_helper';
+import { viewerTypes } from '~/ide/constants';
import IdeTree from '~/ide/components/ide_tree.vue';
-import { createStore } from '~/ide/stores';
+import { createStoreOptions } from '~/ide/stores';
import { file } from '../helpers';
import { projectData } from '../mock_data';
@@ -13,46 +13,72 @@ describe('IdeTree', () => {
let store;
let wrapper;
- beforeEach(() => {
- store = createStore();
-
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'main';
- store.state.projects.abcproject = { ...projectData };
- Vue.set(store.state.trees, 'abcproject/main', {
- tree: [file('fileName')],
- loading: false,
+ const actionSpies = {
+ updateViewer: jest.fn(),
+ };
+
+ const testState = {
+ currentProjectId: 'abcproject',
+ currentBranchId: 'main',
+ projects: {
+ abcproject: { ...projectData },
+ },
+ trees: {
+ 'abcproject/main': {
+ tree: [file('fileName')],
+ loading: false,
+ },
+ },
+ };
+
+ const createComponent = (replaceState) => {
+ const defaultStore = createStoreOptions();
+
+ store = new Vuex.Store({
+ ...defaultStore,
+ state: {
+ ...defaultStore.state,
+ ...testState,
+ replaceState,
+ },
+ actions: {
+ ...defaultStore.actions,
+ ...actionSpies,
+ },
});
- wrapper = mount(keepAlive(IdeTree), {
+ wrapper = mount(IdeTree, {
store,
});
+ };
+
+ beforeEach(() => {
+ createComponent();
});
afterEach(() => {
- wrapper.destroy();
+ actionSpies.updateViewer.mockClear();
});
- it('renders list of files', () => {
- expect(wrapper.text()).toContain('fileName');
+ describe('renders properly', () => {
+ it('renders list of files', () => {
+ expect(wrapper.text()).toContain('fileName');
+ });
});
describe('activated', () => {
- let inititializeSpy;
-
- beforeEach(async () => {
- inititializeSpy = jest.spyOn(wrapper.findComponent(IdeTree).vm, 'initialize');
- store.state.viewer = 'diff';
-
- await wrapper.vm.reactivate();
+ beforeEach(() => {
+ createComponent({
+ viewer: viewerTypes.diff,
+ });
});
it('re initializes the component', () => {
- expect(inititializeSpy).toHaveBeenCalled();
+ expect(actionSpies.updateViewer).toHaveBeenCalled();
});
it('updates viewer to "editor" by default', () => {
- expect(store.state.viewer).toBe('editor');
+ expect(actionSpies.updateViewer).toHaveBeenCalledWith(expect.any(Object), viewerTypes.edit);
});
});
});
diff --git a/spec/frontend/ide/components/jobs/detail/description_spec.js b/spec/frontend/ide/components/jobs/detail/description_spec.js
index 629c4424314..2bb0f3fccf4 100644
--- a/spec/frontend/ide/components/jobs/detail/description_spec.js
+++ b/spec/frontend/ide/components/jobs/detail/description_spec.js
@@ -14,10 +14,6 @@ describe('IDE job description', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders job details', () => {
expect(wrapper.text()).toContain('#1');
expect(wrapper.text()).toContain('test');
diff --git a/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js
index 5eb66f75978..450c6cb357c 100644
--- a/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js
+++ b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js
@@ -15,10 +15,6 @@ describe('IDE job log scroll button', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each`
direction | icon | title
${'up'} | ${'scroll_up'} | ${'Scroll to top'}
@@ -45,6 +41,6 @@ describe('IDE job log scroll button', () => {
it('disables button when disabled is true', () => {
createComponent({ disabled: true });
- expect(wrapper.find('button').attributes('disabled')).toBe('disabled');
+ expect(wrapper.find('button').attributes('disabled')).toBeDefined();
});
});
diff --git a/spec/frontend/ide/components/jobs/detail_spec.js b/spec/frontend/ide/components/jobs/detail_spec.js
index bf2be3aa595..334501bbca7 100644
--- a/spec/frontend/ide/components/jobs/detail_spec.js
+++ b/spec/frontend/ide/components/jobs/detail_spec.js
@@ -34,10 +34,6 @@ describe('IDE jobs detail view', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('mounted', () => {
const findJobOutput = () => wrapper.find('.bash');
const findBuildLoaderAnimation = () => wrapper.find('.build-loader-animation');
@@ -123,8 +119,8 @@ describe('IDE jobs detail view', () => {
await findBuildJobLog().trigger('scroll');
- expect(findScrollToBottomButton().attributes('disabled')).toBe('disabled');
- expect(findScrollToTopButton().attributes('disabled')).not.toBe('disabled');
+ expect(findScrollToBottomButton().attributes('disabled')).toBeDefined();
+ expect(findScrollToTopButton().attributes('disabled')).toBeUndefined();
});
it('keeps scroll at top when already at top', async () => {
@@ -132,8 +128,8 @@ describe('IDE jobs detail view', () => {
await findBuildJobLog().trigger('scroll');
- expect(findScrollToBottomButton().attributes('disabled')).not.toBe('disabled');
- expect(findScrollToTopButton().attributes('disabled')).toBe('disabled');
+ expect(findScrollToBottomButton().attributes('disabled')).toBeUndefined();
+ expect(findScrollToTopButton().attributes('disabled')).toBeDefined();
});
it('resets scroll when not at top or bottom', async () => {
@@ -141,8 +137,8 @@ describe('IDE jobs detail view', () => {
await findBuildJobLog().trigger('scroll');
- expect(findScrollToBottomButton().attributes('disabled')).not.toBe('disabled');
- expect(findScrollToTopButton().attributes('disabled')).not.toBe('disabled');
+ expect(findScrollToBottomButton().attributes('disabled')).toBeUndefined();
+ expect(findScrollToTopButton().attributes('disabled')).toBeUndefined();
});
});
});
diff --git a/spec/frontend/ide/components/jobs/item_spec.js b/spec/frontend/ide/components/jobs/item_spec.js
index 32e27333e42..ab442a27817 100644
--- a/spec/frontend/ide/components/jobs/item_spec.js
+++ b/spec/frontend/ide/components/jobs/item_spec.js
@@ -12,10 +12,6 @@ describe('IDE jobs item', () => {
wrapper = mount(JobItem, { propsData: { job } });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders job details', () => {
expect(wrapper.text()).toContain(job.name);
expect(wrapper.text()).toContain(`#${job.id}`);
diff --git a/spec/frontend/ide/components/jobs/list_spec.js b/spec/frontend/ide/components/jobs/list_spec.js
index b4c7eb51781..0ece42bce51 100644
--- a/spec/frontend/ide/components/jobs/list_spec.js
+++ b/spec/frontend/ide/components/jobs/list_spec.js
@@ -50,11 +50,6 @@ describe('IDE stages list', () => {
Object.values(storeActions).forEach((actionMock) => actionMock.mockClear());
});
- afterAll(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders loading icon when no stages & loading', () => {
createComponent({ loading: true, stages: [] });
diff --git a/spec/frontend/ide/components/jobs/stage_spec.js b/spec/frontend/ide/components/jobs/stage_spec.js
index 52fbff2f497..23ef92f9682 100644
--- a/spec/frontend/ide/components/jobs/stage_spec.js
+++ b/spec/frontend/ide/components/jobs/stage_spec.js
@@ -31,11 +31,6 @@ describe('IDE pipeline stage', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('emits fetch event when mounted', () => {
createComponent();
expect(wrapper.emitted().fetch).toBeDefined();
diff --git a/spec/frontend/ide/components/merge_requests/item_spec.js b/spec/frontend/ide/components/merge_requests/item_spec.js
index d6cf8127b53..2fbb6919b8b 100644
--- a/spec/frontend/ide/components/merge_requests/item_spec.js
+++ b/spec/frontend/ide/components/merge_requests/item_spec.js
@@ -39,11 +39,6 @@ describe('IDE merge request item', () => {
router = createRouter(store);
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/ide/components/merge_requests/list_spec.js b/spec/frontend/ide/components/merge_requests/list_spec.js
index ea6e2741a85..3b0e8c632fb 100644
--- a/spec/frontend/ide/components/merge_requests/list_spec.js
+++ b/spec/frontend/ide/components/merge_requests/list_spec.js
@@ -48,11 +48,6 @@ describe('IDE merge requests list', () => {
fetchMergeRequestsMock = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('calls fetch on mounted', () => {
createComponent();
expect(fetchMergeRequestsMock).toHaveBeenCalledWith(expect.any(Object), {
diff --git a/spec/frontend/ide/components/nav_dropdown_button_spec.js b/spec/frontend/ide/components/nav_dropdown_button_spec.js
index 8eebcdd9e08..3aae2c83e80 100644
--- a/spec/frontend/ide/components/nav_dropdown_button_spec.js
+++ b/spec/frontend/ide/components/nav_dropdown_button_spec.js
@@ -9,10 +9,6 @@ describe('NavDropdownButton component', () => {
const TEST_MR_ID = '12345';
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
const createComponent = ({ props = {}, state = {} } = {}) => {
const store = createStore();
store.replaceState(state);
diff --git a/spec/frontend/ide/components/nav_dropdown_spec.js b/spec/frontend/ide/components/nav_dropdown_spec.js
index 33e638843f5..794aaba6d01 100644
--- a/spec/frontend/ide/components/nav_dropdown_spec.js
+++ b/spec/frontend/ide/components/nav_dropdown_spec.js
@@ -30,10 +30,6 @@ describe('IDE NavDropdown', () => {
jest.spyOn(store, 'dispatch').mockImplementation(() => {});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const createComponent = () => {
wrapper = mount(NavDropdown, {
store,
diff --git a/spec/frontend/ide/components/new_dropdown/button_spec.js b/spec/frontend/ide/components/new_dropdown/button_spec.js
index a9cfdfd20c1..bfd5cdf7263 100644
--- a/spec/frontend/ide/components/new_dropdown/button_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/button_spec.js
@@ -14,10 +14,6 @@ describe('IDE new entry dropdown button component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders button with label', () => {
createComponent();
diff --git a/spec/frontend/ide/components/new_dropdown/index_spec.js b/spec/frontend/ide/components/new_dropdown/index_spec.js
index 747c099db33..a2371abe955 100644
--- a/spec/frontend/ide/components/new_dropdown/index_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/index_spec.js
@@ -1,37 +1,50 @@
-import { mount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import NewDropdown from '~/ide/components/new_dropdown/index.vue';
import Button from '~/ide/components/new_dropdown/button.vue';
-import { createStore } from '~/ide/stores';
+import Modal from '~/ide/components/new_dropdown/modal.vue';
+import { stubComponent } from 'helpers/stub_component';
+
+Vue.use(Vuex);
describe('new dropdown component', () => {
let wrapper;
+ const openMock = jest.fn();
+ const deleteEntryMock = jest.fn();
const findAllButtons = () => wrapper.findAllComponents(Button);
- const mountComponent = () => {
- const store = createStore();
- store.state.currentProjectId = 'abcproject';
- store.state.path = '';
- store.state.trees['abcproject/mybranch'] = { tree: [] };
+ const mountComponent = (props = {}) => {
+ const fakeStore = () => {
+ return new Vuex.Store({
+ actions: {
+ deleteEntry: deleteEntryMock,
+ },
+ });
+ };
- wrapper = mount(NewDropdown, {
- store,
+ wrapper = mountExtended(NewDropdown, {
+ store: fakeStore(),
propsData: {
branch: 'main',
path: '',
mouseOver: false,
type: 'tree',
+ ...props,
+ },
+ stubs: {
+ NewModal: stubComponent(Modal, {
+ methods: {
+ open: openMock,
+ },
+ }),
},
});
};
beforeEach(() => {
mountComponent();
- jest.spyOn(wrapper.vm.$refs.newModal, 'open').mockImplementation(() => {});
- });
-
- afterEach(() => {
- wrapper.destroy();
});
it('renders new file, upload and new directory links', () => {
@@ -42,37 +55,34 @@ describe('new dropdown component', () => {
describe('createNewItem', () => {
it('opens modal for a blob when new file is clicked', () => {
- findAllButtons().at(0).trigger('click');
+ findAllButtons().at(0).vm.$emit('click');
- expect(wrapper.vm.$refs.newModal.open).toHaveBeenCalledWith('blob', '');
+ expect(openMock).toHaveBeenCalledWith('blob', '');
});
it('opens modal for a tree when new directory is clicked', () => {
- findAllButtons().at(2).trigger('click');
+ findAllButtons().at(2).vm.$emit('click');
- expect(wrapper.vm.$refs.newModal.open).toHaveBeenCalledWith('tree', '');
+ expect(openMock).toHaveBeenCalledWith('tree', '');
});
});
describe('isOpen', () => {
it('scrolls dropdown into view', async () => {
- jest.spyOn(wrapper.vm.$refs.dropdownMenu, 'scrollIntoView').mockImplementation(() => {});
+ const dropdownMenu = wrapper.findByTestId('dropdown-menu');
+ const scrollIntoViewSpy = jest.spyOn(dropdownMenu.element, 'scrollIntoView');
await wrapper.setProps({ isOpen: true });
- expect(wrapper.vm.$refs.dropdownMenu.scrollIntoView).toHaveBeenCalledWith({
- block: 'nearest',
- });
+ expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: 'nearest' });
});
});
describe('delete entry', () => {
it('calls delete action', () => {
- jest.spyOn(wrapper.vm, 'deleteEntry').mockImplementation(() => {});
-
findAllButtons().at(4).trigger('click');
- expect(wrapper.vm.deleteEntry).toHaveBeenCalledWith('');
+ expect(deleteEntryMock).toHaveBeenCalledWith(expect.anything(), '');
});
});
});
diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js
index c6f9fd0c4ea..36c3d323e63 100644
--- a/spec/frontend/ide/components/new_dropdown/modal_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js
@@ -1,13 +1,13 @@
import { GlButton, GlModal } from '@gitlab/ui';
import { nextTick } from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Modal from '~/ide/components/new_dropdown/modal.vue';
import { createStore } from '~/ide/stores';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createEntriesFromPaths } from '../../helpers';
-jest.mock('~/flash');
+jest.mock('~/alert');
const NEW_NAME = 'babar';
@@ -79,7 +79,6 @@ describe('new file modal component', () => {
afterEach(() => {
store = null;
- wrapper.destroy();
document.body.innerHTML = '';
});
@@ -94,11 +93,11 @@ describe('new file modal component', () => {
it('renders modal', () => {
expect(findGlModal().props()).toMatchObject({
actionCancel: {
- attributes: [{ variant: 'default' }],
+ attributes: { variant: 'default' },
text: 'Cancel',
},
actionPrimary: {
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
text: 'Create file',
},
actionSecondary: null,
@@ -170,7 +169,7 @@ describe('new file modal component', () => {
expect(findGlModal().props()).toMatchObject({
title: modalTitle,
actionPrimary: {
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
text: btnTitle,
},
});
@@ -298,7 +297,7 @@ describe('new file modal component', () => {
expect(findGlModal().props()).toMatchObject({
title,
actionPrimary: {
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
text: title,
},
});
@@ -340,7 +339,7 @@ describe('new file modal component', () => {
});
});
- it('does not trigger flash', () => {
+ it('does not trigger alert', () => {
expect(createAlert).not.toHaveBeenCalled();
});
});
@@ -359,7 +358,7 @@ describe('new file modal component', () => {
});
});
- it('does not trigger flash', () => {
+ it('does not trigger alert', () => {
expect(createAlert).not.toHaveBeenCalled();
});
});
@@ -379,7 +378,7 @@ describe('new file modal component', () => {
triggerSubmitModal();
});
- it('creates flash', () => {
+ it('creates alert', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'The name "src" is already taken in this directory.',
fadeTransition: false,
@@ -404,7 +403,7 @@ describe('new file modal component', () => {
triggerSubmitModal();
});
- it('does not create flash', () => {
+ it('does not create alert', () => {
expect(createAlert).not.toHaveBeenCalled();
});
diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js
index fc643589d51..883b365c99a 100644
--- a/spec/frontend/ide/components/new_dropdown/upload_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js
@@ -1,36 +1,31 @@
import { mount } from '@vue/test-utils';
import Upload from '~/ide/components/new_dropdown/upload.vue';
+import waitForPromises from 'helpers/wait_for_promises';
describe('new dropdown upload', () => {
let wrapper;
- beforeEach(() => {
+ function createComponent() {
wrapper = mount(Upload, {
propsData: {
path: '',
},
});
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('openFile', () => {
- it('calls for each file', () => {
- const files = ['test', 'test2', 'test3'];
-
- jest.spyOn(wrapper.vm, 'readFile').mockImplementation(() => {});
- jest.spyOn(wrapper.vm.$refs.fileUpload, 'files', 'get').mockReturnValue(files);
+ }
- wrapper.vm.openFile();
+ const uploadFile = (file) => {
+ const input = wrapper.find('input[type="file"]');
+ Object.defineProperty(input.element, 'files', { value: [file] });
+ input.trigger('change', file);
+ };
- expect(wrapper.vm.readFile.mock.calls.length).toBe(3);
+ const waitForFileToLoad = async () => {
+ await waitForPromises();
+ return waitForPromises();
+ };
- files.forEach((file, i) => {
- expect(wrapper.vm.readFile.mock.calls[i]).toEqual([file]);
- });
- });
+ beforeEach(() => {
+ createComponent();
});
describe('readFile', () => {
@@ -43,20 +38,13 @@ describe('new dropdown upload', () => {
type: 'images/png',
};
- wrapper.vm.readFile(file);
+ uploadFile(file);
expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file);
});
});
describe('createFile', () => {
- const textTarget = {
- result: 'base64,cGxhaW4gdGV4dA==',
- };
- const binaryTarget = {
- result: 'base64,8PDw8A==', // ðððð
- };
-
const textFile = new File(['plain text'], 'textFile', { type: 'test/mime-text' });
const binaryFile = new File(['😺'], 'binaryFile', { type: 'test/mime-binary' });
@@ -65,15 +53,13 @@ describe('new dropdown upload', () => {
});
it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', async () => {
- const waitForCreate = new Promise((resolve) => {
- wrapper.vm.$on('create', resolve);
- });
+ uploadFile(textFile);
- wrapper.vm.createFile(textTarget, textFile);
+ // Text file has an additional load, so need to wait twice
+ await waitForFileToLoad();
+ await waitForFileToLoad();
expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile);
-
- await waitForCreate;
expect(wrapper.emitted('create')[0]).toStrictEqual([
{
name: textFile.name,
@@ -85,8 +71,10 @@ describe('new dropdown upload', () => {
]);
});
- it('creates a blob URL for the content if binary', () => {
- wrapper.vm.createFile(binaryTarget, binaryFile);
+ it('creates a blob URL for the content if binary', async () => {
+ uploadFile(binaryFile);
+
+ await waitForFileToLoad();
expect(FileReader.prototype.readAsText).not.toHaveBeenCalled();
@@ -94,7 +82,7 @@ describe('new dropdown upload', () => {
{
name: binaryFile.name,
type: 'blob',
- content: 'ðððð',
+ content: '😺', // '😺'
rawPath: 'blob:https://gitlab.com/048c7ac1-98de-4a37-ab1b-0206d0ea7e1b',
mimeType: 'test/mime-binary',
},
diff --git a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js
index e92f843ae6e..42eb5b3fc7a 100644
--- a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js
+++ b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js
@@ -35,11 +35,6 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => {
jest.spyOn(store, 'dispatch').mockImplementation();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('with a tab', () => {
let fakeView;
let extensionTabs;
diff --git a/spec/frontend/ide/components/panes/right_spec.js b/spec/frontend/ide/components/panes/right_spec.js
index 1d81c3ea89d..832983edf21 100644
--- a/spec/frontend/ide/components/panes/right_spec.js
+++ b/spec/frontend/ide/components/panes/right_spec.js
@@ -28,11 +28,6 @@ describe('ide/components/panes/right.vue', () => {
store = createStore();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/ide/components/pipelines/empty_state_spec.js b/spec/frontend/ide/components/pipelines/empty_state_spec.js
index 31081e8f9d5..71de9aecb52 100644
--- a/spec/frontend/ide/components/pipelines/empty_state_spec.js
+++ b/spec/frontend/ide/components/pipelines/empty_state_spec.js
@@ -22,10 +22,6 @@ describe('~/ide/components/pipelines/empty_state.vue', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js
index d82b97561f0..e913fa84d56 100644
--- a/spec/frontend/ide/components/pipelines/list_spec.js
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -65,11 +65,6 @@ describe('IDE pipelines list', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('fetches latest pipeline', () => {
createComponent();
diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js
index d3312358402..ead609421b7 100644
--- a/spec/frontend/ide/components/repo_commit_section_spec.js
+++ b/spec/frontend/ide/components/repo_commit_section_spec.js
@@ -63,11 +63,6 @@ describe('RepoCommitSection', () => {
jest.spyOn(router, 'push').mockImplementation();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('empty state', () => {
beforeEach(() => {
store.state.noChangesStateSvgPath = TEST_NO_CHANGES_SVG;
@@ -162,21 +157,4 @@ describe('RepoCommitSection', () => {
expect(wrapper.findComponent(EmptyState).exists()).toBe(false);
});
});
-
- describe('activated', () => {
- let inititializeSpy;
-
- beforeEach(async () => {
- createComponent();
-
- inititializeSpy = jest.spyOn(wrapper.findComponent(RepoCommitSection).vm, 'initialize');
- store.state.viewer = 'diff';
-
- await wrapper.vm.reactivate();
- });
-
- it('re initializes the component', () => {
- expect(inititializeSpy).toHaveBeenCalled();
- });
- });
});
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index c9f033bffbb..6747ec97050 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -162,8 +162,6 @@ describe('RepoEditor', () => {
// create a new model each time, otherwise tests conflict with each other
// because of same model being used in multiple tests
monacoEditor.getModels().forEach((model) => model.dispose());
- wrapper.destroy();
- wrapper = null;
});
describe('default', () => {
@@ -295,7 +293,7 @@ describe('RepoEditor', () => {
});
describe('when file changes to non-markdown file', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper.setProps({ file: dummyFile.empty });
});
@@ -603,7 +601,7 @@ describe('RepoEditor', () => {
const f = createRemoteFile('newFile');
Vue.set(vm.$store.state.entries, f.path, f);
- jest.spyOn(service, 'getRawFileData').mockImplementation(async () => {
+ jest.spyOn(service, 'getRawFileData').mockImplementation(() => {
expect(vm.file.loading).toBe(true);
// switching from edit to diff mode usually triggers editor initialization
@@ -611,7 +609,7 @@ describe('RepoEditor', () => {
jest.runOnlyPendingTimers();
- return 'rawFileData123\n';
+ return Promise.resolve('rawFileData123\n');
});
wrapper.setProps({
@@ -632,18 +630,18 @@ describe('RepoEditor', () => {
jest
.spyOn(service, 'getRawFileData')
- .mockImplementation(async () => {
+ .mockImplementation(() => {
// opening fileB while the content of fileA is still being fetched
wrapper.setProps({
file: fileB,
});
- return aContent;
+ return Promise.resolve(aContent);
})
- .mockImplementationOnce(async () => {
+ .mockImplementationOnce(() => {
// we delay returning fileB content
// to make sure the editor doesn't initialize prematurely
jest.advanceTimersByTime(30);
- return bContent;
+ return Promise.resolve(bContent);
});
wrapper.setProps({
diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js
index b26edc5a85b..d4f29b16a88 100644
--- a/spec/frontend/ide/components/repo_tab_spec.js
+++ b/spec/frontend/ide/components/repo_tab_spec.js
@@ -1,11 +1,10 @@
import { GlTab } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { stubComponent } from 'helpers/stub_component';
import RepoTab from '~/ide/components/repo_tab.vue';
-import { createRouter } from '~/ide/ide_router';
import { createStore } from '~/ide/stores';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { file } from '../helpers';
Vue.use(Vuex);
@@ -17,41 +16,40 @@ const GlTabStub = stubComponent(GlTab, {
describe('RepoTab', () => {
let wrapper;
let store;
- let router;
+ const pushMock = jest.fn();
const findTab = () => wrapper.findComponent(GlTabStub);
+ const findCloseButton = () => wrapper.findByTestId('close-button');
function createComponent(propsData) {
- wrapper = mount(RepoTab, {
+ wrapper = mountExtended(RepoTab, {
store,
propsData,
stubs: {
GlTab: GlTabStub,
},
+ mocks: {
+ $router: {
+ push: pushMock,
+ },
+ },
});
}
beforeEach(() => {
store = createStore();
- router = createRouter(store);
- jest.spyOn(router, 'push').mockImplementation(() => {});
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
});
it('renders a close link and a name link', () => {
+ const tab = file();
createComponent({
- tab: file(),
+ tab,
});
- wrapper.vm.$store.state.openFiles.push(wrapper.vm.tab);
- const close = wrapper.find('.multi-file-tab-close');
+ store.state.openFiles.push(tab);
const name = wrapper.find(`[title]`);
- expect(close.html()).toContain('#close');
- expect(name.text().trim()).toEqual(wrapper.vm.tab.name);
+ expect(findCloseButton().html()).toContain('#close');
+ expect(name.text()).toBe(tab.name);
});
it('does not call openPendingTab when tab is active', async () => {
@@ -63,35 +61,33 @@ describe('RepoTab', () => {
},
});
- jest.spyOn(wrapper.vm, 'openPendingTab').mockImplementation(() => {});
+ jest.spyOn(store, 'dispatch');
await findTab().vm.$emit('click');
- expect(wrapper.vm.openPendingTab).not.toHaveBeenCalled();
+ expect(store.dispatch).not.toHaveBeenCalledWith('openPendingTab');
});
- it('fires clickFile when the link is clicked', () => {
- createComponent({
- tab: file(),
- });
-
- jest.spyOn(wrapper.vm, 'clickFile').mockImplementation(() => {});
+ it('fires clickFile when the link is clicked', async () => {
+ const { getters } = store;
+ const tab = file();
+ createComponent({ tab });
- findTab().vm.$emit('click');
+ await findTab().vm.$emit('click', tab);
- expect(wrapper.vm.clickFile).toHaveBeenCalledWith(wrapper.vm.tab);
+ expect(pushMock).toHaveBeenCalledWith(getters.getUrlForPath(tab.path));
});
- it('calls closeFile when clicking close button', () => {
- createComponent({
- tab: file(),
- });
+ it('calls closeFile when clicking close button', async () => {
+ const tab = file();
+ createComponent({ tab });
+ store.state.entries[tab.path] = tab;
- jest.spyOn(wrapper.vm, 'closeFile').mockImplementation(() => {});
+ jest.spyOn(store, 'dispatch');
- wrapper.find('.multi-file-tab-close').trigger('click');
+ await findCloseButton().trigger('click');
- expect(wrapper.vm.closeFile).toHaveBeenCalledWith(wrapper.vm.tab);
+ expect(store.dispatch).toHaveBeenCalledWith('closeFile', tab);
});
it('changes icon on hover', async () => {
@@ -119,7 +115,7 @@ describe('RepoTab', () => {
createComponent({ tab });
- expect(wrapper.find('button').attributes('aria-label')).toBe(closeLabel);
+ expect(findCloseButton().attributes('aria-label')).toBe(closeLabel);
});
describe('locked file', () => {
@@ -157,15 +153,15 @@ describe('RepoTab', () => {
createComponent({
tab,
});
- wrapper.vm.$store.state.openFiles.push(tab);
- wrapper.vm.$store.state.changedFiles.push(tab);
- wrapper.vm.$store.state.entries[tab.path] = tab;
- wrapper.vm.$store.dispatch('setFileActive', tab.path);
+ store.state.openFiles.push(tab);
+ store.state.changedFiles.push(tab);
+ store.state.entries[tab.path] = tab;
+ store.dispatch('setFileActive', tab.path);
- await wrapper.find('.multi-file-tab-close').trigger('click');
+ await findCloseButton().trigger('click');
expect(tab.opened).toBe(false);
- expect(wrapper.vm.$store.state.changedFiles).toHaveLength(1);
+ expect(store.state.changedFiles).toHaveLength(1);
});
it('closes tab when clicking close btn', async () => {
@@ -174,11 +170,11 @@ describe('RepoTab', () => {
createComponent({
tab,
});
- wrapper.vm.$store.state.openFiles.push(tab);
- wrapper.vm.$store.state.entries[tab.path] = tab;
- wrapper.vm.$store.dispatch('setFileActive', tab.path);
+ store.state.openFiles.push(tab);
+ store.state.entries[tab.path] = tab;
+ store.dispatch('setFileActive', tab.path);
- await wrapper.find('.multi-file-tab-close').trigger('click');
+ await findCloseButton().trigger('click');
expect(tab.opened).toBe(false);
});
diff --git a/spec/frontend/ide/components/repo_tabs_spec.js b/spec/frontend/ide/components/repo_tabs_spec.js
index 1cfc1f12745..06ad162d398 100644
--- a/spec/frontend/ide/components/repo_tabs_spec.js
+++ b/spec/frontend/ide/components/repo_tabs_spec.js
@@ -25,10 +25,6 @@ describe('RepoTabs', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a list of tabs', async () => {
store.state.openFiles[0].active = true;
diff --git a/spec/frontend/ide/components/resizable_panel_spec.js b/spec/frontend/ide/components/resizable_panel_spec.js
index fe2a128c9c8..240e675a38e 100644
--- a/spec/frontend/ide/components/resizable_panel_spec.js
+++ b/spec/frontend/ide/components/resizable_panel_spec.js
@@ -19,11 +19,6 @@ describe('~/ide/components/resizable_panel', () => {
jest.spyOn(store, 'dispatch').mockImplementation();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const createComponent = (props = {}) => {
wrapper = shallowMount(ResizablePanel, {
propsData: {
diff --git a/spec/frontend/ide/components/shared/commit_message_field_spec.js b/spec/frontend/ide/components/shared/commit_message_field_spec.js
index 94da06f4cb2..ccf544b27b7 100644
--- a/spec/frontend/ide/components/shared/commit_message_field_spec.js
+++ b/spec/frontend/ide/components/shared/commit_message_field_spec.js
@@ -23,10 +23,6 @@ describe('CommitMessageField', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findTextArea = () => wrapper.find('textarea');
const findHighlights = () => wrapper.findByTestId('highlights');
const findHighlightsText = () => wrapper.findByTestId('highlights-text');
@@ -54,7 +50,7 @@ describe('CommitMessageField', () => {
await nextTick();
});
- it('is added on textarea focus', async () => {
+ it('is added on textarea focus', () => {
expect(wrapper.attributes('class')).toEqual(
expect.stringContaining('gl-outline-none! gl-focus-ring-border-1-gray-900!'),
);
diff --git a/spec/frontend/ide/components/shared/tokened_input_spec.js b/spec/frontend/ide/components/shared/tokened_input_spec.js
index b70c9659e46..4bd5a6527e2 100644
--- a/spec/frontend/ide/components/shared/tokened_input_spec.js
+++ b/spec/frontend/ide/components/shared/tokened_input_spec.js
@@ -28,10 +28,6 @@ describe('IDE shared/TokenedInput', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders tokens', () => {
createComponent();
const renderedTokens = getTokenElements(wrapper).wrappers.map((w) => w.text());
diff --git a/spec/frontend/ide/components/terminal/empty_state_spec.js b/spec/frontend/ide/components/terminal/empty_state_spec.js
index 15fb0fe9013..3a691c151d5 100644
--- a/spec/frontend/ide/components/terminal/empty_state_spec.js
+++ b/spec/frontend/ide/components/terminal/empty_state_spec.js
@@ -16,10 +16,6 @@ describe('IDE TerminalEmptyState', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('does not show illustration, if no path specified', () => {
factory();
diff --git a/spec/frontend/ide/components/terminal/terminal_spec.js b/spec/frontend/ide/components/terminal/terminal_spec.js
index 0d22f7f73fe..0500c116d23 100644
--- a/spec/frontend/ide/components/terminal/terminal_spec.js
+++ b/spec/frontend/ide/components/terminal/terminal_spec.js
@@ -59,10 +59,6 @@ describe('IDE Terminal', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('loading text', () => {
[STARTING, PENDING].forEach((status) => {
it(`shows when starting (${status})`, () => {
diff --git a/spec/frontend/ide/components/terminal/view_spec.js b/spec/frontend/ide/components/terminal/view_spec.js
index 57c8da9f5b7..b8ffaa89047 100644
--- a/spec/frontend/ide/components/terminal/view_spec.js
+++ b/spec/frontend/ide/components/terminal/view_spec.js
@@ -59,10 +59,6 @@ describe('IDE TerminalView', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders empty state', async () => {
await factory();
diff --git a/spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js
index 5b1502cc190..e420e28c7b6 100644
--- a/spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js
+++ b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js
@@ -22,10 +22,6 @@ describe('ide/components/terminal_sync/terminal_sync_status_safe', () => {
beforeEach(createComponent);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with terminal sync module in store', () => {
beforeEach(() => {
store.registerModule('terminalSync', {
diff --git a/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js
index 147235abc8e..4541c3b5ec8 100644
--- a/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js
+++ b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js
@@ -48,10 +48,6 @@ describe('ide/components/terminal_sync/terminal_sync_status', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when doing nothing', () => {
it('shows nothing', () => {
createComponent();
diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js
index bfc87f17092..f8af8459025 100644
--- a/spec/frontend/ide/init_gitlab_web_ide_spec.js
+++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js
@@ -20,12 +20,12 @@ const ROOT_ELEMENT_ID = 'ide';
const TEST_NONCE = 'test123nonce';
const TEST_PROJECT_PATH = 'group1/project1';
const TEST_BRANCH_NAME = '12345-foo-patch';
-const TEST_GITLAB_URL = 'https://test-gitlab/';
const TEST_USER_PREFERENCES_PATH = '/user/preferences';
const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/webpack/assets/gitlab-web-ide/public/path';
const TEST_FILE_PATH = 'foo/README.md';
const TEST_MR_ID = '7';
const TEST_MR_TARGET_PROJECT = 'gitlab-org/the-real-gitlab';
+const TEST_SIGN_IN_PATH = 'sign-in';
const TEST_FORK_INFO = { fork_path: '/forky' };
const TEST_IDE_REMOTE_PATH = '/-/ide/remote/:remote_host/:remote_path';
const TEST_START_REMOTE_PARAMS = {
@@ -56,6 +56,7 @@ describe('ide/init_gitlab_web_ide', () => {
el.dataset.editorFontSrcUrl = TEST_EDITOR_FONT_SRC_URL;
el.dataset.editorFontFormat = TEST_EDITOR_FONT_FORMAT;
el.dataset.editorFontFamily = TEST_EDITOR_FONT_FAMILY;
+ el.dataset.signInPath = TEST_SIGN_IN_PATH;
document.body.append(el);
};
@@ -69,7 +70,6 @@ describe('ide/init_gitlab_web_ide', () => {
beforeEach(() => {
process.env.GITLAB_WEB_IDE_PUBLIC_PATH = TEST_GITLAB_WEB_IDE_PUBLIC_PATH;
- window.gon.gitlab_url = TEST_GITLAB_URL;
confirmAction.mockImplementation(
() =>
@@ -100,7 +100,7 @@ describe('ide/init_gitlab_web_ide', () => {
mrId: TEST_MR_ID,
mrTargetProject: '',
forkInfo: null,
- gitlabUrl: TEST_GITLAB_URL,
+ gitlabUrl: TEST_HOST,
nonce: TEST_NONCE,
httpHeaders: {
'mock-csrf-header': 'mock-csrf-token',
@@ -109,6 +109,7 @@ describe('ide/init_gitlab_web_ide', () => {
links: {
userPreferences: TEST_USER_PREFERENCES_PATH,
feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE,
+ signIn: TEST_SIGN_IN_PATH,
},
editorFont: {
srcUrl: TEST_EDITOR_FONT_SRC_URL,
diff --git a/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js b/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js
index ed67a0948e4..3c42f54a1f7 100644
--- a/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js
+++ b/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js
@@ -2,14 +2,12 @@ import { getBaseConfig } from '~/ide/lib/gitlab_web_ide/get_base_config';
import { TEST_HOST } from 'helpers/test_constants';
const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/gitlab-web-ide/public/path';
-const TEST_GITLAB_URL = 'https://gdk.test/';
const TEST_RELATIVE_URL_ROOT = '/gl_rel_root';
describe('~/ide/lib/gitlab_web_ide/get_base_config', () => {
beforeEach(() => {
// why: add trailing "/" to test that it gets removed
process.env.GITLAB_WEB_IDE_PUBLIC_PATH = `${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}/`;
- window.gon.gitlab_url = TEST_GITLAB_URL;
window.gon.relative_url_root = '';
});
@@ -18,7 +16,7 @@ describe('~/ide/lib/gitlab_web_ide/get_base_config', () => {
expect(actual).toEqual({
baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`,
- gitlabUrl: TEST_GITLAB_URL,
+ gitlabUrl: TEST_HOST,
});
});
@@ -27,8 +25,9 @@ describe('~/ide/lib/gitlab_web_ide/get_base_config', () => {
const actual = getBaseConfig();
- expect(actual).toMatchObject({
+ expect(actual).toEqual({
baseUrl: `${TEST_HOST}${TEST_RELATIVE_URL_ROOT}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`,
+ gitlabUrl: `${TEST_HOST}${TEST_RELATIVE_URL_ROOT}`,
});
});
});
diff --git a/spec/frontend/ide/lib/languages/codeowners_spec.js b/spec/frontend/ide/lib/languages/codeowners_spec.js
new file mode 100644
index 00000000000..aa0ab123c4b
--- /dev/null
+++ b/spec/frontend/ide/lib/languages/codeowners_spec.js
@@ -0,0 +1,85 @@
+import { editor } from 'monaco-editor';
+import codeowners from '~/ide/lib/languages/codeowners';
+import { registerLanguages } from '~/ide/utils';
+
+describe('tokenization for CODEOWNERS files', () => {
+ beforeEach(() => {
+ registerLanguages(codeowners);
+ });
+
+ it.each([
+ ['## Foo bar comment', [[{ language: 'codeowners', offset: 0, type: 'comment.codeowners' }]]],
+ [
+ '/foo/bar @gsamsa',
+ [
+ [
+ { language: 'codeowners', offset: 0, type: 'regexp.codeowners' },
+ { language: 'codeowners', offset: 8, type: 'source.codeowners' },
+ { language: 'codeowners', offset: 9, type: 'variable.value.codeowners' },
+ ],
+ ],
+ ],
+ [
+ '^[Section name]',
+ [
+ [
+ { language: 'codeowners', offset: 0, type: 'constant.numeric.codeowners' },
+ { language: 'codeowners', offset: 1, type: 'namespace.codeowners' },
+ ],
+ ],
+ ],
+ [
+ '[Section name][3]',
+ [
+ [
+ { language: 'codeowners', offset: 0, type: 'namespace.codeowners' },
+ { language: 'codeowners', offset: 14, type: 'constant.numeric.codeowners' },
+ ],
+ ],
+ ],
+ [
+ '[Section name][30]',
+ [
+ [
+ { language: 'codeowners', offset: 0, type: 'namespace.codeowners' },
+ { language: 'codeowners', offset: 14, type: 'constant.numeric.codeowners' },
+ ],
+ ],
+ ],
+ [
+ '^[Section name][3]',
+ [
+ [
+ { language: 'codeowners', offset: 0, type: 'constant.numeric.codeowners' },
+ { language: 'codeowners', offset: 1, type: 'namespace.codeowners' },
+ { language: 'codeowners', offset: 15, type: 'constant.numeric.codeowners' },
+ ],
+ ],
+ ],
+ [
+ '^[Section-name-test][3]',
+ [
+ [
+ { language: 'codeowners', offset: 0, type: 'constant.numeric.codeowners' },
+ { language: 'codeowners', offset: 1, type: 'namespace.codeowners' },
+ { language: 'codeowners', offset: 20, type: 'constant.numeric.codeowners' },
+ ],
+ ],
+ ],
+ [
+ '[Section-name_test]',
+ [[{ language: 'codeowners', offset: 0, type: 'namespace.codeowners' }]],
+ ],
+ [
+ '[2 Be or not 2 be][3]',
+ [
+ [
+ { language: 'codeowners', offset: 0, type: 'namespace.codeowners' },
+ { language: 'codeowners', offset: 18, type: 'constant.numeric.codeowners' },
+ ],
+ ],
+ ],
+ ])('%s', (string, tokens) => {
+ expect(editor.tokenize(string, 'codeowners')).toEqual(tokens);
+ });
+});
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index 623dee387e5..cd099e60070 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -252,12 +252,10 @@ describe('IDE services', () => {
describe('pingUsage', () => {
let mock;
- let relativeUrlRoot;
const TEST_RELATIVE_URL_ROOT = 'blah-blah';
beforeEach(() => {
jest.spyOn(axios, 'post');
- relativeUrlRoot = gon.relative_url_root;
gon.relative_url_root = TEST_RELATIVE_URL_ROOT;
mock = new MockAdapter(axios);
@@ -265,7 +263,6 @@ describe('IDE services', () => {
afterEach(() => {
mock.restore();
- gon.relative_url_root = relativeUrlRoot;
});
it('posts to usage endpoint', () => {
diff --git a/spec/frontend/ide/services/terminals_spec.js b/spec/frontend/ide/services/terminals_spec.js
index 5f752197e13..5b6b60a250c 100644
--- a/spec/frontend/ide/services/terminals_spec.js
+++ b/spec/frontend/ide/services/terminals_spec.js
@@ -9,7 +9,6 @@ const TEST_BRANCH = 'ref';
describe('~/ide/services/terminals', () => {
let axiosSpy;
let mock;
- const prevRelativeUrlRoot = gon.relative_url_root;
beforeEach(() => {
axiosSpy = jest.fn().mockReturnValue([HTTP_STATUS_OK, {}]);
@@ -19,7 +18,6 @@ describe('~/ide/services/terminals', () => {
});
afterEach(() => {
- gon.relative_url_root = prevRelativeUrlRoot;
mock.restore();
});
diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js
index 90ca8526698..7f4e1cf761d 100644
--- a/spec/frontend/ide/stores/actions/file_spec.js
+++ b/spec/frontend/ide/stores/actions/file_spec.js
@@ -16,7 +16,6 @@ const RELATIVE_URL_ROOT = '/gitlab';
describe('IDE store file actions', () => {
let mock;
- let originalGon;
let store;
let router;
@@ -24,9 +23,7 @@ describe('IDE store file actions', () => {
stubPerformanceWebAPI();
mock = new MockAdapter(axios);
- originalGon = window.gon;
window.gon = {
- ...window.gon,
relative_url_root: RELATIVE_URL_ROOT,
};
@@ -44,7 +41,6 @@ describe('IDE store file actions', () => {
afterEach(() => {
mock.restore();
- window.gon = originalGon;
});
describe('closeFile', () => {
diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js
index fbae84631ee..a41ffdb0a31 100644
--- a/spec/frontend/ide/stores/actions/merge_request_spec.js
+++ b/spec/frontend/ide/stores/actions/merge_request_spec.js
@@ -3,7 +3,7 @@ import { range } from 'lodash';
import { stubPerformanceWebAPI } from 'helpers/performance';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '~/ide/constants';
import service from '~/ide/services';
import { createStore } from '~/ide/stores';
@@ -30,7 +30,7 @@ const createMergeRequestChangesCount = (n) =>
const testGetUrlForPath = (path) => `${TEST_HOST}/test/${path}`;
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('IDE store merge request actions', () => {
let store;
@@ -135,7 +135,7 @@ describe('IDE store merge request actions', () => {
mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).networkError();
});
- it('flashes message, if error', () => {
+ it('shows an alert, if error', () => {
return store
.dispatch('getMergeRequestsForBranch', {
projectId: TEST_PROJECT,
@@ -519,7 +519,7 @@ describe('IDE store merge request actions', () => {
);
});
- it('flashes message, if error', () => {
+ it('shows an alert, if error', () => {
store.dispatch.mockRejectedValue();
return openMergeRequest(store, mr).catch(() => {
diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js
index 5a5ead4c544..b13228c20f5 100644
--- a/spec/frontend/ide/stores/actions/project_spec.js
+++ b/spec/frontend/ide/stores/actions/project_spec.js
@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import testAction from 'helpers/vuex_action_helper';
import api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import service from '~/ide/services';
import { createStore } from '~/ide/stores';
import {
@@ -19,7 +19,7 @@ import {
import { logError } from '~/lib/logger';
import axios from '~/lib/utils/axios_utils';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/logger');
const TEST_PROJECT_ID = 'abc/def';
@@ -104,7 +104,7 @@ describe('IDE store project actions', () => {
desc | projectPath | responseSuccess | expectedMutations
${'does not fetch permissions if project does not exist'} | ${undefined} | ${true} | ${[]}
${'fetches permission when project is specified'} | ${TEST_PROJECT_ID} | ${true} | ${[...permissionsMutations]}
- ${'flashes an error if the request fails'} | ${TEST_PROJECT_ID} | ${false} | ${[]}
+ ${'alerts an error if the request fails'} | ${TEST_PROJECT_ID} | ${false} | ${[]}
`('$desc', async ({ projectPath, expectedMutations, responseSuccess } = {}) => {
store.state.currentProjectId = projectPath;
if (responseSuccess) {
diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js
index 1c90c0f943a..f6925e78b6a 100644
--- a/spec/frontend/ide/stores/actions_spec.js
+++ b/spec/frontend/ide/stores/actions_spec.js
@@ -4,7 +4,7 @@ import testAction from 'helpers/vuex_action_helper';
import eventHub from '~/ide/eventhub';
import { createRouter } from '~/ide/ide_router';
import { createStore } from '~/ide/stores';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
init,
stageAllChanges,
@@ -31,7 +31,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
}));
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Multi-file store actions', () => {
let store;
@@ -210,7 +210,7 @@ describe('Multi-file store actions', () => {
expect(store.dispatch).toHaveBeenCalledWith('setFileActive', 'test');
});
- it('creates flash message if file already exists', async () => {
+ it('creates alert if file already exists', async () => {
const f = file('test', '1', 'blob');
store.state.trees['abcproject/mybranch'].tree = [f];
store.state.entries[f.path] = f;
@@ -440,7 +440,7 @@ describe('Multi-file store actions', () => {
});
describe('setErrorMessage', () => {
- it('commis error messsage', () => {
+ it('commis error message', () => {
return testAction(
setErrorMessage,
'error',
@@ -927,7 +927,7 @@ describe('Multi-file store actions', () => {
expect(document.querySelector('.flash-alert')).toBeNull();
});
- it('does not pass the error further and flashes an alert if error is not 404', async () => {
+ it('does not pass the error further and creates an alert if error is not 404', async () => {
mock.onGet(/(.*)/).replyOnce(HTTP_STATUS_IM_A_TEAPOT);
await expect(getBranchData(...callParams)).rejects.toEqual(
diff --git a/spec/frontend/ide/stores/extend_spec.js b/spec/frontend/ide/stores/extend_spec.js
index ffb00f9ef5b..88909999c82 100644
--- a/spec/frontend/ide/stores/extend_spec.js
+++ b/spec/frontend/ide/stores/extend_spec.js
@@ -6,12 +6,10 @@ jest.mock('~/ide/stores/plugins/terminal', () => jest.fn());
jest.mock('~/ide/stores/plugins/terminal_sync', () => jest.fn());
describe('ide/stores/extend', () => {
- let prevGon;
let store;
let el;
beforeEach(() => {
- prevGon = global.gon;
store = {};
el = {};
@@ -23,13 +21,12 @@ describe('ide/stores/extend', () => {
});
afterEach(() => {
- global.gon = prevGon;
terminalPlugin.mockClear();
terminalSyncPlugin.mockClear();
});
const withGonFeatures = (features) => {
- global.gon = { ...global.gon, features };
+ global.gon.features = features;
};
describe('terminalPlugin', () => {
diff --git a/spec/frontend/ide/stores/getters_spec.js b/spec/frontend/ide/stores/getters_spec.js
index d4166a3bd6d..0fe6a16c676 100644
--- a/spec/frontend/ide/stores/getters_spec.js
+++ b/spec/frontend/ide/stores/getters_spec.js
@@ -24,11 +24,8 @@ const TEST_FORK_PATH = '/test/fork/path';
describe('IDE store getters', () => {
let localState;
let localStore;
- let origGon;
beforeEach(() => {
- origGon = window.gon;
-
// Feature flag is defaulted to on in prod
window.gon = { features: { rejectUnsignedCommitsByGitlab: true } };
@@ -36,10 +33,6 @@ describe('IDE store getters', () => {
localState = localStore.state;
});
- afterEach(() => {
- window.gon = origGon;
- });
-
describe('activeFile', () => {
it('returns the current active file', () => {
localState.openFiles.push(file());
diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js
index 4068a9d0919..3eaff92d321 100644
--- a/spec/frontend/ide/stores/modules/commit/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js
@@ -53,7 +53,6 @@ describe('IDE commit module actions', () => {
});
afterEach(() => {
- delete gon.api_version;
mock.restore();
});
@@ -81,19 +80,12 @@ describe('IDE commit module actions', () => {
});
describe('updateBranchName', () => {
- let originalGon;
-
beforeEach(() => {
- originalGon = window.gon;
- window.gon = { current_username: 'johndoe' };
+ window.gon.current_username = 'johndoe';
store.state.currentBranchId = 'main';
});
- afterEach(() => {
- window.gon = originalGon;
- });
-
it('updates store with new branch name', async () => {
await store.dispatch('commit/updateBranchName', 'branch-name');
diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js
index 0287e5269ee..3f7ded5e718 100644
--- a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js
+++ b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js
@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import * as actions from '~/ide/stores/modules/terminal/actions/session_controls';
import { STARTING, PENDING, STOPPING, STOPPED } from '~/ide/stores/modules/terminal/constants';
import * as messages from '~/ide/stores/modules/terminal/messages';
@@ -13,7 +13,7 @@ import {
HTTP_STATUS_UNPROCESSABLE_ENTITY,
} from '~/lib/utils/http_status';
-jest.mock('~/flash');
+jest.mock('~/alert');
const TEST_PROJECT_PATH = 'lorem/root';
const TEST_BRANCH_ID = 'main';
@@ -91,7 +91,7 @@ describe('IDE store terminal session controls actions', () => {
});
describe('receiveStartSessionError', () => {
- it('flashes message', () => {
+ it('shows an alert', () => {
actions.receiveStartSessionError({ dispatch });
expect(createAlert).toHaveBeenCalledWith({
@@ -165,7 +165,7 @@ describe('IDE store terminal session controls actions', () => {
});
describe('receiveStopSessionError', () => {
- it('flashes message', () => {
+ it('shows an alert', () => {
actions.receiveStopSessionError({ dispatch });
expect(createAlert).toHaveBeenCalledWith({
diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js
index 9616733f052..30ae7d203a9 100644
--- a/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js
+++ b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js
@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import * as actions from '~/ide/stores/modules/terminal/actions/session_status';
import { PENDING, RUNNING, STOPPING, STOPPED } from '~/ide/stores/modules/terminal/constants';
import * as messages from '~/ide/stores/modules/terminal/messages';
@@ -8,7 +8,7 @@ import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-jest.mock('~/flash');
+jest.mock('~/alert');
const TEST_SESSION = {
id: 7,
@@ -113,7 +113,7 @@ describe('IDE store terminal session controls actions', () => {
});
describe('receiveSessionStatusError', () => {
- it('flashes message', () => {
+ it('shows an alert', () => {
actions.receiveSessionStatusError({ dispatch });
expect(createAlert).toHaveBeenCalledWith({
diff --git a/spec/frontend/import/details/components/import_details_app_spec.js b/spec/frontend/import/details/components/import_details_app_spec.js
new file mode 100644
index 00000000000..6e748a57a1d
--- /dev/null
+++ b/spec/frontend/import/details/components/import_details_app_spec.js
@@ -0,0 +1,18 @@
+import { shallowMount } from '@vue/test-utils';
+import ImportDetailsApp from '~/import/details/components/import_details_app.vue';
+
+describe('Import details app', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(ImportDetailsApp);
+ };
+
+ describe('template', () => {
+ it('renders heading', () => {
+ createComponent();
+
+ expect(wrapper.find('h1').text()).toBe(ImportDetailsApp.i18n.pageTitle);
+ });
+ });
+});
diff --git a/spec/frontend/import/details/components/import_details_table_spec.js b/spec/frontend/import/details/components/import_details_table_spec.js
new file mode 100644
index 00000000000..aee8573eb02
--- /dev/null
+++ b/spec/frontend/import/details/components/import_details_table_spec.js
@@ -0,0 +1,113 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_OK, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
+import { createAlert } from '~/alert';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
+import ImportDetailsTable from '~/import/details/components/import_details_table.vue';
+import { mockImportFailures, mockHeaders } from '../mock_data';
+
+jest.mock('~/alert');
+
+describe('Import details table', () => {
+ let wrapper;
+ let mock;
+
+ const createComponent = ({ mountFn = shallowMount, provide = {} } = {}) => {
+ wrapper = mountFn(ImportDetailsTable, {
+ provide,
+ });
+ };
+
+ const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findGlTable = () => wrapper.findComponent(GlTable);
+ const findGlTableRows = () => findGlTable().find('tbody').findAll('tr');
+ const findGlEmptyState = () => findGlTable().findComponent(GlEmptyState);
+ const findPaginationBar = () => wrapper.findComponent(PaginationBar);
+
+ describe('template', () => {
+ describe('when no items are available', () => {
+ it('renders table with empty state', () => {
+ createComponent({ mountFn: mount });
+
+ expect(findGlEmptyState().text()).toBe(ImportDetailsTable.i18n.emptyText);
+ });
+
+ it('does not render pagination', () => {
+ createComponent();
+
+ expect(findPaginationBar().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('fetching failures from API', () => {
+ const mockImportFailuresPath = '/failures';
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('when request is successful', () => {
+ beforeEach(() => {
+ mock.onGet(mockImportFailuresPath).reply(HTTP_STATUS_OK, mockImportFailures, mockHeaders);
+
+ createComponent({
+ mountFn: mount,
+ provide: {
+ failuresPath: mockImportFailuresPath,
+ },
+ });
+ });
+
+ it('renders loading icon', () => {
+ expect(findGlLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not render loading icon after fetch', async () => {
+ await waitForPromises();
+
+ expect(findGlLoadingIcon().exists()).toBe(false);
+ });
+
+ it('sets items and pagination info', async () => {
+ await waitForPromises();
+
+ expect(findGlTableRows().length).toBe(mockImportFailures.length);
+ expect(findPaginationBar().props('pageInfo')).toMatchObject({
+ page: mockHeaders['x-page'],
+ perPage: mockHeaders['x-per-page'],
+ total: mockHeaders['x-total'],
+ totalPages: mockHeaders['x-total-pages'],
+ });
+ });
+ });
+
+ describe('when request fails', () => {
+ beforeEach(() => {
+ mock.onGet(mockImportFailuresPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ createComponent({
+ provide: {
+ failuresPath: mockImportFailuresPath,
+ },
+ });
+ });
+
+ it('displays an error', async () => {
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: ImportDetailsTable.i18n.fetchErrorMessage,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/import/details/mock_data.js b/spec/frontend/import/details/mock_data.js
new file mode 100644
index 00000000000..67148173404
--- /dev/null
+++ b/spec/frontend/import/details/mock_data.js
@@ -0,0 +1,53 @@
+export const mockImportFailures = [
+ {
+ type: 'pull_request',
+ title: 'Add one cool feature',
+ provider_url: 'https://github.com/USER/REPO/pull/2',
+ details: {
+ exception_class: 'ActiveRecord::RecordInvalid',
+ exception_message: 'Record invalid',
+ source: 'Gitlab::GithubImport::Importer::PullRequestImporter',
+ github_identifiers: {
+ iid: 2,
+ issuable_type: 'MergeRequest',
+ object_type: 'pull_request',
+ },
+ },
+ },
+ {
+ type: 'pull_request',
+ title: 'Add another awesome feature',
+ provider_url: 'https://github.com/USER/REPO/pull/3',
+ details: {
+ exception_class: 'ActiveRecord::RecordInvalid',
+ exception_message: 'Record invalid',
+ source: 'Gitlab::GithubImport::Importer::PullRequestImporter',
+ github_identifiers: {
+ iid: 3,
+ issuable_type: 'MergeRequest',
+ object_type: 'pull_request',
+ },
+ },
+ },
+ {
+ type: 'lfs_object',
+ title: '3a9257fae9e86faee27d7208cb55e086f18e6f29f48c430bfbc26d42eb',
+ provider_url: null,
+ details: {
+ exception_class: 'NameError',
+ exception_message: 'some message',
+ source: 'Gitlab::GithubImport::Importer::LfsObjectImporter',
+ github_identifiers: {
+ oid: '3a9257fae9e86faee27d7208cb55e086f18e6f29f48c430bfbc26d42eb',
+ size: 2473979,
+ },
+ },
+ },
+];
+
+export const mockHeaders = {
+ 'x-page': 1,
+ 'x-per-page': 20,
+ 'x-total': 3,
+ 'x-total-pages': 1,
+};
diff --git a/spec/frontend/import_entities/components/group_dropdown_spec.js b/spec/frontend/import_entities/components/group_dropdown_spec.js
index 31e097cfa7b..14f39a35387 100644
--- a/spec/frontend/import_entities/components/group_dropdown_spec.js
+++ b/spec/frontend/import_entities/components/group_dropdown_spec.js
@@ -6,7 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import GroupDropdown from '~/import_entities/components/group_dropdown.vue';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
-import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql';
Vue.use(VueApollo);
@@ -49,7 +49,7 @@ describe('Import entities group dropdown component', () => {
const createComponent = (propsData) => {
const apolloProvider = createMockApollo([
- [searchNamespacesWhereUserCanCreateProjectsQuery, () => SEARCH_NAMESPACES_MOCK],
+ [searchNamespacesWhereUserCanImportProjectsQuery, () => SEARCH_NAMESPACES_MOCK],
]);
namespacesTracker = jest.fn();
@@ -64,10 +64,6 @@ describe('Import entities group dropdown component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('passes namespaces from graphql query to default slot', async () => {
createComponent();
jest.advanceTimersByTime(DEBOUNCE_DELAY);
diff --git a/spec/frontend/import_entities/components/import_status_spec.js b/spec/frontend/import_entities/components/import_status_spec.js
index 56c4ed827d7..4c6fee35389 100644
--- a/spec/frontend/import_entities/components/import_status_spec.js
+++ b/spec/frontend/import_entities/components/import_status_spec.js
@@ -1,4 +1,4 @@
-import { GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
+import { GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ImportStatus from '~/import_entities/components/import_status.vue';
import { STATUSES } from '~/import_entities/constants';
@@ -6,15 +6,16 @@ import { STATUSES } from '~/import_entities/constants';
describe('Import entities status component', () => {
let wrapper;
- const createComponent = (propsData) => {
+ const mockStatItems = { label: 100, note: 200 };
+
+ const createComponent = (propsData, { provide } = {}) => {
wrapper = shallowMount(ImportStatus, {
propsData,
+ provide,
});
};
- afterEach(() => {
- wrapper.destroy();
- });
+ const findGlLink = () => wrapper.findComponent(GlLink);
describe('success status', () => {
const getStatusText = () => wrapper.findComponent(GlBadge).text();
@@ -28,13 +29,11 @@ describe('Import entities status component', () => {
});
it('displays finished status as complete when all stats items were processed', () => {
- const statItems = { label: 100, note: 200 };
-
createComponent({
status: STATUSES.FINISHED,
stats: {
- fetched: { ...statItems },
- imported: { ...statItems },
+ fetched: { ...mockStatItems },
+ imported: { ...mockStatItems },
},
});
@@ -43,17 +42,15 @@ describe('Import entities status component', () => {
});
it('displays finished status as partial when all stats items were processed', () => {
- const statItems = { label: 100, note: 200 };
-
createComponent({
status: STATUSES.FINISHED,
stats: {
- fetched: { ...statItems },
- imported: { ...statItems, label: 50 },
+ fetched: { ...mockStatItems },
+ imported: { ...mockStatItems, label: 50 },
},
});
- expect(getStatusText()).toBe('Partial import');
+ expect(getStatusText()).toBe('Partially completed');
expect(getStatusIcon()).toBe('status-alert');
});
});
@@ -155,4 +152,57 @@ describe('Import entities status component', () => {
expect(getStatusIcon()).toBe('status-success');
});
});
+
+ describe('show details link', () => {
+ const mockDetailsPath = 'details_path';
+ const mockProjectId = 29;
+ const mockCompleteStats = {
+ fetched: { ...mockStatItems },
+ imported: { ...mockStatItems },
+ };
+ const mockIncompleteStats = {
+ fetched: { ...mockStatItems },
+ imported: { ...mockStatItems, label: 50 },
+ };
+
+ describe.each`
+ detailsPath | importDetailsPage | partialImport | expectLink
+ ${undefined} | ${false} | ${false} | ${false}
+ ${undefined} | ${false} | ${true} | ${false}
+ ${undefined} | ${true} | ${false} | ${false}
+ ${undefined} | ${true} | ${true} | ${false}
+ ${mockDetailsPath} | ${false} | ${false} | ${false}
+ ${mockDetailsPath} | ${false} | ${true} | ${false}
+ ${mockDetailsPath} | ${true} | ${false} | ${false}
+ ${mockDetailsPath} | ${true} | ${true} | ${true}
+ `(
+ 'when detailsPath is $detailsPath, feature flag importDetailsPage is $importDetailsPage, partial import is $partialImport',
+ ({ detailsPath, importDetailsPage, partialImport, expectLink }) => {
+ beforeEach(() => {
+ createComponent(
+ {
+ projectId: mockProjectId,
+ status: STATUSES.FINISHED,
+ stats: partialImport ? mockIncompleteStats : mockCompleteStats,
+ },
+ {
+ provide: {
+ detailsPath,
+ glFeatures: { importDetailsPage },
+ },
+ },
+ );
+ });
+
+ it(`${expectLink ? 'renders' : 'does not render'} import details link`, () => {
+ expect(findGlLink().exists()).toBe(expectLink);
+ if (expectLink) {
+ expect(findGlLink().attributes('href')).toBe(
+ `${mockDetailsPath}?project_id=${mockProjectId}`,
+ );
+ }
+ });
+ },
+ );
+ });
});
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
index 163a60bae36..4c13ec555c2 100644
--- 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
@@ -1,4 +1,4 @@
-import { GlButton, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlIcon, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
@@ -8,7 +8,6 @@ describe('import actions cell', () => {
const createComponent = (props) => {
wrapper = shallowMount(ImportActionsCell, {
propsData: {
- isProjectsImportEnabled: false,
isFinished: false,
isAvailableForImport: false,
isInvalid: false,
@@ -17,19 +16,15 @@ describe('import actions cell', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when group is available for import', () => {
beforeEach(() => {
createComponent({ isAvailableForImport: true });
});
- it('renders import button', () => {
- const button = wrapper.findComponent(GlButton);
- expect(button.exists()).toBe(true);
- expect(button.text()).toBe('Import');
+ it('renders import dropdown', () => {
+ const dropdown = wrapper.findComponent(GlDropdown);
+ expect(dropdown.exists()).toBe(true);
+ expect(dropdown.props('text')).toBe('Import with projects');
});
it('does not render icon with a hint', () => {
@@ -42,10 +37,10 @@ describe('import actions cell', () => {
createComponent({ isAvailableForImport: false, isFinished: true });
});
- it('renders re-import button', () => {
- const button = wrapper.findComponent(GlButton);
- expect(button.exists()).toBe(true);
- expect(button.text()).toBe('Re-import');
+ it('renders re-import dropdown', () => {
+ const dropdown = wrapper.findComponent(GlDropdown);
+ expect(dropdown.exists()).toBe(true);
+ expect(dropdown.props('text')).toBe('Re-import with projects');
});
it('renders icon with a hint', () => {
@@ -57,25 +52,25 @@ describe('import actions cell', () => {
});
});
- it('does not render import button when group is not available for import', () => {
+ it('does not render import dropdown when group is not available for import', () => {
createComponent({ isAvailableForImport: false });
- const button = wrapper.findComponent(GlButton);
- expect(button.exists()).toBe(false);
+ const dropdown = wrapper.findComponent(GlDropdown);
+ expect(dropdown.exists()).toBe(false);
});
- it('renders import button as disabled when group is invalid', () => {
+ it('renders import dropdown as disabled when group is invalid', () => {
createComponent({ isInvalid: true, isAvailableForImport: true });
- const button = wrapper.findComponent(GlButton);
- expect(button.props().disabled).toBe(true);
+ const dropdown = wrapper.findComponent(GlDropdown);
+ expect(dropdown.props().disabled).toBe(true);
});
it('emits import-group event when import button is clicked', () => {
createComponent({ isAvailableForImport: true });
- const button = wrapper.findComponent(GlButton);
- button.vm.$emit('click');
+ const dropdown = wrapper.findComponent(GlDropdown);
+ dropdown.vm.$emit('click');
expect(wrapper.emitted('import-group')).toHaveLength(1);
});
@@ -85,10 +80,10 @@ describe('import actions cell', () => {
${false} | ${'Import'}
${true} | ${'Re-import'}
`(
- 'when import projects is enabled, group is available for import and finish status is $status',
+ 'group is available for import and finish status is $isFinished',
({ isFinished, expectedAction }) => {
beforeEach(() => {
- createComponent({ isProjectsImportEnabled: true, isAvailableForImport: true, isFinished });
+ createComponent({ isAvailableForImport: true, isFinished });
});
it('render import dropdown', () => {
@@ -99,14 +94,14 @@ describe('import actions cell', () => {
);
});
- it('request migrate projects by default', async () => {
+ it('request migrate projects by default', () => {
const dropdown = wrapper.findComponent(GlDropdown);
dropdown.vm.$emit('click');
expect(wrapper.emitted('import-group')[0]).toStrictEqual([{ migrateProjects: true }]);
});
- it('request not to migrate projects via dropdown option', async () => {
+ it('request not to migrate projects via dropdown option', () => {
const dropdown = wrapper.findComponent(GlDropdown);
dropdown.findComponent(GlDropdownItem).vm.$emit('click');
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
index f2735d86493..9ead483d02f 100644
--- 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
@@ -22,10 +22,6 @@ describe('import source cell', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when group status is NONE', () => {
beforeEach(() => {
group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE });
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 c7bda5a60ec..dae5671777c 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
@@ -6,7 +6,7 @@ import MockAdapter from 'axios-mock-adapter';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_OK, HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { STATUSES } from '~/import_entities/constants';
@@ -15,7 +15,7 @@ import ImportTable from '~/import_entities/import_groups/components/import_table
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
-import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql';
import {
AVAILABLE_NAMESPACES,
@@ -23,7 +23,7 @@ import {
generateFakeEntry,
} from '../graphql/fixtures';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/import_entities/import_groups/services/status_poller');
Vue.use(VueApollo);
@@ -49,12 +49,12 @@ describe('import table', () => {
},
};
- const findImportSelectedButton = () =>
- wrapper.findAll('button').wrappers.find((w) => w.text() === 'Import selected');
const findImportSelectedDropdown = () =>
- wrapper.findAll('.gl-dropdown').wrappers.find((w) => w.text().includes('Import with projects'));
- const findImportButtons = () =>
- wrapper.findAll('button').wrappers.filter((w) => w.text() === 'Import');
+ wrapper.find('[data-testid="import-selected-groups-dropdown"]');
+ const findRowImportDropdownAtIndex = (idx) =>
+ wrapper.findAll('tbody td button').wrappers.filter((w) => w.text() === 'Import with projects')[
+ idx
+ ];
const findPaginationDropdown = () => wrapper.find('[data-testid="page-size"]');
const findTargetNamespaceDropdown = (rowWrapper) =>
rowWrapper.find('[data-testid="target-namespace-selector"]');
@@ -70,16 +70,11 @@ describe('import table', () => {
const findRowCheckbox = (idx) => wrapper.findAll('tbody td input[type=checkbox]').at(idx);
const selectRow = (idx) => findRowCheckbox(idx).setChecked(true);
- const createComponent = ({
- bulkImportSourceGroups,
- importGroups,
- defaultTargetNamespace,
- glFeatures = {},
- }) => {
+ const createComponent = ({ bulkImportSourceGroups, importGroups, defaultTargetNamespace }) => {
apolloProvider = createMockApollo(
[
[
- searchNamespacesWhereUserCanCreateProjectsQuery,
+ searchNamespacesWhereUserCanImportProjectsQuery,
() => Promise.resolve(availableNamespacesFixture),
],
],
@@ -102,10 +97,7 @@ describe('import table', () => {
defaultTargetNamespace,
},
directives: {
- GlTooltip: createMockDirective(),
- },
- provide: {
- glFeatures,
+ GlTooltip: createMockDirective('gl-tooltip'),
},
apolloProvider,
});
@@ -120,10 +112,6 @@ describe('import table', () => {
axiosMock.onGet(/.*\/exists$/, () => []).reply(HTTP_STATUS_OK, { exists: false });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('loading state', () => {
it('renders loading icon while performing request', async () => {
createComponent({
@@ -134,7 +122,7 @@ describe('import table', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
- it('does not renders loading icon when request is completed', async () => {
+ it('does not render loading icon when request is completed', async () => {
createComponent({
bulkImportSourceGroups: () => [],
});
@@ -245,12 +233,13 @@ describe('import table', () => {
await waitForPromises();
- await findImportButtons()[0].trigger('click');
+ await findRowImportDropdownAtIndex(0).trigger('click');
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: importGroupsMutation,
variables: {
importRequests: [
{
+ migrateProjects: true,
newName: FAKE_GROUP.lastImportTarget.newName,
sourceGroupId: FAKE_GROUP.id,
targetNamespace: AVAILABLE_NAMESPACES[0].fullPath,
@@ -273,7 +262,7 @@ describe('import table', () => {
});
await waitForPromises();
- await findImportButtons()[0].trigger('click');
+ await findRowImportDropdownAtIndex(0).trigger('click');
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(
@@ -298,7 +287,7 @@ describe('import table', () => {
});
await waitForPromises();
- await findImportButtons()[0].trigger('click');
+ await findRowImportDropdownAtIndex(0).trigger('click');
await waitForPromises();
expect(createAlert).not.toHaveBeenCalled();
@@ -476,7 +465,7 @@ describe('import table', () => {
});
await waitForPromises();
- expect(findImportSelectedButton().props().disabled).toBe(true);
+ expect(findImportSelectedDropdown().props().disabled).toBe(true);
});
it('import selected button is enabled when groups were selected for import', async () => {
@@ -491,7 +480,7 @@ describe('import table', () => {
await selectRow(0);
- expect(findImportSelectedButton().props().disabled).toBe(false);
+ expect(findImportSelectedDropdown().props().disabled).toBe(false);
});
it('does not allow selecting already started groups', async () => {
@@ -509,7 +498,7 @@ describe('import table', () => {
await selectRow(0);
await nextTick();
- expect(findImportSelectedButton().props().disabled).toBe(true);
+ expect(findImportSelectedDropdown().props().disabled).toBe(true);
});
it('does not allow selecting groups with validation errors', async () => {
@@ -534,10 +523,10 @@ describe('import table', () => {
await selectRow(0);
await nextTick();
- expect(findImportSelectedButton().props().disabled).toBe(true);
+ expect(findImportSelectedDropdown().props().disabled).toBe(true);
});
- it('invokes importGroups mutation when import selected button is clicked', async () => {
+ it('invokes importGroups mutation when import selected dropdown is clicked', async () => {
const NEW_GROUPS = [
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ id: 2, status: STATUSES.NONE }),
@@ -558,7 +547,7 @@ describe('import table', () => {
await selectRow(1);
await nextTick();
- await findImportSelectedButton().trigger('click');
+ await findImportSelectedDropdown().find('button').trigger('click');
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: importGroupsMutation,
@@ -679,7 +668,7 @@ describe('import table', () => {
});
});
- describe('when import projects is enabled', () => {
+ describe('importing projects', () => {
const NEW_GROUPS = [
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ id: 2, status: STATUSES.NONE }),
@@ -693,15 +682,12 @@ describe('import table', () => {
pageInfo: FAKE_PAGE_INFO,
versionValidation: FAKE_VERSION_VALIDATION,
}),
- glFeatures: {
- bulkImportProjects: true,
- },
});
jest.spyOn(apolloProvider.defaultClient, 'mutate');
return waitForPromises();
});
- it('renders import all dropdown', async () => {
+ it('renders import all dropdown', () => {
expect(findImportSelectedDropdown().exists()).toBe(true);
});
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 d5286e71c44..46884a42707 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
@@ -8,7 +8,7 @@ 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 { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
-import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql';
import {
generateFakeEntry,
@@ -42,7 +42,7 @@ describe('import target cell', () => {
const createComponent = (props) => {
apolloProvider = createMockApollo([
[
- searchNamespacesWhereUserCanCreateProjectsQuery,
+ searchNamespacesWhereUserCanImportProjectsQuery,
() => Promise.resolve(availableNamespacesFixture),
],
]);
@@ -57,11 +57,6 @@ describe('import target cell', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('events', () => {
beforeEach(async () => {
group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE });
@@ -146,7 +141,7 @@ describe('import target cell', () => {
});
it('renders namespace dropdown as disabled', () => {
- expect(findNamespaceDropdown().attributes('disabled')).toBe('true');
+ expect(findNamespaceDropdown().attributes('disabled')).toBeDefined();
});
});
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 ce111a0c10c..540c42a2854 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
@@ -16,7 +16,7 @@ import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { statusEndpointFixture } from './fixtures';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/import_entities/import_groups/graphql/services/local_storage_cache', () => ({
LocalStorageCache: jest.fn().mockImplementation(function mock() {
this.get = jest.fn();
@@ -48,7 +48,7 @@ describe('Bulk import resolvers', () => {
};
let results;
- beforeEach(async () => {
+ beforeEach(() => {
axiosMockAdapter = new MockAdapter(axios);
client = createClient();
diff --git a/spec/frontend/import_entities/import_groups/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/services/status_poller_spec.js
index 4a1b85d24e3..e1ed739a708 100644
--- a/spec/frontend/import_entities/import_groups/services/status_poller_spec.js
+++ b/spec/frontend/import_entities/import_groups/services/status_poller_spec.js
@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { STATUSES } from '~/import_entities/constants';
import { StatusPoller } from '~/import_entities/import_groups/services/status_poller';
import axios from '~/lib/utils/axios_utils';
@@ -8,7 +8,7 @@ import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
jest.mock('visibilityjs');
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/poll');
const FAKE_POLL_PATH = '/fake/poll/path';
@@ -81,7 +81,7 @@ describe('Bulk import status poller', () => {
expect(pollInstance.makeRequest).toHaveBeenCalled();
});
- it('when error occurs shows flash with error', () => {
+ it('when error occurs shows an alert with error', () => {
const [[pollConfig]] = Poll.mock.calls;
pollConfig.errorCallback();
expect(createAlert).toHaveBeenCalled();
diff --git a/spec/frontend/import_entities/import_projects/components/advanced_settings_spec.js b/spec/frontend/import_entities/import_projects/components/advanced_settings_spec.js
index 68716600592..29af6dc946f 100644
--- a/spec/frontend/import_entities/import_projects/components/advanced_settings_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/advanced_settings_spec.js
@@ -5,8 +5,8 @@ import AdvancedSettingsPanel from '~/import_entities/import_projects/components/
describe('Import Advanced Settings', () => {
let wrapper;
const OPTIONAL_STAGES = [
- { name: 'stage1', label: 'Stage 1' },
- { name: 'stage2', label: 'Stage 2', details: 'Extra details' },
+ { name: 'stage1', label: 'Stage 1', selected: false },
+ { name: 'stage2', label: 'Stage 2', details: 'Extra details', selected: false },
];
const createComponent = () => {
@@ -25,10 +25,6 @@ describe('Import Advanced Settings', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders GLFormCheckbox for each optional stage', () => {
expect(wrapper.findAllComponents(GlFormCheckbox)).toHaveLength(OPTIONAL_STAGES.length);
});
diff --git a/spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js b/spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js
index 9eae4ed974e..246c6499a97 100644
--- a/spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js
@@ -14,18 +14,11 @@ const ImportProjectsTableStub = {
describe('BitbucketStatusTable', () => {
let wrapper;
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
- function createComponent(propsData, importProjectsTableStub = true, slots) {
+ function createComponent(propsData, slots) {
wrapper = shallowMount(BitbucketStatusTable, {
propsData,
stubs: {
- ImportProjectsTable: importProjectsTableStub,
+ ImportProjectsTable: ImportProjectsTableStub,
},
slots,
});
@@ -37,20 +30,23 @@ describe('BitbucketStatusTable', () => {
});
it('passes alert in incompatible-repos-warning slot', () => {
- createComponent({ providerTitle: 'Test' }, ImportProjectsTableStub);
+ createComponent({ providerTitle: 'Test' });
expect(wrapper.findComponent(GlAlert).exists()).toBe(true);
});
it('passes actions slot to import project table component', () => {
const actionsSlotContent = 'DEMO';
- createComponent({ providerTitle: 'Test' }, ImportProjectsTableStub, {
- actions: actionsSlotContent,
- });
+ createComponent(
+ { providerTitle: 'Test' },
+ {
+ actions: actionsSlotContent,
+ },
+ );
expect(wrapper.findComponent(ImportProjectsTable).text()).toBe(actionsSlotContent);
});
it('dismisses alert when requested', async () => {
- createComponent({ providerTitle: 'Test' }, ImportProjectsTableStub);
+ createComponent({ providerTitle: 'Test' });
wrapper.findComponent(GlAlert).vm.$emit('dismiss');
await nextTick();
diff --git a/spec/frontend/import_entities/import_projects/components/github_organizations_box_spec.js b/spec/frontend/import_entities/import_projects/components/github_organizations_box_spec.js
new file mode 100644
index 00000000000..b6f96cd6a23
--- /dev/null
+++ b/spec/frontend/import_entities/import_projects/components/github_organizations_box_spec.js
@@ -0,0 +1,97 @@
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import { mount } from '@vue/test-utils';
+import { captureException } from '@sentry/browser';
+import { nextTick } from 'vue';
+
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { createAlert } from '~/alert';
+
+import GithubOrganizationsBox from '~/import_entities/import_projects/components/github_organizations_box.vue';
+
+jest.mock('@sentry/browser');
+jest.mock('~/alert');
+
+const MOCK_RESPONSE = {
+ provider_groups: [{ name: 'alpha-1' }, { name: 'alpha-2' }, { name: 'beta-1' }],
+};
+
+describe('GithubOrganizationsBox component', () => {
+ let wrapper;
+ let mockAxios;
+
+ const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
+ const mockGithubGroupPath = '/mock/groups.json';
+
+ const createComponent = (props) => {
+ wrapper = mount(GithubOrganizationsBox, {
+ propsData: {
+ value: 'some-org',
+ ...props,
+ },
+ provide: () => ({
+ statusImportGithubGroupPath: mockGithubGroupPath,
+ }),
+ });
+ };
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+ mockAxios.onGet(mockGithubGroupPath).reply(HTTP_STATUS_OK, MOCK_RESPONSE);
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
+ });
+
+ it('has underlying listbox as loading while loading organizations', () => {
+ createComponent();
+ expect(findListbox().props('loading')).toBe(true);
+ });
+
+ it('clears underlying listbox when loading is complete', async () => {
+ createComponent();
+ await axios.waitForAll();
+ expect(findListbox().props('loading')).toBe(false);
+ });
+
+ it('sets toggle-text to all organizations when selection is not provided', () => {
+ createComponent({ value: '' });
+ expect(findListbox().props('toggleText')).toBe(GithubOrganizationsBox.i18n.allOrganizations);
+ });
+
+ it('sets toggle-text to organization name when it is provided', () => {
+ const ORG_NAME = 'org';
+ createComponent({ value: ORG_NAME });
+
+ expect(findListbox().props('toggleText')).toBe(ORG_NAME);
+ });
+
+ it('emits selected organization from underlying listbox', () => {
+ createComponent();
+
+ findListbox().vm.$emit('select', 'org-id');
+ expect(wrapper.emitted('input').at(-1)).toStrictEqual(['org-id']);
+ });
+
+ it('filters list for underlying listbox', async () => {
+ createComponent();
+ await axios.waitForAll();
+
+ findListbox().vm.$emit('search', 'alpha');
+ await nextTick();
+
+ // 2 matches + 'All organizations'
+ expect(findListbox().props('items')).toHaveLength(3);
+ });
+
+ it('reports error to sentry on load', async () => {
+ mockAxios.onGet(mockGithubGroupPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ createComponent();
+ await axios.waitForAll();
+
+ expect(captureException).toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/import_entities/import_projects/components/github_status_table_spec.js b/spec/frontend/import_entities/import_projects/components/github_status_table_spec.js
new file mode 100644
index 00000000000..7eebff7364c
--- /dev/null
+++ b/spec/frontend/import_entities/import_projects/components/github_status_table_spec.js
@@ -0,0 +1,125 @@
+import { GlTabs, GlSearchBoxByClick } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
+
+import { stubComponent } from 'helpers/stub_component';
+import GithubStatusTable from '~/import_entities/import_projects/components/github_status_table.vue';
+import GithubOrganizationsBox from '~/import_entities/import_projects/components/github_organizations_box.vue';
+import ImportProjectsTable from '~/import_entities/import_projects/components/import_projects_table.vue';
+import initialState from '~/import_entities/import_projects/store/state';
+import * as getters from '~/import_entities/import_projects/store/getters';
+
+const ImportProjectsTableStub = stubComponent(ImportProjectsTable, {
+ importAllButtonText: 'IMPORT_ALL_TEXT',
+ methods: {
+ showImportAllModal: jest.fn(),
+ },
+ template:
+ '<div><slot name="filter" v-bind="{ importAllButtonText: $options.importAllButtonText, showImportAllModal }"></slot></div>',
+});
+
+Vue.use(Vuex);
+
+describe('GithubStatusTable', () => {
+ let wrapper;
+
+ const setFilterAction = jest.fn().mockImplementation(({ state }, filter) => {
+ state.filter = { ...state.filter, ...filter };
+ });
+
+ const findFilterField = () => wrapper.findComponent(GlSearchBoxByClick);
+ const selectTab = (idx) => {
+ wrapper.findComponent(GlTabs).vm.$emit('input', idx);
+ return nextTick();
+ };
+
+ function createComponent() {
+ const store = new Vuex.Store({
+ state: { ...initialState() },
+ getters,
+ actions: {
+ setFilter: setFilterAction,
+ },
+ });
+
+ wrapper = mount(GithubStatusTable, {
+ store,
+ propsData: {
+ providerTitle: 'Github',
+ },
+ stubs: {
+ ImportProjectsTable: ImportProjectsTableStub,
+ GithubOrganizationsBox: stubComponent(GithubOrganizationsBox),
+ GlTabs: false,
+ GlTab: false,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders import table component', () => {
+ expect(wrapper.findComponent(ImportProjectsTable).exists()).toBe(true);
+ });
+
+ it('sets relation_type filter to owned repositories by default', () => {
+ expect(setFilterAction).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ relation_type: 'owned' }),
+ );
+ });
+
+ it('updates relation_type and resets organization filter when tab is switched', async () => {
+ const NEW_ACTIVE_TAB_IDX = 1;
+ await selectTab(NEW_ACTIVE_TAB_IDX);
+
+ expect(setFilterAction).toHaveBeenCalledTimes(2);
+ expect(setFilterAction).toHaveBeenCalledWith(expect.anything(), {
+ ...GithubStatusTable.relationTypes[NEW_ACTIVE_TAB_IDX].backendFilter,
+ organization_login: '',
+ filter: '',
+ });
+ });
+
+ it('renders name filter disabled when tab with organization filter is selected and organization is not set', async () => {
+ const NEW_ACTIVE_TAB_IDX = GithubStatusTable.relationTypes.findIndex(
+ (entry) => entry.showOrganizationFilter,
+ );
+ await selectTab(NEW_ACTIVE_TAB_IDX);
+ expect(findFilterField().props('disabled')).toBe(true);
+ });
+
+ it('enables name filter disabled when organization is set', async () => {
+ const NEW_ACTIVE_TAB_IDX = GithubStatusTable.relationTypes.findIndex(
+ (entry) => entry.showOrganizationFilter,
+ );
+ await selectTab(NEW_ACTIVE_TAB_IDX);
+
+ wrapper.findComponent(GithubOrganizationsBox).vm.$emit('input', 'some-org');
+ await nextTick();
+
+ expect(findFilterField().props('disabled')).toBe(false);
+ });
+
+ it('updates filter when search box is changed', async () => {
+ const NEW_FILTER = 'test';
+ findFilterField().vm.$emit('submit', NEW_FILTER);
+ await nextTick();
+
+ expect(setFilterAction).toHaveBeenCalledWith(expect.anything(), {
+ filter: NEW_FILTER,
+ });
+ });
+
+ it('updates organization_login filter when GithubOrganizationsBox emits input', () => {
+ const NEW_ORG = 'some-org';
+ wrapper.findComponent(GithubOrganizationsBox).vm.$emit('input', NEW_ORG);
+
+ expect(setFilterAction).toHaveBeenCalledWith(expect.anything(), {
+ organization_login: NEW_ORG,
+ });
+ });
+});
diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
index 51f82dab381..351bbe5ea28 100644
--- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
@@ -81,13 +81,6 @@ describe('ImportProjectsTable', () => {
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
it('renders a loading icon while repos are loading', () => {
createComponent({ state: { isLoadingRepos: true } });
@@ -292,7 +285,7 @@ describe('ImportProjectsTable', () => {
});
it('should render advanced settings panel when no optional steps are passed', () => {
- const OPTIONAL_STAGES = [{ name: 'step1', label: 'Step 1' }];
+ const OPTIONAL_STAGES = [{ name: 'step1', label: 'Step 1', selected: true }];
createComponent({ state: { providerRepos: [providerRepo] }, optionalStages: OPTIONAL_STAGES });
expect(wrapper.findComponent(AdvancedSettingsPanel).exists()).toBe(true);
@@ -300,7 +293,7 @@ describe('ImportProjectsTable', () => {
OPTIONAL_STAGES,
);
expect(wrapper.findComponent(AdvancedSettingsPanel).props('value')).toStrictEqual({
- step1: false,
+ step1: true,
});
});
});
diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
index e613b9756af..57e232a4c46 100644
--- a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
@@ -40,6 +40,7 @@ describe('ProviderRepoTableRow', () => {
const findImportButton = () => findButton('Import');
const findReimportButton = () => findButton('Re-import');
const findGroupDropdown = () => wrapper.findComponent(ImportGroupDropdown);
+ const findImportStatus = () => wrapper.findComponent(ImportStatus);
const findCancelButton = () => {
const buttons = wrapper
@@ -60,11 +61,6 @@ describe('ProviderRepoTableRow', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when rendering importable project', () => {
const repo = {
importSource: {
@@ -86,7 +82,7 @@ describe('ProviderRepoTableRow', () => {
});
it('renders empty import status', () => {
- expect(wrapper.findComponent(ImportStatus).props().status).toBe(STATUSES.NONE);
+ expect(findImportStatus().props().status).toBe(STATUSES.NONE);
});
it('renders a group namespace select', () => {
@@ -203,9 +199,7 @@ describe('ProviderRepoTableRow', () => {
});
it('renders proper import status', () => {
- expect(wrapper.findComponent(ImportStatus).props().status).toBe(
- repo.importedProject.importStatus,
- );
+ expect(findImportStatus().props().status).toBe(repo.importedProject.importStatus);
});
it('does not render a namespace select', () => {
@@ -241,8 +235,11 @@ describe('ProviderRepoTableRow', () => {
});
});
- it('passes stats to import status component', () => {
- expect(wrapper.findComponent(ImportStatus).props().stats).toBe(FAKE_STATS);
+ it('passes props to import status component', () => {
+ expect(findImportStatus().props()).toMatchObject({
+ projectId: repo.importedProject.id,
+ stats: FAKE_STATS,
+ });
});
});
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 990587d4af7..3b94db37801 100644
--- a/spec/frontend/import_entities/import_projects/store/actions_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js
@@ -1,7 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { STATUSES, PROVIDERS } from '~/import_entities/constants';
import actionsFactory from '~/import_entities/import_projects/store/actions';
import { getImportTarget } from '~/import_entities/import_projects/store/getters';
@@ -27,7 +27,7 @@ import {
HTTP_STATUS_TOO_MANY_REQUESTS,
} from '~/lib/utils/http_status';
-jest.mock('~/flash');
+jest.mock('~/alert');
const MOCK_ENDPOINT = `${TEST_HOST}/endpoint.json`;
const endpoints = {
@@ -220,12 +220,14 @@ describe('import_projects store actions', () => {
describe('when rate limited', () => {
it('commits RECEIVE_REPOS_ERROR and shows rate limited error message', async () => {
- mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(HTTP_STATUS_TOO_MANY_REQUESTS);
+ mock
+ .onGet(`${TEST_HOST}/endpoint.json?filtered_field=filter`)
+ .reply(HTTP_STATUS_TOO_MANY_REQUESTS);
await testAction(
fetchRepos,
null,
- { ...localState, filter: 'filter' },
+ { ...localState, filter: { filtered_field: 'filter' } },
[{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }],
[],
);
@@ -238,12 +240,12 @@ describe('import_projects store actions', () => {
describe('when filtered', () => {
it('fetches repos with filter applied', () => {
- mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(HTTP_STATUS_OK, payload);
+ mock.onGet(`${TEST_HOST}/endpoint.json?some_filter=filter`).reply(HTTP_STATUS_OK, payload);
return testAction(
fetchRepos,
null,
- { ...localState, filter: 'filter' },
+ { ...localState, filter: { some_filter: 'filter' } },
[
{ type: REQUEST_REPOS },
{ type: SET_PAGE, payload: 1 },
@@ -374,12 +376,12 @@ describe('import_projects store actions', () => {
describe('when filtered', () => {
beforeEach(() => {
- localState.filter = 'filter';
+ localState.filter = { some_filter: 'filter' };
});
it('fetches realtime changes with filter applied', () => {
mock
- .onGet(`${TEST_HOST}/endpoint.json?filter=filter`)
+ .onGet(`${TEST_HOST}/endpoint.json?some_filter=filter`)
.reply(HTTP_STATUS_OK, updatedProjects);
return testAction(
diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
index 514a168553a..07d247630cc 100644
--- a/spec/frontend/import_entities/import_projects/store/mutations_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
@@ -25,7 +25,7 @@ describe('import_projects store mutations', () => {
beforeEach(() => {
state = {
- filter: 'some-value',
+ filter: { someField: 'some-value' },
repositories: ['some', ' repositories'],
pageInfo: {
page: 1,
@@ -47,6 +47,17 @@ describe('import_projects store mutations', () => {
expect(state.pageInfo.endCursor).toBe(null);
expect(state.pageInfo.hasNextPage).toBe(true);
});
+
+ it('merges filter updates', () => {
+ const originalFilter = { ...state.filter };
+ const anotherFilter = { anotherField: 'another-value' };
+ mutations[types.SET_FILTER](state, anotherFilter);
+
+ expect(state.filter).toStrictEqual({
+ ...originalFilter,
+ ...anotherFilter,
+ });
+ });
});
describe(`${types.REQUEST_REPOS}`, () => {
diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js
index e8d222dc2e9..a0710ddb06c 100644
--- a/spec/frontend/incidents/components/incidents_list_spec.js
+++ b/spec/frontend/incidents/components/incidents_list_spec.js
@@ -97,13 +97,6 @@ describe('Incidents List', () => {
);
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
it('shows the loading state', () => {
mountComponent({
loading: true,
@@ -212,7 +205,7 @@ describe('Incidents List', () => {
});
});
- it('contains a link to the incident details page', async () => {
+ it('contains a link to the incident details page', () => {
findTableRows().at(0).trigger('click');
expect(visitUrl).toHaveBeenCalledWith(
joinPaths(`/project/issues/incident`, mockIncidents[0].iid),
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap
deleted file mode 100644
index 0f042dfaa4c..00000000000
--- a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap
+++ /dev/null
@@ -1,59 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`IncidentsSettingTabs should render the component 1`] = `
-<section
- class="settings no-animate"
- data-qa-selector="incidents_settings_content"
- id="incident-management-settings"
->
- <div
- class="settings-header"
- >
- <h4
- class="settings-title js-settings-toggle js-settings-toggle-trigger-only"
- >
-
- Incidents
-
- </h4>
-
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- class="js-settings-toggle"
- icon=""
- size="medium"
- variant="default"
- >
- Expand
- </gl-button-stub>
-
- <p>
-
- Fine-tune incident settings and set up integrations with external tools to help better manage incidents.
-
- </p>
- </div>
-
- <div
- class="settings-content"
- >
- <gl-tabs-stub
- queryparamname="tab"
- value="0"
- >
- <!---->
-
- <gl-tab-stub
- title="PagerDuty integration"
- titlelinkclass=""
- >
- <pagerdutysettingsform-stub
- class="gl-pt-3"
- data-testid="PagerDutySettingsForm-tab"
- />
- </gl-tab-stub>
- </gl-tabs-stub>
- </div>
-</section>
-`;
diff --git a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js
index 1d1b285c1b6..9b11fe2bff0 100644
--- a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js
+++ b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js
@@ -1,12 +1,12 @@
import AxiosMockAdapter from 'axios-mock-adapter';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { ERROR_MSG } from '~/incidents_settings/constants';
import IncidentsSettingsService from '~/incidents_settings/incidents_settings_service';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility');
describe('IncidentsSettingsService', () => {
@@ -33,7 +33,7 @@ describe('IncidentsSettingsService', () => {
});
});
- it('should display a flash message on update error', () => {
+ it('should display an alert on update error', () => {
mock.onPatch().reply(HTTP_STATUS_BAD_REQUEST);
return service.updateSettings({}).then(() => {
diff --git a/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js
index 394d1f12bcb..850fd9f0fc9 100644
--- a/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js
+++ b/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js
@@ -1,12 +1,13 @@
import { GlTab } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import IncidentsSettingTabs from '~/incidents_settings/components/incidents_settings_tabs.vue';
+import { INTEGRATION_TABS_CONFIG } from '~/incidents_settings/constants';
describe('IncidentsSettingTabs', () => {
let wrapper;
beforeEach(() => {
- wrapper = shallowMount(IncidentsSettingTabs, {
+ wrapper = shallowMountExtended(IncidentsSettingTabs, {
provide: {
service: {},
serviceLevelAgreementSettings: {},
@@ -14,16 +15,12 @@ describe('IncidentsSettingTabs', () => {
});
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
const findToggleButton = () => wrapper.findComponent({ ref: 'toggleBtn' });
const findSectionHeader = () => wrapper.findComponent({ ref: 'sectionHeader' });
-
const findIntegrationTabs = () => wrapper.findAllComponents(GlTab);
+ const findIntegrationTabAt = (index) => findIntegrationTabs().at(index);
+ const findTabComponent = (tab) => wrapper.findByTestId(`${tab.component}-tab`);
+
it('renders header text', () => {
expect(findSectionHeader().text()).toBe('Incidents');
});
@@ -34,18 +31,13 @@ describe('IncidentsSettingTabs', () => {
});
});
- it('should render the component', () => {
- expect(wrapper.element).toMatchSnapshot();
- });
-
it('should render the tab for each active integration', () => {
- const activeTabs = wrapper.vm.$options.tabs.filter((tab) => tab.active);
- expect(findIntegrationTabs().length).toBe(activeTabs.length);
+ const activeTabs = INTEGRATION_TABS_CONFIG.filter((tab) => tab.active);
+ expect(findIntegrationTabs()).toHaveLength(activeTabs.length);
+
activeTabs.forEach((tab, index) => {
- expect(findIntegrationTabs().at(index).attributes('title')).toBe(tab.title);
- expect(
- findIntegrationTabs().at(index).find(`[data-testid="${tab.component}-tab"]`).exists(),
- ).toBe(true);
+ expect(findIntegrationTabAt(index).attributes('title')).toBe(tab.title);
+ expect(findTabComponent(tab).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js
index 521a861829b..77258db437d 100644
--- a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js
+++ b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js
@@ -26,10 +26,6 @@ describe('Alert integration settings form', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
index 1f7a5f0dbc9..3a78140d0b1 100644
--- a/spec/frontend/integrations/edit/components/active_checkbox_spec.js
+++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
@@ -17,10 +17,6 @@ describe('ActiveCheckbox', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findInputInCheckbox = () => findGlFormCheckbox().find('input');
@@ -30,7 +26,7 @@ describe('ActiveCheckbox', () => {
createComponent({}, { isInheriting: true });
expect(findGlFormCheckbox().exists()).toBe(true);
- expect(findInputInCheckbox().attributes('disabled')).toBe('disabled');
+ expect(findInputInCheckbox().attributes('disabled')).toBeDefined();
});
});
@@ -39,7 +35,7 @@ describe('ActiveCheckbox', () => {
createComponent({ activateDisabled: true });
expect(findGlFormCheckbox().exists()).toBe(true);
- expect(findInputInCheckbox().attributes('disabled')).toBe('disabled');
+ expect(findInputInCheckbox().attributes('disabled')).toBeDefined();
});
});
diff --git a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
index cbe3402727a..dfb6b7d9a9c 100644
--- a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
+++ b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
@@ -14,10 +14,6 @@ describe('ConfirmationModal', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlModal = () => wrapper.findComponent(GlModal);
describe('template', () => {
diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js
index 7589b04b0fd..e1d9aef752f 100644
--- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js
+++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js
@@ -21,10 +21,6 @@ describe('DynamicField', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlFormGroup = () => wrapper.findComponent(GlFormGroup);
const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findGlFormInput = () => wrapper.findComponent(GlFormInput);
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index 383dfb36aa5..5aa3ee35379 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -84,7 +84,6 @@ describe('IntegrationForm', () => {
});
afterEach(() => {
- wrapper.destroy();
mockAxios.restore();
});
@@ -496,7 +495,7 @@ describe('IntegrationForm', () => {
expect(refreshCurrentPage).toHaveBeenCalledTimes(1);
});
- it('resets `isResetting`', async () => {
+ it('resets `isResetting`', () => {
expect(findFormActions().props('isResetting')).toBe(false);
});
});
@@ -507,30 +506,21 @@ describe('IntegrationForm', () => {
const dummyHelp = 'Foo Help';
it.each`
- integration | flagIsOn | helpHtml | sections | shouldShowSections | shouldShowHelp
- ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${''} | ${[]} | ${false} | ${false}
- ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true}
- ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${false} | ${false}
- ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${false} | ${true}
- ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${''} | ${[]} | ${false} | ${false}
- ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true}
- ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
- ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${true}
- ${'foo'} | ${false} | ${''} | ${[]} | ${false} | ${false}
- ${'foo'} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true}
- ${'foo'} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
- ${'foo'} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
- ${'foo'} | ${true} | ${''} | ${[]} | ${false} | ${false}
- ${'foo'} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true}
- ${'foo'} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
- ${'foo'} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
+ integration | helpHtml | sections | shouldShowSections | shouldShowHelp
+ ${INTEGRATION_FORM_TYPE_SLACK} | ${''} | ${[]} | ${false} | ${false}
+ ${INTEGRATION_FORM_TYPE_SLACK} | ${dummyHelp} | ${[]} | ${false} | ${true}
+ ${INTEGRATION_FORM_TYPE_SLACK} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
+ ${INTEGRATION_FORM_TYPE_SLACK} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${true}
+ ${'foo'} | ${''} | ${[]} | ${false} | ${false}
+ ${'foo'} | ${dummyHelp} | ${[]} | ${false} | ${true}
+ ${'foo'} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
+ ${'foo'} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
`(
- '$sections sections, and "$helpHtml" helpHtml when the FF is "$flagIsOn" for "$integration" integration',
- ({ integration, flagIsOn, helpHtml, sections, shouldShowSections, shouldShowHelp }) => {
+ '$sections sections, and "$helpHtml" helpHtml for "$integration" integration',
+ ({ integration, helpHtml, sections, shouldShowSections, shouldShowHelp }) => {
createComponent({
provide: {
helpHtml,
- glFeatures: { integrationSlackAppNotifications: flagIsOn },
},
customStateProps: {
sections,
@@ -553,20 +543,15 @@ describe('IntegrationForm', () => {
${false} | ${true} | ${'When having only the fields without a section'}
`('$description', ({ hasSections, hasFieldsWithoutSections }) => {
it.each`
- prefix | integration | shouldUpgradeSlack | flagIsOn | shouldShowAlert
- ${'does'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${true} | ${true}
- ${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${true} | ${false}
- ${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${false} | ${false}
- ${'does not'} | ${'foo'} | ${true} | ${true} | ${false}
- ${'does not'} | ${'foo'} | ${false} | ${true} | ${false}
- ${'does not'} | ${'foo'} | ${true} | ${false} | ${false}
+ prefix | integration | shouldUpgradeSlack | shouldShowAlert
+ ${'does'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${true}
+ ${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${false}
+ ${'does not'} | ${'foo'} | ${true} | ${false}
+ ${'does not'} | ${'foo'} | ${false} | ${false}
`(
- '$prefix render the upgrade warning when we are in "$integration" integration with the flag "$flagIsOn" and Slack-needs-upgrade is "$shouldUpgradeSlack" and have sections',
- ({ integration, shouldUpgradeSlack, flagIsOn, shouldShowAlert }) => {
+ '$prefix render the upgrade warning when we are in "$integration" integration with Slack-needs-upgrade is "$shouldUpgradeSlack" and have sections',
+ ({ integration, shouldUpgradeSlack, shouldShowAlert }) => {
createComponent({
- provide: {
- glFeatures: { integrationSlackAppNotifications: flagIsOn },
- },
customStateProps: {
shouldUpgradeSlack,
type: integration,
diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
index fa91f8de45a..82f70b8ede1 100644
--- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
@@ -31,18 +31,13 @@ describe('JiraIssuesFields', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findEnableCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findEnableCheckboxDisabled = () =>
findEnableCheckbox().find('[type=checkbox]').attributes('disabled');
const findProjectKey = () => wrapper.findComponent(GlFormInput);
const findProjectKeyFormGroup = () => wrapper.findByTestId('project-key-form-group');
const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities');
- const setEnableCheckbox = async (isEnabled = true) =>
- findEnableCheckbox().vm.$emit('input', isEnabled);
+ const setEnableCheckbox = (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled);
const assertProjectKeyState = (expectedStateValue) => {
expect(findProjectKey().attributes('state')).toBe(expectedStateValue);
@@ -182,7 +177,7 @@ describe('JiraIssuesFields', () => {
});
describe('with no project key', () => {
- it('sets Project Key `state` attribute to `undefined`', async () => {
+ it('sets Project Key `state` attribute to `undefined`', () => {
assertProjectKeyState(undefined);
});
});
diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
index 6011b3e6edc..a038b63d28c 100644
--- a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
@@ -22,10 +22,6 @@ describe('JiraTriggerFields', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findCommentSettings = () => wrapper.findByTestId('comment-settings');
const findCommentDetail = () => wrapper.findByTestId('comment-detail');
const findCommentSettingsCheckbox = () => findCommentSettings().findComponent(GlFormCheckbox);
@@ -191,7 +187,7 @@ describe('JiraTriggerFields', () => {
);
wrapper.findAll('[type=text], [type=checkbox], [type=radio]').wrappers.forEach((input) => {
- expect(input.attributes('disabled')).toBe('disabled');
+ expect(input.attributes('disabled')).toBeDefined();
});
});
});
diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js
index 90facaff1f9..2d1a6b3ace1 100644
--- a/spec/frontend/integrations/edit/components/override_dropdown_spec.js
+++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js
@@ -26,10 +26,6 @@ describe('OverrideDropdown', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlLink = () => wrapper.findComponent(GlLink);
const findGlDropdown = () => wrapper.findComponent(GlDropdown);
diff --git a/spec/frontend/integrations/edit/components/sections/apple_app_store_spec.js b/spec/frontend/integrations/edit/components/sections/apple_app_store_spec.js
new file mode 100644
index 00000000000..62f0439a13f
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/sections/apple_app_store_spec.js
@@ -0,0 +1,57 @@
+import { shallowMount } from '@vue/test-utils';
+
+import IntegrationSectionAppleAppStore from '~/integrations/edit/components/sections/apple_app_store.vue';
+import UploadDropzoneField from '~/integrations/edit/components/upload_dropzone_field.vue';
+import { createStore } from '~/integrations/edit/store';
+
+describe('IntegrationSectionAppleAppStore', () => {
+ let wrapper;
+
+ const createComponent = (componentFields) => {
+ const store = createStore({
+ customState: { ...componentFields },
+ });
+ wrapper = shallowMount(IntegrationSectionAppleAppStore, {
+ store,
+ });
+ };
+
+ const componentFields = (fileName = '') => {
+ return {
+ fields: [
+ {
+ name: 'app_store_private_key_file_name',
+ value: fileName,
+ },
+ ],
+ };
+ };
+
+ const findUploadDropzoneField = () => wrapper.findComponent(UploadDropzoneField);
+
+ describe('computed properties', () => {
+ it('renders UploadDropzoneField with default values', () => {
+ createComponent(componentFields());
+
+ const field = findUploadDropzoneField();
+
+ expect(field.exists()).toBe(true);
+ expect(field.props()).toMatchObject({
+ label: 'The Apple App Store Connect Private Key (.p8)',
+ helpText: '',
+ });
+ });
+
+ it('renders UploadDropzoneField with custom values for an attached file', () => {
+ createComponent(componentFields('fileName.txt'));
+
+ const field = findUploadDropzoneField();
+
+ expect(field.exists()).toBe(true);
+ expect(field.props()).toMatchObject({
+ label: 'Upload a new Apple App Store Connect Private Key (replace fileName.txt)',
+ helpText: 'Leave empty to use your current Private Key.',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/sections/configuration_spec.js b/spec/frontend/integrations/edit/components/sections/configuration_spec.js
index e697212ea0b..c8a7d17c041 100644
--- a/spec/frontend/integrations/edit/components/sections/configuration_spec.js
+++ b/spec/frontend/integrations/edit/components/sections/configuration_spec.js
@@ -19,10 +19,6 @@ describe('IntegrationSectionCoonfiguration', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField);
describe('template', () => {
diff --git a/spec/frontend/integrations/edit/components/sections/connection_spec.js b/spec/frontend/integrations/edit/components/sections/connection_spec.js
index 1eb92e80723..a24253d542d 100644
--- a/spec/frontend/integrations/edit/components/sections/connection_spec.js
+++ b/spec/frontend/integrations/edit/components/sections/connection_spec.js
@@ -20,10 +20,6 @@ describe('IntegrationSectionConnection', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox);
const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField);
diff --git a/spec/frontend/integrations/edit/components/sections/google_play_spec.js b/spec/frontend/integrations/edit/components/sections/google_play_spec.js
new file mode 100644
index 00000000000..c0d6d17f639
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/sections/google_play_spec.js
@@ -0,0 +1,54 @@
+import { shallowMount } from '@vue/test-utils';
+
+import IntegrationSectionGooglePlay from '~/integrations/edit/components/sections/google_play.vue';
+import UploadDropzoneField from '~/integrations/edit/components/upload_dropzone_field.vue';
+import { createStore } from '~/integrations/edit/store';
+
+describe('IntegrationSectionGooglePlay', () => {
+ let wrapper;
+
+ const createComponent = (fileName = '') => {
+ const store = createStore({
+ customState: {
+ fields: [
+ {
+ name: 'service_account_key_file_name',
+ value: fileName,
+ },
+ ],
+ },
+ });
+
+ wrapper = shallowMount(IntegrationSectionGooglePlay, {
+ store,
+ });
+ };
+
+ const findUploadDropzoneField = () => wrapper.findComponent(UploadDropzoneField);
+
+ describe('computed properties', () => {
+ it('renders UploadDropzoneField with default values', () => {
+ createComponent();
+
+ const field = findUploadDropzoneField();
+
+ expect(field.exists()).toBe(true);
+ expect(field.props()).toMatchObject({
+ label: 'Service account key (.json)',
+ helpText: '',
+ });
+ });
+
+ it('renders UploadDropzoneField with custom values for an attached file', () => {
+ createComponent('fileName.txt');
+
+ const field = findUploadDropzoneField();
+
+ expect(field.exists()).toBe(true);
+ expect(field.props()).toMatchObject({
+ label: 'Upload a new service account key (replace fileName.txt)',
+ helpText: 'Leave empty to use your current service account key.',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/sections/jira_issues_spec.js b/spec/frontend/integrations/edit/components/sections/jira_issues_spec.js
index a7c1cc2a03f..8b39fa8f583 100644
--- a/spec/frontend/integrations/edit/components/sections/jira_issues_spec.js
+++ b/spec/frontend/integrations/edit/components/sections/jira_issues_spec.js
@@ -18,10 +18,6 @@ describe('IntegrationSectionJiraIssue', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields);
describe('template', () => {
diff --git a/spec/frontend/integrations/edit/components/sections/jira_trigger_spec.js b/spec/frontend/integrations/edit/components/sections/jira_trigger_spec.js
index d4ab9864fab..b3b7f508e25 100644
--- a/spec/frontend/integrations/edit/components/sections/jira_trigger_spec.js
+++ b/spec/frontend/integrations/edit/components/sections/jira_trigger_spec.js
@@ -18,10 +18,6 @@ describe('IntegrationSectionJiraTrigger', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields);
describe('template', () => {
diff --git a/spec/frontend/integrations/edit/components/sections/trigger_spec.js b/spec/frontend/integrations/edit/components/sections/trigger_spec.js
index 883f5c7bf79..b9c1efbb0a2 100644
--- a/spec/frontend/integrations/edit/components/sections/trigger_spec.js
+++ b/spec/frontend/integrations/edit/components/sections/trigger_spec.js
@@ -18,10 +18,6 @@ describe('IntegrationSectionTrigger', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findAllTriggerFields = () => wrapper.findAllComponents(TriggerField);
describe('template', () => {
diff --git a/spec/frontend/integrations/edit/components/trigger_field_spec.js b/spec/frontend/integrations/edit/components/trigger_field_spec.js
index ed0b3324708..b3d6784959f 100644
--- a/spec/frontend/integrations/edit/components/trigger_field_spec.js
+++ b/spec/frontend/integrations/edit/components/trigger_field_spec.js
@@ -23,10 +23,6 @@ describe('TriggerField', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findGlFormInput = () => wrapper.findComponent(GlFormInput);
const findHiddenInput = () => wrapper.find('input[type="hidden"]');
@@ -41,7 +37,7 @@ describe('TriggerField', () => {
it('when isInheriting is true, renders disabled GlFormCheckbox', () => {
createComponent({ isInheriting: true });
- expect(findGlFormCheckbox().attributes('disabled')).toBe('true');
+ expect(findGlFormCheckbox().attributes('disabled')).toBeDefined();
});
it('renders correct title', () => {
diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
index 082eeea30f1..defa02aefd2 100644
--- a/spec/frontend/integrations/edit/components/trigger_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
@@ -20,10 +20,6 @@ describe('TriggerFields', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findTriggerLabel = () => wrapper.findByTestId('trigger-fields-group').find('label');
const findAllGlFormGroups = () => wrapper.find('#trigger-fields').findAllComponents(GlFormGroup);
const findAllGlFormCheckboxes = () => wrapper.findAllComponents(GlFormCheckbox);
diff --git a/spec/frontend/integrations/edit/components/upload_dropzone_field_spec.js b/spec/frontend/integrations/edit/components/upload_dropzone_field_spec.js
new file mode 100644
index 00000000000..36e20db0022
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/upload_dropzone_field_spec.js
@@ -0,0 +1,88 @@
+import { mount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import { nextTick } from 'vue';
+
+import UploadDropzoneField from '~/integrations/edit/components/upload_dropzone_field.vue';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+import { mockField } from '../mock_data';
+
+describe('UploadDropzoneField', () => {
+ let wrapper;
+
+ const contentsInputName = 'service[app_store_private_key]';
+ const fileNameInputName = 'service[app_store_private_key_file_name]';
+
+ const createComponent = (props) => {
+ wrapper = mount(UploadDropzoneField, {
+ propsData: {
+ ...mockField,
+ ...props,
+ name: contentsInputName,
+ label: 'Input Label',
+ fileInputName: fileNameInputName,
+ },
+ });
+ };
+
+ const findGlAlert = () => wrapper.findComponent(GlAlert);
+ const findUploadDropzone = () => wrapper.findComponent(UploadDropzone);
+ const findFileContentsHiddenInput = () => wrapper.find(`input[name="${contentsInputName}"]`);
+ const findFileNameHiddenInput = () => wrapper.find(`input[name="${fileNameInputName}"]`);
+
+ describe('template', () => {
+ it('adds the expected file inputFieldName', () => {
+ createComponent();
+
+ expect(findUploadDropzone().props('inputFieldName')).toBe('service[dropzone_file_name]');
+ });
+
+ it('adds a disabled, hidden text input for the file contents', () => {
+ createComponent();
+
+ expect(findFileContentsHiddenInput().attributes('name')).toBe(contentsInputName);
+ expect(findFileContentsHiddenInput().attributes('disabled')).toBeDefined();
+ });
+
+ it('adds a disabled, hidden text input for the file name', () => {
+ createComponent();
+
+ expect(findFileNameHiddenInput().attributes('name')).toBe(fileNameInputName);
+ expect(findFileNameHiddenInput().attributes('disabled')).toBeDefined();
+ });
+ });
+
+ describe('clearError', () => {
+ it('clears uploadError when called', async () => {
+ createComponent();
+
+ expect(findGlAlert().exists()).toBe(false);
+
+ findUploadDropzone().vm.$emit('error');
+ await nextTick();
+
+ expect(findGlAlert().exists()).toBe(true);
+ expect(findGlAlert().text()).toBe(
+ 'Error: You are trying to upload something other than an allowed file.',
+ );
+
+ findGlAlert().vm.$emit('dismiss');
+ await nextTick();
+
+ expect(findGlAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('onError', () => {
+ it('assigns uploadError to the supplied custom message', async () => {
+ const message = 'test error message';
+ createComponent({ errorMessage: message });
+
+ findUploadDropzone().vm.$emit('error');
+
+ await nextTick();
+
+ expect(findGlAlert().exists()).toBe(true);
+ expect(findGlAlert().text()).toBe(message);
+ });
+ });
+});
diff --git a/spec/frontend/integrations/index/components/integrations_list_spec.js b/spec/frontend/integrations/index/components/integrations_list_spec.js
index ee54a5fd359..155a3d1c6be 100644
--- a/spec/frontend/integrations/index/components/integrations_list_spec.js
+++ b/spec/frontend/integrations/index/components/integrations_list_spec.js
@@ -13,10 +13,6 @@ describe('IntegrationsList', () => {
wrapper = shallowMountExtended(IntegrationsList, { propsData });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('provides correct `integrations` prop to the IntegrationsTable instance', () => {
createComponent({ integrations: [...mockInactiveIntegrations, ...mockActiveIntegrations] });
diff --git a/spec/frontend/integrations/index/components/integrations_table_spec.js b/spec/frontend/integrations/index/components/integrations_table_spec.js
index 976c7b74890..54e5b45a5d8 100644
--- a/spec/frontend/integrations/index/components/integrations_table_spec.js
+++ b/spec/frontend/integrations/index/components/integrations_table_spec.js
@@ -1,6 +1,5 @@
import { GlTable, GlIcon, GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { INTEGRATION_TYPE_SLACK } from '~/integrations/constants';
import IntegrationsTable from '~/integrations/index/components/integrations_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -11,24 +10,18 @@ describe('IntegrationsTable', () => {
const findTable = () => wrapper.findComponent(GlTable);
- const createComponent = (propsData = {}, flagIsOn = false) => {
+ const createComponent = (propsData = {}, glFeatures = {}) => {
wrapper = mount(IntegrationsTable, {
propsData: {
integrations: mockActiveIntegrations,
...propsData,
},
provide: {
- glFeatures: {
- integrationSlackAppNotifications: flagIsOn,
- },
+ glFeatures,
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each([true, false])('when `showUpdatedAt` is %p', (showUpdatedAt) => {
beforeEach(() => {
createComponent({ showUpdatedAt });
@@ -57,50 +50,16 @@ describe('IntegrationsTable', () => {
});
});
- describe('integrations filtering', () => {
- const slackActive = {
- ...mockActiveIntegrations[0],
- name: INTEGRATION_TYPE_SLACK,
- title: 'Slack',
- };
- const slackInactive = {
- ...mockInactiveIntegrations[0],
- name: INTEGRATION_TYPE_SLACK,
- title: 'Slack',
- };
-
- describe.each`
- desc | flagIsOn | integrations | expectedIntegrations
- ${'only active'} | ${false} | ${mockActiveIntegrations} | ${mockActiveIntegrations}
- ${'only active'} | ${true} | ${mockActiveIntegrations} | ${mockActiveIntegrations}
- ${'only inactive'} | ${true} | ${mockInactiveIntegrations} | ${mockInactiveIntegrations}
- ${'only inactive'} | ${false} | ${mockInactiveIntegrations} | ${mockInactiveIntegrations}
- ${'active and inactive'} | ${true} | ${[...mockActiveIntegrations, ...mockInactiveIntegrations]} | ${[...mockActiveIntegrations, ...mockInactiveIntegrations]}
- ${'active and inactive'} | ${false} | ${[...mockActiveIntegrations, ...mockInactiveIntegrations]} | ${[...mockActiveIntegrations, ...mockInactiveIntegrations]}
- ${'Slack active with active'} | ${false} | ${[slackActive, ...mockActiveIntegrations]} | ${[slackActive, ...mockActiveIntegrations]}
- ${'Slack active with active'} | ${true} | ${[slackActive, ...mockActiveIntegrations]} | ${[slackActive, ...mockActiveIntegrations]}
- ${'Slack active with inactive'} | ${false} | ${[slackActive, ...mockInactiveIntegrations]} | ${[slackActive, ...mockInactiveIntegrations]}
- ${'Slack active with inactive'} | ${true} | ${[slackActive, ...mockInactiveIntegrations]} | ${[slackActive, ...mockInactiveIntegrations]}
- ${'Slack inactive with active'} | ${false} | ${[slackInactive, ...mockActiveIntegrations]} | ${[slackInactive, ...mockActiveIntegrations]}
- ${'Slack inactive with active'} | ${true} | ${[slackInactive, ...mockActiveIntegrations]} | ${mockActiveIntegrations}
- ${'Slack inactive with inactive'} | ${false} | ${[slackInactive, ...mockInactiveIntegrations]} | ${[slackInactive, ...mockInactiveIntegrations]}
- ${'Slack inactive with inactive'} | ${true} | ${[slackInactive, ...mockInactiveIntegrations]} | ${mockInactiveIntegrations}
- ${'Slack active with active and inactive'} | ${true} | ${[slackActive, ...mockActiveIntegrations, ...mockInactiveIntegrations]} | ${[slackActive, ...mockActiveIntegrations, ...mockInactiveIntegrations]}
- ${'Slack active with active and inactive'} | ${false} | ${[slackActive, ...mockActiveIntegrations, ...mockInactiveIntegrations]} | ${[slackActive, ...mockActiveIntegrations, ...mockInactiveIntegrations]}
- ${'Slack inactive with active and inactive'} | ${true} | ${[slackInactive, ...mockActiveIntegrations, ...mockInactiveIntegrations]} | ${[...mockActiveIntegrations, ...mockInactiveIntegrations]}
- ${'Slack inactive with active and inactive'} | ${false} | ${[slackInactive, ...mockActiveIntegrations, ...mockInactiveIntegrations]} | ${[slackInactive, ...mockActiveIntegrations, ...mockInactiveIntegrations]}
- `('when $desc and flag "$flagIsOn"', ({ flagIsOn, integrations, expectedIntegrations }) => {
+ describe.each([true, false])(
+ 'when `remove_monitor_metrics` flag is %p',
+ (removeMonitorMetrics) => {
beforeEach(() => {
- createComponent({ integrations }, flagIsOn);
+ createComponent({ integrations: [mockInactiveIntegrations[3]] }, { removeMonitorMetrics });
});
- it('renders correctly', () => {
- const links = wrapper.findAllComponents(GlLink);
- expect(links).toHaveLength(expectedIntegrations.length);
- expectedIntegrations.forEach((integration, index) => {
- expect(links.at(index).text()).toBe(integration.title);
- });
+ it(`${removeMonitorMetrics ? 'does not render' : 'renders'} prometheus integration`, () => {
+ expect(findTable().findComponent(GlLink).exists()).toBe(!removeMonitorMetrics);
});
- });
- });
+ },
+ );
});
diff --git a/spec/frontend/integrations/index/mock_data.js b/spec/frontend/integrations/index/mock_data.js
index 2231687d255..c07b320c0d3 100644
--- a/spec/frontend/integrations/index/mock_data.js
+++ b/spec/frontend/integrations/index/mock_data.js
@@ -47,4 +47,13 @@ export const mockInactiveIntegrations = [
'/gitlab-qa-sandbox-group/project_with_jenkins_6a55a67c-57c6ed0597c9319a/-/services/bamboo/edit',
name: 'bamboo',
},
+ {
+ active: false,
+ title: 'Prometheus',
+ description: 'A monitoring tool for Kubernetes',
+ updated_at: null,
+ edit_path:
+ '/gitlab-qa-sandbox-group/project_with_jenkins_6a55a67c-57c6ed0597c9319a/-/services/prometheus/edit',
+ name: 'prometheus',
+ },
];
diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js
index fdb728281b5..9e863eaecfd 100644
--- a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js
+++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js
@@ -47,7 +47,6 @@ describe('IntegrationOverrides', () => {
afterEach(() => {
mockAxios.restore();
- wrapper.destroy();
});
const findGlTable = () => wrapper.findComponent(GlTable);
diff --git a/spec/frontend/integrations/overrides/components/integration_tabs_spec.js b/spec/frontend/integrations/overrides/components/integration_tabs_spec.js
index a728b4d391f..b35a40d69c1 100644
--- a/spec/frontend/integrations/overrides/components/integration_tabs_spec.js
+++ b/spec/frontend/integrations/overrides/components/integration_tabs_spec.js
@@ -21,10 +21,6 @@ describe('IntegrationTabs', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlBadge = () => wrapper.findComponent(GlBadge);
const findGlTab = () => wrapper.findComponent(GlTab);
const findSettingsLink = () => wrapper.find('a');
diff --git a/spec/frontend/invite_members/components/confetti_spec.js b/spec/frontend/invite_members/components/confetti_spec.js
index 2f361f1dc1e..382569abfd9 100644
--- a/spec/frontend/invite_members/components/confetti_spec.js
+++ b/spec/frontend/invite_members/components/confetti_spec.js
@@ -6,16 +6,10 @@ jest.mock('canvas-confetti', () => ({
create: jest.fn(),
}));
-let wrapper;
-
const createComponent = () => {
- wrapper = shallowMount(Confetti);
+ shallowMount(Confetti);
};
-afterEach(() => {
- wrapper.destroy();
-});
-
describe('Confetti', () => {
it('initiates confetti', () => {
const basicCannon = jest.spyOn(Confetti.methods, 'basicCannon').mockImplementation(() => {});
diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js
index e1563a7bb3a..a1ca9a69926 100644
--- a/spec/frontend/invite_members/components/group_select_spec.js
+++ b/spec/frontend/invite_members/components/group_select_spec.js
@@ -26,14 +26,9 @@ describe('GroupSelect', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="true"]');
+ const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="menu"]');
const findAvatarByLabel = (text) =>
wrapper
.findAllComponents(GlAvatarLabeled)
@@ -66,6 +61,7 @@ describe('GroupSelect', () => {
expect(groupsApi.getGroups).toHaveBeenCalledWith(group1.name, {
exclude_internal: true,
active: true,
+ order_by: 'similarity',
});
});
diff --git a/spec/frontend/invite_members/components/import_project_members_modal_spec.js b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
index d839cde163c..73634855850 100644
--- a/spec/frontend/invite_members/components/import_project_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
@@ -1,6 +1,8 @@
import { GlFormGroup, GlSprintf, GlModal } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
+import { createWrapper } from '@vue/test-utils';
+import { BV_HIDE_MODAL } from '~/lib/utils/constants';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -54,7 +56,6 @@ beforeEach(() => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
@@ -108,6 +109,15 @@ describe('ImportProjectMembersModal', () => {
});
describe('submitting the import', () => {
+ it('prevents closing', () => {
+ const evt = { preventDefault: jest.fn() };
+ createComponent();
+
+ findGlModal().vm.$emit('primary', evt);
+
+ expect(evt.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
describe('when the import is successful with reloadPageOnSubmit', () => {
beforeEach(() => {
createComponent({
@@ -162,6 +172,12 @@ describe('ImportProjectMembersModal', () => {
);
});
+ it('hides the modal', () => {
+ const rootWrapper = createWrapper(wrapper.vm.$root);
+
+ expect(rootWrapper.emitted(BV_HIDE_MODAL)).toHaveLength(1);
+ });
+
it('does not call displaySuccessfulInvitationAlert on mount', () => {
expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled();
});
diff --git a/spec/frontend/invite_members/components/import_project_members_trigger_spec.js b/spec/frontend/invite_members/components/import_project_members_trigger_spec.js
index b6375fcfa22..0e8243491a8 100644
--- a/spec/frontend/invite_members/components/import_project_members_trigger_spec.js
+++ b/spec/frontend/invite_members/components/import_project_members_trigger_spec.js
@@ -17,10 +17,6 @@ const createComponent = (props = {}) => {
describe('ImportProjectMembersTrigger', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
const findButton = () => wrapper.findComponent(GlButton);
describe('displayText', () => {
diff --git a/spec/frontend/invite_members/components/invite_group_notification_spec.js b/spec/frontend/invite_members/components/invite_group_notification_spec.js
index 3e6ba6da9f4..1da2e7b705d 100644
--- a/spec/frontend/invite_members/components/invite_group_notification_spec.js
+++ b/spec/frontend/invite_members/components/invite_group_notification_spec.js
@@ -2,7 +2,7 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { sprintf } from '~/locale';
import InviteGroupNotification from '~/invite_members/components/invite_group_notification.vue';
-import { GROUP_MODAL_ALERT_BODY } from '~/invite_members/constants';
+import { GROUP_MODAL_TO_GROUP_ALERT_BODY } from '~/invite_members/constants';
describe('InviteGroupNotification', () => {
let wrapper;
@@ -13,7 +13,11 @@ describe('InviteGroupNotification', () => {
const createComponent = () => {
wrapper = shallowMountExtended(InviteGroupNotification, {
provide: { freeUsersLimit: 5 },
- propsData: { name: 'name' },
+ propsData: {
+ name: 'name',
+ notificationLink: '_notification_link_',
+ notificationText: GROUP_MODAL_TO_GROUP_ALERT_BODY,
+ },
stubs: { GlSprintf },
});
};
@@ -28,15 +32,13 @@ describe('InviteGroupNotification', () => {
});
it('shows the correct message', () => {
- const message = sprintf(GROUP_MODAL_ALERT_BODY, { count: 5 });
+ const message = sprintf(GROUP_MODAL_TO_GROUP_ALERT_BODY, { count: 5 });
expect(findAlert().text()).toMatchInterpolatedText(message);
});
it('has a help link', () => {
- expect(findLink().attributes('href')).toEqual(
- 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group',
- );
+ expect(findLink().attributes('href')).toEqual('_notification_link_');
});
});
});
diff --git a/spec/frontend/invite_members/components/invite_group_trigger_spec.js b/spec/frontend/invite_members/components/invite_group_trigger_spec.js
index 84ddb779a9e..e088dc41a2b 100644
--- a/spec/frontend/invite_members/components/invite_group_trigger_spec.js
+++ b/spec/frontend/invite_members/components/invite_group_trigger_spec.js
@@ -17,11 +17,6 @@ const createComponent = (props = {}) => {
describe('InviteGroupTrigger', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findButton = () => wrapper.findComponent(GlButton);
describe('displayText', () => {
diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js
index c2a55517405..4f082145562 100644
--- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js
@@ -12,6 +12,12 @@ import {
displaySuccessfulInvitationAlert,
reloadOnInvitationSuccess,
} from '~/invite_members/utils/trigger_successful_invite_alert';
+import {
+ GROUP_MODAL_TO_GROUP_ALERT_BODY,
+ GROUP_MODAL_TO_GROUP_ALERT_LINK,
+ GROUP_MODAL_TO_PROJECT_ALERT_BODY,
+ GROUP_MODAL_TO_PROJECT_ALERT_LINK,
+} from '~/invite_members/constants';
import { propsData, sharedGroup } from '../mock_data/group_modal';
jest.mock('~/invite_members/utils/trigger_successful_invite_alert');
@@ -44,11 +50,6 @@ describe('InviteGroupsModal', () => {
createComponent({ isProject: false });
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findModal = () => wrapper.findComponent(GlModal);
const findGroupSelect = () => wrapper.findComponent(GroupSelect);
const findInviteGroupAlert = () => wrapper.findComponent(InviteGroupNotification);
@@ -58,11 +59,13 @@ describe('InviteGroupsModal', () => {
findMembersFormGroup().attributes('invalid-feedback');
const findBase = () => wrapper.findComponent(InviteModalBase);
const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val);
- const emitEventFromModal = (eventName) => () =>
- findModal().vm.$emit(eventName, { preventDefault: jest.fn() });
- const hideModal = emitEventFromModal('hidden');
- const clickInviteButton = emitEventFromModal('primary');
- const clickCancelButton = emitEventFromModal('cancel');
+ const hideModal = () => findModal().vm.$emit('hidden', { preventDefault: jest.fn() });
+
+ const emitClickFromModal = (testId) => () =>
+ wrapper.findByTestId(testId).vm.$emit('click', { preventDefault: jest.fn() });
+
+ const clickInviteButton = emitClickFromModal('invite-modal-submit');
+ const clickCancelButton = emitClickFromModal('invite-modal-cancel');
describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => {
@@ -94,6 +97,26 @@ describe('InviteGroupsModal', () => {
expect(findInviteGroupAlert().exists()).toBe(false);
});
+
+ it('shows the user limit notification alert with correct link and text for group', () => {
+ createComponent({ freeUserCapEnabled: true });
+
+ expect(findInviteGroupAlert().props()).toMatchObject({
+ name: propsData.name,
+ notificationText: GROUP_MODAL_TO_GROUP_ALERT_BODY,
+ notificationLink: GROUP_MODAL_TO_GROUP_ALERT_LINK,
+ });
+ });
+
+ it('shows the user limit notification alert with correct link and text for project', () => {
+ createComponent({ freeUserCapEnabled: true, isProject: true });
+
+ expect(findInviteGroupAlert().props()).toMatchObject({
+ name: propsData.name,
+ notificationText: GROUP_MODAL_TO_PROJECT_ALERT_BODY,
+ notificationLink: GROUP_MODAL_TO_PROJECT_ALERT_LINK,
+ });
+ });
});
describe('submitting the invite form', () => {
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
index 9687d528321..e080e665a3b 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -6,7 +6,6 @@ import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
import InviteModalBase from '~/invite_members/components/invite_modal_base.vue';
import ModalConfetti from '~/invite_members/components/confetti.vue';
@@ -18,11 +17,11 @@ import {
MEMBERS_MODAL_CELEBRATE_TITLE,
MEMBERS_PLACEHOLDER,
MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
- LEARN_GITLAB,
EXPANDED_ERRORS,
EMPTY_INVITES_ALERT_TEXT,
ON_CELEBRATION_TRACK_LABEL,
INVITE_MEMBER_MODAL_TRACKING_CATEGORY,
+ INVALID_FEEDBACK_MESSAGE_DEFAULT,
} from '~/invite_members/constants';
import eventHub from '~/invite_members/event_hub';
import ContentTransition from '~/vue_shared/components/content_transition.vue';
@@ -40,7 +39,9 @@ import {
import { GROUPS_INVITATIONS_PATH, invitationsApiResponse } from '../mock_data/api_responses';
import {
propsData,
- inviteSource,
+ emailPostData,
+ postData,
+ singleUserPostData,
newProjectPath,
user1,
user2,
@@ -63,16 +64,18 @@ describe('InviteMembersModal', () => {
let mock;
let trackingSpy;
- const expectTracking = (
- action,
- label = undefined,
- category = INVITE_MEMBER_MODAL_TRACKING_CATEGORY,
- ) => expect(trackingSpy).toHaveBeenCalledWith(category, action, { label, category });
+ const expectTracking = (action, label = undefined, property = undefined) =>
+ expect(trackingSpy).toHaveBeenCalledWith(INVITE_MEMBER_MODAL_TRACKING_CATEGORY, action, {
+ label,
+ category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY,
+ property,
+ });
const createComponent = (props = {}, stubs = {}) => {
wrapper = shallowMountExtended(InviteMembersModal, {
provide: {
newProjectPath,
+ name: propsData.name,
},
propsData: {
usersLimitDataset: {},
@@ -116,8 +119,6 @@ describe('InviteMembersModal', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
mock.restore();
});
@@ -134,10 +135,15 @@ describe('InviteMembersModal', () => {
`${Object.keys(invitationsApiResponse.EXPANDED_RESTRICTED.message)[element]}: ${
Object.values(invitationsApiResponse.EXPANDED_RESTRICTED.message)[element]
}`;
- const emitEventFromModal = (eventName) => () =>
- findModal().vm.$emit(eventName, { preventDefault: jest.fn() });
- const clickInviteButton = emitEventFromModal('primary');
- const clickCancelButton = emitEventFromModal('cancel');
+ const findActionButton = () => wrapper.findByTestId('invite-modal-submit');
+ const findCancelButton = () => wrapper.findByTestId('invite-modal-cancel');
+
+ const emitClickFromModal = (findButton) => () =>
+ findButton().vm.$emit('click', { preventDefault: jest.fn() });
+
+ const clickInviteButton = emitClickFromModal(findActionButton);
+ const clickCancelButton = emitClickFromModal(findCancelButton);
+
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const membersFormGroupInvalidFeedback = () =>
findMembersFormGroup().attributes('invalid-feedback');
@@ -207,15 +213,6 @@ describe('InviteMembersModal', () => {
expect(findTasksToBeDone().exists()).toBe(false);
});
-
- describe('when opened from the Learn GitLab page', () => {
- it('does render the tasks to be done', async () => {
- await setupComponent({}, []);
- await triggerOpenModal({ source: LEARN_GITLAB });
-
- expect(findTasksToBeDone().exists()).toBe(true);
- });
- });
});
describe('rendering the tasks', () => {
@@ -274,38 +271,18 @@ describe('InviteMembersModal', () => {
});
describe('tracking events', () => {
- it('tracks the view for invite_members_for_task', async () => {
- await setupComponentWithTasks();
-
- expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name);
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
- INVITE_MEMBERS_FOR_TASK.view,
- );
- });
-
it('tracks the submit for invite_members_for_task', async () => {
await setupComponentWithTasks();
- await triggerMembersTokenSelect([user1]);
- clickInviteButton();
+ await triggerMembersTokenSelect([user1]);
- expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name, {
- label: 'selected_tasks_to_be_done',
- property: 'ci,code',
- });
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
- INVITE_MEMBERS_FOR_TASK.submit,
- );
- });
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- it('does not track the submit for invite_members_for_task when invites have not been entered', async () => {
- await setupComponentWithTasks();
clickInviteButton();
- expect(ExperimentTracking).not.toHaveBeenCalledWith(
- INVITE_MEMBERS_FOR_TASK.name,
- expect.any,
- );
+ expectTracking(INVITE_MEMBERS_FOR_TASK.submit, 'selected_tasks_to_be_done', 'ci,code');
+
+ unmockTracking();
});
});
});
@@ -368,13 +345,11 @@ describe('InviteMembersModal', () => {
it('tracks actions', async () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- const mockEvent = { preventDefault: jest.fn() };
-
await triggerOpenModal({ mode: 'celebrate', source: ON_CELEBRATION_TRACK_LABEL });
expectTracking('render', ON_CELEBRATION_TRACK_LABEL);
- findModal().vm.$emit('cancel', mockEvent);
+ clickCancelButton();
expectTracking('click_cancel', ON_CELEBRATION_TRACK_LABEL);
findModal().vm.$emit('close');
@@ -411,13 +386,11 @@ describe('InviteMembersModal', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- const mockEvent = { preventDefault: jest.fn() };
-
await triggerOpenModal(source);
expectTracking('render', label);
- findModal().vm.$emit('cancel', mockEvent);
+ clickCancelButton();
expectTracking('click_cancel', label);
findModal().vm.$emit('close');
@@ -472,7 +445,7 @@ describe('InviteMembersModal', () => {
const expectedSyntaxError = 'email contains an invalid email address';
describe('when no invites have been entered in the form and then some are entered', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createInviteMembersToGroupWrapper();
});
@@ -492,16 +465,6 @@ describe('InviteMembersModal', () => {
});
describe('when inviting an existing user to group by user ID', () => {
- const postData = {
- user_id: '1,2',
- access_level: propsData.defaultAccessLevel,
- expires_at: undefined,
- invite_source: inviteSource,
- format: 'json',
- tasks_to_be_done: [],
- tasks_project_id: '',
- };
-
describe('when reloadOnSubmit is true', () => {
beforeEach(async () => {
createComponent({ reloadPageOnSubmit: true });
@@ -555,20 +518,6 @@ describe('InviteMembersModal', () => {
expect(reloadOnInvitationSuccess).not.toHaveBeenCalled();
});
});
-
- describe('when opened from a Learn GitLab page', () => {
- it('emits the `showSuccessfulInvitationsAlert` event', async () => {
- await triggerOpenModal({ source: LEARN_GITLAB });
-
- jest.spyOn(eventHub, '$emit').mockImplementation();
-
- clickInviteButton();
-
- await waitForPromises();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('showSuccessfulInvitationsAlert');
- });
- });
});
describe('when member is not added successfully', () => {
@@ -675,16 +624,6 @@ describe('InviteMembersModal', () => {
});
describe('when inviting a new user by email address', () => {
- const postData = {
- access_level: propsData.defaultAccessLevel,
- expires_at: undefined,
- email: 'email@example.com',
- invite_source: inviteSource,
- tasks_to_be_done: [],
- tasks_project_id: '',
- format: 'json',
- };
-
describe('when invites are sent successfully', () => {
beforeEach(async () => {
createComponent();
@@ -692,7 +631,7 @@ describe('InviteMembersModal', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: emailPostData });
});
describe('when triggered from regular mounting', () => {
@@ -701,7 +640,7 @@ describe('InviteMembersModal', () => {
});
it('calls Api inviteGroupMembers with the correct params', () => {
- expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, emailPostData);
});
it('displays the successful toastMessage', () => {
@@ -719,96 +658,117 @@ describe('InviteMembersModal', () => {
});
describe('when invites are not sent successfully', () => {
- beforeEach(async () => {
- createInviteMembersToGroupWrapper();
+ describe('when api throws error', () => {
+ beforeEach(async () => {
+ jest.spyOn(axios, 'post').mockImplementation(() => {
+ throw new Error();
+ });
- await triggerMembersTokenSelect([user3]);
+ createInviteMembersToGroupWrapper();
+
+ await triggerMembersTokenSelect([user3]);
+ clickInviteButton();
+ });
+
+ it('displays the default error message', () => {
+ expect(membersFormGroupInvalidFeedback()).toBe(INVALID_FEEDBACK_MESSAGE_DEFAULT);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ expect(findActionButton().props('loading')).toBe(false);
+ });
});
- it('displays the api error for invalid email syntax', async () => {
- mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
+ describe('when api rejects promise', () => {
+ beforeEach(async () => {
+ createInviteMembersToGroupWrapper();
- clickInviteButton();
+ await triggerMembersTokenSelect([user3]);
+ });
- await waitForPromises();
+ it('displays the api error for invalid email syntax', async () => {
+ mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
- expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- expect(findMembersSelect().props('exceptionState')).toBe(false);
- expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
- });
+ clickInviteButton();
- it('clears the error when the modal is hidden', async () => {
- mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
+ await waitForPromises();
- clickInviteButton();
+ expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ expect(findActionButton().props('loading')).toBe(false);
+ });
- await waitForPromises();
+ it('clears the error when the modal is hidden', async () => {
+ mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
- expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- expect(findMembersSelect().props('exceptionState')).toBe(false);
- expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
+ clickInviteButton();
- findModal().vm.$emit('hidden');
+ await waitForPromises();
- await nextTick();
+ expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ expect(findActionButton().props('loading')).toBe(false);
- expect(findMemberErrorAlert().exists()).toBe(false);
- expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('exceptionState')).not.toBe(false);
- });
+ findModal().vm.$emit('hidden');
- it('displays the restricted email error when restricted email is invited', async () => {
- mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
+ await nextTick();
- clickInviteButton();
+ expect(findMemberErrorAlert().exists()).toBe(false);
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
+ });
- await waitForPromises();
+ it('displays the restricted email error when restricted email is invited', async () => {
+ mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
- expect(findMemberErrorAlert().exists()).toBe(true);
- expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError);
- expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('exceptionState')).not.toBe(false);
- expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
- });
+ clickInviteButton();
- it('displays all errors when there are multiple emails that return a restricted error message', async () => {
- mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
+ await waitForPromises();
- clickInviteButton();
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError);
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
+ expect(findActionButton().props('loading')).toBe(false);
+ });
- await waitForPromises();
+ it('displays all errors when there are multiple emails that return a restricted error message', async () => {
+ mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
- expect(findMemberErrorAlert().exists()).toBe(true);
- expect(findMemberErrorAlert().text()).toContain(
- Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0],
- );
- expect(findMemberErrorAlert().text()).toContain(
- Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1],
- );
- expect(findMemberErrorAlert().text()).toContain(
- Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2],
- );
- expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('exceptionState')).not.toBe(false);
- });
+ clickInviteButton();
- it('displays the invalid syntax error for bad request', async () => {
- mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID);
+ await waitForPromises();
- clickInviteButton();
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0],
+ );
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1],
+ );
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2],
+ );
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
+ });
- await waitForPromises();
+ it('displays the invalid syntax error for bad request', async () => {
+ mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID);
- expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- expect(findMembersSelect().props('exceptionState')).toBe(false);
- });
+ clickInviteButton();
- it('does not call displaySuccessfulInvitationAlert on mount', () => {
- expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled();
- });
+ await waitForPromises();
+
+ expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ });
+
+ it('does not call displaySuccessfulInvitationAlert on mount', () => {
+ expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled();
+ });
- it('does not call reloadOnInvitationSuccess', () => {
- expect(reloadOnInvitationSuccess).not.toHaveBeenCalled();
+ it('does not call reloadOnInvitationSuccess', () => {
+ expect(reloadOnInvitationSuccess).not.toHaveBeenCalled();
+ });
});
});
@@ -892,17 +852,6 @@ describe('InviteMembersModal', () => {
});
describe('when inviting members and non-members in same click', () => {
- const postData = {
- access_level: propsData.defaultAccessLevel,
- expires_at: undefined,
- invite_source: inviteSource,
- format: 'json',
- tasks_to_be_done: [],
- tasks_project_id: '',
- user_id: '1',
- email: 'email@example.com',
- };
-
describe('when invites are sent successfully', () => {
beforeEach(async () => {
createComponent();
@@ -910,7 +859,7 @@ describe('InviteMembersModal', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: singleUserPostData });
});
describe('when triggered from regular mounting', () => {
@@ -922,7 +871,7 @@ describe('InviteMembersModal', () => {
it('calls Api inviteGroupMembers with the correct params and invite source', () => {
expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, {
- ...postData,
+ ...singleUserPostData,
invite_source: '_invite_source_',
});
});
@@ -951,26 +900,9 @@ describe('InviteMembersModal', () => {
clickInviteButton();
- expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, singleUserPostData);
});
});
});
-
- describe('tracking', () => {
- beforeEach(async () => {
- createComponent();
- await triggerMembersTokenSelect([user3]);
-
- wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({});
- });
-
- it('tracks the view for learn_gitlab source', () => {
- eventHub.$emit('openModal', { source: LEARN_GITLAB });
-
- expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name);
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(LEARN_GITLAB);
- });
- });
});
});
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 c522abe63c5..58c40a49b3c 100644
--- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
@@ -1,12 +1,15 @@
-import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
+import { GlButton, GlLink, GlDropdownItem, GlDisclosureDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import eventHub from '~/invite_members/event_hub';
import {
TRIGGER_ELEMENT_BUTTON,
- TRIGGER_ELEMENT_SIDE_NAV,
TRIGGER_DEFAULT_QA_SELECTOR,
+ TRIGGER_ELEMENT_WITH_EMOJI,
+ TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI,
+ TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
} from '~/invite_members/constants';
+import { GlEmoji } from '../mock_data/member_modal';
jest.mock('~/experimentation/experiment_tracking');
@@ -19,7 +22,9 @@ let findButton;
const triggerComponent = {
button: GlButton,
anchor: GlLink,
- 'side-nav': GlLink,
+ 'text-emoji': GlLink,
+ 'dropdown-text-emoji': GlDropdownItem,
+ 'dropdown-text': GlButton,
};
const createComponent = (props = {}) => {
@@ -29,6 +34,11 @@ const createComponent = (props = {}) => {
...triggerProps,
...props,
},
+ stubs: {
+ GlEmoji,
+ GlDisclosureDropdownItem,
+ GlButton,
+ },
});
};
@@ -40,8 +50,8 @@ const triggerItems = [
triggerElement: 'anchor',
},
{
- triggerElement: TRIGGER_ELEMENT_SIDE_NAV,
- icon: 'plus',
+ triggerElement: TRIGGER_ELEMENT_WITH_EMOJI,
+ icon: 'shaking_hands',
},
];
@@ -50,10 +60,6 @@ describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => {
findButton = () => wrapper.findComponent(triggerComponent[triggerItem.triggerElement]);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('configurable attributes', () => {
it('includes the correct displayText for the button', () => {
createComponent();
@@ -91,31 +97,45 @@ describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => {
});
});
});
+});
- describe('tracking', () => {
- it('does not add tracking attributes', () => {
- createComponent();
-
- expect(findButton().attributes('data-track-action')).toBeUndefined();
- expect(findButton().attributes('data-track-label')).toBeUndefined();
- });
+describe('link with emoji', () => {
+ it('includes the specified icon with correct size when triggerElement is link', () => {
+ const findEmoji = () => wrapper.findComponent(GlEmoji);
- it('adds tracking attributes', () => {
- createComponent({ label: '_label_', event: '_event_' });
+ createComponent({ triggerElement: TRIGGER_ELEMENT_WITH_EMOJI, icon: 'shaking_hands' });
- expect(findButton().attributes('data-track-action')).toBe('_event_');
- expect(findButton().attributes('data-track-label')).toBe('_label_');
- });
+ expect(findEmoji().exists()).toBe(true);
+ expect(findEmoji().attributes('data-name')).toBe('shaking_hands');
});
});
-describe('side-nav with icon', () => {
+describe('dropdown item with emoji', () => {
it('includes the specified icon with correct size when triggerElement is link', () => {
- const findIcon = () => wrapper.findComponent(GlIcon);
+ const findEmoji = () => wrapper.findComponent(GlEmoji);
+
+ createComponent({ triggerElement: TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI, icon: 'shaking_hands' });
+
+ expect(findEmoji().exists()).toBe(true);
+ expect(findEmoji().attributes('data-name')).toBe('shaking_hands');
+ });
+});
+
+describe('disclosure dropdown item', () => {
+ const findTrigger = () => wrapper.findComponent(GlDisclosureDropdownItem);
+
+ beforeEach(() => {
+ createComponent({ triggerElement: TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN });
+ });
+
+ it('renders a trigger button', () => {
+ expect(findTrigger().exists()).toBe(true);
+ expect(findTrigger().text()).toBe(displayText);
+ });
- createComponent({ triggerElement: TRIGGER_ELEMENT_SIDE_NAV, icon: 'plus' });
+ it('emits modalOpened which clicked', () => {
+ findTrigger().vm.$emit('action');
- expect(findIcon().exists()).toBe(true);
- expect(findIcon().props('name')).toBe('plus');
+ expect(wrapper.emitted('modal-opened')).toHaveLength(1);
});
});
diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js
index f34f9902514..e70c83a424e 100644
--- a/spec/frontend/invite_members/components/invite_modal_base_spec.js
+++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js
@@ -54,10 +54,6 @@ describe('InviteModalBase', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findFormSelect = () => wrapper.findComponent(GlFormSelect);
const findFormSelectOptions = () => findFormSelect().findAllComponents('option');
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
@@ -66,8 +62,8 @@ describe('InviteModalBase', () => {
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const findDisabledInput = () => wrapper.findByTestId('disabled-input');
- const findCancelButton = () => wrapper.find('.js-modal-action-cancel');
- const findActionButton = () => wrapper.find('.js-modal-action-primary');
+ const findCancelButton = () => wrapper.findByTestId('invite-modal-cancel');
+ const findActionButton = () => wrapper.findByTestId('invite-modal-submit');
describe('rendering the modal', () => {
let trackingSpy;
@@ -88,20 +84,19 @@ describe('InviteModalBase', () => {
});
it('renders the Cancel button text correctly', () => {
- expect(wrapper.findComponent(GlModal).props('actionCancel')).toMatchObject({
- text: CANCEL_BUTTON_TEXT,
- });
+ expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT);
});
it('renders the Invite button correctly', () => {
- expect(wrapper.findComponent(GlModal).props('actionPrimary')).toMatchObject({
- text: INVITE_BUTTON_TEXT,
- attributes: {
- variant: 'confirm',
- disabled: false,
- loading: false,
- 'data-qa-selector': 'invite_button',
- },
+ const actionButton = findActionButton();
+
+ expect(actionButton.text()).toBe(INVITE_BUTTON_TEXT);
+ expect(actionButton.attributes('data-qa-selector')).toBe('invite_button');
+
+ expect(actionButton.props()).toMatchObject({
+ variant: 'confirm',
+ disabled: false,
+ loading: false,
});
});
@@ -235,7 +230,7 @@ describe('InviteModalBase', () => {
},
});
- expect(wrapper.findComponent(GlModal).props('actionPrimary').attributes.loading).toBe(true);
+ expect(findActionButton().props('loading')).toBe(true);
});
it('with invalidFeedbackMessage, set members form group exception state', () => {
diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js
index 0455460918c..c7e9905dee3 100644
--- a/spec/frontend/invite_members/components/members_token_select_spec.js
+++ b/spec/frontend/invite_members/components/members_token_select_spec.js
@@ -30,11 +30,6 @@ const createComponent = (props) => {
describe('MembersTokenSelect', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
describe('rendering the token-selector component', () => {
diff --git a/spec/frontend/invite_members/components/project_select_spec.js b/spec/frontend/invite_members/components/project_select_spec.js
index 6fbf95362fa..20db4f20408 100644
--- a/spec/frontend/invite_members/components/project_select_spec.js
+++ b/spec/frontend/invite_members/components/project_select_spec.js
@@ -23,10 +23,6 @@ describe('ProjectSelect', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox);
const findAvatarLabeled = (index) => wrapper.findAllComponents(GlAvatarLabeled).at(index);
diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js
index 59d58f21bb0..67fb1dcbfbd 100644
--- a/spec/frontend/invite_members/mock_data/member_modal.js
+++ b/spec/frontend/invite_members/mock_data/member_modal.js
@@ -45,4 +45,35 @@ export const user6 = {
avatar_url: '',
};
+export const postData = {
+ user_id: `${user1.id},${user2.id}`,
+ access_level: propsData.defaultAccessLevel,
+ expires_at: undefined,
+ invite_source: inviteSource,
+ format: 'json',
+ tasks_to_be_done: [],
+ tasks_project_id: '',
+};
+
+export const emailPostData = {
+ access_level: propsData.defaultAccessLevel,
+ expires_at: undefined,
+ email: `${user3.name}`,
+ invite_source: inviteSource,
+ tasks_to_be_done: [],
+ tasks_project_id: '',
+ format: 'json',
+};
+
+export const singleUserPostData = {
+ access_level: propsData.defaultAccessLevel,
+ expires_at: undefined,
+ user_id: `${user1.id}`,
+ email: `${user3.name}`,
+ invite_source: inviteSource,
+ tasks_to_be_done: [],
+ tasks_project_id: '',
+ format: 'json',
+};
+
export const GlEmoji = { template: '<img/>' };
diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js
index eb76c9845d4..b6fc70038bb 100644
--- a/spec/frontend/invite_members/utils/member_utils_spec.js
+++ b/spec/frontend/invite_members/utils/member_utils_spec.js
@@ -1,4 +1,12 @@
-import { memberName } from '~/invite_members/utils/member_utils';
+import {
+ memberName,
+ triggerExternalAlert,
+ qualifiesForTasksToBeDone,
+} from '~/invite_members/utils/member_utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { getParameterValues } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility');
describe('Member Name', () => {
it.each([
@@ -10,3 +18,23 @@ describe('Member Name', () => {
expect(memberName(member)).toBe(result);
});
});
+
+describe('Trigger External Alert', () => {
+ it('returns false', () => {
+ expect(triggerExternalAlert()).toBe(false);
+ });
+});
+
+describe('Qualifies For Tasks To Be Done', () => {
+ it.each([
+ ['invite_members_for_task', true],
+ ['blah', false],
+ ])(`returns name from supplied member token: %j`, (value, result) => {
+ setWindowLocation(`blah/blah?open_modal=${value}`);
+ getParameterValues.mockImplementation(() => {
+ return [value];
+ });
+
+ expect(qualifiesForTasksToBeDone()).toBe(result);
+ });
+});
diff --git a/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js b/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js
index 38b16dd0c2c..6192713f121 100644
--- a/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js
+++ b/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js
@@ -6,14 +6,14 @@ import {
TOAST_MESSAGE_LOCALSTORAGE_KEY,
TOAST_MESSAGE_SUCCESSFUL,
} from '~/invite_members/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
-jest.mock('~/flash');
+jest.mock('~/alert');
useLocalStorageSpy();
describe('Display Successful Invitation Alert', () => {
- it('does not show alert if localStorage key not present', () => {
+ it('does not show an alert if localStorage key not present', () => {
localStorage.removeItem(TOAST_MESSAGE_LOCALSTORAGE_KEY);
displaySuccessfulInvitationAlert();
@@ -21,7 +21,7 @@ describe('Display Successful Invitation Alert', () => {
expect(createAlert).not.toHaveBeenCalled();
});
- it('shows alert when localStorage key is present', () => {
+ it('shows an alert when localStorage key is present', () => {
localStorage.setItem(TOAST_MESSAGE_LOCALSTORAGE_KEY, 'true');
displaySuccessfulInvitationAlert();
diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js
index f798f87b6b2..ccd53e64c4d 100644
--- a/spec/frontend/issuable/components/csv_export_modal_spec.js
+++ b/spec/frontend/issuable/components/csv_export_modal_spec.js
@@ -17,7 +17,7 @@ describe('CsvExportModal', () => {
...props,
},
provide: {
- issuableType: 'issues',
+ issuableType: 'issue',
...injectedProperties,
},
stubs: {
@@ -29,19 +29,15 @@ describe('CsvExportModal', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findModal = () => wrapper.findComponent(GlModal);
const findIcon = () => wrapper.findComponent(GlIcon);
describe('template', () => {
describe.each`
- issuableType | modalTitle
- ${'issues'} | ${'Export issues'}
- ${'merge-requests'} | ${'Export merge requests'}
- `('with the issuableType "$issuableType"', ({ issuableType, modalTitle }) => {
+ issuableType | modalTitle | dataTrackLabel
+ ${'issue'} | ${'Export issues'} | ${'export_issues_csv'}
+ ${'merge_request'} | ${'Export merge requests'} | ${'export_merge-requests_csv'}
+ `('with the issuableType "$issuableType"', ({ issuableType, modalTitle, dataTrackLabel }) => {
beforeEach(() => {
wrapper = createComponent({ injectedProperties: { issuableType } });
});
@@ -57,9 +53,9 @@ describe('CsvExportModal', () => {
href: 'export/csv/path',
variant: 'confirm',
'data-method': 'post',
- 'data-qa-selector': `export_${issuableType}_button`,
+ 'data-qa-selector': `export_issues_button`,
'data-track-action': 'click_button',
- 'data-track-label': `export_${issuableType}_csv`,
+ 'data-track-label': dataTrackLabel,
},
});
});
diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js
index 118c12d968b..0e2f71fa3ee 100644
--- a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js
+++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js
@@ -1,5 +1,4 @@
-import { GlButton, GlDropdown } from '@gitlab/ui';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { createMockDirective } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import CsvExportModal from '~/issuable/components/csv_export_modal.vue';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
@@ -16,7 +15,7 @@ describe('CsvImportExportButtons', () => {
glModalDirective = jest.fn();
return mountExtended(CsvImportExportButtons, {
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
glModal: {
bind(_, { value }) {
glModalDirective(value);
@@ -33,12 +32,7 @@ describe('CsvImportExportButtons', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
- const findExportCsvButton = () => wrapper.findComponent(GlButton);
- const findImportDropdown = () => wrapper.findComponent(GlDropdown);
+ const findExportCsvButton = () => wrapper.findByRole('menuitem', { name: 'Export as CSV' });
const findImportCsvButton = () => wrapper.findByRole('menuitem', { name: 'Import CSV' });
const findImportFromJiraLink = () => wrapper.findByRole('menuitem', { name: 'Import from Jira' });
const findExportCsvModal = () => wrapper.findComponent(CsvExportModal);
@@ -54,13 +48,6 @@ describe('CsvImportExportButtons', () => {
expect(findExportCsvButton().exists()).toBe(true);
});
- it('export button has a tooltip', () => {
- const tooltip = getBinding(findExportCsvButton().element, 'gl-tooltip');
-
- expect(tooltip).toBeDefined();
- expect(tooltip.value).toBe('Export as CSV');
- });
-
it('renders the export modal', () => {
expect(findExportCsvModal().props()).toMatchObject({ exportCsvPath, issuableCount });
});
@@ -68,7 +55,7 @@ describe('CsvImportExportButtons', () => {
it('opens the export modal', () => {
findExportCsvButton().trigger('click');
- expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.exportModalId);
+ expect(glModalDirective).toHaveBeenCalled();
});
});
@@ -87,79 +74,38 @@ describe('CsvImportExportButtons', () => {
});
describe('when the showImportButton=true', () => {
- beforeEach(() => {
+ it('renders the import csv menu item', () => {
wrapper = createComponent({ showImportButton: true });
- });
-
- it('displays the import dropdown', () => {
- expect(findImportDropdown().exists()).toBe(true);
- });
- it('renders the import csv menu item', () => {
expect(findImportCsvButton().exists()).toBe(true);
});
- describe('when showLabel=false', () => {
- beforeEach(() => {
- wrapper = createComponent({ showImportButton: true, showLabel: false });
- });
-
- it('hides button text', () => {
- expect(findImportDropdown().props()).toMatchObject({
- text: 'Import issues',
- textSrOnly: true,
- });
- });
-
- it('import button has a tooltip', () => {
- const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip');
-
- expect(tooltip).toBeDefined();
- expect(tooltip.value).toBe('Import issues');
- });
- });
-
- describe('when showLabel=true', () => {
- beforeEach(() => {
- wrapper = createComponent({ showImportButton: true, showLabel: true });
- });
-
- it('displays a button text', () => {
- expect(findImportDropdown().props()).toMatchObject({
- text: 'Import issues',
- textSrOnly: false,
- });
- });
-
- it('import button has no tooltip', () => {
- const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip');
-
- expect(tooltip.value).toBe(null);
- });
- });
-
it('renders the import modal', () => {
+ wrapper = createComponent({ showImportButton: true });
+
expect(findImportCsvModal().exists()).toBe(true);
});
it('opens the import modal', () => {
+ wrapper = createComponent({ showImportButton: true });
+
findImportCsvButton().trigger('click');
- expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.importModalId);
+ expect(glModalDirective).toHaveBeenCalled();
});
describe('import from jira link', () => {
const projectImportJiraPath = 'gitlab-org/gitlab-test/-/import/jira';
- beforeEach(() => {
- wrapper = createComponent({
- showImportButton: true,
- canEdit: true,
- projectImportJiraPath,
+ describe('when canEdit=true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ showImportButton: true,
+ canEdit: true,
+ projectImportJiraPath,
+ });
});
- });
- describe('when canEdit=true', () => {
it('renders the import dropdown item', () => {
expect(findImportFromJiraLink().exists()).toBe(true);
});
@@ -186,8 +132,8 @@ describe('CsvImportExportButtons', () => {
wrapper = createComponent({ showImportButton: false });
});
- it('does not display the import dropdown', () => {
- expect(findImportDropdown().exists()).toBe(false);
+ it('does not render the import csv menu item', () => {
+ expect(findImportCsvButton().exists()).toBe(false);
});
it('does not render the import modal', () => {
diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js
index 6e954c91f46..9069d2b3ab3 100644
--- a/spec/frontend/issuable/components/csv_import_modal_spec.js
+++ b/spec/frontend/issuable/components/csv_import_modal_spec.js
@@ -32,10 +32,6 @@ describe('CsvImportModal', () => {
formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findModal = () => wrapper.findComponent(GlModal);
const findForm = () => wrapper.find('form');
const findFileInput = () => wrapper.findByLabelText('Upload CSV file');
diff --git a/spec/frontend/issuable/components/issuable_by_email_spec.js b/spec/frontend/issuable/components/issuable_by_email_spec.js
index b04a6c0b8fd..4cc5775b54e 100644
--- a/spec/frontend/issuable/components/issuable_by_email_spec.js
+++ b/spec/frontend/issuable/components/issuable_by_email_spec.js
@@ -53,8 +53,6 @@ describe('IssuableByEmail', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
mockAxios.restore();
});
diff --git a/spec/frontend/issuable/components/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js
index 99aa6778e1e..ff772040d22 100644
--- a/spec/frontend/issuable/components/issuable_header_warnings_spec.js
+++ b/spec/frontend/issuable/components/issuable_header_warnings_spec.js
@@ -25,16 +25,11 @@ describe('IssuableHeaderWarnings', () => {
store,
provide,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe.each`
issuableType
${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR}
diff --git a/spec/frontend/issuable/components/issue_assignees_spec.js b/spec/frontend/issuable/components/issue_assignees_spec.js
index 9a33bfae240..8ed51120508 100644
--- a/spec/frontend/issuable/components/issue_assignees_spec.js
+++ b/spec/frontend/issuable/components/issue_assignees_spec.js
@@ -21,11 +21,6 @@ describe('IssueAssigneesComponent', () => {
vm = wrapper.vm;
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findTooltipText = () => wrapper.find('.js-assignee-tooltip').text();
const findAvatars = () => wrapper.findAllComponents(UserAvatarLink);
const findOverflowCounter = () => wrapper.find('.avatar-counter');
diff --git a/spec/frontend/issuable/components/issue_milestone_spec.js b/spec/frontend/issuable/components/issue_milestone_spec.js
index eac53c5f761..232d6177862 100644
--- a/spec/frontend/issuable/components/issue_milestone_spec.js
+++ b/spec/frontend/issuable/components/issue_milestone_spec.js
@@ -1,160 +1,61 @@
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltip } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-
import { mockMilestone } from 'jest/boards/mock_data';
import IssueMilestone from '~/issuable/components/issue_milestone.vue';
-const createComponent = (milestone = mockMilestone) => {
- const Component = Vue.extend(IssueMilestone);
-
- return shallowMount(Component, {
- propsData: {
- milestone,
- },
- });
-};
-
-describe('IssueMilestoneComponent', () => {
+describe('IssueMilestone component', () => {
let wrapper;
- let vm;
- beforeEach(async () => {
- wrapper = createComponent();
+ const findTooltip = () => wrapper.findComponent(GlTooltip);
- ({ vm } = wrapper);
+ const createComponent = (milestone = mockMilestone) =>
+ shallowMount(IssueMilestone, { propsData: { milestone } });
- await nextTick();
+ beforeEach(() => {
+ wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
+ it('renders milestone icon', () => {
+ expect(wrapper.findComponent(GlIcon).props('name')).toBe('clock');
});
- describe('computed', () => {
- describe('isMilestoneStarted', () => {
- it('should return `false` when milestoneStart prop is not defined', async () => {
- wrapper.setProps({
- milestone: { ...mockMilestone, start_date: '' },
- });
- await nextTick();
-
- expect(wrapper.vm.isMilestoneStarted).toBe(false);
- });
-
- it('should return `true` when milestone start date is past current date', async () => {
- await wrapper.setProps({
- milestone: { ...mockMilestone, start_date: '1990-07-22' },
- });
- await nextTick();
+ it('renders milestone title', () => {
+ expect(wrapper.find('.milestone-title').text()).toBe(mockMilestone.title);
+ });
- expect(wrapper.vm.isMilestoneStarted).toBe(true);
- });
+ describe('tooltip', () => {
+ it('renders `Milestone`', () => {
+ expect(findTooltip().text()).toContain('Milestone');
});
- describe('isMilestonePastDue', () => {
- it('should return `false` when milestoneDue prop is not defined', async () => {
- wrapper.setProps({
- milestone: { ...mockMilestone, due_date: '' },
- });
- await nextTick();
-
- expect(wrapper.vm.isMilestonePastDue).toBe(false);
- });
-
- it('should return `true` when milestone due is past current date', () => {
- wrapper.setProps({
- milestone: { ...mockMilestone, due_date: '1990-07-22' },
- });
-
- expect(wrapper.vm.isMilestonePastDue).toBe(true);
- });
+ it('renders milestone title', () => {
+ expect(findTooltip().text()).toContain(mockMilestone.title);
});
- describe('milestoneDatesAbsolute', () => {
- it('returns string containing absolute milestone due date', () => {
- expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)');
- });
+ describe('humanized dates', () => {
+ it('renders `Expired` when there is a due date in the past', () => {
+ wrapper = createComponent({ ...mockMilestone, due_date: '2019-12-31', start_date: '' });
- it('returns string containing absolute milestone start date when due date is not present', async () => {
- wrapper.setProps({
- milestone: { ...mockMilestone, due_date: '' },
- });
- await nextTick();
-
- expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)');
+ expect(findTooltip().text()).toContain('Expired 6 months ago(December 31, 2019)');
});
- it('returns empty string when both milestone start and due dates are not present', async () => {
- wrapper.setProps({
- milestone: { ...mockMilestone, start_date: '', due_date: '' },
- });
- await nextTick();
+ it('renders `remaining` when there is a due date in the future', () => {
+ wrapper = createComponent({ ...mockMilestone, due_date: '2020-12-31', start_date: '' });
- expect(wrapper.vm.milestoneDatesAbsolute).toBe('');
+ expect(findTooltip().text()).toContain('5 months remaining(December 31, 2020)');
});
- });
- describe('milestoneDatesHuman', () => {
- it('returns string containing milestone due date when date is yet to be due', async () => {
- wrapper.setProps({
- milestone: { ...mockMilestone, due_date: `${new Date().getFullYear() + 10}-01-01` },
- });
- await nextTick();
+ it('renders `Started` when there is a start date in the past', () => {
+ wrapper = createComponent({ ...mockMilestone, due_date: '', start_date: '2019-12-31' });
- expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining');
+ expect(findTooltip().text()).toContain('Started 6 months ago(December 31, 2019)');
});
- it('returns string containing milestone start date when date has already started and due date is not present', async () => {
- wrapper.setProps({
- milestone: { ...mockMilestone, start_date: '1990-07-22', due_date: '' },
- });
- await nextTick();
+ it('renders `Starts` when there is a start date in the future', () => {
+ wrapper = createComponent({ ...mockMilestone, due_date: '', start_date: '2020-12-31' });
- expect(wrapper.vm.milestoneDatesHuman).toContain('Started');
+ expect(findTooltip().text()).toContain('Starts in 5 months(December 31, 2020)');
});
-
- it('returns string containing milestone start date when date is yet to start and due date is not present', async () => {
- wrapper.setProps({
- milestone: {
- ...mockMilestone,
- start_date: `${new Date().getFullYear() + 10}-01-01`,
- due_date: '',
- },
- });
- await nextTick();
-
- expect(wrapper.vm.milestoneDatesHuman).toContain('Starts');
- });
-
- it('returns empty string when milestone start and due dates are not present', async () => {
- wrapper.setProps({
- milestone: { ...mockMilestone, start_date: '', due_date: '' },
- });
- await nextTick();
-
- expect(wrapper.vm.milestoneDatesHuman).toBe('');
- });
- });
- });
-
- describe('template', () => {
- it('renders component root element with class `issue-milestone-details`', () => {
- expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true);
- });
-
- it('renders milestone icon', () => {
- expect(wrapper.findComponent(GlIcon).props('name')).toBe('clock');
- });
-
- it('renders milestone title', () => {
- expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title);
- });
-
- it('renders milestone tooltip', () => {
- expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain(
- mockMilestone.title,
- );
});
});
});
diff --git a/spec/frontend/issuable/components/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js
index 3f9f048605a..7322894164b 100644
--- a/spec/frontend/issuable/components/related_issuable_item_spec.js
+++ b/spec/frontend/issuable/components/related_issuable_item_spec.js
@@ -1,14 +1,18 @@
import { GlIcon, GlLink, GlButton } from '@gitlab/ui';
+import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { formatDate } from '~/lib/utils/datetime_utility';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import { stubComponent } from 'helpers/stub_component';
import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
import IssueMilestone from '~/issuable/components/issue_milestone.vue';
import IssueAssignees from '~/issuable/components/issue_assignees.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+import { mockWorkItemCommentNote } from 'jest/work_items/mock_data';
import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
@@ -18,9 +22,11 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('RelatedIssuableItem', () => {
let wrapper;
+ let showModalSpy;
const defaultProps = {
idKey: 1,
+ iid: 1,
displayReference: 'gitlab-org/gitlab-test#1',
pathIdSeparator: '#',
path: `${TEST_HOST}/path`,
@@ -40,23 +46,31 @@ describe('RelatedIssuableItem', () => {
const findRemoveButton = () => wrapper.findComponent(GlButton);
const findTitleLink = () => wrapper.findComponent(GlLink);
const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
function mountComponent({ data = {}, props = {} } = {}) {
+ showModalSpy = jest.fn();
wrapper = shallowMount(RelatedIssuableItem, {
propsData: {
...defaultProps,
...props,
},
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
+ stubs: {
+ WorkItemDetailModal: stubComponent(WorkItemDetailModal, {
+ methods: {
+ show: showModalSpy,
+ },
+ }),
+ },
data() {
return data;
},
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains issuable-info-container class when canReorder is false', () => {
mountComponent({ props: { canReorder: false } });
@@ -181,7 +195,7 @@ describe('RelatedIssuableItem', () => {
});
it('renders disabled button when removeDisabled', () => {
- expect(findRemoveButton().attributes('disabled')).toBe('true');
+ expect(findRemoveButton().attributes('disabled')).toBeDefined();
});
it('triggers onRemoveRequest when clicked', () => {
@@ -208,12 +222,15 @@ describe('RelatedIssuableItem', () => {
});
describe('work item modal', () => {
- const workItem = 'gid://gitlab/WorkItem/1';
+ const workItemId = 'gid://gitlab/WorkItem/1';
it('renders', () => {
mountComponent();
- expect(findWorkItemDetailModal().props('workItemId')).toBe(workItem);
+ expect(findWorkItemDetailModal().props()).toMatchObject({
+ workItemId,
+ workItemIid: '1',
+ });
});
describe('when work item is issue and the related issue title is clicked', () => {
@@ -240,7 +257,7 @@ describe('RelatedIssuableItem', () => {
it('updates the url params with the work item id', () => {
expect(updateHistory).toHaveBeenCalledWith({
- url: `${TEST_HOST}/?work_item_id=1`,
+ url: `${TEST_HOST}/?work_item_iid=1`,
replace: true,
});
});
@@ -250,9 +267,9 @@ describe('RelatedIssuableItem', () => {
it('emits "relatedIssueRemoveRequest" event', () => {
mountComponent();
- findWorkItemDetailModal().vm.$emit('workItemDeleted', workItem);
+ findWorkItemDetailModal().vm.$emit('workItemDeleted', workItemId);
- expect(wrapper.emitted('relatedIssueRemoveRequest')).toEqual([[workItem]]);
+ expect(wrapper.emitted('relatedIssueRemoveRequest')).toEqual([[workItemId]]);
});
});
@@ -269,4 +286,30 @@ describe('RelatedIssuableItem', () => {
});
});
});
+
+ describe('abuse category selector', () => {
+ beforeEach(() => {
+ mountComponent({ props: { workItemType: 'TASK' } });
+ findTitleLink().vm.$emit('click', { preventDefault: () => {} });
+ });
+
+ it('should not be visible by default', () => {
+ expect(showModalSpy).toHaveBeenCalled();
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+
+ it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
+ findWorkItemDetailModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote);
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(true);
+
+ findAbuseCategorySelector().vm.$emit('close-drawer');
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/issuable/components/status_box_spec.js b/spec/frontend/issuable/components/status_box_spec.js
index 728b8958b9b..d26f287d90c 100644
--- a/spec/frontend/issuable/components/status_box_spec.js
+++ b/spec/frontend/issuable/components/status_box_spec.js
@@ -11,11 +11,6 @@ function factory(propsData) {
describe('Merge request status box component', () => {
const findBadge = () => wrapper.findComponent(GlBadge);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe.each`
issuableType | badgeText | initialState | badgeClass | badgeVariant | badgeIcon
${'merge_request'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'merge-request-open'}
diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js
index 3e778e50fb8..d7e5f9083b0 100644
--- a/spec/frontend/issuable/issuable_form_spec.js
+++ b/spec/frontend/issuable/issuable_form_spec.js
@@ -3,11 +3,18 @@ import Autosave from '~/autosave';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import IssuableForm from '~/issuable/issuable_form';
import setWindowLocation from 'helpers/set_window_location_helper';
-
+import { confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
import { getSaveableFormChildren } from './helpers';
jest.mock('~/autosave');
+jest.mock('~/lib/utils/secret_detection', () => {
+ return {
+ ...jest.requireActual('~/lib/utils/secret_detection'),
+ confirmSensitiveAction: jest.fn(() => Promise.resolve(false)),
+ };
+});
+
const createIssuable = (form) => {
return new IssuableForm(form);
};
@@ -35,16 +42,13 @@ describe('IssuableForm', () => {
describe('autosave', () => {
let $title;
- let $description;
beforeEach(() => {
$title = $form.find('input[name*="[title]"]').get(0);
- $description = $form.find('textarea[name*="[description]"]').get(0);
});
afterEach(() => {
$title = null;
- $description = null;
});
describe('initAutosave', () => {
@@ -64,11 +68,6 @@ describe('IssuableForm', () => {
['/foo', 'bar=true', 'title'],
'autosave//foo/bar=true=title',
);
- expect(Autosave).toHaveBeenCalledWith(
- $description,
- ['/foo', 'bar=true', 'description'],
- 'autosave//foo/bar=true=description',
- );
});
it("creates autosave fields without the searchTerm if it's an issue new form", () => {
@@ -81,11 +80,6 @@ describe('IssuableForm', () => {
['/issues/new', '', 'title'],
'autosave//issues/new/bar=true=title',
);
- expect(Autosave).toHaveBeenCalledWith(
- $description,
- ['/issues/new', '', 'description'],
- 'autosave//issues/new/bar=true=description',
- );
});
it.each([
@@ -106,7 +100,9 @@ describe('IssuableForm', () => {
const children = getSaveableFormChildren($form[0]);
- expect(Autosave).toHaveBeenCalledTimes(children.length);
+ // description autosave is being handled separately
+ // hence we're using children.length - 1
+ expect(Autosave).toHaveBeenCalledTimes(children.length - 1);
expect(Autosave).toHaveBeenLastCalledWith(
$input.get(0),
['/', '', id],
@@ -116,13 +112,12 @@ describe('IssuableForm', () => {
});
describe('resetAutosave', () => {
- it('calls reset on title and description', () => {
+ it('calls reset on title', () => {
instance = createIssuable($form);
instance.resetAutosave();
expect(instance.autosaves.get('title').reset).toHaveBeenCalledTimes(1);
- expect(instance.autosaves.get('description').reset).toHaveBeenCalledTimes(1);
});
it('resets autosave when submit', () => {
@@ -245,4 +240,44 @@ describe('IssuableForm', () => {
);
});
});
+
+ describe('Checks for sensitive token', () => {
+ let issueDescription;
+ const sensitiveMessage = 'token: glpat-1234567890abcdefghij';
+
+ beforeEach(() => {
+ issueDescription = $form.find('textarea[name*="[description]"]').get(0);
+ });
+
+ afterEach(() => {
+ issueDescription = null;
+ });
+
+ it('submits the form when no token is present', () => {
+ issueDescription.value = 'sample message';
+
+ const handleSubmit = jest.spyOn(IssuableForm.prototype, 'handleSubmit');
+ const resetAutosave = jest.spyOn(IssuableForm.prototype, 'resetAutosave');
+ createIssuable($form);
+
+ $form.submit();
+
+ expect(handleSubmit).toHaveBeenCalled();
+ expect(resetAutosave).toHaveBeenCalled();
+ });
+
+ it('prevents form submission when token is present', () => {
+ issueDescription.value = sensitiveMessage;
+
+ const handleSubmit = jest.spyOn(IssuableForm.prototype, 'handleSubmit');
+ const resetAutosave = jest.spyOn(IssuableForm.prototype, 'resetAutosave');
+ createIssuable($form);
+
+ $form.submit();
+
+ expect(handleSubmit).toHaveBeenCalled();
+ expect(confirmSensitiveAction).toHaveBeenCalledWith(i18n.descriptionPrompt);
+ expect(resetAutosave).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/issuable/popover/components/issue_popover_spec.js b/spec/frontend/issuable/popover/components/issue_popover_spec.js
index 444165f61c7..a7605016039 100644
--- a/spec/frontend/issuable/popover/components/issue_popover_spec.js
+++ b/spec/frontend/issuable/popover/components/issue_popover_spec.js
@@ -33,10 +33,6 @@ describe('Issue Popover', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows skeleton-loader while apollo is loading', () => {
mountComponent();
diff --git a/spec/frontend/issuable/popover/components/mr_popover_spec.js b/spec/frontend/issuable/popover/components/mr_popover_spec.js
index 5fdd1e6e8fc..5b29ecfc0ba 100644
--- a/spec/frontend/issuable/popover/components/mr_popover_spec.js
+++ b/spec/frontend/issuable/popover/components/mr_popover_spec.js
@@ -71,10 +71,6 @@ describe('MR Popover', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows skeleton-loader while apollo is loading', () => {
mountComponent();
@@ -99,7 +95,7 @@ describe('MR Popover', () => {
expect(wrapper.text()).toContain('foo/bar!1');
});
- it('shows CI Icon if there is pipeline data', async () => {
+ it('shows CI Icon if there is pipeline data', () => {
expect(wrapper.findComponent(CiIcon).exists()).toBe(true);
});
});
@@ -112,7 +108,7 @@ describe('MR Popover', () => {
return waitForPromises();
});
- it('does not show CI icon if there is no pipeline data', async () => {
+ it('does not show CI icon if there is no pipeline data', () => {
expect(wrapper.findComponent(CiIcon).exists()).toBe(false);
});
});
diff --git a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
index 72fcab63ba7..f90b9117688 100644
--- a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
+++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
@@ -1,9 +1,9 @@
-import { GlFormGroup } from '@gitlab/ui';
+import { GlButton, GlFormGroup, GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import AddIssuableForm from '~/related_issues/components/add_issuable_form.vue';
import IssueToken from '~/related_issues/components/issue_token.vue';
+import RelatedIssuableInput from '~/related_issues/components/related_issuable_input.vue';
import { linkedIssueTypesMap, PathIdSeparator } from '~/related_issues/constants';
const issuable1 = {
@@ -26,71 +26,60 @@ const issuable2 = {
const pathIdSeparator = PathIdSeparator.Issue;
-const findFormInput = (wrapper) => wrapper.find('input').element;
-
-const findRadioInput = (inputs, value) =>
- inputs.filter((input) => input.element.value === value)[0];
-
-const findRadioInputs = (wrapper) => wrapper.findAll('[name="linked-issue-type-radio"]');
-
-const constructWrapper = (props) => {
- return shallowMount(AddIssuableForm, {
- propsData: {
- inputValue: '',
- pendingReferences: [],
- pathIdSeparator,
- ...props,
- },
- });
-};
-
describe('AddIssuableForm', () => {
let wrapper;
- afterEach(() => {
- // Jest doesn't blur an item even if it is destroyed,
- // so blur the input manually after each test
- const input = findFormInput(wrapper);
- if (input) input.blur();
+ const createComponent = (props = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(AddIssuableForm, {
+ propsData: {
+ inputValue: '',
+ pendingReferences: [],
+ pathIdSeparator,
+ ...props,
+ },
+ stubs: {
+ RelatedIssuableInput,
+ },
+ });
+ };
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
+ const findAddIssuableForm = () => wrapper.find('form');
+ const findFormInput = () => wrapper.find('input').element;
+ const findRadioInput = (inputs, value) =>
+ inputs.filter((input) => input.element.value === value)[0];
+ const findAllIssueTokens = () => wrapper.findAllComponents(IssueToken);
+ const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
+ const findRadioInputs = () => wrapper.findAllComponents(GlFormRadio);
+
+ const findFormGroup = () => wrapper.findComponent(GlFormGroup);
+ const findFormButtons = () => wrapper.findAllComponents(GlButton);
+ const findSubmitButton = () => findFormButtons().at(0);
+ const findRelatedIssuableInput = () => wrapper.findComponent(RelatedIssuableInput);
describe('with data', () => {
describe('without references', () => {
describe('without any input text', () => {
beforeEach(() => {
- wrapper = shallowMount(AddIssuableForm, {
- propsData: {
- inputValue: '',
- pendingReferences: [],
- pathIdSeparator,
- },
- });
+ createComponent();
});
it('should have disabled submit button', () => {
- expect(wrapper.vm.$refs.addButton.disabled).toBe(true);
- expect(wrapper.vm.$refs.loadingIcon).toBeUndefined();
+ expect(findSubmitButton().props('disabled')).toBe(true);
+ expect(findSubmitButton().props('loading')).toBe(false);
});
});
describe('with input text', () => {
beforeEach(() => {
- wrapper = shallowMount(AddIssuableForm, {
- propsData: {
- inputValue: 'foo',
- pendingReferences: [],
- pathIdSeparator,
- },
+ createComponent({
+ inputValue: 'foo',
+ pendingReferences: [],
+ pathIdSeparator,
});
});
it('should not have disabled submit button', () => {
- expect(wrapper.vm.$refs.addButton.disabled).toBe(false);
+ expect(findSubmitButton().props('disabled')).toBe(false);
});
});
});
@@ -99,59 +88,56 @@ describe('AddIssuableForm', () => {
const inputValue = 'foo #123';
beforeEach(() => {
- wrapper = mount(AddIssuableForm, {
- propsData: {
- inputValue,
- pendingReferences: [issuable1.reference, issuable2.reference],
- pathIdSeparator,
- },
+ createComponent({
+ inputValue,
+ pendingReferences: [issuable1.reference, issuable2.reference],
+ pathIdSeparator,
});
- });
+ }, mount);
it('should put input value in place', () => {
expect(findFormInput(wrapper).value).toBe(inputValue);
});
it('should render pending issuables items', () => {
- expect(wrapper.findAllComponents(IssueToken)).toHaveLength(2);
+ expect(findAllIssueTokens()).toHaveLength(2);
});
it('should not have disabled submit button', () => {
- expect(wrapper.vm.$refs.addButton.disabled).toBe(false);
+ expect(findSubmitButton().props('disabled')).toBe(false);
});
});
describe('when issuable type is "issue"', () => {
beforeEach(() => {
- wrapper = mount(AddIssuableForm, {
- propsData: {
+ createComponent(
+ {
inputValue: '',
issuableType: TYPE_ISSUE,
pathIdSeparator,
pendingReferences: [],
},
- });
+ mount,
+ );
});
it('does not show radio inputs', () => {
- expect(findRadioInputs(wrapper).length).toBe(0);
+ expect(findRadioInputs()).toHaveLength(0);
});
});
describe('when issuable type is "epic"', () => {
beforeEach(() => {
- wrapper = shallowMount(AddIssuableForm, {
- propsData: {
- inputValue: '',
- issuableType: TYPE_EPIC,
- pathIdSeparator,
- pendingReferences: [],
- },
+ createComponent({
+ inputValue: '',
+ issuableType: TYPE_EPIC,
+ pathIdSeparator,
+ pendingReferences: [],
});
});
it('does not show radio inputs', () => {
- expect(findRadioInputs(wrapper).length).toBe(0);
+ expect(findRadioInputs()).toHaveLength(0);
});
});
@@ -163,17 +149,15 @@ describe('AddIssuableForm', () => {
`(
'show header text as "$contextHeader" and footer text as "$contextFooter" issuableType is set to $issuableType',
({ issuableType, contextHeader, contextFooter }) => {
- wrapper = shallowMount(AddIssuableForm, {
- propsData: {
- issuableType,
- inputValue: '',
- showCategorizedIssues: true,
- pathIdSeparator,
- pendingReferences: [],
- },
+ createComponent({
+ issuableType,
+ inputValue: '',
+ showCategorizedIssues: true,
+ pathIdSeparator,
+ pendingReferences: [],
});
- expect(wrapper.findComponent(GlFormGroup).attributes('label')).toBe(contextHeader);
+ expect(findFormGroup().attributes('label')).toBe(contextHeader);
expect(wrapper.find('p.bold').text()).toContain(contextFooter);
},
);
@@ -181,26 +165,24 @@ describe('AddIssuableForm', () => {
describe('when it is a Linked Issues form', () => {
beforeEach(() => {
- wrapper = mount(AddIssuableForm, {
- propsData: {
- inputValue: '',
- showCategorizedIssues: true,
- issuableType: TYPE_ISSUE,
- pathIdSeparator,
- pendingReferences: [],
- },
+ createComponent({
+ inputValue: '',
+ showCategorizedIssues: true,
+ issuableType: TYPE_ISSUE,
+ pathIdSeparator,
+ pendingReferences: [],
});
});
it('shows radio inputs to allow categorisation of blocking issues', () => {
- expect(findRadioInputs(wrapper).length).toBeGreaterThan(0);
+ expect(findRadioGroup().props('options').length).toBeGreaterThan(0);
});
describe('form radio buttons', () => {
let radioInputs;
beforeEach(() => {
- radioInputs = findRadioInputs(wrapper);
+ radioInputs = findRadioInputs();
});
it('shows "relates to" option', () => {
@@ -216,58 +198,59 @@ describe('AddIssuableForm', () => {
});
it('shows 3 options in total', () => {
- expect(radioInputs.length).toBe(3);
+ expect(findRadioGroup().props('options')).toHaveLength(3);
});
});
describe('when the form is submitted', () => {
- it('emits an event with a "relates_to" link type when the "relates to" radio input selected', async () => {
- jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {});
-
- wrapper.vm.linkedIssueType = linkedIssueTypesMap.RELATES_TO;
- wrapper.vm.onFormSubmit();
-
- await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
- pendingReferences: '',
- linkedIssueType: linkedIssueTypesMap.RELATES_TO,
- });
+ it('emits an event with a "relates_to" link type when the "relates to" radio input selected', () => {
+ findAddIssuableForm().trigger('submit');
+
+ expect(wrapper.emitted('addIssuableFormSubmit')).toEqual([
+ [
+ {
+ pendingReferences: '',
+ linkedIssueType: linkedIssueTypesMap.RELATES_TO,
+ },
+ ],
+ ]);
});
- it('emits an event with a "blocks" link type when the "blocks" radio input selected', async () => {
- jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {});
-
- wrapper.vm.linkedIssueType = linkedIssueTypesMap.BLOCKS;
- wrapper.vm.onFormSubmit();
-
- await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
- pendingReferences: '',
- linkedIssueType: linkedIssueTypesMap.BLOCKS,
- });
+ it('emits an event with a "blocks" link type when the "blocks" radio input selected', () => {
+ findRadioGroup().vm.$emit('input', linkedIssueTypesMap.BLOCKS);
+ findAddIssuableForm().trigger('submit');
+
+ expect(wrapper.emitted('addIssuableFormSubmit')).toEqual([
+ [
+ {
+ pendingReferences: '',
+ linkedIssueType: linkedIssueTypesMap.BLOCKS,
+ },
+ ],
+ ]);
});
- it('emits an event with a "is_blocked_by" link type when the "is blocked by" radio input selected', async () => {
- jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {});
-
- wrapper.vm.linkedIssueType = linkedIssueTypesMap.IS_BLOCKED_BY;
- wrapper.vm.onFormSubmit();
-
- await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
- pendingReferences: '',
- linkedIssueType: linkedIssueTypesMap.IS_BLOCKED_BY,
- });
+ it('emits an event with a "is_blocked_by" link type when the "is blocked by" radio input selected', () => {
+ findRadioGroup().vm.$emit('input', linkedIssueTypesMap.IS_BLOCKED_BY);
+ findAddIssuableForm().trigger('submit');
+
+ expect(wrapper.emitted('addIssuableFormSubmit')).toEqual([
+ [
+ {
+ pendingReferences: '',
+ linkedIssueType: linkedIssueTypesMap.IS_BLOCKED_BY,
+ },
+ ],
+ ]);
});
- it('shows error message when error is present', async () => {
+ it('shows error message when error is present', () => {
const itemAddFailureMessage = 'Something went wrong while submitting.';
- wrapper.setProps({
+ createComponent({
hasError: true,
itemAddFailureMessage,
});
- await nextTick();
expect(wrapper.find('.gl-field-error').exists()).toBe(true);
expect(wrapper.find('.gl-field-error').text()).toContain(itemAddFailureMessage);
});
@@ -283,27 +266,31 @@ describe('AddIssuableForm', () => {
};
it('returns autocomplete object', () => {
- wrapper = constructWrapper({
+ createComponent({
autoCompleteSources,
});
- expect(wrapper.vm.transformedAutocompleteSources).toBe(autoCompleteSources);
+ expect(findRelatedIssuableInput().props('autoCompleteSources')).toEqual(
+ autoCompleteSources,
+ );
- wrapper = constructWrapper({
+ createComponent({
autoCompleteSources,
confidential: false,
});
- expect(wrapper.vm.transformedAutocompleteSources).toBe(autoCompleteSources);
+ expect(findRelatedIssuableInput().props('autoCompleteSources')).toEqual(
+ autoCompleteSources,
+ );
});
it('returns autocomplete sources with query `confidential_only`, when it is confidential', () => {
- wrapper = constructWrapper({
+ createComponent({
autoCompleteSources,
confidential: true,
});
- const actualSources = wrapper.vm.transformedAutocompleteSources;
+ const actualSources = findRelatedIssuableInput().props('autoCompleteSources');
expect(actualSources.epics).toContain('?confidential_only=true');
expect(actualSources.issues).toContain('?confidential_only=true');
diff --git a/spec/frontend/issuable/related_issues/components/issue_token_spec.js b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
index bacebbade7f..4f2a96306e3 100644
--- a/spec/frontend/issuable/related_issues/components/issue_token_spec.js
+++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
@@ -24,13 +24,6 @@ describe('IssueToken', () => {
});
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findLink = () => wrapper.findComponent({ ref: 'link' });
const findReference = () => wrapper.findComponent({ ref: 'reference' });
const findReferenceIcon = () => wrapper.find('[data-testid="referenceIcon"]');
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
index ff8d5073005..e97c0312181 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
@@ -1,5 +1,5 @@
import { nextTick } from 'vue';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlCard } from '@gitlab/ui';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
issuable1,
@@ -22,21 +22,44 @@ describe('RelatedIssuesBlock', () => {
const findRelatedIssuesBody = () => wrapper.findByTestId('related-issues-body');
const findIssueCountBadgeAddButton = () => wrapper.findByTestId('related-issues-plus-button');
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
+ const createComponent = ({
+ mountFn = mountExtended,
+ pathIdSeparator = PathIdSeparator.Issue,
+ issuableType = TYPE_ISSUE,
+ canAdmin = false,
+ helpPath = '',
+ isFetching = false,
+ isFormVisible = false,
+ relatedIssues = [],
+ showCategorizedIssues = false,
+ autoCompleteEpics = true,
+ slots = '',
+ } = {}) => {
+ wrapper = mountFn(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator,
+ issuableType,
+ canAdmin,
+ helpPath,
+ isFetching,
+ isFormVisible,
+ relatedIssues,
+ showCategorizedIssues,
+ autoCompleteEpics,
+ },
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
+ stubs: {
+ GlCard,
+ },
+ slots,
+ });
+ };
describe('with defaults', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: TYPE_ISSUE,
- },
- });
+ createComponent();
});
it.each`
@@ -46,13 +69,11 @@ describe('RelatedIssuesBlock', () => {
`(
'displays "$titleText" in the header and "$addButtonText" aria-label for add button when issuableType is set to "$issuableType"',
({ issuableType, pathIdSeparator, titleText, addButtonText }) => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator,
- issuableType,
- canAdmin: true,
- helpPath: '/help/user/project/issues/related_issues',
- },
+ createComponent({
+ pathIdSeparator,
+ issuableType,
+ canAdmin: true,
+ helpPath: '/help/user/project/issues/related_issues',
});
expect(wrapper.find('.card-title').text()).toContain(titleText);
@@ -73,11 +94,8 @@ describe('RelatedIssuesBlock', () => {
it('displays header text slot data', () => {
const headerText = '<div>custom header text</div>';
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- },
+ createComponent({
+ mountFn: shallowMountExtended,
slots: { 'header-text': headerText },
});
@@ -89,11 +107,8 @@ describe('RelatedIssuesBlock', () => {
it('displays header actions slot data', () => {
const headerActions = '<button data-testid="custom-button">custom button</button>';
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- },
+ createComponent({
+ mountFn: shallowMountExtended,
slots: { 'header-actions': headerActions },
});
@@ -103,12 +118,8 @@ describe('RelatedIssuesBlock', () => {
describe('with isFetching=true', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- isFetching: true,
- issuableType: 'issue',
- },
+ createComponent({
+ isFetching: true,
});
});
@@ -119,13 +130,7 @@ describe('RelatedIssuesBlock', () => {
describe('with canAddRelatedIssues=true', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- canAdmin: true,
- issuableType: 'issue',
- },
- });
+ createComponent({ canAdmin: true });
});
it('can add new related issues', () => {
@@ -135,14 +140,7 @@ describe('RelatedIssuesBlock', () => {
describe('with isFormVisible=true', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- isFormVisible: true,
- issuableType: 'issue',
- autoCompleteEpics: false,
- },
- });
+ createComponent({ isFormVisible: true, autoCompleteEpics: false });
});
it('shows add related issues form', () => {
@@ -158,19 +156,14 @@ describe('RelatedIssuesBlock', () => {
const issueList = () => wrapper.findAll('.js-related-issues-token-list-item');
const categorizedHeadings = () => wrapper.findAll('h4');
const headingTextAt = (index) => categorizedHeadings().at(index).text();
- const mountComponent = (showCategorizedIssues) => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1, issuable2, issuable3],
- issuableType: 'issue',
- showCategorizedIssues,
- },
- });
- };
describe('when showCategorizedIssues=true', () => {
- beforeEach(() => mountComponent(true));
+ beforeEach(() =>
+ createComponent({
+ showCategorizedIssues: true,
+ relatedIssues: [issuable1, issuable2, issuable3],
+ }),
+ );
it('should render issue tokens items', () => {
expect(issueList()).toHaveLength(3);
@@ -197,8 +190,10 @@ describe('RelatedIssuesBlock', () => {
describe('when showCategorizedIssues=false', () => {
it('should render issues as a flat list with no header', () => {
- mountComponent(false);
-
+ createComponent({
+ showCategorizedIssues: false,
+ relatedIssues: [issuable1, issuable2, issuable3],
+ });
expect(issueList()).toHaveLength(3);
expect(categorizedHeadings()).toHaveLength(0);
});
@@ -217,11 +212,8 @@ describe('RelatedIssuesBlock', () => {
},
].forEach(({ issuableType, icon }) => {
it(`issuableType=${issuableType} is passed`, () => {
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType,
- },
+ createComponent({
+ issuableType,
});
const iconComponent = wrapper.findComponent(GlIcon);
@@ -233,12 +225,8 @@ describe('RelatedIssuesBlock', () => {
describe('toggle', () => {
beforeEach(() => {
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1, issuable2, issuable3],
- issuableType: TYPE_ISSUE,
- },
+ createComponent({
+ relatedIssues: [issuable1, issuable2, issuable3],
});
});
@@ -268,14 +256,12 @@ describe('RelatedIssuesBlock', () => {
`(
'displays "$emptyText" in the body and "$helpLinkText" aria-label for help link',
({ issuableType, pathIdSeparator, showCategorizedIssues, emptyText, helpLinkText }) => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator,
- issuableType,
- canAdmin: true,
- helpPath: '/help/user/project/issues/related_issues',
- showCategorizedIssues,
- },
+ createComponent({
+ pathIdSeparator,
+ issuableType,
+ canAdmin: true,
+ helpPath: '/help/user/project/issues/related_issues',
+ showCategorizedIssues,
});
expect(wrapper.findByTestId('related-issues-body').text()).toContain(emptyText);
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
index 9bb71ec3dcb..592dc19f0ea 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
@@ -13,25 +13,35 @@ import { PathIdSeparator } from '~/related_issues/constants';
describe('RelatedIssuesList', () => {
let wrapper;
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
+ const createComponent = ({
+ mountFn = shallowMount,
+ pathIdSeparator = PathIdSeparator.Issue,
+ issuableType = 'issue',
+ listLinkType = 'relates_to',
+ heading = '',
+ isFetching = false,
+ relatedIssues = [],
+ } = {}) => {
+ wrapper = mountFn(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator,
+ issuableType,
+ listLinkType,
+ heading,
+ isFetching,
+ relatedIssues,
+ },
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
+ });
+ };
describe('with defaults', () => {
const heading = 'Related to';
beforeEach(() => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- listLinkType: 'relates_to',
- heading,
- },
- });
+ createComponent({ heading });
});
it('assigns value of listLinkType prop to data attribute', () => {
@@ -49,13 +59,7 @@ describe('RelatedIssuesList', () => {
describe('with isFetching=true', () => {
beforeEach(() => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- isFetching: true,
- issuableType: 'issue',
- },
- });
+ createComponent({ isFetching: true });
});
it('should show loading icon', () => {
@@ -65,13 +69,7 @@ describe('RelatedIssuesList', () => {
describe('methods', () => {
beforeEach(() => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5],
- issuableType: 'issue',
- },
- });
+ createComponent({ relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5] });
});
it('updates the order correctly when an item is moved to the top', () => {
@@ -112,23 +110,17 @@ describe('RelatedIssuesList', () => {
});
describe('issuableOrderingId returns correct issuable order id when', () => {
- it('issuableType is epic', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- },
+ it('issuableType is issue', () => {
+ createComponent({
+ issuableType: 'issue',
});
expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.epicIssueId);
});
- it('issuableType is issue', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'epic',
- },
+ it('issuableType is epic', () => {
+ createComponent({
+ issuableType: 'epic',
});
expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.id);
@@ -143,12 +135,9 @@ describe('RelatedIssuesList', () => {
});
it('issuableType is epic', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'epic',
- relatedIssues,
- },
+ createComponent({
+ issuableType: 'epic',
+ relatedIssues,
});
const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
@@ -159,12 +148,9 @@ describe('RelatedIssuesList', () => {
});
it('issuableType is issue', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- relatedIssues,
- },
+ createComponent({
+ issuableType: 'issue',
+ relatedIssues,
});
const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
@@ -177,13 +163,7 @@ describe('RelatedIssuesList', () => {
describe('related item contents', () => {
beforeAll(() => {
- wrapper = mount(RelatedIssuesList, {
- propsData: {
- issuableType: 'issue',
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1],
- },
- });
+ createComponent({ mountFn: mount, relatedIssues: [issuable1] });
});
it('shows due date', () => {
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
index 96c0b87e2cb..b119c836411 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
@@ -7,7 +7,7 @@ import {
issuable1,
issuable2,
} from 'jest/issuable/components/related_issuable_mock_data';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import {
HTTP_STATUS_CONFLICT,
@@ -19,7 +19,7 @@ import RelatedIssuesBlock from '~/related_issues/components/related_issues_block
import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
import relatedIssuesService from '~/related_issues/services/related_issues_service';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('RelatedIssuesRoot', () => {
let wrapper;
@@ -34,7 +34,6 @@ describe('RelatedIssuesRoot', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
const createComponent = ({ props = {}, data = {} } = {}) => {
@@ -43,6 +42,9 @@ describe('RelatedIssuesRoot', () => {
...defaultProps,
...props,
},
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
data() {
return data;
},
diff --git a/spec/frontend/issues/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js
index cc2ee84348a..21ae844e2dd 100644
--- a/spec/frontend/issues/create_merge_request_dropdown_spec.js
+++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js
@@ -65,6 +65,14 @@ describe('CreateMergeRequestDropdown', () => {
expect(dropdown.createMrPath).toBe(
`${TEST_HOST}/create_merge_request?merge_request%5Bsource_branch%5D=contains%23hash&merge_request%5Btarget_branch%5D=master&merge_request%5Bissue_iid%5D=42`,
);
+
+ expect(dropdown.wrapperEl.dataset.createBranchPath).toBe(
+ `${TEST_HOST}/branches?branch_name=contains%23hash&issue=42`,
+ );
+
+ expect(dropdown.wrapperEl.dataset.createMrPath).toBe(
+ `${TEST_HOST}/create_merge_request?merge_request%5Bsource_branch%5D=contains%23hash&merge_request%5Btarget_branch%5D=master&merge_request%5Bissue_iid%5D=42`,
+ );
});
});
diff --git a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js
index 77d5a0579a4..c152a5ef9a8 100644
--- a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js
+++ b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js
@@ -1,4 +1,4 @@
-import { GlEmptyState } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlEmptyState } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import AxiosMockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
@@ -18,6 +18,7 @@ import {
setSortPreferenceMutationResponse,
setSortPreferenceMutationResponseWithErrors,
} from 'jest/issues/list/mock_data';
+import { STATUS_ALL, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import IssuesDashboardApp from '~/issues/dashboard/components/issues_dashboard_app.vue';
import getIssuesCountsQuery from '~/issues/dashboard/queries/get_issues_counts.query.graphql';
import { CREATED_DESC, i18n, UPDATED_DESC, urlSortParams } from '~/issues/list/constants';
@@ -36,7 +37,6 @@ import {
TOKEN_TYPE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
-import { IssuableStates } from '~/vue_shared/issuable/list/constants';
import {
emptyIssuesQueryResponse,
issuesCountsQueryResponse,
@@ -78,6 +78,7 @@ describe('IssuesDashboardApp component', () => {
}
const findCalendarButton = () => wrapper.findByRole('link', { name: i18n.calendarLabel });
+ const findDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findIssueCardStatistics = () => wrapper.findComponent(IssueCardStatistics);
@@ -113,18 +114,17 @@ describe('IssuesDashboardApp component', () => {
});
describe('UI components', () => {
- beforeEach(() => {
+ beforeEach(async () => {
setWindowLocation(locationSearch);
mountComponent();
- jest.runOnlyPendingTimers();
- return waitForPromises();
+ await waitForPromises();
});
// https://gitlab.com/gitlab-org/gitlab/-/issues/391722
// eslint-disable-next-line jest/no-disabled-tests
it.skip('renders IssuableList component', () => {
expect(findIssuableList().props()).toMatchObject({
- currentTab: IssuableStates.Opened,
+ currentTab: STATUS_OPEN,
hasNextPage: true,
hasPreviousPage: false,
hasScopedLabelsFeature: defaultProvide.hasScopedLabelsFeature,
@@ -145,21 +145,33 @@ describe('IssuesDashboardApp component', () => {
closed: 2,
all: 3,
},
- tabs: IssuesDashboardApp.IssuableListTabs,
+ tabs: IssuesDashboardApp.issuableListTabs,
urlParams: {
sort: urlSortParams[CREATED_DESC],
- state: IssuableStates.Opened,
+ state: STATUS_OPEN,
},
useKeysetPagination: true,
});
});
- it('renders RSS button link', () => {
- expect(findRssButton().attributes('href')).toBe(defaultProvide.rssPath);
- });
+ describe('actions dropdown', () => {
+ it('renders', () => {
+ expect(findDisclosureDropdown().props()).toMatchObject({
+ category: 'tertiary',
+ icon: 'ellipsis_v',
+ noCaret: true,
+ textSrOnly: true,
+ toggleText: 'Actions',
+ });
+ });
- it('renders calendar button link', () => {
- expect(findCalendarButton().attributes('href')).toBe(defaultProvide.calendarPath);
+ it('renders RSS button link', () => {
+ expect(findRssButton().attributes('href')).toBe(defaultProvide.rssPath);
+ });
+
+ it('renders calendar button link', () => {
+ expect(findCalendarButton().attributes('href')).toBe(defaultProvide.calendarPath);
+ });
});
it('renders issue time information', () => {
@@ -174,11 +186,10 @@ describe('IssuesDashboardApp component', () => {
describe('fetching issues', () => {
describe('with a search query', () => {
describe('when there are issues returned', () => {
- beforeEach(() => {
+ beforeEach(async () => {
setWindowLocation(locationSearch);
mountComponent();
- jest.runOnlyPendingTimers();
- return waitForPromises();
+ await waitForPromises();
});
it('renders the issues', () => {
@@ -193,12 +204,12 @@ describe('IssuesDashboardApp component', () => {
});
describe('when there are no issues returned', () => {
- beforeEach(() => {
+ beforeEach(async () => {
setWindowLocation(locationSearch);
mountComponent({
issuesQueryHandler: jest.fn().mockResolvedValue(emptyIssuesQueryResponse),
});
- return waitForPromises();
+ await waitForPromises();
});
it('renders no issues', () => {
@@ -218,10 +229,10 @@ describe('IssuesDashboardApp component', () => {
describe('with no search query', () => {
let issuesQueryHandler;
- beforeEach(() => {
+ beforeEach(async () => {
issuesQueryHandler = jest.fn().mockResolvedValue(defaultQueryResponse);
mountComponent({ issuesQueryHandler });
- return waitForPromises();
+ await waitForPromises();
});
it('does not call issues query', () => {
@@ -283,7 +294,7 @@ describe('IssuesDashboardApp component', () => {
describe('state', () => {
it('is set from the url params', () => {
- const initialState = IssuableStates.All;
+ const initialState = STATUS_ALL;
setWindowLocation(`?state=${initialState}`);
mountComponent();
@@ -307,11 +318,10 @@ describe('IssuesDashboardApp component', () => {
${'fetching issues'} | ${'issuesQueryHandler'} | ${i18n.errorFetchingIssues}
${'fetching issue counts'} | ${'issuesCountsQueryHandler'} | ${i18n.errorFetchingCounts}
`('when there is an error $error', ({ mountOption, message }) => {
- beforeEach(() => {
+ beforeEach(async () => {
setWindowLocation(locationSearch);
mountComponent({ [mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')) });
- jest.runOnlyPendingTimers();
- return waitForPromises();
+ await waitForPromises();
});
it('shows an error message', () => {
@@ -337,11 +347,9 @@ describe('IssuesDashboardApp component', () => {
username: 'root',
avatar_url: 'avatar/url',
};
- const originalGon = window.gon;
beforeEach(() => {
window.gon = {
- ...originalGon,
current_user_id: mockCurrentUser.id,
current_user_fullname: mockCurrentUser.name,
current_username: mockCurrentUser.username,
@@ -350,10 +358,6 @@ describe('IssuesDashboardApp component', () => {
mountComponent();
});
- afterEach(() => {
- window.gon = originalGon;
- });
-
it('renders all tokens alphabetically', () => {
const preloadedUsers = [{ ...mockCurrentUser, id: mockCurrentUser.id }];
@@ -375,16 +379,16 @@ describe('IssuesDashboardApp component', () => {
beforeEach(() => {
mountComponent();
- findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
+ findIssuableList().vm.$emit('click-tab', STATUS_CLOSED);
});
it('updates ui to the new tab', () => {
- expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
+ expect(findIssuableList().props('currentTab')).toBe(STATUS_CLOSED);
});
it('updates url to the new tab', () => {
expect(findIssuableList().props('urlParams')).toMatchObject({
- state: IssuableStates.Closed,
+ state: STATUS_CLOSED,
});
});
});
diff --git a/spec/frontend/issues/issue_spec.js b/spec/frontend/issues/issue_spec.js
index f04e766a78c..3b8a09714a7 100644
--- a/spec/frontend/issues/issue_spec.js
+++ b/spec/frontend/issues/issue_spec.js
@@ -1,6 +1,8 @@
import { getByText } from '@testing-library/dom';
+import htmlOpenIssue from 'test_fixtures/issues/open-issue.html';
+import htmlClosedIssue from 'test_fixtures/issues/closed-issue.html';
import MockAdapter from 'axios-mock-adapter';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import Issue from '~/issues/issue';
import axios from '~/lib/utils/axios_utils';
@@ -40,9 +42,9 @@ describe('Issue', () => {
`('$desc', ({ isIssueInitiallyOpen, expectedCounterText }) => {
beforeEach(() => {
if (isIssueInitiallyOpen) {
- loadHTMLFixture('issues/open-issue.html');
+ setHTMLFixture(htmlOpenIssue);
} else {
- loadHTMLFixture('issues/closed-issue.html');
+ setHTMLFixture(htmlClosedIssue);
}
testContext.issueCounter = getIssueCounter();
diff --git a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js
index 0a2e4e7c671..4ea3a39f15b 100644
--- a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js
+++ b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js
@@ -1,4 +1,4 @@
-import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlDropdown, GlEmptyState, GlLink } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue';
@@ -26,6 +26,7 @@ describe('EmptyStateWithoutAnyIssues component', () => {
};
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
+ const findCsvImportExportDropdown = () => wrapper.findComponent(GlDropdown);
const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuesHelpPageLink = () =>
@@ -135,6 +136,7 @@ describe('EmptyStateWithoutAnyIssues component', () => {
it('renders', () => {
mountComponent({ props: { showCsvButtons: true } });
+ expect(findCsvImportExportDropdown().props('text')).toBe('Import issues');
expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: defaultProps.exportCsvPathWithQuery,
issuableCount: 0,
@@ -146,6 +148,7 @@ describe('EmptyStateWithoutAnyIssues component', () => {
it('does not render', () => {
mountComponent({ props: { showCsvButtons: false } });
+ expect(findCsvImportExportDropdown().exists()).toBe(false);
expect(findCsvImportExportButtons().exists()).toBe(false);
});
});
diff --git a/spec/frontend/issues/list/components/issue_card_time_info_spec.js b/spec/frontend/issues/list/components/issue_card_time_info_spec.js
index ab4d023ee39..e80ffea0591 100644
--- a/spec/frontend/issues/list/components/issue_card_time_info_spec.js
+++ b/spec/frontend/issues/list/components/issue_card_time_info_spec.js
@@ -45,10 +45,6 @@ describe('CE IssueCardTimeInfo component', () => {
},
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('milestone', () => {
it('renders', () => {
wrapper = mountComponent();
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 8281ce0ed1a..af24b547545 100644
--- a/spec/frontend/issues/list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -1,4 +1,4 @@
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlDropdown } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
@@ -11,23 +11,26 @@ import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_coun
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
getIssuesCountsQueryResponse,
getIssuesQueryResponse,
+ getIssuesQueryEmptyResponse,
filteredTokens,
locationSearch,
setSortPreferenceMutationResponse,
setSortPreferenceMutationResponseWithErrors,
urlParams,
} from 'jest/issues/list/mock_data';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { TYPENAME_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { STATUS_ALL, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
-import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+import { issuableListTabs } from '~/vue_shared/issuable/list/constants';
import EmptyStateWithAnyIssues from '~/issues/list/components/empty_state_with_any_issues.vue';
import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue';
import IssuesListApp from '~/issues/list/components/issues_list_app.vue';
@@ -70,7 +73,7 @@ import('~/issuable');
import('~/users_select');
jest.mock('@sentry/browser');
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/scroll_utils', () => ({ scrollUp: jest.fn() }));
describe('CE IssuesListApp component', () => {
@@ -124,12 +127,16 @@ describe('CE IssuesListApp component', () => {
const mockIssuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse);
const mockIssuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse);
+ const findCalendarButton = () =>
+ wrapper.findByRole('menuitem', { name: IssuesListApp.i18n.calendarLabel });
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
+ const findGlButton = () => wrapper.findComponent(GlButton);
const findGlButtons = () => wrapper.findAllComponents(GlButton);
- const findGlButtonAt = (index) => findGlButtons().at(index);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findNewResourceDropdown = () => wrapper.findComponent(NewResourceDropdown);
+ const findRssButton = () => wrapper.findByRole('menuitem', { name: IssuesListApp.i18n.rssLabel });
const findLabelsToken = () =>
findIssuableList()
@@ -154,7 +161,24 @@ describe('CE IssuesListApp component', () => {
router = new VueRouter({ mode: 'history' });
return mountFn(IssuesListApp, {
- apolloProvider: createMockApollo(requestHandlers),
+ apolloProvider: createMockApollo(
+ requestHandlers,
+ {},
+ {
+ typePolicies: {
+ Query: {
+ fields: {
+ project: {
+ merge: true,
+ },
+ group: {
+ merge: true,
+ },
+ },
+ },
+ },
+ },
+ ),
router,
provide: {
...defaultProvide,
@@ -174,17 +198,15 @@ describe('CE IssuesListApp component', () => {
afterEach(() => {
axiosMock.reset();
- wrapper.destroy();
});
describe('IssuableList', () => {
beforeEach(() => {
wrapper = mountComponent();
- jest.runOnlyPendingTimers();
return waitForPromises();
});
- it('renders', async () => {
+ it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: defaultProvide.fullPath,
recentSearchesStorageKey: 'issues',
@@ -196,8 +218,8 @@ describe('CE IssuesListApp component', () => {
}),
initialSortBy: CREATED_DESC,
issuables: getIssuesQueryResponse.data.project.issues.nodes,
- tabs: IssuableListTabs,
- currentTab: IssuableStates.Opened,
+ tabs: issuableListTabs,
+ currentTab: STATUS_OPEN,
tabCounts: {
opened: 1,
closed: 1,
@@ -215,64 +237,66 @@ describe('CE IssuesListApp component', () => {
});
describe('header action buttons', () => {
- it('renders rss button', async () => {
- wrapper = mountComponent({ mountFn: mount });
- await waitForPromises();
+ describe('actions dropdown', () => {
+ it('renders', () => {
+ wrapper = mountComponent({ mountFn: mount });
- expect(findGlButtonAt(0).props('icon')).toBe('rss');
- expect(findGlButtonAt(0).attributes()).toMatchObject({
- href: defaultProvide.rssPath,
- 'aria-label': IssuesListApp.i18n.rssLabel,
+ expect(findDropdown().props()).toMatchObject({
+ category: 'tertiary',
+ icon: 'ellipsis_v',
+ text: 'Actions',
+ textSrOnly: true,
+ });
});
- });
- it('renders calendar button', async () => {
- wrapper = mountComponent({ mountFn: mount });
- await waitForPromises();
+ describe('csv import/export buttons', () => {
+ describe('when user is signed in', () => {
+ beforeEach(() => {
+ setWindowLocation('?search=refactor&state=opened');
- expect(findGlButtonAt(1).props('icon')).toBe('calendar');
- expect(findGlButtonAt(1).attributes()).toMatchObject({
- href: defaultProvide.calendarPath,
- 'aria-label': IssuesListApp.i18n.calendarLabel,
- });
- });
+ wrapper = mountComponent({
+ provide: { initialSortBy: CREATED_DESC, isSignedIn: true },
+ mountFn: mount,
+ });
- describe('csv import/export component', () => {
- describe('when user is signed in', () => {
- beforeEach(() => {
- setWindowLocation('?search=refactor&state=opened');
+ return waitForPromises();
+ });
- wrapper = mountComponent({
- provide: { initialSortBy: CREATED_DESC, isSignedIn: true },
- mountFn: mount,
+ it('renders', () => {
+ expect(findCsvImportExportButtons().props()).toMatchObject({
+ exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&state=opened`,
+ issuableCount: 1,
+ });
});
+ });
- jest.runOnlyPendingTimers();
- return waitForPromises();
+ describe('when user is not signed in', () => {
+ it('does not render', () => {
+ wrapper = mountComponent({ provide: { isSignedIn: false }, mountFn: mount });
+
+ expect(findCsvImportExportButtons().exists()).toBe(false);
+ });
});
- it('renders', () => {
- expect(findCsvImportExportButtons().props()).toMatchObject({
- exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&state=opened`,
- issuableCount: 1,
+ describe('when in a group context', () => {
+ it('does not render', () => {
+ wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount });
+
+ expect(findCsvImportExportButtons().exists()).toBe(false);
});
});
});
- describe('when user is not signed in', () => {
- it('does not render', () => {
- wrapper = mountComponent({ provide: { isSignedIn: false }, mountFn: mount });
+ it('renders RSS button link', () => {
+ wrapper = mountComponent({ mountFn: mountExtended });
- expect(findCsvImportExportButtons().exists()).toBe(false);
- });
+ expect(findRssButton().attributes('href')).toBe(defaultProvide.rssPath);
});
- describe('when in a group context', () => {
- it('does not render', () => {
- wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount });
+ it('renders calendar button link', () => {
+ wrapper = mountComponent({ mountFn: mountExtended });
- expect(findCsvImportExportButtons().exists()).toBe(false);
- });
+ expect(findCalendarButton().attributes('href')).toBe(defaultProvide.calendarPath);
});
});
@@ -280,20 +304,20 @@ describe('CE IssuesListApp component', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount });
- expect(findGlButtonAt(2).text()).toBe('Edit issues');
+ expect(findGlButton().text()).toBe('Bulk edit');
});
it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: false }, mountFn: mount });
- expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0);
+ expect(findGlButtons().filter((button) => button.text() === 'Bulk edit')).toHaveLength(0);
});
it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', async () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount });
jest.spyOn(eventHub, '$emit');
- findGlButtonAt(2).vm.$emit('click');
+ findGlButton().vm.$emit('click');
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit');
@@ -304,8 +328,8 @@ describe('CE IssuesListApp component', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: true }, mountFn: mount });
- expect(findGlButtonAt(2).text()).toBe('New issue');
- expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath);
+ expect(findGlButton().text()).toBe('New issue');
+ expect(findGlButton().attributes('href')).toBe(defaultProvide.newIssuePath);
});
it('does not render when user does not have permissions', () => {
@@ -416,7 +440,7 @@ describe('CE IssuesListApp component', () => {
describe('state', () => {
it('is set from the url params', () => {
- const initialState = IssuableStates.All;
+ const initialState = STATUS_ALL;
setWindowLocation(`?state=${initialState}`);
wrapper = mountComponent();
@@ -477,7 +501,12 @@ describe('CE IssuesListApp component', () => {
describe('empty states', () => {
describe('when there are issues', () => {
beforeEach(() => {
- wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount });
+ wrapper = mountComponent({
+ provide: { hasAnyIssues: true },
+ mountFn: mount,
+ issuesQueryResponse: getIssuesQueryEmptyResponse,
+ });
+ return waitForPromises();
});
it('shows EmptyStateWithAnyIssues empty state', () => {
@@ -543,11 +572,8 @@ describe('CE IssuesListApp component', () => {
});
describe('when all tokens are available', () => {
- const originalGon = window.gon;
-
beforeEach(() => {
window.gon = {
- ...originalGon,
current_user_id: mockCurrentUser.id,
current_user_fullname: mockCurrentUser.name,
current_username: mockCurrentUser.username,
@@ -563,10 +589,6 @@ describe('CE IssuesListApp component', () => {
});
});
- afterEach(() => {
- window.gon = originalGon;
- });
-
it('renders all tokens alphabetically', () => {
const preloadedUsers = [
{ ...mockCurrentUser, id: convertToGraphQLId(TYPENAME_USER, mockCurrentUser.id) },
@@ -599,7 +621,6 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
[mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')),
});
- jest.runOnlyPendingTimers();
return waitForPromises();
});
@@ -620,40 +641,31 @@ describe('CE IssuesListApp component', () => {
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
- beforeEach(() => {
+ beforeEach(async () => {
wrapper = mountComponent();
+ await waitForPromises();
router.push = jest.fn();
- findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
+ findIssuableList().vm.$emit('click-tab', STATUS_CLOSED);
});
it('updates ui to the new tab', () => {
- expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
+ expect(findIssuableList().props('currentTab')).toBe(STATUS_CLOSED);
});
it('updates url to the new tab', () => {
expect(router.push).toHaveBeenCalledWith({
- query: expect.objectContaining({ state: IssuableStates.Closed }),
+ query: expect.objectContaining({ state: STATUS_CLOSED }),
});
});
});
describe.each`
- event | params
- ${'next-page'} | ${{
- page_after: 'endCursor',
- page_before: undefined,
- first_page_size: 20,
- last_page_size: undefined,
-}}
- ${'previous-page'} | ${{
- page_after: undefined,
- page_before: 'startCursor',
- first_page_size: undefined,
- last_page_size: 20,
-}}
+ event | params
+ ${'next-page'} | ${{ page_after: 'endcursor', page_before: undefined, first_page_size: 20, last_page_size: undefined }}
+ ${'previous-page'} | ${{ page_after: undefined, page_before: 'startcursor', first_page_size: undefined, last_page_size: 20 }}
`('when "$event" event is emitted by IssuableList', ({ event, params }) => {
- beforeEach(() => {
+ beforeEach(async () => {
wrapper = mountComponent({
data: {
pageInfo: {
@@ -662,6 +674,7 @@ describe('CE IssuesListApp component', () => {
},
},
});
+ await waitForPromises();
router.push = jest.fn();
findIssuableList().vm.$emit(event);
@@ -735,7 +748,6 @@ describe('CE IssuesListApp component', () => {
provide: { isProject },
issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)),
});
- jest.runOnlyPendingTimers();
return waitForPromises();
});
@@ -761,7 +773,6 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
issuesQueryResponse: jest.fn().mockResolvedValue(response()),
});
- jest.runOnlyPendingTimers();
return waitForPromises();
});
@@ -784,7 +795,7 @@ describe('CE IssuesListApp component', () => {
describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(urlSortParams))(
'updates to the new sort when payload is `%s`',
- async (sortKey) => {
+ (sortKey) => {
// Ensure initial sort key is different so we can trigger an update when emitting a sort key
wrapper =
sortKey === CREATED_DESC
@@ -793,8 +804,6 @@ describe('CE IssuesListApp component', () => {
router.push = jest.fn();
findIssuableList().vm.$emit('sort', sortKey);
- jest.runOnlyPendingTimers();
- await nextTick();
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ sort: urlSortParams[sortKey] }),
@@ -914,13 +923,13 @@ describe('CE IssuesListApp component', () => {
${'shows users when public visibility is not restricted and is signed in'} | ${false} | ${true} | ${false}
${'hides users when public visibility is restricted and is not signed in'} | ${true} | ${false} | ${true}
${'shows users when public visibility is restricted and is signed in'} | ${true} | ${true} | ${false}
- `('$description', ({ isPublicVisibilityRestricted, isSignedIn, hideUsers }) => {
+ `('$description', async ({ isPublicVisibilityRestricted, isSignedIn, hideUsers }) => {
const mockQuery = jest.fn().mockResolvedValue(defaultQueryResponse);
wrapper = mountComponent({
provide: { isPublicVisibilityRestricted, isSignedIn },
issuesQueryResponse: mockQuery,
});
- jest.runOnlyPendingTimers();
+ await waitForPromises();
expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers }));
});
@@ -929,7 +938,6 @@ describe('CE IssuesListApp component', () => {
describe('fetching issues', () => {
beforeEach(() => {
wrapper = mountComponent();
- jest.runOnlyPendingTimers();
});
it('fetches issue, incident, test case, and task types', () => {
diff --git a/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js
index 406b1fbc1af..81739f6ef1d 100644
--- a/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js
+++ b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js
@@ -38,11 +38,6 @@ describe('JiraIssuesImportStatus', () => {
},
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when Jira import is neither in progress nor finished', () => {
beforeEach(() => {
wrapper = mountComponent();
@@ -99,7 +94,7 @@ describe('JiraIssuesImportStatus', () => {
});
});
- describe('alert message', () => {
+ describe('alert', () => {
it('is hidden when dismissed', async () => {
wrapper = mountComponent({
shouldShowInProgressAlert: true,
diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js
index 1e8a81116f3..bd006a6b3ce 100644
--- a/spec/frontend/issues/list/mock_data.js
+++ b/spec/frontend/issues/list/mock_data.js
@@ -101,6 +101,26 @@ export const getIssuesQueryResponse = {
},
};
+export const getIssuesQueryEmptyResponse = {
+ data: {
+ project: {
+ id: '1',
+ __typename: 'Project',
+ issues: {
+ __persist: true,
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'startcursor',
+ endCursor: 'endcursor',
+ },
+ nodes: [],
+ },
+ },
+ },
+};
+
export const getIssuesCountsQueryResponse = {
data: {
project: {
@@ -196,6 +216,7 @@ export const locationSearchWithSpecialValues = [
].join('&');
export const filteredTokens = [
+ { type: FILTERED_SEARCH_TERM, value: { data: 'find issues', operator: 'undefined' } },
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'homer', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_NOT } },
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'burns', operator: OPERATOR_OR } },
@@ -240,8 +261,6 @@ export const filteredTokens = [
{ type: TOKEN_TYPE_ORGANIZATION, value: { data: '456', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_HEALTH, value: { data: 'atRisk', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_HEALTH, value: { data: 'onTrack', operator: OPERATOR_NOT } },
- { type: FILTERED_SEARCH_TERM, value: { data: 'find' } },
- { type: FILTERED_SEARCH_TERM, value: { data: 'issues' } },
];
export const filteredTokensWithSpecialValues = [
@@ -258,6 +277,7 @@ export const filteredTokensWithSpecialValues = [
];
export const apiParams = {
+ search: 'find issues',
authorUsername: 'homer',
assigneeUsernames: ['bart', 'lisa', '5'],
milestoneTitle: ['season 3', 'season 4'],
@@ -306,6 +326,7 @@ export const apiParamsWithSpecialValues = {
};
export const urlParams = {
+ search: 'find issues',
author_username: 'homer',
'not[author_username]': 'marge',
'or[author_username]': ['burns', 'smithers'],
diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js
index a281ed1c989..c14dcf96c98 100644
--- a/spec/frontend/issues/list/utils_spec.js
+++ b/spec/frontend/issues/list/utils_spec.js
@@ -10,7 +10,7 @@ import {
urlParams,
urlParamsWithSpecialValues,
} from 'jest/issues/list/mock_data';
-import { PAGE_SIZE, urlSortParams } from '~/issues/list/constants';
+import { urlSortParams } from '~/issues/list/constants';
import {
convertToApiParams,
convertToSearchQuery,
@@ -21,11 +21,11 @@ import {
getSortOptions,
isSortKey,
} from '~/issues/list/utils';
-import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants';
describe('getInitialPageParams', () => {
it('returns page params with a default page size when no arguments are given', () => {
- expect(getInitialPageParams()).toEqual({ firstPageSize: PAGE_SIZE });
+ expect(getInitialPageParams()).toEqual({ firstPageSize: DEFAULT_PAGE_SIZE });
});
it('returns page params with the given page size', () => {
@@ -124,20 +124,6 @@ describe('getFilterTokens', () => {
filteredTokensWithSpecialValues,
);
});
-
- it.each`
- description | argument
- ${'an undefined value'} | ${undefined}
- ${'an irrelevant value'} | ${'?unrecognised=parameter'}
- `('returns an empty filtered search term given $description', ({ argument }) => {
- expect(getFilterTokens(argument)).toEqual([
- {
- id: expect.any(String),
- type: FILTERED_SEARCH_TERM,
- value: { data: '' },
- },
- ]);
- });
});
describe('convertToApiParams', () => {
diff --git a/spec/frontend/issues/new/components/title_suggestions_item_spec.js b/spec/frontend/issues/new/components/title_suggestions_item_spec.js
index c54a762440f..4454ef81416 100644
--- a/spec/frontend/issues/new/components/title_suggestions_item_spec.js
+++ b/spec/frontend/issues/new/components/title_suggestions_item_spec.js
@@ -25,10 +25,6 @@ describe('Issue title suggestions item component', () => {
const findTooltip = () => wrapper.findComponent(GlTooltip);
const findUserAvatar = () => wrapper.findComponent(UserAvatarImage);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders title', () => {
createComponent();
diff --git a/spec/frontend/issues/new/components/title_suggestions_spec.js b/spec/frontend/issues/new/components/title_suggestions_spec.js
index 1cd6576967a..343bdbba301 100644
--- a/spec/frontend/issues/new/components/title_suggestions_spec.js
+++ b/spec/frontend/issues/new/components/title_suggestions_spec.js
@@ -1,106 +1,95 @@
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import TitleSuggestions from '~/issues/new/components/title_suggestions.vue';
import TitleSuggestionsItem from '~/issues/new/components/title_suggestions_item.vue';
+import getIssueSuggestionsQuery from '~/issues/new/queries/issues.query.graphql';
+import { mockIssueSuggestionResponse } from '../mock_data';
+
+Vue.use(VueApollo);
+
+const MOCK_PROJECT_PATH = 'project';
+const MOCK_ISSUES_COUNT = mockIssueSuggestionResponse.data.project.issues.edges.length;
describe('Issue title suggestions component', () => {
let wrapper;
+ let mockApollo;
+
+ function createComponent({
+ search = 'search',
+ queryResponse = jest.fn().mockResolvedValue(mockIssueSuggestionResponse),
+ } = {}) {
+ mockApollo = createMockApollo([[getIssueSuggestionsQuery, queryResponse]]);
- function createComponent(search = 'search') {
wrapper = shallowMount(TitleSuggestions, {
propsData: {
search,
- projectPath: 'project',
+ projectPath: MOCK_PROJECT_PATH,
},
+ apolloProvider: mockApollo,
});
}
- beforeEach(() => {
- createComponent();
- });
+ const waitForDebounce = () => {
+ jest.runOnlyPendingTimers();
+ return waitForPromises();
+ };
afterEach(() => {
- wrapper.destroy();
+ mockApollo = null;
});
it('does not render with empty search', async () => {
- wrapper.setProps({ search: '' });
+ createComponent({ search: '' });
+ await waitForDebounce();
- await nextTick();
expect(wrapper.isVisible()).toBe(false);
});
- describe('with data', () => {
- let data;
-
- beforeEach(() => {
- data = { issues: [{ id: 1 }, { id: 2 }] };
- });
-
- it('renders component', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData(data);
-
- await nextTick();
- expect(wrapper.findAll('li').length).toBe(data.issues.length);
- });
+ it('does not render when loading', () => {
+ createComponent();
+ expect(wrapper.isVisible()).toBe(false);
+ });
- it('does not render with empty search', async () => {
- wrapper.setProps({ search: '' });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData(data);
+ it('does not render with empty issues data', async () => {
+ const emptyIssuesResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/1',
+ issues: {
+ edges: [],
+ },
+ },
+ },
+ };
- await nextTick();
- expect(wrapper.isVisible()).toBe(false);
- });
+ createComponent({ queryResponse: jest.fn().mockResolvedValue(emptyIssuesResponse) });
+ await waitForDebounce();
- it('does not render when loading', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- ...data,
- loading: 1,
- });
+ expect(wrapper.isVisible()).toBe(false);
+ });
- await nextTick();
- expect(wrapper.isVisible()).toBe(false);
+ describe('with data', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForDebounce();
});
- it('does not render with empty issues data', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ issues: [] });
-
- await nextTick();
- expect(wrapper.isVisible()).toBe(false);
+ it('renders component', () => {
+ expect(wrapper.findAll('li').length).toBe(MOCK_ISSUES_COUNT);
});
- it('renders list of issues', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData(data);
-
- await nextTick();
- expect(wrapper.findAllComponents(TitleSuggestionsItem).length).toBe(2);
+ it('renders list of issues', () => {
+ expect(wrapper.findAllComponents(TitleSuggestionsItem).length).toBe(MOCK_ISSUES_COUNT);
});
- it('adds margin class to first item', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData(data);
-
- await nextTick();
+ it('adds margin class to first item', () => {
expect(wrapper.findAll('li').at(0).classes()).toContain('gl-mb-3');
});
- it('does not add margin class to last item', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData(data);
-
- await nextTick();
+ it('does not add margin class to last item', () => {
expect(wrapper.findAll('li').at(1).classes()).not.toContain('gl-mb-3');
});
});
diff --git a/spec/frontend/issues/new/components/type_popover_spec.js b/spec/frontend/issues/new/components/type_popover_spec.js
index fe3d5207516..1ae150797c3 100644
--- a/spec/frontend/issues/new/components/type_popover_spec.js
+++ b/spec/frontend/issues/new/components/type_popover_spec.js
@@ -8,10 +8,6 @@ describe('Issue type info popover', () => {
wrapper = shallowMount(TypePopover);
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders', () => {
createComponent();
diff --git a/spec/frontend/issues/new/components/type_select_spec.js b/spec/frontend/issues/new/components/type_select_spec.js
new file mode 100644
index 00000000000..a25ace10fe7
--- /dev/null
+++ b/spec/frontend/issues/new/components/type_select_spec.js
@@ -0,0 +1,141 @@
+import { GlCollapsibleListbox, GlIcon } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import * as urlUtility from '~/lib/utils/url_utility';
+import TypeSelect from '~/issues/new/components/type_select.vue';
+import { TYPE_ISSUE, TYPE_INCIDENT } from '~/issues/constants';
+import { __ } from '~/locale';
+
+const issuePath = 'issues/new';
+const incidentPath = 'issues/new?issuable_template=incident';
+const tracking = {
+ action: 'select_issue_type_incident',
+ label: 'select_issue_type_incident_dropdown_option',
+};
+
+const defaultProps = {
+ selectedType: '',
+ isIssueAllowed: true,
+ isIncidentAllowed: true,
+ issuePath,
+ incidentPath,
+};
+
+const issue = {
+ value: TYPE_ISSUE,
+ text: __('Issue'),
+ icon: 'issue-type-issue',
+ href: issuePath,
+};
+const incident = {
+ value: TYPE_INCIDENT,
+ text: __('Incident'),
+ icon: 'issue-type-incident',
+ href: incidentPath,
+ tracking,
+};
+
+describe('Issue type select component', () => {
+ let wrapper;
+ let trackingSpy;
+ let navigationSpy;
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(TypeSelect, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findAllIcons = () => wrapper.findAllComponents(GlIcon);
+ const findListboxItemIcon = () => findAllIcons().at(2);
+
+ describe('initial state', () => {
+ it('renders listbox with the correct header text', () => {
+ createComponent();
+
+ expect(findListbox().props('headerText')).toBe(TypeSelect.i18n.selectType);
+ });
+
+ it.each`
+ selectedType | toggleText
+ ${''} | ${TypeSelect.i18n.selectType}
+ ${TYPE_ISSUE} | ${TypeSelect.i18n.issuableType[TYPE_ISSUE]}
+ ${TYPE_INCIDENT} | ${TypeSelect.i18n.issuableType[TYPE_INCIDENT]}
+ `(
+ 'renders listbox with the correct toggle text when selectedType is "$selectedType"',
+ ({ selectedType, toggleText }) => {
+ createComponent({ selectedType });
+
+ expect(findListbox().props('toggleText')).toBe(toggleText);
+ },
+ );
+
+ it.each`
+ isIssueAllowed | isIncidentAllowed | items
+ ${true} | ${true} | ${[issue, incident]}
+ ${true} | ${false} | ${[issue]}
+ ${false} | ${true} | ${[incident]}
+ `(
+ 'renders listbox with the correct items when isIssueAllowed is "$isIssueAllowed" and isIncidentAllowed is "$isIncidentAllowed"',
+ ({ isIssueAllowed, isIncidentAllowed, items }) => {
+ createComponent({ isIssueAllowed, isIncidentAllowed });
+
+ expect(findListbox().props('items')).toMatchObject(items);
+ },
+ );
+
+ it.each`
+ isIssueAllowed | isIncidentAllowed | icon
+ ${true} | ${false} | ${issue.icon}
+ ${false} | ${true} | ${incident.icon}
+ `(
+ 'renders listbox item with the correct $icon icon',
+ ({ isIssueAllowed, isIncidentAllowed, icon }) => {
+ createComponent({ isIssueAllowed, isIncidentAllowed });
+ findListbox().vm.$emit('shown');
+
+ expect(findListboxItemIcon().props('name')).toBe(icon);
+ },
+ );
+ });
+
+ describe('on type selected', () => {
+ beforeEach(() => {
+ navigationSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ navigationSpy.mockRestore();
+ });
+
+ it.each`
+ selectedType | expectedUrl
+ ${TYPE_ISSUE} | ${issuePath}
+ ${TYPE_INCIDENT} | ${incidentPath}
+ `('navigates to the $selectedType issuable page', ({ selectedType, expectedUrl }) => {
+ createComponent();
+ findListbox().vm.$emit('select', selectedType);
+
+ expect(navigationSpy).toHaveBeenCalledWith(expectedUrl);
+ });
+
+ it("doesn't call tracking APIs when tracking is not available for the issuable type", () => {
+ createComponent();
+ findListbox().vm.$emit('select', TYPE_ISSUE);
+
+ expect(trackingSpy).not.toHaveBeenCalled();
+ });
+
+ it('calls tracking APIs when tracking is available for the issuable type', () => {
+ createComponent();
+ findListbox().vm.$emit('select', TYPE_INCIDENT);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, tracking.action, {
+ label: tracking.label,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issues/new/mock_data.js b/spec/frontend/issues/new/mock_data.js
index 74b569d9833..0d2a388cd86 100644
--- a/spec/frontend/issues/new/mock_data.js
+++ b/spec/frontend/issues/new/mock_data.js
@@ -26,3 +26,67 @@ export default () => ({
webUrl: `${TEST_HOST}/author`,
},
});
+
+export const mockIssueSuggestionResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/278964',
+ issues: {
+ edges: [
+ {
+ node: {
+ id: 'gid://gitlab/Issue/123725957',
+ iid: '696',
+ title: 'Remove unused MR widget extension expand success, failed, warning events',
+ confidential: false,
+ userNotesCount: 16,
+ upvotes: 0,
+ webUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/696',
+ state: 'opened',
+ closedAt: null,
+ createdAt: '2023-02-15T12:29:59Z',
+ updatedAt: '2023-03-01T19:38:22Z',
+ author: {
+ id: 'gid://gitlab/User/325',
+ name: 'User Name',
+ username: 'user-name',
+ avatarUrl: '/uploads/-/system/user/avatar/325/avatar.png',
+ webUrl: 'https://gitlab.com/user-name',
+ __typename: 'UserCore',
+ },
+ __typename: 'Issue',
+ },
+ __typename: 'IssueEdge',
+ },
+ {
+ node: {
+ id: 'gid://gitlab/Issue/123',
+ iid: '391',
+ title: 'Remove unused MR widget extension expand success, failed, warning events',
+ confidential: false,
+ userNotesCount: 16,
+ upvotes: 0,
+ webUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391',
+ state: 'opened',
+ closedAt: null,
+ createdAt: '2023-02-15T12:29:59Z',
+ updatedAt: '2023-03-01T19:38:22Z',
+ author: {
+ id: 'gid://gitlab/User/2080',
+ name: 'User Name',
+ username: 'user-name',
+ avatarUrl: '/uploads/-/system/user/avatar/2080/avatar.png',
+ webUrl: 'https://gitlab.com/user-name',
+ __typename: 'UserCore',
+ },
+ __typename: 'Issue',
+ },
+ __typename: 'IssueEdge',
+ },
+ ],
+ __typename: 'IssueConnection',
+ },
+ __typename: 'Project',
+ },
+ },
+};
diff --git a/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js
index 010c719bd84..c5507c88fd7 100644
--- a/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js
+++ b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js
@@ -34,7 +34,6 @@ describe('RelatedMergeRequests', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
diff --git a/spec/frontend/issues/related_merge_requests/store/actions_spec.js b/spec/frontend/issues/related_merge_requests/store/actions_spec.js
index 7339372a8d1..31c96265f8d 100644
--- a/spec/frontend/issues/related_merge_requests/store/actions_spec.js
+++ b/spec/frontend/issues/related_merge_requests/store/actions_spec.js
@@ -1,12 +1,12 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import * as actions from '~/issues/related_merge_requests/store/actions';
import * as types from '~/issues/related_merge_requests/store/mutation_types';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('RelatedMergeRequest store actions', () => {
let state;
diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js
index 9fa0ce6f93d..83707dfd254 100644
--- a/spec/frontend/issues/show/components/app_spec.js
+++ b/spec/frontend/issues/show/components/app_spec.js
@@ -1,13 +1,11 @@
import { GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
-import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
- IssuableStatusText,
+ issuableStatusText,
STATUS_CLOSED,
STATUS_OPEN,
STATUS_REOPENED,
@@ -21,29 +19,27 @@ import FormComponent from '~/issues/show/components/form.vue';
import TitleComponent from '~/issues/show/components/title.vue';
import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue';
import PinnedLinks from '~/issues/show/components/pinned_links.vue';
-import { POLLING_DELAY } from '~/issues/show/constants';
import eventHub from '~/issues/show/event_hub';
import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { HTTP_STATUS_OK, HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
import {
appProps,
initialRequest,
publishedIncidentUrl,
+ putRequest,
secondRequest,
zoomMeetingUrl,
} from '../mock_data/mock_data';
-jest.mock('~/flash');
-jest.mock('~/issues/show/event_hub');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility');
jest.mock('~/behaviors/markdown/render_gfm');
const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
describe('Issuable output', () => {
- let mock;
- let realtimeRequestCount = 0;
+ let axiosMock;
let wrapper;
const findStickyHeader = () => wrapper.findByTestId('issue-sticky-header');
@@ -57,15 +53,14 @@ describe('Issuable output', () => {
const findForm = () => wrapper.findComponent(FormComponent);
const findPinnedLinks = () => wrapper.findComponent(PinnedLinks);
- const mountComponent = (props = {}, options = {}, data = {}) => {
+ const createComponent = ({ props = {}, options = {}, data = {} } = {}) => {
wrapper = shallowMountExtended(IssuableApp, {
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
propsData: { ...appProps, ...props },
provide: {
fullPath: 'gitlab-org/incidents',
- iid: '19',
uploadMetricsFeatureAvailable: false,
},
stubs: {
@@ -79,387 +74,256 @@ describe('Issuable output', () => {
},
...options,
});
+
+ jest.advanceTimersToNextTimer(2);
+ return waitForPromises();
};
- beforeEach(() => {
- setHTMLFixture(`
- <div>
- <title>Title</title>
- <div class="detail-page-description content-block">
- <details open>
- <summary>One</summary>
- </details>
- <details>
- <summary>Two</summary>
- </details>
- </div>
- <div class="flash-container"></div>
- <span id="task_status"></span>
- </div>
- `);
-
- mock = new MockAdapter(axios);
- mock
- .onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes')
- .reply(() => {
- const res = Promise.resolve([HTTP_STATUS_OK, REALTIME_REQUEST_STACK[realtimeRequestCount]]);
- realtimeRequestCount += 1;
- return res;
- });
+ const emitHubEvent = (event) => {
+ eventHub.$emit(event);
+ return waitForPromises();
+ };
- mountComponent();
+ const openForm = () => {
+ return emitHubEvent('open.form');
+ };
- jest.advanceTimersByTime(2);
- });
+ const updateIssuable = () => {
+ return emitHubEvent('update.issuable');
+ };
- afterEach(() => {
- mock.restore();
- realtimeRequestCount = 0;
- wrapper.vm.poll.stop();
- wrapper.destroy();
- resetHTMLFixture();
- });
+ const advanceToNextPoll = () => {
+ // We get new data through the HTTP request.
+ jest.advanceTimersToNextTimer();
+ return waitForPromises();
+ };
- it('should render a title/description/edited and update title/description/edited on update', () => {
- return axios
- .waitForAll()
- .then(() => {
- expect(findTitle().props('titleText')).toContain('this is a title');
- expect(findDescription().props('descriptionText')).toContain('this is a description');
-
- expect(findEdited().exists()).toBe(true);
- expect(findEdited().props('updatedByPath')).toMatch(/\/some_user$/);
- expect(findEdited().props('updatedAt')).toBe(initialRequest.updated_at);
- expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version);
- })
- .then(() => {
- wrapper.vm.poll.makeRequest();
- return axios.waitForAll();
- })
- .then(() => {
- expect(findTitle().props('titleText')).toContain('2');
- expect(findDescription().props('descriptionText')).toContain('42');
-
- expect(findEdited().exists()).toBe(true);
- expect(findEdited().props('updatedByName')).toBe('Other User');
- expect(findEdited().props('updatedByPath')).toMatch(/\/other_user$/);
- expect(findEdited().props('updatedAt')).toBe(secondRequest.updated_at);
- });
- });
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$emit');
- it('shows actions if permissions are correct', async () => {
- wrapper.vm.showForm = true;
+ axiosMock = new MockAdapter(axios);
+ const endpoint = '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes';
- await nextTick();
- expect(findForm().exists()).toBe(true);
+ axiosMock.onGet(endpoint).replyOnce(HTTP_STATUS_OK, REALTIME_REQUEST_STACK[0], {
+ 'POLL-INTERVAL': '1',
+ });
+ axiosMock.onGet(endpoint).reply(HTTP_STATUS_OK, REALTIME_REQUEST_STACK[1], {
+ 'POLL-INTERVAL': '-1',
+ });
+ axiosMock.onPut().reply(HTTP_STATUS_OK, putRequest);
});
- it('does not show actions if permissions are incorrect', async () => {
- wrapper.vm.showForm = true;
- wrapper.setProps({ canUpdate: false });
-
- await nextTick();
- expect(findForm().exists()).toBe(false);
- });
+ describe('update', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
- it('does not update formState if form is already open', async () => {
- wrapper.vm.updateAndShowForm();
+ it('should render a title/description/edited and update title/description/edited on update', async () => {
+ expect(findTitle().props('titleText')).toContain(initialRequest.title_text);
+ expect(findDescription().props('descriptionText')).toContain('this is a description');
- wrapper.vm.state.titleText = 'testing 123';
+ expect(findEdited().exists()).toBe(true);
+ expect(findEdited().props('updatedByPath')).toMatch(/\/some_user$/);
+ expect(findEdited().props('updatedAt')).toBe(initialRequest.updated_at);
+ expect(findDescription().props().lockVersion).toBe(initialRequest.lock_version);
- wrapper.vm.updateAndShowForm();
+ await advanceToNextPoll();
- await nextTick();
- expect(wrapper.vm.store.formState.title).not.toBe('testing 123');
- });
+ expect(findTitle().props('titleText')).toContain('2');
+ expect(findDescription().props('descriptionText')).toContain('42');
- describe('Pinned links propagated', () => {
- it.each`
- prop | value
- ${'zoomMeetingUrl'} | ${zoomMeetingUrl}
- ${'publishedIncidentUrl'} | ${publishedIncidentUrl}
- `('sets the $prop correctly on underlying pinned links', ({ prop, value }) => {
- expect(findPinnedLinks().props(prop)).toBe(value);
+ expect(findEdited().exists()).toBe(true);
+ expect(findEdited().props('updatedByName')).toBe('Other User');
+ expect(findEdited().props('updatedByPath')).toMatch(/\/other_user$/);
+ expect(findEdited().props('updatedAt')).toBe(secondRequest.updated_at);
});
});
- describe('updateIssuable', () => {
- it('fetches new data after update', async () => {
- const updateStoreSpy = jest.spyOn(wrapper.vm, 'updateStoreState');
- const getDataSpy = jest.spyOn(wrapper.vm.service, 'getData');
- jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({
- data: { web_url: window.location.pathname },
- });
-
- await wrapper.vm.updateIssuable();
- expect(updateStoreSpy).toHaveBeenCalled();
- expect(getDataSpy).toHaveBeenCalled();
+ describe('with permissions', () => {
+ beforeEach(async () => {
+ await createComponent();
});
- it('correctly updates issuable data', async () => {
- const spy = jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({
- data: { web_url: window.location.pathname },
- });
+ it('shows actions on `open.form` event', async () => {
+ expect(findForm().exists()).toBe(false);
- await wrapper.vm.updateIssuable();
- expect(spy).toHaveBeenCalledWith(wrapper.vm.formState);
- expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
- });
+ await openForm();
- it('does not redirect if issue has not moved', async () => {
- jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({
- data: {
- web_url: window.location.pathname,
- confidential: wrapper.vm.isConfidential,
- },
- });
-
- await wrapper.vm.updateIssuable();
- expect(visitUrl).not.toHaveBeenCalled();
+ expect(findForm().exists()).toBe(true);
});
- it('does not redirect if issue has not moved and user has switched tabs', async () => {
- jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({
- data: {
- web_url: '',
- confidential: wrapper.vm.isConfidential,
- },
- });
+ it('update formState if form is not open', async () => {
+ const titleValue = initialRequest.title_text;
- await wrapper.vm.updateIssuable();
- expect(visitUrl).not.toHaveBeenCalled();
- });
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().props('titleText')).toBe(titleValue);
- it('redirects if returned web_url has changed', async () => {
- jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({
- data: {
- web_url: '/testing-issue-move',
- confidential: wrapper.vm.isConfidential,
- },
- });
+ await advanceToNextPoll();
- wrapper.vm.updateIssuable();
-
- await wrapper.vm.updateIssuable();
- expect(visitUrl).toHaveBeenCalledWith('/testing-issue-move');
+ // The title component has the new data, so the state was updated
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().props('titleText')).toBe(secondRequest.title_text);
});
- describe('shows dialog when issue has unsaved changed', () => {
- it('confirms on title change', async () => {
- wrapper.vm.showForm = true;
- wrapper.vm.state.titleText = 'title has changed';
- const e = { returnValue: null };
- wrapper.vm.handleBeforeUnloadEvent(e);
+ it('does not update formState if form is already open', async () => {
+ const titleValue = initialRequest.title_text;
- await nextTick();
- expect(e.returnValue).not.toBeNull();
- });
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().props('titleText')).toBe(titleValue);
- it('confirms on description change', async () => {
- wrapper.vm.showForm = true;
- wrapper.vm.state.descriptionText = 'description has changed';
- const e = { returnValue: null };
- wrapper.vm.handleBeforeUnloadEvent(e);
+ await openForm();
- await nextTick();
- expect(e.returnValue).not.toBeNull();
- });
+ // Opening the form, the data has not changed
+ expect(findForm().props().formState.title).toBe(titleValue);
- it('does nothing when nothing has changed', async () => {
- const e = { returnValue: null };
- wrapper.vm.handleBeforeUnloadEvent(e);
+ await advanceToNextPoll();
- await nextTick();
- expect(e.returnValue).toBeNull();
- });
+ // We expect the prop value not to have changed after another API call
+ expect(findForm().props().formState.title).toBe(titleValue);
});
+ });
- describe('error when updating', () => {
- it('closes form on error', async () => {
- jest.spyOn(wrapper.vm.service, 'updateIssuable').mockRejectedValue();
+ describe('without permissions', () => {
+ beforeEach(async () => {
+ await createComponent({ props: { canUpdate: false } });
+ });
- await wrapper.vm.updateIssuable();
- expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
- expect(createAlert).toHaveBeenCalledWith({ message: `Error updating issue` });
- });
+ it('does not show actions if permissions are incorrect', async () => {
+ await openForm();
- it('returns the correct error message for issuableType', async () => {
- jest.spyOn(wrapper.vm.service, 'updateIssuable').mockRejectedValue();
- wrapper.setProps({ issuableType: 'merge request' });
-
- await nextTick();
- await wrapper.vm.updateIssuable();
- expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
- expect(createAlert).toHaveBeenCalledWith({ message: `Error updating merge request` });
- });
+ expect(findForm().exists()).toBe(false);
+ });
+ });
- it('shows error message from backend if exists', async () => {
- const msg = 'Custom error message from backend';
- jest
- .spyOn(wrapper.vm.service, 'updateIssuable')
- .mockRejectedValue({ response: { data: { errors: [msg] } } });
+ describe('Pinned links propagated', () => {
+ it.each`
+ prop | value
+ ${'zoomMeetingUrl'} | ${zoomMeetingUrl}
+ ${'publishedIncidentUrl'} | ${publishedIncidentUrl}
+ `('sets the $prop correctly on underlying pinned links', async ({ prop, value }) => {
+ await createComponent();
- await wrapper.vm.updateIssuable();
- expect(createAlert).toHaveBeenCalledWith({
- message: `${wrapper.vm.defaultErrorMessage}. ${msg}`,
- });
- });
+ expect(findPinnedLinks().props(prop)).toBe(value);
});
});
- describe('updateAndShowForm', () => {
- it('shows locked warning if form is open & data is different', async () => {
- await nextTick();
- wrapper.vm.updateAndShowForm();
+ describe('updating an issue', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
- wrapper.vm.poll.makeRequest();
+ it('fetches new data after update', async () => {
+ await advanceToNextPoll();
- await new Promise((resolve) => {
- wrapper.vm.$watch('formState.lockedWarningVisible', (value) => {
- if (value) {
- resolve();
- }
- });
- });
+ await updateIssuable();
- expect(wrapper.vm.formState.lockedWarningVisible).toBe(true);
- expect(wrapper.vm.formState.lock_version).toBe(1);
+ expect(axiosMock.history.put).toHaveLength(1);
+ // The call was made with the new data
+ expect(axiosMock.history.put[0].data.title).toEqual(findTitle().props().title);
});
- });
- describe('requestTemplatesAndShowForm', () => {
- let formSpy;
+ it('closes the form after fetching data', async () => {
+ await updateIssuable();
- beforeEach(() => {
- formSpy = jest.spyOn(wrapper.vm, 'updateAndShowForm');
+ expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
});
- it('shows the form if template names as hash request is successful', () => {
- const mockData = {
- test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
- };
- mock
- .onGet('/issuable-templates-path')
- .reply(() => Promise.resolve([HTTP_STATUS_OK, mockData]));
-
- return wrapper.vm.requestTemplatesAndShowForm().then(() => {
- expect(formSpy).toHaveBeenCalledWith(mockData);
+ it('does not redirect if issue has not moved', async () => {
+ axiosMock.onPut().reply(HTTP_STATUS_OK, {
+ ...putRequest,
+ confidential: appProps.isConfidential,
});
- });
- it('shows the form if template names as array request is successful', () => {
- const mockData = [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }];
- mock
- .onGet('/issuable-templates-path')
- .reply(() => Promise.resolve([HTTP_STATUS_OK, mockData]));
+ await updateIssuable();
- return wrapper.vm.requestTemplatesAndShowForm().then(() => {
- expect(formSpy).toHaveBeenCalledWith(mockData);
- });
+ expect(visitUrl).not.toHaveBeenCalled();
});
- it('shows the form if template names request failed', () => {
- mock
- .onGet('/issuable-templates-path')
- .reply(() => Promise.reject(new Error('something went wrong')));
-
- return wrapper.vm.requestTemplatesAndShowForm().then(() => {
- expect(createAlert).toHaveBeenCalledWith({ message: 'Error updating issue' });
-
- expect(formSpy).toHaveBeenCalledWith();
+ it('does not redirect if issue has not moved and user has switched tabs', async () => {
+ axiosMock.onPut().reply(HTTP_STATUS_OK, {
+ ...putRequest,
+ web_url: '',
+ confidential: appProps.isConfidential,
});
- });
- });
- describe('show inline edit button', () => {
- it('should render by default', () => {
- expect(findTitle().props('showInlineEditButton')).toBe(true);
+ await updateIssuable();
+
+ expect(visitUrl).not.toHaveBeenCalled();
});
- it('should render if showInlineEditButton', async () => {
- wrapper.setProps({ showInlineEditButton: true });
+ it('redirects if returned web_url has changed', async () => {
+ const webUrl = '/testing-issue-move';
- await nextTick();
- expect(findTitle().props('showInlineEditButton')).toBe(true);
- });
+ axiosMock.onPut().reply(HTTP_STATUS_OK, {
+ ...putRequest,
+ web_url: webUrl,
+ confidential: appProps.isConfidential,
+ });
- it('should not render if showInlineEditButton is false', async () => {
- wrapper.setProps({ showInlineEditButton: false });
+ await updateIssuable();
- await nextTick();
- expect(findTitle().props('showInlineEditButton')).toBe(false);
+ expect(visitUrl).toHaveBeenCalledWith(webUrl);
});
- });
- describe('updateStoreState', () => {
- it('should make a request and update the state of the store', () => {
- const data = { foo: 1 };
- const getDataSpy = jest.spyOn(wrapper.vm.service, 'getData').mockResolvedValue({ data });
- const updateStateSpy = jest
- .spyOn(wrapper.vm.store, 'updateState')
- .mockImplementation(jest.fn);
-
- return wrapper.vm.updateStoreState().then(() => {
- expect(getDataSpy).toHaveBeenCalled();
- expect(updateStateSpy).toHaveBeenCalledWith(data);
- });
- });
+ describe('error when updating', () => {
+ it('closes form', async () => {
+ axiosMock.onPut().reply(HTTP_STATUS_UNAUTHORIZED);
- it('should show error message if store update fails', () => {
- jest.spyOn(wrapper.vm.service, 'getData').mockRejectedValue();
- wrapper.setProps({ issuableType: 'merge request' });
+ await updateIssuable();
- return wrapper.vm.updateStoreState().then(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
expect(createAlert).toHaveBeenCalledWith({
- message: `Error updating ${wrapper.vm.issuableType}`,
+ message: `Error updating issue. Request failed with status code 401`,
});
});
- });
- });
- describe('issueChanged', () => {
- beforeEach(() => {
- wrapper.vm.store.formState.title = '';
- wrapper.vm.store.formState.description = '';
- wrapper.setProps({
- initialDescriptionText: '',
- initialTitleText: '',
- });
- });
+ it('returns the correct error message for issuableType', async () => {
+ axiosMock.onPut().reply(HTTP_STATUS_UNAUTHORIZED);
- it('returns true when title is changed', () => {
- wrapper.vm.store.formState.title = 'RandomText';
+ await updateIssuable();
- expect(wrapper.vm.issueChanged).toBe(true);
- });
+ wrapper.setProps({ issuableType: 'merge request' });
- it('returns false when title is empty null', () => {
- wrapper.vm.store.formState.title = null;
+ await updateIssuable();
- expect(wrapper.vm.issueChanged).toBe(false);
- });
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `Error updating merge request. Request failed with status code 401`,
+ });
+ });
- it('returns true when description is changed', () => {
- wrapper.vm.store.formState.description = 'RandomText';
+ it('shows error message from backend if exists', async () => {
+ const msg = 'Custom error message from backend';
+ axiosMock.onPut().reply(HTTP_STATUS_UNAUTHORIZED, { errors: [msg] });
- expect(wrapper.vm.issueChanged).toBe(true);
- });
+ await updateIssuable();
- it('returns false when description is empty null', () => {
- wrapper.vm.store.formState.description = null;
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `Error updating issue. ${msg}`,
+ });
+ });
+ });
+ });
- expect(wrapper.vm.issueChanged).toBe(false);
+ describe('Locked warning', () => {
+ beforeEach(async () => {
+ await createComponent();
});
- it('returns false when `initialDescriptionText` is null and `formState.description` is empty string', () => {
- wrapper.vm.store.formState.description = '';
- wrapper.setProps({ initialDescriptionText: null });
+ it('shows locked warning if form is open & data is different', async () => {
+ await openForm();
+ await advanceToNextPoll();
- expect(wrapper.vm.issueChanged).toBe(false);
+ expect(findForm().props().formState.lockedWarningVisible).toBe(true);
+ expect(findForm().props().formState.lock_version).toBe(1);
});
});
describe('sticky header', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
+
describe('when title is in view', () => {
it('is not shown', () => {
expect(findStickyHeader().exists()).toBe(false);
@@ -468,20 +332,17 @@ describe('Issuable output', () => {
describe('when title is not in view', () => {
beforeEach(() => {
- wrapper.vm.state.titleText = 'Sticky header title';
wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear');
});
it('shows with title', () => {
- expect(findStickyHeader().text()).toContain('Sticky header title');
+ expect(findStickyHeader().text()).toContain(initialRequest.title_text);
});
it('shows with title for an epic', async () => {
- wrapper.setProps({ issuableType: 'epic' });
-
- await nextTick();
+ await wrapper.setProps({ issuableType: 'epic' });
- expect(findStickyHeader().text()).toContain('Sticky header title');
+ expect(findStickyHeader().text()).toContain(' this is a title');
});
it.each`
@@ -493,9 +354,7 @@ describe('Issuable output', () => {
`(
'shows with state icon "$statusIcon" for $issuableType when status is $issuableStatus',
async ({ issuableType, issuableStatus, statusIcon }) => {
- wrapper.setProps({ issuableType, issuableStatus });
-
- await nextTick();
+ await wrapper.setProps({ issuableType, issuableStatus });
expect(findStickyHeader().findComponent(GlIcon).props('name')).toBe(statusIcon);
},
@@ -507,11 +366,9 @@ describe('Issuable output', () => {
${'shows with Closed when status is closed'} | ${STATUS_CLOSED}
${'shows with Open when status is reopened'} | ${STATUS_REOPENED}
`('$title', async ({ state }) => {
- wrapper.setProps({ issuableStatus: state });
-
- await nextTick();
+ await wrapper.setProps({ issuableStatus: state });
- expect(findStickyHeader().text()).toContain(IssuableStatusText[state]);
+ expect(findStickyHeader().text()).toContain(issuableStatusText[state]);
});
it.each`
@@ -519,9 +376,7 @@ describe('Issuable output', () => {
${'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 });
-
- await nextTick();
+ await wrapper.setProps({ isConfidential });
const confidentialEl = findConfidentialBadge();
expect(confidentialEl.exists()).toBe(isConfidential);
@@ -538,9 +393,7 @@ describe('Issuable output', () => {
${'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 });
-
- await nextTick();
+ await wrapper.setProps({ isLocked });
expect(findLockedBadge().exists()).toBe(isLocked);
});
@@ -550,9 +403,7 @@ describe('Issuable output', () => {
${'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();
+ await wrapper.setProps({ isHidden });
const hiddenBadge = findHiddenBadge();
@@ -569,6 +420,10 @@ describe('Issuable output', () => {
});
describe('Composable description component', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
+
const findIncidentTabs = () => wrapper.findComponent(IncidentTabs);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
@@ -587,13 +442,13 @@ describe('Issuable output', () => {
});
describe('when using incident tabs description wrapper', () => {
- beforeEach(() => {
- mountComponent(
- {
+ beforeEach(async () => {
+ await createComponent({
+ props: {
descriptionComponent: IncidentTabs,
showTitleBorder: false,
},
- {
+ options: {
mocks: {
$apollo: {
queries: {
@@ -604,7 +459,7 @@ describe('Issuable output', () => {
},
},
},
- );
+ });
});
it('does not the description component', () => {
@@ -622,48 +477,77 @@ describe('Issuable output', () => {
});
describe('taskListUpdateStarted', () => {
- it('stops polling', () => {
- jest.spyOn(wrapper.vm.poll, 'stop');
+ beforeEach(async () => {
+ await createComponent();
+ });
+
+ it('stops polling', async () => {
+ expect(findTitle().props().titleText).toBe(initialRequest.title_text);
- wrapper.vm.taskListUpdateStarted();
+ findDescription().vm.$emit('taskListUpdateStarted');
- expect(wrapper.vm.poll.stop).toHaveBeenCalled();
+ await advanceToNextPoll();
+
+ expect(findTitle().props().titleText).toBe(initialRequest.title_text);
});
});
describe('taskListUpdateSucceeded', () => {
- it('enables polling', () => {
- jest.spyOn(wrapper.vm.poll, 'enable');
- jest.spyOn(wrapper.vm.poll, 'makeDelayedRequest');
+ beforeEach(async () => {
+ await createComponent();
+ findDescription().vm.$emit('taskListUpdateStarted');
+ });
- wrapper.vm.taskListUpdateSucceeded();
+ it('enables polling', async () => {
+ // Ensure that polling is not working before
+ expect(findTitle().props().titleText).toBe(initialRequest.title_text);
+ await advanceToNextPoll();
- expect(wrapper.vm.poll.enable).toHaveBeenCalled();
- expect(wrapper.vm.poll.makeDelayedRequest).toHaveBeenCalledWith(POLLING_DELAY);
+ expect(findTitle().props().titleText).toBe(initialRequest.title_text);
+
+ // Enable Polling an move forward
+ findDescription().vm.$emit('taskListUpdateSucceeded');
+ await advanceToNextPoll();
+
+ // Title has changed: polling works!
+ expect(findTitle().props().titleText).toBe(secondRequest.title_text);
});
});
describe('taskListUpdateFailed', () => {
- it('enables polling and calls updateStoreState', () => {
- jest.spyOn(wrapper.vm.poll, 'enable');
- jest.spyOn(wrapper.vm.poll, 'makeDelayedRequest');
- jest.spyOn(wrapper.vm, 'updateStoreState');
+ beforeEach(async () => {
+ await createComponent();
+ findDescription().vm.$emit('taskListUpdateStarted');
+ });
+
+ it('enables polling and calls updateStoreState', async () => {
+ // Ensure that polling is not working before
+ expect(findTitle().props().titleText).toBe(initialRequest.title_text);
+ await advanceToNextPoll();
- wrapper.vm.taskListUpdateFailed();
+ expect(findTitle().props().titleText).toBe(initialRequest.title_text);
- expect(wrapper.vm.poll.enable).toHaveBeenCalled();
- expect(wrapper.vm.poll.makeDelayedRequest).toHaveBeenCalledWith(POLLING_DELAY);
- expect(wrapper.vm.updateStoreState).toHaveBeenCalled();
+ // Enable Polling an move forward
+ findDescription().vm.$emit('taskListUpdateFailed');
+ await advanceToNextPoll();
+
+ // Title has changed: polling works!
+ expect(findTitle().props().titleText).toBe(secondRequest.title_text);
});
});
describe('saveDescription event', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
+
it('makes request to update issue', async () => {
const description = 'I have been updated!';
findDescription().vm.$emit('saveDescription', description);
+
await waitForPromises();
- expect(mock.history.put[0].data).toContain(description);
+ expect(axiosMock.history.put[0].data).toContain(description);
});
});
});
diff --git a/spec/frontend/issues/show/components/delete_issue_modal_spec.js b/spec/frontend/issues/show/components/delete_issue_modal_spec.js
index 97a091a1748..b8adeb24005 100644
--- a/spec/frontend/issues/show/components/delete_issue_modal_spec.js
+++ b/spec/frontend/issues/show/components/delete_issue_modal_spec.js
@@ -20,10 +20,6 @@ describe('DeleteIssueModal component', () => {
const mountComponent = (props = {}) =>
shallowMount(DeleteIssueModal, { propsData: { ...defaultProps, ...props } });
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('modal', () => {
it('renders', () => {
wrapper = mountComponent();
diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index da51372dd3d..9a0cde15b24 100644
--- a/spec/frontend/issues/show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -1,25 +1,16 @@
-import $ from 'jquery';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import { GlModal } from '@gitlab/ui';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
-import setWindowLocation from 'helpers/set_window_location_helper';
-import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
-import { mockTracking } from 'helpers/tracking_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Description from '~/issues/show/components/description.vue';
import eventHub from '~/issues/show/event_hub';
-import { updateHistory } from '~/lib/utils/url_utility';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import workItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import TaskList from '~/task_list';
-import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
-import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import {
createWorkItemMutationErrorResponse,
@@ -30,39 +21,23 @@ import {
import {
descriptionProps as initialProps,
descriptionHtmlWithList,
- descriptionHtmlWithCheckboxes,
- descriptionHtmlWithTask,
+ descriptionHtmlWithDetailsTag,
} from '../mock_data/mock_data';
-jest.mock('~/flash');
-jest.mock('~/lib/utils/url_utility', () => ({
- ...jest.requireActual('~/lib/utils/url_utility'),
- updateHistory: jest.fn(),
-}));
+jest.mock('~/alert');
jest.mock('~/task_list');
jest.mock('~/behaviors/markdown/render_gfm');
const mockSpriteIcons = '/icons.svg';
-const showModal = jest.fn();
-const hideModal = jest.fn();
-const showDetailsModal = jest.fn();
const $toast = {
show: jest.fn(),
};
const issueDetailsResponse = getIssueDetailsResponse();
-const workItemQueryResponse = {
- data: {
- workItem: null,
- },
-};
-
-const queryHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const workItemTypesQueryHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
describe('Description component', () => {
let wrapper;
- let originalGon;
Vue.use(VueApollo);
@@ -70,21 +45,16 @@ describe('Description component', () => {
const findTextarea = () => wrapper.find('[data-testid="textarea"]');
const findListItems = () => findGfmContent().findAll('ul > li');
const findTaskActionButtons = () => wrapper.findAll('.task-list-item-actions');
- const findTaskLink = () => wrapper.find('a.gfm-issue');
- const findModal = () => wrapper.findComponent(GlModal);
- const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
function createComponent({
props = {},
provide,
issueDetailsQueryHandler = jest.fn().mockResolvedValue(issueDetailsResponse),
createWorkItemMutationHandler,
- ...options
} = {}) {
wrapper = shallowMountExtended(Description, {
propsData: {
issueId: 1,
- issueIid: 1,
...initialProps,
...props,
},
@@ -94,7 +64,6 @@ describe('Description component', () => {
...provide,
},
apolloProvider: createMockApollo([
- [workItemQuery, queryHandler],
[workItemTypesQuery, workItemTypesQueryHandler],
[getIssueDetailsQuery, issueDetailsQueryHandler],
[createWorkItemMutation, createWorkItemMutationHandler],
@@ -102,43 +71,11 @@ describe('Description component', () => {
mocks: {
$toast,
},
- stubs: {
- GlModal: stubComponent(GlModal, {
- methods: {
- show: showModal,
- hide: hideModal,
- },
- }),
- WorkItemDetailModal: stubComponent(WorkItemDetailModal, {
- methods: {
- show: showDetailsModal,
- },
- }),
- },
- ...options,
});
}
beforeEach(() => {
- originalGon = window.gon;
window.gon = { sprite_icons: mockSpriteIcons };
-
- setWindowLocation(TEST_HOST);
-
- if (!document.querySelector('.issuable-meta')) {
- const metaData = document.createElement('div');
- metaData.classList.add('issuable-meta');
- metaData.innerHTML =
- '<div class="flash-container"></div><span id="task_status"></span><span id="task_status_short"></span>';
-
- document.body.appendChild(metaData);
- }
- });
-
- afterAll(() => {
- window.gon = originalGon;
-
- $('.issuable-meta .flash-container').remove();
});
it('doesnt animate first description changes', async () => {
@@ -169,6 +106,19 @@ describe('Description component', () => {
expect(findGfmContent().classes()).toContain('issue-realtime-trigger-pulse');
});
+ it('doesnt animate expand/collapse of details elements', async () => {
+ createComponent();
+
+ await wrapper.setProps({ descriptionHtml: descriptionHtmlWithDetailsTag.collapsed });
+ expect(findGfmContent().classes()).not.toContain('issue-realtime-pre-pulse');
+
+ await wrapper.setProps({ descriptionHtml: descriptionHtmlWithDetailsTag.expanded });
+ expect(findGfmContent().classes()).not.toContain('issue-realtime-pre-pulse');
+
+ await wrapper.setProps({ descriptionHtml: descriptionHtmlWithDetailsTag.collapsed });
+ expect(findGfmContent().classes()).not.toContain('issue-realtime-pre-pulse');
+ });
+
it('applies syntax highlighting and math when description changed', async () => {
createComponent();
@@ -203,7 +153,7 @@ describe('Description component', () => {
expect(TaskList).toHaveBeenCalled();
});
- it('does not re-init the TaskList when canUpdate is false', async () => {
+ it('does not re-init the TaskList when canUpdate is false', () => {
createComponent({
props: {
issuableType: 'issuableType',
@@ -239,53 +189,12 @@ describe('Description component', () => {
});
});
- describe('taskStatus', () => {
- it('adds full taskStatus', async () => {
- createComponent({
- props: {
- taskStatus: '1 of 1',
- },
- });
- await nextTick();
-
- expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe(
- '1 of 1',
- );
- });
-
- it('adds short taskStatus', async () => {
- createComponent({
- props: {
- taskStatus: '1 of 1',
- },
- });
- await nextTick();
-
- expect(document.querySelector('.issuable-meta #task_status_short').textContent.trim()).toBe(
- '1/1 checklist item',
- );
- });
-
- it('clears task status text when no tasks are present', async () => {
- createComponent({
- props: {
- taskStatus: '0 of 0',
- },
- });
-
- await nextTick();
-
- expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe('');
- });
- });
-
describe('with list', () => {
beforeEach(async () => {
createComponent({
props: {
descriptionHtml: descriptionHtmlWithList,
},
- attachTo: document.body,
});
await nextTick();
});
@@ -325,33 +234,6 @@ describe('Description component', () => {
});
});
- describe('description with checkboxes', () => {
- beforeEach(() => {
- createComponent({
- props: {
- descriptionHtml: descriptionHtmlWithCheckboxes,
- },
- });
- return nextTick();
- });
-
- it('renders a list of hidden buttons corresponding to checkboxes in description HTML', () => {
- expect(findTaskActionButtons()).toHaveLength(3);
- });
-
- it('does not show a modal by default', () => {
- expect(findModal().exists()).toBe(false);
- });
-
- it('shows toast after delete success', async () => {
- const newDesc = 'description';
- findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc);
-
- expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]);
- expect($toast.show).toHaveBeenCalledWith('Task deleted');
- });
- });
-
describe('task list item actions', () => {
describe('converting the task list item to a task', () => {
describe('when successful', () => {
@@ -391,11 +273,7 @@ describe('Description component', () => {
});
it('calls a mutation to create a task', () => {
- const {
- confidential,
- iteration,
- milestone,
- } = issueDetailsResponse.data.workspace.issuable;
+ const { confidential, iteration, milestone } = issueDetailsResponse.data.issue;
expect(createWorkItemMutationHandler).toHaveBeenCalledWith({
input: {
confidential,
@@ -468,109 +346,4 @@ describe('Description component', () => {
});
});
});
-
- describe('work items detail', () => {
- describe('when opening and closing', () => {
- beforeEach(() => {
- createComponent({
- props: {
- descriptionHtml: descriptionHtmlWithTask,
- },
- });
- return nextTick();
- });
-
- it('opens when task button is clicked', async () => {
- await findTaskLink().trigger('click');
-
- expect(showDetailsModal).toHaveBeenCalled();
- expect(updateHistory).toHaveBeenCalledWith({
- url: `${TEST_HOST}/?work_item_id=2`,
- replace: true,
- });
- });
-
- it('closes from an open state', async () => {
- await findTaskLink().trigger('click');
-
- findWorkItemDetailModal().vm.$emit('close');
- await nextTick();
-
- expect(updateHistory).toHaveBeenLastCalledWith({
- url: `${TEST_HOST}/`,
- replace: true,
- });
- });
-
- it('tracks when opened', async () => {
- const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
-
- await findTaskLink().trigger('click');
-
- expect(trackingSpy).toHaveBeenCalledWith(
- TRACKING_CATEGORY_SHOW,
- 'viewed_work_item_from_modal',
- {
- category: TRACKING_CATEGORY_SHOW,
- label: 'work_item_view',
- property: 'type_task',
- },
- );
- });
- });
-
- describe('when url query `work_item_id` exists', () => {
- it.each`
- behavior | workItemId | modalOpened
- ${'opens'} | ${'2'} | ${1}
- ${'does not open'} | ${'123'} | ${0}
- ${'does not open'} | ${'123e'} | ${0}
- ${'does not open'} | ${'12e3'} | ${0}
- ${'does not open'} | ${'1e23'} | ${0}
- ${'does not open'} | ${'x'} | ${0}
- ${'does not open'} | ${'undefined'} | ${0}
- `(
- '$behavior when url contains `work_item_id=$workItemId`',
- async ({ workItemId, modalOpened }) => {
- setWindowLocation(`?work_item_id=${workItemId}`);
-
- createComponent({
- props: { descriptionHtml: descriptionHtmlWithTask },
- });
-
- expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened);
- },
- );
- });
- });
-
- describe('when hovering task links', () => {
- beforeEach(() => {
- createComponent({
- props: {
- descriptionHtml: descriptionHtmlWithTask,
- },
- });
- return nextTick();
- });
-
- it('prefetches work item detail after work item link is hovered for 150ms', async () => {
- await findTaskLink().trigger('mouseover');
- jest.advanceTimersByTime(150);
- await waitForPromises();
-
- expect(queryHandler).toHaveBeenCalledWith({
- id: 'gid://gitlab/WorkItem/2',
- });
- });
-
- it('does not work item detail after work item link is hovered for less than 150ms', async () => {
- await findTaskLink().trigger('mouseover');
- await findTaskLink().trigger('mouseout');
- jest.advanceTimersByTime(150);
- await waitForPromises();
-
- expect(queryHandler).not.toHaveBeenCalled();
- });
- });
});
diff --git a/spec/frontend/issues/show/components/edit_actions_spec.js b/spec/frontend/issues/show/components/edit_actions_spec.js
index 11c43ea4388..0ebeb1b7b56 100644
--- a/spec/frontend/issues/show/components/edit_actions_spec.js
+++ b/spec/frontend/issues/show/components/edit_actions_spec.js
@@ -56,10 +56,6 @@ describe('Edit Actions component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders all buttons as enabled', () => {
const buttons = findEditButtons().wrappers;
buttons.forEach((button) => {
@@ -70,7 +66,7 @@ describe('Edit Actions component', () => {
it('disables save button when title is blank', () => {
createComponent({ props: { formState: { title: '', issue_type: '' } } });
- expect(findSaveButton().attributes('disabled')).toBe('true');
+ expect(findSaveButton().attributes('disabled')).toBeDefined();
});
describe('updateIssuable', () => {
diff --git a/spec/frontend/issues/show/components/edited_spec.js b/spec/frontend/issues/show/components/edited_spec.js
index aa6e0a9dceb..dc0c7f5be46 100644
--- a/spec/frontend/issues/show/components/edited_spec.js
+++ b/spec/frontend/issues/show/components/edited_spec.js
@@ -1,22 +1,72 @@
+import { GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { getTimeago } from '~/lib/utils/datetime_utility';
import Edited from '~/issues/show/components/edited.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-const timeago = getTimeago();
-
describe('Edited component', () => {
let wrapper;
- const findAuthorLink = () => wrapper.find('a');
+ const timeago = getTimeago();
+ const updatedAt = '2017-05-15T12:31:04.428Z';
+
+ const findAuthorLink = () => wrapper.findComponent(GlLink);
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
const formatText = (text) => text.trim().replace(/\s\s+/g, ' ');
const mountComponent = (propsData) => mount(Edited, { propsData });
- const updatedAt = '2017-05-15T12:31:04.428Z';
- afterEach(() => {
- wrapper.destroy();
+ describe('task status section', () => {
+ describe('task status text', () => {
+ it('renders when there is a task status', () => {
+ wrapper = mountComponent({ taskCompletionStatus: { completed_count: 1, count: 3 } });
+
+ expect(wrapper.text()).toContain('1 of 3 checklist items completed');
+ });
+
+ it('does not render when task count is 0', () => {
+ wrapper = mountComponent({ taskCompletionStatus: { completed_count: 0, count: 0 } });
+
+ expect(wrapper.text()).not.toContain('0 of 0 checklist items completed');
+ });
+ });
+
+ describe('checkmark', () => {
+ it('renders when all tasks are completed', () => {
+ wrapper = mountComponent({ taskCompletionStatus: { completed_count: 3, count: 3 } });
+
+ expect(wrapper.text()).toContain('✓');
+ });
+
+ it('does not render when tasks are incomplete', () => {
+ wrapper = mountComponent({ taskCompletionStatus: { completed_count: 2, count: 3 } });
+
+ expect(wrapper.text()).not.toContain('✓');
+ });
+
+ it('does not render when task count is 0', () => {
+ wrapper = mountComponent({ taskCompletionStatus: { completed_count: 0, count: 0 } });
+
+ expect(wrapper.text()).not.toContain('✓');
+ });
+ });
+
+ describe('middot', () => {
+ it('renders when there is also "Edited by" text', () => {
+ wrapper = mountComponent({
+ taskCompletionStatus: { completed_count: 3, count: 3 },
+ updatedAt,
+ });
+
+ expect(wrapper.text()).toContain('·');
+ });
+
+ it('does not render when there is no "Edited by" text', () => {
+ wrapper = mountComponent({ taskCompletionStatus: { completed_count: 3, count: 3 } });
+
+ expect(wrapper.text()).not.toContain('·');
+ });
+ });
});
it('renders an edited at+by string', () => {
@@ -31,17 +81,6 @@ describe('Edited component', () => {
expect(findTimeAgoTooltip().exists()).toBe(true);
});
- it('if no updatedAt is provided, no time element will be rendered', () => {
- wrapper = mountComponent({
- updatedByName: 'Some User',
- updatedByPath: '/some_user',
- });
-
- expect(formatText(wrapper.text())).toBe('Edited by Some User');
- expect(findAuthorLink().attributes('href')).toBe('/some_user');
- expect(findTimeAgoTooltip().exists()).toBe(false);
- });
-
it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
wrapper = mountComponent({
updatedAt,
diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js
index 273ddfdd5d4..c7116f380a1 100644
--- a/spec/frontend/issues/show/components/fields/description_spec.js
+++ b/spec/frontend/issues/show/components/fields/description_spec.js
@@ -33,11 +33,6 @@ describe('Description field component', () => {
jest.spyOn(eventHub, '$emit');
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders markdown field with description', () => {
wrapper = mountComponent();
@@ -80,17 +75,15 @@ describe('Description field component', () => {
});
it('uses the MarkdownEditor component to edit markdown', () => {
- expect(findMarkdownEditor().props()).toEqual(
- expect.objectContaining({
- value: 'test',
- renderMarkdownPath: '/',
- markdownDocsPath: '/',
- quickActionsDocsPath: expect.any(String),
- autofocus: true,
- supportsQuickActions: true,
- enableAutocomplete: true,
- }),
- );
+ expect(findMarkdownEditor().props()).toMatchObject({
+ value: 'test',
+ renderMarkdownPath: '/',
+ autofocus: true,
+ supportsQuickActions: true,
+ quickActionsDocsPath: expect.any(String),
+ markdownDocsPath: '/',
+ enableAutocomplete: true,
+ });
});
it('triggers update with meta+enter', () => {
diff --git a/spec/frontend/issues/show/components/fields/description_template_spec.js b/spec/frontend/issues/show/components/fields/description_template_spec.js
index 79a3bfa9840..1e8d5e2dd95 100644
--- a/spec/frontend/issues/show/components/fields/description_template_spec.js
+++ b/spec/frontend/issues/show/components/fields/description_template_spec.js
@@ -22,10 +22,6 @@ describe('Issue description template component with templates as hash', () => {
wrapper = shallowMount(descriptionTemplate, options);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders templates as JSON hash in data attribute', () => {
createComponent();
expect(findIssuableSelector().attributes('data-data')).toBe(
diff --git a/spec/frontend/issues/show/components/fields/title_spec.js b/spec/frontend/issues/show/components/fields/title_spec.js
index a5fa96d8d64..b28762f1520 100644
--- a/spec/frontend/issues/show/components/fields/title_spec.js
+++ b/spec/frontend/issues/show/components/fields/title_spec.js
@@ -17,11 +17,6 @@ describe('Title field component', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders form control with formState title', () => {
expect(findInput().element.value).toBe('test');
});
diff --git a/spec/frontend/issues/show/components/fields/type_spec.js b/spec/frontend/issues/show/components/fields/type_spec.js
index 27ac0e1baf3..e655cf3b37d 100644
--- a/spec/frontend/issues/show/components/fields/type_spec.js
+++ b/spec/frontend/issues/show/components/fields/type_spec.js
@@ -1,4 +1,4 @@
-import { GlFormGroup, GlListbox, GlIcon } from '@gitlab/ui';
+import { GlFormGroup, GlCollapsibleListbox, GlIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@@ -32,7 +32,7 @@ describe('Issue type field component', () => {
},
};
- const findListBox = () => wrapper.findComponent(GlListbox);
+ const findListBox = () => wrapper.findComponent(GlCollapsibleListbox);
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findAllIssueItems = () => wrapper.findAll('[data-testid="issue-type-list-item"]');
const findIssueItemAt = (at) => findAllIssueItems().at(at);
@@ -60,10 +60,6 @@ describe('Issue type field component', () => {
mockIssueStateData = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
at | text | icon
${0} | ${issuableTypes[0].text} | ${issuableTypes[0].icon}
diff --git a/spec/frontend/issues/show/components/form_spec.js b/spec/frontend/issues/show/components/form_spec.js
index aedb974cbd0..b8ed33801f2 100644
--- a/spec/frontend/issues/show/components/form_spec.js
+++ b/spec/frontend/issues/show/components/form_spec.js
@@ -30,10 +30,6 @@ describe('Inline edit form component', () => {
projectNamespace: '/',
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const createComponent = (props) => {
wrapper = shallowMount(formComponent, {
propsData: {
diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js
index 3d9dad3a721..a5ba512434c 100644
--- a/spec/frontend/issues/show/components/header_actions_spec.js
+++ b/spec/frontend/issues/show/components/header_actions_spec.js
@@ -1,28 +1,37 @@
import Vue, { nextTick } from 'vue';
-import { GlButton, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
+import { GlDropdownItem, GlLink, GlModal, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
+import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
import { mockTracking } from 'helpers/tracking_helper';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
-import { IssueType, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { STATUS_CLOSED, STATUS_OPEN, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import HeaderActions from '~/issues/show/components/header_actions.vue';
import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants';
+import issuesEventHub from '~/issues/show/event_hub';
import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutation.graphql';
import * as urlUtility from '~/lib/utils/url_utility';
import eventHub from '~/notes/event_hub';
import createStore from '~/notes/stores';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
+import updateIssueMutation from '~/issues/show/queries/update_issue.mutation.graphql';
+import toast from '~/vue_shared/plugins/global_toast';
-jest.mock('~/flash');
+jest.mock('~/alert');
+jest.mock('~/issues/show/event_hub', () => ({ $emit: jest.fn() }));
+jest.mock('~/vue_shared/plugins/global_toast');
describe('HeaderActions component', () => {
let dispatchEventSpy;
- let mutateMock;
let wrapper;
let visitUrlSpy;
Vue.use(Vuex);
+ Vue.use(VueApollo);
const store = createStore();
@@ -36,22 +45,35 @@ describe('HeaderActions component', () => {
iid: '32',
isIssueAuthor: true,
issuePath: 'gitlab-org/gitlab-test/-/issues/1',
- issueType: IssueType.Issue,
+ issueType: TYPE_ISSUE,
newIssuePath: 'gitlab-org/gitlab-test/-/issues/new',
projectPath: 'gitlab-org/gitlab-test',
reportAbusePath: '-/abuse_reports/add_category',
reportedUserId: 1,
reportedFromUrl: 'http://localhost:/gitlab-org/-/issues/32',
submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam',
+ issuableEmailAddress: null,
+ fullPath: 'full-path',
};
- const updateIssueMutationResponse = { data: { updateIssue: { errors: [] } } };
+ const updateIssueMutationResponse = {
+ data: {
+ updateIssue: {
+ errors: [],
+ issuable: {
+ id: 'gid://gitlab/Issue/511',
+ state: STATUS_OPEN,
+ },
+ },
+ },
+ };
const promoteToEpicMutationResponse = {
data: {
promoteToEpic: {
errors: [],
epic: {
+ id: 'gid://gitlab/Epic/1',
webPath: '/groups/gitlab-org/-/epics/1',
},
},
@@ -67,42 +89,81 @@ describe('HeaderActions component', () => {
},
};
- const findToggleIssueStateButton = () => wrapper.findComponent(GlButton);
+ const mockIssueReferenceData = {
+ data: {
+ workspace: {
+ id: 'gid://gitlab/Project/7',
+ issuable: {
+ id: 'gid://gitlab/Issue/511',
+ reference: 'flightjs/Flight#33',
+ __typename: 'Issue',
+ },
+ __typename: 'Project',
+ },
+ },
+ };
+
+ const findToggleIssueStateButton = () => wrapper.find(`[data-testid="toggle-button"]`);
+ const findEditButton = () => wrapper.find(`[data-testid="edit-button"]`);
const findDropdownBy = (dataTestId) => wrapper.find(`[data-testid="${dataTestId}"]`);
const findMobileDropdown = () => findDropdownBy('mobile-dropdown');
const findDesktopDropdown = () => findDropdownBy('desktop-dropdown');
const findMobileDropdownItems = () => findMobileDropdown().findAllComponents(GlDropdownItem);
const findDesktopDropdownItems = () => findDesktopDropdown().findAllComponents(GlDropdownItem);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
+ const findReportAbuseSelectorItem = () => wrapper.find(`[data-testid="report-abuse-item"]`);
+ const findNotificationWidget = () => wrapper.find(`[data-testid="notification-toggle"]`);
+ const findLockIssueWidget = () => wrapper.find(`[data-testid="lock-issue-toggle"]`);
+ const findCopyRefenceDropdownItem = () => wrapper.find(`[data-testid="copy-reference"]`);
+ const findCopyEmailItem = () => wrapper.find(`[data-testid="copy-email"]`);
const findModal = () => wrapper.findComponent(GlModal);
const findModalLinkAt = (index) => findModal().findAllComponents(GlLink).at(index);
+ const issueReferenceSuccessHandler = jest.fn().mockResolvedValue(mockIssueReferenceData);
+ const updateIssueMutationResponseHandler = jest
+ .fn()
+ .mockResolvedValue(updateIssueMutationResponse);
+ const promoteToEpicMutationSuccessResponseHandler = jest
+ .fn()
+ .mockResolvedValue(promoteToEpicMutationResponse);
+ const promoteToEpicMutationErrorHandler = jest
+ .fn()
+ .mockResolvedValue(promoteToEpicMutationErrorResponse);
+
const mountComponent = ({
props = {},
issueState = STATUS_OPEN,
blockedByIssues = [],
- mutateResponse = {},
+ movedMrSidebarEnabled = false,
+ promoteToEpicHandler = promoteToEpicMutationSuccessResponseHandler,
} = {}) => {
- mutateMock = jest.fn().mockResolvedValue(mutateResponse);
-
store.dispatch('setNoteableData', {
blocked_by_issues: blockedByIssues,
state: issueState,
});
+ const handlers = [
+ [issueReferenceQuery, issueReferenceSuccessHandler],
+ [updateIssueMutation, updateIssueMutationResponseHandler],
+ [promoteToEpicMutation, promoteToEpicHandler],
+ ];
+
return shallowMount(HeaderActions, {
+ apolloProvider: createMockApollo(handlers),
store,
provide: {
...defaultProps,
...props,
- },
- mocks: {
- $apollo: {
- mutate: mutateMock,
+ glFeatures: {
+ movedMrSidebar: movedMrSidebarEnabled,
},
},
+ stubs: {
+ GlButton,
+ },
});
};
@@ -113,13 +174,12 @@ describe('HeaderActions component', () => {
if (visitUrlSpy) {
visitUrlSpy.mockRestore();
}
- wrapper.destroy();
});
describe.each`
issueType
- ${IssueType.Issue}
- ${IssueType.Incident}
+ ${TYPE_ISSUE}
+ ${TYPE_INCIDENT}
`('when issue type is $issueType', ({ issueType }) => {
describe('close/reopen button', () => {
describe.each`
@@ -133,7 +193,6 @@ describe('HeaderActions component', () => {
wrapper = mountComponent({
props: { issueType },
issueState,
- mutateResponse: updateIssueMutationResponse,
});
});
@@ -144,23 +203,19 @@ describe('HeaderActions component', () => {
it('calls apollo mutation', () => {
findToggleIssueStateButton().vm.$emit('click');
- expect(mutateMock).toHaveBeenCalledWith(
- expect.objectContaining({
- variables: {
- input: {
- iid: defaultProps.iid,
- projectPath: defaultProps.projectPath,
- stateEvent: newIssueState,
- },
- },
- }),
- );
+ expect(updateIssueMutationResponseHandler).toHaveBeenCalledWith({
+ input: {
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ stateEvent: newIssueState,
+ },
+ });
});
it('dispatches a custom event to update the issue page', async () => {
findToggleIssueStateButton().vm.$emit('click');
- await nextTick();
+ await waitForPromises();
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
});
@@ -240,6 +295,30 @@ describe('HeaderActions component', () => {
});
});
});
+
+ describe(`show edit button ${issueType}`, () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ canUpdateIssue: true,
+ canCreateIssue: false,
+ isIssueAuthor: true,
+ issueType,
+ canReportSpam: false,
+ canPromoteToEpic: false,
+ },
+ });
+ });
+ it(`shows the edit button`, () => {
+ expect(findEditButton().exists()).toBe(true);
+ });
+
+ it('should trigger "open.form" event when clicked', async () => {
+ expect(issuesEventHub.$emit).not.toHaveBeenCalled();
+ await findEditButton().trigger('click');
+ expect(issuesEventHub.$emit).toHaveBeenCalledWith('open.form');
+ });
+ });
});
describe('delete issue button', () => {
@@ -261,28 +340,25 @@ describe('HeaderActions component', () => {
describe('when "Promote to epic" button is clicked', () => {
describe('when response is successful', () => {
- beforeEach(() => {
+ beforeEach(async () => {
visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
wrapper = mountComponent({
- mutateResponse: promoteToEpicMutationResponse,
+ promoteToEpicHandler: promoteToEpicMutationSuccessResponseHandler,
});
wrapper.find('[data-testid="promote-button"]').vm.$emit('click');
+
+ await waitForPromises();
});
it('invokes GraphQL mutation when clicked', () => {
- expect(mutateMock).toHaveBeenCalledWith(
- expect.objectContaining({
- mutation: promoteToEpicMutation,
- variables: {
- input: {
- iid: defaultProps.iid,
- projectPath: defaultProps.projectPath,
- },
- },
- }),
- );
+ expect(promoteToEpicMutationSuccessResponseHandler).toHaveBeenCalledWith({
+ input: {
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ },
+ });
});
it('shows a success message and tells the user they are being redirected', () => {
@@ -300,14 +376,16 @@ describe('HeaderActions component', () => {
});
describe('when response contains errors', () => {
- beforeEach(() => {
+ beforeEach(async () => {
visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
wrapper = mountComponent({
- mutateResponse: promoteToEpicMutationErrorResponse,
+ promoteToEpicHandler: promoteToEpicMutationErrorHandler,
});
wrapper.find('[data-testid="promote-button"]').vm.$emit('click');
+
+ await waitForPromises();
});
it('shows an error message', () => {
@@ -320,21 +398,17 @@ describe('HeaderActions component', () => {
describe('when `toggle.issuable.state` event is emitted', () => {
it('invokes a method to toggle the issue state', () => {
- wrapper = mountComponent({ mutateResponse: updateIssueMutationResponse });
+ wrapper = mountComponent();
eventHub.$emit('toggle.issuable.state');
- expect(mutateMock).toHaveBeenCalledWith(
- expect.objectContaining({
- variables: {
- input: {
- iid: defaultProps.iid,
- projectPath: defaultProps.projectPath,
- stateEvent: ISSUE_STATE_EVENT_CLOSE,
- },
- },
- }),
- );
+ expect(updateIssueMutationResponseHandler).toHaveBeenCalledWith({
+ input: {
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ stateEvent: ISSUE_STATE_EVENT_CLOSE,
+ },
+ });
});
});
@@ -363,17 +437,13 @@ describe('HeaderActions component', () => {
it('calls apollo mutation when primary button is clicked', () => {
findModal().vm.$emit('primary');
- expect(mutateMock).toHaveBeenCalledWith(
- expect.objectContaining({
- variables: {
- input: {
- iid: defaultProps.iid.toString(),
- projectPath: defaultProps.projectPath,
- stateEvent: ISSUE_STATE_EVENT_CLOSE,
- },
- },
- }),
- );
+ expect(updateIssueMutationResponseHandler).toHaveBeenCalledWith({
+ input: {
+ iid: defaultProps.iid.toString(),
+ projectPath: defaultProps.projectPath,
+ stateEvent: ISSUE_STATE_EVENT_CLOSE,
+ },
+ });
});
describe.each`
@@ -405,18 +475,16 @@ describe('HeaderActions component', () => {
});
describe('abuse category selector', () => {
- const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
-
beforeEach(() => {
wrapper = mountComponent({ props: { isIssueAuthor: false } });
});
- it("doesn't render", async () => {
+ it("doesn't render", () => {
expect(findAbuseCategorySelector().exists()).toEqual(false);
});
it('opens the drawer', async () => {
- findDesktopDropdownItems().at(2).vm.$emit('click');
+ findReportAbuseSelectorItem().vm.$emit('click');
await nextTick();
@@ -424,10 +492,160 @@ describe('HeaderActions component', () => {
});
it('closes the drawer', async () => {
- await findDesktopDropdownItems().at(2).vm.$emit('click');
+ await findReportAbuseSelectorItem().vm.$emit('click');
await findAbuseCategorySelector().vm.$emit('close-drawer');
expect(findAbuseCategorySelector().exists()).toEqual(false);
});
});
+
+ describe('notification toggle', () => {
+ describe('visibility', () => {
+ describe.each`
+ movedMrSidebarEnabled | issueType | visible
+ ${true} | ${TYPE_ISSUE} | ${true}
+ ${true} | ${TYPE_INCIDENT} | ${true}
+ ${false} | ${TYPE_ISSUE} | ${false}
+ ${false} | ${TYPE_INCIDENT} | ${false}
+ `(
+ `when movedMrSidebarEnabled flag is "$movedMrSidebarEnabled" with issue type "$issueType"`,
+ ({ movedMrSidebarEnabled, issueType, visible }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType,
+ },
+ movedMrSidebarEnabled,
+ });
+ });
+
+ it(`${visible ? 'shows' : 'hides'} Notification toggle`, () => {
+ expect(findNotificationWidget().exists()).toBe(visible);
+ });
+ },
+ );
+ });
+ });
+
+ describe('lock issue option', () => {
+ describe('visibility', () => {
+ describe.each`
+ movedMrSidebarEnabled | issueType | visible
+ ${true} | ${TYPE_ISSUE} | ${true}
+ ${true} | ${TYPE_INCIDENT} | ${false}
+ ${false} | ${TYPE_ISSUE} | ${false}
+ ${false} | ${TYPE_INCIDENT} | ${false}
+ `(
+ `when movedMrSidebarEnabled flag is "$movedMrSidebarEnabled" with issue type "$issueType"`,
+ ({ movedMrSidebarEnabled, issueType, visible }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType,
+ },
+ movedMrSidebarEnabled,
+ });
+ });
+
+ it(`${visible ? 'shows' : 'hides'} Lock issue option`, () => {
+ expect(findLockIssueWidget().exists()).toBe(visible);
+ });
+ },
+ );
+ });
+ });
+
+ describe('copy reference option', () => {
+ describe('visibility', () => {
+ describe.each`
+ movedMrSidebarEnabled | issueType | visible
+ ${true} | ${TYPE_ISSUE} | ${true}
+ ${true} | ${TYPE_INCIDENT} | ${true}
+ ${false} | ${TYPE_ISSUE} | ${false}
+ ${false} | ${TYPE_INCIDENT} | ${false}
+ `(
+ 'when movedMrSidebarFlagEnabled is "$movedMrSidebarEnabled" with issue type "$issueType"',
+ ({ movedMrSidebarEnabled, issueType, visible }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType,
+ },
+ movedMrSidebarEnabled,
+ });
+ });
+
+ it(`${visible ? 'shows' : 'hides'} Copy reference option`, () => {
+ expect(findCopyRefenceDropdownItem().exists()).toBe(visible);
+ });
+ },
+ );
+ });
+
+ describe('clicking when visible', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType: TYPE_ISSUE,
+ },
+ movedMrSidebarEnabled: true,
+ });
+ });
+
+ it('shows toast message', () => {
+ findCopyRefenceDropdownItem().vm.$emit('click');
+
+ expect(toast).toHaveBeenCalledWith('Reference copied');
+ });
+ });
+ });
+
+ describe('copy email option', () => {
+ describe('visibility', () => {
+ describe.each`
+ movedMrSidebarEnabled | issueType | issuableEmailAddress | visible
+ ${true} | ${TYPE_ISSUE} | ${'mock-email-address'} | ${true}
+ ${true} | ${TYPE_ISSUE} | ${''} | ${false}
+ ${true} | ${TYPE_INCIDENT} | ${'mock-email-address'} | ${true}
+ ${true} | ${TYPE_INCIDENT} | ${''} | ${false}
+ ${false} | ${TYPE_ISSUE} | ${'mock-email-address'} | ${false}
+ ${false} | ${TYPE_INCIDENT} | ${'mock-email-address'} | ${false}
+ `(
+ 'when movedMrSidebarEnabled flag is "$movedMrSidebarEnabled" issue type is "$issueType" and issuableEmailAddress="$issuableEmailAddress"',
+ ({ movedMrSidebarEnabled, issueType, issuableEmailAddress, visible }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType,
+ issuableEmailAddress,
+ },
+ movedMrSidebarEnabled,
+ });
+ });
+
+ it(`${visible ? 'shows' : 'hides'} Copy email option`, () => {
+ expect(findCopyEmailItem().exists()).toBe(visible);
+ });
+ },
+ );
+ });
+
+ describe('clicking when visible', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType: TYPE_ISSUE,
+ issuableEmailAddress: 'mock-email-address',
+ },
+ movedMrSidebarEnabled: true,
+ });
+ });
+
+ it('shows toast message', () => {
+ findCopyEmailItem().vm.$emit('click');
+
+ expect(toast).toHaveBeenCalledWith('Email address copied');
+ });
+ });
+ });
});
diff --git a/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js
index 6c923cae0cc..b13a1041eda 100644
--- a/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js
+++ b/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js
@@ -9,7 +9,7 @@ import createTimelineEventMutation from '~/issues/show/components/incidents/grap
import getTimelineEvents from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql';
import { timelineFormI18n } from '~/issues/show/components/incidents/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { useFakeDate } from 'helpers/fake_date';
import {
timelineEventsCreateEventResponse,
@@ -19,7 +19,7 @@ import {
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
const fakeDate = '2020-07-08T00:00:00.000Z';
@@ -86,7 +86,6 @@ describe('Create Timeline events', () => {
provide: {
fullPath: 'group/project',
issuableId: '1',
- glFeatures: { incidentEventTags: true },
},
apolloProvider,
});
@@ -99,7 +98,6 @@ describe('Create Timeline events', () => {
afterEach(() => {
createAlert.mockReset();
- wrapper.destroy();
});
describe('createIncidentTimelineEvent', () => {
diff --git a/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js
index 1cfb7d12a91..ad730fd69f7 100644
--- a/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js
+++ b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js
@@ -34,13 +34,6 @@ describe('Highlight Bar', () => {
mountComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findLink = () => wrapper.findComponent(GlLink);
describe('empty state', () => {
diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
index 33a3a6eddfc..5a49b29c458 100644
--- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
+++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
@@ -1,4 +1,5 @@
import merge from 'lodash/merge';
+import { nextTick } from 'vue';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
import DescriptionComponent from '~/issues/show/components/description.vue';
@@ -11,6 +12,11 @@ import Tracking from '~/tracking';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import { descriptionProps } from '../../mock_data/mock_data';
+const push = jest.fn();
+const $router = {
+ push,
+};
+
const mockAlert = {
__typename: 'AlertManagementAlert',
detailsUrl: INVALID_URL,
@@ -28,12 +34,20 @@ const defaultMocks = {
},
},
},
+ $route: { params: {} },
+ $router,
};
describe('Incident Tabs component', () => {
let wrapper;
- const mountComponent = ({ data = {}, options = {}, mount = shallowMountExtended } = {}) => {
+ const mountComponent = ({
+ data = {},
+ options = {},
+ mount = shallowMountExtended,
+ hasLinkedAlerts = false,
+ mocks = {},
+ } = {}) => {
wrapper = mount(
IncidentTabs,
merge(
@@ -54,11 +68,12 @@ describe('Incident Tabs component', () => {
slaFeatureAvailable: true,
canUpdate: true,
canUpdateTimelineEvent: true,
+ hasLinkedAlerts,
},
data() {
return { alert: mockAlert, ...data };
},
- mocks: defaultMocks,
+ mocks: { ...defaultMocks, ...mocks },
},
options,
),
@@ -102,11 +117,13 @@ describe('Incident Tabs component', () => {
});
it('renders the alert details tab', () => {
+ mountComponent({ hasLinkedAlerts: true });
expect(findAlertDetailsTab().exists()).toBe(true);
expect(findAlertDetailsTab().attributes('title')).toBe('Alert details');
});
it('renders the alert details table with the correct props', () => {
+ mountComponent({ hasLinkedAlerts: true });
const alert = { iid: mockAlert.iid };
expect(findAlertDetailsComponent().props('alert')).toMatchObject(alert);
@@ -146,7 +163,7 @@ describe('Incident Tabs component', () => {
mountComponent({ mount: mountExtended });
});
- it('shows only the summary tab by default', async () => {
+ it('shows only the summary tab by default', () => {
expect(findActiveTabs()).toHaveLength(1);
expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.summaryTitle);
});
@@ -156,6 +173,40 @@ describe('Incident Tabs component', () => {
expect(findActiveTabs()).toHaveLength(1);
expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.timelineTitle);
+ expect(push).toHaveBeenCalledWith('/timeline');
+ });
+ });
+
+ describe('loading page with tab', () => {
+ it('shows the timeline tab when timeline path is passed', async () => {
+ mountComponent({
+ mount: mountExtended,
+ mocks: { $route: { params: { tabId: 'timeline' } } },
+ });
+ await nextTick();
+ expect(findActiveTabs()).toHaveLength(1);
+ expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.timelineTitle);
+ });
+
+ it('shows the alerts tab when timeline path is passed', async () => {
+ mountComponent({
+ mount: mountExtended,
+ mocks: { $route: { params: { tabId: 'alerts' } } },
+ hasLinkedAlerts: true,
+ });
+ await nextTick();
+ expect(findActiveTabs()).toHaveLength(1);
+ expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.alertsTitle);
+ });
+
+ it('shows the metrics tab when metrics path is passed', async () => {
+ mountComponent({
+ mount: mountExtended,
+ mocks: { $route: { params: { tabId: 'metrics' } } },
+ });
+ await nextTick();
+ expect(findActiveTabs()).toHaveLength(1);
+ expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.metricsTitle);
});
});
});
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js
index e352f9708e4..9c4662ce38f 100644
--- a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js
@@ -10,12 +10,12 @@ import {
TIMELINE_EVENT_TAGS,
timelineEventTagsI18n,
} from '~/issues/show/components/incidents/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { useFakeDate } from 'helpers/fake_date';
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
const fakeDate = '2020-07-08T00:00:00.000Z';
@@ -51,7 +51,6 @@ describe('Timeline events form', () => {
afterEach(() => {
createAlert.mockReset();
- wrapper.destroy();
});
const findMarkdownField = () => wrapper.findComponent(MarkdownField);
@@ -114,17 +113,7 @@ describe('Timeline events form', () => {
]);
});
- describe('with incident_event_tag feature flag enabled', () => {
- beforeEach(() => {
- mountComponent(
- {},
- {},
- {
- incidentEventTags: true,
- },
- );
- });
-
+ describe('Event Tags', () => {
describe('event tags listbox', () => {
it('should render option list from provided array', () => {
expect(findTagsListbox().props('items')).toEqual(mockTags);
@@ -256,7 +245,7 @@ describe('Timeline events form', () => {
expect(findMinuteInput().element.value).toBe('0');
});
- it('should disable the save buttons when event content does not exist', async () => {
+ it('should disable the save buttons when event content does not exist', () => {
expect(findSubmitButton().props('disabled')).toBe(true);
expect(findSubmitAndAddButton().props('disabled')).toBe(true);
});
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js
index 26fda877089..8d79dece888 100644
--- a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js
@@ -11,7 +11,7 @@ import deleteTimelineEventMutation from '~/issues/show/components/incidents/grap
import editTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { useFakeDate } from 'helpers/fake_date';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
mockEvents,
timelineEventsDeleteEventResponse,
@@ -26,7 +26,7 @@ import {
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
const mockConfirmAction = ({ confirmed }) => {
@@ -77,10 +77,6 @@ describe('IncidentTimelineEventList', () => {
mountComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
it('groups items correctly', () => {
expect(findTimelineEventGroups()).toHaveLength(2);
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js
index 63474070701..41c103d5bcb 100644
--- a/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js
@@ -8,13 +8,13 @@ import IncidentTimelineEventsList from '~/issues/show/components/incidents/timel
import CreateTimelineEvent from '~/issues/show/components/incidents/create_timeline_event.vue';
import timelineEventsQuery from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { timelineTabI18n } from '~/issues/show/components/incidents/constants';
import { timelineEventsQueryListResponse, timelineEventsQueryEmptyResponse } from './mock_data';
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
const graphQLError = new Error('GraphQL error');
const listResponse = jest.fn().mockResolvedValue(timelineEventsQueryListResponse);
@@ -44,12 +44,6 @@ describe('TimelineEventsTab', () => {
});
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findTimelineEventsList = () => wrapper.findComponent(IncidentTimelineEventsList);
diff --git a/spec/frontend/issues/show/components/incidents/utils_spec.js b/spec/frontend/issues/show/components/incidents/utils_spec.js
index 75be17f9889..8ee0d906dd4 100644
--- a/spec/frontend/issues/show/components/incidents/utils_spec.js
+++ b/spec/frontend/issues/show/components/incidents/utils_spec.js
@@ -5,10 +5,10 @@ import {
getUtcShiftedDate,
getPreviousEventTags,
} from '~/issues/show/components/incidents/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { mockTimelineEventTags } from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('incident utils', () => {
describe('display and log error', () => {
diff --git a/spec/frontend/issues/show/components/locked_warning_spec.js b/spec/frontend/issues/show/components/locked_warning_spec.js
index dd3c7c58380..2e786b665d0 100644
--- a/spec/frontend/issues/show/components/locked_warning_spec.js
+++ b/spec/frontend/issues/show/components/locked_warning_spec.js
@@ -13,11 +13,6 @@ describe('LockedWarning component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findAlert = () => wrapper.findComponent(GlAlert);
const findLink = () => wrapper.findComponent(GlLink);
@@ -40,11 +35,11 @@ describe('LockedWarning component', () => {
expect(alert.props('dismissible')).toBe(false);
});
- it(`displays correct message`, async () => {
+ it(`displays correct message`, () => {
expect(alert.text()).toMatchInterpolatedText(sprintf(i18n.alertMessage, { issuableType }));
});
- it(`displays a link with correct text`, async () => {
+ it(`displays a link with correct text`, () => {
expect(link.exists()).toBe(true);
expect(link.text()).toBe(`the ${issuableType}`);
});
diff --git a/spec/frontend/issues/show/components/new_header_actions_popover_spec.js b/spec/frontend/issues/show/components/new_header_actions_popover_spec.js
new file mode 100644
index 00000000000..bf3e81c7d3a
--- /dev/null
+++ b/spec/frontend/issues/show/components/new_header_actions_popover_spec.js
@@ -0,0 +1,77 @@
+import { GlPopover } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue';
+import { NEW_ACTIONS_POPOVER_KEY } from '~/issues/show/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
+import * as utils from '~/lib/utils/common_utils';
+
+describe('NewHeaderActionsPopover', () => {
+ let wrapper;
+
+ const createComponent = ({ issueType = TYPE_ISSUE, movedMrSidebarEnabled = true }) => {
+ wrapper = shallowMountExtended(NewHeaderActionsPopover, {
+ propsData: {
+ issueType,
+ },
+ stubs: {
+ GlPopover,
+ },
+ provide: {
+ glFeatures: {
+ movedMrSidebar: movedMrSidebarEnabled,
+ },
+ },
+ });
+ };
+
+ const findPopover = () => wrapper.findComponent(GlPopover);
+ const findConfirmButton = () => wrapper.findByTestId('confirm-button');
+
+ it('should not be visible when the feature flag :moved_mr_sidebar is disabled', () => {
+ createComponent({ movedMrSidebarEnabled: false });
+ expect(findPopover().exists()).toBe(false);
+ });
+
+ describe('without the popover cookie', () => {
+ beforeEach(() => {
+ utils.setCookie = jest.fn();
+
+ createComponent({});
+ });
+
+ it('renders the popover with correct text', () => {
+ expect(findPopover().exists()).toBe(true);
+ expect(findPopover().text()).toContain('issue actions');
+ });
+
+ it('does not call setCookie', () => {
+ expect(utils.setCookie).not.toHaveBeenCalled();
+ });
+
+ describe('when the confirm button is clicked', () => {
+ beforeEach(() => {
+ findConfirmButton().vm.$emit('click');
+ });
+
+ it('sets the popover cookie', () => {
+ expect(utils.setCookie).toHaveBeenCalledWith(NEW_ACTIONS_POPOVER_KEY, true);
+ });
+
+ it('hides the popover', () => {
+ expect(findPopover().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with the popover cookie', () => {
+ beforeEach(() => {
+ jest.spyOn(utils, 'getCookie').mockReturnValue('true');
+
+ createComponent({});
+ });
+
+ it('does not render the popover', () => {
+ expect(findPopover().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js b/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js
index d4202f4a6ab..02b20b9e7b7 100644
--- a/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js
+++ b/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js
@@ -53,12 +53,6 @@ describe('Sentry Error Stack Trace', () => {
});
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('loading', () => {
it('should show spinner while loading', () => {
mountComponent();
diff --git a/spec/frontend/issues/show/components/task_list_item_actions_spec.js b/spec/frontend/issues/show/components/task_list_item_actions_spec.js
index d52f9d57453..7dacbefaeff 100644
--- a/spec/frontend/issues/show/components/task_list_item_actions_spec.js
+++ b/spec/frontend/issues/show/components/task_list_item_actions_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TaskListItemActions from '~/issues/show/components/task_list_item_actions.vue';
import eventHub from '~/issues/show/event_hub';
@@ -6,9 +6,9 @@ import eventHub from '~/issues/show/event_hub';
describe('TaskListItemActions component', () => {
let wrapper;
- const findGlDropdown = () => wrapper.findComponent(GlDropdown);
- const findConvertToTaskItem = () => wrapper.findAllComponents(GlDropdownItem).at(0);
- const findDeleteItem = () => wrapper.findAllComponents(GlDropdownItem).at(1);
+ const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findConvertToTaskItem = () => wrapper.findAllComponents(GlDisclosureDropdownItem).at(0);
+ const findDeleteItem = () => wrapper.findAllComponents(GlDisclosureDropdownItem).at(1);
const mountComponent = () => {
const li = document.createElement('li');
@@ -17,9 +17,10 @@ describe('TaskListItemActions component', () => {
document.body.appendChild(li);
wrapper = shallowMount(TaskListItemActions, {
- provide: { canUpdate: true, toggleClass: 'task-list-item-actions' },
+ provide: { canUpdate: true },
attachTo: document.querySelector('div'),
});
+ wrapper.vm.$refs.dropdown.close = jest.fn();
};
beforeEach(() => {
@@ -30,8 +31,8 @@ describe('TaskListItemActions component', () => {
expect(findGlDropdown().props()).toMatchObject({
category: 'tertiary',
icon: 'ellipsis_v',
- right: true,
- text: TaskListItemActions.i18n.taskActions,
+ placement: 'right',
+ toggleText: TaskListItemActions.i18n.taskActions,
textSrOnly: true,
});
});
@@ -39,7 +40,7 @@ describe('TaskListItemActions component', () => {
it('emits event when `Convert to task` dropdown item is clicked', () => {
jest.spyOn(eventHub, '$emit');
- findConvertToTaskItem().vm.$emit('click');
+ findConvertToTaskItem().vm.$emit('action');
expect(eventHub.$emit).toHaveBeenCalledWith('convert-task-list-item', '3:1-3:10');
});
@@ -47,7 +48,7 @@ describe('TaskListItemActions component', () => {
it('emits event when `Delete` dropdown item is clicked', () => {
jest.spyOn(eventHub, '$emit');
- findDeleteItem().vm.$emit('click');
+ findDeleteItem().vm.$emit('action');
expect(eventHub.$emit).toHaveBeenCalledWith('delete-task-list-item', '3:1-3:10');
});
diff --git a/spec/frontend/issues/show/components/title_spec.js b/spec/frontend/issues/show/components/title_spec.js
index 7560b733ae6..16ac675e12c 100644
--- a/spec/frontend/issues/show/components/title_spec.js
+++ b/spec/frontend/issues/show/components/title_spec.js
@@ -1,96 +1,59 @@
-import Vue, { nextTick } from 'vue';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import titleComponent from '~/issues/show/components/title.vue';
-import eventHub from '~/issues/show/event_hub';
-import Store from '~/issues/show/stores';
+import Title from '~/issues/show/components/title.vue';
describe('Title component', () => {
- let vm;
- beforeEach(() => {
+ let wrapper;
+
+ const getTitleHeader = () => wrapper.findByTestId('issue-title');
+
+ const createWrapper = (props) => {
setHTMLFixture(`<title />`);
- const Component = Vue.extend(titleComponent);
- const store = new Store({
- titleHtml: '',
- descriptionHtml: '',
- issuableRef: '',
- });
- vm = new Component({
+ wrapper = shallowMountExtended(Title, {
propsData: {
issuableRef: '#1',
titleHtml: 'Testing <img />',
titleText: 'Testing',
- showForm: false,
- formState: store.formState,
+ ...props,
},
- }).$mount();
- });
+ });
+ };
afterEach(() => {
resetHTMLFixture();
});
it('renders title HTML', () => {
- expect(vm.$el.querySelector('.title').innerHTML.trim()).toBe('Testing <img>');
- });
-
- it('updates page title when changing titleHtml', async () => {
- const spy = jest.spyOn(vm, 'setPageTitle');
- vm.titleHtml = 'test';
+ createWrapper();
- await nextTick();
- expect(spy).toHaveBeenCalled();
+ expect(getTitleHeader().element.innerHTML.trim()).toBe('Testing <img>');
});
it('animates title changes', async () => {
- vm.titleHtml = 'test';
+ createWrapper();
- await nextTick();
+ await wrapper.setProps({
+ titleHtml: 'test',
+ });
- expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-pre-pulse');
- jest.runAllTimers();
+ expect(getTitleHeader().classes('issue-realtime-pre-pulse')).toBe(true);
+ jest.runAllTimers();
await nextTick();
- expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-trigger-pulse');
+ expect(getTitleHeader().classes('issue-realtime-trigger-pulse')).toBe(true);
});
it('updates page title after changing title', async () => {
- vm.titleHtml = 'changed';
- vm.titleText = 'changed';
-
- await nextTick();
- expect(document.querySelector('title').textContent.trim()).toContain('changed');
- });
+ createWrapper();
- describe('inline edit button', () => {
- it('should not show by default', () => {
- expect(vm.$el.querySelector('.btn-edit')).toBeNull();
+ await wrapper.setProps({
+ titleHtml: 'changed',
+ titleText: 'changed',
});
- it('should not show if canUpdate is false', () => {
- vm.showInlineEditButton = true;
- vm.canUpdate = false;
-
- expect(vm.$el.querySelector('.btn-edit')).toBeNull();
- });
-
- it('should show if showInlineEditButton and canUpdate', () => {
- vm.showInlineEditButton = true;
- vm.canUpdate = true;
-
- expect(vm.$el.querySelector('.btn-edit')).toBeDefined();
- });
-
- it('should trigger open.form event when clicked', async () => {
- jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
- vm.showInlineEditButton = true;
- vm.canUpdate = true;
-
- await nextTick();
- vm.$el.querySelector('.btn-edit').click();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('open.form');
- });
+ expect(document.querySelector('title').textContent.trim()).toContain('changed');
});
});
diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js
index 9f0b6fb1148..ed969a08ac5 100644
--- a/spec/frontend/issues/show/mock_data/mock_data.js
+++ b/spec/frontend/issues/show/mock_data/mock_data.js
@@ -5,7 +5,7 @@ export const initialRequest = {
title_text: 'this is a title',
description: '<p>this is a description!</p>',
description_text: 'this is a description',
- task_status: '2 of 4 completed',
+ task_completion_status: { completed_count: 2, count: 4 },
updated_at: '2015-05-15T12:31:04.428Z',
updated_by_name: 'Some User',
updated_by_path: '/some_user',
@@ -17,7 +17,20 @@ export const secondRequest = {
title_text: '2',
description: '<p>42</p>',
description_text: '42',
- task_status: '0 of 0 completed',
+ task_completion_status: { completed_count: 0, count: 0 },
+ updated_at: '2016-05-15T12:31:04.428Z',
+ updated_by_name: 'Other User',
+ updated_by_path: '/other_user',
+ lock_version: 2,
+};
+
+export const putRequest = {
+ web_url: window.location.pathname,
+ title: '<p>PUT</p>',
+ title_text: 'PUT',
+ description: '<p>PUT_DESC</p>',
+ description_text: 'PUT_DESC',
+ task_completion_status: { completed_count: 0, count: 0 },
updated_at: '2016-05-15T12:31:04.428Z',
updated_by_name: 'Other User',
updated_by_path: '/other_user',
@@ -28,7 +41,6 @@ export const descriptionProps = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
- taskStatus: '',
updateUrl: TEST_HOST,
};
@@ -47,6 +59,7 @@ export const appProps = {
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
+ initialTaskCompletionStatus: { completed_count: 2, count: 4 },
lockVersion: 1,
issueType: 'issue',
markdownPreviewPath: '/',
@@ -67,46 +80,15 @@ export const descriptionHtmlWithList = `
</ul>
`;
-export const descriptionHtmlWithCheckboxes = `
- <ul dir="auto" class="task-list" data-sourcepos"3:1-5:12">
- <li class="task-list-item" data-sourcepos="3:1-3:11">
- <input class="task-list-item-checkbox" type="checkbox"> todo 1
- </li>
- <li class="task-list-item" data-sourcepos="4:1-4:12">
- <input class="task-list-item-checkbox" type="checkbox"> todo 2
- </li>
- <li class="task-list-item" data-sourcepos="5:1-5:12">
- <input class="task-list-item-checkbox" type="checkbox"> todo 3
- </li>
- </ul>
-`;
-
-export const descriptionHtmlWithTask = `
- <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto">
- <li data-sourcepos="1:1-1:10" class="task-list-item">
- <input type="checkbox" class="task-list-item-checkbox" disabled>
- <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip" data-issue-type="task">1 (#48)</a>
- </li>
- <li data-sourcepos="2:1-2:7" class="task-list-item">
- <input type="checkbox" class="task-list-item-checkbox" disabled> 2
- </li>
- <li data-sourcepos="3:1-3:7" class="task-list-item">
- <input type="checkbox" class="task-list-item-checkbox" disabled> 3
- </li>
- </ul>
-`;
-
-export const descriptionHtmlWithIssue = `
- <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto">
- <li data-sourcepos="1:1-1:10" class="task-list-item">
- <input type="checkbox" class="task-list-item-checkbox" disabled>
- <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip" data-issue-type="issue">1 (#48)</a>
- </li>
- <li data-sourcepos="2:1-2:7" class="task-list-item">
- <input type="checkbox" class="task-list-item-checkbox" disabled> 2
- </li>
- <li data-sourcepos="3:1-3:7" class="task-list-item">
- <input type="checkbox" class="task-list-item-checkbox" disabled> 3
- </li>
- </ul>
-`;
+export const descriptionHtmlWithDetailsTag = {
+ expanded: `
+ <details open="true">
+ <summary>Section 1</summary>
+ <p>Data</p>
+ </details>'`,
+ collapsed: `
+ <details>
+ <summary>Section 1</summary>
+ <p>Data</p>
+ </details>'`,
+};
diff --git a/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js b/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js
index d41031f9eaa..5e6b67aec40 100644
--- a/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js
+++ b/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js
@@ -78,10 +78,6 @@ describe('NewBranchForm', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when selecting items from dropdowns', () => {
describe('when no project selected', () => {
beforeEach(() => {
diff --git a/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js
index 944854faab3..0a887efee4b 100644
--- a/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js
+++ b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js
@@ -49,10 +49,6 @@ describe('ProjectDropdown', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when loading projects', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js
index 56e425fa4eb..a3bc8e861b2 100644
--- a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js
+++ b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js
@@ -54,10 +54,6 @@ describe('SourceBranchDropdown', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when `selectedProject` prop is not specified', () => {
beforeEach(() => {
createComponent();
@@ -151,7 +147,7 @@ describe('SourceBranchDropdown', () => {
});
describe('when selecting a listbox item', () => {
- it('emits `change` event with the selected branch name', async () => {
+ it('emits `change` event with the selected branch name', () => {
const mockBranchName = mockProject.repository.branchNames[1];
findListbox().vm.$emit('select', mockBranchName);
expect(wrapper.emitted('change')[1]).toEqual([mockBranchName]);
@@ -161,7 +157,7 @@ describe('SourceBranchDropdown', () => {
describe('when `selectedBranchName` prop is specified', () => {
const mockBranchName = mockProject.repository.branchNames[2];
- beforeEach(async () => {
+ beforeEach(() => {
wrapper.setProps({
selectedBranchName: mockBranchName,
});
diff --git a/spec/frontend/jira_connect/branches/pages/index_spec.js b/spec/frontend/jira_connect/branches/pages/index_spec.js
index 92976dd28da..4b79d5feab5 100644
--- a/spec/frontend/jira_connect/branches/pages/index_spec.js
+++ b/spec/frontend/jira_connect/branches/pages/index_spec.js
@@ -25,10 +25,6 @@ describe('NewBranchForm', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('page title', () => {
it.each`
initialBranchName | pageTitle
diff --git a/spec/frontend/jira_connect/subscriptions/api_spec.js b/spec/frontend/jira_connect/subscriptions/api_spec.js
index e2a14a9102f..36e2c7bbab2 100644
--- a/spec/frontend/jira_connect/subscriptions/api_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/api_spec.js
@@ -1,7 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import {
axiosInstance,
- addSubscription,
removeSubscription,
fetchGroups,
getCurrentUser,
@@ -17,10 +16,8 @@ jest.mock('~/jira_connect/subscriptions/utils', () => ({
describe('JiraConnect API', () => {
let axiosMock;
- let originalGon;
let response;
- const mockAddPath = 'addPath';
const mockRemovePath = 'removePath';
const mockNamespace = 'namespace';
const mockJwt = 'jwt';
@@ -29,39 +26,14 @@ describe('JiraConnect API', () => {
beforeEach(() => {
axiosMock = new MockAdapter(axiosInstance);
- originalGon = window.gon;
window.gon = { api_version: 'v4' };
});
afterEach(() => {
axiosMock.restore();
- window.gon = originalGon;
response = null;
});
- describe('addSubscription', () => {
- const makeRequest = () => addSubscription(mockAddPath, mockNamespace);
-
- it('returns success response', async () => {
- jest.spyOn(axiosInstance, 'post');
- axiosMock
- .onPost(mockAddPath, {
- jwt: mockJwt,
- namespace_path: mockNamespace,
- })
- .replyOnce(HTTP_STATUS_OK, mockResponse);
-
- response = await makeRequest();
-
- expect(getJwt).toHaveBeenCalled();
- expect(axiosInstance.post).toHaveBeenCalledWith(mockAddPath, {
- jwt: mockJwt,
- namespace_path: mockNamespace,
- });
- expect(response.data).toEqual(mockResponse);
- });
- });
-
describe('removeSubscription', () => {
const makeRequest = () => removeSubscription(mockRemovePath);
diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_button_spec.js
index 9f92ad2adc1..934473c15ba 100644
--- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_button_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_button_spec.js
@@ -11,7 +11,7 @@ describe('AddNamespaceButton', () => {
const createComponent = () => {
wrapper = shallowMount(AddNamespaceButton, {
directives: {
- glModal: createMockDirective(),
+ glModal: createMockDirective('gl-modal'),
},
});
};
@@ -23,10 +23,6 @@ describe('AddNamespaceButton', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays a button', () => {
expect(findButton().exists()).toBe(true);
});
diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal_spec.js
index d80381107f2..dbe8a734bb4 100644
--- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal_spec.js
@@ -17,10 +17,6 @@ describe('AddNamespaceModal', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays modal with correct props', () => {
const modal = findModal();
expect(modal.exists()).toBe(true);
diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js
index 5df54abfc05..c5035a12bd1 100644
--- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js
@@ -1,28 +1,15 @@
import { GlButton } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import waitForPromises from 'helpers/wait_for_promises';
-import * as JiraConnectApi from '~/jira_connect/subscriptions/api';
import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue';
import GroupsListItem from '~/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue';
-import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils';
-import {
- I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
- I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
- INTEGRATIONS_DOC_LINK,
-} from '~/jira_connect/subscriptions/constants';
import createStore from '~/jira_connect/subscriptions/store';
import { mockGroup1 } from '../../mock_data';
-jest.mock('~/jira_connect/subscriptions/utils');
-
describe('GroupsListItem', () => {
let wrapper;
let store;
- const mockAddSubscriptionsPath = '/addSubscriptionsPath';
-
const createComponent = ({ mountFn = shallowMount, provide } = {}) => {
store = createStore();
@@ -34,16 +21,11 @@ describe('GroupsListItem', () => {
group: mockGroup1,
},
provide: {
- addSubscriptionsPath: mockAddSubscriptionsPath,
...provide,
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGroupItemName = () => wrapper.findComponent(GroupItemName);
const findLinkButton = () => wrapper.findComponent(GlButton);
const clickLinkButton = () => findLinkButton().trigger('click');
@@ -65,88 +47,24 @@ describe('GroupsListItem', () => {
});
describe('on Link button click', () => {
- describe('when jiraConnectOauth feature flag is disabled', () => {
- let addSubscriptionSpy;
-
- beforeEach(() => {
- createComponent({ mountFn: mount });
-
- addSubscriptionSpy = jest.spyOn(JiraConnectApi, 'addSubscription').mockResolvedValue();
- });
-
- it('sets button to loading and sends request', async () => {
- expect(findLinkButton().props('loading')).toBe(false);
-
- clickLinkButton();
- await nextTick();
-
- expect(findLinkButton().props('loading')).toBe(true);
- await waitForPromises();
-
- expect(addSubscriptionSpy).toHaveBeenCalledWith(
- mockAddSubscriptionsPath,
- mockGroup1.full_path,
- );
- expect(persistAlert).toHaveBeenCalledWith({
- linkUrl: INTEGRATIONS_DOC_LINK,
- message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
- title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
- variant: 'success',
- });
- });
-
- describe('when request is successful', () => {
- it('reloads the page', async () => {
- clickLinkButton();
+ const mockSubscriptionsPath = '/subscriptions';
- await waitForPromises();
-
- expect(reloadPage).toHaveBeenCalled();
- });
- });
-
- describe('when request has errors', () => {
- const mockErrorMessage = 'error message';
- const mockError = { response: { data: { error: mockErrorMessage } } };
-
- beforeEach(() => {
- addSubscriptionSpy = jest
- .spyOn(JiraConnectApi, 'addSubscription')
- .mockRejectedValue(mockError);
- });
-
- it('emits `error` event', async () => {
- clickLinkButton();
-
- await waitForPromises();
-
- expect(reloadPage).not.toHaveBeenCalled();
- expect(wrapper.emitted('error')[0][0]).toBe(mockErrorMessage);
- });
+ beforeEach(() => {
+ createComponent({
+ mountFn: mount,
+ provide: {
+ subscriptionsPath: mockSubscriptionsPath,
+ },
});
});
- describe('when jiraConnectOauth feature flag is enabled', () => {
- const mockSubscriptionsPath = '/subscriptions';
-
- beforeEach(() => {
- createComponent({
- mountFn: mount,
- provide: {
- subscriptionsPath: mockSubscriptionsPath,
- glFeatures: { jiraConnectOauth: true },
- },
- });
- });
-
- it('dispatches `addSubscription` action', async () => {
- clickLinkButton();
- await nextTick();
+ it('dispatches `addSubscription` action', () => {
+ clickLinkButton();
- expect(store.dispatch).toHaveBeenCalledWith('addSubscription', {
- namespacePath: mockGroup1.full_path,
- subscriptionsPath: mockSubscriptionsPath,
- });
+ expect(store.dispatch).toHaveBeenCalledTimes(1);
+ expect(store.dispatch).toHaveBeenCalledWith('addSubscription', {
+ namespacePath: mockGroup1.full_path,
+ subscriptionsPath: mockSubscriptionsPath,
});
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js
index 97038a2a231..9d5bc8dff2a 100644
--- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js
@@ -48,10 +48,6 @@ describe('GroupsList', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlAlert = () => wrapper.findComponent(GlAlert);
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAllItems = () => wrapper.findAllComponents(GroupsListItem);
diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
index 369ddda8dbe..26a9d07321c 100644
--- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
@@ -1,6 +1,6 @@
-import { GlLink } from '@gitlab/ui';
+import { GlLink, GlSprintf } from '@gitlab/ui';
import { nextTick } from 'vue';
-import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue';
import SignInPage from '~/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue';
@@ -10,228 +10,214 @@ import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser
import createStore from '~/jira_connect/subscriptions/store';
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants';
+import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import { __ } from '~/locale';
import AccessorUtilities from '~/lib/utils/accessor';
import * as api from '~/jira_connect/subscriptions/api';
import { mockSubscription } from '../mock_data';
-jest.mock('~/jira_connect/subscriptions/utils', () => ({
- retrieveAlert: jest.fn().mockReturnValue({ message: 'error message' }),
- getGitlabSignInURL: jest.fn(),
-}));
+jest.mock('~/jira_connect/subscriptions/utils');
describe('JiraConnectApp', () => {
let wrapper;
let store;
+ const mockCurrentUser = { name: 'root' };
+
const findAlert = () => wrapper.findByTestId('jira-connect-persisted-alert');
+ const findJiraConnectApp = () => wrapper.findByTestId('jira-connect-app');
const findAlertLink = () => findAlert().findComponent(GlLink);
const findSignInPage = () => wrapper.findComponent(SignInPage);
const findSubscriptionsPage = () => wrapper.findComponent(SubscriptionsPage);
const findUserLink = () => wrapper.findComponent(UserLink);
const findBrowserSupportAlert = () => wrapper.findComponent(BrowserSupportAlert);
- const createComponent = ({ provide, mountFn = shallowMountExtended, initialState = {} } = {}) => {
+ const createComponent = ({ provide, initialState = {} } = {}) => {
store = createStore({ ...initialState, subscriptions: [mockSubscription] });
jest.spyOn(store, 'dispatch').mockImplementation();
- wrapper = mountFn(JiraConnectApp, {
+ wrapper = shallowMountExtended(JiraConnectApp, {
store,
provide,
+ stubs: {
+ GlSprintf,
+ },
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
- describe.each`
- scenario | usersPath | shouldRenderSignInPage | shouldRenderSubscriptionsPage
- ${'user is not signed in'} | ${'/users'} | ${true} | ${false}
- ${'user is signed in'} | ${undefined} | ${false} | ${true}
- `('when $scenario', ({ usersPath, shouldRenderSignInPage, shouldRenderSubscriptionsPage }) => {
- beforeEach(() => {
- createComponent({
- provide: {
- usersPath,
- },
- });
- });
-
- it(`${shouldRenderSignInPage ? 'renders' : 'does not render'} sign in page`, () => {
- expect(findSignInPage().isVisible()).toBe(shouldRenderSignInPage);
- if (shouldRenderSignInPage) {
- expect(findSignInPage().props('hasSubscriptions')).toBe(true);
- }
- });
-
- it(`${
- shouldRenderSubscriptionsPage ? 'renders' : 'does not render'
- } subscriptions page`, () => {
- expect(findSubscriptionsPage().exists()).toBe(shouldRenderSubscriptionsPage);
- if (shouldRenderSubscriptionsPage) {
- expect(findSubscriptionsPage().props('hasSubscriptions')).toBe(true);
- }
- });
+ beforeEach(() => {
+ jest.spyOn(AccessorUtilities, 'canUseCrypto').mockReturnValue(true);
});
- it('renders UserLink component', () => {
- createComponent({
- provide: {
- usersPath: '/user',
- },
- });
+ it('renders only Jira Connect app', () => {
+ createComponent();
- const userLink = findUserLink();
- expect(userLink.exists()).toBe(true);
- expect(userLink.props()).toEqual({
- hasSubscriptions: true,
- user: null,
- userSignedIn: false,
- });
+ expect(findBrowserSupportAlert().exists()).toBe(false);
+ expect(findJiraConnectApp().exists()).toBe(true);
});
- });
-
- describe('alert', () => {
- it.each`
- message | variant | alertShouldRender
- ${'Test error'} | ${'danger'} | ${true}
- ${'Test notice'} | ${'info'} | ${true}
- ${''} | ${undefined} | ${false}
- ${undefined} | ${undefined} | ${false}
- `(
- 'renders correct alert when message is `$message` and variant is `$variant`',
- async ({ message, alertShouldRender, variant }) => {
- createComponent();
-
- store.commit(SET_ALERT, { message, variant });
- await nextTick();
- const alert = findAlert();
+ it('renders only BrowserSupportAlert when canUseCrypto is false', () => {
+ jest.spyOn(AccessorUtilities, 'canUseCrypto').mockReturnValue(false);
- expect(alert.exists()).toBe(alertShouldRender);
- if (alertShouldRender) {
- expect(alert.isVisible()).toBe(alertShouldRender);
- expect(alert.html()).toContain(message);
- expect(alert.props('variant')).toBe(variant);
- expect(findAlertLink().exists()).toBe(false);
- }
- },
- );
-
- it('hides alert on @dismiss event', async () => {
createComponent();
- store.commit(SET_ALERT, { message: 'test message' });
- await nextTick();
-
- findAlert().vm.$emit('dismiss');
- await nextTick();
-
- expect(findAlert().exists()).toBe(false);
+ expect(findBrowserSupportAlert().exists()).toBe(true);
+ expect(findJiraConnectApp().exists()).toBe(false);
});
- it('renders link when `linkUrl` is set', async () => {
- createComponent({ provide: { usersPath: '' }, mountFn: mountExtended });
+ describe.each`
+ scenario | currentUser | expectUserLink | expectSignInPage | expectSubscriptionsPage
+ ${'user is not signed in'} | ${undefined} | ${false} | ${true} | ${false}
+ ${'user is signed in'} | ${mockCurrentUser} | ${true} | ${false} | ${true}
+ `(
+ 'when $scenario',
+ ({ currentUser, expectUserLink, expectSignInPage, expectSubscriptionsPage }) => {
+ beforeEach(() => {
+ createComponent({
+ initialState: {
+ currentUser,
+ },
+ });
+ });
- store.commit(SET_ALERT, {
- message: __('test message %{linkStart}test link%{linkEnd}'),
- linkUrl: 'https://gitlab.com',
- });
- await nextTick();
+ it(`${expectUserLink ? 'renders' : 'does not render'} user link`, () => {
+ expect(findUserLink().exists()).toBe(expectUserLink);
+ if (expectUserLink) {
+ expect(findUserLink().props('user')).toBe(mockCurrentUser);
+ }
+ });
- const alertLink = findAlertLink();
+ it(`${expectSignInPage ? 'renders' : 'does not render'} sign in page`, () => {
+ expect(findSignInPage().isVisible()).toBe(expectSignInPage);
+ if (expectSignInPage) {
+ expect(findSignInPage().props('hasSubscriptions')).toBe(true);
+ }
+ });
- expect(alertLink.exists()).toBe(true);
- expect(alertLink.text()).toContain('test link');
- expect(alertLink.attributes('href')).toBe('https://gitlab.com');
- });
+ it(`${expectSubscriptionsPage ? 'renders' : 'does not render'} subscriptions page`, () => {
+ expect(findSubscriptionsPage().exists()).toBe(expectSubscriptionsPage);
+ if (expectSubscriptionsPage) {
+ expect(findSubscriptionsPage().props('hasSubscriptions')).toBe(true);
+ }
+ });
+ },
+ );
- describe('when alert is set in localStoage', () => {
- it('renders alert on mount', () => {
+ describe('when sign in page emits `error` event', () => {
+ beforeEach(() => {
createComponent();
+ findSignInPage().vm.$emit('error');
+ });
+ it('displays alert', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
- expect(alert.html()).toContain('error message');
+ expect(alert.text()).toContain(I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE);
+ expect(alert.props('variant')).toBe('danger');
});
});
- });
- describe('when user signed out', () => {
- describe('when sign in page emits `error` event', () => {
- beforeEach(async () => {
+ describe('when sign in page emits `sign-in-oauth` event', () => {
+ const mockSubscriptionsPath = '/mockSubscriptionsPath';
+
+ beforeEach(() => {
+ jest.spyOn(api, 'fetchSubscriptions').mockResolvedValue({ data: { subscriptions: [] } });
+
createComponent({
+ initialState: {
+ currentUser: mockCurrentUser,
+ },
provide: {
- usersPath: '/mock',
+ subscriptionsPath: mockSubscriptionsPath,
},
});
- findSignInPage().vm.$emit('error');
- await nextTick();
+ findSignInPage().vm.$emit('sign-in-oauth');
});
- it('displays alert', () => {
- const alert = findAlert();
-
- expect(alert.exists()).toBe(true);
- expect(alert.html()).toContain(I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE);
- expect(alert.props('variant')).toBe('danger');
+ it('dispatches `fetchSubscriptions` action', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('fetchSubscriptions', mockSubscriptionsPath);
});
});
- });
- describe.each`
- jiraConnectOauthEnabled | canUseCrypto | shouldShowAlert
- ${false} | ${false} | ${false}
- ${false} | ${true} | ${false}
- ${true} | ${false} | ${true}
- ${true} | ${true} | ${false}
- `(
- 'when `jiraConnectOauth` feature flag is $jiraConnectOauthEnabled and `AccessorUtilities.canUseCrypto` returns $canUseCrypto',
- ({ jiraConnectOauthEnabled, canUseCrypto, shouldShowAlert }) => {
- beforeEach(() => {
- jest.spyOn(AccessorUtilities, 'canUseCrypto').mockReturnValue(canUseCrypto);
+ describe('alert', () => {
+ const mockAlertData = { message: 'error message' };
- createComponent({ provide: { glFeatures: { jiraConnectOauth: jiraConnectOauthEnabled } } });
- });
+ describe.each`
+ alertData | expectAlert
+ ${undefined} | ${false}
+ ${mockAlertData} | ${true}
+ `('when retrieveAlert returns $alertData', ({ alertData, expectAlert }) => {
+ beforeEach(() => {
+ retrieveAlert.mockReturnValue(alertData);
- it(`does ${shouldShowAlert ? '' : 'not'} render BrowserSupportAlert component`, () => {
- expect(findBrowserSupportAlert().exists()).toBe(shouldShowAlert);
- });
+ createComponent();
+ });
+
+ it(`${expectAlert ? 'renders' : 'does not render'} alert on mount`, () => {
+ const alert = findAlert();
- it(`does ${!shouldShowAlert ? '' : 'not'} render the main Jira Connect app template`, () => {
- expect(wrapper.findByTestId('jira-connect-app').exists()).toBe(!shouldShowAlert);
+ expect(alert.exists()).toBe(expectAlert);
+ if (expectAlert) {
+ expect(alert.text()).toContain(mockAlertData.message);
+ }
+ });
});
- },
- );
- describe('when `jiraConnectOauth` feature flag is enabled', () => {
- const mockSubscriptionsPath = '/mockSubscriptionsPath';
+ it.each`
+ message | variant | alertShouldRender
+ ${'Test error'} | ${'danger'} | ${true}
+ ${'Test notice'} | ${'info'} | ${true}
+ ${''} | ${undefined} | ${false}
+ ${undefined} | ${undefined} | ${false}
+ `(
+ 'renders correct alert when message is `$message` and variant is `$variant`',
+ async ({ message, alertShouldRender, variant }) => {
+ createComponent();
+
+ store.commit(SET_ALERT, { message, variant });
+ await nextTick();
+
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(alertShouldRender);
+ if (alertShouldRender) {
+ expect(alert.isVisible()).toBe(alertShouldRender);
+ expect(alert.text()).toContain(message);
+ expect(alert.props('variant')).toBe(variant);
+ expect(findAlertLink().exists()).toBe(false);
+ }
+ },
+ );
- beforeEach(async () => {
- jest.spyOn(api, 'fetchSubscriptions').mockResolvedValue({ data: { subscriptions: [] } });
- jest.spyOn(AccessorUtilities, 'canUseCrypto').mockReturnValue(true);
+ it('hides alert on @dismiss event', async () => {
+ createComponent();
- createComponent({
- initialState: {
- currentUser: { name: 'root' },
- },
- provide: {
- glFeatures: { jiraConnectOauth: true },
- subscriptionsPath: mockSubscriptionsPath,
- },
+ store.commit(SET_ALERT, { message: 'test message' });
+ await nextTick();
+
+ findAlert().vm.$emit('dismiss');
+ await nextTick();
+
+ expect(findAlert().exists()).toBe(false);
});
- findSignInPage().vm.$emit('sign-in-oauth');
- await nextTick();
- });
+ it('renders link when `linkUrl` is set', async () => {
+ createComponent();
- describe('when oauth button emits `sign-in-oauth` event', () => {
- it('dispatches `fetchSubscriptions` action', () => {
- expect(store.dispatch).toHaveBeenCalledWith('fetchSubscriptions', mockSubscriptionsPath);
+ store.commit(SET_ALERT, {
+ message: __('test message %{linkStart}test link%{linkEnd}'),
+ linkUrl: 'https://gitlab.com',
+ });
+ await nextTick();
+
+ const alertLink = findAlertLink();
+
+ expect(alertLink.exists()).toBe(true);
+ expect(alertLink.text()).toContain('test link');
+ expect(alertLink.attributes('href')).toBe('https://gitlab.com');
});
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js b/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js
index aa93a6be3c8..a8aa383d917 100644
--- a/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js
@@ -12,10 +12,6 @@ describe('BrowserSupportAlert', () => {
const findAlert = () => wrapper.findComponent(GlAlert);
const findLink = () => wrapper.findComponent(GlLink);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays a non-dismissible alert', () => {
createComponent();
diff --git a/spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js b/spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js
index b5fe08486b1..e4da10569f3 100644
--- a/spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js
@@ -14,10 +14,6 @@ describe('GroupItemName', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
it('matches the snapshot', () => {
createComponent();
diff --git a/spec/frontend/jira_connect/subscriptions/components/sign_in_legacy_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/sign_in_legacy_button_spec.js
deleted file mode 100644
index 4ebfaed261e..00000000000
--- a/spec/frontend/jira_connect/subscriptions/components/sign_in_legacy_button_spec.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
-import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_legacy_button.vue';
-import waitForPromises from 'helpers/wait_for_promises';
-
-const MOCK_USERS_PATH = '/user';
-
-jest.mock('~/jira_connect/subscriptions/utils');
-
-describe('SignInLegacyButton', () => {
- let wrapper;
-
- const createComponent = ({ slots } = {}) => {
- wrapper = shallowMount(SignInLegacyButton, {
- propsData: {
- usersPath: MOCK_USERS_PATH,
- },
- slots,
- });
- };
-
- const findButton = () => wrapper.findComponent(GlButton);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('displays a button', () => {
- createComponent();
-
- expect(findButton().exists()).toBe(true);
- expect(findButton().text()).toBe(SignInLegacyButton.i18n.defaultButtonText);
- });
-
- describe.each`
- expectedHref
- ${MOCK_USERS_PATH}
- ${`${MOCK_USERS_PATH}?return_to=${encodeURIComponent('https://test.jira.com')}`}
- `('when getGitlabSignInURL resolves with `$expectedHref`', ({ expectedHref }) => {
- it(`sets button href to ${expectedHref}`, async () => {
- getGitlabSignInURL.mockResolvedValue(expectedHref);
- createComponent();
-
- await waitForPromises();
-
- expect(findButton().attributes('href')).toBe(expectedHref);
- });
- });
-
- describe('with slot', () => {
- const mockSlotContent = 'custom button content!';
- it('renders slot content in button', () => {
- createComponent({ slots: { default: mockSlotContent } });
- expect(wrapper.text()).toMatchInterpolatedText(mockSlotContent);
- });
- });
-});
diff --git a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js
index e20c4b62e77..ee272d55e0e 100644
--- a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js
@@ -56,10 +56,6 @@ describe('SignInOauthButton', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findButton = () => wrapper.findComponent(GlButton);
describe('when `gitlabBasePath` is GitLab.com', () => {
it('displays a button', () => {
@@ -161,6 +157,7 @@ describe('SignInOauthButton', () => {
const mockEvent = {
origin: messageOrigin,
data: {
+ type: 'jiraConnectOauthCallback',
state: messageState,
code: '1234',
},
@@ -186,6 +183,7 @@ describe('SignInOauthButton', () => {
const mockEvent = {
origin: window.origin,
data: {
+ type: 'jiraConnectOauthCallback',
state: mockOauthMetadata.state,
code: '1234',
},
diff --git a/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js
index 2d7c58fc278..5337575d5ef 100644
--- a/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js
@@ -29,10 +29,6 @@ describe('SubscriptionsList', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findUnlinkButton = () => wrapper.findComponent(GlButton);
const clickUnlinkButton = () => findUnlinkButton().trigger('click');
diff --git a/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js
index e16121243a0..77bc1d2004c 100644
--- a/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js
@@ -1,13 +1,7 @@
import { GlSprintf } from '@gitlab/ui';
import UserLink from '~/jira_connect/subscriptions/components/user_link.vue';
-import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-
-jest.mock('~/jira_connect/subscriptions/utils', () => ({
- getGitlabSignInURL: jest.fn().mockImplementation((path) => Promise.resolve(path)),
-}));
describe('UserLink', () => {
let wrapper;
@@ -18,76 +12,17 @@ describe('UserLink', () => {
provide,
stubs: {
GlSprintf,
- SignInOauthButton,
},
});
};
- const findSignInLink = () => wrapper.findByTestId('sign-in-link');
const findGitlabUserLink = () => wrapper.findByTestId('gitlab-user-link');
const findSprintf = () => wrapper.findComponent(GlSprintf);
- const findOauthButton = () => wrapper.findComponent(SignInOauthButton);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe.each`
- userSignedIn | hasSubscriptions | expectGlSprintf | expectGlLink | expectOauthButton | jiraConnectOauthEnabled
- ${true} | ${false} | ${true} | ${false} | ${false} | ${false}
- ${false} | ${true} | ${false} | ${true} | ${false} | ${false}
- ${true} | ${true} | ${true} | ${false} | ${false} | ${false}
- ${false} | ${false} | ${false} | ${false} | ${false} | ${false}
- ${false} | ${true} | ${false} | ${false} | ${true} | ${true}
- `(
- 'when `userSignedIn` is $userSignedIn, `hasSubscriptions` is $hasSubscriptions, `jiraConnectOauthEnabled` is $jiraConnectOauthEnabled',
- ({
- userSignedIn,
- hasSubscriptions,
- expectGlSprintf,
- expectGlLink,
- expectOauthButton,
- jiraConnectOauthEnabled,
- }) => {
- it('renders template correctly', () => {
- createComponent(
- {
- userSignedIn,
- hasSubscriptions,
- },
- {
- provide: {
- glFeatures: {
- jiraConnectOauth: jiraConnectOauthEnabled,
- },
- oauthMetadata: {},
- },
- },
- );
- expect(findSprintf().exists()).toBe(expectGlSprintf);
- expect(findSignInLink().exists()).toBe(expectGlLink);
- expect(findOauthButton().exists()).toBe(expectOauthButton);
- });
- },
- );
+ it('renders template correctly', () => {
+ createComponent();
- describe('sign in link', () => {
- it('renders with correct href', async () => {
- const mockUsersPath = '/user';
- createComponent(
- {
- userSignedIn: false,
- hasSubscriptions: true,
- },
- { provide: { usersPath: mockUsersPath } },
- );
-
- await waitForPromises();
-
- expect(findSignInLink().exists()).toBe(true);
- expect(findSignInLink().attributes('href')).toBe(mockUsersPath);
- });
+ expect(findSprintf().exists()).toBe(true);
});
describe('gitlab user link', () => {
@@ -102,14 +37,7 @@ describe('UserLink', () => {
beforeEach(() => {
window.gon = { current_username, relative_root_url: '' };
- createComponent(
- {
- userSignedIn: true,
- hasSubscriptions: true,
- user,
- },
- { provide: { gitlabUserPath } },
- );
+ createComponent({ user }, { provide: { gitlabUserPath } });
});
it(`sets href to ${expectedUserLink}`, () => {
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js
index b9a8451f3b3..4cfd925db34 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js
@@ -1,7 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import SignInGitlabCom from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue';
-import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_legacy_button.vue';
import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
import createStore from '~/jira_connect/subscriptions/store';
@@ -9,100 +8,69 @@ import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/c
jest.mock('~/jira_connect/subscriptions/utils');
-const mockUsersPath = '/test';
const defaultProvide = {
oauthMetadata: {},
- usersPath: mockUsersPath,
};
describe('SignInGitlabCom', () => {
let wrapper;
let store;
- const findSignInLegacyButton = () => wrapper.findComponent(SignInLegacyButton);
const findSignInOauthButton = () => wrapper.findComponent(SignInOauthButton);
const findSubscriptionsList = () => wrapper.findComponent(SubscriptionsList);
- const createComponent = ({ props, jiraConnectOauthEnabled } = {}) => {
+ const createComponent = ({ props } = {}) => {
store = createStore();
wrapper = shallowMount(SignInGitlabCom, {
store,
provide: {
...defaultProvide,
- glFeatures: {
- jiraConnectOauth: jiraConnectOauthEnabled,
- },
},
propsData: props,
stubs: {
- SignInLegacyButton,
SignInOauthButton,
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
describe.each`
scenario | hasSubscriptions | signInButtonText
${'with subscriptions'} | ${true} | ${SignInGitlabCom.i18n.signInButtonTextWithSubscriptions}
${'without subscriptions'} | ${false} | ${I18N_DEFAULT_SIGN_IN_BUTTON_TEXT}
`('$scenario', ({ hasSubscriptions, signInButtonText }) => {
- describe('when `jiraConnectOauthEnabled` feature flag is disabled', () => {
- beforeEach(() => {
- createComponent({
- jiraConnectOauthEnabled: false,
- props: {
- hasSubscriptions,
- },
- });
- });
-
- it('renders legacy sign in button', () => {
- const button = findSignInLegacyButton();
- expect(button.props('usersPath')).toBe(mockUsersPath);
- expect(button.text()).toMatchInterpolatedText(signInButtonText);
+ beforeEach(() => {
+ createComponent({
+ props: {
+ hasSubscriptions,
+ },
});
});
- describe('when `jiraConnectOauthEnabled` feature flag is enabled', () => {
- beforeEach(() => {
- createComponent({
- jiraConnectOauthEnabled: true,
- props: {
- hasSubscriptions,
- },
- });
+ describe('oauth sign in button', () => {
+ it('renders oauth sign in button', () => {
+ const button = findSignInOauthButton();
+ expect(button.text()).toMatchInterpolatedText(signInButtonText);
});
- describe('oauth sign in button', () => {
- it('renders oauth sign in button', () => {
+ describe('when button emits `sign-in` event', () => {
+ it('emits `sign-in-oauth` event', () => {
const button = findSignInOauthButton();
- expect(button.text()).toMatchInterpolatedText(signInButtonText);
- });
-
- describe('when button emits `sign-in` event', () => {
- it('emits `sign-in-oauth` event', () => {
- const button = findSignInOauthButton();
- const mockUser = { name: 'test' };
- button.vm.$emit('sign-in', mockUser);
+ const mockUser = { name: 'test' };
+ button.vm.$emit('sign-in', mockUser);
- expect(wrapper.emitted('sign-in-oauth')[0]).toEqual([mockUser]);
- });
+ expect(wrapper.emitted('sign-in-oauth')[0]).toEqual([mockUser]);
});
+ });
- describe('when button emits `error` event', () => {
- it('emits `error` event', () => {
- const button = findSignInOauthButton();
- button.vm.$emit('error');
+ describe('when button emits `error` event', () => {
+ it('emits `error` event', () => {
+ const button = findSignInOauthButton();
+ button.vm.$emit('error');
- expect(wrapper.emitted('error')).toHaveLength(1);
- });
+ expect(wrapper.emitted('error')).toHaveLength(1);
});
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js
index e98c6ff1054..93663319e6d 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js
@@ -1,12 +1,11 @@
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import SetupInstructions from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue';
import SignInGitlabMultiversion from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue';
import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
import VersionSelectForm from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue';
-import { updateInstallation } from '~/jira_connect/subscriptions/api';
+import { updateInstallation, setApiBaseURL } from '~/jira_connect/subscriptions/api';
import { reloadPage, persistBaseUrl, retrieveBaseUrl } from '~/jira_connect/subscriptions/utils';
import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
@@ -23,7 +22,6 @@ describe('SignInGitlabMultiversion', () => {
const mockBasePath = 'gitlab.mycompany.com';
- const findSetupInstructions = () => wrapper.findComponent(SetupInstructions);
const findSignInOauthButton = () => wrapper.findComponent(SignInOauthButton);
const findVersionSelectForm = () => wrapper.findComponent(VersionSelectForm);
const findSubtitle = () => wrapper.findByTestId('subtitle');
@@ -32,10 +30,6 @@ describe('SignInGitlabMultiversion', () => {
wrapper = shallowMountExtended(SignInGitlabMultiversion);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when version is not selected', () => {
describe('VersionSelectForm', () => {
it('renders version select form', () => {
@@ -72,23 +66,12 @@ describe('SignInGitlabMultiversion', () => {
expect(findSubtitle().text()).toBe(SignInGitlabMultiversion.i18n.signInSubtitle);
});
- it('renders setup instructions', () => {
- expect(findSetupInstructions().exists()).toBe(true);
+ it('renders sign in button', () => {
+ expect(findSignInOauthButton().props('gitlabBasePath')).toBe(mockBasePath);
});
- describe('when SetupInstructions emits `next` event', () => {
- beforeEach(async () => {
- findSetupInstructions().vm.$emit('next');
- await nextTick();
- });
-
- it('renders sign in button', () => {
- expect(findSignInOauthButton().props('gitlabBasePath')).toBe(mockBasePath);
- });
-
- it('hides setup instructions', () => {
- expect(findSetupInstructions().exists()).toBe(false);
- });
+ it('calls setApiBaseURL with correct params', () => {
+ expect(setApiBaseURL).toHaveBeenCalledWith(mockBasePath);
});
});
@@ -98,14 +81,14 @@ describe('SignInGitlabMultiversion', () => {
createComponent();
});
- it('does not render setup instructions', () => {
- expect(findSetupInstructions().exists()).toBe(false);
- });
-
it('renders sign in button', () => {
expect(findSignInOauthButton().props('gitlabBasePath')).toBe(GITLAB_COM_BASE_PATH);
});
+ it('does not call setApiBaseURL', () => {
+ expect(setApiBaseURL).not.toHaveBeenCalled();
+ });
+
describe('when button emits `sign-in` event', () => {
it('emits `sign-in-oauth` event', () => {
const button = findSignInOauthButton();
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js
index 5496cf008c5..40ea6058c70 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js
@@ -7,8 +7,9 @@ import SetupInstructions from '~/jira_connect/subscriptions/pages/sign_in/sign_i
describe('SetupInstructions', () => {
let wrapper;
- const findGlButton = () => wrapper.findComponent(GlButton);
const findGlLink = () => wrapper.findComponent(GlLink);
+ const findBackButton = () => wrapper.findAllComponents(GlButton).at(0);
+ const findNextButton = () => wrapper.findAllComponents(GlButton).at(1);
const createComponent = () => {
wrapper = shallowMount(SetupInstructions);
@@ -23,12 +24,23 @@ describe('SetupInstructions', () => {
expect(findGlLink().attributes('href')).toBe(OAUTH_SELF_MANAGED_DOC_LINK);
});
- describe('when button is clicked', () => {
+ describe('when "Next" button is clicked', () => {
it('emits "next" event', () => {
expect(wrapper.emitted('next')).toBeUndefined();
- findGlButton().vm.$emit('click');
+ findNextButton().vm.$emit('click');
expect(wrapper.emitted('next')).toHaveLength(1);
+ expect(wrapper.emitted('back')).toBeUndefined();
+ });
+ });
+
+ describe('when "Back" button is clicked', () => {
+ it('emits "back" event', () => {
+ expect(wrapper.emitted('back')).toBeUndefined();
+ findBackButton().vm.$emit('click');
+
+ expect(wrapper.emitted('back')).toHaveLength(1);
+ expect(wrapper.emitted('next')).toBeUndefined();
});
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js
index 29e7fe7a5b2..2a08547b048 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js
@@ -1,8 +1,9 @@
import { GlFormInput, GlFormRadioGroup, GlForm } from '@gitlab/ui';
-import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import VersionSelectForm from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue';
+import SelfManagedAlert from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue';
+import SetupInstructions from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue';
describe('VersionSelectForm', () => {
let wrapper;
@@ -10,30 +11,53 @@ describe('VersionSelectForm', () => {
const findFormRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
const findForm = () => wrapper.findComponent(GlForm);
const findInput = () => wrapper.findComponent(GlFormInput);
+ const findSelfManagedAlert = () => wrapper.findComponent(SelfManagedAlert);
+ const findSetupInstructions = () => wrapper.findComponent(SetupInstructions);
+ const findBackButton = () => wrapper.findByTestId('back-button');
+ const findSubmitButton = () => wrapper.findByTestId('submit-button');
const submitForm = () => findForm().vm.$emit('submit', new Event('submit'));
+ const expectSelfManagedFlowAtStep = (step) => {
+ // step 0 is for SaaS which doesn't have any of the self-managed elements
+ const expectSelfManagedAlert = step === 1;
+ const expectSetupInstructions = step === 2;
+ const expectSelfManagedInput = step === 3;
+
+ it(`${expectSelfManagedAlert ? 'renders' : 'does not render'} self-managed alert`, () => {
+ expect(findSelfManagedAlert().exists()).toBe(expectSelfManagedAlert);
+ });
+
+ it(`${expectSetupInstructions ? 'renders' : 'does not render'} setup instructions`, () => {
+ expect(findSetupInstructions().exists()).toBe(expectSetupInstructions);
+ });
+
+ it(`${
+ expectSelfManagedInput ? 'renders' : 'does not render'
+ } self-managed instance URL input`, () => {
+ expect(findInput().exists()).toBe(expectSelfManagedInput);
+ });
+ };
+
const createComponent = () => {
wrapper = shallowMountExtended(VersionSelectForm);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('default state', () => {
+ describe('when "SaaS" radio option is selected (default state)', () => {
beforeEach(() => {
createComponent();
});
- it('selects saas radio option by default', () => {
+ it('selects "saas" radio option by default', () => {
expect(findFormRadioGroup().vm.$attrs.checked).toBe(VersionSelectForm.radioOptions.saas);
});
- it('does not render instance input', () => {
- expect(findInput().exists()).toBe(false);
+ it('renders submit button as "Save"', () => {
+ expect(findSubmitButton().text()).toBe(VersionSelectForm.i18n.buttonSave);
});
+ expectSelfManagedFlowAtStep(0);
+
describe('when form is submitted', () => {
it('emits "submit" event with gitlab.com as the payload', () => {
submitForm();
@@ -43,26 +67,61 @@ describe('VersionSelectForm', () => {
});
});
- describe('when "self-managed" radio option is selected', () => {
- beforeEach(async () => {
+ describe('when "self-managed" radio option is selected (step 1 of 3)', () => {
+ beforeEach(() => {
createComponent();
findFormRadioGroup().vm.$emit('input', VersionSelectForm.radioOptions.selfManaged);
- await nextTick();
});
- it('reveals the self-managed input field', () => {
- expect(findInput().exists()).toBe(true);
+ it('renders submit button as "Next"', () => {
+ expect(findSubmitButton().text()).toBe(VersionSelectForm.i18n.buttonNext);
});
- describe('when form is submitted', () => {
- it('emits "submit" event with the input field value as the payload', () => {
- const mockInstanceUrl = 'https://gitlab.example.com';
+ expectSelfManagedFlowAtStep(1);
- findInput().vm.$emit('input', mockInstanceUrl);
+ describe('when user clicks "Next" button (next to step 2 of 3)', () => {
+ beforeEach(() => {
submitForm();
+ });
+
+ expectSelfManagedFlowAtStep(2);
+
+ describe('when SetupInstructions emits `next` event (next to step 3 of 3)', () => {
+ beforeEach(() => {
+ findSetupInstructions().vm.$emit('next');
+ });
+
+ expectSelfManagedFlowAtStep(3);
+
+ describe('when form is submitted', () => {
+ it('emits "submit" event with the input field value as the payload', () => {
+ const mockInstanceUrl = 'https://gitlab.example.com';
+
+ findInput().vm.$emit('input', mockInstanceUrl);
+ submitForm();
+
+ expect(wrapper.emitted('submit')[0][0]).toBe(mockInstanceUrl);
+ });
+ });
+
+ describe('when back button is clicked', () => {
+ beforeEach(() => {
+ findBackButton().vm.$emit('click', {
+ preventDefault: jest.fn(), // preventDefault is needed to prevent form submission
+ });
+ });
+
+ expectSelfManagedFlowAtStep(1);
+ });
+ });
+
+ describe('when SetupInstructions emits `back` event (back to step 1 of 3)', () => {
+ beforeEach(() => {
+ findSetupInstructions().vm.$emit('back');
+ });
- expect(wrapper.emitted('submit')[0][0]).toBe(mockInstanceUrl);
+ expectSelfManagedFlowAtStep(1);
});
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js
index b27eba6b040..36e78ff309e 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js
@@ -12,20 +12,11 @@ describe('SignInPage', () => {
const findSignInGitlabCom = () => wrapper.findComponent(SignInGitlabCom);
const findSignInGitabMultiversion = () => wrapper.findComponent(SignInGitlabMultiversion);
- const createComponent = ({
- props = {},
- jiraConnectOauthEnabled,
- publicKeyStorageEnabled,
- } = {}) => {
+ const createComponent = ({ props = {}, publicKeyStorageEnabled } = {}) => {
store = createStore();
wrapper = shallowMount(SignInPage, {
store,
- provide: {
- glFeatures: {
- jiraConnectOauth: jiraConnectOauthEnabled,
- },
- },
propsData: {
hasSubscriptions: false,
publicKeyStorageEnabled,
@@ -34,25 +25,14 @@ describe('SignInPage', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
- jiraConnectOauthEnabled | publicKeyStorageEnabled | shouldRenderDotCom | shouldRenderMultiversion
- ${false} | ${true} | ${true} | ${false}
- ${false} | ${false} | ${true} | ${false}
- ${true} | ${true} | ${false} | ${true}
- ${true} | ${false} | ${true} | ${false}
+ publicKeyStorageEnabled | shouldRenderDotCom | shouldRenderMultiversion
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${false}
`(
- 'renders correct component when jiraConnectOauth is $jiraConnectOauthEnabled',
- ({
- jiraConnectOauthEnabled,
- publicKeyStorageEnabled,
- shouldRenderDotCom,
- shouldRenderMultiversion,
- }) => {
- createComponent({ jiraConnectOauthEnabled, publicKeyStorageEnabled });
+ 'renders correct component when publicKeyStorageEnabled is $publicKeyStorageEnabled',
+ ({ publicKeyStorageEnabled, shouldRenderDotCom, shouldRenderMultiversion }) => {
+ createComponent({ publicKeyStorageEnabled });
expect(findSignInGitlabCom().exists()).toBe(shouldRenderDotCom);
expect(findSignInGitabMultiversion().exists()).toBe(shouldRenderMultiversion);
diff --git a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js
index 4956af76ead..d262f4b2735 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js
@@ -26,10 +26,6 @@ describe('SubscriptionsPage', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
describe.each`
scenario | subscriptionsLoading | hasSubscriptions | expectSubscriptionsList | expectEmptyState
diff --git a/spec/frontend/jira_connect/subscriptions/store/actions_spec.js b/spec/frontend/jira_connect/subscriptions/store/actions_spec.js
index 5e3c30269b5..e53c3e766d2 100644
--- a/spec/frontend/jira_connect/subscriptions/store/actions_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/store/actions_spec.js
@@ -117,7 +117,7 @@ describe('JiraConnect actions', () => {
});
describe('when API request succeeds', () => {
- it('commits the SET_ACCESS_TOKEN and SET_CURRENT_USER mutations', async () => {
+ it('commits the SET_ALERT mutation', async () => {
jest.spyOn(api, 'addJiraConnectSubscription').mockResolvedValue({ success: true });
await testAction(
@@ -125,7 +125,6 @@ describe('JiraConnect actions', () => {
{ namespacePath: mockNamespace, subscriptionsPath: mockSubscriptionsPath },
mockedState,
[
- { type: types.ADD_SUBSCRIPTION_LOADING, payload: true },
{
type: types.SET_ALERT,
payload: {
@@ -135,7 +134,6 @@ describe('JiraConnect actions', () => {
variant: 'success',
},
},
- { type: types.ADD_SUBSCRIPTION_LOADING, payload: false },
],
[{ type: 'fetchSubscriptions', payload: mockSubscriptionsPath }],
);
@@ -148,20 +146,18 @@ describe('JiraConnect actions', () => {
});
describe('when API request fails', () => {
- it('commits the SET_CURRENT_USER_ERROR mutation', async () => {
+ it('does not commit the SET_ALERT mutation', () => {
jest.spyOn(api, 'addJiraConnectSubscription').mockRejectedValue();
- await testAction(
+ // We need the empty catch(), since we are testing rejecting the promise,
+ // which would otherwise cause the test to fail.
+ testAction(
addSubscription,
- mockNamespace,
+ { namespacePath: mockNamespace, subscriptionsPath: mockSubscriptionsPath },
mockedState,
- [
- { type: types.ADD_SUBSCRIPTION_LOADING, payload: true },
- { type: types.ADD_SUBSCRIPTION_ERROR },
- { type: types.ADD_SUBSCRIPTION_LOADING, payload: false },
- ],
[],
- );
+ [],
+ ).catch(() => {});
});
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js
index aeb136a76b9..e41bcec19b7 100644
--- a/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js
@@ -51,22 +51,6 @@ describe('JiraConnect store mutations', () => {
});
});
- describe('ADD_SUBSCRIPTION_LOADING', () => {
- it('sets addSubscriptionLoading', () => {
- mutations.ADD_SUBSCRIPTION_LOADING(localState, true);
-
- expect(localState.addSubscriptionLoading).toBe(true);
- });
- });
-
- describe('ADD_SUBSCRIPTION_ERROR', () => {
- it('sets addSubscriptionError', () => {
- mutations.ADD_SUBSCRIPTION_ERROR(localState, true);
-
- expect(localState.addSubscriptionError).toBe(true);
- });
- });
-
describe('SET_CURRENT_USER', () => {
it('sets currentUser', () => {
const mockUser = { name: 'root' };
diff --git a/spec/frontend/jira_connect/subscriptions/utils_spec.js b/spec/frontend/jira_connect/subscriptions/utils_spec.js
index 762d9eb3443..d1588dbad2c 100644
--- a/spec/frontend/jira_connect/subscriptions/utils_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/utils_spec.js
@@ -5,10 +5,8 @@ import {
persistAlert,
retrieveAlert,
getJwt,
- getLocation,
reloadPage,
sizeToParent,
- getGitlabSignInURL,
} from '~/jira_connect/subscriptions/utils';
describe('JiraConnect utils', () => {
@@ -69,29 +67,6 @@ describe('JiraConnect utils', () => {
});
});
- describe('getLocation', () => {
- const mockLocation = 'test/location';
- const getLocationSpy = jest.fn((callback) => callback(mockLocation));
-
- it('resolves to the function call when AP.getLocation is a function', async () => {
- global.AP = {
- getLocation: getLocationSpy,
- };
-
- const location = await getLocation();
-
- expect(getLocationSpy).toHaveBeenCalled();
- expect(location).toBe(mockLocation);
- });
-
- it('resolves to undefined when AP.getLocation is not a function', async () => {
- const location = await getLocation();
-
- expect(getLocationSpy).not.toHaveBeenCalled();
- expect(location).toBeUndefined();
- });
- });
-
describe('reloadPage', () => {
const reloadSpy = jest.fn();
@@ -138,25 +113,4 @@ describe('JiraConnect utils', () => {
});
});
});
-
- describe('getGitlabSignInURL', () => {
- const mockSignInURL = 'https://gitlab.com/sign_in';
-
- it.each`
- returnTo | expectResult
- ${undefined} | ${mockSignInURL}
- ${''} | ${mockSignInURL}
- ${'/test/location'} | ${`${mockSignInURL}?return_to=${encodeURIComponent('/test/location')}`}
- `(
- 'returns `$expectResult` when `AP.getLocation` resolves to `$returnTo`',
- async ({ returnTo, expectResult }) => {
- global.AP = {
- getLocation: jest.fn().mockImplementation((cb) => cb(returnTo)),
- };
-
- const url = await getGitlabSignInURL(mockSignInURL);
- expect(url).toBe(expectResult);
- },
- );
- });
});
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 40e627262db..abd849b387e 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
@@ -2,7 +2,7 @@
exports[`JiraImportForm table body shows correct information in each cell 1`] = `
<table
- aria-busy="false"
+ aria-busy=""
aria-colcount="3"
class="table b-table gl-table b-table-fixed"
role="table"
@@ -76,7 +76,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
role="img"
>
<use
- href="#arrow-right"
+ href="file-mock#arrow-right"
/>
</svg>
</td>
@@ -92,7 +92,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
<!---->
<button
aria-expanded="false"
- aria-haspopup="true"
+ aria-haspopup="menu"
class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle"
type="button"
>
@@ -113,7 +113,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
role="img"
>
<use
- href="#chevron-down"
+ href="file-mock#chevron-down"
/>
</svg>
</button>
@@ -144,7 +144,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
role="img"
>
<use
- href="#search"
+ href="file-mock#search"
/>
</svg>
@@ -201,7 +201,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
role="img"
>
<use
- href="#arrow-right"
+ href="file-mock#arrow-right"
/>
</svg>
</td>
@@ -217,7 +217,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
<!---->
<button
aria-expanded="false"
- aria-haspopup="true"
+ aria-haspopup="menu"
class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle"
type="button"
>
@@ -238,7 +238,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
role="img"
>
<use
- href="#chevron-down"
+ href="file-mock#chevron-down"
/>
</svg>
</button>
@@ -269,7 +269,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
role="img"
>
<use
- href="#search"
+ href="file-mock#search"
/>
</svg>
diff --git a/spec/frontend/jira_import/components/jira_import_app_spec.js b/spec/frontend/jira_import/components/jira_import_app_spec.js
index 022a0f81aaa..dc1b75f5d9e 100644
--- a/spec/frontend/jira_import/components/jira_import_app_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_app_spec.js
@@ -67,11 +67,6 @@ describe('JiraImportApp', () => {
},
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when Jira integration is not configured', () => {
beforeEach(() => {
wrapper = mountComponent({ isJiraConfigured: false });
diff --git a/spec/frontend/jira_import/components/jira_import_form_spec.js b/spec/frontend/jira_import/components/jira_import_form_spec.js
index d43a9f8a145..7fd6398aaa4 100644
--- a/spec/frontend/jira_import/components/jira_import_form_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_form_spec.js
@@ -106,7 +106,6 @@ describe('JiraImportForm', () => {
axiosMock.restore();
mutateSpy.mockRestore();
querySpy.mockRestore();
- wrapper.destroy();
});
describe('select dropdown project selection', () => {
@@ -305,7 +304,7 @@ describe('JiraImportForm', () => {
expect(getContinueButton().text()).toBe('Continue');
});
- it('is in loading state when the form is submitting', async () => {
+ it('is in loading state when the form is submitting', () => {
wrapper = mountComponent({ isSubmitting: true });
expect(getContinueButton().props('loading')).toBe(true);
@@ -417,7 +416,7 @@ describe('JiraImportForm', () => {
wrapper = mountComponent({ hasMoreUsers: true });
});
- it('calls the GraphQL user mapping mutation', async () => {
+ it('calls the GraphQL user mapping mutation', () => {
const mutationArguments = {
mutation: getJiraUserMappingMutation,
variables: {
diff --git a/spec/frontend/jira_import/components/jira_import_progress_spec.js b/spec/frontend/jira_import/components/jira_import_progress_spec.js
index 42356763492..c0d415a2130 100644
--- a/spec/frontend/jira_import/components/jira_import_progress_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_progress_spec.js
@@ -25,11 +25,6 @@ describe('JiraImportProgress', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('empty state', () => {
beforeEach(() => {
wrapper = mountComponent();
diff --git a/spec/frontend/jira_import/components/jira_import_setup_spec.js b/spec/frontend/jira_import/components/jira_import_setup_spec.js
index 0085a2b5572..5331467d669 100644
--- a/spec/frontend/jira_import/components/jira_import_setup_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_setup_spec.js
@@ -17,11 +17,6 @@ describe('JiraImportSetup', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('contains illustration', () => {
expect(getGlEmptyStateProp('svgPath')).toBe(illustration);
});
diff --git a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js
index 14613775791..5ecddc7efd6 100644
--- a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js
+++ b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js
@@ -27,10 +27,6 @@ describe('Jobs filtered search', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays filtered search', () => {
createComponent();
diff --git a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js
index fbe5f6a2e11..6755b854f01 100644
--- a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js
+++ b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js
@@ -45,10 +45,6 @@ describe('Job Status Token', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
diff --git a/spec/frontend/jobs/components/job/artifacts_block_spec.js b/spec/frontend/jobs/components/job/artifacts_block_spec.js
index c75deb64d84..ea5d727bd08 100644
--- a/spec/frontend/jobs/components/job/artifacts_block_spec.js
+++ b/spec/frontend/jobs/components/job/artifacts_block_spec.js
@@ -55,11 +55,6 @@ describe('Artifacts block', () => {
locked: true,
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('with expired artifacts that are not locked', () => {
beforeEach(() => {
wrapper = createWrapper({
diff --git a/spec/frontend/jobs/components/job/commit_block_spec.js b/spec/frontend/jobs/components/job/commit_block_spec.js
index 4fcc754c82c..1c28b5079d7 100644
--- a/spec/frontend/jobs/components/job/commit_block_spec.js
+++ b/spec/frontend/jobs/components/job/commit_block_spec.js
@@ -32,10 +32,6 @@ describe('Commit block', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('without merge request', () => {
beforeEach(() => {
mountComponent();
diff --git a/spec/frontend/jobs/components/job/empty_state_spec.js b/spec/frontend/jobs/components/job/empty_state_spec.js
index c6ab259bf46..970c2591795 100644
--- a/spec/frontend/jobs/components/job/empty_state_spec.js
+++ b/spec/frontend/jobs/components/job/empty_state_spec.js
@@ -35,13 +35,6 @@ describe('Empty State', () => {
const findAction = () => wrapper.findByTestId('job-empty-state-action');
const findManualVarsForm = () => wrapper.findComponent(ManualVariablesForm);
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
describe('renders image and title', () => {
beforeEach(() => {
createWrapper();
diff --git a/spec/frontend/jobs/components/job/environments_block_spec.js b/spec/frontend/jobs/components/job/environments_block_spec.js
index 134533e2af8..ab36f79ea5e 100644
--- a/spec/frontend/jobs/components/job/environments_block_spec.js
+++ b/spec/frontend/jobs/components/job/environments_block_spec.js
@@ -51,11 +51,6 @@ describe('Environments block', () => {
const findEnvironmentLink = () => wrapper.find('[data-testid="job-environment-link"]');
const findClusterLink = () => wrapper.find('[data-testid="job-cluster-link"]');
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('with last deployment', () => {
it('renders info for most recent deployment', () => {
createComponent({
diff --git a/spec/frontend/jobs/components/job/erased_block_spec.js b/spec/frontend/jobs/components/job/erased_block_spec.js
index c6aba01fa53..aeab676fc7e 100644
--- a/spec/frontend/jobs/components/job/erased_block_spec.js
+++ b/spec/frontend/jobs/components/job/erased_block_spec.js
@@ -18,10 +18,6 @@ describe('Erased block', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with job erased by user', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/jobs/components/job/job_app_spec.js b/spec/frontend/jobs/components/job/job_app_spec.js
index cefedcd82fb..394fc8ad43c 100644
--- a/spec/frontend/jobs/components/job/job_app_spec.js
+++ b/spec/frontend/jobs/components/job/job_app_spec.js
@@ -83,8 +83,9 @@ describe('Job App', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
+ // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy
+ wrapper.destroy();
});
describe('while loading', () => {
diff --git a/spec/frontend/jobs/components/job/job_container_item_spec.js b/spec/frontend/jobs/components/job/job_container_item_spec.js
index 05c38dd74b7..8121aa1172f 100644
--- a/spec/frontend/jobs/components/job/job_container_item_spec.js
+++ b/spec/frontend/jobs/components/job/job_container_item_spec.js
@@ -24,11 +24,6 @@ describe('JobContainerItem', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when a job is not active and not retried', () => {
beforeEach(() => {
createComponent(job);
diff --git a/spec/frontend/jobs/components/job/job_log_controllers_spec.js b/spec/frontend/jobs/components/job/job_log_controllers_spec.js
index 5e9a73b4387..218096b9745 100644
--- a/spec/frontend/jobs/components/job/job_log_controllers_spec.js
+++ b/spec/frontend/jobs/components/job/job_log_controllers_spec.js
@@ -16,9 +16,6 @@ describe('Job log controllers', () => {
});
afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
commonUtils.backOff.mockReset();
});
@@ -133,7 +130,7 @@ describe('Job log controllers', () => {
});
it('renders disabled scroll top button', () => {
- expect(findScrollTop().attributes('disabled')).toBe('disabled');
+ expect(findScrollTop().attributes('disabled')).toBeDefined();
});
it('does not emit scrollJobLogTop event on click', async () => {
@@ -285,6 +282,18 @@ describe('Job log controllers', () => {
expect(findScrollFailure().props('disabled')).toBe(false);
});
});
+
+ describe('on error', () => {
+ beforeEach(() => {
+ jest.spyOn(commonUtils, 'backOff').mockRejectedValueOnce();
+
+ createWrapper({}, { jobLogJumpToFailures: true });
+ });
+
+ it('stays disabled', () => {
+ expect(findScrollFailure().props('disabled')).toBe(true);
+ });
+ });
});
});
diff --git a/spec/frontend/jobs/components/job/job_retry_forward_deployment_modal_spec.js b/spec/frontend/jobs/components/job/job_retry_forward_deployment_modal_spec.js
index d60043f33f7..a44a13259aa 100644
--- a/spec/frontend/jobs/components/job/job_retry_forward_deployment_modal_spec.js
+++ b/spec/frontend/jobs/components/job/job_retry_forward_deployment_modal_spec.js
@@ -27,13 +27,6 @@ describe('Job Retry Forward Deployment Modal', () => {
});
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
beforeEach(createWrapper);
describe('Modal configuration', () => {
@@ -64,13 +57,11 @@ describe('Job Retry Forward Deployment Modal', () => {
beforeEach(createWrapper);
it('should correctly configure the primary action', () => {
- expect(findModal().props('actionPrimary').attributes).toMatchObject([
- {
- 'data-method': 'post',
- href: job.retry_path,
- variant: 'danger',
- },
- ]);
+ expect(findModal().props('actionPrimary').attributes).toMatchObject({
+ 'data-method': 'post',
+ href: job.retry_path,
+ variant: 'danger',
+ });
});
});
});
diff --git a/spec/frontend/jobs/components/job/job_sidebar_details_container_spec.js b/spec/frontend/jobs/components/job/job_sidebar_details_container_spec.js
index 4da17ed8366..c1028f3929d 100644
--- a/spec/frontend/jobs/components/job/job_sidebar_details_container_spec.js
+++ b/spec/frontend/jobs/components/job/job_sidebar_details_container_spec.js
@@ -26,13 +26,6 @@ describe('Job Sidebar Details Container', () => {
);
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
describe('when no details are available', () => {
beforeEach(() => {
createWrapper();
diff --git a/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js
index 91821a38a78..8a63bfdc3d6 100644
--- a/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js
+++ b/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js
@@ -24,12 +24,6 @@ describe('Job Sidebar Retry Button', () => {
});
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
beforeEach(createWrapper);
it.each([
diff --git a/spec/frontend/jobs/components/job/jobs_container_spec.js b/spec/frontend/jobs/components/job/jobs_container_spec.js
index 2fde4d3020b..05660880751 100644
--- a/spec/frontend/jobs/components/job/jobs_container_spec.js
+++ b/spec/frontend/jobs/components/job/jobs_container_spec.js
@@ -68,10 +68,6 @@ describe('Jobs List block', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a list of jobs', () => {
createComponent({
jobs: [job, retried, active],
diff --git a/spec/frontend/jobs/components/job/manual_variables_form_spec.js b/spec/frontend/jobs/components/job/manual_variables_form_spec.js
index a5b3b0e3b47..a48155d93ac 100644
--- a/spec/frontend/jobs/components/job/manual_variables_form_spec.js
+++ b/spec/frontend/jobs/components/job/manual_variables_form_spec.js
@@ -2,24 +2,28 @@ import { GlSprintf, GlLink } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
+import { createAlert } from '~/alert';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TYPENAME_CI_BUILD } from '~/graphql_shared/constants';
+import { JOB_GRAPHQL_ERRORS } from '~/jobs/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import waitForPromises from 'helpers/wait_for_promises';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue';
import getJobQuery from '~/jobs/components/job/graphql/queries/get_job.query.graphql';
-import retryJobMutation from '~/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql';
+import playJobMutation from '~/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql';
import {
mockFullPath,
mockId,
mockJobResponse,
mockJobWithVariablesResponse,
- mockJobMutationData,
+ mockJobPlayMutationData,
+ mockJobRetryMutationData,
} from './mock_data';
const localVue = createLocalVue();
+jest.mock('~/alert');
localVue.use(VueApollo);
jest.mock('~/lib/utils/url_utility', () => ({
@@ -39,9 +43,9 @@ describe('Manual Variables Form', () => {
const createComponent = ({ options = {}, props = {} } = {}) => {
wrapper = mountExtended(ManualVariablesForm, {
propsData: {
- ...props,
jobId: mockId,
- isRetryable: true,
+ isRetryable: false,
+ ...props,
},
provide: {
...defaultProvide,
@@ -50,7 +54,7 @@ describe('Manual Variables Form', () => {
});
};
- const createComponentWithApollo = async ({ props = {} } = {}) => {
+ const createComponentWithApollo = ({ props = {} } = {}) => {
const requestHandlers = [[getJobQuery, getJobQueryResponse]];
mockApollo = createMockApollo(requestHandlers);
@@ -71,7 +75,7 @@ describe('Manual Variables Form', () => {
const findHelpText = () => wrapper.findComponent(GlSprintf);
const findHelpLink = () => wrapper.findComponent(GlLink);
const findCancelBtn = () => wrapper.findByTestId('cancel-btn');
- const findRerunBtn = () => wrapper.findByTestId('run-manual-job-btn');
+ const findRunBtn = () => wrapper.findByTestId('run-manual-job-btn');
const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn');
const findAllDeleteVarBtns = () => wrapper.findAllByTestId('delete-variable-btn');
const findDeleteVarBtnPlaceholder = () => wrapper.findByTestId('delete-variable-btn-placeholder');
@@ -97,7 +101,7 @@ describe('Manual Variables Form', () => {
});
afterEach(() => {
- wrapper.destroy();
+ createAlert.mockClear();
});
describe('when page renders', () => {
@@ -112,10 +116,30 @@ describe('Manual Variables Form', () => {
'/help/ci/variables/index#add-a-cicd-variable-to-a-project',
);
});
+ });
- it('renders buttons', () => {
- expect(findCancelBtn().exists()).toBe(true);
- expect(findRerunBtn().exists()).toBe(true);
+ describe('when query is unsuccessful', () => {
+ beforeEach(async () => {
+ getJobQueryResponse.mockRejectedValue({});
+ await createComponentWithApollo();
+ });
+
+ it('shows an alert with error', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText,
+ });
+ });
+ });
+
+ describe('when job has not been retried', () => {
+ beforeEach(async () => {
+ getJobQueryResponse.mockResolvedValue(mockJobWithVariablesResponse);
+ await createComponentWithApollo();
+ });
+
+ it('does not render the cancel button', () => {
+ expect(findCancelBtn().exists()).toBe(false);
+ expect(findRunBtn().exists()).toBe(true);
});
});
@@ -135,10 +159,10 @@ describe('Manual Variables Form', () => {
});
});
- describe('when mutation fires', () => {
+ describe('when play mutation fires', () => {
beforeEach(async () => {
await createComponentWithApollo();
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockJobMutationData);
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockJobPlayMutationData);
});
it('passes variables in correct format', async () => {
@@ -146,11 +170,11 @@ describe('Manual Variables Form', () => {
await findCiVariableValue().setValue('new value');
- await findRerunBtn().vm.$emit('click');
+ await findRunBtn().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: retryJobMutation,
+ mutation: playJobMutation,
variables: {
id: convertToGraphQLId(TYPENAME_CI_BUILD, mockId),
variables: [
@@ -163,13 +187,63 @@ describe('Manual Variables Form', () => {
});
});
- // redirect to job after initial trigger assertion will be added in https://gitlab.com/gitlab-org/gitlab/-/issues/377268
+ it('redirects to job properly after job is run', async () => {
+ findRunBtn().vm.$emit('click');
+ await waitForPromises();
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+ expect(redirectTo).toHaveBeenCalledWith(mockJobPlayMutationData.data.jobPlay.job.webPath); // eslint-disable-line import/no-deprecated
+ });
+ });
+
+ describe('when play mutation is unsuccessful', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({});
+ await createComponentWithApollo();
+ });
+
+ it('shows an alert with error', async () => {
+ findRunBtn().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText,
+ });
+ });
+ });
+
+ describe('when job is retryable', () => {
+ beforeEach(async () => {
+ await createComponentWithApollo({ props: { isRetryable: true } });
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockJobRetryMutationData);
+ });
+
+ it('renders cancel button', () => {
+ expect(findCancelBtn().exists()).toBe(true);
+ });
+
it('redirects to job properly after rerun', async () => {
- findRerunBtn().vm.$emit('click');
+ findRunBtn().vm.$emit('click');
await waitForPromises();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
- expect(redirectTo).toHaveBeenCalledWith(mockJobMutationData.data.jobRetry.job.webPath);
+ expect(redirectTo).toHaveBeenCalledWith(mockJobRetryMutationData.data.jobRetry.job.webPath); // eslint-disable-line import/no-deprecated
+ });
+ });
+
+ describe('when retry mutation is unsuccessful', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({});
+ await createComponentWithApollo({ props: { isRetryable: true } });
+ });
+
+ it('shows an alert with error', async () => {
+ findRunBtn().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText,
+ });
});
});
@@ -235,7 +309,7 @@ describe('Manual Variables Form', () => {
await createComponentWithApollo();
});
- it('delete variable button placeholder should only exist when a user cannot remove', async () => {
+ it('delete variable button placeholder should only exist when a user cannot remove', () => {
expect(findDeleteVarBtnPlaceholder().exists()).toBe(true);
});
diff --git a/spec/frontend/jobs/components/job/mock_data.js b/spec/frontend/jobs/components/job/mock_data.js
index 8a838acca7a..fb3a361c9c9 100644
--- a/spec/frontend/jobs/components/job/mock_data.js
+++ b/spec/frontend/jobs/components/job/mock_data.js
@@ -50,7 +50,32 @@ export const mockJobWithVariablesResponse = {
},
};
-export const mockJobMutationData = {
+export const mockJobPlayMutationData = {
+ data: {
+ jobPlay: {
+ job: {
+ id: 'gid://gitlab/Ci::Build/401',
+ manualVariables: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Ci::JobVariable/151',
+ key: 'new key',
+ value: 'new value',
+ __typename: 'CiManualVariable',
+ },
+ ],
+ __typename: 'CiManualVariableConnection',
+ },
+ webPath: '/Commit451/lab-coat/-/jobs/401',
+ __typename: 'CiJob',
+ },
+ errors: [],
+ __typename: 'JobPlayPayload',
+ },
+ },
+};
+
+export const mockJobRetryMutationData = {
data: {
jobRetry: {
job: {
diff --git a/spec/frontend/jobs/components/job/sidebar_detail_row_spec.js b/spec/frontend/jobs/components/job/sidebar_detail_row_spec.js
index 5c9c011b4ab..fd27004816a 100644
--- a/spec/frontend/jobs/components/job/sidebar_detail_row_spec.js
+++ b/spec/frontend/jobs/components/job/sidebar_detail_row_spec.js
@@ -1,5 +1,4 @@
-import { GlLink } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SidebarDetailRow from '~/jobs/components/job/sidebar/sidebar_detail_row.vue';
describe('Sidebar detail row', () => {
@@ -8,23 +7,20 @@ describe('Sidebar detail row', () => {
const title = 'this is the title';
const value = 'this is the value';
const helpUrl = 'https://docs.gitlab.com/runner/register/index.html';
+ const path = 'path/to/value';
- const findHelpLink = () => wrapper.findComponent(GlLink);
+ const findHelpLink = () => wrapper.findByTestId('job-sidebar-help-link');
+ const findValueLink = () => wrapper.findByTestId('job-sidebar-value-link');
const createComponent = (props) => {
- wrapper = shallowMount(SidebarDetailRow, {
+ wrapper = shallowMountExtended(SidebarDetailRow, {
propsData: {
...props,
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('with title/value and without helpUrl', () => {
+ describe('with title/value and without helpUrl/path', () => {
beforeEach(() => {
createComponent({ title, value });
});
@@ -36,6 +32,10 @@ describe('Sidebar detail row', () => {
it('should not render the help link', () => {
expect(findHelpLink().exists()).toBe(false);
});
+
+ it('should not render the value link', () => {
+ expect(findValueLink().exists()).toBe(false);
+ });
});
describe('when helpUrl provided', () => {
@@ -52,4 +52,16 @@ describe('Sidebar detail row', () => {
expect(findHelpLink().attributes('href')).toBe(helpUrl);
});
});
+
+ describe('when path is provided', () => {
+ it('should render link to value', () => {
+ createComponent({
+ path,
+ title,
+ value,
+ });
+
+ expect(findValueLink().attributes('href')).toBe(path);
+ });
+ });
});
diff --git a/spec/frontend/jobs/components/job/sidebar_header_spec.js b/spec/frontend/jobs/components/job/sidebar_header_spec.js
index da97945f9bf..cf182330578 100644
--- a/spec/frontend/jobs/components/job/sidebar_header_spec.js
+++ b/spec/frontend/jobs/components/job/sidebar_header_spec.js
@@ -31,7 +31,7 @@ describe('Sidebar Header', () => {
});
};
- const createComponentWithApollo = async ({ props = {}, restJob = {} } = {}) => {
+ const createComponentWithApollo = ({ props = {}, restJob = {} } = {}) => {
const getJobQueryResponse = jest.fn().mockResolvedValue(mockJobResponse);
const requestHandlers = [[getJobQuery, getJobQueryResponse]];
diff --git a/spec/frontend/jobs/components/job/sidebar_spec.js b/spec/frontend/jobs/components/job/sidebar_spec.js
index aa9ca932023..fbff64b4d78 100644
--- a/spec/frontend/jobs/components/job/sidebar_spec.js
+++ b/spec/frontend/jobs/components/job/sidebar_spec.js
@@ -48,10 +48,6 @@ describe('Sidebar details block', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('without terminal path', () => {
it('does not render terminal link', async () => {
createWrapper();
@@ -143,7 +139,7 @@ describe('Sidebar details block', () => {
return store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
});
- it('renders list of jobs', async () => {
+ it('renders list of jobs', () => {
expect(findJobsContainer().exists()).toBe(true);
});
});
@@ -151,7 +147,7 @@ describe('Sidebar details block', () => {
describe('when job data changes', () => {
const stageArg = job.pipeline.details.stages.find((stage) => stage.name === job.stage);
- beforeEach(async () => {
+ beforeEach(() => {
jest.spyOn(store, 'dispatch');
});
diff --git a/spec/frontend/jobs/components/job/stages_dropdown_spec.js b/spec/frontend/jobs/components/job/stages_dropdown_spec.js
index 61dec585e82..9d01dc50e96 100644
--- a/spec/frontend/jobs/components/job/stages_dropdown_spec.js
+++ b/spec/frontend/jobs/components/job/stages_dropdown_spec.js
@@ -1,6 +1,6 @@
import { GlDropdown, GlDropdownItem, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import StagesDropdown from '~/jobs/components/job/sidebar/stages_dropdown.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -37,10 +37,6 @@ describe('Stages Dropdown', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('without a merge request pipeline', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/jobs/components/job/stuck_block_spec.js b/spec/frontend/jobs/components/job/stuck_block_spec.js
index 8dc570cce27..0f014a9222b 100644
--- a/spec/frontend/jobs/components/job/stuck_block_spec.js
+++ b/spec/frontend/jobs/components/job/stuck_block_spec.js
@@ -5,13 +5,6 @@ import StuckBlock from '~/jobs/components/job/stuck_block.vue';
describe('Stuck Block Job component', () => {
let wrapper;
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const createWrapper = (props) => {
wrapper = shallowMount(StuckBlock, {
propsData: {
diff --git a/spec/frontend/jobs/components/job/trigger_block_spec.js b/spec/frontend/jobs/components/job/trigger_block_spec.js
index a1de8fd143f..8bb2c1f3ad8 100644
--- a/spec/frontend/jobs/components/job/trigger_block_spec.js
+++ b/spec/frontend/jobs/components/job/trigger_block_spec.js
@@ -20,10 +20,6 @@ describe('Trigger block', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with short token and no variables', () => {
it('renders short token', () => {
createComponent({
diff --git a/spec/frontend/jobs/components/job/unmet_prerequisites_block_spec.js b/spec/frontend/jobs/components/job/unmet_prerequisites_block_spec.js
index fb7d389c4d6..1072cdd6781 100644
--- a/spec/frontend/jobs/components/job/unmet_prerequisites_block_spec.js
+++ b/spec/frontend/jobs/components/job/unmet_prerequisites_block_spec.js
@@ -18,10 +18,6 @@ describe('Unmet Prerequisites Block Job component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders an alert with the correct message', () => {
const container = wrapper.findComponent(GlAlert);
const alertMessage =
diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js
index 646935568b1..5adedea28a5 100644
--- a/spec/frontend/jobs/components/log/collapsible_section_spec.js
+++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js
@@ -19,10 +19,6 @@ describe('Job Log Collapsible Section', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with closed section', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/jobs/components/log/duration_badge_spec.js b/spec/frontend/jobs/components/log/duration_badge_spec.js
index 84dae386bdb..644d05366a0 100644
--- a/spec/frontend/jobs/components/log/duration_badge_spec.js
+++ b/spec/frontend/jobs/components/log/duration_badge_spec.js
@@ -20,10 +20,6 @@ describe('Job Log Duration Badge', () => {
createComponent(data);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders provided duration', () => {
expect(wrapper.text()).toBe(data.duration);
});
diff --git a/spec/frontend/jobs/components/log/line_header_spec.js b/spec/frontend/jobs/components/log/line_header_spec.js
index ec8e79bba13..16fe753e08a 100644
--- a/spec/frontend/jobs/components/log/line_header_spec.js
+++ b/spec/frontend/jobs/components/log/line_header_spec.js
@@ -29,10 +29,6 @@ describe('Job Log Header Line', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('line', () => {
beforeEach(() => {
createComponent(data);
diff --git a/spec/frontend/jobs/components/log/line_number_spec.js b/spec/frontend/jobs/components/log/line_number_spec.js
index 96aa31baab9..4130c124a30 100644
--- a/spec/frontend/jobs/components/log/line_number_spec.js
+++ b/spec/frontend/jobs/components/log/line_number_spec.js
@@ -21,10 +21,6 @@ describe('Job Log Line Number', () => {
createComponent(data);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders incremented lineNunber by 1', () => {
expect(wrapper.text()).toBe('1');
});
diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js
index c933ed5c3e1..20638b13169 100644
--- a/spec/frontend/jobs/components/log/log_spec.js
+++ b/spec/frontend/jobs/components/log/log_spec.js
@@ -1,15 +1,24 @@
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
+import waitForPromises from 'helpers/wait_for_promises';
+import { scrollToElement } from '~/lib/utils/common_utils';
import Log from '~/jobs/components/log/log.vue';
+import LogLineHeader from '~/jobs/components/log/line_header.vue';
import { logLinesParser } from '~/jobs/store/utils';
import { jobLog } from './mock_data';
+jest.mock('~/lib/utils/common_utils', () => ({
+ ...jest.requireActual('~/lib/utils/common_utils'),
+ scrollToElement: jest.fn(),
+}));
+
describe('Job Log', () => {
let wrapper;
let actions;
let state;
let store;
+ let toggleCollapsibleLineMock;
Vue.use(Vuex);
@@ -20,8 +29,9 @@ describe('Job Log', () => {
};
beforeEach(() => {
+ toggleCollapsibleLineMock = jest.fn();
actions = {
- toggleCollapsibleLine: () => {},
+ toggleCollapsibleLine: toggleCollapsibleLineMock,
};
state = {
@@ -33,17 +43,15 @@ describe('Job Log', () => {
actions,
state,
});
-
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
});
- const findCollapsibleLine = () => wrapper.find('.collapsible-line');
+ const findCollapsibleLine = () => wrapper.findComponent(LogLineHeader);
describe('line numbers', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
it('renders a line number for each open line', () => {
expect(wrapper.find('#L1').text()).toBe('1');
expect(wrapper.find('#L2').text()).toBe('2');
@@ -56,6 +64,10 @@ describe('Job Log', () => {
});
describe('collapsible sections', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
it('renders a clickable header section', () => {
expect(findCollapsibleLine().attributes('role')).toBe('button');
});
@@ -68,11 +80,54 @@ describe('Job Log', () => {
describe('on click header section', () => {
it('calls toggleCollapsibleLine', () => {
- jest.spyOn(wrapper.vm, 'toggleCollapsibleLine');
-
findCollapsibleLine().trigger('click');
- expect(wrapper.vm.toggleCollapsibleLine).toHaveBeenCalled();
+ expect(toggleCollapsibleLineMock).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('anchor scrolling', () => {
+ afterEach(() => {
+ window.location.hash = '';
+ });
+
+ describe('when hash is not present', () => {
+ it('does not scroll to line number', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.find('#L6').exists()).toBe(false);
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when hash is present', () => {
+ beforeEach(() => {
+ window.location.hash = '#L6';
+ });
+
+ it('scrolls to line number', async () => {
+ createComponent();
+
+ state.jobLog = logLinesParser(jobLog, [], '#L6');
+ await waitForPromises();
+
+ expect(scrollToElement).toHaveBeenCalledTimes(1);
+
+ state.jobLog = logLinesParser(jobLog, [], '#L7');
+ await waitForPromises();
+
+ expect(scrollToElement).toHaveBeenCalledTimes(1);
+ });
+
+ it('line number within collapsed section is visible', () => {
+ state.jobLog = logLinesParser(jobLog, [], '#L6');
+
+ createComponent();
+
+ expect(wrapper.find('#L6').exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js
index eb8c4fe8bc9..fa51b92a044 100644
--- a/spec/frontend/jobs/components/log/mock_data.js
+++ b/spec/frontend/jobs/components/log/mock_data.js
@@ -22,6 +22,30 @@ export const jobLog = [
content: [{ text: 'Starting service postgres:9.6.14 ...', style: 'text-green' }],
section: 'prepare-executor',
},
+ {
+ offset: 1004,
+ content: [
+ {
+ text: 'Restore cache',
+ style: 'term-fg-l-cyan term-bold',
+ },
+ ],
+ section: 'restore-cache',
+ section_header: true,
+ section_options: {
+ collapsed: 'true',
+ },
+ },
+ {
+ offset: 1005,
+ content: [
+ {
+ text: 'Checking cache for ruby-gems-debian-bullseye-ruby-3.0-16...',
+ style: 'term-fg-l-green term-bold',
+ },
+ ],
+ section: 'restore-cache',
+ },
];
export const utilsMockData = [
diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
index 55fe534aa3b..f2d249b6014 100644
--- a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
@@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue';
import eventHub from '~/jobs/components/table/event_hub';
import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql';
@@ -122,7 +122,7 @@ describe('Job actions cell', () => {
${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${playableJob.id}
${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${retryableJob.id}
${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler} | ${cancelableJob.id}
- `('performs the $action mutation', async ({ button, jobType, mutationFile, handler, jobId }) => {
+ `('performs the $action mutation', ({ button, jobType, mutationFile, handler, jobId }) => {
createComponent(jobType, [[mutationFile, handler]]);
button().vm.$emit('click');
@@ -146,7 +146,7 @@ describe('Job actions cell', () => {
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith('jobActionPerformed');
- expect(redirectTo).not.toHaveBeenCalled();
+ expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
},
);
@@ -165,7 +165,7 @@ describe('Job actions cell', () => {
await waitForPromises();
- expect(redirectTo).toHaveBeenCalledWith(redirectLink);
+ expect(redirectTo).toHaveBeenCalledWith(redirectLink); // eslint-disable-line import/no-deprecated
expect(eventHub.$emit).not.toHaveBeenCalled();
},
);
diff --git a/spec/frontend/jobs/components/table/cells/duration_cell_spec.js b/spec/frontend/jobs/components/table/cells/duration_cell_spec.js
index 763a4b0eaa2..d015edb0e91 100644
--- a/spec/frontend/jobs/components/table/cells/duration_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/duration_cell_spec.js
@@ -22,10 +22,6 @@ describe('Duration Cell', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('does not display duration or finished time when no properties are present', () => {
createComponent();
diff --git a/spec/frontend/jobs/components/table/cells/job_cell_spec.js b/spec/frontend/jobs/components/table/cells/job_cell_spec.js
index ddc196129a7..73e37eed5f1 100644
--- a/spec/frontend/jobs/components/table/cells/job_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/job_cell_spec.js
@@ -39,10 +39,6 @@ describe('Job Cell', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Job Id', () => {
it('displays the job id and links to the job', () => {
createComponent();
diff --git a/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js b/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js
index 1f5e0a7aa21..3d424b20964 100644
--- a/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js
@@ -42,10 +42,6 @@ describe('Pipeline Cell', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Pipeline Id', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js
index 88c97285b85..e3b1ca1cce3 100644
--- a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js
+++ b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js
@@ -84,4 +84,23 @@ describe('jobs/components/table/graphql/cache_config', () => {
expect(res.nodes).toHaveLength(CIJobConnectionIncomingCacheRunningStatus.nodes.length);
});
});
+
+ describe('when incoming data has no nodes', () => {
+ it('should return existing cache', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge(
+ CIJobConnectionExistingCache,
+ { __typename: 'CiJobConnection', count: 500 },
+ {
+ args: { statuses: 'SUCCESS' },
+ },
+ );
+
+ const expectedResponse = {
+ ...CIJobConnectionExistingCache,
+ statuses: 'SUCCESS',
+ };
+
+ expect(res).toEqual(expectedResponse);
+ });
+ });
});
diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js
index 109cef6f817..0e59e9ab5b6 100644
--- a/spec/frontend/jobs/components/table/job_table_app_spec.js
+++ b/spec/frontend/jobs/components/table/job_table_app_spec.js
@@ -1,10 +1,4 @@
-import {
- GlSkeletonLoader,
- GlAlert,
- GlEmptyState,
- GlIntersectionObserver,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlAlert, GlEmptyState, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@@ -12,23 +6,26 @@ import { s__ } from '~/locale';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql';
+import getJobsCountQuery from '~/jobs/components/table/graphql/queries/get_jobs_count.query.graphql';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue';
+import JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue';
import * as urlUtils from '~/lib/utils/url_utility';
import {
mockJobsResponsePaginated,
mockJobsResponseEmpty,
mockFailedSearchToken,
+ mockJobsCountResponse,
} from '../../mock_data';
const projectPath = 'gitlab-org/gitlab';
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Job table app', () => {
let wrapper;
@@ -37,7 +34,9 @@ describe('Job table app', () => {
const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
const emptyHandler = jest.fn().mockResolvedValue(mockJobsResponseEmpty);
- const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const countSuccessHandler = jest.fn().mockResolvedValue(mockJobsCountResponse);
+
+ const findSkeletonLoader = () => wrapper.findComponent(JobsSkeletonLoader);
const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
const findTable = () => wrapper.findComponent(JobsTable);
const findTabs = () => wrapper.findComponent(JobsTableTabs);
@@ -48,14 +47,18 @@ describe('Job table app', () => {
const triggerInfiniteScroll = () =>
wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
- const createMockApolloProvider = (handler) => {
- const requestHandlers = [[getJobsQuery, handler]];
+ const createMockApolloProvider = (handler, countHandler) => {
+ const requestHandlers = [
+ [getJobsQuery, handler],
+ [getJobsCountQuery, countHandler],
+ ];
return createMockApollo(requestHandlers);
};
const createComponent = ({
handler = successHandler,
+ countHandler = countSuccessHandler,
mountFn = shallowMount,
data = {},
} = {}) => {
@@ -68,14 +71,10 @@ describe('Job table app', () => {
provide: {
fullPath: projectPath,
},
- apolloProvider: createMockApolloProvider(handler),
+ apolloProvider: createMockApolloProvider(handler, countHandler),
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('loading state', () => {
it('should display skeleton loader when loading', () => {
createComponent();
@@ -118,6 +117,35 @@ describe('Job table app', () => {
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
});
+ it('avoids refetch jobs query when scope has not changed', async () => {
+ jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+
+ await findTabs().vm.$emit('fetchJobsByStatus', null);
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ });
+
+ it('should refetch jobs count query when the amount jobs and count do not match', async () => {
+ jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+
+ // after applying filter a new count is fetched
+ findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1);
+
+ // tab is switched to `finished`, no count
+ await findTabs().vm.$emit('fetchJobsByStatus', ['FAILED', 'SUCCESS', 'CANCELED']);
+
+ // tab is switched back to `all`, the old filter count has to be overwritten with new count
+ await findTabs().vm.$emit('fetchJobsByStatus', null);
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(2);
+ });
+
describe('when infinite scrolling is triggered', () => {
it('does not display a skeleton loader', () => {
triggerInfiniteScroll();
@@ -148,12 +176,39 @@ describe('Job table app', () => {
});
describe('error state', () => {
- it('should show an alert if there is an error fetching the data', async () => {
+ it('should show an alert if there is an error fetching the jobs data', async () => {
createComponent({ handler: failedHandler });
await waitForPromises();
- expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe('There was an error fetching the jobs for your project.');
+ expect(findTable().exists()).toBe(false);
+ });
+
+ it('should show an alert if there is an error fetching the jobs count data', async () => {
+ createComponent({ handler: successHandler, countHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findAlert().text()).toBe(
+ 'There was an error fetching the number of jobs for your project.',
+ );
+ });
+
+ it('jobs table should still load if count query fails', async () => {
+ createComponent({ handler: successHandler, countHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it('jobs count should be zero if count query fails', async () => {
+ createComponent({ handler: successHandler, countHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findTabs().props('allJobsCount')).toBe(0);
});
});
@@ -215,6 +270,18 @@ describe('Job table app', () => {
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
});
+ it('refetches jobs count query when filtering', async () => {
+ createComponent();
+
+ jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+
+ await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1);
+ });
+
it('shows raw text warning when user inputs raw text', async () => {
const expectedWarning = {
message: s__(
@@ -226,11 +293,13 @@ describe('Job table app', () => {
createComponent();
jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
+ jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']);
expect(createAlert).toHaveBeenCalledWith(expectedWarning);
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
});
it('updates URL query string when filtering jobs by status', async () => {
@@ -244,5 +313,42 @@ describe('Job table app', () => {
url: `${TEST_HOST}/?statuses=FAILED`,
});
});
+
+ it('resets query param after clearing tokens', () => {
+ createComponent();
+
+ jest.spyOn(urlUtils, 'updateHistory');
+
+ findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
+
+ expect(successHandler).toHaveBeenCalledWith({
+ first: 30,
+ fullPath: 'gitlab-org/gitlab',
+ statuses: 'FAILED',
+ });
+ expect(countSuccessHandler).toHaveBeenCalledWith({
+ fullPath: 'gitlab-org/gitlab',
+ statuses: 'FAILED',
+ });
+ expect(urlUtils.updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/?statuses=FAILED`,
+ });
+
+ findFilteredSearch().vm.$emit('filterJobsBySearch', []);
+
+ expect(urlUtils.updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/`,
+ });
+
+ expect(successHandler).toHaveBeenCalledWith({
+ first: 30,
+ fullPath: 'gitlab-org/gitlab',
+ statuses: null,
+ });
+ expect(countSuccessHandler).toHaveBeenCalledWith({
+ fullPath: 'gitlab-org/gitlab',
+ statuses: null,
+ });
+ });
});
});
diff --git a/spec/frontend/jobs/components/table/jobs_table_spec.js b/spec/frontend/jobs/components/table/jobs_table_spec.js
index 3c4f2d624fe..654b6d1c130 100644
--- a/spec/frontend/jobs/components/table/jobs_table_spec.js
+++ b/spec/frontend/jobs/components/table/jobs_table_spec.js
@@ -3,7 +3,10 @@ import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
-import { mockJobsNodes } from '../../mock_data';
+import { DEFAULT_FIELDS_ADMIN } from '~/pages/admin/jobs/components/constants';
+import ProjectCell from '~/pages/admin/jobs/components/table/cell/project_cell.vue';
+import RunnerCell from '~/pages/admin/jobs/components/table/cells/runner_cell.vue';
+import { mockJobsNodes, mockAllJobsNodes } from '../../mock_data';
describe('Jobs Table', () => {
let wrapper;
@@ -13,52 +16,83 @@ describe('Jobs Table', () => {
const findTableRows = () => wrapper.findAllByTestId('jobs-table-row');
const findJobStage = () => wrapper.findByTestId('job-stage-name');
const findJobName = () => wrapper.findByTestId('job-name');
+ const findJobProject = () => wrapper.findComponent(ProjectCell);
+ const findJobRunner = () => wrapper.findComponent(RunnerCell);
const findAllCoverageJobs = () => wrapper.findAllByTestId('job-coverage');
const createComponent = (props = {}) => {
wrapper = extendedWrapper(
mount(JobsTable, {
propsData: {
- jobs: mockJobsNodes,
...props,
},
}),
);
};
- beforeEach(() => {
- createComponent();
- });
+ describe('jobs table', () => {
+ beforeEach(() => {
+ createComponent({ jobs: mockJobsNodes });
+ });
- afterEach(() => {
- wrapper.destroy();
- });
+ it('displays the jobs table', () => {
+ expect(findTable().exists()).toBe(true);
+ });
- it('displays the jobs table', () => {
- expect(findTable().exists()).toBe(true);
- });
+ it('displays correct number of job rows', () => {
+ expect(findTableRows()).toHaveLength(mockJobsNodes.length);
+ });
- it('displays correct number of job rows', () => {
- expect(findTableRows()).toHaveLength(mockJobsNodes.length);
- });
+ it('displays job status', () => {
+ expect(findCiBadgeLink().exists()).toBe(true);
+ });
+
+ it('displays the job stage and name', () => {
+ const [firstJob] = mockJobsNodes;
+
+ expect(findJobStage().text()).toBe(firstJob.stage.name);
+ expect(findJobName().text()).toBe(firstJob.name);
+ });
- it('displays job status', () => {
- expect(findCiBadgeLink().exists()).toBe(true);
+ it('displays the coverage for only jobs that have coverage', () => {
+ const jobsThatHaveCoverage = mockJobsNodes.filter((job) => job.coverage !== null);
+
+ jobsThatHaveCoverage.forEach((job, index) => {
+ expect(findAllCoverageJobs().at(index).text()).toBe(`${job.coverage}%`);
+ });
+ expect(findAllCoverageJobs()).toHaveLength(jobsThatHaveCoverage.length);
+ });
});
- it('displays the job stage and name', () => {
- const firstJob = mockJobsNodes[0];
+ describe('regular user', () => {
+ beforeEach(() => {
+ createComponent({ jobs: mockJobsNodes });
+ });
+
+ it('hides the job runner', () => {
+ expect(findJobRunner().exists()).toBe(false);
+ });
- expect(findJobStage().text()).toBe(firstJob.stage.name);
- expect(findJobName().text()).toBe(firstJob.name);
+ it('hides the job project link', () => {
+ expect(findJobProject().exists()).toBe(false);
+ });
});
- it('displays the coverage for only jobs that have coverage', () => {
- const jobsThatHaveCoverage = mockJobsNodes.filter((job) => job.coverage !== null);
+ describe('admin mode', () => {
+ beforeEach(() => {
+ createComponent({ jobs: mockAllJobsNodes, tableFields: DEFAULT_FIELDS_ADMIN, admin: true });
+ });
+
+ it('displays the runner cell', () => {
+ expect(findJobRunner().exists()).toBe(true);
+ });
+
+ it('displays the project cell', () => {
+ expect(findJobProject().exists()).toBe(true);
+ });
- jobsThatHaveCoverage.forEach((job, index) => {
- expect(findAllCoverageJobs().at(index).text()).toBe(`${job.coverage}%`);
+ it('displays correct number of job rows', () => {
+ expect(findTableRows()).toHaveLength(mockAllJobsNodes.length);
});
- expect(findAllCoverageJobs()).toHaveLength(jobsThatHaveCoverage.length);
});
});
diff --git a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js
index 23632001060..d20a732508a 100644
--- a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js
+++ b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js
@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
+import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue';
describe('Jobs Table Tabs', () => {
let wrapper;
@@ -12,6 +13,11 @@ describe('Jobs Table Tabs', () => {
loading: false,
};
+ const adminProps = {
+ ...defaultProps,
+ showCancelAllJobsButton: true,
+ };
+
const statuses = {
success: 'SUCCESS',
failed: 'FAILED',
@@ -20,6 +26,7 @@ describe('Jobs Table Tabs', () => {
const findAllTab = () => wrapper.findByTestId('jobs-all-tab');
const findFinishedTab = () => wrapper.findByTestId('jobs-finished-tab');
+ const findCancelJobsButton = () => wrapper.findAllComponents(CancelJobs);
const triggerTabChange = (index) => wrapper.findAllComponents(GlTab).at(index).vm.$emit('click');
@@ -42,10 +49,6 @@ describe('Jobs Table Tabs', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays All tab with count', () => {
expect(trimText(findAllTab().text())).toBe(`All ${defaultProps.allJobsCount}`);
});
@@ -63,4 +66,16 @@ describe('Jobs Table Tabs', () => {
expect(wrapper.emitted()).toEqual({ fetchJobsByStatus: [[expectedScope]] });
});
+
+ it('does not displays cancel all jobs button', () => {
+ expect(findCancelJobsButton().exists()).toBe(false);
+ });
+
+ describe('admin mode', () => {
+ it('displays cancel all jobs button', () => {
+ createComponent(adminProps);
+
+ expect(findCancelJobsButton().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js
index 1d3845b19bb..098a63719fe 100644
--- a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js
+++ b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js
@@ -16,11 +16,6 @@ describe('DelayedJobMixin', () => {
template: '<div>{{remainingTime}}</div>',
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('if job is empty object', () => {
beforeEach(() => {
wrapper = shallowMount(dummyComponent, {
diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js
index 9abd610c26d..253e669e889 100644
--- a/spec/frontend/jobs/mock_data.js
+++ b/spec/frontend/jobs/mock_data.js
@@ -1,7 +1,13 @@
+import mockJobsCount from 'test_fixtures/graphql/jobs/get_jobs_count.query.graphql.json';
+import mockAllJobsCount from 'test_fixtures/graphql/jobs/get_all_jobs_count.query.graphql.json';
import mockJobsEmpty from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.empty.json';
+import mockAllJobsEmpty from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.empty.json';
import mockJobsPaginated from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.paginated.json';
+import mockAllJobsPaginated from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.paginated.json';
import mockJobs from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.json';
+import mockAllJobs from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.json';
import mockJobsAsGuest from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.as_guest.json';
+import mockCancelableJobsCount from 'test_fixtures/graphql/jobs/get_cancelable_jobs_count.query.graphql.json';
import { TEST_HOST } from 'spec/test_constants';
import { TOKEN_TYPE_STATUS } from '~/vue_shared/components/filtered_search_bar/constants';
@@ -10,9 +16,15 @@ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
// Fixtures generated at spec/frontend/fixtures/jobs.rb
export const mockJobsResponsePaginated = mockJobsPaginated;
+export const mockAllJobsResponsePaginated = mockAllJobsPaginated;
export const mockJobsResponseEmpty = mockJobsEmpty;
+export const mockAllJobsResponseEmpty = mockAllJobsEmpty;
export const mockJobsNodes = mockJobs.data.project.jobs.nodes;
+export const mockAllJobsNodes = mockAllJobs.data.jobs.nodes;
export const mockJobsNodesAsGuest = mockJobsAsGuest.data.project.jobs.nodes;
+export const mockJobsCountResponse = mockJobsCount;
+export const mockAllJobsCountResponse = mockAllJobsCount;
+export const mockCancelableJobsCountResponse = mockCancelableJobsCount;
export const stages = [
{
@@ -920,6 +932,14 @@ export const stages = [
},
];
+export const statuses = {
+ success: 'SUCCESS',
+ failed: 'FAILED',
+ canceled: 'CANCELED',
+ pending: 'PENDING',
+ running: 'RUNNING',
+};
+
export default {
id: 4757,
artifact: {
diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js
index 9458c2184f5..37a6722c555 100644
--- a/spec/frontend/jobs/store/utils_spec.js
+++ b/spec/frontend/jobs/store/utils_spec.js
@@ -43,6 +43,14 @@ describe('Jobs Store Utils', () => {
expect(parsedHeaderLine.isClosed).toBe(true);
});
+
+ it('expands all pre-closed sections if hash is present', () => {
+ const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } };
+
+ const parsedHeaderLine = parseHeaderLine(headerLine, 2, '#L33');
+
+ expect(parsedHeaderLine.isClosed).toBe(false);
+ });
});
describe('parseLine', () => {
diff --git a/spec/frontend/labels/components/delete_label_modal_spec.js b/spec/frontend/labels/components/delete_label_modal_spec.js
index 24a803d3f16..19aef42528a 100644
--- a/spec/frontend/labels/components/delete_label_modal_spec.js
+++ b/spec/frontend/labels/components/delete_label_modal_spec.js
@@ -1,64 +1,55 @@
import { GlModal } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import DeleteLabelModal from '~/labels/components/delete_label_modal.vue';
-const MOCK_MODAL_DATA = {
- labelName: 'label 1',
- subjectName: 'GitLab Org',
- destroyPath: `${TEST_HOST}/1`,
-};
-
describe('~/labels/components/delete_label_modal', () => {
let wrapper;
- const createComponent = () => {
- wrapper = extendedWrapper(
- mount(DeleteLabelModal, {
- propsData: {
- selector: '.js-test-btn',
- },
- stubs: {
- GlModal: stubComponent(GlModal, {
- template:
- '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
- }),
- },
- }),
- );
- };
+ const mountComponent = () => {
+ const button = document.createElement('button');
+ button.classList.add('js-test-btn');
+ button.dataset.destroyPath = `${TEST_HOST}/1`;
+ button.dataset.labelName = 'label 1';
+ button.dataset.subjectName = 'GitLab Org';
+ document.body.append(button);
+
+ wrapper = mountExtended(DeleteLabelModal, {
+ propsData: {
+ selector: '.js-test-btn',
+ },
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ template:
+ '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
+ }),
+ },
+ });
- afterEach(() => {
- wrapper.destroy();
- });
+ button.click();
+ };
const findModal = () => wrapper.findComponent(GlModal);
- const findPrimaryModalButton = () => wrapper.findByTestId('delete-button');
+ const findDeleteButton = () => wrapper.findByRole('link', { name: 'Delete label' });
- describe('template', () => {
- describe('when modal data is set', () => {
- beforeEach(() => {
- createComponent();
- wrapper.vm.labelName = MOCK_MODAL_DATA.labelName;
- wrapper.vm.subjectName = MOCK_MODAL_DATA.subjectName;
- wrapper.vm.destroyPath = MOCK_MODAL_DATA.destroyPath;
- });
+ describe('when modal data is set', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
- it('renders GlModal', () => {
- expect(findModal().exists()).toBe(true);
- });
+ it('renders GlModal', () => {
+ expect(findModal().exists()).toBe(true);
+ });
- it('displays the label name and subject name', () => {
- expect(findModal().text()).toContain(
- `${MOCK_MODAL_DATA.labelName} will be permanently deleted from ${MOCK_MODAL_DATA.subjectName}. This cannot be undone`,
- );
- });
+ it('displays the label name and subject name', () => {
+ expect(findModal().text()).toContain(
+ `label 1 will be permanently deleted from GitLab Org. This cannot be undone`,
+ );
+ });
- it('passes the destroyPath to the button', () => {
- expect(findPrimaryModalButton().attributes('href')).toBe(MOCK_MODAL_DATA.destroyPath);
- });
+ it('passes the destroyPath to the button', () => {
+ expect(findDeleteButton().attributes('href')).toBe('http://test.host/1');
});
});
});
diff --git a/spec/frontend/labels/components/promote_label_modal_spec.js b/spec/frontend/labels/components/promote_label_modal_spec.js
index 97913c20229..5983c16a9d1 100644
--- a/spec/frontend/labels/components/promote_label_modal_spec.js
+++ b/spec/frontend/labels/components/promote_label_modal_spec.js
@@ -41,7 +41,6 @@ describe('Promote label modal', () => {
afterEach(() => {
axiosMock.reset();
- wrapper.destroy();
});
describe('Modal title and description', () => {
diff --git a/spec/frontend/language_switcher/components/app_spec.js b/spec/frontend/language_switcher/components/app_spec.js
index 7f6fb138d89..036ff55fef7 100644
--- a/spec/frontend/language_switcher/components/app_spec.js
+++ b/spec/frontend/language_switcher/components/app_spec.js
@@ -24,10 +24,6 @@ describe('<LanguageSwitcher />', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const getPreferredLanguage = () => wrapper.find('.gl-new-dropdown-button-text').text();
const findLanguageDropdownItem = (code) => wrapper.findByTestId(`language_switcher_lang_${code}`);
const findFooter = () => wrapper.findByTestId('footer');
diff --git a/spec/frontend/lib/apollo/indexed_db_persistent_storage_spec.js b/spec/frontend/lib/apollo/indexed_db_persistent_storage_spec.js
new file mode 100644
index 00000000000..f96364a918e
--- /dev/null
+++ b/spec/frontend/lib/apollo/indexed_db_persistent_storage_spec.js
@@ -0,0 +1,90 @@
+import { IndexedDBPersistentStorage } from '~/lib/apollo/indexed_db_persistent_storage';
+import { db } from '~/lib/apollo/local_db';
+import CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS from './mock_data/cache_with_persist_directive_and_field.json';
+
+describe('IndexedDBPersistentStorage', () => {
+ let subject;
+
+ const seedData = async (cacheKey, data = CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS) => {
+ const { ROOT_QUERY, ...rest } = data;
+
+ await db.table('queries').put(ROOT_QUERY, cacheKey);
+
+ const asyncPuts = Object.entries(rest).map(async ([key, value]) => {
+ const {
+ groups: { type, gid },
+ } = /^(?<type>.+?):(?<gid>.+)$/.exec(key);
+ const tableName = type.toLowerCase();
+
+ if (tableName !== 'projectmember' && tableName !== 'groupmember') {
+ await db.table(tableName).put(value, gid);
+ }
+ });
+
+ await Promise.all(asyncPuts);
+ };
+
+ beforeEach(async () => {
+ subject = await IndexedDBPersistentStorage.create();
+ });
+
+ afterEach(() => {
+ db.close();
+ });
+
+ it('returns empty response if there is nothing stored in the DB', async () => {
+ const result = await subject.getItem('some-query');
+
+ expect(result).toEqual({});
+ });
+
+ it('returns stored cache if cache was persisted in IndexedDB', async () => {
+ await seedData('issues_list', CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS);
+
+ const result = await subject.getItem('issues_list');
+ expect(result).toEqual(CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS);
+ });
+
+ it('puts the results in database on `setItem` call', async () => {
+ await subject.setItem(
+ 'issues_list',
+ JSON.stringify({
+ ROOT_QUERY: 'ROOT_QUERY_KEY',
+ 'Project:gid://gitlab/Project/6': {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/6',
+ },
+ }),
+ );
+
+ await expect(db.table('queries').get('issues_list')).resolves.toEqual('ROOT_QUERY_KEY');
+ await expect(db.table('project').get('gid://gitlab/Project/6')).resolves.toEqual({
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/6',
+ });
+ });
+
+ it('does not put results into non-existent table', async () => {
+ const queryId = 'issues_list';
+
+ await subject.setItem(
+ queryId,
+ JSON.stringify({
+ ROOT_QUERY: 'ROOT_QUERY_KEY',
+ 'DNE:gid://gitlab/DNE/1': {},
+ }),
+ );
+
+ expect(db.tables.map((x) => x.name)).not.toContain('dne');
+ });
+
+ it('when removeItem is called, clears all data', async () => {
+ await seedData('issues_list', CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS);
+
+ await subject.removeItem();
+
+ const actual = await Promise.all(db.tables.map((x) => x.toArray()));
+
+ expect(actual).toEqual(db.tables.map(() => []));
+ });
+});
diff --git a/spec/frontend/lib/apollo/mock_data/cache_with_persist_directive_and_field.json b/spec/frontend/lib/apollo/mock_data/cache_with_persist_directive_and_field.json
index c0651517986..0dfc2240cc3 100644
--- a/spec/frontend/lib/apollo/mock_data/cache_with_persist_directive_and_field.json
+++ b/spec/frontend/lib/apollo/mock_data/cache_with_persist_directive_and_field.json
@@ -171,38 +171,6 @@
}
]
},
- "projectMembers({\"relations\":[\"DIRECT\",\"INHERITED\",\"INVITED_GROUPS\"],\"search\":\"\"})": {
- "__typename": "MemberInterfaceConnection",
- "nodes": [
- {
- "__ref": "ProjectMember:gid://gitlab/ProjectMember/54"
- },
- {
- "__ref": "ProjectMember:gid://gitlab/ProjectMember/53"
- },
- {
- "__ref": "ProjectMember:gid://gitlab/ProjectMember/52"
- },
- {
- "__ref": "GroupMember:gid://gitlab/GroupMember/26"
- },
- {
- "__ref": "GroupMember:gid://gitlab/GroupMember/25"
- },
- {
- "__ref": "GroupMember:gid://gitlab/GroupMember/11"
- },
- {
- "__ref": "GroupMember:gid://gitlab/GroupMember/10"
- },
- {
- "__ref": "GroupMember:gid://gitlab/GroupMember/9"
- },
- {
- "__ref": "GroupMember:gid://gitlab/GroupMember/1"
- }
- ]
- },
"milestones({\"includeAncestors\":true,\"searchTitle\":\"\",\"sort\":\"EXPIRED_LAST_DUE_DATE_ASC\",\"state\":\"active\"})": {
"__typename": "MilestoneConnection",
"nodes": [
@@ -1999,125 +1967,6 @@
"healthStatus": null,
"weight": null
},
- "UserCore:gid://gitlab/User/9": {
- "__typename": "UserCore",
- "id": "gid://gitlab/User/9",
- "avatarUrl": "https://secure.gravatar.com/avatar/175e76e391370beeb21914ab74c2efd4?s=80&d=identicon",
- "name": "Kiyoko Bahringer",
- "username": "jamie"
- },
- "ProjectMember:gid://gitlab/ProjectMember/54": {
- "__typename": "ProjectMember",
- "id": "gid://gitlab/ProjectMember/54",
- "user": {
- "__ref": "UserCore:gid://gitlab/User/9"
- }
- },
- "UserCore:gid://gitlab/User/19": {
- "__typename": "UserCore",
- "id": "gid://gitlab/User/19",
- "avatarUrl": "https://secure.gravatar.com/avatar/3126153e3301ebf7cc8f7c99e57007f2?s=80&d=identicon",
- "name": "Cecile Hermann",
- "username": "jeannetta_breitenberg"
- },
- "ProjectMember:gid://gitlab/ProjectMember/53": {
- "__typename": "ProjectMember",
- "id": "gid://gitlab/ProjectMember/53",
- "user": {
- "__ref": "UserCore:gid://gitlab/User/19"
- }
- },
- "UserCore:gid://gitlab/User/2": {
- "__typename": "UserCore",
- "id": "gid://gitlab/User/2",
- "avatarUrl": "https://secure.gravatar.com/avatar/a138e401136c90561f949297387a3bb9?s=80&d=identicon",
- "name": "Tish Treutel",
- "username": "liana.larkin"
- },
- "ProjectMember:gid://gitlab/ProjectMember/52": {
- "__typename": "ProjectMember",
- "id": "gid://gitlab/ProjectMember/52",
- "user": {
- "__ref": "UserCore:gid://gitlab/User/2"
- }
- },
- "UserCore:gid://gitlab/User/13": {
- "__typename": "UserCore",
- "id": "gid://gitlab/User/13",
- "avatarUrl": "https://secure.gravatar.com/avatar/0ce8057f452296a13b5620bb2d9ede57?s=80&d=identicon",
- "name": "Tammy Gusikowski",
- "username": "xuan_oreilly"
- },
- "GroupMember:gid://gitlab/GroupMember/26": {
- "__typename": "GroupMember",
- "id": "gid://gitlab/GroupMember/26",
- "user": {
- "__ref": "UserCore:gid://gitlab/User/13"
- }
- },
- "UserCore:gid://gitlab/User/21": {
- "__typename": "UserCore",
- "id": "gid://gitlab/User/21",
- "avatarUrl": "https://secure.gravatar.com/avatar/415b09d256f26403384363d7948c4d77?s=80&d=identicon",
- "name": "Twanna Hegmann",
- "username": "jamaal"
- },
- "GroupMember:gid://gitlab/GroupMember/25": {
- "__typename": "GroupMember",
- "id": "gid://gitlab/GroupMember/25",
- "user": {
- "__ref": "UserCore:gid://gitlab/User/21"
- }
- },
- "UserCore:gid://gitlab/User/14": {
- "__typename": "UserCore",
- "id": "gid://gitlab/User/14",
- "avatarUrl": "https://secure.gravatar.com/avatar/e99697c6664381b0351b7617717dd49b?s=80&d=identicon",
- "name": "Francie Cole",
- "username": "greg.wisoky"
- },
- "GroupMember:gid://gitlab/GroupMember/11": {
- "__typename": "GroupMember",
- "id": "gid://gitlab/GroupMember/11",
- "user": {
- "__ref": "UserCore:gid://gitlab/User/14"
- }
- },
- "UserCore:gid://gitlab/User/7": {
- "__typename": "UserCore",
- "id": "gid://gitlab/User/7",
- "avatarUrl": "https://secure.gravatar.com/avatar/3a382857e362d6cce60d3806dd173444?s=80&d=identicon",
- "name": "Ivan Carter",
- "username": "ethyl"
- },
- "GroupMember:gid://gitlab/GroupMember/10": {
- "__typename": "GroupMember",
- "id": "gid://gitlab/GroupMember/10",
- "user": {
- "__ref": "UserCore:gid://gitlab/User/7"
- }
- },
- "UserCore:gid://gitlab/User/15": {
- "__typename": "UserCore",
- "id": "gid://gitlab/User/15",
- "avatarUrl": "https://secure.gravatar.com/avatar/79653006ff557e081db02deaa4ca281c?s=80&d=identicon",
- "name": "Danuta Dare",
- "username": "maddie_hintz"
- },
- "GroupMember:gid://gitlab/GroupMember/9": {
- "__typename": "GroupMember",
- "id": "gid://gitlab/GroupMember/9",
- "user": {
- "__ref": "UserCore:gid://gitlab/User/15"
- }
- },
- "GroupMember:gid://gitlab/GroupMember/1": {
- "__typename": "GroupMember",
- "id": "gid://gitlab/GroupMember/1",
- "user": {
- "__ref": "UserCore:gid://gitlab/User/1"
- }
- },
"Milestone:gid://gitlab/Milestone/30": {
"__typename": "Milestone",
"id": "gid://gitlab/Milestone/30",
diff --git a/spec/frontend/lib/apollo/persist_link_spec.js b/spec/frontend/lib/apollo/persist_link_spec.js
index ddb861bcee0..f3afc4ba8cd 100644
--- a/spec/frontend/lib/apollo/persist_link_spec.js
+++ b/spec/frontend/lib/apollo/persist_link_spec.js
@@ -56,7 +56,7 @@ describe('~/lib/apollo/persist_link', () => {
expect(childFields.some((field) => field.name.value === '__persist')).toBe(false);
});
- it('decorates the response with `__persist: true` is there is `__persist` field in the query', async () => {
+ it('decorates the response with `__persist: true` is there is `__persist` field in the query', () => {
const link = getPersistLink().concat(terminatingLink);
subscription = execute(link, { query: QUERY_WITH_PERSIST_FIELD }).subscribe(({ data }) => {
@@ -64,7 +64,7 @@ describe('~/lib/apollo/persist_link', () => {
});
});
- it('does not decorate the response with `__persist: true` is there if query is not persistent', async () => {
+ it('does not decorate the response with `__persist: true` is there if query is not persistent', () => {
const link = getPersistLink().concat(terminatingLink);
subscription = execute(link, { query: DEFAULT_QUERY }).subscribe(({ data }) => {
diff --git a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js
index 5ac7a7985a8..b8847f0fca3 100644
--- a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js
+++ b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js
@@ -6,13 +6,8 @@ import { isNavigatingAway } from '~/lib/utils/is_navigating_away';
jest.mock('~/lib/utils/is_navigating_away');
describe('getSuppressNetworkErrorsDuringNavigationLink', () => {
- const originalGon = window.gon;
let subscription;
- beforeEach(() => {
- window.gon = originalGon;
- });
-
afterEach(() => {
if (subscription) {
subscription.unsubscribe();
diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js
index f767a673553..fdc8789c1a8 100644
--- a/spec/frontend/lib/dompurify_spec.js
+++ b/spec/frontend/lib/dompurify_spec.js
@@ -49,8 +49,6 @@ const forbiddenDataAttrs = defaultConfig.FORBID_ATTR;
const acceptedDataAttrs = ['data-random', 'data-custom'];
describe('~/lib/dompurify', () => {
- let originalGon;
-
it('uses local configuration when given', () => {
// As dompurify uses a "Persistent Configuration", it might
// ignore config, this check verifies we respect
@@ -104,15 +102,10 @@ describe('~/lib/dompurify', () => {
${'root'} | ${rootGon}
${'absolute'} | ${absoluteGon}
`('when gon contains $type icon urls', ({ type, gon }) => {
- beforeAll(() => {
- originalGon = window.gon;
+ beforeEach(() => {
window.gon = gon;
});
- afterAll(() => {
- window.gon = originalGon;
- });
-
it('allows no href attrs', () => {
const htmlHref = `<svg><use></use></svg>`;
expect(sanitize(htmlHref)).toBe(htmlHref);
@@ -137,14 +130,9 @@ describe('~/lib/dompurify', () => {
describe('when gon does not contain icon urls', () => {
beforeAll(() => {
- originalGon = window.gon;
window.gon = {};
});
- afterAll(() => {
- window.gon = originalGon;
- });
-
it.each([...safeUrls.root, ...safeUrls.absolute, ...unsafeUrls])('sanitizes URL %s', (url) => {
const htmlHref = `<svg><use href="${url}"></use></svg>`;
const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`;
diff --git a/spec/frontend/lib/mousetrap_spec.js b/spec/frontend/lib/mousetrap_spec.js
new file mode 100644
index 00000000000..0ea221300a9
--- /dev/null
+++ b/spec/frontend/lib/mousetrap_spec.js
@@ -0,0 +1,113 @@
+// eslint-disable-next-line no-restricted-imports
+import Mousetrap from 'mousetrap';
+
+const originalMethodReturnValue = {};
+// Create a mock stopCallback method before ~/lib/utils/mousetrap overwrites
+// it. This allows us to spy on calls to it.
+const mockOriginalStopCallbackMethod = jest.fn().mockReturnValue(originalMethodReturnValue);
+Mousetrap.prototype.stopCallback = mockOriginalStopCallbackMethod;
+
+describe('mousetrap utils', () => {
+ describe('addStopCallback', () => {
+ let addStopCallback;
+ let clearStopCallbacksForTests;
+ const mockMousetrapInstance = { isMockMousetrap: true };
+ const mockKeyboardEvent = { type: 'keydown', key: 'Enter' };
+ const mockCombo = 'enter';
+
+ const mockKeydown = ({
+ instance = mockMousetrapInstance,
+ event = mockKeyboardEvent,
+ element = document,
+ combo = mockCombo,
+ } = {}) => Mousetrap.prototype.stopCallback.call(instance, event, element, combo);
+
+ beforeEach(async () => {
+ // Import async since it mutates the Mousetrap instance, by design.
+ ({ addStopCallback, clearStopCallbacksForTests } = await import('~/lib/mousetrap'));
+ clearStopCallbacksForTests();
+ });
+
+ it('delegates to the original stopCallback method when no additional callbacks added', () => {
+ const returnValue = mockKeydown();
+
+ expect(mockOriginalStopCallbackMethod).toHaveBeenCalledTimes(1);
+
+ const [thisArg] = mockOriginalStopCallbackMethod.mock.contexts;
+ const [eventArg, element, combo] = mockOriginalStopCallbackMethod.mock.calls[0];
+
+ expect(thisArg).toBe(mockMousetrapInstance);
+ expect(eventArg).toBe(mockKeyboardEvent);
+ expect(element).toBe(document);
+ expect(combo).toBe(mockCombo);
+
+ expect(returnValue).toBe(originalMethodReturnValue);
+ });
+
+ it('passes the expected arguments to the given stop callback', () => {
+ const callback = jest.fn();
+
+ addStopCallback(callback);
+
+ mockKeydown();
+
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ const [thisArg] = callback.mock.contexts;
+ const [eventArg, element, combo] = callback.mock.calls[0];
+
+ expect(thisArg).toBe(mockMousetrapInstance);
+ expect(eventArg).toBe(mockKeyboardEvent);
+ expect(element).toBe(document);
+ expect(combo).toBe(mockCombo);
+ });
+
+ describe.each([true, false])('when a stop handler returns %p', (stopCallbackReturnValue) => {
+ let methodReturnValue;
+ const stopCallback = jest.fn().mockReturnValue(stopCallbackReturnValue);
+
+ beforeEach(() => {
+ addStopCallback(stopCallback);
+
+ methodReturnValue = mockKeydown();
+ });
+
+ it(`returns ${stopCallbackReturnValue}`, () => {
+ expect(methodReturnValue).toBe(stopCallbackReturnValue);
+ });
+
+ it('calls stop callback', () => {
+ expect(stopCallback).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call mockOriginalStopCallbackMethod', () => {
+ expect(mockOriginalStopCallbackMethod).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when a stop handler returns undefined', () => {
+ let methodReturnValue;
+ const stopCallback = jest.fn().mockReturnValue(undefined);
+
+ beforeEach(() => {
+ addStopCallback(stopCallback);
+
+ methodReturnValue = mockKeydown();
+ });
+
+ it('returns originalMethodReturnValue', () => {
+ expect(methodReturnValue).toBe(originalMethodReturnValue);
+ });
+
+ it('calls stop callback', () => {
+ expect(stopCallback).toHaveBeenCalledTimes(1);
+ });
+
+ // Because this is the only registered stop callback, the next callback
+ // is the original method.
+ it('does call original stopCallback method', () => {
+ expect(mockOriginalStopCallbackMethod).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/axios_startup_calls_spec.js b/spec/frontend/lib/utils/axios_startup_calls_spec.js
index 4471b781446..3d063ff9b46 100644
--- a/spec/frontend/lib/utils/axios_startup_calls_spec.js
+++ b/spec/frontend/lib/utils/axios_startup_calls_spec.js
@@ -113,17 +113,10 @@ describe('setupAxiosStartupCalls', () => {
});
describe('startup call', () => {
- let oldGon;
-
beforeEach(() => {
- oldGon = window.gon;
window.gon = { gitlab_url: 'https://example.org/gitlab' };
});
- afterEach(() => {
- window.gon = oldGon;
- });
-
it('removes GitLab Base URL from startup call', async () => {
window.gl.startup_calls = {
'/startup': {
diff --git a/spec/frontend/lib/utils/chart_utils_spec.js b/spec/frontend/lib/utils/chart_utils_spec.js
index 65bb68c5017..3b34b0ef672 100644
--- a/spec/frontend/lib/utils/chart_utils_spec.js
+++ b/spec/frontend/lib/utils/chart_utils_spec.js
@@ -1,4 +1,8 @@
-import { firstAndLastY } from '~/lib/utils/chart_utils';
+import { firstAndLastY, getToolboxOptions } from '~/lib/utils/chart_utils';
+import { __ } from '~/locale';
+import * as iconUtils from '~/lib/utils/icon_utils';
+
+jest.mock('~/lib/utils/icon_utils');
describe('Chart utils', () => {
describe('firstAndLastY', () => {
@@ -12,4 +16,53 @@ describe('Chart utils', () => {
expect(firstAndLastY(data)).toEqual([1, 3]);
});
});
+
+ describe('getToolboxOptions', () => {
+ describe('when icons are successfully fetched', () => {
+ beforeEach(() => {
+ iconUtils.getSvgIconPathContent.mockImplementation((name) =>
+ Promise.resolve(`${name}-svg-path-mock`),
+ );
+ });
+
+ it('returns toolbox config', async () => {
+ await expect(getToolboxOptions()).resolves.toEqual({
+ toolbox: {
+ feature: {
+ dataZoom: {
+ icon: {
+ zoom: 'path://marquee-selection-svg-path-mock',
+ back: 'path://redo-svg-path-mock',
+ },
+ },
+ restore: {
+ icon: 'path://repeat-svg-path-mock',
+ },
+ saveAsImage: {
+ icon: 'path://download-svg-path-mock',
+ },
+ },
+ },
+ });
+ });
+ });
+
+ describe('when icons are not successfully fetched', () => {
+ const error = new Error();
+
+ beforeEach(() => {
+ iconUtils.getSvgIconPathContent.mockRejectedValue(error);
+ jest.spyOn(console, 'warn').mockImplementation();
+ });
+
+ it('returns empty object and calls `console.warn`', async () => {
+ await expect(getToolboxOptions()).resolves.toEqual({});
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith(
+ __('SVG could not be rendered correctly: '),
+ error,
+ );
+ });
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/color_utils_spec.js b/spec/frontend/lib/utils/color_utils_spec.js
index 87966cf9fba..92ac66c19f0 100644
--- a/spec/frontend/lib/utils/color_utils_spec.js
+++ b/spec/frontend/lib/utils/color_utils_spec.js
@@ -1,44 +1,6 @@
-import {
- isValidColorExpression,
- textColorForBackground,
- hexToRgb,
- validateHexColor,
- darkModeEnabled,
-} from '~/lib/utils/color_utils';
+import { isValidColorExpression, validateHexColor, darkModeEnabled } from '~/lib/utils/color_utils';
describe('Color utils', () => {
- describe('Converting hex code to rgb', () => {
- it('convert hex code to rgb', () => {
- expect(hexToRgb('#000000')).toEqual([0, 0, 0]);
- expect(hexToRgb('#ffffff')).toEqual([255, 255, 255]);
- });
-
- it('convert short hex code to rgb', () => {
- expect(hexToRgb('#000')).toEqual([0, 0, 0]);
- expect(hexToRgb('#fff')).toEqual([255, 255, 255]);
- });
-
- it('handle conversion regardless of the characters case', () => {
- expect(hexToRgb('#f0F')).toEqual([255, 0, 255]);
- });
- });
-
- describe('Getting text color for given background', () => {
- // following tests are being ported from `text_color_for_bg` section in labels_helper_spec.rb
- it('uses light text on dark backgrounds', () => {
- expect(textColorForBackground('#222E2E')).toEqual('#FFFFFF');
- });
-
- it('uses dark text on light backgrounds', () => {
- expect(textColorForBackground('#EEEEEE')).toEqual('#333333');
- });
-
- it('supports RGB triplets', () => {
- expect(textColorForBackground('#FFF')).toEqual('#333333');
- expect(textColorForBackground('#000')).toEqual('#FFFFFF');
- });
- });
-
describe('Validate hex color', () => {
it.each`
color | output
@@ -63,7 +25,7 @@ describe('Color utils', () => {
${'groups:issues:index'} | ${'gl-dark'} | ${'monokai-light'} | ${true}
`(
'is $expected on $page with $bodyClass body class and $ideTheme IDE theme',
- async ({ page, bodyClass, ideTheme, expected }) => {
+ ({ page, bodyClass, ideTheme, expected }) => {
document.body.outerHTML = `<body class="${bodyClass}" data-page="${page}"></body>`;
window.gon = {
user_color_scheme: ideTheme,
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index 7b068f7d248..b4ec00ab766 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -534,18 +534,10 @@ describe('common_utils', () => {
});
describe('spriteIcon', () => {
- let beforeGon;
-
beforeEach(() => {
- window.gon = window.gon || {};
- beforeGon = { ...window.gon };
window.gon.sprite_icons = 'icons.svg';
});
- afterEach(() => {
- window.gon = beforeGon;
- });
-
it('should return the svg for a linked icon', () => {
expect(commonUtils.spriteIcon('test')).toEqual(
'<svg ><use xlink:href="icons.svg#test" /></svg>',
diff --git a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_action_spec.js b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_action_spec.js
index 142c76f7bc0..fab5a7b8844 100644
--- a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_action_spec.js
+++ b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_action_spec.js
@@ -44,7 +44,6 @@ describe('confirmAction', () => {
resetHTMLFixture();
Vue.prototype.$mount.mockRestore();
modalWrapper?.destroy();
- modalWrapper = null;
modal?.destroy();
modal = null;
});
@@ -67,6 +66,7 @@ describe('confirmAction', () => {
modalHtmlMessage: '<strong>Hello</strong>',
title: 'title',
hideCancel: true,
+ size: 'md',
};
await renderRootComponent('', options);
expect(modal.props()).toEqual(
@@ -80,6 +80,7 @@ describe('confirmAction', () => {
modalHtmlMessage: options.modalHtmlMessage,
title: options.title,
hideCancel: options.hideCancel,
+ size: 'md',
}),
);
});
diff --git a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js
index 313e028d861..9dcb850076c 100644
--- a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js
+++ b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js
@@ -14,6 +14,7 @@ describe('Confirm Modal', () => {
secondaryText,
secondaryVariant,
title,
+ size,
hideCancel = false,
} = {}) => {
wrapper = mount(ConfirmModal, {
@@ -24,14 +25,11 @@ describe('Confirm Modal', () => {
secondaryVariant,
hideCancel,
title,
+ size,
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlModal = () => wrapper.findComponent(GlModal);
describe('Modal events', () => {
@@ -95,5 +93,17 @@ describe('Confirm Modal', () => {
expect(findGlModal().props().title).toBe(title);
});
+
+ it('should set modal size to `sm` by default', () => {
+ createComponent();
+
+ expect(findGlModal().props('size')).toBe('sm');
+ });
+
+ it('should set modal size when `size` prop is set', () => {
+ createComponent({ size: 'md' });
+
+ expect(findGlModal().props('size')).toBe('md');
+ });
});
});
diff --git a/spec/frontend/lib/utils/css_utils_spec.js b/spec/frontend/lib/utils/css_utils_spec.js
new file mode 100644
index 00000000000..dcaeb075c93
--- /dev/null
+++ b/spec/frontend/lib/utils/css_utils_spec.js
@@ -0,0 +1,22 @@
+import { getCssClassDimensions } from '~/lib/utils/css_utils';
+
+describe('getCssClassDimensions', () => {
+ const mockDimensions = { width: 1, height: 2 };
+ let actual;
+
+ beforeEach(() => {
+ jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(mockDimensions);
+ actual = getCssClassDimensions('foo bar');
+ });
+
+ it('returns the measured width and height', () => {
+ expect(actual).toEqual(mockDimensions);
+ });
+
+ it('measures an element with the given classes', () => {
+ expect(Element.prototype.getBoundingClientRect).toHaveBeenCalledTimes(1);
+
+ const [tempElement] = Element.prototype.getBoundingClientRect.mock.contexts;
+ expect([...tempElement.classList]).toEqual(['foo', 'bar']);
+ });
+});
diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
index a83b0ed9fbe..e7a6367eeac 100644
--- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
@@ -134,18 +134,6 @@ describe('formatTimeAsSummary', () => {
});
});
-describe('durationTimeFormatted', () => {
- it.each`
- duration | expectedOutput
- ${87} | ${'00:01:27'}
- ${141} | ${'00:02:21'}
- ${12} | ${'00:00:12'}
- ${60} | ${'00:01:00'}
- `('returns $expectedOutput when provided $duration', ({ duration, expectedOutput }) => {
- expect(utils.durationTimeFormatted(duration)).toBe(expectedOutput);
- });
-});
-
describe('formatUtcOffset', () => {
it.each`
offset | expected
diff --git a/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js
new file mode 100644
index 00000000000..15e056e45d0
--- /dev/null
+++ b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js
@@ -0,0 +1,25 @@
+import { formatTimeSpent } from '~/lib/utils/datetime/time_spent_utility';
+
+describe('Time spent utils', () => {
+ describe('formatTimeSpent', () => {
+ describe('with limitToHours false', () => {
+ it('formats 34500 seconds to `1d 1h 35m`', () => {
+ expect(formatTimeSpent(34500)).toEqual('1d 1h 35m');
+ });
+
+ it('formats -34500 seconds to `- 1d 1h 35m`', () => {
+ expect(formatTimeSpent(-34500)).toEqual('- 1d 1h 35m');
+ });
+ });
+
+ describe('with limitToHours true', () => {
+ it('formats 34500 seconds to `9h 35m`', () => {
+ expect(formatTimeSpent(34500, true)).toEqual('9h 35m');
+ });
+
+ it('formats -34500 seconds to `- 9h 35m`', () => {
+ expect(formatTimeSpent(-34500, true)).toEqual('- 9h 35m');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js
index 1ef7047d959..74ce8175357 100644
--- a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js
@@ -1,18 +1,9 @@
+import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/constants';
import { getTimeago, localTimeAgo, timeFor, duration } from '~/lib/utils/datetime/timeago_utility';
import { s__ } from '~/locale';
import '~/commons/bootstrap';
describe('TimeAgo utils', () => {
- let oldGon;
-
- afterEach(() => {
- window.gon = oldGon;
- });
-
- beforeEach(() => {
- oldGon = window.gon;
- });
-
describe('getTimeago', () => {
describe('with User Setting timeDisplayRelative: true', () => {
beforeEach(() => {
@@ -34,15 +25,37 @@ describe('TimeAgo utils', () => {
window.gon = { time_display_relative: false };
});
- it.each([
+ const defaultFormatExpectations = [
[new Date().toISOString(), 'Jul 6, 2020, 12:00 AM'],
[new Date(), 'Jul 6, 2020, 12:00 AM'],
[new Date().getTime(), 'Jul 6, 2020, 12:00 AM'],
// Slightly different behaviour when `null` is passed :see_no_evil`
[null, 'Jan 1, 1970, 12:00 AM'],
- ])('formats date `%p` as `%p`', (date, result) => {
+ ];
+
+ it.each(defaultFormatExpectations)('formats date `%p` as `%p`', (date, result) => {
expect(getTimeago().format(date)).toEqual(result);
});
+
+ describe('with unknown format', () => {
+ it.each(defaultFormatExpectations)(
+ 'uses default format and formats date `%p` as `%p`',
+ (date, result) => {
+ expect(getTimeago('non_existent').format(date)).toEqual(result);
+ },
+ );
+ });
+
+ describe('with DATE_ONLY_FORMAT', () => {
+ it.each([
+ [new Date().toISOString(), 'Jul 6, 2020'],
+ [new Date(), 'Jul 6, 2020'],
+ [new Date().getTime(), 'Jul 6, 2020'],
+ [null, 'Jan 1, 1970'],
+ ])('formats date `%p` as `%p`', (date, result) => {
+ expect(getTimeago(DATE_ONLY_FORMAT).format(date)).toEqual(result);
+ });
+ });
});
});
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 8d989350173..330bfca7029 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -276,19 +276,35 @@ describe('getTimeframeWindowFrom', () => {
});
describe('formatTime', () => {
- const expectedTimestamps = [
- [0, '00:00:00'],
- [1000, '00:00:01'],
- [42000, '00:00:42'],
- [121000, '00:02:01'],
- [10921000, '03:02:01'],
- [108000000, '30:00:00'],
- ];
+ it.each`
+ milliseconds | expected
+ ${0} | ${'00:00:00'}
+ ${1} | ${'00:00:00'}
+ ${499} | ${'00:00:00'}
+ ${500} | ${'00:00:01'}
+ ${1000} | ${'00:00:01'}
+ ${42 * 1000} | ${'00:00:42'}
+ ${60 * 1000} | ${'00:01:00'}
+ ${(60 + 1) * 1000} | ${'00:01:01'}
+ ${(3 * 60 * 60 + 2 * 60 + 1) * 1000} | ${'03:02:01'}
+ ${(11 * 60 * 60 + 59 * 60 + 59) * 1000} | ${'11:59:59'}
+ ${30 * 60 * 60 * 1000} | ${'30:00:00'}
+ ${(35 * 60 * 60 + 3 * 60 + 7) * 1000} | ${'35:03:07'}
+ ${240 * 60 * 60 * 1000} | ${'240:00:00'}
+ ${1000 * 60 * 60 * 1000} | ${'1000:00:00'}
+ `(`formats $milliseconds ms as $expected`, ({ milliseconds, expected }) => {
+ expect(datetimeUtility.formatTime(milliseconds)).toBe(expected);
+ });
- expectedTimestamps.forEach(([milliseconds, expectedTimestamp]) => {
- it(`formats ${milliseconds}ms as ${expectedTimestamp}`, () => {
- expect(datetimeUtility.formatTime(milliseconds)).toBe(expectedTimestamp);
- });
+ it.each`
+ milliseconds | expected
+ ${-1} | ${'00:00:00'}
+ ${-499} | ${'00:00:00'}
+ ${-1000} | ${'-00:00:01'}
+ ${-60 * 1000} | ${'-00:01:00'}
+ ${-(35 * 60 * 60 + 3 * 60 + 7) * 1000} | ${'-35:03:07'}
+ `(`when negative, formats $milliseconds ms as $expected`, ({ milliseconds, expected }) => {
+ expect(datetimeUtility.formatTime(milliseconds)).toBe(expected);
});
});
diff --git a/spec/frontend/lib/utils/error_message_spec.js b/spec/frontend/lib/utils/error_message_spec.js
new file mode 100644
index 00000000000..d55a6de06c3
--- /dev/null
+++ b/spec/frontend/lib/utils/error_message_spec.js
@@ -0,0 +1,48 @@
+import { parseErrorMessage } from '~/lib/utils/error_message';
+
+const defaultErrorMessage = 'Default error message';
+const errorMessage = 'Returned error message';
+
+const generateErrorWithMessage = (message) => {
+ return {
+ message,
+ };
+};
+
+describe('parseErrorMessage', () => {
+ const ufErrorPrefix = 'Foo:';
+ beforeEach(() => {
+ gon.uf_error_prefix = ufErrorPrefix;
+ });
+
+ it.each`
+ error | expectedResult
+ ${`${ufErrorPrefix} ${errorMessage}`} | ${errorMessage}
+ ${`${errorMessage} ${ufErrorPrefix}`} | ${defaultErrorMessage}
+ ${errorMessage} | ${defaultErrorMessage}
+ ${undefined} | ${defaultErrorMessage}
+ ${''} | ${defaultErrorMessage}
+ `(
+ 'properly parses "$error" error object and returns "$expectedResult"',
+ ({ error, expectedResult }) => {
+ const errorObject = generateErrorWithMessage(error);
+ expect(parseErrorMessage(errorObject, defaultErrorMessage)).toEqual(expectedResult);
+ },
+ );
+
+ it.each`
+ error | defaultMessage | expectedResult
+ ${undefined} | ${defaultErrorMessage} | ${defaultErrorMessage}
+ ${''} | ${defaultErrorMessage} | ${defaultErrorMessage}
+ ${{}} | ${defaultErrorMessage} | ${defaultErrorMessage}
+ ${generateErrorWithMessage(errorMessage)} | ${undefined} | ${''}
+ ${generateErrorWithMessage(`${ufErrorPrefix} ${errorMessage}`)} | ${undefined} | ${errorMessage}
+ ${generateErrorWithMessage(errorMessage)} | ${''} | ${''}
+ ${generateErrorWithMessage(`${ufErrorPrefix} ${errorMessage}`)} | ${''} | ${errorMessage}
+ `(
+ 'properly handles the edge case of error="$error" and defaultMessage="$defaultMessage"',
+ ({ error, defaultMessage, expectedResult }) => {
+ expect(parseErrorMessage(error, defaultMessage)).toEqual(expectedResult);
+ },
+ );
+});
diff --git a/spec/frontend/lib/utils/file_upload_spec.js b/spec/frontend/lib/utils/file_upload_spec.js
index f63af2fe0a4..509ddc7ce86 100644
--- a/spec/frontend/lib/utils/file_upload_spec.js
+++ b/spec/frontend/lib/utils/file_upload_spec.js
@@ -1,5 +1,9 @@
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import fileUpload, { getFilename, validateImageName } from '~/lib/utils/file_upload';
+import fileUpload, {
+ getFilename,
+ validateImageName,
+ validateFileFromAllowList,
+} from '~/lib/utils/file_upload';
describe('File upload', () => {
beforeEach(() => {
@@ -89,3 +93,19 @@ describe('file name validator', () => {
expect(validateImageName(file)).toBe('image.png');
});
});
+
+describe('validateFileFromAllowList', () => {
+ it('returns true if the file type is in the allowed list', () => {
+ const allowList = ['.foo', '.bar'];
+ const fileName = 'file.foo';
+
+ expect(validateFileFromAllowList(fileName, allowList)).toBe(true);
+ });
+
+ it('returns false if the file type is in the allowed list', () => {
+ const allowList = ['.foo', '.bar'];
+ const fileName = 'file.baz';
+
+ expect(validateFileFromAllowList(fileName, allowList)).toBe(false);
+ });
+});
diff --git a/spec/frontend/lib/utils/intersection_observer_spec.js b/spec/frontend/lib/utils/intersection_observer_spec.js
index 71b1daffe0d..8eef403f0ae 100644
--- a/spec/frontend/lib/utils/intersection_observer_spec.js
+++ b/spec/frontend/lib/utils/intersection_observer_spec.js
@@ -57,7 +57,7 @@ describe('IntersectionObserver Utility', () => {
${true} | ${'IntersectionAppear'}
`(
'should emit the correct event on the entry target based on the computed Intersection',
- async ({ isIntersecting, event }) => {
+ ({ isIntersecting, event }) => {
const target = document.createElement('div');
observer.addEntry({ target, isIntersecting });
diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js
index dc4aa0ea5ed..d2591cd2328 100644
--- a/spec/frontend/lib/utils/number_utility_spec.js
+++ b/spec/frontend/lib/utils/number_utility_spec.js
@@ -3,6 +3,7 @@ import {
bytesToKiB,
bytesToMiB,
bytesToGiB,
+ numberToHumanSizeSplit,
numberToHumanSize,
numberToMetricPrefix,
sum,
@@ -13,6 +14,12 @@ import {
isNumeric,
isPositiveInteger,
} from '~/lib/utils/number_utils';
+import {
+ BYTES_FORMAT_BYTES,
+ BYTES_FORMAT_KIB,
+ BYTES_FORMAT_MIB,
+ BYTES_FORMAT_GIB,
+} from '~/lib/utils/constants';
describe('Number Utils', () => {
describe('formatRelevantDigits', () => {
@@ -78,6 +85,28 @@ describe('Number Utils', () => {
});
});
+ describe('numberToHumanSizeSplit', () => {
+ it('should return bytes', () => {
+ expect(numberToHumanSizeSplit(654)).toEqual(['654', BYTES_FORMAT_BYTES]);
+ expect(numberToHumanSizeSplit(-654)).toEqual(['-654', BYTES_FORMAT_BYTES]);
+ });
+
+ it('should return KiB', () => {
+ expect(numberToHumanSizeSplit(1079)).toEqual(['1.05', BYTES_FORMAT_KIB]);
+ expect(numberToHumanSizeSplit(-1079)).toEqual(['-1.05', BYTES_FORMAT_KIB]);
+ });
+
+ it('should return MiB', () => {
+ expect(numberToHumanSizeSplit(10485764)).toEqual(['10.00', BYTES_FORMAT_MIB]);
+ expect(numberToHumanSizeSplit(-10485764)).toEqual(['-10.00', BYTES_FORMAT_MIB]);
+ });
+
+ it('should return GiB', () => {
+ expect(numberToHumanSizeSplit(10737418240)).toEqual(['10.00', BYTES_FORMAT_GIB]);
+ expect(numberToHumanSizeSplit(-10737418240)).toEqual(['-10.00', BYTES_FORMAT_GIB]);
+ });
+ });
+
describe('numberToHumanSize', () => {
it('should return bytes', () => {
expect(numberToHumanSize(654)).toEqual('654 bytes');
diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js
index 63eeb54e850..096a92305dc 100644
--- a/spec/frontend/lib/utils/poll_spec.js
+++ b/spec/frontend/lib/utils/poll_spec.js
@@ -121,7 +121,7 @@ describe('Poll', () => {
});
describe('with delayed initial request', () => {
- it('delays the first request', async () => {
+ it('delays the first request', () => {
mockServiceCall({ status: HTTP_STATUS_OK, headers: { 'poll-interval': 1 } });
const Polling = new Poll({
diff --git a/spec/frontend/lib/utils/ref_validator_spec.js b/spec/frontend/lib/utils/ref_validator_spec.js
new file mode 100644
index 00000000000..7185ebf0a24
--- /dev/null
+++ b/spec/frontend/lib/utils/ref_validator_spec.js
@@ -0,0 +1,79 @@
+import { validateTag, validationMessages } from '~/lib/utils/ref_validator';
+
+describe('~/lib/utils/ref_validator', () => {
+ describe('validateTag', () => {
+ describe.each([
+ ['foo'],
+ ['FOO'],
+ ['foo/a.lockx'],
+ ['foo.123'],
+ ['foo/123'],
+ ['foo/bar/123'],
+ ['foo.bar.123'],
+ ['foo-bar_baz'],
+ ['head'],
+ ['"foo"-'],
+ ['foo@bar'],
+ ['\ud83e\udd8a'],
+ ['ünicöde'],
+ ['\x80}'],
+ ])('tag with the name "%s"', (tagName) => {
+ it('is valid', () => {
+ const result = validateTag(tagName);
+ expect(result.isValid).toBe(true);
+ expect(result.validationErrors).toEqual([]);
+ });
+ });
+
+ describe.each([
+ [' ', validationMessages.EmptyNameValidationMessage],
+
+ ['refs/heads/tagName', validationMessages.DisallowedPrefixesValidationMessage],
+ ['/foo', validationMessages.DisallowedPrefixesValidationMessage],
+ ['-tagName', validationMessages.DisallowedPrefixesValidationMessage],
+
+ ['HEAD', validationMessages.DisallowedNameValidationMessage],
+ ['@', validationMessages.DisallowedNameValidationMessage],
+
+ ['tag name with spaces', validationMessages.DisallowedSubstringsValidationMessage],
+ ['tag\\name', validationMessages.DisallowedSubstringsValidationMessage],
+ ['tag^name', validationMessages.DisallowedSubstringsValidationMessage],
+ ['tag..name', validationMessages.DisallowedSubstringsValidationMessage],
+ ['..', validationMessages.DisallowedSubstringsValidationMessage],
+ ['tag?name', validationMessages.DisallowedSubstringsValidationMessage],
+ ['tag*name', validationMessages.DisallowedSubstringsValidationMessage],
+ ['tag[name', validationMessages.DisallowedSubstringsValidationMessage],
+ ['tag@{name', validationMessages.DisallowedSubstringsValidationMessage],
+ ['tag:name', validationMessages.DisallowedSubstringsValidationMessage],
+ ['tag~name', validationMessages.DisallowedSubstringsValidationMessage],
+
+ ['/', validationMessages.DisallowedSequenceEmptyValidationMessage],
+ ['//', validationMessages.DisallowedSequenceEmptyValidationMessage],
+ ['foo//123', validationMessages.DisallowedSequenceEmptyValidationMessage],
+
+ ['.', validationMessages.DisallowedSequencePrefixesValidationMessage],
+ ['/./', validationMessages.DisallowedSequencePrefixesValidationMessage],
+ ['./.', validationMessages.DisallowedSequencePrefixesValidationMessage],
+ ['.tagName', validationMessages.DisallowedSequencePrefixesValidationMessage],
+ ['tag/.Name', validationMessages.DisallowedSequencePrefixesValidationMessage],
+ ['foo/.123/bar', validationMessages.DisallowedSequencePrefixesValidationMessage],
+
+ ['foo.', validationMessages.DisallowedSequencePostfixesValidationMessage],
+ ['a.lock', validationMessages.DisallowedSequencePostfixesValidationMessage],
+ ['foo/a.lock', validationMessages.DisallowedSequencePostfixesValidationMessage],
+ ['foo/a.lock/b', validationMessages.DisallowedSequencePostfixesValidationMessage],
+ ['foo.123.', validationMessages.DisallowedSequencePostfixesValidationMessage],
+
+ ['foo/', validationMessages.DisallowedPostfixesValidationMessage],
+
+ ['control-character\x7f', validationMessages.ControlCharactersValidationMessage],
+ ['control-character\x15', validationMessages.ControlCharactersValidationMessage],
+ ])('tag with name "%s"', (tagName, validationMessage) => {
+ it(`should be invalid with validation message "${validationMessage}"`, () => {
+ const result = validateTag(tagName);
+ expect(result.isValid).toBe(false);
+ expect(result.validationErrors).toContain(validationMessage);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js
new file mode 100644
index 00000000000..7bde6cc4a8e
--- /dev/null
+++ b/spec/frontend/lib/utils/secret_detection_spec.js
@@ -0,0 +1,68 @@
+import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
+
+const mockConfirmAction = ({ confirmed }) => {
+ confirmAction.mockResolvedValueOnce(confirmed);
+};
+
+describe('containsSensitiveToken', () => {
+ describe('when message does not contain sensitive tokens', () => {
+ const nonSensitiveMessages = [
+ 'This is a normal message',
+ '1234567890',
+ '!@#$%^&*()_+',
+ 'https://example.com',
+ ];
+
+ it.each(nonSensitiveMessages)('returns false for message: %s', (message) => {
+ expect(containsSensitiveToken(message)).toBe(false);
+ });
+ });
+
+ describe('when message contains sensitive tokens', () => {
+ const sensitiveMessages = [
+ 'token: glpat-cgyKc1k_AsnEpmP-5fRL',
+ 'token: GlPat-abcdefghijklmnopqrstuvwxyz',
+ 'token: feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ 'https://example.com/feed?feed_token=123456789_abcdefghij',
+ 'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ ];
+
+ it.each(sensitiveMessages)('returns true for message: %s', (message) => {
+ expect(containsSensitiveToken(message)).toBe(true);
+ });
+ });
+});
+
+describe('confirmSensitiveAction', () => {
+ afterEach(() => {
+ confirmAction.mockReset();
+ });
+
+ it('should call confirmAction with correct parameters', async () => {
+ const prompt = 'Are you sure you want to delete this item?';
+ const expectedParams = {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: i18n.primaryBtnText,
+ };
+ await confirmSensitiveAction(prompt);
+
+ expect(confirmAction).toHaveBeenCalledWith(prompt, expectedParams);
+ });
+
+ it('should return true when confirmed is true', async () => {
+ mockConfirmAction({ confirmed: true });
+
+ const result = await confirmSensitiveAction();
+ expect(result).toBe(true);
+ });
+
+ it('should return false when confirmed is false', async () => {
+ mockConfirmAction({ confirmed: false });
+
+ const result = await confirmSensitiveAction();
+ expect(result).toBe(false);
+ });
+});
diff --git a/spec/frontend/lib/utils/sticky_spec.js b/spec/frontend/lib/utils/sticky_spec.js
deleted file mode 100644
index ec9e746c838..00000000000
--- a/spec/frontend/lib/utils/sticky_spec.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import { setHTMLFixture } from 'helpers/fixtures';
-import { isSticky } from '~/lib/utils/sticky';
-
-const TEST_OFFSET_TOP = 500;
-
-describe('sticky', () => {
- let el;
- let offsetTop;
-
- beforeEach(() => {
- setHTMLFixture(
- `
- <div class="parent">
- <div id="js-sticky"></div>
- </div>
- `,
- );
-
- offsetTop = TEST_OFFSET_TOP;
- el = document.getElementById('js-sticky');
- Object.defineProperty(el, 'offsetTop', {
- get() {
- return offsetTop;
- },
- });
- });
-
- afterEach(() => {
- el = null;
- });
-
- describe('when stuck', () => {
- it('does not remove is-stuck class', () => {
- isSticky(el, 0, el.offsetTop);
- isSticky(el, 0, el.offsetTop);
-
- expect(el.classList.contains('is-stuck')).toBe(true);
- });
-
- it('adds is-stuck class', () => {
- isSticky(el, 0, el.offsetTop);
-
- expect(el.classList.contains('is-stuck')).toBe(true);
- });
-
- it('inserts placeholder element', () => {
- isSticky(el, 0, el.offsetTop, true);
-
- expect(document.querySelector('.sticky-placeholder')).not.toBeNull();
- });
- });
-
- describe('when not stuck', () => {
- it('removes is-stuck class', () => {
- jest.spyOn(el.classList, 'remove');
-
- isSticky(el, 0, el.offsetTop);
- isSticky(el, 0, 0);
-
- expect(el.classList.remove).toHaveBeenCalledWith('is-stuck');
- expect(el.classList.contains('is-stuck')).toBe(false);
- });
-
- it('does not add is-stuck class', () => {
- isSticky(el, 0, 0);
-
- expect(el.classList.contains('is-stuck')).toBe(false);
- });
-
- it('removes placeholder', () => {
- isSticky(el, 0, el.offsetTop, true);
- isSticky(el, 0, 0, true);
-
- expect(document.querySelector('.sticky-placeholder')).toBeNull();
- });
- });
-});
diff --git a/spec/frontend/lib/utils/tappable_promise_spec.js b/spec/frontend/lib/utils/tappable_promise_spec.js
new file mode 100644
index 00000000000..654cd20a9de
--- /dev/null
+++ b/spec/frontend/lib/utils/tappable_promise_spec.js
@@ -0,0 +1,63 @@
+import TappablePromise from '~/lib/utils/tappable_promise';
+
+describe('TappablePromise', () => {
+ it('allows a promise to have a progress indicator', () => {
+ const pseudoProgress = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
+ const progressCallback = jest.fn();
+ const promise = new TappablePromise((tap, resolve) => {
+ pseudoProgress.forEach(tap);
+ resolve('done');
+
+ return 'returned value';
+ });
+
+ return promise
+ .tap(progressCallback)
+ .then((val) => {
+ expect(val).toBe('done');
+ expect(val).not.toBe('returned value');
+
+ expect(progressCallback).toHaveBeenCalledTimes(pseudoProgress.length);
+
+ pseudoProgress.forEach((progress, index) => {
+ expect(progressCallback).toHaveBeenNthCalledWith(index + 1, progress);
+ });
+ })
+ .catch(() => {});
+ });
+
+ it('resolves with the value returned by the callback', () => {
+ const promise = new TappablePromise((tap) => {
+ tap(0.5);
+ return 'test';
+ });
+
+ return promise
+ .tap((progress) => {
+ expect(progress).toBe(0.5);
+ })
+ .then((value) => {
+ expect(value).toBe('test');
+ });
+ });
+
+ it('allows a promise to be rejected', () => {
+ const promise = new TappablePromise((tap, resolve, reject) => {
+ reject(new Error('test error'));
+ });
+
+ return promise.catch((e) => {
+ expect(e.message).toBe('test error');
+ });
+ });
+
+ it('rejects the promise if the callback throws an error', () => {
+ const promise = new TappablePromise(() => {
+ throw new Error('test error');
+ });
+
+ return promise.catch((e) => {
+ expect(e.message).toBe('test error');
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index 7aab1013fc0..2180ea7e6c2 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -1,12 +1,16 @@
import $ from 'jquery';
+import AxiosMockAdapter from 'axios-mock-adapter';
import {
insertMarkdownText,
keypressNoteText,
compositionStartNoteText,
compositionEndNoteText,
updateTextForToolbarBtn,
+ resolveSelectedImage,
} from '~/lib/utils/text_markdown';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import '~/lib/utils/jquery_at_who';
+import axios from '~/lib/utils/axios_utils';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
describe('init markdown', () => {
@@ -14,6 +18,7 @@ describe('init markdown', () => {
let textArea;
let indentButton;
let outdentButton;
+ let axiosMock;
beforeAll(() => {
setHTMLFixture(
@@ -34,6 +39,14 @@ describe('init markdown', () => {
document.execCommand = jest.fn(() => false);
});
+ beforeEach(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ });
+
afterAll(() => {
resetHTMLFixture();
});
@@ -707,6 +720,55 @@ describe('init markdown', () => {
});
});
+ describe('resolveSelectedImage', () => {
+ const markdownPreviewPath = '/markdown/preview';
+ const imageMarkdown = '![image](/uploads/image.png)';
+ const imageAbsoluteUrl = '/abs/uploads/image.png';
+
+ describe('when textarea cursor is positioned on an image', () => {
+ beforeEach(() => {
+ axiosMock.onPost(markdownPreviewPath, { text: imageMarkdown }).reply(HTTP_STATUS_OK, {
+ body: `
+ <p><a href="${imageAbsoluteUrl}"><img src="${imageAbsoluteUrl}"></a></p>
+ `,
+ });
+ });
+
+ it('returns the image absolute URL, markdown, and filename', async () => {
+ textArea.value = `image ${imageMarkdown}`;
+ textArea.setSelectionRange(8, 8);
+ expect(await resolveSelectedImage(textArea, markdownPreviewPath)).toEqual({
+ imageURL: imageAbsoluteUrl,
+ imageMarkdown,
+ filename: 'image.png',
+ });
+ });
+ });
+
+ describe('when textarea cursor is not positioned on an image', () => {
+ it.each`
+ markdown | selectionRange
+ ${`image ${imageMarkdown}`} | ${[4, 4]}
+ ${`!2 (issue)`} | ${[2, 2]}
+ `('returns null', async ({ markdown, selectionRange }) => {
+ textArea.value = markdown;
+ textArea.setSelectionRange(...selectionRange);
+ expect(await resolveSelectedImage(textArea, markdownPreviewPath)).toBe(null);
+ });
+ });
+
+ describe('when textarea cursor is positioned between images', () => {
+ it('returns null', async () => {
+ const position = imageMarkdown.length + 1;
+
+ textArea.value = `${imageMarkdown}\n\n${imageMarkdown}`;
+ textArea.setSelectionRange(position, position);
+
+ expect(await resolveSelectedImage(textArea, markdownPreviewPath)).toBe(null);
+ });
+ });
+ });
+
describe('Source Editor', () => {
let editor;
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
index f2572ca0ad2..71a84d56791 100644
--- a/spec/frontend/lib/utils/text_utility_spec.js
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -398,4 +398,36 @@ describe('text_utility', () => {
expect(textUtils.base64DecodeUnicode('8J+YgA==')).toBe('😀');
});
});
+
+ describe('findInvalidBranchNameCharacters', () => {
+ const invalidChars = [' ', '~', '^', ':', '?', '*', '[', '..', '@{', '\\', '//'];
+ const badBranchName = 'branch-with all these ~ ^ : ? * [ ] \\ // .. @{ } //';
+ const goodBranch = 'branch-with-no-errrors';
+
+ it('returns an array of invalid characters in a branch name', () => {
+ const chars = textUtils.findInvalidBranchNameCharacters(badBranchName);
+ chars.forEach((char) => {
+ expect(invalidChars).toContain(char);
+ });
+ });
+
+ it('returns an empty array with no invalid characters', () => {
+ expect(textUtils.findInvalidBranchNameCharacters(goodBranch)).toEqual([]);
+ });
+ });
+
+ describe('humanizeBranchValidationErrors', () => {
+ it.each`
+ errors | message
+ ${[' ']} | ${"Can't contain spaces"}
+ ${['?', '//', ' ']} | ${"Can't contain spaces, ?, //"}
+ ${['\\', '[', '..']} | ${"Can't contain \\, [, .."}
+ `('returns an $message with $errors', ({ errors, message }) => {
+ expect(textUtils.humanizeBranchValidationErrors(errors)).toEqual(message);
+ });
+
+ it('returns an empty string with no invalid characters', () => {
+ expect(textUtils.humanizeBranchValidationErrors([])).toEqual('');
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 6afdab455a6..0799bc87c8c 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -45,10 +45,6 @@ describe('URL utility', () => {
});
describe('webIDEUrl', () => {
- afterEach(() => {
- gon.relative_url_root = '';
- });
-
it('escapes special characters', () => {
expect(urlUtils.webIDEUrl('/gitlab-org/gitlab-#-foss/merge_requests/1')).toBe(
'/-/ide/project/gitlab-org/gitlab-%23-foss/merge_requests/1',
@@ -505,10 +501,6 @@ describe('URL utility', () => {
gon.gitlab_url = gitlabUrl;
});
- afterEach(() => {
- gon.gitlab_url = '';
- });
-
it.each`
url | urlType | external
${'/gitlab-org/gitlab-test/-/issues/2'} | ${'relative'} | ${false}
@@ -796,18 +788,6 @@ describe('URL utility', () => {
});
});
- describe('stripFinalUrlSegment', () => {
- it.each`
- path | expected
- ${'http://fake.domain/twitter/typeahead-js/-/tags/v0.11.0'} | ${'http://fake.domain/twitter/typeahead-js/-/tags/'}
- ${'http://fake.domain/bar/cool/-/nested/content'} | ${'http://fake.domain/bar/cool/-/nested/'}
- ${'http://fake.domain/bar/cool?q="search"'} | ${'http://fake.domain/bar/'}
- ${'http://fake.domain/bar/cool#link-to-something'} | ${'http://fake.domain/bar/'}
- `('stripFinalUrlSegment $path => $expected', ({ path, expected }) => {
- expect(urlUtils.stripFinalUrlSegment(path)).toBe(expected);
- });
- });
-
describe('escapeFileUrl', () => {
it('encodes URL excluding the slashes', () => {
expect(urlUtils.escapeFileUrl('/foo-bar/file.md')).toBe('/foo-bar/file.md');
diff --git a/spec/frontend/lib/utils/vuex_module_mappers_spec.js b/spec/frontend/lib/utils/vuex_module_mappers_spec.js
index d25a692dfea..abd5095c1d2 100644
--- a/spec/frontend/lib/utils/vuex_module_mappers_spec.js
+++ b/spec/frontend/lib/utils/vuex_module_mappers_spec.js
@@ -96,10 +96,6 @@ describe('~/lib/utils/vuex_module_mappers', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('from module defined by prop', () => {
it('maps state', () => {
expect(getMappedState()).toEqual({
diff --git a/spec/frontend/lib/utils/web_ide_navigator_spec.js b/spec/frontend/lib/utils/web_ide_navigator_spec.js
new file mode 100644
index 00000000000..0f5cd09d50e
--- /dev/null
+++ b/spec/frontend/lib/utils/web_ide_navigator_spec.js
@@ -0,0 +1,38 @@
+import { visitUrl, webIDEUrl } from '~/lib/utils/url_utility';
+import { openWebIDE } from '~/lib/utils/web_ide_navigator';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ webIDEUrl: jest.fn().mockImplementation((path) => `/-/ide/projects${path}`),
+}));
+
+describe('openWebIDE', () => {
+ it('when called without projectPath throws TypeError and does not call visitUrl', () => {
+ expect(() => {
+ openWebIDE();
+ }).toThrow(new TypeError('projectPath parameter is required'));
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+
+ it('when called with projectPath and without fileName calls visitUrl with correct path', () => {
+ const params = { projectPath: 'project-path' };
+ const expectedNonIDEPath = `/${params.projectPath}/edit/main/-/`;
+ const expectedIDEPath = `/-/ide/projects${expectedNonIDEPath}`;
+
+ openWebIDE(params.projectPath);
+
+ expect(webIDEUrl).toHaveBeenCalledWith(expectedNonIDEPath);
+ expect(visitUrl).toHaveBeenCalledWith(expectedIDEPath);
+ });
+
+ it('when called with projectPath and fileName calls visitUrl with correct path', () => {
+ const params = { projectPath: 'project-path', fileName: 'README' };
+ const expectedNonIDEPath = `/${params.projectPath}/edit/main/-/${params.fileName}/`;
+ const expectedIDEPath = `/-/ide/projects${expectedNonIDEPath}`;
+
+ openWebIDE(params.projectPath, params.fileName);
+
+ expect(webIDEUrl).toHaveBeenCalledWith(expectedNonIDEPath);
+ expect(visitUrl).toHaveBeenCalledWith(expectedIDEPath);
+ });
+});
diff --git a/spec/frontend/listbox/redirect_behavior_spec.js b/spec/frontend/listbox/redirect_behavior_spec.js
index 7b2a40b65ce..c2479e71e4a 100644
--- a/spec/frontend/listbox/redirect_behavior_spec.js
+++ b/spec/frontend/listbox/redirect_behavior_spec.js
@@ -1,6 +1,6 @@
import { initListbox } from '~/listbox';
import { initRedirectListboxBehavior } from '~/listbox/redirect_behavior';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { getFixture, setHTMLFixture } from 'helpers/fixtures';
jest.mock('~/lib/utils/url_utility');
@@ -42,10 +42,10 @@ describe('initRedirectListboxBehavior', () => {
const { onChange } = firstCallArgs[1];
const mockItem = { href: '/foo' };
- expect(redirectTo).not.toHaveBeenCalled();
+ expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
onChange(mockItem);
- expect(redirectTo).toHaveBeenCalledWith(mockItem.href);
+ expect(redirectTo).toHaveBeenCalledWith(mockItem.href); // eslint-disable-line import/no-deprecated
});
});
diff --git a/spec/frontend/locale/sprintf_spec.js b/spec/frontend/locale/sprintf_spec.js
index e0d0e117ea4..a7e245e2b78 100644
--- a/spec/frontend/locale/sprintf_spec.js
+++ b/spec/frontend/locale/sprintf_spec.js
@@ -84,5 +84,16 @@ describe('locale', () => {
expect(output).toBe('contains duplicated 15%');
});
});
+
+ describe('ignores special replacements in the input', () => {
+ it.each(['$$', '$&', '$`', `$'`])('replacement "%s" is ignored', (replacement) => {
+ const input = 'My odd %{replacement} is preserved';
+
+ const parameters = { replacement };
+
+ const output = sprintf(input, parameters, false);
+ expect(output).toBe(`My odd ${replacement} is preserved`);
+ });
+ });
});
});
diff --git a/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js
index b94964dc482..c2e0e44f97f 100644
--- a/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js
+++ b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js
@@ -20,10 +20,6 @@ describe('AccessRequestActionButtons', () => {
const findRemoveMemberButton = () => wrapper.findComponent(RemoveMemberButton);
const findApproveButton = () => wrapper.findComponent(ApproveAccessRequestButton);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders remove member button', () => {
createComponent();
diff --git a/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js b/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js
index 15bb03480e1..7a4cd844425 100644
--- a/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js
+++ b/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js
@@ -38,7 +38,7 @@ describe('ApproveAccessRequestButton', () => {
...propsData,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -50,10 +50,6 @@ describe('ApproveAccessRequestButton', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays a tooltip', () => {
const button = findButton();
diff --git a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js
index 68009708c99..a852443844b 100644
--- a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js
+++ b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js
@@ -19,10 +19,6 @@ describe('InviteActionButtons', () => {
const findRemoveMemberButton = () => wrapper.findComponent(RemoveMemberButton);
const findResendInviteButton = () => wrapper.findComponent(ResendInviteButton);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when user has `canRemove` permissions', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js b/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js
index b511cebdf28..1d83a2e0e71 100644
--- a/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js
+++ b/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js
@@ -37,7 +37,7 @@ describe('RemoveGroupLinkButton', () => {
groupLink: group,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -48,11 +48,6 @@ describe('RemoveGroupLinkButton', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('displays a tooltip', () => {
const button = findButton();
diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js
index cca340169b7..3879279b559 100644
--- a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js
+++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js
@@ -47,7 +47,7 @@ describe('RemoveMemberButton', () => {
...propsData,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -58,10 +58,6 @@ describe('RemoveMemberButton', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('sets attributes on button', () => {
expect(wrapper.attributes()).toMatchObject({
'aria-label': 'Remove member',
diff --git a/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js b/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js
index 51cfd47ddf4..a6b5978b566 100644
--- a/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js
+++ b/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js
@@ -38,7 +38,7 @@ describe('ResendInviteButton', () => {
...propsData,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -50,10 +50,6 @@ describe('ResendInviteButton', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays a tooltip', () => {
expect(getBinding(findButton().element, 'gl-tooltip')).not.toBeUndefined();
expect(findButton().attributes('title')).toBe('Resend invite');
diff --git a/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js
index 90f5b217007..679ad7897ed 100644
--- a/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js
+++ b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js
@@ -18,7 +18,7 @@ describe('LeaveGroupDropdownItem', () => {
...propsData,
},
directives: {
- GlModal: createMockDirective(),
+ GlModal: createMockDirective('gl-modal'),
},
slots: {
default: text,
@@ -32,10 +32,6 @@ describe('LeaveGroupDropdownItem', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a slot with red text', () => {
expect(findDropdownItem().html()).toContain(`<span class="gl-text-red-500">${text}</span>`);
});
diff --git a/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js
index e1c498249d7..125f1f8fff3 100644
--- a/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js
+++ b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js
@@ -58,10 +58,6 @@ describe('RemoveMemberDropdownItem', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a slot with red text', () => {
expect(findDropdownItem().html()).toContain(`<span class="gl-text-red-500">${text}</span>`);
});
diff --git a/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js b/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js
index 5a2de1cac80..448c04bcb69 100644
--- a/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js
+++ b/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js
@@ -24,17 +24,13 @@ describe('UserActionDropdown', () => {
...propsData,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
const findRemoveMemberDropdownItem = () => wrapper.findComponent(RemoveMemberDropdownItem);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when user has `canRemove` permissions', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/members/components/app_spec.js b/spec/frontend/members/components/app_spec.js
index d105a4d9fde..b2147163233 100644
--- a/spec/frontend/members/components/app_spec.js
+++ b/spec/frontend/members/components/app_spec.js
@@ -49,7 +49,6 @@ describe('MembersApp', () => {
});
afterEach(() => {
- wrapper.destroy();
store = null;
});
diff --git a/spec/frontend/members/components/avatars/group_avatar_spec.js b/spec/frontend/members/components/avatars/group_avatar_spec.js
index 13c50de9835..8e4263f88fe 100644
--- a/spec/frontend/members/components/avatars/group_avatar_spec.js
+++ b/spec/frontend/members/components/avatars/group_avatar_spec.js
@@ -25,10 +25,6 @@ describe('MemberList', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders link to group', () => {
const link = wrapper.findComponent(GlAvatarLink);
diff --git a/spec/frontend/members/components/avatars/invite_avatar_spec.js b/spec/frontend/members/components/avatars/invite_avatar_spec.js
index b197a46c0d1..84878fb9be2 100644
--- a/spec/frontend/members/components/avatars/invite_avatar_spec.js
+++ b/spec/frontend/members/components/avatars/invite_avatar_spec.js
@@ -24,10 +24,6 @@ describe('MemberList', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders email as name', () => {
expect(getByText(invite.email).exists()).toBe(true);
});
diff --git a/spec/frontend/members/components/avatars/user_avatar_spec.js b/spec/frontend/members/components/avatars/user_avatar_spec.js
index 9172876e76f..4808bcb9363 100644
--- a/spec/frontend/members/components/avatars/user_avatar_spec.js
+++ b/spec/frontend/members/components/avatars/user_avatar_spec.js
@@ -26,10 +26,6 @@ describe('UserAvatar', () => {
const findStatusEmoji = (emoji) => wrapper.find(`gl-emoji[data-name="${emoji}"]`);
- afterEach(() => {
- wrapper.destroy();
- });
-
it("renders link to user's profile", () => {
createComponent();
diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js
index f346967121c..29b7ceae0e3 100644
--- a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js
+++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js
@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import setWindowLocation from 'helpers/set_window_location_helper';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue';
import {
MEMBER_TYPES,
@@ -167,7 +167,7 @@ describe('MembersFilteredSearchBar', () => {
{ type: FILTERED_SEARCH_TOKEN_TWO_FACTOR.type, value: { data: 'enabled', operator: '=' } },
]);
- expect(redirectTo).toHaveBeenCalledWith('https://localhost/?two_factor=enabled');
+ expect(redirectTo).toHaveBeenCalledWith('https://localhost/?two_factor=enabled'); // eslint-disable-line import/no-deprecated
});
it('adds search query param', () => {
@@ -178,6 +178,7 @@ describe('MembersFilteredSearchBar', () => {
{ type: FILTERED_SEARCH_TERM, value: { data: 'foobar' } },
]);
+ // eslint-disable-next-line import/no-deprecated
expect(redirectTo).toHaveBeenCalledWith(
'https://localhost/?two_factor=enabled&search=foobar',
);
@@ -191,6 +192,7 @@ describe('MembersFilteredSearchBar', () => {
{ type: FILTERED_SEARCH_TERM, value: { data: 'foo bar baz' } },
]);
+ // eslint-disable-next-line import/no-deprecated
expect(redirectTo).toHaveBeenCalledWith(
'https://localhost/?two_factor=enabled&search=foo+bar+baz',
);
@@ -206,6 +208,7 @@ describe('MembersFilteredSearchBar', () => {
{ type: FILTERED_SEARCH_TERM, value: { data: 'foobar' } },
]);
+ // eslint-disable-next-line import/no-deprecated
expect(redirectTo).toHaveBeenCalledWith(
'https://localhost/?two_factor=enabled&search=foobar&sort=name_asc',
);
@@ -220,7 +223,7 @@ describe('MembersFilteredSearchBar', () => {
{ type: FILTERED_SEARCH_TERM, value: { data: 'foobar' } },
]);
- expect(redirectTo).toHaveBeenCalledWith('https://localhost/?search=foobar&tab=invited');
+ expect(redirectTo).toHaveBeenCalledWith('https://localhost/?search=foobar&tab=invited'); // eslint-disable-line import/no-deprecated
});
});
});
diff --git a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js
index ef3c8bde3cf..526f839ece8 100644
--- a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js
+++ b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js
@@ -46,7 +46,7 @@ describe('SortDropdown', () => {
const findSortingComponent = () => wrapper.findComponent(GlSorting);
const findSortDirectionToggle = () =>
findSortingComponent().find('button[title^="Sort direction"]');
- const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]');
+ const findDropdownToggle = () => wrapper.find('button[aria-haspopup="menu"]');
const findDropdownItemByText = (text) =>
wrapper
.findAllComponents(GlSortingItem)
diff --git a/spec/frontend/members/components/members_tabs_spec.js b/spec/frontend/members/components/members_tabs_spec.js
index 77af5e7293e..9078bd87d62 100644
--- a/spec/frontend/members/components/members_tabs_spec.js
+++ b/spec/frontend/members/components/members_tabs_spec.js
@@ -100,10 +100,6 @@ describe('MembersTabs', () => {
setWindowLocation('https://localhost');
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', async () => {
await createComponent();
diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js
index ba587c6f0b3..cec5f192e59 100644
--- a/spec/frontend/members/components/modals/leave_modal_spec.js
+++ b/spec/frontend/members/components/modals/leave_modal_spec.js
@@ -60,10 +60,6 @@ describe('LeaveModal', () => {
const findForm = () => findModal().findComponent(GlForm);
const findUserDeletionObstaclesList = () => findModal().findComponent(UserDeletionObstaclesList);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('sets modal ID', async () => {
await createComponent();
diff --git a/spec/frontend/members/components/modals/remove_group_link_modal_spec.js b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js
index af96396f09f..e4782ac7f2e 100644
--- a/spec/frontend/members/components/modals/remove_group_link_modal_spec.js
+++ b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js
@@ -52,11 +52,6 @@ describe('RemoveGroupLinkModal', () => {
const getByText = (text, options) =>
createWrapper(within(findModal().element).getByText(text, options));
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when modal is open', () => {
beforeEach(async () => {
createComponent();
diff --git a/spec/frontend/members/components/modals/remove_member_modal_spec.js b/spec/frontend/members/components/modals/remove_member_modal_spec.js
index 47a03b5083a..baef0b30b02 100644
--- a/spec/frontend/members/components/modals/remove_member_modal_spec.js
+++ b/spec/frontend/members/components/modals/remove_member_modal_spec.js
@@ -54,10 +54,6 @@ describe('RemoveMemberModal', () => {
const findGlModal = () => wrapper.findComponent(GlModal);
const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each`
state | memberModelType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | userDeletionObstacles | isPartOfOncall
${'removing a group member'} | ${MEMBER_MODEL_TYPE_GROUP_MEMBER} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} | ${false}
diff --git a/spec/frontend/members/components/table/created_at_spec.js b/spec/frontend/members/components/table/created_at_spec.js
index fa31177564b..2c0493e7c59 100644
--- a/spec/frontend/members/components/table/created_at_spec.js
+++ b/spec/frontend/members/components/table/created_at_spec.js
@@ -20,10 +20,6 @@ describe('CreatedAt', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('created at text', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/members/components/table/expiration_datepicker_spec.js b/spec/frontend/members/components/table/expiration_datepicker_spec.js
index 9b8f053348b..9176a02a447 100644
--- a/spec/frontend/members/components/table/expiration_datepicker_spec.js
+++ b/spec/frontend/members/components/table/expiration_datepicker_spec.js
@@ -58,10 +58,6 @@ describe('ExpirationDatepicker', () => {
const findInput = () => wrapper.find('input');
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('datepicker input', () => {
it('sets `member.expiresAt` as initial date', async () => {
createComponent({ member: { ...member, expiresAt: '2020-03-17T00:00:00Z' } });
@@ -97,7 +93,7 @@ describe('ExpirationDatepicker', () => {
});
describe('when datepicker is changed', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
findDatepicker().vm.$emit('input', new Date('2020-03-17'));
diff --git a/spec/frontend/members/components/table/member_action_buttons_spec.js b/spec/frontend/members/components/table/member_action_buttons_spec.js
index 95db30a3683..3a04d1dcb0a 100644
--- a/spec/frontend/members/components/table/member_action_buttons_spec.js
+++ b/spec/frontend/members/components/table/member_action_buttons_spec.js
@@ -23,10 +23,6 @@ describe('MemberActions', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
memberType | member | expectedComponent | expectedComponentName
${MEMBER_TYPES.user} | ${memberMock} | ${UserActionDropdown} | ${'UserActionDropdown'}
diff --git a/spec/frontend/members/components/table/member_avatar_spec.js b/spec/frontend/members/components/table/member_avatar_spec.js
index dc5c97f41df..369f8a06cfd 100644
--- a/spec/frontend/members/components/table/member_avatar_spec.js
+++ b/spec/frontend/members/components/table/member_avatar_spec.js
@@ -18,10 +18,6 @@ describe('MemberList', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
memberType | member | expectedComponent | expectedComponentName
${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'}
diff --git a/spec/frontend/members/components/table/member_source_spec.js b/spec/frontend/members/components/table/member_source_spec.js
index fbfd0ca7ae7..bbfbb19fd92 100644
--- a/spec/frontend/members/components/table/member_source_spec.js
+++ b/spec/frontend/members/components/table/member_source_spec.js
@@ -23,17 +23,13 @@ describe('MemberSource', () => {
...propsData,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
const getTooltipDirective = (elementWrapper) => getBinding(elementWrapper.element, 'gl-tooltip');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('direct member', () => {
describe('when created by is available', () => {
it('displays "Direct member by <user name>"', () => {
diff --git a/spec/frontend/members/components/table/members_table_cell_spec.js b/spec/frontend/members/components/table/members_table_cell_spec.js
index ac5d83d028d..1c6f1b086cf 100644
--- a/spec/frontend/members/components/table/members_table_cell_spec.js
+++ b/spec/frontend/members/components/table/members_table_cell_spec.js
@@ -97,11 +97,6 @@ describe('MembersTableCell', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it.each`
member | expectedMemberType
${memberMock} | ${MEMBER_TYPES.user}
diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js
index b8e0d73d8f6..e3c89bfed53 100644
--- a/spec/frontend/members/components/table/members_table_spec.js
+++ b/spec/frontend/members/components/table/members_table_spec.js
@@ -96,10 +96,6 @@ describe('MembersTable', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('fields', () => {
const memberCanUpdate = {
...directMember,
diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js
index a11f67be8f5..1045e3f9849 100644
--- a/spec/frontend/members/components/table/role_dropdown_spec.js
+++ b/spec/frontend/members/components/table/role_dropdown_spec.js
@@ -1,5 +1,6 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import * as Sentry from '@sentry/browser';
import { within } from '@testing-library/dom';
import { mount, createWrapper } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
@@ -12,6 +13,7 @@ import { member } from '../../mock_data';
Vue.use(Vuex);
jest.mock('ee_else_ce/members/guest_overage_confirm_action');
+jest.mock('@sentry/browser');
describe('RoleDropdown', () => {
let wrapper;
@@ -20,9 +22,9 @@ describe('RoleDropdown', () => {
show: jest.fn(),
};
- const createStore = () => {
+ const createStore = ({ updateMemberRoleReturn = Promise.resolve() } = {}) => {
actions = {
- updateMemberRole: jest.fn(() => Promise.resolve()),
+ updateMemberRole: jest.fn(() => updateMemberRoleReturn),
};
return new Vuex.Store({
@@ -32,7 +34,7 @@ describe('RoleDropdown', () => {
});
};
- const createComponent = (propsData = {}) => {
+ const createComponent = (propsData = {}, store = createStore()) => {
wrapper = mount(RoleDropdown, {
provide: {
namespace: MEMBER_TYPES.user,
@@ -46,7 +48,7 @@ describe('RoleDropdown', () => {
permissions: {},
...propsData,
},
- store: createStore(),
+ store,
mocks: {
$toast,
},
@@ -67,27 +69,19 @@ describe('RoleDropdown', () => {
.findAllComponents(GlDropdownItem)
.wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.props('isChecked'));
- const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]');
+ const findDropdownToggle = () => wrapper.find('button[aria-haspopup="menu"]');
const findDropdown = () => wrapper.findComponent(GlDropdown);
- let originalGon;
-
beforeEach(() => {
- originalGon = window.gon;
gon.features = { showOverageOnRolePromotion: true };
});
- afterEach(() => {
- window.gon = originalGon;
- wrapper.destroy();
- });
-
describe('when dropdown is open', () => {
- beforeEach(() => {
+ beforeEach(async () => {
guestOverageConfirmAction.mockReturnValue(true);
createComponent();
- return findDropdownToggle().trigger('click');
+ await findDropdownToggle().trigger('click');
});
it('renders all valid roles', () => {
@@ -121,26 +115,74 @@ describe('RoleDropdown', () => {
});
});
- it('displays toast when successful', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ describe('when updateMemberRole is successful', () => {
+ it('displays toast', async () => {
+ await getDropdownItemByText('Developer').trigger('click');
- await nextTick();
+ await nextTick();
- expect($toast.show).toHaveBeenCalledWith('Role updated successfully.');
- });
+ expect($toast.show).toHaveBeenCalledWith('Role updated successfully.');
+ });
- it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => {
+ await getDropdownItemByText('Developer').trigger('click');
- expect(findDropdown().props('loading')).toBe(true);
+ expect(findDropdown().props('loading')).toBe(true);
+ });
+
+ it('enables dropdown after `updateMemberRole` resolves', async () => {
+ await getDropdownItemByText('Developer').trigger('click');
+
+ await waitForPromises();
+
+ expect(findDropdown().props('disabled')).toBe(false);
+ });
+
+ it('does not log error to Sentry', async () => {
+ await getDropdownItemByText('Developer').trigger('click');
+
+ await waitForPromises();
+
+ expect(Sentry.captureException).not.toHaveBeenCalled();
+ });
});
- it('enables dropdown after `updateMemberRole` resolves', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ describe('when updateMemberRole is not successful', () => {
+ const reason = 'Rejected ☹️';
+
+ beforeEach(() => {
+ createComponent({}, createStore({ updateMemberRoleReturn: Promise.reject(reason) }));
+ });
+
+ it('does not display toast', async () => {
+ await getDropdownItemByText('Developer').trigger('click');
+
+ await nextTick();
+
+ expect($toast.show).not.toHaveBeenCalled();
+ });
+
+ it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => {
+ await getDropdownItemByText('Developer').trigger('click');
- await waitForPromises();
+ expect(findDropdown().props('loading')).toBe(true);
+ });
+
+ it('enables dropdown after `updateMemberRole` resolves', async () => {
+ await getDropdownItemByText('Developer').trigger('click');
+
+ await waitForPromises();
+
+ expect(findDropdown().props('disabled')).toBe(false);
+ });
+
+ it('logs error to Sentry', async () => {
+ await getDropdownItemByText('Developer').trigger('click');
- expect(findDropdown().props('disabled')).toBe(false);
+ await waitForPromises();
+
+ expect(Sentry.captureException).toHaveBeenCalledWith(reason);
+ });
});
});
});
diff --git a/spec/frontend/members/index_spec.js b/spec/frontend/members/index_spec.js
index 5c813eb2a67..b1730cf3746 100644
--- a/spec/frontend/members/index_spec.js
+++ b/spec/frontend/members/index_spec.js
@@ -31,9 +31,6 @@ describe('initMembersApp', () => {
afterEach(() => {
el = null;
-
- wrapper.destroy();
- wrapper = null;
});
it('renders `MembersTabs`', () => {
diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js
index 4f276e8c9df..c4357e9c1f0 100644
--- a/spec/frontend/members/utils_spec.js
+++ b/spec/frontend/members/utils_spec.js
@@ -213,7 +213,7 @@ describe('Members Utils', () => {
${'recent_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: false }}
${'oldest_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: true }}
`('when `sort` query string param is `$sortParam`', ({ sortParam, expected }) => {
- it(`returns ${JSON.stringify(expected)}`, async () => {
+ it(`returns ${JSON.stringify(expected)}`, () => {
setWindowLocation(`?sort=${sortParam}`);
expect(parseSortParam(['account', 'granted', 'expires', 'maxRole', 'lastSignIn'])).toEqual(
diff --git a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js
index 9b5641ef7b3..ab913b30f3c 100644
--- a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js
+++ b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js
@@ -37,10 +37,6 @@ describe('Merge Conflict Resolver App', () => {
store.dispatch('setConflictsData', conflictsMock);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findLoadingSpinner = () => wrapper.findByTestId('loading-spinner');
const findConflictsCount = () => wrapper.findByTestId('conflicts-count');
const findFiles = () => wrapper.findAllByTestId('files');
diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js
index 19ef4b7db25..d2c4c8b796c 100644
--- a/spec/frontend/merge_conflicts/store/actions_spec.js
+++ b/spec/frontend/merge_conflicts/store/actions_spec.js
@@ -4,13 +4,13 @@ import Cookies from '~/lib/utils/cookies';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '~/merge_conflicts/constants';
import * as actions from '~/merge_conflicts/store/actions';
import * as types from '~/merge_conflicts/store/mutation_types';
import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflicts/utils';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
jest.mock('~/merge_conflicts/utils');
jest.mock('~/lib/utils/cookies');
@@ -114,7 +114,7 @@ describe('merge conflicts actions', () => {
expect(window.location.assign).toHaveBeenCalledWith('hrefPath');
});
- it('on errors shows flash', async () => {
+ it('on errors shows an alert', async () => {
mock.onPost(resolveConflictsPath).reply(HTTP_STATUS_BAD_REQUEST);
await testAction(
actions.submitResolvedConflicts,
diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js
index 579cee8c022..6f80f8e6aab 100644
--- a/spec/frontend/merge_request_spec.js
+++ b/spec/frontend/merge_request_spec.js
@@ -1,14 +1,16 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlMergeRequestWithTaskList from 'test_fixtures/merge_requests/merge_request_with_task_list.html';
+import htmlMergeRequestOfCurrentUser from 'test_fixtures/merge_requests/merge_request_of_current_user.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'spec/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_CONFLICT, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import MergeRequest from '~/merge_request';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('MergeRequest', () => {
const test = {};
@@ -16,7 +18,7 @@ describe('MergeRequest', () => {
let mock;
beforeEach(() => {
- loadHTMLFixture('merge_requests/merge_request_with_task_list.html');
+ setHTMLFixture(htmlMergeRequestWithTaskList);
jest.spyOn(axios, 'patch');
mock = new MockAdapter(axios);
@@ -112,7 +114,7 @@ describe('MergeRequest', () => {
describe('hideCloseButton', () => {
describe('merge request of current_user', () => {
beforeEach(() => {
- loadHTMLFixture('merge_requests/merge_request_of_current_user.html');
+ setHTMLFixture(htmlMergeRequestOfCurrentUser);
test.el = document.querySelector('.js-issuable-actions');
MergeRequest.hideCloseButton();
});
diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js
index 6d434d7e654..3b8c9dd3bf3 100644
--- a/spec/frontend/merge_request_tabs_spec.js
+++ b/spec/frontend/merge_request_tabs_spec.js
@@ -1,6 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlMergeRequestsWithTaskList from 'test_fixtures/merge_requests/merge_request_with_task_list.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initMrPage from 'helpers/init_vue_mr_page_helper';
import { stubPerformanceWebAPI } from 'helpers/performance';
import axios from '~/lib/utils/axios_utils';
@@ -40,6 +41,10 @@ describe('MergeRequestTabs', () => {
gl.mrWidget = {};
});
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
describe('clickTab', () => {
let params;
@@ -84,7 +89,7 @@ describe('MergeRequestTabs', () => {
let tabUrl;
beforeEach(() => {
- loadHTMLFixture('merge_requests/merge_request_with_task_list.html');
+ setHTMLFixture(htmlMergeRequestsWithTaskList);
tabUrl = $('.commits-tab a').attr('href');
@@ -268,32 +273,32 @@ describe('MergeRequestTabs', () => {
describe('expandViewContainer', () => {
beforeEach(() => {
- $('body').append(
- '<div class="content-wrapper"><div class="container-fluid container-limited"></div></div>',
- );
- });
-
- afterEach(() => {
- $('.content-wrapper').remove();
+ $('.content-wrapper .container-fluid').addClass('container-limited');
});
- it('removes container-limited from containers', () => {
+ it('removes `container-limited` class from content container', () => {
+ expect($('.content-wrapper .container-limited')).toHaveLength(1);
testContext.class.expandViewContainer();
-
expect($('.content-wrapper .container-limited')).toHaveLength(0);
});
+ });
- it('does not add container-limited when fluid layout is prefered', () => {
- $('.content-wrapper .container-fluid').removeClass('container-limited');
-
- testContext.class.expandViewContainer(false);
+ describe('resetViewContainer', () => {
+ it('does not add `container-limited` CSS class when fluid layout is preferred', () => {
+ testContext.class.resetViewContainer();
expect($('.content-wrapper .container-limited')).toHaveLength(0);
});
- it('does remove container-limited from breadcrumbs', () => {
- $('.container-limited').addClass('breadcrumbs');
- testContext.class.expandViewContainer();
+ it('adds `container-limited` CSS class back when fixed layout is preferred', () => {
+ document.body.innerHTML = '';
+ initMrPage();
+ $('.content-wrapper .container-fluid').addClass('container-limited');
+ // recreate the instance so that `isFixedLayoutPreferred` is re-evaluated
+ testContext.class = new MergeRequestTabs({ stubLocation });
+ $('.content-wrapper .container-fluid').removeClass('container-limited');
+
+ testContext.class.resetViewContainer();
expect($('.content-wrapper .container-limited')).toHaveLength(1);
});
@@ -354,8 +359,6 @@ describe('MergeRequestTabs', () => {
testContext.class.expandSidebar.forEach((el) => {
expect(el.classList.contains('gl-display-none!')).toBe(hides);
});
-
- window.gon = {};
});
describe('when switching tabs', () => {
@@ -381,12 +384,12 @@ describe('MergeRequestTabs', () => {
});
});
- it('scrolls to 0, if no position is stored', () => {
+ it('does not scroll 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' });
+ expect(window.scrollTo).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/merge_requests/components/compare_app_spec.js b/spec/frontend/merge_requests/components/compare_app_spec.js
index 8f84341b653..ba129363ffd 100644
--- a/spec/frontend/merge_requests/components/compare_app_spec.js
+++ b/spec/frontend/merge_requests/components/compare_app_spec.js
@@ -30,10 +30,6 @@ function factory(provideData = {}) {
}
describe('Merge requests compare app component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows commit box when selected branch is empty', () => {
factory({
currentBranch: {
diff --git a/spec/frontend/merge_requests/components/compare_dropdown_spec.js b/spec/frontend/merge_requests/components/compare_dropdown_spec.js
index ab5c315816c..ce03b80bdcb 100644
--- a/spec/frontend/merge_requests/components/compare_dropdown_spec.js
+++ b/spec/frontend/merge_requests/components/compare_dropdown_spec.js
@@ -47,7 +47,6 @@ describe('Merge requests compare dropdown component', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
diff --git a/spec/frontend/milestones/components/delete_milestone_modal_spec.js b/spec/frontend/milestones/components/delete_milestone_modal_spec.js
index 87235fa843a..ad6aedaa8ff 100644
--- a/spec/frontend/milestones/components/delete_milestone_modal_spec.js
+++ b/spec/frontend/milestones/components/delete_milestone_modal_spec.js
@@ -5,11 +5,11 @@ import axios from '~/lib/utils/axios_utils';
import DeleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue';
import eventHub from '~/milestones/event_hub';
import { HTTP_STATUS_IM_A_TEAPOT, HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
-import { redirectTo } from '~/lib/utils/url_utility';
-import { createAlert } from '~/flash';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { createAlert } from '~/alert';
jest.mock('~/lib/utils/url_utility');
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Delete milestone modal', () => {
let wrapper;
@@ -39,10 +39,6 @@ describe('Delete milestone modal', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('onSubmit', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
@@ -64,7 +60,7 @@ describe('Delete milestone modal', () => {
});
});
await findModal().vm.$emit('primary');
- expect(redirectTo).toHaveBeenCalledWith(responseURL);
+ expect(redirectTo).toHaveBeenCalledWith(responseURL); // eslint-disable-line import/no-deprecated
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
milestoneUrl: mockProps.milestoneUrl,
successful: true,
@@ -94,7 +90,7 @@ describe('Delete milestone modal', () => {
expect(createAlert).toHaveBeenCalledWith({
message: alertMessage,
});
- expect(redirectTo).not.toHaveBeenCalled();
+ expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
milestoneUrl: mockProps.milestoneUrl,
successful: false,
diff --git a/spec/frontend/milestones/components/milestone_combobox_spec.js b/spec/frontend/milestones/components/milestone_combobox_spec.js
index f8ddca1a2ad..748e01d4291 100644
--- a/spec/frontend/milestones/components/milestone_combobox_spec.js
+++ b/spec/frontend/milestones/components/milestone_combobox_spec.js
@@ -85,11 +85,6 @@ describe('Milestone combobox component', () => {
mock.onGet(`/api/v4/projects/${projectId}/search`).reply((config) => searchApiCallSpy(config));
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
//
// Finders
//
diff --git a/spec/frontend/milestones/components/promote_milestone_modal_spec.js b/spec/frontend/milestones/components/promote_milestone_modal_spec.js
index d7ad3d29d0a..e91e792afe8 100644
--- a/spec/frontend/milestones/components/promote_milestone_modal_spec.js
+++ b/spec/frontend/milestones/components/promote_milestone_modal_spec.js
@@ -3,14 +3,14 @@ import { shallowMount } from '@vue/test-utils';
import { setHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
import * as urlUtils from '~/lib/utils/url_utility';
import PromoteMilestoneModal from '~/milestones/components/promote_milestone_modal.vue';
jest.mock('~/lib/utils/url_utility');
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Promote milestone modal', () => {
let wrapper;
@@ -33,10 +33,6 @@ describe('Promote milestone modal', () => {
wrapper = shallowMount(PromoteMilestoneModal);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Modal opener button', () => {
it('button gets disabled when the modal opens', () => {
expect(promoteButton().disabled).toBe(false);
diff --git a/spec/frontend/milestones/index_spec.js b/spec/frontend/milestones/index_spec.js
new file mode 100644
index 00000000000..477217fc10f
--- /dev/null
+++ b/spec/frontend/milestones/index_spec.js
@@ -0,0 +1,38 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { initShow, MILESTONE_DESCRIPTION_ELEMENT } from '~/milestones/index';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
+
+jest.mock('~/behaviors/markdown/render_gfm');
+jest.mock('~/milestones/milestone');
+jest.mock('~/right_sidebar');
+jest.mock('~/sidebar/mount_milestone_sidebar');
+
+describe('#initShow', () => {
+ beforeEach(() => {
+ setHTMLFixture(`
+ <div class="detail-page-description milestone-detail">
+ <div class="description">
+ <div class="markdown-code-block">
+ <pre class="js-render-mermaid">
+ graph TD;
+ A-- > B;
+ A-- > C;
+ B-- > D;
+ C-- > D;
+ </pre>
+ </div>
+ </div>
+ </div>
+ `);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ it('calls `renderGFM` to ensure that all gitlab-flavoured markdown is rendered on the milestone details page', () => {
+ initShow();
+
+ expect(renderGFM).toHaveBeenCalledWith(document.querySelector(MILESTONE_DESCRIPTION_ELEMENT));
+ });
+});
diff --git a/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap b/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap
deleted file mode 100644
index 7d7eee2bc2c..00000000000
--- a/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap
+++ /dev/null
@@ -1,268 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`MlCandidate renders correctly 1`] = `
-<div>
- <div
- class="gl-alert gl-alert-warning"
- >
- <svg
- aria-hidden="true"
- class="gl-icon s16 gl-alert-icon"
- data-testid="warning-icon"
- role="img"
- >
- <use
- href="#warning"
- />
- </svg>
-
- <div
- aria-live="assertive"
- class="gl-alert-content"
- role="alert"
- >
- <h2
- class="gl-alert-title"
- >
- Machine learning experiment tracking is in incubating phase
- </h2>
-
- <div
- class="gl-alert-body"
- >
-
- GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited.
-
- <a
- class="gl-link"
- href="https://about.gitlab.com/handbook/engineering/incubation/"
- rel="noopener noreferrer"
- target="_blank"
- >
- Learn more about incubating features
- </a>
- </div>
-
- <div
- class="gl-alert-actions"
- >
- <a
- class="btn gl-alert-action btn-confirm btn-md gl-button"
- href="https://gitlab.com/gitlab-org/gitlab/-/issues/381660"
- >
- <!---->
-
- <!---->
-
- <span
- class="gl-button-text"
- >
-
- Give feedback on this feature
-
- </span>
- </a>
- </div>
- </div>
-
- <button
- aria-label="Dismiss"
- class="btn gl-dismiss-btn btn-default btn-sm gl-button btn-default-tertiary btn-icon"
- type="button"
- >
- <!---->
-
- <svg
- aria-hidden="true"
- class="gl-button-icon gl-icon s16"
- data-testid="close-icon"
- role="img"
- >
- <use
- href="#close"
- />
- </svg>
-
- <!---->
- </button>
- </div>
-
- <h3>
-
- Model candidate details
-
- </h3>
-
- <table
- class="candidate-details"
- >
- <tbody>
- <tr
- class="divider"
- />
-
- <tr>
- <td
- class="gl-text-secondary gl-font-weight-bold"
- >
- Info
- </td>
-
- <td
- class="gl-font-weight-bold"
- >
- ID
- </td>
-
- <td>
- candidate_iid
- </td>
- </tr>
-
- <tr>
- <td />
-
- <td
- class="gl-font-weight-bold"
- >
- Status
- </td>
-
- <td>
- SUCCESS
- </td>
- </tr>
-
- <tr>
- <td />
-
- <td
- class="gl-font-weight-bold"
- >
- Experiment
- </td>
-
- <td>
- <a
- class="gl-link"
- href="#"
- >
- The Experiment
- </a>
- </td>
- </tr>
-
- <!---->
-
- <tr
- class="divider"
- />
-
- <tr>
- <td
- class="gl-text-secondary gl-font-weight-bold"
- >
-
- Parameters
-
- </td>
-
- <td
- class="gl-font-weight-bold"
- >
- Algorithm
- </td>
-
- <td>
- Decision Tree
- </td>
- </tr>
- <tr>
- <td />
-
- <td
- class="gl-font-weight-bold"
- >
- MaxDepth
- </td>
-
- <td>
- 3
- </td>
- </tr>
- <tr
- class="divider"
- />
-
- <tr>
- <td
- class="gl-text-secondary gl-font-weight-bold"
- >
-
- Metrics
-
- </td>
-
- <td
- class="gl-font-weight-bold"
- >
- AUC
- </td>
-
- <td>
- .55
- </td>
- </tr>
- <tr>
- <td />
-
- <td
- class="gl-font-weight-bold"
- >
- Accuracy
- </td>
-
- <td>
- .99
- </td>
- </tr>
- <tr
- class="divider"
- />
-
- <tr>
- <td
- class="gl-text-secondary gl-font-weight-bold"
- >
-
- Metadata
-
- </td>
-
- <td
- class="gl-font-weight-bold"
- >
- FileName
- </td>
-
- <td>
- test.py
- </td>
- </tr>
- <tr>
- <td />
-
- <td
- class="gl-font-weight-bold"
- >
- ExecutionTime
- </td>
-
- <td>
- .0856
- </td>
- </tr>
- </tbody>
- </table>
-</div>
-`;
diff --git a/spec/frontend/ml/experiment_tracking/components/delete_button_spec.js b/spec/frontend/ml/experiment_tracking/components/delete_button_spec.js
new file mode 100644
index 00000000000..f2a9e3ad9ee
--- /dev/null
+++ b/spec/frontend/ml/experiment_tracking/components/delete_button_spec.js
@@ -0,0 +1,68 @@
+import { GlModal, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
+
+const csrfToken = 'mock-csrf-token';
+jest.mock('~/lib/utils/csrf', () => ({ token: csrfToken }));
+
+const MODAL_BODY = 'MODAL_BODY';
+const MODAL_TITLE = 'MODAL_TITLE';
+
+describe('DeleteButton', () => {
+ let wrapper;
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findDeleteButton = () => wrapper.findComponent(GlDisclosureDropdownItem);
+ const findForm = () => wrapper.find('form');
+ const findModalText = () => wrapper.findByText(MODAL_BODY);
+
+ beforeEach(() => {
+ wrapper = shallowMountExtended(DeleteButton, {
+ propsData: {
+ deletePath: '/delete',
+ deleteConfirmationText: MODAL_BODY,
+ actionPrimaryText: 'Delete!',
+ modalTitle: MODAL_TITLE,
+ },
+ });
+ });
+
+ it('mounts the modal', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('mounts the dropdown', () => {
+ expect(findDropdown().exists()).toBe(true);
+ });
+
+ it('mounts the button', () => {
+ expect(findDeleteButton().exists()).toBe(true);
+ });
+
+ describe('when modal is opened', () => {
+ it('displays modal title', () => {
+ expect(findModal().props('title')).toBe(MODAL_TITLE);
+ });
+
+ it('displays modal body', () => {
+ expect(findModalText().exists()).toBe(true);
+ });
+
+ it('submits the form when primary action is clicked', () => {
+ const submitSpy = jest.spyOn(findForm().element, 'submit');
+
+ findModal().vm.$emit('primary');
+
+ expect(submitSpy).toHaveBeenCalled();
+ });
+
+ it('displays form with correct action and inputs', () => {
+ const form = findForm();
+
+ expect(form.attributes('action')).toBe('/delete');
+ expect(form.find('input[name="_method"]').attributes('value')).toBe('delete');
+ expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(csrfToken);
+ });
+ });
+});
diff --git a/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js b/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js
deleted file mode 100644
index 483e454d7d7..00000000000
--- a/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import { GlAlert } from '@gitlab/ui';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import MlCandidate from '~/ml/experiment_tracking/components/ml_candidate.vue';
-
-describe('MlCandidate', () => {
- let wrapper;
-
- const createWrapper = () => {
- const candidate = {
- params: [
- { name: 'Algorithm', value: 'Decision Tree' },
- { name: 'MaxDepth', value: '3' },
- ],
- metrics: [
- { name: 'AUC', value: '.55' },
- { name: 'Accuracy', value: '.99' },
- ],
- metadata: [
- { name: 'FileName', value: 'test.py' },
- { name: 'ExecutionTime', value: '.0856' },
- ],
- info: {
- iid: 'candidate_iid',
- artifact_link: 'path_to_artifact',
- experiment_name: 'The Experiment',
- experiment_path: 'path/to/experiment',
- status: 'SUCCESS',
- },
- };
-
- return mountExtended(MlCandidate, { propsData: { candidate } });
- };
-
- const findAlert = () => wrapper.findComponent(GlAlert);
-
- it('shows incubation warning', () => {
- wrapper = createWrapper();
-
- expect(findAlert().exists()).toBe(true);
- });
-
- it('renders correctly', () => {
- wrapper = createWrapper();
-
- expect(wrapper.element).toMatchSnapshot();
- });
-});
diff --git a/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js b/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js
deleted file mode 100644
index f307d2c5a58..00000000000
--- a/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js
+++ /dev/null
@@ -1,316 +0,0 @@
-import { GlAlert, GlTable, GlLink } from '@gitlab/ui';
-import { nextTick } from 'vue';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue';
-import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
-import Pagination from '~/vue_shared/components/incubation/pagination.vue';
-import setWindowLocation from 'helpers/set_window_location_helper';
-import * as urlHelpers from '~/lib/utils/url_utility';
-
-describe('MlExperiment', () => {
- let wrapper;
-
- const startCursor = 'eyJpZCI6IjE2In0';
- const defaultPageInfo = {
- startCursor,
- endCursor: 'eyJpZCI6IjIifQ',
- hasNextPage: true,
- hasPreviousPage: true,
- };
-
- const createWrapper = (
- candidates = [],
- metricNames = [],
- paramNames = [],
- pageInfo = defaultPageInfo,
- ) => {
- wrapper = mountExtended(MlExperiment, {
- provide: { candidates, metricNames, paramNames, pageInfo },
- });
- };
-
- const candidates = [
- {
- rmse: 1,
- l1_ratio: 0.4,
- details: 'link_to_candidate1',
- artifact: 'link_to_artifact',
- name: 'aCandidate',
- created_at: '2023-01-05T14:07:02.975Z',
- user: { username: 'root', path: '/root' },
- },
- {
- auc: 0.3,
- l1_ratio: 0.5,
- details: 'link_to_candidate2',
- created_at: '2023-01-05T14:07:02.975Z',
- name: null,
- user: null,
- },
- {
- auc: 0.3,
- l1_ratio: 0.5,
- details: 'link_to_candidate3',
- created_at: '2023-01-05T14:07:02.975Z',
- name: null,
- user: null,
- },
- {
- auc: 0.3,
- l1_ratio: 0.5,
- details: 'link_to_candidate4',
- created_at: '2023-01-05T14:07:02.975Z',
- name: null,
- user: null,
- },
- {
- auc: 0.3,
- l1_ratio: 0.5,
- details: 'link_to_candidate5',
- created_at: '2023-01-05T14:07:02.975Z',
- name: null,
- user: null,
- },
- ];
-
- const createWrapperWithCandidates = (pageInfo = defaultPageInfo) => {
- createWrapper(candidates, ['rmse', 'auc', 'mae'], ['l1_ratio'], pageInfo);
- };
-
- const findAlert = () => wrapper.findComponent(GlAlert);
- const findPagination = () => wrapper.findComponent(Pagination);
- const findEmptyState = () => wrapper.findByText('No candidates to display');
- const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
- const findTable = () => wrapper.findComponent(GlTable);
- const findTableHeaders = () => findTable().findAll('th');
- const findTableRows = () => findTable().findAll('tbody > tr');
- const findNthTableRow = (idx) => findTableRows().at(idx);
- const findColumnInRow = (row, col) => findNthTableRow(row).findAll('td').at(col);
- const hrefInRowAndColumn = (row, col) =>
- findColumnInRow(row, col).findComponent(GlLink).attributes().href;
-
- it('shows incubation warning', () => {
- createWrapper();
-
- expect(findAlert().exists()).toBe(true);
- });
-
- describe('default inputs', () => {
- beforeEach(async () => {
- createWrapper();
-
- await nextTick();
- });
-
- it('shows empty state', () => {
- expect(findEmptyState().exists()).toBe(true);
- });
-
- it('does not show pagination', () => {
- expect(findPagination().exists()).toBe(false);
- });
-
- it('there are no columns', () => {
- expect(findTable().findAll('th')).toHaveLength(0);
- });
-
- it('initializes sorting correctly', () => {
- expect(findRegistrySearch().props('sorting')).toMatchObject({
- orderBy: 'created_at',
- sort: 'desc',
- });
- });
-
- it('initializes filters correctly', () => {
- expect(findRegistrySearch().props('filters')).toMatchObject([{ value: { data: '' } }]);
- });
- });
-
- describe('Search', () => {
- it('shows search box', () => {
- createWrapper();
-
- expect(findRegistrySearch().exists()).toBe(true);
- });
-
- it('metrics are added as options for sorting', () => {
- createWrapper([], ['bar']);
-
- const labels = findRegistrySearch()
- .props('sortableFields')
- .map((e) => e.orderBy);
- expect(labels).toContain('metric.bar');
- });
-
- it('sets the component filters based on the querystring', () => {
- setWindowLocation('https://blah?name=A&orderBy=B&sort=C');
-
- createWrapper();
-
- expect(findRegistrySearch().props('filters')).toMatchObject([{ value: { data: 'A' } }]);
- });
-
- it('sets the component sort based on the querystring', () => {
- setWindowLocation('https://blah?name=A&orderBy=B&sort=C');
-
- createWrapper();
-
- expect(findRegistrySearch().props('sorting')).toMatchObject({ orderBy: 'B', sort: 'c' });
- });
-
- it('sets the component sort based on the querystring, when order by is a metric', () => {
- setWindowLocation('https://blah?name=A&orderBy=B&sort=C&orderByType=metric');
-
- createWrapper();
-
- expect(findRegistrySearch().props('sorting')).toMatchObject({
- orderBy: 'metric.B',
- sort: 'c',
- });
- });
-
- describe('Search submit', () => {
- beforeEach(() => {
- setWindowLocation('https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc');
- jest.spyOn(urlHelpers, 'visitUrl').mockImplementation(() => {});
-
- createWrapper();
- });
-
- it('On submit, resets the cursor and reloads to correct page', () => {
- findRegistrySearch().vm.$emit('filter:submit');
-
- expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
- expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
- 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc',
- );
- });
-
- it('On sorting changed, resets cursor and reloads to correct page', () => {
- findRegistrySearch().vm.$emit('sorting:changed', { orderBy: 'created_at' });
-
- expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
- expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
- 'https://blah.com/?name=query&orderBy=created_at&orderByType=column&sort=asc',
- );
- });
-
- it('On sorting changed and is metric, resets cursor and reloads to correct page', () => {
- findRegistrySearch().vm.$emit('sorting:changed', { orderBy: 'metric.auc' });
-
- expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
- expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
- 'https://blah.com/?name=query&orderBy=auc&orderByType=metric&sort=asc',
- );
- });
-
- it('On direction changed, reloads to correct page', () => {
- findRegistrySearch().vm.$emit('sorting:changed', { sort: 'desc' });
-
- expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
- expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
- 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=desc',
- );
- });
- });
- });
-
- describe('Pagination behaviour', () => {
- beforeEach(() => {
- createWrapperWithCandidates();
- });
-
- it('should show', () => {
- expect(findPagination().exists()).toBe(true);
- });
-
- it('Passes pagination to pagination component', () => {
- createWrapperWithCandidates();
-
- expect(findPagination().props('startCursor')).toBe(startCursor);
- });
- });
-
- describe('Candidate table', () => {
- const firstCandidateIndex = 0;
- const secondCandidateIndex = 1;
- const firstCandidate = candidates[firstCandidateIndex];
-
- beforeEach(() => {
- createWrapperWithCandidates();
- });
-
- it('renders all rows', () => {
- expect(findTableRows()).toHaveLength(candidates.length);
- });
-
- it('sets the correct columns in the table', () => {
- const expectedColumnNames = [
- 'Name',
- 'Created at',
- 'User',
- 'L1 Ratio',
- 'Rmse',
- 'Auc',
- 'Mae',
- '',
- '',
- ];
-
- expect(findTableHeaders().wrappers.map((h) => h.text())).toEqual(expectedColumnNames);
- });
-
- describe('Artifact column', () => {
- const artifactColumnIndex = -1;
-
- it('shows the a link to the artifact', () => {
- expect(hrefInRowAndColumn(firstCandidateIndex, artifactColumnIndex)).toBe(
- firstCandidate.artifact,
- );
- });
-
- it('shows empty state when no artifact', () => {
- expect(findColumnInRow(secondCandidateIndex, artifactColumnIndex).text()).toBe('-');
- });
- });
-
- describe('User column', () => {
- const userColumn = 2;
-
- it('creates a link to the user', () => {
- const column = findColumnInRow(firstCandidateIndex, userColumn).findComponent(GlLink);
-
- expect(column.attributes().href).toBe(firstCandidate.user.path);
- expect(column.text()).toBe(`@${firstCandidate.user.username}`);
- });
-
- it('when there is no user shows empty state', () => {
- createWrapperWithCandidates();
-
- expect(findColumnInRow(secondCandidateIndex, userColumn).text()).toBe('-');
- });
- });
-
- describe('Candidate name column', () => {
- const nameColumnIndex = 0;
-
- it('Sets the name', () => {
- expect(findColumnInRow(firstCandidateIndex, nameColumnIndex).text()).toBe(
- firstCandidate.name,
- );
- });
-
- it('when there is no user shows nothing', () => {
- expect(findColumnInRow(secondCandidateIndex, nameColumnIndex).text()).toBe('');
- });
- });
-
- describe('Detail column', () => {
- const detailColumn = -2;
-
- it('is a link to details', () => {
- expect(hrefInRowAndColumn(firstCandidateIndex, detailColumn)).toBe(firstCandidate.details);
- });
- });
- });
-});
diff --git a/spec/frontend/ml/experiment_tracking/components/model_experiments_header_spec.js b/spec/frontend/ml/experiment_tracking/components/model_experiments_header_spec.js
new file mode 100644
index 00000000000..0794d4747b3
--- /dev/null
+++ b/spec/frontend/ml/experiment_tracking/components/model_experiments_header_spec.js
@@ -0,0 +1,35 @@
+import { GlBadge } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
+
+describe('ml/experiment_tracking/components/model_experiments_header.vue', () => {
+ let wrapper;
+
+ const createWrapper = () => {
+ wrapper = shallowMount(ModelExperimentsHeader, {
+ propsData: { pageTitle: 'Some Title' },
+ slots: {
+ default: 'Slot content',
+ },
+ });
+ };
+
+ beforeEach(createWrapper);
+
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const findTitle = () => wrapper.find('h3');
+
+ it('renders title', () => {
+ expect(findTitle().text()).toBe('Some Title');
+ });
+
+ it('link points to documentation', () => {
+ expect(findBadge().attributes().href).toBe(
+ '/help/user/project/ml/experiment_tracking/index.md',
+ );
+ });
+
+ it('renders slots', () => {
+ expect(wrapper.html()).toContain('Slot content');
+ });
+});
diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js
new file mode 100644
index 00000000000..8a39c5de2b3
--- /dev/null
+++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js
@@ -0,0 +1,49 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink } from '@gitlab/ui';
+import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue';
+
+describe('CandidateDetailRow', () => {
+ const SECTION_LABEL_CELL = 0;
+ const ROW_LABEL_CELL = 1;
+ const ROW_VALUE_CELL = 2;
+
+ let wrapper;
+
+ const createWrapper = (href = '') => {
+ wrapper = shallowMount(DetailRow, {
+ propsData: { sectionLabel: 'Section', label: 'Item', text: 'Text', href },
+ });
+ };
+
+ const findCellAt = (index) => wrapper.findAll('td').at(index);
+ const findLink = () => findCellAt(ROW_VALUE_CELL).findComponent(GlLink);
+
+ beforeEach(() => createWrapper());
+
+ it('renders section label', () => {
+ expect(findCellAt(SECTION_LABEL_CELL).text()).toBe('Section');
+ });
+
+ it('renders row label', () => {
+ expect(findCellAt(ROW_LABEL_CELL).text()).toBe('Item');
+ });
+
+ describe('No href', () => {
+ it('Renders text', () => {
+ expect(findCellAt(ROW_VALUE_CELL).text()).toBe('Text');
+ });
+
+ it('Does not render as link', () => {
+ expect(findLink().exists()).toBe(false);
+ });
+ });
+
+ describe('With href', () => {
+ beforeEach(() => createWrapper('LINK'));
+
+ it('Renders link', () => {
+ expect(findLink().attributes().href).toBe('LINK');
+ expect(findLink().text()).toBe('Text');
+ });
+ });
+});
diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js
new file mode 100644
index 00000000000..9d1c22faa8f
--- /dev/null
+++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js
@@ -0,0 +1,119 @@
+import { shallowMount } from '@vue/test-utils';
+import MlCandidatesShow from '~/ml/experiment_tracking/routes/candidates/show';
+import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue';
+import { TITLE_LABEL } from '~/ml/experiment_tracking/routes/candidates/show/translations';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
+import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
+import { newCandidate } from './mock_data';
+
+describe('MlCandidatesShow', () => {
+ let wrapper;
+ const CANDIDATE = newCandidate();
+
+ const createWrapper = (createCandidate = () => CANDIDATE) => {
+ wrapper = shallowMount(MlCandidatesShow, {
+ propsData: { candidate: createCandidate() },
+ });
+ };
+
+ const findDeleteButton = () => wrapper.findComponent(DeleteButton);
+ const findHeader = () => wrapper.findComponent(ModelExperimentsHeader);
+ const findNthDetailRow = (index) => wrapper.findAllComponents(DetailRow).at(index);
+ const findSectionLabel = (label) => wrapper.find(`[sectionLabel='${label}']`);
+ const findLabel = (label) => wrapper.find(`[label='${label}']`);
+
+ describe('Header', () => {
+ beforeEach(() => createWrapper());
+
+ it('shows delete button', () => {
+ expect(findDeleteButton().exists()).toBe(true);
+ });
+
+ it('passes the delete path to delete button', () => {
+ expect(findDeleteButton().props('deletePath')).toBe('path_to_candidate');
+ });
+
+ it('passes the right title', () => {
+ expect(findHeader().props('pageTitle')).toBe(TITLE_LABEL);
+ });
+ });
+
+ describe('Detail Table', () => {
+ describe('All info available', () => {
+ beforeEach(() => createWrapper());
+
+ const expectedTable = [
+ ['Info', 'ID', CANDIDATE.info.iid, ''],
+ ['', 'MLflow run ID', CANDIDATE.info.eid, ''],
+ ['', 'Status', CANDIDATE.info.status, ''],
+ ['', 'Experiment', CANDIDATE.info.experiment_name, CANDIDATE.info.path_to_experiment],
+ ['', 'Artifacts', 'Artifacts', CANDIDATE.info.path_to_artifact],
+ ['Parameters', CANDIDATE.params[0].name, CANDIDATE.params[0].value, ''],
+ ['', CANDIDATE.params[1].name, CANDIDATE.params[1].value, ''],
+ ['Metrics', CANDIDATE.metrics[0].name, CANDIDATE.metrics[0].value, ''],
+ ['', CANDIDATE.metrics[1].name, CANDIDATE.metrics[1].value, ''],
+ ['Metadata', CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value, ''],
+ ['', CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value, ''],
+ ].map((row, index) => [index, ...row]);
+
+ it.each(expectedTable)(
+ 'row %s is created correctly',
+ (index, sectionLabel, label, text, href) => {
+ const row = findNthDetailRow(index);
+
+ expect(row.props()).toMatchObject({ sectionLabel, label, text, href });
+ },
+ );
+ it('does not render params', () => {
+ expect(findSectionLabel('Parameters').exists()).toBe(true);
+ });
+
+ it('renders all conditional rows', () => {
+ // This is a bit of a duplicated test from the above table test, but having this makes sure that the
+ // tests that test the negatives are implemented correctly
+ expect(findLabel('Artifacts').exists()).toBe(true);
+ expect(findSectionLabel('Parameters').exists()).toBe(true);
+ expect(findSectionLabel('Metadata').exists()).toBe(true);
+ expect(findSectionLabel('Metrics').exists()).toBe(true);
+ });
+ });
+
+ describe('No artifact path', () => {
+ beforeEach(() =>
+ createWrapper(() => {
+ const candidate = newCandidate();
+ delete candidate.info.path_to_artifact;
+ return candidate;
+ }),
+ );
+
+ it('does not render artifact row', () => {
+ expect(findLabel('Artifacts').exists()).toBe(false);
+ });
+ });
+
+ describe('No params, metrics, ci or metadata available', () => {
+ beforeEach(() =>
+ createWrapper(() => {
+ const candidate = newCandidate();
+ delete candidate.params;
+ delete candidate.metrics;
+ delete candidate.metadata;
+ return candidate;
+ }),
+ );
+
+ it('does not render params', () => {
+ expect(findSectionLabel('Parameters').exists()).toBe(false);
+ });
+
+ it('does not render metadata', () => {
+ expect(findSectionLabel('Metadata').exists()).toBe(false);
+ });
+
+ it('does not render metrics', () => {
+ expect(findSectionLabel('Metrics').exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js
new file mode 100644
index 00000000000..cad2c03fc93
--- /dev/null
+++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js
@@ -0,0 +1,23 @@
+export const newCandidate = () => ({
+ params: [
+ { name: 'Algorithm', value: 'Decision Tree' },
+ { name: 'MaxDepth', value: '3' },
+ ],
+ metrics: [
+ { name: 'AUC', value: '.55' },
+ { name: 'Accuracy', value: '.99' },
+ ],
+ metadata: [
+ { name: 'FileName', value: 'test.py' },
+ { name: 'ExecutionTime', value: '.0856' },
+ ],
+ info: {
+ iid: 'candidate_iid',
+ eid: 'abcdefg',
+ path_to_artifact: 'path_to_artifact',
+ experiment_name: 'The Experiment',
+ path_to_experiment: 'path/to/experiment',
+ status: 'SUCCESS',
+ path: 'path_to_candidate',
+ },
+});
diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js b/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js
index 017db647ac6..0c83be1822e 100644
--- a/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js
+++ b/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js
@@ -1,8 +1,9 @@
import { GlEmptyState, GlLink, GlTableLite } from '@gitlab/ui';
import MlExperimentsIndexApp from '~/ml/experiment_tracking/routes/experiments/index';
-import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
import Pagination from '~/vue_shared/components/incubation/pagination.vue';
+import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { TITLE_LABEL } from '~/ml/experiment_tracking/routes/experiments/index/translations';
import {
startCursor,
firstExperiment,
@@ -14,11 +15,10 @@ import {
let wrapper;
const createWrapper = (defaultExperiments = [], pageInfo = defaultPageInfo) => {
wrapper = mountExtended(MlExperimentsIndexApp, {
- propsData: { experiments: defaultExperiments, pageInfo },
+ propsData: { experiments: defaultExperiments, pageInfo, emptyStateSvgPath: 'path' },
});
};
-const findAlert = () => wrapper.findComponent(IncubationAlert);
const findPagination = () => wrapper.findComponent(Pagination);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findTable = () => wrapper.findComponent(GlTableLite);
@@ -28,6 +28,7 @@ const findNthTableRow = (idx) => findTableRows().at(idx);
const findColumnInRow = (row, col) => findNthTableRow(row).findAll('td').at(col);
const hrefInRowAndColumn = (row, col) =>
findColumnInRow(row, col).findComponent(GlLink).attributes().href;
+const findTitleHeader = () => wrapper.findComponent(ModelExperimentsHeader);
describe('MlExperimentsIndex', () => {
describe('empty state', () => {
@@ -44,12 +45,18 @@ describe('MlExperimentsIndex', () => {
it('does not show pagination', () => {
expect(findPagination().exists()).toBe(false);
});
+
+ it('does not render header', () => {
+ expect(findTitleHeader().exists()).toBe(false);
+ });
});
- it('displays IncubationAlert', () => {
- createWrapper(experiments);
+ describe('Title header', () => {
+ beforeEach(() => createWrapper(experiments));
- expect(findAlert().exists()).toBe(true);
+ it('has the right title', () => {
+ expect(findTitleHeader().props('pageTitle')).toBe(TITLE_LABEL);
+ });
});
describe('experiments table', () => {
diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js
new file mode 100644
index 00000000000..2dd17888305
--- /dev/null
+++ b/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js
@@ -0,0 +1,321 @@
+import { GlTableLite, GlLink, GlEmptyState, GlButton } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import MlExperimentsShow from '~/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
+import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import Pagination from '~/vue_shared/components/incubation/pagination.vue';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import * as urlHelpers from '~/lib/utils/url_utility';
+import { MOCK_START_CURSOR, MOCK_PAGE_INFO, MOCK_CANDIDATES, MOCK_EXPERIMENT } from './mock_data';
+
+describe('MlExperimentsShow', () => {
+ let wrapper;
+
+ const createWrapper = (
+ candidates = [],
+ metricNames = [],
+ paramNames = [],
+ pageInfo = MOCK_PAGE_INFO,
+ experiment = MOCK_EXPERIMENT,
+ emptyStateSvgPath = 'path',
+ ) => {
+ wrapper = mount(MlExperimentsShow, {
+ propsData: { experiment, candidates, metricNames, paramNames, pageInfo, emptyStateSvgPath },
+ });
+ };
+
+ const createWrapperWithCandidates = (pageInfo = MOCK_PAGE_INFO) => {
+ createWrapper(MOCK_CANDIDATES, ['rmse', 'auc', 'mae'], ['l1_ratio'], pageInfo);
+ };
+
+ const findPagination = () => wrapper.findComponent(Pagination);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
+ const findTable = () => wrapper.findComponent(GlTableLite);
+ const findTableHeaders = () => findTable().findAll('th');
+ const findTableRows = () => findTable().findAll('tbody > tr');
+ const findNthTableRow = (idx) => findTableRows().at(idx);
+ const findColumnInRow = (row, col) => findNthTableRow(row).findAll('td').at(col);
+ const findExperimentHeader = () => wrapper.findComponent(ModelExperimentsHeader);
+ const findDeleteButton = () => wrapper.findComponent(DeleteButton);
+ const findDownloadButton = () => findExperimentHeader().findComponent(GlButton);
+
+ const hrefInRowAndColumn = (row, col) =>
+ findColumnInRow(row, col).findComponent(GlLink).attributes().href;
+ const linkTextInRowAndColumn = (row, col) =>
+ findColumnInRow(row, col).findComponent(GlLink).text();
+
+ describe('default inputs', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('shows empty state', () => {
+ expect(findEmptyState().exists()).toBe(true);
+ });
+
+ it('does not show pagination', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+
+ it('shows experiment header', () => {
+ expect(findExperimentHeader().exists()).toBe(true);
+ });
+
+ it('passes the correct title to experiment header', () => {
+ expect(findExperimentHeader().props('pageTitle')).toBe(MOCK_EXPERIMENT.name);
+ });
+
+ it('does not show table', () => {
+ expect(findTable().exists()).toBe(false);
+ });
+
+ it('initializes sorting correctly', () => {
+ expect(findRegistrySearch().props('sorting')).toMatchObject({
+ orderBy: 'created_at',
+ sort: 'desc',
+ });
+ });
+
+ it('initializes filters correctly', () => {
+ expect(findRegistrySearch().props('filters')).toMatchObject([{ value: { data: '' } }]);
+ });
+ });
+
+ describe('Delete', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('shows delete button', () => {
+ expect(findDeleteButton().exists()).toBe(true);
+ });
+
+ it('passes the right props', () => {
+ expect(findDeleteButton().props('deletePath')).toBe(MOCK_EXPERIMENT.path);
+ });
+ });
+
+ describe('CSV download', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('shows download CSV button', () => {
+ expect(findDownloadButton().exists()).toBe(true);
+ });
+
+ it('calls the action to download the CSV', () => {
+ setWindowLocation('https://blah.com/something/1?name=query&orderBy=name');
+ jest.spyOn(urlHelpers, 'visitUrl').mockImplementation(() => {});
+
+ findDownloadButton().vm.$emit('click');
+
+ expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
+ expect(urlHelpers.visitUrl).toHaveBeenCalledWith('/something/1.csv?name=query&orderBy=name');
+ });
+ });
+
+ describe('Search', () => {
+ it('shows search box', () => {
+ createWrapper();
+
+ expect(findRegistrySearch().exists()).toBe(true);
+ });
+
+ it('metrics are added as options for sorting', () => {
+ createWrapper([], ['bar']);
+
+ const labels = findRegistrySearch()
+ .props('sortableFields')
+ .map((e) => e.orderBy);
+ expect(labels).toContain('metric.bar');
+ });
+
+ it('sets the component filters based on the querystring', () => {
+ setWindowLocation('https://blah?name=A&orderBy=B&sort=C');
+
+ createWrapper();
+
+ expect(findRegistrySearch().props('filters')).toMatchObject([{ value: { data: 'A' } }]);
+ });
+
+ it('sets the component sort based on the querystring', () => {
+ setWindowLocation('https://blah?name=A&orderBy=B&sort=C');
+
+ createWrapper();
+
+ expect(findRegistrySearch().props('sorting')).toMatchObject({ orderBy: 'B', sort: 'c' });
+ });
+
+ it('sets the component sort based on the querystring, when order by is a metric', () => {
+ setWindowLocation('https://blah?name=A&orderBy=B&sort=C&orderByType=metric');
+
+ createWrapper();
+
+ expect(findRegistrySearch().props('sorting')).toMatchObject({
+ orderBy: 'metric.B',
+ sort: 'c',
+ });
+ });
+
+ describe('Search submit', () => {
+ beforeEach(() => {
+ setWindowLocation('https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc');
+ jest.spyOn(urlHelpers, 'visitUrl').mockImplementation(() => {});
+
+ createWrapper();
+ });
+
+ it('On submit, resets the cursor and reloads to correct page', () => {
+ findRegistrySearch().vm.$emit('filter:submit');
+
+ expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
+ expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
+ 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc',
+ );
+ });
+
+ it('On sorting changed, resets cursor and reloads to correct page', () => {
+ findRegistrySearch().vm.$emit('sorting:changed', { orderBy: 'created_at' });
+
+ expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
+ expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
+ 'https://blah.com/?name=query&orderBy=created_at&orderByType=column&sort=asc',
+ );
+ });
+
+ it('On sorting changed and is metric, resets cursor and reloads to correct page', () => {
+ findRegistrySearch().vm.$emit('sorting:changed', { orderBy: 'metric.auc' });
+
+ expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
+ expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
+ 'https://blah.com/?name=query&orderBy=auc&orderByType=metric&sort=asc',
+ );
+ });
+
+ it('On direction changed, reloads to correct page', () => {
+ findRegistrySearch().vm.$emit('sorting:changed', { sort: 'desc' });
+
+ expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1);
+ expect(urlHelpers.visitUrl).toHaveBeenCalledWith(
+ 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=desc',
+ );
+ });
+ });
+ });
+
+ describe('Pagination behaviour', () => {
+ beforeEach(() => {
+ createWrapperWithCandidates();
+ });
+
+ it('should show', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('Passes pagination to pagination component', () => {
+ createWrapperWithCandidates();
+
+ expect(findPagination().props('startCursor')).toBe(MOCK_START_CURSOR);
+ });
+ });
+
+ describe('Candidate table', () => {
+ const firstCandidateIndex = 0;
+ const secondCandidateIndex = 1;
+ const firstCandidate = MOCK_CANDIDATES[firstCandidateIndex];
+
+ beforeEach(() => {
+ createWrapperWithCandidates();
+ });
+
+ it('renders all rows', () => {
+ expect(findTableRows()).toHaveLength(MOCK_CANDIDATES.length);
+ });
+
+ it('sets the correct columns in the table', () => {
+ const expectedColumnNames = [
+ 'Name',
+ 'Created at',
+ 'Author',
+ 'L1 Ratio',
+ 'Rmse',
+ 'Auc',
+ 'Mae',
+ 'CI Job',
+ 'Artifacts',
+ ];
+
+ expect(findTableHeaders().wrappers.map((h) => h.text())).toEqual(expectedColumnNames);
+ });
+
+ describe('Artifact column', () => {
+ const artifactColumnIndex = -1;
+
+ it('shows the a link to the artifact', () => {
+ expect(hrefInRowAndColumn(firstCandidateIndex, artifactColumnIndex)).toBe(
+ firstCandidate.artifact,
+ );
+ });
+
+ it('shows empty state when no artifact', () => {
+ expect(findColumnInRow(secondCandidateIndex, artifactColumnIndex).text()).toBe(
+ 'No artifacts',
+ );
+ });
+ });
+
+ describe('CI Job column', () => {
+ const jobColumnIndex = -2;
+
+ it('has a link to the job', () => {
+ expect(hrefInRowAndColumn(firstCandidateIndex, jobColumnIndex)).toBe(
+ firstCandidate.ci_job.path,
+ );
+ });
+
+ it('shows the name of the job', () => {
+ expect(linkTextInRowAndColumn(firstCandidateIndex, jobColumnIndex)).toBe(
+ firstCandidate.ci_job.name,
+ );
+ });
+
+ it('shows empty state when there is no job', () => {
+ expect(findColumnInRow(secondCandidateIndex, jobColumnIndex).text()).toBe('-');
+ });
+ });
+
+ describe('User column', () => {
+ const userColumn = 2;
+
+ it('creates a link to the user', () => {
+ const column = findColumnInRow(firstCandidateIndex, userColumn).findComponent(GlLink);
+
+ expect(column.attributes().href).toBe(firstCandidate.user.path);
+ expect(column.text()).toBe(`@${firstCandidate.user.username}`);
+ });
+
+ it('when there is no user shows empty state', () => {
+ createWrapperWithCandidates();
+
+ expect(findColumnInRow(secondCandidateIndex, userColumn).text()).toBe('-');
+ });
+ });
+
+ describe('Candidate name column', () => {
+ const nameColumnIndex = 0;
+
+ it('Sets the name', () => {
+ expect(findColumnInRow(firstCandidateIndex, nameColumnIndex).text()).toBe(
+ firstCandidate.name,
+ );
+ });
+
+ it('when there is no user shows nothing', () => {
+ expect(findColumnInRow(secondCandidateIndex, nameColumnIndex).text()).toBe('No name');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js b/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js
new file mode 100644
index 00000000000..4a606be8da6
--- /dev/null
+++ b/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js
@@ -0,0 +1,58 @@
+export const MOCK_START_CURSOR = 'eyJpZCI6IjE2In0';
+
+export const MOCK_PAGE_INFO = {
+ startCursor: MOCK_START_CURSOR,
+ endCursor: 'eyJpZCI6IjIifQ',
+ hasNextPage: true,
+ hasPreviousPage: true,
+};
+
+export const MOCK_EXPERIMENT = { name: 'experiment', path: '/path/to/experiment' };
+
+export const MOCK_CANDIDATES = [
+ {
+ rmse: 1,
+ l1_ratio: 0.4,
+ details: 'link_to_candidate1',
+ artifact: 'link_to_artifact',
+ ci_job: {
+ path: 'link_to_job',
+ name: 'a job',
+ },
+ name: 'aCandidate',
+ created_at: '2023-01-05T14:07:02.975Z',
+ user: { username: 'root', path: '/root' },
+ },
+ {
+ auc: 0.3,
+ l1_ratio: 0.5,
+ details: 'link_to_candidate2',
+ created_at: '2023-01-05T14:07:02.975Z',
+ name: null,
+ user: null,
+ },
+ {
+ auc: 0.3,
+ l1_ratio: 0.5,
+ details: 'link_to_candidate3',
+ created_at: '2023-01-05T14:07:02.975Z',
+ name: null,
+ user: null,
+ },
+ {
+ auc: 0.3,
+ l1_ratio: 0.5,
+ details: 'link_to_candidate4',
+ created_at: '2023-01-05T14:07:02.975Z',
+ name: null,
+ user: null,
+ },
+ {
+ auc: 0.3,
+ l1_ratio: 0.5,
+ details: 'link_to_candidate5',
+ created_at: '2023-01-05T14:07:02.975Z',
+ name: null,
+ user: null,
+ },
+];
diff --git a/spec/frontend/monitoring/components/charts/column_spec.js b/spec/frontend/monitoring/components/charts/column_spec.js
index 0158966997f..cc38a3fd8a1 100644
--- a/spec/frontend/monitoring/components/charts/column_spec.js
+++ b/spec/frontend/monitoring/components/charts/column_spec.js
@@ -51,10 +51,6 @@ describe('Column component', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('xAxisLabel', () => {
const mockDate = Date.UTC(2020, 4, 26, 20); // 8:00 PM in GMT
diff --git a/spec/frontend/monitoring/components/charts/gauge_spec.js b/spec/frontend/monitoring/components/charts/gauge_spec.js
index 484199698ea..33ea5e83598 100644
--- a/spec/frontend/monitoring/components/charts/gauge_spec.js
+++ b/spec/frontend/monitoring/components/charts/gauge_spec.js
@@ -21,11 +21,6 @@ describe('Gauge Chart component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('chart component', () => {
it('is rendered when props are passed', () => {
createWrapper();
diff --git a/spec/frontend/monitoring/components/charts/heatmap_spec.js b/spec/frontend/monitoring/components/charts/heatmap_spec.js
index e163d4e73a0..54245cbdbc1 100644
--- a/spec/frontend/monitoring/components/charts/heatmap_spec.js
+++ b/spec/frontend/monitoring/components/charts/heatmap_spec.js
@@ -28,10 +28,6 @@ describe('Heatmap component', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should display a label on the x axis', () => {
expect(wrapper.vm.xAxisName).toBe(graphData.xLabel);
});
diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js
index 62a0b7e6ad3..fa31b479296 100644
--- a/spec/frontend/monitoring/components/charts/single_stat_spec.js
+++ b/spec/frontend/monitoring/components/charts/single_stat_spec.js
@@ -21,10 +21,6 @@ describe('Single Stat Chart component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('computed', () => {
describe('statValue', () => {
it('should display the correct value', () => {
diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js
index 503dee7b937..c1b51f71a7e 100644
--- a/spec/frontend/monitoring/components/charts/time_series_spec.js
+++ b/spec/frontend/monitoring/components/charts/time_series_spec.js
@@ -58,10 +58,6 @@ describe('Time series component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('With a single time series', () => {
describe('general functions', () => {
const findChart = () => wrapper.findComponent({ ref: 'chart' });
diff --git a/spec/frontend/monitoring/components/create_dashboard_modal_spec.js b/spec/frontend/monitoring/components/create_dashboard_modal_spec.js
index 88de3467580..eb05b1f184a 100644
--- a/spec/frontend/monitoring/components/create_dashboard_modal_spec.js
+++ b/spec/frontend/monitoring/components/create_dashboard_modal_spec.js
@@ -29,10 +29,6 @@ describe('Create dashboard modal', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has button that links to the project url', async () => {
findRepoButton().trigger('click');
diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
index bb57420d406..4d290922707 100644
--- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
@@ -2,7 +2,7 @@ import { GlDropdownItem, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import ActionsMenu from '~/monitoring/components/dashboard_actions_menu.vue';
import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants';
import { createStore } from '~/monitoring/stores';
@@ -55,11 +55,6 @@ describe('Actions menu', () => {
store = createStore();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('add metric item', () => {
it('is rendered when custom metrics are available', async () => {
createShallowWrapper();
@@ -297,8 +292,8 @@ describe('Actions menu', () => {
findDuplicateDashboardModal().vm.$emit('dashboardDuplicated', newDashboard);
await nextTick();
- expect(redirectTo).toHaveBeenCalled();
- expect(redirectTo).toHaveBeenCalledWith(newDashboardUrl);
+ expect(redirectTo).toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith(newDashboardUrl); // eslint-disable-line import/no-deprecated
});
});
});
@@ -324,7 +319,7 @@ describe('Actions menu', () => {
await nextTick();
expect(findStarDashboardItem().exists()).toBe(true);
- expect(findStarDashboardItem().attributes('disabled')).toBe('true');
+ expect(findStarDashboardItem().attributes('disabled')).toBeDefined();
});
it('on click it dispatches a toggle star action', async () => {
@@ -370,7 +365,7 @@ describe('Actions menu', () => {
});
it('is rendered by default but it is disabled', () => {
- expect(findCreateDashboardItem().attributes('disabled')).toBe('true');
+ expect(findCreateDashboardItem().attributes('disabled')).toBeDefined();
});
describe('when project path is set', () => {
@@ -415,7 +410,7 @@ describe('Actions menu', () => {
});
it('is disabled', () => {
- expect(findCreateDashboardItem().attributes('disabled')).toBe('true');
+ expect(findCreateDashboardItem().attributes('disabled')).toBeDefined();
});
it('does not render a modal for creating a dashboard', () => {
diff --git a/spec/frontend/monitoring/components/dashboard_header_spec.js b/spec/frontend/monitoring/components/dashboard_header_spec.js
index 18ccda2c41c..091e05ab271 100644
--- a/spec/frontend/monitoring/components/dashboard_header_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_header_spec.js
@@ -1,7 +1,7 @@
import { GlDropdownItem, GlSearchBoxByType, GlLoadingIcon, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import ActionsMenu from '~/monitoring/components/dashboard_actions_menu.vue';
import DashboardHeader from '~/monitoring/components/dashboard_header.vue';
import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue';
@@ -9,12 +9,7 @@ import RefreshButton from '~/monitoring/components/refresh_button.vue';
import { createStore } from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
-import {
- environmentData,
- dashboardGitResponse,
- selfMonitoringDashboardGitResponse,
- dashboardHeaderProps,
-} from '../mock_data';
+import { environmentData, dashboardGitResponse, dashboardHeaderProps } from '../mock_data';
import { setupAllDashboards, setupStoreWithDashboard, setupStoreWithData } from '../store_utils';
const mockProjectPath = 'https://path/to/project';
@@ -59,10 +54,6 @@ describe('Dashboard header', () => {
store = createStore();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('dashboards dropdown', () => {
beforeEach(() => {
store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
@@ -83,6 +74,7 @@ describe('Dashboard header', () => {
display_name: 'A display name',
});
+ // eslint-disable-next-line import/no-deprecated
expect(redirectTo).toHaveBeenCalledWith(
`${mockProjectPath}/-/metrics/.gitlab%2Fdashboards%2Fdashboard%26copy.yml`,
);
@@ -94,6 +86,7 @@ describe('Dashboard header', () => {
display_name: 'dashboard&copy.yml',
});
+ // eslint-disable-next-line import/no-deprecated
expect(redirectTo).toHaveBeenCalledWith(`${mockProjectPath}/-/metrics/dashboard%26copy.yml`);
});
});
@@ -271,14 +264,8 @@ describe('Dashboard header', () => {
});
describe('actions menu', () => {
- const ootbDashboards = [
- dashboardGitResponse[0].path,
- selfMonitoringDashboardGitResponse[0].path,
- ];
- const customDashboards = [
- dashboardGitResponse[1].path,
- selfMonitoringDashboardGitResponse[1].path,
- ];
+ const ootbDashboards = [dashboardGitResponse[0].path];
+ const customDashboards = [dashboardGitResponse[1].path];
it('is rendered', () => {
createShallowWrapper();
diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js
index d71f6374967..1cfd132b123 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js
@@ -49,8 +49,6 @@ describe('dashboard invalid url parameters', () => {
jest.spyOn(store, 'dispatch').mockResolvedValue();
});
- afterEach(() => {});
-
it('is mounted', () => {
expect(wrapper.exists()).toBe(true);
});
diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js
index 339c1710a9e..491649e5b96 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js
@@ -106,10 +106,6 @@ describe('Dashboard Panel', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the chart title', () => {
expect(findTitle().text()).toBe(graphDataEmpty.title);
});
@@ -134,10 +130,6 @@ describe('Dashboard Panel', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders no chart title', () => {
expect(findTitle().text()).toBe('');
});
@@ -160,10 +152,6 @@ describe('Dashboard Panel', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the chart title', () => {
expect(findTitle().text()).toBe(graphData.title);
});
@@ -377,10 +365,6 @@ describe('Dashboard Panel', () => {
await nextTick();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('csvText', () => {
it('converts metrics data from json to csv', () => {
const header = `timestamp,"${graphData.y_label} > ${graphData.metrics[0].label}"`;
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 1d17a9116df..1f995965003 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -4,7 +4,7 @@ import { nextTick } from 'vue';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { ESC_KEY } from '~/lib/utils/keys';
import { objectToQuery } from '~/lib/utils/url_utility';
@@ -33,7 +33,7 @@ import {
setupStoreWithLinks,
} from '../store_utils';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Dashboard', () => {
let store;
@@ -75,7 +75,6 @@ describe('Dashboard', () => {
if (store.dispatch.mockReset) {
store.dispatch.mockReset();
}
- wrapper.destroy();
});
describe('request information to the server', () => {
diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js
index 9873654bdda..b123d1e7d79 100644
--- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js
@@ -1,11 +1,11 @@
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import {
queryToObject,
- redirectTo,
+ redirectTo, // eslint-disable-line import/no-deprecated
removeParams,
mergeUrlParams,
updateHistory,
@@ -18,7 +18,7 @@ import { defaultTimeRange } from '~/vue_shared/constants';
import { dashboardProps } from '../fixture_data';
import { mockProjectDir } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility');
describe('dashboard invalid url parameters', () => {
@@ -46,9 +46,6 @@ describe('dashboard invalid url parameters', () => {
});
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
mock.restore();
queryToObject.mockReset();
});
@@ -139,7 +136,7 @@ describe('dashboard invalid url parameters', () => {
// redirect to with new parameters
expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: '120' }, toUrl);
- expect(redirectTo).toHaveBeenCalledTimes(1);
+ expect(redirectTo).toHaveBeenCalledTimes(1); // eslint-disable-line import/no-deprecated
});
it('changes the url when a panel moves the time slider', async () => {
diff --git a/spec/frontend/monitoring/components/embeds/embed_group_spec.js b/spec/frontend/monitoring/components/embeds/embed_group_spec.js
index 6695353bdb5..beb698c838f 100644
--- a/spec/frontend/monitoring/components/embeds/embed_group_spec.js
+++ b/spec/frontend/monitoring/components/embeds/embed_group_spec.js
@@ -48,9 +48,6 @@ describe('Embed Group', () => {
afterEach(() => {
metricsWithDataGetter.mockReset();
- if (wrapper) {
- wrapper.destroy();
- }
});
describe('interactivity', () => {
diff --git a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js
index beff3da2baf..db25d524592 100644
--- a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js
+++ b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js
@@ -52,9 +52,6 @@ describe('MetricEmbed', () => {
afterEach(() => {
metricsWithDataGetter.mockClear();
- if (wrapper) {
- wrapper.destroy();
- }
});
describe('no metrics are available yet', () => {
diff --git a/spec/frontend/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js
index 104263e73e0..593d832f297 100644
--- a/spec/frontend/monitoring/components/graph_group_spec.js
+++ b/spec/frontend/monitoring/components/graph_group_spec.js
@@ -18,10 +18,6 @@ describe('Graph group component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('When group is not collapsed', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/monitoring/components/group_empty_state_spec.js b/spec/frontend/monitoring/components/group_empty_state_spec.js
index e3cd26b0e48..d3a48be7939 100644
--- a/spec/frontend/monitoring/components/group_empty_state_spec.js
+++ b/spec/frontend/monitoring/components/group_empty_state_spec.js
@@ -23,10 +23,6 @@ function createComponent(props) {
describe('GroupEmptyState', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each([
metricStates.NO_DATA,
metricStates.TIMEOUT,
diff --git a/spec/frontend/monitoring/components/refresh_button_spec.js b/spec/frontend/monitoring/components/refresh_button_spec.js
index cb300870689..f6cc6789b1f 100644
--- a/spec/frontend/monitoring/components/refresh_button_spec.js
+++ b/spec/frontend/monitoring/components/refresh_button_spec.js
@@ -40,6 +40,7 @@ describe('RefreshButton', () => {
afterEach(() => {
dispatch.mockReset();
+ // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy
wrapper.destroy();
});
diff --git a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js
index 012e2e9c3e2..e6c5569fa19 100644
--- a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js
+++ b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js
@@ -1,6 +1,5 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import DropdownField from '~/monitoring/components/variables/dropdown_field.vue';
describe('Custom variable component', () => {
@@ -54,13 +53,10 @@ describe('Custom variable component', () => {
expect(findDropdown().exists()).toBe(true);
});
- it('changing dropdown items triggers update', async () => {
+ it('changing dropdown items triggers update', () => {
createShallowWrapper();
- jest.spyOn(wrapper.vm, '$emit');
-
findDropdownItems().at(1).vm.$emit('click');
- await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', 'canary');
+ expect(wrapper.emitted('input')).toEqual([['canary']]);
});
});
diff --git a/spec/frontend/monitoring/components/variables/text_field_spec.js b/spec/frontend/monitoring/components/variables/text_field_spec.js
index 3073b3968aa..20e1937c5ac 100644
--- a/spec/frontend/monitoring/components/variables/text_field_spec.js
+++ b/spec/frontend/monitoring/components/variables/text_field_spec.js
@@ -33,25 +33,23 @@ describe('Text variable component', () => {
it('triggers keyup enter', async () => {
createShallowWrapper();
- jest.spyOn(wrapper.vm, '$emit');
findInput().element.value = 'prod-pod';
findInput().trigger('input');
findInput().trigger('keyup.enter');
await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', 'prod-pod');
+ expect(wrapper.emitted('input')).toEqual([['prod-pod']]);
});
it('triggers blur enter', async () => {
createShallowWrapper();
- jest.spyOn(wrapper.vm, '$emit');
findInput().element.value = 'canary-pod';
findInput().trigger('input');
findInput().trigger('blur');
await nextTick();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', 'canary-pod');
+ expect(wrapper.emitted('input')).toEqual([['canary-pod']]);
});
});
diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js
index 00be5868ba3..1d23190e586 100644
--- a/spec/frontend/monitoring/mock_data.js
+++ b/spec/frontend/monitoring/mock_data.js
@@ -206,32 +206,6 @@ export const dashboardGitResponse = [
...customDashboardsData,
];
-export const selfMonitoringDashboardGitResponse = [
- {
- default: true,
- display_name: 'Default',
- can_edit: false,
- system_dashboard: true,
- out_of_the_box_dashboard: true,
- project_blob_path: null,
- path: 'config/prometheus/self_monitoring_default.yml',
- starred: false,
- user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/self_monitoring_default.yml`,
- },
- {
- default: false,
- display_name: 'dashboard.yml',
- can_edit: true,
- system_dashboard: false,
- out_of_the_box_dashboard: false,
- project_blob_path: `${mockProjectDir}/-/blob/main/.gitlab/dashboards/dashboard.yml`,
- path: '.gitlab/dashboards/dashboard.yml',
- starred: true,
- user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`,
- },
- ...customDashboardsData,
-];
-
// Metrics mocks
export const metricsResult = [
diff --git a/spec/frontend/monitoring/pages/dashboard_page_spec.js b/spec/frontend/monitoring/pages/dashboard_page_spec.js
index c5a8b50ee60..7fcb7607772 100644
--- a/spec/frontend/monitoring/pages/dashboard_page_spec.js
+++ b/spec/frontend/monitoring/pages/dashboard_page_spec.js
@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import Dashboard from '~/monitoring/components/dashboard.vue';
import DashboardPage from '~/monitoring/pages/dashboard_page.vue';
import { createStore } from '~/monitoring/stores';
+import { assertProps } from 'helpers/assert_props';
import { dashboardProps } from '../fixture_data';
describe('monitoring/pages/dashboard_page', () => {
@@ -37,15 +38,8 @@ describe('monitoring/pages/dashboard_page', () => {
jest.spyOn(store, 'dispatch').mockResolvedValue();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
it('throws errors if dashboard props are not passed', () => {
- expect(() => buildWrapper()).toThrow('Missing required prop: "dashboardProps"');
+ expect(() => assertProps(DashboardPage, {})).toThrow('Missing required prop: "dashboardProps"');
});
it('renders the dashboard page with dashboard component', () => {
diff --git a/spec/frontend/monitoring/pages/panel_new_page_spec.js b/spec/frontend/monitoring/pages/panel_new_page_spec.js
index fa112fca2db..98ee6c1cb29 100644
--- a/spec/frontend/monitoring/pages/panel_new_page_spec.js
+++ b/spec/frontend/monitoring/pages/panel_new_page_spec.js
@@ -49,10 +49,6 @@ describe('monitoring/pages/panel_new_page', () => {
mountComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('back to dashboard button', () => {
it('is rendered', () => {
expect(findBackButton().exists()).toBe(true);
diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js
index 8eda46a2ff1..b3b198d6b51 100644
--- a/spec/frontend/monitoring/store/actions_spec.js
+++ b/spec/frontend/monitoring/store/actions_spec.js
@@ -1,7 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import { backoffMockImplementation } from 'helpers/backoff_helper';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import * as commonUtils from '~/lib/utils/common_utils';
import {
@@ -61,7 +61,7 @@ import {
mockDashboardsErrorResponse,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Monitoring store actions', () => {
const { convertObjectPropsToCamelCase } = commonUtils;
@@ -177,7 +177,6 @@ describe('Monitoring store actions', () => {
});
it('dispatches when feature metricsDashboardAnnotations is on', () => {
- const origGon = window.gon;
window.gon = { features: { metricsDashboardAnnotations: true } };
return testAction(
@@ -190,9 +189,7 @@ describe('Monitoring store actions', () => {
{ type: 'fetchDashboard' },
{ type: 'fetchAnnotations' },
],
- ).then(() => {
- window.gon = origGon;
- });
+ );
});
});
@@ -263,7 +260,7 @@ describe('Monitoring store actions', () => {
});
});
- it('does not show a flash error when showErrorBanner is disabled', async () => {
+ it('does not show an alert when showErrorBanner is disabled', async () => {
state.showErrorBanner = false;
await result();
diff --git a/spec/frontend/nav/components/new_nav_toggle_spec.js b/spec/frontend/nav/components/new_nav_toggle_spec.js
index bad24345f9d..cf8e59d6522 100644
--- a/spec/frontend/nav/components/new_nav_toggle_spec.js
+++ b/spec/frontend/nav/components/new_nav_toggle_spec.js
@@ -1,16 +1,17 @@
import { mount, createWrapper } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { getByText as getByTextHelper } from '@testing-library/dom';
-import { GlToggle } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlToggle } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
+import { mockTracking } from 'helpers/tracking_helper';
-jest.mock('~/flash');
+jest.mock('~/alert');
const TEST_ENDPONT = 'https://example.com/toggle';
@@ -18,8 +19,10 @@ describe('NewNavToggle', () => {
useMockLocationHelper();
let wrapper;
+ let trackingSpy;
const findToggle = () => wrapper.findComponent(GlToggle);
+ const findDisclosureItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
const createComponent = (propsData = { enabled: false }) => {
wrapper = mount(NewNavToggle, {
@@ -28,85 +31,184 @@ describe('NewNavToggle', () => {
...propsData,
},
});
- };
- afterEach(() => {
- wrapper.destroy();
- });
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ };
const getByText = (text, options) =>
createWrapper(getByTextHelper(wrapper.element, text, options));
- it('renders its title', () => {
- createComponent();
- expect(getByText('Navigation redesign').exists()).toBe(true);
- });
-
- describe('when user preference is enabled', () => {
- beforeEach(() => {
- createComponent({ enabled: true });
- });
-
- it('renders the toggle as enabled', () => {
- expect(findToggle().props('value')).toBe(true);
+ describe('When rendered in scope of the new navigation', () => {
+ it('renders the disclosure item', () => {
+ createComponent({ newNavigation: true, enabled: true });
+ expect(findDisclosureItem().exists()).toBe(true);
});
- });
- describe('when user preference is disabled', () => {
- beforeEach(() => {
- createComponent({ enabled: false });
- });
+ describe('when user preference is enabled', () => {
+ beforeEach(() => {
+ createComponent({ newNavigation: true, enabled: true });
+ });
- it('renders the toggle as disabled', () => {
- expect(findToggle().props('value')).toBe(false);
+ it('renders the toggle as enabled', () => {
+ expect(findToggle().props('value')).toBe(true);
+ });
});
- });
- describe.each`
- desc | actFn
- ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')}
- ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')}
- `('$desc', ({ actFn }) => {
- let mock;
+ describe('when user preference is disabled', () => {
+ beforeEach(() => {
+ createComponent({ enabled: false });
+ });
- beforeEach(() => {
- mock = new MockAdapter(axios);
- createComponent({ enabled: false });
+ it('renders the toggle as disabled', () => {
+ expect(findToggle().props('value')).toBe(false);
+ });
});
- it('reloads the page on success', async () => {
- mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK);
-
- actFn();
- await waitForPromises();
-
- expect(window.location.reload).toHaveBeenCalled();
+ describe.each`
+ desc | actFn | toggleValue | trackingLabel | trackingProperty
+ ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'}
+ ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'}
+ ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'}
+ ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'}
+ `('$desc', ({ actFn, toggleValue, trackingLabel, trackingProperty }) => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ createComponent({ enabled: toggleValue });
+ });
+
+ it('reloads the page on success', async () => {
+ mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK);
+
+ actFn();
+ await waitForPromises();
+
+ expect(window.location.reload).toHaveBeenCalled();
+ });
+
+ it('shows an alert on error', async () => {
+ mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ actFn();
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: s__(
+ 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.',
+ ),
+ }),
+ );
+ expect(window.location.reload).not.toHaveBeenCalled();
+ });
+
+ it('changes the toggle', async () => {
+ await actFn();
+
+ expect(findToggle().props('value')).toBe(!toggleValue);
+ });
+
+ it('tracks the Snowplow event', async () => {
+ mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK);
+ await actFn();
+ await waitForPromises();
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
+ label: trackingLabel,
+ property: trackingProperty,
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
});
+ });
- it('shows an alert on error', async () => {
- mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ describe('When rendered in scope of the current navigation', () => {
+ it('renders its title', () => {
+ createComponent();
+ expect(getByText('Navigation redesign').exists()).toBe(true);
+ });
- actFn();
- await waitForPromises();
+ describe('when user preference is enabled', () => {
+ beforeEach(() => {
+ createComponent({ enabled: true });
+ });
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: s__(
- 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.',
- ),
- }),
- );
- expect(window.location.reload).not.toHaveBeenCalled();
+ it('renders the toggle as enabled', () => {
+ expect(findToggle().props('value')).toBe(true);
+ });
});
- it('changes the toggle', async () => {
- await actFn();
+ describe('when user preference is disabled', () => {
+ beforeEach(() => {
+ createComponent({ enabled: false });
+ });
- expect(findToggle().props('value')).toBe(true);
+ it('renders the toggle as disabled', () => {
+ expect(findToggle().props('value')).toBe(false);
+ });
});
- afterEach(() => {
- mock.restore();
+ describe.each`
+ desc | actFn | toggleValue | trackingLabel | trackingProperty
+ ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'}
+ ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'}
+ ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'}
+ ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'}
+ `('$desc', ({ actFn, toggleValue, trackingLabel, trackingProperty }) => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ createComponent({ enabled: toggleValue });
+ });
+
+ it('reloads the page on success', async () => {
+ mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK);
+
+ actFn();
+ await waitForPromises();
+
+ expect(window.location.reload).toHaveBeenCalled();
+ });
+
+ it('shows an alert on error', async () => {
+ mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ actFn();
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: s__(
+ 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.',
+ ),
+ }),
+ );
+ expect(window.location.reload).not.toHaveBeenCalled();
+ });
+
+ it('changes the toggle', async () => {
+ await actFn();
+
+ expect(findToggle().props('value')).toBe(!toggleValue);
+ });
+
+ it('tracks the Snowplow event', async () => {
+ mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK);
+ await actFn();
+ await waitForPromises();
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
+ label: trackingLabel,
+ property: trackingProperty,
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
});
});
});
diff --git a/spec/frontend/nav/components/responsive_app_spec.js b/spec/frontend/nav/components/responsive_app_spec.js
index 76b8ebdc92f..9d3b43520ec 100644
--- a/spec/frontend/nav/components/responsive_app_spec.js
+++ b/spec/frontend/nav/components/responsive_app_spec.js
@@ -33,10 +33,6 @@ describe('~/nav/components/responsive_app.vue', () => {
document.body.className = 'test-class';
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/nav/components/responsive_header_spec.js b/spec/frontend/nav/components/responsive_header_spec.js
index f87de0afb14..2514035270a 100644
--- a/spec/frontend/nav/components/responsive_header_spec.js
+++ b/spec/frontend/nav/components/responsive_header_spec.js
@@ -14,7 +14,7 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
default: TEST_SLOT_CONTENT,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -25,10 +25,6 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders slot', () => {
expect(wrapper.text()).toBe(TEST_SLOT_CONTENT);
});
diff --git a/spec/frontend/nav/components/responsive_home_spec.js b/spec/frontend/nav/components/responsive_home_spec.js
index 8f198d92747..5a5cfc93607 100644
--- a/spec/frontend/nav/components/responsive_home_spec.js
+++ b/spec/frontend/nav/components/responsive_home_spec.js
@@ -29,7 +29,7 @@ describe('~/nav/components/responsive_home.vue', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
listeners: {
'menu-item-click': menuItemClickListener,
@@ -45,10 +45,6 @@ describe('~/nav/components/responsive_home.vue', () => {
menuItemClickListener = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/nav/components/top_nav_app_spec.js b/spec/frontend/nav/components/top_nav_app_spec.js
index e70f70afc97..7f39552eb42 100644
--- a/spec/frontend/nav/components/top_nav_app_spec.js
+++ b/spec/frontend/nav/components/top_nav_app_spec.js
@@ -28,10 +28,6 @@ describe('~/nav/components/top_nav_app.vue', () => {
const findNavItemDropdowToggle = () => findNavItemDropdown().find('.js-top-nav-dropdown-toggle');
const findMenu = () => wrapper.findComponent(TopNavDropdownMenu);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponentShallow();
diff --git a/spec/frontend/nav/components/top_nav_container_view_spec.js b/spec/frontend/nav/components/top_nav_container_view_spec.js
index 293fe361fa9..388ac243648 100644
--- a/spec/frontend/nav/components/top_nav_container_view_spec.js
+++ b/spec/frontend/nav/components/top_nav_container_view_spec.js
@@ -48,10 +48,6 @@ describe('~/nav/components/top_nav_container_view.vue', () => {
};
const findFrequentItemsContainer = () => wrapper.find('[data-testid="frequent-items-container"]');
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each(['projects', 'groups'])(
'emits frequent items event to event hub (%s)',
async (frequentItemsDropdownType) => {
diff --git a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js
index 8a0340087ec..08d6650b5bb 100644
--- a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js
+++ b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js
@@ -36,10 +36,6 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => {
active: idx === activeIndex,
}));
- afterEach(() => {
- wrapper.destroy();
- });
-
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation();
});
diff --git a/spec/frontend/nav/components/top_nav_menu_sections_spec.js b/spec/frontend/nav/components/top_nav_menu_sections_spec.js
index 7a5a8475ab7..7a3e58fd964 100644
--- a/spec/frontend/nav/components/top_nav_menu_sections_spec.js
+++ b/spec/frontend/nav/components/top_nav_menu_sections_spec.js
@@ -54,10 +54,6 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
menuItems: findMenuItemModels(x),
}));
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js
index 18210658b89..2cd65307b0b 100644
--- a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js
+++ b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js
@@ -1,6 +1,8 @@
import { GlDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { TOP_NAV_INVITE_MEMBERS_COMPONENT } from '~/invite_members/constants';
const TEST_VIEW_MODEL = {
title: 'Dropdown',
@@ -18,6 +20,16 @@ const TEST_VIEW_MODEL = {
menu_items: [
{ id: 'bar-1', title: 'Bar 1', href: '/bar/1' },
{ id: 'bar-2', title: 'Bar 2', href: '/bar/2' },
+ {
+ id: 'invite',
+ title: '_invite members title_',
+ component: TOP_NAV_INVITE_MEMBERS_COMPONENT,
+ icon: '_icon_',
+ data: {
+ trigger_element: '_trigger_element_',
+ trigger_source: '_trigger_source_',
+ },
+ },
],
},
],
@@ -36,6 +48,7 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger);
const findDropdownContents = () =>
findDropdown()
.findAll('[data-testid]')
@@ -55,10 +68,6 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
@@ -73,6 +82,10 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
});
it('renders dropdown content', () => {
+ const hrefItems = TEST_VIEW_MODEL.menu_sections[1].menu_items.filter((item) =>
+ Boolean(item.href),
+ );
+
expect(findDropdownContents()).toEqual([
{
type: 'header',
@@ -90,12 +103,18 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
type: 'header',
text: TEST_VIEW_MODEL.menu_sections[1].title,
},
- ...TEST_VIEW_MODEL.menu_sections[1].menu_items.map(({ title, href }) => ({
+ ...hrefItems.map(({ title, href }) => ({
type: 'item',
href,
text: title,
})),
]);
+ expect(findInviteMembersTrigger().props()).toMatchObject({
+ displayText: '_invite members title_',
+ icon: '_icon_',
+ triggerElement: 'dropdown-_trigger_element_',
+ triggerSource: '_trigger_source_',
+ });
});
});
diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js
index 5a09598059d..baff5ebfdb8 100644
--- a/spec/frontend/new_branch_spec.js
+++ b/spec/frontend/new_branch_spec.js
@@ -1,4 +1,5 @@
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlBranchesNewBranch from 'test_fixtures/branches/new_branch.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import NewBranchForm from '~/new_branch_form';
describe('Branch', () => {
@@ -11,7 +12,7 @@ describe('Branch', () => {
describe('create a new branch', () => {
function fillNameWith(value) {
document.querySelector('.js-branch-name').value = value;
- const event = new CustomEvent('blur');
+ const event = new CustomEvent('change');
document.querySelector('.js-branch-name').dispatchEvent(event);
}
@@ -20,7 +21,7 @@ describe('Branch', () => {
}
beforeEach(() => {
- loadHTMLFixture('branches/new_branch.html');
+ setHTMLFixture(htmlBranchesNewBranch);
document.querySelector('form').addEventListener('submit', (e) => e.preventDefault());
testContext.form = new NewBranchForm(document.querySelector('.js-create-branch-form'), []);
});
diff --git a/spec/frontend/notebook/cells/code_spec.js b/spec/frontend/notebook/cells/code_spec.js
index 10762a1c3a2..9836400a366 100644
--- a/spec/frontend/notebook/cells/code_spec.js
+++ b/spec/frontend/notebook/cells/code_spec.js
@@ -13,10 +13,6 @@ describe('Code component', () => {
json = JSON.parse(JSON.stringify(fixture));
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('without output', () => {
beforeEach(() => {
wrapper = mountComponent(json.cells[0]);
diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js
index a7776bd5b69..f226776212a 100644
--- a/spec/frontend/notebook/cells/markdown_spec.js
+++ b/spec/frontend/notebook/cells/markdown_spec.js
@@ -1,18 +1,16 @@
import { mount } from '@vue/test-utils';
import katex from 'katex';
-import Vue, { nextTick } from 'vue';
+import { nextTick } from 'vue';
import markdownTableJson from 'test_fixtures/blob/notebook/markdown-table.json';
import basicJson from 'test_fixtures/blob/notebook/basic.json';
import mathJson from 'test_fixtures/blob/notebook/math.json';
import MarkdownComponent from '~/notebook/cells/markdown.vue';
import Prompt from '~/notebook/cells/prompt.vue';
-const Component = Vue.extend(MarkdownComponent);
-
window.katex = katex;
function buildCellComponent(cell, relativePath = '', hidePrompt) {
- return mount(Component, {
+ return mount(MarkdownComponent, {
propsData: {
cell,
hidePrompt,
diff --git a/spec/frontend/notebook/cells/output/dataframe_spec.js b/spec/frontend/notebook/cells/output/dataframe_spec.js
new file mode 100644
index 00000000000..bf90497a36b
--- /dev/null
+++ b/spec/frontend/notebook/cells/output/dataframe_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import DataframeOutput from '~/notebook/cells/output/dataframe.vue';
+import JSONTable from '~/behaviors/components/json_table.vue';
+import { outputWithDataframe } from '../../mock_data';
+
+describe('~/notebook/cells/output/DataframeOutput', () => {
+ let wrapper;
+
+ function createComponent(rawCode) {
+ wrapper = shallowMount(DataframeOutput, {
+ propsData: {
+ rawCode,
+ count: 0,
+ index: 0,
+ },
+ });
+ }
+
+ const findTable = () => wrapper.findComponent(JSONTable);
+
+ describe('with valid dataframe', () => {
+ beforeEach(() => createComponent(outputWithDataframe.data['text/html'].join('')));
+
+ it('mounts the table', () => {
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it('table caption is empty', () => {
+ expect(findTable().props().caption).toEqual('');
+ });
+
+ it('allows filtering', () => {
+ expect(findTable().props().hasFilter).toBe(true);
+ });
+
+ it('sets the correct fields', () => {
+ expect(findTable().props().fields).toEqual([
+ { key: 'index0', label: '', sortable: true, class: 'gl-font-weight-bold' },
+ { key: 'column0', label: 'column_1', sortable: true, class: '' },
+ { key: 'column1', label: 'column_2', sortable: true, class: '' },
+ ]);
+ });
+
+ it('sets the correct items', () => {
+ expect(findTable().props().items).toEqual([
+ { index0: '0', column0: 'abc de f', column1: 'a' },
+ { index0: '1', column0: 'True', column1: '0.1' },
+ ]);
+ });
+ });
+
+ describe('invalid dataframe', () => {
+ it('still displays the table', () => {
+ createComponent('dataframe');
+
+ expect(findTable().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/output/dataframe_util_spec.js b/spec/frontend/notebook/cells/output/dataframe_util_spec.js
new file mode 100644
index 00000000000..37dee5429e4
--- /dev/null
+++ b/spec/frontend/notebook/cells/output/dataframe_util_spec.js
@@ -0,0 +1,133 @@
+import { isDataframe, convertHtmlTableToJson } from '~/notebook/cells/output/dataframe_util';
+import { outputWithDataframeContent, outputWithMultiIndexDataFrame } from '../../mock_data';
+import sanitizeTests from './html_sanitize_fixtures';
+
+describe('notebook/cells/output/dataframe_utils', () => {
+ describe('isDataframe', () => {
+ describe('when output data has no text/html', () => {
+ it('is is not a dataframe', () => {
+ const input = { data: { 'image/png': ['blah'] } };
+
+ expect(isDataframe(input)).toBe(false);
+ });
+ });
+
+ describe('when output data has no text/html, but no mention of dataframe', () => {
+ it('is is not a dataframe', () => {
+ const input = { data: { 'text/html': ['blah'] } };
+
+ expect(isDataframe(input)).toBe(false);
+ });
+ });
+
+ describe('when output data has text/html, but no mention of dataframe in the first 20 lines', () => {
+ it('is is not a dataframe', () => {
+ const input = { data: { 'text/html': [...new Array(20).fill('a'), 'dataframe'] } };
+
+ expect(isDataframe(input)).toBe(false);
+ });
+ });
+
+ describe('when output data has text/html, and includes "dataframe" within the first 20 lines', () => {
+ it('is is not a dataframe', () => {
+ const input = { data: { 'text/html': ['dataframe'] } };
+
+ expect(isDataframe(input)).toBe(true);
+ });
+ });
+ });
+
+ describe('convertHtmlTableToJson', () => {
+ it('converts table correctly', () => {
+ const input = outputWithDataframeContent;
+
+ const output = {
+ fields: [
+ { key: 'index0', label: '', sortable: true, class: 'gl-font-weight-bold' },
+ { key: 'column0', label: 'column_1', sortable: true, class: '' },
+ { key: 'column1', label: 'column_2', sortable: true, class: '' },
+ ],
+ items: [
+ { index0: '0', column0: 'abc de f', column1: 'a' },
+ { index0: '1', column0: 'True', column1: '0.1' },
+ ],
+ };
+
+ expect(convertHtmlTableToJson(input)).toEqual(output);
+ });
+
+ it('converts multi-index table correctly', () => {
+ const input = outputWithMultiIndexDataFrame;
+
+ const output = {
+ fields: [
+ { key: 'index0', label: 'first', sortable: true, class: 'gl-font-weight-bold' },
+ { key: 'index1', label: 'second', sortable: true, class: 'gl-font-weight-bold' },
+ { key: 'column0', label: '0', sortable: true, class: '' },
+ ],
+ items: [
+ { index0: 'bar', index1: 'one', column0: '1' },
+ { index0: 'bar', index1: 'two', column0: '2' },
+ { index0: 'baz', index1: 'one', column0: '3' },
+ { index0: 'baz', index1: 'two', column0: '4' },
+ ],
+ };
+
+ expect(convertHtmlTableToJson(input)).toEqual(output);
+ });
+
+ describe('sanitizes input before parsing table', () => {
+ it('sanitizes input html', () => {
+ const parser = new DOMParser();
+ const spy = jest.spyOn(parser, 'parseFromString');
+ const input = 'hello<style>p {width:50%;}</style><script>alert(1)</script>';
+
+ convertHtmlTableToJson(input, parser);
+
+ expect(spy).toHaveBeenCalledWith('hello', 'text/html');
+ });
+ });
+
+ describe('does not include harmful html', () => {
+ const makeDataframeWithHtml = (html) => {
+ return [
+ '<table border="1" class="dataframe">\n',
+ ' <thead>\n',
+ ' <tr style="text-align: right;">\n',
+ ' <th></th>\n',
+ ' <th>column_1</th>\n',
+ ' </tr>\n',
+ ' </thead>\n',
+ ' <tbody>\n',
+ ' <tr>\n',
+ ' <th>0</th>\n',
+ ` <td>${html}</td>\n`,
+ ' </tr>\n',
+ ' </tbody>\n',
+ '</table>\n',
+ '</div>',
+ ];
+ };
+
+ it.each([
+ ['table', 0],
+ ['style', 1],
+ ['iframe', 2],
+ ['svg', 3],
+ ])('sanitizes output for: %p', (tag, index) => {
+ const inputHtml = makeDataframeWithHtml(sanitizeTests[index][1].input);
+ const convertedHtml = convertHtmlTableToJson(inputHtml).items[0].column0;
+
+ expect(convertedHtml).not.toContain(tag);
+ });
+ });
+
+ describe('when dataframe is invalid', () => {
+ it('returns empty', () => {
+ const input = [' dataframe', ' blah'];
+
+ expect(convertHtmlTableToJson(input)).toEqual({ fields: [], items: [] });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/output/error_spec.js b/spec/frontend/notebook/cells/output/error_spec.js
new file mode 100644
index 00000000000..2e4ca8c1761
--- /dev/null
+++ b/spec/frontend/notebook/cells/output/error_spec.js
@@ -0,0 +1,48 @@
+import { mount } from '@vue/test-utils';
+import ErrorOutput from '~/notebook/cells/output/error.vue';
+import Prompt from '~/notebook/cells/prompt.vue';
+import Markdown from '~/notebook/cells/markdown.vue';
+import { errorOutputContent, relativeRawPath } from '../../mock_data';
+
+describe('notebook/cells/output/error.vue', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = mount(ErrorOutput, {
+ propsData: {
+ rawCode: errorOutputContent,
+ index: 1,
+ count: 2,
+ },
+ provide: { relativeRawPath },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ const findPrompt = () => wrapper.findComponent(Prompt);
+ const findMarkdown = () => wrapper.findComponent(Markdown);
+
+ it('renders the prompt', () => {
+ expect(findPrompt().props()).toMatchObject({ count: 2, showOutput: true, type: 'Out' });
+ });
+
+ it('renders the markdown', () => {
+ const expectedParsedMarkdown =
+ '```error\n' +
+ '---------------------------------------------------------------------------\n' +
+ 'NameError Traceback (most recent call last)\n' +
+ '/var/folders/cq/l637k4x13gx6y9p_gfs4c_gc0000gn/T/ipykernel_79203/294318627.py in <module>\n' +
+ '----> 1 To\n' +
+ '\n' +
+ "NameError: name 'To' is not defined\n" +
+ '```';
+
+ expect(findMarkdown().props()).toMatchObject({
+ cell: { source: [expectedParsedMarkdown] },
+ hidePrompt: true,
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js
index 585cbb68eeb..efbdfca8d8c 100644
--- a/spec/frontend/notebook/cells/output/index_spec.js
+++ b/spec/frontend/notebook/cells/output/index_spec.js
@@ -2,7 +2,13 @@ import { mount } from '@vue/test-utils';
import json from 'test_fixtures/blob/notebook/basic.json';
import Output from '~/notebook/cells/output/index.vue';
import MarkdownOutput from '~/notebook/cells/output/markdown.vue';
-import { relativeRawPath, markdownCellContent } from '../../mock_data';
+import DataframeOutput from '~/notebook/cells/output/dataframe.vue';
+import {
+ relativeRawPath,
+ markdownCellContent,
+ outputWithDataframe,
+ outputWithDataframeContent,
+} from '../../mock_data';
describe('Output component', () => {
let wrapper;
@@ -17,10 +23,6 @@ describe('Output component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('text output', () => {
beforeEach(() => {
const textType = json.cells[2];
@@ -109,6 +111,16 @@ describe('Output component', () => {
});
});
+ describe('Dataframe output', () => {
+ it('renders DataframeOutput component', () => {
+ createComponent(outputWithDataframe);
+
+ expect(wrapper.findComponent(DataframeOutput).props('rawCode')).toBe(
+ outputWithDataframeContent.join(''),
+ );
+ });
+ });
+
describe('default to plain text', () => {
beforeEach(() => {
const unknownType = json.cells[6];
diff --git a/spec/frontend/notebook/cells/prompt_spec.js b/spec/frontend/notebook/cells/prompt_spec.js
index 0cda0c5bc2b..4c864a9b930 100644
--- a/spec/frontend/notebook/cells/prompt_spec.js
+++ b/spec/frontend/notebook/cells/prompt_spec.js
@@ -6,10 +6,6 @@ describe('Prompt component', () => {
const mountComponent = ({ type }) => shallowMount(Prompt, { propsData: { type, count: 1 } });
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('input', () => {
beforeEach(() => {
wrapper = mountComponent({ type: 'In' });
diff --git a/spec/frontend/notebook/index_spec.js b/spec/frontend/notebook/index_spec.js
index b79000a3505..3c73d420703 100644
--- a/spec/frontend/notebook/index_spec.js
+++ b/spec/frontend/notebook/index_spec.js
@@ -1,16 +1,14 @@
import { mount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import { nextTick } from 'vue';
import json from 'test_fixtures/blob/notebook/basic.json';
import jsonWithWorksheet from 'test_fixtures/blob/notebook/worksheets.json';
import Notebook from '~/notebook/index.vue';
-const Component = Vue.extend(Notebook);
-
describe('Notebook component', () => {
let vm;
function buildComponent(notebook) {
- return mount(Component, {
+ return mount(Notebook, {
propsData: { notebook },
provide: { relativeRawPath: '' },
}).vm;
diff --git a/spec/frontend/notebook/mock_data.js b/spec/frontend/notebook/mock_data.js
index b1419e1256f..9c63ad773b5 100644
--- a/spec/frontend/notebook/mock_data.js
+++ b/spec/frontend/notebook/mock_data.js
@@ -1,2 +1,104 @@
export const relativeRawPath = '/test';
export const markdownCellContent = ['# Test'];
+export const errorOutputContent = [
+ '\u001b[0;31m---------------------------------------------------------------------------\u001b[0m',
+ '\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)',
+ '\u001b[0;32m/var/folders/cq/l637k4x13gx6y9p_gfs4c_gc0000gn/T/ipykernel_79203/294318627.py\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mTo\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m',
+ "\u001b[0;31mNameError\u001b[0m: name 'To' is not defined",
+];
+export const outputWithDataframeContent = [
+ '<div>\n',
+ '<style scoped>\n',
+ ' .dataframe tbody tr th:only-of-type {\n',
+ ' vertical-align: middle;\n',
+ ' }\n',
+ '\n',
+ ' .dataframe tbody tr th {\n',
+ ' vertical-align: top;\n',
+ ' }\n',
+ '\n',
+ ' .dataframe thead th {\n',
+ ' text-align: right;\n',
+ ' }\n',
+ '</style>\n',
+ '<table border="1" class="dataframe">\n',
+ ' <thead>\n',
+ ' <tr style="text-align: right;">\n',
+ ' <th></th>\n',
+ ' <th>column_1</th>\n',
+ ' <th>column_2</th>\n',
+ ' </tr>\n',
+ ' </thead>\n',
+ ' <tbody>\n',
+ ' <tr>\n',
+ ' <th>0</th>\n',
+ ' <td>abc de f</td>\n',
+ ' <td>a</td>\n',
+ ' </tr>\n',
+ ' <tr>\n',
+ ' <th>1</th>\n',
+ ' <td>True</td>\n',
+ ' <td>0.1</td>\n',
+ ' </tr>\n',
+ ' </tbody>\n',
+ '</table>\n',
+ '</div>',
+];
+
+export const outputWithMultiIndexDataFrame = [
+ '<div>\n',
+ '<style scoped>\n',
+ ' .dataframe tbody tr th:only-of-type {\n',
+ ' vertical-align: middle;\n',
+ ' }\n',
+ '\n',
+ ' .dataframe tbody tr th {\n',
+ ' vertical-align: top;\n',
+ ' }\n',
+ '\n',
+ ' .dataframe thead th {\n',
+ ' text-align: right;\n',
+ ' }\n',
+ '</style>\n',
+ '<table border="1" class="dataframe">\n',
+ ' <thead>\n',
+ ' <tr style="text-align: right;">\n',
+ ' <th></th>\n',
+ ' <th></th>\n',
+ ' <th>0</th>\n',
+ ' </tr>\n',
+ ' <tr>\n',
+ ' <th>first</th>\n',
+ ' <th>second</th>\n',
+ ' <th></th>\n',
+ ' </tr>\n',
+ ' </thead>\n',
+ ' <tbody>\n',
+ ' <tr>\n',
+ ' <th rowspan="2" valign="top">bar</th>\n',
+ ' <th>one</th>\n',
+ ' <td>1</td>\n',
+ ' </tr>\n',
+ ' <tr>\n',
+ ' <th>two</th>\n',
+ ' <td>2</td>\n',
+ ' </tr>\n',
+ ' <tr>\n',
+ ' <th rowspan="2" valign="top">baz</th>\n',
+ ' <th>one</th>\n',
+ ' <td>3</td>\n',
+ ' </tr>\n',
+ ' <tr>\n',
+ ' <th>two</th>\n',
+ ' <td>4</td>\n',
+ ' </tr>\n',
+ ' </tbody>\n',
+ '</table>\n',
+ '</div>',
+];
+
+export const outputWithDataframe = {
+ data: {
+ 'text/html': outputWithDataframeContent,
+ },
+};
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index dfb05c85fc8..70f25afc5ba 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -5,11 +5,13 @@ import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import Autosave from '~/autosave';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import batchComments from '~/batch_comments/stores/modules/batch_comments';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import CommentForm from '~/notes/components/comment_form.vue';
import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue';
@@ -21,18 +23,20 @@ import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock }
jest.mock('autosize');
jest.mock('~/commons/nav/user_merge_requests');
-jest.mock('~/flash');
-jest.mock('~/autosave');
+jest.mock('~/alert');
Vue.use(Vuex);
describe('issue_comment_form component', () => {
+ useLocalStorageSpy();
+
let store;
let wrapper;
let axiosMock;
const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button');
- const findTextArea = () => wrapper.findByTestId('comment-field');
+ const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
+ const findMarkdownEditorTextarea = () => findMarkdownEditor().find('textarea');
const findAddToReviewButton = () => wrapper.findByTestId('add-to-review-button');
const findAddCommentNowButton = () => wrapper.findByTestId('add-comment-now-button');
const findConfidentialNoteCheckbox = () => wrapper.findByTestId('internal-note-checkbox');
@@ -127,7 +131,6 @@ describe('issue_comment_form component', () => {
afterEach(() => {
axiosMock.restore();
- wrapper.destroy();
});
describe('user is logged in', () => {
@@ -136,7 +139,6 @@ describe('issue_comment_form component', () => {
mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } });
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
- jest.spyOn(wrapper.vm, 'resizeTextarea');
jest.spyOn(wrapper.vm, 'stopPolling');
findCloseReopenButton().trigger('click');
@@ -145,7 +147,6 @@ describe('issue_comment_form component', () => {
expect(wrapper.vm.note).toBe('');
expect(wrapper.vm.saveNote).toHaveBeenCalled();
expect(wrapper.vm.stopPolling).toHaveBeenCalled();
- expect(wrapper.vm.resizeTextarea).toHaveBeenCalled();
});
it('does not report errors in the UI when the save succeeds', async () => {
@@ -260,6 +261,18 @@ describe('issue_comment_form component', () => {
});
});
+ it('hides content editor switcher if feature flag content_editor_on_issues is off', () => {
+ mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: false } });
+
+ expect(wrapper.text()).not.toContain('Switch to rich text');
+ });
+
+ it('shows content editor switcher if feature flag content_editor_on_issues is on', () => {
+ mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: true } });
+
+ expect(wrapper.text()).toContain('Switch to rich text');
+ });
+
describe('textarea', () => {
describe('general', () => {
it.each`
@@ -268,13 +281,13 @@ describe('issue_comment_form component', () => {
${'internal note'} | ${true} | ${'Write an internal note or drag your files here…'}
`(
'should render textarea with placeholder for $noteType',
- ({ noteIsInternal, placeholder }) => {
- mountComponent({
- mountFunction: mount,
- initialData: { noteIsInternal },
- });
+ async ({ noteIsInternal, placeholder }) => {
+ mountComponent();
- expect(findTextArea().attributes('placeholder')).toBe(placeholder);
+ wrapper.vm.noteIsInternal = noteIsInternal;
+ await nextTick();
+
+ expect(findMarkdownEditor().props('formFieldProps').placeholder).toBe(placeholder);
},
);
@@ -290,13 +303,13 @@ describe('issue_comment_form component', () => {
await findCommentButton().trigger('click');
- expect(findTextArea().attributes('disabled')).toBe('disabled');
+ expect(findMarkdownEditor().find('textarea').attributes('disabled')).toBeDefined();
});
it('should support quick actions', () => {
mountComponent({ mountFunction: mount });
- expect(findTextArea().attributes('data-supports-quick-actions')).toBe('true');
+ expect(findMarkdownEditor().props('supportsQuickActions')).toBe(true);
});
it('should link to markdown docs', () => {
@@ -336,63 +349,51 @@ describe('issue_comment_form component', () => {
it('should enter edit mode when arrow up is pressed', () => {
jest.spyOn(wrapper.vm, 'editCurrentUserLastNote');
- findTextArea().trigger('keydown.up');
+ findMarkdownEditorTextarea().trigger('keydown.up');
expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled();
});
- it('inits autosave', () => {
- expect(Autosave).toHaveBeenCalledWith(expect.any(Element), [
- 'Note',
- 'Issue',
- noteableDataMock.id,
- ]);
- });
- });
-
- describe('event enter', () => {
- beforeEach(() => {
- mountComponent({ mountFunction: mount });
- });
-
- describe('when no draft exists', () => {
- it('should save note when cmd+enter is pressed', () => {
- jest.spyOn(wrapper.vm, 'handleSave');
+ describe('event enter', () => {
+ describe('when no draft exists', () => {
+ it('should save note when cmd+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSave');
- findTextArea().trigger('keydown.enter', { metaKey: true });
+ findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
- expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
- });
+ expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
+ });
- it('should save note when ctrl+enter is pressed', () => {
- jest.spyOn(wrapper.vm, 'handleSave');
+ it('should save note when ctrl+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSave');
- findTextArea().trigger('keydown.enter', { ctrlKey: true });
+ findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
- expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
+ expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
+ });
});
- });
- describe('when a draft exists', () => {
- beforeEach(() => {
- store.registerModule('batchComments', batchComments());
- store.state.batchComments.drafts = [{ note: 'A' }];
- });
+ describe('when a draft exists', () => {
+ beforeEach(() => {
+ store.registerModule('batchComments', batchComments());
+ store.state.batchComments.drafts = [{ note: 'A' }];
+ });
- it('should save note draft when cmd+enter is pressed', () => {
- jest.spyOn(wrapper.vm, 'handleSaveDraft');
+ it('should save note draft when cmd+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSaveDraft');
- findTextArea().trigger('keydown.enter', { metaKey: true });
+ findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
- expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
- });
+ expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
+ });
- it('should save note draft when ctrl+enter is pressed', () => {
- jest.spyOn(wrapper.vm, 'handleSaveDraft');
+ it('should save note draft when ctrl+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSaveDraft');
- findTextArea().trigger('keydown.enter', { ctrlKey: true });
+ findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
- expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
+ expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
+ });
});
});
});
@@ -482,7 +483,7 @@ describe('issue_comment_form component', () => {
it(`makes an API call to open it`, () => {
mountComponent({
noteableType,
- noteableData: { ...noteableDataMock, state: constants.OPENED },
+ noteableData: { ...noteableDataMock, state: STATUS_OPEN },
mountFunction: mount,
});
@@ -496,7 +497,7 @@ describe('issue_comment_form component', () => {
it(`shows an error when the API call fails`, async () => {
mountComponent({
noteableType,
- noteableData: { ...noteableDataMock, state: constants.OPENED },
+ noteableData: { ...noteableDataMock, state: STATUS_OPEN },
mountFunction: mount,
});
@@ -517,7 +518,7 @@ describe('issue_comment_form component', () => {
it('makes an API call to close it', () => {
mountComponent({
noteableType,
- noteableData: { ...noteableDataMock, state: constants.CLOSED },
+ noteableData: { ...noteableDataMock, state: STATUS_CLOSED },
mountFunction: mount,
});
@@ -532,7 +533,7 @@ describe('issue_comment_form component', () => {
it(`shows an error when the API call fails`, async () => {
mountComponent({
noteableType,
- noteableData: { ...noteableDataMock, state: constants.CLOSED },
+ noteableData: { ...noteableDataMock, state: STATUS_CLOSED },
mountFunction: mount,
});
@@ -651,6 +652,37 @@ describe('issue_comment_form component', () => {
});
});
+ describe('check sensitive tokens', () => {
+ const sensitiveMessage = 'token: glpat-1234567890abcdefghij';
+ const nonSensitiveMessage = 'text';
+
+ it('should not save note when it contains sensitive token', () => {
+ mountComponent({
+ mountFunction: mount,
+ initialData: { note: sensitiveMessage },
+ });
+
+ jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
+
+ clickCommentButton();
+
+ expect(wrapper.vm.saveNote).not.toHaveBeenCalled();
+ });
+
+ it('should save note it does not contain sensitive token', () => {
+ mountComponent({
+ mountFunction: mount,
+ initialData: { note: nonSensitiveMessage },
+ });
+
+ jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
+
+ clickCommentButton();
+
+ expect(wrapper.vm.saveNote).toHaveBeenCalled();
+ });
+ });
+
describe('user is not logged in', () => {
beforeEach(() => {
mountComponent({ userData: null, noteableData: loggedOutnoteableData, mountFunction: mount });
@@ -661,7 +693,7 @@ describe('issue_comment_form component', () => {
});
it('should not render submission form', () => {
- expect(findTextArea().exists()).toBe(false);
+ expect(findMarkdownEditor().exists()).toBe(false);
});
});
diff --git a/spec/frontend/notes/components/comment_type_dropdown_spec.js b/spec/frontend/notes/components/comment_type_dropdown_spec.js
index cabf551deba..b891c1f553d 100644
--- a/spec/frontend/notes/components/comment_type_dropdown_spec.js
+++ b/spec/frontend/notes/components/comment_type_dropdown_spec.js
@@ -24,10 +24,6 @@ describe('CommentTypeDropdown component', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
isInternalNote | buttonText
${false} | ${COMMENT_FORM.comment}
diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js
index bb44563b87a..66b86ed3ce0 100644
--- a/spec/frontend/notes/components/diff_discussion_header_spec.js
+++ b/spec/frontend/notes/components/diff_discussion_header_spec.js
@@ -22,10 +22,6 @@ describe('diff_discussion_header component', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Avatar', () => {
const firstNoteAuthor = discussionMock.notes[0].author;
const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js
index e414ada1854..a9a20bd8bc3 100644
--- a/spec/frontend/notes/components/discussion_actions_spec.js
+++ b/spec/frontend/notes/components/discussion_actions_spec.js
@@ -38,15 +38,12 @@ describe('DiscussionActions', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('rendering', () => {
const createComponent = createComponentFactory();
it('renders reply placeholder, resolve discussion button, resolve with issue button and jump to next discussion button', () => {
createComponent();
+
expect(wrapper.findComponent(ReplyPlaceholder).exists()).toBe(true);
expect(wrapper.findComponent(ResolveDiscussionButton).exists()).toBe(true);
expect(wrapper.findComponent(ResolveWithIssueButton).exists()).toBe(true);
@@ -94,17 +91,15 @@ describe('DiscussionActions', () => {
it('emits showReplyForm event when clicking on reply placeholder', () => {
createComponent({}, { attachTo: document.body });
- jest.spyOn(wrapper.vm, '$emit');
wrapper.findComponent(ReplyPlaceholder).find('textarea').trigger('focus');
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('showReplyForm');
+ expect(wrapper.emitted().showReplyForm).toHaveLength(1);
});
it('emits resolve event when clicking on resolve button', () => {
createComponent();
- jest.spyOn(wrapper.vm, '$emit');
wrapper.findComponent(ResolveDiscussionButton).find('button').trigger('click');
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('resolve');
+ expect(wrapper.emitted().resolve).toHaveLength(1);
});
});
});
diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js
index f4ec7f835bb..ac677841ee1 100644
--- a/spec/frontend/notes/components/discussion_counter_spec.js
+++ b/spec/frontend/notes/components/discussion_counter_spec.js
@@ -40,7 +40,6 @@ describe('DiscussionCounter component', () => {
afterEach(() => {
wrapper.vm.$destroy();
- wrapper = null;
});
describe('has no discussions', () => {
@@ -119,8 +118,6 @@ describe('DiscussionCounter component', () => {
toggleAllButton = wrapper.find('[data-testid="toggle-all-discussions-btn"]');
};
- afterEach(() => wrapper.destroy());
-
it('calls button handler when clicked', async () => {
await updateStoreWithExpanded(true);
diff --git a/spec/frontend/notes/components/discussion_filter_note_spec.js b/spec/frontend/notes/components/discussion_filter_note_spec.js
index 48f5030aa1a..e31155a028f 100644
--- a/spec/frontend/notes/components/discussion_filter_note_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_note_spec.js
@@ -18,11 +18,6 @@ describe('DiscussionFilterNote component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('timelineContent renders a string containing instruction for switching feed type', () => {
expect(wrapper.find('[data-testid="discussion-filter-timeline-content"]').html()).toBe(
'<div data-testid="discussion-filter-timeline-content">You\'re only seeing <b>other activity</b> in the feed. To add a comment, switch to one of the following options.</div>',
diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js
index ed1ced1b3d1..7d8347b20d4 100644
--- a/spec/frontend/notes/components/discussion_filter_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import AxiosMockAdapter from 'axios-mock-adapter';
@@ -34,7 +34,8 @@ describe('DiscussionFilter component', () => {
const filterDiscussion = jest.fn();
const findFilter = (filterType) =>
- wrapper.find(`.dropdown-item[data-filter-type="${filterType}"]`);
+ wrapper.find(`.gl-new-dropdown-item[data-filter-type="${filterType}"]`);
+ const findGlDisclosureDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
@@ -77,17 +78,16 @@ describe('DiscussionFilter component', () => {
// as it doesn't matter for our tests here
mock.onGet(DISCUSSION_PATH).reply(HTTP_STATUS_OK, '');
window.mrTabs = undefined;
- wrapper = mountComponent();
jest.spyOn(Tracking, 'event');
});
afterEach(() => {
- wrapper.vm.$destroy();
mock.restore();
});
describe('default', () => {
beforeEach(() => {
+ wrapper = mountComponent();
jest.spyOn(store, 'dispatch').mockImplementation();
});
@@ -104,12 +104,13 @@ describe('DiscussionFilter component', () => {
describe('when asc', () => {
beforeEach(() => {
+ wrapper = mountComponent();
jest.spyOn(store, 'dispatch').mockImplementation();
});
describe('when the dropdown is clicked', () => {
it('calls the right actions', () => {
- wrapper.find('.js-newest-first').vm.$emit('click');
+ wrapper.find('.js-newest-first').vm.$emit('action');
expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', {
direction: DESC,
@@ -123,13 +124,14 @@ describe('DiscussionFilter component', () => {
describe('when desc', () => {
beforeEach(() => {
+ wrapper = mountComponent();
store.state.discussionSortOrder = DESC;
jest.spyOn(store, 'dispatch').mockImplementation();
});
describe('when the dropdown item is clicked', () => {
it('calls the right actions', () => {
- wrapper.find('.js-oldest-first').vm.$emit('click');
+ wrapper.find('.js-oldest-first').vm.$emit('action');
expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', {
direction: ASC,
@@ -139,62 +141,68 @@ describe('DiscussionFilter component', () => {
});
});
- it('sets is-checked to true on the active button in the dropdown', () => {
- expect(wrapper.find('.js-newest-first').props('isChecked')).toBe(true);
+ it('sets is-selected to true on the active button in the dropdown', () => {
+ expect(findGlDisclosureDropdownItem().attributes('is-selected')).toBe('true');
});
});
});
- it('renders the all filters', () => {
- expect(wrapper.findAll('.discussion-filter-container .dropdown-item').length).toBe(
- discussionFiltersMock.length,
- );
- });
+ describe('discussion filter functionality', () => {
+ beforeEach(() => {
+ wrapper = mountComponent();
+ });
- it('renders the default selected item', () => {
- expect(wrapper.find('.discussion-filter-container .dropdown-item').text().trim()).toBe(
- discussionFiltersMock[0].title,
- );
- });
+ it('renders the all filters', () => {
+ expect(wrapper.findAll('.discussion-filter-container .gl-new-dropdown-item').length).toBe(
+ discussionFiltersMock.length,
+ );
+ });
- it('disables the dropdown when discussions are loading', () => {
- store.state.isLoading = true;
+ it('renders the default selected item', () => {
+ expect(wrapper.find('.discussion-filter-container .gl-new-dropdown-item').text().trim()).toBe(
+ discussionFiltersMock[0].title,
+ );
+ });
- expect(wrapper.findComponent(GlDropdown).props('disabled')).toBe(true);
- });
+ it('disables the dropdown when discussions are loading', () => {
+ store.state.isLoading = true;
- it('updates to the selected item', () => {
- const filterItem = findFilter(DISCUSSION_FILTER_TYPES.ALL);
+ expect(wrapper.findComponent(GlDisclosureDropdown).props('disabled')).toBe(true);
+ });
- filterItem.trigger('click');
+ it('updates to the selected item', () => {
+ const filterItem = findFilter(DISCUSSION_FILTER_TYPES.ALL);
- expect(wrapper.vm.currentFilter.title).toBe(filterItem.text().trim());
- });
+ filterItem.vm.$emit('action');
- it('only updates when selected filter changes', () => {
- findFilter(DISCUSSION_FILTER_TYPES.ALL).trigger('click');
+ expect(filterItem.text().trim()).toBe('Show all activity');
+ });
- expect(filterDiscussion).not.toHaveBeenCalled();
- });
+ it('only updates when selected filter changes', () => {
+ findFilter(DISCUSSION_FILTER_TYPES.ALL).vm.$emit('action');
+
+ expect(filterDiscussion).not.toHaveBeenCalled();
+ });
- it('disables timeline view if it was enabled', () => {
- store.state.isTimelineEnabled = true;
+ it('disables timeline view if it was enabled', () => {
+ store.state.isTimelineEnabled = true;
- findFilter(DISCUSSION_FILTER_TYPES.HISTORY).trigger('click');
+ findFilter(DISCUSSION_FILTER_TYPES.HISTORY).vm.$emit('action');
- expect(wrapper.vm.$store.state.isTimelineEnabled).toBe(false);
- });
+ expect(store.state.isTimelineEnabled).toBe(false);
+ });
- it('disables commenting when "Show history only" filter is applied', () => {
- findFilter(DISCUSSION_FILTER_TYPES.HISTORY).trigger('click');
+ it('disables commenting when "Show history only" filter is applied', () => {
+ findFilter(DISCUSSION_FILTER_TYPES.HISTORY).vm.$emit('action');
- expect(wrapper.vm.$store.state.commentsDisabled).toBe(true);
- });
+ expect(store.state.commentsDisabled).toBe(true);
+ });
- it('enables commenting when "Show history only" filter is not applied', () => {
- findFilter(DISCUSSION_FILTER_TYPES.ALL).trigger('click');
+ it('enables commenting when "Show history only" filter is not applied', () => {
+ findFilter(DISCUSSION_FILTER_TYPES.ALL).vm.$emit('action');
- expect(wrapper.vm.$store.state.commentsDisabled).toBe(false);
+ expect(store.state.commentsDisabled).toBe(false);
+ });
});
describe('Merge request tabs', () => {
@@ -222,52 +230,41 @@ describe('DiscussionFilter component', () => {
});
describe('URL with Links to notes', () => {
+ const findGlDisclosureDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
+
afterEach(() => {
window.location.hash = '';
});
- it('updates the filter when the URL links to a note', async () => {
- window.location.hash = `note_${discussionMock.notes[0].id}`;
- wrapper.vm.currentValue = discussionFiltersMock[2].value;
- wrapper.vm.handleLocationHash();
-
- await nextTick();
- expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE);
- });
-
it('does not update the filter when the current filter is "Show all activity"', async () => {
window.location.hash = `note_${discussionMock.notes[0].id}`;
- wrapper.vm.handleLocationHash();
+ wrapper = mountComponent();
await nextTick();
- expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE);
+ const filtered = findGlDisclosureDropdownItems().filter((el) => el.classes('is-active'));
+
+ expect(filtered).toHaveLength(1);
+ expect(filtered.at(0).text()).toBe(discussionFiltersMock[0].title);
});
it('only updates filter when the URL links to a note', async () => {
window.location.hash = `testing123`;
- wrapper.vm.handleLocationHash();
+ wrapper = mountComponent();
await nextTick();
- expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE);
- });
+ const filtered = findGlDisclosureDropdownItems().filter((el) => el.classes('is-active'));
- it('fetches discussions when there is a hash', async () => {
- window.location.hash = `note_${discussionMock.notes[0].id}`;
- wrapper.vm.currentValue = discussionFiltersMock[2].value;
- jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {});
- wrapper.vm.handleLocationHash();
-
- await nextTick();
- expect(wrapper.vm.selectFilter).toHaveBeenCalled();
+ expect(filtered).toHaveLength(1);
+ expect(filtered.at(0).text()).toBe(discussionFiltersMock[0].title);
});
it('does not fetch discussions when there is no hash', async () => {
window.location.hash = '';
- jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {});
- wrapper.vm.handleLocationHash();
+ const selectFilterSpy = jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {});
+ wrapper = mountComponent();
await nextTick();
- expect(wrapper.vm.selectFilter).not.toHaveBeenCalled();
+ expect(selectFilterSpy).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/notes/components/discussion_navigator_spec.js b/spec/frontend/notes/components/discussion_navigator_spec.js
index 77ae7b2c3b5..885a7e2802e 100644
--- a/spec/frontend/notes/components/discussion_navigator_spec.js
+++ b/spec/frontend/notes/components/discussion_navigator_spec.js
@@ -1,5 +1,3 @@
-/* global Mousetrap */
-import 'mousetrap';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import {
@@ -7,6 +5,7 @@ import {
MR_NEXT_UNRESOLVED_DISCUSSION,
MR_PREVIOUS_UNRESOLVED_DISCUSSION,
} from '~/behaviors/shortcuts/keybindings';
+import { Mousetrap } from '~/lib/mousetrap';
import DiscussionNavigator from '~/notes/components/discussion_navigator.vue';
import eventHub from '~/notes/event_hub';
@@ -33,13 +32,6 @@ describe('notes/components/discussion_navigator', () => {
jumpToPreviousDiscussion = jest.fn();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- wrapper = null;
- });
-
describe('on create', () => {
let onSpy;
let vm;
diff --git a/spec/frontend/notes/components/discussion_notes_replies_wrapper_spec.js b/spec/frontend/notes/components/discussion_notes_replies_wrapper_spec.js
index 8d5ea108b50..d11ca7ad1ec 100644
--- a/spec/frontend/notes/components/discussion_notes_replies_wrapper_spec.js
+++ b/spec/frontend/notes/components/discussion_notes_replies_wrapper_spec.js
@@ -19,10 +19,6 @@ describe('DiscussionNotesRepliesWrapper', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when normal discussion', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js
index add2ed1ba8a..bc0c04f2d8a 100644
--- a/spec/frontend/notes/components/discussion_notes_spec.js
+++ b/spec/frontend/notes/components/discussion_notes_spec.js
@@ -53,11 +53,6 @@ describe('DiscussionNotes', () => {
store.dispatch('setNotesData', notesDataMock);
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('rendering', () => {
it('renders an element for each note in the discussion', () => {
createComponent();
diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
index 971e3987929..a9201b78669 100644
--- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
+++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
@@ -17,10 +17,6 @@ describe('ReplyPlaceholder', () => {
const findTextarea = () => wrapper.findComponent({ ref: 'textarea' });
- afterEach(() => {
- wrapper.destroy();
- });
-
it('emits focus event on button click', async () => {
createComponent({ options: { attachTo: document.body } });
diff --git a/spec/frontend/notes/components/discussion_resolve_button_spec.js b/spec/frontend/notes/components/discussion_resolve_button_spec.js
index 17c3523cf48..4bd21842fec 100644
--- a/spec/frontend/notes/components/discussion_resolve_button_spec.js
+++ b/spec/frontend/notes/components/discussion_resolve_button_spec.js
@@ -23,10 +23,6 @@ describe('resolveDiscussionButton', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should emit a onClick event on button click', async () => {
const button = wrapper.findComponent(GlButton);
diff --git a/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js
index a185f11ffaa..3dfae45ec49 100644
--- a/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js
+++ b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js
@@ -15,10 +15,6 @@ describe('ResolveWithIssueButton', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should have a link with the provided link property as href', () => {
const button = wrapper.findComponent(GlButton);
diff --git a/spec/frontend/notes/components/email_participants_warning_spec.js b/spec/frontend/notes/components/email_participants_warning_spec.js
index ab1a6b152a4..34b7524d8fb 100644
--- a/spec/frontend/notes/components/email_participants_warning_spec.js
+++ b/spec/frontend/notes/components/email_participants_warning_spec.js
@@ -4,11 +4,6 @@ import EmailParticipantsWarning from '~/notes/components/email_participants_warn
describe('Email Participants Warning Component', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findMoreButton = () => wrapper.find('button');
const createWrapper = (emails) => {
diff --git a/spec/frontend/notes/components/mr_discussion_filter_spec.js b/spec/frontend/notes/components/mr_discussion_filter_spec.js
new file mode 100644
index 00000000000..beb25c30af6
--- /dev/null
+++ b/spec/frontend/notes/components/mr_discussion_filter_spec.js
@@ -0,0 +1,110 @@
+import { mount } from '@vue/test-utils';
+import { GlCollapsibleListbox, GlListboxItem, GlButton } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
+import DiscussionFilter from '~/notes/components/mr_discussion_filter.vue';
+import { MR_FILTER_OPTIONS } from '~/notes/constants';
+
+Vue.use(Vuex);
+
+describe('Merge request discussion filter component', () => {
+ let wrapper;
+ let store;
+ let updateMergeRequestFilters;
+ let setDiscussionSortDirection;
+
+ function createComponent(mergeRequestFilters = MR_FILTER_OPTIONS.map((f) => f.value)) {
+ updateMergeRequestFilters = jest.fn();
+ setDiscussionSortDirection = jest.fn();
+
+ store = new Vuex.Store({
+ modules: {
+ notes: {
+ state: {
+ mergeRequestFilters,
+ discussionSortOrder: 'asc',
+ },
+ actions: {
+ updateMergeRequestFilters,
+ setDiscussionSortDirection,
+ },
+ },
+ },
+ });
+
+ wrapper = mount(DiscussionFilter, {
+ store,
+ });
+ }
+
+ afterEach(() => {
+ localStorage.removeItem('mr_activity_filters');
+ localStorage.removeItem('sort_direction_merge_request');
+ });
+
+ describe('local sync sort direction', () => {
+ it('calls setDiscussionSortDirection when mounted', () => {
+ localStorage.setItem('sort_direction_merge_request', 'desc');
+
+ createComponent();
+
+ expect(setDiscussionSortDirection).toHaveBeenCalledWith(expect.anything(), {
+ direction: 'desc',
+ });
+ });
+ });
+
+ describe('local sync sort filters', () => {
+ it('calls setDiscussionSortDirection when mounted', () => {
+ localStorage.setItem('mr_activity_filters', '["comments"]');
+
+ createComponent();
+
+ expect(updateMergeRequestFilters).toHaveBeenCalledWith(expect.anything(), ['comments']);
+ });
+ });
+
+ it('lists current filters', () => {
+ createComponent();
+
+ expect(wrapper.findAllComponents(GlListboxItem).length).toBe(MR_FILTER_OPTIONS.length);
+ });
+
+ it('updates store when selecting filter', async () => {
+ createComponent();
+
+ wrapper.findComponent(GlListboxItem).vm.$emit('select');
+
+ await nextTick();
+
+ wrapper.findComponent(GlCollapsibleListbox).vm.$emit('hidden');
+
+ expect(updateMergeRequestFilters).toHaveBeenCalledWith(expect.anything(), [
+ 'assignees_reviewers',
+ 'comments',
+ 'commit_branches',
+ 'edits',
+ 'labels',
+ 'lock_status',
+ 'mentions',
+ 'status',
+ 'tracking',
+ ]);
+ });
+
+ it.each`
+ state | expectedText
+ ${['status']} | ${'Merge request status'}
+ ${['status', 'comments']} | ${'Merge request status +1 more'}
+ ${[]} | ${'None'}
+ ${MR_FILTER_OPTIONS.map((f) => f.value)} | ${'All activity'}
+ `('updates toggle text to $expectedText with $state', async ({ state, expectedText }) => {
+ createComponent();
+
+ store.state.notes.mergeRequestFilters = state;
+
+ await nextTick();
+
+ expect(wrapper.findComponent(GlButton).text()).toBe(expectedText);
+ });
+});
diff --git a/spec/frontend/notes/components/note_actions/reply_button_spec.js b/spec/frontend/notes/components/note_actions/reply_button_spec.js
index 20b32b8c178..68b11fb3b1a 100644
--- a/spec/frontend/notes/components/note_actions/reply_button_spec.js
+++ b/spec/frontend/notes/components/note_actions/reply_button_spec.js
@@ -9,11 +9,6 @@ describe('ReplyButton', () => {
wrapper = shallowMount(ReplyButton);
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('emits startReplying on click', () => {
wrapper.findComponent(GlButton).vm.$emit('click');
diff --git a/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js b/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js
index 658e844a9b1..7860e9d45da 100644
--- a/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js
+++ b/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js
@@ -20,13 +20,9 @@ describe('NoteTimelineEventButton', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findTimelineButton = () => wrapper.findComponent(GlButton);
- it('emits click-promote-comment-to-event', async () => {
+ it('emits click-promote-comment-to-event', () => {
findTimelineButton().vm.$emit('click');
expect(wrapper.emitted('click-promote-comment-to-event')).toEqual([[emitData]]);
diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js
index 8630b7b7d07..879bada4aee 100644
--- a/spec/frontend/notes/components/note_actions_spec.js
+++ b/spec/frontend/notes/components/note_actions_spec.js
@@ -1,9 +1,10 @@
-import { mount, createWrapper } from '@vue/test-utils';
+import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
+import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import noteActions from '~/notes/components/note_actions.vue';
import { NOTEABLE_TYPE_MAPPING } from '~/notes/constants';
import TimelineEventButton from '~/notes/components/note_actions/timeline_event_button.vue';
@@ -19,6 +20,8 @@ describe('noteActions', () => {
let actions;
let axiosMock;
+ const mockCloseDropdown = jest.fn();
+
const findUserAccessRoleBadge = (idx) => wrapper.findAllComponents(UserAccessRoleBadge).at(idx);
const findUserAccessRoleBadgeText = (idx) => findUserAccessRoleBadge(idx).text().trim();
const findTimelineButton = () => wrapper.findComponent(TimelineEventButton);
@@ -45,6 +48,14 @@ describe('noteActions', () => {
store,
propsData,
computed,
+ stubs: {
+ GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
+ methods: {
+ close: mockCloseDropdown,
+ },
+ }),
+ GlDisclosureDropdownItem,
+ },
});
};
@@ -77,7 +88,6 @@ describe('noteActions', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
});
@@ -145,17 +155,6 @@ describe('noteActions', () => {
expect(wrapper.find('.js-note-delete').exists()).toBe(true);
});
- it('closes tooltip when dropdown opens', async () => {
- wrapper.find('.more-actions-toggle').trigger('click');
-
- const rootWrapper = createWrapper(wrapper.vm.$root);
-
- await nextTick();
- const emitted = Object.keys(rootWrapper.emitted());
-
- expect(emitted).toEqual([BV_HIDE_TOOLTIP]);
- });
-
it('should not be possible to assign or unassign the comment author in a merge request', () => {
const assignUserButton = wrapper.find('[data-testid="assign-user"]');
expect(assignUserButton.exists()).toBe(false);
@@ -176,6 +175,11 @@ describe('noteActions', () => {
const { resolveButton } = wrapper.vm.$refs;
expect(resolveButton.$el.getAttribute('title')).toBe(`Resolved by ${complexUnescapedName}`);
});
+
+ it('closes the dropdown', () => {
+ findReportAbuseButton().vm.$emit('action');
+ expect(mockCloseDropdown).toHaveBeenCalled();
+ });
});
});
@@ -203,7 +207,6 @@ describe('noteActions', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
});
@@ -226,7 +229,6 @@ describe('noteActions', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
});
@@ -248,10 +250,6 @@ describe('noteActions', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should not be possible to assign the comment author', testButtonDoesNotRender);
it('should not be possible to unassign the comment author', testButtonDoesNotRender);
});
@@ -408,13 +406,13 @@ describe('noteActions', () => {
});
it('opens the drawer when report abuse button is clicked', async () => {
- await findReportAbuseButton().trigger('click');
+ await findReportAbuseButton().vm.$emit('action');
expect(findAbuseCategorySelector().props('showDrawer')).toEqual(true);
});
it('closes the drawer', async () => {
- await findReportAbuseButton().trigger('click');
+ await findReportAbuseButton().vm.$emit('action');
findAbuseCategorySelector().vm.$emit('close-drawer');
await nextTick();
diff --git a/spec/frontend/notes/components/note_attachment_spec.js b/spec/frontend/notes/components/note_attachment_spec.js
index 24632f8e427..7f44171f6cc 100644
--- a/spec/frontend/notes/components/note_attachment_spec.js
+++ b/spec/frontend/notes/components/note_attachment_spec.js
@@ -15,11 +15,6 @@ describe('Issue note attachment', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders attachment image if it is passed in attachment prop', () => {
createComponent({
image: 'test-image',
diff --git a/spec/frontend/notes/components/note_awards_list_spec.js b/spec/frontend/notes/components/note_awards_list_spec.js
index 89ac0216f41..0107b27f980 100644
--- a/spec/frontend/notes/components/note_awards_list_spec.js
+++ b/spec/frontend/notes/components/note_awards_list_spec.js
@@ -1,76 +1,110 @@
import AxiosMockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
+import Vuex from 'vuex';
import { TEST_HOST } from 'helpers/test_constants';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { userDataMock } from 'jest/notes/mock_data';
+import EmojiPicker from '~/emoji/components/picker.vue';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import awardsNote from '~/notes/components/note_awards_list.vue';
import createStore from '~/notes/stores';
-import { noteableDataMock, notesDataMock } from '../mock_data';
-describe('note_awards_list component', () => {
- let store;
- let vm;
- let awardsMock;
- let mock;
-
- const toggleAwardPath = `${TEST_HOST}/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji`;
-
- beforeEach(() => {
- mock = new AxiosMockAdapter(axios);
-
- mock.onPost(toggleAwardPath).reply(HTTP_STATUS_OK, '');
+Vue.use(Vuex);
- const Component = Vue.extend(awardsNote);
-
- store = createStore();
- store.dispatch('setNoteableData', noteableDataMock);
- store.dispatch('setNotesData', notesDataMock);
- awardsMock = [
- {
- name: 'flag_tz',
- user: { id: 1, name: 'Administrator', username: 'root' },
- },
- {
- name: 'cartwheel_tone3',
- user: { id: 12, name: 'Bobbie Stehr', username: 'erin' },
- },
- ];
+describe('Note Awards List', () => {
+ let wrapper;
+ let mock;
- vm = new Component({
+ const awardsMock = [
+ {
+ name: 'flag_tz',
+ user: { id: 1, name: 'Administrator', username: 'root' },
+ },
+ {
+ name: 'cartwheel_tone3',
+ user: { id: 12, name: 'Bobbie Stehr', username: 'erin' },
+ },
+ ];
+ const toggleAwardPathMock = `${TEST_HOST}/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji`;
+
+ const defaultProps = {
+ awards: awardsMock,
+ noteAuthorId: 2,
+ noteId: '545',
+ canAwardEmoji: false,
+ toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji',
+ };
+
+ const findAddAward = () => wrapper.find('.js-add-award');
+ const findAwardButton = () => wrapper.findByTestId('award-button');
+ const findAllEmojiAwards = () => wrapper.findAll('gl-emoji');
+ const findEmojiPicker = () => wrapper.findComponent(EmojiPicker);
+
+ const createComponent = (props = defaultProps, store = createStore()) => {
+ wrapper = mountExtended(awardsNote, {
store,
propsData: {
- awards: awardsMock,
- noteAuthorId: 2,
- noteId: '545',
- canAwardEmoji: true,
- toggleAwardPath,
+ ...props,
},
- }).$mount();
- });
+ });
+ };
+
+ describe('Note Awards functionality', () => {
+ const toggleAwardRequestSpy = jest.fn();
+ const fakeStore = () => {
+ return new Vuex.Store({
+ getters: {
+ getUserData: () => userDataMock,
+ },
+ actions: {
+ toggleAwardRequest: toggleAwardRequestSpy,
+ },
+ });
+ };
- afterEach(() => {
- mock.restore();
- vm.$destroy();
- });
+ beforeEach(() => {
+ mock = new AxiosMockAdapter(axios);
+ mock.onPost(toggleAwardPathMock).reply(HTTP_STATUS_OK, '');
- it('should render awarded emojis', () => {
- expect(vm.$el.querySelector('.js-awards-block button [data-name="flag_tz"]')).toBeDefined();
- expect(
- vm.$el.querySelector('.js-awards-block button [data-name="cartwheel_tone3"]'),
- ).toBeDefined();
- });
+ createComponent(
+ {
+ awards: awardsMock,
+ noteAuthorId: 2,
+ noteId: '545',
+ canAwardEmoji: true,
+ toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji',
+ },
+ fakeStore(),
+ );
+ });
- it('should be possible to remove awarded emoji', () => {
- jest.spyOn(vm, 'handleAward');
- jest.spyOn(vm, 'toggleAwardRequest');
- vm.$el.querySelector('.js-awards-block button').click();
+ afterEach(() => {
+ mock.restore();
+ });
- expect(vm.handleAward).toHaveBeenCalledWith('flag_tz');
- expect(vm.toggleAwardRequest).toHaveBeenCalled();
- });
+ it('should render awarded emojis', () => {
+ const emojiAwards = findAllEmojiAwards();
+
+ expect(emojiAwards).toHaveLength(awardsMock.length);
+ expect(emojiAwards.at(0).attributes('data-name')).toBe('flag_tz');
+ expect(emojiAwards.at(1).attributes('data-name')).toBe('cartwheel_tone3');
+ });
+
+ it('should be possible to add new emoji', () => {
+ expect(findEmojiPicker().exists()).toBe(true);
+ });
+
+ it('should be possible to remove awarded emoji', async () => {
+ await findAwardButton().vm.$emit('click');
- it('should be possible to add new emoji', () => {
- expect(vm.$el.querySelector('.js-add-award')).toBeDefined();
+ const { toggleAwardPath, noteId } = defaultProps;
+ expect(toggleAwardRequestSpy).toHaveBeenCalledWith(expect.anything(), {
+ awardName: awardsMock[0].name,
+ endpoint: toggleAwardPath,
+ noteId,
+ });
+ });
});
describe('when the user name contains special HTML characters', () => {
@@ -79,85 +113,69 @@ describe('note_awards_list component', () => {
user: { id: index, name: `&<>"\`'-${index}`, username: `user-${index}` },
});
- const mountComponent = () => {
- const Component = Vue.extend(awardsNote);
- vm = new Component({
- store,
- propsData: {
- awards: awardsMock,
- noteAuthorId: 0,
- noteId: '545',
- canAwardEmoji: true,
- toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji',
- },
- }).$mount();
+ const customProps = {
+ awards: awardsMock,
+ noteAuthorId: 0,
+ noteId: '545',
+ canAwardEmoji: true,
+ toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji',
};
- const findTooltip = () => vm.$el.querySelector('[title]').getAttribute('title');
-
- it('should only escape & and " characters', () => {
- awardsMock = [...new Array(1)].map(createAwardEmoji);
- mountComponent();
- const escapedName = awardsMock[0].user.name.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
-
- expect(vm.$el.querySelector('[title]').outerHTML).toContain(escapedName);
- });
-
it('should not escape special HTML characters twice when only 1 person awarded', () => {
- awardsMock = [...new Array(1)].map(createAwardEmoji);
- mountComponent();
+ const awardsCopy = [...new Array(1)].map(createAwardEmoji);
+ createComponent({
+ ...customProps,
+ awards: awardsCopy,
+ });
- awardsMock.forEach((award) => {
- expect(findTooltip()).toContain(award.user.name);
+ awardsCopy.forEach((award) => {
+ expect(findAwardButton().attributes('title')).toContain(award.user.name);
});
});
it('should not escape special HTML characters twice when 2 people awarded', () => {
- awardsMock = [...new Array(2)].map(createAwardEmoji);
- mountComponent();
+ const awardsCopy = [...new Array(2)].map(createAwardEmoji);
+ createComponent({
+ ...customProps,
+ awards: awardsCopy,
+ });
- awardsMock.forEach((award) => {
- expect(findTooltip()).toContain(award.user.name);
+ awardsCopy.forEach((award) => {
+ expect(findAwardButton().attributes('title')).toContain(award.user.name);
});
});
it('should not escape special HTML characters twice when more than 10 people awarded', () => {
- awardsMock = [...new Array(11)].map(createAwardEmoji);
- mountComponent();
+ const awardsCopy = [...new Array(11)].map(createAwardEmoji);
+ createComponent({
+ ...customProps,
+ awards: awardsCopy,
+ });
// Testing only the first 10 awards since 11 onward will not be displayed.
- awardsMock.slice(0, 10).forEach((award) => {
- expect(findTooltip()).toContain(award.user.name);
+ awardsCopy.slice(0, 10).forEach((award) => {
+ expect(findAwardButton().attributes('title')).toContain(award.user.name);
});
});
});
- describe('when the user cannot award emoji', () => {
+ describe('when the user cannot award an emoji', () => {
beforeEach(() => {
- const Component = Vue.extend(awardsNote);
-
- vm = new Component({
- store,
- propsData: {
- awards: awardsMock,
- noteAuthorId: 2,
- noteId: '545',
- canAwardEmoji: false,
- toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji',
- },
- }).$mount();
+ createComponent({
+ awards: awardsMock,
+ noteAuthorId: 2,
+ noteId: '545',
+ canAwardEmoji: false,
+ toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji',
+ });
});
- it('should not be possible to remove awarded emoji', () => {
- jest.spyOn(vm, 'toggleAwardRequest');
-
- vm.$el.querySelector('.js-awards-block button').click();
-
- expect(vm.toggleAwardRequest).not.toHaveBeenCalled();
+ it('should display an award emoji button with a disabled class', () => {
+ expect(findAwardButton().classes()).toContain('disabled');
});
it('should not be possible to add new emoji', () => {
- expect(vm.$el.querySelector('.js-add-award')).toBeNull();
+ expect(findAddAward().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js
index c71cf7666ab..c4f8e50b969 100644
--- a/spec/frontend/notes/components/note_body_spec.js
+++ b/spec/frontend/notes/components/note_body_spec.js
@@ -7,10 +7,7 @@ import NoteAwardsList from '~/notes/components/note_awards_list.vue';
import NoteForm from '~/notes/components/note_form.vue';
import createStore from '~/notes/stores';
import notes from '~/notes/stores/modules/index';
-import Autosave from '~/autosave';
-
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
-
import { noteableDataMock, notesDataMock, note } from '../mock_data';
jest.mock('~/autosave');
@@ -49,10 +46,6 @@ describe('issue_note_body component', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render the note', () => {
expect(wrapper.find('.note-text').html()).toContain(note.note_html);
});
@@ -86,11 +79,6 @@ describe('issue_note_body component', () => {
expect(wrapper.findComponent(NoteForm).props('saveButtonTitle')).toBe(buttonText);
});
- it('adds autosave', () => {
- // passing undefined instead of an element because of shallowMount
- expect(Autosave).toHaveBeenCalledWith(undefined, ['Note', note.noteable_type, note.id]);
- });
-
describe('isInternalNote', () => {
beforeEach(() => {
wrapper.setProps({ isInternalNote: true });
diff --git a/spec/frontend/notes/components/note_edited_text_spec.js b/spec/frontend/notes/components/note_edited_text_spec.js
index 0a5fe48ef94..577e1044588 100644
--- a/spec/frontend/notes/components/note_edited_text_spec.js
+++ b/spec/frontend/notes/components/note_edited_text_spec.js
@@ -1,3 +1,4 @@
+import { GlSprintf, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import NoteEditedText from '~/notes/components/note_edited_text.vue';
@@ -5,41 +6,63 @@ const propsData = {
actionText: 'Edited',
className: 'foo-bar',
editedAt: '2017-08-04T09:52:31.062Z',
- editedBy: {
- avatar_url: 'path',
- id: 1,
- name: 'Root',
- path: '/root',
- state: 'active',
- username: 'root',
- },
+ editedBy: null,
};
describe('NoteEditedText', () => {
let wrapper;
- beforeEach(() => {
+ const createWrapper = (props = {}) => {
wrapper = shallowMount(NoteEditedText, {
- propsData,
+ propsData: {
+ ...propsData,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
});
- });
+ };
- afterEach(() => {
- wrapper.destroy();
- });
+ const findUserElement = () => wrapper.findComponent(GlLink);
- it('should render block with provided className', () => {
- expect(wrapper.classes()).toContain(propsData.className);
- });
+ describe('default', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
- it('should render provided actionText', () => {
- expect(wrapper.text().trim()).toContain(propsData.actionText);
+ it('should render block with provided className', () => {
+ expect(wrapper.classes()).toContain(propsData.className);
+ });
+
+ it('should render provided actionText', () => {
+ expect(wrapper.text().trim()).toContain(propsData.actionText);
+ });
+
+ it('should not render user information', () => {
+ expect(findUserElement().exists()).toBe(false);
+ });
});
- it('should render provided user information', () => {
- const authorLink = wrapper.find('.js-user-link');
+ describe('edited note', () => {
+ const editedBy = {
+ avatar_url: 'path',
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+ };
+
+ beforeEach(() => {
+ createWrapper({ editedBy });
+ });
+
+ it('should render user information', () => {
+ const authorLink = findUserElement();
- expect(authorLink.attributes('href')).toEqual(propsData.editedBy.path);
- expect(authorLink.text().trim()).toEqual(propsData.editedBy.name);
+ expect(authorLink.attributes('href')).toEqual(editedBy.path);
+ expect(authorLink.text().trim()).toEqual(editedBy.name);
+ });
});
});
diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js
index 90473e7ccba..b5b33607282 100644
--- a/spec/frontend/notes/components/note_form_spec.js
+++ b/spec/frontend/notes/components/note_form_spec.js
@@ -1,42 +1,39 @@
-import { GlLink } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { GlLink, GlFormCheckbox } from '@gitlab/ui';
import { nextTick } from 'vue';
import batchComments from '~/batch_comments/stores/modules/batch_comments';
-import { getDraft, updateDraft } from '~/lib/utils/autosave';
import NoteForm from '~/notes/components/note_form.vue';
import createStore from '~/notes/stores';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { AT_WHO_ACTIVE_CLASS } from '~/gfm_auto_complete';
+import eventHub from '~/environments/event_hub';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { noteableDataMock, notesDataMock, discussionMock, note } from '../mock_data';
jest.mock('~/lib/utils/autosave');
describe('issue_note_form component', () => {
- const dummyAutosaveKey = 'some-autosave-key';
- const dummyDraft = 'dummy draft content';
-
let store;
let wrapper;
let props;
- const createComponentWrapper = () => {
- return mount(NoteForm, {
+ const createComponentWrapper = (propsData = {}, provide = {}) => {
+ wrapper = mountExtended(NoteForm, {
store,
- propsData: props,
+ propsData: {
+ ...props,
+ ...propsData,
+ },
+ provide: {
+ glFeatures: provide,
+ },
});
};
- const findCancelButton = () => wrapper.find('[data-testid="cancel"]');
+ const findCancelButton = () => wrapper.findByTestId('cancel');
+ const findCancelCommentButton = () => wrapper.findByTestId('cancelBatchCommentsEnabled');
+ const findMarkdownField = () => wrapper.findComponent(MarkdownField);
beforeEach(() => {
- getDraft.mockImplementation((key) => {
- if (key === dummyAutosaveKey) {
- return dummyDraft;
- }
-
- return null;
- });
-
store = createStore();
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
@@ -48,33 +45,39 @@ describe('issue_note_form component', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('noteHash', () => {
beforeEach(() => {
- wrapper = createComponentWrapper();
+ createComponentWrapper();
});
it('returns note hash string based on `noteId`', () => {
expect(wrapper.vm.noteHash).toBe(`#note_${props.noteId}`);
});
- it('return note hash as `#` when `noteId` is empty', async () => {
- wrapper.setProps({
- ...props,
+ it('return note hash as `#` when `noteId` is empty', () => {
+ createComponentWrapper({
noteId: '',
});
- await nextTick();
expect(wrapper.vm.noteHash).toBe('#');
});
});
+ it('hides content editor switcher if feature flag content_editor_on_issues is off', () => {
+ createComponentWrapper({}, { contentEditorOnIssues: false });
+
+ expect(wrapper.text()).not.toContain('Switch to rich text');
+ });
+
+ it('shows content editor switcher if feature flag content_editor_on_issues is on', () => {
+ createComponentWrapper({}, { contentEditorOnIssues: true });
+
+ expect(wrapper.text()).toContain('Switch to rich text');
+ });
+
describe('conflicts editing', () => {
beforeEach(() => {
- wrapper = createComponentWrapper();
+ createComponentWrapper();
});
it('should show conflict message if note changes outside the component', async () => {
@@ -98,15 +101,13 @@ describe('issue_note_form component', () => {
describe('form', () => {
beforeEach(() => {
- wrapper = createComponentWrapper();
+ createComponentWrapper();
});
it('should render text area with placeholder', () => {
const textarea = wrapper.find('textarea');
- expect(textarea.attributes('placeholder')).toEqual(
- 'Write a comment or drag your files here…',
- );
+ expect(textarea.attributes('placeholder')).toBe('Write a comment or drag your files here…');
});
it('should set data-supports-quick-actions to enable autocomplete', () => {
@@ -121,23 +122,21 @@ describe('issue_note_form component', () => {
${true} | ${'Write an internal note or drag your files here…'}
`(
'should set correct textarea placeholder text when discussion confidentiality is $internal',
- ({ internal, placeholder }) => {
+ async ({ internal, placeholder }) => {
props.note = {
...note,
internal,
};
- wrapper = createComponentWrapper();
+ createComponentWrapper();
+
+ await nextTick();
expect(wrapper.find('textarea').attributes('placeholder')).toBe(placeholder);
},
);
it('should link to markdown docs', () => {
- const { markdownDocsPath } = notesDataMock;
- const markdownField = wrapper.findComponent(MarkdownField);
- const markdownFieldProps = markdownField.props();
-
- expect(markdownFieldProps.markdownDocsPath).toBe(markdownDocsPath);
+ expect(findMarkdownField().props('markdownDocsPath')).toBe(notesDataMock.markdownDocsPath);
});
describe('keyboard events', () => {
@@ -150,12 +149,11 @@ describe('issue_note_form component', () => {
describe('up', () => {
it('should ender edit mode', () => {
- // TODO: do not spy on vm
- jest.spyOn(wrapper.vm, 'editMyLastNote');
+ const eventHubSpy = jest.spyOn(eventHub, '$emit');
textarea.trigger('keydown.up');
- expect(wrapper.vm.editMyLastNote).toHaveBeenCalled();
+ expect(eventHubSpy).not.toHaveBeenCalled();
});
});
@@ -163,17 +161,13 @@ describe('issue_note_form component', () => {
it('should save note when cmd+enter is pressed', () => {
textarea.trigger('keydown.enter', { metaKey: true });
- const { handleFormUpdate } = wrapper.emitted();
-
- expect(handleFormUpdate.length).toBe(1);
+ expect(wrapper.emitted('handleFormUpdate')).toHaveLength(1);
});
it('should save note when ctrl+enter is pressed', () => {
textarea.trigger('keydown.enter', { ctrlKey: true });
- const { handleFormUpdate } = wrapper.emitted();
-
- expect(handleFormUpdate.length).toBe(1);
+ expect(wrapper.emitted('handleFormUpdate')).toHaveLength(1);
});
it('should disable textarea when ctrl+enter is pressed', async () => {
@@ -183,157 +177,68 @@ describe('issue_note_form component', () => {
await nextTick();
- expect(textarea.attributes('disabled')).toBe('disabled');
+ expect(textarea.attributes('disabled')).toBeDefined();
});
});
});
describe('actions', () => {
- it('should be possible to cancel', async () => {
- wrapper.setProps({
- ...props,
- });
- await nextTick();
+ it('should be possible to cancel', () => {
+ createComponentWrapper();
- const cancelButton = findCancelButton();
- cancelButton.vm.$emit('click');
- await nextTick();
+ findCancelButton().vm.$emit('click');
- expect(wrapper.emitted().cancelForm).toHaveLength(1);
+ expect(wrapper.emitted('cancelForm')).toHaveLength(1);
});
it('will not cancel form if there is an active at-who-active class', async () => {
- wrapper.setProps({
- ...props,
- });
- await nextTick();
+ createComponentWrapper();
- const textareaEl = wrapper.vm.$refs.textarea;
+ const textareaEl = wrapper.vm.$refs.markdownEditor.$el.querySelector('textarea');
const cancelButton = findCancelButton();
textareaEl.classList.add(AT_WHO_ACTIVE_CLASS);
cancelButton.vm.$emit('click');
await nextTick();
- expect(wrapper.emitted().cancelForm).toBeUndefined();
+ expect(wrapper.emitted('cancelForm')).toBeUndefined();
});
- it('should be possible to update the note', async () => {
- wrapper.setProps({
- ...props,
- });
- await nextTick();
+ it('should be possible to update the note', () => {
+ createComponentWrapper();
const textarea = wrapper.find('textarea');
textarea.setValue('Foo');
const saveButton = wrapper.find('.js-vue-issue-save');
saveButton.vm.$emit('click');
- expect(wrapper.vm.isSubmitting).toBe(true);
+ expect(wrapper.emitted('handleFormUpdate')).toHaveLength(1);
});
});
});
- describe('with autosaveKey', () => {
- describe('with draft', () => {
- beforeEach(() => {
- Object.assign(props, {
- noteBody: '',
- autosaveKey: dummyAutosaveKey,
- });
- wrapper = createComponentWrapper();
-
- return nextTick();
- });
-
- it('displays the draft in textarea', () => {
- const textarea = wrapper.find('textarea');
-
- expect(textarea.element.value).toBe(dummyDraft);
- });
- });
-
- describe('without draft', () => {
- beforeEach(() => {
- Object.assign(props, {
- noteBody: '',
- autosaveKey: 'some key without draft',
- });
- wrapper = createComponentWrapper();
-
- return nextTick();
- });
-
- it('leaves the textarea empty', () => {
- const textarea = wrapper.find('textarea');
-
- expect(textarea.element.value).toBe('');
- });
- });
-
- it('updates the draft if textarea content changes', () => {
- Object.assign(props, {
- noteBody: '',
- autosaveKey: dummyAutosaveKey,
- });
- wrapper = createComponentWrapper();
- const textarea = wrapper.find('textarea');
- const dummyContent = 'some new content';
-
- textarea.setValue(dummyContent);
-
- expect(updateDraft).toHaveBeenCalledWith(dummyAutosaveKey, dummyContent);
- });
-
- it('does not save draft when ctrl+enter is pressed', () => {
- const options = {
- noteBody: '',
- autosaveKey: dummyAutosaveKey,
- };
-
- props = { ...props, ...options };
- wrapper = createComponentWrapper();
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isSubmittingWithKeydown: true });
-
- const textarea = wrapper.find('textarea');
- textarea.setValue('some content');
- textarea.trigger('keydown.enter', { metaKey: true });
-
- expect(updateDraft).not.toHaveBeenCalled();
- });
- });
-
describe('with batch comments', () => {
beforeEach(() => {
store.registerModule('batchComments', batchComments());
- wrapper = createComponentWrapper();
- wrapper.setProps({
- ...props,
+ createComponentWrapper({
isDraft: true,
noteId: '',
discussion: { ...discussionMock, for_commit: false },
});
});
- it('should be possible to cancel', async () => {
- jest.spyOn(wrapper.vm, 'cancelHandler');
+ it('should be possible to cancel', () => {
+ findCancelCommentButton().vm.$emit('click');
- await nextTick();
- const cancelButton = wrapper.find('[data-testid="cancelBatchCommentsEnabled"]');
- cancelButton.vm.$emit('click');
-
- expect(wrapper.vm.cancelHandler).toHaveBeenCalledWith(true);
+ expect(wrapper.emitted('cancelForm')).toEqual([[true, false]]);
});
it('shows resolve checkbox', () => {
- expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(true);
+ expect(wrapper.findComponent(GlFormCheckbox).exists()).toBe(true);
});
- it('hides resolve checkbox', async () => {
- wrapper.setProps({
+ it('hides resolve checkbox', () => {
+ createComponentWrapper({
isDraft: false,
discussion: {
...discussionMock,
@@ -348,15 +253,11 @@ describe('issue_note_form component', () => {
},
});
- await nextTick();
-
- expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(false);
+ expect(wrapper.findComponent(GlFormCheckbox).exists()).toBe(false);
});
- it('hides actions for commits', async () => {
- wrapper.setProps({ discussion: { for_commit: true } });
-
- await nextTick();
+ it('hides actions for commits', () => {
+ createComponentWrapper({ discussion: { for_commit: true } });
expect(wrapper.find('.note-form-actions').text()).not.toContain('Start a review');
});
@@ -365,13 +266,12 @@ describe('issue_note_form component', () => {
it('should start review or add to review when cmd+enter is pressed', async () => {
const textarea = wrapper.find('textarea');
- jest.spyOn(wrapper.vm, 'handleAddToReview');
-
textarea.setValue('Foo');
textarea.trigger('keydown.enter', { metaKey: true });
await nextTick();
- expect(wrapper.vm.handleAddToReview).toHaveBeenCalled();
+
+ expect(wrapper.emitted('handleFormUpdateAddToReview')).toEqual([['Foo', false]]);
});
});
});
diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js
index 56c22b09e1b..60ad9e3344a 100644
--- a/spec/frontend/notes/components/note_header_spec.js
+++ b/spec/frontend/notes/components/note_header_spec.js
@@ -19,7 +19,9 @@ describe('NoteHeader component', () => {
const findTimestampLink = () => wrapper.findComponent({ ref: 'noteTimestampLink' });
const findTimestamp = () => wrapper.findComponent({ ref: 'noteTimestamp' });
const findInternalNoteIndicator = () => wrapper.findByTestId('internal-note-indicator');
+ const findAuthorName = () => wrapper.findByTestId('author-name');
const findSpinner = () => wrapper.findComponent({ ref: 'spinner' });
+ const authorUsernameLink = () => wrapper.findComponent({ ref: 'authorUsernameLink' });
const statusHtml =
'"<span class="user-status-emoji has-tooltip" title="foo bar" data-html="true" data-placement="top"><gl-emoji title="basketball and hoop" data-name="basketball" data-unicode-version="6.0">🏀</gl-emoji></span>"';
@@ -35,6 +37,17 @@ describe('NoteHeader component', () => {
status_tooltip_html: statusHtml,
};
+ const supportBotAuthor = {
+ avatar_url: null,
+ id: 1,
+ name: 'Gitlab Support Bot',
+ path: '/support-bot',
+ state: 'active',
+ username: 'support-bot',
+ show_status: true,
+ status_tooltip_html: statusHtml,
+ };
+
const createComponent = (props) => {
wrapper = shallowMountExtended(NoteHeader, {
store: new Vuex.Store({
@@ -44,11 +57,6 @@ describe('NoteHeader component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('does not render discussion actions when includeToggle is false', () => {
createComponent({
includeToggle: false,
@@ -119,6 +127,16 @@ describe('NoteHeader component', () => {
expect(wrapper.text()).toContain('A deleted user');
});
+ it('renders participant email when author is a support-bot', () => {
+ createComponent({
+ author: supportBotAuthor,
+ emailParticipant: 'email@example.com',
+ });
+
+ expect(findAuthorName().text()).toBe('email@example.com');
+ expect(authorUsernameLink().exists()).toBe(false);
+ });
+
it('does not render created at information if createdAt is not passed as a prop', () => {
createComponent();
@@ -209,16 +227,15 @@ describe('NoteHeader component', () => {
it('toggles hover specific CSS classes on author name link', async () => {
createComponent({ author });
- const authorUsernameLink = wrapper.findComponent({ ref: 'authorUsernameLink' });
const authorNameLink = wrapper.findComponent({ ref: 'authorNameLink' });
- authorUsernameLink.trigger('mouseenter');
+ authorUsernameLink().trigger('mouseenter');
await nextTick();
expect(authorNameLink.classes()).toContain('hover');
expect(authorNameLink.classes()).toContain('text-underline');
- authorUsernameLink.trigger('mouseleave');
+ authorUsernameLink().trigger('mouseleave');
await nextTick();
expect(authorNameLink.classes()).not.toContain('hover');
diff --git a/spec/frontend/notes/components/note_signed_out_widget_spec.js b/spec/frontend/notes/components/note_signed_out_widget_spec.js
index 84f20e4ad58..d56ee234cd9 100644
--- a/spec/frontend/notes/components/note_signed_out_widget_spec.js
+++ b/spec/frontend/notes/components/note_signed_out_widget_spec.js
@@ -12,10 +12,6 @@ describe('NoteSignedOutWidget component', () => {
wrapper = shallowMount(NoteSignedOutWidget, { store });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders sign in link provided in the store', () => {
expect(wrapper.find(`a[href="${notesDataMock.newSessionPath}"]`).text()).toBe('sign in');
});
diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js
index a90d8bdde06..ac0c037fe36 100644
--- a/spec/frontend/notes/components/noteable_discussion_spec.js
+++ b/spec/frontend/notes/components/noteable_discussion_spec.js
@@ -22,7 +22,6 @@ jest.mock('~/behaviors/markdown/render_gfm');
describe('noteable_discussion component', () => {
let store;
let wrapper;
- let originalGon;
beforeEach(() => {
window.mrTabs = {};
@@ -36,10 +35,6 @@ describe('noteable_discussion component', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should not render thread header for non diff threads', () => {
expect(wrapper.find('.discussion-header').exists()).toBe(false);
});
@@ -167,16 +162,6 @@ describe('noteable_discussion component', () => {
});
describe('signout widget', () => {
- beforeEach(() => {
- originalGon = { ...window.gon };
- window.gon = window.gon || {};
- });
-
- afterEach(() => {
- wrapper.destroy();
- window.gon = originalGon;
- });
-
describe('user is logged in', () => {
beforeEach(() => {
window.gon.current_user_id = userDataMock.id;
diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js
index af1b4f64037..5d81a7a9a0f 100644
--- a/spec/frontend/notes/components/noteable_note_spec.js
+++ b/spec/frontend/notes/components/noteable_note_spec.js
@@ -1,7 +1,7 @@
-import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { GlAvatar } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DiffsModule from '~/diffs/store/modules';
import NoteActions from '~/notes/components/note_actions.vue';
@@ -37,7 +37,9 @@ describe('issue_note', () => {
const REPORT_ABUSE_PATH = '/abuse_reports/add_category';
- const findMultilineComment = () => wrapper.find('[data-testid="multiline-comment"]');
+ const findNoteBody = () => wrapper.findComponent(NoteBody);
+
+ const findMultilineComment = () => wrapper.findByTestId('multiline-comment');
const createWrapper = (props = {}, storeUpdater = (s) => s) => {
store = new Vuex.Store(
@@ -52,7 +54,7 @@ describe('issue_note', () => {
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
- wrapper = mount(issueNote, {
+ wrapper = mountExtended(issueNote, {
store,
propsData: {
note,
@@ -71,10 +73,6 @@ describe('issue_note', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('mutiline comments', () => {
beforeEach(() => {
createWrapper();
@@ -254,21 +252,17 @@ describe('issue_note', () => {
});
it('should render issue body', () => {
- const noteBody = wrapper.findComponent(NoteBody);
- const noteBodyProps = noteBody.props();
-
- expect(noteBodyProps.note).toBe(note);
- expect(noteBodyProps.line).toBe(null);
- expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit);
- expect(noteBodyProps.isEditing).toBe(false);
- expect(noteBodyProps.helpPagePath).toBe('');
+ expect(findNoteBody().props().note).toBe(note);
+ expect(findNoteBody().props().line).toBe(null);
+ expect(findNoteBody().props().canEdit).toBe(note.current_user.can_edit);
+ expect(findNoteBody().props().isEditing).toBe(false);
+ expect(findNoteBody().props().helpPagePath).toBe('');
});
it('prevents note preview xss', async () => {
const noteBody =
'<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="alert(1)" />';
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
- const noteBodyComponent = wrapper.findComponent(NoteBody);
store.hotUpdate({
modules: {
@@ -281,7 +275,7 @@ describe('issue_note', () => {
},
});
- noteBodyComponent.vm.$emit('handleFormUpdate', {
+ findNoteBody().vm.$emit('handleFormUpdate', {
noteText: noteBody,
parentElement: null,
callback: () => {},
@@ -289,7 +283,7 @@ describe('issue_note', () => {
await waitForPromises();
expect(alertSpy).not.toHaveBeenCalled();
- expect(wrapper.vm.note.note_html).toBe(
+ expect(findNoteBody().props().note.note_html).toBe(
'<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">',
);
});
@@ -325,26 +319,21 @@ describe('issue_note', () => {
},
},
});
- const noteBody = wrapper.findComponent(NoteBody);
- noteBody.vm.resetAutoSave = () => {};
- noteBody.vm.$emit('handleFormUpdate', {
+ findNoteBody().vm.$emit('handleFormUpdate', {
noteText: updatedText,
parentElement: null,
callback: () => {},
});
await nextTick();
- let noteBodyProps = noteBody.props();
- expect(noteBodyProps.note.note_html).toBe(`<p>${updatedText}</p>\n`);
+ expect(findNoteBody().props().note.note_html).toBe(`<p dir="auto">${updatedText}</p>\n`);
- noteBody.vm.$emit('cancelForm', {});
+ findNoteBody().vm.$emit('cancelForm', {});
await nextTick();
- noteBodyProps = noteBody.props();
-
- expect(noteBodyProps.note.note_html).toBe(note.note_html);
+ expect(findNoteBody().props().note.note_html).toBe(note.note_html);
});
});
@@ -375,14 +364,23 @@ describe('issue_note', () => {
it('responds to handleFormUpdate', () => {
createWrapper();
updateActions();
- wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params);
+ findNoteBody().vm.$emit('handleFormUpdate', params);
expect(wrapper.emitted('handleUpdateNote')).toHaveLength(1);
});
+ it('should not update note with sensitive token', () => {
+ const sensitiveMessage = 'token: glpat-1234567890abcdefghij';
+
+ createWrapper();
+ updateActions();
+ findNoteBody().vm.$emit('handleFormUpdate', { ...params, noteText: sensitiveMessage });
+ expect(updateNote).not.toHaveBeenCalled();
+ });
+
it('does not stringify empty position', () => {
createWrapper();
updateActions();
- wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params);
+ findNoteBody().vm.$emit('handleFormUpdate', params);
expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined();
});
@@ -391,7 +389,7 @@ describe('issue_note', () => {
const expectation = JSON.stringify(position);
createWrapper({ note: { ...note, position } });
updateActions();
- wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params);
+ findNoteBody().vm.$emit('handleFormUpdate', params);
expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation);
});
});
@@ -416,7 +414,7 @@ describe('issue_note', () => {
createWrapper({ note: noteDef, discussionFile: null }, storeUpdater);
- expect(wrapper.vm.diffFile).toBe(null);
+ expect(findNoteBody().props().file).toBe(null);
},
);
@@ -434,7 +432,7 @@ describe('issue_note', () => {
},
);
- expect(wrapper.vm.diffFile.testId).toBe('diffFileTest');
+ expect(findNoteBody().props().file.testId).toBe('diffFileTest');
});
it('returns the provided diff file if the more robust getters fail', () => {
@@ -450,7 +448,7 @@ describe('issue_note', () => {
},
);
- expect(wrapper.vm.diffFile.testId).toBe('diffFileTest');
+ expect(findNoteBody().props().file.testId).toBe('diffFileTest');
});
});
});
diff --git a/spec/frontend/notes/components/notes_activity_header_spec.js b/spec/frontend/notes/components/notes_activity_header_spec.js
index 5b3165bf401..2de491477b6 100644
--- a/spec/frontend/notes/components/notes_activity_header_spec.js
+++ b/spec/frontend/notes/components/notes_activity_header_spec.js
@@ -24,10 +24,6 @@ describe('~/notes/components/notes_activity_header.vue', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index b08a22f8674..cdfe8b02b48 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -90,8 +90,9 @@ describe('note_app', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
+ // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy
+ wrapper.destroy();
});
describe('render', () => {
@@ -121,15 +122,19 @@ describe('note_app', () => {
);
});
- it('should render form comment button as disabled', () => {
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/410409
+ // eslint-disable-next-line jest/no-disabled-tests
+ it.skip('should render form comment button as disabled', () => {
expect(findCommentButton().props('disabled')).toEqual(true);
});
it('should render notes activity header', () => {
- expect(wrapper.findComponent(NotesActivityHeader).props()).toEqual({
- notesFilterValue: TEST_NOTES_FILTER_VALUE,
- notesFilters: mockData.notesFilters,
- });
+ expect(wrapper.findComponent(NotesActivityHeader).props().notesFilterValue).toEqual(
+ TEST_NOTES_FILTER_VALUE,
+ );
+ expect(wrapper.findComponent(NotesActivityHeader).props().notesFilters).toEqual(
+ mockData.notesFilters,
+ );
});
});
@@ -173,7 +178,7 @@ describe('note_app', () => {
});
describe('while fetching data', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent();
});
diff --git a/spec/frontend/notes/components/timeline_toggle_spec.js b/spec/frontend/notes/components/timeline_toggle_spec.js
index cf79416d300..caa6f95d5da 100644
--- a/spec/frontend/notes/components/timeline_toggle_spec.js
+++ b/spec/frontend/notes/components/timeline_toggle_spec.js
@@ -35,10 +35,6 @@ describe('Timeline toggle', () => {
});
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
store.dispatch.mockReset();
mockEvent.currentTarget.blur.mockReset();
Tracking.event.mockReset();
diff --git a/spec/frontend/notes/components/toggle_replies_widget_spec.js b/spec/frontend/notes/components/toggle_replies_widget_spec.js
index 8c3696e88b7..ef5f06ad2fa 100644
--- a/spec/frontend/notes/components/toggle_replies_widget_spec.js
+++ b/spec/frontend/notes/components/toggle_replies_widget_spec.js
@@ -30,10 +30,6 @@ describe('toggle replies widget for notes', () => {
const mountComponent = ({ collapsed = false }) =>
mountExtended(ToggleRepliesWidget, { propsData: { replies, collapsed } });
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('collapsed state', () => {
beforeEach(() => {
wrapper = mountComponent({ collapsed: true });
diff --git a/spec/frontend/notes/deprecated_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js
index 6d3bc19bd45..355ecb78187 100644
--- a/spec/frontend/notes/deprecated_notes_spec.js
+++ b/spec/frontend/notes/deprecated_notes_spec.js
@@ -1,9 +1,11 @@
/* eslint-disable import/no-commonjs, no-new */
-import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import htmlPipelineSchedulesEditSnippets from 'test_fixtures/snippets/show.html';
+import htmlPipelineSchedulesEditCommit from 'test_fixtures/commit/show.html';
import '~/behaviors/markdown/render_gfm';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
@@ -19,11 +21,9 @@ const Notes = require('~/deprecated_notes').default;
const FLASH_TYPE_ALERT = 'alert';
const NOTES_POST_PATH = /(.*)\/notes\?html=true$/;
-const fixture = 'snippets/show.html';
let mockAxios;
window.project_uploads_path = `${TEST_HOST}/uploads`;
-window.gon = window.gon || {};
window.gl = window.gl || {};
gl.utils = gl.utils || {};
gl.utils.disableButtonIfEmptyField = () => {};
@@ -37,7 +37,7 @@ function wrappedDiscussionNote(note) {
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('Old Notes (~/deprecated_notes.js)', () => {
beforeEach(() => {
- loadHTMLFixture(fixture);
+ setHTMLFixture(htmlPipelineSchedulesEditSnippets);
// Re-declare this here so that test_setup.js#beforeEach() doesn't
// overwrite it.
@@ -672,7 +672,7 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => {
let $notesContainer;
beforeEach(() => {
- loadHTMLFixture('commit/show.html');
+ setHTMLFixture(htmlPipelineSchedulesEditCommit);
mockAxios.onPost(NOTES_POST_PATH).reply(HTTP_STATUS_OK, note);
new Notes('', []);
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index c4c0dc58b0d..97249d232dc 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -3,7 +3,7 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import toast from '~/vue_shared/plugins/global_toast';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import axios from '~/lib/utils/axios_utils';
@@ -36,7 +36,7 @@ import {
const TEST_ERROR_MESSAGE = 'Test error message';
const mockAlertDismiss = jest.fn();
-jest.mock('~/flash', () => ({
+jest.mock('~/alert', () => ({
createAlert: jest.fn().mockImplementation(() => ({
dismiss: mockAlertDismiss,
})),
@@ -257,14 +257,14 @@ describe('Actions Notes Store', () => {
axiosMock.onGet(notesDataMock.notesPath).reply(HTTP_STATUS_OK, pollResponse, pollHeaders);
const failureMock = () =>
axiosMock.onGet(notesDataMock.notesPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
- const advanceAndRAF = async (time) => {
+ const advanceAndRAF = (time) => {
if (time) {
jest.advanceTimersByTime(time);
}
return waitForPromises();
};
- const advanceXMoreIntervals = async (number) => {
+ const advanceXMoreIntervals = (number) => {
const timeoutLength = pollInterval * number;
return advanceAndRAF(timeoutLength);
@@ -273,7 +273,7 @@ describe('Actions Notes Store', () => {
await store.dispatch('poll');
await advanceAndRAF(2);
};
- const cleanUp = async () => {
+ const cleanUp = () => {
jest.clearAllTimers();
return store.dispatch('stopPolling');
@@ -876,7 +876,7 @@ describe('Actions Notes Store', () => {
const res = { errors: { base: ['something went wrong'] } };
const error = { message: 'Unprocessable entity', response: { data: res } };
- it('sets flash alert using errors.base message', async () => {
+ it('sets an alert using errors.base message', async () => {
const resp = await actions.saveNote(
{
commit() {},
@@ -906,6 +906,20 @@ describe('Actions Notes Store', () => {
expect(data).toBe(res);
expect(createAlert).not.toHaveBeenCalled();
});
+
+ it('dispatches clearDrafts is command names contains submit_review', async () => {
+ const response = { command_names: ['submit_review'], valid: true };
+ dispatch = jest.fn().mockResolvedValue(response);
+ await actions.saveNote(
+ {
+ commit() {},
+ dispatch,
+ },
+ payload,
+ );
+
+ expect(dispatch).toHaveBeenCalledWith('batchComments/clearDrafts');
+ });
});
});
@@ -946,7 +960,7 @@ describe('Actions Notes Store', () => {
});
});
- it('when service fails, flashes error message', () => {
+ it('when service fails, creates an alert with error message', () => {
const response = { response: { data: { message: TEST_ERROR_MESSAGE } } };
Api.applySuggestion.mockReturnValue(Promise.reject(response));
@@ -1439,10 +1453,6 @@ describe('Actions Notes Store', () => {
describe('fetchDiscussions', () => {
const discussion = { notes: [] };
- afterEach(() => {
- window.gon = {};
- });
-
it('updates the discussions and dispatches `updateResolvableDiscussionsCounts`', () => {
axiosMock.onAny().reply(HTTP_STATUS_OK, { discussion });
return testAction(
diff --git a/spec/frontend/notifications/components/custom_notifications_modal_spec.js b/spec/frontend/notifications/components/custom_notifications_modal_spec.js
index 70749557e61..480d617fcb2 100644
--- a/spec/frontend/notifications/components/custom_notifications_modal_spec.js
+++ b/spec/frontend/notifications/components/custom_notifications_modal_spec.js
@@ -2,7 +2,6 @@ import { GlSprintf, GlModal, GlFormGroup, GlFormCheckbox, GlLoadingIcon } from '
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status';
@@ -66,8 +65,6 @@ describe('CustomNotificationsModal', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
mockAxios.restore();
});
@@ -87,27 +84,26 @@ describe('CustomNotificationsModal', () => {
describe('checkbox items', () => {
beforeEach(async () => {
+ const endpointUrl = '/api/v4/notification_settings';
+
+ mockAxios
+ .onGet(endpointUrl)
+ .reply(HTTP_STATUS_OK, mockNotificationSettingsResponses.default);
+
wrapper = createComponent();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- events: [
- { id: 'new_release', enabled: true, name: 'New release', loading: false },
- { id: 'new_note', enabled: false, name: 'New note', loading: true },
- ],
- });
+ wrapper.findComponent(GlModal).vm.$emit('show');
- await nextTick();
+ await waitForPromises();
});
it.each`
index | eventId | eventName | enabled | loading
${0} | ${'new_release'} | ${'New release'} | ${true} | ${false}
- ${1} | ${'new_note'} | ${'New note'} | ${false} | ${true}
+ ${1} | ${'new_note'} | ${'New note'} | ${false} | ${false}
`(
'renders a checkbox for "$eventName" with checked=$enabled',
- async ({ index, eventName, enabled, loading }) => {
+ ({ index, eventName, enabled, loading }) => {
const checkbox = findCheckboxAt(index);
expect(checkbox.text()).toContain(eventName);
expect(checkbox.vm.$attrs.checked).toBe(enabled);
@@ -214,16 +210,9 @@ describe('CustomNotificationsModal', () => {
wrapper = createComponent({ injectedProperties });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- events: [
- { id: 'new_release', enabled: true, name: 'New release', loading: false },
- { id: 'new_note', enabled: false, name: 'New note', loading: false },
- ],
- });
+ wrapper.findComponent(GlModal).vm.$emit('show');
- await nextTick();
+ await waitForPromises();
findCheckboxAt(1).vm.$emit('change', true);
@@ -241,19 +230,18 @@ describe('CustomNotificationsModal', () => {
);
it('shows a toast message when the request fails', async () => {
- mockAxios.onPut('/api/v4/notification_settings').reply(HTTP_STATUS_NOT_FOUND, {});
+ const endpointUrl = '/api/v4/notification_settings';
+
+ mockAxios
+ .onGet(endpointUrl)
+ .reply(HTTP_STATUS_OK, mockNotificationSettingsResponses.default);
+
+ mockAxios.onPut(endpointUrl).reply(HTTP_STATUS_NOT_FOUND, {});
wrapper = createComponent();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- events: [
- { id: 'new_release', enabled: true, name: 'New release', loading: false },
- { id: 'new_note', enabled: false, name: 'New note', loading: false },
- ],
- });
+ wrapper.findComponent(GlModal).vm.$emit('show');
- await nextTick();
+ await waitForPromises();
findCheckboxAt(1).vm.$emit('change', true);
diff --git a/spec/frontend/notifications/components/notifications_dropdown_spec.js b/spec/frontend/notifications/components/notifications_dropdown_spec.js
index 0f13de0e6d8..bae9b028cf7 100644
--- a/spec/frontend/notifications/components/notifications_dropdown_spec.js
+++ b/spec/frontend/notifications/components/notifications_dropdown_spec.js
@@ -25,7 +25,7 @@ describe('NotificationsDropdown', () => {
CustomNotificationsModal,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
provide: {
dropdownItems: mockDropdownItems,
@@ -61,8 +61,6 @@ describe('NotificationsDropdown', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
mockAxios.restore();
});
diff --git a/spec/frontend/oauth_application/components/oauth_secret_spec.js b/spec/frontend/oauth_application/components/oauth_secret_spec.js
new file mode 100644
index 00000000000..c38bd066da8
--- /dev/null
+++ b/spec/frontend/oauth_application/components/oauth_secret_spec.js
@@ -0,0 +1,116 @@
+import { GlButton, GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import OAuthSecret from '~/oauth_application/components/oauth_secret.vue';
+import {
+ RENEW_SECRET_FAILURE,
+ RENEW_SECRET_SUCCESS,
+ WARNING_NO_SECRET,
+} from '~/oauth_application/constants';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+
+jest.mock('~/alert');
+const mockEvent = { preventDefault: jest.fn() };
+
+describe('OAuthSecret', () => {
+ let wrapper;
+ const renewPath = '/applications/1/renew';
+
+ const createComponent = (provide = {}) => {
+ wrapper = shallowMount(OAuthSecret, {
+ provide: {
+ initialSecret: undefined,
+ renewPath,
+ ...provide,
+ },
+ });
+ };
+
+ const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility);
+ const findRenewSecretButton = () => wrapper.findComponent(GlButton);
+ const findModal = () => wrapper.findComponent(GlModal);
+
+ describe('when secret is provided', () => {
+ const initialSecret = 'my secret';
+ beforeEach(() => {
+ createComponent({ initialSecret });
+ });
+
+ it('shows the masked secret', () => {
+ expect(findInputCopyToggleVisibility().props('value')).toBe(initialSecret);
+ });
+
+ it('shows the renew secret button', () => {
+ expect(findRenewSecretButton().exists()).toBe(true);
+ });
+ });
+
+ describe('when secret is not provided', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows an alert', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: WARNING_NO_SECRET,
+ variant: VARIANT_WARNING,
+ });
+ });
+
+ it('shows the renew secret button', () => {
+ expect(findRenewSecretButton().exists()).toBe(true);
+ });
+
+ describe('when renew secret button is selected', () => {
+ beforeEach(() => {
+ createComponent();
+ findRenewSecretButton().vm.$emit('click');
+ });
+
+ it('shows a modal', () => {
+ expect(findModal().props('visible')).toBe(true);
+ });
+
+ describe('when secret renewal succeeds', () => {
+ const initialSecret = 'my secret';
+
+ beforeEach(async () => {
+ const mockAxios = new MockAdapter(axios);
+ mockAxios.onPut().reply(HTTP_STATUS_OK, { secret: initialSecret });
+ findModal().vm.$emit('primary', mockEvent);
+ await waitForPromises();
+ });
+
+ it('shows an alert', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: RENEW_SECRET_SUCCESS,
+ variant: VARIANT_SUCCESS,
+ });
+ });
+
+ it('shows the new secret', () => {
+ expect(findInputCopyToggleVisibility().props('value')).toBe(initialSecret);
+ });
+ });
+
+ describe('when secret renewal fails', () => {
+ beforeEach(async () => {
+ const mockAxios = new MockAdapter(axios);
+ mockAxios.onPut().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ findModal().vm.$emit('primary', mockEvent);
+ await waitForPromises();
+ });
+
+ it('creates an alert', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: RENEW_SECRET_FAILURE,
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js
index 1fa0e0aa8f6..33295d46fea 100644
--- a/spec/frontend/oauth_remember_me_spec.js
+++ b/spec/frontend/oauth_remember_me_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlOauthRememberMe from 'test_fixtures_static/oauth_remember_me.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me';
describe('OAuthRememberMe', () => {
@@ -8,7 +9,7 @@ describe('OAuthRememberMe', () => {
};
beforeEach(() => {
- loadHTMLFixture('static/oauth_remember_me.html');
+ setHTMLFixture(htmlOauthRememberMe);
new OAuthRememberMe({ container: $('#oauth-container') }).bindEvents();
});
@@ -17,19 +18,16 @@ describe('OAuthRememberMe', () => {
resetHTMLFixture();
});
- it('adds the "remember_me" query parameter to all OAuth login buttons', () => {
- $('#oauth-container #remember_me').click();
+ it('adds and removes the "remember_me" query parameter from all OAuth login buttons', () => {
+ $('#oauth-container #remember_me_omniauth').click();
expect(findFormAction('.twitter')).toBe('http://example.com/?remember_me=1');
expect(findFormAction('.github')).toBe('http://example.com/?remember_me=1');
expect(findFormAction('.facebook')).toBe(
'http://example.com/?redirect_fragment=L1&remember_me=1',
);
- });
- it('removes the "remember_me" query parameter from all OAuth login buttons', () => {
- $('#oauth-container #remember_me').click();
- $('#oauth-container #remember_me').click();
+ $('#oauth-container #remember_me_omniauth').click();
expect(findFormAction('.twitter')).toBe('http://example.com/');
expect(findFormAction('.github')).toBe('http://example.com/');
diff --git a/spec/frontend/observability/index_spec.js b/spec/frontend/observability/index_spec.js
new file mode 100644
index 00000000000..25eb048c62b
--- /dev/null
+++ b/spec/frontend/observability/index_spec.js
@@ -0,0 +1,64 @@
+import { createWrapper } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import renderObservability from '~/observability/index';
+import ObservabilityApp from '~/observability/components/observability_app.vue';
+import { SKELETON_VARIANTS_BY_ROUTE } from '~/observability/constants';
+
+describe('renderObservability', () => {
+ let element;
+ let vueInstance;
+ let component;
+
+ const OBSERVABILITY_ROUTES = Object.keys(SKELETON_VARIANTS_BY_ROUTE);
+ const SKELETON_VARIANTS = Object.values(SKELETON_VARIANTS_BY_ROUTE);
+
+ beforeEach(() => {
+ element = document.createElement('div');
+ element.setAttribute('id', 'js-observability-app');
+ element.dataset.observabilityIframeSrc = 'https://observe.gitlab.com/';
+ document.body.appendChild(element);
+
+ vueInstance = renderObservability();
+ component = createWrapper(vueInstance).findComponent(ObservabilityApp);
+ });
+
+ afterEach(() => {
+ element.remove();
+ });
+
+ it('should return a Vue instance', () => {
+ expect(vueInstance).toEqual(expect.any(Vue));
+ });
+
+ it('should render the ObservabilityApp component', () => {
+ expect(component.props('observabilityIframeSrc')).toBe('https://observe.gitlab.com/');
+ });
+
+ describe('skeleton variant', () => {
+ it.each`
+ pathDescription | path | variant
+ ${'dashboards'} | ${OBSERVABILITY_ROUTES[0]} | ${SKELETON_VARIANTS[0]}
+ ${'explore'} | ${OBSERVABILITY_ROUTES[1]} | ${SKELETON_VARIANTS[1]}
+ ${'manage dashboards'} | ${OBSERVABILITY_ROUTES[2]} | ${SKELETON_VARIANTS[2]}
+ ${'any other'} | ${'unknown/route'} | ${SKELETON_VARIANTS[0]}
+ `(
+ 'renders the $variant skeleton variant for $pathDescription path',
+ async ({ path, variant }) => {
+ component.vm.$router.push(path);
+ await nextTick();
+
+ expect(component.props('skeletonVariant')).toBe(variant);
+ },
+ );
+ });
+
+ it('handle route-update events', () => {
+ component.vm.$router.push('/something?foo=bar');
+ component.vm.$emit('route-update', { url: '/some_path' });
+ expect(component.vm.$router.currentRoute.path).toBe('/something');
+ expect(component.vm.$router.currentRoute.query).toEqual({
+ foo: 'bar',
+ observability_path: '/some_path',
+ });
+ });
+});
diff --git a/spec/frontend/observability/observability_app_spec.js b/spec/frontend/observability/observability_app_spec.js
index e3bcd140d60..4a9be71b880 100644
--- a/spec/frontend/observability/observability_app_spec.js
+++ b/spec/frontend/observability/observability_app_spec.js
@@ -1,19 +1,20 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ObservabilityApp from '~/observability/components/observability_app.vue';
import ObservabilitySkeleton from '~/observability/components/skeleton/index.vue';
-
-import { MESSAGE_EVENT_TYPE, SKELETON_VARIANTS_BY_ROUTE } from '~/observability/constants';
+import {
+ MESSAGE_EVENT_TYPE,
+ INLINE_EMBED_DIMENSIONS,
+ FULL_APP_DIMENSIONS,
+ SKELETON_VARIANT_EMBED,
+} from '~/observability/constants';
import { darkModeEnabled } from '~/lib/utils/color_utils';
jest.mock('~/lib/utils/color_utils');
-describe('Observability root app', () => {
+describe('ObservabilityApp', () => {
let wrapper;
- const replace = jest.fn();
- const $router = {
- replace,
- };
+
const $route = {
pathname: 'https://gitlab.com/gitlab-org/',
path: 'https://gitlab.com/gitlab-org/-/observability/dashboards',
@@ -26,21 +27,19 @@ describe('Observability root app', () => {
const TEST_IFRAME_SRC = 'https://observe.gitlab.com/9970/?groupId=14485840';
- const OBSERVABILITY_ROUTES = Object.keys(SKELETON_VARIANTS_BY_ROUTE);
-
- const SKELETON_VARIANTS = Object.values(SKELETON_VARIANTS_BY_ROUTE);
+ const TEST_USERNAME = 'test-user';
- const mountComponent = (route = $route) => {
+ const mountComponent = (props) => {
wrapper = shallowMountExtended(ObservabilityApp, {
propsData: {
observabilityIframeSrc: TEST_IFRAME_SRC,
+ ...props,
},
stubs: {
'observability-skeleton': ObservabilitySkeleton,
},
mocks: {
- $router,
- $route: route,
+ $route,
},
});
};
@@ -48,17 +47,11 @@ describe('Observability root app', () => {
const dispatchMessageEvent = (message) =>
window.dispatchEvent(new MessageEvent('message', message));
- afterEach(() => {
- wrapper.destroy();
+ beforeEach(() => {
+ gon.current_username = TEST_USERNAME;
});
describe('iframe src', () => {
- const TEST_USERNAME = 'test-user';
-
- beforeAll(() => {
- gon.current_username = TEST_USERNAME;
- });
-
it('should render an iframe with observabilityIframeSrc, decorated with light theme and username', () => {
darkModeEnabled.mockReturnValueOnce(false);
mountComponent();
@@ -92,48 +85,70 @@ describe('Observability root app', () => {
});
});
- describe('on GOUI_ROUTE_UPDATE', () => {
- it('should not call replace method from vue router if message event does not have url', () => {
- mountComponent();
- dispatchMessageEvent({
- type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE,
- payload: { data: 'some other data' },
+ describe('iframe kiosk query param', () => {
+ it('when inlineEmbed, it should set the proper kiosk query parameter', () => {
+ mountComponent({
+ inlineEmbed: true,
});
- expect(replace).not.toHaveBeenCalled();
+
+ const iframe = findIframe();
+
+ expect(iframe.attributes('src')).toBe(
+ `${TEST_IFRAME_SRC}&theme=light&username=${TEST_USERNAME}&kiosk=inline-embed`,
+ );
});
+ });
- it.each`
- condition | origin | observability_path | url
- ${'message origin is different from iframe source origin'} | ${'https://example.com'} | ${'/'} | ${'/explore'}
- ${'path is same as before (observability_path)'} | ${'https://observe.gitlab.com'} | ${'/foo?bar=test'} | ${'/foo?bar=test'}
- `(
- 'should not call replace method from vue router if $condition',
- async ({ origin, observability_path, url }) => {
- mountComponent({ ...$route, query: { observability_path } });
- dispatchMessageEvent({
- data: { type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, payload: { url } },
- origin,
- });
- expect(replace).not.toHaveBeenCalled();
- },
- );
+ describe('iframe size', () => {
+ it('should set the specified size', () => {
+ mountComponent({
+ height: INLINE_EMBED_DIMENSIONS.HEIGHT,
+ width: INLINE_EMBED_DIMENSIONS.WIDTH,
+ });
+
+ const iframe = findIframe();
+
+ expect(iframe.attributes('width')).toBe(INLINE_EMBED_DIMENSIONS.WIDTH);
+ expect(iframe.attributes('height')).toBe(INLINE_EMBED_DIMENSIONS.HEIGHT);
+ });
+
+ it('should fallback to default size', () => {
+ mountComponent({});
+
+ const iframe = findIframe();
- it('should call replace method from vue router on message event callback', () => {
+ expect(iframe.attributes('width')).toBe(FULL_APP_DIMENSIONS.WIDTH);
+ expect(iframe.attributes('height')).toBe(FULL_APP_DIMENSIONS.HEIGHT);
+ });
+ });
+
+ describe('skeleton variant', () => {
+ it('sets the specified skeleton variant', () => {
+ mountComponent({ skeletonVariant: SKELETON_VARIANT_EMBED });
+ const props = wrapper.findComponent(ObservabilitySkeleton).props();
+
+ expect(props.variant).toBe(SKELETON_VARIANT_EMBED);
+ });
+
+ it('should have a default skeleton variant', () => {
+ mountComponent();
+ const props = wrapper.findComponent(ObservabilitySkeleton).props();
+
+ expect(props.variant).toBe('dashboards');
+ });
+ });
+
+ describe('on GOUI_ROUTE_UPDATE', () => {
+ it('should emit a route-update event', () => {
mountComponent();
+ const payload = { url: '/explore' };
dispatchMessageEvent({
- data: { type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, payload: { url: '/explore' } },
+ data: { type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, payload },
origin: 'https://observe.gitlab.com',
});
- expect(replace).toHaveBeenCalled();
- expect(replace).toHaveBeenCalledWith({
- name: 'https://gitlab.com/gitlab-org/',
- query: {
- otherQuery: 100,
- observability_path: '/explore',
- },
- });
+ expect(wrapper.emitted('route-update')[0]).toEqual([payload]);
});
});
@@ -167,34 +182,17 @@ describe('Observability root app', () => {
});
});
- describe('skeleton variant', () => {
- it.each`
- pathDescription | path | variant
- ${'dashboards'} | ${OBSERVABILITY_ROUTES[0]} | ${SKELETON_VARIANTS[0]}
- ${'explore'} | ${OBSERVABILITY_ROUTES[1]} | ${SKELETON_VARIANTS[1]}
- ${'manage dashboards'} | ${OBSERVABILITY_ROUTES[2]} | ${SKELETON_VARIANTS[2]}
- ${'any other'} | ${'unknown/route'} | ${SKELETON_VARIANTS[0]}
- `('renders the $variant skeleton variant for $pathDescription path', ({ path, variant }) => {
- mountComponent({ ...$route, path });
- const props = wrapper.findComponent(ObservabilitySkeleton).props();
-
- expect(props.variant).toBe(variant);
- });
- });
-
- describe('on observability ui unmount', () => {
- it('should remove message event and should not call replace method from vue router', () => {
+ describe('on unmount', () => {
+ it('should not emit any even on route update', () => {
mountComponent();
wrapper.destroy();
- // testing event cleanup logic, should not call on messege event after component is destroyed
-
dispatchMessageEvent({
data: { type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, payload: { url: '/explore' } },
origin: 'https://observe.gitlab.com',
});
- expect(replace).not.toHaveBeenCalled();
+ expect(wrapper.emitted('route-update')).toBeUndefined();
});
});
});
diff --git a/spec/frontend/observability/skeleton_spec.js b/spec/frontend/observability/skeleton_spec.js
index a95597d8516..65dbb003743 100644
--- a/spec/frontend/observability/skeleton_spec.js
+++ b/spec/frontend/observability/skeleton_spec.js
@@ -6,8 +6,13 @@ import Skeleton from '~/observability/components/skeleton/index.vue';
import DashboardsSkeleton from '~/observability/components/skeleton/dashboards.vue';
import ExploreSkeleton from '~/observability/components/skeleton/explore.vue';
import ManageSkeleton from '~/observability/components/skeleton/manage.vue';
+import EmbedSkeleton from '~/observability/components/skeleton/embed.vue';
-import { SKELETON_VARIANTS_BY_ROUTE, DEFAULT_TIMERS } from '~/observability/constants';
+import {
+ SKELETON_VARIANTS_BY_ROUTE,
+ DEFAULT_TIMERS,
+ SKELETON_VARIANT_EMBED,
+} from '~/observability/constants';
describe('Skeleton component', () => {
let wrapper;
@@ -22,6 +27,8 @@ describe('Skeleton component', () => {
const findManageSkeleton = () => wrapper.findComponent(ManageSkeleton);
+ const findEmbedSkeleton = () => wrapper.findComponent(EmbedSkeleton);
+
const findAlert = () => wrapper.findComponent(GlAlert);
const mountComponent = ({ ...props } = {}) => {
@@ -97,16 +104,20 @@ describe('Skeleton component', () => {
${'dashboards'} | ${'variant is dashboards'} | ${SKELETON_VARIANTS[0]}
${'explore'} | ${'variant is explore'} | ${SKELETON_VARIANTS[1]}
${'manage'} | ${'variant is manage'} | ${SKELETON_VARIANTS[2]}
+ ${'embed'} | ${'variant is embed'} | ${SKELETON_VARIANT_EMBED}
${'default'} | ${'variant is not manage, dashboards or explore'} | ${'unknown'}
`('should render $skeletonType skeleton if $condition', async ({ skeletonType, variant }) => {
mountComponent({ variant });
jest.advanceTimersByTime(DEFAULT_TIMERS.CONTENT_WAIT_MS);
await nextTick();
- const showsDefaultSkeleton = !SKELETON_VARIANTS.includes(variant);
+ const showsDefaultSkeleton = ![...SKELETON_VARIANTS, SKELETON_VARIANT_EMBED].includes(
+ variant,
+ );
expect(findDashboardsSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANTS[0]);
expect(findExploreSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANTS[1]);
expect(findManageSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANTS[2]);
+ expect(findEmbedSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANT_EMBED);
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(showsDefaultSkeleton);
});
diff --git a/spec/frontend/operation_settings/components/metrics_settings_spec.js b/spec/frontend/operation_settings/components/metrics_settings_spec.js
index 732dfdd42fb..5bccf4943ae 100644
--- a/spec/frontend/operation_settings/components/metrics_settings_spec.js
+++ b/spec/frontend/operation_settings/components/metrics_settings_spec.js
@@ -2,7 +2,7 @@ import { GlButton, GlLink, GlFormGroup, GlFormInput, GlFormSelect } from '@gitla
import { mount, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { timezones } from '~/monitoring/format_date';
@@ -13,7 +13,7 @@ import MetricsSettings from '~/operation_settings/components/metrics_settings.vu
import store from '~/operation_settings/store';
jest.mock('~/lib/utils/url_utility');
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('operation settings external dashboard component', () => {
let wrapper;
@@ -47,9 +47,6 @@ describe('operation settings external dashboard component', () => {
});
afterEach(() => {
- if (wrapper.destroy) {
- wrapper.destroy();
- }
axios.patch.mockReset();
refreshCurrentPage.mockReset();
createAlert.mockReset();
@@ -198,7 +195,7 @@ describe('operation settings external dashboard component', () => {
expect(refreshCurrentPage).toHaveBeenCalled();
});
- it('creates flash banner on error', async () => {
+ it('creates an alert on error', async () => {
mountComponent(false);
const message = 'mockErrorMessage';
axios.patch.mockRejectedValue({ response: { data: { message } } });
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js
index ff11c8843bb..8ba7e40d728 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js
@@ -27,11 +27,6 @@ describe('delete_button', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('tooltip', () => {
it('the title is controlled by tooltipTitle prop', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js
index 620c96e8c9e..5a7cbdcff5b 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js
@@ -46,11 +46,6 @@ describe('Delete Image', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('executes apollo mutate on doDelete', () => {
const mutate = jest.fn().mockResolvedValue({});
mountComponent({ mutate });
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_modal_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_modal_spec.js
new file mode 100644
index 00000000000..1eaabf1ad09
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_modal_spec.js
@@ -0,0 +1,158 @@
+import { GlSprintf, GlFormInput } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import component from '~/packages_and_registries/container_registry/explorer/components/delete_modal.vue';
+import {
+ REMOVE_TAG_CONFIRMATION_TEXT,
+ REMOVE_TAGS_CONFIRMATION_TEXT,
+ DELETE_IMAGE_CONFIRMATION_TITLE,
+ DELETE_IMAGE_CONFIRMATION_TEXT,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+import { GlModal } from '../stubs';
+
+describe('Delete Modal', () => {
+ let wrapper;
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findDescription = () => wrapper.find('[data-testid="description"]');
+ const findInputComponent = () => wrapper.findComponent(GlFormInput);
+
+ const mountComponent = (propsData) => {
+ wrapper = shallowMount(component, {
+ propsData,
+ stubs: {
+ GlSprintf,
+ GlModal,
+ },
+ });
+ };
+
+ const expectPrimaryActionStatus = (disabled = true) =>
+ expect(findModal().props('actionPrimary')).toMatchObject(
+ expect.objectContaining({
+ attributes: { variant: 'danger', disabled },
+ }),
+ );
+
+ it('contains a GlModal', () => {
+ mountComponent();
+ expect(findModal().exists()).toBe(true);
+ });
+
+ describe('events', () => {
+ it.each`
+ glEvent | localEvent
+ ${'primary'} | ${'confirmDelete'}
+ ${'cancel'} | ${'cancelDelete'}
+ `('GlModal $glEvent emits $localEvent', ({ glEvent, localEvent }) => {
+ mountComponent();
+ findModal().vm.$emit(glEvent);
+ expect(wrapper.emitted(localEvent)).toEqual([[]]);
+ });
+ });
+
+ describe('methods', () => {
+ it('show calls gl-modal show', () => {
+ mountComponent();
+ wrapper.vm.show();
+ expect(GlModal.methods.show).toHaveBeenCalled();
+ });
+ });
+
+ describe('when we are deleting images', () => {
+ it('has the correct title', () => {
+ mountComponent({ deleteImage: true });
+
+ expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TITLE);
+ });
+
+ it('has the correct description', () => {
+ mountComponent({ deleteImage: true });
+
+ expect(wrapper.text()).toContain(
+ DELETE_IMAGE_CONFIRMATION_TEXT.replace('%{code}', '').trim(),
+ );
+ });
+
+ describe('delete button', () => {
+ let itemsToBeDeleted = [{ project: { path: 'foo' } }];
+
+ it('is disabled by default', () => {
+ mountComponent({ deleteImage: true });
+
+ expectPrimaryActionStatus();
+ });
+
+ it('if the user types something different from the project path is disabled', async () => {
+ mountComponent({ deleteImage: true, itemsToBeDeleted });
+
+ findInputComponent().vm.$emit('input', 'bar');
+
+ await nextTick();
+
+ expectPrimaryActionStatus();
+ });
+
+ it('if the user types the project path it is enabled', async () => {
+ mountComponent({ deleteImage: true, itemsToBeDeleted });
+
+ findInputComponent().vm.$emit('input', 'foo');
+
+ await nextTick();
+
+ expectPrimaryActionStatus(false);
+ });
+
+ it('if the user types the image name when available', async () => {
+ itemsToBeDeleted = [{ name: 'foo' }];
+ mountComponent({ deleteImage: true, itemsToBeDeleted });
+
+ findInputComponent().vm.$emit('input', 'foo');
+
+ await nextTick();
+
+ expectPrimaryActionStatus(false);
+ });
+ });
+ });
+
+ describe('when we are deleting tags', () => {
+ it('delete button is enabled', () => {
+ mountComponent();
+
+ expectPrimaryActionStatus(false);
+ });
+
+ describe('itemsToBeDeleted contains one element', () => {
+ beforeEach(() => {
+ mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] });
+ });
+
+ it(`has the correct description`, () => {
+ expect(findDescription().text()).toBe(
+ REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo'),
+ );
+ });
+
+ it('has the correct title', () => {
+ expect(wrapper.text()).toContain('Remove tag');
+ });
+ });
+
+ describe('itemsToBeDeleted contains more than element', () => {
+ beforeEach(() => {
+ mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] });
+ });
+
+ it(`has the correct description`, () => {
+ expect(findDescription().text()).toBe(
+ REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2'),
+ );
+ });
+
+ it('has the correct title', () => {
+ expect(wrapper.text()).toContain('Remove tags');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js
index d45b993b5a2..9d187439ca3 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js
@@ -19,11 +19,6 @@ describe('Delete alert', () => {
wrapper = shallowMount(component, { stubs: { GlSprintf }, propsData });
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when deleteAlertType is null', () => {
it('does not show the alert', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js
deleted file mode 100644
index 16c9485e69e..00000000000
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js
+++ /dev/null
@@ -1,152 +0,0 @@
-import { GlSprintf, GlFormInput } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import component from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue';
-import {
- REMOVE_TAG_CONFIRMATION_TEXT,
- REMOVE_TAGS_CONFIRMATION_TEXT,
- DELETE_IMAGE_CONFIRMATION_TITLE,
- DELETE_IMAGE_CONFIRMATION_TEXT,
-} from '~/packages_and_registries/container_registry/explorer/constants';
-import { GlModal } from '../../stubs';
-
-describe('Delete Modal', () => {
- let wrapper;
-
- const findModal = () => wrapper.findComponent(GlModal);
- const findDescription = () => wrapper.find('[data-testid="description"]');
- const findInputComponent = () => wrapper.findComponent(GlFormInput);
-
- const mountComponent = (propsData) => {
- wrapper = shallowMount(component, {
- propsData,
- stubs: {
- GlSprintf,
- GlModal,
- },
- });
- };
-
- const expectPrimaryActionStatus = (disabled = true) =>
- expect(findModal().props('actionPrimary')).toMatchObject(
- expect.objectContaining({
- attributes: [{ variant: 'danger' }, { disabled }],
- }),
- );
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- it('contains a GlModal', () => {
- mountComponent();
- expect(findModal().exists()).toBe(true);
- });
-
- describe('events', () => {
- it.each`
- glEvent | localEvent
- ${'primary'} | ${'confirmDelete'}
- ${'cancel'} | ${'cancelDelete'}
- `('GlModal $glEvent emits $localEvent', ({ glEvent, localEvent }) => {
- mountComponent();
- findModal().vm.$emit(glEvent);
- expect(wrapper.emitted(localEvent)).toEqual([[]]);
- });
- });
-
- describe('methods', () => {
- it('show calls gl-modal show', () => {
- mountComponent();
- wrapper.vm.show();
- expect(GlModal.methods.show).toHaveBeenCalled();
- });
- });
-
- describe('when we are deleting images', () => {
- it('has the correct title', () => {
- mountComponent({ deleteImage: true });
-
- expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TITLE);
- });
-
- it('has the correct description', () => {
- mountComponent({ deleteImage: true });
-
- expect(wrapper.text()).toContain(
- DELETE_IMAGE_CONFIRMATION_TEXT.replace('%{code}', '').trim(),
- );
- });
-
- describe('delete button', () => {
- const itemsToBeDeleted = [{ project: { path: 'foo' } }];
-
- it('is disabled by default', () => {
- mountComponent({ deleteImage: true });
-
- expectPrimaryActionStatus();
- });
-
- it('if the user types something different from the project path is disabled', async () => {
- mountComponent({ deleteImage: true, itemsToBeDeleted });
-
- findInputComponent().vm.$emit('input', 'bar');
-
- await nextTick();
-
- expectPrimaryActionStatus();
- });
-
- it('if the user types the project path it is enabled', async () => {
- mountComponent({ deleteImage: true, itemsToBeDeleted });
-
- findInputComponent().vm.$emit('input', 'foo');
-
- await nextTick();
-
- expectPrimaryActionStatus(false);
- });
- });
- });
-
- describe('when we are deleting tags', () => {
- it('delete button is enabled', () => {
- mountComponent();
-
- expectPrimaryActionStatus(false);
- });
-
- describe('itemsToBeDeleted contains one element', () => {
- beforeEach(() => {
- mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] });
- });
-
- it(`has the correct description`, () => {
- expect(findDescription().text()).toBe(
- REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo'),
- );
- });
-
- it('has the correct title', () => {
- expect(wrapper.text()).toContain('Remove tag');
- });
- });
-
- describe('itemsToBeDeleted contains more than element', () => {
- beforeEach(() => {
- mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] });
- });
-
- it(`has the correct description`, () => {
- expect(findDescription().text()).toBe(
- REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2'),
- );
- });
-
- it('has the correct title', () => {
- expect(wrapper.text()).toContain('Remove tags');
- });
- });
- });
-});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
index b37edac83f7..01089422376 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
@@ -1,10 +1,10 @@
import { GlDropdownItem, GlIcon, GlDropdown } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vue, { nextTick } from 'vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import waitForPromises from 'helpers/wait_for_promises';
import component from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue';
@@ -22,37 +22,27 @@ import {
} from '~/packages_and_registries/container_registry/explorer/constants';
import getContainerRepositoryMetadata from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
-import { imageTagsCountMock } from '../../mock_data';
+import { containerRepositoryMock, imageTagsCountMock } from '../../mock_data';
describe('Details Header', () => {
let wrapper;
let apolloProvider;
const defaultImage = {
- name: 'foo',
- updatedAt: '2020-11-03T13:29:21Z',
- canDelete: true,
- project: {
- visibility: 'public',
- path: 'path',
- containerExpirationPolicy: {
- enabled: false,
- },
- },
+ ...containerRepositoryMock,
};
// set the date to Dec 4, 2020
useFakeDate(2020, 11, 4);
- const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
- const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility');
- const findTitle = () => findByTestId('title');
- const findTagsCount = () => findByTestId('tags-count');
- const findCleanup = () => findByTestId('cleanup');
+ const findCreatedAndVisibility = () => wrapper.findByTestId('created-and-visibility');
+ const findTitle = () => wrapper.findByTestId('title');
+ const findTagsCount = () => wrapper.findByTestId('tags-count');
+ const findCleanup = () => wrapper.findByTestId('cleanup');
const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
const findInfoIcon = () => wrapper.findComponent(GlIcon);
const findMenu = () => wrapper.findComponent(GlDropdown);
- const findSize = () => findByTestId('image-size');
+ const findSize = () => wrapper.findByTestId('image-size');
const waitForMetadataItems = async () => {
// Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available
@@ -69,11 +59,11 @@ describe('Details Header', () => {
const requestHandlers = [[getContainerRepositoryMetadata, resolver]];
apolloProvider = createMockApollo(requestHandlers);
- wrapper = shallowMount(component, {
+ wrapper = shallowMountExtended(component, {
apolloProvider,
propsData,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
stubs: {
TitleArea,
@@ -85,9 +75,7 @@ describe('Details Header', () => {
afterEach(() => {
// if we want to mix createMockApollo and manual mocks we need to reset everything
- wrapper.destroy();
apolloProvider = undefined;
- wrapper = null;
});
describe('image name', () => {
@@ -99,7 +87,7 @@ describe('Details Header', () => {
});
it('root image shows project path name', () => {
- expect(findTitle().text()).toBe('path');
+ expect(findTitle().text()).toBe('gitlab-test');
});
it('has an icon', () => {
@@ -121,7 +109,7 @@ describe('Details Header', () => {
});
it('shows image.name', () => {
- expect(findTitle().text()).toContain('foo');
+ expect(findTitle().text()).toContain('rails-12009');
});
it('has no icon', () => {
@@ -249,7 +237,7 @@ describe('Details Header', () => {
expect(findCleanup().props('icon')).toBe('expire');
});
- it('when the expiration policy is disabled', async () => {
+ it('when cleanup is not scheduled', async () => {
mountComponent();
await waitForMetadataItems();
@@ -289,12 +277,12 @@ describe('Details Header', () => {
);
});
- describe('visibility and updated at', () => {
- it('has last updated text', async () => {
+ describe('visibility and created at', () => {
+ it('has created text', async () => {
mountComponent();
await waitForMetadataItems();
- expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago');
+ expect(findCreatedAndVisibility().props('text')).toBe('Created Nov 3, 2020 13:29');
});
describe('visibility icon', () => {
@@ -302,7 +290,7 @@ describe('Details Header', () => {
mountComponent();
await waitForMetadataItems();
- expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye');
+ expect(findCreatedAndVisibility().props('icon')).toBe('eye');
});
it('shows an eye slashed when the project is not public', async () => {
mountComponent({
@@ -310,7 +298,7 @@ describe('Details Header', () => {
});
await waitForMetadataItems();
- expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash');
+ expect(findCreatedAndVisibility().props('icon')).toBe('eye-slash');
});
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js
index ce5ecfe4608..d6c1b2c3f51 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js
@@ -23,11 +23,6 @@ describe('Partial Cleanup alert', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it(`gl-alert has the correct properties`, () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js
index d83a5099bcd..3e1fd14475d 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js
@@ -27,11 +27,6 @@ describe('Status Alert', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it.each`
status | title | variant | message | link
${DELETE_SCHEDULED} | ${SCHEDULED_FOR_DELETION_STATUS_TITLE} | ${'info'} | ${SCHEDULED_FOR_DELETION_STATUS_MESSAGE} | ${PACKAGE_DELETE_HELP_PAGE_PATH}
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js
index fa0d76762df..f74dfcb029d 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js
@@ -50,16 +50,11 @@ describe('tags list row', () => {
},
propsData,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('checkbox', () => {
it('exists', () => {
mountComponent();
@@ -158,7 +153,7 @@ describe('tags list row', () => {
it('is disabled when the component is disabled', () => {
mountComponent({ ...defaultProps, disabled: true });
- expect(findClipboardButton().attributes('disabled')).toBe('true');
+ expect(findClipboardButton().attributes('disabled')).toBeDefined();
});
});
@@ -283,26 +278,30 @@ describe('tags list row', () => {
textSrOnly: true,
category: 'tertiary',
right: true,
+ disabled: false,
});
});
- it.each`
- canDelete | digest | disabled | buttonDisabled
- ${true} | ${null} | ${true} | ${true}
- ${false} | ${'foo'} | ${true} | ${true}
- ${false} | ${null} | ${true} | ${true}
- ${true} | ${'foo'} | ${true} | ${true}
- ${true} | ${'foo'} | ${false} | ${false}
- `(
- 'is $visible that is visible when canDelete is $canDelete and digest is $digest and disabled is $disabled',
- ({ canDelete, digest, disabled, buttonDisabled }) => {
- mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled });
+ it('has the correct classes', () => {
+ mountComponent();
- expect(findAdditionalActionsMenu().props('disabled')).toBe(buttonDisabled);
- expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(buttonDisabled);
- expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(buttonDisabled);
- },
- );
+ expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(false);
+ expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(false);
+ });
+
+ it('is not rendered when tag.canDelete is false', () => {
+ mountComponent({ ...defaultProps, tag: { ...tag, canDelete: false } });
+
+ expect(findAdditionalActionsMenu().exists()).toBe(false);
+ });
+
+ it('is hidden when disabled prop is set to true', () => {
+ mountComponent({ ...defaultProps, disabled: true });
+
+ expect(findAdditionalActionsMenu().props('disabled')).toBe(true);
+ expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(true);
+ expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(true);
+ });
describe('delete button', () => {
it('exists and has the correct attrs', () => {
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
index 1017ff06a25..0cbb9eab018 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
@@ -4,13 +4,15 @@ import { GlEmptyState } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-
+import Tracking from '~/tracking';
import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue';
import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql';
+import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql';
+
import {
GRAPHQL_PAGE_SIZE,
NO_TAGS_TITLE,
@@ -19,7 +21,13 @@ import {
NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
} from '~/packages_and_registries/container_registry/explorer/constants/index';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
-import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data';
+import {
+ graphQLDeleteImageRepositoryTagsMock,
+ tagsMock,
+ imageTagsMock,
+ tagsPageInfo,
+} from '../../mock_data';
+import { DeleteModal } from '../../stubs';
describe('Tags List', () => {
let wrapper;
@@ -31,6 +39,7 @@ describe('Tags List', () => {
noContainersImage: 'noContainersImage',
};
+ const findDeleteModal = () => wrapper.findComponent(DeleteModal);
const findPersistedSearch = () => wrapper.findComponent(PersistedSearch);
const findTagsListRow = () => wrapper.findAllComponents(TagsListRow);
const findRegistryList = () => wrapper.findComponent(RegistryList);
@@ -42,20 +51,23 @@ describe('Tags List', () => {
};
const waitForApolloRequestRender = async () => {
+ fireFirstSortUpdate();
await waitForPromises();
- await nextTick();
};
- const mountComponent = ({ propsData = { isMobile: false, id: 1 } } = {}) => {
+ const mountComponent = ({ propsData = { isMobile: false, id: 1 }, mutationResolver } = {}) => {
Vue.use(VueApollo);
- const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]];
+ const requestHandlers = [
+ [getContainerRepositoryTagsQuery, resolver],
+ [deleteContainerRepositoryTagsMutation, mutationResolver],
+ ];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMount(component, {
apolloProvider,
propsData,
- stubs: { RegistryList },
+ stubs: { RegistryList, DeleteModal },
provide() {
return {
config: defaultConfig,
@@ -66,17 +78,13 @@ describe('Tags List', () => {
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(imageTagsMock());
- });
-
- afterEach(() => {
- wrapper.destroy();
+ jest.spyOn(Tracking, 'event');
});
describe('registry list', () => {
- beforeEach(() => {
+ beforeEach(async () => {
mountComponent();
- fireFirstSortUpdate();
- return waitForApolloRequestRender();
+ await waitForApolloRequestRender();
});
it('has a persisted search', () => {
@@ -98,6 +106,7 @@ describe('Tags List', () => {
pagination: tagsPageInfo,
items: tags,
idProperty: 'name',
+ hiddenDelete: false,
});
});
@@ -129,11 +138,46 @@ describe('Tags List', () => {
});
});
- it('emits a delete event when list emits delete', () => {
- const eventPayload = 'foo';
- findRegistryList().vm.$emit('delete', eventPayload);
+ describe('delete event', () => {
+ describe('single item', () => {
+ beforeEach(() => {
+ findRegistryList().vm.$emit('delete', [tags[0]]);
+ });
+
+ it('opens the modal', () => {
+ expect(DeleteModal.methods.show).toHaveBeenCalled();
+ });
+
+ it('sets modal props', () => {
+ expect(findDeleteModal().props('itemsToBeDeleted')).toMatchObject([tags[0]]);
+ });
+
+ it('tracks a single delete event', () => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'registry_tag_delete',
+ });
+ });
+ });
- expect(wrapper.emitted('delete')).toEqual([[eventPayload]]);
+ describe('multiple items', () => {
+ beforeEach(() => {
+ findRegistryList().vm.$emit('delete', tags);
+ });
+
+ it('opens the modal', () => {
+ expect(DeleteModal.methods.show).toHaveBeenCalled();
+ });
+
+ it('sets modal props', () => {
+ expect(findDeleteModal().props('itemsToBeDeleted')).toMatchObject(tags);
+ });
+
+ it('tracks multiple delete event', () => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'bulk_registry_tag_delete',
+ });
+ });
+ });
});
});
});
@@ -141,7 +185,6 @@ describe('Tags List', () => {
describe('list rows', () => {
it('one row exist for each tag', async () => {
mountComponent();
- fireFirstSortUpdate();
await waitForApolloRequestRender();
@@ -150,7 +193,6 @@ describe('Tags List', () => {
it('the correct props are bound to it', async () => {
mountComponent({ propsData: { disabled: true, id: 1 } });
- fireFirstSortUpdate();
await waitForApolloRequestRender();
@@ -165,7 +207,6 @@ describe('Tags List', () => {
describe('events', () => {
it('select event update the selected items', async () => {
mountComponent();
- fireFirstSortUpdate();
await waitForApolloRequestRender();
findTagsListRow().at(0).vm.$emit('select');
@@ -175,23 +216,63 @@ describe('Tags List', () => {
expect(findTagsListRow().at(0).attributes('selected')).toBe('true');
});
- it('delete event emit a delete event', async () => {
- mountComponent();
- fireFirstSortUpdate();
- await waitForApolloRequestRender();
+ describe('delete event', () => {
+ let mutationResolver;
- findTagsListRow().at(0).vm.$emit('delete');
- expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name);
+ beforeEach(async () => {
+ mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock);
+ resolver = jest.fn().mockResolvedValue(imageTagsMock());
+ mountComponent({ mutationResolver });
+
+ await waitForApolloRequestRender();
+ findTagsListRow().at(0).vm.$emit('delete');
+ });
+
+ it('opens the modal', () => {
+ expect(DeleteModal.methods.show).toHaveBeenCalled();
+ });
+
+ it('tracks a single delete event', () => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'registry_tag_delete',
+ });
+ });
+
+ it('confirmDelete event calls apollo mutation with the right parameters and refetches the tags list query', async () => {
+ findDeleteModal().vm.$emit('confirmDelete');
+
+ expect(mutationResolver).toHaveBeenCalledWith(
+ expect.objectContaining({ tagNames: [tags[0].name] }),
+ );
+
+ await waitForPromises();
+
+ expect(resolver).toHaveBeenLastCalledWith({
+ first: GRAPHQL_PAGE_SIZE,
+ name: '',
+ sort: 'NAME_ASC',
+ id: '1',
+ });
+ });
});
});
});
+ describe('when user does not have permission to delete list rows', () => {
+ it('sets registry list hiddenDelete prop to true', async () => {
+ resolver = jest.fn().mockResolvedValue(imageTagsMock({ canDelete: false }));
+ mountComponent();
+ await waitForApolloRequestRender();
+
+ expect(findRegistryList().props('hiddenDelete')).toBe(true);
+ });
+ });
+
describe('when the list of tags is empty', () => {
- beforeEach(() => {
- resolver = jest.fn().mockResolvedValue(imageTagsMock([]));
+ beforeEach(async () => {
+ resolver = jest.fn().mockResolvedValue(imageTagsMock({ nodes: [] }));
mountComponent();
- fireFirstSortUpdate();
- return waitForApolloRequestRender();
+ await waitForApolloRequestRender();
});
it('does not show the loader', () => {
@@ -217,7 +298,7 @@ describe('Tags List', () => {
filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'foo' } }],
});
- await waitForApolloRequestRender();
+ await waitForPromises();
expect(findEmptyState().props()).toMatchObject({
svgPath: defaultConfig.noContainersImage,
@@ -228,6 +309,175 @@ describe('Tags List', () => {
});
});
+ describe('modal', () => {
+ it('exists', async () => {
+ mountComponent();
+ await waitForApolloRequestRender();
+
+ expect(findDeleteModal().exists()).toBe(true);
+ });
+
+ describe('cancel event', () => {
+ it('tracks cancel_delete', async () => {
+ mountComponent();
+ await waitForApolloRequestRender();
+
+ findDeleteModal().vm.$emit('cancel');
+
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', {
+ label: 'registry_tag_delete',
+ });
+ });
+ });
+
+ describe('confirmDelete event', () => {
+ let mutationResolver;
+
+ describe('when mutation', () => {
+ beforeEach(() => {
+ mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock);
+ mountComponent({ mutationResolver });
+
+ return waitForApolloRequestRender();
+ });
+
+ it('is started renders loader', async () => {
+ findRegistryList().vm.$emit('delete', [tags[0]]);
+
+ findDeleteModal().vm.$emit('confirmDelete');
+ await nextTick();
+
+ expect(findTagsLoader().exists()).toBe(true);
+ expect(findTagsListRow().exists()).toBe(false);
+ });
+
+ it('ends, loader is hidden', async () => {
+ findRegistryList().vm.$emit('delete', [tags[0]]);
+
+ findDeleteModal().vm.$emit('confirmDelete');
+ await waitForPromises();
+
+ expect(findTagsLoader().exists()).toBe(false);
+ expect(findTagsListRow().exists()).toBe(true);
+ });
+ });
+
+ describe.each([
+ {
+ description: 'rejection',
+ mutationMock: jest.fn().mockRejectedValue(),
+ },
+ {
+ description: 'error',
+ mutationMock: jest.fn().mockResolvedValue({
+ data: {
+ destroyContainerRepositoryTags: {
+ errors: [new Error()],
+ },
+ },
+ }),
+ },
+ ])('when mutation fails with $description', ({ mutationMock }) => {
+ beforeEach(() => {
+ mutationResolver = mutationMock;
+ mountComponent({ mutationResolver });
+
+ return waitForApolloRequestRender();
+ });
+
+ it('when one item is selected to be deleted calls apollo mutation with the right parameters and emits delete event with right arguments', async () => {
+ findRegistryList().vm.$emit('delete', [tags[0]]);
+
+ resolver.mockClear();
+
+ findDeleteModal().vm.$emit('confirmDelete');
+
+ expect(mutationResolver).toHaveBeenCalledWith(
+ expect.objectContaining({ tagNames: [tags[0].name] }),
+ );
+
+ expect(resolver).not.toHaveBeenCalled();
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('delete')).toHaveLength(1);
+ expect(wrapper.emitted('delete')[0][0]).toEqual('danger_tag');
+ });
+
+ it('when more than one item is selected to be deleted calls apollo mutation with the right parameters and emits delete event with right arguments', async () => {
+ findRegistryList().vm.$emit('delete', tagsMock);
+ resolver.mockClear();
+
+ findDeleteModal().vm.$emit('confirmDelete');
+
+ expect(mutationResolver).toHaveBeenCalledWith(
+ expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }),
+ );
+
+ expect(resolver).not.toHaveBeenCalled();
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('delete')).toHaveLength(1);
+ expect(wrapper.emitted('delete')[0][0]).toEqual('danger_tags');
+ });
+ });
+
+ describe('when mutation is successful', () => {
+ beforeEach(() => {
+ mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock);
+ mountComponent({ mutationResolver });
+
+ return waitForApolloRequestRender();
+ });
+
+ it('and one item is selected to be deleted calls apollo mutation with the right parameters and refetches the tags list query', async () => {
+ findRegistryList().vm.$emit('delete', [tags[0]]);
+
+ findDeleteModal().vm.$emit('confirmDelete');
+
+ expect(mutationResolver).toHaveBeenCalledWith(
+ expect.objectContaining({ tagNames: [tags[0].name] }),
+ );
+
+ expect(resolver).toHaveBeenLastCalledWith({
+ first: GRAPHQL_PAGE_SIZE,
+ name: '',
+ sort: 'NAME_ASC',
+ id: '1',
+ });
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('delete')).toHaveLength(1);
+ expect(wrapper.emitted('delete')[0][0]).toEqual('success_tag');
+ });
+
+ it('and more than one item is selected to be deleted calls apollo mutation with the right parameters and refetches the tags list query', async () => {
+ findRegistryList().vm.$emit('delete', tagsMock);
+
+ findDeleteModal().vm.$emit('confirmDelete');
+
+ expect(mutationResolver).toHaveBeenCalledWith(
+ expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }),
+ );
+
+ expect(resolver).toHaveBeenLastCalledWith({
+ first: GRAPHQL_PAGE_SIZE,
+ name: '',
+ sort: 'NAME_ASC',
+ id: '1',
+ });
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('delete')).toHaveLength(1);
+ expect(wrapper.emitted('delete')[0][0]).toEqual('success_tags');
+ });
+ });
+ });
+ });
+
describe('loading state', () => {
it.each`
isImageLoading | queryExecuting | loadingVisible
@@ -239,7 +489,6 @@ describe('Tags List', () => {
'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown',
async ({ isImageLoading, queryExecuting, loadingVisible }) => {
mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } });
- fireFirstSortUpdate();
if (!queryExecuting) {
await waitForApolloRequestRender();
}
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js
index 88e79c513bc..8896185ce67 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js
@@ -20,11 +20,6 @@ describe('TagsLoader component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('produces the correct amount of loaders', () => {
mountComponent();
expect(findGlSkeletonLoaders().length).toBe(1);
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
index 535faebdd4e..0d1d2c53cab 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
@@ -36,10 +36,6 @@ describe('cleanup_status', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
status | visible | text
${UNFINISHED_STATUS} | ${true} | ${CLEANUP_STATUS_UNFINISHED}
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js
index d2086943e4f..900ea61e4ea 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js
@@ -26,10 +26,6 @@ describe('Registry Group Empty state', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
index 75068591007..5d8df45415e 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
@@ -1,4 +1,4 @@
-import { GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
+import { GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective } from 'helpers/vue_mock_directive';
import { mockTracking } from 'helpers/tracking_helper';
@@ -49,16 +49,11 @@ describe('Image List Row', () => {
config: {},
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('image title and path', () => {
it('renders shortened name of image and contains a link to the details page', () => {
mountComponent();
@@ -150,7 +145,7 @@ describe('Image List Row', () => {
});
it('the clipboard button is disabled', () => {
- expect(findClipboardButton().attributes('disabled')).toBe('true');
+ expect(findClipboardButton().attributes('disabled')).toBeDefined();
});
});
});
@@ -206,13 +201,6 @@ describe('Image List Row', () => {
expect(findTagsCount().exists()).toBe(true);
});
- it('contains a tag icon', () => {
- mountComponent();
- const icon = findTagsCount().findComponent(GlIcon);
- expect(icon.exists()).toBe(true);
- expect(icon.props('name')).toBe('tag');
- });
-
describe('loading state', () => {
it('shows a loader when metadataLoading is true', () => {
mountComponent({ metadataLoading: true });
@@ -231,12 +219,12 @@ describe('Image List Row', () => {
it('with one tag in the image', () => {
mountComponent({ item: { ...item, tagsCount: 1 } });
- expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
+ expect(findTagsCount().text()).toMatchInterpolatedText('1 tag');
});
it('with more than one tag in the image', () => {
mountComponent({ item: { ...item, tagsCount: 3 } });
- expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
+ expect(findTagsCount().text()).toMatchInterpolatedText('3 tags');
});
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js
index 042b8383571..6c771887b88 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js
@@ -21,11 +21,6 @@ describe('Image List', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('list', () => {
it('contains one list element for each image', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js
index 8cfa8128021..e4d13143484 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js
@@ -34,10 +34,6 @@ describe('Registry Project Empty state', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
index bcc8e41fce8..b7f3698e155 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
@@ -35,11 +35,6 @@ describe('registry_header', () => {
await nextTick();
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('header', () => {
it('has a title', () => {
mountComponent({ metadataLoading: true });
@@ -86,7 +81,7 @@ describe('registry_header', () => {
});
});
- describe('expiration policy', () => {
+ describe('cleanup policy', () => {
it('when is disabled', async () => {
await mountComponent({
expirationPolicy: { enabled: false },
@@ -116,11 +111,11 @@ describe('registry_header', () => {
const cleanupLink = findSetupCleanUpLink();
expect(text.exists()).toBe(true);
- expect(text.props('text')).toBe('Expiration policy will run in ');
+ expect(text.props('text')).toBe('Cleanup will run in ');
expect(cleanupLink.exists()).toBe(true);
expect(cleanupLink.text()).toBe(SET_UP_CLEANUP);
});
- it('when the expiration policy is completely disabled', async () => {
+ it('when the cleanup policy is not scheduled', async () => {
await mountComponent({
expirationPolicy: { enabled: true },
expirationPolicyHelpPagePath: 'foo',
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
index e5b99f15e8c..8ca74f5077e 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
@@ -127,7 +127,6 @@ export const containerRepositoryMock = {
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009',
canDelete: true,
createdAt: '2020-11-03T13:29:21Z',
- updatedAt: '2020-11-03T13:29:21Z',
expirationPolicyStartedAt: null,
expirationPolicyCleanupStatus: 'UNSCHEDULED',
project: {
@@ -177,11 +176,12 @@ export const tagsMock = [
},
];
-export const imageTagsMock = (nodes = tagsMock) => ({
+export const imageTagsMock = ({ nodes = tagsMock, canDelete = true } = {}) => ({
data: {
containerRepository: {
id: containerRepositoryMock.id,
tagsCount: nodes.length,
+ canDelete,
tags: {
nodes,
pageInfo: { ...tagsPageInfo },
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
index 26f0e506829..7fed81acead 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
@@ -22,22 +22,15 @@ import {
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '~/packages_and_registries/container_registry/explorer/constants';
-import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
-import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql';
-import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql';
import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue';
import Tracking from '~/tracking';
import {
graphQLImageDetailsMock,
- graphQLDeleteImageRepositoryTagsMock,
- graphQLProjectImageRepositoriesDetailsMock,
containerRepositoryMock,
graphQLEmptyImageDetailsMock,
- tagsMock,
- imageTagsMock,
} from '../mock_data';
import { DeleteModal } from '../stubs';
@@ -69,13 +62,6 @@ describe('Details Page', () => {
isGroupPage: false,
};
- const cleanTags = tagsMock.map((t) => {
- const result = { ...t };
- // eslint-disable-next-line no-underscore-dangle
- delete result.__typename;
- return result;
- });
-
const waitForApolloRequestRender = async () => {
await waitForPromises();
await nextTick();
@@ -83,20 +69,12 @@ describe('Details Page', () => {
const mountComponent = ({
resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()),
- mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock),
- tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock())),
- detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock),
options,
config = defaultConfig,
} = {}) => {
Vue.use(VueApollo);
- const requestHandlers = [
- [getContainerRepositoryDetailsQuery, resolver],
- [deleteContainerRepositoryTagsMutation, mutationResolver],
- [getContainerRepositoryTagsQuery, tagsResolver],
- [getContainerRepositoriesDetails, detailsResolver],
- ];
+ const requestHandlers = [[getContainerRepositoryDetailsQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
@@ -127,11 +105,6 @@ describe('Details Page', () => {
jest.spyOn(Tracking, 'event');
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when isLoading is true', () => {
it('shows the loader', () => {
mountComponent();
@@ -189,50 +162,6 @@ describe('Details Page', () => {
isMobile: false,
});
});
-
- describe('deleteEvent', () => {
- describe('single item', () => {
- let tagToBeDeleted;
- beforeEach(async () => {
- mountComponent();
-
- await waitForApolloRequestRender();
-
- [tagToBeDeleted] = cleanTags;
- findTagsList().vm.$emit('delete', [tagToBeDeleted]);
- });
-
- it('open the modal', async () => {
- expect(DeleteModal.methods.show).toHaveBeenCalled();
- });
-
- it('tracks a single delete event', () => {
- expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
- label: 'registry_tag_delete',
- });
- });
- });
-
- describe('multiple items', () => {
- beforeEach(async () => {
- mountComponent();
-
- await waitForApolloRequestRender();
-
- findTagsList().vm.$emit('delete', cleanTags);
- });
-
- it('open the modal', () => {
- expect(DeleteModal.methods.show).toHaveBeenCalled();
- });
-
- it('tracks a single delete event', () => {
- expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
- label: 'bulk_registry_tag_delete',
- });
- });
- });
- });
});
describe('modal', () => {
@@ -253,61 +182,24 @@ describe('Details Page', () => {
findDeleteModal().vm.$emit('cancel');
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', {
- label: 'registry_tag_delete',
+ label: 'registry_image_delete',
});
});
});
- describe('confirmDelete event', () => {
- let mutationResolver;
- let tagsResolver;
- let detailsResolver;
-
+ describe('tags list delete event', () => {
beforeEach(() => {
- mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock);
- tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock()));
- detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
- mountComponent({ mutationResolver, tagsResolver, detailsResolver });
+ mountComponent();
return waitForApolloRequestRender();
});
- describe('when one item is selected to be deleted', () => {
- it('calls apollo mutation with the right parameters and refetches the tags list query', async () => {
- findTagsList().vm.$emit('delete', [cleanTags[0]]);
-
- await nextTick();
-
- findDeleteModal().vm.$emit('confirmDelete');
-
- expect(mutationResolver).toHaveBeenCalledWith(
- expect.objectContaining({ tagNames: [cleanTags[0].name] }),
- );
-
- await waitForPromises();
+ it('sets delete alert modal deleteAlertType value', async () => {
+ findTagsList().vm.$emit('delete', 'success_tag');
- expect(tagsResolver).toHaveBeenCalled();
- expect(detailsResolver).toHaveBeenCalled();
- });
- });
-
- describe('when more than one item is selected to be deleted', () => {
- it('calls apollo mutation with the right parameters and refetches the tags list query', async () => {
- findTagsList().vm.$emit('delete', tagsMock);
-
- await nextTick();
-
- findDeleteModal().vm.$emit('confirmDelete');
-
- expect(mutationResolver).toHaveBeenCalledWith(
- expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }),
- );
-
- await waitForPromises();
+ await nextTick();
- expect(tagsResolver).toHaveBeenCalled();
- expect(detailsResolver).toHaveBeenCalled();
- });
+ expect(findDeleteAlert().props('deleteAlertType')).toBe('success_tag');
});
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
index 1e514d85e82..1823bbfe533 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
@@ -1,8 +1,8 @@
-import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
+import { GlSkeletonLoader, GlSprintf, GlAlert, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
-
import VueApollo from 'vue-apollo';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
@@ -16,6 +16,7 @@ import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
SORT_FIELDS,
+ SETTINGS_TEXT,
} from '~/packages_and_registries/container_registry/explorer/constants';
import deleteContainerRepositoryMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql';
import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql';
@@ -36,18 +37,19 @@ import {
graphQLProjectImageRepositoriesDetailsMock,
dockerCommands,
} from '../mock_data';
-import { GlModal, GlEmptyState } from '../stubs';
+import { GlEmptyState, DeleteModal } from '../stubs';
describe('List Page', () => {
let wrapper;
let apolloProvider;
- const findDeleteModal = () => wrapper.findComponent(GlModal);
+ const findDeleteModal = () => wrapper.findComponent(DeleteModal);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findCliCommands = () => wrapper.findComponent(CliCommands);
+ const findSettingsLink = () => wrapper.findComponent(GlButton);
const findProjectEmptyState = () => wrapper.findComponent(ProjectEmptyState);
const findGroupEmptyState = () => wrapper.findComponent(GroupEmptyState);
const findRegistryHeader = () => wrapper.findComponent(RegistryHeader);
@@ -89,7 +91,7 @@ describe('List Page', () => {
wrapper = shallowMount(component, {
apolloProvider,
stubs: {
- GlModal,
+ DeleteModal,
GlEmptyState,
GlSprintf,
RegistryHeader,
@@ -110,13 +112,12 @@ describe('List Page', () => {
...dockerCommands,
};
},
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains registry header', async () => {
mountComponent();
fireFirstSortUpdate();
@@ -126,6 +127,42 @@ describe('List Page', () => {
expect(findRegistryHeader().props()).toMatchObject({
imagesCount: 2,
metadataLoading: false,
+ helpPagePath: '',
+ hideExpirationPolicyData: false,
+ showCleanupPolicyLink: false,
+ expirationPolicy: {},
+ cleanupPoliciesSettingsPath: '',
+ });
+ });
+
+ describe('link to settings', () => {
+ beforeEach(() => {
+ const config = {
+ showContainerRegistrySettings: true,
+ cleanupPoliciesSettingsPath: 'bar',
+ };
+ mountComponent({ config });
+ });
+
+ it('is rendered', () => {
+ expect(findSettingsLink().exists()).toBe(true);
+ });
+
+ it('has the right icon', () => {
+ expect(findSettingsLink().props('icon')).toBe('settings');
+ });
+
+ it('has the right attributes', () => {
+ expect(findSettingsLink().attributes()).toMatchObject({
+ 'aria-label': SETTINGS_TEXT,
+ href: 'bar',
+ });
+ });
+
+ it('sets tooltip with right label', () => {
+ const tooltip = getBinding(findSettingsLink().element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(SETTINGS_TEXT);
});
});
@@ -239,6 +276,14 @@ describe('List Page', () => {
expect(findCliCommands().exists()).toBe(false);
});
+
+ it('link to settings is not visible', async () => {
+ mountComponent({ resolver, config });
+
+ await waitForApolloRequestRender();
+
+ expect(findSettingsLink().exists()).toBe(false);
+ });
});
});
@@ -310,7 +355,7 @@ describe('List Page', () => {
await selectImageForDeletion();
- findDeleteModal().vm.$emit('primary');
+ findDeleteModal().vm.$emit('confirmDelete');
expect(mutationResolver).toHaveBeenCalledWith({ id: deletedContainerRepository.id });
});
@@ -468,11 +513,15 @@ describe('List Page', () => {
expect(findDeleteModal().exists()).toBe(true);
});
- it('contains a description with the path of the item to delete', async () => {
- await waitForPromises();
- findImageList().vm.$emit('delete', { path: 'foo' });
+ it('contains the deleted image as props', async () => {
await waitForPromises();
- expect(findDeleteModal().html()).toContain('foo');
+ findImageList().vm.$emit('delete', deletedContainerRepository);
+ await nextTick();
+
+ expect(findDeleteModal().props()).toEqual({
+ itemsToBeDeleted: [deletedContainerRepository],
+ deleteImage: true,
+ });
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js b/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js
index 7d281a53a59..0d80028adf6 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js
@@ -6,7 +6,7 @@ import {
} from '@gitlab/ui';
import { RouterLinkStub } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
-import RealDeleteModal from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue';
+import RealDeleteModal from '~/packages_and_registries/container_registry/explorer/components/delete_modal.vue';
import RealListItem from '~/vue_shared/components/registry/list_item.vue';
export const GlModal = stubComponent(RealGlModal, {
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js
index 5063759a620..d7a9c200c7b 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js
@@ -1,6 +1,23 @@
-import { timeTilRun } from '~/packages_and_registries/container_registry/explorer/utils';
+import {
+ getImageName,
+ timeTilRun,
+} from '~/packages_and_registries/container_registry/explorer/utils';
describe('Container registry utilities', () => {
+ describe('getImageName', () => {
+ it('returns name when present', () => {
+ const result = getImageName({ name: 'foo' });
+
+ expect(result).toBe('foo');
+ });
+
+ it('returns project path when name is empty', () => {
+ const result = getImageName({ name: '', project: { path: 'foo' } });
+
+ expect(result).toBe('foo');
+ });
+ });
+
describe('timeTilRun', () => {
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime());
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
index 601f8abd34d..1928dbf72b6 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
@@ -5,7 +5,6 @@ import {
GlFormInputGroup,
GlFormGroup,
GlModal,
- GlSkeletonLoader,
GlSprintf,
GlEmptyState,
} from '@gitlab/ui';
@@ -13,6 +12,7 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import MockAdapter from 'axios-mock-adapter';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants';
@@ -31,11 +31,6 @@ import { proxyDetailsQuery, proxyData, pagination, proxyManifests } from './mock
const dummyApiVersion = 'v3000';
const dummyGrouptId = 1;
const dummyUrlRoot = '/gitlab';
-const dummyGon = {
- api_version: dummyApiVersion,
- relative_url_root: dummyUrlRoot,
-};
-let originalGon;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${dummyGrouptId}/dependency_proxy/cache`;
Vue.use(VueApollo);
@@ -51,6 +46,7 @@ describe('DependencyProxyApp', () => {
groupId: dummyGrouptId,
noManifestsIllustration: 'noManifestsIllustration',
canClearCache: true,
+ settingsPath: 'path',
};
function createComponent({ provide = provideDefaults } = {}) {
@@ -71,56 +67,46 @@ describe('DependencyProxyApp', () => {
GlSprintf,
TitleArea,
},
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
}
const findClipBoardButton = () => wrapper.findComponent(ClipboardButton);
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup);
- const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
- const findMainArea = () => wrapper.findByTestId('main-area');
const findProxyCountText = () => wrapper.findByTestId('proxy-count');
const findManifestList = () => wrapper.findComponent(ManifestsList);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findClearCacheDropdownList = () => wrapper.findComponent(GlDropdown);
const findClearCacheModal = () => wrapper.findComponent(GlModal);
const findClearCacheAlert = () => wrapper.findComponent(GlAlert);
+ const findSettingsLink = () => wrapper.findByTestId('settings-link');
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(proxyDetailsQuery());
- originalGon = window.gon;
- window.gon = { ...dummyGon };
+ window.gon = {
+ api_version: dummyApiVersion,
+ relative_url_root: dummyUrlRoot,
+ };
mock = new MockAdapter(axios);
mock.onDelete(expectedUrl).reply(HTTP_STATUS_ACCEPTED, {});
});
afterEach(() => {
- wrapper.destroy();
- window.gon = originalGon;
mock.restore();
});
describe('when the dependency proxy is available', () => {
describe('when is loading', () => {
- it('renders the skeleton loader', () => {
- createComponent();
-
- expect(findSkeletonLoader().exists()).toBe(true);
- });
-
it('does not render a form group with label', () => {
createComponent();
expect(findFormGroup().exists()).toBe(false);
});
-
- it('does not show the main section', () => {
- createComponent();
-
- expect(findMainArea().exists()).toBe(false);
- });
});
describe('when the app is loaded', () => {
@@ -130,10 +116,6 @@ describe('DependencyProxyApp', () => {
return waitForPromises();
});
- it('renders the main area', () => {
- expect(findMainArea().exists()).toBe(true);
- });
-
it('renders a form group with a label', () => {
expect(findFormGroup().attributes('label')).toBe(
DependencyProxyApp.i18n.proxyImagePrefix,
@@ -157,6 +139,29 @@ describe('DependencyProxyApp', () => {
expect(findProxyCountText().text()).toBe('Contains 2 blobs of images (1024 Bytes)');
});
+ describe('link to settings', () => {
+ it('is rendered', () => {
+ expect(findSettingsLink().exists()).toBe(true);
+ });
+
+ it('has the right icon', () => {
+ expect(findSettingsLink().props('icon')).toBe('settings');
+ });
+
+ it('has the right attributes', () => {
+ expect(findSettingsLink().attributes()).toMatchObject({
+ 'aria-label': DependencyProxyApp.i18n.settingsText,
+ href: 'path',
+ });
+ });
+
+ it('sets tooltip with right label', () => {
+ const tooltip = getBinding(findSettingsLink().element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(DependencyProxyApp.i18n.settingsText);
+ });
+ });
+
describe('manifest lists', () => {
describe('when there are no manifests', () => {
beforeEach(() => {
@@ -189,6 +194,7 @@ describe('DependencyProxyApp', () => {
it('shows list', () => {
expect(findManifestList().props()).toMatchObject({
+ dependencyProxyImagePrefix: proxyData().dependencyProxyImagePrefix,
manifests: proxyManifests(),
pagination: pagination(),
});
@@ -218,13 +224,6 @@ describe('DependencyProxyApp', () => {
});
describe('triggering page event on list', () => {
- it('re-renders the skeleton loader', async () => {
- findManifestList().vm.$emit('next-page');
- await nextTick();
-
- expect(findSkeletonLoader().exists()).toBe(true);
- });
-
it('renders form group with label', async () => {
findManifestList().vm.$emit('next-page');
await nextTick();
@@ -233,13 +232,6 @@ describe('DependencyProxyApp', () => {
expect.stringMatching(DependencyProxyApp.i18n.proxyImagePrefix),
);
});
-
- it('does not show the main section', async () => {
- findManifestList().vm.$emit('next-page');
- await nextTick();
-
- expect(findMainArea().exists()).toBe(false);
- });
});
it('shows the clear cache dropdown list', () => {
@@ -274,9 +266,7 @@ describe('DependencyProxyApp', () => {
beforeEach(() => {
createComponent({
provide: {
- groupPath: 'gitlab-org',
- groupId: dummyGrouptId,
- noManifestsIllustration: 'noManifestsIllustration',
+ ...provideDefaults,
canClearCache: false,
},
});
@@ -285,6 +275,10 @@ describe('DependencyProxyApp', () => {
it('does not show the clear cache dropdown list', () => {
expect(findClearCacheDropdownList().exists()).toBe(false);
});
+
+ it('does not show link to settings', () => {
+ expect(findSettingsLink().exists()).toBe(false);
+ });
});
});
});
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js
index 2f415bfd6f9..4149f728cd8 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js
@@ -1,9 +1,9 @@
-import { GlKeysetPagination } from '@gitlab/ui';
+import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue';
-
import Component from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue';
import {
+ proxyData,
proxyManifests,
pagination,
} from 'jest/packages_and_registries/dependency_proxy/mock_data';
@@ -12,8 +12,10 @@ describe('Manifests List', () => {
let wrapper;
const defaultProps = {
+ dependencyProxyImagePrefix: proxyData().dependencyProxyImagePrefix,
manifests: proxyManifests(),
pagination: pagination(),
+ loading: false,
};
const createComponent = (propsData = defaultProps) => {
@@ -24,10 +26,8 @@ describe('Manifests List', () => {
const findRows = () => wrapper.findAllComponents(ManifestRow);
const findPagination = () => wrapper.findComponent(GlKeysetPagination);
-
- afterEach(() => {
- wrapper.destroy();
- });
+ const findMainArea = () => wrapper.findByTestId('main-area');
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
it('has the correct title', () => {
createComponent();
@@ -44,8 +44,27 @@ describe('Manifests List', () => {
it('binds a manifest to each row', () => {
createComponent();
- expect(findRows().at(0).props()).toMatchObject({
- manifest: defaultProps.manifests[0],
+ expect(findRows().at(0).props('manifest')).toBe(defaultProps.manifests[0]);
+ });
+
+ it('binds a dependencyProxyImagePrefix to each row', () => {
+ createComponent();
+
+ expect(findRows().at(0).props('dependencyProxyImagePrefix')).toBe(
+ proxyData().dependencyProxyImagePrefix,
+ );
+ });
+
+ describe('loading', () => {
+ it.each`
+ loading | expectLoader | expectContent
+ ${false} | ${false} | ${true}
+ ${true} | ${true} | ${false}
+ `('when loading is $loading', ({ loading, expectLoader, expectContent }) => {
+ createComponent({ ...defaultProps, loading });
+
+ expect(findSkeletonLoader().exists()).toBe(expectLoader);
+ expect(findMainArea().exists()).toBe(expectContent);
});
});
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js
index be3236d1f9c..5f47a1b8098 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js
@@ -1,15 +1,17 @@
import { GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Component from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue';
import { MANIFEST_PENDING_DESTRUCTION_STATUS } from '~/packages_and_registries/dependency_proxy/constants';
-import { proxyManifests } from 'jest/packages_and_registries/dependency_proxy/mock_data';
+import { proxyData, proxyManifests } from 'jest/packages_and_registries/dependency_proxy/mock_data';
describe('Manifest Row', () => {
let wrapper;
const defaultProps = {
+ dependencyProxyImagePrefix: proxyData().dependencyProxyImagePrefix,
manifest: proxyManifests()[0],
};
@@ -24,15 +26,13 @@ describe('Manifest Row', () => {
});
};
+ const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
const findListItem = () => wrapper.findComponent(ListItem);
const findCachedMessages = () => wrapper.findByTestId('cached-message');
+ const findDigest = () => wrapper.findByTestId('manifest-row-short-digest');
const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip);
const findStatus = () => wrapper.findByTestId('status');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('With a manifest on the DEFAULT status', () => {
beforeEach(() => {
createComponent();
@@ -42,12 +42,18 @@ describe('Manifest Row', () => {
expect(findListItem().exists()).toBe(true);
});
- it('displays the name', () => {
- expect(wrapper.text()).toContain('alpine');
+ it('displays the name with tag & digest', () => {
+ expect(wrapper.text()).toContain('alpine:latest');
+ expect(findDigest().text()).toMatchInterpolatedText('Digest: 995efde');
});
- it('displays the version', () => {
- expect(wrapper.text()).toContain('latest');
+ it('displays the name & digest for manifests that contain digest in image name', () => {
+ createComponent({
+ ...defaultProps,
+ manifest: proxyManifests()[1],
+ });
+ expect(wrapper.text()).toContain('alpine');
+ expect(findDigest().text()).toMatchInterpolatedText('Digest: e95efde');
});
it('displays the cached time', () => {
@@ -86,4 +92,35 @@ describe('Manifest Row', () => {
expect(findStatus().text()).toBe('Scheduled for deletion');
});
});
+
+ describe('clipboard button', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('exists', () => {
+ expect(findClipboardButton().exists()).toBe(true);
+ });
+
+ it('passes the correct title prop', () => {
+ expect(findClipboardButton().attributes('title')).toBe(Component.i18n.copyImagePathTitle);
+ });
+
+ it('has the correct copy text when image name contains tag name', () => {
+ expect(findClipboardButton().attributes('text')).toBe(
+ 'gdk.test:3000/private-group/dependency_proxy/containers/alpine:latest',
+ );
+ });
+
+ it('has the correct copy text when image name contains digest', () => {
+ createComponent({
+ ...defaultProps,
+ manifest: proxyManifests()[1],
+ });
+
+ expect(findClipboardButton().attributes('text')).toBe(
+ 'gdk.test:3000/private-group/dependency_proxy/containers/alpine@sha256:e95efde2e81b21d1ea7066aa77a59298a62a9e9fbb4b77f36c189774ec9b1089',
+ );
+ });
+ });
});
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js
index 37c8eb669ba..4d0be0a0c09 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js
@@ -11,13 +11,15 @@ export const proxyManifests = () => [
{
id: 'proxy-1',
createdAt: '2021-09-22T09:45:28Z',
+ digest: 'sha256:995efde2e81b21d1ea7066aa77a59298a62a9e9fbb4b77f36c189774ec9b1089',
imageName: 'alpine:latest',
status: 'DEFAULT',
},
{
id: 'proxy-2',
createdAt: '2021-09-21T09:45:28Z',
- imageName: 'alpine:stable',
+ digest: 'sha256:e95efde2e81b21d1ea7066aa77a59298a62a9e9fbb4b77f36c189774ec9b1089',
+ imageName: 'alpine:sha256:e95efde2e81b21d1ea7066aa77a59298a62a9e9fbb4b77f36c189774ec9b1089',
status: 'DEFAULT',
},
];
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js
index a2e5cbdce8b..1e9b9b1ce47 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js
@@ -63,10 +63,6 @@ describe('Harbor artifact list row', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('list item', () => {
beforeEach(() => {
mountComponent({
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_spec.js
index b9d6dc2679e..786a4715731 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_spec.js
@@ -26,10 +26,6 @@ describe('Harbor artifacts list', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when isLoading is true', () => {
beforeEach(() => {
mountComponent({
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/details/details_header_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/details/details_header_spec.js
index e8cc2b2e22d..d8fb91c085c 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/details/details_header_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/details/details_header_spec.js
@@ -20,10 +20,6 @@ describe('Harbor Details Header', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('artifact name', () => {
describe('missing image name', () => {
beforeEach(() => {
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js
index 7a6169d300c..9a7ad759dba 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js
@@ -29,10 +29,6 @@ describe('harbor_list_header', () => {
await nextTick();
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('header', () => {
it('has a title', () => {
mountComponent({ metadataLoading: true });
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js
index b62d4e8836b..1e031e0557a 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js
@@ -28,10 +28,6 @@ describe('Harbor List Row', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('image title and path', () => {
it('contains a link to the details page', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js
index e7e74a0da58..a1803ecf7fb 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js
@@ -20,10 +20,6 @@ describe('Harbor List', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('list', () => {
it('contains one list element for each image', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_header_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_header_spec.js
index 5e299a269e3..9370ff1fdd4 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_header_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_header_spec.js
@@ -26,10 +26,6 @@ describe('Harbor Tags Header', () => {
totalPages: 1,
};
- afterEach(() => {
- wrapper.destroy();
- });
-
beforeEach(() => {
mountComponent({
propsData: { artifactDetail: mockArtifactDetail, pageInfo: mockPageInfo, tagsLoading: false },
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_row_spec.js
index 849215e286b..0b2ce01ebf6 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_row_spec.js
@@ -37,10 +37,6 @@ describe('Harbor tag list row', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('list item', () => {
beforeEach(() => {
mountComponent({
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_spec.js
index 4c6b2b6daaa..e2a2a584b7d 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_spec.js
@@ -24,10 +24,6 @@ describe('Harbor Tags List', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when isLoading is true', () => {
beforeEach(() => {
mountComponent({
diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js
index 69765d31674..90c3d9082f7 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js
@@ -74,10 +74,6 @@ describe('Harbor Details Page', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when isLoading is true', () => {
it('shows the loader', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js
index 97d30e6fe99..1bc2657822e 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js
@@ -60,10 +60,6 @@ describe('Harbor List Page', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains harbor registry header', async () => {
mountComponent();
fireFirstSortUpdate();
@@ -78,7 +74,7 @@ describe('Harbor List Page', () => {
});
describe('isLoading is true', () => {
- it('shows the skeleton loader', async () => {
+ it('shows the skeleton loader', () => {
mountComponent();
fireFirstSortUpdate();
@@ -97,7 +93,7 @@ describe('Harbor List Page', () => {
expect(findCliCommands().exists()).toBe(false);
});
- it('title has the metadataLoading props set to true', async () => {
+ it('title has the metadataLoading props set to true', () => {
mountComponent();
fireFirstSortUpdate();
diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/tags_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/tags_spec.js
index 10901c6ec1e..6002faa1fa3 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/pages/tags_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/pages/tags_spec.js
@@ -60,10 +60,6 @@ describe('Harbor Tags page', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains tags header', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js
index e74375b7705..f8130287c12 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js
@@ -86,10 +86,6 @@ describe('PackagesApp', () => {
const findTerraformInstallation = () => wrapper.findComponent(TerraformInstallation);
const findPackageFiles = () => wrapper.findComponent(PackageFiles);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the app and displays the package title', async () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js
index b504f7489ab..148e87699f1 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js
@@ -39,10 +39,6 @@ describe('PackageTitle', () => {
const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]');
const packageRef = () => wrapper.find('[data-testid="package-ref"]');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('module title', () => {
it('is correctly bound', async () => {
await createComponent();
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js
index d7caa8ca2d8..7352afff051 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js
@@ -23,10 +23,6 @@ describe('FileSha', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders', () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js
index b76d7c2b57b..c3e0818fc11 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js
@@ -37,11 +37,6 @@ describe('Package Files', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('rows', () => {
it('renders a single file for an npm package', () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js
index 0cbe2755f7e..a650aba464e 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js
@@ -30,11 +30,6 @@ describe('Package History', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findHistoryElement = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findElementLink = (container) => container.findComponent(GlLink);
const findElementTimeAgo = (container) => container.findComponent(TimeAgoTooltip);
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js
index 78c1b840dbc..94797f01d16 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js
@@ -30,10 +30,6 @@ describe('TerraformInstallation', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders all the messages', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js
index bb970336b94..ea4d268d84e 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js
@@ -1,6 +1,6 @@
import testAction from 'helpers/vuex_action_helper';
import Api from '~/api';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages_and_registries/infrastructure_registry/details/constants';
import {
fetchPackageVersions,
@@ -15,7 +15,7 @@ import {
} from '~/packages_and_registries/shared/constants';
import { npmPackage as packageEntity } from '../../mock_data';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
jest.mock('~/api.js');
describe('Actions Package details store', () => {
@@ -53,7 +53,7 @@ describe('Actions Package details store', () => {
expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id);
});
- it('should create flash on API error', async () => {
+ it('should create alert on API error', async () => {
Api.projectPackage = jest.fn().mockRejectedValue();
await testAction(
@@ -83,7 +83,7 @@ describe('Actions Package details store', () => {
packageEntity.id,
);
});
- it('should create flash on API error', async () => {
+ it('should create alert on API error', async () => {
Api.deleteProjectPackage = jest.fn().mockRejectedValue();
await testAction(deletePackage, undefined, { packageEntity }, [], []);
@@ -118,7 +118,7 @@ describe('Actions Package details store', () => {
});
});
- it('should create flash on API error', async () => {
+ it('should create alert on API error', async () => {
Api.deleteProjectPackageFile = jest.fn().mockRejectedValue();
await testAction(deletePackageFile, fileId, { packageEntity }, [], []);
expect(createAlert).toHaveBeenCalledWith({
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap
index 801cde8582e..d0841c6110f 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap
@@ -49,9 +49,7 @@ exports[`packages_list_app renders 1`] = `
Learn how to
<b-link-stub
class="gl-link"
- event="click"
href="helpUrl"
- routertag="a"
target="_blank"
>
publish and share your packages
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js
index a086c20a5e7..a89247c0a97 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js
@@ -55,11 +55,6 @@ describe('Infrastructure Search', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('has a registry search component', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js
index aca6b0942cc..12859b1d77c 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js
@@ -22,11 +22,6 @@ describe('Infrastructure Title', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('title area', () => {
beforeEach(() => {
mountComponent();
@@ -37,7 +32,7 @@ describe('Infrastructure Title', () => {
});
it('has the correct title', () => {
- expect(findTitleArea().props('title')).toBe('Infrastructure Registry');
+ expect(findTitleArea().props('title')).toBe('Terraform Module Registry');
});
describe('with no modules', () => {
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js
index d237023d0cd..47d36d11e35 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import setWindowLocation from 'helpers/set_window_location_helper';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import * as commonUtils from '~/lib/utils/common_utils';
import PackageListApp from '~/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants';
@@ -14,7 +14,7 @@ import InfrastructureSearch from '~/packages_and_registries/infrastructure_regis
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
jest.mock('~/lib/utils/common_utils');
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(Vuex);
@@ -72,10 +72,6 @@ describe('packages_list_app', () => {
mountComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders', () => {
createStore({ packageCount: 1 });
mountComponent();
@@ -217,7 +213,7 @@ describe('packages_list_app', () => {
setWindowLocation(originalLocation);
});
- it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
+ it(`creates an alert if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
mountComponent();
expect(createAlert).toHaveBeenCalledWith({
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js
index 0164d92ce34..51445942eaa 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js
@@ -4,13 +4,13 @@ import Vue from 'vue';
import { last } from 'lodash';
import Vuex from 'vuex';
import stubChildren from 'helpers/stub_children';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import PackagesList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue';
import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants';
import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants';
-import Tracking from '~/tracking';
import { packageList } from '../../mock_data';
Vue.use(Vuex);
@@ -72,11 +72,6 @@ describe('packages_list', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when is loading', () => {
beforeEach(() => {
mountComponent({
@@ -179,23 +174,23 @@ describe('packages_list', () => {
});
describe('tracking', () => {
- let eventSpy;
+ let trackingSpy = null;
beforeEach(() => {
mountComponent();
- eventSpy = jest.spyOn(Tracking, 'event');
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } });
- });
-
- it('deleteItemConfirmation calls event', () => {
- wrapper.vm.deleteItemConfirmation();
- expect(eventSpy).toHaveBeenCalledWith(
- TRACK_CATEGORY,
- TRACKING_ACTIONS.DELETE_PACKAGE,
- expect.any(Object),
- );
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('deleteItemConfirmation calls event', async () => {
+ await findPackageListDeleteModal().vm.$emit('ok');
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACK_CATEGORY, TRACKING_ACTIONS.DELETE_PACKAGE, {
+ category: TRACK_CATEGORY,
+ });
});
});
});
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js
index 2c185e040f4..4f051264172 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js
@@ -2,14 +2,14 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { MISSING_DELETE_PATH_ERROR } from '~/packages_and_registries/infrastructure_registry/list/constants';
import * as actions from '~/packages_and_registries/infrastructure_registry/list/stores/actions';
import * as types from '~/packages_and_registries/infrastructure_registry/list/stores/mutation_types';
import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
jest.mock('~/api.js');
describe('Actions Package list store', () => {
@@ -96,7 +96,7 @@ describe('Actions Package list store', () => {
});
});
- it('should create flash on API error', async () => {
+ it('should create alert on API error', async () => {
Api.projectPackages = jest.fn().mockRejectedValue();
await testAction(
actions.requestPackagesList,
@@ -198,7 +198,7 @@ describe('Actions Package list store', () => {
);
});
- it('should stop the loading and call create flash on api error', async () => {
+ it('should stop the loading and call create alert on api error', async () => {
mock.onDelete(payload._links.delete_api_path).replyOnce(HTTP_STATUS_BAD_REQUEST);
await testAction(
actions.requestDeletePackage,
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js
index 721bdd34a4f..d00d7180f75 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js
@@ -49,16 +49,11 @@ describe('packages_list_row', () => {
disableDelete,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders', () => {
mountComponent();
expect(wrapper.element).toMatchSnapshot();
@@ -143,7 +138,7 @@ describe('packages_list_row', () => {
});
it('details link is disabled', () => {
- expect(findPackageLink().attributes('disabled')).toBe('true');
+ expect(findPackageLink().attributes('disabled')).toBeDefined();
});
it('has a warning icon', () => {
diff --git a/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js b/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js
index 9c1ebf5a2eb..4ab81182c9a 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js
@@ -1,7 +1,14 @@
-import { GlModal as RealGlModal } from '@gitlab/ui';
+import { GlModal as RealGlModal, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
+import {
+ DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
+ DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT,
+ DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+} from '~/packages_and_registries/package_registry/constants';
const GlModal = stubComponent(RealGlModal, {
methods: {
@@ -14,22 +21,30 @@ describe('DeleteModal', () => {
const defaultItemsToBeDeleted = [
{
- name: 'package 01',
+ name: 'package-1',
+ version: '1.0.0',
},
{
- name: 'package 02',
+ name: 'package-2',
+ version: '1.0.0',
},
];
const findModal = () => wrapper.findComponent(GlModal);
+ const findLink = () => wrapper.findComponent(GlLink);
- const mountComponent = ({ itemsToBeDeleted = defaultItemsToBeDeleted } = {}) => {
+ const mountComponent = ({
+ itemsToBeDeleted = defaultItemsToBeDeleted,
+ showRequestForwardingContent = false,
+ } = {}) => {
wrapper = shallowMountExtended(DeleteModal, {
propsData: {
itemsToBeDeleted,
+ showRequestForwardingContent,
},
stubs: {
GlModal,
+ GlSprintf,
},
});
};
@@ -45,16 +60,81 @@ describe('DeleteModal', () => {
it('passes actionPrimary prop', () => {
expect(findModal().props('actionPrimary')).toStrictEqual({
text: 'Permanently delete',
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
});
});
it('renders description', () => {
- expect(findModal().text()).toContain(
+ expect(findModal().text()).toMatchInterpolatedText(
'You are about to delete 2 packages. This operation is irreversible.',
);
});
+ it('with only one item to be deleted renders correct description', () => {
+ mountComponent({ itemsToBeDeleted: [defaultItemsToBeDeleted[0]] });
+
+ expect(findModal().text()).toMatchInterpolatedText(
+ 'You are about to delete version 1.0.0 of package-1. Are you sure?',
+ );
+ });
+
+ it('sets the right action primary text', () => {
+ expect(findModal().props('actionPrimary')).toMatchObject({
+ text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
+ });
+ });
+
+ describe('when showRequestForwardingContent is set', () => {
+ it('renders correct description', () => {
+ mountComponent({ showRequestForwardingContent: true });
+
+ expect(findModal().text()).toMatchInterpolatedText(
+ DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT,
+ );
+ });
+
+ it('contains link to help page', () => {
+ mountComponent({ showRequestForwardingContent: true });
+
+ expect(findLink().exists()).toBe(true);
+ expect(findLink().attributes('href')).toBe(REQUEST_FORWARDING_HELP_PAGE_PATH);
+ });
+
+ it('sets the right action primary text', () => {
+ mountComponent({ showRequestForwardingContent: true });
+
+ expect(findModal().props('actionPrimary')).toMatchObject({
+ text: DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ });
+ });
+
+ describe('and only one item to be deleted', () => {
+ beforeEach(() => {
+ mountComponent({
+ showRequestForwardingContent: true,
+ itemsToBeDeleted: [defaultItemsToBeDeleted[0]],
+ });
+ });
+
+ it('renders correct description', () => {
+ expect(findModal().text()).toMatchInterpolatedText(
+ 'Deleting this package while request forwarding is enabled for the project can pose a security risk. Do you want to delete package-1 version 1.0.0 anyway? What are the risks?',
+ );
+ });
+
+ it('contains link to help page', () => {
+ expect(findLink().exists()).toBe(true);
+ expect(findLink().attributes('href')).toBe(REQUEST_FORWARDING_HELP_PAGE_PATH);
+ });
+
+ it('sets the right action primary text', () => {
+ expect(findModal().props('actionPrimary')).toMatchObject({
+ text: DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ });
+ });
+ });
+ });
+
it('emits confirm when primary event is emitted', () => {
expect(wrapper.emitted('confirm')).toBeUndefined();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap
index 67f1906f6fd..9b429c39faa 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap
@@ -89,8 +89,9 @@ exports[`MavenInstallation maven renders all the messages 1`] = `
/>
<code-instruction-stub
+ class="gl-w-20 gl-mt-5"
copytext="Copy Maven command"
- instruction="mvn dependency:get -Dartifact=appGroup:appName:appVersion"
+ instruction="mvn install"
label="Maven Command"
trackingaction="copy_maven_command"
trackinglabel="code_instruction"
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
deleted file mode 100644
index 047fa04947c..00000000000
--- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap
+++ /dev/null
@@ -1,199 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`PackageTitle renders with tags 1`] = `
-<div
- class="gl-display-flex gl-flex-direction-column"
- data-qa-selector="package_title"
->
- <div
- class="gl-display-flex gl-justify-content-space-between gl-py-3"
- >
- <div
- class="gl-flex-direction-column gl-flex-grow-1"
- >
- <div
- class="gl-display-flex"
- >
- <!---->
-
- <div
- class="gl-display-flex gl-flex-direction-column"
- >
- <h2
- class="gl-font-size-h1 gl-mt-3 gl-mb-0"
- data-testid="title"
- >
- @gitlab-org/package-15
- </h2>
-
- <div
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3"
- >
- <div
- class="gl-display-flex gl-gap-3"
- data-testid="sub-header"
- >
- v
- 1.0.0
- published
- <time-ago-tooltip-stub
- cssclass=""
- time="2020-08-17T14:23:32Z"
- tooltipplacement="top"
- />
-
- <package-tags-stub
- hidelabel="true"
- tagdisplaylimit="2"
- tags="[object Object],[object Object],[object Object]"
- />
- </div>
- </div>
- </div>
- </div>
-
- <div
- class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
- >
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-type"
- icon="package"
- link=""
- size="s"
- text="npm"
- texttooltip=""
- />
- </div>
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-size"
- icon="disk"
- link=""
- size="s"
- text="800.00 KiB"
- texttooltip=""
- />
- </div>
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-last-downloaded-at"
- icon="download"
- link=""
- size="m"
- text="Last downloaded Aug 17, 2021"
- texttooltip=""
- />
- </div>
- </div>
- </div>
-
- <!---->
- </div>
-
- <p />
-</div>
-`;
-
-exports[`PackageTitle renders without tags 1`] = `
-<div
- class="gl-display-flex gl-flex-direction-column"
- data-qa-selector="package_title"
->
- <div
- class="gl-display-flex gl-justify-content-space-between gl-py-3"
- >
- <div
- class="gl-flex-direction-column gl-flex-grow-1"
- >
- <div
- class="gl-display-flex"
- >
- <!---->
-
- <div
- class="gl-display-flex gl-flex-direction-column"
- >
- <h2
- class="gl-font-size-h1 gl-mt-3 gl-mb-0"
- data-testid="title"
- >
- @gitlab-org/package-15
- </h2>
-
- <div
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3"
- >
- <div
- class="gl-display-flex gl-gap-3"
- data-testid="sub-header"
- >
- v
- 1.0.0
- published
- <time-ago-tooltip-stub
- cssclass=""
- time="2020-08-17T14:23:32Z"
- tooltipplacement="top"
- />
-
- <!---->
- </div>
- </div>
- </div>
- </div>
-
- <div
- class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
- >
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-type"
- icon="package"
- link=""
- size="s"
- text="npm"
- texttooltip=""
- />
- </div>
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-size"
- icon="disk"
- link=""
- size="s"
- text="800.00 KiB"
- texttooltip=""
- />
- </div>
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-last-downloaded-at"
- icon="download"
- link=""
- size="m"
- text="Last downloaded Aug 17, 2021"
- texttooltip=""
- />
- </div>
- </div>
- </div>
-
- <!---->
- </div>
-
- <p />
-</div>
-`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap
index b2375da7b11..b4ea6543446 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap
@@ -20,7 +20,7 @@ exports[`PypiInstallation renders all the messages 1`] = `
<!---->
<button
aria-expanded="false"
- aria-haspopup="true"
+ aria-haspopup="menu"
class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle"
id="__BVID__27__BV_toggle_"
type="button"
@@ -42,7 +42,7 @@ exports[`PypiInstallation renders all the messages 1`] = `
role="img"
>
<use
- href="#chevron-down"
+ href="file-mock#chevron-down"
/>
</svg>
</button>
@@ -59,7 +59,6 @@ exports[`PypiInstallation renders all the messages 1`] = `
</div>
<fieldset
- aria-describedby="installation-pip-command-group__BV_description_"
class="form-group gl-form-group"
id="installation-pip-command-group"
>
@@ -75,12 +74,7 @@ exports[`PypiInstallation renders all the messages 1`] = `
<!---->
</legend>
- <div
- aria-labelledby="installation-pip-command-group__BV_label_"
- class="bv-no-focus-ring"
- role="group"
- tabindex="-1"
- >
+ <div>
<div
data-testid="pip-command"
id="installation-pip-command"
@@ -128,7 +122,7 @@ exports[`PypiInstallation renders all the messages 1`] = `
role="img"
>
<use
- href="#copy-to-clipboard"
+ href="file-mock#copy-to-clipboard"
/>
</svg>
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 4f3d780b149..2e59c27cc1b 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
@@ -65,11 +65,6 @@ describe('Package Additional metadata', () => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findTitle = () => wrapper.findByTestId('title');
const findMainArea = () => wrapper.findByTestId('main');
const findComponentIs = () => wrapper.findByTestId('component-is');
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js
index 0aba8f7efc7..a6298ebdea7 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js
@@ -34,10 +34,6 @@ describe('ComposerInstallation', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('install command switch', () => {
it('has the installation title component', () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js
index bf9425def9a..70534b1d0a6 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js
@@ -33,10 +33,6 @@ describe('ConanInstallation', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders all the messages', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js
index 9aed5b90c73..19aedf120b2 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js
@@ -19,10 +19,6 @@ describe('DependencyRow', () => {
const dependencyVersion = () => wrapper.findByTestId('version-pattern');
const dependencyFramework = () => wrapper.findByTestId('target-framework');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('renders', () => {
it('full dependency', () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js
index feed7a7c46c..a9428773a60 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js
@@ -23,10 +23,6 @@ describe('FileSha', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders', () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js
index 5fe795f768e..a2d30be13c2 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js
@@ -20,10 +20,6 @@ describe('InstallationTitle', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has a title', () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js
index 8bb05b00e65..d35d95e319f 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js
@@ -40,10 +40,6 @@ describe('InstallationCommands', () => {
const pypiInstallation = () => wrapper.findComponent(PypiInstallation);
const composerInstallation = () => wrapper.findComponent(ComposerInstallation);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('installation instructions', () => {
describe.each`
packageEntity | selector
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js
index fc60039db30..5ea81dccf7d 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js
@@ -35,7 +35,7 @@ describe('MavenInstallation', () => {
<artifactId>appName</artifactId>
<version>appVersion</version>
</dependency>`;
- const mavenCommandStr = 'mvn dependency:get -Dartifact=appGroup:appName:appVersion';
+ const mavenCommandStr = 'mvn install';
const mavenSetupXml = `<repositories>
<repository>
<id>gitlab-maven</id>
@@ -79,10 +79,6 @@ describe('MavenInstallation', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('install command switch', () => {
it('has the installation title component', () => {
createComponent();
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
index bb6846d354f..f2f3b8507c3 100644
--- 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
@@ -18,11 +18,6 @@ describe('Composer Metadata', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findComposerTargetSha = () => wrapper.findByTestId('composer-target-sha');
const findComposerTargetShaCopyButton = () => wrapper.findComponent(ClipboardButton);
const findComposerJson = () => wrapper.findByTestId('composer-json');
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
index e7e47401aa1..2832dc3a712 100644
--- 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
@@ -19,11 +19,6 @@ describe('Conan Metadata', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findConanRecipe = () => wrapper.findByTestId('conan-recipe');
beforeEach(() => {
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
index 8680d983042..7b253a26fc7 100644
--- 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
@@ -19,11 +19,6 @@ describe('Maven Metadata', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findMavenApp = () => wrapper.findByTestId('maven-app');
const findMavenGroup = () => wrapper.findByTestId('maven-group');
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
index af3692023f0..9fb467f9af1 100644
--- 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
@@ -19,11 +19,6 @@ describe('Nuget Metadata', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findNugetSource = () => wrapper.findByTestId('nuget-source');
const findNugetLicense = () => wrapper.findByTestId('nuget-license');
const findElementLink = (container) => container.findComponent(GlLink);
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
index d7c6ea8379d..67f5fbc9e80 100644
--- 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
@@ -20,11 +20,6 @@ describe('Package Additional Metadata', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findPypiRequiredPython = () => wrapper.findByTestId('pypi-required-python');
beforeEach(() => {
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js
index 8c0e2d948ca..e711f9ee45d 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js
@@ -51,10 +51,6 @@ describe('NpmInstallation', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders all the messages', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js
index 9449c40c7c6..bcc0b78bfce 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js
@@ -36,10 +36,6 @@ describe('NugetInstallation', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders all the messages', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
index 529a6a22ddf..1dcac017ccf 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
@@ -48,10 +48,6 @@ describe('Package Files', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('rows', () => {
it('renders a single file for an npm package', () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js
index bb2fa9eb6f5..ed470f63b8a 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js
@@ -63,11 +63,6 @@ describe('Package History', () => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findPackageHistoryLoader = () => wrapper.findComponent(PackageHistoryLoader);
const findHistoryElement = (testId) => wrapper.findByTestId(testId);
const findElementLink = (container) => container.findComponent(GlLink);
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 1fda77f2aaa..fc0ca0e898f 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
@@ -38,7 +38,7 @@ describe('PackageTitle', () => {
},
provide,
directives: {
- GlResizeObserver: createMockDirective(),
+ GlResizeObserver: createMockDirective('gl-resize-observer'),
},
});
await nextTick();
@@ -55,21 +55,17 @@ describe('PackageTitle', () => {
const findSubHeaderText = () => wrapper.findByTestId('sub-header');
const findSubHeaderTimeAgo = () => wrapper.findComponent(TimeAgoTooltip);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('renders', () => {
it('without tags', async () => {
await createComponent({ ...packageData(), packageFiles: { nodes: packageFiles() } });
- expect(wrapper.element).toMatchSnapshot();
+ expect(findPackageTags().exists()).toBe(false);
});
it('with tags', async () => {
await createComponent();
- expect(wrapper.element).toMatchSnapshot();
+ expect(findPackageTags().exists()).toBe(true);
});
it('with tags on mobile', async () => {
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
index 27c0ab96cfc..e9f2a2c5095 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
@@ -1,5 +1,11 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
+import waitForPromises from 'helpers/wait_for_promises';
import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
@@ -7,39 +13,53 @@ import RegistryList from '~/packages_and_registries/shared/components/registry_l
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
import Tracking from '~/tracking';
import {
+ CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ DELETE_PACKAGE_VERSION_TRACKING_ACTION,
DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ GRAPHQL_PAGE_SIZE,
} from '~/packages_and_registries/package_registry/constants';
-import { packageData } from '../../mock_data';
+import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql//queries/get_package_versions.query.graphql';
+import {
+ emptyPackageVersionsQuery,
+ packageVersionsQuery,
+ packageVersions,
+ pagination,
+} from '../../mock_data';
+
+Vue.use(VueApollo);
describe('PackageVersionsList', () => {
let wrapper;
+ let apolloProvider;
const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>empty message</div>' };
- const packageList = [
- packageData({
- name: 'version 1',
- }),
- packageData({
- id: `gid://gitlab/Packages::Package/112`,
- name: 'version 2',
- }),
- ];
const uiElements = {
+ findAlert: () => wrapper.findComponent(GlAlert),
findLoader: () => wrapper.findComponent(PackagesListLoader),
findRegistryList: () => wrapper.findComponent(RegistryList),
findEmptySlot: () => wrapper.findComponent(EmptySlotStub),
- findListRow: () => wrapper.findAllComponents(VersionRow),
+ findListRow: () => wrapper.findComponent(VersionRow),
+ findAllListRow: () => wrapper.findAllComponents(VersionRow),
findDeletePackagesModal: () => wrapper.findComponent(DeleteModal),
};
- const mountComponent = (props) => {
+
+ const mountComponent = ({
+ props = {},
+ resolver = jest.fn().mockResolvedValue(packageVersionsQuery()),
+ } = {}) => {
+ const requestHandlers = [[getPackageVersionsQuery, resolver]];
+ apolloProvider = createMockApollo(requestHandlers);
+
wrapper = shallowMountExtended(PackageVersionsList, {
+ apolloProvider,
propsData: {
- versions: packageList,
- pageInfo: {},
- isLoading: false,
+ packageId: packageVersionsQuery().data.package.id,
+ isMutationLoading: false,
+ count: packageVersions().length,
...props,
},
stubs: {
@@ -56,9 +76,13 @@ describe('PackageVersionsList', () => {
});
};
+ beforeEach(() => {
+ jest.spyOn(Sentry, 'captureException').mockImplementation();
+ });
+
describe('when list is loading', () => {
beforeEach(() => {
- mountComponent({ isLoading: true, versions: [] });
+ mountComponent({ props: { isMutationLoading: true } });
});
it('displays loader', () => {
expect(uiElements.findLoader().exists()).toBe(true);
@@ -75,11 +99,24 @@ describe('PackageVersionsList', () => {
it('does not display registry list', () => {
expect(uiElements.findRegistryList().exists()).toBe(false);
});
+
+ it('does not display alert', () => {
+ expect(uiElements.findAlert().exists()).toBe(false);
+ });
});
describe('when list is loaded and has no data', () => {
- beforeEach(() => {
- mountComponent({ isLoading: false, versions: [] });
+ const resolver = jest.fn().mockResolvedValue(emptyPackageVersionsQuery);
+ beforeEach(async () => {
+ mountComponent({
+ props: { isMutationLoading: false, count: 0 },
+ resolver,
+ });
+ await waitForPromises();
+ });
+
+ it('skips graphql query', () => {
+ expect(resolver).not.toHaveBeenCalled();
});
it('displays empty slot message', () => {
@@ -97,11 +134,44 @@ describe('PackageVersionsList', () => {
it('does not display registry list', () => {
expect(uiElements.findRegistryList().exists()).toBe(false);
});
+
+ it('does not display alert', () => {
+ expect(uiElements.findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('if load fails, alert', () => {
+ beforeEach(async () => {
+ mountComponent({ resolver: jest.fn().mockRejectedValue() });
+
+ await waitForPromises();
+ });
+
+ it('is displayed', () => {
+ expect(uiElements.findAlert().exists()).toBe(true);
+ });
+
+ it('shows error message', () => {
+ expect(uiElements.findAlert().text()).toMatchInterpolatedText('Failed to load version data');
+ });
+
+ it('is not dismissible', () => {
+ expect(uiElements.findAlert().props('dismissible')).toBe(false);
+ });
+
+ it('is of variant danger', () => {
+ expect(uiElements.findAlert().attributes('variant')).toBe('danger');
+ });
+
+ it('error is logged in sentry', () => {
+ expect(Sentry.captureException).toHaveBeenCalled();
+ });
});
describe('when list is loaded with data', () => {
- beforeEach(() => {
+ beforeEach(async () => {
mountComponent();
+ await waitForPromises();
});
it('displays package registry list', () => {
@@ -110,7 +180,7 @@ describe('PackageVersionsList', () => {
it('binds the right props', () => {
expect(uiElements.findRegistryList().props()).toMatchObject({
- items: packageList,
+ items: packageVersions(),
pagination: {},
isLoading: false,
hiddenDelete: true,
@@ -118,17 +188,17 @@ describe('PackageVersionsList', () => {
});
it('displays package version rows', () => {
- expect(uiElements.findListRow().exists()).toEqual(true);
- expect(uiElements.findListRow()).toHaveLength(packageList.length);
+ expect(uiElements.findAllListRow().exists()).toEqual(true);
+ expect(uiElements.findAllListRow()).toHaveLength(packageVersions().length);
});
it('binds the correct props', () => {
- expect(uiElements.findListRow().at(0).props()).toMatchObject({
- packageEntity: expect.objectContaining(packageList[0]),
+ expect(uiElements.findAllListRow().at(0).props()).toMatchObject({
+ packageEntity: expect.objectContaining(packageVersions()[0]),
});
- expect(uiElements.findListRow().at(1).props()).toMatchObject({
- packageEntity: expect.objectContaining(packageList[1]),
+ expect(uiElements.findAllListRow().at(1).props()).toMatchObject({
+ packageEntity: expect.objectContaining(packageVersions()[1]),
});
});
@@ -142,20 +212,94 @@ describe('PackageVersionsList', () => {
});
describe('when user interacts with pagination', () => {
- beforeEach(() => {
- mountComponent({ pageInfo: { hasNextPage: true } });
+ const resolver = jest.fn().mockResolvedValue(packageVersionsQuery());
+
+ beforeEach(async () => {
+ mountComponent({ resolver });
+ await waitForPromises();
+ });
+
+ it('when list emits next-page fetches the next set of records', async () => {
+ uiElements.findRegistryList().vm.$emit('next-page');
+ await waitForPromises();
+
+ expect(resolver).toHaveBeenLastCalledWith(
+ expect.objectContaining({ after: pagination().endCursor, first: GRAPHQL_PAGE_SIZE }),
+ );
});
- it('emits prev-page event when registry list emits prev event', () => {
+ it('when list emits prev-page fetches the prev set of records', async () => {
uiElements.findRegistryList().vm.$emit('prev-page');
+ await waitForPromises();
- expect(wrapper.emitted('prev-page')).toHaveLength(1);
+ expect(resolver).toHaveBeenLastCalledWith(
+ expect.objectContaining({ before: pagination().startCursor, last: GRAPHQL_PAGE_SIZE }),
+ );
});
+ });
- it('emits next-page when registry list emits next event', () => {
- uiElements.findRegistryList().vm.$emit('next-page');
+ describe.each`
+ description | finderFunction | deletePayload
+ ${'when the user can destroy the package'} | ${uiElements.findListRow} | ${packageVersions()[0]}
+ ${'when the user can bulk destroy packages and deletes only one package'} | ${uiElements.findRegistryList} | ${[packageVersions()[0]]}
+ `('$description', ({ finderFunction, deletePayload }) => {
+ let eventSpy;
+ const category = 'UI::NpmPackages';
+ const { findDeletePackagesModal } = uiElements;
+
+ beforeEach(async () => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ mountComponent({ props: { canDestroy: true } });
+ await waitForPromises();
+ finderFunction().vm.$emit('delete', deletePayload);
+ });
+
+ it('passes itemsToBeDeleted to the modal', () => {
+ expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual([
+ packageVersions()[0],
+ ]);
+ });
+
+ it('requesting delete tracks the right action', () => {
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
+ expect.any(Object),
+ );
+ });
+
+ describe('when modal confirms', () => {
+ beforeEach(() => {
+ findDeletePackagesModal().vm.$emit('confirm');
+ });
+
+ it('emits delete when modal confirms', () => {
+ expect(wrapper.emitted('delete')[0][0]).toEqual([packageVersions()[0]]);
+ });
- expect(wrapper.emitted('next-page')).toHaveLength(1);
+ it('tracks the right action', () => {
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ DELETE_PACKAGE_VERSION_TRACKING_ACTION,
+ expect.any(Object),
+ );
+ });
+ });
+
+ it.each(['confirm', 'cancel'])('resets itemsToBeDeleted when modal emits %s', async (event) => {
+ await findDeletePackagesModal().vm.$emit(event);
+
+ expect(findDeletePackagesModal().props('itemsToBeDeleted')).toEqual([]);
+ });
+
+ it('canceling delete tracks the right action', () => {
+ findDeletePackagesModal().vm.$emit('cancel');
+
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
+ expect.any(Object),
+ );
});
});
@@ -163,14 +307,15 @@ describe('PackageVersionsList', () => {
let eventSpy;
const { findDeletePackagesModal, findRegistryList } = uiElements;
- beforeEach(() => {
+ beforeEach(async () => {
eventSpy = jest.spyOn(Tracking, 'event');
- mountComponent({ canDestroy: true });
+ mountComponent({ props: { canDestroy: true } });
+ await waitForPromises();
});
it('binds the right props', () => {
expect(uiElements.findRegistryList().props()).toMatchObject({
- items: packageList,
+ items: packageVersions(),
pagination: {},
isLoading: false,
hiddenDelete: false,
@@ -180,11 +325,13 @@ describe('PackageVersionsList', () => {
describe('upon deletion', () => {
beforeEach(() => {
- findRegistryList().vm.$emit('delete', packageList);
+ findRegistryList().vm.$emit('delete', packageVersions());
});
it('passes itemsToBeDeleted to the modal', () => {
- expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual(packageList);
+ expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual(
+ packageVersions(),
+ );
expect(wrapper.emitted('delete')).toBeUndefined();
});
@@ -202,7 +349,7 @@ describe('PackageVersionsList', () => {
});
it('emits delete event', () => {
- expect(wrapper.emitted('delete')[0]).toEqual([packageList]);
+ expect(wrapper.emitted('delete')[0]).toEqual([packageVersions()]);
});
it('tracks the right action', () => {
@@ -234,4 +381,15 @@ describe('PackageVersionsList', () => {
});
});
});
+
+ describe('with isRequestForwardingEnabled prop', () => {
+ const { findDeletePackagesModal } = uiElements;
+
+ it.each([true, false])('sets modal prop showRequestForwardingContent to %s', async (value) => {
+ mountComponent({ props: { isRequestForwardingEnabled: value } });
+ await waitForPromises();
+
+ expect(findDeletePackagesModal().props('showRequestForwardingContent')).toBe(value);
+ });
+ });
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js
index 4a27f8011df..3f4358bb3b0 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js
@@ -29,10 +29,13 @@ password = <your personal access token>`;
const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
const findSetupDocsLink = () => wrapper.findByTestId('pypi-docs-link');
- function createComponent() {
+ function createComponent(props = {}) {
wrapper = mountExtended(PypiInstallation, {
propsData: {
- packageEntity,
+ packageEntity: {
+ ...packageEntity,
+ ...props,
+ },
},
stubs: {
GlSprintf,
@@ -44,10 +47,6 @@ password = <your personal access token>`;
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('install command switch', () => {
it('has the installation title component', () => {
expect(findInstallationTitle().exists()).toBe(true);
@@ -86,6 +85,12 @@ password = <your personal access token>`;
});
});
+ it('does not have a link to personal access token docs when package is public', () => {
+ createComponent({ publicPackage: true });
+
+ expect(findAccessTokenLink().exists()).toBe(false);
+ });
+
it('has a link to the docs', () => {
expect(findSetupDocsLink().attributes()).toMatchObject({
href: PYPI_HELP_PATH,
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
index 67340822fa5..f7c8e909ff6 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
@@ -1,4 +1,4 @@
-import { GlFormCheckbox, GlIcon, GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
+import { GlDropdownItem, GlFormCheckbox, GlIcon, GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
@@ -24,6 +24,7 @@ describe('VersionRow', () => {
const findPackageName = () => wrapper.findComponent(GlTruncate);
const findWarningIcon = () => wrapper.findComponent(GlIcon);
const findBulkDeleteAction = () => wrapper.findComponent(GlFormCheckbox);
+ const findDeleteDropdownItem = () => wrapper.findComponent(GlDropdownItem);
function createComponent({ packageEntity = packageVersion, selected = false } = {}) {
wrapper = shallowMountExtended(VersionRow, {
@@ -36,15 +37,11 @@ describe('VersionRow', () => {
GlTruncate,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has a link to the version detail', () => {
createComponent();
@@ -112,6 +109,31 @@ describe('VersionRow', () => {
});
});
+ describe('delete button', () => {
+ it('does not exist when package cannot be destroyed', () => {
+ createComponent({ packageEntity: { ...packageVersion, canDestroy: false } });
+
+ expect(findDeleteDropdownItem().exists()).toBe(false);
+ });
+
+ it('exists and has the correct props', () => {
+ createComponent();
+
+ expect(findDeleteDropdownItem().exists()).toBe(true);
+ expect(findDeleteDropdownItem().attributes()).toMatchObject({
+ variant: 'danger',
+ });
+ });
+
+ it('emits the delete event when the delete button is clicked', () => {
+ createComponent();
+
+ findDeleteDropdownItem().vm.$emit('click');
+
+ expect(wrapper.emitted('delete')).toHaveLength(1);
+ });
+ });
+
describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/packages_and_registries/package_registry/components/functional/delete_packages_spec.js b/spec/frontend/packages_and_registries/package_registry/components/functional/delete_packages_spec.js
index 689b53fa2a4..04546c4cea4 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/functional/delete_packages_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/functional/delete_packages_spec.js
@@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue';
import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql';
@@ -14,7 +14,7 @@ import {
packagesListQuery,
} from '../../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('DeletePackages', () => {
let wrapper;
@@ -67,10 +67,6 @@ describe('DeletePackages', () => {
mutationResolver = jest.fn().mockResolvedValue(packagesDestroyMutation());
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('binds deletePackages method to the default slot', () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
index ec8e77fa923..c647230bc5f 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
@@ -66,11 +66,12 @@ exports[`packages_list_row renders 1`] = `
<!---->
- <package-icon-and-name-stub>
-
- npm
-
- </package-icon-and-name-stub>
+ <span
+ class="gl-ml-2"
+ data-testid="package-type"
+ >
+ · npm
+ </span>
<!---->
</div>
@@ -95,6 +96,7 @@ exports[`packages_list_row renders 1`] = `
Created
<timeago-tooltip-stub
cssclass=""
+ datetimeformat="DATE_WITH_TIME_FORMAT"
time="2020-08-17T14:23:32Z"
tooltipplacement="top"
/>
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
index 2a78cfb13f9..81ad47b1e13 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
@@ -1,5 +1,5 @@
import { GlFormCheckbox, GlSprintf, GlTruncate } from '@gitlab/ui';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import VueRouter from 'vue-router';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -9,7 +9,6 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
-import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue';
import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/package_registry/constants';
@@ -39,7 +38,7 @@ describe('packages_list_row', () => {
const findPackageTags = () => wrapper.findComponent(PackageTags);
const findPackagePath = () => wrapper.findComponent(PackagePath);
const findDeleteDropdown = () => wrapper.findByTestId('action-delete');
- const findPackageIconAndName = () => wrapper.findComponent(PackageIconAndName);
+ const findPackageType = () => wrapper.findByTestId('package-type');
const findPackageLink = () => wrapper.findByTestId('details-link');
const findWarningIcon = () => wrapper.findByTestId('warning-icon');
const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos');
@@ -67,15 +66,11 @@ describe('packages_list_row', () => {
selected,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders', () => {
mountComponent();
expect(wrapper.element).toMatchSnapshot();
@@ -136,12 +131,11 @@ describe('packages_list_row', () => {
});
});
- it('emits the delete event when the delete button is clicked', async () => {
+ it('emits the delete event when the delete button is clicked', () => {
mountComponent({ packageEntity: packageWithoutTags });
findDeleteDropdown().vm.$emit('click');
- await nextTick();
expect(wrapper.emitted('delete')).toHaveLength(1);
});
});
@@ -237,10 +231,10 @@ describe('packages_list_row', () => {
expect(findLeftSecondaryInfos().text()).toContain('published by Administrator');
});
- it('has icon and name component', () => {
+ it('has package type with middot', () => {
mountComponent();
- expect(findPackageIconAndName().text()).toBe(packageWithoutTags.packageType.toLowerCase());
+ expect(findPackageType().text()).toBe(`· ${packageWithoutTags.packageType.toLowerCase()}`);
});
});
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
index 610640e0ca3..483b7a9383d 100644
--- 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
@@ -4,7 +4,6 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
-import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import {
@@ -17,7 +16,7 @@ import {
} from '~/packages_and_registries/package_registry/constants';
import PackagesList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
import Tracking from '~/tracking';
-import { packageData } from '../../mock_data';
+import { defaultPackageGroupSettings, packageData } from '../../mock_data';
describe('packages_list', () => {
let wrapper;
@@ -39,18 +38,20 @@ describe('packages_list', () => {
list: [firstPackage, secondPackage],
isLoading: false,
pageInfo: {},
+ groupSettings: defaultPackageGroupSettings,
};
const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' };
const findPackagesListLoader = () => wrapper.findComponent(PackagesListLoader);
- const findPackageListDeleteModal = () => wrapper.findComponent(DeletePackageModal);
const findEmptySlot = () => wrapper.findComponent(EmptySlotStub);
const findRegistryList = () => wrapper.findComponent(RegistryList);
const findPackagesListRow = () => wrapper.findComponent(PackagesListRow);
const findErrorPackageAlert = () => wrapper.findComponent(GlAlert);
const findDeletePackagesModal = () => wrapper.findComponent(DeleteModal);
+ const showMock = jest.fn();
+
const mountComponent = (props) => {
wrapper = shallowMountExtended(PackagesList, {
propsData: {
@@ -58,10 +59,9 @@ describe('packages_list', () => {
...props,
},
stubs: {
- DeletePackageModal,
DeleteModal: stubComponent(DeleteModal, {
methods: {
- show: jest.fn(),
+ show: showMock,
},
}),
GlSprintf,
@@ -73,10 +73,6 @@ describe('packages_list', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when is loading', () => {
beforeEach(() => {
mountComponent({ isLoading: true });
@@ -123,15 +119,20 @@ describe('packages_list', () => {
});
describe('layout', () => {
- it("doesn't contain a visible modal component", () => {
+ beforeEach(() => {
mountComponent();
+ });
- expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull();
+ it('modal component is not shown', () => {
+ expect(showMock).not.toHaveBeenCalled();
});
- it('does not have an error alert displayed', () => {
- mountComponent();
+ it('modal component props is empty', () => {
+ expect(findDeletePackagesModal().props('itemsToBeDeleted')).toEqual([]);
+ expect(findDeletePackagesModal().props('showRequestForwardingContent')).toBe(false);
+ });
+ it('does not have an error alert displayed', () => {
expect(findErrorPackageAlert().exists()).toBe(false);
});
});
@@ -150,8 +151,8 @@ describe('packages_list', () => {
finderFunction().vm.$emit('delete', deletePayload);
});
- it('passes itemToBeDeleted to the modal', () => {
- expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(firstPackage);
+ it('passes itemsToBeDeleted to the modal', () => {
+ expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual([firstPackage]);
});
it('requesting delete tracks the right action', () => {
@@ -162,9 +163,13 @@ describe('packages_list', () => {
);
});
+ it('modal component is shown', () => {
+ expect(showMock).toHaveBeenCalledTimes(1);
+ });
+
describe('when modal confirms', () => {
beforeEach(() => {
- findPackageListDeleteModal().vm.$emit('ok');
+ findDeletePackagesModal().vm.$emit('confirm');
});
it('emits delete when modal confirms', () => {
@@ -180,14 +185,14 @@ describe('packages_list', () => {
});
});
- it.each(['ok', 'cancel'])('resets itemToBeDeleted when modal emits %s', async (event) => {
- await findPackageListDeleteModal().vm.$emit(event);
+ it.each(['confirm', 'cancel'])('resets itemsToBeDeleted when modal emits %s', async (event) => {
+ await findDeletePackagesModal().vm.$emit(event);
- expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull();
+ expect(findDeletePackagesModal().props('itemsToBeDeleted')).toEqual([]);
});
it('canceling delete tracks the right action', () => {
- findPackageListDeleteModal().vm.$emit('cancel');
+ findDeletePackagesModal().vm.$emit('cancel');
expect(eventSpy).toHaveBeenCalledWith(
category,
@@ -241,7 +246,7 @@ describe('packages_list', () => {
it.each(['confirm', 'cancel'])('resets itemsToBeDeleted when modal emits %s', async (event) => {
await findDeletePackagesModal().vm.$emit(event);
- expect(findDeletePackagesModal().props('itemsToBeDeleted')).toHaveLength(0);
+ expect(findDeletePackagesModal().props('itemsToBeDeleted')).toEqual([]);
});
it('canceling delete tracks the right action', () => {
@@ -262,7 +267,7 @@ describe('packages_list', () => {
return nextTick();
});
- it('should display an alert message', () => {
+ it('should display an alert', () => {
expect(findErrorPackageAlert().exists()).toBe(true);
expect(findErrorPackageAlert().props('title')).toBe(
'There was an error publishing a error package package',
@@ -277,7 +282,9 @@ describe('packages_list', () => {
await nextTick();
- expect(findPackageListDeleteModal().text()).toContain(errorPackage.name);
+ expect(showMock).toHaveBeenCalledTimes(1);
+
+ expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual([errorPackage]);
});
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
index a884959ab62..82fa5b76367 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
@@ -46,10 +46,6 @@ describe('Package Search', () => {
extractFilterAndSorting.mockReturnValue(defaultQueryParamsMock);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has a registry search component', async () => {
mountComponent();
@@ -58,7 +54,7 @@ describe('Package Search', () => {
expect(findRegistrySearch().exists()).toBe(true);
});
- it('registry search is mounted after mount', async () => {
+ it('registry search is mounted after mount', () => {
mountComponent();
expect(findRegistrySearch().exists()).toBe(false);
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
index b47515e15c3..1296458155a 100644
--- 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
@@ -20,11 +20,6 @@ describe('PackageTitle', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('title area', () => {
it('exists', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js
index fcbd7cc6a50..e9119b736c2 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js
@@ -19,10 +19,6 @@ describe('publish_method', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders', () => {
mountComponent();
expect(wrapper.element).toMatchSnapshot();
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
index 8f3c8667c47..c98f5f32344 100644
--- 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
@@ -19,11 +19,6 @@ describe('packages_filter', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('binds all of his attrs to filtered search token', () => {
mountComponent({ attrs: { foo: 'bar' } });
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 d897be1f344..5fb53566d4e 100644
--- a/spec/frontend/packages_and_registries/package_registry/mock_data.js
+++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js
@@ -16,7 +16,7 @@ export const packagePipelines = (extend) => [
ref: 'master',
sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
project: {
- id: '1',
+ id: '14',
name: 'project14',
webUrl: 'http://gdk.test:3000/namespace14/project14',
__typename: 'Project',
@@ -103,12 +103,20 @@ export const linksData = {
},
};
+export const defaultPackageGroupSettings = {
+ mavenPackageRequestsForwarding: true,
+ npmPackageRequestsForwarding: true,
+ pypiPackageRequestsForwarding: true,
+ __typename: 'PackageSettings',
+};
+
export const packageVersions = () => [
{
createdAt: '2021-08-10T09:33:54Z',
id: 'gid://gitlab/Packages::Package/243',
name: '@gitlab-org/package-15',
status: 'DEFAULT',
+ packageType: 'NPM',
canDestroy: true,
tags: { nodes: packageTags() },
version: '1.0.1',
@@ -120,6 +128,7 @@ export const packageVersions = () => [
id: 'gid://gitlab/Packages::Package/244',
name: '@gitlab-org/package-15',
status: 'DEFAULT',
+ packageType: 'NPM',
canDestroy: true,
tags: { nodes: packageTags() },
version: '1.0.2',
@@ -130,7 +139,7 @@ export const packageVersions = () => [
export const packageData = (extend) => ({
__typename: 'Package',
- id: 'gid://gitlab/Packages::Package/111',
+ id: 'gid://gitlab/Packages::Package/1',
canDestroy: true,
name: '@gitlab-org/package-15',
packageType: 'NPM',
@@ -147,6 +156,7 @@ export const packageData = (extend) => ({
conanUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/conan',
pypiUrl:
'http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple',
+ publicPackage: false,
pypiSetupUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/pypi',
...extend,
});
@@ -209,7 +219,10 @@ export const pagination = (extend) => ({
...extend,
});
-export const packageDetailsQuery = (extendPackage) => ({
+export const packageDetailsQuery = ({
+ extendPackage = {},
+ packageSettings = defaultPackageGroupSettings,
+} = {}) => ({
data: {
package: {
...packageData(),
@@ -225,6 +238,12 @@ export const packageDetailsQuery = (extendPackage) => ({
path: 'projectPath',
name: 'gitlab-test',
fullPath: 'gitlab-test',
+ group: {
+ id: '1',
+ packageSettings,
+ __typename: 'Group',
+ },
+ __typename: 'Project',
},
tags: {
nodes: packageTags(),
@@ -243,14 +262,6 @@ export const packageDetailsQuery = (extendPackage) => ({
},
versions: {
count: packageVersions().length,
- nodes: packageVersions(),
- pageInfo: {
- hasNextPage: true,
- hasPreviousPage: false,
- endCursor: 'endCursor',
- startCursor: 'startCursor',
- },
- __typename: 'PackageConnection',
},
dependencyLinks: {
nodes: dependencyLinks(),
@@ -297,6 +308,41 @@ export const packageMetadataQuery = (packageType) => {
};
};
+export const packageVersionsQuery = (versions = packageVersions()) => ({
+ data: {
+ package: {
+ id: 'gid://gitlab/Packages::Package/111',
+ versions: {
+ count: versions.length,
+ nodes: versions,
+ pageInfo: pagination(),
+ __typename: 'PackageConnection',
+ },
+ __typename: 'PackageDetailsType',
+ },
+ },
+});
+
+export const emptyPackageVersionsQuery = {
+ data: {
+ package: {
+ id: 'gid://gitlab/Packages::Package/111',
+ versions: {
+ count: 0,
+ nodes: [],
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ endCursor: 'endCursor',
+ startCursor: 'startCursor',
+ },
+ __typename: 'PackageConnection',
+ },
+ __typename: 'PackageDetailsType',
+ },
+ },
+};
+
export const packagesDestroyMutation = () => ({
data: {
destroyPackages: {
@@ -351,7 +397,12 @@ export const packageDestroyFilesMutationError = () => ({
],
});
-export const packagesListQuery = ({ type = 'group', extend = {}, extendPagination = {} } = {}) => ({
+export const packagesListQuery = ({
+ type = 'group',
+ extend = {},
+ extendPagination = {},
+ packageSettings = defaultPackageGroupSettings,
+} = {}) => ({
data: {
[type]: {
id: '1',
@@ -378,6 +429,14 @@ export const packagesListQuery = ({ type = 'group', extend = {}, extendPaginatio
pageInfo: pagination(extendPagination),
__typename: 'PackageConnection',
},
+ ...(type === 'group' && { packageSettings }),
+ ...(type === 'project' && {
+ group: {
+ id: '1',
+ packageSettings,
+ __typename: 'Group',
+ },
+ }),
...extend,
__typename: capitalize(type),
},
diff --git a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js
index b494965a3cb..0962b4fa757 100644
--- a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js
@@ -1,4 +1,4 @@
-import { GlEmptyState, GlTabs, GlTab, GlSprintf } from '@gitlab/ui';
+import { GlEmptyState, GlModal, GlTabs, GlTab, GlSprintf, GlLink } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@@ -6,8 +6,8 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
-
+import { createAlert } from '~/alert';
+import { stubComponent } from 'helpers/stub_component';
import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue';
import PackagesApp from '~/packages_and_registries/package_registry/pages/details.vue';
import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue';
@@ -18,6 +18,7 @@ import PackageTitle from '~/packages_and_registries/package_registry/components/
import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue';
import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue';
import {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
PACKAGE_TYPE_COMPOSER,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
@@ -33,6 +34,7 @@ import {
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
+import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql//queries/get_package_versions.query.graphql';
import {
packageDetailsQuery,
packageData,
@@ -42,12 +44,14 @@ import {
packageFiles,
packageDestroyFilesMutation,
packageDestroyFilesMutationError,
- pagination,
+ defaultPackageGroupSettings,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
useMockLocationHelper();
+Vue.use(VueApollo);
+
describe('PackagesApp', () => {
let wrapper;
let apolloProvider;
@@ -57,7 +61,7 @@ describe('PackagesApp', () => {
};
const provide = {
- packageId: '111',
+ packageId: '1',
emptyListIllustration: 'svgPath',
projectListUrl: 'projectListUrl',
groupListUrl: 'groupListUrl',
@@ -66,14 +70,13 @@ describe('PackagesApp', () => {
};
const { __typename, ...packageWithoutTypename } = packageData();
+ const showMock = jest.fn();
function createComponent({
resolver = jest.fn().mockResolvedValue(packageDetailsQuery()),
filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()),
routeId = '1',
} = {}) {
- Vue.use(VueApollo);
-
const requestHandlers = [
[getPackageDetails, resolver],
[destroyPackageFilesMutation, filesDeleteMutationResolver],
@@ -86,17 +89,11 @@ describe('PackagesApp', () => {
stubs: {
PackageTitle,
DeletePackages,
- GlModal: {
- template: `
- <div>
- <slot name="modal-title"></slot>
- <p><slot></slot></p>
- </div>
- `,
+ GlModal: stubComponent(GlModal, {
methods: {
- show: jest.fn(),
+ show: showMock,
},
- },
+ }),
GlSprintf,
GlTabs,
GlTab,
@@ -130,10 +127,7 @@ describe('PackagesApp', () => {
const findDependencyRows = () => wrapper.findAllComponents(DependencyRow);
const findDeletePackageModal = () => wrapper.findAllComponents(DeletePackages).at(1);
const findDeletePackages = () => wrapper.findComponent(DeletePackages);
-
- afterEach(() => {
- wrapper.destroy();
- });
+ const findLink = () => wrapper.findComponent(GlLink);
it('renders an empty state component', async () => {
createComponent({ resolver: jest.fn().mockResolvedValue(emptyPackageDetailsQuery) });
@@ -193,7 +187,9 @@ describe('PackagesApp', () => {
createComponent({
resolver: jest.fn().mockResolvedValue(
packageDetailsQuery({
- packageType,
+ extendPackage: {
+ packageType,
+ },
}),
),
});
@@ -248,16 +244,55 @@ describe('PackagesApp', () => {
});
});
- it('shows the delete confirmation modal when delete is clicked', async () => {
- createComponent();
+ describe('when delete button is clicked', () => {
+ describe('with request forwarding enabled', () => {
+ beforeEach(async () => {
+ const resolver = jest.fn().mockResolvedValue(
+ packageDetailsQuery({
+ packageSettings: {
+ ...defaultPackageGroupSettings,
+ npmPackageRequestsForwarding: true,
+ },
+ }),
+ );
+ createComponent({ resolver });
- await waitForPromises();
+ await waitForPromises();
- await findDeleteButton().trigger('click');
+ await findDeleteButton().trigger('click');
+ });
- expect(findDeleteModal().find('p').text()).toBe(
- 'You are about to delete version 1.0.0 of @gitlab-org/package-15. Are you sure?',
- );
+ it('shows the delete confirmation modal with request forwarding content', () => {
+ expect(findDeleteModal().text()).toBe(
+ 'Deleting this package while request forwarding is enabled for the project can pose a security risk. Do you want to delete @gitlab-org/package-15 version 1.0.0 anyway? What are the risks?',
+ );
+ });
+
+ it('contains link to help page', () => {
+ expect(findLink().exists()).toBe(true);
+ expect(findLink().attributes('href')).toBe(REQUEST_FORWARDING_HELP_PAGE_PATH);
+ });
+ });
+
+ it('shows the delete confirmation modal without request forwarding content', async () => {
+ const resolver = jest.fn().mockResolvedValue(
+ packageDetailsQuery({
+ packageSettings: {
+ ...defaultPackageGroupSettings,
+ npmPackageRequestsForwarding: false,
+ },
+ }),
+ );
+ createComponent({ resolver });
+
+ await waitForPromises();
+
+ await findDeleteButton().trigger('click');
+
+ expect(findDeleteModal().text()).toBe(
+ 'You are about to delete version 1.0.0 of @gitlab-org/package-15. Are you sure?',
+ );
+ });
});
describe('successful request', () => {
@@ -311,7 +346,9 @@ describe('PackagesApp', () => {
createComponent({
resolver: jest
.fn()
- .mockResolvedValue(packageDetailsQuery({ packageType: PACKAGE_TYPE_COMPOSER })),
+ .mockResolvedValue(
+ packageDetailsQuery({ extendPackage: { packageType: PACKAGE_TYPE_COMPOSER } }),
+ ),
});
await waitForPromises();
@@ -322,7 +359,7 @@ describe('PackagesApp', () => {
describe('deleting a file', () => {
const [fileToDelete] = packageFiles();
- const doDeleteFile = async () => {
+ const doDeleteFile = () => {
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
findDeleteFileModal().vm.$emit('primary');
@@ -335,25 +372,33 @@ describe('PackagesApp', () => {
await waitForPromises();
- const showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show');
- const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
-
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
- expect(showDeletePackageSpy).not.toHaveBeenCalled();
- expect(showDeleteFileSpy).toHaveBeenCalled();
+ expect(showMock).toHaveBeenCalledTimes(1);
+
+ await waitForPromises();
+
+ expect(findDeleteFileModal().text()).toBe(
+ 'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?',
+ );
});
it('when its the only file opens delete package confirmation modal', async () => {
const [packageFile] = packageFiles();
const resolver = jest.fn().mockResolvedValue(
packageDetailsQuery({
- packageFiles: {
- pageInfo: {
- hasNextPage: false,
+ extendPackage: {
+ packageFiles: {
+ pageInfo: {
+ hasNextPage: false,
+ },
+ nodes: [packageFile],
+ __typename: 'PackageFileConnection',
},
- nodes: [packageFile],
- __typename: 'PackageFileConnection',
+ },
+ packageSettings: {
+ ...defaultPackageGroupSettings,
+ npmPackageRequestsForwarding: false,
},
}),
);
@@ -364,17 +409,13 @@ describe('PackagesApp', () => {
await waitForPromises();
- const showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show');
- const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
-
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
- expect(showDeletePackageSpy).toHaveBeenCalled();
- expect(showDeleteFileSpy).not.toHaveBeenCalled();
+ expect(showMock).toHaveBeenCalledTimes(1);
await waitForPromises();
- expect(findDeleteModal().find('p').text()).toBe(
+ expect(findDeleteModal().text()).toBe(
'Deleting the last package asset will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?',
);
});
@@ -444,7 +485,7 @@ describe('PackagesApp', () => {
});
describe('deleting multiple files', () => {
- const doDeleteFiles = async () => {
+ const doDeleteFiles = () => {
findPackageFiles().vm.$emit('delete-files', packageFiles());
findDeleteFilesModal().vm.$emit('primary');
@@ -486,6 +527,8 @@ describe('PackagesApp', () => {
await doDeleteFiles();
+ expect(resolver).toHaveBeenCalledTimes(2);
+
expect(createAlert).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
@@ -532,11 +575,17 @@ describe('PackagesApp', () => {
it('opens the delete package confirmation modal', async () => {
const resolver = jest.fn().mockResolvedValue(
packageDetailsQuery({
- packageFiles: {
- pageInfo: {
- hasNextPage: false,
+ extendPackage: {
+ packageFiles: {
+ pageInfo: {
+ hasNextPage: false,
+ },
+ nodes: packageFiles(),
},
- nodes: packageFiles(),
+ },
+ packageSettings: {
+ ...defaultPackageGroupSettings,
+ npmPackageRequestsForwarding: false,
},
}),
);
@@ -546,15 +595,13 @@ describe('PackagesApp', () => {
await waitForPromises();
- const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
-
findPackageFiles().vm.$emit('delete-files', packageFiles());
- expect(showDeletePackageSpy).toHaveBeenCalled();
+ expect(showMock).toHaveBeenCalledTimes(1);
await waitForPromises();
- expect(findDeleteModal().find('p').text()).toBe(
+ expect(findDeleteModal().text()).toBe(
'Deleting all package assets will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?',
);
});
@@ -576,10 +623,10 @@ describe('PackagesApp', () => {
createComponent({
resolver: jest.fn().mockResolvedValue(
packageDetailsQuery({
- versions: {
- count: 0,
- nodes: [],
- pageInfo: pagination({ hasNextPage: false, hasPreviousPage: false }),
+ extendPackage: {
+ versions: {
+ count: 0,
+ },
},
}),
),
@@ -595,61 +642,62 @@ describe('PackagesApp', () => {
});
it('binds the correct props', async () => {
- const versionNodes = packageVersions();
createComponent();
await waitForPromises();
expect(findVersionsList().props()).toMatchObject({
canDestroy: true,
- versions: expect.arrayContaining(versionNodes),
+ count: packageVersions().length,
+ isMutationLoading: false,
+ packageId: 'gid://gitlab/Packages::Package/1',
+ isRequestForwardingEnabled: true,
});
});
describe('delete packages', () => {
- it('exists and has the correct props', async () => {
+ beforeEach(async () => {
createComponent();
-
await waitForPromises();
-
- expect(findDeletePackages().props()).toMatchObject({
- refetchQueries: [{ query: getPackageDetails, variables: {} }],
- showSuccessAlert: true,
- });
});
- it('deletePackages is bound to package-versions-list delete event', async () => {
- createComponent();
-
- await waitForPromises();
+ it('exists and has the correct props', () => {
+ expect(findDeletePackages().props('showSuccessAlert')).toBe(true);
+ expect(findDeletePackages().props('refetchQueries')).toEqual([
+ {
+ query: getPackageVersionsQuery,
+ variables: {
+ first: 20,
+ id: 'gid://gitlab/Packages::Package/1',
+ },
+ },
+ ]);
+ });
+ it('deletePackages is bound to package-versions-list delete event', () => {
findVersionsList().vm.$emit('delete', [{ id: 1 }]);
expect(findDeletePackages().emitted('start')).toEqual([[]]);
});
it('start and end event set loading correctly', async () => {
- createComponent();
-
- await waitForPromises();
-
findDeletePackages().vm.$emit('start');
await nextTick();
- expect(findVersionsList().props('isLoading')).toBe(true);
+ expect(findVersionsList().props('isMutationLoading')).toBe(true);
findDeletePackages().vm.$emit('end');
await nextTick();
- expect(findVersionsList().props('isLoading')).toBe(false);
+ expect(findVersionsList().props('isMutationLoading')).toBe(false);
});
});
});
describe('dependency links', () => {
- it('does not show the dependency links for a non nuget package', async () => {
+ it('does not show the dependency links for a non nuget package', () => {
createComponent();
expect(findDependenciesCountBadge().exists()).toBe(false);
@@ -659,8 +707,10 @@ describe('PackagesApp', () => {
createComponent({
resolver: jest.fn().mockResolvedValue(
packageDetailsQuery({
- packageType: PACKAGE_TYPE_NUGET,
- dependencyLinks: { nodes: [] },
+ extendPackage: {
+ packageType: PACKAGE_TYPE_NUGET,
+ dependencyLinks: { nodes: [] },
+ },
}),
),
});
@@ -676,7 +726,9 @@ describe('PackagesApp', () => {
createComponent({
resolver: jest.fn().mockResolvedValue(
packageDetailsQuery({
- packageType: PACKAGE_TYPE_NUGET,
+ extendPackage: {
+ packageType: PACKAGE_TYPE_NUGET,
+ },
}),
),
});
diff --git a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js
index a2ec527ce12..2ee24200ed3 100644
--- a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js
@@ -1,17 +1,18 @@
-import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { s__ } from '~/locale';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import ListPage from '~/packages_and_registries/package_registry/pages/list.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue';
import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue';
import OriginalPackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue';
import {
- PROJECT_RESOURCE_TYPE,
- GROUP_RESOURCE_TYPE,
GRAPHQL_PAGE_SIZE,
EMPTY_LIST_HELP_URL,
PACKAGE_HELP_URL,
@@ -21,7 +22,7 @@ import getPackagesQuery from '~/packages_and_registries/package_registry/graphql
import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql';
import { packagesListQuery, packageData, pagination } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('PackagesListApp', () => {
let wrapper;
@@ -31,6 +32,7 @@ describe('PackagesListApp', () => {
emptyListIllustration: 'emptyListIllustration',
isGroupPage: true,
fullPath: 'gitlab-org',
+ settingsPath: 'settings-path',
};
const PackageList = {
@@ -50,6 +52,7 @@ describe('PackagesListApp', () => {
const findListComponent = () => wrapper.findComponent(PackageList);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findDeletePackages = () => wrapper.findComponent(DeletePackages);
+ const findSettingsLink = () => wrapper.findComponent(GlButton);
const mountComponent = ({
resolver = jest.fn().mockResolvedValue(packagesListQuery()),
@@ -72,17 +75,17 @@ describe('PackagesListApp', () => {
GlLoadingIcon,
GlSprintf,
GlLink,
+ PackageTitle,
PackageList,
DeletePackages,
},
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
- const waitForFirstRequest = async () => {
+ const waitForFirstRequest = () => {
// emit a search update so the query is executed
findSearch().vm.$emit('update', { sort: 'NAME_DESC', filters: [] });
return waitForPromises();
@@ -108,6 +111,52 @@ describe('PackagesListApp', () => {
});
});
+ describe('link to settings', () => {
+ describe('when settings path is not provided', () => {
+ beforeEach(() => {
+ mountComponent({
+ provide: {
+ ...defaultProvide,
+ settingsPath: '',
+ },
+ });
+ });
+
+ it('is not rendered', () => {
+ expect(findSettingsLink().exists()).toBe(false);
+ });
+ });
+
+ describe('when settings path is provided', () => {
+ const label = s__('PackageRegistry|Configure in settings');
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('is rendered', () => {
+ expect(findSettingsLink().exists()).toBe(true);
+ });
+
+ it('has the right icon', () => {
+ expect(findSettingsLink().props('icon')).toBe('settings');
+ });
+
+ it('has the right attributes', () => {
+ expect(findSettingsLink().attributes()).toMatchObject({
+ 'aria-label': label,
+ href: defaultProvide.settingsPath,
+ });
+ });
+
+ it('sets tooltip with right label', () => {
+ const tooltip = getBinding(findSettingsLink().element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(label);
+ });
+ });
+ });
+
describe('search component', () => {
it('exists', () => {
mountComponent();
@@ -146,6 +195,11 @@ describe('PackagesListApp', () => {
list: expect.arrayContaining([expect.objectContaining({ id: packageData().id })]),
isLoading: false,
pageInfo: expect.objectContaining({ endCursor: pagination().endCursor }),
+ groupSettings: expect.objectContaining({
+ mavenPackageRequestsForwarding: true,
+ npmPackageRequestsForwarding: true,
+ pypiPackageRequestsForwarding: true,
+ }),
});
});
@@ -171,14 +225,14 @@ describe('PackagesListApp', () => {
});
describe.each`
- type | sortType
- ${PROJECT_RESOURCE_TYPE} | ${'sort'}
- ${GROUP_RESOURCE_TYPE} | ${'groupSort'}
+ type | sortType
+ ${WORKSPACE_PROJECT} | ${'sort'}
+ ${WORKSPACE_GROUP} | ${'groupSort'}
`('$type query', ({ type, sortType }) => {
let provide;
let resolver;
- const isGroupPage = type === GROUP_RESOURCE_TYPE;
+ const isGroupPage = type === WORKSPACE_GROUP;
beforeEach(() => {
provide = { ...defaultProvide, isGroupPage };
@@ -196,11 +250,25 @@ describe('PackagesListApp', () => {
expect.objectContaining({ isGroupPage, [sortType]: 'NAME_DESC' }),
);
});
+
+ it('list component has group settings prop set', () => {
+ expect(findListComponent().props()).toMatchObject({
+ groupSettings: expect.objectContaining({
+ mavenPackageRequestsForwarding: true,
+ npmPackageRequestsForwarding: true,
+ pypiPackageRequestsForwarding: true,
+ }),
+ });
+ });
});
- describe('empty state', () => {
+ describe.each`
+ description | resolverResponse
+ ${'empty response'} | ${packagesListQuery({ extend: { nodes: [] } })}
+ ${'error response'} | ${{ data: { group: null } }}
+ `(`$description renders empty state`, ({ resolverResponse }) => {
beforeEach(() => {
- const resolver = jest.fn().mockResolvedValue(packagesListQuery({ extend: { nodes: [] } }));
+ const resolver = jest.fn().mockResolvedValue(resolverResponse);
mountComponent({ resolver });
return waitForFirstRequest();
diff --git a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js
index 796d89231f4..6dd4b9f2d20 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js
@@ -28,7 +28,7 @@ import {
dependencyProxyUpdateTllPolicyMutationMock,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses');
describe('DependencyProxySettings', () => {
@@ -82,10 +82,6 @@ describe('DependencyProxySettings', () => {
.mockResolvedValue(dependencyProxyUpdateTllPolicyMutationMock());
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
const findEnableProxyToggle = () => wrapper.findByTestId('dependency-proxy-setting-toggle');
const findEnableTtlPoliciesToggle = () =>
diff --git a/spec/frontend/packages_and_registries/settings/group/components/exceptions_input_spec.js b/spec/frontend/packages_and_registries/settings/group/components/exceptions_input_spec.js
index 86f14961690..dd1edbaa3fd 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/exceptions_input_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/exceptions_input_spec.js
@@ -23,10 +23,6 @@ describe('Exceptions Input', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findInputGroup = () => wrapper.findComponent(GlFormGroup);
const findInput = () => wrapper.findComponent(GlFormInput);
@@ -102,7 +98,7 @@ describe('Exceptions Input', () => {
});
it('disables the form input', () => {
- expect(findInput().attributes('disabled')).toBe('true');
+ expect(findInput().attributes('disabled')).toBeDefined();
});
});
});
diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
index 7edc321867c..3ce8e91d43d 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
@@ -19,7 +19,7 @@ import {
dependencyProxyImageTtlPolicy,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Group Settings App', () => {
let wrapper;
@@ -55,10 +55,6 @@ describe('Group Settings App', () => {
show = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findAlert = () => wrapper.findComponent(GlAlert);
const findPackageSettings = () => wrapper.findComponent(PackagesSettings);
const findPackageForwardingSettings = () => wrapper.findComponent(PackagesForwardingSettings);
diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js
index 807f332f4d3..49e76cfbae0 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js
@@ -23,7 +23,7 @@ import {
groupPackageSettingsMutationErrorMock,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses');
describe('Packages Settings', () => {
@@ -56,10 +56,6 @@ describe('Packages Settings', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
const findDescription = () => wrapper.findByTestId('description');
const findMavenSettings = () => wrapper.findByTestId('maven-settings');
@@ -181,7 +177,7 @@ describe('Packages Settings', () => {
});
});
- it('renders ExceptionsInput and assigns duplication allowness and exception props', async () => {
+ it('renders ExceptionsInput and assigns duplication allowness and exception props', () => {
mountComponent({ mountFn: mountExtended });
const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings;
@@ -196,7 +192,7 @@ describe('Packages Settings', () => {
});
});
- it('on update event calls the mutation', async () => {
+ it('on update event calls the mutation', () => {
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
mountComponent({ mountFn: mountExtended, mutationResolver });
diff --git a/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js
index a0b257a9496..8a66a685733 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js
@@ -1,12 +1,13 @@
import Vue from 'vue';
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { s__ } from '~/locale';
import component from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue';
import {
- PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
PACKAGE_FORWARDING_SETTINGS_HEADER,
} from '~/packages_and_registries/settings/group/constants';
@@ -25,7 +26,7 @@ import {
mavenProps,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses');
describe('Packages Forwarding Settings', () => {
@@ -60,6 +61,7 @@ describe('Packages Forwarding Settings', () => {
forwardSettings,
},
stubs: {
+ GlSprintf,
SettingsBlock,
},
});
@@ -72,6 +74,7 @@ describe('Packages Forwarding Settings', () => {
const findMavenForwardingSettings = () => wrapper.findByTestId('maven');
const findNpmForwardingSettings = () => wrapper.findByTestId('npm');
const findPyPiForwardingSettings = () => wrapper.findByTestId('pypi');
+ const findRequestForwardingDocsLink = () => wrapper.findComponent(GlLink);
const fillApolloCache = () => {
apolloProvider.defaultClient.cache.writeQuery({
@@ -111,8 +114,18 @@ describe('Packages Forwarding Settings', () => {
it('has the correct description text', () => {
mountComponent();
- expect(findDescription().text()).toMatchInterpolatedText(
- PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
+ expect(findDescription().text()).toBe(
+ s__(
+ 'PackageRegistry|Forward package requests to a public registry if the packages are not found in the GitLab package registry.',
+ ),
+ );
+ });
+
+ it('has the right help link', () => {
+ mountComponent();
+
+ expect(findRequestForwardingDocsLink().attributes('href')).toBe(
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
);
});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js
index 2bb99fb8e8f..cbe68df5343 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js
@@ -61,10 +61,6 @@ describe('Cleanup image tags project settings', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('isEdited status', () => {
it.each`
description | apiResponse | workingCopy | result
@@ -130,7 +126,7 @@ describe('Cleanup image tags project settings', () => {
});
describe('an admin is visiting the page', () => {
- it('shows the admin part of the alert message', async () => {
+ it('shows the admin part of the alert', async () => {
mountComponentWithApollo({
provide: { ...defaultProvidedValues, isAdmin: true },
resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()),
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js
index cbb5aa52694..a68087f7f57 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js
@@ -46,7 +46,7 @@ describe('Container Expiration Policy Settings Form', () => {
const findOlderThanDropdown = () => wrapper.find('[data-testid="older-than-dropdown"]');
const findRemoveRegexInput = () => wrapper.find('[data-testid="remove-regex-input"]');
- const submitForm = async () => {
+ const submitForm = () => {
findForm().trigger('submit');
return waitForPromises();
};
@@ -124,10 +124,6 @@ describe('Container Expiration Policy Settings Form', () => {
jest.spyOn(Tracking, 'event');
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each`
model | finder | fieldName | type | defaultValue
${'enabled'} | ${findEnableToggle} | ${'Enable'} | ${'toggle'} | ${false}
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js
index 43484d26d76..c9dd9ce7a45 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js
@@ -63,10 +63,6 @@ describe('Container expiration policy project settings', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the setting form', async () => {
mountComponentWithApollo({
resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()),
@@ -113,7 +109,7 @@ describe('Container expiration policy project settings', () => {
});
describe('an admin is visiting the page', () => {
- it('shows the admin part of the alert message', async () => {
+ it('shows the admin part of the alert', async () => {
mountComponentWithApollo({
provide: { ...defaultProvidedValues, isAdmin: true },
resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()),
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js
index ae41fdf65e0..058fe427106 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js
@@ -32,11 +32,6 @@ describe('ExpirationDropdown', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('structure', () => {
it('has a form-select component', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js
index 1cea0704154..be12d108d1e 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js
@@ -38,11 +38,6 @@ describe('ExpirationInput', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('structure', () => {
it('has a label', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js
index 653f2a8b40e..f950a9d5add 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js
@@ -23,11 +23,6 @@ describe('ExpirationToggle', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('structure', () => {
it('has an input component', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js
index 55a66cebd83..ec7b89aa927 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js
@@ -23,11 +23,6 @@ describe('ExpirationToggle', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('structure', () => {
it('has a toggle component', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js
index 0fbbf4ae58f..50b72d3ad72 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js
@@ -48,7 +48,7 @@ describe('Packages Cleanup Policy Settings Form', () => {
wrapper.findByTestId('keep-n-duplicated-package-files-dropdown');
const findNextRunAt = () => wrapper.findByTestId('next-run-at');
- const submitForm = async () => {
+ const submitForm = () => {
findForm().trigger('submit');
return waitForPromises();
};
@@ -115,7 +115,6 @@ describe('Packages Cleanup Policy Settings Form', () => {
});
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js
index 6dfeeca6862..94277d34f30 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js
@@ -47,7 +47,6 @@ describe('Packages cleanup policy project settings', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
index 07d13839c61..12425909454 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
@@ -20,11 +20,6 @@ describe('Registry Settings app', () => {
const findPackagesCleanupPolicy = () => wrapper.findComponent(PackagesCleanupPolicy);
const findAlert = () => wrapper.findComponent(GlAlert);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const defaultProvide = {
showContainerRegistrySettings: true,
showPackageRegistrySettings: true,
@@ -84,7 +79,7 @@ describe('Registry Settings app', () => {
${false} | ${true}
${false} | ${false}
`(
- 'container expiration policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings',
+ 'container cleanup policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings',
({ showContainerRegistrySettings, showPackageRegistrySettings }) => {
mountComponent({
showContainerRegistrySettings,
diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap
index e6e89806ce0..e9ee6ebdb5c 100644
--- a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap
+++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap
@@ -5,7 +5,6 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = `
aria-label="Breadcrumb"
class="gl-breadcrumbs"
>
-
<ol
class="breadcrumb gl-breadcrumb-list"
>
@@ -16,29 +15,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = `
class=""
target="_self"
>
+ <!---->
<span>
</span>
-
- <span
- class="gl-breadcrumb-separator"
- data-testid="separator"
- >
- <span
- class="gl-mx-n5"
- >
- <svg
- aria-hidden="true"
- class="gl-icon s8"
- data-testid="chevron-lg-right-icon"
- role="img"
- >
- <use
- href="#chevron-lg-right"
- />
- </svg>
- </span>
- </span>
</a>
</li>
@@ -52,11 +32,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = `
href="#"
target="_self"
>
+ <!---->
<span>
</span>
-
- <!---->
</a>
</li>
@@ -70,7 +49,6 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = `
aria-label="Breadcrumb"
class="gl-breadcrumbs"
>
-
<ol
class="breadcrumb gl-breadcrumb-list"
>
@@ -82,11 +60,10 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = `
class=""
target="_self"
>
+ <!---->
<span>
</span>
-
- <!---->
</a>
</li>
diff --git a/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js b/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js
index 18084766db9..41482e6e681 100644
--- a/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js
@@ -38,11 +38,6 @@ describe('cli_commands', () => {
mountComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('shows the correct text on the button', () => {
expect(findDropdownButton().text()).toContain(QUICK_START);
});
diff --git a/spec/frontend/packages_and_registries/shared/components/delete_package_modal_spec.js b/spec/frontend/packages_and_registries/shared/components/delete_package_modal_spec.js
index 357dab593e8..ba5ba8f9884 100644
--- a/spec/frontend/packages_and_registries/shared/components/delete_package_modal_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/delete_package_modal_spec.js
@@ -19,11 +19,6 @@ describe('DeletePackageModal', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when itemToBeDeleted prop is defined', () => {
beforeEach(() => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/shared/components/package_icon_and_name_spec.js b/spec/frontend/packages_and_registries/shared/components/package_icon_and_name_spec.js
deleted file mode 100644
index a0ff6ca01b5..00000000000
--- a/spec/frontend/packages_and_registries/shared/components/package_icon_and_name_spec.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import { GlIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue';
-
-describe('PackageIconAndName', () => {
- let wrapper;
-
- const findIcon = () => wrapper.findComponent(GlIcon);
-
- const mountComponent = () => {
- wrapper = shallowMount(PackageIconAndName, {
- slots: {
- default: 'test',
- },
- });
- };
-
- it('has an icon', () => {
- mountComponent();
-
- const icon = findIcon();
-
- expect(icon.exists()).toBe(true);
- expect(icon.props('name')).toBe('package');
- });
-
- it('renders the slot content', () => {
- mountComponent();
-
- expect(wrapper.text()).toBe('test');
- });
-});
diff --git a/spec/frontend/packages_and_registries/shared/components/package_path_spec.js b/spec/frontend/packages_and_registries/shared/components/package_path_spec.js
index 93425d4f399..3ffbb6f435c 100644
--- a/spec/frontend/packages_and_registries/shared/components/package_path_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/package_path_spec.js
@@ -9,7 +9,7 @@ describe('PackagePath', () => {
wrapper = shallowMount(PackagePath, {
propsData,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -24,11 +24,6 @@ describe('PackagePath', () => {
const findItem = (name) => wrapper.find(`[data-testid="${name}"]`);
const findTooltip = (w) => getBinding(w.element, 'gl-tooltip');
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe.each`
path | rootUrl | shouldExist | shouldNotExist
${'foo/bar'} | ${'/foo/bar'} | ${[]} | ${[ROOT_CHEVRON, ELLIPSIS_ICON, ELLIPSIS_CHEVRON, LEAF_LINK]}
@@ -91,12 +86,12 @@ describe('PackagePath', () => {
});
it('root link is disabled', () => {
- expect(findItem(ROOT_LINK).attributes('disabled')).toBe('true');
+ expect(findItem(ROOT_LINK).attributes('disabled')).toBeDefined();
});
if (shouldExist.includes(LEAF_LINK)) {
it('the last link is disabled', () => {
- expect(findItem(LEAF_LINK).attributes('disabled')).toBe('true');
+ expect(findItem(LEAF_LINK).attributes('disabled')).toBeDefined();
});
}
});
diff --git a/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js b/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js
index 33e96c0775e..b025517ae47 100644
--- a/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js
@@ -20,10 +20,6 @@ describe('PackageTags', () => {
const tagBadges = () => wrapper.findAll('[data-testid="tagBadge"]');
const moreBadge = () => wrapper.find('[data-testid="moreBadge"]');
- afterEach(() => {
- if (wrapper) wrapper.destroy();
- });
-
describe('tag label', () => {
it('shows the tag label by default', () => {
createComponent();
diff --git a/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js b/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js
index 0005162e0bb..e43a9f57255 100644
--- a/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js
@@ -17,11 +17,6 @@ describe('PackagesListLoader', () => {
beforeEach(createComponent);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('desktop loader', () => {
it('produces the right loader', () => {
expect(findDesktopShapes().findAll('rect[width="1000"]')).toHaveLength(20);
diff --git a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js
index db9f96bff39..c1e86080d29 100644
--- a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js
@@ -43,10 +43,6 @@ describe('Persisted Search', () => {
extractFilterAndSorting.mockReturnValue(defaultQueryParamsMock);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has a registry search component', async () => {
mountComponent();
@@ -55,7 +51,7 @@ describe('Persisted Search', () => {
expect(findRegistrySearch().exists()).toBe(true);
});
- it('registry search is mounted after mount', async () => {
+ it('registry search is mounted after mount', () => {
mountComponent();
expect(findRegistrySearch().exists()).toBe(false);
diff --git a/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js b/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js
index fa8f8f7641a..167599a54ea 100644
--- a/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js
@@ -20,11 +20,6 @@ describe('publish_method', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders', () => {
mountComponent(packageWithPipeline);
expect(wrapper.element).toMatchSnapshot();
diff --git a/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js
index 15db454ac68..c1f1a25d53b 100644
--- a/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js
@@ -31,10 +31,6 @@ describe('Registry Breadcrumb', () => {
nameGenerator.mockClear();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when is rootRoute', () => {
beforeEach(() => {
mountComponent(routes[0]);
diff --git a/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js
index 2e2d5e26d33..66fca2ce12e 100644
--- a/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js
@@ -36,10 +36,6 @@ describe('Registry List', () => {
const findScopedSlotFirstValue = (index) => findScopedSlots().at(index).find('span');
const findScopedSlotIsSelectedValue = (index) => findScopedSlots().at(index).find('p');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('header', () => {
it('renders the title passed in the prop', () => {
mountComponent();
@@ -62,7 +58,7 @@ describe('Registry List', () => {
it('sets disabled prop to true when items length is 0', () => {
mountComponent({ propsData: { ...defaultPropsData, items: [] } });
- expect(findSelectAll().attributes('disabled')).toBe('true');
+ expect(findSelectAll().attributes('disabled')).toBeDefined();
});
it('when few are selected, sets indeterminate prop to true', async () => {
@@ -111,10 +107,21 @@ describe('Registry List', () => {
expect(findDeleteSelected().text()).toBe(component.i18n.deleteSelected);
});
- it('is hidden when hiddenDelete is true', () => {
- mountComponent({ propsData: { ...defaultPropsData, hiddenDelete: true } });
+ describe('when hiddenDelete is true', () => {
+ beforeEach(() => {
+ mountComponent({ propsData: { ...defaultPropsData, hiddenDelete: true } });
+ });
- expect(findDeleteSelected().exists()).toBe(false);
+ it('is hidden', () => {
+ expect(findDeleteSelected().exists()).toBe(false);
+ });
+
+ it('populates the first slot prop correctly', () => {
+ expect(findScopedSlots().at(0).exists()).toBe(true);
+
+ // it's the first slot
+ expect(findScopedSlotFirstValue(0).text()).toBe('false');
+ });
});
it('is disabled when isLoading is true', () => {
diff --git a/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js b/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js
index a4c1b989dac..664a821c275 100644
--- a/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js
@@ -15,10 +15,6 @@ describe('SettingsBlock', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDefaultSlot = () => wrapper.findByTestId('default-slot');
const findTitleSlot = () => wrapper.findByTestId('title-slot');
const findDescriptionSlot = () => wrapper.findByTestId('description-slot');
diff --git a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
index 6edfe9641b9..6cf30e84288 100644
--- a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
+++ b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
@@ -1,9 +1,9 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlAbuseReportsList from 'test_fixtures/abuse_reports/abuse_reports_list.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import AbuseReports from '~/pages/admin/abuse_reports/abuse_reports';
describe('Abuse Reports', () => {
- const FIXTURE = 'abuse_reports/abuse_reports_list.html';
const MAX_MESSAGE_LENGTH = 500;
let $messages;
@@ -15,7 +15,7 @@ describe('Abuse Reports', () => {
$messages.filter((index, element) => element.innerText.indexOf(searchText) > -1).first();
beforeEach(() => {
- loadHTMLFixture(FIXTURE);
+ setHTMLFixture(htmlAbuseReportsList);
new AbuseReports(); // eslint-disable-line no-new
$messages = $('.abuse-reports .message');
});
diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
index d422f5dade3..176ec36fffc 100644
--- a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
+++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
@@ -1,14 +1,13 @@
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlApplicationSettingsAccountsAndLimit from 'test_fixtures/application_settings/accounts_and_limit.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initAccountAndLimitsSection, {
PLACEHOLDER_USER_EXTERNAL_DEFAULT_FALSE,
PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE,
} from '~/pages/admin/application_settings/account_and_limits';
describe('AccountAndLimits', () => {
- const FIXTURE = 'application_settings/accounts_and_limit.html';
-
beforeEach(() => {
- loadHTMLFixture(FIXTURE);
+ setHTMLFixture(htmlApplicationSettingsAccountsAndLimit);
initAccountAndLimitsSection();
});
diff --git a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js
index 3c512cfd6ae..72d2bb0f983 100644
--- a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js
+++ b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js
@@ -1,18 +1,18 @@
+import htmlApplicationSettingsUsage from 'test_fixtures/application_settings/usage.html';
import initSetHelperText, {
HELPER_TEXT_SERVICE_PING_DISABLED,
HELPER_TEXT_SERVICE_PING_ENABLED,
} from '~/pages/admin/application_settings/metrics_and_profiling/usage_statistics';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
describe('UsageStatistics', () => {
- const FIXTURE = 'application_settings/usage.html';
let servicePingCheckBox;
let servicePingFeaturesCheckBox;
let servicePingFeaturesLabel;
let servicePingFeaturesHelperText;
beforeEach(() => {
- loadHTMLFixture(FIXTURE);
+ setHTMLFixture(htmlApplicationSettingsUsage);
initSetHelperText();
servicePingCheckBox = document.getElementById('application_setting_usage_ping_enabled');
servicePingFeaturesCheckBox = document.getElementById(
diff --git a/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js
new file mode 100644
index 00000000000..b1d2e443d54
--- /dev/null
+++ b/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js
@@ -0,0 +1,66 @@
+import Vue, { nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
+import { TEST_HOST } from 'helpers/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import CancelJobsModal from '~/pages/admin/jobs/components/cancel_jobs_modal.vue';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ redirectTo: jest.fn(),
+}));
+
+describe('Cancel jobs modal', () => {
+ const props = {
+ url: `${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`,
+ modalId: 'cancel-jobs-modal',
+ };
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(CancelJobsModal, { propsData: props });
+ });
+
+ describe('on submit', () => {
+ it('cancels jobs and redirects to overview page', async () => {
+ const responseURL = `${TEST_HOST}/cancel_jobs_modal.vue/jobs`;
+ // TODO: We can't use axios-mock-adapter because our current version
+ // does not support responseURL
+ //
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details
+ jest.spyOn(axios, 'post').mockImplementation((url) => {
+ expect(url).toBe(props.url);
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ wrapper.findComponent(GlModal).vm.$emit('primary');
+ await nextTick();
+
+ expect(redirectTo).toHaveBeenCalledWith(responseURL); // eslint-disable-line import/no-deprecated
+ });
+
+ it('displays error if canceling jobs failed', async () => {
+ Vue.config.errorHandler = () => {}; // silencing thrown error
+
+ const dummyError = new Error('canceling jobs failed');
+ // TODO: We can't use axios-mock-adapter because our current version
+ // does not support responseURL
+ //
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details
+ jest.spyOn(axios, 'post').mockImplementation((url) => {
+ expect(url).toBe(props.url);
+ return Promise.reject(dummyError);
+ });
+
+ wrapper.findComponent(GlModal).vm.$emit('primary');
+ await nextTick();
+
+ expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js b/spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js
new file mode 100644
index 00000000000..d94de48f238
--- /dev/null
+++ b/spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js
@@ -0,0 +1,57 @@
+import { GlButton } from '@gitlab/ui';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { TEST_HOST } from 'helpers/test_constants';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue';
+import CancelJobsModal from '~/pages/admin/jobs/components/cancel_jobs_modal.vue';
+import {
+ CANCEL_JOBS_MODAL_ID,
+ CANCEL_BUTTON_TOOLTIP,
+} from '~/pages/admin/jobs/components/constants';
+
+describe('CancelJobs component', () => {
+ let wrapper;
+
+ const findCancelJobs = () => wrapper.findComponent(CancelJobs);
+ const findButton = () => wrapper.findComponent(GlButton);
+ const findModal = () => wrapper.findComponent(CancelJobsModal);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(CancelJobs, {
+ directives: {
+ GlModal: createMockDirective('gl-modal'),
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ propsData: {
+ url: `${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('has correct inputs', () => {
+ expect(findCancelJobs().props().url).toBe(`${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`);
+ });
+
+ it('has correct button variant', () => {
+ expect(findButton().props().variant).toBe('danger');
+ });
+
+ it('checks that button and modal are connected', () => {
+ const buttonModalDirective = getBinding(findButton().element, 'gl-modal');
+ const modalId = findModal().props('modalId');
+
+ expect(buttonModalDirective.value).toBe(CANCEL_JOBS_MODAL_ID);
+ expect(modalId).toBe(CANCEL_JOBS_MODAL_ID);
+ });
+
+ it('checks that tooltip is displayed', () => {
+ const buttonTooltipDirective = getBinding(findButton().element, 'gl-tooltip');
+
+ expect(buttonTooltipDirective.value).toBe(CANCEL_BUTTON_TOOLTIP);
+ });
+});
diff --git a/spec/frontend/pages/admin/jobs/components/jobs_skeleton_loader_spec.js b/spec/frontend/pages/admin/jobs/components/jobs_skeleton_loader_spec.js
new file mode 100644
index 00000000000..03e5cd75420
--- /dev/null
+++ b/spec/frontend/pages/admin/jobs/components/jobs_skeleton_loader_spec.js
@@ -0,0 +1,28 @@
+import { GlSkeletonLoader } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue';
+
+describe('jobs_skeleton_loader.vue', () => {
+ let wrapper;
+
+ const findGlSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ const WIDTH = '1248';
+ const HEIGHT = '73';
+
+ beforeEach(() => {
+ wrapper = shallowMount(JobsSkeletonLoader);
+ });
+
+ it('renders a GlSkeletonLoader', () => {
+ expect(findGlSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('has correct width', () => {
+ expect(findGlSkeletonLoader().attributes('width')).toBe(WIDTH);
+ });
+
+ it('has correct height', () => {
+ expect(findGlSkeletonLoader().attributes('height')).toBe(HEIGHT);
+ });
+});
diff --git a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js
new file mode 100644
index 00000000000..dad7308ac0a
--- /dev/null
+++ b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js
@@ -0,0 +1,394 @@
+import { GlLoadingIcon, GlEmptyState, GlAlert, GlIntersectionObserver } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
+import JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue';
+import getAllJobsQuery from '~/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql';
+import getAllJobsCount from '~/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql';
+import getCancelableJobsQuery from '~/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql';
+import AdminJobsTableApp from '~/pages/admin/jobs/components/table/admin_jobs_table_app.vue';
+import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue';
+import JobsTable from '~/jobs/components/table/jobs_table.vue';
+import { createAlert } from '~/alert';
+import { TEST_HOST } from 'spec/test_constants';
+import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue';
+import * as urlUtils from '~/lib/utils/url_utility';
+import {
+ JOBS_FETCH_ERROR_MSG,
+ CANCELABLE_JOBS_ERROR_MSG,
+ LOADING_ARIA_LABEL,
+ RAW_TEXT_WARNING_ADMIN,
+ JOBS_COUNT_ERROR_MESSAGE,
+} from '~/pages/admin/jobs/components/constants';
+import {
+ mockAllJobsResponsePaginated,
+ mockCancelableJobsCountResponse,
+ mockAllJobsResponseEmpty,
+ statuses,
+ mockFailedSearchToken,
+ mockAllJobsCountResponse,
+} from '../../../../../jobs/mock_data';
+
+Vue.use(VueApollo);
+
+jest.mock('~/alert');
+
+describe('Job table app', () => {
+ let wrapper;
+
+ const successHandler = jest.fn().mockResolvedValue(mockAllJobsResponsePaginated);
+ const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+ const cancelHandler = jest.fn().mockResolvedValue(mockCancelableJobsCountResponse);
+ const emptyHandler = jest.fn().mockResolvedValue(mockAllJobsResponseEmpty);
+ const countSuccessHandler = jest.fn().mockResolvedValue(mockAllJobsCountResponse);
+
+ const findSkeletonLoader = () => wrapper.findComponent(JobsSkeletonLoader);
+ const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
+ const findTable = () => wrapper.findComponent(JobsTable);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findTabs = () => wrapper.findComponent(JobsTableTabs);
+ const findCancelJobsButton = () => wrapper.findComponent(CancelJobs);
+ const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch);
+
+ const triggerInfiniteScroll = () =>
+ wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
+
+ const createMockApolloProvider = (handler, cancelableHandler, countHandler) => {
+ const requestHandlers = [
+ [getAllJobsQuery, handler],
+ [getCancelableJobsQuery, cancelableHandler],
+ [getAllJobsCount, countHandler],
+ ];
+
+ return createMockApollo(requestHandlers);
+ };
+
+ const createComponent = ({
+ handler = successHandler,
+ cancelableHandler = cancelHandler,
+ countHandler = countSuccessHandler,
+ mountFn = shallowMount,
+ data = {},
+ } = {}) => {
+ wrapper = mountFn(AdminJobsTableApp, {
+ data() {
+ return {
+ ...data,
+ };
+ },
+ provide: {
+ jobStatuses: statuses,
+ },
+ apolloProvider: createMockApolloProvider(handler, cancelableHandler, countHandler),
+ });
+ };
+
+ describe('loading state', () => {
+ it('should display skeleton loader when loading', () => {
+ createComponent();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ expect(findTable().exists()).toBe(false);
+ expect(findLoadingSpinner().exists()).toBe(false);
+ });
+
+ it('when switching tabs only the skeleton loader should show', () => {
+ createComponent();
+
+ findTabs().vm.$emit('fetchJobsByStatus', null);
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ expect(findLoadingSpinner().exists()).toBe(false);
+ });
+ });
+
+ describe('loaded state', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('should display the jobs table with data', () => {
+ expect(findTable().exists()).toBe(true);
+ expect(findSkeletonLoader().exists()).toBe(false);
+ expect(findLoadingSpinner().exists()).toBe(false);
+ });
+
+ it('should refetch jobs query on fetchJobsByStatus event', async () => {
+ jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+
+ await findTabs().vm.$emit('fetchJobsByStatus');
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('avoids refetch jobs query when scope has not changed', async () => {
+ jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+
+ await findTabs().vm.$emit('fetchJobsByStatus', null);
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ });
+
+ it('should refetch jobs count query when the amount jobs and count do not match', async () => {
+ jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+
+ // after applying filter a new count is fetched
+ findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1);
+
+ // tab is switched to `finished`, no count
+ await findTabs().vm.$emit('fetchJobsByStatus', ['FAILED', 'SUCCESS', 'CANCELED']);
+
+ // tab is switched back to `all`, the old filter count has to be overwritten with new count
+ await findTabs().vm.$emit('fetchJobsByStatus', null);
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(2);
+ });
+
+ describe('when infinite scrolling is triggered', () => {
+ it('does not display a skeleton loader', () => {
+ triggerInfiniteScroll();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('handles infinite scrolling by calling fetch more', async () => {
+ triggerInfiniteScroll();
+
+ await nextTick();
+
+ const pageSize = 50;
+
+ expect(findLoadingSpinner().exists()).toBe(true);
+ expect(findLoadingSpinner().attributes('aria-label')).toBe(LOADING_ARIA_LABEL);
+
+ await waitForPromises();
+
+ expect(findLoadingSpinner().exists()).toBe(false);
+
+ expect(successHandler).toHaveBeenLastCalledWith({
+ first: pageSize,
+ after: mockAllJobsResponsePaginated.data.jobs.pageInfo.endCursor,
+ });
+ });
+ });
+ });
+
+ describe('empty state', () => {
+ it('should display empty state if there are no jobs and tab scope is null', async () => {
+ createComponent({ handler: emptyHandler, mountFn: mount });
+
+ await waitForPromises();
+
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findTable().exists()).toBe(false);
+ });
+
+ it('should not display empty state if there are jobs and tab scope is not null', async () => {
+ createComponent({ handler: successHandler, mountFn: mount });
+
+ await waitForPromises();
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findTable().exists()).toBe(true);
+ });
+ });
+
+ describe('error state', () => {
+ it('should show an alert if there is an error fetching the jobs data', async () => {
+ createComponent({ handler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findAlert().text()).toBe(JOBS_FETCH_ERROR_MSG);
+ expect(findTable().exists()).toBe(false);
+ });
+
+ it('should show an alert if there is an error fetching the jobs count data', async () => {
+ createComponent({ handler: successHandler, countHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findAlert().text()).toBe(JOBS_COUNT_ERROR_MESSAGE);
+ });
+
+ it('should show an alert if there is an error fetching the cancelable jobs data', async () => {
+ createComponent({ handler: successHandler, cancelableHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findAlert().text()).toBe(CANCELABLE_JOBS_ERROR_MSG);
+ });
+
+ it('jobs table should still load if count query fails', async () => {
+ createComponent({ handler: successHandler, countHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it('jobs table should still load if cancel query fails', async () => {
+ createComponent({ handler: successHandler, cancelableHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it('jobs count should be zero if count query fails', async () => {
+ createComponent({ handler: successHandler, countHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findTabs().props('allJobsCount')).toBe(0);
+ });
+
+ it('cancel button should be hidden if query fails', async () => {
+ createComponent({ handler: successHandler, cancelableHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(findCancelJobsButton().exists()).toBe(false);
+ });
+ });
+
+ describe('cancel jobs button', () => {
+ it('should display cancel all jobs button', async () => {
+ createComponent({ cancelableHandler: cancelHandler, mountFn: mount });
+
+ await waitForPromises();
+
+ expect(findCancelJobsButton().exists()).toBe(true);
+ });
+
+ it('should not display cancel all jobs button', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findCancelJobsButton().exists()).toBe(false);
+ });
+ });
+
+ describe('filtered search', () => {
+ it('should display filtered search', () => {
+ createComponent();
+
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+
+ // this test should be updated once BE supports tab and filtered search filtering
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/356210
+ it.each`
+ scope | shouldDisplay
+ ${null} | ${true}
+ ${['FAILED', 'SUCCESS', 'CANCELED']} | ${false}
+ `(
+ 'with tab scope $scope the filtered search displays $shouldDisplay',
+ async ({ scope, shouldDisplay }) => {
+ createComponent();
+
+ await waitForPromises();
+
+ await findTabs().vm.$emit('fetchJobsByStatus', scope);
+
+ expect(findFilteredSearch().exists()).toBe(shouldDisplay);
+ },
+ );
+
+ it('refetches jobs query when filtering', async () => {
+ createComponent();
+
+ jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+
+ await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('refetches jobs count query when filtering', async () => {
+ createComponent();
+
+ jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+
+ await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
+
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows raw text warning when user inputs raw text', async () => {
+ const expectedWarning = {
+ message: RAW_TEXT_WARNING_ADMIN,
+ type: 'warning',
+ };
+
+ createComponent();
+
+ jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
+ jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
+
+ await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']);
+
+ expect(createAlert).toHaveBeenCalledWith(expectedWarning);
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+ });
+
+ it('updates URL query string when filtering jobs by status', async () => {
+ createComponent();
+
+ jest.spyOn(urlUtils, 'updateHistory');
+
+ await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
+
+ expect(urlUtils.updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/?statuses=FAILED`,
+ });
+ });
+
+ it('resets query param after clearing tokens', () => {
+ createComponent();
+
+ jest.spyOn(urlUtils, 'updateHistory');
+
+ findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
+
+ expect(successHandler).toHaveBeenCalledWith({
+ first: 50,
+ statuses: 'FAILED',
+ });
+ expect(urlUtils.updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/?statuses=FAILED`,
+ });
+
+ findFilteredSearch().vm.$emit('filterJobsBySearch', []);
+
+ expect(urlUtils.updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/`,
+ });
+
+ expect(successHandler).toHaveBeenCalledWith({
+ first: 50,
+ statuses: null,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/jobs/components/table/cells/project_cell_spec.js b/spec/frontend/pages/admin/jobs/components/table/cells/project_cell_spec.js
new file mode 100644
index 00000000000..3366d60d9f3
--- /dev/null
+++ b/spec/frontend/pages/admin/jobs/components/table/cells/project_cell_spec.js
@@ -0,0 +1,32 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import ProjectCell from '~/pages/admin/jobs/components/table/cell/project_cell.vue';
+import { mockAllJobsNodes } from '../../../../../../jobs/mock_data';
+
+const mockJob = mockAllJobsNodes[0];
+
+describe('Project cell', () => {
+ let wrapper;
+
+ const findProjectLink = () => wrapper.findComponent(GlLink);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ProjectCell, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ describe('Project Link', () => {
+ beforeEach(() => {
+ createComponent({ job: mockJob });
+ });
+
+ it('shows and links to the project', () => {
+ expect(findProjectLink().exists()).toBe(true);
+ expect(findProjectLink().text()).toBe(mockJob.pipeline.project.fullPath);
+ expect(findProjectLink().attributes('href')).toBe(mockJob.pipeline.project.webUrl);
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/jobs/components/table/cells/runner_cell_spec.js b/spec/frontend/pages/admin/jobs/components/table/cells/runner_cell_spec.js
new file mode 100644
index 00000000000..2f76ad66dd5
--- /dev/null
+++ b/spec/frontend/pages/admin/jobs/components/table/cells/runner_cell_spec.js
@@ -0,0 +1,64 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RunnerCell from '~/pages/admin/jobs/components/table/cells/runner_cell.vue';
+import { RUNNER_EMPTY_TEXT } from '~/pages/admin/jobs/components/constants';
+import { allRunnersData } from '../../../../../../ci/runner/mock_data';
+
+const mockRunner = allRunnersData.data.runners.nodes[0];
+
+const mockJobWithRunner = {
+ id: 'gid://gitlab/Ci::Build/2264',
+ runner: mockRunner,
+};
+
+const mockJobWithoutRunner = {
+ id: 'gid://gitlab/Ci::Build/2265',
+};
+
+describe('Runner Cell', () => {
+ let wrapper;
+
+ const findRunnerLink = () => wrapper.findComponent(GlLink);
+ const findEmptyRunner = () => wrapper.find('[data-testid="empty-runner-text"]');
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(RunnerCell, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ describe('Runner Link', () => {
+ describe('Job with runner', () => {
+ beforeEach(() => {
+ createComponent({ job: mockJobWithRunner });
+ });
+
+ it('shows and links to the runner', () => {
+ expect(findRunnerLink().exists()).toBe(true);
+ expect(findRunnerLink().text()).toBe(mockRunner.description);
+ expect(findRunnerLink().attributes('href')).toBe(mockRunner.adminUrl);
+ });
+
+ it('hides the empty runner text', () => {
+ expect(findEmptyRunner().exists()).toBe(false);
+ });
+ });
+
+ describe('Job without runner', () => {
+ beforeEach(() => {
+ createComponent({ job: mockJobWithoutRunner });
+ });
+
+ it('shows default `empty` text', () => {
+ expect(findEmptyRunner().exists()).toBe(true);
+ expect(findEmptyRunner().text()).toBe(RUNNER_EMPTY_TEXT);
+ });
+
+ it('hides the runner link', () => {
+ expect(findRunnerLink().exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js b/spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js
new file mode 100644
index 00000000000..59e9eda6343
--- /dev/null
+++ b/spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js
@@ -0,0 +1,106 @@
+import cacheConfig from '~/pages/admin/jobs/components/table/graphql/cache_config';
+import {
+ CIJobConnectionExistingCache,
+ CIJobConnectionIncomingCache,
+ CIJobConnectionIncomingCacheRunningStatus,
+} from '../../../../../../jobs/mock_data';
+
+const firstLoadArgs = { first: 3, statuses: 'PENDING' };
+const runningArgs = { first: 3, statuses: 'RUNNING' };
+
+describe('jobs/components/table/graphql/cache_config', () => {
+ describe('when fetching data with the same statuses', () => {
+ it('should contain cache nodes and a status when merging caches on first load', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, {
+ args: firstLoadArgs,
+ });
+
+ expect(res.nodes).toHaveLength(CIJobConnectionIncomingCache.nodes.length);
+ expect(res.statuses).toBe('PENDING');
+ });
+
+ it('should add to existing caches when merging caches after first load', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge(
+ CIJobConnectionExistingCache,
+ CIJobConnectionIncomingCache,
+ {
+ args: firstLoadArgs,
+ },
+ );
+
+ expect(res.nodes).toHaveLength(
+ CIJobConnectionIncomingCache.nodes.length + CIJobConnectionExistingCache.nodes.length,
+ );
+ });
+
+ it('should not add to existing cache if the incoming elements are the same', () => {
+ // simulate that this is the last page
+ const finalExistingCache = {
+ ...CIJobConnectionExistingCache,
+ pageInfo: {
+ hasNextPage: false,
+ },
+ };
+
+ const res = cacheConfig.typePolicies.CiJobConnection.merge(
+ CIJobConnectionExistingCache,
+ finalExistingCache,
+ {
+ args: firstLoadArgs,
+ },
+ );
+
+ expect(res.nodes).toHaveLength(CIJobConnectionExistingCache.nodes.length);
+ });
+
+ it('should contain the pageInfo key as part of the result', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, {
+ args: firstLoadArgs,
+ });
+
+ expect(res.pageInfo).toEqual(
+ expect.objectContaining({
+ __typename: 'PageInfo',
+ endCursor: 'eyJpZCI6IjIwNTEifQ',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'eyJpZCI6IjIxNzMifQ',
+ }),
+ );
+ });
+ });
+
+ describe('when fetching data with different statuses', () => {
+ it('should reset cache when a cache already exists', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge(
+ CIJobConnectionExistingCache,
+ CIJobConnectionIncomingCacheRunningStatus,
+ {
+ args: runningArgs,
+ },
+ );
+
+ expect(res.nodes).not.toEqual(CIJobConnectionExistingCache.nodes);
+ expect(res.nodes).toHaveLength(CIJobConnectionIncomingCacheRunningStatus.nodes.length);
+ });
+ });
+
+ describe('when incoming data has no nodes', () => {
+ it('should return existing cache', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge(
+ CIJobConnectionExistingCache,
+ { __typename: 'CiJobConnection', count: 500 },
+ {
+ args: { statuses: 'SUCCESS' },
+ },
+ );
+
+ const expectedResponse = {
+ ...CIJobConnectionExistingCache,
+ statuses: 'SUCCESS',
+ };
+
+ expect(res).toEqual(expectedResponse);
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_modal_spec.js
deleted file mode 100644
index 366d148a608..00000000000
--- a/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_modal_spec.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import Vue, { nextTick } from 'vue';
-import { mount } from '@vue/test-utils';
-import { GlModal } from '@gitlab/ui';
-import { TEST_HOST } from 'helpers/test_constants';
-import axios from '~/lib/utils/axios_utils';
-import { redirectTo } from '~/lib/utils/url_utility';
-import CancelJobsModal from '~/pages/admin/jobs/index/components/cancel_jobs_modal.vue';
-
-jest.mock('~/lib/utils/url_utility', () => ({
- ...jest.requireActual('~/lib/utils/url_utility'),
- redirectTo: jest.fn(),
-}));
-
-describe('Cancel jobs modal', () => {
- const props = {
- url: `${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`,
- modalId: 'cancel-jobs-modal',
- };
- let wrapper;
-
- beforeEach(() => {
- wrapper = mount(CancelJobsModal, { propsData: props });
- });
-
- describe('on submit', () => {
- it('cancels jobs and redirects to overview page', async () => {
- const responseURL = `${TEST_HOST}/cancel_jobs_modal.vue/jobs`;
- // TODO: We can't use axios-mock-adapter because our current version
- // does not support responseURL
- //
- // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details
- jest.spyOn(axios, 'post').mockImplementation((url) => {
- expect(url).toBe(props.url);
- return Promise.resolve({
- request: {
- responseURL,
- },
- });
- });
-
- wrapper.findComponent(GlModal).vm.$emit('primary');
- await nextTick();
-
- expect(redirectTo).toHaveBeenCalledWith(responseURL);
- });
-
- it('displays error if canceling jobs failed', async () => {
- Vue.config.errorHandler = () => {}; // silencing thrown error
-
- const dummyError = new Error('canceling jobs failed');
- // TODO: We can't use axios-mock-adapter because our current version
- // does not support responseURL
- //
- // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details
- jest.spyOn(axios, 'post').mockImplementation((url) => {
- expect(url).toBe(props.url);
- return Promise.reject(dummyError);
- });
-
- wrapper.findComponent(GlModal).vm.$emit('primary');
- await nextTick();
-
- expect(redirectTo).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_spec.js b/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_spec.js
deleted file mode 100644
index ec6369e7119..00000000000
--- a/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_spec.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import { TEST_HOST } from 'helpers/test_constants';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import CancelJobs from '~/pages/admin/jobs/index/components/cancel_jobs.vue';
-import CancelJobsModal from '~/pages/admin/jobs/index/components/cancel_jobs_modal.vue';
-import {
- CANCEL_JOBS_MODAL_ID,
- CANCEL_BUTTON_TOOLTIP,
-} from '~/pages/admin/jobs/index/components/constants';
-
-describe('CancelJobs component', () => {
- let wrapper;
-
- const findCancelJobs = () => wrapper.findComponent(CancelJobs);
- const findButton = () => wrapper.findComponent(GlButton);
- const findModal = () => wrapper.findComponent(CancelJobsModal);
-
- const createComponent = (props = {}) => {
- wrapper = shallowMountExtended(CancelJobs, {
- directives: {
- GlModal: createMockDirective(),
- GlTooltip: createMockDirective(),
- },
- propsData: {
- url: `${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`,
- ...props,
- },
- });
- };
-
- beforeEach(() => {
- createComponent();
- });
-
- it('has correct inputs', () => {
- expect(findCancelJobs().props().url).toBe(`${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`);
- });
-
- it('has correct button variant', () => {
- expect(findButton().props().variant).toBe('danger');
- });
-
- it('checks that button and modal are connected', () => {
- const buttonModalDirective = getBinding(findButton().element, 'gl-modal');
- const modalId = findModal().props('modalId');
-
- expect(buttonModalDirective.value).toBe(CANCEL_JOBS_MODAL_ID);
- expect(modalId).toBe(CANCEL_JOBS_MODAL_ID);
- });
-
- it('checks that tooltip is displayed', () => {
- const buttonTooltipDirective = getBinding(findButton().element, 'gl-tooltip');
-
- expect(buttonTooltipDirective.value).toBe(CANCEL_BUTTON_TOOLTIP);
- });
-});
diff --git a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js
index 834d14e0fb3..c00dbc0ec02 100644
--- a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js
+++ b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js
@@ -45,7 +45,7 @@ describe('NamespaceSelect', () => {
expect(findNamespaceInput().exists()).toBe(false);
});
- it('sets appropriate props', async () => {
+ it('sets appropriate props', () => {
expect(findListbox().props()).toMatchObject({
items: [
{ text: 'user: Administrator', value: '10' },
@@ -84,7 +84,7 @@ describe('NamespaceSelect', () => {
expect(findNamespaceInput().attributes('value')).toBe(selectId);
});
- it('updates the listbox value', async () => {
+ it('updates the listbox value', () => {
expect(findListbox().props()).toMatchObject({
selected: selectId,
toggleText: expectToggleText,
diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
index 70d7cb9c839..52091d45ada 100644
--- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js
+++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlTodos from 'test_fixtures/todos/todos.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
@@ -18,7 +19,7 @@ describe('Todos', () => {
let mock;
beforeEach(() => {
- loadHTMLFixture('todos/todos.html');
+ setHTMLFixture(htmlTodos);
mock = new MockAdapter(axios);
return new Todos();
diff --git a/spec/frontend/pages/groups/new/components/app_spec.js b/spec/frontend/pages/groups/new/components/app_spec.js
index ab483316086..19240f1a044 100644
--- a/spec/frontend/pages/groups/new/components/app_spec.js
+++ b/spec/frontend/pages/groups/new/components/app_spec.js
@@ -6,7 +6,9 @@ describe('App component', () => {
let wrapper;
const createComponent = (propsData = {}) => {
- wrapper = shallowMount(App, { propsData });
+ wrapper = shallowMount(App, {
+ propsData: { rootPath: '/', groupsUrl: '/dashboard/groups', ...propsData },
+ });
};
const findNewNamespacePage = () => wrapper.findComponent(NewNamespacePage);
@@ -16,24 +18,32 @@ describe('App component', () => {
.props('panels')
.find((panel) => panel.name === 'create-group-pane');
- afterEach(() => {
- wrapper.destroy();
- });
-
it('creates correct component for group creation', () => {
createComponent();
- expect(findNewNamespacePage().props('initialBreadcrumb')).toBe('New group');
+ expect(findNewNamespacePage().props('initialBreadcrumbs')).toEqual([
+ { href: '/', text: 'Your work' },
+ { href: '/dashboard/groups', text: 'Groups' },
+ { href: '#', text: 'New group' },
+ ]);
expect(findCreateGroupPanel().title).toBe('Create group');
});
it('creates correct component for subgroup creation', () => {
- const props = { parentGroupName: 'parent', importExistingGroupPath: '/path' };
+ const detailProps = {
+ parentGroupName: 'parent',
+ importExistingGroupPath: '/path',
+ };
+
+ const props = { ...detailProps, parentGroupUrl: '/parent' };
createComponent(props);
- expect(findNewNamespacePage().props('initialBreadcrumb')).toBe('parent');
+ expect(findNewNamespacePage().props('initialBreadcrumbs')).toEqual([
+ { href: '/parent', text: 'parent' },
+ { href: '#', text: 'New subgroup' },
+ ]);
expect(findCreateGroupPanel().title).toBe('Create subgroup');
- expect(findCreateGroupPanel().detailProps).toEqual(props);
+ expect(findCreateGroupPanel().detailProps).toEqual(detailProps);
});
});
diff --git a/spec/frontend/pages/groups/new/components/create_group_description_details_spec.js b/spec/frontend/pages/groups/new/components/create_group_description_details_spec.js
index 56a1fd03f71..35015d84085 100644
--- a/spec/frontend/pages/groups/new/components/create_group_description_details_spec.js
+++ b/spec/frontend/pages/groups/new/components/create_group_description_details_spec.js
@@ -15,10 +15,6 @@ describe('CreateGroupDescriptionDetails component', () => {
const findLinkHref = (at) => wrapper.findAllComponents(GlLink).at(at);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('creates correct component for group creation', () => {
createComponent();
diff --git a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js
index b020caa3010..40d5dff9d06 100644
--- a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js
+++ b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js
@@ -18,13 +18,6 @@ describe('BitbucketServerStatusTable', () => {
.filter((w) => w.props().variant === 'info')
.at(0);
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
function createComponent(bitbucketStatusTableStub = true) {
wrapper = shallowMount(BitbucketServerStatusTable, {
propsData: { providerTitle: 'Test', reconfigurePath: '/reconfigure' },
@@ -39,7 +32,7 @@ describe('BitbucketServerStatusTable', () => {
expect(wrapper.findComponent(BitbucketStatusTable).exists()).toBe(true);
});
- it('renders Reconfigure button', async () => {
+ it('renders Reconfigure button', () => {
createComponent(BitbucketStatusTableStub);
expect(findReconfigureButton().attributes().href).toBe('/reconfigure');
expect(findReconfigureButton().text()).toBe('Reconfigure');
diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
index da3954b4918..8a7fc57c409 100644
--- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
+++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
@@ -68,15 +68,10 @@ describe('BulkImportsHistoryApp', () => {
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
- const originalApiVersion = gon.api_version;
- beforeAll(() => {
+ beforeEach(() => {
gon.api_version = 'v4';
});
- afterAll(() => {
- gon.api_version = originalApiVersion;
- });
-
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS);
@@ -84,7 +79,6 @@ describe('BulkImportsHistoryApp', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('general behavior', () => {
@@ -198,7 +192,7 @@ describe('BulkImportsHistoryApp', () => {
return axios.waitForAll();
});
- it('renders details button if relevant item has failures', async () => {
+ it('renders details button if relevant item has failures', () => {
expect(
extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(),
).toBe(true);
diff --git a/spec/frontend/pages/import/history/components/import_error_details_spec.js b/spec/frontend/pages/import/history/components/import_error_details_spec.js
index 628ee8d7999..239826c1458 100644
--- a/spec/frontend/pages/import/history/components/import_error_details_spec.js
+++ b/spec/frontend/pages/import/history/components/import_error_details_spec.js
@@ -21,22 +21,13 @@ describe('ImportErrorDetails', () => {
});
}
- const originalApiVersion = gon.api_version;
- beforeAll(() => {
- gon.api_version = 'v4';
- });
-
- afterAll(() => {
- gon.api_version = originalApiVersion;
- });
-
beforeEach(() => {
+ gon.api_version = 'v4';
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('general behavior', () => {
diff --git a/spec/frontend/pages/import/history/components/import_history_app_spec.js b/spec/frontend/pages/import/history/components/import_history_app_spec.js
index 7d79583be19..8e14b5a24f8 100644
--- a/spec/frontend/pages/import/history/components/import_history_app_spec.js
+++ b/spec/frontend/pages/import/history/components/import_history_app_spec.js
@@ -59,23 +59,13 @@ describe('ImportHistoryApp', () => {
});
}
- const originalApiVersion = gon.api_version;
- beforeAll(() => {
- gon.api_version = 'v4';
- gon.features = { fullPathProjectSearch: true };
- });
-
- afterAll(() => {
- gon.api_version = originalApiVersion;
- });
-
beforeEach(() => {
+ gon.api_version = 'v4';
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('general behavior', () => {
@@ -176,7 +166,7 @@ describe('ImportHistoryApp', () => {
return axios.waitForAll();
});
- it('renders details button if relevant item has failed', async () => {
+ it('renders details button if relevant item has failed', () => {
expect(
extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(),
).toBe(true);
diff --git a/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js b/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js
index c30b996437d..18a0098a715 100644
--- a/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js
+++ b/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js
@@ -25,8 +25,7 @@ describe('Password prompt modal', () => {
const findField = () => wrapper.findByTestId('password-prompt-field');
const findModal = () => wrapper.findComponent(GlModal);
const findConfirmBtn = () => findModal().props('actionPrimary');
- const findConfirmBtnDisabledState = () =>
- findModal().props('actionPrimary').attributes[2].disabled;
+ const findConfirmBtnDisabledState = () => findModal().props('actionPrimary').attributes.disabled;
const findCancelBtn = () => findModal().props('actionCancel');
@@ -41,10 +40,6 @@ describe('Password prompt modal', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the password field', () => {
expect(findField().exists()).toBe(true);
});
diff --git a/spec/frontend/pages/projects/forks/new/components/app_spec.js b/spec/frontend/pages/projects/forks/new/components/app_spec.js
index 0342b94a44d..e9a94878867 100644
--- a/spec/frontend/pages/projects/forks/new/components/app_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/app_spec.js
@@ -22,10 +22,6 @@ describe('App component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays the correct svg illustration', () => {
expect(wrapper.find('img').attributes('src')).toBe('illustrations/project-create-new-sm.svg');
});
diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
index f0593a854b2..722857a1420 100644
--- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
@@ -6,7 +6,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import { kebabCase } from 'lodash';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import * as urlUtility from '~/lib/utils/url_utility';
import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -14,7 +14,7 @@ import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_name
import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue';
import { START_RULE, CONTAINS_RULE } from '~/projects/project_name_rules';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('ForkForm component', () => {
@@ -111,7 +111,6 @@ describe('ForkForm component', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
});
@@ -462,7 +461,7 @@ describe('ForkForm component', () => {
await submitForm();
- expect(urlUtility.redirectTo).not.toHaveBeenCalled();
+ expect(urlUtility.redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
});
it('does not make POST request if no visibility is checked', async () => {
@@ -550,10 +549,10 @@ describe('ForkForm component', () => {
setupComponent();
await submitForm();
- expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl);
+ expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); // eslint-disable-line import/no-deprecated
});
- it('display flash when POST is unsuccessful', async () => {
+ it('displays an alert when POST is unsuccessful', async () => {
const dummyError = 'Fork project failed';
jest.spyOn(axios, 'post').mockRejectedValue(dummyError);
@@ -561,7 +560,7 @@ describe('ForkForm component', () => {
setupComponent();
await submitForm();
- expect(urlUtility.redirectTo).not.toHaveBeenCalled();
+ expect(urlUtility.redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred while forking the project. Please try again.',
});
diff --git a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
index 82f451ed6ef..b308d6305da 100644
--- a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
@@ -3,15 +3,14 @@ import { mount, shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql';
import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('ProjectNamespace component', () => {
let wrapper;
- let originalGon;
const data = {
project: {
@@ -85,14 +84,8 @@ describe('ProjectNamespace component', () => {
findListBox().vm.$emit('shown');
};
- beforeAll(() => {
- originalGon = window.gon;
- window.gon = { gitlab_url: gitlabUrl };
- });
-
- afterAll(() => {
- window.gon = originalGon;
- wrapper.destroy();
+ beforeEach(() => {
+ gon.gitlab_url = gitlabUrl;
});
describe('Initial state', () => {
@@ -152,7 +145,7 @@ describe('ProjectNamespace component', () => {
await nextTick();
});
- it('creates a flash message and captures the error', () => {
+ it('creates an alert and captures the error', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'Something went wrong while loading data. Please refresh the page to try again.',
captureError: true,
diff --git a/spec/frontend/pages/projects/graphs/code_coverage_spec.js b/spec/frontend/pages/projects/graphs/code_coverage_spec.js
index 5356953060a..882730d90ae 100644
--- a/spec/frontend/pages/projects/graphs/code_coverage_spec.js
+++ b/spec/frontend/pages/projects/graphs/code_coverage_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlListbox, GlListboxItem } from '@gitlab/ui';
+import { GlAlert, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
@@ -22,7 +22,7 @@ describe('Code Coverage', () => {
const findAlert = () => wrapper.findComponent(GlAlert);
const findAreaChart = () => wrapper.findComponent(GlAreaChart);
- const findListBox = () => wrapper.findComponent(GlListbox);
+ const findListBox = () => wrapper.findComponent(GlCollapsibleListbox);
const findListBoxItems = () => wrapper.findAllComponents(GlListboxItem);
const findFirstListBoxItem = () => findListBoxItems().at(0);
const findSecondListBoxItem = () => findListBoxItems().at(1);
@@ -37,15 +37,10 @@ describe('Code Coverage', () => {
graphRef,
graphCsvPath,
},
- stubs: { GlListbox },
+ stubs: { GlCollapsibleListbox },
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when fetching data is successful', () => {
beforeEach(() => {
mockAxios = new MockAdapter(axios);
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 2d3b9afa8f6..07d05293a3c 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
@@ -37,10 +37,6 @@ describe('Interval Pattern Input Component', () => {
const selectCustomRadio = () => findCustomRadio().setChecked(true);
const createWrapper = (props = {}, data = {}) => {
- if (wrapper) {
- throw new Error('A wrapper already exists');
- }
-
wrapper = mount(IntervalPatternInput, {
propsData: { ...props },
data() {
@@ -64,8 +60,6 @@ describe('Interval Pattern Input Component', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
window.gl = oldWindowGl;
});
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
index a633332ab65..e20c2fa47a7 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
@@ -31,10 +31,6 @@ describe('Pipeline Schedule Callout', () => {
await nextTick();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('does not render the callout', () => {
expect(findInnerContentOfCallout().exists()).toBe(false);
});
@@ -46,10 +42,6 @@ describe('Pipeline Schedule Callout', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the callout container', () => {
expect(findInnerContentOfCallout().exists()).toBe(true);
});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js
index 5771e1b88e8..03c65ab4c9c 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js
@@ -31,11 +31,6 @@ describe('Project Feature Settings', () => {
},
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('Hidden name input', () => {
it('should set the hidden name input if the name exists', () => {
wrapper = mountComponent();
diff --git a/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js b/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js
index 6230809a6aa..91d3057aec5 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js
@@ -15,10 +15,6 @@ describe('Project Setting Row', () => {
wrapper = mountComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should show the label if it is set', async () => {
wrapper.setProps({ label: 'Test label' });
diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
index ff20b72c72c..a7a1e649cd0 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
@@ -140,11 +140,6 @@ describe('Settings Panel', () => {
const findMonitorVisibilityInput = () =>
findMonitorSettings().findComponent(ProjectFeatureSetting);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('Project Visibility', () => {
it('should set the project visibility help path', () => {
wrapper = mountComponent();
@@ -163,7 +158,7 @@ describe('Settings Panel', () => {
it('should disable the visibility level dropdown', () => {
wrapper = mountComponent({ canChangeVisibilityLevel: false });
- expect(findProjectVisibilityLevelInput().attributes('disabled')).toBe('disabled');
+ expect(findProjectVisibilityLevelInput().attributes('disabled')).toBeDefined();
});
it.each`
@@ -765,7 +760,7 @@ describe('Settings Panel', () => {
expect(findEnvironmentsSettings().exists()).toBe(true);
});
});
- describe('Feature Flags', () => {
+ describe('Feature flags', () => {
it('should show the feature flags toggle', () => {
wrapper = mountComponent({});
diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
index 4c4a0fbea11..6ff2bb42d8d 100644
--- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
+++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlSessionsNew from 'test_fixtures/sessions/new.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import preserveUrlFragment from '~/pages/sessions/new/preserve_url_fragment';
describe('preserve_url_fragment', () => {
@@ -8,7 +9,7 @@ describe('preserve_url_fragment', () => {
};
beforeEach(() => {
- loadHTMLFixture('sessions/new.html');
+ setHTMLFixture(htmlSessionsNew);
});
afterEach(() => {
@@ -30,8 +31,6 @@ describe('preserve_url_fragment', () => {
it('does not add an empty query parameter to OmniAuth login buttons', () => {
preserveUrlFragment();
- expect(findFormAction('#oauth-login-cas3')).toBe('http://test.host/users/auth/cas3');
-
expect(findFormAction('#oauth-login-auth0')).toBe('http://test.host/users/auth/auth0');
});
@@ -39,10 +38,6 @@ describe('preserve_url_fragment', () => {
it('when "remember_me" is not present', () => {
preserveUrlFragment('#L65');
- expect(findFormAction('#oauth-login-cas3')).toBe(
- 'http://test.host/users/auth/cas3?redirect_fragment=L65',
- );
-
expect(findFormAction('#oauth-login-auth0')).toBe(
'http://test.host/users/auth/auth0?redirect_fragment=L65',
);
@@ -55,10 +50,6 @@ describe('preserve_url_fragment', () => {
preserveUrlFragment('#L65');
- expect(findFormAction('#oauth-login-cas3')).toBe(
- 'http://test.host/users/auth/cas3?remember_me=1&redirect_fragment=L65',
- );
-
expect(findFormAction('#oauth-login-auth0')).toBe(
'http://test.host/users/auth/auth0?remember_me=1&redirect_fragment=L65',
);
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 f736ce46f9b..cae2615e849 100644
--- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
+++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
@@ -1,4 +1,5 @@
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlStaticSigninTabs from 'test_fixtures_static/signin_tabs.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import AccessorUtilities from '~/lib/utils/accessor';
import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer';
@@ -6,7 +7,6 @@ import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer';
useLocalStorageSpy();
describe('SigninTabsMemoizer', () => {
- const fixtureTemplate = 'static/signin_tabs.html';
const tabSelector = 'ul.new-session-tabs';
const currentTabKey = 'current_signin_tab';
let memo;
@@ -20,7 +20,7 @@ describe('SigninTabsMemoizer', () => {
}
beforeEach(() => {
- loadHTMLFixture(fixtureTemplate);
+ setHTMLFixture(htmlStaticSigninTabs);
jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true);
});
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_alert_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_alert_spec.js
index 6a18473b1a7..1858a56b0e1 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_alert_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_alert_spec.js
@@ -15,11 +15,6 @@ describe('WikiAlert', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findGlAlert = () => wrapper.findComponent(GlAlert);
const findGlLink = () => wrapper.findComponent(GlLink);
const findGlSprintf = () => wrapper.findComponent(GlSprintf);
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js
index c8e9a31b526..8e26453b564 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js
@@ -31,11 +31,6 @@ describe('pages/shared/wikis/components/wiki_content', () => {
mock = new MockAdapter(axios);
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findGlAlert = () => wrapper.findComponent(GlAlert);
const findGlSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findContent = () => wrapper.find('[data-testid="wiki-page-content"]');
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
index ffcfd1d9f78..ddaa3df71e8 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
@@ -103,8 +103,6 @@ describe('WikiForm', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
- wrapper = null;
});
it('displays markdown editor', () => {
@@ -116,9 +114,9 @@ describe('WikiForm', () => {
expect.objectContaining({
value: pageInfoPersisted.content,
renderMarkdownPath: pageInfoPersisted.markdownPreviewPath,
- markdownDocsPath: pageInfoPersisted.markdownHelpPath,
uploadsPath: pageInfoPersisted.uploadsPath,
autofocus: pageInfoPersisted.persisted,
+ markdownDocsPath: pageInfoPersisted.markdownHelpPath,
}),
);
@@ -172,7 +170,7 @@ describe('WikiForm', () => {
nextTick();
- expect(findMarkdownEditor().props('enablePreview')).toBe(enabled);
+ expect(findMarkdownEditor().vm.$attrs['enable-preview']).toBe(enabled);
});
it.each`
@@ -306,7 +304,7 @@ describe('WikiForm', () => {
expect(findFormat().element.getAttribute('disabled')).toBeDefined();
});
- it('sends tracking event when editor loads', async () => {
+ it('sends tracking event when editor loads', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, {
label: WIKI_CONTENT_EDITOR_TRACKING_LABEL,
});
@@ -320,7 +318,7 @@ describe('WikiForm', () => {
await triggerFormSubmit();
});
- it('triggers tracking events on form submit', async () => {
+ it('triggers tracking events on form submit', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, {
label: WIKI_CONTENT_EDITOR_TRACKING_LABEL,
});
diff --git a/spec/frontend/pdf/index_spec.js b/spec/frontend/pdf/index_spec.js
index 98946412264..23477c73ba0 100644
--- a/spec/frontend/pdf/index_spec.js
+++ b/spec/frontend/pdf/index_spec.js
@@ -7,10 +7,6 @@ describe('PDFLab component', () => {
const mountComponent = ({ pdf }) => shallowMount(PDFLab, { propsData: { pdf } });
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('without PDF data', () => {
beforeEach(() => {
wrapper = mountComponent({ pdf: '' });
diff --git a/spec/frontend/pdf/page_spec.js b/spec/frontend/pdf/page_spec.js
index 4cf83a3252d..1d5c5cd98c4 100644
--- a/spec/frontend/pdf/page_spec.js
+++ b/spec/frontend/pdf/page_spec.js
@@ -9,10 +9,6 @@ jest.mock('pdfjs-dist/webpack', () => {
describe('Page component', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the page when mounting', async () => {
const testPage = {
render: jest.fn().mockReturnValue({ promise: Promise.resolve() }),
diff --git a/spec/frontend/performance_bar/components/add_request_spec.js b/spec/frontend/performance_bar/components/add_request_spec.js
index 5460feb66fe..de9cc1e8008 100644
--- a/spec/frontend/performance_bar/components/add_request_spec.js
+++ b/spec/frontend/performance_bar/components/add_request_spec.js
@@ -13,10 +13,6 @@ describe('add request form', () => {
wrapper = mount(AddRequest);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('hides the input on load', () => {
expect(findGlFormInput().exists()).toBe(false);
});
diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js
index 5ab2c9abe5d..4194639fffe 100644
--- a/spec/frontend/performance_bar/components/detailed_metric_spec.js
+++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js
@@ -38,10 +38,6 @@ describe('detailedMetric', () => {
const findAllSummaryItems = () =>
wrapper.findAllByTestId('performance-bar-summary-item').wrappers.map((w) => w.text());
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when the current request has no details', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/performance_bar/components/performance_bar_app_spec.js b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
index 2c9ab4bf78d..7a018236314 100644
--- a/spec/frontend/performance_bar/components/performance_bar_app_spec.js
+++ b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
@@ -1,18 +1,53 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
+import { GlLink } from '@gitlab/ui';
import PerformanceBarApp from '~/performance_bar/components/performance_bar_app.vue';
import PerformanceBarStore from '~/performance_bar/stores/performance_bar_store';
describe('performance bar app', () => {
+ let wrapper;
const store = new PerformanceBarStore();
- const wrapper = shallowMount(PerformanceBarApp, {
- propsData: {
- store,
- env: 'development',
- requestId: '123',
- statsUrl: 'https://log.gprd.gitlab.net/app/dashboards#/view/',
- peekUrl: '/-/peek/results',
- profileUrl: '?lineprofiler=true',
- },
+ store.addRequest('123', 'https://gitlab.com', '', {}, 'GET');
+ const createComponent = () => {
+ wrapper = mount(PerformanceBarApp, {
+ propsData: {
+ store,
+ env: 'development',
+ requestId: '123',
+ requestMethod: 'GET',
+ statsUrl: 'https://log.gprd.gitlab.net/app/dashboards#/view/',
+ peekUrl: '/-/peek/results',
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('flamegraph buttons', () => {
+ const flamegraphDiv = () => wrapper.find('#peek-flamegraph');
+ const flamegraphLinks = () => flamegraphDiv().findAllComponents(GlLink);
+
+ it('creates three flamegraph buttons based on the path', () => {
+ expect(flamegraphLinks()).toHaveLength(3);
+
+ ['wall', 'cpu', 'object'].forEach((path, index) => {
+ expect(flamegraphLinks().at(index).attributes('href')).toBe(
+ `https://gitlab.com?performance_bar=flamegraph&stackprof_mode=${path}`,
+ );
+ });
+ });
+ });
+
+ describe('memory report button', () => {
+ const memoryReportDiv = () => wrapper.find('#peek-memory-report');
+ const memoryReportLink = () => memoryReportDiv().findComponent(GlLink);
+
+ it('creates memory report button', () => {
+ expect(memoryReportLink().attributes('href')).toEqual(
+ 'https://gitlab.com?performance_bar=memory',
+ );
+ });
});
it('sets the class to match the environment', () => {
diff --git a/spec/frontend/performance_bar/components/request_warning_spec.js b/spec/frontend/performance_bar/components/request_warning_spec.js
index 9dd8ea9f933..7b6d8ff695d 100644
--- a/spec/frontend/performance_bar/components/request_warning_spec.js
+++ b/spec/frontend/performance_bar/components/request_warning_spec.js
@@ -5,10 +5,6 @@ describe('request warning', () => {
let wrapper;
const htmlId = 'request-123';
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when the request has warnings', () => {
beforeEach(() => {
wrapper = shallowMount(RequestWarning, {
diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js
index f09b0cc3df8..1849c373326 100644
--- a/spec/frontend/performance_bar/index_spec.js
+++ b/spec/frontend/performance_bar/index_spec.js
@@ -20,9 +20,9 @@ describe('performance bar wrapper', () => {
peekWrapper.setAttribute('id', 'js-peek');
peekWrapper.dataset.env = 'development';
peekWrapper.dataset.requestId = '123';
+ peekWrapper.dataset.requestMethod = 'GET';
peekWrapper.dataset.peekUrl = '/-/peek/results';
peekWrapper.dataset.statsUrl = 'https://log.gprd.gitlab.net/app/dashboards#/view/';
- peekWrapper.dataset.profileUrl = '?lineprofiler=true';
mock = new MockAdapter(axios);
@@ -70,7 +70,13 @@ describe('performance bar wrapper', () => {
it('adds the request immediately', () => {
vm.addRequest('123', 'https://gitlab.com/');
- expect(vm.store.addRequest).toHaveBeenCalledWith('123', 'https://gitlab.com/', undefined);
+ expect(vm.store.addRequest).toHaveBeenCalledWith(
+ '123',
+ 'https://gitlab.com/',
+ undefined,
+ undefined,
+ undefined,
+ );
});
});
diff --git a/spec/frontend/performance_bar/services/performance_bar_service_spec.js b/spec/frontend/performance_bar/services/performance_bar_service_spec.js
index 1bb70a43a1b..b1f5f4d6982 100644
--- a/spec/frontend/performance_bar/services/performance_bar_service_spec.js
+++ b/spec/frontend/performance_bar/services/performance_bar_service_spec.js
@@ -66,7 +66,7 @@ describe('PerformanceBarService', () => {
describe('operationName', () => {
function requestUrl(response, peekUrl) {
- return PerformanceBarService.callbackParams(response, peekUrl)[3];
+ return PerformanceBarService.callbackParams(response, peekUrl)[4];
}
it('gets the operation name from response.config', () => {
diff --git a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js
index 7d5c5031792..170469db6ad 100644
--- a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js
+++ b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js
@@ -46,6 +46,14 @@ describe('PerformanceBarStore', () => {
store.addRequest('id', 'http://localhost:3001/api/graphql', 'someOperation');
expect(findUrl('id')).toBe('graphql (someOperation)');
});
+
+ it('appends the number of batches queries when it is a GraphQL call', () => {
+ store.addRequest('id', 'http://localhost:3001/api/graphql', 'someOperation');
+ store.addRequest('id', 'http://localhost:3001/api/graphql', 'anotherOperation');
+ store.addRequest('id', 'http://localhost:3001/api/graphql', 'anotherOne');
+ store.addRequest('anotherId', 'http://localhost:3001/api/graphql', 'operationName');
+ expect(findUrl('id')).toBe('graphql (someOperation) [3 queries batched]');
+ });
});
describe('setRequestDetailsData', () => {
diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js
index 6519989661f..376575a8acb 100644
--- a/spec/frontend/persistent_user_callout_spec.js
+++ b/spec/frontend/persistent_user_callout_spec.js
@@ -1,12 +1,12 @@
import MockAdapter from 'axios-mock-adapter';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import PersistentUserCallout from '~/persistent_user_callout';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('PersistentUserCallout', () => {
const dismissEndpoint = '/dismiss';
diff --git a/spec/frontend/pipeline_wizard/components/commit_spec.js b/spec/frontend/pipeline_wizard/components/commit_spec.js
index fa30b9c2b97..7095525e948 100644
--- a/spec/frontend/pipeline_wizard/components/commit_spec.js
+++ b/spec/frontend/pipeline_wizard/components/commit_spec.js
@@ -74,10 +74,6 @@ describe('Pipeline Wizard - Commit Page', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows a commit message input with the correct label', () => {
expect(wrapper.findByTestId('commit_message').exists()).toBe(true);
expect(wrapper.find('label[for="commit_message"]').text()).toBe(i18n.commitMessageLabel);
@@ -121,10 +117,6 @@ describe('Pipeline Wizard - Commit Page', () => {
expect(wrapper.findByTestId('load-error').exists()).toBe(true);
expect(wrapper.findByTestId('load-error').text()).toBe(i18n.errors.loadError);
});
-
- afterEach(() => {
- wrapper.destroy();
- });
});
describe('commit result handling', () => {
@@ -136,7 +128,7 @@ describe('Pipeline Wizard - Commit Page', () => {
await waitForPromises();
});
- it('will not show an error', async () => {
+ it('will not show an error', () => {
expect(wrapper.findByTestId('commit-error').exists()).not.toBe(true);
});
@@ -151,7 +143,6 @@ describe('Pipeline Wizard - Commit Page', () => {
});
afterEach(() => {
- wrapper.destroy();
jest.clearAllMocks();
});
});
@@ -164,7 +155,7 @@ describe('Pipeline Wizard - Commit Page', () => {
await waitForPromises();
});
- it('will show an error', async () => {
+ it('will show an error', () => {
expect(wrapper.findByTestId('commit-error').exists()).toBe(true);
expect(wrapper.findByTestId('commit-error').text()).toBe(i18n.errors.commitError);
});
@@ -178,7 +169,6 @@ describe('Pipeline Wizard - Commit Page', () => {
});
afterEach(() => {
- wrapper.destroy();
jest.clearAllMocks();
});
});
@@ -246,15 +236,11 @@ describe('Pipeline Wizard - Commit Page', () => {
await waitForPromises();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('sets up without error', async () => {
+ it('sets up without error', () => {
expect(consoleSpy).not.toHaveBeenCalled();
});
- it('does not show a load error', async () => {
+ it('does not show a load error', () => {
expect(wrapper.findByTestId('load-error').exists()).not.toBe(true);
});
diff --git a/spec/frontend/pipeline_wizard/components/editor_spec.js b/spec/frontend/pipeline_wizard/components/editor_spec.js
index dd0a609043a..6d7d4363189 100644
--- a/spec/frontend/pipeline_wizard/components/editor_spec.js
+++ b/spec/frontend/pipeline_wizard/components/editor_spec.js
@@ -9,10 +9,6 @@ describe('Pages Yaml Editor wrapper', () => {
propsData: { doc: new Document({ foo: 'bar' }), filename: 'foo.yml' },
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('mount hook', () => {
beforeEach(() => {
wrapper = mount(YamlEditor, defaultOptions);
diff --git a/spec/frontend/pipeline_wizard/components/input_wrapper_spec.js b/spec/frontend/pipeline_wizard/components/input_wrapper_spec.js
index f288264a11e..7f521e2523e 100644
--- a/spec/frontend/pipeline_wizard/components/input_wrapper_spec.js
+++ b/spec/frontend/pipeline_wizard/components/input_wrapper_spec.js
@@ -33,10 +33,6 @@ describe('Pipeline Wizard -- Input Wrapper', () => {
inputChild = wrapper.findComponent(TextWidget);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('will replace its value in compiled', async () => {
await inputChild.vm.$emit('input', inputValue);
const expected = new Document({
@@ -54,10 +50,6 @@ describe('Pipeline Wizard -- Input Wrapper', () => {
});
describe('Target Path Discovery', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
scenario | template | target | expected
${'simple nested object'} | ${{ foo: { bar: { baz: '$BOO' } } }} | ${'$BOO'} | ${['foo', 'bar', 'baz']}
diff --git a/spec/frontend/pipeline_wizard/components/step_nav_spec.js b/spec/frontend/pipeline_wizard/components/step_nav_spec.js
index c6eac1386fa..8a94f58523a 100644
--- a/spec/frontend/pipeline_wizard/components/step_nav_spec.js
+++ b/spec/frontend/pipeline_wizard/components/step_nav_spec.js
@@ -19,17 +19,13 @@ describe('Pipeline Wizard - Step Navigation Component', () => {
nextButton = wrapper.findByTestId('next-button');
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
scenario | showBackButton | showNextButton
${'does not show prev button'} | ${false} | ${false}
${'has prev, but not next'} | ${true} | ${false}
${'has next, but not prev'} | ${false} | ${true}
${'has both next and prev'} | ${true} | ${true}
- `('$scenario', async ({ showBackButton, showNextButton }) => {
+ `('$scenario', ({ showBackButton, showNextButton }) => {
createComponent({ showBackButton, showNextButton });
expect(prevButton.exists()).toBe(showBackButton);
@@ -57,16 +53,16 @@ describe('Pipeline Wizard - Step Navigation Component', () => {
expect(wrapper.emitted().next.length).toBe(1);
});
- it('enables the next button if nextButtonEnabled ist set to true', async () => {
+ it('enables the next button if nextButtonEnabled ist set to true', () => {
createComponent({ nextButtonEnabled: true });
- expect(nextButton.attributes('disabled')).not.toBe('disabled');
+ expect(nextButton.attributes('disabled')).toBeUndefined();
});
- it('disables the next button if nextButtonEnabled ist set to false', async () => {
+ it('disables the next button if nextButtonEnabled ist set to false', () => {
createComponent({ nextButtonEnabled: false });
- expect(nextButton.attributes('disabled')).toBe('disabled');
+ expect(nextButton.attributes('disabled')).toBeDefined();
});
it('does not emit "next" event when clicking next button while nextButtonEnabled ist set to false', async () => {
diff --git a/spec/frontend/pipeline_wizard/components/step_spec.js b/spec/frontend/pipeline_wizard/components/step_spec.js
index 00b57f95ccc..99a7eff7acc 100644
--- a/spec/frontend/pipeline_wizard/components/step_spec.js
+++ b/spec/frontend/pipeline_wizard/components/step_spec.js
@@ -56,10 +56,6 @@ describe('Pipeline Wizard - Step Page', () => {
});
};
- afterEach(async () => {
- await wrapper.destroy();
- });
-
describe('input children', () => {
beforeEach(() => {
createComponent();
@@ -207,7 +203,7 @@ describe('Pipeline Wizard - Step Page', () => {
findInputWrappers();
});
- it('injects the template when an input wrapper emits a beforeUpdate:compiled event', async () => {
+ it('injects the template when an input wrapper emits a beforeUpdate:compiled event', () => {
input1.vm.$emit('beforeUpdate:compiled');
expect(wrapper.vm.compiled.toString()).toBe(compiledYamlAfterInitialLoad);
diff --git a/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js b/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js
index b8e194015b0..52e5d49ec99 100644
--- a/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js
+++ b/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js
@@ -39,10 +39,6 @@ describe('Pipeline Wizard - Checklist Widget', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('creates the component', () => {
createComponent();
expect(wrapper.exists()).toBe(true);
diff --git a/spec/frontend/pipeline_wizard/components/widgets/list_spec.js b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js
index c9e9f5caebe..df8841e6ad3 100644
--- a/spec/frontend/pipeline_wizard/components/widgets/list_spec.js
+++ b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js
@@ -39,10 +39,6 @@ describe('Pipeline Wizard - List Widget', () => {
};
describe('component setup and interface', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('prints the label inside the legend', () => {
createComponent();
@@ -55,7 +51,7 @@ describe('Pipeline Wizard - List Widget', () => {
expect(findGlFormGroup().attributes('labeldescription')).toBe(defaultProps.description);
});
- it('sets the input field type attribute to "text"', async () => {
+ it('sets the input field type attribute to "text"', () => {
createComponent();
expect(findFirstGlFormInputGroup().attributes('type')).toBe('text');
@@ -168,11 +164,7 @@ describe('Pipeline Wizard - List Widget', () => {
});
describe('form validation', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('does not show validation state when untouched', async () => {
+ it('does not show validation state when untouched', () => {
createComponent({}, mountExtended);
expect(findGlFormGroup().classes()).not.toContain('is-valid');
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
diff --git a/spec/frontend/pipeline_wizard/components/widgets/text_spec.js b/spec/frontend/pipeline_wizard/components/widgets/text_spec.js
index a11c0214d15..041ca05fd2c 100644
--- a/spec/frontend/pipeline_wizard/components/widgets/text_spec.js
+++ b/spec/frontend/pipeline_wizard/components/widgets/text_spec.js
@@ -27,12 +27,6 @@ describe('Pipeline Wizard - Text Widget', () => {
});
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
it('creates an input element with the correct label', () => {
createComponent();
@@ -123,7 +117,7 @@ describe('Pipeline Wizard - Text Widget', () => {
expect(findGlFormGroup().classes()).toContain('is-invalid');
});
- it('does not update validation if not required', async () => {
+ it('does not update validation if not required', () => {
createComponent({
pattern: null,
validate: true,
diff --git a/spec/frontend/pipeline_wizard/components/wrapper_spec.js b/spec/frontend/pipeline_wizard/components/wrapper_spec.js
index 33c6394eb41..b8d84572873 100644
--- a/spec/frontend/pipeline_wizard/components/wrapper_spec.js
+++ b/spec/frontend/pipeline_wizard/components/wrapper_spec.js
@@ -48,10 +48,6 @@ describe('Pipeline Wizard - wrapper.vue', () => {
wrapper.find(`[data-input-target="${target}"]`).find('input');
describe('display', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('shows the steps', () => {
createComponent();
@@ -86,7 +82,7 @@ describe('Pipeline Wizard - wrapper.vue', () => {
expect(wrapper.findByTestId('editor-header').text()).toBe(expectedMessage);
});
- it('shows the editor header with a custom filename', async () => {
+ it('shows the editor header with a custom filename', () => {
const filename = 'my-file.yml';
createComponent({
filename,
@@ -145,12 +141,8 @@ describe('Pipeline Wizard - wrapper.vue', () => {
}
});
- afterEach(() => {
- wrapper.destroy();
- });
-
if (expectCommitStepShown) {
- it('does not show the step wrapper', async () => {
+ it('does not show the step wrapper', () => {
expect(wrapper.findComponent(WizardStep).isVisible()).toBe(false);
});
@@ -158,7 +150,7 @@ describe('Pipeline Wizard - wrapper.vue', () => {
expect(wrapper.findComponent(CommitStep).isVisible()).toBe(true);
});
} else {
- it('passes the correct step config to the step component', async () => {
+ it('passes the correct step config to the step component', () => {
expect(getStepWrapper().props('inputs')).toMatchObject(expectStepDef.inputs);
});
@@ -188,10 +180,6 @@ describe('Pipeline Wizard - wrapper.vue', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('initially shows a placeholder', async () => {
const editorContent = getEditorContent();
@@ -223,10 +211,6 @@ describe('Pipeline Wizard - wrapper.vue', () => {
createComponent();
});
- afterAll(() => {
- wrapper.destroy();
- });
-
it('editor reflects changes', async () => {
const newCompiledDoc = new Document({ faa: 'bur' });
await getStepWrapper().vm.$emit('update:compiled', newCompiledDoc);
@@ -240,10 +224,6 @@ describe('Pipeline Wizard - wrapper.vue', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('highlight requests by the step get passed on to the editor', async () => {
const highlight = 'foo';
@@ -266,7 +246,7 @@ describe('Pipeline Wizard - wrapper.vue', () => {
});
describe('integration test', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({}, mountExtended);
});
@@ -309,7 +289,6 @@ describe('Pipeline Wizard - wrapper.vue', () => {
});
afterEach(() => {
- wrapper.destroy();
inputField = undefined;
});
@@ -331,10 +310,6 @@ describe('Pipeline Wizard - wrapper.vue', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('emits done', () => {
expect(wrapper.emitted('done')).toBeUndefined();
diff --git a/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js b/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js
index 13234525159..e7bd7f686b6 100644
--- a/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js
+++ b/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js
@@ -24,10 +24,6 @@ describe('PipelineWizard', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('mounts without error', () => {
const consoleSpy = jest.spyOn(console, 'error');
diff --git a/spec/frontend/pipelines/components/dag/dag_annotations_spec.js b/spec/frontend/pipelines/components/dag/dag_annotations_spec.js
index 28a08b6da0f..124f02bcec7 100644
--- a/spec/frontend/pipelines/components/dag/dag_annotations_spec.js
+++ b/spec/frontend/pipelines/components/dag/dag_annotations_spec.js
@@ -14,10 +14,6 @@ describe('The DAG annotations', () => {
const getToggleButton = () => wrapper.findComponent(GlButton);
const createComponent = (propsData = {}, method = shallowMount) => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
-
wrapper = method(DagAnnotations, {
propsData,
data() {
@@ -28,11 +24,6 @@ describe('The DAG annotations', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when there is one annotation', () => {
const currentNote = singleNote['dag-link103'];
diff --git a/spec/frontend/pipelines/components/dag/dag_graph_spec.js b/spec/frontend/pipelines/components/dag/dag_graph_spec.js
index 4619548d1bb..6b46be3dd49 100644
--- a/spec/frontend/pipelines/components/dag/dag_graph_spec.js
+++ b/spec/frontend/pipelines/components/dag/dag_graph_spec.js
@@ -36,11 +36,6 @@ describe('The DAG graph', () => {
createComponent({ graphData: parsedData });
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('in the basic case', () => {
beforeEach(() => {
/*
diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js
index b0c26976c85..53719065611 100644
--- a/spec/frontend/pipelines/components/dag/dag_spec.js
+++ b/spec/frontend/pipelines/components/dag/dag_spec.js
@@ -30,10 +30,6 @@ describe('Pipeline DAG graph wrapper', () => {
provideOverride = {},
method = shallowMount,
} = {}) => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
-
wrapper = method(Dag, {
provide: {
pipelineProjectPath: 'root/abc-dag',
@@ -51,11 +47,6 @@ describe('Pipeline DAG graph wrapper', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when a query argument is undefined', () => {
beforeEach(() => {
createComponent({
@@ -64,7 +55,7 @@ describe('Pipeline DAG graph wrapper', () => {
});
});
- it('does not render the graph', async () => {
+ it('does not render the graph', () => {
expect(getGraph().exists()).toBe(false);
});
@@ -75,7 +66,7 @@ describe('Pipeline DAG graph wrapper', () => {
describe('when all query variables are defined', () => {
describe('but the parse fails', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({
graphData: unparseableGraph,
});
@@ -93,7 +84,7 @@ describe('Pipeline DAG graph wrapper', () => {
});
describe('parse succeeds', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({ method: mount });
});
@@ -107,7 +98,7 @@ describe('Pipeline DAG graph wrapper', () => {
});
describe('parse succeeds, but the resulting graph is too small', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({
graphData: tooSmallGraph,
});
@@ -125,7 +116,7 @@ describe('Pipeline DAG graph wrapper', () => {
});
describe('the returned data is empty', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({
method: mount,
graphData: graphWithoutDependencies,
@@ -144,7 +135,7 @@ describe('Pipeline DAG graph wrapper', () => {
});
describe('annotations', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
});
diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js
index d1da7cb3acf..6a2453704db 100644
--- a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js
+++ b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js
@@ -4,15 +4,15 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import FailedJobsApp from '~/pipelines/components/jobs/failed_jobs_app.vue';
import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue';
import GetFailedJobsQuery from '~/pipelines/graphql/queries/get_failed_jobs.query.graphql';
-import { mockFailedJobsQueryResponse, mockFailedJobsSummaryData } from '../../mock_data';
+import { mockFailedJobsQueryResponse } from '../../mock_data';
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Failed Jobs App', () => {
let wrapper;
@@ -27,15 +27,12 @@ describe('Failed Jobs App', () => {
return createMockApollo(requestHandlers);
};
- const createComponent = (resolver, failedJobsSummaryData = mockFailedJobsSummaryData) => {
+ const createComponent = (resolver) => {
wrapper = shallowMount(FailedJobsApp, {
provide: {
fullPath: 'root/ci-project',
pipelineIid: 1,
},
- propsData: {
- failedJobsSummary: failedJobsSummaryData,
- },
apolloProvider: createMockApolloProvider(resolver),
});
};
@@ -44,10 +41,6 @@ describe('Failed Jobs App', () => {
resolverSpy = jest.fn().mockResolvedValue(mockFailedJobsQueryResponse);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('loading spinner', () => {
it('displays loading spinner when fetching failed jobs', () => {
createComponent(resolverSpy);
diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js
index 0df15afd70d..d5307b87a11 100644
--- a/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js
+++ b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js
@@ -4,18 +4,18 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { createAlert } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue';
import RetryFailedJobMutation from '~/pipelines/graphql/mutations/retry_failed_job.mutation.graphql';
import {
successRetryMutationResponse,
failedRetryMutationResponse,
- mockPreparedFailedJobsData,
- mockPreparedFailedJobsDataNoPermission,
+ mockFailedJobsData,
+ mockFailedJobsDataNoPermission,
} from '../../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility');
Vue.use(VueApollo);
@@ -30,13 +30,15 @@ describe('Failed Jobs Table', () => {
const findRetryButton = () => wrapper.findComponent(GlButton);
const findJobLink = () => wrapper.findComponent(GlLink);
const findJobLog = () => wrapper.findByTestId('job-log');
+ const findSummary = (index) => wrapper.findAllByTestId('job-trace-summary').at(index);
+ const findFirstFailureMessage = () => wrapper.findAllByTestId('job-failure-message').at(0);
const createMockApolloProvider = (resolver) => {
const requestHandlers = [[RetryFailedJobMutation, resolver]];
return createMockApollo(requestHandlers);
};
- const createComponent = (resolver, failedJobsData = mockPreparedFailedJobsData) => {
+ const createComponent = (resolver, failedJobsData = mockFailedJobsData) => {
wrapper = mountExtended(FailedJobsTable, {
propsData: {
failedJobs: failedJobsData,
@@ -45,23 +47,37 @@ describe('Failed Jobs Table', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays the failed jobs table', () => {
createComponent();
expect(findJobsTable().exists()).toBe(true);
});
+ it('displays failed job summary', () => {
+ createComponent();
+
+ expect(findSummary(0).text()).toBe('Html Summary');
+ });
+
+ it('displays no job log when no trace', () => {
+ createComponent();
+
+ expect(findSummary(1).text()).toBe('No job log');
+ });
+
+ it('displays failure reason', () => {
+ createComponent();
+
+ expect(findFirstFailureMessage().text()).toBe('Job failed');
+ });
+
it('calls the retry failed job mutation correctly', () => {
createComponent(successRetryMutationHandler);
findRetryButton().trigger('click');
expect(successRetryMutationHandler).toHaveBeenCalledWith({
- id: mockPreparedFailedJobsData[0].id,
+ id: mockFailedJobsData[0].id,
});
});
@@ -78,7 +94,7 @@ describe('Failed Jobs Table', () => {
await waitForPromises();
- expect(redirectTo).toHaveBeenCalledWith(job.detailedStatus.detailsPath);
+ expect(redirectTo).toHaveBeenCalledWith(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated
});
it('shows error message if the retry failed job mutation fails', async () => {
@@ -94,7 +110,7 @@ describe('Failed Jobs Table', () => {
});
it('hides the job log and retry button if a user does not have permission', () => {
- createComponent([[]], mockPreparedFailedJobsDataNoPermission);
+ createComponent([[]], mockFailedJobsDataNoPermission);
expect(findJobLog().exists()).toBe(false);
expect(findRetryButton().exists()).toBe(false);
@@ -110,8 +126,6 @@ describe('Failed Jobs Table', () => {
it('job name links to the correct job', () => {
createComponent();
- expect(findJobLink().attributes('href')).toBe(
- mockPreparedFailedJobsData[0].detailedStatus.detailsPath,
- );
+ expect(findJobLink().attributes('href')).toBe(mockFailedJobsData[0].detailedStatus.detailsPath);
});
});
diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
index 9bc14266593..39475788fe2 100644
--- a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
+++ b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
@@ -4,7 +4,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import JobsApp from '~/pipelines/components/jobs/jobs_app.vue';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import getPipelineJobsQuery from '~/pipelines/graphql/queries/get_pipeline_jobs.query.graphql';
@@ -12,7 +12,7 @@ import { mockPipelineJobsQueryResponse } from '../../mock_data';
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Jobs app', () => {
let wrapper;
@@ -34,7 +34,7 @@ describe('Jobs app', () => {
const createComponent = (resolver) => {
wrapper = shallowMount(JobsApp, {
provide: {
- fullPath: 'root/ci-project',
+ projectPath: 'root/ci-project',
pipelineIid: 1,
},
apolloProvider: createMockApolloProvider(resolver),
@@ -45,10 +45,6 @@ describe('Jobs app', () => {
resolverSpy = jest.fn().mockResolvedValue(mockPipelineJobsQueryResponse);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('loading spinner', () => {
const setup = async () => {
createComponent(resolverSpy);
diff --git a/spec/frontend/pipelines/components/jobs/utils_spec.js b/spec/frontend/pipelines/components/jobs/utils_spec.js
deleted file mode 100644
index 720446cfda3..00000000000
--- a/spec/frontend/pipelines/components/jobs/utils_spec.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { prepareFailedJobs } from '~/pipelines/components/jobs/utils';
-import {
- mockFailedJobsData,
- mockFailedJobsSummaryData,
- mockPreparedFailedJobsData,
-} from '../../mock_data';
-
-describe('Utils', () => {
- it('prepares failed jobs data correctly', () => {
- expect(prepareFailedJobs(mockFailedJobsData, mockFailedJobsSummaryData)).toEqual(
- mockPreparedFailedJobsData,
- );
- });
-});
diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list_spec.js
index 5ea57c51e70..a4ecb9041c9 100644
--- a/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list_spec.js
+++ b/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list_spec.js
@@ -19,7 +19,7 @@ describe('Linked pipeline mini list', () => {
const createComponent = (props = {}) => {
wrapper = mount(LinkedPipelinesMiniList, {
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
propsData: {
...props,
@@ -34,11 +34,6 @@ describe('Linked pipeline mini list', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should render one linked pipeline item', () => {
expect(findLinkedPipelineMiniItem().exists()).toBe(true);
});
@@ -102,11 +97,6 @@ describe('Linked pipeline mini list', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should render three linked pipeline items', () => {
expect(findLinkedPipelineMiniItems().exists()).toBe(true);
expect(findLinkedPipelineMiniItems().length).toBe(3);
diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js
index 036b82530d5..e7415a6c596 100644
--- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js
+++ b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js
@@ -33,11 +33,6 @@ describe('Pipeline Mini Graph', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should render the pipeline stages', () => {
expect(findPipelineStages().exists()).toBe(true);
});
@@ -71,11 +66,6 @@ describe('Pipeline Mini Graph', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should have the correct props', () => {
expect(findPipelineMiniGraph().props()).toMatchObject({
downstreamPipelines: [],
@@ -118,11 +108,6 @@ describe('Pipeline Mini Graph', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should render the downstream linked pipelines mini list only', () => {
expect(findLinkedPipelineDownstream().exists()).toBe(true);
expect(findLinkedPipelineUpstream().exists()).toBe(false);
diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js
index ab2056b4035..21d92fec9bf 100644
--- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js
+++ b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js
@@ -45,11 +45,10 @@ describe('Pipelines stage component', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
-
eventHub.$emit.mockRestore();
mock.restore();
+ // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy
+ wrapper.destroy();
});
const findCiActionBtn = () => wrapper.find('.js-ci-action');
@@ -130,7 +129,7 @@ describe('Pipelines stage component', () => {
await axios.waitForAll();
});
- it('renders the received data and emits the correct events', async () => {
+ it('renders the received data and emits the correct events', () => {
expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name);
expect(findDropdownMenuTitle().text()).toContain(stageReply.name);
expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown');
diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js
index c123f53886e..73e810bde99 100644
--- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js
+++ b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js
@@ -60,9 +60,4 @@ describe('Pipeline Stages', () => {
expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(true);
expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(true);
});
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
});
diff --git a/spec/frontend/pipelines/components/pipeline_tabs_spec.js b/spec/frontend/pipelines/components/pipeline_tabs_spec.js
index c2cb95d4320..fde13128662 100644
--- a/spec/frontend/pipelines/components/pipeline_tabs_spec.js
+++ b/spec/frontend/pipelines/components/pipeline_tabs_spec.js
@@ -19,7 +19,6 @@ describe('The Pipeline Tabs', () => {
const defaultProvide = {
defaultTabValue: '',
failedJobsCount: 1,
- failedJobsSummary: [],
totalJobCount: 10,
testsCount: 123,
};
@@ -39,10 +38,6 @@ describe('The Pipeline Tabs', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Tabs', () => {
it.each`
tabName | tabComponent
diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
index ba7262353f0..51a4487a3ef 100644
--- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
@@ -51,8 +51,6 @@ describe('Pipelines filtered search', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
- wrapper = null;
});
it('displays UI elements', () => {
diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js
index 6531a15ab8e..b560eea4882 100644
--- a/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js
@@ -29,11 +29,6 @@ describe('CI Templates', () => {
const findTemplateName = () => wrapper.findByTestId('template-name');
const findTemplateLogo = () => wrapper.findByTestId('template-logo');
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('renders template list', () => {
beforeEach(() => {
createWrapper();
diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js
index 0c2938921d6..700be076e0c 100644
--- a/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js
@@ -37,11 +37,6 @@ describe('iOS Templates', () => {
const findSetupRunnerLink = () => wrapper.findByText('Set up a runner');
const configurePipelineLink = () => wrapper.findByTestId('configure-pipeline-link');
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when ios runners are not available', () => {
beforeEach(() => {
wrapper = createWrapper({ iosRunnersAvailable: false });
diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js
index f255e0d857f..4bf4257f462 100644
--- a/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js
@@ -1,24 +1,10 @@
import '~/commons';
-import { GlButton, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import { stubExperiments } from 'helpers/experimentation_helper';
-import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue';
import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue';
-import {
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
- RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
- RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
- I18N,
-} from '~/ci/pipeline_editor/constants';
const pipelineEditorPath = '/-/ci/editor';
-const ciRunnerSettingsPath = '/-/settings/ci_cd';
-
-jest.mock('~/experimentation/experiment_tracking');
describe('Pipelines CI Templates', () => {
let wrapper;
@@ -28,8 +14,6 @@ describe('Pipelines CI Templates', () => {
return shallowMountExtended(PipelinesCiTemplates, {
provide: {
pipelineEditorPath,
- ciRunnerSettingsPath,
- anyRunnersAvailable: true,
...propsData,
},
stubs,
@@ -38,24 +22,17 @@ describe('Pipelines CI Templates', () => {
const findTestTemplateLink = () => wrapper.findByTestId('test-template-link');
const findCiTemplates = () => wrapper.findComponent(CiTemplates);
- const findSettingsLink = () => wrapper.findByTestId('settings-link');
- const findDocumentationLink = () => wrapper.findByTestId('documentation-link');
- const findSettingsButton = () => wrapper.findByTestId('settings-button');
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
- describe('renders test template', () => {
+ describe('templates', () => {
beforeEach(() => {
wrapper = createWrapper();
});
- it('links to the getting started template', () => {
+ it('renders test template and Ci templates', () => {
expect(findTestTemplateLink().attributes('href')).toBe(
pipelineEditorPath.concat('?template=Getting-Started'),
);
+ expect(findCiTemplates().exists()).toBe(true);
});
});
@@ -78,84 +55,4 @@ describe('Pipelines CI Templates', () => {
});
});
});
-
- describe('when the runners_availability_section experiment is active', () => {
- beforeEach(() => {
- stubExperiments({ runners_availability_section: 'candidate' });
- });
-
- describe('when runners are available', () => {
- beforeEach(() => {
- wrapper = createWrapper({ anyRunnersAvailable: true }, { GitlabExperiment, GlSprintf });
- });
-
- it('show the runners available section', () => {
- expect(wrapper.text()).toContain(I18N.runners.title);
- });
-
- it('tracks an event when clicking the settings link', () => {
- findSettingsLink().vm.$emit('click');
-
- expect(ExperimentTracking).toHaveBeenCalledWith(
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- );
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
- RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
- );
- });
-
- it('tracks an event when clicking the documentation link', () => {
- findDocumentationLink().vm.$emit('click');
-
- expect(ExperimentTracking).toHaveBeenCalledWith(
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- );
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
- RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
- );
- });
- });
-
- describe('when runners are not available', () => {
- beforeEach(() => {
- wrapper = createWrapper({ anyRunnersAvailable: false }, { GitlabExperiment, GlButton });
- });
-
- it('show the no runners available section', () => {
- expect(wrapper.text()).toContain(I18N.noRunners.title);
- });
-
- it('tracks an event when clicking the settings button', () => {
- findSettingsButton().trigger('click');
-
- expect(ExperimentTracking).toHaveBeenCalledWith(
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- );
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
- RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
- );
- });
- });
- });
-
- describe.each`
- experimentVariant | anyRunnersAvailable | templatesRendered
- ${'control'} | ${true} | ${true}
- ${'control'} | ${false} | ${true}
- ${'candidate'} | ${true} | ${true}
- ${'candidate'} | ${false} | ${false}
- `(
- 'when the runners_availability_section experiment variant is $experimentVariant and runners are available: $anyRunnersAvailable',
- ({ experimentVariant, anyRunnersAvailable, templatesRendered }) => {
- beforeEach(() => {
- stubExperiments({ runners_availability_section: experimentVariant });
- wrapper = createWrapper({ anyRunnersAvailable });
- });
-
- it(`renders the templates: ${templatesRendered}`, () => {
- expect(findTestTemplateLink().exists()).toBe(templatesRendered);
- expect(findCiTemplates().exists()).toBe(templatesRendered);
- });
- },
- );
});
diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js
index 0abf7f59717..5465e4d77da 100644
--- a/spec/frontend/pipelines/empty_state_spec.js
+++ b/spec/frontend/pipelines/empty_state_spec.js
@@ -35,11 +35,6 @@ describe('Pipelines Empty State', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when user can configure CI', () => {
describe('when the ios_specific_templates experiment is active', () => {
beforeEach(() => {
diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js
index e3eea503b46..890255f225e 100644
--- a/spec/frontend/pipelines/graph/action_component_spec.js
+++ b/spec/frontend/pipelines/graph/action_component_spec.js
@@ -33,7 +33,6 @@ describe('pipeline graph action component', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('render', () => {
diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
index 99bccd21656..cc952eac1d7 100644
--- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
@@ -1,10 +1,10 @@
-import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
-import { mount, shallowMount } from '@vue/test-utils';
+import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubPerformanceWebAPI } from 'helpers/performance';
import waitForPromises from 'helpers/wait_for_promises';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
@@ -48,23 +48,25 @@ describe('Pipeline graph wrapper', () => {
useLocalStorageSpy();
let wrapper;
- const getAlert = () => wrapper.findComponent(GlAlert);
- const getDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]');
- const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const getLinksLayer = () => wrapper.findComponent(LinksLayer);
- const getGraph = () => wrapper.findComponent(PipelineGraph);
- const getStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]');
- const getAllStageColumnGroupsInColumn = () =>
+ let requestHandlers;
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findDependenciesToggle = () => wrapper.findByTestId('show-links-toggle');
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findLinksLayer = () => wrapper.findComponent(LinksLayer);
+ const findGraph = () => wrapper.findComponent(PipelineGraph);
+ const findStageColumnTitle = () => wrapper.findByTestId('stage-column-title');
+ const findAllStageColumnGroupsInColumn = () =>
wrapper.findComponent(StageColumnComponent).findAll('[data-testid="stage-column-group"]');
- const getViewSelector = () => wrapper.findComponent(GraphViewSelector);
- const getViewSelectorTrip = () => getViewSelector().findComponent(GlAlert);
+ const findViewSelector = () => wrapper.findComponent(GraphViewSelector);
+ const findViewSelectorToggle = () => findViewSelector().findComponent(GlToggle);
+ const findViewSelectorTrip = () => findViewSelector().findComponent(GlAlert);
const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const createComponent = ({
apolloProvider,
data = {},
provide = {},
- mountFn = shallowMount,
+ mountFn = shallowMountExtended,
} = {}) => {
wrapper = mountFn(PipelineGraphWrapper, {
provide: {
@@ -84,41 +86,41 @@ describe('Pipeline graph wrapper', () => {
calloutsList = [],
data = {},
getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse),
- mountFn = shallowMount,
+ mountFn = shallowMountExtended,
provide = {},
} = {}) => {
const callouts = mapCallouts(calloutsList);
- const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse(callouts));
- const getPipelineHeaderDataHandler = jest.fn().mockResolvedValue(mockRunningPipelineHeaderData);
- const requestHandlers = [
- [getPipelineHeaderData, getPipelineHeaderDataHandler],
- [getPipelineDetails, getPipelineDetailsHandler],
- [getUserCallouts, getUserCalloutsHandler],
+ requestHandlers = {
+ getUserCalloutsHandler: jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)),
+ getPipelineHeaderDataHandler: jest.fn().mockResolvedValue(mockRunningPipelineHeaderData),
+ getPipelineDetailsHandler,
+ };
+
+ const handlers = [
+ [getPipelineHeaderData, requestHandlers.getPipelineHeaderDataHandler],
+ [getPipelineDetails, requestHandlers.getPipelineDetailsHandler],
+ [getUserCallouts, requestHandlers.getUserCalloutsHandler],
];
- const apolloProvider = createMockApollo(requestHandlers);
+ const apolloProvider = createMockApollo(handlers);
createComponent({ apolloProvider, data, provide, mountFn });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when data is loading', () => {
it('displays the loading icon', () => {
createComponentWithApollo();
- expect(getLoadingIcon().exists()).toBe(true);
+ expect(findLoadingIcon().exists()).toBe(true);
});
it('does not display the alert', () => {
createComponentWithApollo();
- expect(getAlert().exists()).toBe(false);
+ expect(findAlert().exists()).toBe(false);
});
it('does not display the graph', () => {
createComponentWithApollo();
- expect(getGraph().exists()).toBe(false);
+ expect(findGraph().exists()).toBe(false);
});
it('skips querying headerPipeline', () => {
@@ -134,19 +136,19 @@ describe('Pipeline graph wrapper', () => {
});
it('does not display the loading icon', () => {
- expect(getLoadingIcon().exists()).toBe(false);
+ expect(findLoadingIcon().exists()).toBe(false);
});
it('does not display the alert', () => {
- expect(getAlert().exists()).toBe(false);
+ expect(findAlert().exists()).toBe(false);
});
it('displays the graph', () => {
- expect(getGraph().exists()).toBe(true);
+ expect(findGraph().exists()).toBe(true);
});
it('passes the etag resource and metrics path to the graph', () => {
- expect(getGraph().props('configPaths')).toMatchObject({
+ expect(findGraph().props('configPaths')).toMatchObject({
graphqlResourceEtag: defaultProvide.graphqlResourceEtag,
metricsPath: defaultProvide.metricsPath,
});
@@ -162,15 +164,15 @@ describe('Pipeline graph wrapper', () => {
});
it('does not display the loading icon', () => {
- expect(getLoadingIcon().exists()).toBe(false);
+ expect(findLoadingIcon().exists()).toBe(false);
});
it('displays the alert', () => {
- expect(getAlert().exists()).toBe(true);
+ expect(findAlert().exists()).toBe(true);
});
it('does not display the graph', () => {
- expect(getGraph().exists()).toBe(false);
+ expect(findGraph().exists()).toBe(false);
});
});
@@ -185,18 +187,18 @@ describe('Pipeline graph wrapper', () => {
});
it('does not display the loading icon', () => {
- expect(getLoadingIcon().exists()).toBe(false);
+ expect(findLoadingIcon().exists()).toBe(false);
});
it('displays the no iid alert', () => {
- expect(getAlert().exists()).toBe(true);
- expect(getAlert().text()).toBe(
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(
'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.',
);
});
it('does not display the graph', () => {
- expect(getGraph().exists()).toBe(false);
+ expect(findGraph().exists()).toBe(false);
});
});
@@ -207,11 +209,11 @@ describe('Pipeline graph wrapper', () => {
});
describe('when receiving `setSkipRetryModal` event', () => {
it('passes down `skipRetryModal` value as true', async () => {
- expect(getGraph().props('skipRetryModal')).toBe(false);
+ expect(findGraph().props('skipRetryModal')).toBe(false);
- await getGraph().vm.$emit('setSkipRetryModal');
+ await findGraph().vm.$emit('setSkipRetryModal');
- expect(getGraph().props('skipRetryModal')).toBe(true);
+ expect(findGraph().props('skipRetryModal')).toBe(true);
});
});
});
@@ -220,36 +222,37 @@ describe('Pipeline graph wrapper', () => {
beforeEach(async () => {
createComponentWithApollo();
await waitForPromises();
- await getGraph().vm.$emit('error', { type: ACTION_FAILURE });
+ await findGraph().vm.$emit('error', { type: ACTION_FAILURE });
});
it('does not display the loading icon', () => {
- expect(getLoadingIcon().exists()).toBe(false);
+ expect(findLoadingIcon().exists()).toBe(false);
});
it('displays the action error alert', () => {
- expect(getAlert().exists()).toBe(true);
- expect(getAlert().text()).toBe('An error occurred while performing this action.');
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe('An error occurred while performing this action.');
});
it('displays the graph', () => {
- expect(getGraph().exists()).toBe(true);
+ expect(findGraph().exists()).toBe(true);
});
});
describe('when refresh action is emitted', () => {
beforeEach(async () => {
createComponentWithApollo();
- jest.spyOn(wrapper.vm.$apollo.queries.headerPipeline, 'refetch');
- jest.spyOn(wrapper.vm.$apollo.queries.pipeline, 'refetch');
await waitForPromises();
- getGraph().vm.$emit('refreshPipelineGraph');
+ findGraph().vm.$emit('refreshPipelineGraph');
});
it('calls refetch', () => {
- expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(false);
- expect(wrapper.vm.$apollo.queries.headerPipeline.refetch).toHaveBeenCalled();
- expect(wrapper.vm.$apollo.queries.pipeline.refetch).toHaveBeenCalled();
+ expect(requestHandlers.getPipelineHeaderDataHandler).toHaveBeenCalledWith({
+ fullPath: 'frog/amphibirama',
+ iid: '22',
+ });
+ expect(requestHandlers.getPipelineDetailsHandler).toHaveBeenCalledTimes(2);
+ expect(requestHandlers.getUserCalloutsHandler).toHaveBeenCalledWith({});
});
});
@@ -281,18 +284,18 @@ describe('Pipeline graph wrapper', () => {
it('shows correct errors and does not overwrite populated data when data is empty', async () => {
/* fails at first, shows error, no data yet */
- expect(getAlert().exists()).toBe(true);
- expect(getGraph().exists()).toBe(false);
+ expect(findAlert().exists()).toBe(true);
+ expect(findGraph().exists()).toBe(false);
/* succeeds, clears error, shows graph */
await advanceApolloTimers();
- expect(getAlert().exists()).toBe(false);
- expect(getGraph().exists()).toBe(true);
+ expect(findAlert().exists()).toBe(false);
+ expect(findGraph().exists()).toBe(true);
/* fails again, alert returns but data persists */
await advanceApolloTimers();
- expect(getAlert().exists()).toBe(true);
- expect(getGraph().exists()).toBe(true);
+ expect(findAlert().exists()).toBe(true);
+ expect(findGraph().exists()).toBe(true);
});
});
@@ -302,38 +305,38 @@ describe('Pipeline graph wrapper', () => {
beforeEach(async () => {
layersFn = jest.spyOn(parsingUtils, 'listByLayers');
createComponentWithApollo({
- mountFn: mount,
+ mountFn: mountExtended,
});
await waitForPromises();
});
it('appears when pipeline uses needs', () => {
- expect(getViewSelector().exists()).toBe(true);
+ expect(findViewSelector().exists()).toBe(true);
});
it('switches between views', async () => {
const groupsInFirstColumn =
mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes.length;
- expect(getAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn);
- expect(getStageColumnTitle().text()).toBe('build');
- await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW);
- expect(getAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn + 1);
- expect(getStageColumnTitle().text()).toBe('');
+ expect(findAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn);
+ expect(findStageColumnTitle().text()).toBe('build');
+ await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW);
+ expect(findAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn + 1);
+ expect(findStageColumnTitle().text()).toBe('');
});
it('saves the view type to local storage', async () => {
- await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW);
+ await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW);
expect(localStorage.setItem.mock.calls).toEqual([[VIEW_TYPE_KEY, LAYER_VIEW]]);
});
it('calls listByLayers only once no matter how many times view is switched', async () => {
expect(layersFn).not.toHaveBeenCalled();
- await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW);
+ await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW);
expect(layersFn).toHaveBeenCalledTimes(1);
- await getViewSelector().vm.$emit('updateViewType', STAGE_VIEW);
- await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW);
- await getViewSelector().vm.$emit('updateViewType', STAGE_VIEW);
+ await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW);
+ await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW);
+ await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW);
expect(layersFn).toHaveBeenCalledTimes(1);
});
});
@@ -344,7 +347,7 @@ describe('Pipeline graph wrapper', () => {
data: {
currentViewType: LAYER_VIEW,
},
- mountFn: mount,
+ mountFn: mountExtended,
});
jest.runOnlyPendingTimers();
@@ -353,10 +356,10 @@ describe('Pipeline graph wrapper', () => {
it('sets showLinks to true', async () => {
/* This spec uses .props for performance reasons. */
- expect(getLinksLayer().exists()).toBe(true);
- expect(getLinksLayer().props('showLinks')).toBe(false);
- expect(getViewSelector().props('type')).toBe(LAYER_VIEW);
- await getDependenciesToggle().vm.$emit('change', true);
+ expect(findLinksLayer().exists()).toBe(true);
+ expect(findLinksLayer().props('showLinks')).toBe(false);
+ expect(findViewSelector().props('type')).toBe(LAYER_VIEW);
+ await findDependenciesToggle().vm.$emit('change', true);
jest.runOnlyPendingTimers();
await waitForPromises();
@@ -371,15 +374,15 @@ describe('Pipeline graph wrapper', () => {
currentViewType: LAYER_VIEW,
showLinks: true,
},
- mountFn: mount,
+ mountFn: mountExtended,
});
await waitForPromises();
});
it('shows the hover tip in the view selector', async () => {
- await getViewSelector().setData({ showLinksActive: true });
- expect(getViewSelectorTrip().exists()).toBe(true);
+ await findViewSelectorToggle().vm.$emit('change', true);
+ expect(findViewSelectorTrip().exists()).toBe(true);
});
});
@@ -390,7 +393,7 @@ describe('Pipeline graph wrapper', () => {
currentViewType: LAYER_VIEW,
showLinks: true,
},
- mountFn: mount,
+ mountFn: mountExtended,
calloutsList: ['pipeline_needs_hover_tip'.toUpperCase()],
});
@@ -399,8 +402,8 @@ describe('Pipeline graph wrapper', () => {
});
it('does not show the hover tip', async () => {
- await getViewSelector().setData({ showLinksActive: true });
- expect(getViewSelectorTrip().exists()).toBe(false);
+ await findViewSelectorToggle().vm.$emit('change', true);
+ expect(findViewSelectorTrip().exists()).toBe(false);
});
});
@@ -409,7 +412,7 @@ describe('Pipeline graph wrapper', () => {
localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW);
createComponentWithApollo({
- mountFn: mount,
+ mountFn: mountExtended,
});
await waitForPromises();
@@ -440,7 +443,7 @@ describe('Pipeline graph wrapper', () => {
localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW);
createComponentWithApollo({
- mountFn: mount,
+ mountFn: mountExtended,
getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse),
});
@@ -452,7 +455,7 @@ describe('Pipeline graph wrapper', () => {
});
it('still passes stage type to graph', () => {
- expect(getGraph().props('viewType')).toBe(STAGE_VIEW);
+ expect(findGraph().props('viewType')).toBe(STAGE_VIEW);
});
});
@@ -462,7 +465,7 @@ describe('Pipeline graph wrapper', () => {
nonNeedsResponse.data.project.pipeline.usesNeeds = false;
createComponentWithApollo({
- mountFn: mount,
+ mountFn: mountExtended,
getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse),
});
@@ -471,7 +474,7 @@ describe('Pipeline graph wrapper', () => {
});
it('does not appear when pipeline does not use needs', () => {
- expect(getViewSelector().exists()).toBe(false);
+ expect(findViewSelector().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/pipelines/graph/graph_view_selector_spec.js b/spec/frontend/pipelines/graph/graph_view_selector_spec.js
index 43587bebedf..65ae9d19978 100644
--- a/spec/frontend/pipelines/graph/graph_view_selector_spec.js
+++ b/spec/frontend/pipelines/graph/graph_view_selector_spec.js
@@ -42,10 +42,6 @@ describe('the graph view selector component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when showing stage view', () => {
beforeEach(() => {
createComponent({ mountFn: mount });
@@ -148,6 +144,7 @@ describe('the graph view selector component', () => {
createComponent({
props: {
showLinks: true,
+ type: LAYER_VIEW,
},
data: {
showLinksActive: true,
@@ -166,6 +163,18 @@ describe('the graph view selector component', () => {
await findHoverTip().find('button').trigger('click');
expect(wrapper.emitted().dismissHoverTip).toHaveLength(1);
});
+
+ it('is displayed at first then hidden on swith to STAGE_VIEW then displayed on switch to LAYER_VIEW', async () => {
+ expect(findHoverTip().exists()).toBe(true);
+ expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText);
+
+ await findStageViewButton().trigger('click');
+ expect(findHoverTip().exists()).toBe(false);
+
+ await findLayerViewButton().trigger('click');
+ expect(findHoverTip().exists()).toBe(true);
+ expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText);
+ });
});
describe('when links are live and it has been previously dismissed', () => {
@@ -174,6 +183,7 @@ describe('the graph view selector component', () => {
props: {
showLinks: true,
tipPreviouslyDismissed: true,
+ type: LAYER_VIEW,
},
data: {
showLinksActive: true,
@@ -191,6 +201,7 @@ describe('the graph view selector component', () => {
createComponent({
props: {
showLinks: true,
+ type: LAYER_VIEW,
},
data: {
showLinksActive: false,
diff --git a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js
index d8afb33e148..1419a7b9982 100644
--- a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js
+++ b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js
@@ -69,10 +69,6 @@ describe('job group dropdown component', () => {
wrapper = mountFn(JobGroupDropdown, { propsData: { group } });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
beforeEach(() => {
createComponent({ mountFn: mount });
});
diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js
index 3224c87ab6b..2a5dfd7e0ee 100644
--- a/spec/frontend/pipelines/graph/job_item_spec.js
+++ b/spec/frontend/pipelines/graph/job_item_spec.js
@@ -1,10 +1,11 @@
import MockAdapter from 'axios-mock-adapter';
-import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { GlBadge, GlModal } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import { GlBadge, GlModal, GlToast } from '@gitlab/ui';
import JobItem from '~/pipelines/components/graph/job_item.vue';
import axios from '~/lib/utils/axios_utils';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import ActionComponent from '~/pipelines/components/jobs_shared/action_component.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import {
@@ -19,12 +20,14 @@ import {
describe('pipeline graph job item', () => {
useLocalStorageSpy();
+ Vue.use(GlToast);
let wrapper;
let mockAxios;
const findJobWithoutLink = () => wrapper.findByTestId('job-without-link');
const findJobWithLink = () => wrapper.findByTestId('job-with-link');
+ const findActionVueComponent = () => wrapper.findComponent(ActionComponent);
const findActionComponent = () => wrapper.findByTestId('ci-action-component');
const findBadge = () => wrapper.findComponent(GlBadge);
const findJobLink = () => wrapper.findByTestId('job-with-link');
@@ -41,9 +44,9 @@ describe('pipeline graph job item', () => {
job: mockJob,
};
- const createWrapper = ({ props, data } = {}) => {
+ const createWrapper = ({ props, data, mountFn = mount, mocks = {} } = {}) => {
wrapper = extendedWrapper(
- mount(JobItem, {
+ mountFn(JobItem, {
data() {
return {
...data,
@@ -53,6 +56,9 @@ describe('pipeline graph job item', () => {
...defaultProps,
...props,
},
+ mocks: {
+ ...mocks,
+ },
}),
);
};
@@ -115,7 +121,7 @@ describe('pipeline graph job item', () => {
expect(actionComponent.exists()).toBe(true);
expect(actionComponent.props('actionIcon')).toBe('retry');
- expect(actionComponent.attributes('disabled')).not.toBe('disabled');
+ expect(actionComponent.attributes('disabled')).toBeUndefined();
});
it('should render disabled action icon when user cannot run the action', () => {
@@ -129,7 +135,7 @@ describe('pipeline graph job item', () => {
expect(actionComponent.exists()).toBe(true);
expect(actionComponent.props('actionIcon')).toBe('stop');
- expect(actionComponent.attributes('disabled')).toBe('disabled');
+ expect(actionComponent.attributes('disabled')).toBeDefined();
});
it('action icon tooltip text when job has passed but can be ran again', () => {
@@ -238,6 +244,37 @@ describe('pipeline graph job item', () => {
});
});
+ describe('when retrying', () => {
+ const mockToastShow = jest.fn();
+
+ beforeEach(async () => {
+ createWrapper({
+ mountFn: shallowMount,
+ data: {
+ currentSkipModalValue: true,
+ },
+ props: {
+ skipRetryModal: true,
+ job: triggerJobWithRetryAction,
+ },
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
+ },
+ });
+
+ jest.spyOn(wrapper.vm.$toast, 'show');
+
+ await findActionVueComponent().vm.$emit('pipelineActionRequestComplete');
+ await nextTick();
+ });
+
+ it('shows a toast message that the downstream is being created', () => {
+ expect(mockToastShow).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('highlighting', () => {
it.each`
job | jobName | expanded | link
diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
index f396fe2aff4..bf92cd585d9 100644
--- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
@@ -1,11 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { GlButton, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { createWrapper } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants';
import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue';
@@ -14,10 +13,9 @@ import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.
import CiStatus from '~/vue_shared/components/ci_icon.vue';
import mockPipeline from './linked_pipelines_mock_data';
-Vue.use(VueApollo);
-
describe('Linked pipeline', () => {
let wrapper;
+ let requestHandlers;
const downstreamProps = {
pipeline: {
@@ -47,20 +45,29 @@ describe('Linked pipeline', () => {
const findPipelineLink = () => wrapper.findByTestId('pipelineLink');
const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline');
- const createWrapper = ({ propsData }) => {
- const mockApollo = createMockApollo();
+ const defaultHandlers = {
+ cancelPipeline: jest.fn().mockResolvedValue({ data: { pipelineCancel: { errors: [] } } }),
+ retryPipeline: jest.fn().mockResolvedValue({ data: { pipelineRetry: { errors: [] } } }),
+ };
- wrapper = extendedWrapper(
- mount(LinkedPipelineComponent, {
- propsData,
- apolloProvider: mockApollo,
- }),
- );
+ const createMockApolloProvider = (handlers) => {
+ Vue.use(VueApollo);
+
+ requestHandlers = handlers;
+ return createMockApollo([
+ [CancelPipelineMutation, requestHandlers.cancelPipeline],
+ [RetryPipelineMutation, requestHandlers.retryPipeline],
+ ]);
};
- afterEach(() => {
- wrapper.destroy();
- });
+ const createComponent = ({ propsData, handlers = defaultHandlers }) => {
+ const mockApollo = createMockApolloProvider(handlers);
+
+ wrapper = mountExtended(LinkedPipelineComponent, {
+ propsData,
+ apolloProvider: mockApollo,
+ });
+ };
describe('rendered output', () => {
const props = {
@@ -72,7 +79,7 @@ describe('Linked pipeline', () => {
};
beforeEach(() => {
- createWrapper({ propsData: props });
+ createComponent({ propsData: props });
});
it('should render the project name', () => {
@@ -113,7 +120,7 @@ describe('Linked pipeline', () => {
describe('upstream pipelines', () => {
beforeEach(() => {
- createWrapper({ propsData: upstreamProps });
+ createComponent({ propsData: upstreamProps });
});
it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => {
@@ -133,7 +140,7 @@ describe('Linked pipeline', () => {
describe('downstream pipelines', () => {
describe('styling', () => {
beforeEach(() => {
- createWrapper({ propsData: downstreamProps });
+ createComponent({ propsData: downstreamProps });
});
it('parent/child label container should exist', () => {
@@ -168,7 +175,7 @@ describe('Linked pipeline', () => {
pipeline: { ...mockPipeline, retryable: true },
};
- createWrapper({ propsData: retryablePipeline });
+ createComponent({ propsData: retryablePipeline });
});
it('does not show the retry or cancel button', () => {
@@ -179,14 +186,14 @@ describe('Linked pipeline', () => {
});
describe('on a downstream', () => {
+ const retryablePipeline = {
+ ...downstreamProps,
+ pipeline: { ...mockPipeline, retryable: true },
+ };
+
describe('when retryable', () => {
beforeEach(() => {
- const retryablePipeline = {
- ...downstreamProps,
- pipeline: { ...mockPipeline, retryable: true },
- };
-
- createWrapper({ propsData: retryablePipeline });
+ createComponent({ propsData: retryablePipeline });
});
it('shows only the retry button', () => {
@@ -209,50 +216,51 @@ describe('Linked pipeline', () => {
describe('and the retry button is clicked', () => {
describe('on success', () => {
beforeEach(async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
- jest.spyOn(wrapper.vm, '$emit');
await findRetryButton().trigger('click');
});
it('calls the retry mutation', () => {
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: RetryPipelineMutation,
- variables: {
- id: convertToGraphQLId(TYPENAME_CI_PIPELINE, mockPipeline.id),
- },
+ expect(requestHandlers.retryPipeline).toHaveBeenCalledTimes(1);
+ expect(requestHandlers.retryPipeline).toHaveBeenCalledWith({
+ id: 'gid://gitlab/Ci::Pipeline/195',
});
});
- it('emits the refreshPipelineGraph event', () => {
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph');
+ it('emits the refreshPipelineGraph event', async () => {
+ await waitForPromises();
+ expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1);
});
});
describe('on failure', () => {
beforeEach(async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] });
- jest.spyOn(wrapper.vm, '$emit');
+ createComponent({
+ propsData: retryablePipeline,
+ handlers: {
+ retryPipeline: jest.fn().mockRejectedValue({ errors: [] }),
+ cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }),
+ },
+ });
+
await findRetryButton().trigger('click');
});
- it('emits an error event', () => {
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', {
- type: ACTION_FAILURE,
- });
+ it('emits an error event', async () => {
+ await waitForPromises();
+ expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]);
});
});
});
});
describe('when cancelable', () => {
- beforeEach(() => {
- const cancelablePipeline = {
- ...downstreamProps,
- pipeline: { ...mockPipeline, cancelable: true },
- };
+ const cancelablePipeline = {
+ ...downstreamProps,
+ pipeline: { ...mockPipeline, cancelable: true },
+ };
- createWrapper({ propsData: cancelablePipeline });
+ beforeEach(() => {
+ createComponent({ propsData: cancelablePipeline });
});
it('shows only the cancel button', () => {
@@ -275,34 +283,37 @@ describe('Linked pipeline', () => {
describe('and the cancel button is clicked', () => {
describe('on success', () => {
beforeEach(async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
- jest.spyOn(wrapper.vm, '$emit');
await findCancelButton().trigger('click');
});
it('calls the cancel mutation', () => {
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: CancelPipelineMutation,
- variables: {
- id: convertToGraphQLId(TYPENAME_CI_PIPELINE, mockPipeline.id),
- },
+ expect(requestHandlers.cancelPipeline).toHaveBeenCalledTimes(1);
+ expect(requestHandlers.cancelPipeline).toHaveBeenCalledWith({
+ id: 'gid://gitlab/Ci::Pipeline/195',
});
});
- it('emits the refreshPipelineGraph event', () => {
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph');
+ it('emits the refreshPipelineGraph event', async () => {
+ await waitForPromises();
+ expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1);
});
});
+
describe('on failure', () => {
beforeEach(async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] });
- jest.spyOn(wrapper.vm, '$emit');
+ createComponent({
+ propsData: cancelablePipeline,
+ handlers: {
+ retryPipeline: jest.fn().mockRejectedValue({ errors: [] }),
+ cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }),
+ },
+ });
+
await findCancelButton().trigger('click');
});
- it('emits an error event', () => {
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', {
- type: ACTION_FAILURE,
- });
+
+ it('emits an error event', async () => {
+ await waitForPromises();
+ expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]);
});
});
});
@@ -315,7 +326,7 @@ describe('Linked pipeline', () => {
pipeline: { ...mockPipeline, cancelable: true, retryable: true },
};
- createWrapper({ propsData: pipelineWithTwoActions });
+ createComponent({ propsData: pipelineWithTwoActions });
});
it('only shows the cancel button', () => {
@@ -338,7 +349,7 @@ describe('Linked pipeline', () => {
},
};
- createWrapper({ propsData: pipelineWithTwoActions });
+ createComponent({ propsData: pipelineWithTwoActions });
});
it('does not show any action button', () => {
@@ -359,7 +370,7 @@ describe('Linked pipeline', () => {
`(
'$pipelineType.columnTitle pipeline button icon should be $chevronPosition with $buttonBorderClasses if expanded state is $expanded',
({ pipelineType, chevronPosition, buttonBorderClasses, expanded }) => {
- createWrapper({ propsData: { ...pipelineType, expanded } });
+ createComponent({ propsData: { ...pipelineType, expanded } });
expect(findExpandButton().props('icon')).toBe(chevronPosition);
expect(findExpandButton().classes()).toContain(buttonBorderClasses);
},
@@ -367,7 +378,7 @@ describe('Linked pipeline', () => {
describe('shadow border', () => {
beforeEach(() => {
- createWrapper({ propsData: downstreamProps });
+ createComponent({ propsData: downstreamProps });
});
it.each`
@@ -401,7 +412,7 @@ describe('Linked pipeline', () => {
};
beforeEach(() => {
- createWrapper({ propsData: props });
+ createComponent({ propsData: props });
});
it('loading icon is visible', () => {
@@ -419,36 +430,35 @@ describe('Linked pipeline', () => {
};
beforeEach(() => {
- createWrapper({ propsData: props });
+ createComponent({ propsData: props });
});
it('emits `pipelineClicked` event', () => {
- jest.spyOn(wrapper.vm, '$emit');
findButton().trigger('click');
- expect(wrapper.emitted().pipelineClicked).toHaveLength(1);
+ expect(wrapper.emitted('pipelineClicked')).toHaveLength(1);
});
- it(`should emit ${BV_HIDE_TOOLTIP} to close the tooltip`, () => {
- jest.spyOn(wrapper.vm.$root, '$emit');
- findButton().trigger('click');
+ it(`should emit ${BV_HIDE_TOOLTIP} to close the tooltip`, async () => {
+ const root = createWrapper(wrapper.vm.$root);
+ await findButton().vm.$emit('click');
- expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual([BV_HIDE_TOOLTIP]);
+ expect(root.emitted(BV_HIDE_TOOLTIP)).toHaveLength(1);
});
it('should emit downstreamHovered with job name on mouseover', () => {
findLinkedPipeline().trigger('mouseover');
- expect(wrapper.emitted().downstreamHovered).toStrictEqual([['test_c']]);
+ expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['test_c']]);
});
it('should emit downstreamHovered with empty string on mouseleave', () => {
findLinkedPipeline().trigger('mouseleave');
- expect(wrapper.emitted().downstreamHovered).toStrictEqual([['']]);
+ expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['']]);
});
it('should emit pipelineExpanded with job name and expanded state on click', () => {
findExpandButton().trigger('click');
- expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['test_c', 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 63e2d8707ea..6e4b9498918 100644
--- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
@@ -65,10 +65,6 @@ describe('Linked Pipelines Column', () => {
createComponent({ apolloProvider, mountFn, props });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('it renders correctly', () => {
beforeEach(() => {
createComponentWithApollo();
diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js
index 19f597a7267..d4d7f1618c5 100644
--- a/spec/frontend/pipelines/graph/stage_column_component_spec.js
+++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js
@@ -54,10 +54,6 @@ describe('stage column component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when mounted', () => {
beforeEach(() => {
createComponent({ method: mount });
diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js
index 2c6d126e12c..50f754393fe 100644
--- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js
+++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js
@@ -81,7 +81,6 @@ describe('Links Inner component', () => {
afterEach(() => {
jest.restoreAllMocks();
- wrapper.destroy();
resetHTMLFixture();
});
diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js
index e2699d6ff2e..9d39c86ed5e 100644
--- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js
+++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js
@@ -35,10 +35,6 @@ describe('links layer component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with show links off', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
index e583c0798f5..18def4ab62c 100644
--- a/spec/frontend/pipelines/header_component_spec.js
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -6,7 +6,7 @@ import HeaderComponent from '~/pipelines/components/header_component.vue';
import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
-import { BUTTON_TOOLTIP_RETRY } from '~/pipelines/constants';
+import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/pipelines/constants';
import {
mockCancelledPipelineHeader,
mockFailedPipelineHeader,
@@ -71,11 +71,6 @@ describe('Pipeline details header', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('initial loading', () => {
beforeEach(() => {
wrapper = createComponent(null, { isLoading: true });
@@ -174,6 +169,10 @@ describe('Pipeline details header', () => {
});
});
+ it('should render cancel action tooltip', () => {
+ expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL);
+ });
+
it('should display error message on failure', async () => {
const failureMessage = 'failure message';
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js
index dd7e81f3f22..a4b8d223a0c 100644
--- a/spec/frontend/pipelines/mock_data.js
+++ b/spec/frontend/pipelines/mock_data.js
@@ -1190,6 +1190,10 @@ export const mockFailedJobsQueryResponse = {
readBuild: true,
updateBuild: true,
},
+ trace: {
+ htmlSummary: '<span>Html Summary</span>',
+ },
+ failureMessage: 'Failed',
},
{
__typename: 'CiJob',
@@ -1218,6 +1222,8 @@ export const mockFailedJobsQueryResponse = {
readBuild: true,
updateBuild: true,
},
+ trace: null,
+ failureMessage: 'Failed',
},
],
},
@@ -1226,18 +1232,8 @@ export const mockFailedJobsQueryResponse = {
},
};
-export const mockFailedJobsSummaryData = [
- {
- id: 1848,
- failure: null,
- failure_summary:
- '<span>Pulling docker image node:latest ...<br/></span><span>Using docker image sha256:738d733448be00c72cb6618b7a06a1424806c6d239d8885e92f9b1e8727092b5 for node:latest with digest node@sha256:e5b7b349d517159246070bf14242027a9e220ffa8bd98a67ba1495d969c06c01 ...<br/></span><div class="section-start" data-timestamp="1651175313" data-section="prepare-script" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-prepare-script">Preparing environment</span><span class="section section-header js-s-prepare-script"><br/></span><span class="section line js-s-prepare-script">Running on runner-kvkqh24-project-20-concurrent-0 via 0706719b1b8d...<br/></span><div class="section-end" data-section="prepare-script"></div><div class="section-start" data-timestamp="1651175313" data-section="get-sources" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-get-sources">Getting source from Git repository</span><span class="section section-header js-s-get-sources"><br/></span><span class="term-fg-l-green term-bold section line js-s-get-sources">Fetching changes with git depth set to 50...</span><span class="section line js-s-get-sources"><br/>Reinitialized existing Git repository in /builds/root/ci-project/.git/<br/>fatal: couldn\'t find remote ref refs/heads/test<br/></span><div class="section-end" data-section="get-sources"></div><span class="term-fg-l-red term-bold">ERROR: Job failed: exit code 1<br/></span>',
- },
-];
-
export const mockFailedJobsData = [
{
- normalizedId: 1848,
__typename: 'CiJob',
status: 'FAILED',
detailedStatus: {
@@ -1260,13 +1256,25 @@ export const mockFailedJobsData = [
},
},
id: 'gid://gitlab/Ci::Build/1848',
- stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' },
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/358',
+ name: 'build',
+ },
name: 'wait_job',
retryable: true,
- userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true },
+ userPermissions: {
+ __typename: 'JobPermissions',
+ readBuild: true,
+ updateBuild: true,
+ },
+ trace: {
+ htmlSummary: '<span>Html Summary</span>',
+ },
+ failureMessage: 'Job failed',
+ _showDetails: true,
},
{
- normalizedId: 1710,
__typename: 'CiJob',
status: 'FAILED',
detailedStatus: {
@@ -1281,52 +1289,27 @@ export const mockFailedJobsData = [
action: null,
},
id: 'gid://gitlab/Ci::Build/1710',
- stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' },
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/358',
+ name: 'build',
+ },
name: 'wait_job',
retryable: false,
- userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true },
- },
-];
-
-export const mockPreparedFailedJobsData = [
- {
- __typename: 'CiJob',
- _showDetails: true,
- detailedStatus: {
- __typename: 'DetailedStatus',
- action: {
- __typename: 'StatusAction',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- id: 'Ci::Build-failed-1848',
- method: 'post',
- path: '/root/ci-project/-/jobs/1848/retry',
- title: 'Retry',
- },
- detailsPath: '/root/ci-project/-/jobs/1848',
- group: 'failed',
- icon: 'status_failed',
- id: 'failed-1848-1848',
- label: 'failed',
- text: 'failed',
- tooltip: 'failed - (script failure)',
+ userPermissions: {
+ __typename: 'JobPermissions',
+ readBuild: true,
+ updateBuild: true,
},
- failure: null,
- failureSummary:
- '<span>Pulling docker image node:latest ...<br/></span><span>Using docker image sha256:738d733448be00c72cb6618b7a06a1424806c6d239d8885e92f9b1e8727092b5 for node:latest with digest node@sha256:e5b7b349d517159246070bf14242027a9e220ffa8bd98a67ba1495d969c06c01 ...<br/></span><div class="section-start" data-timestamp="1651175313" data-section="prepare-script" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-prepare-script">Preparing environment</span><span class="section section-header js-s-prepare-script"><br/></span><span class="section line js-s-prepare-script">Running on runner-kvkqh24-project-20-concurrent-0 via 0706719b1b8d...<br/></span><div class="section-end" data-section="prepare-script"></div><div class="section-start" data-timestamp="1651175313" data-section="get-sources" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-get-sources">Getting source from Git repository</span><span class="section section-header js-s-get-sources"><br/></span><span class="term-fg-l-green term-bold section line js-s-get-sources">Fetching changes with git depth set to 50...</span><span class="section line js-s-get-sources"><br/>Reinitialized existing Git repository in /builds/root/ci-project/.git/<br/>fatal: couldn\'t find remote ref refs/heads/test<br/></span><div class="section-end" data-section="get-sources"></div><span class="term-fg-l-red term-bold">ERROR: Job failed: exit code 1<br/></span>',
- id: 'gid://gitlab/Ci::Build/1848',
- name: 'wait_job',
- normalizedId: 1848,
- retryable: true,
- stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' },
- status: 'FAILED',
- userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true },
+ trace: null,
+ failureMessage: 'Job failed',
+ _showDetails: true,
},
];
-export const mockPreparedFailedJobsDataNoPermission = [
+export const mockFailedJobsDataNoPermission = [
{
- ...mockPreparedFailedJobsData[0],
+ ...mockFailedJobsData[0],
userPermissions: { __typename: 'JobPermissions', readBuild: false, updateBuild: false },
},
];
diff --git a/spec/frontend/pipelines/nav_controls_spec.js b/spec/frontend/pipelines/nav_controls_spec.js
index 2c4740df174..15de7dc51f1 100644
--- a/spec/frontend/pipelines/nav_controls_spec.js
+++ b/spec/frontend/pipelines/nav_controls_spec.js
@@ -1,23 +1,20 @@
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import NavControls from '~/pipelines/components/pipelines_list/nav_controls.vue';
describe('Pipelines Nav Controls', () => {
let wrapper;
const createComponent = (props) => {
- wrapper = shallowMount(NavControls, {
+ wrapper = shallowMountExtended(NavControls, {
propsData: {
...props,
},
});
};
- const findRunPipeline = () => wrapper.find('.js-run-pipeline');
-
- afterEach(() => {
- wrapper.destroy();
- });
+ const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button');
+ const findCiLintButton = () => wrapper.findByTestId('ci-lint-button');
+ const findClearCacheButton = () => wrapper.findByTestId('clear-cache-button');
it('should render link to create a new pipeline', () => {
const mockData = {
@@ -28,9 +25,9 @@ describe('Pipelines Nav Controls', () => {
createComponent(mockData);
- const runPipeline = findRunPipeline();
- expect(runPipeline.text()).toContain('Run pipeline');
- expect(runPipeline.attributes('href')).toBe(mockData.newPipelinePath);
+ const runPipelineButton = findRunPipelineButton();
+ expect(runPipelineButton.text()).toContain('Run pipeline');
+ expect(runPipelineButton.attributes('href')).toBe(mockData.newPipelinePath);
});
it('should not render link to create pipeline if no path is provided', () => {
@@ -42,7 +39,7 @@ describe('Pipelines Nav Controls', () => {
createComponent(mockData);
- expect(findRunPipeline().exists()).toBe(false);
+ expect(findRunPipelineButton().exists()).toBe(false);
});
it('should render link for CI lint', () => {
@@ -54,9 +51,10 @@ describe('Pipelines Nav Controls', () => {
};
createComponent(mockData);
+ const ciLintButton = findCiLintButton();
- expect(wrapper.find('.js-ci-lint').text().trim()).toContain('CI lint');
- expect(wrapper.find('.js-ci-lint').attributes('href')).toBe(mockData.ciLintPath);
+ expect(ciLintButton.text()).toContain('CI lint');
+ expect(ciLintButton.attributes('href')).toBe(mockData.ciLintPath);
});
describe('Reset Runners Cache', () => {
@@ -70,16 +68,13 @@ describe('Pipelines Nav Controls', () => {
});
it('should render button for resetting runner caches', () => {
- expect(wrapper.find('.js-clear-cache').text().trim()).toContain('Clear runner caches');
+ expect(findClearCacheButton().text()).toContain('Clear runner caches');
});
- it('should emit postAction event when reset runner cache button is clicked', async () => {
- jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {});
-
- wrapper.find('.js-clear-cache').vm.$emit('click');
- await nextTick();
+ it('should emit postAction event when reset runner cache button is clicked', () => {
+ findClearCacheButton().vm.$emit('click');
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('resetRunnersCache', 'foo');
+ expect(wrapper.emitted('resetRunnersCache')).toEqual([['foo']]);
});
});
});
diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
index df10742fd93..123f2e011c3 100644
--- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
+++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
@@ -39,10 +39,6 @@ describe('pipeline graph component', () => {
const findLinksLayer = () => wrapper.findComponent(LinksLayer);
const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with `VALID` status', () => {
beforeEach(() => {
wrapper = createComponent({
diff --git a/spec/frontend/pipelines/pipeline_labels_spec.js b/spec/frontend/pipelines/pipeline_labels_spec.js
index ca0229b1cbe..6a37e36352b 100644
--- a/spec/frontend/pipelines/pipeline_labels_spec.js
+++ b/spec/frontend/pipelines/pipeline_labels_spec.js
@@ -30,10 +30,6 @@ describe('Pipeline label component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should not render tags when flags are not set', () => {
createComponent();
diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js
index bedde71c48d..e3c9983aa52 100644
--- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js
+++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js
@@ -67,8 +67,6 @@ describe('Pipeline Multi Actions Dropdown', () => {
afterEach(() => {
mockAxios.restore();
-
- wrapper.destroy();
});
it('should render the dropdown', () => {
diff --git a/spec/frontend/pipelines/pipeline_operations_spec.js b/spec/frontend/pipelines/pipeline_operations_spec.js
new file mode 100644
index 00000000000..b2191453824
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_operations_spec.js
@@ -0,0 +1,77 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue';
+import PipelineMultiActions from '~/pipelines/components/pipelines_list/pipeline_multi_actions.vue';
+import PipelineOperations from '~/pipelines/components/pipelines_list/pipeline_operations.vue';
+import eventHub from '~/pipelines/event_hub';
+
+describe('Pipeline operations', () => {
+ let wrapper;
+
+ const defaultProps = {
+ pipeline: {
+ id: 329,
+ iid: 234,
+ details: {
+ has_manual_actions: true,
+ has_scheduled_actions: false,
+ },
+ flags: {
+ retryable: true,
+ cancelable: true,
+ },
+ cancel_path: '/root/ci-project/-/pipelines/329/cancel',
+ retry_path: '/root/ci-project/-/pipelines/329/retry',
+ },
+ };
+
+ const createComponent = (props = defaultProps) => {
+ wrapper = shallowMountExtended(PipelineOperations, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ const findManualActions = () => wrapper.findComponent(PipelinesManualActions);
+ const findMultiActions = () => wrapper.findComponent(PipelineMultiActions);
+ const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button');
+ const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button');
+
+ it('should display pipeline manual actions', () => {
+ createComponent();
+
+ expect(findManualActions().exists()).toBe(true);
+ });
+
+ it('should display pipeline multi actions', () => {
+ createComponent();
+
+ expect(findMultiActions().exists()).toBe(true);
+ });
+
+ describe('events', () => {
+ beforeEach(() => {
+ createComponent();
+
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ });
+
+ it('should emit retryPipeline event', () => {
+ findRetryBtn().vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'retryPipeline',
+ defaultProps.pipeline.retry_path,
+ );
+ });
+
+ it('should emit openConfirmationModal event', () => {
+ findCancelBtn().vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', {
+ pipeline: defaultProps.pipeline,
+ endpoint: defaultProps.pipeline.cancel_path,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipeline_tabs_spec.js b/spec/frontend/pipelines/pipeline_tabs_spec.js
index 099748a5cca..8d1cd98e981 100644
--- a/spec/frontend/pipelines/pipeline_tabs_spec.js
+++ b/spec/frontend/pipelines/pipeline_tabs_spec.js
@@ -25,7 +25,6 @@ describe('~/pipelines/pipeline_tabs.js', () => {
el.dataset.exposeSecurityDashboard = 'true';
el.dataset.exposeLicenseScanningData = 'true';
el.dataset.failedJobsCount = 1;
- el.dataset.failedJobsSummary = '[]';
el.dataset.graphqlResourceEtag = 'graphqlResourceEtag';
el.dataset.pipelineIid = '123';
el.dataset.pipelineProjectPath = 'pipelineProjectPath';
@@ -50,7 +49,6 @@ describe('~/pipelines/pipeline_tabs.js', () => {
exposeSecurityDashboard: true,
exposeLicenseScanningData: true,
failedJobsCount: '1',
- failedJobsSummary: [],
graphqlResourceEtag: 'graphqlResourceEtag',
pipelineIid: '123',
pipelineProjectPath: 'pipelineProjectPath',
diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js
index 58bfb68e85c..856c0484075 100644
--- a/spec/frontend/pipelines/pipeline_triggerer_spec.js
+++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js
@@ -22,15 +22,11 @@ describe('Pipelines Triggerer', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
const findAvatar = () => wrapper.findComponent(GlAvatar);
const findTriggerer = () => wrapper.findByText('API');
diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js
index c62898f0c83..f00ee4a6367 100644
--- a/spec/frontend/pipelines/pipeline_url_spec.js
+++ b/spec/frontend/pipelines/pipeline_url_spec.js
@@ -35,10 +35,6 @@ describe('Pipeline Url Component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render pipeline url table cell', () => {
createComponent();
diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js
deleted file mode 100644
index e034d52a33c..00000000000
--- a/spec/frontend/pipelines/pipelines_actions_spec.js
+++ /dev/null
@@ -1,171 +0,0 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
-import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import { TEST_HOST } from 'spec/test_constants';
-import { createAlert } from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
-import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue';
-import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-import { TRACKING_CATEGORIES } from '~/pipelines/constants';
-
-jest.mock('~/flash');
-jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
-
-describe('Pipelines Actions dropdown', () => {
- let wrapper;
- let mock;
-
- const createComponent = (props, mountFn = shallowMount) => {
- wrapper = mountFn(PipelinesManualActions, {
- propsData: {
- ...props,
- },
- });
- };
-
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown);
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
-
- mock.restore();
- confirmAction.mockReset();
- });
-
- describe('manual actions', () => {
- const mockActions = [
- {
- name: 'stop_review',
- path: `${TEST_HOST}/root/review-app/builds/1893/play`,
- },
- {
- name: 'foo',
- path: `${TEST_HOST}/disabled/pipeline/action`,
- playable: false,
- },
- ];
-
- beforeEach(() => {
- createComponent({ actions: mockActions });
- });
-
- it('renders a dropdown with the provided actions', () => {
- expect(findAllDropdownItems()).toHaveLength(mockActions.length);
- });
-
- it("renders a disabled action when it's not playable", () => {
- expect(findAllDropdownItems().at(1).attributes('disabled')).toBe('true');
- });
-
- describe('on click', () => {
- it('makes a request and toggles the loading state', async () => {
- mock.onPost(mockActions.path).reply(HTTP_STATUS_OK);
-
- findAllDropdownItems().at(0).vm.$emit('click');
-
- await nextTick();
- expect(findDropdown().props('loading')).toBe(true);
-
- await waitForPromises();
- expect(findDropdown().props('loading')).toBe(false);
- });
-
- it('makes a failed request and toggles the loading state', async () => {
- mock.onPost(mockActions.path).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
-
- findAllDropdownItems().at(0).vm.$emit('click');
-
- await nextTick();
- expect(findDropdown().props('loading')).toBe(true);
-
- await waitForPromises();
- expect(findDropdown().props('loading')).toBe(false);
- expect(createAlert).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('tracking', () => {
- afterEach(() => {
- unmockTracking();
- });
-
- it('tracks manual actions click', () => {
- const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
-
- findDropdown().vm.$emit('shown');
-
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', {
- label: TRACKING_CATEGORIES.table,
- });
- });
- });
- });
-
- describe('scheduled jobs', () => {
- const scheduledJobAction = {
- name: 'scheduled action',
- path: `${TEST_HOST}/scheduled/job/action`,
- playable: true,
- scheduled_at: '2063-04-05T00:42:00Z',
- };
- const expiredJobAction = {
- name: 'expired action',
- path: `${TEST_HOST}/expired/job/action`,
- playable: true,
- scheduled_at: '2018-10-05T08:23:00Z',
- };
-
- beforeEach(() => {
- jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime());
- createComponent({ actions: [scheduledJobAction, expiredJobAction] });
- });
-
- it('makes post request after confirming', async () => {
- mock.onPost(scheduledJobAction.path).reply(HTTP_STATUS_OK);
- confirmAction.mockResolvedValueOnce(true);
-
- findAllDropdownItems().at(0).vm.$emit('click');
-
- expect(confirmAction).toHaveBeenCalled();
-
- await waitForPromises();
-
- expect(mock.history.post).toHaveLength(1);
- });
-
- it('does not make post request if confirmation is cancelled', async () => {
- mock.onPost(scheduledJobAction.path).reply(HTTP_STATUS_OK);
- confirmAction.mockResolvedValueOnce(false);
-
- findAllDropdownItems().at(0).vm.$emit('click');
-
- expect(confirmAction).toHaveBeenCalled();
-
- await waitForPromises();
-
- expect(mock.history.post).toHaveLength(0);
- });
-
- it('displays the remaining time in the dropdown', () => {
- expect(findAllCountdowns().at(0).props('endDateString')).toBe(
- scheduledJobAction.scheduled_at,
- );
- });
-
- it('displays 00:00:00 for expired jobs in the dropdown', () => {
- expect(findAllCountdowns().at(1).props('endDateString')).toBe(expiredJobAction.scheduled_at);
- });
- });
-});
diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js
index e3e54716a7b..9fedbaf9b56 100644
--- a/spec/frontend/pipelines/pipelines_artifacts_spec.js
+++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js
@@ -34,11 +34,6 @@ describe('Pipelines Artifacts dropdown', () => {
const findAllGlDropdownItems = () =>
wrapper.findComponent(GlDropdown).findAllComponents(GlDropdownItem);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should render a dropdown with all the provided artifacts', () => {
createComponent();
diff --git a/spec/frontend/pipelines/pipelines_manual_actions_spec.js b/spec/frontend/pipelines/pipelines_manual_actions_spec.js
new file mode 100644
index 00000000000..82cab88c9eb
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_manual_actions_spec.js
@@ -0,0 +1,216 @@
+import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import mockPipelineActionsQueryResponse from 'test_fixtures/graphql/pipelines/get_pipeline_actions.query.graphql.json';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue';
+import getPipelineActionsQuery from '~/pipelines/graphql/queries/get_pipeline_actions.query.graphql';
+import { TRACKING_CATEGORIES } from '~/pipelines/constants';
+import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
+
+Vue.use(VueApollo);
+
+jest.mock('~/alert');
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
+
+describe('Pipeline manual actions', () => {
+ let wrapper;
+ let mock;
+
+ const queryHandler = jest.fn().mockResolvedValue(mockPipelineActionsQueryResponse);
+ const {
+ data: {
+ project: {
+ pipeline: {
+ jobs: { nodes },
+ },
+ },
+ },
+ } = mockPipelineActionsQueryResponse;
+
+ const mockPath = nodes[2].playPath;
+
+ const createComponent = (limit = 50) => {
+ wrapper = shallowMountExtended(PipelinesManualActions, {
+ provide: {
+ fullPath: 'root/ci-project',
+ manualActionsLimit: limit,
+ },
+ propsData: {
+ iid: 100,
+ },
+ stubs: {
+ GlDropdown,
+ },
+ apolloProvider: createMockApollo([[getPipelineActionsQuery, queryHandler]]),
+ });
+ };
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findLimitMessage = () => wrapper.findByTestId('limit-reached-msg');
+
+ it('skips calling query on mount', () => {
+ createComponent();
+
+ expect(queryHandler).not.toHaveBeenCalled();
+ });
+
+ describe('loading', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+ });
+
+ it('display loading state while actions are being fetched', () => {
+ expect(findAllDropdownItems().at(0).text()).toBe('Loading...');
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findAllDropdownItems()).toHaveLength(1);
+ });
+ });
+
+ describe('loaded', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+
+ await waitForPromises();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ confirmAction.mockReset();
+ });
+
+ it('displays dropdown with the provided actions', () => {
+ expect(findAllDropdownItems()).toHaveLength(3);
+ });
+
+ it("displays a disabled action when it's not playable", () => {
+ expect(findAllDropdownItems().at(0).attributes('disabled')).toBeDefined();
+ });
+
+ describe('on action click', () => {
+ it('makes a request and toggles the loading state', async () => {
+ mock.onPost(mockPath).reply(HTTP_STATUS_OK);
+
+ findAllDropdownItems().at(1).vm.$emit('click');
+
+ await nextTick();
+
+ expect(findDropdown().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findDropdown().props('loading')).toBe(false);
+ });
+
+ it('makes a failed request and toggles the loading state', async () => {
+ mock.onPost(mockPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ findAllDropdownItems().at(1).vm.$emit('click');
+
+ await nextTick();
+
+ expect(findDropdown().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findDropdown().props('loading')).toBe(false);
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('tracking', () => {
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('tracks manual actions click', () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ findDropdown().vm.$emit('shown');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', {
+ label: TRACKING_CATEGORIES.table,
+ });
+ });
+ });
+
+ describe('scheduled jobs', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(Date, 'now')
+ .mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime());
+ });
+
+ it('makes post request after confirming', async () => {
+ mock.onPost(mockPath).reply(HTTP_STATUS_OK);
+
+ confirmAction.mockResolvedValueOnce(true);
+
+ findAllDropdownItems().at(2).vm.$emit('click');
+
+ expect(confirmAction).toHaveBeenCalled();
+
+ await waitForPromises();
+
+ expect(mock.history.post).toHaveLength(1);
+ });
+
+ it('does not make post request if confirmation is cancelled', async () => {
+ mock.onPost(mockPath).reply(HTTP_STATUS_OK);
+
+ confirmAction.mockResolvedValueOnce(false);
+
+ findAllDropdownItems().at(2).vm.$emit('click');
+
+ expect(confirmAction).toHaveBeenCalled();
+
+ await waitForPromises();
+
+ expect(mock.history.post).toHaveLength(0);
+ });
+
+ it('displays the remaining time in the dropdown', () => {
+ expect(findAllCountdowns().at(0).props('endDateString')).toBe(nodes[2].scheduledAt);
+ });
+ });
+ });
+
+ describe('limit message', () => {
+ it('limit message does not show', async () => {
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+
+ await waitForPromises();
+
+ expect(findLimitMessage().exists()).toBe(false);
+ });
+
+ it('limit message does show', async () => {
+ createComponent(3);
+
+ findDropdown().vm.$emit('shown');
+
+ await waitForPromises();
+
+ expect(findLimitMessage().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index 2523b901506..f0772bce167 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -11,7 +11,7 @@ import { mockTracking } from 'helpers/tracking_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue';
@@ -25,7 +25,7 @@ import TablePagination from '~/vue_shared/components/pagination/table_pagination
import { stageReply, users, mockSearch, branches } from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const mockProjectPath = 'twitter/flight';
const mockProjectId = '21';
@@ -42,7 +42,7 @@ describe('Pipelines', () => {
let trackingSpy;
const paths = {
- emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
+ emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
ciLintPath: '/ci/lint',
@@ -53,7 +53,7 @@ describe('Pipelines', () => {
};
const noPermissions = {
- emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
+ emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
};
@@ -114,7 +114,6 @@ describe('Pipelines', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.reset();
window.history.pushState.mockReset();
});
@@ -246,7 +245,7 @@ describe('Pipelines', () => {
await waitForPromises();
});
- it('should filter pipelines', async () => {
+ it('should filter pipelines', () => {
expect(findPipelinesTable().exists()).toBe(true);
expect(findPipelineUrlLinks()).toHaveLength(1);
@@ -288,7 +287,7 @@ describe('Pipelines', () => {
await waitForPromises();
});
- it('should filter pipelines', async () => {
+ it('should filter pipelines', () => {
expect(findEmptyState().text()).toBe('There are currently no pipelines.');
});
@@ -331,11 +330,11 @@ describe('Pipelines', () => {
await waitForPromises();
});
- it('requests data with query params on filter submit', async () => {
+ it('requests data with query params on filter submit', () => {
expect(mock.history.get[1].params).toEqual(expectedParams);
});
- it('renders filtered pipelines', async () => {
+ it('renders filtered pipelines', () => {
expect(findPipelineUrlLinks()).toHaveLength(1);
expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`);
});
@@ -357,7 +356,7 @@ describe('Pipelines', () => {
await waitForPromises();
});
- it('requests data with query params on filter submit', async () => {
+ it('requests data with query params on filter submit', () => {
expect(mock.history.get[1].params).toEqual({ page: '1', scope: 'all' });
});
@@ -517,7 +516,7 @@ describe('Pipelines', () => {
expect(findNavigationTabs().exists()).toBe(true);
});
- it('is loading after a time', async () => {
+ it('is loading after a time', () => {
expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length);
expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`);
expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`);
@@ -728,7 +727,7 @@ describe('Pipelines', () => {
});
describe('when pipelines cannot be loaded', () => {
- beforeEach(async () => {
+ beforeEach(() => {
mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, {});
});
@@ -751,8 +750,9 @@ describe('Pipelines', () => {
});
it('shows error state', () => {
- expect(findEmptyState().text()).toBe(
- 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.',
+ expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.');
+ expect(findEmptyState().props('description')).toBe(
+ 'Try again in a few moments or contact your support team.',
);
});
});
@@ -776,8 +776,9 @@ describe('Pipelines', () => {
});
it('shows error state', () => {
- expect(findEmptyState().text()).toBe(
- 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.',
+ expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.');
+ expect(findEmptyState().props('description')).toBe(
+ 'Try again in a few moments or contact your support team.',
);
});
});
diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js
index 6ec8901038b..8d2a52eb6d0 100644
--- a/spec/frontend/pipelines/pipelines_table_spec.js
+++ b/spec/frontend/pipelines/pipelines_table_spec.js
@@ -69,12 +69,6 @@ describe('Pipelines Table', () => {
pipeline = createMockPipeline();
});
- afterEach(() => {
- wrapper.destroy();
-
- wrapper = null;
- });
-
describe('Pipelines Table', () => {
beforeEach(() => {
createComponent({ pipelines: [pipeline], viewType: 'root' });
diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js
index f6287107ed0..e05d2151f0a 100644
--- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js
+++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js
@@ -2,13 +2,13 @@ import MockAdapter from 'axios-mock-adapter';
import testReports from 'test_fixtures/pipelines/test_report.json';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import * as actions from '~/pipelines/stores/test_reports/actions';
import * as types from '~/pipelines/stores/test_reports/mutation_types';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
describe('Actions TestReports Store', () => {
let mock;
@@ -49,7 +49,7 @@ describe('Actions TestReports Store', () => {
);
});
- it('should create flash on API error', async () => {
+ it('should create alert on API error', async () => {
await testAction(
actions.fetchSummary,
null,
diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
index ed0cc71eb97..685ac6ea3e5 100644
--- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
+++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
@@ -1,9 +1,9 @@
import testReports from 'test_fixtures/pipelines/test_report.json';
import * as types from '~/pipelines/stores/test_reports/mutation_types';
import mutations from '~/pipelines/stores/test_reports/mutations';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
describe('Mutations TestReports Store', () => {
let mockState;
@@ -58,7 +58,7 @@ describe('Mutations TestReports Store', () => {
expect(mockState.errorMessage).toBe(message);
});
- it('should show a flash message otherwise', () => {
+ it('should show an alert otherwise', () => {
mutations[types.SET_SUITE_ERROR](mockState, {});
expect(createAlert).toHaveBeenCalled();
diff --git a/spec/frontend/pipelines/test_reports/test_case_details_spec.js b/spec/frontend/pipelines/test_reports/test_case_details_spec.js
index f194864447c..f8663408817 100644
--- a/spec/frontend/pipelines/test_reports/test_case_details_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_case_details_spec.js
@@ -45,11 +45,6 @@ describe('Test case details', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('required details', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js
index 9b9ee4172f9..c8c917a1b9e 100644
--- a/spec/frontend/pipelines/test_reports/test_reports_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js
@@ -60,10 +60,6 @@ describe('Test reports app', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when component is created', () => {
it('should call fetchSummary when pipeline has test report', () => {
createComponent();
diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
index da13df833e7..8eb83f17f4d 100644
--- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
@@ -65,10 +65,6 @@ describe('Test reports suite table', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render a message when there are no test cases', () => {
createComponent({ suite: [] });
diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js
index f0da0df2ba6..efb1bf09d20 100644
--- a/spec/frontend/pipelines/time_ago_spec.js
+++ b/spec/frontend/pipelines/time_ago_spec.js
@@ -30,11 +30,6 @@ describe('Timeago component', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const duration = () => wrapper.find('.duration');
const finishedAt = () => wrapper.find('.finished-at');
const findInProgress = () => wrapper.findByTestId('pipeline-in-progress');
diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
index caa66502e11..d518519a424 100644
--- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
@@ -71,11 +71,6 @@ describe('Pipeline Branch Name Token', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js
index c090fd353f7..cf4ccb5ce43 100644
--- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js
@@ -45,11 +45,6 @@ describe('Pipeline Status Token', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
diff --git a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js
index 7311a5d2f5a..88c88d8f16f 100644
--- a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js
@@ -53,11 +53,6 @@ describe('Pipeline Branch Name Token', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
index c763bfe1b27..e9ec684a350 100644
--- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
@@ -52,11 +52,6 @@ describe('Pipeline Trigger Author Token', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
diff --git a/spec/frontend/popovers/components/popovers_spec.js b/spec/frontend/popovers/components/popovers_spec.js
index 1299e7277d1..7f247fbbd4f 100644
--- a/spec/frontend/popovers/components/popovers_spec.js
+++ b/spec/frontend/popovers/components/popovers_spec.js
@@ -33,11 +33,6 @@ describe('popovers/components/popovers.vue', () => {
const allPopovers = () => wrapper.findAllComponents(GlPopover);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('addPopovers', () => {
it('attaches popovers to the targets specified', async () => {
const target = createPopoverTarget();
diff --git a/spec/frontend/profile/account/components/delete_account_modal_spec.js b/spec/frontend/profile/account/components/delete_account_modal_spec.js
index e4a316e1ee7..9a8f82f0028 100644
--- a/spec/frontend/profile/account/components/delete_account_modal_spec.js
+++ b/spec/frontend/profile/account/components/delete_account_modal_spec.js
@@ -40,12 +40,6 @@ describe('DeleteAccountModal component', () => {
vm = wrapper.vm;
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- vm = null;
- });
-
const findElements = () => {
const confirmation = vm.confirmWithPassword ? 'password' : 'username';
return {
diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js
index fa0e86a7b05..fa107600d64 100644
--- a/spec/frontend/profile/account/components/update_username_spec.js
+++ b/spec/frontend/profile/account/components/update_username_spec.js
@@ -1,14 +1,15 @@
import { GlModal } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'helpers/test_constants';
-import { createAlert } from '~/flash';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import UpdateUsername from '~/profile/account/components/update_username.vue';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('UpdateUsername component', () => {
const rootUrl = TEST_HOST;
@@ -21,8 +22,10 @@ describe('UpdateUsername component', () => {
let wrapper;
let axiosMock;
+ const findNewUsernameInput = () => wrapper.findByTestId('new-username-input');
+
const createComponent = (props = {}) => {
- wrapper = shallowMount(UpdateUsername, {
+ wrapper = shallowMountExtended(UpdateUsername, {
propsData: {
...defaultProps,
...props,
@@ -39,8 +42,8 @@ describe('UpdateUsername component', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
+ Vue.config.errorHandler = null;
});
const findElements = () => {
@@ -56,6 +59,13 @@ describe('UpdateUsername component', () => {
};
};
+ const clickModalWithErrorResponse = () => {
+ Vue.config.errorHandler = jest.fn(); // silence thrown error
+ const { modal } = findElements();
+ modal.vm.$emit('primary');
+ return waitForPromises();
+ };
+
it('has a disabled button if the username was not changed', async () => {
const { openModalBtn } = findElements();
@@ -80,14 +90,10 @@ describe('UpdateUsername component', () => {
beforeEach(async () => {
createComponent();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ newUsername });
-
- await nextTick();
+ await findNewUsernameInput().setValue(newUsername);
});
- it('confirmation modal contains proper header and body', async () => {
+ it('confirmation modal contains proper header and body', () => {
const { modal } = findElements();
expect(modal.props('title')).toBe('Change username?');
@@ -100,25 +106,26 @@ describe('UpdateUsername component', () => {
axiosMock.onPut(actionUrl).replyOnce(() => [HTTP_STATUS_OK, { message: 'Username changed' }]);
jest.spyOn(axios, 'put');
- await wrapper.vm.onConfirm();
- await nextTick();
+ const { modal } = findElements();
+ modal.vm.$emit('primary');
+ await waitForPromises();
expect(axios.put).toHaveBeenCalledWith(actionUrl, { user: { username: newUsername } });
});
it('sets the username after a successful update', async () => {
- const { input, openModalBtn } = findElements();
+ const { input, openModalBtn, modal } = findElements();
axiosMock.onPut(actionUrl).replyOnce(() => {
- expect(input.attributes('disabled')).toBe('disabled');
+ expect(input.attributes('disabled')).toBeDefined();
expect(openModalBtn.props('disabled')).toBe(false);
expect(openModalBtn.props('loading')).toBe(true);
return [HTTP_STATUS_OK, { message: 'Username changed' }];
});
- await wrapper.vm.onConfirm();
- await nextTick();
+ modal.vm.$emit('primary');
+ await waitForPromises();
expect(input.attributes('disabled')).toBe(undefined);
expect(openModalBtn.props('disabled')).toBe(true);
@@ -129,14 +136,15 @@ describe('UpdateUsername component', () => {
const { input, openModalBtn } = findElements();
axiosMock.onPut(actionUrl).replyOnce(() => {
- expect(input.attributes('disabled')).toBe('disabled');
+ expect(input.attributes('disabled')).toBeDefined();
expect(openModalBtn.props('disabled')).toBe(false);
expect(openModalBtn.props('loading')).toBe(true);
return [HTTP_STATUS_BAD_REQUEST, { message: 'Invalid username' }];
});
- await expect(wrapper.vm.onConfirm()).rejects.toThrow();
+ await clickModalWithErrorResponse();
+
expect(input.attributes('disabled')).toBe(undefined);
expect(openModalBtn.props('disabled')).toBe(false);
expect(openModalBtn.props('loading')).toBe(false);
@@ -147,7 +155,7 @@ describe('UpdateUsername component', () => {
return [HTTP_STATUS_BAD_REQUEST, { message: 'Invalid username' }];
});
- await expect(wrapper.vm.onConfirm()).rejects.toThrow();
+ await clickModalWithErrorResponse();
expect(createAlert).toHaveBeenCalledWith({
message: 'Invalid username',
@@ -159,7 +167,7 @@ describe('UpdateUsername component', () => {
return [HTTP_STATUS_BAD_REQUEST];
});
- await expect(wrapper.vm.onConfirm()).rejects.toThrow();
+ await clickModalWithErrorResponse();
expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred while updating your username, please try again.',
diff --git a/spec/frontend/profile/components/activity_calendar_spec.js b/spec/frontend/profile/components/activity_calendar_spec.js
new file mode 100644
index 00000000000..fb9dc7b22f7
--- /dev/null
+++ b/spec/frontend/profile/components/activity_calendar_spec.js
@@ -0,0 +1,120 @@
+import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import * as GitLabUIUtils from '@gitlab/ui/dist/utils';
+
+import ActivityCalendar from '~/profile/components/activity_calendar.vue';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { useFakeDate } from 'helpers/fake_date';
+import { userCalendarResponse } from '../mock_data';
+
+jest.mock('~/lib/utils/ajax_cache');
+jest.mock('@gitlab/ui/dist/utils');
+
+describe('ActivityCalendar', () => {
+ // Feb 21st, 2023
+ useFakeDate(2023, 1, 21);
+
+ let wrapper;
+
+ const defaultProvide = {
+ userCalendarPath: '/users/root/calendar.json',
+ utcOffset: '0',
+ };
+
+ const createComponent = () => {
+ wrapper = mountExtended(ActivityCalendar, { provide: defaultProvide });
+ };
+
+ const mockSuccessfulApiRequest = () =>
+ AjaxCache.retrieve.mockResolvedValueOnce(userCalendarResponse);
+ const mockUnsuccessfulApiRequest = () => AjaxCache.retrieve.mockRejectedValueOnce();
+
+ const findCalendar = () => wrapper.findByTestId('contrib-calendar');
+
+ describe('when API request is loading', () => {
+ beforeEach(() => {
+ AjaxCache.retrieve.mockReturnValueOnce(new Promise(() => {}));
+ });
+
+ it('renders loading icon', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('when API request is successful', () => {
+ beforeEach(() => {
+ mockSuccessfulApiRequest();
+ });
+
+ it('renders the calendar', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findCalendar().exists()).toBe(true);
+ expect(wrapper.findByText(ActivityCalendar.i18n.calendarHint).exists()).toBe(true);
+ });
+
+ describe('when window is resized', () => {
+ it('re-renders the calendar', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ mockSuccessfulApiRequest();
+ window.innerWidth = 1200;
+ window.dispatchEvent(new Event('resize'));
+
+ await waitForPromises();
+
+ expect(findCalendar().exists()).toBe(true);
+ expect(AjaxCache.retrieve).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+
+ describe('when API request is not successful', () => {
+ beforeEach(() => {
+ mockUnsuccessfulApiRequest();
+ });
+
+ it('renders error', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GlAlert).exists()).toBe(true);
+ });
+
+ describe('when retry button is clicked', () => {
+ it('retries API request', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ mockSuccessfulApiRequest();
+
+ await wrapper.findByRole('button', { name: ActivityCalendar.i18n.retry }).trigger('click');
+
+ await waitForPromises();
+
+ expect(findCalendar().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('when screen is extra small', () => {
+ beforeEach(() => {
+ GitLabUIUtils.GlBreakpointInstance.getBreakpointSize.mockReturnValueOnce('xs');
+ });
+
+ it('does not render the calendar', () => {
+ createComponent();
+
+ expect(findCalendar().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/profile/components/followers_tab_spec.js b/spec/frontend/profile/components/followers_tab_spec.js
index 4af428c4e0c..9cc5bdea9be 100644
--- a/spec/frontend/profile/components/followers_tab_spec.js
+++ b/spec/frontend/profile/components/followers_tab_spec.js
@@ -1,4 +1,4 @@
-import { GlTab } from '@gitlab/ui';
+import { GlBadge, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
import FollowersTab from '~/profile/components/followers_tab.vue';
@@ -8,12 +8,25 @@ describe('FollowersTab', () => {
let wrapper;
const createComponent = () => {
- wrapper = shallowMountExtended(FollowersTab);
+ wrapper = shallowMountExtended(FollowersTab, {
+ provide: {
+ followers: 2,
+ },
+ });
};
- it('renders `GlTab` and sets `title` prop', () => {
+ it('renders `GlTab` and sets title', () => {
createComponent();
- expect(wrapper.findComponent(GlTab).attributes('title')).toBe(s__('UserProfile|Followers'));
+ expect(wrapper.findComponent(GlTab).element.textContent).toContain(
+ s__('UserProfile|Followers'),
+ );
+ });
+
+ it('renders `GlBadge`, sets size and content', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(GlBadge).attributes('size')).toBe('sm');
+ expect(wrapper.findComponent(GlBadge).element.textContent).toBe('2');
});
});
diff --git a/spec/frontend/profile/components/following_tab_spec.js b/spec/frontend/profile/components/following_tab_spec.js
index 75123274ccb..c9d56360c3e 100644
--- a/spec/frontend/profile/components/following_tab_spec.js
+++ b/spec/frontend/profile/components/following_tab_spec.js
@@ -1,4 +1,4 @@
-import { GlTab } from '@gitlab/ui';
+import { GlBadge, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
import FollowingTab from '~/profile/components/following_tab.vue';
@@ -8,12 +8,25 @@ describe('FollowingTab', () => {
let wrapper;
const createComponent = () => {
- wrapper = shallowMountExtended(FollowingTab);
+ wrapper = shallowMountExtended(FollowingTab, {
+ provide: {
+ followees: 3,
+ },
+ });
};
- it('renders `GlTab` and sets `title` prop', () => {
+ it('renders `GlTab` and sets title', () => {
createComponent();
- expect(wrapper.findComponent(GlTab).attributes('title')).toBe(s__('UserProfile|Following'));
+ expect(wrapper.findComponent(GlTab).element.textContent).toContain(
+ s__('UserProfile|Following'),
+ );
+ });
+
+ it('renders `GlBadge`, sets size and content', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(GlBadge).attributes('size')).toBe('sm');
+ expect(wrapper.findComponent(GlBadge).element.textContent).toBe('3');
});
});
diff --git a/spec/frontend/profile/components/overview_tab_spec.js b/spec/frontend/profile/components/overview_tab_spec.js
index eb27515bca3..aeab24cb730 100644
--- a/spec/frontend/profile/components/overview_tab_spec.js
+++ b/spec/frontend/profile/components/overview_tab_spec.js
@@ -1,14 +1,25 @@
-import { GlTab } from '@gitlab/ui';
+import { GlLoadingIcon, GlTab, GlLink } from '@gitlab/ui';
+import projects from 'test_fixtures/api/users/projects/get.json';
import { s__ } from '~/locale';
import OverviewTab from '~/profile/components/overview_tab.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ActivityCalendar from '~/profile/components/activity_calendar.vue';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
describe('OverviewTab', () => {
let wrapper;
- const createComponent = () => {
- wrapper = shallowMountExtended(OverviewTab);
+ const defaultPropsData = {
+ personalProjects: convertObjectPropsToCamelCase(projects, { deep: true }),
+ personalProjectsLoading: false,
+ };
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = shallowMountExtended(OverviewTab, {
+ propsData: { ...defaultPropsData, ...propsData },
+ });
};
it('renders `GlTab` and sets `title` prop', () => {
@@ -16,4 +27,47 @@ describe('OverviewTab', () => {
expect(wrapper.findComponent(GlTab).attributes('title')).toBe(s__('UserProfile|Overview'));
});
+
+ it('renders `ActivityCalendar` component', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(ActivityCalendar).exists()).toBe(true);
+ });
+
+ it('renders personal projects section heading and `View all` link', () => {
+ createComponent();
+
+ expect(
+ wrapper.findByRole('heading', { name: OverviewTab.i18n.personalProjects }).exists(),
+ ).toBe(true);
+ expect(wrapper.findComponent(GlLink).text()).toBe(OverviewTab.i18n.viewAll);
+ });
+
+ describe('when personal projects are loading', () => {
+ it('renders loading icon', () => {
+ createComponent({
+ propsData: {
+ personalProjects: [],
+ personalProjectsLoading: true,
+ },
+ });
+
+ expect(
+ wrapper.findByTestId('personal-projects-section').findComponent(GlLoadingIcon).exists(),
+ ).toBe(true);
+ });
+ });
+
+ describe('when projects are done loading', () => {
+ it('renders `ProjectsList` component and passes `projects` prop', () => {
+ createComponent();
+
+ expect(
+ wrapper
+ .findByTestId('personal-projects-section')
+ .findComponent(ProjectsList)
+ .props('projects'),
+ ).toMatchObject(defaultPropsData.personalProjects);
+ });
+ });
});
diff --git a/spec/frontend/profile/components/profile_tabs_spec.js b/spec/frontend/profile/components/profile_tabs_spec.js
index 11ab372f1dd..80a1ff422ab 100644
--- a/spec/frontend/profile/components/profile_tabs_spec.js
+++ b/spec/frontend/profile/components/profile_tabs_spec.js
@@ -1,6 +1,9 @@
+import projects from 'test_fixtures/api/users/projects/get.json';
import ProfileTabs from '~/profile/components/profile_tabs.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-
+import { createAlert } from '~/alert';
+import { getUserProjects } from '~/rest_api';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import OverviewTab from '~/profile/components/overview_tab.vue';
import ActivityTab from '~/profile/components/activity_tab.vue';
import GroupsTab from '~/profile/components/groups_tab.vue';
@@ -10,12 +13,20 @@ import StarredProjectsTab from '~/profile/components/starred_projects_tab.vue';
import SnippetsTab from '~/profile/components/snippets_tab.vue';
import FollowersTab from '~/profile/components/followers_tab.vue';
import FollowingTab from '~/profile/components/following_tab.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('~/alert');
+jest.mock('~/rest_api');
describe('ProfileTabs', () => {
let wrapper;
const createComponent = () => {
- wrapper = shallowMountExtended(ProfileTabs);
+ wrapper = shallowMountExtended(ProfileTabs, {
+ provide: {
+ userId: '1',
+ },
+ });
};
it.each([
@@ -33,4 +44,46 @@ describe('ProfileTabs', () => {
expect(wrapper.findComponent(tab).exists()).toBe(true);
});
+
+ describe('when personal projects API request is loading', () => {
+ beforeEach(() => {
+ getUserProjects.mockReturnValueOnce(new Promise(() => {}));
+ createComponent();
+ });
+
+ it('passes correct props to `OverviewTab` component', () => {
+ expect(wrapper.findComponent(OverviewTab).props()).toEqual({
+ personalProjects: [],
+ personalProjectsLoading: true,
+ });
+ });
+ });
+
+ describe('when personal projects API request is successful', () => {
+ beforeEach(async () => {
+ getUserProjects.mockResolvedValueOnce({ data: projects });
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('passes correct props to `OverviewTab` component', () => {
+ expect(wrapper.findComponent(OverviewTab).props()).toMatchObject({
+ personalProjects: convertObjectPropsToCamelCase(projects, { deep: true }),
+ personalProjectsLoading: false,
+ });
+ });
+ });
+
+ describe('when personal projects API request is not successful', () => {
+ beforeEach(() => {
+ getUserProjects.mockRejectedValueOnce();
+ createComponent();
+ });
+
+ it('calls `createAlert`', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: ProfileTabs.i18n.personalProjectsErrorMessage,
+ });
+ });
+ });
});
diff --git a/spec/frontend/profile/components/user_achievements_spec.js b/spec/frontend/profile/components/user_achievements_spec.js
new file mode 100644
index 00000000000..ff6f323621a
--- /dev/null
+++ b/spec/frontend/profile/components/user_achievements_spec.js
@@ -0,0 +1,122 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import getUserAchievementsEmptyResponse from 'test_fixtures/graphql/get_user_achievements_empty_response.json';
+import getUserAchievementsLongResponse from 'test_fixtures/graphql/get_user_achievements_long_response.json';
+import getUserAchievementsResponse from 'test_fixtures/graphql/get_user_achievements_with_avatar_and_description_response.json';
+import getUserAchievementsPrivateGroupResponse from 'test_fixtures/graphql/get_user_achievements_from_private_group.json';
+import getUserAchievementsNoAvatarResponse from 'test_fixtures/graphql/get_user_achievements_without_avatar_or_description_response.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import UserAchievements from '~/profile/components/user_achievements.vue';
+import getUserAchievements from '~/profile/components//graphql/get_user_achievements.query.graphql';
+import { getTimeago, timeagoLanguageCode } from '~/lib/utils/datetime_utility';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
+const USER_ID = 123;
+const ROOT_URL = 'https://gitlab.com/';
+const PLACEHOLDER_URL = 'https://gitlab.com/assets/gitlab_logo.png';
+const userAchievement1 = getUserAchievementsResponse.data.user.userAchievements.nodes[0];
+
+Vue.use(VueApollo);
+
+describe('UserAchievements', () => {
+ let wrapper;
+
+ const getUserAchievementsQueryHandler = jest.fn().mockResolvedValue(getUserAchievementsResponse);
+ const achievement = () => wrapper.findByTestId('user-achievement');
+
+ const createComponent = ({ queryHandler = getUserAchievementsQueryHandler } = {}) => {
+ const fakeApollo = createMockApollo([[getUserAchievements, queryHandler]]);
+
+ wrapper = mountExtended(UserAchievements, {
+ apolloProvider: fakeApollo,
+ provide: {
+ rootUrl: ROOT_URL,
+ userId: USER_ID,
+ },
+ });
+ };
+
+ it('renders no achievements on reject', async () => {
+ createComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
+
+ await waitForPromises();
+
+ expect(wrapper.findAllByTestId('user-achievement').length).toBe(0);
+ });
+
+ it('renders no achievements when none are present', async () => {
+ createComponent({
+ queryHandler: jest.fn().mockResolvedValue(getUserAchievementsEmptyResponse),
+ });
+
+ await waitForPromises();
+
+ expect(wrapper.findAllByTestId('user-achievement').length).toBe(0);
+ });
+
+ it('only renders 3 achievements when more are present', async () => {
+ createComponent({ queryHandler: jest.fn().mockResolvedValue(getUserAchievementsLongResponse) });
+
+ await waitForPromises();
+
+ expect(wrapper.findAllByTestId('user-achievement').length).toBe(3);
+ });
+
+ it('renders correctly if the achievement is from a private namespace', async () => {
+ createComponent({
+ queryHandler: jest.fn().mockResolvedValue(getUserAchievementsPrivateGroupResponse),
+ });
+
+ await waitForPromises();
+
+ const userAchievement =
+ getUserAchievementsPrivateGroupResponse.data.user.userAchievements.nodes[0];
+
+ expect(achievement().text()).toContain(userAchievement.achievement.name);
+ expect(achievement().text()).toContain(
+ `Awarded ${getTimeago().format(
+ userAchievement.createdAt,
+ timeagoLanguageCode,
+ )} by a private namespace`,
+ );
+ });
+
+ it('renders achievement correctly', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(achievement().text()).toContain(userAchievement1.achievement.name);
+ expect(achievement().text()).toContain(
+ `Awarded ${getTimeago().format(userAchievement1.createdAt, timeagoLanguageCode)} by`,
+ );
+ expect(achievement().text()).toContain(userAchievement1.achievement.namespace.fullPath);
+ expect(achievement().text()).toContain(userAchievement1.achievement.description);
+ expect(achievement().find('img').attributes('src')).toBe(
+ userAchievement1.achievement.avatarUrl,
+ );
+ });
+
+ it('renders a placeholder when no avatar is present', async () => {
+ gon.gitlab_logo = PLACEHOLDER_URL;
+ createComponent({
+ queryHandler: jest.fn().mockResolvedValue(getUserAchievementsNoAvatarResponse),
+ });
+
+ await waitForPromises();
+
+ expect(achievement().find('img').attributes('src')).toBe(PLACEHOLDER_URL);
+ });
+
+ it('does not render a description when none is present', async () => {
+ gon.gitlab_logo = PLACEHOLDER_URL;
+ createComponent({
+ queryHandler: jest.fn().mockResolvedValue(getUserAchievementsNoAvatarResponse),
+ });
+
+ await waitForPromises();
+
+ expect(wrapper.findAllByTestId('achievement-description').length).toBe(0);
+ });
+});
diff --git a/spec/frontend/profile/mock_data.js b/spec/frontend/profile/mock_data.js
new file mode 100644
index 00000000000..7106ea84619
--- /dev/null
+++ b/spec/frontend/profile/mock_data.js
@@ -0,0 +1,22 @@
+export const userCalendarResponse = {
+ '2022-11-18': 13,
+ '2022-11-19': 21,
+ '2022-11-20': 14,
+ '2022-11-21': 15,
+ '2022-11-22': 20,
+ '2022-11-23': 21,
+ '2022-11-24': 15,
+ '2022-11-25': 14,
+ '2022-11-26': 16,
+ '2022-11-27': 13,
+ '2022-11-28': 4,
+ '2022-11-29': 1,
+ '2022-11-30': 1,
+ '2022-12-13': 1,
+ '2023-01-10': 3,
+ '2023-01-11': 2,
+ '2023-01-20': 1,
+ '2023-02-02': 1,
+ '2023-02-06': 2,
+ '2023-02-07': 2,
+};
diff --git a/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js b/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js
index e60602ab336..e69bfad765a 100644
--- a/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js
+++ b/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js
@@ -12,11 +12,6 @@ describe('DiffsColorsPreview component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders diff colors preview', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/profile/preferences/components/diffs_colors_spec.js b/spec/frontend/profile/preferences/components/diffs_colors_spec.js
index 02f501a0b06..28fc01654b9 100644
--- a/spec/frontend/profile/preferences/components/diffs_colors_spec.js
+++ b/spec/frontend/profile/preferences/components/diffs_colors_spec.js
@@ -29,11 +29,6 @@ describe('DiffsColors component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('mounts', () => {
createComponent();
@@ -65,9 +60,12 @@ describe('DiffsColors component', () => {
});
it.each([
- [{}, '--diff-deletion-color: rgba(255,0,0,0.2); --diff-addition-color: rgba(0,255,0,0.2);'],
- [{ addition: null }, '--diff-deletion-color: rgba(255,0,0,0.2);'],
- [{ deletion: null }, '--diff-addition-color: rgba(0,255,0,0.2);'],
+ [
+ {},
+ '--diff-deletion-color: rgba(255, 0, 0, 0.2); --diff-addition-color: rgba(0, 255, 0, 0.2);',
+ ],
+ [{ addition: null }, '--diff-deletion-color: rgba(255, 0, 0, 0.2);'],
+ [{ deletion: null }, '--diff-addition-color: rgba(0, 255, 0, 0.2);'],
])('should set correct CSS variables', (provide, expectedStyle) => {
createComponent(provide);
diff --git a/spec/frontend/profile/preferences/components/integration_view_spec.js b/spec/frontend/profile/preferences/components/integration_view_spec.js
index f650bee7fda..b809f2f4aed 100644
--- a/spec/frontend/profile/preferences/components/integration_view_spec.js
+++ b/spec/frontend/profile/preferences/components/integration_view_spec.js
@@ -38,11 +38,6 @@ describe('IntegrationView component', () => {
const findHiddenField = () =>
wrapper.findByTestId('profile-preferences-integration-hidden-field');
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should render the form group legend correctly', () => {
wrapper = createComponent();
diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
index 91cd868daac..21167dccda9 100644
--- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js
+++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/alert';
import IntegrationView from '~/profile/preferences/components/integration_view.vue';
import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue';
import { i18n } from '~/profile/preferences/constants';
@@ -17,7 +17,7 @@ import {
lightModeThemeId2,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const expectedUrl = '/foo';
useMockLocationHelper();
@@ -83,11 +83,6 @@ describe('ProfilePreferences component', () => {
document.body.classList.add('content-wrapper');
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should not render Integrations section', () => {
wrapper = createComponent();
const views = wrapper.findAllComponents(IntegrationView);
diff --git a/spec/frontend/profile/utils_spec.js b/spec/frontend/profile/utils_spec.js
new file mode 100644
index 00000000000..43537afe169
--- /dev/null
+++ b/spec/frontend/profile/utils_spec.js
@@ -0,0 +1,15 @@
+import { getVisibleCalendarPeriod } from '~/profile/utils';
+import { CALENDAR_PERIOD_12_MONTHS, CALENDAR_PERIOD_6_MONTHS } from '~/profile/constants';
+
+describe('getVisibleCalendarPeriod', () => {
+ it.each`
+ width | expected
+ ${1000} | ${CALENDAR_PERIOD_12_MONTHS}
+ ${900} | ${CALENDAR_PERIOD_6_MONTHS}
+ `('returns $expected when container width is $width', ({ width, expected }) => {
+ const container = document.createElement('div');
+ jest.spyOn(container, 'getBoundingClientRect').mockReturnValueOnce({ width });
+
+ expect(getVisibleCalendarPeriod(container)).toBe(expected);
+ });
+});
diff --git a/spec/frontend/projects/clusters_deprecation_slert/components/clusters_deprecation_alert_spec.js b/spec/frontend/projects/clusters_deprecation_slert/components/clusters_deprecation_alert_spec.js
index d230b96ad82..68ea3a4dc4d 100644
--- a/spec/frontend/projects/clusters_deprecation_slert/components/clusters_deprecation_alert_spec.js
+++ b/spec/frontend/projects/clusters_deprecation_slert/components/clusters_deprecation_alert_spec.js
@@ -26,10 +26,6 @@ describe('ClustersDeprecationAlert', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
it('should render a non-dismissible warning alert', () => {
expect(findAlert().props()).toMatchObject({
diff --git a/spec/frontend/projects/commit/components/branches_dropdown_spec.js b/spec/frontend/projects/commit/components/branches_dropdown_spec.js
index 6aa5a9a5a3a..bff40c2bc39 100644
--- a/spec/frontend/projects/commit/components/branches_dropdown_spec.js
+++ b/spec/frontend/projects/commit/components/branches_dropdown_spec.js
@@ -12,7 +12,7 @@ describe('BranchesDropdown', () => {
let store;
const spyFetchBranches = jest.fn();
- const createComponent = (props, state = { isFetching: false }) => {
+ const createComponent = (props, state = { isFetching: false, branch: '_main_' }) => {
store = new Vuex.Store({
getters: {
joinedBranches: () => ['_main_', '_branch_1_', '_branch_2_'],
@@ -41,8 +41,6 @@ describe('BranchesDropdown', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
spyFetchBranches.mockReset();
});
@@ -61,7 +59,7 @@ describe('BranchesDropdown', () => {
});
describe('Selecting Dropdown Item', () => {
- it('emits event', async () => {
+ it('emits event', () => {
findDropdown().vm.$emit('select', '_anything_');
expect(wrapper.emitted()).toHaveProperty('input');
@@ -70,13 +68,11 @@ describe('BranchesDropdown', () => {
describe('When searching', () => {
it('invokes fetchBranches', async () => {
- const spy = jest.spyOn(wrapper.vm, 'fetchBranches');
-
findDropdown().vm.$emit('search', '_anything_');
await nextTick();
- expect(spy).toHaveBeenCalledWith('_anything_');
+ expect(spyFetchBranches).toHaveBeenCalledWith(expect.any(Object), '_anything_');
});
});
});
diff --git a/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js
index 70491405986..7df498f597b 100644
--- a/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js
+++ b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js
@@ -85,7 +85,7 @@ describe('BranchesDropdown', () => {
expect(findTagItem().exists()).toBe(false);
});
- it('does not have a email patches options', () => {
+ it('does not have a patches options', () => {
createComponent({ canEmailPatches: false });
expect(findEmailPatchesItem().exists()).toBe(false);
diff --git a/spec/frontend/projects/commit/components/form_modal_spec.js b/spec/frontend/projects/commit/components/form_modal_spec.js
index c59cf700e0d..d40e2d7a48c 100644
--- a/spec/frontend/projects/commit/components/form_modal_spec.js
+++ b/spec/frontend/projects/commit/components/form_modal_spec.js
@@ -1,9 +1,9 @@
import { GlModal, GlForm, GlFormCheckbox, GlSprintf } from '@gitlab/ui';
import { within } from '@testing-library/dom';
-import { shallowMount, mount, createWrapper } from '@vue/test-utils';
+import { createWrapper } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
@@ -21,21 +21,24 @@ describe('CommitFormModal', () => {
let store;
let axiosMock;
- const createComponent = (method, state = {}, provide = {}, propsData = {}) => {
+ const createComponent = ({
+ method = shallowMountExtended,
+ state = {},
+ provide = {},
+ propsData = {},
+ } = {}) => {
store = createStore({ ...mockData.mockModal, ...state });
- wrapper = extendedWrapper(
- method(CommitFormModal, {
- provide: {
- ...provide,
- },
- propsData: { ...mockData.modalPropsData, ...propsData },
- store,
- attrs: {
- static: true,
- visible: true,
- },
- }),
- );
+ wrapper = method(CommitFormModal, {
+ provide: {
+ ...provide,
+ },
+ propsData: { ...mockData.modalPropsData, ...propsData },
+ store,
+ attrs: {
+ static: true,
+ visible: true,
+ },
+ });
};
const findModal = () => wrapper.findComponent(GlModal);
@@ -55,7 +58,6 @@ describe('CommitFormModal', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
});
@@ -63,13 +65,13 @@ describe('CommitFormModal', () => {
it('Listens for opening of modal on mount', () => {
jest.spyOn(eventHub, '$on');
- createComponent(shallowMount);
+ createComponent();
expect(eventHub.$on).toHaveBeenCalledWith(mockData.modalPropsData.openModal, wrapper.vm.show);
});
it('Shows modal', () => {
- createComponent(shallowMount);
+ createComponent();
const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit');
wrapper.vm.show();
@@ -78,25 +80,25 @@ describe('CommitFormModal', () => {
});
it('Clears the modal state once modal is hidden', () => {
- createComponent(shallowMount);
+ createComponent();
jest.spyOn(store, 'dispatch').mockImplementation();
- wrapper.vm.checked = false;
+ findCheckBox().vm.$emit('input', false);
findModal().vm.$emit('hidden');
expect(store.dispatch).toHaveBeenCalledWith('clearModal');
expect(store.dispatch).toHaveBeenCalledWith('setSelectedBranch', '');
- expect(wrapper.vm.checked).toBe(true);
+ expect(findCheckBox().attributes('checked')).toBe('true');
});
it('Shows the checkbox for new merge request', () => {
- createComponent(shallowMount);
+ createComponent();
expect(findCheckBox().exists()).toBe(true);
});
it('Shows the prepended text', () => {
- createComponent(shallowMount, {}, { prependedText: '_prepended_text_' });
+ createComponent({ provide: { prependedText: '_prepended_text_' } });
expect(findPrependedText().exists()).toBe(true);
expect(findPrependedText().findComponent(GlSprintf).attributes('message')).toBe(
@@ -105,25 +107,25 @@ describe('CommitFormModal', () => {
});
it('Does not show prepended text', () => {
- createComponent(shallowMount);
+ createComponent();
expect(findPrependedText().exists()).toBe(false);
});
it('Does not show extra message text', () => {
- createComponent(shallowMount);
+ createComponent();
expect(findModal().find('[data-testid="appended-text"]').exists()).toBe(false);
});
it('Does not show the checkbox for new merge request', () => {
- createComponent(shallowMount, { pushCode: false });
+ createComponent({ state: { pushCode: false } });
expect(findCheckBox().exists()).toBe(false);
});
it('Shows the branch in fork message', () => {
- createComponent(shallowMount, { pushCode: false });
+ createComponent({ state: { pushCode: false } });
expect(findAppendedText().exists()).toBe(true);
expect(findAppendedText().findComponent(GlSprintf).attributes('message')).toContain(
@@ -132,7 +134,7 @@ describe('CommitFormModal', () => {
});
it('Shows the branch collaboration message', () => {
- createComponent(shallowMount, { pushCode: false, branchCollaboration: true });
+ createComponent({ state: { pushCode: false, branchCollaboration: true } });
expect(findAppendedText().exists()).toBe(true);
expect(findAppendedText().findComponent(GlSprintf).attributes('message')).toContain(
@@ -143,17 +145,13 @@ describe('CommitFormModal', () => {
describe('Taking action on the form', () => {
beforeEach(() => {
- createComponent(mount);
+ createComponent({ method: mountExtended });
});
it('Action primary button dispatches submit action', () => {
- const submitSpy = jest.spyOn(findForm().element, 'submit');
-
getByText(mockData.modalPropsData.i18n.actionPrimaryText).trigger('click');
- expect(submitSpy).toHaveBeenCalled();
-
- submitSpy.mockRestore();
+ expect(wrapper.vm.$refs.form.$el.submit).toHaveBeenCalled();
});
it('Changes the start_branch input value', async () => {
@@ -165,8 +163,8 @@ describe('CommitFormModal', () => {
});
it('Changes the target_project_id input value', async () => {
- createComponent(shallowMount, {}, {}, { isCherryPick: true });
- findProjectsDropdown().vm.$emit('selectProject', '_changed_project_value_');
+ createComponent({ propsData: { isCherryPick: true } });
+ findProjectsDropdown().vm.$emit('input', '_changed_project_value_');
await nextTick();
@@ -175,12 +173,9 @@ describe('CommitFormModal', () => {
});
it('action primary button triggers Redis HLL tracking api call', async () => {
- createComponent(mount, {}, {}, { primaryActionEventName: 'test_event' });
-
+ createComponent({ method: mountExtended, propsData: { primaryActionEventName: 'test_event' } });
await nextTick();
- jest.spyOn(findForm().element, 'submit');
-
getByText(mockData.modalPropsData.i18n.actionPrimaryText).trigger('click');
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_event');
diff --git a/spec/frontend/projects/commit/components/projects_dropdown_spec.js b/spec/frontend/projects/commit/components/projects_dropdown_spec.js
index 0e213ff388a..baf2ea2656f 100644
--- a/spec/frontend/projects/commit/components/projects_dropdown_spec.js
+++ b/spec/frontend/projects/commit/components/projects_dropdown_spec.js
@@ -1,6 +1,6 @@
import { GlCollapsibleListbox } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ProjectsDropdown from '~/projects/commit/components/projects_dropdown.vue';
@@ -38,7 +38,6 @@ describe('ProjectsDropdown', () => {
const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
afterEach(() => {
- wrapper.destroy();
spyFetchProjects.mockReset();
});
@@ -48,20 +47,24 @@ describe('ProjectsDropdown', () => {
});
describe('Custom events', () => {
- it('should emit selectProject if a project is clicked', () => {
+ it('should emit input if a project is clicked', () => {
findDropdown().vm.$emit('select', '1');
- expect(wrapper.emitted('selectProject')).toEqual([['1']]);
+ expect(wrapper.emitted('input')).toEqual([['1']]);
});
});
});
describe('Case insensitive for search term', () => {
beforeEach(() => {
- createComponent('_PrOjEcT_1_');
+ createComponent('_PrOjEcT_1_', { targetProjectId: '1' });
});
- it('renders only the project searched for', () => {
+ it('renders only the project searched for', async () => {
+ findDropdown().vm.$emit('search', '_project_1_');
+
+ await nextTick();
+
expect(findDropdown().props('items')).toEqual([{ text: '_project_1_', value: '1' }]);
});
});
diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js
index 008710984b9..adb87142fee 100644
--- a/spec/frontend/projects/commit/store/actions_spec.js
+++ b/spec/frontend/projects/commit/store/actions_spec.js
@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { PROJECT_BRANCHES_ERROR } from '~/projects/commit/constants';
import * as actions from '~/projects/commit/store/actions';
@@ -8,7 +8,7 @@ import * as types from '~/projects/commit/store/mutation_types';
import getInitialState from '~/projects/commit/store/state';
import mockData from '../mock_data';
-jest.mock('~/flash.js');
+jest.mock('~/alert');
describe('Commit form modal store actions', () => {
let axiosMock;
@@ -63,7 +63,7 @@ describe('Commit form modal store actions', () => {
);
});
- it('should show flash error and set error in state on fetchBranches failure', async () => {
+ it('should show an alert and set error in state on fetchBranches failure', async () => {
jest.spyOn(axios, 'get').mockRejectedValue();
await testAction(actions.fetchBranches, {}, state, [], [{ type: 'requestBranches' }]);
diff --git a/spec/frontend/projects/commit_box/info/init_details_button_spec.js b/spec/frontend/projects/commit_box/info/init_details_button_spec.js
new file mode 100644
index 00000000000..bf9c6a4c998
--- /dev/null
+++ b/spec/frontend/projects/commit_box/info/init_details_button_spec.js
@@ -0,0 +1,47 @@
+import { setHTMLFixture } from 'helpers/fixtures';
+import { initDetailsButton } from '~/projects/commit_box/info/init_details_button';
+
+const htmlFixture = `
+ <span>
+ <a href="#" class="js-details-expand"><span class="sub-element">Expand</span></a>
+ <span class="js-details-content hide">Some branch</span>
+ </span>`;
+
+describe('~/projects/commit_box/info/init_details_button', () => {
+ const findExpandButton = () => document.querySelector('.js-details-expand');
+ const findContent = () => document.querySelector('.js-details-content');
+ const findExpandSubElement = () => document.querySelector('.sub-element');
+
+ beforeEach(() => {
+ setHTMLFixture(htmlFixture);
+ initDetailsButton();
+ });
+
+ describe('when clicking the expand button', () => {
+ it('renders the content by removing the `hide` class', () => {
+ expect(findContent().classList).toContain('hide');
+ findExpandButton().click();
+ expect(findContent().classList).not.toContain('hide');
+ });
+
+ it('hides the expand button by adding the `gl-display-none` class', () => {
+ expect(findExpandButton().classList).not.toContain('gl-display-none');
+ findExpandButton().click();
+ expect(findExpandButton().classList).toContain('gl-display-none');
+ });
+ });
+
+ describe('when user clicks on element inside of expand button', () => {
+ it('renders the content by removing the `hide` class', () => {
+ expect(findContent().classList).toContain('hide');
+ findExpandSubElement().click();
+ expect(findContent().classList).not.toContain('hide');
+ });
+
+ it('hides the expand button by adding the `gl-display-none` class', () => {
+ expect(findExpandButton().classList).not.toContain('gl-display-none');
+ findExpandSubElement().click();
+ expect(findExpandButton().classList).toContain('gl-display-none');
+ });
+ });
+});
diff --git a/spec/frontend/projects/commit_box/info/load_branches_spec.js b/spec/frontend/projects/commit_box/info/load_branches_spec.js
index e49d92188ed..b00a6378e07 100644
--- a/spec/frontend/projects/commit_box/info/load_branches_spec.js
+++ b/spec/frontend/projects/commit_box/info/load_branches_spec.js
@@ -4,6 +4,9 @@ import { setHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { loadBranches } from '~/projects/commit_box/info/load_branches';
+import { initDetailsButton } from '~/projects/commit_box/info/init_details_button';
+
+jest.mock('~/projects/commit_box/info/init_details_button');
const mockCommitPath = '/commit/abcd/branches';
const mockBranchesRes =
@@ -26,6 +29,13 @@ describe('~/projects/commit_box/info/load_branches', () => {
mock.onGet(mockCommitPath).reply(HTTP_STATUS_OK, mockBranchesRes);
});
+ it('initializes the details button', async () => {
+ loadBranches();
+ await waitForPromises();
+
+ expect(initDetailsButton).toHaveBeenCalled();
+ });
+
it('loads and renders branches info', async () => {
loadBranches();
await waitForPromises();
diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js
index 907e0e226b6..630b8feafbc 100644
--- a/spec/frontend/projects/commits/components/author_select_spec.js
+++ b/spec/frontend/projects/commits/components/author_select_spec.js
@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import setWindowLocation from 'helpers/set_window_location_helper';
import * as urlUtility from '~/lib/utils/url_utility';
import AuthorSelect from '~/projects/commits/components/author_select.vue';
import { createStore } from '~/projects/commits/store';
@@ -54,7 +55,6 @@ describe('Author Select', () => {
});
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
@@ -65,39 +65,23 @@ describe('Author Select', () => {
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
describe('user is searching via "filter by commit message"', () => {
- it('disables dropdown container', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ hasSearchParam: true });
+ beforeEach(() => {
+ setWindowLocation(`?search=foo`);
+ createComponent();
+ });
- await nextTick();
+ it('does not disable dropdown container', () => {
expect(findDropdownContainer().attributes('disabled')).toBeUndefined();
});
- it('has correct tooltip message', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ hasSearchParam: true });
-
- await nextTick();
+ it('has correct tooltip message', () => {
expect(findDropdownContainer().attributes('title')).toBe(
'Searching by both author and message is currently not supported.',
);
});
- it('disables dropdown', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ hasSearchParam: false });
-
- await nextTick();
- expect(findDropdown().attributes('disabled')).toBeUndefined();
- });
-
- it('hasSearchParam if user types a truthy string', () => {
- wrapper.vm.setSearchParam('false');
-
- expect(wrapper.vm.hasSearchParam).toBe(true);
+ it('disables dropdown', () => {
+ expect(findDropdown().attributes('disabled')).toBeDefined();
});
});
@@ -107,9 +91,8 @@ describe('Author Select', () => {
});
it('displays the current selected author', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ currentAuthor });
+ setWindowLocation(`?author=${currentAuthor}`);
+ createComponent();
await nextTick();
expect(findDropdown().attributes('text')).toBe(currentAuthor);
@@ -147,12 +130,14 @@ describe('Author Select', () => {
expect(findDropdownItems().at(0).text()).toBe('Any Author');
});
- it('displays the project authors', async () => {
- await nextTick();
+ it('displays the project authors', () => {
expect(findDropdownItems()).toHaveLength(authors.length + 1);
});
it('has the correct props', async () => {
+ setWindowLocation(`?author=${currentAuthor}`);
+ createComponent();
+
const [{ avatar_url: avatarUrl, username }] = authors;
const result = {
avatarUrl,
@@ -160,16 +145,11 @@ describe('Author Select', () => {
isChecked: true,
};
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ currentAuthor });
-
await nextTick();
expect(findDropdownItems().at(1).props()).toEqual(expect.objectContaining(result));
});
- it("display the author's name", async () => {
- await nextTick();
+ it("display the author's name", () => {
expect(findDropdownItems().at(1).text()).toBe(currentAuthor);
});
diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js
index bae9c48fc1e..8afa2a6fb8f 100644
--- a/spec/frontend/projects/commits/store/actions_spec.js
+++ b/spec/frontend/projects/commits/store/actions_spec.js
@@ -1,13 +1,13 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import actions from '~/projects/commits/store/actions';
import * as types from '~/projects/commits/store/mutation_types';
import createState from '~/projects/commits/store/state';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Project commits actions', () => {
let state;
@@ -34,8 +34,8 @@ describe('Project commits actions', () => {
]));
});
- describe('shows a flash message when there is an error', () => {
- it('creates a flash', () => {
+ describe('shows an alert when there is an error', () => {
+ it('creates an alert', () => {
const mockDispatchContext = { dispatch: () => {}, commit: () => {}, state };
actions.receiveAuthorsError(mockDispatchContext);
diff --git a/spec/frontend/projects/compare/components/app_spec.js b/spec/frontend/projects/compare/components/app_spec.js
index 9b052a17caa..ee96f46ea0c 100644
--- a/spec/frontend/projects/compare/components/app_spec.js
+++ b/spec/frontend/projects/compare/components/app_spec.js
@@ -21,11 +21,6 @@ describe('CompareApp component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
beforeEach(() => {
createComponent();
});
diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js
index 21cca857c6a..0b1085470b8 100644
--- a/spec/frontend/projects/compare/components/repo_dropdown_spec.js
+++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js
@@ -16,11 +16,6 @@ describe('RepoDropdown component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findGlDropdown = () => wrapper.findComponent(GlDropdown);
const findHiddenInput = () => wrapper.find('input[type="hidden"]');
diff --git a/spec/frontend/projects/compare/components/revision_card_spec.js b/spec/frontend/projects/compare/components/revision_card_spec.js
index b23bd91ceda..3c9c61c8903 100644
--- a/spec/frontend/projects/compare/components/revision_card_spec.js
+++ b/spec/frontend/projects/compare/components/revision_card_spec.js
@@ -16,11 +16,6 @@ describe('RepoDropdown component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
beforeEach(() => {
createComponent();
});
diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js
index 53763bd7d8f..e289569f8ce 100644
--- a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js
+++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js
@@ -1,8 +1,8 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
-import { createAlert } from '~/flash';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue';
@@ -14,7 +14,7 @@ const defaultProps = {
paramsBranch: 'main',
};
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('RevisionDropdown component', () => {
let wrapper;
@@ -35,11 +35,14 @@ describe('RevisionDropdown component', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
});
const findGlDropdown = () => wrapper.findComponent(GlDropdown);
+ const findBranchesDropdownItem = () =>
+ wrapper.findAllComponents('[data-testid="branches-dropdown-item"]');
+ const findTagsDropdownItem = () =>
+ wrapper.findAllComponents('[data-testid="tags-dropdown-item"]');
it('sets hidden input', () => {
expect(wrapper.find('input[type="hidden"]').attributes('value')).toBe(
@@ -58,10 +61,21 @@ describe('RevisionDropdown component', () => {
createComponent();
- await axios.waitForAll();
+ expect(findBranchesDropdownItem()).toHaveLength(0);
+ expect(findTagsDropdownItem()).toHaveLength(0);
- expect(wrapper.vm.branches).toEqual(Branches);
- expect(wrapper.vm.tags).toEqual(Tags);
+ await waitForPromises();
+
+ Branches.forEach((branch, index) => {
+ expect(findBranchesDropdownItem().at(index).text()).toBe(branch);
+ });
+
+ Tags.forEach((tag, index) => {
+ expect(findTagsDropdownItem().at(index).text()).toBe(tag);
+ });
+
+ expect(findBranchesDropdownItem()).toHaveLength(Branches.length);
+ expect(findTagsDropdownItem()).toHaveLength(Tags.length);
});
it('sets branches and tags to be an empty array when no tags or branches are given', async () => {
@@ -70,16 +84,17 @@ describe('RevisionDropdown component', () => {
Tags: undefined,
});
- await axios.waitForAll();
+ await waitForPromises();
- expect(wrapper.vm.branches).toEqual([]);
- expect(wrapper.vm.tags).toEqual([]);
+ expect(findBranchesDropdownItem()).toHaveLength(0);
+ expect(findTagsDropdownItem()).toHaveLength(0);
});
- it('shows flash message on error', async () => {
+ it('shows an alert on error', async () => {
axiosMock.onGet('some/invalid/path').replyOnce(HTTP_STATUS_NOT_FOUND);
- await wrapper.vm.fetchBranchesAndTags();
+ await waitForPromises();
+
expect(createAlert).toHaveBeenCalled();
});
@@ -102,17 +117,19 @@ describe('RevisionDropdown component', () => {
it('emits a "selectRevision" event when a revision is selected', async () => {
const findGlDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstGlDropdownItem = () => findGlDropdownItems().at(0);
+ const branchName = 'some-branch';
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ branches: ['some-branch'] });
+ axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(HTTP_STATUS_OK, {
+ Branches: [branchName],
+ });
- await nextTick();
+ createComponent();
+ await waitForPromises();
findFirstGlDropdownItem().vm.$emit('click');
expect(wrapper.emitted()).toEqual({
- selectRevision: [[{ direction: 'from', revision: 'some-branch' }]],
+ selectRevision: [[{ direction: 'from', revision: branchName }]],
});
});
});
diff --git a/spec/frontend/projects/compare/components/revision_dropdown_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_spec.js
index db4a1158996..1cf99f16601 100644
--- a/spec/frontend/projects/compare/components/revision_dropdown_spec.js
+++ b/spec/frontend/projects/compare/components/revision_dropdown_spec.js
@@ -2,13 +2,14 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
-import { createAlert } from '~/flash';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue';
import { revisionDropdownDefaultProps as defaultProps } from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('RevisionDropdown component', () => {
let wrapper;
@@ -32,12 +33,15 @@ describe('RevisionDropdown component', () => {
});
afterEach(() => {
- wrapper.destroy();
axiosMock.restore();
});
const findGlDropdown = () => wrapper.findComponent(GlDropdown);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+ const findBranchesDropdownItem = () =>
+ wrapper.findAllComponents('[data-testid="branches-dropdown-item"]');
+ const findTagsDropdownItem = () =>
+ wrapper.findAllComponents('[data-testid="tags-dropdown-item"]');
it('sets hidden input', () => {
createComponent();
@@ -57,17 +61,29 @@ describe('RevisionDropdown component', () => {
createComponent();
- await axios.waitForAll();
- expect(wrapper.vm.branches).toEqual(Branches);
- expect(wrapper.vm.tags).toEqual(Tags);
+ expect(findBranchesDropdownItem()).toHaveLength(0);
+ expect(findTagsDropdownItem()).toHaveLength(0);
+
+ await waitForPromises();
+
+ expect(findBranchesDropdownItem()).toHaveLength(Branches.length);
+ expect(findTagsDropdownItem()).toHaveLength(Tags.length);
+
+ Branches.forEach((branch, index) => {
+ expect(findBranchesDropdownItem().at(index).text()).toBe(branch);
+ });
+
+ Tags.forEach((tag, index) => {
+ expect(findTagsDropdownItem().at(index).text()).toBe(tag);
+ });
});
- it('shows flash message on error', async () => {
+ it('shows an alert on error', async () => {
axiosMock.onGet('some/invalid/path').replyOnce(HTTP_STATUS_NOT_FOUND);
createComponent();
+ await waitForPromises();
- await wrapper.vm.fetchBranchesAndTags();
expect(createAlert).toHaveBeenCalled();
});
@@ -83,17 +99,17 @@ describe('RevisionDropdown component', () => {
refsProjectPath: newRefsProjectPath,
});
- await axios.waitForAll();
+ await waitForPromises();
expect(axios.get).toHaveBeenLastCalledWith(newRefsProjectPath);
});
describe('search', () => {
- it('shows flash message on error', async () => {
+ it('shows alert on error', async () => {
axiosMock.onGet('some/invalid/path').replyOnce(HTTP_STATUS_NOT_FOUND);
createComponent();
+ await waitForPromises();
- await wrapper.vm.searchBranchesAndTags();
expect(createAlert).toHaveBeenCalled();
});
@@ -108,7 +124,7 @@ describe('RevisionDropdown component', () => {
const mockSearchTerm = 'foobar';
createComponent();
findSearchBox().vm.$emit('input', mockSearchTerm);
- await axios.waitForAll();
+ await waitForPromises();
expect(axios.get).toHaveBeenCalledWith(
defaultProps.refsProjectPath,
@@ -141,8 +157,14 @@ describe('RevisionDropdown component', () => {
});
it('emits `selectRevision` event when another revision is selected', async () => {
+ jest.spyOn(axios, 'get').mockResolvedValue({
+ data: {
+ Branches: ['some-branch'],
+ Tags: [],
+ },
+ });
+
createComponent();
- wrapper.vm.branches = ['some-branch'];
await nextTick();
findGlDropdown().findAllComponents(GlDropdownItem).at(0).vm.$emit('click');
diff --git a/spec/frontend/projects/components/project_delete_button_spec.js b/spec/frontend/projects/components/project_delete_button_spec.js
index 49e3218e5bc..bae76e7eeb6 100644
--- a/spec/frontend/projects/components/project_delete_button_spec.js
+++ b/spec/frontend/projects/components/project_delete_button_spec.js
@@ -33,11 +33,6 @@ describe('Project remove modal', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('initialized', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/projects/components/shared/delete_button_spec.js b/spec/frontend/projects/components/shared/delete_button_spec.js
index 097b18025a3..6b4ef341b0c 100644
--- a/spec/frontend/projects/components/shared/delete_button_spec.js
+++ b/spec/frontend/projects/components/shared/delete_button_spec.js
@@ -45,11 +45,6 @@ describe('Project remove modal', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('intialized', () => {
beforeEach(() => {
createComponent();
@@ -74,7 +69,7 @@ describe('Project remove modal', () => {
});
it('the confirm button is disabled', () => {
- expect(findConfirmButton().attributes('disabled')).toBe('true');
+ expect(findConfirmButton().attributes('disabled')).toBeDefined();
});
});
diff --git a/spec/frontend/projects/details/upload_button_spec.js b/spec/frontend/projects/details/upload_button_spec.js
index 50638755260..e9b11ce544a 100644
--- a/spec/frontend/projects/details/upload_button_spec.js
+++ b/spec/frontend/projects/details/upload_button_spec.js
@@ -27,10 +27,6 @@ describe('UploadButton', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays an upload button', () => {
expect(wrapper.findComponent(GlButton).exists()).toBe(true);
});
diff --git a/spec/frontend/projects/new/components/app_spec.js b/spec/frontend/projects/new/components/app_spec.js
index f6edbab3cca..60d8385eb91 100644
--- a/spec/frontend/projects/new/components/app_spec.js
+++ b/spec/frontend/projects/new/components/app_spec.js
@@ -6,12 +6,12 @@ describe('Experimental new project creation app', () => {
let wrapper;
const createComponent = (propsData) => {
- wrapper = shallowMount(App, { propsData });
+ wrapper = shallowMount(App, {
+ propsData: { rootPath: '/', projectsUrl: '/dashboard/projects', ...propsData },
+ });
};
- afterEach(() => {
- wrapper.destroy();
- });
+ const findNewNamespacePage = () => wrapper.findComponent(NewNamespacePage);
it('passes custom new project guideline text to underlying component', () => {
const DEMO_GUIDELINES = 'Demo guidelines';
@@ -34,11 +34,45 @@ describe('Experimental new project creation app', () => {
expect(
Boolean(
- wrapper
- .findComponent(NewNamespacePage)
+ findNewNamespacePage()
.props()
.panels.find((p) => p.name === 'cicd_for_external_repo'),
),
).toBe(isCiCdAvailable);
});
+
+ it.each`
+ canImportProjects | outcome
+ ${false} | ${'do not show Import panel'}
+ ${true} | ${'show Import panel'}
+ `('$outcome when canImportProjects is $canImportProjects', ({ canImportProjects }) => {
+ createComponent({
+ canImportProjects,
+ });
+
+ expect(
+ findNewNamespacePage()
+ .props()
+ .panels.some((p) => p.name === 'import_project'),
+ ).toBe(canImportProjects);
+ });
+
+ it('creates correct breadcrumbs for top-level projects', () => {
+ createComponent();
+
+ expect(findNewNamespacePage().props('initialBreadcrumbs')).toEqual([
+ { href: '/', text: 'Your work' },
+ { href: '/dashboard/projects', text: 'Projects' },
+ { href: '#', text: 'New project' },
+ ]);
+ });
+
+ it('creates correct breadcrumbs for projects within groups', () => {
+ createComponent({ parentGroupUrl: '/parent-group', parentGroupName: 'Parent Group' });
+
+ expect(findNewNamespacePage().props('initialBreadcrumbs')).toEqual([
+ { href: '/parent-group', text: 'Parent Group' },
+ { href: '#', text: 'New project' },
+ ]);
+ });
});
diff --git a/spec/frontend/projects/new/components/deployment_target_select_spec.js b/spec/frontend/projects/new/components/deployment_target_select_spec.js
index f3b22d4a1b9..57b804b632a 100644
--- a/spec/frontend/projects/new/components/deployment_target_select_spec.js
+++ b/spec/frontend/projects/new/components/deployment_target_select_spec.js
@@ -47,7 +47,6 @@ describe('Deployment target select', () => {
});
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
@@ -57,7 +56,7 @@ describe('Deployment target select', () => {
it('renders a select with the disabled default option', () => {
expect(findSelect().find('option').text()).toBe('Select the deployment target');
- expect(findSelect().find('option').attributes('disabled')).toBe('disabled');
+ expect(findSelect().find('option').attributes('disabled')).toBeDefined();
});
describe.each`
diff --git a/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js
index 16b4493c622..1a43dcb682b 100644
--- a/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js
+++ b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js
@@ -37,7 +37,6 @@ describe('New project push tip popover', () => {
});
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js
index 67532cea61e..ceac4435282 100644
--- a/spec/frontend/projects/new/components/new_project_url_select_spec.js
+++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js
@@ -3,8 +3,8 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
- GlSearchBoxByType,
GlTruncate,
+ GlSearchBoxByType,
} from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
@@ -12,6 +12,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { stubComponent } from 'helpers/stub_component';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/projects/new/event_hub';
import NewProjectUrlSelect from '~/projects/new/components/new_project_url_select.vue';
@@ -68,6 +69,7 @@ describe('NewProjectUrlSelect component', () => {
};
let mockQueryResponse;
+ let focusInputSpy;
const mountComponent = ({
search = '',
@@ -78,6 +80,7 @@ describe('NewProjectUrlSelect component', () => {
mockQueryResponse = jest.fn().mockResolvedValue({ data: queryResponse });
const requestHandlers = [[searchQuery, mockQueryResponse]];
const apolloProvider = createMockApollo(requestHandlers);
+ focusInputSpy = jest.fn();
return mountFn(NewProjectUrlSelect, {
apolloProvider,
@@ -87,13 +90,17 @@ describe('NewProjectUrlSelect component', () => {
search,
};
},
+ stubs: {
+ GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
+ methods: { focusInput: focusInputSpy },
+ }),
+ },
});
};
const findButtonLabel = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findSelectedPath = () => wrapper.findComponent(GlTruncate);
- const findInput = () => wrapper.findComponent(GlSearchBoxByType);
const findHiddenNamespaceInput = () => wrapper.find(`[name="${defaultProvide.inputName}`);
const findHiddenSelectedNamespaceInput = () =>
@@ -111,10 +118,6 @@ describe('NewProjectUrlSelect component', () => {
await waitForPromises();
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the root url as a label', () => {
wrapper = mountComponent();
@@ -177,13 +180,11 @@ describe('NewProjectUrlSelect component', () => {
});
it('focuses on the input when the dropdown is opened', async () => {
- wrapper = mountComponent({ mountFn: mount });
-
- const spy = jest.spyOn(findInput().vm, 'focusInput');
+ wrapper = mountComponent();
await showDropdown();
- expect(spy).toHaveBeenCalledTimes(1);
+ expect(focusInputSpy).toHaveBeenCalledTimes(1);
});
it('renders expected dropdown items', async () => {
@@ -246,7 +247,7 @@ describe('NewProjectUrlSelect component', () => {
eventHub.$emit('select-template', getIdFromGraphQLId(id), fullPath);
});
- it('filters the dropdown items to the selected group and children', async () => {
+ it('filters the dropdown items to the selected group and children', () => {
const listItems = wrapper.findAll('li');
expect(listItems).toHaveLength(3);
diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap
index fc51825f15b..61bcd44724c 100644
--- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap
+++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap
@@ -8,20 +8,20 @@ exports[`CiCdAnalyticsAreaChart matches the snapshot 1`] = `
Some title
</p>
- <div>
- <glareachart-stub
- annotations=""
- data="[object Object],[object Object]"
- height="300"
- legendaveragetext="Avg"
- legendcurrenttext="Current"
- legendlayout="inline"
- legendmaxtext="Max"
- legendmintext="Min"
- option="[object Object]"
- thresholds=""
- width="0"
- />
- </div>
+ <glareachart-stub
+ annotations=""
+ data="[object Object],[object Object]"
+ height="300"
+ legendaveragetext="Avg"
+ legendcurrenttext="Current"
+ legendlayout="inline"
+ legendmaxtext="Max"
+ legendmintext="Min"
+ legendseriesinfo=""
+ option="[object Object]"
+ responsive=""
+ thresholds=""
+ width="auto"
+ />
</div>
`;
diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js
index d8876349c5e..94f421239da 100644
--- a/spec/frontend/projects/pipelines/charts/components/app_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js
@@ -49,10 +49,6 @@ describe('ProjectsPipelinesChartsApp', () => {
);
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlTabs = () => wrapper.findComponent(GlTabs);
const findAllGlTabs = () => wrapper.findAllComponents(GlTab);
const findGlTabAtIndex = (index) => findAllGlTabs().at(index);
diff --git a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_area_chart_spec.js b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_area_chart_spec.js
index 2b523467379..5fc121b5c9f 100644
--- a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_area_chart_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_area_chart_spec.js
@@ -28,11 +28,6 @@ describe('CiCdAnalyticsAreaChart', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js
index cf28eda5349..38760a724ff 100644
--- a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js
@@ -47,13 +47,6 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', (
},
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findMetricsSlot = () => wrapper.findByTestId('metrics-slot');
const findSegmentedControl = () => wrapper.findComponent(SegmentedControlButtonGroup);
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 8fb59f38ee1..ab2a12219e5 100644
--- a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js
@@ -37,11 +37,6 @@ describe('~/projects/pipelines/charts/components/pipeline_charts.vue', () => {
await waitForPromises();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('overall statistics', () => {
it('displays the statistics list', () => {
const list = wrapper.findComponent(StatisticsList);
diff --git a/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js b/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js
index 57a864cb2c4..24dbc628ce6 100644
--- a/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js
@@ -21,10 +21,6 @@ describe('StatisticsList', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays the counts data with labels', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/projects/prune_unreachable_objects_button_spec.js b/spec/frontend/projects/prune_unreachable_objects_button_spec.js
index b345f264ca7..012b19ea3d3 100644
--- a/spec/frontend/projects/prune_unreachable_objects_button_spec.js
+++ b/spec/frontend/projects/prune_unreachable_objects_button_spec.js
@@ -22,16 +22,11 @@ describe('Project remove modal', () => {
wrapper = shallowMountExtended(PruneObjectsButton, {
propsData: defaultProps,
directives: {
- GlModal: createMockDirective(),
+ GlModal: createMockDirective('gl-modal'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('intialized', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/projects/settings/branch_rules/components/edit/branch_dropdown_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/branch_dropdown_spec.js
index 11f219c1f90..6d3317a5f78 100644
--- a/spec/frontend/projects/settings/branch_rules/components/edit/branch_dropdown_spec.js
+++ b/spec/frontend/projects/settings/branch_rules/components/edit/branch_dropdown_spec.js
@@ -8,10 +8,10 @@ import BranchDropdown, {
import createMockApollo from 'helpers/mock_apollo_helper';
import branchesQuery from '~/projects/settings/branch_rules/queries/branches.query.graphql';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Branch dropdown', () => {
let wrapper;
@@ -46,10 +46,6 @@ describe('Branch dropdown', () => {
beforeEach(() => createComponent());
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a GlDropdown component with the correct props', () => {
expect(findGlDropdown().props()).toMatchObject({ text: value });
});
diff --git a/spec/frontend/projects/settings/branch_rules/components/edit/index_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/index_spec.js
index 21e63fdb24d..e9982872e03 100644
--- a/spec/frontend/projects/settings/branch_rules/components/edit/index_spec.js
+++ b/spec/frontend/projects/settings/branch_rules/components/edit/index_spec.js
@@ -24,10 +24,6 @@ describe('Edit branch rule', () => {
beforeEach(() => createComponent());
- afterEach(() => {
- wrapper.destroy();
- });
-
it('gets the branch param from url', () => {
expect(getParameterByName).toHaveBeenCalledWith('branch');
});
diff --git a/spec/frontend/projects/settings/branch_rules/components/edit/protections/index_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/protections/index_spec.js
index ee90ff8318f..14edaf31a1f 100644
--- a/spec/frontend/projects/settings/branch_rules/components/edit/protections/index_spec.js
+++ b/spec/frontend/projects/settings/branch_rules/components/edit/protections/index_spec.js
@@ -26,10 +26,6 @@ describe('Branch Protections', () => {
beforeEach(() => createComponent());
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a heading', () => {
expect(findHeading().text()).toBe(i18n.protections);
});
diff --git a/spec/frontend/projects/settings/branch_rules/components/edit/protections/merge_protections_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/protections/merge_protections_spec.js
index b5fdc46d600..ca561ef87ec 100644
--- a/spec/frontend/projects/settings/branch_rules/components/edit/protections/merge_protections_spec.js
+++ b/spec/frontend/projects/settings/branch_rules/components/edit/protections/merge_protections_spec.js
@@ -24,10 +24,6 @@ describe('Merge Protections', () => {
beforeEach(() => createComponent());
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a form group with the correct label', () => {
expect(findFormGroup().text()).toContain(i18n.allowedToMerge);
});
diff --git a/spec/frontend/projects/settings/branch_rules/components/edit/protections/push_protections_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/protections/push_protections_spec.js
index 60bb7a51dcb..82998640f17 100644
--- a/spec/frontend/projects/settings/branch_rules/components/edit/protections/push_protections_spec.js
+++ b/spec/frontend/projects/settings/branch_rules/components/edit/protections/push_protections_spec.js
@@ -24,10 +24,6 @@ describe('Push Protections', () => {
beforeEach(() => createComponent());
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a form group with the correct label', () => {
expect(findFormGroup().attributes('label')).toBe(i18n.allowedToPush);
});
diff --git a/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js
index 714e0df596e..077995ab6e4 100644
--- a/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js
+++ b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js
@@ -9,6 +9,10 @@ import Protection from '~/projects/settings/branch_rules/components/view/protect
import {
I18N,
ALL_BRANCHES_WILDCARD,
+ REQUIRED_ICON,
+ NOT_REQUIRED_ICON,
+ REQUIRED_ICON_CLASS,
+ NOT_REQUIRED_ICON_CLASS,
} from '~/projects/settings/branch_rules/components/view/constants';
import branchRulesQuery from 'ee_else_ce/projects/settings/branch_rules/queries/branch_rules_details.query.graphql';
import { sprintf } from '~/locale';
@@ -19,7 +23,7 @@ import {
jest.mock('~/lib/utils/url_utility', () => ({
getParameterByName: jest.fn().mockReturnValue('main'),
- mergeUrlParams: jest.fn().mockReturnValue('/branches?state=all&search=main'),
+ mergeUrlParams: jest.fn().mockReturnValue('/branches?state=all&search=%5Emain%24'),
joinPaths: jest.fn(),
}));
@@ -39,12 +43,13 @@ describe('View branch rules', () => {
let fakeApollo;
const projectPath = 'test/testing';
const protectedBranchesPath = 'protected/branches';
- const branchProtectionsMockRequestHandler = jest
- .fn()
- .mockResolvedValue(branchProtectionsMockResponse);
+ const branchProtectionsMockRequestHandler = (response = branchProtectionsMockResponse) =>
+ jest.fn().mockResolvedValue(response);
- const createComponent = async () => {
- fakeApollo = createMockApollo([[branchRulesQuery, branchProtectionsMockRequestHandler]]);
+ const createComponent = async (mockResponse) => {
+ fakeApollo = createMockApollo([
+ [branchRulesQuery, branchProtectionsMockRequestHandler(mockResponse)],
+ ]);
wrapper = shallowMountExtended(RuleView, {
apolloProvider: fakeApollo,
@@ -57,13 +62,13 @@ describe('View branch rules', () => {
beforeEach(() => createComponent());
- afterEach(() => wrapper.destroy());
-
const findBranchName = () => wrapper.findByTestId('branch');
const findBranchTitle = () => wrapper.findByTestId('branch-title');
const findBranchProtectionTitle = () => wrapper.findByText(I18N.protectBranchTitle);
const findBranchProtections = () => wrapper.findAllComponents(Protection);
- const findForcePushTitle = () => wrapper.findByText(I18N.allowForcePushDescription);
+ const findForcePushIcon = () => wrapper.findByTestId('force-push-icon');
+ const findForcePushTitle = (title) => wrapper.findByText(title);
+ const findForcePushDescription = () => wrapper.findByText(I18N.forcePushDescription);
const findApprovalsTitle = () => wrapper.findByText(I18N.approvalsTitle);
const findStatusChecksTitle = () => wrapper.findByText(I18N.statusChecksTitle);
const findMatchingBranchesLink = () =>
@@ -94,9 +99,12 @@ describe('View branch rules', () => {
});
it('renders matching branches link', () => {
+ const mergeUrlParams = jest.spyOn(util, 'mergeUrlParams');
const matchingBranchesLink = findMatchingBranchesLink();
+
+ expect(mergeUrlParams).toHaveBeenCalledWith({ state: 'all', search: `^main$` }, '');
expect(matchingBranchesLink.exists()).toBe(true);
- expect(matchingBranchesLink.attributes().href).toBe('/branches?state=all&search=main');
+ expect(matchingBranchesLink.attributes().href).toBe('/branches?state=all&search=%5Emain%24');
});
it('renders a branch protection title', () => {
@@ -123,9 +131,23 @@ describe('View branch rules', () => {
});
});
- it('renders force push protection', () => {
- expect(findForcePushTitle().exists()).toBe(true);
- });
+ it.each`
+ allowForcePush | iconName | iconClass | title
+ ${true} | ${REQUIRED_ICON} | ${REQUIRED_ICON_CLASS} | ${I18N.allowForcePushTitle}
+ ${false} | ${NOT_REQUIRED_ICON} | ${NOT_REQUIRED_ICON_CLASS} | ${I18N.doesNotAllowForcePushTitle}
+ `(
+ 'renders force push section with the correct icon, title and description',
+ async ({ allowForcePush, iconName, iconClass, title }) => {
+ const mockResponse = branchProtectionsMockResponse;
+ mockResponse.data.project.branchRules.nodes[0].branchProtection.allowForcePush = allowForcePush;
+ await createComponent(mockResponse);
+
+ expect(findForcePushIcon().props('name')).toBe(iconName);
+ expect(findForcePushIcon().attributes('class')).toBe(iconClass);
+ expect(findForcePushTitle(title).exists()).toBe(true);
+ expect(findForcePushDescription().exists()).toBe(true);
+ },
+ );
it('renders a branch protection component for merge rules', () => {
expect(findBranchProtections().at(1).props()).toMatchObject({
diff --git a/spec/frontend/projects/settings/branch_rules/components/view/protection_row_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/protection_row_spec.js
index a98b156f94e..1bfd04e10a1 100644
--- a/spec/frontend/projects/settings/branch_rules/components/view/protection_row_spec.js
+++ b/spec/frontend/projects/settings/branch_rules/components/view/protection_row_spec.js
@@ -18,8 +18,6 @@ describe('Branch rule protection row', () => {
beforeEach(() => createComponent());
- afterEach(() => wrapper.destroy());
-
const findTitle = () => wrapper.findByText(protectionRowPropsMock.title);
const findAvatarsInline = () => wrapper.findComponent(GlAvatarsInline);
const findAvatarLinks = () => wrapper.findAllComponents(GlAvatarLink);
diff --git a/spec/frontend/projects/settings/branch_rules/components/view/protection_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/protection_spec.js
index caf967b4257..f10d8d6d770 100644
--- a/spec/frontend/projects/settings/branch_rules/components/view/protection_spec.js
+++ b/spec/frontend/projects/settings/branch_rules/components/view/protection_spec.js
@@ -16,8 +16,6 @@ describe('Branch rule protection', () => {
beforeEach(() => createComponent());
- afterEach(() => wrapper.destroy());
-
const findCard = () => wrapper.findComponent(GlCard);
const findHeader = () => wrapper.findByText(protectionPropsMock.header);
const findLink = () => wrapper.findComponent(GlLink);
diff --git a/spec/frontend/projects/settings/components/default_branch_selector_spec.js b/spec/frontend/projects/settings/components/default_branch_selector_spec.js
index ca9a72663d2..9baea5c5517 100644
--- a/spec/frontend/projects/settings/components/default_branch_selector_spec.js
+++ b/spec/frontend/projects/settings/components/default_branch_selector_spec.js
@@ -19,10 +19,6 @@ describe('projects/settings/components/default_branch_selector', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
beforeEach(() => {
buildWrapper();
});
@@ -32,7 +28,6 @@ describe('projects/settings/components/default_branch_selector', () => {
value: persistedDefaultBranch,
enabledRefTypes: [REF_TYPE_BRANCHES],
projectId,
- refType: null,
state: true,
toggleButtonClass: null,
translations: {
diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
index 26297d0c3ff..f3e536de703 100644
--- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
+++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
@@ -89,15 +89,12 @@ describe('Access Level Dropdown', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownToggleLabel = () => findDropdown().props('text');
const findAllDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findAllDropdownHeaders = () => findDropdown().findAllComponents(GlDropdownSectionHeader);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+ const findDeployKeyDropdownItem = () => wrapper.findByTestId('deploy_key-dropdown-item');
const findDropdownItemWithText = (items, text) =>
items.filter((item) => item.text().includes(text)).at(0);
@@ -142,6 +139,21 @@ describe('Access Level Dropdown', () => {
it('renders dropdown item for each access level type', () => {
expect(findAllDropdownItems()).toHaveLength(12);
});
+
+ it.each`
+ accessLevel | shouldRenderDeployKeyItems
+ ${ACCESS_LEVELS.PUSH} | ${true}
+ ${ACCESS_LEVELS.CREATE} | ${true}
+ ${ACCESS_LEVELS.MERGE} | ${false}
+ `(
+ 'conditionally renders deploy keys based on access levels',
+ async ({ accessLevel, shouldRenderDeployKeyItems }) => {
+ createComponent({ accessLevel });
+ await waitForPromises();
+
+ expect(findDeployKeyDropdownItem().exists()).toBe(shouldRenderDeployKeyItems);
+ },
+ );
});
describe('toggleLabel', () => {
diff --git a/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js b/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js
index f82ad80135e..f28bc13895e 100644
--- a/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js
+++ b/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js
@@ -1,7 +1,7 @@
-import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlToggle } from '@gitlab/ui';
import MockAxiosAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK, HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
@@ -9,14 +9,14 @@ import SharedRunnersToggleComponent from '~/projects/settings/components/shared_
const TEST_UPDATE_PATH = '/test/update_shared_runners';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('projects/settings/components/shared_runners', () => {
let wrapper;
let mockAxios;
const createComponent = (props = {}) => {
- wrapper = shallowMount(SharedRunnersToggleComponent, {
+ wrapper = shallowMountExtended(SharedRunnersToggleComponent, {
propsData: {
isEnabled: false,
isDisabledAndUnoverridable: false,
@@ -28,9 +28,9 @@ describe('projects/settings/components/shared_runners', () => {
});
};
- const findErrorAlert = () => wrapper.findComponent(GlAlert);
+ const findErrorAlert = () => wrapper.findByTestId('error-alert');
+ const findUnoverridableAlert = () => wrapper.findByTestId('unoverridable-alert');
const findSharedRunnersToggle = () => wrapper.findComponent(GlToggle);
- const findToggleTooltip = () => wrapper.findComponent(GlTooltip);
const getToggleValue = () => findSharedRunnersToggle().props('value');
const isToggleLoading = () => findSharedRunnersToggle().props('isLoading');
const isToggleDisabled = () => findSharedRunnersToggle().props('disabled');
@@ -41,8 +41,6 @@ describe('projects/settings/components/shared_runners', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
mockAxios.restore();
});
@@ -57,8 +55,8 @@ describe('projects/settings/components/shared_runners', () => {
expect(isToggleDisabled()).toBe(true);
});
- it('tooltip should exist explaining why the toggle is disabled', () => {
- expect(findToggleTooltip().exists()).toBe(true);
+ it('alert should exist explaining why the toggle is disabled', () => {
+ expect(findUnoverridableAlert().exists()).toBe(true);
});
});
@@ -74,7 +72,7 @@ describe('projects/settings/components/shared_runners', () => {
it('loading icon, error message, and tooltip should not exist', () => {
expect(isToggleLoading()).toBe(false);
expect(findErrorAlert().exists()).toBe(false);
- expect(findToggleTooltip().exists()).toBe(false);
+ expect(findUnoverridableAlert().exists()).toBe(false);
});
describe('with shared runners DISABLED', () => {
diff --git a/spec/frontend/projects/settings/components/transfer_project_form_spec.js b/spec/frontend/projects/settings/components/transfer_project_form_spec.js
index e091f3e25c3..e12938c3bab 100644
--- a/spec/frontend/projects/settings/components/transfer_project_form_spec.js
+++ b/spec/frontend/projects/settings/components/transfer_project_form_spec.js
@@ -31,10 +31,6 @@ describe('Transfer project form', () => {
const findTransferLocations = () => wrapper.findComponent(TransferLocations);
const findConfirmDanger = () => wrapper.findComponent(ConfirmDanger);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the namespace selector and passes `groupTransferLocationsApiMethod` prop', () => {
createComponent();
@@ -53,7 +49,7 @@ describe('Transfer project form', () => {
it('disables the confirm button by default', () => {
createComponent();
- expect(findConfirmDanger().attributes('disabled')).toBe('true');
+ expect(findConfirmDanger().attributes('disabled')).toBeDefined();
});
describe('with a selected namespace', () => {
@@ -68,17 +64,17 @@ describe('Transfer project form', () => {
expect(findTransferLocations().props('value')).toEqual(selectedItem);
});
- it('emits the `selectTransferLocation` event when a namespace is selected', async () => {
+ it('emits the `selectTransferLocation` event when a namespace is selected', () => {
const args = [selectedItem.id];
expect(wrapper.emitted('selectTransferLocation')).toEqual([args]);
});
- it('enables the confirm button', async () => {
+ it('enables the confirm button', () => {
expect(findConfirmDanger().attributes('disabled')).toBeUndefined();
});
- it('clicking the confirm button emits the `confirm` event', async () => {
+ it('clicking the confirm button emits the `confirm` event', () => {
findConfirmDanger().vm.$emit('confirm');
expect(wrapper.emitted('confirm')).toBeDefined();
diff --git a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js
index 56b39f04580..dd534bec25d 100644
--- a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js
+++ b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js
@@ -7,7 +7,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import BranchRules from '~/projects/settings/repository/branch_rules/app.vue';
import BranchRule from '~/projects/settings/repository/branch_rules/components/branch_rule.vue';
import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
branchRulesMockResponse,
appProvideMock,
@@ -22,7 +22,7 @@ import { expandSection } from '~/settings_panels';
import { scrollToElement } from '~/lib/utils/common_utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/settings_panels');
jest.mock('~/lib/utils/common_utils');
@@ -41,7 +41,7 @@ describe('Branch rules app', () => {
apolloProvider: fakeApollo,
provide: appProvideMock,
stubs: { GlModal: stubComponent(GlModal, { template: RENDER_ALL_SLOTS_TEMPLATE }) },
- directives: { GlModal: createMockDirective() },
+ directives: { GlModal: createMockDirective('gl-modal') },
});
await waitForPromises();
diff --git a/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js b/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js
index 8d0fd390e35..8bea84f4429 100644
--- a/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js
+++ b/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js
@@ -71,8 +71,10 @@ describe('Branch rule', () => {
});
it('renders a detail button with the correct href', () => {
+ const encodedBranchName = encodeURIComponent(branchRulePropsMock.name);
+
expect(findDetailsButton().attributes('href')).toBe(
- `${branchRuleProvideMock.branchRulesPath}?branch=${branchRulePropsMock.name}`,
+ `${branchRuleProvideMock.branchRulesPath}?branch=${encodedBranchName}`,
);
});
});
diff --git a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js
index de7f6c8b88d..d169397241d 100644
--- a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js
+++ b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js
@@ -74,7 +74,7 @@ export const branchRuleProvideMock = {
};
export const branchRulePropsMock = {
- name: 'main',
+ name: 'branch-with-$speci@l-#-chars',
isDefault: true,
matchingBranchesCount: 1,
branchProtection: {
diff --git a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js
index 8b8e7d1454d..b2c03352cdc 100644
--- a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js
+++ b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js
@@ -64,7 +64,6 @@ describe('TopicsTokenSelector', () => {
});
afterEach(() => {
- wrapper.destroy();
div.remove();
input.remove();
});
@@ -84,7 +83,7 @@ describe('TopicsTokenSelector', () => {
});
});
- it('passes topic title to the avatar', async () => {
+ it('passes topic title to the avatar', () => {
createComponent();
const avatars = findAllAvatars();
diff --git a/spec/frontend/projects/settings/utils_spec.js b/spec/frontend/projects/settings/utils_spec.js
index 319aa4000b5..d85f43778b1 100644
--- a/spec/frontend/projects/settings/utils_spec.js
+++ b/spec/frontend/projects/settings/utils_spec.js
@@ -1,4 +1,5 @@
-import { getAccessLevels } from '~/projects/settings/utils';
+import { getAccessLevels, generateRefDestinationPath } from '~/projects/settings/utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { pushAccessLevelsMockResponse, pushAccessLevelsMockResult } from './mock_data';
describe('Utils', () => {
@@ -8,4 +9,25 @@ describe('Utils', () => {
expect(pushAccessLevels).toEqual(pushAccessLevelsMockResult);
});
});
+
+ describe('generateRefDestinationPath', () => {
+ const projectRootPath = 'http://test.host/root/Project1';
+ const settingsCi = '-/settings/ci_cd';
+
+ it.each`
+ currentPath | selectedRef | result
+ ${`${projectRootPath}`} | ${undefined} | ${`${projectRootPath}`}
+ ${`${projectRootPath}`} | ${'test'} | ${`${projectRootPath}`}
+ ${`${projectRootPath}/${settingsCi}`} | ${'test'} | ${`${projectRootPath}/${settingsCi}?ref=test`}
+ ${`${projectRootPath}/${settingsCi}`} | ${'branch-hyphen'} | ${`${projectRootPath}/${settingsCi}?ref=branch-hyphen`}
+ ${`${projectRootPath}/${settingsCi}`} | ${'test/branch'} | ${`${projectRootPath}/${settingsCi}?ref=test%2Fbranch`}
+ ${`${projectRootPath}/${settingsCi}`} | ${'test/branch-hyphen'} | ${`${projectRootPath}/${settingsCi}?ref=test%2Fbranch-hyphen`}
+ `(
+ 'generates the correct destination path for the `$selectedRef` ref and current url $currentPath by outputting $result',
+ ({ currentPath, selectedRef, result }) => {
+ setWindowLocation(currentPath);
+ expect(generateRefDestinationPath(selectedRef)).toBe(result);
+ },
+ );
+ });
});
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
index 5fc9f9ba629..86e4e88e3cf 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
@@ -41,7 +41,6 @@ describe('ServiceDeskRoot', () => {
afterEach(() => {
axiosMock.restore();
- wrapper.destroy();
if (spy) {
spy.mockRestore();
}
@@ -79,7 +78,7 @@ describe('ServiceDeskRoot', () => {
const alertBodyLink = alertEl.findComponent(GlLink);
expect(alertBodyLink.exists()).toBe(true);
expect(alertBodyLink.attributes('href')).toBe(
- '/help/user/project/service_desk.html#using-a-custom-email-address',
+ '/help/user/project/service_desk.html#use-a-custom-email-address',
);
expect(alertBodyLink.text()).toBe('How do I create a custom email address?');
});
@@ -148,7 +147,7 @@ describe('ServiceDeskRoot', () => {
await waitForPromises();
});
- it('sends a request to update template', async () => {
+ it('sends a request to update template', () => {
expect(spy).toHaveBeenCalledWith(provideData.endpoint, {
issue_template_key: 'Bug',
outgoing_name: 'GitLab Support Bot',
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 f9762491507..84eafc3d0f3 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
@@ -27,12 +27,6 @@ describe('ServiceDeskSetting', () => {
}),
);
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('when isEnabled=true', () => {
describe('only isEnabled', () => {
describe('as project admin', () => {
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js
index 6adcfbe8157..7090db5cad7 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js
@@ -19,12 +19,6 @@ describe('ServiceDeskTemplateDropdown', () => {
}),
);
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('templates dropdown', () => {
it('renders a dropdown to choose a template', () => {
wrapper = createComponent();
diff --git a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js
index 6576ce70d60..1d0faebbcb2 100644
--- a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js
+++ b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js
@@ -41,10 +41,6 @@ describe('TerraformNotificationBanner', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when user has already dismissed the banner', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
index 3852f2678b7..706f932aa8d 100644
--- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js
+++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import prometheusIntegration from 'test_fixtures/integrations/prometheus/prometheus_integration.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import PANEL_STATE from '~/prometheus_metrics/constants';
@@ -7,7 +8,6 @@ import CustomMetrics from '~/prometheus_metrics/custom_metrics';
import { metrics1 as metrics } from './mock_data';
describe('PrometheusMetrics', () => {
- const FIXTURE = 'integrations/prometheus/prometheus_integration.html';
const customMetricsEndpoint =
'http://test.host/frontend-fixtures/integrations-project/prometheus/metrics';
let mock;
@@ -17,7 +17,7 @@ describe('PrometheusMetrics', () => {
mock.onGet(customMetricsEndpoint).reply(HTTP_STATUS_OK, {
metrics,
});
- loadHTMLFixture(FIXTURE);
+ setHTMLFixture(prometheusIntegration);
});
afterEach(() => {
diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
index 45654d6a2eb..64cf69b7f5b 100644
--- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
+++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import prometheusIntegration from 'test_fixtures/integrations/prometheus/prometheus_integration.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
@@ -8,10 +9,8 @@ import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
import { metrics2 as metrics, missingVarMetrics } from './mock_data';
describe('PrometheusMetrics', () => {
- const FIXTURE = 'integrations/prometheus/prometheus_integration.html';
-
beforeEach(() => {
- loadHTMLFixture(FIXTURE);
+ setHTMLFixture(prometheusIntegration);
});
describe('constructor', () => {
diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js
index b4029d94980..e1966908452 100644
--- a/spec/frontend/protected_branches/protected_branch_edit_spec.js
+++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js
@@ -2,12 +2,12 @@ import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import ProtectedBranchEdit from '~/protected_branches/protected_branch_edit';
-jest.mock('~/flash');
+jest.mock('~/alert');
const TEST_URL = `${TEST_HOST}/url`;
const FORCE_PUSH_TOGGLE_TESTID = 'force-push-toggle';
@@ -115,7 +115,7 @@ describe('ProtectedBranchEdit', () => {
});
describe('when clicked', () => {
- beforeEach(async () => {
+ beforeEach(() => {
mock
.onPatch(TEST_URL, { protected_branch: { [patchParam]: true } })
.replyOnce(HTTP_STATUS_OK, {});
@@ -149,7 +149,7 @@ describe('ProtectedBranchEdit', () => {
toggle.click();
});
- it('flashes error', async () => {
+ it('alerts error', async () => {
await axios.waitForAll();
expect(createAlert).toHaveBeenCalled();
diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js
index 9eddc50d50a..5f7bd32e231 100644
--- a/spec/frontend/read_more_spec.js
+++ b/spec/frontend/read_more_spec.js
@@ -1,9 +1,8 @@
-import { loadHTMLFixture, resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
+import htmlProjectsOverview from 'test_fixtures/projects/overview.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initReadMore from '~/read_more';
describe('Read more click-to-expand functionality', () => {
- const fixtureName = 'projects/overview.html';
-
const findTrigger = () => document.querySelector('.js-read-more-trigger');
afterEach(() => {
@@ -12,7 +11,7 @@ describe('Read more click-to-expand functionality', () => {
describe('expands target element', () => {
beforeEach(() => {
- loadHTMLFixture(fixtureName);
+ setHTMLFixture(htmlProjectsOverview);
});
it('adds "is-expanded" class to target element', () => {
@@ -42,7 +41,7 @@ describe('Read more click-to-expand functionality', () => {
nestedElement.click();
});
- it('removes the trigger element', async () => {
+ it('removes the trigger element', () => {
expect(findTrigger()).toBe(null);
});
});
diff --git a/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap b/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap
deleted file mode 100644
index 5053778369e..00000000000
--- a/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap
+++ /dev/null
@@ -1,80 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Ref selector component footer slot passes the expected slot props 1`] = `
-Object {
- "isLoading": false,
- "matches": Object {
- "branches": Object {
- "error": null,
- "list": Array [
- Object {
- "default": false,
- "name": "add_images_and_changes",
- "value": undefined,
- },
- Object {
- "default": false,
- "name": "conflict-contains-conflict-markers",
- "value": undefined,
- },
- Object {
- "default": false,
- "name": "deleted-image-test",
- "value": undefined,
- },
- Object {
- "default": false,
- "name": "diff-files-image-to-symlink",
- "value": undefined,
- },
- Object {
- "default": false,
- "name": "diff-files-symlink-to-image",
- "value": undefined,
- },
- Object {
- "default": false,
- "name": "markdown",
- "value": undefined,
- },
- Object {
- "default": true,
- "name": "master",
- "value": undefined,
- },
- ],
- "totalCount": 123,
- },
- "commits": Object {
- "error": null,
- "list": Array [
- Object {
- "name": "b83d6e39",
- "subtitle": "Merge branch 'branch-merged' into 'master'",
- "value": "b83d6e391c22777fca1ed3012fce84f633d7fed0",
- },
- ],
- "totalCount": 1,
- },
- "tags": Object {
- "error": null,
- "list": Array [
- Object {
- "name": "v1.1.1",
- "value": undefined,
- },
- Object {
- "name": "v1.1.0",
- "value": undefined,
- },
- Object {
- "name": "v1.0.0",
- "value": undefined,
- },
- ],
- "totalCount": 456,
- },
- },
- "query": "abcd1234",
-}
-`;
diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js
index 40d3a291074..290cde29866 100644
--- a/spec/frontend/ref/components/ref_selector_spec.js
+++ b/spec/frontend/ref/components/ref_selector_spec.js
@@ -4,9 +4,9 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { merge, last } from 'lodash';
import Vuex from 'vuex';
+import tags from 'test_fixtures/api/tags/tags.json';
import commit from 'test_fixtures/api/commits/commit.json';
import branches from 'test_fixtures/api/branches/branches.json';
-import tags from 'test_fixtures/api/tags/tags.json';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
import {
@@ -22,8 +22,6 @@ import {
REF_TYPE_BRANCHES,
REF_TYPE_TAGS,
REF_TYPE_COMMITS,
- BRANCH_REF_TYPE,
- TAG_REF_TYPE,
} from '~/ref/constants';
import createStore from '~/ref/stores/';
@@ -33,6 +31,9 @@ describe('Ref selector component', () => {
const fixtures = { branches, tags, commit };
const projectId = '8';
+ const totalBranchesCount = 123;
+ const totalTagsCount = 456;
+ const queryParams = { sort: 'updated_desc' };
let wrapper;
let branchesApiCallSpy;
@@ -69,10 +70,14 @@ describe('Ref selector component', () => {
branchesApiCallSpy = jest
.fn()
- .mockReturnValue([HTTP_STATUS_OK, fixtures.branches, { [X_TOTAL_HEADER]: '123' }]);
+ .mockReturnValue([
+ HTTP_STATUS_OK,
+ fixtures.branches,
+ { [X_TOTAL_HEADER]: totalBranchesCount },
+ ]);
tagsApiCallSpy = jest
.fn()
- .mockReturnValue([HTTP_STATUS_OK, fixtures.tags, { [X_TOTAL_HEADER]: '456' }]);
+ .mockReturnValue([HTTP_STATUS_OK, fixtures.tags, { [X_TOTAL_HEADER]: totalTagsCount }]);
commitApiCallSpy = jest.fn().mockReturnValue([HTTP_STATUS_OK, fixtures.commit]);
requestSpies = { branchesApiCallSpy, tagsApiCallSpy, commitApiCallSpy };
@@ -316,7 +321,7 @@ describe('Ref selector component', () => {
describe('branches', () => {
describe('when the branches search returns results', () => {
beforeEach(() => {
- createComponent({}, { refType: BRANCH_REF_TYPE, useSymbolicRefNames: true });
+ createComponent({}, { useSymbolicRefNames: true });
return waitForRequests();
});
@@ -379,7 +384,7 @@ describe('Ref selector component', () => {
describe('tags', () => {
describe('when the tags search returns results', () => {
beforeEach(() => {
- createComponent({}, { refType: TAG_REF_TYPE, useSymbolicRefNames: true });
+ createComponent({}, { useSymbolicRefNames: true });
return waitForRequests();
});
@@ -690,7 +695,67 @@ describe('Ref selector component', () => {
// is updated. For the sake of this test, we'll just test the last call, which
// represents the final state of the slot props.
const lastCallProps = last(createFooter.mock.calls)[0];
- expect(lastCallProps).toMatchSnapshot();
+ expect(lastCallProps.isLoading).toBe(false);
+ expect(lastCallProps.query).toBe('abcd1234');
+
+ const branchesList = fixtures.branches.map((branch) => {
+ return {
+ default: branch.default,
+ name: branch.name,
+ };
+ });
+
+ const commitsList = [
+ {
+ name: fixtures.commit.short_id,
+ subtitle: fixtures.commit.title,
+ value: fixtures.commit.id,
+ },
+ ];
+
+ const tagsList = fixtures.tags.map((tag) => {
+ return {
+ name: tag.name,
+ };
+ });
+
+ const expectedMatches = {
+ branches: {
+ list: branchesList,
+ totalCount: totalBranchesCount,
+ },
+ commits: {
+ list: commitsList,
+ totalCount: 1,
+ },
+ tags: {
+ list: tagsList,
+ totalCount: totalTagsCount,
+ },
+ };
+
+ expect(lastCallProps.matches).toMatchObject(expectedMatches);
+ });
+ });
+ describe('when queryParam prop is present', () => {
+ it('passes params to a branches API call', () => {
+ createComponent({ propsData: { queryParams } });
+
+ return waitForRequests().then(() => {
+ expect(branchesApiCallSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ params: { per_page: 20, search: '', sort: queryParams.sort } }),
+ );
+ });
+ });
+
+ it('does not pass params to tags API call', () => {
+ createComponent({ propsData: { queryParams } });
+
+ return waitForRequests().then(() => {
+ expect(tagsApiCallSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ params: { per_page: 20, search: '' } }),
+ );
+ });
});
});
});
diff --git a/spec/frontend/ref/stores/actions_spec.js b/spec/frontend/ref/stores/actions_spec.js
index 099ce062a3a..c6aac8c9c98 100644
--- a/spec/frontend/ref/stores/actions_spec.js
+++ b/spec/frontend/ref/stores/actions_spec.js
@@ -52,6 +52,13 @@ describe('Ref selector Vuex store actions', () => {
});
});
+ describe('setParams', () => {
+ it(`commits ${types.SET_PARAMS} with the provided params`, () => {
+ const params = { sort: 'updated_asc' };
+ testAction(actions.setParams, params, state, [{ type: types.SET_PARAMS, payload: params }]);
+ });
+ });
+
describe('search', () => {
it(`commits ${types.SET_QUERY} with the new search query`, () => {
const query = 'hello';
diff --git a/spec/frontend/ref/stores/mutations_spec.js b/spec/frontend/ref/stores/mutations_spec.js
index 37eee18dc10..8f16317b751 100644
--- a/spec/frontend/ref/stores/mutations_spec.js
+++ b/spec/frontend/ref/stores/mutations_spec.js
@@ -34,6 +34,7 @@ describe('Ref selector Vuex store mutations', () => {
error: null,
},
},
+ params: null,
selectedRef: null,
requestCount: 0,
});
@@ -56,6 +57,15 @@ describe('Ref selector Vuex store mutations', () => {
});
});
+ describe(`${types.SET_PARAMS}`, () => {
+ it('sets the additional query params', () => {
+ const params = { sort: 'updated_desc' };
+ mutations[types.SET_PARAMS](state, params);
+
+ expect(state.params).toBe(params);
+ });
+ });
+
describe(`${types.SET_PROJECT_ID}`, () => {
it('updates the project ID', () => {
const newProjectId = '4';
diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap
index 00fc521b716..79792a4a0ea 100644
--- a/spec/frontend/releases/__snapshots__/util_spec.js.snap
+++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap
@@ -82,7 +82,6 @@ Object {
Object {
"__typename": "ReleaseAssetLink",
"directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-3",
- "external": true,
"id": "gid://gitlab/Releases::Link/13",
"linkType": "image",
"name": "Image",
@@ -91,7 +90,6 @@ Object {
Object {
"__typename": "ReleaseAssetLink",
"directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-2",
- "external": true,
"id": "gid://gitlab/Releases::Link/12",
"linkType": "package",
"name": "Package",
@@ -100,7 +98,6 @@ Object {
Object {
"__typename": "ReleaseAssetLink",
"directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-1",
- "external": false,
"id": "gid://gitlab/Releases::Link/11",
"linkType": "runbook",
"name": "Runbook",
@@ -109,7 +106,6 @@ Object {
Object {
"__typename": "ReleaseAssetLink",
"directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/linux-amd64",
- "external": true,
"id": "gid://gitlab/Releases::Link/10",
"linkType": "other",
"name": "linux-amd64 binaries",
@@ -306,7 +302,6 @@ Object {
Object {
"__typename": "ReleaseAssetLink",
"directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-3",
- "external": true,
"id": "gid://gitlab/Releases::Link/13",
"linkType": "image",
"name": "Image",
@@ -315,7 +310,6 @@ Object {
Object {
"__typename": "ReleaseAssetLink",
"directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-2",
- "external": true,
"id": "gid://gitlab/Releases::Link/12",
"linkType": "package",
"name": "Package",
@@ -324,7 +318,6 @@ Object {
Object {
"__typename": "ReleaseAssetLink",
"directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-1",
- "external": false,
"id": "gid://gitlab/Releases::Link/11",
"linkType": "runbook",
"name": "Runbook",
@@ -333,7 +326,6 @@ Object {
Object {
"__typename": "ReleaseAssetLink",
"directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/linux-amd64",
- "external": true,
"id": "gid://gitlab/Releases::Link/10",
"linkType": "other",
"name": "linux-amd64 binaries",
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index bd61e4537f9..69d8969f0ad 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -16,6 +16,7 @@ import AssetLinksForm from '~/releases/components/asset_links_form.vue';
import ConfirmDeleteModal from '~/releases/components/confirm_delete_modal.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { ValidationResult } from '~/lib/utils/ref_validator';
const originalRelease = originalOneReleaseForEditingQueryResponse.data.project.release;
const originalMilestones = originalRelease.milestones;
@@ -30,6 +31,8 @@ describe('Release edit/new component', () => {
let actions;
let getters;
let state;
+ let refActions;
+ let refState;
let mock;
const factory = async ({ featureFlags = {}, store: storeUpdates = {} } = {}) => {
@@ -58,8 +61,23 @@ describe('Release edit/new component', () => {
assets: {
links: [],
},
+ tagNameValidation: new ValidationResult(),
}),
formattedReleaseNotes: () => 'these notes are formatted',
+ isCreating: jest.fn(),
+ isSearching: jest.fn(),
+ isExistingTag: jest.fn(),
+ isNewTag: jest.fn(),
+ };
+
+ refState = {
+ matches: [],
+ };
+
+ refActions = {
+ setEnabledRefTypes: jest.fn(),
+ setProjectId: jest.fn(),
+ search: jest.fn(),
};
const store = new Vuex.Store(
@@ -72,6 +90,11 @@ describe('Release edit/new component', () => {
state,
getters,
},
+ ref: {
+ namespaced: true,
+ actions: refActions,
+ state: refState,
+ },
},
},
storeUpdates,
@@ -101,11 +124,6 @@ describe('Release edit/new component', () => {
release = convertOneReleaseGraphQLResponse(originalOneReleaseForEditingQueryResponse).data;
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findSubmitButton = () => wrapper.find('button[type=submit]');
const findForm = () => wrapper.find('form');
@@ -291,7 +309,7 @@ describe('Release edit/new component', () => {
});
it('renders the submit button as disabled', () => {
- expect(findSubmitButton().attributes('disabled')).toBe('disabled');
+ expect(findSubmitButton().attributes('disabled')).toBeDefined();
});
it('does not allow the form to be submitted', () => {
diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js
index ef3bd5ca873..b8507dc5fb4 100644
--- a/spec/frontend/releases/components/app_index_spec.js
+++ b/spec/frontend/releases/components/app_index_spec.js
@@ -6,9 +6,8 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
-import { createAlert } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { historyPushState } from '~/lib/utils/common_utils';
-import { sprintf, __ } from '~/locale';
import ReleasesIndexApp from '~/releases/components/app_index.vue';
import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
@@ -16,11 +15,11 @@ import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
import ReleasesPagination from '~/releases/components/releases_pagination.vue';
import ReleasesSort from '~/releases/components/releases_sort.vue';
import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants';
-import { deleteReleaseSessionKey } from '~/releases/util';
+import { deleteReleaseSessionKey } from '~/releases/release_notification_service';
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
let mockQueryParams;
jest.mock('~/lib/utils/common_utils', () => ({
@@ -114,7 +113,7 @@ describe('app_index.vue', () => {
const toDescription = (bool) => (bool ? 'does' : 'does not');
describe.each`
- description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination
+ description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | alertMessage | releaseCount | pagination
${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false}
${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
@@ -134,7 +133,7 @@ describe('app_index.vue', () => {
fullResponseFn,
loadingIndicator,
emptyState,
- flashMessage,
+ alertMessage,
releaseCount,
pagination,
}) => {
@@ -154,9 +153,9 @@ describe('app_index.vue', () => {
expect(findEmptyState().exists()).toBe(emptyState);
});
- it(`${toDescription(flashMessage)} show a flash message`, async () => {
+ it(`${toDescription(alertMessage)} show a flash message`, async () => {
await waitForPromises();
- if (flashMessage) {
+ if (alertMessage) {
expect(createAlert).toHaveBeenCalledWith({
message: ReleasesIndexApp.i18n.errorMessage,
captureError: true,
@@ -412,15 +411,15 @@ describe('app_index.vue', () => {
await createComponent();
});
- it('shows a toast', async () => {
- expect(toast).toHaveBeenCalledWith(
- sprintf(__('Release %{release} has been successfully deleted.'), {
- release,
- }),
- );
+ it('shows a toast', () => {
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `Release ${release} has been successfully deleted.`,
+ variant: VARIANT_SUCCESS,
+ });
});
- it('clears session storage', async () => {
+ it('clears session storage', () => {
expect(window.sessionStorage.getItem(key)).toBe(null);
});
});
diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js
index efe72e8000a..942280cb6a2 100644
--- a/spec/frontend/releases/components/app_show_spec.js
+++ b/spec/frontend/releases/components/app_show_spec.js
@@ -4,14 +4,14 @@ import VueApollo from 'vue-apollo';
import oneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { popCreateReleaseNotification } from '~/releases/release_notification_service';
import ReleaseShowApp from '~/releases/components/app_show.vue';
import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
import oneReleaseQuery from '~/releases/graphql/queries/one_release.query.graphql';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/releases/release_notification_service');
Vue.use(VueApollo);
@@ -33,11 +33,6 @@ describe('Release show component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findLoadingSkeleton = () => wrapper.findComponent(ReleaseSkeletonLoader);
const findReleaseBlock = () => wrapper.findComponent(ReleaseBlock);
@@ -54,13 +49,13 @@ describe('Release show component', () => {
};
const expectNoFlash = () => {
- it('does not show a flash message', () => {
+ it('does not show an alert message', () => {
expect(createAlert).not.toHaveBeenCalled();
});
};
const expectFlashWithMessage = (message) => {
- it(`shows a flash message that reads "${message}"`, () => {
+ it(`shows an alert message that reads "${message}"`, () => {
expect(createAlert).toHaveBeenCalledWith({
message,
captureError: true,
@@ -152,7 +147,7 @@ describe('Release show component', () => {
beforeEach(async () => {
// As we return a release as `null`, Apollo also throws an error to the console
// about the missing field. We need to suppress console.error in order to check
- // that flash message was called
+ // that alert message was called
// eslint-disable-next-line no-console
console.error = jest.fn();
diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js
index b1e9d8d1256..8eee9acd808 100644
--- a/spec/frontend/releases/components/asset_links_form_spec.js
+++ b/spec/frontend/releases/components/asset_links_form_spec.js
@@ -60,11 +60,6 @@ describe('Release edit component', () => {
release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('with a basic store state', () => {
beforeEach(() => {
factory();
diff --git a/spec/frontend/releases/components/confirm_delete_modal_spec.js b/spec/frontend/releases/components/confirm_delete_modal_spec.js
index f7c526c1ced..b4699302779 100644
--- a/spec/frontend/releases/components/confirm_delete_modal_spec.js
+++ b/spec/frontend/releases/components/confirm_delete_modal_spec.js
@@ -42,10 +42,6 @@ describe('~/releases/components/confirm_delete_modal.vue', () => {
factory();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('button', () => {
it('should open the modal on click', async () => {
await wrapper.findByRole('button', { name: 'Delete' }).trigger('click');
diff --git a/spec/frontend/releases/components/evidence_block_spec.js b/spec/frontend/releases/components/evidence_block_spec.js
index 69443cb7a11..42eac31e5ac 100644
--- a/spec/frontend/releases/components/evidence_block_spec.js
+++ b/spec/frontend/releases/components/evidence_block_spec.js
@@ -27,10 +27,6 @@ describe('Evidence Block', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the evidence icon', () => {
expect(wrapper.findComponent(GlIcon).props('name')).toBe('review-list');
});
diff --git a/spec/frontend/releases/components/issuable_stats_spec.js b/spec/frontend/releases/components/issuable_stats_spec.js
index 3ac75e138ee..c8cdf9cb951 100644
--- a/spec/frontend/releases/components/issuable_stats_spec.js
+++ b/spec/frontend/releases/components/issuable_stats_spec.js
@@ -34,11 +34,6 @@ describe('~/releases/components/issuable_stats.vue', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('matches snapshot', () => {
createComponent();
diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js
index 6d53bf5a49e..8332e307ce9 100644
--- a/spec/frontend/releases/components/release_block_assets_spec.js
+++ b/spec/frontend/releases/components/release_block_assets_spec.js
@@ -124,13 +124,12 @@ describe('Release block assets', () => {
});
describe('links', () => {
- const containsExternalSourceIndicator = () =>
- wrapper.find('[data-testid="external-link-indicator"]').exists();
+ const findAllExternalIcons = () => wrapper.findAll('[data-testid="external-link-indicator"]');
beforeEach(() => createComponent(defaultProps));
- it('renders with an external source indicator (except for sections with no title)', () => {
- expect(containsExternalSourceIndicator()).toBe(true);
+ it('renders with an external source indicator', () => {
+ expect(findAllExternalIcons()).toHaveLength(defaultProps.assets.count);
});
});
});
diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js
index 19b41d05a44..12e3807c9fa 100644
--- a/spec/frontend/releases/components/release_block_footer_spec.js
+++ b/spec/frontend/releases/components/release_block_footer_spec.js
@@ -33,11 +33,6 @@ describe('Release block footer', () => {
release = cloneDeep(originalRelease);
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const commitInfoSection = () => wrapper.find('.js-commit-info');
const commitInfoSectionLink = () => commitInfoSection().findComponent(GlLink);
const tagInfoSection = () => wrapper.find('.js-tag-info');
diff --git a/spec/frontend/releases/components/release_block_header_spec.js b/spec/frontend/releases/components/release_block_header_spec.js
index fc421776d60..dd39a1bce53 100644
--- a/spec/frontend/releases/components/release_block_header_spec.js
+++ b/spec/frontend/releases/components/release_block_header_spec.js
@@ -25,10 +25,6 @@ describe('Release block header', () => {
release = convertObjectPropsToCamelCase(originalRelease, { deep: true });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findHeader = () => wrapper.find('h2');
const findHeaderLink = () => findHeader().findComponent(GlLink);
const findEditButton = () => wrapper.find('.js-edit-button');
diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js
index 541d487091c..b8030ae1fd2 100644
--- a/spec/frontend/releases/components/release_block_milestone_info_spec.js
+++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js
@@ -25,11 +25,6 @@ describe('Release block milestone info', () => {
milestones = convertObjectPropsToCamelCase(originalMilestones, { deep: true });
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const milestoneProgressBarContainer = () => wrapper.find('.js-milestone-progress-bar-container');
const milestoneListContainer = () => wrapper.find('.js-milestone-list-container');
const issuesContainer = () => wrapper.find('[data-testid="issue-stats"]');
diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js
index f1b8554fbc3..3355b5ab2c3 100644
--- a/spec/frontend/releases/components/release_block_spec.js
+++ b/spec/frontend/releases/components/release_block_spec.js
@@ -39,10 +39,6 @@ describe('Release block', () => {
release = convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse).data;
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with default props', () => {
beforeEach(() => factory(release));
diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js
index 59be808c802..923d84ae2b3 100644
--- a/spec/frontend/releases/components/releases_pagination_spec.js
+++ b/spec/frontend/releases/components/releases_pagination_spec.js
@@ -29,10 +29,6 @@ describe('releases_pagination.vue', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const singlePageInfo = {
hasPreviousPage: false,
hasNextPage: false,
diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js
index c6e1846d252..76907b4b8bb 100644
--- a/spec/frontend/releases/components/releases_sort_spec.js
+++ b/spec/frontend/releases/components/releases_sort_spec.js
@@ -1,5 +1,6 @@
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { assertProps } from 'helpers/assert_props';
import ReleasesSort from '~/releases/components/releases_sort.vue';
import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants';
@@ -17,10 +18,6 @@ describe('releases_sort.vue', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSorting = () => wrapper.findComponent(GlSorting);
const findSortingItems = () => wrapper.findAllComponents(GlSortingItem);
const findReleasedDateItem = () =>
@@ -96,7 +93,7 @@ describe('releases_sort.vue', () => {
describe('prop validation', () => {
it('validates that the `value` prop is one of the expected sort strings', () => {
expect(() => {
- createComponent('not a valid value');
+ assertProps(ReleasesSort, { value: 'not a valid value' });
}).toThrow('Invalid prop: custom validator check failed');
});
});
diff --git a/spec/frontend/releases/components/tag_create_spec.js b/spec/frontend/releases/components/tag_create_spec.js
new file mode 100644
index 00000000000..0df2483bcf2
--- /dev/null
+++ b/spec/frontend/releases/components/tag_create_spec.js
@@ -0,0 +1,107 @@
+import { GlButton, GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { __, s__ } from '~/locale';
+import TagCreate from '~/releases/components/tag_create.vue';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import createStore from '~/releases/stores';
+import createEditNewModule from '~/releases/stores/modules/edit_new';
+import { createRefModule } from '~/ref/stores';
+
+const TEST_PROJECT_ID = '1234';
+
+const VALUE = 'new-tag';
+
+describe('releases/components/tag_create', () => {
+ let store;
+ let wrapper;
+ let mock;
+
+ const createWrapper = () => {
+ wrapper = shallowMount(TagCreate, {
+ store,
+ propsData: { value: VALUE },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ store = createStore({
+ modules: {
+ editNew: createEditNewModule({
+ projectId: TEST_PROJECT_ID,
+ }),
+ ref: createRefModule(),
+ },
+ });
+ store.state.editNew.release = {
+ tagMessage: 'test',
+ };
+ store.state.editNew.createFrom = 'v1';
+ createWrapper();
+ });
+
+ afterEach(() => mock.restore());
+
+ const findTagInput = () => wrapper.findComponent(GlFormInput);
+ const findTagRef = () => wrapper.findComponent(RefSelector);
+ const findTagMessage = () => wrapper.findComponent(GlFormTextarea);
+ const findSave = () => wrapper.findAllComponents(GlButton).at(-2);
+ const findCancel = () => wrapper.findAllComponents(GlButton).at(-1);
+
+ it('initializes the input with value prop', () => {
+ expect(findTagInput().attributes('value')).toBe(VALUE);
+ });
+
+ it('emits a change event when the tag name chagnes', () => {
+ findTagInput().vm.$emit('input', 'new-value');
+
+ expect(wrapper.emitted('change')).toEqual([['new-value']]);
+ });
+
+ it('binds the store value to the ref selector', () => {
+ const ref = findTagRef();
+ expect(ref.props('value')).toBe('v1');
+
+ ref.vm.$emit('input', 'v2');
+
+ expect(ref.props('value')).toBe('v1');
+ });
+
+ it('passes the project id tot he ref selector', () => {
+ expect(findTagRef().props('projectId')).toBe(TEST_PROJECT_ID);
+ });
+
+ it('binds the store value to the message', async () => {
+ const message = findTagMessage();
+ expect(message.attributes('value')).toBe('test');
+
+ message.vm.$emit('input', 'hello');
+
+ await nextTick();
+
+ expect(message.attributes('value')).toBe('hello');
+ });
+
+ it('emits create event when Save is clicked', () => {
+ const button = findSave();
+
+ expect(button.text()).toBe(__('Save'));
+
+ button.vm.$emit('click');
+
+ expect(wrapper.emitted('create')).toEqual([[]]);
+ });
+
+ it('emits cancel event when Select another tag is clicked', () => {
+ const button = findCancel();
+
+ expect(button.text()).toBe(s__('Release|Select another tag'));
+
+ button.vm.$emit('click');
+
+ expect(wrapper.emitted('cancel')).toEqual([[]]);
+ });
+});
diff --git a/spec/frontend/releases/components/tag_field_exsting_spec.js b/spec/frontend/releases/components/tag_field_exsting_spec.js
index 8105aa4f6f2..0e896eb645c 100644
--- a/spec/frontend/releases/components/tag_field_exsting_spec.js
+++ b/spec/frontend/releases/components/tag_field_exsting_spec.js
@@ -37,11 +37,6 @@ describe('releases/components/tag_field_existing', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('default', () => {
it('shows the tag name', () => {
createComponent();
diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js
index fcba0da3462..3468338b8a7 100644
--- a/spec/frontend/releases/components/tag_field_new_spec.js
+++ b/spec/frontend/releases/components/tag_field_new_spec.js
@@ -1,17 +1,19 @@
-import { GlDropdownItem, GlFormGroup, GlSprintf } from '@gitlab/ui';
-import { mount, shallowMount } from '@vue/test-utils';
+import { GlFormGroup, GlDropdown, GlPopover } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import Vue, { nextTick } from 'vue';
-import { trimText } from 'helpers/text_helper';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { __ } from '~/locale';
import TagFieldNew from '~/releases/components/tag_field_new.vue';
+import TagSearch from '~/releases/components/tag_search.vue';
+import TagCreate from '~/releases/components/tag_create.vue';
import createStore from '~/releases/stores';
import createEditNewModule from '~/releases/stores/modules/edit_new';
+import { CREATE } from '~/releases/stores/modules/edit_new/constants';
+import { createRefModule } from '~/ref/stores';
+import { i18n } from '~/releases/constants';
const TEST_TAG_NAME = 'test-tag-name';
-const TEST_TAG_MESSAGE = 'Test tag message';
const TEST_PROJECT_ID = '1234';
const TEST_CREATE_FROM = 'test-create-from';
const NONEXISTENT_TAG_NAME = 'nonexistent-tag';
@@ -20,38 +22,12 @@ describe('releases/components/tag_field_new', () => {
let store;
let wrapper;
let mock;
- let RefSelectorStub;
-
- const createComponent = (
- mountFn = shallowMount,
- { searchQuery } = { searchQuery: NONEXISTENT_TAG_NAME },
- ) => {
- // A mock version of the RefSelector component that just renders the
- // #footer slot, so that the content inside this slot can be tested.
- RefSelectorStub = Vue.component('RefSelectorStub', {
- data() {
- return {
- footerSlotProps: {
- isLoading: false,
- matches: {
- tags: {
- totalCount: 1,
- list: [{ name: TEST_TAG_NAME }],
- },
- },
- query: searchQuery,
- },
- };
- },
- template: '<div><slot name="footer" v-bind="footerSlotProps"></slot></div>',
- });
- wrapper = mountFn(TagFieldNew, {
+ const createComponent = () => {
+ wrapper = shallowMountExtended(TagFieldNew, {
store,
stubs: {
- RefSelector: RefSelectorStub,
GlFormGroup,
- GlSprintf,
},
});
};
@@ -62,11 +38,12 @@ describe('releases/components/tag_field_new', () => {
editNew: createEditNewModule({
projectId: TEST_PROJECT_ID,
}),
+ ref: createRefModule(),
},
});
store.state.editNew.createFrom = TEST_CREATE_FROM;
- store.state.editNew.showCreateFrom = true;
+ store.state.editNew.step = CREATE;
store.state.editNew.release = {
tagName: TEST_TAG_NAME,
@@ -80,21 +57,13 @@ describe('releases/components/tag_field_new', () => {
gon.api_version = 'v4';
});
- afterEach(() => {
- wrapper.destroy();
- mock.restore();
- });
-
- const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]');
- const findTagNameDropdown = () => findTagNameFormGroup().findComponent(RefSelectorStub);
+ afterEach(() => mock.restore());
- const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]');
- const findCreateFromDropdown = () => findCreateFromFormGroup().findComponent(RefSelectorStub);
-
- const findCreateNewTagOption = () => wrapper.findComponent(GlDropdownItem);
-
- const findAnnotatedTagMessageFormGroup = () =>
- wrapper.find('[data-testid="annotated-tag-message-field"]');
+ const findTagNameFormGroup = () => wrapper.findComponent(GlFormGroup);
+ const findTagNameInput = () => wrapper.findComponent(GlDropdown);
+ const findTagNamePopover = () => wrapper.findComponent(GlPopover);
+ const findTagNameSearch = () => wrapper.findComponent(TagSearch);
+ const findTagNameCreate = () => wrapper.findComponent(TagCreate);
describe('"Tag name" field', () => {
describe('rendering and behavior', () => {
@@ -102,20 +71,37 @@ describe('releases/components/tag_field_new', () => {
it('renders a label', () => {
expect(findTagNameFormGroup().attributes().label).toBe(__('Tag name'));
- expect(findTagNameFormGroup().props().labelDescription).toBe(__('*Required'));
+ expect(findTagNameFormGroup().props().optionalText).toBe(__('(required)'));
+ });
+
+ it('flips between search and create, passing the searched value', async () => {
+ let create = findTagNameCreate();
+ let search = findTagNameSearch();
+
+ expect(create.exists()).toBe(true);
+ expect(search.exists()).toBe(false);
+
+ await create.vm.$emit('cancel');
+
+ search = findTagNameSearch();
+ expect(create.exists()).toBe(false);
+ expect(search.exists()).toBe(true);
+
+ await search.vm.$emit('create', TEST_TAG_NAME);
+
+ create = findTagNameCreate();
+ expect(create.exists()).toBe(true);
+ expect(create.props('value')).toBe(TEST_TAG_NAME);
+ expect(search.exists()).toBe(false);
});
describe('when the user selects a new tag name', () => {
- beforeEach(async () => {
- findCreateNewTagOption().vm.$emit('click');
- });
+ it("updates the store's release.tagName property", async () => {
+ findTagNameCreate().vm.$emit('change', NONEXISTENT_TAG_NAME);
+ await findTagNameCreate().vm.$emit('create');
- it("updates the store's release.tagName property", () => {
expect(store.state.editNew.release.tagName).toBe(NONEXISTENT_TAG_NAME);
- });
-
- it('hides the "Create from" field', () => {
- expect(findCreateFromFormGroup().exists()).toBe(true);
+ expect(findTagNameInput().props('text')).toBe(NONEXISTENT_TAG_NAME);
});
});
@@ -123,19 +109,17 @@ describe('releases/components/tag_field_new', () => {
const updatedTagName = 'updated-tag-name';
beforeEach(async () => {
- findTagNameDropdown().vm.$emit('input', updatedTagName);
+ await findTagNameCreate().vm.$emit('cancel');
+ findTagNameSearch().vm.$emit('select', updatedTagName);
});
it("updates the store's release.tagName property", () => {
expect(store.state.editNew.release.tagName).toBe(updatedTagName);
+ expect(findTagNameInput().props('text')).toBe(updatedTagName);
});
it('hides the "Create from" field', () => {
- expect(findCreateFromFormGroup().exists()).toBe(false);
- });
-
- it('hides the "Tag message" field', () => {
- expect(findAnnotatedTagMessageFormGroup().exists()).toBe(false);
+ expect(findTagNameCreate().exists()).toBe(false);
});
it('fetches the release notes for the tag', () => {
@@ -145,133 +129,66 @@ describe('releases/components/tag_field_new', () => {
});
});
- describe('"Create tag" option', () => {
- describe('when the search query exactly matches one of the search results', () => {
- beforeEach(async () => {
- createComponent(mount, { searchQuery: TEST_TAG_NAME });
- });
-
- it('does not show the "Create tag" option', () => {
- expect(findCreateNewTagOption().exists()).toBe(false);
- });
- });
-
- describe('when the search query does not exactly match one of the search results', () => {
- beforeEach(async () => {
- createComponent(mount, { searchQuery: NONEXISTENT_TAG_NAME });
- });
-
- it('shows the "Create tag" option', () => {
- expect(findCreateNewTagOption().exists()).toBe(true);
- });
- });
- });
-
describe('validation', () => {
beforeEach(() => {
- createComponent(mount);
+ createComponent();
+ findTagNameCreate().vm.$emit('cancel');
});
/**
* Utility function to test the visibility of the validation message
- * @param {'shown' | 'hidden'} state The expected state of the validation message.
- * Should be passed either 'shown' or 'hidden'
+ * @param {boolean} isShown Whether or not the message is shown.
*/
- const expectValidationMessageToBe = async (state) => {
+ const expectValidationMessageToBeShown = async (isShown) => {
await nextTick();
- expect(findTagNameFormGroup().element).toHaveClass(
- state === 'shown' ? 'is-invalid' : 'is-valid',
- );
- expect(findTagNameFormGroup().element).not.toHaveClass(
- state === 'shown' ? 'is-valid' : 'is-invalid',
- );
+ const state = findTagNameFormGroup().attributes('state');
+
+ if (isShown) {
+ expect(state).toBeUndefined();
+ } else {
+ expect(state).toBe('true');
+ }
};
describe('when the user has not yet interacted with the component', () => {
it('does not display a validation error', async () => {
- findTagNameDropdown().vm.$emit('input', '');
-
- await expectValidationMessageToBe('hidden');
+ await expectValidationMessageToBeShown(false);
});
});
describe('when the user has interacted with the component and the value is not empty', () => {
it('does not display validation error', async () => {
- findTagNameDropdown().vm.$emit('hide');
+ findTagNameSearch().vm.$emit('select', 'vTest');
+ findTagNamePopover().vm.$emit('hide');
- await expectValidationMessageToBe('hidden');
+ await expectValidationMessageToBeShown(false);
});
it('displays a validation error if the tag has an associated release', async () => {
- findTagNameDropdown().vm.$emit('input', 'vTest');
- findTagNameDropdown().vm.$emit('hide');
+ findTagNameSearch().vm.$emit('select', 'vTest');
+ findTagNamePopover().vm.$emit('hide');
store.state.editNew.existingRelease = {};
- await expectValidationMessageToBe('shown');
- expect(findTagNameFormGroup().text()).toContain(
- __('Selected tag is already in use. Choose another option.'),
+ await expectValidationMessageToBeShown(true);
+ expect(findTagNameFormGroup().attributes('invalidfeedback')).toBe(
+ i18n.tagIsAlredyInUseMessage,
);
});
});
describe('when the user has interacted with the component and the value is empty', () => {
it('displays a validation error', async () => {
- findTagNameDropdown().vm.$emit('input', '');
- findTagNameDropdown().vm.$emit('hide');
+ findTagNameSearch().vm.$emit('select', '');
+ findTagNamePopover().vm.$emit('hide');
- await expectValidationMessageToBe('shown');
- expect(findTagNameFormGroup().text()).toContain(__('Tag name is required.'));
+ await expectValidationMessageToBeShown(true);
+ expect(findTagNameFormGroup().attributes('invalidfeedback')).toContain(
+ i18n.tagNameIsRequiredMessage,
+ );
});
});
});
});
-
- describe('"Create from" field', () => {
- beforeEach(() => createComponent());
-
- it('renders a label', () => {
- expect(findCreateFromFormGroup().attributes().label).toBe('Create from');
- });
-
- describe('when the user selects a git ref', () => {
- it("updates the store's createFrom property", async () => {
- const updatedCreateFrom = 'update-create-from';
- findCreateFromDropdown().vm.$emit('input', updatedCreateFrom);
-
- expect(store.state.editNew.createFrom).toBe(updatedCreateFrom);
- });
- });
- });
-
- describe('"Annotated Tag" field', () => {
- beforeEach(() => {
- createComponent(mountExtended);
- });
-
- it('renders a label', () => {
- expect(wrapper.findByRole('textbox', { name: 'Set tag message' }).exists()).toBe(true);
- });
-
- it('renders a description', () => {
- expect(trimText(findAnnotatedTagMessageFormGroup().text())).toContain(
- 'Add a message to the tag. Leaving this blank creates a lightweight tag.',
- );
- });
-
- it('updates the store', async () => {
- await findAnnotatedTagMessageFormGroup().find('textarea').setValue(TEST_TAG_MESSAGE);
-
- expect(store.state.editNew.release.tagMessage).toBe(TEST_TAG_MESSAGE);
- });
-
- it('shows a link', () => {
- const link = wrapper.findByRole('link', {
- name: 'lightweight tag',
- });
-
- expect(link.attributes('href')).toBe('https://git-scm.com/book/en/v2/Git-Basics-Tagging/');
- });
- });
});
diff --git a/spec/frontend/releases/components/tag_field_spec.js b/spec/frontend/releases/components/tag_field_spec.js
index 85a40f02c53..8509c347291 100644
--- a/spec/frontend/releases/components/tag_field_spec.js
+++ b/spec/frontend/releases/components/tag_field_spec.js
@@ -24,11 +24,6 @@ describe('releases/components/tag_field', () => {
const findTagFieldNew = () => wrapper.findComponent(TagFieldNew);
const findTagFieldExisting = () => wrapper.findComponent(TagFieldExisting);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when an existing release is being edited', () => {
beforeEach(() => {
createComponent({ isExistingRelease: true });
diff --git a/spec/frontend/releases/components/tag_search_spec.js b/spec/frontend/releases/components/tag_search_spec.js
new file mode 100644
index 00000000000..4144a9cc297
--- /dev/null
+++ b/spec/frontend/releases/components/tag_search_spec.js
@@ -0,0 +1,144 @@
+import { GlButton, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import { DEFAULT_PER_PAGE } from '~/api';
+import { __, s__, sprintf } from '~/locale';
+import TagSearch from '~/releases/components/tag_search.vue';
+import createStore from '~/releases/stores';
+import createEditNewModule from '~/releases/stores/modules/edit_new';
+import { createRefModule } from '~/ref/stores';
+
+const TEST_TAG_NAME = 'test-tag-name';
+const TEST_PROJECT_ID = '1234';
+const TAGS = [{ name: 'v1' }, { name: 'v2' }, { name: 'v3' }];
+
+describe('releases/components/tag_search', () => {
+ let store;
+ let wrapper;
+ let mock;
+
+ const createWrapper = (propsData = {}) => {
+ wrapper = mount(TagSearch, {
+ store,
+ propsData,
+ });
+ };
+
+ beforeEach(() => {
+ store = createStore({
+ modules: {
+ editNew: createEditNewModule({
+ projectId: TEST_PROJECT_ID,
+ }),
+ ref: createRefModule(),
+ },
+ });
+
+ store.state.editNew.release = {};
+
+ mock = new MockAdapter(axios);
+ gon.api_version = 'v4';
+ });
+
+ afterEach(() => mock.restore());
+
+ const findSearch = () => wrapper.findComponent(GlSearchBoxByType);
+ const findCreate = () => wrapper.findAllComponents(GlButton).at(-1);
+ const findResults = () => wrapper.findAllComponents(GlDropdownItem);
+
+ describe('init', () => {
+ beforeEach(async () => {
+ mock
+ .onGet(`/api/v4/projects/${TEST_PROJECT_ID}/repository/tags`)
+ .reply(200, TAGS, { 'x-total': TAGS.length });
+
+ createWrapper();
+
+ await waitForPromises();
+ });
+
+ it('displays a set of results immediately', () => {
+ findResults().wrappers.forEach((w, i) => expect(w.text()).toBe(TAGS[i].name));
+ });
+
+ it('has a disabled button', () => {
+ const button = findCreate();
+ expect(button.text()).toBe(s__('Release|Or type a new tag name'));
+ expect(button.props('disabled')).toBe(true);
+ });
+
+ it('has an empty search input', () => {
+ expect(findSearch().props('value')).toBe('');
+ });
+
+ describe('searching', () => {
+ const query = TEST_TAG_NAME;
+
+ beforeEach(async () => {
+ mock.reset();
+ mock
+ .onGet(`/api/v4/projects/${TEST_PROJECT_ID}/repository/tags`, {
+ params: { search: query, per_page: DEFAULT_PER_PAGE },
+ })
+ .reply(200, [], { 'x-total': 0 });
+
+ findSearch().vm.$emit('input', query);
+
+ await nextTick();
+ await waitForPromises();
+ });
+
+ it('shows "No results found" when there are no results', () => {
+ expect(wrapper.text()).toContain(__('No results found'));
+ });
+
+ it('searches with the given input', () => {
+ expect(mock.history.get[0].params.search).toBe(query);
+ });
+
+ it('emits the query', () => {
+ expect(wrapper.emitted('change')).toEqual([[query]]);
+ });
+ });
+ });
+
+ describe('with query', () => {
+ const query = TEST_TAG_NAME;
+
+ beforeEach(async () => {
+ mock
+ .onGet(`/api/v4/projects/${TEST_PROJECT_ID}/repository/tags`, {
+ params: { search: query, per_page: DEFAULT_PER_PAGE },
+ })
+ .reply(200, TAGS, { 'x-total': TAGS.length });
+
+ createWrapper({ query });
+
+ await waitForPromises();
+ });
+
+ it('displays a set of results immediately', () => {
+ findResults().wrappers.forEach((w, i) => expect(w.text()).toBe(TAGS[i].name));
+ });
+
+ it('has an enabled button', () => {
+ const button = findCreate();
+ expect(button.text()).toMatchInterpolatedText(
+ sprintf(s__('Release|Create tag %{tag}'), { tag: query }),
+ );
+ expect(button.props('disabled')).toBe(false);
+ });
+
+ it('emits create event when button clicked', () => {
+ findCreate().vm.$emit('click');
+ expect(wrapper.emitted('create')).toEqual([[query]]);
+ });
+
+ it('has an empty search input', () => {
+ expect(findSearch().props('value')).toBe(query);
+ });
+ });
+});
diff --git a/spec/frontend/releases/release_notification_service_spec.js b/spec/frontend/releases/release_notification_service_spec.js
index 2344d4b929a..332e0a7e6ed 100644
--- a/spec/frontend/releases/release_notification_service_spec.js
+++ b/spec/frontend/releases/release_notification_service_spec.js
@@ -1,56 +1,107 @@
import {
popCreateReleaseNotification,
putCreateReleaseNotification,
+ popDeleteReleaseNotification,
+ putDeleteReleaseNotification,
} from '~/releases/release_notification_service';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('~/releases/release_notification_service', () => {
const projectPath = 'test-project-path';
const releaseName = 'test-release-name';
- const storageKey = `createRelease:${projectPath}`;
+ describe('create release', () => {
+ const storageKey = `createRelease:${projectPath}`;
- describe('prepareCreateReleaseFlash', () => {
- it('should set the session storage with project path key and release name value', () => {
- putCreateReleaseNotification(projectPath, releaseName);
+ describe('prepareFlash', () => {
+ it('should set the session storage with project path key and release name value', () => {
+ putCreateReleaseNotification(projectPath, releaseName);
- const item = window.sessionStorage.getItem(storageKey);
+ const item = window.sessionStorage.getItem(storageKey);
- expect(item).toBe(releaseName);
+ expect(item).toBe(releaseName);
+ });
});
- });
- describe('showNotificationsIfPresent', () => {
- describe('if notification is prepared', () => {
- beforeEach(() => {
- window.sessionStorage.setItem(storageKey, releaseName);
- popCreateReleaseNotification(projectPath);
- });
+ describe('showNotificationsIfPresent', () => {
+ describe('if notification is prepared', () => {
+ beforeEach(() => {
+ window.sessionStorage.setItem(storageKey, releaseName);
+ popCreateReleaseNotification(projectPath);
+ });
- it('should remove storage key', () => {
- const item = window.sessionStorage.getItem(storageKey);
+ it('should remove storage key', () => {
+ const item = window.sessionStorage.getItem(storageKey);
+
+ expect(item).toBe(null);
+ });
- expect(item).toBe(null);
+ it('should create an alert message', () => {
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `Release ${releaseName} has been successfully created.`,
+ variant: VARIANT_SUCCESS,
+ });
+ });
});
- it('should create a flash message', () => {
- expect(createAlert).toHaveBeenCalledTimes(1);
- expect(createAlert).toHaveBeenCalledWith({
- message: `Release ${releaseName} has been successfully created.`,
- variant: VARIANT_SUCCESS,
+ describe('if notification is not prepared', () => {
+ beforeEach(() => {
+ popCreateReleaseNotification(projectPath);
+ });
+
+ it('should not create an alert message', () => {
+ expect(createAlert).toHaveBeenCalledTimes(0);
});
});
});
+ });
+
+ describe('delete release', () => {
+ const storageKey = `deleteRelease:${projectPath}`;
+
+ describe('prepareFlash', () => {
+ it('should set the session storage with project path key and release name value', () => {
+ putDeleteReleaseNotification(projectPath, releaseName);
+
+ const item = window.sessionStorage.getItem(storageKey);
+
+ expect(item).toBe(releaseName);
+ });
+ });
+
+ describe('showNotificationsIfPresent', () => {
+ describe('if notification is prepared', () => {
+ beforeEach(() => {
+ window.sessionStorage.setItem(storageKey, releaseName);
+ popDeleteReleaseNotification(projectPath);
+ });
- describe('if notification is not prepared', () => {
- beforeEach(() => {
- popCreateReleaseNotification(projectPath);
+ it('should remove storage key', () => {
+ const item = window.sessionStorage.getItem(storageKey);
+
+ expect(item).toBe(null);
+ });
+
+ it('should create an alert message', () => {
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `Release ${releaseName} has been successfully deleted.`,
+ variant: VARIANT_SUCCESS,
+ });
+ });
});
- it('should not create a flash message', () => {
- expect(createAlert).toHaveBeenCalledTimes(0);
+ describe('if notification is not prepared', () => {
+ beforeEach(() => {
+ popDeleteReleaseNotification(projectPath);
+ });
+
+ it('should not create an alert message', () => {
+ expect(createAlert).toHaveBeenCalledTimes(0);
+ });
});
});
});
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index ca3b2d5f734..1d164b9f5c1 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -2,8 +2,8 @@ import { cloneDeep } from 'lodash';
import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json';
import testAction from 'helpers/vuex_action_helper';
import { getTag } from '~/api/tags_api';
-import { createAlert } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__ } from '~/locale';
import { ASSET_LINK_TYPE } from '~/releases/constants';
import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql';
@@ -13,17 +13,12 @@ import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.m
import * as actions from '~/releases/stores/modules/edit_new/actions';
import * as types from '~/releases/stores/modules/edit_new/mutation_types';
import createState from '~/releases/stores/modules/edit_new/state';
-import {
- gqClient,
- convertOneReleaseGraphQLResponse,
- deleteReleaseSessionKey,
-} from '~/releases/util';
+import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
+import { deleteReleaseSessionKey } from '~/releases/release_notification_service';
jest.mock('~/api/tags_api');
-jest.mock('~/flash');
-
-jest.mock('~/releases/release_notification_service');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
@@ -154,7 +149,7 @@ describe('Release edit/new actions', () => {
]);
});
- it(`shows a flash message`, () => {
+ it(`shows an alert message`, () => {
return actions.fetchRelease({ commit: jest.fn(), state, rootState: state }).then(() => {
expect(createAlert).toHaveBeenCalledTimes(1);
expect(createAlert).toHaveBeenCalledWith({
@@ -311,8 +306,8 @@ describe('Release edit/new actions', () => {
it("redirects to the release's dedicated page", () => {
const { selfUrl } = releaseResponse.data.project.release.links;
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state }, selfUrl);
- expect(redirectTo).toHaveBeenCalledTimes(1);
- expect(redirectTo).toHaveBeenCalledWith(selfUrl);
+ expect(redirectTo).toHaveBeenCalledTimes(1); // eslint-disable-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith(selfUrl); // eslint-disable-line import/no-deprecated
});
});
@@ -380,7 +375,7 @@ describe('Release edit/new actions', () => {
]);
});
- it(`shows a flash message`, () => {
+ it(`shows an alert message`, () => {
return actions
.createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} })
.then(() => {
@@ -406,7 +401,7 @@ describe('Release edit/new actions', () => {
]);
});
- it(`shows a flash message`, () => {
+ it(`shows an alert message`, () => {
return actions
.createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} })
.then(() => {
@@ -538,7 +533,7 @@ describe('Release edit/new actions', () => {
expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]);
});
- it('shows a flash message', async () => {
+ it('shows an alert message', async () => {
await actions.updateRelease({ commit, dispatch, state, getters });
expect(createAlert).toHaveBeenCalledTimes(1);
@@ -558,7 +553,7 @@ describe('Release edit/new actions', () => {
]);
});
- it('shows a flash message', async () => {
+ it('shows an alert message', async () => {
await actions.updateRelease({ commit, dispatch, state, getters });
expect(createAlert).toHaveBeenCalledTimes(1);
@@ -711,7 +706,7 @@ describe('Release edit/new actions', () => {
expect(commit.mock.calls).toContainEqual([types.RECEIVE_SAVE_RELEASE_ERROR, error]);
});
- it('shows a flash message', async () => {
+ it('shows an alert message', async () => {
await actions.deleteRelease({ commit, dispatch, state, getters });
expect(createAlert).toHaveBeenCalledTimes(1);
@@ -747,7 +742,7 @@ describe('Release edit/new actions', () => {
]);
});
- it('shows a flash message', async () => {
+ it('shows an alert message', async () => {
await actions.deleteRelease({ commit, dispatch, state, getters });
expect(createAlert).toHaveBeenCalledTimes(1);
@@ -778,7 +773,7 @@ describe('Release edit/new actions', () => {
expect(getTag).toHaveBeenCalledWith(state.projectId, tagName);
});
- it('creates a flash on error', async () => {
+ it('creates an alert on error', async () => {
error = new Error();
getTag.mockRejectedValue(error);
diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js
index f8b87ec71dc..736eae13fb3 100644
--- a/spec/frontend/releases/stores/modules/detail/getters_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js
@@ -1,5 +1,16 @@
import { s__ } from '~/locale';
import * as getters from '~/releases/stores/modules/edit_new/getters';
+import { i18n } from '~/releases/constants';
+import { validateTag, ValidationResult } from '~/lib/utils/ref_validator';
+
+jest.mock('~/lib/utils/ref_validator', () => {
+ const original = jest.requireActual('~/lib/utils/ref_validator');
+ return {
+ __esModule: true,
+ ValidationResult: original.ValidationResult,
+ validateTag: jest.fn(() => new original.ValidationResult()),
+ };
+});
describe('Release edit/new getters', () => {
describe('releaseLinksToCreate', () => {
@@ -59,23 +70,23 @@ describe('Release edit/new getters', () => {
});
describe('validationErrors', () => {
+ const validState = {
+ release: {
+ tagName: 'test-tag-name',
+ assets: {
+ links: [
+ { id: 1, url: 'https://example.com/valid', name: 'Link 1' },
+ { id: 2, url: '', name: '' },
+ { id: 3, url: '', name: ' ' },
+ { id: 4, url: ' ', name: '' },
+ { id: 5, url: ' ', name: ' ' },
+ ],
+ },
+ },
+ };
describe('when the form is valid', () => {
+ const state = validState;
it('returns no validation errors', () => {
- const state = {
- release: {
- tagName: 'test-tag-name',
- assets: {
- links: [
- { id: 1, url: 'https://example.com/valid', name: 'Link 1' },
- { id: 2, url: '', name: '' },
- { id: 3, url: '', name: ' ' },
- { id: 4, url: ' ', name: '' },
- { id: 5, url: ' ', name: ' ' },
- ],
- },
- },
- };
-
const expectedErrors = {
assets: {
links: {
@@ -88,7 +99,27 @@ describe('Release edit/new getters', () => {
},
};
- expect(getters.validationErrors(state)).toEqual(expectedErrors);
+ expect(getters.validationErrors(state).assets).toEqual(expectedErrors.assets);
+ expect(getters.validationErrors(state).tagNameValidation.isValid).toBe(true);
+ });
+ });
+
+ describe('when validating tag', () => {
+ const state = validState;
+ it('validateTag is called with right parameters', () => {
+ getters.validationErrors(state);
+ expect(validateTag).toHaveBeenCalledWith(state.release.tagName);
+ });
+
+ it('validation error is correctly returned', () => {
+ const validationError = new ValidationResult();
+ const errorText = 'Tag format validation error';
+ validationError.addValidationError(errorText);
+ validateTag.mockReturnValue(validationError);
+
+ const result = getters.validationErrors(state);
+ expect(validateTag).toHaveBeenCalledWith(state.release.tagName);
+ expect(result.tagNameValidation.validationErrors).toContain(errorText);
});
});
@@ -140,19 +171,17 @@ describe('Release edit/new getters', () => {
});
it('returns a validation error if the tag name is empty', () => {
- const expectedErrors = {
- isTagNameEmpty: true,
- };
-
- expect(actualErrors).toMatchObject(expectedErrors);
+ expect(actualErrors.tagNameValidation.isValid).toBe(false);
+ expect(actualErrors.tagNameValidation.validationErrors).toContain(
+ i18n.tagNameIsRequiredMessage,
+ );
});
it('returns a validation error if the tag has an existing release', () => {
- const expectedErrors = {
- existingRelease: true,
- };
-
- expect(actualErrors).toMatchObject(expectedErrors);
+ expect(actualErrors.tagNameValidation.isValid).toBe(false);
+ expect(actualErrors.tagNameValidation.validationErrors).toContain(
+ i18n.tagIsAlredyInUseMessage,
+ );
});
it('returns a validation error if links share a URL', () => {
@@ -395,14 +424,27 @@ describe('Release edit/new getters', () => {
describe('formattedReleaseNotes', () => {
it.each`
- description | includeTagNotes | tagNotes | included
- ${'release notes'} | ${true} | ${'tag notes'} | ${true}
- ${'release notes'} | ${true} | ${''} | ${false}
- ${'release notes'} | ${false} | ${'tag notes'} | ${false}
+ description | includeTagNotes | tagNotes | included | showCreateFrom
+ ${'release notes'} | ${true} | ${'tag notes'} | ${true} | ${false}
+ ${'release notes'} | ${true} | ${''} | ${false} | ${false}
+ ${'release notes'} | ${false} | ${'tag notes'} | ${false} | ${false}
+ ${'release notes'} | ${true} | ${'tag notes'} | ${true} | ${true}
+ ${'release notes'} | ${true} | ${''} | ${false} | ${true}
+ ${'release notes'} | ${false} | ${'tag notes'} | ${false} | ${true}
`(
- 'should include tag notes=$included when includeTagNotes=$includeTagNotes and tagNotes=$tagNotes',
- ({ description, includeTagNotes, tagNotes, included }) => {
- const state = { release: { description }, includeTagNotes, tagNotes };
+ 'should include tag notes=$included when includeTagNotes=$includeTagNotes and tagNotes=$tagNotes and showCreateFrom=$showCreateFrom',
+ ({ description, includeTagNotes, tagNotes, included, showCreateFrom }) => {
+ let state;
+
+ if (showCreateFrom) {
+ state = {
+ release: { description, tagMessage: tagNotes },
+ includeTagNotes,
+ showCreateFrom,
+ };
+ } else {
+ state = { release: { description }, includeTagNotes, tagNotes, showCreateFrom };
+ }
const text = `### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`;
if (included) {
diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js
index e56975d021a..22ef552c2f9 100644
--- a/spec/frontend/repository/commits_service_spec.js
+++ b/spec/frontend/repository/commits_service_spec.js
@@ -2,11 +2,11 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { I18N_COMMIT_DATA_FETCH_ERROR } from '~/repository/constants';
import { refWithSpecialCharMock } from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('commits service', () => {
let mock;
diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
index 6fe60f3c2e6..6825d4afecf 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -8,6 +8,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
class="gl-my-2 gl-mr-4"
imgalt=""
imgcssclasses=""
+ imgcsswrapperclasses=""
imgsize="32"
imgsrc="https://test.com"
linkhref="/test"
@@ -47,6 +48,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
<timeago-tooltip-stub
cssclass=""
+ datetimeformat="DATE_WITH_TIME_FORMAT"
time="2019-01-01"
tooltipplacement="bottom"
/>
diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js
index 33a85c04fcf..2c63deb99c9 100644
--- a/spec/frontend/repository/components/blob_button_group_spec.js
+++ b/spec/frontend/repository/components/blob_button_group_spec.js
@@ -1,5 +1,6 @@
import { GlButton } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
import BlobButtonGroup from '~/repository/components/blob_button_group.vue';
import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
@@ -26,7 +27,24 @@ const DEFAULT_INJECT = {
describe('BlobButtonGroup component', () => {
let wrapper;
+ let showUploadBlobModalMock;
+ let showDeleteBlobModalMock;
+
const createComponent = (props = {}) => {
+ showUploadBlobModalMock = jest.fn();
+ showDeleteBlobModalMock = jest.fn();
+
+ const UploadBlobModalStub = stubComponent(UploadBlobModal, {
+ methods: {
+ show: showUploadBlobModalMock,
+ },
+ });
+ const DeleteBlobModalStub = stubComponent(DeleteBlobModal, {
+ methods: {
+ show: showDeleteBlobModalMock,
+ },
+ });
+
wrapper = mountExtended(BlobButtonGroup, {
propsData: {
...DEFAULT_PROPS,
@@ -35,13 +53,13 @@ describe('BlobButtonGroup component', () => {
provide: {
...DEFAULT_INJECT,
},
+ stubs: {
+ UploadBlobModal: UploadBlobModalStub,
+ DeleteBlobModal: DeleteBlobModalStub,
+ },
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal);
const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal);
const findDeleteButton = () => wrapper.findByTestId('delete');
@@ -61,8 +79,6 @@ describe('BlobButtonGroup component', () => {
describe('buttons', () => {
beforeEach(() => {
createComponent();
- jest.spyOn(findUploadBlobModal().vm, 'show');
- jest.spyOn(findDeleteBlobModal().vm, 'show');
});
it('renders both the replace and delete button', () => {
@@ -77,33 +93,31 @@ describe('BlobButtonGroup component', () => {
it('triggers the UploadBlobModal from the replace button', () => {
findReplaceButton().trigger('click');
- expect(findUploadBlobModal().vm.show).toHaveBeenCalled();
+ expect(showUploadBlobModalMock).toHaveBeenCalled();
});
it('triggers the DeleteBlobModal from the delete button', () => {
findDeleteButton().trigger('click');
- expect(findDeleteBlobModal().vm.show).toHaveBeenCalled();
+ expect(showDeleteBlobModalMock).toHaveBeenCalled();
});
describe('showForkSuggestion set to true', () => {
beforeEach(() => {
createComponent({ showForkSuggestion: true });
- jest.spyOn(findUploadBlobModal().vm, 'show');
- jest.spyOn(findDeleteBlobModal().vm, 'show');
});
it('does not trigger the UploadBlobModal from the replace button', () => {
findReplaceButton().trigger('click');
- expect(findUploadBlobModal().vm.show).not.toHaveBeenCalled();
+ expect(showUploadBlobModalMock).not.toHaveBeenCalled();
expect(wrapper.emitted().fork).toHaveLength(1);
});
it('does not trigger the DeleteBlobModal from the delete button', () => {
findDeleteButton().trigger('click');
- expect(findDeleteBlobModal().vm.show).not.toHaveBeenCalled();
+ expect(showDeleteBlobModalMock).not.toHaveBeenCalled();
expect(wrapper.emitted().fork).toHaveLength(1);
});
});
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index 03a8ee6ac5d..7e14d292946 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -175,7 +175,6 @@ describe('Blob content viewer component', () => {
});
afterEach(() => {
- wrapper.destroy();
mockAxios.reset();
});
@@ -482,11 +481,6 @@ describe('Blob content viewer component', () => {
repository: { empty },
} = projectMock;
- afterEach(() => {
- delete gon.current_user_id;
- delete gon.current_username;
- });
-
it('renders component', async () => {
window.gon.current_user_id = 1;
window.gon.current_username = 'root';
@@ -557,12 +551,12 @@ describe('Blob content viewer component', () => {
it('simple edit redirects to the simple editor', () => {
findWebIdeLink().vm.$emit('edit', 'simple');
- expect(urlUtility.redirectTo).toHaveBeenCalledWith(simpleViewerMock.editBlobPath);
+ expect(urlUtility.redirectTo).toHaveBeenCalledWith(simpleViewerMock.editBlobPath); // eslint-disable-line import/no-deprecated
});
it('IDE edit redirects to the IDE editor', () => {
findWebIdeLink().vm.$emit('edit', 'ide');
- expect(urlUtility.redirectTo).toHaveBeenCalledWith(simpleViewerMock.ideEditPath);
+ expect(urlUtility.redirectTo).toHaveBeenCalledWith(simpleViewerMock.ideEditPath); // eslint-disable-line import/no-deprecated
});
it.each`
diff --git a/spec/frontend/repository/components/blob_controls_spec.js b/spec/frontend/repository/components/blob_controls_spec.js
index 0d52542397f..3ced5f6c4d2 100644
--- a/spec/frontend/repository/components/blob_controls_spec.js
+++ b/spec/frontend/repository/components/blob_controls_spec.js
@@ -50,8 +50,6 @@ describe('Blob controls component', () => {
beforeEach(() => createComponent());
- afterEach(() => wrapper.destroy());
-
it('renders a find button with the correct href', () => {
expect(findFindButton().attributes('href')).toBe('find/file.js');
});
diff --git a/spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js
index 599443bf862..b4f4b0058de 100644
--- a/spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js
@@ -21,8 +21,6 @@ describe('LFS Viewer', () => {
beforeEach(() => createComponent());
- afterEach(() => wrapper.destroy());
-
it('renders the correct text', () => {
expect(wrapper.text()).toBe(
'This content could not be displayed because it is stored in LFS. You can download it instead.',
diff --git a/spec/frontend/repository/components/blob_viewers/notebook_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/notebook_viewer_spec.js
index 51f3d31ec72..5d37692bf90 100644
--- a/spec/frontend/repository/components/blob_viewers/notebook_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_viewers/notebook_viewer_spec.js
@@ -1,7 +1,6 @@
-import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import NotebookViewer from '~/repository/components/blob_viewers/notebook_viewer.vue';
-import notebookLoader from '~/blob/notebook';
+import Notebook from '~/blob/notebook/notebook_viewer.vue';
jest.mock('~/blob/notebook');
@@ -17,24 +16,11 @@ describe('Notebook Viewer', () => {
});
};
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findNotebookWrapper = () => wrapper.findByTestId('notebook');
+ const findNotebook = () => wrapper.findComponent(Notebook);
beforeEach(() => createComponent());
- it('calls the notebook loader', () => {
- expect(notebookLoader).toHaveBeenCalledWith({
- el: wrapper.vm.$refs.viewer,
- relativeRawPath: ROOT_RELATIVE_PATH,
- });
- });
-
- it('renders a loading icon component', () => {
- expect(findLoadingIcon().props('size')).toBe('lg');
- });
-
- it('renders the notebook wrapper', () => {
- expect(findNotebookWrapper().exists()).toBe(true);
- expect(findNotebookWrapper().attributes('data-endpoint')).toBe(DEFAULT_BLOB_DATA.rawPath);
+ it('renders a Notebook component', () => {
+ expect(findNotebook().props('endpoint')).toBe(DEFAULT_BLOB_DATA.rawPath);
});
});
diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js
index c2f34f79f89..f4baa817d32 100644
--- a/spec/frontend/repository/components/breadcrumbs_spec.js
+++ b/spec/frontend/repository/components/breadcrumbs_spec.js
@@ -1,27 +1,60 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import { GlDropdown } from '@gitlab/ui';
import { shallowMount, RouterLinkStub } from '@vue/test-utils';
-import { nextTick } from 'vue';
import Breadcrumbs from '~/repository/components/breadcrumbs.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
import NewDirectoryModal from '~/repository/components/new_directory_modal.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
+import projectPathQuery from '~/repository/queries/project_path.query.graphql';
+
+import createApolloProvider from 'helpers/mock_apollo_helper';
const defaultMockRoute = {
name: 'blobPath',
};
+const TEST_PROJECT_PATH = 'test-project/path';
+
+Vue.use(VueApollo);
+
describe('Repository breadcrumbs component', () => {
let wrapper;
-
- const factory = (currentPath, extraProps = {}, mockRoute = {}) => {
- const $apollo = {
- queries: {
+ let permissionsQuerySpy;
+
+ const createPermissionsQueryResponse = ({
+ pushCode = false,
+ forkProject = false,
+ createMergeRequestIn = false,
+ } = {}) => ({
+ data: {
+ project: {
+ id: 1,
+ __typename: '__typename',
userPermissions: {
- loading: true,
+ __typename: '__typename',
+ pushCode,
+ forkProject,
+ createMergeRequestIn,
},
},
- };
+ },
+ });
+
+ const factory = (currentPath, extraProps = {}, mockRoute = {}) => {
+ const apolloProvider = createApolloProvider([[permissionsQuery, permissionsQuerySpy]]);
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: projectPathQuery,
+ data: {
+ projectPath: TEST_PROJECT_PATH,
+ },
+ });
wrapper = shallowMount(Breadcrumbs, {
+ apolloProvider,
propsData: {
currentPath,
...extraProps,
@@ -34,16 +67,28 @@ describe('Repository breadcrumbs component', () => {
defaultMockRoute,
...mockRoute,
},
- $apollo,
},
});
};
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal);
const findNewDirectoryModal = () => wrapper.findComponent(NewDirectoryModal);
+ const findRouterLink = () => wrapper.findAllComponents(RouterLinkStub);
+
+ beforeEach(() => {
+ permissionsQuerySpy = jest.fn().mockResolvedValue(createPermissionsQueryResponse());
+ });
+
+ it('queries for permissions', async () => {
+ factory('/');
- afterEach(() => {
- wrapper.destroy();
+ // We need to wait for the projectPath query to resolve
+ await waitForPromises();
+
+ expect(permissionsQuerySpy).toHaveBeenCalledWith({
+ projectPath: TEST_PROJECT_PATH,
+ });
});
it.each`
@@ -55,7 +100,7 @@ describe('Repository breadcrumbs component', () => {
`('renders $linkCount links for path $path', ({ path, linkCount }) => {
factory(path);
- expect(wrapper.findAllComponents(RouterLinkStub).length).toEqual(linkCount);
+ expect(findRouterLink().length).toEqual(linkCount);
});
it.each`
@@ -68,36 +113,27 @@ describe('Repository breadcrumbs component', () => {
'links to the correct router path when routeName is $routeName',
({ routeName, path, linkTo }) => {
factory(path, {}, { name: routeName });
- expect(wrapper.findAllComponents(RouterLinkStub).at(3).props('to')).toEqual(linkTo);
+ expect(findRouterLink().at(3).props('to')).toEqual(linkTo);
},
);
it('escapes hash in directory path', () => {
factory('app/assets/javascripts#');
- expect(wrapper.findAllComponents(RouterLinkStub).at(3).props('to')).toEqual(
- '/-/tree/app/assets/javascripts%23',
- );
+ expect(findRouterLink().at(3).props('to')).toEqual('/-/tree/app/assets/javascripts%23');
});
it('renders last link as active', () => {
factory('app/assets');
- expect(wrapper.findAllComponents(RouterLinkStub).at(2).attributes('aria-current')).toEqual(
- 'page',
- );
+ expect(findRouterLink().at(2).attributes('aria-current')).toEqual('page');
});
it('does not render add to tree dropdown when permissions are false', async () => {
- factory('/', { canCollaborate: false });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } });
-
+ factory('/', { canCollaborate: false }, {});
await nextTick();
- expect(wrapper.findComponent(GlDropdown).exists()).toBe(false);
+ expect(findDropdown().exists()).toBe(false);
});
it.each`
@@ -111,20 +147,19 @@ describe('Repository breadcrumbs component', () => {
'does render add to tree dropdown $isRendered when route is $routeName',
({ routeName, isRendered }) => {
factory('app/assets/javascripts.js', { canCollaborate: true }, { name: routeName });
- expect(wrapper.findComponent(GlDropdown).exists()).toBe(isRendered);
+ expect(findDropdown().exists()).toBe(isRendered);
},
);
it('renders add to tree dropdown when permissions are true', async () => {
- factory('/', { canCollaborate: true });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } });
+ permissionsQuerySpy.mockResolvedValue(
+ createPermissionsQueryResponse({ forkProject: true, createMergeRequestIn: true }),
+ );
+ factory('/', { canCollaborate: true });
await nextTick();
- expect(wrapper.findComponent(GlDropdown).exists()).toBe(true);
+ expect(findDropdown().exists()).toBe(true);
});
describe('renders the upload blob modal', () => {
@@ -137,10 +172,6 @@ describe('Repository breadcrumbs component', () => {
});
it('renders the modal once loaded', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } });
-
await nextTick();
expect(findUploadBlobModal().exists()).toBe(true);
@@ -156,10 +187,6 @@ describe('Repository breadcrumbs component', () => {
});
it('renders the modal once loaded', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } });
-
await nextTick();
expect(findNewDirectoryModal().exists()).toBe(true);
diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js
index b5996816ad8..90f2150222c 100644
--- a/spec/frontend/repository/components/delete_blob_modal_spec.js
+++ b/spec/frontend/repository/components/delete_blob_modal_spec.js
@@ -49,10 +49,6 @@ describe('DeleteBlobModal', () => {
await findCommitTextarea().vm.$emit('input', commitText);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders Modal component', () => {
createComponent();
@@ -186,13 +182,13 @@ describe('DeleteBlobModal', () => {
await fillForm({ targetText: '', commitText: '' });
});
- it('disables submit button', async () => {
- expect(findModal().props('actionPrimary').attributes[0]).toEqual(
+ it('disables submit button', () => {
+ expect(findModal().props('actionPrimary').attributes).toEqual(
expect.objectContaining({ disabled: true }),
);
});
- it('does not submit form', async () => {
+ it('does not submit form', () => {
findModal().vm.$emit('primary', { preventDefault: () => {} });
expect(submitSpy).not.toHaveBeenCalled();
});
@@ -206,13 +202,13 @@ describe('DeleteBlobModal', () => {
});
});
- it('enables submit button', async () => {
- expect(findModal().props('actionPrimary').attributes[0]).toEqual(
+ it('enables submit button', () => {
+ expect(findModal().props('actionPrimary').attributes).toEqual(
expect.objectContaining({ disabled: false }),
);
});
- it('submits form', async () => {
+ it('submits form', () => {
findModal().vm.$emit('primary', { preventDefault: () => {} });
expect(submitSpy).toHaveBeenCalled();
});
diff --git a/spec/frontend/repository/components/directory_download_links_spec.js b/spec/frontend/repository/components/directory_download_links_spec.js
index 72c4165c2e9..3739829c759 100644
--- a/spec/frontend/repository/components/directory_download_links_spec.js
+++ b/spec/frontend/repository/components/directory_download_links_spec.js
@@ -16,10 +16,6 @@ function factory(currentPath) {
}
describe('Repository directory download links component', () => {
- afterEach(() => {
- vm.destroy();
- });
-
it.each`
path
${'app'}
diff --git a/spec/frontend/repository/components/fork_info_spec.js b/spec/frontend/repository/components/fork_info_spec.js
index f327a8cfae7..62a66e59d24 100644
--- a/spec/frontend/repository/components/fork_info_spec.js
+++ b/spec/frontend/repository/components/fork_info_spec.js
@@ -1,42 +1,82 @@
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import { GlSkeletonLoader, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlSkeletonLoader, GlIcon, GlLink, GlSprintf, GlButton, GlLoadingIcon } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { createAlert } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import ForkInfo, { i18n } from '~/repository/components/fork_info.vue';
+import ConflictsModal from '~/repository/components/fork_sync_conflicts_modal.vue';
import forkDetailsQuery from '~/repository/queries/fork_details.query.graphql';
+import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql';
+import eventHub from '~/repository/event_hub';
+import {
+ POLLING_INTERVAL_DEFAULT,
+ POLLING_INTERVAL_BACKOFF,
+ FORK_UPDATED_EVENT,
+} from '~/repository/constants';
import { propsForkInfo } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('ForkInfo component', () => {
let wrapper;
- let mockResolver;
+ let mockForkDetailsQuery;
const forkInfoError = new Error('Something went wrong');
const projectId = 'gid://gitlab/Project/1';
+ const showMock = jest.fn();
+ const synchronizeFork = true;
Vue.use(VueApollo);
- const createCommitData = ({ ahead = 3, behind = 7 }) => {
+ const waitForPolling = async (interval = POLLING_INTERVAL_DEFAULT) => {
+ jest.advanceTimersByTime(interval);
+ await waitForPromises();
+ };
+
+ const mockResolvedForkDetailsQuery = (
+ forkDetails = { ahead: 3, behind: 7, isSyncing: false, hasConflicts: false },
+ ) => {
+ mockForkDetailsQuery.mockResolvedValue({
+ data: {
+ project: { id: projectId, forkDetails },
+ },
+ });
+ };
+
+ const createSyncForkDetailsData = (
+ forkDetails = { ahead: 3, behind: 7, isSyncing: false, hasConflicts: false },
+ ) => {
return {
data: {
- project: { id: projectId, forkDetails: { ahead, behind, __typename: 'ForkDetails' } },
+ projectSyncFork: { details: forkDetails, errors: [] },
},
};
};
- const createComponent = (props = {}, data = {}, isRequestFailed = false) => {
- mockResolver = isRequestFailed
- ? jest.fn().mockRejectedValue(forkInfoError)
- : jest.fn().mockResolvedValue(createCommitData(data));
-
+ const createComponent = (props = {}, mutationData = {}) => {
wrapper = shallowMountExtended(ForkInfo, {
- apolloProvider: createMockApollo([[forkDetailsQuery, mockResolver]]),
+ apolloProvider: createMockApollo([
+ [forkDetailsQuery, mockForkDetailsQuery],
+ [syncForkMutation, jest.fn().mockResolvedValue(createSyncForkDetailsData(mutationData))],
+ ]),
propsData: { ...propsForkInfo, ...props },
- stubs: { GlSprintf },
+ stubs: {
+ GlSprintf,
+ GlButton,
+ ConflictsModal: stubComponent(ConflictsModal, {
+ template:
+ '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
+ methods: { show: showMock },
+ }),
+ },
+ provide: {
+ glFeatures: {
+ synchronizeFork,
+ },
+ },
});
return waitForPromises();
};
@@ -44,11 +84,25 @@ describe('ForkInfo component', () => {
const findLink = () => wrapper.findComponent(GlLink);
const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader);
const findIcon = () => wrapper.findComponent(GlIcon);
+ const findUpdateForkButton = () => wrapper.findByTestId('update-fork-button');
+ const findCreateMrButton = () => wrapper.findByTestId('create-mr-button');
+ const findViewMrButton = () => wrapper.findByTestId('view-mr-button');
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findDivergenceMessage = () => wrapper.findByTestId('divergence-message');
const findInaccessibleMessage = () => wrapper.findByTestId('inaccessible-project');
const findCompareLinks = () => findDivergenceMessage().findAllComponents(GlLink);
- it('displays a skeleton while loading data', async () => {
+ const startForkUpdate = async () => {
+ findUpdateForkButton().vm.$emit('click');
+ await waitForPromises();
+ };
+
+ beforeEach(() => {
+ mockForkDetailsQuery = jest.fn();
+ mockResolvedForkDetailsQuery();
+ });
+
+ it('displays a skeleton while loading data', () => {
createComponent();
expect(findSkeleton().exists()).toBe(true);
});
@@ -65,12 +119,12 @@ describe('ForkInfo component', () => {
it('queries the data when sourceName is present', async () => {
await createComponent();
- expect(mockResolver).toHaveBeenCalled();
+ expect(mockForkDetailsQuery).toHaveBeenCalled();
});
it('does not query the data when sourceName is empty', async () => {
await createComponent({ sourceName: null });
- expect(mockResolver).not.toHaveBeenCalled();
+ expect(mockForkDetailsQuery).not.toHaveBeenCalled();
});
it('renders inaccessible message when fork source is not available', async () => {
@@ -87,14 +141,99 @@ describe('ForkInfo component', () => {
expect(link.attributes('href')).toBe(propsForkInfo.sourcePath);
});
- it('renders unknown divergence message when divergence is unknown', async () => {
- await createComponent({}, { ahead: null, behind: null });
- expect(findDivergenceMessage().text()).toBe(i18n.unknown);
+ it('renders Create MR Button with correct path', async () => {
+ await createComponent();
+ expect(findCreateMrButton().attributes('href')).toBe(propsForkInfo.createMrPath);
+ });
+
+ it('renders View MR Button with correct path', async () => {
+ const viewMrPath = 'path/to/view/mr';
+ await createComponent({ viewMrPath });
+ expect(findViewMrButton().attributes('href')).toBe(viewMrPath);
});
- it('renders up to date message when divergence is unknown', async () => {
- await createComponent({}, { ahead: 0, behind: 0 });
- expect(findDivergenceMessage().text()).toBe(i18n.upToDate);
+ it('does not render create MR button if create MR path is blank', async () => {
+ await createComponent({ createMrPath: '' });
+ expect(findCreateMrButton().exists()).toBe(false);
+ });
+
+ it('renders alert with error message when request fails', async () => {
+ mockForkDetailsQuery.mockRejectedValue(forkInfoError);
+ await createComponent({});
+ expect(createAlert).toHaveBeenCalledWith({
+ message: i18n.error,
+ captureError: true,
+ error: forkInfoError,
+ });
+ });
+
+ describe('Unknown divergence', () => {
+ it('renders unknown divergence message when divergence is unknown', async () => {
+ mockResolvedForkDetailsQuery({
+ ahead: null,
+ behind: null,
+ isSyncing: false,
+ hasConflicts: false,
+ });
+ await createComponent({});
+ expect(findDivergenceMessage().text()).toBe(i18n.unknown);
+ });
+
+ it('renders Update Fork button', async () => {
+ mockResolvedForkDetailsQuery({
+ ahead: null,
+ behind: null,
+ isSyncing: false,
+ hasConflicts: false,
+ });
+ await createComponent({});
+ expect(findUpdateForkButton().exists()).toBe(true);
+ expect(findUpdateForkButton().text()).toBe(i18n.updateFork);
+ });
+ });
+
+ describe('Up to date divergence', () => {
+ beforeEach(async () => {
+ mockResolvedForkDetailsQuery({ ahead: 0, behind: 0, isSyncing: false, hasConflicts: false });
+ await createComponent({}, { ahead: 0, behind: 0, isSyncing: false, hasConflicts: false });
+ });
+
+ it('renders up to date message when fork is up to date', () => {
+ expect(findDivergenceMessage().text()).toBe(i18n.upToDate);
+ });
+
+ it('does not render Update Fork button', () => {
+ expect(findUpdateForkButton().exists()).toBe(false);
+ });
+ });
+
+ describe('Limited visibility project', () => {
+ beforeEach(async () => {
+ mockResolvedForkDetailsQuery(null);
+ await createComponent({}, null);
+ });
+
+ it('renders limited visibility message when forkDetails are empty', () => {
+ expect(findDivergenceMessage().text()).toBe(i18n.limitedVisibility);
+ });
+
+ it('does not render Update Fork button', () => {
+ expect(findUpdateForkButton().exists()).toBe(false);
+ });
+ });
+
+ describe('User cannot sync the branch', () => {
+ beforeEach(async () => {
+ mockResolvedForkDetailsQuery({ ahead: 0, behind: 7, isSyncing: false, hasConflicts: false });
+ await createComponent(
+ { canSyncBranch: false },
+ { ahead: 0, behind: 7, isSyncing: false, hasConflicts: false },
+ );
+ });
+
+ it('does not render Update Fork button', () => {
+ expect(findUpdateForkButton().exists()).toBe(false);
+ });
});
describe.each([
@@ -104,6 +243,8 @@ describe('ForkInfo component', () => {
message: '3 commits behind, 7 commits ahead of the upstream repository.',
firstLink: propsForkInfo.behindComparePath,
secondLink: propsForkInfo.aheadComparePath,
+ hasUpdateButton: true,
+ hasCreateMrButton: true,
},
{
ahead: 7,
@@ -111,6 +252,8 @@ describe('ForkInfo component', () => {
message: '7 commits ahead of the upstream repository.',
firstLink: propsForkInfo.aheadComparePath,
secondLink: '',
+ hasUpdateButton: false,
+ hasCreateMrButton: true,
},
{
ahead: 0,
@@ -118,12 +261,15 @@ describe('ForkInfo component', () => {
message: '3 commits behind the upstream repository.',
firstLink: propsForkInfo.behindComparePath,
secondLink: '',
+ hasUpdateButton: true,
+ hasCreateMrButton: false,
},
])(
'renders correct divergence message for ahead: $ahead, behind: $behind divergence commits',
- ({ ahead, behind, message, firstLink, secondLink }) => {
+ ({ ahead, behind, message, firstLink, secondLink, hasUpdateButton, hasCreateMrButton }) => {
beforeEach(async () => {
- await createComponent({}, { ahead, behind });
+ mockResolvedForkDetailsQuery({ ahead, behind, isSyncing: false, hasConflicts: false });
+ await createComponent({});
});
it('displays correct text', () => {
@@ -138,15 +284,99 @@ describe('ForkInfo component', () => {
expect(links.at(1).attributes('href')).toBe(secondLink);
}
});
+
+ it('renders Update Fork button when fork is behind', () => {
+ expect(findUpdateForkButton().exists()).toBe(hasUpdateButton);
+ if (hasUpdateButton) {
+ expect(findUpdateForkButton().text()).toBe(i18n.updateFork);
+ }
+ });
+
+ it('renders Create Merge Request button when fork is ahead', () => {
+ expect(findCreateMrButton().exists()).toBe(hasCreateMrButton);
+ if (hasCreateMrButton) {
+ expect(findCreateMrButton().text()).toBe(i18n.createMergeRequest);
+ }
+ });
},
);
- it('renders alert with error message when request fails', async () => {
- await createComponent({}, {}, true);
- expect(createAlert).toHaveBeenCalledWith({
- message: i18n.error,
- captureError: true,
- error: forkInfoError,
+ describe('when sync is not possible due to conflicts', () => {
+ it('Opens Conflicts Modal', async () => {
+ mockResolvedForkDetailsQuery({ ahead: 7, behind: 3, isSyncing: false, hasConflicts: true });
+ await createComponent({});
+ findUpdateForkButton().vm.$emit('click');
+ expect(showMock).toHaveBeenCalled();
+ });
+ });
+
+ describe('projectSyncFork mutation', () => {
+ it('changes button to have loading state', async () => {
+ await createComponent({}, { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false });
+ mockResolvedForkDetailsQuery({ ahead: 0, behind: 3, isSyncing: false, hasConflicts: false });
+ expect(findLoadingIcon().exists()).toBe(false);
+ await startForkUpdate();
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('polling', () => {
+ beforeEach(async () => {
+ await createComponent({}, { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false });
+ mockResolvedForkDetailsQuery({ ahead: 0, behind: 3, isSyncing: true, hasConflicts: false });
+ });
+
+ it('fetches data on the initial load', () => {
+ expect(mockForkDetailsQuery).toHaveBeenCalledTimes(1);
+ });
+
+ it('starts polling after sync button is clicked', async () => {
+ await startForkUpdate();
+ await waitForPolling();
+ expect(mockForkDetailsQuery).toHaveBeenCalledTimes(2);
+
+ await waitForPolling(POLLING_INTERVAL_DEFAULT * POLLING_INTERVAL_BACKOFF);
+ expect(mockForkDetailsQuery).toHaveBeenCalledTimes(3);
+ });
+
+ it('stops polling once sync is finished', async () => {
+ mockResolvedForkDetailsQuery({ ahead: 0, behind: 0, isSyncing: false, hasConflicts: false });
+ await startForkUpdate();
+ await waitForPolling();
+ expect(mockForkDetailsQuery).toHaveBeenCalledTimes(2);
+ await waitForPolling(POLLING_INTERVAL_DEFAULT * POLLING_INTERVAL_BACKOFF);
+ expect(mockForkDetailsQuery).toHaveBeenCalledTimes(2);
+ await nextTick();
+ });
+ });
+
+ describe('once fork is updated', () => {
+ beforeEach(async () => {
+ await createComponent({}, { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false });
+ mockResolvedForkDetailsQuery({ ahead: 0, behind: 0, isSyncing: false, hasConflicts: false });
+ });
+
+ it('shows info alert once the fork is updated', async () => {
+ await startForkUpdate();
+ await waitForPolling();
+ expect(createAlert).toHaveBeenCalledWith({
+ message: i18n.successMessage,
+ variant: VARIANT_INFO,
+ });
+ });
+
+ it('emits fork:updated event to eventHub', async () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+ await startForkUpdate();
+ await waitForPolling();
+ expect(eventHub.$emit).toHaveBeenCalledWith(FORK_UPDATED_EVENT);
+ });
+
+ it('hides update fork button', async () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+ await startForkUpdate();
+ await waitForPolling();
+ expect(findUpdateForkButton().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/repository/components/fork_suggestion_spec.js b/spec/frontend/repository/components/fork_suggestion_spec.js
index 36a48a3fdb8..a9e5c18c0a9 100644
--- a/spec/frontend/repository/components/fork_suggestion_spec.js
+++ b/spec/frontend/repository/components/fork_suggestion_spec.js
@@ -14,8 +14,6 @@ describe('ForkSuggestion component', () => {
beforeEach(() => createComponent());
- afterEach(() => wrapper.destroy());
-
const { i18n } = ForkSuggestion;
const findMessage = () => wrapper.findByTestId('message');
const findForkButton = () => wrapper.findByTestId('fork');
diff --git a/spec/frontend/repository/components/fork_sync_conflicts_modal_spec.js b/spec/frontend/repository/components/fork_sync_conflicts_modal_spec.js
new file mode 100644
index 00000000000..3fd9284e29b
--- /dev/null
+++ b/spec/frontend/repository/components/fork_sync_conflicts_modal_spec.js
@@ -0,0 +1,46 @@
+import { GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import ConflictsModal, { i18n } from '~/repository/components/fork_sync_conflicts_modal.vue';
+import { propsConflictsModal } from '../mock_data';
+
+describe('ConflictsModal', () => {
+ let wrapper;
+
+ function createComponent({ props = {} } = {}) {
+ wrapper = shallowMount(ConflictsModal, {
+ propsData: props,
+ stubs: { GlModal },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent({ props: propsConflictsModal });
+ });
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findInstructions = () => wrapper.findAll('[ data-testid="resolve-conflict-instructions"]');
+
+ it('renders a modal', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('passes title as a prop to a gl-modal component', () => {
+ expect(findModal().props().title).toBe(i18n.modalTitle);
+ });
+
+ it('renders a selection of markdown fields', () => {
+ expect(findInstructions().length).toBe(3);
+ });
+
+ it('renders a source url in a first intruction', () => {
+ expect(findInstructions().at(0).text()).toContain(propsConflictsModal.sourcePath);
+ });
+
+ it('renders default branch name in a first step intruction', () => {
+ expect(findInstructions().at(0).text()).toContain(propsConflictsModal.sourceDefaultBranch);
+ });
+
+ it('renders selected branch name in a second step intruction', () => {
+ expect(findInstructions().at(1).text()).toContain(propsConflictsModal.selectedBranch);
+ });
+});
diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js
index 7226e7baa36..c207d32d61d 100644
--- a/spec/frontend/repository/components/last_commit_spec.js
+++ b/spec/frontend/repository/components/last_commit_spec.js
@@ -4,10 +4,12 @@ import { GlLoadingIcon } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-
import LastCommit from '~/repository/components/last_commit.vue';
+import SignatureBadge from '~/commit/components/signature_badge.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import eventHub from '~/repository/event_hub';
import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
+import { FORK_UPDATED_EVENT } from '~/repository/constants';
import { refMock } from '../mock_data';
let wrapper;
@@ -20,7 +22,7 @@ const findUserAvatarLink = () => wrapper.findComponent(UserAvatarLink);
const findLastCommitLabel = () => wrapper.findByTestId('last-commit-id-label');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findCommitRowDescription = () => wrapper.find('.commit-row-description');
-const findStatusBox = () => wrapper.find('.signature-badge');
+const findStatusBox = () => wrapper.findComponent(SignatureBadge);
const findItemTitle = () => wrapper.find('.item-title');
const defaultPipelineEdges = [
@@ -56,7 +58,7 @@ const createCommitData = ({
pipelineEdges = defaultPipelineEdges,
author = defaultAuthor,
descriptionHtml = '',
- signatureHtml = null,
+ signature = null,
message = defaultMessage,
}) => {
return {
@@ -84,7 +86,7 @@ const createCommitData = ({
authorName: 'Test',
authorGravatar: 'https://test.com',
author,
- signatureHtml,
+ signature,
pipelines: {
__typename: 'PipelineConnection',
edges: pipelineEdges,
@@ -99,7 +101,7 @@ const createCommitData = ({
};
};
-const createComponent = async (data = {}) => {
+const createComponent = (data = {}) => {
Vue.use(VueApollo);
const currentPath = 'path';
@@ -110,11 +112,13 @@ const createComponent = async (data = {}) => {
apolloProvider: createMockApollo([[pathLastCommitQuery, mockResolver]]),
propsData: { currentPath },
mixins: [{ data: () => ({ ref: refMock }) }],
+ stubs: {
+ SignatureBadge,
+ },
});
};
afterEach(() => {
- wrapper.destroy();
mockResolver = null;
});
@@ -177,6 +181,25 @@ describe('Repository last commit component', () => {
expect(findCommitRowDescription().exists()).toBe(false);
});
+ describe('created', () => {
+ it('binds `epicsListScrolled` event listener via eventHub', () => {
+ jest.spyOn(eventHub, '$on').mockImplementation(() => {});
+ createComponent();
+
+ expect(eventHub.$on).toHaveBeenCalledWith(FORK_UPDATED_EVENT, expect.any(Function));
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('unbinds `epicsListScrolled` event listener via eventHub', () => {
+ jest.spyOn(eventHub, '$off').mockImplementation(() => {});
+ createComponent();
+ wrapper.destroy();
+
+ expect(eventHub.$off).toHaveBeenCalledWith(FORK_UPDATED_EVENT, expect.any(Function));
+ });
+ });
+
describe('when the description is present', () => {
beforeEach(async () => {
createComponent({ descriptionHtml: '&#x000A;Update ADOPTERS.md' });
@@ -204,23 +227,19 @@ describe('Repository last commit component', () => {
});
it('renders the signature HTML as returned by the backend', async () => {
+ const signatureResponse = {
+ __typename: 'GpgSignature',
+ gpgKeyPrimaryKeyid: 'xxx',
+ verificationStatus: 'VERIFIED',
+ };
createComponent({
- signatureHtml: `<a
- class="btn signature-badge"
- data-content="signature-content"
- data-html="true"
- data-placement="top"
- data-title="signature-title"
- data-toggle="popover"
- role="button"
- tabindex="0"
- ><span class="gl-badge badge badge-pill badge-success md">Verified</span></a>`,
+ signature: {
+ ...signatureResponse,
+ },
});
await waitForPromises();
- expect(findStatusBox().html()).toBe(
- `<a class="btn signature-badge" data-content="signature-content" data-html="true" data-placement="top" data-title="signature-title" data-toggle="popover" role="button" tabindex="0"><span class="gl-badge badge badge-pill badge-success md">Verified</span></a>`,
- );
+ expect(findStatusBox().props()).toMatchObject({ signature: signatureResponse });
});
it('sets correct CSS class if the commit message is empty', async () => {
diff --git a/spec/frontend/repository/components/new_directory_modal_spec.js b/spec/frontend/repository/components/new_directory_modal_spec.js
index 4e5c9a685c4..55a24089d48 100644
--- a/spec/frontend/repository/components/new_directory_modal_spec.js
+++ b/spec/frontend/repository/components/new_directory_modal_spec.js
@@ -4,12 +4,12 @@ import { nextTick } from 'vue';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
import NewDirectoryModal from '~/repository/components/new_directory_modal.vue';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
}));
@@ -76,10 +76,6 @@ describe('NewDirectoryModal', () => {
await waitForPromises();
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders modal component', () => {
createComponent();
@@ -128,7 +124,7 @@ describe('NewDirectoryModal', () => {
});
describe('form submission', () => {
- beforeEach(async () => {
+ beforeEach(() => {
mock = new MockAdapter(axios);
});
@@ -185,10 +181,10 @@ describe('NewDirectoryModal', () => {
it('disables submit button', async () => {
await fillForm({ dirName: '', branchName: '', commitMessage: '' });
- expect(findModal().props('actionPrimary').attributes[0].disabled).toBe(true);
+ expect(findModal().props('actionPrimary').attributes.disabled).toBe(true);
});
- it('creates a flash error', async () => {
+ it('creates an alert error', async () => {
mock.onPost(initialProps.path).timeout();
await fillForm({ dirName: 'foo', branchName: 'master', commitMessage: 'foo' });
diff --git a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap
deleted file mode 100644
index 48a4feca1e5..00000000000
--- a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap
+++ /dev/null
@@ -1,42 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Repository file preview component renders file HTML 1`] = `
-<article
- class="file-holder limited-width-container readme-holder"
->
- <div
- class="js-file-title file-title-flex-parent"
- >
- <div
- class="file-header-content"
- >
- <gl-icon-stub
- name="doc-text"
- size="16"
- />
-
- <gl-link-stub
- href="http://test.com"
- >
- <strong>
- README.md
- </strong>
- </gl-link-stub>
- </div>
- </div>
-
- <div
- class="blob-viewer"
- data-qa-selector="blob_viewer_content"
- itemprop="about"
- >
- <div>
- <div
- class="blob"
- >
- test
- </div>
- </div>
- </div>
-</article>
-`;
diff --git a/spec/frontend/repository/components/preview/index_spec.js b/spec/frontend/repository/components/preview/index_spec.js
index d4c746b67d6..316ddfb5731 100644
--- a/spec/frontend/repository/components/preview/index_spec.js
+++ b/spec/frontend/repository/components/preview/index_spec.js
@@ -1,77 +1,60 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { handleLocationHash } from '~/lib/utils/common_utils';
+import waitForPromises from 'helpers/wait_for_promises';
import Preview from '~/repository/components/preview/index.vue';
+const PROPS_DATA = {
+ blob: {
+ webPath: 'http://test.com',
+ name: 'README.md',
+ },
+};
+
+const MOCK_README_DATA = {
+ __typename: 'ReadmeFile',
+ html: '<div class="blob">test</div>',
+};
+
jest.mock('~/lib/utils/common_utils');
-let vm;
-let $apollo;
+Vue.use(VueApollo);
+
+let wrapper;
+let mockApollo;
+let mockReadmeData;
-function factory(blob, loading) {
- $apollo = {
- queries: {
- readme: {
- query: jest.fn().mockReturnValue(Promise.resolve({})),
- loading,
- },
- },
- };
+const mockResolvers = {
+ Query: {
+ readme: () => mockReadmeData(),
+ },
+};
- vm = shallowMount(Preview, {
- propsData: {
- blob,
- },
- mocks: {
- $apollo,
- },
+function createComponent() {
+ mockApollo = createMockApollo([], mockResolvers);
+
+ return shallowMount(Preview, {
+ propsData: PROPS_DATA,
+ apolloProvider: mockApollo,
});
}
describe('Repository file preview component', () => {
- afterEach(() => {
- vm.destroy();
- });
-
- it('renders file HTML', async () => {
- factory({
- webPath: 'http://test.com',
- name: 'README.md',
- });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ readme: { html: '<div class="blob">test</div>' } });
-
- await nextTick();
- expect(vm.element).toMatchSnapshot();
+ beforeEach(() => {
+ mockReadmeData = jest.fn();
+ wrapper = createComponent();
+ mockReadmeData.mockResolvedValue(MOCK_README_DATA);
});
it('handles hash after render', async () => {
- factory({
- webPath: 'http://test.com',
- name: 'README.md',
- });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ readme: { html: '<div class="blob">test</div>' } });
-
- await nextTick();
+ await waitForPromises();
expect(handleLocationHash).toHaveBeenCalled();
});
- it('renders loading icon', async () => {
- factory(
- {
- webPath: 'http://test.com',
- name: 'README.md',
- },
- true,
- );
-
- await nextTick();
- expect(vm.findComponent(GlLoadingIcon).exists()).toBe(true);
+ it('renders loading icon', () => {
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
});
diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
index b99d741e984..85bf683fdf6 100644
--- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
+++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
@@ -56,6 +56,7 @@ exports[`Repository table row component renders a symlink table row 1`] = `
>
<timeago-tooltip-stub
cssclass=""
+ datetimeformat="DATE_WITH_TIME_FORMAT"
time="2019-01-01"
tooltipplacement="top"
/>
@@ -121,6 +122,7 @@ exports[`Repository table row component renders table row 1`] = `
>
<timeago-tooltip-stub
cssclass=""
+ datetimeformat="DATE_WITH_TIME_FORMAT"
time="2019-01-01"
tooltipplacement="top"
/>
@@ -186,6 +188,7 @@ exports[`Repository table row component renders table row for path with special
>
<timeago-tooltip-stub
cssclass=""
+ datetimeformat="DATE_WITH_TIME_FORMAT"
time="2019-01-01"
tooltipplacement="top"
/>
diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js
index 8b987551b33..f7be367887c 100644
--- a/spec/frontend/repository/components/table/index_spec.js
+++ b/spec/frontend/repository/components/table/index_spec.js
@@ -88,10 +88,6 @@ function factory({ path, isLoading = false, hasMore = true, entries = {}, commit
const findTableRows = () => vm.findAllComponents(TableRow);
describe('Repository table component', () => {
- afterEach(() => {
- vm.destroy();
- });
-
it.each`
path | ref
${'/'} | ${'main'}
diff --git a/spec/frontend/repository/components/table/parent_row_spec.js b/spec/frontend/repository/components/table/parent_row_spec.js
index 03fb4242e40..77822a148b7 100644
--- a/spec/frontend/repository/components/table/parent_row_spec.js
+++ b/spec/frontend/repository/components/table/parent_row_spec.js
@@ -26,10 +26,6 @@ function factory(path, loadingPath) {
}
describe('Repository parent row component', () => {
- afterEach(() => {
- vm.destroy();
- });
-
it.each`
path | to
${'app'} | ${'/-/tree/main/'}
diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js
index 5d9138ab9cd..02b505c828c 100644
--- a/spec/frontend/repository/components/table/row_spec.js
+++ b/spec/frontend/repository/components/table/row_spec.js
@@ -1,7 +1,10 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { GlBadge, GlLink, GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import { shallowMount, RouterLinkStub } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import refQuery from '~/repository/queries/ref.query.graphql';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import createMockApollo from 'helpers/mock_apollo_helper';
import TableRow from '~/repository/components/table/row.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { FILE_SYMLINK_MODE } from '~/vue_shared/constants';
@@ -9,26 +12,40 @@ import { ROW_APPEAR_DELAY } from '~/repository/constants';
const COMMIT_MOCK = { lockLabel: 'Locked by Root', committedDate: '2019-01-01' };
-let vm;
+let wrapper;
let $router;
-function factory(propsData = {}) {
+const createMockApolloProvider = (mockData) => {
+ Vue.use(VueApollo);
+ const apolloProver = createMockApollo([]);
+ apolloProver.clients.defaultClient.cache.writeQuery({ query: refQuery, data: { ...mockData } });
+
+ return apolloProver;
+};
+
+function factory({ mockData = { ref: 'main', escapedRef: 'main' }, propsData = {} } = {}) {
$router = {
push: jest.fn(),
};
- vm = shallowMount(TableRow, {
+ wrapper = shallowMount(TableRow, {
+ apolloProvider: createMockApolloProvider(mockData),
propsData: {
+ id: '1',
+ sha: '0as4k',
commitInfo: COMMIT_MOCK,
- ...propsData,
- name: propsData.path,
+ name: 'name',
+ currentPath: 'gitlab-org/gitlab-ce',
projectPath: 'gitlab-org/gitlab-ce',
url: `https://test.com`,
totalEntries: 10,
rowNumber: 123,
+ path: 'gitlab-org/gitlab-ce',
+ type: 'tree',
+ ...propsData,
},
directives: {
- GlHoverLoad: createMockDirective(),
+ GlHoverLoad: createMockDirective('gl-hover-load'),
},
mocks: {
$router,
@@ -37,67 +54,67 @@ function factory(propsData = {}) {
RouterLink: RouterLinkStub,
},
});
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ escapedRef: 'main' });
}
describe('Repository table row component', () => {
- const findRouterLink = () => vm.findComponent(RouterLinkStub);
- const findIntersectionObserver = () => vm.findComponent(GlIntersectionObserver);
-
- afterEach(() => {
- vm.destroy();
- });
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findFileIcon = () => wrapper.findComponent(FileIcon);
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const findRouterLink = () => wrapper.findComponent(RouterLinkStub);
+ const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
- it('renders table row', async () => {
+ it('renders table row', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test',
- type: 'file',
- currentPath: '/',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type: 'file',
+ currentPath: '/',
+ },
});
- await nextTick();
- expect(vm.element).toMatchSnapshot();
+ expect(wrapper.element).toMatchSnapshot();
});
- it('renders a symlink table row', async () => {
+ it('renders a symlink table row', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test',
- type: 'blob',
- currentPath: '/',
- mode: FILE_SYMLINK_MODE,
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type: 'blob',
+ currentPath: '/',
+ mode: FILE_SYMLINK_MODE,
+ },
});
- await nextTick();
- expect(vm.element).toMatchSnapshot();
+ expect(wrapper.element).toMatchSnapshot();
});
- it('renders table row for path with special character', async () => {
+ it('renders table row for path with special character', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test$/test',
- type: 'file',
- currentPath: 'test$',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test$/test',
+ type: 'file',
+ currentPath: 'test$',
+ },
});
- await nextTick();
- expect(vm.element).toMatchSnapshot();
+ expect(wrapper.element).toMatchSnapshot();
});
it('renders a gl-hover-load directive', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test',
- type: 'blob',
- currentPath: '/',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type: 'blob',
+ currentPath: '/',
+ },
});
const hoverLoadDirective = getBinding(findRouterLink().element, 'gl-hover-load');
@@ -111,150 +128,162 @@ describe('Repository table row component', () => {
${'tree'} | ${RouterLinkStub} | ${'RouterLink'}
${'blob'} | ${RouterLinkStub} | ${'RouterLink'}
${'commit'} | ${'a'} | ${'hyperlink'}
- `('renders a $componentName for type $type', async ({ type, component }) => {
+ `('renders a $componentName for type $type', ({ type, component }) => {
factory({
- id: '1',
- sha: '123',
- path: 'test',
- type,
- currentPath: '/',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type,
+ currentPath: '/',
+ },
});
- await nextTick();
- expect(vm.findComponent(component).exists()).toBe(true);
+ expect(wrapper.findComponent(component).exists()).toBe(true);
});
it.each`
path
${'test#'}
${'Änderungen'}
- `('renders link for $path', async ({ path }) => {
+ `('renders link for $path', ({ path }) => {
factory({
- id: '1',
- sha: '123',
- path,
- type: 'tree',
- currentPath: '/',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path,
+ type: 'tree',
+ currentPath: '/',
+ },
});
- await nextTick();
- expect(vm.findComponent({ ref: 'link' }).props('to')).toEqual({
+ expect(wrapper.findComponent({ ref: 'link' }).props('to')).toEqual({
path: `/-/tree/main/${encodeURIComponent(path)}`,
});
});
- it('renders link for directory with hash', async () => {
+ it('renders link for directory with hash', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test#',
- type: 'tree',
- currentPath: '/',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test#',
+ type: 'tree',
+ currentPath: '/',
+ },
});
- await nextTick();
- expect(vm.find('.tree-item-link').props('to')).toEqual({ path: '/-/tree/main/test%23' });
+ expect(wrapper.find('.tree-item-link').props('to')).toEqual({ path: '/-/tree/main/test%23' });
});
- it('renders commit ID for submodule', async () => {
+ it('renders commit ID for submodule', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test',
- type: 'commit',
- currentPath: '/',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type: 'commit',
+ currentPath: '/',
+ },
});
- await nextTick();
- expect(vm.find('.commit-sha').text()).toContain('1');
+ expect(wrapper.find('.commit-sha').text()).toContain('1');
});
- it('renders link with href', async () => {
+ it('renders link with href', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test',
- type: 'blob',
- url: 'https://test.com',
- currentPath: '/',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type: 'blob',
+ url: 'https://test.com',
+ currentPath: '/',
+ },
});
- await nextTick();
- expect(vm.find('a').attributes('href')).toEqual('https://test.com');
+ expect(wrapper.find('a').attributes('href')).toEqual('https://test.com');
});
- it('renders LFS badge', async () => {
+ it('renders LFS badge', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test',
- type: 'commit',
- currentPath: '/',
- lfsOid: '1',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type: 'commit',
+ currentPath: '/',
+ lfsOid: '1',
+ },
});
- await nextTick();
- expect(vm.findComponent(GlBadge).exists()).toBe(true);
+ expect(findBadge().exists()).toBe(true);
});
- it('renders commit and web links with href for submodule', async () => {
+ it('renders commit and web links with href for submodule', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test',
- type: 'commit',
- url: 'https://test.com',
- submoduleTreeUrl: 'https://test.com/commit',
- currentPath: '/',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type: 'commit',
+ url: 'https://test.com',
+ submoduleTreeUrl: 'https://test.com/commit',
+ currentPath: '/',
+ },
});
- await nextTick();
- expect(vm.find('a').attributes('href')).toEqual('https://test.com');
- expect(vm.findComponent(GlLink).attributes('href')).toEqual('https://test.com/commit');
+ expect(wrapper.find('a').attributes('href')).toEqual('https://test.com');
+ expect(wrapper.findComponent(GlLink).attributes('href')).toEqual('https://test.com/commit');
});
- it('renders lock icon', async () => {
+ it('renders lock icon', () => {
factory({
- id: '1',
- sha: '123',
- path: 'test',
- type: 'tree',
- currentPath: '/',
+ propsData: {
+ id: '1',
+ sha: '123',
+ path: 'test',
+ type: 'tree',
+ currentPath: '/',
+ },
});
- await nextTick();
- expect(vm.findComponent(GlIcon).exists()).toBe(true);
- expect(vm.findComponent(GlIcon).props('name')).toBe('lock');
+ expect(findIcon().exists()).toBe(true);
+ expect(findIcon().props('name')).toBe('lock');
});
it('renders loading icon when path is loading', () => {
factory({
- id: '1',
- sha: '1',
- path: 'test',
- type: 'tree',
- currentPath: '/',
- loadingPath: 'test',
+ propsData: {
+ id: '1',
+ sha: '1',
+ path: 'test',
+ type: 'tree',
+ currentPath: '/',
+ loadingPath: 'test',
+ },
});
- expect(vm.findComponent(FileIcon).props('loading')).toBe(true);
+ expect(findFileIcon().props('loading')).toBe(true);
});
describe('row visibility', () => {
beforeEach(() => {
factory({
- id: '1',
- sha: '1',
- path: 'test',
- type: 'tree',
- currentPath: '/',
- commitInfo: null,
+ propsData: {
+ id: '1',
+ sha: '1',
+ path: 'test',
+ type: 'tree',
+ currentPath: '/',
+ commitInfo: null,
+ },
});
});
afterAll(() => jest.useRealTimers());
- it('emits a `row-appear` event', async () => {
+ it('emits a `row-appear` event', () => {
const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
findIntersectionObserver().vm.$emit('appear');
@@ -262,7 +291,7 @@ describe('Repository table row component', () => {
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), ROW_APPEAR_DELAY);
- expect(vm.emitted('row-appear')).toEqual([[123]]);
+ expect(wrapper.emitted('row-appear')).toEqual([[123]]);
});
});
});
diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js
index f694c8e9166..8d45e24e9e6 100644
--- a/spec/frontend/repository/components/tree_content_spec.js
+++ b/spec/frontend/repository/components/tree_content_spec.js
@@ -1,216 +1,170 @@
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import FilePreview from '~/repository/components/preview/index.vue';
import FileTable from '~/repository/components/table/index.vue';
import TreeContent from 'jh_else_ce/repository/components/tree_content.vue';
+import { TREE_PAGE_LIMIT, i18n } from '~/repository/constants';
import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service';
+import createApolloProvider from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
-import { i18n } from '~/repository/constants';
-import { graphQLErrors } from '../mock_data';
+import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
+import projectPathQuery from '~/repository/queries/project_path.query.graphql';
+
+import { createAlert } from '~/alert';
+import { graphQLErrors, paginatedTreeResponseFactory } from '../mock_data';
jest.mock('~/repository/commits_service', () => ({
loadCommits: jest.fn(() => Promise.resolve()),
isRequested: jest.fn(),
resetRequestedCommits: jest.fn(),
}));
-jest.mock('~/flash');
-
-let vm;
-let $apollo;
-const mockResponse = jest.fn().mockReturnValue(Promise.resolve({ data: {} }));
-
-function factory(path, appoloMockResponse = mockResponse) {
- $apollo = {
- query: appoloMockResponse,
- };
-
- vm = shallowMount(TreeContent, {
- propsData: {
- path,
- },
- mocks: {
- $apollo,
- },
- provide: {
- glFeatures: {
- increasePageSizeExponentially: true,
- paginatedTreeGraphqlQuery: true,
- },
- },
- });
-}
+jest.mock('~/alert');
describe('Repository table component', () => {
- const findFileTable = () => vm.findComponent(FileTable);
-
- afterEach(() => {
- vm.destroy();
- });
-
- it('renders file preview', async () => {
- factory('/');
+ Vue.use(VueApollo);
+ let wrapper;
+
+ const paginatedTreeResponseWithMoreThanLimit = jest
+ .fn()
+ .mockResolvedValue(paginatedTreeResponseFactory({ numberOfBlobs: TREE_PAGE_LIMIT + 2 }));
+ const paginatedTreeQueryResponseHandler = jest
+ .fn()
+ .mockResolvedValue(paginatedTreeResponseFactory());
+ const findFileTable = () => wrapper.findComponent(FileTable);
+
+ const createComponent = ({
+ path = '/',
+ responseHandler = paginatedTreeQueryResponseHandler,
+ } = {}) => {
+ const apolloProvider = createApolloProvider([[paginatedTreeQuery, responseHandler]]);
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: projectPathQuery,
+ data: {
+ projectPath: path,
+ },
+ });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ entries: { blobs: [{ name: 'README.md' }] } });
+ wrapper = shallowMount(TreeContent, {
+ apolloProvider,
+ propsData: {
+ path,
+ },
+ });
+ };
+ it('renders file preview when the response has README.md', async () => {
+ const paginatedTreeResponseWithReadMe = jest
+ .fn()
+ .mockResolvedValue(paginatedTreeResponseFactory({ numberOfBlobs: 1, blobHasReadme: true }));
+ createComponent({ responseHandler: paginatedTreeResponseWithReadMe });
await nextTick();
- expect(vm.findComponent(FilePreview).exists()).toBe(true);
+ await waitForPromises();
+ expect(wrapper.findComponent(FilePreview).exists()).toBe(true);
});
- it('trigger fetchFiles and resetRequestedCommits when mounted', async () => {
- factory('/');
-
- jest.spyOn(vm.vm, 'fetchFiles').mockImplementation(() => {});
+ it('calls tree response handler and resetRequestedCommits when mounted', async () => {
+ createComponent();
await nextTick();
- expect(vm.vm.fetchFiles).toHaveBeenCalled();
+ expect(paginatedTreeQueryResponseHandler).toHaveBeenCalled();
expect(resetRequestedCommits).toHaveBeenCalled();
});
describe('normalizeData', () => {
- it('normalizes edge nodes', () => {
- factory('/');
+ it('normalizes edge nodes', async () => {
+ createComponent();
- const output = vm.vm.normalizeData('blobs', { nodes: ['1', '2'] });
+ await nextTick();
+ await waitForPromises();
- expect(output).toEqual(['1', '2']);
- });
- });
+ const [
+ paginatedTreeNode,
+ ] = paginatedTreeResponseFactory().data.project.repository.paginatedTree.nodes;
- describe('hasNextPage', () => {
- it('returns undefined when hasNextPage is false', () => {
- factory('/');
+ const {
+ blobs: { nodes: blobs },
+ trees: { nodes: trees },
+ submodules: { nodes: submodules },
+ } = paginatedTreeNode;
- const output = vm.vm.hasNextPage({
- trees: { pageInfo: { hasNextPage: false } },
- submodules: { pageInfo: { hasNextPage: false } },
- blobs: { pageInfo: { hasNextPage: false } },
+ expect(findFileTable().props('entries')).toEqual({
+ blobs,
+ trees,
+ submodules,
});
-
- expect(output).toBe(undefined);
});
+ });
- it('returns pageInfo object when hasNextPage is true', () => {
- factory('/');
+ describe('when there is next page', () => {
+ it('make sure it has the correct props to filetable', async () => {
+ createComponent({ responseHandler: paginatedTreeResponseWithMoreThanLimit });
- const output = vm.vm.hasNextPage({
- trees: { pageInfo: { hasNextPage: false } },
- submodules: { pageInfo: { hasNextPage: false } },
- blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } },
- });
+ await nextTick();
+ await waitForPromises();
- expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' });
+ expect(findFileTable().props('hasMore')).toBe(true);
});
});
- describe('FileTable showMore', () => {
- describe('when is present', () => {
+ describe('FileTable', () => {
+ describe('when "showMore" event is emitted', () => {
beforeEach(async () => {
- factory('/');
- });
-
- it('is changes hasShowMore to false when "showMore" event is emitted', async () => {
- findFileTable().vm.$emit('showMore');
-
+ createComponent();
await nextTick();
-
- expect(vm.vm.hasShowMore).toBe(false);
+ await waitForPromises();
});
- it('changes clickedShowMore when "showMore" event is emitted', async () => {
+ it('changes hasShowMore to false', async () => {
findFileTable().vm.$emit('showMore');
await nextTick();
- expect(vm.vm.clickedShowMore).toBe(true);
+ expect(findFileTable().props('hasMore')).toBe(false);
});
- it('triggers fetchFiles when "showMore" event is emitted', () => {
- jest.spyOn(vm.vm, 'fetchFiles');
-
+ it('triggers the tree responseHandler', () => {
findFileTable().vm.$emit('showMore');
- expect(vm.vm.fetchFiles).toHaveBeenCalled();
+ expect(paginatedTreeQueryResponseHandler).toHaveBeenCalled();
});
});
- it('is not rendered if less than 1000 files', async () => {
- factory('/');
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ fetchCounter: 5, clickedShowMore: false });
-
- await nextTick();
-
- expect(vm.vm.hasShowMore).toBe(false);
- });
-
- it.each`
- totalBlobs | pagesLoaded | limitReached
- ${900} | ${1} | ${false}
- ${1000} | ${1} | ${true}
- ${1002} | ${1} | ${true}
- ${1002} | ${2} | ${false}
- ${1900} | ${2} | ${false}
- ${2000} | ${2} | ${true}
- `('has limit of 1000 entries per page', async ({ totalBlobs, pagesLoaded, limitReached }) => {
- factory('/');
-
- const blobs = new Array(totalBlobs).fill('fakeBlob');
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ entries: { blobs }, pagesLoaded });
-
- await nextTick();
-
- expect(findFileTable().props('hasMore')).toBe(limitReached);
- });
-
- it.each`
- fetchCounter | pageSize
- ${0} | ${10}
- ${2} | ${30}
- ${4} | ${50}
- ${6} | ${70}
- ${8} | ${90}
- ${10} | ${100}
- ${20} | ${100}
- ${100} | ${100}
- ${200} | ${100}
- `('exponentially increases page size, to a maximum of 100', ({ fetchCounter, pageSize }) => {
- factory('/');
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ fetchCounter });
-
- vm.vm.fetchFiles();
-
- expect($apollo.query).toHaveBeenCalledWith({
- query: paginatedTreeQuery,
- variables: {
- pageSize,
- nextPageCursor: '',
- path: '/',
- projectPath: '',
- ref: '',
+ describe('"hasMore" props is correctly computed with the limit to 1000 per page', () => {
+ it.each`
+ totalBlobs | limitReached
+ ${500} | ${false}
+ ${900} | ${false}
+ ${1000} | ${true}
+ ${1002} | ${true}
+ ${2000} | ${true}
+ `(
+ 'is `$limitReached` when the number of entries is `$totalBlobs`',
+ async ({ totalBlobs, limitReached }) => {
+ const paginatedTreeResponseHandler = jest
+ .fn()
+ .mockResolvedValue(paginatedTreeResponseFactory({ numberOfBlobs: totalBlobs }));
+ createComponent({ responseHandler: paginatedTreeResponseHandler });
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(findFileTable().props('hasMore')).toBe(limitReached);
},
- });
+ );
});
});
describe('commit data', () => {
- const path = 'some/path';
+ const path = '';
it('loads commit data for both top and bottom batches when row-appear event is emitted', () => {
const rowNumber = 50;
- factory(path);
+ createComponent({ path });
findFileTable().vm.$emit('row-appear', rowNumber);
expect(isRequested).toHaveBeenCalledWith(rowNumber);
@@ -222,7 +176,7 @@ describe('Repository table component', () => {
});
it('loads commit data once if rowNumber is zero', () => {
- factory(path);
+ createComponent({ path });
findFileTable().vm.$emit('row-appear', 0);
expect(loadCommits.mock.calls).toEqual([['', path, '', 0]]);
@@ -235,10 +189,13 @@ describe('Repository table component', () => {
error | message
${gitalyError} | ${i18n.gitalyError}
${'Error'} | ${i18n.generalError}
- `('should show an expected error', async ({ error, message }) => {
- factory('/', jest.fn().mockRejectedValue(error));
- await waitForPromises();
- expect(createAlert).toHaveBeenCalledWith({ message, captureError: true });
- });
+ `(
+ `when the graphql error is "$error" shows the message "$message"`,
+ async ({ error, message }) => {
+ createComponent({ path: '/', responseHandler: jest.fn().mockRejectedValue(error) });
+ await waitForPromises();
+ expect(createAlert).toHaveBeenCalledWith({ message, captureError: true });
+ },
+ );
});
});
diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js
index 9de0666f27a..319321cfcb4 100644
--- a/spec/frontend/repository/components/upload_blob_modal_spec.js
+++ b/spec/frontend/repository/components/upload_blob_modal_spec.js
@@ -4,13 +4,13 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
joinPaths: () => '/new_upload',
@@ -53,14 +53,9 @@ describe('UploadBlobModal', () => {
const findBranchName = () => wrapper.findComponent(GlFormInput);
const findMrToggle = () => wrapper.findComponent(GlToggle);
const findUploadDropzone = () => wrapper.findComponent(UploadDropzone);
- const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes[0].disabled;
- const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes[0].disabled;
- const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes[0].loading;
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
+ const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes.disabled;
+ const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes.disabled;
+ const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes.loading;
describe.each`
canPushCode | displayBranchName | displayForkedBranchMessage
@@ -110,9 +105,7 @@ describe('UploadBlobModal', () => {
if (canPushCode) {
describe('when changing the branch name', () => {
it('displays the MR toggle', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ target: 'Not main' });
+ createComponent({ targetBranch: 'Not main' });
await nextTick();
@@ -123,12 +116,10 @@ describe('UploadBlobModal', () => {
describe('completed form', () => {
beforeEach(() => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- file: { type: 'jpg' },
- filePreviewURL: 'http://file.com?format=jpg',
- });
+ findUploadDropzone().vm.$emit(
+ 'change',
+ new File(['http://file.com?format=jpg'], 'file.jpg'),
+ );
});
it('enables the upload button when the form is completed', () => {
@@ -184,7 +175,7 @@ describe('UploadBlobModal', () => {
await waitForPromises();
});
- it('creates a flash error', () => {
+ it('creates an alert error', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'Error uploading file. Please try again.',
});
@@ -199,13 +190,6 @@ describe('UploadBlobModal', () => {
);
describe('blob file submission type', () => {
- const submitForm = async () => {
- wrapper.vm.uploadFile = jest.fn();
- wrapper.vm.replaceFile = jest.fn();
- wrapper.vm.submitForm();
- await nextTick();
- };
-
const submitRequest = async () => {
mock = new MockAdapter(axios);
findModal().vm.$emit('primary', mockEvent);
@@ -225,13 +209,6 @@ describe('UploadBlobModal', () => {
expect(findModal().props('actionPrimary').text).toBe('Upload file');
});
- it('calls the default uploadFile when the form submit', async () => {
- await submitForm();
-
- expect(wrapper.vm.uploadFile).toHaveBeenCalled();
- expect(wrapper.vm.replaceFile).not.toHaveBeenCalled();
- });
-
it('makes a POST request', async () => {
await submitRequest();
@@ -261,13 +238,6 @@ describe('UploadBlobModal', () => {
expect(findModal().props('actionPrimary').text).toBe(primaryBtnText);
});
- it('calls the replaceFile when the form submit', async () => {
- await submitForm();
-
- expect(wrapper.vm.replaceFile).toHaveBeenCalled();
- expect(wrapper.vm.uploadFile).not.toHaveBeenCalled();
- });
-
it('makes a PUT request', async () => {
await submitRequest();
diff --git a/spec/frontend/repository/mixins/highlight_mixin_spec.js b/spec/frontend/repository/mixins/highlight_mixin_spec.js
index 7c48fe440d2..5f872749581 100644
--- a/spec/frontend/repository/mixins/highlight_mixin_spec.js
+++ b/spec/frontend/repository/mixins/highlight_mixin_spec.js
@@ -44,8 +44,6 @@ describe('HighlightMixin', () => {
beforeEach(() => createComponent());
- afterEach(() => wrapper.destroy());
-
describe('initHighlightWorker', () => {
const firstSeventyLines = contentArray.slice(0, LINES_PER_CHUNK).join('\n');
diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js
index 04ffe52bc3f..399341d23a0 100644
--- a/spec/frontend/repository/mock_data.js
+++ b/spec/frontend/repository/mock_data.js
@@ -123,6 +123,78 @@ export const propsForkInfo = {
selectedBranch: 'main',
sourceName: 'gitLab',
sourcePath: 'gitlab-org/gitlab',
+ canSyncBranch: true,
aheadComparePath: '/nataliia/myGitLab/-/compare/main...ref?from_project_id=1',
behindComparePath: 'gitlab-org/gitlab/-/compare/ref...main?from_project_id=2',
+ createMrPath: 'path/to/new/mr',
};
+
+export const propsConflictsModal = {
+ sourceDefaultBranch: 'branch-name',
+ sourceName: 'source-name',
+ sourcePath: 'path/to/project',
+ selectedBranch: 'my-branch',
+};
+
+export const paginatedTreeResponseFactory = ({
+ numberOfBlobs = 3,
+ numberOfTrees = 3,
+ hasNextPage = false,
+ blobHasReadme = false,
+} = {}) => ({
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/278964',
+ __typename: 'Project',
+ repository: {
+ __typename: 'Repository',
+ paginatedTree: {
+ __typename: 'TreeConnection',
+ pageInfo: {
+ __typename: 'PageInfo',
+ endCursor: hasNextPage ? 'aaa' : '',
+ startCursor: '',
+ hasNextPage,
+ },
+ nodes: [
+ {
+ __typename: 'Tree',
+ trees: {
+ __typename: 'TreeEntryConnection',
+ nodes: new Array(numberOfTrees).fill({
+ __typename: 'TreeEntry',
+ id:
+ 'gid://gitlab/Gitlab::Graphql::Representation::TreeEntry/dc36320ac91aca2f890a31458c9e9920159e68a3',
+ sha: 'dc36320ac91aca2f890a31458c9e9920159e68ae',
+ name: 'gitlab-resize-image',
+ flatPath: 'workhorse/cmd/gitlab-resize-image',
+ type: 'tree',
+ webPath: '/gitlab-org/gitlab/-/tree/master/workhorse/cmd/gitlab-resize-image',
+ }),
+ },
+ submodules: {
+ __typename: 'SubmoduleConnection',
+ nodes: [],
+ },
+ blobs: {
+ __typename: 'BlobConnection',
+ nodes: new Array(numberOfBlobs).fill({
+ __typename: 'Blob',
+ id:
+ 'gid://gitlab/Gitlab::Graphql::Representation::TreeEntry/99712dbc6b26ff92c15bf93449ea09df38adfb10',
+ sha: '99712dbc6b26ff92c15bf93449ea09df38adfb1b',
+ name: blobHasReadme ? 'README.md' : 'fakeBlob',
+ flatPath: blobHasReadme ? 'README.md' : 'fakeBlob',
+ type: 'blob',
+ mode: '100644',
+ webPath: '/gitlab-org/gitlab-build-images/-/blob/master/README.md',
+ lfsOid: null,
+ }),
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+});
diff --git a/spec/frontend/repository/pages/blob_spec.js b/spec/frontend/repository/pages/blob_spec.js
index 4fe6188370e..366523e2b8b 100644
--- a/spec/frontend/repository/pages/blob_spec.js
+++ b/spec/frontend/repository/pages/blob_spec.js
@@ -16,10 +16,6 @@ describe('Repository blob page component', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has a Blob Content Viewer component', () => {
expect(findBlobContentViewer().exists()).toBe(true);
expect(findBlobContentViewer().props('path')).toBe(path);
diff --git a/spec/frontend/repository/pages/index_spec.js b/spec/frontend/repository/pages/index_spec.js
index 559257d414c..e50557e7d61 100644
--- a/spec/frontend/repository/pages/index_spec.js
+++ b/spec/frontend/repository/pages/index_spec.js
@@ -13,8 +13,6 @@ describe('Repository index page component', () => {
}
afterEach(() => {
- wrapper.destroy();
-
updateElementsVisibility.mockClear();
});
diff --git a/spec/frontend/repository/pages/tree_spec.js b/spec/frontend/repository/pages/tree_spec.js
index 36662696c91..b1529d77c7d 100644
--- a/spec/frontend/repository/pages/tree_spec.js
+++ b/spec/frontend/repository/pages/tree_spec.js
@@ -12,8 +12,6 @@ describe('Repository tree page component', () => {
}
afterEach(() => {
- wrapper.destroy();
-
updateElementsVisibility.mockClear();
});
diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js
index f51d51ee182..e3a04796f7b 100644
--- a/spec/frontend/right_sidebar_spec.js
+++ b/spec/frontend/right_sidebar_spec.js
@@ -1,6 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlOpenIssues from 'test_fixtures/issues/open-issue.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import axios from '~/lib/utils/axios_utils';
import Sidebar from '~/right_sidebar';
@@ -26,11 +27,10 @@ const assertSidebarState = (state) => {
describe('RightSidebar', () => {
describe('fixture tests', () => {
- const fixtureName = 'issues/open-issue.html';
let mock;
beforeEach(() => {
- loadHTMLFixture(fixtureName);
+ setHTMLFixture(htmlOpenIssues);
mock = new MockAdapter(axios);
new Sidebar(); // eslint-disable-line no-new
$aside = $('.right-sidebar');
diff --git a/spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap
deleted file mode 100644
index 3abdfcdaf20..00000000000
--- a/spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap
+++ /dev/null
@@ -1,21 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Saved replies list item component renders list item 1`] = `
-<li
- class="gl-mb-5"
->
- <div
- class="gl-display-flex gl-align-items-center"
- >
- <strong>
- test
- </strong>
- </div>
-
- <div
- class="gl-mt-3 gl-font-monospace"
- >
- /assign_reviewer
- </div>
-</li>
-`;
diff --git a/spec/frontend/saved_replies/components/list_item_spec.js b/spec/frontend/saved_replies/components/list_item_spec.js
deleted file mode 100644
index cad1000473b..00000000000
--- a/spec/frontend/saved_replies/components/list_item_spec.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import ListItem from '~/saved_replies/components/list_item.vue';
-
-let wrapper;
-
-function createComponent(propsData = {}) {
- return shallowMount(ListItem, {
- propsData,
- });
-}
-
-describe('Saved replies list item component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders list item', async () => {
- wrapper = createComponent({ reply: { name: 'test', content: '/assign_reviewer' } });
-
- expect(wrapper.element).toMatchSnapshot();
- });
-});
diff --git a/spec/frontend/saved_replies/components/list_spec.js b/spec/frontend/saved_replies/components/list_spec.js
deleted file mode 100644
index 66e9ddfe148..00000000000
--- a/spec/frontend/saved_replies/components/list_spec.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import Vue from 'vue';
-import { mount } from '@vue/test-utils';
-import VueApollo from 'vue-apollo';
-import noSavedRepliesResponse from 'test_fixtures/graphql/saved_replies/saved_replies_empty.query.graphql.json';
-import savedRepliesResponse from 'test_fixtures/graphql/saved_replies/saved_replies.query.graphql.json';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import List from '~/saved_replies/components/list.vue';
-import ListItem from '~/saved_replies/components/list_item.vue';
-import savedRepliesQuery from '~/saved_replies/queries/saved_replies.query.graphql';
-
-let wrapper;
-
-function createMockApolloProvider(response) {
- Vue.use(VueApollo);
-
- const requestHandlers = [[savedRepliesQuery, jest.fn().mockResolvedValue(response)]];
-
- return createMockApollo(requestHandlers);
-}
-
-function createComponent(options = {}) {
- const { mockApollo } = options;
-
- return mount(List, {
- apolloProvider: mockApollo,
- });
-}
-
-describe('Saved replies list component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('does not render any list items when response is empty', async () => {
- const mockApollo = createMockApolloProvider(noSavedRepliesResponse);
- wrapper = createComponent({ mockApollo });
-
- await waitForPromises();
-
- expect(wrapper.findAllComponents(ListItem).length).toBe(0);
- });
-
- it('render saved replies count', async () => {
- const mockApollo = createMockApolloProvider(savedRepliesResponse);
- wrapper = createComponent({ mockApollo });
-
- await waitForPromises();
-
- expect(wrapper.find('[data-testid="title"]').text()).toEqual('My saved replies (2)');
- });
-
- it('renders list of saved replies', async () => {
- const mockApollo = createMockApolloProvider(savedRepliesResponse);
- const savedReplies = savedRepliesResponse.data.currentUser.savedReplies.nodes;
- wrapper = createComponent({ mockApollo });
-
- await waitForPromises();
-
- expect(wrapper.findAllComponents(ListItem).length).toBe(2);
- expect(wrapper.findAllComponents(ListItem).at(0).props('reply')).toEqual(
- expect.objectContaining(savedReplies[0]),
- );
- expect(wrapper.findAllComponents(ListItem).at(1).props('reply')).toEqual(
- expect.objectContaining(savedReplies[1]),
- );
- });
-});
diff --git a/spec/frontend/scripts/frontend/__fixtures__/locale/de/converted.json b/spec/frontend/scripts/frontend/__fixtures__/locale/de/converted.json
new file mode 100644
index 00000000000..570980b1c27
--- /dev/null
+++ b/spec/frontend/scripts/frontend/__fixtures__/locale/de/converted.json
@@ -0,0 +1,21 @@
+{
+ "domain": "app",
+ "locale_data": {
+ "app": {
+ "": {
+ "domain": "app",
+ "lang": "de"
+ },
+ " %{start} to %{end}": [
+ " %{start} bis %{end}"
+ ],
+ "%d Alert:": [
+ "%d Warnung:",
+ "%d Warnungen:"
+ ],
+ "Example": [
+ ""
+ ]
+ }
+ }
+}
diff --git a/spec/frontend/scripts/frontend/__fixtures__/locale/de/gitlab.po b/spec/frontend/scripts/frontend/__fixtures__/locale/de/gitlab.po
new file mode 100644
index 00000000000..fe80cb72c29
--- /dev/null
+++ b/spec/frontend/scripts/frontend/__fixtures__/locale/de/gitlab.po
@@ -0,0 +1,13 @@
+# Simple translated string
+msgid " %{start} to %{end}"
+msgstr " %{start} bis %{end}"
+
+# Simple translated, pluralized string
+msgid "%d Alert:"
+msgid_plural "%d Alerts:"
+msgstr[0] "%d Warnung:"
+msgstr[1] "%d Warnungen:"
+
+# Simple string without translation
+msgid "Example"
+msgstr ""
diff --git a/spec/frontend/scripts/frontend/po_to_json_spec.js b/spec/frontend/scripts/frontend/po_to_json_spec.js
new file mode 100644
index 00000000000..858e3c9d3c7
--- /dev/null
+++ b/spec/frontend/scripts/frontend/po_to_json_spec.js
@@ -0,0 +1,244 @@
+import { join } from 'path';
+import { tmpdir } from 'os';
+import { readFile, rm, mkdtemp, stat } from 'fs/promises';
+import {
+ convertPoToJed,
+ convertPoFileForLocale,
+ main,
+} from '../../../../scripts/frontend/po_to_json';
+
+describe('PoToJson', () => {
+ const LOCALE = 'de';
+ const LOCALE_DIR = join(__dirname, '__fixtures__/locale');
+ const PO_FILE = join(LOCALE_DIR, LOCALE, 'gitlab.po');
+ const CONVERTED_FILE = join(LOCALE_DIR, LOCALE, 'converted.json');
+ let DE_CONVERTED = null;
+
+ beforeAll(async () => {
+ DE_CONVERTED = Object.freeze(JSON.parse(await readFile(CONVERTED_FILE, 'utf-8')));
+ });
+
+ describe('tests writing to the file system', () => {
+ let resultDir = null;
+
+ afterEach(async () => {
+ if (resultDir) {
+ await rm(resultDir, { recursive: true, force: true });
+ }
+ });
+
+ beforeEach(async () => {
+ resultDir = await mkdtemp(join(tmpdir(), 'locale-test'));
+ });
+
+ describe('#main', () => {
+ it('throws without arguments', () => {
+ return expect(main()).rejects.toThrow(/doesn't seem to be a folder/);
+ });
+
+ it('throws if outputDir does not exist', () => {
+ return expect(
+ main({
+ localeRoot: LOCALE_DIR,
+ outputDir: 'i-do-not-exist',
+ }),
+ ).rejects.toThrow(/doesn't seem to be a folder/);
+ });
+
+ it('throws if localeRoot does not exist', () => {
+ return expect(
+ main({
+ localeRoot: 'i-do-not-exist',
+ outputDir: resultDir,
+ }),
+ ).rejects.toThrow(/doesn't seem to be a folder/);
+ });
+
+ it('converts folder of po files to app.js files', async () => {
+ expect((await stat(resultDir)).isDirectory()).toBe(true);
+ await main({ localeRoot: LOCALE_DIR, outputDir: resultDir });
+
+ const resultFile = join(resultDir, LOCALE, 'app.js');
+ expect((await stat(resultFile)).isFile()).toBe(true);
+
+ window.translations = null;
+ await import(resultFile);
+ expect(window.translations).toEqual(DE_CONVERTED);
+ });
+ });
+
+ describe('#convertPoFileForLocale', () => {
+ it('converts simple PO to app.js, which exposes translations on the window', async () => {
+ await convertPoFileForLocale({ locale: 'de', localeFile: PO_FILE, resultDir });
+
+ const resultFile = join(resultDir, 'app.js');
+ expect((await stat(resultFile)).isFile()).toBe(true);
+
+ window.translations = null;
+ await import(resultFile);
+ expect(window.translations).toEqual(DE_CONVERTED);
+ });
+ });
+ });
+
+ describe('#convertPoToJed', () => {
+ it('converts simple PO to JED compatible JSON', async () => {
+ const poContent = await readFile(PO_FILE, 'utf-8');
+
+ expect(convertPoToJed(poContent, LOCALE).jed).toEqual(DE_CONVERTED);
+ });
+
+ it('returns null for empty string', () => {
+ const poContent = '';
+
+ expect(convertPoToJed(poContent, LOCALE).jed).toEqual(null);
+ });
+
+ describe('PO File headers', () => {
+ it('parses headers properly', () => {
+ const poContent = `
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab-ee\\n"
+"Report-Msgid-Bugs-To: \\n"
+"X-Crowdin-Project: gitlab-ee\\n"
+`;
+
+ expect(convertPoToJed(poContent, LOCALE).jed).toEqual({
+ domain: 'app',
+ locale_data: {
+ app: {
+ '': {
+ 'Project-Id-Version': 'gitlab-ee',
+ 'Report-Msgid-Bugs-To': '',
+ 'X-Crowdin-Project': 'gitlab-ee',
+ domain: 'app',
+ lang: LOCALE,
+ },
+ },
+ },
+ });
+ });
+
+ // JED needs that property, hopefully we could get
+ // rid of this in a future iteration
+ it("exposes 'Plural-Forms' as 'plural_forms' for `jed`", () => {
+ const poContent = `
+msgid ""
+msgstr ""
+"Plural-Forms: nplurals=2; plural=(n != 1);\\n"
+`;
+
+ expect(convertPoToJed(poContent, LOCALE).jed).toEqual({
+ domain: 'app',
+ locale_data: {
+ app: {
+ '': {
+ 'Plural-Forms': 'nplurals=2; plural=(n != 1);',
+ plural_forms: 'nplurals=2; plural=(n != 1);',
+ domain: 'app',
+ lang: LOCALE,
+ },
+ },
+ },
+ });
+ });
+
+ it('removes POT-Creation-Date', () => {
+ const poContent = `
+msgid ""
+msgstr ""
+"Plural-Forms: nplurals=2; plural=(n != 1);\\n"
+`;
+
+ expect(convertPoToJed(poContent, LOCALE).jed).toEqual({
+ domain: 'app',
+ locale_data: {
+ app: {
+ '': {
+ 'Plural-Forms': 'nplurals=2; plural=(n != 1);',
+ plural_forms: 'nplurals=2; plural=(n != 1);',
+ domain: 'app',
+ lang: LOCALE,
+ },
+ },
+ },
+ });
+ });
+ });
+
+ describe('escaping', () => {
+ it('escapes quotes in msgid and translation', () => {
+ const poContent = `
+# Escaped quotes in msgid and msgstr
+msgid "Changes the title to \\"%{title_param}\\"."
+msgstr "Ändert den Titel in \\"%{title_param}\\"."
+`;
+
+ expect(convertPoToJed(poContent, LOCALE).jed).toEqual({
+ domain: 'app',
+ locale_data: {
+ app: {
+ '': {
+ domain: 'app',
+ lang: LOCALE,
+ },
+ 'Changes the title to \\"%{title_param}\\".': [
+ 'Ändert den Titel in \\"%{title_param}\\".',
+ ],
+ },
+ },
+ });
+ });
+
+ it('escapes backslashes in msgid and translation', () => {
+ const poContent = `
+# Escaped backslashes in msgid and msgstr
+msgid "Example: ssh\\\\:\\\\/\\\\/"
+msgstr "Beispiel: ssh\\\\:\\\\/\\\\/"
+`;
+
+ expect(convertPoToJed(poContent, LOCALE).jed).toEqual({
+ domain: 'app',
+ locale_data: {
+ app: {
+ '': {
+ domain: 'app',
+ lang: LOCALE,
+ },
+ 'Example: ssh\\\\:\\\\/\\\\/': ['Beispiel: ssh\\\\:\\\\/\\\\/'],
+ },
+ },
+ });
+ });
+
+ // This is potentially faulty behavior but demands further investigation
+ // See also the escapeMsgstr method
+ it('escapes \\n and \\t in translation', () => {
+ const poContent = `
+# Escaped \\n
+msgid "Outdent line"
+msgstr "Désindenter la ligne\\n"
+
+# Escaped \\t
+msgid "Headers"
+msgstr "Cabeçalhos\\t"
+`;
+
+ expect(convertPoToJed(poContent, LOCALE).jed).toEqual({
+ domain: 'app',
+ locale_data: {
+ app: {
+ '': {
+ domain: 'app',
+ lang: LOCALE,
+ },
+ Headers: ['Cabeçalhos\\t'],
+ 'Outdent line': ['Désindenter la ligne\\n'],
+ },
+ },
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js
index 15cff436076..91fc97c15ae 100644
--- a/spec/frontend/search/highlight_blob_search_result_spec.js
+++ b/spec/frontend/search/highlight_blob_search_result_spec.js
@@ -1,11 +1,11 @@
+import htmlPipelineSchedulesEdit from 'test_fixtures/search/blob_search_result.html';
import setHighlightClass from '~/search/highlight_blob_search_result';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-const fixture = 'search/blob_search_result.html';
const searchKeyword = 'Send'; // spec/frontend/fixtures/search.rb#79
describe('search/highlight_blob_search_result', () => {
- beforeEach(() => loadHTMLFixture(fixture));
+ beforeEach(() => setHTMLFixture(htmlPipelineSchedulesEdit));
afterEach(() => {
resetHTMLFixture();
diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js
index fb9c0a93907..f8dd6f6df27 100644
--- a/spec/frontend/search/mock_data.js
+++ b/spec/frontend/search/mock_data.js
@@ -6,6 +6,7 @@ export const MOCK_QUERY = {
state: 'all',
confidential: null,
group_id: 1,
+ language: ['C', 'JavaScript'],
};
export const MOCK_GROUP = {
@@ -193,214 +194,6 @@ export const MOCK_NAVIGATION_ACTION_MUTATION = {
payload: { key: 'projects', count: '13' },
};
-export const MOCK_AGGREGATIONS = [
- {
- name: 'language',
- buckets: [
- { key: 'random-label-edumingos0', count: 1 },
- { key: 'random-label-rbourgourd1', count: 2 },
- { key: 'random-label-dfearnside2', count: 3 },
- { key: 'random-label-gewins3', count: 4 },
- { key: 'random-label-telverstone4', count: 5 },
- { key: 'random-label-ygerriets5', count: 6 },
- { key: 'random-label-lmoffet6', count: 7 },
- { key: 'random-label-ehinnerk7', count: 8 },
- { key: 'random-label-flanceley8', count: 9 },
- { key: 'random-label-adoyle9', count: 10 },
- { key: 'random-label-rmcgirla', count: 11 },
- { key: 'random-label-dwhellansb', count: 12 },
- { key: 'random-label-apitkethlyc', count: 13 },
- { key: 'random-label-senevoldsend', count: 14 },
- { key: 'random-label-tlardnare', count: 15 },
- { key: 'random-label-fcoilsf', count: 16 },
- { key: 'random-label-qgeckg', count: 17 },
- { key: 'random-label-rgrabenh', count: 18 },
- { key: 'random-label-lashardi', count: 19 },
- { key: 'random-label-sadamovitchj', count: 20 },
- { key: 'random-label-rlyddiardk', count: 21 },
- { key: 'random-label-jpoell', count: 22 },
- { key: 'random-label-kcharitym', count: 23 },
- { key: 'random-label-cbertenshawn', count: 24 },
- { key: 'random-label-jsturgeso', count: 25 },
- { key: 'random-label-ohouldcroftp', count: 26 },
- { key: 'random-label-rheijnenq', count: 27 },
- { key: 'random-label-snortheyr', count: 28 },
- { key: 'random-label-vpairpoints', count: 29 },
- { key: 'random-label-odavidovicit', count: 30 },
- { key: 'random-label-fmccartu', count: 31 },
- { key: 'random-label-cwansburyv', count: 32 },
- { key: 'random-label-bdimontw', count: 33 },
- { key: 'random-label-adocketx', count: 34 },
- { key: 'random-label-obavridgey', count: 35 },
- { key: 'random-label-jperezz', count: 36 },
- { key: 'random-label-gdeneve10', count: 37 },
- { key: 'random-label-rmckeand11', count: 38 },
- { key: 'random-label-kwestmerland12', count: 39 },
- { key: 'random-label-mpryer13', count: 40 },
- { key: 'random-label-rmcneil14', count: 41 },
- { key: 'random-label-ablondel15', count: 42 },
- { key: 'random-label-wbalducci16', count: 43 },
- { key: 'random-label-swigley17', count: 44 },
- { key: 'random-label-gferroni18', count: 45 },
- { key: 'random-label-icollings19', count: 46 },
- { key: 'random-label-wszymanski1a', count: 47 },
- { key: 'random-label-jelson1b', count: 48 },
- { key: 'random-label-fsambrook1c', count: 49 },
- { key: 'random-label-kconey1d', count: 50 },
- { key: 'random-label-agoodread1e', count: 51 },
- { key: 'random-label-nmewton1f', count: 52 },
- { key: 'random-label-gcodman1g', count: 53 },
- { key: 'random-label-rpoplee1h', count: 54 },
- { key: 'random-label-mhug1i', count: 55 },
- { key: 'random-label-ggowrie1j', count: 56 },
- { key: 'random-label-ctonepohl1k', count: 57 },
- { key: 'random-label-cstillman1l', count: 58 },
- { key: 'random-label-dcollyer1m', count: 59 },
- { key: 'random-label-idimelow1n', count: 60 },
- { key: 'random-label-djarley1o', count: 61 },
- { key: 'random-label-omclleese1p', count: 62 },
- { key: 'random-label-dstivers1q', count: 63 },
- { key: 'random-label-svose1r', count: 64 },
- { key: 'random-label-clanfare1s', count: 65 },
- { key: 'random-label-aport1t', count: 66 },
- { key: 'random-label-hcarlett1u', count: 67 },
- { key: 'random-label-dstillmann1v', count: 68 },
- { key: 'random-label-ncorpe1w', count: 69 },
- { key: 'random-label-mjacobsohn1x', count: 70 },
- { key: 'random-label-ycleiment1y', count: 71 },
- { key: 'random-label-owherton1z', count: 72 },
- { key: 'random-label-anowaczyk20', count: 73 },
- { key: 'random-label-rmckennan21', count: 74 },
- { key: 'random-label-cmoulding22', count: 75 },
- { key: 'random-label-sswate23', count: 76 },
- { key: 'random-label-cbarge24', count: 77 },
- { key: 'random-label-agrainger25', count: 78 },
- { key: 'random-label-ncosin26', count: 79 },
- { key: 'random-label-pkears27', count: 80 },
- { key: 'random-label-cmcarthur28', count: 81 },
- { key: 'random-label-jmantripp29', count: 82 },
- { key: 'random-label-cjekel2a', count: 83 },
- { key: 'random-label-hdilleway2b', count: 84 },
- { key: 'random-label-lbovaird2c', count: 85 },
- { key: 'random-label-mweld2d', count: 86 },
- { key: 'random-label-marnowitz2e', count: 87 },
- { key: 'random-label-nbertomieu2f', count: 88 },
- { key: 'random-label-mledward2g', count: 89 },
- { key: 'random-label-mhince2h', count: 90 },
- { key: 'random-label-baarons2i', count: 91 },
- { key: 'random-label-kfrancie2j', count: 92 },
- { key: 'random-label-ishooter2k', count: 93 },
- { key: 'random-label-glowmass2l', count: 94 },
- { key: 'random-label-rgeorgi2m', count: 95 },
- { key: 'random-label-bproby2n', count: 96 },
- { key: 'random-label-hsteffan2o', count: 97 },
- { key: 'random-label-doruane2p', count: 98 },
- { key: 'random-label-rlunny2q', count: 99 },
- { key: 'random-label-geles2r', count: 100 },
- { key: 'random-label-nmaggiore2s', count: 101 },
- { key: 'random-label-aboocock2t', count: 102 },
- { key: 'random-label-eguilbert2u', count: 103 },
- { key: 'random-label-emccutcheon2v', count: 104 },
- { key: 'random-label-hcowser2w', count: 105 },
- { key: 'random-label-dspeeding2x', count: 106 },
- { key: 'random-label-oseebright2y', count: 107 },
- { key: 'random-label-hpresdee2z', count: 108 },
- { key: 'random-label-pesseby30', count: 109 },
- { key: 'random-label-hpusey31', count: 110 },
- { key: 'random-label-dmanthorpe32', count: 111 },
- { key: 'random-label-natley33', count: 112 },
- { key: 'random-label-iferentz34', count: 113 },
- { key: 'random-label-adyble35', count: 114 },
- { key: 'random-label-dlockitt36', count: 115 },
- { key: 'random-label-acoxwell37', count: 116 },
- { key: 'random-label-amcgarvey38', count: 117 },
- { key: 'random-label-rmcgougan39', count: 118 },
- { key: 'random-label-mscole3a', count: 119 },
- { key: 'random-label-lmalim3b', count: 120 },
- { key: 'random-label-cends3c', count: 121 },
- { key: 'random-label-dmannie3d', count: 122 },
- { key: 'random-label-lgoodricke3e', count: 123 },
- { key: 'random-label-rcaghy3f', count: 124 },
- { key: 'random-label-mprozillo3g', count: 125 },
- { key: 'random-label-mcardnell3h', count: 126 },
- { key: 'random-label-gericssen3i', count: 127 },
- { key: 'random-label-fspooner3j', count: 128 },
- { key: 'random-label-achadney3k', count: 129 },
- { key: 'random-label-corchard3l', count: 130 },
- { key: 'random-label-lyerill3m', count: 131 },
- { key: 'random-label-jrusk3n', count: 132 },
- { key: 'random-label-lbonelle3o', count: 133 },
- { key: 'random-label-eduny3p', count: 134 },
- { key: 'random-label-mhutchence3q', count: 135 },
- { key: 'random-label-rmargeram3r', count: 136 },
- { key: 'random-label-smaudlin3s', count: 137 },
- { key: 'random-label-sfarrance3t', count: 138 },
- { key: 'random-label-eclendennen3u', count: 139 },
- { key: 'random-label-cyabsley3v', count: 140 },
- { key: 'random-label-ahensmans3w', count: 141 },
- { key: 'random-label-tsenchenko3x', count: 142 },
- { key: 'random-label-ryurchishin3y', count: 143 },
- { key: 'random-label-teby3z', count: 144 },
- { key: 'random-label-dvaillant40', count: 145 },
- { key: 'random-label-kpetyakov41', count: 146 },
- { key: 'random-label-cmorrison42', count: 147 },
- { key: 'random-label-ltwiddy43', count: 148 },
- { key: 'random-label-ineame44', count: 149 },
- { key: 'random-label-blucock45', count: 150 },
- { key: 'random-label-kdunsford46', count: 151 },
- { key: 'random-label-dducham47', count: 152 },
- { key: 'random-label-javramovitz48', count: 153 },
- { key: 'random-label-mascraft49', count: 154 },
- { key: 'random-label-bloughead4a', count: 155 },
- { key: 'random-label-sduckit4b', count: 156 },
- { key: 'random-label-hhardman4c', count: 157 },
- { key: 'random-label-cstaniforth4d', count: 158 },
- { key: 'random-label-jedney4e', count: 159 },
- { key: 'random-label-bobbard4f', count: 160 },
- { key: 'random-label-cgiraux4g', count: 161 },
- { key: 'random-label-tkiln4h', count: 162 },
- { key: 'random-label-jwansbury4i', count: 163 },
- { key: 'random-label-dquinlan4j', count: 164 },
- { key: 'random-label-hgindghill4k', count: 165 },
- { key: 'random-label-jjowle4l', count: 166 },
- { key: 'random-label-egambrell4m', count: 167 },
- { key: 'random-label-jmcgloughlin4n', count: 168 },
- { key: 'random-label-bbabb4o', count: 169 },
- { key: 'random-label-achuck4p', count: 170 },
- { key: 'random-label-tsyers4q', count: 171 },
- { key: 'random-label-jlandon4r', count: 172 },
- { key: 'random-label-wteather4s', count: 173 },
- { key: 'random-label-dfoskin4t', count: 174 },
- { key: 'random-label-gmorlon4u', count: 175 },
- { key: 'random-label-jseely4v', count: 176 },
- { key: 'random-label-cbrass4w', count: 177 },
- { key: 'random-label-fmanilo4x', count: 178 },
- { key: 'random-label-bfrangleton4y', count: 179 },
- { key: 'random-label-vbartkiewicz4z', count: 180 },
- { key: 'random-label-tclymer50', count: 181 },
- { key: 'random-label-pqueen51', count: 182 },
- { key: 'random-label-bpol52', count: 183 },
- { key: 'random-label-jclaeskens53', count: 184 },
- { key: 'random-label-cstranieri54', count: 185 },
- { key: 'random-label-drumbelow55', count: 186 },
- { key: 'random-label-wbrumham56', count: 187 },
- { key: 'random-label-azeal57', count: 188 },
- { key: 'random-label-msnooks58', count: 189 },
- { key: 'random-label-blapre59', count: 190 },
- { key: 'random-label-cduckers5a', count: 191 },
- { key: 'random-label-mgumary5b', count: 192 },
- { key: 'random-label-rtebbs5c', count: 193 },
- { key: 'random-label-eroe5d', count: 194 },
- { key: 'random-label-rconfait5e', count: 195 },
- { key: 'random-label-fsinderland5f', count: 196 },
- { key: 'random-label-tdallywater5g', count: 197 },
- { key: 'random-label-glindenman5h', count: 198 },
- { key: 'random-label-fbauser5i', count: 199 },
- { key: 'random-label-bdownton5j', count: 200 },
- ],
- },
-];
-
export const MOCK_LANGUAGE_AGGREGATIONS_BUCKETS = [
{ key: 'random-label-edumingos0', count: 1 },
{ key: 'random-label-rbourgourd1', count: 2 },
@@ -604,13 +397,27 @@ export const MOCK_LANGUAGE_AGGREGATIONS_BUCKETS = [
{ key: 'random-label-bdownton5j', count: 200 },
];
+export const MOCK_AGGREGATIONS = [
+ {
+ name: 'language',
+ buckets: MOCK_LANGUAGE_AGGREGATIONS_BUCKETS,
+ },
+];
+
+export const SORTED_MOCK_AGGREGATIONS = [
+ {
+ name: 'language',
+ buckets: MOCK_LANGUAGE_AGGREGATIONS_BUCKETS.reverse(),
+ },
+];
+
export const MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION = [
{
type: types.REQUEST_AGGREGATIONS,
},
{
type: types.RECEIVE_AGGREGATIONS_SUCCESS,
- payload: MOCK_AGGREGATIONS,
+ payload: SORTED_MOCK_AGGREGATIONS,
},
];
@@ -653,3 +460,85 @@ export const TEST_FILTER_DATA = {
JSON: { label: 'JSON', value: 'JSON', count: 15 },
},
};
+
+export const SMALL_MOCK_AGGREGATIONS = [
+ {
+ name: 'language',
+ buckets: TEST_RAW_BUCKETS,
+ },
+];
+
+export const MOCK_NAVIGATION_ITEMS = [
+ {
+ title: 'Projects',
+ icon: 'project',
+ link: '/search?scope=projects&search=et',
+ is_active: false,
+ pill_count: '10K+',
+ items: [],
+ },
+ {
+ title: 'Code',
+ icon: 'code',
+ link: '/search?scope=blobs&search=et',
+ is_active: false,
+ pill_count: '0',
+ items: [],
+ },
+ {
+ title: 'Issues',
+ icon: 'issues',
+ link: '/search?scope=issues&search=et',
+ is_active: true,
+ pill_count: '2.4K',
+ items: [],
+ },
+ {
+ title: 'Merge requests',
+ icon: 'merge-request',
+ link: '/search?scope=merge_requests&search=et',
+ is_active: false,
+ pill_count: '0',
+ items: [],
+ },
+ {
+ title: 'Wiki',
+ icon: 'overview',
+ link: '/search?scope=wiki_blobs&search=et',
+ is_active: false,
+ pill_count: '0',
+ items: [],
+ },
+ {
+ title: 'Commits',
+ icon: 'commit',
+ link: '/search?scope=commits&search=et',
+ is_active: false,
+ pill_count: '0',
+ items: [],
+ },
+ {
+ title: 'Comments',
+ icon: 'comments',
+ link: '/search?scope=notes&search=et',
+ is_active: false,
+ pill_count: '0',
+ items: [],
+ },
+ {
+ title: 'Milestones',
+ icon: 'tag',
+ link: '/search?scope=milestones&search=et',
+ is_active: false,
+ pill_count: '0',
+ items: [],
+ },
+ {
+ title: 'Users',
+ icon: 'users',
+ link: '/search?scope=users&search=et',
+ is_active: false,
+ pill_count: '0',
+ items: [],
+ },
+];
diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js
index 83302b90233..963b73aeae5 100644
--- a/spec/frontend/search/sidebar/components/app_spec.js
+++ b/spec/frontend/search/sidebar/components/app_spec.js
@@ -5,7 +5,7 @@ import { MOCK_QUERY } from 'jest/search/mock_data';
import GlobalSearchSidebar from '~/search/sidebar/components/app.vue';
import ResultsFilters from '~/search/sidebar/components/results_filters.vue';
import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue';
-import LanguageFilter from '~/search/sidebar/components/language_filter.vue';
+import LanguageFilter from '~/search/sidebar/components/language_filter/index.vue';
Vue.use(Vuex);
@@ -17,6 +17,10 @@ describe('GlobalSearchSidebar', () => {
resetQuery: jest.fn(),
};
+ const getterSpies = {
+ currentScope: jest.fn(() => 'issues'),
+ };
+
const createComponent = (initialState, featureFlags) => {
const store = new Vuex.Store({
state: {
@@ -24,6 +28,7 @@ describe('GlobalSearchSidebar', () => {
...initialState,
},
actions: actionSpies,
+ getters: getterSpies,
});
wrapper = shallowMount(GlobalSearchSidebar, {
@@ -52,22 +57,23 @@ describe('GlobalSearchSidebar', () => {
});
describe.each`
- scope | showFilters | ShowsLanguage
+ scope | showFilters | showsLanguage
${'issues'} | ${true} | ${false}
${'merge_requests'} | ${true} | ${false}
${'projects'} | ${false} | ${false}
${'blobs'} | ${false} | ${true}
- `('sidebar scope: $scope', ({ scope, showFilters, ShowsLanguage }) => {
+ `('sidebar scope: $scope', ({ scope, showFilters, showsLanguage }) => {
beforeEach(() => {
- createComponent({ urlQuery: { scope } }, { searchBlobsLanguageAggregation: true });
+ getterSpies.currentScope = jest.fn(() => scope);
+ createComponent({ urlQuery: { scope } });
});
it(`${!showFilters ? "doesn't" : ''} shows filters`, () => {
expect(findFilters().exists()).toBe(showFilters);
});
- it(`${!ShowsLanguage ? "doesn't" : ''} shows language filters`, () => {
- expect(findLanguageAggregation().exists()).toBe(ShowsLanguage);
+ it(`${!showsLanguage ? "doesn't" : ''} shows language filters`, () => {
+ expect(findLanguageAggregation().exists()).toBe(showsLanguage);
});
});
@@ -80,22 +86,4 @@ describe('GlobalSearchSidebar', () => {
});
});
});
-
- describe('when search_blobs_language_aggregation is enabled', () => {
- beforeEach(() => {
- createComponent({ urlQuery: { scope: 'blobs' } }, { searchBlobsLanguageAggregation: true });
- });
- it('shows the language filter', () => {
- expect(findLanguageAggregation().exists()).toBe(true);
- });
- });
-
- describe('when search_blobs_language_aggregation is disabled', () => {
- beforeEach(() => {
- createComponent({ urlQuery: { scope: 'blobs' } }, { searchBlobsLanguageAggregation: false });
- });
- it('hides the language filter', () => {
- expect(findLanguageAggregation().exists()).toBe(false);
- });
- });
});
diff --git a/spec/frontend/search/sidebar/components/checkbox_filter_spec.js b/spec/frontend/search/sidebar/components/checkbox_filter_spec.js
index 82017754b23..3907e199cae 100644
--- a/spec/frontend/search/sidebar/components/checkbox_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/checkbox_filter_spec.js
@@ -1,44 +1,55 @@
import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { MOCK_QUERY, MOCK_LANGUAGE_AGGREGATIONS_BUCKETS } from 'jest/search/mock_data';
-import CheckboxFilter from '~/search/sidebar/components/checkbox_filter.vue';
+import CheckboxFilter, {
+ TRACKING_LABEL_CHECKBOX,
+ TRACKING_LABEL_SET,
+} from '~/search/sidebar/components/checkbox_filter.vue';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import { convertFiltersData } from '~/search/sidebar/utils';
Vue.use(Vuex);
describe('CheckboxFilter', () => {
let wrapper;
+ let trackingSpy;
const actionSpies = {
setQuery: jest.fn(),
};
+ const getterSpies = {
+ queryLanguageFilters: jest.fn(() => []),
+ };
+
const defaultProps = {
- filterData: convertFiltersData(MOCK_LANGUAGE_AGGREGATIONS_BUCKETS),
+ filtersData: convertFiltersData(MOCK_LANGUAGE_AGGREGATIONS_BUCKETS),
+ trackingNamespace: 'testNameSpace',
};
- const createComponent = () => {
+ const createComponent = (Props = defaultProps) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
},
actions: actionSpies,
+ getters: getterSpies,
});
wrapper = shallowMountExtended(CheckboxFilter, {
store,
propsData: {
- ...defaultProps,
+ ...Props,
},
});
};
- beforeEach(() => {
- createComponent();
+ afterEach(() => {
+ unmockTracking();
});
const findFormCheckboxGroup = () => wrapper.findComponent(GlFormCheckboxGroup);
@@ -47,6 +58,11 @@ describe('CheckboxFilter', () => {
const fintAllCheckboxLabelCounts = () => wrapper.findAllByTestId('labelCount');
describe('Renders correctly', () => {
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
+ });
+
it('renders form', () => {
expect(findFormCheckboxGroup().exists()).toBe(true);
});
@@ -71,15 +87,34 @@ describe('CheckboxFilter', () => {
});
describe('actions', () => {
- it('triggers setQuery', () => {
- const filter =
- defaultProps.filterData.filters[Object.keys(defaultProps.filterData.filters)[0]].value;
- findFormCheckboxGroup().vm.$emit('input', filter);
+ const checkedLanguageName = MOCK_LANGUAGE_AGGREGATIONS_BUCKETS[0].key;
+
+ beforeEach(() => {
+ defaultProps.filtersData = convertFiltersData(MOCK_LANGUAGE_AGGREGATIONS_BUCKETS.slice(0, 3));
+ CheckboxFilter.computed.selectedFilter.get = jest.fn(() => checkedLanguageName);
+ createComponent();
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
+ findFormCheckboxGroup().vm.$emit('input', checkedLanguageName);
+ });
+
+ it('triggers setQuery', () => {
expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
key: languageFilterData.filterParam,
- value: filter,
+ value: checkedLanguageName,
});
});
+
+ it('sends tracking information when setQuery', () => {
+ findFormCheckboxGroup().vm.$emit('input', checkedLanguageName);
+ expect(trackingSpy).toHaveBeenCalledWith(
+ defaultProps.trackingNamespace,
+ TRACKING_LABEL_CHECKBOX,
+ {
+ label: TRACKING_LABEL_SET,
+ property: checkedLanguageName,
+ },
+ );
+ });
});
});
diff --git a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js
index 4f146757454..1f65884e959 100644
--- a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js
@@ -1,25 +1,52 @@
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue';
import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
+Vue.use(Vuex);
+
describe('ConfidentialityFilter', () => {
let wrapper;
- const createComponent = (initProps) => {
+ const createComponent = (state) => {
+ const store = new Vuex.Store({
+ state,
+ });
+
wrapper = shallowMount(ConfidentialityFilter, {
- ...initProps,
+ store,
});
};
const findRadioFilter = () => wrapper.findComponent(RadioFilter);
+ const findHR = () => wrapper.findComponent('hr');
- describe('template', () => {
+ describe('old sidebar', () => {
beforeEach(() => {
- createComponent();
+ createComponent({ useNewNavigation: false });
});
it('renders the component', () => {
expect(findRadioFilter().exists()).toBe(true);
});
+
+ it('renders the divider', () => {
+ expect(findHR().exists()).toBe(true);
+ });
+ });
+
+ describe('new sidebar', () => {
+ beforeEach(() => {
+ createComponent({ useNewNavigation: true });
+ });
+
+ it('renders the component', () => {
+ expect(findRadioFilter().exists()).toBe(true);
+ });
+
+ it("doesn't render the divider", () => {
+ expect(findHR().exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/search/sidebar/components/filters_spec.js b/spec/frontend/search/sidebar/components/filters_spec.js
index 7e564bfa005..d189c695467 100644
--- a/spec/frontend/search/sidebar/components/filters_spec.js
+++ b/spec/frontend/search/sidebar/components/filters_spec.js
@@ -17,6 +17,10 @@ describe('GlobalSearchSidebarFilters', () => {
resetQuery: jest.fn(),
};
+ const defaultGetters = {
+ currentScope: () => 'issues',
+ };
+
const createComponent = (initialState) => {
const store = new Vuex.Store({
state: {
@@ -24,6 +28,7 @@ describe('GlobalSearchSidebarFilters', () => {
...initialState,
},
actions: actionSpies,
+ getters: defaultGetters,
});
wrapper = shallowMount(ResultsFilters, {
@@ -31,10 +36,6 @@ describe('GlobalSearchSidebarFilters', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSidebarForm = () => wrapper.find('form');
const findStatusFilter = () => wrapper.findComponent(StatusFilter);
const findConfidentialityFilter = () => wrapper.findComponent(ConfidentialityFilter);
@@ -65,7 +66,7 @@ describe('GlobalSearchSidebarFilters', () => {
});
it('disables the button', () => {
- expect(findApplyButton().attributes('disabled')).toBe('true');
+ expect(findApplyButton().attributes('disabled')).toBeDefined();
});
});
@@ -142,7 +143,11 @@ describe('GlobalSearchSidebarFilters', () => {
${'blobs'} | ${false}
`(`ConfidentialityFilter`, ({ scope, showFilter }) => {
beforeEach(() => {
- createComponent({ urlQuery: { scope } });
+ defaultGetters.currentScope = () => scope;
+ createComponent();
+ });
+ afterEach(() => {
+ defaultGetters.currentScope = () => 'issues';
});
it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => {
@@ -162,7 +167,11 @@ describe('GlobalSearchSidebarFilters', () => {
${'blobs'} | ${false}
`(`StatusFilter`, ({ scope, showFilter }) => {
beforeEach(() => {
- createComponent({ urlQuery: { scope } });
+ defaultGetters.currentScope = () => scope;
+ createComponent();
+ });
+ afterEach(() => {
+ defaultGetters.currentScope = () => 'issues';
});
it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => {
diff --git a/spec/frontend/search/sidebar/components/language_filter_spec.js b/spec/frontend/search/sidebar/components/language_filter_spec.js
new file mode 100644
index 00000000000..9ad9d095aca
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/language_filter_spec.js
@@ -0,0 +1,222 @@
+import { GlAlert, GlFormCheckbox, GlForm } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ MOCK_QUERY,
+ MOCK_AGGREGATIONS,
+ MOCK_LANGUAGE_AGGREGATIONS_BUCKETS,
+} from 'jest/search/mock_data';
+import LanguageFilter from '~/search/sidebar/components/language_filter/index.vue';
+import CheckboxFilter from '~/search/sidebar/components/checkbox_filter.vue';
+
+import {
+ TRACKING_LABEL_SHOW_MORE,
+ TRACKING_CATEGORY,
+ TRACKING_PROPERTY_MAX,
+ TRACKING_LABEL_MAX,
+ TRACKING_LABEL_FILTERS,
+ TRACKING_ACTION_SHOW,
+ TRACKING_ACTION_CLICK,
+ TRACKING_LABEL_APPLY,
+ TRACKING_LABEL_ALL,
+} from '~/search/sidebar/components/language_filter/tracking';
+
+import { MAX_ITEM_LENGTH } from '~/search/sidebar/components/language_filter/data';
+
+Vue.use(Vuex);
+
+describe('GlobalSearchSidebarLanguageFilter', () => {
+ let wrapper;
+ let trackingSpy;
+
+ const actionSpies = {
+ fetchLanguageAggregation: jest.fn(),
+ applyQuery: jest.fn(),
+ };
+
+ const getterSpies = {
+ languageAggregationBuckets: jest.fn(() => MOCK_LANGUAGE_AGGREGATIONS_BUCKETS),
+ queryLanguageFilters: jest.fn(() => []),
+ };
+
+ const createComponent = (initialState) => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ urlQuery: MOCK_QUERY,
+ aggregations: MOCK_AGGREGATIONS,
+ ...initialState,
+ },
+ actions: actionSpies,
+ getters: getterSpies,
+ });
+
+ wrapper = shallowMountExtended(LanguageFilter, {
+ store,
+ stubs: {
+ CheckboxFilter,
+ },
+ });
+ };
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findCheckboxFilter = () => wrapper.findComponent(CheckboxFilter);
+ const findApplyButton = () => wrapper.findByTestId('apply-button');
+ const findResetButton = () => wrapper.findByTestId('reset-button');
+ const findShowMoreButton = () => wrapper.findByTestId('show-more-button');
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findAllCheckboxes = () => wrapper.findAllComponents(GlFormCheckbox);
+ const findHasOverMax = () => wrapper.findByTestId('has-over-max-text');
+
+ describe('Renders correctly', () => {
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ it('renders form', () => {
+ expect(findForm().exists()).toBe(true);
+ });
+
+ it('renders checkbox-filter', () => {
+ expect(findCheckboxFilter().exists()).toBe(true);
+ });
+
+ it('renders all checkbox-filter checkboxes', () => {
+ // 11th checkbox is hidden
+ expect(findAllCheckboxes()).toHaveLength(10);
+ });
+
+ it('renders ApplyButton', () => {
+ expect(findApplyButton().exists()).toBe(true);
+ });
+
+ it('renders Show More button', () => {
+ expect(findShowMoreButton().exists()).toBe(true);
+ });
+
+ it("doesn't render Alert", () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('resetButton', () => {
+ describe.each`
+ description | sidebarDirty | queryFilters | isDisabled
+ ${'sidebar dirty only'} | ${true} | ${[]} | ${undefined}
+ ${'query filters only'} | ${false} | ${['JSON', 'C']} | ${undefined}
+ ${'sidebar dirty and query filters'} | ${true} | ${['JSON', 'C']} | ${undefined}
+ ${'no sidebar and no query filters'} | ${false} | ${[]} | ${'true'}
+ `('$description', ({ sidebarDirty, queryFilters, isDisabled }) => {
+ beforeEach(() => {
+ getterSpies.queryLanguageFilters = jest.fn(() => queryFilters);
+ createComponent({ sidebarDirty, query: { ...MOCK_QUERY, language: queryFilters } });
+ });
+
+ it(`button is ${isDisabled ? 'enabled' : 'disabled'}`, () => {
+ expect(findResetButton().attributes('disabled')).toBe(isDisabled);
+ });
+ });
+ });
+
+ describe('ApplyButton', () => {
+ describe('when sidebarDirty is false', () => {
+ beforeEach(() => {
+ createComponent({ sidebarDirty: false });
+ });
+
+ it('disables the button', () => {
+ expect(findApplyButton().attributes('disabled')).toBeDefined();
+ });
+ });
+
+ describe('when sidebarDirty is true', () => {
+ beforeEach(() => {
+ createComponent({ sidebarDirty: true });
+ });
+
+ it('enables the button', () => {
+ expect(findApplyButton().attributes('disabled')).toBe(undefined);
+ });
+ });
+ });
+
+ describe('Show All button works', () => {
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ it(`renders ${MAX_ITEM_LENGTH} amount of items`, async () => {
+ findShowMoreButton().vm.$emit('click');
+
+ await nextTick();
+
+ expect(findAllCheckboxes()).toHaveLength(MAX_ITEM_LENGTH);
+ });
+
+ it('sends tracking information when show more clicked', () => {
+ findShowMoreButton().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_CLICK, TRACKING_LABEL_SHOW_MORE, {
+ label: TRACKING_LABEL_ALL,
+ });
+ });
+
+ it(`renders more then ${MAX_ITEM_LENGTH} text`, async () => {
+ findShowMoreButton().vm.$emit('click');
+ await nextTick();
+ expect(findHasOverMax().exists()).toBe(true);
+ });
+
+ it('sends tracking information when show more clicked and max item reached', () => {
+ findShowMoreButton().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_SHOW, TRACKING_LABEL_FILTERS, {
+ label: TRACKING_LABEL_MAX,
+ property: TRACKING_PROPERTY_MAX,
+ });
+ });
+
+ it(`doesn't render show more button after click`, async () => {
+ findShowMoreButton().vm.$emit('click');
+ await nextTick();
+ expect(findShowMoreButton().exists()).toBe(false);
+ });
+ });
+
+ describe('actions', () => {
+ beforeEach(() => {
+ createComponent({});
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ it('uses getter languageAggregationBuckets', () => {
+ expect(getterSpies.languageAggregationBuckets).toHaveBeenCalled();
+ });
+
+ it('uses action fetchLanguageAggregation', () => {
+ expect(actionSpies.fetchLanguageAggregation).toHaveBeenCalled();
+ });
+
+ it('clicking ApplyButton calls applyQuery', () => {
+ findForm().vm.$emit('submit', { preventDefault: () => {} });
+
+ expect(actionSpies.applyQuery).toHaveBeenCalled();
+ });
+
+ it('sends tracking information clicking ApplyButton', () => {
+ findForm().vm.$emit('submit', { preventDefault: () => {} });
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, {
+ label: TRACKING_CATEGORY,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search/sidebar/components/language_filters_spec.js b/spec/frontend/search/sidebar/components/language_filters_spec.js
deleted file mode 100644
index e297d1c33b0..00000000000
--- a/spec/frontend/search/sidebar/components/language_filters_spec.js
+++ /dev/null
@@ -1,152 +0,0 @@
-import { GlAlert, GlFormCheckbox, GlForm } from '@gitlab/ui';
-import Vue, { nextTick } from 'vue';
-import Vuex from 'vuex';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import {
- MOCK_QUERY,
- MOCK_AGGREGATIONS,
- MOCK_LANGUAGE_AGGREGATIONS_BUCKETS,
-} from 'jest/search/mock_data';
-import LanguageFilter from '~/search/sidebar/components/language_filter.vue';
-import CheckboxFilter from '~/search/sidebar/components/checkbox_filter.vue';
-import { MAX_ITEM_LENGTH } from '~/search/sidebar/constants/language_filter_data';
-
-Vue.use(Vuex);
-
-describe('GlobalSearchSidebarLanguageFilter', () => {
- let wrapper;
-
- const actionSpies = {
- fetchLanguageAggregation: jest.fn(),
- applyQuery: jest.fn(),
- };
-
- const getterSpies = {
- langugageAggregationBuckets: jest.fn(() => MOCK_LANGUAGE_AGGREGATIONS_BUCKETS),
- };
-
- const createComponent = (initialState) => {
- const store = new Vuex.Store({
- state: {
- query: MOCK_QUERY,
- urlQuery: MOCK_QUERY,
- aggregations: MOCK_AGGREGATIONS,
- ...initialState,
- },
- actions: actionSpies,
- getters: getterSpies,
- });
-
- wrapper = shallowMountExtended(LanguageFilter, {
- store,
- stubs: {
- CheckboxFilter,
- },
- });
- };
-
- const findForm = () => wrapper.findComponent(GlForm);
- const findCheckboxFilter = () => wrapper.findComponent(CheckboxFilter);
- const findApplyButton = () => wrapper.findByTestId('apply-button');
- const findShowMoreButton = () => wrapper.findByTestId('show-more-button');
- const findAlert = () => wrapper.findComponent(GlAlert);
- const findAllCheckboxes = () => wrapper.findAllComponents(GlFormCheckbox);
- const findHasOverMax = () => wrapper.findByTestId('has-over-max-text');
-
- describe('Renders correctly', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders form', () => {
- expect(findForm().exists()).toBe(true);
- });
-
- it('renders checkbox-filter', () => {
- expect(findCheckboxFilter().exists()).toBe(true);
- });
-
- it('renders all checkbox-filter checkboxes', () => {
- // 11th checkbox is hidden
- expect(findAllCheckboxes()).toHaveLength(10);
- });
-
- it('renders ApplyButton', () => {
- expect(findApplyButton().exists()).toBe(true);
- });
-
- it('renders Show More button', () => {
- expect(findShowMoreButton().exists()).toBe(true);
- });
-
- it("doesn't render Alert", () => {
- expect(findAlert().exists()).toBe(false);
- });
- });
-
- describe('ApplyButton', () => {
- describe('when sidebarDirty is false', () => {
- beforeEach(() => {
- createComponent({ sidebarDirty: false });
- });
-
- it('disables the button', () => {
- expect(findApplyButton().attributes('disabled')).toBe('true');
- });
- });
-
- describe('when sidebarDirty is true', () => {
- beforeEach(() => {
- createComponent({ sidebarDirty: true });
- });
-
- it('enables the button', () => {
- expect(findApplyButton().attributes('disabled')).toBe(undefined);
- });
- });
- });
-
- describe('Show All button works', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it(`renders ${MAX_ITEM_LENGTH} amount of items`, async () => {
- findShowMoreButton().vm.$emit('click');
- await nextTick();
- expect(findAllCheckboxes()).toHaveLength(MAX_ITEM_LENGTH);
- });
-
- it(`renders more then ${MAX_ITEM_LENGTH} text`, async () => {
- findShowMoreButton().vm.$emit('click');
- await nextTick();
- expect(findHasOverMax().exists()).toBe(true);
- });
-
- it(`doesn't render show more button after click`, async () => {
- findShowMoreButton().vm.$emit('click');
- await nextTick();
- expect(findShowMoreButton().exists()).toBe(false);
- });
- });
-
- describe('actions', () => {
- beforeEach(() => {
- createComponent({});
- });
-
- it('uses getter langugageAggregationBuckets', () => {
- expect(getterSpies.langugageAggregationBuckets).toHaveBeenCalled();
- });
-
- it('uses action fetchLanguageAggregation', () => {
- expect(actionSpies.fetchLanguageAggregation).toHaveBeenCalled();
- });
-
- it('clicking ApplyButton calls applyQuery', () => {
- findForm().vm.$emit('submit', { preventDefault: () => {} });
-
- expect(actionSpies.applyQuery).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/search/sidebar/components/radio_filter_spec.js b/spec/frontend/search/sidebar/components/radio_filter_spec.js
index 94d529348a9..47235b828c3 100644
--- a/spec/frontend/search/sidebar/components/radio_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/radio_filter_spec.js
@@ -16,6 +16,10 @@ describe('RadioFilter', () => {
setQuery: jest.fn(),
};
+ const defaultGetters = {
+ currentScope: jest.fn(() => 'issues'),
+ };
+
const defaultProps = {
filterData: stateFilterData,
};
@@ -27,6 +31,7 @@ describe('RadioFilter', () => {
...initialState,
},
actions: actionSpies,
+ getters: defaultGetters,
});
wrapper = shallowMount(RadioFilter, {
@@ -38,11 +43,6 @@ describe('RadioFilter', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findGlRadioButtonGroup = () => wrapper.findComponent(GlFormRadioGroup);
const findGlRadioButtons = () => findGlRadioButtonGroup().findAllComponents(GlFormRadio);
const findGlRadioButtonsText = () => findGlRadioButtons().wrappers.map((w) => w.text());
diff --git a/spec/frontend/search/sidebar/components/scope_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_navigation_spec.js
index 23c158239dc..e8737384f27 100644
--- a/spec/frontend/search/sidebar/components/scope_navigation_spec.js
+++ b/spec/frontend/search/sidebar/components/scope_navigation_spec.js
@@ -14,6 +14,10 @@ describe('ScopeNavigation', () => {
fetchSidebarCount: jest.fn(),
};
+ const getterSpies = {
+ currentScope: jest.fn(() => 'issues'),
+ };
+
const createComponent = (initialState) => {
const store = new Vuex.Store({
state: {
@@ -22,6 +26,7 @@ describe('ScopeNavigation', () => {
...initialState,
},
actions: actionSpies,
+ getters: getterSpies,
});
wrapper = shallowMount(ScopeNavigation, {
@@ -29,16 +34,12 @@ describe('ScopeNavigation', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findNavElement = () => wrapper.find('nav');
const findGlNav = () => wrapper.findComponent(GlNav);
const findGlNavItems = () => wrapper.findAllComponents(GlNavItem);
- const findGlNavItemActive = () => findGlNavItems().wrappers.filter((w) => w.attributes('active'));
- const findGlNavItemActiveLabel = () => findGlNavItemActive().at(0).findAll('span').at(0).text();
- const findGlNavItemActiveCount = () => findGlNavItemActive().at(0).findAll('span').at(1);
+ const findGlNavItemActive = () => wrapper.find('[active=true]');
+ const findGlNavItemActiveLabel = () => findGlNavItemActive().find('[data-testid="label"]');
+ const findGlNavItemActiveCount = () => findGlNavItemActive().find('[data-testid="count"]');
describe('scope navigation', () => {
beforeEach(() => {
@@ -71,8 +72,8 @@ describe('ScopeNavigation', () => {
});
it('has correct active item', () => {
- expect(findGlNavItemActive()).toHaveLength(1);
- expect(findGlNavItemActiveLabel()).toBe('Issues');
+ expect(findGlNavItemActive().exists()).toBe(true);
+ expect(findGlNavItemActiveLabel().text()).toBe('Issues');
});
it('has correct active item count', () => {
@@ -80,7 +81,7 @@ describe('ScopeNavigation', () => {
});
it('does not have plus sign after count text', () => {
- expect(findGlNavItemActive().at(0).findComponent(GlIcon).exists()).toBe(false);
+ expect(findGlNavItemActive().findComponent(GlIcon).exists()).toBe(false);
});
it('has count is highlighted correctly', () => {
@@ -90,14 +91,26 @@ describe('ScopeNavigation', () => {
describe('scope navigation sets proper state with NO url scope set', () => {
beforeEach(() => {
+ getterSpies.currentScope = jest.fn(() => 'projects');
createComponent({
urlQuery: {},
+ navigation: {
+ ...MOCK_NAVIGATION,
+ projects: {
+ ...MOCK_NAVIGATION.projects,
+ active: true,
+ },
+ issues: {
+ ...MOCK_NAVIGATION.issues,
+ active: false,
+ },
+ },
});
});
it('has correct active item', () => {
- expect(findGlNavItems().at(0).attributes('active')).toBe('true');
- expect(findGlNavItemActiveLabel()).toBe('Projects');
+ expect(findGlNavItemActive().exists()).toBe(true);
+ expect(findGlNavItemActiveLabel().text()).toBe('Projects');
});
it('has correct active item count', () => {
@@ -105,7 +118,25 @@ describe('ScopeNavigation', () => {
});
it('has correct active item count and over limit sign', () => {
- expect(findGlNavItemActive().at(0).findComponent(GlIcon).exists()).toBe(true);
+ expect(findGlNavItemActive().findComponent(GlIcon).exists()).toBe(true);
+ });
+ });
+
+ describe.each`
+ searchTherm | hasBeenCalled
+ ${null} | ${0}
+ ${'test'} | ${1}
+ `('fetchSidebarCount', ({ searchTherm, hasBeenCalled }) => {
+ beforeEach(() => {
+ createComponent({
+ urlQuery: {
+ search: searchTherm,
+ },
+ });
+ });
+
+ it('is only called when search term is set', () => {
+ expect(actionSpies.fetchSidebarCount).toHaveBeenCalledTimes(hasBeenCalled);
});
});
});
diff --git a/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js
new file mode 100644
index 00000000000..5207665f883
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js
@@ -0,0 +1,83 @@
+import { mount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
+import ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { MOCK_QUERY, MOCK_NAVIGATION, MOCK_NAVIGATION_ITEMS } from '../../mock_data';
+
+Vue.use(Vuex);
+
+describe('ScopeNewNavigation', () => {
+ let wrapper;
+
+ const actionSpies = {
+ fetchSidebarCount: jest.fn(),
+ };
+
+ const getterSpies = {
+ currentScope: jest.fn(() => 'issues'),
+ navigationItems: jest.fn(() => MOCK_NAVIGATION_ITEMS),
+ };
+
+ const createComponent = (initialState) => {
+ const store = new Vuex.Store({
+ state: {
+ urlQuery: MOCK_QUERY,
+ navigation: MOCK_NAVIGATION,
+ ...initialState,
+ },
+ actions: actionSpies,
+ getters: getterSpies,
+ });
+
+ wrapper = mount(ScopeNewNavigation, {
+ store,
+ stubs: {
+ NavItem,
+ },
+ });
+ };
+
+ const findNavElement = () => wrapper.findComponent('nav');
+ const findNavItems = () => wrapper.findAllComponents(NavItem);
+ const findNavItemActive = () => wrapper.find('[aria-current=page]');
+ const findNavItemActiveLabel = () =>
+ findNavItemActive().find('[class="gl-pr-8 gl-text-gray-900 gl-truncate-end"]');
+
+ describe('scope navigation', () => {
+ beforeEach(() => {
+ createComponent({ urlQuery: { ...MOCK_QUERY, search: 'test' } });
+ });
+
+ it('renders section', () => {
+ expect(findNavElement().exists()).toBe(true);
+ });
+
+ it('calls proper action when rendered', async () => {
+ await nextTick();
+ expect(actionSpies.fetchSidebarCount).toHaveBeenCalled();
+ });
+
+ it('renders all nav item components', () => {
+ expect(findNavItems()).toHaveLength(9);
+ });
+
+ it('has all proper links', () => {
+ const linkAtPosition = 3;
+ const { link } = MOCK_NAVIGATION[Object.keys(MOCK_NAVIGATION)[linkAtPosition]];
+
+ expect(findNavItems().at(linkAtPosition).findComponent('a').attributes('href')).toBe(link);
+ });
+ });
+
+ describe('scope navigation sets proper state with url scope set', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('has correct active item', () => {
+ expect(findNavItemActive().exists()).toBe(true);
+ expect(findNavItemActiveLabel().text()).toBe('Issues');
+ });
+ });
+});
diff --git a/spec/frontend/search/sidebar/components/status_filter_spec.js b/spec/frontend/search/sidebar/components/status_filter_spec.js
index 6704634ef36..a332a43e624 100644
--- a/spec/frontend/search/sidebar/components/status_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/status_filter_spec.js
@@ -1,25 +1,52 @@
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
import StatusFilter from '~/search/sidebar/components/status_filter.vue';
+Vue.use(Vuex);
+
describe('StatusFilter', () => {
let wrapper;
- const createComponent = (initProps) => {
+ const createComponent = (state) => {
+ const store = new Vuex.Store({
+ state,
+ });
+
wrapper = shallowMount(StatusFilter, {
- ...initProps,
+ store,
});
};
const findRadioFilter = () => wrapper.findComponent(RadioFilter);
+ const findHR = () => wrapper.findComponent('hr');
- describe('template', () => {
+ describe('old sidebar', () => {
beforeEach(() => {
- createComponent();
+ createComponent({ useNewNavigation: false });
});
it('renders the component', () => {
expect(findRadioFilter().exists()).toBe(true);
});
+
+ it('renders the divider', () => {
+ expect(findHR().exists()).toBe(true);
+ });
+ });
+
+ describe('new sidebar', () => {
+ beforeEach(() => {
+ createComponent({ useNewNavigation: true });
+ });
+
+ it('renders the component', () => {
+ expect(findRadioFilter().exists()).toBe(true);
+ });
+
+ it("doesn't render the divider", () => {
+ expect(findHR().exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/search/sort/components/app_spec.js b/spec/frontend/search/sort/components/app_spec.js
index a566b9b99d3..322ce1b16ef 100644
--- a/spec/frontend/search/sort/components/app_spec.js
+++ b/spec/frontend/search/sort/components/app_spec.js
@@ -38,11 +38,6 @@ describe('GlobalSearchSort', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findSortButtonGroup = () => wrapper.findComponent(GlButtonGroup);
const findSortDropdown = () => wrapper.findComponent(GlDropdown);
const findSortDirectionButton = () => wrapper.findComponent(GlButton);
diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js
index 2f87802dfe6..0884411df0c 100644
--- a/spec/frontend/search/store/actions_spec.js
+++ b/spec/frontend/search/store/actions_spec.js
@@ -1,7 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import * as logger from '~/lib/logger';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
@@ -33,7 +33,7 @@ import {
MOCK_AGGREGATIONS,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
setUrlParams: jest.fn(),
joinPaths: jest.fn().mockReturnValue(''),
@@ -47,7 +47,7 @@ describe('Global Search Store Actions', () => {
let mock;
let state;
- const flashCallback = (callCount) => {
+ const alertCallback = (callCount) => {
expect(createAlert).toHaveBeenCalledTimes(callCount);
createAlert.mockClear();
};
@@ -63,12 +63,12 @@ describe('Global Search Store Actions', () => {
});
describe.each`
- action | axiosMock | type | expectedMutations | flashCallCount
+ action | axiosMock | type | expectedMutations | alertCallCount
${actions.fetchGroups} | ${{ method: 'onGet', code: HTTP_STATUS_OK, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${0}
${actions.fetchGroups} | ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${1}
${actions.fetchProjects} | ${{ method: 'onGet', code: HTTP_STATUS_OK, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${0}
${actions.fetchProjects} | ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${1}
- `(`axios calls`, ({ action, axiosMock, type, expectedMutations, flashCallCount }) => {
+ `(`axios calls`, ({ action, axiosMock, type, expectedMutations, alertCallCount }) => {
describe(action.name, () => {
describe(`on ${type}`, () => {
beforeEach(() => {
@@ -76,7 +76,7 @@ describe('Global Search Store Actions', () => {
});
it(`should dispatch the correct mutations`, () => {
return testAction({ action, state, expectedMutations }).then(() =>
- flashCallback(flashCallCount),
+ alertCallback(alertCallCount),
);
});
});
@@ -84,12 +84,12 @@ describe('Global Search Store Actions', () => {
});
describe.each`
- action | axiosMock | type | expectedMutations | flashCallCount
+ action | axiosMock | type | expectedMutations | alertCallCount
${actions.loadFrequentGroups} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.resGroups]} | ${0}
${actions.loadFrequentGroups} | ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR }} | ${'error'} | ${[]} | ${1}
${actions.loadFrequentProjects} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.resProjects]} | ${0}
${actions.loadFrequentProjects} | ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR }} | ${'error'} | ${[]} | ${1}
- `('Promise.all calls', ({ action, axiosMock, type, expectedMutations, flashCallCount }) => {
+ `('Promise.all calls', ({ action, axiosMock, type, expectedMutations, alertCallCount }) => {
describe(action.name, () => {
describe(`on ${type}`, () => {
beforeEach(() => {
@@ -103,7 +103,7 @@ describe('Global Search Store Actions', () => {
it(`should dispatch the correct mutations`, () => {
return testAction({ action, state, expectedMutations }).then(() => {
- flashCallback(flashCallCount);
+ alertCallback(alertCallCount);
});
});
});
@@ -275,7 +275,7 @@ describe('Global Search Store Actions', () => {
describe.each`
action | axiosMock | type | scope | expectedMutations | errorLogs
${actions.fetchSidebarCount} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${'issues'} | ${[MOCK_NAVIGATION_ACTION_MUTATION]} | ${0}
- ${actions.fetchSidebarCount} | ${{ method: null, code: 0 }} | ${'success'} | ${'projects'} | ${[]} | ${0}
+ ${actions.fetchSidebarCount} | ${{ method: null, code: 0 }} | ${'error'} | ${'projects'} | ${[]} | ${1}
${actions.fetchSidebarCount} | ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR }} | ${'error'} | ${'issues'} | ${[]} | ${1}
`('fetchSidebarCount', ({ action, axiosMock, type, expectedMutations, scope, errorLogs }) => {
describe(`on ${type}`, () => {
@@ -290,9 +290,9 @@ describe('Global Search Store Actions', () => {
}
});
- it(`should ${expectedMutations.length === 0 ? 'NOT ' : ''}dispatch ${
- expectedMutations.length === 0 ? '' : 'the correct '
- }mutations for ${scope}`, () => {
+ it(`should ${expectedMutations.length === 0 ? 'NOT' : ''} dispatch ${
+ expectedMutations.length === 0 ? '' : 'the correct'
+ } mutations for ${scope}`, () => {
return testAction({ action, state, expectedMutations }).then(() => {
expect(logger.logError).toHaveBeenCalledTimes(errorLogs);
});
@@ -325,4 +325,26 @@ describe('Global Search Store Actions', () => {
});
});
});
+
+ describe('resetLanguageQueryWithRedirect', () => {
+ it('calls visitUrl and setParams with the state.query', () => {
+ return testAction(actions.resetLanguageQueryWithRedirect, null, state, [], [], () => {
+ expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null });
+ expect(urlUtils.visitUrl).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('resetLanguageQuery', () => {
+ it('calls commit SET_QUERY with value []', () => {
+ state = { ...state, query: { ...state.query, language: ['YAML', 'Text', 'Markdown'] } };
+ return testAction(
+ actions.resetLanguageQuery,
+ null,
+ state,
+ [{ type: types.SET_QUERY, payload: { key: 'language', value: [] } }],
+ [],
+ );
+ });
+ });
});
diff --git a/spec/frontend/search/store/getters_spec.js b/spec/frontend/search/store/getters_spec.js
index 818902ee720..e3b8e7575a4 100644
--- a/spec/frontend/search/store/getters_spec.js
+++ b/spec/frontend/search/store/getters_spec.js
@@ -1,12 +1,16 @@
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
import * as getters from '~/search/store/getters';
import createState from '~/search/store/state';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import {
MOCK_QUERY,
MOCK_GROUPS,
MOCK_PROJECTS,
MOCK_AGGREGATIONS,
MOCK_LANGUAGE_AGGREGATIONS_BUCKETS,
+ TEST_FILTER_DATA,
+ MOCK_NAVIGATION,
+ MOCK_NAVIGATION_ITEMS,
} from '../mock_data';
describe('Global Search Store Getters', () => {
@@ -14,37 +18,62 @@ describe('Global Search Store Getters', () => {
beforeEach(() => {
state = createState({ query: MOCK_QUERY });
+ useMockLocationHelper();
});
describe('frequentGroups', () => {
- beforeEach(() => {
- state.frequentItems[GROUPS_LOCAL_STORAGE_KEY] = MOCK_GROUPS;
- });
-
it('returns the correct data', () => {
+ state.frequentItems[GROUPS_LOCAL_STORAGE_KEY] = MOCK_GROUPS;
expect(getters.frequentGroups(state)).toStrictEqual(MOCK_GROUPS);
});
});
describe('frequentProjects', () => {
- beforeEach(() => {
- state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY] = MOCK_PROJECTS;
- });
-
it('returns the correct data', () => {
+ state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY] = MOCK_PROJECTS;
expect(getters.frequentProjects(state)).toStrictEqual(MOCK_PROJECTS);
});
});
- describe('langugageAggregationBuckets', () => {
- beforeEach(() => {
+ describe('languageAggregationBuckets', () => {
+ it('returns the correct data', () => {
state.aggregations.data = MOCK_AGGREGATIONS;
+ expect(getters.languageAggregationBuckets(state)).toStrictEqual(
+ MOCK_LANGUAGE_AGGREGATIONS_BUCKETS,
+ );
});
+ });
+ describe('queryLanguageFilters', () => {
it('returns the correct data', () => {
- expect(getters.langugageAggregationBuckets(state)).toStrictEqual(
- MOCK_LANGUAGE_AGGREGATIONS_BUCKETS,
- );
+ state.query.language = Object.keys(TEST_FILTER_DATA.filters);
+ expect(getters.queryLanguageFilters(state)).toStrictEqual(state.query.language);
+ });
+ });
+
+ describe('currentScope', () => {
+ it('returns the correct scope name', () => {
+ state.navigation = MOCK_NAVIGATION;
+ expect(getters.currentScope(state)).toBe('issues');
+ });
+ });
+
+ describe('currentUrlQueryHasLanguageFilters', () => {
+ it.each`
+ description | lang | result
+ ${'has valid language'} | ${{ language: ['a', 'b'] }} | ${true}
+ ${'has empty lang'} | ${{ language: [] }} | ${false}
+ ${'has no lang'} | ${{}} | ${false}
+ `('$description', ({ lang, result }) => {
+ state.urlQuery = lang;
+ expect(getters.currentUrlQueryHasLanguageFilters(state)).toBe(result);
+ });
+ });
+
+ describe('navigationItems', () => {
+ it('returns the re-mapped navigation data', () => {
+ state.navigation = MOCK_NAVIGATION;
+ expect(getters.navigationItems(state)).toStrictEqual(MOCK_NAVIGATION_ITEMS);
});
});
});
diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js
index 487ed7bfe03..802c5219799 100644
--- a/spec/frontend/search/store/utils_spec.js
+++ b/spec/frontend/search/store/utils_spec.js
@@ -7,6 +7,8 @@ import {
isSidebarDirty,
formatSearchResultCount,
getAggregationsUrl,
+ prepareSearchAggregations,
+ addCountOverLimit,
} from '~/search/store/utils';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import {
@@ -15,6 +17,9 @@ import {
MOCK_INFLATED_DATA,
FRESH_STORED_DATA,
STALE_STORED_DATA,
+ MOCK_AGGREGATIONS,
+ SMALL_MOCK_AGGREGATIONS,
+ TEST_RAW_BUCKETS,
} from '../mock_data';
const PREV_TIME = new Date().getTime() - 1;
@@ -226,11 +231,14 @@ describe('Global Search Store Utils', () => {
});
describe.each`
- description | currentQuery | urlQuery | isDirty
- ${'identical'} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'default' }} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'default' }} | ${false}
- ${'different'} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'new' }} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'default' }} | ${true}
- ${'null/undefined'} | ${{ [SIDEBAR_PARAMS[0]]: null, [SIDEBAR_PARAMS[1]]: null }} | ${{ [SIDEBAR_PARAMS[0]]: undefined, [SIDEBAR_PARAMS[1]]: undefined }} | ${false}
- ${'updated/undefined'} | ${{ [SIDEBAR_PARAMS[0]]: 'new', [SIDEBAR_PARAMS[1]]: 'new' }} | ${{ [SIDEBAR_PARAMS[0]]: undefined, [SIDEBAR_PARAMS[1]]: undefined }} | ${true}
+ description | currentQuery | urlQuery | isDirty
+ ${'identical'} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'default', [SIDEBAR_PARAMS[2]]: ['a', 'b'] }} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'default', [SIDEBAR_PARAMS[2]]: ['a', 'b'] }} | ${false}
+ ${'different'} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'new', [SIDEBAR_PARAMS[2]]: ['a', 'b'] }} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'default', [SIDEBAR_PARAMS[2]]: ['a', 'c'] }} | ${true}
+ ${'null/undefined'} | ${{ [SIDEBAR_PARAMS[0]]: null, [SIDEBAR_PARAMS[1]]: null, [SIDEBAR_PARAMS[2]]: null }} | ${{ [SIDEBAR_PARAMS[0]]: undefined, [SIDEBAR_PARAMS[1]]: undefined, [SIDEBAR_PARAMS[2]]: undefined }} | ${false}
+ ${'updated/undefined'} | ${{ [SIDEBAR_PARAMS[0]]: 'new', [SIDEBAR_PARAMS[1]]: 'new', [SIDEBAR_PARAMS[2]]: ['a', 'b'] }} | ${{ [SIDEBAR_PARAMS[0]]: undefined, [SIDEBAR_PARAMS[1]]: undefined, [SIDEBAR_PARAMS[2]]: [] }} | ${true}
+ ${'language only no url params'} | ${{ [SIDEBAR_PARAMS[2]]: ['a', 'b'] }} | ${{ [SIDEBAR_PARAMS[2]]: undefined }} | ${true}
+ ${'language only url params symetric'} | ${{ [SIDEBAR_PARAMS[2]]: ['a', 'b'] }} | ${{ [SIDEBAR_PARAMS[2]]: ['a', 'b'] }} | ${false}
+ ${'language only url params asymetric'} | ${{ [SIDEBAR_PARAMS[2]]: ['a'] }} | ${{ [SIDEBAR_PARAMS[2]]: ['a', 'b'] }} | ${true}
`('isSidebarDirty', ({ description, currentQuery, urlQuery, isDirty }) => {
describe(`with ${description} sidebar query data`, () => {
let res;
@@ -263,4 +271,36 @@ describe('Global Search Store Utils', () => {
expect(getAggregationsUrl()).toStrictEqual(`${testURL}search/aggregations`);
});
});
+
+ const TEST_LANGUAGE_QUERY = ['Markdown', 'JSON'];
+ const TEST_EXPECTED_ORDERED_BUCKETS = [
+ TEST_RAW_BUCKETS.find((x) => x.key === 'Markdown'),
+ TEST_RAW_BUCKETS.find((x) => x.key === 'JSON'),
+ ...TEST_RAW_BUCKETS.filter((x) => !TEST_LANGUAGE_QUERY.includes(x.key)),
+ ];
+
+ describe('prepareSearchAggregations', () => {
+ it.each`
+ description | query | data | result
+ ${'has no query'} | ${undefined} | ${MOCK_AGGREGATIONS} | ${MOCK_AGGREGATIONS}
+ ${'has query'} | ${{ language: TEST_LANGUAGE_QUERY }} | ${SMALL_MOCK_AGGREGATIONS} | ${[{ ...SMALL_MOCK_AGGREGATIONS[0], buckets: TEST_EXPECTED_ORDERED_BUCKETS }]}
+ ${'has bad query'} | ${{ language: ['sdf', 'wrt'] }} | ${SMALL_MOCK_AGGREGATIONS} | ${SMALL_MOCK_AGGREGATIONS}
+ `('$description', ({ query, data, result }) => {
+ expect(prepareSearchAggregations({ query }, data)).toStrictEqual(result);
+ });
+ });
+
+ describe('addCountOverLimit', () => {
+ it("should return '+' if count includes '+'", () => {
+ expect(addCountOverLimit('10+')).toEqual('+');
+ });
+
+ it("should return empty string if count does not include '+'", () => {
+ expect(addCountOverLimit('10')).toEqual('');
+ });
+
+ it('should return empty string if count is not provided', () => {
+ expect(addCountOverLimit()).toEqual('');
+ });
+ });
});
diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js
index 3975887cfff..423ec6ff63b 100644
--- a/spec/frontend/search/topbar/components/app_spec.js
+++ b/spec/frontend/search/topbar/components/app_spec.js
@@ -36,10 +36,6 @@ describe('GlobalSearchTopbar', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlSearchBox = () => wrapper.findComponent(GlSearchBoxByClick);
const findGroupFilter = () => wrapper.findComponent(GroupFilter);
const findProjectFilter = () => wrapper.findComponent(ProjectFilter);
diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/topbar/components/group_filter_spec.js
index b2d0297fdc2..78d9efbd686 100644
--- a/spec/frontend/search/topbar/components/group_filter_spec.js
+++ b/spec/frontend/search/topbar/components/group_filter_spec.js
@@ -49,10 +49,6 @@ describe('GroupFilter', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSearchableDropdown = () => wrapper.findComponent(SearchableDropdown);
describe('template', () => {
diff --git a/spec/frontend/search/topbar/components/project_filter_spec.js b/spec/frontend/search/topbar/components/project_filter_spec.js
index 297a536e075..9eda34b1633 100644
--- a/spec/frontend/search/topbar/components/project_filter_spec.js
+++ b/spec/frontend/search/topbar/components/project_filter_spec.js
@@ -49,10 +49,6 @@ describe('ProjectFilter', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSearchableDropdown = () => wrapper.findComponent(SearchableDropdown);
describe('template', () => {
diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js
index e51fe9a4cf9..c911fe53d40 100644
--- a/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js
+++ b/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js
@@ -24,10 +24,6 @@ describe('Global Search Searchable Dropdown Item', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlDropdownItem = () => wrapper.findComponent(GlDropdownItem);
const findGlAvatar = () => wrapper.findComponent(GlAvatar);
const findDropdownTitle = () => wrapper.findByTestId('item-title');
diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js
index de1cefa9e9d..f7d847674eb 100644
--- a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js
+++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js
@@ -1,6 +1,6 @@
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
@@ -40,10 +40,6 @@ describe('Global Search Searchable Dropdown', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlDropdown = () => wrapper.findComponent(GlDropdown);
const findGlDropdownSearch = () => findGlDropdown().findComponent(GlSearchBoxByType);
const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text');
@@ -133,9 +129,7 @@ describe('Global Search Searchable Dropdown', () => {
describe(`when search is ${searchText} and frequentItems length is ${frequentItems.length}`, () => {
beforeEach(() => {
createComponent({}, { frequentItems });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ searchText });
+ findGlDropdownSearch().vm.$emit('input', searchText);
});
it(`should${length ? '' : ' not'} render frequent dropdown items`, () => {
@@ -191,28 +185,33 @@ describe('Global Search Searchable Dropdown', () => {
});
describe('opening the dropdown', () => {
- describe('for the first time', () => {
- beforeEach(() => {
- findGlDropdown().vm.$emit('show');
- });
+ beforeEach(() => {
+ findGlDropdown().vm.$emit('show');
+ });
- it('$emits @search and @first-open', () => {
- expect(wrapper.emitted('search')[0]).toStrictEqual([wrapper.vm.searchText]);
- expect(wrapper.emitted('first-open')[0]).toStrictEqual([]);
- });
+ it('$emits @search and @first-open on the first open', () => {
+ expect(wrapper.emitted('search')[0]).toStrictEqual(['']);
+ expect(wrapper.emitted('first-open')[0]).toStrictEqual([]);
});
- describe('not for the first time', () => {
- beforeEach(() => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ hasBeenOpened: true });
- findGlDropdown().vm.$emit('show');
+ describe('when the dropdown has been opened', () => {
+ it('$emits @search with the searchText', async () => {
+ const searchText = 'foo';
+
+ findGlDropdownSearch().vm.$emit('input', searchText);
+ await nextTick();
+
+ expect(wrapper.emitted('search')[1]).toStrictEqual([searchText]);
+ expect(wrapper.emitted('first-open')).toHaveLength(1);
});
- it('$emits @search and not @first-open', () => {
- expect(wrapper.emitted('search')[0]).toStrictEqual([wrapper.vm.searchText]);
- expect(wrapper.emitted('first-open')).toBeUndefined();
+ it('does not emit @first-open again', async () => {
+ expect(wrapper.emitted('first-open')).toHaveLength(1);
+
+ findGlDropdownSearch().vm.$emit('input');
+ await nextTick();
+
+ expect(wrapper.emitted('first-open')).toHaveLength(1);
});
});
});
diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js
deleted file mode 100644
index a3098fb81ea..00000000000
--- a/spec/frontend/search_autocomplete_spec.js
+++ /dev/null
@@ -1,293 +0,0 @@
-import AxiosMockAdapter from 'axios-mock-adapter';
-import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import axios from '~/lib/utils/axios_utils';
-import initSearchAutocomplete from '~/search_autocomplete';
-import '~/lib/utils/common_utils';
-import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
-
-describe('Search autocomplete dropdown', () => {
- let widget = null;
-
- const userName = 'root';
- const userId = 1;
- const dashboardIssuesPath = '/dashboard/issues';
- const dashboardMRsPath = '/dashboard/merge_requests';
- const projectIssuesPath = '/gitlab-org/gitlab-foss/issues';
- const projectMRsPath = '/gitlab-org/gitlab-foss/-/merge_requests';
- const groupIssuesPath = '/groups/gitlab-org/-/issues';
- const groupMRsPath = '/groups/gitlab-org/-/merge_requests';
- const autocompletePath = '/search/autocomplete';
- const projectName = 'GitLab Community Edition';
- const groupName = 'Gitlab Org';
-
- const removeBodyAttributes = () => {
- const { body } = document;
-
- delete body.dataset.page;
- delete body.dataset.project;
- delete body.dataset.group;
- };
-
- // Add required attributes to body before starting the test.
- // section would be dashboard|group|project
- const addBodyAttributes = (section = 'dashboard') => {
- removeBodyAttributes();
-
- const { body } = document;
- switch (section) {
- case 'dashboard':
- body.dataset.page = 'root:index';
- break;
- case 'group':
- body.dataset.page = 'groups:show';
- body.dataset.group = 'gitlab-org';
- break;
- case 'project':
- body.dataset.page = 'projects:show';
- body.dataset.project = 'gitlab-ce';
- break;
- default:
- break;
- }
- };
-
- const disableProjectIssues = () => {
- document.querySelector('.js-search-project-options').dataset.issuesDisabled = true;
- };
-
- // Mock `gl` object in window for dashboard specific page. App code will need it.
- const mockDashboardOptions = () => {
- window.gl.dashboardOptions = {
- issuesPath: dashboardIssuesPath,
- mrPath: dashboardMRsPath,
- };
- };
-
- // Mock `gl` object in window for project specific page. App code will need it.
- const mockProjectOptions = () => {
- window.gl.projectOptions = {
- 'gitlab-ce': {
- issuesPath: projectIssuesPath,
- mrPath: projectMRsPath,
- projectName,
- },
- };
- };
-
- const mockGroupOptions = () => {
- window.gl.groupOptions = {
- 'gitlab-org': {
- issuesPath: groupIssuesPath,
- mrPath: groupMRsPath,
- projectName: groupName,
- },
- };
- };
-
- const assertLinks = (list, issuesPath, mrsPath) => {
- if (issuesPath) {
- const issuesAssignedToMeLink = `a[href="${issuesPath}/?assignee_username=${userName}"]`;
- const issuesIHaveCreatedLink = `a[href="${issuesPath}/?author_username=${userName}"]`;
-
- expect(list.find(issuesAssignedToMeLink).length).toBe(1);
- expect(list.find(issuesAssignedToMeLink).text()).toBe('Issues assigned to me');
- expect(list.find(issuesIHaveCreatedLink).length).toBe(1);
- expect(list.find(issuesIHaveCreatedLink).text()).toBe("Issues I've created");
- }
- const mrsAssignedToMeLink = `a[href="${mrsPath}/?assignee_username=${userName}"]`;
- const mrsIHaveCreatedLink = `a[href="${mrsPath}/?author_username=${userName}"]`;
-
- expect(list.find(mrsAssignedToMeLink).length).toBe(1);
- expect(list.find(mrsAssignedToMeLink).text()).toBe('Merge requests assigned to me');
- expect(list.find(mrsIHaveCreatedLink).length).toBe(1);
- expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created");
- };
-
- beforeEach(() => {
- loadHTMLFixture('static/search_autocomplete.html');
-
- window.gon = {};
- window.gon.current_user_id = userId;
- window.gon.current_username = userName;
- window.gl = window.gl || (window.gl = {});
-
- widget = initSearchAutocomplete({ autocompletePath });
- });
-
- afterEach(() => {
- // Undo what we did to the shared <body>
- removeBodyAttributes();
- window.gon = {};
-
- resetHTMLFixture();
- });
-
- it('should show Dashboard specific dropdown menu', () => {
- addBodyAttributes();
- mockDashboardOptions();
- widget.searchInput.triggerHandler('focus');
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- return assertLinks(list, dashboardIssuesPath, dashboardMRsPath);
- });
-
- it('should show Group specific dropdown menu', () => {
- addBodyAttributes('group');
- mockGroupOptions();
- widget.searchInput.triggerHandler('focus');
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- return assertLinks(list, groupIssuesPath, groupMRsPath);
- });
-
- it('should show Project specific dropdown menu', () => {
- addBodyAttributes('project');
- mockProjectOptions();
- widget.searchInput.triggerHandler('focus');
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- return assertLinks(list, projectIssuesPath, projectMRsPath);
- });
-
- it('should show only Project mergeRequest dropdown menu items when project issues are disabled', () => {
- addBodyAttributes('project');
- disableProjectIssues();
- mockProjectOptions();
- widget.searchInput.triggerHandler('focus');
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- assertLinks(list, null, projectMRsPath);
- });
-
- it('should not show category related menu if there is text in the input', () => {
- addBodyAttributes('project');
- mockProjectOptions();
- widget.searchInput.val('help');
- widget.searchInput.triggerHandler('focus');
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- const link = `a[href='${projectIssuesPath}/?assignee_username=${userName}']`;
-
- expect(list.find(link).length).toBe(0);
- });
-
- it('should not submit the search form when selecting an autocomplete row with the keyboard', () => {
- const ENTER = 13;
- const DOWN = 40;
- addBodyAttributes();
- mockDashboardOptions(true);
- const submitSpy = jest.spyOn(document.querySelector('form'), 'submit');
- widget.searchInput.triggerHandler('focus');
- widget.wrap.trigger($.Event('keydown', { which: DOWN }));
- const enterKeyEvent = $.Event('keydown', { which: ENTER });
- widget.searchInput.trigger(enterKeyEvent);
-
- // This does not currently catch failing behavior. For security reasons,
- // browsers will not trigger default behavior (form submit, in this
- // example) on JavaScript-created keypresses.
- expect(submitSpy).not.toHaveBeenCalled();
- });
-
- describe('show autocomplete results', () => {
- beforeEach(() => {
- widget.enableAutocomplete();
-
- const axiosMock = new AxiosMockAdapter(axios);
- const autocompleteUrl = new RegExp(autocompletePath);
-
- axiosMock.onGet(autocompleteUrl).reply(HTTP_STATUS_OK, [
- {
- category: 'Projects',
- id: 1,
- value: 'Gitlab Test',
- label: 'Gitlab Org / Gitlab Test',
- url: '/gitlab-org/gitlab-test',
- avatar_url: '',
- },
- {
- category: 'Groups',
- id: 1,
- value: 'Gitlab Org',
- label: 'Gitlab Org',
- url: '/gitlab-org',
- avatar_url: '',
- },
- ]);
- });
-
- function triggerAutocomplete() {
- return new Promise((resolve) => {
- const dropdown = widget.searchInput.data('deprecatedJQueryDropdown');
- const filterCallback = dropdown.filter.options.callback;
- dropdown.filter.options.callback = jest.fn((data) => {
- filterCallback(data);
-
- resolve();
- });
-
- widget.searchInput.val('Gitlab');
- widget.searchInput.triggerHandler('input');
- });
- }
-
- it('suggest Projects', async () => {
- await triggerAutocomplete();
-
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- const link = "a[href$='/gitlab-org/gitlab-test']";
-
- expect(list.find(link).length).toBe(1);
- });
-
- it('suggest Groups', async () => {
- await triggerAutocomplete();
-
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- const link = "a[href$='/gitlab-org']";
-
- expect(list.find(link).length).toBe(1);
- });
- });
-
- describe('disableAutocomplete', () => {
- beforeEach(() => {
- widget.enableAutocomplete();
- });
-
- it('should close the Dropdown', () => {
- const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown');
-
- widget.dropdown.addClass('show');
- widget.disableAutocomplete();
-
- expect(toggleSpy).toHaveBeenCalledWith('toggle');
- });
- });
-
- describe('enableAutocomplete', () => {
- let toggleSpy;
- let trackingSpy;
-
- beforeEach(() => {
- toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown');
- trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
- document.body.dataset.page = 'some:page'; // default tracking for category
- });
-
- afterEach(() => {
- unmockTracking();
- });
-
- it('should open the Dropdown', () => {
- widget.enableAutocomplete();
-
- expect(toggleSpy).toHaveBeenCalledWith('toggle');
- });
-
- it('should track the opening', () => {
- widget.enableAutocomplete();
-
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_search_bar', {
- label: 'main_navigation',
- property: 'navigation',
- });
- });
- });
-});
diff --git a/spec/frontend/search_autocomplete_utils_spec.js b/spec/frontend/search_autocomplete_utils_spec.js
deleted file mode 100644
index 4fdec717e93..00000000000
--- a/spec/frontend/search_autocomplete_utils_spec.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import {
- isInGroupsPage,
- isInProjectPage,
- getGroupSlug,
- getProjectSlug,
-} from '~/search_autocomplete_utils';
-
-describe('search_autocomplete_utils', () => {
- let originalBody;
-
- beforeEach(() => {
- originalBody = document.body;
- document.body = document.createElement('body');
- });
-
- afterEach(() => {
- document.body = originalBody;
- });
-
- describe('isInGroupsPage', () => {
- it.each`
- page | result
- ${'groups:index'} | ${true}
- ${'groups:show'} | ${true}
- ${'projects:show'} | ${false}
- `(`returns $result in for page $page`, ({ page, result }) => {
- document.body.dataset.page = page;
-
- expect(isInGroupsPage()).toBe(result);
- });
- });
-
- describe('isInProjectPage', () => {
- it.each`
- page | result
- ${'projects:index'} | ${true}
- ${'projects:show'} | ${true}
- ${'groups:show'} | ${false}
- `(`returns $result in for page $page`, ({ page, result }) => {
- document.body.dataset.page = page;
-
- expect(isInProjectPage()).toBe(result);
- });
- });
-
- describe('getProjectSlug', () => {
- it('returns null when no project is present or on project page', () => {
- expect(getProjectSlug()).toBe(null);
- });
-
- it('returns null when not on project page', () => {
- document.body.dataset.project = 'gitlab';
-
- expect(getProjectSlug()).toBe(null);
- });
-
- it('returns null when project is missing', () => {
- document.body.dataset.page = 'projects';
-
- expect(getProjectSlug()).toBe(undefined);
- });
-
- it('returns project', () => {
- document.body.dataset.page = 'projects';
- document.body.dataset.project = 'gitlab';
-
- expect(getProjectSlug()).toBe('gitlab');
- });
-
- it('returns project in edit page', () => {
- document.body.dataset.page = 'projects:edit';
- document.body.dataset.project = 'gitlab';
-
- expect(getProjectSlug()).toBe('gitlab');
- });
- });
-
- describe('getGroupSlug', () => {
- it('returns null when no group is present or on group page', () => {
- expect(getGroupSlug()).toBe(null);
- });
-
- it('returns null when not on group page', () => {
- document.body.dataset.group = 'gitlab-org';
-
- expect(getGroupSlug()).toBe(null);
- });
-
- it('returns null when group is missing on groups page', () => {
- document.body.dataset.page = 'groups';
-
- expect(getGroupSlug()).toBe(undefined);
- });
-
- it('returns null when group is missing on project page', () => {
- document.body.dataset.page = 'project';
-
- expect(getGroupSlug()).toBe(null);
- });
-
- it.each`
- page
- ${'groups'}
- ${'groups:edit'}
- ${'projects'}
- ${'projects:edit'}
- `(`returns group in page $page`, ({ page }) => {
- document.body.dataset.page = page;
- document.body.dataset.group = 'gitlab-org';
-
- expect(getGroupSlug()).toBe('gitlab-org');
- });
- });
-});
diff --git a/spec/frontend/search_settings/components/search_settings_spec.js b/spec/frontend/search_settings/components/search_settings_spec.js
index 3f856968db6..fe761049a70 100644
--- a/spec/frontend/search_settings/components/search_settings_spec.js
+++ b/spec/frontend/search_settings/components/search_settings_spec.js
@@ -79,10 +79,6 @@ describe('search_settings/components/search_settings.vue', () => {
buildWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('hides sections that do not match the search term', () => {
const hiddenSection = document.querySelector(`#${GENERAL_SETTINGS_ID}`);
search(SEARCH_TERM);
diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js
index ddefda2ffc3..364fe733a41 100644
--- a/spec/frontend/security_configuration/components/app_spec.js
+++ b/spec/frontend/security_configuration/components/app_spec.js
@@ -8,78 +8,31 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue';
import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue';
import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue';
-import {
- SAST_NAME,
- SAST_SHORT_NAME,
- SAST_DESCRIPTION,
- SAST_HELP_PATH,
- SAST_CONFIG_HELP_PATH,
- LICENSE_COMPLIANCE_NAME,
- LICENSE_COMPLIANCE_DESCRIPTION,
- LICENSE_COMPLIANCE_HELP_PATH,
- AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY,
-} from '~/security_configuration/components/constants';
+import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from '~/security_configuration/components/constants';
import FeatureCard from '~/security_configuration/components/feature_card.vue';
import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
-import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue';
-import {
- REPORT_TYPE_LICENSE_COMPLIANCE,
- REPORT_TYPE_SAST,
-} from '~/vue_shared/security_reports/constants';
-
-const upgradePath = '/upgrade';
-const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath';
-const autoDevopsPath = '/autoDevopsPath';
+import { securityFeaturesMock, provideMock } from '../mock_data';
+
const gitlabCiHistoryPath = 'test/historyPath';
-const projectFullPath = 'namespace/project';
-const vulnerabilityTrainingDocsPath = 'user/application_security/vulnerabilities/index';
+const { vulnerabilityTrainingDocsPath, projectFullPath } = provideMock;
useLocalStorageSpy();
Vue.use(VueApollo);
-describe('App component', () => {
+describe('~/security_configuration/components/app', () => {
let wrapper;
let userCalloutDismissSpy;
- const securityFeaturesMock = [
- {
- name: SAST_NAME,
- shortName: SAST_SHORT_NAME,
- description: SAST_DESCRIPTION,
- helpPath: SAST_HELP_PATH,
- configurationHelpPath: SAST_CONFIG_HELP_PATH,
- type: REPORT_TYPE_SAST,
- available: true,
- },
- ];
-
- const complianceFeaturesMock = [
- {
- name: LICENSE_COMPLIANCE_NAME,
- description: LICENSE_COMPLIANCE_DESCRIPTION,
- helpPath: LICENSE_COMPLIANCE_HELP_PATH,
- type: REPORT_TYPE_LICENSE_COMPLIANCE,
- configurationHelpPath: LICENSE_COMPLIANCE_HELP_PATH,
- },
- ];
-
const createComponent = ({ shouldShowCallout = true, ...propsData } = {}) => {
userCalloutDismissSpy = jest.fn();
wrapper = mountExtended(SecurityConfigurationApp, {
propsData: {
augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
securityTrainingEnabled: true,
...propsData,
},
- provide: {
- upgradePath,
- autoDevopsHelpPagePath,
- autoDevopsPath,
- projectFullPath,
- vulnerabilityTrainingDocsPath,
- },
+ provide: provideMock,
stubs: {
...stubChildren(SecurityConfigurationApp),
GlLink: false,
@@ -118,21 +71,11 @@ describe('App component', () => {
text: i18n.configurationHistory,
container: findByTestId('security-testing-tab'),
});
- const findComplianceViewHistoryLink = () =>
- findLink({
- href: gitlabCiHistoryPath,
- text: i18n.configurationHistory,
- container: findByTestId('compliance-testing-tab'),
- });
- const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner);
+
const findAutoDevopsAlert = () => wrapper.findComponent(AutoDevopsAlert);
const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert);
const findVulnerabilityManagementTab = () => wrapper.findByTestId('vulnerability-management-tab');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('basic structure', () => {
beforeEach(() => {
createComponent();
@@ -141,11 +84,11 @@ describe('App component', () => {
it('renders main-heading with correct text', () => {
const mainHeading = findMainHeading();
expect(mainHeading.exists()).toBe(true);
- expect(mainHeading.text()).toContain('Security Configuration');
+ expect(mainHeading.text()).toContain('Security configuration');
});
describe('tabs', () => {
- const expectedTabs = ['security-testing', 'compliance-testing', 'vulnerability-management'];
+ const expectedTabs = ['security-testing', 'vulnerability-management'];
it('renders GlTab Component', () => {
expect(findTab().exists()).toBe(true);
@@ -174,9 +117,8 @@ describe('App component', () => {
it('renders right amount of feature cards for given props with correct props', () => {
const cards = findFeatureCards();
- expect(cards).toHaveLength(2);
+ expect(cards).toHaveLength(1);
expect(cards.at(0).props()).toEqual({ feature: securityFeaturesMock[0] });
- expect(cards.at(1).props()).toEqual({ feature: complianceFeaturesMock[0] });
});
it('renders a basic description', () => {
@@ -188,7 +130,6 @@ describe('App component', () => {
});
it('should not show configuration History Link when gitlabCiPresent & gitlabCiHistoryPath are not defined', () => {
- expect(findComplianceViewHistoryLink().exists()).toBe(false);
expect(findSecurityViewHistoryLink().exists()).toBe(false);
});
});
@@ -205,17 +146,19 @@ describe('App component', () => {
});
describe('when error occurs', () => {
+ const errorMessage = 'There was a manage via MR error';
+
it('should show Alert with error Message', async () => {
expect(findManageViaMRErrorAlert().exists()).toBe(false);
- findFeatureCards().at(1).vm.$emit('error', 'There was a manage via MR error');
+ findFeatureCards().at(0).vm.$emit('error', errorMessage);
await nextTick();
expect(findManageViaMRErrorAlert().exists()).toBe(true);
- expect(findManageViaMRErrorAlert().text()).toEqual('There was a manage via MR error');
+ expect(findManageViaMRErrorAlert().text()).toBe(errorMessage);
});
it('should hide Alert when it is dismissed', async () => {
- findFeatureCards().at(1).vm.$emit('error', 'There was a manage via MR error');
+ findFeatureCards().at(0).vm.$emit('error', errorMessage);
await nextTick();
expect(findManageViaMRErrorAlert().exists()).toBe(true);
@@ -306,7 +249,6 @@ describe('App component', () => {
createComponent({
augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
autoDevopsEnabled: true,
});
@@ -328,80 +270,12 @@ describe('App component', () => {
});
});
- describe('upgrade banner', () => {
- const makeAvailable = (available) => (feature) => ({ ...feature, available });
-
- describe('given at least one unavailable feature', () => {
- beforeEach(() => {
- createComponent({
- augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)),
- });
- });
-
- it('renders the banner', () => {
- expect(findUpgradeBanner().exists()).toBe(true);
- });
-
- it('calls the dismiss callback when closing the banner', () => {
- expect(userCalloutDismissSpy).not.toHaveBeenCalled();
-
- findUpgradeBanner().vm.$emit('close');
-
- expect(userCalloutDismissSpy).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('given at least one unavailable feature, but banner is already dismissed', () => {
- beforeEach(() => {
- createComponent({
- augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)),
- shouldShowCallout: false,
- });
- });
-
- it('does not render the banner', () => {
- expect(findUpgradeBanner().exists()).toBe(false);
- });
- });
-
- describe('given all features are available', () => {
- beforeEach(() => {
- createComponent({
- augmentedSecurityFeatures: securityFeaturesMock.map(makeAvailable(true)),
- augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(true)),
- });
- });
-
- it('does not render the banner', () => {
- expect(findUpgradeBanner().exists()).toBe(false);
- });
- });
- });
-
describe('when given latestPipelinePath props', () => {
beforeEach(() => {
createComponent({
latestPipelinePath: 'test/path',
});
});
-
- it('should show latest pipeline info on the security tab with correct link when latestPipelinePath is defined', () => {
- const latestPipelineInfoSecurity = findByTestId('latest-pipeline-info-security');
-
- expect(latestPipelineInfoSecurity.text()).toMatchInterpolatedText(
- i18n.latestPipelineDescription,
- );
- expect(latestPipelineInfoSecurity.find('a').attributes('href')).toBe('test/path');
- });
-
- it('should show latest pipeline info on the compliance tab with correct link when latestPipelinePath is defined', () => {
- const latestPipelineInfoCompliance = findByTestId('latest-pipeline-info-compliance');
-
- expect(latestPipelineInfoCompliance.text()).toMatchInterpolatedText(
- i18n.latestPipelineDescription,
- );
- expect(latestPipelineInfoCompliance.find('a').attributes('href')).toBe('test/path');
- });
});
describe('given gitlabCiPresent & gitlabCiHistoryPath props', () => {
@@ -413,10 +287,8 @@ describe('App component', () => {
});
it('should show configuration History Link', () => {
- expect(findComplianceViewHistoryLink().exists()).toBe(true);
expect(findSecurityViewHistoryLink().exists()).toBe(true);
- expect(findComplianceViewHistoryLink().attributes('href')).toBe('test/historyPath');
expect(findSecurityViewHistoryLink().attributes('href')).toBe('test/historyPath');
});
});
@@ -424,7 +296,7 @@ describe('App component', () => {
describe('Vulnerability management', () => {
const props = { securityTrainingEnabled: true };
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({
...props,
});
diff --git a/spec/frontend/security_configuration/components/auto_dev_ops_alert_spec.js b/spec/frontend/security_configuration/components/auto_dev_ops_alert_spec.js
index 467ae35408c..df1fa1a8084 100644
--- a/spec/frontend/security_configuration/components/auto_dev_ops_alert_spec.js
+++ b/spec/frontend/security_configuration/components/auto_dev_ops_alert_spec.js
@@ -23,10 +23,6 @@ describe('AutoDevopsAlert component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains correct body text', () => {
expect(wrapper.text()).toContain('Quickly enable all');
});
diff --git a/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js b/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js
index 778fea2896a..22f45a92f70 100644
--- a/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js
+++ b/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js
@@ -21,10 +21,6 @@ describe('AutoDevopsEnabledAlert component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('contains correct body text', () => {
expect(wrapper.text()).toMatchInterpolatedText(AutoDevopsEnabledAlert.i18n.body);
});
diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js
index d10722be8ea..983a66a7fd3 100644
--- a/spec/frontend/security_configuration/components/feature_card_spec.js
+++ b/spec/frontend/security_configuration/components/feature_card_spec.js
@@ -1,10 +1,16 @@
import { GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { securityFeatures } from '~/security_configuration/components/constants';
import FeatureCard from '~/security_configuration/components/feature_card.vue';
import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
-import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
+import {
+ REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
+ REPORT_TYPE_SAST,
+ REPORT_TYPE_SAST_IAC,
+} from '~/vue_shared/security_reports/constants';
+import { manageViaMRErrorMessage } from '../constants';
import { makeFeature } from './utils';
describe('FeatureCard component', () => {
@@ -78,7 +84,6 @@ describe('FeatureCard component', () => {
};
afterEach(() => {
- wrapper.destroy();
feature = undefined;
});
@@ -107,8 +112,8 @@ describe('FeatureCard component', () => {
});
it('should catch and emit manage-via-mr-error', () => {
- findManageViaMr().vm.$emit('error', 'There was a manage via MR error');
- expect(wrapper.emitted('error')).toEqual([['There was a manage via MR error']]);
+ findManageViaMr().vm.$emit('error', manageViaMRErrorMessage);
+ expect(wrapper.emitted('error')).toEqual([[manageViaMRErrorMessage]]);
});
});
@@ -265,6 +270,56 @@ describe('FeatureCard component', () => {
expect(links.exists()).toBe(false);
});
});
+
+ describe('given an available secondary with a configuration guide', () => {
+ beforeEach(() => {
+ feature = makeFeature({
+ available: true,
+ configurationHelpPath: null,
+ secondary: {
+ name: 'secondary name',
+ description: 'secondary description',
+ configurationHelpPath: '/secondary',
+ configurationText: null,
+ },
+ });
+ createComponent({ feature });
+ });
+
+ it('shows the secondary action', () => {
+ const links = findLinks({
+ text: 'Configuration guide',
+ href: feature.secondary.configurationHelpPath,
+ });
+ expect(links.exists()).toBe(true);
+ expect(links).toHaveLength(1);
+ });
+ });
+
+ describe('given an unavailable secondary with a configuration guide', () => {
+ beforeEach(() => {
+ feature = makeFeature({
+ available: false,
+ configurationHelpPath: null,
+ secondary: {
+ name: 'secondary name',
+ description: 'secondary description',
+ configurationHelpPath: '/secondary',
+ configurationText: null,
+ },
+ });
+ createComponent({ feature });
+ });
+
+ it('does not show the secondary action', () => {
+ const links = findLinks({
+ text: 'Configuration guide',
+ href: feature.secondary.configurationHelpPath,
+ });
+ expect(links.exists()).toBe(false);
+ expect(links).toHaveLength(0);
+ });
+ });
});
describe('information badge', () => {
@@ -290,4 +345,48 @@ describe('FeatureCard component', () => {
});
});
});
+
+ describe('status and badge', () => {
+ describe.each`
+ context | available | configured | expectedStatus
+ ${'configured BAS feature'} | ${true} | ${true} | ${null}
+ ${'unavailable BAS feature'} | ${false} | ${false} | ${'Available with Ultimate'}
+ ${'unconfigured BAS feature'} | ${true} | ${false} | ${null}
+ `('given $context', ({ available, configured, expectedStatus }) => {
+ beforeEach(() => {
+ const securityFeature = securityFeatures.find(
+ ({ type }) => REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION === type,
+ );
+ feature = { ...securityFeature, available, configured };
+ createComponent({ feature });
+ });
+
+ it('should show an incubating feature badge', () => {
+ expect(findBadge().exists()).toBe(true);
+ });
+
+ if (expectedStatus) {
+ it(`should show the status "${expectedStatus}"`, () => {
+ expect(wrapper.findByTestId('feature-status').text()).toBe(expectedStatus);
+ });
+ }
+ });
+
+ describe.each`
+ context | available | configured
+ ${'configured SAST IaC feature'} | ${true} | ${true}
+ ${'unavailable SAST IaC feature'} | ${false} | ${false}
+ ${'unconfigured SAST IaC feature'} | ${true} | ${false}
+ `('given $context', ({ available, configured }) => {
+ beforeEach(() => {
+ const securityFeature = securityFeatures.find(({ type }) => REPORT_TYPE_SAST_IAC === type);
+ feature = { ...securityFeature, available, configured };
+ createComponent({ feature });
+ });
+
+ it(`should not show a status`, () => {
+ expect(wrapper.findByTestId('feature-status').exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js
index 8f2b5383191..2982cef7c74 100644
--- a/spec/frontend/security_configuration/components/training_provider_list_spec.js
+++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js
@@ -106,7 +106,7 @@ describe('TrainingProviderList component', () => {
projectFullPath: testProjectPath,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
propsData: {
securityTrainingEnabled: true,
@@ -132,7 +132,6 @@ describe('TrainingProviderList component', () => {
const toggleFirstProvider = () => findFirstToggle().vm.$emit('change', testProviderIds[0]);
afterEach(() => {
- wrapper.destroy();
apolloProvider = null;
});
@@ -254,7 +253,7 @@ describe('TrainingProviderList component', () => {
expect(findLogos().at(provider).attributes('role')).toBe('presentation');
});
- it.each(providerIndexArray)('renders the svg content for provider %s', async (provider) => {
+ it.each(providerIndexArray)('renders the svg content for provider %s', (provider) => {
expect(findLogos().at(provider).html()).toContain(
TEMP_PROVIDER_LOGOS[testProviderName[provider]].svg,
);
@@ -402,7 +401,7 @@ describe('TrainingProviderList component', () => {
it('has disabled state for radio', () => {
findPrimaryProviderRadios().wrappers.forEach((radio) => {
- expect(radio.attributes('disabled')).toBe('true');
+ expect(radio.attributes('disabled')).toBeDefined();
});
});
diff --git a/spec/frontend/security_configuration/components/upgrade_banner_spec.js b/spec/frontend/security_configuration/components/upgrade_banner_spec.js
deleted file mode 100644
index c34d8e47a6c..00000000000
--- a/spec/frontend/security_configuration/components/upgrade_banner_spec.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import { GlBanner } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import UpgradeBanner, {
- SECURITY_UPGRADE_BANNER,
- UPGRADE_OR_FREE_TRIAL,
-} from '~/security_configuration/components/upgrade_banner.vue';
-
-const upgradePath = '/upgrade';
-
-describe('UpgradeBanner component', () => {
- let wrapper;
- let closeSpy;
- let primarySpy;
- let trackingSpy;
-
- const createComponent = (propsData) => {
- closeSpy = jest.fn();
- primarySpy = jest.fn();
-
- wrapper = shallowMountExtended(UpgradeBanner, {
- provide: {
- upgradePath,
- },
- propsData,
- listeners: {
- close: closeSpy,
- primary: primarySpy,
- },
- });
- };
-
- const findGlBanner = () => wrapper.findComponent(GlBanner);
-
- const expectTracking = (action, label) => {
- return expect(trackingSpy).toHaveBeenCalledWith(undefined, action, {
- label,
- property: SECURITY_UPGRADE_BANNER,
- });
- };
-
- beforeEach(() => {
- trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
- });
-
- afterEach(() => {
- wrapper.destroy();
- unmockTracking();
- });
-
- describe('when the component renders', () => {
- it('tracks an event', () => {
- expect(trackingSpy).not.toHaveBeenCalled();
-
- createComponent();
-
- expectTracking('render', SECURITY_UPGRADE_BANNER);
- });
- });
-
- describe('when ready', () => {
- beforeEach(() => {
- createComponent();
- trackingSpy.mockClear();
- });
-
- it('passes the expected props to GlBanner', () => {
- expect(findGlBanner().props()).toMatchObject({
- title: UpgradeBanner.i18n.title,
- buttonText: UpgradeBanner.i18n.buttonText,
- buttonLink: upgradePath,
- });
- });
-
- it('renders the list of benefits', () => {
- const wrapperText = wrapper.text();
-
- expect(wrapperText).toContain('Immediately begin risk analysis and remediation');
- expect(wrapperText).toContain('statistics in the merge request');
- expect(wrapperText).toContain('statistics across projects');
- expect(wrapperText).toContain('Runtime security metrics');
- expect(wrapperText).toContain('More scan types, including DAST,');
- });
-
- describe('when user interacts', () => {
- it(`re-emits GlBanner's close event & tracks an event`, () => {
- expect(closeSpy).not.toHaveBeenCalled();
- expect(trackingSpy).not.toHaveBeenCalled();
-
- wrapper.findComponent(GlBanner).vm.$emit('close');
-
- expect(closeSpy).toHaveBeenCalledTimes(1);
- expectTracking('dismiss_banner', SECURITY_UPGRADE_BANNER);
- });
-
- it(`re-emits GlBanner's primary event & tracks an event`, () => {
- expect(primarySpy).not.toHaveBeenCalled();
- expect(trackingSpy).not.toHaveBeenCalled();
-
- wrapper.findComponent(GlBanner).vm.$emit('primary');
-
- expect(primarySpy).toHaveBeenCalledTimes(1);
- expectTracking('click_button', UPGRADE_OR_FREE_TRIAL);
- });
- });
- });
-});
diff --git a/spec/frontend/security_configuration/constants.js b/spec/frontend/security_configuration/constants.js
new file mode 100644
index 00000000000..d31036a2534
--- /dev/null
+++ b/spec/frontend/security_configuration/constants.js
@@ -0,0 +1 @@
+export const manageViaMRErrorMessage = 'There was a manage via MR error';
diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js
index 2fe3b59cea3..df10d33e2f0 100644
--- a/spec/frontend/security_configuration/mock_data.js
+++ b/spec/frontend/security_configuration/mock_data.js
@@ -1,9 +1,19 @@
+import {
+ SAST_NAME,
+ SAST_SHORT_NAME,
+ SAST_DESCRIPTION,
+ SAST_HELP_PATH,
+ SAST_CONFIG_HELP_PATH,
+} from '~/security_configuration/components/constants';
+import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
+
export const testProjectPath = 'foo/bar';
export const testProviderIds = [101, 102, 103];
-export const testProviderName = ['Kontra', 'Secure Code Warrior', 'Other Vendor'];
+export const testProviderName = ['Kontra', 'Secure Code Warrior', 'SecureFlag'];
export const testTrainingUrls = [
'https://www.vendornameone.com/url',
'https://www.vendornametwo.com/url',
+ 'https://www.vendornamethree.com/url',
];
const createSecurityTrainingProviders = ({ providerOverrides = {} }) => [
@@ -100,3 +110,23 @@ export const updateSecurityTrainingProvidersErrorResponse = {
},
},
};
+
+export const securityFeaturesMock = [
+ {
+ name: SAST_NAME,
+ shortName: SAST_SHORT_NAME,
+ description: SAST_DESCRIPTION,
+ helpPath: SAST_HELP_PATH,
+ configurationHelpPath: SAST_CONFIG_HELP_PATH,
+ type: REPORT_TYPE_SAST,
+ available: true,
+ },
+];
+
+export const provideMock = {
+ upgradePath: '/upgrade',
+ autoDevopsHelpPagePath: '/autoDevopsHelpPagePath',
+ autoDevopsPath: '/autoDevopsPath',
+ projectFullPath: 'namespace/project',
+ vulnerabilityTrainingDocsPath: 'user/application_security/vulnerabilities/index',
+};
diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js
index 241e69204d2..6e731e45da2 100644
--- a/spec/frontend/security_configuration/utils_spec.js
+++ b/spec/frontend/security_configuration/utils_spec.js
@@ -9,13 +9,6 @@ describe('augmentFeatures', () => {
},
];
- const mockComplianceFeatures = [
- {
- name: 'LICENSE_COMPLIANCE',
- type: 'LICENSE_COMPLIANCE',
- },
- ];
-
const mockFeaturesWithSecondary = [
{
name: 'DAST',
@@ -51,30 +44,25 @@ describe('augmentFeatures', () => {
const expectedOutputDefault = {
augmentedSecurityFeatures: mockSecurityFeatures,
- augmentedComplianceFeatures: mockComplianceFeatures,
};
const expectedOutputSecondary = {
augmentedSecurityFeatures: mockSecurityFeatures,
- augmentedComplianceFeatures: mockFeaturesWithSecondary,
};
const expectedOutputCustomFeature = {
augmentedSecurityFeatures: mockValidCustomFeature,
- augmentedComplianceFeatures: mockComplianceFeatures,
};
- describe('returns an object with augmentedSecurityFeatures and augmentedComplianceFeatures when', () => {
+ describe('returns an object with augmentedSecurityFeatures when', () => {
it('given an empty array', () => {
- expect(augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, [])).toEqual(
- expectedOutputDefault,
- );
+ expect(augmentFeatures(mockSecurityFeatures, [])).toEqual(expectedOutputDefault);
});
it('given an invalid populated array', () => {
- expect(
- augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockInvalidCustomFeature),
- ).toEqual(expectedOutputDefault);
+ expect(augmentFeatures(mockSecurityFeatures, mockInvalidCustomFeature)).toEqual(
+ expectedOutputDefault,
+ );
});
it('features have secondary key', () => {
@@ -84,21 +72,17 @@ describe('augmentFeatures', () => {
});
it('given a valid populated array', () => {
- expect(
- augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockValidCustomFeature),
- ).toEqual(expectedOutputCustomFeature);
+ expect(augmentFeatures(mockSecurityFeatures, mockValidCustomFeature)).toEqual(
+ expectedOutputCustomFeature,
+ );
});
});
describe('returns an object with camelcased keys', () => {
it('given a customfeature in snakecase', () => {
- expect(
- augmentFeatures(
- mockSecurityFeatures,
- mockComplianceFeatures,
- mockValidCustomFeatureSnakeCase,
- ),
- ).toEqual(expectedOutputCustomFeature);
+ expect(augmentFeatures(mockSecurityFeatures, mockValidCustomFeatureSnakeCase)).toEqual(
+ expectedOutputCustomFeature,
+ );
});
});
});
diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
deleted file mode 100644
index efe3f7e8dbf..00000000000
--- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
+++ /dev/null
@@ -1,85 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`self-monitor component When the self-monitor project has not been created default state to match the default snapshot 1`] = `
-<section
- class="settings no-animate js-self-monitoring-settings"
->
- <div
- class="settings-header"
- >
- <h4
- class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
- >
-
- Self-monitoring
-
- </h4>
-
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- class="js-settings-toggle"
- icon=""
- size="medium"
- variant="default"
- >
- Expand
- </gl-button-stub>
-
- <p
- class="js-section-sub-header"
- >
-
- Activate or deactivate instance self-monitoring.
-
- <gl-link-stub
- href="/help/administration/monitoring/gitlab_self_monitoring_project/index"
- >
- Learn more.
- </gl-link-stub>
- </p>
- </div>
-
- <div
- class="settings-content"
- >
- <form
- name="self-monitoring-form"
- >
- <p>
- Activate self-monitoring to create a project to use to monitor the health of your instance.
- </p>
-
- <gl-form-group-stub
- labeldescription=""
- optionaltext="(optional)"
- >
- <gl-toggle-stub
- label="Self-monitoring"
- labelposition="top"
- />
- </gl-form-group-stub>
- </form>
- </div>
-
- <gl-modal-stub
- arialabel=""
- cancel-title="Cancel"
- category="primary"
- dismisslabel="Close"
- modalclass=""
- modalid="delete-self-monitor-modal"
- ok-title="Delete self-monitoring project"
- ok-variant="danger"
- size="md"
- title="Deactivate self-monitoring?"
- titletag="h4"
- >
- <div>
-
- Deactivating self-monitoring deletes the self-monitoring project. Are you sure you want to deactivate self-monitoring and delete the project?
-
- </div>
- </gl-modal-stub>
-</section>
-`;
diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js
deleted file mode 100644
index 35f2734dde3..00000000000
--- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import { GlButton, GlToggle } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { TEST_HOST } from 'helpers/test_constants';
-import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue';
-import { createStore } from '~/self_monitor/store';
-
-describe('self-monitor component', () => {
- let wrapper;
- let store;
-
- describe('When the self-monitor project has not been created', () => {
- beforeEach(() => {
- store = createStore({
- projectEnabled: false,
- selfMonitoringProjectExists: false,
- createSelfMonitoringProjectPath: '/create',
- deleteSelfMonitoringProjectPath: '/delete',
- });
- });
-
- afterEach(() => {
- if (wrapper.destroy) {
- wrapper.destroy();
- }
- });
-
- describe('default state', () => {
- it('to match the default snapshot', () => {
- wrapper = shallowMount(SelfMonitor, { store });
-
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('renders header text', () => {
- wrapper = shallowMount(SelfMonitor, { store });
-
- expect(wrapper.find('.js-section-header').text()).toBe('Self-monitoring');
- });
-
- describe('expand/collapse button', () => {
- it('renders as an expand button by default', () => {
- wrapper = shallowMount(SelfMonitor, { store });
-
- const button = wrapper.findComponent(GlButton);
-
- expect(button.text()).toBe('Expand');
- });
- });
-
- describe('sub-header', () => {
- it('renders descriptive text', () => {
- wrapper = shallowMount(SelfMonitor, { store });
-
- expect(wrapper.find('.js-section-sub-header').text()).toContain(
- 'Activate or deactivate instance self-monitoring.',
- );
- });
- });
-
- describe('settings-content', () => {
- it('renders the form description without a link', () => {
- wrapper = shallowMount(SelfMonitor, { store });
-
- expect(wrapper.vm.selfMonitoringFormText).toContain(
- 'Activate self-monitoring to create a project to use to monitor the health of your instance.',
- );
- });
-
- it('renders the form description with a link', () => {
- store = createStore({
- projectEnabled: true,
- selfMonitoringProjectExists: true,
- createSelfMonitoringProjectPath: '/create',
- deleteSelfMonitoringProjectPath: '/delete',
- selfMonitoringProjectFullPath: 'instance-administrators-random/gitlab-self-monitoring',
- });
-
- wrapper = shallowMount(SelfMonitor, { store });
-
- expect(
- wrapper.findComponent({ ref: 'selfMonitoringFormText' }).find('a').attributes('href'),
- ).toEqual(`${TEST_HOST}/instance-administrators-random/gitlab-self-monitoring`);
- });
-
- it('renders toggle', () => {
- wrapper = shallowMount(SelfMonitor, { store });
-
- expect(wrapper.findComponent(GlToggle).props('label')).toBe(
- SelfMonitor.formLabels.createProject,
- );
- });
- });
- });
-});
diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js
deleted file mode 100644
index 0e28e330009..00000000000
--- a/spec/frontend/self_monitor/store/actions_spec.js
+++ /dev/null
@@ -1,254 +0,0 @@
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'helpers/vuex_action_helper';
-import {
- HTTP_STATUS_ACCEPTED,
- HTTP_STATUS_INTERNAL_SERVER_ERROR,
- HTTP_STATUS_OK,
-} from '~/lib/utils/http_status';
-import * as actions from '~/self_monitor/store/actions';
-import * as types from '~/self_monitor/store/mutation_types';
-import createState from '~/self_monitor/store/state';
-
-describe('self-monitor actions', () => {
- let state;
- let mock;
-
- beforeEach(() => {
- state = createState();
- mock = new MockAdapter(axios);
- });
-
- describe('setSelfMonitor', () => {
- it('commits the SET_ENABLED mutation', () => {
- return testAction(
- actions.setSelfMonitor,
- null,
- state,
- [{ type: types.SET_ENABLED, payload: null }],
- [],
- );
- });
- });
-
- describe('resetAlert', () => {
- it('commits the SET_ENABLED mutation', () => {
- return testAction(
- actions.resetAlert,
- null,
- state,
- [{ type: types.SET_SHOW_ALERT, payload: false }],
- [],
- );
- });
- });
-
- describe('requestCreateProject', () => {
- describe('success', () => {
- beforeEach(() => {
- state.createProjectEndpoint = '/create';
- state.createProjectStatusEndpoint = '/create_status';
- mock.onPost(state.createProjectEndpoint).reply(HTTP_STATUS_ACCEPTED, {
- job_id: '123',
- });
- mock.onGet(state.createProjectStatusEndpoint).reply(HTTP_STATUS_OK, {
- project_full_path: '/self-monitor-url',
- });
- });
-
- it('dispatches status request with job data', () => {
- return testAction(
- actions.requestCreateProject,
- null,
- state,
- [
- {
- type: types.SET_LOADING,
- payload: true,
- },
- ],
- [
- {
- type: 'requestCreateProjectStatus',
- payload: '123',
- },
- ],
- );
- });
-
- it('dispatches success with project path', () => {
- return testAction(
- actions.requestCreateProjectStatus,
- null,
- state,
- [],
- [
- {
- type: 'requestCreateProjectSuccess',
- payload: { project_full_path: '/self-monitor-url' },
- },
- ],
- );
- });
- });
-
- describe('error', () => {
- beforeEach(() => {
- state.createProjectEndpoint = '/create';
- mock.onPost(state.createProjectEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
- });
-
- it('dispatches error', () => {
- return testAction(
- actions.requestCreateProject,
- null,
- state,
- [
- {
- type: types.SET_LOADING,
- payload: true,
- },
- ],
- [
- {
- type: 'requestCreateProjectError',
- payload: new Error('Request failed with status code 500'),
- },
- ],
- );
- });
- });
-
- describe('requestCreateProjectSuccess', () => {
- it('should commit the received data', () => {
- return testAction(
- actions.requestCreateProjectSuccess,
- { project_full_path: '/self-monitor-url' },
- state,
- [
- { type: types.SET_LOADING, payload: false },
- { type: types.SET_PROJECT_URL, payload: '/self-monitor-url' },
- {
- type: types.SET_ALERT_CONTENT,
- payload: {
- actionName: 'viewSelfMonitorProject',
- actionText: 'View project',
- message: 'Self-monitoring project successfully created.',
- },
- },
- { type: types.SET_SHOW_ALERT, payload: true },
- { type: types.SET_PROJECT_CREATED, payload: true },
- ],
- [
- {
- payload: true,
- type: 'setSelfMonitor',
- },
- ],
- );
- });
- });
- });
-
- describe('deleteSelfMonitorProject', () => {
- describe('success', () => {
- beforeEach(() => {
- state.deleteProjectEndpoint = '/delete';
- state.deleteProjectStatusEndpoint = '/delete-status';
- mock.onDelete(state.deleteProjectEndpoint).reply(HTTP_STATUS_ACCEPTED, {
- job_id: '456',
- });
- mock.onGet(state.deleteProjectStatusEndpoint).reply(HTTP_STATUS_OK, {
- status: 'success',
- });
- });
-
- it('dispatches status request with job data', () => {
- return testAction(
- actions.requestDeleteProject,
- null,
- state,
- [
- {
- type: types.SET_LOADING,
- payload: true,
- },
- ],
- [
- {
- type: 'requestDeleteProjectStatus',
- payload: '456',
- },
- ],
- );
- });
-
- it('dispatches success with status', () => {
- return testAction(
- actions.requestDeleteProjectStatus,
- null,
- state,
- [],
- [
- {
- type: 'requestDeleteProjectSuccess',
- payload: { status: 'success' },
- },
- ],
- );
- });
- });
-
- describe('error', () => {
- beforeEach(() => {
- state.deleteProjectEndpoint = '/delete';
- mock.onDelete(state.deleteProjectEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
- });
-
- it('dispatches error', () => {
- return testAction(
- actions.requestDeleteProject,
- null,
- state,
- [
- {
- type: types.SET_LOADING,
- payload: true,
- },
- ],
- [
- {
- type: 'requestDeleteProjectError',
- payload: new Error('Request failed with status code 500'),
- },
- ],
- );
- });
- });
-
- describe('requestDeleteProjectSuccess', () => {
- it('should commit mutations to remove previously set data', () => {
- return testAction(
- actions.requestDeleteProjectSuccess,
- null,
- state,
- [
- { type: types.SET_PROJECT_URL, payload: '' },
- { type: types.SET_PROJECT_CREATED, payload: false },
- {
- type: types.SET_ALERT_CONTENT,
- payload: {
- actionName: 'createProject',
- actionText: 'Undo',
- message: 'Self-monitoring project successfully deleted.',
- },
- },
- { type: types.SET_SHOW_ALERT, payload: true },
- { type: types.SET_LOADING, payload: false },
- ],
- [],
- );
- });
- });
- });
-});
diff --git a/spec/frontend/self_monitor/store/mutations_spec.js b/spec/frontend/self_monitor/store/mutations_spec.js
deleted file mode 100644
index 315450f3aef..00000000000
--- a/spec/frontend/self_monitor/store/mutations_spec.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import mutations from '~/self_monitor/store/mutations';
-import createState from '~/self_monitor/store/state';
-
-describe('self-monitoring mutations', () => {
- let localState;
-
- beforeEach(() => {
- localState = createState();
- });
-
- describe('SET_ENABLED', () => {
- it('sets selfMonitor', () => {
- mutations.SET_ENABLED(localState, true);
-
- expect(localState.projectEnabled).toBe(true);
- });
- });
-
- describe('SET_PROJECT_CREATED', () => {
- it('sets projectCreated', () => {
- mutations.SET_PROJECT_CREATED(localState, true);
-
- expect(localState.projectCreated).toBe(true);
- });
- });
-
- describe('SET_SHOW_ALERT', () => {
- it('sets showAlert', () => {
- mutations.SET_SHOW_ALERT(localState, true);
-
- expect(localState.showAlert).toBe(true);
- });
- });
-
- describe('SET_PROJECT_URL', () => {
- it('sets projectPath', () => {
- mutations.SET_PROJECT_URL(localState, '/url/');
-
- expect(localState.projectPath).toBe('/url/');
- });
- });
-
- describe('SET_LOADING', () => {
- it('sets loading', () => {
- mutations.SET_LOADING(localState, true);
-
- expect(localState.loading).toBe(true);
- });
- });
-
- describe('SET_ALERT_CONTENT', () => {
- it('set alertContent', () => {
- const alertContent = {
- message: 'success',
- actionText: 'undo',
- actionName: 'createProject',
- };
-
- mutations.SET_ALERT_CONTENT(localState, alertContent);
-
- expect(localState.alertContent).toBe(alertContent);
- });
- });
-});
diff --git a/spec/frontend/sentry/index_spec.js b/spec/frontend/sentry/index_spec.js
index 2dd528a8a1c..aa19bb03cda 100644
--- a/spec/frontend/sentry/index_spec.js
+++ b/spec/frontend/sentry/index_spec.js
@@ -4,8 +4,6 @@ import LegacySentryConfig from '~/sentry/legacy_sentry_config';
import SentryConfig from '~/sentry/sentry_config';
describe('Sentry init', () => {
- let originalGon;
-
const dsn = 'https://123@sentry.gitlab.test/123';
const environment = 'test';
const currentUserId = '1';
@@ -14,7 +12,6 @@ describe('Sentry init', () => {
const featureCategory = 'my_feature_category';
beforeEach(() => {
- originalGon = window.gon;
window.gon = {
sentry_dsn: dsn,
sentry_environment: environment,
@@ -28,10 +25,6 @@ describe('Sentry init', () => {
jest.spyOn(SentryConfig, 'init').mockImplementation();
});
- afterEach(() => {
- window.gon = originalGon;
- });
-
it('exports new version of Sentry in the global object', () => {
// eslint-disable-next-line no-underscore-dangle
expect(window._Sentry.SDK_VERSION).not.toMatch(/^5\./);
@@ -61,4 +54,49 @@ describe('Sentry init', () => {
expect(LegacySentryConfig.init).not.toHaveBeenCalled();
});
});
+
+ describe('with "data-page" attr in body', () => {
+ const mockPage = 'projects:show';
+
+ beforeEach(() => {
+ document.body.dataset.page = mockPage;
+
+ index();
+ });
+
+ afterEach(() => {
+ delete document.body.dataset.page;
+ });
+
+ it('configures sentry with a "page" tag', () => {
+ expect(SentryConfig.init).toHaveBeenCalledTimes(1);
+ expect(SentryConfig.init).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tags: {
+ revision,
+ page: mockPage,
+ feature_category: featureCategory,
+ },
+ }),
+ );
+ });
+ });
+
+ describe('with no tags configuration', () => {
+ beforeEach(() => {
+ window.gon.revision = undefined;
+ window.gon.feature_category = undefined;
+
+ index();
+ });
+
+ it('configures sentry with no tags', () => {
+ expect(SentryConfig.init).toHaveBeenCalledTimes(1);
+ expect(SentryConfig.init).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tags: {},
+ }),
+ );
+ });
+ });
});
diff --git a/spec/frontend/sentry/legacy_index_spec.js b/spec/frontend/sentry/legacy_index_spec.js
index 5c336f8392e..493b4dfde67 100644
--- a/spec/frontend/sentry/legacy_index_spec.js
+++ b/spec/frontend/sentry/legacy_index_spec.js
@@ -4,8 +4,6 @@ import LegacySentryConfig from '~/sentry/legacy_sentry_config';
import SentryConfig from '~/sentry/sentry_config';
describe('Sentry init', () => {
- let originalGon;
-
const dsn = 'https://123@sentry.gitlab.test/123';
const environment = 'test';
const currentUserId = '1';
@@ -14,7 +12,6 @@ describe('Sentry init', () => {
const featureCategory = 'my_feature_category';
beforeEach(() => {
- originalGon = window.gon;
window.gon = {
sentry_dsn: dsn,
sentry_environment: environment,
@@ -28,10 +25,6 @@ describe('Sentry init', () => {
jest.spyOn(SentryConfig, 'init').mockImplementation();
});
- afterEach(() => {
- window.gon = originalGon;
- });
-
it('exports legacy version of Sentry in the global object', () => {
// eslint-disable-next-line no-underscore-dangle
expect(window._Sentry.SDK_VERSION).toMatch(/^5\./);
diff --git a/spec/frontend/sentry/sentry_browser_wrapper_spec.js b/spec/frontend/sentry/sentry_browser_wrapper_spec.js
index f4d646bab78..55354eceb8d 100644
--- a/spec/frontend/sentry/sentry_browser_wrapper_spec.js
+++ b/spec/frontend/sentry/sentry_browser_wrapper_spec.js
@@ -25,7 +25,7 @@ describe('SentryBrowserWrapper', () => {
let mockCaptureMessage;
let mockWithScope;
- beforeEach(async () => {
+ beforeEach(() => {
mockCaptureException = jest.fn();
mockCaptureMessage = jest.fn();
mockWithScope = jest.fn();
diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js
index 44acbee9b38..34c5221ef0d 100644
--- a/spec/frontend/sentry/sentry_config_spec.js
+++ b/spec/frontend/sentry/sentry_config_spec.js
@@ -1,5 +1,4 @@
import * as Sentry from 'sentrybrowser7';
-import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from '~/sentry/constants';
import SentryConfig from '~/sentry/sentry_config';
@@ -62,11 +61,8 @@ describe('SentryConfig', () => {
expect(Sentry.init).toHaveBeenCalledWith({
dsn: options.dsn,
release: options.release,
- sampleRate: SAMPLE_RATE,
allowUrls: options.allowUrls,
environment: options.environment,
- ignoreErrors: IGNORE_ERRORS,
- denyUrls: DENY_URLS,
});
});
@@ -82,11 +78,8 @@ describe('SentryConfig', () => {
expect(Sentry.init).toHaveBeenCalledWith({
dsn: options.dsn,
release: options.release,
- sampleRate: SAMPLE_RATE,
allowUrls: options.allowUrls,
environment: 'development',
- ignoreErrors: IGNORE_ERRORS,
- denyUrls: DENY_URLS,
});
});
});
diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
index 85cd8d51272..60267cf31be 100644
--- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
+++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
@@ -1,22 +1,27 @@
import { GlModal, GlFormCheckbox } from '@gitlab/ui';
import { nextTick } from 'vue';
+import { createWrapper } from '@vue/test-utils';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { useFakeDate } from 'helpers/fake_date';
import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import * as UserApi from '~/api/user_api';
import EmojiPicker from '~/emoji/components/picker.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import stubChildren from 'helpers/stub_children';
import SetStatusModalWrapper from '~/set_status_modal/set_status_modal_wrapper.vue';
import { AVAILABILITY_STATUS } from '~/set_status_modal/constants';
import SetStatusForm from '~/set_status_modal/set_status_form.vue';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
+import { BV_HIDE_MODAL } from '~/lib/utils/constants';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('SetStatusModalWrapper', () => {
let wrapper;
+ const mockToastShow = jest.fn();
+
const $toast = {
- show: jest.fn(),
+ show: mockToastShow,
};
const defaultEmoji = 'speech_balloon';
@@ -58,21 +63,9 @@ describe('SetStatusModalWrapper', () => {
const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button');
const findAvailabilityCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const getEmojiPicker = () => wrapper.findComponent(EmojiPickerStub);
-
- const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => {
- const modal = findModal();
- // mock internal emoji methods
- wrapper.vm.showEmojiMenu = jest.fn();
- wrapper.vm.hideEmojiMenu = jest.fn();
- if (mockOnUpdateSuccess) wrapper.vm.onUpdateSuccess = jest.fn();
- if (mockOnUpdateFailure) wrapper.vm.onUpdateFail = jest.fn();
-
- modal.vm.$emit('shown');
- await nextTick();
- };
+ const initModal = () => findModal().vm.$emit('shown');
afterEach(() => {
- wrapper.destroy();
clearEmojiMock();
});
@@ -149,6 +142,8 @@ describe('SetStatusModalWrapper', () => {
describe('update status', () => {
describe('succeeds', () => {
+ useMockLocationHelper();
+
beforeEach(async () => {
await initEmojiMock();
wrapper = createComponent();
@@ -195,11 +190,21 @@ describe('SetStatusModalWrapper', () => {
});
});
- it('calls the "onUpdateSuccess" handler', async () => {
+ it('displays a toast message and reloads window', async () => {
+ findModal().vm.$emit('primary');
+ await nextTick();
+
+ expect(mockToastShow).toHaveBeenCalledWith('Status updated');
+ expect(window.location.reload).toHaveBeenCalled();
+ });
+
+ it('closes modal', async () => {
+ const rootWrapper = createWrapper(wrapper.vm.$root);
+
findModal().vm.$emit('primary');
await nextTick();
- expect(wrapper.vm.onUpdateSuccess).toHaveBeenCalled();
+ expect(rootWrapper.emitted(BV_HIDE_MODAL)).toEqual([['set-user-status-modal']]);
});
});
@@ -228,11 +233,22 @@ describe('SetStatusModalWrapper', () => {
jest.spyOn(UserApi, 'updateUserStatus').mockRejectedValue();
});
- it('calls the "onUpdateFail" handler', async () => {
+ it('displays an error alert', async () => {
+ findModal().vm.$emit('primary');
+ await nextTick();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: "Sorry, we weren't able to set your status. Please try again later.",
+ });
+ });
+
+ it('closes modal', async () => {
+ const rootWrapper = createWrapper(wrapper.vm.$root);
+
findModal().vm.$emit('primary');
await nextTick();
- expect(wrapper.vm.onUpdateFail).toHaveBeenCalled();
+ expect(rootWrapper.emitted(BV_HIDE_MODAL)).toEqual([['set-user-status-modal']]);
});
});
@@ -244,7 +260,7 @@ describe('SetStatusModalWrapper', () => {
return initModal({ mockOnUpdateFailure: false });
});
- it('flashes an error message', async () => {
+ it('alerts an error message', async () => {
findModal().vm.$emit('primary');
await nextTick();
diff --git a/spec/frontend/set_status_modal/user_profile_set_status_wrapper_spec.js b/spec/frontend/set_status_modal/user_profile_set_status_wrapper_spec.js
index a4a2a86dc73..a6ad90123b7 100644
--- a/spec/frontend/set_status_modal/user_profile_set_status_wrapper_spec.js
+++ b/spec/frontend/set_status_modal/user_profile_set_status_wrapper_spec.js
@@ -37,10 +37,6 @@ describe('UserProfileSetStatusWrapper', () => {
const findInput = (name) => wrapper.find(`[name="${name}"]`);
const findSetStatusForm = () => wrapper.findComponent(SetStatusForm);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders `SetStatusForm` component and passes expected props', () => {
createComponent();
diff --git a/spec/frontend/settings_panels_spec.js b/spec/frontend/settings_panels_spec.js
index d59e1a20b27..1ef91181e1d 100644
--- a/spec/frontend/settings_panels_spec.js
+++ b/spec/frontend/settings_panels_spec.js
@@ -1,10 +1,11 @@
import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlGroupsEdit from 'test_fixtures/groups/edit.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initSettingsPanels, { isExpanded } from '~/settings_panels';
describe('Settings Panels', () => {
beforeEach(() => {
- loadHTMLFixture('groups/edit.html');
+ setHTMLFixture(htmlGroupsEdit);
});
afterEach(() => {
diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js
index e859d435f48..88ad9204d08 100644
--- a/spec/frontend/shortcuts_spec.js
+++ b/spec/frontend/shortcuts_spec.js
@@ -1,67 +1,41 @@
import $ from 'jquery';
import { flatten } from 'lodash';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import Shortcuts from '~/behaviors/shortcuts/shortcuts';
-
-const mockMousetrap = {
- bind: jest.fn(),
- unbind: jest.fn(),
-};
-
-jest.mock('mousetrap', () => {
- return jest.fn().mockImplementation(() => mockMousetrap);
-});
-
-jest.mock('mousetrap/plugins/pause/mousetrap-pause', () => {});
+import htmlSnippetsShow from 'test_fixtures/snippets/show.html';
+import { Mousetrap } from '~/lib/mousetrap';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import Shortcuts, { LOCAL_MOUSETRAP_DATA_KEY } from '~/behaviors/shortcuts/shortcuts';
+import MarkdownPreview from '~/behaviors/preview_markdown';
describe('Shortcuts', () => {
- const fixtureName = 'snippets/show.html';
const createEvent = (type, target) =>
$.Event(type, {
target,
});
+ let shortcuts;
+
+ beforeAll(() => {
+ shortcuts = new Shortcuts();
+ });
beforeEach(() => {
- loadHTMLFixture(fixtureName);
+ setHTMLFixture(htmlSnippetsShow);
+
+ new Shortcuts(); // eslint-disable-line no-new
+ new MarkdownPreview(); // eslint-disable-line no-new
- jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus');
- jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus');
jest.spyOn(document.querySelector('#search'), 'focus');
- new Shortcuts(); // eslint-disable-line no-new
+ jest.spyOn(Mousetrap.prototype, 'stopCallback');
+ jest.spyOn(Mousetrap.prototype, 'bind').mockImplementation();
+ jest.spyOn(Mousetrap.prototype, 'unbind').mockImplementation();
});
afterEach(() => {
resetHTMLFixture();
});
- describe('toggleMarkdownPreview', () => {
- it('focuses preview button in form', () => {
- Shortcuts.toggleMarkdownPreview(
- createEvent('KeyboardEvent', document.querySelector('.js-new-note-form .js-note-text')),
- );
-
- expect(
- document.querySelector('.js-new-note-form .js-md-preview-button').focus,
- ).toHaveBeenCalled();
- });
-
- it('focuses preview button inside edit comment form', () => {
- document.querySelector('.js-note-edit').click();
-
- Shortcuts.toggleMarkdownPreview(
- createEvent('KeyboardEvent', document.querySelector('.edit-note .js-note-text')),
- );
-
- expect(
- document.querySelector('.js-new-note-form .js-md-preview-button').focus,
- ).not.toHaveBeenCalled();
- expect(document.querySelector('.edit-note .js-md-preview-button').focus).toHaveBeenCalled();
- });
- });
-
describe('markdown shortcuts', () => {
- let shortcuts;
+ let shortcutElements;
beforeEach(() => {
// Get all shortcuts specified with md-shortcuts attributes in the fixture.
@@ -71,7 +45,7 @@ describe('Shortcuts', () => {
// [ 'mod+i' ],
// [ 'mod+k' ]
// ]
- shortcuts = $('.edit-note .js-md')
+ shortcutElements = $('.edit-note .js-md')
.map(function getShortcutsFromToolbarBtn() {
const mdShortcuts = $(this).data('md-shortcuts');
@@ -83,19 +57,26 @@ describe('Shortcuts', () => {
});
describe('initMarkdownEditorShortcuts', () => {
+ let $textarea;
+ let localMousetrapInstance;
+
beforeEach(() => {
- Shortcuts.initMarkdownEditorShortcuts($('.edit-note textarea'));
+ $textarea = $('.edit-note textarea');
+ Shortcuts.initMarkdownEditorShortcuts($textarea);
+ localMousetrapInstance = $textarea.data(LOCAL_MOUSETRAP_DATA_KEY);
});
it('attaches a Mousetrap handler for every markdown shortcut specified with md-shortcuts', () => {
- const expectedCalls = shortcuts.map((s) => [s, expect.any(Function)]);
+ const expectedCalls = shortcutElements.map((s) => [s, expect.any(Function)]);
- expect(mockMousetrap.bind.mock.calls).toEqual(expectedCalls);
+ expect(Mousetrap.prototype.bind.mock.calls).toEqual(expectedCalls);
});
it('attaches a stopCallback that allows each markdown shortcut specified with md-shortcuts', () => {
- flatten(shortcuts).forEach((s) => {
- expect(mockMousetrap.stopCallback(null, null, s)).toBe(false);
+ flatten(shortcutElements).forEach((s) => {
+ expect(
+ localMousetrapInstance.stopCallback.call(localMousetrapInstance, null, null, s),
+ ).toBe(false);
});
});
});
@@ -104,25 +85,67 @@ describe('Shortcuts', () => {
it('does nothing if initMarkdownEditorShortcuts was not previous called', () => {
Shortcuts.removeMarkdownEditorShortcuts($('.edit-note textarea'));
- expect(mockMousetrap.unbind.mock.calls).toEqual([]);
+ expect(Mousetrap.prototype.unbind.mock.calls).toEqual([]);
});
it('removes Mousetrap handlers for every markdown shortcut specified with md-shortcuts', () => {
Shortcuts.initMarkdownEditorShortcuts($('.edit-note textarea'));
Shortcuts.removeMarkdownEditorShortcuts($('.edit-note textarea'));
- const expectedCalls = shortcuts.map((s) => [s]);
+ const expectedCalls = shortcutElements.map((s) => [s]);
- expect(mockMousetrap.unbind.mock.calls).toEqual(expectedCalls);
+ expect(Mousetrap.prototype.unbind.mock.calls).toEqual(expectedCalls);
});
});
});
describe('focusSearch', () => {
- it('focuses the search bar', () => {
- Shortcuts.focusSearch(createEvent('KeyboardEvent'));
+ describe('when super sidebar is NOT enabled', () => {
+ let originalGon;
+ beforeEach(() => {
+ originalGon = window.gon;
+ window.gon = { use_new_navigation: false };
+ });
+
+ afterEach(() => {
+ window.gon = originalGon;
+ });
+
+ it('focuses the search bar', () => {
+ Shortcuts.focusSearch(createEvent('KeyboardEvent'));
+ expect(document.querySelector('#search').focus).toHaveBeenCalled();
+ });
+ });
+ });
- expect(document.querySelector('#search').focus).toHaveBeenCalled();
+ describe('bindCommand(s)', () => {
+ it('bindCommand calls Mousetrap.bind correctly', () => {
+ const mockCommand = { defaultKeys: ['m'] };
+ const mockCallback = () => {};
+
+ shortcuts.bindCommand(mockCommand, mockCallback);
+
+ expect(Mousetrap.prototype.bind).toHaveBeenCalledTimes(1);
+ const [callArguments] = Mousetrap.prototype.bind.mock.calls;
+ expect(callArguments[0]).toEqual(mockCommand.defaultKeys);
+ expect(callArguments[1]).toBe(mockCallback);
+ });
+
+ it('bindCommands calls Mousetrap.bind correctly', () => {
+ const mockCommandsAndCallbacks = [
+ [{ defaultKeys: ['1'] }, () => {}],
+ [{ defaultKeys: ['2'] }, () => {}],
+ ];
+
+ shortcuts.bindCommands(mockCommandsAndCallbacks);
+
+ expect(Mousetrap.prototype.bind).toHaveBeenCalledTimes(mockCommandsAndCallbacks.length);
+ const { calls } = Mousetrap.prototype.bind.mock;
+
+ mockCommandsAndCallbacks.forEach(([mockCommand, mockCallback], i) => {
+ expect(calls[i][0]).toEqual(mockCommand.defaultKeys);
+ expect(calls[i][1]).toBe(mockCallback);
+ });
});
});
});
diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
index 60edab8766a..81b65f4f050 100644
--- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
+++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
@@ -30,10 +30,6 @@ describe('AssigneeAvatarLink component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findTooltipText = () => wrapper.attributes('title');
const findUserLink = () => wrapper.findComponent(GlLink);
diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_spec.js
index 7df37d11987..b6b3dbd5b6b 100644
--- a/spec/frontend/sidebar/components/assignees/assignee_avatar_spec.js
+++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_spec.js
@@ -7,7 +7,6 @@ const TEST_AVATAR = `${TEST_HOST}/avatar.png`;
const TEST_DEFAULT_AVATAR_URL = `${TEST_HOST}/default/avatar/url.png`;
describe('AssigneeAvatar', () => {
- let origGon;
let wrapper;
function createComponent(props = {}) {
@@ -24,15 +23,9 @@ describe('AssigneeAvatar', () => {
}
beforeEach(() => {
- origGon = window.gon;
window.gon = { default_avatar_url: TEST_DEFAULT_AVATAR_URL };
});
- afterEach(() => {
- window.gon = origGon;
- wrapper.destroy();
- });
-
const findImg = () => wrapper.find('img');
it('does not show warning icon if assignee can merge', () => {
diff --git a/spec/frontend/sidebar/components/assignees/assignee_title_spec.js b/spec/frontend/sidebar/components/assignees/assignee_title_spec.js
index 14a6bdbf907..d561c761c99 100644
--- a/spec/frontend/sidebar/components/assignees/assignee_title_spec.js
+++ b/spec/frontend/sidebar/components/assignees/assignee_title_spec.js
@@ -17,11 +17,6 @@ describe('AssigneeTitle component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('assignee title', () => {
it('renders assignee', () => {
wrapper = createComponent({
diff --git a/spec/frontend/sidebar/components/assignees/assignees_realtime_spec.js b/spec/frontend/sidebar/components/assignees/assignees_realtime_spec.js
index 080171fb2ea..0501c1bae23 100644
--- a/spec/frontend/sidebar/components/assignees/assignees_realtime_spec.js
+++ b/spec/frontend/sidebar/components/assignees/assignees_realtime_spec.js
@@ -49,7 +49,6 @@ describe('Assignees Realtime', () => {
});
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
SidebarMediator.singleton = null;
});
diff --git a/spec/frontend/sidebar/components/assignees/assignees_spec.js b/spec/frontend/sidebar/components/assignees/assignees_spec.js
index d422292ed9e..1661e28abd2 100644
--- a/spec/frontend/sidebar/components/assignees/assignees_spec.js
+++ b/spec/frontend/sidebar/components/assignees/assignees_spec.js
@@ -25,10 +25,6 @@ describe('Assignee component', () => {
const findComponentTextNoUsers = () => wrapper.find('[data-testid="no-value"]');
const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('No assignees/users', () => {
it('displays no assignee icon when collapsed', () => {
createWrapper();
diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js
index 7e7d4921cfa..40d3d090bb4 100644
--- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js
+++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js
@@ -26,10 +26,6 @@ describe('CollapsedAssigneeList component', () => {
const findAssignees = () => wrapper.findAllComponents(CollapsedAssignee);
const getTooltipTitle = () => wrapper.attributes('title');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('No assignees/users', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js
index 4db95114b96..851eaedf0bd 100644
--- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js
+++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js
@@ -21,10 +21,6 @@ describe('CollapsedAssignee assignee component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has author name', () => {
createComponent();
diff --git a/spec/frontend/sidebar/components/assignees/issuable_assignees_spec.js b/spec/frontend/sidebar/components/assignees/issuable_assignees_spec.js
index 1161fefcc64..82145b82e21 100644
--- a/spec/frontend/sidebar/components/assignees/issuable_assignees_spec.js
+++ b/spec/frontend/sidebar/components/assignees/issuable_assignees_spec.js
@@ -20,11 +20,6 @@ describe('IssuableAssignees', () => {
const findUncollapsedAssigneeList = () => wrapper.findComponent(UncollapsedAssigneeList);
const findEmptyAssignee = () => wrapper.find('[data-testid="none"]');
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when no assignees are present', () => {
it.each`
signedIn | editable | message
diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js
index 58b174059fa..a189d3656a2 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js
@@ -39,9 +39,6 @@ describe('sidebar assignees', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
-
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
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 3aca346ff5f..9f7c587ca9d 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
@@ -5,8 +5,8 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
-import { IssuableType } from '~/issues/constants';
+import { createAlert } from '~/alert';
+import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
@@ -17,7 +17,7 @@ import updateIssueAssigneesMutation from '~/sidebar/queries/update_issue_assigne
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import { issuableQueryResponse, updateIssueAssigneesMutationResponse } from '../../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const updateIssueAssigneesMutationSuccess = jest
.fn()
@@ -98,10 +98,7 @@ describe('Sidebar assignees widget', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
fakeApollo = null;
- delete gon.current_username;
});
describe('with passed initial assignees', () => {
@@ -397,7 +394,7 @@ describe('Sidebar assignees widget', () => {
});
it('does not render invite members link on non-issue sidebar', async () => {
- createComponent({ props: { issuableType: IssuableType.MergeRequest } });
+ createComponent({ props: { issuableType: TYPE_MERGE_REQUEST } });
await waitForPromises();
expect(findInviteMembersLink().exists()).toBe(false);
});
diff --git a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js
index 6c22d2f687d..27c31ac56c9 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js
@@ -21,11 +21,6 @@ describe('boards sidebar remove issue', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('template', () => {
it('renders title', () => {
const title = 'Sidebar item title';
diff --git a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js
index b738d931040..501048bf056 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js
@@ -16,10 +16,6 @@ describe('Sidebar invite members component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when directly inviting members', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js
index be0b14fa997..25a19b5808b 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js
@@ -1,6 +1,6 @@
import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
const user = {
@@ -32,20 +32,17 @@ describe('Sidebar participant component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('does not show `Busy` status when user is not busy', () => {
createComponent();
expect(findAvatar().props('label')).toBe(user.name);
+ expect(wrapper.text()).not.toContain('Busy');
});
it('shows `Busy` status when user is busy', () => {
createComponent({ status: { availability: 'BUSY' } });
- expect(findAvatar().props('label')).toBe(`${user.name} (Busy)`);
+ expect(wrapper.text()).toContain('Busy');
});
it('does not render a warning icon', () => {
@@ -56,13 +53,13 @@ describe('Sidebar participant component', () => {
describe('when on merge request sidebar', () => {
it('when project member cannot merge', () => {
- createComponent({ issuableType: IssuableType.MergeRequest });
+ createComponent({ issuableType: TYPE_MERGE_REQUEST });
expect(findIcon().exists()).toBe(true);
});
it('when project member can merge', () => {
- createComponent({ issuableType: IssuableType.MergeRequest, canMerge: true });
+ createComponent({ issuableType: TYPE_MERGE_REQUEST, canMerge: true });
expect(findIcon().exists()).toBe(false);
});
diff --git a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js
index 03c2e1a37a9..c74a714cca4 100644
--- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js
+++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js
@@ -24,10 +24,6 @@ describe('UncollapsedAssigneeList component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findMoreButton = () => wrapper.find('.user-list-more button');
describe('One assignee/user', () => {
diff --git a/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js b/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js
index 37c16bc9235..e54ba31a30c 100644
--- a/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js
+++ b/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js
@@ -18,10 +18,6 @@ describe('UserNameWithStatus', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('will render the users name', () => {
expect(wrapper.html()).toContain(name);
});
@@ -41,7 +37,7 @@ describe('UserNameWithStatus', () => {
});
it('will render "Busy"', () => {
- expect(wrapper.text()).toContain('(Busy)');
+ expect(wrapper.text()).toContain('Busy');
});
});
@@ -53,7 +49,7 @@ describe('UserNameWithStatus', () => {
});
it("renders user's name with pronouns", () => {
- expect(wrapper.text()).toMatchInterpolatedText(`${name} (${pronouns})`);
+ expect(wrapper.text()).toMatchInterpolatedText(`${name}(${pronouns})`);
});
});
diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js
index 81354d64a90..4a2b3b30e6d 100644
--- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js
+++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js
@@ -18,10 +18,6 @@ describe('Sidebar Confidentiality Content', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('emits `expandSidebar` event on collapsed icon click', () => {
createComponent();
findCollapsedIcon().trigger('click');
diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js
index b27f7c6b4e1..1ca20dad1c6 100644
--- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js
+++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js
@@ -2,11 +2,11 @@ import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue';
import { confidentialityQueries } from '~/sidebar/constants';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Sidebar Confidentiality Form', () => {
let wrapper;
@@ -38,10 +38,6 @@ describe('Sidebar Confidentiality Form', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('emits a `closeForm` event when Cancel button is clicked', () => {
createComponent();
findCancelButton().vm.$emit('click');
@@ -58,7 +54,7 @@ describe('Sidebar Confidentiality Form', () => {
expect(findConfidentialToggle().props('loading')).toBe(true);
});
- it('creates a flash if mutation is rejected', async () => {
+ it('creates an alert if mutation is rejected', async () => {
createComponent({ mutate: jest.fn().mockRejectedValue('Error!') });
findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
await waitForPromises();
@@ -68,7 +64,7 @@ describe('Sidebar Confidentiality Form', () => {
});
});
- it('creates a flash if mutation contains errors', async () => {
+ it('creates an alert if mutation contains errors', async () => {
createComponent({
mutate: jest.fn().mockResolvedValue({
data: { issuableSetConfidential: { errors: ['Houston, we have a problem!'] } },
diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js
index e486a8e9ec7..39b30307dd7 100644
--- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js
+++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js
@@ -4,7 +4,7 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue';
import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue';
import SidebarConfidentialityWidget, {
@@ -14,7 +14,7 @@ import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
import { issueConfidentialityResponse } from '../../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -48,7 +48,6 @@ describe('Sidebar Confidentiality Widget', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -120,7 +119,7 @@ describe('Sidebar Confidentiality Widget', () => {
});
});
- it('displays a flash message when query is rejected', async () => {
+ it('displays an alert message when query is rejected', async () => {
createComponent({
confidentialQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
});
diff --git a/spec/frontend/sidebar/components/copy/copyable_field_spec.js b/spec/frontend/sidebar/components/copy/copyable_field_spec.js
index 7790d77bc65..03e131aab35 100644
--- a/spec/frontend/sidebar/components/copy/copyable_field_spec.js
+++ b/spec/frontend/sidebar/components/copy/copyable_field_spec.js
@@ -20,10 +20,6 @@ describe('SidebarCopyableField', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
diff --git a/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js
index c3de076d6aa..2ae80b2c97b 100644
--- a/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js
+++ b/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import SidebarReferenceWidget from '~/sidebar/components/copy/sidebar_reference_widget.vue';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
@@ -39,10 +39,6 @@ describe('Sidebar Reference Widget', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when reference is loading', () => {
it('sets CopyableField `is-loading` prop to `true`', () => {
createComponent({ referenceQueryHandler: jest.fn().mockReturnValue(new Promise(() => {})) });
@@ -52,7 +48,7 @@ describe('Sidebar Reference Widget', () => {
describe.each([
[TYPE_ISSUE, issueReferenceQuery],
- [IssuableType.MergeRequest, mergeRequestReferenceQuery],
+ [TYPE_MERGE_REQUEST, mergeRequestReferenceQuery],
])('when issuableType is %s', (issuableType, referenceQuery) => {
it('sets CopyableField `value` prop to reference value', async () => {
createComponent({
diff --git a/spec/frontend/sidebar/components/crm_contacts/crm_contacts_spec.js b/spec/frontend/sidebar/components/crm_contacts/crm_contacts_spec.js
index ca43c219d92..546cabd07d3 100644
--- a/spec/frontend/sidebar/components/crm_contacts/crm_contacts_spec.js
+++ b/spec/frontend/sidebar/components/crm_contacts/crm_contacts_spec.js
@@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import CrmContacts from '~/sidebar/components/crm_contacts/crm_contacts.vue';
import getIssueCrmContactsQuery from '~/sidebar/queries/get_issue_crm_contacts.query.graphql';
import issueCrmContactsSubscription from '~/sidebar/queries/issue_crm_contacts.subscription.graphql';
@@ -13,7 +13,7 @@ import {
issueCrmContactsUpdateNullResponse,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Issue crm contacts component', () => {
Vue.use(VueApollo);
@@ -39,7 +39,6 @@ describe('Issue crm contacts component', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js
index 67413cffdda..8f82a2d1258 100644
--- a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js
+++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js
@@ -4,16 +4,21 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarFormattedDate from '~/sidebar/components/date/sidebar_formatted_date.vue';
import SidebarInheritDate from '~/sidebar/components/date/sidebar_inherit_date.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql';
import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
-import { issuableDueDateResponse, issuableStartDateResponse } from '../../mock_data';
+import issueDueDateSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql';
+import {
+ issuableDueDateResponse,
+ issuableStartDateResponse,
+ issueDueDateSubscriptionResponse,
+} from '../../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -22,10 +27,6 @@ describe('Sidebar date Widget', () => {
let fakeApollo;
const date = '2021-04-15';
- window.gon = {
- first_day_of_week: 1,
- };
-
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findPopoverIcon = () => wrapper.find('[data-testid="inherit-date-popover"]');
const findDatePicker = () => wrapper.findComponent(GlDatepicker);
@@ -33,6 +34,7 @@ describe('Sidebar date Widget', () => {
const createComponent = ({
dueDateQueryHandler = jest.fn().mockResolvedValue(issuableDueDateResponse()),
startDateQueryHandler = jest.fn().mockResolvedValue(issuableStartDateResponse()),
+ dueDateSubscriptionHandler = jest.fn().mockResolvedValue(issueDueDateSubscriptionResponse()),
canInherit = false,
dateType = undefined,
issuableType = 'issue',
@@ -40,6 +42,7 @@ describe('Sidebar date Widget', () => {
fakeApollo = createMockApollo([
[issueDueDateQuery, dueDateQueryHandler],
[epicStartDateQuery, startDateQueryHandler],
+ [issueDueDateSubscription, dueDateSubscriptionHandler],
]);
wrapper = shallowMount(SidebarDateWidget, {
@@ -61,8 +64,11 @@ describe('Sidebar date Widget', () => {
});
};
+ beforeEach(() => {
+ window.gon.first_day_of_week = 1;
+ });
+
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -125,18 +131,30 @@ describe('Sidebar date Widget', () => {
it('uses a correct prop to set the initial date and first day of the week for GlDatePicker', () => {
expect(findDatePicker().props()).toMatchObject({
- value: null,
+ value: new Date(date),
autocomplete: 'off',
defaultDate: expect.any(Object),
firstDay: window.gon.first_day_of_week,
});
});
- it('renders GlDatePicker', async () => {
+ it('renders GlDatePicker', () => {
expect(findDatePicker().exists()).toBe(true);
});
});
+ describe('real time issue due date feature', () => {
+ it('should call the subscription', async () => {
+ const dueDateSubscriptionHandler = jest
+ .fn()
+ .mockResolvedValue(issueDueDateSubscriptionResponse());
+ createComponent({ dueDateSubscriptionHandler });
+ await waitForPromises();
+
+ expect(dueDateSubscriptionHandler).toHaveBeenCalled();
+ });
+ });
+
it.each`
canInherit | component | componentName | expected
${true} | ${SidebarFormattedDate} | ${'SidebarFormattedDate'} | ${false}
@@ -153,13 +171,13 @@ describe('Sidebar date Widget', () => {
},
);
- it('does not render SidebarInheritDate when canInherit is true and date is loading', async () => {
+ it('does not render SidebarInheritDate when canInherit is true and date is loading', () => {
createComponent({ canInherit: true });
expect(wrapper.findComponent(SidebarInheritDate).exists()).toBe(false);
});
- it('displays a flash message when query is rejected', async () => {
+ it('displays an alert message when query is rejected', async () => {
createComponent({
dueDateQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
});
diff --git a/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js b/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js
index cbe01263dcd..1bb910c53ea 100644
--- a/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js
+++ b/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js
@@ -27,10 +27,6 @@ describe('SidebarFormattedDate', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays formatted date', () => {
expect(findFormattedDate().text()).toBe('Apr 15, 2021');
});
diff --git a/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js
index a7556b9110c..97debe3088d 100644
--- a/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js
+++ b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js
@@ -31,10 +31,6 @@ describe('SidebarInheritDate', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays formatted fixed and inherited dates with radio buttons', () => {
expect(wrapper.findAllComponents(SidebarFormattedDate)).toHaveLength(2);
expect(wrapper.findAllComponents(GlFormRadio)).toHaveLength(2);
diff --git a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js
index 1a78ce4ddee..e356f02a36b 100644
--- a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js
+++ b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js
@@ -17,10 +17,6 @@ describe('EscalationStatus', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDropdownComponent = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findDropdownMenu = () => findDropdownComponent().find('.dropdown-menu');
diff --git a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js
index 2dded61c073..00b57b4916e 100644
--- a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js
+++ b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js
@@ -18,11 +18,11 @@ import {
} from '~/sidebar/constants';
import waitForPromises from 'helpers/wait_for_promises';
import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { logError } from '~/lib/logger';
jest.mock('~/lib/logger');
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -57,7 +57,7 @@ describe('SidebarEscalationStatus', () => {
canUpdate: true,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
apolloProvider,
});
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js
index 4f2a89e20db..084ca5ed3fc 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js
@@ -29,10 +29,6 @@ describe('DropdownButton', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDropdownButton = () => wrapper.findComponent(GlButton);
const findDropdownText = () => wrapper.find('.dropdown-toggle-text');
const findDropdownIcon = () => wrapper.findComponent(GlIcon);
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js
index 59e95edfa20..7e53fcfe850 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js
@@ -1,77 +1,70 @@
import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue';
import labelSelectModule from '~/sidebar/components/labels/labels_select_vue/store';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockConfig, mockSuggestedColors } from './mock_data';
Vue.use(Vuex);
-const createComponent = (initialState = mockConfig) => {
- const store = new Vuex.Store(labelSelectModule());
-
- store.dispatch('setInitialState', initialState);
-
- return shallowMount(DropdownContentsCreateView, {
- store,
- });
-};
-
describe('DropdownContentsCreateView', () => {
let wrapper;
const colors = Object.keys(mockSuggestedColors).map((color) => ({
[color]: mockSuggestedColors[color],
}));
+ const createComponent = (initialState = mockConfig) => {
+ const store = new Vuex.Store(labelSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ wrapper = shallowMountExtended(DropdownContentsCreateView, {
+ store,
+ });
+ };
+
+ const findColorSelectorInput = () => wrapper.findByTestId('selected-color');
+ const findLabelTitleInput = () => wrapper.findByTestId('label-title');
+ const findCreateClickButton = () => wrapper.findByTestId('create-click');
+ const findAllLinks = () => wrapper.find('.dropdown-content').findAllComponents(GlLink);
+
beforeEach(() => {
gon.suggested_label_colors = mockSuggestedColors;
- wrapper = createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
+ createComponent();
});
describe('computed', () => {
describe('disableCreate', () => {
it('returns `true` when label title and color is not defined', () => {
- expect(wrapper.vm.disableCreate).toBe(true);
+ expect(findCreateClickButton().props('disabled')).toBe(true);
});
it('returns `true` when `labelCreateInProgress` is true', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- labelTitle: 'Foo',
- selectedColor: '#ff0000',
- });
+ await findColorSelectorInput().vm.$emit('input', '#ff0000');
+ await findLabelTitleInput().vm.$emit('input', 'Foo');
wrapper.vm.$store.dispatch('requestCreateLabel');
await nextTick();
- expect(wrapper.vm.disableCreate).toBe(true);
+
+ expect(findCreateClickButton().props('disabled')).toBe(true);
});
it('returns `false` when label title and color is defined and create request is not already in progress', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- labelTitle: 'Foo',
- selectedColor: '#ff0000',
- });
+ await findColorSelectorInput().vm.$emit('input', '#ff0000');
+ await findLabelTitleInput().vm.$emit('input', 'Foo');
- await nextTick();
- expect(wrapper.vm.disableCreate).toBe(false);
+ expect(findCreateClickButton().props('disabled')).toBe(false);
});
});
describe('suggestedColors', () => {
it('returns array of color objects containing color code and name', () => {
colors.forEach((color, index) => {
- expect(wrapper.vm.suggestedColors[index]).toEqual(expect.objectContaining(color));
+ expect(findAllLinks().at(index).attributes('title')).toBe(Object.values(color)[0]);
});
});
});
@@ -86,29 +79,29 @@ describe('DropdownContentsCreateView', () => {
describe('getColorName', () => {
it('returns color name from color object', () => {
+ expect(findAllLinks().at(0).attributes('title')).toBe(Object.values(colors[0]).pop());
expect(wrapper.vm.getColorName(colors[0])).toBe(Object.values(colors[0]).pop());
});
});
describe('handleColorClick', () => {
- it('sets provided `color` param to `selectedColor` prop', () => {
- wrapper.vm.handleColorClick(colors[0]);
+ it('sets provided `color` param to `selectedColor` prop', async () => {
+ await findAllLinks()
+ .at(0)
+ .vm.$emit('click', { preventDefault: () => {} });
- expect(wrapper.vm.selectedColor).toBe(Object.keys(colors[0]).pop());
+ expect(findColorSelectorInput().attributes('value')).toBe(Object.keys(colors[0]).pop());
});
});
describe('handleCreateClick', () => {
it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', async () => {
jest.spyOn(wrapper.vm, 'createLabel').mockImplementation();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- labelTitle: 'Foo',
- selectedColor: '#ff0000',
- });
- wrapper.vm.handleCreateClick();
+ await findColorSelectorInput().vm.$emit('input', '#ff0000');
+ await findLabelTitleInput().vm.$emit('input', 'Foo');
+
+ findCreateClickButton().vm.$emit('click');
await nextTick();
expect(wrapper.vm.createLabel).toHaveBeenCalledWith(
@@ -158,27 +151,27 @@ describe('DropdownContentsCreateView', () => {
});
it('renders color block element for all suggested colors', () => {
- const colorBlocksEl = wrapper.find('.dropdown-content').findAllComponents(GlLink);
-
- colorBlocksEl.wrappers.forEach((colorBlock, index) => {
+ findAllLinks().wrappers.forEach((colorBlock, index) => {
expect(colorBlock.attributes('style')).toContain('background-color');
expect(colorBlock.attributes('title')).toBe(Object.values(colors[index]).pop());
});
});
it('renders color input element', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- selectedColor: '#ff0000',
- });
+ await findColorSelectorInput().vm.$emit('input', '#ff0000');
await nextTick();
- const colorPreviewEl = wrapper.find('.color-input-container > .dropdown-label-color-preview');
- const colorInputEl = wrapper.find('.color-input-container').findComponent(GlFormInput);
+ const colorPreviewEl = wrapper
+ .find('.color-input-container')
+ .findAllComponents(GlFormInput)
+ .at(0);
+ const colorInputEl = wrapper
+ .find('.color-input-container')
+ .findAllComponents(GlFormInput)
+ .at(1);
expect(colorPreviewEl.exists()).toBe(true);
- expect(colorPreviewEl.attributes('style')).toContain('background-color');
+ expect(colorPreviewEl.attributes('value')).toBe('#ff0000');
expect(colorInputEl.exists()).toBe(true);
expect(colorInputEl.attributes('placeholder')).toBe('Use custom color #FF0000');
expect(colorInputEl.attributes('value')).toBe('#ff0000');
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js
index 865dc8fe8fb..5c6358a94ab 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js
@@ -5,27 +5,31 @@ import {
GlSearchBoxByType,
GlLink,
} from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
-import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue';
import LabelItem from '~/sidebar/components/labels/labels_select_vue/label_item.vue';
-
+import { stubComponent } from 'helpers/stub_component';
import * as actions from '~/sidebar/components/labels/labels_select_vue/store/actions';
import * as getters from '~/sidebar/components/labels/labels_select_vue/store/getters';
import mutations from '~/sidebar/components/labels/labels_select_vue/store/mutations';
import defaultState from '~/sidebar/components/labels/labels_select_vue/store/state';
-import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
+import { mockConfig, mockLabels } from './mock_data';
Vue.use(Vuex);
describe('DropdownContentsLabelsView', () => {
let wrapper;
+ let store;
+
+ const focusInputMock = jest.fn();
+ const updateSelectedLabelsMock = jest.fn();
+ const toggleDropdownContentsMock = jest.fn();
- const createComponent = (initialState = mockConfig) => {
- const store = new Vuex.Store({
+ const createComponent = (initialState = mockConfig, mountFn = shallowMountExtended) => {
+ store = new Vuex.Store({
getters,
mutations,
state: {
@@ -36,14 +40,20 @@ describe('DropdownContentsLabelsView', () => {
actions: {
...actions,
fetchLabels: jest.fn(),
+ updateSelectedLabels: updateSelectedLabelsMock,
+ toggleDropdownContents: toggleDropdownContentsMock,
},
});
store.dispatch('setInitialState', initialState);
- store.dispatch('receiveLabelsSuccess', mockLabels);
- wrapper = shallowMount(DropdownContentsLabelsView, {
+ wrapper = mountFn(DropdownContentsLabelsView, {
store,
+ stubs: {
+ GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
+ methods: { focusInput: focusInputMock },
+ }),
+ },
});
};
@@ -51,48 +61,95 @@ describe('DropdownContentsLabelsView', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
+ const findDropdownContent = () => wrapper.findByTestId('dropdown-content');
+ const findDropdownTitle = () => wrapper.findByTestId('dropdown-title');
+ const findDropdownFooter = () => wrapper.findByTestId('dropdown-footer');
+ const findNoMatchingResults = () => wrapper.findByTestId('no-matching-results');
+ const findCreateLabelLink = () => wrapper.findByTestId('create-label-link');
+ const findLabelsList = () => wrapper.findByTestId('labels-list');
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
+ const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
+ const findLabelItems = () => wrapper.findAllComponents(LabelItem);
+
+ const setCurrentHighlightItem = (value) => {
+ let initialValue = -1;
+
+ while (initialValue < value) {
+ findLabelsList().trigger('keydown.down');
+ initialValue += 1;
+ }
+ };
+
+ describe('component', () => {
+ it('calls `focusInput` on searchInput field when the component appears', async () => {
+ findIntersectionObserver().vm.$emit('appear');
+
+ await nextTick();
+
+ expect(focusInputMock).toHaveBeenCalled();
+ });
+
+ it('removes loaded labels when the component disappears', async () => {
+ jest.spyOn(store, 'dispatch');
+
+ await findIntersectionObserver().vm.$emit('disappear');
+
+ expect(store.dispatch).toHaveBeenCalledWith(expect.anything(), []);
+ });
});
- const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
- const findDropdownTitle = () => wrapper.find('[data-testid="dropdown-title"]');
- const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ describe('labels', () => {
+ describe('when it is visible', () => {
+ beforeEach(() => {
+ createComponent(undefined, mountExtended);
+ store.dispatch('receiveLabelsSuccess', mockLabels);
+ });
- describe('computed', () => {
- describe('visibleLabels', () => {
- it('returns matching labels filtered with `searchKey`', () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- searchKey: 'bug',
- });
-
- expect(wrapper.vm.visibleLabels.length).toBe(1);
- expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ it('returns matching labels filtered with `searchKey`', async () => {
+ await findSearchBoxByType().vm.$emit('input', 'bug');
+
+ const labelItems = findLabelItems();
+ expect(labelItems).toHaveLength(1);
+ expect(labelItems.at(0).text()).toBe('Bug');
+ });
+
+ it('returns matching labels with fuzzy filtering', async () => {
+ await findSearchBoxByType().vm.$emit('input', 'bg');
+
+ const labelItems = findLabelItems();
+ expect(labelItems).toHaveLength(2);
+ expect(labelItems.at(0).text()).toBe('Bug');
+ expect(labelItems.at(1).text()).toBe('Boog');
+ });
+
+ it('returns all labels when `searchKey` is empty', async () => {
+ await findSearchBoxByType().vm.$emit('input', '');
+
+ expect(findLabelItems()).toHaveLength(mockLabels.length);
+ });
+ });
+
+ describe('when it is clicked', () => {
+ beforeEach(() => {
+ createComponent(undefined, mountExtended);
+ store.dispatch('receiveLabelsSuccess', mockLabels);
});
- it('returns matching labels with fuzzy filtering', () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- searchKey: 'bg',
- });
+ it('calls action `updateSelectedLabels` with provided `label` param', () => {
+ findLabelItems().at(0).findComponent(GlLink).vm.$emit('click');
- expect(wrapper.vm.visibleLabels.length).toBe(2);
- expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
- expect(wrapper.vm.visibleLabels[1].title).toBe('Boog');
+ expect(updateSelectedLabelsMock).toHaveBeenCalledWith(expect.anything(), [
+ { ...mockLabels[0], indeterminate: expect.anything(), set: expect.anything() },
+ ]);
});
- it('returns all labels when `searchKey` is empty', () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- searchKey: '',
- });
+ it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
+ store.state.allowMultiselect = false;
+
+ findLabelItems().at(0).findComponent(GlLink).vm.$emit('click');
- expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length);
+ expect(toggleDropdownContentsMock).toHaveBeenCalled();
});
});
@@ -106,190 +163,110 @@ describe('DropdownContentsLabelsView', () => {
`(
'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription',
async ({ searchKey, labels, returnValue }) => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- searchKey,
- });
+ store.dispatch('receiveLabelsSuccess', labels);
- wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels);
+ await findSearchBoxByType().vm.$emit('input', searchKey);
- await nextTick();
-
- expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue);
+ expect(findNoMatchingResults().isVisible()).toBe(returnValue);
},
);
});
});
- describe('methods', () => {
- const fakePreventDefault = jest.fn();
+ describe('create label link', () => {
+ it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', async () => {
+ jest.spyOn(store, 'dispatch');
- describe('isLabelSelected', () => {
- it('returns true when provided `label` param is one of the selected labels', () => {
- expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
- });
+ await findCreateLabelLink().vm.$emit('click');
- it('returns false when provided `label` param is not one of the selected labels', () => {
- expect(wrapper.vm.isLabelSelected(mockLabels[1])).toBe(false);
- });
+ expect(store.dispatch).toHaveBeenCalledWith('receiveLabelsSuccess', []);
+ expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContentsCreateView');
});
+ });
- describe('handleComponentAppear', () => {
- it('calls `focusInput` on searchInput field', async () => {
- wrapper.vm.$refs.searchInput.focusInput = jest.fn();
-
- await wrapper.vm.handleComponentAppear();
-
- expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
- });
- });
-
- describe('handleComponentDisappear', () => {
- it('calls action `receiveLabelsSuccess` with empty array', () => {
- jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
-
- wrapper.vm.handleComponentDisappear();
+ describe('keyboard navigation', () => {
+ const fakePreventDefault = jest.fn();
- expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
- });
+ beforeEach(() => {
+ createComponent(undefined, mountExtended);
+ store.dispatch('receiveLabelsSuccess', mockLabels);
});
- describe('handleCreateLabelClick', () => {
- it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => {
- jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
- jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView');
+ describe('when the "down" key is pressed', () => {
+ it('highlights the item', async () => {
+ expect(findLabelItems().at(0).classes()).not.toContain('is-focused');
- wrapper.vm.handleCreateLabelClick();
+ await findLabelsList().trigger('keydown.down');
- expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
- expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled();
+ expect(findLabelItems().at(0).classes()).toContain('is-focused');
});
});
- describe('handleKeyDown', () => {
- it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentHighlightItem: 1,
- });
+ describe('when the "up" arrow key is pressed', () => {
+ it('un-highlights the item', async () => {
+ await setCurrentHighlightItem(1);
- wrapper.vm.handleKeyDown({
- keyCode: UP_KEY_CODE,
- });
+ expect(findLabelItems().at(1).classes()).toContain('is-focused');
- expect(wrapper.vm.currentHighlightItem).toBe(0);
- });
-
- it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentHighlightItem: 1,
- });
-
- wrapper.vm.handleKeyDown({
- keyCode: DOWN_KEY_CODE,
- });
+ await findLabelsList().trigger('keydown.up');
- expect(wrapper.vm.currentHighlightItem).toBe(2);
+ expect(findLabelItems().at(1).classes()).not.toContain('is-focused');
});
+ });
- it('resets the search text when the Enter key is pressed', () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentHighlightItem: 1,
- searchKey: 'bug',
- });
-
- wrapper.vm.handleKeyDown({
- keyCode: ENTER_KEY_CODE,
- preventDefault: fakePreventDefault,
- });
+ describe('when the "enter" key is pressed', () => {
+ it('resets the search text', async () => {
+ await setCurrentHighlightItem(1);
+ await findSearchBoxByType().vm.$emit('input', 'bug');
+ await findLabelsList().trigger('keydown.enter', { preventDefault: fakePreventDefault });
- expect(wrapper.vm.searchKey).toBe('');
+ expect(findSearchBoxByType().props('value')).toBe('');
expect(fakePreventDefault).toHaveBeenCalled();
});
- it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
- jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentHighlightItem: 2,
- });
-
- wrapper.vm.handleKeyDown({
- keyCode: ENTER_KEY_CODE,
- preventDefault: fakePreventDefault,
- });
-
- expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockLabels[2]]);
- });
-
- it('calls action `toggleDropdownContents` when Esc key is pressed', () => {
- jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentHighlightItem: 1,
- });
+ it('calls action `updateSelectedLabels` with currently highlighted label', async () => {
+ await setCurrentHighlightItem(2);
+ await findLabelsList().trigger('keydown.enter', { preventDefault: fakePreventDefault });
- wrapper.vm.handleKeyDown({
- keyCode: ESC_KEY_CODE,
- });
-
- expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
- });
-
- it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', async () => {
- jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentHighlightItem: 1,
- });
-
- wrapper.vm.handleKeyDown({
- keyCode: DOWN_KEY_CODE,
- });
-
- await nextTick();
- expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled();
+ expect(updateSelectedLabelsMock).toHaveBeenCalledWith(expect.anything(), [mockLabels[2]]);
});
});
- describe('handleLabelClick', () => {
- beforeEach(() => {
- jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
- });
-
- it('calls action `updateSelectedLabels` with provided `label` param', () => {
- wrapper.vm.handleLabelClick(mockRegularLabel);
+ describe('when the "esc" key is pressed', () => {
+ it('calls action `toggleDropdownContents`', async () => {
+ await setCurrentHighlightItem(1);
+ await findLabelsList().trigger('keydown.esc');
- expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
+ expect(toggleDropdownContentsMock).toHaveBeenCalled();
});
- it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
- jest.spyOn(wrapper.vm, 'toggleDropdownContents');
- wrapper.vm.$store.state.allowMultiselect = false;
+ it('scrolls dropdown content into view', async () => {
+ const containerTop = 500;
+ const labelTop = 0;
+
+ jest
+ .spyOn(findDropdownContent().element, 'getBoundingClientRect')
+ .mockReturnValueOnce({ top: containerTop });
- wrapper.vm.handleLabelClick(mockRegularLabel);
+ await setCurrentHighlightItem(1);
+ await findLabelsList().trigger('keydown.esc');
- expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ expect(findDropdownContent().element.scrollTop).toBe(labelTop - containerTop);
});
});
});
describe('template', () => {
+ beforeEach(() => {
+ store.dispatch('receiveLabelsSuccess', mockLabels);
+ });
+
it('renders gl-intersection-observer as component root', () => {
expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(true);
});
it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', async () => {
- wrapper.vm.$store.dispatch('requestLabels');
+ store.dispatch('requestLabels');
await nextTick();
const loadingIconEl = findLoadingIcon();
@@ -329,30 +306,19 @@ describe('DropdownContentsLabelsView', () => {
});
it('renders label elements for all labels', () => {
- expect(wrapper.findAllComponents(LabelItem)).toHaveLength(mockLabels.length);
+ expect(findLabelItems()).toHaveLength(mockLabels.length);
});
it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentHighlightItem: 0,
- });
+ await setCurrentHighlightItem(0);
- await nextTick();
const labelItemEl = findDropdownContent().findComponent(LabelItem);
expect(labelItemEl.attributes('highlight')).toBe('true');
});
it('renders element containing "No matching results" when `searchKey` does not match with any label', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- searchKey: 'abc',
- });
-
- await nextTick();
+ await findSearchBoxByType().vm.$emit('input', 'abc');
const noMatchEl = findDropdownContent().find('li');
expect(noMatchEl.isVisible()).toBe(true);
@@ -360,7 +326,7 @@ describe('DropdownContentsLabelsView', () => {
});
it('renders empty content while loading', async () => {
- wrapper.vm.$store.state.labelsFetchInProgress = true;
+ store.state.labelsFetchInProgress = true;
await nextTick();
const dropdownContent = findDropdownContent();
@@ -384,7 +350,7 @@ describe('DropdownContentsLabelsView', () => {
});
it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', async () => {
- wrapper.vm.$store.state.allowLabelCreate = false;
+ store.state.allowLabelCreate = false;
await nextTick();
const createLabelLink = findDropdownFooter().findAllComponents(GlLink).at(0);
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js
index e9ffda7c251..d74cea2827c 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js
@@ -2,9 +2,13 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
-import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants';
import DropdownContents from '~/sidebar/components/labels/labels_select_vue/dropdown_contents.vue';
import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store';
+import {
+ VARIANT_EMBEDDED,
+ VARIANT_SIDEBAR,
+ VARIANT_STANDALONE,
+} from '~/sidebar/components/labels/labels_select_widget/constants';
import { mockConfig } from './mock_data';
@@ -28,10 +32,6 @@ describe('DropdownContent', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('computed', () => {
describe('dropdownContentsView', () => {
it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => {
@@ -54,10 +54,10 @@ describe('DropdownContent', () => {
describe('when `renderOnTop` is true', () => {
it.each`
- variant | expected
- ${DropdownVariant.Sidebar} | ${'bottom: 3rem'}
- ${DropdownVariant.Standalone} | ${'bottom: 2rem'}
- ${DropdownVariant.Embedded} | ${'bottom: 2rem'}
+ variant | expected
+ ${VARIANT_SIDEBAR} | ${'bottom: 3rem'}
+ ${VARIANT_STANDALONE} | ${'bottom: 2rem'}
+ ${VARIANT_EMBEDDED} | ${'bottom: 2rem'}
`('renders upward for $variant variant', ({ variant, expected }) => {
wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true });
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js
index 6c3fda421ff..367f6007194 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js
@@ -31,10 +31,6 @@ describe('DropdownTitle', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
it('renders component container element with string "Labels"', () => {
expect(wrapper.text()).toContain('Labels');
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js
index 56f25a1c6a4..6684cf0c5f4 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js
@@ -19,15 +19,11 @@ describe('DropdownValueCollapsedComponent', () => {
wrapper = shallowMount(DropdownValueCollapsedComponent, {
propsData: { ...defaultProps, ...props },
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlIcon = () => wrapper.findComponent(GlIcon);
const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip');
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js
index a1ccc9d2ab1..70aafceb00c 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js
@@ -28,10 +28,6 @@ describe('DropdownValue', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('methods', () => {
describe('labelFilterUrl', () => {
it('returns a label filter URL based on provided label param', () => {
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js
index e14c0e308ce..468dd14c9ee 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js
@@ -26,10 +26,6 @@ describe('LabelItem', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
it('renders gl-link component', () => {
expect(wrapper.findComponent(GlLink).exists()).toBe(true);
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js
index a3b10c18374..3add96f2c03 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js
@@ -3,15 +3,18 @@ import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
-import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants';
import DropdownButton from '~/sidebar/components/labels/labels_select_vue/dropdown_button.vue';
import DropdownContents from '~/sidebar/components/labels/labels_select_vue/dropdown_contents.vue';
import DropdownTitle from '~/sidebar/components/labels/labels_select_vue/dropdown_title.vue';
import DropdownValue from '~/sidebar/components/labels/labels_select_vue/dropdown_value.vue';
import DropdownValueCollapsed from '~/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue';
import LabelsSelectRoot from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue';
-
import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store';
+import {
+ VARIANT_EMBEDDED,
+ VARIANT_SIDEBAR,
+ VARIANT_STANDALONE,
+} from '~/sidebar/components/labels/labels_select_widget/constants';
import { mockConfig } from './mock_data';
@@ -40,10 +43,6 @@ describe('LabelsSelectRoot', () => {
store = new Vuex.Store(labelsSelectModule());
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('methods', () => {
describe('handleVuexActionDispatch', () => {
const touchedLabels = [
@@ -173,7 +172,7 @@ describe('LabelsSelectRoot', () => {
});
describe('sets content direction based on viewport', () => {
- describe.each(Object.values(DropdownVariant))(
+ describe.each(Object.values([VARIANT_EMBEDDED, VARIANT_SIDEBAR, VARIANT_STANDALONE]))(
'when labels variant is "%s"',
({ variant }) => {
beforeEach(() => {
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js
index 55651bccaa8..c27afb75375 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js
@@ -1,14 +1,14 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import * as actions from '~/sidebar/components/labels/labels_select_vue/store/actions';
import * as types from '~/sidebar/components/labels/labels_select_vue/store/mutation_types';
import defaultState from '~/sidebar/components/labels/labels_select_vue/store/state';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('LabelsSelect Actions', () => {
let state;
@@ -100,7 +100,7 @@ describe('LabelsSelect Actions', () => {
);
});
- it('shows flash error', () => {
+ it('shows alert error', () => {
actions.receiveLabelsFailure({ commit: () => {} });
expect(createAlert).toHaveBeenCalledWith({ message: 'Error fetching labels.' });
@@ -184,7 +184,7 @@ describe('LabelsSelect Actions', () => {
);
});
- it('shows flash error', () => {
+ it('shows alert error', () => {
actions.receiveCreateLabelFailure({ commit: () => {} });
expect(createAlert).toHaveBeenCalledWith({ message: 'Error creating label.' });
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js
index 79b164b0ea7..9c8d9656955 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js
@@ -4,18 +4,20 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { workspaceLabelsQueries } from '~/sidebar/constants';
import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue';
import createLabelMutation from '~/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql';
+import { DEFAULT_LABEL_COLOR } from '~/sidebar/components/labels/labels_select_widget/constants';
import {
mockRegularLabel,
mockSuggestedColors,
createLabelSuccessfulResponse,
workspaceLabelsQueryResponse,
+ workspaceLabelsQueryEmptyResponse,
} from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const colors = Object.keys(mockSuggestedColors);
@@ -61,14 +63,16 @@ describe('DropdownContentsCreateView', () => {
mutationHandler = createLabelSuccessHandler,
labelCreateType = 'project',
workspaceType = 'project',
+ labelsResponse = workspaceLabelsQueryResponse,
+ searchTerm = '',
} = {}) => {
const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]);
mockApollo.clients.defaultClient.cache.writeQuery({
query: workspaceLabelsQueries[workspaceType].query,
- data: workspaceLabelsQueryResponse.data,
+ data: labelsResponse.data,
variables: {
fullPath: '',
- searchTerm: '',
+ searchTerm,
},
});
@@ -87,10 +91,6 @@ describe('DropdownContentsCreateView', () => {
gon.suggested_label_colors = mockSuggestedColors;
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a palette of 21 colors', () => {
createComponent();
expect(findAllColors()).toHaveLength(21);
@@ -98,17 +98,17 @@ describe('DropdownContentsCreateView', () => {
it('selects a color after clicking on colored block', async () => {
createComponent();
- expect(findSelectedColor().attributes('style')).toBeUndefined();
+ expect(findSelectedColorText().attributes('value')).toBe(DEFAULT_LABEL_COLOR);
findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
await nextTick();
- expect(findSelectedColor().attributes('style')).toBe('background-color: rgb(0, 153, 102);');
+ expect(findSelectedColor().attributes('value')).toBe('#009966');
});
it('shows correct color hex code after selecting a color', async () => {
createComponent();
- expect(findSelectedColorText().attributes('value')).toBe('');
+ expect(findSelectedColorText().attributes('value')).toBe(DEFAULT_LABEL_COLOR);
findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
await nextTick();
@@ -127,6 +127,7 @@ describe('DropdownContentsCreateView', () => {
it('disables a Create button if color is not set', async () => {
createComponent();
findLabelTitleInput().vm.$emit('input', 'Test title');
+ findSelectedColorText().vm.$emit('input', '');
await nextTick();
expect(findCreateButton().props('disabled')).toBe(true);
@@ -236,4 +237,21 @@ describe('DropdownContentsCreateView', () => {
titleTakenError.data.labelCreate.errors[0],
);
});
+
+ describe('when empty labels response', () => {
+ it('is able to create label with searched text when empty response', async () => {
+ createComponent({ searchTerm: '', labelsResponse: workspaceLabelsQueryEmptyResponse });
+
+ findLabelTitleInput().vm.$emit('input', 'random');
+
+ findCreateButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createLabelSuccessHandler).toHaveBeenCalledWith({
+ color: DEFAULT_LABEL_COLOR,
+ projectPath: '',
+ title: 'random',
+ });
+ });
+ });
});
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js
index 913badccbe4..c939856331d 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js
@@ -9,15 +9,15 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { DropdownVariant } from '~/sidebar/components/labels/labels_select_widget/constants';
+import { VARIANT_SIDEBAR } from '~/sidebar/components/labels/labels_select_widget/constants';
import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue';
import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql';
import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue';
import { mockConfig, workspaceLabelsQueryResponse } from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -48,7 +48,7 @@ describe('DropdownContentsLabelsView', () => {
wrapper = shallowMount(DropdownContentsLabelsView, {
apolloProvider: mockApollo,
provide: {
- variant: DropdownVariant.Sidebar,
+ variant: VARIANT_SIDEBAR,
...injected,
},
propsData: {
@@ -64,10 +64,6 @@ describe('DropdownContentsLabelsView', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findLabels = () => wrapper.findAllComponents(LabelItem);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findObserver = () => wrapper.findComponent(GlIntersectionObserver);
@@ -100,11 +96,11 @@ describe('DropdownContentsLabelsView', () => {
await waitForPromises();
});
- it('does not render loading icon', async () => {
+ it('does not render loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
- it('renders labels list', async () => {
+ it('renders labels list', () => {
expect(findLabelsList().exists()).toBe(true);
expect(findLabels()).toHaveLength(2);
});
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js
index 9bbb1413ee9..3abd87a69d6 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js
@@ -1,6 +1,9 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
-import { DropdownVariant } from '~/sidebar/components/labels/labels_select_widget/constants';
+import {
+ VARIANT_EMBEDDED,
+ VARIANT_STANDALONE,
+} from '~/sidebar/components/labels/labels_select_widget/constants';
import DropdownContents from '~/sidebar/components/labels/labels_select_widget/dropdown_contents.vue';
import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue';
@@ -66,10 +69,6 @@ describe('DropdownContent', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView);
const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView);
const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub);
@@ -93,7 +92,7 @@ describe('DropdownContent', () => {
});
it('emits `setLabels` event on dropdown hide if labels changed on non-sidebar widget', async () => {
- createComponent({ props: { variant: DropdownVariant.Standalone } });
+ createComponent({ props: { variant: VARIANT_STANDALONE } });
const updatedLabel = {
id: 28,
title: 'Bug',
@@ -109,7 +108,7 @@ describe('DropdownContent', () => {
});
it('emits `setLabels` event on visibility change if labels changed on sidebar widget', async () => {
- createComponent({ props: { variant: DropdownVariant.Standalone, isVisible: true } });
+ createComponent({ props: { variant: VARIANT_STANDALONE, isVisible: true } });
const updatedLabel = {
id: 28,
title: 'Bug',
@@ -177,6 +176,21 @@ describe('DropdownContent', () => {
expect(findCreateView().exists()).toBe(false);
expect(findLabelsView().exists()).toBe(true);
});
+
+ it('selects created labels', async () => {
+ const createdLabel = {
+ id: 29,
+ title: 'new label',
+ description: null,
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ };
+
+ findCreateView().vm.$emit('labelCreated', createdLabel);
+ await nextTick();
+
+ expect(findLabelsView().props('localSelectedLabels')).toContain(createdLabel);
+ });
});
describe('Labels view', () => {
@@ -193,13 +207,13 @@ describe('DropdownContent', () => {
});
it('does not render footer on standalone dropdown', () => {
- createComponent({ props: { variant: DropdownVariant.Standalone } });
+ createComponent({ props: { variant: VARIANT_STANDALONE } });
expect(findDropdownFooter().exists()).toBe(false);
});
it('renders footer on embedded dropdown', () => {
- createComponent({ props: { variant: DropdownVariant.Embedded } });
+ createComponent({ props: { variant: VARIANT_EMBEDDED } });
expect(findDropdownFooter().exists()).toBe(true);
});
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js
index 9a6e0ca3ccd..ad1edaa6671 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js
@@ -20,10 +20,6 @@ describe('DropdownFooter', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]');
describe('Labels view', () => {
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js
index d9001dface4..4861d2ca55e 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js
@@ -28,10 +28,6 @@ describe('DropdownHeader', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findGoBackButton = () => wrapper.findByTestId('go-back-button');
const findDropdownTitle = () => wrapper.findByTestId('dropdown-header-title');
@@ -49,7 +45,7 @@ describe('DropdownHeader', () => {
expect(findGoBackButton().exists()).toBe(true);
});
- it('does not render search input field', async () => {
+ it('does not render search input field', () => {
expect(findSearchInput().exists()).toBe(false);
});
});
@@ -85,7 +81,7 @@ describe('DropdownHeader', () => {
expect(findSearchInput().exists()).toBe(true);
});
- it('does not render title', async () => {
+ it('does not render title', () => {
expect(findDropdownTitle().exists()).toBe(false);
});
});
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js
index 585048983c9..d70b989b493 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js
@@ -30,10 +30,6 @@ describe('DropdownValue', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when there are no labels', () => {
beforeEach(() => {
createComponent(
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js
index 4fa65c752f9..715dd4e034e 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js
@@ -30,10 +30,6 @@ describe('EmbeddedLabelsList', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when there are no labels', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js
index 74188a77994..377d1894411 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js
@@ -19,10 +19,6 @@ describe('LabelItem', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
it('renders label color element', () => {
const colorEl = wrapper.find('[data-testid="label-color-box"]');
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js
index fd8e72bac49..b0a080ba1ef 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js
@@ -3,8 +3,8 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
-import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
+import { createAlert } from '~/alert';
+import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_TEST_CASE } from '~/issues/constants';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import DropdownContents from '~/sidebar/components/labels/labels_select_widget/dropdown_contents.vue';
import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue';
@@ -25,7 +25,7 @@ import {
mockRegularLabel,
} from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -36,9 +36,9 @@ const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a proble
const updateLabelsMutation = {
[TYPE_ISSUE]: updateIssueLabelsMutation,
- [IssuableType.MergeRequest]: updateMergeRequestLabelsMutation,
+ [TYPE_MERGE_REQUEST]: updateMergeRequestLabelsMutation,
[TYPE_EPIC]: updateEpicLabelsMutation,
- [IssuableType.TestCase]: updateTestCaseLabelsMutation,
+ [TYPE_TEST_CASE]: updateTestCaseLabelsMutation,
};
describe('LabelsSelectRoot', () => {
@@ -83,10 +83,6 @@ describe('LabelsSelectRoot', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders component with classes `labels-select-wrapper gl-relative`', () => {
createComponent();
expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'gl-relative']);
@@ -150,7 +146,7 @@ describe('LabelsSelectRoot', () => {
});
});
- it('creates flash with error message when query is rejected', async () => {
+ it('creates alert with error message when query is rejected', async () => {
createComponent({ queryHandler: errorQueryHandler });
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({ message: 'Error fetching labels.' });
@@ -203,7 +199,7 @@ describe('LabelsSelectRoot', () => {
});
});
- it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if iid is not set', async () => {
+ it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if iid is not set', () => {
const label = { id: 'gid://gitlab/ProjectLabel/1' };
createComponent({ config: { ...mockConfig, iid: undefined } });
@@ -214,9 +210,9 @@ describe('LabelsSelectRoot', () => {
describe.each`
issuableType
${TYPE_ISSUE}
- ${IssuableType.MergeRequest}
+ ${TYPE_MERGE_REQUEST}
${TYPE_EPIC}
- ${IssuableType.TestCase}
+ ${TYPE_TEST_CASE}
`('when updating labels for $issuableType', ({ issuableType }) => {
const label = { id: 'gid://gitlab/ProjectLabel/2' };
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js
index 5d5a7e9a200..b0b473625bb 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js
@@ -117,6 +117,17 @@ export const workspaceLabelsQueryResponse = {
},
};
+export const workspaceLabelsQueryEmptyResponse = {
+ data: {
+ workspace: {
+ id: 'gid://gitlab/Project/126',
+ labels: {
+ nodes: [],
+ },
+ },
+ },
+};
+
export const issuableLabelsQueryResponse = {
data: {
workspace: {
diff --git a/spec/frontend/sidebar/components/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/components/lock/edit_form_buttons_spec.js
index 2abb0c24d7d..2c256a67bb0 100644
--- a/spec/frontend/sidebar/components/lock/edit_form_buttons_spec.js
+++ b/spec/frontend/sidebar/components/lock/edit_form_buttons_spec.js
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { createStore as createMrStore } from '~/mr_notes/stores';
import createStore from '~/notes/stores';
import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue';
@@ -8,7 +8,7 @@ import eventHub from '~/sidebar/event_hub';
import { ISSUABLE_TYPE_ISSUE, ISSUABLE_TYPE_MR } from './constants';
jest.mock('~/sidebar/event_hub', () => ({ $emit: jest.fn() }));
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('EditFormButtons', () => {
let wrapper;
@@ -51,11 +51,6 @@ describe('EditFormButtons', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe.each`
pageType
${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR}
@@ -74,7 +69,7 @@ describe('EditFormButtons', () => {
});
it('disables the toggle button', () => {
- expect(findLockToggle().attributes('disabled')).toBe('disabled');
+ expect(findLockToggle().attributes('disabled')).toBeDefined();
});
it('sets loading on the toggle button', () => {
@@ -128,7 +123,7 @@ describe('EditFormButtons', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('closeLockForm');
});
- it('does not flash an error message', () => {
+ it('does not alert an error message', () => {
expect(createAlert).not.toHaveBeenCalled();
});
});
@@ -161,7 +156,7 @@ describe('EditFormButtons', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('closeLockForm');
});
- it('calls flash with the correct message', () => {
+ it('calls alert with the correct message', () => {
expect(createAlert).toHaveBeenCalledWith({
message: `Something went wrong trying to change the locked state of this ${issuableDisplayName}`,
});
diff --git a/spec/frontend/sidebar/components/lock/edit_form_spec.js b/spec/frontend/sidebar/components/lock/edit_form_spec.js
index 4ae9025ee39..06cce7bd7ca 100644
--- a/spec/frontend/sidebar/components/lock/edit_form_spec.js
+++ b/spec/frontend/sidebar/components/lock/edit_form_spec.js
@@ -24,11 +24,6 @@ describe('Edit Form Dropdown', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe.each`
pageType
${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR}
diff --git a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js
index 8f825847cfc..5e766e9a41c 100644
--- a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js
+++ b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js
@@ -29,6 +29,7 @@ describe('IssuableLockForm', () => {
const findEditForm = () => wrapper.findComponent(EditForm);
const findSidebarLockStatusTooltip = () =>
getBinding(findSidebarCollapseIcon().element, 'gl-tooltip');
+ const findIssuableLockClickable = () => wrapper.find('[data-testid="issuable-lock"]');
const initStore = (isLocked) => {
if (issuableType === ISSUABLE_TYPE_ISSUE) {
@@ -48,7 +49,7 @@ describe('IssuableLockForm', () => {
store.getters.getNoteableData.discussion_locked = isLocked;
};
- const createComponent = ({ props = {} }, movedMrSidebar = false) => {
+ const createComponent = ({ props = {}, movedMrSidebar = false }) => {
wrapper = shallowMount(IssuableLockForm, {
store,
provide: {
@@ -62,16 +63,11 @@ describe('IssuableLockForm', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe.each`
pageType
${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR}
@@ -174,11 +170,27 @@ describe('IssuableLockForm', () => {
`('displays $message when merge request is $locked', async ({ locked, message }) => {
initStore(locked);
- createComponent({}, true);
+ createComponent({ movedMrSidebar: true });
await wrapper.find('.dropdown-item').trigger('click');
expect(toast).toHaveBeenCalledWith(message);
});
});
+
+ describe('moved_mr_sidebar flag', () => {
+ describe('when the flag is off', () => {
+ it('does not show the non editable lock status', () => {
+ createComponent({ movedMrSidebar: false });
+ expect(findIssuableLockClickable().exists()).toBe(false);
+ });
+ });
+
+ describe('when the flag is on', () => {
+ it('does not show the non editable lock status', () => {
+ createComponent({ movedMrSidebar: true });
+ expect(findIssuableLockClickable().exists()).toBe(true);
+ });
+ });
+ });
});
diff --git a/spec/frontend/sidebar/components/milestone/milestone_dropdown_spec.js b/spec/frontend/sidebar/components/milestone/milestone_dropdown_spec.js
index b492753867b..8a0db1715f3 100644
--- a/spec/frontend/sidebar/components/milestone/milestone_dropdown_spec.js
+++ b/spec/frontend/sidebar/components/milestone/milestone_dropdown_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_PROJECT } from '~/issues/constants';
import { __ } from '~/locale';
import MilestoneDropdown from '~/sidebar/components/milestone/milestone_dropdown.vue';
import SidebarDropdown from '~/sidebar/components/sidebar_dropdown.vue';
@@ -12,7 +12,7 @@ describe('MilestoneDropdown component', () => {
const propsData = {
attrWorkspacePath: 'full/path',
issuableType: TYPE_ISSUE,
- workspaceType: WorkspaceType.project,
+ workspaceType: WORKSPACE_PROJECT,
};
const findHiddenInput = () => wrapper.find('input');
diff --git a/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js b/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js
index 72279f44e80..56c915c4cae 100644
--- a/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js
+++ b/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import {
GlIcon,
GlLoadingIcon,
@@ -7,12 +8,13 @@ import {
GlSearchBoxByType,
GlButton,
} from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
-
-import { nextTick } from 'vue';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import IssuableMoveDropdown from '~/sidebar/components/move/issuable_move_dropdown.vue';
+import { stubComponent } from 'helpers/stub_component';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
const mockProjects = [
{
@@ -45,60 +47,82 @@ const mockEvent = {
preventDefault: jest.fn(),
};
+const focusInputMock = jest.fn();
+const hideMock = jest.fn();
+
describe('IssuableMoveDropdown', () => {
let mock;
let wrapper;
const createComponent = (propsData = mockProps) => {
- wrapper = shallowMount(IssuableMoveDropdown, {
+ wrapper = shallowMountExtended(IssuableMoveDropdown, {
propsData,
+ stubs: {
+ GlDropdown: stubComponent(GlDropdown, {
+ methods: {
+ hide: hideMock,
+ },
+ }),
+ GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
+ methods: {
+ focusInput: focusInputMock,
+ },
+ }),
+ },
});
- wrapper.vm.$refs.dropdown.hide = jest.fn();
- wrapper.vm.$refs.searchInput.focusInput = jest.fn();
};
beforeEach(() => {
mock = new MockAdapter(axios);
+ mock.onGet(mockProps.projectsFetchPath).reply(HTTP_STATUS_OK, mockProjects);
+
createComponent();
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
+ const findCollapsedEl = () => wrapper.findByTestId('move-collapsed');
+ const findFooter = () => wrapper.findByTestId('footer');
+ const findHeader = () => wrapper.findByTestId('header');
+ const findFailedLoadResults = () => wrapper.findByTestId('failed-load-results');
+ const findDropdownContent = () => wrapper.findByTestId('content');
+ const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findDropdownEl = () => wrapper.findComponent(GlDropdown);
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+
describe('watch', () => {
describe('searchKey', () => {
it('calls `fetchProjects` with value of the prop', async () => {
- jest.spyOn(wrapper.vm, 'fetchProjects');
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- searchKey: 'foo',
- });
+ jest.spyOn(axios, 'get');
+ findSearchBox().vm.$emit('input', 'foo');
- await nextTick();
+ await waitForPromises();
- expect(wrapper.vm.fetchProjects).toHaveBeenCalledWith('foo');
+ expect(axios.get).toHaveBeenCalledWith('/-/autocomplete/projects?project_id=1', {
+ params: { search: 'foo' },
+ });
});
});
});
describe('methods', () => {
describe('fetchProjects', () => {
- it('sets projectsListLoading to true and projectsListLoadFailed to false', () => {
- wrapper.vm.fetchProjects();
+ it('sets projectsListLoading to true and projectsListLoadFailed to false', async () => {
+ findDropdownEl().vm.$emit('shown');
+ await nextTick();
- expect(wrapper.vm.projectsListLoading).toBe(true);
- expect(wrapper.vm.projectsListLoadFailed).toBe(false);
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findFailedLoadResults().exists()).toBe(false);
});
- it('calls `axios.get` with `projectsFetchPath` and query param `search`', () => {
- jest.spyOn(axios, 'get').mockResolvedValue({
- data: mockProjects,
- });
+ it('calls `axios.get` with `projectsFetchPath` and query param `search`', async () => {
+ jest.spyOn(axios, 'get');
- wrapper.vm.fetchProjects('foo');
+ findSearchBox().vm.$emit('input', 'foo');
+ await waitForPromises();
expect(axios.get).toHaveBeenCalledWith(
mockProps.projectsFetchPath,
@@ -111,74 +135,65 @@ describe('IssuableMoveDropdown', () => {
});
it('sets response to `projects` and focuses on searchInput when request is successful', async () => {
- jest.spyOn(axios, 'get').mockResolvedValue({
- data: mockProjects,
- });
+ jest.spyOn(axios, 'get');
- await wrapper.vm.fetchProjects('foo');
+ findSearchBox().vm.$emit('input', 'foo');
+ await waitForPromises();
- expect(wrapper.vm.projects).toBe(mockProjects);
- expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
+ expect(findAllDropdownItems()).toHaveLength(mockProjects.length);
+ expect(focusInputMock).toHaveBeenCalled();
});
it('sets projectsListLoadFailed to true when request fails', async () => {
jest.spyOn(axios, 'get').mockRejectedValue({});
- await wrapper.vm.fetchProjects('foo');
+ findSearchBox().vm.$emit('input', 'foo');
+ await waitForPromises();
- expect(wrapper.vm.projectsListLoadFailed).toBe(true);
+ expect(findFailedLoadResults().exists()).toBe(true);
});
it('sets projectsListLoading to false when request completes', async () => {
- jest.spyOn(axios, 'get').mockResolvedValue({
- data: mockProjects,
- });
+ jest.spyOn(axios, 'get');
- await wrapper.vm.fetchProjects('foo');
+ findDropdownEl().vm.$emit('shown');
+ await waitForPromises();
- expect(wrapper.vm.projectsListLoading).toBe(false);
+ expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('isSelectedProject', () => {
it.each`
- project | selectedProject | title | returnValue
- ${mockProjects[0]} | ${mockProjects[0]} | ${'are same projects'} | ${true}
- ${mockProjects[0]} | ${mockProjects[1]} | ${'are different projects'} | ${false}
+ projectIndex | selectedProjectIndex | title | returnValue
+ ${0} | ${0} | ${'are same projects'} | ${true}
+ ${0} | ${1} | ${'are different projects'} | ${false}
`(
'returns $returnValue when selectedProject and provided project param $title',
- async ({ project, selectedProject, returnValue }) => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- selectedProject,
- });
+ async ({ projectIndex, selectedProjectIndex, returnValue }) => {
+ findDropdownEl().vm.$emit('shown');
+ await waitForPromises();
+
+ findAllDropdownItems().at(selectedProjectIndex).vm.$emit('click', mockEvent);
await nextTick();
- expect(wrapper.vm.isSelectedProject(project)).toBe(returnValue);
+ expect(findAllDropdownItems().at(projectIndex).props('isChecked')).toBe(returnValue);
},
);
it('returns false when selectedProject is null', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- selectedProject: null,
- });
-
- await nextTick();
+ findDropdownEl().vm.$emit('shown');
+ await waitForPromises();
- expect(wrapper.vm.isSelectedProject(mockProjects[0])).toBe(false);
+ expect(findAllDropdownItems().at(0).props('isChecked')).toBe(false);
});
});
});
describe('template', () => {
- const findDropdownEl = () => wrapper.findComponent(GlDropdown);
-
it('renders collapsed state element with icon', () => {
- const collapsedEl = wrapper.find('[data-testid="move-collapsed"]');
+ const collapsedEl = findCollapsedEl();
expect(collapsedEl.exists()).toBe(true);
expect(collapsedEl.attributes('title')).toBe(mockProps.dropdownButtonTitle);
@@ -198,12 +213,11 @@ describe('IssuableMoveDropdown', () => {
it('renders disabled dropdown when `disabled` is true', () => {
createComponent({ ...mockProps, disabled: true });
-
- expect(findDropdownEl().attributes('disabled')).toBe('true');
+ expect(findDropdownEl().props('disabled')).toBe(true);
});
it('renders header element', () => {
- const headerEl = findDropdownEl().find('[data-testid="header"]');
+ const headerEl = findHeader();
expect(headerEl.exists()).toBe(true);
expect(headerEl.find('span').text()).toBe(mockProps.dropdownHeaderTitle);
@@ -221,108 +235,71 @@ describe('IssuableMoveDropdown', () => {
});
it('renders gl-loading-icon component when projectsListLoading prop is true', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- projectsListLoading: true,
- });
-
+ findDropdownEl().vm.$emit('shown');
await nextTick();
- expect(findDropdownEl().findComponent(GlLoadingIcon).exists()).toBe(true);
+ expect(findLoadingIcon().exists()).toBe(true);
});
it('renders gl-dropdown-item components for available projects', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- projects: mockProjects,
- selectedProject: mockProjects[0],
- });
+ findDropdownEl().vm.$emit('shown');
+ await waitForPromises();
+ findAllDropdownItems().at(0).vm.$emit('click', mockEvent);
await nextTick();
- const dropdownItems = wrapper.findAllComponents(GlDropdownItem);
-
- expect(dropdownItems).toHaveLength(mockProjects.length);
- expect(dropdownItems.at(0).props()).toMatchObject({
+ expect(findAllDropdownItems()).toHaveLength(mockProjects.length);
+ expect(findAllDropdownItems().at(0).props()).toMatchObject({
isCheckItem: true,
isChecked: true,
});
- expect(dropdownItems.at(0).text()).toBe(mockProjects[0].name_with_namespace);
+ expect(findAllDropdownItems().at(0).text()).toBe(mockProjects[0].name_with_namespace);
});
it('renders string "No matching results" when search does not yield any matches', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- searchKey: 'foo',
- });
-
- // Wait for `searchKey` watcher to run.
- await nextTick();
+ mock.onGet(mockProps.projectsFetchPath).reply(HTTP_STATUS_OK, []);
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- projects: [],
- projectsListLoading: false,
- });
-
- await nextTick();
+ findSearchBox().vm.$emit('input', 'foo');
+ await waitForPromises();
- const dropdownContentEl = wrapper.find('[data-testid="content"]');
-
- expect(dropdownContentEl.text()).toContain('No matching results');
+ expect(findDropdownContent().text()).toContain('No matching results');
});
it('renders string "Failed to load projects" when loading projects list fails', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- projects: [],
- projectsListLoading: false,
- projectsListLoadFailed: true,
- });
-
- await nextTick();
+ mock.onGet(mockProps.projectsFetchPath).reply(HTTP_STATUS_OK, []);
+ jest.spyOn(axios, 'get').mockRejectedValue({});
- const dropdownContentEl = wrapper.find('[data-testid="content"]');
+ findDropdownEl().vm.$emit('shown');
+ await waitForPromises();
- expect(dropdownContentEl.text()).toContain('Failed to load projects');
+ expect(findDropdownContent().text()).toContain('Failed to load projects');
});
it('renders gl-button within footer', async () => {
- const moveButtonEl = wrapper.find('[data-testid="footer"]').findComponent(GlButton);
+ const moveButtonEl = findFooter().findComponent(GlButton);
expect(moveButtonEl.text()).toBe('Move');
- expect(moveButtonEl.attributes('disabled')).toBe('true');
+ expect(moveButtonEl.attributes('disabled')).toBeDefined();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- selectedProject: mockProjects[0],
- });
+ findDropdownEl().vm.$emit('shown');
+ await waitForPromises();
+ findAllDropdownItems().at(0).vm.$emit('click', mockEvent);
await nextTick();
- expect(
- wrapper.find('[data-testid="footer"]').findComponent(GlButton).attributes('disabled'),
- ).not.toBeDefined();
+ expect(findFooter().findComponent(GlButton).attributes('disabled')).not.toBeDefined();
});
});
describe('events', () => {
it('collapsed state element emits `toggle-collapse` event on component when clicked', () => {
- wrapper.find('[data-testid="move-collapsed"]').trigger('click');
+ findCollapsedEl().trigger('click');
expect(wrapper.emitted('toggle-collapse')).toHaveLength(1);
});
it('gl-dropdown component calls `fetchProjects` on `shown` event', () => {
- jest.spyOn(axios, 'get').mockResolvedValue({
- data: mockProjects,
- });
+ jest.spyOn(axios, 'get');
findDropdownEl().vm.$emit('shown');
@@ -330,56 +307,50 @@ describe('IssuableMoveDropdown', () => {
});
it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- projectItemClick: true,
- });
+ findDropdownEl().vm.$emit('shown');
+ await waitForPromises();
+
+ findAllDropdownItems().at(0).vm.$emit('click', mockEvent);
+ await nextTick();
findDropdownEl().vm.$emit('hide', mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
- expect(wrapper.vm.projectItemClick).toBe(false);
});
- it('gl-dropdown component emits `dropdown-close` event on component from `hide` event', async () => {
+ it('gl-dropdown component emits `dropdown-close` event on component from `hide` event', () => {
findDropdownEl().vm.$emit('hide');
expect(wrapper.emitted('dropdown-close')).toHaveLength(1);
});
- it('close icon in dropdown header closes the dropdown when clicked', () => {
- wrapper.find('[data-testid="header"]').findComponent(GlButton).vm.$emit('click', mockEvent);
+ it('close icon in dropdown header closes the dropdown when clicked', async () => {
+ findHeader().findComponent(GlButton).vm.$emit('click', mockEvent);
- expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled();
+ await nextTick();
+ expect(hideMock).toHaveBeenCalled();
});
it('sets project for clicked gl-dropdown-item to selectedProject', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- projects: mockProjects,
- });
+ findDropdownEl().vm.$emit('shown');
+ await waitForPromises();
+ findAllDropdownItems().at(0).vm.$emit('click', mockEvent);
await nextTick();
- wrapper.findAllComponents(GlDropdownItem).at(0).vm.$emit('click', mockEvent);
-
- expect(wrapper.vm.selectedProject).toBe(mockProjects[0]);
+ expect(findAllDropdownItems().at(0).props('isChecked')).toBe(true);
});
it('hides dropdown and emits `move-issuable` event when move button is clicked', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- selectedProject: mockProjects[0],
- });
+ findDropdownEl().vm.$emit('shown');
+ await waitForPromises();
+ findAllDropdownItems().at(0).vm.$emit('click', mockEvent);
await nextTick();
- wrapper.find('[data-testid="footer"]').findComponent(GlButton).vm.$emit('click');
+ findFooter().findComponent(GlButton).vm.$emit('click');
- expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled();
+ expect(hideMock).toHaveBeenCalled();
expect(wrapper.emitted('move-issuable')).toHaveLength(1);
expect(wrapper.emitted('move-issuable')[0]).toEqual([mockProjects[0]]);
});
diff --git a/spec/frontend/sidebar/components/move/move_issue_button_spec.js b/spec/frontend/sidebar/components/move/move_issue_button_spec.js
index acd6b23c1f5..e2f5414056a 100644
--- a/spec/frontend/sidebar/components/move/move_issue_button_spec.js
+++ b/spec/frontend/sidebar/components/move/move_issue_button_spec.js
@@ -4,14 +4,14 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { visitUrl } from '~/lib/utils/url_utility';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import ProjectSelect from '~/sidebar/components/move/issuable_move_dropdown.vue';
import MoveIssueButton from '~/sidebar/components/move/move_issue_button.vue';
import moveIssueMutation from '~/sidebar/queries/move_issue.mutation.graphql';
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
}));
@@ -71,10 +71,6 @@ describe('MoveIssueButton', () => {
});
};
- afterEach(() => {
- fakeApollo = null;
- });
-
it('renders the project select dropdown', () => {
createComponent();
@@ -118,7 +114,7 @@ describe('MoveIssueButton', () => {
expect(findProjectSelect().props('moveInProgress')).toBe(false);
});
- it('creates a flash and logs errors when a mutation returns errors', async () => {
+ it('creates an alert and logs errors when a mutation returns errors', async () => {
createComponent(resolvedMutationWithErrorsMock);
emitProjectSelectEvent();
diff --git a/spec/frontend/sidebar/components/move/move_issues_button_spec.js b/spec/frontend/sidebar/components/move/move_issues_button_spec.js
index c65bad642a0..83b32d04fcf 100644
--- a/spec/frontend/sidebar/components/move/move_issues_button_spec.js
+++ b/spec/frontend/sidebar/components/move/move_issues_button_spec.js
@@ -6,7 +6,7 @@ import { GlAlert } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { logError } from '~/lib/logger';
import IssuableMoveDropdown from '~/sidebar/components/move/issuable_move_dropdown.vue';
import issuableEventHub from '~/issues/list/eventhub';
@@ -22,7 +22,7 @@ import {
WORK_ITEM_TYPE_ENUM_TEST_CASE,
} from '~/work_items/constants';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/logger');
useMockLocationHelper();
@@ -159,7 +159,6 @@ describe('MoveIssuesButton', () => {
});
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -167,7 +166,7 @@ describe('MoveIssuesButton', () => {
it('renders disabled by default', () => {
createComponent();
expect(findDropdown().exists()).toBe(true);
- expect(findDropdown().attributes('disabled')).toBe('true');
+ expect(findDropdown().attributes('disabled')).toBeDefined();
});
it.each`
@@ -186,7 +185,7 @@ describe('MoveIssuesButton', () => {
await nextTick();
if (disabled) {
- expect(findDropdown().attributes('disabled')).toBe('true');
+ expect(findDropdown().attributes('disabled')).toBeDefined();
} else {
expect(findDropdown().attributes('disabled')).toBeUndefined();
}
@@ -347,7 +346,7 @@ describe('MoveIssuesButton', () => {
expect(issuableEventHub.$emit).not.toHaveBeenCalled();
});
- it('emits `issuables:bulkMoveStarted` when issues are moving', async () => {
+ it('emits `issuables:bulkMoveStarted` when issues are moving', () => {
createComponent({ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases });
emitMoveIssuablesEvent();
@@ -389,7 +388,7 @@ describe('MoveIssuesButton', () => {
});
describe('shows errors', () => {
- it('does not create flashes or logs errors when no issue is selected', async () => {
+ it('does not create alerts or logs errors when no issue is selected', async () => {
createComponent();
emitMoveIssuablesEvent();
@@ -399,7 +398,7 @@ describe('MoveIssuesButton', () => {
expect(createAlert).not.toHaveBeenCalled();
});
- it('does not create flashes or logs errors when only tasks are selected', async () => {
+ it('does not create alerts or logs errors when only tasks are selected', async () => {
createComponent({ selectedIssuables: selectedIssuesMocks.tasksOnly });
emitMoveIssuablesEvent();
@@ -409,7 +408,7 @@ describe('MoveIssuesButton', () => {
expect(createAlert).not.toHaveBeenCalled();
});
- it('does not create flashes or logs errors when only test cases are selected', async () => {
+ it('does not create alerts or logs errors when only test cases are selected', async () => {
createComponent({ selectedIssuables: selectedIssuesMocks.testCasesOnly });
emitMoveIssuablesEvent();
@@ -419,7 +418,7 @@ describe('MoveIssuesButton', () => {
expect(createAlert).not.toHaveBeenCalled();
});
- it('does not create flashes or logs errors when only tasks and test cases are selected', async () => {
+ it('does not create alerts or logs errors when only tasks and test cases are selected', async () => {
createComponent({ selectedIssuables: selectedIssuesMocks.tasksAndTestCases });
emitMoveIssuablesEvent();
@@ -429,7 +428,7 @@ describe('MoveIssuesButton', () => {
expect(createAlert).not.toHaveBeenCalled();
});
- it('does not create flashes or logs errors when issues are moved without errors', async () => {
+ it('does not create alerts or logs errors when issues are moved without errors', async () => {
createComponent(
{ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases },
resolvedMutationWithoutErrorsMock,
@@ -442,7 +441,7 @@ describe('MoveIssuesButton', () => {
expect(createAlert).not.toHaveBeenCalled();
});
- it('creates a flash and logs errors when a mutation returns errors', async () => {
+ it('creates an alert and logs errors when a mutation returns errors', async () => {
createComponent(
{ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases },
resolvedMutationWithErrorsMock,
@@ -462,14 +461,14 @@ describe('MoveIssuesButton', () => {
`Error moving issue. Error message: ${mockMutationErrorMessage}`,
);
- // Only one flash is created even if multiple errors are reported
+ // Only one alert is created even if multiple errors are reported
expect(createAlert).toHaveBeenCalledTimes(1);
expect(createAlert).toHaveBeenCalledWith({
message: 'There was an error while moving the issues.',
});
});
- it('creates a flash but not logs errors when a mutation is rejected', async () => {
+ it('creates an alert but not logs errors when a mutation is rejected', async () => {
createComponent({ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases });
emitMoveIssuablesEvent();
diff --git a/spec/frontend/sidebar/components/participants/participants_spec.js b/spec/frontend/sidebar/components/participants/participants_spec.js
index f7a626a189c..72d83ebeca4 100644
--- a/spec/frontend/sidebar/components/participants/participants_spec.js
+++ b/spec/frontend/sidebar/components/participants/participants_spec.js
@@ -1,203 +1,114 @@
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import Participants from '~/sidebar/components/participants/participants.vue';
-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('Participants', () => {
+describe('Participants component', () => {
let wrapper;
- const getMoreParticipantsButton = () => wrapper.find('[data-testid="more-participants"]');
- const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]');
+ const participant = {
+ id: 1,
+ state: 'active',
+ username: 'marcene',
+ name: 'Allie Will',
+ web_url: 'foo.com',
+ avatar_url: 'gravatar.com/avatar/xxx',
+ };
- const mountComponent = (propsData) =>
- shallowMount(Participants, {
- propsData,
- });
+ const participants = [participant, { ...participant, id: 2 }, { ...participant, id: 3 }];
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findMoreParticipantsButton = () => wrapper.findComponent(GlButton);
+ const findCollapsedIcon = () => wrapper.find('.sidebar-collapsed-icon');
+ const findParticipantsAuthor = () => wrapper.findAll('.participants-author');
+
+ const mountComponent = (propsData) => shallowMount(Participants, { propsData });
describe('collapsed sidebar state', () => {
it('shows loading spinner when loading', () => {
- wrapper = mountComponent({
- loading: true,
- });
+ wrapper = mountComponent({ loading: true });
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ expect(findLoadingIcon().exists()).toBe(true);
});
- it('does not show loading spinner not loading', () => {
- wrapper = mountComponent({
- loading: false,
- });
+ it('does not show loading spinner when not loading', () => {
+ wrapper = mountComponent({ loading: false });
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
+ expect(findLoadingIcon().exists()).toBe(false);
});
it('shows participant count when given', () => {
- wrapper = mountComponent({
- loading: false,
- participants: PARTICIPANT_LIST,
- });
+ wrapper = mountComponent({ participants });
- expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`);
+ expect(findCollapsedIcon().text()).toBe(participants.length.toString());
});
it('shows full participant count when there are hidden participants', () => {
- wrapper = mountComponent({
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 1,
- });
+ wrapper = mountComponent({ participants, numberOfLessParticipants: 1 });
- expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`);
+ expect(findCollapsedIcon().text()).toBe(participants.length.toString());
});
});
describe('expanded sidebar state', () => {
it('shows loading spinner when loading', () => {
- wrapper = mountComponent({
- loading: true,
- });
+ wrapper = mountComponent({ loading: true });
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ expect(findLoadingIcon().exists()).toBe(true);
});
- it('when only showing visible participants, shows an avatar only for each participant under the limit', async () => {
+ it('when only showing visible participants, shows an avatar only for each participant under the limit', () => {
const numberOfLessParticipants = 2;
- wrapper = mountComponent({
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants,
- });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- isShowingMoreParticipants: false,
- });
-
- await nextTick();
- expect(wrapper.findAll('.participants-author')).toHaveLength(numberOfLessParticipants);
+ wrapper = mountComponent({ participants, numberOfLessParticipants });
+
+ expect(findParticipantsAuthor()).toHaveLength(numberOfLessParticipants);
});
it('when only showing all participants, each has an avatar', async () => {
- wrapper = mountComponent({
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 2,
- });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- isShowingMoreParticipants: true,
- });
-
- await nextTick();
- expect(wrapper.findAll('.participants-author')).toHaveLength(PARTICIPANT_LIST.length);
+ wrapper = mountComponent({ participants, numberOfLessParticipants: 2 });
+
+ await findMoreParticipantsButton().vm.$emit('click');
+
+ expect(findParticipantsAuthor()).toHaveLength(participants.length);
});
it('does not have more participants link when they can all be shown', () => {
const numberOfLessParticipants = 100;
- wrapper = mountComponent({
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants,
- });
-
- expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants);
- expect(getMoreParticipantsButton().exists()).toBe(false);
- });
+ wrapper = mountComponent({ participants, numberOfLessParticipants });
- it('when too many participants, has more participants link to show more', async () => {
- wrapper = mountComponent({
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 2,
- });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- isShowingMoreParticipants: false,
- });
-
- await nextTick();
- expect(getMoreParticipantsButton().text()).toBe('+ 1 more');
+ expect(participants.length).toBeLessThan(numberOfLessParticipants);
+ expect(findMoreParticipantsButton().exists()).toBe(false);
});
- it('when too many participants and already showing them, has more participants link to show less', async () => {
- wrapper = mountComponent({
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 2,
- });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- isShowingMoreParticipants: true,
- });
-
- await nextTick();
- expect(getMoreParticipantsButton().text()).toBe('- show less');
- });
+ it('when too many participants, has more participants link to show more', () => {
+ wrapper = mountComponent({ participants, numberOfLessParticipants: 2 });
- it('clicking more participants link emits event', () => {
- wrapper = mountComponent({
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 2,
- });
+ expect(findMoreParticipantsButton().text()).toBe('+ 1 more');
+ });
- expect(wrapper.vm.isShowingMoreParticipants).toBe(false);
+ it('when too many participants and already showing them, has more participants link to show less', async () => {
+ wrapper = mountComponent({ participants, numberOfLessParticipants: 2 });
- getMoreParticipantsButton().vm.$emit('click');
+ await findMoreParticipantsButton().vm.$emit('click');
- expect(wrapper.vm.isShowingMoreParticipants).toBe(true);
+ expect(findMoreParticipantsButton().text()).toBe('- show less');
});
- it('clicking on participants icon emits `toggleSidebar` event', async () => {
- wrapper = mountComponent({
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 2,
- });
-
- const spy = jest.spyOn(wrapper.vm, '$emit');
+ it('clicking on participants icon emits `toggleSidebar` event', () => {
+ wrapper = mountComponent({ participants, numberOfLessParticipants: 2 });
- wrapper.find('.sidebar-collapsed-icon').trigger('click');
+ findCollapsedIcon().trigger('click');
- await nextTick();
- expect(spy).toHaveBeenCalledWith('toggleSidebar');
- spy.mockRestore();
+ expect(wrapper.emitted('toggleSidebar')).toEqual([[]]);
});
});
describe('when not showing participants label', () => {
beforeEach(() => {
- wrapper = mountComponent({
- participants: PARTICIPANT_LIST,
- showParticipantLabel: false,
- });
+ wrapper = mountComponent({ participants, showParticipantLabel: false });
});
it('does not show sidebar collapsed icon', () => {
- expect(wrapper.find('.sidebar-collapsed-icon').exists()).toBe(false);
+ expect(findCollapsedIcon().exists()).toBe(false);
});
it('does not show participants label title', () => {
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 859e63b3df6..914e848eced 100644
--- a/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js
+++ b/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js
@@ -35,7 +35,6 @@ describe('Sidebar Participants Widget', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
diff --git a/spec/frontend/sidebar/components/reviewers/reviewer_title_spec.js b/spec/frontend/sidebar/components/reviewers/reviewer_title_spec.js
index 68ecd62e4c6..0f595ab21a5 100644
--- a/spec/frontend/sidebar/components/reviewers/reviewer_title_spec.js
+++ b/spec/frontend/sidebar/components/reviewers/reviewer_title_spec.js
@@ -16,11 +16,6 @@ describe('ReviewerTitle component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('reviewer title', () => {
it('renders reviewer', () => {
wrapper = createComponent({
diff --git a/spec/frontend/sidebar/components/reviewers/reviewers_spec.js b/spec/frontend/sidebar/components/reviewers/reviewers_spec.js
index 229f7ffbe04..016ec9225da 100644
--- a/spec/frontend/sidebar/components/reviewers/reviewers_spec.js
+++ b/spec/frontend/sidebar/components/reviewers/reviewers_spec.js
@@ -35,10 +35,6 @@ describe('Reviewer component', () => {
const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('No reviewers/users', () => {
it('displays no reviewer icon when collapsed', () => {
createWrapper();
diff --git a/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js
index 57ae146a27a..a221d28704b 100644
--- a/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js
+++ b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js
@@ -44,9 +44,6 @@ describe('sidebar reviewers', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
-
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
index d00c8dcb653..66bc1f393ae 100644
--- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
+++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
@@ -1,9 +1,10 @@
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue';
import UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue';
-const userDataMock = () => ({
+const userDataMock = ({ approved = false } = {}) => ({
id: 1,
name: 'Root',
state: 'active',
@@ -14,14 +15,21 @@ const userDataMock = () => ({
canMerge: true,
canUpdate: true,
reviewed: true,
- approved: false,
+ approved,
},
});
describe('UncollapsedReviewerList component', () => {
let wrapper;
- const reviewerApprovalIcons = () => wrapper.findAll('[data-testid="re-approved"]');
+ const findAllRerequestButtons = () => wrapper.findAll('[data-testid="re-request-button"]');
+ const findAllReviewerApprovalIcons = () => wrapper.findAll('[data-testid="approved"]');
+ const findAllReviewedNotApprovedIcons = () =>
+ wrapper.findAll('[data-testid="reviewed-not-approved"]');
+ const findAllReviewerAvatarLinks = () => wrapper.findAllComponents(ReviewerAvatarLink);
+
+ const hasApprovalIconAnimation = () =>
+ findAllReviewerApprovalIcons().at(0).classes('merge-request-approved-icon');
function createComponent(props = {}, glFeatures = {}) {
const propsData = {
@@ -38,10 +46,6 @@ describe('UncollapsedReviewerList component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('single reviewer', () => {
const user = userDataMock();
@@ -52,27 +56,17 @@ describe('UncollapsedReviewerList component', () => {
});
it('only has one user', () => {
- expect(wrapper.findAllComponents(ReviewerAvatarLink).length).toBe(1);
+ expect(findAllReviewerAvatarLinks()).toHaveLength(1);
});
it('shows one user with avatar, and author name', () => {
- expect(wrapper.text()).toContain(user.name);
+ expect(wrapper.text()).toBe(user.name);
});
it('renders re-request loading icon', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({ loadingStates: { 1: 'loading' } });
+ await findAllRerequestButtons().at(0).vm.$emit('click');
- expect(wrapper.find('[data-testid="re-request-button"]').props('loading')).toBe(true);
- });
-
- it('renders re-request success icon', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({ loadingStates: { 1: 'success' } });
-
- expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true);
+ expect(findAllRerequestButtons().at(0).props('loading')).toBe(true);
});
});
@@ -88,51 +82,126 @@ describe('UncollapsedReviewerList component', () => {
approved: true,
},
};
+ const user3 = {
+ ...user,
+ id: 3,
+ name: 'lizabeth-wilderman',
+ username: 'lizabeth-wilderman',
+ mergeRequestInteraction: {
+ ...user.mergeRequestInteraction,
+ approved: false,
+ reviewed: true,
+ },
+ };
beforeEach(() => {
createComponent({
- users: [user, user2],
+ users: [user, user2, user3],
});
});
- it('has both users', () => {
- expect(wrapper.findAllComponents(ReviewerAvatarLink).length).toBe(2);
+ it('has three users', () => {
+ expect(findAllReviewerAvatarLinks()).toHaveLength(3);
});
- it('shows both users with avatar, and author name', () => {
+ it('shows all users with avatar, and author name', () => {
expect(wrapper.text()).toContain(user.name);
expect(wrapper.text()).toContain(user2.name);
+ expect(wrapper.text()).toContain(user3.name);
});
it('renders approval icon', () => {
- expect(reviewerApprovalIcons().length).toBe(1);
+ expect(findAllReviewerApprovalIcons()).toHaveLength(1);
});
it('shows that hello-world approved', () => {
- const icon = reviewerApprovalIcons().at(0);
+ const icon = findAllReviewerApprovalIcons().at(0);
- expect(icon.attributes('title')).toEqual('Approved by @hello-world');
+ expect(icon.attributes('title')).toBe('Approved by @hello-world');
+ });
+
+ it('shows that lizabeth-wilderman reviewed but did not approve', () => {
+ const icon = findAllReviewedNotApprovedIcons().at(1);
+
+ expect(icon.attributes('title')).toBe('Reviewed by @lizabeth-wilderman but not yet approved');
});
it('renders re-request loading icon', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({ loadingStates: { 2: 'loading' } });
+ await findAllRerequestButtons().at(1).vm.$emit('click');
- expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(2);
- expect(wrapper.findAll('[data-testid="re-request-button"]').at(1).props('loading')).toBe(
- true,
- );
+ const allRerequestButtons = findAllRerequestButtons();
+
+ expect(allRerequestButtons).toHaveLength(3);
+ expect(allRerequestButtons.at(1).props('loading')).toBe(true);
+ });
+ });
+
+ describe('when updating reviewers list', () => {
+ it('does not animate icon on initial page load', () => {
+ const user = userDataMock({ approved: true });
+ createComponent({ users: [user] });
+
+ expect(hasApprovalIconAnimation()).toBe(false);
});
- it('renders re-request success icon', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({ loadingStates: { 2: 'success' } });
+ it('does not animate icon when adding a new reviewer', async () => {
+ const user = userDataMock({ approved: true });
+ const anotherUser = { ...user, id: 2 };
+ createComponent({ users: [user] });
+
+ await wrapper.setProps({ users: [user, anotherUser] });
+
+ expect(
+ findAllReviewerApprovalIcons().wrappers.every((w) =>
+ w.classes('merge-request-approved-icon'),
+ ),
+ ).toBe(false);
+ });
+
+ it('removes animation CSS class after 1500ms', async () => {
+ const previousUserState = userDataMock({ approved: false });
+ const currentUserState = userDataMock({ approved: true });
+
+ createComponent({
+ users: [previousUserState],
+ });
+
+ await wrapper.setProps({
+ users: [currentUserState],
+ });
+
+ expect(hasApprovalIconAnimation()).toBe(true);
+
+ jest.advanceTimersByTime(1500);
+ await nextTick();
- expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(1);
- expect(wrapper.findAll('[data-testid="re-request-success"]').length).toBe(1);
- expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true);
+ expect(findAllReviewerApprovalIcons().at(0).classes('merge-request-approved-icon')).toBe(
+ false,
+ );
+ });
+
+ describe('when reviewer was present in the list', () => {
+ it.each`
+ previousApprovalState | currentApprovalState | shouldAnimate
+ ${false} | ${true} | ${true}
+ ${true} | ${true} | ${false}
+ `(
+ 'when approval state changes from $previousApprovalState to $currentApprovalState',
+ async ({ previousApprovalState, currentApprovalState, shouldAnimate }) => {
+ const previousUserState = userDataMock({ approved: previousApprovalState });
+ const currentUserState = userDataMock({ approved: currentApprovalState });
+
+ createComponent({
+ users: [previousUserState],
+ });
+
+ await wrapper.setProps({
+ users: [currentUserState],
+ });
+
+ expect(hasApprovalIconAnimation()).toBe(shouldAnimate);
+ },
+ );
});
});
});
diff --git a/spec/frontend/sidebar/components/severity/severity_spec.js b/spec/frontend/sidebar/components/severity/severity_spec.js
index 99d33e840d5..939d86917bb 100644
--- a/spec/frontend/sidebar/components/severity/severity_spec.js
+++ b/spec/frontend/sidebar/components/severity/severity_spec.js
@@ -14,13 +14,6 @@ describe('SeverityToken', () => {
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findIcon = () => wrapper.findComponent(GlIcon);
it('renders severity token for each severity type', () => {
diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js
deleted file mode 100644
index 71c6c259c32..00000000000
--- a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
-import { nextTick } from 'vue';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
-import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/constants';
-import updateIssuableSeverity from '~/sidebar/queries/update_issuable_severity.mutation.graphql';
-import SeverityToken from '~/sidebar/components/severity/severity.vue';
-import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severity_widget.vue';
-
-jest.mock('~/flash');
-
-describe('SidebarSeverity', () => {
- let wrapper;
- let mutate;
- const projectPath = 'gitlab-org/gitlab-test';
- const iid = '1';
- const severity = 'CRITICAL';
- let canUpdate = true;
-
- function createComponent(props = {}) {
- const propsData = {
- projectPath,
- iid,
- issuableType: ISSUABLE_TYPES.INCIDENT,
- initialSeverity: severity,
- ...props,
- };
- mutate = jest.fn();
- wrapper = mountExtended(SidebarSeverityWidget, {
- propsData,
- provide: {
- canUpdate,
- },
- mocks: {
- $apollo: {
- mutate,
- },
- },
- stubs: {
- GlSprintf,
- },
- });
- }
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const findSeverityToken = () => wrapper.findAllComponents(SeverityToken);
- const findEditBtn = () => wrapper.findByTestId('edit-button');
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findCriticalSeverityDropdownItem = () => wrapper.findComponent(GlDropdownItem);
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findTooltip = () => wrapper.findComponent(GlTooltip);
- const findCollapsedSeverity = () => wrapper.findComponent({ ref: 'severity' });
-
- describe('Severity widget', () => {
- it('renders severity dropdown and token', () => {
- expect(findSeverityToken().exists()).toBe(true);
- expect(findDropdown().exists()).toBe(true);
- });
-
- describe('edit button', () => {
- it('is rendered when `canUpdate` provided as `true`', () => {
- expect(findEditBtn().exists()).toBe(true);
- });
-
- it('is NOT rendered when `canUpdate` provided as `false`', () => {
- canUpdate = false;
- createComponent();
- expect(findEditBtn().exists()).toBe(false);
- });
- });
- });
-
- describe('Update severity', () => {
- it('calls `$apollo.mutate` with `updateIssuableSeverity`', () => {
- jest
- .spyOn(wrapper.vm.$apollo, 'mutate')
- .mockResolvedValueOnce({ data: { issueSetSeverity: { issue: { severity } } } });
-
- findCriticalSeverityDropdownItem().vm.$emit('click');
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: updateIssuableSeverity,
- variables: {
- iid,
- projectPath,
- severity,
- },
- });
- });
-
- it('shows error alert when severity update fails', async () => {
- const errorMsg = 'Something went wrong';
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValueOnce(errorMsg);
- findCriticalSeverityDropdownItem().vm.$emit('click');
-
- await waitForPromises();
-
- expect(createAlert).toHaveBeenCalled();
- });
-
- it('shows loading icon while updating', async () => {
- let resolvePromise;
- wrapper.vm.$apollo.mutate = jest.fn(
- () =>
- new Promise((resolve) => {
- resolvePromise = resolve;
- }),
- );
- findCriticalSeverityDropdownItem().vm.$emit('click');
-
- await nextTick();
- expect(findLoadingIcon().exists()).toBe(true);
-
- resolvePromise();
- await waitForPromises();
- expect(findLoadingIcon().exists()).toBe(false);
- });
- });
-
- describe('Switch between collapsed/expanded view of the sidebar', () => {
- describe('collapsed', () => {
- it('should have collapsed icon class', () => {
- expect(findCollapsedSeverity().classes('sidebar-collapsed-icon')).toBe(true);
- });
-
- it('should display only icon with a tooltip', () => {
- expect(findSeverityToken().exists()).toBe(true);
- expect(findTooltip().text()).toContain(INCIDENT_SEVERITY[severity].label);
- expect(findEditBtn().exists()).toBe(false);
- });
- });
-
- describe('expanded', () => {
- it('toggles dropdown with edit button', async () => {
- canUpdate = true;
- createComponent();
- await nextTick();
- expect(findDropdown().isVisible()).toBe(false);
-
- findEditBtn().vm.$emit('click');
- await nextTick();
- expect(findDropdown().isVisible()).toBe(true);
-
- findEditBtn().vm.$emit('click');
- await nextTick();
- expect(findDropdown().isVisible()).toBe(false);
- });
- });
- });
-});
diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js
new file mode 100644
index 00000000000..bee90d2b2b6
--- /dev/null
+++ b/spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js
@@ -0,0 +1,160 @@
+import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
+import { TYPE_INCIDENT } from '~/issues/constants';
+import { INCIDENT_SEVERITY } from '~/sidebar/constants';
+import updateIssuableSeverity from '~/sidebar/queries/update_issuable_severity.mutation.graphql';
+import SeverityToken from '~/sidebar/components/severity/severity.vue';
+import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severity_widget.vue';
+
+jest.mock('~/alert');
+
+Vue.use(VueApollo);
+
+describe('SidebarSeverityWidget', () => {
+ let wrapper;
+ let mockApollo;
+ const projectPath = 'gitlab-org/gitlab-test';
+ const iid = '1';
+ const severity = 'CRITICAL';
+
+ function createComponent({ props, canUpdate = true, mutationMock } = {}) {
+ mockApollo = createMockApollo([[updateIssuableSeverity, mutationMock]]);
+
+ const propsData = {
+ projectPath,
+ iid,
+ issuableType: TYPE_INCIDENT,
+ initialSeverity: severity,
+ ...props,
+ };
+
+ wrapper = mountExtended(SidebarSeverityWidget, {
+ propsData,
+ provide: {
+ canUpdate,
+ },
+ apolloProvider: mockApollo,
+ stubs: {
+ GlSprintf,
+ },
+ });
+ }
+
+ afterEach(() => {
+ mockApollo = null;
+ });
+
+ const findSeverityToken = () => wrapper.findAllComponents(SeverityToken);
+ const findEditBtn = () => wrapper.findByTestId('edit-button');
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findCriticalSeverityDropdownItem = () => wrapper.findComponent(GlDropdownItem); // First dropdown item is critical severity
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findTooltip = () => wrapper.findComponent(GlTooltip);
+ const findCollapsedSeverity = () => wrapper.findComponent({ ref: 'severity' });
+
+ describe('Severity widget', () => {
+ it('renders severity dropdown and token', () => {
+ createComponent();
+
+ expect(findSeverityToken().exists()).toBe(true);
+ expect(findDropdown().exists()).toBe(true);
+ });
+
+ describe('edit button', () => {
+ it('is rendered when `canUpdate` provided as `true`', () => {
+ createComponent();
+
+ expect(findEditBtn().exists()).toBe(true);
+ });
+
+ it('is NOT rendered when `canUpdate` provided as `false`', () => {
+ createComponent({ canUpdate: false });
+
+ expect(findEditBtn().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Update severity', () => {
+ it('calls mutate with `updateIssuableSeverity`', () => {
+ const mutationMock = jest.fn().mockResolvedValue({
+ data: { issueSetSeverity: { issue: { severity } } },
+ });
+ createComponent({ mutationMock });
+
+ findCriticalSeverityDropdownItem().vm.$emit('click');
+
+ expect(mutationMock).toHaveBeenCalledWith({
+ iid,
+ projectPath,
+ severity,
+ });
+ });
+
+ it('shows error alert when severity update fails', async () => {
+ const mutationMock = jest.fn().mockRejectedValue('Something went wrong');
+ createComponent({ mutationMock });
+
+ findCriticalSeverityDropdownItem().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalled();
+ });
+
+ it('shows loading icon while updating', async () => {
+ const mutationMock = jest.fn().mockRejectedValue({});
+ createComponent({ mutationMock });
+
+ findCriticalSeverityDropdownItem().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('Switch between collapsed/expanded view of the sidebar', () => {
+ describe('collapsed', () => {
+ beforeEach(() => {
+ createComponent({ canUpdate: false });
+ });
+
+ it('should have collapsed icon class', () => {
+ expect(findCollapsedSeverity().classes('sidebar-collapsed-icon')).toBe(true);
+ });
+
+ it('should display only icon with a tooltip', () => {
+ expect(findSeverityToken().exists()).toBe(true);
+ expect(findTooltip().text()).toContain(INCIDENT_SEVERITY[severity].label);
+ expect(findEditBtn().exists()).toBe(false);
+ });
+ });
+
+ describe('expanded', () => {
+ it('toggles dropdown with edit button', async () => {
+ createComponent();
+ await nextTick();
+
+ expect(findDropdown().isVisible()).toBe(false);
+
+ findEditBtn().vm.$emit('click');
+ await nextTick();
+
+ expect(findDropdown().isVisible()).toBe(true);
+
+ findEditBtn().vm.$emit('click');
+ await nextTick();
+
+ expect(findDropdown().isVisible()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_spec.js
index 9f3d689edee..7a0044c00ac 100644
--- a/spec/frontend/sidebar/components/sidebar_dropdown_spec.js
+++ b/spec/frontend/sidebar/components/sidebar_dropdown_spec.js
@@ -10,7 +10,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import SidebarDropdown from '~/sidebar/components/sidebar_dropdown.vue';
import { IssuableAttributeType } from '~/sidebar/constants';
@@ -23,7 +23,7 @@ import {
noCurrentMilestoneResponse,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('SidebarDropdown component', () => {
let wrapper;
diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
index 060a2873e04..27ab347775a 100644
--- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
+++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlLink, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+import { GlLink, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { shallowMount, mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
@@ -7,7 +7,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/issues/constants';
import { timeFor } from '~/lib/utils/datetime_utility';
@@ -27,7 +27,7 @@ import {
mockMilestone2,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('SidebarDropdownWidget', () => {
let wrapper;
@@ -133,43 +133,34 @@ describe('SidebarDropdownWidget', () => {
$apollo: {
mutate: mutationPromise(),
queries: {
- currentAttribute: { loading: false },
+ issuable: { loading: false },
attributesList: { loading: false },
...queries,
},
},
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
stubs: {
SidebarEditableItem,
GlSearchBoxByType,
- GlDropdown,
},
}),
);
wrapper.vm.$refs.dropdown.show = jest.fn();
-
- // We need to mock out `showDropdown` which
- // invokes `show` method of BDropdown used inside GlDropdown.
- jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when not editing', () => {
beforeEach(() => {
createComponent({
data: {
- currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl', dueDate: '2021-09-09' },
+ issuable: {
+ attribute: { id: 'id', title: 'title', webUrl: 'webUrl', dueDate: '2021-09-09' },
+ },
},
stubs: {
- GlDropdown,
SidebarEditableItem,
},
});
@@ -190,7 +181,7 @@ describe('SidebarDropdownWidget', () => {
it('shows a loading spinner while fetching the current attribute', () => {
createComponent({
queries: {
- currentAttribute: { loading: true },
+ issuable: { loading: true },
},
});
@@ -204,7 +195,7 @@ describe('SidebarDropdownWidget', () => {
selectedTitle: 'Some milestone title',
},
queries: {
- currentAttribute: { loading: false },
+ issuable: { loading: false },
},
});
@@ -229,10 +220,10 @@ describe('SidebarDropdownWidget', () => {
createComponent({
data: {
hasCurrentAttribute: true,
- currentAttribute: null,
+ issuable: {},
},
queries: {
- currentAttribute: { loading: false },
+ issuable: { loading: false },
},
});
@@ -256,7 +247,9 @@ describe('SidebarDropdownWidget', () => {
{ id: '123', title: '123' },
{ id: 'id', title: 'title' },
],
- currentAttribute: { id: '123' },
+ issuable: {
+ attribute: { id: '123' },
+ },
},
mutationPromise: mutationResp,
});
diff --git a/spec/frontend/sidebar/components/status/status_dropdown_spec.js b/spec/frontend/sidebar/components/status/status_dropdown_spec.js
index 5a75299c3a4..229b51ea568 100644
--- a/spec/frontend/sidebar/components/status/status_dropdown_spec.js
+++ b/spec/frontend/sidebar/components/status/status_dropdown_spec.js
@@ -14,10 +14,6 @@ describe('SubscriptionsDropdown component', () => {
wrapper = shallowMount(StatusDropdown);
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with no value selected', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js
index c94f9918243..7275557e7f2 100644
--- a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js
+++ b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js
@@ -4,7 +4,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import SidebarSubscriptionWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql';
@@ -15,7 +15,7 @@ import {
mergeRequestSubscriptionMutationResponse,
} from '../../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/vue_shared/plugins/global_toast');
Vue.use(VueApollo);
@@ -62,7 +62,6 @@ describe('Sidebar Subscriptions Widget', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -138,7 +137,7 @@ describe('Sidebar Subscriptions Widget', () => {
});
});
- it('displays a flash message when query is rejected', async () => {
+ it('displays an alert message when query is rejected', async () => {
createComponent({
subscriptionsQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
});
diff --git a/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js
index 3fb8214606c..eaf7bc13d20 100644
--- a/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js
+++ b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js
@@ -15,10 +15,6 @@ describe('SubscriptionsDropdown component', () => {
wrapper = shallowMount(SubscriptionsDropdown);
}
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with no value selected', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js b/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js
index 1a1aa370eef..b644b7a9421 100644
--- a/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js
+++ b/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js
@@ -1,28 +1,24 @@
import { GlToggle } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
import eventHub from '~/sidebar/event_hub';
describe('Subscriptions', () => {
let wrapper;
+ let trackingSpy;
const findToggleButton = () => wrapper.findComponent(GlToggle);
+ const findTooltip = () => wrapper.findComponent({ ref: 'tooltip' });
- const mountComponent = (propsData) =>
- extendedWrapper(
- shallowMount(Subscriptions, {
- propsData,
- }),
- );
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
+ const mountComponent = (propsData) => {
+ wrapper = shallowMountExtended(Subscriptions, {
+ propsData,
+ });
+ };
it('shows loading spinner when loading', () => {
- wrapper = mountComponent({
+ mountComponent({
loading: true,
subscribed: undefined,
});
@@ -31,7 +27,7 @@ describe('Subscriptions', () => {
});
it('is toggled "off" when currently not subscribed', () => {
- wrapper = mountComponent({
+ mountComponent({
subscribed: false,
});
@@ -39,7 +35,7 @@ describe('Subscriptions', () => {
});
it('is toggled "on" when currently subscribed', () => {
- wrapper = mountComponent({
+ mountComponent({
subscribed: true,
});
@@ -48,44 +44,38 @@ describe('Subscriptions', () => {
it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => {
const id = 42;
- wrapper = mountComponent({ subscribed: true, id });
+ mountComponent({ subscribed: true, id });
const eventHubSpy = jest.spyOn(eventHub, '$emit');
- const wrapperEmitSpy = jest.spyOn(wrapper.vm, '$emit');
- wrapper.vm.toggleSubscription();
+ findToggleButton().vm.$emit('change');
expect(eventHubSpy).toHaveBeenCalledWith('toggleSubscription', id);
- expect(wrapperEmitSpy).toHaveBeenCalledWith('toggleSubscription', id);
- eventHubSpy.mockRestore();
- wrapperEmitSpy.mockRestore();
+ expect(wrapper.emitted('toggleSubscription')).toEqual([[id]]);
});
it('tracks the event when toggled', () => {
- wrapper = mountComponent({ subscribed: true });
-
- const wrapperTrackSpy = jest.spyOn(wrapper.vm, 'track');
+ trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
+ mountComponent({ subscribed: true });
- wrapper.vm.toggleSubscription();
+ findToggleButton().vm.$emit('change');
- expect(wrapperTrackSpy).toHaveBeenCalledWith('toggle_button', {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'toggle_button', {
+ category: undefined,
+ label: 'right_sidebar',
property: 'notifications',
value: 0,
});
- wrapperTrackSpy.mockRestore();
});
it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => {
- wrapper = mountComponent({ subscribed: true });
- const spy = jest.spyOn(wrapper.vm, '$emit');
+ mountComponent({ subscribed: true });
+ findTooltip().trigger('click');
- wrapper.vm.onClickCollapsedIcon();
-
- expect(spy).toHaveBeenCalledWith('toggleSidebar');
- spy.mockRestore();
+ expect(wrapper.emitted('toggleSidebar')).toHaveLength(1);
});
it('has visually hidden label', () => {
- wrapper = mountComponent();
+ mountComponent();
expect(findToggleButton().props()).toMatchObject({
label: 'Notifications',
@@ -97,7 +87,7 @@ describe('Subscriptions', () => {
const subscribeDisabledDescription = 'Notifications have been disabled';
beforeEach(() => {
- wrapper = mountComponent({
+ mountComponent({
subscribed: false,
projectEmailsDisabled: true,
subscribeDisabledDescription,
@@ -108,9 +98,7 @@ describe('Subscriptions', () => {
expect(wrapper.findByTestId('subscription-title').text()).toContain(
subscribeDisabledDescription,
);
- expect(wrapper.findComponent({ ref: 'tooltip' }).attributes('title')).toBe(
- subscribeDisabledDescription,
- );
+ expect(findTooltip().attributes('title')).toBe(subscribeDisabledDescription);
});
it('does not render the toggle button', () => {
diff --git a/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js
index 715f66d305a..a7c3867c359 100644
--- a/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js
+++ b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js
@@ -47,8 +47,8 @@ describe('Create Timelog Form', () => {
const findAlert = () => wrapper.findComponent(GlAlert);
const findDocsLink = () => wrapper.findByTestId('timetracking-docs-link');
const findSaveButton = () => findModal().props('actionPrimary');
- const findSaveButtonLoadingState = () => findSaveButton().attributes[0].loading;
- const findSaveButtonDisabledState = () => findSaveButton().attributes[0].disabled;
+ const findSaveButtonLoadingState = () => findSaveButton().attributes.loading;
+ const findSaveButtonDisabledState = () => findSaveButton().attributes.disabled;
const submitForm = () => findForm().trigger('submit');
diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js
index 0259aee48f0..713ae83cbf1 100644
--- a/spec/frontend/sidebar/components/time_tracking/report_spec.js
+++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js
@@ -6,7 +6,7 @@ import VueApollo from 'vue-apollo';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Report from '~/sidebar/components/time_tracking/report.vue';
import getIssueTimelogsQuery from '~/sidebar/queries/get_issue_timelogs.query.graphql';
import getMrTimelogsQuery from '~/sidebar/queries/get_mr_timelogs.query.graphql';
@@ -17,7 +17,7 @@ import {
timelogToRemoveId,
} from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Issuable Time Tracking Report', () => {
Vue.use(VueApollo);
@@ -51,7 +51,6 @@ describe('Issuable Time Tracking Report', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
index 45d8b5e4647..e23d24f9629 100644
--- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
+++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
@@ -32,7 +32,7 @@ describe('Issuable Time Tracker', () => {
const mountComponent = ({ props = {}, issuableType = 'issue', loading = false } = {}) => {
return mount(TimeTracker, {
propsData: { ...defaultProps, ...props },
- directives: { GlTooltip: createMockDirective() },
+ directives: { GlTooltip: createMockDirective('gl-tooltip') },
stubs: {
transition: stubTransition(),
},
@@ -53,10 +53,6 @@ describe('Issuable Time Tracker', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Initialization', () => {
beforeEach(() => {
wrapper = mountComponent();
@@ -148,7 +144,7 @@ describe('Issuable Time Tracker', () => {
});
describe('Comparison pane when limitToHours is true', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent({
props: {
limitToHours: true,
diff --git a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js
index 5bfe3b59eb3..39b480b295c 100644
--- a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js
+++ b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js
@@ -4,13 +4,13 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql';
import TodoButton from '~/sidebar/components/todo_toggle/todo_button.vue';
import { todosResponse, noTodosResponse } from '../../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -41,7 +41,6 @@ describe('Sidebar Todo Widget', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -77,7 +76,7 @@ describe('Sidebar Todo Widget', () => {
});
});
- it('displays a flash message when query is rejected', async () => {
+ it('displays an alert message when query is rejected', async () => {
createComponent({
todosQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
});
diff --git a/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js b/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js
index fb07029a249..472a89e9b21 100644
--- a/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js
+++ b/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js
@@ -22,7 +22,6 @@ describe('Todo Button', () => {
});
afterEach(() => {
- wrapper.destroy();
dispatchEventSpy = null;
jest.clearAllMocks();
});
diff --git a/spec/frontend/sidebar/components/todo_toggle/todo_spec.js b/spec/frontend/sidebar/components/todo_toggle/todo_spec.js
index 8e6597bf80f..4da915f0dd3 100644
--- a/spec/frontend/sidebar/components/todo_toggle/todo_spec.js
+++ b/spec/frontend/sidebar/components/todo_toggle/todo_spec.js
@@ -21,10 +21,6 @@ describe('SidebarTodo', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
state | classes
${false} | ${['gl-button', 'btn', 'btn-default', 'btn-todo', 'issuable-header-btn', 'float-right']}
diff --git a/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js b/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js
index cf9b2828dde..8e34a612705 100644
--- a/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js
+++ b/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js
@@ -17,10 +17,6 @@ describe('ToggleSidebar', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findGlButton = () => wrapper.findComponent(GlButton);
it('should render the "chevron-double-lg-left" icon when collapsed', () => {
@@ -29,7 +25,7 @@ describe('ToggleSidebar', () => {
expect(findGlButton().props('icon')).toBe('chevron-double-lg-left');
});
- it('should render the "chevron-double-lg-right" icon when expanded', async () => {
+ it('should render the "chevron-double-lg-right" icon when expanded', () => {
createComponent({ props: { collapsed: false } });
expect(findGlButton().props('icon')).toBe('chevron-double-lg-right');
diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js
index 391cbb1e0d5..05a7f504fd4 100644
--- a/spec/frontend/sidebar/mock_data.js
+++ b/spec/frontend/sidebar/mock_data.js
@@ -243,6 +243,18 @@ export const issuableDueDateResponse = (dueDate = null) => ({
__typename: 'Issue',
id: 'gid://gitlab/Issue/4',
dueDate,
+ dueDateFixed: dueDate,
+ },
+ },
+ },
+});
+
+export const issueDueDateSubscriptionResponse = () => ({
+ data: {
+ issuableDatesUpdated: {
+ issue: {
+ id: 'gid://gitlab/Issue/4',
+ dueDate: '2022-12-31',
},
},
},
diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js
index 77b1ccb4f9a..f2003aee96e 100644
--- a/spec/frontend/sidebar/sidebar_mediator_spec.js
+++ b/spec/frontend/sidebar/sidebar_mediator_spec.js
@@ -7,7 +7,7 @@ import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import Mock from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/vue_shared/plugins/global_toast');
jest.mock('~/commons/nav/user_merge_requests');
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 fec300ddd7e..c8d972b19a3 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
@@ -19,7 +19,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
<gl-form-input-stub
class="form-control"
data-qa-selector="description_placeholder"
- placeholder="Optionally add a description about what your snippet does or how to use it…"
+ placeholder="Describe what your snippet does or how to use it…"
/>
</div>
@@ -28,10 +28,13 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
data-uploads-path=""
>
<markdown-header-stub
+ data-testid="markdownHeader"
enablepreview="true"
linecontent=""
+ markdownpreviewpath="foo/"
restrictedtoolbaritems=""
suggestionstartindex="0"
+ uploadspath=""
/>
<div
@@ -87,7 +90,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
</div>
<div
- class="js-vue-md-preview md md-preview-holder"
+ class="js-vue-md-preview md md-preview-holder gl-px-5"
style="display: none;"
/>
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
index f4ebc5c3e3f..ed54582ca29 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
@@ -13,7 +13,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] =
target="_blank"
>
<gl-icon-stub
- name="question"
+ name="question-o"
size="12"
/>
</gl-link-stub>
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index e7dab0ad79d..d17e20ac227 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -9,7 +9,7 @@ import { stubPerformanceWebAPI } from 'helpers/performance';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import * as urlUtils from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue';
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
@@ -25,7 +25,7 @@ import UpdateSnippetMutation from '~/snippets/mutations/update_snippet.mutation.
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import { testEntries, createGQLSnippetsQueryResponse, createGQLSnippet } from '../test_utils';
-jest.mock('~/flash');
+jest.mock('~/alert');
const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js'];
const TEST_API_ERROR = new Error('TEST_API_ERROR');
@@ -94,7 +94,6 @@ describe('Snippet Edit app', () => {
let mutateSpy;
const relativeUrlRoot = '/foo/';
- const originalRelativeUrlRoot = gon.relative_url_root;
beforeEach(() => {
stubPerformanceWebAPI();
@@ -108,12 +107,6 @@ describe('Snippet Edit app', () => {
jest.spyOn(urlUtils, 'redirectTo').mockImplementation();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- gon.relative_url_root = originalRelativeUrlRoot;
- });
-
const findBlobActions = () => wrapper.findComponent(SnippetBlobActionsEdit);
const findCancelButton = () => wrapper.findByTestId('snippet-cancel-btn');
const clickSubmitBtn = () => wrapper.findByTestId('snippet-edit-form').trigger('submit');
@@ -132,10 +125,6 @@ describe('Snippet Edit app', () => {
props = {},
selectedLevel = VISIBILITY_LEVEL_PRIVATE_STRING,
} = {}) => {
- if (wrapper) {
- throw new Error('wrapper already created');
- }
-
const requestHandlers = [
[GetSnippetQuery, getSpy],
// See `mutateSpy` declaration comment for why we send a key
@@ -267,7 +256,7 @@ describe('Snippet Edit app', () => {
VISIBILITY_LEVEL_PRIVATE_STRING,
VISIBILITY_LEVEL_INTERNAL_STRING,
VISIBILITY_LEVEL_PUBLIC_STRING,
- ])('marks %s visibility by default', async (visibility) => {
+ ])('marks %s visibility by default', (visibility) => {
createComponent({
props: { snippetGid: '' },
selectedLevel: visibility,
@@ -339,7 +328,7 @@ describe('Snippet Edit app', () => {
it('should redirect to snippet view on successful mutation', async () => {
await createComponentAndSubmit();
- expect(urlUtils.redirectTo).toHaveBeenCalledWith(TEST_WEB_URL);
+ expect(urlUtils.redirectTo).toHaveBeenCalledWith(TEST_WEB_URL); // eslint-disable-line import/no-deprecated
});
describe('when there are errors after creating a new snippet', () => {
@@ -347,7 +336,7 @@ describe('Snippet Edit app', () => {
projectPath
${'project/path'}
${''}
- `('should flash error (projectPath=$projectPath)', async ({ projectPath }) => {
+ `('should alert error (projectPath=$projectPath)', async ({ projectPath }) => {
mutateSpy.mockResolvedValue(createMutationResponseWithErrors('createSnippet'));
await createComponentAndLoad({
@@ -360,7 +349,7 @@ describe('Snippet Edit app', () => {
await waitForPromises();
- expect(urlUtils.redirectTo).not.toHaveBeenCalled();
+ expect(urlUtils.redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
expect(createAlert).toHaveBeenCalledWith({
message: `Can't create snippet: ${TEST_MUTATION_ERROR}`,
});
@@ -373,7 +362,7 @@ describe('Snippet Edit app', () => {
${'project/path'}
${''}
`(
- 'should flash error with (snippet=$snippetGid, projectPath=$projectPath)',
+ 'should alert error with (snippet=$snippetGid, projectPath=$projectPath)',
async ({ projectPath }) => {
mutateSpy.mockResolvedValue(createMutationResponseWithErrors('updateSnippet'));
@@ -384,7 +373,7 @@ describe('Snippet Edit app', () => {
},
});
- expect(urlUtils.redirectTo).not.toHaveBeenCalled();
+ expect(urlUtils.redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
expect(createAlert).toHaveBeenCalledWith({
message: `Can't update snippet: ${TEST_MUTATION_ERROR}`,
});
@@ -402,10 +391,10 @@ describe('Snippet Edit app', () => {
});
it('should not redirect', () => {
- expect(urlUtils.redirectTo).not.toHaveBeenCalled();
+ expect(urlUtils.redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
});
- it('should flash', () => {
+ it('should alert', () => {
// Apollo automatically wraps the resolver's error in a NetworkError
expect(createAlert).toHaveBeenCalledWith({
message: `Can't update snippet: ${TEST_API_ERROR.message}`,
diff --git a/spec/frontend/snippets/components/embed_dropdown_spec.js b/spec/frontend/snippets/components/embed_dropdown_spec.js
index ed5ea6cab8a..d8c6ad3278a 100644
--- a/spec/frontend/snippets/components/embed_dropdown_spec.js
+++ b/spec/frontend/snippets/components/embed_dropdown_spec.js
@@ -17,11 +17,6 @@ describe('snippets/components/embed_dropdown', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findSectionsData = () => {
const sections = [];
let current = {};
diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js
index 032dcf8e5f5..45a7c7b0b4a 100644
--- a/spec/frontend/snippets/components/show_spec.js
+++ b/spec/frontend/snippets/components/show_spec.js
@@ -50,10 +50,6 @@ describe('Snippet view app', () => {
stubPerformanceWebAPI();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders loader while the query is in flight', () => {
createComponent({ loading: true });
expect(findLoadingIcon().exists()).toBe(true);
diff --git a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js
index a650353093d..58f47e8b0dc 100644
--- a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js
@@ -56,11 +56,6 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
const triggerBlobDelete = (idx) => findBlobEdits().at(idx).vm.$emit('delete');
const triggerBlobUpdate = (idx, props) => findBlobEdits().at(idx).vm.$emit('blob-updated', props);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('multi-file snippets rendering', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js
index 82c4a37ccc9..b699e056576 100644
--- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js
@@ -4,14 +4,14 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { joinPaths } from '~/lib/utils/url_utility';
import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
-jest.mock('~/flash');
+jest.mock('~/alert');
const TEST_ID = 'blob_local_7';
const TEST_PATH = 'foo/bar/test.md';
@@ -62,8 +62,6 @@ describe('Snippet Blob Edit component', () => {
});
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
axiosMock.restore();
});
@@ -123,7 +121,7 @@ describe('Snippet Blob Edit component', () => {
createComponent();
});
- it('should call flash', async () => {
+ it('should call alert', async () => {
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({
diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js
index c7ff8c21d80..05ff64c2296 100644
--- a/spec/frontend/snippets/components/snippet_blob_view_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js
@@ -1,5 +1,6 @@
-import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
import {
Blob as BlobMock,
SimpleViewerMock,
@@ -7,6 +8,7 @@ import {
RichBlobContentMock,
SimpleBlobContentMock,
} from 'jest/blob/components/mock_data';
+import GetBlobContent from 'shared_queries/snippet/snippet_blob_content.query.graphql';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import {
@@ -17,9 +19,13 @@ import {
import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue';
import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
describe('Blob Embeddable', () => {
let wrapper;
+ let requestHandlers;
+
const snippet = {
id: 'gid://foo.bar/snippet',
webUrl: 'https://foo.bar',
@@ -29,23 +35,47 @@ describe('Blob Embeddable', () => {
activeViewerType: SimpleViewerMock.type,
};
+ const mockDefaultHandler = ({ path, nodes } = { path: BlobMock.path }) => {
+ const renderedNodes = nodes || [
+ { __typename: 'Blob', path, richData: 'richData', plainData: 'plainData' },
+ ];
+
+ return jest.fn().mockResolvedValue({
+ data: {
+ snippets: {
+ __typename: 'Snippet',
+ id: '1',
+ nodes: [
+ {
+ __typename: 'Snippet',
+ id: '2',
+ blobs: {
+ __typename: 'Blob',
+ hasUnretrievableBlobs: false,
+ nodes: renderedNodes,
+ },
+ },
+ ],
+ },
+ },
+ });
+ };
+
+ const createMockApolloProvider = (handler) => {
+ Vue.use(VueApollo);
+
+ requestHandlers = handler;
+ return createMockApollo([[GetBlobContent, requestHandlers]]);
+ };
+
function createComponent({
snippetProps = {},
data = dataMock,
blob = BlobMock,
- contentLoading = false,
+ handler = mockDefaultHandler(),
} = {}) {
- const $apollo = {
- queries: {
- blobContent: {
- loading: contentLoading,
- refetch: jest.fn(),
- skip: true,
- },
- },
- };
-
- wrapper = mount(SnippetBlobView, {
+ wrapper = shallowMount(SnippetBlobView, {
+ apolloProvider: createMockApolloProvider(handler),
propsData: {
snippet: {
...snippet,
@@ -58,45 +88,56 @@ describe('Blob Embeddable', () => {
...data,
};
},
- mocks: { $apollo },
+ stubs: {
+ BlobHeader,
+ BlobContent,
+ },
});
}
- afterEach(() => {
- wrapper.destroy();
- });
+ const findBlobHeader = () => wrapper.findComponent(BlobHeader);
+ const findBlobContent = () => wrapper.findComponent(BlobContent);
+ const findSimpleViewer = () => wrapper.findComponent(SimpleViewer);
+ const findRichViewer = () => wrapper.findComponent(RichViewer);
describe('rendering', () => {
it('renders correct components', () => {
createComponent();
- expect(wrapper.findComponent(BlobHeader).exists()).toBe(true);
- expect(wrapper.findComponent(BlobContent).exists()).toBe(true);
+ expect(findBlobHeader().exists()).toBe(true);
+ expect(findBlobContent().exists()).toBe(true);
});
- it('sets simple viewer correctly', () => {
+ it('sets simple viewer correctly', async () => {
createComponent();
- expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true);
+ await waitForPromises();
+
+ expect(findSimpleViewer().exists()).toBe(true);
});
- it('sets rich viewer correctly', () => {
+ it('sets rich viewer correctly', async () => {
const data = { ...dataMock, activeViewerType: RichViewerMock.type };
createComponent({
data,
});
- expect(wrapper.findComponent(RichViewer).exists()).toBe(true);
+ await waitForPromises();
+ expect(findRichViewer().exists()).toBe(true);
});
it('correctly switches viewer type', async () => {
createComponent();
- expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true);
+ await waitForPromises();
+
+ expect(findSimpleViewer().exists()).toBe(true);
- wrapper.vm.switchViewer(RichViewerMock.type);
+ findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, RichViewerMock.type);
+ await waitForPromises();
- await nextTick();
- expect(wrapper.findComponent(RichViewer).exists()).toBe(true);
- await wrapper.vm.switchViewer(SimpleViewerMock.type);
+ expect(findRichViewer().exists()).toBe(true);
- expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true);
+ findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, SimpleViewerMock.type);
+ await waitForPromises();
+
+ expect(findSimpleViewer().exists()).toBe(true);
});
it('passes information about render error down to blob header', () => {
@@ -110,7 +151,7 @@ describe('Blob Embeddable', () => {
},
});
- expect(wrapper.findComponent(BlobHeader).props('hasRenderError')).toBe(true);
+ expect(findBlobHeader().props('hasRenderError')).toBe(true);
});
describe('bob content in multi-file scenario', () => {
@@ -123,47 +164,38 @@ describe('Blob Embeddable', () => {
richData: 'Another Rich Foo',
};
+ const MixedSimpleBlobContentMock = {
+ ...SimpleBlobContentMock,
+ richData: '<h1>Rich</h1>',
+ };
+
+ const MixedRichBlobContentMock = {
+ ...RichBlobContentMock,
+ plainData: 'Plain',
+ };
+
it.each`
- snippetBlobs | description | currentBlob | expectedContent
- ${[SimpleBlobContentMock]} | ${'one existing textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData}
- ${[RichBlobContentMock]} | ${'one existing rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData}
- ${[SimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData}
- ${[SimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData}
- ${[SimpleBlobContentMock, SimpleBlobContentMock2]} | ${'textual blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData}
- ${[RichBlobContentMock, RichBlobContentMock2]} | ${'rich blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData}
+ snippetBlobs | description | currentBlob | expectedContent | activeViewerType
+ ${[SimpleBlobContentMock]} | ${'one existing textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} | ${SimpleViewerMock.type}
+ ${[RichBlobContentMock]} | ${'one existing rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} | ${RichViewerMock.type}
+ ${[SimpleBlobContentMock, MixedRichBlobContentMock]} | ${'mixed blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} | ${SimpleViewerMock.type}
+ ${[MixedSimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} | ${RichViewerMock.type}
+ ${[SimpleBlobContentMock, SimpleBlobContentMock2]} | ${'textual blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} | ${SimpleViewerMock.type}
+ ${[RichBlobContentMock, RichBlobContentMock2]} | ${'rich blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} | ${RichViewerMock.type}
`(
'renders correct content for $description',
- async ({ snippetBlobs, currentBlob, expectedContent }) => {
- const apolloData = {
- snippets: {
- nodes: [
- {
- blobs: {
- nodes: snippetBlobs,
- },
- },
- ],
- },
- };
+ async ({ snippetBlobs, currentBlob, expectedContent, activeViewerType }) => {
createComponent({
+ handler: mockDefaultHandler({ path: currentBlob.path, nodes: snippetBlobs }),
+ data: { activeViewerType },
blob: {
...BlobMock,
path: currentBlob.path,
},
});
+ await waitForPromises();
- // mimic apollo's update
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- blobContent: wrapper.vm.onContentUpdate(apolloData),
- });
-
- await nextTick();
-
- const findContent = () => wrapper.findComponent(BlobContent);
-
- expect(findContent().props('content')).toBe(expectedContent);
+ expect(findBlobContent().props('content')).toBe(expectedContent);
},
);
});
@@ -178,28 +210,32 @@ describe('Blob Embeddable', () => {
window.location.hash = '#LC2';
});
- it('renders simple viewer by default', () => {
+ it('renders simple viewer by default', async () => {
createComponent({
data: {},
});
+ await waitForPromises();
- expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type);
- expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true);
+ expect(findBlobHeader().props('activeViewerType')).toBe(SimpleViewerMock.type);
+ expect(findSimpleViewer().exists()).toBe(true);
});
describe('switchViewer()', () => {
it('switches to the passed viewer', async () => {
createComponent();
+ await waitForPromises();
+
+ findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, RichViewerMock.type);
+ await waitForPromises();
- wrapper.vm.switchViewer(RichViewerMock.type);
+ expect(findBlobHeader().props('activeViewerType')).toBe(RichViewerMock.type);
+ expect(findRichViewer().exists()).toBe(true);
- await nextTick();
- expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type);
- expect(wrapper.findComponent(RichViewer).exists()).toBe(true);
+ findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, SimpleViewerMock.type);
+ await waitForPromises();
- await wrapper.vm.switchViewer(SimpleViewerMock.type);
- expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type);
- expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true);
+ expect(findBlobHeader().props('activeViewerType')).toBe(SimpleViewerMock.type);
+ expect(findSimpleViewer().exists()).toBe(true);
});
});
});
@@ -209,28 +245,32 @@ describe('Blob Embeddable', () => {
window.location.hash = '#last-headline';
});
- it('renders rich viewer by default', () => {
+ it('renders rich viewer by default', async () => {
createComponent({
data: {},
});
+ await waitForPromises();
- expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type);
- expect(wrapper.findComponent(RichViewer).exists()).toBe(true);
+ expect(findBlobHeader().props('activeViewerType')).toBe(RichViewerMock.type);
+ expect(findRichViewer().exists()).toBe(true);
});
describe('switchViewer()', () => {
it('switches to the passed viewer', async () => {
createComponent();
+ await waitForPromises();
- wrapper.vm.switchViewer(SimpleViewerMock.type);
+ findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, SimpleViewerMock.type);
+ await waitForPromises();
- await nextTick();
- expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type);
- expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true);
+ expect(findBlobHeader().props('activeViewerType')).toBe(SimpleViewerMock.type);
+ expect(findSimpleViewer().exists()).toBe(true);
- await wrapper.vm.switchViewer(RichViewerMock.type);
- expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type);
- expect(wrapper.findComponent(RichViewer).exists()).toBe(true);
+ findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, RichViewerMock.type);
+ await waitForPromises();
+
+ expect(findBlobHeader().props('activeViewerType')).toBe(RichViewerMock.type);
+ expect(findRichViewer().exists()).toBe(true);
});
});
});
@@ -239,19 +279,21 @@ describe('Blob Embeddable', () => {
describe('functionality', () => {
describe('render error', () => {
- const findContentEl = () => wrapper.findComponent(BlobContent);
-
it('correctly sets blob on the blob-content-error component', () => {
createComponent();
- expect(findContentEl().props('blob')).toEqual(BlobMock);
+ expect(findBlobContent().props('blob')).toEqual(BlobMock);
});
- it(`refetches blob content on ${BLOB_RENDER_EVENT_LOAD} event`, () => {
+ it(`refetches blob content on ${BLOB_RENDER_EVENT_LOAD} event`, async () => {
createComponent();
+ await waitForPromises();
+
+ expect(requestHandlers).toHaveBeenCalledTimes(1);
+
+ findBlobContent().vm.$emit(BLOB_RENDER_EVENT_LOAD);
+ await waitForPromises();
- expect(wrapper.vm.$apollo.queries.blobContent.refetch).not.toHaveBeenCalled();
- findContentEl().vm.$emit(BLOB_RENDER_EVENT_LOAD);
- expect(wrapper.vm.$apollo.queries.blobContent.refetch).toHaveBeenCalledTimes(1);
+ expect(requestHandlers).toHaveBeenCalledTimes(2);
});
it(`sets '${SimpleViewerMock.type}' as active on ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => {
@@ -261,7 +303,7 @@ describe('Blob Embeddable', () => {
},
});
- findContentEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE);
+ findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE);
expect(wrapper.vm.activeViewerType).toEqual(SimpleViewerMock.type);
});
});
diff --git a/spec/frontend/snippets/components/snippet_description_edit_spec.js b/spec/frontend/snippets/components/snippet_description_edit_spec.js
index ff75515e71a..2b42eba19c2 100644
--- a/spec/frontend/snippets/components/snippet_description_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_description_edit_spec.js
@@ -30,10 +30,6 @@ describe('Snippet Description Edit component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('rendering', () => {
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
diff --git a/spec/frontend/snippets/components/snippet_description_view_spec.js b/spec/frontend/snippets/components/snippet_description_view_spec.js
index 14f116f2aaf..3c5d50ccaa6 100644
--- a/spec/frontend/snippets/components/snippet_description_view_spec.js
+++ b/spec/frontend/snippets/components/snippet_description_view_spec.js
@@ -17,10 +17,6 @@ describe('Snippet Description component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js
index c930c9f635b..4bf64bfd3cd 100644
--- a/spec/frontend/snippets/components/snippet_header_spec.js
+++ b/spec/frontend/snippets/components/snippet_header_spec.js
@@ -1,8 +1,9 @@
-import { GlButton, GlModal, GlDropdown } from '@gitlab/ui';
+import { GlModal, GlButton, GlDropdown } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { ApolloMutation } from 'vue-apollo';
+import VueApollo from 'vue-apollo';
import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { Blob, BinaryBlob } from 'jest/blob/components/mock_data';
@@ -10,31 +11,41 @@ import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
import SnippetHeader, { i18n } from '~/snippets/components/snippet_header.vue';
import DeleteSnippetMutation from '~/snippets/mutations/delete_snippet.mutation.graphql';
import axios from '~/lib/utils/axios_utils';
-import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/alert';
+import CanCreateProjectSnippet from 'shared_queries/snippet/project_permissions.query.graphql';
+import CanCreatePersonalSnippet from 'shared_queries/snippet/user_permissions.query.graphql';
+import { getCanCreateProjectSnippetMock, getCanCreatePersonalSnippetMock } from '../mock_data';
-jest.mock('~/flash');
+const ERROR_MSG = 'Foo bar';
+const ERR = { message: ERROR_MSG };
+
+const MUTATION_TYPES = {
+ RESOLVE: jest.fn().mockResolvedValue({ data: { destroySnippet: { errors: [] } } }),
+ REJECT: jest.fn().mockRejectedValue(ERR),
+};
+
+jest.mock('~/alert');
+
+Vue.use(VueApollo);
describe('Snippet header component', () => {
let wrapper;
let snippet;
- let mutationTypes;
- let mutationVariables;
let mock;
+ let mockApollo;
- let errorMsg;
- let err;
- const originalRelativeUrlRoot = gon.relative_url_root;
const reportAbusePath = '/-/snippets/42/mark_as_spam';
const canReportSpam = true;
const GlEmoji = { template: '<img/>' };
function createComponent({
- loading = false,
permissions = {},
- mutationRes = mutationTypes.RESOLVE,
snippetProps = {},
provide = {},
+ canCreateProjectSnippetMock = jest.fn().mockResolvedValue(getCanCreateProjectSnippetMock()),
+ canCreatePersonalSnippetMock = jest.fn().mockResolvedValue(getCanCreatePersonalSnippetMock()),
+ deleteSnippetMock = MUTATION_TYPES.RESOLVE,
} = {}) {
const defaultProps = Object.assign(snippet, snippetProps);
if (permissions) {
@@ -42,17 +53,14 @@ describe('Snippet header component', () => {
...permissions,
});
}
- const $apollo = {
- queries: {
- canCreateSnippet: {
- loading,
- },
- },
- mutate: mutationRes,
- };
+
+ mockApollo = createMockApollo([
+ [CanCreateProjectSnippet, canCreateProjectSnippetMock],
+ [CanCreatePersonalSnippet, canCreatePersonalSnippetMock],
+ [DeleteSnippetMutation, deleteSnippetMock],
+ ]);
wrapper = mount(SnippetHeader, {
- mocks: { $apollo },
provide: {
reportAbusePath,
canReportSpam,
@@ -64,9 +72,9 @@ describe('Snippet header component', () => {
},
},
stubs: {
- ApolloMutation,
GlEmoji,
},
+ apolloProvider: mockApollo,
});
}
@@ -91,6 +99,7 @@ describe('Snippet header component', () => {
title: x.attributes('title'),
text: x.text(),
}));
+ const findDeleteModal = () => wrapper.findComponent(GlModal);
beforeEach(() => {
gon.relative_url_root = '/foo/';
@@ -113,28 +122,12 @@ describe('Snippet header component', () => {
createdAt: new Date(differenceInMilliseconds(32 * 24 * 3600 * 1000)).toISOString(),
};
- mutationVariables = {
- mutation: DeleteSnippetMutation,
- variables: {
- id: snippet.id,
- },
- };
-
- errorMsg = 'Foo bar';
- err = { message: errorMsg };
-
- mutationTypes = {
- RESOLVE: jest.fn(() => Promise.resolve({ data: { destroySnippet: { errors: [] } } })),
- REJECT: jest.fn(() => Promise.reject(err)),
- };
-
mock = new MockAdapter(axios);
});
afterEach(() => {
- wrapper.destroy();
+ mockApollo = null;
mock.restore();
- gon.relative_url_root = originalRelativeUrlRoot;
});
it('renders itself', () => {
@@ -238,15 +231,16 @@ describe('Snippet header component', () => {
});
it('with canCreateSnippet permission, renders create button', async () => {
- createComponent();
+ createComponent({
+ canCreateProjectSnippetMock: jest
+ .fn()
+ .mockResolvedValue(getCanCreateProjectSnippetMock(true)),
+ canCreatePersonalSnippetMock: jest
+ .fn()
+ .mockResolvedValue(getCanCreatePersonalSnippetMock(true)),
+ });
- // TODO: we should avoid `wrapper.setData` since they
- // are component internals. Let's use the apollo mock helpers
- // in a follow-up.
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ canCreateSnippet: true });
- await nextTick();
+ await waitForPromises();
expect(findButtonsAsModel()).toEqual(
expect.arrayContaining([
@@ -262,7 +256,7 @@ describe('Snippet header component', () => {
});
describe('submit snippet as spam', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
});
@@ -271,7 +265,7 @@ describe('Snippet header component', () => {
${200} | ${VARIANT_SUCCESS} | ${i18n.snippetSpamSuccess}
${500} | ${VARIANT_DANGER} | ${i18n.snippetSpamFailure}
`(
- 'renders a "$variant" flash message with "$text" message for a request with a "$request" response',
+ 'renders a "$variant" alert message with "$text" message for a request with a "$request" response',
async ({ request, variant, text }) => {
const submitAsSpamBtn = findButtons().at(2);
mock.onPost(reportAbusePath).reply(request);
@@ -329,21 +323,37 @@ describe('Snippet header component', () => {
});
describe('Delete mutation', () => {
- it('dispatches a mutation to delete the snippet with correct variables', () => {
+ const deleteSnippet = async () => {
+ // Click delete action
+ findButtons().at(1).trigger('click');
+ await nextTick();
+
+ expect(findDeleteModal().props().visible).toBe(true);
+
+ // Click delete button in delete modal
+ document.querySelector('[data-testid="delete-snippet"').click();
+ await waitForPromises();
+ };
+
+ it('dispatches a mutation to delete the snippet with correct variables', async () => {
createComponent();
- wrapper.vm.deleteSnippet();
- expect(mutationTypes.RESOLVE).toHaveBeenCalledWith(mutationVariables);
+
+ await deleteSnippet();
+
+ expect(MUTATION_TYPES.RESOLVE).toHaveBeenCalledWith({
+ id: snippet.id,
+ });
});
it('sets error message if mutation fails', async () => {
- createComponent({ mutationRes: mutationTypes.REJECT });
+ createComponent({ deleteSnippetMock: MUTATION_TYPES.REJECT });
expect(Boolean(wrapper.vm.errorMessage)).toBe(false);
- wrapper.vm.deleteSnippet();
-
- await waitForPromises();
+ await deleteSnippet();
- expect(wrapper.vm.errorMessage).toEqual(errorMsg);
+ expect(document.querySelector('[data-testid="delete-alert"').textContent.trim()).toBe(
+ ERROR_MSG,
+ );
});
describe('in case of successful mutation, closes modal and redirects to correct listing', () => {
@@ -353,15 +363,16 @@ describe('Snippet header component', () => {
createComponent({
snippetProps,
});
- wrapper.vm.closeDeleteModal = jest.fn();
- wrapper.vm.deleteSnippet();
- await nextTick();
+ await deleteSnippet();
};
it('redirects to dashboard/snippets for personal snippet', async () => {
await createDeleteSnippet();
- expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
+
+ // Check that the modal is hidden after deleting the snippet
+ expect(findDeleteModal().props().visible).toBe(false);
+
expect(window.location.pathname).toBe(`${gon.relative_url_root}dashboard/snippets`);
});
@@ -372,7 +383,10 @@ describe('Snippet header component', () => {
fullPath,
},
});
- expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
+
+ // Check that the modal is hidden after deleting the snippet
+ expect(findDeleteModal().props().visible).toBe(false);
+
expect(window.location.pathname).toBe(`${fullPath}/-/snippets`);
});
});
diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js
index 7c40735d64e..0a3b57c9244 100644
--- a/spec/frontend/snippets/components/snippet_title_spec.js
+++ b/spec/frontend/snippets/components/snippet_title_spec.js
@@ -26,10 +26,6 @@ describe('Snippet header component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders itself', () => {
createComponent();
expect(wrapper.find('.snippet-header').exists()).toBe(true);
diff --git a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js
index 29eb002ef4a..70eb719f706 100644
--- a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js
@@ -51,10 +51,6 @@ describe('Snippet Visibility Edit component', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('rendering', () => {
it('matches the snapshot', () => {
createComponent();
diff --git a/spec/frontend/snippets/mock_data.js b/spec/frontend/snippets/mock_data.js
new file mode 100644
index 00000000000..7546fa575c6
--- /dev/null
+++ b/spec/frontend/snippets/mock_data.js
@@ -0,0 +1,19 @@
+export const getCanCreateProjectSnippetMock = (createSnippet = false) => ({
+ data: {
+ project: {
+ userPermissions: {
+ createSnippet,
+ },
+ },
+ },
+});
+
+export const getCanCreatePersonalSnippetMock = (createSnippet = false) => ({
+ data: {
+ currentUser: {
+ userPermissions: {
+ createSnippet,
+ },
+ },
+ },
+});
diff --git a/spec/frontend/streaming/chunk_writer_spec.js b/spec/frontend/streaming/chunk_writer_spec.js
new file mode 100644
index 00000000000..2aadb332838
--- /dev/null
+++ b/spec/frontend/streaming/chunk_writer_spec.js
@@ -0,0 +1,214 @@
+import { ChunkWriter } from '~/streaming/chunk_writer';
+import { RenderBalancer } from '~/streaming/render_balancer';
+
+jest.mock('~/streaming/render_balancer');
+
+describe('ChunkWriter', () => {
+ let accumulator = '';
+ let write;
+ let close;
+ let abort;
+ let config;
+ let render;
+
+ const createChunk = (text) => {
+ const encoder = new TextEncoder();
+ return encoder.encode(text);
+ };
+
+ const createHtmlStream = () => {
+ write = jest.fn((part) => {
+ accumulator += part;
+ });
+ close = jest.fn();
+ abort = jest.fn();
+ return {
+ write,
+ close,
+ abort,
+ };
+ };
+
+ const createWriter = () => {
+ return new ChunkWriter(createHtmlStream(), config);
+ };
+
+ const pushChunks = (...chunks) => {
+ const writer = createWriter();
+ chunks.forEach((chunk) => {
+ writer.write(createChunk(chunk));
+ });
+ writer.close();
+ };
+
+ afterAll(() => {
+ global.JEST_DEBOUNCE_THROTTLE_TIMEOUT = undefined;
+ });
+
+ beforeEach(() => {
+ global.JEST_DEBOUNCE_THROTTLE_TIMEOUT = 100;
+ accumulator = '';
+ config = undefined;
+ render = jest.fn((cb) => {
+ while (cb()) {
+ // render until 'false'
+ }
+ });
+ RenderBalancer.mockImplementation(() => ({ render }));
+ });
+
+ describe('when chunk length must be "1"', () => {
+ beforeEach(() => {
+ config = { minChunkSize: 1, maxChunkSize: 1 };
+ });
+
+ it('splits big chunks into smaller ones', () => {
+ const text = 'foobar';
+ pushChunks(text);
+ expect(accumulator).toBe(text);
+ expect(write).toHaveBeenCalledTimes(text.length);
+ });
+
+ it('handles small emoji chunks', () => {
+ const text = 'foo👀bar👨‍👩‍👧baz👧👧🏻👧🏼👧🏽👧🏾👧🏿';
+ pushChunks(text);
+ expect(accumulator).toBe(text);
+ expect(write).toHaveBeenCalledTimes(createChunk(text).length);
+ });
+ });
+
+ describe('when chunk length must not be lower than "5" and exceed "10"', () => {
+ beforeEach(() => {
+ config = { minChunkSize: 5, maxChunkSize: 10 };
+ });
+
+ it('joins small chunks', () => {
+ const text = '12345';
+ pushChunks(...text.split(''));
+ expect(accumulator).toBe(text);
+ expect(write).toHaveBeenCalledTimes(1);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles overflow with small chunks', () => {
+ const text = '123456789';
+ pushChunks(...text.split(''));
+ expect(accumulator).toBe(text);
+ expect(write).toHaveBeenCalledTimes(2);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls flush on small chunks', () => {
+ global.JEST_DEBOUNCE_THROTTLE_TIMEOUT = undefined;
+ const flushAccumulator = jest.spyOn(ChunkWriter.prototype, 'flushAccumulator');
+ const text = '1';
+ pushChunks(text);
+ expect(accumulator).toBe(text);
+ expect(flushAccumulator).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls flush on large chunks', () => {
+ const flushAccumulator = jest.spyOn(ChunkWriter.prototype, 'flushAccumulator');
+ const text = '1234567890123';
+ const writer = createWriter();
+ writer.write(createChunk(text));
+ jest.runAllTimers();
+ expect(accumulator).toBe(text);
+ expect(flushAccumulator).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('chunk balancing', () => {
+ let increase;
+ let decrease;
+ let renderOnce;
+
+ beforeEach(() => {
+ render = jest.fn((cb) => {
+ let next = true;
+ renderOnce = () => {
+ if (!next) return;
+ next = cb();
+ };
+ });
+ RenderBalancer.mockImplementation(({ increase: inc, decrease: dec }) => {
+ increase = jest.fn(inc);
+ decrease = jest.fn(dec);
+ return {
+ render,
+ };
+ });
+ });
+
+ describe('when frame time exceeds low limit', () => {
+ beforeEach(() => {
+ config = {
+ minChunkSize: 1,
+ maxChunkSize: 5,
+ balanceRate: 10,
+ };
+ });
+
+ it('increases chunk size', () => {
+ const text = '111222223';
+ const writer = createWriter();
+ const chunk = createChunk(text);
+
+ writer.write(chunk);
+
+ renderOnce();
+ increase();
+ renderOnce();
+ renderOnce();
+
+ writer.close();
+
+ expect(accumulator).toBe(text);
+ expect(write.mock.calls).toMatchObject([['111'], ['22222'], ['3']]);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when frame time exceeds high limit', () => {
+ beforeEach(() => {
+ config = {
+ minChunkSize: 1,
+ maxChunkSize: 10,
+ balanceRate: 2,
+ };
+ });
+
+ it('decreases chunk size', () => {
+ const text = '1111112223345';
+ const writer = createWriter();
+ const chunk = createChunk(text);
+
+ writer.write(chunk);
+
+ renderOnce();
+ decrease();
+
+ renderOnce();
+ decrease();
+
+ renderOnce();
+ decrease();
+
+ renderOnce();
+ renderOnce();
+
+ writer.close();
+
+ expect(accumulator).toBe(text);
+ expect(write.mock.calls).toMatchObject([['111111'], ['222'], ['33'], ['4'], ['5']]);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ it('calls abort on htmlStream', () => {
+ const writer = createWriter();
+ writer.abort();
+ expect(abort).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/spec/frontend/streaming/handle_streamed_anchor_link_spec.js b/spec/frontend/streaming/handle_streamed_anchor_link_spec.js
new file mode 100644
index 00000000000..ef17957b2fc
--- /dev/null
+++ b/spec/frontend/streaming/handle_streamed_anchor_link_spec.js
@@ -0,0 +1,132 @@
+import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
+import waitForPromises from 'helpers/wait_for_promises';
+import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import LineHighlighter from '~/blob/line_highlighter';
+import { TEST_HOST } from 'spec/test_constants';
+
+jest.mock('~/lib/utils/common_utils');
+jest.mock('~/blob/line_highlighter');
+
+describe('handleStreamedAnchorLink', () => {
+ const ANCHOR_START = 'L100';
+ const ANCHOR_END = '300';
+ const findRoot = () => document.querySelector('#root');
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ describe('when single line anchor is given', () => {
+ beforeEach(() => {
+ delete window.location;
+ window.location = new URL(`${TEST_HOST}#${ANCHOR_START}`);
+ });
+
+ describe('when element is present', () => {
+ beforeEach(() => {
+ setHTMLFixture(`<div id="root"><div id="${ANCHOR_START}"></div></div>`);
+ handleStreamedAnchorLink(findRoot());
+ });
+
+ it('does nothing', async () => {
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when element is streamed', () => {
+ let stop;
+ const insertElement = () => {
+ findRoot().insertAdjacentHTML('afterbegin', `<div id="${ANCHOR_START}"></div>`);
+ };
+
+ beforeEach(() => {
+ setHTMLFixture('<div id="root"></div>');
+ stop = handleStreamedAnchorLink(findRoot());
+ });
+
+ afterEach(() => {
+ stop = undefined;
+ });
+
+ it('scrolls to the anchor when inserted', async () => {
+ insertElement();
+ await waitForPromises();
+ expect(scrollToElement).toHaveBeenCalledTimes(1);
+ expect(LineHighlighter).toHaveBeenCalledTimes(1);
+ });
+
+ it("doesn't scroll to the anchor when destroyed", async () => {
+ stop();
+ insertElement();
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when line range anchor is given', () => {
+ beforeEach(() => {
+ delete window.location;
+ window.location = new URL(`${TEST_HOST}#${ANCHOR_START}-${ANCHOR_END}`);
+ });
+
+ describe('when last element is present', () => {
+ beforeEach(() => {
+ setHTMLFixture(`<div id="root"><div id="L${ANCHOR_END}"></div></div>`);
+ handleStreamedAnchorLink(findRoot());
+ });
+
+ it('does nothing', async () => {
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when last element is streamed', () => {
+ let stop;
+ const insertElement = () => {
+ findRoot().insertAdjacentHTML(
+ 'afterbegin',
+ `<div id="${ANCHOR_START}"></div><div id="L${ANCHOR_END}"></div>`,
+ );
+ };
+
+ beforeEach(() => {
+ setHTMLFixture('<div id="root"></div>');
+ stop = handleStreamedAnchorLink(findRoot());
+ });
+
+ afterEach(() => {
+ stop = undefined;
+ });
+
+ it('scrolls to the anchor when inserted', async () => {
+ insertElement();
+ await waitForPromises();
+ expect(scrollToElement).toHaveBeenCalledTimes(1);
+ expect(LineHighlighter).toHaveBeenCalledTimes(1);
+ });
+
+ it("doesn't scroll to the anchor when destroyed", async () => {
+ stop();
+ insertElement();
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when anchor is not given', () => {
+ beforeEach(() => {
+ setHTMLFixture(`<div id="root"></div>`);
+ handleStreamedAnchorLink(findRoot());
+ });
+
+ it('does nothing', async () => {
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/streaming/html_stream_spec.js b/spec/frontend/streaming/html_stream_spec.js
new file mode 100644
index 00000000000..115a9ddc803
--- /dev/null
+++ b/spec/frontend/streaming/html_stream_spec.js
@@ -0,0 +1,46 @@
+import { HtmlStream } from '~/streaming/html_stream';
+import { ChunkWriter } from '~/streaming/chunk_writer';
+
+jest.mock('~/streaming/chunk_writer');
+
+describe('HtmlStream', () => {
+ let write;
+ let close;
+ let streamingElement;
+
+ beforeEach(() => {
+ write = jest.fn();
+ close = jest.fn();
+ jest.spyOn(Document.prototype, 'write').mockImplementation(write);
+ jest.spyOn(Document.prototype, 'close').mockImplementation(close);
+ jest.spyOn(Document.prototype, 'querySelector').mockImplementation(() => {
+ streamingElement = document.createElement('div');
+ return streamingElement;
+ });
+ });
+
+ it('attaches to original document', () => {
+ // eslint-disable-next-line no-new
+ new HtmlStream(document.body);
+ expect(document.body.contains(streamingElement)).toBe(true);
+ });
+
+ it('can write to a document', () => {
+ const htmlStream = new HtmlStream(document.body);
+ htmlStream.write('foo');
+ htmlStream.close();
+ expect(write.mock.calls).toEqual([['<streaming-element>'], ['foo'], ['</streaming-element>']]);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns chunked writer', () => {
+ const htmlStream = new HtmlStream(document.body).withChunkWriter();
+ expect(htmlStream).toBeInstanceOf(ChunkWriter);
+ });
+
+ it('closes on abort', () => {
+ const htmlStream = new HtmlStream(document.body);
+ htmlStream.abort();
+ expect(close).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/streaming/rate_limit_stream_requests_spec.js b/spec/frontend/streaming/rate_limit_stream_requests_spec.js
new file mode 100644
index 00000000000..02e3cf93014
--- /dev/null
+++ b/spec/frontend/streaming/rate_limit_stream_requests_spec.js
@@ -0,0 +1,155 @@
+import waitForPromises from 'helpers/wait_for_promises';
+import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests';
+
+describe('rateLimitStreamRequests', () => {
+ const encoder = new TextEncoder('utf-8');
+ const createStreamResponse = (content = 'foo') =>
+ new ReadableStream({
+ pull(controller) {
+ controller.enqueue(encoder.encode(content));
+ controller.close();
+ },
+ });
+
+ const createFactory = (content) => {
+ return jest.fn(() => {
+ return Promise.resolve(createStreamResponse(content));
+ });
+ };
+
+ it('does nothing for zero total requests', () => {
+ const factory = jest.fn();
+ const requests = rateLimitStreamRequests({
+ factory,
+ total: 0,
+ });
+ expect(factory).toHaveBeenCalledTimes(0);
+ expect(requests.length).toBe(0);
+ });
+
+ it('does not exceed total requests', () => {
+ const factory = createFactory();
+ const requests = rateLimitStreamRequests({
+ factory,
+ immediateCount: 100,
+ maxConcurrentRequests: 100,
+ total: 2,
+ });
+ expect(factory).toHaveBeenCalledTimes(2);
+ expect(requests.length).toBe(2);
+ });
+
+ it('creates immediate requests', () => {
+ const factory = createFactory();
+ const requests = rateLimitStreamRequests({
+ factory,
+ maxConcurrentRequests: 2,
+ total: 2,
+ });
+ expect(factory).toHaveBeenCalledTimes(2);
+ expect(requests.length).toBe(2);
+ });
+
+ it('returns correct values', async () => {
+ const fixture = 'foobar';
+ const factory = createFactory(fixture);
+ const requests = rateLimitStreamRequests({
+ factory,
+ maxConcurrentRequests: 2,
+ total: 2,
+ });
+
+ const decoder = new TextDecoder('utf-8');
+ let result = '';
+ for await (const stream of requests) {
+ await stream.pipeTo(
+ new WritableStream({
+ // eslint-disable-next-line no-loop-func
+ write(content) {
+ result += decoder.decode(content);
+ },
+ }),
+ );
+ }
+
+ expect(result).toBe(fixture + fixture);
+ });
+
+ it('delays rate limited requests', async () => {
+ const factory = createFactory();
+ const requests = rateLimitStreamRequests({
+ factory,
+ maxConcurrentRequests: 2,
+ total: 3,
+ });
+ expect(factory).toHaveBeenCalledTimes(2);
+ expect(requests.length).toBe(3);
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(3);
+ });
+
+ it('runs next request after previous has been fulfilled', async () => {
+ let res;
+ const factory = jest
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ res = resolve;
+ }),
+ )
+ .mockImplementationOnce(() => Promise.resolve(createStreamResponse()));
+ const requests = rateLimitStreamRequests({
+ factory,
+ maxConcurrentRequests: 1,
+ total: 2,
+ });
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(requests.length).toBe(2);
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(1);
+
+ res(createStreamResponse());
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(2);
+ });
+
+ it('uses timer to schedule next request', async () => {
+ let res;
+ const factory = jest
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ res = resolve;
+ }),
+ )
+ .mockImplementationOnce(() => Promise.resolve(createStreamResponse()));
+ const requests = rateLimitStreamRequests({
+ factory,
+ immediateCount: 1,
+ maxConcurrentRequests: 2,
+ total: 2,
+ timeout: 9999,
+ });
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(requests.length).toBe(2);
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(1);
+
+ jest.runAllTimers();
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(2);
+ res(createStreamResponse());
+ });
+});
diff --git a/spec/frontend/streaming/render_balancer_spec.js b/spec/frontend/streaming/render_balancer_spec.js
new file mode 100644
index 00000000000..dae0c98d678
--- /dev/null
+++ b/spec/frontend/streaming/render_balancer_spec.js
@@ -0,0 +1,69 @@
+import { RenderBalancer } from '~/streaming/render_balancer';
+
+const HIGH_FRAME_TIME = 100;
+const LOW_FRAME_TIME = 10;
+
+describe('renderBalancer', () => {
+ let frameTime = 0;
+ let frameTimeDelta = 0;
+ let decrease;
+ let increase;
+
+ const createBalancer = () => {
+ decrease = jest.fn();
+ increase = jest.fn();
+ return new RenderBalancer({
+ highFrameTime: HIGH_FRAME_TIME,
+ lowFrameTime: LOW_FRAME_TIME,
+ increase,
+ decrease,
+ });
+ };
+
+ const renderTimes = (times) => {
+ const balancer = createBalancer();
+ return new Promise((resolve) => {
+ let counter = 0;
+ balancer.render(() => {
+ if (counter === times) {
+ resolve(counter);
+ return false;
+ }
+ counter += 1;
+ return true;
+ });
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+ frameTime += frameTimeDelta;
+ cb(frameTime);
+ });
+ });
+
+ afterEach(() => {
+ window.requestAnimationFrame.mockRestore();
+ frameTime = 0;
+ frameTimeDelta = 0;
+ });
+
+ it('renders in a loop', async () => {
+ const count = await renderTimes(5);
+ expect(count).toBe(5);
+ });
+
+ it('calls decrease', async () => {
+ frameTimeDelta = 200;
+ await renderTimes(5);
+ expect(decrease).toHaveBeenCalled();
+ expect(increase).not.toHaveBeenCalled();
+ });
+
+ it('calls increase', async () => {
+ frameTimeDelta = 1;
+ await renderTimes(5);
+ expect(increase).toHaveBeenCalled();
+ expect(decrease).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/streaming/render_html_streams_spec.js b/spec/frontend/streaming/render_html_streams_spec.js
new file mode 100644
index 00000000000..55cef0ea469
--- /dev/null
+++ b/spec/frontend/streaming/render_html_streams_spec.js
@@ -0,0 +1,96 @@
+import { ReadableStream } from 'node:stream/web';
+import { renderHtmlStreams } from '~/streaming/render_html_streams';
+import { HtmlStream } from '~/streaming/html_stream';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('~/streaming/html_stream');
+jest.mock('~/streaming/constants', () => {
+ return {
+ HIGH_FRAME_TIME: 0,
+ LOW_FRAME_TIME: 0,
+ MAX_CHUNK_SIZE: 1,
+ MIN_CHUNK_SIZE: 1,
+ };
+});
+
+const firstStreamContent = 'foobar';
+const secondStreamContent = 'bazqux';
+
+describe('renderHtmlStreams', () => {
+ let htmlWriter;
+ const encoder = new TextEncoder();
+ const createSingleChunkStream = (chunk) => {
+ const encoded = encoder.encode(chunk);
+ const stream = new ReadableStream({
+ pull(controller) {
+ controller.enqueue(encoded);
+ controller.close();
+ },
+ });
+ return [stream, encoded];
+ };
+
+ beforeEach(() => {
+ htmlWriter = {
+ write: jest.fn(),
+ close: jest.fn(),
+ abort: jest.fn(),
+ };
+ jest.spyOn(HtmlStream.prototype, 'withChunkWriter').mockReturnValue(htmlWriter);
+ });
+
+ it('renders a single stream', async () => {
+ const [stream, encoded] = createSingleChunkStream(firstStreamContent);
+
+ await renderHtmlStreams([Promise.resolve(stream)], document.body);
+
+ expect(htmlWriter.write).toHaveBeenCalledWith(encoded);
+ expect(htmlWriter.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders stream sequence', async () => {
+ const [stream1, encoded1] = createSingleChunkStream(firstStreamContent);
+ const [stream2, encoded2] = createSingleChunkStream(secondStreamContent);
+
+ await renderHtmlStreams([Promise.resolve(stream1), Promise.resolve(stream2)], document.body);
+
+ expect(htmlWriter.write.mock.calls).toMatchObject([[encoded1], [encoded2]]);
+ expect(htmlWriter.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("doesn't wait for the whole sequence to resolve before streaming", async () => {
+ const [stream1, encoded1] = createSingleChunkStream(firstStreamContent);
+ const [stream2, encoded2] = createSingleChunkStream(secondStreamContent);
+
+ let res;
+ const delayedStream = new Promise((resolve) => {
+ res = resolve;
+ });
+
+ renderHtmlStreams([Promise.resolve(stream1), delayedStream], document.body);
+
+ await waitForPromises();
+
+ expect(htmlWriter.write.mock.calls).toMatchObject([[encoded1]]);
+ expect(htmlWriter.close).toHaveBeenCalledTimes(0);
+
+ res(stream2);
+ await waitForPromises();
+
+ expect(htmlWriter.write.mock.calls).toMatchObject([[encoded1], [encoded2]]);
+ expect(htmlWriter.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('closes HtmlStream on error', async () => {
+ const [stream1] = createSingleChunkStream(firstStreamContent);
+ const error = new Error();
+
+ try {
+ await renderHtmlStreams([Promise.resolve(stream1), Promise.reject(error)], document.body);
+ } catch (err) {
+ expect(err).toBe(error);
+ }
+
+ expect(htmlWriter.abort).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/context_switcher_spec.js b/spec/frontend/super_sidebar/components/context_switcher_spec.js
new file mode 100644
index 00000000000..7928ee6400c
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/context_switcher_spec.js
@@ -0,0 +1,309 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { s__ } from '~/locale';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue';
+import ContextSwitcherToggle from '~/super_sidebar/components/context_switcher_toggle.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import ProjectsList from '~/super_sidebar/components/projects_list.vue';
+import GroupsList from '~/super_sidebar/components/groups_list.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import searchUserProjectsAndGroupsQuery from '~/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql';
+import { trackContextAccess, formatContextSwitcherItems } from '~/super_sidebar/utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import { stubComponent } from 'helpers/stub_component';
+import { searchUserProjectsAndGroupsResponseMock } from '../mock_data';
+
+jest.mock('~/super_sidebar/utils', () => ({
+ getStorageKeyFor: jest.requireActual('~/super_sidebar/utils').getStorageKeyFor,
+ getTopFrequentItems: jest.requireActual('~/super_sidebar/utils').getTopFrequentItems,
+ formatContextSwitcherItems: jest.requireActual('~/super_sidebar/utils')
+ .formatContextSwitcherItems,
+ trackContextAccess: jest.fn(),
+}));
+const focusInputMock = jest.fn();
+
+const persistentLinks = [
+ { title: 'Explore', link: '/explore', icon: 'compass', link_classes: 'persistent-link-class' },
+];
+const username = 'root';
+const projectsPath = 'projectsPath';
+const groupsPath = 'groupsPath';
+const contextHeader = { avatar_shape: 'circle' };
+
+Vue.use(VueApollo);
+
+describe('ContextSwitcher component', () => {
+ let wrapper;
+ let mockApollo;
+
+ const findDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findContextSwitcherToggle = () => wrapper.findComponent(ContextSwitcherToggle);
+ const findNavItems = () => wrapper.findAllComponents(NavItem);
+ const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+ const findProjectsList = () => wrapper.findComponent(ProjectsList);
+ const findGroupsList = () => wrapper.findComponent(GroupsList);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ const triggerSearchQuery = async () => {
+ findSearchBox().vm.$emit('input', 'foo');
+ await nextTick();
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ return waitForPromises();
+ };
+
+ const searchUserProjectsAndGroupsHandlerSuccess = jest
+ .fn()
+ .mockResolvedValue(searchUserProjectsAndGroupsResponseMock);
+
+ const createWrapper = ({ props = {}, requestHandlers = {} } = {}) => {
+ mockApollo = createMockApollo([
+ [
+ searchUserProjectsAndGroupsQuery,
+ requestHandlers.searchUserProjectsAndGroupsQueryHandler ??
+ searchUserProjectsAndGroupsHandlerSuccess,
+ ],
+ ]);
+
+ wrapper = shallowMountExtended(ContextSwitcher, {
+ apolloProvider: mockApollo,
+ propsData: {
+ persistentLinks,
+ username,
+ projectsPath,
+ groupsPath,
+ contextHeader,
+ ...props,
+ },
+ stubs: {
+ GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
+ template: `
+ <div>
+ <slot name="toggle" />
+ <slot />
+ </div>
+ `,
+ }),
+ GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
+ props: ['placeholder'],
+ methods: { focusInput: focusInputMock },
+ }),
+ ProjectsList: stubComponent(ProjectsList, {
+ props: ['username', 'viewAllLink', 'isSearch', 'searchResults'],
+ }),
+ GroupsList: stubComponent(GroupsList, {
+ props: ['username', 'viewAllLink', 'isSearch', 'searchResults'],
+ }),
+ },
+ });
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders the persistent links', () => {
+ const navItems = findNavItems();
+ const firstNavItem = navItems.at(0);
+
+ expect(navItems.length).toBe(persistentLinks.length);
+ expect(firstNavItem.props('item')).toBe(persistentLinks[0]);
+ expect(firstNavItem.props('linkClasses')).toEqual({
+ [persistentLinks[0].link_classes]: persistentLinks[0].link_classes,
+ });
+ });
+
+ it('passes the placeholder to the search box', () => {
+ expect(findSearchBox().props('placeholder')).toBe(
+ s__('Navigation|Search your projects or groups'),
+ );
+ });
+
+ it('passes the correct props to the frequent projects list', () => {
+ expect(findProjectsList().props()).toEqual({
+ username,
+ viewAllLink: projectsPath,
+ isSearch: false,
+ searchResults: [],
+ });
+ });
+
+ it('passes the correct props to the frequent groups list', () => {
+ expect(findGroupsList().props()).toEqual({
+ username,
+ viewAllLink: groupsPath,
+ isSearch: false,
+ searchResults: [],
+ });
+ });
+
+ it('does not trigger the search query on mount', () => {
+ expect(searchUserProjectsAndGroupsHandlerSuccess).not.toHaveBeenCalled();
+ });
+
+ it('shows a loading spinner when search query is typed in', async () => {
+ findSearchBox().vm.$emit('input', 'foo');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('passes the correct props to the toggle', () => {
+ expect(findContextSwitcherToggle().props('context')).toEqual(contextHeader);
+ expect(findContextSwitcherToggle().props('expanded')).toEqual(false);
+ });
+
+ it("passes Popper.js' options to the disclosure dropdown", () => {
+ expect(findDisclosureDropdown().props('popperOptions')).toMatchObject({
+ modifiers: expect.any(Array),
+ });
+ });
+
+ it('does not emit the `toggle` event initially', () => {
+ expect(wrapper.emitted('toggle')).toBe(undefined);
+ });
+ });
+
+ describe('visibility changes', () => {
+ beforeEach(() => {
+ createWrapper();
+ findDisclosureDropdown().vm.$emit('shown');
+ });
+
+ it('emits the `toggle` event, focuses the search input and puts the toggle in the expanded state when opened', () => {
+ expect(wrapper.emitted('toggle')).toHaveLength(1);
+ expect(wrapper.emitted('toggle')[0]).toEqual([true]);
+ expect(focusInputMock).toHaveBeenCalledTimes(1);
+ expect(findContextSwitcherToggle().props('expanded')).toBe(true);
+ });
+
+ it("emits the `toggle` event, does not attempt to focus the input, and resets the toggle's `expanded` props to `false` when closed", async () => {
+ findDisclosureDropdown().vm.$emit('hidden');
+ await nextTick();
+
+ expect(wrapper.emitted('toggle')).toHaveLength(2);
+ expect(wrapper.emitted('toggle')[1]).toEqual([false]);
+ expect(focusInputMock).toHaveBeenCalledTimes(1);
+ expect(findContextSwitcherToggle().props('expanded')).toBe(false);
+ });
+ });
+
+ describe('item access tracking', () => {
+ it('does not track anything if not within a trackable context', () => {
+ createWrapper();
+
+ expect(trackContextAccess).not.toHaveBeenCalled();
+ });
+
+ it('tracks item access if within a trackable context', () => {
+ const currentContext = { namespace: 'groups' };
+ createWrapper({
+ props: {
+ currentContext,
+ },
+ });
+
+ expect(trackContextAccess).toHaveBeenCalledWith(username, currentContext);
+ });
+ });
+
+ describe('on search', () => {
+ beforeEach(() => {
+ createWrapper();
+ return triggerSearchQuery();
+ });
+
+ it('hides persistent links', () => {
+ expect(findNavItems().length).toBe(0);
+ });
+
+ it('triggers the search query on search', () => {
+ expect(searchUserProjectsAndGroupsHandlerSuccess).toHaveBeenCalled();
+ });
+
+ it('hides the loading spinner', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('passes the projects to the frequent projects list', () => {
+ expect(findProjectsList().props('isSearch')).toBe(true);
+ expect(findProjectsList().props('searchResults')).toEqual(
+ formatContextSwitcherItems(searchUserProjectsAndGroupsResponseMock.data.projects.nodes),
+ );
+ });
+
+ it('passes the groups to the frequent groups list', () => {
+ expect(findGroupsList().props('isSearch')).toBe(true);
+ expect(findGroupsList().props('searchResults')).toEqual(
+ formatContextSwitcherItems(searchUserProjectsAndGroupsResponseMock.data.user.groups.nodes),
+ );
+ });
+ });
+
+ describe('when search query does not match any items', () => {
+ beforeEach(() => {
+ createWrapper({
+ requestHandlers: {
+ searchUserProjectsAndGroupsQueryHandler: jest.fn().mockResolvedValue({
+ data: {
+ projects: {
+ nodes: [],
+ },
+ user: {
+ id: '1',
+ groups: {
+ nodes: [],
+ },
+ },
+ },
+ }),
+ },
+ });
+ return triggerSearchQuery();
+ });
+
+ it('passes empty results to the lists', () => {
+ expect(findProjectsList().props('isSearch')).toBe(true);
+ expect(findProjectsList().props('searchResults')).toEqual([]);
+ expect(findGroupsList().props('isSearch')).toBe(true);
+ expect(findGroupsList().props('searchResults')).toEqual([]);
+ });
+ });
+
+ describe('when search query fails', () => {
+ beforeEach(() => {
+ jest.spyOn(Sentry, 'captureException');
+ });
+
+ it('captures exception and shows an alert if response is formatted incorrectly', async () => {
+ createWrapper({
+ requestHandlers: {
+ searchUserProjectsAndGroupsQueryHandler: jest.fn().mockResolvedValue({
+ data: {},
+ }),
+ },
+ });
+ await triggerSearchQuery();
+
+ expect(Sentry.captureException).toHaveBeenCalled();
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('captures exception and shows an alert if query fails', async () => {
+ createWrapper({
+ requestHandlers: {
+ searchUserProjectsAndGroupsQueryHandler: jest.fn().mockRejectedValue(),
+ },
+ });
+ await triggerSearchQuery();
+
+ expect(Sentry.captureException).toHaveBeenCalled();
+ expect(findAlert().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/context_switcher_toggle_spec.js b/spec/frontend/super_sidebar/components/context_switcher_toggle_spec.js
new file mode 100644
index 00000000000..7172b60d0fa
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/context_switcher_toggle_spec.js
@@ -0,0 +1,50 @@
+import { GlAvatar } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ContextSwitcherToggle from '~/super_sidebar/components/context_switcher_toggle.vue';
+
+describe('ContextSwitcherToggle component', () => {
+ let wrapper;
+
+ const context = {
+ id: 1,
+ title: 'Title',
+ avatar: '/path/to/avatar.png',
+ };
+
+ const findGlAvatar = () => wrapper.getComponent(GlAvatar);
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMountExtended(ContextSwitcherToggle, {
+ propsData: {
+ context,
+ expanded: false,
+ ...props,
+ },
+ });
+ };
+
+ describe('with an avatar', () => {
+ it('passes the correct props to GlAvatar', () => {
+ createWrapper();
+ const avatar = findGlAvatar();
+
+ expect(avatar.props('shape')).toBe('rect');
+ expect(avatar.props('entityName')).toBe(context.title);
+ expect(avatar.props('entityId')).toBe(context.id);
+ expect(avatar.props('src')).toBe(context.avatar);
+ });
+
+ it('renders the avatar with a custom shape', () => {
+ const customShape = 'circle';
+ createWrapper({
+ context: {
+ ...context,
+ avatar_shape: customShape,
+ },
+ });
+ const avatar = findGlAvatar();
+
+ expect(avatar.props('shape')).toBe(customShape);
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/counter_spec.js b/spec/frontend/super_sidebar/components/counter_spec.js
index 8f514540413..77f77eae1c2 100644
--- a/spec/frontend/super_sidebar/components/counter_spec.js
+++ b/spec/frontend/super_sidebar/components/counter_spec.js
@@ -49,4 +49,15 @@ describe('Counter component', () => {
expect(findButton().exists()).toBe(false);
});
});
+
+ it.each([
+ ['99+', '99+'],
+ ['110%', '110%'],
+ [100, '99+'],
+ [10, '10'],
+ [0, ''],
+ ])('formats count %p as %p', (count, result) => {
+ createWrapper({ count });
+ expect(findButton().text()).toBe(result);
+ });
});
diff --git a/spec/frontend/super_sidebar/components/create_menu_spec.js b/spec/frontend/super_sidebar/components/create_menu_spec.js
index b24c6b8de7f..456085e23da 100644
--- a/spec/frontend/super_sidebar/components/create_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/create_menu_spec.js
@@ -1,5 +1,13 @@
-import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import {
+ GlDisclosureDropdown,
+ GlTooltip,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import { __ } from '~/locale';
import CreateMenu from '~/super_sidebar/components/create_menu.vue';
import { createNewMenuGroups } from '../mock_data';
@@ -8,13 +16,24 @@ describe('CreateMenu component', () => {
let wrapper;
const findGlDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findGlDisclosureDropdownGroups = () => wrapper.findAllComponents(GlDisclosureDropdownGroup);
+ const findGlDisclosureDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
+ const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger);
const findGlTooltip = () => wrapper.findComponent(GlTooltip);
+ const closeAndFocusMock = jest.fn();
+
const createWrapper = () => {
wrapper = shallowMountExtended(CreateMenu, {
propsData: {
groups: createNewMenuGroups,
},
+ stubs: {
+ InviteMembersTrigger,
+ GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
+ methods: { closeAndFocus: closeAndFocusMock },
+ }),
+ },
});
};
@@ -23,17 +42,61 @@ describe('CreateMenu component', () => {
createWrapper();
});
+ it('passes popper options to the dropdown', () => {
+ createWrapper();
+
+ expect(findGlDisclosureDropdown().props('popperOptions')).toEqual({
+ modifiers: [{ name: 'offset', options: { offset: [-147, 4] } }],
+ });
+ });
+
it("sets the toggle's label", () => {
expect(findGlDisclosureDropdown().props('toggleText')).toBe(__('Create new...'));
});
+ it('has correct amount of dropdown groups', () => {
+ const items = findGlDisclosureDropdownGroups();
- it('passes the groups to the disclosure dropdown', () => {
- expect(findGlDisclosureDropdown().props('items')).toBe(createNewMenuGroups);
+ expect(items.exists()).toBe(true);
+ expect(items).toHaveLength(createNewMenuGroups.length);
+ });
+
+ it('has correct amount of dropdown items', () => {
+ const items = findGlDisclosureDropdownItems();
+ const numberOfMenuItems = createNewMenuGroups
+ .map((group) => group.items.length)
+ .reduce((a, b) => a + b);
+
+ expect(items.exists()).toBe(true);
+ expect(items).toHaveLength(numberOfMenuItems);
+ });
+
+ it('renders the invite member trigger', () => {
+ expect(findInviteMembersTrigger().exists()).toBe(true);
});
it("sets the toggle ID and tooltip's target", () => {
expect(findGlDisclosureDropdown().props('toggleId')).toBe(wrapper.vm.$options.toggleId);
expect(findGlTooltip().props('target')).toBe(`#${wrapper.vm.$options.toggleId}`);
});
+
+ it('hides the tooltip when the dropdown is opened', async () => {
+ findGlDisclosureDropdown().vm.$emit('shown');
+ await nextTick();
+
+ expect(findGlTooltip().exists()).toBe(false);
+ });
+
+ it('shows the tooltip when the dropdown is closed', async () => {
+ findGlDisclosureDropdown().vm.$emit('shown');
+ findGlDisclosureDropdown().vm.$emit('hidden');
+ await nextTick();
+
+ expect(findGlTooltip().exists()).toBe(true);
+ });
+
+ it('closes the dropdown when invite members modal is opened', () => {
+ findInviteMembersTrigger().vm.$emit('modal-opened');
+ expect(closeAndFocusMock).toHaveBeenCalled();
+ });
});
});
diff --git a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js
new file mode 100644
index 00000000000..5329a8f5da3
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js
@@ -0,0 +1,79 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { s__ } from '~/locale';
+import FrequentItemsList from '~/super_sidebar/components//frequent_items_list.vue';
+import ItemsList from '~/super_sidebar/components/items_list.vue';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import { cachedFrequentProjects } from '../mock_data';
+
+const title = s__('Navigation|FREQUENT PROJECTS');
+const pristineText = s__('Navigation|Projects you visit often will appear here.');
+const storageKey = 'storageKey';
+const maxItems = 5;
+
+describe('FrequentItemsList component', () => {
+ useLocalStorageSpy();
+
+ let wrapper;
+
+ const findListTitle = () => wrapper.findByTestId('list-title');
+ const findItemsList = () => wrapper.findComponent(ItemsList);
+ const findEmptyText = () => wrapper.findByTestId('empty-text');
+
+ const createWrapper = ({ props = {} } = {}) => {
+ wrapper = shallowMountExtended(FrequentItemsList, {
+ propsData: {
+ title,
+ pristineText,
+ storageKey,
+ maxItems,
+ ...props,
+ },
+ });
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it("renders the list's title", () => {
+ expect(findListTitle().text()).toBe(title);
+ });
+
+ it('renders the empty text', () => {
+ expect(findEmptyText().exists()).toBe(true);
+ expect(findEmptyText().text()).toBe(pristineText);
+ });
+ });
+
+ describe('when there are cached frequent items', () => {
+ beforeEach(() => {
+ window.localStorage.setItem(storageKey, cachedFrequentProjects);
+ createWrapper();
+ });
+
+ it('attempts to retrieve the items from the local storage', () => {
+ expect(window.localStorage.getItem).toHaveBeenCalledTimes(1);
+ expect(window.localStorage.getItem).toHaveBeenCalledWith(storageKey);
+ });
+
+ it('renders the maximum amount of items', () => {
+ expect(findItemsList().props('items').length).toBe(maxItems);
+ });
+
+ it('does not render the empty text slot', () => {
+ expect(findEmptyText().exists()).toBe(false);
+ });
+
+ describe('items editing', () => {
+ it('remove-item event emission from items-list causes list item to be removed', async () => {
+ const localStorageProjects = findItemsList().props('items');
+
+ await findItemsList().vm.$emit('remove-item', localStorageProjects[0]);
+
+ expect(findItemsList().props('items')).toHaveLength(maxItems - 1);
+ expect(findItemsList().props('items')).not.toContain(localStorageProjects[0]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js
new file mode 100644
index 00000000000..aac321bd8e0
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js
@@ -0,0 +1,128 @@
+import {
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlLoadingIcon,
+ GlAvatar,
+ GlAlert,
+} from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue';
+
+import {
+ MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+ MOCK_SCOPED_SEARCH_OPTIONS,
+ MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
+} from '../mock_data';
+
+Vue.use(Vuex);
+
+describe('GlobalSearchAutocompleteItems', () => {
+ let wrapper;
+
+ const createComponent = (initialState, mockGetters, props) => {
+ const store = new Vuex.Store({
+ state: {
+ loading: false,
+ ...initialState,
+ },
+ getters: {
+ autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+ scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS,
+ ...mockGetters,
+ },
+ });
+
+ wrapper = shallowMount(GlobalSearchAutocompleteItems, {
+ store,
+ propsData: {
+ ...props,
+ },
+ stubs: {
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ },
+ });
+ };
+
+ const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
+ const findItemTitles = () =>
+ findItems().wrappers.map((w) => w.find('[data-testid="autocomplete-item-name"]').text());
+ const findItemSubTitles = () =>
+ findItems()
+ .wrappers.map((w) => w.find('[data-testid="autocomplete-item-namespace"]'))
+ .filter((w) => w.exists())
+ .map((w) => w.text());
+ const findItemLinks = () => findItems().wrappers.map((w) => w.find('a').attributes('href'));
+ const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findAvatars = () => wrapper.findAllComponents(GlAvatar).wrappers.map((w) => w.props('src'));
+ const findGlAlert = () => wrapper.findComponent(GlAlert);
+
+ describe('template', () => {
+ describe('when loading is true', () => {
+ beforeEach(() => {
+ createComponent({ loading: true });
+ });
+
+ it('renders GlLoadingIcon', () => {
+ expect(findGlLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not render autocomplete options', () => {
+ expect(findItems()).toHaveLength(0);
+ });
+ });
+
+ describe('when api returns error', () => {
+ beforeEach(() => {
+ createComponent({ autocompleteError: true });
+ });
+
+ it('renders Alert', () => {
+ expect(findGlAlert().exists()).toBe(true);
+ });
+ });
+
+ describe('when loading is false', () => {
+ beforeEach(() => {
+ createComponent({ loading: false });
+ });
+
+ it('does not render GlLoadingIcon', () => {
+ expect(findGlLoadingIcon().exists()).toBe(false);
+ });
+
+ describe('Search results items', () => {
+ it('renders item for each option in autocomplete option', () => {
+ expect(findItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length);
+ });
+
+ it('renders titles correctly', () => {
+ const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.text);
+ expect(findItemTitles()).toStrictEqual(expectedTitles);
+ });
+
+ it('renders sub-titles correctly', () => {
+ const expectedSubTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.filter((o) => o.value).map(
+ (o) => o.namespace,
+ );
+
+ expect(findItemSubTitles()).toStrictEqual(expectedSubTitles);
+ });
+
+ it('renders links correctly', () => {
+ const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.href);
+ expect(findItemLinks()).toStrictEqual(expectedLinks);
+ });
+
+ it('renders avatars', () => {
+ const expectedAvatars = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.avatar_url).filter(
+ Boolean,
+ );
+ expect(findAvatars()).toStrictEqual(expectedAvatars);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js
new file mode 100644
index 00000000000..52e9aa52c14
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js
@@ -0,0 +1,75 @@
+import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem } from '@gitlab/ui';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue';
+import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data';
+
+Vue.use(Vuex);
+
+describe('GlobalSearchDefaultItems', () => {
+ let wrapper;
+
+ const createComponent = (initialState, props) => {
+ const store = new Vuex.Store({
+ state: {
+ searchContext: MOCK_SEARCH_CONTEXT,
+ ...initialState,
+ },
+ getters: {
+ defaultSearchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
+ },
+ });
+
+ wrapper = shallowMountExtended(GlobalSearchDefaultItems, {
+ store,
+ propsData: {
+ ...props,
+ },
+ stubs: {
+ GlDisclosureDropdownGroup,
+ },
+ });
+ };
+
+ const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
+ const findItemsData = () => findItems().wrappers.map((w) => w.props('item'));
+
+ describe('template', () => {
+ describe('Dropdown items', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders item for each option in defaultSearchOptions', () => {
+ expect(findItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length);
+ });
+
+ it('provides the `item` prop to the `GlDisclosureDropdownItem` component', () => {
+ expect(findItemsData()).toStrictEqual(MOCK_DEFAULT_SEARCH_OPTIONS);
+ });
+ });
+
+ describe.each`
+ group | project | groupHeader
+ ${null} | ${null} | ${'All GitLab'}
+ ${{ name: 'Test Group' }} | ${null} | ${'Test Group'}
+ ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'}
+ `('Group Header', ({ group, project, groupHeader }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createComponent({
+ searchContext: {
+ group,
+ project,
+ },
+ });
+ });
+
+ it(`should render as ${groupHeader}`, () => {
+ expect(wrapper.text()).toContain(groupHeader);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js
new file mode 100644
index 00000000000..4976f3be4cd
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js
@@ -0,0 +1,91 @@
+import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlToken, GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { trimText } from 'helpers/text_helper';
+import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue';
+import { truncate } from '~/lib/utils/text_utility';
+import { SCOPE_TOKEN_MAX_LENGTH } from '~/super_sidebar/components/global_search/constants';
+import { MSG_IN_ALL_GITLAB } from '~/vue_shared/global_search/constants';
+import {
+ MOCK_SEARCH,
+ MOCK_SCOPED_SEARCH_GROUP,
+ MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+} from '../mock_data';
+
+Vue.use(Vuex);
+
+describe('GlobalSearchScopedItems', () => {
+ let wrapper;
+
+ const createComponent = (initialState, mockGetters, props) => {
+ const store = new Vuex.Store({
+ state: {
+ search: MOCK_SEARCH,
+ ...initialState,
+ },
+ getters: {
+ scopedSearchGroup: () => MOCK_SCOPED_SEARCH_GROUP,
+ autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+ ...mockGetters,
+ },
+ });
+
+ wrapper = shallowMount(GlobalSearchScopedItems, {
+ store,
+ propsData: {
+ ...props,
+ },
+ stubs: {
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ },
+ });
+ };
+
+ const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
+ const findItemsText = () => findItems().wrappers.map((w) => trimText(w.text()));
+ const findScopeTokens = () => wrapper.findAllComponents(GlToken);
+ const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text()));
+ const findScopeTokensIcons = () =>
+ findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon));
+ const findItemLinks = () => findItems().wrappers.map((w) => w.find('a').attributes('href'));
+
+ describe('Search results scoped items', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders item for each item in scopedSearchGroup', () => {
+ expect(findItems()).toHaveLength(MOCK_SCOPED_SEARCH_GROUP.items.length);
+ });
+
+ it('renders titles correctly', () => {
+ findItemsText().forEach((title) => expect(title).toContain(MOCK_SEARCH));
+ });
+
+ it('renders scope names correctly', () => {
+ const expectedTitles = MOCK_SCOPED_SEARCH_GROUP.items.map((o) =>
+ truncate(trimText(`in ${o.scope || o.description}`), SCOPE_TOKEN_MAX_LENGTH),
+ );
+
+ expect(findScopeTokensText()).toStrictEqual(expectedTitles);
+ });
+
+ it('renders scope icons correctly', () => {
+ findScopeTokensIcons().forEach((icon, i) => {
+ const w = icon.wrappers[0];
+ expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_GROUP.items[i].icon);
+ });
+ });
+
+ it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => {
+ expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false);
+ });
+
+ it('renders links correctly', () => {
+ const expectedLinks = MOCK_SCOPED_SEARCH_GROUP.items.map((o) => o.href);
+ expect(findItemLinks()).toStrictEqual(expectedLinks);
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js
new file mode 100644
index 00000000000..f78e141afad
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js
@@ -0,0 +1,372 @@
+import { GlModal, GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { s__, sprintf } from '~/locale';
+import GlobalSearchModal from '~/super_sidebar/components/global_search/components/global_search.vue';
+import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue';
+import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue';
+import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue';
+import {
+ SEARCH_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
+ ICON_PROJECT,
+ ICON_GROUP,
+ ICON_SUBGROUP,
+ SCOPE_TOKEN_MAX_LENGTH,
+ IS_SEARCHING,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
+} from '~/super_sidebar/components/global_search/constants';
+import { truncate } from '~/lib/utils/text_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { ENTER_KEY } from '~/lib/utils/keys';
+import {
+ MOCK_SEARCH,
+ MOCK_SEARCH_QUERY,
+ MOCK_USERNAME,
+ MOCK_DEFAULT_SEARCH_OPTIONS,
+ MOCK_SCOPED_SEARCH_OPTIONS,
+ MOCK_SEARCH_CONTEXT_FULL,
+ MOCK_PROJECT,
+ MOCK_GROUP,
+} from '../mock_data';
+
+Vue.use(Vuex);
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+}));
+
+describe('GlobalSearchModal', () => {
+ let wrapper;
+
+ const actionSpies = {
+ setSearch: jest.fn(),
+ fetchAutocompleteOptions: jest.fn(),
+ clearAutocomplete: jest.fn(),
+ };
+
+ const deafaultMockState = {
+ searchContext: {
+ project: MOCK_PROJECT,
+ group: MOCK_GROUP,
+ },
+ };
+
+ const createComponent = (initialState, mockGetters, stubs) => {
+ const store = new Vuex.Store({
+ state: {
+ ...deafaultMockState,
+ ...initialState,
+ },
+ actions: actionSpies,
+ getters: {
+ searchQuery: () => MOCK_SEARCH_QUERY,
+ searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
+ scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS,
+ ...mockGetters,
+ },
+ });
+
+ wrapper = shallowMountExtended(GlobalSearchModal, {
+ store,
+ stubs,
+ });
+ };
+
+ const formatScopeName = (scopeName) => {
+ if (!scopeName) {
+ return false;
+ }
+ const searchResultsScope = s__('GlobalSearch|in %{scope}');
+ return truncate(
+ sprintf(searchResultsScope, {
+ scope: scopeName,
+ }),
+ SCOPE_TOKEN_MAX_LENGTH,
+ );
+ };
+
+ const findGlobalSearchModal = () => wrapper.findComponent(GlModal);
+
+ const findGlobalSearchForm = () => wrapper.findByTestId('global-search-form');
+ const findGlobalSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
+ const findScopeToken = () => wrapper.findComponent(GlToken);
+ const findGlobalSearchDefaultItems = () => wrapper.findComponent(GlobalSearchDefaultItems);
+ const findGlobalSearchScopedItems = () => wrapper.findComponent(GlobalSearchScopedItems);
+ const findGlobalSearchAutocompleteItems = () =>
+ wrapper.findComponent(GlobalSearchAutocompleteItems);
+ const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`);
+ const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION);
+
+ describe('template', () => {
+ describe('always renders', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('Global Search Input', () => {
+ expect(findGlobalSearchInput().exists()).toBe(true);
+ });
+
+ it('Search Input Description', () => {
+ expect(findSearchInputDescription().exists()).toBe(true);
+ });
+
+ it('Search Results Description', () => {
+ expect(findSearchResultsDescription().exists()).toBe(true);
+ });
+ });
+
+ describe.each`
+ search | showDefault | showScoped | showAutocomplete
+ ${null} | ${true} | ${false} | ${false}
+ ${''} | ${true} | ${false} | ${false}
+ ${'t'} | ${false} | ${false} | ${true}
+ ${'te'} | ${false} | ${false} | ${true}
+ ${'tes'} | ${false} | ${true} | ${true}
+ ${MOCK_SEARCH} | ${false} | ${true} | ${true}
+ `('Global Search Result Items', ({ search, showDefault, showScoped, showAutocomplete }) => {
+ describe(`when search is ${search}`, () => {
+ beforeEach(() => {
+ window.gon.current_username = MOCK_USERNAME;
+ createComponent({ search }, {});
+ findGlobalSearchInput().vm.$emit('click');
+ });
+
+ it(`should${showDefault ? '' : ' not'} render the Default Items`, () => {
+ expect(findGlobalSearchDefaultItems().exists()).toBe(showDefault);
+ });
+
+ it(`should${showScoped ? '' : ' not'} render the Scoped Items`, () => {
+ expect(findGlobalSearchScopedItems().exists()).toBe(showScoped);
+ });
+
+ it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Items`, () => {
+ expect(findGlobalSearchAutocompleteItems().exists()).toBe(showAutocomplete);
+ });
+ });
+ });
+
+ describe.each`
+ username | search | loading | searchOptions | expectedDesc
+ ${null} | ${'gi'} | ${false} | ${[]} | ${GlobalSearchModal.i18n.MIN_SEARCH_TERM}
+ ${MOCK_USERNAME} | ${'gi'} | ${false} | ${[]} | ${GlobalSearchModal.i18n.MIN_SEARCH_TERM}
+ ${MOCK_USERNAME} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
+ ${MOCK_USERNAME} | ${MOCK_SEARCH} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${GlobalSearchModal.i18n.SEARCH_RESULTS_LOADING}
+ ${MOCK_USERNAME} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`}
+ ${MOCK_USERNAME} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${GlobalSearchModal.i18n.SEARCH_RESULTS_LOADING}
+ `(
+ 'Search Results Description',
+ ({ username, search, loading, searchOptions, expectedDesc }) => {
+ describe(`search is "${search}" and loading is ${loading}`, () => {
+ beforeEach(() => {
+ window.gon.current_username = username;
+ createComponent(
+ {
+ search,
+ loading,
+ },
+ {
+ searchOptions: () => searchOptions,
+ },
+ );
+ });
+
+ it(`sets description to ${expectedDesc}`, () => {
+ expect(findSearchResultsDescription().text()).toBe(expectedDesc);
+ });
+ });
+ },
+ );
+
+ describe('input box', () => {
+ describe.each`
+ search | searchOptions | hasToken
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[1]]} | ${true}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${true}
+ ${'te'} | ${[MOCK_SCOPED_SEARCH_OPTIONS[5]]} | ${false}
+ ${'x'} | ${[]} | ${false}
+ `('token', ({ search, searchOptions, hasToken }) => {
+ beforeEach(() => {
+ window.gon.current_username = MOCK_USERNAME;
+ createComponent(
+ { search },
+ {
+ searchOptions: () => searchOptions,
+ },
+ );
+ findGlobalSearchInput().vm.$emit('click');
+ });
+
+ it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${
+ searchOptions[0]?.html_id
+ }"`, () => {
+ expect(findScopeToken().exists()).toBe(hasToken);
+ });
+
+ it(`text ${hasToken ? 'is correctly' : 'is NOT'} rendered when text is "${
+ searchOptions[0]?.scope || searchOptions[0]?.description
+ }"`, () => {
+ expect(findScopeToken().exists() && findScopeToken().text()).toBe(
+ formatScopeName(searchOptions[0]?.scope || searchOptions[0]?.description),
+ );
+ });
+ });
+ });
+
+ describe('form', () => {
+ describe.each`
+ searchContext | search | searchOptions
+ ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]}
+ ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]}
+ ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
+ ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
+ ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
+ ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS}
+ ${null} | ${null} | ${[]}
+ `('wrapper', ({ searchContext, search, searchOptions }) => {
+ beforeEach(() => {
+ window.gon.current_username = MOCK_USERNAME;
+ createComponent({ search, searchContext }, { searchOptions: () => searchOptions });
+ });
+
+ const isSearching = search?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
+
+ it(`classes ${isSearching ? 'contain' : 'do not contain'} "${IS_SEARCHING}"`, () => {
+ if (isSearching) {
+ expect(findGlobalSearchForm().classes()).toContain(IS_SEARCHING);
+ return;
+ }
+ if (!isSearching) {
+ expect(findGlobalSearchForm().classes()).not.toContain(IS_SEARCHING);
+ }
+ });
+ });
+ });
+
+ describe.each`
+ search | searchOptions | hasIcon | iconName
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} | ${ICON_PROJECT}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} | ${ICON_GROUP}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} | ${ICON_SUBGROUP}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} | ${false}
+ `('token', ({ search, searchOptions, hasIcon, iconName }) => {
+ beforeEach(() => {
+ window.gon.current_username = MOCK_USERNAME;
+ createComponent(
+ { search },
+ {
+ searchOptions: () => searchOptions,
+ },
+ );
+ findGlobalSearchInput().vm.$emit('click');
+ });
+
+ it(`icon for data set type "${searchOptions[0]?.html_id}" ${
+ hasIcon ? 'is' : 'is NOT'
+ } rendered`, () => {
+ expect(findScopeToken().findComponent(GlIcon).exists()).toBe(hasIcon);
+ });
+
+ it(`render ${iconName ? `"${iconName}"` : 'NO'} icon for data set type "${
+ searchOptions[0]?.html_id
+ }"`, () => {
+ expect(
+ findScopeToken().findComponent(GlIcon).exists() &&
+ findScopeToken().findComponent(GlIcon).attributes('name'),
+ ).toBe(iconName);
+ });
+ });
+ });
+
+ describe('events', () => {
+ beforeEach(() => {
+ createComponent();
+ window.gon.current_username = MOCK_USERNAME;
+ });
+
+ describe('Global Search Input', () => {
+ describe('onInput', () => {
+ describe('when search has text', () => {
+ beforeEach(() => {
+ findGlobalSearchInput().vm.$emit('input', MOCK_SEARCH);
+ });
+
+ it('calls setSearch with search term', () => {
+ expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH);
+ });
+
+ it('calls fetchAutocompleteOptions', () => {
+ expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled();
+ });
+
+ it('does not call clearAutocomplete', () => {
+ expect(actionSpies.clearAutocomplete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when search is emptied', () => {
+ beforeEach(() => {
+ findGlobalSearchInput().vm.$emit('input', '');
+ });
+
+ it('calls setSearch with empty term', () => {
+ expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), '');
+ });
+
+ it('does not call fetchAutocompleteOptions', () => {
+ expect(actionSpies.fetchAutocompleteOptions).not.toHaveBeenCalled();
+ });
+
+ it('calls clearAutocomplete', () => {
+ expect(actionSpies.clearAutocomplete).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Submitting a search', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('onKey-enter submits a search', () => {
+ findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+
+ expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
+ });
+
+ describe('with less than min characters', () => {
+ beforeEach(() => {
+ createComponent({ search: 'x' });
+ });
+
+ it('onKey-enter will NOT submit a search', () => {
+ findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+
+ expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
+ });
+ });
+ });
+ });
+
+ describe('Modal events', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should emit `shown` event when modal shown`', () => {
+ findGlobalSearchModal().vm.$emit('shown');
+ expect(wrapper.emitted('shown')).toHaveLength(1);
+ });
+
+ it('should emit `hidden` event when modal hidden`', () => {
+ findGlobalSearchModal().vm.$emit('hidden');
+ expect(wrapper.emitted('hidden')).toHaveLength(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/mock_data.js b/spec/frontend/super_sidebar/components/global_search/mock_data.js
new file mode 100644
index 00000000000..0884fce567c
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/mock_data.js
@@ -0,0 +1,456 @@
+import {
+ ICON_PROJECT,
+ ICON_GROUP,
+ ICON_SUBGROUP,
+} from '~/super_sidebar/components/global_search/constants';
+
+import {
+ PROJECTS_CATEGORY,
+ GROUPS_CATEGORY,
+ MSG_ISSUES_ASSIGNED_TO_ME,
+ MSG_ISSUES_IVE_CREATED,
+ MSG_MR_ASSIGNED_TO_ME,
+ MSG_MR_IM_REVIEWER,
+ MSG_MR_IVE_CREATED,
+ MSG_IN_ALL_GITLAB,
+} from '~/vue_shared/global_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_AUTOCOMPLETE_PATH = '/autocomplete';
+
+export const MOCK_PROJECT = {
+ id: 123,
+ name: 'MockProject',
+ path: '/mock-project',
+};
+
+export const MOCK_PROJECT_LONG = {
+ id: 124,
+ name: 'Mock Project Name That Is Ridiculously Long And It Goes Forever',
+ path: '/mock-project-name-that-is-ridiculously-long-and-it-goes-forever',
+};
+
+export const MOCK_GROUP = {
+ id: 321,
+ name: 'MockGroup',
+ path: '/mock-group',
+};
+
+export const MOCK_SUBGROUP = {
+ id: 322,
+ name: 'MockSubGroup',
+ path: `${MOCK_GROUP}/mock-subgroup`,
+};
+
+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_SEARCH_CONTEXT_FULL = {
+ group: {
+ id: 31,
+ name: 'testGroup',
+ full_name: 'testGroup',
+ },
+ group_metadata: {
+ group_path: 'testGroup',
+ name: 'testGroup',
+ issues_path: '/groups/testGroup/-/issues',
+ mr_path: '/groups/testGroup/-/merge_requests',
+ },
+};
+
+export const MOCK_DEFAULT_SEARCH_OPTIONS = [
+ {
+ text: MSG_ISSUES_ASSIGNED_TO_ME,
+ href: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`,
+ },
+ {
+ text: MSG_ISSUES_IVE_CREATED,
+ href: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`,
+ },
+ {
+ text: MSG_MR_ASSIGNED_TO_ME,
+ href: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`,
+ },
+ {
+ text: MSG_MR_IM_REVIEWER,
+ href: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`,
+ },
+ {
+ text: MSG_MR_IVE_CREATED,
+ href: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`,
+ },
+];
+export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [
+ {
+ text: 'scoped-in-project',
+ scope: MOCK_PROJECT.name,
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
+ href: MOCK_PROJECT.path,
+ },
+ {
+ text: 'scoped-in-group',
+ scope: MOCK_GROUP.name,
+ scopeCategory: GROUPS_CATEGORY,
+ icon: ICON_GROUP,
+ href: MOCK_GROUP.path,
+ },
+ {
+ text: 'scoped-in-all',
+ description: MSG_IN_ALL_GITLAB,
+ href: MOCK_ALL_PATH,
+ },
+];
+export const MOCK_SCOPED_SEARCH_OPTIONS = [
+ {
+ text: 'scoped-in-project',
+ scope: MOCK_PROJECT.name,
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
+ url: MOCK_PROJECT.path,
+ },
+ {
+ text: 'scoped-in-project-long',
+ scope: MOCK_PROJECT_LONG.name,
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
+ url: MOCK_PROJECT_LONG.path,
+ },
+ {
+ text: 'scoped-in-group',
+ scope: MOCK_GROUP.name,
+ scopeCategory: GROUPS_CATEGORY,
+ icon: ICON_GROUP,
+ url: MOCK_GROUP.path,
+ },
+ {
+ text: 'scoped-in-subgroup',
+ scope: MOCK_SUBGROUP.name,
+ scopeCategory: GROUPS_CATEGORY,
+ icon: ICON_SUBGROUP,
+ url: MOCK_SUBGROUP.path,
+ },
+ {
+ text: 'scoped-in-all',
+ description: MSG_IN_ALL_GITLAB,
+ url: MOCK_ALL_PATH,
+ },
+];
+
+export const MOCK_SCOPED_SEARCH_GROUP = {
+ items: [
+ {
+ text: 'scoped-in-project',
+ scope: MOCK_PROJECT.name,
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
+ href: MOCK_PROJECT.path,
+ },
+ {
+ text: 'scoped-in-group',
+ scope: MOCK_GROUP.name,
+ scopeCategory: GROUPS_CATEGORY,
+ icon: ICON_GROUP,
+ href: MOCK_GROUP.path,
+ },
+ {
+ text: 'scoped-in-all',
+ description: MSG_IN_ALL_GITLAB,
+ href: MOCK_ALL_PATH,
+ },
+ ],
+};
+
+export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [
+ {
+ category: 'Projects',
+ id: 1,
+ label: 'Gitlab Org / MockProject1',
+ value: 'MockProject1',
+ url: 'project/1',
+ avatar_url: '/project/avatar/1/avatar.png',
+ },
+ {
+ avatar_url: '/groups/avatar/1/avatar.png',
+ category: 'Groups',
+ id: 1,
+ label: 'Gitlab Org / MockGroup1',
+ value: 'MockGroup1',
+ url: 'group/1',
+ },
+ {
+ avatar_url: '/project/avatar/2/avatar.png',
+ category: 'Projects',
+ id: 2,
+ label: 'Gitlab Org / MockProject2',
+ value: 'MockProject2',
+ url: 'project/2',
+ },
+ {
+ category: 'Help',
+ label: 'GitLab Help',
+ url: 'help/gitlab',
+ },
+];
+
+export const MOCK_AUTOCOMPLETE_OPTIONS = [
+ {
+ category: 'Projects',
+ id: 1,
+ label: 'Gitlab Org / MockProject1',
+ value: 'MockProject1',
+ url: 'project/1',
+ avatar_url: '/project/avatar/1/avatar.png',
+ },
+ {
+ category: 'Groups',
+ id: 1,
+ label: 'Gitlab Org / MockGroup1',
+ value: 'MockGroup1',
+ url: 'group/1',
+ avatar_url: '/groups/avatar/1/avatar.png',
+ },
+ {
+ category: 'Projects',
+ id: 2,
+ label: 'Gitlab Org / MockProject2',
+ value: 'MockProject2',
+ url: 'project/2',
+ avatar_url: '/project/avatar/2/avatar.png',
+ },
+ {
+ category: 'Help',
+ label: 'GitLab Help',
+ url: 'help/gitlab',
+ },
+];
+
+export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
+ {
+ name: 'Groups',
+ items: [
+ {
+ category: 'Groups',
+ id: 1,
+ label: 'Gitlab Org / MockGroup1',
+ namespace: 'Gitlab Org / MockGroup1',
+ value: 'MockGroup1',
+ text: 'MockGroup1',
+ href: 'group/1',
+ avatar_url: '/groups/avatar/1/avatar.png',
+ avatar_size: 32,
+ entity_id: 1,
+ entity_name: 'MockGroup1',
+ },
+ ],
+ },
+ {
+ name: 'Projects',
+ items: [
+ {
+ category: 'Projects',
+ id: 1,
+ label: 'Gitlab Org / MockProject1',
+ namespace: 'Gitlab Org / MockProject1',
+ value: 'MockProject1',
+ text: 'MockProject1',
+ href: 'project/1',
+ avatar_url: '/project/avatar/1/avatar.png',
+ avatar_size: 32,
+ entity_id: 1,
+ entity_name: 'MockProject1',
+ },
+ {
+ category: 'Projects',
+ id: 2,
+ value: 'MockProject2',
+ label: 'Gitlab Org / MockProject2',
+ namespace: 'Gitlab Org / MockProject2',
+ text: 'MockProject2',
+ href: 'project/2',
+ avatar_url: '/project/avatar/2/avatar.png',
+ avatar_size: 32,
+ entity_id: 2,
+ entity_name: 'MockProject2',
+ },
+ ],
+ },
+ {
+ name: 'Help',
+ items: [
+ {
+ category: 'Help',
+ label: 'GitLab Help',
+ text: 'GitLab Help',
+ href: 'help/gitlab',
+ avatar_size: 16,
+ entity_name: 'GitLab Help',
+ },
+ ],
+ },
+];
+
+export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [
+ {
+ category: 'Groups',
+ id: 1,
+ label: 'Gitlab Org / MockGroup1',
+ value: 'MockGroup1',
+ text: 'MockGroup1',
+ href: 'group/1',
+ namespace: 'Gitlab Org / MockGroup1',
+ avatar_url: '/groups/avatar/1/avatar.png',
+ avatar_size: 32,
+ entity_id: 1,
+ entity_name: 'MockGroup1',
+ },
+ {
+ avatar_size: 32,
+ avatar_url: '/project/avatar/1/avatar.png',
+ category: 'Projects',
+ entity_id: 1,
+ entity_name: 'MockProject1',
+ href: 'project/1',
+ id: 1,
+ label: 'Gitlab Org / MockProject1',
+ namespace: 'Gitlab Org / MockProject1',
+ text: 'MockProject1',
+ value: 'MockProject1',
+ },
+ {
+ avatar_size: 32,
+ avatar_url: '/project/avatar/2/avatar.png',
+ category: 'Projects',
+ entity_id: 2,
+ entity_name: 'MockProject2',
+ href: 'project/2',
+ id: 2,
+ label: 'Gitlab Org / MockProject2',
+ namespace: 'Gitlab Org / MockProject2',
+ text: 'MockProject2',
+ value: 'MockProject2',
+ },
+ {
+ avatar_size: 16,
+ entity_name: 'GitLab Help',
+ category: 'Help',
+ label: 'GitLab Help',
+ text: 'GitLab Help',
+ href: 'help/gitlab',
+ },
+];
+
+export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP = [
+ {
+ category: 'Help',
+ data: [
+ {
+ html_id: 'autocomplete-Help-1',
+ category: 'Help',
+ text: 'Rake Tasks Help',
+ label: 'Rake Tasks Help',
+ href: '/help/raketasks/index',
+ },
+ {
+ html_id: 'autocomplete-Help-2',
+ category: 'Help',
+ text: 'System Hooks Help',
+ label: 'System Hooks Help',
+ href: '/help/system_hooks/system_hooks',
+ },
+ ],
+ },
+];
+
+export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP = [
+ {
+ category: 'Settings',
+ data: [
+ {
+ html_id: 'autocomplete-Settings-0',
+ category: 'Settings',
+ label: 'User settings',
+ url: '/-/profile',
+ },
+ {
+ html_id: 'autocomplete-Settings-3',
+ category: 'Settings',
+ label: 'Admin Section',
+ url: '/admin',
+ },
+ ],
+ },
+ {
+ category: 'Help',
+ data: [
+ {
+ html_id: 'autocomplete-Help-1',
+ category: 'Help',
+ label: 'Rake Tasks Help',
+ url: '/help/raketasks/index',
+ },
+ {
+ html_id: 'autocomplete-Help-2',
+ category: 'Help',
+ label: 'System Hooks Help',
+ url: '/help/system_hooks/system_hooks',
+ },
+ ],
+ },
+];
+
+export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2 = [
+ {
+ category: 'Groups',
+ data: [
+ {
+ html_id: 'autocomplete-Groups-0',
+ category: 'Groups',
+ id: 148,
+ label: 'Jashkenas / Test Subgroup / test-subgroup',
+ url: '/jashkenas/test-subgroup/test-subgroup',
+ avatar_url: '',
+ },
+ {
+ html_id: 'autocomplete-Groups-1',
+ category: 'Groups',
+ id: 147,
+ label: 'Jashkenas / Test Subgroup',
+ url: '/jashkenas/test-subgroup',
+ avatar_url: '',
+ },
+ ],
+ },
+ {
+ category: 'Projects',
+ data: [
+ {
+ html_id: 'autocomplete-Projects-2',
+ category: 'Projects',
+ id: 1,
+ value: 'Gitlab Test',
+ label: 'Gitlab Org / Gitlab Test',
+ url: '/gitlab-org/gitlab-test',
+ avatar_url: '/uploads/-/system/project/avatar/1/icons8-gitlab-512.png',
+ },
+ ],
+ },
+];
diff --git a/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js b/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js
new file mode 100644
index 00000000000..f6d8e1f26eb
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js
@@ -0,0 +1,111 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import * as actions from '~/super_sidebar/components/global_search/store/actions';
+import * as types from '~/super_sidebar/components/global_search/store/mutation_types';
+import initState from '~/super_sidebar/components/global_search/store/state';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import {
+ MOCK_SEARCH,
+ MOCK_AUTOCOMPLETE_OPTIONS_RES,
+ MOCK_AUTOCOMPLETE_PATH,
+ MOCK_PROJECT,
+ MOCK_SEARCH_CONTEXT,
+ MOCK_SEARCH_PATH,
+ MOCK_MR_PATH,
+ MOCK_ISSUE_PATH,
+} from '../mock_data';
+
+describe('Global Search Store Actions', () => {
+ let state;
+ let mock;
+
+ const createState = (initialState) =>
+ initState({
+ searchPath: MOCK_SEARCH_PATH,
+ issuesPath: MOCK_ISSUE_PATH,
+ mrPath: MOCK_MR_PATH,
+ autocompletePath: MOCK_AUTOCOMPLETE_PATH,
+ searchContext: MOCK_SEARCH_CONTEXT,
+ ...initialState,
+ });
+
+ afterEach(() => {
+ state = null;
+ mock.restore();
+ });
+
+ describe.each`
+ axiosMock | type | expectedMutations
+ ${{ method: 'onGet', code: HTTP_STATUS_OK, res: MOCK_AUTOCOMPLETE_OPTIONS_RES }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }]}
+ ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]}
+ `('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations }) => {
+ describe(`on ${type}`, () => {
+ beforeEach(() => {
+ state = createState({});
+ mock = new MockAdapter(axios);
+ mock[axiosMock.method]().reply(axiosMock.code, axiosMock.res);
+ });
+ it(`should dispatch the correct mutations`, () => {
+ return testAction({
+ action: actions.fetchAutocompleteOptions,
+ state,
+ expectedMutations,
+ });
+ });
+ });
+ });
+
+ describe.each`
+ project | ref | fetchType | expectedPath
+ ${null} | ${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}`}
+ ${MOCK_PROJECT} | ${null} | ${'generic'} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&filter=generic`}
+ ${null} | ${MOCK_PROJECT.id} | ${'generic'} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_ref=${MOCK_PROJECT.id}&filter=generic`}
+ ${MOCK_PROJECT} | ${MOCK_PROJECT.id} | ${'search'} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=${MOCK_PROJECT.id}&filter=search`}
+ `('autocompleteQuery', ({ project, ref, fetchType, expectedPath }) => {
+ describe(`when project is ${project?.name} and project ref is ${ref}`, () => {
+ beforeEach(() => {
+ state = createState({
+ search: MOCK_SEARCH,
+ searchContext: {
+ project,
+ ref,
+ },
+ });
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(actions.autocompleteQuery({ state, fetchType })).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe('clearAutocomplete', () => {
+ beforeEach(() => {
+ state = createState({});
+ });
+
+ it('calls the CLEAR_AUTOCOMPLETE mutation', () => {
+ return testAction({
+ action: actions.clearAutocomplete,
+ state,
+ expectedMutations: [{ type: types.CLEAR_AUTOCOMPLETE }],
+ });
+ });
+ });
+
+ describe('setSearch', () => {
+ beforeEach(() => {
+ state = createState({});
+ });
+
+ 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/super_sidebar/components/global_search/store/getters_spec.js b/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js
new file mode 100644
index 00000000000..68583d04b31
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js
@@ -0,0 +1,334 @@
+import * as getters from '~/super_sidebar/components/global_search/store/getters';
+import initState from '~/super_sidebar/components/global_search/store/state';
+import {
+ MOCK_USERNAME,
+ MOCK_SEARCH_PATH,
+ MOCK_ISSUE_PATH,
+ MOCK_MR_PATH,
+ MOCK_AUTOCOMPLETE_PATH,
+ MOCK_SEARCH_CONTEXT,
+ MOCK_DEFAULT_SEARCH_OPTIONS,
+ MOCK_SCOPED_SEARCH_OPTIONS,
+ MOCK_SCOPED_SEARCH_GROUP,
+ MOCK_PROJECT,
+ MOCK_GROUP,
+ MOCK_ALL_PATH,
+ MOCK_SEARCH,
+ MOCK_AUTOCOMPLETE_OPTIONS,
+ MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+ MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
+ MOCK_SCOPED_SEARCH_OPTIONS_DEF,
+} from '../mock_data';
+
+describe('Global Search Store Getters', () => {
+ let state;
+
+ const createState = (initialState) => {
+ state = initState({
+ searchPath: MOCK_SEARCH_PATH,
+ issuesPath: MOCK_ISSUE_PATH,
+ mrPath: MOCK_MR_PATH,
+ autocompletePath: MOCK_AUTOCOMPLETE_PATH,
+ searchContext: MOCK_SEARCH_CONTEXT,
+ ...initialState,
+ });
+ };
+
+ afterEach(() => {
+ state = null;
+ });
+
+ describe.each`
+ group | project | scope | forSnippets | codeSearch | ref | expectedPath
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
+ ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
+ `('searchQuery', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ project,
+ scope,
+ for_snippets: forSnippets,
+ code_search: codeSearch,
+ ref,
+ },
+ });
+ 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 | scope | forSnippets | codeSearch | ref | expectedPath
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
+ ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
+ `('projectUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ project,
+ scope,
+ for_snippets: forSnippets,
+ code_search: codeSearch,
+ ref,
+ },
+ });
+ state.search = MOCK_SEARCH;
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.projectUrl(state)).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe.each`
+ group | project | scope | forSnippets | codeSearch | ref | expectedPath
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
+ ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
+ `('groupUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ project,
+ scope,
+ for_snippets: forSnippets,
+ code_search: codeSearch,
+ ref,
+ },
+ });
+ state.search = MOCK_SEARCH;
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.groupUrl(state)).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe.each`
+ group | project | scope | forSnippets | codeSearch | ref | expectedPath
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
+ ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true&search_code=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
+ `('allUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ project,
+ scope,
+ for_snippets: forSnippets,
+ code_search: codeSearch,
+ ref,
+ },
+ });
+ 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,
+ );
+ });
+
+ it('returns the correct array if issues path is false', () => {
+ mockGetters.scopedIssuesPath = undefined;
+ expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual(
+ MOCK_DEFAULT_SEARCH_OPTIONS.slice(2, MOCK_DEFAULT_SEARCH_OPTIONS.length),
+ );
+ });
+ });
+
+ 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_DEF,
+ );
+ });
+ });
+
+ describe('autocompleteGroupedSearchOptions', () => {
+ beforeEach(() => {
+ createState();
+ state.autocompleteOptions = MOCK_AUTOCOMPLETE_OPTIONS;
+ });
+
+ it('returns the correct grouped array', () => {
+ expect(getters.autocompleteGroupedSearchOptions(state)).toStrictEqual(
+ MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+ );
+ });
+ });
+
+ describe.each`
+ search | defaultSearchOptions | scopedSearchOptions | autocompleteGroupedSearchOptions | expectedArray
+ ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_GROUP} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS}
+ ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS}
+ ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
+ ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
+ ${1} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
+ ${'('} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
+ ${'t'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
+ ${'te'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
+ ${'tes'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
+ `(
+ 'searchOptions',
+ ({
+ search,
+ defaultSearchOptions,
+ scopedSearchOptions,
+ autocompleteGroupedSearchOptions,
+ expectedArray,
+ }) => {
+ describe(`when search is ${search} and the defaultSearchOptions${
+ defaultSearchOptions.length ? '' : ' do not'
+ } exist, scopedSearchOptions${
+ scopedSearchOptions.length ? '' : ' do not'
+ } exist, and autocompleteGroupedSearchOptions${
+ autocompleteGroupedSearchOptions.length ? '' : ' do not'
+ } exist`, () => {
+ const mockGetters = {
+ defaultSearchOptions,
+ scopedSearchOptions,
+ autocompleteGroupedSearchOptions,
+ };
+
+ beforeEach(() => {
+ createState();
+ state.search = search;
+ });
+
+ it(`should return the correct combined array`, () => {
+ expect(getters.searchOptions(state, mockGetters)).toStrictEqual(expectedArray);
+ });
+ });
+ },
+ );
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js b/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js
new file mode 100644
index 00000000000..4d275cf86c7
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js
@@ -0,0 +1,63 @@
+import * as types from '~/super_sidebar/components/global_search/store/mutation_types';
+import mutations from '~/super_sidebar/components/global_search/store/mutations';
+import createState from '~/super_sidebar/components/global_search/store/state';
+import {
+ MOCK_SEARCH,
+ MOCK_AUTOCOMPLETE_OPTIONS_RES,
+ MOCK_AUTOCOMPLETE_OPTIONS,
+} from '../mock_data';
+
+describe('Header Search Store Mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({});
+ });
+
+ describe('REQUEST_AUTOCOMPLETE', () => {
+ it('sets loading to true and empties autocompleteOptions array', () => {
+ mutations[types.REQUEST_AUTOCOMPLETE](state);
+
+ expect(state.loading).toBe(true);
+ expect(state.autocompleteOptions).toStrictEqual([]);
+ expect(state.autocompleteError).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_AUTOCOMPLETE_SUCCESS', () => {
+ it('sets loading to false and then formats and sets the autocompleteOptions array', () => {
+ mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS_RES);
+
+ expect(state.loading).toBe(false);
+ expect(state.autocompleteOptions).toEqual(MOCK_AUTOCOMPLETE_OPTIONS);
+ expect(state.autocompleteError).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_AUTOCOMPLETE_ERROR', () => {
+ it('sets loading to false and empties autocompleteOptions array', () => {
+ mutations[types.RECEIVE_AUTOCOMPLETE_ERROR](state);
+
+ expect(state.loading).toBe(false);
+ expect(state.autocompleteOptions).toStrictEqual([]);
+ expect(state.autocompleteError).toBe(true);
+ });
+ });
+
+ describe('CLEAR_AUTOCOMPLETE', () => {
+ it('empties autocompleteOptions array', () => {
+ mutations[types.CLEAR_AUTOCOMPLETE](state);
+
+ expect(state.autocompleteOptions).toStrictEqual([]);
+ expect(state.autocompleteError).toBe(false);
+ });
+ });
+
+ 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/super_sidebar/components/global_search/utils_spec.js b/spec/frontend/super_sidebar/components/global_search/utils_spec.js
new file mode 100644
index 00000000000..3b12063e733
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/utils_spec.js
@@ -0,0 +1,60 @@
+import { getFormattedItem } from '~/super_sidebar/components/global_search/utils';
+import {
+ LARGE_AVATAR_PX,
+ SMALL_AVATAR_PX,
+} from '~/super_sidebar/components/global_search/constants';
+import {
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+} from '~/vue_shared/global_search/constants';
+
+describe('getFormattedItem', () => {
+ describe.each`
+ item | avatarSize | searchContext | entityId | entityName
+ ${{ category: PROJECTS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 29 } }} | ${29} | ${'project1'}
+ ${{ category: GROUPS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 12 } }} | ${12} | ${'project1'}
+ ${{ category: 'Help', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'}
+ ${{ category: 'Settings', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'}
+ ${{ category: GROUPS_CATEGORY, value: 'group1', label: 'Group 1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${1} | ${'group1'}
+ ${{ category: PROJECTS_CATEGORY, value: 'group2', label: 'Group2' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${2} | ${'group2'}
+ ${{ category: ISSUES_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${3} | ${'test3'}
+ ${{ category: MERGE_REQUEST_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${4} | ${'test4'}
+ ${{ category: RECENT_EPICS_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${5} | ${'test5'}
+ ${{ category: GROUPS_CATEGORY, group_id: 6, group_name: 'test6' }} | ${LARGE_AVATAR_PX} | ${null} | ${6} | ${'test6'}
+ ${{ category: PROJECTS_CATEGORY, project_id: 7, project_name: 'test7' }} | ${LARGE_AVATAR_PX} | ${null} | ${7} | ${'test7'}
+ ${{ category: ISSUES_CATEGORY, project_id: 8, project_name: 'test8' }} | ${SMALL_AVATAR_PX} | ${null} | ${8} | ${'test8'}
+ ${{ category: MERGE_REQUEST_CATEGORY, project_id: 9, project_name: 'test9' }} | ${SMALL_AVATAR_PX} | ${null} | ${9} | ${'test9'}
+ ${{ category: RECENT_EPICS_CATEGORY, group_id: 10, group_name: 'test10' }} | ${SMALL_AVATAR_PX} | ${null} | ${10} | ${'test10'}
+ ${{ category: GROUPS_CATEGORY, group_id: 11, group_name: 'test11' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${11} | ${'test11'}
+ ${{ category: PROJECTS_CATEGORY, project_id: 12, project_name: 'test12' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${12} | ${'test12'}
+ ${{ category: ISSUES_CATEGORY, project_id: 13, project_name: 'test13' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${13} | ${'test13'}
+ ${{ category: MERGE_REQUEST_CATEGORY, project_id: 14, project_name: 'test14' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${14} | ${'test14'}
+ ${{ category: RECENT_EPICS_CATEGORY, group_id: 15, group_name: 'test15' }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${15} | ${'test15'}
+ `('formats the item', ({ item, avatarSize, searchContext, entityId, entityName }) => {
+ describe(`when item is ${JSON.stringify(item)}`, () => {
+ let formattedItem;
+ beforeEach(() => {
+ formattedItem = getFormattedItem(item, searchContext);
+ });
+
+ it(`should set text to ${item.value || item.label}`, () => {
+ expect(formattedItem.text).toBe(item.value || item.label);
+ });
+
+ it(`should set avatarSize to ${avatarSize}`, () => {
+ expect(formattedItem.avatar_size).toBe(avatarSize);
+ });
+
+ it(`should set avatar entityId to ${entityId}`, () => {
+ expect(formattedItem.entity_id).toBe(entityId);
+ });
+
+ it(`should set avatar entityName to ${entityName}`, () => {
+ expect(formattedItem.entity_name).toBe(entityName);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/groups_list_spec.js b/spec/frontend/super_sidebar/components/groups_list_spec.js
new file mode 100644
index 00000000000..4fa3303c12f
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/groups_list_spec.js
@@ -0,0 +1,90 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { s__ } from '~/locale';
+import GroupsList from '~/super_sidebar/components/groups_list.vue';
+import SearchResults from '~/super_sidebar/components/search_results.vue';
+import FrequentItemsList from '~/super_sidebar/components/frequent_items_list.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants';
+
+const username = 'root';
+const viewAllLink = '/path/to/groups';
+const storageKey = `${username}/frequent-groups`;
+
+describe('GroupsList component', () => {
+ let wrapper;
+
+ const findSearchResults = () => wrapper.findComponent(SearchResults);
+ const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList);
+ const findViewAllLink = () => wrapper.findComponent(NavItem);
+
+ const itRendersViewAllItem = () => {
+ it('renders the "View all..." item', () => {
+ const link = findViewAllLink();
+
+ expect(link.props('item')).toEqual({
+ icon: 'group',
+ link: viewAllLink,
+ title: s__('Navigation|View all your groups'),
+ });
+ expect(link.props('linkClasses')).toEqual({ 'dashboard-shortcuts-groups': true });
+ });
+ };
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMountExtended(GroupsList, {
+ propsData: {
+ username,
+ viewAllLink,
+ ...props,
+ },
+ });
+ };
+
+ describe('when displaying search results', () => {
+ const searchResults = ['A search result'];
+
+ beforeEach(() => {
+ createWrapper({
+ isSearch: true,
+ searchResults,
+ });
+ });
+
+ it('renders the search results component', () => {
+ expect(findSearchResults().exists()).toBe(true);
+ expect(findFrequentItemsList().exists()).toBe(false);
+ });
+
+ it('passes the correct props to the search results component', () => {
+ expect(findSearchResults().props()).toEqual({
+ title: s__('Navigation|Groups'),
+ noResultsText: s__('Navigation|No group matches found'),
+ searchResults,
+ });
+ });
+
+ itRendersViewAllItem();
+ });
+
+ describe('when displaying frequent groups', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders the frequent items list', () => {
+ expect(findFrequentItemsList().exists()).toBe(true);
+ expect(findSearchResults().exists()).toBe(false);
+ });
+
+ it('passes the correct props to the frequent items list', () => {
+ expect(findFrequentItemsList().props()).toEqual({
+ title: s__('Navigation|Frequently visited groups'),
+ storageKey,
+ maxItems: MAX_FREQUENT_GROUPS_COUNT,
+ pristineText: s__('Navigation|Groups you visit often will appear here.'),
+ });
+ });
+
+ itRendersViewAllItem();
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js
index bc847a3e159..808c30436a3 100644
--- a/spec/frontend/super_sidebar/components/help_center_spec.js
+++ b/spec/frontend/super_sidebar/components/help_center_spec.js
@@ -1,21 +1,25 @@
-import { GlDisclosureDropdownGroup } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui';
import { within } from '@testing-library/dom';
import toggleWhatsNewDrawer from '~/whats_new';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import HelpCenter from '~/super_sidebar/components/help_center.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
+import { DOMAIN, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { STORAGE_KEY } from '~/whats_new/utils/notification';
+import { helpCenterState } from '~/super_sidebar/constants';
+import { mockTracking } from 'helpers/tracking_helper';
import { sidebarData } from '../mock_data';
jest.mock('~/whats_new');
describe('HelpCenter component', () => {
let wrapper;
+ let trackingSpy;
const GlEmoji = { template: '<img/>' };
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findDropdownGroup = (i = 0) => {
return wrapper.findAllComponents(GlDisclosureDropdownGroup).at(i);
};
@@ -28,26 +32,58 @@ describe('HelpCenter component', () => {
propsData: { sidebarData },
stubs: { GlEmoji },
});
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
};
+ const trackingAttrs = (label) => {
+ return {
+ 'data-track-action': 'click_link',
+ 'data-track-property': 'nav_help_menu',
+ 'data-track-label': label,
+ };
+ };
+
+ const DEFAULT_HELP_ITEMS = [
+ { text: HelpCenter.i18n.help, href: helpPagePath(), extraAttrs: trackingAttrs('help') },
+ {
+ text: HelpCenter.i18n.support,
+ href: sidebarData.support_path,
+ extraAttrs: trackingAttrs('support'),
+ },
+ {
+ text: HelpCenter.i18n.docs,
+ href: `https://docs.${DOMAIN}`,
+ extraAttrs: trackingAttrs('gitlab_documentation'),
+ },
+ {
+ text: HelpCenter.i18n.plans,
+ href: `${PROMO_URL}/pricing`,
+ extraAttrs: trackingAttrs('compare_gitlab_plans'),
+ },
+ {
+ text: HelpCenter.i18n.forum,
+ href: `https://forum.${DOMAIN}/`,
+ extraAttrs: trackingAttrs('community_forum'),
+ },
+ {
+ text: HelpCenter.i18n.contribute,
+ href: helpPagePath('', { anchor: 'contributing-to-gitlab' }),
+ extraAttrs: trackingAttrs('contribute_to_gitlab'),
+ },
+ {
+ text: HelpCenter.i18n.feedback,
+ href: `${PROMO_URL}/submit-feedback`,
+ extraAttrs: trackingAttrs('submit_feedback'),
+ },
+ ];
+
describe('default', () => {
beforeEach(() => {
createWrapper(sidebarData);
});
it('renders menu items', () => {
- expect(findDropdownGroup(0).props('group').items).toEqual([
- { text: HelpCenter.i18n.help, href: helpPagePath() },
- { text: HelpCenter.i18n.support, href: sidebarData.support_path },
- { text: HelpCenter.i18n.docs, href: 'https://docs.gitlab.com' },
- { text: HelpCenter.i18n.plans, href: `${PROMO_URL}/pricing` },
- { text: HelpCenter.i18n.forum, href: 'https://forum.gitlab.com/' },
- {
- text: HelpCenter.i18n.contribute,
- href: helpPagePath('', { anchor: 'contributing-to-gitlab' }),
- },
- { text: HelpCenter.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' },
- ]);
+ expect(findDropdownGroup(0).props('group').items).toEqual(DEFAULT_HELP_ITEMS);
expect(findDropdownGroup(1).props('group').items).toEqual([
expect.objectContaining({ text: HelpCenter.i18n.shortcuts }),
@@ -55,6 +91,44 @@ describe('HelpCenter component', () => {
]);
});
+ it('passes popper options to the dropdown', () => {
+ expect(findDropdown().props('popperOptions')).toEqual({
+ modifiers: [{ name: 'offset', options: { offset: [-4, 4] } }],
+ });
+ });
+
+ describe('with show_tanuki_bot true', () => {
+ beforeEach(() => {
+ createWrapper({ ...sidebarData, show_tanuki_bot: true });
+ jest.spyOn(wrapper.vm.$refs.dropdown, 'close');
+ });
+
+ it('shows Ask GitLab Chat with the help items', () => {
+ expect(findDropdownGroup(0).props('group').items).toEqual([
+ expect.objectContaining({
+ icon: 'tanuki',
+ text: HelpCenter.i18n.chat,
+ extraAttrs: trackingAttrs('tanuki_bot_help_dropdown'),
+ }),
+ ...DEFAULT_HELP_ITEMS,
+ ]);
+ });
+
+ describe('when Ask GitLab Chat button is clicked', () => {
+ beforeEach(() => {
+ findButton('Ask GitLab Chat').click();
+ });
+
+ it('closes the dropdown', () => {
+ expect(wrapper.vm.$refs.dropdown.close).toHaveBeenCalled();
+ });
+
+ it('sets helpCenterState.showTanukiBotChatDrawer to true', () => {
+ expect(helpCenterState.showTanukiBotChatDrawer).toBe(true);
+ });
+ });
+ });
+
describe('with Gitlab version check feature enabled', () => {
beforeEach(() => {
createWrapper({ ...sidebarData, show_version_check: true });
@@ -62,30 +136,53 @@ describe('HelpCenter component', () => {
it('shows version information as first item', () => {
expect(findDropdownGroup(0).props('group').items).toEqual([
- { text: HelpCenter.i18n.version, href: helpPagePath('update/index'), version: '16.0' },
+ {
+ text: HelpCenter.i18n.version,
+ href: helpPagePath('update/index'),
+ version: '16.0',
+ extraAttrs: trackingAttrs('version_help_dropdown'),
+ },
]);
});
});
describe('showKeyboardShortcuts', () => {
+ let button;
+
beforeEach(() => {
jest.spyOn(wrapper.vm.$refs.dropdown, 'close');
- window.toggleShortcutsHelp = jest.fn();
- findButton('Keyboard shortcuts ?').click();
+
+ button = findButton('Keyboard shortcuts ?');
});
it('closes the dropdown', () => {
+ button.click();
expect(wrapper.vm.$refs.dropdown.close).toHaveBeenCalled();
});
it('shows the keyboard shortcuts modal', () => {
- expect(window.toggleShortcutsHelp).toHaveBeenCalled();
+ // This relies on the event delegation set up by the Shortcuts class in
+ // ~/behaviors/shortcuts/shortcuts.js.
+ expect(button.classList.contains('js-shortcuts-modal-trigger')).toBe(true);
+ });
+
+ it('should have Snowplow tracking attributes', () => {
+ expect(findButton('Keyboard shortcuts ?').dataset).toEqual(
+ expect.objectContaining({
+ trackAction: 'click_button',
+ trackLabel: 'keyboard_shortcuts_help',
+ trackProperty: 'nav_help_menu',
+ }),
+ );
});
});
describe('showWhatsNew', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm.$refs.dropdown, 'close');
+ beforeEach(() => {
+ createWrapper({ ...sidebarData, show_version_check: true });
+ });
findButton("What's new 5").click();
});
@@ -102,6 +199,18 @@ describe('HelpCenter component', () => {
expect(toggleWhatsNewDrawer).toHaveBeenCalledTimes(2);
expect(toggleWhatsNewDrawer).toHaveBeenLastCalledWith();
});
+
+ it('should have Snowplow tracking attributes', () => {
+ createWrapper({ ...sidebarData, display_whats_new: true });
+
+ expect(findButton("What's new 5").dataset).toEqual(
+ expect.objectContaining({
+ trackAction: 'click_button',
+ trackLabel: 'whats_new',
+ trackProperty: 'nav_help_menu',
+ }),
+ );
+ });
});
describe('shouldShowWhatsNewNotification', () => {
@@ -148,5 +257,23 @@ describe('HelpCenter component', () => {
});
});
});
+
+ describe('toggle dropdown', () => {
+ it('should track Snowplow event when dropdown is shown', () => {
+ findDropdown().vm.$emit('shown');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
+ label: 'show_help_dropdown',
+ property: 'nav_help_menu',
+ });
+ });
+
+ it('should track Snowplow event when dropdown is hidden', () => {
+ findDropdown().vm.$emit('hidden');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
+ label: 'hide_help_dropdown',
+ property: 'nav_help_menu',
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/super_sidebar/components/items_list_spec.js b/spec/frontend/super_sidebar/components/items_list_spec.js
new file mode 100644
index 00000000000..d5e8043cce9
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/items_list_spec.js
@@ -0,0 +1,101 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import ItemsList from '~/super_sidebar/components/items_list.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { cachedFrequentProjects } from '../mock_data';
+
+const mockItems = JSON.parse(cachedFrequentProjects);
+const [firstMockedProject] = mockItems;
+
+describe('ItemsList component', () => {
+ let wrapper;
+
+ const findNavItems = () => wrapper.findAllComponents(NavItem);
+
+ const createWrapper = ({ props = {}, slots = {}, mountFn = shallowMountExtended } = {}) => {
+ wrapper = mountFn(ItemsList, {
+ propsData: {
+ ...props,
+ },
+ slots,
+ });
+ };
+
+ it('does not render nav items when there are no items', () => {
+ createWrapper();
+
+ expect(findNavItems().length).toBe(0);
+ });
+
+ it('renders one nav item per item', () => {
+ createWrapper({
+ props: {
+ items: mockItems,
+ },
+ });
+
+ expect(findNavItems().length).not.toBe(0);
+ expect(findNavItems().length).toBe(mockItems.length);
+ });
+
+ it('passes the correct props to the nav items', () => {
+ createWrapper({
+ props: {
+ items: mockItems,
+ },
+ });
+ const firstNavItem = findNavItems().at(0);
+
+ expect(firstNavItem.props('item')).toEqual(firstMockedProject);
+ });
+
+ it('renders the `view-all-items` slot', () => {
+ const testId = 'view-all-items';
+ createWrapper({
+ slots: {
+ 'view-all-items': {
+ template: `<div data-testid="${testId}" />`,
+ },
+ },
+ });
+
+ expect(wrapper.findByTestId(testId).exists()).toBe(true);
+ });
+
+ describe('item removal', () => {
+ const findRemoveButton = () => wrapper.findByTestId('item-remove');
+ const mockProject = {
+ ...firstMockedProject,
+ title: firstMockedProject.name,
+ };
+
+ beforeEach(() => {
+ createWrapper({
+ props: {
+ items: [mockProject],
+ },
+ mountFn: mountExtended,
+ });
+ });
+
+ it('renders the remove button', () => {
+ const itemRemoveButton = findRemoveButton();
+
+ expect(itemRemoveButton.exists()).toBe(true);
+ expect(itemRemoveButton.attributes('title')).toBe('Remove');
+ expect(itemRemoveButton.findComponent(GlIcon).props('name')).toBe('dash');
+ });
+
+ it('emits `remove-item` event with item param when remove button is clicked', () => {
+ const itemRemoveButton = findRemoveButton();
+
+ itemRemoveButton.vm.$emit(
+ 'click',
+ { stopPropagation: jest.fn(), preventDefault: jest.fn() },
+ mockProject,
+ );
+
+ expect(wrapper.emitted('remove-item')).toEqual([[mockProject]]);
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/menu_section_spec.js b/spec/frontend/super_sidebar/components/menu_section_spec.js
new file mode 100644
index 00000000000..556e07a2e31
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/menu_section_spec.js
@@ -0,0 +1,102 @@
+import { GlCollapse } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import MenuSection from '~/super_sidebar/components/menu_section.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { stubComponent } from 'helpers/stub_component';
+
+describe('MenuSection component', () => {
+ let wrapper;
+
+ const findButton = () => wrapper.find('button');
+ const findCollapse = () => wrapper.getComponent(GlCollapse);
+ const findNavItems = () => wrapper.findAllComponents(NavItem);
+ const createWrapper = (item, otherProps) => {
+ wrapper = shallowMountExtended(MenuSection, {
+ propsData: { item, ...otherProps },
+ stubs: {
+ GlCollapse: stubComponent(GlCollapse, {
+ props: ['visible'],
+ }),
+ },
+ });
+ };
+
+ it('renders its title', () => {
+ createWrapper({ title: 'Asdf' });
+ expect(findButton().text()).toBe('Asdf');
+ });
+
+ it('renders all its subitems', () => {
+ createWrapper({
+ title: 'Asdf',
+ items: [
+ { title: 'Item1', href: '/item1' },
+ { title: 'Item2', href: '/item2' },
+ ],
+ });
+ expect(findNavItems().length).toBe(2);
+ });
+
+ it('associates button with list with aria-controls', () => {
+ createWrapper({ title: 'Asdf' });
+ expect(findButton().attributes('aria-controls')).toBe('asdf');
+ expect(findCollapse().attributes('id')).toBe('asdf');
+ });
+
+ describe('collapse behavior', () => {
+ describe('when active', () => {
+ it('is expanded', () => {
+ createWrapper({ title: 'Asdf', is_active: true });
+ expect(findCollapse().props('visible')).toBe(true);
+ });
+ });
+
+ describe('when set to expanded', () => {
+ it('is expanded', () => {
+ createWrapper({ title: 'Asdf' }, { expanded: true });
+ expect(findButton().attributes('aria-expanded')).toBe('true');
+ expect(findCollapse().props('visible')).toBe(true);
+ });
+ });
+
+ describe('when not active nor set to expanded', () => {
+ it('is not expanded', () => {
+ createWrapper({ title: 'Asdf' });
+ expect(findButton().attributes('aria-expanded')).toBe('false');
+ expect(findCollapse().props('visible')).toBe(false);
+ });
+ });
+ });
+
+ describe('`separated` prop', () => {
+ describe('by default (false)', () => {
+ it('does not render a separator', () => {
+ createWrapper({ title: 'Asdf' });
+ expect(wrapper.find('hr').exists()).toBe(false);
+ });
+ });
+
+ describe('when set to true', () => {
+ it('does render a separator', () => {
+ createWrapper({ title: 'Asdf' }, { separated: true });
+ expect(wrapper.find('hr').exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('`tag` prop', () => {
+ describe('by default', () => {
+ it('renders as <div> tag', () => {
+ createWrapper({ title: 'Asdf' });
+ expect(wrapper.element.tagName).toBe('DIV');
+ });
+ });
+
+ describe('when set to "li"', () => {
+ it('renders as <li> tag', () => {
+ createWrapper({ title: 'Asdf' }, { tag: 'li' });
+ expect(wrapper.element.tagName).toBe('LI');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/merge_request_menu_spec.js b/spec/frontend/super_sidebar/components/merge_request_menu_spec.js
index fe87c4be9c3..53d47397eb3 100644
--- a/spec/frontend/super_sidebar/components/merge_request_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/merge_request_menu_spec.js
@@ -1,6 +1,7 @@
import { GlBadge, GlDisclosureDropdown } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue';
+import { userCounts } from '~/super_sidebar/user_counts_manager';
import { mergeRequestMenuGroup } from '../mock_data';
describe('MergeRequestMenu component', () => {
@@ -8,30 +9,37 @@ describe('MergeRequestMenu component', () => {
const findGlBadge = (at) => wrapper.findAllComponents(GlBadge).at(at);
const findGlDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
- const findLink = () => wrapper.findByRole('link');
+ const findLink = (name) => wrapper.findByRole('link', { name });
- const createWrapper = () => {
+ const createWrapper = (items) => {
wrapper = mountExtended(MergeRequestMenu, {
propsData: {
- items: mergeRequestMenuGroup,
+ items,
},
});
};
describe('default', () => {
beforeEach(() => {
- createWrapper();
+ createWrapper(mergeRequestMenuGroup);
});
it('passes the items to the disclosure dropdown', () => {
expect(findGlDisclosureDropdown().props('items')).toBe(mergeRequestMenuGroup);
});
- it('renders item text and count in link', () => {
- const { text, href, count } = mergeRequestMenuGroup[0].items[0];
- expect(findLink().text()).toContain(text);
- expect(findLink().text()).toContain(String(count));
- expect(findLink().attributes('href')).toBe(href);
+ it.each(mergeRequestMenuGroup[0].items)('renders item text and count in link', (item) => {
+ const index = mergeRequestMenuGroup[0].items.indexOf(item);
+ const { text, href, count, extraAttrs } = mergeRequestMenuGroup[0].items[index];
+ const link = findLink(new RegExp(text));
+
+ expect(link.text()).toContain(text);
+ expect(link.text()).toContain(String(count));
+ expect(link.attributes('href')).toBe(href);
+ expect(link.attributes('data-track-action')).toBe(extraAttrs['data-track-action']);
+ expect(link.attributes('data-track-label')).toBe(extraAttrs['data-track-label']);
+ expect(link.attributes('data-track-property')).toBe(extraAttrs['data-track-property']);
+ expect(link.attributes('class')).toContain(extraAttrs.class);
});
it('renders item count string in badge', () => {
@@ -42,5 +50,21 @@ describe('MergeRequestMenu component', () => {
it('renders 0 string when count is empty', () => {
expect(findGlBadge(1).text()).toBe(String(0));
});
+
+ it('renders value from userCounts if `userCount` prop is defined', () => {
+ userCounts.assigned_merge_requests = 5;
+ mergeRequestMenuGroup[0].items[0].userCount = 'assigned_merge_requests';
+ createWrapper(mergeRequestMenuGroup);
+
+ expect(findGlBadge(0).text()).toBe(String(userCounts.assigned_merge_requests));
+ });
+
+ it('renders item count if unknown `userCount` prop is defined', () => {
+ const { count } = mergeRequestMenuGroup[0].items[0];
+ mergeRequestMenuGroup[0].items[0].userCount = 'foobar';
+ createWrapper(mergeRequestMenuGroup);
+
+ expect(findGlBadge(0).text()).toBe(String(count));
+ });
});
});
diff --git a/spec/frontend/super_sidebar/components/nav_item_link_spec.js b/spec/frontend/super_sidebar/components/nav_item_link_spec.js
new file mode 100644
index 00000000000..5cc1bd01d0f
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/nav_item_link_spec.js
@@ -0,0 +1,37 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import NavItemLink from '~/super_sidebar/components/nav_item_link.vue';
+
+describe('NavItemLink component', () => {
+ let wrapper;
+
+ const createWrapper = (item) => {
+ wrapper = shallowMountExtended(NavItemLink, {
+ propsData: {
+ item,
+ },
+ });
+ };
+
+ describe('when `item` has `is_active` set to `false`', () => {
+ it('renders an anchor tag without active CSS class and `aria-current` attribute', () => {
+ createWrapper({ title: 'foo', link: '/foo', is_active: false });
+
+ expect(wrapper.attributes()).toEqual({
+ href: '/foo',
+ class: '',
+ });
+ });
+ });
+
+ describe('when `item` has `is_active` set to `true`', () => {
+ it('renders an anchor tag with active CSS class and `aria-current="page"`', () => {
+ createWrapper({ title: 'foo', link: '/foo', is_active: true });
+
+ expect(wrapper.attributes()).toEqual({
+ href: '/foo',
+ class: 'gl-bg-t-gray-a-08',
+ 'aria-current': 'page',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js b/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js
new file mode 100644
index 00000000000..a7ca56325fe
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js
@@ -0,0 +1,56 @@
+import { RouterLinkStub } from '@vue/test-utils';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import NavItemRouterLink from '~/super_sidebar/components/nav_item_router_link.vue';
+
+describe('NavItemRouterLink component', () => {
+ let wrapper;
+
+ const createWrapper = ({ item, routerLinkSlotProps = {} }) => {
+ wrapper = mountExtended(NavItemRouterLink, {
+ propsData: {
+ item,
+ },
+ stubs: {
+ RouterLink: {
+ ...RouterLinkStub,
+ render() {
+ const children = this.$scopedSlots.default({
+ href: '/foo',
+ isActive: false,
+ navigate: jest.fn(),
+ ...routerLinkSlotProps,
+ });
+ return children;
+ },
+ },
+ },
+ });
+ };
+
+ describe('when `RouterLink` is not active', () => {
+ it('renders an anchor tag without active CSS class and `aria-current` attribute', () => {
+ createWrapper({ item: { title: 'foo', to: { name: 'foo' } } });
+
+ expect(wrapper.attributes()).toEqual({
+ href: '/foo',
+ custom: '',
+ });
+ });
+ });
+
+ describe('when `RouterLink` is active', () => {
+ it('renders an anchor tag with active CSS class and `aria-current="page"`', () => {
+ createWrapper({
+ item: { title: 'foo', to: { name: 'foo' } },
+ routerLinkSlotProps: { isActive: true },
+ });
+
+ expect(wrapper.findComponent(RouterLinkStub).props('activeClass')).toBe('gl-bg-t-gray-a-08');
+ expect(wrapper.attributes()).toEqual({
+ href: '/foo',
+ 'aria-current': 'page',
+ custom: '',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/nav_item_spec.js b/spec/frontend/super_sidebar/components/nav_item_spec.js
new file mode 100644
index 00000000000..54ac4965ad8
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/nav_item_spec.js
@@ -0,0 +1,156 @@
+import { GlBadge } from '@gitlab/ui';
+import { RouterLinkStub } from '@vue/test-utils';
+import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import NavItemRouterLink from '~/super_sidebar/components/nav_item_router_link.vue';
+import NavItemLink from '~/super_sidebar/components/nav_item_link.vue';
+import {
+ CLICK_MENU_ITEM_ACTION,
+ TRACKING_UNKNOWN_ID,
+ TRACKING_UNKNOWN_PANEL,
+} from '~/super_sidebar/constants';
+
+describe('NavItem component', () => {
+ let wrapper;
+
+ const findLink = () => wrapper.findByTestId('nav-item-link');
+ const findPill = () => wrapper.findComponent(GlBadge);
+ const findNavItemRouterLink = () => extendedWrapper(wrapper.findComponent(NavItemRouterLink));
+ const findNavItemLink = () => extendedWrapper(wrapper.findComponent(NavItemLink));
+
+ const createWrapper = ({ item, props = {}, provide = {}, routerLinkSlotProps = {} }) => {
+ wrapper = mountExtended(NavItem, {
+ propsData: {
+ item,
+ ...props,
+ },
+ provide,
+ stubs: {
+ RouterLink: {
+ ...RouterLinkStub,
+ render() {
+ const children = this.$scopedSlots.default({
+ href: '/foo',
+ isActive: false,
+ navigate: jest.fn(),
+ ...routerLinkSlotProps,
+ });
+ return children;
+ },
+ },
+ },
+ });
+ };
+
+ describe('pills', () => {
+ it.each([0, 5, 3.4, 'foo', '10%'])('item with pill_data `%p` renders a pill', (pillCount) => {
+ createWrapper({ item: { title: 'Foo', pill_count: pillCount } });
+
+ expect(findPill().text()).toEqual(pillCount.toString());
+ });
+
+ it.each([null, undefined, false, true, '', NaN, Number.POSITIVE_INFINITY])(
+ 'item with pill_data `%p` renders no pill',
+ (pillCount) => {
+ createWrapper({ item: { title: 'Foo', pill_count: pillCount } });
+
+ expect(findPill().exists()).toEqual(false);
+ },
+ );
+ });
+
+ it('applies custom link classes', () => {
+ const customClass = 'customClass';
+ createWrapper({
+ item: { title: 'Foo' },
+ props: {
+ linkClasses: {
+ [customClass]: true,
+ },
+ },
+ });
+
+ expect(findLink().attributes('class')).toContain(customClass);
+ });
+
+ it('applies custom classes set in the backend', () => {
+ const customClass = 'customBackendClass';
+ createWrapper({ item: { title: 'Foo', link_classes: customClass } });
+
+ expect(findLink().attributes('class')).toContain(customClass);
+ });
+
+ it('applies data-method specified in the backend', () => {
+ const method = 'post';
+ createWrapper({ item: { title: 'Foo', data_method: method } });
+
+ expect(findLink().attributes('data-method')).toContain(method);
+ });
+
+ describe('Data Tracking Attributes', () => {
+ it.each`
+ id | panelType | eventLabel | eventProperty | eventExtra
+ ${'abc'} | ${'xyz'} | ${'abc'} | ${'nav_panel_xyz'} | ${undefined}
+ ${undefined} | ${'xyz'} | ${TRACKING_UNKNOWN_ID} | ${'nav_panel_xyz'} | ${'{"title":"Foo"}'}
+ ${'abc'} | ${undefined} | ${'abc'} | ${TRACKING_UNKNOWN_PANEL} | ${'{"title":"Foo"}'}
+ ${undefined} | ${undefined} | ${TRACKING_UNKNOWN_ID} | ${TRACKING_UNKNOWN_PANEL} | ${'{"title":"Foo"}'}
+ `(
+ 'adds appropriate data tracking labels for id=$id and panelType=$panelType',
+ ({ id, eventLabel, panelType, eventProperty, eventExtra }) => {
+ createWrapper({ item: { title: 'Foo', id }, props: {}, provide: { panelType } });
+
+ expect(findLink().attributes('data-track-action')).toBe(CLICK_MENU_ITEM_ACTION);
+ expect(findLink().attributes('data-track-label')).toBe(eventLabel);
+ expect(findLink().attributes('data-track-property')).toBe(eventProperty);
+ expect(findLink().attributes('data-track-extra')).toBe(eventExtra);
+ },
+ );
+ });
+
+ describe('when `item` prop has `to` attribute', () => {
+ describe('when `RouterLink` is not active', () => {
+ it('renders `NavItemRouterLink` with active indicator hidden', () => {
+ createWrapper({ item: { title: 'Foo', to: { name: 'foo' } } });
+
+ expect(findNavItemRouterLink().findByTestId('active-indicator').classes()).toContain(
+ 'gl-bg-transparent',
+ );
+ });
+ });
+
+ describe('when `RouterLink` is active', () => {
+ it('renders `NavItemRouterLink` with active indicator shown', () => {
+ createWrapper({
+ item: { title: 'Foo', to: { name: 'foo' } },
+ routerLinkSlotProps: { isActive: true },
+ });
+
+ expect(findNavItemRouterLink().findByTestId('active-indicator').classes()).toContain(
+ 'gl-bg-blue-500',
+ );
+ });
+ });
+ });
+
+ describe('when `item` prop has `link` attribute', () => {
+ describe('when `item` has `is_active` set to `false`', () => {
+ it('renders `NavItemLink` with active indicator hidden', () => {
+ createWrapper({ item: { title: 'Foo', link: '/foo', is_active: false } });
+
+ expect(findNavItemLink().findByTestId('active-indicator').classes()).toContain(
+ 'gl-bg-transparent',
+ );
+ });
+ });
+
+ describe('when `item` has `is_active` set to `true`', () => {
+ it('renders `NavItemLink` with active indicator shown', () => {
+ createWrapper({ item: { title: 'Foo', link: '/foo', is_active: true } });
+
+ expect(findNavItemLink().findByTestId('active-indicator').classes()).toContain(
+ 'gl-bg-blue-500',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/pinned_section_spec.js b/spec/frontend/super_sidebar/components/pinned_section_spec.js
new file mode 100644
index 00000000000..fd6e2b7343e
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/pinned_section_spec.js
@@ -0,0 +1,75 @@
+import { nextTick } from 'vue';
+import Cookies from '~/lib/utils/cookies';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import PinnedSection from '~/super_sidebar/components/pinned_section.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '~/super_sidebar/constants';
+import { setCookie } from '~/lib/utils/common_utils';
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ getCookie: jest.requireActual('~/lib/utils/common_utils').getCookie,
+ setCookie: jest.fn(),
+}));
+
+describe('PinnedSection component', () => {
+ let wrapper;
+
+ const findToggle = () => wrapper.find('button');
+
+ const createWrapper = () => {
+ wrapper = mountExtended(PinnedSection, {
+ propsData: {
+ items: [{ title: 'Pin 1', href: '/page1' }],
+ },
+ });
+ };
+
+ describe('expanded', () => {
+ describe('when cookie is not set', () => {
+ it('is expanded by default', () => {
+ createWrapper();
+ expect(wrapper.findComponent(NavItem).isVisible()).toBe(true);
+ });
+ });
+
+ describe('when cookie is set to false', () => {
+ beforeEach(() => {
+ Cookies.set(SIDEBAR_PINS_EXPANDED_COOKIE, 'false');
+ createWrapper();
+ });
+
+ it('is collapsed', () => {
+ expect(wrapper.findComponent(NavItem).isVisible()).toBe(false);
+ });
+
+ it('updates the cookie when expanding the section', async () => {
+ findToggle().trigger('click');
+ await nextTick();
+
+ expect(setCookie).toHaveBeenCalledWith(SIDEBAR_PINS_EXPANDED_COOKIE, true, {
+ expires: SIDEBAR_COOKIE_EXPIRATION,
+ });
+ });
+ });
+
+ describe('when cookie is set to true', () => {
+ beforeEach(() => {
+ Cookies.set(SIDEBAR_PINS_EXPANDED_COOKIE, 'true');
+ createWrapper();
+ });
+
+ it('is expanded', () => {
+ expect(wrapper.findComponent(NavItem).isVisible()).toBe(true);
+ });
+
+ it('updates the cookie when collapsing the section', async () => {
+ findToggle().trigger('click');
+ await nextTick();
+
+ expect(setCookie).toHaveBeenCalledWith(SIDEBAR_PINS_EXPANDED_COOKIE, false, {
+ expires: SIDEBAR_COOKIE_EXPIRATION,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/projects_list_spec.js b/spec/frontend/super_sidebar/components/projects_list_spec.js
new file mode 100644
index 00000000000..93a414e1e8c
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/projects_list_spec.js
@@ -0,0 +1,85 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { s__ } from '~/locale';
+import ProjectsList from '~/super_sidebar/components/projects_list.vue';
+import SearchResults from '~/super_sidebar/components/search_results.vue';
+import FrequentItemsList from '~/super_sidebar/components/frequent_items_list.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants';
+
+const username = 'root';
+const viewAllLink = '/path/to/projects';
+const storageKey = `${username}/frequent-projects`;
+
+describe('ProjectsList component', () => {
+ let wrapper;
+
+ const findSearchResults = () => wrapper.findComponent(SearchResults);
+ const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList);
+ const findViewAllLink = () => wrapper.findComponent(NavItem);
+
+ const itRendersViewAllItem = () => {
+ it('renders the "View all..." item', () => {
+ const link = findViewAllLink();
+
+ expect(link.props('item')).toEqual({
+ icon: 'project',
+ link: viewAllLink,
+ title: s__('Navigation|View all your projects'),
+ });
+ expect(link.props('linkClasses')).toEqual({ 'dashboard-shortcuts-projects': true });
+ });
+ };
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMountExtended(ProjectsList, {
+ propsData: {
+ username,
+ viewAllLink,
+ ...props,
+ },
+ });
+ };
+
+ describe('when displaying search results', () => {
+ const searchResults = ['A search result'];
+
+ beforeEach(() => {
+ createWrapper({
+ isSearch: true,
+ searchResults,
+ });
+ });
+
+ it('renders the search results component', () => {
+ expect(findSearchResults().exists()).toBe(true);
+ expect(findFrequentItemsList().exists()).toBe(false);
+ });
+
+ it('passes the correct props to the search results component', () => {
+ expect(findSearchResults().props()).toEqual({
+ title: s__('Navigation|Projects'),
+ noResultsText: s__('Navigation|No project matches found'),
+ searchResults,
+ });
+ });
+
+ itRendersViewAllItem();
+ });
+
+ describe('when displaying frequent projects', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('passes the correct props to the frequent items list', () => {
+ expect(findFrequentItemsList().props()).toEqual({
+ title: s__('Navigation|Frequently visited projects'),
+ storageKey,
+ maxItems: MAX_FREQUENT_PROJECTS_COUNT,
+ pristineText: s__('Navigation|Projects you visit often will appear here.'),
+ });
+ });
+
+ itRendersViewAllItem();
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/search_results_spec.js b/spec/frontend/super_sidebar/components/search_results_spec.js
new file mode 100644
index 00000000000..daec5c2a9b4
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/search_results_spec.js
@@ -0,0 +1,69 @@
+import { GlCollapse } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { s__ } from '~/locale';
+import SearchResults from '~/super_sidebar/components/search_results.vue';
+import ItemsList from '~/super_sidebar/components/items_list.vue';
+import { stubComponent } from 'helpers/stub_component';
+
+const title = s__('Navigation|PROJECTS');
+const noResultsText = s__('Navigation|No project matches found');
+
+describe('SearchResults component', () => {
+ let wrapper;
+
+ const findSearchResultsToggle = () => wrapper.findByTestId('search-results-toggle');
+ const findCollapsibleSection = () => wrapper.findComponent(GlCollapse);
+ const findItemsList = () => wrapper.findComponent(ItemsList);
+ const findEmptyText = () => wrapper.findByTestId('empty-text');
+
+ const createWrapper = ({ props = {} } = {}) => {
+ wrapper = shallowMountExtended(SearchResults, {
+ propsData: {
+ title,
+ noResultsText,
+ ...props,
+ },
+ stubs: {
+ GlCollapse: stubComponent(GlCollapse, {
+ props: ['visible'],
+ }),
+ },
+ });
+ };
+
+ describe('default state', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it("renders the list's title", () => {
+ expect(findSearchResultsToggle().text()).toBe(title);
+ });
+
+ it('is expanded', () => {
+ expect(findCollapsibleSection().props('visible')).toBe(true);
+ });
+
+ it('renders the empty text', () => {
+ expect(findEmptyText().exists()).toBe(true);
+ expect(findEmptyText().text()).toBe(noResultsText);
+ });
+ });
+
+ describe('when displaying search results', () => {
+ it('shows search results', () => {
+ const searchResults = [{ id: 1 }];
+ createWrapper({ props: { isSearch: true, searchResults } });
+
+ expect(findItemsList().props('items')[0]).toEqual(searchResults[0]);
+ });
+
+ it('shows the no results text if search results are empty', () => {
+ const searchResults = [];
+ createWrapper({ props: { isSearch: true, searchResults } });
+
+ expect(findItemsList().props('items').length).toEqual(0);
+ expect(findEmptyText().text()).toBe(noResultsText);
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js
new file mode 100644
index 00000000000..9b726b620dd
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js
@@ -0,0 +1,184 @@
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue';
+import PinnedSection from '~/super_sidebar/components/pinned_section.vue';
+import { PANELS_WITH_PINS } from '~/super_sidebar/constants';
+import { sidebarData } from '../mock_data';
+
+const menuItems = [
+ { id: 1, title: 'No subitems' },
+ { id: 2, title: 'With subitems', items: [{ id: 21, title: 'Pinned subitem' }] },
+ { id: 3, title: 'Empty subitems array', items: [] },
+ { id: 4, title: 'Also with subitems', items: [{ id: 41, title: 'Subitem' }] },
+];
+
+describe('SidebarMenu component', () => {
+ let wrapper;
+
+ const createWrapper = (mockData) => {
+ wrapper = mountExtended(SidebarMenu, {
+ propsData: {
+ items: mockData.current_menu_items,
+ pinnedItemIds: mockData.pinned_items,
+ panelType: mockData.panel_type,
+ updatePinsUrl: mockData.update_pins_url,
+ },
+ });
+ };
+
+ const findPinnedSection = () => wrapper.findComponent(PinnedSection);
+ const findMainMenuSeparator = () => wrapper.findByTestId('main-menu-separator');
+
+ describe('computed', () => {
+ describe('supportsPins', () => {
+ it('is true for the project sidebar', () => {
+ createWrapper({ ...sidebarData, panel_type: 'project' });
+ expect(wrapper.vm.supportsPins).toBe(true);
+ });
+
+ it('is true for the group sidebar', () => {
+ createWrapper({ ...sidebarData, panel_type: 'group' });
+ expect(wrapper.vm.supportsPins).toBe(true);
+ });
+
+ it('is false for any other sidebar', () => {
+ createWrapper({ ...sidebarData, panel_type: 'your_work' });
+ expect(wrapper.vm.supportsPins).toEqual(false);
+ });
+ });
+
+ describe('flatPinnableItems', () => {
+ it('returns all subitems in a flat array', () => {
+ createWrapper({ ...sidebarData, current_menu_items: menuItems });
+ expect(wrapper.vm.flatPinnableItems).toEqual([
+ { id: 21, title: 'Pinned subitem' },
+ { id: 41, title: 'Subitem' },
+ ]);
+ });
+ });
+
+ describe('staticItems', () => {
+ describe('when the sidebar supports pins', () => {
+ beforeEach(() => {
+ createWrapper({
+ ...sidebarData,
+ current_menu_items: menuItems,
+ panel_type: PANELS_WITH_PINS[0],
+ });
+ });
+
+ it('makes everything that has no subitems a static item', () => {
+ expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([
+ 'No subitems',
+ 'Empty subitems array',
+ ]);
+ });
+ });
+
+ describe('when the sidebar does not support pins', () => {
+ beforeEach(() => {
+ createWrapper({
+ ...sidebarData,
+ current_menu_items: menuItems,
+ panel_type: 'explore',
+ });
+ });
+
+ it('returns an empty array', () => {
+ expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([]);
+ });
+ });
+ });
+
+ describe('nonStaticItems', () => {
+ describe('when the sidebar supports pins', () => {
+ beforeEach(() => {
+ createWrapper({
+ ...sidebarData,
+ current_menu_items: menuItems,
+ panel_type: PANELS_WITH_PINS[0],
+ });
+ });
+
+ it('keeps items that have subitems (aka "sections") as non-static', () => {
+ expect(wrapper.vm.nonStaticItems.map((i) => i.title)).toEqual([
+ 'With subitems',
+ 'Also with subitems',
+ ]);
+ });
+ });
+
+ describe('when the sidebar does not support pins', () => {
+ beforeEach(() => {
+ createWrapper({
+ ...sidebarData,
+ current_menu_items: menuItems,
+ panel_type: 'explore',
+ });
+ });
+
+ it('keeps all items as non-static', () => {
+ expect(wrapper.vm.nonStaticItems).toEqual(menuItems);
+ });
+ });
+ });
+
+ describe('pinnedItems', () => {
+ describe('when user has no pinned item ids stored', () => {
+ beforeEach(() => {
+ createWrapper({
+ ...sidebarData,
+ current_menu_items: menuItems,
+ pinned_items: [],
+ });
+ });
+
+ it('returns an empty array', () => {
+ expect(wrapper.vm.pinnedItems).toEqual([]);
+ });
+ });
+
+ describe('when user has some pinned item ids stored', () => {
+ beforeEach(() => {
+ createWrapper({
+ ...sidebarData,
+ current_menu_items: menuItems,
+ pinned_items: [21],
+ });
+ });
+
+ it('returns the items matching the pinned ids', () => {
+ expect(wrapper.vm.pinnedItems).toEqual([{ id: 21, title: 'Pinned subitem' }]);
+ });
+ });
+ });
+ });
+
+ describe('Menu separators', () => {
+ it('should add the separator above pinned section', () => {
+ createWrapper({
+ ...sidebarData,
+ current_menu_items: menuItems,
+ panel_type: 'project',
+ });
+ expect(findPinnedSection().props('separated')).toBe(true);
+ });
+
+ it('should add the separator above main menu items when there is a pinned section', () => {
+ createWrapper({
+ ...sidebarData,
+ current_menu_items: menuItems,
+ panel_type: PANELS_WITH_PINS[0],
+ });
+ expect(findMainMenuSeparator().exists()).toBe(true);
+ });
+
+ it('should NOT add the separator above main menu items when there is no pinned section', () => {
+ createWrapper({
+ ...sidebarData,
+ current_menu_items: menuItems,
+ panel_type: 'explore',
+ });
+ expect(findMainMenuSeparator().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js b/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js
new file mode 100644
index 00000000000..047dc9a6599
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js
@@ -0,0 +1,207 @@
+import { mount } from '@vue/test-utils';
+import {
+ SUPER_SIDEBAR_PEEK_OPEN_DELAY,
+ SUPER_SIDEBAR_PEEK_CLOSE_DELAY,
+} from '~/super_sidebar/constants';
+import SidebarPeek, {
+ STATE_CLOSED,
+ STATE_WILL_OPEN,
+ STATE_OPEN,
+ STATE_WILL_CLOSE,
+} from '~/super_sidebar/components/sidebar_peek_behavior.vue';
+
+// These are measured at runtime in the browser, but statically defined here
+// since Jest does not do layout/styling.
+const X_NEAR_WINDOW_EDGE = 5;
+const X_SIDEBAR_EDGE = 10;
+const X_AWAY_FROM_SIDEBAR = 20;
+
+jest.mock('~/lib/utils/css_utils', () => ({
+ getCssClassDimensions: (className) => {
+ if (className === 'gl-w-3') {
+ return { width: X_NEAR_WINDOW_EDGE };
+ }
+
+ if (className === 'super-sidebar') {
+ return { width: X_SIDEBAR_EDGE };
+ }
+
+ throw new Error(`No mock for CSS class ${className}`);
+ },
+}));
+
+describe('SidebarPeek component', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = mount(SidebarPeek);
+ };
+
+ const moveMouse = (clientX) => {
+ const event = new MouseEvent('mousemove', {
+ clientX,
+ });
+
+ document.dispatchEvent(event);
+ };
+
+ const moveMouseOutOfDocument = () => {
+ const event = new MouseEvent('mouseleave');
+ document.documentElement.dispatchEvent(event);
+ };
+
+ const lastNChangeEvents = (n = 1) => wrapper.emitted('change').slice(-n).flat();
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('begins in the closed state', () => {
+ expect(lastNChangeEvents(Infinity)).toEqual([STATE_CLOSED]);
+ });
+
+ it('does not emit duplicate events in a region', () => {
+ moveMouse(0);
+ moveMouse(1);
+ moveMouse(2);
+
+ expect(lastNChangeEvents(Infinity)).toEqual([STATE_CLOSED, STATE_WILL_OPEN]);
+ });
+
+ it('transitions to will-open when in peek region', () => {
+ moveMouse(X_NEAR_WINDOW_EDGE);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_CLOSED]);
+
+ moveMouse(X_NEAR_WINDOW_EDGE - 1);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_OPEN]);
+ });
+
+ it('transitions will-open -> open after delay', () => {
+ moveMouse(0);
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY - 1);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_OPEN]);
+
+ jest.advanceTimersByTime(1);
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_WILL_OPEN, STATE_OPEN]);
+ });
+
+ it('cancels transition will-open -> open if mouse out of peek region', () => {
+ moveMouse(0);
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY - 1);
+
+ moveMouse(X_NEAR_WINDOW_EDGE);
+
+ jest.runOnlyPendingTimers();
+
+ expect(lastNChangeEvents(3)).toEqual([STATE_CLOSED, STATE_WILL_OPEN, STATE_CLOSED]);
+ });
+
+ it('transitions open -> will-close if mouse out of sidebar region', () => {
+ moveMouse(0);
+ jest.runOnlyPendingTimers();
+
+ moveMouse(X_SIDEBAR_EDGE - 1);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]);
+
+ moveMouse(X_SIDEBAR_EDGE);
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_OPEN, STATE_WILL_CLOSE]);
+ });
+
+ it('transitions will-close -> closed after delay', () => {
+ moveMouse(0);
+ jest.runOnlyPendingTimers();
+
+ moveMouse(X_SIDEBAR_EDGE);
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_CLOSE]);
+
+ jest.advanceTimersByTime(1);
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_WILL_CLOSE, STATE_CLOSED]);
+ });
+
+ it('cancels transition will-close -> close if mouse move in sidebar region', () => {
+ moveMouse(0);
+ jest.runOnlyPendingTimers();
+
+ moveMouse(X_SIDEBAR_EDGE);
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_CLOSE]);
+
+ moveMouse(X_SIDEBAR_EDGE - 1);
+ jest.runOnlyPendingTimers();
+
+ expect(lastNChangeEvents(3)).toEqual([STATE_OPEN, STATE_WILL_CLOSE, STATE_OPEN]);
+ });
+
+ it('immediately transitions open -> closed if mouse moves far away', () => {
+ moveMouse(0);
+ jest.runOnlyPendingTimers();
+
+ moveMouse(X_AWAY_FROM_SIDEBAR);
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_OPEN, STATE_CLOSED]);
+ });
+
+ it('immediately transitions will-close -> closed if mouse moves far away', () => {
+ moveMouse(0);
+ jest.runOnlyPendingTimers();
+
+ moveMouse(X_AWAY_FROM_SIDEBAR - 1);
+ moveMouse(X_AWAY_FROM_SIDEBAR);
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_WILL_CLOSE, STATE_CLOSED]);
+ });
+
+ it('cleans up its mousemove listener before destroy', () => {
+ moveMouse(0);
+ jest.runOnlyPendingTimers();
+
+ wrapper.destroy();
+ moveMouse(X_AWAY_FROM_SIDEBAR);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]);
+ });
+
+ it('cleans up its timers before destroy', () => {
+ moveMouse(0);
+
+ wrapper.destroy();
+ jest.runOnlyPendingTimers();
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_OPEN]);
+ });
+
+ it('transitions will-open -> closed if cursor leaves document', () => {
+ moveMouse(0);
+ moveMouseOutOfDocument();
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_WILL_OPEN, STATE_CLOSED]);
+ });
+
+ it('transitions open -> will-close if cursor leaves document', () => {
+ moveMouse(0);
+ jest.runOnlyPendingTimers();
+ moveMouseOutOfDocument();
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_OPEN, STATE_WILL_CLOSE]);
+ });
+
+ it('cleans up document mouseleave listener before destroy', () => {
+ moveMouse(0);
+
+ wrapper.destroy();
+
+ moveMouseOutOfDocument();
+
+ expect(lastNChangeEvents(1)).not.toEqual([STATE_CLOSED]);
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/sidebar_portal_spec.js b/spec/frontend/super_sidebar/components/sidebar_portal_spec.js
new file mode 100644
index 00000000000..3ef1cb7e692
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/sidebar_portal_spec.js
@@ -0,0 +1,68 @@
+import { nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
+import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue';
+import SidebarPortalTarget from '~/super_sidebar/components/sidebar_portal_target.vue';
+
+describe('SidebarPortal', () => {
+ let targetWrapper;
+
+ const Target = {
+ components: { SidebarPortalTarget },
+ props: ['show'],
+ template: '<sidebar-portal-target v-if="show" />',
+ };
+
+ const Source = {
+ components: { SidebarPortal },
+ template: '<sidebar-portal><br data-testid="test"></sidebar-portal>',
+ };
+
+ const mountSource = () => {
+ mount(Source);
+ };
+
+ const mountTarget = ({ show = true } = {}) => {
+ targetWrapper = mount(Target, {
+ propsData: { show },
+ attachTo: document.body,
+ });
+ };
+
+ const findTestContent = () => targetWrapper.find('[data-testid="test"]');
+
+ it('renders content into the target', async () => {
+ mountTarget();
+ await nextTick();
+
+ mountSource();
+ await nextTick();
+
+ expect(findTestContent().exists()).toBe(true);
+ });
+
+ it('waits for target to be available before rendering', async () => {
+ mountSource();
+ await nextTick();
+
+ mountTarget();
+ await nextTick();
+
+ expect(findTestContent().exists()).toBe(true);
+ });
+
+ it('supports conditional rendering of target', async () => {
+ mountTarget({ show: false });
+ await nextTick();
+
+ mountSource();
+ await nextTick();
+
+ expect(findTestContent().exists()).toBe(false);
+
+ await targetWrapper.setProps({ show: true });
+ expect(findTestContent().exists()).toBe(true);
+
+ await targetWrapper.setProps({ show: false });
+ expect(findTestContent().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js
index 45fc30c08f0..b76c637caf4 100644
--- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js
+++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js
@@ -1,35 +1,247 @@
+import { nextTick } from 'vue';
+import { Mousetrap } from '~/lib/mousetrap';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue';
import HelpCenter from '~/super_sidebar/components/help_center.vue';
import UserBar from '~/super_sidebar/components/user_bar.vue';
-import { sidebarData } from '../mock_data';
+import SidebarPeekBehavior, {
+ STATE_CLOSED,
+ STATE_WILL_OPEN,
+ STATE_OPEN,
+ STATE_WILL_CLOSE,
+} from '~/super_sidebar/components/sidebar_peek_behavior.vue';
+import SidebarPortalTarget from '~/super_sidebar/components/sidebar_portal_target.vue';
+import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue';
+import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue';
+import { sidebarState } from '~/super_sidebar/constants';
+import {
+ toggleSuperSidebarCollapsed,
+ isCollapsed,
+} from '~/super_sidebar/super_sidebar_collapsed_state_manager';
+import { stubComponent } from 'helpers/stub_component';
+import { sidebarData as mockSidebarData } from '../mock_data';
+
+const initialSidebarState = { ...sidebarState };
+
+jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager');
+const closeContextSwitcherMock = jest.fn();
+
+const trialStatusWidgetStubTestId = 'trial-status-widget';
+const TrialStatusWidgetStub = { template: `<div data-testid="${trialStatusWidgetStubTestId}" />` };
+const trialStatusPopoverStubTestId = 'trial-status-popover';
+const TrialStatusPopoverStub = {
+ template: `<div data-testid="${trialStatusPopoverStubTestId}" />`,
+};
+
+const peekClass = 'super-sidebar-peek';
+const peekHintClass = 'super-sidebar-peek-hint';
describe('SuperSidebar component', () => {
let wrapper;
+ const findSidebar = () => wrapper.findByTestId('super-sidebar');
const findUserBar = () => wrapper.findComponent(UserBar);
+ const findContextSwitcher = () => wrapper.findComponent(ContextSwitcher);
+ const findNavContainer = () => wrapper.findByTestId('nav-container');
const findHelpCenter = () => wrapper.findComponent(HelpCenter);
+ const findSidebarPortalTarget = () => wrapper.findComponent(SidebarPortalTarget);
+ const findPeekBehavior = () => wrapper.findComponent(SidebarPeekBehavior);
+ const findTrialStatusWidget = () => wrapper.findByTestId(trialStatusWidgetStubTestId);
+ const findTrialStatusPopover = () => wrapper.findByTestId(trialStatusPopoverStubTestId);
+ const findSidebarMenu = () => wrapper.findComponent(SidebarMenu);
+
+ const createWrapper = ({
+ provide = {},
+ sidebarData = mockSidebarData,
+ sidebarState: state = {},
+ } = {}) => {
+ Object.assign(sidebarState, state);
- const createWrapper = (props = {}) => {
wrapper = shallowMountExtended(SuperSidebar, {
+ provide: {
+ showTrialStatusWidget: false,
+ ...provide,
+ },
propsData: {
sidebarData,
- ...props,
+ },
+ stubs: {
+ ContextSwitcher: stubComponent(ContextSwitcher, {
+ methods: { close: closeContextSwitcherMock },
+ }),
+ TrialStatusWidget: TrialStatusWidgetStub,
+ TrialStatusPopover: TrialStatusPopoverStub,
},
});
};
+ beforeEach(() => {
+ Object.assign(sidebarState, initialSidebarState);
+ });
+
describe('default', () => {
- beforeEach(() => {
+ it('adds inert attribute when collapsed', () => {
+ createWrapper({ sidebarState: { isCollapsed: true } });
+ expect(findSidebar().attributes('inert')).toBe('inert');
+ });
+
+ it('does not add inert attribute when expanded', () => {
createWrapper();
+ expect(findSidebar().attributes('inert')).toBe(undefined);
});
it('renders UserBar with sidebarData', () => {
- expect(findUserBar().props('sidebarData')).toBe(sidebarData);
+ createWrapper();
+ expect(findUserBar().props('sidebarData')).toBe(mockSidebarData);
});
it('renders HelpCenter with sidebarData', () => {
- expect(findHelpCenter().props('sidebarData')).toBe(sidebarData);
+ createWrapper();
+ expect(findHelpCenter().props('sidebarData')).toBe(mockSidebarData);
+ });
+
+ it('does not render SidebarMenu when items are empty', () => {
+ createWrapper();
+ expect(findSidebarMenu().exists()).toBe(false);
+ });
+
+ it('renders SidebarMenu with menu items', () => {
+ const menuItems = [
+ { id: 1, title: 'Menu item 1' },
+ { id: 2, title: 'Menu item 2' },
+ ];
+ createWrapper({ sidebarData: { ...mockSidebarData, current_menu_items: menuItems } });
+ expect(findSidebarMenu().props('items')).toBe(menuItems);
+ });
+
+ it('renders SidebarPortalTarget', () => {
+ createWrapper();
+ expect(findSidebarPortalTarget().exists()).toBe(true);
+ });
+
+ it("does not call the context switcher's close method initially", () => {
+ createWrapper();
+
+ expect(closeContextSwitcherMock).not.toHaveBeenCalled();
+ });
+
+ it('renders hidden shortcut links', () => {
+ createWrapper();
+ const [linkAttrs] = mockSidebarData.shortcut_links;
+ const link = wrapper.find(`.${linkAttrs.css_class}`);
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe(linkAttrs.href);
+ expect(link.attributes('class')).toContain('gl-display-none');
+ });
+
+ it('sets up the sidebar toggle shortcut', () => {
+ createWrapper();
+
+ isCollapsed.mockReturnValue(false);
+ Mousetrap.trigger('mod+\\');
+
+ expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(1);
+ expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(true, true);
+
+ isCollapsed.mockReturnValue(true);
+ Mousetrap.trigger('mod+\\');
+
+ expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(2);
+ expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(false, true);
+
+ jest.spyOn(Mousetrap, 'unbind');
+
+ wrapper.destroy();
+
+ expect(Mousetrap.unbind).toHaveBeenCalledWith(['mod+\\']);
+ });
+
+ it('does not render trial status widget', () => {
+ createWrapper();
+
+ expect(findTrialStatusWidget().exists()).toBe(false);
+ expect(findTrialStatusPopover().exists()).toBe(false);
+ });
+
+ it('does not have peek behavior', () => {
+ createWrapper();
+
+ expect(findPeekBehavior().exists()).toBe(false);
+ });
+ });
+
+ describe('on collapse', () => {
+ beforeEach(() => {
+ createWrapper();
+ sidebarState.isCollapsed = true;
+ });
+
+ it('closes the context switcher', () => {
+ expect(closeContextSwitcherMock).toHaveBeenCalled();
+ });
+ });
+
+ describe('peek behavior', () => {
+ it(`initially makes sidebar inert and peekable (${STATE_CLOSED})`, () => {
+ createWrapper({ sidebarState: { isCollapsed: true, isPeekable: true } });
+
+ expect(findSidebar().attributes('inert')).toBe('inert');
+ expect(findSidebar().classes()).not.toContain(peekHintClass);
+ expect(findSidebar().classes()).not.toContain(peekClass);
+ });
+
+ it(`makes sidebar inert and shows peek hint when peek state is ${STATE_WILL_OPEN}`, async () => {
+ createWrapper({ sidebarState: { isCollapsed: true, isPeekable: true } });
+
+ findPeekBehavior().vm.$emit('change', STATE_WILL_OPEN);
+ await nextTick();
+
+ expect(findSidebar().attributes('inert')).toBe('inert');
+ expect(findSidebar().classes()).toContain(peekHintClass);
+ expect(findSidebar().classes()).not.toContain(peekClass);
+ });
+
+ it.each([STATE_OPEN, STATE_WILL_CLOSE])(
+ 'makes sidebar interactive and visible when peek state is %s',
+ async (state) => {
+ createWrapper({ sidebarState: { isCollapsed: true, isPeekable: true } });
+
+ findPeekBehavior().vm.$emit('change', state);
+ await nextTick();
+
+ expect(findSidebar().attributes('inert')).toBe(undefined);
+ expect(findSidebar().classes()).toContain(peekClass);
+ expect(findSidebar().classes()).not.toContain(peekHintClass);
+ },
+ );
+ });
+
+ describe('nav container', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('allows overflow while the context switcher is closed', () => {
+ expect(findNavContainer().classes()).toContain('gl-overflow-auto');
+ });
+
+ it('hides overflow when context switcher is opened', async () => {
+ findContextSwitcher().vm.$emit('toggle', true);
+ await nextTick();
+
+ expect(findNavContainer().classes()).not.toContain('gl-overflow-auto');
+ });
+ });
+
+ describe('when a trial is active', () => {
+ beforeEach(() => {
+ createWrapper({ provide: { showTrialStatusWidget: true } });
+ });
+
+ it('renders trial status widget', () => {
+ expect(findTrialStatusWidget().exists()).toBe(true);
+ expect(findTrialStatusPopover().exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js
new file mode 100644
index 00000000000..8bb20186e16
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js
@@ -0,0 +1,106 @@
+import { nextTick } from 'vue';
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS } from '~/super_sidebar/constants';
+import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue';
+import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager';
+
+jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager.js', () => ({
+ toggleSuperSidebarCollapsed: jest.fn(),
+}));
+
+describe('SuperSidebarToggle component', () => {
+ let wrapper;
+
+ const findButton = () => wrapper.findComponent(GlButton);
+ const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
+
+ const createWrapper = ({ props = {}, sidebarState = {} } = {}) => {
+ wrapper = shallowMountExtended(SuperSidebarToggle, {
+ data() {
+ return {
+ ...sidebarState,
+ };
+ },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ describe('attributes', () => {
+ it('has aria-controls attribute', () => {
+ createWrapper();
+ expect(findButton().attributes('aria-controls')).toBe('super-sidebar');
+ });
+
+ it('has aria-expanded as true when expanded', () => {
+ createWrapper();
+ expect(findButton().attributes('aria-expanded')).toBe('true');
+ });
+
+ it('has aria-expanded as false when collapsed', () => {
+ createWrapper({ sidebarState: { isCollapsed: true } });
+ expect(findButton().attributes('aria-expanded')).toBe('false');
+ });
+
+ it('has aria-label attribute', () => {
+ createWrapper();
+ expect(findButton().attributes('aria-label')).toBe(__('Navigation sidebar'));
+ });
+
+ it('is disabled when isPeek is true', () => {
+ createWrapper({ sidebarState: { isPeek: true } });
+ expect(findButton().attributes('disabled')).toBeDefined();
+ });
+ });
+
+ describe('toolip', () => {
+ it('displays collapse when expanded', () => {
+ createWrapper();
+ expect(getTooltip().title).toBe(__('Hide sidebar'));
+ });
+
+ it('displays expand when collapsed', () => {
+ createWrapper({ sidebarState: { isCollapsed: true } });
+ expect(getTooltip().title).toBe(__('Show sidebar'));
+ });
+ });
+
+ describe('toggle', () => {
+ beforeEach(() => {
+ setHTMLFixture(`
+ <button class="${JS_TOGGLE_COLLAPSE_CLASS}">Hide</button>
+ <button class="${JS_TOGGLE_EXPAND_CLASS}">Show</button>
+ `);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ it('collapses the sidebar and focuses the other toggle', async () => {
+ createWrapper();
+ findButton().vm.$emit('click');
+ await nextTick();
+ expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(true, true);
+ expect(document.activeElement).toEqual(
+ document.querySelector(`.${JS_TOGGLE_COLLAPSE_CLASS}`),
+ );
+ });
+
+ it('expands the sidebar and focuses the other toggle', async () => {
+ createWrapper({ sidebarState: { isCollapsed: true } });
+ findButton().vm.$emit('click');
+ await nextTick();
+ expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(false, true);
+ expect(document.activeElement).toEqual(document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`));
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js
index eceb792c3db..6878e724c65 100644
--- a/spec/frontend/super_sidebar/components/user_bar_spec.js
+++ b/spec/frontend/super_sidebar/components/user_bar_spec.js
@@ -1,28 +1,61 @@
+import { GlBadge } from '@gitlab/ui';
+import Vuex from 'vuex';
+import Vue, { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { __ } from '~/locale';
import CreateMenu from '~/super_sidebar/components/create_menu.vue';
+import SearchModal from '~/super_sidebar/components/global_search/components/global_search.vue';
import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue';
import Counter from '~/super_sidebar/components/counter.vue';
import UserBar from '~/super_sidebar/components/user_bar.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import waitForPromises from 'helpers/wait_for_promises';
+import { userCounts } from '~/super_sidebar/user_counts_manager';
import { sidebarData } from '../mock_data';
+import { MOCK_DEFAULT_SEARCH_OPTIONS } from './global_search/mock_data';
describe('UserBar component', () => {
let wrapper;
const findCreateMenu = () => wrapper.findComponent(CreateMenu);
const findCounter = (at) => wrapper.findAllComponents(Counter).at(at);
+ const findIssuesCounter = () => findCounter(0);
+ const findMRsCounter = () => findCounter(1);
+ const findTodosCounter = () => findCounter(2);
const findMergeRequestMenu = () => wrapper.findComponent(MergeRequestMenu);
+ const findBrandLogo = () => wrapper.findByTestId('brand-header-custom-logo');
+ const findCollapseButton = () => wrapper.findByTestId('super-sidebar-collapse-button');
+ const findSearchButton = () => wrapper.findByTestId('super-sidebar-search-button');
+ const findSearchModal = () => wrapper.findComponent(SearchModal);
+ const findStopImpersonationButton = () => wrapper.findByTestId('stop-impersonation-btn');
- const createWrapper = (props = {}) => {
+ Vue.use(Vuex);
+
+ const store = new Vuex.Store({
+ getters: {
+ searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
+ },
+ });
+ const createWrapper = ({
+ hasCollapseButton = true,
+ extraSidebarData = {},
+ provideOverrides = {},
+ } = {}) => {
wrapper = shallowMountExtended(UserBar, {
propsData: {
- sidebarData,
- ...props,
+ hasCollapseButton,
+ sidebarData: { ...sidebarData, ...extraSidebarData },
},
provide: {
rootPath: '/',
toggleNewNavEndpoint: '/-/profile/preferences',
+ isImpersonating: false,
+ ...provideOverrides,
+ },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
},
+ store,
});
};
@@ -40,20 +73,143 @@ describe('UserBar component', () => {
});
it('renders issues counter', () => {
- expect(findCounter(0).props('count')).toBe(sidebarData.assigned_open_issues_count);
- expect(findCounter(0).props('href')).toBe(sidebarData.issues_dashboard_path);
- expect(findCounter(0).props('label')).toBe(__('Issues'));
+ const isuesCounter = findIssuesCounter();
+ expect(isuesCounter.props('count')).toBe(userCounts.assigned_issues);
+ expect(isuesCounter.props('href')).toBe(sidebarData.issues_dashboard_path);
+ expect(isuesCounter.props('label')).toBe(__('Issues'));
+ expect(isuesCounter.attributes('data-track-action')).toBe('click_link');
+ expect(isuesCounter.attributes('data-track-label')).toBe('issues_link');
+ expect(isuesCounter.attributes('data-track-property')).toBe('nav_core_menu');
+ expect(isuesCounter.attributes('class')).toContain('dashboard-shortcuts-issues');
});
it('renders merge requests counter', () => {
- expect(findCounter(1).props('count')).toBe(sidebarData.total_merge_requests_count);
- expect(findCounter(1).props('label')).toBe(__('Merge requests'));
+ const mrsCounter = findMRsCounter();
+ expect(mrsCounter.props('count')).toBe(
+ userCounts.assigned_merge_requests + userCounts.review_requested_merge_requests,
+ );
+ expect(mrsCounter.props('label')).toBe(__('Merge requests'));
+ expect(mrsCounter.attributes('data-track-action')).toBe('click_dropdown');
+ expect(mrsCounter.attributes('data-track-label')).toBe('merge_requests_menu');
+ expect(mrsCounter.attributes('data-track-property')).toBe('nav_core_menu');
+ });
+
+ describe('Todos counter', () => {
+ it('renders it', () => {
+ const todosCounter = findTodosCounter();
+ expect(todosCounter.props('href')).toBe(sidebarData.todos_dashboard_path);
+ expect(todosCounter.props('label')).toBe(__('To-Do list'));
+ expect(todosCounter.attributes('data-track-action')).toBe('click_link');
+ expect(todosCounter.attributes('data-track-label')).toBe('todos_link');
+ expect(todosCounter.attributes('data-track-property')).toBe('nav_core_menu');
+ expect(todosCounter.attributes('class')).toContain('shortcuts-todos');
+ });
+
+ it('should update todo counter when event is emitted', async () => {
+ createWrapper();
+ const count = 100;
+ document.dispatchEvent(new CustomEvent('todo:toggle', { detail: { count } }));
+ await nextTick();
+ expect(findTodosCounter().props('count')).toBe(count);
+ });
+ });
+
+ it('renders branding logo', () => {
+ expect(findBrandLogo().exists()).toBe(true);
+ expect(findBrandLogo().attributes('src')).toBe(sidebarData.logo_url);
+ });
+
+ it('does not render the "Stop impersonating" button', () => {
+ expect(findStopImpersonationButton().exists()).toBe(false);
+ });
+
+ it('renders collapse button when hasCollapseButton is true', () => {
+ expect(findCollapseButton().exists()).toBe(true);
+ });
+
+ it('does not render collapse button when hasCollapseButton is false', () => {
+ createWrapper({ hasCollapseButton: false });
+ expect(findCollapseButton().exists()).toBe(false);
+ });
+ });
+
+ describe('GitLab Next badge', () => {
+ describe('when on canary', () => {
+ it('should render a badge to switch off GitLab Next', () => {
+ createWrapper({ extraSidebarData: { gitlab_com_and_canary: true } });
+ const badge = wrapper.findComponent(GlBadge);
+ expect(badge.text()).toBe('Next');
+ expect(badge.attributes('href')).toBe(sidebarData.canary_toggle_com_url);
+ });
+ });
+
+ describe('when not on canary', () => {
+ it('should not render the GitLab Next badge', () => {
+ createWrapper({ extraSidebarData: { gitlab_com_and_canary: false } });
+ const badge = wrapper.findComponent(GlBadge);
+ expect(badge.exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Search', () => {
+ beforeEach(async () => {
+ createWrapper();
+ await waitForPromises();
+ });
+
+ it('should render search button', () => {
+ expect(findSearchButton().exists()).toBe(true);
+ });
+
+ it('search button should have tooltip', () => {
+ const tooltip = getBinding(findSearchButton().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(`Search GitLab <kbd>/</kbd>`);
+ });
+
+ it('should render search modal', () => {
+ expect(findSearchModal().exists()).toBe(true);
+ });
+
+ describe('Search tooltip', () => {
+ it('should hide search tooltip when modal is shown', async () => {
+ findSearchModal().vm.$emit('shown');
+ await nextTick();
+ const tooltip = getBinding(findSearchButton().element, 'gl-tooltip');
+ expect(tooltip.value).toBe('');
+ });
+
+ it('should add search tooltip when modal is hidden', async () => {
+ findSearchModal().vm.$emit('hidden');
+ await nextTick();
+ const tooltip = getBinding(findSearchButton().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(`Search GitLab <kbd>/</kbd>`);
+ });
});
+ });
+
+ describe('While impersonating a user', () => {
+ beforeEach(() => {
+ createWrapper({ provideOverrides: { isImpersonating: true } });
+ });
+
+ it('renders the "Stop impersonating" button', () => {
+ expect(findStopImpersonationButton().exists()).toBe(true);
+ });
+
+ it('sets the correct label on the button', () => {
+ const btn = findStopImpersonationButton();
+ const label = __('Stop impersonating');
+
+ expect(btn.attributes('title')).toBe(label);
+ expect(btn.attributes('aria-label')).toBe(label);
+ });
+
+ it('sets the href and data-method attributes', () => {
+ const btn = findStopImpersonationButton();
- it('renders todos counter', () => {
- expect(findCounter(2).props('count')).toBe(sidebarData.todos_pending_count);
- expect(findCounter(2).props('href')).toBe('/dashboard/todos');
- expect(findCounter(2).props('label')).toBe(__('To-Do list'));
+ expect(btn.attributes('href')).toBe(sidebarData.stop_impersonation_path);
+ expect(btn.attributes('data-method')).toBe('delete');
});
});
});
diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js
new file mode 100644
index 00000000000..cf8f650ec8f
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/user_menu_spec.js
@@ -0,0 +1,502 @@
+import { GlAvatar, GlDisclosureDropdown } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import UserMenu from '~/super_sidebar/components/user_menu.vue';
+import UserNameGroup from '~/super_sidebar/components/user_name_group.vue';
+import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
+import invalidUrl from '~/lib/utils/invalid_url';
+import { mockTracking } from 'helpers/tracking_helper';
+import PersistentUserCallout from '~/persistent_user_callout';
+import { userMenuMockData, userMenuMockStatus, userMenuMockPipelineMinutes } from '../mock_data';
+
+describe('UserMenu component', () => {
+ let wrapper;
+ let trackingSpy;
+
+ const GlEmoji = { template: '<img/>' };
+ const toggleNewNavEndpoint = invalidUrl;
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const showDropdown = () => findDropdown().vm.$emit('shown');
+
+ const createWrapper = (userDataChanges = {}) => {
+ wrapper = mountExtended(UserMenu, {
+ propsData: {
+ data: {
+ ...userMenuMockData,
+ ...userDataChanges,
+ },
+ },
+ stubs: {
+ GlEmoji,
+ GlAvatar: true,
+ },
+ provide: {
+ toggleNewNavEndpoint,
+ },
+ });
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ };
+
+ it('passes popper options to the dropdown', () => {
+ createWrapper();
+
+ expect(findDropdown().props('popperOptions')).toEqual({
+ modifiers: [{ name: 'offset', options: { offset: [-211, 4] } }],
+ });
+ });
+
+ describe('Toggle button', () => {
+ let toggle;
+
+ beforeEach(() => {
+ createWrapper();
+ toggle = wrapper.findByTestId('base-dropdown-toggle');
+ });
+
+ it('renders User Avatar in a toggle', () => {
+ const avatar = toggle.findComponent(GlAvatar);
+ expect(avatar.exists()).toBe(true);
+ expect(avatar.props()).toMatchObject({
+ entityName: userMenuMockData.name,
+ src: userMenuMockData.avatar_url,
+ });
+ });
+
+ it('renders screen reader text', () => {
+ expect(toggle.find('.gl-sr-only').text()).toBe(`${userMenuMockData.name} user’s menu`);
+ });
+ });
+
+ describe('User Menu Group', () => {
+ it('renders and passes data to it', () => {
+ createWrapper();
+ const userNameGroup = wrapper.findComponent(UserNameGroup);
+ expect(userNameGroup.exists()).toBe(true);
+ expect(userNameGroup.props('user')).toEqual(userMenuMockData);
+ });
+ });
+
+ describe('User status item', () => {
+ let item;
+
+ const setItem = ({ can_update, busy, customized } = {}) => {
+ createWrapper({ status: { ...userMenuMockStatus, can_update, busy, customized } });
+ item = wrapper.findByTestId('status-item');
+ };
+
+ describe('When user cannot update the status', () => {
+ it('does not render the status menu item', () => {
+ setItem();
+ expect(item.exists()).toBe(false);
+ });
+ });
+
+ describe('When user can update the status', () => {
+ it('renders the status menu item', () => {
+ setItem({ can_update: true });
+ expect(item.exists()).toBe(true);
+ });
+
+ it('should set the CSS class for triggering status update modal', () => {
+ setItem({ can_update: true });
+ expect(item.find('.js-set-status-modal-trigger').exists()).toBe(true);
+ });
+
+ it('should close the dropdown when status modal opened', () => {
+ setItem({ can_update: true });
+ wrapper.vm.$refs.userDropdown.close = jest.fn();
+ expect(wrapper.vm.$refs.userDropdown.close).not.toHaveBeenCalled();
+ item.vm.$emit('action');
+ expect(wrapper.vm.$refs.userDropdown.close).toHaveBeenCalled();
+ });
+
+ describe('renders correct label', () => {
+ it.each`
+ busy | customized | label
+ ${false} | ${false} | ${'Set status'}
+ ${false} | ${true} | ${'Edit status'}
+ ${true} | ${false} | ${'Edit status'}
+ ${true} | ${true} | ${'Edit status'}
+ `(
+ 'when busy is "$busy" and customized is "$customized" the label is "$label"',
+ ({ busy, customized, label }) => {
+ setItem({ can_update: true, busy, customized });
+ expect(item.text()).toBe(label);
+ },
+ );
+ });
+
+ describe('Status update modal wrapper', () => {
+ const findModalWrapper = () => wrapper.find('.js-set-status-modal-wrapper');
+
+ it('renders the modal wrapper', () => {
+ setItem({ can_update: true });
+ expect(findModalWrapper().exists()).toBe(true);
+ });
+
+ describe('when user cannot update status', () => {
+ it('sets default data attributes', () => {
+ setItem({ can_update: true });
+ expect(findModalWrapper().attributes()).toMatchObject({
+ 'data-current-emoji': '',
+ 'data-current-message': '',
+ 'data-default-emoji': 'speech_balloon',
+ });
+ });
+ });
+
+ describe.each`
+ busy | customized
+ ${true} | ${true}
+ ${true} | ${false}
+ ${false} | ${true}
+ ${false} | ${false}
+ `(`when user can update status`, ({ busy, customized }) => {
+ it(`and ${busy ? 'is busy' : 'is not busy'} and status ${
+ customized ? 'is' : 'is not'
+ } customized sets user status data attributes`, () => {
+ setItem({ can_update: true, busy, customized });
+ if (busy || customized) {
+ expect(findModalWrapper().attributes()).toMatchObject({
+ 'data-current-emoji': userMenuMockStatus.emoji,
+ 'data-current-message': userMenuMockStatus.message,
+ 'data-current-availability': userMenuMockStatus.availability,
+ 'data-current-clear-status-after': userMenuMockStatus.clear_after,
+ });
+ } else {
+ expect(findModalWrapper().attributes()).toMatchObject({
+ 'data-current-emoji': '',
+ 'data-current-message': '',
+ 'data-default-emoji': 'speech_balloon',
+ });
+ }
+ });
+ });
+ });
+ });
+ });
+
+ describe('Start Ultimate trial item', () => {
+ let item;
+
+ const setItem = ({ has_start_trial } = {}) => {
+ createWrapper({ trial: { has_start_trial, url: '' } });
+ item = wrapper.findByTestId('start-trial-item');
+ };
+
+ describe('When Ultimate trial is not suggested for the user', () => {
+ it('does not render the start trial menu item', () => {
+ setItem();
+ expect(item.exists()).toBe(false);
+ });
+ });
+
+ describe('When Ultimate trial can be suggested for the user', () => {
+ it('does render the start trial menu item', () => {
+ setItem({ has_start_trial: true });
+ expect(item.exists()).toBe(true);
+ });
+ });
+
+ it('has Snowplow tracking attributes', () => {
+ setItem({ has_start_trial: true });
+ expect(item.find('a').attributes()).toMatchObject({
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'start_trial',
+ });
+ });
+
+ describe('When trial info is not provided', () => {
+ it('does not render the start trial menu item', () => {
+ createWrapper();
+
+ expect(wrapper.findByTestId('start-trial-item').exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Buy Pipeline Minutes item', () => {
+ let item;
+
+ const setItem = ({
+ show_buy_pipeline_minutes,
+ show_with_subtext,
+ show_notification_dot,
+ } = {}) => {
+ createWrapper({
+ pipeline_minutes: {
+ ...userMenuMockPipelineMinutes,
+ show_buy_pipeline_minutes,
+ show_with_subtext,
+ show_notification_dot,
+ },
+ });
+ item = wrapper.findByTestId('buy-pipeline-minutes-item');
+ };
+
+ describe('When does NOT meet the condition to buy CI minutes', () => {
+ beforeEach(() => {
+ setItem();
+ });
+
+ it('does NOT render the buy pipeline minutes item', () => {
+ expect(item.exists()).toBe(false);
+ });
+
+ it('does not track the Sentry event', () => {
+ showDropdown();
+ expect(trackingSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('When does meet the condition to buy CI minutes', () => {
+ it('does render the menu item', () => {
+ setItem({ show_buy_pipeline_minutes: true });
+ expect(item.exists()).toBe(true);
+ });
+
+ describe('Snowplow tracking attributes to track item click', () => {
+ beforeEach(() => {
+ setItem({ show_buy_pipeline_minutes: true });
+ });
+
+ it('has attributes to track item click in scope of new nav', () => {
+ expect(item.find('a').attributes()).toMatchObject({
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'buy_pipeline_minutes',
+ });
+ });
+
+ it('tracks the click on the item', () => {
+ item.vm.$emit('action');
+ expect(trackingSpy).toHaveBeenCalledWith(
+ undefined,
+ userMenuMockPipelineMinutes.tracking_attrs['track-action'],
+ {
+ label: userMenuMockPipelineMinutes.tracking_attrs['track-label'],
+ property: userMenuMockPipelineMinutes.tracking_attrs['track-property'],
+ },
+ );
+ });
+ });
+
+ describe('Callout & notification dot', () => {
+ let spyFactory;
+
+ beforeEach(() => {
+ spyFactory = jest.spyOn(PersistentUserCallout, 'factory');
+ });
+
+ describe('When `show_notification_dot` is `false`', () => {
+ beforeEach(() => {
+ setItem({ show_buy_pipeline_minutes: true, show_notification_dot: false });
+ showDropdown();
+ });
+
+ it('does not set callout attributes', () => {
+ expect(item.attributes()).not.toEqual(
+ expect.objectContaining({
+ 'data-feature-id': userMenuMockPipelineMinutes.callout_attrs.feature_id,
+ 'data-dismiss-endpoint': userMenuMockPipelineMinutes.callout_attrs.dismiss_endpoint,
+ }),
+ );
+ });
+
+ it('does not initialize the Persistent Callout', () => {
+ expect(spyFactory).not.toHaveBeenCalled();
+ });
+
+ it('does not render notification dot', () => {
+ expect(wrapper.findByTestId('buy-pipeline-minutes-notification-dot').exists()).toBe(
+ false,
+ );
+ });
+ });
+
+ describe('When `show_notification_dot` is `true`', () => {
+ beforeEach(() => {
+ setItem({ show_buy_pipeline_minutes: true, show_notification_dot: true });
+ showDropdown();
+ });
+
+ it('sets the callout data attributes', () => {
+ expect(item.attributes()).toEqual(
+ expect.objectContaining({
+ 'data-feature-id': userMenuMockPipelineMinutes.callout_attrs.feature_id,
+ 'data-dismiss-endpoint': userMenuMockPipelineMinutes.callout_attrs.dismiss_endpoint,
+ }),
+ );
+ });
+
+ it('initializes the Persistent Callout', () => {
+ expect(spyFactory).toHaveBeenCalled();
+ });
+
+ it('renders notification dot', () => {
+ expect(wrapper.findByTestId('buy-pipeline-minutes-notification-dot').exists()).toBe(
+ true,
+ );
+ });
+ });
+ });
+
+ describe('Warning message', () => {
+ it('does not display the warning message when `show_with_subtext` is `false`', () => {
+ setItem({ show_buy_pipeline_minutes: true });
+
+ expect(item.text()).not.toContain(UserMenu.i18n.oneOfGroupsRunningOutOfPipelineMinutes);
+ });
+
+ it('displays the text and warning message when `show_with_subtext` is true', () => {
+ setItem({ show_buy_pipeline_minutes: true, show_with_subtext: true });
+
+ expect(item.text()).toContain(UserMenu.i18n.oneOfGroupsRunningOutOfPipelineMinutes);
+ });
+ });
+ });
+ });
+
+ describe('Edit profile item', () => {
+ let item;
+
+ beforeEach(() => {
+ createWrapper();
+ item = wrapper.findByTestId('edit-profile-item');
+ });
+
+ it('should render a link to the profile page', () => {
+ expect(item.text()).toBe(UserMenu.i18n.editProfile);
+ expect(item.find('a').attributes('href')).toBe(userMenuMockData.settings.profile_path);
+ });
+
+ it('has Snowplow tracking attributes', () => {
+ expect(item.find('a').attributes()).toMatchObject({
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'user_edit_profile',
+ });
+ });
+ });
+
+ describe('Preferences item', () => {
+ let item;
+
+ beforeEach(() => {
+ createWrapper();
+ item = wrapper.findByTestId('preferences-item');
+ });
+
+ it('should render a link to the profile page', () => {
+ expect(item.text()).toBe(UserMenu.i18n.preferences);
+ expect(item.find('a').attributes('href')).toBe(
+ userMenuMockData.settings.profile_preferences_path,
+ );
+ });
+
+ it('has Snowplow tracking attributes', () => {
+ expect(item.find('a').attributes()).toMatchObject({
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'user_preferences',
+ });
+ });
+ });
+
+ describe('GitLab Next item', () => {
+ describe('on gitlab.com', () => {
+ let item;
+
+ beforeEach(() => {
+ createWrapper({ gitlab_com_but_not_canary: true });
+ item = wrapper.findByTestId('gitlab-next-item');
+ });
+ it('should render a link to switch to GitLab Next', () => {
+ expect(item.text()).toBe(UserMenu.i18n.gitlabNext);
+ expect(item.find('a').attributes('href')).toBe(userMenuMockData.canary_toggle_com_url);
+ });
+
+ it('has Snowplow tracking attributes', () => {
+ expect(item.find('a').attributes()).toMatchObject({
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'switch_to_canary',
+ });
+ });
+ });
+
+ describe('anywhere else', () => {
+ it('should not render the GitLab Next link', () => {
+ createWrapper({ gitlab_com_but_not_canary: false });
+ const item = wrapper.findByTestId('gitlab-next-item');
+ expect(item.exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('New navigation toggle item', () => {
+ it('should render menu item with new navigation toggle', () => {
+ createWrapper();
+ const toggleItem = wrapper.findComponent(NewNavToggle);
+ expect(toggleItem.exists()).toBe(true);
+ expect(toggleItem.props('endpoint')).toBe(toggleNewNavEndpoint);
+ });
+ });
+
+ describe('Feedback item', () => {
+ let item;
+
+ beforeEach(() => {
+ createWrapper();
+ item = wrapper.findByTestId('feedback-item');
+ });
+
+ it('should render feedback item with a link to a new GitLab issue', () => {
+ expect(item.find('a').attributes('href')).toBe(UserMenu.feedbackUrl);
+ });
+
+ it('has Snowplow tracking attributes', () => {
+ expect(item.find('a').attributes()).toMatchObject({
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'provide_nav_feedback',
+ });
+ });
+ });
+
+ describe('Sign out group', () => {
+ const findSignOutGroup = () => wrapper.findByTestId('sign-out-group');
+
+ it('should not render sign out group when user cannot sign out', () => {
+ createWrapper();
+ expect(findSignOutGroup().exists()).toBe(false);
+ });
+
+ describe('when user can sign out', () => {
+ beforeEach(() => {
+ createWrapper({ can_sign_out: true });
+ });
+
+ it('should render sign out group', () => {
+ expect(findSignOutGroup().exists()).toBe(true);
+ });
+
+ it('should render the menu item with a link to sign out and correct data attribute', () => {
+ expect(findSignOutGroup().find('a').attributes('href')).toBe(
+ userMenuMockData.sign_out_link,
+ );
+ expect(findSignOutGroup().find('a').attributes('data-method')).toBe('post');
+ });
+
+ it('should track Snowplow event on sign out', () => {
+ findSignOutGroup().vm.$emit('action');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', {
+ label: 'user_sign_out',
+ property: 'nav_user_menu',
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/user_name_group_spec.js b/spec/frontend/super_sidebar/components/user_name_group_spec.js
new file mode 100644
index 00000000000..6e3b18d3107
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/user_name_group_spec.js
@@ -0,0 +1,114 @@
+import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlTooltip } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import UserNameGroup from '~/super_sidebar/components/user_name_group.vue';
+import { userMenuMockData, userMenuMockStatus } from '../mock_data';
+
+describe('UserNameGroup component', () => {
+ let wrapper;
+
+ const findGlDisclosureDropdownGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup);
+ const findGlDisclosureDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
+ const findGlTooltip = () => wrapper.findComponent(GlTooltip);
+ const findUserStatus = () => wrapper.findByTestId('user-menu-status');
+
+ const GlEmoji = { template: '<img/>' };
+
+ const createWrapper = (userDataChanges = {}) => {
+ wrapper = shallowMountExtended(UserNameGroup, {
+ propsData: {
+ user: {
+ ...userMenuMockData,
+ ...userDataChanges,
+ },
+ },
+ stubs: {
+ GlEmoji,
+ GlDisclosureDropdownItem,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders the menu item in a separate group', () => {
+ expect(findGlDisclosureDropdownGroup().exists()).toBe(true);
+ });
+
+ it('renders menu item', () => {
+ expect(findGlDisclosureDropdownItem().exists()).toBe(true);
+ });
+
+ it('passes the item to the disclosure dropdown item', () => {
+ expect(findGlDisclosureDropdownItem().props('item')).toEqual(
+ expect.objectContaining({
+ text: userMenuMockData.name,
+ href: userMenuMockData.link_to_profile,
+ }),
+ );
+ });
+
+ it("renders user's name", () => {
+ expect(findGlDisclosureDropdownItem().text()).toContain(userMenuMockData.name);
+ });
+
+ it("renders user's username", () => {
+ expect(findGlDisclosureDropdownItem().text()).toContain(userMenuMockData.username);
+ });
+
+ describe('Busy status', () => {
+ it('should not render "Busy" when user is NOT busy', () => {
+ expect(findGlDisclosureDropdownItem().text()).not.toContain('Busy');
+ });
+ it('should render "Busy" when user is busy', () => {
+ createWrapper({ status: { customized: true, busy: true } });
+
+ expect(findGlDisclosureDropdownItem().text()).toContain('Busy');
+ });
+ });
+
+ describe('User status', () => {
+ describe('when not customized', () => {
+ it('should not render it', () => {
+ expect(findUserStatus().exists()).toBe(false);
+ });
+ });
+
+ describe('when customized', () => {
+ beforeEach(() => {
+ createWrapper({ status: { ...userMenuMockStatus, customized: true } });
+ });
+
+ it('should render it', () => {
+ expect(findUserStatus().exists()).toBe(true);
+ });
+
+ it('should render status emoji', () => {
+ expect(findUserStatus().findComponent(GlEmoji).attributes('data-name')).toBe(
+ userMenuMockData.status.emoji,
+ );
+ });
+
+ it('should render status message', () => {
+ expect(findUserStatus().text()).toContain(userMenuMockData.status.message);
+ });
+
+ it("sets the tooltip's target to the status container", () => {
+ expect(findGlTooltip().props('target')?.()).toBe(findUserStatus().element);
+ });
+ });
+ });
+
+ describe('Tracking', () => {
+ it('sets the tracking attributes', () => {
+ expect(findGlDisclosureDropdownItem().find('a').attributes()).toEqual(
+ expect.objectContaining({
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'user_profile',
+ }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js
index 91a2dc93a47..a3a74f7aac8 100644
--- a/spec/frontend/super_sidebar/mock_data.js
+++ b/spec/frontend/super_sidebar/mock_data.js
@@ -1,3 +1,5 @@
+import invalidUrl from '~/lib/utils/invalid_url';
+
export const createNewMenuGroups = [
{
name: 'This group',
@@ -16,7 +18,7 @@ export const createNewMenuGroups = [
},
{
text: 'Invite members',
- href: '/groups/gitlab-org/-/group_members',
+ component: 'invite_members',
},
],
},
@@ -47,26 +49,51 @@ export const mergeRequestMenuGroup = [
text: 'Assigned',
href: '/dashboard/merge_requests?assignee_username=root',
count: 4,
+ extraAttrs: {
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'merge_requests_assigned',
+ 'data-track-property': 'nav_core_menu',
+ class: 'dashboard-shortcuts-merge_requests',
+ },
},
{
text: 'Review requests',
href: '/dashboard/merge_requests?reviewer_username=root',
count: 0,
+ extraAttrs: {
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'merge_requests_to_review',
+ 'data-track-property': 'nav_core_menu',
+ class: 'dashboard-shortcuts-review_requests',
+ },
},
],
},
];
export const sidebarData = {
+ current_menu_items: [],
+ current_context_header: {
+ title: 'Your Work',
+ icon: 'work',
+ },
name: 'Administrator',
username: 'root',
avatar_url: 'path/to/img_administrator',
- assigned_open_issues_count: 1,
- todos_pending_count: 3,
+ logo_url: 'path/to/logo',
+ user_counts: {
+ last_update: Date.now(),
+ todos: 3,
+ assigned_issues: 1,
+ assigned_merge_requests: 3,
+ review_requested_merge_requests: 1,
+ },
issues_dashboard_path: 'path/to/issues',
- total_merge_requests_count: 4,
+ todos_dashboard_path: 'path/to/todos',
create_new_menu_groups: createNewMenuGroups,
merge_request_menu: mergeRequestMenuGroup,
+ projects_path: 'path/to/projects',
+ groups_path: 'path/to/groups',
support_path: '/support',
display_whats_new: true,
whats_new_most_recent_release_items_count: 5,
@@ -74,4 +101,193 @@ export const sidebarData = {
show_version_check: false,
gitlab_version: { major: 16, minor: 0 },
gitlab_version_check: { severity: 'success' },
+ gitlab_com_and_canary: false,
+ canary_toggle_com_url: 'https://next.gitlab.com',
+ context_switcher_links: [],
+ search: {
+ search_path: '/search',
+ },
+ pinned_items: [],
+ panel_type: 'your_work',
+ update_pins_url: 'path/to/pins',
+ stop_impersonation_path: '/admin/impersonation',
+ shortcut_links: [
+ {
+ title: 'Shortcut link',
+ href: '/shortcut-link',
+ css_class: 'shortcut-link-class',
+ },
+ ],
+};
+
+export const userMenuMockStatus = {
+ can_update: false,
+ busy: false,
+ customized: false,
+ emoji: 'art',
+ message: 'Working on user menu in super sidebar',
+ availability: 'busy',
+ clear_after: '2023-02-09 20:06:35 UTC',
+};
+
+export const userMenuMockPipelineMinutes = {
+ show_buy_pipeline_minutes: false,
+ show_notification_dot: false,
+ callout_attrs: {
+ feature_id: 'pipeline_minutes',
+ dismiss_endpoint: '/-/dismiss',
+ },
+ buy_pipeline_minutes_path: '/buy/pipeline_minutes',
+ tracking_attrs: {
+ 'track-action': 'trackAction',
+ 'track-label': 'label',
+ 'track-property': 'property',
+ },
+};
+
+export const userMenuMockData = {
+ name: 'Orange Fox',
+ username: 'thefox',
+ avatar_url: invalidUrl,
+ has_link_to_profile: true,
+ link_to_profile: '/thefox',
+ status: userMenuMockStatus,
+ settings: {
+ profile_path: invalidUrl,
+ profile_preferences_path: invalidUrl,
+ },
+ pipeline_minutes: userMenuMockPipelineMinutes,
+ can_sign_out: false,
+ sign_out_link: invalidUrl,
+ gitlab_com_but_not_canary: true,
+ canary_toggle_com_url: 'https://next.gitlab.com',
+};
+
+export const cachedFrequentProjects = JSON.stringify([
+ {
+ id: 1,
+ name: 'Cached project 1',
+ namespace: 'Cached Namespace 1 / Cached project 1',
+ webUrl: '/cached-namespace-1/cached-project-1',
+ avatarUrl: '/uploads/-/avatar1.png',
+ lastAccessedOn: 1676325329054,
+ frequency: 10,
+ },
+ {
+ id: 2,
+ name: 'Cached project 2',
+ namespace: 'Cached Namespace 2 / Cached project 2',
+ webUrl: '/cached-namespace-2/cached-project-2',
+ avatarUrl: '/uploads/-/avatar2.png',
+ lastAccessedOn: 1674314684308,
+ frequency: 8,
+ },
+ {
+ id: 3,
+ name: 'Cached project 3',
+ namespace: 'Cached Namespace 3 / Cached project 3',
+ webUrl: '/cached-namespace-3/cached-project-3',
+ avatarUrl: '/uploads/-/avatar3.png',
+ lastAccessedOn: 1664977333191,
+ frequency: 12,
+ },
+ {
+ id: 4,
+ name: 'Cached project 4',
+ namespace: 'Cached Namespace 4 / Cached project 4',
+ webUrl: '/cached-namespace-4/cached-project-4',
+ avatarUrl: '/uploads/-/avatar4.png',
+ lastAccessedOn: 1674315407569,
+ frequency: 3,
+ },
+ {
+ id: 5,
+ name: 'Cached project 5',
+ namespace: 'Cached Namespace 5 / Cached project 5',
+ webUrl: '/cached-namespace-5/cached-project-5',
+ avatarUrl: '/uploads/-/avatar5.png',
+ lastAccessedOn: 1677084729436,
+ frequency: 21,
+ },
+ {
+ id: 6,
+ name: 'Cached project 6',
+ namespace: 'Cached Namespace 6 / Cached project 6',
+ webUrl: '/cached-namespace-6/cached-project-6',
+ avatarUrl: '/uploads/-/avatar6.png',
+ lastAccessedOn: 1676325329679,
+ frequency: 5,
+ },
+]);
+
+export const cachedFrequentGroups = JSON.stringify([
+ {
+ id: 1,
+ name: 'Cached group 1',
+ namespace: 'Cached Namespace 1',
+ webUrl: '/cached-namespace-1/cached-group-1',
+ avatarUrl: '/uploads/-/avatar1.png',
+ lastAccessedOn: 1676325329054,
+ frequency: 10,
+ },
+ {
+ id: 2,
+ name: 'Cached group 2',
+ namespace: 'Cached Namespace 2',
+ webUrl: '/cached-namespace-2/cached-group-2',
+ avatarUrl: '/uploads/-/avatar2.png',
+ lastAccessedOn: 1674314684308,
+ frequency: 8,
+ },
+ {
+ id: 3,
+ name: 'Cached group 3',
+ namespace: 'Cached Namespace 3',
+ webUrl: '/cached-namespace-3/cached-group-3',
+ avatarUrl: '/uploads/-/avatar3.png',
+ lastAccessedOn: 1664977333191,
+ frequency: 12,
+ },
+ {
+ id: 4,
+ name: 'Cached group 4',
+ namespace: 'Cached Namespace 4',
+ webUrl: '/cached-namespace-4/cached-group-4',
+ avatarUrl: '/uploads/-/avatar4.png',
+ lastAccessedOn: 1674315407569,
+ frequency: 3,
+ },
+]);
+
+export const searchUserProjectsAndGroupsResponseMock = {
+ data: {
+ projects: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Project/2',
+ name: 'Gitlab Shell',
+ namespace: 'Gitlab Org / Gitlab Shell',
+ webUrl: 'http://gdk.test:3000/gitlab-org/gitlab-shell',
+ avatarUrl: null,
+ __typename: 'Project',
+ },
+ ],
+ },
+
+ user: {
+ id: 'gid://gitlab/User/1',
+ groups: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Group/60',
+ name: 'GitLab Instance',
+ namespace: 'gitlab-instance-2e4abb29',
+ webUrl: 'http://gdk.test:3000/groups/gitlab-instance-2e4abb29',
+ avatarUrl: null,
+ __typename: 'Group',
+ },
+ ],
+ },
+ },
+ },
};
diff --git a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js
new file mode 100644
index 00000000000..909f4249e28
--- /dev/null
+++ b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js
@@ -0,0 +1,139 @@
+import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { sidebarState } from '~/super_sidebar/constants';
+import {
+ SIDEBAR_COLLAPSED_CLASS,
+ SIDEBAR_COLLAPSED_COOKIE,
+ SIDEBAR_COLLAPSED_COOKIE_EXPIRATION,
+ toggleSuperSidebarCollapsed,
+ initSuperSidebarCollapsedState,
+ findPage,
+ bindSuperSidebarCollapsedEvents,
+} from '~/super_sidebar/super_sidebar_collapsed_state_manager';
+
+const { xl, sm } = breakpoints;
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ getCookie: jest.fn(),
+ setCookie: jest.fn(),
+}));
+
+const pageHasCollapsedClass = (hasClass) => {
+ if (hasClass) {
+ expect(findPage().classList).toContain(SIDEBAR_COLLAPSED_CLASS);
+ } else {
+ expect(findPage().classList).not.toContain(SIDEBAR_COLLAPSED_CLASS);
+ }
+};
+
+describe('Super Sidebar Collapsed State Manager', () => {
+ beforeEach(() => {
+ setHTMLFixture(`
+ <div class="page-with-super-sidebar">
+ <aside class="super-sidebar"></aside>
+ </div>
+ `);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ describe('toggleSuperSidebarCollapsed', () => {
+ it.each`
+ collapsed | saveCookie | windowWidth | hasClass | superSidebarPeek | isPeekable
+ ${true} | ${true} | ${xl} | ${true} | ${false} | ${false}
+ ${true} | ${true} | ${xl} | ${true} | ${true} | ${true}
+ ${true} | ${false} | ${xl} | ${true} | ${false} | ${false}
+ ${true} | ${true} | ${sm} | ${true} | ${false} | ${false}
+ ${true} | ${false} | ${sm} | ${true} | ${false} | ${false}
+ ${false} | ${true} | ${xl} | ${false} | ${false} | ${false}
+ ${false} | ${true} | ${xl} | ${false} | ${true} | ${false}
+ ${false} | ${false} | ${xl} | ${false} | ${false} | ${false}
+ ${false} | ${true} | ${sm} | ${false} | ${false} | ${false}
+ ${false} | ${false} | ${sm} | ${false} | ${false} | ${false}
+ `(
+ 'when collapsed is $collapsed, saveCookie is $saveCookie, and windowWidth is $windowWidth then page class contains `page-with-super-sidebar-collapsed` is $hasClass',
+ ({ collapsed, saveCookie, windowWidth, hasClass, superSidebarPeek, isPeekable }) => {
+ jest.spyOn(bp, 'windowWidth').mockReturnValue(windowWidth);
+ gon.features = { superSidebarPeek };
+
+ toggleSuperSidebarCollapsed(collapsed, saveCookie);
+
+ pageHasCollapsedClass(hasClass);
+ expect(sidebarState.isCollapsed).toBe(collapsed);
+ expect(sidebarState.isPeekable).toBe(isPeekable);
+
+ if (saveCookie && windowWidth >= xl) {
+ expect(setCookie).toHaveBeenCalledWith(SIDEBAR_COLLAPSED_COOKIE, collapsed, {
+ expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION,
+ });
+ } else {
+ expect(setCookie).not.toHaveBeenCalled();
+ }
+ },
+ );
+ });
+
+ describe('initSuperSidebarCollapsedState', () => {
+ it.each`
+ windowWidth | cookie | hasClass
+ ${xl} | ${undefined} | ${false}
+ ${sm} | ${undefined} | ${true}
+ ${xl} | ${'true'} | ${true}
+ ${sm} | ${'true'} | ${true}
+ `(
+ 'sets page class to `page-with-super-sidebar-collapsed` when windowWidth is $windowWidth and cookie value is $cookie',
+ ({ windowWidth, cookie, hasClass }) => {
+ jest.spyOn(bp, 'windowWidth').mockReturnValue(windowWidth);
+ getCookie.mockReturnValue(cookie);
+
+ initSuperSidebarCollapsedState();
+
+ pageHasCollapsedClass(hasClass);
+ expect(setCookie).not.toHaveBeenCalled();
+ },
+ );
+
+ it('does not collapse sidebar when forceDesktopExpandedSidebar is true and windowWidth is xl', () => {
+ jest.spyOn(bp, 'windowWidth').mockReturnValue(xl);
+ initSuperSidebarCollapsedState(true);
+ expect(findPage().classList).not.toContain(SIDEBAR_COLLAPSED_CLASS);
+ });
+ });
+
+ describe('bindSuperSidebarCollapsedEvents', () => {
+ describe('handles width change', () => {
+ let removeEventListener;
+
+ afterEach(() => {
+ removeEventListener();
+ });
+
+ it.each`
+ initialWindowWidth | updatedWindowWidth | hasClassBeforeResize | hasClassAfterResize
+ ${xl} | ${sm} | ${false} | ${true}
+ ${sm} | ${xl} | ${true} | ${false}
+ ${xl} | ${xl} | ${false} | ${false}
+ ${sm} | ${sm} | ${true} | ${true}
+ `(
+ 'when changing width from $initialWindowWidth to $updatedWindowWidth expect page to have collapsed class before resize to be $hasClassBeforeResize and after resize to be $hasClassAfterResize',
+ ({ initialWindowWidth, updatedWindowWidth, hasClassBeforeResize, hasClassAfterResize }) => {
+ getCookie.mockReturnValue(undefined);
+ window.innerWidth = initialWindowWidth;
+ initSuperSidebarCollapsedState();
+
+ pageHasCollapsedClass(hasClassBeforeResize);
+
+ removeEventListener = bindSuperSidebarCollapsedEvents();
+
+ window.innerWidth = updatedWindowWidth;
+ window.dispatchEvent(new Event('resize'));
+
+ pageHasCollapsedClass(hasClassAfterResize);
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/user_counts_manager_spec.js b/spec/frontend/super_sidebar/user_counts_manager_spec.js
new file mode 100644
index 00000000000..b5074620195
--- /dev/null
+++ b/spec/frontend/super_sidebar/user_counts_manager_spec.js
@@ -0,0 +1,166 @@
+import waitForPromises from 'helpers/wait_for_promises';
+
+import * as UserApi from '~/api/user_api';
+import {
+ createUserCountsManager,
+ userCounts,
+ destroyUserCountsManager,
+} from '~/super_sidebar/user_counts_manager';
+
+jest.mock('~/api');
+
+const USER_ID = 123;
+const userCountDefaults = {
+ todos: 1,
+ assigned_issues: 2,
+ assigned_merge_requests: 3,
+ review_requested_merge_requests: 4,
+};
+
+const userCountUpdate = {
+ todos: 123,
+ assigned_issues: 456,
+ assigned_merge_requests: 789,
+ review_requested_merge_requests: 101112,
+};
+
+describe('User Merge Requests', () => {
+ let channelMock;
+ let newBroadcastChannelMock;
+
+ beforeEach(() => {
+ jest.spyOn(document, 'removeEventListener');
+ jest.spyOn(document, 'addEventListener');
+
+ global.gon.current_user_id = USER_ID;
+
+ channelMock = {
+ postMessage: jest.fn(),
+ close: jest.fn(),
+ };
+ newBroadcastChannelMock = jest.fn().mockImplementation(() => channelMock);
+
+ Object.assign(userCounts, userCountDefaults, { last_update: 0 });
+
+ global.BroadcastChannel = newBroadcastChannelMock;
+ });
+
+ describe('createUserCountsManager', () => {
+ beforeEach(() => {
+ createUserCountsManager();
+ });
+
+ it('creates BroadcastChannel which updates counts on message received', () => {
+ expect(newBroadcastChannelMock).toHaveBeenCalledWith(`user_counts_${USER_ID}`);
+ });
+
+ it('closes BroadCastchannel if called while already open', () => {
+ expect(channelMock.close).not.toHaveBeenCalled();
+
+ createUserCountsManager();
+
+ expect(channelMock.close).toHaveBeenCalled();
+ });
+
+ describe('BroadcastChannel onmessage handler', () => {
+ it('updates counts on message received', () => {
+ expect(userCounts).toMatchObject(userCountDefaults);
+
+ channelMock.onmessage({ data: { ...userCountUpdate, last_update: Date.now() } });
+
+ expect(userCounts).toMatchObject(userCountUpdate);
+ });
+
+ it('ignores updates with older data', () => {
+ expect(userCounts).toMatchObject(userCountDefaults);
+ userCounts.last_update = Date.now();
+
+ channelMock.onmessage({
+ data: { ...userCountUpdate, last_update: userCounts.last_update - 1000 },
+ });
+
+ expect(userCounts).toMatchObject(userCountDefaults);
+ });
+
+ it('ignores unknown fields', () => {
+ expect(userCounts).toMatchObject(userCountDefaults);
+
+ channelMock.onmessage({ data: { ...userCountUpdate, i_am_unknown: 5 } });
+
+ expect(userCounts).toMatchObject(userCountUpdate);
+ expect(userCounts.i_am_unknown).toBeUndefined();
+ });
+ });
+
+ it('broadcasts user counts during initialization', () => {
+ expect(channelMock.postMessage).toHaveBeenCalledWith(
+ expect.objectContaining(userCountDefaults),
+ );
+ });
+
+ it('setups event listener without leaking them', () => {
+ expect(document.removeEventListener).toHaveBeenCalledWith(
+ 'userCounts:fetch',
+ expect.any(Function),
+ );
+ expect(document.addEventListener).toHaveBeenCalledWith(
+ 'userCounts:fetch',
+ expect.any(Function),
+ );
+ });
+ });
+
+ describe('Event listener userCounts:fetch', () => {
+ beforeEach(() => {
+ jest.spyOn(UserApi, 'getUserCounts').mockResolvedValue({
+ data: { ...userCountUpdate, merge_requests: 'FOO' },
+ });
+ createUserCountsManager();
+ });
+
+ it('fetches counts from API, stores and rebroadcasts them', async () => {
+ expect(userCounts).toMatchObject(userCountDefaults);
+
+ document.dispatchEvent(new CustomEvent('userCounts:fetch'));
+ await waitForPromises();
+
+ expect(UserApi.getUserCounts).toHaveBeenCalled();
+ expect(userCounts).toMatchObject(userCountUpdate);
+ expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts);
+ });
+ });
+
+ describe('destroyUserCountsManager', () => {
+ it('unregisters event handler', () => {
+ expect(document.removeEventListener).not.toHaveBeenCalledWith();
+
+ destroyUserCountsManager();
+
+ expect(document.removeEventListener).toHaveBeenCalledWith(
+ 'userCounts:fetch',
+ expect.any(Function),
+ );
+ });
+
+ describe('when BroadcastChannel is not opened', () => {
+ it('does nothing', () => {
+ destroyUserCountsManager();
+ expect(channelMock.close).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when BroadcastChannel is opened', () => {
+ beforeEach(() => {
+ createUserCountsManager();
+ });
+
+ it('closes BroadcastChannel', () => {
+ expect(channelMock.close).not.toHaveBeenCalled();
+
+ destroyUserCountsManager();
+
+ expect(channelMock.close).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/utils_spec.js b/spec/frontend/super_sidebar/utils_spec.js
new file mode 100644
index 00000000000..8c8673ddbc4
--- /dev/null
+++ b/spec/frontend/super_sidebar/utils_spec.js
@@ -0,0 +1,171 @@
+import {
+ getTopFrequentItems,
+ trackContextAccess,
+ formatContextSwitcherItems,
+ ariaCurrent,
+} from '~/super_sidebar/utils';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import AccessorUtilities from '~/lib/utils/accessor';
+import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants';
+import { unsortedFrequentItems, sortedFrequentItems } from '../frequent_items/mock_data';
+import { searchUserProjectsAndGroupsResponseMock } from './mock_data';
+
+describe('Super sidebar utils spec', () => {
+ describe('getTopFrequentItems', () => {
+ const maxItems = 3;
+
+ it.each([undefined, null])('returns empty array if `items` is %s', (items) => {
+ const result = getTopFrequentItems(items);
+
+ expect(result.length).toBe(0);
+ });
+
+ it('returns the requested amount of items', () => {
+ const result = getTopFrequentItems(unsortedFrequentItems, maxItems);
+
+ expect(result.length).toBe(maxItems);
+ });
+
+ it('sorts frequent items in order of frequency and lastAccessedOn', () => {
+ const result = getTopFrequentItems(unsortedFrequentItems, maxItems);
+ const expectedResult = sortedFrequentItems.slice(0, maxItems);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('trackContextAccess', () => {
+ useLocalStorageSpy();
+
+ const username = 'root';
+ const context = {
+ namespace: 'groups',
+ item: { id: 1 },
+ };
+ const storageKey = `${username}/frequent-${context.namespace}`;
+
+ it('returns `false` if local storage is not available', () => {
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false);
+
+ expect(trackContextAccess()).toBe(false);
+ });
+
+ it('creates a new item if it does not exist in the local storage', () => {
+ trackContextAccess(username, context);
+
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(
+ storageKey,
+ JSON.stringify([
+ {
+ id: 1,
+ frequency: 1,
+ lastAccessedOn: Date.now(),
+ },
+ ]),
+ );
+ });
+
+ it('updates existing item if it was persisted to the local storage over 15 minutes ago', () => {
+ window.localStorage.setItem(
+ storageKey,
+ JSON.stringify([
+ {
+ id: 1,
+ frequency: 2,
+ lastAccessedOn: Date.now() - FIFTEEN_MINUTES_IN_MS - 1,
+ },
+ ]),
+ );
+ trackContextAccess(username, context);
+
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(
+ storageKey,
+ JSON.stringify([
+ {
+ id: 1,
+ frequency: 3,
+ lastAccessedOn: Date.now(),
+ },
+ ]),
+ );
+ });
+
+ it('leaves item as is if it was persisted to the local storage under 15 minutes ago', () => {
+ const jsonString = JSON.stringify([
+ {
+ id: 1,
+ frequency: 2,
+ lastAccessedOn: Date.now() - FIFTEEN_MINUTES_IN_MS,
+ },
+ ]);
+ window.localStorage.setItem(storageKey, jsonString);
+
+ expect(window.localStorage.setItem).toHaveBeenCalledTimes(1);
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(storageKey, jsonString);
+
+ trackContextAccess(username, context);
+
+ expect(window.localStorage.setItem).toHaveBeenCalledTimes(3);
+ expect(window.localStorage.setItem).toHaveBeenLastCalledWith(storageKey, jsonString);
+ });
+
+ it('replaces the least popular item in the local storage once the persisted items limit has been hit', () => {
+ // Add the maximum amount of items to the local storage, in increasing popularity
+ const storedItems = Array.from({ length: FREQUENT_ITEMS.MAX_COUNT }).map((_, i) => ({
+ id: i + 1,
+ frequency: i + 1,
+ lastAccessedOn: Date.now(),
+ }));
+ // The first item is considered the least popular one as it has the lowest frequency (1)
+ const [leastPopularItem] = storedItems;
+ // Persist the list to the local storage
+ const jsonString = JSON.stringify(storedItems);
+ window.localStorage.setItem(storageKey, jsonString);
+ // Track some new item that hasn't been visited yet
+ const newItem = {
+ id: FREQUENT_ITEMS.MAX_COUNT + 1,
+ };
+ trackContextAccess(username, {
+ namespace: 'groups',
+ item: newItem,
+ });
+ // Finally, retrieve the final data from the local storage
+ const finallyStoredItems = JSON.parse(window.localStorage.getItem(storageKey));
+
+ expect(finallyStoredItems).not.toEqual(expect.arrayContaining([leastPopularItem]));
+ expect(finallyStoredItems).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: newItem.id,
+ frequency: 1,
+ }),
+ ]),
+ );
+ });
+ });
+
+ describe('formatContextSwitcherItems', () => {
+ it('returns the formatted items', () => {
+ const projects = searchUserProjectsAndGroupsResponseMock.data.projects.nodes;
+ expect(formatContextSwitcherItems(projects)).toEqual([
+ {
+ id: projects[0].id,
+ avatar: null,
+ title: projects[0].name,
+ subtitle: 'Gitlab Org',
+ link: projects[0].webUrl,
+ },
+ ]);
+ });
+ });
+
+ describe('ariaCurrent', () => {
+ it.each`
+ isActive | expected
+ ${true} | ${'page'}
+ ${false} | ${null}
+ `('returns `$expected` when `isActive` is `$isActive`', ({ isActive, expected }) => {
+ expect(ariaCurrent(isActive)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/frontend/surveys/merge_request_performance/app_spec.js b/spec/frontend/surveys/merge_request_performance/app_spec.js
index af91d8aeb6b..d03451c71a8 100644
--- a/spec/frontend/surveys/merge_request_performance/app_spec.js
+++ b/spec/frontend/surveys/merge_request_performance/app_spec.js
@@ -56,17 +56,17 @@ describe('MergeRequestExperienceSurveyApp', () => {
createWrapper();
});
- it('shows survey', async () => {
+ it('shows survey', () => {
expect(wrapper.html()).toContain('Overall, how satisfied are you with merge requests?');
expect(wrapper.findComponent(SatisfactionRate).exists()).toBe(true);
expect(wrapper.emitted().close).toBe(undefined);
});
- it('tracks render once', async () => {
+ it('tracks render once', () => {
expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments());
});
- it("doesn't track subsequent renders", async () => {
+ it("doesn't track subsequent renders", () => {
createWrapper();
expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments());
expect(trackingSpy).toHaveBeenCalledTimes(1);
@@ -77,15 +77,15 @@ describe('MergeRequestExperienceSurveyApp', () => {
findCloseButton().vm.$emit('click');
});
- it('triggers user callout on close', async () => {
+ it('triggers user callout on close', () => {
expect(dismiss).toHaveBeenCalledTimes(1);
});
- it('emits close event on close button click', async () => {
+ it('emits close event on close button click', () => {
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
});
- it('tracks dismissal', async () => {
+ it('tracks dismissal', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', {
label: 'dismiss',
extra: {
@@ -94,7 +94,7 @@ describe('MergeRequestExperienceSurveyApp', () => {
});
});
- it('tracks subsequent renders', async () => {
+ it('tracks subsequent renders', () => {
createWrapper();
expect(trackingSpy.mock.calls).toEqual([
createRenderTrackedArguments(),
@@ -110,7 +110,7 @@ describe('MergeRequestExperienceSurveyApp', () => {
);
});
- it('dismisses user callout on survey rate', async () => {
+ it('dismisses user callout on survey rate', () => {
const rate = wrapper.findComponent(SatisfactionRate);
expect(dismiss).not.toHaveBeenCalled();
rate.vm.$emit('rate', 5);
@@ -126,7 +126,7 @@ describe('MergeRequestExperienceSurveyApp', () => {
);
});
- it('tracks survey rates', async () => {
+ it('tracks survey rates', () => {
const rate = wrapper.findComponent(SatisfactionRate);
rate.vm.$emit('rate', 5);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', {
@@ -146,7 +146,7 @@ describe('MergeRequestExperienceSurveyApp', () => {
});
});
- it('shows legal note', async () => {
+ it('shows legal note', () => {
expect(wrapper.text()).toContain(
'By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the GitLab Privacy Policy.',
);
@@ -179,11 +179,11 @@ describe('MergeRequestExperienceSurveyApp', () => {
createWrapper({ shouldShowCallout: false });
});
- it('emits close event', async () => {
+ it('emits close event', () => {
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
});
- it("doesn't track anything", async () => {
+ it("doesn't track anything", () => {
expect(trackingSpy).toHaveBeenCalledTimes(0);
});
});
@@ -195,12 +195,12 @@ describe('MergeRequestExperienceSurveyApp', () => {
document.dispatchEvent(event);
});
- it('emits close event', async () => {
+ it('emits close event', () => {
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
expect(dismiss).toHaveBeenCalledTimes(1);
});
- it('tracks dismissal', async () => {
+ it('tracks dismissal', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', {
label: 'dismiss',
extra: {
diff --git a/spec/frontend/syntax_highlight_spec.js b/spec/frontend/syntax_highlight_spec.js
index 1be6c213350..a573c37ca44 100644
--- a/spec/frontend/syntax_highlight_spec.js
+++ b/spec/frontend/syntax_highlight_spec.js
@@ -1,14 +1,10 @@
-/* eslint-disable no-return-assign */
import $ from 'jquery';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import syntaxHighlight from '~/syntax_highlight';
describe('Syntax Highlighter', () => {
const stubUserColorScheme = (value) => {
- if (window.gon == null) {
- window.gon = {};
- }
- return (window.gon.user_color_scheme = value);
+ window.gon.user_color_scheme = value;
};
// We have to bind `document.querySelectorAll` to `document` to not mess up the fn's context
diff --git a/spec/frontend/tags/components/delete_tag_modal_spec.js b/spec/frontend/tags/components/delete_tag_modal_spec.js
index b1726a2c0ef..8ec9925563a 100644
--- a/spec/frontend/tags/components/delete_tag_modal_spec.js
+++ b/spec/frontend/tags/components/delete_tag_modal_spec.js
@@ -44,10 +44,6 @@ const findFormInput = () => wrapper.findComponent(GlFormInput);
const findForm = () => wrapper.find('form');
describe('Delete tag modal', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Deleting a regular tag', () => {
const expectedTitle = 'Delete tag. Are you ABSOLUTELY SURE?';
const expectedMessage = "You're about to permanently delete the tag test-tag.";
@@ -73,7 +69,7 @@ describe('Delete tag modal', () => {
expect(submitFormSpy).toHaveBeenCalled();
});
- it('calls show on the modal when a `openModal` event is received through the event hub', async () => {
+ it('calls show on the modal when a `openModal` event is received through the event hub', () => {
const showSpy = jest.spyOn(wrapper.vm.$refs.modal, 'show');
eventHub.$emit('openModal', {
diff --git a/spec/frontend/tags/components/sort_dropdown_spec.js b/spec/frontend/tags/components/sort_dropdown_spec.js
index b0fd98ec68e..e0ff370d313 100644
--- a/spec/frontend/tags/components/sort_dropdown_spec.js
+++ b/spec/frontend/tags/components/sort_dropdown_spec.js
@@ -26,12 +26,6 @@ describe('Tags sort dropdown', () => {
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByClick);
const findTagsDropdown = () => wrapper.findByTestId('tags-dropdown');
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('default state', () => {
beforeEach(() => {
wrapper = createWrapper();
diff --git a/spec/frontend/terms/components/app_spec.js b/spec/frontend/terms/components/app_spec.js
index 99f61a31dbd..cab7fbe18b0 100644
--- a/spec/frontend/terms/components/app_spec.js
+++ b/spec/frontend/terms/components/app_spec.js
@@ -37,10 +37,6 @@ describe('TermsApp', () => {
isLoggedIn.mockReturnValue(true);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findFormWithAction = (path) => wrapper.find(`form[action="${path}"]`);
const findButton = (path) => findFormWithAction(path).find('button[type="submit"]');
const findScrollableViewport = () => wrapper.findByTestId('scrollable-viewport');
@@ -69,7 +65,6 @@ describe('TermsApp', () => {
describe('accept button', () => {
it('is disabled until user scrolls to the bottom of the terms', async () => {
createComponent();
-
expect(findButton(defaultProvide.paths.accept).attributes('disabled')).toBe('disabled');
wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js
index 21bfff5f1be..293b59007c9 100644
--- a/spec/frontend/terraform/components/empty_state_spec.js
+++ b/spec/frontend/terraform/components/empty_state_spec.js
@@ -1,6 +1,7 @@
-import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlEmptyState, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EmptyState from '~/terraform/components/empty_state.vue';
+import InitCommandModal from '~/terraform/components/init_command_modal.vue';
describe('EmptyStateComponent', () => {
let wrapper;
@@ -10,23 +11,33 @@ describe('EmptyStateComponent', () => {
};
const docsUrl = '/help/user/infrastructure/iac/terraform_state';
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
- const findLink = () => wrapper.findComponent(GlLink);
+ const findButton = () => wrapper.findComponent(GlButton);
+ const findCopyModal = () => wrapper.findComponent(InitCommandModal);
+ const findCopyButton = () => wrapper.find('[data-testid="terraform-state-copy-init-command"]');
beforeEach(() => {
wrapper = shallowMount(EmptyState, { propsData });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render content', () => {
expect(findEmptyState().props('title')).toBe(
"Your project doesn't have any Terraform state files",
);
});
- it('should have a link to the GitLab managed Terraform states docs', () => {
- expect(findLink().attributes('href')).toBe(docsUrl);
+ it('buttons explore documentation should have a link to the GitLab managed Terraform states docs', () => {
+ expect(findButton().attributes('href')).toBe(docsUrl);
+ });
+
+ describe('copy command button', () => {
+ it('displays a copy init command button', () => {
+ expect(findCopyButton().text()).toBe('Copy Terraform init command');
+ });
+
+ it('opens the modal on copy button click', async () => {
+ await findCopyButton().vm.$emit('click');
+
+ expect(findCopyModal().isVisible()).toBe(true);
+ });
});
});
diff --git a/spec/frontend/terraform/components/init_command_modal_spec.js b/spec/frontend/terraform/components/init_command_modal_spec.js
index 911bb8878da..4015482b81b 100644
--- a/spec/frontend/terraform/components/init_command_modal_spec.js
+++ b/spec/frontend/terraform/components/init_command_modal_spec.js
@@ -8,6 +8,7 @@ const terraformApiUrl = 'https://gitlab.com/api/v4/projects/1';
const username = 'username';
const modalId = 'fake-modal-id';
const stateName = 'aws/eu-central-1';
+const stateNamePlaceholder = '<YOUR-STATE-NAME>';
const stateNameEncoded = encodeURIComponent(stateName);
const modalInfoCopyStr = `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN>
terraform init \\
@@ -34,53 +35,68 @@ describe('InitCommandModal', () => {
username,
};
- const findExplanatoryText = () => wrapper.findByTestId('init-command-explanatory-text');
- const findLink = () => wrapper.findComponent(GlLink);
- const findInitCommand = () => wrapper.findByTestId('terraform-init-command');
- const findCopyButton = () => wrapper.findComponent(ModalCopyButton);
-
- beforeEach(() => {
+ const mountComponent = ({ props = propsData } = {}) => {
wrapper = shallowMountExtended(InitCommandModal, {
- propsData,
+ propsData: props,
provide: provideData,
stubs: {
GlSprintf,
},
});
- });
+ };
- afterEach(() => {
- wrapper.destroy();
- });
+ const findExplanatoryText = () => wrapper.findByTestId('init-command-explanatory-text');
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findInitCommand = () => wrapper.findByTestId('terraform-init-command');
+ const findCopyButton = () => wrapper.findComponent(ModalCopyButton);
- describe('on rendering', () => {
- it('renders the explanatory text', () => {
- expect(findExplanatoryText().text()).toContain('personal access token');
+ describe('when has stateName', () => {
+ beforeEach(() => {
+ mountComponent();
});
- it('renders the personal access token link', () => {
- expect(findLink().attributes('href')).toBe(accessTokensPath);
- });
+ describe('on rendering', () => {
+ it('renders the explanatory text', () => {
+ expect(findExplanatoryText().text()).toContain('personal access token');
+ });
- describe('init command', () => {
- it('includes correct address', () => {
- expect(findInitCommand().text()).toContain(
- `-backend-config="address=${terraformApiUrl}/${stateNameEncoded}"`,
- );
+ it('renders the personal access token link', () => {
+ expect(findLink().attributes('href')).toBe(accessTokensPath);
});
- it('includes correct username', () => {
- expect(findInitCommand().text()).toContain(`-backend-config="username=${username}"`);
+
+ describe('init command', () => {
+ it('includes correct address', () => {
+ expect(findInitCommand().text()).toContain(
+ `-backend-config="address=${terraformApiUrl}/${stateNameEncoded}"`,
+ );
+ });
+ it('includes correct username', () => {
+ expect(findInitCommand().text()).toContain(`-backend-config="username=${username}"`);
+ });
+ });
+
+ it('renders the copyToClipboard button', () => {
+ expect(findCopyButton().exists()).toBe(true);
});
});
- it('renders the copyToClipboard button', () => {
- expect(findCopyButton().exists()).toBe(true);
+ describe('when copy button is clicked', () => {
+ it('copies init command to clipboard', () => {
+ expect(findCopyButton().props('text')).toBe(modalInfoCopyStr);
+ });
});
});
+ describe('when has no stateName', () => {
+ beforeEach(() => {
+ mountComponent({ props: { modalId } });
+ });
- describe('when copy button is clicked', () => {
- it('copies init command to clipboard', () => {
- expect(findCopyButton().props('text')).toBe(modalInfoCopyStr);
+ describe('on rendering', () => {
+ it('includes correct address', () => {
+ expect(findInitCommand().text()).toContain(
+ `-backend-config="address=${terraformApiUrl}/${stateNamePlaceholder}"`,
+ );
+ });
});
});
});
diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js
index 40b7448d78d..ed85825c13a 100644
--- a/spec/frontend/terraform/components/states_table_actions_spec.js
+++ b/spec/frontend/terraform/components/states_table_actions_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlModal, GlSprintf } from '@gitlab/ui';
+import { GlDropdown, GlModal, GlSprintf, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@@ -85,6 +85,7 @@ describe('StatesTableActions', () => {
const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]');
const findRemoveBtn = () => wrapper.find('[data-testid="terraform-state-remove"]');
const findRemoveModal = () => wrapper.findComponent(GlModal);
+ const findFormInput = () => wrapper.findComponent(GlFormInput);
beforeEach(() => {
return createComponent();
@@ -96,7 +97,6 @@ describe('StatesTableActions', () => {
toast = null;
unlockResponse = null;
updateStateResponse = null;
- wrapper.destroy();
});
describe('when the state is loading', () => {
@@ -143,7 +143,7 @@ describe('StatesTableActions', () => {
return waitForPromises();
});
- it('opens the modal', async () => {
+ it('opens the modal', () => {
expect(findCopyModal().exists()).toBe(true);
expect(findCopyModal().isVisible()).toBe(true);
});
@@ -296,9 +296,7 @@ describe('StatesTableActions', () => {
describe('when state name is present', () => {
beforeEach(async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({ removeConfirmText: defaultProps.state.name });
+ await findFormInput().vm.$emit('input', defaultProps.state.name);
findRemoveModal().vm.$emit('ok');
diff --git a/spec/frontend/terraform/components/states_table_spec.js b/spec/frontend/terraform/components/states_table_spec.js
index 0b3b169891b..7c783c9f717 100644
--- a/spec/frontend/terraform/components/states_table_spec.js
+++ b/spec/frontend/terraform/components/states_table_spec.js
@@ -127,7 +127,7 @@ describe('StatesTable', () => {
propsData,
provide: { projectPath: 'path/to/project' },
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
}),
);
@@ -140,11 +140,6 @@ describe('StatesTable', () => {
return createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it.each`
name | toolTipText | hasBadge | loading | lineNumber
${'state-1'} | ${'Locked by user-1 2 days ago'} | ${true} | ${false} | ${0}
diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js
index 580951e799a..ef79c51415b 100644
--- a/spec/frontend/terraform/components/terraform_list_spec.js
+++ b/spec/frontend/terraform/components/terraform_list_spec.js
@@ -63,11 +63,6 @@ describe('TerraformList', () => {
const findStatesTable = () => wrapper.findComponent(StatesTable);
const findTab = () => wrapper.findComponent(GlTab);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when the terraform query has succeeded', () => {
describe('when there is a list of terraform states', () => {
const states = [
@@ -115,8 +110,8 @@ describe('TerraformList', () => {
return waitForPromises();
});
- it('displays a states tab and count', () => {
- expect(findTab().text()).toContain('States');
+ it('displays a terraform states tab and count', () => {
+ expect(findTab().text()).toContain('Terraform states');
expect(findBadge().text()).toBe('2');
});
@@ -163,8 +158,8 @@ describe('TerraformList', () => {
return waitForPromises();
});
- it('displays a states tab with no count', () => {
- expect(findTab().text()).toContain('States');
+ it('displays a terraform states tab with no count', () => {
+ expect(findTab().text()).toContain('Terraform states');
expect(findBadge().exists()).toBe(false);
});
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 3fb226e5ed3..d3d3e5c8c72 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -1,8 +1,15 @@
/* Setup for unit test environment */
// eslint-disable-next-line no-restricted-syntax
import { setImmediate } from 'timers';
+import Dexie from 'dexie';
+import { IDBKeyRange, IDBFactory } from 'fake-indexeddb';
import 'helpers/shared_test_setup';
+const indexedDB = new IDBFactory();
+
+Dexie.dependencies.indexedDB = indexedDB;
+Dexie.dependencies.IDBKeyRange = IDBKeyRange;
+
afterEach(() =>
// give Promises a bit more time so they fail the right test
// eslint-disable-next-line no-restricted-syntax
@@ -11,3 +18,9 @@ afterEach(() =>
jest.runOnlyPendingTimers();
}),
);
+
+afterEach(async () => {
+ const dbs = await indexedDB.databases();
+
+ await Promise.all(dbs.map((db) => indexedDB.deleteDatabase(db.name)));
+});
diff --git a/spec/frontend/time_tracking/components/timelog_source_cell_spec.js b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js
new file mode 100644
index 00000000000..14015ee4e75
--- /dev/null
+++ b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js
@@ -0,0 +1,136 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue';
+import {
+ issuableStatusText,
+ STATUS_CLOSED,
+ STATUS_MERGED,
+ STATUS_OPEN,
+ STATUS_LOCKED,
+ STATUS_REOPENED,
+} from '~/issues/constants';
+
+const createIssuableTimelogMock = (
+ type,
+ { title, state, webUrl, reference } = {
+ title: 'Issuable title',
+ state: STATUS_OPEN,
+ webUrl: 'https://example.com/issuable_url',
+ reference: '#111',
+ },
+) => {
+ return {
+ timelog: {
+ project: {
+ fullPath: 'group/project',
+ },
+ [type]: {
+ title,
+ state,
+ webUrl,
+ reference,
+ },
+ },
+ };
+};
+
+describe('TimelogSourceCell component', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+
+ const findTitleContainer = () => wrapper.findByTestId('title-container');
+ const findReferenceContainer = () => wrapper.findByTestId('reference-container');
+ const findStateContainer = () => wrapper.findByTestId('state-container');
+
+ const mountComponent = ({ timelog } = {}) => {
+ wrapper = shallowMountExtended(TimelogSourceCell, {
+ propsData: {
+ timelog,
+ },
+ });
+ };
+
+ describe('when the timelog is associated to an issue', () => {
+ it('shows the issue title as link to the issue', () => {
+ mountComponent(
+ createIssuableTimelogMock('issue', {
+ title: 'Issue title',
+ webUrl: 'https://example.com/issue_url',
+ }),
+ );
+
+ const titleContainer = findTitleContainer();
+
+ expect(titleContainer.text()).toBe('Issue title');
+ expect(titleContainer.attributes('href')).toBe('https://example.com/issue_url');
+ });
+
+ it('shows the issue full reference as link to the issue', () => {
+ mountComponent(
+ createIssuableTimelogMock('issue', {
+ reference: '#111',
+ webUrl: 'https://example.com/issue_url',
+ }),
+ );
+
+ const referenceContainer = findReferenceContainer();
+
+ expect(referenceContainer.text()).toBe('group/project#111');
+ expect(referenceContainer.attributes('href')).toBe('https://example.com/issue_url');
+ });
+
+ it.each`
+ state | stateDescription
+ ${STATUS_OPEN} | ${issuableStatusText[STATUS_OPEN]}
+ ${STATUS_REOPENED} | ${issuableStatusText[STATUS_REOPENED]}
+ ${STATUS_LOCKED} | ${issuableStatusText[STATUS_LOCKED]}
+ ${STATUS_CLOSED} | ${issuableStatusText[STATUS_CLOSED]}
+ `('shows $stateDescription when the state is $state', ({ state, stateDescription }) => {
+ mountComponent(createIssuableTimelogMock('issue', { state }));
+
+ expect(findStateContainer().text()).toBe(stateDescription);
+ });
+ });
+
+ describe('when the timelog is associated to a merge request', () => {
+ it('shows the merge request title as link to the merge request', () => {
+ mountComponent(
+ createIssuableTimelogMock('mergeRequest', {
+ title: 'MR title',
+ webUrl: 'https://example.com/mr_url',
+ }),
+ );
+
+ const titleContainer = findTitleContainer();
+
+ expect(titleContainer.text()).toBe('MR title');
+ expect(titleContainer.attributes('href')).toBe('https://example.com/mr_url');
+ });
+
+ it('shows the merge request full reference as link to the merge request', () => {
+ mountComponent(
+ createIssuableTimelogMock('mergeRequest', {
+ reference: '!111',
+ webUrl: 'https://example.com/mr_url',
+ }),
+ );
+
+ const referenceContainer = findReferenceContainer();
+
+ expect(referenceContainer.text()).toBe('group/project!111');
+ expect(referenceContainer.attributes('href')).toBe('https://example.com/mr_url');
+ });
+ it.each`
+ state | stateDescription
+ ${STATUS_OPEN} | ${issuableStatusText[STATUS_OPEN]}
+ ${STATUS_CLOSED} | ${issuableStatusText[STATUS_CLOSED]}
+ ${STATUS_MERGED} | ${issuableStatusText[STATUS_MERGED]}
+ `('shows $stateDescription when the state is $state', ({ state, stateDescription }) => {
+ mountComponent(createIssuableTimelogMock('mergeRequest', { state }));
+
+ expect(findStateContainer().text()).toBe(stateDescription);
+ });
+ });
+});
diff --git a/spec/frontend/time_tracking/components/timelogs_app_spec.js b/spec/frontend/time_tracking/components/timelogs_app_spec.js
new file mode 100644
index 00000000000..ca470ce63ac
--- /dev/null
+++ b/spec/frontend/time_tracking/components/timelogs_app_spec.js
@@ -0,0 +1,238 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import * as Sentry from '@sentry/browser';
+import { GlDatepicker, GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui';
+import getTimelogsEmptyResponse from 'test_fixtures/graphql/get_timelogs_empty_response.json';
+import getPaginatedTimelogsResponse from 'test_fixtures/graphql/get_paginated_timelogs_response.json';
+import getNonPaginatedTimelogsResponse from 'test_fixtures/graphql/get_non_paginated_timelogs_response.json';
+import { createAlert } from '~/alert';
+import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import getTimelogsQuery from '~/time_tracking/components/queries/get_timelogs.query.graphql';
+import TimelogsApp from '~/time_tracking/components/timelogs_app.vue';
+import TimelogsTable from '~/time_tracking/components/timelogs_table.vue';
+
+jest.mock('~/alert');
+jest.mock('@sentry/browser');
+
+describe('Timelogs app', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+ let fakeApollo;
+
+ const findForm = () => wrapper.find('form');
+ const findUsernameInput = () => extendedWrapper(findForm()).findByTestId('form-username');
+ const findTableContainer = () => wrapper.findByTestId('table-container');
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findTotalTimeSpentContainer = () => wrapper.findByTestId('total-time-spent-container');
+ const findTable = () => wrapper.findComponent(TimelogsTable);
+ const findPagination = () => wrapper.findComponent(GlKeysetPagination);
+
+ const findFormDatePicker = (testId) =>
+ findForm()
+ .findAllComponents(GlDatepicker)
+ .filter((c) => c.attributes('data-testid') === testId);
+ const findFromDatepicker = () => findFormDatePicker('form-from-date').at(0);
+ const findToDatepicker = () => findFormDatePicker('form-to-date').at(0);
+
+ const submitForm = () => findForm().trigger('submit');
+
+ const resolvedEmptyListMock = jest.fn().mockResolvedValue(getTimelogsEmptyResponse);
+ const resolvedPaginatedListMock = jest.fn().mockResolvedValue(getPaginatedTimelogsResponse);
+ const resolvedNonPaginatedListMock = jest.fn().mockResolvedValue(getNonPaginatedTimelogsResponse);
+ const rejectedMock = jest.fn().mockRejectedValue({});
+
+ const mountComponent = ({ props, data } = {}, queryResolverMock = resolvedEmptyListMock) => {
+ fakeApollo = createMockApollo([[getTimelogsQuery, queryResolverMock]]);
+
+ wrapper = mountExtended(TimelogsApp, {
+ data() {
+ return {
+ ...data,
+ };
+ },
+ propsData: {
+ limitToHours: false,
+ ...props,
+ },
+ apolloProvider: fakeApollo,
+ });
+ };
+
+ beforeEach(() => {
+ createAlert.mockClear();
+ Sentry.captureException.mockClear();
+ });
+
+ afterEach(() => {
+ fakeApollo = null;
+ });
+
+ describe('the content', () => {
+ it('shows the form and the loading icon when loading', () => {
+ mountComponent();
+
+ expect(findForm().exists()).toBe(true);
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findTableContainer().exists()).toBe(false);
+ });
+
+ it('shows the form and the table container when finished loading', async () => {
+ mountComponent();
+
+ await waitForPromises();
+
+ expect(findForm().exists()).toBe(true);
+ expect(findLoadingIcon().exists()).toBe(false);
+ expect(findTableContainer().exists()).toBe(true);
+ });
+ });
+
+ describe('the filter form', () => {
+ it('runs the query with the correct data', async () => {
+ mountComponent();
+
+ const username = 'johnsmith';
+ const fromDate = new Date('2023-02-28');
+ const toDate = new Date('2023-03-28');
+
+ findUsernameInput().vm.$emit('input', username);
+ findFromDatepicker().vm.$emit('input', fromDate);
+ findToDatepicker().vm.$emit('input', toDate);
+
+ resolvedEmptyListMock.mockClear();
+
+ submitForm();
+
+ await waitForPromises();
+
+ expect(resolvedEmptyListMock).toHaveBeenCalledWith({
+ username,
+ startDate: fromDate,
+ endDate: toDate,
+ groupId: null,
+ projectId: null,
+ first: 20,
+ last: null,
+ after: null,
+ before: null,
+ });
+ expect(createAlert).not.toHaveBeenCalled();
+ expect(Sentry.captureException).not.toHaveBeenCalled();
+ });
+
+ it('runs the query with the correct data after the date filters are cleared', async () => {
+ mountComponent();
+
+ const username = 'johnsmith';
+
+ findUsernameInput().vm.$emit('input', username);
+ findFromDatepicker().vm.$emit('clear');
+ findToDatepicker().vm.$emit('clear');
+
+ resolvedEmptyListMock.mockClear();
+
+ submitForm();
+
+ await waitForPromises();
+
+ expect(resolvedEmptyListMock).toHaveBeenCalledWith({
+ username,
+ startDate: null,
+ endDate: null,
+ groupId: null,
+ projectId: null,
+ first: 20,
+ last: null,
+ after: null,
+ before: null,
+ });
+ expect(createAlert).not.toHaveBeenCalled();
+ expect(Sentry.captureException).not.toHaveBeenCalled();
+ });
+
+ it('shows an alert an logs to sentry when the mutation is rejected', async () => {
+ mountComponent({}, rejectedMock);
+
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Something went wrong. Please try again.',
+ });
+ expect(Sentry.captureException).toHaveBeenCalled();
+ });
+ });
+
+ describe('the total time spent container', () => {
+ it('is not visible when there are no timelogs', async () => {
+ mountComponent();
+
+ await waitForPromises();
+
+ expect(findTotalTimeSpentContainer().exists()).toBe(false);
+ });
+
+ it('shows the correct value when `limitToHours` is false', async () => {
+ mountComponent({}, resolvedNonPaginatedListMock);
+
+ await waitForPromises();
+
+ expect(findTotalTimeSpentContainer().exists()).toBe(true);
+ expect(findTotalTimeSpentContainer().text()).toBe('3d');
+ });
+
+ it('shows the correct value when `limitToHours` is true', async () => {
+ mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock);
+
+ await waitForPromises();
+
+ expect(findTotalTimeSpentContainer().exists()).toBe(true);
+ expect(findTotalTimeSpentContainer().text()).toBe('24h');
+ });
+ });
+
+ describe('the table', () => {
+ it('gets created with the right props when `limitToHours` is false', async () => {
+ mountComponent({}, resolvedNonPaginatedListMock);
+
+ await waitForPromises();
+
+ expect(findTable().props()).toMatchObject({
+ limitToHours: false,
+ entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes,
+ });
+ });
+
+ it('gets created with the right props when `limitToHours` is true', async () => {
+ mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock);
+
+ await waitForPromises();
+
+ expect(findTable().props()).toMatchObject({
+ limitToHours: true,
+ entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes,
+ });
+ });
+ });
+
+ describe('the pagination element', () => {
+ it('is not visible whene there is no pagination data', async () => {
+ mountComponent({}, resolvedNonPaginatedListMock);
+
+ await waitForPromises();
+
+ expect(findPagination().exists()).toBe(false);
+ });
+
+ it('is visible whene there is pagination data', async () => {
+ mountComponent({}, resolvedPaginatedListMock);
+
+ await waitForPromises();
+ await nextTick();
+
+ expect(findPagination().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/time_tracking/components/timelogs_table_spec.js b/spec/frontend/time_tracking/components/timelogs_table_spec.js
new file mode 100644
index 00000000000..980fb79e8fb
--- /dev/null
+++ b/spec/frontend/time_tracking/components/timelogs_table_spec.js
@@ -0,0 +1,223 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlTable } from '@gitlab/ui';
+import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
+import TimelogsTable from '~/time_tracking/components/timelogs_table.vue';
+import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
+
+const baseTimelogMock = {
+ timeSpent: 600,
+ project: {
+ fullPath: 'group/project',
+ },
+ user: {
+ name: 'John Smith',
+ avatarUrl: 'https://example.gitlab.com/john.jpg',
+ webPath: 'https://example.gitlab.com/john',
+ },
+ spentAt: '2023-03-27T21:00:00Z',
+ note: null,
+ summary: 'Summary from timelog field',
+ issue: {
+ title: 'Issue title',
+ webUrl: 'https://example.gitlab.com/issue_url_a',
+ state: STATUS_OPEN,
+ reference: '#111',
+ },
+ mergeRequest: null,
+};
+
+const timelogsMock = [
+ baseTimelogMock,
+ {
+ timeSpent: 3600,
+ project: {
+ fullPath: 'group/project_b',
+ },
+ user: {
+ name: 'Paul Reed',
+ avatarUrl: 'https://example.gitlab.com/paul.jpg',
+ webPath: 'https://example.gitlab.com/paul',
+ },
+ spentAt: '2023-03-28T16:00:00Z',
+ note: {
+ body: 'Summary from the body',
+ },
+ summary: null,
+ issue: {
+ title: 'Other issue title',
+ webUrl: 'https://example.gitlab.com/issue_url_b',
+ state: STATUS_CLOSED,
+ reference: '#112',
+ },
+ mergeRequest: null,
+ },
+ {
+ timeSpent: 27 * 60 * 60, // 27h or 3d 3h (3 days of 8 hours)
+ project: {
+ fullPath: 'group/project_b',
+ },
+ user: {
+ name: 'Les Gibbons',
+ avatarUrl: 'https://example.gitlab.com/les.jpg',
+ webPath: 'https://example.gitlab.com/les',
+ },
+ spentAt: '2023-03-28T18:00:00Z',
+ note: null,
+ summary: 'Other timelog summary',
+ issue: null,
+ mergeRequest: {
+ title: 'MR title',
+ webUrl: 'https://example.gitlab.com/mr_url',
+ state: STATUS_MERGED,
+ reference: '!99',
+ },
+ },
+];
+
+describe('TimelogsTable component', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+
+ const findTable = () => wrapper.findComponent(GlTable);
+ const findTableRows = () => findTable().find('tbody').findAll('tr');
+ const findRowSpentAt = (rowIndex) =>
+ extendedWrapper(findTableRows().at(rowIndex)).findByTestId('date-container');
+ const findRowSource = (rowIndex) => findTableRows().at(rowIndex).findComponent(TimelogSourceCell);
+ const findRowUser = (rowIndex) => findTableRows().at(rowIndex).findComponent(UserAvatarLink);
+ const findRowTimeSpent = (rowIndex) =>
+ extendedWrapper(findTableRows().at(rowIndex)).findByTestId('time-spent-container');
+ const findRowSummary = (rowIndex) =>
+ extendedWrapper(findTableRows().at(rowIndex)).findByTestId('summary-container');
+
+ const mountComponent = (props = {}) => {
+ wrapper = mountExtended(TimelogsTable, {
+ propsData: {
+ entries: timelogsMock,
+ limitToHours: false,
+ ...props,
+ },
+ stubs: { GlTable },
+ });
+ };
+
+ describe('when there are no entries', () => {
+ it('show the empty table message and no rows', () => {
+ mountComponent({ entries: [] });
+
+ expect(findTable().text()).toContain('There are no records to show');
+ expect(findTableRows()).toHaveLength(1);
+ });
+ });
+
+ describe('when there are some entries', () => {
+ it('does not show the empty table message and has the correct number of rows', () => {
+ mountComponent();
+
+ expect(findTable().text()).not.toContain('There are no records to show');
+ expect(findTableRows()).toHaveLength(3);
+ });
+
+ describe('Spent at column', () => {
+ it('shows the spent at value with in the correct format', () => {
+ mountComponent();
+
+ expect(findRowSpentAt(0).text()).toBe('March 27, 2023, 21:00 (UTC: +0000)');
+ });
+ });
+
+ describe('Source column', () => {
+ it('creates the source cell component passing the right props', () => {
+ mountComponent();
+
+ expect(findRowSource(0).props()).toMatchObject({
+ timelog: timelogsMock[0],
+ });
+ expect(findRowSource(1).props()).toMatchObject({
+ timelog: timelogsMock[1],
+ });
+ expect(findRowSource(2).props()).toMatchObject({
+ timelog: timelogsMock[2],
+ });
+ });
+ });
+
+ describe('User column', () => {
+ it('creates the user avatar component passing the right props', () => {
+ mountComponent();
+
+ expect(findRowUser(0).props()).toMatchObject({
+ linkHref: timelogsMock[0].user.webPath,
+ imgSrc: timelogsMock[0].user.avatarUrl,
+ imgSize: 16,
+ imgAlt: timelogsMock[0].user.name,
+ tooltipText: timelogsMock[0].user.name,
+ username: timelogsMock[0].user.name,
+ });
+ expect(findRowUser(1).props()).toMatchObject({
+ linkHref: timelogsMock[1].user.webPath,
+ imgSrc: timelogsMock[1].user.avatarUrl,
+ imgSize: 16,
+ imgAlt: timelogsMock[1].user.name,
+ tooltipText: timelogsMock[1].user.name,
+ username: timelogsMock[1].user.name,
+ });
+ expect(findRowUser(2).props()).toMatchObject({
+ linkHref: timelogsMock[2].user.webPath,
+ imgSrc: timelogsMock[2].user.avatarUrl,
+ imgSize: 16,
+ imgAlt: timelogsMock[2].user.name,
+ tooltipText: timelogsMock[2].user.name,
+ username: timelogsMock[2].user.name,
+ });
+ });
+ });
+
+ describe('Time spent column', () => {
+ it('shows the time spent value with the correct format when `limitToHours` is false', () => {
+ mountComponent();
+
+ expect(findRowTimeSpent(0).text()).toBe('10m');
+ expect(findRowTimeSpent(1).text()).toBe('1h');
+ expect(findRowTimeSpent(2).text()).toBe('3d 3h');
+ });
+
+ it('shows the time spent value with the correct format when `limitToHours` is true', () => {
+ mountComponent({ limitToHours: true });
+
+ expect(findRowTimeSpent(0).text()).toBe('10m');
+ expect(findRowTimeSpent(1).text()).toBe('1h');
+ expect(findRowTimeSpent(2).text()).toBe('27h');
+ });
+ });
+
+ describe('Summary column', () => {
+ it('shows the summary from the note when note body is present and not empty', () => {
+ mountComponent({
+ entries: [{ ...baseTimelogMock, note: { body: 'Summary from note body' } }],
+ });
+
+ expect(findRowSummary(0).text()).toBe('Summary from note body');
+ });
+
+ it('shows the summary from the timelog note body is present but empty', () => {
+ mountComponent({
+ entries: [{ ...baseTimelogMock, note: { body: '' } }],
+ });
+
+ expect(findRowSummary(0).text()).toBe('Summary from timelog field');
+ });
+
+ it('shows the summary from the timelog note body is not present', () => {
+ mountComponent({
+ entries: [baseTimelogMock],
+ });
+
+ expect(findRowSummary(0).text()).toBe('Summary from timelog field');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/toggles/index_spec.js b/spec/frontend/toggles/index_spec.js
index f8c43e0ad0c..cccdf17a787 100644
--- a/spec/frontend/toggles/index_spec.js
+++ b/spec/frontend/toggles/index_spec.js
@@ -44,7 +44,6 @@ describe('toggles/index.js', () => {
afterEach(() => {
document.body.innerHTML = '';
instance = null;
- toggleWrapper = null;
});
describe('initToggle', () => {
@@ -53,7 +52,7 @@ describe('toggles/index.js', () => {
initToggleWithOptions();
});
- it('attaches a GlToggle to the element', async () => {
+ it('attaches a GlToggle to the element', () => {
expect(toggleWrapper).not.toBe(null);
expect(toggleWrapper.querySelector(TOGGLE_LABEL_CLASS).textContent).toBe(toggleLabel);
});
diff --git a/spec/frontend/token_access/inbound_token_access_spec.js b/spec/frontend/token_access/inbound_token_access_spec.js
index fcd1a33fa68..1ca58053e68 100644
--- a/spec/frontend/token_access/inbound_token_access_spec.js
+++ b/spec/frontend/token_access/inbound_token_access_spec.js
@@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import InboundTokenAccess from '~/token_access/components/inbound_token_access.vue';
import inboundAddProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql';
import inboundRemoveProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql';
@@ -26,7 +26,7 @@ const error = new Error(message);
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('TokenAccess component', () => {
let wrapper;
diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js
index ab04735b985..a4b5532108a 100644
--- a/spec/frontend/token_access/mock_data.js
+++ b/spec/frontend/token_access/mock_data.js
@@ -70,13 +70,13 @@ export const removeProjectSuccess = {
export const updateScopeSuccess = {
data: {
- ciCdSettingsUpdate: {
+ projectCiCdSettingsUpdate: {
ciCdSettings: {
jobTokenScopeEnabled: false,
__typename: 'ProjectCiCdSetting',
},
errors: [],
- __typename: 'CiCdSettingsUpdatePayload',
+ __typename: 'ProjectCiCdSettingsUpdatePayload',
},
},
};
@@ -121,32 +121,6 @@ export const mockFields = [
},
];
-export const optInJwtQueryResponse = (optInJwt) => ({
- data: {
- project: {
- id: '1',
- ciCdSettings: {
- optInJwt,
- __typename: 'ProjectCiCdSetting',
- },
- __typename: 'Project',
- },
- },
-});
-
-export const optInJwtMutationResponse = (optInJwt) => ({
- data: {
- ciCdSettingsUpdate: {
- ciCdSettings: {
- optInJwt,
- __typename: 'ProjectCiCdSetting',
- },
- errors: [],
- __typename: 'CiCdSettingsUpdatePayload',
- },
- },
-});
-
export const inboundJobTokenScopeEnabledResponse = {
data: {
project: {
@@ -217,13 +191,13 @@ export const inboundRemoveProjectSuccess = {
export const inboundUpdateScopeSuccessResponse = {
data: {
- ciCdSettingsUpdate: {
+ projectCiCdSettingsUpdate: {
ciCdSettings: {
inboundJobTokenScopeEnabled: false,
__typename: 'ProjectCiCdSetting',
},
errors: [],
- __typename: 'CiCdSettingsUpdatePayload',
+ __typename: 'ProjectCiCdSettingsUpdatePayload',
},
},
};
diff --git a/spec/frontend/token_access/opt_in_jwt_spec.js b/spec/frontend/token_access/opt_in_jwt_spec.js
deleted file mode 100644
index 3a68f247aa6..00000000000
--- a/spec/frontend/token_access/opt_in_jwt_spec.js
+++ /dev/null
@@ -1,144 +0,0 @@
-import { GlLink, GlLoadingIcon, GlToggle, GlSprintf } from '@gitlab/ui';
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
-import { OPT_IN_JWT_HELP_LINK } from '~/token_access/constants';
-import OptInJwt from '~/token_access/components/opt_in_jwt.vue';
-import getOptInJwtSettingQuery from '~/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql';
-import updateOptInJwtMutation from '~/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql';
-import { optInJwtMutationResponse, optInJwtQueryResponse } from './mock_data';
-
-const errorMessage = 'An error occurred';
-const error = new Error(errorMessage);
-
-Vue.use(VueApollo);
-
-jest.mock('~/flash');
-
-describe('OptInJwt component', () => {
- let wrapper;
-
- const failureHandler = jest.fn().mockRejectedValue(error);
- const enabledOptInJwtHandler = jest.fn().mockResolvedValue(optInJwtQueryResponse(true));
- const disabledOptInJwtHandler = jest.fn().mockResolvedValue(optInJwtQueryResponse(false));
- const updateOptInJwtHandler = jest.fn().mockResolvedValue(optInJwtMutationResponse(true));
-
- const findHelpLink = () => wrapper.findComponent(GlLink);
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findToggle = () => wrapper.findComponent(GlToggle);
- const findOptInJwtExpandedSection = () => wrapper.findByTestId('opt-in-jwt-expanded-section');
-
- const createMockApolloProvider = (requestHandlers) => {
- return createMockApollo(requestHandlers);
- };
-
- const createComponent = (requestHandlers, mountFn = shallowMountExtended, options = {}) => {
- wrapper = mountFn(OptInJwt, {
- provide: {
- fullPath: 'root/my-repo',
- },
- apolloProvider: createMockApolloProvider(requestHandlers),
- data() {
- return {
- targetProjectPath: 'root/test',
- };
- },
- ...options,
- });
- };
-
- const createShallowComponent = (requestHandlers, options = {}) =>
- createComponent(requestHandlers, shallowMountExtended, options);
- const createFullComponent = (requestHandlers, options = {}) =>
- createComponent(requestHandlers, mountExtended, options);
-
- describe('loading state', () => {
- it('shows loading state and hides toggle while waiting on query to resolve', async () => {
- createShallowComponent([[getOptInJwtSettingQuery, enabledOptInJwtHandler]]);
-
- expect(findLoadingIcon().exists()).toBe(true);
- expect(findToggle().exists()).toBe(false);
-
- await waitForPromises();
-
- expect(findLoadingIcon().exists()).toBe(false);
- expect(findToggle().exists()).toBe(true);
- });
- });
-
- describe('template', () => {
- it('renders help link', async () => {
- createShallowComponent([[getOptInJwtSettingQuery, enabledOptInJwtHandler]], {
- stubs: {
- GlToggle,
- GlSprintf,
- GlLink,
- },
- });
- await waitForPromises();
-
- expect(findHelpLink().exists()).toBe(true);
- expect(findHelpLink().attributes('href')).toBe(OPT_IN_JWT_HELP_LINK);
- });
- });
-
- describe('toggle JWT token access', () => {
- it('code instruction is visible when toggle is enabled', async () => {
- createShallowComponent([[getOptInJwtSettingQuery, enabledOptInJwtHandler]]);
-
- await waitForPromises();
-
- expect(findToggle().props('value')).toBe(true);
- expect(findOptInJwtExpandedSection().exists()).toBe(true);
- });
-
- it('code instruction is hidden when toggle is disabled', async () => {
- createShallowComponent([[getOptInJwtSettingQuery, disabledOptInJwtHandler]]);
-
- await waitForPromises();
-
- expect(findToggle().props('value')).toBe(false);
- expect(findOptInJwtExpandedSection().exists()).toBe(false);
- });
-
- describe('update JWT token access', () => {
- it('calls updateOptInJwtMutation with correct arguments', async () => {
- createFullComponent([
- [getOptInJwtSettingQuery, disabledOptInJwtHandler],
- [updateOptInJwtMutation, updateOptInJwtHandler],
- ]);
-
- await waitForPromises();
-
- findToggle().vm.$emit('change', true);
-
- expect(updateOptInJwtHandler).toHaveBeenCalledWith({
- input: {
- fullPath: 'root/my-repo',
- optInJwt: true,
- },
- });
- });
-
- it('handles update error', async () => {
- createFullComponent([
- [getOptInJwtSettingQuery, enabledOptInJwtHandler],
- [updateOptInJwtMutation, failureHandler],
- ]);
-
- await waitForPromises();
-
- findToggle().vm.$emit('change', false);
-
- await waitForPromises();
-
- expect(createAlert).toHaveBeenCalledWith({
- message: 'An error occurred while update the setting. Please try again.',
- });
- });
- });
- });
-});
diff --git a/spec/frontend/token_access/outbound_token_access_spec.js b/spec/frontend/token_access/outbound_token_access_spec.js
index 893a021197f..7f321495d72 100644
--- a/spec/frontend/token_access/outbound_token_access_spec.js
+++ b/spec/frontend/token_access/outbound_token_access_spec.js
@@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import OutboundTokenAccess from '~/token_access/components/outbound_token_access.vue';
import addProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
import removeProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql';
@@ -26,7 +26,7 @@ const error = new Error(message);
Vue.use(VueApollo);
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('TokenAccess component', () => {
let wrapper;
@@ -44,15 +44,26 @@ describe('TokenAccess component', () => {
const findAddProjectBtn = () => wrapper.findByRole('button', { name: 'Add project' });
const findRemoveProjectBtn = () => wrapper.findByRole('button', { name: 'Remove access' });
const findTokenDisabledAlert = () => wrapper.findByTestId('token-disabled-alert');
+ const findDeprecationAlert = () => wrapper.findByTestId('deprecation-alert');
+ const findProjectPathInput = () => wrapper.findByTestId('project-path-input');
const createMockApolloProvider = (requestHandlers) => {
return createMockApollo(requestHandlers);
};
- const createComponent = (requestHandlers, mountFn = shallowMountExtended) => {
+ const createComponent = (
+ requestHandlers,
+ mountFn = shallowMountExtended,
+ frozenOutboundJobTokenScopes = false,
+ frozenOutboundJobTokenScopesOverride = false,
+ ) => {
wrapper = mountFn(OutboundTokenAccess, {
provide: {
fullPath: projectPath,
+ glFeatures: {
+ frozenOutboundJobTokenScopes,
+ frozenOutboundJobTokenScopesOverride,
+ },
},
apolloProvider: createMockApolloProvider(requestHandlers),
data() {
@@ -272,4 +283,59 @@ describe('TokenAccess component', () => {
expect(createAlert).toHaveBeenCalledWith({ message });
});
});
+
+ describe('with the frozenOutboundJobTokenScopes feature flag enabled', () => {
+ describe('toggle', () => {
+ it('the toggle is off and the deprecation alert is visible', async () => {
+ createComponent(
+ [
+ [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler],
+ [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler],
+ ],
+ shallowMountExtended,
+ true,
+ );
+
+ await waitForPromises();
+
+ expect(findToggle().props('value')).toBe(false);
+ expect(findToggle().props('disabled')).toBe(true);
+ expect(findDeprecationAlert().exists()).toBe(true);
+ expect(findTokenDisabledAlert().exists()).toBe(false);
+ });
+
+ it('contains a warning message about disabling the current configuration', async () => {
+ createComponent(
+ [
+ [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler],
+ [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler],
+ ],
+ mountExtended,
+ true,
+ );
+
+ await waitForPromises();
+
+ expect(findToggle().text()).toContain('Disabling this feature is a permanent change.');
+ });
+ });
+
+ describe('adding a new project', () => {
+ it('disables the input to add new projects', async () => {
+ createComponent(
+ [
+ [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler],
+ [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler],
+ ],
+ mountExtended,
+ true,
+ false,
+ );
+
+ await waitForPromises();
+
+ expect(findProjectPathInput().attributes('disabled')).toBe('disabled');
+ });
+ });
+ });
});
diff --git a/spec/frontend/token_access/token_access_app_spec.js b/spec/frontend/token_access/token_access_app_spec.js
index 7f269ee5fda..77356f1b839 100644
--- a/spec/frontend/token_access/token_access_app_spec.js
+++ b/spec/frontend/token_access/token_access_app_spec.js
@@ -1,7 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import OutboundTokenAccess from '~/token_access/components/outbound_token_access.vue';
import InboundTokenAccess from '~/token_access/components/inbound_token_access.vue';
-import OptInJwt from '~/token_access/components/opt_in_jwt.vue';
import TokenAccessApp from '~/token_access/components/token_access_app.vue';
describe('TokenAccessApp component', () => {
@@ -9,14 +8,9 @@ describe('TokenAccessApp component', () => {
const findOutboundTokenAccess = () => wrapper.findComponent(OutboundTokenAccess);
const findInboundTokenAccess = () => wrapper.findComponent(InboundTokenAccess);
- const findOptInJwt = () => wrapper.findComponent(OptInJwt);
- const createComponent = (flagState = false) => {
- wrapper = shallowMount(TokenAccessApp, {
- provide: {
- glFeatures: { ciInboundJobTokenScope: flagState },
- },
- });
+ const createComponent = () => {
+ wrapper = shallowMount(TokenAccessApp);
};
describe('default', () => {
@@ -24,20 +18,10 @@ describe('TokenAccessApp component', () => {
createComponent();
});
- it('renders the opt in jwt component', () => {
- expect(findOptInJwt().exists()).toBe(true);
- });
-
it('renders the outbound token access component', () => {
expect(findOutboundTokenAccess().exists()).toBe(true);
});
- it('does not render the inbound token access component', () => {
- expect(findInboundTokenAccess().exists()).toBe(false);
- });
- });
-
- describe('with feature flag enabled', () => {
it('renders the inbound token access component', () => {
createComponent(true);
diff --git a/spec/frontend/token_access/token_projects_table_spec.js b/spec/frontend/token_access/token_projects_table_spec.js
index b51d8b3ccea..7654aa09b0a 100644
--- a/spec/frontend/token_access/token_projects_table_spec.js
+++ b/spec/frontend/token_access/token_projects_table_spec.js
@@ -6,14 +6,19 @@ import { mockProjects, mockFields } from './mock_data';
describe('Token projects table', () => {
let wrapper;
- const createComponent = () => {
+ const defaultProps = {
+ tableFields: mockFields,
+ projects: mockProjects,
+ };
+
+ const createComponent = (props) => {
wrapper = mountExtended(TokenProjectsTable, {
provide: {
fullPath: 'root/ci-project',
},
propsData: {
- tableFields: mockFields,
- projects: mockProjects,
+ ...defaultProps,
+ ...props,
},
});
};
@@ -25,31 +30,52 @@ describe('Token projects table', () => {
const findProjectNameCell = () => wrapper.findByTestId('token-access-project-name');
const findProjectNamespaceCell = () => wrapper.findByTestId('token-access-project-namespace');
- beforeEach(() => {
+ it('displays a table', () => {
createComponent();
- });
- it('displays a table', () => {
expect(findTable().exists()).toBe(true);
});
it('displays the correct amount of table rows', () => {
+ createComponent();
+
expect(findAllTableRows()).toHaveLength(mockProjects.length);
});
it('delete project button emits event with correct project to delete', async () => {
+ createComponent();
+
await findDeleteProjectBtn().trigger('click');
expect(wrapper.emitted('removeProject')).toEqual([[mockProjects[0].fullPath]]);
});
it('does not show the remove icon if the project is locked', () => {
+ createComponent();
+
// currently two mock projects with one being a locked project
expect(findAllDeleteProjectBtn()).toHaveLength(1);
});
it('displays project and namespace cells', () => {
+ createComponent();
+
expect(findProjectNameCell().text()).toBe('merge-train-stuff');
expect(findProjectNamespaceCell().text()).toBe('root');
});
+
+ it('displays empty string for namespace when namespace is null', () => {
+ const nullNamespace = {
+ id: '1',
+ name: 'merge-train-stuff',
+ namespace: null,
+ fullPath: 'root/merge-train-stuff',
+ isLocked: false,
+ __typename: 'Project',
+ };
+
+ createComponent({ projects: [nullNamespace] });
+
+ expect(findProjectNamespaceCell().text()).toBe('');
+ });
});
diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js
index d5a63a99601..e473091f405 100644
--- a/spec/frontend/tooltips/components/tooltips_spec.js
+++ b/spec/frontend/tooltips/components/tooltips_spec.js
@@ -30,11 +30,6 @@ describe('tooltips/components/tooltips.vue', () => {
const allTooltips = () => wrapper.findAllComponents(GlTooltip);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('addTooltips', () => {
let target;
diff --git a/spec/frontend/tracking/tracking_initialization_spec.js b/spec/frontend/tracking/tracking_initialization_spec.js
index f1628ad9793..3c512cf73a7 100644
--- a/spec/frontend/tracking/tracking_initialization_spec.js
+++ b/spec/frontend/tracking/tracking_initialization_spec.js
@@ -52,14 +52,12 @@ describe('Tracking', () => {
hostname: 'app.test.com',
cookieDomain: '.test.com',
appId: '',
- userFingerprint: false,
respectDoNotTrack: true,
- forceSecureTracker: true,
eventMethod: 'post',
+ plugins: [],
contexts: { webPage: true, performanceTiming: true },
formTracking: false,
linkClickTracking: false,
- pageUnloadTimer: 10,
formTrackingConfig: {
fields: { allow: [] },
forms: { allow: [] },
@@ -80,8 +78,14 @@ describe('Tracking', () => {
it('should activate features based on what has been enabled', () => {
initDefaultTrackers();
- expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30);
- expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', 'GitLab', [standardContext]);
+ expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', {
+ minimumVisitLength: 30,
+ heartbeatDelay: 30,
+ });
+ expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', {
+ title: 'GitLab',
+ context: [standardContext],
+ });
expect(snowplowSpy).toHaveBeenCalledWith('setDocumentTitle', 'GitLab');
expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking');
expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking');
@@ -131,10 +135,10 @@ describe('Tracking', () => {
it('includes those contexts alongside the standard context', () => {
initDefaultTrackers();
- expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', 'GitLab', [
- standardContext,
- ...experimentContexts,
- ]);
+ expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', {
+ title: 'GitLab',
+ context: [standardContext, ...experimentContexts],
+ });
});
});
});
diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js
index 4871644d99f..c23790bb589 100644
--- a/spec/frontend/tracking/tracking_spec.js
+++ b/spec/frontend/tracking/tracking_spec.js
@@ -65,15 +65,14 @@ describe('Tracking', () => {
it('tracks to snowplow (our current tracking system)', () => {
Tracking.event(TEST_CATEGORY, TEST_ACTION, { label: TEST_LABEL });
- expect(snowplowSpy).toHaveBeenCalledWith(
- 'trackStructEvent',
- TEST_CATEGORY,
- TEST_ACTION,
- TEST_LABEL,
- undefined,
- undefined,
- [standardContext],
- );
+ expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', {
+ category: TEST_CATEGORY,
+ action: TEST_ACTION,
+ label: TEST_LABEL,
+ property: undefined,
+ value: undefined,
+ context: [standardContext],
+ });
});
it('returns `true` if the Snowplow library was called without issues', () => {
@@ -93,14 +92,13 @@ describe('Tracking', () => {
Tracking.event(TEST_CATEGORY, TEST_ACTION, { extra });
- expect(snowplowSpy).toHaveBeenCalledWith(
- 'trackStructEvent',
- TEST_CATEGORY,
- TEST_ACTION,
- undefined,
- undefined,
- undefined,
- [
+ expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', {
+ category: TEST_CATEGORY,
+ action: TEST_ACTION,
+ label: undefined,
+ property: undefined,
+ value: undefined,
+ context: [
{
...standardContext,
data: {
@@ -109,7 +107,7 @@ describe('Tracking', () => {
},
},
],
- );
+ });
});
it('skips tracking if snowplow is unavailable', () => {
@@ -209,14 +207,16 @@ describe('Tracking', () => {
describe('.enableFormTracking', () => {
it('tells snowplow to enable form tracking, with only explicit contexts', () => {
- const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } };
+ const config = {
+ forms: { allow: ['form-class1'] },
+ fields: { allow: ['input-class1'] },
+ };
Tracking.enableFormTracking(config, ['_passed_context_', standardContext]);
- expect(snowplowSpy).toHaveBeenCalledWith(
- 'enableFormTracking',
- { forms: { whitelist: ['form-class1'] }, fields: { whitelist: ['input-class1'] } },
- ['_passed_context_'],
- );
+ expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking', {
+ options: { forms: { allowlist: ['form-class1'] }, fields: { allowlist: ['input-class1'] } },
+ context: ['_passed_context_'],
+ });
});
it('throws an error if no allow rules are provided', () => {
@@ -232,11 +232,10 @@ describe('Tracking', () => {
it('does not add empty form allow rules', () => {
Tracking.enableFormTracking({ fields: { allow: ['input-class1'] } });
- expect(snowplowSpy).toHaveBeenCalledWith(
- 'enableFormTracking',
- { fields: { whitelist: ['input-class1'] } },
- [],
- );
+ expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking', {
+ options: { fields: { allowlist: ['input-class1'] } },
+ context: [],
+ });
});
describe('when `document.readyState` does not equal `complete`', () => {
@@ -285,15 +284,14 @@ describe('Tracking', () => {
Tracking.flushPendingEvents();
- expect(snowplowSpy).toHaveBeenCalledWith(
- 'trackStructEvent',
- TEST_CATEGORY,
- TEST_ACTION,
- TEST_LABEL,
- undefined,
- undefined,
- [standardContext],
- );
+ expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', {
+ category: TEST_CATEGORY,
+ action: TEST_ACTION,
+ label: TEST_LABEL,
+ property: undefined,
+ value: undefined,
+ context: [standardContext],
+ });
});
});
@@ -457,15 +455,14 @@ describe('Tracking', () => {
value: '0',
});
- expect(snowplowSpy).toHaveBeenCalledWith(
- 'trackStructEvent',
- TEST_CATEGORY,
- 'click_input2',
- undefined,
- undefined,
- 0,
- [standardContext],
- );
+ expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', {
+ category: TEST_CATEGORY,
+ action: 'click_input2',
+ label: undefined,
+ property: undefined,
+ value: 0,
+ context: [standardContext],
+ });
});
it('handles checkbox values correctly', () => {
diff --git a/spec/frontend/usage_quotas/components/usage_quotas_app_spec.js b/spec/frontend/usage_quotas/components/usage_quotas_app_spec.js
index cb70ea4e72d..3508bf7cfde 100644
--- a/spec/frontend/usage_quotas/components/usage_quotas_app_spec.js
+++ b/spec/frontend/usage_quotas/components/usage_quotas_app_spec.js
@@ -23,10 +23,6 @@ describe('UsageQuotasApp', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSubTitle = () => wrapper.findByTestId('usage-quotas-page-subtitle');
it('renders the view title', () => {
diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js
index 3379af3f41c..1a200090805 100644
--- a/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js
+++ b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js
@@ -53,10 +53,6 @@ describe('ProjectStorageApp', () => {
const findUsageQuotasHelpLink = () => wrapper.findByTestId('usage-quotas-help-link');
const findUsageGraph = () => wrapper.findComponent(UsageGraph);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with apollo fetching successful', () => {
let mockApollo;
diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js
index ce489f69cad..15758c94436 100644
--- a/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js
+++ b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js
@@ -2,12 +2,7 @@ import { GlTableLite, GlPopover } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ProjectStorageDetail from '~/usage_quotas/storage/components/project_storage_detail.vue';
-import {
- containerRegistryPopoverId,
- containerRegistryId,
- uploadsPopoverId,
- uploadsId,
-} from '~/usage_quotas/storage/constants';
+import { containerRegistryPopoverId, containerRegistryId } from '~/usage_quotas/storage/constants';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { projectData, projectHelpLinks } from '../mock_data';
@@ -47,16 +42,11 @@ describe('ProjectStorageDetail', () => {
const findPopoverById = (id) =>
wrapper.findAllComponents(GlPopover).filter((p) => p.attributes('data-testid') === id);
const findContainerRegistryPopover = () => findPopoverById(containerRegistryPopoverId);
- const findUploadsPopover = () => findPopoverById(uploadsPopoverId);
const findContainerRegistryWarningIcon = () => wrapper.find(`#${containerRegistryPopoverId}`);
- const findUploadsWarningIcon = () => wrapper.find(`#${uploadsPopoverId}`);
beforeEach(() => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
describe('with storage types', () => {
it.each(storageTypes)(
@@ -99,31 +89,19 @@ describe('ProjectStorageDetail', () => {
});
describe.each`
- description | mockStorageTypes | rendersContainerRegistryPopover | rendersUploadsPopover
- ${'without any storage type that has popover'} | ${[generateStorageType()]} | ${false} | ${false}
- ${'with container registry storage type'} | ${[generateStorageType(containerRegistryId)]} | ${true} | ${false}
- ${'with uploads storage type'} | ${[generateStorageType(uploadsId)]} | ${false} | ${true}
- ${'with container registry and uploads storage types'} | ${[generateStorageType(containerRegistryId), generateStorageType(uploadsId)]} | ${true} | ${true}
- `(
- '$description',
- ({ mockStorageTypes, rendersContainerRegistryPopover, rendersUploadsPopover }) => {
- beforeEach(() => {
- createComponent({ storageTypes: mockStorageTypes });
- });
-
- it(`does ${
- rendersContainerRegistryPopover ? '' : ' not'
- } render container registry warning icon and popover`, () => {
- expect(findContainerRegistryWarningIcon().exists()).toBe(rendersContainerRegistryPopover);
- expect(findContainerRegistryPopover().exists()).toBe(rendersContainerRegistryPopover);
- });
+ description | mockStorageTypes | rendersContainerRegistryPopover
+ ${'without any storage type that has popover'} | ${[generateStorageType()]} | ${false}
+ ${'with container registry storage type'} | ${[generateStorageType(containerRegistryId)]} | ${true}
+ `('$description', ({ mockStorageTypes, rendersContainerRegistryPopover }) => {
+ beforeEach(() => {
+ createComponent({ storageTypes: mockStorageTypes });
+ });
- it(`does ${
- rendersUploadsPopover ? '' : ' not'
- } render container registry warning icon and popover`, () => {
- expect(findUploadsWarningIcon().exists()).toBe(rendersUploadsPopover);
- expect(findUploadsPopover().exists()).toBe(rendersUploadsPopover);
- });
- },
- );
+ it(`does ${
+ rendersContainerRegistryPopover ? '' : ' not'
+ } render container registry warning icon and popover`, () => {
+ expect(findContainerRegistryWarningIcon().exists()).toBe(rendersContainerRegistryPopover);
+ expect(findContainerRegistryPopover().exists()).toBe(rendersContainerRegistryPopover);
+ });
+ });
});
diff --git a/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js
index 1eb3386bfb8..ebe4c4b7f4e 100644
--- a/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js
+++ b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js
@@ -16,17 +16,12 @@ describe('StorageTypeIcon', () => {
const findGlIcon = () => wrapper.findComponent(GlIcon);
describe('rendering icon', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
expected | provided
${'doc-image'} | ${'lfsObjectsSize'}
${'snippet'} | ${'snippetsSize'}
${'infrastructure-registry'} | ${'repositorySize'}
${'package'} | ${'packagesSize'}
- ${'upload'} | ${'uploadsSize'}
${'disk'} | ${'wikiSize'}
${'disk'} | ${'anything-else'}
`(
diff --git a/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js
index 75b970d937a..2662711076b 100644
--- a/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js
+++ b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js
@@ -28,21 +28,16 @@ describe('Storage Counter usage graph component', () => {
packagesSize: 3000,
containerRegistrySize: 2500,
lfsObjectsSize: 2000,
- buildArtifactsSize: 500,
- pipelineArtifactsSize: 500,
+ buildArtifactsSize: 700,
+ pipelineArtifactsSize: 300,
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"]');
@@ -55,7 +50,6 @@ describe('Storage Counter usage graph component', () => {
repositorySize,
wikiSize,
snippetsSize,
- uploadsSize,
} = data.rootStorageStatistics;
expect(types.at(0).text()).toMatchInterpolatedText(`Wiki ${numberToHumanSize(wikiSize)}`);
@@ -68,16 +62,16 @@ describe('Storage Counter usage graph component', () => {
expect(types.at(3).text()).toMatchInterpolatedText(
`Container Registry ${numberToHumanSize(containerRegistrySize)}`,
);
- expect(types.at(4).text()).toMatchInterpolatedText(
- `LFS storage ${numberToHumanSize(lfsObjectsSize)}`,
- );
+ expect(types.at(4).text()).toMatchInterpolatedText(`LFS ${numberToHumanSize(lfsObjectsSize)}`);
expect(types.at(5).text()).toMatchInterpolatedText(
`Snippets ${numberToHumanSize(snippetsSize)}`,
);
expect(types.at(6).text()).toMatchInterpolatedText(
- `Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`,
+ `Job artifacts ${numberToHumanSize(buildArtifactsSize)}`,
+ );
+ expect(types.at(7).text()).toMatchInterpolatedText(
+ `Pipeline artifacts ${numberToHumanSize(pipelineArtifactsSize)}`,
);
- expect(types.at(7).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`);
});
describe('when storage type is not used', () => {
@@ -116,8 +110,8 @@ describe('Storage Counter usage graph component', () => {
'0.14705882352941177',
'0.11764705882352941',
'0.11764705882352941',
- '0.058823529411764705',
- '0.058823529411764705',
+ '0.041176470588235294',
+ '0.01764705882352941',
]);
});
});
@@ -136,8 +130,8 @@ describe('Storage Counter usage graph component', () => {
'0.14705882352941177',
'0.11764705882352941',
'0.11764705882352941',
- '0.058823529411764705',
- '0.058823529411764705',
+ '0.041176470588235294',
+ '0.01764705882352941',
]);
});
});
diff --git a/spec/frontend/usage_quotas/storage/mock_data.js b/spec/frontend/usage_quotas/storage/mock_data.js
index b1c6be10d80..b4b02f77b52 100644
--- a/spec/frontend/usage_quotas/storage/mock_data.js
+++ b/spec/frontend/usage_quotas/storage/mock_data.js
@@ -19,16 +19,25 @@ export const projectData = {
{
storageType: {
id: 'buildArtifactsSize',
- name: 'Artifacts',
- description: 'Pipeline artifacts and job artifacts, created with CI/CD.',
+ name: 'Job artifacts',
+ description: 'Job artifacts created by CI/CD.',
helpPath: '/build-artifacts',
},
value: 400000,
},
{
storageType: {
+ id: 'pipelineArtifactsSize',
+ name: 'Pipeline artifacts',
+ description: 'Pipeline artifacts created by CI/CD.',
+ helpPath: '/pipeline-artifacts',
+ },
+ value: 400000,
+ },
+ {
+ storageType: {
id: 'lfsObjectsSize',
- name: 'LFS storage',
+ name: 'LFS',
description: 'Audio samples, videos, datasets, and graphics.',
helpPath: '/lsf-objects',
},
@@ -63,15 +72,6 @@ export const projectData = {
},
{
storageType: {
- id: 'uploadsSize',
- name: 'Uploads',
- description: 'File attachments and smaller design graphics.',
- helpPath: '/uploads',
- },
- value: 900000,
- },
- {
- storageType: {
id: 'wikiSize',
name: 'Wiki',
description: 'Wiki content.',
@@ -87,11 +87,11 @@ export const projectHelpLinks = {
containerRegistry: '/container_registry',
usageQuotas: '/usage-quotas',
buildArtifacts: '/build-artifacts',
+ pipelineArtifacts: '/pipeline-artifacts',
lfsObjects: '/lsf-objects',
packages: '/packages',
repository: '/repository',
snippets: '/snippets',
- uploads: '/uploads',
wiki: '/wiki',
};
diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js
index 5f067d9de3c..21a883aefe0 100644
--- a/spec/frontend/user_lists/components/edit_user_list_spec.js
+++ b/spec/frontend/user_lists/components/edit_user_list_spec.js
@@ -4,7 +4,7 @@ import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import EditUserList from '~/user_lists/components/edit_user_list.vue';
import UserListForm from '~/user_lists/components/user_list_form.vue';
import createStore from '~/user_lists/store/edit';
@@ -67,7 +67,7 @@ describe('user_lists/components/edit_user_list', () => {
expect(alert.text()).toContain(message);
});
- it('should not be dismissible', async () => {
+ it('should not be dismissible', () => {
expect(alert.props('dismissible')).toBe(false);
});
@@ -114,7 +114,7 @@ describe('user_lists/components/edit_user_list', () => {
});
it('should redirect to the feature flag details page', () => {
- expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ expect(redirectTo).toHaveBeenCalledWith(userList.path); // eslint-disable-line import/no-deprecated
});
});
diff --git a/spec/frontend/user_lists/components/new_user_list_spec.js b/spec/frontend/user_lists/components/new_user_list_spec.js
index 8683cf2463c..004cfb6ca07 100644
--- a/spec/frontend/user_lists/components/new_user_list_spec.js
+++ b/spec/frontend/user_lists/components/new_user_list_spec.js
@@ -4,7 +4,7 @@ import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import NewUserList from '~/user_lists/components/new_user_list.vue';
import createStore from '~/user_lists/store/new';
import { userList } from 'jest/feature_flags/mock_data';
@@ -58,7 +58,7 @@ describe('user_lists/components/new_user_list', () => {
});
it('should redirect to the feature flag details page', () => {
- expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ expect(redirectTo).toHaveBeenCalledWith(userList.path); // eslint-disable-line import/no-deprecated
});
});
diff --git a/spec/frontend/user_lists/components/user_lists_spec.js b/spec/frontend/user_lists/components/user_lists_spec.js
index 161eb036361..2da2eb0dd5f 100644
--- a/spec/frontend/user_lists/components/user_lists_spec.js
+++ b/spec/frontend/user_lists/components/user_lists_spec.js
@@ -39,11 +39,6 @@ describe('~/user_lists/components/user_lists.vue', () => {
const newButton = () => within(wrapper.element).queryAllByText('New user list');
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('without permissions', () => {
const provideData = {
...mockProvide,
@@ -87,7 +82,7 @@ describe('~/user_lists/components/user_lists.vue', () => {
emptyState = wrapper.findComponent(GlEmptyState);
});
- it('should render the empty state', async () => {
+ it('should render the empty state', () => {
expect(emptyState.exists()).toBe(true);
});
diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js
index 3324b040b86..96e9705f02b 100644
--- a/spec/frontend/user_lists/components/user_lists_table_spec.js
+++ b/spec/frontend/user_lists/components/user_lists_table_spec.js
@@ -22,10 +22,6 @@ describe('User Lists Table', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should display the details of a user list', () => {
expect(wrapper.find('[data-testid="ffUserListName"]').text()).toBe(userList.name);
expect(wrapper.find('[data-testid="ffUserListIds"]').text()).toBe(
diff --git a/spec/frontend/user_lists/store/edit/actions_spec.js b/spec/frontend/user_lists/store/edit/actions_spec.js
index ca56c935ea5..0fd08c1c052 100644
--- a/spec/frontend/user_lists/store/edit/actions_spec.js
+++ b/spec/frontend/user_lists/store/edit/actions_spec.js
@@ -1,6 +1,6 @@
import testAction from 'helpers/vuex_action_helper';
import Api from '~/api';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import * as actions from '~/user_lists/store/edit/actions';
import * as types from '~/user_lists/store/edit/mutation_types';
import createState from '~/user_lists/store/edit/state';
@@ -89,7 +89,7 @@ describe('User Lists Edit Actions', () => {
name: updatedList.name,
iid: updatedList.iid,
});
- expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ expect(redirectTo).toHaveBeenCalledWith(userList.path); // eslint-disable-line import/no-deprecated
});
});
});
diff --git a/spec/frontend/user_lists/store/new/actions_spec.js b/spec/frontend/user_lists/store/new/actions_spec.js
index fa69fa7fa66..7ecf05e380a 100644
--- a/spec/frontend/user_lists/store/new/actions_spec.js
+++ b/spec/frontend/user_lists/store/new/actions_spec.js
@@ -1,6 +1,6 @@
import testAction from 'helpers/vuex_action_helper';
import Api from '~/api';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import * as actions from '~/user_lists/store/new/actions';
import * as types from '~/user_lists/store/new/mutation_types';
import createState from '~/user_lists/store/new/state';
@@ -41,7 +41,7 @@ describe('User Lists Edit Actions', () => {
it('should redirect to the user list page', () => {
return testAction(actions.createUserList, createdList, state, [], [], () => {
expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', createdList);
- expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ expect(redirectTo).toHaveBeenCalledWith(userList.path); // eslint-disable-line import/no-deprecated
});
});
});
diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js
index 8ce071c075f..3346735055d 100644
--- a/spec/frontend/user_popovers_spec.js
+++ b/spec/frontend/user_popovers_spec.js
@@ -1,5 +1,6 @@
import { within } from '@testing-library/dom';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlMergeRequestWithMentions from 'test_fixtures/merge_requests/merge_request_with_mentions.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import UsersCache from '~/lib/utils/users_cache';
import initUserPopovers from '~/user_popovers';
import waitForPromises from 'helpers/wait_for_promises';
@@ -10,10 +11,6 @@ jest.mock('~/api/user_api', () => ({
}));
describe('User Popovers', () => {
- let origGon;
-
- const fixtureTemplate = 'merge_requests/merge_request_with_mentions.html';
-
const selector = '.js-user-link[data-user], .js-user-link[data-user-id]';
const findFixtureLinks = () => Array.from(document.querySelectorAll(selector));
const createUserLink = () => {
@@ -42,7 +39,7 @@ describe('User Popovers', () => {
};
const setupTestSubject = () => {
- loadHTMLFixture(fixtureTemplate);
+ setHTMLFixture(htmlMergeRequestWithMentions);
const usersCacheSpy = () => Promise.resolve(dummyUser);
jest.spyOn(UsersCache, 'retrieveById').mockImplementation((userId) => usersCacheSpy(userId));
@@ -60,15 +57,6 @@ describe('User Popovers', () => {
});
};
- beforeEach(() => {
- origGon = window.gon;
- window.gon = {};
- });
-
- afterEach(() => {
- window.gon = origGon;
- });
-
describe('when signed out', () => {
beforeEach(() => {
setupTestSubject();
@@ -108,7 +96,7 @@ describe('User Popovers', () => {
expect(findPopovers().length).toBe(linksWithUsers.length);
});
- it('for elements added after initial load', async () => {
+ it('for elements added after initial load', () => {
const addedLinks = [createUserLink(), createUserLink()];
addedLinks.forEach((link) => {
document.body.appendChild(link);
@@ -124,7 +112,7 @@ describe('User Popovers', () => {
});
});
- it('does not initialize the popovers for group references', async () => {
+ it('does not initialize the popovers for group references', () => {
const [groupLink] = Array.from(document.querySelectorAll('.js-user-link[data-group]'));
triggerEvent('mouseover', groupLink);
@@ -133,7 +121,7 @@ describe('User Popovers', () => {
expect(findPopovers().length).toBe(0);
});
- it('does not initialize the popovers for @all references', async () => {
+ it('does not initialize the popovers for @all references', () => {
const [projectLink] = Array.from(document.querySelectorAll('.js-user-link[data-project]'));
triggerEvent('mouseover', projectLink);
@@ -142,7 +130,7 @@ describe('User Popovers', () => {
expect(findPopovers().length).toBe(0);
});
- it('does not initialize the user popovers twice for the same element', async () => {
+ it('does not initialize the user popovers twice for the same element', () => {
const [firstUserLink] = findFixtureLinks();
triggerEvent('mouseover', firstUserLink);
jest.runOnlyPendingTimers();
diff --git a/spec/frontend/validators/length_validator_spec.js b/spec/frontend/validators/length_validator_spec.js
new file mode 100644
index 00000000000..ece8238b3e3
--- /dev/null
+++ b/spec/frontend/validators/length_validator_spec.js
@@ -0,0 +1,91 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import LengthValidator, { isAboveMaxLength, isBelowMinLength } from '~/validators/length_validator';
+
+describe('length_validator', () => {
+ describe('isAboveMaxLength', () => {
+ it('should return true if the string is longer than the maximum length', () => {
+ expect(isAboveMaxLength('123456', '5')).toBe(true);
+ });
+
+ it('should return false if the string is shorter than the maximum length', () => {
+ expect(isAboveMaxLength('1234', '5')).toBe(false);
+ });
+ });
+
+ describe('isBelowMinLength', () => {
+ it('should return true if the string is shorter than the minimum length and not empty', () => {
+ expect(isBelowMinLength('1234', '5', 'false')).toBe(true);
+ });
+
+ it('should return false if the string is longer than the minimum length', () => {
+ expect(isBelowMinLength('123456', '5', 'false')).toBe(false);
+ });
+
+ it('should return false if the string is empty and allowed to be empty', () => {
+ expect(isBelowMinLength('', '5', 'true')).toBe(false);
+ });
+
+ it('should return true if the string is empty and not allowed to be empty', () => {
+ expect(isBelowMinLength('', '5', 'false')).toBe(true);
+ });
+ });
+
+ describe('LengthValidator', () => {
+ let input;
+ let validator;
+
+ beforeEach(() => {
+ setHTMLFixture(
+ '<div class="container"><input class="js-validate-length" /><span class="gl-field-error"></span></div>',
+ );
+ input = document.querySelector('input');
+ input.dataset.minLength = '3';
+ input.dataset.maxLength = '5';
+ input.dataset.minLengthMessage = 'Too short';
+ input.dataset.maxLengthMessage = 'Too long';
+ validator = new LengthValidator({ container: '.container' });
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ it('sets error message for input with value longer than max length', () => {
+ input.value = '123456';
+ input.dispatchEvent(new Event('input'));
+ expect(validator.errorMessage).toBe('Too long');
+ });
+
+ it('sets error message for input with value shorter than min length', () => {
+ input.value = '12';
+ input.dispatchEvent(new Event('input'));
+ expect(validator.errorMessage).toBe('Too short');
+ });
+
+ it('does not set error message for input with valid length', () => {
+ input.value = '123';
+ input.dispatchEvent(new Event('input'));
+ expect(validator.errorMessage).toBeNull();
+ });
+
+ it('does not set error message for empty input if allowEmpty is true', () => {
+ input.dataset.allowEmpty = 'true';
+ input.value = '';
+ input.dispatchEvent(new Event('input'));
+ expect(validator.errorMessage).toBeNull();
+ });
+
+ it('sets error message for empty input if allowEmpty is false', () => {
+ input.dataset.allowEmpty = 'false';
+ input.value = '';
+ input.dispatchEvent(new Event('input'));
+ expect(validator.errorMessage).toBe('Too short');
+ });
+
+ it('sets error message for empty input if allowEmpty is not defined', () => {
+ input.value = '';
+ input.dispatchEvent(new Event('input'));
+ expect(validator.errorMessage).toBe('Too short');
+ });
+ });
+});
diff --git a/spec/frontend/vue3migration/compiler_spec.js b/spec/frontend/vue3migration/compiler_spec.js
new file mode 100644
index 00000000000..3623f69fe07
--- /dev/null
+++ b/spec/frontend/vue3migration/compiler_spec.js
@@ -0,0 +1,38 @@
+import { mount } from '@vue/test-utils';
+
+import SlotsWithSameName from './components/slots_with_same_name.vue';
+import VOnceInsideVIf from './components/v_once_inside_v_if.vue';
+import KeyInsideTemplate from './components/key_inside_template.vue';
+import CommentsOnRootLevel from './components/comments_on_root_level.vue';
+import SlotWithComment from './components/slot_with_comment.vue';
+import DefaultSlotWithComment from './components/default_slot_with_comment.vue';
+
+describe('Vue.js 3 compiler edge cases', () => {
+ it('workarounds issue #6063 when same slot is used with whitespace preserve', () => {
+ expect(() => mount(SlotsWithSameName)).not.toThrow();
+ });
+
+ it('workarounds issue #7725 when v-once is used inside v-if', () => {
+ expect(() => mount(VOnceInsideVIf)).not.toThrow();
+ });
+
+ it('renders vue.js 2 component when key is inside template', () => {
+ const wrapper = mount(KeyInsideTemplate);
+ expect(wrapper.text()).toBe('12345');
+ });
+
+ it('passes attributes to component with trailing comments on root level', () => {
+ const wrapper = mount(CommentsOnRootLevel, { propsData: { 'data-testid': 'test' } });
+ expect(wrapper.html()).toBe('<div data-testid="test"></div>');
+ });
+
+ it('treats empty slots with comments as empty', () => {
+ const wrapper = mount(SlotWithComment);
+ expect(wrapper.html()).toBe('<div>Simple</div>');
+ });
+
+ it('treats empty default slot with comments as empty', () => {
+ const wrapper = mount(DefaultSlotWithComment);
+ expect(wrapper.html()).toBe('<div>Simple</div>');
+ });
+});
diff --git a/spec/frontend/vue3migration/components/comments_on_root_level.vue b/spec/frontend/vue3migration/components/comments_on_root_level.vue
new file mode 100644
index 00000000000..78222c059d5
--- /dev/null
+++ b/spec/frontend/vue3migration/components/comments_on_root_level.vue
@@ -0,0 +1,5 @@
+<template>
+ <!-- root level comment -->
+ <div><slot></slot></div>
+ <!-- root level comment -->
+</template>
diff --git a/spec/frontend/vue3migration/components/default_slot_with_comment.vue b/spec/frontend/vue3migration/components/default_slot_with_comment.vue
new file mode 100644
index 00000000000..d2589104a5d
--- /dev/null
+++ b/spec/frontend/vue3migration/components/default_slot_with_comment.vue
@@ -0,0 +1,18 @@
+<script>
+import Simple from './simple.vue';
+
+export default {
+ components: {
+ Simple,
+ },
+};
+</script>
+<template>
+ <simple>
+ <!-- slot comment typical for gitlab-ui, for example -->
+ <!-- slot comment typical for gitlab-ui, for example -->
+ <slot></slot>
+ <!-- slot comment typical for gitlab-ui, for example -->
+ <!-- slot comment typical for gitlab-ui, for example -->
+ </simple>
+</template>
diff --git a/spec/frontend/vue3migration/components/key_inside_template.vue b/spec/frontend/vue3migration/components/key_inside_template.vue
new file mode 100644
index 00000000000..af1f46c44e6
--- /dev/null
+++ b/spec/frontend/vue3migration/components/key_inside_template.vue
@@ -0,0 +1,7 @@
+<template>
+ <div>
+ <template v-for="count in 5"
+ ><span :key="count">{{ count }}</span></template
+ >
+ </div>
+</template>
diff --git a/spec/frontend/vue3migration/components/simple.vue b/spec/frontend/vue3migration/components/simple.vue
new file mode 100644
index 00000000000..1d9854b5b4d
--- /dev/null
+++ b/spec/frontend/vue3migration/components/simple.vue
@@ -0,0 +1,10 @@
+<script>
+export default {
+ name: 'Simple',
+};
+</script>
+<template>
+ <div>
+ <slot>{{ $options.name }}</slot>
+ </div>
+</template>
diff --git a/spec/frontend/vue3migration/components/slot_with_comment.vue b/spec/frontend/vue3migration/components/slot_with_comment.vue
new file mode 100644
index 00000000000..56bb41e432f
--- /dev/null
+++ b/spec/frontend/vue3migration/components/slot_with_comment.vue
@@ -0,0 +1,20 @@
+<script>
+import Simple from './simple.vue';
+
+export default {
+ components: {
+ Simple,
+ },
+};
+</script>
+<template>
+ <simple>
+ <template #default>
+ <!-- slot comment typical for gitlab-ui, for example -->
+ <!-- slot comment typical for gitlab-ui, for example -->
+ <slot></slot>
+ <!-- slot comment typical for gitlab-ui, for example -->
+ <!-- slot comment typical for gitlab-ui, for example -->
+ </template>
+ </simple>
+</template>
diff --git a/spec/frontend/vue3migration/components/slots_with_same_name.vue b/spec/frontend/vue3migration/components/slots_with_same_name.vue
new file mode 100644
index 00000000000..37604cd9f6e
--- /dev/null
+++ b/spec/frontend/vue3migration/components/slots_with_same_name.vue
@@ -0,0 +1,14 @@
+<script>
+import Simple from './simple.vue';
+
+export default {
+ name: 'SlotsWithSameName',
+ components: { Simple },
+};
+</script>
+<template>
+ <simple>
+ <template v-if="true" #default>{{ $options.name }}</template>
+ <template v-else #default>{{ $options.name }}</template>
+ </simple>
+</template>
diff --git a/spec/frontend/vue3migration/components/v_once_inside_v_if.vue b/spec/frontend/vue3migration/components/v_once_inside_v_if.vue
new file mode 100644
index 00000000000..708aa7a96c2
--- /dev/null
+++ b/spec/frontend/vue3migration/components/v_once_inside_v_if.vue
@@ -0,0 +1,12 @@
+<script>
+export default {
+ name: 'VOnceInsideVIf',
+};
+</script>
+<template>
+ <div>
+ <template v-if="true">
+ <div v-once>{{ $options.name }}</div>
+ </template>
+ </div>
+</template>
diff --git a/spec/frontend/vue_compat_test_setup.js b/spec/frontend/vue_compat_test_setup.js
new file mode 100644
index 00000000000..6eba9465c80
--- /dev/null
+++ b/spec/frontend/vue_compat_test_setup.js
@@ -0,0 +1,141 @@
+/* eslint-disable import/no-commonjs */
+const Vue = require('vue');
+const VTU = require('@vue/test-utils');
+const { installCompat: installVTUCompat, fullCompatConfig } = require('vue-test-utils-compat');
+
+function getComponentName(component) {
+ if (!component) {
+ return undefined;
+ }
+
+ return (
+ component.name ||
+ getComponentName(component.extends) ||
+ component.mixins?.find((mixin) => getComponentName(mixin))
+ );
+}
+
+function isLegacyExtendedComponent(component) {
+ return Reflect.has(component, 'super') && component.super.extend({}).super === component.super;
+}
+function unwrapLegacyVueExtendComponent(selector) {
+ return isLegacyExtendedComponent(selector) ? selector.options : selector;
+}
+
+if (global.document) {
+ const compatConfig = {
+ MODE: 2,
+
+ GLOBAL_MOUNT: 'suppress-warning',
+ GLOBAL_EXTEND: 'suppress-warning',
+ GLOBAL_PROTOTYPE: 'suppress-warning',
+ RENDER_FUNCTION: 'suppress-warning',
+
+ INSTANCE_DESTROY: 'suppress-warning',
+ INSTANCE_DELETE: 'suppress-warning',
+
+ INSTANCE_ATTRS_CLASS_STYLE: 'suppress-warning',
+ INSTANCE_CHILDREN: 'suppress-warning',
+ INSTANCE_SCOPED_SLOTS: 'suppress-warning',
+ INSTANCE_LISTENERS: 'suppress-warning',
+ INSTANCE_EVENT_EMITTER: 'suppress-warning',
+ INSTANCE_EVENT_HOOKS: 'suppress-warning',
+ INSTANCE_SET: 'suppress-warning',
+ GLOBAL_OBSERVABLE: 'suppress-warning',
+ GLOBAL_SET: 'suppress-warning',
+ COMPONENT_FUNCTIONAL: 'suppress-warning',
+ COMPONENT_V_MODEL: 'suppress-warning',
+ COMPONENT_ASYNC: 'suppress-warning',
+ CUSTOM_DIR: 'suppress-warning',
+ OPTIONS_BEFORE_DESTROY: 'suppress-warning',
+ OPTIONS_DATA_MERGE: 'suppress-warning',
+ OPTIONS_DATA_FN: 'suppress-warning',
+ OPTIONS_DESTROYED: 'suppress-warning',
+ ATTR_FALSE_VALUE: 'suppress-warning',
+
+ COMPILER_V_ON_NATIVE: 'suppress-warning',
+ COMPILER_V_BIND_OBJECT_ORDER: 'suppress-warning',
+
+ CONFIG_WHITESPACE: 'suppress-warning',
+ CONFIG_OPTION_MERGE_STRATS: 'suppress-warning',
+ PRIVATE_APIS: 'suppress-warning',
+ WATCH_ARRAY: 'suppress-warning',
+ };
+
+ let compatH;
+ Vue.config.compilerOptions.whitespace = 'preserve';
+ Vue.createApp({
+ compatConfig: {
+ MODE: 3,
+ RENDER_FUNCTION: 'suppress-warning',
+ },
+ render(h) {
+ compatH = h;
+ },
+ }).mount(document.createElement('div'));
+
+ Vue.configureCompat(compatConfig);
+ installVTUCompat(VTU, fullCompatConfig, compatH);
+ VTU.config.global.renderStubDefaultSlot = true;
+
+ const noop = () => {};
+
+ VTU.config.plugins.createStubs = ({ name, component: rawComponent, registerStub }) => {
+ const component = unwrapLegacyVueExtendComponent(rawComponent);
+ const hyphenatedName = name.replace(/\B([A-Z])/g, '-$1').toLowerCase();
+
+ const stub = Vue.defineComponent({
+ name: getComponentName(component),
+ props: component.props,
+ model: component.model,
+ methods: Object.fromEntries(
+ Object.entries(component.methods ?? {}).map(([key]) => [key, noop]),
+ ),
+ render() {
+ const {
+ $slots: slots = {},
+ $scopedSlots: scopedSlots = {},
+ $parent: parent,
+ $vnode: vnode,
+ } = this;
+
+ const hasStaticDefaultSlot = 'default' in slots && !('default' in scopedSlots);
+ const isTheOnlyChild = parent?.$.subTree === vnode;
+ // this condition should be altered when https://github.com/vuejs/vue-test-utils/pull/2068 is merged
+ // and our codebase will be updated to include it (@vue/test-utils@1.3.6 I assume)
+ const shouldRenderAllSlots = !hasStaticDefaultSlot && isTheOnlyChild;
+
+ const renderSlotByName = (slotName) => {
+ const slot = scopedSlots[slotName] || slots[slotName];
+ let result;
+ if (typeof slot === 'function') {
+ try {
+ result = slot({});
+ } catch {
+ // intentionally blank
+ }
+ } else {
+ result = slot;
+ }
+ return result;
+ };
+
+ const slotContents = shouldRenderAllSlots
+ ? [...new Set([...Object.keys(slots), ...Object.keys(scopedSlots)])]
+ .map(renderSlotByName)
+ .filter(Boolean)
+ : renderSlotByName('default');
+
+ return Vue.h(`${hyphenatedName || 'anonymous'}-stub`, this.$props, slotContents);
+ },
+ });
+
+ if (typeof component === 'function') {
+ component()?.then?.((resolvedComponent) => {
+ registerStub({ source: resolvedComponent.default, stub });
+ });
+ }
+
+ return stub;
+ };
+}
diff --git a/spec/frontend/vue_merge_request_widget/components/action_buttons.js b/spec/frontend/vue_merge_request_widget/components/action_buttons.js
index 6d714aeaf18..7334f061dc9 100644
--- a/spec/frontend/vue_merge_request_widget/components/action_buttons.js
+++ b/spec/frontend/vue_merge_request_widget/components/action_buttons.js
@@ -11,10 +11,6 @@ function factory(propsData = {}) {
}
describe('MR widget extension actions', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('tertiaryButtons', () => {
it('renders buttons', () => {
factory({
diff --git a/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js
index 063425454d7..4164a7df482 100644
--- a/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js
@@ -14,10 +14,6 @@ function factory(propsData) {
}
describe('Widget added commit message', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('displays changes where not merged when state is closed', () => {
factory({ state: 'closed' });
diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js
index bf208f16d18..a07a60438fb 100644
--- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js
@@ -1,26 +1,34 @@
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import { GlButton, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { createAlert } from '~/flash';
+import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client';
+import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createAlert } from '~/alert';
import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue';
import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue';
import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue';
import {
- FETCH_LOADING,
- FETCH_ERROR,
APPROVE_ERROR,
UNAPPROVE_ERROR,
} from '~/vue_merge_request_widget/components/approvals/messages';
import eventHub from '~/vue_merge_request_widget/event_hub';
+import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql';
+import approvedBySubscription from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql';
+import { createCanApproveResponse } from 'jest/approvals/mock_data';
+
+Vue.use(VueApollo);
const mockAlertDismiss = jest.fn();
-jest.mock('~/flash', () => ({
+jest.mock('~/alert', () => ({
createAlert: jest.fn().mockImplementation(() => ({
dismiss: mockAlertDismiss,
})),
}));
-const RULE_NAME = 'first_rule';
const TEST_HELP_PATH = 'help/path';
const testApprovedBy = () => [1, 7, 10].map((id) => ({ id }));
const testApprovals = () => ({
@@ -34,20 +42,38 @@ const testApprovals = () => ({
require_password_to_approve: false,
invalid_approvers_rules: [],
});
-const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] });
describe('MRWidget approvals', () => {
+ let mockedSubscription;
let wrapper;
let service;
let mr;
- const createComponent = (props = {}) => {
+ const createComponent = (options = {}, responses = { query: approvedByCurrentUser }) => {
+ mockedSubscription = createMockApolloSubscription();
+
+ const requestHandlers = [[approvedByQuery, jest.fn().mockResolvedValue(responses.query)]];
+ const subscriptionHandlers = [[approvedBySubscription, () => mockedSubscription]];
+ const apolloProvider = createMockApollo(requestHandlers);
+ const provide = {
+ ...options.provide,
+ glFeatures: {
+ realtimeApprovals: options.provide?.glFeatures?.realtimeApprovals || false,
+ },
+ };
+
+ subscriptionHandlers.forEach(([document, stream]) => {
+ apolloProvider.defaultClient.setRequestHandler(document, stream);
+ });
+
wrapper = shallowMount(Approvals, {
+ apolloProvider,
propsData: {
mr,
service,
- ...props,
+ ...options.props,
},
+ provide,
stubs: {
GlSprintf,
},
@@ -68,15 +94,10 @@ describe('MRWidget approvals', () => {
};
const findSummary = () => wrapper.findComponent(ApprovalsSummary);
const findOptionalSummary = () => wrapper.findComponent(ApprovalsSummaryOptional);
- const findInvalidRules = () => wrapper.find('[data-testid="invalid-rules"]');
beforeEach(() => {
service = {
...{
- fetchApprovals: jest.fn().mockReturnValue(Promise.resolve(testApprovals())),
- fetchApprovalSettings: jest
- .fn()
- .mockReturnValue(Promise.resolve(testApprovalRulesResponse())),
approveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())),
unapproveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())),
approveMergeRequestWithAuth: jest.fn().mockReturnValue(Promise.resolve(testApprovals())),
@@ -93,59 +114,26 @@ describe('MRWidget approvals', () => {
isOpen: true,
state: 'open',
targetProjectFullPath: 'gitlab-org/gitlab',
+ id: 1,
iid: '1',
};
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
- });
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('when created', () => {
- it('shows loading message', async () => {
- service = {
- fetchApprovals: jest.fn().mockReturnValue(new Promise(() => {})),
- };
-
- createComponent();
- await nextTick();
- expect(wrapper.text()).toContain(FETCH_LOADING);
- });
-
- it('fetches approvals', () => {
- createComponent();
- expect(service.fetchApprovals).toHaveBeenCalled();
- });
- });
-
- describe('when fetch approvals error', () => {
- beforeEach(() => {
- jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject());
- createComponent();
- return nextTick();
- });
-
- it('still shows loading message', () => {
- expect(wrapper.text()).toContain(FETCH_LOADING);
- });
-
- it('flashes error', () => {
- expect(createAlert).toHaveBeenCalledWith({ message: FETCH_ERROR });
- });
+ gon.current_user_id = getIdFromGraphQLId(
+ approvedByCurrentUser.data.project.mergeRequest.approvedBy.nodes[0].id,
+ );
});
describe('action button', () => {
describe('when mr is closed', () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ const response = createCanApproveResponse();
+
mr.isOpen = false;
- mr.approvals.user_has_approved = false;
- mr.approvals.user_can_approve = true;
- createComponent();
- return nextTick();
+ createComponent({}, { query: response });
+ await waitForPromises();
});
it('action is not rendered', () => {
@@ -154,12 +142,12 @@ describe('MRWidget approvals', () => {
});
describe('when user cannot approve', () => {
- beforeEach(() => {
- mr.approvals.user_has_approved = false;
- mr.approvals.user_can_approve = false;
+ beforeEach(async () => {
+ const response = JSON.parse(JSON.stringify(approvedByCurrentUser));
+ response.data.project.mergeRequest.approvedBy.nodes = [];
- createComponent();
- return nextTick();
+ createComponent({}, { query: response });
+ await waitForPromises();
});
it('action is not rendered', () => {
@@ -168,15 +156,16 @@ describe('MRWidget approvals', () => {
});
describe('when user can approve', () => {
+ let canApproveResponse;
+
beforeEach(() => {
- mr.approvals.user_has_approved = false;
- mr.approvals.user_can_approve = true;
+ canApproveResponse = createCanApproveResponse();
});
describe('and MR is unapproved', () => {
- beforeEach(() => {
- createComponent();
- return nextTick();
+ beforeEach(async () => {
+ createComponent({}, { query: canApproveResponse });
+ await waitForPromises();
});
it('approve action is rendered', () => {
@@ -190,30 +179,33 @@ describe('MRWidget approvals', () => {
describe('and MR is approved', () => {
beforeEach(() => {
- mr.approvals.approved = true;
+ canApproveResponse.data.project.mergeRequest.approved = true;
});
describe('with no approvers', () => {
- beforeEach(() => {
- mr.approvals.approved_by = [];
- createComponent();
- return nextTick();
+ beforeEach(async () => {
+ canApproveResponse.data.project.mergeRequest.approvedBy.nodes = [];
+ createComponent({}, { query: canApproveResponse });
+ await nextTick();
});
- it('approve action (with inverted style) is rendered', () => {
- expect(findActionData()).toEqual({
+ it('approve action is rendered', () => {
+ expect(findActionData()).toMatchObject({
variant: 'confirm',
text: 'Approve',
- category: 'secondary',
});
});
});
describe('with approvers', () => {
- beforeEach(() => {
- mr.approvals.approved_by = [{ user: { id: 7 } }];
- createComponent();
- return nextTick();
+ beforeEach(async () => {
+ canApproveResponse.data.project.mergeRequest.approvedBy.nodes =
+ approvedByCurrentUser.data.project.mergeRequest.approvedBy.nodes;
+
+ canApproveResponse.data.project.mergeRequest.approvedBy.nodes[0].id = 2;
+
+ createComponent({}, { query: canApproveResponse });
+ await waitForPromises();
});
it('approve additionally action is rendered', () => {
@@ -227,9 +219,9 @@ describe('MRWidget approvals', () => {
});
describe('when approve action is clicked', () => {
- beforeEach(() => {
- createComponent();
- return nextTick();
+ beforeEach(async () => {
+ createComponent({}, { query: canApproveResponse });
+ await waitForPromises();
});
it('shows loading icon', () => {
@@ -258,10 +250,6 @@ describe('MRWidget approvals', () => {
it('emits to eventHub', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
});
-
- it('calls store setApprovals', () => {
- expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals());
- });
});
describe('and error', () => {
@@ -286,12 +274,12 @@ describe('MRWidget approvals', () => {
});
describe('when user has approved', () => {
- beforeEach(() => {
- mr.approvals.user_has_approved = true;
- mr.approvals.user_can_approve = false;
+ beforeEach(async () => {
+ const response = JSON.parse(JSON.stringify(approvedByCurrentUser));
- createComponent();
- return nextTick();
+ createComponent({}, { query: response });
+
+ await waitForPromises();
});
it('revoke action is rendered', () => {
@@ -316,10 +304,6 @@ describe('MRWidget approvals', () => {
it('emits to eventHub', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
});
-
- it('calls store setApprovals', () => {
- expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals());
- });
});
describe('and error', () => {
@@ -329,7 +313,7 @@ describe('MRWidget approvals', () => {
return nextTick();
});
- it('flashes error message', () => {
+ it('alerts error message', () => {
expect(createAlert).toHaveBeenCalledWith({ message: UNAPPROVE_ERROR });
});
});
@@ -338,19 +322,24 @@ describe('MRWidget approvals', () => {
});
describe('approvals optional summary', () => {
+ let optionalApprovalsResponse;
+
+ beforeEach(() => {
+ optionalApprovalsResponse = JSON.parse(JSON.stringify(approvedByCurrentUser));
+ });
+
describe('when no approvals required and no approvers', () => {
beforeEach(() => {
- mr.approvals.approved_by = [];
- mr.approvals.approvals_required = 0;
- mr.approvals.user_has_approved = false;
+ optionalApprovalsResponse.data.project.mergeRequest.approvedBy.nodes = [];
+ optionalApprovalsResponse.data.project.mergeRequest.approvalsRequired = 0;
});
describe('and can approve', () => {
- beforeEach(() => {
- mr.approvals.user_can_approve = true;
+ beforeEach(async () => {
+ optionalApprovalsResponse.data.project.mergeRequest.userPermissions.canApprove = true;
- createComponent();
- return nextTick();
+ createComponent({}, { query: optionalApprovalsResponse });
+ await waitForPromises();
});
it('is shown', () => {
@@ -363,11 +352,9 @@ describe('MRWidget approvals', () => {
});
describe('and cannot approve', () => {
- beforeEach(() => {
- mr.approvals.user_can_approve = false;
-
- createComponent();
- return nextTick();
+ beforeEach(async () => {
+ createComponent({}, { query: optionalApprovalsResponse });
+ await nextTick();
});
it('is shown', () => {
@@ -382,9 +369,9 @@ describe('MRWidget approvals', () => {
});
describe('approvals summary', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent();
- return nextTick();
+ await nextTick();
});
it('is rendered with props', () => {
@@ -393,41 +380,47 @@ describe('MRWidget approvals', () => {
expect(findOptionalSummary().exists()).toBe(false);
expect(summary.exists()).toBe(true);
expect(summary.props()).toMatchObject({
- projectPath: 'gitlab-org/gitlab',
- iid: '1',
- updatedCount: 0,
+ approvalState: approvedByCurrentUser.data.project.mergeRequest,
});
});
});
- describe('invalid rules', () => {
- beforeEach(() => {
- mr.approvals.merge_request_approvers_available = true;
- createComponent();
- });
+ describe('realtime approvals update', () => {
+ describe('realtime_approvals feature disabled', () => {
+ beforeEach(() => {
+ jest.spyOn(console, 'warn').mockImplementation();
+ createComponent();
+ });
- it('does not render related components', () => {
- expect(findInvalidRules().exists()).toBe(false);
+ it('does not subscribe to the approvals update socket', () => {
+ expect(mr.setApprovals).not.toHaveBeenCalled();
+ mockedSubscription.next({});
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith(
+ expect.stringMatching('Mock subscription has no observer, this will have no effect'),
+ );
+ expect(mr.setApprovals).not.toHaveBeenCalled();
+ });
});
- describe('when invalid rules are present', () => {
+ describe('realtime_approvals feature enabled', () => {
+ const subscriptionApproval = { approved: true };
+ const subscriptionResponse = {
+ data: { mergeRequestApprovalStateUpdated: subscriptionApproval },
+ };
+
beforeEach(() => {
- mr.approvals.invalid_approvers_rules = [{ name: RULE_NAME }];
- createComponent();
+ createComponent({
+ provide: { glFeatures: { realtimeApprovals: true } },
+ });
});
- it('renders related components', () => {
- const invalidRules = findInvalidRules();
+ it('updates approvals when the subscription data is streamed to the Apollo client', () => {
+ expect(mr.setApprovals).not.toHaveBeenCalled();
- expect(invalidRules.exists()).toBe(true);
+ mockedSubscription.next(subscriptionResponse);
- const invalidRulesText = invalidRules.text();
-
- expect(invalidRulesText).toContain(RULE_NAME);
- expect(invalidRulesText).toContain(
- 'GitLab has approved this rule automatically to unblock the merge request.',
- );
- expect(invalidRulesText).toContain('Learn more.');
+ expect(mr.setApprovals).toHaveBeenCalledWith(subscriptionApproval);
});
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js
index e6fb0495947..bf3df70d423 100644
--- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js
@@ -13,11 +13,6 @@ describe('MRWidget approvals summary optional', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findHelpLink = () => wrapper.findComponent(GlLink);
describe('when can approve', () => {
diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js
index e75ce7c60c9..2c8d8b11b94 100644
--- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js
@@ -1,11 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mount } from '@vue/test-utils';
-import approvedByMultipleUsers from 'test_fixtures/graphql/merge_requests/approvals/approved_by.query.graphql_multiple_users.json';
-import noApprovalsResponse from 'test_fixtures/graphql/merge_requests/approvals/approved_by.query.graphql_no_approvals.json';
-import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approved_by.query.graphql.json';
+import approvedByMultipleUsers from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql_multiple_users.json';
+import noApprovalsResponse from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql_no_approvals.json';
+import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json';
import waitForPromises from 'helpers/wait_for_promises';
-import createMockApollo from 'helpers/mock_apollo_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue';
import {
@@ -14,32 +13,22 @@ import {
APPROVED_BY_YOU_AND_OTHERS,
} from '~/vue_merge_request_widget/components/approvals/messages';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
-import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql';
Vue.use(VueApollo);
describe('MRWidget approvals summary', () => {
- const originalUserId = gon.current_user_id;
let wrapper;
- const createComponent = (response = approvedByCurrentUser) => {
+ const createComponent = (data = approvedByCurrentUser) => {
wrapper = mount(ApprovalsSummary, {
propsData: {
- projectPath: 'gitlab-org/gitlab',
- iid: '1',
+ approvalState: data.data.project.mergeRequest,
},
- apolloProvider: createMockApollo([[approvedByQuery, jest.fn().mockResolvedValue(response)]]),
});
};
const findAvatars = () => wrapper.findComponent(UserAvatarList);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- gon.current_user_id = originalUserId;
- });
-
describe('when approved', () => {
beforeEach(async () => {
createComponent();
@@ -116,4 +105,31 @@ describe('MRWidget approvals summary', () => {
expect(wrapper.findComponent(UserAvatarList).exists()).toBe(false);
});
});
+
+ describe('user avatars list layout', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('does not add top padding initially', () => {
+ const avatarsList = findAvatars();
+
+ expect(avatarsList.classes()).not.toContain('gl-pt-1');
+ });
+
+ it('adds some top padding when the list is expanded', async () => {
+ const avatarsList = findAvatars();
+ await avatarsList.vm.$emit('expanded');
+
+ expect(avatarsList.classes()).toContain('gl-pt-1');
+ });
+
+ it('removes the top padding when the list collapsed', async () => {
+ const avatarsList = findAvatars();
+ await avatarsList.vm.$emit('expanded');
+ await avatarsList.vm.$emit('collapsed');
+
+ expect(avatarsList.classes()).not.toContain('gl-pt-1');
+ });
+ });
});
diff --git a/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js b/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js
index 52e2393bf05..9516aacea0a 100644
--- a/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js
@@ -26,7 +26,6 @@ describe('Merge Requests Artifacts list app', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
@@ -71,8 +70,8 @@ describe('Merge Requests Artifacts list app', () => {
it('renders disabled buttons', () => {
const buttons = findButtons();
- expect(buttons.at(0).attributes('disabled')).toBe('disabled');
- expect(buttons.at(1).attributes('disabled')).toBe('disabled');
+ expect(buttons.at(0).attributes('disabled')).toBeDefined();
+ expect(buttons.at(1).attributes('disabled')).toBeDefined();
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js b/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js
index b7bf72cd215..bb049a5d52f 100644
--- a/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js
@@ -18,10 +18,6 @@ describe('Artifacts List', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
beforeEach(() => {
mountComponent(data);
});
diff --git a/spec/frontend/vue_merge_request_widget/components/extensions/child_content_spec.js b/spec/frontend/vue_merge_request_widget/components/extensions/child_content_spec.js
index 198a4c2823a..3a621db7b44 100644
--- a/spec/frontend/vue_merge_request_widget/components/extensions/child_content_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/extensions/child_content_spec.js
@@ -20,11 +20,6 @@ function factory(propsData) {
}
describe('MR widget extension child content', () => {
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders child components', () => {
factory({
data: {
diff --git a/spec/frontend/vue_merge_request_widget/components/extensions/status_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/extensions/status_icon_spec.js
index f3aa5bb774f..ffa6b5538d3 100644
--- a/spec/frontend/vue_merge_request_widget/components/extensions/status_icon_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/extensions/status_icon_spec.js
@@ -11,10 +11,6 @@ function factory(propsData = {}) {
}
describe('MR widget extensions status icon', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders loading icon', () => {
factory({ name: 'test', isLoading: true, iconName: 'failed' });
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js
index 81f266d8070..6b22c2e26ac 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js
@@ -25,10 +25,6 @@ describe('Merge Request Collapsible Extension', () => {
const findErrorMessage = () => wrapper.find('.js-error-state');
const findIcon = () => wrapper.findComponent(GlIcon);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('while collapsed', () => {
beforeEach(() => {
mountComponent(data);
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_alert_message_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_alert_message_spec.js
index 5d923d0383f..01178dab9bb 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_alert_message_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_alert_message_spec.js
@@ -11,10 +11,6 @@ function createComponent(propsData = {}) {
}
describe('MrWidgetAlertMessage', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render a GlAert', () => {
createComponent({ type: 'danger' });
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_author_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_spec.js
index 8a42e2e2ce7..7eafccae083 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_author_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_spec.js
@@ -29,7 +29,6 @@ describe('MrWidgetAuthor', () => {
});
afterEach(() => {
- wrapper.destroy();
window.gl = oldWindowGl;
});
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js
index 90a29d15488..534b745aed2 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js
@@ -23,10 +23,6 @@ describe('MrWidgetAuthorTime', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders provided action text', () => {
expect(wrapper.text()).toContain('Merged by');
});
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_container_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_container_spec.js
index 8dadb0c65d0..25de76ba33c 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_container_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_container_spec.js
@@ -13,10 +13,6 @@ describe('MrWidgetContainer', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has layout', () => {
factory();
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js
index 6a9b019fb4f..090a96d576c 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js
@@ -15,10 +15,6 @@ describe('MrWidgetIcon', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders icon and container', () => {
expect(wrapper.element.className).toContain('circle-icon-container');
expect(wrapper.findComponent(GlIcon).props('name')).toEqual(TEST_ICON);
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js
index 4775a0673b5..33647671853 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js
@@ -1,10 +1,12 @@
import axios from 'axios';
+import { GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
-import Vue, { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import MemoryUsage from '~/vue_merge_request_widget/components/deployment/memory_usage.vue';
import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
+import MemoryGraph from '~/vue_shared/components/memory_graph.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
const url = '/root/acets-review-apps/environments/15/deployments/1/metrics';
const monitoringUrl = '/root/acets-review-apps/environments/15/metrics';
@@ -35,50 +37,49 @@ const metricsMockData = {
deployment_time: 1493718485,
};
-const createComponent = () => {
- const Component = Vue.extend(MemoryUsage);
-
- return new Component({
- el: document.createElement('div'),
- propsData: {
- metricsUrl: url,
- metricsMonitoringUrl: monitoringUrl,
- memoryMetrics: [],
- deploymentTime: 0,
- hasMetrics: false,
- loadFailed: false,
- loadingMetrics: true,
- backOffRequestCounter: 0,
- },
- });
-};
-
const messages = {
loadingMetrics: 'Loading deployment statistics',
- hasMetrics: 'Memory usage is unchanged at 0MB',
+ hasMetrics: 'Memory usage is unchanged at 0.00MB',
loadFailed: 'Failed to load deployment statistics',
metricsUnavailable: 'Deployment statistics are not available currently',
};
describe('MemoryUsage', () => {
- let vm;
- let el;
+ let wrapper;
let mock;
+ const createComponent = () => {
+ wrapper = shallowMountExtended(MemoryUsage, {
+ propsData: {
+ metricsUrl: url,
+ metricsMonitoringUrl: monitoringUrl,
+ memoryMetrics: [],
+ deploymentTime: 0,
+ hasMetrics: false,
+ loadFailed: false,
+ loadingMetrics: true,
+ backOffRequestCounter: 0,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findUsageInfo = () => wrapper.find('.js-usage-info');
+ const findUsageInfoFailed = () => wrapper.find('.usage-info-failed');
+ const findUsageInfoUnavailable = () => wrapper.find('.usage-info-unavailable');
+ const findMemoryGraph = () => wrapper.findComponent(MemoryGraph);
+
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(`${url}.json`).reply(HTTP_STATUS_OK);
-
- vm = createComponent();
- el = vm.$el;
- });
-
- afterEach(() => {
- mock.restore();
});
describe('data', () => {
it('should have default data', () => {
+ createComponent();
const data = MemoryUsage.data();
expect(Array.isArray(data.memoryMetrics)).toBe(true);
@@ -103,126 +104,182 @@ describe('MemoryUsage', () => {
describe('computed', () => {
describe('memoryChangeMessage', () => {
- it('should contain "increased" if memoryFrom value is less than memoryTo value', () => {
- vm.memoryFrom = 4.28;
- vm.memoryTo = 9.13;
+ it('should contain "increased" if memoryFrom value is less than memoryTo value', async () => {
+ jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({
+ data: {
+ ...metricsMockData,
+ metrics: {
+ ...metricsMockData.metrics,
+ memory_after: [
+ {
+ metric: {},
+ value: [1495787020.607, '54858853.130206379'],
+ },
+ ],
+ },
+ },
+ });
- expect(vm.memoryChangeMessage.indexOf('increased')).not.toEqual('-1');
+ createComponent();
+ await waitForPromises();
+
+ expect(findUsageInfo().text().indexOf('increased')).not.toEqual(-1);
});
- it('should contain "decreased" if memoryFrom value is less than memoryTo value', () => {
- vm.memoryFrom = 9.13;
- vm.memoryTo = 4.28;
+ it('should contain "decreased" if memoryFrom value is less than memoryTo value', async () => {
+ jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({
+ data: metricsMockData,
+ });
+
+ createComponent();
+ await waitForPromises();
- expect(vm.memoryChangeMessage.indexOf('decreased')).not.toEqual('-1');
+ expect(findUsageInfo().text().indexOf('decreased')).not.toEqual(-1);
});
- it('should contain "unchanged" if memoryFrom value equal to memoryTo value', () => {
- vm.memoryFrom = 1;
- vm.memoryTo = 1;
+ it('should contain "unchanged" if memoryFrom value equal to memoryTo value', async () => {
+ jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({
+ data: {
+ ...metricsMockData,
+ metrics: {
+ ...metricsMockData.metrics,
+ memory_after: [
+ {
+ metric: {},
+ value: [1495785220.607, '9572875.906976745'],
+ },
+ ],
+ },
+ },
+ });
+
+ createComponent();
+ await waitForPromises();
- expect(vm.memoryChangeMessage.indexOf('unchanged')).not.toEqual('-1');
+ expect(findUsageInfo().text().indexOf('unchanged')).not.toEqual(-1);
});
});
});
describe('methods', () => {
- const { metrics, deployment_time } = metricsMockData;
+ beforeEach(async () => {
+ jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({
+ data: metricsMockData,
+ });
+
+ createComponent();
+ await waitForPromises();
+ });
describe('getMegabytes', () => {
it('should return Megabytes from provided Bytes value', () => {
- const memoryInBytes = '9572875.906976745';
-
- expect(vm.getMegabytes(memoryInBytes)).toEqual('9.13');
+ expect(findUsageInfo().text()).toContain('9.13MB');
});
});
describe('computeGraphData', () => {
it('should populate sparkline graph', () => {
- // ignore BoostrapVue warnings
- jest.spyOn(console, 'warn').mockImplementation();
-
- vm.computeGraphData(metrics, deployment_time);
- const { hasMetrics, memoryMetrics, deploymentTime, memoryFrom, memoryTo } = vm;
-
- expect(hasMetrics).toBe(true);
- expect(memoryMetrics.length).toBeGreaterThan(0);
- expect(deploymentTime).toEqual(deployment_time);
- expect(memoryFrom).toEqual('9.13');
- expect(memoryTo).toEqual('4.28');
+ expect(findMemoryGraph().exists()).toBe(true);
+ expect(findMemoryGraph().props('metrics')).toHaveLength(1);
+ expect(findUsageInfo().text()).toContain('9.13MB');
+ expect(findUsageInfo().text()).toContain('4.28MB');
});
});
describe('loadMetrics', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
it('should load metrics data using MRWidgetService', async () => {
jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({
data: metricsMockData,
});
- jest.spyOn(vm, 'computeGraphData').mockImplementation(() => {});
-
- vm.loadMetrics();
await waitForPromises();
expect(MRWidgetService.fetchMetrics).toHaveBeenCalledWith(url);
- expect(vm.computeGraphData).toHaveBeenCalledWith(metrics, deployment_time);
});
});
});
describe('template', () => {
- it('should render template elements correctly', () => {
- expect(el.classList.contains('mr-memory-usage')).toBe(true);
- expect(el.querySelector('.js-usage-info')).toBeDefined();
- });
+ it('should render template elements correctly', async () => {
+ jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({
+ data: metricsMockData,
+ });
- it('should show loading metrics message while metrics are being loaded', async () => {
- vm.loadingMetrics = true;
- vm.hasMetrics = false;
- vm.loadFailed = false;
+ createComponent();
+ await waitForPromises();
- await nextTick();
+ expect(wrapper.classes()).toContain('mr-memory-usage');
+ expect(findUsageInfo().exists()).toBe(true);
+ });
+
+ it('should show loading metrics message while metrics are being loaded', () => {
+ createComponent();
- expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined();
- expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined();
- expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics);
+ expect(findGlLoadingIcon().exists()).toBe(true);
+ expect(findUsageInfo().exists()).toBe(true);
+ expect(findUsageInfo().text()).toBe(messages.loadingMetrics);
});
it('should show deployment memory usage when metrics are loaded', async () => {
- // ignore BoostrapVue warnings
- jest.spyOn(console, 'warn').mockImplementation();
-
- vm.loadingMetrics = false;
- vm.hasMetrics = true;
- vm.loadFailed = false;
- vm.memoryMetrics = metricsMockData.metrics.memory_values[0].values;
+ jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({
+ data: {
+ ...metricsMockData,
+ metrics: {
+ ...metricsMockData.metrics,
+ memory_after: [
+ {
+ metric: {},
+ value: [0, '0'],
+ },
+ ],
+ memory_before: [
+ {
+ metric: {},
+ value: [0, '0'],
+ },
+ ],
+ },
+ },
+ });
- await nextTick();
+ createComponent();
+ await waitForPromises();
- expect(el.querySelector('.memory-graph-container')).toBeDefined();
- expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics);
+ expect(findMemoryGraph().exists()).toBe(true);
+ expect(findUsageInfo().text()).toBe(messages.hasMetrics);
});
it('should show failure message when metrics loading failed', async () => {
- vm.loadingMetrics = false;
- vm.hasMetrics = false;
- vm.loadFailed = true;
+ jest.spyOn(MRWidgetService, 'fetchMetrics').mockRejectedValue({});
- await nextTick();
+ createComponent();
+ await waitForPromises();
- expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined();
- expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed);
+ expect(findUsageInfoFailed().exists()).toBe(true);
+ expect(findUsageInfo().text()).toBe(messages.loadFailed);
});
it('should show metrics unavailable message when metrics loading failed', async () => {
- vm.loadingMetrics = false;
- vm.hasMetrics = false;
- vm.loadFailed = false;
+ jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({
+ data: {
+ ...metricsMockData,
+ metrics: {
+ ...metricsMockData.metrics,
+ memory_values: [],
+ },
+ },
+ });
- await nextTick();
+ createComponent();
+ await waitForPromises();
- expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined();
- expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable);
+ expect(findUsageInfoUnavailable().exists()).toBe(true);
+ expect(findUsageInfo().text()).toBe(messages.metricsUnavailable);
});
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js
index 13beb43e10b..18842e996de 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js
@@ -29,10 +29,6 @@ describe('MrWidgetPipelineContainer', () => {
mock.onGet().reply(HTTP_STATUS_OK, {});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDeploymentList = () => wrapper.findComponent(DeploymentList);
const findCIErrorMessage = () => wrapper.findByTestId('ci-error-message');
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js
index 6a899c00b98..820e486c13f 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js
@@ -53,13 +53,6 @@ describe('MRWidgetPipeline', () => {
);
};
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
it('should render CI error if there is a pipeline, but no status', () => {
createWrapper({ ciStatus: null }, mount);
expect(findCIErrorMessage().text()).toBe(ciErrorMessage);
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js
index ec047fe0714..9bd46267daa 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js
@@ -1,54 +1,101 @@
-import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlModal } from '@gitlab/ui';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue';
+import rebaseQuery from '~/vue_merge_request_widget/queries/states/rebase.query.graphql';
import eventHub from '~/vue_merge_request_widget/event_hub';
+import StateContainer from '~/vue_merge_request_widget/components/state_container.vue';
import toast from '~/vue_shared/plugins/global_toast';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { stubComponent } from 'helpers/stub_component';
jest.mock('~/vue_shared/plugins/global_toast');
let wrapper;
-
-function createWrapper(propsData) {
- wrapper = mount(WidgetRebase, {
- propsData,
- data() {
- return {
- state: {
- rebaseInProgress: propsData.mr.rebaseInProgress,
- targetBranch: propsData.mr.targetBranch,
+const showMock = jest.fn();
+
+const mockPipelineNodes = [
+ {
+ id: '1',
+ project: {
+ id: '2',
+ fullPath: 'user/forked',
+ },
+ },
+];
+
+const mockQueryHandler = ({
+ rebaseInProgress = false,
+ targetBranch = '',
+ pushToSourceBranch = false,
+ nodes = mockPipelineNodes,
+} = {}) =>
+ jest.fn().mockResolvedValue({
+ data: {
+ project: {
+ id: '1',
+ mergeRequest: {
+ id: '2',
+ rebaseInProgress,
+ targetBranch,
userPermissions: {
- pushToSourceBranch: propsData.mr.canPushToSourceBranch,
+ pushToSourceBranch,
+ },
+ pipelines: {
+ nodes,
},
},
- };
+ },
+ },
+ });
+
+const createMockApolloProvider = (handler) => {
+ Vue.use(VueApollo);
+
+ return createMockApollo([[rebaseQuery, handler]]);
+};
+
+function createWrapper({ propsData = {}, provideData = {}, handler = mockQueryHandler() } = {}) {
+ wrapper = shallowMountExtended(WidgetRebase, {
+ apolloProvider: createMockApolloProvider(handler),
+ provide: {
+ ...provideData,
+ },
+ propsData: {
+ mr: {},
+ service: {},
+ ...propsData,
},
- mocks: {
- $apollo: {
- queries: {
- state: { loading: false },
+ stubs: {
+ StateContainer,
+ GlModal: stubComponent(GlModal, {
+ methods: {
+ show: showMock,
},
- },
+ }),
},
});
}
describe('Merge request widget rebase component', () => {
- const findRebaseMessage = () => wrapper.find('[data-testid="rebase-message"]');
+ const findRebaseMessage = () => wrapper.findByTestId('rebase-message');
+ const findBoldText = () => wrapper.findComponent(BoldText);
const findRebaseMessageText = () => findRebaseMessage().text();
- const findStandardRebaseButton = () => wrapper.find('[data-testid="standard-rebase-button"]');
- const findRebaseWithoutCiButton = () => wrapper.find('[data-testid="rebase-without-ci-button"]');
+ const findStandardRebaseButton = () => wrapper.findByTestId('standard-rebase-button');
+ const findRebaseWithoutCiButton = () => wrapper.findByTestId('rebase-without-ci-button');
+ const findModal = () => wrapper.findComponent(GlModal);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
describe('while rebasing', () => {
- it('should show progress message', () => {
+ it('should show progress message', async () => {
createWrapper({
- mr: { rebaseInProgress: true },
- service: {},
+ handler: mockQueryHandler({ rebaseInProgress: true }),
});
+ await waitForPromises();
+
expect(findRebaseMessageText()).toContain('Rebase in progress');
});
});
@@ -57,95 +104,110 @@ describe('Merge request widget rebase component', () => {
const rebaseMock = jest.fn().mockResolvedValue();
const pollMock = jest.fn().mockResolvedValue({});
- it('renders the warning message', () => {
+ it('renders the warning message', async () => {
createWrapper({
- mr: {
+ handler: mockQueryHandler({
rebaseInProgress: false,
- canPushToSourceBranch: true,
- },
- service: {
- rebase: rebaseMock,
- poll: pollMock,
- },
+ pushToSourceBranch: false,
+ }),
});
- const text = findRebaseMessageText();
+ await waitForPromises();
- expect(text).toContain('Merge blocked');
- expect(text.replace(/\s\s+/g, ' ')).toContain(
+ expect(findBoldText().props('message')).toContain('Merge blocked');
+ expect(findBoldText().props('message').replace(/\s\s+/g, ' ')).toContain(
'the source branch must be rebased onto the target branch',
);
});
it('renders an error message when rebasing has failed', async () => {
createWrapper({
- mr: {
- rebaseInProgress: false,
- canPushToSourceBranch: true,
- },
- service: {
- rebase: rebaseMock,
- poll: pollMock,
+ propsData: {
+ service: {
+ rebase: jest.fn().mockRejectedValue({
+ response: {
+ data: {
+ merge_error: 'Something went wrong!',
+ },
+ },
+ }),
+ },
},
+ handler: mockQueryHandler({ pushToSourceBranch: true }),
});
+ await waitForPromises();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ rebasingError: 'Something went wrong!' });
+ findStandardRebaseButton().vm.$emit('click');
- await nextTick();
+ await waitForPromises();
expect(findRebaseMessageText()).toContain('Something went wrong!');
});
describe('Rebase buttons', () => {
- beforeEach(() => {
+ it('renders both buttons', async () => {
createWrapper({
- mr: {
- rebaseInProgress: false,
- canPushToSourceBranch: true,
- },
- service: {
- rebase: rebaseMock,
- poll: pollMock,
- },
+ handler: mockQueryHandler({ pushToSourceBranch: true }),
});
- });
- it('renders both buttons', () => {
+ await waitForPromises();
+
expect(findRebaseWithoutCiButton().exists()).toBe(true);
expect(findStandardRebaseButton().exists()).toBe(true);
});
it('starts the rebase when clicking', async () => {
- findStandardRebaseButton().vm.$emit('click');
+ createWrapper({
+ propsData: {
+ service: {
+ rebase: rebaseMock,
+ poll: pollMock,
+ },
+ },
+ handler: mockQueryHandler({ pushToSourceBranch: true }),
+ });
- await nextTick();
+ await waitForPromises();
+
+ findStandardRebaseButton().vm.$emit('click');
expect(rebaseMock).toHaveBeenCalledWith({ skipCi: false });
});
it('starts the CI-skipping rebase when clicking on "Rebase without CI"', async () => {
- findRebaseWithoutCiButton().vm.$emit('click');
+ createWrapper({
+ propsData: {
+ service: {
+ rebase: rebaseMock,
+ poll: pollMock,
+ },
+ },
+ handler: mockQueryHandler({ pushToSourceBranch: true }),
+ });
- await nextTick();
+ await waitForPromises();
+
+ findRebaseWithoutCiButton().vm.$emit('click');
expect(rebaseMock).toHaveBeenCalledWith({ skipCi: true });
});
});
describe('Rebase when pipelines must succeed is enabled', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createWrapper({
- mr: {
- rebaseInProgress: false,
- canPushToSourceBranch: true,
- onlyAllowMergeIfPipelineSucceeds: true,
- },
- service: {
- rebase: rebaseMock,
- poll: pollMock,
+ propsData: {
+ mr: {
+ onlyAllowMergeIfPipelineSucceeds: true,
+ },
+ service: {
+ rebase: rebaseMock,
+ poll: pollMock,
+ },
},
+ handler: mockQueryHandler({ pushToSourceBranch: true }),
});
+
+ await waitForPromises();
});
it('renders only the rebase button', () => {
@@ -163,19 +225,22 @@ describe('Merge request widget rebase component', () => {
});
describe('Rebase when pipelines must succeed and skipped pipelines are considered successful are enabled', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createWrapper({
- mr: {
- rebaseInProgress: false,
- canPushToSourceBranch: true,
- onlyAllowMergeIfPipelineSucceeds: true,
- allowMergeOnSkippedPipeline: true,
- },
- service: {
- rebase: rebaseMock,
- poll: pollMock,
+ propsData: {
+ mr: {
+ onlyAllowMergeIfPipelineSucceeds: true,
+ allowMergeOnSkippedPipeline: true,
+ },
+ service: {
+ rebase: rebaseMock,
+ poll: pollMock,
+ },
},
+ handler: mockQueryHandler({ pushToSourceBranch: true }),
});
+
+ await waitForPromises();
});
it('renders both rebase buttons', () => {
@@ -199,48 +264,99 @@ describe('Merge request widget rebase component', () => {
expect(rebaseMock).toHaveBeenCalledWith({ skipCi: true });
});
});
+
+ describe('security modal', () => {
+ it('displays modal and rebases after confirming', async () => {
+ createWrapper({
+ propsData: {
+ mr: {
+ sourceProjectFullPath: 'user/forked',
+ targetProjectFullPath: 'root/original',
+ },
+ service: {
+ rebase: rebaseMock,
+ poll: pollMock,
+ },
+ },
+ provideData: { canCreatePipelineInTargetProject: true },
+ handler: mockQueryHandler({ pushToSourceBranch: true }),
+ });
+
+ await waitForPromises();
+
+ findStandardRebaseButton().vm.$emit('click');
+ expect(showMock).toHaveBeenCalled();
+
+ findModal().vm.$emit('primary');
+
+ expect(rebaseMock).toHaveBeenCalled();
+ });
+
+ it('does not display modal', async () => {
+ createWrapper({
+ propsData: {
+ mr: {
+ sourceProjectFullPath: 'user/forked',
+ targetProjectFullPath: 'root/original',
+ },
+ service: {
+ rebase: rebaseMock,
+ poll: pollMock,
+ },
+ },
+ provideData: { canCreatePipelineInTargetProject: false },
+ handler: mockQueryHandler({ pushToSourceBranch: true }),
+ });
+
+ await waitForPromises();
+
+ findStandardRebaseButton().vm.$emit('click');
+
+ expect(showMock).not.toHaveBeenCalled();
+ expect(rebaseMock).toHaveBeenCalled();
+ });
+ });
});
describe('without permissions', () => {
const exampleTargetBranch = 'fake-branch-to-test-with';
describe('UI text', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createWrapper({
- mr: {
- rebaseInProgress: false,
- canPushToSourceBranch: false,
+ handler: mockQueryHandler({
+ pushToSourceBranch: false,
targetBranch: exampleTargetBranch,
- },
- service: {},
+ }),
});
+
+ await waitForPromises();
});
it('renders a message explaining user does not have permissions', () => {
- const text = findRebaseMessageText();
-
- expect(text).toContain('Merge blocked:');
- expect(text).toContain('the source branch must be rebased');
+ expect(findBoldText().props('message')).toContain('Merge blocked');
+ expect(findBoldText().props('message')).toContain('the source branch must be rebased');
});
it('renders the correct target branch name', () => {
- const text = findRebaseMessageText();
-
- expect(text).toContain('Merge blocked:');
- expect(text).toContain('the source branch must be rebased onto the target branch.');
+ expect(findBoldText().props('message')).toContain('Merge blocked:');
+ expect(findBoldText().props('message')).toContain(
+ 'the source branch must be rebased onto the target branch.',
+ );
});
});
- it('does render the "Rebase without pipeline" button', () => {
+ it('does render the "Rebase without pipeline" button', async () => {
createWrapper({
- mr: {
+ handler: mockQueryHandler({
rebaseInProgress: false,
- canPushToSourceBranch: false,
+ pushToSourceBranch: false,
targetBranch: exampleTargetBranch,
- },
- service: {},
+ }),
});
+ await waitForPromises();
+
expect(findRebaseWithoutCiButton().exists()).toBe(true);
});
});
@@ -249,24 +365,27 @@ describe('Merge request widget rebase component', () => {
it('checkRebaseStatus', async () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
createWrapper({
- mr: {},
- service: {
- rebase() {
- return Promise.resolve();
- },
- poll() {
- return Promise.resolve({
- data: {
- rebase_in_progress: false,
- should_be_rebased: false,
- merge_error: null,
- },
- });
+ propsData: {
+ service: {
+ rebase() {
+ return Promise.resolve();
+ },
+ poll() {
+ return Promise.resolve({
+ data: {
+ rebase_in_progress: false,
+ should_be_rebased: false,
+ merge_error: null,
+ },
+ });
+ },
},
},
});
- wrapper.vm.rebase();
+ await waitForPromises();
+
+ findRebaseWithoutCiButton().vm.$emit('click');
// Wait for the rebase request
await nextTick();
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_related_links_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_related_links_spec.js
index 15522f7ac1d..42a16090510 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_related_links_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_related_links_spec.js
@@ -9,10 +9,6 @@ describe('MRWidgetRelatedLinks', () => {
wrapper = shallowMount(RelatedLinks, { propsData });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('computed', () => {
describe('closesText', () => {
it('returns Closes text for open merge request', () => {
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js
index 530549b7b9c..b210327aa31 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js
@@ -17,11 +17,6 @@ describe('MR widget status icon component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('while loading', () => {
it('renders loading icon', () => {
createWrapper({ status: 'loading' });
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js
index 73358edee78..70c76687a79 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js
@@ -18,10 +18,6 @@ describe('MRWidgetSuggestPipeline', () => {
describe('template', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('core functionality', () => {
const findOkBtn = () => wrapper.find('[data-testid="ok"]');
let trackingSpy;
diff --git a/spec/frontend/vue_merge_request_widget/components/review_app_link_spec.js b/spec/frontend/vue_merge_request_widget/components/review_app_link_spec.js
index e393b56034d..aaaa19b16dc 100644
--- a/spec/frontend/vue_merge_request_widget/components/review_app_link_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/review_app_link_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue';
@@ -17,10 +18,6 @@ describe('review app link', () => {
wrapper = shallowMount(ReviewAppLink, { propsData: props });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders provided link as href attribute', () => {
expect(wrapper.attributes('href')).toBe(props.link);
});
@@ -37,6 +34,10 @@ describe('review app link', () => {
expect(wrapper.find('svg')).not.toBeNull();
});
+ it('renders unsafe links', () => {
+ expect(wrapper.findComponent(GlButton).props('isUnsafeLink')).toBe(true);
+ });
+
it('tracks an event when clicked', () => {
const spy = mockTracking('_category_', wrapper.element, jest.spyOn);
triggerEvent(wrapper.element);
diff --git a/spec/frontend/vue_merge_request_widget/components/states/commit_edit_spec.js b/spec/frontend/vue_merge_request_widget/components/states/commit_edit_spec.js
index c0add94e6ed..f520c6a4f78 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/commit_edit_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/commit_edit_spec.js
@@ -26,10 +26,6 @@ describe('Commits edit component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findTextarea = () => wrapper.find('.form-control');
it('has a correct label', () => {
diff --git a/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js
index e4448346685..c2ab0e384e8 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js
@@ -12,10 +12,6 @@ function factory(propsData = {}) {
}
describe('Merge request widget merge checks failed state component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
mrState | displayText
${{ approvals: true, isApproved: false }} | ${'approvalNeeded'}
diff --git a/spec/frontend/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog_spec.js b/spec/frontend/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog_spec.js
index c9aca01083d..d321ff6e668 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog_spec.js
@@ -3,6 +3,8 @@ import MergeFailedPipelineConfirmationDialog from '~/vue_merge_request_widget/co
import { trimText } from 'helpers/text_helper';
describe('MergeFailedPipelineConfirmationDialog', () => {
+ const mockModalHide = jest.fn();
+
let wrapper;
const GlModal = {
@@ -13,7 +15,7 @@ describe('MergeFailedPipelineConfirmationDialog', () => {
</div>
`,
methods: {
- hide: jest.fn(),
+ hide: mockModalHide,
},
};
@@ -38,7 +40,7 @@ describe('MergeFailedPipelineConfirmationDialog', () => {
});
afterEach(() => {
- wrapper.destroy();
+ mockModalHide.mockReset();
});
it('should render informational text explaining why merging immediately can be dangerous', () => {
@@ -54,12 +56,10 @@ describe('MergeFailedPipelineConfirmationDialog', () => {
});
it('when the cancel button is clicked should emit cancel and call hide', () => {
- jest.spyOn(findModal().vm, 'hide');
-
findCancelBtn().vm.$emit('click');
expect(wrapper.emitted('cancel')).toHaveLength(1);
- expect(findModal().vm.hide).toHaveBeenCalled();
+ expect(mockModalHide).toHaveBeenCalled();
});
it('should emit cancel when the hide event is emitted', () => {
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_archived_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_archived_spec.js
index 08700e834d7..3e18ee75125 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_archived_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_archived_spec.js
@@ -10,11 +10,6 @@ describe('MRWidgetArchived', () => {
wrapper = shallowMount(archivedComponent, { propsData: { mr: {} } });
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders error icon', () => {
expect(wrapper.findComponent(StateContainer).exists()).toBe(true);
expect(wrapper.findComponent(StateContainer).props().status).toBe('failed');
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js
index fef5fee5f19..65d170cae8b 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js
@@ -83,8 +83,6 @@ describe('MRWidgetAutoMergeEnabled', () => {
afterEach(() => {
window.gl = oldWindowGl;
- wrapper.destroy();
- wrapper = null;
});
describe('computed', () => {
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js
index 826f708069c..e65deb2db3d 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js
@@ -18,10 +18,6 @@ describe('MRWidgetAutoMergeFailed', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
beforeEach(() => {
createComponent({
mr: { mergeError },
@@ -48,7 +44,7 @@ describe('MRWidgetAutoMergeFailed', () => {
await nextTick();
- expect(findButton().attributes('disabled')).toBe('disabled');
+ expect(findButton().attributes('disabled')).toBeDefined();
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_checking_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_checking_spec.js
index ac18ccf9e26..6c3b7f76fe6 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_checking_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_checking_spec.js
@@ -9,11 +9,6 @@ describe('MRWidgetChecking', () => {
wrapper = shallowMount(CheckingComponent, { propsData: { mr: {} } });
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders loading icon', () => {
expect(wrapper.findComponent(StateContainer).exists()).toBe(true);
expect(wrapper.findComponent(StateContainer).props().status).toBe('loading');
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js
index 270a37f87e7..04cc396af40 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js
@@ -67,12 +67,6 @@ describe('MRWidgetClosed', () => {
wrapper = createComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
it('renders closed icon', () => {
expect(wrapper.findComponent(StateContainer).exists()).toBe(true);
expect(wrapper.findComponent(StateContainer).props().status).toBe('closed');
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js
index 5d2d1fdd6f1..e4febda1daa 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js
@@ -36,10 +36,6 @@ describe('Commits message dropdown component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDropdownElements = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstDropdownElement = () => findDropdownElements().at(0);
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commits_header_spec.js
index a6d3a6286a7..b3843b066df 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commits_header_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commits_header_spec.js
@@ -21,10 +21,6 @@ describe('Commits header component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findHeaderWrapper = () => wrapper.find('.js-mr-widget-commits-count');
const findCommitToggle = () => wrapper.find('.commit-edit-toggle');
const findTargetBranchMessage = () => wrapper.find('.label-branch');
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js
index 2ca9dc61745..7f0a171d712 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js
@@ -50,10 +50,6 @@ describe('MRWidgetConflicts', () => {
await nextTick();
}
- afterEach(() => {
- wrapper.destroy();
- });
-
// There are two permissions we need to consider:
//
// 1. Is the user allowed to merge to the target branch?
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js
index 833fa27d453..38e5422325a 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js
@@ -28,10 +28,6 @@ describe('MRWidgetFailedToMerge', () => {
jest.spyOn(window, 'clearInterval').mockImplementation();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('interval', () => {
it('sets interval to refresh', () => {
createComponent();
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js
index a3aa563b516..e44e2834a0e 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js
@@ -62,10 +62,6 @@ describe('MRWidgetMerged', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findButtonByText = (text) =>
wrapper.findAll('button').wrappers.find((w) => w.text() === text);
const findRemoveSourceBranchButton = () => findButtonByText('Delete source branch');
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js
index 5408f731b34..85acd5f9a9e 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js
@@ -10,6 +10,8 @@ jest.mock('~/lib/utils/simple_poll', () =>
describe('MRWidgetMerging', () => {
let wrapper;
+ const pollMock = jest.fn().mockResolvedValue();
+
const GlEmoji = { template: '<img />' };
beforeEach(() => {
wrapper = shallowMount(MrWidgetMerging, {
@@ -20,7 +22,7 @@ describe('MRWidgetMerging', () => {
transitionStateMachine() {},
},
service: {
- poll: jest.fn().mockResolvedValue(),
+ poll: pollMock,
},
},
stubs: {
@@ -29,10 +31,6 @@ describe('MRWidgetMerging', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders information about merge request being merged', () => {
const message = wrapper.findComponent(BoldText).props('message');
expect(message).toContain('Merging!');
@@ -40,17 +38,11 @@ describe('MRWidgetMerging', () => {
describe('initiateMergePolling', () => {
it('should call simplePoll', () => {
- wrapper.vm.initiateMergePolling();
-
expect(simplePoll).toHaveBeenCalledWith(expect.any(Function), { timeout: 0 });
});
it('should call handleMergePolling', () => {
- jest.spyOn(wrapper.vm, 'handleMergePolling').mockImplementation(() => {});
-
- wrapper.vm.initiateMergePolling();
-
- expect(wrapper.vm.handleMergePolling).toHaveBeenCalled();
+ expect(pollMock).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_missing_branch_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_missing_branch_spec.js
index f29cf55f7ce..fca25b8bb94 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_missing_branch_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_missing_branch_spec.js
@@ -15,10 +15,6 @@ function factory(sourceBranchRemoved) {
}
describe('MRWidgetMissingBranch', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
sourceBranchRemoved | branchName
${true} | ${'source'}
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_not_allowed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_not_allowed_spec.js
index 42515c597c5..40b053282de 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_not_allowed_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_not_allowed_spec.js
@@ -10,11 +10,6 @@ describe('MRWidgetNotAllowed', () => {
wrapper = shallowMount(notAllowedComponent);
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders success icon', () => {
expect(wrapper.findComponent(StatusIcon).exists()).toBe(true);
expect(wrapper.findComponent(StatusIcon).props().status).toBe('success');
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js
index 6de0c06c33d..c8fa1399dcb 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js
@@ -1,28 +1,58 @@
-import Vue, { nextTick } from 'vue';
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import NothingToMerge from '~/vue_merge_request_widget/components/states/nothing_to_merge.vue';
describe('NothingToMerge', () => {
- describe('template', () => {
- const Component = Vue.extend(NothingToMerge);
- const newBlobPath = '/foo';
- const vm = new Component({
- el: document.createElement('div'),
+ let wrapper;
+ const newBlobPath = '/foo';
+
+ const defaultProps = {
+ mr: {
+ newBlobPath,
+ },
+ };
+
+ const createComponent = (props = defaultProps) => {
+ wrapper = shallowMountExtended(NothingToMerge, {
propsData: {
- mr: { newBlobPath },
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
},
});
+ };
+
+ const findCreateButton = () => wrapper.findByTestId('createFileButton');
+ const findNothingToMergeTextBody = () => wrapper.findByTestId('nothing-to-merge-body');
+
+ describe('With Blob link', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows the component with the correct text and highlights', () => {
+ expect(wrapper.text()).toContain('This merge request contains no changes.');
+ expect(findNothingToMergeTextBody().text()).toContain(
+ 'Use merge requests to propose changes to your project and discuss them with your team. To make changes, push a commit or edit this merge request to use a different branch.',
+ );
+ });
+
+ it('shows the Create file button with the correct attributes', () => {
+ const createButton = findCreateButton();
+
+ expect(createButton.exists()).toBe(true);
+ expect(createButton.attributes('href')).toBe(newBlobPath);
+ });
+ });
- it('should have correct elements', () => {
- expect(vm.$el.classList.contains('mr-widget-body')).toBe(true);
- expect(vm.$el.querySelector('[data-testid="createFileButton"]').href).toContain(newBlobPath);
- expect(vm.$el.innerText).toContain('Use merge requests to propose changes to your project');
+ describe('Without Blob link', () => {
+ beforeEach(() => {
+ createComponent({ mr: { newBlobPath: '' } });
});
- it('should not show new blob link if there is no link available', () => {
- vm.mr.newBlobPath = null;
- nextTick(() => {
- expect(vm.$el.querySelector('[data-testid="createFileButton"]')).toEqual(null);
- });
+ it('does not show the Create file button', () => {
+ expect(findCreateButton().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked_spec.js
index c0197b5e20a..d99106df0a2 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked_spec.js
@@ -10,11 +10,6 @@ describe('MRWidgetPipelineBlocked', () => {
wrapper = shallowMount(PipelineBlockedComponent);
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders error icon', () => {
expect(wrapper.findComponent(StatusIcon).exists()).toBe(true);
expect(wrapper.findComponent(StatusIcon).props().status).toBe('failed');
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js
index 8bae2b62ed1..ea93463f3ab 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js
@@ -19,11 +19,6 @@ describe('PipelineFailed', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should render error status icon', () => {
createComponent();
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
index 1e4e089e7c1..07fc0be9e51 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -113,6 +113,11 @@ const createComponent = (customConfig = {}, createState = true) => {
GlSprintf,
},
apolloProvider: createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]),
+ provide: {
+ glFeatures: {
+ autoMergeLabelsMrWidget: false,
+ },
+ },
});
};
@@ -596,7 +601,7 @@ describe('ReadyToMerge', () => {
describe('commits edit components', () => {
describe('when fast-forward merge is enabled', () => {
- it('should not be rendered if squash is disabled', async () => {
+ it('should not be rendered if squash is disabled', () => {
createComponent({
mr: {
ffOnlyEnabled: true,
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_sha_mismatch_spec.js
index aaa4591d67d..02b71ebf183 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_sha_mismatch_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_sha_mismatch_spec.js
@@ -20,10 +20,6 @@ describe('ShaMismatch', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render warning message', () => {
expect(wrapper.text()).toContain('Merge blocked: new changes were just added.');
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js
index c839fa17fe5..97f8e695df9 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js
@@ -14,10 +14,6 @@ describe('Squash before merge component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
describe('checkbox', () => {
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js
index c97b42f61ac..19825318a4f 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js
@@ -4,12 +4,19 @@ import { removeBreakLine } from 'helpers/text_helper';
import notesEventHub from '~/notes/event_hub';
import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue';
-function createComponent({ path = '' } = {}) {
+function createComponent({ path = '', propsData = {}, provide = {} } = {}) {
return mount(UnresolvedDiscussions, {
propsData: {
mr: {
createIssueToResolveDiscussionsPath: path,
},
+ ...propsData,
+ },
+ provide: {
+ glFeatures: {
+ hideCreateIssueResolveAll: false,
+ },
+ ...provide,
},
});
}
@@ -21,11 +28,7 @@ describe('UnresolvedDiscussions', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('triggers the correct notes event when the jump to first unresolved discussion button is clicked', () => {
+ it('triggers the correct notes event when the go to first unresolved discussion button is clicked', () => {
jest.spyOn(notesEventHub, '$emit');
wrapper.find('[data-testid="jump-to-first"]').trigger('click');
@@ -38,17 +41,13 @@ describe('UnresolvedDiscussions', () => {
wrapper = createComponent({ path: TEST_HOST });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should have correct elements', () => {
const text = removeBreakLine(wrapper.text()).trim();
expect(text).toContain('Merge blocked:');
expect(text).toContain('all threads must be resolved.');
- expect(wrapper.element.innerText).toContain('Jump to first unresolved thread');
- expect(wrapper.element.innerText).toContain('Create issue to resolve all threads');
+ expect(wrapper.element.innerText).toContain('Resolve all with new issue');
+ expect(wrapper.element.innerText).toContain('Go to first unresolved thread');
expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual(
TEST_HOST,
);
@@ -61,9 +60,26 @@ describe('UnresolvedDiscussions', () => {
expect(text).toContain('Merge blocked:');
expect(text).toContain('all threads must be resolved.');
- expect(wrapper.element.innerText).toContain('Jump to first unresolved thread');
- expect(wrapper.element.innerText).not.toContain('Create issue to resolve all threads');
+ expect(wrapper.element.innerText).not.toContain('Resolve all with new issue');
+ expect(wrapper.element.innerText).toContain('Go to first unresolved thread');
expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null);
});
});
+
+ describe('when `hideCreateIssueResolveAll` is enabled', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ path: TEST_HOST,
+ provide: {
+ glFeatures: {
+ hideCreateIssueResolveAll: true,
+ },
+ },
+ });
+ });
+
+ it('do not show jump to first button', () => {
+ expect(wrapper.text()).not.toContain('Create issue to resolve all threads');
+ });
+ });
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/new_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/new_ready_to_merge_spec.js
index 5ec9654a4af..20d06a7aaee 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/new_ready_to_merge_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/new_ready_to_merge_spec.js
@@ -15,10 +15,6 @@ function factory({ canMerge }) {
}
describe('New ready to merge state component', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
canMerge
${true}
diff --git a/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js b/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js
index e610ceb2122..f46829539a8 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_state.query.graphql.json';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import WorkInProgress, {
MSG_SOMETHING_WENT_WRONG,
MSG_MARK_READY,
@@ -22,8 +22,8 @@ const TEST_MR_IID = '23';
const TEST_MR_TITLE = 'Test MR Title';
const TEST_PROJECT_PATH = 'lorem/ipsum';
-jest.mock('~/flash');
-jest.mock('~/merge_request');
+jest.mock('~/alert');
+jest.mock('~/merge_request', () => ({ toggleDraftStatus: jest.fn() }));
describe('~/vue_merge_request_widget/components/states/work_in_progress.vue', () => {
let wrapper;
diff --git a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap
index e9a34453930..296d7924243 100644
--- a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap
+++ b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap
@@ -1,35 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue renders given data 1`] = `
-"<content-row-stub level=\\"2\\" statusiconname=\\"success\\" widgetname=\\"MyWidget\\" header=\\"This is a header,This is a subheader\\" helppopover=\\"[object Object]\\" actionbuttons=\\"\\">
- <div class=\\"gl-display-flex gl-flex-direction-column\\">
- <div>
- <p class=\\"gl-mb-0\\">Main text for the row</p>
- <gl-link-stub href=\\"https://gitlab.com\\">Optional link to display after text</gl-link-stub>
- <!---->
- <gl-badge-stub size=\\"md\\" variant=\\"info\\" iconsize=\\"md\\">
- Badge is optional. Text to be displayed inside badge
- </gl-badge-stub>
- <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub>
- <p class=\\"gl-m-0 gl-font-sm\\">Optional: Smaller sub-text to be displayed below the main text</p>
+"<div class=\\"gl-display-flex gl-border-t gl-py-3 gl-pl-7 gl-align-items-baseline\\">
+ <!---->
+ <div class=\\"gl-w-full gl-min-w-0\\">
+ <div class=\\"gl-display-flex\\">
+ <div class=\\"gl-mb-2\\"><strong class=\\"gl-display-block\\">This is a header</strong><span class=\\"gl-display-block\\">This is a subheader</span></div>
+ <div class=\\"gl-ml-auto gl-display-flex gl-align-items-baseline\\">
+ <help-popover-stub options=\\"[object Object]\\" icon=\\"information-o\\" class=\\"\\">
+ <p class=\\"gl-mb-0\\">Widget help popover content</p>
+ <!---->
+ </help-popover-stub>
+ <!---->
+ </div>
</div>
- <ul class=\\"gl-m-0 gl-p-0 gl-list-style-none\\">
- <li>
- <content-row-stub level=\\"3\\" statusiconname=\\"\\" widgetname=\\"MyWidget\\" header=\\"Child row header\\" actionbuttons=\\"\\" data-qa-selector=\\"child_content\\">
- <div class=\\"gl-display-flex gl-flex-direction-column\\">
- <div>
- <p class=\\"gl-mb-0\\">This is recursive. It will be listed in level 3.</p>
- <!---->
- <!---->
- <!---->
- <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub>
+ <div class=\\"gl-display-flex gl-align-items-baseline\\">
+ <status-icon-stub level=\\"2\\" name=\\"MyWidget\\" iconname=\\"success\\"></status-icon-stub>
+ <div class=\\"gl-display-flex gl-flex-direction-column\\">
+ <div>
+ <p class=\\"gl-mb-0\\">Main text for the row</p>
+ <gl-link-stub href=\\"https://gitlab.com\\">Optional link to display after text</gl-link-stub>
+ <!---->
+ <gl-badge-stub size=\\"md\\" variant=\\"info\\" iconsize=\\"md\\">
+ Badge is optional. Text to be displayed inside badge
+ </gl-badge-stub>
+ <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub>
+ <p class=\\"gl-m-0 gl-font-sm\\">Optional: Smaller sub-text to be displayed below the main text</p>
+ </div>
+ <ul class=\\"gl-m-0 gl-p-0 gl-list-style-none\\">
+ <li>
+ <div class=\\"gl-display-flex gl-align-items-center\\" data-qa-selector=\\"child_content\\">
<!---->
+ <div class=\\"gl-w-full gl-min-w-0\\">
+ <div class=\\"gl-display-flex\\">
+ <div class=\\"gl-mb-2\\"><strong class=\\"gl-display-block\\">Child row header</strong>
+ <!---->
+ </div>
+ <!---->
+ </div>
+ <div class=\\"gl-display-flex gl-align-items-baseline\\">
+ <!---->
+ <div class=\\"gl-display-flex gl-flex-direction-column\\">
+ <div>
+ <p class=\\"gl-mb-0\\">This is recursive. It will be listed in level 3.</p>
+ <!---->
+ <!---->
+ <!---->
+ <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub>
+ <!---->
+ </div>
+ <!---->
+ </div>
+ </div>
+ </div>
</div>
- <!---->
- </div>
- </content-row-stub>
- </li>
- </ul>
+ </li>
+ </ul>
+ </div>
+ </div>
</div>
-</content-row-stub>"
+</div>"
`;
diff --git a/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js
index 366ea113162..adefce9060c 100644
--- a/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js
@@ -11,10 +11,6 @@ function factory(propsData = {}) {
}
describe('~/vue_merge_request_widget/components/widget/action_buttons.vue', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('tertiaryButtons', () => {
it('renders buttons', () => {
factory({
diff --git a/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js
index 527e800ddcf..16751bcc0f0 100644
--- a/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js
@@ -1,6 +1,7 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
import DynamicContent from '~/vue_merge_request_widget/components/widget/dynamic_content.vue';
+import ContentRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue';
describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', () => {
let wrapper;
@@ -13,6 +14,7 @@ describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', ()
},
stubs: {
DynamicContent,
+ ContentRow,
},
});
};
diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js
index 973866176c2..4972c522733 100644
--- a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js
@@ -3,6 +3,7 @@ import * as Sentry from '@sentry/browser';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import waitForPromises from 'helpers/wait_for_promises';
+import { assertProps } from 'helpers/assert_props';
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
import ActionButtons from '~/vue_merge_request_widget/components/widget/action_buttons.vue';
import Widget from '~/vue_merge_request_widget/components/widget/widget.vue';
@@ -50,10 +51,6 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('on mount', () => {
it('fetches collapsed', async () => {
const fetchCollapsedData = jest
@@ -115,9 +112,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
it('validates widget name', () => {
expect(() => {
- createComponent({
- propsData: { widgetName: 'InvalidWidgetName' },
- });
+ assertProps(Widget, { widgetName: 'InvalidWidgetName' });
}).toThrow();
});
});
@@ -125,7 +120,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
describe('fetch', () => {
it('sets the data.collapsed property after a successfull call - multiPolling: false', async () => {
const mockData = { headers: {}, status: HTTP_STATUS_OK, data: { vulnerabilities: [] } };
- createComponent({ propsData: { fetchCollapsedData: async () => mockData } });
+ createComponent({ propsData: { fetchCollapsedData: () => Promise.resolve(mockData) } });
await waitForPromises();
expect(wrapper.emitted('input')[0][0]).toEqual({ collapsed: mockData.data, expanded: null });
});
@@ -291,6 +286,21 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
expect(findExpandedSection().text()).toBe('More complex content');
});
+ it('emits a toggle even when button is toggled', () => {
+ createComponent({
+ propsData: {
+ isCollapsible: true,
+ },
+ slots: {
+ content: '<b>More complex content</b>',
+ },
+ });
+
+ expect(findExpandedSection().exists()).toBe(false);
+ findToggleButton().vm.$emit('click');
+ expect(wrapper.emitted('toggle')).toEqual([[{ expanded: true }]]);
+ });
+
it('does not display the toggle button if isCollapsible is false', () => {
createComponent({
propsData: {
diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js
index 1bad5dacefa..785515ae846 100644
--- a/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js
+++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js
@@ -25,10 +25,6 @@ describe('Deployment action button', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when passed only icon via props', () => {
beforeEach(() => {
factory({
diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js
index 41df485b0de..f2b78dedf3a 100644
--- a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js
+++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { visitUrl } from '~/lib/utils/url_utility';
import {
@@ -21,7 +21,7 @@ import {
retryDetails,
} from './deployment_mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility');
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
@@ -30,11 +30,6 @@ describe('DeploymentAction component', () => {
let executeActionSpy;
const factory = (options = {}) => {
- // This destroys any wrappers created before a nested call to factory reassigns it
- if (wrapper && wrapper.destroy) {
- wrapper.destroy();
- }
-
wrapper = mount(DeploymentActions, options);
};
@@ -54,7 +49,6 @@ describe('DeploymentAction component', () => {
});
afterEach(() => {
- wrapper.destroy();
confirmAction.mockReset();
});
diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_list_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_list_spec.js
index 948d7ebab5e..77dac4204db 100644
--- a/spec/frontend/vue_merge_request_widget/deployment/deployment_list_spec.js
+++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_list_spec.js
@@ -28,7 +28,6 @@ describe('~/vue_merge_request_widget/components/deployment/deployment_list.vue',
afterEach(() => {
wrapper?.destroy?.();
- wrapper = null;
});
describe('with few deployments', () => {
diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js
index f310f7669a9..234491c531a 100644
--- a/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js
+++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js
@@ -16,10 +16,6 @@ describe('Deployment component', () => {
let wrapper;
const factory = (options = {}) => {
- // This destroys any wrappers created before a nested call to factory reassigns it
- if (wrapper && wrapper.destroy) {
- wrapper.destroy();
- }
wrapper = mount(DeploymentComponent, options);
};
@@ -32,10 +28,6 @@ describe('Deployment component', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('always renders DeploymentInfo', () => {
expect(wrapper.findComponent(DeploymentInfo).exists()).toBe(true);
});
diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js
index 8994fa522d0..72cfd5dd29f 100644
--- a/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js
+++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js
@@ -2,7 +2,6 @@ import { GlDropdown, GlLink } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue';
import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import { deploymentMockData } from './deployment_mock_data';
const appButtonText = {
@@ -28,16 +27,11 @@ describe('Deployment View App button', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findReviewAppLink = () => wrapper.findComponent(ReviewAppLink);
const findMrWigdetDeploymentDropdown = () => wrapper.findComponent(GlDropdown);
const findMrWigdetDeploymentDropdownIcon = () =>
wrapper.findByTestId('mr-wigdet-deployment-dropdown-icon');
const findDeployUrlMenuItems = () => wrapper.findAllComponents(GlLink);
- const findCopyButton = () => wrapper.findComponent(ModalCopyButton);
describe('text', () => {
it('renders text as passed', () => {
@@ -46,93 +40,39 @@ describe('Deployment View App button', () => {
});
describe('without changes', () => {
- let deployment;
-
beforeEach(() => {
- deployment = { ...deploymentMockData, changes: null };
- });
-
- describe('with safe url', () => {
- beforeEach(() => {
- createComponent({
- propsData: {
- deployment,
- appButtonText,
- },
- });
- });
-
- it('renders the link to the review app without dropdown', () => {
- expect(findMrWigdetDeploymentDropdown().exists()).toBe(false);
- expect(findReviewAppLink().attributes('href')).toBe(deployment.external_url);
+ createComponent({
+ propsData: {
+ deployment: { ...deploymentMockData, changes: null },
+ appButtonText,
+ },
});
});
- describe('without safe URL', () => {
- beforeEach(() => {
- deployment = { ...deployment, external_url: 'postgres://example' };
- createComponent({
- propsData: {
- deployment,
- appButtonText,
- },
- });
- });
-
- it('renders the link as a copy button', () => {
- expect(findMrWigdetDeploymentDropdown().exists()).toBe(false);
- expect(findCopyButton().props('text')).toBe(deployment.external_url);
- });
+ it('renders the link to the review app without dropdown', () => {
+ expect(findMrWigdetDeploymentDropdown().exists()).toBe(false);
});
});
describe('with a single change', () => {
- let deployment;
- let change;
-
beforeEach(() => {
- [change] = deploymentMockData.changes;
- deployment = { ...deploymentMockData, changes: [change] };
- });
-
- describe('with safe URL', () => {
- beforeEach(() => {
- createComponent({
- propsData: {
- deployment,
- appButtonText,
- },
- });
- });
-
- it('renders the link to the review app without dropdown', () => {
- expect(findMrWigdetDeploymentDropdown().exists()).toBe(false);
- expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(false);
+ createComponent({
+ propsData: {
+ deployment: { ...deploymentMockData, changes: [deploymentMockData.changes[0]] },
+ appButtonText,
+ },
});
+ });
- it('renders the link to the review app linked to to the first change', () => {
- const expectedUrl = deploymentMockData.changes[0].external_url;
-
- expect(findReviewAppLink().attributes('href')).toBe(expectedUrl);
- });
+ it('renders the link to the review app without dropdown', () => {
+ expect(findMrWigdetDeploymentDropdown().exists()).toBe(false);
+ expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(false);
});
- describe('with unsafe URL', () => {
- beforeEach(() => {
- change = { ...change, external_url: 'postgres://example' };
- deployment = { ...deployment, changes: [change] };
- createComponent({
- propsData: {
- deployment,
- appButtonText,
- },
- });
- });
+ it('renders the link to the review app linked to to the first change', () => {
+ const expectedUrl = deploymentMockData.changes[0].external_url;
- it('renders the link as a copy button', () => {
- expect(findMrWigdetDeploymentDropdown().exists()).toBe(false);
- expect(findCopyButton().props('text')).toBe(change.external_url);
- });
+ expect(findReviewAppLink().attributes('href')).toBe(expectedUrl);
});
});
diff --git a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js
index 548b68bc103..d2d622d0534 100644
--- a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js
+++ b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js
@@ -73,7 +73,6 @@ describe('Test report extension', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
diff --git a/spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js
index 01049e54a7f..9b1e694d9c4 100644
--- a/spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js
+++ b/spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js
@@ -39,7 +39,6 @@ describe('Accessibility extension', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
@@ -102,7 +101,7 @@ describe('Accessibility extension', () => {
await waitForPromises();
});
- it('displays all report list items in viewport', async () => {
+ it('displays all report list items in viewport', () => {
expect(findAllExtensionListItems()).toHaveLength(7);
});
diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js
index 67b327217ef..8d3bf3dd3be 100644
--- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js
+++ b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js
@@ -61,7 +61,6 @@ describe('Code Quality extension', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
@@ -185,14 +184,14 @@ describe('Code Quality extension', () => {
await waitForPromises();
});
- it('displays all report list items in viewport', async () => {
- expect(findAllExtensionListItems()).toHaveLength(2);
+ it('displays all report list items in viewport', () => {
+ expect(findAllExtensionListItems()).toHaveLength(4);
});
it('displays report list item formatted', () => {
const text = {
newError: trimText(findAllExtensionListItems().at(0).text().replace(/\s+/g, ' ').trim()),
- resolvedError: findAllExtensionListItems().at(1).text().replace(/\s+/g, ' ').trim(),
+ resolvedError: findAllExtensionListItems().at(2).text().replace(/\s+/g, ' ').trim(),
};
expect(text.newError).toContain(
@@ -203,9 +202,23 @@ describe('Code Quality extension', () => {
);
});
+ it('displays report list item formatted with check_name', () => {
+ const text = {
+ newError: trimText(findAllExtensionListItems().at(1).text().replace(/\s+/g, ' ').trim()),
+ resolvedError: findAllExtensionListItems().at(3).text().replace(/\s+/g, ' ').trim(),
+ };
+
+ expect(text.newError).toContain(
+ 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] in main.rb:3',
+ );
+ expect(text.resolvedError).toContain(
+ 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] Fixed in main.rb:3',
+ );
+ });
+
it('adds fixed indicator (badge) when error is resolved', () => {
- expect(findAllExtensionListItems().at(1).findComponent(GlBadge).exists()).toBe(true);
- expect(findAllExtensionListItems().at(1).findComponent(GlBadge).text()).toEqual(i18n.fixed);
+ expect(findAllExtensionListItems().at(3).findComponent(GlBadge).exists()).toBe(true);
+ expect(findAllExtensionListItems().at(3).findComponent(GlBadge).text()).toEqual(i18n.fixed);
});
it('should not add fixed indicator (badge) when error is new', () => {
diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js
index cb23b730a93..e66c1521ff5 100644
--- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js
+++ b/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js
@@ -57,6 +57,13 @@ export const codeQualityResponseResolvedAndNewErrors = {
file_path: 'index.js',
line: 12,
},
+ {
+ description: 'Avoid parameter lists longer than 5 parameters. [12/5]',
+ check_name: 'Rubocop/Metrics/ParameterLists',
+ severity: 'minor',
+ file_path: 'main.rb',
+ line: 3,
+ },
],
resolved_errors: [
{
@@ -65,6 +72,13 @@ export const codeQualityResponseResolvedAndNewErrors = {
file_path: 'index.js',
line: 12,
},
+ {
+ description: 'Avoid parameter lists longer than 5 parameters. [12/5]',
+ check_name: 'Rubocop/Metrics/ParameterLists',
+ severity: 'minor',
+ file_path: 'main.rb',
+ line: 3,
+ },
],
existing_errors: [],
summary: {
diff --git a/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js
index 13384e1efca..5baed8ff211 100644
--- a/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js
+++ b/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js
@@ -48,7 +48,6 @@ describe('Terraform extension', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
});
@@ -83,7 +82,7 @@ describe('Terraform extension', () => {
${'2 valid reports'} | ${{ 0: validPlanWithName, 1: validPlanWithName }} | ${'2 Terraform reports were generated in your pipelines'} | ${''}
${'1 valid and 2 invalid reports'} | ${{ 0: validPlanWithName, 1: invalidPlanWithName, 2: invalidPlanWithName }} | ${'Terraform report was generated in your pipelines'} | ${'2 Terraform reports failed to generate'}
`('and received $responseType', ({ response, summaryTitle, summarySubtitle }) => {
- beforeEach(async () => {
+ beforeEach(() => {
mockPollingApi(HTTP_STATUS_OK, response, {});
return createComponent();
});
diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js
index 015d394312a..20f1796008a 100644
--- a/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js
+++ b/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js
@@ -15,11 +15,6 @@ describe('MRWidgetHowToMerge', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
beforeEach(() => {
mountComponent();
});
diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
index f37276ad594..64fb2806447 100644
--- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
@@ -1,9 +1,10 @@
import { GlBadge, GlLink, GlIcon, GlButton, GlDropdown } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import * as Sentry from '@sentry/browser';
+import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json';
import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_state.query.graphql.json';
import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -20,6 +21,7 @@ import {
registerExtension,
registeredExtensions,
} from '~/vue_merge_request_widget/components/extensions';
+import { STATE_QUERY_POLLING_INTERVAL_BACKOFF } from '~/vue_merge_request_widget/constants';
import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants';
import eventHub from '~/vue_merge_request_widget/event_hub';
import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
@@ -28,6 +30,7 @@ import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_
import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
+import approvalsQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql';
import userPermissionsQuery from '~/vue_merge_request_widget/queries/permissions.query.graphql';
import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql';
import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data';
@@ -60,6 +63,8 @@ jest.mock('@sentry/browser', () => ({
Vue.use(VueApollo);
describe('MrWidgetOptions', () => {
+ let stateQueryHandler;
+ let queryResponse;
let wrapper;
let mock;
@@ -83,37 +88,41 @@ describe('MrWidgetOptions', () => {
afterEach(() => {
mock.restore();
+ // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy
wrapper.destroy();
-
gl.mrWidgetData = {};
- gon.features = {};
});
- const createComponent = (mrData = mockData, options = {}) => {
- wrapper = mount(MrWidgetOptions, {
+ const createComponent = (mrData = mockData, options = {}, data = {}, fullMount = true) => {
+ const mounting = fullMount ? mount : shallowMount;
+
+ queryResponse = {
+ data: {
+ project: {
+ ...getStateQueryResponse.data.project,
+ mergeRequest: {
+ ...getStateQueryResponse.data.project.mergeRequest,
+ mergeError: mrData.mergeError || null,
+ },
+ },
+ },
+ };
+ stateQueryHandler = jest.fn().mockResolvedValue(queryResponse);
+ wrapper = mounting(MrWidgetOptions, {
propsData: {
mrData: { ...mrData },
},
data() {
- return { loading: false };
+ return {
+ loading: false,
+ ...data,
+ };
},
...options,
apolloProvider: createMockApollo([
- [
- getStateQuery,
- jest.fn().mockResolvedValue({
- data: {
- project: {
- ...getStateQueryResponse.data.project,
- mergeRequest: {
- ...getStateQueryResponse.data.project.mergeRequest,
- mergeError: mrData.mergeError || null,
- },
- },
- },
- }),
- ],
+ [approvalsQuery, jest.fn().mockResolvedValue(approvedByCurrentUser)],
+ [getStateQuery, stateQueryHandler],
[readyToMergeQuery, jest.fn().mockResolvedValue(readyToMergeResponse)],
[
userPermissionsQuery,
@@ -142,7 +151,9 @@ describe('MrWidgetOptions', () => {
return createComponent();
});
- describe('data', () => {
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/385238
+ // eslint-disable-next-line jest/no-disabled-tests
+ describe.skip('data', () => {
it('should instantiate Store and Service', () => {
expect(wrapper.vm.mr).toBeDefined();
expect(wrapper.vm.service).toBeDefined();
@@ -151,9 +162,17 @@ describe('MrWidgetOptions', () => {
describe('computed', () => {
describe('componentName', () => {
+ // eslint-disable-next-line jest/no-disabled-tests
+ it.skip.each`
+ ${'merged'} | ${'mr-widget-merged'}
+ `('should translate $state into $componentName', ({ state, componentName }) => {
+ wrapper.vm.mr.state = state;
+
+ expect(wrapper.vm.componentName).toEqual(componentName);
+ });
+
it.each`
state | componentName
- ${'merged'} | ${'mr-widget-merged'}
${'conflicts'} | ${'mr-widget-conflicts'}
${'shaMismatch'} | ${'sha-mismatch'}
`('should translate $state into $componentName', ({ state, componentName }) => {
@@ -351,18 +370,6 @@ describe('MrWidgetOptions', () => {
});
});
- describe('initPolling', () => {
- it('should call SmartInterval', () => {
- wrapper.vm.initPolling();
-
- expect(SmartInterval).toHaveBeenCalledWith(
- expect.objectContaining({
- callback: wrapper.vm.checkStatus,
- }),
- );
- });
- });
-
describe('initDeploymentsPolling', () => {
it('should call SmartInterval', () => {
wrapper.vm.initDeploymentsPolling();
@@ -473,15 +480,15 @@ describe('MrWidgetOptions', () => {
});
it('should call setFavicon method', async () => {
- wrapper.vm.mr.ciStatusFaviconPath = overlayDataUrl;
+ wrapper.vm.mr.faviconOverlayPath = overlayDataUrl;
await wrapper.vm.setFaviconHelper();
expect(setFaviconOverlay).toHaveBeenCalledWith(overlayDataUrl);
});
- it('should not call setFavicon when there is no ciStatusFaviconPath', async () => {
- wrapper.vm.mr.ciStatusFaviconPath = null;
+ it('should not call setFavicon when there is no faviconOverlayPath', async () => {
+ wrapper.vm.mr.faviconOverlayPath = null;
await wrapper.vm.setFaviconHelper();
expect(faviconElement.getAttribute('href')).toEqual(null);
});
@@ -529,23 +536,64 @@ describe('MrWidgetOptions', () => {
});
});
- describe('resumePolling', () => {
- it('should call stopTimer on pollingInterval', () => {
- jest.spyOn(wrapper.vm.pollingInterval, 'resume').mockImplementation(() => {});
+ describe('Apollo query', () => {
+ const interval = 5;
+ const data = 'foo';
+ const mockCheckStatus = jest.fn().mockResolvedValue({ data });
+ const mockSetGraphqlData = jest.fn();
+ const mockSetData = jest.fn();
- wrapper.vm.resumePolling();
+ beforeEach(() => {
+ wrapper.destroy();
+
+ return createComponent(
+ mockData,
+ {},
+ {
+ pollInterval: interval,
+ startingPollInterval: interval,
+ mr: {
+ setData: mockSetData,
+ setGraphqlData: mockSetGraphqlData,
+ },
+ service: {
+ checkStatus: mockCheckStatus,
+ },
+ },
+ false,
+ );
+ });
- expect(wrapper.vm.pollingInterval.resume).toHaveBeenCalled();
+ describe('normal polling behavior', () => {
+ it('responds to the GraphQL query finishing', () => {
+ expect(mockSetGraphqlData).toHaveBeenCalledWith(queryResponse.data.project);
+ expect(mockCheckStatus).toHaveBeenCalled();
+ expect(mockSetData).toHaveBeenCalledWith(data, undefined);
+ expect(stateQueryHandler).toHaveBeenCalledTimes(1);
+ });
});
- });
- describe('stopPolling', () => {
- it('should call stopTimer on pollingInterval', () => {
- jest.spyOn(wrapper.vm.pollingInterval, 'stopTimer').mockImplementation(() => {});
+ describe('external event control', () => {
+ describe('enablePolling', () => {
+ it('enables the Apollo query polling using the event hub', () => {
+ eventHub.$emit('EnablePolling');
+
+ expect(stateQueryHandler).toHaveBeenCalled();
+ jest.advanceTimersByTime(interval * STATE_QUERY_POLLING_INTERVAL_BACKOFF);
+ expect(stateQueryHandler).toHaveBeenCalledTimes(2);
+ });
+ });
- wrapper.vm.stopPolling();
+ describe('disablePolling', () => {
+ it('disables the Apollo query polling using the event hub', () => {
+ expect(stateQueryHandler).toHaveBeenCalledTimes(1);
- expect(wrapper.vm.pollingInterval.stopTimer).toHaveBeenCalled();
+ eventHub.$emit('DisablePolling');
+ jest.advanceTimersByTime(interval * STATE_QUERY_POLLING_INTERVAL_BACKOFF);
+
+ expect(stateQueryHandler).toHaveBeenCalledTimes(1); // no additional polling after a real interval timeout
+ });
+ });
});
});
});
@@ -800,7 +848,7 @@ describe('MrWidgetOptions', () => {
});
describe('security widget', () => {
- const setup = async (hasPipeline) => {
+ const setup = (hasPipeline) => {
const mrData = {
...mockData,
...(hasPipeline ? {} : { pipeline: null }),
@@ -815,7 +863,9 @@ describe('MrWidgetOptions', () => {
apolloMock: [
[
securityReportMergeRequestDownloadPathsQuery,
- async () => ({ data: securityReportMergeRequestDownloadPathsQueryResponse }),
+ jest
+ .fn()
+ .mockResolvedValue({ data: securityReportMergeRequestDownloadPathsQueryResponse }),
],
],
});
@@ -890,11 +940,7 @@ describe('MrWidgetOptions', () => {
});
describe('mock extension', () => {
- let pollRequest;
-
beforeEach(() => {
- pollRequest = jest.spyOn(Poll.prototype, 'makeRequest');
-
registerExtension(workingExtension());
createComponent();
@@ -945,10 +991,6 @@ describe('MrWidgetOptions', () => {
expect(collapsedSection.findComponent(GlButton).exists()).toBe(true);
expect(collapsedSection.findComponent(GlButton).text()).toBe('Full report');
});
-
- it('extension polling is not called if enablePolling flag is not passed', () => {
- expect(pollRequest).toHaveBeenCalledTimes(0);
- });
});
describe('expansion', () => {
@@ -1167,33 +1209,6 @@ describe('MrWidgetOptions', () => {
'i_code_review_merge_request_widget_test_extension_count_expand_warning',
);
});
-
- it.each`
- widgetName | nonStandardEvent
- ${'WidgetCodeQuality'} | ${'i_testing_code_quality_widget_total'}
- ${'WidgetTerraform'} | ${'i_testing_terraform_widget_total'}
- ${'WidgetIssues'} | ${'i_testing_issues_widget_total'}
- ${'WidgetTestSummary'} | ${'i_testing_summary_widget_total'}
- `(
- "sends non-standard events for the '$widgetName' widget",
- async ({ widgetName, nonStandardEvent }) => {
- const definition = {
- ...workingExtension(),
- name: widgetName,
- };
-
- registerExtension(definition);
- createComponent();
-
- await waitForPromises();
-
- api.trackRedisHllUserEvent.mockClear();
-
- findExtensionToggleButton().trigger('click');
-
- expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(nonStandardEvent);
- },
- );
});
it('triggers the "full report clicked" events when the appropriate button is clicked', () => {
@@ -1235,10 +1250,6 @@ describe('MrWidgetOptions', () => {
});
describe('widget container', () => {
- afterEach(() => {
- delete window.gon.features.refactorSecurityExtension;
- });
-
it('should not be displayed when the refactor_security_extension feature flag is turned off', () => {
createComponent();
expect(findWidgetContainer().exists()).toBe(false);
diff --git a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js
index 88d9d0b4cff..a6288b9c725 100644
--- a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js
+++ b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js
@@ -20,7 +20,7 @@ describe('getStateKey', () => {
};
const bound = getStateKey.bind(context);
- expect(bound()).toEqual(null);
+ expect(bound()).toEqual('checking');
context.detailedMergeStatus = 'MERGEABLE';
diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js
index 3bc191d988f..6c2b21053f0 100644
--- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js
+++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js
@@ -86,12 +86,10 @@ describe('AlertDetails', () => {
});
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
mock.restore();
});
+ const findTabs = () => wrapper.findByTestId('alertDetailsTabs');
const findCreateIncidentBtn = () => wrapper.findByTestId('createIncidentBtn');
const findViewIncidentBtn = () => wrapper.findByTestId('viewIncidentBtn');
const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError');
@@ -107,7 +105,7 @@ describe('AlertDetails', () => {
});
it('shows an empty state', () => {
- expect(wrapper.findByTestId('alertDetailsTabs').exists()).toBe(false);
+ expect(findTabs().exists()).toBe(false);
});
});
@@ -349,9 +347,7 @@ describe('AlertDetails', () => {
${1} | ${'metrics'}
${2} | ${'activity'}
`('will navigate to the correct tab via $tabId', ({ index, tabId }) => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ currentTabIndex: index });
+ findTabs().vm.$emit('input', index);
expect($router.push).toHaveBeenCalledWith({ name: 'tab', params: { tabId } });
});
});
diff --git a/spec/frontend/vue_shared/alert_details/alert_management_sidebar_todo_spec.js b/spec/frontend/vue_shared/alert_details/alert_management_sidebar_todo_spec.js
index 12c5c190e26..217103ab25c 100644
--- a/spec/frontend/vue_shared/alert_details/alert_management_sidebar_todo_spec.js
+++ b/spec/frontend/vue_shared/alert_details/alert_management_sidebar_todo_spec.js
@@ -32,10 +32,6 @@ describe('Alert Details Sidebar To Do', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findToDoButton = () => wrapper.find('[data-testid="alert-todo-button"]');
describe('updating the alert to do', () => {
diff --git a/spec/frontend/vue_shared/alert_details/alert_metrics_spec.js b/spec/frontend/vue_shared/alert_details/alert_metrics_spec.js
deleted file mode 100644
index 9d84a535d67..00000000000
--- a/spec/frontend/vue_shared/alert_details/alert_metrics_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
-import waitForPromises from 'helpers/wait_for_promises';
-import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue';
-import AlertMetrics from '~/vue_shared/alert_details/components/alert_metrics.vue';
-
-jest.mock('~/monitoring/stores', () => ({
- monitoringDashboard: {},
-}));
-
-jest.mock('~/monitoring/components/embeds/metric_embed.vue', () => ({
- render(h) {
- return h('div');
- },
-}));
-
-describe('Alert Metrics', () => {
- let wrapper;
- const mock = new MockAdapter(axios);
-
- function mountComponent({ props } = {}) {
- wrapper = shallowMount(AlertMetrics, {
- propsData: {
- ...props,
- },
- });
- }
-
- const findChart = () => wrapper.findComponent(MetricEmbed);
- const findEmptyState = () => wrapper.findComponent({ ref: 'emptyState' });
-
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
- afterAll(() => {
- mock.restore();
- });
-
- describe('Empty state', () => {
- it('should display a message when metrics dashboard url is not provided', () => {
- mountComponent();
- expect(findChart().exists()).toBe(false);
- expect(findEmptyState().text()).toBe("Metrics weren't available in the alerts payload.");
- });
- });
-
- describe('Chart', () => {
- it('should be rendered when dashboard url is provided', async () => {
- mountComponent({ props: { dashboardUrl: 'metrics.url' } });
-
- await waitForPromises();
- await nextTick();
-
- expect(findEmptyState().exists()).toBe(false);
- expect(findChart().exists()).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/alert_details/alert_status_spec.js b/spec/frontend/vue_shared/alert_details/alert_status_spec.js
index 2a37ff2b784..98cb2f5cb0b 100644
--- a/spec/frontend/vue_shared/alert_details/alert_status_spec.js
+++ b/spec/frontend/vue_shared/alert_details/alert_status_spec.js
@@ -45,12 +45,6 @@ describe('AlertManagementStatus', () => {
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('sidebar', () => {
it('displays the dropdown status header', () => {
mountComponent({ props: { isSidebar: true } });
diff --git a/spec/frontend/vue_shared/alert_details/alert_summary_row_spec.js b/spec/frontend/vue_shared/alert_details/alert_summary_row_spec.js
index a2981478954..0ecca0a69b9 100644
--- a/spec/frontend/vue_shared/alert_details/alert_summary_row_spec.js
+++ b/spec/frontend/vue_shared/alert_details/alert_summary_row_spec.js
@@ -16,13 +16,6 @@ describe('AlertSummaryRow', () => {
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
describe('Alert Summary Row', () => {
beforeEach(() => {
mountComponent({
diff --git a/spec/frontend/vue_shared/alert_details/router_spec.js b/spec/frontend/vue_shared/alert_details/router_spec.js
deleted file mode 100644
index e3efc104862..00000000000
--- a/spec/frontend/vue_shared/alert_details/router_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import createRouter from '~/vue_shared/alert_details/router';
-import setWindowLocation from 'helpers/set_window_location_helper';
-
-const BASE_PATH = '/-/alert_management/1/details';
-const EMPTY_HASH = '';
-const NOOP = () => {};
-
-describe('AlertDetails router', () => {
- const originalLocation = window.location.href;
- let router;
-
- beforeEach(() => {
- setWindowLocation(originalLocation);
- router = createRouter(BASE_PATH);
- });
-
- describe('redirects hash route mode URLs to history route mode', () => {
- it.each`
- hashPath | historyPath
- ${'/#/overview'} | ${'/overview'}
- ${'#/overview'} | ${'/overview'}
- ${'/#/'} | ${'/'}
- ${'#/'} | ${'/'}
- ${'/#'} | ${'/'}
- ${'#'} | ${'/'}
- ${'/'} | ${'/'}
- ${'/overview'} | ${'/overview'}
- `('should redirect "$hashPath" to "$historyPath"', ({ hashPath, historyPath }) => {
- router.push(hashPath, NOOP);
-
- expect(window.location.hash).toBe(EMPTY_HASH);
- expect(window.location.pathname).toBe(BASE_PATH + historyPath);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js
index 98a357bac2b..bf4435fae45 100644
--- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js
+++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js
@@ -1,21 +1,28 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { GlDropdownItem } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import SidebarAssignee from '~/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue';
import SidebarAssignees from '~/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue';
import AlertSetAssignees from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import mockAlerts from '../mocks/alerts.json';
const mockAlert = mockAlerts[0];
describe('Alert Details Sidebar Assignees', () => {
let wrapper;
+ let requestHandlers;
let mock;
const mockPath = '/-/autocomplete/users.json';
+ const mockUrlRoot = '/gitlab';
+ const expectedUrl = `${mockUrlRoot}${mockPath}`;
+
const mockUsers = [
{
avatar_url:
@@ -40,81 +47,64 @@ describe('Alert Details Sidebar Assignees', () => {
const findSidebarIcon = () => wrapper.findByTestId('assignees-icon');
const findUnassigned = () => wrapper.findByTestId('unassigned-users');
+ const mockDefaultHandler = (errors = []) =>
+ jest.fn().mockResolvedValue({
+ data: {
+ issuableSetAssignees: {
+ errors,
+ issuable: {
+ id: 'id',
+ iid: 'iid',
+ assignees: {
+ nodes: [],
+ },
+ notes: {
+ nodes: [],
+ },
+ },
+ },
+ },
+ });
+ const createMockApolloProvider = (handlers) => {
+ Vue.use(VueApollo);
+ requestHandlers = handlers;
+
+ return createMockApollo([[AlertSetAssignees, handlers]]);
+ };
+
function mountComponent({
- data,
- users = [],
- isDropdownSearching = false,
+ props,
sidebarCollapsed = true,
- loading = false,
- stubs = {},
+ handlers = mockDefaultHandler(),
} = {}) {
wrapper = shallowMountExtended(SidebarAssignees, {
- data() {
- return {
- users,
- isDropdownSearching,
- };
- },
+ apolloProvider: createMockApolloProvider(handlers),
propsData: {
alert: { ...mockAlert },
- ...data,
+ ...props,
sidebarCollapsed,
projectPath: 'projectPath',
projectId: '1',
},
- mocks: {
- $apollo: {
- mutate: jest.fn(),
- queries: {
- alert: {
- loading,
- },
- },
- },
- },
- stubs,
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- mock.restore();
- });
-
describe('sidebar expanded', () => {
- const mockUpdatedMutationResult = {
- data: {
- alertSetAssignees: {
- errors: [],
- alert: {
- assigneeUsernames: ['root'],
- },
- },
- },
- };
-
beforeEach(() => {
mock = new MockAdapter(axios);
+ window.gon = {
+ relative_url_root: mockUrlRoot,
+ };
- mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers);
+ mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, mockUsers);
mountComponent({
- data: { alert: mockAlert },
+ props: { alert: mockAlert },
sidebarCollapsed: false,
- loading: false,
- users: mockUsers,
- stubs: {
- SidebarAssignee,
- },
});
});
it('renders a unassigned option', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isDropdownSearching: false });
- await nextTick();
+ await waitForPromises();
expect(findDropdown().text()).toBe('Unassigned');
});
@@ -122,60 +112,38 @@ describe('Alert Details Sidebar Assignees', () => {
expect(findSidebarIcon().exists()).toBe(false);
});
- it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isDropdownSearching: false });
-
- await nextTick();
+ it('calls `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => {
+ await waitForPromises();
wrapper.findComponent(SidebarAssignee).vm.$emit('update-alert-assignees', 'root');
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: AlertSetAssignees,
- variables: {
- iid: '1527542',
- assigneeUsernames: ['root'],
- fullPath: 'projectPath',
- },
+ expect(requestHandlers).toHaveBeenCalledWith({
+ iid: '1527542',
+ assigneeUsernames: ['root'],
+ fullPath: 'projectPath',
});
});
it('emits an error when request contains error messages', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isDropdownSearching: false });
- const errorMutationResult = {
- data: {
- issuableSetAssignees: {
- errors: ['There was a problem for sure.'],
- alert: {},
- },
- },
- };
-
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(errorMutationResult);
+ mountComponent({
+ sidebarCollapsed: false,
+ handlers: mockDefaultHandler(['There was a problem for sure.']),
+ });
+ await waitForPromises();
- await nextTick();
const SideBarAssigneeItem = wrapper.findAllComponents(SidebarAssignee).at(0);
await SideBarAssigneeItem.vm.$emit('update-alert-assignees');
- expect(wrapper.emitted('alert-error')).toBeDefined();
+
+ await waitForPromises();
+ expect(wrapper.emitted('alert-error')).toHaveLength(1);
});
it('stops updating and cancels loading when the request fails', () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
- wrapper.vm.updateAlertAssignees('root');
expect(findUnassigned().text()).toBe('assign yourself');
});
it('shows a user avatar, username and full name when a user is set', () => {
mountComponent({
- data: { alert: mockAlerts[1] },
- sidebarCollapsed: false,
- loading: false,
- stubs: {
- SidebarAssignee,
- },
+ props: { alert: mockAlerts[1] },
});
expect(findAssigned().find('img').attributes('src')).toBe('/url');
@@ -188,15 +156,10 @@ describe('Alert Details Sidebar Assignees', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
- mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers);
+ mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, mockUsers);
mountComponent({
- data: { alert: mockAlert },
- loading: false,
- users: mockUsers,
- stubs: {
- SidebarAssignee,
- },
+ props: { alert: mockAlert },
});
});
it('does not display the status dropdown', () => {
diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js
index 3b38349622f..89d02cc9de8 100644
--- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js
+++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js
@@ -44,9 +44,6 @@ describe('Alert Details Sidebar', () => {
}
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
mock.restore();
});
diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js
index a3adbcf8d3a..7df744cd11d 100644
--- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js
+++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js
@@ -45,12 +45,6 @@ describe('Alert Details Sidebar Status', () => {
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
describe('sidebar expanded', () => {
beforeEach(() => {
mountComponent({
diff --git a/spec/frontend/vue_shared/alert_details/system_notes/alert_management_system_note_spec.js b/spec/frontend/vue_shared/alert_details/system_notes/alert_management_system_note_spec.js
index 6a750bb99c0..72c16e8ff22 100644
--- a/spec/frontend/vue_shared/alert_details/system_notes/alert_management_system_note_spec.js
+++ b/spec/frontend/vue_shared/alert_details/system_notes/alert_management_system_note_spec.js
@@ -17,13 +17,6 @@ describe('Alert Details System Note', () => {
});
}
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
describe('System notes', () => {
beforeEach(() => {
mountComponent({});
diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap
index 45d34bcdd3f..b93c64efbcb 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap
@@ -17,7 +17,7 @@ exports[`Expand button on click when short text is provided renders button after
role="img"
>
<use
- href="#ellipsis_h"
+ href="file-mock#ellipsis_h"
/>
</svg>
@@ -47,7 +47,7 @@ exports[`Expand button on click when short text is provided renders button after
role="img"
>
<use
- href="#ellipsis_h"
+ href="file-mock#ellipsis_h"
/>
</svg>
@@ -72,7 +72,7 @@ exports[`Expand button when short text is provided renders button before text 1`
role="img"
>
<use
- href="#ellipsis_h"
+ href="file-mock#ellipsis_h"
/>
</svg>
@@ -102,7 +102,7 @@ exports[`Expand button when short text is provided renders button before text 1`
role="img"
>
<use
- href="#ellipsis_h"
+ href="file-mock#ellipsis_h"
/>
</svg>
diff --git a/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap
deleted file mode 100644
index ca9d4488870..00000000000
--- a/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap
+++ /dev/null
@@ -1,40 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`File row header component adds multiple ellipsises after 40 characters 1`] = `
-<div
- class="file-row-header bg-white sticky-top p-2 js-file-row-header"
- title="app/assets/javascripts/merge_requests/widget/diffs/notes"
->
- <gl-truncate-stub
- class="bold"
- position="middle"
- text="app/assets/javascripts/merge_requests/widget/diffs/notes"
- />
-</div>
-`;
-
-exports[`File row header component renders file path 1`] = `
-<div
- class="file-row-header bg-white sticky-top p-2 js-file-row-header"
- title="app/assets"
->
- <gl-truncate-stub
- class="bold"
- position="middle"
- text="app/assets"
- />
-</div>
-`;
-
-exports[`File row header component trucates path after 40 characters 1`] = `
-<div
- class="file-row-header bg-white sticky-top p-2 js-file-row-header"
- title="app/assets/javascripts/merge_requests"
->
- <gl-truncate-stub
- class="bold"
- position="middle"
- text="app/assets/javascripts/merge_requests"
- />
-</div>
-`;
diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js
index f3fb840b270..8c2f2b52f8e 100644
--- a/spec/frontend/vue_shared/components/actions_button_spec.js
+++ b/spec/frontend/vue_shared/components/actions_button_spec.js
@@ -34,10 +34,6 @@ describe('Actions button component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findButton = () => wrapper.findComponent(GlButton);
const findTooltip = () => wrapper.findComponent(GlTooltip);
const findDropdown = () => wrapper.findComponent(GlDropdown);
diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js
index 8a9ee4699bd..8e7a10c4d77 100644
--- a/spec/frontend/vue_shared/components/alert_details_table_spec.js
+++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js
@@ -41,11 +41,6 @@ describe('AlertDetails', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findTableComponent = () => wrapper.findComponent(GlTable);
const findTableKeys = () => findTableComponent().findAll('tbody td:first-child');
const findTableFieldValueByKey = (fieldKey) =>
diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js
index c7f9d8fd8d5..da5516f8db1 100644
--- a/spec/frontend/vue_shared/components/awards_list_spec.js
+++ b/spec/frontend/vue_shared/components/awards_list_spec.js
@@ -64,16 +64,7 @@ const REACTION_CONTROL_CLASSES = [
describe('vue_shared/components/awards_list', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const createComponent = (props = {}) => {
- if (wrapper) {
- throw new Error('There should only be one wrapper created per test');
- }
-
wrapper = mount(AwardsList, { propsData: props });
};
const matchingEmojiTag = (name) => expect.stringMatching(`gl-emoji data-name="${name}"`);
@@ -98,7 +89,6 @@ describe('vue_shared/components/awards_list', () => {
addButtonClass: TEST_ADD_BUTTON_CLASS,
});
});
-
it('shows awards in correct order', () => {
expect(findAwardsData()).toEqual([
{
diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
index ce7fd40937f..6acd1f51a86 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
@@ -24,10 +24,6 @@ describe('Blob Rich Viewer component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the passed content without transformations', () => {
expect(wrapper.html()).toContain(content);
});
diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
index 4b44311b253..a480e0869e8 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
@@ -23,10 +23,6 @@ describe('Blob Simple Viewer component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('does not fail if content is empty', () => {
const spy = jest.spyOn(window.console, 'error');
createComponent('');
diff --git a/spec/frontend/vue_shared/components/changed_file_icon_spec.js b/spec/frontend/vue_shared/components/changed_file_icon_spec.js
index ea708b6f3fe..d1b1e58f5d7 100644
--- a/spec/frontend/vue_shared/components/changed_file_icon_spec.js
+++ b/spec/frontend/vue_shared/components/changed_file_icon_spec.js
@@ -21,10 +21,6 @@ describe('Changed file icon', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findIcon = () => wrapper.findComponent(GlIcon);
const findIconName = () => findIcon().props('name');
const findIconClasses = () => findIcon().classes();
diff --git a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js
index 6932a812287..2a40511affb 100644
--- a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js
+++ b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js
@@ -10,8 +10,6 @@ describe('vue_shared/components/chronic_duration_input', () => {
let hiddenElement;
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
textElement = null;
hiddenElement = null;
});
@@ -22,10 +20,6 @@ describe('vue_shared/components/chronic_duration_input', () => {
};
const createComponent = (props = {}) => {
- if (wrapper) {
- throw new Error('There should only be one wrapper created per test');
- }
-
wrapper = mount(ChronicDurationInput, { propsData: props });
findComponents();
};
diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js
index 4f24ec2d015..afb509b9fe6 100644
--- a/spec/frontend/vue_shared/components/ci_badge_link_spec.js
+++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js
@@ -82,10 +82,6 @@ describe('CI Badge Link Component', () => {
wrapper = shallowMount(CiBadgeLink, { propsData });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each(Object.keys(statuses))('should render badge for status: %s', (status) => {
createComponent({ status: statuses[status] });
diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon_spec.js
index 2064bee9673..31d63654168 100644
--- a/spec/frontend/vue_shared/components/ci_icon_spec.js
+++ b/spec/frontend/vue_shared/components/ci_icon_spec.js
@@ -7,11 +7,6 @@ describe('CI Icon component', () => {
const findIconWrapper = () => wrapper.find('[data-testid="ci-icon-wrapper"]');
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should render a span element with an svg', () => {
wrapper = shallowMount(ciIcon, {
propsData: {
diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js
index b18b00e70bb..08a9c2a42d8 100644
--- a/spec/frontend/vue_shared/components/clipboard_button_spec.js
+++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js
@@ -59,11 +59,6 @@ describe('clipboard button', () => {
expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::hide::tooltip', 'clipboard-button-1');
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('without gfm', () => {
beforeEach(() => {
createWrapper({
diff --git a/spec/frontend/vue_shared/components/clone_dropdown_spec.js b/spec/frontend/vue_shared/components/clone_dropdown_spec.js
index 31c08260dd0..584e29d94c4 100644
--- a/spec/frontend/vue_shared/components/clone_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/clone_dropdown_spec.js
@@ -21,11 +21,6 @@ describe('Clone Dropdown Button', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('rendering', () => {
it('matches the snapshot', () => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/code_block_highlighted_spec.js b/spec/frontend/vue_shared/components/code_block_highlighted_spec.js
index 181692e61b5..25283eb1211 100644
--- a/spec/frontend/vue_shared/components/code_block_highlighted_spec.js
+++ b/spec/frontend/vue_shared/components/code_block_highlighted_spec.js
@@ -11,10 +11,6 @@ describe('Code Block Highlighted', () => {
wrapper = shallowMount(CodeBlock, { propsData });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders highlighted code if language is supported', async () => {
createComponent({ code, language: 'javascript' });
diff --git a/spec/frontend/vue_shared/components/code_block_spec.js b/spec/frontend/vue_shared/components/code_block_spec.js
index 9a4dbcc47ff..0fdfb96cb23 100644
--- a/spec/frontend/vue_shared/components/code_block_spec.js
+++ b/spec/frontend/vue_shared/components/code_block_spec.js
@@ -13,10 +13,6 @@ describe('Code Block', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('overwrites the default slot', () => {
createComponent({}, { default: 'DEFAULT SLOT' });
diff --git a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js
index 060048c4bbd..174e27af948 100644
--- a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js
+++ b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js
@@ -34,10 +34,6 @@ describe('ColorPicker', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('label', () => {
it('hides the label if the label is not passed', () => {
createComponent(shallowMount);
@@ -100,7 +96,7 @@ describe('ColorPicker', () => {
expect(colorTextInput().attributes('class')).not.toContain('is-invalid');
});
- it('shows invalid feedback when the state is marked as invalid', async () => {
+ it('shows invalid feedback when the state is marked as invalid', () => {
createComponent(mount, { invalidFeedback: invalidText, state: false });
expect(invalidFeedback().text()).toBe(invalidText);
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js
index fe614f03119..0c9bdc1848d 100644
--- a/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js
+++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js
@@ -1,5 +1,5 @@
+import { rgbFromHex } from '@gitlab/ui/dist/utils/utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { hexToRgb } from '~/lib/utils/color_utils';
import ColorItem from '~/vue_shared/components/color_select_dropdown/color_item.vue';
import { color } from './mock_data';
@@ -20,16 +20,14 @@ describe('ColorItem', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the correct title', () => {
expect(wrapper.text()).toBe(propsData.title);
});
it('renders the correct background color for the color item', () => {
- const convertedColor = hexToRgb(propsData.color).join(', ');
- expect(findColorItem().attributes('style')).toBe(`background-color: rgb(${convertedColor});`);
+ const colorAsRGB = rgbFromHex(propsData.color);
+ expect(findColorItem().attributes('style')).toBe(
+ `background-color: rgb(${colorAsRGB.join(', ')});`,
+ );
});
});
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js
index 5b0772f6e34..f262b03414c 100644
--- a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue';
import DropdownValue from '~/vue_shared/components/color_select_dropdown/dropdown_value.vue';
@@ -13,7 +13,7 @@ import ColorSelectRoot from '~/vue_shared/components/color_select_dropdown/color
import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants';
import { colorQueryResponse, updateColorMutationResponse, color } from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
@@ -60,10 +60,6 @@ describe('LabelsSelectRoot', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
const defaultClasses = ['labels-select-wrapper', 'gl-relative'];
@@ -73,7 +69,7 @@ describe('LabelsSelectRoot', () => {
${'embedded'} | ${[...defaultClasses, 'is-embedded']}
`(
'renders component root element with CSS class `$cssClass` when variant is "$variant"',
- async ({ variant, cssClass }) => {
+ ({ variant, cssClass }) => {
createComponent({
propsData: { variant },
});
@@ -145,7 +141,7 @@ describe('LabelsSelectRoot', () => {
await waitForPromises();
});
- it('creates flash with error message', () => {
+ it('creates alert with error message', () => {
expect(createAlert).toHaveBeenCalledWith({
captureError: true,
message: 'Error fetching epic color.',
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js
index 303824c77b3..bdb9e8763e2 100644
--- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js
+++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js
@@ -22,14 +22,10 @@ describe('DropdownContentsColorView', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findColors = () => wrapper.findAllComponents(ColorItem);
const findColorList = () => wrapper.findComponent(GlDropdownForm);
- it('renders color list', async () => {
+ it('renders color list', () => {
expect(findColorList().exists()).toBe(true);
expect(findColors()).toHaveLength(ISSUABLE_COLORS.length);
});
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js
index ee4d3a2630a..2e3a8550e97 100644
--- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js
+++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js
@@ -2,6 +2,7 @@ import { nextTick } from 'vue';
import { GlDropdown } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants';
+import { stubComponent } from 'helpers/stub_component';
import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue';
import DropdownContentsColorView from '~/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue';
import DropdownHeader from '~/vue_shared/components/color_select_dropdown/dropdown_header.vue';
@@ -19,31 +20,37 @@ const defaultProps = {
describe('DropdownContent', () => {
let wrapper;
- const createComponent = ({ propsData = {} } = {}) => {
+ const createComponent = ({ propsData = {}, stubs = {} } = {}) => {
wrapper = mountExtended(DropdownContents, {
propsData: {
...defaultProps,
...propsData,
},
+ stubs,
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findColorView = () => wrapper.findComponent(DropdownContentsColorView);
const findDropdownHeader = () => wrapper.findComponent(DropdownHeader);
const findDropdown = () => wrapper.findComponent(GlDropdown);
it('calls dropdown `show` method on `isVisible` prop change', async () => {
- createComponent();
- const spy = jest.spyOn(wrapper.vm.$refs.dropdown, 'show');
+ const showDropdown = jest.fn();
+ const hideDropdown = jest.fn();
+ const dropdownStub = {
+ GlDropdown: stubComponent(GlDropdown, {
+ methods: {
+ show: showDropdown,
+ hide: hideDropdown,
+ },
+ }),
+ };
+ createComponent({ stubs: dropdownStub });
await wrapper.setProps({
isVisible: true,
});
- expect(spy).toHaveBeenCalledTimes(1);
+ expect(showDropdown).toHaveBeenCalledTimes(1);
});
it('does not emit `setColor` event on dropdown hide if color did not change', () => {
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js
index d203d78477f..6c8aabe1c7f 100644
--- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js
+++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js
@@ -15,10 +15,6 @@ describe('DropdownHeader', () => {
const findButton = () => wrapper.findComponent(GlButton);
- afterEach(() => {
- wrapper.destroy();
- });
-
beforeEach(() => {
createComponent();
});
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js
index 5bbdb136353..01d3fde279b 100644
--- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js
+++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js
@@ -22,10 +22,6 @@ describe('DropdownValue', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when there is a color set', () => {
it('renders the color', () => {
expect(findColorItems()).toHaveLength(2);
@@ -35,12 +31,9 @@ describe('DropdownValue', () => {
index | cssClass
${0} | ${[]}
${1} | ${['hide-collapsed']}
- `(
- 'passes correct props to the ColorItem with CSS class `$cssClass`',
- async ({ index, cssClass }) => {
- expect(findColorItems().at(index).props()).toMatchObject(propsData.selectedColor);
- expect(findColorItems().at(index).classes()).toEqual(cssClass);
- },
- );
+ `('passes correct props to the ColorItem with CSS class `$cssClass`', ({ index, cssClass }) => {
+ expect(findColorItems().at(index).props()).toMatchObject(propsData.selectedColor);
+ expect(findColorItems().at(index).classes()).toEqual(cssClass);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js
index 1893e127f6f..62a2738d8df 100644
--- a/spec/frontend/vue_shared/components/commit_spec.js
+++ b/spec/frontend/vue_shared/components/commit_spec.js
@@ -24,10 +24,6 @@ describe('Commit component', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render a fork icon if it does not represent a tag', () => {
createComponent({
tag: false,
diff --git a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js
index 3f7ec156c19..92cd7597637 100644
--- a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js
+++ b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js
@@ -1,14 +1,11 @@
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { WorkspaceType, TYPE_ISSUE, TYPE_EPIC } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
-const createComponent = ({
- workspaceType = WorkspaceType.project,
- issuableType = TYPE_ISSUE,
-} = {}) =>
+const createComponent = ({ workspaceType = WORKSPACE_PROJECT, issuableType = TYPE_ISSUE } = {}) =>
shallowMount(ConfidentialityBadge, {
propsData: {
workspaceType,
@@ -23,14 +20,10 @@ describe('ConfidentialityBadge', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
- workspaceType | issuableType | expectedTooltip
- ${WorkspaceType.project} | ${TYPE_ISSUE} | ${'Only project members with at least the Reporter role, the author, and assignees can view or be notified about this issue.'}
- ${WorkspaceType.group} | ${TYPE_EPIC} | ${'Only group members with at least the Reporter role can view or be notified about this epic.'}
+ workspaceType | issuableType | expectedTooltip
+ ${WORKSPACE_PROJECT} | ${TYPE_ISSUE} | ${'Only project members with at least the Reporter role, the author, and assignees can view or be notified about this issue.'}
+ ${WORKSPACE_GROUP} | ${TYPE_EPIC} | ${'Only group members with at least the Reporter role can view or be notified about this epic.'}
`(
'should render gl-badge with correct tooltip when workspaceType is $workspaceType and issuableType is $issuableType',
({ workspaceType, issuableType, expectedTooltip }) => {
diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js
index a660643d74f..d7f94c00d09 100644
--- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js
@@ -24,7 +24,7 @@ describe('Confirm Danger Modal', () => {
const findAdditionalMessage = () => wrapper.findByTestId('confirm-danger-message');
const findPrimaryAction = () => findModal().props('actionPrimary');
const findCancelAction = () => findModal().props('actionCancel');
- const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr];
+ const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[attr];
const createComponent = ({ provide = {} } = {}) =>
shallowMountExtended(ConfirmDangerModal, {
@@ -42,10 +42,6 @@ describe('Confirm Danger Modal', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the default warning message', () => {
expect(findDefaultWarning().text()).toBe(CONFIRM_DANGER_WARNING);
});
diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js
index a179afccae0..e082fa4085f 100644
--- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js
@@ -32,10 +32,6 @@ describe('Confirm Danger Modal', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the button', () => {
expect(wrapper.html()).toContain(buttonText);
});
@@ -52,7 +48,7 @@ describe('Confirm Danger Modal', () => {
wrapper = createComponent({ disabled: true });
- expect(findBtn().attributes('disabled')).toBe('true');
+ expect(findBtn().attributes('disabled')).toBeDefined();
});
it('passes `buttonClass` prop to button', () => {
diff --git a/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js b/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js
index 1cde92cf522..fbfef5cbe46 100644
--- a/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js
@@ -21,10 +21,6 @@ describe('vue_shared/components/confirm_fork_modal', () => {
},
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('visible = false', () => {
beforeEach(() => {
wrapper = createComponent();
diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js
index c1e682a1aae..283ef52cee7 100644
--- a/spec/frontend/vue_shared/components/confirm_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js
@@ -47,10 +47,6 @@ describe('vue_shared/components/confirm_modal', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findModal = () => wrapper.findComponent(GlModalStub);
const findForm = () => wrapper.find('form');
const findFormData = () =>
diff --git a/spec/frontend/vue_shared/components/content_transition_spec.js b/spec/frontend/vue_shared/components/content_transition_spec.js
index 8bb6d31cce7..5f2b1f096f3 100644
--- a/spec/frontend/vue_shared/components/content_transition_spec.js
+++ b/spec/frontend/vue_shared/components/content_transition_spec.js
@@ -13,11 +13,6 @@ const TEST_SLOTS = [
describe('~/vue_shared/components/content_transition.vue', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const createComponent = (props = {}, slots = {}) => {
wrapper = shallowMount(ContentTransition, {
propsData: {
diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js
index c1495e8264a..a3e5f187f9b 100644
--- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js
+++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js
@@ -18,10 +18,6 @@ describe('DateTimePickerInput', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders label above the input', () => {
createComponent({
label: inputLabel,
diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js
index aa41df438d2..5620b569409 100644
--- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js
+++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js
@@ -26,10 +26,6 @@ describe('DateTimePicker', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders dropdown toggle button with selected text', async () => {
createComponent();
await nextTick();
diff --git a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js
index 79001b9282f..dde2540e121 100644
--- a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js
+++ b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js
@@ -17,10 +17,6 @@ describe('Deploy Board Instance', () => {
});
describe('as a non-canary deployment', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render a div with the correct css status and tooltip data', () => {
wrapper = createComponent({
tooltipText: 'This is a pod',
@@ -43,10 +39,6 @@ describe('Deploy Board Instance', () => {
});
describe('as a canary deployment', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render a div with canary class when stable prop is provided as false', async () => {
wrapper = createComponent({
stable: false,
@@ -58,10 +50,6 @@ describe('Deploy Board Instance', () => {
});
describe('as a legend item', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should not have a tooltip', () => {
wrapper = createComponent();
diff --git a/spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js b/spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js
index 353d493add9..ca9c2b7d381 100644
--- a/spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js
+++ b/spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js
@@ -16,10 +16,6 @@ describe('Design note pin component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should match the snapshot of note without index', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js
index 99c973bdd26..2a4037d76b7 100644
--- a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js
@@ -58,7 +58,6 @@ describe('Diff Stats Dropdown', () => {
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', () => {
@@ -88,40 +87,25 @@ describe('Diff Stats Dropdown', () => {
});
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'}
+ changed | added | deleted | expectedDropdownHeader | expectedAddedDeletedCollapsed
+ ${0} | ${0} | ${0} | ${'0 changed files'} | ${'with 0 additions and 0 deletions'}
+ ${2} | ${0} | ${2} | ${'2 changed files'} | ${'with 0 additions and 2 deletions'}
+ ${2} | ${2} | ${0} | ${'2 changed files'} | ${'with 2 additions and 0 deletions'}
+ ${2} | ${1} | ${1} | ${'2 changed files'} | ${'with 1 addition and 1 deletion'}
+ ${1} | ${0} | ${1} | ${'1 changed file'} | ${'with 0 additions and 1 deletion'}
+ ${1} | ${1} | ${0} | ${'1 changed file'} | ${'with 1 addition and 0 deletions'}
+ ${4} | ${2} | ${2} | ${'4 changed files'} | ${'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,
- }) => {
+ ({ changed, added, deleted, expectedDropdownHeader, expectedAddedDeletedCollapsed }) => {
beforeEach(() => {
createComponent({ changed, added, deleted });
});
- afterEach(() => {
- 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);
});
diff --git a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js
index 6e0717c29d7..694c69fbe9f 100644
--- a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js
@@ -18,10 +18,6 @@ describe('DiffViewer', () => {
wrapper = mount(DiffViewer, { propsData });
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders image diff', () => {
window.gon = {
relative_url_root: '',
diff --git a/spec/frontend/vue_shared/components/diff_viewer/utils_spec.js b/spec/frontend/vue_shared/components/diff_viewer/utils_spec.js
new file mode 100644
index 00000000000..b95e1ee283e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/diff_viewer/utils_spec.js
@@ -0,0 +1,33 @@
+import { transition } from '~/vue_shared/components/diff_viewer/utils';
+import {
+ TRANSITION_LOAD_START,
+ TRANSITION_LOAD_ERROR,
+ TRANSITION_LOAD_SUCCEED,
+ TRANSITION_ACKNOWLEDGE_ERROR,
+ STATE_IDLING,
+ STATE_LOADING,
+ STATE_ERRORED,
+} from '~/diffs/constants';
+
+describe('transition', () => {
+ it.each`
+ state | transitionEvent | result
+ ${'idle'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING}
+ ${'idle'} | ${TRANSITION_LOAD_ERROR} | ${STATE_IDLING}
+ ${'idle'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING}
+ ${'idle'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING}
+ ${'loading'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING}
+ ${'loading'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED}
+ ${'loading'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING}
+ ${'loading'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_LOADING}
+ ${'errored'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING}
+ ${'errored'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED}
+ ${'errored'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_ERRORED}
+ ${'errored'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING}
+ `(
+ 'correctly updates the state to "$result" when it starts as "$state" and the transition is "$transitionEvent"',
+ ({ state, transitionEvent, result }) => {
+ expect(transition(state, transitionEvent)).toBe(result);
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js
index 16f924b44d8..7863ef45817 100644
--- a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js
@@ -42,10 +42,6 @@ describe('ImageDiffViewer component', () => {
triggerEvent('mouseup', doc.body);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders image diff for replaced', () => {
createComponent(allProps);
const metaInfoElements = wrapper.findAll('.image-info');
diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js
index c4358f0d9cb..661db19ff0e 100644
--- a/spec/frontend/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js
+++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js
@@ -13,10 +13,6 @@ describe('Diff viewer mode changed component', () => {
});
});
- afterEach(() => {
- vm.destroy();
- });
-
it('renders aMode & bMode', () => {
expect(vm.text()).toContain('File mode changed from 123 to 321');
});
diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js
index 549388c1a5c..0d536b23c45 100644
--- a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js
+++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js
@@ -1,173 +1,119 @@
import { shallowMount, mount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import Vuex from 'vuex';
+import { GlAlert, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import * as transitionModule from '~/vue_shared/components/diff_viewer/utils';
import {
+ TRANSITION_ACKNOWLEDGE_ERROR,
TRANSITION_LOAD_START,
TRANSITION_LOAD_ERROR,
TRANSITION_LOAD_SUCCEED,
- TRANSITION_ACKNOWLEDGE_ERROR,
STATE_IDLING,
STATE_LOADING,
- STATE_ERRORED,
} from '~/diffs/constants';
import Renamed from '~/vue_shared/components/diff_viewer/viewers/renamed.vue';
Vue.use(Vuex);
-function createRenamedComponent({ props = {}, store = new Vuex.Store({}), deep = false }) {
+let wrapper;
+let store;
+let event;
+
+const DIFF_FILE_COMMIT_SHA = 'commitsha';
+const DIFF_FILE_SHORT_SHA = 'commitsh';
+const DIFF_FILE_VIEW_PATH = `blob/${DIFF_FILE_COMMIT_SHA}/filename.ext`;
+
+const defaultStore = {
+ modules: {
+ diffs: {
+ namespaced: true,
+ actions: { switchToFullDiffFromRenamedFile: jest.fn().mockResolvedValue() },
+ },
+ },
+};
+const diffFile = {
+ content_sha: DIFF_FILE_COMMIT_SHA,
+ view_path: DIFF_FILE_VIEW_PATH,
+ alternate_viewer: {
+ name: 'text',
+ },
+};
+const defaultProps = { diffFile };
+
+function createRenamedComponent({ props = {}, storeArg = defaultStore, deep = false } = {}) {
+ store = new Vuex.Store(storeArg);
const mnt = deep ? mount : shallowMount;
- return mnt(Renamed, {
- propsData: { ...props },
+ wrapper = mnt(Renamed, {
+ propsData: { ...defaultProps, ...props },
store,
});
}
-describe('Renamed Diff Viewer', () => {
- const DIFF_FILE_COMMIT_SHA = 'commitsha';
- const DIFF_FILE_SHORT_SHA = 'commitsh';
- const DIFF_FILE_VIEW_PATH = `blob/${DIFF_FILE_COMMIT_SHA}/filename.ext`;
- let diffFile;
- let wrapper;
+const findErrorAlert = () => wrapper.findComponent(GlAlert);
+const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+const findShowFullDiffBtn = () => wrapper.findComponent(GlLink);
+const findPlainText = () => wrapper.find('[test-id="plaintext"]');
+describe('Renamed Diff Viewer', () => {
beforeEach(() => {
- diffFile = {
- content_sha: DIFF_FILE_COMMIT_SHA,
- view_path: DIFF_FILE_VIEW_PATH,
- alternate_viewer: {
- name: 'text',
- },
+ event = {
+ preventDefault: jest.fn(),
};
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
- describe('is', () => {
+ describe('when clicking to load full diff', () => {
beforeEach(() => {
- wrapper = createRenamedComponent({ props: { diffFile } });
+ createRenamedComponent();
});
- it.each`
- state | request | result
- ${'idle'} | ${'idle'} | ${true}
- ${'idle'} | ${'loading'} | ${false}
- ${'idle'} | ${'errored'} | ${false}
- ${'loading'} | ${'loading'} | ${true}
- ${'loading'} | ${'idle'} | ${false}
- ${'loading'} | ${'errored'} | ${false}
- ${'errored'} | ${'errored'} | ${true}
- ${'errored'} | ${'idle'} | ${false}
- ${'errored'} | ${'loading'} | ${false}
- `(
- 'returns the $result for "$request" when the state is "$state"',
- ({ request, result, state }) => {
- wrapper.vm.state = state;
+ it('shows a loading state', async () => {
+ expect(findLoadingIcon().exists()).toBe(false);
- expect(wrapper.vm.is(request)).toEqual(result);
- },
- );
- });
+ await findShowFullDiffBtn().vm.$emit('click', event);
- describe('transition', () => {
- beforeEach(() => {
- wrapper = createRenamedComponent({ props: { diffFile } });
+ expect(findLoadingIcon().exists()).toBe(true);
});
- it.each`
- state | transition | result
- ${'idle'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING}
- ${'idle'} | ${TRANSITION_LOAD_ERROR} | ${STATE_IDLING}
- ${'idle'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING}
- ${'idle'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING}
- ${'loading'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING}
- ${'loading'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED}
- ${'loading'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING}
- ${'loading'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_LOADING}
- ${'errored'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING}
- ${'errored'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED}
- ${'errored'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_ERRORED}
- ${'errored'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING}
- `(
- 'correctly updates the state to "$result" when it starts as "$state" and the transition is "$transition"',
- ({ state, transition, result }) => {
- wrapper.vm.state = state;
-
- wrapper.vm.transition(transition);
-
- expect(wrapper.vm.state).toEqual(result);
- },
- );
- });
-
- describe('switchToFull', () => {
- let store;
-
- beforeEach(() => {
- store = new Vuex.Store({
- modules: {
- diffs: {
- namespaced: true,
- actions: { switchToFullDiffFromRenamedFile: () => {} },
- },
- },
- });
-
+ it('calls the switchToFullDiffFromRenamedFile action when the method is triggered', () => {
jest.spyOn(store, 'dispatch');
- wrapper = createRenamedComponent({ props: { diffFile }, store });
- });
-
- afterEach(() => {
- store = null;
- });
-
- it('calls the switchToFullDiffFromRenamedFile action when the method is triggered', async () => {
- store.dispatch.mockResolvedValue();
-
- wrapper.vm.switchToFull();
+ findShowFullDiffBtn().vm.$emit('click', event);
- await nextTick();
expect(store.dispatch).toHaveBeenCalledWith('diffs/switchToFullDiffFromRenamedFile', {
diffFile,
});
});
it.each`
- after | resolvePromise | resolution
- ${STATE_IDLING} | ${'mockResolvedValue'} | ${'successful'}
- ${STATE_ERRORED} | ${'mockRejectedValue'} | ${'rejected'}
+ after | resolvePromise | resolution
+ ${TRANSITION_LOAD_SUCCEED} | ${'mockResolvedValue'} | ${'successful'}
+ ${TRANSITION_LOAD_ERROR} | ${'mockRejectedValue'} | ${'rejected'}
`(
'moves through the correct states during a $resolution request',
async ({ after, resolvePromise }) => {
- store.dispatch[resolvePromise]();
+ jest.spyOn(transitionModule, 'transition');
+ store.dispatch = jest.fn()[resolvePromise]();
- expect(wrapper.vm.state).toEqual(STATE_IDLING);
+ expect(transitionModule.transition).not.toHaveBeenCalled();
- wrapper.vm.switchToFull();
+ findShowFullDiffBtn().vm.$emit('click', event);
- expect(wrapper.vm.state).toEqual(STATE_LOADING);
+ expect(transitionModule.transition).toHaveBeenCalledWith(
+ STATE_IDLING,
+ TRANSITION_LOAD_START,
+ );
+
+ await waitForPromises();
- await nextTick(); // This tick is needed for when the action (promise) finishes
- await nextTick(); // This tick waits for the state change in the promise .then/.catch to bubble into the component
- expect(wrapper.vm.state).toEqual(after);
+ expect(transitionModule.transition).toHaveBeenCalledTimes(2);
+ expect(transitionModule.transition.mock.calls[1]).toEqual([STATE_LOADING, after]);
},
);
});
describe('clickLink', () => {
- let event;
-
- beforeEach(() => {
- event = {
- preventDefault: jest.fn(),
- };
- });
-
it.each`
alternateViewer | stops | handled
${'text'} | ${true} | ${'should'}
@@ -175,42 +121,51 @@ describe('Renamed Diff Viewer', () => {
`(
'given { alternate_viewer: { name: "$alternateViewer" } }, the click event $handled be handled in the component',
({ alternateViewer, stops }) => {
- wrapper = createRenamedComponent({
- props: {
- diffFile: {
- ...diffFile,
- alternate_viewer: { name: alternateViewer },
- },
+ const props = {
+ diffFile: {
+ ...diffFile,
+ alternate_viewer: { name: alternateViewer },
},
+ };
+
+ createRenamedComponent({
+ props,
});
- jest.spyOn(wrapper.vm, 'switchToFull').mockImplementation(() => {});
+ store.dispatch = jest.fn().mockResolvedValue();
- wrapper.vm.clickLink(event);
+ findShowFullDiffBtn().vm.$emit('click', event);
if (stops) {
expect(event.preventDefault).toHaveBeenCalled();
- expect(wrapper.vm.switchToFull).toHaveBeenCalled();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'diffs/switchToFullDiffFromRenamedFile',
+ props,
+ );
} else {
expect(event.preventDefault).not.toHaveBeenCalled();
- expect(wrapper.vm.switchToFull).not.toHaveBeenCalled();
+ expect(store.dispatch).not.toHaveBeenCalled();
}
},
);
});
describe('dismissError', () => {
- let transitionSpy;
-
beforeEach(() => {
- wrapper = createRenamedComponent({ props: { diffFile } });
- transitionSpy = jest.spyOn(wrapper.vm, 'transition');
+ createRenamedComponent({ props: { diffFile } });
});
it(`transitions the component with "${TRANSITION_ACKNOWLEDGE_ERROR}"`, () => {
- wrapper.vm.dismissError();
+ jest.spyOn(transitionModule, 'transition');
+
+ expect(transitionModule.transition).not.toHaveBeenCalled();
+
+ findErrorAlert().vm.$emit('dismiss');
- expect(transitionSpy).toHaveBeenCalledWith(TRANSITION_ACKNOWLEDGE_ERROR);
+ expect(transitionModule.transition).toHaveBeenCalledWith(
+ expect.stringContaining(''),
+ TRANSITION_ACKNOWLEDGE_ERROR,
+ );
});
});
@@ -224,14 +179,19 @@ describe('Renamed Diff Viewer', () => {
`(
'with { alternate_viewer: { name: $nameDisplay } }, renders the component',
({ altViewer }) => {
- const file = { ...diffFile };
-
- file.alternate_viewer.name = altViewer;
- wrapper = createRenamedComponent({ props: { diffFile: file } });
+ createRenamedComponent({
+ props: {
+ diffFile: {
+ ...diffFile,
+ alternate_viewer: {
+ ...diffFile.alternate_viewer,
+ name: altViewer,
+ },
+ },
+ },
+ });
- expect(wrapper.find('[test-id="plaintext"]').text()).toEqual(
- 'File renamed with no changes.',
- );
+ expect(findPlainText().text()).toBe('File renamed with no changes.');
},
);
@@ -245,15 +205,15 @@ describe('Renamed Diff Viewer', () => {
const file = { ...diffFile };
file.alternate_viewer.name = altType;
- wrapper = createRenamedComponent({
+ createRenamedComponent({
deep: true,
props: { diffFile: file },
});
- const link = wrapper.find('a');
+ const link = findShowFullDiffBtn();
- expect(link.text()).toEqual(linkText);
- expect(link.attributes('href')).toEqual(DIFF_FILE_VIEW_PATH);
+ expect(link.text()).toBe(linkText);
+ expect(link.attributes('href')).toBe(DIFF_FILE_VIEW_PATH);
},
);
});
diff --git a/spec/frontend/vue_shared/components/dismissible_alert_spec.js b/spec/frontend/vue_shared/components/dismissible_alert_spec.js
index 8b1189f25d5..53e7d9fc7fc 100644
--- a/spec/frontend/vue_shared/components/dismissible_alert_spec.js
+++ b/spec/frontend/vue_shared/components/dismissible_alert_spec.js
@@ -16,10 +16,6 @@ describe('vue_shared/components/dismissible_alert', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findAlert = () => wrapper.findComponent(GlAlert);
describe('default', () => {
diff --git a/spec/frontend/vue_shared/components/dismissible_container_spec.js b/spec/frontend/vue_shared/components/dismissible_container_spec.js
index 7d8581e11e9..6d179434d1d 100644
--- a/spec/frontend/vue_shared/components/dismissible_container_spec.js
+++ b/spec/frontend/vue_shared/components/dismissible_container_spec.js
@@ -11,10 +11,6 @@ describe('DismissibleContainer', () => {
featureId: 'some-feature-id',
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
const findBtn = () => wrapper.find('[data-testid="close"]');
let mockAxios;
diff --git a/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js b/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js
index 4b32fbffebe..463fd74f582 100644
--- a/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js
+++ b/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js
@@ -12,10 +12,11 @@ describe('Dismissible Feedback Alert', () => {
const featureName = 'Dependency List';
const STORAGE_DISMISSAL_KEY = 'dependency_list_feedback_dismissed';
- const createComponent = ({ mountFn = shallowMount } = {}) => {
+ const createComponent = ({ props, mountFn = shallowMount } = {}) => {
wrapper = mountFn(Component, {
propsData: {
featureName,
+ ...props,
},
stubs: {
GlSprintf,
@@ -23,11 +24,6 @@ describe('Dismissible Feedback Alert', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const createFullComponent = () => createComponent({ mountFn: mount });
const findAlert = () => wrapper.findComponent(GlAlert);
@@ -45,6 +41,27 @@ describe('Dismissible Feedback Alert', () => {
});
});
+ describe('with other attributes', () => {
+ const mockTitle = 'My title';
+ const mockVariant = 'warning';
+
+ beforeEach(() => {
+ createComponent({
+ props: {
+ title: mockTitle,
+ variant: mockVariant,
+ },
+ });
+ });
+
+ it('passes props to alert', () => {
+ expect(findAlert().props()).toMatchObject({
+ title: mockTitle,
+ variant: mockVariant,
+ });
+ });
+ });
+
describe('dismissible', () => {
describe('after dismissal', () => {
beforeEach(() => {
diff --git a/spec/frontend/vue_shared/components/dom_element_listener_spec.js b/spec/frontend/vue_shared/components/dom_element_listener_spec.js
index a848c34b7ce..d31e9b867e4 100644
--- a/spec/frontend/vue_shared/components/dom_element_listener_spec.js
+++ b/spec/frontend/vue_shared/components/dom_element_listener_spec.js
@@ -42,10 +42,6 @@ describe('~/vue_shared/components/dom_element_listener.vue', () => {
};
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
index e34ed31b4bf..82130500458 100644
--- a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
@@ -11,10 +11,6 @@ describe('DropdownButton component', () => {
wrapper = mount(DropdownButton, { propsData: props, slots });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('computed', () => {
describe('dropdownToggleText', () => {
it('returns default toggle text', () => {
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js
index dd3e55c82bb..dd5a05a40c6 100644
--- a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js
@@ -31,10 +31,6 @@ describe('DropdownWidget component', () => {
},
});
- // We need to mock out `showDropdown` which
- // invokes `show` method of BDropdown used inside GlDropdown.
- // Context: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54895#note_524281679
- jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
jest.spyOn(findDropdown().vm, 'hide').mockImplementation();
};
@@ -42,11 +38,6 @@ describe('DropdownWidget component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('passes default selectText prop to dropdown', () => {
expect(findDropdown().props('text')).toBe('Select');
});
@@ -64,7 +55,7 @@ describe('DropdownWidget component', () => {
expect(wrapper.emitted('set-search')).toEqual([[searchTerm]]);
});
- it('renders one selectable item per passed option', async () => {
+ it('renders one selectable item per passed option', () => {
expect(findDropdownItems()).toHaveLength(2);
});
diff --git a/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js b/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js
index 119d6448507..4708a5555f8 100644
--- a/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js
+++ b/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js
@@ -39,16 +39,12 @@ describe('DropdownKeyboardNavigation', () => {
},
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('onInit', () => {
beforeEach(() => {
createComponent();
});
- it('should $emit @change with the default index', async () => {
+ it('should $emit @change with the default index', () => {
expect(wrapper.emitted('change')[0]).toStrictEqual([MOCK_DEFAULT_INDEX]);
});
@@ -104,6 +100,25 @@ describe('DropdownKeyboardNavigation', () => {
describe.each`
keyboardAction | direction | index | max | min
+ ${helpers.arrowDown} | ${1} | ${10} | ${10} | ${0}
+ ${helpers.arrowUp} | ${-1} | ${0} | ${10} | ${0}
+ `(
+ 'moving out of bounds with cycle enabled',
+ ({ keyboardAction, direction, index, max, min }) => {
+ beforeEach(() => {
+ createComponent({ index, max, min, enableCycle: true });
+ keyboardAction();
+ });
+
+ it(`in ${direction} direction does $emit correct @change event`, () => {
+ // The first @change`call happens on created() so we test that we only have 1 call
+ expect(wrapper.emitted('change')[1]).toStrictEqual([direction === 1 ? min : max]);
+ });
+ },
+ );
+
+ describe.each`
+ keyboardAction | direction | index | max | min
${helpers.arrowDown} | ${1} | ${0} | ${10} | ${0}
${helpers.arrowUp} | ${-1} | ${10} | ${10} | ${0}
`('moving in bounds', ({ keyboardAction, direction, index, max, min }) => {
diff --git a/spec/frontend/vue_shared/components/ensure_data_spec.js b/spec/frontend/vue_shared/components/ensure_data_spec.js
index eef8b452f5f..217e795bc64 100644
--- a/spec/frontend/vue_shared/components/ensure_data_spec.js
+++ b/spec/frontend/vue_shared/components/ensure_data_spec.js
@@ -59,7 +59,6 @@ describe('EnsureData', () => {
});
afterEach(() => {
- wrapper.destroy();
Sentry.captureException.mockClear();
});
diff --git a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js
index 6b98f6c5e89..6e2e854adae 100644
--- a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js
+++ b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js
@@ -122,6 +122,12 @@ describe('EntitySelect', () => {
});
describe('once a group is selected', () => {
+ it('emits `input` event with the select value', async () => {
+ createComponent();
+ await selectGroup();
+ expect(wrapper.emitted('input')[0]).toEqual(['1']);
+ });
+
it(`uses the selected group's name as the toggle text`, async () => {
createComponent();
await selectGroup();
@@ -146,6 +152,16 @@ describe('EntitySelect', () => {
expect(findListbox().props('toggleText')).toBe(defaultToggleText);
});
+
+ it('emits `input` event with `null` on reset', async () => {
+ createComponent();
+ await selectGroup();
+
+ findListbox().vm.$emit('reset');
+ await nextTick();
+
+ expect(wrapper.emitted('input')[2]).toEqual([null]);
+ });
});
});
@@ -201,7 +217,7 @@ describe('EntitySelect', () => {
describe('pagination', () => {
const searchString = 'searchString';
- beforeEach(async () => {
+ beforeEach(() => {
let requestCount = 0;
fetchItemsMock.mockImplementation((searchQuery, page) => {
requestCount += 1;
diff --git a/spec/frontend/vue_shared/components/entity_select/project_select_spec.js b/spec/frontend/vue_shared/components/entity_select/project_select_spec.js
index 57dce032d30..0a174c98efb 100644
--- a/spec/frontend/vue_shared/components/entity_select/project_select_spec.js
+++ b/spec/frontend/vue_shared/components/entity_select/project_select_spec.js
@@ -63,11 +63,8 @@ describe('ProjectSelect', () => {
};
const openListbox = () => findListbox().vm.$emit('shown');
- beforeAll(() => {
- gon.api_version = apiVersion;
- });
-
beforeEach(() => {
+ gon.api_version = apiVersion;
mock = new MockAdapter(axios);
});
@@ -100,6 +97,7 @@ describe('ProjectSelect', () => {
${'defaultToggleText'} | ${PROJECT_TOGGLE_TEXT}
${'headerText'} | ${PROJECT_HEADER_TEXT}
${'clearable'} | ${true}
+ ${'block'} | ${false}
`('passes the $prop prop to entity-select', ({ prop, expectedValue }) => {
expect(findEntitySelect().props(prop)).toBe(expectedValue);
});
@@ -139,6 +137,18 @@ describe('ProjectSelect', () => {
expect(mock.history.get[0].params.include_subgroups).toBe(true);
});
+ it('does not include shared projects if withShared prop is false', async () => {
+ createComponent({
+ props: {
+ withShared: false,
+ },
+ });
+ openListbox();
+ await waitForPromises();
+
+ expect(mock.history.get[0].params.with_shared).toBe(false);
+ });
+
it('fetches projects globally if no group ID is provided', async () => {
createComponent({
props: {
diff --git a/spec/frontend/vue_shared/components/expand_button_spec.js b/spec/frontend/vue_shared/components/expand_button_spec.js
index 170c947e520..ad2a57d90eb 100644
--- a/spec/frontend/vue_shared/components/expand_button_spec.js
+++ b/spec/frontend/vue_shared/components/expand_button_spec.js
@@ -27,10 +27,6 @@ describe('Expand button', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the prepended collapse button', () => {
expect(expanderPrependEl().isVisible()).toBe(true);
expect(expanderAppendEl().isVisible()).toBe(false);
diff --git a/spec/frontend/vue_shared/components/file_finder/index_spec.js b/spec/frontend/vue_shared/components/file_finder/index_spec.js
index 5cf891a2e52..bb0b12d205a 100644
--- a/spec/frontend/vue_shared/components/file_finder/index_spec.js
+++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js
@@ -1,244 +1,215 @@
-import Mousetrap from 'mousetrap';
-import Vue, { nextTick } from 'vue';
-import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import VirtualList from 'vue-virtual-scroll-list';
+import { Mousetrap } from '~/lib/mousetrap';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { file } from 'jest/ide/helpers';
-import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import FindFileComponent from '~/vue_shared/components/file_finder/index.vue';
+import FileFinderItem from '~/vue_shared/components/file_finder/item.vue';
+import { setHTMLFixture } from 'helpers/fixtures';
describe('File finder item spec', () => {
- const Component = Vue.extend(FindFileComponent);
- let vm;
+ let wrapper;
+
+ const TEST_FILES = [
+ {
+ ...file('index.js'),
+ path: 'index.js',
+ type: 'blob',
+ url: '/index.jsurl',
+ },
+ {
+ ...file('component.js'),
+ path: 'component.js',
+ type: 'blob',
+ },
+ ];
function createComponent(props) {
- vm = new Component({
+ wrapper = mountExtended(FindFileComponent, {
+ attachTo: document.body,
propsData: {
- files: [],
+ files: TEST_FILES,
visible: true,
loading: false,
...props,
},
});
-
- vm.$mount('#app');
}
- beforeEach(() => {
- setHTMLFixture('<div id="app"></div>');
- });
-
- afterEach(() => {
- resetHTMLFixture();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
+ const findAllFileFinderItems = () => wrapper.findAllComponents(FileFinderItem);
+ const findSearchInput = () => wrapper.findByTestId('search-input');
+ const enterSearchText = (text) => findSearchInput().setValue(text);
+ const clearSearch = () => wrapper.findByTestId('clear-search-input').vm.$emit('click');
describe('with entries', () => {
beforeEach(() => {
createComponent({
- files: [
- {
- ...file('index.js'),
- path: 'index.js',
- type: 'blob',
- url: '/index.jsurl',
- },
- {
- ...file('component.js'),
- path: 'component.js',
- type: 'blob',
- },
- ],
+ files: TEST_FILES,
});
return nextTick();
});
it('renders list of blobs', () => {
- expect(vm.$el.textContent).toContain('index.js');
- expect(vm.$el.textContent).toContain('component.js');
- expect(vm.$el.textContent).not.toContain('folder');
+ expect(wrapper.text()).toContain('index.js');
+ expect(wrapper.text()).toContain('component.js');
+ expect(wrapper.text()).not.toContain('folder');
});
it('filters entries', async () => {
- vm.searchText = 'index';
-
- await nextTick();
+ await enterSearchText('index');
- expect(vm.$el.textContent).toContain('index.js');
- expect(vm.$el.textContent).not.toContain('component.js');
+ expect(wrapper.text()).toContain('index.js');
+ expect(wrapper.text()).not.toContain('component.js');
});
it('shows clear button when searchText is not empty', async () => {
- vm.searchText = 'index';
-
- await nextTick();
+ await enterSearchText('index');
- expect(vm.$el.querySelector('.dropdown-input').classList).toContain('has-value');
- expect(vm.$el.querySelector('.dropdown-input-search').classList).toContain('hidden');
+ expect(wrapper.find('.dropdown-input').classes()).toContain('has-value');
+ expect(wrapper.find('.dropdown-input-search').classes()).toContain('hidden');
});
it('clear button resets searchText', async () => {
- vm.searchText = 'index';
+ await enterSearchText('index');
+ expect(findSearchInput().element.value).toBe('index');
- vm.clearSearchInput();
+ await clearSearch();
- expect(vm.searchText).toBe('');
+ expect(findSearchInput().element.value).toBe('');
});
it('clear button focuses search input', async () => {
- jest.spyOn(vm.$refs.searchInput, 'focus').mockImplementation(() => {});
- vm.searchText = 'index';
+ expect(findSearchInput().element).not.toBe(document.activeElement);
- vm.clearSearchInput();
+ await enterSearchText('index');
+ await clearSearch();
- await nextTick();
-
- expect(vm.$refs.searchInput.focus).toHaveBeenCalled();
+ expect(findSearchInput().element).toBe(document.activeElement);
});
describe('listShowCount', () => {
- it('returns 1 when no filtered entries exist', () => {
- vm.searchText = 'testing 123';
+ it('returns 1 when no filtered entries exist', async () => {
+ await enterSearchText('testing 123');
- expect(vm.listShowCount).toBe(1);
+ expect(wrapper.findComponent(VirtualList).props('remain')).toBe(1);
});
it('returns entries length when not filtered', () => {
- expect(vm.listShowCount).toBe(2);
+ expect(wrapper.findComponent(VirtualList).props('remain')).toBe(2);
});
});
- describe('filteredBlobsLength', () => {
- it('returns length of filtered blobs', () => {
- vm.searchText = 'index';
+ describe('filtering', () => {
+ it('renders only items that match the filter', async () => {
+ await enterSearchText('index');
- expect(vm.filteredBlobsLength).toBe(1);
+ expect(findAllFileFinderItems()).toHaveLength(1);
});
});
describe('DOM Performance', () => {
it('renders less DOM nodes if not visible by utilizing v-if', async () => {
- vm.visible = false;
+ createComponent({ visible: false });
await nextTick();
- expect(vm.$el).toBeInstanceOf(Comment);
+ expect(wrapper.findByTestId('overlay').exists()).toBe(false);
});
});
describe('watches', () => {
describe('searchText', () => {
it('resets focusedIndex when updated', async () => {
- vm.focusedIndex = 1;
- vm.searchText = 'test';
-
+ await enterSearchText('index');
await nextTick();
- expect(vm.focusedIndex).toBe(0);
+ expect(findAllFileFinderItems().at(0).props('focused')).toBe(true);
});
});
describe('visible', () => {
it('resets searchText when changed to false', async () => {
- vm.searchText = 'test';
- vm.visible = false;
-
- await nextTick();
+ await enterSearchText('test');
+ await wrapper.setProps({ visible: false });
+ // need to set it back to true, so the component's content renders
+ await wrapper.setProps({ visible: true });
- expect(vm.searchText).toBe('');
+ expect(findSearchInput().element.value).toBe('');
});
});
});
describe('openFile', () => {
- beforeEach(() => {
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
- });
-
it('closes file finder', () => {
- vm.openFile(vm.files[0]);
+ expect(wrapper.emitted('toggle')).toBeUndefined();
- expect(vm.$emit).toHaveBeenCalledWith('toggle', false);
+ findSearchInput().trigger('keyup.enter');
+
+ expect(wrapper.emitted('toggle')).toHaveLength(1);
});
it('pushes to router', () => {
- vm.openFile(vm.files[0]);
+ expect(wrapper.emitted('click')).toBeUndefined();
+
+ findSearchInput().trigger('keyup.enter');
- expect(vm.$emit).toHaveBeenCalledWith('click', vm.files[0]);
+ expect(wrapper.emitted('click')).toHaveLength(1);
});
});
describe('onKeyup', () => {
it('opens file on enter key', async () => {
- const event = new CustomEvent('keyup');
- event.keyCode = ENTER_KEY_CODE;
+ expect(wrapper.emitted('click')).toBeUndefined();
- jest.spyOn(vm, 'openFile').mockImplementation(() => {});
+ await findSearchInput().trigger('keyup.enter');
- vm.$refs.searchInput.dispatchEvent(event);
-
- await nextTick();
-
- expect(vm.openFile).toHaveBeenCalledWith(vm.files[0]);
+ expect(wrapper.emitted('click')[0][0]).toBe(TEST_FILES[0]);
});
it('closes file finder on esc key', async () => {
- const event = new CustomEvent('keyup');
- event.keyCode = ESC_KEY_CODE;
-
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
-
- vm.$refs.searchInput.dispatchEvent(event);
+ expect(wrapper.emitted('toggle')).toBeUndefined();
- await nextTick();
+ await findSearchInput().trigger('keyup.esc');
- expect(vm.$emit).toHaveBeenCalledWith('toggle', false);
+ expect(wrapper.emitted('toggle')[0][0]).toBe(false);
});
});
describe('onKeyDown', () => {
- let el;
-
- beforeEach(() => {
- el = vm.$refs.searchInput;
- });
-
describe('up key', () => {
- const event = new CustomEvent('keydown');
- event.keyCode = UP_KEY_CODE;
+ it('resets to last index when at top', async () => {
+ expect(findAllFileFinderItems().at(0).props('focused')).toBe(true);
- it('resets to last index when at top', () => {
- el.dispatchEvent(event);
+ await findSearchInput().trigger('keydown.up');
- expect(vm.focusedIndex).toBe(1);
+ expect(findAllFileFinderItems().at(-1).props('focused')).toBe(true);
});
- it('minus 1 from focusedIndex', () => {
- vm.focusedIndex = 1;
-
- el.dispatchEvent(event);
+ it('minus 1 from focusedIndex', async () => {
+ await findSearchInput().trigger('keydown.up');
+ await findSearchInput().trigger('keydown.up');
- expect(vm.focusedIndex).toBe(0);
+ expect(findAllFileFinderItems().at(0).props('focused')).toBe(true);
});
});
describe('down key', () => {
- const event = new CustomEvent('keydown');
- event.keyCode = DOWN_KEY_CODE;
+ it('resets to first index when at bottom', async () => {
+ await findSearchInput().trigger('keydown.down');
+ expect(findAllFileFinderItems().at(-1).props('focused')).toBe(true);
- it('resets to first index when at bottom', () => {
- vm.focusedIndex = 1;
- el.dispatchEvent(event);
-
- expect(vm.focusedIndex).toBe(0);
+ await findSearchInput().trigger('keydown.down');
+ expect(findAllFileFinderItems().at(0).props('focused')).toBe(true);
});
- it('adds 1 to focusedIndex', () => {
- el.dispatchEvent(event);
+ it('adds 1 to focusedIndex', async () => {
+ expect(findAllFileFinderItems().at(0).props('focused')).toBe(true);
+
+ await findSearchInput().trigger('keydown.down');
- expect(vm.focusedIndex).toBe(1);
+ expect(findAllFileFinderItems().at(1).props('focused')).toBe(true);
});
});
});
@@ -246,46 +217,45 @@ describe('File finder item spec', () => {
describe('without entries', () => {
it('renders loading text when loading', () => {
- createComponent({ loading: true });
+ createComponent({ loading: true, files: [] });
- expect(vm.$el.querySelector('.gl-spinner')).not.toBe(null);
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('renders no files text', () => {
- createComponent();
+ createComponent({ files: [] });
- expect(vm.$el.textContent).toContain('No files found.');
+ expect(wrapper.text()).toContain('No files found.');
});
});
describe('keyboard shortcuts', () => {
beforeEach(async () => {
createComponent();
-
- jest.spyOn(vm, 'toggle').mockImplementation(() => {});
-
await nextTick();
});
- it('calls toggle on `t` key press', async () => {
+ it('calls toggle on `t` key press', () => {
+ expect(wrapper.emitted('toggle')).toBeUndefined();
+
Mousetrap.trigger('t');
- await nextTick();
- expect(vm.toggle).toHaveBeenCalled();
+ expect(wrapper.emitted('toggle')).not.toBeUndefined();
});
- it('calls toggle on `mod+p` key press', async () => {
+ it('calls toggle on `mod+p` key press', () => {
+ expect(wrapper.emitted('toggle')).toBeUndefined();
+
Mousetrap.trigger('mod+p');
- await nextTick();
- expect(vm.toggle).toHaveBeenCalled();
+ expect(wrapper.emitted('toggle')).not.toBeUndefined();
});
it('always allows `mod+p` to trigger toggle', () => {
expect(
Mousetrap.prototype.stopCallback(
null,
- vm.$el.querySelector('.dropdown-input-field'),
+ wrapper.find('.dropdown-input-field').element,
'mod+p',
),
).toBe(false);
@@ -293,7 +263,7 @@ describe('File finder item spec', () => {
it('onlys handles `t` when focused in input-field', () => {
expect(
- Mousetrap.prototype.stopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'),
+ Mousetrap.prototype.stopCallback(null, wrapper.find('.dropdown-input-field').element, 't'),
).toBe(true);
});
diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js
index f0998b1b5c6..c73f14e9c6e 100644
--- a/spec/frontend/vue_shared/components/file_finder/item_spec.js
+++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js
@@ -22,10 +22,6 @@ describe('File finder item spec', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders file name & path', () => {
createComponent();
@@ -40,7 +36,7 @@ describe('File finder item spec', () => {
expect(wrapper.classes()).toContain('is-focused');
});
- it('does not have is-focused class when not focused', async () => {
+ it('does not have is-focused class when not focused', () => {
createComponent({ focused: false });
expect(wrapper.classes()).not.toContain('is-focused');
@@ -54,13 +50,13 @@ describe('File finder item spec', () => {
expect(wrapper.find('.diff-changed-stats').exists()).toBe(false);
});
- it('renders when a changed file', async () => {
+ it('renders when a changed file', () => {
createComponent({ file: { changed: true } });
expect(wrapper.find('.diff-changed-stats').exists()).toBe(true);
});
- it('renders when a temp file', async () => {
+ it('renders when a temp file', () => {
createComponent({ file: { tempFile: true } });
expect(wrapper.find('.diff-changed-stats').exists()).toBe(true);
@@ -84,7 +80,7 @@ describe('File finder item spec', () => {
expect(findChangedFilePath().findAll('.highlighted')).toHaveLength(4);
});
- it('adds ellipsis to long text', async () => {
+ it('adds ellipsis to long text', () => {
const path = new Array(70)
.fill()
.map((_, i) => `${i}-`)
@@ -105,7 +101,7 @@ describe('File finder item spec', () => {
expect(findChangedFileName().findAll('.highlighted')).toHaveLength(4);
});
- it('does not add ellipsis to long text', async () => {
+ it('does not add ellipsis to long text', () => {
const name = new Array(70)
.fill()
.map((_, i) => `${i}-`)
diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js
index 0fcc0678c13..d95773f2218 100644
--- a/spec/frontend/vue_shared/components/file_icon_spec.js
+++ b/spec/frontend/vue_shared/components/file_icon_spec.js
@@ -16,10 +16,6 @@ describe('File Icon component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render a span element and an icon', () => {
createComponent({
fileName: 'test.js',
diff --git a/spec/frontend/vue_shared/components/file_row_header_spec.js b/spec/frontend/vue_shared/components/file_row_header_spec.js
index 80f4c275dcc..885a80f73b5 100644
--- a/spec/frontend/vue_shared/components/file_row_header_spec.js
+++ b/spec/frontend/vue_shared/components/file_row_header_spec.js
@@ -1,36 +1,24 @@
import { shallowMount } from '@vue/test-utils';
+import { GlTruncate } from '@gitlab/ui';
import FileRowHeader from '~/vue_shared/components/file_row_header.vue';
describe('File row header component', () => {
- let vm;
+ let wrapper;
function createComponent(path) {
- vm = shallowMount(FileRowHeader, {
+ wrapper = shallowMount(FileRowHeader, {
propsData: {
path,
},
});
}
- afterEach(() => {
- vm.destroy();
- });
-
it('renders file path', () => {
- createComponent('app/assets');
-
- expect(vm.element).toMatchSnapshot();
- });
-
- it('trucates path after 40 characters', () => {
- createComponent('app/assets/javascripts/merge_requests');
-
- expect(vm.element).toMatchSnapshot();
- });
-
- it('adds multiple ellipsises after 40 characters', () => {
- createComponent('app/assets/javascripts/merge_requests/widget/diffs/notes');
+ const path = 'app/assets';
+ createComponent(path);
- expect(vm.element).toMatchSnapshot();
+ const truncate = wrapper.findComponent(GlTruncate);
+ expect(truncate.exists()).toBe(true);
+ expect(truncate.props('text')).toBe(path);
});
});
diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js
index b70d4565f56..976866af27c 100644
--- a/spec/frontend/vue_shared/components/file_row_spec.js
+++ b/spec/frontend/vue_shared/components/file_row_spec.js
@@ -6,6 +6,9 @@ import FileIcon from '~/vue_shared/components/file_icon.vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
+const scrollIntoViewMock = jest.fn();
+HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
+
describe('File row component', () => {
let wrapper;
@@ -18,10 +21,6 @@ describe('File row component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders name', () => {
const fileName = 't4';
createComponent({
@@ -72,11 +71,10 @@ describe('File row component', () => {
},
level: 0,
});
- jest.spyOn(wrapper.vm, '$emit');
wrapper.element.click();
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('toggleTreeOpen', fileName);
+ expect(wrapper.emitted('toggleTreeOpen')[0][0]).toEqual(fileName);
});
it('calls scrollIntoView if made active', () => {
@@ -89,14 +87,12 @@ describe('File row component', () => {
level: 0,
});
- jest.spyOn(wrapper.vm, 'scrollIntoView');
-
wrapper.setProps({
file: { ...wrapper.props('file'), active: true },
});
return nextTick().then(() => {
- expect(wrapper.vm.scrollIntoView).toHaveBeenCalled();
+ expect(scrollIntoViewMock).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/vue_shared/components/file_tree_spec.js b/spec/frontend/vue_shared/components/file_tree_spec.js
index e8818e09dc0..9d2fa369910 100644
--- a/spec/frontend/vue_shared/components/file_tree_spec.js
+++ b/spec/frontend/vue_shared/components/file_tree_spec.js
@@ -33,10 +33,6 @@ describe('File Tree component', () => {
...pick(x.attributes(), Object.keys(TEST_EXTA_ARGS)),
}));
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('file row component', () => {
beforeEach(() => {
createComponent({ file: {} });
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
index b0e393bbf5e..f576121fc18 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
@@ -82,10 +82,6 @@ describe('FilteredSearchBarRoot', () => {
wrapper = createComponent({ sortOptions: mockSortOptions });
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('data', () => {
it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props and displays the sort dropdown', () => {
expect(wrapper.vm.filterValue).toEqual([]);
@@ -402,7 +398,7 @@ describe('FilteredSearchBarRoot', () => {
expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems);
});
- it('renders checkbox when `showCheckbox` prop is true', async () => {
+ it('renders checkbox when `showCheckbox` prop is true', () => {
let wrapperWithCheckbox = createComponent({
showCheckbox: true,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js
index 63c22aff3d5..dd0ec65c871 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js
@@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_OK, HTTP_STATUS_SERVICE_UNAVAILABLE } from '~/lib/utils/http_status';
import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions';
import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types';
@@ -15,7 +15,7 @@ const labelsEndpoint = 'fake_labels_endpoint';
const groupEndpoint = 'fake_group_endpoint';
const projectEndpoint = 'fake_project_endpoint';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Filters actions', () => {
let state;
@@ -165,16 +165,10 @@ describe('Filters actions', () => {
});
describe('fetchAuthors', () => {
- let restoreVersion;
beforeEach(() => {
- restoreVersion = gon.api_version;
gon.api_version = 'v1';
});
- afterEach(() => {
- gon.api_version = restoreVersion;
- });
-
describe('success', () => {
beforeEach(() => {
mock.onAny().replyOnce(HTTP_STATUS_OK, filterUsers);
@@ -305,17 +299,11 @@ describe('Filters actions', () => {
describe('fetchAssignees', () => {
describe('success', () => {
- let restoreVersion;
beforeEach(() => {
mock.onAny().replyOnce(HTTP_STATUS_OK, filterUsers);
- restoreVersion = gon.api_version;
gon.api_version = 'v1';
});
- afterEach(() => {
- gon.api_version = restoreVersion;
- });
-
it('dispatches RECEIVE_ASSIGNEES_SUCCESS with received data and groupEndpoint set', () => {
return testAction(
actions.fetchAssignees,
@@ -350,17 +338,11 @@ describe('Filters actions', () => {
});
describe('error', () => {
- let restoreVersion;
beforeEach(() => {
mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE);
- restoreVersion = gon.api_version;
gon.api_version = 'v1';
});
- afterEach(() => {
- gon.api_version = restoreVersion;
- });
-
it('dispatches RECEIVE_ASSIGNEES_ERROR and groupEndpoint set', () => {
return testAction(
actions.fetchAssignees,
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index 164235e4bb9..d87aa3194d2 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -18,6 +18,7 @@ import {
OPTIONS_NONE_ANY,
OPERATOR_IS,
OPERATOR_NOT,
+ OPERATOR_OR,
} from '~/vue_shared/components/filtered_search_bar/constants';
import {
getRecentlyUsedSuggestions,
@@ -98,6 +99,7 @@ function createComponent({
portalName: 'fake target',
alignSuggestions: jest.fn(),
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
filteredSearchSuggestionListInstance: {
register: jest.fn(),
unregister: jest.fn(),
@@ -120,10 +122,6 @@ describe('BaseToken', () => {
const getMockSuggestionListSuggestions = () =>
JSON.parse(findMockSuggestionList().attributes('data-suggestions'));
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('data', () => {
it('calls `getRecentlyUsedSuggestions` to populate `recentSuggestions` when `recentSuggestionsStorageKey` is defined', () => {
wrapper = createComponent();
@@ -304,6 +302,7 @@ describe('BaseToken', () => {
operator | shouldRenderFilteredSearchSuggestion
${OPERATOR_IS} | ${true}
${OPERATOR_NOT} | ${false}
+ ${OPERATOR_OR} | ${false}
`('when operator is $operator', ({ shouldRenderFilteredSearchSuggestion, operator }) => {
beforeEach(() => {
const props = {
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
index 311d5a13280..6bbbfd838a0 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
@@ -9,14 +9,15 @@ import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { mockBranches, mockBranchToken } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const defaultStubs = {
Portal: true,
GlFilteredSearchSuggestionList: {
@@ -45,6 +46,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
});
@@ -54,58 +56,83 @@ describe('BranchToken', () => {
let mock;
let wrapper;
+ const findBaseToken = () => wrapper.findComponent(BaseToken);
+ const triggerFetchBranches = (searchTerm = null) => {
+ findBaseToken().vm.$emit('fetch-suggestions', searchTerm);
+ return waitForPromises();
+ };
+
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('methods', () => {
- beforeEach(() => {
- wrapper = createComponent();
- });
-
describe('fetchBranches', () => {
- it('calls `config.fetchBranches` with provided searchTerm param', () => {
- jest.spyOn(wrapper.vm.config, 'fetchBranches');
-
- wrapper.vm.fetchBranches('foo');
+ it('sets loading state', async () => {
+ wrapper = createComponent({
+ config: {
+ fetchBranches: jest.fn().mockResolvedValue(new Promise(() => {})),
+ },
+ });
+ await nextTick();
- expect(wrapper.vm.config.fetchBranches).toHaveBeenCalledWith('foo');
+ expect(findBaseToken().props('suggestionsLoading')).toBe(true);
});
- it('sets response to `branches` when request is succesful', () => {
- jest.spyOn(wrapper.vm.config, 'fetchBranches').mockResolvedValue({ data: mockBranches });
+ describe('when request is successful', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ config: {
+ fetchBranches: jest.fn().mockResolvedValue({ data: mockBranches }),
+ },
+ });
+ });
+
+ it('calls `config.fetchBranches` with provided searchTerm param', async () => {
+ const searchTerm = 'foo';
+ await triggerFetchBranches(searchTerm);
- wrapper.vm.fetchBranches('foo');
+ expect(findBaseToken().props('config').fetchBranches).toHaveBeenCalledWith(searchTerm);
+ });
+
+ it('sets response to `branches`', async () => {
+ await triggerFetchBranches();
- return waitForPromises().then(() => {
- expect(wrapper.vm.branches).toEqual(mockBranches);
+ expect(findBaseToken().props('suggestions')).toEqual(mockBranches);
+ });
+
+ it('sets `loading` to false when request completes', async () => {
+ await triggerFetchBranches();
+
+ expect(findBaseToken().props('suggestionsLoading')).toBe(false);
});
});
- it('calls `createAlert` with flash error message when request fails', () => {
- jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({});
+ describe('when request fails', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ config: {
+ fetchBranches: jest.fn().mockRejectedValue({}),
+ },
+ });
+ });
- wrapper.vm.fetchBranches('foo');
+ it('calls `createAlert` with alert error message when request fails', async () => {
+ await triggerFetchBranches();
- return waitForPromises().then(() => {
expect(createAlert).toHaveBeenCalledWith({
message: 'There was a problem fetching branches.',
});
});
- });
-
- it('sets `loading` to false when request completes', () => {
- jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({});
- wrapper.vm.fetchBranches('foo');
+ it('sets `loading` to false when request completes', async () => {
+ await triggerFetchBranches();
- return waitForPromises().then(() => {
- expect(wrapper.vm.loading).toBe(false);
+ expect(findBaseToken().props('suggestionsLoading')).toBe(false);
});
});
});
@@ -120,16 +147,13 @@ describe('BranchToken', () => {
await nextTick();
}
- beforeEach(async () => {
- wrapper = createComponent({ value: { data: mockBranches[0].name } });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- branches: mockBranches,
+ beforeEach(() => {
+ wrapper = createComponent({
+ value: { data: mockBranches[0].name },
+ config: {
+ initialBranches: mockBranches,
+ },
});
-
- await nextTick();
});
it('renders gl-filtered-search-token component', () => {
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
index 7be7035a0f2..fb8cea09a9b 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
@@ -8,7 +8,7 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
@@ -22,7 +22,7 @@ import {
mockProjectCrmContactsQueryResponse,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const defaultStubs = {
Portal: true,
@@ -71,6 +71,7 @@ describe('CrmContactToken', () => {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
listeners,
@@ -79,7 +80,6 @@ describe('CrmContactToken', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -159,7 +159,7 @@ describe('CrmContactToken', () => {
});
});
- it('calls `createAlert` with flash error message when request fails', async () => {
+ it('calls `createAlert` with alert error message when request fails', async () => {
mountComponent();
jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
index ecd3e8a04f1..20369342220 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
@@ -8,7 +8,7 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
@@ -22,7 +22,7 @@ import {
mockProjectCrmOrganizationsQueryResponse,
} from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const defaultStubs = {
Portal: true,
@@ -70,6 +70,7 @@ describe('CrmOrganizationToken', () => {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
listeners,
@@ -78,7 +79,6 @@ describe('CrmOrganizationToken', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -158,7 +158,7 @@ describe('CrmOrganizationToken', () => {
});
});
- it('calls `createAlert` with flash error message when request fails', async () => {
+ it('calls `createAlert` when request fails', async () => {
mountComponent();
jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
index 773df01ada7..5e675c10038 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
@@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import {
@@ -17,10 +17,11 @@ import {
OPTIONS_NONE_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { mockReactionEmojiToken, mockEmojis } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const GlEmoji = { template: '<img/>' };
const defaultStubs = {
Portal: true,
@@ -51,6 +52,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
});
@@ -60,58 +62,72 @@ describe('EmojiToken', () => {
let mock;
let wrapper;
+ const findBaseToken = () => wrapper.findComponent(BaseToken);
+ const triggerFetchEmojis = (searchTerm = null) => {
+ findBaseToken().vm.$emit('fetch-suggestions', searchTerm);
+ return waitForPromises();
+ };
+
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('methods', () => {
- beforeEach(() => {
- wrapper = createComponent();
- });
-
describe('fetchEmojis', () => {
- it('calls `config.fetchEmojis` with provided searchTerm param', () => {
- jest.spyOn(wrapper.vm.config, 'fetchEmojis');
-
- wrapper.vm.fetchEmojis('foo');
+ it('sets loading state', async () => {
+ wrapper = createComponent({
+ config: {
+ fetchEmojis: jest.fn().mockResolvedValue(new Promise(() => {})),
+ },
+ });
+ await nextTick();
- expect(wrapper.vm.config.fetchEmojis).toHaveBeenCalledWith('foo');
+ expect(findBaseToken().props('suggestionsLoading')).toBe(true);
});
- it('sets response to `emojis` when request is successful', () => {
- jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockResolvedValue(mockEmojis);
+ describe('when request is successful', () => {
+ const searchTerm = 'foo';
- wrapper.vm.fetchEmojis('foo');
+ beforeEach(() => {
+ wrapper = createComponent({
+ config: {
+ fetchEmojis: jest.fn().mockResolvedValue({ data: mockEmojis }),
+ },
+ });
+ return triggerFetchEmojis(searchTerm);
+ });
- return waitForPromises().then(() => {
- expect(wrapper.vm.emojis).toEqual(mockEmojis);
+ it('calls `config.fetchEmojis` with provided searchTerm param', () => {
+ expect(findBaseToken().props('config').fetchEmojis).toHaveBeenCalledWith(searchTerm);
});
- });
- it('calls `createAlert` with flash error message when request fails', () => {
- jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
+ it('sets response to `emojis`', () => {
+ expect(findBaseToken().props('suggestions')).toEqual(mockEmojis);
+ });
+ });
- wrapper.vm.fetchEmojis('foo');
+ describe('when request fails', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ config: {
+ fetchEmojis: jest.fn().mockRejectedValue({}),
+ },
+ });
+ return triggerFetchEmojis();
+ });
- return waitForPromises().then(() => {
+ it('calls `createAlert`', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'There was a problem fetching emojis.',
});
});
- });
-
- it('sets `loading` to false when request completes', () => {
- jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
-
- wrapper.vm.fetchEmojis('foo');
- return waitForPromises().then(() => {
- expect(wrapper.vm.loading).toBe(false);
+ it('sets `loading` to false when request completes', () => {
+ expect(findBaseToken().props('suggestionsLoading')).toBe(false);
});
});
});
@@ -120,18 +136,13 @@ describe('EmojiToken', () => {
describe('template', () => {
const defaultEmojis = OPTIONS_NONE_ANY;
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = createComponent({
value: { data: `"${mockEmojis[0].name}"` },
+ config: {
+ initialEmojis: mockEmojis,
+ },
});
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- emojis: mockEmojis,
- });
-
- await nextTick();
});
it('renders gl-filtered-search-token component', () => {
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index 9d96123c17f..c55721fe032 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -11,7 +11,7 @@ import {
mockRegularLabel,
mockLabels,
} from 'jest/sidebar/components/labels/labels_select_vue/mock_data';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
@@ -20,7 +20,7 @@ import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label
import { mockLabelToken } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const defaultStubs = {
Portal: true,
BaseToken,
@@ -51,6 +51,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
listeners,
@@ -60,103 +61,125 @@ function createComponent(options = {}) {
describe('LabelToken', () => {
let mock;
let wrapper;
+ const defaultLabels = OPTIONS_NONE_ANY;
beforeEach(() => {
mock = new MockAdapter(axios);
});
+ const findBaseToken = () => wrapper.findComponent(BaseToken);
+ const findSuggestions = () => wrapper.findAllComponents(GlFilteredSearchSuggestion);
+ const findTokenSegments = () => wrapper.findAllComponents(GlFilteredSearchTokenSegment);
+ const triggerFetchLabels = (searchTerm = null) => {
+ findBaseToken().vm.$emit('fetch-suggestions', searchTerm);
+ return waitForPromises();
+ };
+
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('methods', () => {
- beforeEach(() => {
- wrapper = createComponent();
- });
-
describe('getActiveLabel', () => {
it('returns label object from labels array based on provided `currentValue` param', () => {
- expect(wrapper.vm.getActiveLabel(mockLabels, 'Foo Label')).toEqual(mockRegularLabel);
+ wrapper = createComponent();
+
+ expect(findBaseToken().props('getActiveTokenValue')(mockLabels, 'Foo Label')).toEqual(
+ mockRegularLabel,
+ );
});
});
describe('getLabelName', () => {
- it('returns value of `name` or `title` property present in provided label param', () => {
- let mockLabel = {
- title: 'foo',
- };
+ it('returns value of `name` or `title` property present in provided label param', async () => {
+ const customMockLabels = [
+ { title: 'Title with no name label' },
+ { name: 'Name Label', title: 'Title with name label' },
+ ];
+
+ wrapper = createComponent({
+ active: true,
+ config: {
+ ...mockLabelToken,
+ fetchLabels: jest.fn().mockResolvedValue({ data: customMockLabels }),
+ },
+ stubs: { Portal: true },
+ });
- expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.title);
+ await waitForPromises();
- mockLabel = {
- name: 'foo',
- };
+ const suggestions = findSuggestions();
+ const indexWithTitle = defaultLabels.length;
+ const indexWithName = defaultLabels.length + 1;
- expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.name);
+ expect(suggestions.at(indexWithTitle).text()).toBe(customMockLabels[0].title);
+ expect(suggestions.at(indexWithName).text()).toBe(customMockLabels[1].name);
});
});
describe('fetchLabels', () => {
- it('calls `config.fetchLabels` with provided searchTerm param', () => {
- jest.spyOn(wrapper.vm.config, 'fetchLabels');
-
- wrapper.vm.fetchLabels('foo');
-
- expect(wrapper.vm.config.fetchLabels).toHaveBeenCalledWith('foo');
- });
+ describe('when request is successful', () => {
+ const searchTerm = 'foo';
+
+ beforeEach(async () => {
+ wrapper = createComponent({
+ config: {
+ fetchLabels: jest.fn().mockResolvedValue({ data: mockLabels }),
+ },
+ });
+ await triggerFetchLabels(searchTerm);
+ });
- it('sets response to `labels` when request is succesful', () => {
- jest.spyOn(wrapper.vm.config, 'fetchLabels').mockResolvedValue(mockLabels);
+ it('calls `config.fetchLabels` with provided searchTerm param', () => {
+ expect(findBaseToken().props('config').fetchLabels).toHaveBeenCalledWith(searchTerm);
+ });
- wrapper.vm.fetchLabels('foo');
+ it('sets response to `labels`', () => {
+ expect(findBaseToken().props('suggestions')).toEqual(mockLabels);
+ });
- return waitForPromises().then(() => {
- expect(wrapper.vm.labels).toEqual(mockLabels);
+ it('sets `loading` to false when request completes', () => {
+ expect(findBaseToken().props('suggestionsLoading')).toBe(false);
});
});
- it('calls `createAlert` with flash error message when request fails', () => {
- jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({});
-
- wrapper.vm.fetchLabels('foo');
+ describe('when request fails', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({
+ config: {
+ fetchLabels: jest.fn().mockRejectedValue({}),
+ },
+ });
+ await triggerFetchLabels();
+ });
- return waitForPromises().then(() => {
+ it('calls `createAlert`', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'There was a problem fetching labels.',
});
});
- });
-
- it('sets `loading` to false when request completes', () => {
- jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({});
-
- wrapper.vm.fetchLabels('foo');
- return waitForPromises().then(() => {
- expect(wrapper.vm.loading).toBe(false);
+ it('sets `loading` to false when request completes', () => {
+ expect(findBaseToken().props('suggestionsLoading')).toBe(false);
});
});
});
});
describe('template', () => {
- const defaultLabels = OPTIONS_NONE_ANY;
-
beforeEach(async () => {
- wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- labels: mockLabels,
+ wrapper = createComponent({
+ value: { data: `"${mockRegularLabel.title}"` },
+ config: {
+ initialLabels: mockLabels,
+ },
});
await nextTick();
});
it('renders base-token component', () => {
- const baseTokenEl = wrapper.findComponent(BaseToken);
+ const baseTokenEl = findBaseToken();
expect(baseTokenEl.exists()).toBe(true);
expect(baseTokenEl.props()).toMatchObject({
@@ -166,7 +189,7 @@ describe('LabelToken', () => {
});
it('renders token item when value is selected', () => {
- const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
+ const tokenSegments = findTokenSegments();
expect(tokenSegments).toHaveLength(3); // Label, =, "Foo Label"
expect(tokenSegments.at(2).text()).toBe(`~${mockRegularLabel.title}`); // "Foo Label"
@@ -181,12 +204,12 @@ describe('LabelToken', () => {
config: { ...mockLabelToken, defaultLabels },
stubs: { Portal: true },
});
- const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
+ const tokenSegments = findTokenSegments();
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
await nextTick();
- const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion);
+ const suggestions = findSuggestions();
expect(suggestions).toHaveLength(defaultLabels.length);
defaultLabels.forEach((label, index) => {
@@ -200,7 +223,7 @@ describe('LabelToken', () => {
config: { ...mockLabelToken, defaultLabels: [] },
stubs: { Portal: true },
});
- const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
+ const tokenSegments = findTokenSegments();
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
await nextTick();
@@ -215,11 +238,10 @@ describe('LabelToken', () => {
config: { ...mockLabelToken },
stubs: { Portal: true },
});
- const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
+ const tokenSegments = findTokenSegments();
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
-
- const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion);
+ const suggestions = findSuggestions();
expect(suggestions).toHaveLength(OPTIONS_NONE_ANY.length);
OPTIONS_NONE_ANY.forEach((label, index) => {
@@ -234,7 +256,7 @@ describe('LabelToken', () => {
input: mockInput,
},
});
- wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+ findBaseToken().vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
});
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 589697fe542..db51b4a05b1 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
@@ -8,16 +8,17 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { sortMilestonesByDueDate } from '~/milestones/utils';
import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { mockMilestoneToken, mockMilestones, mockRegularMilestone } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/milestones/utils');
const defaultStubs = {
@@ -48,6 +49,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
});
@@ -57,6 +59,12 @@ describe('MilestoneToken', () => {
let mock;
let wrapper;
+ const findBaseToken = () => wrapper.findComponent(BaseToken);
+ const triggerFetchMilestones = (searchTerm = null) => {
+ findBaseToken().vm.$emit('fetch-suggestions', searchTerm);
+ return waitForPromises();
+ };
+
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
@@ -64,73 +72,77 @@ describe('MilestoneToken', () => {
afterEach(() => {
mock.restore();
- wrapper.destroy();
});
describe('methods', () => {
describe('fetchMilestones', () => {
- describe('when config.shouldSkipSort is true', () => {
- beforeEach(() => {
- wrapper.vm.config.shouldSkipSort = true;
+ it('sets loading state', async () => {
+ wrapper = createComponent({
+ config: {
+ fetchMilestones: jest.fn().mockResolvedValue(new Promise(() => {})),
+ },
});
+ await nextTick();
- afterEach(() => {
- wrapper.vm.config.shouldSkipSort = false;
- });
+ expect(findBaseToken().props('suggestionsLoading')).toBe(true);
+ });
+
+ describe('when config.shouldSkipSort is true', () => {
it('does not call sortMilestonesByDueDate', async () => {
- jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockResolvedValue({
- data: mockMilestones,
+ wrapper = createComponent({
+ config: {
+ shouldSkipSort: true,
+ fetchMilestones: jest.fn().mockResolvedValue({ data: mockMilestones }),
+ },
});
- wrapper.vm.fetchMilestones();
-
- await waitForPromises();
+ await triggerFetchMilestones();
expect(sortMilestonesByDueDate).toHaveBeenCalledTimes(0);
});
});
- it('calls `config.fetchMilestones` with provided searchTerm param', () => {
- jest.spyOn(wrapper.vm.config, 'fetchMilestones');
-
- wrapper.vm.fetchMilestones('foo');
+ describe('when request is successful', () => {
+ const searchTerm = 'foo';
- expect(wrapper.vm.config.fetchMilestones).toHaveBeenCalledWith('foo');
- });
-
- it('sets response to `milestones` when request is successful', () => {
- wrapper.vm.config.shouldSkipSort = false;
+ beforeEach(() => {
+ wrapper = createComponent({
+ config: {
+ shouldSkipSort: false,
+ fetchMilestones: jest.fn().mockResolvedValue({ data: mockMilestones }),
+ },
+ });
+ return triggerFetchMilestones(searchTerm);
+ });
- jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockResolvedValue({
- data: mockMilestones,
+ it('calls `config.fetchMilestones` with provided searchTerm param', () => {
+ expect(findBaseToken().props('config').fetchMilestones).toHaveBeenCalledWith(searchTerm);
});
- wrapper.vm.fetchMilestones();
- return waitForPromises().then(() => {
- expect(wrapper.vm.milestones).toEqual(mockMilestones);
+ it('sets response to `milestones`', () => {
expect(sortMilestonesByDueDate).toHaveBeenCalled();
+ expect(findBaseToken().props('suggestions')).toEqual(mockMilestones);
});
});
- it('calls `createAlert` with flash error message when request fails', () => {
- jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({});
-
- wrapper.vm.fetchMilestones('foo');
+ describe('when request fails', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ config: {
+ fetchMilestones: jest.fn().mockRejectedValue({}),
+ },
+ });
+ return triggerFetchMilestones();
+ });
- return waitForPromises().then(() => {
+ it('calls `createAlert`', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'There was a problem fetching milestones.',
});
});
- });
- it('sets `loading` to false when request completes', () => {
- jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({});
-
- wrapper.vm.fetchMilestones('foo');
-
- return waitForPromises().then(() => {
- expect(wrapper.vm.loading).toBe(false);
+ it('sets `loading` to false when request completes', () => {
+ expect(findBaseToken().props('suggestionsLoading')).toBe(false);
});
});
});
@@ -142,16 +154,13 @@ describe('MilestoneToken', () => {
{ text: 'bar', value: 'baz' },
];
- beforeEach(async () => {
- wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` } });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- milestones: mockMilestones,
+ beforeEach(() => {
+ wrapper = createComponent({
+ value: { data: `"${mockRegularMilestone.title}"` },
+ config: {
+ initialMilestones: mockMilestones,
+ },
});
-
- await nextTick();
});
it('renders gl-filtered-search-token component', () => {
@@ -228,7 +237,7 @@ describe('MilestoneToken', () => {
it('finds the correct value from the activeToken', () => {
DEFAULT_MILESTONES.forEach(({ value, title }) => {
- const activeToken = wrapper.vm.getActiveMilestone([], value);
+ const activeToken = findBaseToken().props('getActiveTokenValue')([], value);
expect(activeToken.title).toEqual(title);
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
index 0e5fa0f66d4..79fd527cbe3 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
@@ -2,11 +2,11 @@ import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue';
import { mockReleaseToken } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('ReleaseToken', () => {
const id = '123';
@@ -24,13 +24,10 @@ describe('ReleaseToken', () => {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders release value', async () => {
wrapper = createComponent({ value: { data: id } });
await nextTick();
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
index 32cb74d5f80..e4ca7dcb19a 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
@@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
@@ -17,7 +17,7 @@ import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_t
import { mockAuthorToken, mockUsers } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const defaultStubs = {
Portal: true,
GlFilteredSearchSuggestionList: {
@@ -57,6 +57,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
data() {
return { ...data };
@@ -67,99 +68,82 @@ function createComponent(options = {}) {
}
describe('UserToken', () => {
- const originalGon = window.gon;
const currentUserLength = 1;
let mock;
let wrapper;
- const getBaseToken = () => wrapper.findComponent(BaseToken);
+ const findBaseToken = () => wrapper.findComponent(BaseToken);
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
- window.gon = originalGon;
mock.restore();
- wrapper.destroy();
});
describe('methods', () => {
describe('fetchUsers', () => {
+ const triggerFetchUsers = (searchTerm = null) => {
+ findBaseToken().vm.$emit('fetch-suggestions', searchTerm);
+ return waitForPromises();
+ };
+
beforeEach(() => {
wrapper = createComponent();
});
- it('calls `config.fetchUsers` with provided searchTerm param', () => {
- jest.spyOn(wrapper.vm.config, 'fetchUsers');
-
- getBaseToken().vm.$emit('fetch-suggestions', mockUsers[0].username);
-
- expect(wrapper.vm.config.fetchUsers).toHaveBeenCalledWith(
- mockAuthorToken.fetchPath,
- mockUsers[0].username,
- );
- });
-
- it('sets response to `users` when request is successful', () => {
- jest.spyOn(wrapper.vm.config, 'fetchUsers').mockResolvedValue(mockUsers);
-
- getBaseToken().vm.$emit('fetch-suggestions', 'root');
-
- return waitForPromises().then(() => {
- expect(getBaseToken().props('suggestions')).toEqual(mockUsers);
+ it('sets loading state', async () => {
+ wrapper = createComponent({
+ config: {
+ fetchUsers: jest.fn().mockResolvedValue(new Promise(() => {})),
+ },
});
+ await nextTick();
+
+ expect(findBaseToken().props('suggestionsLoading')).toBe(true);
});
- // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756
- describe('when there are null users presents', () => {
- const mockUsersWithNullUser = mockUsers.concat([null]);
+ describe('when request is successful', () => {
+ const searchTerm = 'foo';
beforeEach(() => {
- jest
- .spyOn(wrapper.vm.config, 'fetchUsers')
- .mockResolvedValue({ data: mockUsersWithNullUser });
-
- getBaseToken().vm.$emit('fetch-suggestions', 'root');
+ wrapper = createComponent({
+ config: {
+ fetchUsers: jest.fn().mockResolvedValue({ data: mockUsers }),
+ },
+ });
+ return triggerFetchUsers(searchTerm);
});
- describe('when res.data is present', () => {
- it('filters the successful response when null values are present', () => {
- return waitForPromises().then(() => {
- expect(getBaseToken().props('suggestions')).toEqual(mockUsers);
- });
- });
+ it('calls `config.fetchUsers` with provided searchTerm param', () => {
+ expect(findBaseToken().props('config').fetchUsers).toHaveBeenCalledWith(searchTerm);
});
- describe('when response is an array', () => {
- it('filters the successful response when null values are present', () => {
- return waitForPromises().then(() => {
- expect(getBaseToken().props('suggestions')).toEqual(mockUsers);
- });
- });
+ it('sets response to `users` when request is successful', () => {
+ expect(findBaseToken().props('suggestions')).toEqual(mockUsers);
});
});
- it('calls `createAlert` with flash error message when request fails', () => {
- jest.spyOn(wrapper.vm.config, 'fetchUsers').mockRejectedValue({});
-
- getBaseToken().vm.$emit('fetch-suggestions', 'root');
+ describe('when request fails', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ config: {
+ fetchUsers: jest.fn().mockRejectedValue({}),
+ },
+ });
+ return triggerFetchUsers();
+ });
- return waitForPromises().then(() => {
+ it('calls `createAlert`', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'There was a problem fetching users.',
});
});
- });
-
- it('sets `loading` to false when request completes', async () => {
- jest.spyOn(wrapper.vm.config, 'fetchUsers').mockRejectedValue({});
-
- getBaseToken().vm.$emit('fetch-suggestions', 'root');
- await waitForPromises();
-
- expect(getBaseToken().props('suggestionsLoading')).toBe(false);
+ it('sets `loading` to false when request completes', () => {
+ expect(findBaseToken().props('suggestionsLoading')).toBe(false);
+ });
});
});
});
@@ -178,12 +162,12 @@ describe('UserToken', () => {
data: { users: mockUsers },
});
- const baseTokenEl = getBaseToken();
+ const baseTokenEl = findBaseToken();
expect(baseTokenEl.exists()).toBe(true);
expect(baseTokenEl.props()).toMatchObject({
suggestions: mockUsers,
- getActiveTokenValue: wrapper.vm.getActiveUser,
+ getActiveTokenValue: baseTokenEl.props('getActiveTokenValue'),
});
});
@@ -191,7 +175,6 @@ describe('UserToken', () => {
wrapper = createComponent({
value: { data: mockUsers[0].username },
data: { users: mockUsers },
- stubs: { Portal: true },
});
await nextTick();
@@ -205,7 +188,7 @@ describe('UserToken', () => {
expect(tokenValue.text()).toBe(mockUsers[0].name); // "Administrator"
});
- it('renders token value with correct avatarUrl from user object', async () => {
+ it('renders token value with correct avatarUrl from user object', () => {
const getAvatarEl = () =>
wrapper.findAllComponents(GlFilteredSearchTokenSegment).at(2).findComponent(GlAvatar);
@@ -215,30 +198,13 @@ describe('UserToken', () => {
users: [
{
...mockUsers[0],
+ avatarUrl: mockUsers[0].avatar_url,
+ avatar_url: undefined,
},
],
},
- stubs: { Portal: true },
});
- await nextTick();
-
- expect(getAvatarEl().props('src')).toBe(mockUsers[0].avatar_url);
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- users: [
- {
- ...mockUsers[0],
- avatarUrl: mockUsers[0].avatar_url,
- avatar_url: undefined,
- },
- ],
- });
-
- await nextTick();
-
expect(getAvatarEl().props('src')).toBe(mockUsers[0].avatar_url);
});
@@ -264,7 +230,6 @@ describe('UserToken', () => {
wrapper = createComponent({
active: true,
config: { ...mockAuthorToken, defaultUsers: [] },
- stubs: { Portal: true },
});
const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
diff --git a/spec/frontend/vue_shared/components/form/__snapshots__/form_footer_actions_spec.js.snap b/spec/frontend/vue_shared/components/form/__snapshots__/form_footer_actions_spec.js.snap
index 2189d6ac3cc..6f98a74a82f 100644
--- a/spec/frontend/vue_shared/components/form/__snapshots__/form_footer_actions_spec.js.snap
+++ b/spec/frontend/vue_shared/components/form/__snapshots__/form_footer_actions_spec.js.snap
@@ -2,18 +2,8 @@
exports[`Form Footer Actions renders content properly 1`] = `
<footer
- class="form-actions d-flex justify-content-between"
+ class="gl-mt-5 footer-block"
>
- <div>
- Bar
- </div>
-
- <div>
- Foo
- </div>
-
- <div>
- Abrakadabra
- </div>
+ Bar Foo Abrakadabra
</footer>
`;
diff --git a/spec/frontend/vue_shared/components/form/form_footer_actions_spec.js b/spec/frontend/vue_shared/components/form/form_footer_actions_spec.js
index 361b162b6a0..eee8a0c4532 100644
--- a/spec/frontend/vue_shared/components/form/form_footer_actions_spec.js
+++ b/spec/frontend/vue_shared/components/form/form_footer_actions_spec.js
@@ -10,10 +10,6 @@ describe('Form Footer Actions', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders content properly', () => {
const defaultSlot = 'Foo';
const prepend = 'Bar';
diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
index e1da8b690af..4f1603f93ba 100644
--- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
+++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
@@ -10,10 +10,6 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
describe('InputCopyToggleVisibility', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
const valueProp = 'hR8x1fuJbzwu5uFKLf9e';
const createComponent = (options = {}) => {
@@ -21,7 +17,7 @@ describe('InputCopyToggleVisibility', () => {
InputCopyToggleVisibility,
merge({}, options, {
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
}),
);
diff --git a/spec/frontend/vue_shared/components/form/title_spec.js b/spec/frontend/vue_shared/components/form/title_spec.js
index 452f3723e76..d499f847c72 100644
--- a/spec/frontend/vue_shared/components/form/title_spec.js
+++ b/spec/frontend/vue_shared/components/form/title_spec.js
@@ -12,10 +12,6 @@ describe('Title edit field', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/vue_shared/components/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js
index af53d256236..38d54eff872 100644
--- a/spec/frontend/vue_shared/components/gl_countdown_spec.js
+++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js
@@ -10,12 +10,8 @@ describe('GlCountdown', () => {
jest.spyOn(Date, 'now').mockImplementation(() => new Date(now).getTime());
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when there is time remaining', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mount(GlCountdown, {
propsData: {
endDateString: '2000-01-01T01:02:03Z',
@@ -37,7 +33,7 @@ describe('GlCountdown', () => {
});
describe('when there is no time remaining', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mount(GlCountdown, {
propsData: {
endDateString: '1900-01-01T00:00:00Z',
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 458f2cc5374..da9bc0f8a2f 100644
--- a/spec/frontend/vue_shared/components/header_ci_component_spec.js
+++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js
@@ -48,11 +48,6 @@ describe('Header CI Component', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('render', () => {
beforeEach(() => {
createComponent({ itemName: 'Pipeline' });
diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js
index 77c03dc0c3c..76e66d07fa0 100644
--- a/spec/frontend/vue_shared/components/help_popover_spec.js
+++ b/spec/frontend/vue_shared/components/help_popover_spec.js
@@ -23,10 +23,6 @@ describe('HelpPopover', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('with title and content', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/integration_help_text_spec.js b/spec/frontend/vue_shared/components/integration_help_text_spec.js
index c63e46313b3..dd20b09f176 100644
--- a/spec/frontend/vue_shared/components/integration_help_text_spec.js
+++ b/spec/frontend/vue_shared/components/integration_help_text_spec.js
@@ -22,11 +22,6 @@ describe('IntegrationHelpText component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should use the gl components', () => {
wrapper = createComponent();
diff --git a/spec/frontend/vue_shared/components/keep_alive_slots_spec.js b/spec/frontend/vue_shared/components/keep_alive_slots_spec.js
index 10c6cbe6d94..f69a883ee4d 100644
--- a/spec/frontend/vue_shared/components/keep_alive_slots_spec.js
+++ b/spec/frontend/vue_shared/components/keep_alive_slots_spec.js
@@ -37,10 +37,6 @@ describe('~/vue_shared/components/keep_alive_slots.vue', () => {
isVisible: x.isVisible(),
}));
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js
index 7ed6a59c844..397fd270344 100644
--- a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js
+++ b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlFormGroup, GlListbox } from '@gitlab/ui';
+import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui';
import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue';
describe('ListboxInput', () => {
@@ -27,7 +27,7 @@ describe('ListboxInput', () => {
// Finders
const findGlFormGroup = () => wrapper.findComponent(GlFormGroup);
- const findGlListbox = () => wrapper.findComponent(GlListbox);
+ const findGlListbox = () => wrapper.findComponent(GlCollapsibleListbox);
const findInput = () => wrapper.find('input');
const createComponent = (propsData) => {
@@ -153,7 +153,7 @@ describe('ListboxInput', () => {
expect(findGlListbox().props('searchable')).toBe(true);
});
- it('passes all items to GlListbox by default', () => {
+ it('passes all items to GlCollapsibleListbox by default', () => {
createComponent();
expect(findGlListbox().props('items')).toStrictEqual(items);
@@ -165,7 +165,7 @@ describe('ListboxInput', () => {
findGlListbox().vm.$emit('search', '1');
});
- it('passes only the items that match the search string', async () => {
+ it('passes only the items that match the search string', () => {
expect(findGlListbox().props('items')).toStrictEqual([
{
text: 'Group 1',
@@ -183,7 +183,7 @@ describe('ListboxInput', () => {
findGlListbox().vm.$emit('search', '1');
});
- it('passes only the items that match the search string', async () => {
+ it('passes only the items that match the search string', () => {
expect(findGlListbox().props('items')).toStrictEqual([{ text: 'Item 1', value: '1' }]);
});
});
diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
index a80717a1aea..1c7f419b118 100644
--- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js
+++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
@@ -17,7 +17,6 @@ describe('Local Storage Sync', () => {
const getStorageValue = (value) => localStorage.getItem(STORAGE_KEY, value);
afterEach(() => {
- wrapper.destroy();
localStorage.clear();
});
diff --git a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js
index ecb2b37c3a5..8aab867f32a 100644
--- a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js
@@ -17,11 +17,6 @@ describe('Apply Suggestion component', () => {
beforeEach(() => createWrapper());
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('initial template', () => {
it('renders a dropdown with the correct props', () => {
const dropdown = findDropdown();
diff --git a/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js
new file mode 100644
index 00000000000..aea25abb324
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js
@@ -0,0 +1,76 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import savedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies.query.graphql.json';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { updateText } from '~/lib/utils/text_markdown';
+import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue';
+import savedRepliesQuery from '~/vue_shared/components/markdown/saved_replies.query.graphql';
+
+jest.mock('~/lib/utils/text_markdown');
+
+let wrapper;
+let savedRepliesResp;
+
+function createMockApolloProvider(response) {
+ Vue.use(VueApollo);
+
+ savedRepliesResp = jest.fn().mockResolvedValue(response);
+
+ const requestHandlers = [[savedRepliesQuery, savedRepliesResp]];
+
+ return createMockApollo(requestHandlers);
+}
+
+function createComponent(options = {}) {
+ const { mockApollo } = options;
+
+ return mountExtended(CommentTemplatesDropdown, {
+ attachTo: '#root',
+ propsData: {
+ newCommentTemplatePath: '/new',
+ },
+ apolloProvider: mockApollo,
+ });
+}
+
+describe('Comment templates dropdown', () => {
+ beforeEach(() => {
+ setHTMLFixture('<div class="md-area"><textarea></textarea><div id="root"></div></div>');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ it('fetches data when dropdown gets opened', async () => {
+ const mockApollo = createMockApolloProvider(savedRepliesResponse);
+ wrapper = createComponent({ mockApollo });
+
+ wrapper.find('.js-comment-template-toggle').trigger('click');
+
+ await waitForPromises();
+
+ expect(savedRepliesResp).toHaveBeenCalled();
+ });
+
+ it('adds content to textarea', async () => {
+ const mockApollo = createMockApolloProvider(savedRepliesResponse);
+ wrapper = createComponent({ mockApollo });
+
+ wrapper.find('.js-comment-template-toggle').trigger('click');
+
+ await waitForPromises();
+
+ wrapper.find('.gl-new-dropdown-item').trigger('click');
+
+ expect(updateText).toHaveBeenCalledWith({
+ textArea: document.querySelector('textarea'),
+ tag: savedRepliesResponse.data.currentUser.savedReplies.nodes[0].content,
+ cursorOffset: 0,
+ wrap: false,
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/drawio_toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/drawio_toolbar_button_spec.js
new file mode 100644
index 00000000000..67f296b1bf0
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/drawio_toolbar_button_spec.js
@@ -0,0 +1,66 @@
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import DrawioToolbarButton from '~/vue_shared/components/markdown/drawio_toolbar_button.vue';
+import { launchDrawioEditor } from '~/drawio/drawio_editor';
+import { create } from '~/drawio/markdown_field_editor_facade';
+
+jest.mock('~/drawio/drawio_editor');
+jest.mock('~/drawio/markdown_field_editor_facade');
+
+describe('vue_shared/components/markdown/drawio_toolbar_button', () => {
+ let wrapper;
+ let textArea;
+ const uploadsPath = '/uploads';
+ const markdownPreviewPath = '/markdown/preview';
+
+ const buildWrapper = (props = { uploadsPath, markdownPreviewPath }) => {
+ wrapper = shallowMount(DrawioToolbarButton, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ textArea = document.createElement('textarea');
+ textArea.classList.add('js-gfm-input');
+
+ document.body.appendChild(textArea);
+ });
+
+ afterEach(() => {
+ textArea.remove();
+ });
+
+ describe('default', () => {
+ it('renders button that launches draw.io editor', () => {
+ buildWrapper();
+
+ expect(wrapper.findComponent(GlButton).props()).toMatchObject({
+ icon: 'diagram',
+ category: 'tertiary',
+ });
+ });
+ });
+
+ describe('when clicking button', () => {
+ it('launches draw.io editor', async () => {
+ const editorFacadeStub = {};
+
+ create.mockReturnValueOnce(editorFacadeStub);
+
+ buildWrapper();
+
+ await wrapper.findComponent(GlButton).vm.$emit('click');
+
+ expect(create).toHaveBeenCalledWith({
+ markdownPreviewPath,
+ textArea,
+ uploadsPath,
+ });
+ expect(launchDrawioEditor).toHaveBeenCalledWith({
+ editorFacade: editorFacadeStub,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js
deleted file mode 100644
index 34071775b9c..00000000000
--- a/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue';
-
-describe('vue_shared/component/markdown/editor_mode_dropdown', () => {
- let wrapper;
-
- const createComponent = ({ value, size } = {}) => {
- wrapper = shallowMount(EditorModeDropdown, {
- propsData: {
- value,
- size,
- },
- });
- };
-
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findDropdownItem = (text) =>
- wrapper
- .findAllComponents(GlDropdownItem)
- .filter((item) => item.text().startsWith(text))
- .at(0);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe.each`
- modeText | value | dropdownText | otherMode
- ${'Rich text'} | ${'richText'} | ${'View markdown'} | ${'Markdown'}
- ${'Markdown'} | ${'markdown'} | ${'View rich text'} | ${'Rich text'}
- `('$modeText', ({ modeText, value, dropdownText, otherMode }) => {
- beforeEach(() => {
- createComponent({ value });
- });
-
- it('shows correct dropdown label', () => {
- expect(findDropdown().props('text')).toEqual(dropdownText);
- });
-
- it('checks correct checked dropdown item', () => {
- expect(findDropdownItem(modeText).props().isChecked).toBe(true);
- expect(findDropdownItem(otherMode).props().isChecked).toBe(false);
- });
-
- it('emits event on click', () => {
- findDropdownItem(modeText).vm.$emit('click');
-
- expect(wrapper.emitted().input).toEqual([[value]]);
- });
- });
-
- it('passes size to dropdown', () => {
- createComponent({ size: 'small', value: 'markdown' });
-
- expect(findDropdown().props('size')).toEqual('small');
- });
-});
diff --git a/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js b/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js
new file mode 100644
index 00000000000..693353ed604
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js
@@ -0,0 +1,37 @@
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
+
+describe('vue_shared/component/markdown/editor_mode_switcher', () => {
+ let wrapper;
+
+ const createComponent = ({ value } = {}) => {
+ wrapper = shallowMount(EditorModeSwitcher, {
+ propsData: {
+ value,
+ },
+ });
+ };
+
+ const findSwitcherButton = () => wrapper.findComponent(GlButton);
+
+ describe.each`
+ modeText | value | buttonText
+ ${'Rich text'} | ${'richText'} | ${'Switch to Markdown'}
+ ${'Markdown'} | ${'markdown'} | ${'Switch to rich text'}
+ `('when $modeText', ({ modeText, value, buttonText }) => {
+ beforeEach(() => {
+ createComponent({ value });
+ });
+
+ it('shows correct button label', () => {
+ expect(findSwitcherButton().text()).toEqual(buttonText);
+ });
+
+ it('emits event on click', () => {
+ findSwitcherButton(modeText).vm.$emit('click');
+
+ expect(wrapper.emitted().input).toEqual([[]]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index 68ce07f86b9..b29f0d58d77 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -18,12 +18,6 @@ const textareaValue = 'testing\n123';
const uploadsPath = 'test/uploads';
const restrictedToolBarItems = ['quote'];
-function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) {
- expect(writeLink.element.children[0].classList.contains('active')).toBe(isWrite);
- expect(previewLink.element.children[0].classList.contains('active')).toBe(!isWrite);
- expect(wrapper.find('.md-preview-holder').element.style.display).toBe(isWrite ? 'none' : '');
-}
-
describe('Markdown field component', () => {
let axiosMock;
let subject;
@@ -92,8 +86,7 @@ describe('Markdown field component', () => {
});
}
- const getPreviewLink = () => subject.findByTestId('preview-tab');
- const getWriteLink = () => subject.findByTestId('write-tab');
+ const getPreviewToggle = () => subject.findByTestId('preview-toggle');
const getMarkdownButton = () => subject.find('.js-md');
const getListBulletedButton = () => subject.findAll('.js-md[title="Add a bullet list"]');
const getVideo = () => subject.find('video');
@@ -109,8 +102,7 @@ describe('Markdown field component', () => {
<p>markdown preview</p>
<video src="${FIXTURES_PATH}/static/mock-video.mp4"></video>
`;
- let previewLink;
- let writeLink;
+ let previewToggle;
let dropzoneSpy;
beforeEach(() => {
@@ -140,8 +132,8 @@ describe('Markdown field component', () => {
.onPost(markdownPreviewPath)
.reply(HTTP_STATUS_OK, { references: { users: [], commands: 'test command' } });
- previewLink = getPreviewLink();
- previewLink.vm.$emit('click', { target: {} });
+ previewToggle = getPreviewToggle();
+ previewToggle.vm.$emit('click', true);
await axios.waitFor(markdownPreviewPath);
const referencedCommands = subject.find('[data-testid="referenced-commands"]');
@@ -155,26 +147,29 @@ describe('Markdown field component', () => {
axiosMock.onPost(markdownPreviewPath).reply(HTTP_STATUS_OK, { body: previewHTML });
});
- it('sets preview link as active', async () => {
- previewLink = getPreviewLink();
- previewLink.vm.$emit('click', { target: {} });
+ it('sets preview toggle as active', async () => {
+ previewToggle = getPreviewToggle();
+
+ expect(previewToggle.text()).toBe('Preview');
+
+ previewToggle.vm.$emit('click', true);
await nextTick();
- expect(previewLink.element.children[0].classList.contains('active')).toBe(true);
+ expect(previewToggle.text()).toBe('Continue editing');
});
it('shows preview loading text', async () => {
- previewLink = getPreviewLink();
- previewLink.vm.$emit('click', { target: {} });
+ previewToggle = getPreviewToggle();
+ previewToggle.vm.$emit('click', true);
await nextTick();
expect(subject.find('.md-preview-holder').element.textContent.trim()).toContain('Loading…');
});
it('renders markdown preview and GFM', async () => {
- previewLink = getPreviewLink();
+ previewToggle = getPreviewToggle();
- previewLink.vm.$emit('click', { target: {} });
+ previewToggle.vm.$emit('click', true);
await axios.waitFor(markdownPreviewPath);
expect(subject.find('.md-preview-holder').element.innerHTML).toContain(previewHTML);
@@ -182,8 +177,8 @@ describe('Markdown field component', () => {
});
it('calls video.pause() on comment input when isSubmitting is changed to true', async () => {
- previewLink = getPreviewLink();
- previewLink.vm.$emit('click', { target: {} });
+ previewToggle = getPreviewToggle();
+ previewToggle.vm.$emit('click', true);
await axios.waitFor(markdownPreviewPath);
const video = getVideo();
@@ -195,34 +190,27 @@ describe('Markdown field component', () => {
expect(callPause).toHaveBeenCalled();
});
- it('clicking already active write or preview link does nothing', async () => {
- writeLink = getWriteLink();
- previewLink = getPreviewLink();
-
- writeLink.vm.$emit('click', { target: {} });
- await nextTick();
-
- assertMarkdownTabs(true, writeLink, previewLink, subject);
- writeLink.vm.$emit('click', { target: {} });
- await nextTick();
+ it('switches between preview/write on toggle', async () => {
+ previewToggle = getPreviewToggle();
- assertMarkdownTabs(true, writeLink, previewLink, subject);
- previewLink.vm.$emit('click', { target: {} });
+ previewToggle.vm.$emit('click', true);
await nextTick();
+ expect(subject.find('.md-preview-holder').element.style.display).toBe(''); // visible
- assertMarkdownTabs(false, writeLink, previewLink, subject);
- previewLink.vm.$emit('click', { target: {} });
+ previewToggle.vm.$emit('click', false);
await nextTick();
-
- assertMarkdownTabs(false, writeLink, previewLink, subject);
+ expect(subject.find('.md-preview-holder').element.style.display).toBe('none');
});
- it('passes correct props to MarkdownToolbar', () => {
+ it('passes correct props to MarkdownHeader and MarkdownToolbar', () => {
expect(findMarkdownToolbar().props()).toEqual({
canAttachFile: true,
markdownDocsPath,
quickActionsDocsPath: '',
showCommentToolBar: true,
+ });
+
+ expect(findMarkdownHeader().props()).toMatchObject({
showContentEditorSwitcher: false,
});
});
@@ -380,13 +368,13 @@ describe('Markdown field component', () => {
it('defaults to false', () => {
createSubject();
- expect(findMarkdownToolbar().props('showContentEditorSwitcher')).toBe(false);
+ expect(findMarkdownHeader().props('showContentEditorSwitcher')).toBe(false);
});
it('passes showContentEditorSwitcher', () => {
createSubject({ showContentEditorSwitcher: true });
- expect(findMarkdownToolbar().props('showContentEditorSwitcher')).toBe(true);
+ expect(findMarkdownHeader().props('showContentEditorSwitcher')).toBe(true);
});
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/field_view_spec.js b/spec/frontend/vue_shared/components/markdown/field_view_spec.js
index 176ccfc5a69..1bbbe0896f2 100644
--- a/spec/frontend/vue_shared/components/markdown/field_view_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_view_spec.js
@@ -6,20 +6,14 @@ import { renderGFM } from '~/behaviors/markdown/render_gfm';
jest.mock('~/behaviors/markdown/render_gfm');
describe('Markdown Field View component', () => {
- let wrapper;
-
function createComponent() {
- wrapper = shallowMount(MarkdownFieldView);
+ shallowMount(MarkdownFieldView);
}
beforeEach(() => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('processes rendering with GFM', () => {
expect(renderGFM).toHaveBeenCalledTimes(1);
});
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index ed417097e1e..48fe5452e74 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -1,9 +1,11 @@
import $ from 'jquery';
import { nextTick } from 'vue';
-import { GlTabs } from '@gitlab/ui';
+import { GlToggle } from '@gitlab/ui';
import HeaderComponent from '~/vue_shared/components/markdown/header.vue';
import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue';
+import DrawioToolbarButton from '~/vue_shared/components/markdown/drawio_toolbar_button.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
describe('Markdown field header component', () => {
let wrapper;
@@ -14,18 +16,18 @@ describe('Markdown field header component', () => {
previewMarkdown: false,
...props,
},
- stubs: { GlTabs },
+ stubs: { GlToggle },
});
};
- const findWriteTab = () => wrapper.findByTestId('write-tab');
- const findPreviewTab = () => wrapper.findByTestId('preview-tab');
+ const findPreviewToggle = () => wrapper.findByTestId('preview-toggle');
const findToolbar = () => wrapper.findByTestId('md-header-toolbar');
const findToolbarButtons = () => wrapper.findAllComponents(ToolbarButton);
const findToolbarButtonByProp = (prop, value) =>
findToolbarButtons()
.filter((button) => button.props(prop) === value)
.at(0);
+ const findDrawioToolbarButton = () => wrapper.findComponent(DrawioToolbarButton);
beforeEach(() => {
window.gl = {
@@ -37,10 +39,6 @@ describe('Markdown field header component', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('markdown header buttons', () => {
it('renders the buttons with the correct title', () => {
const buttons = [
@@ -89,16 +87,14 @@ describe('Markdown field header component', () => {
});
});
- it('activates `write` tab when previewMarkdown is false', () => {
- expect(findWriteTab().attributes('active')).toBe('true');
- expect(findPreviewTab().attributes('active')).toBeUndefined();
+ it('hides markdown preview when previewMarkdown is false', () => {
+ expect(findPreviewToggle().text()).toBe('Preview');
});
- it('activates `preview` tab when previewMarkdown is true', () => {
+ it('shows markdown preview when previewMarkdown is true', () => {
createWrapper({ previewMarkdown: true });
- expect(findWriteTab().attributes('active')).toBeUndefined();
- expect(findPreviewTab().attributes('active')).toBe('true');
+ expect(findPreviewToggle().text()).toBe('Continue editing');
});
it('hides toolbar in preview mode', () => {
@@ -107,17 +103,16 @@ describe('Markdown field header component', () => {
expect(findToolbar().classes().includes('gl-display-none!')).toBe(true);
});
- it('emits toggle markdown event when clicking preview tab', async () => {
- const eventData = { target: {} };
- findPreviewTab().vm.$emit('click', eventData);
+ it('emits toggle markdown event when clicking preview toggle', async () => {
+ findPreviewToggle().vm.$emit('click', true);
await nextTick();
- expect(wrapper.emitted('preview-markdown').length).toEqual(1);
+ expect(wrapper.emitted('showPreview').length).toEqual(1);
- findWriteTab().vm.$emit('click', eventData);
+ findPreviewToggle().vm.$emit('click', false);
await nextTick();
- expect(wrapper.emitted('write-markdown').length).toEqual(1);
+ expect(wrapper.emitted('showPreview').length).toEqual(2);
});
it('does not emit toggle markdown event when triggered from another form', () => {
@@ -127,15 +122,8 @@ describe('Markdown field header component', () => {
),
]);
- expect(wrapper.emitted('preview-markdown')).toBeUndefined();
- expect(wrapper.emitted('write-markdown')).toBeUndefined();
- });
-
- it('blurs preview link after click', () => {
- const target = { blur: jest.fn() };
- findPreviewTab().vm.$emit('click', { target });
-
- expect(target.blur).toHaveBeenCalled();
+ expect(wrapper.emitted('showPreview')).toBeUndefined();
+ expect(wrapper.emitted('hidePreview')).toBeUndefined();
});
it('renders markdown table template', () => {
@@ -168,12 +156,12 @@ describe('Markdown field header component', () => {
expect(wrapper.find('.js-suggestion-btn').exists()).toBe(false);
});
- it('hides preview tab when previewMarkdown property is false', () => {
+ it('hides markdown preview when previewMarkdown property is false', () => {
createWrapper({
enablePreview: false,
});
- expect(wrapper.findByTestId('preview-tab').exists()).toBe(false);
+ expect(wrapper.findByTestId('preview-toggle').exists()).toBe(false);
});
describe('restricted tool bar items', () => {
@@ -197,4 +185,38 @@ describe('Markdown field header component', () => {
expect(findToolbarButtons().length).toBe(defaultCount);
});
});
+
+ describe('when drawIOEnabled is true', () => {
+ const uploadsPath = '/uploads';
+ const markdownPreviewPath = '/preview';
+
+ beforeEach(() => {
+ createWrapper({
+ drawioEnabled: true,
+ uploadsPath,
+ markdownPreviewPath,
+ });
+ });
+
+ it('renders drawio toolbar button', () => {
+ expect(findDrawioToolbarButton().props()).toEqual({
+ uploadsPath,
+ markdownPreviewPath,
+ });
+ });
+ });
+
+ describe('with content editor switcher', () => {
+ beforeEach(() => {
+ createWrapper({
+ showContentEditorSwitcher: true,
+ });
+ });
+
+ it('re-emits event from switcher', () => {
+ wrapper.findComponent(EditorModeSwitcher).vm.$emit('input', 'richText');
+
+ expect(wrapper.emitted('enableContentEditor')).toEqual([[]]);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
index 26b536984ff..26a74036b10 100644
--- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
@@ -1,18 +1,30 @@
import axios from 'axios';
+import Autosize from 'autosize';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '~/vue_shared/constants';
+import {
+ EDITING_MODE_MARKDOWN_FIELD,
+ EDITING_MODE_CONTENT_EDITOR,
+ CLEAR_AUTOSAVE_ENTRY_EVENT,
+} from '~/vue_shared/constants';
+import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { assertProps } from 'helpers/assert_props';
import { stubComponent } from 'helpers/stub_component';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/emoji');
+jest.mock('autosize');
describe('vue_shared/component/markdown/markdown_editor', () => {
+ useLocalStorageSpy();
+
let wrapper;
const value = 'test markdown';
const renderMarkdownPath = '/api/markdown';
@@ -27,23 +39,26 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
const autocompleteDataSources = { commands: '/foobar/-/autcomplete_sources' };
let mock;
+ const defaultProps = {
+ value,
+ renderMarkdownPath,
+ markdownDocsPath,
+ quickActionsDocsPath,
+ enableAutocomplete,
+ autocompleteDataSources,
+ enablePreview,
+ formFieldProps: {
+ id: formFieldId,
+ name: formFieldName,
+ placeholder: formFieldPlaceholder,
+ 'aria-label': formFieldAriaLabel,
+ },
+ };
const buildWrapper = ({ propsData = {}, attachTo, stubs = {} } = {}) => {
wrapper = mountExtended(MarkdownEditor, {
attachTo,
propsData: {
- value,
- renderMarkdownPath,
- markdownDocsPath,
- quickActionsDocsPath,
- enableAutocomplete,
- autocompleteDataSources,
- enablePreview,
- formFieldProps: {
- id: formFieldId,
- name: formFieldName,
- placeholder: formFieldPlaceholder,
- 'aria-label': formFieldAriaLabel,
- },
+ ...defaultProps,
...propsData,
},
stubs: {
@@ -52,10 +67,31 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
},
});
};
+
+ const ContentEditorStub = stubComponent(ContentEditor);
+
const findMarkdownField = () => wrapper.findComponent(MarkdownField);
const findTextarea = () => wrapper.find('textarea');
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
- const findContentEditor = () => wrapper.findComponent(ContentEditor);
+ const findContentEditor = () => {
+ const result = wrapper.findComponent(ContentEditor);
+
+ // In Vue.js 3 there are nuances stubbing component with custom stub on mount
+ // So we try to search for stub also
+ return result.exists() ? result : wrapper.findComponent(ContentEditorStub);
+ };
+
+ const enableContentEditor = async () => {
+ findMarkdownField().vm.$emit('enableContentEditor');
+ await nextTick();
+ await waitForPromises();
+ };
+
+ const enableMarkdownEditor = async () => {
+ findContentEditor().vm.$emit('enableMarkdownEditor');
+ await nextTick();
+ await waitForPromises();
+ };
beforeEach(() => {
window.uploads_path = 'uploads';
@@ -63,8 +99,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
afterEach(() => {
- wrapper.destroy();
mock.restore();
+
+ localStorage.clear();
});
it('displays markdown field by default', () => {
@@ -83,8 +120,178 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
});
+ it.each`
+ desc | supportsQuickActions
+ ${'passes render_quick_actions param to renderMarkdownPath if quick actions are enabled'} | ${true}
+ ${'does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled'} | ${false}
+ `('$desc', async ({ supportsQuickActions }) => {
+ buildWrapper({ propsData: { supportsQuickActions } });
+
+ await enableContentEditor();
+
+ expect(mock.history.post).toHaveLength(1);
+ expect(mock.history.post[0].url).toContain(`render_quick_actions=${supportsQuickActions}`);
+ });
+
+ it('enables content editor switcher when contentEditorEnabled prop is true', () => {
+ buildWrapper({ propsData: { enableContentEditor: true } });
+
+ expect(findMarkdownField().text()).toContain('Switch to rich text');
+ });
+
+ it('hides content editor switcher when contentEditorEnabled prop is false', () => {
+ buildWrapper({ propsData: { enableContentEditor: false } });
+
+ expect(findMarkdownField().text()).not.toContain('Switch to rich text');
+ });
+
+ it('passes down any additional props to markdown field component', () => {
+ const propsData = {
+ line: { text: 'hello world', richText: 'hello world' },
+ lines: [{ text: 'hello world', richText: 'hello world' }],
+ canSuggest: true,
+ };
+
+ buildWrapper({
+ propsData: { ...propsData, myCustomProp: 'myCustomValue', 'data-testid': 'custom id' },
+ });
+
+ expect(findMarkdownField().props()).toMatchObject(propsData);
+ expect(findMarkdownField().vm.$attrs).toMatchObject({
+ myCustomProp: 'myCustomValue',
+
+ // data-testid isn't copied over
+ 'data-testid': 'markdown-field',
+ });
+ });
+
+ describe('disabled', () => {
+ it('disables markdown field when disabled prop is true', () => {
+ buildWrapper({ propsData: { disabled: true } });
+
+ expect(findMarkdownField().find('textarea').attributes('disabled')).toBeDefined();
+ });
+
+ it('enables markdown field when disabled prop is false', () => {
+ buildWrapper({ propsData: { disabled: false } });
+
+ expect(findMarkdownField().find('textarea').attributes('disabled')).toBe(undefined);
+ });
+
+ it('disables content editor when disabled prop is true', async () => {
+ buildWrapper({ propsData: { disabled: true } });
+
+ await enableContentEditor();
+
+ expect(findContentEditor().props('editable')).toBe(false);
+ });
+
+ it('enables content editor when disabled prop is false', async () => {
+ buildWrapper({ propsData: { disabled: false } });
+
+ await enableContentEditor();
+
+ expect(findContentEditor().props('editable')).toBe(true);
+ });
+ });
+
+ describe('autosize', () => {
+ it('autosizes the textarea when the value changes', async () => {
+ buildWrapper();
+ await findTextarea().setValue('Lots of newlines\n\n\n\n\n\n\nMore content\n\n\nand newlines');
+ await nextTick();
+ expect(Autosize.update).toHaveBeenCalled();
+ });
+
+ it('autosizes the textarea when the value changes from outside the component', async () => {
+ buildWrapper();
+ wrapper.setProps({ value: 'Lots of newlines\n\n\n\n\n\n\nMore content\n\n\nand newlines' });
+
+ await nextTick();
+ await waitForPromises();
+ expect(Autosize.update).toHaveBeenCalled();
+ });
+
+ it('does not autosize the textarea if markdown editor is disabled', async () => {
+ buildWrapper();
+ await enableContentEditor();
+
+ wrapper.setProps({ value: 'Lots of newlines\n\n\n\n\n\n\nMore content\n\n\nand newlines' });
+
+ expect(Autosize.update).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('autosave', () => {
+ it('automatically saves the textarea value to local storage if autosaveKey is defined', () => {
+ buildWrapper({ propsData: { autosaveKey: 'issue/1234', value: 'This is **markdown**' } });
+
+ expect(localStorage.getItem('autosave/issue/1234')).toBe('This is **markdown**');
+ });
+
+ it("loads value from local storage if autosaveKey is defined, and value isn't", () => {
+ localStorage.setItem('autosave/issue/1234', 'This is **markdown**');
+
+ buildWrapper({ propsData: { autosaveKey: 'issue/1234', value: '' } });
+
+ expect(findTextarea().element.value).toBe('This is **markdown**');
+ });
+
+ it("doesn't load value from local storage if autosaveKey is defined, and value is", () => {
+ localStorage.setItem('autosave/issue/1234', 'This is **markdown**');
+
+ buildWrapper({ propsData: { autosaveKey: 'issue/1234' } });
+
+ expect(findTextarea().element.value).toBe('test markdown');
+ });
+
+ it('does not save the textarea value to local storage if autosaveKey is not defined', () => {
+ buildWrapper({ propsData: { value: 'This is **markdown**' } });
+
+ expect(localStorage.setItem).not.toHaveBeenCalled();
+ });
+
+ it('does not save the textarea value to local storage if value is empty', () => {
+ buildWrapper({ propsData: { autosaveKey: 'issue/1234', value: '' } });
+
+ expect(localStorage.setItem).not.toHaveBeenCalled();
+ });
+
+ describe('clear local storage event handler', () => {
+ it('does not clear the local storage if the autosave key is not defined', async () => {
+ buildWrapper();
+
+ await waitForPromises();
+
+ markdownEditorEventHub.$emit(CLEAR_AUTOSAVE_ENTRY_EVENT, 'issue/1234');
+
+ expect(localStorage.removeItem).not.toHaveBeenCalled();
+ });
+
+ it('does not clear the local storage if the event autosave key does not match', async () => {
+ buildWrapper({ propsData: { autosaveKey: 'issue/1234' } });
+
+ await waitForPromises();
+
+ markdownEditorEventHub.$emit(CLEAR_AUTOSAVE_ENTRY_EVENT, 'issue/1235');
+
+ expect(localStorage.removeItem).not.toHaveBeenCalled();
+ });
+
+ it('clears the local storage if the event autosave key matches', async () => {
+ buildWrapper({ propsData: { autosaveKey: 'issue/1234' } });
+
+ await waitForPromises();
+
+ markdownEditorEventHub.$emit(CLEAR_AUTOSAVE_ENTRY_EVENT, 'issue/1234');
+
+ expect(localStorage.removeItem).toHaveBeenCalledWith('autosave/issue/1234');
+ });
+ });
+ });
+
it('renders markdown field textarea', () => {
- buildWrapper();
+ buildWrapper({ propsData: { supportsQuickActions: true } });
expect(findTextarea().attributes()).toEqual(
expect.objectContaining({
@@ -92,6 +299,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
name: formFieldName,
placeholder: formFieldPlaceholder,
'aria-label': formFieldAriaLabel,
+ 'data-supports-quick-actions': 'true',
}),
);
@@ -99,31 +307,26 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
it('fails to render if textarea id and name is not passed', () => {
- expect(() => {
- buildWrapper({ propsData: { formFieldProps: {} } });
- }).toThrow('Invalid prop: custom validator check failed for prop "formFieldProps"');
+ expect(() => assertProps(MarkdownEditor, { ...defaultProps, formFieldProps: {} })).toThrow(
+ 'Invalid prop: custom validator check failed for prop "formFieldProps"',
+ );
});
it(`emits ${EDITING_MODE_CONTENT_EDITOR} event when enableContentEditor emitted from markdown editor`, async () => {
buildWrapper();
- findMarkdownField().vm.$emit('enableContentEditor');
-
- await nextTick();
+ await enableContentEditor();
expect(wrapper.emitted(EDITING_MODE_CONTENT_EDITOR)).toHaveLength(1);
});
it(`emits ${EDITING_MODE_MARKDOWN_FIELD} event when enableMarkdownEditor emitted from content editor`, async () => {
buildWrapper({
- stubs: { ContentEditor: stubComponent(ContentEditor) },
+ stubs: { ContentEditor: ContentEditorStub },
});
- findMarkdownField().vm.$emit('enableContentEditor');
-
- await nextTick();
-
- findContentEditor().vm.$emit('enableMarkdownEditor');
+ await enableContentEditor();
+ await enableMarkdownEditor();
expect(wrapper.emitted(EDITING_MODE_MARKDOWN_FIELD)).toHaveLength(1);
});
@@ -135,7 +338,17 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
await findTextarea().setValue(newValue);
- expect(wrapper.emitted('input')).toEqual([[newValue]]);
+ expect(wrapper.emitted('input')).toEqual([[value], [newValue]]);
+ });
+
+ it('autosaves the markdown value to local storage', async () => {
+ buildWrapper({ propsData: { autosaveKey: 'issue/1234' } });
+
+ const newValue = 'new value';
+
+ await findTextarea().setValue(newValue);
+
+ expect(localStorage.getItem('autosave/issue/1234')).toBe(newValue);
});
describe('when autofocus is true', () => {
@@ -159,9 +372,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
describe(`when markdown field triggers enableContentEditor event`, () => {
- beforeEach(() => {
+ beforeEach(async () => {
buildWrapper();
- findMarkdownField().vm.$emit('enableContentEditor');
+ await enableContentEditor();
});
it('displays the content editor', () => {
@@ -169,7 +382,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
expect.objectContaining({
renderMarkdown: expect.any(Function),
uploadsPath: window.uploads_path,
- useBottomToolbar: false,
markdown: value,
}),
);
@@ -197,17 +409,27 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
});
+ describe('when contentEditor is disabled', () => {
+ it('resets the editingMode to markdownField', () => {
+ localStorage.setItem('gl-markdown-editor-mode', 'contentEditor');
+
+ buildWrapper({ propsData: { autosaveKey: 'issue/1234', enableContentEditor: false } });
+
+ expect(wrapper.vm.editingMode).toBe(EDITING_MODE_MARKDOWN_FIELD);
+ });
+ });
+
describe(`when editingMode is ${EDITING_MODE_CONTENT_EDITOR}`, () => {
- beforeEach(() => {
- buildWrapper();
- findMarkdownField().vm.$emit('enableContentEditor');
+ beforeEach(async () => {
+ buildWrapper({ propsData: { autosaveKey: 'issue/1234' } });
+ await enableContentEditor();
});
describe('when autofocus is true', () => {
beforeEach(() => {
buildWrapper({
propsData: { autofocus: true },
- stubs: { ContentEditor: stubComponent(ContentEditor) },
+ stubs: { ContentEditor: ContentEditorStub },
});
});
@@ -221,7 +443,15 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
await findContentEditor().vm.$emit('change', { markdown: newValue });
- expect(wrapper.emitted('input')).toEqual([[newValue]]);
+ expect(wrapper.emitted('input')).toEqual([[value], [newValue]]);
+ });
+
+ it('autosaves the content editor value to local storage', async () => {
+ const newValue = 'new value';
+
+ await findContentEditor().vm.$emit('change', { markdown: newValue });
+
+ expect(localStorage.getItem('autosave/issue/1234')).toBe(newValue);
});
it('bubbles up keydown event', () => {
@@ -233,9 +463,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
describe(`when richText editor triggers enableMarkdownEditor event`, () => {
- beforeEach(() => {
- findContentEditor().vm.$emit('enableMarkdownEditor');
- });
+ beforeEach(enableMarkdownEditor);
it('hides the content editor', () => {
expect(findContentEditor().exists()).toBe(false);
diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
index 9db1b779a04..9768bc7a6dd 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
@@ -25,7 +25,7 @@ describe('Suggestion Diff component', () => {
...props,
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
@@ -34,10 +34,6 @@ describe('Suggestion Diff component', () => {
window.gon.current_user_id = 1;
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findApplyButton = () => wrapper.findComponent(ApplySuggestion);
const findApplyBatchButton = () => wrapper.find('.js-apply-batch-btn');
const findAddToBatchButton = () => wrapper.find('.js-add-to-batch-btn');
diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js
index f9a8b64f89b..c46a2d3e117 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js
@@ -36,10 +36,6 @@ describe('SuggestionDiffRow', () => {
const findNewLineWrapper = () => wrapper.find('.new_line');
const findSuggestionContent = () => wrapper.find('[data-testid="suggestion-diff-content"]');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('renders correctly', () => {
it('renders the correct base suggestion markup', () => {
factory({
diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js
index d84483c1663..8c7f51664ad 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js
@@ -61,11 +61,6 @@ describe('Suggestion Diff component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('matches snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
index 8f4235cfe41..2fdab40b4bd 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
@@ -1,4 +1,5 @@
-import Vue, { nextTick } from 'vue';
+import { nextTick } from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue';
const MOCK_DATA = {
@@ -48,56 +49,37 @@ const MOCK_DATA = {
};
describe('Suggestion component', () => {
- let vm;
- let diffTable;
+ let wrapper;
- beforeEach(async () => {
- const Component = Vue.extend(SuggestionsComponent);
+ const createComponent = (props = {}) => {
+ wrapper = mountExtended(SuggestionsComponent, {
+ propsData: {
+ ...MOCK_DATA,
+ ...props,
+ },
+ });
+ };
- vm = new Component({
- propsData: MOCK_DATA,
- }).$mount();
+ const findSuggestionsContainer = () => wrapper.findByTestId('suggestions-container');
- diffTable = vm.generateDiff(0).$mount().$el;
+ beforeEach(async () => {
+ createComponent();
- jest.spyOn(vm, 'renderSuggestions').mockImplementation(() => {});
- vm.renderSuggestions();
await nextTick();
});
describe('mounted', () => {
it('renders a flash container', () => {
- expect(vm.$el.querySelector('.js-suggestions-flash')).not.toBeNull();
+ expect(wrapper.find('.js-suggestions-flash').exists()).toBe(true);
});
it('renders a container for suggestions', () => {
- expect(vm.$refs.container).not.toBeNull();
+ expect(findSuggestionsContainer().exists()).toBe(true);
});
it('renders suggestions', () => {
- expect(vm.renderSuggestions).toHaveBeenCalled();
- expect(vm.$el.innerHTML.includes('oldtest')).toBe(true);
- expect(vm.$el.innerHTML.includes('newtest')).toBe(true);
- });
- });
-
- describe('generateDiff', () => {
- it('generates a diff table', () => {
- expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull();
- });
-
- it('generates a diff table that contains contents the suggested lines', () => {
- MOCK_DATA.suggestions[0].diff_lines.forEach((line) => {
- const text = line.text.substring(1);
-
- expect(diffTable.innerHTML.includes(text)).toBe(true);
- });
- });
-
- it('generates a diff table with the correct line number for each suggested line', () => {
- const lines = diffTable.querySelectorAll('.old_line');
-
- expect(parseInt([...lines][0].innerHTML, 10)).toBe(5);
+ expect(findSuggestionsContainer().text()).toContain('oldtest');
+ expect(findSuggestionsContainer().text()).toContain('newtest');
});
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
index 82210e79799..33e9d6add99 100644
--- a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
@@ -20,11 +20,6 @@ describe('toolbar_button', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const getButtonShortcutsAttr = () => {
return wrapper.findComponent(GlButton).attributes('data-md-shortcuts');
};
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
index b1a1dbbeb7a..2489421b697 100644
--- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils';
import Toolbar from '~/vue_shared/components/markdown/toolbar.vue';
-import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue';
describe('toolbar', () => {
let wrapper;
@@ -11,10 +10,6 @@ describe('toolbar', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('user can attach file', () => {
beforeEach(() => {
createMountedWrapper();
@@ -48,18 +43,4 @@ describe('toolbar', () => {
expect(wrapper.find('.comment-toolbar').exists()).toBe(true);
});
});
-
- describe('with content editor switcher', () => {
- beforeEach(() => {
- createMountedWrapper({
- showContentEditorSwitcher: true,
- });
- });
-
- it('re-emits event from switcher', () => {
- wrapper.findComponent(EditorModeDropdown).vm.$emit('input', 'richText');
-
- expect(wrapper.emitted('enableContentEditor')).toEqual([[]]);
- });
- });
});
diff --git a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js
index 2b311b75f85..6f4902e3f96 100644
--- a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js
+++ b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js
@@ -36,8 +36,6 @@ describe('MarkdownDrawer', () => {
};
afterEach(() => {
- wrapper.destroy();
- wrapper = null;
Object.keys(cache).forEach((key) => delete cache[key]);
});
@@ -158,7 +156,7 @@ describe('MarkdownDrawer', () => {
renderGLFMSpy.mockClear();
});
- it('fetches the Markdown and caches it', async () => {
+ it('fetches the Markdown and caches it', () => {
expect(getRenderedMarkdown).toHaveBeenCalledTimes(1);
expect(Object.keys(cache)).toHaveLength(1);
});
@@ -201,13 +199,13 @@ describe('MarkdownDrawer', () => {
afterEach(() => {
getRenderedMarkdown.mockClear();
});
- it('shows alert', () => {
+ it('shows an alert', () => {
expect(findAlert().exists()).toBe(true);
});
});
describe('While Markdown is fetching', () => {
- beforeEach(async () => {
+ beforeEach(() => {
getRenderedMarkdown.mockReturnValue(new Promise(() => {}));
createComponent();
@@ -217,7 +215,7 @@ describe('MarkdownDrawer', () => {
getRenderedMarkdown.mockClear();
});
- it('shows skeleton', async () => {
+ it('shows skeleton', () => {
expect(findSkeleton().exists()).toBe(true);
});
});
diff --git a/spec/frontend/vue_shared/components/memory_graph_spec.js b/spec/frontend/vue_shared/components/memory_graph_spec.js
index ae8d5ff78ba..81325fb3269 100644
--- a/spec/frontend/vue_shared/components/memory_graph_spec.js
+++ b/spec/frontend/vue_shared/components/memory_graph_spec.js
@@ -1,10 +1,8 @@
import { GlSparklineChart } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
import MemoryGraph from '~/vue_shared/components/memory_graph.vue';
describe('MemoryGraph', () => {
- const Component = Vue.extend(MemoryGraph);
let wrapper;
const metrics = [
[1573586253.853, '2.87'],
@@ -13,12 +11,10 @@ describe('MemoryGraph', () => {
[1573586433.853, '3.0066964285714284'],
];
- afterEach(() => {
- wrapper.destroy();
- });
+ const findGlSparklineChart = () => wrapper.findComponent(GlSparklineChart);
beforeEach(() => {
- wrapper = shallowMount(Component, {
+ wrapper = shallowMount(MemoryGraph, {
propsData: {
metrics,
width: 100,
@@ -27,19 +23,15 @@ describe('MemoryGraph', () => {
});
});
- describe('chartData', () => {
- it('should calculate chartData', () => {
- expect(wrapper.vm.chartData.length).toEqual(metrics.length);
- });
-
- it('should format date & MB values', () => {
+ describe('Chart data', () => {
+ it('should have formatted date & MB values', () => {
const formattedData = [
['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'],
];
- expect(wrapper.vm.chartData).toEqual(formattedData);
+ expect(findGlSparklineChart().props('data')).toEqual(formattedData);
});
});
@@ -47,7 +39,7 @@ describe('MemoryGraph', () => {
it('should draw container with chart', () => {
expect(wrapper.element).toMatchSnapshot();
expect(wrapper.find('.memory-graph-container').exists()).toBe(true);
- expect(wrapper.findComponent(GlSparklineChart).exists()).toBe(true);
+ expect(findGlSparklineChart().exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js
index 1789610dba9..4b0b89fe1e7 100644
--- a/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js
+++ b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js
@@ -45,13 +45,6 @@ describe('Metric images tab', () => {
mountComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findUploadDropzone = () => wrapper.findComponent(UploadDropzone);
const findImages = () => wrapper.findAllComponents(MetricImagesTable);
const findModal = () => wrapper.findComponent(GlModal);
diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js
index 9c91dc9b5fc..12dca95e9ba 100644
--- a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js
+++ b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js
@@ -39,13 +39,6 @@ describe('Metrics upload item', () => {
);
};
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
const findImageLink = () => wrapper.findComponent(GlLink);
const findLabelTextSpan = () => wrapper.find('[data-testid="metric-image-label-span"]');
const findCollapseButton = () => wrapper.find('[data-testid="collapse-button"]');
diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js
index 537367940e0..626f6fc735e 100644
--- a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js
+++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js
@@ -4,11 +4,11 @@ import actionsFactory from '~/vue_shared/components/metric_images/store/actions'
import * as types from '~/vue_shared/components/metric_images/store/mutation_types';
import createStore from '~/vue_shared/components/metric_images/store';
import testAction from 'helpers/vuex_action_helper';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { fileList, initialData } from '../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
const service = {
getMetricImages: jest.fn(),
uploadMetricImage: jest.fn(),
diff --git a/spec/frontend/vue_shared/components/modal_copy_button_spec.js b/spec/frontend/vue_shared/components/modal_copy_button_spec.js
index 61e4e774420..2f8f97c5b95 100644
--- a/spec/frontend/vue_shared/components/modal_copy_button_spec.js
+++ b/spec/frontend/vue_shared/components/modal_copy_button_spec.js
@@ -6,10 +6,6 @@ import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
describe('modal copy button', () => {
let wrapper;
- afterEach(() => {
- wrapper.destroy();
- });
-
beforeEach(() => {
wrapper = shallowMount(ModalCopyButton, {
propsData: {
@@ -17,16 +13,9 @@ describe('modal copy button', () => {
title: 'Copy this value',
id: 'test-id',
},
- slots: {
- default: 'test',
- },
});
});
- it('should show the default slot', () => {
- expect(wrapper.text()).toBe('test');
- });
-
describe('clipboard', () => {
it('should fire a `success` event on click', async () => {
const root = createWrapper(wrapper.vm.$root);
diff --git a/spec/frontend/vue_shared/components/navigation_tabs_spec.js b/spec/frontend/vue_shared/components/navigation_tabs_spec.js
index b1bec28bffb..947ee756259 100644
--- a/spec/frontend/vue_shared/components/navigation_tabs_spec.js
+++ b/spec/frontend/vue_shared/components/navigation_tabs_spec.js
@@ -38,11 +38,6 @@ describe('navigation tabs component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('should render tabs', () => {
expect(wrapper.findAllComponents(GlTab)).toHaveLength(data.length);
});
diff --git a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js
index 31320b1d2a6..a116233a065 100644
--- a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js
@@ -21,7 +21,7 @@ import {
searchProjectsWithinGroupQueryResponse,
} from './mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('NewResourceDropdown component', () => {
useLocalStorageSpy();
diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap
index 3bac96069ec..de53caa66c7 100644
--- a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap
+++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap
@@ -57,7 +57,9 @@ exports[`Issue placeholder note component matches snapshot 1`] = `
<div
class="note-text md"
>
- <p>
+ <p
+ dir="auto"
+ >
Foo
</p>
diff --git a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js
index 17a62ae8a33..d7fcb9a25d4 100644
--- a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js
+++ b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js
@@ -22,13 +22,6 @@ describe('Issue Warning Component', () => {
},
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
describe('when issue is locked but not confidential', () => {
beforeEach(() => {
wrapper = createComponent({
@@ -132,12 +125,6 @@ describe('Issue Warning Component', () => {
});
});
- afterEach(() => {
- wrapperLocked.destroy();
- wrapperConfidential.destroy();
- wrapperLockedAndConfidential.destroy();
- });
-
it('renders confidential & locked messages with noteable "issue"', () => {
expect(findLockedBlock(wrapperLocked).text()).toContain('This issue is locked.');
expect(findConfidentialBlock(wrapperConfidential).text()).toContain(
diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
index 8f9f1bb336f..7e669fb7c71 100644
--- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
+++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
@@ -30,11 +30,6 @@ describe('Issue placeholder note component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('matches snapshot', () => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js
index de6ab43bc41..5897b9e0ffc 100644
--- a/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js
+++ b/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js
@@ -12,11 +12,6 @@ describe('Placeholder system note component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('matches snapshot', () => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js
index bcfd7a8ec70..7f3912dcadb 100644
--- a/spec/frontend/vue_shared/components/notes/system_note_spec.js
+++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js
@@ -46,7 +46,6 @@ describe('system note component', () => {
});
afterEach(() => {
- vm.destroy();
mock.restore();
});
@@ -65,7 +64,7 @@ describe('system note component', () => {
it('should render svg icon', () => {
createComponent(props);
- expect(vm.find('.timeline-icon svg').exists()).toBe(true);
+ expect(vm.find('[data-testid="timeline-icon"]').exists()).toBe(true);
});
// Redcarpet Markdown renderer wraps text in `<p>` tags
diff --git a/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js b/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js
index bd4b6a463ab..fa9d3cd28a9 100644
--- a/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js
+++ b/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js
@@ -10,10 +10,6 @@ describe(`TimelineEntryItem`, () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders correctly', () => {
factory();
diff --git a/spec/frontend/vue_shared/components/ordered_layout_spec.js b/spec/frontend/vue_shared/components/ordered_layout_spec.js
index 21588569d6a..b6c8c467028 100644
--- a/spec/frontend/vue_shared/components/ordered_layout_spec.js
+++ b/spec/frontend/vue_shared/components/ordered_layout_spec.js
@@ -37,10 +37,6 @@ describe('Ordered Layout', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when slotKeys are in initial slot order', () => {
beforeEach(() => {
createComponent({ slotKeys: regularSlotOrder });
diff --git a/spec/frontend/vue_shared/components/page_size_selector_spec.js b/spec/frontend/vue_shared/components/page_size_selector_spec.js
index 5ec0b863afd..fce7ceee2fe 100644
--- a/spec/frontend/vue_shared/components/page_size_selector_spec.js
+++ b/spec/frontend/vue_shared/components/page_size_selector_spec.js
@@ -14,10 +14,6 @@ describe('Page size selector component', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each(PAGE_SIZES)('shows expected text in the dropdown button for page size %s', (pageSize) => {
createWrapper({ pageSize });
diff --git a/spec/frontend/vue_shared/components/paginated_list_spec.js b/spec/frontend/vue_shared/components/paginated_list_spec.js
index ae9c920ebd2..fc9adab2e2b 100644
--- a/spec/frontend/vue_shared/components/paginated_list_spec.js
+++ b/spec/frontend/vue_shared/components/paginated_list_spec.js
@@ -33,10 +33,6 @@ describe('Pagination links component', () => {
[glPaginatedList] = wrapper.vm.$children;
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Paginated List Component', () => {
describe('props', () => {
// We test attrs and not props because we pass through to child component using v-bind:"$attrs"
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
index 86a63db0d9e..9b6f5ae3e38 100644
--- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
@@ -90,12 +90,6 @@ describe('AlertManagementEmptyState', () => {
mountComponent();
});
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
const EmptyState = () => wrapper.find('.empty-state');
const ItemsTable = () => wrapper.find('.gl-table');
const ErrorAlert = () => wrapper.findComponent(GlAlert);
@@ -108,16 +102,23 @@ describe('AlertManagementEmptyState', () => {
const findStatusTabs = () => wrapper.findComponent(GlTabs);
const findStatusFilterBadge = () => wrapper.findAllComponents(GlBadge);
+ const handleFilterItems = (filters) => {
+ Filters().vm.$emit('onFilter', filters);
+ return nextTick();
+ };
+
describe('Snowplow tracking', () => {
+ const category = 'category';
+ const action = 'action';
+
beforeEach(() => {
jest.spyOn(Tracking, 'event');
mountComponent({
- props: { trackViewsOptions: { category: 'category', action: 'action' } },
+ props: { trackViewsOptions: { category, action } },
});
});
it('should track the items list page views', () => {
- const { category, action } = wrapper.vm.trackViewsOptions;
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
});
@@ -234,14 +235,14 @@ describe('AlertManagementEmptyState', () => {
findPagination().vm.$emit('input', 3);
await nextTick();
- expect(wrapper.vm.previousPage).toBe(2);
+ expect(findPagination().props('prevPage')).toBe(2);
});
it('returns 0 when it is the first page', async () => {
findPagination().vm.$emit('input', 1);
await nextTick();
- expect(wrapper.vm.previousPage).toBe(0);
+ expect(findPagination().props('prevPage')).toBe(0);
});
});
@@ -265,14 +266,14 @@ describe('AlertManagementEmptyState', () => {
findPagination().vm.$emit('input', 1);
await nextTick();
- expect(wrapper.vm.nextPage).toBe(2);
+ expect(findPagination().props('nextPage')).toBe(2);
});
it('returns `null` when currentPage is already last page', async () => {
findStatusTabs().vm.$emit('input', 1);
findPagination().vm.$emit('input', 1);
await nextTick();
- expect(wrapper.vm.nextPage).toBeNull();
+ expect(findPagination().props('nextPage')).toBeNull();
});
});
});
@@ -320,36 +321,32 @@ describe('AlertManagementEmptyState', () => {
it('returns correctly applied filter search values', async () => {
const searchTerm = 'foo';
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- searchTerm,
- });
-
+ await handleFilterItems([{ type: 'filtered-search-term', value: { data: searchTerm } }]);
await nextTick();
- expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]);
+ expect(Filters().props('initialFilterValue')).toEqual([searchTerm]);
});
- it('updates props tied to getIncidents GraphQL query', () => {
- wrapper.vm.handleFilterItems(mockFilters);
-
- expect(wrapper.vm.authorUsername).toBe('root');
- expect(wrapper.vm.assigneeUsername).toEqual('root2');
- expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data);
- });
+ it('updates props tied to getIncidents GraphQL query', async () => {
+ await handleFilterItems(mockFilters);
- it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- authorUsername: 'foo',
- searchTerm: 'bar',
- });
+ const [
+ {
+ value: { data: authorUsername },
+ },
+ {
+ value: { data: assigneeUsername },
+ },
+ searchTerm,
+ ] = Filters().props('initialFilterValue');
- wrapper.vm.handleFilterItems([]);
+ expect(authorUsername).toBe('root');
+ expect(assigneeUsername).toEqual('root2');
+ expect(searchTerm).toBe(mockFilters[2].value.data);
+ });
- expect(wrapper.vm.authorUsername).toBe('');
- expect(wrapper.vm.searchTerm).toBe('');
+ it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', async () => {
+ await handleFilterItems([]);
+ expect(Filters().props('initialFilterValue')).toEqual([]);
});
});
});
diff --git a/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js
index 112cdaf74c6..2a1a6342c38 100644
--- a/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js
+++ b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js
@@ -25,10 +25,6 @@ describe('Pagination bar', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('events', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/pagination_links_spec.js b/spec/frontend/vue_shared/components/pagination_links_spec.js
index d444ad7a733..99a4f776305 100644
--- a/spec/frontend/vue_shared/components/pagination_links_spec.js
+++ b/spec/frontend/vue_shared/components/pagination_links_spec.js
@@ -44,10 +44,6 @@ describe('Pagination links component', () => {
glPagination = wrapper.findComponent(GlPagination);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should provide translated text to GitLab UI pagination', () => {
Object.entries(translations).forEach((entry) => {
expect(glPagination.vm[entry[0]]).toBe(entry[1]);
diff --git a/spec/frontend/vue_shared/components/panel_resizer_spec.js b/spec/frontend/vue_shared/components/panel_resizer_spec.js
index 0e261124cbf..a535fe4939c 100644
--- a/spec/frontend/vue_shared/components/panel_resizer_spec.js
+++ b/spec/frontend/vue_shared/components/panel_resizer_spec.js
@@ -27,10 +27,6 @@ describe('Panel Resizer component', () => {
el.dispatchEvent(event);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render a div element with the correct classes and styles', () => {
wrapper = mount(PanelResizer, {
propsData: {
diff --git a/spec/frontend/vue_shared/components/papa_parse_alert_spec.js b/spec/frontend/vue_shared/components/papa_parse_alert_spec.js
index ff4febd647e..a44a1aba8c0 100644
--- a/spec/frontend/vue_shared/components/papa_parse_alert_spec.js
+++ b/spec/frontend/vue_shared/components/papa_parse_alert_spec.js
@@ -16,10 +16,6 @@ describe('app/assets/javascripts/vue_shared/components/papa_parse_alert.vue', ()
const findAlert = () => wrapper.findComponent(GlAlert);
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render alert with correct props', async () => {
createComponent({ errorMessages: [{ code: 'MissingQuotes' }] });
await nextTick();
diff --git a/spec/frontend/vue_shared/components/project_avatar_spec.js b/spec/frontend/vue_shared/components/project_avatar_spec.js
index af828fbca51..330ff001db9 100644
--- a/spec/frontend/vue_shared/components/project_avatar_spec.js
+++ b/spec/frontend/vue_shared/components/project_avatar_spec.js
@@ -15,10 +15,6 @@ describe('ProjectAvatar', () => {
wrapper = shallowMount(ProjectAvatar, { propsData: { ...defaultProps, ...props }, attrs });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders GlAvatar with correct props', () => {
createComponent();
@@ -29,6 +25,7 @@ describe('ProjectAvatar', () => {
entityName: defaultProps.projectName,
size: 32,
src: '',
+ fallbackOnError: true,
});
});
diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
index 4e0c318c84e..d704fcc0e7b 100644
--- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
+++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
@@ -1,57 +1,49 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import { GlButton } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import mockProjects from 'test_fixtures_static/projects.json';
import { trimText } from 'helpers/text_helper';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
describe('ProjectListItem component', () => {
- const Component = Vue.extend(ProjectListItem);
let wrapper;
- let vm;
- let options;
const project = JSON.parse(JSON.stringify(mockProjects))[0];
- beforeEach(() => {
- options = {
+ const createWrapper = ({ propsData } = {}) => {
+ wrapper = shallowMountExtended(ProjectListItem, {
propsData: {
project,
selected: false,
+ ...propsData,
},
- };
- });
-
- afterEach(() => {
- wrapper.vm.$destroy();
- });
-
- it('does not render a check mark icon if selected === false', () => {
- wrapper = shallowMount(Component, options);
-
- expect(wrapper.find('.js-selected-icon').exists()).toBe(false);
- });
+ });
+ };
- it('renders a check mark icon if selected === true', () => {
- options.propsData.selected = true;
+ const findProjectNamespace = () => wrapper.findByTestId('project-namespace');
+ const findProjectName = () => wrapper.findByTestId('project-name');
- wrapper = shallowMount(Component, options);
+ it.each([true, false])('renders a checkmark correctly when selected === "%s"', (selected) => {
+ createWrapper({
+ propsData: {
+ selected,
+ },
+ });
- expect(wrapper.find('.js-selected-icon').exists()).toBe(true);
+ expect(wrapper.findByTestId('selected-icon').exists()).toBe(selected);
});
- it(`emits a "clicked" event when clicked`, () => {
- wrapper = shallowMount(Component, options);
- ({ vm } = wrapper);
+ it(`emits a "clicked" event when the button is clicked`, () => {
+ createWrapper();
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
- wrapper.vm.onClick();
+ expect(wrapper.emitted('click')).toBeUndefined();
+ wrapper.findComponent(GlButton).vm.$emit('click');
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('click');
+ expect(wrapper.emitted('click')).toHaveLength(1);
});
it(`renders the project avatar`, () => {
- wrapper = shallowMount(Component, options);
+ createWrapper();
const avatar = wrapper.findComponent(ProjectAvatar);
expect(avatar.exists()).toBe(true);
@@ -63,48 +55,73 @@ describe('ProjectListItem component', () => {
});
it(`renders a simple namespace name with a trailing slash`, () => {
- options.propsData.project.name_with_namespace = 'a / b';
-
- wrapper = shallowMount(Component, options);
- const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
+ createWrapper({
+ propsData: {
+ project: {
+ ...project,
+ name_with_namespace: 'a / b',
+ },
+ },
+ });
+ const renderedNamespace = trimText(findProjectNamespace().text());
expect(renderedNamespace).toBe('a /');
});
it(`renders a properly truncated namespace with a trailing slash`, () => {
- options.propsData.project.name_with_namespace = 'a / b / c / d / e / f';
-
- wrapper = shallowMount(Component, options);
- const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
+ createWrapper({
+ propsData: {
+ project: {
+ ...project,
+ name_with_namespace: 'a / b / c / d / e / f',
+ },
+ },
+ });
+ const renderedNamespace = trimText(findProjectNamespace().text());
expect(renderedNamespace).toBe('a / ... / e /');
});
it(`renders a simple namespace name of a GraphQL project`, () => {
- options.propsData.project.name_with_namespace = undefined;
- options.propsData.project.nameWithNamespace = 'test';
-
- wrapper = shallowMount(Component, options);
- const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
+ createWrapper({
+ propsData: {
+ project: {
+ ...project,
+ name_with_namespace: undefined,
+ nameWithNamespace: 'test',
+ },
+ },
+ });
+ const renderedNamespace = trimText(findProjectNamespace().text());
expect(renderedNamespace).toBe('test /');
});
it(`renders the project name`, () => {
- options.propsData.project.name = 'my-test-project';
-
- wrapper = shallowMount(Component, options);
- const renderedName = trimText(wrapper.find('.js-project-name').text());
+ createWrapper({
+ propsData: {
+ project: {
+ ...project,
+ name: 'my-test-project',
+ },
+ },
+ });
+ const renderedName = trimText(findProjectName().text());
expect(renderedName).toBe('my-test-project');
});
it(`renders the project name with highlighting in the case of a search query match`, () => {
- options.propsData.project.name = 'my-test-project';
- options.propsData.matcher = 'pro';
-
- wrapper = shallowMount(Component, options);
- const renderedName = trimText(wrapper.find('.js-project-name').html());
+ createWrapper({
+ propsData: {
+ project: {
+ ...project,
+ name: 'my-test-project',
+ },
+ matcher: 'pro',
+ },
+ });
+ const renderedName = trimText(findProjectName().html());
const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject';
expect(renderedName).toContain(expected);
@@ -112,11 +129,16 @@ describe('ProjectListItem component', () => {
it('prevents search query and project name XSS', () => {
const alertSpy = jest.spyOn(window, 'alert');
- options.propsData.project.name = "my-xss-pro<script>alert('XSS');</script>ject";
- options.propsData.matcher = "pro<script>alert('XSS');</script>";
-
- wrapper = shallowMount(Component, options);
- const renderedName = trimText(wrapper.find('.js-project-name').html());
+ createWrapper({
+ propsData: {
+ project: {
+ ...project,
+ name: "my-xss-pro<script>alert('XSS');</script>ject",
+ },
+ matcher: "pro<script>alert('XSS');</script>",
+ },
+ });
+ const renderedName = trimText(findProjectName().html());
const expected = 'my-xss-project';
expect(renderedName).toContain(expected);
diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
index a0832dd7030..5e304f1c118 100644
--- a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
+++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
@@ -1,7 +1,7 @@
import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { head } from 'lodash';
-import Vue, { nextTick } from 'vue';
+import { nextTick } from 'vue';
import mockProjects from 'test_fixtures_static/projects.json';
import { trimText } from 'helpers/text_helper';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
@@ -25,7 +25,7 @@ describe('ProjectSelector component', () => {
};
beforeEach(() => {
- wrapper = mount(Vue.extend(ProjectSelector), {
+ wrapper = mount(ProjectSelector, {
propsData: {
projectSearchResults: searchResults,
selectedProjects: selected,
diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
new file mode 100644
index 00000000000..3e4d5c558f6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
@@ -0,0 +1,266 @@
+import { GlAvatarLabeled, GlBadge, GlIcon, GlPopover } from '@gitlab/ui';
+import projects from 'test_fixtures/api/users/projects/get.json';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import {
+ VISIBILITY_TYPE_ICON,
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ PROJECT_VISIBILITY_TYPE,
+} from '~/visibility_level/constants';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import { FEATURABLE_DISABLED, FEATURABLE_ENABLED } from '~/featurable/constants';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+jest.mock('lodash/uniqueId', () => (prefix) => `${prefix}1`);
+
+describe('ProjectsListItem', () => {
+ let wrapper;
+
+ const [project] = convertObjectPropsToCamelCase(projects, { deep: true });
+
+ const defaultPropsData = { project };
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = mountExtended(ProjectsListItem, {
+ propsData: { ...defaultPropsData, ...propsData },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ });
+ };
+
+ const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled);
+ const findIssuesLink = () => wrapper.findByRole('link', { name: ProjectsListItem.i18n.issues });
+ const findForksLink = () => wrapper.findByRole('link', { name: ProjectsListItem.i18n.forks });
+ const findProjectTopics = () => wrapper.findByTestId('project-topics');
+ const findPopover = () => findProjectTopics().findComponent(GlPopover);
+ const findProjectDescription = () => wrapper.findByTestId('project-description');
+
+ it('renders project avatar', () => {
+ createComponent();
+
+ const avatarLabeled = findAvatarLabeled();
+
+ expect(avatarLabeled.props()).toMatchObject({
+ label: project.name,
+ labelLink: project.webUrl,
+ });
+ expect(avatarLabeled.attributes()).toMatchObject({
+ 'entity-id': project.id.toString(),
+ 'entity-name': project.name,
+ shape: 'rect',
+ size: '48',
+ });
+ });
+
+ it('renders visibility icon with tooltip', () => {
+ createComponent();
+
+ const icon = findAvatarLabeled().findComponent(GlIcon);
+ const tooltip = getBinding(icon.element, 'gl-tooltip');
+
+ expect(icon.props('name')).toBe(VISIBILITY_TYPE_ICON[VISIBILITY_LEVEL_PRIVATE_STRING]);
+ expect(tooltip.value).toBe(PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_PRIVATE_STRING]);
+ });
+
+ it('renders access role badge', () => {
+ createComponent();
+
+ expect(findAvatarLabeled().findComponent(UserAccessRoleBadge).text()).toBe(
+ ACCESS_LEVEL_LABELS[project.permissions.projectAccess.accessLevel],
+ );
+ });
+
+ describe('if project is archived', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ project: {
+ ...project,
+ archived: true,
+ },
+ },
+ });
+ });
+
+ it('renders the archived badge', () => {
+ expect(
+ wrapper
+ .findAllComponents(GlBadge)
+ .wrappers.find((badge) => badge.text() === ProjectsListItem.i18n.archived),
+ ).not.toBeUndefined();
+ });
+ });
+
+ it('renders stars count', () => {
+ createComponent();
+
+ const starsLink = wrapper.findByRole('link', { name: ProjectsListItem.i18n.stars });
+ const tooltip = getBinding(starsLink.element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(ProjectsListItem.i18n.stars);
+ expect(starsLink.attributes('href')).toBe(`${project.webUrl}/-/starrers`);
+ expect(starsLink.text()).toBe(project.starCount.toString());
+ expect(starsLink.findComponent(GlIcon).props('name')).toBe('star-o');
+ });
+
+ it('renders updated at', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(TimeAgoTooltip).props('time')).toBe(project.updatedAt);
+ });
+
+ describe('when issues are enabled', () => {
+ it('renders issues count', () => {
+ createComponent();
+
+ const issuesLink = findIssuesLink();
+ const tooltip = getBinding(issuesLink.element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(ProjectsListItem.i18n.issues);
+ expect(issuesLink.attributes('href')).toBe(`${project.webUrl}/-/issues`);
+ expect(issuesLink.text()).toBe(project.openIssuesCount.toString());
+ expect(issuesLink.findComponent(GlIcon).props('name')).toBe('issues');
+ });
+ });
+
+ describe('when issues are not enabled', () => {
+ it('does not render issues count', () => {
+ createComponent({
+ propsData: {
+ project: {
+ ...project,
+ issuesAccessLevel: FEATURABLE_DISABLED,
+ },
+ },
+ });
+
+ expect(findIssuesLink().exists()).toBe(false);
+ });
+ });
+
+ describe('when forking is enabled', () => {
+ it('renders forks count', () => {
+ createComponent();
+
+ const forksLink = findForksLink();
+ const tooltip = getBinding(forksLink.element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(ProjectsListItem.i18n.forks);
+ expect(forksLink.attributes('href')).toBe(`${project.webUrl}/-/forks`);
+ expect(forksLink.text()).toBe(project.openIssuesCount.toString());
+ expect(forksLink.findComponent(GlIcon).props('name')).toBe('fork');
+ });
+ });
+
+ describe('when forking is not enabled', () => {
+ it.each([
+ {
+ ...project,
+ forksCount: 2,
+ forkingAccessLevel: FEATURABLE_DISABLED,
+ },
+ {
+ ...project,
+ forksCount: undefined,
+ forkingAccessLevel: FEATURABLE_ENABLED,
+ },
+ ])('does not render forks count', (modifiedProject) => {
+ createComponent({
+ propsData: {
+ project: modifiedProject,
+ },
+ });
+
+ expect(findForksLink().exists()).toBe(false);
+ });
+ });
+
+ describe('if project has topics', () => {
+ it('renders first three topics', () => {
+ createComponent();
+
+ const firstThreeTopics = project.topics.slice(0, 3);
+ const firstThreeBadges = findProjectTopics().findAllComponents(GlBadge).wrappers.slice(0, 3);
+ const firstThreeBadgesText = firstThreeBadges.map((badge) => badge.text());
+ const firstThreeBadgesHref = firstThreeBadges.map((badge) => badge.attributes('href'));
+
+ expect(firstThreeTopics).toEqual(firstThreeBadgesText);
+ expect(firstThreeBadgesHref).toEqual(
+ firstThreeTopics.map((topic) => `/explore/projects/topics/${encodeURIComponent(topic)}`),
+ );
+ });
+
+ it('renders the rest of the topics in a popover', () => {
+ createComponent();
+
+ const topics = project.topics.slice(3);
+ const badges = findPopover().findAllComponents(GlBadge).wrappers;
+ const badgesText = badges.map((badge) => badge.text());
+ const badgesHref = badges.map((badge) => badge.attributes('href'));
+
+ expect(topics).toEqual(badgesText);
+ expect(badgesHref).toEqual(
+ topics.map((topic) => `/explore/projects/topics/${encodeURIComponent(topic)}`),
+ );
+ });
+
+ it('renders button to open popover', () => {
+ createComponent();
+
+ const expectedButtonId = 'project-topics-popover-1';
+
+ expect(wrapper.findByText('+ 2 more').attributes('id')).toBe(expectedButtonId);
+ expect(findPopover().props('target')).toBe(expectedButtonId);
+ });
+
+ describe('when topic has a name longer than 15 characters', () => {
+ it('truncates name and shows tooltip with full name', () => {
+ const topicWithLongName = 'topic with very very very long name';
+
+ createComponent({
+ propsData: {
+ project: {
+ ...project,
+ topics: [topicWithLongName, ...project.topics],
+ },
+ },
+ });
+
+ const firstTopicBadge = findProjectTopics().findComponent(GlBadge);
+ const tooltip = getBinding(firstTopicBadge.element, 'gl-tooltip');
+
+ expect(firstTopicBadge.text()).toBe('topic with ver…');
+ expect(tooltip.value).toBe(topicWithLongName);
+ });
+ });
+ });
+
+ describe('when project has a description', () => {
+ it('renders description', () => {
+ const descriptionHtml = '<p>Foo bar</p>';
+
+ createComponent({
+ propsData: {
+ project: {
+ ...project,
+ descriptionHtml,
+ },
+ },
+ });
+
+ expect(findProjectDescription().element.innerHTML).toBe(descriptionHtml);
+ });
+ });
+
+ describe('when project does not have a description', () => {
+ it('does not render description', () => {
+ createComponent();
+
+ expect(findProjectDescription().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js
new file mode 100644
index 00000000000..9380e19c39e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js
@@ -0,0 +1,34 @@
+import projects from 'test_fixtures/api/users/projects/get.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
+import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+describe('ProjectsList', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ projects: convertObjectPropsToCamelCase(projects, { deep: true }),
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ProjectsList, {
+ propsData: defaultPropsData,
+ });
+ };
+
+ it('renders list with `ProjectListItem` component', () => {
+ createComponent();
+
+ const projectsListItemWrappers = wrapper.findAllComponents(ProjectsListItem).wrappers;
+ const expectedProps = projectsListItemWrappers.map((projectsListItemWrapper) =>
+ projectsListItemWrapper.props(),
+ );
+
+ expect(expectedProps).toEqual(
+ defaultPropsData.projects.map((project) => ({
+ project,
+ })),
+ );
+ });
+});
diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
index e8d76991b90..eadcb6ceeb7 100644
--- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
+++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
@@ -46,31 +46,15 @@ exports[`Package code instruction single line to match the default snapshot 1`]
class="input-group-append"
data-testid="instruction-button"
>
- <button
- aria-label="Copy npm install command"
- aria-live="polite"
- class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon"
- data-clipboard-handle-tooltip="false"
- data-clipboard-text="npm i @my-package"
- id="clipboard-button-1"
+ <clipboard-button-stub
+ category="secondary"
+ class="input-group-text"
+ size="medium"
+ text="npm i @my-package"
title="Copy npm install command"
- type="button"
- >
- <!---->
-
- <svg
- aria-hidden="true"
- class="gl-button-icon gl-icon s16"
- data-testid="copy-to-clipboard-icon"
- role="img"
- >
- <use
- href="#copy-to-clipboard"
- />
- </svg>
-
- <!---->
- </button>
+ tooltipplacement="top"
+ variant="default"
+ />
</span>
</div>
</div>
diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap
index 66cf2354bc7..5c487754b87 100644
--- a/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap
+++ b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap
@@ -8,7 +8,7 @@ exports[`History Item renders the correct markup 1`] = `
class="timeline-entry-inner"
>
<div
- class="timeline-icon"
+ class="gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600 gl-float-left"
>
<gl-icon-stub
name="pencil"
diff --git a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
index 8f19f0ea14d..299535775e0 100644
--- a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
+++ b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
@@ -1,4 +1,4 @@
-import { mount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
@@ -14,7 +14,7 @@ describe('Package code instruction', () => {
};
function createComponent(props = {}) {
- wrapper = mount(CodeInstruction, {
+ wrapper = shallowMount(CodeInstruction, {
propsData: {
...defaultProps,
...props,
@@ -26,10 +26,6 @@ describe('Package code instruction', () => {
const findInputElement = () => wrapper.find('[data-testid="instruction-input"]');
const findMultilineInstruction = () => wrapper.find('[data-testid="multiline-instruction"]');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('single line', () => {
beforeEach(() =>
createComponent({
diff --git a/spec/frontend/vue_shared/components/registry/details_row_spec.js b/spec/frontend/vue_shared/components/registry/details_row_spec.js
index ebc9816f983..9ef1ce5647d 100644
--- a/spec/frontend/vue_shared/components/registry/details_row_spec.js
+++ b/spec/frontend/vue_shared/components/registry/details_row_spec.js
@@ -20,11 +20,6 @@ describe('DetailsRow', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('has a default slot', () => {
mountComponent();
expect(findDefaultSlot().exists()).toBe(true);
diff --git a/spec/frontend/vue_shared/components/registry/history_item_spec.js b/spec/frontend/vue_shared/components/registry/history_item_spec.js
index 947520567e6..17abe06dbee 100644
--- a/spec/frontend/vue_shared/components/registry/history_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/history_item_spec.js
@@ -22,11 +22,6 @@ describe('History Item', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findTimelineEntry = () => wrapper.findComponent(TimelineEntryItem);
const findGlIcon = () => wrapper.findComponent(GlIcon);
const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]');
diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js
index b941eb77c32..298fa163d59 100644
--- a/spec/frontend/vue_shared/components/registry/list_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js
@@ -30,11 +30,6 @@ describe('list item', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe.each`
slotName | finderFunction
${'left-primary'} | ${findLeftPrimarySlot}
diff --git a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js
index a04e1e237d4..278b09d80b2 100644
--- a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js
@@ -14,16 +14,11 @@ describe('Metadata Item', () => {
wrapper = shallowMount(component, {
propsData,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const findIcon = () => wrapper.findComponent(GlIcon);
const findLink = (w = wrapper) => w.findComponent(GlLink);
const findText = () => wrapper.find('[data-testid="metadata-item-text"]');
diff --git a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js
index 616fefe847e..b93fa37546f 100644
--- a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js
+++ b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js
@@ -31,10 +31,6 @@ describe('Persisted dropdown selection', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('local storage sync', () => {
it('uses the local storage sync component with the correct props', () => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/registry/registry_search_spec.js b/spec/frontend/vue_shared/components/registry/registry_search_spec.js
index 591447a37c2..59bb0646350 100644
--- a/spec/frontend/vue_shared/components/registry/registry_search_spec.js
+++ b/spec/frontend/vue_shared/components/registry/registry_search_spec.js
@@ -36,11 +36,6 @@ describe('Registry Search', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('searching', () => {
it('has a filtered-search component', () => {
mountComponent();
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 efb57ddd310..ec1451de470 100644
--- a/spec/frontend/vue_shared/components/registry/title_area_spec.js
+++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js
@@ -36,11 +36,6 @@ describe('title area', () => {
return acc;
}, {});
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('title', () => {
it('if slot is not present defaults to prop', () => {
mountComponent();
diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap
deleted file mode 100644
index cdfe311acd9..00000000000
--- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap
+++ /dev/null
@@ -1,23 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Resizable Chart Container renders the component 1`] = `
-<div>
- <template>
- <div
- class="slot"
- >
- <span
- class="width"
- >
- 0
- </span>
-
- <span
- class="height"
- >
- 0
- </span>
- </div>
- </template>
-</div>
-`;
diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap
index 623f7d083c5..65427374e1b 100644
--- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap
+++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap
@@ -15,8 +15,8 @@ exports[`Resizable Skeleton Loader default setup renders the bars, labels, and g
</title>
<rect
clip-path="url(#null-idClip)"
+ fill="url(#null-idGradient)"
height="130"
- style="fill: url(#null-idGradient);"
width="400"
x="0"
y="0"
@@ -234,8 +234,8 @@ exports[`Resizable Skeleton Loader with custom settings renders the correct posi
</title>
<rect
clip-path="url(#-idClip)"
+ fill="url(#-idGradient)"
height="130"
- style="fill: url(#-idGradient);"
width="400"
x="0"
y="0"
diff --git a/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js b/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js
deleted file mode 100644
index 7536df24ac6..00000000000
--- a/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import { mount } from '@vue/test-utils';
-import $ from 'jquery';
-import { nextTick } from 'vue';
-import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
-
-jest.mock('~/lib/utils/common_utils', () => ({
- debounceByAnimationFrame(callback) {
- return jest.spyOn({ callback }, 'callback');
- },
-}));
-
-describe('Resizable Chart Container', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = mount(ResizableChartContainer, {
- scopedSlots: {
- default: `
- <template #default="{ width, height }">
- <div class="slot">
- <span class="width">{{width}}</span>
- <span class="height">{{height}}</span>
- </div>
- </template>
- `,
- },
- });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders the component', () => {
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('updates the slot width and height props', async () => {
- const width = 1920;
- const height = 1080;
-
- // JSDOM mocks and sets clientWidth/clientHeight to 0 so we set manually
- wrapper.vm.$refs.chartWrapper = { clientWidth: width, clientHeight: height };
-
- $(document).trigger('content.resize');
-
- await nextTick();
- const widthNode = wrapper.find('.slot > .width');
- const heightNode = wrapper.find('.slot > .height');
-
- expect(parseInt(widthNode.text(), 10)).toEqual(width);
- expect(parseInt(heightNode.text(), 10)).toEqual(height);
- });
-
- it('calls onResize on manual resize', () => {
- $(document).trigger('content.resize');
- expect(wrapper.vm.debouncedResize).toHaveBeenCalled();
- });
-
- it('calls onResize on page resize', () => {
- window.dispatchEvent(new Event('resize'));
- expect(wrapper.vm.debouncedResize).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js
index bfc3aeb0303..043552baf0c 100644
--- a/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js
+++ b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js
@@ -19,12 +19,6 @@ describe('Resizable Skeleton Loader', () => {
expect(labelItems.length).toBe(8);
};
- afterEach(() => {
- if (wrapper?.destroy) {
- wrapper.destroy();
- }
- });
-
describe('default setup', () => {
beforeEach(() => {
createComponent({ uniqueKey: null });
diff --git a/spec/frontend/vue_shared/components/rich_timestamp_tooltip_spec.js b/spec/frontend/vue_shared/components/rich_timestamp_tooltip_spec.js
index 5d96fe27676..11ee2e56c14 100644
--- a/spec/frontend/vue_shared/components/rich_timestamp_tooltip_spec.js
+++ b/spec/frontend/vue_shared/components/rich_timestamp_tooltip_spec.js
@@ -27,10 +27,6 @@ describe('RichTimestampTooltip', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders the tooltip text header', () => {
expect(wrapper.findByTestId('header-text').text()).toBe('Created just now');
});
diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap
deleted file mode 100644
index d14f66df8a1..00000000000
--- a/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap
+++ /dev/null
@@ -1,3 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`RunnerDockerInstructions renders contents 1`] = `"To install Runner in a container follow the instructions described in the GitLab documentation View installation instructions Close"`;
diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap
deleted file mode 100644
index 1172bf07dff..00000000000
--- a/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap
+++ /dev/null
@@ -1,3 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`RunnerKubernetesInstructions renders contents 1`] = `"To install Runner in Kubernetes follow the instructions described in the GitLab documentation. View installation instructions Close"`;
diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js
index f9d700fe67f..c6cd963fc33 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js
@@ -59,17 +59,13 @@ describe('RunnerCliInstructions component', () => {
runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockInstructions);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when the instructions are shown', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
- it('should not show alert', async () => {
+ it('should not show alert', () => {
expect(findAlert().exists()).toBe(false);
});
@@ -89,13 +85,13 @@ describe('RunnerCliInstructions component', () => {
});
});
- it('binary instructions are shown', async () => {
+ it('binary instructions are shown', () => {
const instructions = findBinaryInstructions().text();
expect(instructions).toBe(installInstructions.trim());
});
- it('register command is shown with a replaced token', async () => {
+ it('register command is shown with a replaced token', () => {
const command = findRegisterCommand().text();
expect(command).toBe(
diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js
index 2922d261b24..94823bb640b 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js
@@ -17,7 +17,11 @@ describe('RunnerDockerInstructions', () => {
});
it('renders contents', () => {
- expect(wrapper.text().replace(/\s+/g, ' ')).toMatchSnapshot();
+ expect(wrapper.text()).toContain(
+ 'To install Runner in a container follow the instructions described in the GitLab documentation',
+ );
+ expect(wrapper.text()).toContain('View installation instructions');
+ expect(wrapper.text()).toContain('Close');
});
it('renders link', () => {
diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js
index 0bfcc0e3d86..9d6658e002c 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js
@@ -17,7 +17,11 @@ describe('RunnerKubernetesInstructions', () => {
});
it('renders contents', () => {
- expect(wrapper.text().replace(/\s+/g, ' ')).toMatchSnapshot();
+ expect(wrapper.text()).toContain(
+ 'To install Runner in Kubernetes follow the instructions described in the GitLab documentation.',
+ );
+ expect(wrapper.text()).toContain('View installation instructions');
+ expect(wrapper.text()).toContain('Close');
});
it('renders link', () => {
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
index 8f593b6aa1b..2eaf46e6209 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
@@ -1,10 +1,10 @@
import { GlAlert, GlModal, GlButton, GlSkeletonLoader } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { shallowMount } from '@vue/test-utils';
+import { ErrorWrapper } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
@@ -15,6 +15,8 @@ import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/i
import { mockRunnerPlatforms } from './mock_data';
+const mockPlatformList = mockRunnerPlatforms.data.runnerPlatforms.nodes;
+
Vue.use(VueApollo);
let resizeCallback;
@@ -41,28 +43,36 @@ describe('RunnerInstructionsModal component', () => {
let runnerPlatformsHandler;
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
- const findAlert = () => wrapper.findComponent(GlAlert);
+ const findAlert = (variant = 'danger') => {
+ const { wrappers } = wrapper
+ .findAllComponents(GlAlert)
+ .filter((w) => w.props('variant') === variant);
+ return wrappers[0] || new ErrorWrapper();
+ };
const findModal = () => wrapper.findComponent(GlModal);
const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons');
const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton);
const findRunnerCliInstructions = () => wrapper.findComponent(RunnerCliInstructions);
- const createComponent = ({ props, shown = true, ...options } = {}) => {
+ const createComponent = ({
+ props,
+ shown = true,
+ mountFn = shallowMountExtended,
+ ...options
+ } = {}) => {
const requestHandlers = [[getRunnerPlatformsQuery, runnerPlatformsHandler]];
fakeApollo = createMockApollo(requestHandlers);
- wrapper = extendedWrapper(
- shallowMount(RunnerInstructionsModal, {
- propsData: {
- modalId: 'runner-instructions-modal',
- registrationToken: 'MY_TOKEN',
- ...props,
- },
- apolloProvider: fakeApollo,
- ...options,
- }),
- );
+ wrapper = mountFn(RunnerInstructionsModal, {
+ propsData: {
+ modalId: 'runner-instructions-modal',
+ registrationToken: 'MY_TOKEN',
+ ...props,
+ },
+ apolloProvider: fakeApollo,
+ ...options,
+ });
// trigger open modal
if (shown) {
@@ -74,34 +84,47 @@ describe('RunnerInstructionsModal component', () => {
runnerPlatformsHandler = jest.fn().mockResolvedValue(mockRunnerPlatforms);
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when the modal is shown', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
- it('should not show alert', async () => {
+ it('should not show alert', () => {
expect(findAlert().exists()).toBe(false);
});
+ it('should not show deprecation alert', () => {
+ expect(findAlert('warning').exists()).toBe(false);
+ });
+
it('should contain a number of platforms buttons', () => {
expect(runnerPlatformsHandler).toHaveBeenCalledWith({});
const buttons = findPlatformButtons();
- expect(buttons).toHaveLength(mockRunnerPlatforms.data.runnerPlatforms.nodes.length);
+ expect(buttons).toHaveLength(mockPlatformList.length);
});
it('should display architecture options', () => {
const { architectures } = findRunnerCliInstructions().props('platform');
- expect(architectures).toEqual(
- mockRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes,
- );
+ expect(architectures).toEqual(mockPlatformList[0].architectures.nodes);
+ });
+
+ describe.each`
+ glFeatures | deprecationAlertExists
+ ${{}} | ${false}
+ ${{ createRunnerWorkflowForAdmin: true }} | ${true}
+ ${{ createRunnerWorkflowForNamespace: true }} | ${true}
+ `('with features $glFeatures', ({ glFeatures, deprecationAlertExists }) => {
+ beforeEach(() => {
+ createComponent({ provide: { glFeatures } });
+ });
+
+ it(`alert is ${deprecationAlertExists ? 'shown' : 'not shown'}`, () => {
+ expect(findAlert('warning').exists()).toBe(deprecationAlertExists);
+ });
});
describe('when the modal resizes', () => {
@@ -119,6 +142,14 @@ describe('RunnerInstructionsModal component', () => {
expect(findPlatformButtonGroup().props('vertical')).toBeUndefined();
});
});
+
+ it('should focus platform button', async () => {
+ createComponent({ shown: true, mountFn: mountExtended, attachTo: document.body });
+ wrapper.vm.show();
+ await waitForPromises();
+
+ expect(document.activeElement.textContent.trim()).toBe(mockPlatformList[0].humanReadableName);
+ });
});
describe.each([null, 'DEFINED'])('when registration token is %p', (token) => {
@@ -206,7 +237,7 @@ describe('RunnerInstructionsModal component', () => {
expect(findAlert().exists()).toBe(true);
});
- it('should show alert when instructions cannot be loaded', async () => {
+ it('should show an alert when instructions cannot be loaded', async () => {
createComponent();
await waitForPromises();
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js
index 986d76d2b95..260eddbb37d 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js
@@ -12,7 +12,7 @@ describe('RunnerInstructions component', () => {
const createComponent = () => {
wrapper = shallowMountExtended(RunnerInstructions, {
directives: {
- GlModal: createMockDirective(),
+ GlModal: createMockDirective('gl-tooltip'),
},
});
};
@@ -21,10 +21,6 @@ describe('RunnerInstructions component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should show the "Show runner installation instructions" button', () => {
expect(findModalButton().text()).toBe('Show runner installation instructions');
});
diff --git a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js
index 09b0b3d43ad..6eebd129beb 100644
--- a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js
+++ b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js
@@ -6,7 +6,7 @@ import {
expectedDownloadDropdownPropsWithTitle,
securityReportMergeRequestDownloadPathsQueryResponse,
} from 'jest/vue_shared/security_reports/mock_data';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Component from '~/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import {
@@ -15,7 +15,7 @@ import {
} from '~/vue_shared/security_reports/constants';
import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('Merge request artifact Download', () => {
let wrapper;
@@ -52,10 +52,6 @@ describe('Merge request artifact Download', () => {
const findDownloadDropdown = () => wrapper.findComponent(SecurityReportDownloadDropdown);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('given the query is loading', () => {
beforeEach(() => {
createWrapper({
diff --git a/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js b/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js
index 08d3d5b19d4..2f6e633fb34 100644
--- a/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js
+++ b/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js
@@ -21,11 +21,6 @@ describe('HelpIcon component', () => {
const findPopover = () => wrapper.findComponent(GlPopover);
const findPopoverTarget = () => wrapper.findComponent({ ref: 'discoverProjectSecurity' });
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('given a help path only', () => {
beforeEach(() => {
createWrapper();
diff --git a/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js b/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js
index f186eb848f2..61cdc329220 100644
--- a/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js
+++ b/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js
@@ -15,11 +15,6 @@ describe('SecuritySummary component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe.each([
{ message: '' },
{ message: 'foo' },
diff --git a/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js
index 88445b6684c..c1feb64dacb 100644
--- a/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js
+++ b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js
@@ -40,10 +40,6 @@ describe('~/vue_shared/components/segmented_control_button_group.vue', () => {
disabled,
}));
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('default', () => {
beforeEach(() => {
createComponent();
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 5e829653c13..94d634f79bd 100644
--- a/spec/frontend/vue_shared/components/settings/settings_block_spec.js
+++ b/spec/frontend/vue_shared/components/settings/settings_block_spec.js
@@ -16,10 +16,6 @@ describe('Settings Block', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findDefaultSlot = () => wrapper.findByTestId('default-slot');
const findTitleSlot = () => wrapper.findByTestId('title-slot');
const findDescriptionSlot = () => wrapper.findByTestId('description-slot');
diff --git a/spec/frontend/vue_shared/components/slot_switch_spec.js b/spec/frontend/vue_shared/components/slot_switch_spec.js
index f25b9877aba..3a2147c6c89 100644
--- a/spec/frontend/vue_shared/components/slot_switch_spec.js
+++ b/spec/frontend/vue_shared/components/slot_switch_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { assertProps } from 'helpers/assert_props';
import SlotSwitch from '~/vue_shared/components/slot_switch.vue';
@@ -19,14 +20,10 @@ describe('SlotSwitch', () => {
const getChildrenHtml = () => wrapper.findAll('* *').wrappers.map((c) => c.html());
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
it('throws an error if activeSlotNames is missing', () => {
- expect(createComponent).toThrow('[Vue warn]: Missing required prop: "activeSlotNames"');
+ expect(() => assertProps(SlotSwitch, {})).toThrow(
+ '[Vue warn]: Missing required prop: "activeSlotNames"',
+ );
});
it('renders no slots if activeSlotNames is empty', () => {
diff --git a/spec/frontend/vue_shared/components/smart_virtual_list_spec.js b/spec/frontend/vue_shared/components/smart_virtual_list_spec.js
index 8802a832781..e5d988f75f5 100644
--- a/spec/frontend/vue_shared/components/smart_virtual_list_spec.js
+++ b/spec/frontend/vue_shared/components/smart_virtual_list_spec.js
@@ -1,5 +1,4 @@
import { mount } from '@vue/test-utils';
-import Vue from 'vue';
import SmartVirtualScrollList from '~/vue_shared/components/smart_virtual_list.vue';
describe('Toggle Button', () => {
@@ -16,7 +15,7 @@ describe('Toggle Button', () => {
remain,
};
- const Component = Vue.extend({
+ const Component = {
components: {
SmartVirtualScrollList,
},
@@ -26,7 +25,7 @@ describe('Toggle Button', () => {
<smart-virtual-scroll-list v-bind="$options.smartListProperties">
<li v-for="(val, key) in $options.items" :key="key">{{ key + 1 }}</li>
</smart-virtual-scroll-list>`,
- });
+ };
return mount(Component).vm;
};
diff --git a/spec/frontend/vue_shared/components/source_editor_spec.js b/spec/frontend/vue_shared/components/source_editor_spec.js
index ca5b990bc29..5b155207029 100644
--- a/spec/frontend/vue_shared/components/source_editor_spec.js
+++ b/spec/frontend/vue_shared/components/source_editor_spec.js
@@ -47,10 +47,6 @@ describe('Source Editor component', () => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const triggerChangeContent = (val) => {
mockInstance.getValue.mockReturnValue(val);
const [cb] = mockInstance.onDidChangeModelContent.mock.calls[0];
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js
index da9067a8ddc..395ba92d4c6 100644
--- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js
@@ -45,8 +45,6 @@ describe('Chunk component', () => {
createComponent();
});
- afterEach(() => wrapper.destroy());
-
describe('Intersection observer', () => {
it('renders an Intersection observer component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js
index f661bd6747a..9a38a96663d 100644
--- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js
@@ -10,16 +10,10 @@ const DEFAULT_PROPS = {
describe('Chunk Line component', () => {
let wrapper;
- const fileLineBlame = true;
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(ChunkLine, {
propsData: { ...DEFAULT_PROPS, ...props },
- provide: {
- glFeatures: {
- fileLineBlame,
- },
- },
});
};
@@ -31,8 +25,6 @@ describe('Chunk Line component', () => {
createComponent();
});
- afterEach(() => wrapper.destroy());
-
describe('rendering', () => {
it('renders a blame link', () => {
expect(findBlameLink().attributes()).toMatchObject({
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
index 95ef11d776a..ff50326917f 100644
--- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
@@ -11,7 +11,6 @@ describe('Chunk component', () => {
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(Chunk, {
propsData: { ...CHUNK_1, ...props },
- provide: { glFeatures: { fileLineBlame: true } },
});
};
@@ -24,8 +23,6 @@ describe('Chunk component', () => {
createComponent();
});
- afterEach(() => wrapper.destroy());
-
describe('Intersection observer', () => {
it('renders an Intersection observer component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js
index 0beec8e9d3e..8419a0c5ddf 100644
--- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js
@@ -11,6 +11,7 @@ import {
EVENT_LABEL_FALLBACK,
ROUGE_TO_HLJS_LANGUAGE_MAP,
LINES_PER_CHUNK,
+ LEGACY_FALLBACKS,
} from '~/vue_shared/components/source_viewer/constants';
import waitForPromises from 'helpers/wait_for_promises';
import LineHighlighter from '~/blob/line_highlighter';
@@ -68,8 +69,6 @@ describe('Source Viewer component', () => {
return createComponent();
});
- afterEach(() => wrapper.destroy());
-
describe('event tracking', () => {
it('fires a tracking event when the component is created', () => {
const eventData = { label: EVENT_LABEL_VIEWER, property: language };
@@ -91,14 +90,16 @@ describe('Source Viewer component', () => {
});
describe('legacy fallbacks', () => {
- it('tracks a fallback event and emits an error when viewing python files', () => {
- const fallbackLanguage = 'python';
- const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage };
- createComponent({ language: fallbackLanguage });
-
- expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
- expect(wrapper.emitted('error')).toHaveLength(1);
- });
+ it.each(LEGACY_FALLBACKS)(
+ 'tracks a fallback event and emits an error when viewing %s files',
+ (fallbackLanguage) => {
+ const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage };
+ createComponent({ language: fallbackLanguage });
+
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
+ expect(wrapper.emitted('error')).toHaveLength(1);
+ },
+ );
});
describe('highlight.js', () => {
@@ -170,7 +171,7 @@ describe('Source Viewer component', () => {
});
describe('LineHighlighter', () => {
- it('instantiates the lineHighlighter class', async () => {
+ it('instantiates the lineHighlighter class', () => {
expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' });
});
});
diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
index 1c75442b4a8..46b582c3668 100644
--- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
@@ -25,8 +25,6 @@ describe('Source Viewer component', () => {
return createComponent();
});
- afterEach(() => wrapper.destroy());
-
describe('event tracking', () => {
it('fires a tracking event when the component is created', () => {
const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK };
diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js
index 6b869db4058..ffa25ae8448 100644
--- a/spec/frontend/vue_shared/components/split_button_spec.js
+++ b/spec/frontend/vue_shared/components/split_button_spec.js
@@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { assertProps } from 'helpers/assert_props';
import SplitButton from '~/vue_shared/components/split_button.vue';
const mockActionItems = [
@@ -42,12 +43,12 @@ describe('SplitButton', () => {
it('fails for empty actionItems', () => {
const actionItems = [];
- expect(() => createComponent({ actionItems })).toThrow();
+ expect(() => assertProps(SplitButton, { actionItems })).toThrow();
});
it('fails for single actionItems', () => {
const actionItems = [mockActionItems[0]];
- expect(() => createComponent({ actionItems })).toThrow();
+ expect(() => assertProps(SplitButton, { actionItems })).toThrow();
});
it('renders actionItems', () => {
diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
index 79b1f17afa0..13911d487f2 100644
--- a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
+++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
@@ -18,10 +18,6 @@ describe('StackedProgressBarComponent', () => {
wrapper = mount(StackedProgressBarComponent, { propsData: defaultConfig });
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findSuccessBar = () => wrapper.find('.status-green');
const findNeutralBar = () => wrapper.find('.status-neutral');
const findFailureBar = () => wrapper.find('.status-red');
diff --git a/spec/frontend/vue_shared/components/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js
index 99de26ce2ae..79aba1b2516 100644
--- a/spec/frontend/vue_shared/components/table_pagination_spec.js
+++ b/spec/frontend/vue_shared/components/table_pagination_spec.js
@@ -16,10 +16,6 @@ describe('Pagination component', () => {
spy = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('render', () => {
it('should not render anything', () => {
mountComponent({
diff --git a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
index 28c5acc8110..17a363ad8b1 100644
--- a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
+++ b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import timezoneMock from 'timezone-mock';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
+import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/constants';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
describe('Time ago with tooltip component', () => {
@@ -25,7 +26,6 @@ describe('Time ago with tooltip component', () => {
};
afterEach(() => {
- vm.destroy();
timezoneMock.unregister();
});
@@ -50,12 +50,36 @@ describe('Time ago with tooltip component', () => {
expect(vm.attributes('datetime')).toEqual(timestamp);
});
+ it('should render with the timestamp provided as Date', () => {
+ buildVm({ time: new Date(timestamp) });
+
+ expect(vm.text()).toEqual(timeAgoTimestamp);
+ });
+
it('should render provided scope content with the correct timeAgo string', () => {
buildVm(null, { default: `<span>The time is {{ props.timeAgo }}</span>` });
expect(vm.text()).toEqual(`The time is ${timeAgoTimestamp}`);
});
+ describe('with User Setting timeDisplayRelative: false', () => {
+ beforeEach(() => {
+ window.gon = { time_display_relative: false };
+ });
+
+ it('should render with the correct absolute datetime in the default format', () => {
+ buildVm();
+
+ expect(vm.text()).toEqual('May 8, 2017, 2:57 PM');
+ });
+
+ it('should render with the correct absolute datetime in the requested dateTimeFormat', () => {
+ buildVm({ dateTimeFormat: DATE_ONLY_FORMAT });
+
+ expect(vm.text()).toEqual('May 8, 2017');
+ });
+ });
+
describe('number based timestamps', () => {
// Store a date object before we mock the TZ
const date = new Date();
diff --git a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js
index c8351ed61d7..d8dedd8240b 100644
--- a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem, GlDropdown } from '@gitlab/ui';
+import { GlDropdownItem, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue';
@@ -9,7 +9,9 @@ describe('Deploy freeze timezone dropdown', () => {
let wrapper;
let store;
- const createComponent = (searchTerm, selectedTimezone) => {
+ const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+
+ const createComponent = async (searchTerm, selectedTimezone) => {
wrapper = shallowMountExtended(TimezoneDropdown, {
store,
propsData: {
@@ -19,9 +21,8 @@ describe('Deploy freeze timezone dropdown', () => {
},
});
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ searchTerm });
+ findSearchBox().vm.$emit('input', searchTerm);
+ await nextTick();
};
const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
@@ -29,14 +30,9 @@ describe('Deploy freeze timezone dropdown', () => {
const findEmptyResultsItem = () => wrapper.findByTestId('noMatchingResults');
const findHiddenInput = () => wrapper.find('input');
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('No time zones found', () => {
- beforeEach(() => {
- createComponent('UTC timezone');
+ beforeEach(async () => {
+ await createComponent('UTC timezone');
});
it('renders empty results message', () => {
@@ -45,8 +41,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
describe('Search term is empty', () => {
- beforeEach(() => {
- createComponent('');
+ beforeEach(async () => {
+ await createComponent('');
});
it('renders all timezones when search term is empty', () => {
@@ -55,8 +51,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
describe('Time zones found', () => {
- beforeEach(() => {
- createComponent('Alaska');
+ beforeEach(async () => {
+ await createComponent('Alaska');
});
it('renders only the time zone searched for', () => {
@@ -87,8 +83,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
describe('Selected time zone not found', () => {
- beforeEach(() => {
- createComponent('', 'Berlin');
+ beforeEach(async () => {
+ await createComponent('', 'Berlin');
});
it('renders empty selections', () => {
@@ -101,8 +97,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
describe('Selected time zone found', () => {
- beforeEach(() => {
- createComponent('', 'Europe/Berlin');
+ beforeEach(async () => {
+ await createComponent('', 'Europe/Berlin');
});
it('renders selected time zone as dropdown label', () => {
diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
index ca1f7996ad6..f5da498a205 100644
--- a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
+++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
@@ -30,8 +30,8 @@ describe('TooltipOnTruncate component', () => {
default: [MOCK_TITLE],
},
directives: {
- GlTooltip: createMockDirective(),
- GlResizeObserver: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
+ GlResizeObserver: createMockDirective('gl-resize-observer'),
},
...options,
});
@@ -42,8 +42,8 @@ describe('TooltipOnTruncate component', () => {
...TooltipOnTruncate,
directives: {
...TooltipOnTruncate.directives,
- GlTooltip: createMockDirective(),
- GlResizeObserver: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
+ GlResizeObserver: createMockDirective('gl-resize-observer'),
},
};
@@ -78,19 +78,15 @@ describe('TooltipOnTruncate component', () => {
await nextTick();
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when truncated', () => {
- beforeEach(async () => {
+ beforeEach(() => {
hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent();
});
- it('renders tooltip', async () => {
+ it('renders tooltip', () => {
expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
- expect(getTooltipValue()).toMatchObject({
+ expect(getTooltipValue()).toStrictEqual({
title: MOCK_TITLE,
placement: 'top',
disabled: false,
@@ -100,7 +96,7 @@ describe('TooltipOnTruncate component', () => {
});
describe('with default target', () => {
- beforeEach(async () => {
+ beforeEach(() => {
hasHorizontalOverflow.mockReturnValueOnce(false);
createComponent();
});
@@ -144,7 +140,7 @@ describe('TooltipOnTruncate component', () => {
await nextTick();
- expect(getTooltipValue()).toMatchObject({
+ expect(getTooltipValue()).toStrictEqual({
title: MOCK_TITLE,
placement: 'top',
disabled: false,
@@ -194,20 +190,22 @@ describe('TooltipOnTruncate component', () => {
});
});
- describe('placement', () => {
- it('sets placement when tooltip is rendered', () => {
- const mockPlacement = 'bottom';
-
+ describe('tooltip customization', () => {
+ it.each`
+ property | mockValue
+ ${'placement'} | ${'bottom'}
+ ${'boundary'} | ${'viewport'}
+ `('sets $property when the tooltip is rendered', ({ property, mockValue }) => {
hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({
propsData: {
- placement: mockPlacement,
+ [property]: mockValue,
},
});
expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
expect(getTooltipValue()).toMatchObject({
- placement: mockPlacement,
+ [property]: mockValue,
});
});
});
diff --git a/spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js b/spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js
new file mode 100644
index 00000000000..76467c185db
--- /dev/null
+++ b/spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js
@@ -0,0 +1,113 @@
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { __ } from '~/locale';
+import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+
+describe('TruncatedText', () => {
+ let wrapper;
+
+ const findContent = () => wrapper.findComponent({ ref: 'content' }).element;
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(TruncatedText, {
+ propsData,
+ directives: {
+ GlResizeObserver: createMockDirective('gl-resize-observer'),
+ },
+ stubs: {
+ GlButton,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('when mounted', () => {
+ it('the content has class `gl-truncate-text-by-line`', () => {
+ expect(findContent().classList).toContain('gl-truncate-text-by-line');
+ });
+
+ it('the content has style variables for `lines` and `mobile-lines` with the correct values', () => {
+ const { style } = findContent();
+
+ expect(style).toContain('--lines');
+ expect(style.getPropertyValue('--lines')).toBe('3');
+ expect(style).toContain('--mobile-lines');
+ expect(style.getPropertyValue('--mobile-lines')).toBe('10');
+ });
+
+ it('the button is not visible', () => {
+ expect(findButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when mounted with a value for the lines property', () => {
+ const lines = 4;
+
+ beforeEach(() => {
+ createComponent({ lines });
+ });
+
+ it('the lines variable has the value of the passed property', () => {
+ expect(findContent().style.getPropertyValue('--lines')).toBe(lines.toString());
+ });
+ });
+
+ describe('when mounted with a value for the mobileLines property', () => {
+ const mobileLines = 4;
+
+ beforeEach(() => {
+ createComponent({ mobileLines });
+ });
+
+ it('the lines variable has the value of the passed property', () => {
+ expect(findContent().style.getPropertyValue('--mobile-lines')).toBe(mobileLines.toString());
+ });
+ });
+
+ describe('when resizing and the scroll height is smaller than the offset height', () => {
+ beforeEach(() => {
+ getBinding(findContent(), 'gl-resize-observer').value({
+ target: { scrollHeight: 10, offsetHeight: 20 },
+ });
+ });
+
+ it('the button remains invisible', () => {
+ expect(findButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when resizing and the scroll height is greater than the offset height', () => {
+ beforeEach(() => {
+ getBinding(findContent(), 'gl-resize-observer').value({
+ target: { scrollHeight: 20, offsetHeight: 10 },
+ });
+ });
+
+ it('the button becomes visible', () => {
+ expect(findButton().exists()).toBe(true);
+ });
+
+ it('the button text says "show more"', () => {
+ expect(findButton().text()).toBe(__('Show more'));
+ });
+
+ describe('clicking the button', () => {
+ beforeEach(() => {
+ findButton().trigger('click');
+ });
+
+ it('removes the `gl-truncate-text-by-line` class on the content', () => {
+ expect(findContent().classList).not.toContain('gl-truncate-text-by-line');
+ });
+
+ it('toggles the button text to "Show less"', () => {
+ expect(findButton().text()).toBe(__('Show less'));
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap
index f9d615d4f68..c816fe790a8 100644
--- a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap
+++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap
@@ -5,7 +5,7 @@ exports[`Upload dropzone component correctly overrides description and drop mess
class="gl-w-full gl-relative"
>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-px-5 gl-py-4 gl-mb-0"
type="button"
>
<div
@@ -86,7 +86,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
class="gl-w-full gl-relative"
>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-px-5 gl-py-4 gl-mb-0"
type="button"
>
<div
@@ -171,7 +171,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
class="gl-w-full gl-relative"
>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-px-5 gl-py-4 gl-mb-0"
type="button"
>
<div
@@ -256,7 +256,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
class="gl-w-full gl-relative"
>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-px-5 gl-py-4 gl-mb-0"
type="button"
>
<div
@@ -342,7 +342,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
class="gl-w-full gl-relative"
>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-px-5 gl-py-4 gl-mb-0"
type="button"
>
<div
@@ -428,7 +428,7 @@ exports[`Upload dropzone component when dragging renders correct template when d
class="gl-w-full gl-relative"
>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-px-5 gl-py-4 gl-mb-0"
type="button"
>
<div
@@ -514,7 +514,7 @@ exports[`Upload dropzone component when no slot provided renders default dropzon
class="gl-w-full gl-relative"
>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-px-5 gl-py-4 gl-mb-0"
type="button"
>
<div
diff --git a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js
index a063a5591e3..24f96195e05 100644
--- a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js
+++ b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js
@@ -3,8 +3,6 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
-jest.mock('~/flash');
-
describe('Upload dropzone component', () => {
let wrapper;
@@ -34,11 +32,6 @@ describe('Upload dropzone component', () => {
});
}
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('when slot provided', () => {
it('renders dropzone with slot content', () => {
createComponent({
diff --git a/spec/frontend/vue_shared/components/url_sync_spec.js b/spec/frontend/vue_shared/components/url_sync_spec.js
index 30a7439579f..2718be74111 100644
--- a/spec/frontend/vue_shared/components/url_sync_spec.js
+++ b/spec/frontend/vue_shared/components/url_sync_spec.js
@@ -33,10 +33,6 @@ describe('url sync component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const expectUrlSyncWithMergeUrlParams = (
query,
times,
diff --git a/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js b/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js
index 662c09d02bf..ba55df5512f 100644
--- a/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js
+++ b/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js
@@ -24,10 +24,6 @@ describe('usage banner', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each`
slotName | finderFunction
${'left-primary-text'} | ${findLeftPrimaryTextSlot}
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js
index d63b13981ac..3ae3d89af27 100644
--- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js
@@ -20,10 +20,6 @@ describe('User Avatar Image Component', () => {
const findAvatar = () => wrapper.findComponent(GlAvatar);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('Initialization', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js
index df7ce449678..90f9156af38 100644
--- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js
@@ -34,10 +34,6 @@ describe('User Avatar Link Component', () => {
createWrapper();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('should render GlLink with correct props', () => {
const link = wrapper.findComponent(GlAvatarLink);
expect(link.exists()).toBe(true);
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js
index 63371b1492b..075cb753301 100644
--- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js
@@ -50,10 +50,6 @@ describe('UserAvatarList', () => {
props = { imgSize: TEST_IMAGE_SIZE };
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('empty text', () => {
it('shows when items are empty', () => {
factory({ propsData: { items: [] } });
@@ -152,6 +148,13 @@ describe('UserAvatarList', () => {
expect(links.length).toEqual(TEST_BREAKPOINT);
});
+ it('does not emit any event on mount', async () => {
+ factory();
+ await nextTick();
+
+ expect(wrapper.emitted()).toEqual({});
+ });
+
describe('with expand clicked', () => {
beforeEach(() => {
factory();
@@ -164,13 +167,25 @@ describe('UserAvatarList', () => {
expect(links.length).toEqual(props.items.length);
});
- it('with collapse clicked, it renders avatars up to breakpoint', async () => {
- clickButton();
+ it('emits the `expanded` event', () => {
+ expect(wrapper.emitted('expanded')).toHaveLength(1);
+ });
- await nextTick();
- const links = wrapper.findAllComponents(UserAvatarLink);
+ describe('with collapse clicked', () => {
+ beforeEach(() => {
+ clickButton();
+ });
+
+ it('renders avatars up to breakpoint', async () => {
+ await nextTick();
+ const links = wrapper.findAllComponents(UserAvatarLink);
+
+ expect(links.length).toEqual(TEST_BREAKPOINT);
+ });
- expect(links.length).toEqual(TEST_BREAKPOINT);
+ it('emits the `collapsed` event', () => {
+ expect(wrapper.emitted('collapsed')).toHaveLength(1);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js
index 521744154ba..a4efbda06ce 100644
--- a/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js
+++ b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js
@@ -28,23 +28,21 @@ const initialSlotProps = (changes = {}) => ({
});
describe('UserCalloutDismisser', () => {
- let wrapper;
-
const MOCK_FEATURE_NAME = 'mock_feature_name';
// Query handlers
- const successHandlerFactory = (dismissedCallouts = []) => async () =>
- userCalloutsResponse(dismissedCallouts);
- const anonUserHandler = async () => anonUserCalloutsResponse();
+ const successHandlerFactory = (dismissedCallouts = []) => () =>
+ Promise.resolve(userCalloutsResponse(dismissedCallouts));
+ const anonUserHandler = () => Promise.resolve(anonUserCalloutsResponse());
const errorHandler = () => Promise.reject(new Error('query error'));
const pendingHandler = () => new Promise(() => {});
// Mutation handlers
- const mutationSuccessHandlerSpy = jest.fn(async (variables) =>
- userCalloutMutationResponse(variables),
+ const mutationSuccessHandlerSpy = jest.fn((variables) =>
+ Promise.resolve(userCalloutMutationResponse(variables)),
);
- const mutationErrorHandlerSpy = jest.fn(async (variables) =>
- userCalloutMutationResponse(variables, ['mutation error']),
+ const mutationErrorHandlerSpy = jest.fn((variables) =>
+ Promise.resolve(userCalloutMutationResponse(variables, ['mutation error'])),
);
const defaultScopedSlotSpy = jest.fn();
@@ -52,7 +50,7 @@ describe('UserCalloutDismisser', () => {
const callDismissSlotProp = () => defaultScopedSlotSpy.mock.calls[0][0].dismiss();
const createComponent = ({ queryHandler, mutationHandler, ...options }) => {
- wrapper = mount(
+ mount(
UserCalloutDismisser,
merge(
{
@@ -72,10 +70,6 @@ describe('UserCalloutDismisser', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when loading', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js
index 78abb89e7b8..d77e357a50c 100644
--- a/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js
+++ b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js
@@ -51,10 +51,6 @@ describe('User deletion obstacles list', () => {
);
}
- afterEach(() => {
- wrapper.destroy();
- });
-
const findLinks = () => wrapper.findAllComponents(GlLink);
const findTitle = () => wrapper.findByTestId('title');
const findFooter = () => wrapper.findByTestId('footer');
@@ -65,7 +61,7 @@ describe('User deletion obstacles list', () => {
${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'}
${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'}
`('when current user', ({ isCurrentUser, titleText, footerText }) => {
- it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call management`, async () => {
+ it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call management`, () => {
createComponent({
isCurrentUser,
});
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 f6316af6ad8..41181ab9a68 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
@@ -1,5 +1,6 @@
import { GlSkeletonLoader, GlIcon } from '@gitlab/ui';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import mrDiffCommentFixture from 'test_fixtures/merge_requests/diff_comment.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { sprintf } from '~/locale';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { AVAILABILITY_STATUS } from '~/set_status_modal/constants';
@@ -13,11 +14,11 @@ import {
I18N_ERROR_UNFOLLOW,
} from '~/vue_shared/components/user_popover/constants';
import axios from '~/lib/utils/axios_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { followUser, unfollowUser } from '~/api/user_api';
import { mockTracking } from 'helpers/tracking_helper';
-jest.mock('~/flash');
+jest.mock('~/alert');
jest.mock('~/api/user_api', () => ({
followUser: jest.fn(),
unfollowUser: jest.fn(),
@@ -41,17 +42,14 @@ const DEFAULT_PROPS = {
};
describe('User Popover Component', () => {
- const fixtureTemplate = 'merge_requests/diff_comment.html';
-
let wrapper;
beforeEach(() => {
- loadHTMLFixture(fixtureTemplate);
+ setHTMLFixture(mrDiffCommentFixture);
gon.features = {};
});
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
@@ -277,7 +275,7 @@ describe('User Popover Component', () => {
createWrapper({ user });
- expect(wrapper.findByText('(Busy)').exists()).toBe(true);
+ expect(wrapper.findByText('Busy').exists()).toBe(true);
});
it('should hide the busy status for any other status', () => {
@@ -288,7 +286,7 @@ describe('User Popover Component', () => {
createWrapper({ user });
- expect(wrapper.findByText('(Busy)').exists()).toBe(false);
+ expect(wrapper.findByText('Busy').exists()).toBe(false);
});
it('shows pronouns when user has them set', () => {
diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js
index b0e9584a15b..e881bfed35e 100644
--- a/spec/frontend/vue_shared/components/user_select_spec.js
+++ b/spec/frontend/vue_shared/components/user_select_spec.js
@@ -7,7 +7,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import searchUsersQueryOnMR from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
-import { IssuableType } from '~/issues/constants';
+import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import getIssueParticipantsQuery from '~/sidebar/queries/get_issue_participants.query.graphql';
@@ -105,7 +105,6 @@ describe('User select dropdown', () => {
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -409,7 +408,7 @@ describe('User select dropdown', () => {
describe('when on merge request sidebar', () => {
beforeEach(() => {
- createComponent({ props: { issuableType: IssuableType.MergeRequest, issuableId: 1 } });
+ createComponent({ props: { issuableType: TYPE_MERGE_REQUEST, issuableId: 1 } });
return waitForPromises();
});
diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
index c136c2054ac..e24c5a4609d 100644
--- a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
+++ b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
@@ -3,10 +3,10 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
-const TestComponent = Vue.extend({
+const TestComponent = {
inject: ['vuexModule'],
template: `<div data-testid="vuexModule">{{ vuexModule }}</div> `,
-});
+};
const TEST_VUEX_MODULE = 'testVuexModule';
@@ -27,15 +27,18 @@ describe('~/vue_shared/components/vuex_module_provider', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('provides "vuexModule" set from prop', () => {
createComponent();
expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE);
});
+ it('provides "vuexModel" set from "vuex-module" prop when using @vue/compat', () => {
+ createComponent({
+ propsData: { 'vuex-module': TEST_VUEX_MODULE },
+ });
+ expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE);
+ });
+
it('does not blow up when used with vue-apollo', () => {
// See https://github.com/vuejs/vue-apollo/pull/1153 for details
Vue.use(VueApollo);
diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js
index 18afe049149..d888abc19ef 100644
--- a/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -1,4 +1,4 @@
-import { GlButton, GlLink, GlModal, GlPopover } from '@gitlab/ui';
+import { GlButton, GlModal } from '@gitlab/ui';
import { nextTick } from 'vue';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
@@ -9,7 +9,6 @@ import WebIdeLink, {
PREFERRED_EDITOR_KEY,
} from '~/vue_shared/components/web_ide_link.vue';
import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
-import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import { KEY_WEB_IDE } from '~/vue_shared/components/constants';
import { stubComponent } from 'helpers/stub_component';
@@ -95,14 +94,7 @@ describe('Web IDE link component', () => {
let wrapper;
- function createComponent(
- props,
- {
- mountFn = shallowMountExtended,
- glFeatures = {},
- userCalloutDismisserSlotProps = { dismiss: jest.fn() },
- } = {},
- ) {
+ function createComponent(props, { mountFn = shallowMountExtended, glFeatures = {} } = {}) {
wrapper = mountFn(WebIdeLink, {
propsData: {
editUrl: TEST_EDIT_URL,
@@ -124,11 +116,6 @@ describe('Web IDE link component', () => {
<slot name="modal-footer"></slot>
</div>`,
}),
- UserCalloutDismisser: stubComponent(UserCalloutDismisser, {
- render() {
- return this.$scopedSlots.default(userCalloutDismisserSlotProps);
- },
- }),
},
});
}
@@ -137,21 +124,10 @@ describe('Web IDE link component', () => {
localStorage.setItem(PREFERRED_EDITOR_RESET_KEY, 'true');
});
- afterEach(() => {
- wrapper.destroy();
- });
-
const findActionsButton = () => wrapper.findComponent(ActionsButton);
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const findModal = () => wrapper.findComponent(GlModal);
const findForkConfirmModal = () => wrapper.findComponent(ConfirmForkModal);
- const findUserCalloutDismisser = () => wrapper.findComponent(UserCalloutDismisser);
- const findNewWebIdeCalloutPopover = () => wrapper.findComponent(GlPopover);
- const findTryItOutLink = () =>
- wrapper
- .findAllComponents(GlLink)
- .filter((link) => link.text().includes('Try it out'))
- .at(0);
it.each([
{
@@ -349,7 +325,7 @@ describe('Web IDE link component', () => {
it.each(testActions)(
'emits the correct event when an action handler is called',
- async ({ props, expectedEventPayload }) => {
+ ({ props, expectedEventPayload }) => {
createComponent({ ...props, needsToFork: true, disableForkModal: true });
findActionsButton().props('actions')[0].handle();
@@ -358,7 +334,7 @@ describe('Web IDE link component', () => {
},
);
- it.each(testActions)('renders the fork confirmation modal', async ({ props }) => {
+ it.each(testActions)('renders the fork confirmation modal', ({ props }) => {
createComponent({ ...props, needsToFork: true });
expect(findForkConfirmModal().exists()).toBe(true);
@@ -450,132 +426,6 @@ describe('Web IDE link component', () => {
});
});
- describe('Web IDE callout', () => {
- describe('vscode_web_ide feature flag is enabled and the edit button is not shown', () => {
- let dismiss;
-
- beforeEach(() => {
- dismiss = jest.fn();
- createComponent(
- {
- showEditButton: false,
- },
- {
- glFeatures: { vscodeWebIde: true },
- userCalloutDismisserSlotProps: { dismiss },
- },
- );
- });
- it('does not skip the user_callout_dismisser query', () => {
- expect(findUserCalloutDismisser().props()).toEqual(
- expect.objectContaining({
- skipQuery: false,
- featureName: 'vscode_web_ide_callout',
- }),
- );
- });
-
- it('mounts new web ide callout popover', () => {
- expect(findNewWebIdeCalloutPopover().props()).toEqual(
- expect.objectContaining({
- showCloseButton: '',
- target: 'web-ide-link',
- triggers: 'manual',
- boundaryPadding: 80,
- }),
- );
- });
-
- describe.each`
- calloutStatus | shouldShowCallout | popoverVisibility | tooltipVisibility
- ${'show'} | ${true} | ${true} | ${false}
- ${'hide'} | ${false} | ${false} | ${true}
- `(
- 'when should $calloutStatus web ide callout',
- ({ shouldShowCallout, popoverVisibility, tooltipVisibility }) => {
- beforeEach(() => {
- createComponent(
- {
- showEditButton: false,
- },
- {
- glFeatures: { vscodeWebIde: true },
- userCalloutDismisserSlotProps: { shouldShowCallout, dismiss },
- },
- );
- });
-
- it(`popover visibility = ${popoverVisibility}`, () => {
- expect(findNewWebIdeCalloutPopover().props().show).toBe(popoverVisibility);
- });
-
- it(`action button tooltip visibility = ${tooltipVisibility}`, () => {
- expect(findActionsButton().props().showActionTooltip).toBe(tooltipVisibility);
- });
- },
- );
-
- it('dismisses the callout when popover close button is clicked', () => {
- findNewWebIdeCalloutPopover().vm.$emit('close-button-clicked');
-
- expect(dismiss).toHaveBeenCalled();
- });
-
- it('dismisses the callout when try it now link is clicked', () => {
- findTryItOutLink().vm.$emit('click');
-
- expect(dismiss).toHaveBeenCalled();
- });
-
- it('dismisses the callout when action button is clicked', () => {
- findActionsButton().vm.$emit('actionClicked');
-
- expect(dismiss).toHaveBeenCalled();
- });
- });
-
- describe.each`
- featureFlag | showEditButton
- ${false} | ${true}
- ${true} | ${false}
- ${false} | ${false}
- `(
- 'when vscode_web_ide=$featureFlag and showEditButton = $showEditButton',
- ({ vscodeWebIde, showEditButton }) => {
- let dismiss;
-
- beforeEach(() => {
- dismiss = jest.fn();
-
- createComponent(
- {
- showEditButton,
- },
- { glFeatures: { vscodeWebIde }, userCalloutDismisserSlotProps: { dismiss } },
- );
- });
-
- it('skips the user_callout_dismisser query', () => {
- expect(findUserCalloutDismisser().props().skipQuery).toBe(true);
- });
-
- it('displays actions button tooltip', () => {
- expect(findActionsButton().props().showActionTooltip).toBe(true);
- });
-
- it('mounts new web ide callout popover', () => {
- expect(findNewWebIdeCalloutPopover().exists()).toBe(false);
- });
-
- it('does not dismiss the callout when action button is clicked', () => {
- findActionsButton().vm.$emit('actionClicked');
-
- expect(dismiss).not.toHaveBeenCalled();
- });
- },
- );
- });
-
describe('when vscode_web_ide feature flag is enabled', () => {
describe('when is not showing edit button', () => {
describe(`when ${PREFERRED_EDITOR_RESET_KEY} is unset`, () => {
diff --git a/spec/frontend/vue_shared/directives/track_event_spec.js b/spec/frontend/vue_shared/directives/track_event_spec.js
index 4bf84b06246..fc69e884258 100644
--- a/spec/frontend/vue_shared/directives/track_event_spec.js
+++ b/spec/frontend/vue_shared/directives/track_event_spec.js
@@ -1,50 +1,47 @@
import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import Tracking from '~/tracking';
import TrackEvent from '~/vue_shared/directives/track_event';
jest.mock('~/tracking');
-const Component = Vue.component('DummyElement', {
- directives: {
- TrackEvent,
- },
- data() {
- return {
- trackingOptions: null,
- };
- },
- template: '<button id="trackable" v-track-event="trackingOptions"></button>',
-});
+describe('TrackEvent directive', () => {
+ let wrapper;
-let wrapper;
-let button;
+ const clickButton = () => wrapper.find('button').trigger('click');
-describe('Error Tracking directive', () => {
- beforeEach(() => {
- wrapper = shallowMount(Component);
- button = wrapper.find('#trackable');
- });
+ const createComponent = (trackingOptions) =>
+ Vue.component('DummyElement', {
+ directives: {
+ TrackEvent,
+ },
+ data() {
+ return {
+ trackingOptions,
+ };
+ },
+ template: '<button v-track-event="trackingOptions"></button>',
+ });
+
+ const mountComponent = (trackingOptions) => shallowMount(createComponent(trackingOptions));
+
+ it('does not track the event if required arguments are not provided', () => {
+ wrapper = mountComponent();
+ clickButton();
- it('should not track the event if required arguments are not provided', () => {
- button.trigger('click');
expect(Tracking.event).not.toHaveBeenCalled();
});
- it('should track event on click if tracking info provided', async () => {
- const trackingOptions = {
+ it('tracks event on click if tracking info provided', () => {
+ wrapper = mountComponent({
category: 'Tracking',
action: 'click_trackable_btn',
label: 'Trackable Info',
- };
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ trackingOptions });
- const { category, action, label, property, value } = trackingOptions;
+ });
+ clickButton();
- await nextTick();
- button.trigger('click');
- expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property, value });
+ expect(Tracking.event).toHaveBeenCalledWith('Tracking', 'click_trackable_btn', {
+ label: 'Trackable Info',
+ });
});
});
diff --git a/spec/frontend/vue_shared/directives/validation_spec.js b/spec/frontend/vue_shared/directives/validation_spec.js
index dcd3a44a6fc..72a348c1a79 100644
--- a/spec/frontend/vue_shared/directives/validation_spec.js
+++ b/spec/frontend/vue_shared/directives/validation_spec.js
@@ -80,11 +80,6 @@ describe('validation directive', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const getFormData = () => wrapper.vm.form;
const findForm = () => wrapper.find('form');
const findInput = () => wrapper.find('input');
diff --git a/spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap b/spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap
index dd011b9d84e..1d4aa1afeaf 100644
--- a/spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap
+++ b/spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap
@@ -2,7 +2,7 @@
exports[`IssuableBlockedIcon on mouseenter on blocked icon with more than three blocking issues matches the snapshot 1`] = `
"<div class=\\"gl-display-inline\\"><svg data-testid=\\"issuable-blocked-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"issuable-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500 gl-icon s16\\" id=\\"blocked-icon-uniqueId\\">
- <use href=\\"#issue-block\\"></use>
+ <use href=\\"file-mock#issue-block\\"></use>
</svg>
<div class=\\"gl-popover\\">
<ul class=\\"gl-list-style-none gl-p-0 gl-mb-0\\">
diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js
index 7b0f0f7e344..e983519d9fc 100644
--- a/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js
@@ -34,10 +34,6 @@ describe('IssuableCreateRoot', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
it('renders component container element with class "issuable-create-container"', () => {
expect(wrapper.classes()).toContain('issuable-create-container');
diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js
index ff21b3bc356..ae2fd5ebffa 100644
--- a/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js
+++ b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js
@@ -36,10 +36,6 @@ describe('IssuableForm', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('methods', () => {
describe('handleUpdateSelectedLabels', () => {
it('sets provided `labels` param to prop `selectedLabels`', () => {
diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js
index 76b6efa15b6..1a490359040 100644
--- a/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js
+++ b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js
@@ -1,16 +1,12 @@
import { shallowMount } from '@vue/test-utils';
-import { GlIcon } from '@gitlab/ui';
import {
mockRegularLabel,
mockScopedLabel,
} from 'jest/sidebar/components/labels/labels_select_widget/mock_data';
import IssuableLabelSelector from '~/vue_shared/issuable/create/components/issuable_label_selector.vue';
import LabelsSelect from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
-import {
- DropdownVariant,
- LabelType,
-} from '~/sidebar/components/labels/labels_select_widget/constants';
-import { WorkspaceType } from '~/issues/constants';
+import { VARIANT_EMBEDDED } from '~/sidebar/components/labels/labels_select_widget/constants';
+import { WORKSPACE_PROJECT } from '~/issues/constants';
import { __ } from '~/locale';
const allowLabelRemove = true;
@@ -20,15 +16,13 @@ const fullPath = '/full-path';
const labelsFilterBasePath = '/labels-filter-base-path';
const initialLabels = [];
const issuableType = 'issue';
-const labelType = LabelType.project;
-const variant = DropdownVariant.Embedded;
-const workspaceType = WorkspaceType.project;
+const labelType = WORKSPACE_PROJECT;
+const variant = VARIANT_EMBEDDED;
+const workspaceType = WORKSPACE_PROJECT;
describe('IssuableLabelSelector', () => {
let wrapper;
- const findTitle = () => wrapper.find('label').text().replace(/\s+/, ' ');
- const findLabelIcon = () => wrapper.findComponent(GlIcon);
const findAllHiddenInputs = () => wrapper.findAll('input[type="hidden"]');
const findLabelSelector = () => wrapper.findComponent(LabelsSelect);
@@ -50,27 +44,11 @@ describe('IssuableLabelSelector', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
- const expectTitleWithCount = (count) => {
- const title = findTitle();
-
- expect(title).toContain(__('Labels'));
- expect(title).toContain(count.toString());
- };
-
describe('by default', () => {
beforeEach(() => {
wrapper = createComponent();
});
- it('has the selected labels count', () => {
- expectTitleWithCount(0);
- expect(findLabelIcon().props('name')).toBe('labels');
- });
-
it('has the label selector', () => {
expect(findLabelSelector().props()).toMatchObject({
allowLabelRemove,
@@ -96,7 +74,6 @@ describe('IssuableLabelSelector', () => {
it('passing initial labels applies them to the form', () => {
wrapper = createComponent({ initialLabels: [mockRegularLabel, mockScopedLabel] });
- expectTitleWithCount(2);
expect(findLabelSelector().props('selectedLabels')).toStrictEqual([
mockRegularLabel,
mockScopedLabel,
@@ -110,13 +87,11 @@ describe('IssuableLabelSelector', () => {
it('updates the selected labels on the `updateSelectedLabels` event', async () => {
wrapper = createComponent();
- expectTitleWithCount(0);
expect(findLabelSelector().props('selectedLabels')).toStrictEqual([]);
expect(findAllHiddenInputs()).toHaveLength(0);
await findLabelSelector().vm.$emit('updateSelectedLabels', { labels: [mockRegularLabel] });
- expectTitleWithCount(1);
expect(findLabelSelector().props('selectedLabels')).toStrictEqual([mockRegularLabel]);
expect(findAllHiddenInputs().wrappers.map((input) => input.element.value)).toStrictEqual([
`${mockRegularLabel.id}`,
@@ -126,7 +101,6 @@ describe('IssuableLabelSelector', () => {
it('updates the selected labels on the `onLabelRemove` event', async () => {
wrapper = createComponent({ initialLabels: [mockRegularLabel] });
- expectTitleWithCount(1);
expect(findLabelSelector().props('selectedLabels')).toStrictEqual([mockRegularLabel]);
expect(findAllHiddenInputs().wrappers.map((input) => input.element.value)).toStrictEqual([
`${mockRegularLabel.id}`,
@@ -134,7 +108,6 @@ describe('IssuableLabelSelector', () => {
await findLabelSelector().vm.$emit('onLabelRemove', mockRegularLabel.id);
- expectTitleWithCount(0);
expect(findLabelSelector().props('selectedLabels')).toStrictEqual([]);
expect(findAllHiddenInputs()).toHaveLength(0);
});
diff --git a/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js b/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js
index a0b1d64b97c..d5603d4ba4b 100644
--- a/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js
+++ b/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js
@@ -7,8 +7,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import IssuableBlockedIcon from '~/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue';
import { blockingIssuablesQueries } from '~/vue_shared/components/issuable_blocked_icon/constants';
-import { issuableTypes } from '~/boards/constants';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import { truncate } from '~/lib/utils/text_utility';
import {
mockIssue,
@@ -49,11 +48,6 @@ describe('IssuableBlockedIcon', () => {
await waitForApollo();
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
const createWrapperWithApollo = ({
item = mockBlockedIssue1,
blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1),
@@ -121,9 +115,9 @@ describe('IssuableBlockedIcon', () => {
};
it.each`
- mockIssuable | issuableType | expectedIcon
- ${mockIssue} | ${TYPE_ISSUE} | ${'issue-block'}
- ${mockEpic} | ${issuableTypes.epic} | ${'entity-blocked'}
+ mockIssuable | issuableType | expectedIcon
+ ${mockIssue} | ${TYPE_ISSUE} | ${'issue-block'}
+ ${mockEpic} | ${TYPE_EPIC} | ${'entity-blocked'}
`(
'should render blocked icon for $issuableType',
({ mockIssuable, issuableType, expectedIcon }) => {
@@ -145,7 +139,7 @@ describe('IssuableBlockedIcon', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
- it('should not query for blocking issuables by default', async () => {
+ it('should not query for blocking issuables by default', () => {
createWrapperWithApollo();
expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title);
@@ -153,9 +147,9 @@ describe('IssuableBlockedIcon', () => {
describe('on mouseenter on blocked icon', () => {
it.each`
- item | issuableType | mockBlockingIssuable | issuableItem | blockingIssuablesSpy
- ${mockBlockedIssue1} | ${TYPE_ISSUE} | ${mockBlockingIssue1} | ${mockIssue} | ${jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1)}
- ${mockBlockedEpic1} | ${issuableTypes.epic} | ${mockBlockingEpic1} | ${mockEpic} | ${jest.fn().mockResolvedValue(mockBlockingEpicIssuablesResponse1)}
+ item | issuableType | mockBlockingIssuable | issuableItem | blockingIssuablesSpy
+ ${mockBlockedIssue1} | ${TYPE_ISSUE} | ${mockBlockingIssue1} | ${mockIssue} | ${jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1)}
+ ${mockBlockedEpic1} | ${TYPE_EPIC} | ${mockBlockingEpic1} | ${mockEpic} | ${jest.fn().mockResolvedValue(mockBlockingEpicIssuablesResponse1)}
`(
'should query for blocking issuables and render the result for $issuableType',
async ({ item, issuableType, issuableItem, mockBlockingIssuable, blockingIssuablesSpy }) => {
@@ -201,18 +195,18 @@ describe('IssuableBlockedIcon', () => {
await mouseenter();
});
- it('should render a title of the issuable', async () => {
+ it('should render a title of the issuable', () => {
expect(findIssuableTitle().text()).toBe(mockBlockingIssue1.title);
});
- it('should render issuable reference and link to the issuable', async () => {
+ it('should render issuable reference and link to the issuable', () => {
const formattedRef = mockBlockingIssue1.reference.split('/')[1];
expect(findGlLink().text()).toBe(formattedRef);
expect(findGlLink().attributes('href')).toBe(mockBlockingIssue1.webUrl);
});
- it('should render popover title with correct blocking issuable count', async () => {
+ it('should render popover title with correct blocking issuable count', () => {
expect(findPopoverTitle().text()).toBe('Blocked by 1 issue');
});
});
@@ -247,7 +241,7 @@ describe('IssuableBlockedIcon', () => {
expect(wrapper.html()).toMatchSnapshot();
});
- it('should render popover title with correct blocking issuable count', async () => {
+ it('should render popover title with correct blocking issuable count', () => {
expect(findPopoverTitle().text()).toBe('Blocked by 4 issues');
});
@@ -255,7 +249,7 @@ describe('IssuableBlockedIcon', () => {
expect(findHiddenBlockingCount().text()).toBe('+ 1 more issue');
});
- it('should link to the blocked issue page at the related issue anchor', async () => {
+ it('should link to the blocked issue page at the related issue anchor', () => {
expect(findViewAllIssuableLink().text()).toBe('View all blocking issues');
expect(findViewAllIssuableLink().attributes('href')).toBe(
`${mockBlockedIssue2.webUrl}#related-issues`,
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js
index a25f92c9cf2..c23bd002ee5 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js
@@ -28,7 +28,6 @@ describe('IssuableBulkEditSidebar', () => {
});
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
index 2fac004875a..502fa609ebc 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
@@ -39,7 +39,6 @@ describe('IssuableItem', () => {
const mockLabels = mockIssuable.labels.nodes;
const mockAuthor = mockIssuable.author;
- const originalUrl = gon.gitlab_url;
let wrapper;
const findTimestampWrapper = () => wrapper.find('[data-testid="issuable-timestamp"]');
@@ -49,11 +48,6 @@ describe('IssuableItem', () => {
gon.gitlab_url = MOCK_GITLAB_URL;
});
- afterEach(() => {
- wrapper.destroy();
- gon.gitlab_url = originalUrl;
- });
-
describe('computed', () => {
describe('author', () => {
it('returns `issuable.author` reference', () => {
@@ -337,7 +331,7 @@ describe('IssuableItem', () => {
});
});
- it('renders spam icon when issuable is hidden', async () => {
+ it('renders spam icon when issuable is hidden', () => {
wrapper = createComponent({ issuable: { ...mockIssuable, hidden: true } });
const hiddenIcon = wrapper.findComponent(GlIcon);
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
index 371844e66f4..ec975dfdcb5 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
@@ -47,10 +47,6 @@ describe('IssuableListRoot', () => {
const findVueDraggable = () => wrapper.findComponent(VueDraggable);
const findPageSizeSelector = () => wrapper.findComponent(PageSizeSelector);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('computed', () => {
beforeEach(() => {
wrapper = createComponent();
@@ -337,7 +333,7 @@ describe('IssuableListRoot', () => {
describe('alert', () => {
const error = 'oopsie!';
- it('shows alert when there is an error', () => {
+ it('shows an alert when there is an error', () => {
wrapper = createComponent({ props: { error } });
expect(findAlert().text()).toBe(error);
@@ -508,7 +504,7 @@ describe('IssuableListRoot', () => {
});
});
- it('has the page size change component', async () => {
+ it('has the page size change component', () => {
expect(findPageSizeSelector().exists()).toBe(true);
});
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js
index 27985895c62..9cdd4d75c42 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js
@@ -35,7 +35,6 @@ describe('IssuableTabs', () => {
afterEach(() => {
setLanguage(null);
- wrapper.destroy();
});
const findAllGlBadges = () => wrapper.findAllComponents(GlBadge);
diff --git a/spec/frontend/vue_shared/issuable/list/mock_data.js b/spec/frontend/vue_shared/issuable/list/mock_data.js
index b67bd0f42fe..964b48f4275 100644
--- a/spec/frontend/vue_shared/issuable/list/mock_data.js
+++ b/spec/frontend/vue_shared/issuable/list/mock_data.js
@@ -60,6 +60,12 @@ export const mockIssuable = {
type: 'issue',
};
+export const mockIssuableItems = (n) =>
+ [...Array(n).keys()].map((i) => ({
+ id: i,
+ ...mockIssuable,
+ }));
+
export const mockIssuables = [
mockIssuable,
{
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js
index 6b20f0c77a3..02e729a00bd 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js
@@ -1,5 +1,5 @@
+import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import { useFakeDate } from 'helpers/fake_date';
import IssuableBody from '~/vue_shared/issuable/show/components/issuable_body.vue';
@@ -13,101 +13,77 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { mockIssuableShowProps, mockIssuable } from '../mock_data';
jest.mock('~/autosave');
-jest.mock('~/flash');
+jest.mock('~/alert');
+jest.mock('~/task_list');
const issuableBodyProps = {
...mockIssuableShowProps,
issuable: mockIssuable,
};
-const createComponent = (propsData = issuableBodyProps) =>
- shallowMount(IssuableBody, {
- propsData,
- stubs: {
- IssuableTitle,
- IssuableDescription,
- IssuableEditForm,
- TimeAgoTooltip,
- },
- slots: {
- 'status-badge': 'Open',
- 'edit-form-actions': `
- <button class="js-save">Save changes</button>
- <button class="js-cancel">Cancel</button>
- `,
- },
- });
-
describe('IssuableBody', () => {
// Some assertions expect a date later than our default
useFakeDate(2020, 11, 11);
let wrapper;
- beforeEach(() => {
- wrapper = createComponent();
- });
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(IssuableBody, {
+ propsData: {
+ ...issuableBodyProps,
+ ...propsData,
+ },
+ stubs: {
+ IssuableTitle,
+ IssuableDescription,
+ IssuableEditForm,
+ TimeAgoTooltip,
+ },
+ slots: {
+ 'status-badge': 'Open',
+ 'edit-form-actions': `
+ <button class="js-save">Save changes</button>
+ <button class="js-cancel">Cancel</button>
+ `,
+ },
+ });
+ };
+
+ const findUpdatedLink = () => wrapper.findComponent(GlLink);
+ const findIssuableEditForm = () => wrapper.findComponent(IssuableEditForm);
+ const findIssuableEditFormButton = (type) => findIssuableEditForm().find(`button.js-${type}`);
+ const findIssuableTitle = () => wrapper.findComponent(IssuableTitle);
- afterEach(() => {
- wrapper.destroy();
+ beforeEach(() => {
+ createComponent();
+ TaskList.mockClear();
});
describe('computed', () => {
- describe('isUpdated', () => {
- it.each`
- updatedAt | returnValue
- ${mockIssuable.updatedAt} | ${true}
- ${null} | ${false}
- ${''} | ${false}
- `(
- 'returns $returnValue when value of `updateAt` prop is `$updatedAt`',
- async ({ updatedAt, returnValue }) => {
- wrapper.setProps({
- issuable: {
- ...mockIssuable,
- updatedAt,
- },
- });
-
- await nextTick();
-
- expect(wrapper.vm.isUpdated).toBe(returnValue);
- },
- );
- });
-
describe('updatedBy', () => {
it('returns value of `issuable.updatedBy`', () => {
- expect(wrapper.vm.updatedBy).toBe(mockIssuable.updatedBy);
+ expect(findUpdatedLink().text()).toBe(mockIssuable.updatedBy.name);
+ expect(findUpdatedLink().attributes('href')).toBe(mockIssuable.updatedBy.webUrl);
});
});
});
describe('watchers', () => {
describe('editFormVisible', () => {
- it('calls initTaskList in nextTick', async () => {
- jest.spyOn(wrapper.vm, 'initTaskList');
- wrapper.setProps({
- editFormVisible: true,
- });
-
- await nextTick();
-
- wrapper.setProps({
+ it('calls initTaskList in nextTick', () => {
+ createComponent({
editFormVisible: false,
});
- await nextTick();
-
- expect(wrapper.vm.initTaskList).toHaveBeenCalled();
+ expect(TaskList).toHaveBeenCalled();
});
});
});
describe('mounted', () => {
it('initializes TaskList instance when enabledEdit and enableTaskList props are true', () => {
- expect(wrapper.vm.taskList instanceof TaskList).toBe(true);
- expect(wrapper.vm.taskList).toMatchObject({
+ createComponent();
+ expect(TaskList).toHaveBeenCalledWith({
dataType: 'issue',
fieldName: 'description',
lockVersion: issuableBodyProps.taskListLockVersion,
@@ -118,14 +94,12 @@ describe('IssuableBody', () => {
});
it('does not initialize TaskList instance when either enabledEdit or enableTaskList prop is false', () => {
- const wrapperNoTaskList = createComponent({
+ createComponent({
...issuableBodyProps,
enableTaskList: false,
});
- expect(wrapperNoTaskList.vm.taskList).not.toBeDefined();
-
- wrapperNoTaskList.destroy();
+ expect(TaskList).toHaveBeenCalledTimes(0);
});
});
@@ -154,10 +128,8 @@ describe('IssuableBody', () => {
describe('template', () => {
it('renders issuable-title component', () => {
- const titleEl = wrapper.findComponent(IssuableTitle);
-
- expect(titleEl.exists()).toBe(true);
- expect(titleEl.props()).toMatchObject({
+ expect(findIssuableTitle().exists()).toBe(true);
+ expect(findIssuableTitle().props()).toMatchObject({
issuable: issuableBodyProps.issuable,
statusIcon: issuableBodyProps.statusIcon,
enableEdit: issuableBodyProps.enableEdit,
@@ -172,42 +144,37 @@ describe('IssuableBody', () => {
});
it('renders issuable edit info', () => {
- const editedEl = wrapper.find('small');
-
- expect(editedEl.text()).toMatchInterpolatedText('Edited 3 months ago by Administrator');
+ expect(wrapper.find('small').text()).toMatchInterpolatedText(
+ 'Edited 3 months ago by Administrator',
+ );
});
- it('renders issuable-edit-form when `editFormVisible` prop is true', async () => {
- wrapper.setProps({
+ it('renders issuable-edit-form when `editFormVisible` prop is true', () => {
+ createComponent({
editFormVisible: true,
});
- await nextTick();
-
- const editFormEl = wrapper.findComponent(IssuableEditForm);
- expect(editFormEl.exists()).toBe(true);
- expect(editFormEl.props()).toMatchObject({
+ expect(findIssuableEditForm().exists()).toBe(true);
+ expect(findIssuableEditForm().props()).toMatchObject({
issuable: issuableBodyProps.issuable,
enableAutocomplete: issuableBodyProps.enableAutocomplete,
descriptionPreviewPath: issuableBodyProps.descriptionPreviewPath,
descriptionHelpPath: issuableBodyProps.descriptionHelpPath,
});
- expect(editFormEl.find('button.js-save').exists()).toBe(true);
- expect(editFormEl.find('button.js-cancel').exists()).toBe(true);
+ expect(findIssuableEditFormButton('save').exists()).toBe(true);
+ expect(findIssuableEditFormButton('cancel').exists()).toBe(true);
});
describe('events', () => {
it('component emits `edit-issuable` event bubbled via issuable-title', () => {
- const issuableTitle = wrapper.findComponent(IssuableTitle);
-
- issuableTitle.vm.$emit('edit-issuable');
+ findIssuableTitle().vm.$emit('edit-issuable');
expect(wrapper.emitted('edit-issuable')).toHaveLength(1);
});
it.each(['keydown-title', 'keydown-description'])(
'component emits `%s` event with event object and issuableMeta params via issuable-edit-form',
- async (eventName) => {
+ (eventName) => {
const eventObj = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
@@ -217,15 +184,11 @@ describe('IssuableBody', () => {
issuableDescription: 'foobar',
};
- wrapper.setProps({
+ createComponent({
editFormVisible: true,
});
- await nextTick();
-
- const issuableEditForm = wrapper.findComponent(IssuableEditForm);
-
- issuableEditForm.vm.$emit(eventName, eventObj, issuableMeta);
+ findIssuableEditForm().vm.$emit(eventName, eventObj, issuableMeta);
expect(wrapper.emitted(eventName)).toHaveLength(1);
expect(wrapper.emitted(eventName)[0]).toMatchObject([eventObj, issuableMeta]);
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js
index ea58cc2baf5..b4f1c286158 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js
@@ -24,10 +24,6 @@ describe('IssuableDescription', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('mounted', () => {
it('calls `renderGFM`', () => {
expect(renderGFM).toHaveBeenCalledTimes(1);
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
index 159be4cd1ef..4a52c2a8dad 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
@@ -43,6 +43,9 @@ describe('IssuableEditForm', () => {
});
afterEach(() => {
+ // note: the order of wrapper.destroy() and jest.resetAllMocks() matters.
+ // maybe it'll help with investigation on how to remove this wrapper.destroy() call
+ // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy
wrapper.destroy();
jest.resetAllMocks();
});
@@ -162,7 +165,7 @@ describe('IssuableEditForm', () => {
stopPropagation: jest.fn(),
};
- it('component emits `keydown-title` event with event object and issuableMeta params via gl-form-input', async () => {
+ it('component emits `keydown-title` event with event object and issuableMeta params via gl-form-input', () => {
const titleInputEl = wrapper.findComponent(GlFormInput);
titleInputEl.vm.$emit('keydown', eventObj, 'title');
@@ -176,7 +179,7 @@ describe('IssuableEditForm', () => {
]);
});
- it('component emits `keydown-description` event with event object and issuableMeta params via textarea', async () => {
+ it('component emits `keydown-description` event with event object and issuableMeta params via textarea', () => {
const descriptionInputEl = wrapper.find('[data-testid="description"] textarea');
descriptionInputEl.trigger('keydown', eventObj, 'description');
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js
index 6a8b9ef77a9..fa38ab8d44d 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js
@@ -1,4 +1,4 @@
-import { GlBadge, GlIcon, GlAvatarLabeled } from '@gitlab/ui';
+import { GlButton, GlBadge, GlIcon, GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue';
@@ -13,7 +13,10 @@ const issuableHeaderProps = {
describe('IssuableHeader', () => {
let wrapper;
+ const findAvatar = () => wrapper.findByTestId('avatar');
const findTaskStatusEl = () => wrapper.findByTestId('task-status');
+ const findButton = () => wrapper.findComponent(GlButton);
+ const findGlAvatarLink = () => wrapper.findComponent(GlAvatarLink);
const createComponent = (props = {}, { stubs } = {}) => {
wrapper = shallowMountExtended(IssuableHeader, {
@@ -33,7 +36,6 @@ describe('IssuableHeader', () => {
};
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
@@ -41,7 +43,7 @@ describe('IssuableHeader', () => {
describe('authorId', () => {
it('returns numeric ID from GraphQL ID of `author` prop', () => {
createComponent();
- expect(wrapper.vm.authorId).toBe(1);
+ expect(findGlAvatarLink().attributes('data-user-id')).toBe('1');
});
});
});
@@ -53,12 +55,14 @@ describe('IssuableHeader', () => {
it('dispatches `click` event on sidebar toggle button', () => {
createComponent();
- wrapper.vm.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button');
- jest.spyOn(wrapper.vm.toggleSidebarButtonEl, 'dispatchEvent').mockImplementation(jest.fn);
+ const toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button');
+ const dispatchEvent = jest
+ .spyOn(toggleSidebarButtonEl, 'dispatchEvent')
+ .mockImplementation(jest.fn);
- wrapper.vm.handleRightSidebarToggleClick();
+ findButton().vm.$emit('click');
- expect(wrapper.vm.toggleSidebarButtonEl.dispatchEvent).toHaveBeenCalledWith(
+ expect(dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'click',
}),
@@ -78,7 +82,7 @@ describe('IssuableHeader', () => {
expect(statusBoxEl.text()).toContain('Open');
});
- it('renders blocked icon when issuable is blocked', async () => {
+ it('renders blocked icon when issuable is blocked', () => {
createComponent({
blocked: true,
});
@@ -89,7 +93,7 @@ describe('IssuableHeader', () => {
expect(blockedEl.findComponent(GlIcon).props('name')).toBe('lock');
});
- it('renders confidential icon when issuable is confidential', async () => {
+ it('renders confidential icon when issuable is confidential', () => {
createComponent({
confidential: true,
});
@@ -110,7 +114,7 @@ describe('IssuableHeader', () => {
href: webUrl,
target: '_blank',
};
- const avatarEl = wrapper.findByTestId('avatar');
+ const avatarEl = findAvatar();
expect(avatarEl.exists()).toBe(true);
expect(avatarEl.attributes()).toMatchObject(avatarElAttrs);
expect(avatarEl.findComponent(GlAvatarLabeled).attributes()).toMatchObject({
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js
index edfd55c8bb4..f976e0499f0 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js
@@ -41,10 +41,6 @@ describe('IssuableShowRoot', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('template', () => {
const {
statusIcon,
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
index 6f62fb77353..39316dfa249 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
@@ -22,35 +22,35 @@ const createComponent = (propsData = issuableTitleProps) =>
'status-badge': 'Open',
},
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
describe('IssuableTitle', () => {
let wrapper;
+ const findStickyHeader = () => wrapper.findComponent('[data-testid="header"]');
+
beforeEach(() => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('methods', () => {
describe('handleTitleAppear', () => {
- it('sets value of `stickyTitleVisible` prop to false', () => {
+ it('sets value of `stickyTitleVisible` prop to false', async () => {
wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
+ await nextTick();
- expect(wrapper.vm.stickyTitleVisible).toBe(false);
+ expect(findStickyHeader().exists()).toBe(false);
});
});
describe('handleTitleDisappear', () => {
- it('sets value of `stickyTitleVisible` prop to true', () => {
+ it('sets value of `stickyTitleVisible` prop to true', async () => {
wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear');
+ await nextTick();
- expect(wrapper.vm.stickyTitleVisible).toBe(true);
+ expect(findStickyHeader().exists()).toBe(true);
});
});
});
@@ -87,14 +87,10 @@ describe('IssuableTitle', () => {
});
it('renders sticky header when `stickyTitleVisible` prop is true', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- stickyTitleVisible: true,
- });
-
+ wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear');
await nextTick();
- const stickyHeaderEl = wrapper.find('[data-testid="header"]');
+
+ const stickyHeaderEl = findStickyHeader();
expect(stickyHeaderEl.exists()).toBe(true);
expect(stickyHeaderEl.findComponent(GlBadge).props('variant')).toBe('success');
diff --git a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
index 6c9e5f85fa0..f2509aead77 100644
--- a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
@@ -38,7 +38,6 @@ describe('IssuableSidebarRoot', () => {
};
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
});
diff --git a/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js b/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js
index 52f36aa0e77..052ff518468 100644
--- a/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js
+++ b/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js
@@ -11,9 +11,7 @@ describe('Legacy container component', () => {
};
afterEach(() => {
- wrapper.destroy();
resetHTMLFixture();
- wrapper = null;
});
describe('when selector targets real node', () => {
diff --git a/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js
index c90131fea9a..cc8a8d86d19 100644
--- a/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js
+++ b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js
@@ -27,9 +27,7 @@ describe('Welcome page', () => {
});
afterEach(() => {
- wrapper.destroy();
window.location.hash = '';
- wrapper = null;
});
it('tracks link clicks', async () => {
diff --git a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js
index 6115dc6e61b..b87ae8a232f 100644
--- a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js
+++ b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js
@@ -4,17 +4,21 @@ import { nextTick } from 'vue';
import LegacyContainer from '~/vue_shared/new_namespace/components/legacy_container.vue';
import WelcomePage from '~/vue_shared/new_namespace/components/welcome.vue';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
+import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue';
+import { sidebarState } from '~/super_sidebar/constants';
-describe('Experimental new project creation app', () => {
+jest.mock('~/super_sidebar/constants');
+describe('Experimental new namespace creation app', () => {
let wrapper;
const findWelcomePage = () => wrapper.findComponent(WelcomePage);
const findLegacyContainer = () => wrapper.findComponent(LegacyContainer);
const findBreadcrumb = () => wrapper.findComponent(GlBreadcrumb);
+ const findSuperSidebarToggle = () => wrapper.findComponent(SuperSidebarToggle);
const DEFAULT_PROPS = {
title: 'Create something',
- initialBreadcrumb: 'Something',
+ initialBreadcrumbs: [{ text: 'Something', href: '#' }],
panels: [
{ name: 'panel1', selector: '#some-selector1' },
{ name: 'panel2', selector: '#some-selector2' },
@@ -33,7 +37,6 @@ describe('Experimental new project creation app', () => {
};
afterEach(() => {
- wrapper.destroy();
window.location.hash = '';
});
@@ -46,8 +49,8 @@ describe('Experimental new project creation app', () => {
expect(findWelcomePage().exists()).toBe(true);
});
- it('does not render breadcrumbs', () => {
- expect(findBreadcrumb().exists()).toBe(false);
+ it('renders breadcrumbs', () => {
+ expect(findBreadcrumb().exists()).toBe(true);
});
});
@@ -75,7 +78,7 @@ describe('Experimental new project creation app', () => {
it('renders breadcrumbs', () => {
const breadcrumb = findBreadcrumb();
expect(breadcrumb.exists()).toBe(true);
- expect(breadcrumb.props().items[0].text).toBe(DEFAULT_PROPS.initialBreadcrumb);
+ expect(breadcrumb.props().items[0].text).toBe(DEFAULT_PROPS.initialBreadcrumbs[0].text);
});
});
@@ -104,4 +107,22 @@ describe('Experimental new project creation app', () => {
expect(findWelcomePage().exists()).toBe(false);
expect(findLegacyContainer().exists()).toBe(true);
});
+
+ describe.each`
+ featureFlag | isSuperSidebarCollapsed | isToggleVisible
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${false}
+ ${false} | ${true} | ${false}
+ ${false} | ${false} | ${false}
+ `('Super sidebar toggle', ({ featureFlag, isSuperSidebarCollapsed, isToggleVisible }) => {
+ beforeEach(() => {
+ sidebarState.isCollapsed = isSuperSidebarCollapsed;
+ gon.use_new_navigation = featureFlag;
+ createComponent();
+ });
+
+ it(`${isToggleVisible ? 'is visible' : 'is not visible'}`, () => {
+ expect(findSuperSidebarToggle().exists()).toBe(isToggleVisible);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/plugins/global_toast_spec.js b/spec/frontend/vue_shared/plugins/global_toast_spec.js
index 322586a772c..0bf2737fb2b 100644
--- a/spec/frontend/vue_shared/plugins/global_toast_spec.js
+++ b/spec/frontend/vue_shared/plugins/global_toast_spec.js
@@ -1,14 +1,16 @@
-import toast, { instance } from '~/vue_shared/plugins/global_toast';
+import toast from '~/vue_shared/plugins/global_toast';
-describe('Global toast', () => {
- let spyFunc;
-
- beforeEach(() => {
- spyFunc = jest.spyOn(instance.$toast, 'show').mockImplementation(() => {});
- });
+const mockSpy = jest.fn();
+jest.mock('@gitlab/ui', () => ({
+ GlToast: (Vue) => {
+ // eslint-disable-next-line no-param-reassign
+ Vue.prototype.$toast = { show: (...args) => mockSpy(...args) };
+ },
+}));
+describe('Global toast', () => {
afterEach(() => {
- spyFunc.mockRestore();
+ mockSpy.mockRestore();
});
it("should call GitLab UI's toast method", () => {
@@ -17,7 +19,7 @@ describe('Global toast', () => {
toast(arg1, arg2);
- expect(instance.$toast.show).toHaveBeenCalledTimes(1);
- expect(instance.$toast.show).toHaveBeenCalledWith(arg1, arg2);
+ expect(mockSpy).toHaveBeenCalledTimes(1);
+ expect(mockSpy).toHaveBeenCalledWith(arg1, arg2);
});
});
diff --git a/spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js b/spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js
index 136fe74b0d6..d258658d5e2 100644
--- a/spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js
+++ b/spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js
@@ -21,10 +21,6 @@ describe('Section Layout component', () => {
const findHeading = () => wrapper.find('h2');
const findLoader = () => wrapper.findComponent(SectionLoader);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('basic structure', () => {
beforeEach(() => {
createComponent({ heading: 'testheading' });
diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js
index 0a5e46d9263..f3d0d66cdd1 100644
--- a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js
+++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js
@@ -7,8 +7,10 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { humanize } from '~/lib/utils/text_utility';
-import { redirectTo } from '~/lib/utils/url_utility';
-import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import ManageViaMr, {
+ i18n,
+} from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
import { buildConfigureSecurityFeatureMockFactory } from './apollo_mocks';
@@ -17,6 +19,7 @@ jest.mock('~/lib/utils/url_utility');
Vue.use(VueApollo);
const projectFullPath = 'namespace/project';
+const ufErrorPrefix = 'Foo:';
describe('ManageViaMr component', () => {
let wrapper;
@@ -56,8 +59,8 @@ describe('ManageViaMr component', () => {
);
}
- afterEach(() => {
- wrapper.destroy();
+ beforeEach(() => {
+ gon.uf_error_prefix = ufErrorPrefix;
});
// This component supports different report types/mutations depending on
@@ -76,15 +79,19 @@ describe('ManageViaMr component', () => {
const buildConfigureSecurityFeatureMock = buildConfigureSecurityFeatureMockFactory(
mutationId,
);
- const successHandler = jest.fn(async () => buildConfigureSecurityFeatureMock());
- const noSuccessPathHandler = async () =>
+ const successHandler = jest.fn().mockResolvedValue(buildConfigureSecurityFeatureMock());
+ const noSuccessPathHandler = jest.fn().mockResolvedValue(
buildConfigureSecurityFeatureMock({
successPath: '',
- });
- const errorHandler = async () =>
- buildConfigureSecurityFeatureMock({
- errors: ['foo'],
- });
+ }),
+ );
+ const errorHandler = (message = 'foo') => {
+ return Promise.resolve(
+ buildConfigureSecurityFeatureMock({
+ errors: [message],
+ }),
+ );
+ };
const pendingHandler = () => new Promise(() => {});
describe('when feature is configured', () => {
@@ -139,8 +146,8 @@ describe('ManageViaMr component', () => {
it('should call redirect helper with correct value', async () => {
await wrapper.trigger('click');
await waitForPromises();
- expect(redirectTo).toHaveBeenCalledTimes(1);
- expect(redirectTo).toHaveBeenCalledWith('testSuccessPath');
+ expect(redirectTo).toHaveBeenCalledTimes(1); // eslint-disable-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith('testSuccessPath'); // eslint-disable-line import/no-deprecated
// This is done for UX reasons. If the loading prop is set to false
// on success, then there's a period where the button is clickable
// again. Instead, we want the button to display a loading indicator
@@ -151,9 +158,12 @@ describe('ManageViaMr component', () => {
});
describe.each`
- handler | message
- ${noSuccessPathHandler} | ${`${featureName} merge request creation mutation failed`}
- ${errorHandler} | ${'foo'}
+ handler | message
+ ${noSuccessPathHandler} | ${`${featureName} merge request creation mutation failed`}
+ ${errorHandler.bind(null, `${ufErrorPrefix} message`)} | ${'message'}
+ ${errorHandler.bind(null, 'Blah: message')} | ${i18n.genericErrorText}
+ ${errorHandler.bind(null, 'message')} | ${i18n.genericErrorText}
+ ${errorHandler} | ${i18n.genericErrorText}
`('given an error response', ({ handler, message }) => {
beforeEach(() => {
const apolloProvider = createMockApolloProvider(mutation, handler);
diff --git a/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js
index 5f2b13a79c9..299a3d62421 100644
--- a/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js
+++ b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js
@@ -15,11 +15,6 @@ describe('SecurityReportDownloadDropdown component', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
describe('given report artifacts', () => {
beforeEach(() => {
artifacts = [
diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
index 221da35de3d..257f59612e8 100644
--- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
+++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
@@ -14,7 +14,7 @@ import {
sastDiffSuccessMock,
secretDetectionDiffSuccessMock,
} from 'jest/vue_shared/security_reports/mock_data';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue';
@@ -26,7 +26,7 @@ import {
import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
-jest.mock('~/flash');
+jest.mock('~/alert');
Vue.use(VueApollo);
Vue.use(Vuex);
@@ -74,10 +74,6 @@ describe('Security reports app', () => {
const findDownloadDropdown = () => wrapper.findComponent(SecurityReportDownloadDropdown);
const findHelpIconComponent = () => wrapper.findComponent(HelpIcon);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('given the artifacts query is loading', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/webhooks/components/form_url_app_spec.js b/spec/frontend/webhooks/components/form_url_app_spec.js
index 45a39d2dd58..cbeff184e9d 100644
--- a/spec/frontend/webhooks/components/form_url_app_spec.js
+++ b/spec/frontend/webhooks/components/form_url_app_spec.js
@@ -19,10 +19,6 @@ describe('FormUrlApp', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
const findAllRadioButtons = () => wrapper.findAllComponents(GlFormRadio);
const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
const findUrlMaskDisable = () => findAllRadioButtons().at(0);
diff --git a/spec/frontend/webhooks/components/form_url_mask_item_spec.js b/spec/frontend/webhooks/components/form_url_mask_item_spec.js
index 06c743749a6..6bae0ca9854 100644
--- a/spec/frontend/webhooks/components/form_url_mask_item_spec.js
+++ b/spec/frontend/webhooks/components/form_url_mask_item_spec.js
@@ -57,12 +57,12 @@ describe('FormUrlMaskItem', () => {
});
it('renders disabled key and value', () => {
- expect(findMaskItemKey().findComponent(GlFormInput).attributes('disabled')).toBe('true');
- expect(findMaskItemValue().findComponent(GlFormInput).attributes('disabled')).toBe('true');
+ expect(findMaskItemKey().findComponent(GlFormInput).attributes('disabled')).toBeDefined();
+ expect(findMaskItemValue().findComponent(GlFormInput).attributes('disabled')).toBeDefined();
});
it('renders disabled remove button', () => {
- expect(findRemoveButton().attributes('disabled')).toBe('true');
+ expect(findRemoveButton().attributes('disabled')).toBeDefined();
});
it('displays ************ as input value', () => {
diff --git a/spec/frontend/webhooks/components/push_events_spec.js b/spec/frontend/webhooks/components/push_events_spec.js
index ccb61c4049a..6889d48e904 100644
--- a/spec/frontend/webhooks/components/push_events_spec.js
+++ b/spec/frontend/webhooks/components/push_events_spec.js
@@ -61,7 +61,7 @@ describe('Webhook push events form editor component', () => {
await nextTick();
});
- it('all_branches should be selected by default', async () => {
+ it('all_branches should be selected by default', () => {
expect(findPushEventRulesGroup().element).toMatchSnapshot();
});
diff --git a/spec/frontend/webhooks/components/test_dropdown_spec.js b/spec/frontend/webhooks/components/test_dropdown_spec.js
index 2f62ca13469..36777b0ba64 100644
--- a/spec/frontend/webhooks/components/test_dropdown_spec.js
+++ b/spec/frontend/webhooks/components/test_dropdown_spec.js
@@ -1,6 +1,6 @@
import { GlDisclosureDropdown } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import { getByRole } from '@testing-library/dom';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
import HookTestDropdown from '~/webhooks/components/test_dropdown.vue';
const mockItems = [
@@ -14,17 +14,14 @@ describe('HookTestDropdown', () => {
let wrapper;
const findDisclosure = () => wrapper.findComponent(GlDisclosureDropdown);
- const clickItem = (itemText) => {
- const item = getByRole(wrapper.element, 'button', { name: itemText });
- item.dispatchEvent(new MouseEvent('click'));
- };
const createComponent = (props) => {
- wrapper = mount(HookTestDropdown, {
+ wrapper = mountExtended(HookTestDropdown, {
propsData: {
items: mockItems,
...props,
},
+ attachTo: document.body,
});
};
@@ -55,7 +52,7 @@ describe('HookTestDropdown', () => {
});
});
- clickItem(mockItems[0].text);
+ wrapper.findByTestId('disclosure-dropdown-item').find('a').trigger('click');
return railsEventPromise;
});
diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js
index ee15034daff..000b07f4dfd 100644
--- a/spec/frontend/whats_new/components/app_spec.js
+++ b/spec/frontend/whats_new/components/app_spec.js
@@ -49,7 +49,7 @@ describe('App', () => {
store,
propsData: buildProps(),
directives: {
- GlResizeObserver: createMockDirective(),
+ GlResizeObserver: createMockDirective('gl-resize-observer'),
},
});
};
@@ -71,7 +71,6 @@ describe('App', () => {
};
afterEach(() => {
- wrapper.destroy();
unmockTracking();
});
diff --git a/spec/frontend/whats_new/components/feature_spec.js b/spec/frontend/whats_new/components/feature_spec.js
index 099054bf8ca..d69ac2803df 100644
--- a/spec/frontend/whats_new/components/feature_spec.js
+++ b/spec/frontend/whats_new/components/feature_spec.js
@@ -30,11 +30,6 @@ describe("What's new single feature", () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
it('renders the date', () => {
createWrapper({ feature: exampleFeature });
diff --git a/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js b/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js
index b199f4f0c49..79717b8767e 100644
--- a/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js
+++ b/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js
@@ -11,10 +11,6 @@ describe('~/whats_new/utils/get_drawer_body_height', () => {
});
});
- afterEach(() => {
- drawerWrapper.destroy();
- });
-
const setClientHeight = (el, height) => {
Object.defineProperty(el, 'clientHeight', {
get() {
diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js
index dac02ee07bd..8b5663ee764 100644
--- a/spec/frontend/whats_new/utils/notification_spec.js
+++ b/spec/frontend/whats_new/utils/notification_spec.js
@@ -1,4 +1,5 @@
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlWhatsNewNotification from 'test_fixtures_static/whats_new_notification.html';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { setNotification, getVersionDigest } from '~/whats_new/utils/notification';
@@ -12,7 +13,7 @@ describe('~/whats_new/utils/notification', () => {
const getAppEl = () => wrapper.querySelector('.app');
beforeEach(() => {
- loadHTMLFixture('static/whats_new_notification.html');
+ setHTMLFixture(htmlWhatsNewNotification);
wrapper = document.querySelector('.whats-new-notification-fixture-root');
});
diff --git a/spec/frontend/work_items/components/app_spec.js b/spec/frontend/work_items/components/app_spec.js
index 95034085493..d799e8042b1 100644
--- a/spec/frontend/work_items/components/app_spec.js
+++ b/spec/frontend/work_items/components/app_spec.js
@@ -12,10 +12,6 @@ describe('Work Items Application', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders a component', () => {
createComponent();
diff --git a/spec/frontend/work_items/components/item_state_spec.js b/spec/frontend/work_items/components/item_state_spec.js
index c3cc2fbc556..c3bdbfe030e 100644
--- a/spec/frontend/work_items/components/item_state_spec.js
+++ b/spec/frontend/work_items/components/item_state_spec.js
@@ -21,10 +21,6 @@ describe('ItemState', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders label and dropdown', () => {
createComponent();
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js
index 6361f8dafc4..3a84ba4bd5e 100644
--- a/spec/frontend/work_items/components/item_title_spec.js
+++ b/spec/frontend/work_items/components/item_title_spec.js
@@ -19,10 +19,6 @@ describe('ItemTitle', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders title contents', () => {
expect(findInputEl().attributes()).toMatchObject({
'data-placeholder': 'Add a title...',
@@ -51,7 +47,7 @@ describe('ItemTitle', () => {
expect(wrapper.emitted(eventName)).toBeDefined();
});
- it('renders only the text content from clipboard', async () => {
+ it('renders only the text content from clipboard', () => {
const htmlContent = '<strong>bold text</strong>';
const buildClipboardData = (data = {}) => ({
clipboardData: {
diff --git a/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_replying_spec.js.snap b/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_replying_spec.js.snap
index 5901642b8a1..30577dc60cf 100644
--- a/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_replying_spec.js.snap
+++ b/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_replying_spec.js.snap
@@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Work Item Note Replying should have the note body and header 1`] = `"<note-header-stub author=\\"[object Object]\\" actiontext=\\"\\" noteabletype=\\"\\" expanded=\\"true\\" showspinner=\\"true\\"></note-header-stub>"`;
+exports[`Work Item Note Replying should have the note body and header 1`] = `"<note-header-stub author=\\"[object Object]\\" actiontext=\\"\\" noteabletype=\\"\\" expanded=\\"true\\" showspinner=\\"true\\" noteurl=\\"\\" emailparticipant=\\"\\"></note-header-stub>"`;
diff --git a/spec/frontend/work_items/components/notes/activity_filter_spec.js b/spec/frontend/work_items/components/notes/activity_filter_spec.js
deleted file mode 100644
index eb4bcbf942b..00000000000
--- a/spec/frontend/work_items/components/notes/activity_filter_spec.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { nextTick } from 'vue';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import ActivityFilter from '~/work_items/components/notes/activity_filter.vue';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { ASC, DESC } from '~/notes/constants';
-
-import { mockTracking } from 'helpers/tracking_helper';
-import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
-
-describe('Activity Filter', () => {
- let wrapper;
-
- const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- const findNewestFirstItem = () => wrapper.findByTestId('js-newest-first');
-
- const createComponent = ({ sortOrder = ASC, loading = false, workItemType = 'Task' } = {}) => {
- wrapper = shallowMountExtended(ActivityFilter, {
- propsData: {
- sortOrder,
- loading,
- workItemType,
- },
- });
- };
-
- beforeEach(() => {
- createComponent();
- });
-
- describe('default', () => {
- it('has a dropdown with 2 options', () => {
- expect(findDropdown().exists()).toBe(true);
- expect(findAllDropdownItems()).toHaveLength(ActivityFilter.SORT_OPTIONS.length);
- });
-
- it('has local storage sync with the correct props', () => {
- expect(findLocalStorageSync().props('asString')).toBe(true);
- });
-
- it('emits `updateSavedSortOrder` event when update is emitted', async () => {
- findLocalStorageSync().vm.$emit('input', ASC);
-
- await nextTick();
- expect(wrapper.emitted('updateSavedSortOrder')).toHaveLength(1);
- expect(wrapper.emitted('updateSavedSortOrder')).toEqual([[ASC]]);
- });
- });
-
- describe('when asc', () => {
- describe('when the dropdown is clicked', () => {
- it('calls the right actions', async () => {
- const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- findNewestFirstItem().vm.$emit('click');
- await nextTick();
-
- expect(wrapper.emitted('changeSortOrder')).toHaveLength(1);
- expect(wrapper.emitted('changeSortOrder')).toEqual([[DESC]]);
-
- expect(trackingSpy).toHaveBeenCalledWith(
- TRACKING_CATEGORY_SHOW,
- 'notes_sort_order_changed',
- {
- category: TRACKING_CATEGORY_SHOW,
- label: 'item_track_notes_sorting',
- property: 'type_Task',
- },
- );
- });
- });
- });
-});
diff --git a/spec/frontend/work_items/components/notes/work_item_activity_sort_filter_spec.js b/spec/frontend/work_items/components/notes/work_item_activity_sort_filter_spec.js
new file mode 100644
index 00000000000..5ed9d581446
--- /dev/null
+++ b/spec/frontend/work_items/components/notes/work_item_activity_sort_filter_spec.js
@@ -0,0 +1,109 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import WorkItemActivitySortFilter from '~/work_items/components/notes/work_item_activity_sort_filter.vue';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { ASC, DESC } from '~/notes/constants';
+import {
+ WORK_ITEM_ACTIVITY_SORT_OPTIONS,
+ WORK_ITEM_NOTES_SORT_ORDER_KEY,
+ WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+ WORK_ITEM_NOTES_FILTER_KEY,
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_ACTIVITY_FILTER_OPTIONS,
+ TRACKING_CATEGORY_SHOW,
+} from '~/work_items/constants';
+
+import { mockTracking } from 'helpers/tracking_helper';
+
+describe('Work Item Activity/Discussions Filtering', () => {
+ let wrapper;
+
+ const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findByDataTestId = (dataTestId) => wrapper.findByTestId(dataTestId);
+
+ const createComponent = ({
+ loading = false,
+ workItemType = 'Task',
+ sortFilterProp = ASC,
+ filterOptions = WORK_ITEM_ACTIVITY_SORT_OPTIONS,
+ trackingLabel = 'item_track_notes_sorting',
+ trackingAction = 'work_item_notes_sort_order_changed',
+ filterEvent = 'changeSort',
+ defaultSortFilterProp = ASC,
+ storageKey = WORK_ITEM_NOTES_SORT_ORDER_KEY,
+ } = {}) => {
+ wrapper = shallowMountExtended(WorkItemActivitySortFilter, {
+ propsData: {
+ loading,
+ workItemType,
+ sortFilterProp,
+ filterOptions,
+ trackingLabel,
+ trackingAction,
+ filterEvent,
+ defaultSortFilterProp,
+ storageKey,
+ },
+ });
+ };
+
+ describe.each`
+ usedFor | filterOptions | storageKey | filterEvent | newInputOption | trackingLabel | trackingAction | defaultSortFilterProp | sortFilterProp | nonDefaultDataTestId
+ ${'Sorting'} | ${WORK_ITEM_ACTIVITY_SORT_OPTIONS} | ${WORK_ITEM_NOTES_SORT_ORDER_KEY} | ${'changeSort'} | ${DESC} | ${'item_track_notes_sorting'} | ${'work_item_notes_sort_order_changed'} | ${ASC} | ${ASC} | ${'newest-first'}
+ ${'Filtering'} | ${WORK_ITEM_ACTIVITY_FILTER_OPTIONS} | ${WORK_ITEM_NOTES_FILTER_KEY} | ${'changeFilter'} | ${WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS} | ${'item_track_notes_sorting'} | ${'work_item_notes_filter_changed'} | ${WORK_ITEM_NOTES_FILTER_ALL_NOTES} | ${WORK_ITEM_NOTES_FILTER_ALL_NOTES} | ${'comments-activity'}
+ `(
+ 'When used for $usedFor',
+ ({
+ filterOptions,
+ storageKey,
+ filterEvent,
+ trackingLabel,
+ trackingAction,
+ newInputOption,
+ defaultSortFilterProp,
+ sortFilterProp,
+ nonDefaultDataTestId,
+ }) => {
+ beforeEach(() => {
+ createComponent({
+ sortFilterProp,
+ filterOptions,
+ trackingLabel,
+ trackingAction,
+ filterEvent,
+ defaultSortFilterProp,
+ storageKey,
+ });
+ });
+
+ it('has a dropdown with options equal to the length of `filterOptions`', () => {
+ expect(findDropdown().exists()).toBe(true);
+ expect(findAllDropdownItems()).toHaveLength(filterOptions.length);
+ });
+
+ it('has local storage sync with the correct props', () => {
+ expect(findLocalStorageSync().props('asString')).toBe(true);
+ expect(findLocalStorageSync().props('storageKey')).toBe(storageKey);
+ });
+
+ it(`emits ${filterEvent} event when local storage input is emitted`, () => {
+ findLocalStorageSync().vm.$emit('input', newInputOption);
+
+ expect(wrapper.emitted(filterEvent)).toEqual([[newInputOption]]);
+ });
+
+ it('emits tracking event when the a non default dropdown item is clicked', () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ findByDataTestId(nonDefaultDataTestId).vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, trackingAction, {
+ category: TRACKING_CATEGORY_SHOW,
+ label: trackingLabel,
+ property: 'type_Task',
+ });
+ });
+ },
+ );
+});
diff --git a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js
index 2a65e91a906..739340f4936 100644
--- a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js
@@ -1,26 +1,20 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { clearDraft } from '~/lib/utils/autosave';
-import { config } from '~/graphql_shared/issuable_client';
import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue';
import WorkItemCommentLocked from '~/work_items/components/notes/work_item_comment_locked.vue';
import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue';
import createNoteMutation from '~/work_items/graphql/notes/create_work_item_note.mutation.graphql';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
-import workItemNotesQuery from '~/work_items/graphql/notes/work_item_notes.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
- workItemResponseFactory,
- workItemQueryResponse,
- projectWorkItemResponse,
createWorkItemNoteResponse,
- mockWorkItemNotesResponse,
+ workItemByIidResponseFactory,
+ workItemQueryResponse,
} from '../../mock_data';
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
@@ -28,62 +22,49 @@ jest.mock('~/lib/utils/autosave');
const workItemId = workItemQueryResponse.data.workItem.id;
-describe('WorkItemCommentForm', () => {
+describe('Work item add note', () => {
let wrapper;
Vue.use(VueApollo);
const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemNoteResponse);
- const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
let workItemResponseHandler;
const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm);
+ const findTextarea = () => wrapper.findByTestId('note-reply-textarea');
const createComponent = async ({
mutationHandler = mutationSuccessHandler,
canUpdate = true,
- workItemResponse = workItemResponseFactory({ canUpdate }),
- queryVariables = { id: workItemId },
- fetchByIid = false,
+ workItemIid = '1',
+ workItemResponse = workItemByIidResponseFactory({ canUpdate }),
signedIn = true,
isEditing = true,
workItemType = 'Task',
} = {}) => {
workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse);
-
if (signedIn) {
window.gon.current_user_id = '1';
window.gon.current_user_avatar_url = 'avatar.png';
}
- const apolloProvider = createMockApollo(
- [
- [workItemQuery, workItemResponseHandler],
- [createNoteMutation, mutationHandler],
- [workItemByIidQuery, workItemByIidResponseHandler],
- ],
- {},
- { ...config.cacheConfig },
- );
-
- apolloProvider.clients.defaultClient.writeQuery({
- query: workItemNotesQuery,
- variables: {
- id: workItemId,
- pageSize: 100,
- },
- data: mockWorkItemNotesResponse.data,
- });
+ const apolloProvider = createMockApollo([
+ [workItemByIidQuery, workItemResponseHandler],
+ [createNoteMutation, mutationHandler],
+ ]);
const { id } = workItemQueryResponse.data.workItem;
- wrapper = shallowMount(WorkItemAddNote, {
+ wrapper = shallowMountExtended(WorkItemAddNote, {
apolloProvider,
+ provide: {
+ fullPath: 'test-project-path',
+ },
propsData: {
workItemId: id,
- fullPath: 'test-project-path',
- queryVariables,
- fetchByIid,
+ workItemIid,
workItemType,
+ markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem',
+ autocompleteDataSources: {},
},
stubs: {
WorkItemCommentLocked,
@@ -93,7 +74,7 @@ describe('WorkItemCommentForm', () => {
await waitForPromises();
if (isEditing) {
- wrapper.findComponent(GlButton).vm.$emit('click');
+ findTextarea().trigger('click');
}
};
@@ -135,13 +116,7 @@ describe('WorkItemCommentForm', () => {
});
it('emits `replied` event and hides form after successful mutation', async () => {
- await createComponent({
- isEditing: true,
- signedIn: true,
- queryVariables: {
- id: mockWorkItemNotesResponse.data.workItem.id,
- },
- });
+ await createComponent({ isEditing: true, signedIn: true });
findCommentForm().vm.$emit('submitForm', 'some text');
await waitForPromises();
@@ -209,25 +184,48 @@ describe('WorkItemCommentForm', () => {
expect(wrapper.emitted('error')).toEqual([[error]]);
});
- });
- it('calls the global ID work item query when `fetchByIid` prop is false', async () => {
- createComponent({ fetchByIid: false });
- await waitForPromises();
+ it('ignores errors when mutation returns additional information as errors for quick actions', async () => {
+ await createComponent({
+ isEditing: true,
+ mutationHandler: jest.fn().mockResolvedValue({
+ data: {
+ createNote: {
+ note: {
+ id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
+ discussion: {
+ id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
+ notes: {
+ nodes: [],
+ __typename: 'NoteConnection',
+ },
+ __typename: 'Discussion',
+ },
+ __typename: 'Note',
+ },
+ __typename: 'CreateNotePayload',
+ errors: ['Commands only Removed assignee @foobar.', 'Command names ["unassign"]'],
+ },
+ },
+ }),
+ });
- expect(workItemResponseHandler).toHaveBeenCalled();
- expect(workItemByIidResponseHandler).not.toHaveBeenCalled();
+ findCommentForm().vm.$emit('submitForm', 'updated desc');
+
+ await waitForPromises();
+
+ expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment');
+ });
});
- it('calls the IID work item query when when `fetchByIid` prop is true', async () => {
- await createComponent({ fetchByIid: true, isEditing: false });
+ it('calls the work item query', async () => {
+ await createComponent();
- expect(workItemResponseHandler).not.toHaveBeenCalled();
- expect(workItemByIidResponseHandler).toHaveBeenCalled();
+ expect(workItemResponseHandler).toHaveBeenCalled();
});
- it('skips calling the handlers when missing the needed queryVariables', async () => {
- await createComponent({ queryVariables: {}, fetchByIid: false, isEditing: false });
+ it('skips calling the work item query when missing workItemIid', async () => {
+ await createComponent({ workItemIid: null, isEditing: false });
expect(workItemResponseHandler).not.toHaveBeenCalled();
});
diff --git a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js
index 23a9f285804..147f2904761 100644
--- a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js
@@ -1,11 +1,23 @@
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import * as autosave from '~/lib/utils/autosave';
import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys';
+import {
+ STATE_OPEN,
+ STATE_CLOSED,
+ STATE_EVENT_REOPEN,
+ STATE_EVENT_CLOSE,
+} from '~/work_items/constants';
import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import { updateWorkItemMutationResponse, workItemQueryResponse } from 'jest/work_items/mock_data';
+
+Vue.use(VueApollo);
const draftComment = 'draft comment';
@@ -18,6 +30,8 @@ jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => ({
confirmAction: jest.fn().mockResolvedValue(true),
}));
+const workItemId = 'gid://gitlab/WorkItem/1';
+
describe('Work item comment form component', () => {
let wrapper;
@@ -27,14 +41,29 @@ describe('Work item comment form component', () => {
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]');
- const createComponent = ({ isSubmitting = false, initialValue = '' } = {}) => {
+ const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
+
+ const createComponent = ({
+ isSubmitting = false,
+ initialValue = '',
+ isNewDiscussion = false,
+ workItemState = STATE_OPEN,
+ workItemType = 'Task',
+ mutationHandler = mutationSuccessHandler,
+ } = {}) => {
wrapper = shallowMount(WorkItemCommentForm, {
+ apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
propsData: {
- workItemType: 'Issue',
+ workItemState,
+ workItemId,
+ workItemType,
ariaLabel: 'test-aria-label',
autosaveKey: mockAutosaveKey,
isSubmitting,
initialValue,
+ markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem',
+ autocompleteDataSources: {},
+ isNewDiscussion,
},
provide: {
fullPath: 'test-project-path',
@@ -42,11 +71,11 @@ describe('Work item comment form component', () => {
});
};
- it('passes correct markdown preview path to markdown editor', () => {
+ it('passes markdown preview path to markdown editor', () => {
createComponent();
expect(findMarkdownEditor().props('renderMarkdownPath')).toBe(
- '/test-project-path/preview_markdown?target_type=Issue',
+ '/group/project/preview_markdown?target_type=WorkItem',
);
});
@@ -99,7 +128,7 @@ describe('Work item comment form component', () => {
expect(findMarkdownEditor().props('value')).toBe('new comment');
});
- it('calls `updateDraft` with correct parameters', async () => {
+ it('calls `updateDraft` with correct parameters', () => {
findMarkdownEditor().vm.$emit('input', 'new comment');
expect(autosave.updateDraft).toHaveBeenCalledWith(mockAutosaveKey, 'new comment');
@@ -161,4 +190,63 @@ describe('Work item comment form component', () => {
expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]);
});
+
+ describe('when used as a top level/is a new discussion', () => {
+ describe('cancel button text', () => {
+ it.each`
+ workItemState | workItemType | buttonText
+ ${STATE_OPEN} | ${'Task'} | ${'Close task'}
+ ${STATE_CLOSED} | ${'Task'} | ${'Reopen task'}
+ ${STATE_OPEN} | ${'Objective'} | ${'Close objective'}
+ ${STATE_CLOSED} | ${'Objective'} | ${'Reopen objective'}
+ ${STATE_OPEN} | ${'Key result'} | ${'Close key result'}
+ ${STATE_CLOSED} | ${'Key result'} | ${'Reopen key result'}
+ `(
+ 'is "$buttonText" when "$workItemType" state is "$workItemState"',
+ ({ workItemState, workItemType, buttonText }) => {
+ createComponent({ isNewDiscussion: true, workItemState, workItemType });
+
+ expect(findCancelButton().text()).toBe(buttonText);
+ },
+ );
+ });
+
+ describe('Close/reopen button click', () => {
+ it.each`
+ workItemState | stateEvent
+ ${STATE_OPEN} | ${STATE_EVENT_CLOSE}
+ ${STATE_CLOSED} | ${STATE_EVENT_REOPEN}
+ `(
+ 'calls mutation with "$stateEvent" when workItemState is "$workItemState"',
+ async ({ workItemState, stateEvent }) => {
+ createComponent({ isNewDiscussion: true, workItemState });
+
+ findCancelButton().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(mutationSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ id: workItemQueryResponse.data.workItem.id,
+ stateEvent,
+ },
+ });
+ },
+ );
+
+ it('emits an error message when the mutation was unsuccessful', async () => {
+ createComponent({
+ isNewDiscussion: true,
+ mutationHandler: jest.fn().mockRejectedValue('Error!'),
+ });
+ findCancelButton().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([
+ ['Something went wrong while updating the task. Please try again.'],
+ ]);
+ });
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js
index bb65b75c4d8..fac5011b6af 100644
--- a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js
@@ -1,7 +1,5 @@
-import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
-import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
@@ -13,7 +11,7 @@ import {
} from 'jest/work_items/mock_data';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
-const mockWorkItemNotesWidgetResponseWithComments = mockWorkItemNotesResponseWithComments.data.workItem.widgets.find(
+const mockWorkItemNotesWidgetResponseWithComments = mockWorkItemNotesResponseWithComments.data.workspace.workItems.nodes[0].widgets.find(
(widget) => widget.type === WIDGET_TYPE_NOTES,
);
@@ -21,9 +19,6 @@ describe('Work Item Discussion', () => {
let wrapper;
const mockWorkItemId = 'gid://gitlab/WorkItem/625';
- const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem);
- const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
- const findAvatar = () => wrapper.findComponent(GlAvatar);
const findToggleRepliesWidget = () => wrapper.findComponent(ToggleRepliesWidget);
const findAllThreads = () => wrapper.findAllComponents(WorkItemNote);
const findThreadAtIndex = (index) => findAllThreads().at(index);
@@ -33,19 +28,19 @@ describe('Work Item Discussion', () => {
const createComponent = ({
discussion = [mockWorkItemCommentNote],
workItemId = mockWorkItemId,
- queryVariables = { id: workItemId },
- fetchByIid = false,
- fullPath = 'gitlab-org',
workItemType = 'Task',
} = {}) => {
wrapper = shallowMount(WorkItemDiscussion, {
+ provide: {
+ fullPath: 'gitlab-org',
+ },
propsData: {
discussion,
workItemId,
- queryVariables,
- fetchByIid,
- fullPath,
+ workItemIid: '1',
workItemType,
+ markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem',
+ autocompleteDataSources: {},
},
});
};
@@ -55,19 +50,6 @@ describe('Work Item Discussion', () => {
createComponent();
});
- it('Should be wrapped inside the timeline entry item', () => {
- expect(findTimelineEntryItem().exists()).toBe(true);
- });
-
- it('should have the author avatar of the work item note', () => {
- expect(findAvatarLink().exists()).toBe(true);
- expect(findAvatarLink().attributes('href')).toBe(mockWorkItemCommentNote.author.webUrl);
-
- expect(findAvatar().exists()).toBe(true);
- expect(findAvatar().props('src')).toBe(mockWorkItemCommentNote.author.avatarUrl);
- expect(findAvatar().props('entityName')).toBe(mockWorkItemCommentNote.author.username);
- });
-
it('should not show the the toggle replies widget wrapper when no replies', () => {
expect(findToggleRepliesWidget().exists()).toBe(false);
});
@@ -88,14 +70,18 @@ describe('Work Item Discussion', () => {
expect(findToggleRepliesWidget().exists()).toBe(true);
});
- it('the number of threads should be equal to the response length', async () => {
- findToggleRepliesWidget().vm.$emit('toggle');
- await nextTick();
+ it('the number of threads should be equal to the response length', () => {
expect(findAllThreads()).toHaveLength(
mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes.length,
);
});
+ it('should collapse when we click on toggle replies widget', async () => {
+ findToggleRepliesWidget().vm.$emit('toggle');
+ await nextTick();
+ expect(findAllThreads()).toHaveLength(1);
+ });
+
it('should autofocus when we click expand replies', async () => {
const mainComment = findThreadAtIndex(0);
@@ -118,7 +104,7 @@ describe('Work Item Discussion', () => {
await findWorkItemAddNote().vm.$emit('replying', 'reply text');
});
- it('should show optimistic behavior when replying', async () => {
+ it('should show optimistic behavior when replying', () => {
expect(findAllThreads()).toHaveLength(2);
expect(findWorkItemNoteReplying().exists()).toBe(true);
});
diff --git a/spec/frontend/work_items/components/notes/work_item_history_only_filter_note_spec.js b/spec/frontend/work_items/components/notes/work_item_history_only_filter_note_spec.js
new file mode 100644
index 00000000000..339efad0608
--- /dev/null
+++ b/spec/frontend/work_items/components/notes/work_item_history_only_filter_note_spec.js
@@ -0,0 +1,44 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import WorkItemHistoryOnlyFilterNote from '~/work_items/components/notes/work_item_history_only_filter_note.vue';
+import {
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+} from '~/work_items/constants';
+
+describe('Work Item History Filter note', () => {
+ let wrapper;
+
+ const findShowAllActivityButton = () => wrapper.findByTestId('show-all-activity');
+ const findShowCommentsButton = () => wrapper.findByTestId('show-comments-only');
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(WorkItemHistoryOnlyFilterNote, {
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('timelineContent renders a string containing instruction for switching feed type', () => {
+ expect(wrapper.text()).toContain(
+ "You're only seeing other activity in the feed. To add a comment, switch to one of the following options.",
+ );
+ });
+
+ it('emits `changeFilter` event with 0 parameter on clicking Show all activity button', () => {
+ findShowAllActivityButton().vm.$emit('click');
+
+ expect(wrapper.emitted('changeFilter')).toEqual([[WORK_ITEM_NOTES_FILTER_ALL_NOTES]]);
+ });
+
+ it('emits `changeFilter` event with 1 parameter on clicking Show comments only button', () => {
+ findShowCommentsButton().vm.$emit('click');
+
+ expect(wrapper.emitted('changeFilter')).toEqual([[WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS]]);
+ });
+});
diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
index d85cd46c1c3..99bf391e261 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
@@ -1,52 +1,227 @@
+import { GlDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import EmojiPicker from '~/emoji/components/picker.vue';
+import waitForPromises from 'helpers/wait_for_promises';
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
import WorkItemNoteActions from '~/work_items/components/notes/work_item_note_actions.vue';
+import addAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql';
+
+Vue.use(VueApollo);
describe('Work Item Note Actions', () => {
let wrapper;
+ const noteId = '1';
const findReplyButton = () => wrapper.findComponent(ReplyButton);
const findEditButton = () => wrapper.find('[data-testid="edit-work-item-note"]');
+ const findEmojiButton = () => wrapper.find('[data-testid="note-emoji-button"]');
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]');
+ const findCopyLinkButton = () => wrapper.find('[data-testid="copy-link-action"]');
+ const findAssignUnassignButton = () => wrapper.find('[data-testid="assign-note-action"]');
+ const findReportAbuseToAdminButton = () => wrapper.find('[data-testid="abuse-note-action"]');
+
+ const addEmojiMutationResolver = jest.fn().mockResolvedValue({
+ data: {
+ errors: [],
+ },
+ });
+
+ const EmojiPickerStub = {
+ props: EmojiPicker.props,
+ template: '<div></div>',
+ };
- const createComponent = ({ showReply = true, showEdit = true } = {}) => {
+ const createComponent = ({
+ showReply = true,
+ showEdit = true,
+ showAwardEmoji = true,
+ showAssignUnassign = false,
+ canReportAbuse = false,
+ } = {}) => {
wrapper = shallowMount(WorkItemNoteActions, {
propsData: {
showReply,
showEdit,
+ noteId,
+ showAwardEmoji,
+ showAssignUnassign,
+ canReportAbuse,
},
+ provide: {
+ glFeatures: {
+ workItemsMvc2: true,
+ },
+ },
+ stubs: {
+ EmojiPicker: EmojiPickerStub,
+ },
+ apolloProvider: createMockApollo([[addAwardEmojiMutation, addEmojiMutationResolver]]),
});
};
- describe('Default', () => {
- it('Should show the reply button by default', () => {
+ describe('reply button', () => {
+ it('is visible by default', () => {
createComponent();
+
expect(findReplyButton().exists()).toBe(true);
});
- });
- describe('When the reply button needs to be hidden', () => {
- it('Should show the reply button by default', () => {
+ it('is hidden when showReply false', () => {
createComponent({ showReply: false });
+
expect(findReplyButton().exists()).toBe(false);
});
});
- it('shows edit button when `showEdit` prop is true', () => {
- createComponent();
+ describe('edit button', () => {
+ it('is visible when `showEdit` prop is true', () => {
+ createComponent();
+
+ expect(findEditButton().exists()).toBe(true);
+ });
+
+ it('is hidden when `showEdit` prop is false', () => {
+ createComponent({ showEdit: false });
+
+ expect(findEditButton().exists()).toBe(false);
+ });
+
+ it('emits `startEditing` event when clicked', () => {
+ createComponent();
+ findEditButton().vm.$emit('click');
+
+ expect(wrapper.emitted('startEditing')).toEqual([[]]);
+ });
+ });
+
+ describe('emoji picker', () => {
+ it('is visible when `showAwardEmoji` prop is true', () => {
+ createComponent();
+
+ expect(findEmojiButton().exists()).toBe(true);
+ });
+
+ it('is hidden when `showAwardEmoji` prop is false', () => {
+ createComponent({ showAwardEmoji: false });
+
+ expect(findEmojiButton().exists()).toBe(false);
+ });
+
+ it('commits mutation on click', async () => {
+ const awardName = 'carrot';
+
+ createComponent();
+
+ findEmojiButton().vm.$emit('click', awardName);
+
+ await waitForPromises();
+
+ expect(findEmojiButton().emitted('errors')).toEqual(undefined);
+ expect(addEmojiMutationResolver).toHaveBeenCalledWith({
+ awardableId: noteId,
+ name: awardName,
+ });
+ });
+ });
+
+ describe('delete note', () => {
+ it('should display the `Delete comment` dropdown item if user has a permission to delete a note', () => {
+ createComponent({
+ showEdit: true,
+ });
+
+ expect(findDropdown().exists()).toBe(true);
+ expect(findDeleteNoteButton().exists()).toBe(true);
+ });
+
+ it('should not display the `Delete comment` dropdown item if user has no permission to delete a note', () => {
+ createComponent({
+ showEdit: false,
+ });
+
+ expect(findDropdown().exists()).toBe(true);
+ expect(findDeleteNoteButton().exists()).toBe(false);
+ });
+
+ it('should emit `deleteNote` event when delete note action is clicked', () => {
+ createComponent({
+ showEdit: true,
+ });
+
+ findDeleteNoteButton().vm.$emit('click');
- expect(findEditButton().exists()).toBe(true);
+ expect(wrapper.emitted('deleteNote')).toEqual([[]]);
+ });
});
- it('does not show edit button when `showEdit` prop is false', () => {
- createComponent({ showEdit: false });
+ describe('copy link', () => {
+ beforeEach(() => {
+ createComponent({});
+ });
+ it('should display Copy link always', () => {
+ expect(findCopyLinkButton().exists()).toBe(true);
+ });
- expect(findEditButton().exists()).toBe(false);
+ it('should emit `notifyCopyDone` event when copy link note action is clicked', () => {
+ findCopyLinkButton().vm.$emit('click');
+
+ expect(wrapper.emitted('notifyCopyDone')).toEqual([[]]);
+ });
});
- it('emits `startEditing` event when edit button is clicked', () => {
- createComponent();
- findEditButton().vm.$emit('click');
+ describe('assign/unassign to commenting user', () => {
+ it('should not display assign/unassign by default', () => {
+ createComponent();
+
+ expect(findAssignUnassignButton().exists()).toBe(false);
+ });
+
+ it('should display assign/unassign when the props is true', () => {
+ createComponent({
+ showAssignUnassign: true,
+ });
- expect(wrapper.emitted('startEditing')).toEqual([[]]);
+ expect(findAssignUnassignButton().exists()).toBe(true);
+ });
+
+ it('should emit `assignUser` event when assign note action is clicked', () => {
+ createComponent({
+ showAssignUnassign: true,
+ });
+
+ findAssignUnassignButton().vm.$emit('click');
+
+ expect(wrapper.emitted('assignUser')).toEqual([[]]);
+ });
+ });
+
+ describe('report abuse to admin', () => {
+ it('should not report abuse to admin by default', () => {
+ createComponent();
+
+ expect(findReportAbuseToAdminButton().exists()).toBe(false);
+ });
+
+ it('should display assign/unassign when the props is true', () => {
+ createComponent({
+ canReportAbuse: true,
+ });
+
+ expect(findReportAbuseToAdminButton().exists()).toBe(true);
+ });
+
+ it('should emit `reportAbuse` event when report abuse action is clicked', () => {
+ createComponent({
+ canReportAbuse: true,
+ });
+
+ findReportAbuseToAdminButton().vm.$emit('click');
+
+ expect(wrapper.emitted('reportAbuse')).toEqual([[]]);
+ });
});
});
diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js
index 9b87419cee7..f2cf5171cc1 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js
@@ -1,10 +1,9 @@
-import { GlAvatarLink, GlDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import mockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { updateDraft } from '~/lib/utils/autosave';
+import { updateDraft, clearDraft } from '~/lib/utils/autosave';
import EditedAt from '~/issues/show/components/edited.vue';
import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
@@ -13,7 +12,17 @@ import NoteHeader from '~/notes/components/note_header.vue';
import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue';
import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue';
import updateWorkItemNoteMutation from '~/work_items/graphql/notes/update_work_item_note.mutation.graphql';
-import { mockWorkItemCommentNote } from 'jest/work_items/mock_data';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+import {
+ mockAssignees,
+ mockWorkItemCommentNote,
+ updateWorkItemMutationResponse,
+ workItemByIidResponseFactory,
+ workItemQueryResponse,
+} from 'jest/work_items/mock_data';
+import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import { mockTracking } from 'helpers/tracking_helper';
Vue.use(VueApollo);
jest.mock('~/lib/utils/autosave');
@@ -22,6 +31,7 @@ describe('Work Item Note', () => {
let wrapper;
const updatedNoteText = '# Some title';
const updatedNoteBody = '<h1 data-sourcepos="1:1-1:12" dir="auto">Some title</h1>';
+ const mockWorkItemId = workItemQueryResponse.data.workItem.id;
const successHandler = jest.fn().mockResolvedValue({
data: {
@@ -35,32 +45,50 @@ describe('Work Item Note', () => {
},
},
});
+
+ const workItemResponseHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory());
+
+ const updateWorkItemMutationSuccessHandler = jest
+ .fn()
+ .mockResolvedValue(updateWorkItemMutationResponse);
+
const errorHandler = jest.fn().mockRejectedValue('Oops');
- const findAuthorAvatarLink = () => wrapper.findComponent(GlAvatarLink);
const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem);
const findNoteHeader = () => wrapper.findComponent(NoteHeader);
const findNoteBody = () => wrapper.findComponent(NoteBody);
const findNoteActions = () => wrapper.findComponent(NoteActions);
- const findDropdown = () => wrapper.findComponent(GlDropdown);
const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm);
const findEditedAt = () => wrapper.findComponent(EditedAt);
-
- const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]');
const findNoteWrapper = () => wrapper.find('[data-testid="note-wrapper"]');
const createComponent = ({
note = mockWorkItemCommentNote,
isFirstNote = false,
updateNoteMutationHandler = successHandler,
+ workItemId = mockWorkItemId,
+ updateWorkItemMutationHandler = updateWorkItemMutationSuccessHandler,
+ assignees = mockAssignees,
} = {}) => {
wrapper = shallowMount(WorkItemNote, {
+ provide: {
+ fullPath: 'test-project-path',
+ },
propsData: {
+ workItemId,
+ workItemIid: '1',
note,
isFirstNote,
workItemType: 'Task',
+ markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem',
+ autocompleteDataSources: {},
+ assignees,
},
- apolloProvider: mockApollo([[updateWorkItemNoteMutation, updateNoteMutationHandler]]),
+ apolloProvider: mockApollo([
+ [workItemByIidQuery, workItemResponseHandler],
+ [updateWorkItemNoteMutation, updateNoteMutationHandler],
+ [updateWorkItemMutation, updateWorkItemMutationHandler],
+ ]),
});
};
@@ -124,6 +152,7 @@ describe('Work Item Note', () => {
await waitForPromises();
expect(findCommentForm().exists()).toBe(false);
+ expect(clearDraft).toHaveBeenCalledWith(`${mockWorkItemCommentNote.id}-comment`);
});
describe('when mutation fails', () => {
@@ -178,8 +207,7 @@ describe('Work Item Note', () => {
},
});
- expect(findEditedAt().exists()).toBe(true);
- expect(findEditedAt().props()).toEqual({
+ expect(findEditedAt().props()).toMatchObject({
updatedAt: '2023-02-12T07:47:40Z',
updatedByName: 'Administrator',
updatedByPath: 'test-path',
@@ -198,10 +226,6 @@ describe('Work Item Note', () => {
expect(findNoteActions().exists()).toBe(true);
});
- it('should not have the Avatar link for main thread inside the timeline-entry', () => {
- expect(findAuthorAvatarLink().exists()).toBe(false);
- });
-
it('should have the reply button props', () => {
expect(findNoteActions().props('showReply')).toBe(true);
});
@@ -219,43 +243,80 @@ describe('Work Item Note', () => {
expect(findNoteActions().exists()).toBe(true);
});
- it('should have the Avatar link for comment threads', () => {
- expect(findAuthorAvatarLink().exists()).toBe(true);
- });
-
it('should not have the reply button props', () => {
expect(findNoteActions().props('showReply')).toBe(false);
});
});
- it('should display a dropdown if user has a permission to delete a note', () => {
- createComponent({
- note: {
- ...mockWorkItemCommentNote,
- userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true },
- },
+ describe('assign/unassign to commenting user', () => {
+ it('calls a mutation with correct variables', async () => {
+ createComponent({ assignees: mockAssignees });
+ await waitForPromises();
+ findNoteActions().vm.$emit('assignUser');
+
+ await waitForPromises();
+
+ expect(updateWorkItemMutationSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ id: mockWorkItemId,
+ assigneesWidget: {
+ assigneeIds: [mockAssignees[1].id],
+ },
+ },
+ });
});
- expect(findDropdown().exists()).toBe(true);
- });
+ it('emits an error and resets assignees if mutation was rejected', async () => {
+ createComponent({
+ updateWorkItemMutationHandler: errorHandler,
+ assignees: [mockAssignees[0]],
+ });
- it('should not display a dropdown if user has no permission to delete a note', () => {
- createComponent();
+ await waitForPromises();
- expect(findDropdown().exists()).toBe(false);
- });
+ expect(findNoteActions().props('isAuthorAnAssignee')).toEqual(true);
- it('should emit `deleteNote` event when delete note action is clicked', () => {
- createComponent({
- note: {
- ...mockWorkItemCommentNote,
- userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true },
- },
+ findNoteActions().vm.$emit('assignUser');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]);
+ expect(findNoteActions().props('isAuthorAnAssignee')).toEqual(true);
});
- findDeleteNoteButton().vm.$emit('click');
+ it('tracks the event', async () => {
+ createComponent();
+ await waitForPromises();
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ findNoteActions().vm.$emit('assignUser');
- expect(wrapper.emitted('deleteNote')).toEqual([[]]);
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'unassigned_user', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'work_item_note_actions',
+ property: 'type_Task',
+ });
+ });
+ });
+
+ describe('report abuse props', () => {
+ it.each`
+ currentUserId | canReportAbuse | sameAsAuthor
+ ${1} | ${false} | ${'same as'}
+ ${4} | ${true} | ${'not same as'}
+ `(
+ 'should be $canReportAbuse when the author is $sameAsAuthor as the author of the note',
+ ({ currentUserId, canReportAbuse }) => {
+ window.gon = {
+ current_user_id: currentUserId,
+ };
+ createComponent();
+
+ expect(findNoteActions().props('canReportAbuse')).toBe(canReportAbuse);
+ },
+ );
});
});
});
diff --git a/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js b/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js
new file mode 100644
index 00000000000..daf74f7a93b
--- /dev/null
+++ b/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js
@@ -0,0 +1,63 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import WorkItemNotesActivityHeader from '~/work_items/components/notes/work_item_notes_activity_header.vue';
+import { ASC } from '~/notes/constants';
+import {
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_NOTES_FILTER_ONLY_HISTORY,
+} from '~/work_items/constants';
+
+describe('Work Item Note Activity Header', () => {
+ let wrapper;
+
+ const findActivityLabelHeading = () => wrapper.find('h3');
+ const findActivityFilterDropdown = () => wrapper.findByTestId('work-item-filter');
+ const findActivitySortDropdown = () => wrapper.findByTestId('work-item-sort');
+
+ const createComponent = ({
+ disableActivityFilterSort = false,
+ sortOrder = ASC,
+ workItemType = 'Task',
+ discussionFilter = WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ } = {}) => {
+ wrapper = shallowMountExtended(WorkItemNotesActivityHeader, {
+ propsData: {
+ disableActivityFilterSort,
+ sortOrder,
+ workItemType,
+ discussionFilter,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('Should have the Activity label', () => {
+ expect(findActivityLabelHeading().text()).toBe(WorkItemNotesActivityHeader.i18n.activityLabel);
+ });
+
+ it('Should have Activity filtering dropdown', () => {
+ expect(findActivityFilterDropdown().exists()).toBe(true);
+ });
+
+ it('Should have Activity sorting dropdown', () => {
+ expect(findActivitySortDropdown().exists()).toBe(true);
+ });
+
+ describe('Activity Filter', () => {
+ it('emits `changeFilter` when filtering discussions', () => {
+ findActivityFilterDropdown().vm.$emit('changeFilter', WORK_ITEM_NOTES_FILTER_ONLY_HISTORY);
+
+ expect(wrapper.emitted('changeFilter')).toEqual([[WORK_ITEM_NOTES_FILTER_ONLY_HISTORY]]);
+ });
+ });
+
+ describe('Activity Sorting', () => {
+ it('emits `changeSort` when sorting discussions/activity', () => {
+ findActivitySortDropdown().vm.$emit('changeSort', ASC);
+
+ expect(wrapper.emitted('changeSort')).toEqual([[ASC]]);
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/widget_wrapper_spec.js b/spec/frontend/work_items/components/widget_wrapper_spec.js
index a87233300fc..87fbd1b3830 100644
--- a/spec/frontend/work_items/components/widget_wrapper_spec.js
+++ b/spec/frontend/work_items/components/widget_wrapper_spec.js
@@ -30,7 +30,7 @@ describe('WidgetWrapper component', () => {
expect(findWidgetBody().exists()).toBe(false);
});
- it('shows alert when list loading fails', () => {
+ it('shows an alert when list loading fails', () => {
const error = 'Some error';
createComponent({ error });
diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js
index 3c312fb4552..0045abe50d0 100644
--- a/spec/frontend/work_items/components/work_item_actions_spec.js
+++ b/spec/frontend/work_items/components/work_item_actions_spec.js
@@ -1,18 +1,46 @@
-import { GlDropdownDivider, GlModal } from '@gitlab/ui';
+import { GlDropdownDivider, GlModal, GlToggle } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import toast from '~/vue_shared/plugins/global_toast';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
+import {
+ TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ TEST_ID_DELETE_ACTION,
+ TEST_ID_PROMOTE_ACTION,
+} from '~/work_items/constants';
+import updateWorkItemNotificationsMutation from '~/work_items/graphql/update_work_item_notifications.mutation.graphql';
+import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
+import convertWorkItemMutation from '~/work_items/graphql/work_item_convert.mutation.graphql';
+import {
+ convertWorkItemMutationResponse,
+ projectWorkItemTypesQueryResponse,
+ convertWorkItemMutationErrorResponse,
+ workItemByIidResponseFactory,
+} from '../mock_data';
-const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action';
-const TEST_ID_DELETE_ACTION = 'delete-action';
+jest.mock('~/lib/utils/common_utils');
+jest.mock('~/vue_shared/plugins/global_toast');
describe('WorkItemActions component', () => {
+ Vue.use(VueApollo);
+
let wrapper;
let glModalDirective;
+ let mockApollo;
const findModal = () => wrapper.findComponent(GlModal);
const findConfidentialityToggleButton = () =>
wrapper.findByTestId(TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION);
+ const findNotificationsToggleButton = () =>
+ wrapper.findByTestId(TEST_ID_NOTIFICATIONS_TOGGLE_ACTION);
const findDeleteButton = () => wrapper.findByTestId(TEST_ID_DELETE_ACTION);
+ const findPromoteButton = () => wrapper.findByTestId(TEST_ID_PROMOTE_ACTION);
const findDropdownItems = () => wrapper.findAll('[data-testid="work-item-actions-dropdown"] > *');
const findDropdownItemsActual = () =>
findDropdownItems().wrappers.map((x) => {
@@ -25,22 +53,49 @@ describe('WorkItemActions component', () => {
text: x.text(),
};
});
+ const findNotificationsToggle = () => wrapper.findComponent(GlToggle);
+
+ const $toast = {
+ show: jest.fn(),
+ hide: jest.fn(),
+ };
+
+ const convertWorkItemMutationSuccessHandler = jest
+ .fn()
+ .mockResolvedValue(convertWorkItemMutationResponse);
+ const convertWorkItemMutationErrorHandler = jest
+ .fn()
+ .mockResolvedValue(convertWorkItemMutationErrorResponse);
+ const typesQuerySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
const createComponent = ({
canUpdate = true,
canDelete = true,
isConfidential = false,
+ subscribed = false,
isParentConfidential = false,
+ notificationsMock = [updateWorkItemNotificationsMutation, jest.fn()],
+ convertWorkItemMutationHandler = convertWorkItemMutationSuccessHandler,
+ workItemType = 'Task',
} = {}) => {
+ const handlers = [notificationsMock];
glModalDirective = jest.fn();
+ mockApollo = createMockApollo([
+ ...handlers,
+ [convertWorkItemMutation, convertWorkItemMutationHandler],
+ [projectWorkItemTypesQuery, typesQuerySuccessHandler],
+ ]);
wrapper = shallowMountExtended(WorkItemActions, {
+ isLoggedIn: isLoggedIn(),
+ apolloProvider: mockApollo,
propsData: {
- workItemId: '123',
+ workItemId: 'gid://gitlab/WorkItem/1',
canUpdate,
canDelete,
isConfidential,
+ subscribed,
isParentConfidential,
- workItemType: 'Task',
+ workItemType,
},
directives: {
glModal: {
@@ -49,11 +104,18 @@ describe('WorkItemActions component', () => {
},
},
},
+ provide: {
+ fullPath: 'gitlab-org/gitlab',
+ glFeatures: { workItemsMvc2: true },
+ },
+ mocks: {
+ $toast,
+ },
});
};
- afterEach(() => {
- wrapper.destroy();
+ beforeEach(() => {
+ isLoggedIn.mockReturnValue(true);
});
it('renders modal', () => {
@@ -68,6 +130,13 @@ describe('WorkItemActions component', () => {
expect(findDropdownItemsActual()).toEqual([
{
+ testId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ text: '',
+ },
+ {
+ divider: true,
+ },
+ {
testId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
text: 'Turn on confidentiality',
},
@@ -137,7 +206,157 @@ describe('WorkItemActions component', () => {
});
expect(findDeleteButton().exists()).toBe(false);
- expect(wrapper.findComponent(GlDropdownDivider).exists()).toBe(false);
+ });
+ });
+
+ describe('notifications action', () => {
+ const errorMessage = 'Failed to subscribe';
+ const id = 'gid://gitlab/WorkItem/1';
+ const notificationToggledOffMessage = 'Notifications turned off.';
+ const notificationToggledOnMessage = 'Notifications turned on.';
+
+ const inputVariablesOff = {
+ id,
+ notificationsWidget: {
+ subscribed: false,
+ },
+ };
+
+ const inputVariablesOn = {
+ id,
+ notificationsWidget: {
+ subscribed: true,
+ },
+ };
+
+ const notificationsOffExpectedResponse = workItemByIidResponseFactory({
+ subscribed: false,
+ });
+
+ const toggleNotificationsOffHandler = jest.fn().mockResolvedValue({
+ data: {
+ workItemUpdate: {
+ workItem: notificationsOffExpectedResponse.data.workspace.workItems.nodes[0],
+ errors: [],
+ },
+ },
+ });
+
+ const notificationsOnExpectedResponse = workItemByIidResponseFactory({
+ subscribed: true,
+ });
+
+ const toggleNotificationsOnHandler = jest.fn().mockResolvedValue({
+ data: {
+ workItemUpdate: {
+ workItem: notificationsOnExpectedResponse.data.workspace.workItems.nodes[0],
+ errors: [],
+ },
+ },
+ });
+
+ const toggleNotificationsFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage));
+
+ const notificationsOffMock = [
+ updateWorkItemNotificationsMutation,
+ toggleNotificationsOffHandler,
+ ];
+
+ const notificationsOnMock = [updateWorkItemNotificationsMutation, toggleNotificationsOnHandler];
+
+ const notificationsFailureMock = [
+ updateWorkItemNotificationsMutation,
+ toggleNotificationsFailureHandler,
+ ];
+
+ beforeEach(() => {
+ createComponent();
+ isLoggedIn.mockReturnValue(true);
+ });
+
+ it('renders toggle button', () => {
+ expect(findNotificationsToggleButton().exists()).toBe(true);
+ });
+
+ it.each`
+ scenario | subscribedToNotifications | notificationsMock | inputVariables | toastMessage
+ ${'turned off'} | ${false} | ${notificationsOffMock} | ${inputVariablesOff} | ${notificationToggledOffMessage}
+ ${'turned on'} | ${true} | ${notificationsOnMock} | ${inputVariablesOn} | ${notificationToggledOnMessage}
+ `(
+ 'calls mutation and displays toast when notification toggle is $scenario',
+ async ({ subscribedToNotifications, notificationsMock, inputVariables, toastMessage }) => {
+ createComponent({ notificationsMock });
+
+ await waitForPromises();
+
+ findNotificationsToggle().vm.$emit('change', subscribedToNotifications);
+
+ await waitForPromises();
+
+ expect(notificationsMock[1]).toHaveBeenCalledWith({
+ input: inputVariables,
+ });
+ expect(toast).toHaveBeenCalledWith(toastMessage);
+ },
+ );
+
+ it('emits error when the update notification mutation fails', async () => {
+ createComponent({ notificationsMock: notificationsFailureMock });
+
+ await waitForPromises();
+
+ findNotificationsToggle().vm.$emit('change', false);
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[errorMessage]]);
+ });
+ });
+
+ describe('promote action', () => {
+ it.each`
+ workItemType | show
+ ${'Task'} | ${false}
+ ${'Objective'} | ${false}
+ `('does not show promote button for $workItemType', ({ workItemType, show }) => {
+ createComponent({ workItemType });
+
+ expect(findPromoteButton().exists()).toBe(show);
+ });
+
+ it('promote key result to objective', async () => {
+ createComponent({ workItemType: 'Key Result' });
+
+ // wait for work item types
+ await waitForPromises();
+
+ expect(findPromoteButton().exists()).toBe(true);
+ findPromoteButton().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(convertWorkItemMutationSuccessHandler).toHaveBeenCalled();
+ expect($toast.show).toHaveBeenCalledWith('Promoted to objective.');
+ });
+
+ it('emits error when promote mutation fails', async () => {
+ createComponent({
+ workItemType: 'Key Result',
+ convertWorkItemMutationHandler: convertWorkItemMutationErrorHandler,
+ });
+
+ // wait for work item types
+ await waitForPromises();
+
+ expect(findPromoteButton().exists()).toBe(true);
+ findPromoteButton().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(convertWorkItemMutationErrorHandler).toHaveBeenCalled();
+ expect(wrapper.emitted('error')).toEqual([
+ ['Something went wrong while promoting the key result. Please try again.'],
+ ]);
});
});
});
diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js
index e85f62b881d..25b0b74c217 100644
--- a/spec/frontend/work_items/components/work_item_assignees_spec.js
+++ b/spec/frontend/work_items/components/work_item_assignees_spec.js
@@ -8,9 +8,7 @@ import { mockTracking } from 'helpers/tracking_helper';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
-import { config } from '~/graphql_shared/issuable_client';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
import {
@@ -22,7 +20,6 @@ import {
import {
projectMembersResponseWithCurrentUser,
mockAssignees,
- workItemQueryResponse,
currentUserResponse,
currentUserNullResponse,
projectMembersResponseWithoutCurrentUser,
@@ -78,27 +75,16 @@ describe('WorkItemAssignees component', () => {
canInviteMembers = false,
canUpdate = true,
} = {}) => {
- const apolloProvider = createMockApollo(
- [
- [userSearchQuery, searchQueryHandler],
- [currentUserQuery, currentUserQueryHandler],
- [updateWorkItemMutation, updateWorkItemMutationHandler],
- ],
- {},
- {
- typePolicies: config.cacheConfig.typePolicies,
- },
- );
-
- apolloProvider.clients.defaultClient.writeQuery({
- query: workItemQuery,
- variables: {
- id: workItemId,
- },
- data: workItemQueryResponse.data,
- });
+ const apolloProvider = createMockApollo([
+ [userSearchQuery, searchQueryHandler],
+ [currentUserQuery, currentUserQueryHandler],
+ [updateWorkItemMutation, updateWorkItemMutationHandler],
+ ]);
wrapper = mountExtended(WorkItemAssignees, {
+ provide: {
+ fullPath: 'test-project-path',
+ },
propsData: {
assignees,
workItemId,
@@ -106,17 +92,12 @@ describe('WorkItemAssignees component', () => {
workItemType: TASK_TYPE_NAME,
canUpdate,
canInviteMembers,
- fullPath: 'test-project-path',
},
attachTo: document.body,
apolloProvider,
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('passes the correct data-user-id attribute', () => {
createComponent();
@@ -322,7 +303,7 @@ describe('WorkItemAssignees component', () => {
return waitForPromises();
});
- it('renders `Assign myself` button', async () => {
+ it('renders `Assign myself` button', () => {
findTokenSelector().trigger('mouseover');
expect(findAssignSelfButton().exists()).toBe(true);
});
diff --git a/spec/frontend/work_items/components/work_item_award_emoji_spec.js b/spec/frontend/work_items/components/work_item_award_emoji_spec.js
new file mode 100644
index 00000000000..f87c0e3f357
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_award_emoji_spec.js
@@ -0,0 +1,170 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import AwardList from '~/vue_shared/components/awards_list.vue';
+import WorkItemAwardEmoji from '~/work_items/components/work_item_award_emoji.vue';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import {
+ EMOJI_ACTION_REMOVE,
+ EMOJI_ACTION_ADD,
+ EMOJI_THUMBSUP,
+ EMOJI_THUMBSDOWN,
+} from '~/work_items/constants';
+
+import {
+ workItemByIidResponseFactory,
+ mockAwardsWidget,
+ updateWorkItemMutationResponseFactory,
+ mockAwardEmojiThumbsUp,
+} from '../mock_data';
+
+jest.mock('~/lib/utils/common_utils');
+Vue.use(VueApollo);
+
+describe('WorkItemAwardEmoji component', () => {
+ let wrapper;
+
+ const errorMessage = 'Failed to update the award';
+
+ const workItemQueryResponse = workItemByIidResponseFactory();
+ const workItemSuccessHandler = jest
+ .fn()
+ .mockResolvedValue(updateWorkItemMutationResponseFactory());
+ const awardEmojiAddSuccessHandler = jest.fn().mockResolvedValue(
+ updateWorkItemMutationResponseFactory({
+ awardEmoji: {
+ ...mockAwardsWidget,
+ nodes: [mockAwardEmojiThumbsUp],
+ },
+ }),
+ );
+ const awardEmojiRemoveSuccessHandler = jest.fn().mockResolvedValue(
+ updateWorkItemMutationResponseFactory({
+ awardEmoji: {
+ ...mockAwardsWidget,
+ nodes: [],
+ },
+ }),
+ );
+ const workItemUpdateFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage));
+ const mockWorkItem = workItemQueryResponse.data.workspace.workItems.nodes[0];
+
+ const createComponent = ({
+ mockWorkItemUpdateMutationHandler = [updateWorkItemMutation, workItemSuccessHandler],
+ workItem = mockWorkItem,
+ awardEmoji = { ...mockAwardsWidget, nodes: [] },
+ } = {}) => {
+ wrapper = shallowMount(WorkItemAwardEmoji, {
+ isLoggedIn: isLoggedIn(),
+ apolloProvider: createMockApollo([mockWorkItemUpdateMutationHandler]),
+ propsData: {
+ workItem,
+ awardEmoji,
+ },
+ });
+ };
+
+ const findAwardsList = () => wrapper.findComponent(AwardList);
+
+ beforeEach(() => {
+ isLoggedIn.mockReturnValue(true);
+ window.gon = {
+ current_user_id: 1,
+ };
+
+ createComponent();
+ });
+
+ it('renders the award-list component with default props', () => {
+ expect(findAwardsList().exists()).toBe(true);
+ expect(findAwardsList().props()).toEqual({
+ boundary: '',
+ canAwardEmoji: true,
+ currentUserId: 1,
+ defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN],
+ selectedClass: 'selected',
+ awards: [],
+ });
+ });
+
+ it('renders awards-list component with awards present', () => {
+ createComponent({ awardEmoji: mockAwardsWidget });
+
+ expect(findAwardsList().props('awards')).toEqual([
+ {
+ id: 1,
+ name: EMOJI_THUMBSUP,
+ user: {
+ id: 5,
+ },
+ },
+ {
+ id: 2,
+ name: EMOJI_THUMBSDOWN,
+ user: {
+ id: 5,
+ },
+ },
+ ]);
+ });
+
+ it.each`
+ expectedAssertion | action | successHandler | mockAwardEmojiNodes
+ ${'added'} | ${EMOJI_ACTION_ADD} | ${awardEmojiAddSuccessHandler} | ${[]}
+ ${'removed'} | ${EMOJI_ACTION_REMOVE} | ${awardEmojiRemoveSuccessHandler} | ${[mockAwardEmojiThumbsUp]}
+ `(
+ 'calls mutation when an award emoji is $expectedAssertion',
+ async ({ action, successHandler, mockAwardEmojiNodes }) => {
+ createComponent({
+ mockWorkItemUpdateMutationHandler: [updateWorkItemMutation, successHandler],
+ awardEmoji: {
+ ...mockAwardsWidget,
+ nodes: mockAwardEmojiNodes,
+ },
+ });
+
+ findAwardsList().vm.$emit('award', EMOJI_THUMBSUP);
+
+ await waitForPromises();
+
+ expect(successHandler).toHaveBeenCalledWith({
+ input: {
+ id: mockWorkItem.id,
+ awardEmojiWidget: {
+ action,
+ name: EMOJI_THUMBSUP,
+ },
+ },
+ });
+ },
+ );
+
+ it('emits error when the update mutation fails', async () => {
+ createComponent({
+ mockWorkItemUpdateMutationHandler: [updateWorkItemMutation, workItemUpdateFailureHandler],
+ });
+
+ findAwardsList().vm.$emit('award', EMOJI_THUMBSUP);
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[errorMessage]]);
+ });
+
+ describe('when user is not logged in', () => {
+ beforeEach(() => {
+ isLoggedIn.mockReturnValue(false);
+
+ createComponent();
+ });
+
+ it('renders the component with required props and canAwardEmoji false', () => {
+ expect(findAwardsList().props('canAwardEmoji')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_created_updated_spec.js b/spec/frontend/work_items/components/work_item_created_updated_spec.js
index fe31c01df36..68ede7d5bc0 100644
--- a/spec/frontend/work_items/components/work_item_created_updated_spec.js
+++ b/spec/frontend/work_items/components/work_item_created_updated_spec.js
@@ -5,14 +5,12 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemCreatedUpdated from '~/work_items/components/work_item_created_updated.vue';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
-import { workItemResponseFactory, mockAssignees } from '../mock_data';
+import { workItemByIidResponseFactory, mockAssignees } from '../mock_data';
describe('WorkItemCreatedUpdated component', () => {
let wrapper;
let successHandler;
- let successByIidHandler;
Vue.use(VueApollo);
@@ -21,39 +19,20 @@ describe('WorkItemCreatedUpdated component', () => {
const findCreatedAtText = () => findCreatedAt().text().replace(/\s+/g, ' ');
- const createComponent = async ({
- workItemId = 'gid://gitlab/WorkItem/1',
- workItemIid = '1',
- fetchByIid = false,
- author = null,
- updatedAt,
- } = {}) => {
- const workItemQueryResponse = workItemResponseFactory({
+ const createComponent = async ({ workItemIid = '1', author = null, updatedAt } = {}) => {
+ const workItemQueryResponse = workItemByIidResponseFactory({
author,
updatedAt,
});
- const byIidResponse = {
- data: {
- workspace: {
- id: 'gid://gitlab/Project/1',
- workItems: {
- nodes: [workItemQueryResponse.data.workItem],
- },
- },
- },
- };
successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
- successByIidHandler = jest.fn().mockResolvedValue(byIidResponse);
-
- const handlers = [
- [workItemQuery, successHandler],
- [workItemByIidQuery, successByIidHandler],
- ];
wrapper = shallowMount(WorkItemCreatedUpdated, {
- apolloProvider: createMockApollo(handlers),
- propsData: { workItemId, workItemIid, fetchByIid, fullPath: '/some/project' },
+ apolloProvider: createMockApollo([[workItemByIidQuery, successHandler]]),
+ provide: {
+ fullPath: '/some/project',
+ },
+ propsData: { workItemIid },
stubs: {
GlAvatarLink,
GlSprintf,
@@ -63,42 +42,34 @@ describe('WorkItemCreatedUpdated component', () => {
await waitForPromises();
};
- describe.each([true, false])('fetchByIid is %s', (fetchByIid) => {
- describe('work item id and iid undefined', () => {
- beforeEach(async () => {
- await createComponent({ workItemId: null, workItemIid: null, fetchByIid });
- });
+ it('skips the work item query when workItemIid is not defined', async () => {
+ await createComponent({ workItemIid: null });
- it('skips the work item query', () => {
- expect(successHandler).not.toHaveBeenCalled();
- expect(successByIidHandler).not.toHaveBeenCalled();
- });
- });
-
- it('shows author name and link', async () => {
- const author = mockAssignees[0];
+ expect(successHandler).not.toHaveBeenCalled();
+ });
- await createComponent({ fetchByIid, author });
+ it('shows author name and link', async () => {
+ const author = mockAssignees[0];
+ await createComponent({ author });
- expect(findCreatedAtText()).toEqual(`Created by ${author.name}`);
- });
+ expect(findCreatedAtText()).toBe(`Created by ${author.name}`);
+ });
- it('shows created time when author is null', async () => {
- await createComponent({ fetchByIid, author: null });
+ it('shows created time when author is null', async () => {
+ await createComponent({ author: null });
- expect(findCreatedAtText()).toEqual('Created');
- });
+ expect(findCreatedAtText()).toBe('Created');
+ });
- it('shows updated time', async () => {
- await createComponent({ fetchByIid });
+ it('shows updated time', async () => {
+ await createComponent();
- expect(findUpdatedAt().exists()).toBe(true);
- });
+ expect(findUpdatedAt().exists()).toBe(true);
+ });
- it('does not show updated time for new work items', async () => {
- await createComponent({ fetchByIid, updatedAt: null });
+ it('does not show updated time for new work items', async () => {
+ await createComponent({ updatedAt: null });
- expect(findUpdatedAt().exists()).toBe(false);
- });
+ expect(findUpdatedAt().exists()).toBe(false);
});
});
diff --git a/spec/frontend/work_items/components/work_item_description_rendered_spec.js b/spec/frontend/work_items/components/work_item_description_rendered_spec.js
index 0ab2546440b..4f1d49ee2e5 100644
--- a/spec/frontend/work_items/components/work_item_description_rendered_spec.js
+++ b/spec/frontend/work_items/components/work_item_description_rendered_spec.js
@@ -29,10 +29,6 @@ describe('WorkItemDescription', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders gfm', async () => {
createComponent();
diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js
index a12ec23c15a..62cbb1bacb6 100644
--- a/spec/frontend/work_items/components/work_item_description_spec.js
+++ b/spec/frontend/work_items/components/work_item_description_spec.js
@@ -12,17 +12,15 @@ import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemDescriptionSubscription from '~/work_items/graphql/work_item_description.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils';
import {
updateWorkItemMutationResponse,
+ workItemByIidResponseFactory,
workItemDescriptionSubscriptionResponse,
- workItemResponseFactory,
workItemQueryResponse,
- projectWorkItemResponse,
} from '../mock_data';
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
@@ -37,7 +35,6 @@ describe('WorkItemDescription', () => {
const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
const subscriptionHandler = jest.fn().mockResolvedValue(workItemDescriptionSubscriptionResponse);
- const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
let workItemResponseHandler;
let workItemsMvc;
@@ -59,28 +56,25 @@ describe('WorkItemDescription', () => {
const createComponent = async ({
mutationHandler = mutationSuccessHandler,
canUpdate = true,
- workItemResponse = workItemResponseFactory({ canUpdate }),
+ workItemResponse = workItemByIidResponseFactory({ canUpdate }),
isEditing = false,
- queryVariables = { id: workItemId },
- fetchByIid = false,
+ workItemIid = '1',
} = {}) => {
workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse);
const { id } = workItemQueryResponse.data.workItem;
wrapper = shallowMount(WorkItemDescription, {
apolloProvider: createMockApollo([
- [workItemQuery, workItemResponseHandler],
+ [workItemByIidQuery, workItemResponseHandler],
[updateWorkItemMutation, mutationHandler],
[workItemDescriptionSubscription, subscriptionHandler],
- [workItemByIidQuery, workItemByIidResponseHandler],
]),
propsData: {
workItemId: id,
- fullPath: 'test-project-path',
- queryVariables,
- fetchByIid,
+ workItemIid,
},
provide: {
+ fullPath: 'test-project-path',
glFeatures: {
workItemsMvc,
},
@@ -99,10 +93,6 @@ describe('WorkItemDescription', () => {
}
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('editing description with workItemsMvc FF enabled', () => {
beforeEach(() => {
workItemsMvc = true;
@@ -117,10 +107,10 @@ describe('WorkItemDescription', () => {
await createComponent({ isEditing: true });
expect(findMarkdownEditor().props()).toMatchObject({
- autocompleteDataSources: autocompleteDataSources(fullPath, iid),
supportsQuickActions: true,
renderMarkdownPath: markdownPreviewPath(fullPath, iid),
quickActionsDocsPath: wrapper.vm.$options.quickActionsDocsPath,
+ autocompleteDataSources: autocompleteDataSources(fullPath, iid),
});
});
});
@@ -156,9 +146,7 @@ describe('WorkItemDescription', () => {
});
it('has a subscription', async () => {
- createComponent();
-
- await waitForPromises();
+ await createComponent();
expect(subscriptionHandler).toHaveBeenCalledWith({
issuableId: workItemQueryResponse.data.workItem.id,
@@ -174,13 +162,10 @@ describe('WorkItemDescription', () => {
};
await createComponent({
- workItemResponse: workItemResponseFactory({
- lastEditedAt,
- lastEditedBy,
- }),
+ workItemResponse: workItemByIidResponseFactory({ lastEditedAt, lastEditedBy }),
});
- expect(findEditedAt().props()).toEqual({
+ expect(findEditedAt().props()).toMatchObject({
updatedAt: lastEditedAt,
updatedByName: lastEditedBy.name,
updatedByPath: lastEditedBy.webPath,
@@ -313,27 +298,10 @@ describe('WorkItemDescription', () => {
});
});
- it('calls the global ID work item query when `fetchByIid` prop is false', async () => {
- createComponent({ fetchByIid: false });
- await waitForPromises();
+ it('calls the work item query', async () => {
+ await createComponent();
expect(workItemResponseHandler).toHaveBeenCalled();
- expect(workItemByIidResponseHandler).not.toHaveBeenCalled();
- });
-
- it('calls the IID work item query when when `fetchByIid` prop is true', async () => {
- createComponent({ fetchByIid: true });
- await waitForPromises();
-
- expect(workItemResponseHandler).not.toHaveBeenCalled();
- expect(workItemByIidResponseHandler).toHaveBeenCalled();
- });
-
- it('skips calling the handlers when missing the needed queryVariables', async () => {
- createComponent({ queryVariables: {}, fetchByIid: false });
- await waitForPromises();
-
- expect(workItemResponseHandler).not.toHaveBeenCalled();
});
},
);
diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
index 938cf6e6f51..e305cc310bd 100644
--- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
@@ -6,21 +6,16 @@ import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
-import deleteWorkItemFromTaskMutation from '~/work_items/graphql/delete_task_from_work_item.mutation.graphql';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
-import {
- deleteWorkItemFromTaskMutationErrorResponse,
- deleteWorkItemFromTaskMutationResponse,
- deleteWorkItemMutationErrorResponse,
- deleteWorkItemResponse,
-} from '../mock_data';
+import { deleteWorkItemMutationErrorResponse, deleteWorkItemResponse } from '../mock_data';
describe('WorkItemDetailModal component', () => {
let wrapper;
Vue.use(VueApollo);
+ const workItemId = 'gid://gitlab/WorkItem/1';
const hideModal = jest.fn();
const GlModal = {
template: `
@@ -33,37 +28,23 @@ describe('WorkItemDetailModal component', () => {
},
};
- const defaultPropsData = {
- issueGid: 'gid://gitlab/WorkItem/1',
- workItemId: 'gid://gitlab/WorkItem/2',
- };
-
const findModal = () => wrapper.findComponent(GlModal);
const findAlert = () => wrapper.findComponent(GlAlert);
const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail);
const createComponent = ({
- lockVersion,
- lineNumberStart,
- lineNumberEnd,
error = false,
- deleteWorkItemFromTaskMutationHandler = jest
- .fn()
- .mockResolvedValue(deleteWorkItemFromTaskMutationResponse),
deleteWorkItemMutationHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse),
} = {}) => {
const apolloProvider = createMockApollo([
- [deleteWorkItemFromTaskMutation, deleteWorkItemFromTaskMutationHandler],
[deleteWorkItemMutation, deleteWorkItemMutationHandler],
]);
wrapper = shallowMount(WorkItemDetailModal, {
apolloProvider,
propsData: {
- ...defaultPropsData,
- lockVersion,
- lineNumberStart,
- lineNumberEnd,
+ workItemId,
+ workItemIid: '1',
},
data() {
return {
@@ -82,18 +63,14 @@ describe('WorkItemDetailModal component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders WorkItemDetail', () => {
createComponent();
expect(findWorkItemDetail().props()).toEqual({
isModal: true,
- workItemId: defaultPropsData.workItemId,
- workItemParentId: defaultPropsData.issueGid,
- workItemIid: null,
+ workItemId,
+ workItemIid: '1',
+ workItemParentId: null,
});
});
@@ -147,85 +124,31 @@ describe('WorkItemDetailModal component', () => {
});
describe('delete work item', () => {
- describe('when there is task data', () => {
- it('emits workItemDeleted and closes modal', async () => {
- const mutationMock = jest.fn().mockResolvedValue(deleteWorkItemFromTaskMutationResponse);
- createComponent({
- lockVersion: 1,
- lineNumberStart: '3',
- lineNumberEnd: '3',
- deleteWorkItemFromTaskMutationHandler: mutationMock,
- });
- const newDesc = 'updated work item desc';
-
- findWorkItemDetail().vm.$emit('deleteWorkItem');
- await waitForPromises();
-
- expect(wrapper.emitted('workItemDeleted')).toEqual([[newDesc]]);
- expect(hideModal).toHaveBeenCalled();
- expect(mutationMock).toHaveBeenCalledWith({
- input: {
- id: defaultPropsData.issueGid,
- lockVersion: 1,
- taskData: { id: defaultPropsData.workItemId, lineNumberEnd: 3, lineNumberStart: 3 },
- },
- });
- });
-
- it.each`
- errorType | mutationMock | errorMessage
- ${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(deleteWorkItemFromTaskMutationErrorResponse)} | ${'Error'}
- ${'a network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL networkError'))} | ${'GraphQL networkError'}
- `(
- 'shows an error message when there is $errorType',
- async ({ mutationMock, errorMessage }) => {
- createComponent({
- lockVersion: 1,
- lineNumberStart: '3',
- lineNumberEnd: '3',
- deleteWorkItemFromTaskMutationHandler: mutationMock,
- });
-
- findWorkItemDetail().vm.$emit('deleteWorkItem');
- await waitForPromises();
-
- expect(wrapper.emitted('workItemDeleted')).toBeUndefined();
- expect(hideModal).not.toHaveBeenCalled();
- expect(findAlert().text()).toBe(errorMessage);
- },
- );
+ it('emits workItemDeleted and closes modal', async () => {
+ const mutationMock = jest.fn().mockResolvedValue(deleteWorkItemResponse);
+ createComponent({ deleteWorkItemMutationHandler: mutationMock });
+
+ findWorkItemDetail().vm.$emit('deleteWorkItem');
+ await waitForPromises();
+
+ expect(wrapper.emitted('workItemDeleted')).toEqual([[workItemId]]);
+ expect(hideModal).toHaveBeenCalled();
+ expect(mutationMock).toHaveBeenCalledWith({ input: { id: workItemId } });
});
- describe('when there is no task data', () => {
- it('emits workItemDeleted and closes modal', async () => {
- const mutationMock = jest.fn().mockResolvedValue(deleteWorkItemResponse);
- createComponent({ deleteWorkItemMutationHandler: mutationMock });
-
- findWorkItemDetail().vm.$emit('deleteWorkItem');
- await waitForPromises();
-
- expect(wrapper.emitted('workItemDeleted')).toEqual([[defaultPropsData.workItemId]]);
- expect(hideModal).toHaveBeenCalled();
- expect(mutationMock).toHaveBeenCalledWith({ input: { id: defaultPropsData.workItemId } });
- });
-
- it.each`
- errorType | mutationMock | errorMessage
- ${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(deleteWorkItemMutationErrorResponse)} | ${'Error'}
- ${'a network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL networkError'))} | ${'GraphQL networkError'}
- `(
- 'shows an error message when there is $errorType',
- async ({ mutationMock, errorMessage }) => {
- createComponent({ deleteWorkItemMutationHandler: mutationMock });
-
- findWorkItemDetail().vm.$emit('deleteWorkItem');
- await waitForPromises();
-
- expect(wrapper.emitted('workItemDeleted')).toBeUndefined();
- expect(hideModal).not.toHaveBeenCalled();
- expect(findAlert().text()).toBe(errorMessage);
- },
- );
+ it.each`
+ errorType | mutationMock | errorMessage
+ ${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(deleteWorkItemMutationErrorResponse)} | ${'Error'}
+ ${'a network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL networkError'))} | ${'GraphQL networkError'}
+ `('shows an error message when there is $errorType', async ({ mutationMock, errorMessage }) => {
+ createComponent({ deleteWorkItemMutationHandler: mutationMock });
+
+ findWorkItemDetail().vm.$emit('deleteWorkItem');
+ await waitForPromises();
+
+ expect(wrapper.emitted('workItemDeleted')).toBeUndefined();
+ expect(hideModal).not.toHaveBeenCalled();
+ expect(findAlert().text()).toBe(errorMessage);
});
});
});
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
index 64a7502671e..557ae07969e 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -9,6 +9,7 @@ import {
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import setWindowLocation from 'helpers/set_window_location_helper';
@@ -26,39 +27,42 @@ import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue';
import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+import WorkItemTodos from '~/work_items/components/work_item_todos.vue';
import { i18n } from '~/work_items/constants';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
-import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql';
+import workItemDatesSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql';
import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
+
import {
mockParent,
workItemDatesSubscriptionResponse,
- workItemResponseFactory,
+ workItemByIidResponseFactory,
workItemTitleSubscriptionResponse,
workItemAssigneesSubscriptionResponse,
workItemMilestoneSubscriptionResponse,
- projectWorkItemResponse,
objectiveType,
+ mockWorkItemCommentNote,
} from '../mock_data';
+jest.mock('~/lib/utils/common_utils');
+
describe('WorkItemDetail component', () => {
let wrapper;
Vue.use(VueApollo);
- const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true });
- const workItemQueryResponseWithoutParent = workItemResponseFactory({
+ const workItemQueryResponse = workItemByIidResponseFactory({ canUpdate: true, canDelete: true });
+ const workItemQueryResponseWithoutParent = workItemByIidResponseFactory({
parent: null,
canUpdate: true,
canDelete: true,
});
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
- const successByIidHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse);
const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const milestoneSubscriptionHandler = jest
@@ -68,6 +72,7 @@ describe('WorkItemDetail component', () => {
.fn()
.mockResolvedValue(workItemAssigneesSubscriptionResponse);
const showModalHandler = jest.fn();
+ const { id } = workItemQueryResponse.data.workspace.workItems.nodes[0];
const findAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
@@ -89,32 +94,32 @@ describe('WorkItemDetail component', () => {
const findHierarchyTree = () => wrapper.findComponent(WorkItemTree);
const findNotesWidget = () => wrapper.findComponent(WorkItemNotes);
const findModal = () => wrapper.findComponent(WorkItemDetailModal);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
+ const findWorkItemTodos = () => wrapper.findComponent(WorkItemTodos);
const createComponent = ({
isModal = false,
updateInProgress = false,
- workItemId = workItemQueryResponse.data.workItem.id,
+ workItemId = id,
workItemIid = '1',
handler = successHandler,
subscriptionHandler = titleSubscriptionHandler,
confidentialityMock = [updateWorkItemMutation, jest.fn()],
error = undefined,
- workItemsMvcEnabled = false,
workItemsMvc2Enabled = false,
- fetchByIid = false,
} = {}) => {
const handlers = [
- [workItemQuery, handler],
+ [workItemByIidQuery, handler],
[workItemTitleSubscription, subscriptionHandler],
[workItemDatesSubscription, datesSubscriptionHandler],
[workItemAssigneesSubscription, assigneesSubscriptionHandler],
[workItemMilestoneSubscription, milestoneSubscriptionHandler],
- [workItemByIidQuery, successByIidHandler],
confidentialityMock,
];
wrapper = shallowMount(WorkItemDetail, {
apolloProvider: createMockApollo(handlers),
+ isLoggedIn: isLoggedIn(),
propsData: { isModal, workItemId, workItemIid },
data() {
return {
@@ -124,9 +129,7 @@ describe('WorkItemDetail component', () => {
},
provide: {
glFeatures: {
- workItemsMvc: workItemsMvcEnabled,
workItemsMvc2: workItemsMvc2Enabled,
- useIidInWorkItemsPath: fetchByIid,
},
hasIssueWeightsFeature: true,
hasIterationsFeature: true,
@@ -134,6 +137,7 @@ describe('WorkItemDetail component', () => {
hasIssuableHealthStatusFeature: true,
projectNamespace: 'namespace',
fullPath: 'group/project',
+ reportAbusePath: '/report/abuse/path',
},
stubs: {
WorkItemWeight: true,
@@ -148,8 +152,11 @@ describe('WorkItemDetail component', () => {
});
};
+ beforeEach(() => {
+ isLoggedIn.mockReturnValue(true);
+ });
+
afterEach(() => {
- wrapper.destroy();
setWindowLocation('');
});
@@ -190,6 +197,10 @@ describe('WorkItemDetail component', () => {
it('updates the document title', () => {
expect(document.title).toEqual('Updated title · Task · test-project-path');
});
+
+ it('renders todos widget if logged in', () => {
+ expect(findWorkItemTodos().exists()).toBe(true);
+ });
});
describe('close button', () => {
@@ -224,19 +235,17 @@ describe('WorkItemDetail component', () => {
describe('confidentiality', () => {
const errorMessage = 'Mutation failed';
- const confidentialWorkItem = workItemResponseFactory({
+ const confidentialWorkItem = workItemByIidResponseFactory({
confidential: true,
});
+ const workItem = confidentialWorkItem.data.workspace.workItems.nodes[0];
// Mocks for work item without parent
- const withoutParentExpectedInputVars = {
- id: workItemQueryResponse.data.workItem.id,
- confidential: true,
- };
+ const withoutParentExpectedInputVars = { id, confidential: true };
const toggleConfidentialityWithoutParentHandler = jest.fn().mockResolvedValue({
data: {
workItemUpdate: {
- workItem: confidentialWorkItem.data.workItem,
+ workItem,
errors: [],
},
},
@@ -256,17 +265,17 @@ describe('WorkItemDetail component', () => {
// Mocks for work item with parent
const withParentExpectedInputVars = {
id: mockParent.parent.id,
- taskData: { id: workItemQueryResponse.data.workItem.id, confidential: true },
+ taskData: { id, confidential: true },
};
const toggleConfidentialityWithParentHandler = jest.fn().mockResolvedValue({
data: {
workItemUpdate: {
workItem: {
- id: confidentialWorkItem.data.workItem.id,
- descriptionHtml: confidentialWorkItem.data.workItem.description,
+ id: workItem.id,
+ descriptionHtml: workItem.description,
},
task: {
- workItem: confidentialWorkItem.data.workItem,
+ workItem,
confidential: true,
},
errors: [],
@@ -342,7 +351,7 @@ describe('WorkItemDetail component', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
- it('shows alert message when mutation fails', async () => {
+ it('shows an alert when mutation fails', async () => {
createComponent({
handler: handlerMock,
confidentialityMock: confidentialityFailureMock,
@@ -393,16 +402,17 @@ describe('WorkItemDetail component', () => {
expect(findParent().exists()).toBe(false);
});
- it('shows work item type if there is not a parent', async () => {
+ it('shows work item type with reference when there is no a parent', async () => {
createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) });
await waitForPromises();
expect(findWorkItemType().exists()).toBe(true);
+ expect(findWorkItemType().text()).toBe('Task #1');
});
describe('with parent', () => {
beforeEach(() => {
- const parentResponse = workItemResponseFactory(mockParent);
+ const parentResponse = workItemByIidResponseFactory(mockParent);
createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) });
return waitForPromises();
@@ -412,7 +422,7 @@ describe('WorkItemDetail component', () => {
expect(findParent().exists()).toBe(true);
});
- it('does not show work item type', async () => {
+ it('does not show work item type', () => {
expect(findWorkItemType().exists()).toBe(false);
});
@@ -420,6 +430,12 @@ describe('WorkItemDetail component', () => {
expect(findParentButton().props('icon')).toBe(mockParent.parent.workItemType.iconName);
});
+ it('shows parent title and iid', () => {
+ expect(findParentButton().text()).toBe(
+ `${mockParent.parent.title} #${mockParent.parent.iid}`,
+ );
+ });
+
it('sets the parent breadcrumb URL pointing to issue page when parent type is `Issue`', () => {
expect(findParentButton().attributes().href).toBe('../../issues/5');
});
@@ -435,12 +451,17 @@ describe('WorkItemDetail component', () => {
},
},
};
- const parentResponse = workItemResponseFactory(mockParentObjective);
+ const parentResponse = workItemByIidResponseFactory(mockParentObjective);
createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) });
await waitForPromises();
expect(findParentButton().attributes().href).toBe(mockParentObjective.parent.webUrl);
});
+
+ it('shows work item type and iid', () => {
+ const { iid, workItemType } = workItemQueryResponse.data.workspace.workItems.nodes[0];
+ expect(findParent().text()).toContain(`${workItemType.name} #${iid}`);
+ });
});
});
@@ -470,9 +491,7 @@ describe('WorkItemDetail component', () => {
createComponent();
await waitForPromises();
- expect(titleSubscriptionHandler).toHaveBeenCalledWith({
- issuableId: workItemQueryResponse.data.workItem.id,
- });
+ expect(titleSubscriptionHandler).toHaveBeenCalledWith({ issuableId: id });
});
describe('assignees subscription', () => {
@@ -481,15 +500,13 @@ describe('WorkItemDetail component', () => {
createComponent();
await waitForPromises();
- expect(assigneesSubscriptionHandler).toHaveBeenCalledWith({
- issuableId: workItemQueryResponse.data.workItem.id,
- });
+ expect(assigneesSubscriptionHandler).toHaveBeenCalledWith({ issuableId: id });
});
});
describe('when the assignees widget does not exist', () => {
it('does not call the assignees subscription', async () => {
- const response = workItemResponseFactory({ assigneesWidgetPresent: false });
+ const response = workItemByIidResponseFactory({ assigneesWidgetPresent: false });
const handler = jest.fn().mockResolvedValue(response);
createComponent({ handler });
await waitForPromises();
@@ -505,15 +522,13 @@ describe('WorkItemDetail component', () => {
createComponent();
await waitForPromises();
- expect(datesSubscriptionHandler).toHaveBeenCalledWith({
- issuableId: workItemQueryResponse.data.workItem.id,
- });
+ expect(datesSubscriptionHandler).toHaveBeenCalledWith({ issuableId: id });
});
});
describe('when the due date widget does not exist', () => {
it('does not call the dates subscription', async () => {
- const response = workItemResponseFactory({ datesWidgetPresent: false });
+ const response = workItemByIidResponseFactory({ datesWidgetPresent: false });
const handler = jest.fn().mockResolvedValue(response);
createComponent({ handler });
await waitForPromises();
@@ -536,7 +551,7 @@ describe('WorkItemDetail component', () => {
createComponent({
handler: jest
.fn()
- .mockResolvedValue(workItemResponseFactory({ assigneesWidgetPresent: false })),
+ .mockResolvedValue(workItemByIidResponseFactory({ assigneesWidgetPresent: false })),
});
await waitForPromises();
@@ -550,7 +565,7 @@ describe('WorkItemDetail component', () => {
${'renders when widget is returned from API'} | ${true} | ${true}
${'does not render when widget is not returned from API'} | ${false} | ${false}
`('$description', async ({ labelsWidgetPresent, exists }) => {
- const response = workItemResponseFactory({ labelsWidgetPresent });
+ const response = workItemByIidResponseFactory({ labelsWidgetPresent });
const handler = jest.fn().mockResolvedValue(response);
createComponent({ handler });
await waitForPromises();
@@ -566,7 +581,7 @@ describe('WorkItemDetail component', () => {
${'when widget is not returned from API'} | ${false} | ${false}
`('$description', ({ datesWidgetPresent, exists }) => {
it(`${datesWidgetPresent ? 'renders' : 'does not render'} due date component`, async () => {
- const response = workItemResponseFactory({ datesWidgetPresent });
+ const response = workItemByIidResponseFactory({ datesWidgetPresent });
const handler = jest.fn().mockResolvedValue(response);
createComponent({ handler });
await waitForPromises();
@@ -593,7 +608,7 @@ describe('WorkItemDetail component', () => {
${'renders when widget is returned from API'} | ${true} | ${true}
${'does not render when widget is not returned from API'} | ${false} | ${false}
`('$description', async ({ milestoneWidgetPresent, exists }) => {
- const response = workItemResponseFactory({ milestoneWidgetPresent });
+ const response = workItemByIidResponseFactory({ milestoneWidgetPresent });
const handler = jest.fn().mockResolvedValue(response);
createComponent({ handler });
await waitForPromises();
@@ -607,15 +622,13 @@ describe('WorkItemDetail component', () => {
createComponent();
await waitForPromises();
- expect(milestoneSubscriptionHandler).toHaveBeenCalledWith({
- issuableId: workItemQueryResponse.data.workItem.id,
- });
+ expect(milestoneSubscriptionHandler).toHaveBeenCalledWith({ issuableId: id });
});
});
describe('when the assignees widget does not exist', () => {
it('does not call the milestone subscription', async () => {
- const response = workItemResponseFactory({ milestoneWidgetPresent: false });
+ const response = workItemByIidResponseFactory({ milestoneWidgetPresent: false });
const handler = jest.fn().mockResolvedValue(response);
createComponent({ handler });
await waitForPromises();
@@ -626,50 +639,25 @@ describe('WorkItemDetail component', () => {
});
});
- it('calls the global ID work item query when `useIidInWorkItemsPath` feature flag is false', async () => {
+ it('calls the work item query', async () => {
createComponent();
await waitForPromises();
- expect(successHandler).toHaveBeenCalledWith({
- id: workItemQueryResponse.data.workItem.id,
- });
- expect(successByIidHandler).not.toHaveBeenCalled();
- });
-
- it('calls the global ID work item query when `useIidInWorkItemsPath` feature flag is true but there is no `iid_path` parameter in URL', async () => {
- createComponent({ fetchByIid: true });
- await waitForPromises();
-
- expect(successHandler).toHaveBeenCalledWith({
- id: workItemQueryResponse.data.workItem.id,
- });
- expect(successByIidHandler).not.toHaveBeenCalled();
+ expect(successHandler).toHaveBeenCalledWith({ fullPath: 'group/project', iid: '1' });
});
- it('calls the IID work item query when `useIidInWorkItemsPath` feature flag is true and `iid_path` route parameter is present', async () => {
- setWindowLocation(`?iid_path=true`);
-
- createComponent({ fetchByIid: true, iidPathQueryParam: 'true' });
+ it('skips the work item query when there is no workItemIid', async () => {
+ createComponent({ workItemIid: null });
await waitForPromises();
expect(successHandler).not.toHaveBeenCalled();
- expect(successByIidHandler).toHaveBeenCalledWith({
- fullPath: 'group/project',
- iid: '1',
- });
});
- it('calls the IID work item query when `useIidInWorkItemsPath` feature flag is true and `iid_path` route parameter is present and is a modal', async () => {
- setWindowLocation(`?iid_path=true`);
-
- createComponent({ fetchByIid: true, iidPathQueryParam: 'true', isModal: true });
+ it('calls the work item query when isModal=true', async () => {
+ createComponent({ isModal: true });
await waitForPromises();
- expect(successHandler).not.toHaveBeenCalled();
- expect(successByIidHandler).toHaveBeenCalledWith({
- fullPath: 'group/project',
- iid: '1',
- });
+ expect(successHandler).toHaveBeenCalledWith({ fullPath: 'group/project', iid: '1' });
});
describe('hierarchy widget', () => {
@@ -681,7 +669,7 @@ describe('WorkItemDetail component', () => {
});
describe('work item has children', () => {
- const objectiveWorkItem = workItemResponseFactory({
+ const objectiveWorkItem = workItemByIidResponseFactory({
workItemType: objectiveType,
confidential: true,
});
@@ -709,7 +697,10 @@ describe('WorkItemDetail component', () => {
preventDefault: jest.fn(),
};
- findHierarchyTree().vm.$emit('show-modal', event, { id: 'childWorkItemId' });
+ findHierarchyTree().vm.$emit('show-modal', {
+ event,
+ modalWorkItem: { id: 'childWorkItemId' },
+ });
await waitForPromises();
expect(wrapper.findComponent(WorkItemDetailModal).props().workItemId).toBe(
@@ -738,7 +729,10 @@ describe('WorkItemDetail component', () => {
preventDefault: jest.fn(),
};
- findHierarchyTree().vm.$emit('show-modal', event, { id: 'childWorkItemId' });
+ findHierarchyTree().vm.$emit('show-modal', {
+ event,
+ modalWorkItem: { id: 'childWorkItemId' },
+ });
await waitForPromises();
expect(wrapper.emitted('update-modal')).toBeDefined();
@@ -748,21 +742,10 @@ describe('WorkItemDetail component', () => {
});
describe('notes widget', () => {
- it('does not render notes by default', async () => {
+ it('renders notes by default', async () => {
createComponent();
await waitForPromises();
- expect(findNotesWidget().exists()).toBe(false);
- });
-
- it('renders notes when the work_items_mvc flag is on', async () => {
- const notesWorkItem = workItemResponseFactory({
- notesWidgetPresent: true,
- });
- const handler = jest.fn().mockResolvedValue(notesWorkItem);
- createComponent({ workItemsMvcEnabled: true, handler });
- await waitForPromises();
-
expect(findNotesWidget().exists()).toBe(true);
});
});
@@ -773,4 +756,42 @@ describe('WorkItemDetail component', () => {
expect(findCreatedUpdated().exists()).toBe(true);
});
+
+ describe('abuse category selector', () => {
+ beforeEach(async () => {
+ setWindowLocation('?work_item_id=2');
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('should not be visible by default', () => {
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+
+ it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
+ findModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote);
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(true);
+
+ findAbuseCategorySelector().vm.$emit('close-drawer');
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+ });
+
+ describe('todos widget', () => {
+ beforeEach(async () => {
+ isLoggedIn.mockReturnValue(false);
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('does not renders if not logged in', () => {
+ expect(findWorkItemTodos().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_due_date_spec.js b/spec/frontend/work_items/components/work_item_due_date_spec.js
index 7ebaf8209c7..b4811db8bed 100644
--- a/spec/frontend/work_items/components/work_item_due_date_spec.js
+++ b/spec/frontend/work_items/components/work_item_due_date_spec.js
@@ -46,10 +46,6 @@ describe('WorkItemDueDate component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when can update', () => {
describe('start date', () => {
describe('`Add start date` button', () => {
diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js
index 0b6ab5c3290..554c9a4f7b8 100644
--- a/spec/frontend/work_items/components/work_item_labels_spec.js
+++ b/spec/frontend/work_items/components/work_item_labels_spec.js
@@ -6,7 +6,6 @@ import waitForPromises from 'helpers/wait_for_promises';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
@@ -15,11 +14,9 @@ import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS } from '~/work_items/constan
import {
projectLabelsResponse,
mockLabels,
- workItemQueryResponse,
- workItemResponseFactory,
+ workItemByIidResponseFactory,
updateWorkItemMutationResponse,
workItemLabelsSubscriptionResponse,
- projectWorkItemResponse,
} from '../mock_data';
Vue.use(VueApollo);
@@ -34,8 +31,9 @@ describe('WorkItemLabels component', () => {
const findEmptyState = () => wrapper.findByTestId('empty-state');
const findLabelsTitle = () => wrapper.findByTestId('labels-title');
- const workItemQuerySuccess = jest.fn().mockResolvedValue(workItemQueryResponse);
- const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
+ const workItemQuerySuccess = jest
+ .fn()
+ .mockResolvedValue(workItemByIidResponseFactory({ labels: null }));
const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse);
const successUpdateWorkItemMutationHandler = jest
.fn()
@@ -48,34 +46,27 @@ describe('WorkItemLabels component', () => {
workItemQueryHandler = workItemQuerySuccess,
searchQueryHandler = successSearchQueryHandler,
updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler,
- fetchByIid = false,
- queryVariables = { id: workItemId },
+ workItemIid = '1',
} = {}) => {
- const apolloProvider = createMockApollo([
- [workItemQuery, workItemQueryHandler],
- [labelSearchQuery, searchQueryHandler],
- [updateWorkItemMutation, updateWorkItemMutationHandler],
- [workItemLabelsSubscription, subscriptionHandler],
- [workItemByIidQuery, workItemByIidResponseHandler],
- ]);
-
wrapper = mountExtended(WorkItemLabels, {
+ apolloProvider: createMockApollo([
+ [workItemByIidQuery, workItemQueryHandler],
+ [labelSearchQuery, searchQueryHandler],
+ [updateWorkItemMutation, updateWorkItemMutationHandler],
+ [workItemLabelsSubscription, subscriptionHandler],
+ ]),
+ provide: {
+ fullPath: 'test-project-path',
+ },
propsData: {
workItemId,
+ workItemIid,
canUpdate,
- fullPath: 'test-project-path',
- queryVariables,
- fetchByIid,
},
attachTo: document.body,
- apolloProvider,
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('has a label', () => {
createComponent();
@@ -190,7 +181,7 @@ describe('WorkItemLabels component', () => {
});
it('adds new labels to the end', async () => {
- const response = workItemResponseFactory({ labels: [mockLabels[1]] });
+ const response = workItemByIidResponseFactory({ labels: [mockLabels[1]] });
const workItemQueryHandler = jest.fn().mockResolvedValue(response);
createComponent({
workItemQueryHandler,
@@ -267,24 +258,15 @@ describe('WorkItemLabels component', () => {
});
});
- it('calls the global ID work item query when `fetchByIid` prop is false', async () => {
- createComponent({ fetchByIid: false });
+ it('calls the work item query', async () => {
+ createComponent();
await waitForPromises();
expect(workItemQuerySuccess).toHaveBeenCalled();
- expect(workItemByIidResponseHandler).not.toHaveBeenCalled();
- });
-
- it('calls the IID work item query when when `fetchByIid` prop is true', async () => {
- createComponent({ fetchByIid: true });
- await waitForPromises();
-
- expect(workItemQuerySuccess).not.toHaveBeenCalled();
- expect(workItemByIidResponseHandler).toHaveBeenCalled();
});
- it('skips calling the handlers when missing the needed queryVariables', async () => {
- createComponent({ queryVariables: {}, fetchByIid: false });
+ it('skips calling the work item query when missing workItemIid', async () => {
+ createComponent({ workItemIid: null });
await waitForPromises();
expect(workItemQuerySuccess).not.toHaveBeenCalled();
diff --git a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js b/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js
index 5fbd8e7e1a7..688dccbda79 100644
--- a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js
@@ -15,10 +15,6 @@ describe('RelatedItemsTree', () => {
wrapper = createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('OkrActionsSplitButton', () => {
describe('template', () => {
it('renders objective and key results sections', () => {
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js
new file mode 100644
index 00000000000..b06be6c8083
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js
@@ -0,0 +1,98 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue';
+import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+
+import { childrenWorkItems, workItemByIidResponseFactory } from '../../mock_data';
+
+describe('WorkItemChildrenWrapper', () => {
+ let wrapper;
+
+ const getWorkItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory());
+
+ const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild);
+
+ Vue.use(VueApollo);
+
+ const createComponent = ({
+ workItemType = 'Objective',
+ confidential = false,
+ children = childrenWorkItems,
+ } = {}) => {
+ wrapper = shallowMountExtended(WorkItemChildrenWrapper, {
+ apolloProvider: createMockApollo([[workItemByIidQuery, getWorkItemQueryHandler]]),
+ provide: {
+ fullPath: 'test/project',
+ },
+ propsData: {
+ workItemType,
+ workItemId: 'gid://gitlab/WorkItem/515',
+ confidential,
+ children,
+ fetchByIid: true,
+ },
+ });
+ };
+
+ it('renders all hierarchy widget children', () => {
+ createComponent();
+
+ const workItemLinkChildren = findWorkItemLinkChildItems();
+ expect(workItemLinkChildren).toHaveLength(4);
+ expect(workItemLinkChildren.at(0).props().childItem.confidential).toBe(
+ childrenWorkItems[0].confidential,
+ );
+ });
+
+ it('remove event on child triggers `removeChild` event', () => {
+ createComponent();
+ const workItem = { id: 'gid://gitlab/WorkItem/2' };
+ const firstChild = findWorkItemLinkChildItems().at(0);
+
+ firstChild.vm.$emit('removeChild', workItem);
+
+ expect(wrapper.emitted('removeChild')).toEqual([[workItem]]);
+ });
+
+ it('emits `show-modal` on `click` event', () => {
+ createComponent();
+ const firstChild = findWorkItemLinkChildItems().at(0);
+ const event = {
+ childItem: 'gid://gitlab/WorkItem/2',
+ };
+
+ firstChild.vm.$emit('click', event);
+
+ expect(wrapper.emitted('show-modal')).toEqual([[{ event, child: event.childItem }]]);
+ });
+
+ it.each`
+ description | workItemType | prefetch
+ ${'prefetches'} | ${'Issue'} | ${true}
+ ${'does not prefetch'} | ${'Objective'} | ${false}
+ `(
+ '$description work-item-link-child on mouseover when workItemType is "$workItemType"',
+ async ({ workItemType, prefetch }) => {
+ createComponent({ workItemType });
+ const firstChild = findWorkItemLinkChildItems().at(0);
+ firstChild.vm.$emit('mouseover', childrenWorkItems[0]);
+ await nextTick();
+ await waitForPromises();
+
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+
+ if (prefetch) {
+ expect(getWorkItemQueryHandler).toHaveBeenCalled();
+ } else {
+ expect(getWorkItemQueryHandler).not.toHaveBeenCalled();
+ }
+ },
+ );
+});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js
index e693ccfb156..07efb1c5ac8 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js
@@ -1,4 +1,4 @@
-import { GlLabel, GlAvatarsInline } from '@gitlab/ui';
+import { GlAvatarsInline } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -8,10 +8,9 @@ import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/w
import { workItemObjectiveMetadataWidgets } from '../../mock_data';
describe('WorkItemLinkChildMetadata', () => {
- const { MILESTONE, ASSIGNEES, LABELS } = workItemObjectiveMetadataWidgets;
+ const { MILESTONE, ASSIGNEES } = workItemObjectiveMetadataWidgets;
const mockMilestone = MILESTONE.milestone;
const mockAssignees = ASSIGNEES.assignees.nodes;
- const mockLabels = LABELS.labels.nodes;
let wrapper;
const createComponent = ({ metadataWidgets = workItemObjectiveMetadataWidgets } = {}) => {
@@ -53,18 +52,4 @@ describe('WorkItemLinkChildMetadata', () => {
badgeSrOnlyText: '',
});
});
-
- it('renders labels', () => {
- const labels = wrapper.findAllComponents(GlLabel);
- const mockLabel = mockLabels[0];
-
- expect(labels).toHaveLength(mockLabels.length);
- expect(labels.at(0).props()).toMatchObject({
- title: mockLabel.title,
- backgroundColor: mockLabel.color,
- description: mockLabel.description,
- scoped: false,
- });
- expect(labels.at(1).props('scoped')).toBe(true); // Second label is scoped
- });
});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js
index 0470249d7ce..71d1a0e253f 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js
@@ -1,4 +1,4 @@
-import { GlIcon } from '@gitlab/ui';
+import { GlLabel, GlIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -7,10 +7,11 @@ import waitForPromises from 'helpers/wait_for_promises';
import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue';
import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue';
import WorkItemTreeChildren from '~/work_items/components/work_item_links/work_item_tree_children.vue';
@@ -29,19 +30,28 @@ import {
workItemHierarchyTreeResponse,
workItemHierarchyTreeFailureResponse,
workItemObjectiveMetadataWidgets,
+ changeIndirectWorkItemParentMutationResponse,
+ workItemUpdateFailureResponse,
} from '../../mock_data';
-jest.mock('~/flash');
+jest.mock('~/alert');
describe('WorkItemLinkChild', () => {
const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2';
let wrapper;
let getWorkItemTreeQueryHandler;
+ let mutationChangeParentHandler;
+ const { LABELS } = workItemObjectiveMetadataWidgets;
+ const mockLabels = LABELS.labels.nodes;
+
+ const $toast = {
+ show: jest.fn(),
+ hide: jest.fn(),
+ };
Vue.use(VueApollo);
const createComponent = ({
- projectPath = 'gitlab-org/gitlab-test',
canUpdate = true,
issuableGid = WORK_ITEM_ID,
childItem = workItemTask,
@@ -49,17 +59,29 @@ describe('WorkItemLinkChild', () => {
apolloProvider = null,
} = {}) => {
getWorkItemTreeQueryHandler = jest.fn().mockResolvedValue(workItemHierarchyTreeResponse);
+ mutationChangeParentHandler = jest
+ .fn()
+ .mockResolvedValue(changeIndirectWorkItemParentMutationResponse);
wrapper = shallowMountExtended(WorkItemLinkChild, {
apolloProvider:
- apolloProvider || createMockApollo([[getWorkItemTreeQuery, getWorkItemTreeQueryHandler]]),
+ apolloProvider ||
+ createMockApollo([
+ [getWorkItemTreeQuery, getWorkItemTreeQueryHandler],
+ [updateWorkItemMutation, mutationChangeParentHandler],
+ ]),
+ provide: {
+ fullPath: 'gitlab-org/gitlab-test',
+ },
propsData: {
- projectPath,
canUpdate,
issuableGid,
childItem,
workItemType,
},
+ mocks: {
+ $toast,
+ },
});
};
@@ -67,10 +89,6 @@ describe('WorkItemLinkChild', () => {
createAlert.mockClear();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it.each`
status | childItem | statusIconName | statusIconColorClass | rawTimestamp | tooltipContents
${'open'} | ${workItemTask} | ${'issue-open-m'} | ${'gl-text-green-500'} | ${workItemTask.createdAt} | ${'Created'}
@@ -113,6 +131,22 @@ describe('WorkItemLinkChild', () => {
expect(titleEl.text()).toBe(workItemTask.title);
});
+ describe('renders item title correctly for relative instance', () => {
+ beforeEach(() => {
+ window.gon = { relative_url_root: '/test' };
+ createComponent();
+ titleEl = wrapper.findByTestId('item-title');
+ });
+
+ it('renders item title with correct href', () => {
+ expect(titleEl.attributes('href')).toBe('/test/gitlab-org/gitlab-test/-/work_items/4');
+ });
+
+ it('renders item title with correct text', () => {
+ expect(titleEl.text()).toBe(workItemTask.title);
+ });
+ });
+
it.each`
action | event | emittedEvent
${'doing mouseover on'} | ${'mouseover'} | ${'mouseover'}
@@ -159,6 +193,20 @@ describe('WorkItemLinkChild', () => {
expect(findMetadataComponent().exists()).toBe(false);
});
+
+ it('renders labels', () => {
+ const labels = wrapper.findAllComponents(GlLabel);
+ const mockLabel = mockLabels[0];
+
+ expect(labels).toHaveLength(mockLabels.length);
+ expect(labels.at(0).props()).toMatchObject({
+ title: mockLabel.title,
+ backgroundColor: mockLabel.color,
+ description: mockLabel.description,
+ scoped: false,
+ });
+ expect(labels.at(1).props('scoped')).toBe(true); // Second label is scoped
+ });
});
describe('item menu', () => {
@@ -188,7 +236,7 @@ describe('WorkItemLinkChild', () => {
it('removeChild event on menu triggers `click-remove-child` event', () => {
itemMenuEl.vm.$emit('removeChild');
- expect(wrapper.emitted('removeChild')).toEqual([[workItemTask.id]]);
+ expect(wrapper.emitted('removeChild')).toEqual([[workItemTask]]);
});
});
@@ -196,6 +244,13 @@ describe('WorkItemLinkChild', () => {
const findExpandButton = () => wrapper.findByTestId('expand-child');
const findTreeChildren = () => wrapper.findComponent(WorkItemTreeChildren);
+ const getWidgetHierarchy = () =>
+ workItemHierarchyTreeResponse.data.workItem.widgets.find(
+ (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
+ );
+ const getChildrenNodes = () => getWidgetHierarchy().children.nodes;
+ const findFirstItem = () => getChildrenNodes()[0];
+
beforeEach(() => {
getWorkItemTreeQueryHandler.mockClear();
createComponent({
@@ -218,10 +273,8 @@ describe('WorkItemLinkChild', () => {
expect(getWorkItemTreeQueryHandler).toHaveBeenCalled();
expect(findTreeChildren().exists()).toBe(true);
- const widgetHierarchy = workItemHierarchyTreeResponse.data.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
- );
- expect(findTreeChildren().props('children')).toEqual(widgetHierarchy.children.nodes);
+ const childrenNodes = getChildrenNodes();
+ expect(findTreeChildren().props('children')).toEqual(childrenNodes);
});
it('does not fetch children if already fetched once while clicking expand button', async () => {
@@ -270,5 +323,74 @@ describe('WorkItemLinkChild', () => {
expect(wrapper.emitted('click')).toEqual([['event']]);
});
+
+ it('shows toast on removing child item', async () => {
+ findExpandButton().vm.$emit('click');
+ await waitForPromises();
+
+ findTreeChildren().vm.$emit('removeChild', findFirstItem());
+ await waitForPromises();
+
+ expect($toast.show).toHaveBeenCalledWith('Child removed', {
+ action: { onClick: expect.any(Function), text: 'Undo' },
+ });
+ });
+
+ it('renders correct number of children after the removal', async () => {
+ findExpandButton().vm.$emit('click');
+ await waitForPromises();
+
+ const childrenNodes = getChildrenNodes();
+ expect(findTreeChildren().props('children')).toEqual(childrenNodes);
+
+ findTreeChildren().vm.$emit('removeChild', findFirstItem());
+ await waitForPromises();
+
+ expect(findTreeChildren().props('children')).toEqual([]);
+ });
+
+ it('calls correct mutation with correct variables', async () => {
+ const firstItem = findFirstItem();
+
+ findExpandButton().vm.$emit('click');
+ await waitForPromises();
+
+ findTreeChildren().vm.$emit('removeChild', firstItem);
+
+ expect(mutationChangeParentHandler).toHaveBeenCalledWith({
+ input: {
+ id: firstItem.id,
+ hierarchyWidget: {
+ parentId: null,
+ },
+ },
+ });
+ });
+
+ it('shows the alert when workItem update fails', async () => {
+ mutationChangeParentHandler = jest.fn().mockRejectedValue(workItemUpdateFailureResponse);
+ const apolloProvider = createMockApollo([
+ [getWorkItemTreeQuery, getWorkItemTreeQueryHandler],
+ [updateWorkItemMutation, mutationChangeParentHandler],
+ ]);
+
+ createComponent({
+ childItem: workItemObjectiveWithChild,
+ workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE,
+ apolloProvider,
+ });
+
+ findExpandButton().vm.$emit('click');
+ await waitForPromises();
+
+ findTreeChildren().vm.$emit('removeChild', findFirstItem());
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ captureError: true,
+ error: expect.any(Object),
+ message: 'Something went wrong while removing child.',
+ });
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
index 480f8fbcc58..5f7f56d7063 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
@@ -61,7 +61,7 @@ describe('WorkItemLinksForm', () => {
formType,
},
provide: {
- projectPath: 'project/path',
+ fullPath: 'project/path',
hasIterationsFeature,
},
});
@@ -75,10 +75,6 @@ describe('WorkItemLinksForm', () => {
const findConfidentialCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findAddChildButton = () => wrapper.findByTestId('add-child-button');
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('creating a new work item', () => {
beforeEach(async () => {
await createComponent();
@@ -154,7 +150,7 @@ describe('WorkItemLinksForm', () => {
const confidentialCheckbox = findConfidentialCheckbox();
const confidentialTooltip = wrapper.findComponent(GlTooltip);
- expect(confidentialCheckbox.attributes('disabled')).toBe('true');
+ expect(confidentialCheckbox.attributes('disabled')).toBeDefined();
expect(confidentialCheckbox.attributes('checked')).toBe('true');
expect(confidentialTooltip.exists()).toBe(true);
expect(confidentialTooltip.text()).toBe(
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js
index e3f3b74f296..f02a9fbd021 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js
@@ -13,14 +13,10 @@ describe('WorkItemLinksMenu', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findRemoveDropdownItem = () => wrapper.findComponent(GlDropdownItem);
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders dropdown and dropdown items', () => {
expect(findDropdown().exists()).toBe(true);
expect(findRemoveDropdownItem().exists()).toBe(true);
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
index ec51f92b578..786f8604039 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
@@ -5,17 +5,16 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import setWindowLocation from 'helpers/set_window_location_helper';
import { stubComponent } from 'helpers/stub_component';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import issueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import { resolvers } from '~/graphql_shared/issuable_client';
import WidgetWrapper from '~/work_items/components/widget_wrapper.vue';
import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue';
-import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue';
+import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import { FORM_TYPES } from '~/work_items/constants';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
-import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
getIssueDetailsResponse,
@@ -23,8 +22,10 @@ import {
workItemHierarchyEmptyResponse,
workItemHierarchyNoUpdatePermissionResponse,
changeWorkItemParentMutationResponse,
+ workItemByIidResponseFactory,
workItemQueryResponse,
- projectWorkItemResponse,
+ mockWorkItemCommentNote,
+ childrenWorkItems,
} from '../../mock_data';
Vue.use(VueApollo);
@@ -44,23 +45,23 @@ describe('WorkItemLinks', () => {
const mutationChangeParentHandler = jest
.fn()
.mockResolvedValue(changeWorkItemParentMutationResponse);
-
- const childWorkItemQueryHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
- const childWorkItemByIidHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
+ const childWorkItemByIidHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory());
+ const responseWithAddChildPermission = jest.fn().mockResolvedValue(workItemHierarchyResponse);
+ const responseWithoutAddChildPermission = jest
+ .fn()
+ .mockResolvedValue(workItemByIidResponseFactory({ adminParentLink: false }));
const createComponent = async ({
data = {},
- fetchHandler = jest.fn().mockResolvedValue(workItemHierarchyResponse),
+ fetchHandler = responseWithAddChildPermission,
mutationHandler = mutationChangeParentHandler,
issueDetailsQueryHandler = jest.fn().mockResolvedValue(getIssueDetailsResponse()),
hasIterationsFeature = false,
- fetchByIid = false,
} = {}) => {
mockApollo = createMockApollo(
[
- [getWorkItemLinksQuery, fetchHandler],
+ [workItemQuery, fetchHandler],
[changeWorkItemParentMutation, mutationHandler],
- [workItemQuery, childWorkItemQueryHandler],
[issueDetailsQuery, issueDetailsQueryHandler],
[workItemByIidQuery, childWorkItemByIidHandler],
],
@@ -75,12 +76,9 @@ describe('WorkItemLinks', () => {
};
},
provide: {
- projectPath: 'project/path',
- iid: '1',
+ fullPath: 'project/path',
hasIterationsFeature,
- glFeatures: {
- useIidInWorkItemsPath: fetchByIid,
- },
+ reportAbusePath: '/report/abuse/path',
},
propsData: { issuableId: 1 },
apolloProvider: mockApollo,
@@ -106,16 +104,31 @@ describe('WorkItemLinks', () => {
const findToggleFormDropdown = () => wrapper.findByTestId('toggle-form');
const findToggleAddFormButton = () => wrapper.findByTestId('toggle-add-form');
const findToggleCreateFormButton = () => wrapper.findByTestId('toggle-create-form');
- const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild);
- const findFirstWorkItemLinkChild = () => findWorkItemLinkChildItems().at(0);
const findAddLinksForm = () => wrapper.findByTestId('add-links-form');
const findChildrenCount = () => wrapper.findByTestId('children-count');
+ const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
+ const findWorkItemLinkChildrenWrapper = () => wrapper.findComponent(WorkItemChildrenWrapper);
afterEach(() => {
mockApollo = null;
setWindowLocation('');
});
+ it.each`
+ expectedAssertion | workItemFetchHandler | value
+ ${'renders'} | ${responseWithAddChildPermission} | ${true}
+ ${'does not render'} | ${responseWithoutAddChildPermission} | ${false}
+ `(
+ '$expectedAssertion "Add" button in hierarchy widget header when "userPermissions.adminParentLink" is $value',
+ async ({ workItemFetchHandler, value }) => {
+ createComponent({ fetchHandler: workItemFetchHandler });
+ await waitForPromises();
+
+ expect(findToggleFormDropdown().exists()).toBe(value);
+ },
+ );
+
describe('add link form', () => {
it('displays add work item form on click add dropdown then add existing button and hides form on cancel', async () => {
await createComponent();
@@ -157,12 +170,12 @@ describe('WorkItemLinks', () => {
findToggleCreateFormButton().vm.$emit('click');
await nextTick();
- expect(findWorkItemLinkChildItems()).toHaveLength(4);
+ expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4);
findAddLinksForm().vm.$emit('addWorkItemChild', workItem);
await waitForPromises();
- expect(findWorkItemLinkChildItems()).toHaveLength(5);
+ expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(5);
});
});
@@ -178,13 +191,14 @@ describe('WorkItemLinks', () => {
});
});
- it('renders all hierarchy widget children', async () => {
+ it('renders hierarchy widget children container', async () => {
await createComponent();
- expect(findWorkItemLinkChildItems()).toHaveLength(4);
+ expect(findWorkItemLinkChildrenWrapper().exists()).toBe(true);
+ expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4);
});
- it('shows alert when list loading fails', async () => {
+ it('shows an alert when list loading fails', async () => {
const errorMessage = 'Some error';
await createComponent({
fetchHandler: jest.fn().mockRejectedValue(new Error(errorMessage)),
@@ -212,7 +226,7 @@ describe('WorkItemLinks', () => {
});
it('does not display link menu on children', () => {
- expect(findWorkItemLinkChildItems().at(0).props('canUpdate')).toBe(false);
+ expect(findWorkItemLinkChildrenWrapper().props('canUpdate')).toBe(false);
});
});
@@ -222,11 +236,11 @@ describe('WorkItemLinks', () => {
beforeEach(async () => {
await createComponent({ mutationHandler: mutationChangeParentHandler });
- firstChild = findFirstWorkItemLinkChild();
+ [firstChild] = childrenWorkItems;
});
it('calls correct mutation with correct variables', async () => {
- firstChild.vm.$emit('removeChild', firstChild.vm.childItem.id);
+ findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild);
await waitForPromises();
@@ -241,7 +255,7 @@ describe('WorkItemLinks', () => {
});
it('shows toast when mutation succeeds', async () => {
- firstChild.vm.$emit('removeChild', firstChild.vm.childItem.id);
+ findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild);
await waitForPromises();
@@ -251,12 +265,12 @@ describe('WorkItemLinks', () => {
});
it('renders correct number of children after removal', async () => {
- expect(findWorkItemLinkChildItems()).toHaveLength(4);
+ expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4);
- firstChild.vm.$emit('removeChild', firstChild.vm.childItem.id);
+ findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild);
await waitForPromises();
- expect(findWorkItemLinkChildItems()).toHaveLength(3);
+ expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(3);
});
});
@@ -275,144 +289,54 @@ describe('WorkItemLinks', () => {
});
});
- describe('when work item is fetched by id', () => {
- describe('prefetching child items', () => {
- let firstChild;
-
- beforeEach(async () => {
- await createComponent();
-
- firstChild = findFirstWorkItemLinkChild();
- });
-
- it('does not fetch the child work item by id before hovering work item links', () => {
- expect(childWorkItemQueryHandler).not.toHaveBeenCalled();
- });
-
- it('fetches the child work item by id if link is hovered for 250+ ms', async () => {
- firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id);
- jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
- await waitForPromises();
-
- expect(childWorkItemQueryHandler).toHaveBeenCalledWith({
- id: 'gid://gitlab/WorkItem/2',
- });
- });
-
- it('does not fetch the child work item by id if link is hovered for less than 250 ms', async () => {
- firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id);
- jest.advanceTimersByTime(200);
- firstChild.vm.$emit('mouseout', firstChild.vm.childItem.id);
- await waitForPromises();
-
- expect(childWorkItemQueryHandler).not.toHaveBeenCalled();
- });
-
- it('does not fetch work item by iid if link is hovered for 250+ ms', async () => {
- firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id);
- jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
- await waitForPromises();
+ it('starts prefetching work item by iid if URL contains work_item_iid query parameter', async () => {
+ setWindowLocation('?work_item_iid=5');
+ await createComponent();
- expect(childWorkItemByIidHandler).not.toHaveBeenCalled();
- });
+ expect(childWorkItemByIidHandler).toHaveBeenCalledWith({
+ iid: '5',
+ fullPath: 'project/path',
});
+ });
- it('starts prefetching work item by id if URL contains work item id', async () => {
- setWindowLocation('?work_item_id=5');
- await createComponent();
+ it('does not open the modal if work item iid URL parameter is not found in child items', async () => {
+ setWindowLocation('?work_item_iid=555');
+ await createComponent();
- expect(childWorkItemQueryHandler).toHaveBeenCalledWith({
- id: 'gid://gitlab/WorkItem/5',
- });
- });
+ expect(showModal).not.toHaveBeenCalled();
+ expect(findWorkItemDetailModal().props('workItemIid')).toBe(null);
+ });
- it('does not open the modal if work item id URL parameter is not found in child items', async () => {
- setWindowLocation('?work_item_id=555');
- await createComponent();
+ it('opens the modal if work item iid URL parameter is found in child items', async () => {
+ setWindowLocation('?work_item_iid=2');
+ await createComponent();
- expect(showModal).not.toHaveBeenCalled();
- expect(wrapper.findComponent(WorkItemDetailModal).props('workItemId')).toBe(null);
- });
+ expect(showModal).toHaveBeenCalled();
+ expect(findWorkItemDetailModal().props('workItemIid')).toBe('2');
+ });
- it('opens the modal if work item id URL parameter is found in child items', async () => {
+ describe('abuse category selector', () => {
+ beforeEach(async () => {
setWindowLocation('?work_item_id=2');
await createComponent();
-
- expect(showModal).toHaveBeenCalled();
- expect(wrapper.findComponent(WorkItemDetailModal).props('workItemId')).toBe(
- 'gid://gitlab/WorkItem/2',
- );
});
- });
-
- describe('when work item is fetched by iid', () => {
- describe('prefetching child items', () => {
- let firstChild;
-
- beforeEach(async () => {
- setWindowLocation('?iid_path=true');
- await createComponent({ fetchByIid: true });
-
- firstChild = findFirstWorkItemLinkChild();
- });
-
- it('does not fetch the child work item by iid before hovering work item links', () => {
- expect(childWorkItemByIidHandler).not.toHaveBeenCalled();
- });
-
- it('fetches the child work item by iid if link is hovered for 250+ ms', async () => {
- firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id);
- jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
- await waitForPromises();
-
- expect(childWorkItemByIidHandler).toHaveBeenCalledWith({
- fullPath: 'project/path',
- iid: '2',
- });
- });
-
- it('does not fetch the child work item by iid if link is hovered for less than 250 ms', async () => {
- firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id);
- jest.advanceTimersByTime(200);
- firstChild.vm.$emit('mouseout', firstChild.vm.childItem.id);
- await waitForPromises();
- expect(childWorkItemByIidHandler).not.toHaveBeenCalled();
- });
-
- it('does not fetch work item by id if link is hovered for 250+ ms', async () => {
- firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id);
- jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
- await waitForPromises();
-
- expect(childWorkItemQueryHandler).not.toHaveBeenCalled();
- });
+ it('should not be visible by default', () => {
+ expect(findAbuseCategorySelector().exists()).toBe(false);
});
- it('starts prefetching work item by iid if URL contains work item id', async () => {
- setWindowLocation('?work_item_iid=5&iid_path=true');
- await createComponent({ fetchByIid: true });
+ it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
+ findWorkItemDetailModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote);
- expect(childWorkItemByIidHandler).toHaveBeenCalledWith({
- iid: '5',
- fullPath: 'project/path',
- });
- });
- });
+ await nextTick();
- it('does not open the modal if work item iid URL parameter is not found in child items', async () => {
- setWindowLocation('?work_item_iid=555&iid_path=true');
- await createComponent({ fetchByIid: true });
+ expect(findAbuseCategorySelector().exists()).toBe(true);
- expect(showModal).not.toHaveBeenCalled();
- expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe(null);
- });
+ findAbuseCategorySelector().vm.$emit('close-drawer');
- it('opens the modal if work item iid URL parameter is found in child items', async () => {
- setWindowLocation('?work_item_iid=2&iid_path=true');
- await createComponent({ fetchByIid: true });
+ await nextTick();
- expect(showModal).toHaveBeenCalled();
- expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe('2');
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
index 0236fe2e60d..06716584879 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
@@ -1,65 +1,44 @@
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
+import { nextTick } from 'vue';
-import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue';
+import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue';
import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue';
-import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue';
import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
-
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import {
FORM_TYPES,
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
WORK_ITEM_TYPE_ENUM_KEY_RESULT,
} from '~/work_items/constants';
-import { childrenWorkItems, workItemObjectiveWithChild } from '../../mock_data';
+import { childrenWorkItems } from '../../mock_data';
describe('WorkItemTree', () => {
- let getWorkItemQueryHandler;
let wrapper;
const findEmptyState = () => wrapper.findByTestId('tree-empty');
const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton);
const findForm = () => wrapper.findComponent(WorkItemLinksForm);
- const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild);
-
- Vue.use(VueApollo);
+ const findWorkItemLinkChildrenWrapper = () => wrapper.findComponent(WorkItemChildrenWrapper);
const createComponent = ({
workItemType = 'Objective',
parentWorkItemType = 'Objective',
confidential = false,
children = childrenWorkItems,
- apolloProvider = null,
+ canUpdate = true,
} = {}) => {
- const mockWorkItemResponse = {
- data: {
- workItem: {
- ...workItemObjectiveWithChild,
- workItemType: {
- ...workItemObjectiveWithChild.workItemType,
- name: workItemType,
- },
- },
- },
- };
- getWorkItemQueryHandler = jest.fn().mockResolvedValue(mockWorkItemResponse);
-
wrapper = shallowMountExtended(WorkItemTree, {
- apolloProvider:
- apolloProvider || createMockApollo([[workItemQuery, getWorkItemQueryHandler]]),
+ provide: {
+ fullPath: 'test/project',
+ },
propsData: {
workItemType,
parentWorkItemType,
workItemId: 'gid://gitlab/WorkItem/515',
confidential,
children,
- projectPath: 'test/project',
+ canUpdate,
},
});
@@ -78,14 +57,11 @@ describe('WorkItemTree', () => {
expect(findEmptyState().exists()).toBe(true);
});
- it('renders all hierarchy widget children', () => {
+ it('renders hierarchy widget children container', () => {
createComponent();
- const workItemLinkChildren = findWorkItemLinkChildItems();
- expect(workItemLinkChildren).toHaveLength(4);
- expect(workItemLinkChildren.at(0).props().childItem.confidential).toBe(
- childrenWorkItems[0].confidential,
- );
+ expect(findWorkItemLinkChildrenWrapper().exists()).toBe(true);
+ expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4);
});
it('does not display form by default', () => {
@@ -118,47 +94,19 @@ describe('WorkItemTree', () => {
},
);
- it('remove event on child triggers `removeChild` event', () => {
- createComponent();
- const firstChild = findWorkItemLinkChildItems().at(0);
-
- firstChild.vm.$emit('removeChild', 'gid://gitlab/WorkItem/2');
-
- expect(wrapper.emitted('removeChild')).toEqual([['gid://gitlab/WorkItem/2']]);
- });
-
- it('emits `show-modal` on `click` event', () => {
- createComponent();
- const firstChild = findWorkItemLinkChildItems().at(0);
- const event = {
- childItem: 'gid://gitlab/WorkItem/2',
- };
+ describe('when no permission to update', () => {
+ beforeEach(() => {
+ createComponent({
+ canUpdate: false,
+ });
+ });
- firstChild.vm.$emit('click', event);
+ it('does not display button to toggle Add form', () => {
+ expect(findToggleFormSplitButton().exists()).toBe(false);
+ });
- expect(wrapper.emitted('show-modal')).toEqual([[event, event.childItem]]);
+ it('does not display link menu on children', () => {
+ expect(findWorkItemLinkChildrenWrapper().props('canUpdate')).toBe(false);
+ });
});
-
- it.each`
- description | workItemType | prefetch
- ${'prefetches'} | ${'Issue'} | ${true}
- ${'does not prefetch'} | ${'Objective'} | ${false}
- `(
- '$description work-item-link-child on mouseover when workItemType is "$workItemType"',
- async ({ workItemType, prefetch }) => {
- createComponent({ workItemType });
- const firstChild = findWorkItemLinkChildItems().at(0);
- firstChild.vm.$emit('mouseover', childrenWorkItems[0]);
- await nextTick();
- await waitForPromises();
-
- jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
-
- if (prefetch) {
- expect(getWorkItemQueryHandler).toHaveBeenCalled();
- } else {
- expect(getWorkItemQueryHandler).not.toHaveBeenCalled();
- }
- },
- );
});
diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js
index 5997de01274..c42c9a573e5 100644
--- a/spec/frontend/work_items/components/work_item_milestone_spec.js
+++ b/spec/frontend/work_items/components/work_item_milestone_spec.js
@@ -9,27 +9,20 @@ import {
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
-import { resolvers, config } from '~/graphql_shared/issuable_client';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
import {
projectMilestonesResponse,
projectMilestonesResponseWithNoMilestones,
mockMilestoneWidgetResponse,
- workItemResponseFactory,
updateWorkItemMutationErrorResponse,
- workItemMilestoneSubscriptionResponse,
- projectWorkItemResponse,
updateWorkItemMutationResponse,
-} from 'jest/work_items/mock_data';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
-import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
-import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql';
-import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+} from '../mock_data';
describe('WorkItemMilestone component', () => {
Vue.use(VueApollo);
@@ -38,7 +31,6 @@ describe('WorkItemMilestone component', () => {
const workItemId = 'gid://gitlab/WorkItem/1';
const workItemType = 'Task';
- const fullPath = 'full-path';
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
@@ -52,72 +44,36 @@ describe('WorkItemMilestone component', () => {
const findDropdownTextAtIndex = (index) => findDropdownTexts().at(index);
const findInputGroup = () => wrapper.findComponent(GlFormGroup);
- const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true });
- const workItemQueryHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
- const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
-
- const networkResolvedValue = new Error();
-
const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMilestonesResponse);
const successSearchWithNoMatchingMilestones = jest
.fn()
.mockResolvedValue(projectMilestonesResponseWithNoMilestones);
- const milestoneSubscriptionHandler = jest
- .fn()
- .mockResolvedValue(workItemMilestoneSubscriptionResponse);
const successUpdateWorkItemMutationHandler = jest
.fn()
.mockResolvedValue(updateWorkItemMutationResponse);
- const showDropdown = () => {
- findDropdown().vm.$emit('shown');
- };
-
- const hideDropdown = () => {
- findDropdown().vm.$emit('hide');
- };
+ const showDropdown = () => findDropdown().vm.$emit('shown');
+ const hideDropdown = () => findDropdown().vm.$emit('hide');
const createComponent = ({
canUpdate = true,
milestone = mockMilestoneWidgetResponse,
searchQueryHandler = successSearchQueryHandler,
- fetchByIid = false,
mutationHandler = successUpdateWorkItemMutationHandler,
} = {}) => {
- const apolloProvider = createMockApollo(
- [
- [workItemQuery, workItemQueryHandler],
- [workItemMilestoneSubscription, milestoneSubscriptionHandler],
+ wrapper = shallowMountExtended(WorkItemMilestone, {
+ apolloProvider: createMockApollo([
[projectMilestonesQuery, searchQueryHandler],
[updateWorkItemMutation, mutationHandler],
- [workItemByIidQuery, workItemByIidResponseHandler],
- ],
- resolvers,
- {
- typePolicies: config.cacheConfig.typePolicies,
- },
- );
-
- apolloProvider.clients.defaultClient.writeQuery({
- query: workItemQuery,
- variables: {
- id: workItemId,
+ ]),
+ provide: {
+ fullPath: 'full-path',
},
- data: workItemQueryResponse.data,
- });
-
- wrapper = shallowMountExtended(WorkItemMilestone, {
- apolloProvider,
propsData: {
canUpdate,
workItemMilestone: milestone,
workItemId,
workItemType,
- fullPath,
- queryVariables: {
- id: workItemId,
- },
- fetchByIid,
},
stubs: {
GlDropdown,
@@ -244,7 +200,7 @@ describe('WorkItemMilestone component', () => {
it.each`
errorType | expectedErrorMessage | mockValue | resolveFunction
${'graphql error'} | ${'Something went wrong while updating the task. Please try again.'} | ${updateWorkItemMutationErrorResponse} | ${'mockResolvedValue'}
- ${'network error'} | ${'Something went wrong while updating the task. Please try again.'} | ${networkResolvedValue} | ${'mockRejectedValue'}
+ ${'network error'} | ${'Something went wrong while updating the task. Please try again.'} | ${new Error()} | ${'mockRejectedValue'}
`(
'emits an error when there is a $errorType',
async ({ mockValue, expectedErrorMessage, resolveFunction }) => {
diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js
index 3db848a0ad2..c2821cc99f9 100644
--- a/spec/frontend/work_items/components/work_item_notes_spec.js
+++ b/spec/frontend/work_items/components/work_item_notes_spec.js
@@ -9,34 +9,37 @@ import SystemNote from '~/work_items/components/notes/system_note.vue';
import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue';
-import ActivityFilter from '~/work_items/components/notes/activity_filter.vue';
-import workItemNotesQuery from '~/work_items/graphql/notes/work_item_notes.query.graphql';
+import WorkItemNotesActivityHeader from '~/work_items/components/notes/work_item_notes_activity_header.vue';
import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql';
import deleteWorkItemNoteMutation from '~/work_items/graphql/notes/delete_work_item_notes.mutation.graphql';
+import workItemNoteCreatedSubscription from '~/work_items/graphql/notes/work_item_note_created.subscription.graphql';
+import workItemNoteUpdatedSubscription from '~/work_items/graphql/notes/work_item_note_updated.subscription.graphql';
+import workItemNoteDeletedSubscription from '~/work_items/graphql/notes/work_item_note_deleted.subscription.graphql';
import { DEFAULT_PAGE_SIZE_NOTES, WIDGET_TYPE_NOTES } from '~/work_items/constants';
import { ASC, DESC } from '~/notes/constants';
+import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils';
import {
mockWorkItemNotesResponse,
workItemQueryResponse,
mockWorkItemNotesByIidResponse,
mockMoreWorkItemNotesResponse,
mockWorkItemNotesResponseWithComments,
+ workItemNotesCreateSubscriptionResponse,
+ workItemNotesUpdateSubscriptionResponse,
+ workItemNotesDeleteSubscriptionResponse,
} from '../mock_data';
const mockWorkItemId = workItemQueryResponse.data.workItem.id;
+const mockWorkItemIid = workItemQueryResponse.data.workItem.iid;
const mockNotesWidgetResponse = mockWorkItemNotesResponse.data.workItem.widgets.find(
(widget) => widget.type === WIDGET_TYPE_NOTES,
);
-const mockNotesByIidWidgetResponse = mockWorkItemNotesByIidResponse.data.workspace.workItems.nodes[0].widgets.find(
+const mockMoreNotesWidgetResponse = mockMoreWorkItemNotesResponse.data.workspace.workItems.nodes[0].widgets.find(
(widget) => widget.type === WIDGET_TYPE_NOTES,
);
-const mockMoreNotesWidgetResponse = mockMoreWorkItemNotesResponse.data.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_NOTES,
-);
-
-const mockWorkItemNotesWidgetResponseWithComments = mockWorkItemNotesResponseWithComments.data.workItem.widgets.find(
+const mockWorkItemNotesWidgetResponseWithComments = mockWorkItemNotesResponseWithComments.data.workspace.workItems.nodes[0].widgets.find(
(widget) => widget.type === WIDGET_TYPE_NOTES,
);
@@ -53,19 +56,14 @@ describe('WorkItemNotes component', () => {
const findAllSystemNotes = () => wrapper.findAllComponents(SystemNote);
const findAllListItems = () => wrapper.findAll('ul.timeline > *');
- const findActivityLabel = () => wrapper.find('label');
- const findWorkItemAddNote = () => wrapper.findComponent(WorkItemAddNote);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
- const findSortingFilter = () => wrapper.findComponent(ActivityFilter);
+ const findActivityHeader = () => wrapper.findComponent(WorkItemNotesActivityHeader);
const findSystemNoteAtIndex = (index) => findAllSystemNotes().at(index);
const findAllWorkItemCommentNotes = () => wrapper.findAllComponents(WorkItemDiscussion);
const findWorkItemCommentNoteAtIndex = (index) => findAllWorkItemCommentNotes().at(index);
const findDeleteNoteModal = () => wrapper.findComponent(GlModal);
- const workItemNotesQueryHandler = jest.fn().mockResolvedValue(mockWorkItemNotesResponse);
- const workItemNotesByIidQueryHandler = jest
- .fn()
- .mockResolvedValue(mockWorkItemNotesByIidResponse);
+ const workItemNotesQueryHandler = jest.fn().mockResolvedValue(mockWorkItemNotesByIidResponse);
const workItemMoreNotesQueryHandler = jest.fn().mockResolvedValue(mockMoreWorkItemNotesResponse);
const workItemNotesWithCommentsQueryHandler = jest
.fn()
@@ -73,33 +71,41 @@ describe('WorkItemNotes component', () => {
const deleteWorkItemNoteMutationSuccessHandler = jest.fn().mockResolvedValue({
data: { destroyNote: { note: null, __typename: 'DestroyNote' } },
});
+ const notesCreateSubscriptionHandler = jest
+ .fn()
+ .mockResolvedValue(workItemNotesCreateSubscriptionResponse);
+ const notesUpdateSubscriptionHandler = jest
+ .fn()
+ .mockResolvedValue(workItemNotesUpdateSubscriptionResponse);
+ const notesDeleteSubscriptionHandler = jest
+ .fn()
+ .mockResolvedValue(workItemNotesDeleteSubscriptionResponse);
const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const createComponent = ({
workItemId = mockWorkItemId,
- fetchByIid = false,
+ workItemIid = mockWorkItemIid,
defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler,
deleteWINoteMutationHandler = deleteWorkItemNoteMutationSuccessHandler,
+ isModal = false,
} = {}) => {
wrapper = shallowMount(WorkItemNotes, {
apolloProvider: createMockApollo([
- [workItemNotesQuery, defaultWorkItemNotesQueryHandler],
- [workItemNotesByIidQuery, workItemNotesByIidQueryHandler],
+ [workItemNotesByIidQuery, defaultWorkItemNotesQueryHandler],
[deleteWorkItemNoteMutation, deleteWINoteMutationHandler],
+ [workItemNoteCreatedSubscription, notesCreateSubscriptionHandler],
+ [workItemNoteUpdatedSubscription, notesUpdateSubscriptionHandler],
+ [workItemNoteDeletedSubscription, notesDeleteSubscriptionHandler],
]),
+ provide: {
+ fullPath: 'test-path',
+ },
propsData: {
workItemId,
- queryVariables: {
- id: workItemId,
- },
- fullPath: 'test-path',
- fetchByIid,
+ workItemIid,
workItemType: 'task',
- },
- provide: {
- glFeatures: {
- useIidInWorkItemsPath: fetchByIid,
- },
+ reportAbusePath: '/report/abuse/path',
+ isModal,
},
stubs: {
GlModal: stubComponent(GlModal, { methods: { show: showModal } }),
@@ -107,23 +113,12 @@ describe('WorkItemNotes component', () => {
});
};
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
});
- it('renders activity label', () => {
- expect(findActivityLabel().exists()).toBe(true);
- });
-
- it('passes correct props to comment form component', async () => {
- createComponent({
- workItemId: mockWorkItemId,
- fetchByIid: false,
- defaultWorkItemNotesQueryHandler: workItemNotesByIidQueryHandler,
- });
- await waitForPromises();
-
- expect(findWorkItemAddNote().props('fetchByIid')).toEqual(false);
+ it('has the work item note activity header', () => {
+ expect(findActivityHeader().exists()).toBe(true);
});
describe('when notes are loading', () => {
@@ -143,28 +138,16 @@ describe('WorkItemNotes component', () => {
it('renders system notes to the length of the response', async () => {
await waitForPromises();
+ expect(workItemNotesQueryHandler).toHaveBeenCalledWith({
+ after: undefined,
+ fullPath: 'test-path',
+ iid: '1',
+ pageSize: 30,
+ });
expect(findAllSystemNotes()).toHaveLength(mockNotesWidgetResponse.discussions.nodes.length);
});
});
- describe('when the notes are fetched by `iid`', () => {
- beforeEach(async () => {
- createComponent({ workItemId: mockWorkItemId, fetchByIid: true });
- await waitForPromises();
- });
-
- it('renders the notes list to the length of the response', () => {
- expect(workItemNotesByIidQueryHandler).toHaveBeenCalled();
- expect(findAllSystemNotes()).toHaveLength(
- mockNotesByIidWidgetResponse.discussions.nodes.length,
- );
- });
-
- it('passes correct props to comment form component', () => {
- expect(findWorkItemAddNote().props('fetchByIid')).toEqual(true);
- });
- });
-
describe('Pagination', () => {
describe('When there is no next page', () => {
it('fetch more notes is not called', async () => {
@@ -182,15 +165,17 @@ describe('WorkItemNotes component', () => {
it('fetch more notes should be called', async () => {
expect(workItemMoreNotesQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'test-path',
+ iid: '1',
pageSize: DEFAULT_PAGE_SIZE_NOTES,
- id: 'gid://gitlab/WorkItem/1',
});
await nextTick();
expect(workItemMoreNotesQueryHandler).toHaveBeenCalledWith({
- pageSize: 45,
- id: 'gid://gitlab/WorkItem/1',
+ fullPath: 'test-path',
+ iid: '1',
+ pageSize: DEFAULT_PAGE_SIZE_NOTES,
after: mockMoreNotesWidgetResponse.discussions.pageInfo.endCursor,
});
});
@@ -203,26 +188,22 @@ describe('WorkItemNotes component', () => {
await waitForPromises();
});
- it('filter exists', () => {
- expect(findSortingFilter().exists()).toBe(true);
- });
-
- it('sorts the list when the `changeSortOrder` event is emitted', async () => {
+ it('sorts the list when the `changeSort` event is emitted', async () => {
expect(findSystemNoteAtIndex(0).props('note').id).toEqual(firstSystemNodeId);
- await findSortingFilter().vm.$emit('changeSortOrder', DESC);
+ await findActivityHeader().vm.$emit('changeSort', DESC);
expect(findSystemNoteAtIndex(0).props('note').id).not.toEqual(firstSystemNodeId);
});
it('puts form at start of list in when sorting by newest first', async () => {
- await findSortingFilter().vm.$emit('changeSortOrder', DESC);
+ await findActivityHeader().vm.$emit('changeSort', DESC);
expect(findAllListItems().at(0).is(WorkItemAddNote)).toEqual(true);
});
it('puts form at end of list in when sorting by oldest first', async () => {
- await findSortingFilter().vm.$emit('changeSortOrder', ASC);
+ await findActivityHeader().vm.$emit('changeSort', ASC);
expect(findAllListItems().at(-1).is(WorkItemAddNote)).toEqual(true);
});
@@ -250,9 +231,11 @@ describe('WorkItemNotes component', () => {
const commentIndex = 0;
const firstCommentNote = findWorkItemCommentNoteAtIndex(commentIndex);
- expect(firstCommentNote.props('discussion')).toEqual(
- mockDiscussions[commentIndex].notes.nodes,
- );
+ expect(firstCommentNote.props()).toMatchObject({
+ discussion: mockDiscussions[commentIndex].notes.nodes,
+ autocompleteDataSources: autocompleteDataSources('test-path', mockWorkItemIid),
+ markdownPreviewPath: markdownPreviewPath('test-path', mockWorkItemIid),
+ });
});
});
@@ -334,4 +317,31 @@ describe('WorkItemNotes component', () => {
['Something went wrong when deleting a comment. Please try again'],
]);
});
+
+ describe('Notes subscriptions', () => {
+ beforeEach(async () => {
+ createComponent({
+ defaultWorkItemNotesQueryHandler: workItemNotesWithCommentsQueryHandler,
+ });
+ await waitForPromises();
+ });
+
+ it('has create notes subscription', () => {
+ expect(notesCreateSubscriptionHandler).toHaveBeenCalledWith({
+ noteableId: mockWorkItemId,
+ });
+ });
+
+ it('has delete notes subscription', () => {
+ expect(notesDeleteSubscriptionHandler).toHaveBeenCalledWith({
+ noteableId: mockWorkItemId,
+ });
+ });
+
+ it('has update notes subscription', () => {
+ expect(notesUpdateSubscriptionHandler).toHaveBeenCalledWith({
+ noteableId: mockWorkItemId,
+ });
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_state_spec.js b/spec/frontend/work_items/components/work_item_state_spec.js
index b24d940d56a..d1262057c73 100644
--- a/spec/frontend/work_items/components/work_item_state_spec.js
+++ b/spec/frontend/work_items/components/work_item_state_spec.js
@@ -44,10 +44,6 @@ describe('WorkItemState component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders state', () => {
createComponent();
diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js
index a549aad5cd8..34391b74cf7 100644
--- a/spec/frontend/work_items/components/work_item_title_spec.js
+++ b/spec/frontend/work_items/components/work_item_title_spec.js
@@ -41,10 +41,6 @@ describe('WorkItemTitle component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders title', () => {
createComponent();
diff --git a/spec/frontend/work_items/components/work_item_todos_spec.js b/spec/frontend/work_items/components/work_item_todos_spec.js
new file mode 100644
index 00000000000..83b61a04298
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_todos_spec.js
@@ -0,0 +1,97 @@
+import { GlButton, GlIcon } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import WorkItemTodos from '~/work_items/components/work_item_todos.vue';
+import { ADD, TODO_DONE_ICON, TODO_ADD_ICON } from '~/work_items/constants';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import { updateGlobalTodoCount } from '~/sidebar/utils';
+import { workItemResponseFactory, updateWorkItemMutationResponseFactory } from '../mock_data';
+
+jest.mock('~/sidebar/utils');
+
+describe('WorkItemTodo component', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+
+ const findTodoWidget = () => wrapper.findComponent(GlButton);
+ const findTodoIcon = () => wrapper.findComponent(GlIcon);
+
+ const errorMessage = 'Failed to add item';
+ const workItemQueryResponse = workItemResponseFactory({ canUpdate: true });
+ const successHandler = jest
+ .fn()
+ .mockResolvedValue(updateWorkItemMutationResponseFactory({ canUpdate: true }));
+ const failureHandler = jest.fn().mockRejectedValue(new Error(errorMessage));
+
+ const inputVariables = {
+ id: 'gid://gitlab/WorkItem/1',
+ currentUserTodosWidget: {
+ action: ADD,
+ },
+ };
+
+ const createComponent = ({
+ currentUserTodosMock = [updateWorkItemMutation, successHandler],
+ currentUserTodos = [],
+ } = {}) => {
+ const handlers = [currentUserTodosMock];
+ wrapper = shallowMountExtended(WorkItemTodos, {
+ apolloProvider: createMockApollo(handlers),
+ propsData: {
+ workItem: workItemQueryResponse.data.workItem,
+ currentUserTodos,
+ },
+ });
+ };
+
+ it('renders the widget', () => {
+ createComponent();
+
+ expect(findTodoWidget().exists()).toBe(true);
+ expect(findTodoIcon().props('name')).toEqual(TODO_ADD_ICON);
+ expect(findTodoIcon().classes('gl-fill-blue-500')).toBe(false);
+ });
+
+ it('renders mark as done button when there is pending item', () => {
+ createComponent({
+ currentUserTodos: [
+ {
+ node: {
+ id: 'gid://gitlab/Todo/1',
+ state: 'pending',
+ },
+ },
+ ],
+ });
+
+ expect(findTodoIcon().props('name')).toEqual(TODO_DONE_ICON);
+ expect(findTodoIcon().classes('gl-fill-blue-500')).toBe(true);
+ });
+
+ it('calls update mutation when to do button is clicked', async () => {
+ createComponent();
+
+ findTodoWidget().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(successHandler).toHaveBeenCalledWith({
+ input: inputVariables,
+ });
+ expect(updateGlobalTodoCount).toHaveBeenCalled();
+ });
+
+ it('emits error when the update mutation fails', async () => {
+ createComponent({ currentUserTodosMock: [updateWorkItemMutation, failureHandler] });
+
+ findTodoWidget().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[errorMessage]]);
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_type_icon_spec.js b/spec/frontend/work_items/components/work_item_type_icon_spec.js
index 182fb0f8cb6..a5e955c4dbf 100644
--- a/spec/frontend/work_items/components/work_item_type_icon_spec.js
+++ b/spec/frontend/work_items/components/work_item_type_icon_spec.js
@@ -9,7 +9,7 @@ function createComponent(propsData) {
wrapper = shallowMount(WorkItemTypeIcon, {
propsData,
directives: {
- GlTooltip: createMockDirective(),
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
}
@@ -17,10 +17,6 @@ function createComponent(propsData) {
describe('Work Item type component', () => {
const findIcon = () => wrapper.findComponent(GlIcon);
- afterEach(() => {
- wrapper.destroy();
- });
-
describe.each`
workItemType | workItemIconName | iconName | text | showTooltipOnHover
${'TASK'} | ${''} | ${'issue-type-task'} | ${'Task'} | ${false}
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index d4832fe376d..05c6a21bb38 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -46,6 +46,29 @@ export const mockMilestone = {
dueDate: '2022-10-24',
};
+export const mockAwardEmojiThumbsUp = {
+ name: 'thumbsup',
+ __typename: 'AwardEmoji',
+ user: {
+ id: 'gid://gitlab/User/5',
+ __typename: 'UserCore',
+ },
+};
+
+export const mockAwardEmojiThumbsDown = {
+ name: 'thumbsdown',
+ __typename: 'AwardEmoji',
+ user: {
+ id: 'gid://gitlab/User/5',
+ __typename: 'UserCore',
+ },
+};
+
+export const mockAwardsWidget = {
+ nodes: [mockAwardEmojiThumbsUp, mockAwardEmojiThumbsDown],
+ __typename: 'AwardEmojiConnection',
+};
+
export const workItemQueryResponse = {
data: {
workItem: {
@@ -82,6 +105,9 @@ export const workItemQueryResponse = {
userPermissions: {
deleteWorkItem: false,
updateWorkItem: false,
+ setWorkItemMetadata: false,
+ adminParentLink: false,
+ __typename: 'WorkItemPermissions',
},
widgets: [
{
@@ -182,6 +208,9 @@ export const updateWorkItemMutationResponse = {
userPermissions: {
deleteWorkItem: false,
updateWorkItem: false,
+ setWorkItemMetadata: false,
+ adminParentLink: false,
+ __typename: 'WorkItemPermissions',
},
widgets: [
{
@@ -239,6 +268,100 @@ export const updateWorkItemMutationErrorResponse = {
},
};
+export const convertWorkItemMutationErrorResponse = {
+ data: {
+ workItemConvert: {
+ __typename: 'WorkItemConvertPayload',
+ errors: ['Error!'],
+ workItem: {},
+ },
+ },
+};
+
+export const convertWorkItemMutationResponse = {
+ data: {
+ workItemConvert: {
+ __typename: 'WorkItemConvertPayload',
+ errors: [],
+ workItem: {
+ __typename: 'WorkItem',
+ id: 'gid://gitlab/WorkItem/1',
+ iid: '1',
+ title: 'Updated title',
+ state: 'OPEN',
+ description: 'description',
+ confidential: false,
+ createdAt: '2022-08-03T12:41:54Z',
+ updatedAt: '2022-08-08T12:41:54Z',
+ closedAt: null,
+ author: {
+ ...mockAssignees[0],
+ },
+ project: {
+ __typename: 'Project',
+ id: '1',
+ fullPath: 'test-project-path',
+ archived: false,
+ },
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/4',
+ name: 'Objective',
+ iconName: 'issue-type-objective',
+ },
+ userPermissions: {
+ deleteWorkItem: false,
+ updateWorkItem: false,
+ setWorkItemMetadata: false,
+ adminParentLink: false,
+ __typename: 'WorkItemPermissions',
+ },
+ widgets: [
+ {
+ type: 'HIERARCHY',
+ children: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/444',
+ iid: '4',
+ createdAt: '2022-08-03T12:41:54Z',
+ closedAt: null,
+ confidential: false,
+ title: '123',
+ state: 'OPEN',
+ workItemType: {
+ id: '1',
+ name: 'Task',
+ iconName: 'issue-type-task',
+ },
+ },
+ ],
+ },
+ __typename: 'WorkItemConnection',
+ },
+ {
+ __typename: 'WorkItemWidgetAssignees',
+ type: 'ASSIGNEES',
+ allowsMultipleAssignees: true,
+ canInviteMembers: true,
+ assignees: {
+ nodes: [mockAssignees[0]],
+ },
+ },
+ {
+ __typename: 'WorkItemWidgetLabels',
+ type: 'LABELS',
+ allowsScopedLabels: false,
+ labels: {
+ nodes: mockLabels,
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
export const mockParent = {
parent: {
id: 'gid://gitlab/Issue/1',
@@ -284,6 +407,11 @@ export const objectiveType = {
export const workItemResponseFactory = ({
canUpdate = false,
canDelete = false,
+ adminParentLink = false,
+ notificationsWidgetPresent = true,
+ currentUserTodosWidgetPresent = true,
+ awardEmojiWidgetPresent = true,
+ subscribed = true,
allowsMultipleAssignees = true,
assigneesWidgetPresent = true,
datesWidgetPresent = true,
@@ -306,12 +434,13 @@ export const workItemResponseFactory = ({
author = mockAssignees[0],
createdAt = '2022-08-03T12:41:54Z',
updatedAt = '2022-08-08T12:32:54Z',
+ awardEmoji = mockAwardsWidget,
} = {}) => ({
data: {
workItem: {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
- iid: 1,
+ iid: '1',
title: 'Updated title',
state: 'OPEN',
description: 'description',
@@ -330,6 +459,9 @@ export const workItemResponseFactory = ({
userPermissions: {
deleteWorkItem: canDelete,
updateWorkItem: canUpdate,
+ setWorkItemMetadata: canUpdate,
+ adminParentLink,
+ __typename: 'WorkItemPermissions',
},
widgets: [
{
@@ -466,30 +598,87 @@ export const workItemResponseFactory = ({
type: 'NOTES',
}
: { type: 'MOCK TYPE' },
+ notificationsWidgetPresent
+ ? {
+ __typename: 'WorkItemWidgetNotifications',
+ type: 'NOTIFICATIONS',
+ subscribed,
+ }
+ : { type: 'MOCK TYPE' },
+ currentUserTodosWidgetPresent
+ ? {
+ type: 'CURRENT_USER_TODOS',
+ currentUserTodos: {
+ edges: [
+ {
+ node: {
+ id: 'gid://gitlab/Todo/1',
+ state: 'pending',
+ __typename: 'Todo',
+ },
+ __typename: 'TodoEdge',
+ },
+ ],
+ __typename: 'TodoConnection',
+ },
+ __typename: 'WorkItemWidgetCurrentUserTodos',
+ }
+ : { type: 'MOCK TYPE' },
+ awardEmojiWidgetPresent
+ ? {
+ __typename: 'WorkItemWidgetAwardEmoji',
+ type: 'AWARD_EMOJI',
+ awardEmoji,
+ }
+ : { type: 'MOCK TYPE' },
],
},
},
});
+export const workItemByIidResponseFactory = (options) => {
+ const response = workItemResponseFactory(options);
+ return {
+ data: {
+ workspace: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/1',
+ workItems: {
+ nodes: [response.data.workItem],
+ },
+ },
+ },
+ };
+};
+
+export const updateWorkItemMutationResponseFactory = (options) => {
+ const response = workItemResponseFactory(options);
+ return {
+ data: {
+ workItemUpdate: {
+ workItem: response.data.workItem,
+ errors: [],
+ },
+ },
+ };
+};
+
export const getIssueDetailsResponse = ({ confidential = false } = {}) => ({
data: {
- workspace: {
- id: 'gid://gitlab/Project/1',
- issuable: {
- id: 'gid://gitlab/Issue/4',
- confidential,
- iteration: {
- id: 'gid://gitlab/Iteration/1124',
- __typename: 'Iteration',
- },
- milestone: {
- id: 'gid://gitlab/Milestone/28',
- __typename: 'Milestone',
- },
- __typename: 'Issue',
+ issue: {
+ id: 'gid://gitlab/Issue/4',
+ confidential,
+ iteration: {
+ id: 'gid://gitlab/Iteration/1124',
+ __typename: 'Iteration',
},
- __typename: 'Project',
+ milestone: {
+ id: 'gid://gitlab/Milestone/28',
+ __typename: 'Milestone',
+ },
+ __typename: 'Issue',
},
+ __typename: 'Project',
},
});
@@ -503,6 +692,8 @@ export const projectWorkItemTypesQueryResponse = {
{ id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue' },
{ id: 'gid://gitlab/WorkItems::Type/2', name: 'Incident' },
{ id: 'gid://gitlab/WorkItems::Type/3', name: 'Task' },
+ { id: 'gid://gitlab/WorkItems::Type/4', name: 'Objective' },
+ { id: 'gid://gitlab/WorkItems::Type/5', name: 'Key Result' },
],
},
},
@@ -542,6 +733,9 @@ export const createWorkItemMutationResponse = {
userPermissions: {
deleteWorkItem: false,
updateWorkItem: false,
+ setWorkItemMetadata: false,
+ adminParentLink: false,
+ __typename: 'WorkItemPermissions',
},
widgets: [],
},
@@ -560,83 +754,6 @@ export const createWorkItemMutationErrorResponse = {
},
};
-export const createWorkItemFromTaskMutationResponse = {
- data: {
- workItemCreateFromTask: {
- __typename: 'WorkItemCreateFromTaskPayload',
- errors: [],
- workItem: {
- __typename: 'WorkItem',
- description: 'New description',
- id: 'gid://gitlab/WorkItem/1',
- iid: '1',
- title: 'Updated title',
- state: 'OPEN',
- confidential: false,
- createdAt: '2022-08-03T12:41:54Z',
- closedAt: null,
- project: {
- __typename: 'Project',
- id: '1',
- fullPath: 'test-project-path',
- archived: false,
- },
- workItemType: {
- __typename: 'WorkItemType',
- id: 'gid://gitlab/WorkItems::Type/5',
- name: 'Task',
- iconName: 'issue-type-task',
- },
- userPermissions: {
- deleteWorkItem: false,
- updateWorkItem: false,
- },
- widgets: [
- {
- __typename: 'WorkItemWidgetDescription',
- type: 'DESCRIPTION',
- description: 'New description',
- descriptionHtml: '<p>New description</p>',
- lastEditedAt: '2022-09-21T06:18:42Z',
- lastEditedBy: {
- name: 'Administrator',
- webPath: '/root',
- },
- },
- ],
- },
- newWorkItem: {
- __typename: 'WorkItem',
- id: 'gid://gitlab/WorkItem/1000000',
- iid: '100',
- title: 'Updated title',
- state: 'OPEN',
- createdAt: '2022-08-03T12:41:54Z',
- closedAt: null,
- description: '',
- confidential: false,
- project: {
- __typename: 'Project',
- id: '1',
- fullPath: 'test-project-path',
- archived: false,
- },
- workItemType: {
- __typename: 'WorkItemType',
- id: 'gid://gitlab/WorkItems::Type/5',
- name: 'Task',
- iconName: 'issue-type-task',
- },
- userPermissions: {
- deleteWorkItem: false,
- updateWorkItem: false,
- },
- widgets: [],
- },
- },
- },
-};
-
export const deleteWorkItemResponse = {
data: { workItemDelete: { errors: [], __typename: 'WorkItemDeletePayload' } },
};
@@ -661,24 +778,6 @@ export const deleteWorkItemMutationErrorResponse = {
},
};
-export const deleteWorkItemFromTaskMutationResponse = {
- data: {
- workItemDeleteTask: {
- workItem: { id: 123, descriptionHtml: 'updated work item desc' },
- errors: [],
- },
- },
-};
-
-export const deleteWorkItemFromTaskMutationErrorResponse = {
- data: {
- workItemDeleteTask: {
- workItem: { id: 123, descriptionHtml: 'updated work item desc' },
- errors: ['Error'],
- },
- },
-};
-
export const workItemDatesSubscriptionResponse = {
data: {
issuableDatesUpdated: {
@@ -831,15 +930,20 @@ export const workItemHierarchyEmptyResponse = {
data: {
workItem: {
id: 'gid://gitlab/WorkItem/1',
+ iid: '1',
+ state: 'OPEN',
workItemType: {
- id: 'gid://gitlab/WorkItems::Type/6',
+ id: 'gid://gitlab/WorkItems::Type/1',
name: 'Issue',
iconName: 'issue-type-issue',
__typename: 'WorkItemType',
},
title: 'New title',
+ description: '',
createdAt: '2022-08-03T12:41:54Z',
+ updatedAt: null,
closedAt: null,
+ author: mockAssignees[0],
project: {
__typename: 'Project',
id: '1',
@@ -849,14 +953,13 @@ export const workItemHierarchyEmptyResponse = {
userPermissions: {
deleteWorkItem: false,
updateWorkItem: false,
+ setWorkItemMetadata: false,
+ adminParentLink: false,
+ __typename: 'WorkItemPermissions',
},
confidential: false,
widgets: [
{
- type: 'DESCRIPTION',
- __typename: 'WorkItemWidgetDescription',
- },
- {
type: 'HIERARCHY',
parent: null,
hasChildren: false,
@@ -876,6 +979,8 @@ export const workItemHierarchyNoUpdatePermissionResponse = {
data: {
workItem: {
id: 'gid://gitlab/WorkItem/1',
+ iid: '1',
+ state: 'OPEN',
workItemType: {
id: 'gid://gitlab/WorkItems::Type/6',
name: 'Issue',
@@ -883,9 +988,17 @@ export const workItemHierarchyNoUpdatePermissionResponse = {
__typename: 'WorkItemType',
},
title: 'New title',
+ description: '',
+ createdAt: '2022-08-03T12:41:54Z',
+ updatedAt: null,
+ closedAt: null,
+ author: mockAssignees[0],
userPermissions: {
deleteWorkItem: false,
updateWorkItem: false,
+ setWorkItemMetadata: false,
+ adminParentLink: false,
+ __typename: 'WorkItemPermissions',
},
project: {
__typename: 'Project',
@@ -896,10 +1009,6 @@ export const workItemHierarchyNoUpdatePermissionResponse = {
confidential: false,
widgets: [
{
- type: 'DESCRIPTION',
- __typename: 'WorkItemWidgetDescription',
- },
- {
type: 'HIERARCHY',
parent: null,
hasChildren: true,
@@ -952,6 +1061,7 @@ export const workItemTask = {
confidential: false,
createdAt: '2022-08-03T12:41:54Z',
closedAt: null,
+ widgets: [],
__typename: 'WorkItem',
};
@@ -969,6 +1079,7 @@ export const confidentialWorkItemTask = {
confidential: true,
createdAt: '2022-08-03T12:41:54Z',
closedAt: null,
+ widgets: [],
__typename: 'WorkItem',
};
@@ -986,6 +1097,7 @@ export const closedWorkItemTask = {
confidential: false,
createdAt: '2022-08-03T12:41:54Z',
closedAt: '2022-08-12T13:07:52Z',
+ widgets: [],
__typename: 'WorkItem',
};
@@ -1007,6 +1119,7 @@ export const childrenWorkItems = [
confidential: false,
createdAt: '2022-08-03T12:41:54Z',
closedAt: null,
+ widgets: [],
__typename: 'WorkItem',
},
];
@@ -1017,15 +1130,21 @@ export const workItemHierarchyResponse = {
id: 'gid://gitlab/WorkItem/1',
iid: '1',
workItemType: {
- id: 'gid://gitlab/WorkItems::Type/6',
- name: 'Objective',
- iconName: 'issue-type-objective',
+ id: 'gid://gitlab/WorkItems::Type/1',
+ name: 'Issue',
+ iconName: 'issue-type-issue',
__typename: 'WorkItemType',
},
title: 'New title',
userPermissions: {
deleteWorkItem: true,
updateWorkItem: true,
+ setWorkItemMetadata: true,
+ adminParentLink: true,
+ __typename: 'WorkItemPermissions',
+ },
+ author: {
+ ...mockAssignees[0],
},
confidential: false,
project: {
@@ -1034,12 +1153,13 @@ export const workItemHierarchyResponse = {
fullPath: 'test-project-path',
archived: false,
},
+ description: 'Issue description',
+ state: 'OPEN',
+ createdAt: '2022-08-03T12:41:54Z',
+ updatedAt: null,
+ closedAt: null,
widgets: [
{
- type: 'DESCRIPTION',
- __typename: 'WorkItemWidgetDescription',
- },
- {
type: 'HIERARCHY',
parent: null,
hasChildren: true,
@@ -1110,6 +1230,9 @@ export const workItemObjectiveWithChild = {
userPermissions: {
deleteWorkItem: true,
updateWorkItem: true,
+ setWorkItemMetadata: true,
+ adminParentLink: true,
+ __typename: 'WorkItemPermissions',
},
author: {
...mockAssignees[0],
@@ -1176,6 +1299,9 @@ export const workItemHierarchyTreeResponse = {
userPermissions: {
deleteWorkItem: true,
updateWorkItem: true,
+ setWorkItemMetadata: true,
+ adminParentLink: true,
+ __typename: 'WorkItemPermissions',
},
confidential: false,
project: {
@@ -1238,6 +1364,69 @@ export const workItemHierarchyTreeFailureResponse = {
],
};
+export const changeIndirectWorkItemParentMutationResponse = {
+ data: {
+ workItemUpdate: {
+ workItem: {
+ __typename: 'WorkItem',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/2411',
+ name: 'Objective',
+ iconName: 'issue-type-objective',
+ __typename: 'WorkItemType',
+ },
+ userPermissions: {
+ deleteWorkItem: true,
+ updateWorkItem: true,
+ setWorkItemMetadata: true,
+ adminParentLink: true,
+ __typename: 'WorkItemPermissions',
+ },
+ description: null,
+ id: 'gid://gitlab/WorkItem/13',
+ iid: '13',
+ state: 'OPEN',
+ title: 'Objective 2',
+ confidential: false,
+ createdAt: '2022-08-03T12:41:54Z',
+ updatedAt: null,
+ closedAt: null,
+ author: {
+ ...mockAssignees[0],
+ },
+ project: {
+ __typename: 'Project',
+ id: '1',
+ fullPath: 'test-project-path',
+ archived: false,
+ },
+ widgets: [
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ type: 'HIERARCHY',
+ parent: null,
+ hasChildren: false,
+ children: {
+ nodes: [],
+ },
+ },
+ ],
+ },
+ errors: [],
+ __typename: 'WorkItemUpdatePayload',
+ },
+ },
+};
+
+export const workItemUpdateFailureResponse = {
+ data: {},
+ errors: [
+ {
+ message: 'Something went wrong',
+ },
+ ],
+};
+
export const changeWorkItemParentMutationResponse = {
data: {
workItemUpdate: {
@@ -1252,6 +1441,9 @@ export const changeWorkItemParentMutationResponse = {
userPermissions: {
deleteWorkItem: true,
updateWorkItem: true,
+ setWorkItemMetadata: true,
+ adminParentLink: true,
+ __typename: 'WorkItemPermissions',
},
description: null,
id: 'gid://gitlab/WorkItem/2',
@@ -1617,17 +1809,6 @@ export const projectMilestonesResponseWithNoMilestones = {
},
};
-export const projectWorkItemResponse = {
- data: {
- workspace: {
- id: 'gid://gitlab/Project/1',
- workItems: {
- nodes: [workItemQueryResponse.data.workItem],
- },
- },
- },
-};
-
export const mockWorkItemNotesResponse = {
data: {
workItem: {
@@ -1681,6 +1862,8 @@ export const mockWorkItemNotesResponse = {
systemNoteIconName: 'link',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_199',
lastEditedBy: null,
system: true,
internal: false,
@@ -1724,6 +1907,8 @@ export const mockWorkItemNotesResponse = {
systemNoteIconName: 'clock',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_201',
lastEditedBy: null,
system: true,
internal: false,
@@ -1766,6 +1951,8 @@ export const mockWorkItemNotesResponse = {
systemNoteIconName: 'weight',
createdAt: '2022-11-25T07:16:20Z',
lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_202',
lastEditedBy: null,
system: true,
internal: false,
@@ -1868,6 +2055,8 @@ export const mockWorkItemNotesByIidResponse = {
systemNoteIconName: 'link',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
lastEditedBy: null,
system: true,
internal: false,
@@ -1913,6 +2102,8 @@ export const mockWorkItemNotesByIidResponse = {
systemNoteIconName: 'clock',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
lastEditedBy: null,
system: true,
internal: false,
@@ -1959,6 +2150,8 @@ export const mockWorkItemNotesByIidResponse = {
systemNoteIconName: 'iteration',
createdAt: '2022-11-14T04:19:00Z',
lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
lastEditedBy: null,
system: true,
internal: false,
@@ -2008,180 +2201,197 @@ export const mockWorkItemNotesByIidResponse = {
};
export const mockMoreWorkItemNotesResponse = {
data: {
- workItem: {
- id: 'gid://gitlab/WorkItem/600',
- iid: '60',
- widgets: [
- {
- __typename: 'WorkItemWidgetIteration',
- },
- {
- __typename: 'WorkItemWidgetWeight',
- },
- {
- __typename: 'WorkItemWidgetAssignees',
- },
- {
- __typename: 'WorkItemWidgetLabels',
- },
- {
- __typename: 'WorkItemWidgetDescription',
- },
- {
- __typename: 'WorkItemWidgetHierarchy',
- },
- {
- __typename: 'WorkItemWidgetStartAndDueDate',
- },
- {
- __typename: 'WorkItemWidgetMilestone',
- },
- {
- type: 'NOTES',
- discussions: {
- pageInfo: {
- hasNextPage: true,
- hasPreviousPage: false,
- startCursor: null,
- endCursor: 'endCursor',
- __typename: 'PageInfo',
- },
- nodes: [
+ workspace: {
+ id: 'gid://gitlab/Project/6',
+ workItems: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/600',
+ iid: '60',
+ widgets: [
{
- id: 'gid://gitlab/Discussion/8bbc4890b6ff0f2cde93a5a0947cd2b8a13d3b6e',
- notes: {
- nodes: [
- {
- id: 'gid://gitlab/Note/2428',
- body: 'added #31 as parent issue',
- bodyHtml:
- '<p data-sourcepos="1:1-1:25" dir="auto">added <a href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container=body data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue">#31</a> as parent issue</p>',
- systemNoteIconName: 'link',
- createdAt: '2022-11-14T04:18:59Z',
- lastEditedAt: null,
- lastEditedBy: null,
- system: true,
- internal: false,
- discussion: {
- id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1112356a59e',
- },
- userPermissions: {
- adminNote: false,
- awardEmoji: true,
- readNote: true,
- createNote: true,
- resolveNote: true,
- repositionNote: true,
- __typename: 'NotePermissions',
- },
- author: {
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- id: 'gid://gitlab/User/1',
- name: 'Administrator',
- username: 'root',
- webUrl: 'http://127.0.0.1:3000/root',
- __typename: 'UserCore',
- },
- __typename: 'Note',
- },
- ],
- __typename: 'NoteConnection',
- },
- __typename: 'Discussion',
+ __typename: 'WorkItemWidgetIteration',
},
{
- id: 'gid://gitlab/Discussion/7b08b89a728a5ceb7de8334246837ba1d07270dc',
- notes: {
- nodes: [
- {
- id: 'gid://gitlab/MilestoneNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83823',
- body: 'changed milestone to %v4.0',
- bodyHtml:
- '<p data-sourcepos="1:1-1:23" dir="auto">changed milestone to <a href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container=body data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a></p>',
- systemNoteIconName: 'clock',
- createdAt: '2022-11-14T04:18:59Z',
- lastEditedAt: null,
- lastEditedBy: null,
- system: true,
- internal: false,
- discussion: {
- id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1272356a59e',
- },
- userPermissions: {
- adminNote: false,
- awardEmoji: true,
- readNote: true,
- createNote: true,
- resolveNote: true,
- repositionNote: true,
- __typename: 'NotePermissions',
- },
- author: {
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- id: 'gid://gitlab/User/1',
- name: 'Administrator',
- username: 'root',
- webUrl: 'http://127.0.0.1:3000/root',
- __typename: 'UserCore',
- },
- __typename: 'Note',
- },
- ],
- __typename: 'NoteConnection',
- },
- __typename: 'Discussion',
+ __typename: 'WorkItemWidgetWeight',
},
{
- id: 'gid://gitlab/Discussion/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864',
- notes: {
+ __typename: 'WorkItemWidgetAssignees',
+ },
+ {
+ __typename: 'WorkItemWidgetLabels',
+ },
+ {
+ __typename: 'WorkItemWidgetDescription',
+ },
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ },
+ {
+ __typename: 'WorkItemWidgetStartAndDueDate',
+ },
+ {
+ __typename: 'WorkItemWidgetMilestone',
+ },
+ {
+ type: 'NOTES',
+ discussions: {
+ pageInfo: {
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: null,
+ endCursor: 'endCursor',
+ __typename: 'PageInfo',
+ },
nodes: [
{
- id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864',
- body: 'changed weight to **89**',
- bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>',
- systemNoteIconName: 'weight',
- createdAt: '2022-11-25T07:16:20Z',
- lastEditedAt: null,
- lastEditedBy: null,
- system: true,
- internal: false,
- discussion: {
- id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876',
+ id: 'gid://gitlab/Discussion/8bbc4890b6ff0f2cde93a5a0947cd2b8a13d3b6e',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Note/2428',
+ body: 'added #31 as parent issue',
+ bodyHtml:
+ '<p data-sourcepos="1:1-1:25" dir="auto">added <a href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container=body data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue">#31</a> as parent issue</p>',
+ systemNoteIconName: 'link',
+ createdAt: '2022-11-14T04:18:59Z',
+ lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
+ lastEditedBy: null,
+ system: true,
+ internal: false,
+ discussion: {
+ id:
+ 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1112356a59e',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ author: {
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 'gid://gitlab/User/1',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
},
- userPermissions: {
- adminNote: false,
- awardEmoji: true,
- readNote: true,
- createNote: true,
- resolveNote: true,
- repositionNote: true,
- __typename: 'NotePermissions',
+ __typename: 'Discussion',
+ },
+ {
+ id: 'gid://gitlab/Discussion/7b08b89a728a5ceb7de8334246837ba1d07270dc',
+ notes: {
+ nodes: [
+ {
+ id:
+ 'gid://gitlab/MilestoneNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83823',
+ body: 'changed milestone to %v4.0',
+ bodyHtml:
+ '<p data-sourcepos="1:1-1:23" dir="auto">changed milestone to <a href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container=body data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a></p>',
+ systemNoteIconName: 'clock',
+ createdAt: '2022-11-14T04:18:59Z',
+ lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
+ lastEditedBy: null,
+ system: true,
+ internal: false,
+ discussion: {
+ id:
+ 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1272356a59e',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ author: {
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 'gid://gitlab/User/1',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
},
- author: {
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- id: 'gid://gitlab/User/1',
- name: 'Administrator',
- username: 'root',
- webUrl: 'http://127.0.0.1:3000/root',
- __typename: 'UserCore',
+ __typename: 'Discussion',
+ },
+ {
+ id: 'gid://gitlab/Discussion/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864',
+ body: 'changed weight to **89**',
+ bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>',
+ systemNoteIconName: 'weight',
+ createdAt: '2022-11-25T07:16:20Z',
+ lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
+ lastEditedBy: null,
+ system: true,
+ internal: false,
+ discussion: {
+ id:
+ 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ author: {
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 'gid://gitlab/User/1',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
},
- __typename: 'Note',
+ __typename: 'Discussion',
},
],
- __typename: 'NoteConnection',
+ __typename: 'DiscussionConnection',
},
- __typename: 'Discussion',
+ __typename: 'WorkItemWidgetNotes',
},
],
- __typename: 'DiscussionConnection',
+ __typename: 'WorkItem',
},
- __typename: 'WorkItemWidgetNotes',
- },
- ],
- __typename: 'WorkItem',
+ ],
+ },
},
},
};
@@ -2205,6 +2415,7 @@ export const createWorkItemNoteResponse = {
systemNoteIconName: null,
createdAt: '2023-01-25T04:49:46Z',
lastEditedAt: null,
+ url: 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
lastEditedBy: null,
discussion: {
id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
@@ -2252,6 +2463,7 @@ export const mockWorkItemCommentNote = {
systemNoteIconName: false,
createdAt: '2022-11-25T07:16:20Z',
lastEditedAt: null,
+ url: 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
lastEditedBy: null,
system: false,
internal: false,
@@ -2279,171 +2491,313 @@ export const mockWorkItemCommentNote = {
export const mockWorkItemNotesResponseWithComments = {
data: {
- workItem: {
- id: 'gid://gitlab/WorkItem/600',
- iid: '60',
- widgets: [
- {
- __typename: 'WorkItemWidgetIteration',
- },
- {
- __typename: 'WorkItemWidgetWeight',
- },
- {
- __typename: 'WorkItemWidgetAssignees',
- },
- {
- __typename: 'WorkItemWidgetLabels',
- },
- {
- __typename: 'WorkItemWidgetDescription',
- },
- {
- __typename: 'WorkItemWidgetHierarchy',
- },
- {
- __typename: 'WorkItemWidgetStartAndDueDate',
- },
- {
- __typename: 'WorkItemWidgetMilestone',
- },
- {
- type: 'NOTES',
- discussions: {
- pageInfo: {
- hasNextPage: false,
- hasPreviousPage: false,
- startCursor: null,
- endCursor: null,
- __typename: 'PageInfo',
- },
- nodes: [
+ workspace: {
+ id: 'gid://gitlab/Project/6',
+ workItems: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/600',
+ iid: '60',
+ widgets: [
{
- id: 'gid://gitlab/Discussion/8bbc4890b6ff0f2cde93a5a0947cd2b8a13d3b6e',
- notes: {
- nodes: [
- {
- id: 'gid://gitlab/DiscussionNote/174',
- body: 'Separate thread',
- bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Separate thread</p>',
- system: false,
- internal: false,
- systemNoteIconName: null,
- createdAt: '2023-01-12T07:47:40Z',
- lastEditedAt: null,
- lastEditedBy: null,
- discussion: {
- id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
- __typename: 'Discussion',
- },
- author: {
- id: 'gid://gitlab/User/1',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- name: 'Administrator',
- username: 'root',
- webUrl: 'http://127.0.0.1:3000/root',
- __typename: 'UserCore',
- },
- userPermissions: {
- adminNote: true,
- awardEmoji: true,
- readNote: true,
- createNote: true,
- resolveNote: true,
- repositionNote: true,
- __typename: 'NotePermissions',
- },
- __typename: 'Note',
- },
- {
- id: 'gid://gitlab/DiscussionNote/235',
- body: 'Thread comment',
- bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Thread comment</p>',
- system: false,
- internal: false,
- systemNoteIconName: null,
- createdAt: '2023-01-18T09:09:54Z',
- lastEditedAt: null,
- lastEditedBy: null,
- discussion: {
- id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
- __typename: 'Discussion',
- },
- author: {
- id: 'gid://gitlab/User/1',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- name: 'Administrator',
- username: 'root',
- webUrl: 'http://127.0.0.1:3000/root',
- __typename: 'UserCore',
- },
- userPermissions: {
- adminNote: true,
- awardEmoji: true,
- readNote: true,
- createNote: true,
- resolveNote: true,
- repositionNote: true,
- __typename: 'NotePermissions',
- },
- __typename: 'Note',
- },
- ],
- __typename: 'NoteConnection',
- },
- __typename: 'Discussion',
+ __typename: 'WorkItemWidgetIteration',
},
{
- id: 'gid://gitlab/Discussion/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864',
- notes: {
+ __typename: 'WorkItemWidgetWeight',
+ },
+ {
+ __typename: 'WorkItemWidgetAssignees',
+ },
+ {
+ __typename: 'WorkItemWidgetLabels',
+ },
+ {
+ __typename: 'WorkItemWidgetDescription',
+ },
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ },
+ {
+ __typename: 'WorkItemWidgetStartAndDueDate',
+ },
+ {
+ __typename: 'WorkItemWidgetMilestone',
+ },
+ {
+ type: 'NOTES',
+ discussions: {
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: null,
+ endCursor: null,
+ __typename: 'PageInfo',
+ },
nodes: [
{
- id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a9883864',
- body: 'Main thread 2',
- bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Main thread 2</p>',
- systemNoteIconName: 'weight',
- createdAt: '2022-11-25T07:16:20Z',
- lastEditedAt: null,
- lastEditedBy: null,
- system: false,
- internal: false,
- discussion: {
- id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
- },
- userPermissions: {
- adminNote: false,
- awardEmoji: true,
- readNote: true,
- createNote: true,
- resolveNote: true,
- repositionNote: true,
- __typename: 'NotePermissions',
+ id: 'gid://gitlab/Discussion/8bbc4890b6ff0f2cde93a5a0947cd2b8a13d3b6e',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/DiscussionNote/174',
+ body: 'Separate thread',
+ bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Separate thread</p>',
+ system: false,
+ internal: false,
+ systemNoteIconName: null,
+ createdAt: '2023-01-12T07:47:40Z',
+ lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
+ lastEditedBy: null,
+ discussion: {
+ id:
+ 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
+ __typename: 'Discussion',
+ },
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ userPermissions: {
+ adminNote: true,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ __typename: 'Note',
+ },
+ {
+ id: 'gid://gitlab/DiscussionNote/235',
+ body: 'Thread comment',
+ bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Thread comment</p>',
+ system: false,
+ internal: false,
+ systemNoteIconName: null,
+ createdAt: '2023-01-18T09:09:54Z',
+ lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
+ lastEditedBy: null,
+ discussion: {
+ id:
+ 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
+ __typename: 'Discussion',
+ },
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ userPermissions: {
+ adminNote: true,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
},
- author: {
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- id: 'gid://gitlab/User/1',
- name: 'Administrator',
- username: 'root',
- webUrl: 'http://127.0.0.1:3000/root',
- __typename: 'UserCore',
+ __typename: 'Discussion',
+ },
+ {
+ id: 'gid://gitlab/Discussion/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a9883864',
+ body: 'Main thread 2',
+ bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Main thread 2</p>',
+ systemNoteIconName: 'weight',
+ createdAt: '2022-11-25T07:16:20Z',
+ lastEditedAt: null,
+ url:
+ 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
+ lastEditedBy: null,
+ system: false,
+ internal: false,
+ discussion: {
+ id:
+ 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ author: {
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 'gid://gitlab/User/1',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
},
- __typename: 'Note',
+ __typename: 'Discussion',
},
],
- __typename: 'NoteConnection',
+ __typename: 'DiscussionConnection',
},
- __typename: 'Discussion',
+ __typename: 'WorkItemWidgetNotes',
},
],
- __typename: 'DiscussionConnection',
+ __typename: 'WorkItem',
},
- __typename: 'WorkItemWidgetNotes',
+ ],
+ },
+ },
+ },
+};
+
+export const workItemNotesCreateSubscriptionResponse = {
+ data: {
+ workItemNoteCreated: {
+ id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d81864',
+ body: 'changed weight to **89**',
+ bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>',
+ systemNoteIconName: 'weight',
+ createdAt: '2022-11-25T07:16:20Z',
+ lastEditedAt: null,
+ url: 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
+ lastEditedBy: null,
+ system: true,
+ internal: false,
+ discussion: {
+ id: 'gid://gitlab/Discussion/8bbc4890b6ff0f2cde93a5a0947cd2b8a13d3b6e',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a9881864',
+ body: 'changed weight to **89**',
+ bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>',
+ systemNoteIconName: 'weight',
+ createdAt: '2022-11-25T07:16:20Z',
+ lastEditedAt: null,
+ url: 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
+ lastEditedBy: null,
+ system: true,
+ internal: false,
+ discussion: {
+ id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ author: {
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 'gid://gitlab/User/1',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ __typename: 'Note',
+ },
+ ],
},
- ],
- __typename: 'WorkItem',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ author: {
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 'gid://gitlab/User/1',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ __typename: 'Note',
+ },
+ },
+};
+
+export const workItemNotesUpdateSubscriptionResponse = {
+ data: {
+ workItemNoteUpdated: {
+ id: 'gid://gitlab/Note/0f2f195ec0d1ef95ee9d5b10446b8e96a9883894',
+ body: 'changed title',
+ bodyHtml: '<p dir="auto">changed title<strong>89</strong></p>',
+ systemNoteIconName: 'pencil',
+ createdAt: '2022-11-25T07:16:20Z',
+ lastEditedAt: null,
+ url: 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
+ lastEditedBy: null,
+ system: true,
+ internal: false,
+ discussion: {
+ id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ author: {
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 'gid://gitlab/User/1',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ __typename: 'Note',
+ },
+ },
+};
+
+export const workItemNotesDeleteSubscriptionResponse = {
+ data: {
+ workItemNoteDeleted: {
+ id: 'gid://gitlab/DiscussionNote/235',
+ discussionId: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
+ lastDiscussionNote: false,
},
},
};
diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js
index 387c8a355fa..c369a454286 100644
--- a/spec/frontend/work_items/pages/create_work_item_spec.js
+++ b/spec/frontend/work_items/pages/create_work_item_spec.js
@@ -37,7 +37,6 @@ describe('Create work item component', () => {
props = {},
queryHandler = querySuccessHandler,
mutationHandler = createWorkItemSuccessHandler,
- fetchByIid = false,
} = {}) => {
fakeApollo = createMockApollo(
[
@@ -66,15 +65,11 @@ describe('Create work item component', () => {
},
provide: {
fullPath: 'full-path',
- glFeatures: {
- useIidInWorkItemsPath: fetchByIid,
- },
},
});
};
afterEach(() => {
- wrapper.destroy();
fakeApollo = null;
});
@@ -109,9 +104,7 @@ describe('Create work item component', () => {
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
name: 'workItem',
- params: {
- id: '1',
- },
+ params: { id: '1' },
});
});
@@ -149,7 +142,7 @@ describe('Create work item component', () => {
});
it('displays a list of work item types', () => {
- expect(findSelect().attributes('options').split(',')).toHaveLength(4);
+ expect(findSelect().attributes('options').split(',')).toHaveLength(6);
});
it('selects a work item type on click', async () => {
@@ -210,18 +203,4 @@ describe('Create work item component', () => {
'Something went wrong when creating work item. Please try again.',
);
});
-
- it('performs a correct redirect when `useIidInWorkItemsPath` feature flag is enabled', async () => {
- createComponent({ fetchByIid: true });
- findTitleInput().vm.$emit('title-input', 'Test title');
-
- wrapper.find('form').trigger('submit');
- await waitForPromises();
-
- expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
- name: 'workItem',
- params: { id: '1' },
- query: { iid_path: 'true' },
- });
- });
});
diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js
index a766962771a..c480affe484 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -44,10 +44,6 @@ describe('Work items root component', () => {
});
};
- afterEach(() => {
- wrapper.destroy();
- });
-
it('renders WorkItemDetail', () => {
createComponent();
@@ -79,7 +75,7 @@ describe('Work items root component', () => {
expect(visitUrl).toHaveBeenCalledWith(issuesListPath);
});
- it('shows alert if delete fails', async () => {
+ it('shows an alert if delete fails', async () => {
const deleteWorkItemHandler = jest.fn().mockRejectedValue(deleteWorkItemFailureResponse);
createComponent({
diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js
index ef9ae4a2eab..b5d54a7c319 100644
--- a/spec/frontend/work_items/router_spec.js
+++ b/spec/frontend/work_items/router_spec.js
@@ -3,17 +3,19 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import {
+ currentUserResponse,
workItemAssigneesSubscriptionResponse,
workItemDatesSubscriptionResponse,
- workItemResponseFactory,
+ workItemByIidResponseFactory,
workItemTitleSubscriptionResponse,
workItemLabelsSubscriptionResponse,
workItemMilestoneSubscriptionResponse,
workItemDescriptionSubscriptionResponse,
} from 'jest/work_items/mock_data';
+import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
import App from '~/work_items/components/app.vue';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
-import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+import workItemDatesSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql';
import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql';
@@ -30,7 +32,8 @@ describe('Work items router', () => {
Vue.use(VueApollo);
- const workItemQueryHandler = jest.fn().mockResolvedValue(workItemResponseFactory());
+ const workItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory());
+ const currentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse);
const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse);
const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const assigneesSubscriptionHandler = jest
@@ -51,7 +54,8 @@ describe('Work items router', () => {
}
const handlers = [
- [workItemQuery, workItemQueryHandler],
+ [workItemByIidQuery, workItemQueryHandler],
+ [currentUserQuery, currentUserQueryHandler],
[workItemDatesSubscription, datesSubscriptionHandler],
[workItemTitleSubscription, titleSubscriptionHandler],
[workItemAssigneesSubscription, assigneesSubscriptionHandler],
@@ -70,11 +74,13 @@ describe('Work items router', () => {
hasIterationsFeature: false,
hasOkrsFeature: false,
hasIssuableHealthStatusFeature: false,
+ reportAbusePath: '/report/abuse/path',
},
stubs: {
WorkItemWeight: true,
WorkItemIteration: true,
WorkItemHealthStatus: true,
+ WorkItemNotes: true,
},
});
};
@@ -88,7 +94,6 @@ describe('Work items router', () => {
});
afterEach(() => {
- wrapper.destroy();
window.location.hash = '';
});
diff --git a/spec/frontend/work_items/utils_spec.js b/spec/frontend/work_items/utils_spec.js
index aa24b80cf08..b8af5f10a5a 100644
--- a/spec/frontend/work_items/utils_spec.js
+++ b/spec/frontend/work_items/utils_spec.js
@@ -1,4 +1,9 @@
-import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils';
+import {
+ autocompleteDataSources,
+ markdownPreviewPath,
+ getWorkItemTodoOptimisticResponse,
+} from '~/work_items/utils';
+import { workItemResponseFactory } from './mock_data';
describe('autocompleteDataSources', () => {
beforeEach(() => {
@@ -25,3 +30,17 @@ describe('markdownPreviewPath', () => {
);
});
});
+
+describe('getWorkItemTodoOptimisticResponse', () => {
+ it.each`
+ scenario | pendingTodo | result
+ ${'empty'} | ${false} | ${0}
+ ${'present'} | ${true} | ${1}
+ `('returns correct response when pending item list is $scenario', ({ pendingTodo, result }) => {
+ const workItem = workItemResponseFactory({ canUpdate: true });
+ expect(
+ getWorkItemTodoOptimisticResponse({ workItem, pendingTodo }).workItemUpdate.workItem
+ .widgets[0].currentUserTodos.edges.length,
+ ).toBe(result);
+ });
+});
diff --git a/spec/frontend/work_items_hierarchy/components/app_spec.js b/spec/frontend/work_items_hierarchy/components/app_spec.js
index 124ff5f1608..22fd7d5f48a 100644
--- a/spec/frontend/work_items_hierarchy/components/app_spec.js
+++ b/spec/frontend/work_items_hierarchy/components/app_spec.js
@@ -24,10 +24,6 @@ describe('WorkItemsHierarchy App', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('survey banner', () => {
it('shows when the banner is visible', () => {
createComponent({}, { bannerVisible: true });
diff --git a/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js b/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js
index 084aaa754ab..dfdef7915dd 100644
--- a/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js
+++ b/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js
@@ -40,10 +40,6 @@ describe('WorkItemsHierarchy Hierarchy', () => {
);
};
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('available structure', () => {
let items = [];
diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js
index 85f1dbdc305..97a9f95e8e1 100644
--- a/spec/frontend/zen_mode_spec.js
+++ b/spec/frontend/zen_mode_spec.js
@@ -2,8 +2,9 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import Dropzone from 'dropzone';
import $ from 'jquery';
-import Mousetrap from 'mousetrap';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import htmlSnippetsShow from 'test_fixtures/snippets/show.html';
+import { Mousetrap } from '~/lib/mousetrap';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import GLForm from '~/gl_form';
import * as utils from '~/lib/utils/common_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
@@ -13,7 +14,8 @@ describe('ZenMode', () => {
let mock;
let zen;
let dropzoneForElementSpy;
- const fixtureName = 'snippets/show.html';
+
+ const getTextarea = () => $('.notes-form textarea');
function enterZen() {
$('.notes-form .js-zen-enter').click();
@@ -24,7 +26,7 @@ describe('ZenMode', () => {
}
function escapeKeydown() {
- $('.notes-form textarea').trigger(
+ getTextarea().trigger(
$.Event('keydown', {
keyCode: 27,
}),
@@ -35,7 +37,7 @@ describe('ZenMode', () => {
mock = new MockAdapter(axios);
mock.onGet().reply(HTTP_STATUS_OK);
- loadHTMLFixture(fixtureName);
+ setHTMLFixture(htmlSnippetsShow);
const form = $('.js-new-note-form');
new GLForm(form); // eslint-disable-line no-new
@@ -50,6 +52,12 @@ describe('ZenMode', () => {
});
afterEach(() => {
+ $(document).off('click', '.js-zen-enter');
+ $(document).off('click', '.js-zen-leave');
+ $(document).off('zen_mode:enter');
+ $(document).off('zen_mode:leave');
+ $(document).off('keydown');
+
resetHTMLFixture();
});
@@ -62,14 +70,14 @@ describe('ZenMode', () => {
$('.div-dropzone').addClass('js-invalid-dropzone');
exitZen();
- expect(dropzoneForElementSpy.mock.calls.length).toEqual(0);
+ expect(dropzoneForElementSpy).not.toHaveBeenCalled();
});
it('should call dropzone if element is dropzone valid', () => {
$('.div-dropzone').removeClass('js-invalid-dropzone');
exitZen();
- expect(dropzoneForElementSpy.mock.calls.length).toEqual(2);
+ expect(dropzoneForElementSpy).toHaveBeenCalledTimes(1);
});
});
@@ -82,10 +90,10 @@ describe('ZenMode', () => {
});
it('removes textarea styling', () => {
- $('.notes-form textarea').attr('style', 'height: 400px');
+ getTextarea().attr('style', 'height: 400px');
enterZen();
- expect($('.notes-form textarea')).not.toHaveAttr('style');
+ expect(getTextarea()).not.toHaveAttr('style');
});
});
@@ -116,4 +124,15 @@ describe('ZenMode', () => {
expect(utils.scrollToElement).toHaveBeenCalled();
});
});
+
+ it('restores textarea style', () => {
+ const style = 'color: red; overflow-y: hidden;';
+ getTextarea().attr('style', style);
+ expect(getTextarea()).toHaveAttr('style', style);
+
+ enterZen();
+ exitZen();
+
+ expect(getTextarea()).toHaveAttr('style', style);
+ });
});
diff --git a/spec/frontend_integration/README.md b/spec/frontend_integration/README.md
index 377294fb19f..ee760113307 100644
--- a/spec/frontend_integration/README.md
+++ b/spec/frontend_integration/README.md
@@ -22,6 +22,8 @@ We can generate the necessary fixtures and GraphQL schema by running:
bundle exec rake frontend:fixtures gitlab:graphql:schema:dump
```
+You can also download those fixtures from the package registry: see [download fixtures](https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html#download-fixtures) for more info.
+
Then we can use [Jest](https://jestjs.io/) to run the frontend integration tests:
```shell
diff --git a/spec/frontend_integration/content_editor/content_editor_integration_spec.js b/spec/frontend_integration/content_editor/content_editor_integration_spec.js
index 8521e85a971..6bafe609995 100644
--- a/spec/frontend_integration/content_editor/content_editor_integration_spec.js
+++ b/spec/frontend_integration/content_editor/content_editor_integration_spec.js
@@ -38,10 +38,6 @@ describe('content_editor', () => {
renderMarkdown = jest.fn();
});
- afterEach(() => {
- wrapper.destroy();
- });
-
describe('when loading initial content', () => {
describe('when the initial content is empty', () => {
it('still hides the loading indicator', async () => {
@@ -56,7 +52,7 @@ describe('content_editor', () => {
});
describe('when the initial content is not empty', () => {
- const initialContent = '<p><strong>bold text</strong></p>';
+ const initialContent = '<strong>bold text</strong> and <em>italic text</em>';
beforeEach(async () => {
mockRenderMarkdownResponse(initialContent);
@@ -70,7 +66,7 @@ describe('content_editor', () => {
expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
});
- it('displays the initial content', async () => {
+ it('displays the initial content', () => {
expect(wrapper.html()).toContain(initialContent);
});
});
diff --git a/spec/frontend_integration/ide/ide_integration_spec.js b/spec/frontend_integration/ide/ide_integration_spec.js
index a6108fd71e1..5711b004f70 100644
--- a/spec/frontend_integration/ide/ide_integration_spec.js
+++ b/spec/frontend_integration/ide/ide_integration_spec.js
@@ -22,7 +22,6 @@ describe('WebIDE', () => {
afterEach(() => {
vm.$destroy();
- vm = null;
resetHTMLFixture();
});
diff --git a/spec/frontend_integration/ide/user_opens_file_spec.js b/spec/frontend_integration/ide/user_opens_file_spec.js
index af6e2f3b44b..93c9fff309f 100644
--- a/spec/frontend_integration/ide/user_opens_file_spec.js
+++ b/spec/frontend_integration/ide/user_opens_file_spec.js
@@ -23,7 +23,6 @@ describe('IDE: User opens a file in the Web IDE', () => {
afterEach(() => {
vm.$destroy();
- vm = null;
resetHTMLFixture();
});
diff --git a/spec/frontend_integration/ide/user_opens_ide_spec.js b/spec/frontend_integration/ide/user_opens_ide_spec.js
index 552888f04a5..2f89b3c0612 100644
--- a/spec/frontend_integration/ide/user_opens_ide_spec.js
+++ b/spec/frontend_integration/ide/user_opens_ide_spec.js
@@ -20,11 +20,10 @@ describe('IDE: User opens IDE', () => {
afterEach(() => {
vm.$destroy();
- vm = null;
resetHTMLFixture();
});
- it('shows loading indicator while the IDE is loading', async () => {
+ it('shows loading indicator while the IDE is loading', () => {
vm = startWebIDE(container);
expect(container.querySelectorAll('.multi-file-loading-container')).toHaveLength(3);
@@ -53,7 +52,7 @@ describe('IDE: User opens IDE', () => {
await screen.findByText('README'); // wait for file tree to load
});
- it('shows a list of files in the left sidebar', async () => {
+ it('shows a list of files in the left sidebar', () => {
expect(ideHelper.getFilesList()).toEqual(
expect.arrayContaining(['README', 'LICENSE', 'CONTRIBUTING.md']),
);
diff --git a/spec/frontend_integration/ide/user_opens_mr_spec.js b/spec/frontend_integration/ide/user_opens_mr_spec.js
index af0276a5055..4e90cef6016 100644
--- a/spec/frontend_integration/ide/user_opens_mr_spec.js
+++ b/spec/frontend_integration/ide/user_opens_mr_spec.js
@@ -34,7 +34,6 @@ describe('IDE: User opens Merge Request', () => {
afterEach(() => {
vm.$destroy();
- vm = null;
resetHTMLFixture();
});
diff --git a/spec/frontend_integration/snippets/snippets_notes_spec.js b/spec/frontend_integration/snippets/snippets_notes_spec.js
index 5e9eaa1aada..27be7793ce6 100644
--- a/spec/frontend_integration/snippets/snippets_notes_spec.js
+++ b/spec/frontend_integration/snippets/snippets_notes_spec.js
@@ -1,12 +1,13 @@
import $ from 'jquery';
+import htmlSnippetsShow from 'test_fixtures/snippets/show.html';
import axios from '~/lib/utils/axios_utils';
import initGFMInput from '~/behaviors/markdown/gfm_auto_complete';
import initDeprecatedNotes from '~/init_deprecated_notes';
-import { loadHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture } from 'helpers/fixtures';
describe('Integration Snippets notes', () => {
- beforeEach(async () => {
- loadHTMLFixture('snippets/show.html');
+ beforeEach(() => {
+ setHTMLFixture(htmlSnippetsShow);
// Check if we have to Load GFM Input
const $gfmInputs = $('.js-gfm-input:not(.js-gfm-input-initialized)');
diff --git a/spec/graphql/graphql_triggers_spec.rb b/spec/graphql/graphql_triggers_spec.rb
index 00b5aec366e..a8a37289ddd 100644
--- a/spec/graphql/graphql_triggers_spec.rb
+++ b/spec/graphql/graphql_triggers_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GraphqlTriggers do
+RSpec.describe GraphqlTriggers, feature_category: :shared do
let_it_be(:issuable, refind: true) { create(:work_item) }
describe '.issuable_assignees_updated' do
@@ -12,9 +12,9 @@ RSpec.describe GraphqlTriggers do
issuable.update!(assignees: assignees)
end
- it 'triggers the issuableAssigneesUpdated subscription' do
+ it 'triggers the issuable_assignees_updated subscription' do
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
- 'issuableAssigneesUpdated',
+ :issuable_assignees_updated,
{ issuable_id: issuable.to_gid },
issuable
)
@@ -24,9 +24,9 @@ RSpec.describe GraphqlTriggers do
end
describe '.issuable_title_updated' do
- it 'triggers the issuableTitleUpdated subscription' do
+ it 'triggers the issuable_title_updated subscription' do
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
- 'issuableTitleUpdated',
+ :issuable_title_updated,
{ issuable_id: issuable.to_gid },
issuable
).and_call_original
@@ -36,9 +36,9 @@ RSpec.describe GraphqlTriggers do
end
describe '.issuable_description_updated' do
- it 'triggers the issuableDescriptionUpdated subscription' do
+ it 'triggers the issuable_description_updated subscription' do
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
- 'issuableDescriptionUpdated',
+ :issuable_description_updated,
{ issuable_id: issuable.to_gid },
issuable
).and_call_original
@@ -54,9 +54,9 @@ RSpec.describe GraphqlTriggers do
issuable.update!(labels: labels)
end
- it 'triggers the issuableLabelsUpdated subscription' do
+ it 'triggers the issuable_labels_updated subscription' do
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
- 'issuableLabelsUpdated',
+ :issuable_labels_updated,
{ issuable_id: issuable.to_gid },
issuable
)
@@ -66,9 +66,9 @@ RSpec.describe GraphqlTriggers do
end
describe '.issuable_dates_updated' do
- it 'triggers the issuableDatesUpdated subscription' do
+ it 'triggers the issuable_dates_updated subscription' do
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
- 'issuableDatesUpdated',
+ :issuable_dates_updated,
{ issuable_id: issuable.to_gid },
issuable
).and_call_original
@@ -78,9 +78,9 @@ RSpec.describe GraphqlTriggers do
end
describe '.issuable_milestone_updated' do
- it 'triggers the issuableMilestoneUpdated subscription' do
+ it 'triggers the issuable_milestone_updated subscription' do
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
- 'issuableMilestoneUpdated',
+ :issuable_milestone_updated,
{ issuable_id: issuable.to_gid },
issuable
).and_call_original
@@ -90,11 +90,11 @@ RSpec.describe GraphqlTriggers do
end
describe '.merge_request_reviewers_updated' do
- it 'triggers the mergeRequestReviewersUpdated subscription' do
+ it 'triggers the merge_request_reviewers_updated subscription' do
merge_request = build_stubbed(:merge_request)
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
- 'mergeRequestReviewersUpdated',
+ :merge_request_reviewers_updated,
{ issuable_id: merge_request.to_gid },
merge_request
).and_call_original
@@ -104,25 +104,39 @@ RSpec.describe GraphqlTriggers do
end
describe '.merge_request_merge_status_updated' do
- it 'triggers the mergeRequestMergeStatusUpdated subscription' do
+ it 'triggers the merge_request_merge_status_updated subscription' do
merge_request = build_stubbed(:merge_request)
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
- 'mergeRequestMergeStatusUpdated',
+ :merge_request_merge_status_updated,
{ issuable_id: merge_request.to_gid },
merge_request
).and_call_original
GraphqlTriggers.merge_request_merge_status_updated(merge_request)
end
+
+ context 'when realtime_mr_status_change feature flag is disabled' do
+ before do
+ stub_feature_flags(realtime_mr_status_change: false)
+ end
+
+ it 'does not trigger realtime_mr_status_change subscription' do
+ merge_request = build_stubbed(:merge_request)
+
+ expect(GitlabSchema.subscriptions).not_to receive(:trigger)
+
+ GraphqlTriggers.merge_request_merge_status_updated(merge_request)
+ end
+ end
end
describe '.merge_request_approval_state_updated' do
- it 'triggers the mergeRequestApprovalStateUpdated subscription' do
+ it 'triggers the merge_request_approval_state_updated subscription' do
merge_request = build_stubbed(:merge_request)
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
- 'mergeRequestApprovalStateUpdated',
+ :merge_request_approval_state_updated,
{ issuable_id: merge_request.to_gid },
merge_request
).and_call_original
diff --git a/spec/graphql/mutations/achievements/award_spec.rb b/spec/graphql/mutations/achievements/award_spec.rb
new file mode 100644
index 00000000000..1bfad46a616
--- /dev/null
+++ b/spec/graphql/mutations/achievements/award_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Achievements::Award, feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:recipient) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+
+ describe '#resolve' do
+ subject(:resolve_mutation) do
+ described_class.new(object: nil, context: { current_user: current_user }, field: nil).resolve(
+ achievement_id: achievement&.to_global_id, user_id: recipient&.to_global_id
+ )
+ end
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when the user does not have permission' do
+ let(:current_user) { developer }
+
+ it 'raises an error' do
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
+ end
+ end
+
+ context 'when the user has permission' do
+ let(:current_user) { maintainer }
+
+ context 'when the params are invalid' do
+ let(:achievement) { nil }
+
+ it 'returns the validation error' do
+ expect { resolve_mutation }.to raise_error { Gitlab::Graphql::Errors::ArgumentError }
+ end
+ end
+
+ it 'creates user_achievement with correct values' do
+ expect(resolve_mutation[:user_achievement]).to have_attributes({ achievement: achievement, user: recipient })
+ end
+ end
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:award_achievement) }
+end
diff --git a/spec/graphql/mutations/achievements/delete_spec.rb b/spec/graphql/mutations/achievements/delete_spec.rb
new file mode 100644
index 00000000000..0eb6f5a2e6f
--- /dev/null
+++ b/spec/graphql/mutations/achievements/delete_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Achievements::Delete, feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:recipient) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ let(:achievement) { create(:achievement, namespace: group) }
+
+ describe '#resolve' do
+ subject(:resolve_mutation) do
+ described_class.new(object: nil, context: { current_user: current_user }, field: nil).resolve(
+ achievement_id: achievement&.to_global_id
+ )
+ end
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when the user does not have permission' do
+ let(:current_user) { developer }
+
+ it 'raises an error' do
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
+ end
+ end
+
+ context 'when the user has permission' do
+ let(:current_user) { maintainer }
+
+ context 'when the params are invalid' do
+ let(:achievement) { nil }
+
+ it 'returns the validation error' do
+ expect { resolve_mutation }.to raise_error { Gitlab::Graphql::Errors::ArgumentError }
+ end
+ end
+
+ it 'deletes the achievement' do
+ resolve_mutation
+
+ expect(Achievements::Achievement.find_by(id: achievement.id)).to be_nil
+ end
+ end
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_achievement) }
+end
diff --git a/spec/graphql/mutations/achievements/revoke_spec.rb b/spec/graphql/mutations/achievements/revoke_spec.rb
new file mode 100644
index 00000000000..0c221b492af
--- /dev/null
+++ b/spec/graphql/mutations/achievements/revoke_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Achievements::Revoke, feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:recipient) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+ let_it_be(:user_achievement) { create(:user_achievement, achievement: achievement) }
+
+ describe '#resolve' do
+ subject(:resolve_mutation) do
+ described_class.new(object: nil, context: { current_user: current_user }, field: nil).resolve(
+ user_achievement_id: user_achievement&.to_global_id
+ )
+ end
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when the user does not have permission' do
+ let(:current_user) { developer }
+
+ it 'raises an error' do
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
+ end
+ end
+
+ context 'when the user has permission' do
+ let(:current_user) { maintainer }
+
+ context 'when the params are invalid' do
+ let(:user_achievement) { nil }
+
+ it 'returns the validation error' do
+ expect { resolve_mutation }.to raise_error { Gitlab::Graphql::Errors::ArgumentError }
+ end
+ end
+
+ it 'revokes user_achievement' do
+ response = resolve_mutation[:user_achievement]
+
+ expect(response.revoked_at).not_to be_nil
+ expect(response.revoked_by_user_id).to be(current_user.id)
+ end
+ end
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:award_achievement) }
+end
diff --git a/spec/graphql/mutations/achievements/update_spec.rb b/spec/graphql/mutations/achievements/update_spec.rb
new file mode 100644
index 00000000000..b69c8bef478
--- /dev/null
+++ b/spec/graphql/mutations/achievements/update_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Achievements::Update, feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:recipient) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ let(:achievement) { create(:achievement, namespace: group) }
+ let(:name) { 'Hero' }
+
+ describe '#resolve' do
+ subject(:resolve_mutation) do
+ described_class.new(object: nil, context: { current_user: current_user }, field: nil).resolve(
+ achievement_id: achievement&.to_global_id, name: name
+ )
+ end
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when the user does not have permission' do
+ let(:current_user) { developer }
+
+ it 'raises an error' do
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
+ end
+ end
+
+ context 'when the user has permission' do
+ let(:current_user) { maintainer }
+
+ context 'when the params are invalid' do
+ let(:achievement) { nil }
+
+ it 'returns the validation error' do
+ expect { resolve_mutation }.to raise_error { Gitlab::Graphql::Errors::ArgumentError }
+ end
+ end
+
+ it 'updates the achievement' do
+ resolve_mutation
+
+ expect(Achievements::Achievement.find_by(id: achievement.id).name).to eq(name)
+ end
+ end
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_achievement) }
+end
diff --git a/spec/graphql/mutations/alert_management/alerts/set_assignees_spec.rb b/spec/graphql/mutations/alert_management/alerts/set_assignees_spec.rb
index 125e15b70cf..da5531d2b93 100644
--- a/spec/graphql/mutations/alert_management/alerts/set_assignees_spec.rb
+++ b/spec/graphql/mutations/alert_management/alerts/set_assignees_spec.rb
@@ -58,7 +58,6 @@ RSpec.describe Mutations::AlertManagement::Alerts::SetAssignees do
it_behaves_like 'an incident management tracked event', :incident_management_alert_assigned
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace.reload }
let(:category) { described_class.to_s }
let(:user) { current_user }
diff --git a/spec/graphql/mutations/alert_management/alerts/todo/create_spec.rb b/spec/graphql/mutations/alert_management/alerts/todo/create_spec.rb
index bcb7c74fa09..8ba1e785b63 100644
--- a/spec/graphql/mutations/alert_management/alerts/todo/create_spec.rb
+++ b/spec/graphql/mutations/alert_management/alerts/todo/create_spec.rb
@@ -20,7 +20,6 @@ RSpec.describe Mutations::AlertManagement::Alerts::Todo::Create do
it_behaves_like 'an incident management tracked event', :incident_management_alert_todo
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace.reload }
let(:category) { described_class.to_s }
let(:user) { current_user }
diff --git a/spec/graphql/mutations/alert_management/create_alert_issue_spec.rb b/spec/graphql/mutations/alert_management/create_alert_issue_spec.rb
index e49596b37c9..f86046bb0d6 100644
--- a/spec/graphql/mutations/alert_management/create_alert_issue_spec.rb
+++ b/spec/graphql/mutations/alert_management/create_alert_issue_spec.rb
@@ -32,7 +32,6 @@ RSpec.describe Mutations::AlertManagement::CreateAlertIssue do
it_behaves_like 'an incident management tracked event', :incident_management_alert_create_incident
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace.reload }
let(:category) { described_class.to_s }
let(:user) { current_user }
@@ -57,7 +56,6 @@ RSpec.describe Mutations::AlertManagement::CreateAlertIssue do
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace.reload }
let(:category) { described_class.to_s }
let(:user) { current_user }
diff --git a/spec/graphql/mutations/alert_management/update_alert_status_spec.rb b/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
index 22ad93df79b..fb11ec7065b 100644
--- a/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
+++ b/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
@@ -36,7 +36,6 @@ RSpec.describe Mutations::AlertManagement::UpdateAlertStatus do
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace }
let(:category) { described_class.to_s }
let(:user) { current_user }
diff --git a/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb b/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb
index 44147987ebb..0485796fe56 100644
--- a/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb
+++ b/spec/graphql/mutations/ci/job_token_scope/add_project_spec.rb
@@ -20,6 +20,10 @@ RSpec.describe Mutations::Ci::JobTokenScope::AddProject, feature_category: :cont
mutation.resolve(**mutation_args)
end
+ before do
+ stub_feature_flags(frozen_outbound_job_token_scopes_override: false)
+ end
+
context 'when user is not logged in' do
let(:current_user) { nil }
@@ -43,10 +47,10 @@ RSpec.describe Mutations::Ci::JobTokenScope::AddProject, feature_category: :cont
target_project.add_guest(current_user)
end
- it 'adds target project to the outbound job token scope by default' do
+ it 'adds target project to the inbound job token scope by default' do
expect do
expect(subject).to include(ci_job_token_scope: be_present, errors: be_empty)
- end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1)
+ end.to change { Ci::JobToken::ProjectScopeLink.inbound.count }.by(1)
end
context 'when mutation uses the direction argument' do
@@ -55,10 +59,8 @@ RSpec.describe Mutations::Ci::JobTokenScope::AddProject, feature_category: :cont
context 'when targeting the outbound allowlist' do
let(:direction) { :outbound }
- it 'adds the target project' do
- expect do
- expect(subject).to include(ci_job_token_scope: be_present, errors: be_empty)
- end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1)
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
end
@@ -73,6 +75,42 @@ RSpec.describe Mutations::Ci::JobTokenScope::AddProject, feature_category: :cont
end
end
+ context 'when FF frozen_outbound_job_token_scopes is disabled' do
+ before do
+ stub_feature_flags(frozen_outbound_job_token_scopes: false)
+ end
+
+ it 'adds target project to the outbound job token scope by default' do
+ expect do
+ expect(subject).to include(ci_job_token_scope: be_present, errors: be_empty)
+ end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1)
+ end
+
+ context 'when mutation uses the direction argument' do
+ let(:mutation_args) { super().merge!(direction: direction) }
+
+ context 'when targeting the outbound allowlist' do
+ let(:direction) { :outbound }
+
+ it 'adds the target project' do
+ expect do
+ expect(subject).to include(ci_job_token_scope: be_present, errors: be_empty)
+ end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1)
+ end
+ end
+
+ context 'when targeting the inbound allowlist' do
+ let(:direction) { :inbound }
+
+ it 'adds the target project' do
+ expect do
+ expect(subject).to include(ci_job_token_scope: be_present, errors: be_empty)
+ end.to change { Ci::JobToken::ProjectScopeLink.inbound.count }.by(1)
+ end
+ end
+ end
+ end
+
context 'when the service returns an error' do
let(:service) { double(:service) }
@@ -81,7 +119,7 @@ RSpec.describe Mutations::Ci::JobTokenScope::AddProject, feature_category: :cont
project,
current_user
).and_return(service)
- expect(service).to receive(:execute).with(target_project, direction: :outbound).and_return(ServiceResponse.error(message: 'The error message'))
+ expect(service).to receive(:execute).with(target_project, direction: :inbound).and_return(ServiceResponse.error(message: 'The error message'))
expect(subject.fetch(:ci_job_token_scope)).to be_nil
expect(subject.fetch(:errors)).to include("The error message")
diff --git a/spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb b/spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb
deleted file mode 100644
index 451f6d1fe06..00000000000
--- a/spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Mutations::FindsByGid do
- include GraphqlHelpers
-
- let(:mutation_class) do
- Class.new(Mutations::BaseMutation) do
- authorize :read_user
-
- include Mutations::FindsByGid
- end
- end
-
- let(:query) { query_double(schema: GitlabSchema) }
- let(:context) { GraphQL::Query::Context.new(query: query, object: nil, values: { current_user: user }) }
- let(:user) { create(:user) }
- let(:gid) { user.to_global_id }
-
- subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) }
-
- it 'calls GitlabSchema.find_by_gid to find objects during authorized_find!' do
- expect(mutation.authorized_find!(id: gid)).to eq(user)
- end
-end
diff --git a/spec/graphql/mutations/container_repositories/destroy_spec.rb b/spec/graphql/mutations/container_repositories/destroy_spec.rb
index 50e83ccdd30..85e0ac96e55 100644
--- a/spec/graphql/mutations/container_repositories/destroy_spec.rb
+++ b/spec/graphql/mutations/container_repositories/destroy_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe Mutations::ContainerRepositories::Destroy do
.to receive(:new).with(nil, user, event_name: :delete_repository, scope: :container).and_call_original
expect(DeleteContainerRepositoryWorker).not_to receive(:perform_async)
- expect { subject }.to change { ::Packages::Event.count }.by(1)
+ subject
expect(container_repository.reload.delete_scheduled?).to be true
end
end
diff --git a/spec/graphql/mutations/container_repositories/destroy_tags_spec.rb b/spec/graphql/mutations/container_repositories/destroy_tags_spec.rb
index 3e5f28ee244..96dd1754155 100644
--- a/spec/graphql/mutations/container_repositories/destroy_tags_spec.rb
+++ b/spec/graphql/mutations/container_repositories/destroy_tags_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe Mutations::ContainerRepositories::DestroyTags do
it 'creates a package event' do
expect(::Packages::CreateEventService)
.to receive(:new).with(nil, user, event_name: :delete_tag_bulk, scope: :tag).and_call_original
- expect { subject }.to change { ::Packages::Event.count }.by(1)
+ subject
end
end
@@ -87,7 +87,7 @@ RSpec.describe Mutations::ContainerRepositories::DestroyTags do
it 'does not create a package event' do
expect(::Packages::CreateEventService).not_to receive(:new)
- expect { subject }.not_to change { ::Packages::Event.count }
+ subject
end
end
end
diff --git a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb
index f2bbf0949fb..3ee898c2079 100644
--- a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb
+++ b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb
@@ -57,10 +57,10 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do
end
end
- context 'when attaching to an organization' do
+ context 'when attaching to an crm_organization' do
context 'when all ok' do
before do
- organization = create(:organization, group: group)
+ organization = create(:crm_organization, group: group)
valid_params[:organization_id] = organization.to_global_id
end
@@ -69,7 +69,7 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do
end
end
- context 'when organization does not exist' do
+ context 'when crm_organization does not exist' do
before do
valid_params[:organization_id] = global_id_of(model_name: 'CustomerRelations::Organization', id: non_existing_record_id)
end
@@ -79,10 +79,10 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do
end
end
- context 'when organzation belongs to a different group' do
+ context 'when crm_organzation belongs to a different group' do
before do
- organization = create(:organization)
- valid_params[:organization_id] = organization.to_global_id
+ crm_organization = create(:crm_organization)
+ valid_params[:organization_id] = crm_organization.to_global_id
end
it 'returns the relevant error' do
diff --git a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb
index ffc9632350a..cf1ff2d5653 100644
--- a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb
+++ b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do
let_it_be(:group) { create(:group, :crm_enabled) }
let(:valid_params) do
- attributes_for(:organization,
+ attributes_for(:crm_organization,
group: group,
description: 'This company is super important!',
default_rate: 1_000
diff --git a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb
index f0f37ee9c47..2fad320b497 100644
--- a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb
+++ b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb
@@ -10,10 +10,10 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do
let(:default_rate) { 1000.to_f }
let(:description) { 'VIP' }
let(:does_not_exist_or_no_permission) { Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR }
- let(:organization) { create(:organization, group: group) }
+ let(:crm_organization) { create(:crm_organization, group: group) }
let(:attributes) do
{
- id: organization.to_global_id,
+ id: crm_organization.to_global_id,
name: name,
default_rate: default_rate,
description: description
@@ -27,7 +27,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do
)
end
- context 'when the user does not have permission to update an organization' do
+ context 'when the user does not have permission to update an crm_organization' do
before do
group.add_reporter(user)
end
@@ -38,7 +38,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do
end
end
- context 'when the organization does not exist' do
+ context 'when the crm_organization does not exist' do
it 'raises an error' do
attributes[:id] = "gid://gitlab/CustomerRelations::Organization/#{non_existing_record_id}"
@@ -47,12 +47,12 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do
end
end
- context 'when the user has permission to update an organization' do
+ context 'when the user has permission to update an crm_organization' do
before_all do
group.add_developer(user)
end
- it 'updates the organization with correct values' do
+ it 'updates the crm_organization with correct values' do
expect(resolve_mutation[:organization]).to have_attributes(attributes)
end
diff --git a/spec/graphql/mutations/design_management/delete_spec.rb b/spec/graphql/mutations/design_management/delete_spec.rb
index 79196d4965d..a76943b9ff8 100644
--- a/spec/graphql/mutations/design_management/delete_spec.rb
+++ b/spec/graphql/mutations/design_management/delete_spec.rb
@@ -86,7 +86,7 @@ RSpec.describe Mutations::DesignManagement::Delete do
end
end
- it 'runs no more than 30 queries' do
+ it 'runs no more than 31 queries' do
allow(Gitlab::Tracking).to receive(:event) # rubocop:disable RSpec/ExpectGitlabTracking
filenames.each(&:present?) # ignore setup
@@ -107,22 +107,23 @@ RSpec.describe Mutations::DesignManagement::Delete do
# 14. project.authorizations for user (same query as 5)
# 15. current designs by filename and issue
# 16, 17 project.authorizations for user (same query as 5)
- # 18. find route by id and source_type
- # 19. find plan for standard context
+ # 18. find design_management_repository for project
+ # 19. find route by id and source_type
+ # 20. find plan for standard context
# ------------- our queries are below:
- # 20. start transaction 1
- # 21. start transaction 2
- # 22. find version by sha and issue
- # 23. exists version with sha and issue?
- # 24. leave transaction 2
- # 25. create version with sha and issue
- # 26. create design-version links
- # 27. validate version.actions.present?
- # 28. validate version.issue.present?
- # 29. validate version.sha is unique
- # 30. leave transaction 1
+ # 21. start transaction 1
+ # 22. start transaction 2
+ # 23. find version by sha and issue
+ # 24. exists version with sha and issue?
+ # 25. leave transaction 2
+ # 26. create version with sha and issue
+ # 27. create design-version links
+ # 28. validate version.actions.present?
+ # 29. validate version.issue.present?
+ # 30. validate version.sha is unique
+ # 31. leave transaction 1
#
- expect { run_mutation }.not_to exceed_query_limit(30)
+ expect { run_mutation }.not_to exceed_query_limit(31)
end
end
diff --git a/spec/graphql/mutations/environments/stop_spec.rb b/spec/graphql/mutations/environments/stop_spec.rb
new file mode 100644
index 00000000000..085d168bc53
--- /dev/null
+++ b/spec/graphql/mutations/environments/stop_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Environments::Stop, feature_category: :environment_management do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:environment) { create(:environment, project: project, state: 'available') }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+
+ let(:user) { maintainer }
+
+ subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+
+ before_all do
+ project.add_maintainer(maintainer)
+ project.add_reporter(reporter)
+ end
+
+ describe '#resolve' do
+ subject { mutation.resolve(id: environment_id, force: force) }
+
+ let(:environment_id) { environment.to_global_id }
+ let(:force) { false }
+
+ context 'when service execution succeeded' do
+ it 'returns no errors' do
+ expect(subject[:errors]).to be_empty
+ end
+
+ it 'stops the environment' do
+ expect(subject[:environment]).to be_stopped
+ end
+ end
+
+ context 'when service cannot change the status without force' do
+ before do
+ environment.update!(state: 'stopping')
+ end
+
+ it 'returns an error' do
+ expect(subject)
+ .to eq({
+ environment: environment,
+ errors: ['Attemped to stop the environment but failed to change the status']
+ })
+ end
+ end
+
+ context 'when force is set to true' do
+ let(:force) { true }
+
+ context 'and state transition would fail without force' do
+ before do
+ environment.update!(state: 'stopping')
+ end
+
+ it 'stops the environment' do
+ expect(subject[:environment]).to be_stopped
+ end
+ end
+ end
+
+ context 'when user is reporter who does not have permission to access the environment' do
+ let(:user) { reporter }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/members/bulk_update_base_spec.rb b/spec/graphql/mutations/members/bulk_update_base_spec.rb
new file mode 100644
index 00000000000..61a27984824
--- /dev/null
+++ b/spec/graphql/mutations/members/bulk_update_base_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Members::BulkUpdateBase, feature_category: :subgroups do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group).tap { |group| group.add_owner(user) } }
+
+ it 'raises a NotImplementedError error if the source_type method is called on the base class' do
+ mutation = described_class.new(context: { current_user: user }, object: nil, field: nil)
+
+ expect { mutation.resolve(group_id: group.to_gid.to_s) }.to raise_error(NotImplementedError)
+ end
+end
diff --git a/spec/graphql/mutations/release_asset_links/create_spec.rb b/spec/graphql/mutations/release_asset_links/create_spec.rb
index a5291a00799..cc6c1554866 100644
--- a/spec/graphql/mutations/release_asset_links/create_spec.rb
+++ b/spec/graphql/mutations/release_asset_links/create_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Mutations::ReleaseAssetLinks::Create do
+RSpec.describe Mutations::ReleaseAssetLinks::Create, feature_category: :release_orchestration do
include GraphqlHelpers
let_it_be(:project) { create(:project, :private, :repository) }
diff --git a/spec/graphql/mutations/release_asset_links/delete_spec.rb b/spec/graphql/mutations/release_asset_links/delete_spec.rb
index cca7bd2ba38..3aecc44afd1 100644
--- a/spec/graphql/mutations/release_asset_links/delete_spec.rb
+++ b/spec/graphql/mutations/release_asset_links/delete_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Mutations::ReleaseAssetLinks::Delete do
+RSpec.describe Mutations::ReleaseAssetLinks::Delete, feature_category: :release_orchestration do
include GraphqlHelpers
let_it_be(:project) { create(:project, :private, :repository) }
@@ -60,6 +60,18 @@ RSpec.describe Mutations::ReleaseAssetLinks::Delete do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
+
+ context 'when destroy process fails' do
+ before do
+ allow_next_instance_of(::Releases::Links::DestroyService) do |service|
+ allow(service).to receive(:execute).and_return(ServiceResponse.error(message: 'error'))
+ end
+ end
+
+ it 'returns errors' do
+ expect(resolve).to include(errors: 'error')
+ end
+ end
end
context 'when the current user does not have access to delete the link' do
diff --git a/spec/graphql/mutations/release_asset_links/update_spec.rb b/spec/graphql/mutations/release_asset_links/update_spec.rb
index e119cf9cc77..abb091fc68d 100644
--- a/spec/graphql/mutations/release_asset_links/update_spec.rb
+++ b/spec/graphql/mutations/release_asset_links/update_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Mutations::ReleaseAssetLinks::Update do
+RSpec.describe Mutations::ReleaseAssetLinks::Update, feature_category: :release_orchestration do
include GraphqlHelpers
let_it_be(:project) { create(:project, :private, :repository) }
diff --git a/spec/graphql/mutations/work_items/update_spec.rb b/spec/graphql/mutations/work_items/update_spec.rb
new file mode 100644
index 00000000000..3acb06346a4
--- /dev/null
+++ b/spec/graphql/mutations/work_items/update_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::WorkItems::Update, feature_category: :portfolio_management do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
+ let_it_be(:current_work_item) { create(:work_item, :task, project: project) }
+ let_it_be(:parent_work_item) { create(:work_item, project: project) }
+
+ subject(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+
+ describe '#ready?' do
+ let(:current_user) { developer }
+ let(:current_gid) { current_work_item.to_gid.to_s }
+ let(:parent_gid) { parent_work_item.to_gid.to_s }
+ let(:valid_arguments) { { id: current_gid, parent_id: parent_gid } }
+
+ it { is_expected.to be_ready(**valid_arguments) }
+ end
+end
diff --git a/spec/graphql/resolvers/achievements/achievements_resolver_spec.rb b/spec/graphql/resolvers/achievements/achievements_resolver_spec.rb
new file mode 100644
index 00000000000..a70c89aa7c7
--- /dev/null
+++ b/spec/graphql/resolvers/achievements/achievements_resolver_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Achievements::AchievementsResolver, feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:achievements) { create_list(:achievement, 3, namespace: group) }
+
+ let(:args) { {} }
+
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::Achievements::AchievementType.connection_type)
+ end
+
+ describe '#resolve' do
+ it 'returns all achievements' do
+ expect(resolve_achievements.items).to match_array(achievements)
+ end
+
+ context 'with ids argument' do
+ let(:args) { { ids: [achievements[0].to_global_id, achievements[1].to_global_id] } }
+
+ it 'returns the specified achievement' do
+ expect(resolve_achievements.items).to contain_exactly(achievements[0], achievements[1])
+ end
+ end
+
+ context 'when `achievements` feature flag is diabled' do
+ before do
+ stub_feature_flags(achievements: false)
+ end
+
+ it 'is empty' do
+ expect(resolve_achievements).to be_empty
+ end
+ end
+ end
+
+ def resolve_achievements
+ resolve(described_class, args: args, obj: group)
+ end
+end
diff --git a/spec/graphql/resolvers/base_resolver_spec.rb b/spec/graphql/resolvers/base_resolver_spec.rb
index 39b00c14161..d80a61fd318 100644
--- a/spec/graphql/resolvers/base_resolver_spec.rb
+++ b/spec/graphql/resolvers/base_resolver_spec.rb
@@ -112,7 +112,7 @@ RSpec.describe Resolvers::BaseResolver do
end
def resolve(foo: 1)
- [foo * foo] # rubocop: disable Lint/BinaryOperatorWithIdenticalOperands
+ [foo * foo]
end
end
end
diff --git a/spec/graphql/resolvers/blobs_resolver_spec.rb b/spec/graphql/resolvers/blobs_resolver_spec.rb
index a666ed2a9fc..26eb6dc0abe 100644
--- a/spec/graphql/resolvers/blobs_resolver_spec.rb
+++ b/spec/graphql/resolvers/blobs_resolver_spec.rb
@@ -71,6 +71,14 @@ RSpec.describe Resolvers::BlobsResolver do
end
end
+ context 'when specifying HEAD ref' do
+ let(:ref) { 'HEAD' }
+
+ it 'returns the specified blobs for HEAD' do
+ is_expected.to contain_exactly(have_attributes(path: 'README.md'))
+ end
+ end
+
context 'when specifying an invalid ref' do
let(:ref) { 'ma:in' }
diff --git a/spec/graphql/resolvers/ci/all_jobs_resolver_spec.rb b/spec/graphql/resolvers/ci/all_jobs_resolver_spec.rb
index 5c632ed3443..fddc73fadfe 100644
--- a/spec/graphql/resolvers/ci/all_jobs_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/all_jobs_resolver_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Resolvers::Ci::AllJobsResolver do
+RSpec.describe Resolvers::Ci::AllJobsResolver, feature_category: :continuous_integration do
include GraphqlHelpers
let_it_be(:successful_job) { create(:ci_build, :success, name: 'Job One') }
diff --git a/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb b/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb
index 5d06db904d5..ff343f3f43d 100644
--- a/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb
@@ -78,7 +78,7 @@ RSpec.describe Resolvers::Ci::GroupRunnersResolver, feature_category: :runner_fl
status_status: 'active',
type_type: :group_type,
tag_name: ['active_runner'],
- preload: { tag_name: false },
+ preload: false,
search: 'abc',
sort: 'contacted_asc',
membership: :descendants,
diff --git a/spec/graphql/resolvers/ci/inherited_variables_resolver_spec.rb b/spec/graphql/resolvers/ci/inherited_variables_resolver_spec.rb
new file mode 100644
index 00000000000..6837d4b0459
--- /dev/null
+++ b/spec/graphql/resolvers/ci/inherited_variables_resolver_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Ci::InheritedVariablesResolver, feature_category: :secrets_management do
+ include GraphqlHelpers
+
+ describe '#resolve' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:project) { create(:project, group: subgroup) }
+ let_it_be(:project_without_group) { create(:project) }
+
+ let_it_be(:inherited_ci_variables) do
+ [
+ create(:ci_group_variable, group: group, key: 'GROUP_VAR_A'),
+ create(:ci_group_variable, group: subgroup, key: 'SUBGROUP_VAR_B')
+ ]
+ end
+
+ subject(:resolve_variables) { resolve(described_class, obj: obj, ctx: { current_user: user }, args: {}) }
+
+ context 'when project does not have a group' do
+ let_it_be(:obj) { project_without_group }
+
+ it 'returns an empty array' do
+ expect(resolve_variables.items.to_a).to match_array([])
+ end
+ end
+
+ context 'when project belongs to a group' do
+ let_it_be(:obj) { project }
+
+ it 'returns variables from parent group and ancestors' do
+ expect(resolve_variables.items.to_a).to match_array(inherited_ci_variables)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/ci/jobs_resolver_spec.rb b/spec/graphql/resolvers/ci/jobs_resolver_spec.rb
index 581652a8cea..b99eb56d6ab 100644
--- a/spec/graphql/resolvers/ci/jobs_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/jobs_resolver_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Resolvers::Ci::JobsResolver do
+RSpec.describe Resolvers::Ci::JobsResolver, feature_category: :continuous_integration do
include GraphqlHelpers
let_it_be(:project) { create(:project, :repository, :public) }
@@ -14,10 +14,11 @@ RSpec.describe Resolvers::Ci::JobsResolver do
create(:ci_build, :dast, name: 'SAST job', pipeline: pipeline)
create(:ci_build, :container_scanning, name: 'Container scanning job', pipeline: pipeline)
create(:ci_build, name: 'Job with tags', pipeline: pipeline, tag_list: ['review'])
+ create(:ci_bridge, name: 'Bridge job', pipeline: pipeline)
end
describe '#resolve' do
- context 'when security_report_types is empty' do
+ context 'when none of the optional params are given' do
it "returns all of the pipeline's jobs" do
jobs = resolve(described_class, obj: pipeline, arg_style: :internal)
@@ -26,7 +27,8 @@ RSpec.describe Resolvers::Ci::JobsResolver do
have_attributes(name: 'DAST job'),
have_attributes(name: 'SAST job'),
have_attributes(name: 'Container scanning job'),
- have_attributes(name: 'Job with tags')
+ have_attributes(name: 'Job with tags'),
+ have_attributes(name: 'Bridge job')
)
end
end
@@ -50,12 +52,14 @@ RSpec.describe Resolvers::Ci::JobsResolver do
context 'when a job has tags' do
it "returns jobs with tags when applicable" do
jobs = resolve(described_class, obj: pipeline, arg_style: :internal)
+
expect(jobs).to contain_exactly(
have_attributes(tag_list: []),
have_attributes(tag_list: []),
have_attributes(tag_list: []),
have_attributes(tag_list: []),
- have_attributes(tag_list: ['review'])
+ have_attributes(tag_list: ['review']),
+ have_attributes(name: 'Bridge job') # A bridge job has no tag list
)
end
end
@@ -72,5 +76,14 @@ RSpec.describe Resolvers::Ci::JobsResolver do
)
end
end
+
+ context 'when filtering by job kind' do
+ it "returns jobs with that type" do
+ jobs = resolve(described_class, obj: pipeline, arg_style: :internal, args: { job_kind: ::Ci::Bridge })
+ expect(jobs).to contain_exactly(
+ have_attributes(name: 'Bridge job')
+ )
+ end
+ end
end
end
diff --git a/spec/graphql/resolvers/ci/project_runners_resolver_spec.rb b/spec/graphql/resolvers/ci/project_runners_resolver_spec.rb
index 4cc00ced104..83435db2ea7 100644
--- a/spec/graphql/resolvers/ci/project_runners_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/project_runners_resolver_spec.rb
@@ -67,7 +67,7 @@ RSpec.describe Resolvers::Ci::ProjectRunnersResolver, feature_category: :runner_
status_status: 'active',
type_type: :group_type,
tag_name: ['active_runner'],
- preload: { tag_name: false },
+ preload: false,
search: 'abc',
sort: 'contacted_asc',
project: project
diff --git a/spec/graphql/resolvers/ci/runner_projects_resolver_spec.rb b/spec/graphql/resolvers/ci/runner_projects_resolver_spec.rb
index 6c69cdc19cc..44203fb2912 100644
--- a/spec/graphql/resolvers/ci/runner_projects_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/runner_projects_resolver_spec.rb
@@ -27,6 +27,28 @@ RSpec.describe Resolvers::Ci::RunnerProjectsResolver, feature_category: :runner_
end
end
+ context 'with sort argument' do
+ let(:args) { { sort: sort } }
+
+ context 'when :id_asc' do
+ let(:sort) { :id_asc }
+
+ it 'returns a lazy value with projects sorted by :id_asc' do
+ expect(subject).to be_a(GraphQL::Execution::Lazy)
+ expect(subject.value.items).to eq([project1, project2, project3])
+ end
+ end
+
+ context 'when :id_desc' do
+ let(:sort) { :id_desc }
+
+ it 'returns a lazy value with projects sorted by :id_desc' do
+ expect(subject).to be_a(GraphQL::Execution::Lazy)
+ expect(subject.value.items).to eq([project3, project2, project1])
+ end
+ end
+ end
+
context 'with supported arguments' do
let(:args) { { membership: true, search_namespaces: true, topics: %w[xyz] } }
@@ -47,9 +69,9 @@ RSpec.describe Resolvers::Ci::RunnerProjectsResolver, feature_category: :runner_
end
context 'without arguments' do
- it 'returns a lazy value with all projects' do
+ it 'returns a lazy value with all projects sorted by :id_asc' do
expect(subject).to be_a(GraphQL::Execution::Lazy)
- expect(subject.value).to contain_exactly(project1, project2, project3)
+ expect(subject.value.items).to eq([project1, project2, project3])
end
end
end
diff --git a/spec/graphql/resolvers/ci/runner_status_resolver_spec.rb b/spec/graphql/resolvers/ci/runner_status_resolver_spec.rb
index 2bea256856d..97a10a7da33 100644
--- a/spec/graphql/resolvers/ci/runner_status_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/runner_status_resolver_spec.rb
@@ -9,32 +9,12 @@ RSpec.describe Resolvers::Ci::RunnerStatusResolver, feature_category: :runner_fl
let(:user) { build(:user) }
let(:runner) { build(:ci_runner) }
- subject(:resolve_subject) { resolve(described_class, ctx: { current_user: user }, obj: runner, args: args) }
+ subject(:resolve_subject) { resolve(described_class, ctx: { current_user: user }, obj: runner) }
- context 'with legacy_mode' do
- context 'set to 14.5' do
- let(:args) do
- { legacy_mode: '14.5' }
- end
+ it 'calls runner.status and returns it' do
+ expect(runner).to receive(:status).once.and_return(:stale)
- it 'calls runner.status with specified legacy_mode' do
- expect(runner).to receive(:status).with('14.5').once.and_return(:online)
-
- expect(resolve_subject).to eq(:online)
- end
- end
-
- context 'set to nil' do
- let(:args) do
- { legacy_mode: nil }
- end
-
- it 'calls runner.status with specified legacy_mode' do
- expect(runner).to receive(:status).with(nil).once.and_return(:stale)
-
- expect(resolve_subject).to eq(:stale)
- end
- end
+ expect(resolve_subject).to eq(:stale)
end
end
end
diff --git a/spec/graphql/resolvers/ci/runners_resolver_spec.rb b/spec/graphql/resolvers/ci/runners_resolver_spec.rb
index d6da8222234..e4620b96cae 100644
--- a/spec/graphql/resolvers/ci/runners_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/runners_resolver_spec.rb
@@ -83,7 +83,7 @@ RSpec.describe Resolvers::Ci::RunnersResolver, feature_category: :runner_fleet d
upgrade_status: 'recommended',
type_type: :instance_type,
tag_name: ['active_runner'],
- preload: { tag_name: false },
+ preload: false,
search: 'abc',
sort: 'contacted_asc'
}
@@ -108,7 +108,7 @@ RSpec.describe Resolvers::Ci::RunnersResolver, feature_category: :runner_fleet d
let(:expected_params) do
{
active: false,
- preload: { tag_name: false }
+ preload: false
}
end
@@ -128,7 +128,7 @@ RSpec.describe Resolvers::Ci::RunnersResolver, feature_category: :runner_fleet d
let(:expected_params) do
{
active: false,
- preload: { tag_name: false }
+ preload: false
}
end
@@ -146,9 +146,7 @@ RSpec.describe Resolvers::Ci::RunnersResolver, feature_category: :runner_fleet d
end
let(:expected_params) do
- {
- preload: { tag_name: false }
- }
+ { preload: false }
end
it 'calls RunnersFinder with expected arguments' do
diff --git a/spec/graphql/resolvers/ci/variables_resolver_spec.rb b/spec/graphql/resolvers/ci/variables_resolver_spec.rb
index 16b72e8cb7f..42227df1fe5 100644
--- a/spec/graphql/resolvers/ci/variables_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/variables_resolver_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Resolvers::Ci::VariablesResolver, feature_category: :pipeline_authoring do
+RSpec.describe Resolvers::Ci::VariablesResolver, feature_category: :secrets_management do
include GraphqlHelpers
describe '#resolve' do
diff --git a/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb b/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb
index 866f4ce7b5a..dfd1addff71 100644
--- a/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb
+++ b/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Resolvers::Clusters::AgentTokensResolver do
it { expect(described_class.type).to eq(Types::Clusters::AgentTokenType) }
it { expect(described_class.null).to be_truthy }
- it { expect(described_class.arguments.keys).to contain_exactly('status') }
+ it { expect(described_class.arguments.keys).to be_empty }
describe '#resolve' do
let(:agent) { create(:cluster_agent) }
@@ -16,22 +16,15 @@ RSpec.describe Resolvers::Clusters::AgentTokensResolver do
let!(:matching_token1) { create(:cluster_agent_token, agent: agent, last_used_at: 5.days.ago) }
let!(:matching_token2) { create(:cluster_agent_token, agent: agent, last_used_at: 2.days.ago) }
+ let!(:revoked_token) { create(:cluster_agent_token, :revoked, agent: agent) }
let!(:other_token) { create(:cluster_agent_token) }
subject { resolve(described_class, obj: agent, ctx: ctx) }
- it 'returns tokens associated with the agent, ordered by last_used_at' do
+ it 'returns active tokens associated with the agent, ordered by last_used_at' do
expect(subject).to eq([matching_token2, matching_token1])
end
- context 'token status is specified' do
- let!(:revoked_token) { create(:cluster_agent_token, :revoked, agent: agent) }
-
- subject { resolve(described_class, obj: agent, ctx: ctx, args: { status: 'revoked' }) }
-
- it { is_expected.to contain_exactly(revoked_token) }
- end
-
context 'user does not have permission' do
let(:user) { create(:user) }
diff --git a/spec/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver_spec.rb b/spec/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver_spec.rb
new file mode 100644
index 00000000000..b5280365794
--- /dev/null
+++ b/spec/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Clusters::Agents::Authorizations::CiAccessResolver,
+ feature_category: :deployment_management do
+ include GraphqlHelpers
+
+ it { expect(described_class.type).to eq(Types::Clusters::Agents::Authorizations::CiAccessType) }
+ it { expect(described_class.null).to be_truthy }
+
+ describe '#resolve' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, maintainer_projects: [project]) }
+
+ let(:ctx) { { current_user: user } }
+
+ subject { resolve(described_class, obj: project, ctx: ctx) }
+
+ it 'calls the finder' do
+ expect_next_instance_of(::Clusters::Agents::Authorizations::CiAccess::Finder, project) do |finder|
+ expect(finder).to receive(:execute)
+ end
+
+ subject
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/clusters/agents/authorizations/user_access_resolver_spec.rb b/spec/graphql/resolvers/clusters/agents/authorizations/user_access_resolver_spec.rb
new file mode 100644
index 00000000000..b7e2fef78eb
--- /dev/null
+++ b/spec/graphql/resolvers/clusters/agents/authorizations/user_access_resolver_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Clusters::Agents::Authorizations::UserAccessResolver,
+ feature_category: :deployment_management do
+ include GraphqlHelpers
+
+ it { expect(described_class.type).to eq(Types::Clusters::Agents::Authorizations::UserAccessType) }
+ it { expect(described_class.null).to be_truthy }
+
+ describe '#resolve' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, maintainer_projects: [project]) }
+
+ let(:ctx) { { current_user: user } }
+
+ subject { resolve(described_class, obj: project, ctx: ctx) }
+
+ it 'calls the finder' do
+ expect_next_instance_of(::Clusters::Agents::Authorizations::UserAccess::Finder,
+ user, project: project) do |finder|
+ expect(finder).to receive(:execute)
+ end
+
+ subject
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/crm/contacts_resolver_spec.rb b/spec/graphql/resolvers/crm/contacts_resolver_spec.rb
index c7c2d11e114..1a53f42633f 100644
--- a/spec/graphql/resolvers/crm/contacts_resolver_spec.rb
+++ b/spec/graphql/resolvers/crm/contacts_resolver_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe Resolvers::Crm::ContactsResolver do
last_name: "DEF",
email: "ghi@test.com",
description: "LMNO",
- organization: create(:organization, group: group),
+ organization: create(:crm_organization, group: group),
state: "inactive"
)
end
diff --git a/spec/graphql/resolvers/crm/organization_state_counts_resolver_spec.rb b/spec/graphql/resolvers/crm/organization_state_counts_resolver_spec.rb
index c6ad4beeee0..f6c2040b7d0 100644
--- a/spec/graphql/resolvers/crm/organization_state_counts_resolver_spec.rb
+++ b/spec/graphql/resolvers/crm/organization_state_counts_resolver_spec.rb
@@ -9,10 +9,10 @@ RSpec.describe Resolvers::Crm::OrganizationStateCountsResolver do
let_it_be(:group) { create(:group, :crm_enabled) }
before_all do
- create(:organization, group: group, name: "ABC Corp")
- create(:organization, group: group, name: "123 Corp", state: 'inactive')
- create_list(:organization, 3, group: group)
- create_list(:organization, 2, group: group, state: 'inactive')
+ create(:crm_organization, group: group, name: "ABC Corp")
+ create(:crm_organization, group: group, name: "123 Corp", state: 'inactive')
+ create_list(:crm_organization, 3, group: group)
+ create_list(:crm_organization, 2, group: group, state: 'inactive')
end
describe '#resolve' do
@@ -36,7 +36,7 @@ RSpec.describe Resolvers::Crm::OrganizationStateCountsResolver do
context 'with a group' do
context 'when no filter is provided' do
- it 'returns the count of all organizations' do
+ it 'returns the count of all crm_organizations' do
counts = resolve_counts(group)
expect(counts['active']).to eq(4)
expect(counts['inactive']).to eq(3)
diff --git a/spec/graphql/resolvers/crm/organizations_resolver_spec.rb b/spec/graphql/resolvers/crm/organizations_resolver_spec.rb
index d5980bf3c41..edc1986799a 100644
--- a/spec/graphql/resolvers/crm/organizations_resolver_spec.rb
+++ b/spec/graphql/resolvers/crm/organizations_resolver_spec.rb
@@ -8,18 +8,18 @@ RSpec.describe Resolvers::Crm::OrganizationsResolver do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :crm_enabled) }
- let_it_be(:organization_a) do
+ let_it_be(:crm_organization_a) do
create(
- :organization,
+ :crm_organization,
group: group,
name: "ABC",
state: "inactive"
)
end
- let_it_be(:organization_b) do
+ let_it_be(:crm_organization_b) do
create(
- :organization,
+ :crm_organization,
group: group,
name: "DEF",
state: "active"
@@ -28,23 +28,23 @@ RSpec.describe Resolvers::Crm::OrganizationsResolver do
describe '#resolve' do
context 'with unauthorized user' do
- it 'does not rise an error and returns no organizations' do
+ it 'does not rise an error and returns no crm_organizations' do
expect { resolve_organizations(group) }.not_to raise_error
expect(resolve_organizations(group)).to be_empty
end
end
context 'with authorized user' do
- it 'does not rise an error and returns all organizations in the correct order' do
+ it 'does not rise an error and returns all crm_organizations in the correct order' do
group.add_reporter(user)
expect { resolve_organizations(group) }.not_to raise_error
- expect(resolve_organizations(group)).to eq([organization_a, organization_b])
+ expect(resolve_organizations(group)).to eq([crm_organization_a, crm_organization_b])
end
end
context 'without parent' do
- it 'returns no organizations' do
+ it 'returns no crm_organizations' do
expect(resolve_organizations(nil)).to be_empty
end
end
@@ -55,40 +55,42 @@ RSpec.describe Resolvers::Crm::OrganizationsResolver do
end
context 'when no filter is provided' do
- it 'returns all the organizations in the default order' do
- expect(resolve_organizations(group)).to eq([organization_a, organization_b])
+ it 'returns all the crm_organizations in the default order' do
+ expect(resolve_organizations(group)).to eq([crm_organization_a, crm_organization_b])
end
end
context 'when a sort is provided' do
- it 'returns all the organizations in the correct order' do
- expect(resolve_organizations(group, { sort: 'NAME_DESC' })).to eq([organization_b, organization_a])
+ it 'returns all the crm_organizations in the correct order' do
+ expect(resolve_organizations(group, { sort: 'NAME_DESC' })).to eq([crm_organization_b, crm_organization_a])
end
end
context 'when filtering for all states' do
- it 'returns all the organizations' do
- expect(resolve_organizations(group, { state: 'all' })).to contain_exactly(organization_a, organization_b)
+ it 'returns all the crm_organizations' do
+ expect(resolve_organizations(group, { state: 'all' })).to contain_exactly(
+ crm_organization_a, crm_organization_b
+ )
end
end
context 'when search term is provided' do
- it 'returns the correct organizations' do
- expect(resolve_organizations(group, { search: "def" })).to contain_exactly(organization_b)
+ it 'returns the correct crm_organizations' do
+ expect(resolve_organizations(group, { search: "def" })).to contain_exactly(crm_organization_b)
end
end
context 'when state is provided' do
- it 'returns the correct organizations' do
- expect(resolve_organizations(group, { state: :inactive })).to contain_exactly(organization_a)
+ it 'returns the correct crm_organizations' do
+ expect(resolve_organizations(group, { state: :inactive })).to contain_exactly(crm_organization_a)
end
end
context 'when ids are provided' do
- it 'returns the correct organizations' do
+ it 'returns the correct crm_organizations' do
expect(resolve_organizations(group, {
- ids: [organization_b.to_global_id]
- })).to contain_exactly(organization_b)
+ ids: [crm_organization_b.to_global_id]
+ })).to contain_exactly(crm_organization_b)
end
end
end
diff --git a/spec/graphql/resolvers/data_transfer/group_data_transfer_resolver_spec.rb b/spec/graphql/resolvers/data_transfer/group_data_transfer_resolver_spec.rb
new file mode 100644
index 00000000000..4ea3d287454
--- /dev/null
+++ b/spec/graphql/resolvers/data_transfer/group_data_transfer_resolver_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::DataTransfer::GroupDataTransferResolver, feature_category: :source_code_management do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:current_user) { create(:user) }
+
+ let(:from) { Date.new(2022, 1, 1) }
+ let(:to) { Date.new(2023, 1, 1) }
+ let(:finder_results) do
+ [
+ build(:project_data_transfer, date: to, repository_egress: 250000)
+ ]
+ end
+
+ context 'with anonymous access' do
+ let_it_be(:current_user) { nil }
+
+ it 'does not raise an error and returns no data' do
+ expect { resolve_egress }.not_to raise_error
+ expect(resolve_egress).to be_nil
+ end
+ end
+
+ context 'with authorized user but without enough permissions' do
+ it 'does not raise an error and returns no data' do
+ group.add_developer(current_user)
+
+ expect { resolve_egress }.not_to raise_error
+ expect(resolve_egress).to be_nil
+ end
+ end
+
+ context 'when user has permissions to see data transfer' do
+ before do
+ group.add_owner(current_user)
+ end
+
+ include_examples 'Data transfer resolver'
+
+ context 'when data_transfer_monitoring_mock_data is disabled' do
+ let(:finder) { instance_double(::DataTransfer::GroupDataTransferFinder) }
+
+ before do
+ stub_feature_flags(data_transfer_monitoring_mock_data: false)
+ end
+
+ it 'calls GroupDataTransferFinder with expected arguments' do
+ expect(::DataTransfer::GroupDataTransferFinder).to receive(:new).with(
+ group: group, from: from, to: to, user: current_user
+ ).once.and_return(finder)
+ allow(finder).to receive(:execute).once.and_return(finder_results)
+
+ expect(resolve_egress).to eq({ egress_nodes: finder_results.map(&:attributes) })
+ end
+ end
+ end
+
+ def resolve_egress
+ resolve(described_class, obj: group, args: { from: from, to: to }, ctx: { current_user: current_user })
+ end
+end
diff --git a/spec/graphql/resolvers/data_transfer/project_data_transfer_resolver_spec.rb b/spec/graphql/resolvers/data_transfer/project_data_transfer_resolver_spec.rb
new file mode 100644
index 00000000000..7307c1a54a9
--- /dev/null
+++ b/spec/graphql/resolvers/data_transfer/project_data_transfer_resolver_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::DataTransfer::ProjectDataTransferResolver, feature_category: :source_code_management do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:current_user) { create(:user) }
+
+ let(:from) { Date.new(2022, 1, 1) }
+ let(:to) { Date.new(2023, 1, 1) }
+ let(:finder_results) do
+ [
+ {
+ date: to,
+ repository_egress: 250000
+ }
+ ]
+ end
+
+ context 'with anonymous access' do
+ let_it_be(:current_user) { nil }
+
+ it 'does not raise an error and returns no data' do
+ expect { resolve_egress }.not_to raise_error
+ expect(resolve_egress).to be_nil
+ end
+ end
+
+ context 'with authorized user but without enough permissions' do
+ it 'does not raise an error and returns no data' do
+ project.add_developer(current_user)
+
+ expect { resolve_egress }.not_to raise_error
+ expect(resolve_egress).to be_nil
+ end
+ end
+
+ context 'when user has permissions to see data transfer' do
+ before do
+ project.add_owner(current_user)
+ end
+
+ include_examples 'Data transfer resolver'
+
+ context 'when data_transfer_monitoring_mock_data is disabled' do
+ let(:finder) { instance_double(::DataTransfer::ProjectDataTransferFinder) }
+
+ before do
+ stub_feature_flags(data_transfer_monitoring_mock_data: false)
+ end
+
+ it 'calls ProjectDataTransferFinder with expected arguments' do
+ expect(::DataTransfer::ProjectDataTransferFinder).to receive(:new).with(
+ project: project, from: from, to: to, user: current_user
+ ).once.and_return(finder)
+ allow(finder).to receive(:execute).once.and_return(finder_results)
+
+ expect(resolve_egress).to eq({ egress_nodes: finder_results })
+ end
+ end
+ end
+
+ def resolve_egress
+ resolve(described_class, obj: project, args: { from: from, to: to }, ctx: { current_user: current_user })
+ end
+end
diff --git a/spec/graphql/resolvers/data_transfer_resolver_spec.rb b/spec/graphql/resolvers/data_transfer_resolver_spec.rb
deleted file mode 100644
index f5a088dc1c3..00000000000
--- a/spec/graphql/resolvers/data_transfer_resolver_spec.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Resolvers::DataTransferResolver, feature_category: :source_code_management do
- include GraphqlHelpers
-
- describe '.source' do
- context 'with base DataTransferResolver' do
- it 'raises NotImplementedError' do
- expect { described_class.source }.to raise_error ::NotImplementedError
- end
- end
-
- context 'with projects DataTransferResolver' do
- let(:source) { described_class.project.source }
-
- it 'outputs "Project"' do
- expect(source).to eq 'Project'
- end
- end
-
- context 'with groups DataTransferResolver' do
- let(:source) { described_class.group.source }
-
- it 'outputs "Group"' do
- expect(source).to eq 'Group'
- end
- end
- end
-end
diff --git a/spec/graphql/resolvers/group_labels_resolver_spec.rb b/spec/graphql/resolvers/group_labels_resolver_spec.rb
index 71290885e6b..341448d7add 100644
--- a/spec/graphql/resolvers/group_labels_resolver_spec.rb
+++ b/spec/graphql/resolvers/group_labels_resolver_spec.rb
@@ -48,6 +48,40 @@ RSpec.describe Resolvers::GroupLabelsResolver do
end
end
+ describe 'association preloading', :saas do
+ let(:params) do
+ {
+ include_ancestor_groups: true,
+ include_descendant_groups: true,
+ only_group_labels: false
+ }
+ end
+
+ before do
+ group.add_developer(current_user)
+
+ # warmup
+ resolve_labels(group, params).to_a
+ end
+
+ it 'prevents N+1 queries' do
+ control = Gitlab::WithRequestStore.with_request_store do
+ ActiveRecord::QueryRecorder.new { resolve_labels(group, params).to_a }
+ end
+
+ another_project = create(:project, :private, group: sub_subgroup)
+ another_subgroup = create(:group, :private, parent: group)
+ create(:label, project: another_project, name: 'another project feature')
+ create(:group_label, group: another_subgroup, name: 'another group feature')
+
+ expect do
+ Gitlab::WithRequestStore.with_request_store do
+ resolve_labels(group, params).to_a
+ end
+ end.not_to exceed_query_limit(control.count)
+ end
+ end
+
context 'at group level' do
before_all do
group.add_developer(current_user)
diff --git a/spec/graphql/resolvers/group_milestones_resolver_spec.rb b/spec/graphql/resolvers/group_milestones_resolver_spec.rb
index a32a031a88f..b9b8ef1870b 100644
--- a/spec/graphql/resolvers/group_milestones_resolver_spec.rb
+++ b/spec/graphql/resolvers/group_milestones_resolver_spec.rb
@@ -2,15 +2,15 @@
require 'spec_helper'
-RSpec.describe Resolvers::GroupMilestonesResolver do
+RSpec.describe Resolvers::GroupMilestonesResolver, feature_category: :team_planning do
using RSpec::Parameterized::TableSyntax
include GraphqlHelpers
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
- def resolve_group_milestones(args = {}, context = { current_user: current_user })
- resolve(described_class, obj: group, args: args, ctx: context, arg_style: :internal)
+ def resolve_group_milestones(args: {}, context: { current_user: current_user }, arg_style: :internal)
+ resolve(described_class, obj: group, args: args, ctx: context, arg_style: arg_style)
end
let_it_be(:now) { Time.now }
@@ -45,18 +45,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
end
context 'with parameters' do
- it 'calls MilestonesFinder with correct parameters' do
- start_date = now
- end_date = start_date + 1.hour
-
- expect(MilestonesFinder).to receive(:new)
- .with(args(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date))
- .and_call_original
-
- resolve_group_milestones(start_date: start_date, end_date: end_date, state: 'closed')
- end
-
- it 'understands the timeframe argument' do
+ it 'timeframe argument' do
start_date = now
end_date = start_date + 1.hour
@@ -64,7 +53,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
.with(args(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date))
.and_call_original
- resolve_group_milestones(timeframe: { start: start_date, end: end_date }, state: 'closed')
+ resolve_group_milestones(args: { timeframe: { start: start_date, end: end_date }, state: 'closed' })
end
end
@@ -76,7 +65,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
.with(args(ids: [milestone.id.to_s], group_ids: group.id, state: 'all'))
.and_call_original
- resolve_group_milestones(ids: [milestone.to_global_id])
+ resolve_group_milestones(args: { ids: [milestone.to_global_id] })
end
end
@@ -86,12 +75,12 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
.with(args(group_ids: group.id, state: 'all', sort: :due_date_desc))
.and_call_original
- resolve_group_milestones(sort: :due_date_desc)
+ resolve_group_milestones(args: { sort: :due_date_desc })
end
%i[expired_last_due_date_asc expired_last_due_date_desc].each do |sort_by|
it "uses offset-pagination when sorting by #{sort_by}" do
- resolved = resolve_group_milestones(sort: sort_by)
+ resolved = resolve_group_milestones(args: { sort: sort_by })
expect(resolved).to be_a(::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection)
end
@@ -99,31 +88,18 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
end
context 'by timeframe' do
- context 'when start_date and end_date are present' do
- context 'when start date is after end_date' do
- it 'generates an error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, "startDate is after endDate") do
- resolve_group_milestones(start_date: now, end_date: now - 2.days)
+ context 'when timeframe start and end are present' do
+ context 'when start is after end' do
+ it 'raises error' do
+ expect_graphql_error_to_be_created(::Gitlab::Graphql::Errors::ArgumentError, 'start must be before end') do
+ resolve_group_milestones(
+ args: { timeframe: { start: now.to_date, end: now.to_date - 2.days } },
+ arg_style: :internal_prepared
+ )
end
end
end
end
-
- context 'when only start_date is present' do
- it 'generates an error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) do
- resolve_group_milestones(start_date: now)
- end
- end
- end
-
- context 'when only end_date is present' do
- it 'generates an error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) do
- resolve_group_milestones(end_date: now)
- end
- end
- end
end
context 'when including descendant milestones in a public group' do
@@ -143,7 +119,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
create(:milestone, group: inaccessible_group)
create(:milestone, project: inaccessible_project)
- expect(resolve_group_milestones(args)).to match_array([milestone1, milestone2, milestone3])
+ expect(resolve_group_milestones(args: args)).to match_array([milestone1, milestone2, milestone3])
end
end
@@ -169,7 +145,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
let(:args) { {} }
it 'finds milestones only in accessible projects and groups' do
- expect(resolve_group_milestones(args)).to match_array([milestone1])
+ expect(resolve_group_milestones(args: args)).to match_array([milestone1])
end
end
@@ -177,7 +153,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
let(:args) { { include_descendants: true } }
it 'finds milestones only in accessible projects and groups' do
- expect(resolve_group_milestones(args)).to match_array([milestone1, milestone2, milestone3])
+ expect(resolve_group_milestones(args: args)).to match_array([milestone1, milestone2, milestone3])
end
end
@@ -185,7 +161,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
let(:args) { { include_ancestors: true } }
it 'finds milestones only in accessible projects and groups' do
- expect(resolve_group_milestones(args)).to match_array([milestone1, milestone6])
+ expect(resolve_group_milestones(args: args)).to match_array([milestone1, milestone6])
end
end
@@ -193,7 +169,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
let(:args) { { include_descendants: true, include_ancestors: true } }
it 'finds milestones only in accessible projects and groups' do
- expect(resolve_group_milestones(args)).to match_array([milestone1, milestone2, milestone3, milestone6])
+ expect(resolve_group_milestones(args: args)).to match_array([milestone1, milestone2, milestone3, milestone6])
end
end
end
diff --git a/spec/graphql/resolvers/labels_resolver_spec.rb b/spec/graphql/resolvers/labels_resolver_spec.rb
index efd2596b9eb..8196315dd7c 100644
--- a/spec/graphql/resolvers/labels_resolver_spec.rb
+++ b/spec/graphql/resolvers/labels_resolver_spec.rb
@@ -48,6 +48,40 @@ RSpec.describe Resolvers::LabelsResolver do
end
end
+ describe 'association preloading' do
+ let_it_be(:project) { create(:project, :private, group: sub_subgroup) }
+
+ let(:params) do
+ {
+ include_ancestor_groups: true
+ }
+ end
+
+ before do
+ group.add_developer(current_user)
+
+ # warmup
+ resolve_labels(project, params).to_a
+ end
+
+ it 'prevents N+1 queries' do
+ control = Gitlab::WithRequestStore.with_request_store do
+ ActiveRecord::QueryRecorder.new { resolve_labels(project, params).to_a }
+ end
+
+ another_project = create(:project, :private, group: subgroup)
+ another_subgroup = create(:group, :private, parent: group)
+ create(:label, project: another_project, name: 'another project feature')
+ create(:group_label, group: another_subgroup, name: 'another group feature')
+
+ expect do
+ Gitlab::WithRequestStore.with_request_store do
+ resolve_labels(project, params).to_a
+ end
+ end.not_to exceed_query_limit(control.count)
+ end
+ end
+
context 'with a parent project' do
before_all do
group.add_developer(current_user)
diff --git a/spec/graphql/resolvers/metrics/dashboard_resolver_spec.rb b/spec/graphql/resolvers/metrics/dashboard_resolver_spec.rb
index 4112e3d4fe6..354fd350aa7 100644
--- a/spec/graphql/resolvers/metrics/dashboard_resolver_spec.rb
+++ b/spec/graphql/resolvers/metrics/dashboard_resolver_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Resolvers::Metrics::DashboardResolver do
+RSpec.describe Resolvers::Metrics::DashboardResolver, feature_category: :metrics do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
@@ -21,6 +21,7 @@ RSpec.describe Resolvers::Metrics::DashboardResolver do
let(:parent_object) { create(:environment, project: project) }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
project.add_developer(current_user)
end
@@ -39,6 +40,17 @@ RSpec.describe Resolvers::Metrics::DashboardResolver do
expect(resolve_dashboard).to be_nil
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns nil', :aggregate_failures do
+ expect(PerformanceMonitoring::PrometheusDashboard).not_to receive(:find_for)
+ expect(resolve_dashboard).to be_nil
+ end
+ end
end
end
end
diff --git a/spec/graphql/resolvers/metrics/dashboards/annotation_resolver_spec.rb b/spec/graphql/resolvers/metrics/dashboards/annotation_resolver_spec.rb
index a83cef40bdf..2ca194d519c 100644
--- a/spec/graphql/resolvers/metrics/dashboards/annotation_resolver_spec.rb
+++ b/spec/graphql/resolvers/metrics/dashboards/annotation_resolver_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Resolvers::Metrics::Dashboards::AnnotationResolver do
+RSpec.describe Resolvers::Metrics::Dashboards::AnnotationResolver, feature_category: :metrics do
include GraphqlHelpers
describe '#resolve' do
@@ -25,6 +25,10 @@ RSpec.describe Resolvers::Metrics::Dashboards::AnnotationResolver do
environment.project.add_developer(current_user)
end
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
context 'with annotation records' do
let_it_be(:annotation_1) { create(:metrics_dashboard_annotation, environment: environment, starting_at: 9.minutes.ago, dashboard_path: path) }
@@ -55,6 +59,16 @@ RSpec.describe Resolvers::Metrics::Dashboards::AnnotationResolver do
expect(resolve_annotations).to be_empty
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns nothing' do
+ expect(resolve_annotations).to be_nil
+ end
+ end
end
end
end
diff --git a/spec/graphql/resolvers/paginated_tree_resolver_spec.rb b/spec/graphql/resolvers/paginated_tree_resolver_spec.rb
index 9a04b716001..931d4ba132c 100644
--- a/spec/graphql/resolvers/paginated_tree_resolver_spec.rb
+++ b/spec/graphql/resolvers/paginated_tree_resolver_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Resolvers::PaginatedTreeResolver do
+RSpec.describe Resolvers::PaginatedTreeResolver, feature_category: :source_code_management do
include GraphqlHelpers
let_it_be(:project) { create(:project, :repository) }
@@ -61,6 +61,16 @@ RSpec.describe Resolvers::PaginatedTreeResolver do
end
end
+ context 'when repository is empty' do
+ before do
+ allow(repository).to receive(:empty?).and_return(true)
+ end
+
+ it 'returns nil' do
+ is_expected.to be(nil)
+ end
+ end
+
describe 'Cursor pagination' do
context 'when cursor is invalid' do
let(:args) { super().merge(after: 'invalid') }
diff --git a/spec/graphql/resolvers/project_issues_resolver_spec.rb b/spec/graphql/resolvers/project_issues_resolver_spec.rb
index b2796ad9b18..a510baab5a9 100644
--- a/spec/graphql/resolvers/project_issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/project_issues_resolver_spec.rb
@@ -359,9 +359,9 @@ RSpec.describe Resolvers::ProjectIssuesResolver do
end
describe 'filtering by crm' do
- let_it_be(:organization) { create(:organization, group: group) }
- let_it_be(:contact1) { create(:contact, group: group, organization: organization) }
- let_it_be(:contact2) { create(:contact, group: group, organization: organization) }
+ let_it_be(:crm_organization) { create(:crm_organization, group: group) }
+ let_it_be(:contact1) { create(:contact, group: group, organization: crm_organization) }
+ let_it_be(:contact2) { create(:contact, group: group, organization: crm_organization) }
let_it_be(:contact3) { create(:contact, group: group) }
let_it_be(:crm_issue1) { create(:issue, project: project) }
let_it_be(:crm_issue2) { create(:issue, project: project) }
@@ -381,9 +381,9 @@ RSpec.describe Resolvers::ProjectIssuesResolver do
end
end
- context 'when filtering by organization' do
+ context 'when filtering by crm_organization' do
it 'returns only the issues for the contact' do
- expect(resolve_issues({ crm_organization_id: organization.id })).to contain_exactly(crm_issue1, crm_issue2)
+ expect(resolve_issues({ crm_organization_id: crm_organization.id })).to contain_exactly(crm_issue1, crm_issue2)
end
end
end
diff --git a/spec/graphql/resolvers/project_milestones_resolver_spec.rb b/spec/graphql/resolvers/project_milestones_resolver_spec.rb
index ad1190e3df7..af6b16804b0 100644
--- a/spec/graphql/resolvers/project_milestones_resolver_spec.rb
+++ b/spec/graphql/resolvers/project_milestones_resolver_spec.rb
@@ -94,44 +94,6 @@ RSpec.describe 'Resolvers::ProjectMilestonesResolver' do
end
context 'by timeframe' do
- context 'when start_date and end_date are present' do
- it 'calls MilestonesFinder with correct parameters' do
- start_date = now
- end_date = now + 5.days
-
- expect(MilestonesFinder).to receive(:new)
- .with(args(project_ids: project.id, state: 'all',
- start_date: start_date, end_date: end_date, sort: :due_date_asc))
- .and_call_original
-
- resolve_project_milestones(start_date: start_date, end_date: end_date)
- end
-
- context 'when start date is after end_date' do
- it 'generates an error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'startDate is after endDate') do
- resolve_project_milestones(start_date: now, end_date: now - 2.days)
- end
- end
- end
- end
-
- context 'when only start_date is present' do
- it 'generates an error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) do
- resolve_project_milestones(start_date: now)
- end
- end
- end
-
- context 'when only end_date is present' do
- it 'generates an error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) do
- resolve_project_milestones(end_date: now)
- end
- end
- end
-
context 'when passing a timeframe' do
it 'calls MilestonesFinder with correct parameters' do
start_date = now_date
diff --git a/spec/graphql/resolvers/timelog_resolver_spec.rb b/spec/graphql/resolvers/timelog_resolver_spec.rb
index cd52308d895..5177873321c 100644
--- a/spec/graphql/resolvers/timelog_resolver_spec.rb
+++ b/spec/graphql/resolvers/timelog_resolver_spec.rb
@@ -214,7 +214,11 @@ RSpec.describe Resolvers::TimelogResolver, feature_category: :team_planning do
let_it_be(:timelog3) { create(:merge_request_timelog, merge_request: merge_request, user: current_user) }
it 'blah' do
- expect(timelogs).to contain_exactly(timelog1, timelog3)
+ if user_found
+ expect(timelogs).to contain_exactly(timelog1, timelog3)
+ else
+ expect(timelogs).to be_empty
+ end
end
end
@@ -250,16 +254,28 @@ RSpec.describe Resolvers::TimelogResolver, feature_category: :team_planning do
let(:object) { current_user }
let(:extra_args) { {} }
let(:args) { {} }
+ let(:user_found) { true }
it_behaves_like 'with a user'
end
context 'with a user filter' do
let(:object) { nil }
- let(:extra_args) { { username: current_user.username } }
let(:args) { {} }
- it_behaves_like 'with a user'
+ context 'when the user has timelogs' do
+ let(:extra_args) { { username: current_user.username } }
+ let(:user_found) { true }
+
+ it_behaves_like 'with a user'
+ end
+
+ context 'when the user doest not have timelogs' do
+ let(:extra_args) { { username: 'not_existing_user' } }
+ let(:user_found) { false }
+
+ it_behaves_like 'with a user'
+ end
end
context 'when no object or arguments provided' do
diff --git a/spec/graphql/types/achievements/achievement_type_spec.rb b/spec/graphql/types/achievements/achievement_type_spec.rb
index f967dc8e25e..08fadcdff22 100644
--- a/spec/graphql/types/achievements/achievement_type_spec.rb
+++ b/spec/graphql/types/achievements/achievement_type_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe GitlabSchema.types['Achievement'], feature_category: :user_profil
description
created_at
updated_at
+ user_achievements
]
end
diff --git a/spec/graphql/types/achievements/user_achievement_type_spec.rb b/spec/graphql/types/achievements/user_achievement_type_spec.rb
new file mode 100644
index 00000000000..b7fe4d815f7
--- /dev/null
+++ b/spec/graphql/types/achievements/user_achievement_type_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['UserAchievement'], feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let(:fields) do
+ %w[
+ id
+ achievement
+ user
+ awarded_by_user
+ revoked_by_user
+ created_at
+ updated_at
+ revoked_at
+ ]
+ end
+
+ it { expect(described_class.graphql_name).to eq('UserAchievement') }
+ it { expect(described_class).to have_graphql_fields(fields) }
+ it { expect(described_class).to require_graphql_authorizations(:read_user_achievement) }
+end
diff --git a/spec/graphql/types/ci/catalog/resource_type_spec.rb b/spec/graphql/types/ci/catalog/resource_type_spec.rb
new file mode 100644
index 00000000000..d0bb45a4f1d
--- /dev/null
+++ b/spec/graphql/types/ci/catalog/resource_type_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::Ci::Catalog::ResourceType, feature_category: :pipeline_composition do
+ specify { expect(described_class.graphql_name).to eq('CiCatalogResource') }
+
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ id
+ name
+ description
+ icon
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/ci/config/include_type_enum_spec.rb b/spec/graphql/types/ci/config/include_type_enum_spec.rb
index a88316ae6f2..a75b9018a2e 100644
--- a/spec/graphql/types/ci/config/include_type_enum_spec.rb
+++ b/spec/graphql/types/ci/config/include_type_enum_spec.rb
@@ -6,6 +6,6 @@ RSpec.describe GitlabSchema.types['CiConfigIncludeType'] do
it { expect(described_class.graphql_name).to eq('CiConfigIncludeType') }
it 'exposes all the existing include types' do
- expect(described_class.values.keys).to match_array(%w[remote local file template])
+ expect(described_class.values.keys).to match_array(%w[remote local file template component])
end
end
diff --git a/spec/graphql/types/ci/inherited_ci_variable_type_spec.rb b/spec/graphql/types/ci/inherited_ci_variable_type_spec.rb
new file mode 100644
index 00000000000..daf80ff9978
--- /dev/null
+++ b/spec/graphql/types/ci/inherited_ci_variable_type_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['InheritedCiVariable'], feature_category: :secrets_management do
+ specify do
+ expect(described_class).to have_graphql_fields(
+ :id,
+ :key,
+ :raw,
+ :variable_type,
+ :environment_scope,
+ :masked,
+ :protected,
+ :group_name,
+ :group_ci_cd_settings_path
+ ).at_least
+ end
+end
diff --git a/spec/graphql/types/ci/job_trace_type_spec.rb b/spec/graphql/types/ci/job_trace_type_spec.rb
new file mode 100644
index 00000000000..71803aa9ece
--- /dev/null
+++ b/spec/graphql/types/ci/job_trace_type_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CiJobTrace'], feature_category: :continuous_integration do
+ include GraphqlHelpers
+
+ let_it_be(:job) { create(:ci_build) }
+
+ it 'has the correct fields' do
+ expected_fields = [:html_summary]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+
+ it 'shows the correct trace contents' do
+ job.trace.set('BUILD TRACE')
+
+ expect_next_instance_of(Gitlab::Ci::Trace) do |trace|
+ expect(trace).to receive(:html).with(last_lines: 10).and_call_original
+ end
+
+ resolved_field = resolve_field(:html_summary, job.trace)
+
+ expect(resolved_field).to eq("<span>BUILD TRACE</span>")
+ end
+end
diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb
index 714eaebfe73..e927bac431c 100644
--- a/spec/graphql/types/ci/job_type_spec.rb
+++ b/spec/graphql/types/ci/job_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Types::Ci::JobType do
+RSpec.describe Types::Ci::JobType, feature_category: :continuous_integration do
include GraphqlHelpers
specify { expect(described_class.graphql_name).to eq('CiJob') }
@@ -40,6 +40,8 @@ RSpec.describe Types::Ci::JobType do
refPath
retryable
retried
+ runner
+ runnerManager
scheduledAt
schedulingType
shortSha
@@ -51,6 +53,11 @@ RSpec.describe Types::Ci::JobType do
triggered
userPermissions
webPath
+ playPath
+ canPlayJob
+ scheduled
+ trace
+ failure_message
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/ci/runner_manager_type_spec.rb b/spec/graphql/types/ci/runner_manager_type_spec.rb
new file mode 100644
index 00000000000..240e1edbf78
--- /dev/null
+++ b/spec/graphql/types/ci/runner_manager_type_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CiRunnerManager'], feature_category: :runner_fleet do
+ specify { expect(described_class.graphql_name).to eq('CiRunnerManager') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_runner_manager) }
+
+ it 'contains attributes related to a runner manager' do
+ expected_fields = %w[
+ architecture_name contacted_at created_at executor_name id ip_address platform_name revision
+ runner status system_id version
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/ci/runner_type_spec.rb b/spec/graphql/types/ci/runner_type_spec.rb
index a2d107ae295..dc664f281b7 100644
--- a/spec/graphql/types/ci/runner_type_spec.rb
+++ b/spec/graphql/types/ci/runner_type_spec.rb
@@ -9,11 +9,11 @@ RSpec.describe GitlabSchema.types['CiRunner'], feature_category: :runner do
it 'contains attributes related to a runner' do
expected_fields = %w[
- id description created_at contacted_at maximum_timeout access_level active paused status
+ id description created_by created_at contacted_at managers maximum_timeout access_level active paused status
version short_sha revision locked run_untagged ip_address runner_type tag_list
- project_count job_count admin_url edit_admin_url user_permissions executor_name architecture_name platform_name
- maintenance_note maintenance_note_html groups projects jobs token_expires_at owner_project job_execution_status
- ephemeral_authentication_token
+ project_count job_count admin_url edit_admin_url register_admin_url user_permissions executor_name
+ architecture_name platform_name maintenance_note maintenance_note_html groups projects jobs token_expires_at
+ owner_project job_execution_status ephemeral_authentication_token ephemeral_register_url
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/ci/variable_sort_enum_spec.rb b/spec/graphql/types/ci/variable_sort_enum_spec.rb
index 1702360a21f..8bfe6dde915 100644
--- a/spec/graphql/types/ci/variable_sort_enum_spec.rb
+++ b/spec/graphql/types/ci/variable_sort_enum_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Types::Ci::VariableSortEnum, feature_category: :pipeline_authoring do
+RSpec.describe Types::Ci::VariableSortEnum, feature_category: :secrets_management do
it 'exposes the available order methods' do
expect(described_class.values).to match(
'KEY_ASC' => have_attributes(value: :key_asc),
diff --git a/spec/graphql/types/clusters/agent_activity_event_type_spec.rb b/spec/graphql/types/clusters/agent_activity_event_type_spec.rb
index cae75485846..f89bd877920 100644
--- a/spec/graphql/types/clusters/agent_activity_event_type_spec.rb
+++ b/spec/graphql/types/clusters/agent_activity_event_type_spec.rb
@@ -6,6 +6,6 @@ RSpec.describe GitlabSchema.types['ClusterAgentActivityEvent'] do
let(:fields) { %i[recorded_at kind level user agent_token] }
it { expect(described_class.graphql_name).to eq('ClusterAgentActivityEvent') }
- it { expect(described_class).to require_graphql_authorizations(:read_cluster) }
+ it { expect(described_class).to require_graphql_authorizations(:read_cluster_agent) }
it { expect(described_class).to have_graphql_fields(fields) }
end
diff --git a/spec/graphql/types/clusters/agent_token_type_spec.rb b/spec/graphql/types/clusters/agent_token_type_spec.rb
index 1ca6d690c80..e04b33f92f8 100644
--- a/spec/graphql/types/clusters/agent_token_type_spec.rb
+++ b/spec/graphql/types/clusters/agent_token_type_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['ClusterAgentToken'] do
it { expect(described_class.graphql_name).to eq('ClusterAgentToken') }
- it { expect(described_class).to require_graphql_authorizations(:read_cluster) }
+ it { expect(described_class).to require_graphql_authorizations(:read_cluster_agent) }
it { expect(described_class).to have_graphql_fields(fields) }
end
diff --git a/spec/graphql/types/clusters/agent_type_spec.rb b/spec/graphql/types/clusters/agent_type_spec.rb
index bb1006c55c0..4bae0ea5602 100644
--- a/spec/graphql/types/clusters/agent_type_spec.rb
+++ b/spec/graphql/types/clusters/agent_type_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['ClusterAgent'] do
it { expect(described_class.graphql_name).to eq('ClusterAgent') }
- it { expect(described_class).to require_graphql_authorizations(:read_cluster) }
+ it { expect(described_class).to require_graphql_authorizations(:read_cluster_agent) }
it { expect(described_class).to include_graphql_fields(*fields) }
end
diff --git a/spec/graphql/types/clusters/agents/authorizations/ci_access_type_spec.rb b/spec/graphql/types/clusters/agents/authorizations/ci_access_type_spec.rb
new file mode 100644
index 00000000000..17725ec11e0
--- /dev/null
+++ b/spec/graphql/types/clusters/agents/authorizations/ci_access_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ClusterAgentAuthorizationCiAccess'],
+ feature_category: :deployment_management do
+ let(:fields) { %i[agent config] }
+
+ it { expect(described_class.graphql_name).to eq('ClusterAgentAuthorizationCiAccess') }
+ it { expect(described_class).to have_graphql_fields(fields) }
+end
diff --git a/spec/graphql/types/clusters/agents/authorizations/user_access_type_spec.rb b/spec/graphql/types/clusters/agents/authorizations/user_access_type_spec.rb
new file mode 100644
index 00000000000..0e798cd1b18
--- /dev/null
+++ b/spec/graphql/types/clusters/agents/authorizations/user_access_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ClusterAgentAuthorizationUserAccess'],
+ feature_category: :deployment_management do
+ let(:fields) { %i[agent config] }
+
+ it { expect(described_class.graphql_name).to eq('ClusterAgentAuthorizationUserAccess') }
+ it { expect(described_class).to have_graphql_fields(fields) }
+end
diff --git a/spec/graphql/types/commit_signature_interface_spec.rb b/spec/graphql/types/commit_signature_interface_spec.rb
index 4962131d9b5..d37c0d1b4fa 100644
--- a/spec/graphql/types/commit_signature_interface_spec.rb
+++ b/spec/graphql/types/commit_signature_interface_spec.rb
@@ -18,6 +18,11 @@ RSpec.describe GitlabSchema.types['CommitSignature'] do
Types::CommitSignatures::X509SignatureType)
end
+ it 'resolves SSH signatures' do
+ expect(described_class.resolve_type(build(:ssh_signature), {})).to eq(
+ Types::CommitSignatures::SshSignatureType)
+ end
+
it 'raises an error when type is not known' do
expect { described_class.resolve_type(Class, {}) }.to raise_error('Unsupported commit signature type')
end
diff --git a/spec/graphql/types/commit_signatures/ssh_signature_type_spec.rb b/spec/graphql/types/commit_signatures/ssh_signature_type_spec.rb
index 4ffb70a0b22..c16e29312a3 100644
--- a/spec/graphql/types/commit_signatures/ssh_signature_type_spec.rb
+++ b/spec/graphql/types/commit_signatures/ssh_signature_type_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe GitlabSchema.types['SshSignature'], feature_category: :source_cod
it 'contains attributes related to SSH signatures' do
expect(described_class).to have_graphql_fields(
- :user, :verification_status, :commit_sha, :project, :key
+ :user, :verification_status, :commit_sha, :project, :key, :key_fingerprint_sha256
)
end
end
diff --git a/spec/graphql/types/data_transfer/project_data_transfer_type_spec.rb b/spec/graphql/types/data_transfer/project_data_transfer_type_spec.rb
new file mode 100644
index 00000000000..a93da279b7f
--- /dev/null
+++ b/spec/graphql/types/data_transfer/project_data_transfer_type_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ProjectDataTransfer'], feature_category: :source_code_management do
+ include GraphqlHelpers
+
+ it 'includes the specific fields' do
+ expect(described_class).to have_graphql_fields(
+ :total_egress, :egress_nodes)
+ end
+
+ describe '#total_egress' do
+ let_it_be(:project) { create(:project) }
+ let(:from) { Date.new(2022, 1, 1) }
+ let(:to) { Date.new(2023, 1, 1) }
+ let(:finder_result) { 40_000_000 }
+
+ it 'returns mock data' do
+ expect(resolve_field(:total_egress, { from: from, to: to }, extras: { parent: project },
+ arg_style: :internal)).to eq(finder_result)
+ end
+
+ context 'when data_transfer_monitoring_mock_data is disabled' do
+ let(:relation) { instance_double(ActiveRecord::Relation) }
+
+ before do
+ allow(relation).to receive(:sum).and_return(10)
+ stub_feature_flags(data_transfer_monitoring_mock_data: false)
+ end
+
+ it 'calls sum on active record relation' do
+ expect(resolve_field(:total_egress, { egress_nodes: relation }, extras: { parent: project },
+ arg_style: :internal)).to eq(10)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/types/design_management/design_at_version_type_spec.rb b/spec/graphql/types/design_management/design_at_version_type_spec.rb
index 4d61ecf62cc..06aefb6fea3 100644
--- a/spec/graphql/types/design_management/design_at_version_type_spec.rb
+++ b/spec/graphql/types/design_management/design_at_version_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabSchema.types['DesignAtVersion'] do
+RSpec.describe GitlabSchema.types['DesignAtVersion'], feature_category: :portfolio_management do
it_behaves_like 'a GraphQL type with design fields' do
let(:extra_design_fields) { %i[version design] }
let_it_be(:design) { create(:design, :with_versions) }
diff --git a/spec/graphql/types/design_management/design_type_spec.rb b/spec/graphql/types/design_management/design_type_spec.rb
index 24b007a6b33..a093f785eb7 100644
--- a/spec/graphql/types/design_management/design_type_spec.rb
+++ b/spec/graphql/types/design_management/design_type_spec.rb
@@ -2,13 +2,16 @@
require 'spec_helper'
-RSpec.describe GitlabSchema.types['Design'] do
+RSpec.describe GitlabSchema.types['Design'], feature_category: :portfolio_management do
specify { expect(described_class.interfaces).to include(Types::CurrentUserTodos) }
specify { expect(described_class.interfaces).to include(Types::TodoableInterface) }
it_behaves_like 'a GraphQL type with design fields' do
- let(:extra_design_fields) { %i[notes current_user_todos discussions versions web_url commenters] }
+ let(:extra_design_fields) do
+ %i[notes current_user_todos discussions versions web_url commenters description descriptionHtml]
+ end
+
let_it_be(:design) { create(:design, :with_versions) }
let(:object_id) { GitlabSchema.id_from_object(design) }
let_it_be(:object_id_b) { GitlabSchema.id_from_object(create(:design, :with_versions)) }
diff --git a/spec/graphql/types/group_type_spec.rb b/spec/graphql/types/group_type_spec.rb
index 6820cf2738e..0fbf50fe258 100644
--- a/spec/graphql/types/group_type_spec.rb
+++ b/spec/graphql/types/group_type_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe GitlabSchema.types['Group'] do
merge_requests container_repositories container_repositories_count
packages dependency_proxy_setting dependency_proxy_manifests
dependency_proxy_blobs dependency_proxy_image_count
- dependency_proxy_blob_count dependency_proxy_total_size
+ dependency_proxy_blob_count dependency_proxy_total_size dependency_proxy_total_size_in_bytes
dependency_proxy_image_prefix dependency_proxy_image_ttl_policy
shared_runners_setting timelogs organization_state_counts organizations
contact_state_counts contacts work_item_types
diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb
index 7c6cf137a1e..a9fe85ac62f 100644
--- a/spec/graphql/types/issue_type_spec.rb
+++ b/spec/graphql/types/issue_type_spec.rb
@@ -265,7 +265,10 @@ RSpec.describe GitlabSchema.types['Issue'] do
context 'for an incident' do
before do
- issue.update!(issue_type: Issue.issue_types[:incident])
+ issue.update!(
+ issue_type: WorkItems::Type.base_types[:incident],
+ work_item_type: WorkItems::Type.default_by_type(:incident)
+ )
end
it { is_expected.to be_nil }
@@ -277,44 +280,4 @@ RSpec.describe GitlabSchema.types['Issue'] do
end
end
end
-
- describe 'type' do
- let_it_be(:issue) { create(:issue, project: project) }
-
- let(:query) do
- %(
- query {
- issue(id: "#{issue.to_gid}") {
- type
- }
- }
- )
- end
-
- subject(:execute) { GitlabSchema.execute(query, context: { current_user: user }).as_json }
-
- context 'when the issue_type_uses_work_item_types_table feature flag is enabled' do
- it 'gets the type field from the work_item_types table' do
- expect_next_instance_of(::IssuePresenter) do |presented_issue|
- expect(presented_issue).to receive_message_chain(:work_item_type, :base_type)
- end
-
- execute
- end
- end
-
- context 'when the issue_type_uses_work_item_types_table feature flag is disabled' do
- before do
- stub_feature_flags(issue_type_uses_work_item_types_table: false)
- end
-
- it 'does not get the type field from the work_item_types table' do
- expect_next_instance_of(::IssuePresenter) do |presented_issue|
- expect(presented_issue).not_to receive(:work_item_type)
- end
-
- execute
- end
- end
- end
end
diff --git a/spec/graphql/types/key_type_spec.rb b/spec/graphql/types/key_type_spec.rb
index 78144076467..13c00d94b37 100644
--- a/spec/graphql/types/key_type_spec.rb
+++ b/spec/graphql/types/key_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabSchema.types['Key'], feature_category: :authentication_and_authorization do
+RSpec.describe GitlabSchema.types['Key'], feature_category: :system_access do
specify { expect(described_class.graphql_name).to eq('Key') }
it 'contains attributes for SSH keys' do
diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb
index 8a4c89fc340..bd271da55a9 100644
--- a/spec/graphql/types/merge_request_type_spec.rb
+++ b/spec/graphql/types/merge_request_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabSchema.types['MergeRequest'] do
+RSpec.describe GitlabSchema.types['MergeRequest'], feature_category: :code_review_workflow do
include GraphqlHelpers
specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
@@ -36,7 +36,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
commit_count current_user_todos conflicts auto_merge_enabled approved_by source_branch_protected
squash_on_merge available_auto_merge_strategies
has_ci mergeable commits committers commits_without_merge_commits squash security_auto_fix default_squash_commit_message
- auto_merge_strategy merge_user
+ auto_merge_strategy merge_user award_emoji prepared_at
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
diff --git a/spec/graphql/types/permission_types/issue_spec.rb b/spec/graphql/types/permission_types/issue_spec.rb
index 58c5808cbcc..8f43a4a44a0 100644
--- a/spec/graphql/types/permission_types/issue_spec.rb
+++ b/spec/graphql/types/permission_types/issue_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Types::PermissionTypes::Issue do
expected_permissions = [
:read_issue, :admin_issue, :update_issue, :reopen_issue,
:read_design, :create_design, :destroy_design,
- :create_note
+ :create_note, :update_design
]
expected_permissions.each do |permission|
diff --git a/spec/graphql/types/permission_types/work_item_spec.rb b/spec/graphql/types/permission_types/work_item_spec.rb
index db6d78b1538..7e16b43a12f 100644
--- a/spec/graphql/types/permission_types/work_item_spec.rb
+++ b/spec/graphql/types/permission_types/work_item_spec.rb
@@ -5,7 +5,8 @@ require 'spec_helper'
RSpec.describe Types::PermissionTypes::WorkItem do
it do
expected_permissions = [
- :read_work_item, :update_work_item, :delete_work_item, :admin_work_item
+ :read_work_item, :update_work_item, :delete_work_item, :admin_work_item,
+ :admin_parent_link, :set_work_item_metadata
]
expected_permissions.each do |permission|
diff --git a/spec/graphql/types/project_member_relation_enum_spec.rb b/spec/graphql/types/project_member_relation_enum_spec.rb
index 3c947bf8406..a486844a687 100644
--- a/spec/graphql/types/project_member_relation_enum_spec.rb
+++ b/spec/graphql/types/project_member_relation_enum_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Types::ProjectMemberRelationEnum do
specify { expect(described_class.graphql_name).to eq('ProjectMemberRelation') }
it 'exposes all the existing project member relation type values' do
- expect(described_class.values.keys).to contain_exactly('DIRECT', 'INHERITED', 'DESCENDANTS', 'INVITED_GROUPS')
+ relation_types = %w[DIRECT INHERITED DESCENDANTS INVITED_GROUPS SHARED_INTO_ANCESTORS]
+ expect(described_class.values.keys).to contain_exactly(*relation_types)
end
end
diff --git a/spec/graphql/types/project_statistics_redirect_type_spec.rb b/spec/graphql/types/project_statistics_redirect_type_spec.rb
new file mode 100644
index 00000000000..3600a5b932f
--- /dev/null
+++ b/spec/graphql/types/project_statistics_redirect_type_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ProjectStatisticsRedirect'], feature_category: :consumables_cost_management do
+ it 'has all the required fields' do
+ expect(described_class).to have_graphql_fields(:repository, :build_artifacts, :packages,
+ :wiki, :snippets, :container_registry)
+ end
+end
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 7f26190830e..bcfdb05ca90 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe GitlabSchema.types['Project'] do
snippets_enabled jobs_enabled public_jobs open_issues_count import_status
only_allow_merge_if_pipeline_succeeds request_access_enabled
only_allow_merge_if_all_discussions_are_resolved printing_merge_request_link_enabled
- namespace group statistics repository merge_requests merge_request issues
+ namespace group statistics statistics_details_paths repository merge_requests merge_request issues
issue milestones pipelines removeSourceBranchAfterMerge pipeline_counts sentryDetailedError snippets
grafanaIntegration autocloseReferencedIssues suggestion_commit_message environments
environment boards jira_import_status jira_imports services releases release
@@ -34,11 +34,11 @@ RSpec.describe GitlabSchema.types['Project'] do
issue_status_counts terraform_states alert_management_integrations
container_repositories container_repositories_count
pipeline_analytics squash_read_only sast_ci_configuration
- cluster_agent cluster_agents agent_configurations
+ cluster_agent cluster_agents agent_configurations ci_access_authorized_agents user_access_authorized_agents
ci_template timelogs merge_commit_template squash_commit_template work_item_types
recent_issue_boards ci_config_path_or_default packages_cleanup_policy ci_variables
timelog_categories fork_targets branch_rules ci_config_variables pipeline_schedules languages
- incident_management_timeline_event_tags visible_forks
+ incident_management_timeline_event_tags visible_forks inherited_ci_variables
]
expect(described_class).to include_graphql_fields(*expected_fields)
@@ -291,7 +291,7 @@ RSpec.describe GitlabSchema.types['Project'] do
let_it_be(:project) { create(:project_empty_repo) }
it 'raises an error' do
- expect(subject['errors'][0]['message']).to eq('You must <a target="_blank" rel="noopener noreferrer" ' \
+ expect(subject['errors'][0]['message']).to eq('UF You must <a target="_blank" rel="noopener noreferrer" ' \
'href="http://localhost/help/user/project/repository/index.md#' \
'add-files-to-a-repository">add at least one file to the ' \
'repository</a> before using Security features.')
@@ -333,6 +333,7 @@ RSpec.describe GitlabSchema.types['Project'] do
:target_branches,
:state,
:draft,
+ :approved,
:labels,
:before,
:after,
@@ -676,8 +677,8 @@ RSpec.describe GitlabSchema.types['Project'] do
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
before do
- allow(::Gitlab::ServiceDeskEmail).to receive(:enabled?) { true }
- allow(::Gitlab::ServiceDeskEmail).to receive(:address_for_key) { 'address-suffix@example.com' }
+ allow(::Gitlab::Email::ServiceDeskEmail).to receive(:enabled?) { true }
+ allow(::Gitlab::Email::ServiceDeskEmail).to receive(:address_for_key) { 'address-suffix@example.com' }
end
context 'when a user can admin issues' do
diff --git a/spec/graphql/types/projects/fork_details_type_spec.rb b/spec/graphql/types/projects/fork_details_type_spec.rb
index 8e20e2c8299..f79371ce4ca 100644
--- a/spec/graphql/types/projects/fork_details_type_spec.rb
+++ b/spec/graphql/types/projects/fork_details_type_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe GitlabSchema.types['ForkDetails'], feature_category: :source_code
fields = %i[
ahead
behind
+ isSyncing
+ hasConflicts
]
expect(described_class).to have_graphql_fields(*fields)
diff --git a/spec/graphql/types/release_asset_link_type_spec.rb b/spec/graphql/types/release_asset_link_type_spec.rb
index 0c903b8d27a..57a42a3966f 100644
--- a/spec/graphql/types/release_asset_link_type_spec.rb
+++ b/spec/graphql/types/release_asset_link_type_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['ReleaseAssetLink'] do
it 'has the expected fields' do
expected_fields = %w[
- id name url external link_type direct_asset_url direct_asset_path
+ id name url link_type direct_asset_url direct_asset_path
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/root_storage_statistics_type_spec.rb b/spec/graphql/types/root_storage_statistics_type_spec.rb
index 07c8378e7a6..5dde6aa8b14 100644
--- a/spec/graphql/types/root_storage_statistics_type_spec.rb
+++ b/spec/graphql/types/root_storage_statistics_type_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe GitlabSchema.types['RootStorageStatistics'] do
expect(described_class).to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
:build_artifacts_size, :packages_size, :wiki_size, :snippets_size,
:pipeline_artifacts_size, :uploads_size, :dependency_proxy_size,
- :container_registry_size)
+ :container_registry_size, :registry_size_estimated)
end
specify { expect(described_class).to require_graphql_authorizations(:read_statistics) }
diff --git a/spec/graphql/types/time_tracking/timelog_connection_type_spec.rb b/spec/graphql/types/time_tracking/timelog_connection_type_spec.rb
index 5cfe561b42c..db1ffd4f59d 100644
--- a/spec/graphql/types/time_tracking/timelog_connection_type_spec.rb
+++ b/spec/graphql/types/time_tracking/timelog_connection_type_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe GitlabSchema.types['TimelogConnection'], feature_category: :team_
context 'when requested' do
it 'returns the total spent time' do
- expect(total_spent_time).to eq(5064)
+ expect(total_spent_time).to eq('5064')
end
end
end
diff --git a/spec/graphql/types/timelog_type_spec.rb b/spec/graphql/types/timelog_type_spec.rb
index 59a0e373c5d..aa05c5ffd94 100644
--- a/spec/graphql/types/timelog_type_spec.rb
+++ b/spec/graphql/types/timelog_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['Timelog'], feature_category: :team_planning do
- let_it_be(:fields) { %i[id spent_at time_spent user issue merge_request note summary userPermissions] }
+ let_it_be(:fields) { %i[id spent_at time_spent user issue merge_request note summary userPermissions project] }
it { expect(described_class.graphql_name).to eq('Timelog') }
it { expect(described_class).to have_graphql_fields(fields) }
diff --git a/spec/graphql/types/user_preferences_type_spec.rb b/spec/graphql/types/user_preferences_type_spec.rb
index fac45443290..06749dda239 100644
--- a/spec/graphql/types/user_preferences_type_spec.rb
+++ b/spec/graphql/types/user_preferences_type_spec.rb
@@ -2,12 +2,13 @@
require 'spec_helper'
-RSpec.describe Types::UserPreferencesType do
+RSpec.describe Types::UserPreferencesType, feature_category: :user_profile do
specify { expect(described_class.graphql_name).to eq('UserPreferences') }
it 'exposes the expected fields' do
expected_fields = %i[
issues_sort
+ visibility_pipeline_id_type
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index a6b5d454b60..0b0dcf2fb6a 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -47,9 +47,10 @@ RSpec.describe GitlabSchema.types['User'], feature_category: :user_profile do
profileEnableGitpodPath
savedReplies
savedReply
+ user_achievements
]
- expect(described_class).to have_graphql_fields(*expected_fields)
+ expect(described_class).to include_graphql_fields(*expected_fields)
end
describe 'name field' do
diff --git a/spec/graphql/types/visibility_pipeline_id_type_enum_spec.rb b/spec/graphql/types/visibility_pipeline_id_type_enum_spec.rb
new file mode 100644
index 00000000000..f1dc6a79b29
--- /dev/null
+++ b/spec/graphql/types/visibility_pipeline_id_type_enum_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::VisibilityPipelineIdTypeEnum, feature_category: :user_profile do
+ specify { expect(described_class.graphql_name).to eq('VisibilityPipelineIdType') }
+
+ it 'exposes all visibility pipeline id types' do
+ expect(described_class.values.keys).to contain_exactly(
+ *UserPreference.visibility_pipeline_id_types.keys.map(&:upcase)
+ )
+ end
+end
diff --git a/spec/graphql/types/work_item_type_spec.rb b/spec/graphql/types/work_item_type_spec.rb
index 42d56598944..328450084c2 100644
--- a/spec/graphql/types/work_item_type_spec.rb
+++ b/spec/graphql/types/work_item_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabSchema.types['WorkItem'] do
+RSpec.describe GitlabSchema.types['WorkItem'], feature_category: :team_planning do
specify { expect(described_class.graphql_name).to eq('WorkItem') }
specify { expect(described_class).to require_graphql_authorizations(:read_work_item) }
@@ -18,6 +18,7 @@ RSpec.describe GitlabSchema.types['WorkItem'] do
id
iid
lock_version
+ namespace
project
state title
title_html
@@ -28,6 +29,8 @@ RSpec.describe GitlabSchema.types['WorkItem'] do
updated_at
closed_at
web_url
+ create_note_email
+ reference
]
expect(described_class).to have_graphql_fields(*fields)
diff --git a/spec/graphql/types/work_items/available_export_fields_enum_spec.rb b/spec/graphql/types/work_items/available_export_fields_enum_spec.rb
new file mode 100644
index 00000000000..9010aabe3cc
--- /dev/null
+++ b/spec/graphql/types/work_items/available_export_fields_enum_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['AvailableExportFields'], feature_category: :team_planning do
+ specify { expect(described_class.graphql_name).to eq('AvailableExportFields') }
+
+ describe 'enum values' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:field_name, :field_value) do
+ 'ID' | 'id'
+ 'TYPE' | 'type'
+ 'TITLE' | 'title'
+ 'DESCRIPTION' | 'description'
+ 'AUTHOR' | 'author'
+ 'AUTHOR_USERNAME' | 'author username'
+ 'CREATED_AT' | 'created_at'
+ end
+
+ with_them do
+ it 'exposes correct available fields' do
+ expect(described_class.values[field_name].value).to eq(field_value)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/types/work_items/widget_interface_spec.rb b/spec/graphql/types/work_items/widget_interface_spec.rb
index a2b12ed52dc..d955ec5023e 100644
--- a/spec/graphql/types/work_items/widget_interface_spec.rb
+++ b/spec/graphql/types/work_items/widget_interface_spec.rb
@@ -15,11 +15,14 @@ RSpec.describe Types::WorkItems::WidgetInterface do
using RSpec::Parameterized::TableSyntax
where(:widget_class, :widget_type_name) do
- WorkItems::Widgets::Description | Types::WorkItems::Widgets::DescriptionType
- WorkItems::Widgets::Hierarchy | Types::WorkItems::Widgets::HierarchyType
- WorkItems::Widgets::Assignees | Types::WorkItems::Widgets::AssigneesType
- WorkItems::Widgets::Labels | Types::WorkItems::Widgets::LabelsType
- WorkItems::Widgets::Notes | Types::WorkItems::Widgets::NotesType
+ WorkItems::Widgets::Description | Types::WorkItems::Widgets::DescriptionType
+ WorkItems::Widgets::Hierarchy | Types::WorkItems::Widgets::HierarchyType
+ WorkItems::Widgets::Assignees | Types::WorkItems::Widgets::AssigneesType
+ WorkItems::Widgets::Labels | Types::WorkItems::Widgets::LabelsType
+ WorkItems::Widgets::Notes | Types::WorkItems::Widgets::NotesType
+ WorkItems::Widgets::Notifications | Types::WorkItems::Widgets::NotificationsType
+ WorkItems::Widgets::CurrentUserTodos | Types::WorkItems::Widgets::CurrentUserTodosType
+ WorkItems::Widgets::AwardEmoji | Types::WorkItems::Widgets::AwardEmojiType
end
with_them do
diff --git a/spec/graphql/types/work_items/widgets/award_emoji_type_spec.rb b/spec/graphql/types/work_items/widgets/award_emoji_type_spec.rb
new file mode 100644
index 00000000000..493e628ac83
--- /dev/null
+++ b/spec/graphql/types/work_items/widgets/award_emoji_type_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::WorkItems::Widgets::AwardEmojiType, feature_category: :team_planning do
+ it 'exposes the expected fields' do
+ expected_fields = %i[award_emoji downvotes upvotes type]
+
+ expect(described_class.graphql_name).to eq('WorkItemWidgetAwardEmoji')
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/work_items/widgets/current_user_todos_input_type_spec.rb b/spec/graphql/types/work_items/widgets/current_user_todos_input_type_spec.rb
new file mode 100644
index 00000000000..0ae660ffac0
--- /dev/null
+++ b/spec/graphql/types/work_items/widgets/current_user_todos_input_type_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Types::WorkItems::Widgets::CurrentUserTodosInputType, feature_category: :team_planning do
+ it { expect(described_class.graphql_name).to eq('WorkItemWidgetCurrentUserTodosInput') }
+
+ it { expect(described_class.arguments.keys).to match_array(%w[action todoId]) }
+end
diff --git a/spec/graphql/types/work_items/widgets/current_user_todos_type_spec.rb b/spec/graphql/types/work_items/widgets/current_user_todos_type_spec.rb
new file mode 100644
index 00000000000..b39adefbd87
--- /dev/null
+++ b/spec/graphql/types/work_items/widgets/current_user_todos_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::WorkItems::Widgets::CurrentUserTodosType, feature_category: :team_planning do
+ it 'exposes the expected fields' do
+ expected_fields = %i[current_user_todos type]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/work_items/widgets/hierarchy_update_input_type_spec.rb b/spec/graphql/types/work_items/widgets/hierarchy_update_input_type_spec.rb
index 6221580605e..0d4d31faee1 100644
--- a/spec/graphql/types/work_items/widgets/hierarchy_update_input_type_spec.rb
+++ b/spec/graphql/types/work_items/widgets/hierarchy_update_input_type_spec.rb
@@ -5,5 +5,11 @@ require 'spec_helper'
RSpec.describe ::Types::WorkItems::Widgets::HierarchyUpdateInputType do
it { expect(described_class.graphql_name).to eq('WorkItemWidgetHierarchyUpdateInput') }
- it { expect(described_class.arguments.keys).to match_array(%w[parentId childrenIds]) }
+ it 'accepts documented arguments' do
+ expect(described_class.arguments.keys).to match_array(%w[parentId childrenIds adjacentWorkItemId relativePosition])
+ end
+
+ it 'sets the type of relative_position argument to RelativePositionTypeEnum' do
+ expect(described_class.arguments['relativePosition'].type).to eq(Types::RelativePositionTypeEnum)
+ end
end
diff --git a/spec/graphql/types/work_items/widgets/notifications_type_spec.rb b/spec/graphql/types/work_items/widgets/notifications_type_spec.rb
new file mode 100644
index 00000000000..4f457a24710
--- /dev/null
+++ b/spec/graphql/types/work_items/widgets/notifications_type_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::WorkItems::Widgets::NotificationsType, feature_category: :team_planning do
+ it 'exposes the expected fields' do
+ expected_fields = %i[subscribed type]
+
+ expect(described_class.graphql_name).to eq('WorkItemWidgetNotifications')
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/work_items/widgets/notifications_update_input_type_spec.rb b/spec/graphql/types/work_items/widgets/notifications_update_input_type_spec.rb
new file mode 100644
index 00000000000..db0d02c597c
--- /dev/null
+++ b/spec/graphql/types/work_items/widgets/notifications_update_input_type_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Types::WorkItems::Widgets::NotificationsUpdateInputType, feature_category: :team_planning do
+ it { expect(described_class.graphql_name).to eq('WorkItemWidgetNotificationsUpdateInput') }
+
+ it { expect(described_class.arguments.keys).to contain_exactly('subscribed') }
+end
diff --git a/spec/haml_lint/linter/no_plain_nodes_spec.rb b/spec/haml_lint/linter/no_plain_nodes_spec.rb
index eeb0e4ea96f..235e742bc54 100644
--- a/spec/haml_lint/linter/no_plain_nodes_spec.rb
+++ b/spec/haml_lint/linter/no_plain_nodes_spec.rb
@@ -6,7 +6,7 @@ require 'haml_lint/spec'
require_relative '../../../haml_lint/linter/no_plain_nodes'
-RSpec.describe HamlLint::Linter::NoPlainNodes do
+RSpec.describe HamlLint::Linter::NoPlainNodes, feature_category: :tooling do
include_context 'linter'
context 'reports when a tag has an inline plain node' do
@@ -68,27 +68,27 @@ RSpec.describe HamlLint::Linter::NoPlainNodes do
end
context 'does not report multiline when one or more html entities' do
- %w(&nbsp;&gt; &#x000A9; &#187;).each do |elem|
- let(:haml) { <<-HAML }
- %tag
- #{elem}
- HAML
-
- it elem do
- is_expected.not_to report_lint
+ %w[&nbsp;&gt; &#x000A9; &#187;].each do |elem|
+ context "with #{elem}" do
+ let(:haml) { <<-HAML }
+ %tag
+ #{elem}
+ HAML
+
+ it { is_expected.not_to report_lint }
end
end
end
context 'does report multiline when one or more html entities amidst plain text' do
- %w(&nbsp;Test Test&gt; &#x000A9;Hello &nbsp;Hello&#187;).each do |elem|
- let(:haml) { <<-HAML }
- %tag
- #{elem}
- HAML
-
- it elem do
- is_expected.to report_lint
+ %w[&nbsp;Test Test&gt; &#x000A9;Hello &nbsp;Hello&#187;].each do |elem|
+ context "with #{elem}" do
+ let(:haml) { <<-HAML }
+ %tag
+ #{elem}
+ HAML
+
+ it { is_expected.to report_lint }
end
end
end
diff --git a/spec/helpers/abuse_reports_helper_spec.rb b/spec/helpers/abuse_reports_helper_spec.rb
new file mode 100644
index 00000000000..6d381b7eb56
--- /dev/null
+++ b/spec/helpers/abuse_reports_helper_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AbuseReportsHelper, feature_category: :insider_threat do
+ describe '#valid_image_mimetypes' do
+ subject(:valid_image_mimetypes) { helper.valid_image_mimetypes }
+
+ it {
+ is_expected.to eq('image/png, image/jpg, image/jpeg, image/gif, image/bmp, image/tiff, image/ico or image/webp')
+ }
+ end
+end
diff --git a/spec/helpers/access_tokens_helper_spec.rb b/spec/helpers/access_tokens_helper_spec.rb
index d34251d03db..a466b2a0d3b 100644
--- a/spec/helpers/access_tokens_helper_spec.rb
+++ b/spec/helpers/access_tokens_helper_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe AccessTokensHelper do
disable_feed_token: false,
static_objects_external_storage_enabled?: true
)
- allow(Gitlab::IncomingEmail).to receive(:supports_issue_creation?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_issue_creation?).and_return(true)
allow(helper).to receive_messages(
current_user: user,
reset_feed_token_profile_path: feed_token_reset_path,
diff --git a/spec/helpers/admin/abuse_reports_helper_spec.rb b/spec/helpers/admin/abuse_reports_helper_spec.rb
new file mode 100644
index 00000000000..496b7361b6e
--- /dev/null
+++ b/spec/helpers/admin/abuse_reports_helper_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::AbuseReportsHelper, feature_category: :insider_threat do
+ describe '#abuse_reports_list_data' do
+ let!(:report) { create(:abuse_report) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+ let(:reports) { AbuseReport.all.page(1) }
+ let(:data) do
+ data = helper.abuse_reports_list_data(reports)[:abuse_reports_data]
+ Gitlab::Json.parse(data)
+ end
+
+ it 'has expected attributes', :aggregate_failures do
+ expect(data['pagination']).to include(
+ "current_page" => 1,
+ "per_page" => 20,
+ "total_items" => 1
+ )
+ expect(data['reports'].first).to include("category", "updated_at", "reported_user", "reporter")
+ expect(data['categories']).to match_array(AbuseReport.categories.keys)
+ end
+ end
+
+ describe '#abuse_report_data' do
+ let(:report) { build_stubbed(:abuse_report) }
+
+ subject(:data) { helper.abuse_report_data(report)[:abuse_report_data] }
+
+ it 'has the expected attributes' do
+ expect(data).to include('user', 'reporter', 'report', 'actions')
+ end
+ end
+end
diff --git a/spec/helpers/analytics/cycle_analytics_helper_spec.rb b/spec/helpers/analytics/cycle_analytics_helper_spec.rb
deleted file mode 100644
index d906646e25c..00000000000
--- a/spec/helpers/analytics/cycle_analytics_helper_spec.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-# 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_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index bb1a4d57cc0..e9b0c900867 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -451,7 +451,7 @@ RSpec.describe ApplicationHelper do
find_file: nil,
group: nil,
project_id: project.id,
- project: project.name,
+ project: project.path,
namespace_id: project.namespace.id
}
)
@@ -469,7 +469,7 @@ RSpec.describe ApplicationHelper do
find_file: nil,
group: project.group.name,
project_id: project.id,
- project: project.name,
+ project: project.path,
namespace_id: project.namespace.id
}
)
@@ -495,7 +495,7 @@ RSpec.describe ApplicationHelper do
find_file: nil,
group: nil,
project_id: issue.project.id,
- project: issue.project.name,
+ project: issue.project.path,
namespace_id: issue.project.namespace.id
}
)
@@ -696,14 +696,84 @@ RSpec.describe ApplicationHelper do
end
describe 'stylesheet_link_tag_defer' do
- it 'uses print stylesheet by default' do
+ it 'uses print stylesheet when feature flag disabled' do
+ stub_feature_flags(remove_startup_css: false)
+
expect(helper.stylesheet_link_tag_defer('test')).to eq( '<link rel="stylesheet" media="print" href="/stylesheets/test.css" />')
end
+ it 'uses regular stylesheet when feature flag enabled' do
+ stub_feature_flags(remove_startup_css: true)
+
+ expect(helper.stylesheet_link_tag_defer('test')).to eq( '<link rel="stylesheet" media="all" href="/stylesheets/test.css" />')
+ end
+
it 'uses regular stylesheet when no_startup_css param present' do
allow(helper.controller).to receive(:params).and_return({ no_startup_css: '' })
- expect(helper.stylesheet_link_tag_defer('test')).to eq( '<link rel="stylesheet" media="screen" href="/stylesheets/test.css" />')
+ expect(helper.stylesheet_link_tag_defer('test')).to eq( '<link rel="stylesheet" media="all" href="/stylesheets/test.css" />')
+ end
+ end
+
+ describe 'sign_in_with_redirect?' do
+ context 'when on the sign-in page that redirects afterwards' do
+ before do
+ allow(helper).to receive(:current_page?).and_return(true)
+ session[:user_return_to] = true
+ end
+
+ it 'returns true' do
+ expect(helper.sign_in_with_redirect?).to be_truthy
+ end
+ end
+
+ context 'when on a non sign-in page' do
+ before do
+ allow(helper).to receive(:current_page?).and_return(false)
+ end
+
+ it 'returns false' do
+ expect(helper.sign_in_with_redirect?).to be_falsey
+ end
+ end
+ end
+
+ describe 'collapsed_super_sidebar?' do
+ context 'when @force_desktop_expanded_sidebar is true' do
+ before do
+ helper.instance_variable_set(:@force_desktop_expanded_sidebar, true)
+ end
+
+ it 'returns false' do
+ expect(helper.collapsed_super_sidebar?).to eq(false)
+ end
+
+ it 'does not use the cookie value' do
+ expect(helper).not_to receive(:cookies)
+ helper.collapsed_super_sidebar?
+ end
+ end
+
+ context 'when @force_desktop_expanded_sidebar is not set (default)' do
+ context 'when super_sidebar_collapsed cookie is true' do
+ before do
+ helper.request.cookies['super_sidebar_collapsed'] = 'true'
+ end
+
+ it 'returns true' do
+ expect(helper.collapsed_super_sidebar?).to eq(true)
+ end
+ end
+
+ context 'when super_sidebar_collapsed cookie is false' do
+ before do
+ helper.request.cookies['super_sidebar_collapsed'] = 'false'
+ end
+
+ it 'returns false' do
+ expect(helper.collapsed_super_sidebar?).to eq(false)
+ end
+ end
end
end
end
diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb
index 19cb970553b..f924704ab54 100644
--- a/spec/helpers/application_settings_helper_spec.rb
+++ b/spec/helpers/application_settings_helper_spec.rb
@@ -68,11 +68,13 @@ RSpec.describe ApplicationSettingsHelper do
))
end
- context 'when GitLab.com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
+ it 'contains GitLab for Slack app parameters' do
+ params = %i(slack_app_enabled slack_app_id slack_app_secret slack_app_signing_secret slack_app_verification_token)
+ expect(helper.visible_attributes).to include(*params)
+ end
+
+ context 'when on SaaS', :saas do
it 'does not contain :deactivate_dormant_users' do
expect(helper.visible_attributes).not_to include(:deactivate_dormant_users)
end
@@ -102,70 +104,6 @@ RSpec.describe ApplicationSettingsHelper do
end
end
- describe '.self_monitoring_project_data' do
- context 'when self-monitoring project does not exist' do
- it 'returns create_self_monitoring_project_path' do
- expect(helper.self_monitoring_project_data).to include(
- 'create_self_monitoring_project_path' =>
- create_self_monitoring_project_admin_application_settings_path
- )
- end
-
- it 'returns status_create_self_monitoring_project_path' do
- expect(helper.self_monitoring_project_data).to include(
- 'status_create_self_monitoring_project_path' =>
- status_create_self_monitoring_project_admin_application_settings_path
- )
- end
-
- it 'returns delete_self_monitoring_project_path' do
- expect(helper.self_monitoring_project_data).to include(
- 'delete_self_monitoring_project_path' =>
- delete_self_monitoring_project_admin_application_settings_path
- )
- end
-
- it 'returns status_delete_self_monitoring_project_path' do
- expect(helper.self_monitoring_project_data).to include(
- 'status_delete_self_monitoring_project_path' =>
- status_delete_self_monitoring_project_admin_application_settings_path
- )
- end
-
- it 'returns self_monitoring_project_exists false' do
- expect(helper.self_monitoring_project_data).to include(
- 'self_monitoring_project_exists' => "false"
- )
- end
-
- it 'returns nil for project full_path' do
- expect(helper.self_monitoring_project_data).to include(
- 'self_monitoring_project_full_path' => nil
- )
- end
- end
-
- context 'when self-monitoring project exists' do
- let(:project) { build(:project) }
-
- before do
- stub_application_setting(self_monitoring_project: project)
- end
-
- it 'returns self_monitoring_project_exists true' do
- expect(helper.self_monitoring_project_data).to include(
- 'self_monitoring_project_exists' => "true"
- )
- end
-
- it 'returns project full_path' do
- expect(helper.self_monitoring_project_data).to include(
- 'self_monitoring_project_full_path' => project.full_path
- )
- end
- end
- end
-
describe '#storage_weights' do
let(:application_setting) { build(:application_setting) }
diff --git a/spec/helpers/artifacts_helper_spec.rb b/spec/helpers/artifacts_helper_spec.rb
index cf48f0ecc39..7c577cbf11c 100644
--- a/spec/helpers/artifacts_helper_spec.rb
+++ b/spec/helpers/artifacts_helper_spec.rb
@@ -17,6 +17,7 @@ RSpec.describe ArtifactsHelper, feature_category: :build_artifacts do
it 'returns expected data' do
expect(subject).to include({
project_path: project.full_path,
+ project_id: project.id,
artifacts_management_feedback_image_path: match_asset_path('illustrations/chat-bubble-sm.svg')
})
end
diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb
index bf23c74c0f0..dd0d6d1246f 100644
--- a/spec/helpers/avatars_helper_spec.rb
+++ b/spec/helpers/avatars_helper_spec.rb
@@ -102,7 +102,7 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
end
describe '#avatar_icon_for_email', :clean_gitlab_redis_cache do
- let(:user) { create(:user, :public_email, avatar: File.open(uploaded_image_temp_path)) }
+ let(:user) { create(:user, :public_email, :commit_email, avatar: File.open(uploaded_image_temp_path)) }
subject { helper.avatar_icon_for_email(user.email).to_s }
@@ -131,13 +131,22 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
end
context 'without an email passed' do
- it 'calls gravatar_icon' do
- expect(helper).to receive(:gravatar_icon).with(nil, 20, 2)
- expect(User).not_to receive(:find_by_any_email)
+ it 'returns the default avatar' do
+ expect(helper).to receive(:default_avatar)
+ expect(User).not_to receive(:with_public_email)
helper.avatar_icon_for_email(nil, 20, 2)
end
end
+
+ context 'with a blank email address' do
+ it 'returns the default avatar' do
+ expect(helper).to receive(:default_avatar)
+ expect(User).not_to receive(:with_public_email)
+
+ helper.avatar_icon_for_email('', 20, 2)
+ end
+ end
end
end
@@ -305,22 +314,26 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
subject { helper.user_avatar_without_link(options) }
it 'displays user avatar' do
- is_expected.to eq tag.img(alt: "#{user.name}'s avatar",
- src: avatar_icon_for_user(user, 16),
- data: { container: 'body' },
- class: 'avatar s16 has-tooltip',
- title: user.name)
+ is_expected.to eq tag.img(
+ alt: "#{user.name}'s avatar",
+ src: avatar_icon_for_user(user, 16),
+ data: { container: 'body' },
+ class: 'avatar s16 has-tooltip',
+ title: user.name
+ )
end
context 'with css_class parameter' do
let(:options) { { user: user, css_class: '.cat-pics' } }
it 'uses provided css_class' do
- is_expected.to eq tag.img(alt: "#{user.name}'s avatar",
- src: avatar_icon_for_user(user, 16),
- data: { container: 'body' },
- class: "avatar s16 #{options[:css_class]} has-tooltip",
- title: user.name)
+ is_expected.to eq tag.img(
+ alt: "#{user.name}'s avatar",
+ src: avatar_icon_for_user(user, 16),
+ data: { container: 'body' },
+ class: "avatar s16 #{options[:css_class]} has-tooltip",
+ title: user.name
+ )
end
end
@@ -328,11 +341,13 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
let(:options) { { user: user, size: 99 } }
it 'uses provided size' do
- is_expected.to eq tag.img(alt: "#{user.name}'s avatar",
- src: avatar_icon_for_user(user, options[:size]),
- data: { container: 'body' },
- class: "avatar s#{options[:size]} has-tooltip",
- title: user.name)
+ is_expected.to eq tag.img(
+ alt: "#{user.name}'s avatar",
+ src: avatar_icon_for_user(user, options[:size]),
+ data: { container: 'body' },
+ class: "avatar s#{options[:size]} has-tooltip",
+ title: user.name
+ )
end
end
@@ -340,11 +355,13 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
let(:options) { { user: user, url: '/over/the/rainbow.png' } }
it 'uses provided url' do
- is_expected.to eq tag.img(alt: "#{user.name}'s avatar",
- src: options[:url],
- data: { container: 'body' },
- class: "avatar s16 has-tooltip",
- title: user.name)
+ is_expected.to eq tag.img(
+ alt: "#{user.name}'s avatar",
+ src: options[:url],
+ data: { container: 'body' },
+ class: "avatar s16 has-tooltip",
+ title: user.name
+ )
end
end
@@ -352,11 +369,13 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
let(:options) { { user: user, lazy: true } }
it 'adds `lazy` class to class list, sets `data-src` with avatar URL and `src` with placeholder image' do
- is_expected.to eq tag.img(alt: "#{user.name}'s avatar",
- src: LazyImageTagHelper.placeholder_image,
- data: { container: 'body', src: avatar_icon_for_user(user, 16) },
- class: "avatar s16 has-tooltip lazy",
- title: user.name)
+ is_expected.to eq tag.img(
+ alt: "#{user.name}'s avatar",
+ src: LazyImageTagHelper.placeholder_image,
+ data: { container: 'body', src: avatar_icon_for_user(user, 16) },
+ class: "avatar s16 has-tooltip lazy",
+ title: user.name
+ )
end
end
@@ -365,11 +384,13 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
let(:options) { { user: user, has_tooltip: true } }
it 'adds has-tooltip' do
- is_expected.to eq tag.img(alt: "#{user.name}'s avatar",
- src: avatar_icon_for_user(user, 16),
- data: { container: 'body' },
- class: "avatar s16 has-tooltip",
- title: user.name)
+ is_expected.to eq tag.img(
+ alt: "#{user.name}'s avatar",
+ src: avatar_icon_for_user(user, 16),
+ data: { container: 'body' },
+ class: "avatar s16 has-tooltip",
+ title: user.name
+ )
end
end
@@ -377,10 +398,12 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
let(:options) { { user: user, has_tooltip: false } }
it 'does not add has-tooltip or data container' do
- is_expected.to eq tag.img(alt: "#{user.name}'s avatar",
- src: avatar_icon_for_user(user, 16),
- class: "avatar s16",
- title: user.name)
+ is_expected.to eq tag.img(
+ alt: "#{user.name}'s avatar",
+ src: avatar_icon_for_user(user, 16),
+ class: "avatar s16",
+ title: user.name
+ )
end
end
end
@@ -392,20 +415,24 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
let(:options) { { user: user, user_name: 'Tinky Winky' } }
it 'prefers user parameter' do
- is_expected.to eq tag.img(alt: "#{user.name}'s avatar",
- src: avatar_icon_for_user(user, 16),
- data: { container: 'body' },
- class: "avatar s16 has-tooltip",
- title: user.name)
+ is_expected.to eq tag.img(
+ alt: "#{user.name}'s avatar",
+ src: avatar_icon_for_user(user, 16),
+ data: { container: 'body' },
+ class: "avatar s16 has-tooltip",
+ title: user.name
+ )
end
end
it 'uses user_name and user_email parameter if user is not present' do
- is_expected.to eq tag.img(alt: "#{options[:user_name]}'s avatar",
- src: helper.avatar_icon_for_email(options[:user_email], 16),
- data: { container: 'body' },
- class: "avatar s16 has-tooltip",
- title: options[:user_name])
+ is_expected.to eq tag.img(
+ alt: "#{options[:user_name]}'s avatar",
+ src: helper.avatar_icon_for_email(options[:user_email], 16),
+ data: { container: 'body' },
+ class: "avatar s16 has-tooltip",
+ title: options[:user_name]
+ )
end
end
@@ -416,11 +443,13 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
let(:options) { { user: user_with_avatar, only_path: false } }
it 'will return avatar with a full path' do
- is_expected.to eq tag.img(alt: "#{user_with_avatar.name}'s avatar",
- src: avatar_icon_for_user(user_with_avatar, 16, only_path: false),
- data: { container: 'body' },
- class: "avatar s16 has-tooltip",
- title: user_with_avatar.name)
+ is_expected.to eq tag.img(
+ alt: "#{user_with_avatar.name}'s avatar",
+ src: avatar_icon_for_user(user_with_avatar, 16, only_path: false),
+ data: { container: 'body' },
+ class: "avatar s16 has-tooltip",
+ title: user_with_avatar.name
+ )
end
end
@@ -428,11 +457,13 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
let(:options) { { user_email: user_with_avatar.email, user_name: user_with_avatar.username, only_path: false } }
it 'will return avatar with a full path' do
- is_expected.to eq tag.img(alt: "#{user_with_avatar.username}'s avatar",
- src: helper.avatar_icon_for_email(user_with_avatar.email, 16, only_path: false),
- data: { container: 'body' },
- class: "avatar s16 has-tooltip",
- title: user_with_avatar.username)
+ is_expected.to eq tag.img(
+ alt: "#{user_with_avatar.username}'s avatar",
+ src: helper.avatar_icon_for_email(user_with_avatar.email, 16, only_path: false),
+ data: { container: 'body' },
+ class: "avatar s16 has-tooltip",
+ title: user_with_avatar.username
+ )
end
end
end
@@ -455,11 +486,13 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do
let(:resource) { user.namespace }
it 'displays user avatar' do
- is_expected.to eq tag.img(alt: "#{user.name}'s avatar",
- src: avatar_icon_for_user(user, 32),
- data: { container: 'body' },
- class: 'avatar s32 has-tooltip',
- title: user.name)
+ is_expected.to eq tag.img(
+ alt: "#{user.name}'s avatar",
+ src: avatar_icon_for_user(user, 32),
+ data: { container: 'body' },
+ class: 'avatar s32 has-tooltip',
+ title: user.name
+ )
end
end
diff --git a/spec/helpers/blame_helper_spec.rb b/spec/helpers/blame_helper_spec.rb
index d305c4c595e..30670064d93 100644
--- a/spec/helpers/blame_helper_spec.rb
+++ b/spec/helpers/blame_helper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BlameHelper do
+RSpec.describe BlameHelper, feature_category: :source_code_management do
describe '#get_age_map_start_date' do
let(:dates) do
[Time.zone.local(2014, 3, 17, 0, 0, 0),
@@ -67,4 +67,14 @@ RSpec.describe BlameHelper do
end
end
end
+
+ describe '#entire_blame_path' do
+ subject { helper.entire_blame_path(id, project) }
+
+ let_it_be(:project) { build_stubbed(:project) }
+
+ let(:id) { 'main/README.md' }
+
+ it { is_expected.to eq "/#{project.full_path}/-/blame/#{id}/streaming" }
+ end
end
diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb
index dac0d3fe182..1fd953d52d8 100644
--- a/spec/helpers/blob_helper_spec.rb
+++ b/spec/helpers/blob_helper_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe BlobHelper do
include TreeHelper
+ include FakeBlobHelpers
describe "#sanitize_svg_data" do
let(:input_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'unsanitized.svg') }
@@ -57,8 +58,6 @@ RSpec.describe BlobHelper do
end
describe "#relative_raw_path" do
- include FakeBlobHelpers
-
let_it_be(:project) { create(:project) }
before do
@@ -82,8 +81,6 @@ RSpec.describe BlobHelper do
end
context 'viewer related' do
- include FakeBlobHelpers
-
let_it_be(:project) { create(:project, lfs_enabled: true) }
before do
@@ -526,4 +523,25 @@ RSpec.describe BlobHelper do
it { is_expected.to be_truthy }
end
end
+
+ describe '#vue_blob_app_data' do
+ let(:blob) { fake_blob(path: 'file.md', size: 2.megabytes) }
+ let(:project) { build_stubbed(:project) }
+ let(:user) { build_stubbed(:user) }
+ let(:ref) { 'main' }
+
+ it 'returns data related to blob app' do
+ allow(helper).to receive(:current_user).and_return(user)
+ assign(:ref, ref)
+
+ expect(helper.vue_blob_app_data(project, blob, ref)).to include({
+ blob_path: blob.path,
+ project_path: project.full_path,
+ resource_id: project.to_global_id,
+ user_id: user.to_global_id,
+ target_branch: ref,
+ original_branch: ref
+ })
+ end
+ end
end
diff --git a/spec/helpers/broadcast_messages_helper_spec.rb b/spec/helpers/broadcast_messages_helper_spec.rb
index e0bdb09f257..5d6d404d24d 100644
--- a/spec/helpers/broadcast_messages_helper_spec.rb
+++ b/spec/helpers/broadcast_messages_helper_spec.rb
@@ -12,11 +12,8 @@ RSpec.describe BroadcastMessagesHelper, feature_category: :onboarding do
end
shared_examples 'returns role-targeted broadcast message when in project, group, or sub-group URL' do
- let(:feature_flag_state) { true }
-
before do
- stub_feature_flags(role_targeted_broadcast_messages: feature_flag_state)
- allow(helper).to receive(:cookies) { {} }
+ allow(helper).to receive(:cookies).and_return({})
end
context 'when in a project page' do
@@ -30,12 +27,6 @@ RSpec.describe BroadcastMessagesHelper, feature_category: :onboarding do
end
it { is_expected.to eq message }
-
- context 'when feature flag is disabled' do
- let(:feature_flag_state) { false }
-
- it { is_expected.to be_nil }
- end
end
context 'when in a group page' do
@@ -49,22 +40,10 @@ RSpec.describe BroadcastMessagesHelper, feature_category: :onboarding do
end
it { is_expected.to eq message }
-
- context 'when feature flag is disabled' do
- let(:feature_flag_state) { false }
-
- it { is_expected.to be_nil }
- end
end
context 'when not in a project, group, or sub-group page' do
it { is_expected.to be_nil }
-
- context 'when feature flag is disabled' do
- let(:feature_flag_state) { false }
-
- it { is_expected.to be_nil }
- end
end
end
@@ -72,7 +51,10 @@ RSpec.describe BroadcastMessagesHelper, feature_category: :onboarding do
subject { helper.current_broadcast_notification_message }
context 'with available broadcast notification messages' do
- let!(:broadcast_message_1) { create(:broadcast_message, broadcast_type: 'notification', starts_at: Time.now - 1.day) }
+ let!(:broadcast_message_1) do
+ create(:broadcast_message, broadcast_type: 'notification', starts_at: Time.now - 1.day)
+ end
+
let!(:broadcast_message_2) { create(:broadcast_message, broadcast_type: 'notification', starts_at: Time.now) }
it { is_expected.to eq broadcast_message_2 }
@@ -91,7 +73,13 @@ RSpec.describe BroadcastMessagesHelper, feature_category: :onboarding do
end
describe 'user access level targeted messages' do
- let_it_be(:message) { create(:broadcast_message, broadcast_type: 'notification', starts_at: Time.now, target_access_levels: [Gitlab::Access::DEVELOPER]) }
+ let_it_be(:message) do
+ create(:broadcast_message,
+ broadcast_type: 'notification',
+ starts_at: Time.now,
+ target_access_levels: [Gitlab::Access::DEVELOPER]
+ )
+ end
include_examples 'returns role-targeted broadcast message when in project, group, or sub-group URL'
end
@@ -99,7 +87,13 @@ RSpec.describe BroadcastMessagesHelper, feature_category: :onboarding do
describe '#current_broadcast_banner_messages' do
describe 'user access level targeted messages' do
- let_it_be(:message) { create(:broadcast_message, broadcast_type: 'banner', starts_at: Time.now, target_access_levels: [Gitlab::Access::DEVELOPER]) }
+ let_it_be(:message) do
+ create(:broadcast_message,
+ broadcast_type: 'banner',
+ starts_at: Time.now,
+ target_access_levels: [Gitlab::Access::DEVELOPER]
+ )
+ end
subject { helper.current_broadcast_banner_messages.first }
@@ -147,7 +141,20 @@ RSpec.describe BroadcastMessagesHelper, feature_category: :onboarding do
subject(:single_broadcast_message) { Gitlab::Json.parse(admin_broadcast_messages_data([message])).first }
it 'returns the expected messages data attributes' do
- keys = %w[id status preview starts_at ends_at target_roles target_path type edit_path delete_path]
+ keys = %w[
+ id
+ status
+ message
+ theme
+ broadcast_type
+ dismissable
+ starts_at
+ ends_at
+ target_roles
+ target_path
+ type edit_path
+ delete_path
+ ]
expect(single_broadcast_message.keys).to match(keys)
end
@@ -157,4 +164,24 @@ RSpec.describe BroadcastMessagesHelper, feature_category: :onboarding do
expect(single_broadcast_message['ends_at']).to eq('2020-01-02T00:00:00Z')
end
end
+
+ describe '#broadcast_message_data' do
+ let(:starts_at) { 1.day.ago }
+ let(:ends_at) { 1.day.from_now }
+ let(:message) { build(:broadcast_message, id: non_existing_record_id, starts_at: starts_at, ends_at: ends_at) }
+
+ it 'returns the expected message data attributes' do
+ keys = [
+ :id, :message, :broadcast_type, :theme, :dismissable, :target_access_levels, :messages_path,
+ :preview_path, :target_path, :starts_at, :ends_at, :target_access_level_options
+ ]
+
+ expect(broadcast_message_data(message).keys).to match(keys)
+ end
+
+ it 'has the correct iso formatted date', time_travel_to: '2020-01-01 00:00:00 +0000' do
+ expect(broadcast_message_data(message)[:starts_at]).to eq('2019-12-31T00:00:00Z')
+ expect(broadcast_message_data(message)[:ends_at]).to eq('2020-01-02T00:00:00Z')
+ end
+ end
end
diff --git a/spec/helpers/ci/builds_helper_spec.rb b/spec/helpers/ci/builds_helper_spec.rb
index c215d7b4a78..eabd40f3dd4 100644
--- a/spec/helpers/ci/builds_helper_spec.rb
+++ b/spec/helpers/ci/builds_helper_spec.rb
@@ -3,51 +3,6 @@
require 'spec_helper'
RSpec.describe Ci::BuildsHelper do
- describe '#build_summary' do
- subject { helper.build_summary(build, skip: skip) }
-
- context 'when build has no trace' do
- let(:build) { instance_double(Ci::Build, has_trace?: false) }
-
- context 'when skip is false' do
- let(:skip) { false }
-
- it 'returns no job log' do
- expect(subject).to eq('No job log')
- end
- end
-
- context 'when skip is true' do
- let(:skip) { true }
-
- it 'returns no job log' do
- expect(subject).to eq('No job log')
- end
- end
- end
-
- context 'when build has trace' do
- let(:build) { create(:ci_build, :trace_live) }
-
- context 'when skip is true' do
- let(:skip) { true }
-
- it 'returns link to logs' do
- expect(subject).to include('View job log')
- expect(subject).to include(pipeline_job_url(build.pipeline, build))
- end
- end
-
- context 'when skip is false' do
- let(:skip) { false }
-
- it 'returns log lines' do
- expect(subject).to include(build.trace.html(last_lines: 10).html_safe)
- end
- end
- end
- end
-
describe '#sidebar_build_class' do
using RSpec::Parameterized::TableSyntax
@@ -97,20 +52,6 @@ RSpec.describe Ci::BuildsHelper do
end
end
- describe '#prepare_failed_jobs_summary_data' do
- let(:failed_build) { create(:ci_build, :failed, :trace_live) }
-
- subject { helper.prepare_failed_jobs_summary_data([failed_build]) }
-
- it 'returns array of failed jobs with id, failure and failure summary' do
- expect(subject).to eq([{
- id: failed_build.id,
- failure: failed_build.present.callout_failure_message,
- failure_summary: helper.build_summary(failed_build)
- }].to_json)
- end
- end
-
def assign_project
build(:project).tap do |project|
assign(:project, project)
diff --git a/spec/helpers/ci/catalog/resources_helper_spec.rb b/spec/helpers/ci/catalog/resources_helper_spec.rb
new file mode 100644
index 00000000000..e873b9379fe
--- /dev/null
+++ b/spec/helpers/ci/catalog/resources_helper_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::Catalog::ResourcesHelper, feature_category: :pipeline_composition do
+ include Devise::Test::ControllerHelpers
+
+ let_it_be(:project) { build(:project) }
+
+ describe '#can_view_namespace_catalog?' do
+ subject { helper.can_view_namespace_catalog?(project) }
+
+ before do
+ stub_licensed_features(ci_namespace_catalog: false)
+ end
+
+ it 'user cannot view the Catalog in CE regardless of permissions' do
+ expect(subject).to be false
+ end
+ end
+
+ describe '#js_ci_catalog_data' do
+ let(:project) { build(:project, :repository) }
+
+ let(:default_helper_data) do
+ {}
+ end
+
+ subject(:catalog_data) { helper.js_ci_catalog_data(project) }
+
+ it 'returns catalog data' do
+ expect(catalog_data).to eq(default_helper_data)
+ end
+ end
+end
diff --git a/spec/helpers/ci/jobs_helper_spec.rb b/spec/helpers/ci/jobs_helper_spec.rb
index 489d9d3fcee..a9ab4ab3b67 100644
--- a/spec/helpers/ci/jobs_helper_spec.rb
+++ b/spec/helpers/ci/jobs_helper_spec.rb
@@ -3,24 +3,49 @@
require 'spec_helper'
RSpec.describe Ci::JobsHelper do
- describe 'jobs data' do
- let(:project) { create(:project, :repository) }
- let(:bridge) { create(:ci_bridge) }
-
- subject(:bridge_data) { helper.bridge_data(bridge, project) }
+ describe 'job helper functions' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:job) { create(:ci_build, project: project) }
before do
- allow(helper)
- .to receive(:image_path)
- .and_return('/path/to/illustration')
+ helper.instance_variable_set(:@project, project)
+ helper.instance_variable_set(:@build, job)
+ end
+
+ it 'returns jobs data' do
+ expect(helper.jobs_data).to include({
+ "endpoint" => "/#{project.full_path}/-/jobs/#{job.id}.json",
+ "project_path" => project.full_path,
+ "artifact_help_url" => "/help/user/gitlab_com/index.md#gitlab-cicd",
+ "deployment_help_url" => "/help/user/project/clusters/deploy_to_cluster.md#troubleshooting",
+ "runner_settings_url" => "/#{project.full_path}/-/runners#js-runners-settings",
+ "page_path" => "/#{project.full_path}/-/jobs/#{job.id}",
+ "build_status" => "pending",
+ "build_stage" => "test",
+ "log_state" => "",
+ "build_options" => {
+ build_stage: "test",
+ build_status: "pending",
+ log_state: "",
+ page_path: "/#{project.full_path}/-/jobs/#{job.id}"
+ },
+ "retry_outdated_job_docs_url" => "/help/ci/pipelines/settings#retry-outdated-jobs"
+ })
end
- it 'returns bridge data' do
- expect(bridge_data).to eq({
- "build_id" => bridge.id,
- "empty-state-illustration-path" => '/path/to/illustration',
- "pipeline_iid" => bridge.pipeline.iid,
- "project_full_path" => project.full_path
+ it 'returns job statuses' do
+ expect(helper.job_statuses).to eq({
+ "canceled" => "CANCELED",
+ "created" => "CREATED",
+ "failed" => "FAILED",
+ "manual" => "MANUAL",
+ "pending" => "PENDING",
+ "preparing" => "PREPARING",
+ "running" => "RUNNING",
+ "scheduled" => "SCHEDULED",
+ "skipped" => "SKIPPED",
+ "success" => "SUCCESS",
+ "waiting_for_resource" => "WAITING_FOR_RESOURCE"
})
end
end
diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb
index c9aac63a883..b45882d9888 100644
--- a/spec/helpers/ci/pipeline_editor_helper_spec.rb
+++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe Ci::PipelineEditorHelper do
let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
describe 'can_view_pipeline_editor?' do
subject { helper.can_view_pipeline_editor?(project) }
@@ -29,12 +30,12 @@ RSpec.describe Ci::PipelineEditorHelper do
"ci-examples-help-page-path" => help_page_path('ci/examples/index'),
"ci-help-page-path" => help_page_path('ci/index'),
"ci-lint-path" => project_ci_lint_path(project),
+ "ci-troubleshooting-path" => help_page_path('ci/troubleshooting', anchor: 'common-cicd-issues'),
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => 'illustrations/empty.svg',
"initial-branch-name" => nil,
"includes-help-page-path" => help_page_path('ci/yaml/includes'),
"lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'),
- "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'),
"needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'),
"new-merge-request-path" => '/mock/project/-/merge_requests/new',
"pipeline-page-path" => project_pipelines_path(project),
@@ -62,6 +63,10 @@ RSpec.describe Ci::PipelineEditorHelper do
.to receive(:image_path)
.with('illustrations/project-run-CICD-pipelines-sm.svg')
.and_return('illustrations/validate.svg')
+
+ allow(helper)
+ .to receive(:current_user)
+ .and_return(user)
end
subject(:pipeline_editor_data) { helper.js_pipeline_editor_data(project) }
diff --git a/spec/helpers/ci/pipelines_helper_spec.rb b/spec/helpers/ci/pipelines_helper_spec.rb
index 19946afb1a4..6463da7c53f 100644
--- a/spec/helpers/ci/pipelines_helper_spec.rb
+++ b/spec/helpers/ci/pipelines_helper_spec.rb
@@ -121,35 +121,7 @@ RSpec.describe Ci::PipelinesHelper do
:has_gitlab_ci,
:pipeline_editor_path,
:suggested_ci_templates,
- :ci_runner_settings_path])
- end
-
- describe 'the `any_runners_available` attribute' do
- subject { data[:any_runners_available] }
-
- context 'when the `runners_availability_section` experiment variant is control' do
- before do
- stub_experiments(runners_availability_section: :control)
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'when the `runners_availability_section` experiment variant is candidate' do
- before do
- stub_experiments(runners_availability_section: :candidate)
- end
-
- context 'when there are no runners' do
- it { is_expected.to eq('false') }
- end
-
- context 'when there are runners' do
- let!(:runner) { create(:ci_runner, :project, projects: [project]) }
-
- it { is_expected.to eq('true') }
- end
- end
+ :full_path])
end
describe 'when the project is eligible for the `ios_specific_templates` experiment' do
@@ -192,11 +164,7 @@ RSpec.describe Ci::PipelinesHelper do
end
end
- describe 'the `ios_runners_available` attribute' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
+ describe 'the `ios_runners_available` attribute', :saas do
subject { data[:ios_runners_available] }
context 'when the `ios_specific_templates` experiment variant is control' do
diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb
index 6d14abd6574..c170d7fae67 100644
--- a/spec/helpers/ci/runners_helper_spec.rb
+++ b/spec/helpers/ci/runners_helper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::RunnersHelper do
+RSpec.describe Ci::RunnersHelper, feature_category: :runner_fleet do
let_it_be(:user) { create(:user) }
before do
@@ -38,6 +38,14 @@ RSpec.describe Ci::RunnersHelper do
end
end
+ describe '#runner_short_name' do
+ it 'shows runner short name' do
+ runner = build_stubbed(:ci_runner, id: non_existing_record_id)
+
+ expect(helper.runner_short_name(runner)).to eq("##{runner.id} (#{runner.short_sha})")
+ end
+ end
+
describe '#runner_contacted_at' do
let(:contacted_at_stored) { 1.hour.ago.change(usec: 0) }
let(:contacted_at_cached) { 1.second.ago.change(usec: 0) }
@@ -77,7 +85,7 @@ RSpec.describe Ci::RunnersHelper do
describe '#admin_runners_data_attributes' do
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:instance_runner) { create(:ci_runner, :instance) }
- let_it_be(:project_runner) { create(:ci_runner, :project ) }
+ let_it_be(:project_runner) { create(:ci_runner, :project) }
before do
allow(helper).to receive(:current_user).and_return(admin)
@@ -88,9 +96,7 @@ RSpec.describe Ci::RunnersHelper do
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
registration_token: Gitlab::CurrentSettings.runners_registration_token,
online_contact_timeout_secs: 7200,
- stale_timeout_secs: 7889238,
- empty_state_svg_path: start_with('/assets/illustrations/pipelines_empty'),
- empty_state_filtered_svg_path: start_with('/assets/illustrations/magnifying-glass')
+ stale_timeout_secs: 7889238
)
end
end
@@ -98,6 +104,8 @@ RSpec.describe Ci::RunnersHelper do
describe '#group_shared_runners_settings_data' do
let_it_be(:parent) { create(:group) }
let_it_be(:group) { create(:group, parent: parent, shared_runners_enabled: false) }
+ let_it_be(:group_with_project) { create(:group, parent: parent) }
+ let_it_be(:project) { create(:project, group: group_with_project) }
let(:runner_constants) do
{
@@ -110,6 +118,8 @@ RSpec.describe Ci::RunnersHelper do
it 'returns group data for top level group' do
result = {
group_id: parent.id,
+ group_name: parent.name,
+ group_is_empty: 'false',
shared_runners_setting: Namespace::SR_ENABLED,
parent_shared_runners_setting: nil
}.merge(runner_constants)
@@ -120,12 +130,26 @@ RSpec.describe Ci::RunnersHelper do
it 'returns group data for child group' do
result = {
group_id: group.id,
+ group_name: group.name,
+ group_is_empty: 'true',
shared_runners_setting: Namespace::SR_DISABLED_AND_UNOVERRIDABLE,
parent_shared_runners_setting: Namespace::SR_ENABLED
}.merge(runner_constants)
expect(helper.group_shared_runners_settings_data(group)).to eq result
end
+
+ it 'returns group data for child group with project' do
+ result = {
+ group_id: group_with_project.id,
+ group_name: group_with_project.name,
+ group_is_empty: 'false',
+ shared_runners_setting: Namespace::SR_ENABLED,
+ parent_shared_runners_setting: Namespace::SR_ENABLED
+ }.merge(runner_constants)
+
+ expect(helper.group_shared_runners_settings_data(group_with_project)).to eq result
+ end
end
describe '#group_runners_data_attributes' do
@@ -142,9 +166,7 @@ RSpec.describe Ci::RunnersHelper do
group_full_path: group.full_path,
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
online_contact_timeout_secs: 7200,
- stale_timeout_secs: 7889238,
- empty_state_svg_path: start_with('/assets/illustrations/pipelines_empty'),
- empty_state_filtered_svg_path: start_with('/assets/illustrations/magnifying-glass')
+ stale_timeout_secs: 7889238
)
end
end
diff --git a/spec/helpers/ci/variables_helper_spec.rb b/spec/helpers/ci/variables_helper_spec.rb
index d032e7f9087..9c3236ace72 100644
--- a/spec/helpers/ci/variables_helper_spec.rb
+++ b/spec/helpers/ci/variables_helper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::VariablesHelper, feature_category: :pipeline_authoring do
+RSpec.describe Ci::VariablesHelper, feature_category: :secrets_management do
describe '#ci_variable_maskable_raw_regex' do
it 'converts to a javascript regex' do
expect(helper.ci_variable_maskable_raw_regex).to eq("^\\S{8,}$")
diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb
index 9a3cd5fd18d..41a8dea7f5a 100644
--- a/spec/helpers/clusters_helper_spec.rb
+++ b/spec/helpers/clusters_helper_spec.rb
@@ -166,6 +166,90 @@ RSpec.describe ClustersHelper do
end
end
+ describe '#render_cluster_info_tab_content' do
+ subject { helper.render_cluster_info_tab_content(tab, expanded) }
+
+ let(:expanded) { true }
+
+ context 'environments' do
+ let(:tab) { 'environments' }
+
+ it 'renders environemtns tab' do
+ expect(helper).to receive(:render_if_exists).with('clusters/clusters/environments')
+ subject
+ end
+ end
+
+ context 'health' do
+ let(:tab) { 'health' }
+
+ it 'renders details tab' do
+ expect(helper).to receive(:render).with('details', { expanded: expanded })
+ subject
+ end
+ end
+
+ context 'apps' do
+ let(:tab) { 'apps' }
+
+ it 'renders apps tab' do
+ expect(helper).to receive(:render).with('applications')
+ subject
+ end
+ end
+
+ context 'integrations ' do
+ let(:tab) { 'integrations' }
+
+ it 'renders details tab' do
+ expect(helper).to receive(:render).with('details', { expanded: expanded })
+ subject
+ end
+ end
+
+ context 'settings' do
+ let(:tab) { 'settings' }
+
+ it 'renders settings tab' do
+ expect(helper).to receive(:render).with('advanced_settings_container')
+ subject
+ end
+ end
+
+ context 'details ' do
+ let(:tab) { 'details' }
+
+ it 'renders details tab' do
+ expect(helper).to receive(:render).with('details', { expanded: expanded })
+ subject
+ end
+ end
+
+ context 'when remove_monitor_metrics FF is disabled' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
+ context 'health' do
+ let(:tab) { 'health' }
+
+ it 'renders health tab' do
+ expect(helper).to receive(:render_if_exists).with('clusters/clusters/health')
+ subject
+ end
+ end
+
+ context 'integrations ' do
+ let(:tab) { 'integrations' }
+
+ it 'renders integrations tab' do
+ expect(helper).to receive(:render).with('integrations')
+ subject
+ end
+ end
+ end
+ end
+
describe '#cluster_type_label' do
subject { helper.cluster_type_label(cluster_type) }
diff --git a/spec/helpers/commits_helper_spec.rb b/spec/helpers/commits_helper_spec.rb
index 27738f73ea5..2d06f42dee4 100644
--- a/spec/helpers/commits_helper_spec.rb
+++ b/spec/helpers/commits_helper_spec.rb
@@ -325,21 +325,20 @@ RSpec.describe CommitsHelper do
assign(:path, current_path)
end
- it { is_expected.to be_an(Array) }
- it { is_expected.to include(commit) }
- it { is_expected.to include(commit.author) }
- it { is_expected.to include(ref) }
-
specify do
- is_expected.to include(
+ expect(subject).to eq([
+ commit,
+ commit.author,
+ ref,
{
merge_request: merge_request.cache_key,
pipeline_status: pipeline.cache_key,
xhr: true,
controller: "commits",
- path: current_path
+ path: current_path,
+ referenced_by: helper.tag_checksum(commit.referenced_by)
}
- )
+ ])
end
describe "final cache key output" do
diff --git a/spec/helpers/device_registration_helper_spec.rb b/spec/helpers/device_registration_helper_spec.rb
new file mode 100644
index 00000000000..7556d037b3d
--- /dev/null
+++ b/spec/helpers/device_registration_helper_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe DeviceRegistrationHelper, feature_category: :system_access do
+ describe "#device_registration_data" do
+ it "returns a hash with device registration properties without initial error" do
+ device_registration_data = helper.device_registration_data(
+ current_password_required: false,
+ target_path: "/my/path",
+ webauthn_error: nil
+ )
+
+ expect(device_registration_data).to eq(
+ {
+ initial_error: nil,
+ target_path: "/my/path",
+ password_required: "false"
+ })
+ end
+
+ it "returns a hash with device registration properties with initial error" do
+ device_registration_data = helper.device_registration_data(
+ current_password_required: true,
+ target_path: "/my/path",
+ webauthn_error: { message: "my error" }
+ )
+
+ expect(device_registration_data).to eq(
+ {
+ initial_error: "my error",
+ target_path: "/my/path",
+ password_required: "true"
+ })
+ end
+ end
+end
diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb
index a46f8c13f00..2318bbf861a 100644
--- a/spec/helpers/diff_helper_spec.rb
+++ b/spec/helpers/diff_helper_spec.rb
@@ -47,6 +47,12 @@ RSpec.describe DiffHelper do
end
describe 'diff_options' do
+ let(:large_notebooks_enabled) { false }
+
+ before do
+ stub_feature_flags(large_ipynb_diffs: large_notebooks_enabled)
+ end
+
it 'returns no collapse false' do
expect(diff_options).to include(expanded: false)
end
@@ -56,21 +62,48 @@ RSpec.describe DiffHelper do
expect(diff_options).to include(expanded: true)
end
- it 'returns no collapse true if action name diff_for_path' do
- allow(controller).to receive(:action_name) { 'diff_for_path' }
- expect(diff_options).to include(expanded: true)
- end
+ context 'when action name is diff_for_path' do
+ before do
+ allow(controller).to receive(:action_name) { 'diff_for_path' }
+ end
- it 'returns paths if action name diff_for_path and param old path' do
- allow(controller).to receive(:params) { { old_path: 'lib/wadus.rb' } }
- allow(controller).to receive(:action_name) { 'diff_for_path' }
- expect(diff_options[:paths]).to include('lib/wadus.rb')
- end
+ it 'returns expanded true' do
+ expect(diff_options).to include(expanded: true)
+ end
- it 'returns paths if action name diff_for_path and param new path' do
- allow(controller).to receive(:params) { { new_path: 'lib/wadus.rb' } }
- allow(controller).to receive(:action_name) { 'diff_for_path' }
- expect(diff_options[:paths]).to include('lib/wadus.rb')
+ it 'returns paths if param old path' do
+ allow(controller).to receive(:params) { { old_path: 'lib/wadus.rb' } }
+ expect(diff_options[:paths]).to include('lib/wadus.rb')
+ end
+
+ it 'returns paths if param new path' do
+ allow(controller).to receive(:params) { { new_path: 'lib/wadus.rb' } }
+ expect(diff_options[:paths]).to include('lib/wadus.rb')
+ end
+
+ it 'does not set max_patch_bytes_for_file_extension' do
+ expect(diff_options[:max_patch_bytes_for_file_extension]).to be_nil
+ end
+
+ context 'when file_identifier include .ipynb' do
+ before do
+ allow(controller).to receive(:params) { { file_identifier: 'something.ipynb' } }
+ end
+
+ context 'when large_ipynb_diffs is disabled' do
+ it 'does not set max_patch_bytes_for_file_extension' do
+ expect(diff_options[:max_patch_bytes_for_file_extension]).to be_nil
+ end
+ end
+
+ context 'when large_ipynb_diffs is enabled' do
+ let(:large_notebooks_enabled) { true }
+
+ it 'sets max_patch_bytes_for_file_extension' do
+ expect(diff_options[:max_patch_bytes_for_file_extension]).to eq({ '.ipynb' => 1.megabyte })
+ end
+ end
+ end
end
end
diff --git a/spec/helpers/emoji_helper_spec.rb b/spec/helpers/emoji_helper_spec.rb
index 6f4c962c0fb..e16c96c86ed 100644
--- a/spec/helpers/emoji_helper_spec.rb
+++ b/spec/helpers/emoji_helper_spec.rb
@@ -12,10 +12,12 @@ RSpec.describe EmojiHelper do
subject { helper.emoji_icon(emoji_text, options) }
it 'has no options' do
- is_expected.to include('<gl-emoji',
- "title=\"#{emoji_text}\"",
- "data-name=\"#{emoji_text}\"",
- "data-unicode-version=\"#{unicode_version}\"")
+ is_expected.to include(
+ '<gl-emoji',
+ "title=\"#{emoji_text}\"",
+ "data-name=\"#{emoji_text}\"",
+ "data-unicode-version=\"#{unicode_version}\""
+ )
is_expected.not_to include(aria_hidden_option)
end
@@ -23,11 +25,13 @@ RSpec.describe EmojiHelper do
let(:options) { { 'aria-hidden': true } }
it 'applies aria-hidden' do
- is_expected.to include('<gl-emoji',
- "title=\"#{emoji_text}\"",
- "data-name=\"#{emoji_text}\"",
- "data-unicode-version=\"#{unicode_version}\"",
- aria_hidden_option)
+ is_expected.to include(
+ '<gl-emoji',
+ "title=\"#{emoji_text}\"",
+ "data-name=\"#{emoji_text}\"",
+ "data-unicode-version=\"#{unicode_version}\"",
+ aria_hidden_option
+ )
end
end
end
diff --git a/spec/helpers/environment_helper_spec.rb b/spec/helpers/environment_helper_spec.rb
index c8d67d6dac2..64735f8b23b 100644
--- a/spec/helpers/environment_helper_spec.rb
+++ b/spec/helpers/environment_helper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe EnvironmentHelper do
+RSpec.describe EnvironmentHelper, feature_category: :environment_management do
describe '#render_deployment_status' do
context 'when using a manual deployment' do
it 'renders a span tag' do
@@ -56,7 +56,6 @@ RSpec.describe EnvironmentHelper do
can_destroy_environment: true,
can_stop_environment: true,
can_admin_environment: true,
- environment_metrics_path: project_metrics_dashboard_path(project, environment: environment),
environments_fetch_path: project_environments_path(project, format: :json),
environment_edit_path: edit_project_environment_path(project, environment),
environment_stop_path: stop_project_environment_path(project, environment),
@@ -65,8 +64,21 @@ RSpec.describe EnvironmentHelper do
environment_terminal_path: terminal_project_environment_path(project, environment),
has_terminals: false,
is_environment_available: true,
- auto_stop_at: auto_stop_at
+ auto_stop_at: auto_stop_at,
+ graphql_etag_key: environment.etag_cache_key
}.to_json)
end
+
+ context 'when metrics dashboard feature is available' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
+ it 'includes metrics path' do
+ expect(Gitlab::Json.parse(subject)).to include(
+ 'environment_metrics_path' => project_metrics_dashboard_path(project, environment: environment)
+ )
+ end
+ end
end
end
diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb
index cf33f8a4939..0ebec3ed6d0 100644
--- a/spec/helpers/environments_helper_spec.rb
+++ b/spec/helpers/environments_helper_spec.rb
@@ -2,13 +2,15 @@
require 'spec_helper'
-RSpec.describe EnvironmentsHelper do
+RSpec.describe EnvironmentsHelper, feature_category: :environment_management do
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project, :repository) }
let_it_be(:environment) { create(:environment, project: project) }
- describe '#metrics_data' do
+ describe '#metrics_data', feature_category: :metrics do
before do
+ stub_feature_flags(remove_monitor_metrics: false)
+
# This is so that this spec also passes in EE.
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:can?).and_return(true)
@@ -103,9 +105,19 @@ RSpec.describe EnvironmentsHelper do
end
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'does not return data' do
+ expect(metrics_data).to be_empty
+ end
+ end
end
- describe '#custom_metrics_available?' do
+ describe '#custom_metrics_available?', feature_category: :metrics do
subject { helper.custom_metrics_available?(project) }
before do
diff --git a/spec/helpers/explore_helper_spec.rb b/spec/helpers/explore_helper_spec.rb
index 4ae1b738858..68c5289a85f 100644
--- a/spec/helpers/explore_helper_spec.rb
+++ b/spec/helpers/explore_helper_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe ExploreHelper do
describe '#explore_nav_links' do
it 'has all the expected links by default' do
- menu_items = [:projects, :groups, :snippets]
+ menu_items = [:projects, :groups, :topics, :snippets]
expect(helper.explore_nav_links).to contain_exactly(*menu_items)
end
diff --git a/spec/helpers/feature_flags_helper_spec.rb b/spec/helpers/feature_flags_helper_spec.rb
index 786454c6c4d..a5e7f8d273e 100644
--- a/spec/helpers/feature_flags_helper_spec.rb
+++ b/spec/helpers/feature_flags_helper_spec.rb
@@ -33,12 +33,14 @@ RSpec.describe FeatureFlagsHelper do
subject { helper.edit_feature_flag_data }
it 'contains all the data needed to edit feature flags' do
- is_expected.to include(endpoint: "/#{project.full_path}/-/feature_flags/#{feature_flag.iid}",
- project_id: project.id,
- feature_flags_path: "/#{project.full_path}/-/feature_flags",
- environments_endpoint: "/#{project.full_path}/-/environments/search.json",
- strategy_type_docs_page_path: "/help/operations/feature_flags#feature-flag-strategies",
- environments_scope_docs_path: "/help/ci/environments/index.md#limit-the-environment-scope-of-a-cicd-variable")
+ is_expected.to include(
+ endpoint: "/#{project.full_path}/-/feature_flags/#{feature_flag.iid}",
+ project_id: project.id,
+ feature_flags_path: "/#{project.full_path}/-/feature_flags",
+ environments_endpoint: "/#{project.full_path}/-/environments/search.json",
+ strategy_type_docs_page_path: "/help/operations/feature_flags#feature-flag-strategies",
+ environments_scope_docs_path: "/help/ci/environments/index.md#limit-the-environment-scope-of-a-cicd-variable"
+ )
end
end
end
diff --git a/spec/helpers/groups/observability_helper_spec.rb b/spec/helpers/groups/observability_helper_spec.rb
index ee33a853f9c..f0e6aa0998a 100644
--- a/spec/helpers/groups/observability_helper_spec.rb
+++ b/spec/helpers/groups/observability_helper_spec.rb
@@ -4,77 +4,46 @@ require "spec_helper"
RSpec.describe Groups::ObservabilityHelper do
let(:group) { build_stubbed(:group) }
- let(:observability_url) { Gitlab::Observability.observability_url }
describe '#observability_iframe_src' do
- context 'if observability_path is missing from params' do
- it 'returns the iframe src for action: dashboards' do
- allow(helper).to receive(:params).and_return({ action: 'dashboards' })
- expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/-/#{group.id}/")
- end
-
- it 'returns the iframe src for action: manage' do
- allow(helper).to receive(:params).and_return({ action: 'manage' })
- expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/-/#{group.id}/dashboards")
- end
-
- it 'returns the iframe src for action: explore' do
- allow(helper).to receive(:params).and_return({ action: 'explore' })
- expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/-/#{group.id}/explore")
- end
-
- it 'returns the iframe src for action: datasources' do
- allow(helper).to receive(:params).and_return({ action: 'datasources' })
- expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/-/#{group.id}/datasources")
- end
+ before do
+ allow(Gitlab::Observability).to receive(:build_full_url).and_return('full-url')
end
- context 'if observability_path exists in params' do
- context 'if observability_path is valid' do
- it 'returns the iframe src by injecting the observability path' do
- allow(helper).to receive(:params).and_return({ action: '/explore', observability_path: '/foo?bar=foobar' })
- expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/-/#{group.id}/foo?bar=foobar")
- end
- end
-
- context 'if observability_path is not valid' do
- it 'returns the iframe src by injecting the sanitised observability path' do
- allow(helper).to receive(:params).and_return({
- action: '/explore',
- observability_path:
- "/test?groupId=<script>alert('attack!')</script>"
- })
- expect(helper.observability_iframe_src(group)).to eq(
- "#{observability_url}/-/#{group.id}/test?groupId=alert('attack!')"
- )
- end
- end
+ it 'returns the iframe src for action: dashboards' do
+ allow(helper).to receive(:params).and_return({ action: 'dashboards', observability_path: '/foo?bar=foobar' })
+ expect(helper.observability_iframe_src(group)).to eq('full-url')
+ expect(Gitlab::Observability).to have_received(:build_full_url).with(group, '/foo?bar=foobar', '/')
end
- context 'when observability ui is standalone' do
- before do
- stub_env('STANDALONE_OBSERVABILITY_UI', 'true')
- end
+ it 'returns the iframe src for action: manage' do
+ allow(helper).to receive(:params).and_return({ action: 'manage', observability_path: '/foo?bar=foobar' })
+ expect(helper.observability_iframe_src(group)).to eq('full-url')
+ expect(Gitlab::Observability).to have_received(:build_full_url).with(group, '/foo?bar=foobar', '/dashboards')
+ end
- it 'returns the iframe src without group.id for action: dashboards' do
- allow(helper).to receive(:params).and_return({ action: 'dashboards' })
- expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/")
- end
+ it 'returns the iframe src for action: explore' do
+ allow(helper).to receive(:params).and_return({ action: 'explore', observability_path: '/foo?bar=foobar' })
+ expect(helper.observability_iframe_src(group)).to eq('full-url')
+ expect(Gitlab::Observability).to have_received(:build_full_url).with(group, '/foo?bar=foobar', '/explore')
+ end
- it 'returns the iframe src without group.id for action: manage' do
- allow(helper).to receive(:params).and_return({ action: 'manage' })
- expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/dashboards")
- end
+ it 'returns the iframe src for action: datasources' do
+ allow(helper).to receive(:params).and_return({ action: 'datasources', observability_path: '/foo?bar=foobar' })
+ expect(helper.observability_iframe_src(group)).to eq('full-url')
+ expect(Gitlab::Observability).to have_received(:build_full_url).with(group, '/foo?bar=foobar', '/datasources')
+ end
- it 'returns the iframe src without group.id for action: explore' do
- allow(helper).to receive(:params).and_return({ action: 'explore' })
- expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/explore")
- end
+ it 'returns the iframe src when action is not recognised' do
+ allow(helper).to receive(:params).and_return({ action: 'unrecognised', observability_path: '/foo?bar=foobar' })
+ expect(helper.observability_iframe_src(group)).to eq('full-url')
+ expect(Gitlab::Observability).to have_received(:build_full_url).with(group, '/foo?bar=foobar', '/')
+ end
- it 'returns the iframe src without group.id for action: datasources' do
- allow(helper).to receive(:params).and_return({ action: 'datasources' })
- expect(helper.observability_iframe_src(group)).to eq("#{observability_url}/datasources")
- end
+ it 'returns the iframe src when observability_path is missing' do
+ allow(helper).to receive(:params).and_return({ action: 'dashboards' })
+ expect(helper.observability_iframe_src(group)).to eq('full-url')
+ expect(Gitlab::Observability).to have_received(:build_full_url).with(group, nil, '/')
end
end
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index 8b4ac6a7cfd..f66f9a8a58e 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -436,7 +436,8 @@ RSpec.describe GroupsHelper do
it 'returns expected hash' do
expect(subgroup_creation_data(subgroup)).to eq({
import_existing_group_path: '/groups/new#import-group-pane',
- parent_group_name: name
+ parent_group_name: name,
+ parent_group_url: group_url(group)
})
end
end
@@ -445,7 +446,8 @@ RSpec.describe GroupsHelper do
it 'returns expected hash' do
expect(subgroup_creation_data(group)).to eq({
import_existing_group_path: '/groups/new#import-group-pane',
- parent_group_name: nil
+ parent_group_name: nil,
+ parent_group_url: nil
})
end
end
@@ -495,6 +497,7 @@ RSpec.describe GroupsHelper do
new_project_path: including("/projects/new?namespace_id=#{group.id}"),
new_subgroup_illustration: including('illustrations/subgroup-create-new-sm'),
new_project_illustration: including('illustrations/project-create-new-sm'),
+ empty_projects_illustration: including('illustrations/empty-state/empty-projects-md'),
empty_subgroup_illustration: including('illustrations/empty-state/empty-subgroup-md'),
render_empty_state: 'true',
can_create_subgroups: 'true',
diff --git a/spec/helpers/ide_helper_spec.rb b/spec/helpers/ide_helper_spec.rb
index e2ee4f33eee..922155abf65 100644
--- a/spec/helpers/ide_helper_spec.rb
+++ b/spec/helpers/ide_helper_spec.rb
@@ -6,117 +6,136 @@ RSpec.describe IdeHelper, feature_category: :web_ide do
describe '#ide_data' do
let_it_be(:project) { create(:project) }
let_it_be(:user) { project.creator }
+ let_it_be(:fork_info) { { ide_path: '/test/ide/path' } }
+
+ let_it_be(:params) do
+ {
+ branch: 'master',
+ path: 'foo/bar',
+ merge_request_id: '1'
+ }
+ end
+
+ let(:base_data) do
+ {
+ 'use-new-web-ide' => 'false',
+ 'user-preferences-path' => profile_preferences_path,
+ 'sign-in-path' => 'test-sign-in-path',
+ 'project' => nil,
+ 'preview-markdown-path' => nil
+ }
+ end
before do
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:content_security_policy_nonce).and_return('test-csp-nonce')
+ allow(helper).to receive(:new_session_path).and_return('test-sign-in-path')
end
- context 'with vscode_web_ide=true and instance vars set' do
- before do
- stub_feature_flags(vscode_web_ide: true)
- end
+ it 'returns hash' do
+ expect(helper.ide_data(project: nil, fork_info: fork_info, params: params))
+ .to include(base_data)
+ end
- it 'returns hash' do
- expect(helper.ide_data(project: project, branch: 'master', path: 'foo/README.md', merge_request: '7',
-fork_info: nil))
- .to match(
- 'can-use-new-web-ide' => 'true',
- 'use-new-web-ide' => 'true',
- 'user-preferences-path' => profile_preferences_path,
- 'new-web-ide-help-page-path' =>
- help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'),
- 'branch-name' => 'master',
- 'project-path' => project.path_with_namespace,
- 'csp-nonce' => 'test-csp-nonce',
- 'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path'),
- 'file-path' => 'foo/README.md',
- 'editor-font-family' => 'JetBrains Mono',
- 'editor-font-format' => 'woff2',
- 'editor-font-src-url' => a_string_matching(%r{jetbrains-mono/JetBrainsMono}),
- 'merge-request' => '7',
- 'fork-info' => nil
- )
+ context 'with project' do
+ it 'returns hash with parameters' do
+ serialized_project = API::Entities::Project.represent(project, current_user: user).to_json
+
+ expect(
+ helper.ide_data(project: project, fork_info: nil, params: params)
+ ).to include(base_data.merge(
+ 'fork-info' => nil,
+ 'branch-name' => params[:branch],
+ 'file-path' => params[:path],
+ 'merge-request' => params[:merge_request_id],
+ 'project' => serialized_project,
+ 'preview-markdown-path' => Gitlab::Routing.url_helpers.preview_markdown_project_path(project)
+ ))
end
- it 'does not use new web ide if user.use_legacy_web_ide' do
- allow(user).to receive(:use_legacy_web_ide).and_return(true)
-
- expect(helper.ide_data(project: project, branch: nil, path: nil, merge_request: nil,
-fork_info: nil)).to include('use-new-web-ide' => 'false')
+ context 'with fork info' do
+ it 'returns hash with fork info' do
+ expect(helper.ide_data(project: project, fork_info: fork_info, params: params))
+ .to include('fork-info' => fork_info.to_json)
+ end
end
end
- context 'with vscode_web_ide=false' do
+ context 'with environments guidance experiment', :experiment do
before do
- stub_feature_flags(vscode_web_ide: false)
+ stub_experiments(in_product_guidance_environments_webide: :candidate)
end
- context 'when instance vars and parameters are not set' do
- it 'returns instance data in the hash as nil' do
- expect(helper.ide_data(project: nil, branch: nil, path: nil, merge_request: nil, fork_info: nil))
- .to include(
- 'can-use-new-web-ide' => 'false',
- 'use-new-web-ide' => 'false',
- 'user-preferences-path' => profile_preferences_path,
- 'branch-name' => nil,
- 'file-path' => nil,
- 'merge-request' => nil,
- 'fork-info' => nil,
- 'project' => nil,
- 'preview-markdown-path' => nil
- )
+ context 'when project has no enviornments' do
+ it 'enables environment guidance' do
+ expect(helper.ide_data(project: project, fork_info: fork_info, params: params))
+ .to include('enable-environments-guidance' => 'true')
end
- end
- context 'when instance vars are set' do
- it 'returns instance data in the hash' do
- fork_info = { ide_path: '/test/ide/path' }
-
- serialized_project = API::Entities::Project.represent(project, current_user: project.creator).to_json
-
- expect(helper.ide_data(project: project, branch: 'master', path: 'foo/bar', merge_request: '1',
-fork_info: fork_info))
- .to include(
- 'branch-name' => 'master',
- 'file-path' => 'foo/bar',
- 'merge-request' => '1',
- 'fork-info' => fork_info.to_json,
- 'project' => serialized_project,
- 'preview-markdown-path' => Gitlab::Routing.url_helpers.preview_markdown_project_path(project)
- )
+ context 'and the callout has been dismissed' do
+ it 'disables environment guidance' do
+ callout = create(:callout, feature_name: :web_ide_ci_environments_guidance, user: user)
+ callout.update!(dismissed_at: Time.now - 1.week)
+ allow(helper).to receive(:current_user).and_return(User.find(user.id))
+
+ expect(helper.ide_data(project: project, fork_info: fork_info, params: params))
+ .to include('enable-environments-guidance' => 'false')
+ end
end
end
- context 'environments guidance experiment', :experiment do
- before do
- stub_experiments(in_product_guidance_environments_webide: :candidate)
+ context 'when the project has environments' do
+ it 'disables environment guidance' do
+ create(:environment, project: project)
+
+ expect(helper.ide_data(project: project, fork_info: fork_info, params: params))
+ .to include('enable-environments-guidance' => 'false')
end
+ end
+ end
- context 'when project has no enviornments' do
- it 'enables environment guidance' do
- expect(helper.ide_data(project: project, branch: nil, path: nil, merge_request: nil,
-fork_info: nil)).to include('enable-environments-guidance' => 'true')
- end
+ context 'with vscode_web_ide=true' do
+ let(:base_data) do
+ {
+ 'use-new-web-ide' => 'true',
+ 'user-preferences-path' => profile_preferences_path,
+ 'sign-in-path' => 'test-sign-in-path',
+ 'new-web-ide-help-page-path' =>
+ help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'),
+ 'csp-nonce' => 'test-csp-nonce',
+ 'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path'),
+ 'editor-font-family' => 'JetBrains Mono',
+ 'editor-font-format' => 'woff2',
+ 'editor-font-src-url' => a_string_matching(%r{jetbrains-mono/JetBrainsMono})
+ }
+ end
- context 'and the callout has been dismissed' do
- it 'disables environment guidance' do
- callout = create(:callout, feature_name: :web_ide_ci_environments_guidance, user: project.creator)
- callout.update!(dismissed_at: Time.now - 1.week)
- allow(helper).to receive(:current_user).and_return(User.find(project.creator.id))
- expect(helper.ide_data(project: project, branch: nil, path: nil, merge_request: nil,
-fork_info: nil)).to include('enable-environments-guidance' => 'false')
- end
- end
- end
+ before do
+ stub_feature_flags(vscode_web_ide: true)
+ end
- context 'when the project has environments' do
- it 'disables environment guidance' do
- create(:environment, project: project)
+ it 'returns hash' do
+ expect(helper.ide_data(project: nil, fork_info: fork_info, params: params))
+ .to include(base_data)
+ end
- expect(helper.ide_data(project: project, branch: nil, path: nil, merge_request: nil,
-fork_info: nil)).to include('enable-environments-guidance' => 'false')
- end
+ it 'does not use new web ide if feature flag is disabled' do
+ stub_feature_flags(vscode_web_ide: false)
+
+ expect(helper.ide_data(project: nil, fork_info: fork_info, params: params))
+ .to include('use-new-web-ide' => 'false')
+ end
+
+ context 'with project' do
+ it 'returns hash with parameters' do
+ expect(
+ helper.ide_data(project: project, fork_info: nil, params: params)
+ ).to include(base_data.merge(
+ 'branch-name' => params[:branch],
+ 'file-path' => params[:path],
+ 'merge-request' => params[:merge_request_id],
+ 'fork-info' => nil
+ ))
end
end
end
diff --git a/spec/helpers/integrations_helper_spec.rb b/spec/helpers/integrations_helper_spec.rb
index 9822f9fac05..4f1e6c86fea 100644
--- a/spec/helpers/integrations_helper_spec.rb
+++ b/spec/helpers/integrations_helper_spec.rb
@@ -165,17 +165,18 @@ RSpec.describe IntegrationsHelper do
with_them do
before do
- issue.update!(issue_type: issue_type)
+ issue.assign_attributes(issue_type: issue_type, work_item_type: WorkItems::Type.default_by_type(issue_type))
+ issue.save!(validate: false)
end
it "return the correct i18n issue type" do
- expect(described_class.integration_issue_type(issue.issue_type)).to eq(expected_i18n_issue_type)
+ expect(described_class.integration_issue_type(issue.work_item_type.base_type)).to eq(expected_i18n_issue_type)
end
end
it "only consider these enumeration values are valid" do
expected_valid_types = %w[issue incident test_case requirement task objective key_result]
- expect(Issue.issue_types.keys).to contain_exactly(*expected_valid_types)
+ expect(WorkItems::Type.base_types.keys).to contain_exactly(*expected_valid_types)
end
end
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 1ae834c0769..ffaffa251d1 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -293,10 +293,13 @@ RSpec.describe IssuablesHelper, feature_category: :team_planning do
end
describe '#issuable_reference' do
+ let(:project_namespace) { build_stubbed(:project_namespace) }
+ let(:project) { build_stubbed(:project, project_namespace: project_namespace) }
+
context 'when show_full_reference truthy' do
it 'display issuable full reference' do
assign(:show_full_reference, true)
- issue = build_stubbed(:issue)
+ issue = build_stubbed(:issue, project: project)
expect(helper.issuable_reference(issue)).to eql(issue.to_reference(full: true))
end
@@ -305,12 +308,10 @@ RSpec.describe IssuablesHelper, feature_category: :team_planning do
context 'when show_full_reference falsey' do
context 'when @group present' do
it 'display issuable reference to @group' do
- project = build_stubbed(:project)
-
assign(:show_full_reference, nil)
assign(:group, project.namespace)
- issue = build_stubbed(:issue)
+ issue = build_stubbed(:issue, project: project)
expect(helper.issuable_reference(issue)).to eql(issue.to_reference(project.namespace))
end
@@ -318,13 +319,11 @@ RSpec.describe IssuablesHelper, feature_category: :team_planning do
context 'when @project present' do
it 'display issuable reference to @project' do
- project = build_stubbed(:project)
-
assign(:show_full_reference, nil)
assign(:group, nil)
assign(:project, project)
- issue = build_stubbed(:issue)
+ issue = build_stubbed(:issue, project: project)
expect(helper.issuable_reference(issue)).to eql(issue.to_reference(project))
end
@@ -333,8 +332,11 @@ RSpec.describe IssuablesHelper, feature_category: :team_planning do
end
describe '#issuable_project_reference' do
+ let(:project_namespace) { build_stubbed(:project_namespace) }
+ let(:project) { build_stubbed(:project, project_namespace: project_namespace) }
+
it 'display project name and simple reference with `#` to an issue' do
- issue = build_stubbed(:issue)
+ issue = build_stubbed(:issue, project: project)
expect(helper.issuable_project_reference(issue)).to eq("#{issue.project.full_name} ##{issue.iid}")
end
@@ -414,7 +416,7 @@ RSpec.describe IssuablesHelper, feature_category: :team_planning do
initialTitleText: issue.title,
initialDescriptionHtml: '<p dir="auto">issue text</p>',
initialDescriptionText: 'issue text',
- initialTaskStatus: '0 of 0 checklist items completed',
+ initialTaskCompletionStatus: { completed_count: 0, count: 0 },
issueType: 'issue',
iid: issue.iid.to_s,
isHidden: false
@@ -430,7 +432,8 @@ RSpec.describe IssuablesHelper, feature_category: :team_planning do
action: "show",
namespace_id: "foo",
project_id: "bar",
- id: incident.iid
+ id: incident.iid,
+ incident_tab: 'timeline'
}).permit!
end
@@ -441,7 +444,9 @@ RSpec.describe IssuablesHelper, feature_category: :team_planning do
expected_data = {
issueType: 'incident',
hasLinkedAlerts: false,
- canUpdateTimelineEvent: true
+ canUpdateTimelineEvent: true,
+ currentPath: "/foo/bar/-/issues/incident/#{incident.iid}/timeline",
+ currentTab: 'timeline'
}
expect(helper.issuable_initial_data(incident)).to match(hash_including(expected_data))
@@ -690,4 +695,94 @@ RSpec.describe IssuablesHelper, feature_category: :team_planning do
end
end
end
+
+ describe '#issuable_type_selector_data' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:project) { create(:project) }
+
+ where(:issuable_type, :issuable_display_type, :is_issue_allowed, :is_incident_allowed) do
+ :issue | 'issue' | true | false
+ :incident | 'incident' | false | true
+ end
+
+ with_them do
+ let(:issuable) { build_stubbed(issuable_type) }
+
+ before do
+ allow(helper).to receive(:create_issue_type_allowed?).with(project, :issue).and_return(is_issue_allowed)
+ allow(helper).to receive(:create_issue_type_allowed?).with(project, :incident).and_return(is_incident_allowed)
+ assign(:project, project)
+ end
+
+ it 'returns the correct data for the issuable type selector' do
+ expected_data = {
+ selected_type: issuable_display_type,
+ is_issue_allowed: is_issue_allowed.to_s,
+ is_incident_allowed: is_incident_allowed.to_s,
+ issue_path: new_project_issue_path(project),
+ incident_path: new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident' } })
+ }
+
+ expect(helper.issuable_type_selector_data(issuable)).to match(expected_data)
+ end
+ end
+ end
+
+ describe '#issuable_label_selector_data' do
+ let_it_be(:project) { create(:project, :repository) }
+
+ context 'with a new issuable' do
+ let_it_be(:issuable) { build(:issue, project: project) }
+
+ it 'returns the expected data' do
+ expect(helper.issuable_label_selector_data(project, issuable)).to match({
+ field_name: "#{issuable.class.model_name.param_key}[label_ids][]",
+ full_path: project.full_path,
+ initial_labels: '[]',
+ issuable_type: issuable.issuable_type,
+ labels_filter_base_path: project_issues_path(project),
+ labels_manage_path: project_labels_path(project)
+ })
+ end
+ end
+
+ context 'with an existing issuable' do
+ let_it_be(:label) { create(:label, name: 'Bug') }
+ let_it_be(:label2) { create(:label, name: 'Community contribution') }
+ let_it_be(:issuable) do
+ create(:merge_request, source_project: project, target_project: project, labels: [label, label2])
+ end
+
+ it 'returns the expected data' do
+ initial_labels = [
+ {
+ __typename: "Label",
+ id: label.id,
+ title: label.title,
+ description: label.description,
+ color: label.color,
+ text_color: label.text_color
+ },
+ {
+ __typename: "Label",
+ id: label2.id,
+ title: label2.title,
+ description: label2.description,
+ color: label2.color,
+ text_color: label2.text_color
+ }
+ ]
+
+ expect(helper.issuable_label_selector_data(project, issuable)).to match({
+ field_name: "#{issuable.class.model_name.param_key}[label_ids][]",
+ full_path: project.full_path,
+ initial_labels: initial_labels.to_json,
+ issuable_type: issuable.issuable_type,
+ labels_filter_base_path: project_merge_requests_path(project),
+ labels_manage_path: project_labels_path(project)
+ })
+ end
+ end
+ end
end
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index 994a1ff4f75..38cbb5a1d66 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -3,21 +3,11 @@
require 'spec_helper'
RSpec.describe IssuesHelper do
+ include Features::MergeRequestHelpers
+
let_it_be(:project) { create(:project) }
let_it_be_with_reload(:issue) { create(:issue, project: project) }
- describe '#work_item_type_icon' do
- it 'returns icon of all standard base types' do
- WorkItems::Type.base_types.each do |type|
- expect(work_item_type_icon(type[0])).to eq "issue-type-#{type[0].to_s.dasherize}"
- end
- end
-
- it 'defaults to issue icon if type is unknown' do
- expect(work_item_type_icon('invalid')).to eq 'issue-type-issue'
- end
- end
-
describe '#award_user_list' do
it 'returns a comma-separated list of the first X users' do
user = build_stubbed(:user, name: 'Joe')
@@ -228,8 +218,8 @@ RSpec.describe IssuesHelper do
let!(:new_issue) { create(:issue, author: User.support_bot, project: project2) }
before do
- allow(Gitlab::IncomingEmail).to receive(:enabled?) { true }
- allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true }
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?) { true }
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?) { true }
old_issue.update!(moved_to: new_issue)
end
@@ -247,10 +237,13 @@ RSpec.describe IssuesHelper do
describe '#issue_header_actions_data' do
let(:current_user) { create(:user) }
+ let(:merge_request) { create(:merge_request, :opened, source_project: project, author: current_user) }
+ let(:issuable_sidebar_issue) { serialize_issuable_sidebar(current_user, project, merge_request) }
before do
allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:can?).and_return(true)
+ allow(helper).to receive(:issuable_sidebar).and_return(issuable_sidebar_issue)
end
it 'returns expected result' do
@@ -269,10 +262,11 @@ RSpec.describe IssuesHelper do
report_abuse_path: add_category_abuse_reports_path,
reported_user_id: issue.author.id,
reported_from_url: issue_url(issue),
- submit_as_spam_path: mark_as_spam_project_issue_path(project, issue)
+ submit_as_spam_path: mark_as_spam_project_issue_path(project, issue),
+ issuable_email_address: issuable_sidebar_issue[:create_note_email]
}
- expect(helper.issue_header_actions_data(project, issue, current_user)).to include(expected)
+ expect(helper.issue_header_actions_data(project, issue, current_user, issuable_sidebar_issue)).to include(expected)
end
end
diff --git a/spec/helpers/jira_connect_helper_spec.rb b/spec/helpers/jira_connect_helper_spec.rb
index 31aeff85c70..b7c25320a0e 100644
--- a/spec/helpers/jira_connect_helper_spec.rb
+++ b/spec/helpers/jira_connect_helper_spec.rb
@@ -9,8 +9,7 @@ RSpec.describe JiraConnectHelper, feature_category: :integrations do
let(:user) { create(:user) }
let(:client_id) { '123' }
- let(:enable_public_keys_storage_config) { false }
- let(:enable_public_keys_storage_setting) { false }
+ let(:enable_public_keys_storage) { false }
before do
stub_application_setting(jira_connect_application_key: client_id)
@@ -22,26 +21,18 @@ RSpec.describe JiraConnectHelper, feature_category: :integrations do
before do
allow(view).to receive(:current_user).and_return(nil)
allow(Gitlab.config.gitlab).to receive(:url).and_return('http://test.host')
- allow(Gitlab.config.jira_connect).to receive(:enable_public_keys_storage)
- .and_return(enable_public_keys_storage_config)
- stub_application_setting(jira_connect_public_key_storage_enabled: enable_public_keys_storage_setting)
+ stub_application_setting(jira_connect_public_key_storage_enabled: enable_public_keys_storage)
end
it 'includes Jira Connect app attributes' do
is_expected.to include(
:groups_path,
- :add_subscriptions_path,
:subscriptions_path,
- :users_path,
:subscriptions,
:gitlab_user_path
)
end
- it 'assigns users_path with value' do
- expect(subject[:users_path]).to eq(jira_connect_users_path)
- end
-
context 'with oauth_metadata' do
let(:oauth_metadata) { helper.jira_connect_app_data([subscription], installation)[:oauth_metadata] }
@@ -72,16 +63,6 @@ RSpec.describe JiraConnectHelper, feature_category: :integrations do
)
end
- context 'jira_connect_oauth feature is disabled' do
- before do
- stub_feature_flags(jira_connect_oauth: false)
- end
-
- it 'does not assign oauth_metadata' do
- expect(oauth_metadata).to be_nil
- end
- end
-
context 'with self-managed instance' do
let_it_be(:installation) { create(:jira_connect_installation, instance_url: 'https://gitlab.example.com') }
@@ -108,16 +89,8 @@ RSpec.describe JiraConnectHelper, feature_category: :integrations do
expect(subject[:public_key_storage_enabled]).to eq(false)
end
- context 'when public_key_storage is enabled via config' do
- let(:enable_public_keys_storage_config) { true }
-
- it 'assignes public_key_storage_enabled to true' do
- expect(subject[:public_key_storage_enabled]).to eq(true)
- end
- end
-
- context 'when public_key_storage is enabled via setting' do
- let(:enable_public_keys_storage_setting) { true }
+ context 'when public_key_storage is enabled' do
+ let(:enable_public_keys_storage) { true }
it 'assignes public_key_storage_enabled to true' do
expect(subject[:public_key_storage_enabled]).to eq(true)
diff --git a/spec/helpers/labels_helper_spec.rb b/spec/helpers/labels_helper_spec.rb
index e8e981251e3..b4549630813 100644
--- a/spec/helpers/labels_helper_spec.rb
+++ b/spec/helpers/labels_helper_spec.rb
@@ -62,19 +62,19 @@ RSpec.describe LabelsHelper do
end
context 'with a project as subject' do
- let(:namespace) { build(:namespace, name: 'foo3') }
- let(:subject) { build(:project, namespace: namespace, name: 'bar3') }
+ let(:namespace) { build(:namespace) }
+ let(:subject) { build(:project, namespace: namespace) }
it 'links to project issues page' do
- expect(link_to_label(label_presenter)).to match %r{<a.*href="/foo3/bar3/-/issues\?label_name%5B%5D=#{label.name}".*>.*</a>}m
+ expect(link_to_label(label_presenter)).to match %r{<a.*href="/#{subject.full_path}/-/issues\?label_name%5B%5D=#{label.name}".*>.*</a>}m
end
end
context 'with a group as subject' do
- let(:subject) { build(:group, name: 'bar') }
+ let(:subject) { build(:group) }
it 'links to group issues page' do
- expect(link_to_label(label_presenter)).to match %r{<a.*href="/groups/bar/-/issues\?label_name%5B%5D=#{label.name}".*>.*</a>}m
+ expect(link_to_label(label_presenter)).to match %r{<a.*href="/groups/#{subject.path}/-/issues\?label_name%5B%5D=#{label.name}".*>.*</a>}m
end
end
@@ -126,11 +126,11 @@ RSpec.describe LabelsHelper do
end
it 'uses dark text on light backgrounds' do
- expect(text_color_for_bg('#EEEEEE')).to be_color('#333333')
+ expect(text_color_for_bg('#EEEEEE')).to be_color('#1F1E24')
end
it 'supports RGB triplets' do
- expect(text_color_for_bg('#FFF')).to be_color '#333333'
+ expect(text_color_for_bg('#FFF')).to be_color '#1F1E24'
expect(text_color_for_bg('#000')).to be_color '#FFFFFF'
end
end
diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb
index 088519248c6..a9f99f29f6d 100644
--- a/spec/helpers/markup_helper_spec.rb
+++ b/spec/helpers/markup_helper_spec.rb
@@ -534,6 +534,45 @@ RSpec.describe MarkupHelper do
helper.first_line_in_markdown(object, attribute, 100, is_todo: true, project: project)
end.not_to change { Gitlab::GitalyClient.get_request_count }
end
+
+ it 'strips non-user links' do
+ html = 'This a cool [website](https://gitlab.com/).'
+
+ object = create_object(html)
+ result = helper.first_line_in_markdown(object, attribute, 100, is_todo: true, project: project)
+
+ expect(result).to include('This a cool website.')
+ end
+
+ it 'styles the current user link', :aggregate_failures do
+ another_user = create(:user)
+ html = "Please have a look, @#{user.username} @#{another_user.username}!"
+
+ object = create_object(html)
+ result = helper.first_line_in_markdown(object, attribute, 100, is_todo: true, project: project)
+ links = Nokogiri::HTML.parse(result).css('//a')
+
+ expect(links[0].classes).to include('current-user')
+ expect(links[1].classes).not_to include('current-user')
+ end
+
+ context 'when current_user is nil' do
+ before do
+ allow(helper).to receive(:current_user).and_return(nil)
+ end
+
+ it 'renders the link with no styling when current_user is nil' do
+ another_user = create(:user)
+ html = "Please have a look, @#{user.username} @#{another_user.username}!"
+
+ object = create_object(html)
+ result = helper.first_line_in_markdown(object, attribute, 100, is_todo: true, project: project)
+ links = Nokogiri::HTML.parse(result).css('//a')
+
+ expect(links[0].classes).not_to include('current-user')
+ expect(links[1].classes).not_to include('current-user')
+ end
+ end
end
context 'when the asked attribute can be redacted' do
diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb
index 6b43e97a0b4..93df9d5f94b 100644
--- a/spec/helpers/merge_requests_helper_spec.rb
+++ b/spec/helpers/merge_requests_helper_spec.rb
@@ -3,7 +3,14 @@
require 'spec_helper'
RSpec.describe MergeRequestsHelper, feature_category: :code_review_workflow do
+ include Users::CalloutsHelper
+ include ApplicationHelper
+ include PageLayoutHelper
+ include ProjectsHelper
include ProjectForksHelper
+ include IconsHelper
+
+ let_it_be(:current_user) { create(:user) }
describe '#format_mr_branch_names' do
describe 'within the same project' do
@@ -27,7 +34,31 @@ RSpec.describe MergeRequestsHelper, feature_category: :code_review_workflow do
end
end
+ describe '#diffs_tab_pane_data' do
+ subject { diffs_tab_pane_data(project, merge_request, {}) }
+
+ context 'for endpoint_diff_for_path' do
+ context 'when sub-group project namespace' do
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:subgroup) { create(:group, :private, parent: group) }
+ let_it_be(:project) { create(:project, :private, group: subgroup) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+
+ it 'returns expected values' do
+ expect(
+ subject[:endpoint_diff_for_path]
+ ).to include("#{project.full_path}/-/merge_requests/#{merge_request.iid}/diff_for_path.json")
+ end
+ end
+ end
+ end
+
describe '#merge_path_description' do
+ # Using let_it_be(:project) raises the following error, so we use need to use let(:project):
+ # ActiveRecord::InvalidForeignKey:
+ # PG::ForeignKeyViolation: ERROR: insert or update on table "fork_network_members" violates foreign key
+ # constraint "fk_rails_a40860a1ca"
+ # DETAIL: Key (fork_network_id)=(8) is not present in table "fork_networks".
let(:project) { create(:project) }
let(:forked_project) { fork_project(project) }
let(:merge_request_forked) { create(:merge_request, source_project: forked_project, target_project: project) }
@@ -150,4 +181,45 @@ RSpec.describe MergeRequestsHelper, feature_category: :code_review_workflow do
end
end
end
+
+ describe '#merge_request_source_branch' do
+ let_it_be(:project) { create(:project) }
+ let(:forked_project) { fork_project(project) }
+ let(:merge_request_forked) { create(:merge_request, source_project: forked_project, target_project: project) }
+ let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+
+ context 'when merge request is a fork' do
+ subject { merge_request_source_branch(merge_request_forked) }
+
+ it 'does show the fork icon' do
+ expect(subject).to match(/fork/)
+ end
+ end
+
+ context 'when merge request is not a fork' do
+ subject { merge_request_source_branch(merge_request) }
+
+ it 'does not show the fork icon' do
+ expect(subject).not_to match(/fork/)
+ end
+ end
+ end
+
+ describe '#tab_count_display' do
+ let(:merge_request) { create(:merge_request) }
+
+ context 'when merge request is preparing' do
+ before do
+ allow(merge_request).to receive(:preparing?).and_return(true)
+ end
+
+ it { expect(tab_count_display(merge_request, 0)).to eq('-') }
+ it { expect(tab_count_display(merge_request, '0')).to eq('-') }
+ end
+
+ context 'when merge request is prepared' do
+ it { expect(tab_count_display(merge_request, 10)).to eq(10) }
+ it { expect(tab_count_display(merge_request, '10')).to eq('10') }
+ end
+ end
end
diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb
index 3e6780d6831..e7c8e40da7f 100644
--- a/spec/helpers/namespaces_helper_spec.rb
+++ b/spec/helpers/namespaces_helper_spec.rb
@@ -2,42 +2,39 @@
require 'spec_helper'
-RSpec.describe NamespacesHelper do
+RSpec.describe NamespacesHelper, feature_category: :subgroups do
let!(:admin) { create(:admin) }
let!(:admin_project_creation_level) { nil }
let!(:admin_group) do
- create(:group,
- :private,
- project_creation_level: admin_project_creation_level)
+ create(:group, :private, project_creation_level: admin_project_creation_level)
end
let!(:user) { create(:user) }
let!(:user_project_creation_level) { nil }
let!(:user_group) do
- create(:group,
- :private,
- project_creation_level: user_project_creation_level)
+ create(:group, :private, project_creation_level: user_project_creation_level)
end
let!(:subgroup1) do
- create(:group,
- :private,
- parent: admin_group,
- project_creation_level: nil)
+ create(:group, :private, parent: admin_group, project_creation_level: nil)
end
let!(:subgroup2) do
- create(:group,
- :private,
- parent: admin_group,
- project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS)
+ create(
+ :group,
+ :private,
+ parent: admin_group,
+ project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS
+ )
end
let!(:subgroup3) do
- create(:group,
- :private,
- parent: admin_group,
- project_creation_level: ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS)
+ create(
+ :group,
+ :private,
+ parent: admin_group,
+ project_creation_level: ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS
+ )
end
before do
@@ -124,7 +121,7 @@ RSpec.describe NamespacesHelper do
end
end
- describe '#pipeline_usage_app_data' do
+ describe '#pipeline_usage_app_data', unless: Gitlab.ee?, feature_category: :consumables_cost_management do
it 'returns a hash with necessary data for the frontend' do
expect(helper.pipeline_usage_app_data(user_group)).to eql({
namespace_actual_plan_name: user_group.actual_plan_name,
diff --git a/spec/helpers/nav/new_dropdown_helper_spec.rb b/spec/helpers/nav/new_dropdown_helper_spec.rb
index 3a66fe474ab..5ae057dc97d 100644
--- a/spec/helpers/nav/new_dropdown_helper_spec.rb
+++ b/spec/helpers/nav/new_dropdown_helper_spec.rb
@@ -11,8 +11,11 @@ RSpec.describe Nav::NewDropdownHelper, feature_category: :navigation do
let(:with_can_create_project) { false }
let(:with_can_create_group) { false }
let(:with_can_create_snippet) { false }
+ let(:title) { 'Create new...' }
- subject(:view_model) { helper.new_dropdown_view_model(project: current_project, group: current_group) }
+ subject(:view_model) do
+ helper.new_dropdown_view_model(project: current_project, group: current_group)
+ end
before do
allow(helper).to receive(:current_user) { current_user }
@@ -22,7 +25,7 @@ RSpec.describe Nav::NewDropdownHelper, feature_category: :navigation do
allow(user).to receive(:can?).with(:create_snippet) { with_can_create_snippet }
end
- shared_examples 'invite member item' do
+ shared_examples 'invite member item' do |partial|
it 'shows invite member link with emoji' do
expect(view_model[:menu_sections]).to eq(
expected_menu_section(
@@ -30,12 +33,12 @@ RSpec.describe Nav::NewDropdownHelper, feature_category: :navigation do
menu_item: ::Gitlab::Nav::TopNavMenuItem.build(
id: 'invite',
title: 'Invite members',
- emoji: 'shaking_hands',
- href: expected_href,
+ icon: 'shaking_hands',
+ partial: partial,
+ component: 'invite_members',
data: {
- track_action: 'click_link_invite_members',
- track_label: 'plus_menu_dropdown',
- track_property: 'navigation_top'
+ trigger_source: 'top-nav',
+ trigger_element: 'text-emoji'
}
)
)
@@ -54,8 +57,13 @@ RSpec.describe Nav::NewDropdownHelper, feature_category: :navigation do
end
context 'when group and project are nil' do
- it 'has no menu sections' do
- expect(view_model[:menu_sections]).to eq([])
+ it 'has base results' do
+ results = {
+ title: title,
+ menu_sections: []
+ }
+
+ expect(view_model).to eq(results)
end
context 'when can create project' do
@@ -145,8 +153,13 @@ RSpec.describe Nav::NewDropdownHelper, feature_category: :navigation do
.to receive(:can?).with(current_user, :admin_group_member, group) { with_can_admin_in_group }
end
- it 'has no menu sections' do
- expect(view_model[:menu_sections]).to eq([])
+ it 'has base results' do
+ results = {
+ title: title,
+ menu_sections: []
+ }
+
+ expect(view_model).to eq(results)
end
context 'when can create projects in group' do
@@ -199,7 +212,7 @@ RSpec.describe Nav::NewDropdownHelper, feature_category: :navigation do
let(:expected_title) { 'In this group' }
let(:expected_href) { "/groups/#{group.full_path}/-/group_members" }
- it_behaves_like 'invite member item'
+ it_behaves_like 'invite member item', 'groups/invite_members_top_nav_link'
end
end
@@ -219,8 +232,13 @@ RSpec.describe Nav::NewDropdownHelper, feature_category: :navigation do
allow(helper).to receive(:can_admin_project_member?) { with_can_admin_project_member }
end
- it 'has no menu sections' do
- expect(view_model[:menu_sections]).to eq([])
+ it 'has base results' do
+ results = {
+ title: title,
+ menu_sections: []
+ }
+
+ expect(view_model).to eq(results)
end
context 'with show_new_issue_link?' do
@@ -296,7 +314,7 @@ RSpec.describe Nav::NewDropdownHelper, feature_category: :navigation do
let(:expected_title) { 'In this project' }
let(:expected_href) { "/#{project.path_with_namespace}/-/project_members" }
- it_behaves_like 'invite member item'
+ it_behaves_like 'invite member item', 'projects/invite_members_top_nav_link'
end
end
@@ -311,22 +329,27 @@ RSpec.describe Nav::NewDropdownHelper, feature_category: :navigation do
allow(helper).to receive(:can?).with(current_user, :create_projects, group).and_return(true)
end
- it 'gives precedence to group over project' do
- group_section = expected_menu_section(
- title: 'In this group',
+ it 'gives precedence to project over group' do
+ project_section = expected_menu_section(
+ title: 'In this project',
menu_item: ::Gitlab::Nav::TopNavMenuItem.build(
- id: 'new_project',
- title: 'New project/repository',
- href: "/projects/new?namespace_id=#{group.id}",
+ id: 'new_issue',
+ title: 'New issue',
+ href: "/#{project.path_with_namespace}/-/issues/new",
data: {
- track_action: 'click_link_new_project_group',
+ track_action: 'click_link_new_issue',
track_label: 'plus_menu_dropdown',
- track_property: 'navigation_top'
+ track_property: 'navigation_top',
+ qa_selector: 'new_issue_link'
}
)
)
+ results = {
+ title: title,
+ menu_sections: project_section
+ }
- expect(view_model[:menu_sections]).to eq(group_section)
+ expect(view_model).to eq(results)
end
end
diff --git a/spec/helpers/nav/top_nav_helper_spec.rb b/spec/helpers/nav/top_nav_helper_spec.rb
index ce5ac2e5404..252423aa988 100644
--- a/spec/helpers/nav/top_nav_helper_spec.rb
+++ b/spec/helpers/nav/top_nav_helper_spec.rb
@@ -56,6 +56,7 @@ RSpec.describe Nav::TopNavHelper do
expected_primary = [
{ href: '/explore', icon: 'project', id: 'project', title: 'Projects' },
{ href: '/explore/groups', icon: 'group', id: 'groups', title: 'Groups' },
+ { href: '/explore/projects/topics', icon: 'labels', id: 'topics', title: 'Topics' },
{ href: '/explore/snippets', icon: 'snippet', id: 'snippets', title: 'Snippets' }
].map do |item|
::Gitlab::Nav::TopNavMenuItem.build(**item)
@@ -79,6 +80,12 @@ RSpec.describe Nav::TopNavHelper do
css_class: 'dashboard-shortcuts-groups'
},
{
+ href: '/explore/projects/topics',
+ id: 'topics-shortcut',
+ title: 'Topics',
+ css_class: 'dashboard-shortcuts-topics'
+ },
+ {
href: '/explore/snippets',
id: 'snippets-shortcut',
title: 'Snippets',
@@ -320,20 +327,6 @@ RSpec.describe Nav::TopNavHelper do
context 'with milestones' do
let(:with_milestones) { true }
- it 'has expected :primary' do
- expected_header = ::Gitlab::Nav::TopNavMenuHeader.build(
- title: 'Explore'
- )
- expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
- data: { **menu_data_tracking_attrs('milestones') },
- href: '/dashboard/milestones',
- icon: 'clock',
- id: 'milestones',
- title: 'Milestones'
- )
- expect(subject[:primary]).to eq([expected_header, expected_primary])
- end
-
it 'has expected :shortcuts' do
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'milestones-shortcut',
@@ -348,23 +341,6 @@ RSpec.describe Nav::TopNavHelper do
context 'with snippets' do
let(:with_snippets) { true }
- it 'has expected :primary' do
- expected_header = ::Gitlab::Nav::TopNavMenuHeader.build(
- title: 'Explore'
- )
- expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
- data: {
- qa_selector: 'snippets_link',
- **menu_data_tracking_attrs('snippets')
- },
- href: '/dashboard/snippets',
- icon: 'snippet',
- id: 'snippets',
- title: 'Snippets'
- )
- expect(subject[:primary]).to eq([expected_header, expected_primary])
- end
-
it 'has expected :shortcuts' do
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'snippets-shortcut',
@@ -379,20 +355,6 @@ RSpec.describe Nav::TopNavHelper do
context 'with activity' do
let(:with_activity) { true }
- it 'has expected :primary' do
- expected_header = ::Gitlab::Nav::TopNavMenuHeader.build(
- title: 'Explore'
- )
- expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
- data: { **menu_data_tracking_attrs('activity') },
- href: '/dashboard/activity',
- icon: 'history',
- id: 'activity',
- title: 'Activity'
- )
- expect(subject[:primary]).to eq([expected_header, expected_primary])
- end
-
it 'has expected :shortcuts' do
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'activity-shortcut',
@@ -431,7 +393,7 @@ RSpec.describe Nav::TopNavHelper do
it 'has leave_admin_mode as last :secondary item' do
expected_leave_admin_mode_item = ::Gitlab::Nav::TopNavMenuItem.build(
id: 'leave_admin_mode',
- title: 'Leave Admin Mode',
+ title: 'Leave admin mode',
icon: 'lock-open',
href: '/admin/session/destroy',
data: { method: 'post', **menu_data_tracking_attrs('leave_admin_mode') }
@@ -447,11 +409,11 @@ RSpec.describe Nav::TopNavHelper do
expected_enter_admin_mode_item = ::Gitlab::Nav::TopNavMenuItem.build(
data: {
qa_selector: 'menu_item_link',
- qa_title: 'Enter Admin Mode',
+ qa_title: 'Enter admin mode',
**menu_data_tracking_attrs('enter_admin_mode')
},
id: 'enter_admin_mode',
- title: 'Enter Admin Mode',
+ title: 'Enter admin mode',
icon: 'lock',
href: '/admin/session/new'
)
diff --git a/spec/helpers/nav_helper_spec.rb b/spec/helpers/nav_helper_spec.rb
index adf784360c2..17d28b07763 100644
--- a/spec/helpers/nav_helper_spec.rb
+++ b/spec/helpers/nav_helper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe NavHelper do
+RSpec.describe NavHelper, feature_category: :navigation do
describe '#header_links' do
include_context 'custom session'
@@ -136,23 +136,8 @@ RSpec.describe NavHelper do
end
describe '#show_super_sidebar?' do
- shared_examples '#show_super_sidebar returns false' do
- it 'returns false' do
- expect(helper.show_super_sidebar?).to eq(false)
- end
- end
-
- it 'returns false by default' do
- allow(helper).to receive(:current_user).and_return(nil)
-
- expect(helper.show_super_sidebar?).to be_falsy
- end
-
- context 'when used is signed-in' do
- let_it_be(:user) { create(:user) }
-
+ shared_examples 'show_super_sidebar is supposed to' do
before do
- allow(helper).to receive(:current_user).and_return(user)
stub_feature_flags(super_sidebar_nav: new_nav_ff)
user.update!(use_new_navigation: user_preference)
end
@@ -163,33 +148,78 @@ RSpec.describe NavHelper do
context 'when user has new nav disabled' do
let(:user_preference) { false }
- it_behaves_like '#show_super_sidebar returns false'
+ specify { expect(subject).to eq false }
end
context 'when user has new nav enabled' do
let(:user_preference) { true }
- it_behaves_like '#show_super_sidebar returns false'
+ specify { expect(subject).to eq false }
end
end
context 'with feature flag on' do
let(:new_nav_ff) { true }
+ context 'when user has not interacted with the new nav toggle yet' do
+ let(:user_preference) { nil }
+
+ specify { expect(subject).to eq false }
+
+ context 'when the user was enrolled into the new nav via a special feature flag' do
+ before do
+ # this ff is disabled in globally to keep tests of the old nav working
+ stub_feature_flags(super_sidebar_nav_enrolled: true)
+ end
+
+ specify { expect(subject).to eq true }
+ end
+ end
+
context 'when user has new nav disabled' do
let(:user_preference) { false }
- it_behaves_like '#show_super_sidebar returns false'
+ specify { expect(subject).to eq false }
end
context 'when user has new nav enabled' do
let(:user_preference) { true }
- it 'returns true' do
- expect(helper.show_super_sidebar?).to eq(true)
- end
+ specify { expect(subject).to eq true }
end
end
end
+
+ context 'when nil is provided' do
+ specify { expect(helper.show_super_sidebar?(nil)).to eq false }
+ end
+
+ context 'when no user is signed-in' do
+ specify do
+ allow(helper).to receive(:current_user).and_return(nil)
+
+ expect(helper.show_super_sidebar?).to eq false
+ end
+ end
+
+ context 'when user is signed-in' do
+ let_it_be(:user) { create(:user) }
+
+ context 'with current_user as a default' do
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ subject { helper.show_super_sidebar? }
+
+ it_behaves_like 'show_super_sidebar is supposed to'
+ end
+
+ context 'with user provided as an argument' do
+ subject { helper.show_super_sidebar?(user) }
+
+ it_behaves_like 'show_super_sidebar is supposed to'
+ end
+ end
end
end
diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb
index 68a6b6293c8..91635ffcdc0 100644
--- a/spec/helpers/notes_helper_spec.rb
+++ b/spec/helpers/notes_helper_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe NotesHelper do
+RSpec.describe NotesHelper, feature_category: :team_planning do
include RepoHelpers
let_it_be(:owner) { create(:owner) }
@@ -223,6 +223,17 @@ RSpec.describe NotesHelper do
end
end
+ describe '#initial_notes_data' do
+ it 'return initial notes data for issuable' do
+ autocomplete = '/autocomplete/users'
+ @project = project
+ @noteable = create(:issue, project: @project)
+
+ expect(helper.initial_notes_data(autocomplete).keys).to match_array(%i[notesUrl now diffView enableGFM])
+ expect(helper.initial_notes_data(autocomplete)[:enableGFM].keys).to match(%i[emojis members issues mergeRequests vulnerabilities epics milestones labels])
+ end
+ end
+
describe '#notes_url' do
it 'return snippet notes path for personal snippet' do
@snippet = create(:personal_snippet)
diff --git a/spec/helpers/notify_helper_spec.rb b/spec/helpers/notify_helper_spec.rb
index 09da2b89dff..bc1b927cc93 100644
--- a/spec/helpers/notify_helper_spec.rb
+++ b/spec/helpers/notify_helper_spec.rb
@@ -64,10 +64,19 @@ RSpec.describe NotifyHelper do
mr_link_style = "font-weight: 600;color:#3777b0;text-decoration:none"
reviewer_avatar_style = "border-radius:12px;margin:-7px 0 -7px 3px;"
mr_link = link_to(merge_request.to_reference, merge_request_url(merge_request), style: mr_link_style).html_safe
- reviewer_avatar = content_tag(:img, nil, height: "24", src: avatar_icon_for_user, style: reviewer_avatar_style, \
- width: "24", alt: "Avatar", class: "avatar").html_safe
- reviewer_link = link_to(reviewer.name, user_url(reviewer), style: "color:#333333;text-decoration:none;", \
- class: "muted").html_safe
+ reviewer_avatar = content_tag(
+ :img,
+ nil,
+ height: "24",
+ src: avatar_icon_for_user,
+ style: reviewer_avatar_style,
+ width: "24",
+ alt: "Avatar",
+ class: "avatar"
+ ).html_safe
+ reviewer_link = link_to(
+ reviewer.name, user_url(reviewer), style: "color:#333333;text-decoration:none;", class: "muted"
+ ).html_safe
result = helper.merge_request_hash_param(merge_request, reviewer)
expect(result[:mr_highlight]).to eq '<span style="font-weight: 600;color:#333333;">'.html_safe
expect(result[:highlight_end]).to eq '</span>'.html_safe
diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb
index fc69aee4e04..ae8a7f0c14c 100644
--- a/spec/helpers/packages_helper_spec.rb
+++ b/spec/helpers/packages_helper_spec.rb
@@ -2,8 +2,9 @@
require 'spec_helper'
-RSpec.describe PackagesHelper do
+RSpec.describe PackagesHelper, feature_category: :package_registry do
using RSpec::Parameterized::TableSyntax
+ include AdminModeHelper
let_it_be_with_reload(:project) { create(:project) }
let_it_be(:base_url) { "#{Gitlab.config.gitlab.url}/api/v4/" }
@@ -38,11 +39,18 @@ RSpec.describe PackagesHelper do
describe '#pypi_registry_url' do
let_it_be(:base_url_with_token) { base_url.sub('://', '://__token__:<your_personal_token>@') }
+ let_it_be(:public_project) { create(:project, :public) }
- it 'returns the pypi registry url' do
- url = helper.pypi_registry_url(1)
+ it 'returns the pypi registry url with token when project is private' do
+ url = helper.pypi_registry_url(project)
- expect(url).to eq("#{base_url_with_token}projects/1/packages/pypi/simple")
+ expect(url).to eq("#{base_url_with_token}projects/#{project.id}/packages/pypi/simple")
+ end
+
+ it 'returns the pypi registry url without token when project is public' do
+ url = helper.pypi_registry_url(public_project)
+
+ expect(url).to eq("#{base_url}projects/#{public_project.id}/packages/pypi/simple")
end
end
@@ -120,4 +128,128 @@ RSpec.describe PackagesHelper do
it { is_expected.to eq(expected_result) }
end
end
+
+ describe '#show_container_registry_settings' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+
+ before do
+ allow(helper).to receive(:current_user) { user }
+ end
+
+ subject { helper.show_container_registry_settings(project) }
+
+ context 'with container registry config enabled' do
+ before do
+ stub_config(registry: { enabled: true })
+ end
+
+ context 'when user has permission' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :admin_container_image, project).and_return(true)
+ end
+
+ it { is_expected.to be(true) }
+ end
+
+ context 'when user does not have permission' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :admin_container_image, project).and_return(false)
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
+
+ context 'with container registry config disabled' do
+ before do
+ stub_config(registry: { enabled: false })
+ end
+
+ context 'when user has permission' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :admin_container_image, project).and_return(true)
+ end
+
+ it { is_expected.to be(false) }
+ end
+
+ context 'when user does not have permission' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :admin_container_image, project).and_return(false)
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
+ end
+
+ describe '#show_group_package_registry_settings' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+
+ before do
+ allow(helper).to receive(:current_user) { user }
+ end
+
+ subject { helper.show_group_package_registry_settings(group) }
+
+ context 'with package registry config enabled' do
+ before do
+ stub_config(packages: { enabled: true })
+ end
+
+ context "with admin", :enable_admin_mode do
+ before do
+ allow(helper).to receive(:current_user) { admin }
+ end
+
+ it { is_expected.to be(true) }
+ end
+
+ context "with owner" do
+ before do
+ group.add_owner(user)
+ end
+
+ it { is_expected.to be(true) }
+ end
+
+ %i[maintainer developer reporter guest].each do |role|
+ context "with #{role}" do
+ before do
+ group.public_send("add_#{role}", user)
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
+ end
+
+ context 'with package registry config disabled' do
+ before do
+ stub_config(packages: { enabled: false })
+ end
+
+ context "with admin", :enable_admin_mode do
+ before do
+ allow(helper).to receive(:current_user) { admin }
+ end
+
+ it { is_expected.to be(false) }
+ end
+
+ %i[owner maintainer developer reporter guest].each do |role|
+ context "with #{role}" do
+ before do
+ group.public_send("add_#{role}", user)
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
+ end
+ end
end
diff --git a/spec/helpers/page_layout_helper_spec.rb b/spec/helpers/page_layout_helper_spec.rb
index eb42ce18da0..b14789fd5d2 100644
--- a/spec/helpers/page_layout_helper_spec.rb
+++ b/spec/helpers/page_layout_helper_spec.rb
@@ -56,11 +56,12 @@ RSpec.describe PageLayoutHelper do
end
%w(project user group).each do |type|
- let(:object) { build(type, trait) }
- let(:trait) { :with_avatar }
-
context "with @#{type} assigned" do
+ let(:object) { build(type, trait) }
+ let(:trait) { :with_avatar }
+
before do
+ stub_application_setting(gravatar_enabled: false)
assign(type, object)
end
@@ -128,12 +129,14 @@ RSpec.describe PageLayoutHelper do
describe 'a bare controller' do
it 'returns an empty context' do
- expect(search_context).to have_attributes(project: nil,
- group: nil,
- snippets: [],
- project_metadata: {},
- group_metadata: {},
- search_url: '/search')
+ expect(search_context).to have_attributes(
+ project: nil,
+ group: nil,
+ snippets: [],
+ project_metadata: {},
+ group_metadata: {},
+ search_url: '/search'
+ )
end
end
end
diff --git a/spec/helpers/plan_limits_helper_spec.rb b/spec/helpers/plan_limits_helper_spec.rb
new file mode 100644
index 00000000000..b25e97150f8
--- /dev/null
+++ b/spec/helpers/plan_limits_helper_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PlanLimitsHelper, feature_category: :continuous_integration do
+ describe '#plan_limit_setting_description' do
+ it 'describes known limits', :aggregate_failures do
+ [
+ :ci_pipeline_size,
+ :ci_active_jobs,
+ :ci_project_subscriptions,
+ :ci_pipeline_schedules,
+ :ci_needs_size_limit,
+ :ci_registered_group_runners,
+ :ci_registered_project_runners,
+ :pipeline_hierarchy_size
+ ].each do |limit_name|
+ expect(helper.plan_limit_setting_description(limit_name)).to be_present
+ end
+ end
+
+ it 'raises an ArgumentError on invalid arguments' do
+ expect { helper.plan_limit_setting_description(:some_invalid_limit) }.to(
+ raise_error(ArgumentError, /No description/)
+ )
+ end
+ end
+end
diff --git a/spec/helpers/profiles_helper_spec.rb b/spec/helpers/profiles_helper_spec.rb
index 7de8ca89d3d..ebe86ccb08d 100644
--- a/spec/helpers/profiles_helper_spec.rb
+++ b/spec/helpers/profiles_helper_spec.rb
@@ -33,28 +33,28 @@ RSpec.describe ProfilesHelper do
end
it "returns omniauth provider label for users with external attributes" do
- stub_omniauth_setting(sync_profile_from_provider: ['cas3'])
+ stub_omniauth_setting(sync_profile_from_provider: [example_omniauth_provider])
stub_omniauth_setting(sync_profile_attributes: true)
- stub_cas_omniauth_provider
- cas_user = create(:omniauth_user, provider: 'cas3')
- cas_user.create_user_synced_attributes_metadata(provider: 'cas3', name_synced: true, email_synced: true, location_synced: true)
- allow(helper).to receive(:current_user).and_return(cas_user)
-
- expect(helper.attribute_provider_label(:email)).to eq('CAS')
- expect(helper.attribute_provider_label(:name)).to eq('CAS')
- expect(helper.attribute_provider_label(:location)).to eq('CAS')
+ stub_auth0_omniauth_provider
+ auth0_user = create(:omniauth_user, provider: example_omniauth_provider)
+ auth0_user.create_user_synced_attributes_metadata(provider: example_omniauth_provider, name_synced: true, email_synced: true, location_synced: true)
+ allow(helper).to receive(:current_user).and_return(auth0_user)
+
+ expect(helper.attribute_provider_label(:email)).to eq(example_omniauth_provider_label)
+ expect(helper.attribute_provider_label(:name)).to eq(example_omniauth_provider_label)
+ expect(helper.attribute_provider_label(:location)).to eq(example_omniauth_provider_label)
end
it "returns the correct omniauth provider label for users with some external attributes" do
- stub_omniauth_setting(sync_profile_from_provider: ['cas3'])
+ stub_omniauth_setting(sync_profile_from_provider: [example_omniauth_provider])
stub_omniauth_setting(sync_profile_attributes: true)
- stub_cas_omniauth_provider
- cas_user = create(:omniauth_user, provider: 'cas3')
- cas_user.create_user_synced_attributes_metadata(provider: 'cas3', name_synced: false, email_synced: true, location_synced: false)
- allow(helper).to receive(:current_user).and_return(cas_user)
+ stub_auth0_omniauth_provider
+ auth0_user = create(:omniauth_user, provider: example_omniauth_provider)
+ auth0_user.create_user_synced_attributes_metadata(provider: example_omniauth_provider, name_synced: false, email_synced: true, location_synced: false)
+ allow(helper).to receive(:current_user).and_return(auth0_user)
expect(helper.attribute_provider_label(:name)).to be_nil
- expect(helper.attribute_provider_label(:email)).to eq('CAS')
+ expect(helper.attribute_provider_label(:email)).to eq(example_omniauth_provider_label)
expect(helper.attribute_provider_label(:location)).to be_nil
end
@@ -118,12 +118,20 @@ RSpec.describe ProfilesHelper do
end
end
- def stub_cas_omniauth_provider
+ def stub_auth0_omniauth_provider
provider = OpenStruct.new(
- 'name' => 'cas3',
- 'label' => 'CAS'
+ 'name' => example_omniauth_provider,
+ 'label' => example_omniauth_provider_label
)
stub_omniauth_setting(providers: [provider])
end
+
+ def example_omniauth_provider
+ "auth0"
+ end
+
+ def example_omniauth_provider_label
+ "Auth0"
+ end
end
diff --git a/spec/helpers/projects/ml/experiments_helper_spec.rb b/spec/helpers/projects/ml/experiments_helper_spec.rb
index 8ef81c49fa7..021d518a329 100644
--- a/spec/helpers/projects/ml/experiments_helper_spec.rb
+++ b/spec/helpers/projects/ml/experiments_helper_spec.rb
@@ -8,8 +8,16 @@ require 'mime/types'
RSpec.describe Projects::Ml::ExperimentsHelper, feature_category: :mlops do
let_it_be(:project) { create(:project, :private) }
let_it_be(:experiment) { create(:ml_experiments, user_id: project.creator, project: project) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be(:build) { create(:ci_build, pipeline: pipeline) }
let_it_be(:candidate0) do
- create(:ml_candidates, :with_artifact, experiment: experiment, user: project.creator).tap do |c|
+ create(:ml_candidates,
+ :with_artifact,
+ experiment: experiment,
+ user: project.creator,
+ project: project,
+ ci_build: build
+ ).tap do |c|
c.params.build([{ name: 'param1', value: 'p1' }, { name: 'param2', value: 'p2' }])
c.metrics.create!(
[{ name: 'metric1', value: 0.1 }, { name: 'metric2', value: 0.2 }, { name: 'metric3', value: 0.3 }]
@@ -18,7 +26,8 @@ RSpec.describe Projects::Ml::ExperimentsHelper, feature_category: :mlops do
end
let_it_be(:candidate1) do
- create(:ml_candidates, experiment: experiment, user: project.creator, name: 'candidate1').tap do |c|
+ create(:ml_candidates, experiment: experiment, user: project.creator, name: 'candidate1',
+ project: project).tap do |c|
c.params.build([{ name: 'param2', value: 'p3' }, { name: 'param3', value: 'p4' }])
c.metrics.create!(name: 'metric3', value: 0.4)
end
@@ -34,11 +43,13 @@ RSpec.describe Projects::Ml::ExperimentsHelper, feature_category: :mlops do
{ 'param1' => 'p1', 'param2' => 'p2', 'metric1' => '0.1000', 'metric2' => '0.2000', 'metric3' => '0.3000',
'artifact' => "/#{project.full_path}/-/packages/#{candidate0.artifact.id}",
'details' => "/#{project.full_path}/-/ml/candidates/#{candidate0.iid}",
+ 'ci_job' => { 'path' => "/#{project.full_path}/-/jobs/#{build.id}", 'name' => 'test' },
'name' => candidate0.name,
'created_at' => candidate0.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
'user' => { 'username' => candidate0.user.username, 'path' => "/#{candidate0.user.username}" } },
{ 'param2' => 'p3', 'param3' => 'p4', 'metric3' => '0.4000',
'artifact' => nil, 'details' => "/#{project.full_path}/-/ml/candidates/#{candidate1.iid}",
+ 'ci_job' => nil,
'name' => candidate1.name,
'created_at' => candidate1.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
'user' => { 'username' => candidate1.user.username, 'path' => "/#{candidate1.user.username}" } }
@@ -77,37 +88,14 @@ RSpec.describe Projects::Ml::ExperimentsHelper, feature_category: :mlops do
end
end
- describe '#show_candidate_view_model' do
- let(:candidate) { candidate0 }
+ describe '#experiment_as_data' do
+ subject { Gitlab::Json.parse(helper.experiment_as_data(experiment)) }
- subject { Gitlab::Json.parse(helper.show_candidate_view_model(candidate))['candidate'] }
-
- it 'generates the correct params' do
- expect(subject['params']).to include(
- hash_including('name' => 'param1', 'value' => 'p1'),
- hash_including('name' => 'param2', 'value' => 'p2')
+ it do
+ is_expected.to eq(
+ { 'name' => experiment.name, 'path' => "/#{project.full_path}/-/ml/experiments/#{experiment.iid}" }
)
end
-
- it 'generates the correct metrics' do
- expect(subject['metrics']).to include(
- hash_including('name' => 'metric1', 'value' => 0.1),
- hash_including('name' => 'metric2', 'value' => 0.2),
- hash_including('name' => 'metric3', 'value' => 0.3)
- )
- end
-
- it 'generates the correct info' do
- expected_info = {
- 'iid' => candidate.iid,
- 'path_to_artifact' => "/#{project.full_path}/-/packages/#{candidate.artifact.id}",
- 'experiment_name' => candidate.experiment.name,
- 'path_to_experiment' => "/#{project.full_path}/-/ml/experiments/#{experiment.iid}",
- 'status' => 'running'
- }
-
- expect(subject['info']).to include(expected_info)
- end
end
describe '#experiments_as_data' do
diff --git a/spec/helpers/projects/pipeline_helper_spec.rb b/spec/helpers/projects/pipeline_helper_spec.rb
index 35045aaef2a..baebbb21aed 100644
--- a/spec/helpers/projects/pipeline_helper_spec.rb
+++ b/spec/helpers/projects/pipeline_helper_spec.rb
@@ -20,8 +20,7 @@ RSpec.describe Projects::PipelineHelper do
it 'returns pipeline tabs data' do
expect(pipeline_tabs_data).to include({
failed_jobs_count: pipeline.failed_builds.count,
- failed_jobs_summary: prepare_failed_jobs_summary_data(pipeline.failed_builds),
- full_path: project.full_path,
+ project_path: project.full_path,
graphql_resource_etag: graphql_etag_pipeline_path(pipeline),
metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json),
pipeline_iid: pipeline.iid,
diff --git a/spec/helpers/projects/settings/branch_rules_helper_spec.rb b/spec/helpers/projects/settings/branch_rules_helper_spec.rb
new file mode 100644
index 00000000000..35a21f72f11
--- /dev/null
+++ b/spec/helpers/projects/settings/branch_rules_helper_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Settings::BranchRulesHelper, feature_category: :source_code_management do
+ let_it_be(:project) { build_stubbed(:project) }
+
+ describe '#branch_rules_data' do
+ subject(:data) { helper.branch_rules_data(project) }
+
+ it 'returns branch rules data' do
+ expect(data).to match({
+ project_path: project.full_path,
+ protected_branches_path: project_settings_repository_path(project, anchor: 'js-protected-branches-settings'),
+ approval_rules_path: project_settings_merge_requests_path(project,
+ anchor: 'js-merge-request-approval-settings'),
+ status_checks_path: project_settings_merge_requests_path(project, anchor: 'js-merge-request-settings'),
+ branches_path: project_branches_path(project),
+ show_status_checks: 'false',
+ show_approvers: 'false',
+ show_code_owners: 'false'
+ })
+ end
+ end
+end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 477b5cd7753..3eb1090c9dc 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe ProjectsHelper, feature_category: :source_code_management do
include ProjectForksHelper
include AfterNextHelpers
- let_it_be_with_reload(:project) { create(:project) }
+ let_it_be_with_reload(:project) { create(:project, :repository) }
let_it_be_with_refind(:project_with_repo) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
@@ -212,6 +212,80 @@ RSpec.describe ProjectsHelper, feature_category: :source_code_management do
end
end
+ describe '#last_pipeline_from_status_cache' do
+ before do
+ # clear cross-example caches
+ project_with_repo.pipeline_status.delete_from_cache
+ project_with_repo.instance_variable_set(:@pipeline_status, nil)
+ end
+
+ context 'without a pipeline' do
+ it 'returns nil', :aggregate_failures do
+ expect(::Gitlab::GitalyClient).to receive(:call).at_least(:once).and_call_original
+ actual_pipeline = last_pipeline_from_status_cache(project_with_repo)
+ expect(actual_pipeline).to be_nil
+ end
+
+ context 'when pipeline_status is loaded' do
+ before do
+ project_with_repo.pipeline_status # this loads the status
+ end
+
+ it 'returns nil without calling gitaly when there is no pipeline', :aggregate_failures do
+ expect(::Gitlab::GitalyClient).not_to receive(:call)
+ actual_pipeline = last_pipeline_from_status_cache(project_with_repo)
+ expect(actual_pipeline).to be_nil
+ end
+ end
+
+ context 'when FF load_last_pipeline_from_pipeline_status is disabled' do
+ before do
+ stub_feature_flags(last_pipeline_from_pipeline_status: false)
+ end
+
+ it 'returns nil', :aggregate_failures do
+ expect(project_with_repo).not_to receive(:pipeline_status)
+ actual_pipeline = last_pipeline_from_status_cache(project_with_repo)
+ expect(actual_pipeline).to be_nil
+ end
+ end
+ end
+
+ context 'with a pipeline' do
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project_with_repo) }
+
+ it 'returns the latest pipeline', :aggregate_failures do
+ expect(::Gitlab::GitalyClient).to receive(:call).at_least(:once).and_call_original
+ actual_pipeline = last_pipeline_from_status_cache(project_with_repo)
+ expect(actual_pipeline).to eq pipeline
+ end
+
+ context 'when pipeline_status is loaded' do
+ before do
+ project_with_repo.pipeline_status # this loads the status
+ end
+
+ it 'returns the latest pipeline without calling gitaly' do
+ expect(::Gitlab::GitalyClient).not_to receive(:call)
+ actual_pipeline = last_pipeline_from_status_cache(project_with_repo)
+ expect(actual_pipeline).to eq pipeline
+ end
+
+ context 'when FF load_last_pipeline_from_pipeline_status is disabled' do
+ before do
+ stub_feature_flags(last_pipeline_from_pipeline_status: false)
+ end
+
+ it 'returns the latest pipeline', :aggregate_failures do
+ expect(project_with_repo).not_to receive(:pipeline_status)
+ actual_pipeline = last_pipeline_from_status_cache(project_with_repo)
+ expect(actual_pipeline).to eq pipeline
+ end
+ end
+ end
+ end
+ end
+
describe '#show_no_ssh_key_message?' do
before do
allow(helper).to receive(:current_user).and_return(user)
@@ -703,6 +777,34 @@ RSpec.describe ProjectsHelper, feature_category: :source_code_management do
end
end
+ describe '#show_mobile_devops_project_promo?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:hide_cookie, :feature_flag_enabled, :mobile_target_platform, :result) do
+ false | true | true | true
+ false | false | true | false
+ false | false | false | false
+ false | true | false | false
+ true | false | false | false
+ true | true | false | false
+ true | true | true | false
+ true | false | true | false
+ end
+
+ with_them do
+ before do
+ allow(Gitlab).to receive(:com?) { gitlab_com }
+ Feature.enable(:mobile_devops_projects_promo, feature_flag_enabled)
+ project.project_setting.target_platforms << 'ios' if mobile_target_platform
+ helper.request.cookies["hide_mobile_devops_promo_#{project.id}"] = true if hide_cookie
+ end
+
+ it 'resolves if the user can import members' do
+ expect(helper.show_mobile_devops_project_promo?(project)).to eq result
+ end
+ end
+ end
+
describe '#can_admin_project_member?' do
context 'when user is project owner' do
before do
@@ -1286,7 +1388,7 @@ RSpec.describe ProjectsHelper, feature_category: :source_code_management do
let_it_be(:has_active_license) { true }
it 'displays the correct messagee' do
- expect(subject).to eq(s_('Clusters|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd} or reach out to GitLab support.'))
+ expect(subject).to eq(s_('Clusters|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}. Contact GitLab Support if you have any additional questions.'))
end
end
@@ -1359,23 +1461,99 @@ RSpec.describe ProjectsHelper, feature_category: :source_code_management do
end
context 'when fork source is available' do
- it 'returns the data related to fork divergence' do
- source_project = project_with_repo
+ let_it_be(:fork_network) { create(:fork_network, root_project: project_with_repo) }
+ let_it_be(:source_project) { project_with_repo }
+
+ before_all do
+ project.fork_network = fork_network
+ project.add_developer(user)
+ source_project.add_developer(user)
+ end
- allow(helper).to receive(:visible_fork_source).with(project).and_return(source_project)
+ it 'returns the data related to fork divergence' do
+ allow(helper).to receive(:current_user).and_return(user)
ahead_path =
"/#{project.full_path}/-/compare/#{source_project.default_branch}...ref?from_project_id=#{source_project.id}"
behind_path =
"/#{source_project.full_path}/-/compare/ref...#{source_project.default_branch}?from_project_id=#{project.id}"
+ create_mr_path = "/#{project.full_path}/-/merge_requests/new?merge_request%5Bsource_branch%5D=ref&merge_request%5Btarget_branch%5D=#{source_project.default_branch}&merge_request%5Btarget_project_id%5D=#{source_project.id}"
expect(helper.vue_fork_divergence_data(project, 'ref')).to eq({
+ project_path: project.full_path,
+ selected_branch: 'ref',
source_name: source_project.full_name,
source_path: project_path(source_project),
+ can_sync_branch: 'false',
ahead_compare_path: ahead_path,
- behind_compare_path: behind_path
+ behind_compare_path: behind_path,
+ source_default_branch: source_project.default_branch,
+ create_mr_path: create_mr_path,
+ view_mr_path: nil
})
end
+
+ it 'returns view_mr_path if a merge request for the branch exists' do
+ allow(helper).to receive(:current_user).and_return(user)
+
+ merge_request =
+ create(:merge_request, source_project: project, target_project: project_with_repo,
+ source_branch: project.default_branch, target_branch: project_with_repo.default_branch)
+
+ expect(helper.vue_fork_divergence_data(project, project.default_branch)).to include({
+ can_sync_branch: 'true',
+ create_mr_path: nil,
+ view_mr_path: "/#{source_project.full_path}/-/merge_requests/#{merge_request.iid}"
+ })
+ end
+
+ context 'when a user cannot create a merge request' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:project_role, :source_project_role) do
+ :guest | :developer
+ :developer | :guest
+ end
+
+ with_them do
+ it 'create_mr_path is nil' do
+ allow(helper).to receive(:current_user).and_return(user)
+
+ project.add_member(user, project_role)
+ source_project.add_member(user, source_project_role)
+
+ expect(helper.vue_fork_divergence_data(project, 'ref')).to include({
+ create_mr_path: nil, view_mr_path: nil
+ })
+ end
+ end
+ end
+ end
+ end
+
+ describe '#remote_mirror_setting_enabled?' do
+ it 'returns false' do
+ expect(helper.remote_mirror_setting_enabled?).to be_falsy
end
end
+
+ describe '#http_clone_url_to_repo' do
+ before do
+ allow(project).to receive(:http_url_to_repo).and_return('http_url_to_repo')
+ end
+
+ subject { helper.http_clone_url_to_repo(project) }
+
+ it { expect(subject).to eq('http_url_to_repo') }
+ end
+
+ describe '#ssh_clone_url_to_repo' do
+ before do
+ allow(project).to receive(:ssh_url_to_repo).and_return('ssh_url_to_repo')
+ end
+
+ subject { helper.ssh_clone_url_to_repo(project) }
+
+ it { expect(subject).to eq('ssh_url_to_repo') }
+ end
end
diff --git a/spec/helpers/protected_refs_helper_spec.rb b/spec/helpers/protected_refs_helper_spec.rb
new file mode 100644
index 00000000000..820da429107
--- /dev/null
+++ b/spec/helpers/protected_refs_helper_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe ProtectedRefsHelper, feature_category: :source_code_management do
+ describe '#protected_access_levels_for_dropdowns' do
+ let(:protected_access_level_dropdown_roles) { :protected_access_level_dropdown_roles }
+
+ before do
+ allow(helper)
+ .to receive(:protected_access_level_dropdown_roles)
+ .and_return(protected_access_level_dropdown_roles)
+ end
+
+ it 'returns roles for {create,push,merge}_access_levels' do
+ expect(helper.protected_access_levels_for_dropdowns).to eq(
+ {
+ create_access_levels: protected_access_level_dropdown_roles,
+ push_access_levels: protected_access_level_dropdown_roles,
+ merge_access_levels: protected_access_level_dropdown_roles
+ }
+ )
+ end
+ end
+
+ describe '#protected_access_level_dropdown_roles' do
+ let(:roles) do
+ [
+ {
+ id: ::Gitlab::Access::DEVELOPER,
+ text: 'Developers + Maintainers',
+ before_divider: true
+ },
+ {
+ id: ::Gitlab::Access::MAINTAINER,
+ text: 'Maintainers',
+ before_divider: true
+ },
+ {
+ id: ::Gitlab::Access::NO_ACCESS,
+ text: 'No one',
+ before_divider: true
+ }
+ ]
+ end
+
+ it 'returns dropdown options for each protected ref access level' do
+ expect(helper.protected_access_level_dropdown_roles[:roles]).to include(*roles)
+ end
+ end
+end
diff --git a/spec/helpers/registrations_helper_spec.rb b/spec/helpers/registrations_helper_spec.rb
index eec87bc8712..b2f9a794cb3 100644
--- a/spec/helpers/registrations_helper_spec.rb
+++ b/spec/helpers/registrations_helper_spec.rb
@@ -8,20 +8,4 @@ RSpec.describe RegistrationsHelper do
expect(helper.signup_username_data_attributes.keys).to include(:min_length, :min_length_message, :max_length, :max_length_message, :qa_selector)
end
end
-
- describe '#arkose_labs_challenge_enabled?' do
- before do
- stub_application_setting(
- arkose_labs_private_api_key: nil,
- arkose_labs_public_api_key: nil,
- arkose_labs_namespace: nil
- )
- stub_env('ARKOSE_LABS_PRIVATE_KEY', nil)
- stub_env('ARKOSE_LABS_PUBLIC_KEY', nil)
- end
-
- it 'is false' do
- expect(helper.arkose_labs_challenge_enabled?).to eq false
- end
- end
end
diff --git a/spec/helpers/routing/pseudonymization_helper_spec.rb b/spec/helpers/routing/pseudonymization_helper_spec.rb
index eb2cb548f35..784579dc895 100644
--- a/spec/helpers/routing/pseudonymization_helper_spec.rb
+++ b/spec/helpers/routing/pseudonymization_helper_spec.rb
@@ -26,17 +26,19 @@ RSpec.describe ::Routing::PseudonymizationHelper do
context 'with controller for MR' do
let(:masked_url) { "http://localhost/namespace#{group.id}/project#{project.id}/-/merge_requests/#{merge_request.id}" }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: "projects/merge_requests",
- action: "show",
- namespace_id: group.name,
- project_id: project.name,
- id: merge_request.id.to_s
- },
- protocol: 'http',
- host: 'localhost',
- query_string: '')
+ double(
+ :Request,
+ path_parameters: {
+ controller: "projects/merge_requests",
+ action: "show",
+ namespace_id: group.name,
+ project_id: project.name,
+ id: merge_request.id.to_s
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: ''
+ )
end
before do
@@ -49,17 +51,19 @@ RSpec.describe ::Routing::PseudonymizationHelper do
context 'with controller for issue' do
let(:masked_url) { "http://localhost/namespace#{group.id}/project#{project.id}/-/issues/#{issue.id}" }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: "projects/issues",
- action: "show",
- namespace_id: group.name,
- project_id: project.name,
- id: issue.id.to_s
- },
- protocol: 'http',
- host: 'localhost',
- query_string: '')
+ double(
+ :Request,
+ path_parameters: {
+ controller: "projects/issues",
+ action: "show",
+ namespace_id: group.name,
+ project_id: project.name,
+ id: issue.id.to_s
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: ''
+ )
end
before do
@@ -74,16 +78,18 @@ RSpec.describe ::Routing::PseudonymizationHelper do
let(:group) { subgroup }
let(:project) { subproject }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: 'projects',
- action: 'show',
- namespace_id: subgroup.name,
- id: subproject.name
- },
- protocol: 'http',
- host: 'localhost',
- query_string: '')
+ double(
+ :Request,
+ path_parameters: {
+ controller: 'projects',
+ action: 'show',
+ namespace_id: subgroup.name,
+ id: subproject.name
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: ''
+ )
end
before do
@@ -97,15 +103,17 @@ RSpec.describe ::Routing::PseudonymizationHelper do
let(:masked_url) { "http://localhost/groups/namespace#{subgroup.id}/-/shared" }
let(:group) { subgroup }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: 'groups',
- action: 'show',
- id: subgroup.name
- },
- protocol: 'http',
- host: 'localhost',
- query_string: '')
+ double(
+ :Request,
+ path_parameters: {
+ controller: 'groups',
+ action: 'show',
+ id: subgroup.name
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: ''
+ )
end
before do
@@ -118,17 +126,19 @@ RSpec.describe ::Routing::PseudonymizationHelper do
context 'with controller for blob with file path' do
let(:masked_url) { "http://localhost/namespace#{group.id}/project#{project.id}/-/blob/:repository_path" }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: 'projects/blob',
- action: 'show',
- namespace_id: group.name,
- project_id: project.name,
- id: 'master/README.md'
- },
- protocol: 'http',
- host: 'localhost',
- query_string: '')
+ double(
+ :Request,
+ path_parameters: {
+ controller: 'projects/blob',
+ action: 'show',
+ namespace_id: group.name,
+ project_id: project.name,
+ id: 'master/README.md'
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: ''
+ )
end
before do
@@ -141,14 +151,16 @@ RSpec.describe ::Routing::PseudonymizationHelper do
context 'when assignee_username is present' do
let(:masked_url) { "http://localhost/dashboard/issues?assignee_username=masked_assignee_username" }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: 'dashboard',
- action: 'issues'
- },
- protocol: 'http',
- host: 'localhost',
- query_string: 'assignee_username=root')
+ double(
+ :Request,
+ path_parameters: {
+ controller: 'dashboard',
+ action: 'issues'
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: 'assignee_username=root'
+ )
end
before do
@@ -161,14 +173,16 @@ RSpec.describe ::Routing::PseudonymizationHelper do
context 'when author_username is present' do
let(:masked_url) { "http://localhost/dashboard/issues?author_username=masked_author_username&scope=all&state=opened" }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: 'dashboard',
- action: 'issues'
- },
- protocol: 'http',
- host: 'localhost',
- query_string: 'author_username=root&scope=all&state=opened')
+ double(
+ :Request,
+ path_parameters: {
+ controller: 'dashboard',
+ action: 'issues'
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: 'author_username=root&scope=all&state=opened'
+ )
end
before do
@@ -181,14 +195,16 @@ RSpec.describe ::Routing::PseudonymizationHelper do
context 'when some query params are not required to be masked' do
let(:masked_url) { "http://localhost/dashboard/issues?author_username=masked_author_username&scope=all&state=masked_state&tab=2" }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: 'dashboard',
- action: 'issues'
- },
- protocol: 'http',
- host: 'localhost',
- query_string: 'author_username=root&scope=all&state=opened&tab=2')
+ double(
+ :Request,
+ path_parameters: {
+ controller: 'dashboard',
+ action: 'issues'
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: 'author_username=root&scope=all&state=opened&tab=2'
+ )
end
before do
@@ -202,14 +218,16 @@ RSpec.describe ::Routing::PseudonymizationHelper do
context 'when query string has keys with the same names as path params' do
let(:masked_url) { "http://localhost/dashboard/issues?action=masked_action&scope=all&state=opened" }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: 'dashboard',
- action: 'issues'
- },
- protocol: 'http',
- host: 'localhost',
- query_string: 'action=foobar&scope=all&state=opened')
+ double(
+ :Request,
+ path_parameters: {
+ controller: 'dashboard',
+ action: 'issues'
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: 'action=foobar&scope=all&state=opened'
+ )
end
before do
@@ -223,16 +241,18 @@ RSpec.describe ::Routing::PseudonymizationHelper do
describe 'when url has no params to mask' do
let(:original_url) { 'http://localhost/-/security/vulnerabilities' }
let(:request) do
- double(:Request,
- path_parameters: {
- controller: 'security/vulnerabilities',
- action: 'index'
- },
- protocol: 'http',
- host: 'localhost',
- query_string: '',
- original_fullpath: '/-/security/vulnerabilities',
- original_url: original_url)
+ double(
+ :Request,
+ path_parameters: {
+ controller: 'security/vulnerabilities',
+ action: 'index'
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: '',
+ original_fullpath: '/-/security/vulnerabilities',
+ original_url: original_url
+ )
end
before do
@@ -247,15 +267,17 @@ RSpec.describe ::Routing::PseudonymizationHelper do
describe 'when it raises exception' do
context 'calls error tracking' do
let(:request) do
- double(:Request,
- path_parameters: {
- controller: 'dashboard',
- action: 'issues'
- },
- protocol: 'http',
- host: 'localhost',
- query_string: 'assignee_username=root',
- original_fullpath: '/dashboard/issues?assignee_username=root')
+ double(
+ :Request,
+ path_parameters: {
+ controller: 'dashboard',
+ action: 'issues'
+ },
+ protocol: 'http',
+ host: 'localhost',
+ query_string: 'assignee_username=root',
+ original_fullpath: '/dashboard/issues?assignee_username=root'
+ )
end
before do
diff --git a/spec/helpers/safe_format_helper_spec.rb b/spec/helpers/safe_format_helper_spec.rb
new file mode 100644
index 00000000000..ced48b0c9c1
--- /dev/null
+++ b/spec/helpers/safe_format_helper_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SafeFormatHelper, feature_category: :shared do
+ describe '#safe_format' do
+ shared_examples 'safe formatting' do |format, args:, result:|
+ subject { helper.safe_format(format, **args) }
+
+ it { is_expected.to eq(result) }
+ it { is_expected.to be_html_safe }
+ end
+
+ it_behaves_like 'safe formatting', '', args: {}, result: ''
+ it_behaves_like 'safe formatting', 'Foo', args: {}, result: 'Foo'
+
+ it_behaves_like 'safe formatting', '<b>strong</b>', args: {},
+ result: '&lt;b&gt;strong&lt;/b&gt;'
+
+ it_behaves_like 'safe formatting', '%{open}strong%{close}',
+ args: { open: '<b>'.html_safe, close: '</b>'.html_safe },
+ result: '<b>strong</b>'
+
+ it_behaves_like 'safe formatting', '%{open}strong%{close} %{user_input}',
+ args: { open: '<b>'.html_safe, close: '</b>'.html_safe,
+ user_input: '<a href="">link</a>' },
+ result: '<b>strong</b> &lt;a href=&quot;&quot;&gt;link&lt;/a&gt;'
+
+ context 'when format is marked as html_safe' do
+ let(:format) { '<b>strong</b>'.html_safe }
+ let(:args) { {} }
+
+ it 'raises an error' do
+ message = 'Argument `format` must not be marked as html_safe!'
+
+ expect { helper.safe_format(format, **args) }
+ .to raise_error ArgumentError, message
+ end
+ end
+ end
+end
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index c7afe0bf391..2cea577a852 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -306,6 +306,46 @@ RSpec.describe SearchHelper, feature_category: :global_search do
end
end
+ describe 'projects_autocomplete' do
+ let_it_be(:user) { create(:user, name: "madelein") }
+ let_it_be(:project_1) { create(:project, name: 'test 1') }
+ let_it_be(:project_2) { create(:project, name: 'test 2') }
+ let(:search_term) { 'test' }
+
+ before do
+ allow(self).to receive(:current_user).and_return(user)
+ end
+
+ context 'when the user does not have access to projects' do
+ it 'does not return any results' do
+ expect(projects_autocomplete(search_term)).to eq([])
+ end
+ end
+
+ context 'when the user has access to one project' do
+ before do
+ project_2.add_developer(user)
+ end
+
+ it 'returns the project' do
+ expect(projects_autocomplete(search_term).pluck(:id)).to eq([project_2.id])
+ end
+
+ context 'when a project namespace matches the search term but the project does not' do
+ let_it_be(:group) { create(:group, name: 'test group') }
+ let_it_be(:project_3) { create(:project, name: 'nothing', namespace: group) }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'returns all projects matching the term' do
+ expect(projects_autocomplete(search_term).pluck(:id)).to match_array([project_2.id, project_3.id])
+ end
+ end
+ end
+ end
+
describe 'search_entries_info' do
using RSpec::Parameterized::TableSyntax
@@ -829,6 +869,21 @@ RSpec.describe SearchHelper, feature_category: :global_search do
expect(header_search_context[:project_metadata]).to eq(project_metadata)
end
+ context 'feature issues is not available' do
+ let(:feature_available) { false }
+ let(:project_metadata) { { mr_path: project_merge_requests_path(project) } }
+
+ before do
+ allow(project).to receive(:feature_available?).and_call_original
+ allow(project).to receive(:feature_available?).with(:issues, current_user).and_return(feature_available)
+ end
+
+ it 'adds the :project and :project-metadata correctly to hash' do
+ expect(header_search_context[:project]).to eq({ id: project.id, name: project.name })
+ expect(header_search_context[:project_metadata]).to eq(project_metadata)
+ end
+ end
+
context 'with scope' do
let(:scope) { 'issues' }
diff --git a/spec/helpers/sessions_helper_spec.rb b/spec/helpers/sessions_helper_spec.rb
index c7b8225b866..5a46a20ce1a 100644
--- a/spec/helpers/sessions_helper_spec.rb
+++ b/spec/helpers/sessions_helper_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe SessionsHelper do
end
describe '#send_rate_limited?' do
- let_it_be(:user) { build(:user) }
+ let(:user) { build_stubbed(:user) }
subject { helper.send_rate_limited?(user) }
@@ -77,30 +77,34 @@ RSpec.describe SessionsHelper do
end
describe '#obfuscated_email' do
+ let(:email) { 'mail@example.com' }
+
subject { helper.obfuscated_email(email) }
- context 'when an email address is normal length' do
- let(:email) { 'alex@gitlab.com' }
+ it 'delegates to Gitlab::Utils::Email.obfuscated_email' do
+ expect(Gitlab::Utils::Email).to receive(:obfuscated_email).with(email).and_call_original
- it { is_expected.to eq('al**@g*****.com') }
+ expect(subject).to eq('ma**@e******.com')
end
+ end
- context 'when an email address contains multiple top level domains' do
- let(:email) { 'alex@gl.co.uk' }
-
- it { is_expected.to eq('al**@g****.uk') }
- end
+ describe '#remember_me_enabled?' do
+ subject { helper.remember_me_enabled? }
- context 'when an email address is very short' do
- let(:email) { 'a@b.c' }
+ context 'when application setting is enabled' do
+ before do
+ stub_application_setting(remember_me_enabled: true)
+ end
- it { is_expected.to eq('a@b.c') }
+ it { is_expected.to be true }
end
- context 'when an email address is even shorter' do
- let(:email) { 'a@b' }
+ context 'when application setting is disabled' do
+ before do
+ stub_application_setting(remember_me_enabled: false)
+ end
- it { is_expected.to eq('a@b') }
+ it { is_expected.to be false }
end
end
end
diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb
index 672c2ef7589..6648663b634 100644
--- a/spec/helpers/sidebars_helper_spec.rb
+++ b/spec/helpers/sidebars_helper_spec.rb
@@ -62,39 +62,154 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do
end
describe '#super_sidebar_context' do
- let(:user) { build(:user) }
- let(:group) { build(:group) }
+ include_context 'custom session'
+
+ let_it_be(:user) { build(:user) }
+ let_it_be(:group) { build(:group) }
+ let_it_be(:panel) { {} }
+ let_it_be(:panel_type) { 'project' }
+ let(:project) { nil }
+ let(:current_user_mode) { Gitlab::Auth::CurrentUserMode.new(user) }
- subject { helper.super_sidebar_context(user, group: group, project: nil) }
+ subject do
+ helper.super_sidebar_context(user, group: group, project: project, panel: panel, panel_type: panel_type)
+ end
before do
+ allow(Time).to receive(:now).and_return(Time.utc(2021, 1, 1))
allow(helper).to receive(:current_user) { user }
- Rails.cache.write(['users', user.id, 'assigned_open_issues_count'], 1)
- Rails.cache.write(['users', user.id, 'assigned_open_merge_requests_count'], 4)
- Rails.cache.write(['users', user.id, 'review_requested_open_merge_requests_count'], 0)
- Rails.cache.write(['users', user.id, 'todos_pending_count'], 3)
- Rails.cache.write(['users', user.id, 'total_merge_requests_count'], 4)
+ allow(helper).to receive(:can?).and_return(true)
+ allow(helper).to receive(:session).and_return(session)
+ allow(helper).to receive(:header_search_context).and_return({ some: "search data" })
+ allow(helper).to receive(:current_user_mode).and_return(current_user_mode)
+ allow(panel).to receive(:super_sidebar_menu_items).and_return(nil)
+ allow(panel).to receive(:super_sidebar_context_header).and_return(nil)
+ allow(user).to receive(:assigned_open_issues_count).and_return(1)
+ allow(user).to receive(:assigned_open_merge_requests_count).and_return(4)
+ allow(user).to receive(:review_requested_open_merge_requests_count).and_return(0)
+ allow(user).to receive(:todos_pending_count).and_return(3)
+ allow(user).to receive(:pinned_nav_items).and_return({ panel_type => %w[foo bar], 'another_panel' => %w[baz] })
end
it 'returns sidebar values from user', :use_clean_rails_memory_store_caching do
expect(subject).to include({
+ current_context_header: nil,
+ current_menu_items: nil,
name: user.name,
username: user.username,
avatar_url: user.avatar_url,
- assigned_open_issues_count: 1,
- todos_pending_count: 3,
+ has_link_to_profile: helper.current_user_menu?(:profile),
+ link_to_profile: user_url(user),
+ status: {
+ can_update: helper.can?(user, :update_user_status, user),
+ busy: user.status&.busy?,
+ customized: user.status&.customized?,
+ availability: user.status&.availability.to_s,
+ emoji: user.status&.emoji,
+ message: user.status&.message_html&.html_safe,
+ clear_after: nil
+ },
+ settings: {
+ has_settings: helper.current_user_menu?(:settings),
+ profile_path: profile_path,
+ profile_preferences_path: profile_preferences_path
+ },
+ user_counts: {
+ assigned_issues: 1,
+ assigned_merge_requests: 4,
+ review_requested_merge_requests: 0,
+ todos: 3,
+ last_update: 1609459200000
+ },
+ can_sign_out: helper.current_user_menu?(:sign_out),
+ sign_out_link: destroy_user_session_path,
issues_dashboard_path: issues_dashboard_path(assignee_username: user.username),
- total_merge_requests_count: 4,
+ todos_dashboard_path: dashboard_todos_path,
+ projects_path: dashboard_projects_path,
+ groups_path: dashboard_groups_path,
support_path: helper.support_url,
display_whats_new: helper.display_whats_new?,
whats_new_most_recent_release_items_count: helper.whats_new_most_recent_release_items_count,
whats_new_version_digest: helper.whats_new_version_digest,
show_version_check: helper.show_version_check?,
gitlab_version: Gitlab.version_info,
- gitlab_version_check: helper.gitlab_version_check
+ gitlab_version_check: helper.gitlab_version_check,
+ gitlab_com_but_not_canary: Gitlab.com_but_not_canary?,
+ gitlab_com_and_canary: Gitlab.com_and_canary?,
+ canary_toggle_com_url: Gitlab::Saas.canary_toggle_com_url,
+ search: {
+ search_path: search_path,
+ issues_path: issues_dashboard_path,
+ mr_path: merge_requests_dashboard_path,
+ autocomplete_path: search_autocomplete_path,
+ search_context: helper.header_search_context
+ },
+ pinned_items: %w[foo bar],
+ panel_type: panel_type,
+ update_pins_url: pins_url,
+ shortcut_links: [
+ {
+ title: _('Milestones'),
+ href: dashboard_milestones_path,
+ css_class: 'dashboard-shortcuts-milestones'
+ },
+ {
+ title: _('Snippets'),
+ href: dashboard_snippets_path,
+ css_class: 'dashboard-shortcuts-snippets'
+ },
+ {
+ title: _('Activity'),
+ href: activity_dashboard_path,
+ css_class: 'dashboard-shortcuts-activity'
+ }
+ ]
})
end
+ describe "shortcut links" do
+ let(:global_shortcut_links) do
+ [
+ {
+ title: _('Milestones'),
+ href: dashboard_milestones_path,
+ css_class: 'dashboard-shortcuts-milestones'
+ },
+ {
+ title: _('Snippets'),
+ href: dashboard_snippets_path,
+ css_class: 'dashboard-shortcuts-snippets'
+ },
+ {
+ title: _('Activity'),
+ href: activity_dashboard_path,
+ css_class: 'dashboard-shortcuts-activity'
+ }
+ ]
+ end
+
+ it 'returns global shortcut links' do
+ expect(subject[:shortcut_links]).to eq(global_shortcut_links)
+ end
+
+ context 'in a project' do
+ # rubocop: disable RSpec/FactoryBot/AvoidCreate
+ let_it_be(:project) { create(:project) }
+ # rubocop: enable RSpec/FactoryBot/AvoidCreate
+
+ it 'returns project-specific shortcut links' do
+ expect(subject[:shortcut_links]).to eq([
+ *global_shortcut_links,
+ {
+ title: _('Create a new issue'),
+ href: new_project_issue_path(project),
+ css_class: 'shortcuts-new-issue'
+ }
+ ])
+ end
+ end
+ end
+
it 'returns "Merge requests" menu', :use_clean_rails_memory_store_caching do
expect(subject[:merge_request_menu]).to eq([
{
@@ -103,12 +218,26 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do
{
text: _('Assigned'),
href: merge_requests_dashboard_path(assignee_username: user.username),
- count: 4
+ count: 4,
+ userCount: 'assigned_merge_requests',
+ extraAttrs: {
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'merge_requests_assigned',
+ 'data-track-property': 'nav_core_menu',
+ class: 'dashboard-shortcuts-merge_requests'
+ }
},
{
text: _('Review requests'),
href: merge_requests_dashboard_path(reviewer_username: user.username),
- count: 0
+ count: 0,
+ userCount: 'review_requested_merge_requests',
+ extraAttrs: {
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'merge_requests_to_review',
+ 'data-track-property': 'nav_core_menu',
+ class: 'dashboard-shortcuts-review_requests'
+ }
}
]
}
@@ -116,19 +245,45 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do
end
it 'returns "Create new" menu groups without headers', :use_clean_rails_memory_store_caching do
+ extra_attrs = ->(id) {
+ {
+ "data-track-label": id,
+ "data-track-action": "click_link",
+ "data-track-property": "nav_create_menu",
+ "data-qa-selector": 'create_menu_item',
+ "data-qa-create-menu-item": id
+ }
+ }
+
expect(subject[:create_new_menu_groups]).to eq([
{
name: "",
items: [
- { href: "/projects/new", text: "New project/repository" },
- { href: "/groups/new", text: "New group" },
- { href: "/-/snippets/new", text: "New snippet" }
+ { href: "/projects/new", text: "New project/repository",
+ component: nil,
+ extraAttrs: extra_attrs.call("general_new_project") },
+ { href: "/groups/new", text: "New group",
+ component: nil,
+ extraAttrs: extra_attrs.call("general_new_group") },
+ { href: "/-/snippets/new", text: "New snippet",
+ component: nil,
+ extraAttrs: extra_attrs.call("general_new_snippet") }
]
}
])
end
it 'returns "Create new" menu groups with headers', :use_clean_rails_memory_store_caching do
+ extra_attrs = ->(id) {
+ {
+ "data-track-label": id,
+ "data-track-action": "click_link",
+ "data-track-property": "nav_create_menu",
+ "data-qa-selector": 'create_menu_item',
+ "data-qa-create-menu-item": id
+ }
+ }
+
allow(group).to receive(:persisted?).and_return(true)
allow(helper).to receive(:can?).and_return(true)
@@ -136,20 +291,241 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do
a_hash_including(
name: "In this group",
items: array_including(
- { href: "/projects/new", text: "New project/repository" },
- { href: "/groups/new#create-group-pane", text: "New subgroup" },
- { href: "/groups/#{group.full_path}/-/group_members", text: "Invite members" }
+ { href: "/projects/new", text: "New project/repository",
+ component: nil,
+ extraAttrs: extra_attrs.call("new_project") },
+ { href: "/groups/new#create-group-pane", text: "New subgroup",
+ component: nil,
+ extraAttrs: extra_attrs.call("new_subgroup") },
+ { href: nil, text: "Invite members",
+ component: 'invite_members',
+ extraAttrs: extra_attrs.call("invite") }
)
),
a_hash_including(
name: "In GitLab",
items: array_including(
- { href: "/projects/new", text: "New project/repository" },
- { href: "/groups/new", text: "New group" },
- { href: "/-/snippets/new", text: "New snippet" }
+ { href: "/projects/new", text: "New project/repository",
+ component: nil,
+ extraAttrs: extra_attrs.call("general_new_project") },
+ { href: "/groups/new", text: "New group",
+ component: nil,
+ extraAttrs: extra_attrs.call("general_new_group") },
+ { href: "/-/snippets/new", text: "New snippet",
+ component: nil,
+ extraAttrs: extra_attrs.call("general_new_snippet") }
)
)
)
end
+
+ describe 'current context' do
+ context 'when current context is a project' do
+ let_it_be(:project) { build(:project) }
+
+ subject do
+ helper.super_sidebar_context(user, group: nil, project: project, panel: panel, panel_type: panel_type)
+ end
+
+ before do
+ allow(project).to receive(:persisted?).and_return(true)
+ end
+
+ it 'returns project context' do
+ expect(subject[:current_context]).to eq({
+ namespace: 'projects',
+ item: {
+ id: project.id,
+ avatarUrl: project.avatar_url,
+ name: project.name,
+ namespace: project.full_name,
+ webUrl: project_path(project)
+ }
+ })
+ end
+ end
+
+ context 'when current context is a group' do
+ subject do
+ helper.super_sidebar_context(user, group: group, project: nil, panel: panel, panel_type: panel_type)
+ end
+
+ before do
+ allow(group).to receive(:persisted?).and_return(true)
+ end
+
+ it 'returns group context' do
+ expect(subject[:current_context]).to eq({
+ namespace: 'groups',
+ item: {
+ id: group.id,
+ avatarUrl: group.avatar_url,
+ name: group.name,
+ namespace: group.full_name,
+ webUrl: group_path(group)
+ }
+ })
+ end
+ end
+
+ context 'when current context is not tracked' do
+ subject do
+ helper.super_sidebar_context(user, group: nil, project: nil, panel: panel, panel_type: panel_type)
+ end
+
+ it 'returns no context' do
+ expect(subject[:current_context]).to eq({})
+ end
+ end
+ end
+
+ describe 'context switcher persistent links' do
+ let_it_be(:public_link) do
+ [
+ { title: s_('Navigation|Your work'), link: '/', icon: 'work' },
+ { title: s_('Navigation|Explore'), link: '/explore', icon: 'compass' }
+ ]
+ end
+
+ let_it_be(:admin_area_link) do
+ { title: s_('Navigation|Admin Area'), link: '/admin', icon: 'admin' }
+ end
+
+ let_it_be(:enter_admin_mode_link) do
+ { title: s_('Navigation|Enter admin mode'), link: '/admin/session/new', icon: 'lock' }
+ end
+
+ let_it_be(:leave_admin_mode_link) do
+ { title: s_('Navigation|Leave admin mode'), link: '/admin/session/destroy', icon: 'lock-open',
+ data_method: 'post' }
+ end
+
+ subject do
+ helper.super_sidebar_context(user, group: nil, project: nil, panel: panel, panel_type: panel_type)
+ end
+
+ context 'when user is not an admin' do
+ it 'returns only the public links' do
+ expect(subject[:context_switcher_links]).to eq(public_link)
+ end
+ end
+
+ context 'when user is an admin' do
+ before do
+ allow(user).to receive(:admin?).and_return(true)
+ end
+
+ context 'when application setting :admin_mode is enabled' do
+ before do
+ stub_application_setting(admin_mode: true)
+ end
+
+ context 'when admin mode is on' do
+ before do
+ current_user_mode.request_admin_mode!
+ current_user_mode.enable_admin_mode!(password: user.password)
+ end
+
+ it 'returns public links, admin area and leave admin mode links' do
+ expect(subject[:context_switcher_links]).to eq([
+ *public_link,
+ admin_area_link,
+ leave_admin_mode_link
+ ])
+ end
+ end
+
+ context 'when admin mode is off' do
+ it 'returns public links and enter admin mode link' do
+ expect(subject[:context_switcher_links]).to eq([
+ *public_link,
+ enter_admin_mode_link
+ ])
+ end
+ end
+ end
+
+ context 'when application setting :admin_mode is disabled' do
+ before do
+ stub_application_setting(admin_mode: false)
+ end
+
+ it 'returns public links and admin area link' do
+ expect(subject[:context_switcher_links]).to eq([
+ *public_link,
+ admin_area_link
+ ])
+ end
+ end
+ end
+ end
+
+ describe 'impersonation data' do
+ it 'sets is_impersonating to `false` when not impersonating' do
+ expect(subject[:is_impersonating]).to be(false)
+ end
+
+ it 'passes the stop_impersonation_path property' do
+ expect(subject[:stop_impersonation_path]).to eq(admin_impersonation_path)
+ end
+
+ describe 'when impersonating' do
+ it 'sets is_impersonating to `true`' do
+ expect(helper).to receive(:session).and_return({ impersonator_id: 1 })
+ expect(subject[:is_impersonating]).to be(true)
+ end
+ end
+ end
+ end
+
+ describe '#super_sidebar_nav_panel' do
+ let(:user) { build(:user) }
+ let(:group) { build(:group) }
+ let(:project) { build(:project) }
+
+ before do
+ allow(helper).to receive(:project_sidebar_context_data).and_return(
+ { current_user: nil, container: project, can_view_pipeline_editor: false, learn_gitlab_enabled: false })
+ allow(helper).to receive(:group_sidebar_context_data).and_return(
+ { current_user: nil, container: group, show_discover_group_security: false })
+
+ allow(group).to receive(:to_global_id).and_return(5)
+ Rails.cache.write(['users', user.id, 'assigned_open_issues_count'], 1)
+ Rails.cache.write(['users', user.id, 'assigned_open_merge_requests_count'], 4)
+ Rails.cache.write(['users', user.id, 'review_requested_open_merge_requests_count'], 0)
+ Rails.cache.write(['users', user.id, 'todos_pending_count'], 3)
+ end
+
+ it 'returns Project Panel for project nav' do
+ expect(helper.super_sidebar_nav_panel(nav: 'project')).to be_a(Sidebars::Projects::SuperSidebarPanel)
+ end
+
+ it 'returns Group Panel for group nav' do
+ expect(helper.super_sidebar_nav_panel(nav: 'group')).to be_a(Sidebars::Groups::SuperSidebarPanel)
+ end
+
+ it 'returns User Settings Panel for profile nav' do
+ expect(helper.super_sidebar_nav_panel(nav: 'profile')).to be_a(Sidebars::UserSettings::Panel)
+ end
+
+ it 'returns User profile Panel for user profile nav' do
+ expect(helper.super_sidebar_nav_panel(nav: 'user_profile')).to be_a(Sidebars::UserProfile::Panel)
+ end
+
+ it 'returns Admin Panel for admin nav' do
+ expect(helper.super_sidebar_nav_panel(nav: 'admin')).to be_a(Sidebars::Admin::Panel)
+ end
+
+ it 'returns "Your Work" Panel for your_work nav', :use_clean_rails_memory_store_caching do
+ expect(helper.super_sidebar_nav_panel(nav: 'your_work', user: user)).to be_a(Sidebars::YourWork::Panel)
+ end
+
+ it 'returns Search Panel for search nav' do
+ expect(helper.super_sidebar_nav_panel(nav: 'search', user: user)).to be_a(Sidebars::Search::Panel)
+ end
+
+ it 'returns "Your Work" Panel as a fallback', :use_clean_rails_memory_store_caching do
+ expect(helper.super_sidebar_nav_panel(user: user)).to be_a(Sidebars::YourWork::Panel)
+ end
end
end
diff --git a/spec/helpers/sorting_helper_spec.rb b/spec/helpers/sorting_helper_spec.rb
index d561b08efac..d625b46e286 100644
--- a/spec/helpers/sorting_helper_spec.rb
+++ b/spec/helpers/sorting_helper_spec.rb
@@ -10,6 +10,60 @@ RSpec.describe SortingHelper do
allow(self).to receive(:request).and_return(double(path: 'http://test.com', query_parameters: { label_name: option }))
end
+ describe '#issuable_sort_options' do
+ let(:viewing_issues) { false }
+ let(:viewing_merge_requests) { false }
+ let(:params) { {} }
+
+ subject(:options) { helper.issuable_sort_options(viewing_issues, viewing_merge_requests) }
+
+ before do
+ allow(helper).to receive(:params).and_return(params)
+ end
+
+ shared_examples 'with merged date option' do
+ it 'adds merged date option' do
+ expect(options).to include(
+ a_hash_including(
+ value: 'merged_at',
+ text: 'Merged date'
+ )
+ )
+ end
+ end
+
+ shared_examples 'without merged date option' do
+ it 'does not set merged date option' do
+ expect(options).not_to include(
+ a_hash_including(
+ value: 'merged_at',
+ text: 'Merged date'
+ )
+ )
+ end
+ end
+
+ it_behaves_like 'without merged date option'
+
+ context 'when viewing_merge_requests is true' do
+ let(:viewing_merge_requests) { true }
+
+ it_behaves_like 'without merged date option'
+
+ context 'when state param is all' do
+ let(:params) { { state: 'all' } }
+
+ it_behaves_like 'with merged date option'
+ end
+
+ context 'when state param is merged' do
+ let(:params) { { state: 'merged' } }
+
+ it_behaves_like 'with merged date option'
+ end
+ end
+ end
+
describe '#admin_users_sort_options' do
it 'returns correct link attributes in array' do
options = admin_users_sort_options(filter: 'filter', search_query: 'search')
diff --git a/spec/helpers/storage_helper_spec.rb b/spec/helpers/storage_helper_spec.rb
index 6c0f1034d65..d62da2ca714 100644
--- a/spec/helpers/storage_helper_spec.rb
+++ b/spec/helpers/storage_helper_spec.rb
@@ -24,18 +24,22 @@ RSpec.describe StorageHelper do
describe "#storage_counters_details" do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project) do
- create(:project,
- namespace: namespace,
- statistics: build(:project_statistics,
- namespace: namespace,
- repository_size: 10.kilobytes,
- wiki_size: 10.bytes,
- lfs_objects_size: 20.gigabytes,
- build_artifacts_size: 30.megabytes,
- pipeline_artifacts_size: 11.megabytes,
- snippets_size: 40.megabytes,
- packages_size: 12.megabytes,
- uploads_size: 15.megabytes))
+ create(
+ :project,
+ namespace: namespace,
+ statistics: build(
+ :project_statistics,
+ namespace: namespace,
+ repository_size: 10.kilobytes,
+ wiki_size: 10.bytes,
+ lfs_objects_size: 20.gigabytes,
+ build_artifacts_size: 30.megabytes,
+ pipeline_artifacts_size: 11.megabytes,
+ snippets_size: 40.megabytes,
+ packages_size: 12.megabytes,
+ uploads_size: 15.megabytes
+ )
+ )
end
let(:message) { 'Repository: 10 KB / Wikis: 10 Bytes / Build Artifacts: 30 MB / Pipeline Artifacts: 11 MB / LFS: 20 GB / Snippets: 40 MB / Packages: 12 MB / Uploads: 15 MB' }
diff --git a/spec/helpers/todos_helper_spec.rb b/spec/helpers/todos_helper_spec.rb
index 26951b0c1e7..9cbcca69dc8 100644
--- a/spec/helpers/todos_helper_spec.rb
+++ b/spec/helpers/todos_helper_spec.rb
@@ -9,20 +9,21 @@ RSpec.describe TodosHelper do
let_it_be(:issue) { create(:issue, title: 'Issue 1', project: project) }
let_it_be(:design) { create(:design, issue: issue) }
let_it_be(:note) do
- create(:note,
- project: issue.project,
- note: 'I am note, hear me roar')
+ create(:note, project: issue.project, note: 'I am note, hear me roar')
end
let_it_be(:group) { create(:group, :public, name: 'Group 1') }
let_it_be(:design_todo) do
- create(:todo, :mentioned,
- user: user,
- project: project,
- target: design,
- author: author,
- note: note)
+ create(
+ :todo,
+ :mentioned,
+ user: user,
+ project: project,
+ target: design,
+ author: author,
+ note: note
+ )
end
let_it_be(:alert_todo) do
@@ -93,11 +94,14 @@ RSpec.describe TodosHelper do
context 'when given a non-design todo' do
let(:todo) do
- build_stubbed(:todo, :assigned,
- user: user,
- project: issue.project,
- target: issue,
- author: author)
+ build_stubbed(
+ :todo,
+ :assigned,
+ user: user,
+ project: issue.project,
+ target: issue,
+ author: author
+ )
end
it 'returns the title' do
@@ -135,22 +139,10 @@ RSpec.describe TodosHelper do
context 'when given a task' do
let(:todo) { task_todo }
- context 'when the use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- end
-
- it 'responds with an appropriate path' do
- path = helper.todo_target_path(todo)
-
- expect(path).to eq("/#{todo.project.full_path}/-/work_items/#{todo.target.id}")
- end
- end
-
it 'responds with an appropriate path using iid' do
path = helper.todo_target_path(todo)
- expect(path).to eq("/#{todo.project.full_path}/-/work_items/#{todo.target.iid}?iid_path=true")
+ expect(path).to eq("/#{todo.project.full_path}/-/work_items/#{todo.target.iid}")
end
end
@@ -166,11 +158,13 @@ RSpec.describe TodosHelper do
context 'when a user requests access to group' do
let_it_be(:group_access_request_todo) do
- create(:todo,
- target_id: group.id,
- target_type: group.class.polymorphic_name,
- group: group,
- action: Todo::MEMBER_ACCESS_REQUESTED)
+ create(
+ :todo,
+ target_id: group.id,
+ target_type: group.class.polymorphic_name,
+ group: group,
+ action: Todo::MEMBER_ACCESS_REQUESTED
+ )
end
it 'responds with access requests tab' do
@@ -307,7 +301,7 @@ RSpec.describe TodosHelper do
end
describe '#no_todos_messages' do
- context 'when getting todos messsages' do
+ context 'when getting todos messages' do
it 'return these sentences' do
expected_sentences = [
s_('Todos|Good job! Looks like you don\'t have anything left on your To-Do List'),
diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb
index c40284ee933..01dacf5fcad 100644
--- a/spec/helpers/tree_helper_spec.rb
+++ b/spec/helpers/tree_helper_spec.rb
@@ -3,6 +3,7 @@
require 'spec_helper'
RSpec.describe TreeHelper do
+ include Devise::Test::ControllerHelpers
let_it_be(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:sha) { 'c1c67abbaf91f624347bb3ae96eabe3a1b742478' }
diff --git a/spec/helpers/users/callouts_helper_spec.rb b/spec/helpers/users/callouts_helper_spec.rb
index 4cb179e4f60..cb724816daf 100644
--- a/spec/helpers/users/callouts_helper_spec.rb
+++ b/spec/helpers/users/callouts_helper_spec.rb
@@ -165,7 +165,27 @@ RSpec.describe Users::CalloutsHelper do
end
end
- describe '#web_hook_disabled_dismissed?' do
+ describe '.show_pages_menu_callout?' do
+ subject { helper.show_pages_menu_callout? }
+
+ before do
+ allow(helper).to receive(:user_dismissed?).with(described_class::PAGES_MOVED_CALLOUT) { dismissed }
+ end
+
+ context 'when user has not dismissed' do
+ let(:dismissed) { false }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user dismissed' do
+ let(:dismissed) { true }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe '#web_hook_disabled_dismissed?', feature_category: :integrations do
context 'without a project' do
it 'is false' do
expect(helper).not_to be_web_hook_disabled_dismissed(nil)
@@ -174,50 +194,12 @@ RSpec.describe Users::CalloutsHelper do
context 'with a project' do
let_it_be(:project) { create(:project) }
+ let(:factory) { :project_callout }
+ let(:container_key) { :project }
+ let(:container) { project }
+ let(:key) { "web_hooks:last_failure:project-#{project.id}" }
- context 'the web-hook failure callout has never been dismissed' do
- it 'is false' do
- expect(helper).not_to be_web_hook_disabled_dismissed(project)
- end
- end
-
- context 'the web-hook failure callout has been dismissed', :freeze_time do
- before do
- create(:project_callout,
- feature_name: described_class::WEB_HOOK_DISABLED,
- user: user,
- project: project,
- dismissed_at: 1.week.ago)
- end
-
- it 'is true' do
- expect(helper).to be_web_hook_disabled_dismissed(project)
- end
-
- context 'when there was an older failure', :clean_gitlab_redis_shared_state do
- let(:key) { "web_hooks:last_failure:project-#{project.id}" }
-
- before do
- Gitlab::Redis::SharedState.with { |r| r.set(key, 1.month.ago.iso8601) }
- end
-
- it 'is true' do
- expect(helper).to be_web_hook_disabled_dismissed(project)
- end
- end
-
- context 'when there has been a more recent failure', :clean_gitlab_redis_shared_state do
- let(:key) { "web_hooks:last_failure:project-#{project.id}" }
-
- before do
- Gitlab::Redis::SharedState.with { |r| r.set(key, 1.day.ago.iso8601) }
- end
-
- it 'is false' do
- expect(helper).not_to be_web_hook_disabled_dismissed(project)
- end
- end
- end
+ include_examples 'CalloutsHelper#web_hook_disabled_dismissed shared examples'
end
end
end
diff --git a/spec/helpers/users/group_callouts_helper_spec.rb b/spec/helpers/users/group_callouts_helper_spec.rb
index da67c4921b3..c6679069c49 100644
--- a/spec/helpers/users/group_callouts_helper_spec.rb
+++ b/spec/helpers/users/group_callouts_helper_spec.rb
@@ -70,10 +70,12 @@ RSpec.describe Users::GroupCalloutsHelper do
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)
+ create(
+ :group_callout,
+ user: user,
+ group: group,
+ feature_name: described_class::INVITE_MEMBERS_BANNER
+ )
end
it { is_expected.to eq(false) }
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
index c2c78be6a0f..f26c37a5ff2 100644
--- a/spec/helpers/users_helper_spec.rb
+++ b/spec/helpers/users_helper_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe UsersHelper do
include TermsHelper
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user, timezone: ActiveSupport::TimeZone::MAPPING['UTC']) }
def filter_ee_badges(badges)
badges.reject { |badge| badge[:text] == 'Is using seat' }
@@ -37,6 +37,35 @@ RSpec.describe UsersHelper do
end
end
+ describe '#user_clear_status_at' do
+ context 'when status exists' do
+ context 'with clear_status_at set' do
+ it 'has the correct iso formatted date', time_travel_to: '2020-01-01 00:00:00 +0000' do
+ clear_status_at = 1.day.from_now
+ status = build_stubbed(:user_status, clear_status_at: clear_status_at)
+
+ expect(user_clear_status_at(status.user)).to eq('2020-01-02T00:00:00Z')
+ end
+ end
+
+ context 'without clear_status_at set' do
+ it 'returns nil' do
+ status = build_stubbed(:user_status, clear_status_at: nil)
+
+ expect(user_clear_status_at(status.user)).to be_nil
+ end
+ end
+ end
+
+ context 'without status' do
+ it 'returns nil' do
+ user = build_stubbed(:user)
+
+ expect(user_clear_status_at(user)).to be_nil
+ end
+ end
+ end
+
describe '#profile_tabs' do
subject(:tabs) { helper.profile_tabs }
@@ -94,10 +123,6 @@ RSpec.describe UsersHelper do
allow(helper).to receive(:can?).and_return(false)
end
- after do
- expect(items).not_to include(:start_trial)
- end
-
it 'includes all default items' do
expect(items).to include(:help, :sign_out)
end
@@ -468,4 +493,72 @@ RSpec.describe UsersHelper do
expect(data[:paths]).to match_schema('entities/admin_users_data_attributes_paths')
end
end
+
+ describe '#user_profile_tabs_app_data' do
+ before do
+ allow(helper).to receive(:user_calendar_path).with(user, :json).and_return('/users/root/calendar.json')
+ allow(user).to receive_message_chain(:followers, :count).and_return(2)
+ allow(user).to receive_message_chain(:followees, :count).and_return(3)
+ end
+
+ it 'returns expected hash' do
+ expect(helper.user_profile_tabs_app_data(user)).to eq({
+ followees: 3,
+ followers: 2,
+ user_calendar_path: '/users/root/calendar.json',
+ utc_offset: 0,
+ user_id: user.id
+ })
+ end
+ end
+
+ describe '#load_max_project_member_accesses' do
+ let_it_be(:projects) { create_list(:project, 3) }
+
+ before(:all) do
+ projects.first.add_developer(user)
+ end
+
+ context 'without current_user' do
+ before do
+ allow(helper).to receive(:current_user).and_return(nil)
+ end
+
+ it 'executes no queries' do
+ sample = ActiveRecord::QueryRecorder.new do
+ helper.load_max_project_member_accesses(projects)
+ end
+
+ expect(sample).not_to exceed_query_limit(0)
+ end
+ end
+
+ context 'when current_user is present', :request_store do
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ it 'preloads ProjectPolicy#lookup_access_level! and UsersHelper#max_member_project_member_access for current_user in two queries', :aggregate_failures do
+ preload_queries = ActiveRecord::QueryRecorder.new do
+ helper.load_max_project_member_accesses(projects)
+ end
+
+ helper_queries = ActiveRecord::QueryRecorder.new do
+ projects.each do |project|
+ helper.max_project_member_access(project)
+ end
+ end
+
+ access_queries = ActiveRecord::QueryRecorder.new do
+ projects.each do |project|
+ user.can?(:read_code, project)
+ end
+ end
+
+ expect(preload_queries).not_to exceed_query_limit(2)
+ expect(helper_queries).not_to exceed_query_limit(0)
+ expect(access_queries).not_to exceed_query_limit(0)
+ end
+ end
+ end
end
diff --git a/spec/helpers/version_check_helper_spec.rb b/spec/helpers/version_check_helper_spec.rb
index 1c8eacf088a..ce5aade2b1c 100644
--- a/spec/helpers/version_check_helper_spec.rb
+++ b/spec/helpers/version_check_helper_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe VersionCheckHelper do
+ include StubVersion
+
let_it_be(:user) { create(:user) }
describe '#show_version_check?' do
@@ -82,4 +84,32 @@ RSpec.describe VersionCheckHelper do
end
end
end
+
+ describe '#link_to_version' do
+ let(:release_url) { 'https://gitlab.com/gitlab-org/gitlab-foss/-/tags/deadbeef' }
+
+ before do
+ allow(Gitlab::Source).to receive(:release_url).and_return(release_url)
+ end
+
+ context 'for a pre-release' do
+ before do
+ stub_version('8.0.2-pre', 'deadbeef')
+ end
+
+ it 'links to commit sha' do
+ expect(helper.link_to_version).to eq("8.0.2-pre <small><a href=\"#{release_url}\">deadbeef</a></small>")
+ end
+ end
+
+ context 'for a normal release' do
+ before do
+ stub_version('8.0.2-ee', 'deadbeef')
+ end
+
+ it 'links to version tag' do
+ expect(helper.link_to_version).to include("<a href=\"#{release_url}\">v8.0.2-ee</a>")
+ end
+ end
+ end
end
diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb
index 2aac0cae0c6..8f37bf29a4b 100644
--- a/spec/helpers/visibility_level_helper_spec.rb
+++ b/spec/helpers/visibility_level_helper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe VisibilityLevelHelper do
+RSpec.describe VisibilityLevelHelper, feature_category: :system_access do
include ProjectForksHelper
let(:project) { build(:project) }
@@ -78,6 +78,23 @@ RSpec.describe VisibilityLevelHelper do
expect(descriptions.uniq.size).to eq(descriptions.size)
expect(descriptions).to all match /group/i
end
+
+ it 'returns default description for public group' do
+ expect(descriptions[2]).to eq('The group and any public projects can be viewed without any authentication.')
+ end
+
+ context 'when application setting `should_check_namespace_plan` is true', if: Gitlab.ee? do
+ let(:group) { create(:group) }
+ let(:public_option_description) { visibility_level_description(Gitlab::VisibilityLevel::PUBLIC, group) }
+
+ before do
+ allow(Gitlab::CurrentSettings.current_application_settings).to receive(:should_check_namespace_plan?) { true }
+ end
+
+ it 'returns updated description for public visibility option in group general settings' do
+ expect(public_option_description).to match /^The group, any public projects, and any of their members, issues, and merge requests can be viewed without authentication./
+ end
+ end
end
end
@@ -161,8 +178,10 @@ RSpec.describe VisibilityLevelHelper do
end
before do
- stub_application_setting(restricted_visibility_levels: restricted_levels,
- default_project_visibility: global_default_level)
+ stub_application_setting(
+ restricted_visibility_levels: restricted_levels,
+ default_project_visibility: global_default_level
+ )
end
with_them do
diff --git a/spec/helpers/work_items_helper_spec.rb b/spec/helpers/work_items_helper_spec.rb
new file mode 100644
index 00000000000..4e1eca3d411
--- /dev/null
+++ b/spec/helpers/work_items_helper_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe WorkItemsHelper, feature_category: :team_planning do
+ describe '#work_items_index_data' do
+ subject(:work_items_index_data) { helper.work_items_index_data(project) }
+
+ let_it_be(:project) { build(:project) }
+
+ it 'returns the expected data properties' do
+ expect(work_items_index_data).to include(
+ {
+ full_path: project.full_path,
+ issues_list_path: project_issues_path(project),
+ register_path: new_user_registration_path(redirect_to_referer: 'yes'),
+ sign_in_path: user_session_path(redirect_to_referer: 'yes'),
+ new_comment_template_path: profile_comment_templates_path,
+ report_abuse_path: add_category_abuse_reports_path
+ }
+ )
+ end
+ end
+end
diff --git a/spec/initializers/active_record_transaction_observer_spec.rb b/spec/initializers/active_record_transaction_observer_spec.rb
new file mode 100644
index 00000000000..a834037dce5
--- /dev/null
+++ b/spec/initializers/active_record_transaction_observer_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'ActiveRecord Transaction Observer', feature_category: :application_performance do
+ def load_initializer
+ load Rails.root.join('config/initializers/active_record_transaction_observer.rb')
+ end
+
+ context 'when DBMS is available' do
+ before do
+ allow_next_instance_of(ActiveRecord::Base.connection) do |connection| # rubocop:disable Database/MultipleDatabases
+ allow(connection).to receive(:active?).and_return(true)
+ end
+ end
+
+ it 'calls Gitlab::Database::Transaction::Observer' do
+ allow(Feature::FlipperFeature).to receive(:table_exists?).and_return(true)
+
+ expect(Gitlab::Database::Transaction::Observer).to receive(:register!)
+
+ load_initializer
+ end
+
+ context 'when flipper table does not exist' do
+ before do
+ allow(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError)
+ end
+
+ it 'does not calls Gitlab::Database::Transaction::Observer' do
+ expect(Gitlab::Database::Transaction::Observer).not_to receive(:register!)
+
+ load_initializer
+ end
+ end
+ end
+
+ context 'when DBMS is not available' do
+ before do
+ allow(ActiveRecord::Base).to receive(:connection).and_raise(PG::ConnectionBad)
+ end
+
+ it 'does not calls Gitlab::Database::Transaction::Observer' do
+ expect(Gitlab::Database::Transaction::Observer).not_to receive(:register!)
+
+ load_initializer
+ end
+ end
+end
diff --git a/spec/initializers/check_forced_decomposition_spec.rb b/spec/initializers/check_forced_decomposition_spec.rb
index a216f078932..23fa3de297a 100644
--- a/spec/initializers/check_forced_decomposition_spec.rb
+++ b/spec/initializers/check_forced_decomposition_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'check_forced_decomposition initializer', feature_category: :pods do
+RSpec.describe 'check_forced_decomposition initializer', feature_category: :cell do
subject(:check_forced_decomposition) do
load Rails.root.join('config/initializers/check_forced_decomposition.rb')
end
@@ -95,11 +95,7 @@ RSpec.describe 'check_forced_decomposition initializer', feature_category: :pods
it { expect { check_forced_decomposition }.to raise_error(/Separate CI database is not ready/) }
- context 'for GitLab.com' do
- before do
- allow(::Gitlab).to receive(:com?).and_return(true)
- end
-
+ context 'for SaaS', :saas do
it { expect { check_forced_decomposition }.not_to raise_error }
end
diff --git a/spec/initializers/circuitbox_spec.rb b/spec/initializers/circuitbox_spec.rb
new file mode 100644
index 00000000000..64e384e4d22
--- /dev/null
+++ b/spec/initializers/circuitbox_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'circuitbox', feature_category: :shared do
+ it 'does not configure Circuitbox', unless: Gitlab.ee? do
+ expect(Circuitbox.default_circuit_store).to be_a(Circuitbox::MemoryStore)
+ expect(Circuitbox.default_notifier).to be_a(Circuitbox::Notifier::ActiveSupport)
+ end
+
+ it 'configures Circuitbox', if: Gitlab.ee? do
+ expect(Circuitbox.default_circuit_store).to be_a(Gitlab::CircuitBreaker::Store)
+ expect(Circuitbox.default_notifier).to be_a(Gitlab::CircuitBreaker::Notifier)
+ end
+end
diff --git a/spec/initializers/direct_upload_support_spec.rb b/spec/initializers/direct_upload_support_spec.rb
index 670deecb4f1..68dd12fdb6e 100644
--- a/spec/initializers/direct_upload_support_spec.rb
+++ b/spec/initializers/direct_upload_support_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe 'Direct upload support' do
end
context 'when other provider is used' do
- let(:provider) { 'Rackspace' }
+ let(:provider) { 'Aliyun' }
it 'raises an error' do
expect { subject }.to raise_error /Object storage provider '#{provider}' is not supported when 'direct_upload' is used for '#{config_name}'/
@@ -103,7 +103,7 @@ RSpec.describe 'Direct upload support' do
context 'when object storage is disabled' do
let(:enabled) { false }
let(:direct_upload) { false }
- let(:provider) { 'Rackspace' }
+ let(:provider) { 'Aliyun' }
it 'succeeds' do
expect { subject }.not_to raise_error
diff --git a/spec/initializers/google_cloud_profiler_spec.rb b/spec/initializers/google_cloud_profiler_spec.rb
new file mode 100644
index 00000000000..493d1e0bea5
--- /dev/null
+++ b/spec/initializers/google_cloud_profiler_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'google cloud profiler', :aggregate_failures, feature_category: :application_performance do
+ subject(:load_initializer) do
+ load rails_root_join('config/initializers/google_cloud_profiler.rb')
+ end
+
+ shared_examples 'does not call profiler agent' do
+ it do
+ expect(CloudProfilerAgent::Agent).not_to receive(:new)
+
+ load_initializer
+ end
+ end
+
+ context 'when GITLAB_GOOGLE_CLOUD_PROFILER_ENABLED is set to true' do
+ before do
+ stub_env('GITLAB_GOOGLE_CLOUD_PROFILER_ENABLED', true)
+ end
+
+ context 'when GITLAB_GOOGLE_CLOUD_PROFILER_PROJECT_ID is not set' do
+ include_examples 'does not call profiler agent'
+ end
+
+ context 'when GITLAB_GOOGLE_CLOUD_PROFILER_PROJECT_ID is set' do
+ let(:project_id) { 'gitlab-staging-1' }
+ let(:agent) { instance_double(CloudProfilerAgent::Agent) }
+
+ before do
+ stub_env('GITLAB_GOOGLE_CLOUD_PROFILER_PROJECT_ID', project_id)
+ end
+
+ context 'when run in Puma context' do
+ before do
+ allow(::Gitlab::Runtime).to receive(:puma?).and_return(true)
+ allow(::Gitlab::Runtime).to receive(:sidekiq?).and_return(false)
+ end
+
+ it 'calls the agent' do
+ expect(CloudProfilerAgent::Agent)
+ .to receive(:new).with(service: 'gitlab-web', project_id: project_id,
+ logger: an_instance_of(::Gitlab::AppJsonLogger),
+ log_labels: hash_including(
+ message: 'Google Cloud Profiler Ruby',
+ pid: be_a(Integer),
+ worker_id: be_a(String)
+ )).and_return(agent)
+ expect(agent).to receive(:start)
+
+ load_initializer
+ end
+ end
+
+ context 'when run in Sidekiq context' do
+ before do
+ allow(::Gitlab::Runtime).to receive(:puma?).and_return(false)
+ allow(::Gitlab::Runtime).to receive(:sidekiq?).and_return(true)
+ end
+
+ include_examples 'does not call profiler agent'
+ end
+
+ context 'when run in another context' do
+ before do
+ allow(::Gitlab::Runtime).to receive(:puma?).and_return(false)
+ allow(::Gitlab::Runtime).to receive(:sidekiq?).and_return(false)
+ end
+
+ include_examples 'does not call profiler agent'
+ end
+ end
+ end
+
+ context 'when GITLAB_GOOGLE_CLOUD_PROFILER_ENABLED is not set' do
+ include_examples 'does not call profiler agent'
+ end
+
+ context 'when GITLAB_GOOGLE_CLOUD_PROFILER_ENABLED is set to false' do
+ before do
+ stub_env('GITLAB_GOOGLE_CLOUD_PROFILER_ENABLED', false)
+ end
+
+ include_examples 'does not call profiler agent'
+ end
+end
diff --git a/spec/initializers/load_balancing_spec.rb b/spec/initializers/load_balancing_spec.rb
index 66aaa52eef2..eddedcb2f38 100644
--- a/spec/initializers/load_balancing_spec.rb
+++ b/spec/initializers/load_balancing_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'load_balancing', :delete, :reestablished_active_record_base, feature_category: :pods do
+RSpec.describe 'load_balancing', :delete, :reestablished_active_record_base, feature_category: :cell do
subject(:initialize_load_balancer) do
load Rails.root.join('config/initializers/load_balancing.rb')
end
diff --git a/spec/initializers/mail_starttls_patch_spec.rb b/spec/initializers/mail_starttls_patch_spec.rb
new file mode 100644
index 00000000000..126ffb98f0e
--- /dev/null
+++ b/spec/initializers/mail_starttls_patch_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# rubocop:disable RSpec/VariableDefinition, RSpec/VariableName
+
+require 'spec_helper'
+require 'mail'
+require_relative '../../config/initializers/mail_starttls_patch'
+
+RSpec.describe 'Mail STARTTLS patch', feature_category: :integrations do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:message) do
+ Mail.new do
+ from 'sender@example.com'
+ to 'receiver@example.com'
+ subject 'test mesage'
+ end
+ end
+
+ # Taken from https://github.com/mikel/mail/pull/1536#issue-1490438378
+ where(:ssl, :tls, :enable_starttls, :enable_starttls_auto, :smtp_tls, :smtp_starttls_mode) do
+ true | nil | nil | nil | true | false
+ nil | false | nil | nil | false | :auto
+ nil | false | nil | true | false | :auto
+ false | false | true | false | false | :always
+ false | nil | false | false | false | false
+ false | false | false | nil | false | false
+ false | nil | :always | nil | false | :always
+ false | nil | :auto | nil | false | :auto
+ end
+
+ with_them do
+ let(:values) do
+ {
+ ssl: ssl,
+ tls: tls,
+ enable_starttls: enable_starttls,
+ enable_starttls_auto: enable_starttls_auto
+ }
+ end
+
+ let(:mail) { Mail::SMTP.new(values) }
+ let(:smtp) { double }
+
+ it 'sets TLS and STARTTLS settings properly' do
+ expect(smtp).to receive(:open_timeout=)
+ expect(smtp).to receive(:read_timeout=)
+ expect(smtp).to receive(:start)
+
+ if smtp_tls
+ expect(smtp).to receive(:enable_tls)
+ expect(smtp).to receive(:disable_starttls)
+ else
+ expect(smtp).to receive(:disable_tls)
+
+ case smtp_starttls_mode
+ when :always
+ expect(smtp).to receive(:enable_starttls)
+ when :auto
+ expect(smtp).to receive(:enable_starttls_auto)
+ when false
+ expect(smtp).to receive(:disable_starttls)
+ end
+ end
+
+ allow(Net::SMTP).to receive(:new).and_return(smtp)
+ mail.deliver!(message)
+ end
+ end
+
+ context 'when enable_starttls and tls are enabled' do
+ let(:values) do
+ {
+ tls: true,
+ enable_starttls: true
+ }
+ end
+
+ let(:mail) { Mail::SMTP.new(values) }
+
+ it 'raises an argument exception' do
+ expect { mail.deliver!(message) }.to raise_error(ArgumentError)
+ end
+ end
+end
+# rubocop:enable RSpec/VariableDefinition, RSpec/VariableName
diff --git a/spec/initializers/net_http_patch_spec.rb b/spec/initializers/net_http_patch_spec.rb
index d56730917f1..82f896e1fa7 100644
--- a/spec/initializers/net_http_patch_spec.rb
+++ b/spec/initializers/net_http_patch_spec.rb
@@ -8,6 +8,12 @@ require_relative '../../config/initializers/net_http_patch'
RSpec.describe 'Net::HTTP patch proxy user and password encoding' do
let(:net_http) { Net::HTTP.new('hostname.example') }
+ before do
+ # This file can be removed once Ruby 3.0 is no longer supported:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/396223
+ skip if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(3.1)
+ end
+
describe '#proxy_user' do
subject { net_http.proxy_user }
diff --git a/spec/initializers/net_http_response_patch_spec.rb b/spec/initializers/net_http_response_patch_spec.rb
index 3bd0d8c3907..eee0747a02a 100644
--- a/spec/initializers/net_http_response_patch_spec.rb
+++ b/spec/initializers/net_http_response_patch_spec.rb
@@ -2,15 +2,15 @@
require 'spec_helper'
-RSpec.describe 'Net::HTTPResponse patch header read timeout' do
+RSpec.describe 'Net::HTTPResponse patch header read timeout', feature_category: :integrations do
describe '.each_response_header' do
let(:server_response) do
- <<~EOS
+ <<~HTTP
Content-Type: text/html
Header-Two: foo
Hello World
- EOS
+ HTTP
end
before do
@@ -30,14 +30,12 @@ RSpec.describe 'Net::HTTPResponse patch header read timeout' do
end
context 'when the response contains many consecutive spaces' do
- before do
+ it 'has no regex backtracking issues' do
expect(socket).to receive(:readuntil).and_return(
"a: #{' ' * 100_000} b",
''
)
- end
- it 'has no regex backtracking issues' do
Timeout.timeout(1) do
each_response_header
end
diff --git a/spec/initializers/safe_session_store_patch_spec.rb b/spec/initializers/safe_session_store_patch_spec.rb
new file mode 100644
index 00000000000..b48aae02e9a
--- /dev/null
+++ b/spec/initializers/safe_session_store_patch_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'safe_sesion_store_patch', feature_category: :integrations do
+ shared_examples 'safe session store' do
+ it 'allows storing a String' do
+ session[:good_data] = 'hello world'
+
+ expect(session[:good_data]).to eq('hello world')
+ end
+
+ it 'raises error when session attempts to store an unsafe object' do
+ expect { session[:test] = Struct.new(:test) }
+ .to raise_error(/Serializing novel Ruby objects can cause uninitialized constants in mixed deployments/)
+ end
+
+ it 'allows instance double of OneLogin::RubySaml::Response' do
+ response_double = instance_double(OneLogin::RubySaml::Response)
+
+ session[:response_double] = response_double
+
+ expect(session[:response_double]).to eq(response_double)
+ end
+
+ it 'raises an error for instance double of REXML::Document' do
+ response_double = instance_double(REXML::Document)
+
+ expect { session[:response_double] = response_double }
+ .to raise_error(/Serializing novel Ruby objects can cause uninitialized constants in mixed deployments/)
+ end
+ end
+
+ context 'with ActionController::TestSession' do
+ let(:session) { ActionController::TestSession.new }
+
+ it_behaves_like 'safe session store'
+ end
+
+ context 'with ActionDispatch::Request::Session' do
+ let(:dummy_store) do
+ Class.new do
+ def load_session(_env)
+ [1, {}]
+ end
+
+ def session_exists?(_env)
+ true
+ end
+
+ def delete_session(_env, _id, _options)
+ 123
+ end
+ end.new
+ end
+
+ let(:request) { ActionDispatch::Request.new({}) }
+ let(:session) { ActionDispatch::Request::Session.create(dummy_store, request, {}) }
+
+ it_behaves_like 'safe session store'
+ end
+end
diff --git a/spec/initializers/settings_spec.rb b/spec/initializers/settings_spec.rb
index c3200d2fab1..09064a21099 100644
--- a/spec/initializers/settings_spec.rb
+++ b/spec/initializers/settings_spec.rb
@@ -9,12 +9,7 @@ RSpec.describe Settings do
expect(Gitlab.config.ldap.servers.main.label).to eq('ldap')
end
- # Specifically trying to cause this error discovered in EE when removing the
- # reassignment of each server element with Settingslogic.
- #
- # `undefined method `label' for #<Hash:0x007fbd18b59c08>`
- #
- it 'can be accessed in a very specific way that breaks without reassigning each element with Settingslogic' do
+ it 'can be accessed in a very specific way that breaks without reassigning each element' do
server_settings = Gitlab.config.ldap.servers['main']
expect(server_settings.label).to eq('ldap')
end
diff --git a/spec/lib/api/ci/helpers/runner_spec.rb b/spec/lib/api/ci/helpers/runner_spec.rb
index 8264db8344d..06ec0396ab1 100644
--- a/spec/lib/api/ci/helpers/runner_spec.rb
+++ b/spec/lib/api/ci/helpers/runner_spec.rb
@@ -67,74 +67,44 @@ RSpec.describe API::Ci::Helpers::Runner do
end
end
- describe '#current_runner_machine', :freeze_time, feature_category: :runner_fleet do
+ describe '#current_runner_manager', :freeze_time, feature_category: :runner_fleet do
let(:runner) { create(:ci_runner, token: 'foo') }
- let(:runner_machine) { create(:ci_runner_machine, runner: runner, system_xid: 'bar', contacted_at: 1.hour.ago) }
+ let(:runner_manager) { create(:ci_runner_machine, runner: runner, system_xid: 'bar', contacted_at: 1.hour.ago) }
- subject(:current_runner_machine) { helper.current_runner_machine }
+ subject(:current_runner_manager) { helper.current_runner_manager }
- context 'with create_runner_machine FF enabled' do
+ context 'when runner manager already exists' do
before do
- stub_feature_flags(create_runner_machine: true)
+ allow(helper).to receive(:params).and_return(token: runner.token, system_id: runner_manager.system_xid)
end
- context 'when runner machine already exists' do
- before do
- allow(helper).to receive(:params).and_return(token: runner.token, system_id: runner_machine.system_xid)
- end
+ it { is_expected.to eq(runner_manager) }
- it { is_expected.to eq(runner_machine) }
-
- it 'does not update the contacted_at field' do
- expect(current_runner_machine.contacted_at).to eq 1.hour.ago
- end
- end
-
- context 'when runner machine cannot be found' do
- it 'creates a new runner machine', :aggregate_failures do
- allow(helper).to receive(:params).and_return(token: runner.token, system_id: 'new_system_id')
-
- expect { current_runner_machine }.to change { Ci::RunnerMachine.count }.by(1)
-
- expect(current_runner_machine).not_to be_nil
- expect(current_runner_machine.system_xid).to eq('new_system_id')
- expect(current_runner_machine.contacted_at).to eq(Time.current)
- expect(current_runner_machine.runner).to eq(runner)
- end
-
- it 'creates a new <legacy> runner machine if system_id is not specified', :aggregate_failures do
- allow(helper).to receive(:params).and_return(token: runner.token)
-
- expect { current_runner_machine }.to change { Ci::RunnerMachine.count }.by(1)
-
- expect(current_runner_machine).not_to be_nil
- expect(current_runner_machine.system_xid).to eq(::API::Ci::Helpers::Runner::LEGACY_SYSTEM_XID)
- expect(current_runner_machine.runner).to eq(runner)
- end
+ it 'does not update the contacted_at field' do
+ expect(current_runner_manager.contacted_at).to eq 1.hour.ago
end
end
- context 'with create_runner_machine FF disabled' do
- before do
- stub_feature_flags(create_runner_machine: false)
- end
+ context 'when runner manager cannot be found' do
+ it 'creates a new runner manager', :aggregate_failures do
+ allow(helper).to receive(:params).and_return(token: runner.token, system_id: 'new_system_id')
- it 'does not return runner machine if no system_id specified' do
- allow(helper).to receive(:params).and_return(token: runner.token)
+ expect { current_runner_manager }.to change { Ci::RunnerManager.count }.by(1)
- is_expected.to be_nil
+ expect(current_runner_manager).not_to be_nil
+ expect(current_runner_manager.system_xid).to eq('new_system_id')
+ expect(current_runner_manager.contacted_at).to eq(Time.current)
+ expect(current_runner_manager.runner).to eq(runner)
end
- context 'when runner machine can not be found' do
- before do
- allow(helper).to receive(:params).and_return(token: runner.token, system_id: 'new_system_id')
- end
+ it 'creates a new <legacy> runner manager if system_id is not specified', :aggregate_failures do
+ allow(helper).to receive(:params).and_return(token: runner.token)
- it 'does not create a new runner machine', :aggregate_failures do
- expect { current_runner_machine }.not_to change { Ci::RunnerMachine.count }
+ expect { current_runner_manager }.to change { Ci::RunnerManager.count }.by(1)
- expect(current_runner_machine).to be_nil
- end
+ expect(current_runner_manager).not_to be_nil
+ expect(current_runner_manager.system_xid).to eq(::API::Ci::Helpers::Runner::LEGACY_SYSTEM_XID)
+ expect(current_runner_manager.runner).to eq(runner)
end
end
end
diff --git a/spec/lib/api/entities/clusters/agent_authorization_spec.rb b/spec/lib/api/entities/clusters/agent_authorization_spec.rb
deleted file mode 100644
index 3a1deb43bf8..00000000000
--- a/spec/lib/api/entities/clusters/agent_authorization_spec.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe API::Entities::Clusters::AgentAuthorization do
- subject { described_class.new(authorization).as_json }
-
- shared_examples 'generic authorization' do
- it 'includes shared 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
-
- context 'project authorization' do
- let(:authorization) { create(:agent_project_authorization) }
-
- include_examples 'generic authorization'
- end
-
- context 'group authorization' do
- let(:authorization) { create(:agent_group_authorization) }
-
- include_examples 'generic authorization'
- end
-
- context 'implicit authorization' do
- let(:agent) { create(:cluster_agent) }
- let(:authorization) { Clusters::Agents::ImplicitAuthorization.new(agent: agent) }
-
- include_examples 'generic authorization'
- end
-end
diff --git a/spec/lib/api/entities/clusters/agents/authorizations/ci_access_spec.rb b/spec/lib/api/entities/clusters/agents/authorizations/ci_access_spec.rb
new file mode 100644
index 00000000000..4dd20f26dc9
--- /dev/null
+++ b/spec/lib/api/entities/clusters/agents/authorizations/ci_access_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::Clusters::Agents::Authorizations::CiAccess, feature_category: :deployment_management do
+ subject { described_class.new(authorization).as_json }
+
+ shared_examples 'generic authorization' do
+ it 'includes shared 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
+
+ context 'project authorization' do
+ let(:authorization) { create(:agent_ci_access_project_authorization) }
+
+ include_examples 'generic authorization'
+ end
+
+ context 'group authorization' do
+ let(:authorization) { create(:agent_ci_access_group_authorization) }
+
+ include_examples 'generic authorization'
+ end
+
+ context 'implicit authorization' do
+ let(:agent) { create(:cluster_agent) }
+ let(:authorization) { Clusters::Agents::Authorizations::CiAccess::ImplicitAuthorization.new(agent: agent) }
+
+ include_examples 'generic authorization'
+ end
+end
diff --git a/spec/lib/api/entities/ml/mlflow/run_info_spec.rb b/spec/lib/api/entities/ml/mlflow/run_info_spec.rb
index b64a1555332..28fef16a532 100644
--- a/spec/lib/api/entities/ml/mlflow/run_info_spec.rb
+++ b/spec/lib/api/entities/ml/mlflow/run_info_spec.rb
@@ -55,13 +55,13 @@ RSpec.describe API::Entities::Ml::Mlflow::RunInfo, feature_category: :mlops do
describe 'run_id' do
it 'is the iid as string' do
- expect(subject[:run_id]).to eq(candidate.iid.to_s)
+ expect(subject[:run_id]).to eq(candidate.eid.to_s)
end
end
describe 'run_uuid' do
it 'is the iid as string' do
- expect(subject[:run_uuid]).to eq(candidate.iid.to_s)
+ expect(subject[:run_uuid]).to eq(candidate.eid.to_s)
end
end
diff --git a/spec/lib/api/entities/ml/mlflow/run_spec.rb b/spec/lib/api/entities/ml/mlflow/run_spec.rb
index b8d38093681..a57f70f788b 100644
--- a/spec/lib/api/entities/ml/mlflow/run_spec.rb
+++ b/spec/lib/api/entities/ml/mlflow/run_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe API::Entities::Ml::Mlflow::Run do
end
it 'has the id' do
- expect(subject.dig(:run, :info, :run_id)).to eq(candidate.iid.to_s)
+ expect(subject.dig(:run, :info, :run_id)).to eq(candidate.eid.to_s)
end
it 'presents the metrics' do
diff --git a/spec/lib/api/entities/personal_access_token_spec.rb b/spec/lib/api/entities/personal_access_token_spec.rb
index fd3c53a21b4..7f79cc80573 100644
--- a/spec/lib/api/entities/personal_access_token_spec.rb
+++ b/spec/lib/api/entities/personal_access_token_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe API::Entities::PersonalAccessToken do
user_id: user.id,
last_used_at: nil,
active: true,
- expires_at: nil
+ expires_at: token.expires_at.iso8601
})
end
end
diff --git a/spec/lib/api/entities/plan_limit_spec.rb b/spec/lib/api/entities/plan_limit_spec.rb
index baaaeb0b600..045d16c91b2 100644
--- a/spec/lib/api/entities/plan_limit_spec.rb
+++ b/spec/lib/api/entities/plan_limit_spec.rb
@@ -11,7 +11,6 @@ RSpec.describe API::Entities::PlanLimit do
expect(subject).to include(
:ci_pipeline_size,
:ci_active_jobs,
- :ci_active_pipelines,
:ci_project_subscriptions,
:ci_pipeline_schedules,
:ci_needs_size_limit,
diff --git a/spec/lib/api/entities/project_job_token_scope_spec.rb b/spec/lib/api/entities/project_job_token_scope_spec.rb
new file mode 100644
index 00000000000..cceef9e7f30
--- /dev/null
+++ b/spec/lib/api/entities/project_job_token_scope_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::API::Entities::ProjectJobTokenScope, feature_category: :secrets_management do
+ let_it_be(:project) do
+ create(:project,
+ :public,
+ ci_inbound_job_token_scope_enabled: true,
+ ci_outbound_job_token_scope_enabled: true
+ )
+ end
+
+ let_it_be(:current_user) { create(:user) }
+
+ let(:options) { { current_user: current_user } }
+ let(:entity) { described_class.new(project, options) }
+
+ describe "#as_json" do
+ subject { entity.as_json }
+
+ it 'includes basic fields' do
+ expect(subject).to eq(
+ inbound_enabled: true,
+ outbound_enabled: true
+ )
+ end
+
+ it 'includes basic fields' do
+ project.update!(ci_inbound_job_token_scope_enabled: false)
+
+ expect(subject).to eq(
+ inbound_enabled: false,
+ outbound_enabled: true
+ )
+ end
+ end
+end
diff --git a/spec/lib/api/entities/project_spec.rb b/spec/lib/api/entities/project_spec.rb
index f4073683919..3a5349bb59b 100644
--- a/spec/lib/api/entities/project_spec.rb
+++ b/spec/lib/api/entities/project_spec.rb
@@ -71,4 +71,26 @@ RSpec.describe ::API::Entities::Project do
end
end
end
+
+ describe '.ci/cd settings' do
+ context 'when the user is not an admin' do
+ before do
+ project.add_reporter(current_user)
+ end
+
+ it 'does not return ci settings' do
+ expect(json[:ci_default_git_depth]).to be_nil
+ end
+ end
+
+ context 'when the user has admin privileges' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ it 'returns ci settings' do
+ expect(json[:ci_default_git_depth]).to be_present
+ end
+ end
+ end
end
diff --git a/spec/lib/api/entities/ssh_key_spec.rb b/spec/lib/api/entities/ssh_key_spec.rb
index b4310035a66..14561beedc5 100644
--- a/spec/lib/api/entities/ssh_key_spec.rb
+++ b/spec/lib/api/entities/ssh_key_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Entities::SSHKey, feature_category: :authentication_and_authorization do
+RSpec.describe API::Entities::SSHKey, feature_category: :system_access do
describe '#as_json' do
subject { entity.as_json }
diff --git a/spec/lib/api/entities/user_spec.rb b/spec/lib/api/entities/user_spec.rb
index 3094fc748c9..6475dcd7618 100644
--- a/spec/lib/api/entities/user_spec.rb
+++ b/spec/lib/api/entities/user_spec.rb
@@ -30,20 +30,62 @@ RSpec.describe API::Entities::User do
end
%i(followers following is_followed).each do |relationship|
- context 'when current user cannot read user profile' do
- let(:can_read_user_profile) { false }
-
+ shared_examples 'does not expose relationship' do
it "does not expose #{relationship}" do
expect(subject).not_to include(relationship)
end
end
+ shared_examples 'exposes relationship' do
+ it "exposes #{relationship}" do
+ expect(subject).to include(relationship)
+ end
+ end
+
+ context 'when current user cannot read user profile' do
+ let(:can_read_user_profile) { false }
+
+ it_behaves_like 'does not expose relationship'
+ end
+
context 'when current user can read user profile' do
let(:can_read_user_profile) { true }
- it "exposes #{relationship}" do
- expect(subject).to include(relationship)
+ it_behaves_like 'exposes relationship'
+ end
+
+ context 'when current user can read user profile and disable_follow_users is switched off' do
+ let(:can_read_user_profile) { true }
+
+ before do
+ stub_feature_flags(disable_follow_users: false)
+ user.enabled_following = false
+ user.save!
+ end
+
+ it_behaves_like 'exposes relationship'
+ end
+
+ context 'when current user can read user profile, disable_follow_users is switched on and user disabled it for themself' do
+ let(:can_read_user_profile) { true }
+
+ before do
+ user.enabled_following = false
+ user.save!
+ end
+
+ it_behaves_like 'does not expose relationship'
+ end
+
+ context 'when current user can read user profile, disable_follow_users is switched on and current user disabled it for themself' do
+ let(:can_read_user_profile) { true }
+
+ before do
+ current_user.enabled_following = false
+ current_user.save!
end
+
+ it_behaves_like 'does not expose relationship'
end
end
end
diff --git a/spec/lib/api/github/entities_spec.rb b/spec/lib/api/github/entities_spec.rb
index 00ea60c5d65..63c54b259a2 100644
--- a/spec/lib/api/github/entities_spec.rb
+++ b/spec/lib/api/github/entities_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe API::Github::Entities do
subject { entity.as_json }
- specify :aggregate_failure do
+ specify :aggregate_failures do
expect(subject[:id]).to eq user.id
expect(subject[:login]).to eq 'name_of_user'
expect(subject[:url]).to eq expected_user_url
diff --git a/spec/lib/api/helpers/internal_helpers_spec.rb b/spec/lib/api/helpers/internal_helpers_spec.rb
new file mode 100644
index 00000000000..847b711f829
--- /dev/null
+++ b/spec/lib/api/helpers/internal_helpers_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe API::Helpers::InternalHelpers, feature_category: :api do
+ describe "log user git operation activity" do
+ let_it_be(:project) { create(:project) }
+ let(:user) { project.first_owner }
+ let(:internal_helper) do
+ Class.new { include API::Helpers::InternalHelpers }.new
+ end
+
+ before do
+ allow(internal_helper).to receive(:project).and_return(project)
+ end
+
+ shared_examples "handles log git operation activity" do
+ it "log the user activity" do
+ activity_service = instance_double(::Users::ActivityService)
+
+ args = { author: user, project: project, namespace: project&.namespace }
+
+ expect(Users::ActivityService).to receive(:new).with(args).and_return(activity_service)
+ expect(activity_service).to receive(:execute)
+
+ internal_helper.log_user_activity(user)
+ end
+ end
+
+ context "when git pull/fetch/clone action" do
+ before do
+ allow(internal_helper).to receive(:params).and_return(action: "git-upload-pack")
+ end
+
+ context "with log the user activity" do
+ it_behaves_like "handles log git operation activity"
+ end
+ end
+
+ context "when git push action" do
+ before do
+ allow(internal_helper).to receive(:params).and_return(action: "git-receive-pack")
+ end
+
+ it "does not log the user activity when log_user_git_push_activity is disabled" do
+ stub_feature_flags(log_user_git_push_activity: false)
+
+ expect(::Users::ActivityService).not_to receive(:new)
+
+ internal_helper.log_user_activity(user)
+ end
+
+ context "with log the user activity when log_user_git_push_activity is enabled" do
+ stub_feature_flags(log_user_git_push_activity: true)
+
+ it_behaves_like "handles log git operation activity"
+ end
+ end
+ end
+end
diff --git a/spec/lib/api/helpers/members_helpers_spec.rb b/spec/lib/api/helpers/members_helpers_spec.rb
index 987d5ba9f6c..ee1ae6b1781 100644
--- a/spec/lib/api/helpers/members_helpers_spec.rb
+++ b/spec/lib/api/helpers/members_helpers_spec.rb
@@ -22,15 +22,6 @@ RSpec.describe API::Helpers::MembersHelpers, feature_category: :subgroups do
it_behaves_like 'returns all direct members'
it_behaves_like 'query with source filters'
-
- context 'when project_members_index_by_project_namespace feature flag is disabled' do
- before do
- stub_feature_flags(project_members_index_by_project_namespace: false)
- end
-
- it_behaves_like 'returns all direct members'
- it_behaves_like 'query with source filters'
- end
end
context 'for a project' do
@@ -39,15 +30,6 @@ RSpec.describe API::Helpers::MembersHelpers, feature_category: :subgroups do
it_behaves_like 'returns all direct members'
it_behaves_like 'query without source filters'
-
- context 'when project_members_index_by_project_namespace feature flag is disabled' do
- before do
- stub_feature_flags(project_members_index_by_project_namespace: false)
- end
-
- it_behaves_like 'returns all direct members'
- it_behaves_like 'query with source filters'
- end
end
end
end
diff --git a/spec/lib/api/helpers/packages/npm_spec.rb b/spec/lib/api/helpers/packages/npm_spec.rb
new file mode 100644
index 00000000000..e1316a10fb1
--- /dev/null
+++ b/spec/lib/api/helpers/packages/npm_spec.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::API::Helpers::Packages::Npm, feature_category: :package_registry do # rubocop: disable RSpec/FilePath
+ let(:object) { klass.new(params) }
+ let(:klass) do
+ Struct.new(:params) do
+ include ::API::Helpers
+ include ::API::Helpers::Packages::Npm
+ end
+ end
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:namespace) { group }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:package) { create(:npm_package, project: project) }
+
+ describe '#endpoint_scope' do
+ subject { object.endpoint_scope }
+
+ context 'when params includes an id' do
+ let(:params) { { id: 42, package_name: 'foo' } }
+
+ it { is_expected.to eq(:project) }
+ end
+
+ context 'when params does not include an id' do
+ let(:params) { { package_name: 'foo' } }
+
+ it { is_expected.to eq(:instance) }
+ end
+ end
+
+ describe '#finder_for_endpoint_scope' do
+ subject { object.finder_for_endpoint_scope(package_name) }
+
+ let(:package_name) { package.name }
+
+ context 'when called with project scope' do
+ let(:params) { { id: project.id } }
+
+ it 'returns a PackageFinder for project scope' do
+ expect(::Packages::Npm::PackageFinder).to receive(:new).with(package_name, project: project)
+
+ subject
+ end
+ end
+
+ context 'when called with instance scope' do
+ let(:params) { { package_name: package_name } }
+
+ it 'returns a PackageFinder for namespace scope' do
+ expect(::Packages::Npm::PackageFinder).to receive(:new).with(package_name, namespace: group)
+
+ subject
+ end
+ end
+ end
+
+ describe '#project_id_or_nil' do
+ subject { object.project_id_or_nil }
+
+ context 'when called with project scope' do
+ let(:params) { { id: project.id } }
+
+ it { is_expected.to eq(project.id) }
+ end
+
+ context 'when called with namespace scope' do
+ context 'when given an unscoped name' do
+ let(:params) { { package_name: 'foo' } }
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'when given a scope that does not match a group name' do
+ let(:params) { { package_name: '@nonexistent-group/foo' } }
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'when given a scope that matches a group name' do
+ let(:params) { { package_name: package.name } }
+
+ it { is_expected.to eq(project.id) }
+
+ context 'with another package with the same name, in another project in the namespace' do
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:package2) { create(:npm_package, name: package.name, project: project2) }
+
+ it 'returns the project id for the newest matching package within the scope' do
+ expect(subject).to eq(project2.id)
+ end
+ end
+ end
+
+ context 'with npm_allow_packages_in_multiple_projects disabled' do
+ before do
+ stub_feature_flags(npm_allow_packages_in_multiple_projects: false)
+ end
+
+ context 'when given an unscoped name' do
+ let(:params) { { package_name: 'foo' } }
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'when given a scope that does not match a group name' do
+ let(:params) { { package_name: '@nonexistent-group/foo' } }
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'when given a scope that matches a group name' do
+ let(:params) { { package_name: package.name } }
+
+ it { is_expected.to eq(project.id) }
+
+ context 'with another package with the same name, in another project in the namespace' do
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:package2) { create(:npm_package, name: package.name, project: project2) }
+
+ it 'returns the project id for the newest matching package within the scope' do
+ expect(subject).to eq(project2.id)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/api/helpers/packages_helpers_spec.rb b/spec/lib/api/helpers/packages_helpers_spec.rb
index 2a663d5e9b2..6ba4396c396 100644
--- a/spec/lib/api/helpers/packages_helpers_spec.rb
+++ b/spec/lib/api/helpers/packages_helpers_spec.rb
@@ -306,7 +306,8 @@ RSpec.describe API::Helpers::PackagesHelpers, feature_category: :package_registr
label: label,
namespace: namespace,
property: property,
- project: project
+ project: project,
+ user: user
)
end
end
diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb
index 0fcf36ca9dd..b70bcb5ab0d 100644
--- a/spec/lib/api/helpers_spec.rb
+++ b/spec/lib/api/helpers_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Helpers, feature_category: :not_owned do
+RSpec.describe API::Helpers, feature_category: :shared do
using RSpec::Parameterized::TableSyntax
subject(:helper) { Class.new.include(described_class).new }
diff --git a/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb
index 86e48a4a0fd..230908ccea1 100644
--- a/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb
+++ b/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb
@@ -3,7 +3,10 @@
require 'spec_helper'
RSpec.describe Atlassian::JiraConnect::Serializers::BranchEntity, feature_category: :integrations do
- let(:project) { create(:project, :repository) }
+ include AfterNextHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+
let(:branch) { project.repository.find_branch('improve/awesome') }
subject { described_class.represent(branch, project: project).as_json }
@@ -11,4 +14,48 @@ RSpec.describe Atlassian::JiraConnect::Serializers::BranchEntity, feature_catego
it 'sets the hash of the branch name as the id' do
expect(subject[:id]).to eq('bbfba9b197ace5da93d03382a7ce50081ae89d99faac1f2326566941288871ce')
end
+
+ describe '#issue_keys' do
+ it 'calls Atlassian::JiraIssueKeyExtractors::Branch#issue_keys' do
+ expect_next(Atlassian::JiraIssueKeyExtractors::Branch) do |extractor|
+ expect(extractor).to receive(:issue_keys)
+ end
+
+ subject
+ end
+
+ it 'avoids N+1 queries when fetching merge requests for multiple branches' do
+ master_branch = project.repository.find_branch('master')
+
+ create(
+ :merge_request,
+ source_project: project,
+ source_branch: 'improve/awesome',
+ title: 'OPEN_MR_TITLE-1',
+ description: 'OPEN_MR_DESC-1'
+ )
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) { subject }
+
+ create(
+ :merge_request,
+ source_project: project,
+ source_branch: 'master',
+ title: 'MASTER_MR_TITLE-1',
+ description: 'MASTER_MR_DESC-1'
+ )
+
+ expect(subject).to include(
+ name: 'improve/awesome',
+ issueKeys: match_array(%w[OPEN_MR_TITLE-1 OPEN_MR_DESC-1])
+ )
+
+ expect do
+ expect(described_class.represent([branch, master_branch], project: project).as_json).to contain_exactly(
+ hash_including(name: 'improve/awesome', issueKeys: match_array(%w[BRANCH-1 OPEN_MR_TITLE-1 OPEN_MR_DESC-1])),
+ hash_including(name: 'master', issueKeys: match_array(%w[MASTER_MR_TITLE-1 MASTER_MR_DESC-1]))
+ )
+ end.not_to exceed_query_limit(control)
+ end
+ end
end
diff --git a/spec/lib/atlassian/jira_connect/serializers/build_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/build_entity_spec.rb
index 48787f2a0d2..1f68b85c7ba 100644
--- a/spec/lib/atlassian/jira_connect/serializers/build_entity_spec.rb
+++ b/spec/lib/atlassian/jira_connect/serializers/build_entity_spec.rb
@@ -29,11 +29,11 @@ RSpec.describe Atlassian::JiraConnect::Serializers::BuildEntity, feature_categor
end
context 'when the pipeline does belong to a Jira issue' do
- let(:pipeline) { create(:ci_pipeline, merge_request: merge_request) }
+ let(:pipeline) { create(:ci_pipeline, merge_request: merge_request, project: project) }
%i[jira_branch jira_title jira_description].each do |trait|
context "because it belongs to an MR with a #{trait}" do
- let(:merge_request) { create(:merge_request, trait) }
+ let(:merge_request) { create(:merge_request, trait, source_project: project) }
describe '#issue_keys' do
it 'is not empty' do
@@ -48,5 +48,22 @@ RSpec.describe Atlassian::JiraConnect::Serializers::BuildEntity, feature_categor
end
end
end
+
+ context 'in the pipeline\'s commit message' do
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:commit_message) { "Merge branch 'staging' into 'master'\n\nFixes bug described in PROJ-1234" }
+
+ before do
+ allow(pipeline).to receive(:git_commit_message).and_return(commit_message)
+ end
+
+ describe '#issue_keys' do
+ it { expect(subject.issue_keys).to match_array(['PROJ-1234']) }
+ end
+
+ describe '#to_json' do
+ it { expect(subject.to_json).to be_valid_json.and match_schema(Atlassian::Schemata.build_info) }
+ end
+ end
end
end
diff --git a/spec/lib/atlassian/jira_connect/serializers/feature_flag_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/feature_flag_entity_spec.rb
index 3f84404f38d..bf855e98570 100644
--- a/spec/lib/atlassian/jira_connect/serializers/feature_flag_entity_spec.rb
+++ b/spec/lib/atlassian/jira_connect/serializers/feature_flag_entity_spec.rb
@@ -47,10 +47,12 @@ RSpec.describe Atlassian::JiraConnect::Serializers::FeatureFlagEntity, feature_c
context 'it has a percentage strategy' do
let!(:scopes) do
- strat = create(:operations_strategy,
- feature_flag: feature_flag,
- name: ::Operations::FeatureFlags::Strategy::STRATEGY_GRADUALROLLOUTUSERID,
- parameters: { 'percentage' => '50', 'groupId' => 'abcde' })
+ strat = create(
+ :operations_strategy,
+ feature_flag: feature_flag,
+ name: ::Operations::FeatureFlags::Strategy::STRATEGY_GRADUALROLLOUTUSERID,
+ parameters: { 'percentage' => '50', 'groupId' => 'abcde' }
+ )
[
create(:operations_scope, strategy: strat, environment_scope: 'production in live'),
diff --git a/spec/lib/atlassian/jira_connect_spec.rb b/spec/lib/atlassian/jira_connect_spec.rb
index 14bf13b8fe6..5238fbdb7cd 100644
--- a/spec/lib/atlassian/jira_connect_spec.rb
+++ b/spec/lib/atlassian/jira_connect_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
RSpec.describe Atlassian::JiraConnect, feature_category: :integrations do
describe '.app_name' do
diff --git a/spec/lib/atlassian/jira_issue_key_extractor_spec.rb b/spec/lib/atlassian/jira_issue_key_extractor_spec.rb
index ce29e03f818..48339d46153 100644
--- a/spec/lib/atlassian/jira_issue_key_extractor_spec.rb
+++ b/spec/lib/atlassian/jira_issue_key_extractor_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Atlassian::JiraIssueKeyExtractor do
+RSpec.describe Atlassian::JiraIssueKeyExtractor, feature_category: :integrations do
describe '.has_keys?' do
subject { described_class.has_keys?(string) }
@@ -33,5 +33,13 @@ RSpec.describe Atlassian::JiraIssueKeyExtractor do
is_expected.to contain_exactly('TEST-01')
end
end
+
+ context 'with custom_regex' do
+ subject { described_class.new('TEST-01 some A-100', custom_regex: /(?<issue>[B-Z]+-\d+)/).issue_keys }
+
+ it 'returns all valid Jira issue keys' do
+ is_expected.to contain_exactly('TEST-01')
+ end
+ end
end
end
diff --git a/spec/lib/atlassian/jira_issue_key_extractors/branch_spec.rb b/spec/lib/atlassian/jira_issue_key_extractors/branch_spec.rb
new file mode 100644
index 00000000000..52b6fc39a3f
--- /dev/null
+++ b/spec/lib/atlassian/jira_issue_key_extractors/branch_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraIssueKeyExtractors::Branch, feature_category: :integrations do
+ include AfterNextHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+
+ let(:branch) { project.repository.find_branch('improve/awesome') }
+
+ describe '.has_keys?' do
+ it 'delegates to `#issue_keys?`' do
+ expect_next(described_class) do |instance|
+ expect(instance).to receive_message_chain(:issue_keys, :any?)
+ end
+
+ described_class.has_keys?(project, branch.name)
+ end
+ end
+
+ describe '#issue_keys' do
+ subject { described_class.new(project, branch.name).issue_keys }
+
+ context 'when branch name does not refer to an issue' do
+ it { is_expected.to eq([]) }
+ end
+
+ context 'when branch name refers to an issue' do
+ before do
+ allow(branch).to receive(:name).and_return('BRANCH-1')
+ end
+
+ it { is_expected.to eq(['BRANCH-1']) }
+
+ context 'when there is a related open merge request, and related closed merge request' do
+ before_all do
+ create(:merge_request,
+ source_project: project,
+ source_branch: 'BRANCH-1',
+ title: 'OPEN_MR_TITLE-1',
+ description: 'OPEN_MR_DESC-1'
+ )
+
+ create(:merge_request, :closed,
+ source_project: project,
+ source_branch: 'BRANCH-1',
+ title: 'CLOSED_MR_TITLE-2',
+ description: 'CLOSED_MR_DESC-2'
+ )
+ end
+
+ it { is_expected.to eq(%w[BRANCH-1 OPEN_MR_TITLE-1 OPEN_MR_DESC-1]) }
+ end
+ end
+ end
+end
diff --git a/spec/lib/backup/database_spec.rb b/spec/lib/backup/database_spec.rb
index c70d47e4940..dd8a4a14531 100644
--- a/spec/lib/backup/database_spec.rb
+++ b/spec/lib/backup/database_spec.rb
@@ -11,12 +11,17 @@ end
RSpec.describe Backup::Database, feature_category: :backup_restore do
let(:progress) { StringIO.new }
let(:output) { progress.string }
- let(:one_db_configured?) { Gitlab::Database.database_base_models.one? }
- let(:database_models_for_backup) { Gitlab::Database.database_base_models_with_gitlab_shared }
+ let(:one_database_configured?) { base_models_for_backup.one? }
let(:timeout_service) do
instance_double(Gitlab::Database::TransactionTimeoutSettings, restore_timeouts: nil, disable_timeouts: nil)
end
+ let(:base_models_for_backup) do
+ Gitlab::Database.database_base_models_with_gitlab_shared.select do |database_name|
+ Gitlab::Database.has_database?(database_name)
+ end
+ end
+
before(:all) do
Rake::Task.define_task(:environment)
Rake.application.rake_require 'active_record/railties/databases'
@@ -33,7 +38,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
subject { described_class.new(progress, force: force) }
before do
- database_models_for_backup.each do |database_name, base_model|
+ base_models_for_backup.each do |_, base_model|
base_model.connection.rollback_transaction unless base_model.connection.open_transactions.zero?
allow(base_model.connection).to receive(:execute).and_call_original
end
@@ -43,48 +48,80 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
Dir.mktmpdir do |dir|
subject.dump(dir, backup_id)
- database_models_for_backup.each_key do |database_name|
+ base_models_for_backup.each_key do |database_name|
filename = database_name == 'main' ? 'database.sql.gz' : "#{database_name}_database.sql.gz"
expect(File.exist?(File.join(dir, filename))).to eq(true)
end
end
end
- it 'uses snapshots' do
- Dir.mktmpdir do |dir|
- base_model = Gitlab::Database.database_base_models['main']
- expect(base_model.connection).to receive(:begin_transaction).with(
- isolation: :repeatable_read
- ).and_call_original
- expect(base_model.connection).to receive(:execute).with(
- "SELECT pg_export_snapshot() as snapshot_id;"
- ).and_call_original
- expect(base_model.connection).to receive(:rollback_transaction).and_call_original
+ context 'when using multiple databases' do
+ before do
+ skip_if_shared_database(:ci)
+ end
- subject.dump(dir, backup_id)
+ it 'uses snapshots' do
+ Dir.mktmpdir do |dir|
+ base_model = Gitlab::Database.database_base_models['main']
+ expect(base_model.connection).to receive(:begin_transaction).with(
+ isolation: :repeatable_read
+ ).and_call_original
+ expect(base_model.connection).to receive(:select_value).with(
+ "SELECT pg_export_snapshot()"
+ ).and_call_original
+ expect(base_model.connection).to receive(:rollback_transaction).and_call_original
+
+ subject.dump(dir, backup_id)
+ end
+ end
+
+ it 'disables transaction time out' do
+ number_of_databases = base_models_for_backup.count
+ expect(Gitlab::Database::TransactionTimeoutSettings)
+ .to receive(:new).exactly(2 * number_of_databases).times.and_return(timeout_service)
+ expect(timeout_service).to receive(:disable_timeouts).exactly(number_of_databases).times
+ expect(timeout_service).to receive(:restore_timeouts).exactly(number_of_databases).times
+
+ Dir.mktmpdir do |dir|
+ subject.dump(dir, backup_id)
+ end
end
end
- it 'disables transaction time out' do
- number_of_databases = Gitlab::Database.database_base_models_with_gitlab_shared.count
- expect(Gitlab::Database::TransactionTimeoutSettings)
- .to receive(:new).exactly(2 * number_of_databases).times.and_return(timeout_service)
- expect(timeout_service).to receive(:disable_timeouts).exactly(number_of_databases).times
- expect(timeout_service).to receive(:restore_timeouts).exactly(number_of_databases).times
+ context 'when using a single databases' do
+ before do
+ skip_if_database_exists(:ci)
+ end
- Dir.mktmpdir do |dir|
- subject.dump(dir, backup_id)
+ it 'does not use snapshots' do
+ Dir.mktmpdir do |dir|
+ base_model = Gitlab::Database.database_base_models['main']
+ expect(base_model.connection).not_to receive(:begin_transaction).with(
+ isolation: :repeatable_read
+ ).and_call_original
+ expect(base_model.connection).not_to receive(:select_value).with(
+ "SELECT pg_export_snapshot()"
+ ).and_call_original
+ expect(base_model.connection).not_to receive(:rollback_transaction).and_call_original
+
+ subject.dump(dir, backup_id)
+ end
end
end
describe 'pg_dump arguments' do
let(:snapshot_id) { 'fake_id' }
let(:pg_args) do
- [
+ args = [
'--clean',
- '--if-exists',
- "--snapshot=#{snapshot_id}"
+ '--if-exists'
]
+
+ if Gitlab::Database.database_mode == Gitlab::Database::MODE_MULTIPLE_DATABASES
+ args + ["--snapshot=#{snapshot_id}"]
+ else
+ args
+ end
end
let(:dumper) { double }
@@ -94,10 +131,10 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
allow(Backup::Dump::Postgres).to receive(:new).and_return(dumper)
allow(dumper).to receive(:dump).with(any_args).and_return(true)
- database_models_for_backup.each do |database_name, base_model|
- allow(base_model.connection).to receive(:execute).with(
- "SELECT pg_export_snapshot() as snapshot_id;"
- ).and_return(['snapshot_id' => snapshot_id])
+ base_models_for_backup.each do |_, base_model|
+ allow(base_model.connection).to receive(:select_value).with(
+ "SELECT pg_export_snapshot()"
+ ).and_return(snapshot_id)
end
end
@@ -134,7 +171,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
it 'restores timeouts' do
Dir.mktmpdir do |dir|
- number_of_databases = Gitlab::Database.database_base_models_with_gitlab_shared.count
+ number_of_databases = base_models_for_backup.count
expect(Gitlab::Database::TransactionTimeoutSettings)
.to receive(:new).exactly(number_of_databases).times.and_return(timeout_service)
expect(timeout_service).to receive(:restore_timeouts).exactly(number_of_databases).times
@@ -165,7 +202,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
it 'warns the user and waits' do
expect(subject).to receive(:sleep)
- if one_db_configured?
+ if one_database_configured?
expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
else
expect(Rake::Task['gitlab:db:drop_tables:main']).to receive(:invoke)
@@ -183,7 +220,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
context 'with an empty .gz file' do
it 'returns successfully' do
- if one_db_configured?
+ if one_database_configured?
expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
else
expect(Rake::Task['gitlab:db:drop_tables:main']).to receive(:invoke)
@@ -203,7 +240,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
end
it 'raises a backup error' do
- if one_db_configured?
+ if one_database_configured?
expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
else
expect(Rake::Task['gitlab:db:drop_tables:main']).to receive(:invoke)
@@ -219,7 +256,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
let(:cmd) { %W[#{Gem.ruby} -e $stderr.write("#{noise}#{visible_error}")] }
it 'filters out noise from errors and has a post restore warning' do
- if one_db_configured?
+ if one_database_configured?
expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
else
expect(Rake::Task['gitlab:db:drop_tables:main']).to receive(:invoke)
@@ -246,7 +283,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
end
it 'overrides default config values' do
- if one_db_configured?
+ if one_database_configured?
expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke)
else
expect(Rake::Task['gitlab:db:drop_tables:main']).to receive(:invoke)
@@ -270,7 +307,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
end
it 'raises an error about missing source file' do
- if one_db_configured?
+ if one_database_configured?
expect(Rake::Task['gitlab:db:drop_tables']).not_to receive(:invoke)
else
expect(Rake::Task['gitlab:db:drop_tables:main']).not_to receive(:invoke)
diff --git a/spec/lib/backup/gitaly_backup_spec.rb b/spec/lib/backup/gitaly_backup_spec.rb
index 7cc8ce2cbae..172fc28dd3e 100644
--- a/spec/lib/backup/gitaly_backup_spec.rb
+++ b/spec/lib/backup/gitaly_backup_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Backup::GitalyBackup do
+RSpec.describe Backup::GitalyBackup, feature_category: :backup_restore do
let(:max_parallelism) { nil }
let(:storage_parallelism) { nil }
let(:destination) { File.join(Gitlab.config.backup.path, 'repositories') }
@@ -17,7 +17,8 @@ RSpec.describe Backup::GitalyBackup do
let(:expected_env) do
{
'SSL_CERT_FILE' => Gitlab::X509::Certificate.default_cert_file,
- 'SSL_CERT_DIR' => Gitlab::X509::Certificate.default_cert_dir
+ 'SSL_CERT_DIR' => Gitlab::X509::Certificate.default_cert_dir,
+ 'GITALY_SERVERS' => anything
}.merge(ENV)
end
@@ -125,12 +126,18 @@ RSpec.describe Backup::GitalyBackup do
}
end
+ let(:expected_env) do
+ ssl_env.merge(
+ 'GITALY_SERVERS' => anything
+ )
+ end
+
before do
stub_const('ENV', ssl_env)
end
it 'passes through SSL envs' do
- expect(Open3).to receive(:popen2).with(ssl_env, anything, 'create', '-path', anything, '-layout', 'pointer', '-id', backup_id).and_call_original
+ expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-layout', 'pointer', '-id', backup_id).and_call_original
subject.start(:create, destination, backup_id: backup_id)
subject.finish!
@@ -174,6 +181,15 @@ RSpec.describe Backup::GitalyBackup do
expect(collect_commit_shas.call(project_snippet.repository)).to match_array(['6e44ba56a4748be361a841e759c20e421a1651a1'])
end
+ it 'clears specified storages when remove_all_repositories is set' do
+ expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-layout', 'pointer', '-remove-all-repositories', 'default').and_call_original
+
+ copy_bundle_to_backup_path('project_repo.bundle', project.disk_path + '.bundle')
+ subject.start(:restore, destination, backup_id: backup_id, remove_all_repositories: %w[default])
+ subject.enqueue(project, Gitlab::GlRepository::PROJECT)
+ subject.finish!
+ end
+
context 'parallel option set' do
let(:max_parallelism) { 3 }
diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb
index 02889c1535d..1733d21c23f 100644
--- a/spec/lib/backup/manager_spec.rb
+++ b/spec/lib/backup/manager_spec.rb
@@ -77,7 +77,9 @@ RSpec.describe Backup::Manager, feature_category: :backup_restore do
end
before do
- allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml'))
+ allow(YAML).to receive(:safe_load_file).with(
+ File.join(Gitlab.config.backup.path, 'backup_information.yml'),
+ permitted_classes: described_class::YAML_PERMITTED_CLASSES)
.and_return(backup_information)
end
@@ -603,14 +605,16 @@ RSpec.describe Backup::Manager, feature_category: :backup_restore do
end
expect(Kernel).not_to have_received(:system).with(*pack_tar_cmdline)
- expect(YAML.load_file(File.join(Gitlab.config.backup.path, 'backup_information.yml'))).to include(
- backup_created_at: backup_time.localtime,
- db_version: be_a(String),
- gitlab_version: Gitlab::VERSION,
- installation_type: Gitlab::INSTALLATION_TYPE,
- skipped: 'tar',
- tar_version: be_a(String)
- )
+ expect(YAML.safe_load_file(
+ File.join(Gitlab.config.backup.path, 'backup_information.yml'),
+ permitted_classes: described_class::YAML_PERMITTED_CLASSES)).to include(
+ backup_created_at: backup_time.localtime,
+ db_version: be_a(String),
+ gitlab_version: Gitlab::VERSION,
+ installation_type: Gitlab::INSTALLATION_TYPE,
+ skipped: 'tar',
+ tar_version: be_a(String)
+ )
expect(FileUtils).to have_received(:rm_rf).with(File.join(Gitlab.config.backup.path, 'tmp'))
end
end
@@ -629,8 +633,10 @@ RSpec.describe Backup::Manager, feature_category: :backup_restore do
end
before do
- allow(YAML).to receive(:load_file).and_call_original
- allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml'))
+ allow(YAML).to receive(:safe_load_file).and_call_original
+ allow(YAML).to receive(:safe_load_file).with(
+ File.join(Gitlab.config.backup.path, 'backup_information.yml'),
+ permitted_classes: described_class::YAML_PERMITTED_CLASSES)
.and_return(backup_information)
end
@@ -658,8 +664,8 @@ RSpec.describe Backup::Manager, feature_category: :backup_restore do
it 'prints the list of available backups' do
expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang
- expect(progress).to have_received(:puts)
- .with(a_string_matching('1451606400_2016_01_01_1.2.3\n 1451520000_2015_12_31'))
+ expect(progress).to have_received(:puts).with(a_string_matching('1451606400_2016_01_01_1.2.3'))
+ expect(progress).to have_received(:puts).with(a_string_matching('1451520000_2015_12_31'))
end
it 'fails the operation and prints an error' do
@@ -892,12 +898,13 @@ RSpec.describe Backup::Manager, feature_category: :backup_restore do
.with(a_string_matching('Non tarred backup found '))
expect(progress).to have_received(:puts)
.with(a_string_matching("Backup #{backup_id} is done"))
- expect(YAML.load_file(File.join(Gitlab.config.backup.path, 'backup_information.yml'))).to include(
- backup_created_at: backup_time,
- full_backup_id: full_backup_id,
- gitlab_version: Gitlab::VERSION,
- skipped: 'something,tar'
- )
+ expect(YAML.safe_load_file(File.join(Gitlab.config.backup.path, 'backup_information.yml'),
+ permitted_classes: described_class::YAML_PERMITTED_CLASSES)).to include(
+ backup_created_at: backup_time,
+ full_backup_id: full_backup_id,
+ gitlab_version: Gitlab::VERSION,
+ skipped: 'something,tar'
+ )
end
context 'on version mismatch' do
@@ -943,7 +950,8 @@ RSpec.describe Backup::Manager, feature_category: :backup_restore do
allow(Gitlab::BackupLogger).to receive(:info)
allow(task1).to receive(:restore).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz'))
allow(task2).to receive(:restore).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz'))
- allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml'))
+ allow(YAML).to receive(:safe_load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml'),
+ permitted_classes: described_class::YAML_PERMITTED_CLASSES)
.and_return(backup_information)
allow(Rake::Task['gitlab:shell:setup']).to receive(:invoke)
allow(Rake::Task['cache:clear']).to receive(:invoke)
@@ -973,8 +981,8 @@ RSpec.describe Backup::Manager, feature_category: :backup_restore do
it 'prints the list of available backups' do
expect { subject.restore }.to raise_error SystemExit
- expect(progress).to have_received(:puts)
- .with(a_string_matching('1451606400_2016_01_01_1.2.3\n 1451520000_2015_12_31'))
+ expect(progress).to have_received(:puts).with(a_string_matching('1451606400_2016_01_01_1.2.3'))
+ expect(progress).to have_received(:puts).with(a_string_matching('1451520000_2015_12_31'))
end
it 'fails the operation and prints an error' do
diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb
index 8bcf1e46c33..b11538b93b7 100644
--- a/spec/lib/backup/repositories_spec.rb
+++ b/spec/lib/backup/repositories_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Backup::Repositories do
+RSpec.describe Backup::Repositories, feature_category: :backup_restore do
let(:progress) { spy(:stdout) }
let(:strategy) { spy(:strategy) }
let(:storages) { [] }
@@ -159,13 +159,14 @@ RSpec.describe Backup::Repositories do
describe '#restore' do
let_it_be(:project) { create(:project, :repository) }
+
let_it_be(:personal_snippet) { create(:personal_snippet, :repository, author: project.first_owner) }
let_it_be(:project_snippet) { create(:project_snippet, :repository, project: project, author: project.first_owner) }
it 'calls enqueue for each repository type', :aggregate_failures do
subject.restore(destination)
- expect(strategy).to have_received(:start).with(:restore, destination)
+ expect(strategy).to have_received(:start).with(:restore, destination, remove_all_repositories: %w[default])
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::WIKI)
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN)
@@ -246,7 +247,7 @@ RSpec.describe Backup::Repositories do
subject.restore(destination)
- expect(strategy).to have_received(:start).with(:restore, destination)
+ expect(strategy).to have_received(:start).with(:restore, destination, remove_all_repositories: %w[default])
expect(strategy).not_to have_received(:enqueue).with(excluded_project, Gitlab::GlRepository::PROJECT)
expect(strategy).not_to have_received(:enqueue).with(excluded_project_snippet, Gitlab::GlRepository::SNIPPET)
expect(strategy).not_to have_received(:enqueue).with(excluded_personal_snippet, Gitlab::GlRepository::SNIPPET)
@@ -268,7 +269,7 @@ RSpec.describe Backup::Repositories do
subject.restore(destination)
- expect(strategy).to have_received(:start).with(:restore, destination)
+ expect(strategy).to have_received(:start).with(:restore, destination, remove_all_repositories: nil)
expect(strategy).not_to have_received(:enqueue).with(excluded_project, Gitlab::GlRepository::PROJECT)
expect(strategy).not_to have_received(:enqueue).with(excluded_project_snippet, Gitlab::GlRepository::SNIPPET)
expect(strategy).not_to have_received(:enqueue).with(excluded_personal_snippet, Gitlab::GlRepository::SNIPPET)
@@ -289,7 +290,7 @@ RSpec.describe Backup::Repositories do
subject.restore(destination)
- expect(strategy).to have_received(:start).with(:restore, destination)
+ expect(strategy).to have_received(:start).with(:restore, destination, remove_all_repositories: nil)
expect(strategy).not_to have_received(:enqueue).with(excluded_project, Gitlab::GlRepository::PROJECT)
expect(strategy).not_to have_received(:enqueue).with(excluded_project_snippet, Gitlab::GlRepository::SNIPPET)
expect(strategy).not_to have_received(:enqueue).with(excluded_personal_snippet, Gitlab::GlRepository::SNIPPET)
diff --git a/spec/lib/banzai/filter/blockquote_fence_filter_spec.rb b/spec/lib/banzai/filter/blockquote_fence_filter_spec.rb
index 575d4879f84..934a0a9fd58 100644
--- a/spec/lib/banzai/filter/blockquote_fence_filter_spec.rb
+++ b/spec/lib/banzai/filter/blockquote_fence_filter_spec.rb
@@ -31,4 +31,8 @@ RSpec.describe Banzai::Filter::BlockquoteFenceFilter, feature_category: :team_pl
end.not_to raise_error
end
end
+
+ it_behaves_like 'text filter timeout' do
+ let(:text) { ">>>\ntest\n>>>" }
+ end
end
diff --git a/spec/lib/banzai/filter/code_language_filter_spec.rb b/spec/lib/banzai/filter/code_language_filter_spec.rb
new file mode 100644
index 00000000000..25f844ee575
--- /dev/null
+++ b/spec/lib/banzai/filter/code_language_filter_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::CodeLanguageFilter, feature_category: :team_planning do
+ include FilterSpecHelper
+
+ shared_examples 'XSS prevention' do |lang|
+ it 'escapes HTML tags' do
+ # This is how a script tag inside a code block is presented to this filter
+ # after Markdown rendering.
+ result = filter(%(<pre lang="#{lang}"><code>&lt;script&gt;alert(1)&lt;/script&gt;</code></pre>))
+
+ # `(1)` symbols are wrapped by lexer tags.
+ expect(result.to_html).not_to match(%r{<script>alert.*</script>})
+
+ # `<>` stands for lexer tags like <span ...>, not &lt;s above.
+ expect(result.to_html).to match(%r{alert(<.*>)?\((<.*>)?1(<.*>)?\)})
+ end
+ end
+
+ context 'when no language is specified' do
+ it 'does nothing' do
+ result = filter('<pre><code>def fun end</code></pre>')
+
+ expect(result.to_html.delete("\n")).to eq('<pre><code>def fun end</code></pre>')
+ end
+ end
+
+ context 'when lang is specified' do
+ it 'adds data-canonical-lang and removes lang attribute' do
+ result = filter('<pre lang="ruby"><code>def fun end</code></pre>')
+
+ expect(result.to_html.delete("\n"))
+ .to eq('<pre data-canonical-lang="ruby"><code>def fun end</code></pre>')
+ end
+ end
+
+ context 'when lang has extra params' do
+ let(:lang_params) { 'foo-bar-kux' }
+ let(:xss_lang) { %(ruby data-meta="foo-bar-kux"&lt;script&gt;alert(1)&lt;/script&gt;) }
+
+ it 'includes data-lang-params tag with extra information and removes data-meta' do
+ expected_result = <<~HTML
+ <pre data-canonical-lang="ruby" data-lang-params="#{lang_params}">
+ <code>This is a test</code></pre>
+ HTML
+
+ result = filter(%(<pre lang="ruby" data-meta="#{lang_params}"><code>This is a test</code></pre>))
+
+ expect(result.to_html.delete("\n")).to eq(expected_result.delete("\n"))
+ end
+
+ include_examples 'XSS prevention', 'ruby'
+
+ include_examples 'XSS prevention',
+ %(ruby data-meta="foo-bar-kux"&lt;script&gt;alert(1)&lt;/script&gt;)
+
+ include_examples 'XSS prevention',
+ %(ruby data-meta="foo-bar-kux"<script>alert(1)</script>)
+ end
+
+ context 'when multiple param delimiters are used' do
+ let(:lang) { 'suggestion' }
+ let(:lang_params) { '-1+10' }
+
+ let(:expected_result) do
+ <<~HTML
+ <pre data-canonical-lang="#{lang}" data-lang-params="#{lang_params} more-things">
+ <code>This is a test</code></pre>
+ HTML
+ end
+
+ context 'when delimiter is colon' do
+ it 'delimits on the first appearance' do
+ result = filter(%(<pre lang="#{lang}:#{lang_params} more-things"><code>This is a test</code></pre>))
+
+ expect(result.to_html.delete("\n")).to eq(expected_result.delete("\n"))
+ end
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/commit_trailers_filter_spec.rb b/spec/lib/banzai/filter/commit_trailers_filter_spec.rb
index 896f3beb7c2..3d992f962ec 100644
--- a/spec/lib/banzai/filter/commit_trailers_filter_spec.rb
+++ b/spec/lib/banzai/filter/commit_trailers_filter_spec.rb
@@ -8,16 +8,15 @@ RSpec.describe Banzai::Filter::CommitTrailersFilter, feature_category: :source_c
include CommitTrailersSpecHelper
let(:secondary_email) { create(:email, :confirmed) }
- let(:user) { create(:user) }
+ let(:user) { create(:user, :public_email) }
+ let(:email) { FFaker::Internet.email }
let(:trailer) { "#{FFaker::Lorem.word}-by:" }
- let(:commit_message) { trailer_line(trailer, user.name, user.email) }
+ let(:commit_message) { trailer_line(trailer, user.name, user.public_email) }
let(:commit_message_html) { commit_html(commit_message) }
context 'detects' do
- let(:email) { FFaker::Internet.email }
-
context 'trailers in the form of *-by' do
where(:commit_trailer) do
["#{FFaker::Lorem.word}-by:", "#{FFaker::Lorem.word}-BY:", "#{FFaker::Lorem.word}-By:"]
@@ -42,7 +41,7 @@ RSpec.describe Banzai::Filter::CommitTrailersFilter, feature_category: :source_c
expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
end
- it 'GitLab users via a secondary email' do
+ it 'does not detect GitLab users via a secondary email' do
_, message_html = build_commit_message(
trailer: trailer,
name: secondary_email.user.name,
@@ -51,9 +50,8 @@ RSpec.describe Banzai::Filter::CommitTrailersFilter, feature_category: :source_c
doc = filter(message_html)
- expect_to_have_user_link_with_avatar(
+ expect_to_have_mailto_link_with_avatar(
doc,
- user: secondary_email.user,
trailer: trailer,
email: secondary_email.email
)
@@ -185,17 +183,16 @@ RSpec.describe Banzai::Filter::CommitTrailersFilter, feature_category: :source_c
it 'preserves the original email used in the commit message' do
message, message_html = build_commit_message(
trailer: trailer,
- name: secondary_email.user.name,
- email: secondary_email.email
+ name: user.name,
+ email: email
)
doc = filter(message_html)
- expect_to_have_user_link_with_avatar(
+ expect_to_have_mailto_link_with_avatar(
doc,
- user: secondary_email.user,
trailer: trailer,
- email: secondary_email.email
+ email: email
)
expect(doc.text).to match Regexp.escape(message)
end
diff --git a/spec/lib/banzai/filter/external_link_filter_spec.rb b/spec/lib/banzai/filter/external_link_filter_spec.rb
index 3f72896939d..de259342998 100644
--- a/spec/lib/banzai/filter/external_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/external_link_filter_spec.rb
@@ -2,25 +2,25 @@
require 'spec_helper'
-RSpec.shared_examples 'an external link with rel attribute', feature_category: :team_planning do
- it 'adds rel="nofollow" to external links' do
- expect(doc.at_css('a')).to have_attribute('rel')
- expect(doc.at_css('a')['rel']).to include 'nofollow'
- end
+RSpec.describe Banzai::Filter::ExternalLinkFilter, feature_category: :team_planning do
+ include FilterSpecHelper
- it 'adds rel="noreferrer" to external links' do
- expect(doc.at_css('a')).to have_attribute('rel')
- expect(doc.at_css('a')['rel']).to include 'noreferrer'
- end
+ shared_examples 'an external link with rel attribute' do
+ it 'adds rel="nofollow" to external links' do
+ expect(doc.at_css('a')).to have_attribute('rel')
+ expect(doc.at_css('a')['rel']).to include 'nofollow'
+ end
- it 'adds rel="noopener" to external links' do
- expect(doc.at_css('a')).to have_attribute('rel')
- expect(doc.at_css('a')['rel']).to include 'noopener'
- end
-end
+ it 'adds rel="noreferrer" to external links' do
+ expect(doc.at_css('a')).to have_attribute('rel')
+ expect(doc.at_css('a')['rel']).to include 'noreferrer'
+ end
-RSpec.describe Banzai::Filter::ExternalLinkFilter do
- include FilterSpecHelper
+ it 'adds rel="noopener" to external links' do
+ expect(doc.at_css('a')).to have_attribute('rel')
+ expect(doc.at_css('a')['rel']).to include 'noopener'
+ end
+ end
it 'ignores elements without an href attribute' do
exp = act = %q(<a id="ignored">Ignore Me</a>)
diff --git a/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb b/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb
index db0c10a802b..746fa6c48a5 100644
--- a/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb
+++ b/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Banzai::Filter::InlineGrafanaMetricsFilter do
+RSpec.describe Banzai::Filter::InlineGrafanaMetricsFilter, feature_category: :metrics do
include FilterSpecHelper
let_it_be(:project) { create(:project) }
@@ -29,6 +29,10 @@ RSpec.describe Banzai::Filter::InlineGrafanaMetricsFilter do
)
end
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
around do |example|
travel_to(Time.utc(2019, 3, 17, 13, 10)) { example.run }
end
diff --git a/spec/lib/banzai/filter/inline_observability_filter_spec.rb b/spec/lib/banzai/filter/inline_observability_filter_spec.rb
index 69a9dc96c2c..81896faced8 100644
--- a/spec/lib/banzai/filter/inline_observability_filter_spec.rb
+++ b/spec/lib/banzai/filter/inline_observability_filter_spec.rb
@@ -2,25 +2,20 @@
require 'spec_helper'
-RSpec.describe Banzai::Filter::InlineObservabilityFilter do
+RSpec.describe Banzai::Filter::InlineObservabilityFilter, feature_category: :metrics do
include FilterSpecHelper
let(:input) { %(<a href="#{url}">example</a>) }
let(:doc) { filter(input) }
- let(:group) { create(:group) }
- let(:user) { create(:user) }
- describe '#filter?' do
- context 'when the document has an external link' do
- let(:url) { 'https://foo.com' }
-
- it 'leaves regular non-observability links unchanged' do
- expect(doc.to_s).to eq(input)
- end
- end
+ before do
+ allow(Gitlab::Observability).to receive(:embeddable_url).and_return('embeddable-url')
+ stub_config_setting(url: "https://www.gitlab.com")
+ end
- context 'when the document contains an embeddable observability link' do
- let(:url) { 'https://observe.gitlab.com/12345' }
+ describe '#filter?' do
+ context 'when the document contains a valid observability link' do
+ let(:url) { "https://www.gitlab.com/groups/some-group/-/observability/test" }
it 'leaves the original link unchanged' do
expect(doc.at_css('a').to_s).to eq(input)
@@ -30,32 +25,34 @@ RSpec.describe Banzai::Filter::InlineObservabilityFilter do
node = doc.at_css('.js-render-observability')
expect(node).to be_present
- expect(node.attribute('data-frame-url').to_s).to eq(url)
+ expect(node.attribute('data-frame-url').to_s).to eq('embeddable-url')
+ expect(Gitlab::Observability).to have_received(:embeddable_url).with(url).once
end
end
- context 'when the document contains an embeddable observability link with redirect' do
- let(:url) { 'https://observe.gitlab.com@example.com/12345' }
+ context 'with duplicate URLs' do
+ let(:url) { "https://www.gitlab.com/groups/some-group/-/observability/test" }
+ let(:input) { %(<a href="#{url}">example1</a><a href="#{url}">example2</a>) }
- it 'leaves the original link unchanged' do
- expect(doc.at_css('a').to_s).to eq(input)
+ where(:embeddable_url) do
+ [
+ 'not-nil',
+ nil
+ ]
end
- it 'does not append an observability charts placeholder' do
- node = doc.at_css('.js-render-observability')
-
- expect(node).not_to be_present
- end
- end
+ with_them do
+ it 'calls Gitlab::Observability.embeddable_url only once' do
+ allow(Gitlab::Observability).to receive(:embeddable_url).with(url).and_return(embeddable_url)
- context 'when the document contains an embeddable observability link with different port' do
- let(:url) { 'https://observe.gitlab.com:3000/12345' }
- let(:observe_url) { 'https://observe.gitlab.com:3001' }
+ filter(input)
- before do
- stub_env('OVERRIDE_OBSERVABILITY_URL', observe_url)
+ expect(Gitlab::Observability).to have_received(:embeddable_url).with(url).once
+ end
end
+ end
+ shared_examples 'does not embed observabilty' do
it 'leaves the original link unchanged' do
expect(doc.at_css('a').to_s).to eq(input)
end
@@ -67,40 +64,37 @@ RSpec.describe Banzai::Filter::InlineObservabilityFilter do
end
end
- context 'when the document contains an embeddable observability link with auth/start' do
- let(:url) { 'https://observe.gitlab.com/auth/start' }
- let(:observe_url) { 'https://observe.gitlab.com' }
+ context 'when the embeddable url is nil' do
+ let(:url) { "https://www.gitlab.com/groups/some-group/-/something-else/test" }
before do
- stub_env('OVERRIDE_OBSERVABILITY_URL', observe_url)
+ allow(Gitlab::Observability).to receive(:embeddable_url).and_return(nil)
end
- it 'leaves the original link unchanged' do
- expect(doc.at_css('a').to_s).to eq(input)
- end
+ it_behaves_like 'does not embed observabilty'
+ end
- it 'does not append an observability charts placeholder' do
- node = doc.at_css('.js-render-observability')
+ context 'when the document has an unrecognised link' do
+ let(:url) { "https://www.gitlab.com/groups/some-group/-/something-else/test" }
- expect(node).not_to be_present
+ it_behaves_like 'does not embed observabilty'
+
+ it 'does not build the embeddable url' do
+ expect(Gitlab::Observability).not_to have_received(:embeddable_url)
end
end
context 'when feature flag is disabled' do
- let(:url) { 'https://observe.gitlab.com/12345' }
+ let(:url) { "https://www.gitlab.com/groups/some-group/-/observability/test" }
before do
stub_feature_flags(observability_group_tab: false)
end
- it 'leaves the original link unchanged' do
- expect(doc.at_css('a').to_s).to eq(input)
- end
+ it_behaves_like 'does not embed observabilty'
- it 'does not append an observability charts placeholder' do
- node = doc.at_css('.js-render-observability')
-
- expect(node).not_to be_present
+ it 'does not build the embeddable url' do
+ expect(Gitlab::Observability).not_to have_received(:embeddable_url)
end
end
end
diff --git a/spec/lib/banzai/filter/issuable_reference_expansion_filter_spec.rb b/spec/lib/banzai/filter/issuable_reference_expansion_filter_spec.rb
index 1fdb29b688e..e14b1362687 100644
--- a/spec/lib/banzai/filter/issuable_reference_expansion_filter_spec.rb
+++ b/spec/lib/banzai/filter/issuable_reference_expansion_filter_spec.rb
@@ -21,6 +21,10 @@ RSpec.describe Banzai::Filter::IssuableReferenceExpansionFilter, feature_categor
create(:issue, state, attributes.merge(project: project))
end
+ def create_item(issuable_type, state, attributes = {})
+ create(issuable_type, state, attributes.merge(project: project))
+ end
+
def create_merge_request(state, attributes = {})
create(:merge_request, state, attributes.merge(source_project: project, target_project: project))
end
@@ -115,53 +119,128 @@ RSpec.describe Banzai::Filter::IssuableReferenceExpansionFilter, feature_categor
end
end
- context 'for issue references' do
- it 'ignores open issue references' do
- issue = create_issue(:opened)
- link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue')
+ shared_examples 'issue / work item references' do
+ it 'ignores open references' do
+ issuable = create_item(issuable_type, :opened)
+ link = create_link(issuable.to_reference, "#{issuable_type}": issuable.id, reference_type: issuable_type)
doc = filter(link, context)
- expect(doc.css('a').last.text).to eq(issue.to_reference)
+ expect(doc.css('a').last.text).to eq(issuable.to_reference)
end
- it 'appends state to closed issue references' do
- link = create_link(closed_issue.to_reference, issue: closed_issue.id, reference_type: 'issue')
+ it 'appends state to moved references' do
+ moved_issuable = create_item(issuable_type, :closed, project: project,
+ moved_to: create_item(issuable_type, :opened))
+ link = create_link(moved_issuable.to_reference, "#{issuable_type}": moved_issuable.id,
+ reference_type: issuable_type)
doc = filter(link, context)
- expect(doc.css('a').last.text).to eq("#{closed_issue.to_reference} (closed)")
+ expect(doc.css('a').last.text).to eq("#{moved_issuable.to_reference} (moved)")
end
- it 'appends state to moved issue references' do
- moved_issue = create(:issue, :closed, project: project, moved_to: create_issue(:opened))
- link = create_link(moved_issue.to_reference, issue: moved_issue.id, reference_type: 'issue')
+ it 'appends state to closed references' do
+ issuable = create_item(issuable_type, :closed)
+ link = create_link(issuable.to_reference, "#{issuable_type}": issuable.id, reference_type: issuable_type)
doc = filter(link, context)
- expect(doc.css('a').last.text).to eq("#{moved_issue.to_reference} (moved)")
+ expect(doc.css('a').last.text).to eq("#{issuable.to_reference} (closed)")
end
it 'shows title for references with +' do
- issue = create_issue(:opened, title: 'Some issue')
- link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue', reference_format: '+')
+ issuable = create_item(issuable_type, :opened, title: 'Some issue')
+ link = create_link(issuable.to_reference, "#{issuable_type}": issuable.id, reference_type: issuable_type,
+ reference_format: '+')
doc = filter(link, context)
- expect(doc.css('a').last.text).to eq("#{issue.title} (#{issue.to_reference})")
+ expect(doc.css('a').last.text).to eq("#{issuable.title} (#{issuable.to_reference})")
end
it 'truncates long title for references with +' do
- issue = create_issue(:opened, title: 'Some issue ' * 10)
- link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue', reference_format: '+')
+ issuable = create_item(issuable_type, :opened, title: 'Some issue ' * 10)
+ link = create_link(issuable.to_reference, "#{issuable_type}": issuable.id, reference_type: issuable_type,
+ reference_format: '+')
doc = filter(link, context)
- expect(doc.css('a').last.text).to eq("#{issue.title.truncate(50)} (#{issue.to_reference})")
+ expect(doc.css('a').last.text).to eq("#{issuable.title.truncate(50)} (#{issuable.to_reference})")
end
it 'shows both title and state for closed references with +' do
- issue = create_issue(:closed, title: 'Some issue')
- link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue', reference_format: '+')
+ issuable = create_item(issuable_type, :closed, title: 'Some issue')
+ link = create_link(issuable.to_reference, "#{issuable_type}": issuable.id, reference_type: issuable_type,
+ reference_format: '+')
+ doc = filter(link, context)
+
+ expect(doc.css('a').last.text).to eq("#{issuable.title} (#{issuable.to_reference} - closed)")
+ end
+
+ it 'shows title for references with +s' do
+ issuable = create_item(issuable_type, :opened, title: 'Some issue')
+ link = create_link(issuable.to_reference, "#{issuable_type}": issuable.id, reference_type: issuable_type,
+ reference_format: '+s')
doc = filter(link, context)
- expect(doc.css('a').last.text).to eq("#{issue.title} (#{issue.to_reference} - closed)")
+ expect(doc.css('a').last.text).to eq("#{issuable.title} (#{issuable.to_reference}) • Unassigned")
end
+
+ context 'when extended summary props are present' do
+ let_it_be(:milestone) { create(:milestone, project: project) }
+ let_it_be(:assignees) { create_list(:user, 3) }
+ let_it_be(:issuable) do
+ create_item(issuable_type, :opened, title: 'Some issue', milestone: milestone,
+ assignees: assignees)
+ end
+
+ let_it_be(:link) do
+ create_link(issuable.to_reference, "#{issuable_type}": issuable.id, reference_type: issuable_type,
+ reference_format: '+s')
+ end
+
+ it 'shows extended summary for references with +s' do
+ doc = filter(link, context)
+
+ expect(doc.css('a').last.text).to eq(
+ "#{issuable.title} (#{issuable.to_reference}) • #{assignees[0].name}, #{assignees[1].name}+ " \
+ "• #{milestone.title}"
+ )
+ end
+
+ describe 'checking N+1' do
+ let_it_be(:milestone2) { create(:milestone, project: project) }
+ let_it_be(:assignees2) { create_list(:user, 3) }
+
+ it 'does not have N+1 for extended summary', :use_sql_query_cache do
+ issuable2 = create_item(issuable_type, :opened, title: 'Another issue',
+ milestone: milestone2, assignees: assignees2)
+ link2 = create_link(issuable2.to_reference, "#{issuable_type}": issuable2.id,
+ reference_type: issuable_type, reference_format: '+s')
+
+ # warm up
+ filter(link, context)
+
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ filter(link, context)
+ end.count
+
+ expect(control_count).to eq 12
+
+ expect do
+ filter("#{link} #{link2}", context)
+ end.not_to exceed_all_query_limit(control_count)
+ end
+ end
+ end
+ end
+
+ context 'for work item references' do
+ let_it_be(:issuable_type) { :work_item }
+
+ it_behaves_like 'issue / work item references'
+ end
+
+ context 'for issue references' do
+ let_it_be(:issuable_type) { :issue }
+
+ it_behaves_like 'issue / work item references'
end
context 'for merge request references' do
@@ -235,5 +314,80 @@ RSpec.describe Banzai::Filter::IssuableReferenceExpansionFilter, feature_categor
expect(doc.css('a').last.text).to eq("#{merge_request.title} (#{merge_request.to_reference})")
end
+
+ it 'shows title for references with +s' do
+ merge_request = create_merge_request(:opened, title: 'Some merge request')
+
+ link = create_link(
+ merge_request.to_reference,
+ merge_request: merge_request.id,
+ reference_type: 'merge_request',
+ reference_format: '+s'
+ )
+
+ doc = filter(link, context)
+
+ expect(doc.css('a').last.text).to eq("#{merge_request.title} (#{merge_request.to_reference}) • Unassigned")
+ end
+
+ context 'when extended summary props are present' do
+ let_it_be(:milestone) { create(:milestone, project: project) }
+ let_it_be(:assignees) { create_list(:user, 2) }
+ let_it_be(:merge_request) do
+ create_merge_request(:opened, title: 'Some merge request', milestone: milestone, assignees: assignees)
+ end
+
+ let_it_be(:link) do
+ create_link(
+ merge_request.to_reference,
+ merge_request: merge_request.id,
+ reference_type: 'merge_request',
+ reference_format: '+s'
+ )
+ end
+
+ it 'shows extended summary for references with +s' do
+ doc = filter(link, context)
+
+ expect(doc.css('a').last.text).to eq(
+ "#{merge_request.title} (#{merge_request.to_reference}) • #{assignees[0].name}, #{assignees[1].name} • " \
+ "#{milestone.title}"
+ )
+ end
+
+ describe 'checking N+1' do
+ let_it_be(:milestone2) { create(:milestone, project: project) }
+ let_it_be(:assignees2) { create_list(:user, 3) }
+
+ it 'does not have N+1 for extended summary', :use_sql_query_cache do
+ merge_request2 = create_merge_request(
+ :closed,
+ title: 'Some merge request',
+ milestone: milestone2,
+ assignees: assignees2
+ )
+
+ link2 = create_link(
+ merge_request2.to_reference,
+ merge_request: merge_request2.id,
+ reference_type: 'merge_request',
+ reference_format: '+s'
+ )
+
+ # warm up
+ filter(link, context)
+
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ filter(link, context)
+ end.count
+
+ expect(control_count).to eq 10
+
+ expect do
+ filter("#{link} #{link2}", context)
+ end.not_to exceed_all_query_limit(control_count)
+ end
+ end
+ end
end
end
diff --git a/spec/lib/banzai/filter/kroki_filter_spec.rb b/spec/lib/banzai/filter/kroki_filter_spec.rb
index 1cd11161439..3915d9fb8f8 100644
--- a/spec/lib/banzai/filter/kroki_filter_spec.rb
+++ b/spec/lib/banzai/filter/kroki_filter_spec.rb
@@ -7,42 +7,46 @@ RSpec.describe Banzai::Filter::KrokiFilter, feature_category: :team_planning do
it 'replaces nomnoml pre tag with img tag if kroki is enabled' do
stub_application_setting(kroki_enabled: true, kroki_url: "http://localhost:8000")
- doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
+ doc = filter("<pre data-canonical-lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDogSW50fHJhaWQoKTtwaWxsYWdlKCl8CiAgW2JlYXJkXS0tW3BhcnJvdF0KICBbYmVhcmRdLTo+W2ZvdWwgbW91dGhdCl0=">'
end
it 'replaces nomnoml pre tag with img tag if both kroki and plantuml are enabled' do
- stub_application_setting(kroki_enabled: true,
- kroki_url: "http://localhost:8000",
- plantuml_enabled: true,
- plantuml_url: "http://localhost:8080")
- doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
+ stub_application_setting(
+ kroki_enabled: true,
+ kroki_url: "http://localhost:8000",
+ plantuml_enabled: true,
+ plantuml_url: "http://localhost:8080"
+ )
+ doc = filter("<pre data-canonical-lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDogSW50fHJhaWQoKTtwaWxsYWdlKCl8CiAgW2JlYXJkXS0tW3BhcnJvdF0KICBbYmVhcmRdLTo+W2ZvdWwgbW91dGhdCl0=">'
end
it 'does not replace nomnoml pre tag with img tag if kroki is disabled' do
stub_application_setting(kroki_enabled: false)
- doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
+ doc = filter("<pre data-canonical-lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
- expect(doc.to_s).to eq "<pre lang=\"nomnoml\"><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:&gt;[foul mouth]\n]</code></pre>"
+ expect(doc.to_s).to eq "<pre data-canonical-lang=\"nomnoml\"><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:&gt;[foul mouth]\n]</code></pre>"
end
it 'does not replace plantuml pre tag with img tag if both kroki and plantuml are enabled' do
- stub_application_setting(kroki_enabled: true,
- kroki_url: "http://localhost:8000",
- plantuml_enabled: true,
- plantuml_url: "http://localhost:8080")
- doc = filter("<pre lang='plantuml'><code>Bob->Alice : hello</code></pre>")
-
- expect(doc.to_s).to eq '<pre lang="plantuml"><code>Bob-&gt;Alice : hello</code></pre>'
+ stub_application_setting(
+ kroki_enabled: true,
+ kroki_url: "http://localhost:8000",
+ plantuml_enabled: true,
+ plantuml_url: "http://localhost:8080"
+ )
+ doc = filter("<pre data-canonical-lang='plantuml'><code>Bob->Alice : hello</code></pre>")
+
+ expect(doc.to_s).to eq '<pre data-canonical-lang="plantuml"><code>Bob-&gt;Alice : hello</code></pre>'
end
it 'adds hidden attribute when content size is large' do
stub_application_setting(kroki_enabled: true, kroki_url: "http://localhost:8000")
text = '[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]' * 25
- doc = filter("<pre lang='nomnoml'><code>#{text}</code></pre>")
+ doc = filter("<pre data-canonical-lang='nomnoml'><code>#{text}</code></pre>")
expect(doc.to_s).to start_with '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KyJyVNQiE5KTSxKidXVjS5ILCrKL4lFFrSyi07LL81RyM0vLckAysRGjxo8avCowaMGjxo8avCowaMGU8lgAE7mIdc=" hidden="" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDog'
end
@@ -50,15 +54,15 @@ RSpec.describe Banzai::Filter::KrokiFilter, feature_category: :team_planning do
it 'allows the lang attribute on the code tag to support RST files processed by gitlab-markup gem' do
stub_application_setting(kroki_enabled: true, kroki_url: "http://localhost:8000")
text = '[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]' * 25
- doc = filter("<pre><code lang='nomnoml'>#{text}</code></pre>")
+ doc = filter("<pre><code data-canonical-lang='nomnoml'>#{text}</code></pre>")
expect(doc.to_s).to start_with '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KyJyVNQiE5KTSxKidXVjS5ILCrKL4lFFrSyi07LL81RyM0vLckAysRGjxo8avCowaMGjxo8avCowaMGU8lgAE7mIdc=" hidden="" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDog'
end
it 'verifies diagram type to avoid possible XSS' do
stub_application_setting(kroki_enabled: true, kroki_url: "http://localhost:8000")
- doc = filter(%(<a><pre lang='f/" onerror=alert(1) onload=alert(1) '><code lang="wavedrom">xss</code></pre></a>))
+ doc = filter(%(<a><pre data-canonical-lang='f/" onerror=alert(1) onload=alert(1) '><code data-canonical-lang="wavedrom">xss</code></pre></a>))
- expect(doc.to_s).to eq %(<a><pre lang='f/" onerror=alert(1) onload=alert(1) '><code lang="wavedrom">xss</code></pre></a>)
+ expect(doc.to_s).to eq %(<a><pre data-canonical-lang='f/" onerror=alert(1) onload=alert(1) '><code data-canonical-lang="wavedrom">xss</code></pre></a>)
end
end
diff --git a/spec/lib/banzai/filter/markdown_engines/base_spec.rb b/spec/lib/banzai/filter/markdown_engines/base_spec.rb
new file mode 100644
index 00000000000..e7b32876610
--- /dev/null
+++ b/spec/lib/banzai/filter/markdown_engines/base_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::MarkdownEngines::Base, feature_category: :team_planning do
+ it 'raise error if render not implemented' do
+ engine = described_class.new({})
+
+ expect { engine.render('# hi') }.to raise_error(NotImplementedError)
+ end
+
+ it 'turns off sourcepos' do
+ engine = described_class.new({ no_sourcepos: true })
+
+ expect(engine.send(:sourcepos_disabled?)).to be_truthy
+ end
+end
diff --git a/spec/lib/banzai/filter/markdown_engines/common_mark_spec.rb b/spec/lib/banzai/filter/markdown_engines/common_mark_spec.rb
new file mode 100644
index 00000000000..74fac75abe8
--- /dev/null
+++ b/spec/lib/banzai/filter/markdown_engines/common_mark_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::MarkdownEngines::CommonMark, feature_category: :team_planning do
+ it 'defaults to generating sourcepos' do
+ engine = described_class.new({})
+
+ expect(engine.render('# hi')).to eq %(<h1 data-sourcepos="1:1-1:4">hi</h1>\n)
+ end
+
+ it 'turns off sourcepos' do
+ engine = described_class.new({ no_sourcepos: true })
+
+ expect(engine.render('# hi')).to eq %(<h1>hi</h1>\n)
+ end
+end
diff --git a/spec/lib/banzai/filter/markdown_filter_spec.rb b/spec/lib/banzai/filter/markdown_filter_spec.rb
index c79cd58255d..64d65528426 100644
--- a/spec/lib/banzai/filter/markdown_filter_spec.rb
+++ b/spec/lib/banzai/filter/markdown_filter_spec.rb
@@ -6,20 +6,19 @@ RSpec.describe Banzai::Filter::MarkdownFilter, feature_category: :team_planning
include FilterSpecHelper
describe 'markdown engine from context' do
- it 'defaults to CommonMark' do
- expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance|
- expect(instance).to receive(:render).and_return('test')
- end
-
- filter('test')
+ it 'finds the correct engine' do
+ expect(described_class.render_engine(:common_mark)).to eq Banzai::Filter::MarkdownEngines::CommonMark
end
- it 'uses CommonMark' do
- expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance|
- expect(instance).to receive(:render).and_return('test')
- end
+ it 'defaults to the DEFAULT_ENGINE' do
+ default_engine = Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE.to_s.classify
+ default = "Banzai::Filter::MarkdownEngines::#{default_engine}".constantize
+
+ expect(described_class.render_engine(nil)).to eq default
+ end
- filter('test', { markdown_engine: :common_mark })
+ it 'raise error for unrecognized engines' do
+ expect { described_class.render_engine(:foo_bar) }.to raise_error(NameError)
end
end
diff --git a/spec/lib/banzai/filter/math_filter_spec.rb b/spec/lib/banzai/filter/math_filter_spec.rb
index 374983e40a1..ded94dd6ce5 100644
--- a/spec/lib/banzai/filter/math_filter_spec.rb
+++ b/spec/lib/banzai/filter/math_filter_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe Banzai::Filter::MathFilter, feature_category: :team_planning do
end
shared_examples 'display math' do
- let_it_be(:template_prefix_with_pre) { '<pre lang="math" data-math-style="display" class="js-render-math"><code>' }
+ let_it_be(:template_prefix_with_pre) { '<pre data-canonical-lang="math" data-math-style="display" class="js-render-math"><code>' }
let_it_be(:template_prefix_with_code) { '<code data-math-style="display" class="code math js-render-math">' }
let(:use_pre_tags) { false }
@@ -101,6 +101,7 @@ RSpec.describe Banzai::Filter::MathFilter, feature_category: :team_planning do
context 'with valid syntax' do
where(:text, :result_template) do
"$$\n2+2\n$$" | "<math>2+2\n</math>"
+ "$$ \n2+2\n$$" | "<math>2+2\n</math>"
"$$\n2+2\n3+4\n$$" | "<math>2+2\n3+4\n</math>"
end
@@ -164,11 +165,11 @@ RSpec.describe Banzai::Filter::MathFilter, feature_category: :team_planning do
input = "```plaintext\n2+2\n```"
doc = pipeline_filter(input)
- expect(doc.to_s).to eq "<pre lang=\"plaintext\"><code>2+2\n</code></pre>"
+ expect(doc.to_s).to eq "<pre data-canonical-lang=\"plaintext\"><code>2+2\n</code></pre>"
end
it 'requires the pre to contain both code and math' do
- input = '<pre lang="math">something</pre>'
+ input = '<pre data-canonical-lang="math">something</pre>'
doc = pipeline_filter(input)
expect(doc.to_s).to eq input
@@ -216,9 +217,11 @@ RSpec.describe Banzai::Filter::MathFilter, feature_category: :team_planning do
def pipeline_filter(text)
context = { project: nil, no_sourcepos: true }
+
doc = Banzai::Pipeline::PreProcessPipeline.call(text, {})
doc = Banzai::Pipeline::PlainMarkdownPipeline.call(doc[:output], context)
- doc = Banzai::Filter::SanitizationFilter.call(doc[:output], context, nil)
+ doc = Banzai::Filter::CodeLanguageFilter.call(doc[:output], context, nil)
+ doc = Banzai::Filter::SanitizationFilter.call(doc, context, nil)
filter(doc)
end
diff --git a/spec/lib/banzai/filter/mermaid_filter_spec.rb b/spec/lib/banzai/filter/mermaid_filter_spec.rb
index de558a0774d..395a6a0bee5 100644
--- a/spec/lib/banzai/filter/mermaid_filter_spec.rb
+++ b/spec/lib/banzai/filter/mermaid_filter_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Banzai::Filter::MermaidFilter, feature_category: :team_planning d
include FilterSpecHelper
it 'adds `js-render-mermaid` class to the `code` tag' do
- doc = filter("<pre class='code highlight js-syntax-highlight mermaid' lang='mermaid' v-pre='true'><code>graph TD;\n A--&gt;B;\n</code></pre>")
+ doc = filter("<pre class='code highlight js-syntax-highlight mermaid' data-canonical-lang='mermaid' v-pre='true'><code>graph TD;\n A--&gt;B;\n</code></pre>")
result = doc.css('code').first
expect(result[:class]).to include('js-render-mermaid')
diff --git a/spec/lib/banzai/filter/plantuml_filter_spec.rb b/spec/lib/banzai/filter/plantuml_filter_spec.rb
index a1eabc23327..8b1e566376c 100644
--- a/spec/lib/banzai/filter/plantuml_filter_spec.rb
+++ b/spec/lib/banzai/filter/plantuml_filter_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter, feature_category: :team_planning
it 'replaces plantuml pre tag with img tag' do
stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080")
- input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
+ input = '<pre data-canonical-lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
output = '<img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==">'
doc = filter(input)
@@ -18,7 +18,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter, feature_category: :team_planning
it 'allows the lang attribute on the code tag to support RST files processed by gitlab-markup gem' do
stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080")
- input = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>'
+ input = '<pre><code data-canonical-lang="plantuml">Bob -> Sara : Hello</code></pre>'
output = '<img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==">'
doc = filter(input)
@@ -28,8 +28,8 @@ RSpec.describe Banzai::Filter::PlantumlFilter, feature_category: :team_planning
it 'does not replace plantuml pre tag with img tag if disabled' do
stub_application_setting(plantuml_enabled: false)
- input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
- output = '<pre lang="plantuml"><code>Bob -&gt; Sara : Hello</code></pre>'
+ input = '<pre data-canonical-lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
+ output = '<pre data-canonical-lang="plantuml"><code>Bob -&gt; Sara : Hello</code></pre>'
doc = filter(input)
expect(doc.to_s).to eq output
@@ -38,8 +38,8 @@ RSpec.describe Banzai::Filter::PlantumlFilter, feature_category: :team_planning
it 'does not replace plantuml pre tag with img tag if url is invalid' do
stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid")
- input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
- output = '<pre lang="plantuml"><code>Bob -&gt; Sara : Hello</code></pre>'
+ input = '<pre data-canonical-lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
+ output = '<pre data-canonical-lang="plantuml"><code>Bob -&gt; Sara : Hello</code></pre>'
doc = filter(input)
expect(doc.to_s).to eq output
diff --git a/spec/lib/banzai/filter/references/design_reference_filter_spec.rb b/spec/lib/banzai/filter/references/design_reference_filter_spec.rb
index 08de9700cad..d97067de155 100644
--- a/spec/lib/banzai/filter/references/design_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/references/design_reference_filter_spec.rb
@@ -128,10 +128,12 @@ RSpec.describe Banzai::Filter::References::DesignReferenceFilter, feature_catego
let(:subject) { filter_instance.data_attributes_for(input_text, project, design) }
specify do
- is_expected.to include(issue: design.issue_id,
- original: input_text,
- project: project.id,
- design: design.id)
+ is_expected.to include(
+ issue: design.issue_id,
+ original: input_text,
+ project: project.id,
+ design: design.id
+ )
end
end
diff --git a/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb
index d8a97c6c3dc..aadd726ac40 100644
--- a/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb
@@ -150,6 +150,15 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter, feature_categor
expect(link.attr('href')).to eq(issue_url)
end
+ it 'includes a data-reference-format attribute for extended summary URL references' do
+ doc = reference_filter("Issue #{issue_url}+s")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-reference-format')
+ expect(link.attr('data-reference-format')).to eq('+s')
+ expect(link.attr('href')).to eq(issue_url)
+ end
+
it 'supports an :only_path context' do
doc = reference_filter("Issue #{written_reference}", only_path: true)
link = doc.css('a').first.attr('href')
diff --git a/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb b/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb
index 9853d6f4093..156455221cf 100644
--- a/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb
@@ -128,6 +128,15 @@ RSpec.describe Banzai::Filter::References::MergeRequestReferenceFilter, feature_
expect(link.attr('href')).to eq(merge_request_url)
end
+ it 'includes a data-reference-format attribute for extended summary URL references' do
+ doc = reference_filter("Merge #{merge_request_url}+s")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-reference-format')
+ expect(link.attr('data-reference-format')).to eq('+s')
+ expect(link.attr('href')).to eq(merge_request_url)
+ end
+
it 'supports an :only_path context' do
doc = reference_filter("Merge #{reference}", only_path: true)
link = doc.css('a').first.attr('href')
diff --git a/spec/lib/banzai/filter/references/reference_cache_spec.rb b/spec/lib/banzai/filter/references/reference_cache_spec.rb
index 7307daca516..577e4471433 100644
--- a/spec/lib/banzai/filter/references/reference_cache_spec.rb
+++ b/spec/lib/banzai/filter/references/reference_cache_spec.rb
@@ -76,12 +76,11 @@ RSpec.describe Banzai::Filter::References::ReferenceCache, feature_category: :te
cache_single.load_records_per_parent
end.count
- expect(control_count).to eq 1
-
+ expect(control_count).to eq 3
# Since this is an issue filter that is not batching issue queries
# across projects, we have to account for that.
# 1 for original issue, 2 for second route/project, 1 for other issue
- max_count = control_count + 3
+ max_count = control_count + 4
expect do
cache.load_references_per_parent(filter.nodes)
diff --git a/spec/lib/banzai/filter/references/work_item_reference_filter_spec.rb b/spec/lib/banzai/filter/references/work_item_reference_filter_spec.rb
new file mode 100644
index 00000000000..e59e53891bf
--- /dev/null
+++ b/spec/lib/banzai/filter/references/work_item_reference_filter_spec.rb
@@ -0,0 +1,314 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::WorkItemReferenceFilter, feature_category: :team_planning do
+ include FilterSpecHelper
+
+ let_it_be(:namespace) { create(:namespace, name: 'main-namespace') }
+ let_it_be(:project) { create(:project, :public, namespace: namespace, path: 'main-project') }
+ let_it_be(:cross_namespace) { create(:namespace, name: 'cross-namespace') }
+ let_it_be(:cross_project) { create(:project, :public, namespace: cross_namespace, path: 'cross-project') }
+ let_it_be(:work_item) { create(:work_item, project: project) }
+
+ def item_url(item)
+ work_item_path = "/#{item.project.namespace.path}/#{item.project.path}/-/work_items/#{item.iid}"
+
+ "http://#{Gitlab.config.gitlab.host}#{work_item_path}"
+ end
+
+ it 'subclasses from IssueReferenceFilter' do
+ expect(described_class.superclass).to eq Banzai::Filter::References::IssueReferenceFilter
+ end
+
+ shared_examples 'a reference with work item type information' do
+ it 'contains work-item-type as a data attribute' do
+ doc = reference_filter("Fixed #{reference}")
+
+ expect(doc.css('a').first.attr('data-work-item-type')).to eq('issue')
+ end
+ end
+
+ shared_examples 'a work item reference' do
+ it_behaves_like 'a reference containing an element node'
+
+ it_behaves_like 'a reference with work item type information'
+
+ it 'links to a valid reference' do
+ doc = reference_filter("Fixed #{written_reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq work_item_url
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Fixed (#{written_reference}.)")
+
+ expect(doc.text).to match(%r{^Fixed \(.*\.\)})
+ end
+
+ it 'includes a title attribute' do
+ doc = reference_filter("Issue #{written_reference}")
+
+ expect(doc.css('a').first.attr('title')).to eq work_item.title
+ end
+
+ it 'escapes the title attribute' do
+ work_item.update_attribute(:title, %("></a>whatever<a title="))
+
+ doc = reference_filter("Issue #{written_reference}")
+
+ expect(doc.text).not_to include 'whatever'
+ end
+
+ it 'renders non-HTML tooltips' do
+ doc = reference_filter("Issue #{written_reference}")
+
+ expect(doc.at_css('a')).not_to have_attribute('data-html')
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Issue #{written_reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-work_item'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Issue #{written_reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-project')
+ expect(link.attr('data-project')).to eq cross_project.id.to_s
+ end
+
+ it 'includes a data-issue attribute' do
+ doc = reference_filter("See #{written_reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-work-item')
+ expect(link.attr('data-work-item')).to eq work_item.id.to_s
+ end
+
+ it 'includes data attributes for issuable popover' do
+ doc = reference_filter("See #{written_reference}")
+ link = doc.css('a').first
+
+ expect(link.attr('data-project-path')).to eq cross_project.full_path
+ expect(link.attr('data-iid')).to eq work_item.iid.to_s
+ end
+
+ it 'includes a data-original attribute' do
+ doc = reference_filter("See #{written_reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-original')
+ expect(link.attr('data-original')).to eq inner_text
+ end
+
+ it 'does not escape the data-original attribute' do
+ skip if written_reference.start_with?('<a')
+
+ inner_html = 'element <code>node</code> inside'
+ doc = reference_filter(%(<a href="#{written_reference}">#{inner_html}</a>))
+
+ expect(doc.children.first.attr('data-original')).to eq inner_html
+ end
+
+ it 'includes a data-reference-format attribute' do
+ skip if written_reference.start_with?('<a')
+
+ doc = reference_filter("Issue #{written_reference}+")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-reference-format')
+ expect(link.attr('data-reference-format')).to eq('+')
+ expect(link.attr('href')).to eq(work_item_url)
+ end
+
+ it 'includes a data-reference-format attribute for URL references' do
+ doc = reference_filter("Issue #{work_item_url}+")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-reference-format')
+ expect(link.attr('data-reference-format')).to eq('+')
+ expect(link.attr('href')).to eq(work_item_url)
+ end
+
+ it 'includes a data-reference-format attribute for extended summary URL references' do
+ doc = reference_filter("Issue #{work_item_url}+s")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-reference-format')
+ expect(link.attr('data-reference-format')).to eq('+s')
+ expect(link.attr('href')).to eq(work_item_url)
+ end
+
+ it 'does not process links containing issue numbers followed by text' do
+ href = "#{written_reference}st"
+ doc = reference_filter("<a href='#{href}'></a>")
+ link = doc.css('a').first.attr('href')
+
+ expect(link).to eq(href)
+ end
+ end
+
+ # Example:
+ # "See #1"
+ context 'when standard internal reference' do
+ it 'is handled by IssueReferenceFilter, not WorkItemReferenceFilter' do
+ doc = reference_filter("Fixed ##{work_item.iid}")
+
+ expect(doc.css('a')).to be_empty
+ end
+ end
+
+ # Example:
+ # "See cross-namespace/cross-project#1"
+ context 'when cross-project / cross-namespace complete reference' do
+ let_it_be(:work_item2) { create(:work_item, project: cross_project) }
+ let_it_be(:reference) { "#{cross_project.full_path}##{work_item2.iid}" }
+
+ it 'is handled by IssueReferenceFilter, not WorkItemReferenceFilter' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a')).to be_empty
+ end
+ end
+
+ # Example:
+ # "See main-namespace/cross-project#1"
+ context 'when cross-project / same-namespace complete reference' do
+ let_it_be(:cross_project) { create(:project, :public, namespace: namespace, path: 'cross-project') }
+ let_it_be(:work_item) { create(:work_item, project: cross_project) }
+ let_it_be(:reference) { "#{cross_project.full_path}##{work_item.iid}" }
+
+ it 'is handled by IssueReferenceFilter, not WorkItemReferenceFilter' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a')).to be_empty
+ end
+ end
+
+ # Example:
+ # "See cross-project#1"
+ context 'when cross-project / same-namespace shorthand reference' do
+ let_it_be(:cross_project) { create(:project, :public, namespace: namespace, path: 'cross-project') }
+ let_it_be(:work_item) { create(:work_item, project: cross_project) }
+ let_it_be(:reference) { "#{cross_project.path}##{work_item.iid}" }
+
+ it 'is handled by IssueReferenceFilter, not WorkItemReferenceFilter' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a')).to be_empty
+ end
+ end
+
+ # Example:
+ # "See http://localhost/cross-namespace/cross-project/-/work_items/1"
+ context 'when cross-project URL reference' do
+ let_it_be(:work_item, reload: true) { create(:work_item, project: cross_project) }
+ let_it_be(:work_item_url) { item_url(work_item) }
+ let_it_be(:reference) { work_item_url }
+ let_it_be(:written_reference) { reference }
+ let_it_be(:inner_text) { written_reference }
+
+ it_behaves_like 'a work item reference'
+ end
+
+ # Example:
+ # "See http://localhost/cross-namespace/cross-project/-/work_items/1#note_123"
+ context 'when cross-project URL reference with comment anchor' do
+ let_it_be(:work_item) { create(:work_item, project: cross_project) }
+ let_it_be(:work_item_url) { item_url(work_item) }
+ let_it_be(:reference) { "#{work_item_url}#note_123" }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it_behaves_like 'a reference with work item type information'
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq reference
+ end
+
+ it 'link with trailing slash' do
+ doc = reference_filter("Fixed (#{work_item_url}/.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(work_item.to_reference(project))}</a>\.\)})
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(work_item.to_reference(project))} \(comment 123\)</a>\.\)})
+ end
+ end
+
+ # Example:
+ # 'See <a href="cross-namespace/cross-project#1">Reference</a>''
+ context 'when cross-project reference in link href' do
+ let_it_be(:work_item) { create(:work_item, project: cross_project) }
+ let_it_be(:reference) { work_item.to_reference(project) }
+ let_it_be(:reference_link) { %(<a href="#{reference}">Reference</a>) }
+ let_it_be(:work_item_url) { item_url(work_item) }
+
+ it 'is handled by IssueReferenceFilter, not WorkItemReferenceFilter' do
+ doc = reference_filter("See #{reference_link}")
+
+ expect(doc.css('a').first[:href]).to eq reference
+ expect(doc.css('a').first[:href]).not_to eq work_item_url
+ end
+ end
+
+ # Example:
+ # 'See <a href=\"http://localhost/cross-namespace/cross-project/-/work_items/1\">Reference</a>''
+ context 'when cross-project URL in link href' do
+ let_it_be(:work_item, reload: true) { create(:work_item, project: cross_project) }
+ let_it_be(:work_item_url) { item_url(work_item) }
+ let_it_be(:reference) { work_item_url }
+ let_it_be(:reference_link) { %(<a href="#{reference}">Reference</a>) }
+ let_it_be(:written_reference) { reference_link }
+ let_it_be(:inner_text) { 'Reference' }
+
+ it_behaves_like 'a work item reference'
+ end
+
+ context 'for group context' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:context) { { project: nil, group: group } }
+ let_it_be(:work_item_url) { item_url(work_item) }
+
+ it 'links to a valid reference for url cross-namespace' do
+ reference = "#{work_item_url}#note_123"
+
+ doc = reference_filter("See #{reference}", context)
+
+ link = doc.css('a').first
+ expect(link.attr('href')).to eq("#{work_item_url}#note_123")
+ expect(link.text).to include("#{project.full_path}##{work_item.iid}")
+ end
+
+ it 'links to a valid reference for cross-namespace in link href' do
+ reference = "#{work_item_url}#note_123"
+ reference_link = %(<a href="#{reference}">Reference</a>)
+
+ doc = reference_filter("See #{reference_link}", context)
+
+ link = doc.css('a').first
+ expect(link.attr('href')).to eq("#{work_item_url}#note_123")
+ expect(link.text).to include('Reference')
+ end
+ end
+
+ describe 'performance' do
+ let(:another_work_item) { create(:work_item, project: project) }
+
+ it 'does not have a N+1 query problem' do
+ single_reference = "Work item #{work_item.to_reference}"
+ multiple_references = "Work items #{work_item.to_reference} and #{another_work_item.to_reference}"
+
+ control_count = ActiveRecord::QueryRecorder.new { reference_filter(single_reference).to_html }.count
+
+ expect { reference_filter(multiple_references).to_html }.not_to exceed_query_limit(control_count)
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/repository_link_filter_spec.rb b/spec/lib/banzai/filter/repository_link_filter_spec.rb
index b2162ea2756..b6966709f5c 100644
--- a/spec/lib/banzai/filter/repository_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/repository_link_filter_spec.rb
@@ -369,7 +369,18 @@ RSpec.describe Banzai::Filter::RepositoryLinkFilter, feature_category: :team_pla
end
end
- context 'with a valid commit' do
+ context 'when public project repo with a valid commit' do
+ include_examples 'valid repository'
+ end
+
+ context 'when private project repo with a valid commit' do
+ let_it_be(:project) { create(:project, :repository, :private) }
+
+ before do
+ # user must have `read_code` ability
+ project.add_developer(user)
+ end
+
include_examples 'valid repository'
end
diff --git a/spec/lib/banzai/filter/suggestion_filter_spec.rb b/spec/lib/banzai/filter/suggestion_filter_spec.rb
index e65a9214e76..12cd411a613 100644
--- a/spec/lib/banzai/filter/suggestion_filter_spec.rb
+++ b/spec/lib/banzai/filter/suggestion_filter_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe Banzai::Filter::SuggestionFilter, feature_category: :team_plannin
end
context 'multi-line suggestions' do
- let(:data_attr) { Banzai::Filter::SyntaxHighlightFilter::LANG_PARAMS_ATTR }
+ let(:data_attr) { Banzai::Filter::CodeLanguageFilter::LANG_PARAMS_ATTR }
let(:input) { %(<pre class="code highlight js-syntax-highlight language-suggestion" #{data_attr}="-3+2"><code>foo\n</code></pre>) }
it 'element has correct data-lang-params' do
diff --git a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb
index 0d7f322d08f..4aacebe6024 100644
--- a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb
+++ b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter, feature_category: :team_pl
it "escapes HTML tags" do
# This is how a script tag inside a code block is presented to this filter
# after Markdown rendering.
- result = filter(%{<pre lang="#{lang}"><code>&lt;script&gt;alert(1)&lt;/script&gt;</code></pre>})
+ result = filter(%{<pre data-canonical-lang="#{lang}"><code>&lt;script&gt;alert(1)&lt;/script&gt;</code></pre>})
# `(1)` symbols are wrapped by lexer tags.
expect(result.to_html).not_to match(%r{<script>alert.*</script>})
@@ -23,7 +23,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter, feature_category: :team_pl
it "highlights as plaintext" do
result = filter('<pre><code>def fun end</code></pre>')
- expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre lang="plaintext" class="code highlight js-syntax-highlight language-plaintext" data-canonical-lang="" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">def fun end</span></code></pre><copy-code></copy-code></div>')
+ expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">def fun end</span></code></pre><copy-code></copy-code></div>')
end
include_examples "XSS prevention", ""
@@ -31,9 +31,9 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter, feature_category: :team_pl
context "when contains mermaid diagrams" do
it "ignores mermaid blocks" do
- result = filter('<pre data-mermaid-style="display" lang="mermaid"><code class="js-render-mermaid">mermaid code</code></pre>')
+ result = filter('<pre data-mermaid-style="display" data-canonical-lang="mermaid"><code class="js-render-mermaid">mermaid code</code></pre>')
- expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-mermaid-style="display" lang="mermaid" class="code highlight js-syntax-highlight language-mermaid" v-pre="true"><code class="js-render-mermaid"><span id="LC1" class="line" lang="mermaid">mermaid code</span></code></pre><copy-code></copy-code></div>')
+ expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-mermaid-style="display" data-canonical-lang="mermaid" class="code highlight js-syntax-highlight language-mermaid" lang="mermaid" v-pre="true"><code class="js-render-mermaid"><span id="LC1" class="line" lang="mermaid">mermaid code</span></code></pre><copy-code></copy-code></div>')
end
end
@@ -62,15 +62,15 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter, feature_category: :team_pl
text = "<div>\n<pre><code>\nsomething\n<pre><code>else\n</code></pre></code></pre>\n</div>"
result = filter(text)
- expect(result.to_html.delete("\n")).to eq('<div><div class="gl-relative markdown-code-block js-markdown-code"><pre lang="plaintext" class="code highlight js-syntax-highlight language-plaintext" data-canonical-lang="" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"></span><span id="LC2" class="line" lang="plaintext">something</span><span id="LC3" class="line" lang="plaintext">else</span></code></pre><copy-code></copy-code></div></div>')
+ expect(result.to_html.delete("\n")).to eq('<div><div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"></span><span id="LC2" class="line" lang="plaintext">something</span><span id="LC3" class="line" lang="plaintext">else</span></code></pre><copy-code></copy-code></div></div>')
end
end
context "when a valid language is specified" do
it "highlights as that language" do
- result = filter('<pre lang="ruby"><code>def fun end</code></pre>')
+ result = filter('<pre data-canonical-lang="ruby"><code>def fun end</code></pre>')
- expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre lang="ruby" class="code highlight js-syntax-highlight language-ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></span></code></pre><copy-code></copy-code></div>')
+ expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-canonical-lang="ruby" class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></span></code></pre><copy-code></copy-code></div>')
end
include_examples "XSS prevention", "ruby"
@@ -78,88 +78,40 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter, feature_category: :team_pl
context "when an invalid language is specified" do
it "highlights as plaintext" do
- result = filter('<pre lang="gnuplot"><code>This is a test</code></pre>')
+ result = filter('<pre data-canonical-lang="gnuplot"><code>This is a test</code></pre>')
- expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre lang="plaintext" class="code highlight js-syntax-highlight language-plaintext" data-canonical-lang="gnuplot" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>')
+ expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-canonical-lang="gnuplot" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>')
end
include_examples "XSS prevention", "gnuplot"
end
context "languages that should be passed through" do
- let(:delimiter) { described_class::LANG_PARAMS_DELIMITER }
- let(:data_attr) { described_class::LANG_PARAMS_ATTR }
-
%w(math mermaid plantuml suggestion).each do |lang|
context "when #{lang} is specified" do
it "highlights as plaintext but with the correct language attribute and class" do
- result = filter(%{<pre lang="#{lang}"><code>This is a test</code></pre>})
- copy_code_btn = '<copy-code></copy-code>' unless lang == 'suggestion'
-
- expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre lang="#{lang}" class="code highlight js-syntax-highlight language-#{lang}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre>#{copy_code_btn}</div>})
- end
-
- include_examples "XSS prevention", lang
- end
-
- context "when #{lang} has extra params" do
- let(:lang_params) { 'foo-bar-kux' }
- let(:xss_lang) { "#{lang} data-meta=\"foo-bar-kux\"&lt;script&gt;alert(1)&lt;/script&gt;" }
-
- it "includes data-lang-params tag with extra information" do
- result = filter(%{<pre lang="#{lang}" data-meta="#{lang_params}"><code>This is a test</code></pre>})
+ result = filter(%{<pre data-canonical-lang="#{lang}"><code>This is a test</code></pre>})
copy_code_btn = '<copy-code></copy-code>' unless lang == 'suggestion'
- expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre lang="#{lang}" class="code highlight js-syntax-highlight language-#{lang}" #{data_attr}="#{lang_params}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre>#{copy_code_btn}</div>})
+ expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre data-canonical-lang="#{lang}" class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre>#{copy_code_btn}</div>})
end
include_examples "XSS prevention", lang
-
- include_examples "XSS prevention",
- "#{lang} data-meta=\"foo-bar-kux\"&lt;script&gt;alert(1)&lt;/script&gt;"
-
- include_examples "XSS prevention",
- "#{lang} data-meta=\"foo-bar-kux\"<script>alert(1)</script>"
- end
- end
-
- context 'when multiple param delimiters are used' do
- let(:lang) { 'suggestion' }
- let(:lang_params) { '-1+10' }
-
- let(:expected_result) do
- %{<div class="gl-relative markdown-code-block js-markdown-code"><pre lang="#{lang}" class="code highlight js-syntax-highlight language-#{lang}" #{data_attr}="#{lang_params} more-things" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre></div>}
- end
-
- context 'when delimiter is space' do
- it 'delimits on the first appearance' do
- result = filter(%{<pre lang="#{lang}" data-meta="#{lang_params} more-things"><code>This is a test</code></pre>})
-
- expect(result.to_html.delete("\n")).to eq(expected_result)
- end
- end
-
- context 'when delimiter is colon' do
- it 'delimits on the first appearance' do
- result = filter(%{<pre lang="#{lang}#{delimiter}#{lang_params} more-things"><code>This is a test</code></pre>})
-
- expect(result.to_html.delete("\n")).to eq(expected_result)
- end
end
end
end
context "when sourcepos metadata is available" do
it "includes it in the highlighted code block" do
- result = filter('<pre data-sourcepos="1:1-3:3"><code lang="plaintext">This is a test</code></pre>')
+ result = filter('<pre data-sourcepos="1:1-3:3" data-canonical-lang="plaintext"><code>This is a test</code></pre>')
- expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-sourcepos="1:1-3:3" lang="plaintext" class="code highlight js-syntax-highlight language-plaintext" data-canonical-lang="" v-pre="true"><code lang="plaintext"><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>')
+ expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-sourcepos="1:1-3:3" data-canonical-lang="plaintext" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>')
end
it "escape sourcepos metadata to prevent XSS" do
result = filter('<pre data-sourcepos="&#34;%22 href=&#34;x&#34;></pre><base href=http://unsafe-website.com/><pre x=&#34;"><code></code></pre>')
- expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-sourcepos=\'"%22 href="x"&gt;&lt;/pre&gt;&lt;base href=http://unsafe-website.com/&gt;&lt;pre x="\' lang="plaintext" class="code highlight js-syntax-highlight language-plaintext" data-canonical-lang="" v-pre="true"><code></code></pre><copy-code></copy-code></div>')
+ expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-sourcepos=\'"%22 href="x"&gt;&lt;/pre&gt;&lt;base href=http://unsafe-website.com/&gt;&lt;pre x="\' class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code></code></pre><copy-code></copy-code></div>')
end
end
@@ -171,9 +123,9 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter, feature_category: :team_pl
end
it "highlights as plaintext" do
- result = filter('<pre lang="ruby"><code>This is a test</code></pre>')
+ result = filter('<pre data-canonical-lang="ruby"><code>This is a test</code></pre>')
- expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre lang="" class="code highlight js-syntax-highlight" data-canonical-lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="">This is a test</span></code></pre><copy-code></copy-code></div>')
+ expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-canonical-lang="ruby" class="code highlight js-syntax-highlight" lang="" v-pre="true"><code><span id="LC1" class="line" lang="">This is a test</span></code></pre><copy-code></copy-code></div>')
end
include_examples "XSS prevention", "ruby"
@@ -195,7 +147,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter, feature_category: :team_pl
include_examples "XSS prevention", "ruby"
end
- it_behaves_like "filter timeout" do
- let(:text) { '<pre lang="ruby"><code>def fun end</code></pre>' }
+ it_behaves_like "html filter timeout" do
+ let(:text) { '<pre data-canonical-lang="ruby"><code>def fun end</code></pre>' }
end
end
diff --git a/spec/lib/banzai/filter/timeout_html_pipeline_filter_spec.rb b/spec/lib/banzai/filter/timeout_html_pipeline_filter_spec.rb
index 066f59758f0..52e4b70cfe5 100644
--- a/spec/lib/banzai/filter/timeout_html_pipeline_filter_spec.rb
+++ b/spec/lib/banzai/filter/timeout_html_pipeline_filter_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Banzai::Filter::TimeoutHtmlPipelineFilter, feature_category: :team_planning do
include FilterSpecHelper
- it_behaves_like 'filter timeout' do
+ it_behaves_like 'html filter timeout' do
let(:text) { '<p>some text</p>' }
end
diff --git a/spec/lib/banzai/filter/timeout_text_pipeline_filter_spec.rb b/spec/lib/banzai/filter/timeout_text_pipeline_filter_spec.rb
new file mode 100644
index 00000000000..4e22594389b
--- /dev/null
+++ b/spec/lib/banzai/filter/timeout_text_pipeline_filter_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::TimeoutTextPipelineFilter, feature_category: :team_planning do
+ include FilterSpecHelper
+
+ it_behaves_like 'text filter timeout' do
+ let(:text) { '<p>some text</p>' }
+ end
+
+ it 'raises NotImplementedError' do
+ expect { filter('test') }.to raise_error NotImplementedError
+ end
+end
diff --git a/spec/lib/banzai/issuable_extractor_spec.rb b/spec/lib/banzai/issuable_extractor_spec.rb
index b2c869bd066..5bbd98592e7 100644
--- a/spec/lib/banzai/issuable_extractor_spec.rb
+++ b/spec/lib/banzai/issuable_extractor_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe Banzai::IssuableExtractor, feature_category: :team_planning do
let(:user) { create(:user) }
let(:extractor) { described_class.new(Banzai::RenderContext.new(project, user)) }
let(:issue) { create(:issue, project: project) }
+ let(:work_item) { create(:work_item, project: project) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:issue_link) do
html_to_node(
@@ -14,6 +15,12 @@ RSpec.describe Banzai::IssuableExtractor, feature_category: :team_planning do
)
end
+ let(:work_item_link) do
+ html_to_node(
+ "<a href='' data-work-item='#{work_item.id}' data-reference-type='work_item' class='gfm'>text</a>"
+ )
+ end
+
let(:merge_request_link) do
html_to_node(
"<a href='' data-merge-request='#{merge_request.id}' data-reference-type='merge_request' class='gfm'>text</a>"
@@ -27,17 +34,17 @@ RSpec.describe Banzai::IssuableExtractor, feature_category: :team_planning do
end
it 'returns instances of issuables for nodes with references' do
- result = extractor.extract([issue_link, merge_request_link])
+ result = extractor.extract([issue_link, work_item_link, merge_request_link])
- expect(result).to eq(issue_link => issue, merge_request_link => merge_request)
+ expect(result).to eq(issue_link => issue, work_item_link => work_item, merge_request_link => merge_request)
end
describe 'caching', :request_store do
it 'saves records to cache' do
- extractor.extract([issue_link, merge_request_link])
+ extractor.extract([issue_link, work_item_link, merge_request_link])
second_call_queries = ActiveRecord::QueryRecorder.new do
- extractor.extract([issue_link, merge_request_link])
+ extractor.extract([issue_link, work_item_link, merge_request_link])
end.count
expect(second_call_queries).to eq 0
diff --git a/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb b/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb
index d0b85a1d043..58d6b9b9a2c 100644
--- a/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Banzai::Pipeline::GfmPipeline, feature_category: :team_planning do
describe 'integration between parsing regular and external issue references' do
- let(:project) { create(:project, :with_redmine_integration, :public) }
+ let_it_be(:project) { create(:project, :with_redmine_integration, :public) }
context 'when internal issue tracker is enabled' do
context 'when shorthand pattern #ISSUE_ID is used' do
diff --git a/spec/lib/banzai/pipeline/incident_management/timeline_event_pipeline_spec.rb b/spec/lib/banzai/pipeline/incident_management/timeline_event_pipeline_spec.rb
index 8d15dbc8f2f..12a6be6bc18 100644
--- a/spec/lib/banzai/pipeline/incident_management/timeline_event_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/incident_management/timeline_event_pipeline_spec.rb
@@ -78,7 +78,7 @@ RSpec.describe Banzai::Pipeline::IncidentManagement::TimelineEventPipeline do
it 'replaces existing label to a link' do
# rubocop:disable Layout/LineLength
is_expected.to match(
- %r(<p>.+<a href="[\w/]+-/issues\?label_name=#{label.name}".+style="background-color: #\d{6}".*>#{label.name}</span></a></span> ~unknown</p>)
+ %r{<p>.+<a href="[\w\-/]+-/issues\?label_name=#{label.name}".+style="background-color: #\d{6}".*>#{label.name}</span></a></span> ~unknown</p>}
)
# rubocop:enable Layout/LineLength
end
@@ -95,7 +95,7 @@ RSpec.describe Banzai::Pipeline::IncidentManagement::TimelineEventPipeline do
let(:markdown) { "issue ##{issue.iid}" }
it 'contains a link to the issue' do
- is_expected.to match(%r(<p>issue <a href="[\w/]+-/issues/#{issue.iid}".*>##{issue.iid}</a></p>))
+ is_expected.to match(%r{<p>issue <a href="[\w\-/]+-/issues/#{issue.iid}".*>##{issue.iid}</a></p>})
end
end
@@ -104,7 +104,7 @@ RSpec.describe Banzai::Pipeline::IncidentManagement::TimelineEventPipeline do
let(:markdown) { "MR !#{mr.iid}" }
it 'contains a link to the merge request' do
- is_expected.to match(%r(<p>MR <a href="[\w/]+-/merge_requests/#{mr.iid}".*>!#{mr.iid}</a></p>))
+ is_expected.to match(%r{<p>MR <a href="[\w\-/]+-/merge_requests/#{mr.iid}".*>!#{mr.iid}</a></p>})
end
end
end
diff --git a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb
index e7c15ed9cf6..b8d2b6f7d7e 100644
--- a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb
@@ -80,7 +80,7 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline, feature_category: :team_
let(:markdown) { %Q(``` foo\\@bar\nfoo\n```) }
it 'renders correct html' do
- correct_html_included(markdown, %Q(<pre data-sourcepos="1:1-3:3" lang="foo@bar"><code>foo\n</code></pre>))
+ correct_html_included(markdown, %Q(<pre lang="foo@bar"><code>foo\n</code></pre>))
end
where(:markdown, :expected) do
@@ -95,7 +95,7 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline, feature_category: :team_
end
def correct_html_included(markdown, expected)
- result = described_class.call(markdown, {})
+ result = described_class.call(markdown, { no_sourcepos: true })
expect(result[:output].to_html).to include(expected)
diff --git a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
index 837ea2d7bc0..81406eae0c9 100644
--- a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe Banzai::Pipeline::WikiPipeline, feature_category: :wiki do
- let_it_be(:namespace) { create(:namespace, name: "wiki_link_ns") }
- let_it_be(:project) { create(:project, :public, name: "wiki_link_project", namespace: namespace) }
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
let_it_be(:wiki) { ProjectWiki.new(project, nil) }
let_it_be(:page) { build(:wiki_page, wiki: wiki, title: 'nested/twice/start-page') }
@@ -85,14 +85,14 @@ RSpec.describe Banzai::Pipeline::WikiPipeline, feature_category: :wiki do
markdown = "[Page](./page)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/page\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/nested/twice/page\"")
end
it "rewrites file links to be at the scope of the current directory" do
markdown = "[Link to Page](./page.md)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/page.md\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/nested/twice/page.md\"")
end
end
@@ -101,14 +101,14 @@ RSpec.describe Banzai::Pipeline::WikiPipeline, feature_category: :wiki do
markdown = "[Link to Page](../page)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/nested/page\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/nested/page\"")
end
it "rewrites file links to be at the scope of the parent directory" do
markdown = "[Link to Page](../page.md)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/nested/page.md\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/nested/page.md\"")
end
end
@@ -117,14 +117,14 @@ RSpec.describe Banzai::Pipeline::WikiPipeline, feature_category: :wiki do
markdown = "[Link to Page](./subdirectory/page)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/subdirectory/page\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/nested/twice/subdirectory/page\"")
end
it "rewrites file links to be at the scope of the sub-directory" do
markdown = "[Link to Page](./subdirectory/page.md)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/subdirectory/page.md\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/nested/twice/subdirectory/page.md\"")
end
end
@@ -133,35 +133,35 @@ RSpec.describe Banzai::Pipeline::WikiPipeline, feature_category: :wiki do
markdown = "[Link to Page](page)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/page\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/page\"")
end
it 'rewrites non-file links (with spaces) to be at the scope of the wiki root' do
markdown = "[Link to Page](page slug)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/page%20slug\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/page%20slug\"")
end
it "rewrites file links to be at the scope of the current directory" do
markdown = "[Link to Page](page.md)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/page.md\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/nested/twice/page.md\"")
end
it 'rewrites links with anchor' do
markdown = '[Link to Header](start-page#title)'
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/start-page#title\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/start-page#title\"")
end
it 'rewrites links (with spaces) with anchor' do
markdown = '[Link to Header](start page#title)'
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/start%20page#title\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/start%20page#title\"")
end
end
@@ -170,14 +170,14 @@ RSpec.describe Banzai::Pipeline::WikiPipeline, feature_category: :wiki do
markdown = "[Link to Page](/page)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/page\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/page\"")
end
it 'rewrites file links to be at the scope of the wiki root' do
markdown = "[Link to Page](/page.md)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/-/wikis/page.md\"")
+ expect(output).to include("href=\"#{relative_url_root}/#{project.full_path}/-/wikis/page.md\"")
end
end
end
@@ -278,28 +278,28 @@ RSpec.describe Banzai::Pipeline::WikiPipeline, feature_category: :wiki do
markdown = "![video_file](video_file_name.mp4)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include('<video src="/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/video_file_name.mp4"')
+ expect(output).to include(%(<video src="/#{project.full_path}/-/wikis/nested/twice/video_file_name.mp4"))
end
it 'rewrites and replaces video links names with white spaces to %20' do
markdown = "![video file](video file name.mp4)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include('<video src="/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/video%20file%20name.mp4"')
+ expect(output).to include(%(<video src="/#{project.full_path}/-/wikis/nested/twice/video%20file%20name.mp4"))
end
it 'generates audio html structure' do
markdown = "![audio_file](audio_file_name.wav)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include('<audio src="/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/audio_file_name.wav"')
+ expect(output).to include(%(<audio src="/#{project.full_path}/-/wikis/nested/twice/audio_file_name.wav"))
end
it 'rewrites and replaces audio links names with white spaces to %20' do
markdown = "![audio file](audio file name.wav)"
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
- expect(output).to include('<audio src="/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/audio%20file%20name.wav"')
+ expect(output).to include(%(<audio src="/#{project.full_path}/-/wikis/nested/twice/audio%20file%20name.wav"))
end
end
@@ -320,7 +320,7 @@ RSpec.describe Banzai::Pipeline::WikiPipeline, feature_category: :wiki do
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
doc = Nokogiri::HTML::DocumentFragment.parse(output)
- full_path = "/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/#{wiki_file.path}"
+ full_path = "/#{project.full_path}/-/wikis/nested/twice/#{wiki_file.path}"
expect(doc.css('a')[0].attr('href')).to eq(full_path)
expect(doc.css('img')[0].attr('class')).to eq('gfm lazy')
expect(doc.css('img')[0].attr('data-src')).to eq(full_path)
diff --git a/spec/lib/banzai/reference_parser/commit_parser_spec.rb b/spec/lib/banzai/reference_parser/commit_parser_spec.rb
index 081bfa26fb2..7a1eed2e35e 100644
--- a/spec/lib/banzai/reference_parser/commit_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/commit_parser_spec.rb
@@ -5,8 +5,9 @@ require 'spec_helper'
RSpec.describe Banzai::ReferenceParser::CommitParser, feature_category: :source_code_management do
include ReferenceParserHelpers
- let(:project) { create(:project, :public) }
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:user) { create(:user) }
+
subject { described_class.new(Banzai::RenderContext.new(project, user)) }
let(:link) { empty_html_link }
@@ -130,20 +131,28 @@ RSpec.describe Banzai::ReferenceParser::CommitParser, feature_category: :source_
end
describe '#find_commits' do
- it 'returns an Array of commit objects' do
- commit = double(:commit)
+ let_it_be(:ids) { project.repository.commits(project.default_branch, limit: 3).map(&:id) }
+
+ it 'is empty when repo is invalid' do
+ allow(project).to receive(:valid_repo?).and_return(false)
- expect(project).to receive(:commit).with('123').and_return(commit)
- expect(project).to receive(:valid_repo?).and_return(true)
+ expect(subject.find_commits(project, ids)).to eq([])
+ end
- expect(subject.find_commits(project, %w{123})).to eq([commit])
+ it 'returns commits by the specified ids' do
+ expect(subject.find_commits(project, ids).map(&:id)).to eq(%w[
+ b83d6e391c22777fca1ed3012fce84f633d7fed0
+ 498214de67004b1da3d820901307bed2a68a8ef6
+ 1b12f15a11fc6e62177bef08f47bc7b5ce50b141
+ ])
end
- it 'skips commit IDs for which no commit could be found' do
- expect(project).to receive(:commit).with('123').and_return(nil)
- expect(project).to receive(:valid_repo?).and_return(true)
+ it 'is limited' do
+ stub_const("#{described_class}::COMMITS_LIMIT", 1)
- expect(subject.find_commits(project, %w{123})).to eq([])
+ expect(subject.find_commits(project, ids).map(&:id)).to eq([
+ "b83d6e391c22777fca1ed3012fce84f633d7fed0"
+ ])
end
end
diff --git a/spec/lib/banzai/reference_parser/work_item_parser_spec.rb b/spec/lib/banzai/reference_parser/work_item_parser_spec.rb
new file mode 100644
index 00000000000..dbde01cc94f
--- /dev/null
+++ b/spec/lib/banzai/reference_parser/work_item_parser_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::ReferenceParser::WorkItemParser, feature_category: :team_planning do
+ include ReferenceParserHelpers
+
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be_with_reload(:project) { create(:project, :public, group: group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:work_item) { create(:work_item, project: project) }
+ let_it_be(:link) { empty_html_link }
+
+ subject { described_class.new(Banzai::RenderContext.new(project, user)) }
+
+ describe '#records_for_nodes' do
+ it 'returns a Hash containing the work items for a list of nodes' do
+ link['data-work-item'] = work_item.id.to_s
+ nodes = [link]
+
+ expect(subject.records_for_nodes(nodes)).to eq({ link => work_item })
+ end
+ end
+
+ context 'when checking multiple work items on another project' do
+ let_it_be(:other_project) { create(:project, :public) }
+ let_it_be(:other_work_item) { create(:work_item, project: other_project) }
+ let_it_be(:control_links) do
+ [work_item_link(other_work_item)]
+ end
+
+ let_it_be(:actual_links) do
+ control_links + [work_item_link(create(:work_item, project: other_project))]
+ end
+
+ def work_item_link(work_item)
+ Nokogiri::HTML.fragment(%(<a data-work-item="#{work_item.id}"></a>)).children[0]
+ end
+
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'no N+1 queries'
+ end
+end
diff --git a/spec/lib/banzai/reference_redactor_spec.rb b/spec/lib/banzai/reference_redactor_spec.rb
index 8a8f3ce586a..21736903cbf 100644
--- a/spec/lib/banzai/reference_redactor_spec.rb
+++ b/spec/lib/banzai/reference_redactor_spec.rb
@@ -111,13 +111,16 @@ RSpec.describe Banzai::ReferenceRedactor, feature_category: :team_planning do
def create_link(issuable)
type = issuable.class.name.underscore.downcase
- ActionController::Base.helpers.link_to(issuable.to_reference, '',
- class: 'gfm has-tooltip',
- title: issuable.title,
- data: {
- reference_type: type,
- "#{type}": issuable.id
- })
+ ActionController::Base.helpers.link_to(
+ issuable.to_reference,
+ '',
+ class: 'gfm has-tooltip',
+ title: issuable.title,
+ data: {
+ reference_type: type,
+ "#{type}": issuable.id
+ }
+ )
end
before do
diff --git a/spec/lib/banzai/renderer_spec.rb b/spec/lib/banzai/renderer_spec.rb
index 8c9d8d51d5f..ef503b8ec52 100644
--- a/spec/lib/banzai/renderer_spec.rb
+++ b/spec/lib/banzai/renderer_spec.rb
@@ -87,15 +87,11 @@ RSpec.describe Banzai::Renderer, feature_category: :team_planning do
describe '#cacheless_render' do
context 'without cache' do
let(:object) { fake_object(fresh: false) }
- let(:histogram) { double('prometheus histogram') }
it 'returns cacheless render field' do
allow(renderer).to receive(:render_result).and_return(output: 'test')
- allow(renderer).to receive(:real_duration_histogram).and_return(histogram)
- allow(renderer).to receive(:cpu_duration_histogram).and_return(histogram)
expect(renderer).to receive(:render_result).with('test', {})
- expect(histogram).to receive(:observe).twice
renderer.cacheless_render('test')
end
diff --git a/spec/lib/bulk_imports/clients/graphql_spec.rb b/spec/lib/bulk_imports/clients/graphql_spec.rb
index 58e6992698c..9bb37a7c438 100644
--- a/spec/lib/bulk_imports/clients/graphql_spec.rb
+++ b/spec/lib/bulk_imports/clients/graphql_spec.rb
@@ -8,39 +8,8 @@ RSpec.describe BulkImports::Clients::Graphql, feature_category: :importers do
subject { described_class.new(url: config.url, token: config.access_token) }
describe '#execute' do
- let(:query) { '{ metadata { version } }' }
let(:graphql_client_double) { double }
let(:response_double) { double }
- let(:version) { '14.0.0' }
-
- before do
- stub_const('BulkImports::MINIMUM_COMPATIBLE_MAJOR_VERSION', version)
- end
-
- describe 'source instance validation' do
- before do
- allow(graphql_client_double).to receive(:execute)
- allow(subject).to receive(:client).and_return(graphql_client_double)
- allow(graphql_client_double).to receive(:execute).with(query).and_return(response_double)
- allow(response_double).to receive_message_chain(:data, :metadata, :version).and_return(version)
- end
-
- context 'when source instance is compatible' do
- it 'marks source instance as compatible' do
- subject.execute('test')
-
- expect(subject.instance_variable_get(:@compatible_instance_version)).to eq(true)
- end
- end
-
- context 'when source instance is incompatible' do
- let(:version) { '13.0.0' }
-
- it 'raises an error' do
- expect { subject.execute('test') }.to raise_error(::BulkImports::Error, "Unsupported GitLab version. Source instance must run GitLab version #{BulkImport::MIN_MAJOR_VERSION} or later.")
- end
- end
- end
describe 'network errors' do
before do
diff --git a/spec/lib/bulk_imports/clients/http_spec.rb b/spec/lib/bulk_imports/clients/http_spec.rb
index 780f61f8c61..aff049408e2 100644
--- a/spec/lib/bulk_imports/clients/http_spec.rb
+++ b/spec/lib/bulk_imports/clients/http_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe BulkImports::Clients::HTTP, feature_category: :importers do
let(:resource) { 'resource' }
let(:version) { "#{BulkImport::MIN_MAJOR_VERSION}.0.0" }
let(:enterprise) { false }
+ let(:sidekiq_request_timeout) { described_class::SIDEKIQ_REQUEST_TIMEOUT }
let(:response_double) { double(code: 200, success?: true, parsed_response: {}) }
let(:metadata_response) do
double(
@@ -123,6 +124,36 @@ RSpec.describe BulkImports::Clients::HTTP, feature_category: :importers do
allow(Gitlab::HTTP).to receive(:get).with(uri, params).and_return(response)
end
end
+
+ context 'when the request is asynchronous' do
+ let(:expected_args) do
+ [
+ 'http://gitlab.example/api/v4/resource',
+ hash_including(
+ query: {
+ page: described_class::DEFAULT_PAGE,
+ per_page: described_class::DEFAULT_PER_PAGE,
+ private_token: token
+ },
+ headers: {
+ 'Content-Type' => 'application/json'
+ },
+ follow_redirects: true,
+ resend_on_redirect: false,
+ limit: 2,
+ timeout: sidekiq_request_timeout
+ )
+ ]
+ end
+
+ it 'sets a timeout that is double the default read timeout' do
+ allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true)
+
+ expect(Gitlab::HTTP).to receive(method).with(*expected_args).and_return(response_double)
+
+ subject.public_send(method, resource)
+ end
+ end
end
describe '#post' do
@@ -230,7 +261,7 @@ RSpec.describe BulkImports::Clients::HTTP, feature_category: :importers do
.to_return(status: 401, body: "", headers: { 'Content-Type' => 'application/json' })
expect { subject.instance_version }.to raise_exception(BulkImports::Error,
- "Import aborted as the provided personal access token does not have the required 'api' scope or " \
+ "Personal access token does not have the required 'api' scope or " \
"is no longer valid.")
end
end
@@ -242,7 +273,7 @@ RSpec.describe BulkImports::Clients::HTTP, feature_category: :importers do
.to_return(status: 403, body: "", headers: { 'Content-Type' => 'application/json' })
expect { subject.instance_version }.to raise_exception(BulkImports::Error,
- "Import aborted as the provided personal access token does not have the required 'api' scope or " \
+ "Personal access token does not have the required 'api' scope or " \
"is no longer valid.")
end
end
@@ -253,7 +284,7 @@ RSpec.describe BulkImports::Clients::HTTP, feature_category: :importers do
stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
.to_return(status: 404, body: "", headers: { 'Content-Type' => 'application/json' })
- expect { subject.instance_version }.to raise_exception(BulkImports::Error, 'Import aborted as it was not possible to connect to the provided GitLab instance URL.')
+ expect { subject.instance_version }.to raise_exception(BulkImports::Error, 'Invalid source URL. Enter only the base URL of the source GitLab instance.')
end
end
diff --git a/spec/lib/bulk_imports/features_spec.rb b/spec/lib/bulk_imports/features_spec.rb
deleted file mode 100644
index a92e4706bbe..00000000000
--- a/spec/lib/bulk_imports/features_spec.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe BulkImports::Features do
- describe '.project_migration_enabled' do
- let_it_be(:top_level_namespace) { create(:group) }
-
- context 'when bulk_import_projects feature flag is enabled' do
- it 'returns true' do
- stub_feature_flags(bulk_import_projects: true)
-
- expect(described_class.project_migration_enabled?).to eq(true)
- end
-
- context 'when feature flag is enabled on root ancestor level' do
- it 'returns true' do
- stub_feature_flags(bulk_import_projects: top_level_namespace)
-
- expect(described_class.project_migration_enabled?(top_level_namespace.full_path)).to eq(true)
- end
- end
-
- context 'when feature flag is enabled on a different top level namespace' do
- it 'returns false' do
- stub_feature_flags(bulk_import_projects: top_level_namespace)
-
- different_namepace = create(:group)
-
- expect(described_class.project_migration_enabled?(different_namepace.full_path)).to eq(false)
- end
- end
- end
-
- context 'when bulk_import_projects feature flag is disabled' do
- it 'returns false' do
- stub_feature_flags(bulk_import_projects: false)
-
- expect(described_class.project_migration_enabled?(top_level_namespace.full_path)).to eq(false)
- end
- 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
index 395f3568913..0155dc8053e 100644
--- a/spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb
@@ -17,18 +17,18 @@ RSpec.describe BulkImports::Groups::Pipelines::ProjectEntitiesPipeline, feature_
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: {
- 'id' => 'gid://gitlab/Project/1234567',
- 'name' => 'My Project',
- 'path' => 'my-project',
- 'full_path' => 'group/my-project'
- })
- end
-
subject { described_class.new(context) }
describe '#run' do
+ let(:extracted_data) do
+ BulkImports::Pipeline::ExtractedData.new(data: {
+ 'id' => 'gid://gitlab/Project/1234567',
+ 'name' => 'My Project',
+ 'path' => 'my-project',
+ 'full_path' => 'group/my-project'
+ })
+ end
+
before do
allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor|
allow(extractor).to receive(:extract).and_return(extracted_data)
diff --git a/spec/lib/bulk_imports/groups/stage_spec.rb b/spec/lib/bulk_imports/groups/stage_spec.rb
index cc772f07d21..7c3127beb97 100644
--- a/spec/lib/bulk_imports/groups/stage_spec.rb
+++ b/spec/lib/bulk_imports/groups/stage_spec.rb
@@ -68,40 +68,16 @@ RSpec.describe BulkImports::Groups::Stage, feature_category: :importers do
end
end
- context 'when bulk_import_projects feature flag is enabled' do
- it 'includes project entities pipeline' do
- stub_feature_flags(bulk_import_projects: true)
-
- expect(described_class.new(entity).pipelines).to include(
- hash_including({ pipeline: BulkImports::Groups::Pipelines::ProjectEntitiesPipeline })
- )
- end
-
- describe 'migrate projects flag' do
- context 'when true' do
- it 'includes project entities pipeline' do
- entity.update!(migrate_projects: true)
-
- expect(described_class.new(entity).pipelines).to include(
- hash_including({ pipeline: BulkImports::Groups::Pipelines::ProjectEntitiesPipeline })
- )
- end
- end
-
- context 'when false' do
- it 'does not include project entities pipeline' do
- entity.update!(migrate_projects: false)
-
- expect(described_class.new(entity).pipelines).not_to include(
- hash_including({ pipeline: BulkImports::Groups::Pipelines::ProjectEntitiesPipeline })
- )
- end
- end
- end
+ it 'includes project entities pipeline' do
+ expect(described_class.new(entity).pipelines).to include(
+ hash_including({ pipeline: BulkImports::Groups::Pipelines::ProjectEntitiesPipeline })
+ )
+ end
- context 'when feature flag is enabled on root ancestor level' do
+ describe 'migrate projects flag' do
+ context 'when true' do
it 'includes project entities pipeline' do
- stub_feature_flags(bulk_import_projects: ancestor)
+ entity.update!(migrate_projects: true)
expect(described_class.new(entity).pipelines).to include(
hash_including({ pipeline: BulkImports::Groups::Pipelines::ProjectEntitiesPipeline })
@@ -109,24 +85,22 @@ RSpec.describe BulkImports::Groups::Stage, feature_category: :importers do
end
end
- context 'when destination namespace is not present' do
- it 'includes project entities pipeline' do
- stub_feature_flags(bulk_import_projects: true)
-
- entity = create(:bulk_import_entity, destination_namespace: '')
+ context 'when false' do
+ it 'does not include project entities pipeline' do
+ entity.update!(migrate_projects: false)
- expect(described_class.new(entity).pipelines).to include(
+ expect(described_class.new(entity).pipelines).not_to include(
hash_including({ pipeline: BulkImports::Groups::Pipelines::ProjectEntitiesPipeline })
)
end
end
end
- context 'when bulk_import_projects feature flag is disabled' do
- it 'does not include project entities pipeline' do
- stub_feature_flags(bulk_import_projects: false)
+ context 'when destination namespace is not present' do
+ it 'includes project entities pipeline' do
+ entity = create(:bulk_import_entity, destination_namespace: '')
- expect(described_class.new(entity).pipelines).not_to include(
+ expect(described_class.new(entity).pipelines).to include(
hash_including({ pipeline: BulkImports::Groups::Pipelines::ProjectEntitiesPipeline })
)
end
diff --git a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
index 138a92a7e6b..9782f2aac27 100644
--- a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
+++ b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
@@ -85,6 +85,22 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer, fe
end
end
+ context 'when the destination_slug has invalid characters' do
+ let(:entity) do
+ build_stubbed(
+ :bulk_import_entity,
+ bulk_import: bulk_import,
+ source_full_path: 'source/full/path',
+ destination_slug: '____destination-_slug-path----__',
+ destination_namespace: destination_namespace
+ )
+ end
+
+ it 'normalizes the path' do
+ expect(transformed_data[:path]).to eq('destination-slug-path')
+ end
+ end
+
describe 'parent group transformation' do
it 'sets parent id' do
expect(transformed_data['parent_id']).to eq(destination_group.id)
@@ -101,45 +117,62 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer, fe
end
end
- describe 'group name transformation' do
- context 'when destination namespace is empty' do
- before do
- entity.destination_namespace = ''
- end
+ context 'when destination namespace is empty' do
+ before do
+ entity.destination_namespace = ''
+ end
+
+ it 'does not transform name' do
+ expect(transformed_data['name']).to eq('Source Group Name')
+ end
+ end
+ context 'when destination namespace is present' do
+ context 'when destination namespace does not have a group or project with same path' do
it 'does not transform name' do
expect(transformed_data['name']).to eq('Source Group Name')
end
end
- context 'when destination namespace is present' do
- context 'when destination namespace does not have a group with same name' do
- it 'does not transform name' do
- expect(transformed_data['name']).to eq('Source Group Name')
- end
+ context 'when destination namespace already has a group or project with the same name' do
+ before do
+ create(:project, group: destination_group, name: 'Source Project Name', path: 'project')
+ create(:group, parent: destination_group, name: 'Source Group Name', path: 'group')
+ create(:group, parent: destination_group, name: 'Source Group Name_1', path: 'group_1')
+ create(:group, parent: destination_group, name: 'Source Group Name_2', path: 'group_2')
end
- context 'when destination namespace already have a group with the same name' do
- before do
- create(:group, parent: destination_group, name: 'Source Group Name', path: 'group_1')
- create(:group, parent: destination_group, name: 'Source Group Name(1)', path: 'group_2')
- create(:group, parent: destination_group, name: 'Source Group Name(2)', path: 'group_3')
- create(:group, parent: destination_group, name: 'Source Group Name(1)(1)', path: 'group_4')
- end
+ it 'makes the name unique by appending a counter', :aggregate_failures do
+ transformed_data = described_class.new.transform(context, data.merge('name' => 'Source Group Name'))
+ expect(transformed_data['name']).to eq('Source Group Name_3')
- it 'makes the name unique by appeding a counter', :aggregate_failures do
- transformed_data = described_class.new.transform(context, data.merge('name' => 'Source Group Name'))
- expect(transformed_data['name']).to eq('Source Group Name(3)')
+ transformed_data = described_class.new.transform(context, data.merge('name' => 'Source Group Name_1'))
+ expect(transformed_data['name']).to eq('Source Group Name_1_1')
- transformed_data = described_class.new.transform(context, data.merge('name' => 'Source Group Name(2)'))
- expect(transformed_data['name']).to eq('Source Group Name(2)(1)')
+ transformed_data = described_class.new.transform(context, data.merge('name' => 'Source Group Name_2'))
+ expect(transformed_data['name']).to eq('Source Group Name_2_1')
- transformed_data = described_class.new.transform(context, data.merge('name' => 'Source Group Name(1)'))
- expect(transformed_data['name']).to eq('Source Group Name(1)(2)')
+ transformed_data = described_class.new.transform(context, data.merge('name' => 'Source Project Name'))
+ expect(transformed_data['name']).to eq('Source Project Name_1')
+ end
+ end
- transformed_data = described_class.new.transform(context, data.merge('name' => 'Source Group Name(1)(1)'))
- expect(transformed_data['name']).to eq('Source Group Name(1)(1)(1)')
- end
+ context 'when destination namespace already has a group or project with the same path' do
+ before do
+ create(:project, group: destination_group, name: 'Source Project Name', path: 'destination-slug-path')
+ create(:group, parent: destination_group, name: 'Source Group Name_4', path: 'destination-slug-path_4')
+ create(:group, parent: destination_group, name: 'Source Group Name_2', path: 'destination-slug-path_2')
+ create(:group, parent: destination_group, name: 'Source Group Name_3', path: 'destination-slug-path_3')
+ end
+
+ it 'makes the path unique by appending a counter', :aggregate_failures do
+ transformed_data = described_class.new.transform(context, data)
+ expect(transformed_data['path']).to eq('destination-slug-path_1')
+
+ create(:group, parent: destination_group, name: 'Source Group Name_1', path: 'destination-slug-path_1')
+
+ transformed_data = described_class.new.transform(context, data)
+ expect(transformed_data['path']).to eq('destination-slug-path_5')
end
end
end
@@ -148,6 +181,49 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer, fe
subject(:transformed_data) { described_class.new.transform(context, data) }
include_examples 'visibility level settings'
+
+ context 'when destination is blank' do
+ let(:destination_namespace) { '' }
+
+ context 'when visibility level is public' do
+ let(:data) { { 'visibility' => 'public' } }
+
+ it 'sets visibility level to public' do
+ expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::PUBLIC)
+ end
+ end
+
+ context 'when when visibility level is internal' do
+ let(:data) { { 'visibility' => 'internal' } }
+
+ it 'sets visibility level to internal' do
+ expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::INTERNAL)
+ end
+ end
+
+ context 'when private' do
+ let(:data) { { 'visibility' => 'private' } }
+
+ it 'sets visibility level to private' do
+ expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::PRIVATE)
+ end
+ end
+
+ context 'when visibility level is restricted' do
+ let(:data) { { 'visibility' => 'internal' } }
+
+ it 'sets visibility level to private' do
+ stub_application_setting(
+ restricted_visibility_levels: [
+ Gitlab::VisibilityLevel::INTERNAL,
+ Gitlab::VisibilityLevel::PUBLIC
+ ]
+ )
+
+ expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::PRIVATE)
+ end
+ end
+ end
end
end
end
diff --git a/spec/lib/bulk_imports/ndjson_pipeline_spec.rb b/spec/lib/bulk_imports/ndjson_pipeline_spec.rb
index 25edc9feea8..29f42ab3366 100644
--- a/spec/lib/bulk_imports/ndjson_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/ndjson_pipeline_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::NdjsonPipeline do
+RSpec.describe BulkImports::NdjsonPipeline, feature_category: :importers do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
@@ -150,13 +150,63 @@ RSpec.describe BulkImports::NdjsonPipeline do
describe '#load' do
context 'when object is not persisted' do
+ it 'saves the object using RelationObjectSaver' do
+ object = double(persisted?: false, new_record?: true)
+
+ allow(subject).to receive(:relation_definition)
+
+ expect_next_instance_of(Gitlab::ImportExport::Base::RelationObjectSaver) do |saver|
+ expect(saver).to receive(:execute)
+ end
+
+ subject.load(nil, object)
+ end
+
+ context 'when object is invalid' do
+ it 'captures invalid subrelations' do
+ entity = create(:bulk_import_entity, group: group)
+ tracker = create(:bulk_import_tracker, entity: entity)
+ context = BulkImports::Pipeline::Context.new(tracker)
+
+ allow(subject).to receive(:context).and_return(context)
+
+ object = group.labels.new(priorities: [LabelPriority.new])
+ object.validate
+
+ allow_next_instance_of(Gitlab::ImportExport::Base::RelationObjectSaver) do |saver|
+ allow(saver).to receive(:execute)
+ allow(saver).to receive(:invalid_subrelations).and_return(object.priorities)
+ end
+
+ subject.load(context, object)
+
+ failure = entity.failures.first
+
+ expect(failure.pipeline_class).to eq(tracker.pipeline_name)
+ expect(failure.exception_class).to eq('RecordInvalid')
+ expect(failure.exception_message).to eq("Project can't be blank, Priority can't be blank, and Priority is not a number")
+ end
+ end
+ end
+
+ context 'when object is persisted' do
it 'saves the object' do
- object = double(persisted?: false)
+ object = double(new_record?: false, invalid?: false)
expect(object).to receive(:save!)
subject.load(nil, object)
end
+
+ context 'when object is invalid' do
+ it 'raises ActiveRecord::RecordInvalid exception' do
+ object = build_stubbed(:issue)
+
+ expect(Gitlab::Import::Errors).to receive(:merge_nested_errors).with(object)
+
+ expect { subject.load(nil, object) }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+ end
end
context 'when object is missing' do
diff --git a/spec/lib/bulk_imports/projects/pipelines/ci_pipelines_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/ci_pipelines_pipeline_spec.rb
index a78f524b227..63e7cdf2e5a 100644
--- a/spec/lib/bulk_imports/projects/pipelines/ci_pipelines_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/projects/pipelines/ci_pipelines_pipeline_spec.rb
@@ -109,6 +109,13 @@ RSpec.describe BulkImports::Projects::Pipelines::CiPipelinesPipeline do
'name' => 'first status',
'status' => 'created'
}
+ ],
+ 'builds' => [
+ {
+ 'name' => 'second status',
+ 'status' => 'created',
+ 'ref' => 'abcd'
+ }
]
}
]
@@ -119,6 +126,7 @@ RSpec.describe BulkImports::Projects::Pipelines::CiPipelinesPipeline do
stage = project.all_pipelines.first.stages.first
expect(stage.name).to eq('test stage')
expect(stage.statuses.first.name).to eq('first status')
+ expect(stage.builds.first.name).to eq('second status')
end
end
diff --git a/spec/lib/bulk_imports/projects/pipelines/commit_notes_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/commit_notes_pipeline_spec.rb
new file mode 100644
index 00000000000..f5f31c83033
--- /dev/null
+++ b/spec/lib/bulk_imports/projects/pipelines/commit_notes_pipeline_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Projects::Pipelines::CommitNotesPipeline, feature_category: :importers do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:bulk_import) { create(:bulk_import, user: user) }
+ let_it_be(:entity) do
+ create(
+ :bulk_import_entity,
+ :project_entity,
+ project: project,
+ bulk_import: bulk_import,
+ source_full_path: 'source/full/path',
+ destination_slug: 'destination-project',
+ destination_namespace: group.full_path
+ )
+ end
+
+ let(:ci_pipeline_note) do
+ {
+ "note" => "Commit note 1",
+ "noteable_type" => "Commit",
+ "author_id" => 1,
+ "created_at" => "2023-01-30T19:27:36.585Z",
+ "updated_at" => "2023-02-10T14:43:01.308Z",
+ "project_id" => 1,
+ "commit_id" => "sha-notes",
+ "system" => false,
+ "updated_by_id" => 1,
+ "discussion_id" => "e3fde7d585c6467a7a5147e83617eb6daa61aaf4",
+ "last_edited_at" => "2023-02-10T14:43:01.306Z",
+ "author" => {
+ "name" => "Administrator"
+ },
+ "events" => [
+ {
+ "project_id" => 1,
+ "author_id" => 1,
+ "action" => "commented",
+ "target_type" => "Note"
+ }
+ ]
+ }
+ end
+
+ let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
+ let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
+
+ subject(:pipeline) { described_class.new(context) }
+
+ describe '#run' do
+ before do
+ group.add_owner(user)
+
+ allow_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor|
+ allow(extractor).to receive(:extract).and_return(
+ BulkImports::Pipeline::ExtractedData.new(data: [ci_pipeline_note])
+ )
+ end
+ end
+
+ it 'imports ci pipeline notes into destination project' do
+ expect { pipeline.run }.to change { project.notes.for_commit_id("sha-notes").count }.from(0).to(1)
+ 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
index 09385a261b6..82b8bb3958a 100644
--- a/spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::Projects::Pipelines::ProjectPipeline do
+RSpec.describe BulkImports::Projects::Pipelines::ProjectPipeline, feature_category: :importers do
describe '#run' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
diff --git a/spec/lib/bulk_imports/projects/pipelines/references_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/references_pipeline_spec.rb
index 895d37ea385..3a808851f81 100644
--- a/spec/lib/bulk_imports/projects/pipelines/references_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/projects/pipelines/references_pipeline_spec.rb
@@ -81,6 +81,16 @@ RSpec.describe BulkImports::Projects::Pipelines::ReferencesPipeline, feature_cat
.to include("class=\"gfm gfm-merge_request\">!#{mr.iid}</a></p>")
.and include(project.full_path.to_s)
end
+
+ context 'when object body is nil' do
+ let(:issue) { create(:issue, project: project, description: nil) }
+
+ it 'returns ExtractedData not containing the object' do
+ extracted_data = subject.extract(context)
+
+ expect(extracted_data.data).to contain_exactly(issue_note, mr, mr_note)
+ end
+ end
end
describe '#transform' do
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
index 36dc63a9331..0e3d8b36fb2 100644
--- a/spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb
+++ b/spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb
@@ -5,7 +5,6 @@ require 'spec_helper'
RSpec.describe BulkImports::Projects::Transformers::ProjectAttributesTransformer, feature_category: :importers do
describe '#transform' do
let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, name: 'My Source Project') }
let_it_be(:bulk_import) { create(:bulk_import, user: user) }
let(:entity) do
@@ -25,6 +24,7 @@ RSpec.describe BulkImports::Projects::Transformers::ProjectAttributesTransformer
let(:context) { BulkImports::Pipeline::Context.new(tracker) }
let(:data) do
{
+ 'name' => 'My Project',
'visibility' => 'private',
'created_at' => '2016-11-18T09:29:42.634Z'
}
@@ -32,12 +32,13 @@ RSpec.describe BulkImports::Projects::Transformers::ProjectAttributesTransformer
subject(:transformed_data) { described_class.new.transform(context, data) }
- it 'transforms name to destination slug' do
- expect(transformed_data[:name]).to eq(entity.destination_slug)
+ it 'uniquifies project name' do
+ create(:project, group: destination_group, name: 'My Project')
+ expect(transformed_data[:name]).to eq('My Project_1')
end
- it 'adds path as parameterized name' do
- expect(transformed_data[:path]).to eq(entity.destination_slug.parameterize)
+ it 'adds path as normalized name' do
+ expect(transformed_data[:path]).to eq(entity.destination_slug.downcase)
end
it 'adds import type' do
@@ -45,27 +46,8 @@ RSpec.describe BulkImports::Projects::Transformers::ProjectAttributesTransformer
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_slug: 'Destination-Project-Name',
- destination_namespace: ''
- )
-
- context = double(entity: entity)
-
- expect(described_class.new.transform(context, data)).not_to have_key(:namespace_id)
- end
+ it 'adds namespace_id' do
+ expect(transformed_data[:namespace_id]).to eq(destination_group.id)
end
end
@@ -86,6 +68,64 @@ RSpec.describe BulkImports::Projects::Transformers::ProjectAttributesTransformer
end
end
+ context 'when destination_slug has invalid characters' do
+ let(:entity) do
+ create(
+ :bulk_import_entity,
+ source_type: :project_entity,
+ bulk_import: bulk_import,
+ source_full_path: 'source/full/path',
+ destination_slug: '------------Destination_-Project-_Name------------',
+ destination_namespace: destination_namespace
+ )
+ end
+
+ it 'parameterizes the path' do
+ expect(transformed_data[:path]).to eq('destination-project-name')
+ end
+ end
+
+ context 'when destination namespace already has a group or project with the same name' do
+ before do
+ create(:project, group: destination_group, name: 'Destination-Project-Name', path: 'project')
+ create(:project, group: destination_group, name: 'Destination-Project-Name_1', path: 'project_1')
+ end
+
+ it 'makes the name unique by appending a counter' do
+ data = {
+ 'visibility' => 'private',
+ 'created_at' => '2016-11-18T09:29:42.634Z',
+ 'name' => 'Destination-Project-Name'
+ }
+
+ transformed_data = described_class.new.transform(context, data)
+ expect(transformed_data['name']).to eq('Destination-Project-Name_2')
+ end
+ end
+
+ context 'when destination namespace already has a project with the same path' do
+ let(:entity) do
+ create(
+ :bulk_import_entity,
+ source_type: :project_entity,
+ bulk_import: bulk_import,
+ source_full_path: 'source/full/path',
+ destination_slug: 'destination-slug-path',
+ destination_namespace: destination_namespace
+ )
+ end
+
+ before do
+ create(:project, group: destination_group, name: 'Source Project Name', path: 'destination-slug-path')
+ create(:project, group: destination_group, name: 'Source Project Name_1', path: 'destination-slug-path_1')
+ end
+
+ it 'makes the path unique by appending a counter' do
+ transformed_data = described_class.new.transform(context, data)
+ expect(transformed_data['path']).to eq('destination-slug-path_2')
+ end
+ end
+
describe 'visibility level' do
include_examples 'visibility level settings'
end
diff --git a/spec/lib/container_registry/gitlab_api_client_spec.rb b/spec/lib/container_registry/gitlab_api_client_spec.rb
index 7d78aad8b13..c70dd265073 100644
--- a/spec/lib/container_registry/gitlab_api_client_spec.rb
+++ b/spec/lib/container_registry/gitlab_api_client_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ContainerRegistry::GitlabApiClient do
+RSpec.describe ContainerRegistry::GitlabApiClient, feature_category: :container_registry do
using RSpec::Parameterized::TableSyntax
include_context 'container registry client'
@@ -320,6 +320,98 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
end
end
+ describe '#sub_repositories_with_tag' do
+ let(:path) { 'namespace/path/to/repository' }
+ let(:page_size) { 100 }
+ let(:last) { nil }
+ let(:response) do
+ [
+ {
+ "name": "docker-alpine",
+ "path": "gitlab-org/build/cng/docker-alpine",
+ "created_at": "2022-06-07T12:11:13.633+00:00",
+ "updated_at": "2022-06-07T14:37:49.251+00:00"
+ },
+ {
+ "name": "git-base",
+ "path": "gitlab-org/build/cng/git-base",
+ "created_at": "2022-06-07T12:11:13.633+00:00",
+ "updated_at": "2022-06-07T14:37:49.251+00:00"
+ }
+ ]
+ end
+
+ let(:result_with_no_pagination) do
+ {
+ pagination: {},
+ response_body: ::Gitlab::Json.parse(response.to_json)
+ }
+ end
+
+ subject { client.sub_repositories_with_tag(path, page_size: page_size, last: last) }
+
+ context 'with valid parameters' do
+ before do
+ stub_sub_repositories_with_tag(path, page_size: page_size, respond_with: response)
+ end
+
+ it { is_expected.to eq(result_with_no_pagination) }
+ end
+
+ context 'with a response with a link header' do
+ let(:next_page_url) { 'http://sandbox.org/test?last=c' }
+ let(:expected) do
+ {
+ pagination: { next: { uri: URI(next_page_url) } },
+ response_body: ::Gitlab::Json.parse(response.to_json)
+ }
+ end
+
+ before do
+ stub_sub_repositories_with_tag(path, page_size: page_size, next_page_url: next_page_url, respond_with: response)
+ end
+
+ it { is_expected.to eq(expected) }
+ end
+
+ context 'with a large page size set' do
+ let(:page_size) { described_class::MAX_TAGS_PAGE_SIZE + 1000 }
+
+ before do
+ stub_sub_repositories_with_tag(path, page_size: described_class::MAX_TAGS_PAGE_SIZE, respond_with: response)
+ end
+
+ it { is_expected.to eq(result_with_no_pagination) }
+ end
+
+ context 'with a last parameter set' do
+ let(:last) { 'test' }
+
+ before do
+ stub_sub_repositories_with_tag(path, page_size: page_size, last: last, respond_with: response)
+ end
+
+ it { is_expected.to eq(result_with_no_pagination) }
+ end
+
+ context 'with non successful response' do
+ before do
+ stub_sub_repositories_with_tag(path, page_size: page_size, status_code: 404)
+ end
+
+ it 'logs an error and returns an empty hash' do
+ expect(Gitlab::ErrorTracking)
+ .to receive(:log_exception).with(
+ instance_of(described_class::UnsuccessfulResponseError),
+ class: described_class.name,
+ url: "/gitlab/v1/repository-paths/#{path}/repositories/list/",
+ status_code: 404
+ )
+ expect(subject).to eq({})
+ end
+ end
+ end
+
describe '.supports_gitlab_api?' do
subject { described_class.supports_gitlab_api? }
@@ -439,6 +531,243 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
end
end
+ describe '.one_project_with_container_registry_tag' do
+ let(:path) { 'build/cng/docker-alpine' }
+ let(:response_body) do
+ [
+ {
+ "name" => "docker-alpine",
+ "path" => path,
+ "created_at" => "2022-06-07T12:11:13.633+00:00",
+ "updated_at" => "2022-06-07T14:37:49.251+00:00"
+ }
+ ]
+ end
+
+ let(:response) do
+ {
+ pagination: { next: { uri: URI('http://sandbox.org/test?last=x') } },
+ response_body: ::Gitlab::Json.parse(response_body.to_json)
+ }
+ end
+
+ let_it_be(:group) { create(:group, path: 'build') }
+ let_it_be(:project) { create(:project, path: 'cng', namespace: group) }
+ let_it_be(:container_repository) { create(:container_repository, project: project, name: "docker-alpine") }
+
+ shared_examples 'fetching the project from container repository and path' do
+ it 'fetches the project from the given path details' do
+ expect(ContainerRegistry::Path).to receive(:new).with(path).and_call_original
+ expect(ContainerRepository).to receive(:find_by_path).and_call_original
+
+ expect(subject).to eq(project)
+ end
+
+ it 'returns nil when path is invalid' do
+ registry_path = ContainerRegistry::Path.new('invalid')
+ expect(ContainerRegistry::Path).to receive(:new).with(path).and_return(registry_path)
+ expect(registry_path.valid?).to eq(false)
+
+ expect(subject).to eq(nil)
+ end
+
+ it 'returns nil when there is no container_repository matching the path' do
+ expect(ContainerRegistry::Path).to receive(:new).with(path).and_call_original
+ expect(ContainerRepository).to receive(:find_by_path).and_return(nil)
+
+ expect(subject).to eq(nil)
+ end
+ end
+
+ subject { described_class.one_project_with_container_registry_tag(path) }
+
+ before do
+ expect(Auth::ContainerRegistryAuthenticationService).to receive(:pull_nested_repositories_access_token).with(path.downcase).and_return(token)
+ stub_container_registry_config(enabled: true, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
+ end
+
+ context 'with successful response' do
+ before do
+ stub_sub_repositories_with_tag(path, page_size: 1, respond_with: response_body)
+ end
+
+ it_behaves_like 'fetching the project from container repository and path'
+ end
+
+ context 'with unsuccessful response' do
+ before do
+ stub_sub_repositories_with_tag(path, page_size: 1, status_code: 404, respond_with: {})
+ end
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'with uppercase path' do
+ let(:path) { 'BuilD/CNG/docker-alpine' }
+
+ before do
+ expect_next_instance_of(described_class) do |client|
+ expect(client).to receive(:sub_repositories_with_tag).with(path.downcase, page_size: 1).and_return(response.with_indifferent_access).once
+ end
+ end
+
+ it_behaves_like 'fetching the project from container repository and path'
+ end
+ end
+
+ describe '#each_sub_repositories_with_tag_page' do
+ let(:page_size) { 100 }
+ let(:project_path) { 'repo/project' }
+
+ shared_examples 'iterating through a page' do |expected_tags: true|
+ it 'iterates through one page' do
+ expect_next_instance_of(described_class) do |client|
+ expect(client).to receive(:sub_repositories_with_tag).with(project_path, page_size: page_size, last: nil).and_return(client_response)
+ end
+
+ expect { |b| described_class.each_sub_repositories_with_tag_page(path: project_path, page_size: page_size, &b) }
+ .to yield_with_args(expected_tags ? client_response_repositories : [])
+ end
+ end
+
+ context 'when no block is given' do
+ it 'raises an Argument error' do
+ expect do
+ described_class.each_sub_repositories_with_tag_page(path: project_path, page_size: page_size)
+ end.to raise_error(ArgumentError, 'block not given')
+ end
+ end
+
+ context 'when a block is given' do
+ before do
+ expect(Auth::ContainerRegistryAuthenticationService).to receive(:pull_nested_repositories_access_token).with(project_path).and_return(token)
+ stub_container_registry_config(enabled: true, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
+ end
+
+ context 'with an empty page' do
+ let(:client_response) { { pagination: {}, response_body: [] } }
+
+ it_behaves_like 'iterating through a page', expected_tags: false
+ end
+
+ context 'with one page' do
+ let(:client_response) { { pagination: {}, response_body: client_response_repositories } }
+ let(:client_response_repositories) do
+ [
+ {
+ "name": "docker-alpine",
+ "path": "gitlab-org/build/cng/docker-alpine",
+ "created_at": "2022-06-07T12:11:13.633+00:00",
+ "updated_at": "2022-06-07T14:37:49.251+00:00"
+ },
+ {
+ "name": "git-base",
+ "path": "gitlab-org/build/cng/git-base",
+ "created_at": "2022-06-07T12:11:13.633+00:00",
+ "updated_at": "2022-06-07T14:37:49.251+00:00"
+ }
+ ]
+ end
+
+ it_behaves_like 'iterating through a page'
+ end
+
+ context 'with two pages' do
+ let(:client_response1) { { pagination: { next: { uri: URI('http://localhost/next?last=latest') } }, response_body: client_response_repositories1 } }
+ let(:client_response_repositories1) do
+ [
+ {
+ "name": "docker-alpine",
+ "path": "gitlab-org/build/cng/docker-alpine",
+ "created_at": "2022-06-07T12:11:13.633+00:00",
+ "updated_at": "2022-06-07T14:37:49.251+00:00"
+ },
+ {
+ "name": "git-base",
+ "path": "gitlab-org/build/cng/git-base",
+ "created_at": "2022-06-07T12:11:13.633+00:00",
+ "updated_at": "2022-06-07T14:37:49.251+00:00"
+ }
+ ]
+ end
+
+ let(:client_response2) { { pagination: {}, response_body: client_response_repositories2 } }
+ let(:client_response_repositories2) do
+ [
+ {
+ "name": "docker-alpine1",
+ "path": "gitlab-org/build/cng/docker-alpine",
+ "created_at": "2022-06-07T12:11:13.633+00:00",
+ "updated_at": "2022-06-07T14:37:49.251+00:00"
+ },
+ {
+ "name": "git-base1",
+ "path": "gitlab-org/build/cng/git-base",
+ "created_at": "2022-06-07T12:11:13.633+00:00",
+ "updated_at": "2022-06-07T14:37:49.251+00:00"
+ }
+ ]
+ end
+
+ it 'iterates through two pages' do
+ expect_next_instance_of(described_class) do |client|
+ expect(client).to receive(:sub_repositories_with_tag).with(project_path, page_size: page_size, last: nil).and_return(client_response1)
+ expect(client).to receive(:sub_repositories_with_tag).with(project_path, page_size: page_size, last: 'latest').and_return(client_response2)
+ end
+
+ expect { |b| described_class.each_sub_repositories_with_tag_page(path: project_path, page_size: page_size, &b) }
+ .to yield_successive_args(client_response_repositories1, client_response_repositories2)
+ end
+ end
+
+ context 'when max pages is reached' do
+ let(:client_response) { { pagination: {}, response_body: [] } }
+
+ before do
+ stub_const('ContainerRegistry::GitlabApiClient::MAX_REPOSITORIES_PAGE_SIZE', 0)
+ expect_next_instance_of(described_class) do |client|
+ expect(client).to receive(:sub_repositories_with_tag).with(project_path, page_size: page_size, last: nil).and_return(client_response)
+ end
+ end
+
+ it 'raises an error' do
+ expect { described_class.each_sub_repositories_with_tag_page(path: project_path, page_size: page_size) {} } # rubocop:disable Lint/EmptyBlock
+ .to raise_error(StandardError, 'too many pages requested')
+ end
+ end
+
+ context 'without a page size set' do
+ let(:client_response) { { pagination: {}, response_body: [] } }
+
+ it 'uses a default size' do
+ expect_next_instance_of(described_class) do |client|
+ expect(client).to receive(:sub_repositories_with_tag).with(project_path, page_size: page_size, last: nil).and_return(client_response)
+ end
+
+ expect { |b| described_class.each_sub_repositories_with_tag_page(path: project_path, &b) }.to yield_with_args([])
+ end
+ end
+
+ context 'with an empty client response' do
+ let(:client_response) { {} }
+
+ it 'breaks the loop' do
+ expect_next_instance_of(described_class) do |client|
+ expect(client).to receive(:sub_repositories_with_tag).with(project_path, page_size: page_size, last: nil).and_return(client_response)
+ end
+
+ expect { |b| described_class.each_sub_repositories_with_tag_page(path: project_path, page_size: page_size, &b) }.not_to yield_control
+ end
+ end
+
+ context 'with a nil page' do
+ let(:client_response) { { pagination: {}, response_body: nil } }
+
+ it_behaves_like 'iterating through a page', expected_tags: false
+ end
+ end
+ end
+
def stub_pre_import(path, status_code, pre:)
import_type = pre ? 'pre' : 'final'
stub_request(:put, "#{registry_api_url}/gitlab/v1/import/#{path}/?import_type=#{import_type}")
@@ -525,4 +854,30 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
headers: response_headers
)
end
+
+ def stub_sub_repositories_with_tag(path, page_size: nil, last: nil, next_page_url: nil, status_code: 200, respond_with: {})
+ params = { n: page_size, last: last }.compact
+
+ url = "#{registry_api_url}/gitlab/v1/repository-paths/#{path}/repositories/list/"
+
+ if params.present?
+ url += "?#{params.map { |param, val| "#{param}=#{val}" }.join('&')}"
+ end
+
+ request_headers = { 'Accept' => described_class::JSON_TYPE }
+ request_headers['Authorization'] = "bearer #{token}" if token
+
+ response_headers = { 'Content-Type' => described_class::JSON_TYPE }
+ if next_page_url
+ response_headers['Link'] = "<#{next_page_url}>; rel=\"next\""
+ end
+
+ stub_request(:get, url)
+ .with(headers: request_headers)
+ .to_return(
+ status: status_code,
+ body: respond_with.to_json,
+ headers: response_headers
+ )
+ end
end
diff --git a/spec/lib/container_registry/path_spec.rb b/spec/lib/container_registry/path_spec.rb
index aa6876225b5..c9fa3ec690b 100644
--- a/spec/lib/container_registry/path_spec.rb
+++ b/spec/lib/container_registry/path_spec.rb
@@ -143,8 +143,8 @@ RSpec.describe ContainerRegistry::Path do
let(:path) { 'some_group/some_project' }
before do
- create(:project, group: group, name: 'some_project')
- create(:project, name: 'some_project')
+ create(:project, group: group, path: 'some_project')
+ create(:project, path: 'some_project')
end
it 'returns a correct project' do
@@ -162,7 +162,7 @@ RSpec.describe ContainerRegistry::Path do
context 'when matching multi-level path' do
let(:project) do
- create(:project, group: group, name: 'some_project')
+ create(:project, group: group, path: 'some_project')
end
context 'when using the zero-level path' do
@@ -212,7 +212,7 @@ RSpec.describe ContainerRegistry::Path do
let(:group) { create(:group, path: 'Some_Group') }
before do
- create(:project, group: group, name: 'some_project')
+ create(:project, group: group, path: 'some_project')
end
context 'when project path equal repository path' do
@@ -255,7 +255,7 @@ RSpec.describe ContainerRegistry::Path do
let(:group) { create(:group, path: 'SomeGroup') }
before do
- create(:project, group: group, name: 'MyProject')
+ create(:project, group: group, path: 'MyProject')
end
it 'returns downcased project path' do
diff --git a/spec/lib/error_tracking/collector/payload_validator_spec.rb b/spec/lib/error_tracking/collector/payload_validator_spec.rb
index 94708f63bf4..96ad66e9b58 100644
--- a/spec/lib/error_tracking/collector/payload_validator_spec.rb
+++ b/spec/lib/error_tracking/collector/payload_validator_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe ErrorTracking::Collector::PayloadValidator do
end
with_them do
- let(:payload) { Gitlab::Json.parse(fixture_file(event_fixture)) }
+ let(:payload) { Gitlab::Json.parse(File.read(event_fixture)) }
it_behaves_like 'valid payload'
end
diff --git a/spec/lib/error_tracking/sentry_client/token_spec.rb b/spec/lib/error_tracking/sentry_client/token_spec.rb
new file mode 100644
index 00000000000..c50ec42ed67
--- /dev/null
+++ b/spec/lib/error_tracking/sentry_client/token_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ErrorTracking::SentryClient::Token, feature_category: :error_tracking do
+ describe '.masked_token?' do
+ subject { described_class.masked_token?(token) }
+
+ context 'with masked token' do
+ let(:token) { '*********' }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'without masked token' do
+ let(:token) { 'token' }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+end
diff --git a/spec/lib/feature_groups/gitlab_team_members_spec.rb b/spec/lib/feature_groups/gitlab_team_members_spec.rb
deleted file mode 100644
index f4db02e6c58..00000000000
--- a/spec/lib/feature_groups/gitlab_team_members_spec.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe FeatureGroups::GitlabTeamMembers, feature_category: :shared do
- let_it_be(:gitlab_com) { create(:group) }
- let_it_be_with_reload(:member) { create(:user).tap { |user| gitlab_com.add_developer(user) } }
- let_it_be_with_reload(:non_member) { create(:user) }
-
- before do
- stub_const("#{described_class.name}::GITLAB_COM_GROUP_ID", gitlab_com.id)
- end
-
- describe '#enabled?' do
- context 'when not on gitlab.com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(false)
- end
-
- it 'returns false' do
- expect(described_class.enabled?(member)).to eq(false)
- end
- end
-
- context 'when on gitlab.com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
- it 'returns true for gitlab-com group members' do
- expect(described_class.enabled?(member)).to eq(true)
- end
-
- it 'returns false for users not in gitlab-com' do
- expect(described_class.enabled?(non_member)).to eq(false)
- end
-
- it 'returns false when actor is not a user' do
- expect(described_class.enabled?(gitlab_com)).to eq(false)
- end
-
- it 'reloads members after 1 hour' do
- expect(described_class.enabled?(non_member)).to eq(false)
-
- gitlab_com.add_developer(non_member)
-
- travel_to(2.hours.from_now) do
- expect(described_class.enabled?(non_member)).to eq(true)
- end
- end
-
- it 'does not make queries on subsequent calls', :use_clean_rails_memory_store_caching do
- described_class.enabled?(member)
- non_member
-
- queries = ActiveRecord::QueryRecorder.new do
- described_class.enabled?(member)
- described_class.enabled?(non_member)
- end
-
- expect(queries.count).to eq(0)
- end
- end
- end
-end
diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb
index c86bc36057a..044415b9952 100644
--- a/spec/lib/feature_spec.rb
+++ b/spec/lib/feature_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Feature, stub_feature_flags: false, feature_category: :shared do
+RSpec.describe Feature, :clean_gitlab_redis_feature_flag, stub_feature_flags: false, feature_category: :shared do
include StubVersion
before do
@@ -11,32 +11,6 @@ RSpec.describe Feature, stub_feature_flags: false, feature_category: :shared do
skip_feature_flags_yaml_validation
end
- describe '.feature_flags_available?' do
- it 'returns false on connection error' do
- expect(ActiveRecord::Base.connection).to receive(:active?).and_raise(PG::ConnectionBad) # rubocop:disable Database/MultipleDatabases
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
-
- it 'returns false when connection is not active' do
- expect(ActiveRecord::Base.connection).to receive(:active?).and_return(false) # rubocop:disable Database/MultipleDatabases
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
-
- it 'returns false when the flipper table does not exist' do
- expect(Feature::FlipperFeature).to receive(:table_exists?).and_return(false)
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
-
- it 'returns false on NoDatabaseError' do
- expect(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError)
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
- end
-
describe '.get' do
let(:feature) { double(:feature) }
let(:key) { 'my_feature' }
@@ -154,17 +128,6 @@ RSpec.describe Feature, stub_feature_flags: false, feature_category: :shared do
end
end
- describe '.register_feature_groups' do
- before do
- Flipper.unregister_groups
- described_class.register_feature_groups
- end
-
- it 'registers expected groups' do
- expect(Flipper.groups).to include(an_object_having_attributes(name: :gitlab_team_members))
- end
- end
-
describe '.enabled?' do
before do
allow(Feature).to receive(:log_feature_flag_states?).and_return(false)
@@ -249,7 +212,7 @@ RSpec.describe Feature, stub_feature_flags: false, feature_category: :shared do
end
it { expect(described_class.send(:l1_cache_backend)).to eq(Gitlab::ProcessMemoryCache.cache_backend) }
- it { expect(described_class.send(:l2_cache_backend)).to eq(Rails.cache) }
+ it { expect(described_class.send(:l2_cache_backend)).to eq(Gitlab::Redis::FeatureFlag.cache_store) }
it 'caches the status in L1 and L2 caches',
:request_store, :use_clean_rails_memory_store_caching do
@@ -361,22 +324,6 @@ RSpec.describe Feature, stub_feature_flags: false, feature_category: :shared do
end
end
- context 'with gitlab_team_members feature group' do
- let(:actor) { build_stubbed(:user) }
-
- before do
- Flipper.unregister_groups
- described_class.register_feature_groups
- described_class.enable(:enabled_feature_flag, :gitlab_team_members)
- end
-
- it 'delegates check to FeatureGroups::GitlabTeamMembers' do
- expect(FeatureGroups::GitlabTeamMembers).to receive(:enabled?).with(actor)
-
- described_class.enabled?(:enabled_feature_flag, actor)
- end
- end
-
context 'with an individual actor' do
let(:actor) { stub_feature_flag_gate('CustomActor:5') }
let(:another_actor) { stub_feature_flag_gate('CustomActor:10') }
diff --git a/spec/lib/generators/batched_background_migration/batched_background_migration_generator_spec.rb b/spec/lib/generators/batched_background_migration/batched_background_migration_generator_spec.rb
new file mode 100644
index 00000000000..d533bcf0039
--- /dev/null
+++ b/spec/lib/generators/batched_background_migration/batched_background_migration_generator_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rails/generators/testing/behaviour'
+require 'rails/generators/testing/assertions'
+
+RSpec.describe BatchedBackgroundMigration::BatchedBackgroundMigrationGenerator, feature_category: :database do
+ include Rails::Generators::Testing::Behaviour
+ include Rails::Generators::Testing::Assertions
+ include FileUtils
+
+ tests described_class
+ destination File.expand_path('tmp', __dir__)
+
+ before do
+ prepare_destination
+ end
+
+ after do
+ rm_rf(destination_root)
+ end
+
+ context 'with valid arguments' do
+ let(:expected_migration_file) { load_expected_file('queue_my_batched_migration.txt') }
+ let(:expected_migration_spec_file) { load_expected_file('queue_my_batched_migration_spec.txt') }
+ let(:expected_migration_job_file) { load_expected_file('my_batched_migration.txt') }
+ let(:expected_migration_job_spec_file) { load_expected_file('my_batched_migration_spec_matcher.txt') }
+ let(:expected_migration_dictionary) { load_expected_file('my_batched_migration_dictionary_matcher.txt') }
+
+ it 'generates expected files' do
+ run_generator %w[my_batched_migration --table_name=projects --column_name=id --feature_category=database]
+
+ assert_migration('db/post_migrate/queue_my_batched_migration.rb') do |migration_file|
+ expect(migration_file).to eq(expected_migration_file)
+ end
+
+ assert_migration('spec/migrations/queue_my_batched_migration_spec.rb') do |migration_spec_file|
+ expect(migration_spec_file).to eq(expected_migration_spec_file)
+ end
+
+ assert_file('lib/gitlab/background_migration/my_batched_migration.rb') do |migration_job_file|
+ expect(migration_job_file).to eq(expected_migration_job_file)
+ end
+
+ assert_file('spec/lib/gitlab/background_migration/my_batched_migration_spec.rb') do |migration_job_spec_file|
+ # Regex is used to match the dynamic schema: <version> in the specs
+ expect(migration_job_spec_file).to match(/#{expected_migration_job_spec_file}/)
+ end
+
+ assert_file('db/docs/batched_background_migrations/my_batched_migration.yml') do |migration_dictionary|
+ # Regex is used to match the dynamically generated 'milestone' in the dictionary
+ expect(migration_dictionary).to match(/#{expected_migration_dictionary}/)
+ end
+ end
+ end
+
+ context 'without required arguments' do
+ it 'throws table_name is required error' do
+ expect do
+ run_generator %w[my_batched_migration]
+ end.to raise_error(ArgumentError, 'table_name is required')
+ end
+
+ it 'throws column_name is required error' do
+ expect do
+ run_generator %w[my_batched_migration --table_name=projects]
+ end.to raise_error(ArgumentError, 'column_name is required')
+ end
+
+ it 'throws feature_category is required error' do
+ expect do
+ run_generator %w[my_batched_migration --table_name=projects --column_name=id]
+ end.to raise_error(ArgumentError, 'feature_category is required')
+ end
+ end
+
+ private
+
+ def load_expected_file(file_name)
+ File.read(File.expand_path("expected_files/#{file_name}", __dir__))
+ end
+end
diff --git a/spec/lib/generators/batched_background_migration/expected_files/my_batched_migration.txt b/spec/lib/generators/batched_background_migration/expected_files/my_batched_migration.txt
new file mode 100644
index 00000000000..b2378b414b1
--- /dev/null
+++ b/spec/lib/generators/batched_background_migration/expected_files/my_batched_migration.txt
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# See https://docs.gitlab.com/ee/development/database/batched_background_migrations.html
+# for more information on how to use batched background migrations
+
+# Update below commented lines with appropriate values.
+
+module Gitlab
+ module BackgroundMigration
+ class MyBatchedMigration < BatchedMigrationJob
+ # operation_name :my_operation
+ # scope_to ->(relation) { relation.where(column: "value") }
+ feature_category :database
+
+ def perform
+ each_sub_batch do |sub_batch|
+ # Your action on each sub_batch
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/generators/batched_background_migration/expected_files/my_batched_migration_dictionary_matcher.txt b/spec/lib/generators/batched_background_migration/expected_files/my_batched_migration_dictionary_matcher.txt
new file mode 100644
index 00000000000..6280d35177e
--- /dev/null
+++ b/spec/lib/generators/batched_background_migration/expected_files/my_batched_migration_dictionary_matcher.txt
@@ -0,0 +1,6 @@
+---
+migration_job_name: MyBatchedMigration
+description: # Please capture what MyBatchedMigration does
+feature_category: database
+introduced_by_url: # URL of the MR \(or issue/commit\) that introduced the migration
+milestone: [0-9\.]+
diff --git a/spec/lib/generators/batched_background_migration/expected_files/my_batched_migration_spec_matcher.txt b/spec/lib/generators/batched_background_migration/expected_files/my_batched_migration_spec_matcher.txt
new file mode 100644
index 00000000000..2728d65d54b
--- /dev/null
+++ b/spec/lib/generators/batched_background_migration/expected_files/my_batched_migration_spec_matcher.txt
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::MyBatchedMigration, schema: [0-9]+, feature_category: :database do # rubocop:disable Layout/LineLength
+ # Tests go here
+end
diff --git a/spec/lib/generators/batched_background_migration/expected_files/queue_my_batched_migration.txt b/spec/lib/generators/batched_background_migration/expected_files/queue_my_batched_migration.txt
new file mode 100644
index 00000000000..536e07d56aa
--- /dev/null
+++ b/spec/lib/generators/batched_background_migration/expected_files/queue_my_batched_migration.txt
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# See https://docs.gitlab.com/ee/development/database/batched_background_migrations.html
+# for more information on when/how to queue batched background migrations
+
+# Update below commented lines with appropriate values.
+
+class QueueMyBatchedMigration < Gitlab::Database::Migration[2.1]
+ MIGRATION = "MyBatchedMigration"
+ # DELAY_INTERVAL = 2.minutes
+ # BATCH_SIZE = 1000
+ # SUB_BATCH_SIZE = 100
+
+ def up
+ queue_batched_background_migration(
+ MIGRATION,
+ :projects,
+ :id,
+ job_interval: DELAY_INTERVAL,
+ batch_size: BATCH_SIZE,
+ sub_batch_size: SUB_BATCH_SIZE
+ )
+ end
+
+ def down
+ delete_batched_background_migration(MIGRATION, :projects, :id, [])
+ end
+end
diff --git a/spec/lib/generators/batched_background_migration/expected_files/queue_my_batched_migration_spec.txt b/spec/lib/generators/batched_background_migration/expected_files/queue_my_batched_migration_spec.txt
new file mode 100644
index 00000000000..6f33de4ae83
--- /dev/null
+++ b/spec/lib/generators/batched_background_migration/expected_files/queue_my_batched_migration_spec.txt
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueMyBatchedMigration, feature_category: :database do
+ # let!(:batched_migration) { described_class::MIGRATION }
+
+ # it 'schedules a new batched migration' do
+ # reversible_migration do |migration|
+ # migration.before -> {
+ # expect(batched_migration).not_to have_scheduled_batched_migration
+ # }
+
+ # migration.after -> {
+ # expect(batched_migration).to have_scheduled_batched_migration(
+ # table_name: :projects,
+ # column_name: :id,
+ # interval: described_class::DELAY_INTERVAL,
+ # batch_size: described_class::BATCH_SIZE,
+ # sub_batch_size: described_class::SUB_BATCH_SIZE
+ # )
+ # }
+ # end
+ # end
+end
diff --git a/spec/lib/generators/gitlab/snowplow_event_definition_generator_spec.rb b/spec/lib/generators/gitlab/snowplow_event_definition_generator_spec.rb
index d9fa6b931ad..6826006949e 100644
--- a/spec/lib/generators/gitlab/snowplow_event_definition_generator_spec.rb
+++ b/spec/lib/generators/gitlab/snowplow_event_definition_generator_spec.rb
@@ -2,10 +2,10 @@
require 'spec_helper'
-RSpec.describe Gitlab::SnowplowEventDefinitionGenerator, :silence_stdout do
+RSpec.describe Gitlab::SnowplowEventDefinitionGenerator, :silence_stdout, feature_category: :product_analytics do
let(:ce_temp_dir) { Dir.mktmpdir }
let(:ee_temp_dir) { Dir.mktmpdir }
- let(:timestamp) { Time.current.to_i }
+ let(:timestamp) { Time.now.utc.strftime('%Y%m%d%H%M%S') }
let(:generator_options) { { 'category' => 'Groups::EmailCampaignsController', 'action' => 'click' } }
before do
@@ -30,7 +30,8 @@ RSpec.describe Gitlab::SnowplowEventDefinitionGenerator, :silence_stdout do
let(:file_name) { Dir.children(ce_temp_dir).first }
it 'creates CE event definition file using the template' do
- sample_event = ::Gitlab::Config::Loader::Yaml.new(fixture_file(File.join(sample_event_dir, 'sample_event.yml'))).load_raw!
+ sample_event = ::Gitlab::Config::Loader::Yaml
+ .new(fixture_file(File.join(sample_event_dir, 'sample_event.yml'))).load_raw!
described_class.new([], generator_options).invoke_all
@@ -62,25 +63,13 @@ RSpec.describe Gitlab::SnowplowEventDefinitionGenerator, :silence_stdout do
end
end
- context 'event definition already exists' do
+ context 'when event definition with same file name already exists' do
before do
stub_const('Gitlab::VERSION', '12.11.0-pre')
described_class.new([], generator_options).invoke_all
end
- it 'overwrites event definition --force flag set to true' do
- sample_event = ::Gitlab::Config::Loader::Yaml.new(fixture_file(File.join(sample_event_dir, 'sample_event.yml'))).load_raw!
-
- stub_const('Gitlab::VERSION', '13.11.0-pre')
- described_class.new([], generator_options.merge('force' => true)).invoke_all
-
- event_definition_path = File.join(ce_temp_dir, file_name)
- event_data = ::Gitlab::Config::Loader::Yaml.new(File.read(event_definition_path)).load_raw!
-
- expect(event_data).to eq(sample_event)
- end
-
- it 'raises error when --force flag set to false' do
+ it 'raises error' do
expect { described_class.new([], generator_options.merge('force' => false)).invoke_all }
.to raise_error(StandardError, /Event definition already exists at/)
end
@@ -90,7 +79,8 @@ RSpec.describe Gitlab::SnowplowEventDefinitionGenerator, :silence_stdout do
let(:file_name) { Dir.children(ee_temp_dir).first }
it 'creates EE event definition file using the template' do
- sample_event = ::Gitlab::Config::Loader::Yaml.new(fixture_file(File.join(sample_event_dir, 'sample_event_ee.yml'))).load_raw!
+ sample_event = ::Gitlab::Config::Loader::Yaml
+ .new(fixture_file(File.join(sample_event_dir, 'sample_event_ee.yml'))).load_raw!
described_class.new([], generator_options.merge('ee' => true)).invoke_all
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb
index de325454b34..122a94a39c2 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb
@@ -40,7 +40,7 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::Average do
subject(:average_duration_in_seconds) { average.seconds }
context 'when no results' do
- let(:query) { Issue.none }
+ let(:query) { Issue.joins(:metrics).none }
it { is_expected.to eq(nil) }
end
@@ -54,7 +54,7 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::Average do
subject(:average_duration_in_days) { average.days }
context 'when no results' do
- let(:query) { Issue.none }
+ let(:query) { Issue.joins(:metrics).none }
it { is_expected.to eq(nil) }
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/request_params_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/request_params_spec.rb
new file mode 100644
index 00000000000..9b362debb10
--- /dev/null
+++ b/spec/lib/gitlab/analytics/cycle_analytics/request_params_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Analytics::CycleAnalytics::RequestParams, feature_category: :value_stream_management do
+ it_behaves_like 'unlicensed cycle analytics request params' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:root_group) { create(:group) }
+ let_it_be_with_refind(:project) { create(:project, group: root_group) }
+
+ let(:namespace) { project.project_namespace }
+
+ describe 'project-level data attributes' do
+ subject(:attributes) { described_class.new(params).to_data_attributes }
+
+ it 'includes the namespace attribute' do
+ expect(attributes).to match(hash_including({
+ namespace: {
+ name: project.name,
+ full_path: project.full_path,
+ type: "Project"
+ }
+ }))
+ end
+
+ context 'with a subgroup project' do
+ let_it_be(:sub_group) { create(:group, parent: root_group) }
+ let_it_be_with_refind(:subgroup_project) { create(:project, group: sub_group) }
+ let(:namespace) { subgroup_project.project_namespace }
+
+ it 'includes the correct group_path' do
+ expect(attributes).to match(hash_including({
+ group_path: "groups/#{subgroup_project.namespace.full_path}",
+ full_path: subgroup_project.full_path
+ }))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/stage_event_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/stage_event_spec.rb
index 1e0034e386e..24248c557bd 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/stage_event_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/stage_event_spec.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
-RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::StageEvent do
+RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::StageEvent, feature_category: :product_analytics do
let(:instance) { described_class.new({}) }
it { expect(described_class).to respond_to(:name) }
diff --git a/spec/lib/gitlab/api_authentication/token_resolver_spec.rb b/spec/lib/gitlab/api_authentication/token_resolver_spec.rb
index c0c8e7aba63..48cae42dcd2 100644
--- a/spec/lib/gitlab/api_authentication/token_resolver_spec.rb
+++ b/spec/lib/gitlab/api_authentication/token_resolver_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::APIAuthentication::TokenResolver, feature_category: :authentication_and_authorization do
+RSpec.describe Gitlab::APIAuthentication::TokenResolver, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project, :public) }
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
diff --git a/spec/lib/gitlab/app_logger_spec.rb b/spec/lib/gitlab/app_logger_spec.rb
index 85ca60d539f..149c3d1f19f 100644
--- a/spec/lib/gitlab/app_logger_spec.rb
+++ b/spec/lib/gitlab/app_logger_spec.rb
@@ -2,26 +2,12 @@
require 'spec_helper'
-RSpec.describe Gitlab::AppLogger do
+RSpec.describe Gitlab::AppLogger, feature_category: :shared do
subject { described_class }
- it 'builds two Logger instances' do
- expect(Gitlab::Logger).to receive(:new).and_call_original
- expect(Gitlab::JsonLogger).to receive(:new).and_call_original
+ specify { expect(described_class.primary_logger).to be Gitlab::AppJsonLogger }
- subject.info('Hello World!')
- end
-
- it 'logs info to AppLogger and AppJsonLogger' do
- expect_any_instance_of(Gitlab::AppTextLogger).to receive(:info).and_call_original
- expect_any_instance_of(Gitlab::AppJsonLogger).to receive(:info).and_call_original
-
- subject.info('Hello World!')
- end
-
- it 'logs info to only the AppJsonLogger when unstructured logs are disabled' do
- stub_env('UNSTRUCTURED_RAILS_LOG', 'false')
- expect_any_instance_of(Gitlab::AppTextLogger).not_to receive(:info).and_call_original
+ it 'logs to AppJsonLogger' do
expect_any_instance_of(Gitlab::AppJsonLogger).to receive(:info).and_call_original
subject.info('Hello World!')
diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb
index cb9d1e9eae8..31e575e0466 100644
--- a/spec/lib/gitlab/asciidoc_spec.rb
+++ b/spec/lib/gitlab/asciidoc_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require 'nokogiri'
module Gitlab
- RSpec.describe Asciidoc do
+ RSpec.describe Asciidoc, feature_category: :wiki do
include FakeBlobHelpers
before do
@@ -97,8 +97,8 @@ module Gitlab
output = <<~HTML
<div>
<div>
- <div class=\"gl-relative markdown-code-block js-markdown-code\">
- <pre lang=\"plaintext\" class=\"code highlight js-syntax-highlight language-plaintext\" data-canonical-lang=\"mypre\" v-pre=\"true\"><code></code></pre>
+ <div class="gl-relative markdown-code-block js-markdown-code">
+ <pre data-canonical-lang="mypre" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code></code></pre>
<copy-code></copy-code>
</div>
</div>
@@ -369,7 +369,7 @@ module Gitlab
<div>
<div>
<div class="gl-relative markdown-code-block js-markdown-code">
- <pre lang="javascript" class="code highlight js-syntax-highlight language-javascript" data-canonical-lang="js" 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>
+ <pre data-canonical-lang="js" 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="nf">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>
<copy-code></copy-code>
</div>
</div>
@@ -399,7 +399,7 @@ module Gitlab
<div>class.cpp</div>
<div>
<div class="gl-relative markdown-code-block js-markdown-code">
- <pre lang="cpp" class="code highlight js-syntax-highlight language-cpp" data-canonical-lang="c++" v-pre="true"><code><span id="LC1" class="line" lang="cpp"><span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span></span>
+ <pre data-canonical-lang="c++" class="code highlight js-syntax-highlight language-cpp" lang="cpp" v-pre="true"><code><span id="LC1" class="line" lang="cpp"><span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span></span>
<span id="LC2" class="line" lang="cpp"></span>
<span id="LC3" class="line" lang="cpp"><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">5</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span></span>
<span id="LC4" class="line" lang="cpp"> <span class="n">std</span><span class="o">::</span><span class="n">cout</span><span class="o">&lt;&lt;</span><span class="s">"*"</span><span class="o">&lt;&lt;</span><span class="n">std</span><span class="o">::</span><span class="n">endl</span><span class="p">;</span></span>
@@ -457,7 +457,7 @@ module Gitlab
stem:[2+2] is 4
MD
- expect(render(input, context)).to include('<pre data-math-style="display" lang="plaintext" class="code math js-render-math" data-canonical-lang="" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">eta_x gamma</span></code></pre>')
+ expect(render(input, context)).to include('<pre data-math-style="display" class="code math js-render-math" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">eta_x gamma</span></code></pre>')
expect(render(input, context)).to include('<p><code data-math-style="inline" class="code math js-render-math">2+2</code> is 4</p>')
end
end
diff --git a/spec/lib/gitlab/audit/auditor_spec.rb b/spec/lib/gitlab/audit/auditor_spec.rb
index 4b16333d913..2b3c8506440 100644
--- a/spec/lib/gitlab/audit/auditor_spec.rb
+++ b/spec/lib/gitlab/audit/auditor_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Audit::Auditor do
+RSpec.describe Gitlab::Audit::Auditor, feature_category: :audit_events do
let(:name) { 'audit_operation' }
let(:author) { create(:user, :with_sign_ins) }
let(:group) { create(:group) }
@@ -22,9 +22,9 @@ RSpec.describe Gitlab::Audit::Auditor do
subject(:auditor) { described_class }
describe '.audit' do
- context 'when authentication event' do
- let(:audit!) { auditor.audit(context) }
+ let(:audit!) { auditor.audit(context) }
+ context 'when authentication event' do
it 'creates an authentication event' do
expect(AuthenticationEvent).to receive(:new).with(
{
@@ -210,19 +210,38 @@ RSpec.describe Gitlab::Audit::Auditor do
end
context 'when authentication event is false' do
+ let(:target) { group }
let(:context) do
{ name: name, author: author, scope: group,
- target: group, authentication_event: false, message: "sample message" }
+ target: target, authentication_event: false, message: "sample message" }
end
it 'does not create an authentication event' do
expect { auditor.audit(context) }.not_to change(AuthenticationEvent, :count)
end
+
+ context 'with permitted target' do
+ { feature_flag: :operations_feature_flag }.each do |target_type, factory_name|
+ context "with #{target_type}" do
+ let(:target) { build_stubbed factory_name }
+
+ it 'logs audit events to database', :aggregate_failures, :freeze_time do
+ audit!
+ audit_event = AuditEvent.last
+
+ expect(audit_event.author_id).to eq(author.id)
+ expect(audit_event.entity_id).to eq(group.id)
+ expect(audit_event.entity_type).to eq(group.class.name)
+ expect(audit_event.created_at).to eq(Time.zone.now)
+ expect(audit_event.details[:target_id]).to eq(target.id)
+ expect(audit_event.details[:target_type]).to eq(target.class.name)
+ end
+ end
+ end
+ end
end
context 'when authentication event is invalid' do
- let(:audit!) { auditor.audit(context) }
-
before do
allow(AuthenticationEvent).to receive(:new).and_raise(ActiveRecord::RecordInvalid)
allow(Gitlab::ErrorTracking).to receive(:track_exception)
@@ -243,8 +262,6 @@ RSpec.describe Gitlab::Audit::Auditor do
end
context 'when audit events are invalid' do
- let(:audit!) { auditor.audit(context) }
-
before do
expect_next_instance_of(AuditEvent) do |instance|
allow(instance).to receive(:save!).and_raise(ActiveRecord::RecordInvalid)
diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb
index 6aedd0a0a23..4498e369695 100644
--- a/spec/lib/gitlab/auth/auth_finders_spec.rb
+++ b/spec/lib/gitlab/auth/auth_finders_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Auth::AuthFinders, feature_category: :authentication_and_authorization do
+RSpec.describe Gitlab::Auth::AuthFinders, feature_category: :system_access do
include described_class
include HttpBasicAuthHelpers
@@ -409,6 +409,17 @@ RSpec.describe Gitlab::Auth::AuthFinders, feature_category: :authentication_and_
expect(find_user_from_access_token).to be_nil
end
+ context 'when run for kubernetes internal API endpoint' do
+ before do
+ set_bearer_token('AgentToken')
+ set_header('SCRIPT_NAME', '/api/v4/internal/kubernetes/modules/starboard_vulnerability/policies_configuration')
+ end
+
+ it 'returns nil' do
+ expect(find_user_from_access_token).to be_nil
+ end
+ end
+
context 'when validate_access_token! returns valid' do
it 'returns user' do
set_header(described_class::PRIVATE_TOKEN_HEADER, personal_access_token.token)
diff --git a/spec/lib/gitlab/auth/o_auth/auth_hash_spec.rb b/spec/lib/gitlab/auth/o_auth/auth_hash_spec.rb
index c94f962ee93..8c50b2acac6 100644
--- a/spec/lib/gitlab/auth/o_auth/auth_hash_spec.rb
+++ b/spec/lib/gitlab/auth/o_auth/auth_hash_spec.rb
@@ -2,14 +2,19 @@
require 'spec_helper'
-RSpec.describe Gitlab::Auth::OAuth::AuthHash do
+RSpec.describe Gitlab::Auth::OAuth::AuthHash, feature_category: :user_management do
let(:provider) { 'ldap' }
let(:auth_hash) do
described_class.new(
OmniAuth::AuthHash.new(
provider: provider,
uid: uid_ascii,
- info: info_hash
+ info: info_hash,
+ extra: {
+ raw_info: {
+ 'https://example.com/claims/username': username_claim_utf8
+ }
+ }
)
)
end
@@ -24,6 +29,7 @@ RSpec.describe Gitlab::Auth::OAuth::AuthHash do
let(:first_name_raw) { +'Onur' }
let(:last_name_raw) { +"K\xC3\xBC\xC3\xA7\xC3\xBCk" }
let(:name_raw) { +"Onur K\xC3\xBC\xC3\xA7\xC3\xBCk" }
+ let(:username_claim_raw) { +'onur.partner' }
let(:uid_ascii) { uid_raw.force_encoding(Encoding::ASCII_8BIT) }
let(:email_ascii) { email_raw.force_encoding(Encoding::ASCII_8BIT) }
@@ -37,6 +43,7 @@ RSpec.describe Gitlab::Auth::OAuth::AuthHash do
let(:nickname_utf8) { nickname_ascii.force_encoding(Encoding::UTF_8) }
let(:name_utf8) { name_ascii.force_encoding(Encoding::UTF_8) }
let(:first_name_utf8) { first_name_ascii.force_encoding(Encoding::UTF_8) }
+ let(:username_claim_utf8) { username_claim_raw.force_encoding(Encoding::ASCII_8BIT) }
let(:info_hash) do
{
@@ -98,10 +105,16 @@ RSpec.describe Gitlab::Auth::OAuth::AuthHash do
allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for).and_return(provider_config)
end
- it 'uses the custom field for the username' do
+ it 'uses the custom field for the username within info' do
expect(auth_hash.username).to eql first_name_utf8
end
+ it 'uses the custom field for the username within extra.raw_info' do
+ provider_config['args']['gitlab_username_claim'] = 'https://example.com/claims/username'
+
+ expect(auth_hash.username).to eql username_claim_utf8
+ end
+
it 'uses the default claim for the username when the custom claim is not found' do
provider_config['args']['gitlab_username_claim'] = 'nonexistent'
@@ -146,4 +159,66 @@ RSpec.describe Gitlab::Auth::OAuth::AuthHash do
expect(auth_hash.password.encoding).to eql Encoding::UTF_8
end
end
+
+ describe '#get_from_auth_hash_or_info' do
+ context 'for a key not within auth_hash' do
+ let(:auth_hash) do
+ described_class.new(
+ OmniAuth::AuthHash.new(
+ provider: provider,
+ uid: uid_ascii,
+ info: info_hash
+ )
+ )
+ end
+
+ let(:info_hash) { { nickname: nickname_ascii } }
+
+ it 'provides username from info_hash' do
+ expect(auth_hash.username).to eql nickname_utf8
+ end
+ end
+
+ context 'for a key within auth_hash' do
+ let(:auth_hash) do
+ described_class.new(
+ OmniAuth::AuthHash.new(
+ provider: provider,
+ uid: uid_ascii,
+ info: info_hash,
+ username: nickname_ascii
+ )
+ )
+ end
+
+ let(:info_hash) { { something: nickname_ascii } }
+
+ it 'provides username from auth_hash' do
+ expect(auth_hash.username).to eql nickname_utf8
+ end
+ end
+
+ context 'for a key within auth_hash extra' do
+ let(:auth_hash) do
+ described_class.new(
+ OmniAuth::AuthHash.new(
+ provider: provider,
+ uid: uid_ascii,
+ info: info_hash,
+ extra: {
+ raw_info: {
+ nickname: nickname_ascii
+ }
+ }
+ )
+ )
+ end
+
+ let(:info_hash) { { something: nickname_ascii } }
+
+ it 'provides username from auth_hash extra' do
+ expect(auth_hash.username).to eql nickname_utf8
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/auth/o_auth/provider_spec.rb b/spec/lib/gitlab/auth/o_auth/provider_spec.rb
index 96a31c50989..226669bab33 100644
--- a/spec/lib/gitlab/auth/o_auth/provider_spec.rb
+++ b/spec/lib/gitlab/auth/o_auth/provider_spec.rb
@@ -49,7 +49,7 @@ RSpec.describe Gitlab::Auth::OAuth::Provider do
context 'for an LDAP provider' do
context 'when the provider exists' do
it 'returns the config' do
- expect(described_class.config_for('ldapmain')).to be_a(Hash)
+ expect(described_class.config_for('ldapmain')).to be_a(GitlabSettings::Options)
end
end
diff --git a/spec/lib/gitlab/auth/o_auth/user_spec.rb b/spec/lib/gitlab/auth/o_auth/user_spec.rb
index 04fbbff3559..78e0df91103 100644
--- a/spec/lib/gitlab/auth/o_auth/user_spec.rb
+++ b/spec/lib/gitlab/auth/o_auth/user_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Auth::OAuth::User, feature_category: :authentication_and_authorization do
+RSpec.describe Gitlab::Auth::OAuth::User, feature_category: :system_access do
include LdapHelpers
let(:oauth_user) { described_class.new(auth_hash) }
@@ -320,6 +320,38 @@ RSpec.describe Gitlab::Auth::OAuth::User, feature_category: :authentication_and_
end
include_examples "to verify compliance with allow_single_sign_on"
+
+ context 'and other providers' do
+ context 'when sync_name is disabled' do
+ before do
+ stub_ldap_config(sync_name: false)
+ end
+
+ let!(:existing_user) { create(:omniauth_user, name: 'John Swift', email: 'john@example.com', extern_uid: dn, provider: 'twitter', username: 'john') }
+
+ it "updates the gl_user name" do
+ oauth_user.save # rubocop:disable Rails/SaveBang
+
+ expect(gl_user).to be_valid
+ expect(gl_user.name).to eql 'John'
+ end
+ end
+
+ context 'when sync_name is enabled' do
+ before do
+ stub_ldap_config(sync_name: true)
+ end
+
+ let!(:existing_user) { create(:omniauth_user, name: 'John Swift', email: 'john@example.com', extern_uid: dn, provider: 'twitter', username: 'john') }
+
+ it "updates the gl_user name" do
+ oauth_user.save # rubocop:disable Rails/SaveBang
+
+ expect(gl_user).to be_valid
+ expect(gl_user.name).to eql 'John'
+ end
+ end
+ end
end
context "with auto_link_ldap_user enabled" do
@@ -418,54 +450,41 @@ RSpec.describe Gitlab::Auth::OAuth::User, feature_category: :authentication_and_
end
context "and LDAP user has an account already" do
+ let(:provider) { 'ldapmain' }
+
+ before do
+ allow(Gitlab::Auth::Ldap::Person).to receive(:find_by_uid).and_return(ldap_user)
+ stub_omniauth_config(sync_profile_attributes: true)
+ allow(Gitlab.config.ldap).to receive(:enabled).and_return(true)
+ end
+
context 'when sync_name is disabled' do
before do
- allow(Gitlab.config.ldap).to receive(:enabled).and_return(true)
- allow(Gitlab.config.ldap).to receive(:sync_name).and_return(false)
+ stub_ldap_config(sync_name: false)
end
- let!(:existing_user) { create(:omniauth_user, name: 'John Doe', email: 'john@example.com', extern_uid: dn, provider: 'ldapmain', username: 'john') }
-
- it "adds the omniauth identity to the LDAP account" do
- allow(Gitlab::Auth::Ldap::Person).to receive(:find_by_uid).and_return(ldap_user)
+ let!(:existing_user) { create(:omniauth_user, name: 'John Deo', email: 'john@example.com', extern_uid: dn, provider: 'ldapmain', username: 'john') }
+ it "does not update the user name" do
oauth_user.save # rubocop:disable Rails/SaveBang
expect(gl_user).to be_valid
- expect(gl_user.username).to eql 'john'
- expect(gl_user.name).to eql 'John Doe'
- expect(gl_user.email).to eql 'john@example.com'
- expect(gl_user.identities.length).to be 2
- identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
- expect(identities_as_hash).to match_array(
- [
- { provider: 'ldapmain', extern_uid: dn },
- { provider: 'twitter', extern_uid: uid }
- ]
- )
+ expect(gl_user.name).to eql 'John Deo'
end
end
context 'when sync_name is enabled' do
- let!(:existing_user) { create(:omniauth_user, name: 'John Swift', email: 'john@example.com', extern_uid: dn, provider: 'ldapmain', username: 'john') }
+ before do
+ stub_ldap_config(sync_name: true)
+ end
- it "adds the omniauth identity to the LDAP account" do
- allow(Gitlab::Auth::Ldap::Person).to receive(:find_by_uid).and_return(ldap_user)
+ let!(:existing_user) { create(:omniauth_user, name: 'John Swift', email: 'john@example.com', extern_uid: dn, provider: 'ldapmain', username: 'john') }
+ it "updates the user name" do
oauth_user.save # rubocop:disable Rails/SaveBang
expect(gl_user).to be_valid
- expect(gl_user.username).to eql 'john'
- expect(gl_user.name).to eql 'John Swift'
- expect(gl_user.email).to eql 'john@example.com'
- expect(gl_user.identities.length).to be 2
- identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
- expect(identities_as_hash).to match_array(
- [
- { provider: 'ldapmain', extern_uid: dn },
- { provider: 'twitter', extern_uid: uid }
- ]
- )
+ expect(gl_user.name).to eql 'John'
end
end
end
diff --git a/spec/lib/gitlab/auth/otp/strategies/duo_auth/manual_otp_spec.rb b/spec/lib/gitlab/auth/otp/strategies/duo_auth/manual_otp_spec.rb
new file mode 100644
index 00000000000..d04e0ad9fb4
--- /dev/null
+++ b/spec/lib/gitlab/auth/otp/strategies/duo_auth/manual_otp_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Auth::Otp::Strategies::DuoAuth::ManualOtp, feature_category: :system_access do
+ let_it_be(:user) { create(:user) }
+
+ let_it_be(:otp_code) { 42 }
+
+ let_it_be(:hostname) { 'duo_auth.example.com' }
+ let_it_be(:integration_key) { 'int3gr4t1on' }
+ let_it_be(:secret_key) { 's3cr3t' }
+
+ let_it_be(:duo_response_builder) { Struct.new(:body) }
+
+ let_it_be(:response_status) { 200 }
+
+ let_it_be(:duo_auth_url) { "https://#{hostname}/auth/v2/auth/" }
+ let_it_be(:params) do
+ { username: user.username,
+ factor: "passcode",
+ passcode: otp_code }
+ end
+
+ let_it_be(:manual_otp) { described_class.new(user) }
+
+ subject(:response) { manual_otp.validate(otp_code) }
+
+ before do
+ stub_duo_auth_config(
+ enabled: true,
+ hostname: hostname,
+ secret_key: secret_key,
+ integration_key: integration_key
+ )
+ end
+
+ context 'when successful validation' do
+ before do
+ allow(duo_client).to receive(:request)
+ .with("POST", "/auth/v2/auth", params)
+ .and_return(duo_response_builder.new('{ "response": { "result": "allow" }}'))
+
+ allow(manual_otp).to receive(:duo_client).and_return(duo_client)
+ end
+
+ it 'returns success' do
+ response
+
+ expect(response[:status]).to eq(:success)
+ end
+ end
+
+ context 'when unsuccessful validation' do
+ before do
+ allow(duo_client).to receive(:request)
+ .with("POST", "/auth/v2/auth", params)
+ .and_return(duo_response_builder.new('{ "response": { "result": "deny" }}'))
+
+ allow(manual_otp).to receive(:duo_client).and_return(duo_client)
+ end
+
+ it 'returns error' do
+ response
+
+ expect(response[:status]).to eq(:error)
+ end
+ end
+
+ context 'when unexpected error' do
+ before do
+ allow(duo_client).to receive(:request)
+ .with("POST", "/auth/v2/auth", params)
+ .and_return(duo_response_builder.new('aaa'))
+
+ allow(manual_otp).to receive(:duo_client).and_return(duo_client)
+ end
+
+ it 'returns error' do
+ response
+
+ expect(response[:status]).to eq(:error)
+ expect(response[:message]).to match(/unexpected character/)
+ end
+ end
+
+ def stub_duo_auth_config(duo_auth_settings)
+ allow(::Gitlab.config.duo_auth).to(receive_messages(duo_auth_settings))
+ end
+
+ def duo_client
+ manual_otp.send(:duo_client)
+ end
+end
diff --git a/spec/lib/gitlab/auth/u2f_webauthn_converter_spec.rb b/spec/lib/gitlab/auth/u2f_webauthn_converter_spec.rb
deleted file mode 100644
index deddc7f5294..00000000000
--- a/spec/lib/gitlab/auth/u2f_webauthn_converter_spec.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Auth::U2fWebauthnConverter do
- let_it_be(:u2f_registration) do
- device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5))
- create(:u2f_registration, name: 'u2f_device',
- certificate: Base64.strict_encode64(device.cert_raw),
- key_handle: U2F.urlsafe_encode64(device.key_handle_raw),
- public_key: Base64.strict_encode64(device.origin_public_key_raw))
- end
-
- it 'converts u2f registration' do
- webauthn_credential = WebAuthn::U2fMigrator.new(
- app_id: Gitlab.config.gitlab.url,
- certificate: u2f_registration.certificate,
- key_handle: u2f_registration.key_handle,
- public_key: u2f_registration.public_key,
- counter: u2f_registration.counter
- ).credential
-
- converted_webauthn = described_class.new(u2f_registration).convert
-
- expect(converted_webauthn).to(
- include(user_id: u2f_registration.user_id,
- credential_xid: Base64.strict_encode64(webauthn_credential.id)))
- end
-end
diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb
index a5f46aa1f35..36c87fb4557 100644
--- a/spec/lib/gitlab/auth_spec.rb
+++ b/spec/lib/gitlab/auth_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching, feature_category: :authentication_and_authorization do
+RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching, feature_category: :system_access do
let_it_be(:project) { create(:project) }
let(:auth_failure) { { actor: nil, project: nil, type: nil, authentication_abilities: nil } }
@@ -21,6 +21,10 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching, feature_cate
expect(subject::REPOSITORY_SCOPES).to match_array %i[read_repository write_repository]
end
+ it 'OBSERVABILITY_SCOPES contains all scopes for Observability access' do
+ expect(subject::OBSERVABILITY_SCOPES).to match_array %i[read_observability write_observability]
+ end
+
it 'OPENID_SCOPES contains all scopes for OpenID Connect' do
expect(subject::OPENID_SCOPES).to match_array [:openid]
end
@@ -31,54 +35,103 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching, feature_cate
end
context 'available_scopes' do
- it 'contains all non-default scopes' do
+ before do
stub_container_registry_config(enabled: true)
+ end
- expect(subject.all_available_scopes).to match_array %i[api read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode]
+ it 'contains all non-default scopes' do
+ expect(subject.all_available_scopes).to match_array %i[api read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode read_observability write_observability]
end
- it 'contains for non-admin user all non-default scopes without ADMIN access' do
- stub_container_registry_config(enabled: true)
- user = create(:user, admin: false)
+ it 'contains for non-admin user all non-default scopes without ADMIN access and without observability scopes' do
+ user = build_stubbed(:user, admin: false)
expect(subject.available_scopes_for(user)).to match_array %i[api read_user read_api read_repository write_repository read_registry write_registry]
end
- it 'contains for admin user all non-default scopes with ADMIN access' do
- stub_container_registry_config(enabled: true)
- user = create(:user, admin: true)
+ it 'contains for admin user all non-default scopes with ADMIN access and without observability scopes' do
+ user = build_stubbed(:user, admin: true)
expect(subject.available_scopes_for(user)).to match_array %i[api read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode]
end
+ it 'contains for project all resource bot scopes without observability scopes' do
+ expect(subject.available_scopes_for(project)).to match_array %i[api read_api read_repository write_repository read_registry write_registry]
+ end
+
+ it 'contains for group all resource bot scopes' do
+ group = build_stubbed(:group)
+
+ expect(subject.available_scopes_for(group)).to match_array %i[api read_api read_repository write_repository read_registry write_registry read_observability write_observability]
+ end
+
+ it 'contains for unsupported type no scopes' do
+ expect(subject.available_scopes_for(:something)).to be_empty
+ end
+
it 'optional_scopes contains all non-default scopes' do
- stub_container_registry_config(enabled: true)
+ expect(subject.optional_scopes).to match_array %i[read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode openid profile email read_observability write_observability]
+ end
+
+ context 'with observability_group_tab feature flag' do
+ context 'when disabled' do
+ before do
+ stub_feature_flags(observability_group_tab: false)
+ end
+
+ it 'contains for group all resource bot scopes without observability scopes' do
+ group = build_stubbed(:group)
- expect(subject.optional_scopes).to match_array %i[read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode openid profile email]
+ expect(subject.available_scopes_for(group)).to match_array %i[api read_api read_repository write_repository read_registry write_registry]
+ end
+ end
+
+ context 'when enabled for specific group' do
+ let(:group) { build_stubbed(:group) }
+
+ before do
+ stub_feature_flags(observability_group_tab: group)
+ end
+
+ it 'contains for other group all resource bot scopes including observability scopes' do
+ expect(subject.available_scopes_for(group)).to match_array %i[api read_api read_repository write_repository read_registry write_registry read_observability write_observability]
+ end
+
+ it 'contains for admin user all non-default scopes with ADMIN access and without observability scopes' do
+ user = build_stubbed(:user, admin: true)
+
+ expect(subject.available_scopes_for(user)).to match_array %i[api read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode]
+ end
+
+ it 'contains for project all resource bot scopes without observability scopes' do
+ expect(subject.available_scopes_for(project)).to match_array %i[api read_api read_repository write_repository read_registry write_registry]
+ end
+
+ it 'contains for other group all resource bot scopes without observability scopes' do
+ other_group = build_stubbed(:group)
+
+ expect(subject.available_scopes_for(other_group)).to match_array %i[api read_api read_repository write_repository read_registry write_registry]
+ end
+ end
end
- context 'with feature flag disabled' do
+ context 'with admin_mode_for_api feature flag disabled' do
before do
stub_feature_flags(admin_mode_for_api: false)
end
it 'contains all non-default scopes' do
- stub_container_registry_config(enabled: true)
-
- expect(subject.all_available_scopes).to match_array %i[api read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode]
+ expect(subject.all_available_scopes).to match_array %i[api read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode read_observability write_observability]
end
- it 'contains for admin user all non-default scopes with ADMIN access' do
- stub_container_registry_config(enabled: true)
- user = create(:user, admin: true)
+ it 'contains for admin user all non-default scopes with ADMIN access and without observability scopes' do
+ user = build_stubbed(:user, admin: true)
expect(subject.available_scopes_for(user)).to match_array %i[api read_user read_api read_repository write_repository read_registry write_registry sudo]
end
it 'optional_scopes contains all non-default scopes' do
- stub_container_registry_config(enabled: true)
-
- expect(subject.optional_scopes).to match_array %i[read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode openid profile email]
+ expect(subject.optional_scopes).to match_array %i[read_user read_api read_repository write_repository read_registry write_registry sudo admin_mode openid profile email read_observability write_observability]
end
end
@@ -120,8 +173,8 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching, feature_cate
end
end
- it 'raises an IpBlacklisted exception' do
- expect { subject }.to raise_error(Gitlab::Auth::IpBlacklisted)
+ it 'raises an IpBlocked exception' do
+ expect { subject }.to raise_error(Gitlab::Auth::IpBlocked)
end
end
@@ -314,15 +367,17 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching, feature_cate
using RSpec::Parameterized::TableSyntax
where(:scopes, :abilities) do
- 'api' | described_class.full_authentication_abilities
- 'read_api' | described_class.read_only_authentication_abilities
- 'read_repository' | [:download_code]
- 'write_repository' | [:download_code, :push_code]
- 'read_user' | []
- 'sudo' | []
- 'openid' | []
- 'profile' | []
- 'email' | []
+ 'api' | described_class.full_authentication_abilities
+ 'read_api' | described_class.read_only_authentication_abilities
+ 'read_repository' | [:download_code]
+ 'write_repository' | [:download_code, :push_code]
+ 'read_user' | []
+ 'sudo' | []
+ 'openid' | []
+ 'profile' | []
+ 'email' | []
+ 'read_observability' | []
+ 'write_observability' | []
end
with_them do
@@ -1024,6 +1079,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching, feature_cate
it { is_expected.to include(*described_class::API_SCOPES - [:read_user]) }
it { is_expected.to include(*described_class::REPOSITORY_SCOPES) }
it { is_expected.to include(*described_class.registry_scopes) }
+ it { is_expected.to include(*described_class::OBSERVABILITY_SCOPES) }
end
private
diff --git a/spec/lib/gitlab/avatar_cache_spec.rb b/spec/lib/gitlab/avatar_cache_spec.rb
index ffe6f81b6e7..a57d811edaf 100644
--- a/spec/lib/gitlab/avatar_cache_spec.rb
+++ b/spec/lib/gitlab/avatar_cache_spec.rb
@@ -62,40 +62,52 @@ RSpec.describe Gitlab::AvatarCache, :clean_gitlab_redis_cache do
end
describe "#delete_by_email" do
- subject { described_class.delete_by_email(*emails) }
+ shared_examples 'delete emails' do
+ subject { described_class.delete_by_email(*emails) }
- before do
- perform_fetch
- end
+ before do
+ perform_fetch
+ end
- context "no emails, somehow" do
- let(:emails) { [] }
+ context "no emails, somehow" do
+ let(:emails) { [] }
- it { is_expected.to eq(0) }
- end
+ it { is_expected.to eq(0) }
+ end
- context "single email" do
- let(:emails) { "foo@bar.com" }
+ context "single email" do
+ let(:emails) { "foo@bar.com" }
- it "removes the email" do
- expect(read(key, "20:2:true")).to eq(avatar_path)
+ it "removes the email" do
+ expect(read(key, "20:2:true")).to eq(avatar_path)
- expect(subject).to eq(1)
+ expect(subject).to eq(1)
- expect(read(key, "20:2:true")).to eq(nil)
+ expect(read(key, "20:2:true")).to eq(nil)
+ end
end
- end
- context "multiple emails" do
- let(:emails) { ["foo@bar.com", "missing@baz.com"] }
+ context "multiple emails" do
+ let(:emails) { ["foo@bar.com", "missing@baz.com"] }
- it "removes the emails it finds" do
- expect(read(key, "20:2:true")).to eq(avatar_path)
+ it "removes the emails it finds" do
+ expect(read(key, "20:2:true")).to eq(avatar_path)
- expect(subject).to eq(1)
+ expect(subject).to eq(1)
- expect(read(key, "20:2:true")).to eq(nil)
+ expect(read(key, "20:2:true")).to eq(nil)
+ end
+ end
+ end
+
+ context 'when feature flag disabled' do
+ before do
+ stub_feature_flags(use_pipeline_over_multikey: false)
end
+
+ it_behaves_like 'delete emails'
end
+
+ it_behaves_like 'delete emails'
end
end
diff --git a/spec/lib/gitlab/background_migration/backfill_admin_mode_scope_for_personal_access_tokens_spec.rb b/spec/lib/gitlab/background_migration/backfill_admin_mode_scope_for_personal_access_tokens_spec.rb
index 7075d4694ae..92fec48454c 100644
--- a/spec/lib/gitlab/background_migration/backfill_admin_mode_scope_for_personal_access_tokens_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_admin_mode_scope_for_personal_access_tokens_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::BackfillAdminModeScopeForPersonalAccessTokens,
- :migration, schema: 20221228103133, feature_category: :authentication_and_authorization do
+ :migration, schema: 20221228103133, feature_category: :system_access do
let(:users) { table(:users) }
let(:personal_access_tokens) { table(:personal_access_tokens) }
@@ -24,8 +24,12 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillAdminModeScopeForPersonalAcc
personal_access_tokens.create!(name: 'admin 4', user_id: admin.id, scopes: "---\n- admin_mode\n")
end
- let!(:pat_admin_2) { personal_access_tokens.create!(name: 'admin 5', user_id: admin.id, scopes: "---\n- read_api\n") }
- let!(:pat_not_in_range) { personal_access_tokens.create!(name: 'admin 6', user_id: admin.id, scopes: "---\n- api\n") }
+ let!(:pat_with_symbol_in_scopes) do
+ personal_access_tokens.create!(name: 'admin 5', user_id: admin.id, scopes: "---\n- :api\n")
+ end
+
+ let!(:pat_admin_2) { personal_access_tokens.create!(name: 'admin 6', user_id: admin.id, scopes: "---\n- read_api\n") }
+ let!(:pat_not_in_range) { personal_access_tokens.create!(name: 'admin 7', user_id: admin.id, scopes: "---\n- api\n") }
subject do
described_class.new(
@@ -47,6 +51,7 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillAdminModeScopeForPersonalAcc
expect(pat_revoked.reload.scopes).to eq("---\n- api\n")
expect(pat_expired.reload.scopes).to eq("---\n- api\n")
expect(pat_admin_mode.reload.scopes).to eq("---\n- admin_mode\n")
+ expect(pat_with_symbol_in_scopes.reload.scopes).to eq("---\n- api\n- admin_mode\n")
expect(pat_admin_2.reload.scopes).to eq("---\n- read_api\n- admin_mode\n")
expect(pat_not_in_range.reload.scopes).to eq("---\n- api\n")
end
diff --git a/spec/lib/gitlab/background_migration/backfill_cluster_agents_has_vulnerabilities_spec.rb b/spec/lib/gitlab/background_migration/backfill_cluster_agents_has_vulnerabilities_spec.rb
index 3aab0cdf54b..edb6ff59340 100644
--- a/spec/lib/gitlab/background_migration/backfill_cluster_agents_has_vulnerabilities_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_cluster_agents_has_vulnerabilities_spec.rb
@@ -4,10 +4,12 @@ require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::BackfillClusterAgentsHasVulnerabilities, :migration do # rubocop:disable Layout/LineLength
let(:migration) do
- described_class.new(start_id: 1, end_id: 10,
- batch_table: table_name, batch_column: batch_column,
- sub_batch_size: sub_batch_size, pause_ms: pause_ms,
- connection: ApplicationRecord.connection)
+ described_class.new(
+ start_id: 1, end_id: 10,
+ batch_table: table_name, batch_column: batch_column,
+ sub_batch_size: sub_batch_size, pause_ms: pause_ms,
+ connection: ApplicationRecord.connection
+ )
end
let(:users_table) { table(:users) }
diff --git a/spec/lib/gitlab/background_migration/backfill_design_management_repositories_spec.rb b/spec/lib/gitlab/background_migration/backfill_design_management_repositories_spec.rb
new file mode 100644
index 00000000000..0cabdc78db8
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_design_management_repositories_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe(
+ Gitlab::BackgroundMigration::BackfillDesignManagementRepositories,
+ schema: 20230406121544,
+ feature_category: :geo_replication
+) do
+ let!(:namespaces) { table(:namespaces) }
+ let!(:projects) { table(:projects) }
+ let!(:design_management_repositories) { table(:design_management_repositories) }
+
+ subject(:migration) do
+ described_class.new(
+ start_id: projects.minimum(:id),
+ end_id: projects.maximum(:id),
+ batch_table: :projects,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ )
+ end
+
+ describe '#perform' do
+ it 'creates design_management_repositories entries for all projects in range' do
+ namespace1 = create_namespace('test1')
+ namespace2 = create_namespace('test2')
+ project1 = create_project(namespace1, 'test1')
+ project2 = create_project(namespace2, 'test2')
+ design_management_repositories.create!(project_id: project2.id)
+
+ expect { migration.perform }
+ .to change { design_management_repositories.pluck(:project_id) }
+ .from([project2.id])
+ .to match_array([project1.id, project2.id])
+ end
+
+ context 'when project_id already exists in design_management_repositories' do
+ it "doesn't duplicate project_id" do
+ namespace = create_namespace('test1')
+ project = create_project(namespace, 'test1')
+ design_management_repositories.create!(project_id: project.id)
+
+ expect { migration.perform }
+ .not_to change { design_management_repositories.pluck(:project_id) }
+ end
+ end
+
+ def create_namespace(name)
+ namespaces.create!(
+ name: name,
+ path: name,
+ type: 'Project'
+ )
+ end
+
+ def create_project(namespace, name)
+ projects.create!(
+ namespace_id: namespace.id,
+ project_namespace_id: namespace.id,
+ name: name,
+ path: name
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_environment_tiers_spec.rb b/spec/lib/gitlab/background_migration/backfill_environment_tiers_spec.rb
index 788ed40b61e..9026c327e3c 100644
--- a/spec/lib/gitlab/background_migration/backfill_environment_tiers_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_environment_tiers_spec.rb
@@ -8,10 +8,12 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillEnvironmentTiers,
let!(:project) { table(:projects).create!(namespace_id: namespace.id, project_namespace_id: namespace.id) }
let(:migration) do
- described_class.new(start_id: 1, end_id: 1000,
- batch_table: :environments, batch_column: :id,
- sub_batch_size: 10, pause_ms: 0,
- connection: ApplicationRecord.connection)
+ described_class.new(
+ start_id: 1, end_id: 1000,
+ batch_table: :environments, batch_column: :id,
+ sub_batch_size: 10, pause_ms: 0,
+ connection: ApplicationRecord.connection
+ )
end
describe '#perform' do
diff --git a/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb
index e0be5a785b8..023d4b04e63 100644
--- a/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb
@@ -7,14 +7,16 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillGroupFeatures, :migration, s
let(:namespaces) { table(:namespaces) }
subject do
- described_class.new(start_id: 1,
- end_id: 4,
- batch_table: :namespaces,
- batch_column: :id,
- sub_batch_size: 10,
- pause_ms: 0,
- job_arguments: [4],
- connection: ActiveRecord::Base.connection)
+ described_class.new(
+ start_id: 1,
+ end_id: 4,
+ batch_table: :namespaces,
+ batch_column: :id,
+ sub_batch_size: 10,
+ pause_ms: 0,
+ job_arguments: [4],
+ connection: ActiveRecord::Base.connection
+ )
end
describe '#perform' do
diff --git a/spec/lib/gitlab/background_migration/backfill_imported_issue_search_data_spec.rb b/spec/lib/gitlab/background_migration/backfill_imported_issue_search_data_spec.rb
index 479afb56210..b3f04055e0a 100644
--- a/spec/lib/gitlab/background_migration/backfill_imported_issue_search_data_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_imported_issue_search_data_spec.rb
@@ -30,13 +30,15 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillImportedIssueSearchData,
end
let(:migration) do
- described_class.new(start_id: issue.id,
- end_id: issue.id + 30,
- batch_table: :issues,
- batch_column: :id,
- sub_batch_size: 2,
- pause_ms: 0,
- connection: ApplicationRecord.connection)
+ described_class.new(
+ start_id: issue.id,
+ end_id: issue.id + 30,
+ batch_table: :issues,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ApplicationRecord.connection
+ )
end
let(:perform_migration) { migration.perform }
diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_details_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_details_spec.rb
index b6282de0da6..39ad60fb13b 100644
--- a/spec/lib/gitlab/background_migration/backfill_namespace_details_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_namespace_details_spec.rb
@@ -7,27 +7,36 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceDetails, :migration
let(:namespace_details) { table(:namespace_details) }
subject(:perform_migration) do
- described_class.new(start_id: namespaces.minimum(:id),
- end_id: namespaces.maximum(:id),
- batch_table: :namespaces,
- batch_column: :id,
- sub_batch_size: 2,
- pause_ms: 0,
- connection: ActiveRecord::Base.connection)
- .perform
+ described_class.new(
+ start_id: namespaces.minimum(:id),
+ end_id: namespaces.maximum(:id),
+ batch_table: :namespaces,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
end
describe '#perform' do
it 'creates details for all namespaces in range' do
- namespace1 = namespaces.create!(id: 5, name: 'test1', path: 'test1', description: "Some description1",
- description_html: "Some description html1", cached_markdown_version: 4)
- namespaces.create!(id: 6, name: 'test2', path: 'test2', type: 'Project',
- description: "Some description2", description_html: "Some description html2",
- cached_markdown_version: 4)
- namespace3 = namespaces.create!(id: 7, name: 'test3', path: 'test3', description: "Some description3",
- description_html: "Some description html3", cached_markdown_version: 4)
- namespace4 = namespaces.create!(id: 8, name: 'test4', path: 'test4', description: "Some description3",
- description_html: "Some description html4", cached_markdown_version: 4)
+ namespace1 = namespaces.create!(
+ id: 5, name: 'test1', path: 'test1', description: "Some description1",
+ description_html: "Some description html1", cached_markdown_version: 4
+ )
+ namespaces.create!(
+ id: 6, name: 'test2', path: 'test2', type: 'Project',
+ description: "Some description2", description_html: "Some description html2",
+ cached_markdown_version: 4
+ )
+ namespace3 = namespaces.create!(
+ id: 7, name: 'test3', path: 'test3', description: "Some description3",
+ description_html: "Some description html3", cached_markdown_version: 4
+ )
+ namespace4 = namespaces.create!(
+ id: 8, name: 'test4', path: 'test4', description: "Some description3",
+ description_html: "Some description html4", cached_markdown_version: 4
+ )
namespace_details.delete_all
expect(namespace_details.pluck(:namespace_id)).to eql []
diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_id_for_namespace_route_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_id_for_namespace_route_spec.rb
index b821efcadb0..3a8a327550b 100644
--- a/spec/lib/gitlab/background_migration/backfill_namespace_id_for_namespace_route_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_namespace_id_for_namespace_route_spec.rb
@@ -22,18 +22,29 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceIdForNamespaceRoute
subject(:perform_migration) { migration.perform(1, 10, table_name, batch_column, sub_batch_size, pause_ms) }
before do
- routes_table.create!(id: 1, name: 'test1', path: 'test1', source_id: namespace1.id,
- source_type: namespace1.class.sti_name)
- routes_table.create!(id: 2, name: 'test2', path: 'test2', source_id: namespace2.id,
- source_type: namespace2.class.sti_name)
- routes_table.create!(id: 5, name: 'test3', path: 'test3', source_id: project1.id,
- source_type: project1.class.sti_name) # should be ignored - project route
- routes_table.create!(id: 6, name: 'test4', path: 'test4', source_id: non_existing_record_id,
- source_type: namespace3.class.sti_name) # should be ignored - invalid source_id
- routes_table.create!(id: 10, name: 'test5', path: 'test5', source_id: namespace3.id,
- source_type: namespace3.class.sti_name)
- routes_table.create!(id: 11, name: 'test6', path: 'test6', source_id: namespace4.id,
- source_type: namespace4.class.sti_name) # should be ignored - outside the scope
+ routes_table.create!(
+ id: 1, name: 'test1', path: 'test1', source_id: namespace1.id, source_type: namespace1.class.sti_name
+ )
+
+ routes_table.create!(
+ id: 2, name: 'test2', path: 'test2', source_id: namespace2.id, source_type: namespace2.class.sti_name
+ )
+
+ routes_table.create!(
+ id: 5, name: 'test3', path: 'test3', source_id: project1.id, source_type: project1.class.sti_name
+ ) # should be ignored - project route
+
+ routes_table.create!(
+ id: 6, name: 'test4', path: 'test4', source_id: non_existing_record_id, source_type: namespace3.class.sti_name
+ ) # should be ignored - invalid source_id
+
+ routes_table.create!(
+ id: 10, name: 'test5', path: 'test5', source_id: namespace3.id, source_type: namespace3.class.sti_name
+ )
+
+ routes_table.create!(
+ id: 11, name: 'test6', path: 'test6', source_id: namespace4.id, source_type: namespace4.class.sti_name
+ ) # should be ignored - outside the scope
end
it 'backfills `type` for the selected records', :aggregate_failures do
diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_id_of_vulnerability_reads_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_id_of_vulnerability_reads_spec.rb
index 564aa3b8c01..6a55c6951d5 100644
--- a/spec/lib/gitlab/background_migration/backfill_namespace_id_of_vulnerability_reads_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_namespace_id_of_vulnerability_reads_spec.rb
@@ -38,14 +38,15 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceIdOfVulnerabilityRe
end
subject(:perform_migration) do
- described_class.new(start_id: vulnerability_read.vulnerability_id,
- end_id: vulnerability_read.vulnerability_id,
- batch_table: :vulnerability_reads,
- batch_column: :vulnerability_id,
- sub_batch_size: 1,
- pause_ms: 0,
- connection: ActiveRecord::Base.connection)
- .perform
+ described_class.new(
+ start_id: vulnerability_read.vulnerability_id,
+ end_id: vulnerability_read.vulnerability_id,
+ batch_table: :vulnerability_reads,
+ batch_column: :vulnerability_id,
+ sub_batch_size: 1,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
end
it 'sets the namespace_id of existing record' do
diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children_spec.rb
deleted file mode 100644
index 876eb070745..00000000000
--- a/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsChildren, :migration, schema: 20210826171758 do
- let(:namespaces_table) { table(:namespaces) }
-
- let!(:user_namespace) { namespaces_table.create!(id: 1, name: 'user', path: 'user', type: nil) }
- let!(:root_group) { namespaces_table.create!(id: 2, name: 'group', path: 'group', type: 'Group', parent_id: nil) }
- let!(:sub_group) { namespaces_table.create!(id: 3, name: 'subgroup', path: 'subgroup', type: 'Group', parent_id: 2) }
-
- describe '#perform' do
- it 'backfills traversal_ids for child namespaces' do
- described_class.new.perform(1, 3, 5)
-
- expect(user_namespace.reload.traversal_ids).to eq([])
- expect(root_group.reload.traversal_ids).to eq([])
- expect(sub_group.reload.traversal_ids).to eq([root_group.id, sub_group.id])
- end
- end
-end
diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots_spec.rb
deleted file mode 100644
index ad9b54608c6..00000000000
--- a/spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsRoots, :migration, schema: 20210826171758 do
- let(:namespaces_table) { table(:namespaces) }
-
- let!(:user_namespace) { namespaces_table.create!(id: 1, name: 'user', path: 'user', type: nil) }
- let!(:root_group) { namespaces_table.create!(id: 2, name: 'group', path: 'group', type: 'Group', parent_id: nil) }
- let!(:sub_group) { namespaces_table.create!(id: 3, name: 'subgroup', path: 'subgroup', type: 'Group', parent_id: 2) }
-
- describe '#perform' do
- it 'backfills traversal_ids for root namespaces' do
- described_class.new.perform(1, 3, 5)
-
- expect(user_namespace.reload.traversal_ids).to eq([user_namespace.id])
- expect(root_group.reload.traversal_ids).to eq([root_group.id])
- expect(sub_group.reload.traversal_ids).to eq([])
- end
- end
-end
diff --git a/spec/lib/gitlab/background_migration/backfill_partitioned_table_spec.rb b/spec/lib/gitlab/background_migration/backfill_partitioned_table_spec.rb
new file mode 100644
index 00000000000..53216cc780b
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_partitioned_table_spec.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillPartitionedTable, feature_category: :database do
+ subject(:backfill_job) do
+ described_class.new(
+ start_id: 1,
+ end_id: 3,
+ batch_table: source_table,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ job_arguments: [destination_table],
+ connection: connection
+ )
+ end
+
+ let(:connection) { ApplicationRecord.connection }
+ let(:source_table) { '_test_source_table' }
+ let(:destination_table) { "#{source_table}_partitioned" }
+ let(:source_model) { Class.new(ApplicationRecord) }
+ let(:destination_model) { Class.new(ApplicationRecord) }
+
+ describe '#perform' do
+ context 'without the destination table' do
+ let(:expected_error_message) do
+ "exiting backfill migration because partitioned table #{destination_table} does not exist. " \
+ "This could be due to rollback of the migration which created the partitioned table."
+ end
+
+ it 'raises an exception' do
+ expect { backfill_job.perform }.to raise_error(expected_error_message)
+ end
+ end
+
+ context 'with destination table being not partitioned' do
+ before do
+ connection.execute(<<~SQL)
+ CREATE TABLE #{destination_table} (
+ id serial NOT NULL,
+ col1 int NOT NULL,
+ col2 text NOT NULL,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (id, created_at)
+ )
+ SQL
+ end
+
+ after do
+ connection.drop_table destination_table
+ end
+
+ let(:expected_error_message) do
+ "exiting backfill migration because the given destination table is not partitioned."
+ end
+
+ it 'raises an exception' do
+ expect { backfill_job.perform }.to raise_error(expected_error_message)
+ end
+ end
+
+ context 'when the destination table exists' do
+ before do
+ connection.execute(<<~SQL)
+ CREATE TABLE #{source_table} (
+ id serial NOT NULL PRIMARY KEY,
+ col1 int NOT NULL,
+ col2 text NOT NULL,
+ created_at timestamptz NOT NULL
+ )
+ SQL
+
+ connection.execute(<<~SQL)
+ CREATE TABLE #{destination_table} (
+ id serial NOT NULL,
+ col1 int NOT NULL,
+ col2 text NOT NULL,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ SQL
+
+ connection.execute(<<~SQL)
+ CREATE TABLE #{destination_table}_202001 PARTITION OF #{destination_table}
+ FOR VALUES FROM ('2020-01-01') TO ('2020-02-01')
+ SQL
+
+ connection.execute(<<~SQL)
+ CREATE TABLE #{destination_table}_202002 PARTITION OF #{destination_table}
+ FOR VALUES FROM ('2020-02-01') TO ('2020-03-01')
+ SQL
+
+ source_model.table_name = source_table
+ destination_model.table_name = destination_table
+ end
+
+ after do
+ connection.drop_table source_table
+ connection.drop_table destination_table
+ end
+
+ let(:timestamp) { Time.utc(2020, 1, 2).round }
+ let!(:source1) { create_source_record(timestamp) }
+ let!(:source2) { create_source_record(timestamp + 1.day) }
+ let!(:source3) { create_source_record(timestamp + 1.month) }
+
+ it 'copies data into the destination table idempotently' do
+ expect(destination_model.count).to eq(0)
+
+ backfill_job.perform
+
+ expect(destination_model.count).to eq(3)
+
+ source_model.find_each do |source_record|
+ destination_record = destination_model.find_by_id(source_record.id)
+
+ expect(destination_record.attributes).to eq(source_record.attributes)
+ end
+
+ backfill_job.perform
+
+ expect(destination_model.count).to eq(3)
+ end
+
+ it 'breaks the assigned batch into smaller sub batches' do
+ expect_next_instance_of(Gitlab::Database::PartitioningMigrationHelpers::BulkCopy) do |bulk_copy|
+ expect(bulk_copy).to receive(:copy_between).with(source1.id, source2.id)
+ expect(bulk_copy).to receive(:copy_between).with(source3.id, source3.id)
+ end
+
+ backfill_job.perform
+ end
+ end
+ end
+
+ def create_source_record(timestamp)
+ source_model.create!(col1: 123, col2: 'original value', created_at: timestamp)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_prepared_at_merge_requests_spec.rb b/spec/lib/gitlab/background_migration/backfill_prepared_at_merge_requests_spec.rb
new file mode 100644
index 00000000000..28ecfae1bd4
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_prepared_at_merge_requests_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillPreparedAtMergeRequests, :migration,
+ feature_category: :code_review_workflow, schema: 20230202135758 do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:mr_table) { table(:merge_requests) }
+
+ let(:namespace) { namespaces.create!(name: 'batchtest1', type: 'Group', path: 'space1') }
+ let(:proj_namespace) { namespaces.create!(name: 'proj1', path: 'proj1', type: 'Project', parent_id: namespace.id) }
+ let(:project) do
+ projects.create!(name: 'proj1', path: 'proj1', namespace_id: namespace.id, project_namespace_id: proj_namespace.id)
+ end
+
+ it 'updates merge requests with prepared_at nil' do
+ time = Time.current
+
+ mr_1 = mr_table.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'feature',
+ prepared_at: nil, merge_status: 'checking')
+ mr_2 = mr_table.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'feature',
+ prepared_at: nil, merge_status: 'preparing')
+ mr_3 = mr_table.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'feature',
+ prepared_at: time)
+ mr_4 = mr_table.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'feature',
+ prepared_at: time, merge_status: 'checking')
+ mr_5 = mr_table.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'feature',
+ prepared_at: time, merge_status: 'preparing')
+
+ test_worker = described_class.new(
+ start_id: mr_1.id,
+ end_id: [(mr_5.id + 1), 100].max,
+ batch_table: :merge_requests,
+ batch_column: :id,
+ sub_batch_size: 10,
+ pause_ms: 0,
+ connection: ApplicationRecord.connection
+ )
+
+ expect(mr_1.prepared_at).to be_nil
+ expect(mr_2.prepared_at).to be_nil
+ expect(mr_3.prepared_at.to_i).to eq(time.to_i)
+ expect(mr_4.prepared_at.to_i).to eq(time.to_i)
+ expect(mr_5.prepared_at.to_i).to eq(time.to_i)
+
+ test_worker.perform
+
+ expect(mr_1.reload.prepared_at.to_i).to eq(mr_1.created_at.to_i)
+ expect(mr_2.reload.prepared_at).to be_nil
+ expect(mr_3.reload.prepared_at.to_i).to eq(time.to_i)
+ expect(mr_4.reload.prepared_at.to_i).to eq(time.to_i)
+ expect(mr_5.reload.prepared_at.to_i).to eq(time.to_i)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_project_feature_package_registry_access_level_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_feature_package_registry_access_level_spec.rb
index fd6c055b9f6..47ff2883fb2 100644
--- a/spec/lib/gitlab/background_migration/backfill_project_feature_package_registry_access_level_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_project_feature_package_registry_access_level_spec.rb
@@ -101,14 +101,15 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillProjectFeaturePackageRegistr
end
subject(:perform_migration) do
- described_class.new(start_id: project1.id,
- end_id: project5.id,
- batch_table: :projects,
- batch_column: :id,
- sub_batch_size: 2,
- pause_ms: 0,
- connection: ActiveRecord::Base.connection)
- .perform
+ described_class.new(
+ start_id: project1.id,
+ end_id: project5.id,
+ batch_table: :projects,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
end
it 'backfills project_features.package_registry_access_level', :aggregate_failures do
diff --git a/spec/lib/gitlab/background_migration/backfill_project_member_namespace_id_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_member_namespace_id_spec.rb
index ca7ca41a33e..96f49624d22 100644
--- a/spec/lib/gitlab/background_migration/backfill_project_member_namespace_id_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_project_member_namespace_id_spec.rb
@@ -4,10 +4,12 @@ require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::BackfillProjectMemberNamespaceId, :migration, schema: 20220516054011 do
let(:migration) do
- described_class.new(start_id: 1, end_id: 10,
- batch_table: table_name, batch_column: batch_column,
- sub_batch_size: sub_batch_size, pause_ms: pause_ms,
- connection: ApplicationRecord.connection)
+ described_class.new(
+ start_id: 1, end_id: 10,
+ batch_table: table_name, batch_column: batch_column,
+ sub_batch_size: sub_batch_size, pause_ms: pause_ms,
+ connection: ApplicationRecord.connection
+ )
end
let(:members_table) { table(:members) }
@@ -35,37 +37,55 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillProjectMemberNamespaceId, :m
projects_table.create!(id: 102, name: 'project3', path: 'project3', namespace_id: 202, project_namespace_id: 302)
# project1, no member namespace (fill in)
- members_table.create!(id: 1, source_id: 100,
- source_type: 'Project', type: 'ProjectMember',
- member_namespace_id: nil, access_level: 10, notification_level: 3)
+ members_table.create!(
+ id: 1, source_id: 100,
+ source_type: 'Project', type: 'ProjectMember',
+ member_namespace_id: nil, access_level: 10, notification_level: 3
+ )
+
# bogus source id, no member namespace id (do nothing)
- members_table.create!(id: 2, source_id: non_existing_record_id,
- source_type: 'Project', type: 'ProjectMember',
- member_namespace_id: nil, access_level: 10, notification_level: 3)
+ members_table.create!(
+ id: 2, source_id: non_existing_record_id,
+ source_type: 'Project', type: 'ProjectMember',
+ member_namespace_id: nil, access_level: 10, notification_level: 3
+ )
+
# project3, existing member namespace id (do nothing)
- members_table.create!(id: 3, source_id: 102,
- source_type: 'Project', type: 'ProjectMember',
- member_namespace_id: 300, access_level: 10, notification_level: 3)
+ members_table.create!(
+ id: 3, source_id: 102,
+ source_type: 'Project', type: 'ProjectMember',
+ member_namespace_id: 300, access_level: 10, notification_level: 3
+ )
# Group memberships (do not change)
# group1, no member namespace (do nothing)
- members_table.create!(id: 4, source_id: 201,
- source_type: 'Namespace', type: 'GroupMember',
- member_namespace_id: nil, access_level: 10, notification_level: 3)
+ members_table.create!(
+ id: 4, source_id: 201,
+ source_type: 'Namespace', type: 'GroupMember',
+ member_namespace_id: nil, access_level: 10, notification_level: 3
+ )
+
# group2, existing member namespace (do nothing)
- members_table.create!(id: 5, source_id: 202,
- source_type: 'Namespace', type: 'GroupMember',
- member_namespace_id: 201, access_level: 10, notification_level: 3)
+ members_table.create!(
+ id: 5, source_id: 202,
+ source_type: 'Namespace', type: 'GroupMember',
+ member_namespace_id: 201, access_level: 10, notification_level: 3
+ )
# Project Namespace memberships (do not change)
# project namespace, existing member namespace (do nothing)
- members_table.create!(id: 6, source_id: 300,
- source_type: 'Namespace', type: 'ProjectNamespaceMember',
- member_namespace_id: 201, access_level: 10, notification_level: 3)
+ members_table.create!(
+ id: 6, source_id: 300,
+ source_type: 'Namespace', type: 'ProjectNamespaceMember',
+ member_namespace_id: 201, access_level: 10, notification_level: 3
+ )
+
# project namespace, not member namespace (do nothing)
- members_table.create!(id: 7, source_id: 301,
- source_type: 'Namespace', type: 'ProjectNamespaceMember',
- member_namespace_id: 201, access_level: 10, notification_level: 3)
+ members_table.create!(
+ id: 7, source_id: 301,
+ source_type: 'Namespace', type: 'ProjectNamespaceMember',
+ member_namespace_id: 201, access_level: 10, notification_level: 3
+ )
end
it 'backfills `member_namespace_id` for the selected records', :aggregate_failures do
diff --git a/spec/lib/gitlab/background_migration/backfill_project_namespace_details_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_namespace_details_spec.rb
index 01daf16d10c..aac17a426b5 100644
--- a/spec/lib/gitlab/background_migration/backfill_project_namespace_details_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_project_namespace_details_spec.rb
@@ -8,32 +8,41 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillProjectNamespaceDetails, :mi
let!(:projects) { table(:projects) }
subject(:perform_migration) do
- described_class.new(start_id: projects.minimum(:id),
- end_id: projects.maximum(:id),
- batch_table: :projects,
- batch_column: :id,
- sub_batch_size: 2,
- pause_ms: 0,
- connection: ActiveRecord::Base.connection)
- .perform
+ described_class.new(
+ start_id: projects.minimum(:id),
+ end_id: projects.maximum(:id),
+ batch_table: :projects,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
end
describe '#perform' do
it 'creates details for all project namespaces in range' do
- namespaces.create!(id: 5, name: 'test1', path: 'test1', description: "Some description1",
- description_html: "Some description html1", cached_markdown_version: 4)
+ namespaces.create!(
+ id: 5, name: 'test1', path: 'test1', description: "Some description1",
+ description_html: "Some description html1", cached_markdown_version: 4
+ )
project_namespace1 = namespaces.create!(id: 6, name: 'test2', path: 'test2', type: 'Project')
- namespaces.create!(id: 7, name: 'test3', path: 'test3', description: "Some description3",
- description_html: "Some description html3", cached_markdown_version: 4)
+ namespaces.create!(
+ id: 7, name: 'test3', path: 'test3', description: "Some description3",
+ description_html: "Some description html3", cached_markdown_version: 4
+ )
project_namespace2 = namespaces.create!(id: 8, name: 'test4', path: 'test4', type: 'Project')
- project1 = projects.create!(namespace_id: project_namespace1.id, name: 'gitlab1', path: 'gitlab1',
- project_namespace_id: project_namespace1.id, description: "Some description2",
- description_html: "Some description html2", cached_markdown_version: 4)
- project2 = projects.create!(namespace_id: project_namespace2.id, name: 'gitlab2', path: 'gitlab2',
- project_namespace_id: project_namespace2.id,
- description: "Some description3",
- description_html: "Some description html4", cached_markdown_version: 4)
+ project1 = projects.create!(
+ namespace_id: project_namespace1.id, name: 'gitlab1', path: 'gitlab1',
+ project_namespace_id: project_namespace1.id, description: "Some description2",
+ description_html: "Some description html2", cached_markdown_version: 4
+ )
+ project2 = projects.create!(
+ namespace_id: project_namespace2.id, name: 'gitlab2', path: 'gitlab2',
+ project_namespace_id: project_namespace2.id,
+ description: "Some description3",
+ description_html: "Some description html4", cached_markdown_version: 4
+ )
namespace_details.delete_all
diff --git a/spec/lib/gitlab/background_migration/backfill_project_wiki_repositories_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_wiki_repositories_spec.rb
new file mode 100644
index 00000000000..e81bd0604e6
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_project_wiki_repositories_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe(
+ Gitlab::BackgroundMigration::BackfillProjectWikiRepositories,
+ schema: 20230306195007,
+ feature_category: :geo_replication) do
+ let!(:namespaces) { table(:namespaces) }
+ let!(:projects) { table(:projects) }
+ let!(:project_wiki_repositories) { table(:project_wiki_repositories) }
+
+ subject(:migration) do
+ described_class.new(
+ start_id: projects.minimum(:id),
+ end_id: projects.maximum(:id),
+ batch_table: :projects,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ )
+ end
+
+ describe '#perform' do
+ it 'creates project_wiki_repositories entries for all projects in range' do
+ namespace1 = create_namespace('test1')
+ namespace2 = create_namespace('test2')
+ project1 = create_project(namespace1, 'test1')
+ project2 = create_project(namespace2, 'test2')
+ project_wiki_repositories.create!(project_id: project2.id)
+
+ expect { migration.perform }
+ .to change { project_wiki_repositories.pluck(:project_id) }
+ .from([project2.id])
+ .to match_array([project1.id, project2.id])
+ end
+
+ it 'does nothing if project_id already exist in project_wiki_repositories' do
+ namespace = create_namespace('test1')
+ project = create_project(namespace, 'test1')
+ project_wiki_repositories.create!(project_id: project.id)
+
+ expect { migration.perform }
+ .not_to change { project_wiki_repositories.pluck(:project_id) }
+ end
+
+ def create_namespace(name)
+ namespaces.create!(
+ name: name,
+ path: name,
+ type: 'Project'
+ )
+ end
+
+ def create_project(namespace, name)
+ projects.create!(
+ namespace_id: namespace.id,
+ project_namespace_id: namespace.id,
+ name: name,
+ path: name
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_releases_author_id_spec.rb b/spec/lib/gitlab/background_migration/backfill_releases_author_id_spec.rb
index d8ad10849f2..898f241a930 100644
--- a/spec/lib/gitlab/background_migration/backfill_releases_author_id_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_releases_author_id_spec.rb
@@ -10,35 +10,52 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillReleasesAuthorId,
let!(:test_user) { user_table.create!(name: 'test', email: 'test@example.com', username: 'test', projects_limit: 10) }
let!(:ghost_user) do
- user_table.create!(name: 'ghost', email: 'ghost@example.com',
- username: 'ghost', user_type: User::USER_TYPES['ghost'], projects_limit: 100000)
+ user_table.create!(
+ name: 'ghost', email: 'ghost@example.com',
+ username: 'ghost', user_type: User::USER_TYPES['ghost'], projects_limit: 100000
+ )
end
let(:migration) do
- described_class.new(start_id: 1, end_id: 100,
- batch_table: :releases, batch_column: :id,
- sub_batch_size: 10, pause_ms: 0,
- job_arguments: [ghost_user.id],
- connection: ApplicationRecord.connection)
+ described_class.new(
+ start_id: 1, end_id: 100,
+ batch_table: :releases, batch_column: :id,
+ sub_batch_size: 10, pause_ms: 0,
+ job_arguments: [ghost_user.id],
+ connection: ApplicationRecord.connection
+ )
end
subject(:perform_migration) { migration.perform }
before do
- releases_table.create!(tag: 'tag1', name: 'tag1',
- released_at: (date_time - 1.minute), author_id: test_user.id)
- releases_table.create!(tag: 'tag2', name: 'tag2',
- released_at: (date_time - 2.minutes), author_id: test_user.id)
- releases_table.new(tag: 'tag3', name: 'tag3',
- released_at: (date_time - 3.minutes), author_id: nil).save!(validate: false)
- releases_table.new(tag: 'tag4', name: 'tag4',
- released_at: (date_time - 4.minutes), author_id: nil).save!(validate: false)
- releases_table.new(tag: 'tag5', name: 'tag5',
- released_at: (date_time - 5.minutes), author_id: nil).save!(validate: false)
- releases_table.create!(tag: 'tag6', name: 'tag6',
- released_at: (date_time - 6.minutes), author_id: test_user.id)
- releases_table.new(tag: 'tag7', name: 'tag7',
- released_at: (date_time - 7.minutes), author_id: nil).save!(validate: false)
+ releases_table.create!(
+ tag: 'tag1', name: 'tag1', released_at: (date_time - 1.minute), author_id: test_user.id
+ )
+
+ releases_table.create!(
+ tag: 'tag2', name: 'tag2', released_at: (date_time - 2.minutes), author_id: test_user.id
+ )
+
+ releases_table.new(
+ tag: 'tag3', name: 'tag3', released_at: (date_time - 3.minutes), author_id: nil
+ ).save!(validate: false)
+
+ releases_table.new(
+ tag: 'tag4', name: 'tag4', released_at: (date_time - 4.minutes), author_id: nil
+ ).save!(validate: false)
+
+ releases_table.new(
+ tag: 'tag5', name: 'tag5', released_at: (date_time - 5.minutes), author_id: nil
+ ).save!(validate: false)
+
+ releases_table.create!(
+ tag: 'tag6', name: 'tag6', released_at: (date_time - 6.minutes), author_id: test_user.id
+ )
+
+ releases_table.new(
+ tag: 'tag7', name: 'tag7', released_at: (date_time - 7.minutes), author_id: nil
+ ).save!(validate: false)
end
it 'backfills `author_id` for the selected records', :aggregate_failures do
diff --git a/spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb b/spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb
index 80fd86e90bb..d8874cb811b 100644
--- a/spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migration, schema: 20210826171758,
-feature_category: :source_code_management do
+RSpec.describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migration, schema: 20211202041233,
+ feature_category: :source_code_management do
let(:gitlab_shell) { Gitlab::Shell.new }
let(:users) { table(:users) }
let(:snippets) { table(:snippets) }
@@ -14,24 +14,28 @@ feature_category: :source_code_management do
let(:user_name) { 'Test' }
let!(:user) do
- users.create!(id: 1,
- email: 'user@example.com',
- projects_limit: 10,
- username: 'test',
- name: user_name,
- state: user_state,
- last_activity_on: 1.minute.ago,
- user_type: user_type,
- confirmed_at: 1.day.ago)
+ users.create!(
+ id: 1,
+ email: 'user@example.com',
+ projects_limit: 10,
+ username: 'test',
+ name: user_name,
+ state: user_state,
+ last_activity_on: 1.minute.ago,
+ user_type: user_type,
+ confirmed_at: 1.day.ago
+ )
end
let!(:migration_bot) do
- users.create!(id: 100,
- email: "noreply+gitlab-migration-bot%s@#{Settings.gitlab.host}",
- user_type: HasUserType::USER_TYPES[:migration_bot],
- name: 'GitLab Migration Bot',
- projects_limit: 10,
- username: 'bot')
+ users.create!(
+ id: 100,
+ email: "noreply+gitlab-migration-bot%s@#{Settings.gitlab.host}",
+ user_type: HasUserType::USER_TYPES[:migration_bot],
+ name: 'GitLab Migration Bot',
+ projects_limit: 10,
+ username: 'bot'
+ )
end
let!(:snippet_with_repo) { snippets.create!(id: 1, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
@@ -260,15 +264,17 @@ feature_category: :source_code_management do
context 'when both user name and snippet file_name are invalid' do
let(:user_name) { '.' }
let!(:other_user) do
- users.create!(id: 2,
- email: 'user2@example.com',
- projects_limit: 10,
- username: 'test2',
- name: 'Test2',
- state: user_state,
- last_activity_on: 1.minute.ago,
- user_type: user_type,
- confirmed_at: 1.day.ago)
+ users.create!(
+ id: 2,
+ email: 'user2@example.com',
+ projects_limit: 10,
+ username: 'test2',
+ name: 'Test2',
+ state: user_state,
+ last_activity_on: 1.minute.ago,
+ user_type: user_type,
+ confirmed_at: 1.day.ago
+ )
end
let!(:invalid_snippet) { snippets.create!(id: 4, type: 'PersonalSnippet', author_id: user.id, file_name: '.', content: content) }
@@ -322,10 +328,12 @@ feature_category: :source_code_management do
end
def raw_repository(snippet)
- Gitlab::Git::Repository.new('default',
- "#{disk_path(snippet)}.git",
- Gitlab::GlRepository::SNIPPET.identifier_for_container(snippet),
- "@snippets/#{snippet.id}")
+ Gitlab::Git::Repository.new(
+ 'default',
+ "#{disk_path(snippet)}.git",
+ Gitlab::GlRepository::SNIPPET.identifier_for_container(snippet),
+ "@snippets/#{snippet.id}"
+ )
end
def hashed_repository(snippet)
diff --git a/spec/lib/gitlab/background_migration/backfill_upvotes_count_on_issues_spec.rb b/spec/lib/gitlab/background_migration/backfill_upvotes_count_on_issues_spec.rb
deleted file mode 100644
index 7142aea3ab2..00000000000
--- a/spec/lib/gitlab/background_migration/backfill_upvotes_count_on_issues_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::BackfillUpvotesCountOnIssues, schema: 20210826171758 do
- let(:award_emoji) { table(:award_emoji) }
-
- let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') }
- let!(:project1) { table(:projects).create!(namespace_id: namespace.id) }
- let!(:project2) { table(:projects).create!(namespace_id: namespace.id) }
- let!(:issue1) { table(:issues).create!(project_id: project1.id) }
- let!(:issue2) { table(:issues).create!(project_id: project2.id) }
- let!(:issue3) { table(:issues).create!(project_id: project2.id) }
- let!(:issue4) { table(:issues).create!(project_id: project2.id) }
-
- describe '#perform' do
- before do
- add_upvotes(issue1, :thumbsdown, 1)
- add_upvotes(issue2, :thumbsup, 2)
- add_upvotes(issue2, :thumbsdown, 1)
- add_upvotes(issue3, :thumbsup, 3)
- add_upvotes(issue4, :thumbsup, 4)
- end
-
- it 'updates upvotes_count' do
- subject.perform(issue1.id, issue4.id)
-
- expect(issue1.reload.upvotes_count).to eq(0)
- expect(issue2.reload.upvotes_count).to eq(2)
- expect(issue3.reload.upvotes_count).to eq(3)
- expect(issue4.reload.upvotes_count).to eq(4)
- end
- end
-
- private
-
- def add_upvotes(issue, name, count)
- count.times do
- award_emoji.create!(
- name: name.to_s,
- awardable_type: 'Issue',
- awardable_id: issue.id
- )
- end
- end
-end
diff --git a/spec/lib/gitlab/background_migration/backfill_user_namespace_spec.rb b/spec/lib/gitlab/background_migration/backfill_user_namespace_spec.rb
deleted file mode 100644
index 395248b786d..00000000000
--- a/spec/lib/gitlab/background_migration/backfill_user_namespace_spec.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::BackfillUserNamespace, :migration, schema: 20210930211936 do
- let(:migration) { described_class.new }
- let(:namespaces_table) { table(:namespaces) }
-
- let(:table_name) { 'namespaces' }
- let(:batch_column) { :id }
- let(:sub_batch_size) { 100 }
- let(:pause_ms) { 0 }
-
- subject(:perform_migration) { migration.perform(1, 10, table_name, batch_column, sub_batch_size, pause_ms) }
-
- before do
- namespaces_table.create!(id: 1, name: 'test1', path: 'test1', type: nil)
- namespaces_table.create!(id: 2, name: 'test2', path: 'test2', type: 'User')
- namespaces_table.create!(id: 3, name: 'test3', path: 'test3', type: 'Group')
- namespaces_table.create!(id: 4, name: 'test4', path: 'test4', type: nil)
- namespaces_table.create!(id: 11, name: 'test11', path: 'test11', type: nil)
- end
-
- it 'backfills `type` for the selected records', :aggregate_failures do
- queries = ActiveRecord::QueryRecorder.new do
- perform_migration
- end
-
- expect(queries.count).to eq(3)
- expect(namespaces_table.where(type: 'User').count).to eq 3
- expect(namespaces_table.where(type: 'User').pluck(:id)).to match_array([1, 2, 4])
- end
-
- it 'tracks timings of queries' do
- expect(migration.batch_metrics.timings).to be_empty
-
- expect { perform_migration }.to change { migration.batch_metrics.timings }
- end
-end
diff --git a/spec/lib/gitlab/background_migration/backfill_vulnerability_reads_cluster_agent_spec.rb b/spec/lib/gitlab/background_migration/backfill_vulnerability_reads_cluster_agent_spec.rb
index f642ec8c20d..3f1a57434a7 100644
--- a/spec/lib/gitlab/background_migration/backfill_vulnerability_reads_cluster_agent_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_vulnerability_reads_cluster_agent_spec.rb
@@ -4,10 +4,12 @@ require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::BackfillVulnerabilityReadsClusterAgent, :migration, schema: 20220525221133 do # rubocop:disable Layout/LineLength
let(:migration) do
- described_class.new(start_id: 1, end_id: 10,
- batch_table: table_name, batch_column: batch_column,
- sub_batch_size: sub_batch_size, pause_ms: pause_ms,
- connection: ApplicationRecord.connection)
+ described_class.new(
+ start_id: 1, end_id: 10,
+ batch_table: table_name, batch_column: batch_column,
+ sub_batch_size: sub_batch_size, pause_ms: pause_ms,
+ connection: ApplicationRecord.connection
+ )
end
let(:users_table) { table(:users) }
diff --git a/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb b/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb
index 5f93424faf6..c7e4095a488 100644
--- a/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb
@@ -2,7 +2,10 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::BackfillWorkItemTypeIdForIssues, :migration, schema: 20220825142324 do
+RSpec.describe Gitlab::BackgroundMigration::BackfillWorkItemTypeIdForIssues,
+ :migration,
+ schema: 20220825142324,
+ feature_category: :team_planning do
let(:batch_column) { 'id' }
let(:sub_batch_size) { 2 }
let(:pause_ms) { 0 }
@@ -13,6 +16,7 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillWorkItemTypeIdForIssues, :mi
let(:project) { table(:projects).create!(namespace_id: namespace.id, project_namespace_id: namespace.id) }
let(:issues_table) { table(:issues) }
let(:issue_type) { table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_type_enum[:issue]) }
+ let(:task_type) { table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_type_enum[:task]) }
let(:issue1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) }
let(:issue2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) }
@@ -25,7 +29,7 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillWorkItemTypeIdForIssues, :mi
let(:start_id) { issue1.id }
let(:end_id) { requirement1.id }
- let(:all_issues) { [issue1, issue2, issue3, incident1, test_case1, requirement1] }
+ let!(:all_issues) { [issue1, issue2, issue3, incident1, test_case1, requirement1] }
let(:migration) do
described_class.new(
@@ -52,6 +56,27 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillWorkItemTypeIdForIssues, :mi
expect(all_issues - [issue1, issue2, issue3]).to all(have_attributes(work_item_type_id: nil))
end
+ context 'when a record already had a work_item_type_id assigned' do
+ let!(:issue4) do
+ issues_table.create!(
+ project_id: project.id,
+ issue_type: issue_type_enum[:issue],
+ work_item_type_id: task_type.id
+ )
+ end
+
+ let(:end_id) { issue4.id }
+
+ it 'ovewrites the work_item_type_id' do
+ # creating with the wrong issue_type/work_item_type_id on purpose so we can test
+ # that the migration is capable of fixing such inconsistencies
+ expect do
+ migrate
+ issue4.reload
+ end.to change { issue4.work_item_type_id }.from(task_type.id).to(issue_type.id)
+ end
+ end
+
it 'tracks timings of queries' do
expect(migration.batch_metrics.timings).to be_empty
diff --git a/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb b/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb
index faaaccfdfaf..781bf93dd85 100644
--- a/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb
+++ b/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb
@@ -301,6 +301,28 @@ RSpec.describe Gitlab::BackgroundMigration::BatchedMigrationJob do
perform_job
end
+ context 'when using a sub batch exception for timeouts' do
+ let(:job_class) do
+ Class.new(described_class) do
+ operation_name :update
+
+ def perform(*_)
+ each_sub_batch { raise ActiveRecord::StatementTimeout } # rubocop:disable Lint/UnreachableLoop
+ end
+ end
+ end
+
+ let(:job_instance) do
+ job_class.new(start_id: 1, end_id: 10, batch_table: '_test_table', batch_column: 'id',
+ sub_batch_size: 2, pause_ms: 1000, connection: connection,
+ sub_batch_exception: StandardError)
+ end
+
+ it 'raises the expected error type' do
+ expect { job_instance.perform }.to raise_error(StandardError)
+ end
+ end
+
context 'when batching_arguments are given' do
it 'forwards them for batching' do
expect(job_instance).to receive(:base_relation).and_return(test_table)
diff --git a/spec/lib/gitlab/background_migration/cleanup_orphaned_lfs_objects_projects_spec.rb b/spec/lib/gitlab/background_migration/cleanup_orphaned_lfs_objects_projects_spec.rb
deleted file mode 100644
index 5ffe665f0ad..00000000000
--- a/spec/lib/gitlab/background_migration/cleanup_orphaned_lfs_objects_projects_spec.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::CleanupOrphanedLfsObjectsProjects, schema: 20210826171758 do
- let(:lfs_objects_projects) { table(:lfs_objects_projects) }
- let(:lfs_objects) { table(:lfs_objects) }
- let(:projects) { table(:projects) }
- let(:namespaces) { table(:namespaces) }
-
- let(:namespace) { namespaces.create!(name: 'namespace', path: 'namespace') }
- let(:project) { projects.create!(namespace_id: namespace.id) }
- let(:another_project) { projects.create!(namespace_id: namespace.id) }
- let(:lfs_object) { lfs_objects.create!(oid: 'abcdef', size: 1) }
- let(:another_lfs_object) { lfs_objects.create!(oid: '1abcde', size: 2) }
-
- let!(:without_object1) { create_object(project_id: project.id) }
- let!(:without_object2) { create_object(project_id: another_project.id) }
- let!(:without_object3) { create_object(project_id: another_project.id) }
- let!(:with_project_and_object1) { create_object(project_id: project.id, lfs_object_id: lfs_object.id) }
- let!(:with_project_and_object2) { create_object(project_id: project.id, lfs_object_id: another_lfs_object.id) }
- let!(:with_project_and_object3) { create_object(project_id: another_project.id, lfs_object_id: another_lfs_object.id) }
- let!(:without_project1) { create_object(lfs_object_id: lfs_object.id) }
- let!(:without_project2) { create_object(lfs_object_id: another_lfs_object.id) }
- let!(:without_project_and_object) { create_object }
-
- def create_object(project_id: non_existing_record_id, lfs_object_id: non_existing_record_id)
- lfs_objects_project = nil
-
- ActiveRecord::Base.connection.disable_referential_integrity do
- lfs_objects_project = lfs_objects_projects.create!(project_id: project_id, lfs_object_id: lfs_object_id)
- end
-
- lfs_objects_project
- end
-
- subject { described_class.new }
-
- describe '#perform' do
- it 'lfs_objects_projects without an existing lfs object or project are removed' do
- subject.perform(without_object1.id, without_object3.id)
-
- expect(lfs_objects_projects.all).to match_array(
- [
- with_project_and_object1, with_project_and_object2, with_project_and_object3,
- without_project1, without_project2, without_project_and_object
- ])
-
- subject.perform(with_project_and_object1.id, with_project_and_object3.id)
-
- expect(lfs_objects_projects.all).to match_array(
- [
- with_project_and_object1, with_project_and_object2, with_project_and_object3,
- without_project1, without_project2, without_project_and_object
- ])
-
- subject.perform(without_project1.id, without_project_and_object.id)
-
- expect(lfs_objects_projects.all).to match_array(
- [
- with_project_and_object1, with_project_and_object2, with_project_and_object3
- ])
-
- expect(lfs_objects.ids).to contain_exactly(lfs_object.id, another_lfs_object.id)
- expect(projects.ids).to contain_exactly(project.id, another_project.id)
- end
-
- it 'cache for affected projects is being reset' do
- expect(ProjectCacheWorker).to receive(:bulk_perform_in) do |delay, args|
- expect(delay).to eq(1.minute)
- expect(args).to match_array([[project.id, [], [:lfs_objects_size]], [another_project.id, [], [:lfs_objects_size]]])
- end
-
- subject.perform(without_object1.id, with_project_and_object1.id)
-
- expect(ProjectCacheWorker).not_to receive(:bulk_perform_in)
-
- subject.perform(with_project_and_object1.id, with_project_and_object3.id)
-
- expect(ProjectCacheWorker).not_to receive(:bulk_perform_in)
-
- subject.perform(without_project1.id, without_project_and_object.id)
- end
- end
-end
diff --git a/spec/lib/gitlab/background_migration/cleanup_personal_access_tokens_with_nil_expires_at_spec.rb b/spec/lib/gitlab/background_migration/cleanup_personal_access_tokens_with_nil_expires_at_spec.rb
new file mode 100644
index 00000000000..ade16c0a780
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/cleanup_personal_access_tokens_with_nil_expires_at_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::CleanupPersonalAccessTokensWithNilExpiresAt, schema: 20230510062503, feature_category: :system_access do # rubocop:disable Layout/LineLength
+ let(:personal_access_tokens_table) { table(:personal_access_tokens) }
+ let(:users_table) { table(:users) }
+ let(:expires_at_default) { described_class::EXPIRES_AT_DEFAULT }
+
+ subject(:perform_migration) do
+ described_class.new(
+ start_id: 1,
+ end_id: 30,
+ batch_table: :personal_access_tokens,
+ batch_column: :id,
+ sub_batch_size: 3,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
+ end
+
+ before do
+ user = users_table.create!(name: 'PAT_USER', email: 'pat_user@gmail.com', username: "pat_user1", projects_limit: 0)
+ personal_access_tokens_table.create!(user_id: user.id, name: "PAT#1", expires_at: expires_at_default + 1.day)
+ personal_access_tokens_table.create!(user_id: user.id, name: "PAT#2", expires_at: nil)
+ personal_access_tokens_table.create!(user_id: user.id, name: "PAT#3", expires_at: Time.zone.now + 2.days)
+ end
+
+ it 'adds expiry to personal access tokens', :aggregate_failures do
+ freeze_time do
+ expect(ActiveRecord::QueryRecorder.new { perform_migration }.count).to eq(3)
+
+ expect(personal_access_tokens_table.find_by_name("PAT#1").expires_at).to eq(expires_at_default.to_date + 1.day)
+ expect(personal_access_tokens_table.find_by_name("PAT#2").expires_at).to eq(expires_at_default.to_date)
+ expect(personal_access_tokens_table.find_by_name("PAT#3").expires_at).to eq(Time.zone.now.to_date + 2.days)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/delete_orphaned_deployments_spec.rb b/spec/lib/gitlab/background_migration/delete_orphaned_deployments_spec.rb
deleted file mode 100644
index 8f058c875a2..00000000000
--- a/spec/lib/gitlab/background_migration/delete_orphaned_deployments_spec.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::DeleteOrphanedDeployments, :migration, schema: 20210826171758 do
- let!(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
- let!(:project) { table(:projects).create!(namespace_id: namespace.id) }
- let!(:environment) { table(:environments).create!(name: 'production', slug: 'production', project_id: project.id) }
- let(:background_migration_jobs) { table(:background_migration_jobs) }
-
- before do
- create_deployment!(environment.id, project.id)
- end
-
- it 'deletes only orphaned deployments' do
- expect(valid_deployments.pluck(:id)).not_to be_empty
-
- subject.perform(table(:deployments).minimum(:id), table(:deployments).maximum(:id))
-
- expect(valid_deployments.pluck(:id)).not_to be_empty
- end
-
- it 'marks jobs as done' do
- first_job = background_migration_jobs.create!(
- class_name: 'DeleteOrphanedDeployments',
- arguments: [table(:deployments).minimum(:id), table(:deployments).minimum(:id)]
- )
-
- subject.perform(table(:deployments).minimum(:id), table(:deployments).minimum(:id))
-
- expect(first_job.reload.status).to eq(Gitlab::Database::BackgroundMigrationJob.statuses[:succeeded])
- end
-
- private
-
- def valid_deployments
- table(:deployments).where('EXISTS (SELECT 1 FROM environments WHERE deployments.environment_id = environments.id)')
- end
-
- def orphaned_deployments
- table(:deployments).where('NOT EXISTS (SELECT 1 FROM environments WHERE deployments.environment_id = environments.id)')
- end
-
- def create_deployment!(environment_id, project_id)
- table(:deployments).create!(
- environment_id: environment_id,
- project_id: project_id,
- ref: 'master',
- tag: false,
- sha: 'x',
- status: 1,
- iid: table(:deployments).count + 1)
- end
-end
diff --git a/spec/lib/gitlab/background_migration/delete_orphaned_packages_dependencies_spec.rb b/spec/lib/gitlab/background_migration/delete_orphaned_packages_dependencies_spec.rb
new file mode 100644
index 00000000000..0d82717c7de
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/delete_orphaned_packages_dependencies_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::DeleteOrphanedPackagesDependencies, schema: 20230303105806,
+ feature_category: :package_registry do
+ let!(:migration_attrs) do
+ {
+ start_id: 1,
+ end_id: 1000,
+ batch_table: :packages_dependencies,
+ batch_column: :id,
+ sub_batch_size: 500,
+ pause_ms: 0,
+ connection: ApplicationRecord.connection
+ }
+ end
+
+ let!(:migration) { described_class.new(**migration_attrs) }
+
+ let(:packages_dependencies) { table(:packages_dependencies) }
+
+ let!(:namespace) { table(:namespaces).create!(name: 'project', path: 'project', type: 'Project') }
+ let!(:project) do
+ table(:projects).create!(name: 'project', path: 'project', project_namespace_id: namespace.id,
+ namespace_id: namespace.id)
+ end
+
+ let!(:package) do
+ table(:packages_packages).create!(name: 'test', version: '1.2.3', package_type: 2, project_id: project.id)
+ end
+
+ let!(:orphan_dependency_1) { packages_dependencies.create!(name: 'dependency 1', version_pattern: '~0.0.1') }
+ let!(:orphan_dependency_2) { packages_dependencies.create!(name: 'dependency 2', version_pattern: '~0.0.2') }
+ let!(:orphan_dependency_3) { packages_dependencies.create!(name: 'dependency 3', version_pattern: '~0.0.3') }
+ let!(:linked_dependency) do
+ packages_dependencies.create!(name: 'dependency 4', version_pattern: '~0.0.4').tap do |dependency|
+ table(:packages_dependency_links).create!(package_id: package.id, dependency_id: dependency.id,
+ dependency_type: 'dependencies')
+ end
+ end
+
+ subject(:perform_migration) { migration.perform }
+
+ it 'executes 3 queries' do
+ queries = ActiveRecord::QueryRecorder.new do
+ perform_migration
+ end
+
+ expect(queries.count).to eq(3)
+ end
+
+ it 'deletes only orphaned dependencies' do
+ expect { perform_migration }.to change { packages_dependencies.count }.by(-3)
+ expect(packages_dependencies.all).to eq([linked_dependency])
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb b/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb
deleted file mode 100644
index e7b0471810d..00000000000
--- a/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb
+++ /dev/null
@@ -1,142 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::DisableExpirationPoliciesLinkedToNoContainerImages, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength
- let!(:projects) { table(:projects) }
- let!(:container_expiration_policies) { table(:container_expiration_policies) }
- let!(:container_repositories) { table(:container_repositories) }
- let!(:namespaces) { table(:namespaces) }
-
- let!(:namespace) { namespaces.create!(name: 'test', path: 'test') }
-
- let!(:policy1) { create_expiration_policy(project_id: 1, enabled: true) }
- let!(:policy2) { create_expiration_policy(project_id: 2, enabled: false) }
- let!(:policy3) { create_expiration_policy(project_id: 3, enabled: false) }
- let!(:policy4) { create_expiration_policy(project_id: 4, enabled: true, with_images: true) }
- let!(:policy5) { create_expiration_policy(project_id: 5, enabled: false, with_images: true) }
- let!(:policy6) { create_expiration_policy(project_id: 6, enabled: false) }
- let!(:policy7) { create_expiration_policy(project_id: 7, enabled: true) }
- let!(:policy8) { create_expiration_policy(project_id: 8, enabled: true, with_images: true) }
- let!(:policy9) { create_expiration_policy(project_id: 9, enabled: true) }
-
- describe '#perform' do
- subject { described_class.new.perform(from_id, to_id) }
-
- shared_examples 'disabling policies with no images' do
- it 'disables the proper policies' do
- subject
-
- rows = container_expiration_policies.order(:project_id).to_h do |row|
- [row.project_id, row.enabled]
- end
- expect(rows).to eq(expected_rows)
- end
- end
-
- context 'the whole range' do
- let(:from_id) { 1 }
- let(:to_id) { 9 }
-
- it_behaves_like 'disabling policies with no images' do
- let(:expected_rows) do
- {
- 1 => false,
- 2 => false,
- 3 => false,
- 4 => true,
- 5 => false,
- 6 => false,
- 7 => false,
- 8 => true,
- 9 => false
- }
- end
- end
- end
-
- context 'a range with no policies to disable' do
- let(:from_id) { 2 }
- let(:to_id) { 6 }
-
- it_behaves_like 'disabling policies with no images' do
- let(:expected_rows) do
- {
- 1 => true,
- 2 => false,
- 3 => false,
- 4 => true,
- 5 => false,
- 6 => false,
- 7 => true,
- 8 => true,
- 9 => true
- }
- end
- end
- end
-
- context 'a range with only images' do
- let(:from_id) { 4 }
- let(:to_id) { 5 }
-
- it_behaves_like 'disabling policies with no images' do
- let(:expected_rows) do
- {
- 1 => true,
- 2 => false,
- 3 => false,
- 4 => true,
- 5 => false,
- 6 => false,
- 7 => true,
- 8 => true,
- 9 => true
- }
- end
- end
- end
-
- context 'a range with a single element' do
- let(:from_id) { 9 }
- let(:to_id) { 9 }
-
- it_behaves_like 'disabling policies with no images' do
- let(:expected_rows) do
- {
- 1 => true,
- 2 => false,
- 3 => false,
- 4 => true,
- 5 => false,
- 6 => false,
- 7 => true,
- 8 => true,
- 9 => false
- }
- end
- end
- end
- end
-
- def create_expiration_policy(project_id:, enabled:, with_images: false)
- projects.create!(id: project_id, namespace_id: namespace.id, name: "gitlab-#{project_id}")
-
- if with_images
- container_repositories.create!(project_id: project_id, name: "image-#{project_id}")
- end
-
- container_expiration_policies.create!(
- enabled: enabled,
- project_id: project_id
- )
- end
-
- def enabled_policies
- container_expiration_policies.where(enabled: true)
- end
-
- def disabled_policies
- container_expiration_policies.where(enabled: false)
- end
-end
diff --git a/spec/lib/gitlab/background_migration/drop_invalid_security_findings_spec.rb b/spec/lib/gitlab/background_migration/drop_invalid_security_findings_spec.rb
deleted file mode 100644
index 5fdd8683d06..00000000000
--- a/spec/lib/gitlab/background_migration/drop_invalid_security_findings_spec.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::DropInvalidSecurityFindings, :suppress_gitlab_schemas_validate_connection,
- schema: 20211108211434 do
- let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user', type: Namespaces::UserNamespace.sti_name) }
- let(:project) { table(:projects).create!(namespace_id: namespace.id) }
-
- let(:pipelines) { table(:ci_pipelines) }
- let!(:pipeline) { pipelines.create!(project_id: project.id) }
-
- let(:ci_builds) { table(:ci_builds) }
- let!(:ci_build) { ci_builds.create! }
-
- let(:security_scans) { table(:security_scans) }
- let!(:security_scan) do
- security_scans.create!(
- scan_type: 1,
- status: 1,
- build_id: ci_build.id,
- project_id: project.id,
- pipeline_id: pipeline.id
- )
- end
-
- let(:vulnerability_scanners) { table(:vulnerability_scanners) }
- let!(:vulnerability_scanner) { vulnerability_scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
-
- let(:security_findings) { table(:security_findings) }
- let!(:security_finding_without_uuid) do
- security_findings.create!(
- severity: 1,
- confidence: 1,
- scan_id: security_scan.id,
- scanner_id: vulnerability_scanner.id,
- uuid: nil
- )
- end
-
- let!(:security_finding_with_uuid) do
- security_findings.create!(
- severity: 1,
- confidence: 1,
- scan_id: security_scan.id,
- scanner_id: vulnerability_scanner.id,
- uuid: 'bd95c085-71aa-51d7-9bb6-08ae669c262e'
- )
- end
-
- let(:sub_batch_size) { 10_000 }
-
- subject { described_class.new.perform(security_finding_without_uuid.id, security_finding_with_uuid.id, sub_batch_size) }
-
- it 'drops Security::Finding objects with no UUID' do
- expect { subject }.to change(security_findings, :count).from(2).to(1)
- end
-end
diff --git a/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb b/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb
deleted file mode 100644
index 8f3ef44e00c..00000000000
--- a/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb
+++ /dev/null
@@ -1,126 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::DropInvalidVulnerabilities, schema: 20210826171758 do
- let!(:background_migration_jobs) { table(:background_migration_jobs) }
- let!(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
- let!(:users) { table(:users) }
- let!(:user) { create_user! }
- let!(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) }
-
- let!(:scanners) { table(:vulnerability_scanners) }
- let!(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
- let!(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') }
-
- let!(:vulnerabilities) { table(:vulnerabilities) }
- let!(:vulnerability_with_finding) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:vulnerability_without_finding) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
- let!(:primary_identifier) do
- vulnerability_identifiers.create!(
- project_id: project.id,
- external_type: 'uuid-v5',
- external_id: 'uuid-v5',
- fingerprint: '7e394d1b1eb461a7406d7b1e08f057a1cf11287a',
- name: 'Identifier for UUIDv5')
- end
-
- let!(:vulnerabilities_findings) { table(:vulnerability_occurrences) }
- let!(:finding) do
- create_finding!(
- vulnerability_id: vulnerability_with_finding.id,
- project_id: project.id,
- scanner_id: scanner.id,
- primary_identifier_id: primary_identifier.id
- )
- end
-
- let(:succeeded_status) { 1 }
- let(:pending_status) { 0 }
-
- it 'drops Vulnerabilities without any Findings' do
- expect(vulnerabilities.pluck(:id)).to eq([vulnerability_with_finding.id, vulnerability_without_finding.id])
-
- expect { subject.perform(vulnerability_with_finding.id, vulnerability_without_finding.id) }.to change(vulnerabilities, :count).by(-1)
-
- expect(vulnerabilities.pluck(:id)).to eq([vulnerability_with_finding.id])
- end
-
- it 'marks jobs as done' do
- background_migration_jobs.create!(
- class_name: 'DropInvalidVulnerabilities',
- arguments: [vulnerability_with_finding.id, vulnerability_with_finding.id]
- )
-
- background_migration_jobs.create!(
- class_name: 'DropInvalidVulnerabilities',
- arguments: [vulnerability_without_finding.id, vulnerability_without_finding.id]
- )
-
- subject.perform(vulnerability_with_finding.id, vulnerability_with_finding.id)
-
- expect(background_migration_jobs.first.status).to eq(succeeded_status)
- expect(background_migration_jobs.second.status).to eq(pending_status)
- end
-
- private
-
- def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0)
- vulnerabilities.create!(
- project_id: project_id,
- author_id: author_id,
- title: title,
- severity: severity,
- confidence: confidence,
- report_type: report_type
- )
- end
-
- # rubocop:disable Metrics/ParameterLists
- def create_finding!(
- vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:,
- name: "test", severity: 7, confidence: 7, report_type: 0,
- project_fingerprint: '123qweasdzxc', location_fingerprint: 'test',
- metadata_version: 'test', raw_metadata: 'test', uuid: SecureRandom.uuid)
- vulnerabilities_findings.create!(
- vulnerability_id: vulnerability_id,
- project_id: project_id,
- name: name,
- severity: severity,
- confidence: confidence,
- report_type: report_type,
- project_fingerprint: project_fingerprint,
- scanner_id: scanner_id,
- primary_identifier_id: primary_identifier_id,
- location_fingerprint: location_fingerprint,
- metadata_version: metadata_version,
- raw_metadata: raw_metadata,
- uuid: uuid
- )
- end
- # rubocop:enable Metrics/ParameterLists
-
- def create_user!(name: "Example User", email: "user@example.com", user_type: nil)
- users.create!(
- name: name,
- email: email,
- username: name,
- projects_limit: 0,
- user_type: user_type,
- confirmed_at: Time.current
- )
- end
-end
diff --git a/spec/lib/gitlab/background_migration/encrypt_ci_trigger_token_spec.rb b/spec/lib/gitlab/background_migration/encrypt_ci_trigger_token_spec.rb
index b52f30a5e21..dd3e7877f8a 100644
--- a/spec/lib/gitlab/background_migration/encrypt_ci_trigger_token_spec.rb
+++ b/spec/lib/gitlab/background_migration/encrypt_ci_trigger_token_spec.rb
@@ -10,8 +10,7 @@ RSpec.describe Gitlab::BackgroundMigration::EncryptCiTriggerToken, feature_categ
mode: :per_attribute_iv,
key: ::Settings.attr_encrypted_db_key_base_32,
algorithm: 'aes-256-gcm',
- encode: false,
- encode_iv: false
+ encode: false
end
end
@@ -52,6 +51,7 @@ RSpec.describe Gitlab::BackgroundMigration::EncryptCiTriggerToken, feature_categ
already_encrypted_token = Ci::Trigger.find(with_encryption.id)
expect(already_encrypted_token.encrypted_token).to eq(with_encryption.encrypted_token)
expect(already_encrypted_token.encrypted_token_iv).to eq(with_encryption.encrypted_token_iv)
+ expect(already_encrypted_token.token).to eq(already_encrypted_token.encrypted_token_tmp)
expect(with_encryption.token).to eq(with_encryption.encrypted_token_tmp)
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
deleted file mode 100644
index 586e75ffb37..00000000000
--- a/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::ExtractProjectTopicsIntoSeparateTable,
- :suppress_gitlab_schemas_validate_connection, schema: 20210826171758 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
- 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/fix_first_mentioned_in_commit_at_spec.rb b/spec/lib/gitlab/background_migration/fix_first_mentioned_in_commit_at_spec.rb
deleted file mode 100644
index 7f15aceca42..00000000000
--- a/spec/lib/gitlab/background_migration/fix_first_mentioned_in_commit_at_spec.rb
+++ /dev/null
@@ -1,166 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20211004110500_add_temporary_index_to_issue_metrics.rb')
-
-RSpec.describe Gitlab::BackgroundMigration::FixFirstMentionedInCommitAt, :migration, schema: 20211004110500 do
- let(:namespaces) { table(:namespaces) }
- let(:projects) { table(:projects) }
- let(:users) { table(:users) }
- let(:merge_requests) { table(:merge_requests) }
- let(:issues) { table(:issues) }
- let(:issue_metrics) { table(:issue_metrics) }
- let(:merge_requests_closing_issues) { table(:merge_requests_closing_issues) }
- let(:diffs) { table(:merge_request_diffs) }
- let(:ten_days_ago) { 10.days.ago }
- let(:commits) do
- table(:merge_request_diff_commits).tap do |t|
- t.extend(SuppressCompositePrimaryKeyWarning)
- end
- end
-
- let(:namespace) { namespaces.create!(name: 'ns', path: 'ns') }
- let(:project) { projects.create!(namespace_id: namespace.id) }
-
- let!(:issue1) do
- issues.create!(
- title: 'issue',
- description: 'description',
- project_id: project.id
- )
- end
-
- let!(:issue2) do
- issues.create!(
- title: 'issue',
- description: 'description',
- project_id: project.id
- )
- end
-
- let!(:merge_request1) do
- merge_requests.create!(
- source_branch: 'a',
- target_branch: 'master',
- target_project_id: project.id
- )
- end
-
- let!(:merge_request2) do
- merge_requests.create!(
- source_branch: 'b',
- target_branch: 'master',
- target_project_id: project.id
- )
- end
-
- let!(:merge_request_closing_issue1) do
- merge_requests_closing_issues.create!(issue_id: issue1.id, merge_request_id: merge_request1.id)
- end
-
- let!(:merge_request_closing_issue2) do
- merge_requests_closing_issues.create!(issue_id: issue2.id, merge_request_id: merge_request2.id)
- end
-
- let!(:diff1) { diffs.create!(merge_request_id: merge_request1.id) }
- let!(:diff2) { diffs.create!(merge_request_id: merge_request1.id) }
-
- let!(:other_diff) { diffs.create!(merge_request_id: merge_request2.id) }
-
- let!(:commit1) do
- commits.create!(
- merge_request_diff_id: diff2.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('aaa'),
- authored_date: 5.days.ago
- )
- end
-
- let!(:commit2) do
- commits.create!(
- merge_request_diff_id: diff2.id,
- relative_order: 1,
- sha: Gitlab::Database::ShaAttribute.serialize('aaa'),
- authored_date: 10.days.ago
- )
- end
-
- let!(:commit3) do
- commits.create!(
- merge_request_diff_id: other_diff.id,
- relative_order: 1,
- sha: Gitlab::Database::ShaAttribute.serialize('aaa'),
- authored_date: 5.days.ago
- )
- end
-
- def run_migration
- described_class
- .new
- .perform(issue_metrics.minimum(:issue_id), issue_metrics.maximum(:issue_id))
- end
-
- shared_examples 'fixes first_mentioned_in_commit_at' do
- it "marks successful slices as completed" do
- min_issue_id = issue_metrics.minimum(:issue_id)
- max_issue_id = issue_metrics.maximum(:issue_id)
-
- expect(subject).to receive(:mark_job_as_succeeded).with(min_issue_id, max_issue_id)
-
- subject.perform(min_issue_id, max_issue_id)
- end
-
- context 'when the persisted first_mentioned_in_commit_at is later than the first commit authored_date' do
- it 'updates the issue_metrics record' do
- record1 = issue_metrics.create!(issue_id: issue1.id, first_mentioned_in_commit_at: Time.current)
- record2 = issue_metrics.create!(issue_id: issue2.id, first_mentioned_in_commit_at: Time.current)
-
- run_migration
- record1.reload
- record2.reload
-
- expect(record1.first_mentioned_in_commit_at).to be_within(2.seconds).of(commit2.authored_date)
- expect(record2.first_mentioned_in_commit_at).to be_within(2.seconds).of(commit3.authored_date)
- end
- end
-
- context 'when the persisted first_mentioned_in_commit_at is earlier than the first commit authored_date' do
- it 'does not update the issue_metrics record' do
- record = issue_metrics.create!(issue_id: issue1.id, first_mentioned_in_commit_at: 20.days.ago)
-
- expect { run_migration }.not_to change { record.reload.first_mentioned_in_commit_at }
- end
- end
-
- context 'when the first_mentioned_in_commit_at is null' do
- it 'does nothing' do
- record = issue_metrics.create!(issue_id: issue1.id, first_mentioned_in_commit_at: nil)
-
- expect { run_migration }.not_to change { record.reload.first_mentioned_in_commit_at }
- end
- end
- end
-
- describe 'running the migration when first_mentioned_in_commit_at is timestamp without time zone' do
- it_behaves_like 'fixes first_mentioned_in_commit_at'
- end
-
- describe 'running the migration when first_mentioned_in_commit_at is timestamp with time zone' do
- around do |example|
- AddTemporaryIndexToIssueMetrics.new.down
-
- ActiveRecord::Base.connection.execute "ALTER TABLE issue_metrics ALTER first_mentioned_in_commit_at type timestamp with time zone"
- Gitlab::BackgroundMigration::FixFirstMentionedInCommitAt::TmpIssueMetrics.reset_column_information
- AddTemporaryIndexToIssueMetrics.new.up
-
- example.run
-
- AddTemporaryIndexToIssueMetrics.new.down
- ActiveRecord::Base.connection.execute "ALTER TABLE issue_metrics ALTER first_mentioned_in_commit_at type timestamp without time zone"
- Gitlab::BackgroundMigration::FixFirstMentionedInCommitAt::TmpIssueMetrics.reset_column_information
- AddTemporaryIndexToIssueMetrics.new.up
- end
-
- it_behaves_like 'fixes first_mentioned_in_commit_at'
- end
-end
diff --git a/spec/lib/gitlab/background_migration/fix_merge_request_diff_commit_users_spec.rb b/spec/lib/gitlab/background_migration/fix_merge_request_diff_commit_users_spec.rb
deleted file mode 100644
index 99df21562b0..00000000000
--- a/spec/lib/gitlab/background_migration/fix_merge_request_diff_commit_users_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-# rubocop: disable RSpec/FactoriesInMigrationSpecs
-RSpec.describe Gitlab::BackgroundMigration::FixMergeRequestDiffCommitUsers do
- let(:migration) { described_class.new }
-
- describe '#perform' do
- context 'when the project exists' do
- it 'does nothing' do
- project = create(:project)
-
- expect { migration.perform(project.id) }.not_to raise_error
- end
- end
-
- context 'when the project does not exist' do
- it 'does nothing' do
- expect { migration.perform(-1) }.not_to raise_error
- end
- end
- end
-end
-# rubocop: enable RSpec/FactoriesInMigrationSpecs
diff --git a/spec/lib/gitlab/background_migration/fix_vulnerability_reads_has_issues_spec.rb b/spec/lib/gitlab/background_migration/fix_vulnerability_reads_has_issues_spec.rb
new file mode 100644
index 00000000000..9f431c43f39
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/fix_vulnerability_reads_has_issues_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::FixVulnerabilityReadsHasIssues, schema: 20230302185739, feature_category: :vulnerability_management do # rubocop:disable Layout/LineLength
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:users) { table(:users) }
+ let(:scanners) { table(:vulnerability_scanners) }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+ let(:vulnerability_reads) { table(:vulnerability_reads) }
+ let(:work_item_types) { table(:work_item_types) }
+ let(:issues) { table(:issues) }
+ let(:vulnerability_issue_links) { table(:vulnerability_issue_links) }
+
+ let(:namespace) { namespaces.create!(name: 'user', path: 'user') }
+ let(:project) { projects.create!(namespace_id: namespace.id, project_namespace_id: namespace.id) }
+ let(:user) { users.create!(username: 'john_doe', email: 'johndoe@gitlab.com', projects_limit: 10) }
+ let(:scanner) { scanners.create!(project_id: project.id, external_id: 'external_id', name: 'Test Scanner') }
+ let(:work_item_type) { work_item_types.create!(name: 'test') }
+
+ let(:vulnerability_records) do
+ Array.new(4).map do |_, n|
+ vulnerabilities.create!(
+ project_id: project.id,
+ author_id: user.id,
+ title: "vulnerability #{n}",
+ severity: 1,
+ confidence: 1,
+ report_type: 1
+ )
+ end
+ end
+
+ let(:vulnerabilities_with_issues) { [vulnerability_records.first, vulnerability_records.third] }
+ let(:vulnerabilities_without_issues) { vulnerability_records - vulnerabilities_with_issues }
+
+ let(:vulnerability_read_records) do
+ vulnerability_records.map do |vulnerability|
+ vulnerability_reads.create!(
+ project_id: project.id,
+ vulnerability_id: vulnerability.id,
+ scanner_id: scanner.id,
+ has_issues: false,
+ severity: 1,
+ report_type: 1,
+ state: 1,
+ uuid: SecureRandom.uuid
+ )
+ end
+ end
+
+ let!(:issue_links) do
+ vulnerabilities_with_issues.map do |vulnerability|
+ issue = issues.create!(
+ title: vulnerability.title,
+ author_id: user.id,
+ project_id: project.id,
+ confidential: true,
+ work_item_type_id: work_item_type.id,
+ namespace_id: namespace.id
+ )
+
+ vulnerability_issue_links.create!(
+ vulnerability_id: vulnerability.id,
+ issue_id: issue.id
+ )
+ end
+ end
+
+ def vulnerability_read_for(vulnerability)
+ vulnerability_read_records.find { |read| read.vulnerability_id == vulnerability.id }
+ end
+
+ subject(:perform_migration) do
+ described_class.new(
+ start_id: issue_links.first.vulnerability_id,
+ end_id: issue_links.last.vulnerability_id,
+ batch_table: :vulnerability_issue_links,
+ batch_column: :vulnerability_id,
+ sub_batch_size: issue_links.size,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
+ end
+
+ it 'only changes records with issue links' do
+ expect(vulnerability_read_records).to all(have_attributes(has_issues: false))
+
+ perform_migration
+
+ vulnerabilities_with_issues.each do |vulnerability|
+ expect(vulnerability_read_for(vulnerability).reload.has_issues).to eq(true)
+ end
+
+ vulnerabilities_without_issues.each do |vulnerability|
+ expect(vulnerability_read_for(vulnerability).reload.has_issues).to eq(false)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/issues_internal_id_scope_updater_spec.rb b/spec/lib/gitlab/background_migration/issues_internal_id_scope_updater_spec.rb
new file mode 100644
index 00000000000..1adff322b41
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/issues_internal_id_scope_updater_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+# this needs the schema to be before we introduce the not null constraint on routes#namespace_id
+# rubocop:disable RSpec/MultipleMemoizedHelpers
+RSpec.describe Gitlab::BackgroundMigration::IssuesInternalIdScopeUpdater, feature_category: :team_planning do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:internal_ids) { table(:internal_ids) }
+
+ let(:gr1) { namespaces.create!(name: 'batchtest1', type: 'Group', path: 'space1') }
+ let(:gr2) { namespaces.create!(name: 'batchtest2', type: 'Group', parent_id: gr1.id, path: 'space2') }
+
+ let(:pr_nmsp1) { namespaces.create!(name: 'proj1', path: 'proj1', type: 'Project', parent_id: gr1.id) }
+ let(:pr_nmsp2) { namespaces.create!(name: 'proj2', path: 'proj2', type: 'Project', parent_id: gr1.id) }
+ let(:pr_nmsp3) { namespaces.create!(name: 'proj3', path: 'proj3', type: 'Project', parent_id: gr2.id) }
+ let(:pr_nmsp4) { namespaces.create!(name: 'proj4', path: 'proj4', type: 'Project', parent_id: gr2.id) }
+ let(:pr_nmsp5) { namespaces.create!(name: 'proj5', path: 'proj5', type: 'Project', parent_id: gr2.id) }
+ let(:pr_nmsp6) { namespaces.create!(name: 'proj6', path: 'proj6', type: 'Project', parent_id: gr2.id) }
+
+ # rubocop:disable Layout/LineLength
+ let(:p1) { projects.create!(name: 'proj1', path: 'proj1', namespace_id: gr1.id, project_namespace_id: pr_nmsp1.id) }
+ let(:p2) { projects.create!(name: 'proj2', path: 'proj2', namespace_id: gr1.id, project_namespace_id: pr_nmsp2.id) }
+ let(:p3) { projects.create!(name: 'proj3', path: 'proj3', namespace_id: gr2.id, project_namespace_id: pr_nmsp3.id) }
+ let(:p4) { projects.create!(name: 'proj4', path: 'proj4', namespace_id: gr2.id, project_namespace_id: pr_nmsp4.id) }
+ let(:p5) { projects.create!(name: 'proj5', path: 'proj5', namespace_id: gr2.id, project_namespace_id: pr_nmsp5.id) }
+ let(:p6) { projects.create!(name: 'proj6', path: 'proj6', namespace_id: gr2.id, project_namespace_id: pr_nmsp6.id) }
+ # rubocop:enable Layout/LineLength
+
+ # a project that already is covered by a record for its namespace. This should result in no new record added and
+ # project related record deleted
+ let!(:issues_internal_ids_p1) { internal_ids.create!(project_id: p1.id, usage: 0, last_value: 100) }
+ let!(:issues_internal_ids_pr_nmsp1) { internal_ids.create!(namespace_id: pr_nmsp1.id, usage: 0, last_value: 111) }
+
+ # project records that do not have a corresponding namespace record. This should result 2 new records
+ # scoped to corresponding project namespaces being added and the project related records being deleted.
+ let!(:issues_internal_ids_p2) { internal_ids.create!(project_id: p2.id, usage: 0, last_value: 200) }
+ let!(:issues_internal_ids_p3) { internal_ids.create!(project_id: p3.id, usage: 0, last_value: 300) }
+
+ # a project record on a different usage, should not be affected by the migration and
+ # no new record should be created for this case
+ let!(:issues_internal_ids_p4) { internal_ids.create!(project_id: p4.id, usage: 4, last_value: 400) }
+
+ # a project namespace scoped record without a corresponding project record, should not affect anything.
+ let!(:issues_internal_ids_pr_nmsp5) { internal_ids.create!(namespace_id: pr_nmsp5.id, usage: 0, last_value: 500) }
+
+ # a record scoped to a group, should not affect anything.
+ let!(:issues_internal_ids_gr1) { internal_ids.create!(namespace_id: gr1.id, usage: 0, last_value: 600) }
+
+ # a project that is covered by a record for its namespace, but has a higher last_value, due to updates during rolling
+ # deploy for instance, see https://gitlab.com/gitlab-com/gl-infra/production/-/issues/8548
+ let!(:issues_internal_ids_p6) { internal_ids.create!(project_id: p6.id, usage: 0, last_value: 111) }
+ let!(:issues_internal_ids_pr_nmsp6) { internal_ids.create!(namespace_id: pr_nmsp6.id, usage: 0, last_value: 100) }
+
+ subject(:perform_migration) do
+ described_class.new(
+ start_id: internal_ids.minimum(:id),
+ end_id: internal_ids.maximum(:id),
+ batch_table: :internal_ids,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
+ end
+
+ it 'backfills internal_ids records and removes related project records', :aggregate_failures do
+ perform_migration
+
+ expected_recs = [pr_nmsp1.id, pr_nmsp2.id, pr_nmsp3.id, pr_nmsp5.id, gr1.id, pr_nmsp6.id]
+
+ # all namespace scoped records for issues(0) usage
+ expect(internal_ids.where.not(namespace_id: nil).where(usage: 0).count).to eq(6)
+ # all namespace_ids for issues(0) usage
+ expect(internal_ids.where.not(namespace_id: nil).where(usage: 0).pluck(:namespace_id)).to match_array(expected_recs)
+ # this is the record with usage: 4
+ expect(internal_ids.where.not(project_id: nil).count).to eq(1)
+ # no project scoped records for issues usage left
+ expect(internal_ids.where.not(project_id: nil).where(usage: 0).count).to eq(0)
+
+ # the case when the project_id scoped record had the higher last_value,
+ # see `issues_internal_ids_p6` and issues_internal_ids_pr_nmsp6 definitions above
+ expect(internal_ids.where(namespace_id: pr_nmsp6.id).first.last_value).to eq(111)
+
+ # the case when the namespace_id scoped record had the higher last_value,
+ # see `issues_internal_ids_p1` and issues_internal_ids_pr_nmsp1 definitions above.
+ expect(internal_ids.where(namespace_id: pr_nmsp1.id).first.last_value).to eq(111)
+ end
+end
+# rubocop:enable RSpec/MultipleMemoizedHelpers
diff --git a/spec/lib/gitlab/background_migration/migrate_evidences_for_vulnerability_findings_spec.rb b/spec/lib/gitlab/background_migration/migrate_evidences_for_vulnerability_findings_spec.rb
new file mode 100644
index 00000000000..ba2f571f5aa
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/migrate_evidences_for_vulnerability_findings_spec.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::MigrateEvidencesForVulnerabilityFindings,
+ feature_category: :vulnerability_management do
+ let(:vulnerability_occurrences) { table(:vulnerability_occurrences) }
+ let(:vulnerability_finding_evidences) { table(:vulnerability_finding_evidences) }
+ let(:evidence_hash) { { url: 'http://test.com' } }
+ let(:namespace1) { table(:namespaces).create!(name: 'namespace 1', path: 'namespace1') }
+ let(:project1) { table(:projects).create!(namespace_id: namespace1.id, project_namespace_id: namespace1.id) }
+ let(:user) { table(:users).create!(email: 'test1@example.com', projects_limit: 5) }
+
+ let(:scanner1) do
+ table(:vulnerability_scanners).create!(project_id: project1.id, external_id: 'test 1', name: 'test scanner 1')
+ end
+
+ let(:stating_id) { vulnerability_occurrences.pluck(:id).min }
+ let(:end_id) { vulnerability_occurrences.pluck(:id).max }
+
+ let(:migration) do
+ described_class.new(
+ start_id: stating_id,
+ end_id: end_id,
+ batch_table: :vulnerability_occurrences,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 2,
+ connection: ApplicationRecord.connection
+ )
+ end
+
+ subject(:perform_migration) { migration.perform }
+
+ context 'without the presence of evidence key' do
+ before do
+ create_finding!(project1.id, scanner1.id, { other_keys: 'test' })
+ end
+
+ it 'does not create any evidence' do
+ expect { perform_migration }.not_to change { vulnerability_finding_evidences.count }
+ end
+ end
+
+ context 'with evidence equals to nil' do
+ before do
+ create_finding!(project1.id, scanner1.id, { evidence: nil })
+ end
+
+ it 'does not create any evidence' do
+ expect { perform_migration }.not_to change { vulnerability_finding_evidences.count }
+ end
+ end
+
+ context 'with existing evidence within raw_metadata' do
+ let!(:finding1) { create_finding!(project1.id, scanner1.id, { evidence: evidence_hash }) }
+ let!(:finding2) { create_finding!(project1.id, scanner1.id, { evidence: evidence_hash }) }
+
+ it 'creates new evidence for each finding' do
+ expect { perform_migration }.to change { vulnerability_finding_evidences.count }.by(2)
+ end
+
+ context 'when parse throws exception JSON::ParserError' do
+ before do
+ allow(Gitlab::Json).to receive(:parse).and_raise(JSON::ParserError)
+ end
+
+ it 'does not create new records' do
+ expect { perform_migration }.not_to change { vulnerability_finding_evidences.count }
+ end
+ end
+ end
+
+ context 'with unsupported Unicode escape sequence' do
+ let!(:finding1) { create_finding!(project1.id, scanner1.id, { evidence: { 'summary' => "\u0000" } }) }
+
+ it 'does not create new evidence' do
+ expect { perform_migration }.not_to change { vulnerability_finding_evidences.count }
+ end
+ end
+
+ context 'with existing evidence records' do
+ let!(:finding) { create_finding!(project1.id, scanner1.id, { evidence: evidence_hash }) }
+
+ before do
+ vulnerability_finding_evidences.create!(vulnerability_occurrence_id: finding.id, data: evidence_hash)
+ end
+
+ it 'does not create new evidence' do
+ expect { perform_migration }.not_to change { vulnerability_finding_evidences.count }
+ end
+
+ context 'with non-existing evidence' do
+ let!(:finding3) { create_finding!(project1.id, scanner1.id, { evidence: { url: 'http://secondary.com' } }) }
+
+ it 'creates a new evidence only to the non-existing evidence' do
+ expect { perform_migration }.to change { vulnerability_finding_evidences.count }.by(1)
+ end
+ end
+ end
+
+ private
+
+ def create_finding!(project_id, scanner_id, raw_metadata)
+ vulnerability = table(:vulnerabilities).create!(project_id: project_id, author_id: user.id, title: 'test',
+ severity: 4, confidence: 4, report_type: 0)
+
+ identifier = table(:vulnerability_identifiers).create!(project_id: project_id, external_type: 'uuid-v5',
+ external_id: 'uuid-v5', fingerprint: OpenSSL::Digest::SHA256.hexdigest(vulnerability.id.to_s),
+ name: 'Identifier for UUIDv5 2 2')
+
+ table(:vulnerability_occurrences).create!(
+ vulnerability_id: vulnerability.id, project_id: project_id, scanner_id: scanner_id,
+ primary_identifier_id: identifier.id, name: 'test', severity: 4, confidence: 4, report_type: 0,
+ uuid: SecureRandom.uuid, project_fingerprint: '123qweasdzxc', location: { "image" => "alpine:3.4" },
+ location_fingerprint: 'test', metadata_version: 'test',
+ raw_metadata: raw_metadata.to_json)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/migrate_human_user_type_spec.rb b/spec/lib/gitlab/background_migration/migrate_human_user_type_spec.rb
new file mode 100644
index 00000000000..7edeaed5794
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/migrate_human_user_type_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::MigrateHumanUserType, schema: 20230327103401, feature_category: :user_management do # rubocop:disable Layout/LineLength
+ let!(:valid_users) do
+ # 13 is the max value we have at the moment.
+ (0..13).map do |type|
+ table(:users).create!(username: "user#{type}", email: "user#{type}@test.com", user_type: type, projects_limit: 0)
+ end
+ end
+
+ let!(:user_to_update) do
+ table(:users).create!(username: "user_nil", email: "user_nil@test.com", user_type: nil, projects_limit: 0)
+ end
+
+ let(:starting_id) { table(:users).pluck(:id).min }
+ let(:end_id) { table(:users).pluck(:id).max }
+
+ let(:migration) do
+ described_class.new(
+ start_id: starting_id,
+ end_id: end_id,
+ batch_table: :users,
+ batch_column: :id,
+ sub_batch_size: 100,
+ pause_ms: 2,
+ connection: ::ApplicationRecord.connection
+ )
+ end
+
+ describe 'perform' do
+ it 'updates user with `nil` user type only' do
+ expect do
+ migration.perform
+ valid_users.map(&:reload)
+ user_to_update.reload
+ end.not_to change { valid_users.map(&:user_type) }
+
+ expect(user_to_update.user_type).to eq(0)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/migrate_links_for_vulnerability_findings_spec.rb b/spec/lib/gitlab/background_migration/migrate_links_for_vulnerability_findings_spec.rb
new file mode 100644
index 00000000000..b973f9d4350
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/migrate_links_for_vulnerability_findings_spec.rb
@@ -0,0 +1,192 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::MigrateLinksForVulnerabilityFindings,
+ feature_category: :vulnerability_management do
+ let(:vulnerability_occurrences) { table(:vulnerability_occurrences) }
+ let(:vulnerability_finding_links) { table(:vulnerability_finding_links) }
+ let(:link_hash) { { url: 'http://test.com' } }
+ let(:namespace1) { table(:namespaces).create!(name: 'namespace 1', path: 'namespace1') }
+ let(:project1) { table(:projects).create!(namespace_id: namespace1.id, project_namespace_id: namespace1.id) }
+ let(:user) { table(:users).create!(email: 'test1@example.com', projects_limit: 5) }
+
+ let(:scanner1) do
+ table(:vulnerability_scanners).create!(project_id: project1.id, external_id: 'test 1', name: 'test scanner 1')
+ end
+
+ let(:stating_id) { vulnerability_occurrences.pluck(:id).min }
+ let(:end_id) { vulnerability_occurrences.pluck(:id).max }
+
+ let(:migration) do
+ described_class.new(
+ start_id: stating_id,
+ end_id: end_id,
+ batch_table: :vulnerability_occurrences,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 2,
+ connection: ApplicationRecord.connection
+ )
+ end
+
+ subject(:perform_migration) { migration.perform }
+
+ context 'without the presence of links key' do
+ before do
+ create_finding!(project1.id, scanner1.id, { other_keys: 'test' })
+ end
+
+ it 'does not create any link' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.not_to change { vulnerability_finding_links.count }
+ end
+ end
+
+ context 'with links equals to an array of nil element' do
+ before do
+ create_finding!(project1.id, scanner1.id, { links: [nil] })
+ end
+
+ it 'does not create any link' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.not_to change { vulnerability_finding_links.count }
+ end
+ end
+
+ context 'with links equals to a string' do
+ before do
+ create_finding!(project1.id, scanner1.id, { links: "wrong format" })
+ end
+
+ it 'does not create any link' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.not_to change { vulnerability_finding_links.count }
+ end
+ end
+
+ context 'with some elements which do not contain the key url' do
+ let!(:finding) do
+ create_finding!(project1.id, scanner1.id, { links: [link_hash, "wrong format", {}] })
+ end
+
+ it 'creates links only to valid elements' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ perform_migration
+
+ expect(vulnerability_finding_links.all).to contain_exactly(have_attributes(
+ url: link_hash[:url],
+ vulnerability_occurrence_id: finding.id))
+ end
+ end
+
+ context 'when link name is too long' do
+ let!(:finding) do
+ create_finding!(project1.id, scanner1.id, { links: [{ name: 'A' * 300, url: 'https://foo' }] })
+ end
+
+ it 'skips creation of link and logs error' do
+ expect(Gitlab::AppLogger).to receive(:error).with({
+ class: described_class.name,
+ message: /check_55f0a95439/,
+ model_id: finding.id
+ })
+ expect { perform_migration }.not_to change { vulnerability_finding_links.count }
+ end
+ end
+
+ context 'when link url is too long' do
+ let!(:finding) do
+ create_finding!(project1.id, scanner1.id, { links: [{ url: "https://f#{'o' * 2050}" }] })
+ end
+
+ it 'skips creation of link and logs error' do
+ expect(Gitlab::AppLogger).to receive(:error).with({
+ class: described_class.name,
+ message: /check_b7fe886df6/,
+ model_id: finding.id
+ })
+ expect { perform_migration }.not_to change { vulnerability_finding_links.count }
+ end
+ end
+
+ context 'with links equals to an array of duplicated elements' do
+ let!(:finding) do
+ create_finding!(project1.id, scanner1.id, { links: [link_hash, link_hash] })
+ end
+
+ it 'creates one new link' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ perform_migration
+
+ expect(vulnerability_finding_links.all).to contain_exactly(have_attributes(
+ url: link_hash[:url],
+ vulnerability_occurrence_id: finding.id))
+ end
+ end
+
+ context 'with existing links within raw_metadata' do
+ let!(:finding1) { create_finding!(project1.id, scanner1.id, { links: [link_hash] }) }
+ let!(:finding2) { create_finding!(project1.id, scanner1.id, { links: [link_hash] }) }
+
+ it 'creates new link for each finding' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.to change { vulnerability_finding_links.count }.by(2)
+ end
+ end
+
+ context 'when Gitlab::Json throws exception JSON::ParserError' do
+ before do
+ create_finding!(project1.id, scanner1.id, { links: [link_hash] })
+ allow(Gitlab::Json).to receive(:parse).and_raise(JSON::ParserError)
+ end
+
+ it 'does not log this error nor create new records' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.not_to change { vulnerability_finding_links.count }
+ end
+ end
+
+ context 'with existing link records' do
+ let!(:finding) { create_finding!(project1.id, scanner1.id, { links: [link_hash] }) }
+
+ before do
+ vulnerability_finding_links.create!(vulnerability_occurrence_id: finding.id, url: link_hash[:url])
+ end
+
+ it 'does not create new link' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.not_to change { vulnerability_finding_links.count }
+ end
+
+ it 'does not raise ActiveRecord::RecordNotUnique' do
+ expect { perform_migration }.not_to raise_error
+ end
+ end
+
+ private
+
+ def create_finding!(project_id, scanner_id, raw_metadata)
+ vulnerability = table(:vulnerabilities).create!(project_id: project_id, author_id: user.id, title: 'test',
+ severity: 4, confidence: 4, report_type: 0)
+
+ identifier = table(:vulnerability_identifiers).create!(project_id: project_id, external_type: 'uuid-v5',
+ external_id: 'uuid-v5', fingerprint: OpenSSL::Digest::SHA256.hexdigest(vulnerability.id.to_s),
+ name: 'Identifier for UUIDv5 2 2')
+
+ table(:vulnerability_occurrences).create!(
+ vulnerability_id: vulnerability.id, project_id: project_id, scanner_id: scanner_id,
+ primary_identifier_id: identifier.id, name: 'test', severity: 4, confidence: 4, report_type: 0,
+ uuid: SecureRandom.uuid, project_fingerprint: '123qweasdzxc', location: { "image" => "alpine:3.4" },
+ location_fingerprint: 'test', metadata_version: 'test',
+ raw_metadata: raw_metadata.to_json)
+ 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
deleted file mode 100644
index c3ae2cc060c..00000000000
--- a/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb
+++ /dev/null
@@ -1,413 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers, schema: 20211012134316 do
- let(:namespaces) { table(:namespaces) }
- let(:projects) { table(:projects) }
- let(:users) { table(:users) }
- let(:merge_requests) { table(:merge_requests) }
- let(:diffs) { table(:merge_request_diffs) }
- let(:commits) do
- table(:merge_request_diff_commits).tap do |t|
- t.extend(SuppressCompositePrimaryKeyWarning)
- end
- end
-
- let(:commit_users) { described_class::MergeRequestDiffCommitUser }
-
- let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
- let(:project) { projects.create!(namespace_id: namespace.id) }
- let(:merge_request) do
- merge_requests.create!(
- source_branch: 'x',
- target_branch: 'master',
- target_project_id: project.id
- )
- end
-
- let(:diff) { diffs.create!(merge_request_id: merge_request.id) }
- let(:migration) { described_class.new }
-
- describe 'MergeRequestDiffCommit' do
- describe '.each_row_to_migrate' do
- it 'yields the rows to migrate for a given range' do
- commit1 = commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc'),
- author_name: 'bob',
- author_email: 'bob@example.com',
- committer_name: 'bob',
- committer_email: 'bob@example.com'
- )
-
- commit2 = commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 1,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc'),
- author_name: 'Alice',
- author_email: 'alice@example.com',
- committer_name: 'Alice',
- committer_email: 'alice@example.com'
- )
-
- # We stub this constant to make sure we run at least two pagination
- # queries for getting the data. This way we can test if the pagination
- # is actually working properly.
- stub_const(
- 'Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers::COMMIT_ROWS_PER_QUERY',
- 1
- )
-
- rows = []
-
- described_class::MergeRequestDiffCommit.each_row_to_migrate(diff.id, diff.id + 1) do |row|
- rows << row
- end
-
- expect(rows.length).to eq(2)
-
- expect(rows[0].author_name).to eq(commit1.author_name)
- expect(rows[1].author_name).to eq(commit2.author_name)
- end
- end
- end
-
- describe 'MergeRequestDiffCommitUser' do
- describe '.union' do
- it 'produces a union of the given queries' do
- alice = commit_users.create!(name: 'Alice', email: 'alice@example.com')
- bob = commit_users.create!(name: 'Bob', email: 'bob@example.com')
- users = commit_users.union(
- [
- commit_users.where(name: 'Alice').to_sql,
- commit_users.where(name: 'Bob').to_sql
- ])
-
- expect(users).to include(alice)
- expect(users).to include(bob)
- end
- end
- 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,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc'),
- author_name: 'bob',
- author_email: 'bob@example.com',
- committer_name: 'bob',
- committer_email: 'bob@example.com'
- )
-
- migration.perform(diff.id, diff.id + 1)
-
- bob = commit_users.find_by(name: 'bob')
- commit = commits.first
-
- expect(commit.commit_author_id).to eq(bob.id)
- expect(commit.committer_id).to eq(bob.id)
- end
-
- it 'treats empty names and Emails the same as NULL values' do
- commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc'),
- author_name: 'bob',
- author_email: 'bob@example.com',
- committer_name: '',
- committer_email: ''
- )
-
- migration.perform(diff.id, diff.id + 1)
-
- bob = commit_users.find_by(name: 'bob')
- commit = commits.first
-
- expect(commit.commit_author_id).to eq(bob.id)
- expect(commit.committer_id).to be_nil
- end
-
- it 'does not update rows without a committer and author' do
- commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc')
- )
-
- migration.perform(diff.id, diff.id + 1)
-
- commit = commits.first
-
- expect(commit_users.count).to eq(0)
- expect(commit.commit_author_id).to be_nil
- expect(commit.committer_id).to be_nil
- end
-
- it 'marks the background job as done' do
- Gitlab::Database::BackgroundMigrationJob.create!(
- class_name: 'MigrateMergeRequestDiffCommitUsers',
- arguments: [diff.id, diff.id + 1]
- )
-
- migration.perform(diff.id, diff.id + 1)
-
- job = Gitlab::Database::BackgroundMigrationJob.first
-
- expect(job.status).to eq('succeeded')
- end
- end
-
- describe '#get_data_to_update' do
- it 'returns the users and commit rows to update' do
- commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc'),
- author_name: 'bob' + ('a' * 510),
- author_email: 'bob@example.com',
- committer_name: 'bob' + ('a' * 510),
- committer_email: 'bob@example.com'
- )
-
- commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 1,
- sha: Gitlab::Database::ShaAttribute.serialize('456abc'),
- author_name: 'alice',
- author_email: 'alice@example.com',
- committer_name: 'alice',
- committer_email: 'alice@example.com'
- )
-
- users, to_update = migration.get_data_to_update(diff.id, diff.id + 1)
-
- bob_name = 'bob' + ('a' * 509)
-
- expect(users).to include(%w[alice alice@example.com])
- expect(users).to include([bob_name, 'bob@example.com'])
-
- expect(to_update[[diff.id, 0]])
- .to eq([[bob_name, 'bob@example.com'], [bob_name, 'bob@example.com']])
-
- expect(to_update[[diff.id, 1]])
- .to eq([%w[alice alice@example.com], %w[alice alice@example.com]])
- end
-
- it 'does not include a user if both the name and Email are missing' do
- commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc'),
- author_name: nil,
- author_email: nil,
- committer_name: 'bob',
- committer_email: 'bob@example.com'
- )
-
- users, _ = migration.get_data_to_update(diff.id, diff.id + 1)
-
- expect(users).to eq([%w[bob bob@example.com]].to_set)
- end
- end
-
- describe '#get_user_rows_in_batches' do
- it 'retrieves all existing users' do
- alice = commit_users.create!(name: 'alice', email: 'alice@example.com')
- bob = commit_users.create!(name: 'bob', email: 'bob@example.com')
-
- users = [[alice.name, alice.email], [bob.name, bob.email]]
- mapping = {}
-
- migration.get_user_rows_in_batches(users, mapping)
-
- expect(mapping[%w[alice alice@example.com]]).to eq(alice)
- expect(mapping[%w[bob bob@example.com]]).to eq(bob)
- end
- end
-
- describe '#create_missing_users' do
- it 'creates merge request diff commit users that are missing' do
- alice = commit_users.create!(name: 'alice', email: 'alice@example.com')
- users = [%w[alice alice@example.com], %w[bob bob@example.com]]
- mapping = { %w[alice alice@example.com] => alice }
-
- migration.create_missing_users(users, mapping)
-
- expect(mapping[%w[alice alice@example.com]]).to eq(alice)
- expect(mapping[%w[bob bob@example.com]].name).to eq('bob')
- expect(mapping[%w[bob bob@example.com]].email).to eq('bob@example.com')
- end
- end
-
- describe '#update_commit_rows' do
- it 'updates the merge request diff commit rows' do
- to_update = { [42, 0] => [%w[alice alice@example.com], []] }
- user_mapping = { %w[alice alice@example.com] => double(:user, id: 1) }
-
- expect(migration)
- .to receive(:bulk_update_commit_rows)
- .with({ [42, 0] => [1, nil] })
-
- migration.update_commit_rows(to_update, user_mapping)
- end
- end
-
- describe '#bulk_update_commit_rows' do
- context 'when there are no authors and committers' do
- it 'does not update any rows' do
- migration.bulk_update_commit_rows({ [1, 0] => [] })
-
- expect(described_class::MergeRequestDiffCommit.connection)
- .not_to receive(:execute)
- end
- end
-
- context 'when there are only authors' do
- it 'only updates the author IDs' do
- author = commit_users.create!(name: 'Alice', email: 'alice@example.com')
- commit = commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc')
- )
-
- mapping = {
- [commit.merge_request_diff_id, commit.relative_order] =>
- [author.id, nil]
- }
-
- migration.bulk_update_commit_rows(mapping)
-
- commit = commits.first
-
- expect(commit.commit_author_id).to eq(author.id)
- expect(commit.committer_id).to be_nil
- end
- end
-
- context 'when there are only committers' do
- it 'only updates the committer IDs' do
- committer =
- commit_users.create!(name: 'Alice', email: 'alice@example.com')
-
- commit = commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc')
- )
-
- mapping = {
- [commit.merge_request_diff_id, commit.relative_order] =>
- [nil, committer.id]
- }
-
- migration.bulk_update_commit_rows(mapping)
-
- commit = commits.first
-
- expect(commit.committer_id).to eq(committer.id)
- expect(commit.commit_author_id).to be_nil
- end
- end
-
- context 'when there are both authors and committers' do
- it 'updates both the author and committer IDs' do
- author = commit_users.create!(name: 'Bob', email: 'bob@example.com')
- committer =
- commit_users.create!(name: 'Alice', email: 'alice@example.com')
-
- commit = commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc')
- )
-
- mapping = {
- [commit.merge_request_diff_id, commit.relative_order] =>
- [author.id, committer.id]
- }
-
- migration.bulk_update_commit_rows(mapping)
-
- commit = commits.first
-
- expect(commit.commit_author_id).to eq(author.id)
- expect(commit.committer_id).to eq(committer.id)
- end
- end
-
- context 'when there are multiple commit rows to update' do
- it 'updates all the rows' do
- author = commit_users.create!(name: 'Bob', email: 'bob@example.com')
- committer =
- commit_users.create!(name: 'Alice', email: 'alice@example.com')
-
- commit1 = commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 0,
- sha: Gitlab::Database::ShaAttribute.serialize('123abc')
- )
-
- commit2 = commits.create!(
- merge_request_diff_id: diff.id,
- relative_order: 1,
- sha: Gitlab::Database::ShaAttribute.serialize('456abc')
- )
-
- mapping = {
- [commit1.merge_request_diff_id, commit1.relative_order] =>
- [author.id, committer.id],
-
- [commit2.merge_request_diff_id, commit2.relative_order] =>
- [author.id, nil]
- }
-
- migration.bulk_update_commit_rows(mapping)
-
- commit1 = commits.find_by(relative_order: 0)
- commit2 = commits.find_by(relative_order: 1)
-
- expect(commit1.commit_author_id).to eq(author.id)
- expect(commit1.committer_id).to eq(committer.id)
-
- expect(commit2.commit_author_id).to eq(author.id)
- expect(commit2.committer_id).to be_nil
- end
- end
- end
-
- describe '#primary_key' do
- it 'returns the primary key for the commits table' do
- key = migration.primary_key
-
- expect(key.to_sql).to eq('("merge_request_diff_commits"."merge_request_diff_id", "merge_request_diff_commits"."relative_order")')
- end
- end
-
- describe '#prepare' do
- it 'trims a value to at most 512 characters' do
- expect(migration.prepare('€' * 1_000)).to eq('€' * 512)
- end
-
- it 'returns nil if the value is an empty string' do
- expect(migration.prepare('')).to be_nil
- end
- end
-end
diff --git a/spec/lib/gitlab/background_migration/migrate_project_taggings_context_from_tags_to_topics_spec.rb b/spec/lib/gitlab/background_migration/migrate_project_taggings_context_from_tags_to_topics_spec.rb
deleted file mode 100644
index b252df4ecff..00000000000
--- a/spec/lib/gitlab/background_migration/migrate_project_taggings_context_from_tags_to_topics_spec.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::MigrateProjectTaggingsContextFromTagsToTopics,
- :suppress_gitlab_schemas_validate_connection, schema: 20210826171758 do
- it 'correctly migrates project taggings context from tags to topics' do
- taggings = table(:taggings)
-
- project_old_tagging_1 = taggings.create!(taggable_type: 'Project', context: 'tags')
- project_new_tagging_1 = taggings.create!(taggable_type: 'Project', context: 'topics')
- project_other_context_tagging_1 = taggings.create!(taggable_type: 'Project', context: 'other')
- project_old_tagging_2 = taggings.create!(taggable_type: 'Project', context: 'tags')
- project_old_tagging_3 = taggings.create!(taggable_type: 'Project', context: 'tags')
-
- subject.perform(project_old_tagging_1.id, project_old_tagging_2.id)
-
- project_old_tagging_1.reload
- project_new_tagging_1.reload
- project_other_context_tagging_1.reload
- project_old_tagging_2.reload
- project_old_tagging_3.reload
-
- expect(project_old_tagging_1.context).to eq('topics')
- expect(project_new_tagging_1.context).to eq('topics')
- expect(project_other_context_tagging_1.context).to eq('other')
- expect(project_old_tagging_2.context).to eq('topics')
- expect(project_old_tagging_3.context).to eq('tags')
- end
-end
diff --git a/spec/lib/gitlab/background_migration/migrate_remediations_for_vulnerability_findings_spec.rb b/spec/lib/gitlab/background_migration/migrate_remediations_for_vulnerability_findings_spec.rb
new file mode 100644
index 00000000000..b75c0e61b19
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/migrate_remediations_for_vulnerability_findings_spec.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::MigrateRemediationsForVulnerabilityFindings,
+ feature_category: :vulnerability_management do
+ let(:vulnerability_occurrences) { table(:vulnerability_occurrences) }
+ let(:vulnerability_findings_remediations) { table(:vulnerability_findings_remediations) }
+ let(:vulnerability_remediations) { table(:vulnerability_remediations) }
+ let(:remediation_hash) { { summary: 'summary', diff: "ZGlmZiAtLWdp" } }
+ let(:namespace1) { table(:namespaces).create!(name: 'namespace 1', path: 'namespace1') }
+ let(:project1) { table(:projects).create!(namespace_id: namespace1.id, project_namespace_id: namespace1.id) }
+ let(:user) { table(:users).create!(email: 'test1@example.com', projects_limit: 5) }
+
+ let(:scanner1) do
+ table(:vulnerability_scanners).create!(project_id: project1.id, external_id: 'test 1', name: 'test scanner 1')
+ end
+
+ let(:stating_id) { vulnerability_occurrences.pluck(:id).min }
+ let(:end_id) { vulnerability_occurrences.pluck(:id).max }
+
+ let(:migration) do
+ described_class.new(
+ start_id: stating_id,
+ end_id: end_id,
+ batch_table: :vulnerability_occurrences,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 2,
+ connection: ApplicationRecord.connection
+ )
+ end
+
+ subject(:perform_migration) { migration.perform }
+
+ context 'without the presence of remediation key' do
+ before do
+ create_finding!(project1.id, scanner1.id, { other_keys: 'test' })
+ end
+
+ it 'does not create any remediation' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.not_to change { vulnerability_remediations.count }
+ end
+ end
+
+ context 'with remediation equals to an array of nil element' do
+ before do
+ create_finding!(project1.id, scanner1.id, { remediations: [nil] })
+ end
+
+ it 'does not create any remediation' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.not_to change { vulnerability_remediations.count }
+ end
+ end
+
+ context 'with remediation with empty string as the diff key' do
+ let!(:finding) do
+ create_finding!(project1.id, scanner1.id, { remediations: [{ summary: 'summary', diff: '' }] })
+ end
+
+ it 'does not create any remediation' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.not_to change { vulnerability_remediations.count }
+ end
+ end
+
+ context 'with remediation equals to an array of duplicated elements' do
+ let!(:finding) do
+ create_finding!(project1.id, scanner1.id, { remediations: [remediation_hash, remediation_hash] })
+ end
+
+ it 'creates new remediation' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.to change { vulnerability_remediations.count }.by(1)
+ expect(vulnerability_findings_remediations.where(vulnerability_occurrence_id: finding.id).length).to eq(1)
+ end
+ end
+
+ context 'with existing remediations within raw_metadata' do
+ let!(:finding1) { create_finding!(project1.id, scanner1.id, { remediations: [remediation_hash] }) }
+ let!(:finding2) { create_finding!(project1.id, scanner1.id, { remediations: [remediation_hash] }) }
+
+ it 'creates new remediation' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.to change { vulnerability_remediations.count }.by(1)
+ expect(vulnerability_findings_remediations.where(vulnerability_occurrence_id: finding1.id).length).to eq(1)
+ expect(vulnerability_findings_remediations.where(vulnerability_occurrence_id: finding2.id).length).to eq(1)
+ end
+
+ context 'when create throws exception other than ActiveRecord::RecordNotUnique' do
+ before do
+ allow(migration).to receive(:create_finding_remediations).and_raise(StandardError)
+ end
+
+ it 'rolls back all related transactions' do
+ expect(Gitlab::AppLogger).to receive(:error).with({
+ class: described_class.name, message: StandardError.to_s, model_id: finding1.id
+ })
+ expect(Gitlab::AppLogger).to receive(:error).with({
+ class: described_class.name, message: StandardError.to_s, model_id: finding2.id
+ })
+ expect { perform_migration }.not_to change { vulnerability_remediations.count }
+ expect(vulnerability_findings_remediations.where(vulnerability_occurrence_id: finding1.id).length).to eq(0)
+ expect(vulnerability_findings_remediations.where(vulnerability_occurrence_id: finding2.id).length).to eq(0)
+ end
+ end
+ end
+
+ context 'with existing remediation records' do
+ let!(:finding) { create_finding!(project1.id, scanner1.id, { remediations: [remediation_hash] }) }
+
+ before do
+ vulnerability_remediations.create!(project_id: project1.id, summary: remediation_hash[:summary],
+ checksum: checksum(remediation_hash[:diff]), file: Tempfile.new.path)
+ end
+
+ it 'does not create new remediation' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.not_to change { vulnerability_remediations.count }
+ expect(vulnerability_findings_remediations.where(vulnerability_occurrence_id: finding.id).length).to eq(1)
+ end
+ end
+
+ context 'with same raw_metadata for different projects' do
+ let(:namespace2) { table(:namespaces).create!(name: 'namespace 2', path: 'namespace2') }
+ let(:project2) { table(:projects).create!(namespace_id: namespace2.id, project_namespace_id: namespace2.id) }
+ let(:scanner2) do
+ table(:vulnerability_scanners).create!(project_id: project2.id, external_id: 'test 2', name: 'test scanner 2')
+ end
+
+ let!(:finding1) { create_finding!(project1.id, scanner1.id, { remediations: [remediation_hash] }) }
+ let!(:finding2) { create_finding!(project2.id, scanner2.id, { remediations: [remediation_hash] }) }
+
+ it 'creates new remediation for each project' do
+ expect(Gitlab::AppLogger).not_to receive(:error)
+
+ expect { perform_migration }.to change { vulnerability_remediations.count }.by(2)
+ expect(vulnerability_findings_remediations.where(vulnerability_occurrence_id: finding1.id).length).to eq(1)
+ expect(vulnerability_findings_remediations.where(vulnerability_occurrence_id: finding2.id).length).to eq(1)
+ end
+ end
+
+ private
+
+ def create_finding!(project_id, scanner_id, raw_metadata)
+ vulnerability = table(:vulnerabilities).create!(project_id: project_id, author_id: user.id, title: 'test',
+ severity: 4, confidence: 4, report_type: 0)
+
+ identifier = table(:vulnerability_identifiers).create!(project_id: project_id, external_type: 'uuid-v5',
+ external_id: 'uuid-v5', fingerprint: OpenSSL::Digest::SHA256.hexdigest(vulnerability.id.to_s),
+ name: 'Identifier for UUIDv5 2 2')
+
+ table(:vulnerability_occurrences).create!(
+ vulnerability_id: vulnerability.id, project_id: project_id, scanner_id: scanner_id,
+ primary_identifier_id: identifier.id, name: 'test', severity: 4, confidence: 4, report_type: 0,
+ uuid: SecureRandom.uuid, project_fingerprint: '123qweasdzxc', location: { "image" => "alpine:3.4" },
+ location_fingerprint: 'test', metadata_version: 'test',
+ raw_metadata: raw_metadata.to_json)
+ end
+
+ def checksum(value)
+ sha = Digest::SHA256.hexdigest(value)
+ Gitlab::Database::ShaAttribute.new.serialize(sha)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb b/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb
deleted file mode 100644
index 08fde0d0ff4..00000000000
--- a/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-require 'webauthn/u2f_migrator'
-
-RSpec.describe Gitlab::BackgroundMigration::MigrateU2fWebauthn, :migration, schema: 20210826171758 do
- let(:users) { table(:users) }
-
- let(:user) { users.create!(email: 'email@email.com', name: 'foo', username: 'foo', projects_limit: 0) }
-
- let(:u2f_registrations) { table(:u2f_registrations) }
- let(:webauthn_registrations) { table(:webauthn_registrations) }
-
- let!(:u2f_registration_not_migrated) { create_u2f_registration(1, 'reg1') }
- let!(:u2f_registration_not_migrated_no_name) { create_u2f_registration(2, nil, 2) }
- let!(:u2f_registration_migrated) { create_u2f_registration(3, 'reg3') }
-
- subject { described_class.new.perform(1, 3) }
-
- before do
- converted_credential = convert_credential_for(u2f_registration_migrated)
- webauthn_registrations.create!(converted_credential)
- end
-
- it 'migrates all records' do
- expect { subject }.to change { webauthn_registrations.count }.from(1).to(3)
-
- all_webauthn_registrations = webauthn_registrations.all.map(&:attributes)
-
- [u2f_registration_not_migrated, u2f_registration_not_migrated_no_name].each do |u2f_registration|
- expected_credential = convert_credential_for(u2f_registration).except(:created_at).stringify_keys
- expect(all_webauthn_registrations).to include(a_hash_including(expected_credential))
- end
- end
-
- def create_u2f_registration(id, name, counter = 5)
- device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5))
- u2f_registrations.create!({ id: id,
- certificate: Base64.strict_encode64(device.cert_raw),
- key_handle: U2F.urlsafe_encode64(device.key_handle_raw),
- public_key: Base64.strict_encode64(device.origin_public_key_raw),
- counter: counter,
- name: name,
- user_id: user.id })
- end
-
- def convert_credential_for(u2f_registration)
- converted_credential = WebAuthn::U2fMigrator.new(
- app_id: Gitlab.config.gitlab.url,
- certificate: u2f_registration.certificate,
- key_handle: u2f_registration.key_handle,
- public_key: u2f_registration.public_key,
- counter: u2f_registration.counter
- ).credential
-
- {
- credential_xid: Base64.strict_encode64(converted_credential.id),
- public_key: Base64.strict_encode64(converted_credential.public_key),
- counter: u2f_registration.counter,
- name: u2f_registration.name || '',
- user_id: u2f_registration.user_id,
- u2f_registration_id: u2f_registration.id,
- created_at: u2f_registration.created_at
- }
- end
-end
diff --git a/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb b/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb
deleted file mode 100644
index 71cf58a933f..00000000000
--- a/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::MoveContainerRegistryEnabledToProjectFeature, :migration, schema: 20210826171758 do
- let(:enabled) { 20 }
- let(:disabled) { 0 }
-
- let(:namespaces) { table(:namespaces) }
- let(:project_features) { table(:project_features) }
- let(:projects) { table(:projects) }
-
- let(:namespace) { namespaces.create!(name: 'user', path: 'user') }
- let!(:project1) { projects.create!(namespace_id: namespace.id) }
- let!(:project2) { projects.create!(namespace_id: namespace.id) }
- let!(:project3) { projects.create!(namespace_id: namespace.id) }
- let!(:project4) { projects.create!(namespace_id: namespace.id) }
-
- # pages_access_level cannot be null.
- let(:non_null_project_features) { { pages_access_level: enabled } }
- let!(:project_feature1) { project_features.create!(project_id: project1.id, **non_null_project_features) }
- let!(:project_feature2) { project_features.create!(project_id: project2.id, **non_null_project_features) }
- let!(:project_feature3) { project_features.create!(project_id: project3.id, **non_null_project_features) }
-
- describe '#perform' do
- before do
- project1.update!(container_registry_enabled: true)
- project2.update!(container_registry_enabled: false)
- project3.update!(container_registry_enabled: nil)
- project4.update!(container_registry_enabled: true)
- end
-
- it 'copies values to project_features' do
- table(:background_migration_jobs).create!(
- class_name: 'MoveContainerRegistryEnabledToProjectFeature',
- arguments: [project1.id, project4.id]
- )
- table(:background_migration_jobs).create!(
- class_name: 'MoveContainerRegistryEnabledToProjectFeature',
- arguments: [-1, -3]
- )
-
- expect(project1.container_registry_enabled).to eq(true)
- expect(project2.container_registry_enabled).to eq(false)
- expect(project3.container_registry_enabled).to eq(nil)
- expect(project4.container_registry_enabled).to eq(true)
-
- expect(project_feature1.container_registry_access_level).to eq(disabled)
- expect(project_feature2.container_registry_access_level).to eq(disabled)
- expect(project_feature3.container_registry_access_level).to eq(disabled)
-
- expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |logger|
- expect(logger).to receive(:info)
- .with(message: "#{described_class}: Copied container_registry_enabled values for projects with IDs between #{project1.id}..#{project4.id}")
-
- expect(logger).not_to receive(:info)
- end
-
- subject.perform(project1.id, project4.id)
-
- expect(project1.reload.container_registry_enabled).to eq(true)
- expect(project2.reload.container_registry_enabled).to eq(false)
- expect(project3.reload.container_registry_enabled).to eq(nil)
- expect(project4.container_registry_enabled).to eq(true)
-
- expect(project_feature1.reload.container_registry_access_level).to eq(enabled)
- expect(project_feature2.reload.container_registry_access_level).to eq(disabled)
- expect(project_feature3.reload.container_registry_access_level).to eq(disabled)
-
- expect(table(:background_migration_jobs).first.status).to eq(1) # succeeded
- expect(table(:background_migration_jobs).second.status).to eq(0) # pending
- end
-
- context 'when no projects exist in range' do
- it 'does not fail' do
- expect(project1.container_registry_enabled).to eq(true)
- expect(project_feature1.container_registry_access_level).to eq(disabled)
-
- expect { subject.perform(-1, -2) }.not_to raise_error
-
- expect(project1.container_registry_enabled).to eq(true)
- expect(project_feature1.container_registry_access_level).to eq(disabled)
- end
- end
-
- context 'when projects in range all have nil container_registry_enabled' do
- it 'does not fail' do
- expect(project3.container_registry_enabled).to eq(nil)
- expect(project_feature3.container_registry_access_level).to eq(disabled)
-
- expect { subject.perform(project3.id, project3.id) }.not_to raise_error
-
- expect(project3.container_registry_enabled).to eq(nil)
- expect(project_feature3.container_registry_access_level).to eq(disabled)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/background_migration/nullify_creator_id_column_of_orphaned_projects_spec.rb b/spec/lib/gitlab/background_migration/nullify_creator_id_column_of_orphaned_projects_spec.rb
index a8574411957..f671a673a08 100644
--- a/spec/lib/gitlab/background_migration/nullify_creator_id_column_of_orphaned_projects_spec.rb
+++ b/spec/lib/gitlab/background_migration/nullify_creator_id_column_of_orphaned_projects_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::NullifyCreatorIdColumnOfOrphanedProjects, feature_category: :projects do
+RSpec.describe Gitlab::BackgroundMigration::NullifyCreatorIdColumnOfOrphanedProjects, feature_category: :projects,
+ schema: 20230130073109 do
let(:users) { table(:users) }
let(:projects) { table(:projects) }
let(:namespaces) { table(:namespaces) }
diff --git a/spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb b/spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb
index 2f0eef3c399..5b234679e22 100644
--- a/spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb
+++ b/spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::NullifyOrphanRunnerIdOnCiBuilds,
- :suppress_gitlab_schemas_validate_connection, migration: :gitlab_ci, schema: 20220223112304 do
+ :suppress_gitlab_schemas_validate_connection, migration: :gitlab_ci, schema: 20220223112304 do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:ci_runners) { table(:ci_runners) }
diff --git a/spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb b/spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb
deleted file mode 100644
index 8e07b43f5b9..00000000000
--- a/spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::PopulateTopicsTotalProjectsCountCache, schema: 20211006060436 do
- it 'correctly populates total projects count cache' do
- namespaces = table(:namespaces)
- projects = table(:projects)
- topics = table(:topics)
- project_topics = table(:project_topics)
-
- group = namespaces.create!(name: 'group', path: 'group')
- project_1 = projects.create!(namespace_id: group.id)
- project_2 = projects.create!(namespace_id: group.id)
- project_3 = projects.create!(namespace_id: group.id)
- topic_1 = topics.create!(name: 'Topic1')
- topic_2 = topics.create!(name: 'Topic2')
- topic_3 = topics.create!(name: 'Topic3')
- topic_4 = topics.create!(name: 'Topic4')
-
- project_topics.create!(project_id: project_1.id, topic_id: topic_1.id)
- project_topics.create!(project_id: project_1.id, topic_id: topic_3.id)
- project_topics.create!(project_id: project_2.id, topic_id: topic_3.id)
- project_topics.create!(project_id: project_1.id, topic_id: topic_4.id)
- project_topics.create!(project_id: project_2.id, topic_id: topic_4.id)
- project_topics.create!(project_id: project_3.id, topic_id: topic_4.id)
-
- subject.perform(topic_1.id, topic_4.id)
-
- expect(topic_1.reload.total_projects_count).to eq(1)
- expect(topic_2.reload.total_projects_count).to eq(0)
- expect(topic_3.reload.total_projects_count).to eq(2)
- expect(topic_4.reload.total_projects_count).to eq(3)
- end
-end
diff --git a/spec/lib/gitlab/background_migration/populate_vulnerability_dismissal_fields_spec.rb b/spec/lib/gitlab/background_migration/populate_vulnerability_dismissal_fields_spec.rb
new file mode 100644
index 00000000000..50380247c9f
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_vulnerability_dismissal_fields_spec.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::PopulateVulnerabilityDismissalFields, schema: 20230412185837, feature_category: :vulnerability_management do # rubocop:disable Layout/LineLength
+ let(:users) { table(:users) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+ let(:findings) { table(:vulnerability_occurrences) }
+ let(:scanners) { table(:vulnerability_scanners) }
+ let(:identifiers) { table(:vulnerability_identifiers) }
+ let(:feedback) { table(:vulnerability_feedback) }
+
+ let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) }
+ let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') }
+ let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo', project_namespace_id: namespace.id) }
+ let(:vulnerability_1) do
+ vulnerabilities.create!(title: 'title', state: 2, severity: 0,
+ confidence: 5, report_type: 2, project_id: project.id, author_id: user.id
+ )
+ end
+
+ let(:vulnerability_2) do
+ vulnerabilities.create!(title: 'title', state: 2, severity: 0,
+ confidence: 5, report_type: 2, project_id: project.id, author_id: user.id
+ )
+ end
+
+ let(:scanner) { scanners.create!(project_id: project.id, external_id: 'foo', name: 'bar') }
+ let(:identifier) do
+ identifiers.create!(project_id: project.id, fingerprint: 'foo',
+ external_type: 'bar', external_id: 'zoo', name: 'identifier'
+ )
+ end
+
+ let(:uuid) { SecureRandom.uuid }
+
+ before do
+ feedback.create!(feedback_type: 0,
+ category: 'sast',
+ project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8',
+ project_id: project.id,
+ author_id: user.id,
+ created_at: Time.current,
+ finding_uuid: uuid
+ )
+
+ findings.create!(name: 'Finding',
+ report_type: 'sast',
+ project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1f98',
+ location_fingerprint: 'bar',
+ severity: 1,
+ confidence: 1,
+ metadata_version: 1,
+ raw_metadata: '',
+ details: {},
+ uuid: uuid,
+ project_id: project.id,
+ vulnerability_id: vulnerability_1.id,
+ scanner_id: scanner.id,
+ primary_identifier_id: identifier.id
+ )
+
+ allow(::Gitlab::BackgroundMigration::Logger).to receive_messages(info: true, warn: true, error: true)
+ end
+
+ subject do
+ described_class.new(
+ start_id: vulnerability_1.id,
+ end_id: vulnerability_2.id,
+ batch_table: :vulnerabilities,
+ batch_column: :id,
+ sub_batch_size: 200,
+ pause_ms: 2.minutes,
+ connection: ApplicationRecord.connection
+ )
+ end
+
+ describe '#perform' do
+ it 'updates the missing dismissal information of the vulnerability' do
+ expect { subject.perform }.to change { vulnerability_1.reload.dismissed_at }
+ .from(nil)
+ .and change { vulnerability_1.reload.dismissed_by_id }.from(nil).to(user.id)
+ end
+
+ it 'writes log messages', :aggregate_failures do
+ subject.perform
+
+ expect(::Gitlab::BackgroundMigration::Logger).to have_received(:info).with(migrator: described_class.name,
+ message: 'Dismissal information has been copied',
+ count: 2
+ )
+ expect(::Gitlab::BackgroundMigration::Logger).to have_received(:warn).with(migrator: described_class.name,
+ message: 'Could not update vulnerability!',
+ vulnerability_id: vulnerability_2.id
+ )
+ end
+
+ context 'when logger throws exception StandardError' do
+ before do
+ allow(::Gitlab::BackgroundMigration::Logger).to receive(:warn).and_raise(StandardError)
+ end
+
+ it 'logs StandardError' do
+ expect(::Gitlab::BackgroundMigration::Logger).to receive(:error).with({
+ migrator: described_class.name, message: StandardError.to_s, vulnerability_id: vulnerability_2.id
+ })
+
+ subject.perform
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/prune_stale_project_export_jobs_spec.rb b/spec/lib/gitlab/background_migration/prune_stale_project_export_jobs_spec.rb
index 5150d0ea4b0..3446b9f0676 100644
--- a/spec/lib/gitlab/background_migration/prune_stale_project_export_jobs_spec.rb
+++ b/spec/lib/gitlab/background_migration/prune_stale_project_export_jobs_spec.rb
@@ -10,14 +10,15 @@ RSpec.describe Gitlab::BackgroundMigration::PruneStaleProjectExportJobs, feature
let(:uploads) { table(:project_relation_export_uploads) }
subject(:perform_migration) do
- described_class.new(start_id: 1,
- end_id: 300,
- batch_table: :project_export_jobs,
- batch_column: :id,
- sub_batch_size: 2,
- pause_ms: 0,
- connection: ActiveRecord::Base.connection)
- .perform
+ described_class.new(
+ start_id: 1,
+ end_id: 300,
+ batch_table: :project_export_jobs,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
end
it 'removes export jobs and associated relations older than 7 days' do
diff --git a/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb
deleted file mode 100644
index 2271bbfb2f3..00000000000
--- a/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb
+++ /dev/null
@@ -1,530 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-def create_background_migration_job(ids, status)
- proper_status = case status
- when :pending
- Gitlab::Database::BackgroundMigrationJob.statuses['pending']
- when :succeeded
- Gitlab::Database::BackgroundMigrationJob.statuses['succeeded']
- else
- raise ArgumentError
- end
-
- background_migration_jobs.create!(
- class_name: 'RecalculateVulnerabilitiesOccurrencesUuid',
- arguments: Array(ids),
- status: proper_status,
- created_at: Time.now.utc
- )
-end
-
-RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid, :suppress_gitlab_schemas_validate_connection, schema: 20211124132705 do
- let(:background_migration_jobs) { table(:background_migration_jobs) }
- let(:pending_jobs) { background_migration_jobs.where(status: Gitlab::Database::BackgroundMigrationJob.statuses['pending']) }
- let(:succeeded_jobs) { background_migration_jobs.where(status: Gitlab::Database::BackgroundMigrationJob.statuses['succeeded']) }
- let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
- let(:users) { table(:users) }
- let(:user) { create_user! }
- let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) }
- let(:scanners) { table(:vulnerability_scanners) }
- let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
- let(:scanner2) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') }
- let(:vulnerabilities) { table(:vulnerabilities) }
- let(:vulnerability_findings) { table(:vulnerability_occurrences) }
- let(:vulnerability_finding_pipelines) { table(:vulnerability_occurrence_pipelines) }
- let(:vulnerability_finding_signatures) { table(:vulnerability_finding_signatures) }
- let(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
-
- let(:identifier_1) { 'identifier-1' }
- let!(:vulnerability_identifier) do
- vulnerability_identifiers.create!(
- project_id: project.id,
- external_type: identifier_1,
- external_id: identifier_1,
- fingerprint: Gitlab::Database::ShaAttribute.serialize('ff9ef548a6e30a0462795d916f3f00d1e2b082ca'),
- name: 'Identifier 1')
- end
-
- let(:identifier_2) { 'identifier-2' }
- let!(:vulnerability_identfier2) do
- vulnerability_identifiers.create!(
- project_id: project.id,
- external_type: identifier_2,
- external_id: identifier_2,
- fingerprint: Gitlab::Database::ShaAttribute.serialize('4299e8ddd819f9bde9cfacf45716724c17b5ddf7'),
- name: 'Identifier 2')
- end
-
- let(:identifier_3) { 'identifier-3' }
- let!(:vulnerability_identifier3) do
- vulnerability_identifiers.create!(
- project_id: project.id,
- external_type: identifier_3,
- external_id: identifier_3,
- fingerprint: Gitlab::Database::ShaAttribute.serialize('8e91632f9c6671e951834a723ee221c44cc0d844'),
- name: 'Identifier 3')
- end
-
- let(:known_uuid_v4) { "b3cc2518-5446-4dea-871c-89d5e999c1ac" }
- let(:known_uuid_v5) { "05377088-dc26-5161-920e-52a7159fdaa1" }
- let(:desired_uuid_v5) { "f3e9a23f-9181-54bf-a5ab-c5bc7a9b881a" }
-
- subject { described_class.new.perform(start_id, end_id) }
-
- context "when finding has a UUIDv4" do
- before do
- @uuid_v4 = create_finding!(
- vulnerability_id: nil,
- project_id: project.id,
- scanner_id: scanner2.id,
- primary_identifier_id: vulnerability_identfier2.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize("fa18f432f1d56675f4098d318739c3cd5b14eb3e"),
- uuid: known_uuid_v4
- )
- end
-
- let(:start_id) { @uuid_v4.id }
- let(:end_id) { @uuid_v4.id }
-
- it "replaces it with UUIDv5" do
- expect(vulnerability_findings.pluck(:uuid)).to match_array([known_uuid_v4])
-
- subject
-
- expect(vulnerability_findings.pluck(:uuid)).to match_array([desired_uuid_v5])
- end
-
- it 'logs recalculation' do
- expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance|
- expect(instance).to receive(:info).twice
- end
-
- subject
- end
- end
-
- context "when finding has a UUIDv5" do
- before do
- @uuid_v5 = create_finding!(
- vulnerability_id: nil,
- project_id: project.id,
- scanner_id: scanner.id,
- primary_identifier_id: vulnerability_identifier.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize("838574be0210968bf6b9f569df9c2576242cbf0a"),
- uuid: known_uuid_v5
- )
- end
-
- let(:start_id) { @uuid_v5.id }
- let(:end_id) { @uuid_v5.id }
-
- it "stays the same" do
- expect(vulnerability_findings.pluck(:uuid)).to match_array([known_uuid_v5])
-
- subject
-
- expect(vulnerability_findings.pluck(:uuid)).to match_array([known_uuid_v5])
- end
- end
-
- context 'if a duplicate UUID would be generated' do # rubocop: disable RSpec/MultipleMemoizedHelpers
- let(:v1) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:finding_with_incorrect_uuid) do
- create_finding!(
- vulnerability_id: v1.id,
- project_id: project.id,
- scanner_id: scanner.id,
- primary_identifier_id: vulnerability_identifier.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis')
- uuid: 'bd95c085-71aa-51d7-9bb6-08ae669c262e'
- )
- end
-
- let(:v2) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:finding_with_correct_uuid) do
- create_finding!(
- vulnerability_id: v2.id,
- project_id: project.id,
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner2.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis')
- uuid: '91984483-5efe-5215-b471-d524ac5792b1'
- )
- end
-
- let(:v3) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:finding_with_incorrect_uuid2) do
- create_finding!(
- vulnerability_id: v3.id,
- project_id: project.id,
- scanner_id: scanner.id,
- primary_identifier_id: vulnerability_identfier2.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis')
- uuid: '00000000-1111-2222-3333-444444444444'
- )
- end
-
- let(:v4) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:finding_with_correct_uuid2) do
- create_finding!(
- vulnerability_id: v4.id,
- project_id: project.id,
- scanner_id: scanner2.id,
- primary_identifier_id: vulnerability_identfier2.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis')
- uuid: '1edd751e-ef9a-5391-94db-a832c8635bfc'
- )
- end
-
- let!(:finding_with_incorrect_uuid3) do
- create_finding!(
- vulnerability_id: nil,
- project_id: project.id,
- scanner_id: scanner.id,
- primary_identifier_id: vulnerability_identifier3.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis')
- uuid: '22222222-3333-4444-5555-666666666666'
- )
- end
-
- let!(:duplicate_not_in_the_same_batch) do
- create_finding!(
- id: 99999,
- vulnerability_id: nil,
- project_id: project.id,
- scanner_id: scanner2.id,
- primary_identifier_id: vulnerability_identifier3.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis')
- uuid: '4564f9d5-3c6b-5cc3-af8c-7c25285362a7'
- )
- end
-
- let(:start_id) { finding_with_incorrect_uuid.id }
- let(:end_id) { finding_with_incorrect_uuid3.id }
-
- before do
- 4.times do
- create_finding_pipeline!(project_id: project.id, finding_id: finding_with_incorrect_uuid.id)
- create_finding_pipeline!(project_id: project.id, finding_id: finding_with_correct_uuid.id)
- create_finding_pipeline!(project_id: project.id, finding_id: finding_with_incorrect_uuid2.id)
- create_finding_pipeline!(project_id: project.id, finding_id: finding_with_correct_uuid2.id)
- end
- end
-
- it 'drops duplicates and related records', :aggregate_failures do
- expect(vulnerability_findings.pluck(:id)).to match_array(
- [
- finding_with_correct_uuid.id,
- finding_with_incorrect_uuid.id,
- finding_with_correct_uuid2.id,
- finding_with_incorrect_uuid2.id,
- finding_with_incorrect_uuid3.id,
- duplicate_not_in_the_same_batch.id
- ])
-
- expect { subject }.to change(vulnerability_finding_pipelines, :count).from(16).to(8)
- .and change(vulnerability_findings, :count).from(6).to(3)
- .and change(vulnerabilities, :count).from(4).to(2)
-
- expect(vulnerability_findings.pluck(:id)).to match_array([finding_with_incorrect_uuid.id, finding_with_incorrect_uuid2.id, finding_with_incorrect_uuid3.id])
- end
-
- context 'if there are conflicting UUID values within the batch' do # rubocop: disable RSpec/MultipleMemoizedHelpers
- let(:end_id) { finding_with_broken_data_integrity.id }
- let(:vulnerability_5) { create_vulnerability!(project_id: project.id, author_id: user.id) }
- let(:different_project) { table(:projects).create!(namespace_id: namespace.id) }
- let!(:identifier_with_broken_data_integrity) do
- vulnerability_identifiers.create!(
- project_id: different_project.id,
- external_type: identifier_2,
- external_id: identifier_2,
- fingerprint: Gitlab::Database::ShaAttribute.serialize('4299e8ddd819f9bde9cfacf45716724c17b5ddf7'),
- name: 'Identifier 2')
- end
-
- let(:finding_with_broken_data_integrity) do
- create_finding!(
- vulnerability_id: vulnerability_5,
- project_id: project.id,
- scanner_id: scanner.id,
- primary_identifier_id: identifier_with_broken_data_integrity.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis')
- uuid: SecureRandom.uuid
- )
- end
-
- it 'deletes the conflicting record' do
- expect { subject }.to change { vulnerability_findings.find_by_id(finding_with_broken_data_integrity.id) }.to(nil)
- end
- end
-
- context 'if a conflicting UUID is found during the migration' do # rubocop:disable RSpec/MultipleMemoizedHelpers
- let(:finding_class) { Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid::VulnerabilitiesFinding }
- let(:uuid) { '4564f9d5-3c6b-5cc3-af8c-7c25285362a7' }
-
- before do
- exception = ActiveRecord::RecordNotUnique.new("(uuid)=(#{uuid})")
-
- call_count = 0
- allow(::Gitlab::Database::BulkUpdate).to receive(:execute) do
- call_count += 1
- call_count.eql?(1) ? raise(exception) : {}
- end
-
- allow(finding_class).to receive(:find_by).with(uuid: uuid).and_return(duplicate_not_in_the_same_batch)
- end
-
- it 'retries the recalculation' do
- subject
-
- expect(Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid::VulnerabilitiesFinding)
- .to have_received(:find_by).with(uuid: uuid).once
- end
-
- it 'logs the conflict' do
- expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance|
- expect(instance).to receive(:info).exactly(6).times
- end
-
- subject
- end
-
- it 'marks the job as done' do
- create_background_migration_job([start_id, end_id], :pending)
-
- subject
-
- expect(pending_jobs.count).to eq(0)
- expect(succeeded_jobs.count).to eq(1)
- end
- end
-
- it 'logs an exception if a different uniquness problem was found' do
- exception = ActiveRecord::RecordNotUnique.new("Totally not an UUID uniqueness problem")
- allow(::Gitlab::Database::BulkUpdate).to receive(:execute).and_raise(exception)
- allow(Gitlab::ErrorTracking).to receive(:track_and_raise_exception)
-
- subject
-
- expect(Gitlab::ErrorTracking).to have_received(:track_and_raise_exception).with(exception).once
- end
-
- it 'logs a duplicate found message' do
- expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance|
- expect(instance).to receive(:info).exactly(3).times
- end
-
- subject
- end
- end
-
- context 'when finding has a signature' do
- before do
- @f1 = create_finding!(
- vulnerability_id: nil,
- project_id: project.id,
- scanner_id: scanner.id,
- primary_identifier_id: vulnerability_identifier.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis')
- uuid: 'd15d774d-e4b1-5a1b-929b-19f2a53e35ec'
- )
-
- vulnerability_finding_signatures.create!(
- finding_id: @f1.id,
- algorithm_type: 2, # location
- signature_sha: Gitlab::Database::ShaAttribute.serialize('57d4e05205f6462a73f039a5b2751aa1ab344e6e') # sha1('youshouldusethis')
- )
-
- vulnerability_finding_signatures.create!(
- finding_id: @f1.id,
- algorithm_type: 1, # hash
- signature_sha: Gitlab::Database::ShaAttribute.serialize('c554d8d8df1a7a14319eafdaae24af421bf5b587') # sha1('andnotthis')
- )
-
- @f2 = create_finding!(
- vulnerability_id: nil,
- project_id: project.id,
- scanner_id: scanner.id,
- primary_identifier_id: vulnerability_identfier2.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis')
- uuid: '4be029b5-75e5-5ac0-81a2-50ab41726135'
- )
-
- vulnerability_finding_signatures.create!(
- finding_id: @f2.id,
- algorithm_type: 2, # location
- signature_sha: Gitlab::Database::ShaAttribute.serialize('57d4e05205f6462a73f039a5b2751aa1ab344e6e') # sha1('youshouldusethis')
- )
-
- vulnerability_finding_signatures.create!(
- finding_id: @f2.id,
- algorithm_type: 1, # hash
- signature_sha: Gitlab::Database::ShaAttribute.serialize('c554d8d8df1a7a14319eafdaae24af421bf5b587') # sha1('andnotthis')
- )
- end
-
- let(:start_id) { @f1.id }
- let(:end_id) { @f2.id }
-
- let(:uuids_before) { [@f1.uuid, @f2.uuid] }
- let(:uuids_after) { %w[d3b60ddd-d312-5606-b4d3-ad058eebeacb 349d9bec-c677-5530-a8ac-5e58889c3b1a] }
-
- it 'is recalculated using signature' do
- expect(vulnerability_findings.pluck(:uuid)).to match_array(uuids_before)
-
- subject
-
- expect(vulnerability_findings.pluck(:uuid)).to match_array(uuids_after)
- end
- end
-
- context 'if all records are removed before the job ran' do
- let(:start_id) { 1 }
- let(:end_id) { 9 }
-
- before do
- create_background_migration_job([start_id, end_id], :pending)
- end
-
- it 'does not error out' do
- expect { subject }.not_to raise_error
- end
-
- it 'marks the job as done' do
- subject
-
- expect(pending_jobs.count).to eq(0)
- expect(succeeded_jobs.count).to eq(1)
- end
- end
-
- context 'when recalculation fails' do
- before do
- @uuid_v4 = create_finding!(
- vulnerability_id: nil,
- project_id: project.id,
- scanner_id: scanner2.id,
- primary_identifier_id: vulnerability_identfier2.id,
- report_type: 0, # "sast"
- location_fingerprint: Gitlab::Database::ShaAttribute.serialize("fa18f432f1d56675f4098d318739c3cd5b14eb3e"),
- uuid: known_uuid_v4
- )
-
- allow(Gitlab::ErrorTracking).to receive(:track_and_raise_exception)
- allow(::Gitlab::Database::BulkUpdate).to receive(:execute).and_raise(expected_error)
- end
-
- let(:start_id) { @uuid_v4.id }
- let(:end_id) { @uuid_v4.id }
- let(:expected_error) { RuntimeError.new }
-
- it 'captures the errors and does not crash entirely' do
- expect { subject }.not_to raise_error
-
- allow(Gitlab::ErrorTracking).to receive(:track_and_raise_exception)
- expect(Gitlab::ErrorTracking).to have_received(:track_and_raise_exception).with(expected_error).once
- end
-
- it_behaves_like 'marks background migration job records' do
- let(:arguments) { [1, 4] }
- subject { described_class.new }
- end
- end
-
- it_behaves_like 'marks background migration job records' do
- let(:arguments) { [1, 4] }
- subject { described_class.new }
- end
-
- private
-
- def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0)
- vulnerabilities.create!(
- project_id: project_id,
- author_id: author_id,
- title: title,
- severity: severity,
- confidence: confidence,
- report_type: report_type
- )
- end
-
- # rubocop:disable Metrics/ParameterLists
- def create_finding!(
- vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, id: nil,
- name: "test", severity: 7, confidence: 7, report_type: 0,
- project_fingerprint: '123qweasdzxc', location_fingerprint: 'test',
- metadata_version: 'test', raw_metadata: 'test', uuid: SecureRandom.uuid)
- vulnerability_findings.create!({
- id: id,
- vulnerability_id: vulnerability_id,
- project_id: project_id,
- name: name,
- severity: severity,
- confidence: confidence,
- report_type: report_type,
- project_fingerprint: project_fingerprint,
- scanner_id: scanner_id,
- primary_identifier_id: primary_identifier_id,
- location_fingerprint: location_fingerprint,
- metadata_version: metadata_version,
- raw_metadata: raw_metadata,
- uuid: uuid
- }.compact
- )
- end
- # rubocop:enable Metrics/ParameterLists
-
- def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.zone.now, confirmed_at: Time.zone.now)
- users.create!(
- name: name,
- email: email,
- username: name,
- projects_limit: 0,
- user_type: user_type,
- confirmed_at: confirmed_at
- )
- end
-
- def create_finding_pipeline!(project_id:, finding_id:)
- pipeline = table(:ci_pipelines).create!(project_id: project_id)
- vulnerability_finding_pipelines.create!(pipeline_id: pipeline.id, occurrence_id: finding_id)
- end
-end
diff --git a/spec/lib/gitlab/background_migration/remove_backfilled_job_artifacts_expire_at_spec.rb b/spec/lib/gitlab/background_migration/remove_backfilled_job_artifacts_expire_at_spec.rb
index 5fede892463..582c0fe1b1b 100644
--- a/spec/lib/gitlab/background_migration/remove_backfilled_job_artifacts_expire_at_spec.rb
+++ b/spec/lib/gitlab/background_migration/remove_backfilled_job_artifacts_expire_at_spec.rb
@@ -86,8 +86,10 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveBackfilledJobArtifactsExpireAt
def create_job_artifact(id:, file_type:, expire_at:)
job = table(:ci_builds, database: :ci).create!(id: id, partition_id: 100)
- job_artifact.create!(id: id, job_id: job.id, expire_at: expire_at, project_id: project.id,
- file_type: file_type, partition_id: 100)
+ job_artifact.create!(
+ id: id, job_id: job.id, expire_at: expire_at, project_id: project.id,
+ file_type: file_type, partition_id: 100
+ )
end
end
end
diff --git a/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb b/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb
deleted file mode 100644
index ed08ae22245..00000000000
--- a/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb
+++ /dev/null
@@ -1,171 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindings, :migration, schema: 20220326161803 do
- let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
- let(:users) { table(:users) }
- let(:user) { create_user! }
- let(:project) { table(:projects).create!(id: 14219619, namespace_id: namespace.id) }
- let(:scanners) { table(:vulnerability_scanners) }
- let!(:scanner1) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
- let!(:scanner2) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') }
- let!(:scanner3) { scanners.create!(project_id: project.id, external_id: 'test 3', name: 'test scanner 3') }
- let!(:unrelated_scanner) { scanners.create!(project_id: project.id, external_id: 'unreleated_scanner', name: 'unrelated scanner') }
- let(:vulnerabilities) { table(:vulnerabilities) }
- let(:vulnerability_findings) { table(:vulnerability_occurrences) }
- let(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
- let(:vulnerability_identifier) do
- vulnerability_identifiers.create!(
- id: 1244459,
- project_id: project.id,
- external_type: 'vulnerability-identifier',
- external_id: 'vulnerability-identifier',
- fingerprint: '0a203e8cd5260a1948edbedc76c7cb91ad6a2e45',
- name: 'vulnerability identifier')
- end
-
- let!(:vulnerability_for_first_duplicate) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:first_finding_duplicate) do
- create_finding!(
- id: 5606961,
- uuid: "bd95c085-71aa-51d7-9bb6-08ae669c262e",
- vulnerability_id: vulnerability_for_first_duplicate.id,
- report_type: 0,
- location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner1.id,
- project_id: project.id
- )
- end
-
- let!(:vulnerability_for_second_duplicate) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:second_finding_duplicate) do
- create_finding!(
- id: 8765432,
- uuid: "5b714f58-1176-5b26-8fd5-e11dfcb031b5",
- vulnerability_id: vulnerability_for_second_duplicate.id,
- report_type: 0,
- location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner2.id,
- project_id: project.id
- )
- end
-
- let!(:vulnerability_for_third_duplicate) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:third_finding_duplicate) do
- create_finding!(
- id: 8832995,
- uuid: "cfe435fa-b25b-5199-a56d-7b007cc9e2d4",
- vulnerability_id: vulnerability_for_third_duplicate.id,
- report_type: 0,
- location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner3.id,
- project_id: project.id
- )
- end
-
- let!(:unrelated_finding) do
- create_finding!(
- id: 9999999,
- uuid: Gitlab::UUID.v5(SecureRandom.hex),
- vulnerability_id: nil,
- report_type: 1,
- location_fingerprint: 'random_location_fingerprint',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: unrelated_scanner.id,
- project_id: project.id
- )
- end
-
- subject { described_class.new.perform(first_finding_duplicate.id, unrelated_finding.id) }
-
- before do
- stub_const("#{described_class}::DELETE_BATCH_SIZE", 1)
- end
-
- it "removes entries which would result in duplicate UUIDv5" do
- expect(vulnerability_findings.count).to eq(4)
-
- expect { subject }.to change { vulnerability_findings.count }.from(4).to(2)
-
- expect(vulnerability_findings.pluck(:id)).to match_array([third_finding_duplicate.id, unrelated_finding.id])
- end
-
- it "removes vulnerabilites without findings" do
- expect(vulnerabilities.count).to eq(3)
-
- expect { subject }.to change { vulnerabilities.count }.from(3).to(1)
-
- expect(vulnerabilities.pluck(:id)).to match_array([vulnerability_for_third_duplicate.id])
- end
-
- private
-
- def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0)
- vulnerabilities.create!(
- project_id: project_id,
- author_id: author_id,
- title: title,
- severity: severity,
- confidence: confidence,
- report_type: report_type
- )
- end
-
- # rubocop:disable Metrics/ParameterLists
- def create_finding!(
- vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, id: nil,
- name: "test", severity: 7, confidence: 7, report_type: 0,
- project_fingerprint: '123qweasdzxc', location_fingerprint: 'test',
- metadata_version: 'test', raw_metadata: 'test', uuid: SecureRandom.uuid)
- params = {
- vulnerability_id: vulnerability_id,
- project_id: project_id,
- name: name,
- severity: severity,
- confidence: confidence,
- report_type: report_type,
- project_fingerprint: project_fingerprint,
- scanner_id: scanner_id,
- primary_identifier_id: vulnerability_identifier.id,
- location_fingerprint: location_fingerprint,
- metadata_version: metadata_version,
- raw_metadata: raw_metadata,
- uuid: uuid
- }
- params[:id] = id unless id.nil?
- vulnerability_findings.create!(params)
- end
- # rubocop:enable Metrics/ParameterLists
-
- def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.zone.now, confirmed_at: Time.zone.now)
- users.create!(
- name: name,
- email: email,
- username: name,
- projects_limit: 0,
- user_type: user_type,
- confirmed_at: confirmed_at
- )
- end
-end
diff --git a/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb b/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb
index 1844347f4a9..60ee61cf50a 100644
--- a/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb
+++ b/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::RemoveOccurrencePipelinesAndDuplicateVulnerabilitiesFindings, :migration,
- :suppress_gitlab_schemas_validate_connection, schema: 20220326161803 do
+ :suppress_gitlab_schemas_validate_connection, schema: 20220326161803 do
let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
let(:users) { table(:users) }
let(:user) { create_user! }
diff --git a/spec/lib/gitlab/background_migration/remove_project_group_link_with_missing_groups_spec.rb b/spec/lib/gitlab/background_migration/remove_project_group_link_with_missing_groups_spec.rb
new file mode 100644
index 00000000000..c45c402ab9d
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/remove_project_group_link_with_missing_groups_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::RemoveProjectGroupLinkWithMissingGroups, :migration,
+ feature_category: :subgroups, schema: 20230206172702 do
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+ let(:project_group_links) { table(:project_group_links) }
+
+ let!(:group) do
+ namespaces.create!(
+ name: 'Group0', type: 'Group', path: 'space0'
+ )
+ end
+
+ let!(:group_1) do
+ namespaces.create!(
+ name: 'Group1', type: 'Group', path: 'space1'
+ )
+ end
+
+ let!(:group_2) do
+ namespaces.create!(
+ name: 'Group2', type: 'Group', path: 'space2'
+ )
+ end
+
+ let!(:group_3) do
+ namespaces.create!(
+ name: 'Group3', type: 'Group', path: 'space3'
+ )
+ end
+
+ let!(:project_namespace_1) do
+ namespaces.create!(
+ name: 'project_1', path: 'project_1', type: 'Project'
+ )
+ end
+
+ let!(:project_namespace_2) do
+ namespaces.create!(
+ name: 'project_2', path: 'project_2', type: 'Project'
+ )
+ end
+
+ let!(:project_namespace_3) do
+ namespaces.create!(
+ name: 'project_3', path: 'project_3', type: 'Project'
+ )
+ end
+
+ let!(:project_1) do
+ projects.create!(
+ name: 'project_1', path: 'project_1', namespace_id: group.id, project_namespace_id: project_namespace_1.id
+ )
+ end
+
+ let!(:project_2) do
+ projects.create!(
+ name: 'project_2', path: 'project_2', namespace_id: group.id, project_namespace_id: project_namespace_2.id
+ )
+ end
+
+ let!(:project_3) do
+ projects.create!(
+ name: 'project_3', path: 'project_3', namespace_id: group.id, project_namespace_id: project_namespace_3.id
+ )
+ end
+
+ let!(:project_group_link_1) do
+ project_group_links.create!(
+ project_id: project_1.id, group_id: group_1.id, group_access: Gitlab::Access::DEVELOPER
+ )
+ end
+
+ let!(:project_group_link_2) do
+ project_group_links.create!(
+ project_id: project_2.id, group_id: group_2.id, group_access: Gitlab::Access::DEVELOPER
+ )
+ end
+
+ let!(:project_group_link_3) do
+ project_group_links.create!(
+ project_id: project_3.id, group_id: group_3.id, group_access: Gitlab::Access::DEVELOPER
+ )
+ end
+
+ let!(:project_group_link_4) do
+ project_group_links.create!(
+ project_id: project_3.id, group_id: group_2.id, group_access: Gitlab::Access::DEVELOPER
+ )
+ end
+
+ subject do
+ described_class.new(
+ start_id: project_group_link_1.id,
+ end_id: project_group_link_4.id,
+ batch_table: :project_group_links,
+ batch_column: :id,
+ sub_batch_size: 1,
+ pause_ms: 0,
+ connection: ApplicationRecord.connection
+ ).perform
+ end
+
+ it 'removes the `project_group_links` records whose associated group does not exist anymore' do
+ group_2.delete
+
+ # Schema is fixed to `20230206172702` on this spec.
+ # This expectation is needed to make sure that the orphaned records are indeed deleted via the migration
+ # and not via the foreign_key relationship introduced after `20230206172702`, in `20230207002330`
+ expect(project_group_links.count).to eq(4)
+
+ expect { subject }
+ .to change { project_group_links.count }.from(4).to(2)
+ .and change {
+ project_group_links.where(project_id: project_2.id, group_id: group_2.id).present?
+ }.from(true).to(false)
+ .and change {
+ project_group_links.where(project_id: project_3.id, group_id: group_2.id).present?
+ }.from(true).to(false)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/remove_self_managed_wiki_notes_spec.rb b/spec/lib/gitlab/background_migration/remove_self_managed_wiki_notes_spec.rb
index 81927100562..59d5d56ebe8 100644
--- a/spec/lib/gitlab/background_migration/remove_self_managed_wiki_notes_spec.rb
+++ b/spec/lib/gitlab/background_migration/remove_self_managed_wiki_notes_spec.rb
@@ -6,14 +6,15 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveSelfManagedWikiNotes, :migrati
let(:notes) { table(:notes) }
subject(:perform_migration) do
- described_class.new(start_id: 1,
- end_id: 30,
- batch_table: :notes,
- batch_column: :id,
- sub_batch_size: 2,
- pause_ms: 0,
- connection: ActiveRecord::Base.connection)
- .perform
+ described_class.new(
+ start_id: 1,
+ end_id: 30,
+ batch_table: :notes,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
end
it 'removes all wiki notes' do
diff --git a/spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb b/spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb
index 918df8f4442..32134b99e37 100644
--- a/spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb
+++ b/spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::RemoveVulnerabilityFindingLinks, :migration, schema: 20211104165220 do
+RSpec.describe Gitlab::BackgroundMigration::RemoveVulnerabilityFindingLinks, :migration, schema: 20211202041233 do
let(:vulnerability_findings) { table(:vulnerability_occurrences) }
let(:finding_links) { table(:vulnerability_finding_links) }
diff --git a/spec/lib/gitlab/background_migration/reset_too_many_tags_skipped_registry_imports_spec.rb b/spec/lib/gitlab/background_migration/reset_too_many_tags_skipped_registry_imports_spec.rb
index 3f59b0a24a3..afdd855c5a8 100644
--- a/spec/lib/gitlab/background_migration/reset_too_many_tags_skipped_registry_imports_spec.rb
+++ b/spec/lib/gitlab/background_migration/reset_too_many_tags_skipped_registry_imports_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::ResetTooManyTagsSkippedRegistryImports, :migration,
- :aggregate_failures,
- schema: 20220502173045 do
+ :aggregate_failures,
+ schema: 20220502173045 do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:container_repositories) { table(:container_repositories) }
@@ -15,46 +15,54 @@ RSpec.describe Gitlab::BackgroundMigration::ResetTooManyTagsSkippedRegistryImpor
let!(:project) { projects.create!(id: 1, project_namespace_id: 1, namespace_id: 1, path: 'bar', name: 'bar') }
let!(:container_repository1) do
- container_repositories.create!(id: 1,
- project_id: 1,
- name: 'a',
- migration_state: 'import_skipped',
- migration_skipped_at: Time.zone.now,
- migration_skipped_reason: 2,
- migration_pre_import_started_at: Time.zone.now,
- migration_pre_import_done_at: Time.zone.now,
- migration_import_started_at: Time.zone.now,
- migration_import_done_at: Time.zone.now,
- migration_aborted_at: Time.zone.now,
- migration_retries_count: 2,
- migration_aborted_in_state: 'importing')
+ container_repositories.create!(
+ id: 1,
+ project_id: 1,
+ name: 'a',
+ migration_state: 'import_skipped',
+ migration_skipped_at: Time.zone.now,
+ migration_skipped_reason: 2,
+ migration_pre_import_started_at: Time.zone.now,
+ migration_pre_import_done_at: Time.zone.now,
+ migration_import_started_at: Time.zone.now,
+ migration_import_done_at: Time.zone.now,
+ migration_aborted_at: Time.zone.now,
+ migration_retries_count: 2,
+ migration_aborted_in_state: 'importing'
+ )
end
let!(:container_repository2) do
- container_repositories.create!(id: 2,
- project_id: 1,
- name: 'b',
- migration_state: 'import_skipped',
- migration_skipped_at: Time.zone.now,
- migration_skipped_reason: 2)
+ container_repositories.create!(
+ id: 2,
+ project_id: 1,
+ name: 'b',
+ migration_state: 'import_skipped',
+ migration_skipped_at: Time.zone.now,
+ migration_skipped_reason: 2
+ )
end
let!(:container_repository3) do
- container_repositories.create!(id: 3,
- project_id: 1,
- name: 'c',
- migration_state: 'import_skipped',
- migration_skipped_at: Time.zone.now,
- migration_skipped_reason: 1)
+ container_repositories.create!(
+ id: 3,
+ project_id: 1,
+ name: 'c',
+ migration_state: 'import_skipped',
+ migration_skipped_at: Time.zone.now,
+ migration_skipped_reason: 1
+ )
end
# This is an unlikely state, but included here to test the edge case
let!(:container_repository4) do
- container_repositories.create!(id: 4,
- project_id: 1,
- name: 'd',
- migration_state: 'default',
- migration_skipped_reason: 2)
+ container_repositories.create!(
+ id: 4,
+ project_id: 1,
+ name: 'd',
+ migration_state: 'default',
+ migration_skipped_reason: 2
+ )
end
describe '#up' do
diff --git a/spec/lib/gitlab/background_migration/set_correct_vulnerability_state_spec.rb b/spec/lib/gitlab/background_migration/set_correct_vulnerability_state_spec.rb
index 2372ce21c4c..df1ee494987 100644
--- a/spec/lib/gitlab/background_migration/set_correct_vulnerability_state_spec.rb
+++ b/spec/lib/gitlab/background_migration/set_correct_vulnerability_state_spec.rb
@@ -35,13 +35,15 @@ RSpec.describe Gitlab::BackgroundMigration::SetCorrectVulnerabilityState do
let(:dismissed_state) { 2 }
let(:migration_job) do
- described_class.new(start_id: vulnerability_with_dismissed_at.id,
- end_id: vulnerability_without_dismissed_at.id,
- batch_table: :vulnerabilities,
- batch_column: :id,
- sub_batch_size: 1,
- pause_ms: 0,
- connection: ActiveRecord::Base.connection)
+ described_class.new(
+ start_id: vulnerability_with_dismissed_at.id,
+ end_id: vulnerability_without_dismissed_at.id,
+ batch_table: :vulnerabilities,
+ batch_column: :id,
+ sub_batch_size: 1,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ )
end
describe '#filter_batch' do
diff --git a/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb b/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb
index e9f73672144..5109c3ec0c2 100644
--- a/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb
+++ b/spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb
@@ -3,21 +3,22 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::SetLegacyOpenSourceLicenseAvailableForNonPublicProjects,
- :migration,
- schema: 20220722110026 do
+ :migration,
+ schema: 20220722110026 do
let(:namespaces_table) { table(:namespaces) }
let(:projects_table) { table(:projects) }
let(:project_settings_table) { table(:project_settings) }
subject(:perform_migration) do
- described_class.new(start_id: projects_table.minimum(:id),
- end_id: projects_table.maximum(:id),
- batch_table: :projects,
- batch_column: :id,
- sub_batch_size: 2,
- pause_ms: 0,
- connection: ActiveRecord::Base.connection)
- .perform
+ described_class.new(
+ start_id: projects_table.minimum(:id),
+ end_id: projects_table.maximum(:id),
+ batch_table: :projects,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
end
it 'sets `legacy_open_source_license_available` attribute to false for non-public projects', :aggregate_failures do
@@ -37,11 +38,13 @@ RSpec.describe Gitlab::BackgroundMigration::SetLegacyOpenSourceLicenseAvailableF
def create_legacy_license_project(path, visibility_level:)
namespace = namespaces_table.create!(name: "namespace-#{path}", path: "namespace-#{path}")
project_namespace = namespaces_table.create!(name: "project-namespace-#{path}", path: path, type: 'Project')
- project = projects_table.create!(name: path,
- path: path,
- namespace_id: namespace.id,
- project_namespace_id: project_namespace.id,
- visibility_level: visibility_level)
+ project = projects_table.create!(
+ name: path,
+ path: path,
+ namespace_id: namespace.id,
+ project_namespace_id: project_namespace.id,
+ visibility_level: visibility_level
+ )
project_settings_table.create!(project_id: project.id, legacy_open_source_license_available: true)
project
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
deleted file mode 100644
index 841a7f306d7..00000000000
--- a/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::StealMigrateMergeRequestDiffCommitUsers, schema: 20211012134316 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/background_migration/update_delayed_project_removal_to_null_for_user_namespaces_spec.rb b/spec/lib/gitlab/background_migration/update_delayed_project_removal_to_null_for_user_namespaces_spec.rb
index 980a7771f4c..0579a299c74 100644
--- a/spec/lib/gitlab/background_migration/update_delayed_project_removal_to_null_for_user_namespaces_spec.rb
+++ b/spec/lib/gitlab/background_migration/update_delayed_project_removal_to_null_for_user_namespaces_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::UpdateDelayedProjectRemovalToNullForUserNamespaces,
- :migration do
+ :migration do
let(:namespaces_table) { table(:namespaces) }
let(:namespace_settings_table) { table(:namespace_settings) }
diff --git a/spec/lib/gitlab/background_migration/update_jira_tracker_data_deployment_type_based_on_url_spec.rb b/spec/lib/gitlab/background_migration/update_jira_tracker_data_deployment_type_based_on_url_spec.rb
index c090c1df424..75fe5699986 100644
--- a/spec/lib/gitlab/background_migration/update_jira_tracker_data_deployment_type_based_on_url_spec.rb
+++ b/spec/lib/gitlab/background_migration/update_jira_tracker_data_deployment_type_based_on_url_spec.rb
@@ -13,10 +13,12 @@ RSpec.describe Gitlab::BackgroundMigration::UpdateJiraTrackerDataDeploymentTypeB
let(:sub_batch_size) { 1 }
let(:pause_ms) { 0 }
let(:migration) do
- described_class.new(start_id: 1, end_id: 10,
- batch_table: table_name, batch_column: batch_column,
- sub_batch_size: sub_batch_size, pause_ms: pause_ms,
- connection: ApplicationRecord.connection)
+ described_class.new(
+ start_id: 1, end_id: 10,
+ batch_table: table_name, batch_column: batch_column,
+ sub_batch_size: sub_batch_size, pause_ms: pause_ms,
+ connection: ApplicationRecord.connection
+ )
end
subject(:perform_migration) do
diff --git a/spec/lib/gitlab/background_migration/update_timelogs_project_id_spec.rb b/spec/lib/gitlab/background_migration/update_timelogs_project_id_spec.rb
deleted file mode 100644
index b8c3bf8f3ac..00000000000
--- a/spec/lib/gitlab/background_migration/update_timelogs_project_id_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::UpdateTimelogsProjectId, schema: 20210826171758 do
- let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') }
- let!(:project1) { table(:projects).create!(namespace_id: namespace.id) }
- let!(:project2) { table(:projects).create!(namespace_id: namespace.id) }
- let!(:issue1) { table(:issues).create!(project_id: project1.id) }
- let!(:issue2) { table(:issues).create!(project_id: project2.id) }
- let!(:merge_request1) { table(:merge_requests).create!(target_project_id: project1.id, source_branch: 'master', target_branch: 'feature') }
- let!(:merge_request2) { table(:merge_requests).create!(target_project_id: project2.id, source_branch: 'master', target_branch: 'feature') }
- let!(:timelog1) { table(:timelogs).create!(issue_id: issue1.id, time_spent: 60) }
- let!(:timelog2) { table(:timelogs).create!(issue_id: issue1.id, time_spent: 60) }
- let!(:timelog3) { table(:timelogs).create!(issue_id: issue2.id, time_spent: 60) }
- let!(:timelog4) { table(:timelogs).create!(merge_request_id: merge_request1.id, time_spent: 600) }
- let!(:timelog5) { table(:timelogs).create!(merge_request_id: merge_request1.id, time_spent: 600) }
- let!(:timelog6) { table(:timelogs).create!(merge_request_id: merge_request2.id, time_spent: 600) }
- let!(:timelog7) { table(:timelogs).create!(issue_id: issue2.id, time_spent: 60, project_id: project1.id) }
- let!(:timelog8) { table(:timelogs).create!(merge_request_id: merge_request2.id, time_spent: 600, project_id: project1.id) }
-
- describe '#perform' do
- context 'when timelogs belong to issues' do
- it 'sets correct project_id' do
- subject.perform(timelog1.id, timelog3.id)
-
- expect(timelog1.reload.project_id).to eq(issue1.project_id)
- expect(timelog2.reload.project_id).to eq(issue1.project_id)
- expect(timelog3.reload.project_id).to eq(issue2.project_id)
- end
- end
-
- context 'when timelogs belong to merge requests' do
- it 'sets correct project ids' do
- subject.perform(timelog4.id, timelog6.id)
-
- expect(timelog4.reload.project_id).to eq(merge_request1.target_project_id)
- expect(timelog5.reload.project_id).to eq(merge_request1.target_project_id)
- expect(timelog6.reload.project_id).to eq(merge_request2.target_project_id)
- end
- end
-
- context 'when timelogs already belong to projects' do
- it 'does not update the project id' do
- subject.perform(timelog7.id, timelog8.id)
-
- expect(timelog7.reload.project_id).to eq(project1.id)
- expect(timelog8.reload.project_id).to eq(project1.id)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/background_migration/update_users_where_two_factor_auth_required_from_group_spec.rb b/spec/lib/gitlab/background_migration/update_users_where_two_factor_auth_required_from_group_spec.rb
deleted file mode 100644
index f16ae489b78..00000000000
--- a/spec/lib/gitlab/background_migration/update_users_where_two_factor_auth_required_from_group_spec.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::UpdateUsersWhereTwoFactorAuthRequiredFromGroup, :migration, schema: 20210826171758 do
- include MigrationHelpers::NamespacesHelpers
-
- let(:group_with_2fa_parent) { create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE, require_two_factor_authentication: true) }
- let(:group_with_2fa_child) { create_namespace('child', Gitlab::VisibilityLevel::PRIVATE, parent_id: group_with_2fa_parent.id) }
- let(:members_table) { table(:members) }
- let(:users_table) { table(:users) }
-
- subject { described_class.new }
-
- describe '#perform' do
- context 'with group members' do
- let(:user_1) { create_user('user@example.com') }
- let!(:member) { create_group_member(user_1, group_with_2fa_parent) }
- let!(:user_without_group) { create_user('user_without@example.com') }
- let(:user_other) { create_user('user_other@example.com') }
- let!(:member_other) { create_group_member(user_other, group_with_2fa_parent) }
-
- it 'updates user when user should be required to establish two factor authentication' do
- subject.perform(user_1.id, user_without_group.id)
-
- expect(user_1.reload.require_two_factor_authentication_from_group).to eq(true)
- end
-
- it 'does not update user who is not in current batch' do
- subject.perform(user_1.id, user_without_group.id)
-
- expect(user_other.reload.require_two_factor_authentication_from_group).to eq(false)
- end
-
- it 'updates all users in current batch' do
- subject.perform(user_1.id, user_other.id)
-
- expect(user_other.reload.require_two_factor_authentication_from_group).to eq(true)
- end
-
- it 'updates user when user is member of group in which parent group requires two factor authentication' do
- member.destroy!
-
- subgroup = create_namespace('subgroup', Gitlab::VisibilityLevel::PRIVATE, require_two_factor_authentication: false, parent_id: group_with_2fa_child.id)
- create_group_member(user_1, subgroup)
-
- subject.perform(user_1.id, user_other.id)
-
- expect(user_1.reload.require_two_factor_authentication_from_group).to eq(true)
- end
-
- it 'updates user when user is member of a group and the subgroup requires two factor authentication' do
- member.destroy!
-
- parent = create_namespace('other_parent', Gitlab::VisibilityLevel::PRIVATE, require_two_factor_authentication: false)
- create_namespace('other_subgroup', Gitlab::VisibilityLevel::PRIVATE, require_two_factor_authentication: true, parent_id: parent.id)
- create_group_member(user_1, parent)
-
- subject.perform(user_1.id, user_other.id)
-
- expect(user_1.reload.require_two_factor_authentication_from_group).to eq(true)
- end
-
- it 'does not update user when not a member of a group that requires two factor authentication' do
- member_other.destroy!
-
- other_group = create_namespace('other_group', Gitlab::VisibilityLevel::PRIVATE, require_two_factor_authentication: false)
- create_group_member(user_other, other_group)
-
- subject.perform(user_1.id, user_other.id)
-
- expect(user_other.reload.require_two_factor_authentication_from_group).to eq(false)
- end
- end
- end
-
- def create_user(email, require_2fa: false)
- users_table.create!(email: email, projects_limit: 10, require_two_factor_authentication_from_group: require_2fa)
- end
-
- def create_group_member(user, group)
- members_table.create!(user_id: user.id, source_id: group.id, access_level: GroupMember::MAINTAINER, source_type: "Namespace", type: "GroupMember", notification_level: 3)
- end
-end
diff --git a/spec/lib/gitlab/background_task_spec.rb b/spec/lib/gitlab/background_task_spec.rb
index 102556b6b2f..da92fc9e765 100644
--- a/spec/lib/gitlab/background_task_spec.rb
+++ b/spec/lib/gitlab/background_task_spec.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
# We need to capture task state from a closure, which requires instance variables.
# rubocop: disable RSpec/InstanceVariable
-RSpec.describe Gitlab::BackgroundTask do
+RSpec.describe Gitlab::BackgroundTask, feature_category: :build do
let(:options) { {} }
let(:task) do
proc do
diff --git a/spec/lib/gitlab/bare_repository_import/importer_spec.rb b/spec/lib/gitlab/bare_repository_import/importer_spec.rb
deleted file mode 100644
index 3a885d70eb4..00000000000
--- a/spec/lib/gitlab/bare_repository_import/importer_spec.rb
+++ /dev/null
@@ -1,197 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BareRepositoryImport::Importer do
- let!(:admin) { create(:admin) }
- let!(:base_dir) { Dir.mktmpdir + '/' }
- let(:bare_repository) { Gitlab::BareRepositoryImport::Repository.new(base_dir, File.join(base_dir, "#{project_path}.git")) }
- let(:gitlab_shell) { Gitlab::Shell.new }
- let(:source_project) { TestEnv.factory_repo_bundle_path }
-
- subject(:importer) { described_class.new(admin, bare_repository) }
-
- before do
- allow(described_class).to receive(:log)
- end
-
- after do
- FileUtils.rm_rf(base_dir)
- end
-
- shared_examples 'importing a repository' do
- describe '.execute' do
- it 'creates a project for a repository in storage' do
- FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.git"))
- fake_importer = double
-
- expect(described_class).to receive(:new).and_return(fake_importer)
- expect(fake_importer).to receive(:create_project_if_needed)
-
- described_class.execute(base_dir)
- end
-
- it 'skips wiki repos' do
- repo_dir = File.join(base_dir, 'the-group', 'the-project.wiki.git')
- FileUtils.mkdir_p(File.join(repo_dir))
-
- expect(described_class).to receive(:log).with(" * Skipping repo #{repo_dir}")
- expect(described_class).not_to receive(:new)
-
- described_class.execute(base_dir)
- end
-
- context 'without admin users' do
- let(:admin) { nil }
-
- it 'raises an error' do
- expect { described_class.execute(base_dir) }.to raise_error(Gitlab::BareRepositoryImport::Importer::NoAdminError)
- end
- end
- end
-
- describe '#create_project_if_needed' do
- it 'starts an import for a project that did not exist' do
- expect(importer).to receive(:create_project)
-
- importer.create_project_if_needed
- end
-
- it 'skips importing when the project already exists' do
- project = create(:project, path: 'a-project', namespace: existing_group)
-
- expect(importer).not_to receive(:create_project)
- expect(importer).to receive(:log).with(" * #{project.name} (#{project_path}) exists")
-
- importer.create_project_if_needed
- end
-
- it 'creates a project with the correct path in the database' do
- importer.create_project_if_needed
-
- expect(Project.find_by_full_path(project_path)).not_to be_nil
- end
-
- it 'does not schedule an import' do
- expect_next_instance_of(Project) do |instance|
- expect(instance).not_to receive(:import_schedule)
- end
-
- importer.create_project_if_needed
- end
-
- it 'creates the Git repo on disk' do
- prepare_repository("#{project_path}.git", source_project)
-
- importer.create_project_if_needed
-
- project = Project.find_by_full_path(project_path)
- repo_path = "#{project.disk_path}.git"
-
- expect(gitlab_shell.repository_exists?(project.repository_storage, repo_path)).to be(true)
- end
-
- context 'hashed storage enabled' do
- it 'creates a project with the correct path in the database' do
- stub_application_setting(hashed_storage_enabled: true)
-
- importer.create_project_if_needed
-
- expect(Project.find_by_full_path(project_path)).not_to be_nil
- end
- end
- end
- end
-
- context 'with subgroups' do
- let(:project_path) { 'a-group/a-sub-group/a-project' }
-
- let(:existing_group) do
- group = create(:group, path: 'a-group')
- create(:group, path: 'a-sub-group', parent: group)
- end
-
- it_behaves_like 'importing a repository'
- end
-
- context 'without subgroups' do
- let(:project_path) { 'a-group/a-project' }
- let(:existing_group) { create(:group, path: 'a-group') }
-
- it_behaves_like 'importing a repository'
- end
-
- context 'without groups' do
- let(:project_path) { 'a-project' }
-
- it 'starts an import for a project that did not exist' do
- expect(importer).to receive(:create_project)
-
- importer.create_project_if_needed
- end
-
- it 'creates a project with the correct path in the database' do
- importer.create_project_if_needed
-
- expect(Project.find_by_full_path("#{admin.full_path}/#{project_path}")).not_to be_nil
- end
-
- it 'creates the Git repo in disk' do
- prepare_repository("#{project_path}.git", source_project)
-
- importer.create_project_if_needed
-
- project = Project.find_by_full_path("#{admin.full_path}/#{project_path}")
-
- expect(gitlab_shell.repository_exists?(project.repository_storage, project.disk_path + '.git')).to be(true)
- expect(gitlab_shell.repository_exists?(project.repository_storage, project.disk_path + '.wiki.git')).to be(true)
- end
-
- context 'with a repository already on disk' do
- # This is a quick way to get a valid repository instead of copying an
- # existing one. Since it's not persisted, the importer will try to
- # create the project.
- let(:project) { build(:project, :legacy_storage, :repository) }
- let(:project_path) { project.full_path }
-
- it 'moves an existing project to the correct path' do
- original_commit_count = project.repository.commit_count
-
- expect(importer).to receive(:create_project).and_call_original
-
- new_project = importer.create_project_if_needed
-
- expect(new_project.repository.commit_count).to eq(original_commit_count)
- end
- end
- end
-
- context 'with Wiki' do
- let(:project_path) { 'a-group/a-project' }
- let(:existing_group) { create(:group, path: 'a-group') }
-
- it_behaves_like 'importing a repository'
-
- it 'creates the Wiki git repo in disk' do
- prepare_repository("#{project_path}.git", source_project)
- prepare_repository("#{project_path}.wiki.git", source_project)
-
- expect(Projects::CreateService).to receive(:new).with(admin, hash_including(skip_wiki: true,
- import_type: 'bare_repository')).and_call_original
-
- importer.create_project_if_needed
-
- project = Project.find_by_full_path(project_path)
-
- expect(gitlab_shell.repository_exists?(project.repository_storage, project.disk_path + '.wiki.git')).to be(true)
- end
- end
-
- def prepare_repository(project_path, source_project)
- repo_path = File.join(base_dir, project_path)
-
- cmd = %W(#{Gitlab.config.git.bin_path} clone --bare #{source_project} #{repo_path})
-
- system(git_env, *cmd, chdir: base_dir, out: '/dev/null', err: '/dev/null')
- end
-end
diff --git a/spec/lib/gitlab/bare_repository_import/repository_spec.rb b/spec/lib/gitlab/bare_repository_import/repository_spec.rb
deleted file mode 100644
index a9778e0e8a7..00000000000
--- a/spec/lib/gitlab/bare_repository_import/repository_spec.rb
+++ /dev/null
@@ -1,123 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ::Gitlab::BareRepositoryImport::Repository do
- context 'legacy storage' do
- subject { described_class.new('/full/path/', '/full/path/to/repo.git') }
-
- it 'stores the repo path' do
- expect(subject.repo_path).to eq('/full/path/to/repo.git')
- end
-
- it 'stores the group path' do
- expect(subject.group_path).to eq('to')
- end
-
- it 'stores the project name' do
- expect(subject.project_name).to eq('repo')
- end
-
- it 'stores the wiki path' do
- expect(subject.wiki_path).to eq('/full/path/to/repo.wiki.git')
- end
-
- describe '#processable?' do
- it 'returns false if it is a wiki' do
- subject = described_class.new('/full/path/', '/full/path/to/a/b/my.wiki.git')
-
- expect(subject).not_to be_processable
- end
-
- it 'returns true if group path is missing' do
- subject = described_class.new('/full/path/', '/full/path/repo.git')
-
- expect(subject).to be_processable
- end
-
- it 'returns true when group path and project name are present' do
- expect(subject).to be_processable
- end
- end
-
- describe '#project_full_path' do
- it 'returns the project full path with trailing slash in the root path' do
- expect(subject.project_full_path).to eq('to/repo')
- end
-
- it 'returns the project full path with no trailing slash in the root path' do
- subject = described_class.new('/full/path', '/full/path/to/repo.git')
-
- expect(subject.project_full_path).to eq('to/repo')
- end
- end
- end
-
- context 'hashed storage' do
- let(:hashed_path) { "@hashed/6b/86/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" }
- let(:root_path) { Gitlab::GitalyClient::StorageSettings.allow_disk_access { TestEnv.repos_path } }
- let(:repo_path) { File.join(root_path, "#{hashed_path}.git") }
- let(:wiki_path) { File.join(root_path, "#{hashed_path}.wiki.git") }
- let(:raw_repository) { Gitlab::Git::Repository.new('default', "#{hashed_path}.git", nil, nil) }
- let(:full_path) { 'to/repo' }
-
- before do
- raw_repository.create_repository
- raw_repository.set_full_path(full_path: full_path) if full_path
- end
-
- after do
- raw_repository.remove
- end
-
- subject { described_class.new(root_path, repo_path) }
-
- it 'stores the repo path' do
- expect(subject.repo_path).to eq(repo_path)
- end
-
- it 'stores the wiki path' do
- expect(subject.wiki_path).to eq(wiki_path)
- end
-
- it 'reads the group path from .git/config' do
- expect(subject.group_path).to eq('to')
- end
-
- it 'reads the project name from .git/config' do
- expect(subject.project_name).to eq('repo')
- end
-
- describe '#processable?' do
- it 'returns false if it is a wiki' do
- subject = described_class.new(root_path, wiki_path)
-
- expect(subject).not_to be_processable
- end
-
- it 'returns true when group path and project name are present' do
- expect(subject).to be_processable
- end
-
- context 'group and project name are missing' do
- let(:full_path) { nil }
-
- it 'returns false' do
- expect(subject).not_to be_processable
- end
- end
- end
-
- describe '#project_full_path' do
- it 'returns the project full path with trailing slash in the root path' do
- expect(subject.project_full_path).to eq('to/repo')
- end
-
- it 'returns the project full path with no trailing slash in the root path' do
- subject = described_class.new(root_path[0...-1], repo_path)
-
- expect(subject.project_full_path).to eq('to/repo')
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb
index 1526a1a9f2d..48ceda9e8d8 100644
--- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb
+++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb
@@ -358,7 +358,7 @@ RSpec.describe Gitlab::BitbucketImport::Importer, feature_category: :integration
describe 'issue import' do
it 'allocates internal ids' do
- expect(Issue).to receive(:track_project_iid!).with(project, 6)
+ expect(Issue).to receive(:track_namespace_iid!).with(project.project_namespace, 6)
importer.execute
end
diff --git a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb
index 236e04a041b..7ecdc5d25ea 100644
--- a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb
+++ b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb
@@ -27,8 +27,8 @@ RSpec.describe Gitlab::BitbucketImport::ProjectCreator do
end
it 'creates project' do
- expect_next_instance_of(Project) do |project|
- expect(project).to receive(:add_import_job)
+ allow_next_instance_of(Project) do |project|
+ allow(project).to receive(:add_import_job)
end
project_creator = described_class.new(repo, 'vim', namespace, user, access_params)
diff --git a/spec/lib/gitlab/bullet/exclusions_spec.rb b/spec/lib/gitlab/bullet/exclusions_spec.rb
index 325b0167f58..ccedfee28c7 100644
--- a/spec/lib/gitlab/bullet/exclusions_spec.rb
+++ b/spec/lib/gitlab/bullet/exclusions_spec.rb
@@ -3,7 +3,7 @@
require 'fast_spec_helper'
require 'tempfile'
-RSpec.describe Gitlab::Bullet::Exclusions do
+RSpec.describe Gitlab::Bullet::Exclusions, feature_category: :application_performance do
let(:config_file) do
file = Tempfile.new('bullet.yml')
File.basename(file)
@@ -78,6 +78,19 @@ RSpec.describe Gitlab::Bullet::Exclusions do
expect(described_class.new('_some_bogus_file_').execute).to match([])
end
end
+
+ context 'with a Symbol' do
+ let(:exclude) { [] }
+ let(:config) { { exclusions: { abc: { exclude: exclude } } } }
+
+ before do
+ File.write(config_file, YAML.dump(config))
+ end
+
+ it 'raises an exception' do
+ expect { executor }.to raise_error(Psych::DisallowedClass)
+ end
+ end
end
describe '#validate_paths!' do
diff --git a/spec/lib/gitlab/cache/client_spec.rb b/spec/lib/gitlab/cache/client_spec.rb
new file mode 100644
index 00000000000..638fed1a905
--- /dev/null
+++ b/spec/lib/gitlab/cache/client_spec.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Cache::Client, feature_category: :source_code_management do
+ subject(:client) { described_class.new(metadata, backend: backend) }
+
+ let(:backend) { Rails.cache }
+ let(:metadata) do
+ Gitlab::Cache::Metadata.new(
+ cache_identifier: cache_identifier,
+ feature_category: feature_category,
+ backing_resource: backing_resource
+ )
+ end
+
+ let(:cache_identifier) { 'MyClass#cache' }
+ let(:feature_category) { :source_code_management }
+ let(:backing_resource) { :cpu }
+
+ let(:metadata_mock) do
+ Gitlab::Cache::Metadata.new(
+ cache_identifier: cache_identifier,
+ feature_category: feature_category
+ )
+ end
+
+ let(:metrics_mock) { Gitlab::Cache::Metrics.new(metadata_mock) }
+
+ describe '.build_with_metadata' do
+ it 'builds a cache client with metrics support' do
+ attributes = {
+ cache_identifier: cache_identifier,
+ feature_category: feature_category,
+ backing_resource: backing_resource
+ }
+
+ instance = described_class.build_with_metadata(**attributes)
+
+ expect(instance).to be_a(described_class)
+ expect(instance.metadata).to have_attributes(**attributes)
+ end
+ end
+
+ describe 'Methods', :use_clean_rails_memory_store_caching do
+ let(:expected_key) { 'key' }
+
+ before do
+ allow(Gitlab::Cache::Metrics).to receive(:new).and_return(metrics_mock)
+ end
+
+ describe '#read' do
+ context 'when key does not exist' do
+ it 'returns nil' do
+ expect(client.read('key')).to be_nil
+ end
+
+ it 'increments cache miss' do
+ expect(metrics_mock).to receive(:increment_cache_miss)
+
+ client.read('key')
+ end
+ end
+
+ context 'when key exists' do
+ before do
+ backend.write(expected_key, 'value')
+ end
+
+ it 'returns key value' do
+ expect(client.read('key')).to eq('value')
+ end
+
+ it 'increments cache hit' do
+ expect(metrics_mock).to receive(:increment_cache_hit)
+
+ client.read('key')
+ end
+ end
+ end
+
+ describe '#write' do
+ it 'calls backend "#write" method with the expected key' do
+ expect(backend).to receive(:write).with(expected_key, 'value')
+
+ client.write('key', 'value')
+ end
+ end
+
+ describe '#exist?' do
+ it 'calls backend "#exist?" method with the expected key' do
+ expect(backend).to receive(:exist?).with(expected_key)
+
+ client.exist?('key')
+ end
+ end
+
+ describe '#delete' do
+ it 'calls backend "#delete" method with the expected key' do
+ expect(backend).to receive(:delete).with(expected_key)
+
+ client.delete('key')
+ end
+ end
+
+ # rubocop:disable Style/RedundantFetchBlock
+ describe '#fetch' do
+ it 'creates key in the specific format' do
+ client.fetch('key') { 'value' }
+
+ expect(backend.fetch(expected_key)).to eq('value')
+ end
+
+ it 'yields the block once' do
+ expect { |b| client.fetch('key', &b) }.to yield_control.once
+ end
+
+ context 'when key already exists' do
+ before do
+ backend.write(expected_key, 'value')
+ end
+
+ it 'does not redefine the value' do
+ expect(client.fetch('key') { 'new-value' }).to eq('value')
+ end
+
+ it 'increments a cache hit' do
+ expect(metrics_mock).to receive(:increment_cache_hit)
+
+ client.fetch('key')
+ end
+
+ it 'does not measure the cache generation time' do
+ expect(metrics_mock).not_to receive(:observe_cache_generation)
+
+ client.fetch('key') { 'new-value' }
+ end
+ end
+
+ context 'when key does not exist' do
+ it 'caches the key' do
+ expect(client.fetch('key') { 'value' }).to eq('value')
+
+ expect(client.fetch('key')).to eq('value')
+ end
+
+ it 'increments a cache miss' do
+ expect(metrics_mock).to receive(:increment_cache_miss)
+
+ client.fetch('key')
+ end
+
+ it 'measures the cache generation time' do
+ expect(metrics_mock).to receive(:observe_cache_generation)
+
+ client.fetch('key') { 'value' }
+ end
+ end
+ end
+ end
+ # rubocop:enable Style/RedundantFetchBlock
+end
diff --git a/spec/lib/gitlab/cache/metadata_spec.rb b/spec/lib/gitlab/cache/metadata_spec.rb
index 2e8af7a9c44..d2b79fb8b08 100644
--- a/spec/lib/gitlab/cache/metadata_spec.rb
+++ b/spec/lib/gitlab/cache/metadata_spec.rb
@@ -5,24 +5,18 @@ require 'spec_helper'
RSpec.describe Gitlab::Cache::Metadata, feature_category: :source_code_management do
subject(:attributes) do
described_class.new(
- caller_id: caller_id,
cache_identifier: cache_identifier,
feature_category: feature_category,
backing_resource: backing_resource
)
end
- let(:caller_id) { 'caller-id' }
let(:cache_identifier) { 'ApplicationController#show' }
let(:feature_category) { :source_code_management }
let(:backing_resource) { :unknown }
describe '#initialize' do
context 'when optional arguments are not set' do
- before do
- Gitlab::ApplicationContext.push(caller_id: 'context-id')
- end
-
it 'sets default value for them' do
attributes = described_class.new(
cache_identifier: cache_identifier,
@@ -30,7 +24,6 @@ RSpec.describe Gitlab::Cache::Metadata, feature_category: :source_code_managemen
)
expect(attributes.backing_resource).to eq(:unknown)
- expect(attributes.caller_id).to eq('context-id')
end
end
@@ -68,12 +61,6 @@ RSpec.describe Gitlab::Cache::Metadata, feature_category: :source_code_managemen
end
end
- describe '#caller_id' do
- subject { attributes.caller_id }
-
- it { is_expected.to eq caller_id }
- end
-
describe '#cache_identifier' do
subject { attributes.cache_identifier }
diff --git a/spec/lib/gitlab/cache/metrics_spec.rb b/spec/lib/gitlab/cache/metrics_spec.rb
index 24b274f4209..76ec0dbfa0b 100644
--- a/spec/lib/gitlab/cache/metrics_spec.rb
+++ b/spec/lib/gitlab/cache/metrics_spec.rb
@@ -7,14 +7,12 @@ RSpec.describe Gitlab::Cache::Metrics do
let(:metadata) do
Gitlab::Cache::Metadata.new(
- caller_id: caller_id,
cache_identifier: cache_identifier,
feature_category: feature_category,
backing_resource: backing_resource
)
end
- let(:caller_id) { 'caller-id' }
let(:cache_identifier) { 'ApplicationController#show' }
let(:feature_category) { :source_code_management }
let(:backing_resource) { :unknown }
@@ -37,7 +35,6 @@ RSpec.describe Gitlab::Cache::Metrics do
.to receive(:increment)
.with(
{
- caller_id: caller_id,
cache_identifier: cache_identifier,
feature_category: feature_category,
backing_resource: backing_resource,
@@ -57,7 +54,6 @@ RSpec.describe Gitlab::Cache::Metrics do
.to receive(:increment)
.with(
{
- caller_id: caller_id,
cache_identifier: cache_identifier,
feature_category: feature_category,
backing_resource: backing_resource,
@@ -86,7 +82,6 @@ RSpec.describe Gitlab::Cache::Metrics do
:redis_cache_generation_duration_seconds,
'Duration of Redis cache generation',
{
- caller_id: caller_id,
cache_identifier: cache_identifier,
feature_category: feature_category,
backing_resource: backing_resource
diff --git a/spec/lib/gitlab/changes_list_spec.rb b/spec/lib/gitlab/changes_list_spec.rb
index 762a121340e..77deffe4b37 100644
--- a/spec/lib/gitlab/changes_list_spec.rb
+++ b/spec/lib/gitlab/changes_list_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::ChangesList do
+RSpec.describe Gitlab::ChangesList, feature_category: :source_code_management do
let(:valid_changes_string) { "\n000000 570e7b2 refs/heads/my_branch\nd14d6c 6fd24d refs/heads/master" }
let(:invalid_changes) { 1 }
diff --git a/spec/lib/gitlab/chat/responder_spec.rb b/spec/lib/gitlab/chat/responder_spec.rb
index a9d290cb87c..15ca3427ae8 100644
--- a/spec/lib/gitlab/chat/responder_spec.rb
+++ b/spec/lib/gitlab/chat/responder_spec.rb
@@ -4,68 +4,32 @@ require 'spec_helper'
RSpec.describe Gitlab::Chat::Responder, feature_category: :integrations do
describe '.responder_for' do
- context 'when the feature flag is disabled' do
- before do
- stub_feature_flags(use_response_url_for_chat_responder: false)
- end
-
- context 'using a regular build' do
- it 'returns nil' do
- build = create(:ci_build)
+ context 'using a regular build' do
+ it 'returns nil' do
+ build = create(:ci_build)
- expect(described_class.responder_for(build)).to be_nil
- end
- end
-
- context 'using a chat build' do
- it 'returns the responder for the build' do
- pipeline = create(:ci_pipeline)
- build = create(:ci_build, pipeline: pipeline)
- integration = double(:integration, chat_responder: Gitlab::Chat::Responder::Slack)
- chat_name = double(:chat_name, integration: integration)
- chat_data = double(:chat_data, chat_name: chat_name)
-
- allow(pipeline)
- .to receive(:chat_data)
- .and_return(chat_data)
-
- expect(described_class.responder_for(build))
- .to be_an_instance_of(Gitlab::Chat::Responder::Slack)
- end
+ expect(described_class.responder_for(build)).to be_nil
end
end
- context 'when the feature flag is enabled' do
- before do
- stub_feature_flags(use_response_url_for_chat_responder: true)
- end
-
- context 'using a regular build' do
- it 'returns nil' do
- build = create(:ci_build)
+ context 'using a chat build' do
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+ let_it_be(:build) { create(:ci_build, pipeline: pipeline) }
- expect(described_class.responder_for(build)).to be_nil
+ context "when response_url starts with 'https://hooks.slack.com/'" do
+ before do
+ pipeline.build_chat_data(response_url: 'https://hooks.slack.com/services/12345', chat_name_id: 'U123')
end
+
+ it { expect(described_class.responder_for(build)).to be_an_instance_of(Gitlab::Chat::Responder::Slack) }
end
- context 'using a chat build' do
- let(:chat_name) { create(:chat_name, chat_id: 'U123') }
- let(:pipeline) do
- pipeline = create(:ci_pipeline)
- pipeline.create_chat_data!(
- response_url: 'https://hooks.slack.com/services/12345',
- chat_name_id: chat_name.id
- )
- pipeline
+ context "when response_url does not start with 'https://hooks.slack.com/'" do
+ before do
+ pipeline.build_chat_data(response_url: 'https://mattermost.example.com/services/12345', chat_name_id: 'U123')
end
- let(:build) { create(:ci_build, pipeline: pipeline) }
- let(:responder) { described_class.new(build) }
-
- it 'returns the responder for the build' do
- expect(described_class.responder_for(build))
- .to be_an_instance_of(Gitlab::Chat::Responder::Slack)
- end
+ it { expect(described_class.responder_for(build)).to be_an_instance_of(Gitlab::Chat::Responder::Mattermost) }
end
end
end
diff --git a/spec/lib/gitlab/checks/changes_access_spec.rb b/spec/lib/gitlab/checks/changes_access_spec.rb
index 60118823b5a..552afcdb180 100644
--- a/spec/lib/gitlab/checks/changes_access_spec.rb
+++ b/spec/lib/gitlab/checks/changes_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Checks::ChangesAccess do
+RSpec.describe Gitlab::Checks::ChangesAccess, feature_category: :source_code_management do
include_context 'changes access checks context'
subject { changes_access }
@@ -47,6 +47,16 @@ RSpec.describe Gitlab::Checks::ChangesAccess do
expect(subject.commits).to match_array([])
end
+ context 'when change is for notes ref' do
+ let(:changes) do
+ [{ oldrev: oldrev, newrev: newrev, ref: 'refs/notes/commit' }]
+ end
+
+ it 'does not return any commits' do
+ expect(subject.commits).to match_array([])
+ end
+ end
+
context 'when changes contain empty revisions' do
let(:expected_commit) { instance_double(Commit) }
diff --git a/spec/lib/gitlab/checks/diff_check_spec.rb b/spec/lib/gitlab/checks/diff_check_spec.rb
index 6b45b8d4628..0845c746545 100644
--- a/spec/lib/gitlab/checks/diff_check_spec.rb
+++ b/spec/lib/gitlab/checks/diff_check_spec.rb
@@ -2,10 +2,20 @@
require 'spec_helper'
-RSpec.describe Gitlab::Checks::DiffCheck do
+RSpec.describe Gitlab::Checks::DiffCheck, feature_category: :source_code_management do
include_context 'change access checks context'
describe '#validate!' do
+ context 'when ref is not tag or branch ref' do
+ let(:ref) { 'refs/notes/commit' }
+
+ it 'does not call find_changed_paths' do
+ expect(project.repository).not_to receive(:find_changed_paths)
+
+ subject.validate!
+ end
+ end
+
context 'when commits is empty' do
it 'does not call find_changed_paths' do
expect(project.repository).not_to receive(:find_changed_paths)
diff --git a/spec/lib/gitlab/ci/ansi2json/state_spec.rb b/spec/lib/gitlab/ci/ansi2json/state_spec.rb
new file mode 100644
index 00000000000..8dd4092f3d8
--- /dev/null
+++ b/spec/lib/gitlab/ci/ansi2json/state_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Ansi2json::State, feature_category: :continuous_integration do
+ def build_state
+ described_class.new('', 1000).tap do |state|
+ state.offset = 1
+ state.new_line!(style: { fg: 'some-fg', bg: 'some-bg', mask: 1234 })
+ state.set_last_line_offset
+ state.open_section('hello', 111, {})
+ end
+ end
+
+ let(:state) { build_state }
+
+ describe '#initialize' do
+ it 'restores valid prior state', :aggregate_failures do
+ new_state = described_class.new(state.encode, 1000)
+
+ expect(new_state.offset).to eq(1)
+ expect(new_state.inherited_style).to eq({
+ bg: 'some-bg',
+ fg: 'some-fg',
+ mask: 1234
+ })
+ expect(new_state.open_sections).to eq({ 'hello' => 111 })
+ end
+
+ it 'ignores unsigned prior state', :aggregate_failures do
+ unsigned, _ = build_state.encode.split('--')
+
+ expect(::Gitlab::AppLogger).to(
+ receive(:warn).with(
+ message: a_string_matching(/signature missing or invalid/),
+ invalid_state: unsigned
+ )
+ )
+
+ new_state = described_class.new(unsigned, 0)
+
+ expect(new_state.offset).to eq(0)
+ expect(new_state.inherited_style).to eq({})
+ expect(new_state.open_sections).to eq({})
+ end
+
+ it 'ignores bad input', :aggregate_failures do
+ expect(::Gitlab::AppLogger).to(
+ receive(:warn).with(
+ message: a_string_matching(/signature missing or invalid/),
+ invalid_state: 'abcd'
+ )
+ )
+
+ new_state = described_class.new('abcd', 0)
+
+ expect(new_state.offset).to eq(0)
+ expect(new_state.inherited_style).to eq({})
+ expect(new_state.open_sections).to eq({})
+ end
+ end
+
+ describe '#encode' do
+ it 'deterministically signs the state' do
+ expect(state.encode).to eq state.encode
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/ansi2json_spec.rb b/spec/lib/gitlab/ci/ansi2json_spec.rb
index 0f8f3759834..98fca40e8ea 100644
--- a/spec/lib/gitlab/ci/ansi2json_spec.rb
+++ b/spec/lib/gitlab/ci/ansi2json_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Ansi2json do
+RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration do
subject { described_class }
describe 'lines' do
diff --git a/spec/lib/gitlab/ci/badge/release/template_spec.rb b/spec/lib/gitlab/ci/badge/release/template_spec.rb
index 2b66c296a94..6be0dcaae99 100644
--- a/spec/lib/gitlab/ci/badge/release/template_spec.rb
+++ b/spec/lib/gitlab/ci/badge/release/template_spec.rb
@@ -59,9 +59,30 @@ RSpec.describe Gitlab::Ci::Badge::Release::Template do
end
describe '#value_width' do
- it 'has a fixed value width' do
+ it 'returns the default value width' do
expect(template.value_width).to eq 54
end
+
+ it 'returns custom value width' do
+ value_width = 100
+ badge = Gitlab::Ci::Badge::Release::LatestRelease.new(project, user, opts: { value_width: value_width })
+
+ expect(described_class.new(badge).value_width).to eq value_width
+ end
+
+ it 'returns VALUE_WIDTH_DEFAULT if the custom value_width supplied is greater than permissible limit' do
+ value_width = 250
+ badge = Gitlab::Ci::Badge::Release::LatestRelease.new(project, user, opts: { value_width: value_width })
+
+ expect(described_class.new(badge).value_width).to eq 54
+ end
+
+ it 'returns VALUE_WIDTH_DEFAULT if value_width is not a number' do
+ value_width = "string"
+ badge = Gitlab::Ci::Badge::Release::LatestRelease.new(project, user, opts: { value_width: value_width })
+
+ expect(described_class.new(badge).value_width).to eq 54
+ end
end
describe '#key_color' do
diff --git a/spec/lib/gitlab/ci/build/auto_retry_spec.rb b/spec/lib/gitlab/ci/build/auto_retry_spec.rb
index 314714c543b..0b275e7d564 100644
--- a/spec/lib/gitlab/ci/build/auto_retry_spec.rb
+++ b/spec/lib/gitlab/ci/build/auto_retry_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Build::AutoRetry, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Build::AutoRetry, feature_category: :pipeline_composition do
let(:auto_retry) { described_class.new(build) }
describe '#allowed?' do
diff --git a/spec/lib/gitlab/ci/build/cache_spec.rb b/spec/lib/gitlab/ci/build/cache_spec.rb
index a8fa14b4b4c..68d6a7978d7 100644
--- a/spec/lib/gitlab/ci/build/cache_spec.rb
+++ b/spec/lib/gitlab/ci/build/cache_spec.rb
@@ -3,16 +3,21 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Build::Cache do
+ let(:cache_config) { [] }
+ let(:pipeline) { double(::Ci::Pipeline) }
+ let(:cache_seed_a) { double(Gitlab::Ci::Pipeline::Seed::Build::Cache) }
+ let(:cache_seed_b) { double(Gitlab::Ci::Pipeline::Seed::Build::Cache) }
+
+ subject(:cache) { described_class.new(cache_config, pipeline) }
+
describe '.initialize' do
context 'when the cache is an array' do
+ let(:cache_config) { [{ key: 'key-a' }, { key: 'key-b' }] }
+
it 'instantiates an array of cache seeds' do
- cache_config = [{ key: 'key-a' }, { key: 'key-b' }]
- pipeline = double(::Ci::Pipeline)
- cache_seed_a = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
- cache_seed_b = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed_a, cache_seed_b)
- cache = described_class.new(cache_config, pipeline)
+ cache
expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, { key: 'key-a' }, 0)
expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, { key: 'key-b' }, 1)
@@ -21,16 +26,31 @@ RSpec.describe Gitlab::Ci::Build::Cache do
end
context 'when the cache is a hash' do
+ let(:cache_config) { { key: 'key-a' } }
+
it 'instantiates a cache seed' do
- cache_config = { key: 'key-a' }
- pipeline = double(::Ci::Pipeline)
- cache_seed = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
- allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed)
+ allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed_a)
- cache = described_class.new(cache_config, pipeline)
+ cache
expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, cache_config, 0)
- expect(cache.instance_variable_get(:@cache)).to eq([cache_seed])
+ expect(cache.instance_variable_get(:@cache)).to eq([cache_seed_a])
+ end
+ end
+
+ context 'when the cache is an array with files inside hashes' do
+ let(:cache_config) { [{ key: { files: ['file1.json'] } }, { key: { files: ['file1.json', 'file2.json'] } }] }
+
+ it 'instantiates a cache seed' do
+ allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed_a, cache_seed_b)
+
+ cache
+
+ expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new)
+ .with(pipeline, cache_config.first, '0_file1')
+ expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new)
+ .with(pipeline, cache_config.second, '1_file1_file2')
+ expect(cache.instance_variable_get(:@cache)).to match_array([cache_seed_a, cache_seed_b])
end
end
end
@@ -38,10 +58,6 @@ RSpec.describe Gitlab::Ci::Build::Cache do
describe '#cache_attributes' do
context 'when there are no caches' do
it 'returns an empty hash' do
- cache_config = []
- pipeline = double(::Ci::Pipeline)
- cache = described_class.new(cache_config, pipeline)
-
attributes = cache.cache_attributes
expect(attributes).to eq({})
@@ -51,7 +67,6 @@ RSpec.describe Gitlab::Ci::Build::Cache do
context 'when there are caches' do
it 'returns the structured attributes for the caches' do
cache_config = [{ key: 'key-a' }, { key: 'key-b' }]
- pipeline = double(::Ci::Pipeline)
cache = described_class.new(cache_config, pipeline)
attributes = cache.cache_attributes
diff --git a/spec/lib/gitlab/ci/build/context/build_spec.rb b/spec/lib/gitlab/ci/build/context/build_spec.rb
index 74739a67be0..d4a2af0015f 100644
--- a/spec/lib/gitlab/ci/build/context/build_spec.rb
+++ b/spec/lib/gitlab/ci/build/context/build_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Build::Context::Build, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Build::Context::Build, feature_category: :pipeline_composition do
let(:pipeline) { create(:ci_pipeline) }
let(:seed_attributes) { { 'name' => 'some-job' } }
@@ -13,14 +13,29 @@ RSpec.describe Gitlab::Ci::Build::Context::Build, feature_category: :pipeline_au
it { is_expected.to include('CI_PIPELINE_IID' => pipeline.iid.to_s) }
it { is_expected.to include('CI_PROJECT_PATH' => pipeline.project.full_path) }
it { is_expected.to include('CI_JOB_NAME' => 'some-job') }
- it { is_expected.to include('CI_BUILD_REF_NAME' => 'master') }
+
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ it { is_expected.to include('CI_BUILD_REF_NAME' => 'master') }
+ end
context 'without passed build-specific attributes' do
let(:context) { described_class.new(pipeline) }
- it { is_expected.to include('CI_JOB_NAME' => nil) }
- it { is_expected.to include('CI_BUILD_REF_NAME' => 'master') }
- it { is_expected.to include('CI_PROJECT_PATH' => pipeline.project.full_path) }
+ it { is_expected.to include('CI_JOB_NAME' => nil) }
+ it { is_expected.to include('CI_COMMIT_REF_NAME' => 'master') }
+ it { is_expected.to include('CI_PROJECT_PATH' => pipeline.project.full_path) }
+
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ it { is_expected.to include('CI_BUILD_REF_NAME' => 'master') }
+ end
end
context 'when environment:name is provided' do
diff --git a/spec/lib/gitlab/ci/build/context/global_spec.rb b/spec/lib/gitlab/ci/build/context/global_spec.rb
index d4141eb8389..328b5eb62fa 100644
--- a/spec/lib/gitlab/ci/build/context/global_spec.rb
+++ b/spec/lib/gitlab/ci/build/context/global_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Build::Context::Global do
+RSpec.describe Gitlab::Ci::Build::Context::Global, feature_category: :pipeline_composition do
let(:pipeline) { create(:ci_pipeline) }
let(:yaml_variables) { {} }
@@ -14,7 +14,14 @@ RSpec.describe Gitlab::Ci::Build::Context::Global do
it { is_expected.to include('CI_PROJECT_PATH' => pipeline.project.full_path) }
it { is_expected.not_to have_key('CI_JOB_NAME') }
- it { is_expected.not_to have_key('CI_BUILD_REF_NAME') }
+
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ it { is_expected.not_to have_key('CI_BUILD_REF_NAME') }
+ end
context 'with passed yaml variables' do
let(:yaml_variables) { [{ key: 'SUPPORTED', value: 'parsed', public: true }] }
diff --git a/spec/lib/gitlab/ci/build/hook_spec.rb b/spec/lib/gitlab/ci/build/hook_spec.rb
index 6ed40a44c97..6c9175b4260 100644
--- a/spec/lib/gitlab/ci/build/hook_spec.rb
+++ b/spec/lib/gitlab/ci/build/hook_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Build::Hook, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Build::Hook, feature_category: :pipeline_composition do
let_it_be(:build1) do
FactoryBot.build(:ci_build,
options: { hooks: { pre_get_sources_script: ["echo 'hello pre_get_sources_script'"] } })
diff --git a/spec/lib/gitlab/ci/build/rules_spec.rb b/spec/lib/gitlab/ci/build/rules_spec.rb
index e82dcd0254d..1ece0f6b7b9 100644
--- a/spec/lib/gitlab/ci/build/rules_spec.rb
+++ b/spec/lib/gitlab/ci/build/rules_spec.rb
@@ -181,6 +181,108 @@ RSpec.describe Gitlab::Ci::Build::Rules do
end
end
+ context 'with needs' do
+ context 'when single needs is specified' do
+ let(:rule_list) do
+ [{ if: '$VAR == null', needs: [{ name: 'test', artifacts: true, optional: false }] }]
+ end
+
+ it {
+ is_expected.to eq(described_class::Result.new('on_success', nil, nil, nil,
+ [{ name: 'test', artifacts: true, optional: false }], nil))
+ }
+ end
+
+ context 'when multiple needs are specified' do
+ let(:rule_list) do
+ [{ if: '$VAR == null',
+ needs: [{ name: 'test', artifacts: true, optional: false },
+ { name: 'rspec', artifacts: true, optional: false }] }]
+ end
+
+ it {
+ is_expected.to eq(described_class::Result.new('on_success', nil, nil, nil,
+ [{ name: 'test', artifacts: true, optional: false },
+ { name: 'rspec', artifacts: true, optional: false }], nil))
+ }
+ end
+
+ context 'when there are no needs specified' do
+ let(:rule_list) { [{ if: '$VAR == null' }] }
+
+ it { is_expected.to eq(described_class::Result.new('on_success', nil, nil, nil, nil, nil)) }
+ end
+
+ context 'when need is specified with additional attibutes' do
+ let(:rule_list) do
+ [{ if: '$VAR == null', needs: [{
+ artifacts: true,
+ name: 'test',
+ optional: false,
+ when: 'never'
+ }] }]
+ end
+
+ it {
+ is_expected.to eq(
+ described_class::Result.new('on_success', nil, nil, nil,
+ [{ artifacts: true, name: 'test', optional: false, when: 'never' }], nil))
+ }
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(introduce_rules_with_needs: false)
+ end
+
+ context 'with needs' do
+ context 'when single needs is specified' do
+ let(:rule_list) do
+ [{ if: '$VAR == null', needs: [{ name: 'test', artifacts: true, optional: false }] }]
+ end
+
+ it {
+ is_expected.to eq(described_class::Result.new('on_success', nil, nil, nil, nil, nil))
+ }
+ end
+
+ context 'when multiple needs are specified' do
+ let(:rule_list) do
+ [{ if: '$VAR == null',
+ needs: [{ name: 'test', artifacts: true, optional: false },
+ { name: 'rspec', artifacts: true, optional: false }] }]
+ end
+
+ it {
+ is_expected.to eq(described_class::Result.new('on_success', nil, nil, nil, nil, nil))
+ }
+ end
+
+ context 'when there are no needs specified' do
+ let(:rule_list) { [{ if: '$VAR == null' }] }
+
+ it { is_expected.to eq(described_class::Result.new('on_success', nil, nil, nil, nil, nil)) }
+ end
+
+ context 'when need is specified with additional attibutes' do
+ let(:rule_list) do
+ [{ if: '$VAR == null', needs: [{
+ artifacts: true,
+ name: 'test',
+ optional: false,
+ when: 'never'
+ }] }]
+ end
+
+ it {
+ is_expected.to eq(
+ described_class::Result.new('on_success', nil, nil, nil, nil, nil))
+ }
+ end
+ end
+ end
+ end
+
context 'with variables' do
context 'with matching rule' do
let(:rule_list) { [{ if: '$VAR == null', variables: { MY_VAR: 'my var' } }] }
@@ -208,9 +310,10 @@ RSpec.describe Gitlab::Ci::Build::Rules do
let(:start_in) { nil }
let(:allow_failure) { nil }
let(:variables) { nil }
+ let(:needs) { nil }
subject(:result) do
- Gitlab::Ci::Build::Rules::Result.new(when_value, start_in, allow_failure, variables)
+ Gitlab::Ci::Build::Rules::Result.new(when_value, start_in, allow_failure, variables, needs)
end
describe '#build_attributes' do
@@ -221,6 +324,45 @@ RSpec.describe Gitlab::Ci::Build::Rules do
it 'compacts nil values' do
is_expected.to eq(options: {}, when: 'on_success')
end
+
+ context 'scheduling_type' do
+ context 'when rules have needs' do
+ context 'single need' do
+ let(:needs) do
+ { job: [{ name: 'test' }] }
+ end
+
+ it 'saves needs' do
+ expect(subject[:needs_attributes]).to eq([{ name: "test" }])
+ end
+
+ it 'adds schedule type to the build_attributes' do
+ expect(subject[:scheduling_type]).to eq(:dag)
+ end
+ end
+
+ context 'multiple needs' do
+ let(:needs) do
+ { job: [{ name: 'test' }, { name: 'test_2', artifacts: true, optional: false }] }
+ end
+
+ it 'saves needs' do
+ expect(subject[:needs_attributes]).to match_array([{ name: "test" },
+ { name: 'test_2', artifacts: true, optional: false }])
+ end
+
+ it 'adds schedule type to the build_attributes' do
+ expect(subject[:scheduling_type]).to eq(:dag)
+ end
+ end
+ end
+
+ context 'when rules do not have needs' do
+ it 'does not add schedule type to the build_attributes' do
+ expect(subject.key?(:scheduling_type)).to be_falsy
+ end
+ end
+ end
end
describe '#pass?' do
diff --git a/spec/lib/gitlab/ci/components/instance_path_spec.rb b/spec/lib/gitlab/ci/components/instance_path_spec.rb
index d9beae0555c..b80422d03e5 100644
--- a/spec/lib/gitlab/ci/components/instance_path_spec.rb
+++ b/spec/lib/gitlab/ci/components/instance_path_spec.rb
@@ -2,11 +2,11 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Components::InstancePath, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Components::InstancePath, feature_category: :pipeline_composition do
let_it_be(:user) { create(:user) }
let(:path) { described_class.new(address: address, content_filename: 'template.yml') }
- let(:settings) { Settingslogic.new({ 'component_fqdn' => current_host }) }
+ let(:settings) { GitlabSettings::Options.build({ 'component_fqdn' => current_host }) }
let(:current_host) { 'acme.com/' }
before do
@@ -98,6 +98,37 @@ RSpec.describe Gitlab::Ci::Components::InstancePath, feature_category: :pipeline
end
end
+ context 'when version is `~latest`' do
+ let(:version) { '~latest' }
+
+ context 'when project is a catalog resource' do
+ before do
+ create(:catalog_resource, project: existing_project)
+ end
+
+ context 'when project has releases' do
+ let_it_be(:releases) do
+ [
+ create(:release, project: existing_project, sha: 'sha-1', released_at: Time.zone.now - 1.day),
+ create(:release, project: existing_project, sha: 'sha-2', released_at: Time.zone.now)
+ ]
+ end
+
+ it 'returns the sha of the latest release' do
+ expect(path.sha).to eq(releases.last.sha)
+ end
+ end
+
+ context 'when project does not have releases' do
+ it { expect(path.sha).to be_nil }
+ end
+ end
+
+ context 'when project is not a catalog resource' do
+ it { expect(path.sha).to be_nil }
+ end
+ end
+
context 'when project does not exist' do
let(:project_path) { 'non-existent/project' }
diff --git a/spec/lib/gitlab/ci/config/entry/cache_spec.rb b/spec/lib/gitlab/ci/config/entry/cache_spec.rb
index 67252eed938..82db116fa0d 100644
--- a/spec/lib/gitlab/ci/config/entry/cache_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/cache_spec.rb
@@ -17,6 +17,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Cache do
let(:key) { 'some key' }
let(:when_config) { nil }
let(:unprotect) { false }
+ let(:fallback_keys) { [] }
let(:config) do
{
@@ -27,13 +28,22 @@ RSpec.describe Gitlab::Ci::Config::Entry::Cache do
}.tap do |config|
config[:policy] = policy if policy
config[:when] = when_config if when_config
+ config[:fallback_keys] = fallback_keys if fallback_keys
end
end
describe '#value' do
shared_examples 'hash key value' do
it 'returns hash value' do
- expect(entry.value).to eq(key: key, untracked: true, paths: ['some/path/'], policy: 'pull-push', when: 'on_success', unprotect: false)
+ expect(entry.value).to eq(
+ key: key,
+ untracked: true,
+ paths: ['some/path/'],
+ policy: 'pull-push',
+ when: 'on_success',
+ unprotect: false,
+ fallback_keys: []
+ )
end
end
@@ -104,6 +114,20 @@ RSpec.describe Gitlab::Ci::Config::Entry::Cache do
expect(entry.value).to include(when: 'on_success')
end
end
+
+ context 'with `fallback_keys`' do
+ let(:fallback_keys) { %w[key-1 key-2] }
+
+ it 'matches the list of fallback keys' do
+ expect(entry.value).to match(a_hash_including(fallback_keys: %w[key-1 key-2]))
+ end
+ end
+
+ context 'without `fallback_keys`' do
+ it 'assigns an empty list' do
+ expect(entry.value).to match(a_hash_including(fallback_keys: []))
+ end
+ end
end
describe '#valid?' do
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index c1b9bd58d98..4be7c11fab0 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_composition do
let(:entry) { described_class.new(config, name: :rspec) }
it_behaves_like 'with inheritable CI config' do
@@ -261,13 +261,13 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_autho
end
end
- context 'when it is lower than two' do
- let(:config) { { script: 'echo', parallel: 1 } }
+ context 'when it is lower than one' do
+ let(:config) { { script: 'echo', parallel: 0 } }
it 'returns error about value too low' do
expect(entry).not_to be_valid
expect(entry.errors)
- .to include 'parallel config must be greater than or equal to 2'
+ .to include 'parallel config must be greater than or equal to 1'
end
end
@@ -595,6 +595,39 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_autho
end
end
end
+
+ context 'when job is not a pages job' do
+ let(:name) { :rspec }
+
+ context 'if the config contains a publish entry' do
+ let(:entry) { described_class.new({ script: 'echo', publish: 'foo' }, name: name) }
+
+ it 'is invalid' do
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include /job publish can only be used within a `pages` job/
+ end
+ end
+ end
+
+ context 'when job is a pages job' do
+ let(:name) { :pages }
+
+ context 'when it does not have a publish entry' do
+ let(:entry) { described_class.new({ script: 'echo' }, name: name) }
+
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
+
+ context 'when it has a publish entry' do
+ let(:entry) { described_class.new({ script: 'echo', publish: 'foo' }, name: name) }
+
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
+ end
end
describe '#relevant?' do
@@ -631,7 +664,13 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_autho
it 'overrides default config' do
expect(entry[:image].value).to eq(name: 'some_image')
- expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success', unprotect: false])
+ expect(entry[:cache].value).to match_array([
+ key: 'test',
+ policy: 'pull-push',
+ when: 'on_success',
+ unprotect: false,
+ fallback_keys: []
+ ])
end
end
@@ -646,7 +685,13 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_autho
it 'uses config from default entry' do
expect(entry[:image].value).to eq 'specified'
- expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success', unprotect: false])
+ expect(entry[:cache].value).to match_array([
+ key: 'test',
+ policy: 'pull-push',
+ when: 'on_success',
+ unprotect: false,
+ fallback_keys: []
+ ])
end
end
@@ -728,27 +773,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_autho
scheduling_type: :stage,
id_tokens: { TEST_ID_TOKEN: { aud: 'https://gitlab.com' } })
end
-
- context 'when the FF ci_hooks_pre_get_sources_script is disabled' do
- before do
- stub_feature_flags(ci_hooks_pre_get_sources_script: false)
- end
-
- it 'returns correct value' do
- expect(entry.value)
- .to eq(name: :rspec,
- before_script: %w[ls pwd],
- script: %w[rspec],
- stage: 'test',
- ignore: false,
- after_script: %w[cleanup],
- only: { refs: %w[branches tags] },
- job_variables: {},
- root_variables_inheritance: true,
- scheduling_type: :stage,
- id_tokens: { TEST_ID_TOKEN: { aud: 'https://gitlab.com' } })
- end
- end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/policy_spec.rb b/spec/lib/gitlab/ci/config/entry/policy_spec.rb
index 378c0947e8a..7093a0a6edf 100644
--- a/spec/lib/gitlab/ci/config/entry/policy_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/policy_spec.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::Entry::Policy do
+RSpec.describe Gitlab::Ci::Config::Entry::Policy, feature_category: :continuous_integration do
let(:entry) { described_class.new(config) }
context 'when using simplified policy' do
diff --git a/spec/lib/gitlab/ci/config/entry/processable_spec.rb b/spec/lib/gitlab/ci/config/entry/processable_spec.rb
index b28562ba2ea..4f13940d7e2 100644
--- a/spec/lib/gitlab/ci/config/entry/processable_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/processable_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::Entry::Processable, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::Entry::Processable, feature_category: :pipeline_composition do
let(:node_class) do
Class.new(::Gitlab::Config::Entry::Node) do
include Gitlab::Ci::Config::Entry::Processable
diff --git a/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb b/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb
index ec21519a8f6..1025c41477d 100644
--- a/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb
@@ -27,10 +27,10 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do
it_behaves_like 'invalid config', /should be an integer or a hash/
end
- context 'when it is lower than two' do
- let(:config) { 1 }
+ context 'when it is lower than one' do
+ let(:config) { 0 }
- it_behaves_like 'invalid config', /must be greater than or equal to 2/
+ it_behaves_like 'invalid config', /must be greater than or equal to 1/
end
context 'when it is bigger than 200' do
diff --git a/spec/lib/gitlab/ci/config/entry/publish_spec.rb b/spec/lib/gitlab/ci/config/entry/publish_spec.rb
new file mode 100644
index 00000000000..53ad868a05e
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/entry/publish_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Entry::Publish, feature_category: :pages do
+ let(:publish) { described_class.new(config) }
+
+ describe 'validations' do
+ context 'when publish config value is correct' do
+ let(:config) { 'dist/static' }
+
+ describe '#config' do
+ it 'returns the publish directory' do
+ expect(publish.config).to eq config
+ end
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(publish).to be_valid
+ end
+ end
+ end
+
+ context 'when the value has a wrong type' do
+ let(:config) { { test: true } }
+
+ it 'reports an error' do
+ expect(publish.errors)
+ .to include 'publish config should be a string'
+ end
+ end
+ end
+
+ describe '.default' do
+ it 'returns the default value' do
+ expect(described_class.default).to eq 'public'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/pull_policy_spec.rb b/spec/lib/gitlab/ci/config/entry/pull_policy_spec.rb
index c35355b10c6..40507a66c2d 100644
--- a/spec/lib/gitlab/ci/config/entry/pull_policy_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/pull_policy_spec.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::Entry::PullPolicy do
+RSpec.describe Gitlab::Ci::Config::Entry::PullPolicy, feature_category: :continuous_integration do
let(:entry) { described_class.new(config) }
describe '#value' do
diff --git a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb
index ccd6f6ab427..6f37dd72083 100644
--- a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::Entry::Reports::CoverageReport, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::Entry::Reports::CoverageReport, feature_category: :pipeline_composition do
let(:entry) { described_class.new(config) }
describe 'validations' do
diff --git a/spec/lib/gitlab/ci/config/entry/reports_spec.rb b/spec/lib/gitlab/ci/config/entry/reports_spec.rb
index 715cb18fb92..73bf2d422b7 100644
--- a/spec/lib/gitlab/ci/config/entry/reports_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/reports_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::Entry::Reports, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::Entry::Reports, feature_category: :pipeline_composition do
let(:entry) { described_class.new(config) }
describe 'validates ALLOWED_KEYS' do
diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb
index 9722609aef6..5fac5298e8e 100644
--- a/spec/lib/gitlab/ci/config/entry/root_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb
@@ -128,7 +128,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success',
- unprotect: false }],
+ unprotect: false, fallback_keys: [] }],
job_variables: {},
root_variables_inheritance: true,
ignore: false,
@@ -144,7 +144,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success',
- unprotect: false }],
+ unprotect: false, fallback_keys: [] }],
job_variables: {},
root_variables_inheritance: true,
ignore: false,
@@ -161,7 +161,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
image: { name: "image:1.0" },
services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }],
cache: [{ key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success',
- unprotect: false }],
+ unprotect: false, fallback_keys: [] }],
only: { refs: %w(branches tags) },
job_variables: { 'VAR' => { value: 'job' } },
root_variables_inheritance: true,
@@ -209,7 +209,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
image: { name: 'image:1.0' },
services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
- cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success', unprotect: false }],
+ cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success', unprotect: false, fallback_keys: [] }],
job_variables: {},
root_variables_inheritance: true,
ignore: false,
@@ -222,7 +222,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
image: { name: 'image:1.0' },
services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
- cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success', unprotect: false }],
+ cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success', unprotect: false, fallback_keys: [] }],
job_variables: { 'VAR' => { value: 'job' } },
root_variables_inheritance: true,
ignore: false,
@@ -277,7 +277,13 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
describe '#cache_value' do
it 'returns correct cache definition' do
- expect(root.cache_value).to eq([key: 'a', policy: 'pull-push', when: 'on_success', unprotect: false])
+ expect(root.cache_value).to match_array([
+ key: 'a',
+ policy: 'pull-push',
+ when: 'on_success',
+ unprotect: false,
+ fallback_keys: []
+ ])
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/trigger_spec.rb b/spec/lib/gitlab/ci/config/entry/trigger_spec.rb
index f47923af45a..fdd598c2ab2 100644
--- a/spec/lib/gitlab/ci/config/entry/trigger_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/trigger_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::Entry::Trigger, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::Entry::Trigger, feature_category: :pipeline_composition do
subject { described_class.new(config) }
context 'when trigger config is a non-empty string' do
diff --git a/spec/lib/gitlab/ci/config/external/context_spec.rb b/spec/lib/gitlab/ci/config/external/context_spec.rb
index 1fd3cf3c99f..d917924f257 100644
--- a/spec/lib/gitlab/ci/config/external/context_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/context_spec.rb
@@ -2,12 +2,21 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Context, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Context, feature_category: :pipeline_composition do
let(:project) { build(:project) }
let(:user) { double('User') }
let(:sha) { '12345' }
let(:variables) { Gitlab::Ci::Variables::Collection.new([{ 'key' => 'a', 'value' => 'b' }]) }
- let(:attributes) { { project: project, user: user, sha: sha, variables: variables } }
+ let(:pipeline_config) { instance_double(Gitlab::Ci::ProjectConfig) }
+ let(:attributes) do
+ {
+ project: project,
+ user: user,
+ sha: sha,
+ variables: variables,
+ pipeline_config: pipeline_config
+ }
+ end
subject(:subject) { described_class.new(**attributes) }
@@ -15,11 +24,11 @@ RSpec.describe Gitlab::Ci::Config::External::Context, feature_category: :pipelin
context 'with values' do
it { is_expected.to have_attributes(**attributes) }
it { expect(subject.expandset).to eq([]) }
- it { expect(subject.max_includes).to eq(Gitlab::Ci::Config::External::Context::NEW_MAX_INCLUDES) }
it { expect(subject.execution_deadline).to eq(0) }
it { expect(subject.variables).to be_instance_of(Gitlab::Ci::Variables::Collection) }
it { expect(subject.variables_hash).to be_instance_of(ActiveSupport::HashWithIndifferentAccess) }
it { expect(subject.variables_hash).to include('a' => 'b') }
+ it { expect(subject.pipeline_config).to eq(pipeline_config) }
end
context 'without values' do
@@ -27,36 +36,25 @@ RSpec.describe Gitlab::Ci::Config::External::Context, feature_category: :pipelin
it { is_expected.to have_attributes(**attributes) }
it { expect(subject.expandset).to eq([]) }
- it { expect(subject.max_includes).to eq(Gitlab::Ci::Config::External::Context::NEW_MAX_INCLUDES) }
it { expect(subject.execution_deadline).to eq(0) }
it { expect(subject.variables).to be_instance_of(Gitlab::Ci::Variables::Collection) }
it { expect(subject.variables_hash).to be_instance_of(ActiveSupport::HashWithIndifferentAccess) }
+ it { expect(subject.pipeline_config).to be_nil }
end
- context 'when FF ci_includes_count_duplicates is disabled' do
- before do
- stub_feature_flags(ci_includes_count_duplicates: false)
- end
-
- context 'with values' do
- it { is_expected.to have_attributes(**attributes) }
- it { expect(subject.expandset).to eq(Set.new) }
- it { expect(subject.max_includes).to eq(Gitlab::Ci::Config::External::Context::MAX_INCLUDES) }
- it { expect(subject.execution_deadline).to eq(0) }
- it { expect(subject.variables).to be_instance_of(Gitlab::Ci::Variables::Collection) }
- it { expect(subject.variables_hash).to be_instance_of(ActiveSupport::HashWithIndifferentAccess) }
- it { expect(subject.variables_hash).to include('a' => 'b') }
+ describe 'max_includes' do
+ it 'returns the default value of application setting `ci_max_includes`' do
+ expect(subject.max_includes).to eq(150)
end
- context 'without values' do
- let(:attributes) { { project: nil, user: nil, sha: nil } }
+ context 'when application setting `ci_max_includes` is changed' do
+ before do
+ stub_application_setting(ci_max_includes: 200)
+ end
- it { is_expected.to have_attributes(**attributes) }
- it { expect(subject.expandset).to eq(Set.new) }
- it { expect(subject.max_includes).to eq(Gitlab::Ci::Config::External::Context::MAX_INCLUDES) }
- it { expect(subject.execution_deadline).to eq(0) }
- it { expect(subject.variables).to be_instance_of(Gitlab::Ci::Variables::Collection) }
- it { expect(subject.variables_hash).to be_instance_of(ActiveSupport::HashWithIndifferentAccess) }
+ it 'returns the new value of application setting `ci_max_includes`' do
+ expect(subject.max_includes).to eq(200)
+ end
end
end
end
@@ -170,4 +168,26 @@ RSpec.describe Gitlab::Ci::Config::External::Context, feature_category: :pipelin
describe '#sentry_payload' do
it { expect(subject.sentry_payload).to match(a_hash_including(:project, :user)) }
end
+
+ describe '#internal_include?' do
+ context 'when pipeline_config is provided' do
+ where(:value) { [true, false] }
+
+ with_them do
+ it 'returns the value of .internal_include_prepended?' do
+ allow(pipeline_config).to receive(:internal_include_prepended?).and_return(value)
+
+ expect(subject.internal_include?).to eq(value)
+ end
+ end
+ end
+
+ context 'when pipeline_config is not provided' do
+ let(:pipeline_config) { nil }
+
+ it 'returns false' do
+ expect(subject.internal_include?).to eq(false)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb b/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb
index 45a15fb5f36..087dacd5ef0 100644
--- a/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb
@@ -2,11 +2,13 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::File::Artifact, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::File::Artifact, feature_category: :pipeline_composition do
let(:parent_pipeline) { create(:ci_pipeline) }
+ let(:project) { parent_pipeline.project }
let(:variables) {}
let(:context) do
- Gitlab::Ci::Config::External::Context.new(variables: variables, parent_pipeline: parent_pipeline)
+ Gitlab::Ci::Config::External::Context
+ .new(variables: variables, parent_pipeline: parent_pipeline, project: project)
end
let(:external_file) { described_class.new(params, context) }
@@ -43,7 +45,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact, feature_category: :
end
describe 'when used in non child pipeline context' do
- let(:parent_pipeline) { nil }
+ let(:context) { Gitlab::Ci::Config::External::Context.new }
let(:params) { { artifact: 'generated.yml' } }
let(:expected_error) do
@@ -201,7 +203,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact, feature_category: :
it {
is_expected.to eq(
- context_project: nil,
+ context_project: project.full_path,
context_sha: nil,
type: :artifact,
location: 'generated.yml',
@@ -218,7 +220,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact, feature_category: :
it {
is_expected.to eq(
- context_project: nil,
+ context_project: project.full_path,
context_sha: nil,
type: :artifact,
location: 'generated.yml',
@@ -227,4 +229,35 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact, feature_category: :
}
end
end
+
+ describe '#to_hash' do
+ context 'when interpolation is being used' do
+ let!(:job) { create(:ci_build, name: 'generator', pipeline: parent_pipeline) }
+ let!(:artifacts) { create(:ci_job_artifact, :archive, job: job) }
+ let!(:metadata) { create(:ci_job_artifact, :metadata, job: job) }
+
+ before do
+ allow_next_instance_of(Gitlab::Ci::ArtifactFileReader) do |reader|
+ allow(reader).to receive(:read).and_return(template)
+ end
+ end
+
+ let(:template) do
+ <<~YAML
+ spec:
+ inputs:
+ env:
+ ---
+ deploy:
+ script: deploy $[[ inputs.env ]]
+ YAML
+ end
+
+ let(:params) { { artifact: 'generated.yml', job: 'generator', inputs: { env: 'production' } } }
+
+ it 'correctly interpolates content' do
+ expect(external_file.to_hash).to eq({ deploy: { script: 'deploy production' } })
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/base_spec.rb b/spec/lib/gitlab/ci/config/external/file/base_spec.rb
index 55d95d0c1f8..1c5918f77ca 100644
--- a/spec/lib/gitlab/ci/config/external/file/base_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/base_spec.rb
@@ -2,15 +2,16 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipeline_composition do
+ let_it_be(:project) { create(:project) }
let(:variables) {}
- let(:context_params) { { sha: 'HEAD', variables: variables } }
- let(:context) { Gitlab::Ci::Config::External::Context.new(**context_params) }
+ let(:context_params) { { sha: 'HEAD', variables: variables, project: project } }
+ let(:ctx) { Gitlab::Ci::Config::External::Context.new(**context_params) }
let(:test_class) do
Class.new(described_class) do
- def initialize(params, context)
- @location = params
+ def initialize(params, ctx)
+ @location = params[:location]
super
end
@@ -18,15 +19,18 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipe
def validate_context!
# no-op
end
+
+ def content
+ params[:content]
+ end
end
end
- subject(:file) { test_class.new(location, context) }
+ let(:content) { 'key: value' }
- before do
- allow_any_instance_of(test_class)
- .to receive(:content).and_return('key: value')
+ subject(:file) { test_class.new({ location: location, content: content }, ctx) }
+ before do
allow_any_instance_of(Gitlab::Ci::Config::External::Context)
.to receive(:check_execution_time!)
end
@@ -51,7 +55,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipe
describe '#valid?' do
subject(:valid?) do
- Gitlab::Ci::Config::External::Mapper::Verifier.new(context).process([file])
+ Gitlab::Ci::Config::External::Mapper::Verifier.new(ctx).process([file])
file.valid?
end
@@ -87,7 +91,12 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipe
context 'when there are YAML syntax errors' do
let(:location) { 'some/file/secret_file_name.yml' }
- let(:variables) { Gitlab::Ci::Variables::Collection.new([{ 'key' => 'GITLAB_TOKEN', 'value' => 'secret_file_name', 'masked' => true }]) }
+
+ let(:variables) do
+ Gitlab::Ci::Variables::Collection.new(
+ [{ 'key' => 'GITLAB_TOKEN', 'value' => 'secret_file_name', 'masked' => true }]
+ )
+ end
before do
allow_any_instance_of(test_class)
@@ -96,15 +105,16 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipe
it 'is not a valid file' do
expect(valid?).to be_falsy
- expect(file.error_message).to eq('Included file `some/file/xxxxxxxxxxxxxxxx.yml` does not have valid YAML syntax!')
+ expect(file.error_message)
+ .to eq('`some/file/xxxxxxxxxxxxxxxx.yml`: content does not have a valid YAML syntax')
end
end
context 'when the class has no validate_context!' do
let(:test_class) do
Class.new(described_class) do
- def initialize(params, context)
- @location = params
+ def initialize(params, ctx)
+ @location = params[:location]
super
end
@@ -117,6 +127,88 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipe
expect { valid? }.to raise_error(NotImplementedError)
end
end
+
+ context 'when interpolation is disabled but there is a spec header' do
+ before do
+ stub_feature_flags(ci_includable_files_interpolation: false)
+ end
+
+ let(:location) { 'some-location.yml' }
+
+ let(:content) do
+ <<~YAML
+ spec:
+ include:
+ website:
+ ---
+ run:
+ script: deploy $[[ inputs.website ]]
+ YAML
+ end
+
+ it 'returns an error saying that interpolation is disabled' do
+ expect(valid?).to be_falsy
+ expect(file.errors)
+ .to include('`some-location.yml`: can not evaluate included file because interpolation is disabled')
+ end
+ end
+
+ context 'when interpolation was unsuccessful' do
+ let(:location) { 'some-location.yml' }
+
+ context 'when context key is missing' do
+ let(:content) do
+ <<~YAML
+ spec:
+ inputs:
+ ---
+ run:
+ script: deploy $[[ inputs.abcd ]]
+ YAML
+ end
+
+ it 'surfaces interpolation errors' do
+ expect(valid?).to be_falsy
+ expect(file.errors)
+ .to include('`some-location.yml`: interpolation interrupted by errors, unknown interpolation key: `abcd`')
+ end
+ end
+
+ context 'when header is invalid' do
+ let(:content) do
+ <<~YAML
+ spec:
+ a: abc
+ ---
+ run:
+ script: deploy $[[ inputs.abcd ]]
+ YAML
+ end
+
+ it 'surfaces header errors' do
+ expect(valid?).to be_falsy
+ expect(file.errors)
+ .to include('`some-location.yml`: header:spec config contains unknown keys: a')
+ end
+ end
+
+ context 'when header is not a hash' do
+ let(:content) do
+ <<~YAML
+ spec: abcd
+ ---
+ run:
+ script: deploy $[[ inputs.abcd ]]
+ YAML
+ end
+
+ it 'surfaces header errors' do
+ expect(valid?).to be_falsy
+ expect(file.errors)
+ .to contain_exactly('`some-location.yml`: header:spec config should be a hash')
+ end
+ end
+ end
end
describe '#to_hash' do
@@ -142,7 +234,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipe
it {
is_expected.to eq(
- context_project: nil,
+ context_project: project.full_path,
context_sha: 'HEAD'
)
}
@@ -154,13 +246,13 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipe
subject(:eql) { file.eql?(other_file) }
context 'when the other file has the same params' do
- let(:other_file) { test_class.new(location, context) }
+ let(:other_file) { test_class.new({ location: location, content: content }, ctx) }
it { is_expected.to eq(true) }
end
context 'when the other file has not the same params' do
- let(:other_file) { test_class.new('some/other/file', context) }
+ let(:other_file) { test_class.new({ location: 'some/other/file', content: content }, ctx) }
it { is_expected.to eq(false) }
end
@@ -172,14 +264,15 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base, feature_category: :pipe
subject(:filehash) { file.hash }
context 'with a project' do
- let(:project) { create(:project) }
let(:context_params) { { project: project, sha: 'HEAD', variables: variables } }
- it { is_expected.to eq([location, project.full_path, 'HEAD'].hash) }
+ it { is_expected.to eq([{ location: location, content: content }, project.full_path, 'HEAD'].hash) }
end
context 'without a project' do
- it { is_expected.to eq([location, nil, 'HEAD'].hash) }
+ let(:context_params) { { sha: 'HEAD', variables: variables } }
+
+ it { is_expected.to eq([{ location: location, content: content }, nil, 'HEAD'].hash) }
end
end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/component_spec.rb b/spec/lib/gitlab/ci/config/external/file/component_spec.rb
index a162a1a8abf..fe811bce9fe 100644
--- a/spec/lib/gitlab/ci/config/external/file/component_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/component_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::File::Component, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::File::Component, feature_category: :pipeline_composition do
let_it_be(:context_project) { create(:project, :repository) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
@@ -121,7 +121,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Component, feature_category:
it 'is invalid' do
expect(subject).to be_falsy
- expect(external_resource.error_message).to match(/does not have valid YAML syntax/)
+ expect(external_resource.error_message).to match(/does not have a valid YAML syntax/)
end
end
end
@@ -176,4 +176,35 @@ RSpec.describe Gitlab::Ci::Config::External::File::Component, feature_category:
variables: context.variables)
end
end
+
+ describe '#to_hash' do
+ context 'when interpolation is being used' do
+ let(:response) do
+ ServiceResponse.success(payload: { content: content, path: path })
+ end
+
+ let(:path) do
+ instance_double(::Gitlab::Ci::Components::InstancePath, project: project, sha: '12345')
+ end
+
+ let(:content) do
+ <<~YAML
+ spec:
+ inputs:
+ env:
+ ---
+ deploy:
+ script: deploy $[[ inputs.env ]]
+ YAML
+ end
+
+ let(:params) do
+ { component: 'gitlab.com/acme/components/my-component@1.0', with: { env: 'production' } }
+ end
+
+ it 'correctly interpolates the content' do
+ expect(external_resource.to_hash).to eq({ deploy: { script: 'deploy production' } })
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/local_spec.rb b/spec/lib/gitlab/ci/config/external/file/local_spec.rb
index b5895b4bc81..0643bf0c046 100644
--- a/spec/lib/gitlab/ci/config/external/file/local_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/local_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::File::Local, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::File::Local, feature_category: :pipeline_composition do
include RepoHelpers
let_it_be(:project) { create(:project, :repository) }
@@ -228,6 +228,34 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local, feature_category: :pip
expect(local_file.to_hash).to include(:rspec)
end
end
+
+ context 'when interpolaton is being used' do
+ let(:local_file_content) do
+ <<~YAML
+ spec:
+ inputs:
+ website:
+ ---
+ test:
+ script: cap deploy $[[ inputs.website ]]
+ YAML
+ end
+
+ let(:location) { '/lib/gitlab/ci/templates/existent-file.yml' }
+ let(:params) { { local: location, inputs: { website: 'gitlab.com' } } }
+
+ before do
+ allow_any_instance_of(described_class)
+ .to receive(:fetch_local_content)
+ .and_return(local_file_content)
+ end
+
+ it 'correctly interpolates the local template' do
+ expect(local_file).to be_valid
+ expect(local_file.to_hash)
+ .to eq({ test: { script: 'cap deploy gitlab.com' } })
+ end
+ end
end
describe '#metadata' do
diff --git a/spec/lib/gitlab/ci/config/external/file/project_spec.rb b/spec/lib/gitlab/ci/config/external/file/project_spec.rb
index abe38cdbc3e..636241ed763 100644
--- a/spec/lib/gitlab/ci/config/external/file/project_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/project_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::File::Project, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::File::Project, feature_category: :pipeline_composition do
include RepoHelpers
let_it_be(:context_project) { create(:project) }
@@ -97,6 +97,36 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project, feature_category: :p
end
end
+ context 'when a valid path is used in uppercase' do
+ let(:params) do
+ { project: project.full_path.upcase, file: '/file.yml' }
+ end
+
+ around do |example|
+ create_and_delete_files(project, { '/file.yml' => 'image: image:1.0' }) do
+ example.run
+ end
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when a valid different case path is used' do
+ let_it_be(:project) { create(:project, :repository, path: 'mY-teSt-proJect', name: 'My Test Project') }
+
+ let(:params) do
+ { project: "#{project.namespace.full_path}/my-test-projecT", file: '/file.yml' }
+ end
+
+ around do |example|
+ create_and_delete_files(project, { '/file.yml' => 'image: image:1.0' }) do
+ example.run
+ end
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
context 'when a valid path with custom ref is used' do
let(:params) do
{ project: project.full_path, ref: 'master', file: '/file.yml' }
@@ -230,16 +260,16 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project, feature_category: :p
}
context 'when project name and ref include masked variables' do
- let(:project_name) { 'my_project_name' }
+ let_it_be(:project) { create(:project, :repository, path: 'my_project_path') }
+
let(:branch_name) { 'merge-commit-analyze-after' }
- let(:project) { create(:project, :repository, name: project_name) }
let(:namespace_path) { project.namespace.full_path }
let(:included_project_sha) { project.commit(branch_name).sha }
let(:variables) do
Gitlab::Ci::Variables::Collection.new(
[
- { key: 'VAR1', value: project_name, masked: true },
+ { key: 'VAR1', value: 'my_project_path', masked: true },
{ key: 'VAR2', value: branch_name, masked: true }
])
end
@@ -259,4 +289,37 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project, feature_category: :p
}
end
end
+
+ describe '#to_hash' do
+ context 'when interpolation is being used' do
+ before do
+ project.repository.create_file(
+ user,
+ 'template-file.yml',
+ template,
+ message: 'Add template',
+ branch_name: 'master'
+ )
+ end
+
+ let(:template) do
+ <<~YAML
+ spec:
+ inputs:
+ name:
+ ---
+ rspec:
+ script: rspec --suite $[[ inputs.name ]]
+ YAML
+ end
+
+ let(:params) do
+ { file: 'template-file.yml', ref: 'master', project: project.full_path, inputs: { name: 'abc' } }
+ end
+
+ it 'correctly interpolates the content' do
+ expect(project_file.to_hash).to eq({ rspec: { script: 'rspec --suite abc' } })
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb
index 27f401db76e..f8d3d1019f5 100644
--- a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::File::Remote, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::File::Remote, feature_category: :pipeline_composition do
include StubRequests
let(:variables) { Gitlab::Ci::Variables::Collection.new([{ 'key' => 'GITLAB_TOKEN', 'value' => 'secret_file', 'masked' => true }]) }
@@ -234,15 +234,13 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote, feature_category: :pi
end
describe '#to_hash' do
- subject(:to_hash) { remote_file.to_hash }
-
before do
stub_full_request(location).to_return(body: remote_file_content)
end
context 'with a valid remote file' do
it 'returns the content as a hash' do
- expect(to_hash).to eql(
+ expect(remote_file.to_hash).to eql(
before_script: ["apt-get update -qq && apt-get install -y -qq sqlite3 libsqlite3-dev nodejs",
"ruby -v",
"which ruby",
@@ -262,7 +260,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote, feature_category: :pi
end
it 'returns the content as a hash' do
- expect(to_hash).to eql(
+ expect(remote_file.to_hash).to eql(
include: [
{ local: 'another-file.yml',
rules: [{ exists: ['Dockerfile'] }] }
@@ -270,5 +268,38 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote, feature_category: :pi
)
end
end
+
+ context 'when interpolation has been used' do
+ let_it_be(:project) { create(:project) }
+
+ let(:remote_file_content) do
+ <<~YAML
+ spec:
+ inputs:
+ include:
+ ---
+ include:
+ - local: $[[ inputs.include ]]
+ rules:
+ - exists: [Dockerfile]
+ YAML
+ end
+
+ let(:params) { { remote: location, inputs: { include: 'some-file.yml' } } }
+
+ let(:context_params) do
+ { sha: '12345', variables: variables, project: project, user: build(:user) }
+ end
+
+ it 'returns the content as a hash' do
+ expect(remote_file).to be_valid
+ expect(remote_file.to_hash).to eql(
+ include: [
+ { local: 'some-file.yml',
+ rules: [{ exists: ['Dockerfile'] }] }
+ ]
+ )
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/template_spec.rb b/spec/lib/gitlab/ci/config/external/file/template_spec.rb
index 83e98874118..078b8831dc3 100644
--- a/spec/lib/gitlab/ci/config/external/file/template_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/template_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::File::Template, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::File::Template, feature_category: :pipeline_composition do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
@@ -130,4 +130,37 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template, feature_category: :
)
}
end
+
+ describe '#to_hash' do
+ context 'when interpolation is being used' do
+ before do
+ allow(Gitlab::Template::GitlabCiYmlTemplate)
+ .to receive(:find)
+ .and_return(template_double)
+ end
+
+ let(:template_double) do
+ instance_double(Gitlab::Template::GitlabCiYmlTemplate, content: template_content)
+ end
+
+ let(:template_content) do
+ <<~YAML
+ spec:
+ inputs:
+ env:
+ ---
+ deploy:
+ script: deploy $[[ inputs.env ]]
+ YAML
+ end
+
+ let(:params) do
+ { template: template, inputs: { env: 'production' } }
+ end
+
+ it 'correctly interpolates the content' do
+ expect(template_file.to_hash).to eq({ deploy: { script: 'deploy production' } })
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/config/external/interpolator_spec.rb b/spec/lib/gitlab/ci/config/external/interpolator_spec.rb
new file mode 100644
index 00000000000..fe6f97a66a5
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/external/interpolator_spec.rb
@@ -0,0 +1,319 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::External::Interpolator, feature_category: :pipeline_composition do
+ let_it_be(:project) { create(:project) }
+
+ let(:ctx) { instance_double(Gitlab::Ci::Config::External::Context, project: project, user: build(:user, id: 1234)) }
+ let(:result) { ::Gitlab::Ci::Config::Yaml::Result.new(config: [header, content]) }
+
+ subject { described_class.new(result, arguments, ctx) }
+
+ context 'when input data is valid' do
+ let(:header) do
+ { spec: { inputs: { website: nil } } }
+ end
+
+ let(:content) do
+ { test: 'deploy $[[ inputs.website ]]' }
+ end
+
+ let(:arguments) do
+ { website: 'gitlab.com' }
+ end
+
+ it 'correctly interpolates the config' do
+ subject.interpolate!
+
+ expect(subject).to be_valid
+ expect(subject.to_hash).to eq({ test: 'deploy gitlab.com' })
+ end
+
+ it 'tracks the event' do
+ expect(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
+ .with('ci_interpolation_users', { values: 1234 })
+
+ subject.interpolate!
+ end
+ end
+
+ context 'when config has a syntax error' do
+ let(:result) { ::Gitlab::Ci::Config::Yaml::Result.new(error: ArgumentError.new) }
+
+ let(:arguments) do
+ { website: 'gitlab.com' }
+ end
+
+ it 'surfaces an error about invalid config' do
+ subject.interpolate!
+
+ expect(subject).not_to be_valid
+ expect(subject.error_message).to eq subject.errors.first
+ expect(subject.errors).to include 'content does not have a valid YAML syntax'
+ end
+ end
+
+ context 'when spec header is invalid' do
+ let(:header) do
+ { spec: { arguments: { website: nil } } }
+ end
+
+ let(:content) do
+ { test: 'deploy $[[ inputs.website ]]' }
+ end
+
+ let(:arguments) do
+ { website: 'gitlab.com' }
+ end
+
+ it 'surfaces an error about invalid header' do
+ subject.interpolate!
+
+ expect(subject).not_to be_valid
+ expect(subject.error_message).to eq subject.errors.first
+ expect(subject.errors).to include('header:spec config contains unknown keys: arguments')
+ end
+ end
+
+ context 'when interpolation block is invalid' do
+ let(:header) do
+ { spec: { inputs: { website: nil } } }
+ end
+
+ let(:content) do
+ { test: 'deploy $[[ inputs.abc ]]' }
+ end
+
+ let(:arguments) do
+ { website: 'gitlab.com' }
+ end
+
+ it 'correctly interpolates the config' do
+ subject.interpolate!
+
+ expect(subject).not_to be_valid
+ expect(subject.errors).to include 'unknown interpolation key: `abc`'
+ expect(subject.error_message).to eq 'interpolation interrupted by errors, unknown interpolation key: `abc`'
+ end
+ end
+
+ context 'when provided interpolation argument is invalid' do
+ let(:header) do
+ { spec: { inputs: { website: nil } } }
+ end
+
+ let(:content) do
+ { test: 'deploy $[[ inputs.website ]]' }
+ end
+
+ let(:arguments) do
+ { website: ['gitlab.com'] }
+ end
+
+ it 'correctly interpolates the config' do
+ subject.interpolate!
+
+ expect(subject).not_to be_valid
+ expect(subject.error_message).to eq subject.errors.first
+ expect(subject.errors).to include 'unsupported value in input argument `website`'
+ end
+ end
+
+ context 'when multiple interpolation blocks are invalid' do
+ let(:header) do
+ { spec: { inputs: { website: nil } } }
+ end
+
+ let(:content) do
+ { test: 'deploy $[[ inputs.something.abc ]] $[[ inputs.cde ]] $[[ efg ]]' }
+ end
+
+ let(:arguments) do
+ { website: 'gitlab.com' }
+ end
+
+ it 'correctly interpolates the config' do
+ subject.interpolate!
+
+ expect(subject).not_to be_valid
+ expect(subject.error_message).to eq 'interpolation interrupted by errors, unknown interpolation key: `something`'
+ end
+ end
+
+ describe '#to_hash' do
+ context 'when interpolation is disabled' do
+ before do
+ stub_feature_flags(ci_includable_files_interpolation: false)
+ end
+
+ let(:header) do
+ { spec: { inputs: { website: nil } } }
+ end
+
+ let(:content) do
+ { test: 'deploy $[[ inputs.website ]]' }
+ end
+
+ let(:arguments) { {} }
+
+ it 'returns an empty hash' do
+ subject.interpolate!
+
+ expect(subject.to_hash).to be_empty
+ end
+ end
+
+ context 'when interpolation is not used' do
+ let(:result) do
+ ::Gitlab::Ci::Config::Yaml::Result.new(config: content)
+ end
+
+ let(:content) do
+ { test: 'deploy production' }
+ end
+
+ let(:arguments) { nil }
+
+ it 'returns original content' do
+ subject.interpolate!
+
+ expect(subject.to_hash).to eq(content)
+ end
+ end
+
+ context 'when interpolation is available' do
+ let(:header) do
+ { spec: { inputs: { website: nil } } }
+ end
+
+ let(:content) do
+ { test: 'deploy $[[ inputs.website ]]' }
+ end
+
+ let(:arguments) do
+ { website: 'gitlab.com' }
+ end
+
+ it 'correctly interpolates content' do
+ subject.interpolate!
+
+ expect(subject.to_hash).to eq({ test: 'deploy gitlab.com' })
+ end
+ end
+ end
+
+ describe '#ready?' do
+ let(:header) do
+ { spec: { inputs: { website: nil } } }
+ end
+
+ let(:content) do
+ { test: 'deploy $[[ inputs.website ]]' }
+ end
+
+ let(:arguments) do
+ { website: 'gitlab.com' }
+ end
+
+ it 'returns false if interpolation has not been done yet' do
+ expect(subject).not_to be_ready
+ end
+
+ it 'returns true if interpolation has been performed' do
+ subject.interpolate!
+
+ expect(subject).to be_ready
+ end
+
+ context 'when interpolation can not be performed' do
+ let(:result) do
+ ::Gitlab::Ci::Config::Yaml::Result.new(error: ArgumentError.new)
+ end
+
+ it 'returns true if interpolator has preliminary errors' do
+ expect(subject).to be_ready
+ end
+
+ it 'returns true if interpolation has been attempted' do
+ subject.interpolate!
+
+ expect(subject).to be_ready
+ end
+ end
+ end
+
+ describe '#interpolate?' do
+ let(:header) do
+ { spec: { inputs: { website: nil } } }
+ end
+
+ let(:content) do
+ { test: 'deploy $[[ inputs.something.abc ]] $[[ inputs.cde ]] $[[ efg ]]' }
+ end
+
+ let(:arguments) do
+ { website: 'gitlab.com' }
+ end
+
+ context 'when interpolation can be performed' do
+ it 'will perform interpolation' do
+ expect(subject.interpolate?).to eq true
+ end
+ end
+
+ context 'when interpolation is disabled' do
+ before do
+ stub_feature_flags(ci_includable_files_interpolation: false)
+ end
+
+ it 'will not perform interpolation' do
+ expect(subject.interpolate?).to eq false
+ end
+ end
+
+ context 'when an interpolation header is missing' do
+ let(:header) { nil }
+
+ it 'will not perform interpolation' do
+ expect(subject.interpolate?).to eq false
+ end
+ end
+
+ context 'when interpolator has preliminary errors' do
+ let(:result) do
+ ::Gitlab::Ci::Config::Yaml::Result.new(error: ArgumentError.new)
+ end
+
+ it 'will not perform interpolation' do
+ expect(subject.interpolate?).to eq false
+ end
+ end
+ end
+
+ describe '#has_header?' do
+ let(:content) do
+ { test: 'deploy $[[ inputs.something.abc ]] $[[ inputs.cde ]] $[[ efg ]]' }
+ end
+
+ let(:arguments) do
+ { website: 'gitlab.com' }
+ end
+
+ context 'when header is an empty hash' do
+ let(:header) { {} }
+
+ it 'does not have a header available' do
+ expect(subject).not_to have_header
+ end
+ end
+
+ context 'when header is not specified' do
+ let(:header) { nil }
+
+ it 'does not have a header available' do
+ expect(subject).not_to have_header
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/external/mapper/base_spec.rb b/spec/lib/gitlab/ci/config/external/mapper/base_spec.rb
index 0fdcc5e8ff7..ce8f3756cbc 100644
--- a/spec/lib/gitlab/ci/config/external/mapper/base_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper/base_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Mapper::Base, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Mapper::Base, feature_category: :pipeline_composition do
let(:test_class) do
Class.new(described_class) do
def self.name
diff --git a/spec/lib/gitlab/ci/config/external/mapper/filter_spec.rb b/spec/lib/gitlab/ci/config/external/mapper/filter_spec.rb
index df2a2f0fd01..5195567ebb4 100644
--- a/spec/lib/gitlab/ci/config/external/mapper/filter_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper/filter_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Mapper::Filter, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Mapper::Filter, feature_category: :pipeline_composition do
let_it_be(:variables) do
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'VARIABLE1', value: 'hello')
diff --git a/spec/lib/gitlab/ci/config/external/mapper/location_expander_spec.rb b/spec/lib/gitlab/ci/config/external/mapper/location_expander_spec.rb
index b14b6b0ca29..1e490bf1d16 100644
--- a/spec/lib/gitlab/ci/config/external/mapper/location_expander_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper/location_expander_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Mapper::LocationExpander, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Mapper::LocationExpander, feature_category: :pipeline_composition do
include RepoHelpers
let_it_be(:project) { create(:project, :repository) }
diff --git a/spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb b/spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb
index 11c79e19cff..719c75dca80 100644
--- a/spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Mapper::Matcher, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Mapper::Matcher, feature_category: :pipeline_composition do
let_it_be(:variables) do
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'A_MASKED_VAR', value: 'this-is-secret', masked: true)
@@ -16,28 +16,56 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Matcher, feature_category:
subject(:matcher) { described_class.new(context) }
describe '#process' do
- let(:locations) do
- [
- { local: 'file.yml' },
- { file: 'file.yml', project: 'namespace/project' },
- { component: 'gitlab.com/org/component@1.0' },
- { remote: 'https://example.com/.gitlab-ci.yml' },
- { template: 'file.yml' },
- { artifact: 'generated.yml', job: 'test' }
- ]
+ subject(:process) { matcher.process(locations) }
+
+ context 'with ci_include_components FF disabled' do
+ before do
+ stub_feature_flags(ci_include_components: false)
+ end
+
+ let(:locations) do
+ [
+ { local: 'file.yml' },
+ { file: 'file.yml', project: 'namespace/project' },
+ { remote: 'https://example.com/.gitlab-ci.yml' },
+ { template: 'file.yml' },
+ { artifact: 'generated.yml', job: 'test' }
+ ]
+ end
+
+ it 'returns an array of file objects' do
+ is_expected.to contain_exactly(
+ an_instance_of(Gitlab::Ci::Config::External::File::Local),
+ an_instance_of(Gitlab::Ci::Config::External::File::Project),
+ an_instance_of(Gitlab::Ci::Config::External::File::Remote),
+ an_instance_of(Gitlab::Ci::Config::External::File::Template),
+ an_instance_of(Gitlab::Ci::Config::External::File::Artifact)
+ )
+ end
end
- subject(:process) { matcher.process(locations) }
+ context 'with ci_include_components FF enabled' do
+ let(:locations) do
+ [
+ { local: 'file.yml' },
+ { file: 'file.yml', project: 'namespace/project' },
+ { component: 'gitlab.com/org/component@1.0' },
+ { remote: 'https://example.com/.gitlab-ci.yml' },
+ { template: 'file.yml' },
+ { artifact: 'generated.yml', job: 'test' }
+ ]
+ end
- it 'returns an array of file objects' do
- is_expected.to contain_exactly(
- an_instance_of(Gitlab::Ci::Config::External::File::Local),
- an_instance_of(Gitlab::Ci::Config::External::File::Project),
- an_instance_of(Gitlab::Ci::Config::External::File::Component),
- an_instance_of(Gitlab::Ci::Config::External::File::Remote),
- an_instance_of(Gitlab::Ci::Config::External::File::Template),
- an_instance_of(Gitlab::Ci::Config::External::File::Artifact)
- )
+ it 'returns an array of file objects' do
+ is_expected.to contain_exactly(
+ an_instance_of(Gitlab::Ci::Config::External::File::Local),
+ an_instance_of(Gitlab::Ci::Config::External::File::Project),
+ an_instance_of(Gitlab::Ci::Config::External::File::Component),
+ an_instance_of(Gitlab::Ci::Config::External::File::Remote),
+ an_instance_of(Gitlab::Ci::Config::External::File::Template),
+ an_instance_of(Gitlab::Ci::Config::External::File::Artifact)
+ )
+ end
end
context 'when a location is not valid' do
diff --git a/spec/lib/gitlab/ci/config/external/mapper/normalizer_spec.rb b/spec/lib/gitlab/ci/config/external/mapper/normalizer_spec.rb
index 709c234253b..09212833d84 100644
--- a/spec/lib/gitlab/ci/config/external/mapper/normalizer_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper/normalizer_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Mapper::Normalizer, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Mapper::Normalizer, feature_category: :pipeline_composition do
let_it_be(:variables) do
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'VARIABLE1', value: 'config')
diff --git a/spec/lib/gitlab/ci/config/external/mapper/variables_expander_spec.rb b/spec/lib/gitlab/ci/config/external/mapper/variables_expander_spec.rb
index f7454dcd4be..5def516bb1e 100644
--- a/spec/lib/gitlab/ci/config/external/mapper/variables_expander_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper/variables_expander_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Mapper::VariablesExpander, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Mapper::VariablesExpander, feature_category: :secrets_management do
let_it_be(:variables) do
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'VARIABLE1', value: 'hello')
diff --git a/spec/lib/gitlab/ci/config/external/mapper/verifier_spec.rb b/spec/lib/gitlab/ci/config/external/mapper/verifier_spec.rb
index a219666f24e..1ee46daa196 100644
--- a/spec/lib/gitlab/ci/config/external/mapper/verifier_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper/verifier_spec.rb
@@ -2,11 +2,11 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Mapper::Verifier, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Mapper::Verifier, feature_category: :pipeline_composition do
include RepoHelpers
include StubRequests
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:project) { create(:project, :small_repo) }
let_it_be(:user) { project.owner }
let(:context) do
@@ -38,7 +38,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Verifier, feature_category:
}
end
- around(:all) do |example|
+ around do |example|
create_and_delete_files(project, project_files) do
example.run
end
@@ -84,42 +84,140 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Verifier, feature_category:
end
context 'when files are project files' do
- let_it_be(:included_project) { create(:project, :repository, namespace: project.namespace, creator: user) }
+ let_it_be(:included_project1) { create(:project, :small_repo, namespace: project.namespace, creator: user) }
+ let_it_be(:included_project2) { create(:project, :small_repo, namespace: project.namespace, creator: user) }
let(:files) do
[
Gitlab::Ci::Config::External::File::Project.new(
- { file: 'myfolder/file1.yml', project: included_project.full_path }, context
+ { file: 'myfolder/file1.yml', project: included_project1.full_path }, context
),
Gitlab::Ci::Config::External::File::Project.new(
- { file: 'myfolder/file2.yml', project: included_project.full_path }, context
+ { file: 'myfolder/file2.yml', project: included_project1.full_path }, context
),
Gitlab::Ci::Config::External::File::Project.new(
- { file: 'myfolder/file3.yml', project: included_project.full_path }, context
+ { file: 'myfolder/file3.yml', project: included_project1.full_path, ref: 'master' }, context
+ ),
+ Gitlab::Ci::Config::External::File::Project.new(
+ { file: 'myfolder/file1.yml', project: included_project2.full_path }, context
+ ),
+ Gitlab::Ci::Config::External::File::Project.new(
+ { file: 'myfolder/file2.yml', project: included_project2.full_path }, context
)
]
end
- around(:all) do |example|
- create_and_delete_files(included_project, project_files) do
- example.run
+ around do |example|
+ create_and_delete_files(included_project1, project_files) do
+ create_and_delete_files(included_project2, project_files) do
+ example.run
+ end
end
end
- it 'returns an array of file objects' do
+ it 'returns an array of valid file objects' do
expect(process.map(&:location)).to contain_exactly(
- 'myfolder/file1.yml', 'myfolder/file2.yml', 'myfolder/file3.yml'
+ 'myfolder/file1.yml', 'myfolder/file2.yml', 'myfolder/file3.yml', 'myfolder/file1.yml', 'myfolder/file2.yml'
)
+
+ expect(process.all?(&:valid?)).to be_truthy
end
it 'adds files to the expandset' do
- expect { process }.to change { context.expandset.count }.by(3)
+ expect { process }.to change { context.expandset.count }.by(5)
end
it 'calls Gitaly only once for all files', :request_store do
- # 1 for project.commit.id, 3 for the sha check, 1 for the files
+ files # calling this to load project creations and the `project.commit.id` call
+
+ # 3 for the sha check, 2 for the files in batch
expect { process }.to change { Gitlab::GitalyClient.get_request_count }.by(5)
end
+
+ it 'queries with batch', :use_sql_query_cache do
+ files # calling this to load project creations and the `project.commit.id` call
+
+ queries = ActiveRecord::QueryRecorder.new(skip_cached: false) { process }
+ projects_queries = queries.occurrences_starting_with('SELECT "projects"')
+ access_check_queries = queries.occurrences_starting_with('SELECT MAX("project_authorizations"."access_level")')
+
+ # We could not reduce the number of projects queries because we need to call project for
+ # the `can_access_local_content?` and `sha` BatchLoaders.
+ expect(projects_queries.values.sum).to eq(2)
+ expect(access_check_queries.values.sum).to eq(2)
+ end
+
+ context 'when the FF ci_batch_project_includes_context is disabled' do
+ before do
+ stub_feature_flags(ci_batch_project_includes_context: false)
+ end
+
+ it 'returns an array of file objects' do
+ expect(process.map(&:location)).to contain_exactly(
+ 'myfolder/file1.yml', 'myfolder/file2.yml', 'myfolder/file3.yml',
+ 'myfolder/file1.yml', 'myfolder/file2.yml'
+ )
+ end
+
+ it 'adds files to the expandset' do
+ expect { process }.to change { context.expandset.count }.by(5)
+ end
+
+ it 'calls Gitaly for all files', :request_store do
+ files # calling this to load project creations and the `project.commit.id` call
+
+ # 5 for the sha check, 2 for the files in batch
+ expect { process }.to change { Gitlab::GitalyClient.get_request_count }.by(7)
+ end
+
+ it 'queries without batch', :use_sql_query_cache do
+ files # calling this to load project creations and the `project.commit.id` call
+
+ queries = ActiveRecord::QueryRecorder.new(skip_cached: false) { process }
+ projects_queries = queries.occurrences_starting_with('SELECT "projects"')
+ access_check_queries = queries.occurrences_starting_with(
+ 'SELECT MAX("project_authorizations"."access_level")'
+ )
+
+ expect(projects_queries.values.sum).to eq(5)
+ expect(access_check_queries.values.sum).to eq(5)
+ end
+ end
+
+ context 'when a project is missing' do
+ let(:files) do
+ [
+ Gitlab::Ci::Config::External::File::Project.new(
+ { file: 'myfolder/file1.yml', project: included_project1.full_path }, context
+ ),
+ Gitlab::Ci::Config::External::File::Project.new(
+ { file: 'myfolder/file2.yml', project: 'invalid-project' }, context
+ )
+ ]
+ end
+
+ it 'returns an array of file objects' do
+ expect(process.map(&:location)).to contain_exactly(
+ 'myfolder/file1.yml', 'myfolder/file2.yml'
+ )
+
+ expect(process.all?(&:valid?)).to be_falsey
+ end
+
+ context 'when the FF ci_batch_project_includes_context is disabled' do
+ before do
+ stub_feature_flags(ci_batch_project_includes_context: false)
+ end
+
+ it 'returns an array of file objects' do
+ expect(process.map(&:location)).to contain_exactly(
+ 'myfolder/file1.yml', 'myfolder/file2.yml'
+ )
+
+ expect(process.all?(&:valid?)).to be_falsey
+ end
+ end
+ end
end
context 'when a file includes other files' do
@@ -150,7 +248,30 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Verifier, feature_category:
end
end
- context 'when total file count exceeds max_includes' do
+ describe 'max includes detection' do
+ shared_examples 'verifies max includes' do
+ context 'when total file count is equal to max_includes' do
+ before do
+ allow(context).to receive(:max_includes).and_return(expected_total_file_count)
+ end
+
+ it 'adds the expected number of files to expandset' do
+ expect { process }.not_to raise_error
+ expect(context.expandset.count).to eq(expected_total_file_count)
+ end
+ end
+
+ context 'when total file count exceeds max_includes' do
+ before do
+ allow(context).to receive(:max_includes).and_return(expected_total_file_count - 1)
+ end
+
+ it 'raises error' do
+ expect { process }.to raise_error(expected_error_class)
+ end
+ end
+ end
+
context 'when files are nested' do
let(:files) do
[
@@ -158,9 +279,21 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Verifier, feature_category:
]
end
- it 'raises Processor::IncludeError' do
- allow(context).to receive(:max_includes).and_return(1)
- expect { process }.to raise_error(Gitlab::Ci::Config::External::Processor::IncludeError)
+ let(:expected_total_file_count) { 4 } # Includes nested_configs.yml + 3 nested files
+ let(:expected_error_class) { Gitlab::Ci::Config::External::Processor::IncludeError }
+
+ it_behaves_like 'verifies max includes'
+
+ context 'when duplicate files are included' do
+ let(:expected_total_file_count) { 8 } # 2 x (Includes nested_configs.yml + 3 nested files)
+ let(:files) do
+ [
+ Gitlab::Ci::Config::External::File::Local.new({ local: 'nested_configs.yml' }, context),
+ Gitlab::Ci::Config::External::File::Local.new({ local: 'nested_configs.yml' }, context)
+ ]
+ end
+
+ it_behaves_like 'verifies max includes'
end
end
@@ -172,34 +305,112 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Verifier, feature_category:
]
end
- it 'raises Mapper::TooManyIncludesError' do
- allow(context).to receive(:max_includes).and_return(1)
- expect { process }.to raise_error(Gitlab::Ci::Config::External::Mapper::TooManyIncludesError)
+ let(:expected_total_file_count) { files.count }
+ let(:expected_error_class) { Gitlab::Ci::Config::External::Mapper::TooManyIncludesError }
+
+ it_behaves_like 'verifies max includes'
+
+ context 'when duplicate files are included' do
+ let(:files) do
+ [
+ Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file1.yml' }, context),
+ Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file2.yml' }, context),
+ Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file2.yml' }, context)
+ ]
+ end
+
+ let(:expected_total_file_count) { files.count }
+
+ it_behaves_like 'verifies max includes'
end
end
- context 'when files are duplicates' do
+ context 'when there is a circular include' do
+ let(:project_files) do
+ {
+ 'myfolder/file1.yml' => <<~YAML
+ include: myfolder/file1.yml
+ YAML
+ }
+ end
+
let(:files) do
[
- Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file1.yml' }, context),
- Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file1.yml' }, context),
Gitlab::Ci::Config::External::File::Local.new({ local: 'myfolder/file1.yml' }, context)
]
end
+ before do
+ allow(context).to receive(:max_includes).and_return(10)
+ end
+
it 'raises error' do
- allow(context).to receive(:max_includes).and_return(2)
- expect { process }.to raise_error(Gitlab::Ci::Config::External::Mapper::TooManyIncludesError)
+ expect { process }.to raise_error(Gitlab::Ci::Config::External::Processor::IncludeError)
end
+ end
- context 'when FF ci_includes_count_duplicates is disabled' do
- before do
- stub_feature_flags(ci_includes_count_duplicates: false)
- end
+ context 'when a file is an internal include' do
+ let(:project_files) do
+ {
+ 'myfolder/file1.yml' => <<~YAML,
+ my_build:
+ script: echo Hello World
+ YAML
+ '.internal-include.yml' => <<~YAML
+ include:
+ - local: myfolder/file1.yml
+ YAML
+ }
+ end
+
+ let(:files) do
+ [
+ Gitlab::Ci::Config::External::File::Local.new({ local: '.internal-include.yml' }, context)
+ ]
+ end
- it 'does not raise error' do
- allow(context).to receive(:max_includes).and_return(2)
+ let(:total_file_count) { 2 } # Includes .internal-include.yml + myfolder/file1.yml
+ let(:pipeline_config) { instance_double(Gitlab::Ci::ProjectConfig) }
+
+ let(:context) do
+ Gitlab::Ci::Config::External::Context.new(
+ project: project,
+ user: user,
+ sha: project.commit.id,
+ pipeline_config: pipeline_config
+ )
+ end
+
+ before do
+ allow(pipeline_config).to receive(:internal_include_prepended?).and_return(true)
+ allow(context).to receive(:max_includes).and_return(1)
+ end
+
+ context 'when total file count excluding internal include is equal to max_includes' do
+ it 'does not add the internal include to expandset' do
expect { process }.not_to raise_error
+ expect(context.expandset.count).to eq(total_file_count - 1)
+ expect(context.expandset.first.location).to eq('myfolder/file1.yml')
+ end
+ end
+
+ context 'when total file count excluding internal include exceeds max_includes' do
+ let(:project_files) do
+ {
+ 'myfolder/file1.yml' => <<~YAML,
+ my_build:
+ script: echo Hello World
+ YAML
+ '.internal-include.yml' => <<~YAML
+ include:
+ - local: myfolder/file1.yml
+ - local: myfolder/file1.yml
+ YAML
+ }
+ end
+
+ it 'raises error' do
+ expect { process }.to raise_error(Gitlab::Ci::Config::External::Processor::IncludeError)
end
end
end
diff --git a/spec/lib/gitlab/ci/config/external/mapper_spec.rb b/spec/lib/gitlab/ci/config/external/mapper_spec.rb
index b3115617084..56d1ddee4b8 100644
--- a/spec/lib/gitlab/ci/config/external/mapper_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Mapper, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Mapper, feature_category: :pipeline_composition do
include StubRequests
include RepoHelpers
@@ -234,17 +234,6 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper, feature_category: :pipeline
process
expect(context.expandset.size).to eq(2)
end
-
- context 'when FF ci_includes_count_duplicates is disabled' do
- before do
- stub_feature_flags(ci_includes_count_duplicates: false)
- end
-
- it 'has expanset with one' do
- process
- expect(context.expandset.size).to eq(1)
- end
- end
end
context 'when passing max number of files' do
diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb
index bb65c2ef10c..74afb3b1e97 100644
--- a/spec/lib/gitlab/ci/config/external/processor_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Processor, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Processor, feature_category: :pipeline_composition do
include StubRequests
include RepoHelpers
@@ -221,7 +221,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor, feature_category: :pipel
it 'raises an error' do
expect { processor.perform }.to raise_error(
described_class::IncludeError,
- "Included file `lib/gitlab/ci/templates/template.yml` does not have valid YAML syntax!"
+ '`lib/gitlab/ci/templates/template.yml`: content does not have a valid YAML syntax'
)
end
end
diff --git a/spec/lib/gitlab/ci/config/external/rules_spec.rb b/spec/lib/gitlab/ci/config/external/rules_spec.rb
index 227b62d8ce8..cc73338b5a8 100644
--- a/spec/lib/gitlab/ci/config/external/rules_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/rules_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::External::Rules, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::External::Rules, feature_category: :pipeline_composition do
let(:rule_hashes) {}
subject(:rules) { described_class.new(rule_hashes) }
diff --git a/spec/lib/gitlab/ci/config/header/input_spec.rb b/spec/lib/gitlab/ci/config/header/input_spec.rb
new file mode 100644
index 00000000000..73b5b8f9497
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/header/input_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Header::Input, feature_category: :pipeline_composition do
+ let(:factory) do
+ Gitlab::Config::Entry::Factory
+ .new(described_class)
+ .value(input_hash)
+ .with(key: input_name)
+ end
+
+ let(:input_name) { 'foo' }
+
+ subject(:config) { factory.create!.tap(&:compose!) }
+
+ shared_examples 'a valid input' do
+ let(:expected_hash) { input_hash }
+
+ it 'passes validations' do
+ expect(config).to be_valid
+ expect(config.errors).to be_empty
+ end
+
+ it 'returns the value' do
+ expect(config.value).to eq(expected_hash)
+ end
+ end
+
+ shared_examples 'an invalid input' do
+ let(:expected_hash) { input_hash }
+
+ it 'fails validations' do
+ expect(config).not_to be_valid
+ expect(config.errors).to eq(expected_errors)
+ end
+
+ it 'returns the value' do
+ expect(config.value).to eq(expected_hash)
+ end
+ end
+
+ context 'when has a default value' do
+ let(:input_hash) { { default: 'bar' } }
+
+ it_behaves_like 'a valid input'
+ end
+
+ context 'when is a required required input' do
+ let(:input_hash) { nil }
+
+ it_behaves_like 'a valid input'
+ end
+
+ context 'when contains unknown keywords' do
+ let(:input_hash) { { test: 123 } }
+ let(:expected_errors) { ['foo config contains unknown keys: test'] }
+
+ it_behaves_like 'an invalid input'
+ end
+
+ context 'when has invalid name' do
+ let(:input_name) { [123] }
+ let(:input_hash) { {} }
+
+ let(:expected_errors) { ['123 key must be an alphanumeric string'] }
+
+ it_behaves_like 'an invalid input'
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/header/root_spec.rb b/spec/lib/gitlab/ci/config/header/root_spec.rb
new file mode 100644
index 00000000000..55f77137619
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/header/root_spec.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Header::Root, feature_category: :pipeline_composition do
+ let(:factory) { Gitlab::Config::Entry::Factory.new(described_class).value(header_hash) }
+
+ subject(:config) { factory.create!.tap(&:compose!) }
+
+ shared_examples 'a valid header' do
+ let(:expected_hash) { header_hash }
+
+ it 'passes validations' do
+ expect(config).to be_valid
+ expect(config.errors).to be_empty
+ end
+
+ it 'returns the value' do
+ expect(config.value).to eq(expected_hash)
+ end
+ end
+
+ shared_examples 'an invalid header' do
+ let(:expected_hash) { header_hash }
+
+ it 'fails validations' do
+ expect(config).not_to be_valid
+ expect(config.errors).to eq(expected_errors)
+ end
+
+ it 'returns the value' do
+ expect(config.value).to eq(expected_hash)
+ end
+ end
+
+ context 'when header contains default and required values for inputs' do
+ let(:header_hash) do
+ {
+ spec: {
+ inputs: {
+ test: {},
+ foo: {
+ default: 'bar'
+ }
+ }
+ }
+ }
+ end
+
+ it_behaves_like 'a valid header'
+ end
+
+ context 'when header contains minimal data' do
+ let(:header_hash) do
+ {
+ spec: {
+ inputs: nil
+ }
+ }
+ end
+
+ it_behaves_like 'a valid header' do
+ let(:expected_hash) { { spec: {} } }
+ end
+ end
+
+ context 'when header contains required inputs' do
+ let(:header_hash) do
+ {
+ spec: {
+ inputs: { foo: nil }
+ }
+ }
+ end
+
+ it_behaves_like 'a valid header' do
+ let(:expected_hash) do
+ {
+ spec: {
+ inputs: { foo: {} }
+ }
+ }
+ end
+ end
+ end
+
+ context 'when header contains unknown keywords' do
+ let(:header_hash) { { test: 123 } }
+ let(:expected_errors) { ['root config contains unknown keys: test'] }
+
+ it_behaves_like 'an invalid header'
+ end
+
+ context 'when header input entry has an unknown key' do
+ let(:header_hash) do
+ {
+ spec: {
+ inputs: {
+ foo: {
+ bad: 'value'
+ }
+ }
+ }
+ }
+ end
+
+ let(:expected_errors) { ['spec:inputs:foo config contains unknown keys: bad'] }
+
+ it_behaves_like 'an invalid header'
+ end
+
+ describe '#inputs_value' do
+ let(:header_hash) do
+ {
+ spec: {
+ inputs: {
+ foo: nil,
+ bar: {
+ default: 'baz'
+ }
+ }
+ }
+ }
+ end
+
+ it 'returns the inputs' do
+ expect(config.inputs_value).to eq({
+ foo: {},
+ bar: { default: 'baz' }
+ })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/header/spec_spec.rb b/spec/lib/gitlab/ci/config/header/spec_spec.rb
new file mode 100644
index 00000000000..74cfb39dfd5
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/header/spec_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Header::Spec, feature_category: :pipeline_composition do
+ let(:factory) { Gitlab::Config::Entry::Factory.new(described_class).value(spec_hash) }
+
+ subject(:config) { factory.create!.tap(&:compose!) }
+
+ context 'when spec contains default values for inputs' do
+ let(:spec_hash) do
+ {
+ inputs: {
+ foo: {
+ default: 'bar'
+ }
+ }
+ }
+ end
+
+ it 'passes validations' do
+ expect(config).to be_valid
+ expect(config.errors).to be_empty
+ end
+
+ it 'returns the value' do
+ expect(config.value).to eq(spec_hash)
+ end
+ end
+
+ context 'when spec contains a required value' do
+ let(:spec_hash) do
+ { inputs: { foo: nil } }
+ end
+
+ it 'parses the config correctly' do
+ expect(config).to be_valid
+ expect(config.errors).to be_empty
+ expect(config.value).to eq({ inputs: { foo: {} } })
+ end
+ end
+
+ context 'when spec contains unknown keywords' do
+ let(:spec_hash) { { test: 123 } }
+ let(:expected_errors) { ['spec config contains unknown keys: test'] }
+
+ it 'fails validations' do
+ expect(config).not_to be_valid
+ expect(config.errors).to eq(expected_errors)
+ end
+
+ it 'returns the value' do
+ expect(config.value).to eq(spec_hash)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/normalizer/number_strategy_spec.rb b/spec/lib/gitlab/ci/config/normalizer/number_strategy_spec.rb
index 06f47fe11c6..965963d40cd 100644
--- a/spec/lib/gitlab/ci/config/normalizer/number_strategy_spec.rb
+++ b/spec/lib/gitlab/ci/config/normalizer/number_strategy_spec.rb
@@ -53,6 +53,22 @@ RSpec.describe Gitlab::Ci::Config::Normalizer::NumberStrategy do
end
end
+ shared_examples 'single parallelized job' do
+ it { expect(subject.size).to eq(1) }
+
+ it 'has attributes' do
+ expect(subject.map(&:attributes)).to match_array(
+ [
+ { name: 'test 1/1', instance: 1, parallel: { total: 1 } }
+ ]
+ )
+ end
+
+ it 'has parallelized name' do
+ expect(subject.map(&:name)).to match_array(['test 1/1'])
+ end
+ end
+
context 'with numbers' do
let(:config) { 3 }
@@ -64,5 +80,11 @@ RSpec.describe Gitlab::Ci::Config::Normalizer::NumberStrategy do
it_behaves_like 'parallelized job'
end
+
+ context 'with one' do
+ let(:config) { 1 }
+
+ it_behaves_like 'single parallelized job'
+ end
end
end
diff --git a/spec/lib/gitlab/ci/config/yaml/result_spec.rb b/spec/lib/gitlab/ci/config/yaml/result_spec.rb
new file mode 100644
index 00000000000..72d96349668
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/yaml/result_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Yaml::Result, feature_category: :pipeline_composition do
+ it 'does not have a header when config is a single hash' do
+ result = described_class.new(config: { a: 1, b: 2 })
+
+ expect(result).not_to have_header
+ end
+
+ context 'when config is an array of hashes' do
+ context 'when first document matches the header schema' do
+ it 'has a header' do
+ result = described_class.new(config: [{ spec: { inputs: {} } }, { b: 2 }])
+
+ expect(result).to have_header
+ expect(result.header).to eq({ spec: { inputs: {} } })
+ expect(result.content).to eq({ b: 2 })
+ end
+ end
+
+ context 'when first document does not match the header schema' do
+ it 'does not have header' do
+ result = described_class.new(config: [{ a: 1 }, { b: 2 }])
+
+ expect(result).not_to have_header
+ expect(result.content).to eq({ a: 1 })
+ end
+ end
+ end
+
+ context 'when the first document is undefined' do
+ it 'does not have header' do
+ result = described_class.new(config: [nil, { a: 1 }])
+
+ expect(result).not_to have_header
+ expect(result.content).to be_nil
+ end
+ end
+
+ it 'raises an error when reading a header when there is none' do
+ result = described_class.new(config: { b: 2 })
+
+ expect { result.header }.to raise_error(ArgumentError)
+ end
+
+ it 'stores an error / exception when initialized with it' do
+ result = described_class.new(error: ArgumentError.new('abc'))
+
+ expect(result).not_to be_valid
+ expect(result.error).to be_a ArgumentError
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/yaml_spec.rb b/spec/lib/gitlab/ci/config/yaml_spec.rb
index 4b34553f55e..beb872071d2 100644
--- a/spec/lib/gitlab/ci/config/yaml_spec.rb
+++ b/spec/lib/gitlab/ci/config/yaml_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config::Yaml, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config::Yaml, feature_category: :pipeline_composition do
describe '.load!' do
it 'loads a single-doc YAML file' do
yaml = <<~YAML
@@ -50,6 +50,15 @@ RSpec.describe Gitlab::Ci::Config::Yaml, feature_category: :pipeline_authoring d
})
end
+ context 'when YAML is invalid' do
+ let(:yaml) { 'some: invalid: syntax' }
+
+ it 'raises an error' do
+ expect { described_class.load!(yaml) }
+ .to raise_error ::Gitlab::Config::Loader::FormatError, /mapping values are not allowed in this context/
+ end
+ end
+
context 'when ci_multi_doc_yaml is disabled' do
before do
stub_feature_flags(ci_multi_doc_yaml: false)
@@ -102,4 +111,152 @@ RSpec.describe Gitlab::Ci::Config::Yaml, feature_category: :pipeline_authoring d
end
end
end
+
+ describe '.load_result!' do
+ let_it_be(:project) { create(:project) }
+
+ subject(:result) { described_class.load_result!(yaml, project: project) }
+
+ context 'when syntax is invalid' do
+ let(:yaml) { 'some: invalid: syntax' }
+
+ it 'returns an invalid result object' do
+ expect(result).not_to be_valid
+ expect(result.error).to be_a ::Gitlab::Config::Loader::FormatError
+ end
+ end
+
+ context 'when the first document is a header' do
+ context 'with explicit document start marker' do
+ let(:yaml) do
+ <<~YAML
+ ---
+ spec:
+ ---
+ b: 2
+ YAML
+ end
+
+ it 'considers the first document as header and the second as content' do
+ expect(result).to be_valid
+ expect(result.error).to be_nil
+ expect(result.header).to eq({ spec: nil })
+ expect(result.content).to eq({ b: 2 })
+ end
+ end
+ end
+
+ context 'when first document is empty' do
+ let(:yaml) do
+ <<~YAML
+ ---
+ ---
+ b: 2
+ YAML
+ end
+
+ it 'considers the first document as header and the second as content' do
+ expect(result).not_to have_header
+ end
+ end
+
+ context 'when first document is an empty hash' do
+ let(:yaml) do
+ <<~YAML
+ {}
+ ---
+ b: 2
+ YAML
+ end
+
+ it 'returns second document as a content' do
+ expect(result).not_to have_header
+ expect(result.content).to eq({ b: 2 })
+ end
+ end
+
+ context 'when first an array' do
+ let(:yaml) do
+ <<~YAML
+ ---
+ - a
+ - b
+ ---
+ b: 2
+ YAML
+ end
+
+ it 'considers the first document as header and the second as content' do
+ expect(result).not_to have_header
+ end
+ end
+
+ context 'when the first document is not a header' do
+ let(:yaml) do
+ <<~YAML
+ a: 1
+ ---
+ b: 2
+ YAML
+ end
+
+ it 'considers the first document as content for backwards compatibility' do
+ expect(result).to be_valid
+ expect(result.error).to be_nil
+ expect(result).not_to have_header
+ expect(result.content).to eq({ a: 1 })
+ end
+
+ context 'with explicit document start marker' do
+ let(:yaml) do
+ <<~YAML
+ ---
+ a: 1
+ ---
+ b: 2
+ YAML
+ end
+
+ it 'considers the first document as content for backwards compatibility' do
+ expect(result).to be_valid
+ expect(result.error).to be_nil
+ expect(result).not_to have_header
+ expect(result.content).to eq({ a: 1 })
+ end
+ end
+ end
+
+ context 'when the first document is not a header and second document is empty' do
+ let(:yaml) do
+ <<~YAML
+ a: 1
+ ---
+ YAML
+ end
+
+ it 'considers the first document as content' do
+ expect(result).to be_valid
+ expect(result.error).to be_nil
+ expect(result).not_to have_header
+ expect(result.content).to eq({ a: 1 })
+ end
+
+ context 'with explicit document start marker' do
+ let(:yaml) do
+ <<~YAML
+ ---
+ a: 1
+ ---
+ YAML
+ end
+
+ it 'considers the first document as content' do
+ expect(result).to be_valid
+ expect(result.error).to be_nil
+ expect(result).not_to have_header
+ expect(result.content).to eq({ a: 1 })
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb
index 5cdc9c21561..fdf152b3584 100644
--- a/spec/lib/gitlab/ci/config_spec.rb
+++ b/spec/lib/gitlab/ci/config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Config, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Config, feature_category: :pipeline_composition do
include StubRequests
include RepoHelpers
diff --git a/spec/lib/gitlab/ci/input/arguments/base_spec.rb b/spec/lib/gitlab/ci/input/arguments/base_spec.rb
new file mode 100644
index 00000000000..ed8e99b7257
--- /dev/null
+++ b/spec/lib/gitlab/ci/input/arguments/base_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Ci::Input::Arguments::Base, feature_category: :pipeline_composition do
+ subject do
+ Class.new(described_class) do
+ def validate!; end
+ def to_value; end
+ end
+ end
+
+ it 'fabricates an invalid input argument if unknown value is provided' do
+ argument = subject.new(:something, { spec: 123 }, [:a, :b])
+
+ expect(argument).not_to be_valid
+ expect(argument.errors.first).to eq 'unsupported value in input argument `something`'
+ end
+end
diff --git a/spec/lib/gitlab/ci/input/arguments/default_spec.rb b/spec/lib/gitlab/ci/input/arguments/default_spec.rb
new file mode 100644
index 00000000000..bc0cee6ac4e
--- /dev/null
+++ b/spec/lib/gitlab/ci/input/arguments/default_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Ci::Input::Arguments::Default, feature_category: :pipeline_composition do
+ it 'returns a user-provided value if it is present' do
+ argument = described_class.new(:website, { default: 'https://gitlab.com' }, 'https://example.gitlab.com')
+
+ expect(argument).to be_valid
+ expect(argument.to_value).to eq 'https://example.gitlab.com'
+ expect(argument.to_hash).to eq({ website: 'https://example.gitlab.com' })
+ end
+
+ it 'returns an empty value if user-provider input is empty' do
+ argument = described_class.new(:website, { default: 'https://gitlab.com' }, '')
+
+ expect(argument).to be_valid
+ expect(argument.to_value).to eq ''
+ expect(argument.to_hash).to eq({ website: '' })
+ end
+
+ it 'returns a default value if user-provider one is unknown' do
+ argument = described_class.new(:website, { default: 'https://gitlab.com' }, nil)
+
+ expect(argument).to be_valid
+ expect(argument.to_value).to eq 'https://gitlab.com'
+ expect(argument.to_hash).to eq({ website: 'https://gitlab.com' })
+ end
+
+ it 'returns an error if the default argument has not been recognized' do
+ argument = described_class.new(:website, { default: ['gitlab.com'] }, 'abc')
+
+ expect(argument).not_to be_valid
+ end
+
+ it 'returns an error if the argument has not been fabricated correctly' do
+ argument = described_class.new(:website, { required: 'https://gitlab.com' }, 'https://example.gitlab.com')
+
+ expect(argument).not_to be_valid
+ end
+
+ describe '.matches?' do
+ it 'matches specs with default configuration' do
+ expect(described_class.matches?({ default: 'abc' })).to be true
+ end
+
+ it 'does not match specs different configuration keyword' do
+ expect(described_class.matches?({ options: %w[a b] })).to be false
+ expect(described_class.matches?('a b c')).to be false
+ expect(described_class.matches?(%w[default a])).to be false
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/input/arguments/options_spec.rb b/spec/lib/gitlab/ci/input/arguments/options_spec.rb
new file mode 100644
index 00000000000..17e3469b294
--- /dev/null
+++ b/spec/lib/gitlab/ci/input/arguments/options_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Ci::Input::Arguments::Options, feature_category: :pipeline_composition do
+ it 'returns a user-provided value if it is an allowed one' do
+ argument = described_class.new(:run, { options: %w[opt1 opt2] }, 'opt1')
+
+ expect(argument).to be_valid
+ expect(argument.to_value).to eq 'opt1'
+ expect(argument.to_hash).to eq({ run: 'opt1' })
+ end
+
+ it 'returns an error if user-provided value is not allowlisted' do
+ argument = described_class.new(:run, { options: %w[opt1 opt2] }, 'opt3')
+
+ expect(argument).not_to be_valid
+ expect(argument.errors.first).to eq '`run` input: argument value opt3 not allowlisted'
+ end
+
+ it 'returns an error if specification is not correct' do
+ argument = described_class.new(:website, { options: nil }, 'opt1')
+
+ expect(argument).not_to be_valid
+ expect(argument.errors.first).to eq '`website` input: argument specification invalid'
+ end
+
+ it 'returns an error if specification is using a hash' do
+ argument = described_class.new(:website, { options: { a: 1 } }, 'opt1')
+
+ expect(argument).not_to be_valid
+ expect(argument.errors.first).to eq '`website` input: argument specification invalid'
+ end
+
+ it 'returns an empty value if it is allowlisted' do
+ argument = described_class.new(:run, { options: ['opt1', ''] }, '')
+
+ expect(argument).to be_valid
+ expect(argument.to_value).to be_empty
+ expect(argument.to_hash).to eq({ run: '' })
+ end
+
+ describe '.matches?' do
+ it 'matches specs with options configuration' do
+ expect(described_class.matches?({ options: %w[a b] })).to be true
+ end
+
+ it 'does not match specs different configuration keyword' do
+ expect(described_class.matches?({ default: 'abc' })).to be false
+ expect(described_class.matches?(['options'])).to be false
+ expect(described_class.matches?('options')).to be false
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/input/arguments/required_spec.rb b/spec/lib/gitlab/ci/input/arguments/required_spec.rb
new file mode 100644
index 00000000000..847272998c2
--- /dev/null
+++ b/spec/lib/gitlab/ci/input/arguments/required_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Ci::Input::Arguments::Required, feature_category: :pipeline_composition do
+ it 'returns a user-provided value if it is present' do
+ argument = described_class.new(:website, nil, 'https://example.gitlab.com')
+
+ expect(argument).to be_valid
+ expect(argument.to_value).to eq 'https://example.gitlab.com'
+ expect(argument.to_hash).to eq({ website: 'https://example.gitlab.com' })
+ end
+
+ it 'returns an empty value if user-provider value is empty' do
+ argument = described_class.new(:website, nil, '')
+
+ expect(argument).to be_valid
+ expect(argument.to_hash).to eq(website: '')
+ end
+
+ it 'returns an error if user-provided value is unspecified' do
+ argument = described_class.new(:website, nil, nil)
+
+ expect(argument).not_to be_valid
+ expect(argument.errors.first).to eq '`website` input: required value has not been provided'
+ end
+
+ describe '.matches?' do
+ it 'matches specs without configuration' do
+ expect(described_class.matches?(nil)).to be true
+ end
+
+ it 'matches specs with empty configuration' do
+ expect(described_class.matches?('')).to be true
+ end
+
+ it 'matches specs with an empty hash configuration' do
+ expect(described_class.matches?({})).to be true
+ end
+
+ it 'does not match specs with configuration' do
+ expect(described_class.matches?({ options: %w[a b] })).to be false
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/input/arguments/unknown_spec.rb b/spec/lib/gitlab/ci/input/arguments/unknown_spec.rb
new file mode 100644
index 00000000000..1270423ac72
--- /dev/null
+++ b/spec/lib/gitlab/ci/input/arguments/unknown_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Ci::Input::Arguments::Unknown, feature_category: :pipeline_composition do
+ it 'raises an error when someone tries to evaluate the value' do
+ argument = described_class.new(:website, nil, 'https://example.gitlab.com')
+
+ expect(argument).not_to be_valid
+ expect { argument.to_value }.to raise_error ArgumentError
+ end
+
+ describe '.matches?' do
+ it 'always matches' do
+ expect(described_class.matches?('abc')).to be true
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/input/inputs_spec.rb b/spec/lib/gitlab/ci/input/inputs_spec.rb
new file mode 100644
index 00000000000..5d2d5192299
--- /dev/null
+++ b/spec/lib/gitlab/ci/input/inputs_spec.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Ci::Input::Inputs, feature_category: :pipeline_composition do
+ describe '#valid?' do
+ let(:spec) { { website: nil } }
+
+ it 'describes user-provided inputs' do
+ inputs = described_class.new(spec, { website: 'http://example.gitlab.com' })
+
+ expect(inputs).to be_valid
+ end
+ end
+
+ context 'when proper specification has been provided' do
+ let(:spec) do
+ {
+ website: nil,
+ env: { default: 'development' },
+ run: { options: %w[tests spec e2e] }
+ }
+ end
+
+ let(:args) { { website: 'https://gitlab.com', run: 'tests' } }
+
+ it 'fabricates desired input arguments' do
+ inputs = described_class.new(spec, args)
+
+ expect(inputs).to be_valid
+ expect(inputs.count).to eq 3
+ expect(inputs.to_hash).to eq(args.merge(env: 'development'))
+ end
+ end
+
+ context 'when inputs and args are empty' do
+ it 'is a valid use-case' do
+ inputs = described_class.new({}, {})
+
+ expect(inputs).to be_valid
+ expect(inputs.to_hash).to be_empty
+ end
+ end
+
+ context 'when there are arguments recoincilation errors present' do
+ context 'when required argument is missing' do
+ let(:spec) { { website: nil } }
+
+ it 'returns an error' do
+ inputs = described_class.new(spec, {})
+
+ expect(inputs).not_to be_valid
+ expect(inputs.errors.first).to eq '`website` input: required value has not been provided'
+ end
+ end
+
+ context 'when argument is not present but configured as allowlist' do
+ let(:spec) do
+ { run: { options: %w[opt1 opt2] } }
+ end
+
+ it 'returns an error' do
+ inputs = described_class.new(spec, {})
+
+ expect(inputs).not_to be_valid
+ expect(inputs.errors.first).to eq '`run` input: argument not provided'
+ end
+ end
+ end
+
+ context 'when unknown specification argument has been used' do
+ let(:spec) do
+ {
+ website: nil,
+ env: { default: 'development' },
+ run: { options: %w[tests spec e2e] },
+ test: { unknown: 'something' }
+ }
+ end
+
+ let(:args) { { website: 'https://gitlab.com', run: 'tests' } }
+
+ it 'fabricates an unknown argument entry and returns an error' do
+ inputs = described_class.new(spec, args)
+
+ expect(inputs).not_to be_valid
+ expect(inputs.count).to eq 4
+ expect(inputs.errors.first).to eq '`test` input: unrecognized input argument specification: `unknown`'
+ end
+ end
+
+ context 'when unknown arguments are being passed by a user' do
+ let(:spec) do
+ { env: { default: 'development' } }
+ end
+
+ let(:args) { { website: 'https://gitlab.com', run: 'tests' } }
+
+ it 'returns an error with a list of unknown arguments' do
+ inputs = described_class.new(spec, args)
+
+ expect(inputs).not_to be_valid
+ expect(inputs.errors.first).to eq 'unknown input arguments: [:website, :run]'
+ end
+ end
+
+ context 'when composite specification is being used' do
+ let(:spec) do
+ {
+ env: {
+ default: 'dev',
+ options: %w[test dev prod]
+ }
+ }
+ end
+
+ let(:args) { { env: 'dev' } }
+
+ it 'returns an error describing an unknown specification' do
+ inputs = described_class.new(spec, args)
+
+ expect(inputs).not_to be_valid
+ expect(inputs.errors.first).to eq '`env` input: unrecognized input argument definition'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/interpolation/access_spec.rb b/spec/lib/gitlab/ci/interpolation/access_spec.rb
index 9f6108a328d..f327377b7e3 100644
--- a/spec/lib/gitlab/ci/interpolation/access_spec.rb
+++ b/spec/lib/gitlab/ci/interpolation/access_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::Ci::Interpolation::Access, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Interpolation::Access, feature_category: :pipeline_composition do
subject { described_class.new(access, ctx) }
let(:access) do
diff --git a/spec/lib/gitlab/ci/interpolation/block_spec.rb b/spec/lib/gitlab/ci/interpolation/block_spec.rb
index 7f2be505d17..4a8709df3dc 100644
--- a/spec/lib/gitlab/ci/interpolation/block_spec.rb
+++ b/spec/lib/gitlab/ci/interpolation/block_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::Ci::Interpolation::Block, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Interpolation::Block, feature_category: :pipeline_composition do
subject { described_class.new(block, data, ctx) }
let(:data) do
diff --git a/spec/lib/gitlab/ci/interpolation/config_spec.rb b/spec/lib/gitlab/ci/interpolation/config_spec.rb
index e5987776e00..e745269d8c0 100644
--- a/spec/lib/gitlab/ci/interpolation/config_spec.rb
+++ b/spec/lib/gitlab/ci/interpolation/config_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::Ci::Interpolation::Config, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Interpolation::Config, feature_category: :pipeline_composition do
subject { described_class.new(YAML.safe_load(config)) }
let(:config) do
diff --git a/spec/lib/gitlab/ci/interpolation/context_spec.rb b/spec/lib/gitlab/ci/interpolation/context_spec.rb
index ada896f4980..2b126f4a8b3 100644
--- a/spec/lib/gitlab/ci/interpolation/context_spec.rb
+++ b/spec/lib/gitlab/ci/interpolation/context_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::Ci::Interpolation::Context, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Interpolation::Context, feature_category: :pipeline_composition do
subject { described_class.new(ctx) }
let(:ctx) do
diff --git a/spec/lib/gitlab/ci/interpolation/template_spec.rb b/spec/lib/gitlab/ci/interpolation/template_spec.rb
index 8a243b4db05..a3ef1bb4445 100644
--- a/spec/lib/gitlab/ci/interpolation/template_spec.rb
+++ b/spec/lib/gitlab/ci/interpolation/template_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::Ci::Interpolation::Template, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Interpolation::Template, feature_category: :pipeline_composition do
subject { described_class.new(YAML.safe_load(config), ctx) }
let(:config) do
diff --git a/spec/lib/gitlab/ci/jwt_spec.rb b/spec/lib/gitlab/ci/jwt_spec.rb
index 147801b6217..a6de5b9879c 100644
--- a/spec/lib/gitlab/ci/jwt_spec.rb
+++ b/spec/lib/gitlab/ci/jwt_spec.rb
@@ -58,26 +58,31 @@ RSpec.describe Gitlab::Ci::Jwt do
expect { payload }.not_to raise_error
end
- describe 'ref type' do
- context 'branches' do
+ describe 'references' do
+ context 'with a branch pipepline' do
it 'is "branch"' do
expect(payload[:ref_type]).to eq('branch')
+ expect(payload[:ref_path]).to eq('refs/heads/auto-deploy-2020-03-19')
end
end
- context 'tags' do
- let(:build) { build_stubbed(:ci_build, :on_tag, project: project) }
+ context 'with a tag pipeline' do
+ let(:pipeline) { build_stubbed(:ci_pipeline, ref: 'auto-deploy-2020-03-19', tag: true) }
+ let(:build) { build_stubbed(:ci_build, :on_tag, project: project, pipeline: pipeline) }
it 'is "tag"' do
expect(payload[:ref_type]).to eq('tag')
+ expect(payload[:ref_path]).to eq('refs/tags/auto-deploy-2020-03-19')
end
end
- context 'merge requests' do
- let(:pipeline) { build_stubbed(:ci_pipeline, :detached_merge_request_pipeline) }
+ context 'with a merge request pipeline' do
+ let(:merge_request) { build_stubbed(:merge_request, source_branch: 'feature-branch') }
+ let(:pipeline) { build_stubbed(:ci_pipeline, :detached_merge_request_pipeline, merge_request: merge_request) }
it 'is "branch"' do
expect(payload[:ref_type]).to eq('branch')
+ expect(payload[:ref_path]).to eq('refs/heads/feature-branch')
end
end
end
diff --git a/spec/lib/gitlab/ci/jwt_v2_spec.rb b/spec/lib/gitlab/ci/jwt_v2_spec.rb
index 5eeab658a8e..528be4b5da7 100644
--- a/spec/lib/gitlab/ci/jwt_v2_spec.rb
+++ b/spec/lib/gitlab/ci/jwt_v2_spec.rb
@@ -2,11 +2,18 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::JwtV2 do
+RSpec.describe Gitlab::Ci::JwtV2, feature_category: :continuous_integration do
let(:namespace) { build_stubbed(:namespace) }
let(:project) { build_stubbed(:project, namespace: namespace) }
- let(:user) { build_stubbed(:user) }
+ let(:user) do
+ build_stubbed(
+ :user,
+ identities: [build_stubbed(:identity, extern_uid: '1', provider: 'github')]
+ )
+ end
+
let(:pipeline) { build_stubbed(:ci_pipeline, ref: 'auto-deploy-2020-03-19') }
+ let(:runner) { build_stubbed(:ci_runner) }
let(:aud) { described_class::DEFAULT_AUD }
let(:build) do
@@ -14,7 +21,8 @@ RSpec.describe Gitlab::Ci::JwtV2 do
:ci_build,
project: project,
user: user,
- pipeline: pipeline
+ pipeline: pipeline,
+ runner: runner
)
end
@@ -33,6 +41,18 @@ RSpec.describe Gitlab::Ci::JwtV2 do
end
end
+ it 'includes user identities when enabled' do
+ expect(user).to receive(:pass_user_identities_to_ci_jwt).and_return(true)
+ identities = payload[:user_identities].map { |identity| identity.slice(:extern_uid, :provider) }
+ expect(identities).to eq([{ extern_uid: '1', provider: 'github' }])
+ end
+
+ it 'does not include user identities when disabled' do
+ expect(user).to receive(:pass_user_identities_to_ci_jwt).and_return(false)
+
+ expect(payload).not_to include(:user_identities)
+ end
+
context 'when given an aud' do
let(:aud) { 'AWS' }
@@ -40,5 +60,57 @@ RSpec.describe Gitlab::Ci::JwtV2 do
expect(payload[:aud]).to eq('AWS')
end
end
+
+ describe 'custom claims' do
+ describe 'runner_id' do
+ it 'is the ID of the runner executing the job' do
+ expect(payload[:runner_id]).to eq(runner.id)
+ end
+
+ context 'when build is not associated with a runner' do
+ let(:runner) { nil }
+
+ it 'is nil' do
+ expect(payload[:runner_id]).to be_nil
+ end
+ end
+ end
+
+ describe 'runner_environment' do
+ context 'when runner is gitlab-hosted' do
+ before do
+ allow(runner).to receive(:gitlab_hosted?).and_return(true)
+ end
+
+ it "is #{described_class::GITLAB_HOSTED_RUNNER}" do
+ expect(payload[:runner_environment]).to eq(described_class::GITLAB_HOSTED_RUNNER)
+ end
+ end
+
+ context 'when runner is self-hosted' do
+ before do
+ allow(runner).to receive(:gitlab_hosted?).and_return(false)
+ end
+
+ it "is #{described_class::SELF_HOSTED_RUNNER}" do
+ expect(payload[:runner_environment]).to eq(described_class::SELF_HOSTED_RUNNER)
+ end
+ end
+
+ context 'when build is not associated with a runner' do
+ let(:runner) { nil }
+
+ it 'is nil' do
+ expect(payload[:runner_environment]).to be_nil
+ end
+ end
+ end
+
+ describe 'sha' do
+ it 'is the commit revision the project is built for' do
+ expect(payload[:sha]).to eq(pipeline.sha)
+ end
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/lint_spec.rb b/spec/lib/gitlab/ci/lint_spec.rb
index b836ca395fa..b238e9161eb 100644
--- a/spec/lib/gitlab/ci/lint_spec.rb
+++ b/spec/lib/gitlab/ci/lint_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Lint, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Lint, feature_category: :pipeline_composition do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
@@ -100,8 +100,8 @@ RSpec.describe Gitlab::Ci::Lint, feature_category: :pipeline_authoring do
end
it 'sets merged_config' do
- root_config = YAML.safe_load(content, [Symbol])
- included_config = YAML.safe_load(included_content, [Symbol])
+ root_config = YAML.safe_load(content, permitted_classes: [Symbol])
+ included_config = YAML.safe_load(included_content, permitted_classes: [Symbol])
expected_config = included_config.merge(root_config).except(:include).deep_stringify_keys
expect(subject.merged_yaml).to eq(expected_config.to_yaml)
diff --git a/spec/lib/gitlab/ci/parsers/security/common_spec.rb b/spec/lib/gitlab/ci/parsers/security/common_spec.rb
index 5d2d22c04fc..421aa29f860 100644
--- a/spec/lib/gitlab/ci/parsers/security/common_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/security/common_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Parsers::Security::Common do
+RSpec.describe Gitlab::Ci::Parsers::Security::Common, feature_category: :vulnerability_management do
describe '#parse!' do
let_it_be(:scanner_data) do
{
@@ -410,6 +410,12 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
end
end
+ describe 'setting the `found_by_pipeline` attribute' do
+ subject { report.findings.map(&:found_by_pipeline).uniq }
+
+ it { is_expected.to eq([pipeline]) }
+ end
+
describe 'parsing tracking' do
let(:finding) { report.findings.first }
diff --git a/spec/lib/gitlab/ci/parsers/security/sast_spec.rb b/spec/lib/gitlab/ci/parsers/security/sast_spec.rb
index f6113308201..d1ce6808d23 100644
--- a/spec/lib/gitlab/ci/parsers/security/sast_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/security/sast_spec.rb
@@ -13,8 +13,8 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Sast do
context "when passing valid report" do
# rubocop: disable Layout/LineLength
where(:report_format, :report_version, :scanner_length, :finding_length, :identifier_length, :file_path, :start_line, :end_line, :primary_identifiers_length) do
- :sast | '14.0.0' | 1 | 5 | 6 | 'groovy/src/main/java/com/gitlab/security_products/tests/App.groovy' | 47 | 47 | nil
- :sast_semgrep_for_multiple_findings | '14.0.4' | 1 | 2 | 6 | 'app/app.py' | 39 | nil | 2
+ :sast | '15.0.0' | 1 | 5 | 6 | 'groovy/src/main/java/com/gitlab/security_products/tests/App.groovy' | 47 | 47 | nil
+ :sast_semgrep_for_multiple_findings | '15.0.4' | 1 | 2 | 6 | 'app/app.py' | 39 | nil | 2
end
# rubocop: enable Layout/LineLength
diff --git a/spec/lib/gitlab/ci/parsers/security/secret_detection_spec.rb b/spec/lib/gitlab/ci/parsers/security/secret_detection_spec.rb
index e8f1d617cb7..13999b2a9e5 100644
--- a/spec/lib/gitlab/ci/parsers/security/secret_detection_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/security/secret_detection_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::SecretDetection do
end
it "generates expected metadata_version" do
- expect(report.findings.first.metadata_version).to eq('14.1.2')
+ expect(report.findings.first.metadata_version).to eq('15.0.0')
end
end
end
diff --git a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
index 5fbaae58a73..2064a592246 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
@@ -5,55 +5,42 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, feature_category: :vulnerability_management do
let_it_be(:project) { create(:project) }
- let(:current_dast_versions) { described_class::CURRENT_VERSIONS[:dast].join(', ') }
let(:supported_dast_versions) { described_class::SUPPORTED_VERSIONS[:dast].join(', ') }
- let(:deprecated_schema_version_message) {}
- let(:missing_schema_version_message) do
- "Report version not provided, dast report type supports versions: #{supported_dast_versions}"
- end
let(:scanner) do
{
- 'id' => 'gemnasium',
- 'name' => 'Gemnasium',
- 'version' => '2.1.0'
+ 'id' => 'my-dast-scanner',
+ 'name' => 'My DAST scanner',
+ 'version' => '0.2.0',
+ 'vendor' => { 'name' => 'A DAST scanner' }
}
end
- let(:analyzer_vendor) do
- { 'name' => 'A DAST analyzer' }
- end
-
- let(:scanner_vendor) do
- { 'name' => 'A DAST scanner' }
- end
+ let(:report_type) { :dast }
- let(:report_data) do
+ let(:valid_data) do
{
'scan' => {
'analyzer' => {
'id' => 'my-dast-analyzer',
'name' => 'My DAST analyzer',
'version' => '0.1.0',
- 'vendor' => analyzer_vendor
+ 'vendor' => { 'name' => 'A DAST analyzer' }
},
'end_time' => '2020-01-28T03:26:02',
'scanned_resources' => [],
- 'scanner' => {
- 'id' => 'my-dast-scanner',
- 'name' => 'My DAST scanner',
- 'version' => '0.2.0',
- 'vendor' => scanner_vendor
- },
+ 'scanner' => scanner,
'start_time' => '2020-01-28T03:26:01',
'status' => 'success',
- 'type' => 'dast'
+ 'type' => report_type.to_s
},
'version' => report_version,
'vulnerabilities' => []
}
end
+ let(:report_data) { valid_data }
+
let(:validator) { described_class.new(report_type, report_data, report_version, project: project, scanner: scanner) }
shared_examples 'report is valid' do
@@ -70,8 +57,8 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
security_report_version: report_version,
project_id: project.id,
security_report_failure: security_report_failure,
- security_report_scanner_id: 'gemnasium',
- security_report_scanner_version: '2.1.0'
+ security_report_scanner_id: scanner['id'],
+ security_report_scanner_version: scanner['version']
)
subject
@@ -142,7 +129,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
subject { validator.valid? }
context 'when given a supported MAJOR.MINOR schema version' do
- let(:report_type) { :dast }
let(:report_version) do
latest_vendored_version = described_class::SUPPORTED_VERSIONS[report_type].last.split(".")
(latest_vendored_version[0...2] << "34").join(".")
@@ -153,7 +139,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when given a supported schema version' do
- let(:report_type) { :dast }
let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
it_behaves_like 'report is valid'
@@ -161,7 +146,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when given a deprecated schema version' do
- let(:report_type) { :dast }
let(:deprecations_hash) do
{
dast: %w[10.0.0]
@@ -175,13 +159,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'and the report passes schema validation' do
- let(:report_data) do
- {
- 'version' => '10.0.0',
- 'vulnerabilities' => []
- }
- end
-
let(:security_report_failure) { 'using_deprecated_schema_version' }
it { is_expected.to be_truthy }
@@ -191,9 +168,8 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
context 'and the report does not pass schema validation' do
let(:report_data) do
- {
- 'version' => 'V2.7.0'
- }
+ valid_data.delete('vulnerabilities')
+ valid_data
end
it { is_expected.to be_falsey }
@@ -201,17 +177,9 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when given an unsupported schema version' do
- let(:report_type) { :dast }
let(:report_version) { "12.37.0" }
context 'and the report is valid' do
- let(:report_data) do
- {
- 'version' => report_version,
- 'vulnerabilities' => []
- }
- end
-
let(:security_report_failure) { 'using_unsupported_schema_version' }
it { is_expected.to be_falsey }
@@ -259,8 +227,8 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when not given a schema version' do
- let(:report_type) { :dast }
let(:report_version) { nil }
+
let(:report_data) do
{
'vulnerabilities' => []
@@ -285,21 +253,19 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
subject { validator.errors }
context 'when given a supported schema version' do
- let(:report_type) { :dast }
let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
it_behaves_like 'report is valid with no error'
context 'and the report is invalid' do
let(:report_data) do
- {
- 'version' => report_version
- }
+ valid_data.delete('vulnerabilities')
+ valid_data
end
let(:expected_errors) do
[
- 'root is missing required keys: scan, vulnerabilities'
+ 'root is missing required keys: vulnerabilities'
]
end
@@ -308,7 +274,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when given a deprecated schema version' do
- let(:report_type) { :dast }
let(:deprecations_hash) do
{
dast: %w[10.0.0]
@@ -325,9 +290,9 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
context 'and the report does not pass schema validation' do
let(:report_data) do
- {
- 'version' => 'V2.7.0'
- }
+ valid_data['version'] = "V2.7.0"
+ valid_data.delete('vulnerabilities')
+ valid_data
end
let(:expected_errors) do
@@ -342,7 +307,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when given an unsupported schema version' do
- let(:report_type) { :dast }
let(:report_version) { "12.37.0" }
let(:expected_unsupported_message) do
"Version #{report_version} for report type #{report_type} is unsupported, supported versions for this report type are: "\
@@ -351,13 +315,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'and the report is valid' do
- let(:report_data) do
- {
- 'version' => report_version,
- 'vulnerabilities' => []
- }
- end
-
let(:expected_errors) do
[
expected_unsupported_message
@@ -369,9 +326,8 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
context 'and the report is invalid' do
let(:report_data) do
- {
- 'version' => report_version
- }
+ valid_data.delete('vulnerabilities')
+ valid_data
end
let(:expected_errors) do
@@ -386,7 +342,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when not given a schema version' do
- let(:report_type) { :dast }
let(:report_version) { nil }
let(:expected_missing_version_message) do
"Report version not provided, #{report_type} report type supports versions: #{supported_dast_versions}. GitLab "\
@@ -395,9 +350,8 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
let(:report_data) do
- {
- 'vulnerabilities' => []
- }
+ valid_data.delete('version')
+ valid_data
end
let(:expected_errors) do
@@ -413,13 +367,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
shared_examples 'report is valid with no warning' do
context 'and the report is valid' do
- let(:report_data) do
- {
- 'version' => report_version,
- 'vulnerabilities' => []
- }
- end
-
it { is_expected.to be_empty }
end
end
@@ -432,25 +379,16 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
subject { validator.deprecation_warnings }
context 'when given a supported schema version' do
- let(:report_type) { :dast }
let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
context 'and the report is valid' do
- let(:report_data) do
- {
- 'version' => report_version,
- 'vulnerabilities' => []
- }
- end
-
it { is_expected.to be_empty }
end
context 'and the report is invalid' do
let(:report_data) do
- {
- 'version' => report_version
- }
+ valid_data.delete('vulnerabilities')
+ valid_data
end
it { is_expected.to be_empty }
@@ -458,7 +396,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when given a deprecated schema version' do
- let(:report_type) { :dast }
let(:deprecations_hash) do
{
dast: %w[V2.7.0]
@@ -466,6 +403,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
+ let(:current_dast_versions) { described_class::CURRENT_VERSIONS[:dast].join(', ') }
let(:expected_deprecation_message) do
"version #{report_version} for report type #{report_type} is deprecated. "\
"However, GitLab will still attempt to parse and ingest this report. "\
@@ -483,53 +421,23 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'and the report passes schema validation' do
- let(:report_data) do
- {
- 'version' => report_version,
- 'vulnerabilities' => []
- }
- end
-
it_behaves_like 'report with expected warnings'
end
context 'and the report does not pass schema validation' do
let(:report_data) do
- {
- 'version' => 'V2.7.0'
- }
+ valid_data['version'] = "V2.7.0"
+ valid_data.delete('vulnerabilities')
+ valid_data
end
it_behaves_like 'report with expected warnings'
end
-
- context 'and the report passes schema validation as a GitLab-vendored analyzer' do
- let(:analyzer_vendor) do
- { 'name' => 'GitLab' }
- end
-
- it { is_expected.to be_empty }
- end
-
- context 'and the report passes schema validation as a GitLab-vendored scanner' do
- let(:scanner_vendor) do
- { 'name' => 'GitLab' }
- end
-
- it { is_expected.to be_empty }
- end
end
context 'when given an unsupported schema version' do
- let(:report_type) { :dast }
let(:report_version) { "21.37.0" }
let(:expected_deprecation_warnings) { [] }
- let(:report_data) do
- {
- 'version' => report_version,
- 'vulnerabilities' => []
- }
- end
it_behaves_like 'report with expected warnings'
end
@@ -539,7 +447,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
subject { validator.warnings }
context 'when given a supported MAJOR.MINOR schema version' do
- let(:report_type) { :dast }
let(:report_version) do
latest_vendored_version = described_class::SUPPORTED_VERSIONS[report_type].last.split(".")
(latest_vendored_version[0...2] << "34").join(".")
@@ -559,13 +466,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'and the report is valid' do
- let(:report_data) do
- {
- 'version' => report_version,
- 'vulnerabilities' => []
- }
- end
-
it { is_expected.to match_array([message]) }
context 'without license', unless: Gitlab.ee? do
@@ -607,7 +507,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when given a supported schema version' do
- let(:report_type) { :dast }
let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
it_behaves_like 'report is valid with no warning'
@@ -624,34 +523,26 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when given a deprecated schema version' do
- let(:report_type) { :dast }
+ let(:deprecated_version) { '14.1.3' }
+ let(:report_version) { deprecated_version }
let(:deprecations_hash) do
{
- dast: %w[V2.7.0]
+ dast: %w[deprecated_version]
}
end
- let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
-
before do
stub_const("#{described_class}::DEPRECATED_VERSIONS", deprecations_hash)
end
context 'and the report passes schema validation' do
- let(:report_data) do
- {
- 'vulnerabilities' => []
- }
- end
-
it { is_expected.to be_empty }
end
context 'and the report does not pass schema validation' do
let(:report_data) do
- {
- 'version' => 'V2.7.0'
- }
+ valid_data.delete('vulnerabilities')
+ valid_data
end
it { is_expected.to be_empty }
@@ -659,7 +550,6 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when given an unsupported schema version' do
- let(:report_type) { :dast }
let(:report_version) { "12.37.0" }
it_behaves_like 'report is valid with no warning'
@@ -676,13 +566,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator, featu
end
context 'when not given a schema version' do
- let(:report_type) { :dast }
let(:report_version) { nil }
- let(:report_data) do
- {
- 'vulnerabilities' => []
- }
- end
it { is_expected.to be_empty }
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 e0d656f456e..a9a52972294 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
+RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content, feature_category: :continuous_integration do
let(:project) { create(:project, ci_config_path: ci_config_path) }
let(:pipeline) { build(:ci_pipeline, project: project) }
let(:content) { nil }
@@ -26,6 +26,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
expect(pipeline.config_source).to eq 'bridge_source'
expect(command.config_content).to eq 'the-yaml'
+ expect(command.pipeline_config.internal_include_prepended?).to eq(false)
end
end
@@ -52,6 +53,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
expect(pipeline.config_source).to eq 'repository_source'
expect(pipeline.pipeline_config.content).to eq(config_content_result)
expect(command.config_content).to eq(config_content_result)
+ expect(command.pipeline_config.internal_include_prepended?).to eq(true)
end
end
@@ -71,6 +73,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
expect(pipeline.config_source).to eq 'remote_source'
expect(pipeline.pipeline_config.content).to eq(config_content_result)
expect(command.config_content).to eq(config_content_result)
+ expect(command.pipeline_config.internal_include_prepended?).to eq(true)
end
end
@@ -91,6 +94,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
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)
+ expect(command.pipeline_config.internal_include_prepended?).to eq(true)
end
context 'when path specifies a refname' do
@@ -111,6 +115,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
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)
+ expect(command.pipeline_config.internal_include_prepended?).to eq(true)
end
end
end
@@ -138,6 +143,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
expect(pipeline.config_source).to eq 'repository_source'
expect(pipeline.pipeline_config.content).to eq(config_content_result)
expect(command.config_content).to eq(config_content_result)
+ expect(command.pipeline_config.internal_include_prepended?).to eq(true)
end
end
@@ -161,6 +167,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
expect(pipeline.config_source).to eq 'auto_devops_source'
expect(pipeline.pipeline_config.content).to eq(config_content_result)
expect(command.config_content).to eq(config_content_result)
+ expect(command.pipeline_config.internal_include_prepended?).to eq(true)
end
end
@@ -181,6 +188,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
expect(pipeline.config_source).to eq 'parameter_source'
expect(pipeline.pipeline_config.content).to eq(content)
expect(command.config_content).to eq(content)
+ expect(command.pipeline_config.internal_include_prepended?).to eq(false)
end
end
@@ -197,6 +205,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
expect(pipeline.config_source).to eq('unknown_source')
expect(pipeline.pipeline_config).to be_nil
expect(command.config_content).to be_nil
+ expect(command.pipeline_config).to be_nil
expect(pipeline.errors.full_messages).to include('Missing CI config file')
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/duration_spec.rb b/spec/lib/gitlab/ci/pipeline/duration_spec.rb
index 36714413da6..89c0ce46237 100644
--- a/spec/lib/gitlab/ci/pipeline/duration_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/duration_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Pipeline::Duration do
+RSpec.describe Gitlab::Ci::Pipeline::Duration, feature_category: :continuous_integration do
describe '.from_periods' do
let(:calculated_duration) { calculate(data) }
@@ -113,16 +113,17 @@ RSpec.describe Gitlab::Ci::Pipeline::Duration do
described_class::Period.new(first, last)
end
- described_class.from_periods(periods.sort_by(&:first))
+ described_class.send(:from_periods, periods.sort_by(&:first))
end
end
describe '.from_pipeline' do
+ let_it_be_with_reload(:pipeline) { create(:ci_pipeline) }
+
let_it_be(:start_time) { Time.current.change(usec: 0) }
let_it_be(:current) { start_time + 1000 }
- let_it_be(:pipeline) { create(:ci_pipeline) }
- let_it_be(:success_build) { create_build(:success, started_at: start_time, finished_at: start_time + 60) }
- let_it_be(:failed_build) { create_build(:failed, started_at: start_time + 60, finished_at: start_time + 120) }
+ let_it_be(:success_build) { create_build(:success, started_at: start_time, finished_at: start_time + 50) }
+ let_it_be(:failed_build) { create_build(:failed, started_at: start_time + 60, finished_at: start_time + 110) }
let_it_be(:canceled_build) { create_build(:canceled, started_at: start_time + 120, finished_at: start_time + 180) }
let_it_be(:skipped_build) { create_build(:skipped, started_at: start_time) }
let_it_be(:pending_build) { create_build(:pending) }
@@ -141,21 +142,55 @@ RSpec.describe Gitlab::Ci::Pipeline::Duration do
end
context 'when there is no running build' do
- let(:running_build) { nil }
+ let!(:running_build) { nil }
it 'returns the duration for all the builds' do
travel_to(current) do
- expect(described_class.from_pipeline(pipeline)).to eq 180.seconds
+ # 160 = success (50) + failed (50) + canceled (60)
+ expect(described_class.from_pipeline(pipeline)).to eq 160.seconds
end
end
end
- context 'when there are bridge jobs' do
- let!(:success_bridge) { create_bridge(:success, started_at: start_time + 220, finished_at: start_time + 280) }
- let!(:failed_bridge) { create_bridge(:failed, started_at: start_time + 180, finished_at: start_time + 240) }
- let!(:skipped_bridge) { create_bridge(:skipped, started_at: start_time) }
- let!(:created_bridge) { create_bridge(:created) }
- let!(:manual_bridge) { create_bridge(:manual) }
+ context 'when there are direct bridge jobs' do
+ let_it_be(:success_bridge) do
+ create_bridge(:success, started_at: start_time + 220, finished_at: start_time + 280)
+ end
+
+ let_it_be(:failed_bridge) { create_bridge(:failed, started_at: start_time + 180, finished_at: start_time + 240) }
+ # NOTE: bridge won't be `canceled` as it will be marked as failed when downstream pipeline is canceled
+ # @see Ci::Bridge#inherit_status_from_downstream
+ let_it_be(:canceled_bridge) do
+ create_bridge(:failed, started_at: start_time + 180, finished_at: start_time + 210)
+ end
+
+ let_it_be(:skipped_bridge) { create_bridge(:skipped, started_at: start_time) }
+ let_it_be(:created_bridge) { create_bridge(:created) }
+ let_it_be(:manual_bridge) { create_bridge(:manual) }
+
+ let_it_be(:success_bridge_pipeline) do
+ create(:ci_pipeline, :success, started_at: start_time + 230, finished_at: start_time + 280).tap do |p|
+ create(:ci_sources_pipeline, source_job: success_bridge, pipeline: p)
+ create_build(:success, pipeline: p, started_at: start_time + 235, finished_at: start_time + 280)
+ create_bridge(:success, pipeline: p, started_at: start_time + 240, finished_at: start_time + 280)
+ end
+ end
+
+ let_it_be(:failed_bridge_pipeline) do
+ create(:ci_pipeline, :failed, started_at: start_time + 225, finished_at: start_time + 240).tap do |p|
+ create(:ci_sources_pipeline, source_job: failed_bridge, pipeline: p)
+ create_build(:failed, pipeline: p, started_at: start_time + 230, finished_at: start_time + 240)
+ create_bridge(:success, pipeline: p, started_at: start_time + 235, finished_at: start_time + 240)
+ end
+ end
+
+ let_it_be(:canceled_bridge_pipeline) do
+ create(:ci_pipeline, :canceled, started_at: start_time + 190, finished_at: start_time + 210).tap do |p|
+ create(:ci_sources_pipeline, source_job: canceled_bridge, pipeline: p)
+ create_build(:canceled, pipeline: p, started_at: start_time + 200, finished_at: start_time + 210)
+ create_bridge(:success, pipeline: p, started_at: start_time + 205, finished_at: start_time + 210)
+ end
+ end
it 'returns the duration of the running build' do
travel_to(current) do
@@ -166,12 +201,99 @@ RSpec.describe Gitlab::Ci::Pipeline::Duration do
context 'when there is no running build' do
let!(:running_build) { nil }
- it 'returns the duration for all the builds and bridge jobs' do
+ it 'returns the duration for all the builds (including self and downstreams)' do
travel_to(current) do
- expect(described_class.from_pipeline(pipeline)).to eq 280.seconds
+ # 220 = 160 (see above)
+ # + success build (45) + failed (10) + canceled (10) - overlapping (success & failed) (5)
+ expect(described_class.from_pipeline(pipeline)).to eq 220.seconds
end
end
end
+
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
+ context 'when there are downstream bridge jobs' do
+ let_it_be(:success_direct_bridge) do
+ create_bridge(:success, started_at: start_time + 280, finished_at: start_time + 400)
+ end
+
+ let_it_be(:success_downstream_pipeline) do
+ create(:ci_pipeline, :success, started_at: start_time + 285, finished_at: start_time + 300).tap do |p|
+ create(:ci_sources_pipeline, source_job: success_direct_bridge, pipeline: p)
+ create_build(:success, pipeline: p, started_at: start_time + 290, finished_at: start_time + 296)
+ create_bridge(:success, pipeline: p, started_at: start_time + 285, finished_at: start_time + 288)
+ end
+ end
+
+ let_it_be(:failed_downstream_pipeline) do
+ create(:ci_pipeline, :failed, started_at: start_time + 305, finished_at: start_time + 350).tap do |p|
+ create(:ci_sources_pipeline, source_job: success_direct_bridge, pipeline: p)
+ create_build(:failed, pipeline: p, started_at: start_time + 320, finished_at: start_time + 327)
+ create_bridge(:success, pipeline: p, started_at: start_time + 305, finished_at: start_time + 350)
+ end
+ end
+
+ let_it_be(:canceled_downstream_pipeline) do
+ create(:ci_pipeline, :canceled, started_at: start_time + 360, finished_at: start_time + 400).tap do |p|
+ create(:ci_sources_pipeline, source_job: success_direct_bridge, pipeline: p)
+ create_build(:canceled, pipeline: p, started_at: start_time + 390, finished_at: start_time + 398)
+ create_bridge(:success, pipeline: p, started_at: start_time + 360, finished_at: start_time + 378)
+ end
+ end
+
+ it 'returns the duration of the running build' do
+ travel_to(current) do
+ expect(described_class.from_pipeline(pipeline)).to eq 1000.seconds
+ end
+ end
+
+ context 'when there is no running build' do
+ let!(:running_build) { nil }
+
+ it 'returns the duration for all the builds (including self and downstreams)' do
+ travel_to(current) do
+ # 241 = 220 (see above)
+ # + success downstream build (6) + failed (7) + canceled (8)
+ expect(described_class.from_pipeline(pipeline)).to eq 241.seconds
+ end
+ end
+ end
+ end
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
+ end
+
+ it 'does not generate N+1 queries if more builds are added' do
+ travel_to(current) do
+ expect do
+ described_class.from_pipeline(pipeline)
+ end.not_to exceed_query_limit(1)
+
+ create_list(:ci_build, 2, :success, pipeline: pipeline, started_at: start_time, finished_at: start_time + 50)
+
+ expect do
+ described_class.from_pipeline(pipeline)
+ end.not_to exceed_query_limit(1)
+ end
+ end
+
+ it 'does not generate N+1 queries if more bridges and their pipeline builds are added' do
+ travel_to(current) do
+ expect do
+ described_class.from_pipeline(pipeline)
+ end.not_to exceed_query_limit(1)
+
+ create_list(
+ :ci_bridge, 2, :success,
+ pipeline: pipeline, started_at: start_time + 220, finished_at: start_time + 280).each do |bridge|
+ create(:ci_pipeline, :success, started_at: start_time + 235, finished_at: start_time + 280).tap do |p|
+ create(:ci_sources_pipeline, source_job: bridge, pipeline: p)
+ create_builds(3, :success)
+ end
+ end
+
+ expect do
+ described_class.from_pipeline(pipeline)
+ end.not_to exceed_query_limit(1)
+ end
end
private
@@ -180,6 +302,10 @@ RSpec.describe Gitlab::Ci::Pipeline::Duration do
create(:ci_build, trait, pipeline: pipeline, **opts)
end
+ def create_builds(counts, trait, **opts)
+ create_list(:ci_build, counts, trait, pipeline: pipeline, **opts)
+ end
+
def create_bridge(trait, **opts)
create(:ci_bridge, trait, pipeline: pipeline, **opts)
end
diff --git a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb
index c264ea3bece..07e2d6960bf 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb
@@ -7,8 +7,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
let_it_be(:head_sha) { project.repository.head_commit.id }
let_it_be(:pipeline) { create(:ci_pipeline, project: project, sha: head_sha) }
let(:index) { 1 }
+ let(:cache_prefix) { index }
- let(:processor) { described_class.new(pipeline, config, index) }
+ let(:processor) { described_class.new(pipeline, config, cache_prefix) }
describe '#attributes' do
subject { processor.attributes }
@@ -32,7 +33,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
}
end
- it { is_expected.to include(config.merge(key: "a_key")) }
+ it { is_expected.to include(config.merge(key: 'a_key')) }
end
context 'with cache:key:files' do
@@ -42,8 +43,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
end
context 'without a prefix' do
- it 'uses default key with an index as a prefix' do
- expected = { key: '1-default' }
+ it 'uses default key with an index and file names as a prefix' do
+ expected = { key: "#{cache_prefix}-default" }
is_expected.to include(expected)
end
@@ -61,9 +62,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
end
context 'without a prefix' do
- it 'builds a string key with an index as a prefix' do
+ it 'builds a string key with an index and file names as a prefix' do
expected = {
- key: '1-703ecc8fef1635427a1f86a8a1a308831c122392',
+ key: "#{cache_prefix}-703ecc8fef1635427a1f86a8a1a308831c122392",
paths: ['vendor/ruby']
}
@@ -74,30 +75,41 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
context 'with existing files' do
let(:files) { ['VERSION', 'Gemfile.zip'] }
+ let(:cache_prefix) { '1_VERSION_Gemfile' }
it_behaves_like 'version and gemfile files'
end
context 'with files starting with ./' do
let(:files) { ['Gemfile.zip', './VERSION'] }
+ let(:cache_prefix) { '1_Gemfile_' }
it_behaves_like 'version and gemfile files'
end
+ context 'with no files' do
+ let(:files) { [] }
+
+ it_behaves_like 'default key'
+ end
+
context 'with files ending with /' do
let(:files) { ['Gemfile.zip/'] }
+ let(:cache_prefix) { '1_Gemfile' }
it_behaves_like 'default key'
end
context 'with new line in filenames' do
- let(:files) { ["Gemfile.zip\nVERSION"] }
+ let(:files) { ['Gemfile.zip\nVERSION'] }
+ let(:cache_prefix) { '1_Gemfile' }
it_behaves_like 'default key'
end
context 'with missing files' do
let(:files) { ['project-gemfile.lock', ''] }
+ let(:cache_prefix) { '1_project-gemfile_' }
it_behaves_like 'default key'
end
@@ -113,8 +125,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
end
context 'without a prefix' do
- it 'builds a string key with an index as a prefix' do
- expected = { key: '1-74bf43fb1090f161bdd4e265802775dbda2f03d1' }
+ it 'builds a string key with an index and file names as a prefix' do
+ expected = { key: "#{cache_prefix}-74bf43fb1090f161bdd4e265802775dbda2f03d1" }
is_expected.to include(expected)
end
@@ -123,18 +135,21 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
context 'with directory' do
let(:files) { ['foo/bar'] }
+ let(:cache_prefix) { '1_foo/bar' }
it_behaves_like 'foo/bar directory key'
end
context 'with directory ending in slash' do
let(:files) { ['foo/bar/'] }
+ let(:cache_prefix) { '1_foo/bar/' }
it_behaves_like 'foo/bar directory key'
end
context 'with directories ending in slash star' do
let(:files) { ['foo/bar/*'] }
+ let(:cache_prefix) { '1_foo/bar/*' }
it_behaves_like 'foo/bar directory key'
end
@@ -205,6 +220,18 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
end
end
+ context 'with cache:fallback_keys' do
+ let(:config) do
+ {
+ key: 'ruby-branch-key',
+ paths: ['vendor/ruby'],
+ fallback_keys: ['ruby-default']
+ }
+ end
+
+ it { is_expected.to include(config) }
+ end
+
context 'with all cache option keys' do
let(:config) do
{
@@ -213,7 +240,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
untracked: true,
policy: 'push',
unprotect: true,
- when: 'on_success'
+ when: 'on_success',
+ fallback_keys: ['default-ruby']
}
end
diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
index 3043d7f5381..9d5a9bc8058 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Pipeline::Seed::Build, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Pipeline::Seed::Build, feature_category: :pipeline_composition do
let_it_be_with_reload(:project) { create(:project, :repository) }
let_it_be(:head_sha) { project.repository.head_commit.id }
@@ -109,6 +109,104 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build, feature_category: :pipeline_au
end
end
+ context 'with job:rules:[needs:]' do
+ context 'with a single rule' do
+ let(:job_needs_attributes) { [{ name: 'rspec' }] }
+
+ context 'when job has needs set' do
+ context 'when rule evaluates to true' do
+ let(:attributes) do
+ { name: 'rspec',
+ ref: 'master',
+ needs_attributes: job_needs_attributes,
+ rules: [{ if: '$VAR == null', needs: { job: [{ name: 'build-job' }] } }] }
+ end
+
+ it 'overrides the job needs' do
+ expect(subject).to include(needs_attributes: [{ name: 'build-job' }])
+ end
+ end
+
+ context 'when rule evaluates to false' do
+ let(:attributes) do
+ { name: 'rspec',
+ ref: 'master',
+ needs_attributes: job_needs_attributes,
+ rules: [{ if: '$VAR == true', needs: { job: [{ name: 'build-job' }] } }] }
+ end
+
+ it 'keeps the job needs' do
+ expect(subject).to include(needs_attributes: job_needs_attributes)
+ end
+ end
+
+ context 'with subkeys: artifacts, optional' do
+ let(:attributes) do
+ { name: 'rspec',
+ ref: 'master',
+ rules:
+ [
+ { if: '$VAR == null',
+ needs: {
+ job: [{
+ name: 'build-job',
+ optional: false,
+ artifacts: true
+ }]
+ } }
+ ] }
+ end
+
+ context 'when rule evaluates to true' do
+ it 'sets the job needs as well as the job subkeys' do
+ expect(subject[:needs_attributes]).to match_array([{ name: 'build-job', optional: false, artifacts: true }])
+ end
+
+ it 'sets the scheduling type to dag' do
+ expect(subject[:scheduling_type]).to eq(:dag)
+ end
+ end
+ end
+ end
+
+ context 'with multiple rules' do
+ context 'when a rule evaluates to true' do
+ let(:attributes) do
+ { name: 'rspec',
+ ref: 'master',
+ needs_attributes: job_needs_attributes,
+ rules: [
+ { if: '$VAR == true', needs: { job: [{ name: 'rspec-1' }] } },
+ { if: '$VAR2 == true', needs: { job: [{ name: 'rspec-2' }] } },
+ { if: '$VAR3 == null', needs: { job: [{ name: 'rspec' }, { name: 'lint' }] } }
+ ] }
+ end
+
+ it 'overrides the job needs' do
+ expect(subject).to include(needs_attributes: [{ name: 'rspec' }, { name: 'lint' }])
+ end
+ end
+
+ context 'when all rules evaluates to false' do
+ let(:attributes) do
+ { name: 'rspec',
+ ref: 'master',
+ needs_attributes: job_needs_attributes,
+ rules: [
+ { if: '$VAR == true', needs: { job: [{ name: 'rspec-1' }] } },
+ { if: '$VAR2 == true', needs: { job: [{ name: 'rspec-2' }] } },
+ { if: '$VAR3 == true', needs: { job: [{ name: 'rspec-3' }] } }
+ ] }
+ end
+
+ it 'keeps the job needs' do
+ expect(subject).to include(needs_attributes: job_needs_attributes)
+ end
+ end
+ end
+ end
+ end
+
context 'with job:tags' do
let(:attributes) do
{
@@ -152,7 +250,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build, feature_category: :pipeline_au
it 'includes cache options' do
cache_options = {
options: {
- cache: [a_hash_including(key: '0-f155568ad0933d8358f66b846133614f76dd0ca4')]
+ cache: [a_hash_including(key: '0_VERSION-f155568ad0933d8358f66b846133614f76dd0ca4')]
}
}
@@ -798,7 +896,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build, feature_category: :pipeline_au
[
[[{ if: '$CI_JOB_NAME == "rspec" && $VAR == null', when: 'on_failure' }]],
[[{ if: '$VARIABLE != null', when: 'delayed', start_in: '1 day' }, { if: '$CI_JOB_NAME == "rspec"', when: 'on_failure' }]],
- [[{ if: '$VARIABLE == "the wrong value"', when: 'delayed', start_in: '1 day' }, { if: '$CI_BUILD_NAME == "rspec"', when: 'on_failure' }]]
+ [[{ if: '$VARIABLE == "the wrong value"', when: 'delayed', start_in: '1 day' }, { if: '$CI_JOB_NAME == "rspec"', when: 'on_failure' }]]
]
end
@@ -811,6 +909,30 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build, feature_category: :pipeline_au
end
end
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ context 'with an explicit `when: on_failure`' do
+ where(:rule_set) do
+ [
+ [[{ if: '$CI_JOB_NAME == "rspec" && $VAR == null', when: 'on_failure' }]],
+ [[{ if: '$VARIABLE != null', when: 'delayed', start_in: '1 day' }, { if: '$CI_JOB_NAME == "rspec"', when: 'on_failure' }]],
+ [[{ if: '$VARIABLE == "the wrong value"', when: 'delayed', start_in: '1 day' }, { if: '$CI_BUILD_NAME == "rspec"', when: 'on_failure' }]]
+ ]
+ end
+
+ with_them do
+ it { is_expected.to be_included }
+
+ it 'correctly populates when:' do
+ expect(seed_build.attributes).to include(when: 'on_failure')
+ end
+ end
+ end
+ end
+
context 'with an explicit `when: delayed`' do
where(:rule_set) do
[
diff --git a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb
index 288ac3f3854..ae40626510f 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Pipeline::Seed::Stage, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Pipeline::Seed::Stage, feature_category: :pipeline_composition do
let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
let(:previous_stages) { [] }
diff --git a/spec/lib/gitlab/ci/project_config/repository_spec.rb b/spec/lib/gitlab/ci/project_config/repository_spec.rb
index 2105b691d9e..e8a997a7e43 100644
--- a/spec/lib/gitlab/ci/project_config/repository_spec.rb
+++ b/spec/lib/gitlab/ci/project_config/repository_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::ProjectConfig::Repository do
+RSpec.describe Gitlab::Ci::ProjectConfig::Repository, feature_category: :continuous_integration do
let(:project) { create(:project, :custom_repo, files: files) }
let(:sha) { project.repository.head_commit.sha }
let(:files) { { 'README.md' => 'hello' } }
@@ -44,4 +44,10 @@ RSpec.describe Gitlab::Ci::ProjectConfig::Repository do
it { is_expected.to eq(:repository_source) }
end
+
+ describe '#internal_include_prepended?' do
+ subject { config.internal_include_prepended? }
+
+ it { is_expected.to eq(true) }
+ end
end
diff --git a/spec/lib/gitlab/ci/project_config/source_spec.rb b/spec/lib/gitlab/ci/project_config/source_spec.rb
index dda5c7cdce8..eefabe1babb 100644
--- a/spec/lib/gitlab/ci/project_config/source_spec.rb
+++ b/spec/lib/gitlab/ci/project_config/source_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::ProjectConfig::Source do
+RSpec.describe Gitlab::Ci::ProjectConfig::Source, feature_category: :continuous_integration do
let_it_be(:custom_config_class) { Class.new(described_class) }
let_it_be(:project) { build_stubbed(:project) }
let_it_be(:sha) { '123456' }
@@ -20,4 +20,10 @@ RSpec.describe Gitlab::Ci::ProjectConfig::Source do
it { expect { source }.to raise_error(NotImplementedError) }
end
+
+ describe '#internal_include_prepended?' do
+ subject(:internal_include_prepended) { custom_config.internal_include_prepended? }
+
+ it { expect(internal_include_prepended).to eq(false) }
+ end
end
diff --git a/spec/lib/gitlab/ci/reports/codequality_mr_diff_spec.rb b/spec/lib/gitlab/ci/reports/codequality_mr_diff_spec.rb
index 73b916da2e9..79fa1c3ec75 100644
--- a/spec/lib/gitlab/ci/reports/codequality_mr_diff_spec.rb
+++ b/spec/lib/gitlab/ci/reports/codequality_mr_diff_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Reports::CodequalityMrDiff do
+RSpec.describe Gitlab::Ci::Reports::CodequalityMrDiff, feature_category: :code_quality do
let(:codequality_report) { Gitlab::Ci::Reports::CodequalityReports.new }
let(:degradation_1) { build(:codequality_degradation_1) }
let(:degradation_2) { build(:codequality_degradation_2) }
diff --git a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb
index d7ac82e3b53..79c59fb0da8 100644
--- a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb
+++ b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb
@@ -131,7 +131,7 @@ RSpec.describe Gitlab::Ci::Reports::Security::Scanner do
context 'when the `name` of the scanners are equal' do
where(:scanner_1_attributes, :scanner_2_attributes, :expected_comparison_result) do
- { external_id: 'gemnasium', name: 'foo', vendor: 'a' } | { external_id: 'gemnasium', name: 'foo', vendor: 'a' } | 0 # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
+ { external_id: 'gemnasium', name: 'foo', vendor: 'a' } | { external_id: 'gemnasium', name: 'foo', vendor: 'a' } | 0
{ external_id: 'gemnasium', name: 'foo', vendor: 'a' } | { external_id: 'gemnasium', name: 'foo', vendor: 'b' } | -1
{ external_id: 'gemnasium', name: 'foo', vendor: 'b' } | { external_id: 'gemnasium', name: 'foo', vendor: 'a' } | 1
end
diff --git a/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb
deleted file mode 100644
index 6f75e2c55e8..00000000000
--- a/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb
+++ /dev/null
@@ -1,163 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer do
- let(:identifier) { build(:ci_reports_security_identifier) }
-
- let_it_be(:project) { create(:project, :repository) }
-
- let(:location_param) { build(:ci_reports_security_locations_sast, :dynamic) }
- let(:vulnerability_params) { vuln_params(project.id, [identifier], confidence: :low, severity: :critical) }
- let(:base_vulnerability) { build(:ci_reports_security_finding, location: location_param, **vulnerability_params) }
- let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability]) }
-
- let(:head_vulnerability) { build(:ci_reports_security_finding, location: location_param, uuid: base_vulnerability.uuid, **vulnerability_params) }
- let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability]) }
-
- shared_context 'comparing reports' do
- let(:vul_params) { vuln_params(project.id, [identifier]) }
- let(:base_vulnerability) { build(:ci_reports_security_finding, :dynamic, **vul_params) }
- let(:head_vulnerability) { build(:ci_reports_security_finding, :dynamic, **vul_params) }
- let(:head_vul_findings) { [head_vulnerability, vuln] }
- end
-
- subject { described_class.new(project, base_report, head_report) }
-
- where(vulnerability_finding_signatures: [true, false])
-
- with_them do
- before do
- stub_licensed_features(vulnerability_finding_signatures: vulnerability_finding_signatures)
- end
-
- describe '#base_report_out_of_date' do
- context 'no base report' do
- let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: []) }
-
- it 'is not out of date' do
- expect(subject.base_report_out_of_date).to be false
- end
- end
-
- context 'base report older than one week' do
- let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago - 60.seconds) }
- let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report]) }
-
- it 'is not out of date' do
- expect(subject.base_report_out_of_date).to be true
- end
- end
-
- context 'base report less than one week old' do
- let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago + 60.seconds) }
- let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report]) }
-
- it 'is not out of date' do
- expect(subject.base_report_out_of_date).to be false
- end
- end
- end
-
- describe '#added' do
- let(:new_location) { build(:ci_reports_security_locations_sast, :dynamic) }
- let(:vul_params) { vuln_params(project.id, [identifier], confidence: :high) }
- let(:vuln) { build(:ci_reports_security_finding, severity: Enums::Vulnerability.severity_levels[:critical], location: new_location, **vul_params) }
- let(:low_vuln) { build(:ci_reports_security_finding, severity: Enums::Vulnerability.severity_levels[:low], location: new_location, **vul_params) }
-
- context 'with new vulnerability' do
- let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln]) }
-
- it 'points to source tree' do
- expect(subject.added).to eq([vuln])
- end
- end
-
- context 'when comparing reports with different fingerprints' do
- include_context 'comparing reports'
-
- let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: head_vul_findings) }
-
- it 'does not find any overlap' do
- expect(subject.added).to eq(head_vul_findings)
- end
- end
-
- context 'order' do
- let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln, low_vuln]) }
-
- it 'does not change' do
- expect(subject.added).to eq([vuln, low_vuln])
- end
- end
- end
-
- describe '#fixed' do
- let(:vul_params) { vuln_params(project.id, [identifier]) }
- let(:vuln) { build(:ci_reports_security_finding, :dynamic, **vul_params ) }
- let(:medium_vuln) { build(:ci_reports_security_finding, confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: Enums::Vulnerability.severity_levels[:medium], uuid: vuln.uuid, **vul_params) }
-
- context 'with fixed vulnerability' do
- let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln]) }
-
- it 'points to base tree' do
- expect(subject.fixed).to eq([vuln])
- end
- end
-
- context 'when comparing reports with different fingerprints' do
- include_context 'comparing reports'
-
- let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln]) }
-
- it 'does not find any overlap' do
- expect(subject.fixed).to eq([base_vulnerability, vuln])
- end
- end
-
- context 'order' do
- let(:vul_findings) { [vuln, medium_vuln] }
- let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [*vul_findings, base_vulnerability]) }
-
- it 'does not change' do
- expect(subject.fixed).to eq(vul_findings)
- end
- end
- end
-
- describe 'with empty vulnerabilities' do
- let(:empty_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: []) }
-
- it 'returns empty array when reports are not present' do
- comparer = described_class.new(project, empty_report, empty_report)
-
- expect(comparer.fixed).to eq([])
- expect(comparer.added).to eq([])
- end
-
- it 'returns added vulnerability when base is empty and head is not empty' do
- comparer = described_class.new(project, empty_report, head_report)
-
- expect(comparer.fixed).to eq([])
- expect(comparer.added).to eq([head_vulnerability])
- end
-
- it 'returns fixed vulnerability when head is empty and base is not empty' do
- comparer = described_class.new(project, base_report, empty_report)
-
- expect(comparer.fixed).to eq([base_vulnerability])
- expect(comparer.added).to eq([])
- end
- end
- end
-
- def vuln_params(project_id, identifiers, confidence: :high, severity: :critical)
- {
- project_id: project_id,
- report_type: :sast,
- identifiers: identifiers,
- confidence: ::Enums::Vulnerability.confidence_levels[confidence],
- severity: ::Enums::Vulnerability.severity_levels[severity]
- }
- end
-end
diff --git a/spec/lib/gitlab/ci/runner_releases_spec.rb b/spec/lib/gitlab/ci/runner_releases_spec.rb
index 14f3c95ec79..9e211327dee 100644
--- a/spec/lib/gitlab/ci/runner_releases_spec.rb
+++ b/spec/lib/gitlab/ci/runner_releases_spec.rb
@@ -177,6 +177,16 @@ RSpec.describe Gitlab::Ci::RunnerReleases, feature_category: :runner_fleet do
it 'returns parsed and sorted Gitlab::VersionInfo objects' do
expect(releases).to eq(expected_result)
end
+
+ context 'when fetching runner releases is disabled' do
+ before do
+ stub_application_setting(update_runner_versions_enabled: false)
+ end
+
+ it 'returns nil' do
+ expect(releases).to be_nil
+ end
+ end
end
context 'when response contains unexpected input type' do
@@ -218,6 +228,16 @@ RSpec.describe Gitlab::Ci::RunnerReleases, feature_category: :runner_fleet do
it 'returns parsed and grouped Gitlab::VersionInfo objects' do
expect(releases_by_minor).to eq(expected_result)
end
+
+ context 'when fetching runner releases is disabled' do
+ before do
+ stub_application_setting(update_runner_versions_enabled: false)
+ end
+
+ it 'returns nil' do
+ expect(releases_by_minor).to be_nil
+ end
+ end
end
context 'when response contains unexpected input type' do
@@ -233,6 +253,18 @@ RSpec.describe Gitlab::Ci::RunnerReleases, feature_category: :runner_fleet do
end
end
+ describe '#enabled?' do
+ it { is_expected.to be_enabled }
+
+ context 'when fetching runner releases is disabled' do
+ before do
+ stub_application_setting(update_runner_versions_enabled: false)
+ end
+
+ it { is_expected.not_to be_enabled }
+ end
+ end
+
def mock_http_response(response)
http_response = instance_double(HTTParty::Response)
diff --git a/spec/lib/gitlab/ci/secure_files/cer_spec.rb b/spec/lib/gitlab/ci/secure_files/cer_spec.rb
index 6b9cd0e3bfc..76ce1785368 100644
--- a/spec/lib/gitlab/ci/secure_files/cer_spec.rb
+++ b/spec/lib/gitlab/ci/secure_files/cer_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Gitlab::Ci::SecureFiles::Cer do
describe '#certificate_data' do
it 'assigns the error message and returns nil' do
expect(invalid_certificate.certificate_data).to be nil
- expect(invalid_certificate.error).to eq('not enough data')
+ expect(invalid_certificate.error).to eq('PEM_read_bio_X509: no start line')
end
end
@@ -50,7 +50,7 @@ RSpec.describe Gitlab::Ci::SecureFiles::Cer do
describe '#expires_at' do
it 'returns the certificate expiration timestamp' do
- expect(subject.metadata[:expires_at]).to eq('2022-04-26 19:20:40 UTC')
+ expect(subject.metadata[:expires_at]).to eq('2023-04-26 19:20:39 UTC')
end
end
diff --git a/spec/lib/gitlab/ci/secure_files/mobile_provision_spec.rb b/spec/lib/gitlab/ci/secure_files/mobile_provision_spec.rb
index fb382174c64..1812b90df8b 100644
--- a/spec/lib/gitlab/ci/secure_files/mobile_provision_spec.rb
+++ b/spec/lib/gitlab/ci/secure_files/mobile_provision_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Gitlab::Ci::SecureFiles::MobileProvision do
describe '#decoded_plist' do
it 'assigns the error message and returns nil' do
expect(invalid_profile.decoded_plist).to be nil
- expect(invalid_profile.error).to eq('Could not parse the PKCS7: not enough data')
+ expect(invalid_profile.error).to eq('Could not parse the PKCS7: no start line')
end
end
diff --git a/spec/lib/gitlab/ci/secure_files/p12_spec.rb b/spec/lib/gitlab/ci/secure_files/p12_spec.rb
index beabf4b4856..7a855868ce8 100644
--- a/spec/lib/gitlab/ci/secure_files/p12_spec.rb
+++ b/spec/lib/gitlab/ci/secure_files/p12_spec.rb
@@ -62,7 +62,7 @@ RSpec.describe Gitlab::Ci::SecureFiles::P12 do
describe '#expires_at' do
it 'returns the certificate expiration timestamp' do
- expect(subject.metadata[:expires_at]).to eq('2022-09-21 14:56:00 UTC')
+ expect(subject.metadata[:expires_at]).to eq('2023-09-21 14:55:59 UTC')
end
end
diff --git a/spec/lib/gitlab/ci/status/composite_spec.rb b/spec/lib/gitlab/ci/status/composite_spec.rb
index cceabc35e85..cbf0976c976 100644
--- a/spec/lib/gitlab/ci/status/composite_spec.rb
+++ b/spec/lib/gitlab/ci/status/composite_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Status::Composite do
+RSpec.describe Gitlab::Ci::Status::Composite, feature_category: :continuous_integration do
let_it_be(:pipeline) { create(:ci_pipeline) }
before_all do
@@ -15,6 +15,18 @@ RSpec.describe Gitlab::Ci::Status::Composite do
end
end
+ describe '.initialize' do
+ subject(:composite_status) { described_class.new(all_statuses) }
+
+ context 'when passing a single status' do
+ let(:all_statuses) { @statuses[:success] }
+
+ it 'raises ArgumentError' do
+ expect { composite_status }.to raise_error(ArgumentError, 'all_jobs needs to respond to `.pluck`')
+ end
+ end
+ end
+
describe '#status' do
using RSpec::Parameterized::TableSyntax
@@ -51,8 +63,8 @@ RSpec.describe Gitlab::Ci::Status::Composite do
%i(created success pending) | false | 'running' | false
%i(skipped success failed) | false | 'failed' | false
%i(skipped success failed) | true | 'skipped' | false
- %i(success manual) | true | 'pending' | false
- %i(success failed created) | true | 'pending' | false
+ %i(success manual) | true | 'manual' | false
+ %i(success failed created) | true | 'running' | false
end
with_them do
diff --git a/spec/lib/gitlab/ci/status/processable/waiting_for_resource_spec.rb b/spec/lib/gitlab/ci/status/processable/waiting_for_resource_spec.rb
index 26087fd771c..e1baa1097e4 100644
--- a/spec/lib/gitlab/ci/status/processable/waiting_for_resource_spec.rb
+++ b/spec/lib/gitlab/ci/status/processable/waiting_for_resource_spec.rb
@@ -2,12 +2,25 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Status::Processable::WaitingForResource do
+RSpec.describe Gitlab::Ci::Status::Processable::WaitingForResource, feature_category: :continuous_integration do
let(:user) { create(:user) }
+ let(:processable) { create(:ci_build, :waiting_for_resource, :resource_group) }
- subject do
- processable = create(:ci_build, :waiting_for_resource, :resource_group)
- described_class.new(Gitlab::Ci::Status::Core.new(processable, user))
+ subject { described_class.new(Gitlab::Ci::Status::Core.new(processable, user)) }
+
+ it 'fabricates status with correct details' do
+ expect(subject.has_action?).to eq false
+ end
+
+ context 'when resource is retained by a build' do
+ before do
+ processable.resource_group.assign_resource_to(create(:ci_build))
+ end
+
+ it 'fabricates status with correct details' do
+ expect(subject.has_action?).to eq true
+ expect(subject.action_path).to include 'jobs'
+ end
end
describe '#illustration' do
diff --git a/spec/lib/gitlab/ci/templates/Jobs/build_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/build_gitlab_ci_yaml_spec.rb
index 07cfa939623..995922b6922 100644
--- a/spec/lib/gitlab/ci/templates/Jobs/build_gitlab_ci_yaml_spec.rb
+++ b/spec/lib/gitlab/ci/templates/Jobs/build_gitlab_ci_yaml_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe 'Jobs/Build.gitlab-ci.yml' do
describe 'AUTO_BUILD_IMAGE_VERSION' do
it 'corresponds to a published image in the registry' do
registry = "https://#{template_registry_host}"
- repository = "gitlab-org/cluster-integration/auto-build-image"
+ repository = auto_build_image_repository
reference = YAML.safe_load(template.content).dig('variables', 'AUTO_BUILD_IMAGE_VERSION')
expect(public_image_exist?(registry, repository, reference)).to be true
diff --git a/spec/lib/gitlab/ci/templates/Jobs/sast_iac_latest_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/sast_iac_latest_gitlab_ci_yaml_spec.rb
index 039a6a739dd..2b9213ea921 100644
--- a/spec/lib/gitlab/ci/templates/Jobs/sast_iac_latest_gitlab_ci_yaml_spec.rb
+++ b/spec/lib/gitlab/ci/templates/Jobs/sast_iac_latest_gitlab_ci_yaml_spec.rb
@@ -23,27 +23,33 @@ RSpec.describe 'Jobs/SAST-IaC.latest.gitlab-ci.yml', feature_category: :continuo
allow(project).to receive(:default_branch).and_return(default_branch)
end
- context 'on feature branch' do
- let(:pipeline_ref) { 'feature' }
+ context 'when SAST_DISABLED="false"' do
+ before do
+ create(:ci_variable, key: 'SAST_DISABLED', value: 'false', project: project)
+ end
+
+ context 'on feature branch' do
+ let(:pipeline_ref) { 'feature' }
- it 'creates the kics-iac-sast job' do
- expect(build_names).to contain_exactly('kics-iac-sast')
+ it 'creates the kics-iac-sast job' do
+ expect(build_names).to contain_exactly('kics-iac-sast')
+ end
end
- end
- context 'on merge request' do
- let(:service) { MergeRequests::CreatePipelineService.new(project: project, current_user: user) }
- let(:merge_request) { create(:merge_request, :simple, source_project: project) }
- let(:pipeline) { service.execute(merge_request).payload }
+ context 'on merge request' do
+ let(:service) { MergeRequests::CreatePipelineService.new(project: project, current_user: user) }
+ let(:merge_request) { create(:merge_request, :simple, source_project: project) }
+ let(:pipeline) { service.execute(merge_request).payload }
- it 'creates a pipeline with the expected jobs' do
- expect(pipeline).to be_merge_request_event
- expect(pipeline.errors.full_messages).to be_empty
- expect(build_names).to match_array(%w(kics-iac-sast))
+ it 'creates a pipeline with the expected jobs' do
+ expect(pipeline).to be_merge_request_event
+ expect(pipeline.errors.full_messages).to be_empty
+ expect(build_names).to match_array(%w(kics-iac-sast))
+ end
end
end
- context 'SAST_DISABLED is set' do
+ context 'when SAST_DISABLED="true"' do
before do
create(:ci_variable, key: 'SAST_DISABLED', value: 'true', project: project)
end
diff --git a/spec/lib/gitlab/ci/trace/chunked_io_spec.rb b/spec/lib/gitlab/ci/trace/chunked_io_spec.rb
index 63625244fe8..7a926a06f16 100644
--- a/spec/lib/gitlab/ci/trace/chunked_io_spec.rb
+++ b/spec/lib/gitlab/ci/trace/chunked_io_spec.rb
@@ -446,15 +446,5 @@ RSpec.describe Gitlab::Ci::Trace::ChunkedIO, :clean_gitlab_redis_cache do
expect(Ci::BuildTraceChunk.where(build: build).count).to eq(0)
end
-
- context 'when the job does not have archived trace' do
- it 'leaves a message in sidekiq log' do
- expect(Sidekiq.logger).to receive(:warn).with(
- message: 'The job does not have archived trace but going to be destroyed.',
- job_id: build.id).and_call_original
-
- subject
- end
- end
end
end
diff --git a/spec/lib/gitlab/ci/variables/builder/pipeline_spec.rb b/spec/lib/gitlab/ci/variables/builder/pipeline_spec.rb
index a5365ae53b8..0a079a69682 100644
--- a/spec/lib/gitlab/ci/variables/builder/pipeline_spec.rb
+++ b/spec/lib/gitlab/ci/variables/builder/pipeline_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Variables::Builder::Pipeline, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Variables::Builder::Pipeline, feature_category: :secrets_management do
let_it_be(:project) { create_default(:project, :repository, create_tag: 'test').freeze }
let_it_be(:user) { create(:user) }
@@ -30,15 +30,13 @@ RSpec.describe Gitlab::Ci::Variables::Builder::Pipeline, feature_category: :pipe
CI_COMMIT_REF_PROTECTED
CI_COMMIT_TIMESTAMP
CI_COMMIT_AUTHOR
- CI_BUILD_REF
- CI_BUILD_BEFORE_SHA
- CI_BUILD_REF_NAME
- CI_BUILD_REF_SLUG
])
end
- context 'when the pipeline is running for a tag' do
- let(:pipeline) { build(:ci_empty_pipeline, :created, project: project, ref: 'test', tag: true) }
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
it 'includes all predefined variables in a valid order' do
keys = subject.pluck(:key)
@@ -52,6 +50,7 @@ RSpec.describe Gitlab::Ci::Variables::Builder::Pipeline, feature_category: :pipe
CI_COMMIT_BEFORE_SHA
CI_COMMIT_REF_NAME
CI_COMMIT_REF_SLUG
+ CI_COMMIT_BRANCH
CI_COMMIT_MESSAGE
CI_COMMIT_TITLE
CI_COMMIT_DESCRIPTION
@@ -62,11 +61,69 @@ RSpec.describe Gitlab::Ci::Variables::Builder::Pipeline, feature_category: :pipe
CI_BUILD_BEFORE_SHA
CI_BUILD_REF_NAME
CI_BUILD_REF_SLUG
+ ])
+ end
+ end
+
+ context 'when the pipeline is running for a tag' do
+ let(:pipeline) { build(:ci_empty_pipeline, :created, project: project, ref: 'test', tag: true) }
+
+ it 'includes all predefined variables in a valid order' do
+ keys = subject.pluck(:key)
+
+ expect(keys).to contain_exactly(*%w[
+ CI_PIPELINE_IID
+ CI_PIPELINE_SOURCE
+ CI_PIPELINE_CREATED_AT
+ CI_COMMIT_SHA
+ CI_COMMIT_SHORT_SHA
+ CI_COMMIT_BEFORE_SHA
+ CI_COMMIT_REF_NAME
+ CI_COMMIT_REF_SLUG
+ CI_COMMIT_MESSAGE
+ CI_COMMIT_TITLE
+ CI_COMMIT_DESCRIPTION
+ CI_COMMIT_REF_PROTECTED
+ CI_COMMIT_TIMESTAMP
+ CI_COMMIT_AUTHOR
CI_COMMIT_TAG
CI_COMMIT_TAG_MESSAGE
- CI_BUILD_TAG
])
end
+
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ it 'includes all predefined variables in a valid order' do
+ keys = subject.pluck(:key)
+
+ expect(keys).to contain_exactly(*%w[
+ CI_PIPELINE_IID
+ CI_PIPELINE_SOURCE
+ CI_PIPELINE_CREATED_AT
+ CI_COMMIT_SHA
+ CI_COMMIT_SHORT_SHA
+ CI_COMMIT_BEFORE_SHA
+ CI_COMMIT_REF_NAME
+ CI_COMMIT_REF_SLUG
+ CI_COMMIT_MESSAGE
+ CI_COMMIT_TITLE
+ CI_COMMIT_DESCRIPTION
+ CI_COMMIT_REF_PROTECTED
+ CI_COMMIT_TIMESTAMP
+ CI_COMMIT_AUTHOR
+ CI_BUILD_REF
+ CI_BUILD_BEFORE_SHA
+ CI_BUILD_REF_NAME
+ CI_BUILD_REF_SLUG
+ CI_COMMIT_TAG
+ CI_COMMIT_TAG_MESSAGE
+ CI_BUILD_TAG
+ ])
+ end
+ end
end
context 'when merge request is present' do
@@ -305,10 +362,24 @@ RSpec.describe Gitlab::Ci::Variables::Builder::Pipeline, feature_category: :pipe
expect(subject.to_hash.keys)
.not_to include(
'CI_COMMIT_TAG',
- 'CI_COMMIT_TAG_MESSAGE',
- 'CI_BUILD_TAG'
+ 'CI_COMMIT_TAG_MESSAGE'
)
end
+
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ it 'does not expose tag variables' do
+ expect(subject.to_hash.keys)
+ .not_to include(
+ 'CI_COMMIT_TAG',
+ 'CI_COMMIT_TAG_MESSAGE',
+ 'CI_BUILD_TAG'
+ )
+ end
+ end
end
context 'without a commit' do
diff --git a/spec/lib/gitlab/ci/variables/builder_spec.rb b/spec/lib/gitlab/ci/variables/builder_spec.rb
index bbd3dc54e6a..10974993fa4 100644
--- a/spec/lib/gitlab/ci/variables/builder_spec.rb
+++ b/spec/lib/gitlab/ci/variables/builder_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Variables::Builder, :clean_gitlab_redis_cache, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Variables::Builder, :clean_gitlab_redis_cache, feature_category: :secrets_management do
include Ci::TemplateHelpers
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, namespace: group) }
@@ -35,10 +35,6 @@ RSpec.describe Gitlab::Ci::Variables::Builder, :clean_gitlab_redis_cache, featur
value: '1' },
{ key: 'CI_ENVIRONMENT_NAME',
value: 'test' },
- { key: 'CI_BUILD_NAME',
- value: 'rspec:test 1' },
- { key: 'CI_BUILD_STAGE',
- value: job.stage_name },
{ key: 'CI',
value: 'true' },
{ key: 'GITLAB_CI',
@@ -51,6 +47,10 @@ RSpec.describe Gitlab::Ci::Variables::Builder, :clean_gitlab_redis_cache, featur
value: Gitlab.config.gitlab.port.to_s },
{ key: 'CI_SERVER_PROTOCOL',
value: Gitlab.config.gitlab.protocol },
+ { key: 'CI_SERVER_SHELL_SSH_HOST',
+ value: Gitlab.config.gitlab_shell.ssh_host.to_s },
+ { key: 'CI_SERVER_SHELL_SSH_PORT',
+ value: Gitlab.config.gitlab_shell.ssh_port.to_s },
{ key: 'CI_SERVER_NAME',
value: 'GitLab' },
{ key: 'CI_SERVER_VERSION',
@@ -101,6 +101,8 @@ RSpec.describe Gitlab::Ci::Variables::Builder, :clean_gitlab_redis_cache, featur
value: project.pages_url },
{ key: 'CI_API_V4_URL',
value: API::Helpers::Version.new('v4').root_url },
+ { key: 'CI_API_GRAPHQL_URL',
+ value: Gitlab::Routing.url_helpers.api_graphql_url },
{ key: 'CI_TEMPLATE_REGISTRY_HOST',
value: template_registry_host },
{ key: 'CI_PIPELINE_IID',
@@ -133,14 +135,6 @@ RSpec.describe Gitlab::Ci::Variables::Builder, :clean_gitlab_redis_cache, featur
value: pipeline.git_commit_timestamp },
{ key: 'CI_COMMIT_AUTHOR',
value: pipeline.git_author_full_text },
- { key: 'CI_BUILD_REF',
- value: job.sha },
- { key: 'CI_BUILD_BEFORE_SHA',
- value: job.before_sha },
- { key: 'CI_BUILD_REF_NAME',
- value: job.ref },
- { key: 'CI_BUILD_REF_SLUG',
- value: job.ref_slug },
{ key: 'YAML_VARIABLE',
value: 'value' },
{ key: 'GITLAB_USER_ID',
@@ -160,6 +154,151 @@ RSpec.describe Gitlab::Ci::Variables::Builder, :clean_gitlab_redis_cache, featur
it { expect(subject.to_runner_variables).to eq(predefined_variables) }
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ let(:predefined_variables) do
+ [
+ { key: 'CI_JOB_NAME',
+ value: 'rspec:test 1' },
+ { key: 'CI_JOB_NAME_SLUG',
+ value: 'rspec-test-1' },
+ { key: 'CI_JOB_STAGE',
+ value: job.stage_name },
+ { key: 'CI_NODE_TOTAL',
+ value: '1' },
+ { key: 'CI_ENVIRONMENT_NAME',
+ value: 'test' },
+ { key: 'CI_BUILD_NAME',
+ value: 'rspec:test 1' },
+ { key: 'CI_BUILD_STAGE',
+ value: job.stage_name },
+ { key: 'CI',
+ value: 'true' },
+ { key: 'GITLAB_CI',
+ value: 'true' },
+ { key: 'CI_SERVER_URL',
+ value: Gitlab.config.gitlab.url },
+ { key: 'CI_SERVER_HOST',
+ value: Gitlab.config.gitlab.host },
+ { key: 'CI_SERVER_PORT',
+ value: Gitlab.config.gitlab.port.to_s },
+ { key: 'CI_SERVER_PROTOCOL',
+ value: Gitlab.config.gitlab.protocol },
+ { key: 'CI_SERVER_SHELL_SSH_HOST',
+ value: Gitlab.config.gitlab_shell.ssh_host.to_s },
+ { key: 'CI_SERVER_SHELL_SSH_PORT',
+ value: Gitlab.config.gitlab_shell.ssh_port.to_s },
+ { key: 'CI_SERVER_NAME',
+ value: 'GitLab' },
+ { key: 'CI_SERVER_VERSION',
+ value: Gitlab::VERSION },
+ { key: 'CI_SERVER_VERSION_MAJOR',
+ value: Gitlab.version_info.major.to_s },
+ { key: 'CI_SERVER_VERSION_MINOR',
+ value: Gitlab.version_info.minor.to_s },
+ { key: 'CI_SERVER_VERSION_PATCH',
+ value: Gitlab.version_info.patch.to_s },
+ { key: 'CI_SERVER_REVISION',
+ value: Gitlab.revision },
+ { key: 'GITLAB_FEATURES',
+ value: project.licensed_features.join(',') },
+ { key: 'CI_PROJECT_ID',
+ value: project.id.to_s },
+ { key: 'CI_PROJECT_NAME',
+ value: project.path },
+ { key: 'CI_PROJECT_TITLE',
+ value: project.title },
+ { key: 'CI_PROJECT_DESCRIPTION',
+ value: project.description },
+ { key: 'CI_PROJECT_PATH',
+ value: project.full_path },
+ { key: 'CI_PROJECT_PATH_SLUG',
+ value: project.full_path_slug },
+ { key: 'CI_PROJECT_NAMESPACE',
+ value: project.namespace.full_path },
+ { key: 'CI_PROJECT_NAMESPACE_ID',
+ value: project.namespace.id.to_s },
+ { key: 'CI_PROJECT_ROOT_NAMESPACE',
+ value: project.namespace.root_ancestor.path },
+ { key: 'CI_PROJECT_URL',
+ value: project.web_url },
+ { key: 'CI_PROJECT_VISIBILITY',
+ value: "private" },
+ { key: 'CI_PROJECT_REPOSITORY_LANGUAGES',
+ value: project.repository_languages.map(&:name).join(',').downcase },
+ { key: 'CI_PROJECT_CLASSIFICATION_LABEL',
+ value: project.external_authorization_classification_label },
+ { key: 'CI_DEFAULT_BRANCH',
+ value: project.default_branch },
+ { key: 'CI_CONFIG_PATH',
+ value: project.ci_config_path_or_default },
+ { key: 'CI_PAGES_DOMAIN',
+ value: Gitlab.config.pages.host },
+ { key: 'CI_PAGES_URL',
+ value: project.pages_url },
+ { key: 'CI_API_V4_URL',
+ value: API::Helpers::Version.new('v4').root_url },
+ { key: 'CI_API_GRAPHQL_URL',
+ value: Gitlab::Routing.url_helpers.api_graphql_url },
+ { key: 'CI_TEMPLATE_REGISTRY_HOST',
+ value: template_registry_host },
+ { key: 'CI_PIPELINE_IID',
+ value: pipeline.iid.to_s },
+ { key: 'CI_PIPELINE_SOURCE',
+ value: pipeline.source },
+ { key: 'CI_PIPELINE_CREATED_AT',
+ value: pipeline.created_at.iso8601 },
+ { key: 'CI_COMMIT_SHA',
+ value: job.sha },
+ { key: 'CI_COMMIT_SHORT_SHA',
+ value: job.short_sha },
+ { key: 'CI_COMMIT_BEFORE_SHA',
+ value: job.before_sha },
+ { key: 'CI_COMMIT_REF_NAME',
+ value: job.ref },
+ { key: 'CI_COMMIT_REF_SLUG',
+ value: job.ref_slug },
+ { key: 'CI_COMMIT_BRANCH',
+ value: job.ref },
+ { key: 'CI_COMMIT_MESSAGE',
+ value: pipeline.git_commit_message },
+ { key: 'CI_COMMIT_TITLE',
+ value: pipeline.git_commit_title },
+ { key: 'CI_COMMIT_DESCRIPTION',
+ value: pipeline.git_commit_description },
+ { key: 'CI_COMMIT_REF_PROTECTED',
+ value: (!!pipeline.protected_ref?).to_s },
+ { key: 'CI_COMMIT_TIMESTAMP',
+ value: pipeline.git_commit_timestamp },
+ { key: 'CI_COMMIT_AUTHOR',
+ value: pipeline.git_author_full_text },
+ { key: 'CI_BUILD_REF',
+ value: job.sha },
+ { key: 'CI_BUILD_BEFORE_SHA',
+ value: job.before_sha },
+ { key: 'CI_BUILD_REF_NAME',
+ value: job.ref },
+ { key: 'CI_BUILD_REF_SLUG',
+ value: job.ref_slug },
+ { key: 'YAML_VARIABLE',
+ value: 'value' },
+ { key: 'GITLAB_USER_ID',
+ value: user.id.to_s },
+ { key: 'GITLAB_USER_EMAIL',
+ value: user.email },
+ { key: 'GITLAB_USER_LOGIN',
+ value: user.username },
+ { key: 'GITLAB_USER_NAME',
+ value: user.name }
+ ].map { |var| var.merge(public: true, masked: false) }
+ end
+
+ it { expect(subject.to_runner_variables).to eq(predefined_variables) }
+ end
+
context 'variables ordering' do
def var(name, value)
{ key: name, value: value.to_s, public: true, masked: false }
diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb
index 4ee122cc607..181e37de9b9 100644
--- a/spec/lib/gitlab/ci/variables/collection_spec.rb
+++ b/spec/lib/gitlab/ci/variables/collection_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Variables::Collection, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Ci::Variables::Collection, feature_category: :secrets_management do
describe '.new' do
it 'can be initialized with an array' do
variable = { key: 'VAR', value: 'value', public: true, masked: false }
diff --git a/spec/lib/gitlab/ci/yaml_processor/result_spec.rb b/spec/lib/gitlab/ci/yaml_processor/result_spec.rb
index 5c9f156e054..36ada9050b2 100644
--- a/spec/lib/gitlab/ci/yaml_processor/result_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor/result_spec.rb
@@ -47,8 +47,8 @@ module Gitlab
end
it 'returns expanded yaml config' do
- expanded_config = YAML.safe_load(config_metadata[:merged_yaml], [Symbol])
- included_config = YAML.safe_load(included_yml, [Symbol])
+ expanded_config = YAML.safe_load(config_metadata[:merged_yaml], permitted_classes: [Symbol])
+ included_config = YAML.safe_load(included_yml, permitted_classes: [Symbol])
expect(expanded_config).to include(*included_config.keys)
end
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index 360686ce65c..2c020e76cb6 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
module Gitlab
module Ci
- RSpec.describe YamlProcessor, feature_category: :pipeline_authoring do
+ RSpec.describe YamlProcessor, feature_category: :pipeline_composition do
include StubRequests
include RepoHelpers
@@ -659,6 +659,191 @@ module Gitlab
it_behaves_like 'has warnings and expected error', /build job: need test is not defined in current or prior stages/
end
+
+ describe '#validate_job_needs!' do
+ context "when all validations pass" do
+ let(:config) do
+ <<-EOYML
+ stages:
+ - lint
+ lint_job:
+ needs: [lint_job_2]
+ stage: lint
+ script: 'echo lint_job'
+ rules:
+ - if: $var == null
+ needs:
+ - lint_job_2
+ - job: lint_job_3
+ optional: true
+ lint_job_2:
+ stage: lint
+ script: 'echo job'
+ rules:
+ - if: $var == null
+ lint_job_3:
+ stage: lint
+ script: 'echo job'
+ rules:
+ - if: $var == null
+ EOYML
+ end
+
+ it 'returns a valid response' do
+ expect(subject).to be_valid
+ expect(subject).to be_instance_of(Gitlab::Ci::YamlProcessor::Result)
+ end
+ end
+
+ context 'needs as array' do
+ context 'single need in following stage' do
+ let(:config) do
+ <<-EOYML
+ stages:
+ - lint
+ - test
+ lint_job:
+ stage: lint
+ script: 'echo lint_job'
+ rules:
+ - if: $var == null
+ needs: [test_job]
+ test_job:
+ stage: test
+ script: 'echo job'
+ rules:
+ - if: $var == null
+ EOYML
+ end
+
+ it_behaves_like 'returns errors', 'lint_job job: need test_job is not defined in current or prior stages'
+ end
+
+ context 'multiple needs in the following stage' do
+ let(:config) do
+ <<-EOYML
+ stages:
+ - lint
+ - test
+ lint_job:
+ stage: lint
+ script: 'echo lint_job'
+ rules:
+ - if: $var == null
+ needs: [test_job, test_job_2]
+ test_job:
+ stage: test
+ script: 'echo job'
+ rules:
+ - if: $var == null
+ test_job_2:
+ stage: test
+ script: 'echo job'
+ rules:
+ - if: $var == null
+ EOYML
+ end
+
+ it_behaves_like 'returns errors', 'lint_job job: need test_job is not defined in current or prior stages'
+ end
+
+ context 'single need in following state - hyphen need' do
+ let(:config) do
+ <<-EOYML
+ stages:
+ - lint
+ - test
+ lint_job:
+ stage: lint
+ script: 'echo lint_job'
+ rules:
+ - if: $var == null
+ needs:
+ - test_job
+ test_job:
+ stage: test
+ script: 'echo job'
+ rules:
+ - if: $var == null
+ EOYML
+ end
+
+ it_behaves_like 'returns errors', 'lint_job job: need test_job is not defined in current or prior stages'
+ end
+
+ context 'when there are duplicate needs (string and hash)' do
+ let(:config) do
+ <<-EOYML
+ stages:
+ - test
+ test_job_1:
+ stage: test
+ script: 'echo lint_job'
+ rules:
+ - if: $var == null
+ needs:
+ - test_job_2
+ - job: test_job_2
+ test_job_2:
+ stage: test
+ script: 'echo job'
+ rules:
+ - if: $var == null
+ EOYML
+ end
+
+ it_behaves_like 'returns errors', 'test_job_1 has the following needs duplicated: test_job_2.'
+ end
+ end
+
+ context 'rule needs as hash' do
+ context 'single hash need in following stage' do
+ let(:config) do
+ <<-EOYML
+ stages:
+ - lint
+ - test
+ lint_job:
+ stage: lint
+ script: 'echo lint_job'
+ rules:
+ - if: $var == null
+ needs:
+ - job: test_job
+ artifacts: false
+ optional: false
+ test_job:
+ stage: test
+ script: 'echo job'
+ rules:
+ - if: $var == null
+ EOYML
+ end
+
+ it_behaves_like 'returns errors', 'lint_job job: need test_job is not defined in current or prior stages'
+ end
+ end
+
+ context 'job rule need does not exist' do
+ let(:config) do
+ <<-EOYML
+ build:
+ stage: build
+ script: echo
+ rules:
+ - when: always
+ test:
+ stage: test
+ script: echo
+ rules:
+ - if: $var == null
+ needs: [unknown_job]
+ EOYML
+ end
+
+ it_behaves_like 'has warnings and expected error', /test job: undefined need: unknown_job/
+ end
+ end
end
end
@@ -1685,7 +1870,8 @@ module Gitlab
key: 'key',
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
])
end
@@ -1710,7 +1896,8 @@ module Gitlab
key: { files: ['file'] },
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
])
end
@@ -1737,7 +1924,8 @@ module Gitlab
key: 'keya',
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
},
{
paths: ['logs/', 'binaries/'],
@@ -1745,7 +1933,8 @@ module Gitlab
key: 'key',
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
}
]
)
@@ -1773,7 +1962,8 @@ module Gitlab
key: { files: ['file'] },
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
])
end
@@ -1799,7 +1989,8 @@ module Gitlab
key: { files: ['file'], prefix: 'prefix' },
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
])
end
@@ -1823,7 +2014,8 @@ module Gitlab
key: 'local',
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
])
end
end
@@ -2395,10 +2587,16 @@ module Gitlab
end
end
- context 'undefined need' do
+ context 'when need is an undefined job' do
let(:needs) { ['undefined'] }
it_behaves_like 'returns errors', 'test1 job: undefined need: undefined'
+
+ context 'when need is optional' do
+ let(:needs) { [{ job: 'undefined', optional: true }] }
+
+ it { is_expected.to be_valid }
+ end
end
context 'needs to deploy' do
@@ -2408,9 +2606,33 @@ module Gitlab
end
context 'duplicate needs' do
- let(:needs) { %w(build1 build1) }
+ context 'when needs are specified in an array' do
+ let(:needs) { %w(build1 build1) }
+
+ it_behaves_like 'returns errors', 'test1 has the following needs duplicated: build1.'
+ end
+
+ context 'when a job is specified multiple times' do
+ let(:needs) do
+ [
+ { job: "build2", artifacts: true, optional: false },
+ { job: "build2", artifacts: true, optional: false }
+ ]
+ end
- it_behaves_like 'returns errors', 'test1 has duplicate entries in the needs section.'
+ it_behaves_like 'returns errors', 'test1 has the following needs duplicated: build2.'
+ end
+
+ context 'when job is specified multiple times with different attributes' do
+ let(:needs) do
+ [
+ { job: "build2", artifacts: false, optional: true },
+ { job: "build2", artifacts: true, optional: false }
+ ]
+ end
+
+ it_behaves_like 'returns errors', 'test1 has the following needs duplicated: build2.'
+ end
end
context 'needs and dependencies that are mismatching' do
diff --git a/spec/lib/gitlab/color_schemes_spec.rb b/spec/lib/gitlab/color_schemes_spec.rb
index feb5648ff2d..bc69c8beeda 100644
--- a/spec/lib/gitlab/color_schemes_spec.rb
+++ b/spec/lib/gitlab/color_schemes_spec.rb
@@ -21,8 +21,9 @@ RSpec.describe Gitlab::ColorSchemes do
end
describe '.default' do
- it 'returns the default scheme' do
- expect(described_class.default.id).to eq 1
+ it 'use config default' do
+ stub_application_setting(default_syntax_highlighting_theme: 2)
+ expect(described_class.default.id).to eq 2
end
end
@@ -36,7 +37,8 @@ RSpec.describe Gitlab::ColorSchemes do
describe '.for_user' do
it 'returns default when user is nil' do
- expect(described_class.for_user(nil).id).to eq 1
+ stub_application_setting(default_syntax_highlighting_theme: 2)
+ expect(described_class.for_user(nil).id).to eq 2
end
it "returns user's preferred color scheme" do
diff --git a/spec/lib/gitlab/color_spec.rb b/spec/lib/gitlab/color_spec.rb
index 28719aa6199..45815bb6e53 100644
--- a/spec/lib/gitlab/color_spec.rb
+++ b/spec/lib/gitlab/color_spec.rb
@@ -125,12 +125,12 @@ RSpec.describe Gitlab::Color do
expect(described_class.new('#fff')).to be_light
end
- specify '#a7a7a7 is light' do
- expect(described_class.new('#a7a7a7')).to be_light
+ specify '#c2c2c2 is light' do
+ expect(described_class.new('#c2c2c2')).to be_light
end
- specify '#a6a7a7 is dark' do
- expect(described_class.new('#a6a7a7')).not_to be_light
+ specify '#868686 is dark' do
+ expect(described_class.new('#868686')).not_to be_light
end
specify '#000 is dark' do
@@ -145,7 +145,7 @@ RSpec.describe Gitlab::Color do
describe '#contrast' do
context 'with light colors' do
it 'is dark' do
- %w[#fff #fefefe #a7a7a7].each do |hex|
+ %w[#fff #fefefe #c2c2c2].each do |hex|
expect(described_class.new(hex)).to have_attributes(
contrast: described_class::Constants::DARK,
luminosity: :light
diff --git a/spec/lib/gitlab/config/entry/validators_spec.rb b/spec/lib/gitlab/config/entry/validators_spec.rb
index 54a2adbefd2..abf3dbacb3d 100644
--- a/spec/lib/gitlab/config/entry/validators_spec.rb
+++ b/spec/lib/gitlab/config/entry/validators_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Config::Entry::Validators, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Config::Entry::Validators, feature_category: :pipeline_composition do
let(:klass) do
Class.new do
include ActiveModel::Validations
diff --git a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb
index bae98f9bc35..438f3e5b17a 100644
--- a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb
+++ b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb
@@ -2,26 +2,122 @@
require 'spec_helper'
-RSpec.describe Gitlab::Config::Loader::MultiDocYaml, feature_category: :pipeline_authoring do
- let(:loader) { described_class.new(yml, max_documents: 2) }
+RSpec.describe Gitlab::Config::Loader::MultiDocYaml, feature_category: :pipeline_composition do
+ let(:loader) { described_class.new(yml, max_documents: 2, reject_empty: reject_empty) }
+ let(:reject_empty) { false }
describe '#load!' do
- let(:yml) do
- <<~YAML
- spec:
- inputs:
- test_input:
- ---
- test_job:
- script: echo "$[[ inputs.test_input ]]"
- YAML
+ context 'when a simple single delimiter is being used' do
+ let(:yml) do
+ <<~YAML
+ spec:
+ inputs:
+ env:
+ ---
+ test:
+ script: echo "$[[ inputs.env ]]"
+ YAML
+ end
+
+ it 'returns the loaded YAML with all keys as symbols' do
+ expect(loader.load!).to contain_exactly(
+ { spec: { inputs: { env: nil } } },
+ { test: { script: 'echo "$[[ inputs.env ]]"' } }
+ )
+ end
+ end
+
+ context 'when the delimiter has a trailing configuration' do
+ let(:yml) do
+ <<~YAML
+ spec:
+ inputs:
+ test_input:
+ --- !test/content
+ test_job:
+ script: echo "$[[ inputs.test_input ]]"
+ YAML
+ end
+
+ it 'returns the loaded YAML with all keys as symbols' do
+ expect(loader.load!).to contain_exactly(
+ { spec: { inputs: { test_input: nil } } },
+ { test_job: { script: 'echo "$[[ inputs.test_input ]]"' } }
+ )
+ end
+ end
+
+ context 'when the YAML file has a leading delimiter' do
+ let(:yml) do
+ <<~YAML
+ ---
+ spec:
+ inputs:
+ test_input:
+ --- !test/content
+ test_job:
+ script: echo "$[[ inputs.test_input ]]"
+ YAML
+ end
+
+ it 'returns the loaded YAML with all keys as symbols' do
+ expect(loader.load!).to contain_exactly(
+ { spec: { inputs: { test_input: nil } } },
+ { test_job: { script: 'echo "$[[ inputs.test_input ]]"' } }
+ )
+ end
+ end
+
+ context 'when the delimiter is followed by content on the same line' do
+ let(:yml) do
+ <<~YAML
+ --- a: 1
+ --- b: 2
+ YAML
+ end
+
+ it 'loads the content as part of the document' do
+ expect(loader.load!).to contain_exactly({ a: 1 }, { b: 2 })
+ end
end
- it 'returns the loaded YAML with all keys as symbols' do
- expect(loader.load!).to eq([
- { spec: { inputs: { test_input: nil } } },
- { test_job: { script: 'echo "$[[ inputs.test_input ]]"' } }
- ])
+ context 'when the delimiter does not have trailing whitespace' do
+ let(:yml) do
+ <<~YAML
+ --- a: 1
+ ---b: 2
+ YAML
+ end
+
+ it 'is not a valid delimiter' do
+ expect(loader.load!).to contain_exactly({ :'---b' => 2, a: 1 }) # rubocop:disable Style/HashSyntax
+ end
+ end
+
+ context 'when the YAML file has whitespace preceding the content' do
+ let(:yml) do
+ <<-EOYML
+ variables:
+ SUPPORTED: "parsed"
+
+ workflow:
+ rules:
+ - if: $VAR == "value"
+
+ hello:
+ script: echo world
+ EOYML
+ end
+
+ it 'loads everything correctly' do
+ expect(loader.load!).to contain_exactly(
+ {
+ variables: { SUPPORTED: 'parsed' },
+ workflow: { rules: [{ if: '$VAR == "value"' }] },
+ hello: { script: 'echo world' }
+ }
+ )
+ end
end
context 'when the YAML file is empty' do
@@ -32,67 +128,89 @@ RSpec.describe Gitlab::Config::Loader::MultiDocYaml, feature_category: :pipeline
end
end
- context 'when the parsed YAML is too big' do
+ context 'when there are more than the maximum number of documents' do
+ let(:yml) do
+ <<~YAML
+ --- a: 1
+ --- b: 2
+ --- c: 3
+ --- d: 4
+ YAML
+ end
+
+ it 'stops splitting documents after the maximum number' do
+ expect(loader.load!).to contain_exactly({ a: 1 }, { b: 2 })
+ end
+ end
+
+ context 'when the YAML contains empty documents' do
let(:yml) do
<<~YAML
- a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]
- b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
- c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
- d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
- e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
- f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]
- g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f]
- h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g]
- i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h]
+ a: 1
---
- a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]
- b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
- c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
- d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
- e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
- f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]
- g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f]
- h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g]
- i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h]
YAML
end
- it 'raises a DataTooLargeError' do
- expect { loader.load! }.to raise_error(described_class::DataTooLargeError, 'The parsed YAML is too big')
+ it 'raises an error' do
+ expect { loader.load! }.to raise_error(::Gitlab::Config::Loader::Yaml::NotHashError)
+ end
+
+ context 'when reject_empty: true' do
+ let(:reject_empty) { true }
+
+ it 'loads only non empty documents' do
+ expect(loader.load!).to contain_exactly({ a: 1 })
+ end
end
end
+ end
- context 'when a document is not a hash' do
+ describe '#load_raw!' do
+ let(:yml) do
+ <<~YAML
+ spec:
+ inputs:
+ test_input:
+ --- !test/content
+ test_job:
+ script: echo "$[[ inputs.test_input ]]"
+ YAML
+ end
+
+ it 'returns the loaded YAML with all keys as strings' do
+ expect(loader.load_raw!).to contain_exactly(
+ { 'spec' => { 'inputs' => { 'test_input' => nil } } },
+ { 'test_job' => { 'script' => 'echo "$[[ inputs.test_input ]]"' } }
+ )
+ end
+ end
+
+ describe '#valid?' do
+ context 'when a document is invalid' do
let(:yml) do
<<~YAML
- not_a_hash
+ a: b
---
- test_job:
- script: echo "$[[ inputs.test_input ]]"
+ c
YAML
end
- it 'raises a NotHashError' do
- expect { loader.load! }.to raise_error(described_class::NotHashError, 'Invalid configuration format')
+ it 'returns false' do
+ expect(loader).not_to be_valid
end
end
- context 'when there are too many documents' do
+ context 'when the number of documents is below the maximum and all documents are valid' do
let(:yml) do
<<~YAML
a: b
---
c: d
- ---
- e: f
YAML
end
- it 'raises a TooManyDocumentsError' do
- expect { loader.load! }.to raise_error(
- described_class::TooManyDocumentsError,
- 'The parsed YAML has too many documents'
- )
+ it 'returns true' do
+ expect(loader).to be_valid
end
end
end
diff --git a/spec/lib/gitlab/config/loader/yaml_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb
index 346424d1681..bba66f33718 100644
--- a/spec/lib/gitlab/config/loader/yaml_spec.rb
+++ b/spec/lib/gitlab/config/loader/yaml_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Config::Loader::Yaml, feature_category: :pipeline_authoring do
+RSpec.describe Gitlab::Config::Loader::Yaml, feature_category: :pipeline_composition do
let(:loader) { described_class.new(yml) }
let(:yml) do
@@ -182,4 +182,30 @@ RSpec.describe Gitlab::Config::Loader::Yaml, feature_category: :pipeline_authori
)
end
end
+
+ describe '#blank?' do
+ context 'when the loaded YAML is empty' do
+ let(:yml) do
+ <<~YAML
+ # only comments here
+ YAML
+ end
+
+ it 'returns true' do
+ expect(loader).to be_blank
+ end
+ end
+
+ context 'when the loaded YAML has content' do
+ let(:yml) do
+ <<~YAML
+ test: value
+ YAML
+ end
+
+ it 'returns false' do
+ expect(loader).not_to be_blank
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/config_checker/external_database_checker_spec.rb b/spec/lib/gitlab/config_checker/external_database_checker_spec.rb
index c962b9ad393..6379a5edb90 100644
--- a/spec/lib/gitlab/config_checker/external_database_checker_spec.rb
+++ b/spec/lib/gitlab/config_checker/external_database_checker_spec.rb
@@ -89,9 +89,9 @@ RSpec.describe Gitlab::ConfigChecker::ExternalDatabaseChecker do
{
type: 'warning',
message: _('Database \'%{database_name}\' is using PostgreSQL %{pg_version_current}, ' \
- 'but PostgreSQL %{pg_version_minimum} is required for this version of GitLab. ' \
- 'Please upgrade your environment to a supported PostgreSQL version, ' \
- 'see %{pg_requirements_url} for details.') % \
+ 'but this version of GitLab requires PostgreSQL %{pg_version_minimum}. ' \
+ 'Please upgrade your environment to a supported PostgreSQL version. ' \
+ 'See %{pg_requirements_url} for details.') % \
{
database_name: database_name,
pg_version_current: database_version,
diff --git a/spec/lib/gitlab/console_spec.rb b/spec/lib/gitlab/console_spec.rb
index f043433b4c5..5723a4421f6 100644
--- a/spec/lib/gitlab/console_spec.rb
+++ b/spec/lib/gitlab/console_spec.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
-RSpec.describe Gitlab::Console do
+RSpec.describe Gitlab::Console, feature_category: :application_instrumentation do
describe '.welcome!' do
context 'when running in the Rails console' do
before do
diff --git a/spec/lib/gitlab/consul/internal_spec.rb b/spec/lib/gitlab/consul/internal_spec.rb
index 28dcaac9ff2..cd3436b3fa4 100644
--- a/spec/lib/gitlab/consul/internal_spec.rb
+++ b/spec/lib/gitlab/consul/internal_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Gitlab::Consul::Internal do
context 'when consul setting is not present in gitlab.yml' do
before do
- allow(Gitlab.config).to receive(:consul).and_raise(Settingslogic::MissingSetting)
+ allow(Gitlab.config).to receive(:consul).and_raise(GitlabSettings::MissingSetting)
end
it 'does not fail' do
@@ -77,9 +77,11 @@ RSpec.describe Gitlab::Consul::Internal do
shared_examples 'returns nil given blank value of' do |input_symbol|
[nil, ''].each do |value|
- let(input_symbol) { value }
+ context "with #{value}" do
+ let(input_symbol) { value }
- it { is_expected.to be_nil }
+ it { is_expected.to be_nil }
+ end
end
end
diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
index f298890623f..b40829d72a0 100644
--- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
+++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
@@ -102,11 +102,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do
end
describe 'Zuora directives' do
- context 'when is Gitlab.com?' do
- before do
- allow(::Gitlab).to receive(:com?).and_return(true)
- end
-
+ context 'when on SaaS', :saas do
it 'adds Zuora host to CSP' do
expect(directives['frame_src']).to include('https://*.zuora.com/apps/PublicHostedPageLite.do')
end
diff --git a/spec/lib/gitlab/data_builder/deployment_spec.rb b/spec/lib/gitlab/data_builder/deployment_spec.rb
index bf08e782035..82ec3e791a4 100644
--- a/spec/lib/gitlab/data_builder/deployment_spec.rb
+++ b/spec/lib/gitlab/data_builder/deployment_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::DataBuilder::Deployment do
+RSpec.describe Gitlab::DataBuilder::Deployment, feature_category: :continuous_delivery do
describe '.build' do
it 'returns the object kind for a deployment' do
deployment = build(:deployment, deployable: nil, environment: create(:environment))
@@ -40,6 +40,7 @@ RSpec.describe Gitlab::DataBuilder::Deployment do
expect(data[:commit_url]).to eq(expected_commit_url)
expect(data[:commit_title]).to eq(commit.title)
expect(data[:ref]).to eq(deployment.ref)
+ expect(data[:environment_tier]).to eq('other')
end
it 'does not include the deployable URL when there is no deployable' do
diff --git a/spec/lib/gitlab/database/async_constraints/migration_helpers_spec.rb b/spec/lib/gitlab/database/async_constraints/migration_helpers_spec.rb
new file mode 100644
index 00000000000..4dd510499ab
--- /dev/null
+++ b/spec/lib/gitlab/database/async_constraints/migration_helpers_spec.rb
@@ -0,0 +1,288 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::AsyncConstraints::MigrationHelpers, feature_category: :database do
+ let(:migration) { Gitlab::Database::Migration[2.1].new }
+ let(:connection) { ApplicationRecord.connection }
+ let(:constraint_model) { Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation }
+ let(:table_name) { '_test_async_fks' }
+ let(:column_name) { 'parent_id' }
+ let(:fk_name) { nil }
+
+ context 'with async FK validation on regular tables' do
+ before do
+ allow(migration).to receive(:puts)
+ allow(migration.connection).to receive(:transaction_open?).and_return(false)
+
+ connection.create_table(table_name) do |t|
+ t.integer column_name
+ end
+
+ migration.add_concurrent_foreign_key(
+ table_name, table_name,
+ column: column_name, validate: false, name: fk_name)
+ end
+
+ describe '#prepare_async_foreign_key_validation' do
+ it 'creates the record for the async FK validation' do
+ expect do
+ migration.prepare_async_foreign_key_validation(table_name, column_name)
+ end.to change { constraint_model.where(table_name: table_name).count }.by(1)
+
+ record = constraint_model.find_by(table_name: table_name)
+
+ expect(record.name).to start_with('fk_')
+ expect(record).to be_foreign_key
+ end
+
+ context 'when an explicit name is given' do
+ let(:fk_name) { 'my_fk_name' }
+
+ it 'creates the record with the given name' do
+ expect do
+ migration.prepare_async_foreign_key_validation(table_name, name: fk_name)
+ end.to change { constraint_model.where(name: fk_name).count }.by(1)
+
+ record = constraint_model.find_by(name: fk_name)
+
+ expect(record.table_name).to eq(table_name)
+ expect(record).to be_foreign_key
+ end
+ end
+
+ context 'when the FK does not exist' do
+ it 'returns an error' do
+ expect do
+ migration.prepare_async_foreign_key_validation(table_name, name: 'no_fk')
+ end.to raise_error RuntimeError, /Could not find foreign key "no_fk" on table "_test_async_fks"/
+ end
+ end
+
+ context 'when the record already exists' do
+ let(:fk_name) { 'my_fk_name' }
+
+ it 'does attempt to create the record' do
+ create(:postgres_async_constraint_validation, table_name: table_name, name: fk_name)
+
+ expect do
+ migration.prepare_async_foreign_key_validation(table_name, name: fk_name)
+ end.not_to change { constraint_model.where(name: fk_name).count }
+ end
+ end
+
+ context 'when the async FK validation table does not exist' do
+ it 'does not raise an error' do
+ connection.drop_table(constraint_model.table_name)
+
+ expect(constraint_model).not_to receive(:safe_find_or_create_by!)
+
+ expect { migration.prepare_async_foreign_key_validation(table_name, column_name) }.not_to raise_error
+ end
+ end
+ end
+
+ describe '#unprepare_async_foreign_key_validation' do
+ context 'with foreign keys' do
+ before do
+ migration.prepare_async_foreign_key_validation(table_name, column_name, name: fk_name)
+ end
+
+ it 'destroys the record' do
+ expect do
+ migration.unprepare_async_foreign_key_validation(table_name, column_name)
+ end.to change { constraint_model.where(table_name: table_name).count }.by(-1)
+ end
+
+ context 'when an explicit name is given' do
+ let(:fk_name) { 'my_test_async_fk' }
+
+ it 'destroys the record' do
+ expect do
+ migration.unprepare_async_foreign_key_validation(table_name, name: fk_name)
+ end.to change { constraint_model.where(name: fk_name).count }.by(-1)
+ end
+ end
+
+ context 'when the async fk validation table does not exist' do
+ it 'does not raise an error' do
+ connection.drop_table(constraint_model.table_name)
+
+ expect(constraint_model).not_to receive(:find_by)
+
+ expect { migration.unprepare_async_foreign_key_validation(table_name, column_name) }.not_to raise_error
+ end
+ end
+ end
+
+ context 'with other types of constraints' do
+ let(:name) { 'my_test_async_constraint' }
+ let(:constraint) { create(:postgres_async_constraint_validation, table_name: table_name, name: name) }
+
+ it 'does not destroy the record' do
+ constraint.update_column(:constraint_type, 99)
+
+ expect do
+ migration.unprepare_async_foreign_key_validation(table_name, name: name)
+ end.not_to change { constraint_model.where(name: name).count }
+
+ expect(constraint).to be_present
+ end
+ end
+ end
+ end
+
+ context 'with partitioned tables' do
+ let(:partition_schema) { 'gitlab_partitions_dynamic' }
+ let(:partition1_name) { "#{partition_schema}.#{table_name}_202001" }
+ let(:partition2_name) { "#{partition_schema}.#{table_name}_202002" }
+ let(:fk_name) { 'my_partitioned_fk_name' }
+
+ before do
+ connection.execute(<<~SQL)
+ CREATE TABLE #{table_name} (
+ id serial NOT NULL,
+ #{column_name} int NOT NULL,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at);
+
+ CREATE TABLE #{partition1_name} PARTITION OF #{table_name}
+ FOR VALUES FROM ('2020-01-01') TO ('2020-02-01');
+
+ CREATE TABLE #{partition2_name} PARTITION OF #{table_name}
+ FOR VALUES FROM ('2020-02-01') TO ('2020-03-01');
+ SQL
+ end
+
+ describe '#prepare_partitioned_async_foreign_key_validation' do
+ it 'delegates to prepare_async_foreign_key_validation for each partition' do
+ expect(migration)
+ .to receive(:prepare_async_foreign_key_validation)
+ .with(partition1_name, column_name, name: fk_name)
+
+ expect(migration)
+ .to receive(:prepare_async_foreign_key_validation)
+ .with(partition2_name, column_name, name: fk_name)
+
+ migration.prepare_partitioned_async_foreign_key_validation(table_name, column_name, name: fk_name)
+ end
+ end
+
+ describe '#unprepare_partitioned_async_foreign_key_validation' do
+ it 'delegates to unprepare_async_foreign_key_validation for each partition' do
+ expect(migration)
+ .to receive(:unprepare_async_foreign_key_validation)
+ .with(partition1_name, column_name, name: fk_name)
+
+ expect(migration)
+ .to receive(:unprepare_async_foreign_key_validation)
+ .with(partition2_name, column_name, name: fk_name)
+
+ migration.unprepare_partitioned_async_foreign_key_validation(table_name, column_name, name: fk_name)
+ end
+ end
+ end
+
+ context 'with async check constraint validations' do
+ let(:table_name) { '_test_async_check_constraints' }
+ let(:check_name) { 'partitioning_constraint' }
+
+ before do
+ allow(migration).to receive(:puts)
+ allow(migration.connection).to receive(:transaction_open?).and_return(false)
+
+ connection.create_table(table_name) do |t|
+ t.integer column_name
+ end
+
+ migration.add_check_constraint(
+ table_name, "#{column_name} = 1",
+ check_name, validate: false)
+ end
+
+ describe '#prepare_async_check_constraint_validation' do
+ it 'creates the record for async validation' do
+ expect do
+ migration.prepare_async_check_constraint_validation(table_name, name: check_name)
+ end.to change { constraint_model.where(name: check_name).count }.by(1)
+
+ record = constraint_model.find_by(name: check_name)
+
+ expect(record.table_name).to eq(table_name)
+ expect(record).to be_check_constraint
+ end
+
+ context 'when the check constraint does not exist' do
+ it 'returns an error' do
+ expect do
+ migration.prepare_async_check_constraint_validation(table_name, name: 'missing')
+ end.to raise_error RuntimeError, /Could not find check constraint "missing" on table "#{table_name}"/
+ end
+ end
+
+ context 'when the record already exists' do
+ it 'does attempt to create the record' do
+ create(:postgres_async_constraint_validation,
+ table_name: table_name,
+ name: check_name,
+ constraint_type: :check_constraint)
+
+ expect do
+ migration.prepare_async_check_constraint_validation(table_name, name: check_name)
+ end.not_to change { constraint_model.where(name: check_name).count }
+ end
+ end
+
+ context 'when the async validation table does not exist' do
+ it 'does not raise an error' do
+ connection.drop_table(constraint_model.table_name)
+
+ expect(constraint_model).not_to receive(:safe_find_or_create_by!)
+
+ expect { migration.prepare_async_check_constraint_validation(table_name, name: check_name) }
+ .not_to raise_error
+ end
+ end
+ end
+
+ describe '#unprepare_async_check_constraint_validation' do
+ context 'with check constraints' do
+ before do
+ migration.prepare_async_check_constraint_validation(table_name, name: check_name)
+ end
+
+ it 'destroys the record' do
+ expect do
+ migration.unprepare_async_check_constraint_validation(table_name, name: check_name)
+ end.to change { constraint_model.where(name: check_name).count }.by(-1)
+ end
+
+ context 'when the async validation table does not exist' do
+ it 'does not raise an error' do
+ connection.drop_table(constraint_model.table_name)
+
+ expect(constraint_model).not_to receive(:find_by)
+
+ expect { migration.unprepare_async_check_constraint_validation(table_name, name: check_name) }
+ .not_to raise_error
+ end
+ end
+ end
+
+ context 'with other types of constraints' do
+ let(:constraint) { create(:postgres_async_constraint_validation, table_name: table_name, name: check_name) }
+
+ it 'does not destroy the record' do
+ constraint.update_column(:constraint_type, 99)
+
+ expect do
+ migration.unprepare_async_check_constraint_validation(table_name, name: check_name)
+ end.not_to change { constraint_model.where(name: check_name).count }
+
+ expect(constraint).to be_present
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/async_constraints/postgres_async_constraint_validation_spec.rb b/spec/lib/gitlab/database/async_constraints/postgres_async_constraint_validation_spec.rb
new file mode 100644
index 00000000000..52fbf6d2f9b
--- /dev/null
+++ b/spec/lib/gitlab/database/async_constraints/postgres_async_constraint_validation_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation, type: :model,
+ feature_category: :database do
+ it { is_expected.to be_a Gitlab::Database::SharedModel }
+
+ describe 'validations' do
+ let_it_be(:constraint_validation) { create(:postgres_async_constraint_validation) }
+ let(:identifier_limit) { described_class::MAX_IDENTIFIER_LENGTH }
+ let(:last_error_limit) { described_class::MAX_LAST_ERROR_LENGTH }
+
+ subject { constraint_validation }
+
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to(:table_name) }
+ it { is_expected.to validate_length_of(:name).is_at_most(identifier_limit) }
+ it { is_expected.to validate_presence_of(:table_name) }
+ it { is_expected.to validate_length_of(:table_name).is_at_most(identifier_limit) }
+ it { is_expected.to validate_length_of(:last_error).is_at_most(last_error_limit) }
+ end
+
+ describe 'scopes' do
+ let!(:failed_validation) { create(:postgres_async_constraint_validation, attempts: 1) }
+ let!(:new_validation) { create(:postgres_async_constraint_validation) }
+
+ describe '.ordered' do
+ subject { described_class.ordered }
+
+ it { is_expected.to eq([new_validation, failed_validation]) }
+ end
+
+ describe '.foreign_key_type' do
+ before do
+ new_validation.update_column(:constraint_type, 99)
+ end
+
+ subject { described_class.foreign_key_type }
+
+ it { is_expected.to eq([failed_validation]) }
+
+ it 'does not apply the filter if the column is not present' do
+ expect(described_class)
+ .to receive(:constraint_type_exists?)
+ .and_return(false)
+
+ is_expected.to match_array([failed_validation, new_validation])
+ end
+ end
+
+ describe '.check_constraint_type' do
+ before do
+ new_validation.update!(constraint_type: :check_constraint)
+ end
+
+ subject { described_class.check_constraint_type }
+
+ it { is_expected.to eq([new_validation]) }
+ end
+ end
+
+ describe '.table_available?' do
+ subject { described_class.table_available? }
+
+ it { is_expected.to be_truthy }
+
+ context 'when the table does not exist' do
+ before do
+ described_class
+ .connection
+ .drop_table(described_class.table_name)
+ end
+
+ it { is_expected.to be_falsy }
+ end
+ end
+
+ describe '.constraint_type_exists?' do
+ it { expect(described_class.constraint_type_exists?).to be_truthy }
+
+ it 'always asks the database' do
+ control = ActiveRecord::QueryRecorder.new(skip_schema_queries: false) do
+ described_class.constraint_type_exists?
+ end
+
+ expect(control.count).to be >= 1
+ expect { described_class.constraint_type_exists? }.to issue_same_number_of_queries_as(control)
+ end
+ end
+
+ describe '#handle_exception!' do
+ let_it_be_with_reload(:constraint_validation) { create(:postgres_async_constraint_validation) }
+
+ let(:error) { instance_double(StandardError, message: 'Oups', backtrace: %w[this that]) }
+
+ subject { constraint_validation.handle_exception!(error) }
+
+ it 'increases the attempts number' do
+ expect { subject }.to change { constraint_validation.reload.attempts }.by(1)
+ end
+
+ it 'saves error details' do
+ subject
+
+ expect(constraint_validation.reload.last_error).to eq("Oups\nthis\nthat")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/async_constraints/validators/check_constraint_spec.rb b/spec/lib/gitlab/database/async_constraints/validators/check_constraint_spec.rb
new file mode 100644
index 00000000000..7622b39feb1
--- /dev/null
+++ b/spec/lib/gitlab/database/async_constraints/validators/check_constraint_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::AsyncConstraints::Validators::CheckConstraint, feature_category: :database do
+ it_behaves_like 'async constraints validation' do
+ let(:constraint_type) { :check_constraint }
+
+ before do
+ connection.create_table(table_name) do |t|
+ t.integer :parent_id
+ end
+
+ connection.execute(<<~SQL.squish)
+ ALTER TABLE #{table_name} ADD CONSTRAINT #{constraint_name}
+ CHECK ( parent_id = 101 ) NOT VALID;
+ SQL
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/async_constraints/validators/foreign_key_spec.rb b/spec/lib/gitlab/database/async_constraints/validators/foreign_key_spec.rb
new file mode 100644
index 00000000000..0e345e0e9ae
--- /dev/null
+++ b/spec/lib/gitlab/database/async_constraints/validators/foreign_key_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::AsyncConstraints::Validators::ForeignKey, feature_category: :database do
+ it_behaves_like 'async constraints validation' do
+ let(:constraint_type) { :foreign_key }
+
+ before do
+ connection.create_table(table_name) do |t|
+ t.references :parent, foreign_key: { to_table: table_name, validate: false, name: constraint_name }
+ end
+ end
+
+ context 'with fully qualified table names' do
+ let(:validation) do
+ create(:postgres_async_constraint_validation,
+ table_name: "public.#{table_name}",
+ name: constraint_name,
+ constraint_type: constraint_type
+ )
+ end
+
+ it 'validates the constraint' do
+ allow(connection).to receive(:execute).and_call_original
+
+ expect(connection).to receive(:execute)
+ .with(/ALTER TABLE "public"."#{table_name}" VALIDATE CONSTRAINT "#{constraint_name}";/)
+ .ordered.and_call_original
+
+ subject.perform
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/async_constraints/validators_spec.rb b/spec/lib/gitlab/database/async_constraints/validators_spec.rb
new file mode 100644
index 00000000000..e903b79dd1b
--- /dev/null
+++ b/spec/lib/gitlab/database/async_constraints/validators_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::AsyncConstraints::Validators, feature_category: :database do
+ describe '.for' do
+ subject { described_class.for(record) }
+
+ context 'with foreign keys validations' do
+ let(:record) { build(:postgres_async_constraint_validation, :foreign_key) }
+
+ it { is_expected.to be_a(described_class::ForeignKey) }
+ end
+
+ context 'with check constraint validations' do
+ let(:record) { build(:postgres_async_constraint_validation, :check_constraint) }
+
+ it { is_expected.to be_a(described_class::CheckConstraint) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/async_constraints_spec.rb b/spec/lib/gitlab/database/async_constraints_spec.rb
new file mode 100644
index 00000000000..e5cf782485f
--- /dev/null
+++ b/spec/lib/gitlab/database/async_constraints_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::AsyncConstraints, feature_category: :database do
+ describe '.validate_pending_entries!' do
+ subject { described_class.validate_pending_entries! }
+
+ let!(:fk_validation) do
+ create(:postgres_async_constraint_validation, :foreign_key, attempts: 2)
+ end
+
+ let(:check_validation) do
+ create(:postgres_async_constraint_validation, :check_constraint, attempts: 1)
+ end
+
+ it 'executes pending validations' do
+ expect_next_instance_of(described_class::Validators::ForeignKey, fk_validation) do |validator|
+ expect(validator).to receive(:perform)
+ end
+
+ expect_next_instance_of(described_class::Validators::CheckConstraint, check_validation) do |validator|
+ expect(validator).to receive(:perform)
+ end
+
+ subject
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/async_foreign_keys/foreign_key_validator_spec.rb b/spec/lib/gitlab/database/async_foreign_keys/foreign_key_validator_spec.rb
deleted file mode 100644
index 90137e259f5..00000000000
--- a/spec/lib/gitlab/database/async_foreign_keys/foreign_key_validator_spec.rb
+++ /dev/null
@@ -1,152 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Database::AsyncForeignKeys::ForeignKeyValidator, feature_category: :database do
- include ExclusiveLeaseHelpers
-
- describe '#perform' do
- let!(:lease) { stub_exclusive_lease(lease_key, :uuid, timeout: lease_timeout) }
- let(:lease_key) { "gitlab/database/asyncddl/actions/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" }
- let(:lease_timeout) { described_class::TIMEOUT_PER_ACTION }
-
- let(:fk_model) { Gitlab::Database::AsyncForeignKeys::PostgresAsyncForeignKeyValidation }
- let(:table_name) { '_test_async_fks' }
- let(:fk_name) { 'fk_parent_id' }
- let(:validation) { create(:postgres_async_foreign_key_validation, table_name: table_name, name: fk_name) }
- let(:connection) { validation.connection }
-
- subject { described_class.new(validation) }
-
- before do
- connection.create_table(table_name) do |t|
- t.references :parent, foreign_key: { to_table: table_name, validate: false, name: fk_name }
- end
- end
-
- it 'validates the FK while controlling statement timeout' do
- allow(connection).to receive(:execute).and_call_original
- expect(connection).to receive(:execute)
- .with("SET statement_timeout TO '43200s'").ordered.and_call_original
- expect(connection).to receive(:execute)
- .with('ALTER TABLE "_test_async_fks" VALIDATE CONSTRAINT "fk_parent_id";').ordered.and_call_original
- expect(connection).to receive(:execute)
- .with("RESET statement_timeout").ordered.and_call_original
-
- subject.perform
- end
-
- context 'with fully qualified table names' do
- let(:validation) do
- create(:postgres_async_foreign_key_validation,
- table_name: "public.#{table_name}",
- name: fk_name
- )
- end
-
- it 'validates the FK' do
- allow(connection).to receive(:execute).and_call_original
-
- expect(connection).to receive(:execute)
- .with('ALTER TABLE "public"."_test_async_fks" VALIDATE CONSTRAINT "fk_parent_id";').ordered.and_call_original
-
- subject.perform
- end
- end
-
- it 'removes the FK validation record from table' do
- expect(validation).to receive(:destroy!).and_call_original
-
- expect { subject.perform }.to change { fk_model.count }.by(-1)
- end
-
- it 'skips logic if not able to acquire exclusive lease' do
- expect(lease).to receive(:try_obtain).ordered.and_return(false)
- expect(connection).not_to receive(:execute).with(/ALTER TABLE/)
- expect(validation).not_to receive(:destroy!)
-
- expect { subject.perform }.not_to change { fk_model.count }
- end
-
- it 'logs messages around execution' do
- allow(Gitlab::AppLogger).to receive(:info).and_call_original
-
- subject.perform
-
- expect(Gitlab::AppLogger)
- .to have_received(:info)
- .with(a_hash_including(message: 'Starting to validate foreign key'))
-
- expect(Gitlab::AppLogger)
- .to have_received(:info)
- .with(a_hash_including(message: 'Finished validating foreign key'))
- end
-
- context 'when the FK does not exist' do
- before do
- connection.create_table(table_name, force: true)
- end
-
- it 'skips validation and removes the record' do
- expect(connection).not_to receive(:execute).with(/ALTER TABLE/)
-
- expect { subject.perform }.to change { fk_model.count }.by(-1)
- end
-
- it 'logs an appropriate message' do
- expected_message = "Skipping #{fk_name} validation since it does not exist. The queuing entry will be deleted"
-
- allow(Gitlab::AppLogger).to receive(:info).and_call_original
-
- subject.perform
-
- expect(Gitlab::AppLogger)
- .to have_received(:info)
- .with(a_hash_including(message: expected_message))
- end
- end
-
- context 'with error handling' do
- before do
- allow(connection).to receive(:execute).and_call_original
-
- allow(connection).to receive(:execute)
- .with('ALTER TABLE "_test_async_fks" VALIDATE CONSTRAINT "fk_parent_id";')
- .and_raise(ActiveRecord::StatementInvalid)
- end
-
- context 'on production' do
- before do
- allow(Gitlab::ErrorTracking).to receive(:should_raise_for_dev?).and_return(false)
- end
-
- it 'increases execution attempts' do
- expect { subject.perform }.to change { validation.attempts }.by(1)
-
- expect(validation.last_error).to be_present
- expect(validation).not_to be_destroyed
- end
-
- it 'logs an error message including the fk_name' do
- expect(Gitlab::AppLogger)
- .to receive(:error)
- .with(a_hash_including(:message, :fk_name))
- .and_call_original
-
- subject.perform
- end
- end
-
- context 'on development' do
- it 'also raises errors' do
- expect { subject.perform }
- .to raise_error(ActiveRecord::StatementInvalid)
- .and change { validation.attempts }.by(1)
-
- expect(validation.last_error).to be_present
- expect(validation).not_to be_destroyed
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/database/async_foreign_keys/migration_helpers_spec.rb b/spec/lib/gitlab/database/async_foreign_keys/migration_helpers_spec.rb
deleted file mode 100644
index 0bd0e8045ff..00000000000
--- a/spec/lib/gitlab/database/async_foreign_keys/migration_helpers_spec.rb
+++ /dev/null
@@ -1,167 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Database::AsyncForeignKeys::MigrationHelpers, feature_category: :database do
- let(:migration) { Gitlab::Database::Migration[2.1].new }
- let(:connection) { ApplicationRecord.connection }
- let(:fk_model) { Gitlab::Database::AsyncForeignKeys::PostgresAsyncForeignKeyValidation }
- let(:table_name) { '_test_async_fks' }
- let(:column_name) { 'parent_id' }
- let(:fk_name) { nil }
-
- context 'with regular tables' do
- before do
- allow(migration).to receive(:puts)
- allow(migration.connection).to receive(:transaction_open?).and_return(false)
-
- connection.create_table(table_name) do |t|
- t.integer column_name
- end
-
- migration.add_concurrent_foreign_key(
- table_name, table_name,
- column: column_name, validate: false, name: fk_name)
- end
-
- describe '#prepare_async_foreign_key_validation' do
- it 'creates the record for the async FK validation' do
- expect do
- migration.prepare_async_foreign_key_validation(table_name, column_name)
- end.to change { fk_model.where(table_name: table_name).count }.by(1)
-
- record = fk_model.find_by(table_name: table_name)
-
- expect(record.name).to start_with('fk_')
- end
-
- context 'when an explicit name is given' do
- let(:fk_name) { 'my_fk_name' }
-
- it 'creates the record with the given name' do
- expect do
- migration.prepare_async_foreign_key_validation(table_name, name: fk_name)
- end.to change { fk_model.where(name: fk_name).count }.by(1)
-
- record = fk_model.find_by(name: fk_name)
-
- expect(record.table_name).to eq(table_name)
- end
- end
-
- context 'when the FK does not exist' do
- it 'returns an error' do
- expect do
- migration.prepare_async_foreign_key_validation(table_name, name: 'no_fk')
- end.to raise_error RuntimeError, /Could not find foreign key "no_fk" on table "_test_async_fks"/
- end
- end
-
- context 'when the record already exists' do
- let(:fk_name) { 'my_fk_name' }
-
- it 'does attempt to create the record' do
- create(:postgres_async_foreign_key_validation, table_name: table_name, name: fk_name)
-
- expect do
- migration.prepare_async_foreign_key_validation(table_name, name: fk_name)
- end.not_to change { fk_model.where(name: fk_name).count }
- end
- end
-
- context 'when the async FK validation table does not exist' do
- it 'does not raise an error' do
- connection.drop_table(:postgres_async_foreign_key_validations)
-
- expect(fk_model).not_to receive(:safe_find_or_create_by!)
-
- expect { migration.prepare_async_foreign_key_validation(table_name, column_name) }.not_to raise_error
- end
- end
- end
-
- describe '#unprepare_async_foreign_key_validation' do
- before do
- migration.prepare_async_foreign_key_validation(table_name, column_name, name: fk_name)
- end
-
- it 'destroys the record' do
- expect do
- migration.unprepare_async_foreign_key_validation(table_name, column_name)
- end.to change { fk_model.where(table_name: table_name).count }.by(-1)
- end
-
- context 'when an explicit name is given' do
- let(:fk_name) { 'my_test_async_fk' }
-
- it 'destroys the record' do
- expect do
- migration.unprepare_async_foreign_key_validation(table_name, name: fk_name)
- end.to change { fk_model.where(name: fk_name).count }.by(-1)
- end
- end
-
- context 'when the async fk validation table does not exist' do
- it 'does not raise an error' do
- connection.drop_table(:postgres_async_foreign_key_validations)
-
- expect(fk_model).not_to receive(:find_by)
-
- expect { migration.unprepare_async_foreign_key_validation(table_name, column_name) }.not_to raise_error
- end
- end
- end
- end
-
- context 'with partitioned tables' do
- let(:partition_schema) { 'gitlab_partitions_dynamic' }
- let(:partition1_name) { "#{partition_schema}.#{table_name}_202001" }
- let(:partition2_name) { "#{partition_schema}.#{table_name}_202002" }
- let(:fk_name) { 'my_partitioned_fk_name' }
-
- before do
- connection.execute(<<~SQL)
- CREATE TABLE #{table_name} (
- id serial NOT NULL,
- #{column_name} int NOT NULL,
- created_at timestamptz NOT NULL,
- PRIMARY KEY (id, created_at)
- ) PARTITION BY RANGE (created_at);
-
- CREATE TABLE #{partition1_name} PARTITION OF #{table_name}
- FOR VALUES FROM ('2020-01-01') TO ('2020-02-01');
-
- CREATE TABLE #{partition2_name} PARTITION OF #{table_name}
- FOR VALUES FROM ('2020-02-01') TO ('2020-03-01');
- SQL
- end
-
- describe '#prepare_partitioned_async_foreign_key_validation' do
- it 'delegates to prepare_async_foreign_key_validation for each partition' do
- expect(migration)
- .to receive(:prepare_async_foreign_key_validation)
- .with(partition1_name, column_name, name: fk_name)
-
- expect(migration)
- .to receive(:prepare_async_foreign_key_validation)
- .with(partition2_name, column_name, name: fk_name)
-
- migration.prepare_partitioned_async_foreign_key_validation(table_name, column_name, name: fk_name)
- end
- end
-
- describe '#unprepare_partitioned_async_foreign_key_validation' do
- it 'delegates to unprepare_async_foreign_key_validation for each partition' do
- expect(migration)
- .to receive(:unprepare_async_foreign_key_validation)
- .with(partition1_name, column_name, name: fk_name)
-
- expect(migration)
- .to receive(:unprepare_async_foreign_key_validation)
- .with(partition2_name, column_name, name: fk_name)
-
- migration.unprepare_partitioned_async_foreign_key_validation(table_name, column_name, name: fk_name)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb b/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb
deleted file mode 100644
index ba201d93f52..00000000000
--- a/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Database::AsyncForeignKeys::PostgresAsyncForeignKeyValidation, type: :model,
- feature_category: :database do
- it { is_expected.to be_a Gitlab::Database::SharedModel }
-
- describe 'validations' do
- let_it_be(:fk_validation) { create(:postgres_async_foreign_key_validation) }
- let(:identifier_limit) { described_class::MAX_IDENTIFIER_LENGTH }
- let(:last_error_limit) { described_class::MAX_LAST_ERROR_LENGTH }
-
- subject { fk_validation }
-
- 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(identifier_limit) }
- it { is_expected.to validate_presence_of(:table_name) }
- it { is_expected.to validate_length_of(:table_name).is_at_most(identifier_limit) }
- it { is_expected.to validate_length_of(:last_error).is_at_most(last_error_limit) }
- end
-
- describe 'scopes' do
- let!(:failed_validation) { create(:postgres_async_foreign_key_validation, attempts: 1) }
- let!(:new_validation) { create(:postgres_async_foreign_key_validation) }
-
- describe '.ordered' do
- subject { described_class.ordered }
-
- it { is_expected.to eq([new_validation, failed_validation]) }
- end
- end
-
- describe '#handle_exception!' do
- let_it_be_with_reload(:fk_validation) { create(:postgres_async_foreign_key_validation) }
-
- let(:error) { instance_double(StandardError, message: 'Oups', backtrace: %w[this that]) }
-
- subject { fk_validation.handle_exception!(error) }
-
- it 'increases the attempts number' do
- expect { subject }.to change { fk_validation.reload.attempts }.by(1)
- end
-
- it 'saves error details' do
- subject
-
- expect(fk_validation.reload.last_error).to eq("Oups\nthis\nthat")
- end
- end
-end
diff --git a/spec/lib/gitlab/database/async_foreign_keys_spec.rb b/spec/lib/gitlab/database/async_foreign_keys_spec.rb
deleted file mode 100644
index f15eb364929..00000000000
--- a/spec/lib/gitlab/database/async_foreign_keys_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Database::AsyncForeignKeys, feature_category: :database do
- describe '.validate_pending_entries!' do
- subject { described_class.validate_pending_entries! }
-
- before do
- create_list(:postgres_async_foreign_key_validation, 3)
- end
-
- it 'takes 2 pending FK validations and executes them' do
- validations = described_class::PostgresAsyncForeignKeyValidation.ordered.limit(2).to_a
-
- expect_next_instances_of(described_class::ForeignKeyValidator, 2, validations) do |validator|
- expect(validator).to receive(:perform)
- end
-
- subject
- end
- end
-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 7c5c368fcb5..b2ba1a60fbb 100644
--- a/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb
@@ -143,6 +143,92 @@ RSpec.describe Gitlab::Database::AsyncIndexes::MigrationHelpers, feature_categor
end
end
+ describe '#prepare_async_index_from_sql' do
+ let(:index_definition) { "CREATE INDEX CONCURRENTLY #{index_name} ON #{table_name} USING btree(id)" }
+
+ subject(:prepare_async_index_from_sql) do
+ migration.prepare_async_index_from_sql(index_definition)
+ end
+
+ before do
+ connection.create_table(table_name)
+
+ allow(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!).and_call_original
+ end
+
+ it 'requires ddl mode' do
+ prepare_async_index_from_sql
+
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to have_received(:require_ddl_mode!)
+ end
+
+ context 'when the given index is invalid' do
+ let(:index_definition) { "SELECT FROM users" }
+
+ it 'raises a RuntimeError' do
+ expect { prepare_async_index_from_sql }.to raise_error(RuntimeError, 'Index statement not found!')
+ end
+ end
+
+ context 'when the given index is valid' do
+ context 'when the index algorithm is not concurrent' do
+ let(:index_definition) { "CREATE INDEX #{index_name} ON #{table_name} USING btree(id)" }
+
+ it 'raises a RuntimeError' do
+ expect { prepare_async_index_from_sql }.to raise_error(RuntimeError, 'Index must be created concurrently!')
+ end
+ end
+
+ context 'when the index algorithm is concurrent' do
+ context 'when the statement tries to create an index for non-existing table' do
+ let(:index_definition) { "CREATE INDEX CONCURRENTLY #{index_name} ON foo_table USING btree(id)" }
+
+ it 'raises a RuntimeError' do
+ expect { prepare_async_index_from_sql }.to raise_error(RuntimeError, 'Table does not exist!')
+ end
+ end
+
+ context 'when the statement tries to create an index for an existing table' do
+ context 'when the async index creation is not available' do
+ before do
+ connection.drop_table(:postgres_async_indexes)
+ end
+
+ it 'does not raise an error' do
+ expect { prepare_async_index_from_sql }.not_to raise_error
+ end
+ end
+
+ context 'when the async index creation is available' do
+ context 'when there is already an index with the given name' do
+ before do
+ connection.add_index(table_name, 'id', name: index_name)
+ end
+
+ it 'does not create the async index record' do
+ expect { prepare_async_index_from_sql }.not_to change { index_model.where(name: index_name).count }
+ end
+ end
+
+ context 'when there is no index with the given name' do
+ let(:async_index) { index_model.find_by(name: index_name) }
+
+ it 'creates the async index record' do
+ expect { prepare_async_index_from_sql }.to change { index_model.where(name: index_name).count }.by(1)
+ end
+
+ it 'sets the async index attributes correctly' do
+ prepare_async_index_from_sql
+
+ expect(async_index).to have_attributes(table_name: table_name, definition: index_definition)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
describe '#prepare_async_index_removal' do
before do
connection.create_table(table_name)
diff --git a/spec/lib/gitlab/database/background_migration/batch_optimizer_spec.rb b/spec/lib/gitlab/database/background_migration/batch_optimizer_spec.rb
index c367f4a4493..fb9b16d46d6 100644
--- a/spec/lib/gitlab/database/background_migration/batch_optimizer_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batch_optimizer_spec.rb
@@ -113,5 +113,14 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchOptimizer do
expect { subject }.to change { migration.reload.batch_size }.to(1_000)
end
end
+
+ context 'when migration max_batch_size is less than MIN_BATCH_SIZE' do
+ let(:migration_params) { { max_batch_size: 900 } }
+
+ it 'does not raise an error' do
+ mock_efficiency(0.7)
+ expect { subject }.not_to raise_error
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
index cc9f3d5b7f1..d9b81a2be30 100644
--- a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
@@ -184,6 +184,35 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
expect(transition_log.exception_message).to eq('RuntimeError')
end
end
+
+ context 'when job fails during sub batch processing' do
+ let(:args) { { error: ActiveRecord::StatementTimeout.new, from_sub_batch: true } }
+ let(:attempts) { 0 }
+ let(:failure) { job.failure!(**args) }
+ let(:job) do
+ create(:batched_background_migration_job, :running, batch_size: 20, sub_batch_size: 10, attempts: attempts)
+ end
+
+ context 'when sub batch size can be reduced in 25%' do
+ it { expect { failure }.to change { job.sub_batch_size }.to 7 }
+ end
+
+ context 'when retries exceeds 2 attempts' do
+ let(:attempts) { 3 }
+
+ before do
+ allow(job).to receive(:split_and_retry!)
+ end
+
+ it 'calls split_and_retry! once sub_batch_size cannot be decreased anymore' do
+ failure
+
+ expect(job).to have_received(:split_and_retry!).once
+ end
+
+ it { expect { failure }.not_to change { job.sub_batch_size } }
+ end
+ end
end
describe 'scopes' do
@@ -271,6 +300,24 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
end
end
+ describe '.extract_transition_options' do
+ let(:perform) { subject.class.extract_transition_options(args) }
+
+ where(:args, :expected_result) do
+ [
+ [[], []],
+ [[{ error: StandardError }], [StandardError, nil]],
+ [[{ error: StandardError, from_sub_batch: true }], [StandardError, true]]
+ ]
+ end
+
+ with_them do
+ it 'matches expected keys and result' do
+ expect(perform).to match_array(expected_result)
+ end
+ end
+ end
+
describe '#can_split?' do
subject { job.can_split?(exception) }
@@ -327,6 +374,34 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
end
end
+ describe '#can_reduce_sub_batch_size?' do
+ let(:attempts) { 0 }
+ let(:batch_size) { 10 }
+ let(:sub_batch_size) { 6 }
+ let(:job) do
+ create(:batched_background_migration_job, attempts: attempts,
+ batch_size: batch_size, sub_batch_size: sub_batch_size)
+ end
+
+ context 'when the number of attempts is lower than the limit and batch size are within boundaries' do
+ let(:attempts) { 1 }
+
+ it { expect(job.can_reduce_sub_batch_size?).to be(true) }
+ end
+
+ context 'when the number of attempts is lower than the limit and batch size are outside boundaries' do
+ let(:batch_size) { 1 }
+
+ it { expect(job.can_reduce_sub_batch_size?).to be(false) }
+ end
+
+ context 'when the number of attempts is greater than the limit and batch size are within boundaries' do
+ let(:attempts) { 3 }
+
+ it { expect(job.can_reduce_sub_batch_size?).to be(false) }
+ end
+ end
+
describe '#time_efficiency' do
subject { job.time_efficiency }
@@ -465,4 +540,80 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
end
end
end
+
+ describe '#reduce_sub_batch_size!' do
+ let(:migration_batch_size) { 20 }
+ let(:migration_sub_batch_size) { 10 }
+ let(:job_batch_size) { 20 }
+ let(:job_sub_batch_size) { 10 }
+ let(:status) { :failed }
+
+ let(:migration) do
+ create(:batched_background_migration, :active, batch_size: migration_batch_size,
+ sub_batch_size: migration_sub_batch_size)
+ end
+
+ let(:job) do
+ create(:batched_background_migration_job, status, sub_batch_size: job_sub_batch_size,
+ batch_size: job_batch_size, batched_migration: migration)
+ end
+
+ context 'when the job sub batch size can be reduced' do
+ let(:expected_sub_batch_size) { 7 }
+
+ it 'reduces sub batch size in 25%' do
+ expect { job.reduce_sub_batch_size! }.to change { job.sub_batch_size }.to(expected_sub_batch_size)
+ end
+
+ it 'log the changes' do
+ expect(Gitlab::AppLogger).to receive(:warn).with(
+ message: 'Sub batch size reduced due to timeout',
+ batched_job_id: job.id,
+ sub_batch_size: job_sub_batch_size,
+ reduced_sub_batch_size: expected_sub_batch_size,
+ attempts: job.attempts,
+ batched_migration_id: migration.id,
+ job_class_name: job.migration_job_class_name,
+ job_arguments: job.migration_job_arguments
+ )
+
+ job.reduce_sub_batch_size!
+ end
+ end
+
+ context 'when reduced sub_batch_size is greater than sub_batch' do
+ let(:job_batch_size) { 5 }
+
+ it "doesn't allow sub_batch_size to greater than sub_batch" do
+ expect { job.reduce_sub_batch_size! }.to change { job.sub_batch_size }.to 5
+ end
+ end
+
+ context 'when sub_batch_size is already 1' do
+ let(:job_sub_batch_size) { 1 }
+
+ it "updates sub_batch_size to it's minimum value" do
+ expect { job.reduce_sub_batch_size! }.not_to change { job.sub_batch_size }
+ end
+ end
+
+ context 'when job has not failed' do
+ let(:status) { :succeeded }
+ let(:error) { Gitlab::Database::BackgroundMigration::ReduceSubBatchSizeError }
+
+ it 'raises an exception' do
+ expect { job.reduce_sub_batch_size! }.to raise_error(error)
+ end
+ end
+
+ context 'when the amount to be reduced exceeds the threshold' do
+ let(:migration_batch_size) { 150 }
+ let(:migration_sub_batch_size) { 100 }
+ let(:job_sub_batch_size) { 30 }
+
+ it 'prevents sub batch size to be reduced' do
+ expect { job.reduce_sub_batch_size! }.not_to change { job.sub_batch_size }
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
index d132559acea..546f9353808 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :model do
+RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :model, feature_category: :database do
it_behaves_like 'having unique enum values'
it { is_expected.to be_a Gitlab::Database::SharedModel }
@@ -328,6 +328,17 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m
end
end
+ describe '.finalizing' do
+ let!(:migration1) { create(:batched_background_migration, :active) }
+ let!(:migration2) { create(:batched_background_migration, :paused) }
+ let!(:migration3) { create(:batched_background_migration, :finalizing) }
+ let!(:migration4) { create(:batched_background_migration, :finished) }
+
+ it 'returns only finalizing migrations' do
+ expect(described_class.finalizing).to contain_exactly(migration3)
+ end
+ end
+
describe '.successful_rows_counts' do
let!(:migration1) { create(:batched_background_migration) }
let!(:migration2) { create(:batched_background_migration) }
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
index f3a292abbae..8d74d16f4e5 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
let(:connection) { Gitlab::Database.database_base_models[:main].connection }
let(:metrics_tracker) { instance_double('::Gitlab::Database::BackgroundMigration::PrometheusMetrics', track: nil) }
let(:job_class) { Class.new(Gitlab::BackgroundMigration::BatchedMigrationJob) }
+ let(:sub_batch_exception) { Gitlab::Database::BackgroundMigration::SubBatchTimeoutError }
let_it_be(:pause_ms) { 250 }
let_it_be(:active_migration) { create(:batched_background_migration, :active, job_arguments: [:id, :other_id]) }
@@ -39,7 +40,8 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
sub_batch_size: 1,
pause_ms: pause_ms,
job_arguments: active_migration.job_arguments,
- connection: connection)
+ connection: connection,
+ sub_batch_exception: sub_batch_exception)
.and_return(job_instance)
expect(job_instance).to receive(:perform).with(no_args)
@@ -119,12 +121,14 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
end
context 'when the migration job raises an error' do
- shared_examples 'an error is raised' do |error_class|
+ shared_examples 'an error is raised' do |error_class, cause|
+ let(:expected_to_raise) { cause || error_class }
+
it 'marks the tracking record as failed' do
expect(job_instance).to receive(:perform).with(no_args).and_raise(error_class)
freeze_time do
- expect { perform }.to raise_error(error_class)
+ expect { perform }.to raise_error(expected_to_raise)
reloaded_job_record = job_record.reload
@@ -137,13 +141,16 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
expect(job_instance).to receive(:perform).with(no_args).and_raise(error_class)
expect(metrics_tracker).to receive(:track).with(job_record)
- expect { perform }.to raise_error(error_class)
+ expect { perform }.to raise_error(expected_to_raise)
end
end
it_behaves_like 'an error is raised', RuntimeError.new('Something broke!')
it_behaves_like 'an error is raised', SignalException.new('SIGTERM')
it_behaves_like 'an error is raised', ActiveRecord::StatementTimeout.new('Timeout!')
+
+ error = StandardError.new
+ it_behaves_like('an error is raised', Gitlab::Database::BackgroundMigration::SubBatchTimeoutError.new(error), error)
end
context 'when the batched background migration does not inherit from BatchedMigrationJob' do
diff --git a/spec/lib/gitlab/database/background_migration/health_status/indicators/patroni_apdex_spec.rb b/spec/lib/gitlab/database/background_migration/health_status/indicators/patroni_apdex_spec.rb
new file mode 100644
index 00000000000..d3102a105ea
--- /dev/null
+++ b/spec/lib/gitlab/database/background_migration/health_status/indicators/patroni_apdex_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::BackgroundMigration::HealthStatus::Indicators::PatroniApdex, :aggregate_failures, feature_category: :database do # rubocop:disable Layout/LineLength
+ let(:schema) { :main }
+ let(:connection) { Gitlab::Database.database_base_models[schema].connection }
+
+ around do |example|
+ Gitlab::Database::SharedModel.using_connection(connection) do
+ example.run
+ end
+ end
+
+ describe '#evaluate' do
+ let(:prometheus_url) { 'http://thanos:9090' }
+ let(:prometheus_config) { [prometheus_url, { allow_local_requests: true, verify: true }] }
+
+ let(:prometheus_client) { instance_double(Gitlab::PrometheusClient) }
+
+ let(:context) do
+ Gitlab::Database::BackgroundMigration::HealthStatus::Context
+ .new(connection, ['users'], gitlab_schema)
+ end
+
+ let(:gitlab_schema) { "gitlab_#{schema}" }
+ let(:client_ready) { true }
+ let(:database_apdex_sli_query_main) { 'Apdex query for main' }
+ let(:database_apdex_sli_query_ci) { 'Apdex query for ci' }
+ let(:database_apdex_slo_main) { 0.99 }
+ let(:database_apdex_slo_ci) { 0.95 }
+ let(:database_apdex_settings) do
+ {
+ prometheus_api_url: prometheus_url,
+ apdex_sli_query: {
+ main: database_apdex_sli_query_main,
+ ci: database_apdex_sli_query_ci
+ },
+ apdex_slo: {
+ main: database_apdex_slo_main,
+ ci: database_apdex_slo_ci
+ }
+ }
+ end
+
+ subject(:evaluate) { described_class.new(context).evaluate }
+
+ before do
+ stub_application_setting(database_apdex_settings: database_apdex_settings)
+
+ allow(Gitlab::PrometheusClient).to receive(:new).with(*prometheus_config).and_return(prometheus_client)
+ allow(prometheus_client).to receive(:ready?).and_return(client_ready)
+ end
+
+ shared_examples 'Patroni Apdex Evaluator' do |schema|
+ context "with #{schema} schema" do
+ let(:schema) { schema }
+ let(:apdex_slo_above_sli) { { main: 0.991, ci: 0.951 } }
+ let(:apdex_slo_below_sli) { { main: 0.989, ci: 0.949 } }
+
+ it 'returns NoSignal signal in case the feature flag is disabled' do
+ stub_feature_flags(batched_migrations_health_status_patroni_apdex: false)
+
+ expect(evaluate).to be_a(Gitlab::Database::BackgroundMigration::HealthStatus::Signals::NotAvailable)
+ expect(evaluate.reason).to include('indicator disabled')
+ end
+
+ context 'without database_apdex_settings' do
+ let(:database_apdex_settings) { nil }
+
+ it 'returns Unknown signal' do
+ expect(evaluate).to be_a(Gitlab::Database::BackgroundMigration::HealthStatus::Signals::Unknown)
+ expect(evaluate.reason).to include('Patroni Apdex Settings not configured')
+ end
+ end
+
+ context 'when Prometheus client is not ready' do
+ let(:client_ready) { false }
+
+ it 'returns Unknown signal' do
+ expect(evaluate).to be_a(Gitlab::Database::BackgroundMigration::HealthStatus::Signals::Unknown)
+ expect(evaluate.reason).to include('Prometheus client is not ready')
+ end
+ end
+
+ context 'when apdex SLI query is not configured' do
+ let(:"database_apdex_sli_query_#{schema}") { nil }
+
+ it 'returns Unknown signal' do
+ expect(evaluate).to be_a(Gitlab::Database::BackgroundMigration::HealthStatus::Signals::Unknown)
+ expect(evaluate.reason).to include('Apdex SLI query is not configured')
+ end
+ end
+
+ context 'when slo is not configured' do
+ let(:"database_apdex_slo_#{schema}") { nil }
+
+ it 'returns Unknown signal' do
+ expect(evaluate).to be_a(Gitlab::Database::BackgroundMigration::HealthStatus::Signals::Unknown)
+ expect(evaluate.reason).to include('Apdex SLO is not configured')
+ end
+ end
+
+ it 'returns Normal signal when Patroni apdex SLI is above SLO' do
+ expect(prometheus_client).to receive(:query)
+ .with(send("database_apdex_sli_query_#{schema}"))
+ .and_return([{ "value" => [1662423310.878, apdex_slo_above_sli[schema]] }])
+ expect(evaluate).to be_a(Gitlab::Database::BackgroundMigration::HealthStatus::Signals::Normal)
+ expect(evaluate.reason).to include('Patroni service apdex is above SLO')
+ end
+
+ it 'returns Stop signal when Patroni apdex is below SLO' do
+ expect(prometheus_client).to receive(:query)
+ .with(send("database_apdex_sli_query_#{schema}"))
+ .and_return([{ "value" => [1662423310.878, apdex_slo_below_sli[schema]] }])
+ expect(evaluate).to be_a(Gitlab::Database::BackgroundMigration::HealthStatus::Signals::Stop)
+ expect(evaluate.reason).to include('Patroni service apdex is below SLO')
+ end
+
+ context 'when Patroni apdex can not be calculated' do
+ where(:result) do
+ [
+ nil,
+ [],
+ [{}],
+ [{ 'value' => 1 }],
+ [{ 'value' => [1] }]
+ ]
+ end
+
+ with_them do
+ it 'returns Unknown signal' do
+ expect(prometheus_client).to receive(:query).and_return(result)
+ expect(evaluate).to be_a(Gitlab::Database::BackgroundMigration::HealthStatus::Signals::Unknown)
+ expect(evaluate.reason).to include('Patroni service apdex can not be calculated')
+ end
+ end
+ end
+ end
+ end
+
+ Gitlab::Database.database_base_models.each do |database_base_model, connection|
+ next unless connection.present?
+
+ it_behaves_like 'Patroni Apdex Evaluator', database_base_model.to_sym
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/background_migration/health_status_spec.rb b/spec/lib/gitlab/database/background_migration/health_status_spec.rb
index 8bc04d80fa1..4d6c729f080 100644
--- a/spec/lib/gitlab/database/background_migration/health_status_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/health_status_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::BackgroundMigration::HealthStatus do
+RSpec.describe Gitlab::Database::BackgroundMigration::HealthStatus, feature_category: :database do
let(:connection) { Gitlab::Database.database_base_models[:main].connection }
around do |example|
@@ -19,8 +19,10 @@ RSpec.describe Gitlab::Database::BackgroundMigration::HealthStatus do
let(:health_status) { Gitlab::Database::BackgroundMigration::HealthStatus }
let(:autovacuum_indicator_class) { health_status::Indicators::AutovacuumActiveOnTable }
let(:wal_indicator_class) { health_status::Indicators::WriteAheadLog }
+ let(:patroni_apdex_indicator_class) { health_status::Indicators::PatroniApdex }
let(:autovacuum_indicator) { instance_double(autovacuum_indicator_class) }
let(:wal_indicator) { instance_double(wal_indicator_class) }
+ let(:patroni_apdex_indicator) { instance_double(patroni_apdex_indicator_class) }
before do
allow(autovacuum_indicator_class).to receive(:new).with(migration.health_context).and_return(autovacuum_indicator)
@@ -36,8 +38,11 @@ RSpec.describe Gitlab::Database::BackgroundMigration::HealthStatus do
expect(autovacuum_indicator).to receive(:evaluate).and_return(normal_signal)
expect(wal_indicator_class).to receive(:new).with(migration.health_context).and_return(wal_indicator)
expect(wal_indicator).to receive(:evaluate).and_return(not_available_signal)
+ expect(patroni_apdex_indicator_class).to receive(:new).with(migration.health_context)
+ .and_return(patroni_apdex_indicator)
+ expect(patroni_apdex_indicator).to receive(:evaluate).and_return(not_available_signal)
- expect(evaluate).to contain_exactly(normal_signal, not_available_signal)
+ expect(evaluate).to contain_exactly(normal_signal, not_available_signal, not_available_signal)
end
end
@@ -50,10 +55,23 @@ RSpec.describe Gitlab::Database::BackgroundMigration::HealthStatus do
end
it 'logs interesting signals' do
- signal = instance_double("#{health_status}::Signals::Stop", log_info?: true)
+ signal = instance_double(
+ "#{health_status}::Signals::Stop",
+ log_info?: true,
+ indicator_class: autovacuum_indicator_class,
+ short_name: 'Stop',
+ reason: 'Test Exception'
+ )
expect(autovacuum_indicator).to receive(:evaluate).and_return(signal)
- expect(described_class).to receive(:log_signal).with(signal, migration)
+
+ expect(Gitlab::BackgroundMigration::Logger).to receive(:info).with(
+ migration_id: migration.id,
+ health_status_indicator: autovacuum_indicator_class.to_s,
+ indicator_signal: 'Stop',
+ signal_reason: 'Test Exception',
+ message: "#{migration} signaled: #{signal}"
+ )
evaluate
end
diff --git a/spec/lib/gitlab/database/background_migration_job_spec.rb b/spec/lib/gitlab/database/background_migration_job_spec.rb
index 1117c17c84a..6a1bedd800b 100644
--- a/spec/lib/gitlab/database/background_migration_job_spec.rb
+++ b/spec/lib/gitlab/database/background_migration_job_spec.rb
@@ -27,26 +27,6 @@ RSpec.describe Gitlab::Database::BackgroundMigrationJob do
end
end
- describe '.for_partitioning_migration' do
- let!(:job1) { create(:background_migration_job, arguments: [1, 100, 'other_table']) }
- let!(:job2) { create(:background_migration_job, arguments: [1, 100, 'audit_events']) }
- let!(:job3) { create(:background_migration_job, class_name: 'OtherJob', arguments: [1, 100, 'audit_events']) }
-
- it 'returns jobs matching class_name and the table_name job argument' do
- relation = described_class.for_partitioning_migration('TestJob', 'audit_events')
-
- expect(relation.count).to eq(1)
- expect(relation.first).to have_attributes(class_name: 'TestJob', arguments: [1, 100, 'audit_events'])
- end
-
- it 'normalizes class names by removing leading ::' do
- relation = described_class.for_partitioning_migration('::TestJob', 'audit_events')
-
- expect(relation.count).to eq(1)
- expect(relation.first).to have_attributes(class_name: 'TestJob', arguments: [1, 100, 'audit_events'])
- end
- end
-
describe '.mark_all_as_succeeded' do
let!(:job1) { create(:background_migration_job, arguments: [1, 100]) }
let!(:job2) { create(:background_migration_job, arguments: [1, 100]) }
diff --git a/spec/lib/gitlab/database/consistency_checker_spec.rb b/spec/lib/gitlab/database/consistency_checker_spec.rb
index c0f0c349ddd..be03bd00619 100644
--- a/spec/lib/gitlab/database/consistency_checker_spec.rb
+++ b/spec/lib/gitlab/database/consistency_checker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::ConsistencyChecker, feature_category: :pods do
+RSpec.describe Gitlab::Database::ConsistencyChecker, feature_category: :cell do
let(:batch_size) { 10 }
let(:max_batches) { 4 }
let(:max_runtime) { described_class::MAX_RUNTIME }
diff --git a/spec/lib/gitlab/database/dynamic_model_helpers_spec.rb b/spec/lib/gitlab/database/dynamic_model_helpers_spec.rb
index 31486240bfa..fe423b3639b 100644
--- a/spec/lib/gitlab/database/dynamic_model_helpers_spec.rb
+++ b/spec/lib/gitlab/database/dynamic_model_helpers_spec.rb
@@ -49,6 +49,21 @@ RSpec.describe Gitlab::Database::DynamicModelHelpers do
expect { |b| each_batch_size.call(&b) }
.to yield_successive_args(1, 1)
end
+
+ context 'when a column to be batched over is specified' do
+ let(:projects) { Project.order(project_namespace_id: :asc) }
+
+ it 'iterates table in batches using the given column' do
+ each_batch_ids = ->(&block) do
+ subject.each_batch(table_name, connection: connection, of: 1, column: :project_namespace_id) do |batch|
+ block.call(batch.pluck(:project_namespace_id))
+ end
+ end
+
+ expect { |b| each_batch_ids.call(&b) }
+ .to yield_successive_args([projects.first.project_namespace_id], [projects.last.project_namespace_id])
+ end
+ end
end
context 'when transaction is open' do
@@ -95,6 +110,35 @@ RSpec.describe Gitlab::Database::DynamicModelHelpers do
expect { |b| each_batch_limited.call(&b) }
.to yield_successive_args([first_project.id, first_project.id])
end
+
+ context 'when primary key is not named id' do
+ let(:namespace_settings1) { create(:namespace_settings) }
+ let(:namespace_settings2) { create(:namespace_settings) }
+ let(:table_name) { NamespaceSetting.table_name }
+ let(:connection) { NamespaceSetting.connection }
+ let(:primary_key) { subject.define_batchable_model(table_name, connection: connection).primary_key }
+
+ it 'iterates table in batch ranges using the correct primary key' do
+ expect(primary_key).to eq("namespace_id") # Sanity check the primary key is not id
+ expect { |b| subject.each_batch_range(table_name, connection: connection, of: 1, &b) }
+ .to yield_successive_args(
+ [namespace_settings1.namespace_id, namespace_settings1.namespace_id],
+ [namespace_settings2.namespace_id, namespace_settings2.namespace_id]
+ )
+ end
+ end
+
+ context 'when a column to be batched over is specified' do
+ it 'iterates table in batch ranges using the given column' do
+ expect do |b|
+ subject.each_batch_range(table_name, connection: connection, of: 1, column: :project_namespace_id, &b)
+ end
+ .to yield_successive_args(
+ [first_project.project_namespace_id, first_project.project_namespace_id],
+ [second_project.project_namespace_id, second_project.project_namespace_id]
+ )
+ end
+ end
end
context 'when transaction is open' do
diff --git a/spec/lib/gitlab/database/gitlab_schema_spec.rb b/spec/lib/gitlab/database/gitlab_schema_spec.rb
index 28a087d5401..5d3260a77c9 100644
--- a/spec/lib/gitlab/database/gitlab_schema_spec.rb
+++ b/spec/lib/gitlab/database/gitlab_schema_spec.rb
@@ -16,19 +16,28 @@ RSpec.shared_examples 'validate schema data' do |tables_and_views|
end
end
-RSpec.describe Gitlab::Database::GitlabSchema do
+RSpec.describe Gitlab::Database::GitlabSchema, feature_category: :database do
shared_examples 'maps table name to table schema' do
using RSpec::Parameterized::TableSyntax
+ before do
+ ApplicationRecord.connection.execute(<<~SQL)
+ CREATE INDEX index_name_on_table_belonging_to_gitlab_main ON public.projects (name);
+ SQL
+ end
+
where(:name, :classification) do
- 'ci_builds' | :gitlab_ci
- 'my_schema.ci_builds' | :gitlab_ci
- 'information_schema.columns' | :gitlab_internal
- 'audit_events_part_5fc467ac26' | :gitlab_main
- '_test_gitlab_main_table' | :gitlab_main
- '_test_gitlab_ci_table' | :gitlab_ci
- '_test_my_table' | :gitlab_shared
- 'pg_attribute' | :gitlab_internal
+ 'ci_builds' | :gitlab_ci
+ 'my_schema.ci_builds' | :gitlab_ci
+ 'my_schema.ci_runner_machine_builds_100' | :gitlab_ci
+ 'my_schema._test_gitlab_main_table' | :gitlab_main
+ 'information_schema.columns' | :gitlab_internal
+ 'audit_events_part_5fc467ac26' | :gitlab_main
+ '_test_gitlab_main_table' | :gitlab_main
+ '_test_gitlab_ci_table' | :gitlab_ci
+ '_test_my_table' | :gitlab_shared
+ 'pg_attribute' | :gitlab_internal
+ 'index_name_on_table_belonging_to_gitlab_main' | :gitlab_main
end
with_them do
@@ -49,8 +58,10 @@ RSpec.describe Gitlab::Database::GitlabSchema do
context "for #{db_config_name} using #{db_class}" do
let(:db_data_sources) { db_class.connection.data_sources }
- # The Geo database does not share the same structure as all decomposed databases
- subject { described_class.views_and_tables_to_schema.select { |_, v| v != :gitlab_geo } }
+ # The embedding and Geo databases do not share the same structure as all decomposed databases
+ subject do
+ described_class.views_and_tables_to_schema.reject { |_, v| v == :gitlab_embedding || v == :gitlab_geo }
+ end
it 'new data sources are added' do
missing_data_sources = db_data_sources.to_set - subject.keys
@@ -116,10 +127,10 @@ RSpec.describe Gitlab::Database::GitlabSchema do
end
end
- describe '.table_schemas' do
+ describe '.table_schemas!' do
let(:tables) { %w[users projects ci_builds] }
- subject { described_class.table_schemas(tables) }
+ subject { described_class.table_schemas!(tables) }
it 'returns the matched schemas' do
expect(subject).to match_array %i[gitlab_main gitlab_ci].to_set
@@ -128,26 +139,8 @@ RSpec.describe Gitlab::Database::GitlabSchema do
context 'when one of the tables does not have a matching table schema' do
let(:tables) { %w[users projects unknown ci_builds] }
- context 'and undefined parameter is false' do
- subject { described_class.table_schemas(tables, undefined: false) }
-
- it 'includes a nil value' do
- is_expected.to match_array [:gitlab_main, nil, :gitlab_ci].to_set
- end
- end
-
- context 'and undefined parameter is true' do
- subject { described_class.table_schemas(tables, undefined: true) }
-
- it 'includes "undefined_<table_name>"' do
- is_expected.to match_array [:gitlab_main, :undefined_unknown, :gitlab_ci].to_set
- end
- end
-
- context 'and undefined parameter is not specified' do
- it 'includes a nil value' do
- is_expected.to match_array [:gitlab_main, :undefined_unknown, :gitlab_ci].to_set
- end
+ it 'raises error' do
+ expect { subject }.to raise_error(/Could not find gitlab schema for table unknown/)
end
end
end
@@ -160,23 +153,7 @@ RSpec.describe Gitlab::Database::GitlabSchema do
context 'when mapping fails' do
let(:name) { 'unknown_table' }
- context "and parameter 'undefined' is set to true" do
- subject { described_class.table_schema(name, undefined: true) }
-
- it { is_expected.to eq(:undefined_unknown_table) }
- end
-
- context "and parameter 'undefined' is set to false" do
- subject { described_class.table_schema(name, undefined: false) }
-
- it { is_expected.to be_nil }
- end
-
- context "and parameter 'undefined' is not set" do
- subject { described_class.table_schema(name) }
-
- it { is_expected.to eq(:undefined_unknown_table) }
- end
+ it { is_expected.to be_nil }
end
end
@@ -192,7 +169,8 @@ RSpec.describe Gitlab::Database::GitlabSchema do
expect { subject }.to raise_error(
Gitlab::Database::GitlabSchema::UnknownSchemaError,
"Could not find gitlab schema for table #{name}: " \
- "Any new tables must be added to the database dictionary"
+ "Any new or deleted tables must be added to the database dictionary " \
+ "See https://docs.gitlab.com/ee/development/database/database_dictionary.html"
)
end
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
index 768855464c1..a57f02b22df 100644
--- a/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb
@@ -2,18 +2,13 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::LoadBalancing::ActionCableCallbacks, :request_store do
+RSpec.describe Gitlab::Database::LoadBalancing::ActionCableCallbacks, :request_store, feature_category: :shared do
describe '.wrapper' do
- it 'uses primary and then releases the connection and clears the session' do
+ it 'releases the connection and clears the session' do
expect(Gitlab::Database::LoadBalancing).to receive(:release_hosts)
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
- )
+ described_class.wrapper.call(nil, lambda {})
end
context 'with an exception' do
diff --git a/spec/lib/gitlab/database/load_balancing/logger_spec.rb b/spec/lib/gitlab/database/load_balancing/logger_spec.rb
new file mode 100644
index 00000000000..81883fa6f1a
--- /dev/null
+++ b/spec/lib/gitlab/database/load_balancing/logger_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::LoadBalancing::Logger, feature_category: :database do
+ subject { described_class.new('/dev/null') }
+
+ it_behaves_like 'a json logger', {}
+
+ it 'excludes context' do
+ expect(described_class.exclude_context?).to be(true)
+ 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 bfd9c644ffa..9a559c7ccb4 100644
--- a/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb
@@ -90,7 +90,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery, feature_catego
end
end
- context 'with failures' do
+ context 'with StandardError' do
before do
allow(Gitlab::ErrorTracking).to receive(:track_exception)
allow(service).to receive(:sleep)
@@ -142,6 +142,21 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery, feature_catego
service.perform_service_discovery
end
end
+
+ context 'with Exception' do
+ it 'logs error and re-raises the exception' do
+ error = Exception.new('uncaught-test-error')
+
+ expect(service).to receive(:refresh_if_necessary).and_raise(error)
+
+ expect(Gitlab::Database::LoadBalancing::Logger).to receive(:error).with(
+ event: :service_discovery_unexpected_exception,
+ message: "Service discovery encountered an uncaught error: uncaught-test-error"
+ )
+
+ expect { service.perform_service_discovery }.to raise_error(Exception, error.message)
+ end
+ end
end
describe '#refresh_if_necessary' 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 7eb20f77417..5a52394742f 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
@@ -62,59 +62,40 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware, feature
include_examples 'job data consistency'
end
- shared_examples_for 'mark data consistency location' do |data_consistency|
- include_context 'data consistency worker class', data_consistency, :load_balancing_for_test_data_consistency_worker
-
+ shared_examples_for 'mark data consistency location' do |data_consistency, worker_klass|
let(:location) { '0/D525E3A8' }
+ include_context 'when tracking WAL location reference'
- context 'when feature flag is disabled' do
- let(:expected_consistency) { :always }
-
- before do
- stub_feature_flags(load_balancing_for_test_data_consistency_worker: false)
- end
-
- include_examples 'does not pass database locations'
+ if worker_klass
+ let(:worker_class) { worker_klass }
+ let(:expected_consistency) { data_consistency }
+ else
+ include_context 'data consistency worker class', data_consistency, :load_balancing_for_test_data_consistency_worker
end
context 'when write was not performed' do
before do
- allow(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary?).and_return(false)
+ stub_no_writes_performed!
end
context 'when replica hosts are available' do
it 'passes database_replica_location' do
- expected_location = {}
-
- Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
- expect(lb.host)
- .to receive(:database_replica_location)
- .and_return(location)
-
- expected_location[lb.name] = location
- end
+ expected_locations = expect_tracked_locations_when_replicas_available
run_middleware
- expect(job['wal_locations']).to eq(expected_location)
+ expect(job['wal_locations']).to eq(expected_locations)
expect(job['wal_location_source']).to eq(:replica)
end
end
context 'when no replica hosts are available' do
it 'passes primary_write_location' do
- expected_location = {}
-
- Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
- expect(lb).to receive(:host).and_return(nil)
- expect(lb).to receive(:primary_write_location).and_return(location)
-
- expected_location[lb.name] = location
- end
+ expected_locations = expect_tracked_locations_when_no_replicas_available
run_middleware
- expect(job['wal_locations']).to eq(expected_location)
+ expect(job['wal_locations']).to eq(expected_locations)
expect(job['wal_location_source']).to eq(:replica)
end
end
@@ -124,23 +105,15 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware, feature
context 'when write was performed' do
before do
- allow(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary?).and_return(true)
+ stub_write_performed!
end
it 'passes primary write location', :aggregate_failures do
- expected_location = {}
-
- Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
- expect(lb)
- .to receive(:primary_write_location)
- .and_return(location)
-
- expected_location[lb.name] = location
- end
+ expected_locations = expect_tracked_locations_from_primary_only
run_middleware
- expect(job['wal_locations']).to eq(expected_location)
+ expect(job['wal_locations']).to eq(expected_locations)
expect(job['wal_location_source']).to eq(:primary)
end
@@ -149,19 +122,36 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware, feature
end
context 'when worker cannot be constantized' do
- let(:worker_class) { 'ActionMailer::MailDeliveryJob' }
+ let(:worker_class) { 'InvalidWorker' }
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(:worker_class) { Gitlab::SidekiqConfig::DummyWorker }
let(:expected_consistency) { :always }
include_examples 'does not pass database locations'
end
+ context 'when job contains wrapped worker' do
+ let(:worker_class) { ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper }
+
+ context 'when wrapped worker does not include WorkerAttributes' do
+ let(:job) { { "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", "wrapped" => Gitlab::SidekiqConfig::DummyWorker } }
+ let(:expected_consistency) { :always }
+
+ include_examples 'does not pass database locations'
+ end
+
+ context 'when wrapped worker includes WorkerAttributes' do
+ let(:job) { { "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", "wrapped" => ActionMailer::MailDeliveryJob } }
+
+ include_examples 'mark data consistency location', :delayed, ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper
+ end
+ end
+
context 'database wal location was already provided' do
let(:old_location) { '0/D525E3A8' }
let(:new_location) { 'AB/12345' }
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 abf10456d0a..7703b5680c2 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_gitlab_redis_queues do
+RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_gitlab_redis_queues, feature_category: :scalability do
let(:middleware) { described_class.new }
let(:worker) { worker_class.new }
let(:location) { '0/D525E3A8' }
@@ -15,6 +15,8 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_
replication_lag!(false)
Gitlab::Database::LoadBalancing::Session.clear_session
+
+ stub_const("#{described_class.name}::REPLICA_WAIT_SLEEP_SECONDS", 0.0)
end
after do
@@ -76,14 +78,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_
end
shared_examples_for 'sticks based on data consistency' do
- context 'when load_balancing_for_test_data_consistency_worker is disabled' do
- before do
- stub_feature_flags(load_balancing_for_test_data_consistency_worker: false)
- end
-
- include_examples 'stick to the primary', 'primary'
- end
-
context 'when database wal location is set' do
let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'wal_locations' => wal_locations } }
@@ -119,9 +113,9 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_
end
end
- shared_examples_for 'sleeps when necessary' do
+ shared_examples_for 'essential sleep' do
context 'when WAL locations are blank', :freeze_time do
- let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", "wal_locations" => {}, "created_at" => Time.current.to_f - (described_class::MINIMUM_DELAY_INTERVAL_SECONDS - 0.3) } }
+ let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", "wal_locations" => {}, "created_at" => Time.current.to_f - (described_class::REPLICA_WAIT_SLEEP_SECONDS + 0.2) } }
it 'does not sleep' do
expect(middleware).not_to receive(:sleep)
@@ -134,7 +128,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_
let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'wal_locations' => wal_locations, "created_at" => Time.current.to_f - elapsed_time } }
context 'when delay interval has not elapsed' do
- let(:elapsed_time) { described_class::MINIMUM_DELAY_INTERVAL_SECONDS - 0.3 }
+ let(:elapsed_time) { described_class::REPLICA_WAIT_SLEEP_SECONDS + 0.2 }
context 'when replica is up to date' do
before do
@@ -158,41 +152,46 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_
end
it 'sleeps until the minimum delay is reached' do
- expect(middleware).to receive(:sleep).with(be_within(0.01).of(described_class::MINIMUM_DELAY_INTERVAL_SECONDS - elapsed_time))
+ expect(middleware).to receive(:sleep).with(described_class::REPLICA_WAIT_SLEEP_SECONDS)
run_middleware
end
end
- end
-
- context 'when delay interval has elapsed' do
- let(:elapsed_time) { described_class::MINIMUM_DELAY_INTERVAL_SECONDS + 0.3 }
-
- it 'does not sleep' do
- expect(middleware).not_to receive(:sleep)
-
- run_middleware
- end
- end
- context 'when created_at is in the future' do
- let(:elapsed_time) { -5 }
+ context 'when replica is never not up to date' do
+ before do
+ Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
+ allow(lb).to receive(:select_up_to_date_host).and_return(false, false)
+ end
+ end
- it 'does not sleep' do
- expect(middleware).not_to receive(:sleep)
+ it 'sleeps until the maximum delay is reached' do
+ expect(middleware).to receive(:sleep).exactly(3).times.with(described_class::REPLICA_WAIT_SLEEP_SECONDS)
- run_middleware
+ run_middleware
+ end
end
end
end
end
- context 'when worker class does not include ApplicationWorker' do
+ context 'when worker class does not include WorkerAttributes' do
let(:worker) { ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper.new }
include_examples 'stick to the primary', 'primary'
end
+ context 'when job contains wrapped worker class' do
+ let(:worker) { ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper.new }
+ let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'wal_locations' => wal_locations, 'wrapped' => 'ActionMailer::MailDeliveryJob' } }
+
+ it 'uses wrapped job if available' do
+ expect(middleware).to receive(:select_load_balancing_strategy).with(ActionMailer::MailDeliveryJob, job).and_call_original
+
+ run_middleware
+ end
+ end
+
context 'when worker data consistency is :always' do
include_context 'data consistency worker class', :always, :load_balancing_for_test_data_consistency_worker
@@ -200,7 +199,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_
context 'when delay interval has not elapsed', :freeze_time do
let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'wal_locations' => wal_locations, "created_at" => Time.current.to_f - elapsed_time } }
- let(:elapsed_time) { described_class::MINIMUM_DELAY_INTERVAL_SECONDS - 0.3 }
+ let(:elapsed_time) { described_class::REPLICA_WAIT_SLEEP_SECONDS + 0.2 }
it 'does not sleep' do
expect(middleware).not_to receive(:sleep)
@@ -214,7 +213,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_
include_context 'data consistency worker class', :delayed, :load_balancing_for_test_data_consistency_worker
include_examples 'sticks based on data consistency'
- include_examples 'sleeps when necessary'
+ include_examples 'essential sleep'
context 'when replica is not up to date' do
before do
@@ -263,7 +262,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_
include_context 'data consistency worker class', :sticky, :load_balancing_for_test_data_consistency_worker
include_examples 'sticks based on data consistency'
- include_examples 'sleeps when necessary'
+ include_examples 'essential sleep'
context 'when replica is not up to date' do
before do
diff --git a/spec/lib/gitlab/database/load_balancing_spec.rb b/spec/lib/gitlab/database/load_balancing_spec.rb
index 59e16e6ca8b..f65c27688b8 100644
--- a/spec/lib/gitlab/database/load_balancing_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::LoadBalancing, :suppress_gitlab_schemas_validate_connection, feature_category: :pods do
+RSpec.describe Gitlab::Database::LoadBalancing, :suppress_gitlab_schemas_validate_connection, feature_category: :cell do
describe '.base_models' do
it 'returns the models to apply load balancing to' do
models = described_class.base_models
@@ -497,14 +497,15 @@ RSpec.describe Gitlab::Database::LoadBalancing, :suppress_gitlab_schemas_validat
where(:queries, :expected_role) do
[
# Reload cache. The schema loading queries should be handled by
- # primary.
+ # replica even when the current session is stuck to the primary.
[
-> {
+ ::Gitlab::Database::LoadBalancing::Session.current.use_primary!
model.connection.clear_cache!
model.connection.schema_cache.add('users')
model.connection.pool.release_connection
},
- :primary
+ :replica
],
# Call model's connection method
diff --git a/spec/lib/gitlab/database/lock_writes_manager_spec.rb b/spec/lib/gitlab/database/lock_writes_manager_spec.rb
index c06c463d918..2aa95372338 100644
--- a/spec/lib/gitlab/database/lock_writes_manager_spec.rb
+++ b/spec/lib/gitlab/database/lock_writes_manager_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::LockWritesManager, :delete, feature_category: :pods do
+RSpec.describe Gitlab::Database::LockWritesManager, :delete, feature_category: :cell do
let(:connection) { ApplicationRecord.connection }
let(:test_table) { '_test_table' }
let(:logger) { instance_double(Logger) }
@@ -122,6 +122,13 @@ RSpec.describe Gitlab::Database::LockWritesManager, :delete, feature_category: :
}
end
+ it 'returns result hash with action skipped' do
+ subject.lock_writes
+
+ expect(subject.lock_writes).to eq({ action: "skipped", database: "main", dry_run: false,
+table: test_table })
+ end
+
context 'when running in dry_run mode' do
let(:dry_run) { true }
@@ -146,6 +153,11 @@ RSpec.describe Gitlab::Database::LockWritesManager, :delete, feature_category: :
connection.execute("truncate #{test_table}")
end.not_to raise_error
end
+
+ it 'returns result hash with action locked' do
+ expect(subject.lock_writes).to eq({ action: "locked", database: "main", dry_run: dry_run,
+table: test_table })
+ end
end
end
@@ -186,6 +198,11 @@ RSpec.describe Gitlab::Database::LockWritesManager, :delete, feature_category: :
subject.unlock_writes
end
+ it 'returns result hash with action unlocked' do
+ expect(subject.unlock_writes).to eq({ action: "unlocked", database: "main", dry_run: dry_run,
+table: test_table })
+ end
+
context 'when running in dry_run mode' do
let(:dry_run) { true }
@@ -206,6 +223,11 @@ RSpec.describe Gitlab::Database::LockWritesManager, :delete, feature_category: :
connection.execute("delete from #{test_table}")
end.to raise_error(ActiveRecord::StatementInvalid, /Table: "#{test_table}" is write protected/)
end
+
+ it 'returns result hash with dry_run true' do
+ expect(subject.unlock_writes).to eq({ action: "unlocked", database: "main", dry_run: dry_run,
+table: test_table })
+ end
end
end
diff --git a/spec/lib/gitlab/database/loose_foreign_keys_spec.rb b/spec/lib/gitlab/database/loose_foreign_keys_spec.rb
index 3c2d9ca82f2..552df64096a 100644
--- a/spec/lib/gitlab/database/loose_foreign_keys_spec.rb
+++ b/spec/lib/gitlab/database/loose_foreign_keys_spec.rb
@@ -85,31 +85,40 @@ RSpec.describe Gitlab::Database::LooseForeignKeys do
end
end
- describe '.definitions' do
- subject(:definitions) { described_class.definitions }
-
- it 'contains at least all parent tables that have triggers' do
- all_definition_parent_tables = definitions.map { |d| d.to_table }.to_set
+ context 'all tables have correct triggers installed' do
+ let(:all_tables_from_yaml) { described_class.definitions.pluck(:to_table).uniq }
+ let(:all_tables_with_triggers) do
triggers_query = <<~SQL
- SELECT event_object_table, trigger_name
- FROM information_schema.triggers
+ SELECT event_object_table FROM information_schema.triggers
WHERE trigger_name LIKE '%_loose_fk_trigger'
- GROUP BY event_object_table, trigger_name
SQL
- all_triggers = ApplicationRecord.connection.execute(triggers_query)
-
- all_triggers.each do |trigger|
- table = trigger['event_object_table']
- trigger_name = trigger['trigger_name']
- error_message = <<~END
- Missing a loose foreign key definition for parent table: #{table} with trigger: #{trigger_name}.
- Loose foreign key definitions must be added before triggers are added and triggers must be removed before removing the loose foreign key definition.
- Read more at https://docs.gitlab.com/ee/development/database/loose_foreign_keys.html ."
- END
- expect(all_definition_parent_tables).to include(table), error_message
- end
+ ApplicationRecord.connection.execute(triggers_query)
+ .pluck('event_object_table').uniq
+ end
+
+ it 'all YAML tables do have `track_record_deletions` installed' do
+ missing_trigger_tables = all_tables_from_yaml - all_tables_with_triggers
+
+ expect(missing_trigger_tables).to be_empty, <<~END
+ The loose foreign keys definitions require using `track_record_deletions`
+ for the following tables: #{missing_trigger_tables}.
+ Read more at https://docs.gitlab.com/ee/development/database/loose_foreign_keys.html."
+ END
+ end
+
+ it 'no extra tables have `track_record_deletions` installed' do
+ extra_trigger_tables = all_tables_with_triggers - all_tables_from_yaml
+
+ pending 'This result of this test is informatory, and not critical' if extra_trigger_tables.any?
+
+ expect(extra_trigger_tables).to be_empty, <<~END
+ The following tables have unused `track_record_deletions` triggers installed,
+ but they are not referenced by any of the loose foreign key definitions: #{extra_trigger_tables}.
+ You can remove them in one of the future releases as part of `db/post_migrate`.
+ Read more at https://docs.gitlab.com/ee/development/database/loose_foreign_keys.html."
+ END
end
end
diff --git a/spec/lib/gitlab/database/migration_helpers/automatic_lock_writes_on_tables_spec.rb b/spec/lib/gitlab/database/migration_helpers/automatic_lock_writes_on_tables_spec.rb
index be9346e3829..e4241348b54 100644
--- a/spec/lib/gitlab/database/migration_helpers/automatic_lock_writes_on_tables_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers/automatic_lock_writes_on_tables_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables,
- :reestablished_active_record_base, :delete, query_analyzers: false, feature_category: :pods do
+ :reestablished_active_record_base, :delete, query_analyzers: false, feature_category: :cell do
using RSpec::Parameterized::TableSyntax
let(:schema_class) { Class.new(Gitlab::Database::Migration[2.1]) }
@@ -86,7 +86,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables,
let(:create_gitlab_shared_table_migration_class) { create_table_migration(gitlab_shared_table_name) }
before do
- skip_if_multiple_databases_are_setup(:ci)
+ skip_if_database_exists(:ci)
end
it 'does not lock any newly created tables' do
@@ -106,7 +106,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables,
context 'when multiple databases' do
before do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
end
let(:migration_class) { create_table_migration(table_name, skip_automatic_lock_on_writes) }
@@ -224,13 +224,12 @@ RSpec.describe Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables,
let(:config_model) { Gitlab::Database.database_base_models[:main] }
it "raises an error about undefined gitlab_schema" do
- expected_error_message = <<~ERROR
- No gitlab_schema is defined for the table #{table_name}. Please consider
- adding it to the database dictionary.
- More info: https://docs.gitlab.com/ee/development/database/database_dictionary.html
- ERROR
-
- expect { run_migration }.to raise_error(expected_error_message)
+ expect { run_migration }.to raise_error(
+ Gitlab::Database::GitlabSchema::UnknownSchemaError,
+ "Could not find gitlab schema for table foobar: " \
+ "Any new or deleted tables must be added to the database dictionary " \
+ "See https://docs.gitlab.com/ee/development/database/database_dictionary.html"
+ )
end
end
end
@@ -238,7 +237,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables,
context 'when renaming a table' do
before do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
create_table_migration(old_table_name).migrate(:up) # create the table first before renaming it
end
@@ -277,7 +276,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables,
let(:config_model) { Gitlab::Database.database_base_models[:main] }
before do
- skip_if_multiple_databases_are_setup(:ci)
+ skip_if_database_exists(:ci)
end
it 'does not lock any newly created tables' do
@@ -305,7 +304,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables,
context 'when multiple databases' do
before do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
migration_class.connection.execute("CREATE TABLE #{table_name}()")
migration_class.migrate(:up)
end
diff --git a/spec/lib/gitlab/database/migration_helpers/convert_to_bigint_spec.rb b/spec/lib/gitlab/database/migration_helpers/convert_to_bigint_spec.rb
new file mode 100644
index 00000000000..cee5f54bd6a
--- /dev/null
+++ b/spec/lib/gitlab/database/migration_helpers/convert_to_bigint_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::MigrationHelpers::ConvertToBigint, feature_category: :database do
+ describe 'com_or_dev_or_test_but_not_jh?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:dot_com, :dev_or_test, :jh, :expectation) do
+ true | true | true | true
+ true | false | true | false
+ false | true | true | true
+ false | false | true | false
+ true | true | false | true
+ true | false | false | true
+ false | true | false | true
+ false | false | false | false
+ end
+
+ with_them do
+ it 'returns true for GitLab.com (but not JH), dev, or test' do
+ allow(Gitlab).to receive(:com?).and_return(dot_com)
+ allow(Gitlab).to receive(:dev_or_test_env?).and_return(dev_or_test)
+ allow(Gitlab).to receive(:jh?).and_return(jh)
+
+ migration = Class
+ .new
+ .include(Gitlab::Database::MigrationHelpers::ConvertToBigint)
+ .new
+
+ expect(migration.com_or_dev_or_test_but_not_jh?).to eq(expectation)
+ end
+ end
+ end
+end
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
index 25fc676d09e..2b58cdff931 100644
--- 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
@@ -7,20 +7,22 @@ RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do
ActiveRecord::Migration.new.extend(described_class)
end
+ let_it_be(:table_name) { :_test_loose_fk_test_table }
+
let(:model) do
Class.new(ApplicationRecord) do
- self.table_name = '_test_loose_fk_test_table'
+ self.table_name = :_test_loose_fk_test_table
end
end
before(:all) do
- migration.create_table :_test_loose_fk_test_table do |t|
+ migration.create_table table_name do |t|
t.timestamps
end
end
after(:all) do
- migration.drop_table :_test_loose_fk_test_table
+ migration.drop_table table_name
end
before do
@@ -33,11 +35,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do
expect(LooseForeignKeys::DeletedRecord.count).to eq(0)
end
+
+ it { expect(migration.has_loose_foreign_key?(table_name)).to be_falsy }
end
context 'when the record deletion tracker trigger is installed' do
before do
- migration.track_record_deletions(:_test_loose_fk_test_table)
+ migration.track_record_deletions(table_name)
end
it 'stores the record deletion' do
@@ -55,7 +59,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do
.first
expect(deleted_record.primary_key_value).to eq(record_to_be_deleted.id)
- expect(deleted_record.fully_qualified_table_name).to eq('public._test_loose_fk_test_table')
+ expect(deleted_record.fully_qualified_table_name).to eq("public.#{table_name}")
expect(deleted_record.partition_number).to eq(1)
end
@@ -64,5 +68,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do
expect(LooseForeignKeys::DeletedRecord.count).to eq(3)
end
+
+ it { expect(migration.has_loose_foreign_key?(table_name)).to be_truthy }
end
end
diff --git a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb
index 714fbab5aff..faf0447c054 100644
--- a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_analyzers: false,
- stub_feature_flags: false, feature_category: :pods do
+ stub_feature_flags: false, feature_category: :cell do
let(:schema_class) { Class.new(Gitlab::Database::Migration[1.0]).include(described_class) }
# We keep only the GitlabSchemasValidateConnection analyzer running
@@ -506,7 +506,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
def down; end
end,
query_matcher: /FROM ci_builds/,
- setup: -> (_) { skip_if_multiple_databases_not_setup },
+ setup: -> (_) { skip_if_shared_database(:ci) },
expected: {
no_gitlab_schema: {
main: :cross_schema_error,
diff --git a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb
index 0d75094a2fd..8b653e2d89d 100644
--- a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb
@@ -416,4 +416,83 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do
end
end
end
+
+ describe '#truncate_tables!' do
+ before do
+ ApplicationRecord.connection.execute(<<~SQL)
+ CREATE TABLE _test_gitlab_main_table (id serial primary key);
+ CREATE TABLE _test_gitlab_main_table2 (id serial primary key);
+
+ INSERT INTO _test_gitlab_main_table DEFAULT VALUES;
+ INSERT INTO _test_gitlab_main_table2 DEFAULT VALUES;
+ SQL
+
+ Ci::ApplicationRecord.connection.execute(<<~SQL)
+ CREATE TABLE _test_gitlab_ci_table (id serial primary key);
+ SQL
+ end
+
+ it 'truncates the table' do
+ expect(migration).to receive(:execute).with('TRUNCATE TABLE "_test_gitlab_main_table"').and_call_original
+
+ expect { migration.truncate_tables!('_test_gitlab_main_table') }
+ .to change { ApplicationRecord.connection.select_value('SELECT count(1) from _test_gitlab_main_table') }.to(0)
+ end
+
+ it 'truncates multiple tables' do
+ expect(migration).to receive(:execute).with('TRUNCATE TABLE "_test_gitlab_main_table", "_test_gitlab_main_table2"').and_call_original
+
+ expect { migration.truncate_tables!('_test_gitlab_main_table', '_test_gitlab_main_table2') }
+ .to change { ApplicationRecord.connection.select_value('SELECT count(1) from _test_gitlab_main_table') }.to(0)
+ .and change { ApplicationRecord.connection.select_value('SELECT count(1) from _test_gitlab_main_table2') }.to(0)
+ end
+
+ it 'raises an ArgumentError if truncating multiple gitlab_schema' do
+ expect do
+ migration.truncate_tables!('_test_gitlab_main_table', '_test_gitlab_ci_table')
+ end.to raise_error(ArgumentError, /one `gitlab_schema`/)
+ end
+
+ context 'with multiple databases' do
+ before do
+ skip_if_shared_database(:ci)
+ end
+
+ context 'for ci database' do
+ before do
+ migration.instance_variable_set :@connection, Ci::ApplicationRecord.connection
+ end
+
+ it 'skips the TRUNCATE statement tables not in schema for connection' do
+ expect(migration).not_to receive(:execute)
+
+ migration.truncate_tables!('_test_gitlab_main_table')
+ end
+ end
+
+ context 'for main database' do
+ before do
+ migration.instance_variable_set :@connection, ApplicationRecord.connection
+ end
+
+ it 'executes a TRUNCATE statement' do
+ expect(migration).to receive(:execute).with('TRUNCATE TABLE "_test_gitlab_main_table"')
+
+ migration.truncate_tables!('_test_gitlab_main_table')
+ end
+ end
+ end
+
+ context 'with single database' do
+ before do
+ skip_if_database_exists(:ci)
+ end
+
+ it 'executes a TRUNCATE statement' do
+ expect(migration).to receive(:execute).with('TRUNCATE TABLE "_test_gitlab_main_table"')
+
+ migration.truncate_tables!('_test_gitlab_main_table')
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers_spec.rb
new file mode 100644
index 00000000000..f7d11184ac7
--- /dev/null
+++ b/spec/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::MigrationHelpers::WraparoundVacuumHelpers, feature_category: :database do
+ include Database::DatabaseHelpers
+
+ let(:table_name) { 'ci_builds' }
+
+ describe '#check_if_wraparound_in_progress' do
+ let(:migration) do
+ ActiveRecord::Migration.new.extend(described_class)
+ end
+
+ subject { migration.check_if_wraparound_in_progress(table_name) }
+
+ it 'delegates to the wraparound class' do
+ expect(described_class::WraparoundCheck)
+ .to receive(:new)
+ .with(table_name, migration: migration)
+ .and_call_original
+
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ describe described_class::WraparoundCheck do
+ let(:migration) do
+ ActiveRecord::Migration.new.extend(Gitlab::Database::MigrationHelpers::WraparoundVacuumHelpers)
+ end
+
+ describe '#execute' do
+ subject do
+ described_class.new(table_name, migration: migration).execute
+ end
+
+ context 'with wraparound vacuuum running' do
+ before do
+ swapout_view_for_table(:pg_stat_activity, connection: migration.connection, schema: 'pg_temp')
+
+ migration.connection.execute(<<~SQL.squish)
+ INSERT INTO pg_stat_activity (
+ datid, datname, pid, backend_start, xact_start, query_start,
+ state_change, wait_event_type, wait_event, state, backend_xmin,
+ query, backend_type)
+ VALUES (
+ 16401, current_database(), 178, '2023-03-30 08:10:50.851322+00',
+ '2023-03-30 08:10:50.890485+00', now() - '150 minutes'::interval,
+ '2023-03-30 08:10:50.890485+00', 'IO', 'DataFileRead', 'active','3214790381'::xid,
+ 'autovacuum: VACUUM public.ci_builds (to prevent wraparound)', 'autovacuum worker')
+ SQL
+ end
+
+ it 'outputs a message related to autovacuum' do
+ expect { subject }
+ .to output(/Autovacuum with wraparound prevention mode is running on `ci_builds`/).to_stdout
+ end
+
+ it { expect { subject }.to output(/autovacuum: VACUUM public.ci_builds \(to prevent wraparound\)/).to_stdout }
+ it { expect { subject }.to output(/Current duration: 2 hours, 30 minutes/).to_stdout }
+
+ context 'when GITLAB_MIGRATIONS_DISABLE_WRAPAROUND_CHECK is set' do
+ before do
+ stub_env('GITLAB_MIGRATIONS_DISABLE_WRAPAROUND_CHECK' => 'true')
+ end
+
+ it { expect { subject }.not_to output(/autovacuum/i).to_stdout }
+
+ it 'is disabled on .com' do
+ expect(Gitlab).to receive(:com?).and_return(true)
+
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when executed by self-managed' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(false)
+ allow(Gitlab).to receive(:dev_or_test_env?).and_return(false)
+ end
+
+ it { expect { subject }.not_to output(/autovacuum/i).to_stdout }
+ end
+ end
+
+ context 'with wraparound vacuuum not running' do
+ it { expect { subject }.not_to output(/autovacuum/i).to_stdout }
+ end
+
+ context 'when the table does not exist' do
+ let(:table_name) { :no_table }
+
+ it { expect { subject }.to raise_error described_class::WraparoundError, /no_table/ }
+ 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 9df23776be8..b1e8301d69f 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::MigrationHelpers do
+RSpec.describe Gitlab::Database::MigrationHelpers, feature_category: :database do
include Database::TableSchemaHelpers
include Database::TriggerHelpers
@@ -14,8 +14,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
allow(model).to receive(:puts)
end
+ it { expect(model.singleton_class.ancestors).to include(described_class::WraparoundVacuumHelpers) }
+
describe 'overridden dynamic model helpers' do
- let(:test_table) { '_test_batching_table' }
+ let(:test_table) { :_test_batching_table }
before do
model.connection.execute(<<~SQL)
@@ -120,157 +122,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
end
- describe '#create_table_with_constraints' 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
-
- before do
- allow(model).to receive(:transaction_open?).and_return(true)
- end
-
- context 'when no check constraints are defined' do
- it 'creates the table as expected' do
- model.create_table_with_constraints table_name do |t|
- t.timestamps_with_timezone
- t.integer :some_id, null: false
- t.boolean :active, null: false, default: true
- t.text :name
- end
-
- expect_table_columns_to_match(column_attributes, table_name)
- end
- end
-
- context 'when check constraints are defined' do
- context 'when the text_limit is explicity named' do
- it 'creates the table as expected' do
- model.create_table_with_constraints table_name do |t|
- t.timestamps_with_timezone
- t.integer :some_id, null: false
- t.boolean :active, null: false, default: true
- t.text :name
-
- t.text_limit :name, 255, name: 'check_name_length'
- t.check_constraint :some_id_is_positive, 'some_id > 0'
- end
-
- expect_table_columns_to_match(column_attributes, table_name)
-
- expect_check_constraint(table_name, 'check_name_length', 'char_length(name) <= 255')
- expect_check_constraint(table_name, 'some_id_is_positive', 'some_id > 0')
- end
- end
-
- context 'when the text_limit is not named' do
- it 'creates the table as expected, naming the text limit' do
- model.create_table_with_constraints table_name do |t|
- t.timestamps_with_timezone
- t.integer :some_id, null: false
- t.boolean :active, null: false, default: true
- t.text :name
-
- t.text_limit :name, 255
- t.check_constraint :some_id_is_positive, 'some_id > 0'
- end
-
- expect_table_columns_to_match(column_attributes, table_name)
-
- expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 255')
- expect_check_constraint(table_name, 'some_id_is_positive', 'some_id > 0')
- end
- end
-
- it 'runs the change within a with_lock_retries' do
- expect(model).to receive(:with_lock_retries).ordered.and_yield
- expect(model).to receive(:create_table).ordered.and_call_original
- expect(model).to receive(:execute).with(<<~SQL).ordered
- ALTER TABLE "#{table_name}"\nADD CONSTRAINT "check_cda6f69506" CHECK (char_length("name") <= 255)
- SQL
-
- model.create_table_with_constraints table_name do |t|
- t.text :name
- t.text_limit :name, 255
- end
- end
-
- context 'when with_lock_retries re-runs the block' do
- it 'only creates constraint for unique definitions' do
- expected_sql = <<~SQL
- ALTER TABLE "#{table_name}"\nADD CONSTRAINT "check_cda6f69506" CHECK (char_length("name") <= 255)
- SQL
-
- expect(model).to receive(:create_table).twice.and_call_original
-
- expect(model).to receive(:execute).with(expected_sql).and_raise(ActiveRecord::LockWaitTimeout)
- expect(model).to receive(:execute).with(expected_sql).and_call_original
-
- model.create_table_with_constraints table_name do |t|
- t.timestamps_with_timezone
- t.integer :some_id, null: false
- t.boolean :active, null: false, default: true
- t.text :name
-
- t.text_limit :name, 255
- end
-
- expect_table_columns_to_match(column_attributes, table_name)
-
- expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 255')
- end
- end
-
- context 'when constraints are given invalid names' do
- let(:expected_max_length) { described_class::MAX_IDENTIFIER_NAME_LENGTH }
- let(:expected_error_message) { "The maximum allowed constraint name is #{expected_max_length} characters" }
-
- context 'when the explicit text limit name is not valid' do
- it 'raises an error' do
- too_long_length = expected_max_length + 1
-
- expect do
- model.create_table_with_constraints table_name do |t|
- t.timestamps_with_timezone
- t.integer :some_id, null: false
- t.boolean :active, null: false, default: true
- t.text :name
-
- t.text_limit :name, 255, name: ('a' * too_long_length)
- t.check_constraint :some_id_is_positive, 'some_id > 0'
- end
- end.to raise_error(expected_error_message)
- end
- end
-
- context 'when a check constraint name is not valid' do
- it 'raises an error' do
- too_long_length = expected_max_length + 1
-
- expect do
- model.create_table_with_constraints table_name do |t|
- t.timestamps_with_timezone
- t.integer :some_id, null: false
- t.boolean :active, null: false, default: true
- t.text :name
-
- t.text_limit :name, 255
- t.check_constraint ('a' * too_long_length), 'some_id > 0'
- end
- end.to raise_error(expected_error_message)
- end
- end
- end
- end
- end
-
describe '#add_concurrent_index' do
context 'outside a transaction' do
before do
@@ -392,7 +243,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
context 'when targeting a partition table' do
let(:schema) { 'public' }
- let(:name) { '_test_partition_01' }
+ let(:name) { :_test_partition_01 }
let(:identifier) { "#{schema}.#{name}" }
before do
@@ -471,10 +322,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
context 'when targeting a partition table' do
let(:schema) { 'public' }
- let(:partition_table_name) { '_test_partition_01' }
+ let(:partition_table_name) { :_test_partition_01 }
let(:identifier) { "#{schema}.#{partition_table_name}" }
- let(:index_name) { '_test_partitioned_index' }
- let(:partition_index_name) { '_test_partition_01_partition_id_idx' }
+ let(:index_name) { :_test_partitioned_index }
+ let(:partition_index_name) { :_test_partition_01_partition_id_idx }
let(:column_name) { 'partition_id' }
before do
@@ -544,10 +395,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
context 'when targeting a partition table' do
let(:schema) { 'public' }
- let(:partition_table_name) { '_test_partition_01' }
+ let(:partition_table_name) { :_test_partition_01 }
let(:identifier) { "#{schema}.#{partition_table_name}" }
- let(:index_name) { '_test_partitioned_index' }
- let(:partition_index_name) { '_test_partition_01_partition_id_idx' }
+ let(:index_name) { :_test_partitioned_index }
+ let(:partition_index_name) { :_test_partition_01_partition_id_idx }
before do
model.execute(<<~SQL)
@@ -928,13 +779,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
it 'references the custom target columns when provided', :aggregate_failures do
expect(model).to receive(:with_lock_retries).and_yield
expect(model).to receive(:execute).with(
- "ALTER TABLE projects\n" \
- "ADD CONSTRAINT fk_multiple_columns\n" \
- "FOREIGN KEY \(partition_number, user_id\)\n" \
- "REFERENCES users \(partition_number, id\)\n" \
- "ON UPDATE CASCADE\n" \
- "ON DELETE CASCADE\n" \
- "NOT VALID;\n"
+ "ALTER TABLE projects " \
+ "ADD CONSTRAINT fk_multiple_columns " \
+ "FOREIGN KEY \(partition_number, user_id\) " \
+ "REFERENCES users \(partition_number, id\) " \
+ "ON UPDATE CASCADE " \
+ "ON DELETE CASCADE " \
+ "NOT VALID;"
)
model.add_concurrent_foreign_key(
@@ -979,6 +830,80 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
end
end
+
+ context 'when creating foreign key on a partitioned table' do
+ let(:source) { :_test_source_partitioned_table }
+ let(:dest) { :_test_dest_partitioned_table }
+ let(:args) { [source, dest] }
+ let(:options) { { column: [:partition_id, :owner_id], target_column: [:partition_id, :id] } }
+
+ before do
+ model.execute(<<~SQL)
+ CREATE TABLE public.#{source} (
+ id serial NOT NULL,
+ partition_id serial NOT NULL,
+ owner_id bigint NOT NULL,
+ PRIMARY KEY (id, partition_id)
+ ) PARTITION BY LIST(partition_id);
+
+ CREATE TABLE #{source}_1
+ PARTITION OF public.#{source}
+ FOR VALUES IN (1);
+
+ CREATE TABLE public.#{dest} (
+ id serial NOT NULL,
+ partition_id serial NOT NULL,
+ PRIMARY KEY (id, partition_id)
+ );
+ SQL
+ end
+
+ it 'creates the FK without using NOT VALID', :aggregate_failures do
+ allow(model).to receive(:execute).and_call_original
+
+ expect(model).to receive(:with_lock_retries).and_yield
+
+ expect(model).to receive(:execute).with(
+ "ALTER TABLE #{source} " \
+ "ADD CONSTRAINT fk_multiple_columns " \
+ "FOREIGN KEY \(partition_id, owner_id\) " \
+ "REFERENCES #{dest} \(partition_id, id\) " \
+ "ON UPDATE CASCADE ON DELETE CASCADE ;"
+ )
+
+ model.add_concurrent_foreign_key(
+ *args,
+ name: :fk_multiple_columns,
+ on_update: :cascade,
+ allow_partitioned: true,
+ **options
+ )
+ end
+
+ it 'raises an error if allow_partitioned is not set' do
+ expect(model).not_to receive(:with_lock_retries).and_yield
+ expect(model).not_to receive(:execute).with(/FOREIGN KEY/)
+
+ expect { model.add_concurrent_foreign_key(*args, **options) }
+ .to raise_error ArgumentError, /use add_concurrent_partitioned_foreign_key/
+ end
+
+ context 'when the reverse_lock_order flag is set' do
+ it 'explicitly locks the tables in target-source order', :aggregate_failures do
+ expect(model).to receive(:with_lock_retries).and_call_original
+ expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
+ expect(model).to receive(:execute).with(/SET statement_timeout TO/)
+ expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT/)
+ expect(model).to receive(:execute).ordered.with(/RESET statement_timeout/)
+
+ expect(model).to receive(:execute).with("LOCK TABLE #{dest}, #{source} IN ACCESS EXCLUSIVE MODE")
+ expect(model).to receive(:execute).with(/REFERENCES #{dest} \(partition_id, id\)/)
+
+ model.add_concurrent_foreign_key(*args, reverse_lock_order: true, allow_partitioned: true, **options)
+ end
+ end
+ end
end
end
@@ -1047,8 +972,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
describe '#foreign_key_exists?' do
- let(:referenced_table_name) { '_test_gitlab_main_referenced' }
- let(:referencing_table_name) { '_test_gitlab_main_referencing' }
+ let(:referenced_table_name) { :_test_gitlab_main_referenced }
+ let(:referencing_table_name) { :_test_gitlab_main_referencing }
+ let(:schema) { 'public' }
+ let(:identifier) { "#{schema}.#{referencing_table_name}" }
before do
model.connection.execute(<<~SQL)
@@ -1085,6 +1012,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model.foreign_key_exists?(referencing_table_name, target_table)).to be_truthy
end
+ it 'finds existing foreign_keys by identifier' do
+ expect(model.foreign_key_exists?(identifier, target_table)).to be_truthy
+ end
+
it 'compares by column name if given' do
expect(model.foreign_key_exists?(referencing_table_name, target_table, column: :user_id)).to be_falsey
end
@@ -1119,6 +1050,38 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
it_behaves_like 'foreign key checks'
end
+ context 'if the schema cache does not include the constrained_columns column' do
+ let(:target_table) { nil }
+
+ around do |ex|
+ model.transaction do
+ require_migration!('add_columns_to_postgres_foreign_keys')
+ AddColumnsToPostgresForeignKeys.new.down
+ Gitlab::Database::PostgresForeignKey.reset_column_information
+ Gitlab::Database::PostgresForeignKey.columns_hash # Force populate the column hash in the old schema
+ AddColumnsToPostgresForeignKeys.new.up
+
+ # Rolling back reverts the schema cache information, so we need to run the example here before the rollback.
+ ex.run
+
+ raise ActiveRecord::Rollback
+ end
+
+ # make sure that we're resetting the schema cache here so that we don't leak the change to other tests.
+ Gitlab::Database::PostgresForeignKey.reset_column_information
+ # Double-check that the column information is back to normal
+ expect(Gitlab::Database::PostgresForeignKey.columns_hash.keys).to include('constrained_columns')
+ end
+
+ # This test verifies that the situation we're trying to set up for the shared examples is actually being
+ # set up correctly
+ it 'correctly sets up the test without the column in the columns_hash' do
+ expect(Gitlab::Database::PostgresForeignKey.columns_hash.keys).not_to include('constrained_columns')
+ end
+
+ it_behaves_like 'foreign key checks'
+ end
+
it 'compares by target table if no column given' do
expect(model.foreign_key_exists?(:projects, :other_table)).to be_falsey
end
@@ -1129,8 +1092,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
context 'with foreign key using multiple columns' do
- let(:p_referenced_table_name) { '_test_gitlab_main_p_referenced' }
- let(:p_referencing_table_name) { '_test_gitlab_main_p_referencing' }
+ let(:p_referenced_table_name) { :_test_gitlab_main_p_referenced }
+ let(:p_referencing_table_name) { :_test_gitlab_main_p_referencing }
before do
model.connection.execute(<<~SQL)
@@ -1254,7 +1217,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
context 'when the table is write-locked' do
- let(:test_table) { '_test_table' }
+ let(:test_table) { :_test_table }
let(:lock_writes_manager) do
Gitlab::Database::LockWritesManager.new(
table_name: test_table,
@@ -1436,7 +1399,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
context 'when the table in the other database is write-locked' do
- let(:test_table) { '_test_table' }
+ let(:test_table) { :_test_table }
let(:lock_writes_manager) do
Gitlab::Database::LockWritesManager.new(
table_name: test_table,
@@ -2129,7 +2092,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
describe '#create_temporary_columns_and_triggers' do
- let(:table) { :test_table }
+ let(:table) { :_test_table }
let(:column) { :id }
let(:mappings) do
{
@@ -2223,7 +2186,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
describe '#initialize_conversion_of_integer_to_bigint' do
- let(:table) { :test_table }
+ let(:table) { :_test_table }
let(:column) { :id }
let(:tmp_column) { model.convert_to_bigint_column(column) }
@@ -2308,7 +2271,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
describe '#restore_conversion_of_integer_to_bigint' do
- let(:table) { :test_table }
+ let(:table) { :_test_table }
let(:column) { :id }
let(:tmp_column) { model.convert_to_bigint_column(column) }
@@ -2363,7 +2326,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
describe '#revert_initialize_conversion_of_integer_to_bigint' do
- let(:table) { :test_table }
+ let(:table) { :_test_table }
before do
model.create_table table, id: false do |t|
@@ -2794,39 +2757,39 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
describe '#add_primary_key_using_index' do
it "executes the statement to add the primary key" do
- expect(model).to receive(:execute).with /ALTER TABLE "test_table" ADD CONSTRAINT "old_name" PRIMARY KEY USING INDEX "new_name"/
+ expect(model).to receive(:execute).with /ALTER TABLE "_test_table" ADD CONSTRAINT "old_name" PRIMARY KEY USING INDEX "new_name"/
- model.add_primary_key_using_index(:test_table, :old_name, :new_name)
+ model.add_primary_key_using_index(:_test_table, :old_name, :new_name)
end
end
context 'when changing the primary key of a given table' do
before do
- model.create_table(:test_table, primary_key: :id) do |t|
+ model.create_table(:_test_table, primary_key: :id) do |t|
t.integer :partition_number, default: 1
end
- model.add_index(:test_table, :id, unique: true, name: :old_index_name)
- model.add_index(:test_table, [:id, :partition_number], unique: true, name: :new_index_name)
+ model.add_index(:_test_table, :id, unique: true, name: :old_index_name)
+ model.add_index(:_test_table, [:id, :partition_number], unique: true, name: :new_index_name)
end
describe '#swap_primary_key' do
it 'executes statements to swap primary key', :aggregate_failures do
expect(model).to receive(:with_lock_retries).with(raise_on_exhaustion: true).ordered.and_yield
- expect(model).to receive(:execute).with(/ALTER TABLE "test_table" DROP CONSTRAINT "test_table_pkey" CASCADE/).and_call_original
- expect(model).to receive(:execute).with(/ALTER TABLE "test_table" ADD CONSTRAINT "test_table_pkey" PRIMARY KEY USING INDEX "new_index_name"/).and_call_original
+ expect(model).to receive(:execute).with(/ALTER TABLE "_test_table" DROP CONSTRAINT "_test_table_pkey" CASCADE/).and_call_original
+ expect(model).to receive(:execute).with(/ALTER TABLE "_test_table" ADD CONSTRAINT "_test_table_pkey" PRIMARY KEY USING INDEX "new_index_name"/).and_call_original
- model.swap_primary_key(:test_table, :test_table_pkey, :new_index_name)
+ model.swap_primary_key(:_test_table, :_test_table_pkey, :new_index_name)
end
context 'when new index does not exist' do
before do
- model.remove_index(:test_table, column: [:id, :partition_number])
+ model.remove_index(:_test_table, column: [:id, :partition_number])
end
it 'raises ActiveRecord::StatementInvalid' do
expect do
- model.swap_primary_key(:test_table, :test_table_pkey, :new_index_name)
+ model.swap_primary_key(:_test_table, :_test_table_pkey, :new_index_name)
end.to raise_error(ActiveRecord::StatementInvalid)
end
end
@@ -2835,27 +2798,27 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
describe '#unswap_primary_key' do
it 'executes statements to unswap primary key' do
expect(model).to receive(:with_lock_retries).with(raise_on_exhaustion: true).ordered.and_yield
- expect(model).to receive(:execute).with(/ALTER TABLE "test_table" DROP CONSTRAINT "test_table_pkey" CASCADE/).ordered.and_call_original
- expect(model).to receive(:execute).with(/ALTER TABLE "test_table" ADD CONSTRAINT "test_table_pkey" PRIMARY KEY USING INDEX "old_index_name"/).ordered.and_call_original
+ expect(model).to receive(:execute).with(/ALTER TABLE "_test_table" DROP CONSTRAINT "_test_table_pkey" CASCADE/).ordered.and_call_original
+ expect(model).to receive(:execute).with(/ALTER TABLE "_test_table" ADD CONSTRAINT "_test_table_pkey" PRIMARY KEY USING INDEX "old_index_name"/).ordered.and_call_original
- model.unswap_primary_key(:test_table, :test_table_pkey, :old_index_name)
+ model.unswap_primary_key(:_test_table, :_test_table_pkey, :old_index_name)
end
end
end
describe '#drop_sequence' do
it "executes the statement to drop the sequence" do
- expect(model).to receive(:execute).with /ALTER TABLE "test_table" ALTER COLUMN "test_column" DROP DEFAULT;\nDROP SEQUENCE IF EXISTS "test_table_id_seq"/
+ expect(model).to receive(:execute).with /ALTER TABLE "_test_table" ALTER COLUMN "test_column" DROP DEFAULT;\nDROP SEQUENCE IF EXISTS "_test_table_id_seq"/
- model.drop_sequence(:test_table, :test_column, :test_table_id_seq)
+ model.drop_sequence(:_test_table, :test_column, :_test_table_id_seq)
end
end
describe '#add_sequence' do
it "executes the statement to add the sequence" do
- expect(model).to receive(:execute).with "CREATE SEQUENCE \"test_table_id_seq\" START 1;\nALTER TABLE \"test_table\" ALTER COLUMN \"test_column\" SET DEFAULT nextval(\'test_table_id_seq\')\n"
+ expect(model).to receive(:execute).with "CREATE SEQUENCE \"_test_table_id_seq\" START 1;\nALTER TABLE \"_test_table\" ALTER COLUMN \"test_column\" SET DEFAULT nextval(\'_test_table_id_seq\')\n"
- model.add_sequence(:test_table, :test_column, :test_table_id_seq, 1)
+ model.add_sequence(:_test_table, :test_column, :_test_table_id_seq, 1)
end
end
@@ -2890,4 +2853,18 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
it { is_expected.to be_falsey }
end
end
+
+ describe "#table_partitioned?" do
+ subject { model.table_partitioned?(table_name) }
+
+ let(:table_name) { 'p_ci_builds_metadata' }
+
+ it { is_expected.to be_truthy }
+
+ context 'with a non-partitioned table' do
+ let(:table_name) { 'users' }
+
+ it { is_expected.to be_falsey }
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb
index 3e249b14f2e..f5ce207773f 100644
--- a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb
@@ -482,16 +482,46 @@ RSpec.describe Gitlab::Database::Migrations::BatchedBackgroundMigrationHelpers d
.not_to raise_error
end
- it 'logs a warning when migration does not exist' do
- expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_dml_mode!)
+ context 'when specified migration does not exist' do
+ let(:lab_key) { 'DBLAB_ENVIRONMENT' }
- create(:batched_background_migration, :active, migration_attributes.merge(gitlab_schema: :gitlab_something_else))
+ context 'when DBLAB_ENVIRONMENT is not set' do
+ it 'logs a warning' do
+ stub_env(lab_key, nil)
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_dml_mode!)
- expect(Gitlab::AppLogger).to receive(:warn)
- .with("Could not find batched background migration for the given configuration: #{configuration}")
+ create(:batched_background_migration, :active, migration_attributes.merge(gitlab_schema: :gitlab_something_else))
- expect { ensure_batched_background_migration_is_finished }
- .not_to raise_error
+ expect(Gitlab::AppLogger).to receive(:warn)
+ .with("Could not find batched background migration for the given configuration: #{configuration}")
+
+ expect { ensure_batched_background_migration_is_finished }
+ .not_to raise_error
+ end
+ end
+
+ context 'when DBLAB_ENVIRONMENT is set' do
+ it 'raises an error' do
+ stub_env(lab_key, 'foo')
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_dml_mode!)
+
+ create(:batched_background_migration, :active, migration_attributes.merge(gitlab_schema: :gitlab_something_else))
+
+ expect { ensure_batched_background_migration_is_finished }
+ .to raise_error(Gitlab::Database::Migrations::BatchedBackgroundMigrationHelpers::NonExistentMigrationError)
+ end
+ end
+ end
+
+ context 'when within transaction' do
+ before do
+ allow(migration).to receive(:transaction_open?).and_return(true)
+ end
+
+ it 'does raise an exception' do
+ expect { ensure_batched_background_migration_is_finished }
+ .to raise_error /`ensure_batched_background_migration_is_finished` cannot be run inside a transaction./
+ end
end
it 'finalizes the migration' do
diff --git a/spec/lib/gitlab/database/migrations/constraints_helpers_spec.rb b/spec/lib/gitlab/database/migrations/constraints_helpers_spec.rb
index 6848fc85aa1..07d913cf5cc 100644
--- a/spec/lib/gitlab/database/migrations/constraints_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migrations/constraints_helpers_spec.rb
@@ -23,43 +23,46 @@ RSpec.describe Gitlab::Database::Migrations::ConstraintsHelpers do
end
end
- describe '#check_constraint_exists?' do
+ describe '#check_constraint_exists?', :aggregate_failures do
before do
- ActiveRecord::Migration.connection.execute(
- 'ALTER TABLE projects ADD CONSTRAINT check_1 CHECK (char_length(path) <= 5) NOT VALID'
- )
-
- ActiveRecord::Migration.connection.execute(
- 'CREATE SCHEMA new_test_schema'
- )
-
- ActiveRecord::Migration.connection.execute(
- 'CREATE TABLE new_test_schema.projects (id integer, name character varying)'
- )
-
- ActiveRecord::Migration.connection.execute(
- 'ALTER TABLE new_test_schema.projects ADD CONSTRAINT check_2 CHECK (char_length(name) <= 5)'
- )
+ ActiveRecord::Migration.connection.execute(<<~SQL)
+ ALTER TABLE projects ADD CONSTRAINT check_1 CHECK (char_length(path) <= 5) NOT VALID;
+ CREATE SCHEMA new_test_schema;
+ CREATE TABLE new_test_schema.projects (id integer, name character varying);
+ ALTER TABLE new_test_schema.projects ADD CONSTRAINT check_2 CHECK (char_length(name) <= 5);
+ SQL
end
it 'returns true if a constraint exists' do
expect(model)
.to be_check_constraint_exists(:projects, 'check_1')
+
+ expect(described_class)
+ .to be_check_constraint_exists(:projects, 'check_1', connection: model.connection)
end
it 'returns false if a constraint does not exist' do
expect(model)
.not_to be_check_constraint_exists(:projects, 'this_does_not_exist')
+
+ expect(described_class)
+ .not_to be_check_constraint_exists(:projects, 'this_does_not_exist', connection: model.connection)
end
it 'returns false if a constraint with the same name exists in another table' do
expect(model)
.not_to be_check_constraint_exists(:users, 'check_1')
+
+ expect(described_class)
+ .not_to be_check_constraint_exists(:users, 'check_1', connection: model.connection)
end
it 'returns false if a constraint with the same name exists for the same table in another schema' do
expect(model)
.not_to be_check_constraint_exists(:projects, 'check_2')
+
+ expect(described_class)
+ .not_to be_check_constraint_exists(:projects, 'check_2', connection: model.connection)
end
end
diff --git a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb
index 4f347034c0b..0b25389c667 100644
--- a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb
+++ b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb
@@ -18,7 +18,9 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do
let(:migration_name) { 'test' }
let(:migration_version) { '12345' }
let(:migration_meta) { { 'max_batch_size' => 1, 'total_tuple_count' => 10, 'interval' => 60 } }
- let(:expected_json_keys) { %w[version name walltime success total_database_size_change query_statistics] }
+ let(:expected_json_keys) do
+ %w[version name walltime success total_database_size_change query_statistics error_message]
+ end
it 'executes the given block' do
expect do |b|
@@ -90,16 +92,14 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do
end
context 'upon failure' do
- where(exception: ['something went wrong', SystemStackError, Interrupt])
+ where(:exception, :error_message) do
+ [[StandardError, 'something went wrong'], [ActiveRecord::StatementTimeout, 'timeout']]
+ end
with_them do
subject(:observe) do
instrumentation.observe(version: migration_version, name: migration_name,
- connection: connection, meta: migration_meta) { raise exception }
- end
-
- it 'raises the exception' do
- expect { observe }.to raise_error(exception)
+ connection: connection, meta: migration_meta) { raise exception, error_message }
end
context 'retrieving observations' do
@@ -107,10 +107,6 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do
before do
observe
- # rubocop:disable Lint/RescueException
- rescue Exception
- # rubocop:enable Lint/RescueException
- # ignore (we expect this exception)
end
it 'records a valid observation', :aggregate_failures do
@@ -118,6 +114,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do
expect(subject['success']).to be_falsey
expect(subject['version']).to eq(migration_version)
expect(subject['name']).to eq(migration_name)
+ expect(subject['error_message']).to eq(error_message)
end
it 'transforms observation to expected json' do
diff --git a/spec/lib/gitlab/database/migrations/pg_backend_pid_spec.rb b/spec/lib/gitlab/database/migrations/pg_backend_pid_spec.rb
new file mode 100644
index 00000000000..33e83ea2575
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/pg_backend_pid_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::PgBackendPid, feature_category: :database do
+ describe Gitlab::Database::Migrations::PgBackendPid::MigratorPgBackendPid do
+ let(:klass) do
+ Class.new do
+ def with_advisory_lock_connection
+ yield :conn
+ end
+ end
+ end
+
+ it 're-yields with same arguments and wraps it with calls to .say' do
+ patched_instance = klass.prepend(described_class).new
+ expect(Gitlab::Database::Migrations::PgBackendPid).to receive(:say).twice
+
+ expect { |b| patched_instance.with_advisory_lock_connection(&b) }.to yield_with_args(:conn)
+ end
+ end
+
+ describe '.patch!' do
+ it 'patches ActiveRecord::Migrator' do
+ expect(ActiveRecord::Migrator).to receive(:prepend).with(described_class::MigratorPgBackendPid)
+
+ described_class.patch!
+ end
+ end
+
+ describe '.say' do
+ it 'outputs the connection information' do
+ conn = ActiveRecord::Base.connection
+
+ expect(conn).to receive(:object_id).and_return(9876)
+ expect(conn).to receive(:select_value).with('SELECT pg_backend_pid()').and_return(12345)
+ expect(Gitlab::Database).to receive(:db_config_name).with(conn).and_return('main')
+
+ expected_output = "main: == [advisory_lock_connection] object_id: 9876, pg_backend_pid: 12345\n"
+
+ expect { described_class.say(conn) }.to output(expected_output).to_stdout
+ end
+
+ it 'outputs nothing if ActiveRecord::Migration.verbose is false' do
+ conn = ActiveRecord::Base.connection
+
+ allow(ActiveRecord::Migration).to receive(:verbose).and_return(false)
+
+ expect { described_class.say(conn) }.not_to output.to_stdout
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migrations/runner_backoff/active_record_mixin_spec.rb b/spec/lib/gitlab/database/migrations/runner_backoff/active_record_mixin_spec.rb
new file mode 100644
index 00000000000..ddf11598d21
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/runner_backoff/active_record_mixin_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::RunnerBackoff::ActiveRecordMixin, feature_category: :database do
+ let(:migration_class) { Gitlab::Database::Migration[2.1] }
+
+ describe described_class::ActiveRecordMigrationProxyRunnerBackoff do
+ let(:migration) { instance_double(migration_class) }
+
+ let(:class_def) do
+ Class.new do
+ attr_reader :migration
+
+ def initialize(migration)
+ @migration = migration
+ end
+ end.prepend(described_class)
+ end
+
+ describe '#enable_runner_backoff?' do
+ subject { class_def.new(migration).enable_runner_backoff? }
+
+ it 'delegates to #migration' do
+ expect(migration).to receive(:enable_runner_backoff?).and_return(true)
+
+ expect(subject).to eq(true)
+ end
+
+ it 'returns false if migration does not implement it' do
+ expect(migration).to receive(:respond_to?).with(:enable_runner_backoff?).and_return(false)
+
+ expect(subject).to eq(false)
+ end
+ end
+ end
+
+ describe described_class::ActiveRecordMigratorRunnerBackoff do
+ let(:class_def) do
+ Class.new do
+ attr_reader :receiver
+
+ def initialize(receiver)
+ @receiver = receiver
+ end
+
+ def execute_migration_in_transaction(migration)
+ receiver.execute_migration_in_transaction(migration)
+ end
+ end.prepend(described_class)
+ end
+
+ let(:receiver) { instance_double(ActiveRecord::Migrator, 'receiver') }
+
+ subject { class_def.new(receiver) }
+
+ before do
+ allow(migration).to receive(:name).and_return('TestClass')
+ allow(receiver).to receive(:execute_migration_in_transaction)
+ end
+
+ context 'with runner backoff disabled' do
+ let(:migration) { instance_double(migration_class, enable_runner_backoff?: false) }
+
+ it 'calls super method' do
+ expect(receiver).to receive(:execute_migration_in_transaction).with(migration)
+
+ subject.execute_migration_in_transaction(migration)
+ end
+ end
+
+ context 'with runner backoff enabled' do
+ let(:migration) { instance_double(migration_class, enable_runner_backoff?: true) }
+
+ it 'calls super method' do
+ expect(Gitlab::Database::Migrations::RunnerBackoff::Communicator)
+ .to receive(:execute_with_lock).with(migration).and_call_original
+
+ expect(receiver).to receive(:execute_migration_in_transaction)
+ .with(migration)
+
+ subject.execute_migration_in_transaction(migration)
+ end
+ end
+ end
+
+ describe '.patch!' do
+ subject { described_class.patch! }
+
+ it 'patches MigrationProxy' do
+ expect(ActiveRecord::MigrationProxy)
+ .to receive(:prepend)
+ .with(described_class::ActiveRecordMigrationProxyRunnerBackoff)
+
+ subject
+ end
+
+ it 'patches Migrator' do
+ expect(ActiveRecord::Migrator)
+ .to receive(:prepend)
+ .with(described_class::ActiveRecordMigratorRunnerBackoff)
+
+ subject
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migrations/runner_backoff/communicator_spec.rb b/spec/lib/gitlab/database/migrations/runner_backoff/communicator_spec.rb
new file mode 100644
index 00000000000..cfc3fb398e2
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/runner_backoff/communicator_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::RunnerBackoff::Communicator, :clean_gitlab_redis_shared_state, feature_category: :database do
+ let(:migration) { instance_double(Gitlab::Database::Migration[2.1], name: 'TestClass') }
+
+ describe '.execute_with_lock' do
+ it 'delegates to a new instance object' do
+ expect_next_instance_of(described_class, migration) do |communicator|
+ expect(communicator).to receive(:execute_with_lock).and_call_original
+ end
+
+ expect { |b| described_class.execute_with_lock(migration, &b) }.to yield_control
+ end
+ end
+
+ describe '.backoff_runner?' do
+ subject { described_class.backoff_runner? }
+
+ it { is_expected.to be_falsey }
+
+ it 'is true when the lock is held' do
+ described_class.execute_with_lock(migration) do
+ is_expected.to be_truthy
+ end
+ end
+
+ it 'reads from Redis' do
+ recorder = RedisCommands::Recorder.new { subject }
+ expect(recorder.log).to include([:exists, 'gitlab:exclusive_lease:gitlab/database/migration/runner/backoff'])
+ end
+
+ context 'with runner_migrations_backoff disabled' do
+ before do
+ stub_feature_flags(runner_migrations_backoff: false)
+ end
+
+ it 'is false when the lock is held' do
+ described_class.execute_with_lock(migration) do
+ is_expected.to be_falsey
+ end
+ end
+ end
+ end
+
+ describe '#execute_with_lock' do
+ include ExclusiveLeaseHelpers
+
+ let(:communicator) { described_class.new(migration) }
+ let!(:lease) { stub_exclusive_lease(described_class::KEY, :uuid, timeout: described_class::EXPIRY) }
+
+ it { expect { |b| communicator.execute_with_lock(&b) }.to yield_control }
+
+ it 'raises error if it can not set the key' do
+ expect(lease).to receive(:try_obtain).ordered.and_return(false)
+
+ expect { communicator.execute_with_lock { 1 / 0 } }.to raise_error 'Could not set backoff key'
+ end
+
+ it 'removes the lease after executing the migration' do
+ expect(lease).to receive(:try_obtain).ordered.and_return(true)
+ expect(lease).to receive(:cancel).ordered.and_return(true)
+
+ expect { communicator.execute_with_lock }.not_to raise_error
+ end
+
+ context 'with logger' do
+ let(:logger) { instance_double(Gitlab::AppLogger) }
+ let(:communicator) { described_class.new(migration, logger: logger) }
+
+ it 'logs messages around execution' do
+ expect(logger).to receive(:info).ordered
+ .with({ class: 'TestClass', message: 'Executing migration with Runner backoff' })
+ expect(logger).to receive(:info).ordered
+ .with({ class: 'TestClass', message: 'Runner backoff key is set' })
+ expect(logger).to receive(:info).ordered
+ .with({ class: 'TestClass', message: 'Runner backoff key was removed' })
+
+ communicator.execute_with_lock
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migrations/runner_backoff/migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/runner_backoff/migration_helpers_spec.rb
new file mode 100644
index 00000000000..9eefc34a7cc
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/runner_backoff/migration_helpers_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::RunnerBackoff::MigrationHelpers, feature_category: :database do
+ let(:class_def) do
+ Class.new.prepend(described_class)
+ end
+
+ describe '.enable_runner_backoff!' do
+ it 'sets the flag' do
+ expect { class_def.enable_runner_backoff! }
+ .to change { class_def.enable_runner_backoff? }
+ .from(false).to(true)
+ end
+ end
+
+ describe '.enable_runner_backoff?' do
+ subject { class_def.enable_runner_backoff? }
+
+ it { is_expected.to be_falsy }
+
+ it 'returns true if the flag is set' do
+ class_def.enable_runner_backoff!
+
+ is_expected.to be_truthy
+ end
+ end
+
+ describe '#enable_runner_backoff?' do
+ subject { class_def.new.enable_runner_backoff? }
+
+ it { is_expected.to be_falsy }
+
+ it 'returns true if the flag is set' do
+ class_def.enable_runner_backoff!
+
+ is_expected.to be_truthy
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migrations/runner_spec.rb b/spec/lib/gitlab/database/migrations/runner_spec.rb
index 66eb5a5de51..7c71076e8f3 100644
--- a/spec/lib/gitlab/database/migrations/runner_spec.rb
+++ b/spec/lib/gitlab/database/migrations/runner_spec.rb
@@ -65,7 +65,7 @@ RSpec.describe Gitlab::Database::Migrations::Runner, :reestablished_active_recor
end
before do
- skip_if_multiple_databases_not_setup unless database == :main
+ skip_if_shared_database(database)
stub_const('Gitlab::Database::Migrations::Runner::BASE_RESULT_DIR', base_result_dir)
allow(ActiveRecord::Migrator).to receive(:new) do |dir, _all_migrations, _schema_migration_class, version_to_migrate|
diff --git a/spec/lib/gitlab/database/migrations/sidekiq_helpers_spec.rb b/spec/lib/gitlab/database/migrations/sidekiq_helpers_spec.rb
index fb1cb46171f..bf3a9e16548 100644
--- a/spec/lib/gitlab/database/migrations/sidekiq_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migrations/sidekiq_helpers_spec.rb
@@ -78,158 +78,174 @@ RSpec.describe Gitlab::Database::Migrations::SidekiqHelpers do
clear_queues
end
- context "when the constant is not defined" do
- it "doesn't try to delete it" do
- my_non_constant = +"SomeThingThatIsNotAConstant"
+ context 'when inside a transaction' do
+ it 'raises RuntimeError' do
+ expect(model).to receive(:transaction_open?).and_return(true)
- expect(Sidekiq::Queue).not_to receive(:new).with(any_args)
- model.sidekiq_remove_jobs(job_klasses: [my_non_constant])
+ expect { model.sidekiq_remove_jobs(job_klasses: [worker.name]) }
+ .to raise_error(RuntimeError)
end
end
- context "when the constant is defined" do
- it "will use it find job instances to delete" do
- my_constant = worker.name
- expect(Sidekiq::Queue)
- .to receive(:new)
- .with(worker.queue)
- .and_call_original
- model.sidekiq_remove_jobs(job_klasses: [my_constant])
+ context 'when outside a transaction' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(false)
+ allow(model).to receive(:disable_statement_timeout).and_call_original
end
- end
- it "removes all related job instances from the job classes' queues" do
- worker.perform_async
- worker_two.perform_async
- same_queue_different_worker.perform_async
- unrelated_worker.perform_async
-
- worker_queue = Sidekiq::Queue.new(worker.queue)
- worker_two_queue = Sidekiq::Queue.new(worker_two.queue)
- unrelated_queue = Sidekiq::Queue.new(unrelated_worker.queue)
-
- expect(worker_queue.size).to eq(2)
- expect(worker_two_queue.size).to eq(1)
- expect(unrelated_queue.size).to eq(1)
-
- model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name])
-
- expect(worker_queue.size).to eq(1)
- expect(worker_two_queue.size).to eq(0)
- expect(worker_queue.map(&:klass)).not_to include(worker.name)
- expect(worker_queue.map(&:klass)).to include(
- same_queue_different_worker.name
- )
- expect(worker_two_queue.map(&:klass)).not_to include(worker_two.name)
- expect(unrelated_queue.size).to eq(1)
- end
+ context "when the constant is not defined" do
+ it "doesn't try to delete it" do
+ my_non_constant = +"SomeThingThatIsNotAConstant"
- context "when job instances are in the scheduled set" do
- it "removes all related job instances from the scheduled set" do
- worker.perform_in(1.hour)
- worker_two.perform_in(1.hour)
- unrelated_worker.perform_in(1.hour)
+ expect(Sidekiq::Queue).not_to receive(:new).with(any_args)
+ model.sidekiq_remove_jobs(job_klasses: [my_non_constant])
+ end
+ end
- scheduled = Sidekiq::ScheduledSet.new
+ context "when the constant is defined" do
+ it "will use it find job instances to delete" do
+ my_constant = worker.name
+ expect(Sidekiq::Queue)
+ .to receive(:new)
+ .with(worker.queue)
+ .and_call_original
+ model.sidekiq_remove_jobs(job_klasses: [my_constant])
+ end
+ end
- expect(scheduled.size).to eq(3)
- expect(scheduled.map(&:klass)).to include(
- worker.name,
- worker_two.name,
- unrelated_worker.name
- )
+ it "removes all related job instances from the job classes' queues" do
+ worker.perform_async
+ worker_two.perform_async
+ same_queue_different_worker.perform_async
+ unrelated_worker.perform_async
+
+ worker_queue = Sidekiq::Queue.new(worker.queue)
+ worker_two_queue = Sidekiq::Queue.new(worker_two.queue)
+ unrelated_queue = Sidekiq::Queue.new(unrelated_worker.queue)
+
+ expect(worker_queue.size).to eq(2)
+ expect(worker_two_queue.size).to eq(1)
+ expect(unrelated_queue.size).to eq(1)
model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name])
- expect(scheduled.size).to eq(1)
- expect(scheduled.map(&:klass)).not_to include(worker.name)
- expect(scheduled.map(&:klass)).not_to include(worker_two.name)
- expect(scheduled.map(&:klass)).to include(unrelated_worker.name)
+ expect(worker_queue.size).to eq(1)
+ expect(worker_two_queue.size).to eq(0)
+ expect(worker_queue.map(&:klass)).not_to include(worker.name)
+ expect(worker_queue.map(&:klass)).to include(
+ same_queue_different_worker.name
+ )
+ expect(worker_two_queue.map(&:klass)).not_to include(worker_two.name)
+ expect(unrelated_queue.size).to eq(1)
end
- end
-
- context "when job instances are in the retry set" do
- include_context "when handling retried jobs"
- it "removes all related job instances from the retry set" do
- retry_in(worker, 1.hour)
- retry_in(worker, 2.hours)
- retry_in(worker, 3.hours)
- retry_in(worker_two, 4.hours)
- retry_in(unrelated_worker, 5.hours)
+ context "when job instances are in the scheduled set" do
+ it "removes all related job instances from the scheduled set" do
+ worker.perform_in(1.hour)
+ worker_two.perform_in(1.hour)
+ unrelated_worker.perform_in(1.hour)
- retries = Sidekiq::RetrySet.new
+ scheduled = Sidekiq::ScheduledSet.new
- expect(retries.size).to eq(5)
- expect(retries.map(&:klass)).to include(
- worker.name,
- worker_two.name,
- unrelated_worker.name
- )
+ expect(scheduled.size).to eq(3)
+ expect(scheduled.map(&:klass)).to include(
+ worker.name,
+ worker_two.name,
+ unrelated_worker.name
+ )
- model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name])
+ model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name])
- expect(retries.size).to eq(1)
- expect(retries.map(&:klass)).not_to include(worker.name)
- expect(retries.map(&:klass)).not_to include(worker_two.name)
- expect(retries.map(&:klass)).to include(unrelated_worker.name)
+ expect(scheduled.size).to eq(1)
+ expect(scheduled.map(&:klass)).not_to include(worker.name)
+ expect(scheduled.map(&:klass)).not_to include(worker_two.name)
+ expect(scheduled.map(&:klass)).to include(unrelated_worker.name)
+ end
end
- end
- # Imitate job deletion returning zero and then non zero.
- context "when job fails to be deleted" do
- let(:job_double) do
- instance_double(
- "Sidekiq::JobRecord",
- klass: worker.name
- )
- end
+ context "when job instances are in the retry set" do
+ include_context "when handling retried jobs"
- context "and does not work enough times in a row before max attempts" do
- it "tries the max attempts without succeeding" do
- worker.perform_async
+ it "removes all related job instances from the retry set" do
+ retry_in(worker, 1.hour)
+ retry_in(worker, 2.hours)
+ retry_in(worker, 3.hours)
+ retry_in(worker_two, 4.hours)
+ retry_in(unrelated_worker, 5.hours)
- allow(job_double).to receive(:delete).and_return(true)
+ retries = Sidekiq::RetrySet.new
- # Scheduled set runs last so only need to stub out its values.
- allow(Sidekiq::ScheduledSet)
- .to receive(:new)
- .and_return([job_double])
-
- expect(model.sidekiq_remove_jobs(job_klasses: [worker.name]))
- .to eq(
- {
- attempts: 5,
- success: false
- }
- )
+ expect(retries.size).to eq(5)
+ expect(retries.map(&:klass)).to include(
+ worker.name,
+ worker_two.name,
+ unrelated_worker.name
+ )
+
+ model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name])
+
+ expect(retries.size).to eq(1)
+ expect(retries.map(&:klass)).not_to include(worker.name)
+ expect(retries.map(&:klass)).not_to include(worker_two.name)
+ expect(retries.map(&:klass)).to include(unrelated_worker.name)
end
end
- context "and then it works enough times in a row before max attempts" do
- it "succeeds" do
- worker.perform_async
-
- # attempt 1: false will increment the streak once to 1
- # attempt 2: true resets it back to 0
- # attempt 3: false will increment the streak once to 1
- # attempt 4: false will increment the streak once to 2, loop breaks
- allow(job_double).to receive(:delete).and_return(false, true, false)
+ # Imitate job deletion returning zero and then non zero.
+ context "when job fails to be deleted" do
+ let(:job_double) do
+ instance_double(
+ "Sidekiq::JobRecord",
+ klass: worker.name
+ )
+ end
- worker.perform_async
+ context "and does not work enough times in a row before max attempts" do
+ it "tries the max attempts without succeeding" do
+ worker.perform_async
+
+ allow(job_double).to receive(:delete).and_return(true)
+
+ # Scheduled set runs last so only need to stub out its values.
+ allow(Sidekiq::ScheduledSet)
+ .to receive(:new)
+ .and_return([job_double])
+
+ expect(model.sidekiq_remove_jobs(job_klasses: [worker.name]))
+ .to eq(
+ {
+ attempts: 5,
+ success: false
+ }
+ )
+ end
+ end
- # Scheduled set runs last so only need to stub out its values.
- allow(Sidekiq::ScheduledSet)
- .to receive(:new)
- .and_return([job_double])
-
- expect(model.sidekiq_remove_jobs(job_klasses: [worker.name]))
- .to eq(
- {
- attempts: 4,
- success: true
- }
- )
+ context "and then it works enough times in a row before max attempts" do
+ it "succeeds" do
+ worker.perform_async
+
+ # attempt 1: false will increment the streak once to 1
+ # attempt 2: true resets it back to 0
+ # attempt 3: false will increment the streak once to 1
+ # attempt 4: false will increment the streak once to 2, loop breaks
+ allow(job_double).to receive(:delete).and_return(false, true, false)
+
+ worker.perform_async
+
+ # Scheduled set runs last so only need to stub out its values.
+ allow(Sidekiq::ScheduledSet)
+ .to receive(:new)
+ .and_return([job_double])
+
+ expect(model.sidekiq_remove_jobs(job_klasses: [worker.name]))
+ .to eq(
+ {
+ attempts: 4,
+ success: true
+ }
+ )
+ end
end
end
end
diff --git a/spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb b/spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb
index 57c5011590c..6bcefa455cf 100644
--- a/spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb
+++ b/spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb
@@ -48,6 +48,7 @@ RSpec.describe Gitlab::Database::Migrations::TestBatchedBackgroundRunner, :freez
let(:result_dir) { Pathname.new(Dir.mktmpdir) }
let(:connection) { base_model.connection }
let(:table_name) { "_test_column_copying" }
+ let(:num_rows_in_table) { 1000 }
let(:from_id) { 0 }
after do
@@ -61,7 +62,7 @@ RSpec.describe Gitlab::Database::Migrations::TestBatchedBackgroundRunner, :freez
data bigint default 0
);
- insert into #{table_name} (id) select i from generate_series(1, 1000) g(i);
+ insert into #{table_name} (id) select i from generate_series(1, #{num_rows_in_table}) g(i);
SQL
end
@@ -134,6 +135,24 @@ RSpec.describe Gitlab::Database::Migrations::TestBatchedBackgroundRunner, :freez
expect(calls).not_to be_empty
end
+ it 'samples 1 job with a batch size higher than the table size' do
+ calls = []
+ define_background_migration(migration_name) do |*args|
+ travel 1.minute
+ calls << args
+ end
+
+ queue_migration(migration_name, table_name, :id,
+ job_interval: 5.minutes,
+ batch_size: num_rows_in_table * 2,
+ sub_batch_size: num_rows_in_table * 2)
+
+ described_class.new(result_dir: result_dir, connection: connection,
+ from_id: from_id).run_jobs(for_duration: 3.minutes)
+
+ expect(calls.size).to eq(1)
+ end
+
context 'with multiple jobs to run' do
let(:last_id) do
Gitlab::Database::SharedModel.using_connection(connection) do
diff --git a/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb b/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb
index f94a40c93e1..e48937037fa 100644
--- a/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb
+++ b/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'cross-database foreign keys' do
end
def is_cross_db?(fk_record)
- Gitlab::Database::GitlabSchema.table_schemas([fk_record.from_table, fk_record.to_table]).many?
+ Gitlab::Database::GitlabSchema.table_schemas!([fk_record.from_table, fk_record.to_table]).many?
end
it 'onlies have allowed list of cross-database foreign keys', :aggregate_failures do
diff --git a/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb b/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb
index b39b273bba9..fa7645d581c 100644
--- a/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb
+++ b/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::ObsoleteIgnoredColumns do
+RSpec.describe Gitlab::Database::ObsoleteIgnoredColumns, feature_category: :database do
before do
stub_const('Testing', Module.new)
stub_const('Testing::MyBase', Class.new(ActiveRecord::Base))
@@ -16,11 +16,10 @@ RSpec.describe Gitlab::Database::ObsoleteIgnoredColumns do
Testing.module_eval do
Testing::MyBase.class_eval do
+ include IgnorableColumns
end
SomeAbstract.class_eval do
- include IgnorableColumns
-
self.abstract_class = true
self.table_name = 'projects'
@@ -29,8 +28,6 @@ RSpec.describe Gitlab::Database::ObsoleteIgnoredColumns do
end
Testing::B.class_eval do
- include IgnorableColumns
-
self.table_name = 'issues'
ignore_column :id, :other, remove_after: '2019-01-01', remove_with: '12.0'
diff --git a/spec/lib/gitlab/database/partitioning/ci_sliding_list_strategy_spec.rb b/spec/lib/gitlab/database/partitioning/ci_sliding_list_strategy_spec.rb
new file mode 100644
index 00000000000..f415e892818
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning/ci_sliding_list_strategy_spec.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Partitioning::CiSlidingListStrategy, feature_category: :database do
+ let(:connection) { ActiveRecord::Base.connection }
+ let(:table_name) { :_test_gitlab_ci_partitioned_test }
+ let(:model) { class_double(ApplicationRecord, table_name: table_name, connection: connection) }
+ let(:next_partition_if) { nil }
+ let(:detach_partition_if) { nil }
+
+ subject(:strategy) do
+ described_class.new(model, :partition,
+ next_partition_if: next_partition_if,
+ detach_partition_if: detach_partition_if)
+ end
+
+ before do
+ next if table_name.to_s.starts_with?('p_')
+
+ connection.execute(<<~SQL)
+ create table #{table_name}
+ (
+ id serial not null,
+ partition_id bigint not null,
+ created_at timestamptz not null,
+ primary key (id, partition_id)
+ )
+ partition by list(partition_id);
+
+ create table #{table_name}_100
+ partition of #{table_name} for values in (100);
+
+ create table #{table_name}_101
+ partition of #{table_name} for values in (101);
+ SQL
+ end
+
+ describe '#current_partitions' do
+ it 'detects both partitions' do
+ expect(strategy.current_partitions).to eq(
+ [
+ Gitlab::Database::Partitioning::SingleNumericListPartition.new(
+ table_name, 100, partition_name: "#{table_name}_100"
+ ),
+ Gitlab::Database::Partitioning::SingleNumericListPartition.new(
+ table_name, 101, partition_name: "#{table_name}_101"
+ )
+ ])
+ end
+ end
+
+ describe '#validate_and_fix' do
+ it 'does not call change_column_default' do
+ expect(strategy.model.connection).not_to receive(:change_column_default)
+
+ strategy.validate_and_fix
+ end
+ end
+
+ describe '#active_partition' do
+ it 'is the partition with the largest value' do
+ expect(strategy.active_partition.value).to eq(101)
+ end
+ end
+
+ describe '#missing_partitions' do
+ context 'when next_partition_if returns true' do
+ let(:next_partition_if) { proc { true } }
+
+ it 'is a partition definition for the next partition in the series' do
+ extra = strategy.missing_partitions
+
+ expect(extra.length).to eq(1)
+ expect(extra.first.value).to eq(102)
+ end
+ end
+
+ context 'when next_partition_if returns false' do
+ let(:next_partition_if) { proc { false } }
+
+ it 'is empty' do
+ expect(strategy.missing_partitions).to be_empty
+ end
+ end
+
+ context 'when there are no partitions for the table' do
+ it 'returns a partition for value 1' do
+ connection.execute("drop table #{table_name}_100; drop table #{table_name}_101;")
+
+ missing_partitions = strategy.missing_partitions
+
+ expect(missing_partitions.size).to eq(1)
+ missing_partition = missing_partitions.first
+
+ expect(missing_partition.value).to eq(100)
+ end
+ end
+ end
+
+ describe '#extra_partitions' do
+ context 'when all partitions are true for detach_partition_if' do
+ let(:detach_partition_if) { ->(_p) { true } }
+
+ it { expect(strategy.extra_partitions).to be_empty }
+ end
+
+ context 'when all partitions are false for detach_partition_if' do
+ let(:detach_partition_if) { proc { false } }
+
+ it { expect(strategy.extra_partitions).to be_empty }
+ end
+ end
+
+ describe '#initial_partition' do
+ it 'starts with the value 100', :aggregate_failures do
+ initial_partition = strategy.initial_partition
+ expect(initial_partition.value).to eq(100)
+ expect(initial_partition.table).to eq(strategy.table_name)
+ expect(initial_partition.partition_name).to eq("#{strategy.table_name}_100")
+ end
+
+ context 'with routing tables' do
+ let(:table_name) { :p_test_gitlab_ci_partitioned_test }
+
+ it 'removes the prefix', :aggregate_failures do
+ initial_partition = strategy.initial_partition
+
+ expect(initial_partition.value).to eq(100)
+ expect(initial_partition.table).to eq(strategy.table_name)
+ expect(initial_partition.partition_name).to eq('test_gitlab_ci_partitioned_test_100')
+ end
+ end
+ end
+
+ describe '#next_partition' do
+ before do
+ allow(strategy)
+ .to receive(:active_partition)
+ .and_return(instance_double(Gitlab::Database::Partitioning::SingleNumericListPartition, value: 105))
+ end
+
+ it 'is one after the active partition', :aggregate_failures do
+ next_partition = strategy.next_partition
+
+ expect(next_partition.value).to eq(106)
+ expect(next_partition.table).to eq(strategy.table_name)
+ expect(next_partition.partition_name).to eq("#{strategy.table_name}_106")
+ end
+
+ context 'with routing tables' do
+ let(:table_name) { :p_test_gitlab_ci_partitioned_test }
+
+ it 'removes the prefix', :aggregate_failures do
+ next_partition = strategy.next_partition
+
+ expect(next_partition.value).to eq(106)
+ expect(next_partition.table).to eq(strategy.table_name)
+ expect(next_partition.partition_name).to eq('test_gitlab_ci_partitioned_test_106')
+ end
+ end
+ end
+
+ describe '#ensure_partitioning_column_ignored_or_readonly!' do
+ it 'does not raise when the column is not ignored' do
+ expect do
+ Class.new(ApplicationRecord) do
+ include PartitionedTable
+
+ partitioned_by :partition_id,
+ strategy: :ci_sliding_list,
+ next_partition_if: proc { false },
+ detach_partition_if: proc { false }
+ end
+ end.not_to raise_error
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/partitioning/convert_table_to_first_list_partition_spec.rb b/spec/lib/gitlab/database/partitioning/convert_table_to_first_list_partition_spec.rb
deleted file mode 100644
index cd3a94f5737..00000000000
--- a/spec/lib/gitlab/database/partitioning/convert_table_to_first_list_partition_spec.rb
+++ /dev/null
@@ -1,273 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Database::Partitioning::ConvertTableToFirstListPartition do
- include Gitlab::Database::DynamicModelHelpers
- include Database::TableSchemaHelpers
-
- let(:migration_context) { Gitlab::Database::Migration[2.0].new }
-
- let(:connection) { migration_context.connection }
- let(:table_name) { '_test_table_to_partition' }
- let(:table_identifier) { "#{connection.current_schema}.#{table_name}" }
- let(:partitioning_column) { :partition_number }
- let(:partitioning_default) { 1 }
- let(:referenced_table_name) { '_test_referenced_table' }
- let(:other_referenced_table_name) { '_test_other_referenced_table' }
- let(:parent_table_name) { "#{table_name}_parent" }
- let(:lock_tables) { [] }
-
- let(:model) { define_batchable_model(table_name, connection: connection) }
-
- let(:parent_model) { define_batchable_model(parent_table_name, connection: connection) }
-
- let(:converter) do
- described_class.new(
- migration_context: migration_context,
- table_name: table_name,
- partitioning_column: partitioning_column,
- parent_table_name: parent_table_name,
- zero_partition_value: partitioning_default,
- lock_tables: lock_tables
- )
- end
-
- before do
- # Suppress printing migration progress
- allow(migration_context).to receive(:puts)
- allow(migration_context.connection).to receive(:transaction_open?).and_return(false)
-
- connection.execute(<<~SQL)
- create table #{referenced_table_name} (
- id bigserial primary key not null
- )
- SQL
-
- connection.execute(<<~SQL)
- create table #{other_referenced_table_name} (
- id bigserial primary key not null
- )
- SQL
-
- connection.execute(<<~SQL)
- insert into #{referenced_table_name} default values;
- insert into #{other_referenced_table_name} default values;
- SQL
-
- connection.execute(<<~SQL)
- create table #{table_name} (
- id bigserial not null,
- #{partitioning_column} bigint not null default #{partitioning_default},
- referenced_id bigint not null references #{referenced_table_name} (id) on delete cascade,
- other_referenced_id bigint not null references #{other_referenced_table_name} (id) on delete set null,
- primary key (id, #{partitioning_column})
- )
- SQL
-
- connection.execute(<<~SQL)
- insert into #{table_name} (referenced_id, other_referenced_id)
- select #{referenced_table_name}.id, #{other_referenced_table_name}.id
- from #{referenced_table_name}, #{other_referenced_table_name};
- SQL
- end
-
- describe "#prepare_for_partitioning" do
- subject(:prepare) { converter.prepare_for_partitioning }
-
- it 'adds a check constraint' do
- expect { prepare }.to change {
- Gitlab::Database::PostgresConstraint
- .check_constraints
- .by_table_identifier(table_identifier)
- .count
- }.from(0).to(1)
- end
- end
-
- describe '#revert_prepare_for_partitioning' do
- before do
- converter.prepare_for_partitioning
- end
-
- subject(:revert_prepare) { converter.revert_preparation_for_partitioning }
-
- it 'removes a check constraint' do
- expect { revert_prepare }.to change {
- Gitlab::Database::PostgresConstraint
- .check_constraints
- .by_table_identifier("#{connection.current_schema}.#{table_name}")
- .count
- }.from(1).to(0)
- end
- end
-
- describe "#convert_to_zero_partition" do
- subject(:partition) { converter.partition }
-
- before do
- converter.prepare_for_partitioning
- end
-
- context 'when the primary key is incorrect' do
- before do
- connection.execute(<<~SQL)
- alter table #{table_name} drop constraint #{table_name}_pkey;
- alter table #{table_name} add constraint #{table_name}_pkey PRIMARY KEY (id);
- SQL
- end
-
- it 'throws a reasonable error message' do
- expect { partition }.to raise_error(described_class::UnableToPartition, /#{partitioning_column}/)
- end
- end
-
- context 'when there is not a supporting check constraint' do
- before do
- connection.execute(<<~SQL)
- alter table #{table_name} drop constraint partitioning_constraint;
- SQL
- end
-
- it 'throws a reasonable error message' do
- expect { partition }.to raise_error(described_class::UnableToPartition, /constraint /)
- end
- end
-
- it 'migrates the table to a partitioned table' do
- fks_before = migration_context.foreign_keys(table_name)
-
- partition
-
- expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(1)
- expect(migration_context.foreign_keys(parent_table_name).map(&:options)).to match_array(fks_before.map(&:options))
-
- connection.execute(<<~SQL)
- insert into #{table_name} (referenced_id, other_referenced_id) select #{referenced_table_name}.id, #{other_referenced_table_name}.id from #{referenced_table_name}, #{other_referenced_table_name};
- SQL
-
- # Create a second partition
- connection.execute(<<~SQL)
- create table #{table_name}2 partition of #{parent_table_name} FOR VALUES IN (2)
- SQL
-
- parent_model.create!(partitioning_column => 2, :referenced_id => 1, :other_referenced_id => 1)
- expect(parent_model.pluck(:id)).to match_array([1, 2, 3])
- end
-
- context 'when the existing table is owned by a different user' do
- before do
- connection.execute(<<~SQL)
- CREATE USER other_user SUPERUSER;
- ALTER TABLE #{table_name} OWNER TO other_user;
- SQL
- end
-
- let(:current_user) { model.connection.select_value('select current_user') }
-
- it 'partitions without error' do
- expect { partition }.not_to raise_error
- end
- end
-
- context 'with locking tables' do
- let(:lock_tables) { [table_name] }
-
- it 'locks the table' do
- recorder = ActiveRecord::QueryRecorder.new { partition }
-
- expect(recorder.log).to include(/LOCK "_test_table_to_partition" IN ACCESS EXCLUSIVE MODE/)
- end
- end
-
- context 'when an error occurs during the conversion' do
- def fail_first_time
- # We can't directly use a boolean here, as we need something that will be passed by-reference to the proc
- fault_status = { faulted: false }
- proc do |m, *args, **kwargs|
- next m.call(*args, **kwargs) if fault_status[:faulted]
-
- fault_status[:faulted] = true
- raise 'fault!'
- end
- end
-
- def fail_sql_matching(regex)
- proc do
- allow(migration_context.connection).to receive(:execute).and_call_original
- allow(migration_context.connection).to receive(:execute).with(regex).and_wrap_original(&fail_first_time)
- end
- end
-
- def fail_adding_fk(from_table, to_table)
- proc do
- allow(migration_context.connection).to receive(:add_foreign_key).and_call_original
- expect(migration_context.connection).to receive(:add_foreign_key).with(from_table, to_table, any_args)
- .and_wrap_original(&fail_first_time)
- end
- end
-
- where(:case_name, :fault) do
- [
- ["creating parent table", lazy { fail_sql_matching(/CREATE/i) }],
- ["adding the first foreign key", lazy { fail_adding_fk(parent_table_name, referenced_table_name) }],
- ["adding the second foreign key", lazy { fail_adding_fk(parent_table_name, other_referenced_table_name) }],
- ["attaching table", lazy { fail_sql_matching(/ATTACH/i) }]
- ]
- end
-
- before do
- # Set up the fault that we'd like to inject
- fault.call
- end
-
- with_them do
- it 'recovers from a fault', :aggregate_failures do
- expect { converter.partition }.to raise_error(/fault/)
- expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(0)
-
- expect { converter.partition }.not_to raise_error
- expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(1)
- end
- end
- end
- end
-
- describe '#revert_conversion_to_zero_partition' do
- before do
- converter.prepare_for_partitioning
- converter.partition
- end
-
- subject(:revert_conversion) { converter.revert_partitioning }
-
- it 'detaches the partition' do
- expect { revert_conversion }.to change {
- Gitlab::Database::PostgresPartition
- .for_parent_table(parent_table_name).count
- }.from(1).to(0)
- end
-
- it 'does not drop the child partition' do
- expect { revert_conversion }.not_to change { table_oid(table_name) }
- end
-
- it 'removes the parent table' do
- expect { revert_conversion }.to change { table_oid(parent_table_name).present? }.from(true).to(false)
- end
-
- it 're-adds the check constraint' do
- expect { revert_conversion }.to change {
- Gitlab::Database::PostgresConstraint
- .check_constraints
- .by_table_identifier(table_identifier)
- .count
- }.by(1)
- end
-
- it 'moves sequences back to the original table' do
- expect { revert_conversion }.to change { converter.send(:sequences_owned_by, table_name).count }.from(0)
- .and change { converter.send(:sequences_owned_by, parent_table_name).count }.to(0)
- end
- end
-end
diff --git a/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb b/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb
index 646ae50fb44..04940028aee 100644
--- a/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb
+++ b/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb
@@ -25,23 +25,23 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do
before do
connection.execute(<<~SQL)
- CREATE TABLE referenced_table (
+ CREATE TABLE _test_referenced_table (
id bigserial primary key not null
)
SQL
connection.execute(<<~SQL)
- CREATE TABLE parent_table (
+ CREATE TABLE _test_parent_table (
id bigserial not null,
referenced_id bigint not null,
created_at timestamptz not null,
primary key (id, created_at),
- constraint fk_referenced foreign key (referenced_id) references referenced_table(id)
+ constraint fk_referenced foreign key (referenced_id) references _test_referenced_table(id)
) PARTITION BY RANGE(created_at)
SQL
end
- def create_partition(name:, from:, to:, attached:, drop_after:, table: 'parent_table')
+ def create_partition(name:, from:, to:, attached:, drop_after:, table: :_test_parent_table)
from = from.beginning_of_month
to = to.beginning_of_month
full_name = "#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.#{name}"
@@ -64,20 +64,20 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do
describe '#perform' do
context 'when the partition should not be dropped yet' do
it 'does not drop the partition' do
- create_partition(name: 'test_partition',
+ create_partition(name: :_test_partition,
from: 2.months.ago, to: 1.month.ago,
attached: false,
drop_after: 1.day.from_now)
dropper.perform
- expect_partition_present('test_partition')
+ expect_partition_present(:_test_partition)
end
end
context 'with a partition to drop' do
before do
- create_partition(name: 'test_partition',
+ create_partition(name: :_test_partition,
from: 2.months.ago,
to: 1.month.ago.beginning_of_month,
attached: false,
@@ -87,45 +87,45 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do
it 'drops the partition' do
dropper.perform
- expect(table_oid('test_partition')).to be_nil
+ expect(table_oid(:_test_partition)).to be_nil
end
context 'removing foreign keys' do
it 'removes foreign keys from the table before dropping it' do
expect(dropper).to receive(:drop_detached_partition).and_wrap_original do |drop_method, partition|
- expect(partition.table_name).to eq('test_partition')
+ expect(partition.table_name).to eq('_test_partition')
expect(foreign_key_exists_by_name(partition.table_name, 'fk_referenced', schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)).to be_falsey
drop_method.call(partition)
end
- expect(foreign_key_exists_by_name('test_partition', 'fk_referenced', schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)).to be_truthy
+ expect(foreign_key_exists_by_name(:_test_partition, 'fk_referenced', schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)).to be_truthy
dropper.perform
end
it 'does not remove foreign keys from the parent table' do
- expect { dropper.perform }.not_to change { foreign_key_exists_by_name('parent_table', 'fk_referenced') }.from(true)
+ expect { dropper.perform }.not_to change { foreign_key_exists_by_name('_test_parent_table', 'fk_referenced') }.from(true)
end
context 'when another process drops the foreign key' do
it 'skips dropping that foreign key' do
expect(dropper).to receive(:drop_foreign_key_if_present).and_wrap_original do |drop_meth, *args|
- connection.execute('alter table gitlab_partitions_dynamic.test_partition drop constraint fk_referenced;')
+ connection.execute('alter table gitlab_partitions_dynamic._test_partition drop constraint fk_referenced;')
drop_meth.call(*args)
end
dropper.perform
- expect_partition_removed('test_partition')
+ expect_partition_removed(:_test_partition)
end
end
context 'when another process drops the partition' do
it 'skips dropping the foreign key' do
expect(dropper).to receive(:drop_foreign_key_if_present).and_wrap_original do |drop_meth, *args|
- connection.execute('drop table gitlab_partitions_dynamic.test_partition')
- Postgresql::DetachedPartition.where(table_name: 'test_partition').delete_all
+ connection.execute('drop table gitlab_partitions_dynamic._test_partition')
+ Postgresql::DetachedPartition.where(table_name: :_test_partition).delete_all
end
expect(Gitlab::AppLogger).not_to receive(:error)
@@ -159,7 +159,7 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do
context 'when the partition to drop is still attached to its table' do
before do
- create_partition(name: 'test_partition',
+ create_partition(name: :_test_partition,
from: 2.months.ago,
to: 1.month.ago.beginning_of_month,
attached: true,
@@ -169,8 +169,8 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do
it 'does not drop the partition, but does remove the DetachedPartition entry' do
dropper.perform
aggregate_failures do
- expect(table_oid('test_partition')).not_to be_nil
- expect(Postgresql::DetachedPartition.find_by(table_name: 'test_partition')).to be_nil
+ expect(table_oid(:_test_partition)).not_to be_nil
+ expect(Postgresql::DetachedPartition.find_by(table_name: :_test_partition)).to be_nil
end
end
@@ -185,20 +185,20 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do
dropper.perform
- expect(table_oid('test_partition')).not_to be_nil
+ expect(table_oid(:_test_partition)).not_to be_nil
end
end
end
context 'with multiple partitions to drop' do
before do
- create_partition(name: 'partition_1',
+ create_partition(name: :_test_partition_1,
from: 3.months.ago,
to: 2.months.ago,
attached: false,
drop_after: 1.second.ago)
- create_partition(name: 'partition_2',
+ create_partition(name: :_test_partition_2,
from: 2.months.ago,
to: 1.month.ago,
attached: false,
@@ -208,8 +208,8 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do
it 'drops both partitions' do
dropper.perform
- expect_partition_removed('partition_1')
- expect_partition_removed('partition_2')
+ expect_partition_removed(:_test_partition_1)
+ expect_partition_removed(:_test_partition_2)
end
context 'when the first drop returns an error' do
@@ -223,7 +223,7 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do
expect(Postgresql::DetachedPartition.count).to eq(1)
errored_partition_name = Postgresql::DetachedPartition.first!.table_name
- dropped_partition_name = (%w[partition_1 partition_2] - [errored_partition_name]).first
+ dropped_partition_name = (%w[_test_partition_1 _test_partition_2] - [errored_partition_name]).first
expect_partition_present(errored_partition_name)
expect_partition_removed(dropped_partition_name)
end
diff --git a/spec/lib/gitlab/database/partitioning/list/convert_table_spec.rb b/spec/lib/gitlab/database/partitioning/list/convert_table_spec.rb
new file mode 100644
index 00000000000..8e2a53ea76f
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning/list/convert_table_spec.rb
@@ -0,0 +1,365 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Partitioning::List::ConvertTable, feature_category: :database do
+ include Gitlab::Database::DynamicModelHelpers
+ include Database::TableSchemaHelpers
+ include Database::InjectFailureHelpers
+
+ include_context 'with a table structure for converting a table to a list partition'
+
+ let(:converter) do
+ described_class.new(
+ migration_context: migration_context,
+ table_name: table_name,
+ partitioning_column: partitioning_column,
+ parent_table_name: parent_table_name,
+ zero_partition_value: partitioning_default,
+ lock_tables: lock_tables
+ )
+ end
+
+ describe "#prepare_for_partitioning" do
+ subject(:prepare) { converter.prepare_for_partitioning(async: async) }
+
+ let(:async) { false }
+
+ it 'adds a check constraint' do
+ expect { prepare }.to change {
+ Gitlab::Database::PostgresConstraint
+ .check_constraints
+ .by_table_identifier(table_identifier)
+ .count
+ }.from(0).to(1)
+ end
+
+ context 'when it fails to add constraint' do
+ before do
+ allow(migration_context).to receive(:add_check_constraint)
+ end
+
+ it 'raises UnableToPartition error' do
+ expect { prepare }
+ .to raise_error(described_class::UnableToPartition)
+ .and change {
+ Gitlab::Database::PostgresConstraint
+ .check_constraints
+ .by_table_identifier(table_identifier)
+ .count
+ }.by(0)
+ end
+ end
+
+ context 'when async' do
+ let(:async) { true }
+
+ it 'adds a NOT VALID check constraint' do
+ expect { prepare }.to change {
+ Gitlab::Database::PostgresConstraint
+ .check_constraints
+ .by_table_identifier(table_identifier)
+ .count
+ }.from(0).to(1)
+
+ constraint =
+ Gitlab::Database::PostgresConstraint
+ .check_constraints
+ .by_table_identifier(table_identifier)
+ .last
+
+ expect(constraint.definition).to end_with('NOT VALID')
+ end
+
+ it 'adds a PostgresAsyncConstraintValidation record' do
+ expect { prepare }.to change {
+ Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation.count
+ }.by(1)
+
+ record = Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation
+ .where(table_name: table_name).last
+
+ expect(record.name).to eq described_class::PARTITIONING_CONSTRAINT_NAME
+ expect(record).to be_check_constraint
+ end
+
+ context 'when constraint exists but is not valid' do
+ before do
+ converter.prepare_for_partitioning(async: true)
+ end
+
+ it 'validates the check constraint' do
+ expect { prepare }.to change {
+ Gitlab::Database::PostgresConstraint
+ .check_constraints
+ .by_table_identifier(table_identifier).first.constraint_valid?
+ }.from(false).to(true)
+ end
+
+ context 'when it fails to validate constraint' do
+ before do
+ allow(migration_context).to receive(:validate_check_constraint)
+ end
+
+ it 'raises UnableToPartition error' do
+ expect { prepare }
+ .to raise_error(described_class::UnableToPartition,
+ starting_with('Error validating partitioning constraint'))
+ .and change {
+ Gitlab::Database::PostgresConstraint
+ .check_constraints
+ .by_table_identifier(table_identifier)
+ .count
+ }.by(0)
+ end
+ end
+ end
+
+ context 'when constraint exists and is valid' do
+ before do
+ converter.prepare_for_partitioning(async: false)
+ end
+
+ it 'raises UnableToPartition error' do
+ expect(Gitlab::AppLogger).to receive(:info).with(starting_with('Nothing to do'))
+ prepare
+ end
+ end
+ end
+ end
+
+ describe '#revert_preparation_for_partitioning' do
+ before do
+ converter.prepare_for_partitioning
+ end
+
+ subject(:revert_prepare) { converter.revert_preparation_for_partitioning }
+
+ it 'removes a check constraint' do
+ expect { revert_prepare }.to change {
+ Gitlab::Database::PostgresConstraint
+ .check_constraints
+ .by_table_identifier("#{connection.current_schema}.#{table_name}")
+ .count
+ }.from(1).to(0)
+ end
+ end
+
+ describe "#partition" do
+ subject(:partition) { converter.partition }
+
+ let(:async) { false }
+
+ before do
+ converter.prepare_for_partitioning(async: async)
+ end
+
+ context 'when the primary key is incorrect' do
+ before do
+ connection.execute(<<~SQL)
+ alter table #{referencing_table_name} drop constraint fk_referencing; -- this depends on the primary key
+ alter table #{other_referencing_table_name} drop constraint fk_referencing_other; -- this does too
+ alter table #{table_name} drop constraint #{table_name}_pkey;
+ alter table #{table_name} add constraint #{table_name}_pkey PRIMARY KEY (id);
+ SQL
+ end
+
+ it 'throws a reasonable error message' do
+ expect { partition }.to raise_error(described_class::UnableToPartition, /#{partitioning_column}/)
+ end
+ end
+
+ context 'when there is not a supporting check constraint' do
+ before do
+ connection.execute(<<~SQL)
+ alter table #{table_name} drop constraint partitioning_constraint;
+ SQL
+ end
+
+ it 'throws a reasonable error message' do
+ expect { partition }.to raise_error(described_class::UnableToPartition, /is not ready for partitioning./)
+ end
+ end
+
+ context 'when supporting check constraint is not valid' do
+ let(:async) { true }
+
+ it 'throws a reasonable error message' do
+ expect { partition }.to raise_error(described_class::UnableToPartition, /is not ready for partitioning./)
+ end
+ end
+
+ it 'migrates the table to a partitioned table' do
+ fks_before = migration_context.foreign_keys(table_name)
+
+ partition
+
+ expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(1)
+ expect(migration_context.foreign_keys(parent_table_name).map(&:options)).to match_array(fks_before.map(&:options))
+
+ connection.execute(<<~SQL)
+ insert into #{table_name} (referenced_id, other_referenced_id) select #{referenced_table_name}.id, #{other_referenced_table_name}.id from #{referenced_table_name}, #{other_referenced_table_name};
+ SQL
+
+ # Create a second partition
+ connection.execute(<<~SQL)
+ create table #{table_name}2 partition of #{parent_table_name} FOR VALUES IN (2)
+ SQL
+
+ parent_model.create!(partitioning_column => 2, :referenced_id => 1, :other_referenced_id => 1)
+ expect(parent_model.pluck(:id)).to match_array([1, 2, 3])
+
+ expect { referencing_model.create!(partitioning_column => 1, :ref_id => 1) }.not_to raise_error
+ end
+
+ context 'when the existing table is owned by a different user' do
+ before do
+ connection.execute(<<~SQL)
+ CREATE USER other_user SUPERUSER;
+ ALTER TABLE #{table_name} OWNER TO other_user;
+ SQL
+ end
+
+ let(:current_user) { model.connection.select_value('select current_user') }
+
+ it 'partitions without error' do
+ expect { partition }.not_to raise_error
+ end
+ end
+
+ context 'with locking tables' do
+ let(:lock_tables) { [table_name] }
+
+ it 'locks the table' do
+ recorder = ActiveRecord::QueryRecorder.new { partition }
+
+ expect(recorder.log).to include(/LOCK "_test_table_to_partition" IN ACCESS EXCLUSIVE MODE/)
+ end
+ end
+
+ context 'when an error occurs during the conversion' do
+ before do
+ # Set up the fault that we'd like to inject
+ fault.call
+ end
+
+ let(:old_fks) do
+ Gitlab::Database::PostgresForeignKey.by_referenced_table_identifier(table_identifier).not_inherited
+ end
+
+ let(:new_fks) do
+ Gitlab::Database::PostgresForeignKey.by_referenced_table_identifier(parent_table_identifier).not_inherited
+ end
+
+ context 'when partitioning fails the first time' do
+ where(:case_name, :fault) do
+ [
+ ["creating parent table", lazy { fail_sql_matching(/CREATE/i) }],
+ ["adding the first foreign key", lazy { fail_adding_fk(parent_table_name, referenced_table_name) }],
+ ["adding the second foreign key", lazy { fail_adding_fk(parent_table_name, other_referenced_table_name) }],
+ ["attaching table", lazy { fail_sql_matching(/ATTACH/i) }]
+ ]
+ end
+
+ with_them do
+ it 'recovers from a fault', :aggregate_failures do
+ expect { converter.partition }.to raise_error(/fault/)
+ expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(0)
+
+ expect { converter.partition }.not_to raise_error
+ expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(1)
+ end
+ end
+ end
+ end
+
+ context 'when table has LFK triggers' do
+ before do
+ migration_context.track_record_deletions(table_name)
+ end
+
+ it 'moves the trigger on the parent table', :aggregate_failures do
+ expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy
+
+ expect { partition }.not_to raise_error
+
+ expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy
+ expect(migration_context.has_loose_foreign_key?(parent_table_name)).to be_truthy
+ end
+
+ context 'with locking tables' do
+ let(:lock_tables) { [table_name] }
+
+ it 'locks the table before dropping the triggers' do
+ recorder = ActiveRecord::QueryRecorder.new { partition }
+
+ lock_index = recorder.log.find_index do |log|
+ log.start_with?('LOCK "_test_table_to_partition" IN ACCESS EXCLUSIVE MODE')
+ end
+
+ trigger_index = recorder.log.find_index do |log|
+ log.start_with?('DROP TRIGGER IF EXISTS _test_table_to_partition_loose_fk_trigger')
+ end
+
+ expect(lock_index).to be_present
+ expect(trigger_index).to be_present
+ expect(lock_index).to be < trigger_index
+ end
+ end
+ end
+ end
+
+ describe '#revert_partitioning' do
+ before do
+ converter.prepare_for_partitioning
+ converter.partition
+ end
+
+ subject(:revert_conversion) { converter.revert_partitioning }
+
+ it 'detaches the partition' do
+ expect { revert_conversion }.to change {
+ Gitlab::Database::PostgresPartition
+ .for_parent_table(parent_table_name).count
+ }.from(1).to(0)
+ end
+
+ it 'does not drop the child partition' do
+ expect { revert_conversion }.not_to change { table_oid(table_name) }
+ end
+
+ it 'removes the parent table' do
+ expect { revert_conversion }.to change { table_oid(parent_table_name).present? }.from(true).to(false)
+ end
+
+ it 're-adds the check constraint' do
+ expect { revert_conversion }.to change {
+ Gitlab::Database::PostgresConstraint
+ .check_constraints
+ .by_table_identifier(table_identifier)
+ .count
+ }.by(1)
+ end
+
+ it 'moves sequences back to the original table' do
+ expect { revert_conversion }.to change { converter.send(:sequences_owned_by, table_name).count }.from(0)
+ .and change { converter.send(:sequences_owned_by, parent_table_name).count }.to(0)
+ end
+
+ context 'when table has LFK triggers' do
+ before do
+ migration_context.track_record_deletions(parent_table_name)
+ migration_context.track_record_deletions(table_name)
+ end
+
+ it 'restores the trigger on the partition', :aggregate_failures do
+ expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy
+ expect(migration_context.has_loose_foreign_key?(parent_table_name)).to be_truthy
+
+ expect { revert_conversion }.not_to raise_error
+
+ expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/partitioning/list/locking_configuration_spec.rb b/spec/lib/gitlab/database/partitioning/list/locking_configuration_spec.rb
new file mode 100644
index 00000000000..851add43e3c
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning/list/locking_configuration_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Partitioning::List::LockingConfiguration, feature_category: :database do
+ let(:migration_context) do
+ Gitlab::Database::Migration[2.1].new.tap do |migration|
+ migration.extend Gitlab::Database::PartitioningMigrationHelpers::TableManagementHelpers
+ migration.extend Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
+ end
+ end
+
+ let(:locking_order) { %w[table_1 table_2 table_3] }
+
+ subject(:locking_configuration) { described_class.new(migration_context, table_locking_order: locking_order) }
+
+ describe '#locking_statement_for' do
+ it 'only includes locking information for tables in the locking specification' do
+ expect(subject.locking_statement_for(%w[table_1 table_other])).to eq(subject.locking_statement_for('table_1'))
+ end
+
+ it 'is nil when none of the tables match the lock configuration' do
+ expect(subject.locking_statement_for('table_other')).to be_nil
+ end
+
+ it 'is a lock tables statement' do
+ expect(subject.locking_statement_for(%w[table_3 table_2])).to eq(<<~SQL)
+ LOCK "table_2", "table_3" IN ACCESS EXCLUSIVE MODE
+ SQL
+ end
+
+ it 'raises if a table name with schema is passed' do
+ expect { subject.locking_statement_for('public.test') }.to raise_error(ArgumentError)
+ end
+ end
+
+ describe '#lock_ordering_for' do
+ it 'is the intersection with the locking specification, in the order of the specification' do
+ expect(subject.locking_order_for(%w[table_other table_3 table_1])).to eq(%w[table_1 table_3])
+ end
+
+ it 'raises if a table name with schema is passed' do
+ expect { subject.locking_order_for('public.test') }.to raise_error(ArgumentError)
+ end
+ 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 2212cb09888..eac4a162879 100644
--- a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb
+++ b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
include Database::PartitioningHelpers
include ExclusiveLeaseHelpers
- let(:partitioned_table_name) { "_test_gitlab_main_my_model_example_table" }
+ let(:partitioned_table_name) { :_test_gitlab_main_my_model_example_table }
context 'creating partitions (mocked)' do
subject(:sync_partitions) { described_class.new(model).sync_partitions }
@@ -45,7 +45,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
sync_partitions
end
- context 'with eplicitly provided connection' do
+ context 'with explicitly provided connection' do
let(:connection) { Ci::ApplicationRecord.connection }
it 'uses the explicitly provided connection when any' do
@@ -59,6 +59,14 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
end
end
+ context 'when an ArgumentError occurs during partition management' do
+ it 'raises error' do
+ expect(partitioning_strategy).to receive(:missing_partitions).and_raise(ArgumentError)
+
+ expect { sync_partitions }.to raise_error(ArgumentError)
+ end
+ end
+
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)')
@@ -115,7 +123,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
let(:manager) { described_class.new(model) }
let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table, connection: connection) }
let(:connection) { ActiveRecord::Base.connection }
- let(:table) { "foo" }
+ let(:table) { :_test_foo }
let(:partitioning_strategy) do
double(extra_partitions: extra_partitions, missing_partitions: [], after_adding_partitions: nil)
end
@@ -144,7 +152,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
end
it 'logs an error if the partitions are not detachable' do
- allow(Gitlab::Database::PostgresForeignKey).to receive(:by_referenced_table_identifier).with("public.foo")
+ allow(Gitlab::Database::PostgresForeignKey).to receive(:by_referenced_table_identifier).with("public._test_foo")
.and_return([double(name: "fk_1", constrained_table_identifier: "public.constrainted_table_1")])
expect(Gitlab::AppLogger).to receive(:error).with(
@@ -154,7 +162,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
exception_class: Gitlab::Database::Partitioning::PartitionManager::UnsafeToDetachPartitionError,
exception_message:
"Cannot detach foo1, it would block while checking foreign key fk_1 on public.constrainted_table_1",
- table_name: "foo"
+ table_name: :_test_foo
}
)
@@ -230,23 +238,20 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
expect(pending_drop.drop_after).to eq(Time.current + described_class::RETAIN_DETACHED_PARTITIONS_FOR)
end
- # Postgres 11 does not support foreign keys to partitioned tables
- if ApplicationRecord.database.version.to_f >= 12
- context 'when the model is the target of a foreign key' do
- before do
- connection.execute(<<~SQL)
+ context 'when the model is the target of a foreign key' do
+ before do
+ connection.execute(<<~SQL)
create unique index idx_for_fk ON #{partitioned_table_name}(created_at);
create table _test_gitlab_main_referencing_table (
id bigserial primary key not null,
referencing_created_at timestamptz references #{partitioned_table_name}(created_at)
);
- SQL
- end
+ SQL
+ end
- it 'does not detach partitions with a referenced foreign key' do
- expect { subject }.not_to change { find_partitions(my_model.table_name).size }
- end
+ it 'does not detach partitions with a referenced foreign key' do
+ expect { subject }.not_to change { find_partitions(my_model.table_name).size }
end
end
end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb
index 1885e84ac4c..fc279051800 100644
--- a/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb
@@ -54,6 +54,11 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition
allow(backfill_job).to receive(:sleep)
end
+ after do
+ connection.drop_table source_table
+ connection.drop_table destination_table
+ end
+
let(:source_model) { Class.new(ActiveRecord::Base) }
let(:destination_model) { Class.new(ActiveRecord::Base) }
let(:timestamp) { Time.utc(2020, 1, 2).round }
@@ -82,7 +87,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition
end
it 'breaks the assigned batch into smaller batches' do
- expect_next_instance_of(described_class::BulkCopy) do |bulk_copy|
+ expect_next_instance_of(Gitlab::Database::PartitioningMigrationHelpers::BulkCopy) do |bulk_copy|
expect(bulk_copy).to receive(:copy_between).with(source1.id, source2.id)
expect(bulk_copy).to receive(:copy_between).with(source3.id, source3.id)
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 f0e34476cf2..d5f4afd7ba4 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers do
+RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers, feature_category: :database do
include Database::TableSchemaHelpers
let(:migration) do
@@ -16,15 +16,23 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
let(:partition_schema) { 'gitlab_partitions_dynamic' }
let(:partition1_name) { "#{partition_schema}.#{source_table_name}_202001" }
let(:partition2_name) { "#{partition_schema}.#{source_table_name}_202002" }
+ let(:validate) { true }
let(:options) do
{
column: column_name,
name: foreign_key_name,
on_delete: :cascade,
- validate: true
+ on_update: nil,
+ primary_key: :id
}
end
+ let(:create_options) do
+ options
+ .except(:primary_key)
+ .merge!(reverse_lock_order: false, target_column: :id, validate: validate)
+ end
+
before do
allow(migration).to receive(:puts)
allow(migration).to receive(:transaction_open?).and_return(false)
@@ -67,12 +75,11 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
expect(migration).to receive(:concurrent_partitioned_foreign_key_name).and_return(foreign_key_name)
- expect_add_concurrent_fk_and_call_original(partition1_name, target_table_name, **options)
- expect_add_concurrent_fk_and_call_original(partition2_name, target_table_name, **options)
+ expect_add_concurrent_fk_and_call_original(partition1_name, target_table_name, **create_options)
+ expect_add_concurrent_fk_and_call_original(partition2_name, target_table_name, **create_options)
- expect(migration).to receive(:with_lock_retries).ordered.and_yield
- expect(migration).to receive(:add_foreign_key)
- .with(source_table_name, target_table_name, **options)
+ expect(migration).to receive(:add_concurrent_foreign_key)
+ .with(source_table_name, target_table_name, allow_partitioned: true, **create_options)
.ordered
.and_call_original
@@ -81,6 +88,39 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
expect_foreign_key_to_exist(source_table_name, foreign_key_name)
end
+ context 'with validate: false option' do
+ let(:validate) { false }
+ let(:options) do
+ {
+ column: column_name,
+ name: foreign_key_name,
+ on_delete: :cascade,
+ on_update: nil,
+ primary_key: :id
+ }
+ end
+
+ it 'creates the foreign key only on partitions' do
+ expect(migration).to receive(:foreign_key_exists?)
+ .with(source_table_name, target_table_name, **options)
+ .and_return(false)
+
+ expect(migration).to receive(:concurrent_partitioned_foreign_key_name).and_return(foreign_key_name)
+
+ expect_add_concurrent_fk_and_call_original(partition1_name, target_table_name, **create_options)
+ expect_add_concurrent_fk_and_call_original(partition2_name, target_table_name, **create_options)
+
+ expect(migration).not_to receive(:add_concurrent_foreign_key)
+ .with(source_table_name, target_table_name, **create_options)
+
+ migration.add_concurrent_partitioned_foreign_key(
+ source_table_name, target_table_name,
+ column: column_name, validate: false)
+
+ expect_foreign_key_not_to_exist(source_table_name, foreign_key_name)
+ end
+ end
+
def expect_add_concurrent_fk_and_call_original(source_table_name, target_table_name, options)
expect(migration).to receive(:add_concurrent_foreign_key)
.ordered
@@ -100,8 +140,6 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
.and_return(true)
expect(migration).not_to receive(:add_concurrent_foreign_key)
- expect(migration).not_to receive(:with_lock_retries)
- expect(migration).not_to receive(:add_foreign_key)
migration.add_concurrent_partitioned_foreign_key(source_table_name, target_table_name, column: column_name)
@@ -110,30 +148,43 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
end
context 'when additional foreign key options are given' do
- let(:options) do
+ let(:exits_options) do
{
column: column_name,
name: '_my_fk_name',
on_delete: :restrict,
- validate: true
+ on_update: nil,
+ primary_key: :id
}
end
+ let(:create_options) do
+ exits_options
+ .except(:primary_key)
+ .merge!(reverse_lock_order: false, target_column: :id, validate: true)
+ end
+
it 'forwards them to the foreign key helper methods' do
expect(migration).to receive(:foreign_key_exists?)
- .with(source_table_name, target_table_name, **options)
+ .with(source_table_name, target_table_name, **exits_options)
.and_return(false)
expect(migration).not_to receive(:concurrent_partitioned_foreign_key_name)
- expect_add_concurrent_fk(partition1_name, target_table_name, **options)
- expect_add_concurrent_fk(partition2_name, target_table_name, **options)
+ expect_add_concurrent_fk(partition1_name, target_table_name, **create_options)
+ expect_add_concurrent_fk(partition2_name, target_table_name, **create_options)
- expect(migration).to receive(:with_lock_retries).ordered.and_yield
- expect(migration).to receive(:add_foreign_key).with(source_table_name, target_table_name, **options).ordered
+ expect(migration).to receive(:add_concurrent_foreign_key)
+ .with(source_table_name, target_table_name, allow_partitioned: true, **create_options)
+ .ordered
- migration.add_concurrent_partitioned_foreign_key(source_table_name, target_table_name,
- column: column_name, name: '_my_fk_name', on_delete: :restrict)
+ migration.add_concurrent_partitioned_foreign_key(
+ source_table_name,
+ target_table_name,
+ column: column_name,
+ name: '_my_fk_name',
+ on_delete: :restrict
+ )
end
def expect_add_concurrent_fk(source_table_name, target_table_name, options)
@@ -153,4 +204,39 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
end
end
end
+
+ describe '#validate_partitioned_foreign_key' do
+ 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.validate_partitioned_foreign_key(source_table_name, column_name, name: '_my_fk_name')
+ end.to raise_error(/can not be run inside a transaction/)
+ end
+ end
+
+ context 'when run outside a transaction block' do
+ before do
+ migration.add_concurrent_partitioned_foreign_key(
+ source_table_name,
+ target_table_name,
+ column: column_name,
+ name: foreign_key_name,
+ validate: false
+ )
+ end
+
+ it 'validates FK for each partition' do
+ expect(migration).to receive(:execute).with(/SET statement_timeout TO 0/).twice
+ expect(migration).to receive(:execute).with(/RESET statement_timeout/).twice
+ expect(migration).to receive(:execute)
+ .with(/ALTER TABLE #{partition1_name} VALIDATE CONSTRAINT #{foreign_key_name}/).ordered
+ expect(migration).to receive(:execute)
+ .with(/ALTER TABLE #{partition2_name} VALIDATE CONSTRAINT #{foreign_key_name}/).ordered
+
+ migration.validate_partitioned_foreign_key(source_table_name, column_name, name: foreign_key_name)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
index e76b1da3834..571c67db597 100644
--- a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
@@ -2,10 +2,11 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHelpers do
+RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHelpers, feature_category: :database do
include Database::PartitioningHelpers
include Database::TriggerHelpers
include Database::TableSchemaHelpers
+ include MigrationsHelpers
let(:migration) do
ActiveRecord::Migration.new.extend(described_class)
@@ -14,9 +15,9 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
let_it_be(:connection) { ActiveRecord::Base.connection }
let(:source_table) { :_test_original_table }
- let(:partitioned_table) { '_test_migration_partitioned_table' }
- let(:function_name) { '_test_migration_function_name' }
- let(:trigger_name) { '_test_migration_trigger_name' }
+ let(:partitioned_table) { :_test_migration_partitioned_table }
+ let(:function_name) { :_test_migration_function_name }
+ let(:trigger_name) { :_test_migration_trigger_name }
let(:partition_column) { 'created_at' }
let(:min_date) { Date.new(2019, 12) }
let(:max_date) { Date.new(2020, 3) }
@@ -42,15 +43,15 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
context 'list partitioning conversion helpers' do
- shared_examples_for 'delegates to ConvertTableToFirstListPartition' do
+ shared_examples_for 'delegates to ConvertTable' do
let(:extra_options) { {} }
it 'throws an error if in a transaction' do
allow(migration).to receive(:transaction_open?).and_return(true)
expect { migrate }.to raise_error(/cannot be run inside a transaction/)
end
- it 'delegates to a method on ConvertTableToFirstListPartition' do
- expect_next_instance_of(Gitlab::Database::Partitioning::ConvertTableToFirstListPartition,
+ it 'delegates to a method on List::ConvertTable' do
+ expect_next_instance_of(Gitlab::Database::Partitioning::List::ConvertTable,
migration_context: migration,
table_name: source_table,
parent_table_name: partitioned_table,
@@ -65,7 +66,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
describe '#convert_table_to_first_list_partition' do
- it_behaves_like 'delegates to ConvertTableToFirstListPartition' do
+ it_behaves_like 'delegates to ConvertTable' do
let(:lock_tables) { [source_table] }
let(:extra_options) { { lock_tables: lock_tables } }
let(:expected_method) { :partition }
@@ -80,7 +81,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
describe '#revert_converting_table_to_first_list_partition' do
- it_behaves_like 'delegates to ConvertTableToFirstListPartition' do
+ it_behaves_like 'delegates to ConvertTable' do
let(:expected_method) { :revert_partitioning }
let(:migrate) do
migration.revert_converting_table_to_first_list_partition(table_name: source_table,
@@ -92,19 +93,20 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
describe '#prepare_constraint_for_list_partitioning' do
- it_behaves_like 'delegates to ConvertTableToFirstListPartition' do
+ it_behaves_like 'delegates to ConvertTable' do
let(:expected_method) { :prepare_for_partitioning }
let(:migrate) do
migration.prepare_constraint_for_list_partitioning(table_name: source_table,
partitioning_column: partition_column,
parent_table_name: partitioned_table,
- initial_partitioning_value: min_date)
+ initial_partitioning_value: min_date,
+ async: false)
end
end
end
describe '#revert_preparing_constraint_for_list_partitioning' do
- it_behaves_like 'delegates to ConvertTableToFirstListPartition' do
+ it_behaves_like 'delegates to ConvertTable' do
let(:expected_method) { :revert_preparation_for_partitioning }
let(:migrate) do
migration.revert_preparing_constraint_for_list_partitioning(table_name: source_table,
@@ -121,12 +123,8 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
let(:old_primary_key) { 'id' }
let(:new_primary_key) { [old_primary_key, partition_column] }
- before do
- allow(migration).to receive(:queue_background_migration_jobs_by_range_at_intervals)
- end
-
context 'when the table is not allowed' do
- let(:source_table) { :this_table_is_not_allowed }
+ let(:source_table) { :_test_this_table_is_not_allowed }
it 'raises an error' do
expect(migration).to receive(:assert_table_is_allowed).with(source_table).and_call_original
@@ -227,7 +225,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
end
- let(:non_int_table) { :another_example }
+ let(:non_int_table) { :_test_another_example }
let(:old_primary_key) { 'identifier' }
it 'does not change the primary key datatype' do
@@ -422,7 +420,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
let(:migration_class) { 'Gitlab::Database::PartitioningMigrationHelpers::BackfillPartitionedTable' }
context 'when the table is not allowed' do
- let(:source_table) { :this_table_is_not_allowed }
+ let(:source_table) { :_test_this_table_is_not_allowed }
it 'raises an error' do
expect(migration).to receive(:assert_table_is_allowed).with(source_table).and_call_original
@@ -462,7 +460,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
describe '#enqueue_partitioning_data_migration' do
context 'when the table is not allowed' do
- let(:source_table) { :this_table_is_not_allowed }
+ let(:source_table) { :_test_this_table_is_not_allowed }
it 'raises an error' do
expect(migration).to receive(:assert_table_is_allowed).with(source_table).and_call_original
@@ -484,17 +482,15 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
context 'when records exist in the source table' do
- let(:migration_class) { '::Gitlab::Database::PartitioningMigrationHelpers::BackfillPartitionedTable' }
+ let(:migration_class) { described_class::MIGRATION }
let(:sub_batch_size) { described_class::SUB_BATCH_SIZE }
- let(:pause_seconds) { described_class::PAUSE_SECONDS }
let!(:first_id) { source_model.create!(name: 'Bob', age: 20).id }
let!(:second_id) { source_model.create!(name: 'Alice', age: 30).id }
let!(:third_id) { source_model.create!(name: 'Sam', age: 40).id }
before do
stub_const("#{described_class.name}::BATCH_SIZE", 2)
-
- expect(migration).to receive(:queue_background_migration_jobs_by_range_at_intervals).and_call_original
+ stub_const("#{described_class.name}::SUB_BATCH_SIZE", 1)
end
it 'enqueues jobs to copy each batch of data' do
@@ -503,13 +499,13 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
Sidekiq::Testing.fake! do
migration.enqueue_partitioning_data_migration source_table
- expect(BackgroundMigrationWorker.jobs.size).to eq(2)
-
- first_job_arguments = [first_id, second_id, source_table.to_s, partitioned_table, 'id']
- expect(BackgroundMigrationWorker.jobs[0]['args']).to eq([migration_class, first_job_arguments])
-
- second_job_arguments = [third_id, third_id, source_table.to_s, partitioned_table, 'id']
- expect(BackgroundMigrationWorker.jobs[1]['args']).to eq([migration_class, second_job_arguments])
+ expect(migration_class).to have_scheduled_batched_migration(
+ table_name: source_table,
+ column_name: :id,
+ job_arguments: [partitioned_table],
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
end
end
end
@@ -517,7 +513,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
describe '#cleanup_partitioning_data_migration' do
context 'when the table is not allowed' do
- let(:source_table) { :this_table_is_not_allowed }
+ let(:source_table) { :_test_this_table_is_not_allowed }
it 'raises an error' do
expect(migration).to receive(:assert_table_is_allowed).with(source_table).and_call_original
@@ -528,18 +524,36 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
end
- context 'when tracking records exist in the background_migration_jobs table' do
- let(:migration_class) { 'Gitlab::Database::PartitioningMigrationHelpers::BackfillPartitionedTable' }
- let!(:job1) { create(:background_migration_job, class_name: migration_class, arguments: [1, 10, source_table]) }
- let!(:job2) { create(:background_migration_job, class_name: migration_class, arguments: [11, 20, source_table]) }
- let!(:job3) { create(:background_migration_job, class_name: migration_class, arguments: [1, 10, 'other_table']) }
+ context 'when tracking records exist in the batched_background_migrations table' do
+ let(:migration_class) { described_class::MIGRATION }
+
+ before do
+ create(
+ :batched_background_migration,
+ job_class_name: migration_class,
+ table_name: source_table,
+ column_name: :id,
+ job_arguments: [partitioned_table]
+ )
+
+ create(
+ :batched_background_migration,
+ job_class_name: migration_class,
+ table_name: 'other_table',
+ column_name: :id,
+ job_arguments: ['other_table_partitioned']
+ )
+ end
it 'deletes those pertaining to the given table' do
expect { migration.cleanup_partitioning_data_migration(source_table) }
- .to change { ::Gitlab::Database::BackgroundMigrationJob.count }.from(3).to(1)
+ .to change { ::Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(-1)
- remaining_record = ::Gitlab::Database::BackgroundMigrationJob.first
- expect(remaining_record).to have_attributes(class_name: migration_class, arguments: [1, 10, 'other_table'])
+ expect(::Gitlab::Database::BackgroundMigration::BatchedMigration.where(table_name: 'other_table').any?)
+ .to be_truthy
+
+ expect(::Gitlab::Database::BackgroundMigration::BatchedMigration.where(table_name: source_table).any?)
+ .to be_falsy
end
end
end
@@ -577,10 +591,10 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
describe '#finalize_backfilling_partitioned_table' do
- let(:source_column) { 'id' }
+ let(:source_column) { :id }
context 'when the table is not allowed' do
- let(:source_table) { :this_table_is_not_allowed }
+ let(:source_table) { :_test_this_table_is_not_allowed }
it 'raises an error' do
expect(migration).to receive(:assert_table_is_allowed).with(source_table).and_call_original
@@ -601,131 +615,28 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
end
- context 'finishing pending background migration jobs' do
+ context 'finishing pending batched background migration jobs' do
let(:source_table_double) { double('table name') }
let(:raw_arguments) { [1, 50_000, source_table_double, partitioned_table, source_column] }
let(:background_job) { double('background job', args: ['background jobs', raw_arguments]) }
-
- before do
- allow(migration).to receive(:table_exists?).with(partitioned_table).and_return(true)
- allow(migration).to receive(:copy_missed_records)
- allow(migration).to receive(:execute).with(/VACUUM/)
- allow(migration).to receive(:execute).with(/^(RE)?SET/)
- end
-
- it 'finishes remaining jobs for the correct table' do
- expect_next_instance_of(described_class::JobArguments) do |job_arguments|
- expect(job_arguments).to receive(:source_table_name).and_call_original
- end
-
- expect(Gitlab::BackgroundMigration).to receive(:steal)
- .with(described_class::MIGRATION_CLASS_NAME)
- .and_yield(background_job)
-
- expect(source_table_double).to receive(:==).with(source_table.to_s)
-
- migration.finalize_backfilling_partitioned_table source_table
- end
-
- it 'requires the migration helper to execute in DML mode' do
- expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_dml_mode!)
-
- expect(Gitlab::BackgroundMigration).to receive(:steal)
- .with(described_class::MIGRATION_CLASS_NAME)
- .and_yield(background_job)
-
- migration.finalize_backfilling_partitioned_table source_table
- end
- end
-
- context 'when there is missed data' do
- let(:partitioned_model) { Class.new(ActiveRecord::Base) }
- let(:timestamp) { Time.utc(2019, 12, 1, 12).round }
- let!(:record1) { source_model.create!(name: 'Bob', age: 20, created_at: timestamp, updated_at: timestamp) }
- let!(:record2) { source_model.create!(name: 'Alice', age: 30, created_at: timestamp, updated_at: timestamp) }
- let!(:record3) { source_model.create!(name: 'Sam', age: 40, created_at: timestamp, updated_at: timestamp) }
- let!(:record4) { source_model.create!(name: 'Sue', age: 50, created_at: timestamp, updated_at: timestamp) }
-
- let!(:pending_job1) do
- create(:background_migration_job,
- class_name: described_class::MIGRATION_CLASS_NAME,
- arguments: [record1.id, record2.id, source_table, partitioned_table, source_column])
- end
-
- let!(:pending_job2) do
- create(:background_migration_job,
- class_name: described_class::MIGRATION_CLASS_NAME,
- arguments: [record3.id, record3.id, source_table, partitioned_table, source_column])
- end
-
- let!(:succeeded_job) do
- create(:background_migration_job, :succeeded,
- class_name: described_class::MIGRATION_CLASS_NAME,
- arguments: [record4.id, record4.id, source_table, partitioned_table, source_column])
+ let(:bbm_arguments) do
+ {
+ job_class_name: described_class::MIGRATION,
+ table_name: source_table,
+ column_name: connection.primary_key(source_table),
+ job_arguments: [partitioned_table]
+ }
end
before do
- partitioned_model.primary_key = :id
- partitioned_model.table_name = partitioned_table
-
- allow(migration).to receive(:queue_background_migration_jobs_by_range_at_intervals)
-
- migration.partition_table_by_date source_table, partition_column, min_date: min_date, max_date: max_date
-
- allow(Gitlab::BackgroundMigration).to receive(:steal)
+ allow(migration).to receive(:table_exists?).with(partitioned_table).and_return(true)
allow(migration).to receive(:execute).with(/VACUUM/)
allow(migration).to receive(:execute).with(/^(RE)?SET/)
end
- it 'idempotently cleans up after failed background migrations' do
- expect(partitioned_model.count).to eq(0)
-
- partitioned_model.insert(record2.attributes, unique_by: [:id, :created_at])
-
- expect_next_instance_of(Gitlab::Database::PartitioningMigrationHelpers::BackfillPartitionedTable) do |backfill|
- allow(backfill).to receive(:transaction_open?).and_return(false)
-
- expect(backfill).to receive(:perform)
- .with(record1.id, record2.id, source_table, partitioned_table, source_column)
- .and_call_original
-
- expect(backfill).to receive(:perform)
- .with(record3.id, record3.id, source_table, partitioned_table, source_column)
- .and_call_original
- end
-
- migration.finalize_backfilling_partitioned_table source_table
-
- expect(partitioned_model.count).to eq(3)
-
- [record1, record2, record3].each do |original|
- copy = partitioned_model.find(original.id)
- expect(copy.attributes).to eq(original.attributes)
- end
-
- expect(partitioned_model.find_by_id(record4.id)).to be_nil
-
- [pending_job1, pending_job2].each do |job|
- expect(job.reload).to be_succeeded
- end
- end
-
- it 'raises an error if no job tracking records are marked as succeeded' do
- expect_next_instance_of(Gitlab::Database::PartitioningMigrationHelpers::BackfillPartitionedTable) do |backfill|
- allow(backfill).to receive(:transaction_open?).and_return(false)
-
- expect(backfill).to receive(:perform).and_return(0)
- end
-
- expect do
- migration.finalize_backfilling_partitioned_table source_table
- end.to raise_error(/failed to update tracking record/)
- end
-
- it 'vacuums the table after loading is complete' do
- expect_next_instance_of(Gitlab::Database::PartitioningMigrationHelpers::BackfillPartitionedTable) do |backfill|
- allow(backfill).to receive(:perform).and_return(1)
- end
+ it 'ensures finishing of remaining jobs and vacuums the partitioned table' do
+ expect(migration).to receive(:ensure_batched_background_migration_is_finished)
+ .with(bbm_arguments)
expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield
expect(migration).to receive(:disable_statement_timeout).and_call_original
diff --git a/spec/lib/gitlab/database/partitioning_spec.rb b/spec/lib/gitlab/database/partitioning_spec.rb
index ae74ee60a4b..9df238a0024 100644
--- a/spec/lib/gitlab/database/partitioning_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_spec.rb
@@ -2,11 +2,11 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::Partitioning do
+RSpec.describe Gitlab::Database::Partitioning, feature_category: :database do
include Database::PartitioningHelpers
include Database::TableSchemaHelpers
- let(:connection) { ApplicationRecord.connection }
+ let(:main_connection) { ApplicationRecord.connection }
around do |example|
previously_registered_models = described_class.registered_models.dup
@@ -65,21 +65,26 @@ RSpec.describe Gitlab::Database::Partitioning do
describe '.sync_partitions' do
let(:ci_connection) { Ci::ApplicationRecord.connection }
- let(:table_names) { %w[partitioning_test1 partitioning_test2] }
+ let(:table_names) { %w[_test_partitioning_test1 _test_partitioning_test2] }
let(:models) do
- table_names.map do |table_name|
+ [
Class.new(ApplicationRecord) do
include PartitionedTable
- self.table_name = table_name
+ self.table_name = :_test_partitioning_test1
partitioned_by :created_at, strategy: :monthly
+ end,
+ Class.new(Gitlab::Database::Partitioning::TableWithoutModel).tap do |klass|
+ klass.table_name = :_test_partitioning_test2
+ klass.partitioned_by(:created_at, strategy: :monthly)
+ klass.limit_connection_names = %i[main]
end
- end
+ ]
end
before do
table_names.each do |table_name|
- connection.execute(<<~SQL)
+ execute_on_each_database(<<~SQL)
CREATE TABLE #{table_name} (
id serial not null,
created_at timestamptz not null,
@@ -96,32 +101,12 @@ RSpec.describe Gitlab::Database::Partitioning do
end
context 'with multiple databases' do
- before do
- table_names.each do |table_name|
- ci_connection.execute("DROP TABLE IF EXISTS #{table_name}")
-
- ci_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);
- SQL
- end
- end
-
- after do
- table_names.each do |table_name|
- ci_connection.execute("DROP TABLE IF EXISTS #{table_name}")
- end
- end
-
it 'creates partitions in each database' do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
expect { described_class.sync_partitions(models) }
- .to change { find_partitions(table_names.first, conn: connection).size }.from(0)
- .and change { find_partitions(table_names.last, conn: connection).size }.from(0)
+ .to change { find_partitions(table_names.first, conn: main_connection).size }.from(0)
+ .and change { find_partitions(table_names.last, conn: main_connection).size }.from(0)
.and change { find_partitions(table_names.first, conn: ci_connection).size }.from(0)
.and change { find_partitions(table_names.last, conn: ci_connection).size }.from(0)
end
@@ -150,16 +135,18 @@ RSpec.describe Gitlab::Database::Partitioning do
Class.new(Ci::ApplicationRecord) do
include PartitionedTable
- self.table_name = 'partitioning_test3'
+ self.table_name = :_test_partitioning_test3
partitioned_by :created_at, strategy: :monthly
end
end
before do
- (table_names + ['partitioning_test3']).each do |table_name|
- ci_connection.execute("DROP TABLE IF EXISTS #{table_name}")
+ skip_if_shared_database(:ci)
+
+ (table_names + [:_test_partitioning_test3]).each do |table_name|
+ execute_on_each_database("DROP TABLE IF EXISTS #{table_name}")
- ci_connection.execute(<<~SQL)
+ execute_on_each_database(<<~SQL)
CREATE TABLE #{table_name} (
id serial not null,
created_at timestamptz not null,
@@ -170,20 +157,33 @@ RSpec.describe Gitlab::Database::Partitioning do
end
after do
- (table_names + ['partitioning_test3']).each do |table_name|
+ (table_names + [:_test_partitioning_test3]).each do |table_name|
ci_connection.execute("DROP TABLE IF EXISTS #{table_name}")
end
end
it 'manages partitions for models for the given database', :aggregate_failures do
- skip_if_multiple_databases_not_setup(:ci)
-
expect { described_class.sync_partitions([models.first, ci_model], only_on: 'ci') }
.to change { find_partitions(ci_model.table_name, conn: ci_connection).size }.from(0)
- expect(find_partitions(models.first.table_name).size).to eq(0)
+ expect(find_partitions(models.first.table_name, conn: main_connection).size).to eq(0)
expect(find_partitions(models.first.table_name, conn: ci_connection).size).to eq(0)
- expect(find_partitions(ci_model.table_name).size).to eq(0)
+ expect(find_partitions(ci_model.table_name, conn: main_connection).size).to eq(0)
+ end
+ end
+
+ context 'when partition_manager_sync_partitions feature flag is disabled' do
+ before do
+ described_class.register_models(models)
+ stub_feature_flags(partition_manager_sync_partitions: false)
+ end
+
+ it 'skips sync_partitions' do
+ expect(described_class::PartitionManager).not_to receive(:new)
+ expect(described_class).to receive(:sync_partitions)
+ .and_call_original
+
+ described_class.sync_partitions(models)
end
end
end
@@ -228,7 +228,7 @@ RSpec.describe Gitlab::Database::Partitioning do
end
describe '.drop_detached_partitions' do
- let(:table_names) { %w[detached_test_partition1 detached_test_partition2] }
+ let(:table_names) { %w[_test_detached_test_partition1 _test_detached_test_partition2] }
before do
table_names.each do |table_name|
diff --git a/spec/lib/gitlab/database/pg_depend_spec.rb b/spec/lib/gitlab/database/pg_depend_spec.rb
new file mode 100644
index 00000000000..547a2c84b76
--- /dev/null
+++ b/spec/lib/gitlab/database/pg_depend_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::PgDepend, type: :model, feature_category: :database do
+ let(:connection) { described_class.connection }
+
+ describe '.from_pg_extension' do
+ subject { described_class.from_pg_extension('VIEW') }
+
+ context 'when having views as dependency' do
+ before do
+ connection.execute('CREATE EXTENSION IF NOT EXISTS pg_stat_statements;')
+ end
+
+ it 'returns pg_stat_statements', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410508' do
+ expect(subject.pluck('relname')).to eq(['pg_stat_statements'])
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/postgres_foreign_key_spec.rb b/spec/lib/gitlab/database/postgres_foreign_key_spec.rb
index ae56f66737d..03343c134ae 100644
--- a/spec/lib/gitlab/database/postgres_foreign_key_spec.rb
+++ b/spec/lib/gitlab/database/postgres_foreign_key_spec.rb
@@ -70,13 +70,29 @@ RSpec.describe Gitlab::Database::PostgresForeignKey, type: :model, feature_categ
end
describe '#by_constrained_table_name' do
- it 'finds the foreign keys for the constrained table' do
- expected = described_class.where(name: %w[fk_constrained_to_referenced fk_constrained_to_other_referenced]).to_a
+ let(:expected) { described_class.where(name: %w[fk_constrained_to_referenced fk_constrained_to_other_referenced]).to_a }
+ it 'finds the foreign keys for the constrained table' do
expect(described_class.by_constrained_table_name(table_name("constrained_table"))).to match_array(expected)
end
end
+ describe '#by_constrained_table_name_or_identifier' do
+ let(:expected) { described_class.where(name: %w[fk_constrained_to_referenced fk_constrained_to_other_referenced]).to_a }
+
+ context 'when using table name' do
+ it 'finds the foreign keys for the constrained table' do
+ expect(described_class.by_constrained_table_name_or_identifier(table_name("constrained_table"))).to match_array(expected)
+ end
+ end
+
+ context 'when using identifier' do
+ it 'finds the foreign keys for the constrained table' do
+ expect(described_class.by_constrained_table_name_or_identifier(schema_table_name('constrained_table'))).to match_array(expected)
+ end
+ end
+ end
+
describe '#by_name' do
it 'finds foreign keys by name' do
expect(described_class.by_name('fk_constrained_to_referenced').pluck(:name)).to contain_exactly('fk_constrained_to_referenced')
@@ -187,10 +203,8 @@ RSpec.describe Gitlab::Database::PostgresForeignKey, type: :model, feature_categ
end
end
- context 'when supporting foreign keys to inherited tables in postgres 12' do
+ context 'when supporting foreign keys on partitioned tables' do
before do
- skip('not supported before postgres 12') if ApplicationRecord.database.version.to_f < 12
-
ApplicationRecord.connection.execute(<<~SQL)
create table #{schema_table_name('parent')} (
id bigserial primary key not null
@@ -232,6 +246,40 @@ RSpec.describe Gitlab::Database::PostgresForeignKey, type: :model, feature_categ
end
end
+ context 'with two tables both partitioned' do
+ before do
+ ApplicationRecord.connection.execute(<<~SQL)
+ create table #{table_name('parent')} (
+ id bigserial primary key not null
+ ) partition by hash(id);
+
+ create table #{table_name('child')}
+ partition of #{table_name('parent')} for values with (remainder 1, modulus 2);
+
+ create table #{table_name('ref_parent')} (
+ id bigserial primary key not null
+ ) partition by hash(id);
+
+ create table #{table_name('ref_child_1')}
+ partition of #{table_name('ref_parent')} for values with (remainder 1, modulus 3);
+
+ create table #{table_name('ref_child_2')}
+ partition of #{table_name('ref_parent')} for values with (remainder 2, modulus 3);
+
+ alter table #{table_name('parent')} add constraint fk foreign key (id) references #{table_name('ref_parent')} (id);
+ SQL
+ end
+
+ describe '#child_foreign_keys' do
+ it 'is the child foreign keys of the partitioned parent fk' do
+ fk = described_class.by_constrained_table_name(table_name('parent')).first
+ children = fk.child_foreign_keys
+ expect(children.count).to eq(1)
+ expect(children.first.constrained_table_name).to eq(table_name('child'))
+ end
+ end
+ end
+
def schema_table_name(name)
"public.#{table_name(name)}"
end
diff --git a/spec/lib/gitlab/database/postgres_partition_spec.rb b/spec/lib/gitlab/database/postgres_partition_spec.rb
index 14a4d405621..48dbdbc7757 100644
--- a/spec/lib/gitlab/database/postgres_partition_spec.rb
+++ b/spec/lib/gitlab/database/postgres_partition_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::PostgresPartition, type: :model do
+RSpec.describe Gitlab::Database::PostgresPartition, type: :model, feature_category: :database do
+ let(:current_schema) { ActiveRecord::Base.connection.select_value("SELECT current_schema()") }
let(:schema) { 'gitlab_partitions_dynamic' }
let(:name) { '_test_partition_01' }
let(:identifier) { "#{schema}.#{name}" }
@@ -56,9 +57,20 @@ RSpec.describe Gitlab::Database::PostgresPartition, type: :model do
expect(partitions.pluck(:name)).to eq([name, second_name])
end
+ it 'returns the partitions if the parent table schema is included in the table name' do
+ partitions = described_class.for_parent_table("#{current_schema}._test_partitioned_table")
+
+ expect(partitions.count).to eq(2)
+ expect(partitions.pluck(:name)).to eq([name, second_name])
+ end
+
it 'does not return partitions for tables not in the current schema' do
expect(described_class.for_parent_table('_test_other_table').count).to eq(0)
end
+
+ it 'does not return partitions for tables if the schema is not the current' do
+ expect(described_class.for_parent_table('foo_bar._test_partitioned_table').count).to eq(0)
+ end
end
describe '#parent_identifier' do
diff --git a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb
index 3a92f35d585..6a0c4226db8 100644
--- a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb
+++ b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb
@@ -57,10 +57,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana
"for query accessing gitlab_main and unknown schema" => {
model: ApplicationRecord,
sql: "SELECT 1 FROM projects LEFT JOIN not_in_schema ON not_in_schema.project_id=projects.id",
- expectations: {
- gitlab_schemas: "gitlab_main,undefined_not_in_schema",
- db_config_name: "main"
- }
+ expect_error:
+ /Could not find gitlab schema for table not_in_schema/
}
}
end
@@ -74,10 +72,14 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana
allow(::Ci::ApplicationRecord.load_balancer).to receive(:configuration)
.and_return(Gitlab::Database::LoadBalancing::Configuration.for_model(::Ci::ApplicationRecord))
- expect(described_class.schemas_metrics).to receive(:increment)
- .with(expectations).and_call_original
+ if expect_error
+ expect { process_sql(model, sql) }.to raise_error(expect_error)
+ else
+ expect(described_class.schemas_metrics).to receive(:increment)
+ .with(expectations).and_call_original
- process_sql(model, sql)
+ process_sql(model, sql)
+ end
end
end
end
diff --git a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb
index d31be6cb883..e3ff5ab4779 100644
--- a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb
+++ b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection, query_analyzers: false,
- feature_category: :pods do
+ feature_category: :cell do
let(:analyzer) { described_class }
# We keep only the GitlabSchemasValidateConnection analyzer running
@@ -28,19 +28,19 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection
model: ApplicationRecord,
sql: "SELECT 1 FROM projects LEFT JOIN ci_builds ON ci_builds.project_id=projects.id",
expect_error: /The query tried to access \["projects", "ci_builds"\]/,
- setup: -> (_) { skip_if_multiple_databases_not_setup(:ci) }
+ setup: -> (_) { skip_if_shared_database(:ci) }
},
"for query accessing gitlab_ci and gitlab_main the gitlab_schemas is always ordered" => {
model: ApplicationRecord,
sql: "SELECT 1 FROM ci_builds LEFT JOIN projects ON ci_builds.project_id=projects.id",
expect_error: /The query tried to access \["ci_builds", "projects"\]/,
- setup: -> (_) { skip_if_multiple_databases_not_setup(:ci) }
+ setup: -> (_) { skip_if_shared_database(:ci) }
},
"for query accessing main table from CI database" => {
model: Ci::ApplicationRecord,
sql: "SELECT 1 FROM projects",
expect_error: /The query tried to access \["projects"\]/,
- setup: -> (_) { skip_if_multiple_databases_not_setup(:ci) }
+ setup: -> (_) { skip_if_shared_database(:ci) }
},
"for query accessing CI database" => {
model: Ci::ApplicationRecord,
@@ -51,13 +51,14 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection
model: ::ApplicationRecord,
sql: "SELECT 1 FROM ci_builds",
expect_error: /The query tried to access \["ci_builds"\]/,
- setup: -> (_) { skip_if_multiple_databases_not_setup(:ci) }
+ setup: -> (_) { skip_if_shared_database(:ci) }
},
"for query accessing unknown gitlab_schema" => {
model: ::ApplicationRecord,
sql: "SELECT 1 FROM new_table",
- expect_error: /The query tried to access \["new_table"\] \(of undefined_new_table\)/,
- setup: -> (_) { skip_if_multiple_databases_not_setup(:ci) }
+ expect_error:
+ /Could not find gitlab schema for table new_table/,
+ setup: -> (_) { skip_if_shared_database(:ci) }
}
}
end
@@ -77,7 +78,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection
context "when analyzer is enabled for tests", :query_analyzers do
before do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
end
it "throws an error when trying to access a table that belongs to the gitlab_main schema from the ci database" do
diff --git a/spec/lib/gitlab/database/query_analyzers/prevent_cross_database_modification_spec.rb b/spec/lib/gitlab/database/query_analyzers/prevent_cross_database_modification_spec.rb
index a4322689bf9..02bd6b51463 100644
--- a/spec/lib/gitlab/database/query_analyzers/prevent_cross_database_modification_spec.rb
+++ b/spec/lib/gitlab/database/query_analyzers/prevent_cross_database_modification_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification, query_analyzers: false,
- feature_category: :pods do
+ feature_category: :cell do
let_it_be(:pipeline, refind: true) { create(:ci_pipeline) }
let_it_be(:project, refind: true) { create(:project) }
@@ -118,6 +118,18 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio
end
end
+ context 'when ci_pipelines are ignored for cross modification' do
+ it 'does not raise error' do
+ Project.transaction do
+ expect do
+ described_class.temporary_ignore_tables_in_transaction(%w[ci_pipelines], url: 'TODO') do
+ run_queries
+ end
+ end.not_to raise_error
+ end
+ end
+ end
+
context 'when data modification happens in nested transactions' do
it 'raises error' do
Project.transaction(requires_new: true) do
@@ -209,27 +221,16 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio
end
end
- context 'when some table with a defined schema and another table with undefined gitlab_schema is modified' do
- it 'raises an error including including message about undefined schema' do
- expect do
- Project.transaction do
- project.touch
- project.connection.execute('UPDATE foo_bars_undefined_table SET a=1 WHERE id = -1')
- end
- end.to raise_error /Cross-database data modification.*The gitlab_schema was undefined/
- end
- end
-
context 'when execution is rescued with StandardError' do
it 'raises cross-database data modification exception' do
expect do
Project.transaction do
project.touch
- project.connection.execute('UPDATE foo_bars_undefined_table SET a=1 WHERE id = -1')
+ project.connection.execute('UPDATE ci_pipelines SET id=1 WHERE id = -1')
end
rescue StandardError
# Ensures that standard rescue does not silence errors
- end.to raise_error /Cross-database data modification.*The gitlab_schema was undefined/
+ end.to raise_error /Cross-database data modification/
end
end
diff --git a/spec/lib/gitlab/database/reflection_spec.rb b/spec/lib/gitlab/database/reflection_spec.rb
index 779bdbe50f0..641dd48be36 100644
--- a/spec/lib/gitlab/database/reflection_spec.rb
+++ b/spec/lib/gitlab/database/reflection_spec.rb
@@ -191,9 +191,15 @@ RSpec.describe Gitlab::Database::Reflection, feature_category: :database do
expect(database.postgresql_minimum_supported_version?).to eq(false)
end
- it 'returns true when using PostgreSQL 12' do
+ it 'returns false when using PostgreSQL 12' do
allow(database).to receive(:version).and_return('12')
+ expect(database.postgresql_minimum_supported_version?).to eq(false)
+ end
+
+ it 'returns true when using PostgreSQL 13' do
+ allow(database).to receive(:version).and_return('13')
+
expect(database.postgresql_minimum_supported_version?).to eq(true)
end
end
diff --git a/spec/lib/gitlab/database/reindexing_spec.rb b/spec/lib/gitlab/database/reindexing_spec.rb
index a8af9bb5a38..4d0e58b0937 100644
--- a/spec/lib/gitlab/database/reindexing_spec.rb
+++ b/spec/lib/gitlab/database/reindexing_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe Gitlab::Database::Reindexing, feature_category: :database, time_t
context 'when async FK validation is enabled' do
it 'executes FK validation for each database prior to any reindexing actions' do
- expect(Gitlab::Database::AsyncForeignKeys).to receive(:validate_pending_entries!).ordered.exactly(databases_count).times
+ expect(Gitlab::Database::AsyncConstraints).to receive(:validate_pending_entries!).ordered.exactly(databases_count).times
expect(described_class).to receive(:automatic_reindexing).ordered.exactly(databases_count).times
described_class.invoke
@@ -82,7 +82,7 @@ RSpec.describe Gitlab::Database::Reindexing, feature_category: :database, time_t
it 'does not execute FK validation' do
stub_feature_flags(database_async_foreign_key_validation: false)
- expect(Gitlab::Database::AsyncForeignKeys).not_to receive(:validate_pending_entries!)
+ expect(Gitlab::Database::AsyncConstraints).not_to receive(:validate_pending_entries!)
described_class.invoke
end
diff --git a/spec/lib/gitlab/database/schema_validation/adapters/column_database_adapter_spec.rb b/spec/lib/gitlab/database/schema_validation/adapters/column_database_adapter_spec.rb
new file mode 100644
index 00000000000..d81f5f3dbec
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/adapters/column_database_adapter_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Adapters::ColumnDatabaseAdapter, feature_category: :database do
+ subject(:adapter) { described_class.new(db_result) }
+
+ let(:column_name) { 'email' }
+ let(:column_default) { "'no-reply@gitlab.com'::character varying" }
+ let(:not_null) { true }
+ let(:partition_key) { false }
+ let(:db_result) do
+ {
+ 'table_name' => 'projects',
+ 'column_name' => column_name,
+ 'data_type' => 'character varying',
+ 'column_default' => column_default,
+ 'not_null' => not_null,
+ 'partition_key' => partition_key
+ }
+ end
+
+ describe '#name' do
+ it { expect(adapter.name).to eq('email') }
+ end
+
+ describe '#table_name' do
+ it { expect(adapter.table_name).to eq('projects') }
+ end
+
+ describe '#data_type' do
+ it { expect(adapter.data_type).to eq('character varying') }
+ end
+
+ describe '#default' do
+ context "when there's no default value in the column" do
+ let(:column_default) { nil }
+
+ it { expect(adapter.default).to be_nil }
+ end
+
+ context 'when the column name is id' do
+ let(:column_name) { 'id' }
+
+ it { expect(adapter.default).to be_nil }
+ end
+
+ context 'when the column default includes nextval' do
+ let(:column_default) { "nextval('my_seq'::regclass)" }
+
+ it { expect(adapter.default).to be_nil }
+ end
+
+ it { expect(adapter.default).to eq("DEFAULT 'no-reply@gitlab.com'::character varying") }
+ end
+
+ describe '#nullable' do
+ context 'when column is not null' do
+ it { expect(adapter.nullable).to eq('NOT NULL') }
+ end
+
+ context 'when column is nullable' do
+ let(:not_null) { false }
+
+ it { expect(adapter.nullable).to be_nil }
+ end
+ end
+
+ describe '#partition_key?' do
+ it { expect(adapter.partition_key?).to be(false) }
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/adapters/column_structure_sql_adapter_spec.rb b/spec/lib/gitlab/database/schema_validation/adapters/column_structure_sql_adapter_spec.rb
new file mode 100644
index 00000000000..64b59e65be6
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/adapters/column_structure_sql_adapter_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Adapters::ColumnStructureSqlAdapter, feature_category: :database do
+ subject(:adapter) { described_class.new(table_name, column_def, partition_stmt) }
+
+ let(:table_name) { 'test_table' }
+ let(:file_path) { Rails.root.join('spec/fixtures/structure.sql') }
+ let(:table_stmts) { PgQuery.parse(File.read(file_path)).tree.stmts.filter_map { |s| s.stmt.create_stmt } }
+ let(:table) { table_stmts.find { |table| table.relation.relname == table_name } }
+ let(:partition_stmt) { table.partspec }
+ let(:column_stmts) { table.table_elts }
+ let(:column_def) { column_stmts.find { |col| col.column_def.colname == column_name }.column_def }
+
+ where(:column_name, :data_type, :default_value, :nullable, :partition_key) do
+ [
+ ['id', 'bigint', nil, 'NOT NULL', false],
+ ['integer_column', 'integer', nil, nil, false],
+ ['integer_with_default_column', 'integer', 'DEFAULT 1', nil, false],
+ ['smallint_with_default_column', 'smallint', 'DEFAULT 0', 'NOT NULL', false],
+ ['double_precision_with_default_column', 'double precision', 'DEFAULT 1.0', nil, false],
+ ['numeric_with_default_column', 'numeric', 'DEFAULT 1.0', 'NOT NULL', false],
+ ['boolean_with_default_colum', 'boolean', 'DEFAULT true', 'NOT NULL', false],
+ ['varying_with_default_column', 'character varying', "DEFAULT 'DEFAULT'::character varying", 'NOT NULL', false],
+ ['varying_with_limit_and_default_column', 'character varying(255)', "DEFAULT 'DEFAULT'::character varying",
+ nil, false],
+ ['text_with_default_column', 'text', "DEFAULT ''::text", 'NOT NULL', false],
+ ['array_with_default_column', 'character varying(255)[]', "DEFAULT '{one,two}'::character varying[]",
+ 'NOT NULL', false],
+ ['jsonb_with_default_column', 'jsonb', "DEFAULT '[]'::jsonb", 'NOT NULL', false],
+ ['timestamptz_with_default_column', 'timestamp(6) with time zone', "DEFAULT now()", nil, false],
+ ['timestamp_with_default_column', 'timestamp(6) without time zone',
+ "DEFAULT '2022-01-23 00:00:00+00'::timestamp without time zone", 'NOT NULL', false],
+ ['date_with_default_column', 'date', 'DEFAULT 2023-04-05', nil, false],
+ ['inet_with_default_column', 'inet', "DEFAULT '0.0.0.0'::inet", 'NOT NULL', false],
+ ['macaddr_with_default_column', 'macaddr', "DEFAULT '00-00-00-00-00-000'::macaddr", 'NOT NULL', false],
+ ['uuid_with_default_column', 'uuid', "DEFAULT '00000000-0000-0000-0000-000000000000'::uuid", 'NOT NULL', false],
+ ['partition_key', 'bigint', 'DEFAULT 1', 'NOT NULL', true],
+ ['created_at', 'timestamp with time zone', 'DEFAULT now()', 'NOT NULL', true]
+ ]
+ end
+
+ with_them do
+ describe '#name' do
+ it { expect(adapter.name).to eq(column_name) }
+ end
+
+ describe '#table_name' do
+ it { expect(adapter.table_name).to eq(table_name) }
+ end
+
+ describe '#data_type' do
+ it { expect(adapter.data_type).to eq(data_type) }
+ end
+
+ describe '#nullable' do
+ it { expect(adapter.nullable).to eq(nullable) }
+ end
+
+ describe '#default' do
+ it { expect(adapter.default).to eq(default_value) }
+ end
+
+ describe '#partition_key?' do
+ it { expect(adapter.partition_key?).to eq(partition_key) }
+ end
+ end
+
+ context 'when the data type is not mapped' do
+ let(:column_name) { 'unmapped_column_type' }
+ let(:error_class) { Gitlab::Database::SchemaValidation::Adapters::UndefinedPGType }
+
+ describe '#data_type' do
+ it { expect { adapter.data_type }.to raise_error(error_class) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/database_spec.rb b/spec/lib/gitlab/database/schema_validation/database_spec.rb
index c0026f91b46..0b5f433b1c9 100644
--- a/spec/lib/gitlab/database/schema_validation/database_spec.rb
+++ b/spec/lib/gitlab/database/schema_validation/database_spec.rb
@@ -2,44 +2,92 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::SchemaValidation::Database, feature_category: :database do
- let(:database_name) { 'main' }
- let(:database_indexes) do
- [['index', 'CREATE UNIQUE INDEX "index" ON public.achievements USING btree (namespace_id, lower(name))']]
- end
+RSpec.shared_examples 'database schema assertions for' do |fetch_by_name_method, exists_method, all_objects_method|
+ subject(:database) { described_class.new(connection) }
- let(:query_result) { instance_double('ActiveRecord::Result', rows: database_indexes) }
- let(:database_model) { Gitlab::Database.database_base_models[database_name] }
+ let(:database_model) { Gitlab::Database.database_base_models['main'] }
let(:connection) { database_model.connection }
- subject(:database) { described_class.new(connection) }
-
before do
- allow(connection).to receive(:exec_query).and_return(query_result)
+ allow(connection).to receive(:select_rows).and_return(results)
+ allow(connection).to receive(:exec_query).and_return(results)
end
- describe '#fetch_index_by_name' do
- context 'when index does not exist' do
- it 'returns nil' do
- index = database.fetch_index_by_name('non_existing_index')
+ describe "##{fetch_by_name_method}" do
+ it 'returns nil when schema object does not exists' do
+ expect(database.public_send(fetch_by_name_method, 'invalid-object-name')).to be_nil
+ end
+
+ it 'returns the schema object by name' do
+ expect(database.public_send(fetch_by_name_method, valid_schema_object_name).name).to eq(valid_schema_object_name)
+ end
+ end
+
+ describe "##{exists_method}" do
+ it 'returns true when schema object exists' do
+ expect(database.public_send(exists_method, valid_schema_object_name)).to be_truthy
+ end
- expect(index).to be_nil
- end
+ it 'returns false when schema object does not exists' do
+ expect(database.public_send(exists_method, 'invalid-object')).to be_falsey
end
+ end
- it 'returns index by name' do
- index = database.fetch_index_by_name('index')
+ describe "##{all_objects_method}" do
+ it 'returns all the schema objects' do
+ schema_objects = database.public_send(all_objects_method)
- expect(index.name).to eq('index')
+ expect(schema_objects).to all(be_a(schema_object))
+ expect(schema_objects.map(&:name)).to eq([valid_schema_object_name])
end
end
+end
+
+RSpec.describe Gitlab::Database::SchemaValidation::Database, feature_category: :database do
+ context 'when having indexes' do
+ let(:schema_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Index }
+ let(:valid_schema_object_name) { 'index' }
+ let(:results) do
+ [['index', 'CREATE UNIQUE INDEX "index" ON public.achievements USING btree (namespace_id, lower(name))']]
+ end
- describe '#indexes' do
- it 'returns indexes' do
- indexes = database.indexes
+ include_examples 'database schema assertions for', 'fetch_index_by_name', 'index_exists?', 'indexes'
+ end
- expect(indexes).to all(be_a(Gitlab::Database::SchemaValidation::Index))
- expect(indexes.map(&:name)).to eq(['index'])
+ context 'when having triggers' do
+ let(:schema_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Trigger }
+ let(:valid_schema_object_name) { 'my_trigger' }
+ let(:results) do
+ [['my_trigger', 'CREATE TRIGGER my_trigger BEFORE INSERT ON todos FOR EACH ROW EXECUTE FUNCTION trigger()']]
end
+
+ include_examples 'database schema assertions for', 'fetch_trigger_by_name', 'trigger_exists?', 'triggers'
+ end
+
+ context 'when having tables' do
+ let(:schema_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Table }
+ let(:valid_schema_object_name) { 'my_table' }
+ let(:results) do
+ [
+ {
+ 'table_name' => 'my_table',
+ 'column_name' => 'id',
+ 'not_null' => true,
+ 'data_type' => 'bigint',
+ 'partition_key' => false,
+ 'column_default' => "nextval('audit_events_id_seq'::regclass)"
+ },
+ {
+ 'table_name' => 'my_table',
+ 'column_name' => 'details',
+ 'not_null' => false,
+ 'data_type' => 'text',
+ 'partition_key' => false,
+ 'column_default' => nil
+ }
+ ]
+ end
+
+ include_examples 'database schema assertions for', 'fetch_table_by_name', 'table_exists?', 'tables'
end
end
diff --git a/spec/lib/gitlab/database/schema_validation/inconsistency_spec.rb b/spec/lib/gitlab/database/schema_validation/inconsistency_spec.rb
new file mode 100644
index 00000000000..a49ff8339a1
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/inconsistency_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Inconsistency, feature_category: :database do
+ let(:validator) { Gitlab::Database::SchemaValidation::Validators::DifferentDefinitionIndexes }
+
+ let(:database_statement) { 'CREATE INDEX index_name ON public.achievements USING btree (namespace_id)' }
+ let(:structure_sql_statement) { 'CREATE INDEX index_name ON public.achievements USING btree (id)' }
+
+ let(:structure_stmt) { PgQuery.parse(structure_sql_statement).tree.stmts.first.stmt.index_stmt }
+ let(:database_stmt) { PgQuery.parse(database_statement).tree.stmts.first.stmt.index_stmt }
+
+ let(:structure_sql_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Index.new(structure_stmt) }
+ let(:database_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Index.new(database_stmt) }
+
+ subject(:inconsistency) { described_class.new(validator, structure_sql_object, database_object) }
+
+ describe '#object_name' do
+ it 'returns the index name' do
+ expect(inconsistency.object_name).to eq('index_name')
+ end
+ end
+
+ describe '#diff' do
+ it 'returns a diff between the structure.sql and the database' do
+ expect(inconsistency.diff).to be_a(Diffy::Diff)
+ expect(inconsistency.diff.string1).to eq("#{structure_sql_statement}\n")
+ expect(inconsistency.diff.string2).to eq("#{database_statement}\n")
+ end
+ end
+
+ describe '#error_message' do
+ it 'returns the error message' do
+ stub_const "#{validator}::ERROR_MESSAGE", 'error message %s'
+
+ expect(inconsistency.error_message).to eq('error message index_name')
+ end
+ end
+
+ describe '#type' do
+ it 'returns the type of the validator' do
+ expect(inconsistency.type).to eq('different_definition_indexes')
+ end
+ end
+
+ describe '#table_name' do
+ it 'returns the table name' do
+ expect(inconsistency.table_name).to eq('achievements')
+ end
+ end
+
+ describe '#object_type' do
+ it 'returns the structure sql object type' do
+ expect(inconsistency.object_type).to eq('Index')
+ end
+
+ context 'when the structure sql object is not available' do
+ subject(:inconsistency) { described_class.new(validator, nil, database_object) }
+
+ it 'returns the database object type' do
+ expect(inconsistency.object_type).to eq('Index')
+ end
+ end
+ end
+
+ describe '#structure_sql_statement' do
+ it 'returns structure sql statement' do
+ expect(inconsistency.structure_sql_statement).to eq("#{structure_sql_statement}\n")
+ end
+ end
+
+ describe '#database_statement' do
+ it 'returns database statement' do
+ expect(inconsistency.database_statement).to eq("#{database_statement}\n")
+ end
+ end
+
+ describe '#inspect' do
+ let(:expected_output) do
+ <<~MSG
+ ------------------------------------------------------
+ The index_name index has a different statement between structure.sql and database
+ Diff:
+ \e[31m-CREATE INDEX index_name ON public.achievements USING btree (id)\e[0m
+ \e[32m+CREATE INDEX index_name ON public.achievements USING btree (namespace_id)\e[0m
+
+ ------------------------------------------------------
+ MSG
+ end
+
+ it 'prints the inconsistency message' do
+ expect(inconsistency.inspect).to eql(expected_output)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/index_spec.rb b/spec/lib/gitlab/database/schema_validation/index_spec.rb
deleted file mode 100644
index 297211d79ed..00000000000
--- a/spec/lib/gitlab/database/schema_validation/index_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::Database::SchemaValidation::Index, feature_category: :database do
- let(:index_statement) { 'CREATE INDEX index_name ON public.achievements USING btree (namespace_id)' }
-
- let(:stmt) { PgQuery.parse(index_statement).tree.stmts.first.stmt.index_stmt }
-
- let(:index) { described_class.new(stmt) }
-
- describe '#name' do
- it 'returns index name' do
- expect(index.name).to eq('index_name')
- end
- end
-
- describe '#statement' do
- it 'returns index statement' do
- expect(index.statement).to eq(index_statement)
- end
- end
-end
diff --git a/spec/lib/gitlab/database/schema_validation/indexes_spec.rb b/spec/lib/gitlab/database/schema_validation/indexes_spec.rb
deleted file mode 100644
index 4351031a4b4..00000000000
--- a/spec/lib/gitlab/database/schema_validation/indexes_spec.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Database::SchemaValidation::Indexes, feature_category: :database do
- let(:structure_file_path) { Rails.root.join('spec/fixtures/structure.sql') }
- let(:database_indexes) do
- [
- ['wrong_index', 'CREATE UNIQUE INDEX wrong_index ON public.table_name (column_name)'],
- ['extra_index', 'CREATE INDEX extra_index ON public.table_name (column_name)'],
- ['index', 'CREATE UNIQUE INDEX "index" ON public.achievements USING btree (namespace_id, lower(name))']
- ]
- end
-
- let(:database_name) { 'main' }
-
- let(:database_model) { Gitlab::Database.database_base_models[database_name] }
-
- let(:connection) { database_model.connection }
-
- let(:query_result) { instance_double('ActiveRecord::Result', rows: database_indexes) }
-
- let(:database) { Gitlab::Database::SchemaValidation::Database.new(connection) }
- let(:structure_file) { Gitlab::Database::SchemaValidation::StructureSql.new(structure_file_path) }
-
- subject(:schema_validation) { described_class.new(structure_file, database) }
-
- before do
- allow(connection).to receive(:exec_query).and_return(query_result)
- end
-
- describe '#missing_indexes' do
- it 'returns missing indexes' do
- missing_indexes = %w[
- missing_index
- index_namespaces_public_groups_name_id
- index_on_deploy_keys_id_and_type_and_public
- index_users_on_public_email_excluding_null_and_empty
- ]
-
- expect(schema_validation.missing_indexes).to match_array(missing_indexes)
- end
- end
-
- describe '#extra_indexes' do
- it 'returns extra indexes' do
- expect(schema_validation.extra_indexes).to match_array(['extra_index'])
- end
- end
-
- describe '#wrong_indexes' do
- it 'returns wrong indexes' do
- expect(schema_validation.wrong_indexes).to match_array(['wrong_index'])
- end
- end
-end
diff --git a/spec/lib/gitlab/database/schema_validation/runner_spec.rb b/spec/lib/gitlab/database/schema_validation/runner_spec.rb
new file mode 100644
index 00000000000..f5d1c6ba31b
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/runner_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Runner, feature_category: :database do
+ let(:structure_file_path) { Rails.root.join('spec/fixtures/structure.sql') }
+ let(:connection) { ActiveRecord::Base.connection }
+
+ let(:database) { Gitlab::Database::SchemaValidation::Database.new(connection) }
+ let(:structure_sql) { Gitlab::Database::SchemaValidation::StructureSql.new(structure_file_path, 'public') }
+
+ describe '#execute' do
+ subject(:inconsistencies) { described_class.new(structure_sql, database).execute }
+
+ it 'returns inconsistencies' do
+ expect(inconsistencies).not_to be_empty
+ end
+
+ it 'execute all validators' do
+ all_validators = Gitlab::Database::SchemaValidation::Validators::BaseValidator.all_validators
+
+ expect(all_validators).to all(receive(:new).with(structure_sql, database).and_call_original)
+
+ inconsistencies
+ end
+
+ context 'when validators are passed' do
+ subject(:inconsistencies) { described_class.new(structure_sql, database, validators: validators).execute }
+
+ let(:class_name) { 'Gitlab::Database::SchemaValidation::Validators::ExtraIndexes' }
+ let(:inconsistency_class_name) { 'Gitlab::Database::SchemaValidation::Inconsistency' }
+
+ let(:extra_indexes) { class_double(class_name) }
+ let(:instace_extra_index) { instance_double(class_name, execute: [inconsistency]) }
+ let(:inconsistency) { instance_double(inconsistency_class_name, object_name: 'test') }
+
+ let(:validators) { [extra_indexes] }
+
+ it 'only execute the validators passed' do
+ expect(extra_indexes).to receive(:new).with(structure_sql, database).and_return(instace_extra_index)
+
+ Gitlab::Database::SchemaValidation::Validators::BaseValidator.all_validators.each do |validator|
+ expect(validator).not_to receive(:new).with(structure_sql, database)
+ end
+
+ expect(inconsistencies.map(&:object_name)).to eql ['test']
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/schema_inconsistency_spec.rb b/spec/lib/gitlab/database/schema_validation/schema_inconsistency_spec.rb
new file mode 100644
index 00000000000..7d6a279def9
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/schema_inconsistency_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::SchemaInconsistency, type: :model, feature_category: :database do
+ it { is_expected.to be_a ApplicationRecord }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:issue) }
+ end
+
+ describe "Validations" do
+ it { is_expected.to validate_presence_of(:object_name) }
+ it { is_expected.to validate_presence_of(:valitador_name) }
+ it { is_expected.to validate_presence_of(:table_name) }
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/schema_objects/column_spec.rb b/spec/lib/gitlab/database/schema_validation/schema_objects/column_spec.rb
new file mode 100644
index 00000000000..74bc5f43b50
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/schema_objects/column_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::SchemaObjects::Column, feature_category: :database do
+ subject(:column) { described_class.new(adapter) }
+
+ let(:database_adapter) { 'Gitlab::Database::SchemaValidation::Adapters::ColumnDatabaseAdapter' }
+ let(:adapter) do
+ instance_double(database_adapter, name: 'id', table_name: 'projects',
+ data_type: 'bigint', default: nil, nullable: 'NOT NULL')
+ end
+
+ describe '#name' do
+ it { expect(column.name).to eq('id') }
+ end
+
+ describe '#table_name' do
+ it { expect(column.table_name).to eq('projects') }
+ end
+
+ describe '#statement' do
+ it { expect(column.statement).to eq('id bigint NOT NULL') }
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/schema_objects/index_spec.rb b/spec/lib/gitlab/database/schema_validation/schema_objects/index_spec.rb
new file mode 100644
index 00000000000..43d8fa38ec8
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/schema_objects/index_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::SchemaObjects::Index, feature_category: :database do
+ let(:statement) { 'CREATE INDEX index_name ON public.achievements USING btree (namespace_id)' }
+ let(:name) { 'index_name' }
+ let(:table_name) { 'achievements' }
+
+ include_examples 'schema objects assertions for', 'index_stmt'
+end
diff --git a/spec/lib/gitlab/database/schema_validation/schema_objects/table_spec.rb b/spec/lib/gitlab/database/schema_validation/schema_objects/table_spec.rb
new file mode 100644
index 00000000000..60ea9581517
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/schema_objects/table_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::SchemaObjects::Table, feature_category: :database do
+ subject(:table) { described_class.new(name, columns) }
+
+ let(:name) { 'my_table' }
+ let(:column_class) { 'Gitlab::Database::SchemaValidation::SchemaObjects::Column' }
+ let(:columns) do
+ [
+ instance_double(column_class, name: 'id', statement: 'id bigint NOT NULL', partition_key?: false),
+ instance_double(column_class, name: 'col', statement: 'col text', partition_key?: false),
+ instance_double(column_class, name: 'partition', statement: 'partition integer DEFAULT 1', partition_key?: true)
+ ]
+ end
+
+ describe '#name' do
+ it { expect(table.name).to eq('my_table') }
+ end
+
+ describe '#table_name' do
+ it { expect(table.table_name).to eq('my_table') }
+ end
+
+ describe '#statement' do
+ it { expect(table.statement).to eq('CREATE TABLE my_table (id bigint NOT NULL, col text)') }
+
+ it 'ignores the partition column' do
+ expect(table.statement).not_to include('partition integer DEFAULT 1')
+ end
+ end
+
+ describe '#fetch_column_by_name' do
+ it { expect(table.fetch_column_by_name('col')).not_to be_nil }
+
+ it { expect(table.fetch_column_by_name('invalid')).to be_nil }
+ end
+
+ describe '#column_exists?' do
+ it { expect(table.column_exists?('col')).to eq(true) }
+
+ it { expect(table.column_exists?('invalid')).to eq(false) }
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/schema_objects/trigger_spec.rb b/spec/lib/gitlab/database/schema_validation/schema_objects/trigger_spec.rb
new file mode 100644
index 00000000000..3c2481dfae0
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/schema_objects/trigger_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::SchemaObjects::Trigger, feature_category: :database do
+ let(:statement) { 'CREATE TRIGGER my_trigger BEFORE INSERT ON todos FOR EACH ROW EXECUTE FUNCTION trigger()' }
+ let(:name) { 'my_trigger' }
+ let(:table_name) { 'todos' }
+
+ include_examples 'schema objects assertions for', 'create_trig_stmt'
+end
diff --git a/spec/lib/gitlab/database/schema_validation/structure_sql_spec.rb b/spec/lib/gitlab/database/schema_validation/structure_sql_spec.rb
new file mode 100644
index 00000000000..b0c056ff5db
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/structure_sql_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'structure sql schema assertions for' do |object_exists_method, all_objects_method|
+ subject(:structure_sql) { described_class.new(structure_file_path, schema_name) }
+
+ let(:structure_file_path) { Rails.root.join('spec/fixtures/structure.sql') }
+ let(:schema_name) { 'public' }
+
+ describe "##{object_exists_method}" do
+ it 'returns true when schema object exists' do
+ expect(structure_sql.public_send(object_exists_method, valid_schema_object_name)).to be_truthy
+ end
+
+ it 'returns false when schema object does not exists' do
+ expect(structure_sql.public_send(object_exists_method, 'invalid-object-name')).to be_falsey
+ end
+ end
+
+ describe "##{all_objects_method}" do
+ it 'returns all the schema objects' do
+ schema_objects = structure_sql.public_send(all_objects_method)
+
+ expect(schema_objects).to all(be_a(schema_object))
+ expect(schema_objects.map(&:name)).to eq(expected_objects)
+ end
+ end
+end
+
+RSpec.describe Gitlab::Database::SchemaValidation::StructureSql, feature_category: :database do
+ let(:structure_file_path) { Rails.root.join('spec/fixtures/structure.sql') }
+ let(:schema_name) { 'public' }
+
+ subject(:structure_sql) { described_class.new(structure_file_path, schema_name) }
+
+ context 'when having indexes' do
+ let(:schema_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Index }
+ let(:valid_schema_object_name) { 'index' }
+ let(:expected_objects) do
+ %w[missing_index wrong_index index index_namespaces_public_groups_name_id
+ index_on_deploy_keys_id_and_type_and_public index_users_on_public_email_excluding_null_and_empty]
+ end
+
+ include_examples 'structure sql schema assertions for', 'index_exists?', 'indexes'
+ end
+
+ context 'when having triggers' do
+ let(:schema_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Trigger }
+ let(:valid_schema_object_name) { 'trigger' }
+ let(:expected_objects) { %w[trigger wrong_trigger missing_trigger_1 projects_loose_fk_trigger] }
+
+ include_examples 'structure sql schema assertions for', 'trigger_exists?', 'triggers'
+ end
+
+ context 'when having tables' do
+ let(:schema_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Table }
+ let(:valid_schema_object_name) { 'test_table' }
+ let(:expected_objects) do
+ %w[test_table ci_project_mirrors wrong_table extra_table_columns missing_table missing_table_columns
+ operations_user_lists]
+ end
+
+ include_examples 'structure sql schema assertions for', 'table_exists?', 'tables'
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/track_inconsistency_spec.rb b/spec/lib/gitlab/database/schema_validation/track_inconsistency_spec.rb
new file mode 100644
index 00000000000..84db721fc2d
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/track_inconsistency_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::TrackInconsistency, feature_category: :database do
+ describe '#execute' do
+ let(:validator) { Gitlab::Database::SchemaValidation::Validators::DifferentDefinitionIndexes }
+
+ let(:database_statement) { 'CREATE INDEX index_name ON public.achievements USING btree (namespace_id)' }
+ let(:structure_sql_statement) { 'CREATE INDEX index_name ON public.achievements USING btree (id)' }
+
+ let(:structure_stmt) { PgQuery.parse(structure_sql_statement).tree.stmts.first.stmt.index_stmt }
+ let(:database_stmt) { PgQuery.parse(database_statement).tree.stmts.first.stmt.index_stmt }
+
+ let(:structure_sql_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Index.new(structure_stmt) }
+ let(:database_object) { Gitlab::Database::SchemaValidation::SchemaObjects::Index.new(database_stmt) }
+
+ let(:inconsistency) do
+ Gitlab::Database::SchemaValidation::Inconsistency.new(validator, structure_sql_object, database_object)
+ end
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ subject(:execute) { described_class.new(inconsistency, project, user).execute }
+
+ before do
+ stub_spam_services
+ end
+
+ context 'when is not GitLab.com' do
+ it 'does not create a schema inconsistency record' do
+ allow(Gitlab).to receive(:com?).and_return(false)
+
+ expect { execute }.not_to change { Gitlab::Database::SchemaValidation::SchemaInconsistency.count }
+ end
+ end
+
+ context 'when the issue creation fails' do
+ let(:issue_creation) { instance_double(Mutations::Issues::Create, resolve: { errors: 'error' }) }
+
+ before do
+ allow(Mutations::Issues::Create).to receive(:new).and_return(issue_creation)
+ end
+
+ it 'does not create a schema inconsistency record' do
+ allow(Gitlab).to receive(:com?).and_return(true)
+
+ expect { execute }.not_to change { Gitlab::Database::SchemaValidation::SchemaInconsistency.count }
+ end
+ end
+
+ context 'when a new inconsistency is found' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'creates a new schema inconsistency record' do
+ allow(Gitlab).to receive(:com?).and_return(true)
+
+ expect { execute }.to change { Gitlab::Database::SchemaValidation::SchemaInconsistency.count }
+ end
+ end
+
+ context 'when the schema inconsistency already exists' do
+ before do
+ project.add_developer(user)
+ end
+
+ let!(:schema_inconsistency) do
+ create(:schema_inconsistency, object_name: 'index_name', table_name: 'achievements',
+ valitador_name: 'different_definition_indexes')
+ end
+
+ it 'does not create a schema inconsistency record' do
+ allow(Gitlab).to receive(:com?).and_return(true)
+
+ expect { execute }.not_to change { Gitlab::Database::SchemaValidation::SchemaInconsistency.count }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/base_validator_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/base_validator_spec.rb
new file mode 100644
index 00000000000..036ad6424f0
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/base_validator_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::BaseValidator, feature_category: :database do
+ describe '.all_validators' do
+ subject(:all_validators) { described_class.all_validators }
+
+ it 'returns an array of all validators' do
+ expect(all_validators).to eq([
+ Gitlab::Database::SchemaValidation::Validators::ExtraTables,
+ Gitlab::Database::SchemaValidation::Validators::ExtraTableColumns,
+ Gitlab::Database::SchemaValidation::Validators::ExtraIndexes,
+ Gitlab::Database::SchemaValidation::Validators::ExtraTriggers,
+ Gitlab::Database::SchemaValidation::Validators::MissingTables,
+ Gitlab::Database::SchemaValidation::Validators::MissingTableColumns,
+ Gitlab::Database::SchemaValidation::Validators::MissingIndexes,
+ Gitlab::Database::SchemaValidation::Validators::MissingTriggers,
+ Gitlab::Database::SchemaValidation::Validators::DifferentDefinitionTables,
+ Gitlab::Database::SchemaValidation::Validators::DifferentDefinitionIndexes,
+ Gitlab::Database::SchemaValidation::Validators::DifferentDefinitionTriggers
+ ])
+ end
+ end
+
+ describe '#execute' do
+ let(:structure_sql) { instance_double(Gitlab::Database::SchemaValidation::StructureSql) }
+ let(:database) { instance_double(Gitlab::Database::SchemaValidation::Database) }
+
+ subject(:inconsistencies) { described_class.new(structure_sql, database).execute }
+
+ it 'raises an exception' do
+ expect { inconsistencies }.to raise_error(NoMethodError)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/different_definition_indexes_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/different_definition_indexes_spec.rb
new file mode 100644
index 00000000000..b9744c86b80
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/different_definition_indexes_spec.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::DifferentDefinitionIndexes,
+ feature_category: :database do
+ include_examples 'index validators', described_class, ['wrong_index']
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/different_definition_tables_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/different_definition_tables_spec.rb
new file mode 100644
index 00000000000..746418b757e
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/different_definition_tables_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::DifferentDefinitionTables, feature_category: :database do
+ include_examples 'table validators', described_class, ['wrong_table']
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/different_definition_triggers_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/different_definition_triggers_spec.rb
new file mode 100644
index 00000000000..4d065929708
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/different_definition_triggers_spec.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::DifferentDefinitionTriggers,
+ feature_category: :database do
+ include_examples 'trigger validators', described_class, ['wrong_trigger']
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/extra_indexes_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/extra_indexes_spec.rb
new file mode 100644
index 00000000000..842dbb42120
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/extra_indexes_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::ExtraIndexes, feature_category: :database do
+ include_examples 'index validators', described_class, ['extra_index']
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/extra_table_columns_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/extra_table_columns_spec.rb
new file mode 100644
index 00000000000..9d17a2fffa9
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/extra_table_columns_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::ExtraTableColumns, feature_category: :database do
+ include_examples 'table validators', described_class, ['extra_table_columns']
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/extra_tables_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/extra_tables_spec.rb
new file mode 100644
index 00000000000..edaf79e3c93
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/extra_tables_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::ExtraTables, feature_category: :database do
+ include_examples 'table validators', described_class, ['extra_table']
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/extra_triggers_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/extra_triggers_spec.rb
new file mode 100644
index 00000000000..d2e1c18a1ab
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/extra_triggers_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::ExtraTriggers, feature_category: :database do
+ include_examples 'trigger validators', described_class, ['extra_trigger']
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/missing_indexes_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/missing_indexes_spec.rb
new file mode 100644
index 00000000000..c402c3a2fa7
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/missing_indexes_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::MissingIndexes, feature_category: :database do
+ missing_indexes = %w[
+ missing_index
+ index_namespaces_public_groups_name_id
+ index_on_deploy_keys_id_and_type_and_public
+ index_users_on_public_email_excluding_null_and_empty
+ ]
+
+ include_examples 'index validators', described_class, missing_indexes
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/missing_table_columns_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/missing_table_columns_spec.rb
new file mode 100644
index 00000000000..de2956b4dd9
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/missing_table_columns_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::MissingTableColumns, feature_category: :database do
+ include_examples 'table validators', described_class, ['missing_table_columns']
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/missing_tables_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/missing_tables_spec.rb
new file mode 100644
index 00000000000..7c80923e860
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/missing_tables_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::MissingTables, feature_category: :database do
+ missing_tables = %w[ci_project_mirrors missing_table operations_user_lists test_table]
+
+ include_examples 'table validators', described_class, missing_tables
+end
diff --git a/spec/lib/gitlab/database/schema_validation/validators/missing_triggers_spec.rb b/spec/lib/gitlab/database/schema_validation/validators/missing_triggers_spec.rb
new file mode 100644
index 00000000000..87bc3ded808
--- /dev/null
+++ b/spec/lib/gitlab/database/schema_validation/validators/missing_triggers_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SchemaValidation::Validators::MissingTriggers, feature_category: :database do
+ missing_triggers = %w[missing_trigger_1 projects_loose_fk_trigger]
+
+ include_examples 'trigger validators', described_class, missing_triggers
+end
diff --git a/spec/lib/gitlab/database/tables_locker_spec.rb b/spec/lib/gitlab/database/tables_locker_spec.rb
index d74f455eaad..aaafe27f7ca 100644
--- a/spec/lib/gitlab/database/tables_locker_spec.rb
+++ b/spec/lib/gitlab/database/tables_locker_spec.rb
@@ -2,20 +2,42 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::TablesLocker, :reestablished_active_record_base, :delete, :silence_stdout,
- :suppress_gitlab_schemas_validate_connection, feature_category: :pods do
- let(:detached_partition_table) { '_test_gitlab_main_part_20220101' }
- let(:lock_writes_manager) do
- instance_double(Gitlab::Database::LockWritesManager, lock_writes: nil, unlock_writes: nil)
+RSpec.describe Gitlab::Database::TablesLocker, :suppress_gitlab_schemas_validate_connection, :silence_stdout,
+ feature_category: :cell do
+ let(:default_lock_writes_manager) do
+ instance_double(
+ Gitlab::Database::LockWritesManager,
+ lock_writes: { action: 'any action' },
+ unlock_writes: { action: 'unlocked' }
+ )
end
before do
- allow(Gitlab::Database::LockWritesManager).to receive(:new).with(any_args).and_return(lock_writes_manager)
+ allow(Gitlab::Database::LockWritesManager).to receive(:new).with(any_args).and_return(default_lock_writes_manager)
+ # Limiting the scope of the tests to a subset of the database tables
+ allow(Gitlab::Database::GitlabSchema).to receive(:tables_to_schema).and_return({
+ 'application_setttings' => :gitlab_main_clusterwide,
+ 'projects' => :gitlab_main,
+ 'security_findings' => :gitlab_main,
+ 'ci_builds' => :gitlab_ci,
+ 'ci_jobs' => :gitlab_ci,
+ 'loose_foreign_keys_deleted_records' => :gitlab_shared,
+ 'ar_internal_metadata' => :gitlab_internal
+ })
end
before(:all) do
+ create_partition_sql = <<~SQL
+ CREATE TABLE IF NOT EXISTS #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.security_findings_test_partition
+ PARTITION OF security_findings
+ FOR VALUES IN (0)
+ SQL
+
+ ApplicationRecord.connection.execute(create_partition_sql)
+ Ci::ApplicationRecord.connection.execute(create_partition_sql)
+
create_detached_partition_sql = <<~SQL
- CREATE TABLE IF NOT EXISTS gitlab_partitions_dynamic._test_gitlab_main_part_20220101 (
+ CREATE TABLE IF NOT EXISTS #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_gitlab_main_part_202201 (
id bigserial primary key not null
)
SQL
@@ -29,35 +51,89 @@ RSpec.describe Gitlab::Database::TablesLocker, :reestablished_active_record_base
drop_after: Time.current
)
end
+ Gitlab::Database::SharedModel.using_connection(Ci::ApplicationRecord.connection) do
+ Postgresql::DetachedPartition.create!(
+ table_name: '_test_gitlab_main_part_20220101',
+ drop_after: Time.current
+ )
+ end
end
- after(:all) do
- drop_detached_partition_sql = <<~SQL
- DROP TABLE IF EXISTS gitlab_partitions_dynamic._test_gitlab_main_part_20220101
- SQL
+ shared_examples "lock tables" do |gitlab_schema, database_name|
+ let(:connection) { Gitlab::Database.database_base_models[database_name].connection }
+ let(:tables_to_lock) do
+ Gitlab::Database::GitlabSchema
+ .tables_to_schema.filter_map { |table_name, schema| table_name if schema == gitlab_schema }
+ end
- ApplicationRecord.connection.execute(drop_detached_partition_sql)
- Ci::ApplicationRecord.connection.execute(drop_detached_partition_sql)
+ it "locks table in schema #{gitlab_schema} and database #{database_name}" do
+ expect(tables_to_lock).not_to be_empty
- Gitlab::Database::SharedModel.using_connection(ApplicationRecord.connection) do
- Postgresql::DetachedPartition.delete_all
+ tables_to_lock.each do |table_name|
+ lock_writes_manager = instance_double(Gitlab::Database::LockWritesManager, lock_writes: nil)
+
+ expect(Gitlab::Database::LockWritesManager).to receive(:new).with(
+ table_name: table_name,
+ connection: connection,
+ database_name: database_name,
+ with_retries: true,
+ logger: anything,
+ dry_run: anything
+ ).once.and_return(lock_writes_manager)
+ expect(lock_writes_manager).to receive(:lock_writes).once
+ end
+
+ subject
+ end
+
+ it 'returns list of actions' do
+ expect(subject).to include({ action: 'any action' })
end
end
- shared_examples "lock tables" do |table_schema, database_name|
- let(:table_name) do
+ shared_examples "unlock tables" do |gitlab_schema, database_name|
+ let(:connection) { Gitlab::Database.database_base_models[database_name].connection }
+
+ let(:tables_to_unlock) do
Gitlab::Database::GitlabSchema
- .tables_to_schema.filter_map { |table_name, schema| table_name if schema == table_schema }
- .first
+ .tables_to_schema.filter_map { |table_name, schema| table_name if schema == gitlab_schema }
+ end
+
+ it "unlocks table in schema #{gitlab_schema} and database #{database_name}" do
+ expect(tables_to_unlock).not_to be_empty
+
+ tables_to_unlock.each do |table_name|
+ lock_writes_manager = instance_double(Gitlab::Database::LockWritesManager, unlock_writes: nil)
+
+ expect(Gitlab::Database::LockWritesManager).to receive(:new).with(
+ table_name: table_name,
+ connection: anything,
+ database_name: database_name,
+ with_retries: true,
+ logger: anything,
+ dry_run: anything
+ ).once.and_return(lock_writes_manager)
+ expect(lock_writes_manager).to receive(:unlock_writes)
+ end
+
+ subject
end
- let(:database) { database_name }
+ it 'returns list of actions' do
+ expect(subject).to include({ action: 'unlocked' })
+ end
+ end
+
+ shared_examples "lock partitions" do |partition_identifier, database_name|
+ let(:connection) { Gitlab::Database.database_base_models[database_name].connection }
+
+ it 'locks the partition' do
+ lock_writes_manager = instance_double(Gitlab::Database::LockWritesManager, lock_writes: nil)
- it "locks table in schema #{table_schema} and database #{database_name}" do
expect(Gitlab::Database::LockWritesManager).to receive(:new).with(
- table_name: table_name,
- connection: anything,
- database_name: database,
+ table_name: partition_identifier,
+ connection: connection,
+ database_name: database_name,
with_retries: true,
logger: anything,
dry_run: anything
@@ -68,20 +144,16 @@ RSpec.describe Gitlab::Database::TablesLocker, :reestablished_active_record_base
end
end
- shared_examples "unlock tables" do |table_schema, database_name|
- let(:table_name) do
- Gitlab::Database::GitlabSchema
- .tables_to_schema.filter_map { |table_name, schema| table_name if schema == table_schema }
- .first
- end
+ shared_examples "unlock partitions" do |partition_identifier, database_name|
+ let(:connection) { Gitlab::Database.database_base_models[database_name].connection }
- let(:database) { database_name }
+ it 'unlocks the partition' do
+ lock_writes_manager = instance_double(Gitlab::Database::LockWritesManager, unlock_writes: nil)
- it "unlocks table in schema #{table_schema} and database #{database_name}" do
expect(Gitlab::Database::LockWritesManager).to receive(:new).with(
- table_name: table_name,
- connection: anything,
- database_name: database,
+ table_name: partition_identifier,
+ connection: connection,
+ database_name: database_name,
with_retries: true,
logger: anything,
dry_run: anything
@@ -94,31 +166,35 @@ RSpec.describe Gitlab::Database::TablesLocker, :reestablished_active_record_base
context 'when running on single database' do
before do
- skip_if_multiple_databases_are_setup(:ci)
+ skip_if_database_exists(:ci)
end
describe '#lock_writes' do
subject { described_class.new.lock_writes }
- it 'does not call Gitlab::Database::LockWritesManager.lock_writes' do
- expect(Gitlab::Database::LockWritesManager).to receive(:new).with(any_args).and_return(lock_writes_manager)
- expect(lock_writes_manager).not_to receive(:lock_writes)
+ it 'does not lock any table' do
+ expect(Gitlab::Database::LockWritesManager).to receive(:new)
+ .with(any_args).and_return(default_lock_writes_manager)
+ expect(default_lock_writes_manager).not_to receive(:lock_writes)
subject
end
- include_examples "unlock tables", :gitlab_main, 'main'
- include_examples "unlock tables", :gitlab_ci, 'ci'
- include_examples "unlock tables", :gitlab_shared, 'main'
- include_examples "unlock tables", :gitlab_internal, 'main'
+ it_behaves_like 'unlock tables', :gitlab_main, 'main'
+ it_behaves_like 'unlock tables', :gitlab_ci, 'main'
+ it_behaves_like 'unlock tables', :gitlab_main_clusterwide, 'main'
+ it_behaves_like 'unlock tables', :gitlab_shared, 'main'
+ it_behaves_like 'unlock tables', :gitlab_internal, 'main'
end
describe '#unlock_writes' do
subject { described_class.new.lock_writes }
it 'does call Gitlab::Database::LockWritesManager.unlock_writes' do
- expect(Gitlab::Database::LockWritesManager).to receive(:new).with(any_args).and_return(lock_writes_manager)
- expect(lock_writes_manager).to receive(:unlock_writes)
+ expect(Gitlab::Database::LockWritesManager).to receive(:new)
+ .with(any_args).and_return(default_lock_writes_manager)
+ expect(default_lock_writes_manager).to receive(:unlock_writes)
+ expect(default_lock_writes_manager).not_to receive(:lock_writes)
subject
end
@@ -127,49 +203,67 @@ RSpec.describe Gitlab::Database::TablesLocker, :reestablished_active_record_base
context 'when running on multiple databases' do
before do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
end
describe '#lock_writes' do
subject { described_class.new.lock_writes }
- include_examples "lock tables", :gitlab_ci, 'main'
- include_examples "lock tables", :gitlab_main, 'ci'
-
- include_examples "unlock tables", :gitlab_main, 'main'
- include_examples "unlock tables", :gitlab_ci, 'ci'
- include_examples "unlock tables", :gitlab_shared, 'main'
- include_examples "unlock tables", :gitlab_shared, 'ci'
- include_examples "unlock tables", :gitlab_internal, 'main'
- include_examples "unlock tables", :gitlab_internal, 'ci'
+ it_behaves_like 'lock tables', :gitlab_ci, 'main'
+ it_behaves_like 'lock tables', :gitlab_main, 'ci'
+ it_behaves_like 'lock tables', :gitlab_main_clusterwide, 'ci'
+
+ it_behaves_like 'unlock tables', :gitlab_main_clusterwide, 'main'
+ it_behaves_like 'unlock tables', :gitlab_main, 'main'
+ it_behaves_like 'unlock tables', :gitlab_ci, 'ci'
+ it_behaves_like 'unlock tables', :gitlab_shared, 'main'
+ it_behaves_like 'unlock tables', :gitlab_shared, 'ci'
+ it_behaves_like 'unlock tables', :gitlab_internal, 'main'
+ it_behaves_like 'unlock tables', :gitlab_internal, 'ci'
+
+ gitlab_main_partition = "#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.security_findings_test_partition"
+ it_behaves_like 'unlock partitions', gitlab_main_partition, 'main'
+ it_behaves_like 'lock partitions', gitlab_main_partition, 'ci'
+
+ gitlab_main_detached_partition = "#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_gitlab_main_part_20220101"
+ it_behaves_like 'unlock partitions', gitlab_main_detached_partition, 'main'
+ it_behaves_like 'lock partitions', gitlab_main_detached_partition, 'ci'
end
describe '#unlock_writes' do
subject { described_class.new.unlock_writes }
- include_examples "unlock tables", :gitlab_ci, 'main'
- include_examples "unlock tables", :gitlab_main, 'ci'
- include_examples "unlock tables", :gitlab_main, 'main'
- include_examples "unlock tables", :gitlab_ci, 'ci'
- include_examples "unlock tables", :gitlab_shared, 'main'
- include_examples "unlock tables", :gitlab_shared, 'ci'
- include_examples "unlock tables", :gitlab_internal, 'main'
- include_examples "unlock tables", :gitlab_internal, 'ci'
+ it_behaves_like "unlock tables", :gitlab_ci, 'main'
+ it_behaves_like "unlock tables", :gitlab_main, 'ci'
+ it_behaves_like "unlock tables", :gitlab_main, 'main'
+ it_behaves_like "unlock tables", :gitlab_ci, 'ci'
+ it_behaves_like "unlock tables", :gitlab_shared, 'main'
+ it_behaves_like "unlock tables", :gitlab_shared, 'ci'
+ it_behaves_like "unlock tables", :gitlab_internal, 'main'
+ it_behaves_like "unlock tables", :gitlab_internal, 'ci'
+
+ gitlab_main_partition = "#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.security_findings_test_partition"
+ it_behaves_like 'unlock partitions', gitlab_main_partition, 'main'
+ it_behaves_like 'unlock partitions', gitlab_main_partition, 'ci'
+
+ gitlab_main_detached_partition = "#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_gitlab_main_part_20220101"
+ it_behaves_like 'unlock partitions', gitlab_main_detached_partition, 'main'
+ it_behaves_like 'unlock partitions', gitlab_main_detached_partition, 'ci'
end
context 'when running in dry_run mode' do
subject { described_class.new(dry_run: true).lock_writes }
- it 'passes dry_run flag to LockManger' do
+ it 'passes dry_run flag to LockWritesManager' do
expect(Gitlab::Database::LockWritesManager).to receive(:new).with(
- table_name: 'users',
+ table_name: 'security_findings',
connection: anything,
database_name: 'ci',
with_retries: true,
logger: anything,
dry_run: true
- ).and_return(lock_writes_manager)
- expect(lock_writes_manager).to receive(:lock_writes)
+ ).and_return(default_lock_writes_manager)
+ expect(default_lock_writes_manager).to receive(:lock_writes)
subject
end
@@ -185,8 +279,9 @@ RSpec.describe Gitlab::Database::TablesLocker, :reestablished_active_record_base
end
it 'does not lock any tables if the ci database is shared with main database' do
- expect(Gitlab::Database::LockWritesManager).to receive(:new).with(any_args).and_return(lock_writes_manager)
- expect(lock_writes_manager).not_to receive(:lock_writes)
+ expect(Gitlab::Database::LockWritesManager).to receive(:new)
+ .with(any_args).and_return(default_lock_writes_manager)
+ expect(default_lock_writes_manager).not_to receive(:lock_writes)
subject
end
@@ -220,7 +315,3 @@ RSpec.describe Gitlab::Database::TablesLocker, :reestablished_active_record_base
end
end
end
-
-def number_of_triggers(connection)
- connection.select_value("SELECT count(*) FROM information_schema.triggers")
-end
diff --git a/spec/lib/gitlab/database/tables_truncate_spec.rb b/spec/lib/gitlab/database/tables_truncate_spec.rb
index 3bb2f4e982c..ef76c9b8da3 100644
--- a/spec/lib/gitlab/database/tables_truncate_spec.rb
+++ b/spec/lib/gitlab/database/tables_truncate_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::TablesTruncate, :reestablished_active_record_base,
- :suppress_gitlab_schemas_validate_connection, feature_category: :pods do
+ :suppress_gitlab_schemas_validate_connection, feature_category: :cell do
include MigrationsHelpers
let(:min_batch_size) { 1 }
@@ -48,7 +48,7 @@ RSpec.describe Gitlab::Database::TablesTruncate, :reestablished_active_record_ba
end
before do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
# Creating some test tables on the main database
main_tables_sql = <<~SQL
@@ -79,8 +79,7 @@ RSpec.describe Gitlab::Database::TablesTruncate, :reestablished_active_record_ba
ALTER TABLE _test_gitlab_hook_logs DETACH PARTITION gitlab_partitions_dynamic._test_gitlab_hook_logs_202201;
SQL
- main_connection.execute(main_tables_sql)
- ci_connection.execute(main_tables_sql)
+ execute_on_each_database(main_tables_sql)
ci_tables_sql = <<~SQL
CREATE TABLE _test_gitlab_ci_items (id serial NOT NULL PRIMARY KEY);
@@ -92,15 +91,13 @@ RSpec.describe Gitlab::Database::TablesTruncate, :reestablished_active_record_ba
);
SQL
- main_connection.execute(ci_tables_sql)
- ci_connection.execute(ci_tables_sql)
+ execute_on_each_database(ci_tables_sql)
internal_tables_sql = <<~SQL
CREATE TABLE _test_gitlab_shared_items (id serial NOT NULL PRIMARY KEY);
SQL
- main_connection.execute(internal_tables_sql)
- ci_connection.execute(internal_tables_sql)
+ execute_on_each_database(internal_tables_sql)
# Filling the tables
5.times do |i|
@@ -156,7 +153,9 @@ RSpec.describe Gitlab::Database::TablesTruncate, :reestablished_active_record_ba
"_test_gitlab_ci_items" => :gitlab_ci,
"_test_gitlab_ci_references" => :gitlab_ci,
"_test_gitlab_shared_items" => :gitlab_shared,
- "_test_gitlab_geo_items" => :gitlab_geo
+ "_test_gitlab_geo_items" => :gitlab_geo,
+ "detached_partitions" => :gitlab_shared,
+ "postgres_partitions" => :gitlab_shared
}
)
@@ -314,8 +313,7 @@ RSpec.describe Gitlab::Database::TablesTruncate, :reestablished_active_record_ba
context 'when running with multiple shared databases' do
before do
skip_if_multiple_databases_not_setup(:ci)
- ci_db_config = Ci::ApplicationRecord.connection_db_config
- allow(::Gitlab::Database).to receive(:db_config_share_with).with(ci_db_config).and_return('main')
+ skip_if_database_exists(:ci)
end
it 'raises an error when truncating the main database that it is a single database setup' do
diff --git a/spec/lib/gitlab/database/transaction_timeout_settings_spec.rb b/spec/lib/gitlab/database/transaction_timeout_settings_spec.rb
index 5b68f9a3757..2725b22ca9d 100644
--- a/spec/lib/gitlab/database/transaction_timeout_settings_spec.rb
+++ b/spec/lib/gitlab/database/transaction_timeout_settings_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database::TransactionTimeoutSettings, feature_category: :pods do
+RSpec.describe Gitlab::Database::TransactionTimeoutSettings, feature_category: :cell do
let(:connection) { ActiveRecord::Base.connection }
subject { described_class.new(connection) }
diff --git a/spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb b/spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb
index 9ccae754a92..82bba31193b 100644
--- a/spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb
+++ b/spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb
@@ -61,12 +61,12 @@ RSpec.describe Gitlab::Database::WithLockRetriesOutsideTransaction, feature_cate
context 'lock_fiber' do
it 'acquires lock successfully' do
- check_exclusive_lock_query = """
+ check_exclusive_lock_query = <<~QUERY
SELECT 1
FROM pg_locks l
JOIN pg_class t ON l.relation = t.oid
WHERE t.relkind = 'r' AND l.mode = 'ExclusiveLock' AND t.relname = '#{Project.table_name}'
- """
+ QUERY
expect(connection.execute(check_exclusive_lock_query).to_a).to be_present
end
diff --git a/spec/lib/gitlab/database/with_lock_retries_spec.rb b/spec/lib/gitlab/database/with_lock_retries_spec.rb
index 7fe6362634b..7e0435c815b 100644
--- a/spec/lib/gitlab/database/with_lock_retries_spec.rb
+++ b/spec/lib/gitlab/database/with_lock_retries_spec.rb
@@ -61,12 +61,12 @@ RSpec.describe Gitlab::Database::WithLockRetries, feature_category: :database do
context 'lock_fiber' do
it 'acquires lock successfully' do
- check_exclusive_lock_query = """
+ check_exclusive_lock_query = <<~QUERY
SELECT 1
FROM pg_locks l
JOIN pg_class t ON l.relation = t.oid
WHERE t.relkind = 'r' AND l.mode = 'ExclusiveLock' AND t.relname = '#{Project.table_name}'
- """
+ QUERY
expect(connection.execute(check_exclusive_lock_query).to_a).to be_present
end
diff --git a/spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb b/spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb
deleted file mode 100644
index 68c29bad287..00000000000
--- a/spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb
+++ /dev/null
@@ -1,169 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::DatabaseImporters::InstanceAdministrators::CreateGroup do
- describe '#execute' do
- let(:result) { subject.execute }
-
- context 'without application_settings' do
- it 'returns error' do
- expect(subject).to receive(:log_error).and_call_original
- expect(result).to eq(
- status: :error,
- message: 'No application_settings found',
- last_step: :validate_application_settings
- )
-
- expect(Group.count).to eq(0)
- end
- end
-
- context 'without admin users' do
- let(:application_setting) { Gitlab::CurrentSettings.current_application_settings }
-
- before do
- allow(ApplicationSetting).to receive(:current_without_cache) { application_setting }
- end
-
- it 'returns error' do
- expect(subject).to receive(:log_error).and_call_original
- expect(result).to eq(
- status: :error,
- message: 'No active admin user found',
- last_step: :validate_admins
- )
-
- expect(Group.count).to eq(0)
- end
- end
-
- context(
- 'with application settings and admin users',
- :do_not_mock_admin_mode_setting,
- :do_not_stub_snowplow_by_default
- ) do
- let(:group) { result[:group] }
- let(:application_setting) { Gitlab::CurrentSettings.current_application_settings }
-
- let!(:user) { create(:user, :admin) }
-
- before do
- allow(ApplicationSetting).to receive(:current_without_cache) { application_setting }
- end
-
- it 'returns correct keys' do
- expect(result.keys).to contain_exactly(
- :status, :group
- )
- end
-
- it "tracks successful install" do
- expect(::Gitlab::Tracking).to receive(:event).with(
- 'instance_administrators_group', 'group_created', namespace: group
- )
-
- subject.execute
- end
-
- it 'creates group' do
- expect(result[:status]).to eq(:success)
- expect(group).to be_persisted
- expect(group.name).to eq('GitLab Instance')
- expect(group.path).to start_with('gitlab-instance')
- expect(group.path.split('-').last.length).to eq(8)
- expect(group.visibility_level).to eq(described_class::VISIBILITY_LEVEL)
- end
-
- it 'adds all admins as maintainers' do
- admin1 = create(:user, :admin)
- admin2 = create(:user, :admin)
- create(:user)
-
- expect(result[:status]).to eq(:success)
- group.reset
- expect(group.members.collect(&:user)).to contain_exactly(user, admin1, admin2)
- expect(group.members.collect(&:access_level)).to contain_exactly(
- Gitlab::Access::OWNER,
- Gitlab::Access::MAINTAINER,
- Gitlab::Access::MAINTAINER
- )
- end
-
- it 'saves the group id' do
- expect(result[:status]).to eq(:success)
- expect(application_setting.instance_administrators_group_id).to eq(group.id)
- end
-
- it 'returns error when saving group ID fails' do
- allow(application_setting).to receive(:save) { false }
-
- expect(result).to eq(
- status: :error,
- message: 'Could not save group ID',
- last_step: :save_group_id
- )
- end
-
- context 'when group already exists' do
- let(:existing_group) { create(:group) }
-
- before do
- admin1 = create(:user, :admin)
- admin2 = create(:user, :admin)
-
- existing_group.add_owner(user)
- existing_group.add_members([admin1, admin2], Gitlab::Access::MAINTAINER)
-
- application_setting.instance_administrators_group_id = existing_group.id
- end
-
- it 'returns success' do
- expect(result).to eq(
- status: :success,
- group: existing_group
- )
-
- expect(Group.count).to eq(1)
- end
- end
-
- context 'when group cannot be created' do
- let(:group) { build(:group) }
-
- before do
- group.errors.add(:base, "Test error")
-
- expect_next_instance_of(::Groups::CreateService) do |group_create_service|
- expect(group_create_service).to receive(:execute)
- .and_return(group)
- end
- end
-
- it 'returns error' do
- expect(subject).to receive(:log_error).and_call_original
- expect(result).to eq(
- status: :error,
- message: 'Could not create group',
- last_step: :create_group
- )
- end
- end
-
- context 'when user cannot be added to group' do
- before do
- subject.instance_variable_set(:@instance_admins, [user, build(:user, :admin)])
- end
-
- it 'returns error' do
- expect(subject).to receive(:log_error).and_call_original
- expect(result).to eq(
- status: :error,
- message: 'Could not add admins as members',
- last_step: :add_group_members
- )
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb b/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb
deleted file mode 100644
index ad91320c6eb..00000000000
--- a/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb
+++ /dev/null
@@ -1,315 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do
- describe '#execute' do
- let(:result) { subject.execute }
-
- let(:prometheus_settings) do
- {
- enabled: true,
- server_address: 'localhost:9090'
- }
- end
-
- before do
- stub_config(prometheus: prometheus_settings)
- end
-
- context 'without application_settings' do
- it 'returns error' do
- expect(subject).to receive(:log_error).and_call_original
- expect(result).to eq(
- status: :error,
- message: 'No application_settings found',
- last_step: :validate_application_settings
- )
-
- expect(Project.count).to eq(0)
- expect(Group.count).to eq(0)
- end
- end
-
- context 'without admin users' do
- let(:application_setting) { Gitlab::CurrentSettings.current_application_settings }
-
- before do
- allow(ApplicationSetting).to receive(:current_without_cache) { application_setting }
- end
-
- it 'returns error' do
- expect(result).to eq(
- status: :error,
- message: 'No active admin user found',
- last_step: :create_group
- )
-
- expect(Project.count).to eq(0)
- expect(Group.count).to eq(0)
- end
- end
-
- context 'with application settings and admin users', :request_store do
- let(:project) { result[:project] }
- let(:group) { result[:group] }
- let(:application_setting) { Gitlab::CurrentSettings.current_application_settings }
-
- let!(:user) { create(:user, :admin) }
-
- before do
- stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
-
- application_setting.update!(allow_local_requests_from_web_hooks_and_services: true)
- end
-
- shared_examples 'has prometheus integration' do |server_address|
- it do
- expect(result[:status]).to eq(:success)
-
- prometheus = project.prometheus_integration
- expect(prometheus).not_to eq(nil)
- expect(prometheus.api_url).to eq(server_address)
- expect(prometheus.active).to eq(true)
- expect(prometheus.manual_configuration).to eq(true)
- end
- end
-
- it_behaves_like 'has prometheus integration', 'http://localhost:9090'
-
- it 'is idempotent' do
- result1 = subject.execute
- expect(result1[:status]).to eq(:success)
-
- result2 = subject.execute
- expect(result2[:status]).to eq(:success)
- end
-
- it "tracks successful install" do
- expect(::Gitlab::Tracking).to receive(:event).with("instance_administrators_group", "group_created", namespace: project.namespace)
- expect(::Gitlab::Tracking).to receive(:event).with('self_monitoring', 'project_created', project: project, namespace: project.namespace)
-
- subject.execute
- end
-
- it 'creates group' do
- expect(result[:status]).to eq(:success)
- expect(group).to be_persisted
- end
-
- it 'creates project with internal visibility' do
- expect(result[:status]).to eq(:success)
- expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL)
- expect(project).to be_persisted
- end
-
- it 'creates project with internal visibility even when internal visibility is restricted' do
- application_setting.restricted_visibility_levels = [Gitlab::VisibilityLevel::INTERNAL]
-
- expect(result[:status]).to eq(:success)
- expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL)
- expect(project).to be_persisted
- end
-
- it 'creates project with correct name and description' do
- path = 'administration/monitoring/gitlab_self_monitoring_project/index'
- docs_path = Rails.application.routes.url_helpers.help_page_path(path)
-
- expect(result[:status]).to eq(:success)
- expect(project.name).to eq(described_class::PROJECT_NAME)
- expect(project.description).to eq(
- 'This project is automatically generated and helps monitor this GitLab instance. ' \
- "[Learn more](#{docs_path})."
- )
- expect(File).to exist("doc/#{path}.md")
- end
-
- it 'creates project with group as owner' do
- expect(result[:status]).to eq(:success)
- expect(project.owner).to eq(group)
- end
-
- it 'saves the project id' do
- expect(result[:status]).to eq(:success)
- expect(application_setting.reload.self_monitoring_project_id).to eq(project.id)
- end
-
- it 'creates a Prometheus integration' do
- expect(result[:status]).to eq(:success)
-
- integrations = result[:project].reload.integrations
-
- expect(integrations.count).to eq(1)
- # Ensures Integrations::Prometheus#self_monitoring_project? is true
- expect(integrations.first.allow_local_api_url?).to be_truthy
- end
-
- it 'creates an environment for the project' do
- expect(project.default_environment.name).to eq('production')
- end
-
- context 'when the environment creation fails' do
- let(:environment) { build(:environment, name: 'production') }
-
- it 'returns error' do
- allow(Environment).to receive(:new).and_return(environment)
- allow(environment).to receive(:save).and_return(false)
-
- expect(result).to eq(
- status: :error,
- message: 'Could not create environment',
- last_step: :create_environment
- )
- end
- end
-
- it 'returns error when saving project ID fails' do
- allow(subject.application_settings).to receive(:update).and_call_original
- allow(subject.application_settings).to receive(:update)
- .with(self_monitoring_project_id: anything)
- .and_return(false)
-
- expect(result).to eq(
- status: :error,
- message: 'Could not save project ID',
- last_step: :save_project_id
- )
- end
-
- context 'when project already exists' do
- let(:existing_group) { create(:group) }
- let(:existing_project) { create(:project, namespace: existing_group) }
-
- before do
- application_setting.update!(
- instance_administrators_group_id: existing_group.id, self_monitoring_project_id: existing_project.id)
- end
-
- it 'returns success' do
- expect(result).to include(status: :success)
-
- expect(Project.count).to eq(1)
- expect(Group.count).to eq(1)
- end
- end
-
- context 'when local requests from hooks and integrations are not allowed' do
- before do
- application_setting.update!(allow_local_requests_from_web_hooks_and_services: false)
- end
-
- it_behaves_like 'has prometheus integration', 'http://localhost:9090'
- end
-
- context 'with non default prometheus address' do
- let(:server_address) { 'https://localhost:9090' }
-
- let(:prometheus_settings) do
- {
- enabled: true,
- server_address: server_address
- }
- end
-
- it_behaves_like 'has prometheus integration', 'https://localhost:9090'
-
- context 'with :9090 symbol' do
- let(:server_address) { :':9090' }
-
- it_behaves_like 'has prometheus integration', 'http://localhost:9090'
- end
-
- context 'with 0.0.0.0:9090' do
- let(:server_address) { '0.0.0.0:9090' }
-
- it_behaves_like 'has prometheus integration', 'http://localhost:9090'
- end
- end
-
- context 'when prometheus setting is not present in gitlab.yml' do
- before do
- allow(Gitlab.config).to receive(:prometheus).and_raise(Settingslogic::MissingSetting)
- end
-
- it 'does not fail' do
- expect(result).to include(status: :success)
- expect(project.prometheus_integration).to be_nil
- end
- end
-
- context 'when prometheus setting is nil' do
- before do
- stub_config(prometheus: nil)
- end
-
- it 'does not fail' do
- expect(result).to include(status: :success)
- expect(project.prometheus_integration).to be_nil
- end
- end
-
- context 'when prometheus setting is disabled in gitlab.yml' do
- let(:prometheus_settings) do
- {
- enabled: false,
- server_address: 'http://localhost:9090'
- }
- end
-
- it 'does not configure prometheus' do
- expect(result).to include(status: :success)
- expect(project.prometheus_integration).to be_nil
- end
- end
-
- context 'when prometheus server address is blank in gitlab.yml' do
- let(:prometheus_settings) { { enabled: true, server_address: '' } }
-
- it 'does not configure prometheus' do
- expect(result).to include(status: :success)
- expect(project.prometheus_integration).to be_nil
- end
- end
-
- context 'when project cannot be created' do
- let(:project) { build(:project) }
-
- before do
- project.errors.add(:base, "Test error")
-
- expect_next_instance_of(::Projects::CreateService) do |project_create_service|
- expect(project_create_service).to receive(:execute)
- .and_return(project)
- end
- end
-
- it 'returns error' do
- expect(subject).to receive(:log_error).and_call_original
- expect(result).to eq(
- status: :error,
- message: 'Could not create project',
- last_step: :create_project
- )
- end
- end
-
- context 'when prometheus manual configuration cannot be saved' do
- let(:prometheus_settings) do
- {
- enabled: true,
- server_address: 'httpinvalid://localhost:9090'
- }
- end
-
- it 'returns error' do
- expect(subject).to receive(:log_error).and_call_original
- expect(result).to eq(
- status: :error,
- message: 'Could not save prometheus manual configuration',
- last_step: :add_prometheus_manual_configuration
- )
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/database_importers/self_monitoring/project/delete_service_spec.rb b/spec/lib/gitlab/database_importers/self_monitoring/project/delete_service_spec.rb
deleted file mode 100644
index d878d46c883..00000000000
--- a/spec/lib/gitlab/database_importers/self_monitoring/project/delete_service_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService do
- describe '#execute' do
- let!(:application_setting) { create(:application_setting) }
- let(:result) { subject.execute }
-
- context 'when project does not exist' do
- it 'returns error' do
- expect(result).to eq(
- status: :error,
- message: 'Self-monitoring project does not exist',
- last_step: :validate_self_monitoring_project_exists
- )
- end
- end
-
- context 'when self-monitoring project exists' do
- let(:group) { create(:group) }
- let(:project) { create(:project, namespace: group) }
-
- let(:application_setting) do
- create(
- :application_setting,
- self_monitoring_project_id: project.id,
- instance_administrators_group_id: group.id
- )
- end
-
- it 'destroys project' do
- subject.execute
-
- expect { project.reload }.to raise_error(ActiveRecord::RecordNotFound)
- end
-
- it 'deletes project ID from application settings' do
- subject.execute
-
- LooseForeignKeys::ProcessDeletedRecordsService.new(connection: Project.connection).execute
-
- expect(application_setting.reload.self_monitoring_project_id).to be_nil
- end
-
- it 'does not delete group' do
- subject.execute
-
- expect(application_setting.instance_administrators_group).to eq(group)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb
index 26d6ff431ec..f2be888e6eb 100644
--- a/spec/lib/gitlab/database_spec.rb
+++ b/spec/lib/gitlab/database_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Database do
+RSpec.describe Gitlab::Database, feature_category: :database do
before do
stub_const('MigrationTest', Class.new { include Gitlab::Database })
end
@@ -66,6 +66,48 @@ RSpec.describe Gitlab::Database do
end
end
+ describe '.has_database?' do
+ context 'three tier database config' do
+ it 'returns true for main' do
+ expect(described_class.has_database?(:main)).to eq(true)
+ end
+
+ it 'returns false for shared database' do
+ skip_if_multiple_databases_not_setup(:ci)
+ skip_if_database_exists(:ci)
+
+ expect(described_class.has_database?(:ci)).to eq(false)
+ end
+
+ it 'returns false for non-existent' do
+ expect(described_class.has_database?(:nonexistent)).to eq(false)
+ end
+ end
+ end
+
+ describe '.database_mode' do
+ context 'three tier database config' do
+ it 'returns single-database if ci is not configured' do
+ skip_if_multiple_databases_are_setup(:ci)
+
+ expect(described_class.database_mode).to eq(::Gitlab::Database::MODE_SINGLE_DATABASE)
+ end
+
+ it 'returns single-database-ci-connection if ci is shared with main database' do
+ skip_if_multiple_databases_not_setup(:ci)
+ skip_if_database_exists(:ci)
+
+ expect(described_class.database_mode).to eq(::Gitlab::Database::MODE_SINGLE_DATABASE_CI_CONNECTION)
+ end
+
+ it 'returns multiple-database if ci has its own database' do
+ skip_if_shared_database(:ci)
+
+ expect(described_class.database_mode).to eq(::Gitlab::Database::MODE_MULTIPLE_DATABASES)
+ end
+ end
+ end
+
describe '.check_for_non_superuser' do
subject { described_class.check_for_non_superuser }
diff --git a/spec/lib/gitlab/diff/formatters/image_formatter_spec.rb b/spec/lib/gitlab/diff/formatters/image_formatter_spec.rb
index 73c0d0dba88..1069666ac50 100644
--- a/spec/lib/gitlab/diff/formatters/image_formatter_spec.rb
+++ b/spec/lib/gitlab/diff/formatters/image_formatter_spec.rb
@@ -26,11 +26,13 @@ RSpec.describe Gitlab::Diff::Formatters::ImageFormatter do
it { is_expected.to eq(subject) }
[:width, :height, :x, :y].each do |attr|
- let(:other_formatter) do
- described_class.new(attrs.merge(attr => 9))
- end
+ context "with attribute:#{attr}" do
+ let(:other_formatter) do
+ described_class.new(attrs.merge(attr => 9))
+ end
- it { is_expected.not_to eq(other_formatter) }
+ it { is_expected.not_to eq(other_formatter) }
+ end
end
end
end
diff --git a/spec/lib/gitlab/diff/highlight_cache_spec.rb b/spec/lib/gitlab/diff/highlight_cache_spec.rb
index 33e9360ee01..43e4f28b4df 100644
--- a/spec/lib/gitlab/diff/highlight_cache_spec.rb
+++ b/spec/lib/gitlab/diff/highlight_cache_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache do
+RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache, feature_category: :source_code_management do
let_it_be(:merge_request) { create(:merge_request_with_diffs) }
let(:diff_hash) do
@@ -282,17 +282,7 @@ RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache do
end
it 'returns cache key' do
- is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{options_hash([cache.diff_options, true, true])}")
- end
-
- context 'when the `use_marker_ranges` feature flag is disabled' do
- before do
- stub_feature_flags(use_marker_ranges: false)
- end
-
- it 'returns the original version of the cache' do
- is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{options_hash([cache.diff_options, false, true])}")
- end
+ is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{options_hash([cache.diff_options, true])}")
end
context 'when the `diff_line_syntax_highlighting` feature flag is disabled' do
@@ -301,7 +291,7 @@ RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache do
end
it 'returns the original version of the cache' do
- is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{options_hash([cache.diff_options, true, false])}")
+ is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{options_hash([cache.diff_options, false])}")
end
end
end
diff --git a/spec/lib/gitlab/diff/highlight_spec.rb b/spec/lib/gitlab/diff/highlight_spec.rb
index c378ecb8134..233dddbdad7 100644
--- a/spec/lib/gitlab/diff/highlight_spec.rb
+++ b/spec/lib/gitlab/diff/highlight_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Diff::Highlight do
+RSpec.describe Gitlab::Diff::Highlight, feature_category: :source_code_management do
include RepoHelpers
let_it_be(:project) { create(:project, :repository) }
@@ -15,7 +15,6 @@ RSpec.describe Gitlab::Diff::Highlight do
let(:code) { '<h2 onmouseover="alert(2)">Test</h2>' }
before do
- allow(Gitlab::Diff::InlineDiff).to receive(:for_lines).and_return([])
allow_any_instance_of(Gitlab::Diff::Line).to receive(:text).and_return(code)
end
@@ -121,18 +120,6 @@ RSpec.describe Gitlab::Diff::Highlight do
end
end
- context 'when `use_marker_ranges` feature flag is disabled' do
- it 'returns the same result' do
- with_feature_flag = described_class.new(diff_file, repository: project.repository).highlight
-
- stub_feature_flags(use_marker_ranges: false)
-
- without_feature_flag = described_class.new(diff_file, repository: project.repository).highlight
-
- expect(with_feature_flag.map(&:rich_text)).to eq(without_feature_flag.map(&:rich_text))
- end
- end
-
context 'when no inline diffs' do
it_behaves_like 'without inline diffs'
end
diff --git a/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb b/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb
index 30981e4bd7d..0dc0f50b104 100644
--- a/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb
+++ b/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb
@@ -41,57 +41,81 @@ RSpec.describe Gitlab::DiscussionsDiff::HighlightCache, :clean_gitlab_redis_cach
end
describe '#read_multiple' do
- it 'reads multiple keys and serializes content into Gitlab::Diff::Line objects' do
- described_class.write_multiple(mapping)
+ shared_examples 'read multiple keys' do
+ it 'reads multiple keys and serializes content into Gitlab::Diff::Line objects' do
+ described_class.write_multiple(mapping)
- found = described_class.read_multiple(mapping.keys)
+ found = described_class.read_multiple(mapping.keys)
- expect(found.size).to eq(2)
- expect(found.first.size).to eq(2)
- expect(found.first).to all(be_a(Gitlab::Diff::Line))
- end
+ expect(found.size).to eq(2)
+ expect(found.first.size).to eq(2)
+ expect(found.first).to all(be_a(Gitlab::Diff::Line))
+ end
- it 'returns nil when cached key is not found' do
- described_class.write_multiple(mapping)
+ it 'returns nil when cached key is not found' do
+ described_class.write_multiple(mapping)
- found = described_class.read_multiple([2, 3])
+ found = described_class.read_multiple([2, 3])
- expect(found.size).to eq(2)
+ expect(found.size).to eq(2)
- expect(found.first).to eq(nil)
- expect(found.second.size).to eq(2)
- expect(found.second).to all(be_a(Gitlab::Diff::Line))
- end
+ expect(found.first).to eq(nil)
+ expect(found.second.size).to eq(2)
+ expect(found.second).to all(be_a(Gitlab::Diff::Line))
+ end
- it 'returns lines which rich_text are HTML-safe' do
- described_class.write_multiple(mapping)
+ it 'returns lines which rich_text are HTML-safe' do
+ described_class.write_multiple(mapping)
+
+ found = described_class.read_multiple(mapping.keys)
+ rich_texts = found.flatten.map(&:rich_text)
+
+ expect(rich_texts).to all(be_html_safe)
+ end
+ end
- found = described_class.read_multiple(mapping.keys)
- rich_texts = found.flatten.map(&:rich_text)
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(use_pipeline_over_multikey: false)
+ end
- expect(rich_texts).to all(be_html_safe)
+ it_behaves_like 'read multiple keys'
end
+
+ it_behaves_like 'read multiple keys'
end
describe '#clear_multiple' do
- it 'removes all named keys' do
- described_class.write_multiple(mapping)
+ shared_examples 'delete multiple keys' do
+ it 'removes all named keys' do
+ described_class.write_multiple(mapping)
- described_class.clear_multiple(mapping.keys)
+ described_class.clear_multiple(mapping.keys)
- expect(described_class.read_multiple(mapping.keys)).to all(be_nil)
- end
+ expect(described_class.read_multiple(mapping.keys)).to all(be_nil)
+ end
- it 'only removed named keys' do
- to_clear, to_leave = mapping.keys
+ it 'only removed named keys' do
+ to_clear, to_leave = mapping.keys
- described_class.write_multiple(mapping)
- described_class.clear_multiple([to_clear])
+ described_class.write_multiple(mapping)
+ described_class.clear_multiple([to_clear])
- cleared, left = described_class.read_multiple([to_clear, to_leave])
+ cleared, left = described_class.read_multiple([to_clear, to_leave])
- expect(cleared).to be_nil
- expect(left).to all(be_a(Gitlab::Diff::Line))
+ expect(cleared).to be_nil
+ expect(left).to all(be_a(Gitlab::Diff::Line))
+ end
end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(use_pipeline_over_multikey: false)
+ end
+
+ it_behaves_like 'delete multiple keys'
+ end
+
+ it_behaves_like 'delete multiple keys'
end
end
diff --git a/spec/lib/gitlab/doorkeeper_secret_storing/secret/pbkdf2_sha512_spec.rb b/spec/lib/gitlab/doorkeeper_secret_storing/secret/pbkdf2_sha512_spec.rb
index df17d92bb0c..fb433923db5 100644
--- a/spec/lib/gitlab/doorkeeper_secret_storing/secret/pbkdf2_sha512_spec.rb
+++ b/spec/lib/gitlab/doorkeeper_secret_storing/secret/pbkdf2_sha512_spec.rb
@@ -10,16 +10,6 @@ RSpec.describe Gitlab::DoorkeeperSecretStoring::Secret::Pbkdf2Sha512 do
expect(described_class.transform_secret(plaintext_secret))
.to eq("$pbkdf2-sha512$20000$$.c0G5XJVEew1TyeJk5TrkvB0VyOaTmDzPrsdNRED9vVeZlSyuG3G90F0ow23zUCiWKAVwmNnR/ceh.nJG3MdpQ") # rubocop:disable Layout/LineLength
end
-
- context 'when hash_oauth_secrets is disabled' do
- before do
- stub_feature_flags(hash_oauth_secrets: false)
- end
-
- it 'returns a plaintext secret' do
- expect(described_class.transform_secret(plaintext_secret)).to eq(plaintext_secret)
- end
- end
end
describe 'STRETCHES' do
@@ -36,7 +26,6 @@ RSpec.describe Gitlab::DoorkeeperSecretStoring::Secret::Pbkdf2Sha512 do
describe '.secret_matches?' do
it "match by hashing the input if the stored value is hashed" do
- stub_feature_flags(hash_oauth_secrets: false)
plain_secret = 'plain_secret'
stored_value = '$pbkdf2-sha512$20000$$/BwQRdwSpL16xkQhstavh7nvA5avCP7.4n9LLKe9AupgJDeA7M5xOAvG3N3E5XbRyGWWBbbr.BsojPVWzd1Sqg' # rubocop:disable Layout/LineLength
expect(described_class.secret_matches?(plain_secret, stored_value)).to be true
diff --git a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
index 8ff8de2379a..369d7e994d2 100644
--- a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
@@ -116,7 +116,7 @@ RSpec.describe Gitlab::Email::Handler::CreateIssueHandler do
context "when the issue could not be saved" do
before do
allow_any_instance_of(Issue).to receive(:persisted?).and_return(false)
- allow_any_instance_of(Issue).to receive(:ensure_metrics).and_return(nil)
+ allow_any_instance_of(Issue).to receive(:ensure_metrics!).and_return(nil)
end
it "raises an InvalidIssueError" do
diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
index f70645a8272..e3b0e90bff9 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -206,4 +206,26 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
it_behaves_like 'a reply to existing comment'
end
+
+ context 'when note is authored from external author for service desk' do
+ before do
+ SentNotification.find_by(reply_key: mail_key).update!(recipient: User.support_bot)
+ end
+
+ context 'when email contains text, quoted text and quick commands' do
+ let(:email_raw) { fixture_file('emails/commands_in_reply.eml') }
+
+ it 'creates a discussion' do
+ expect { receiver.execute }.to change { noteable.notes.count }.by(1)
+ end
+
+ it 'links external participant' do
+ receiver.execute
+
+ new_note = noteable.notes.last
+
+ expect(new_note.note_metadata.external_author).to eq('jake@adventuretime.ooo')
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/email/hook/silent_mode_interceptor_spec.rb b/spec/lib/gitlab/email/hook/silent_mode_interceptor_spec.rb
new file mode 100644
index 00000000000..cc371643bee
--- /dev/null
+++ b/spec/lib/gitlab/email/hook/silent_mode_interceptor_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Email::Hook::SilentModeInterceptor, :mailer, feature_category: :geo_replication do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ Mail.register_interceptor(described_class)
+ end
+
+ after do
+ Mail.unregister_interceptor(described_class)
+ end
+
+ context 'when silent mode is enabled' do
+ it 'prevents mail delivery' do
+ stub_application_setting(silent_mode_enabled: true)
+
+ deliver_mails(user)
+
+ should_not_email_anyone
+ end
+
+ it 'logs the suppression' do
+ stub_application_setting(silent_mode_enabled: true)
+
+ expect(Gitlab::AppJsonLogger).to receive(:info).with(
+ message: 'SilentModeInterceptor prevented sending mail',
+ mail_subject: 'Two-factor authentication disabled',
+ silent_mode_enabled: true
+ )
+ expect(Gitlab::AppJsonLogger).to receive(:info).with(
+ message: 'SilentModeInterceptor prevented sending mail',
+ mail_subject: 'Welcome to GitLab!',
+ silent_mode_enabled: true
+ )
+
+ deliver_mails(user)
+ end
+ end
+
+ context 'when silent mode is disabled' do
+ it 'does not prevent mail delivery' do
+ stub_application_setting(silent_mode_enabled: false)
+
+ deliver_mails(user)
+
+ should_email(user, times: 2)
+ end
+
+ it 'debug logs the no-op' do
+ stub_application_setting(silent_mode_enabled: false)
+
+ expect(Gitlab::AppJsonLogger).to receive(:debug).with(
+ message: 'SilentModeInterceptor did nothing',
+ mail_subject: 'Two-factor authentication disabled',
+ silent_mode_enabled: false
+ )
+ expect(Gitlab::AppJsonLogger).to receive(:debug).with(
+ message: 'SilentModeInterceptor did nothing',
+ mail_subject: 'Welcome to GitLab!',
+ silent_mode_enabled: false
+ )
+
+ deliver_mails(user)
+ end
+ end
+
+ def deliver_mails(user)
+ Notify.disabled_two_factor_email(user).deliver_now
+ DeviseMailer.user_admin_approval(user).deliver_now
+ end
+end
diff --git a/spec/lib/gitlab/email/hook/validate_addresses_interceptor_spec.rb b/spec/lib/gitlab/email/hook/validate_addresses_interceptor_spec.rb
deleted file mode 100644
index a3f0158db40..00000000000
--- a/spec/lib/gitlab/email/hook/validate_addresses_interceptor_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Email::Hook::ValidateAddressesInterceptor do
- describe 'UNSAFE_CHARACTERS' do
- subject { described_class::UNSAFE_CHARACTERS }
-
- it { is_expected.to match('\\') }
- it { is_expected.to match("\x00") }
- it { is_expected.to match("\x01") }
- it { is_expected.not_to match('') }
- it { is_expected.not_to match('user@example.com') }
- it { is_expected.not_to match('foo-123+bar_456@example.com') }
- end
-
- describe '.delivering_email' do
- let(:mail) do
- ActionMailer::Base.mail(to: 'test@mail.com', from: 'info@mail.com', subject: 'title', body: 'hello')
- end
-
- let(:unsafe_email) { "evil+\x01$HOME@example.com" }
-
- it 'sends emails to normal addresses' do
- expect(Gitlab::AuthLogger).not_to receive(:info)
- expect { mail.deliver_now }.to change(ActionMailer::Base.deliveries, :count)
- end
-
- [:from, :to, :cc, :bcc].each do |header|
- it "does not send emails if the #{header.inspect} header contains unsafe characters" do
- mail[header] = unsafe_email
-
- expect(Gitlab::AuthLogger).to receive(:info).with(
- message: 'Skipping email with unsafe characters in address',
- address: unsafe_email,
- subject: mail.subject
- )
-
- expect { mail.deliver_now }.not_to change(ActionMailer::Base.deliveries, :count)
- end
- end
-
- [:reply_to].each do |header|
- it "sends emails if the #{header.inspect} header contains unsafe characters" do
- mail[header] = unsafe_email
-
- expect(Gitlab::AuthLogger).not_to receive(:info)
- expect { mail.deliver_now }.to change(ActionMailer::Base.deliveries, :count)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/email/html_to_markdown_parser_spec.rb b/spec/lib/gitlab/email/html_to_markdown_parser_spec.rb
index fe585d47d59..59c488739dc 100644
--- a/spec/lib/gitlab/email/html_to_markdown_parser_spec.rb
+++ b/spec/lib/gitlab/email/html_to_markdown_parser_spec.rb
@@ -1,17 +1,21 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'kramdown'
+require 'html2text'
+require 'fast_spec_helper'
+require 'support/helpers/fixture_helpers'
RSpec.describe Gitlab::Email::HtmlToMarkdownParser, feature_category: :service_desk do
+ include FixtureHelpers
+
subject { described_class.convert(html) }
describe '.convert' do
let(:html) { fixture_file("lib/gitlab/email/basic.html") }
it 'parses html correctly' do
- expect(subject)
- .to eq(
- <<-BODY.strip_heredoc.chomp
+ expect(subject).to eq(
+ <<~BODY.chomp
Hello, World!
This is some e-mail content. Even though it has whitespace and newlines, the e-mail converter will handle it correctly.
*Even* mismatched tags.
diff --git a/spec/lib/gitlab/email/incoming_email_spec.rb b/spec/lib/gitlab/email/incoming_email_spec.rb
new file mode 100644
index 00000000000..123b050aee7
--- /dev/null
+++ b/spec/lib/gitlab/email/incoming_email_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Email::IncomingEmail, feature_category: :service_desk do
+ let(:setting_name) { :incoming_email }
+
+ it_behaves_like 'common email methods'
+
+ describe 'self.key_from_address' do
+ before do
+ stub_incoming_email_setting(address: 'replies+%{key}@example.com')
+ end
+
+ it "returns reply key" do
+ expect(described_class.key_from_address("replies+key@example.com")).to eq("key")
+ end
+
+ it 'does not match emails with extra bits' do
+ expect(described_class.key_from_address('somereplies+somekey@example.com.someotherdomain.com')).to be nil
+ end
+
+ context 'when a custom wildcard address is used' do
+ let(:wildcard_address) { 'custom.address+%{key}@example.com' }
+
+ it 'finds key if email matches address pattern' do
+ key = described_class.key_from_address(
+ 'custom.address+foo@example.com', wildcard_address: wildcard_address
+ )
+ expect(key).to eq('foo')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb b/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb
index 3089f955252..4b77b2f7192 100644
--- a/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb
+++ b/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb
@@ -2,13 +2,9 @@
require 'spec_helper'
-RSpec.describe Gitlab::Email::Message::BuildIosAppGuide do
+RSpec.describe Gitlab::Email::Message::BuildIosAppGuide, :saas do
subject(:message) { described_class.new }
- before do
- allow(Gitlab).to receive(:com?) { true }
- end
-
it 'contains the correct message', :aggregate_failures do
expect(message.subject_line).to eq 'Get set up to build for iOS'
expect(message.title).to eq "Building for iOS? We've got you covered."
diff --git a/spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb
index 3c0d83d0f9e..a3c2d1b428e 100644
--- a/spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb
+++ b/spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb
@@ -27,11 +27,7 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Helper do
subject(:class_with_helper) { dummy_class_with_helper.new(format) }
- context 'gitlab.com' do
- before do
- allow(Gitlab).to receive(:com?) { true }
- end
-
+ context 'for SaaS', :saas do
context 'format is HTML' do
it 'returns the correct HTML' do
message = "If you no longer wish to receive marketing emails from us, " \
diff --git a/spec/lib/gitlab/email/message/repository_push_spec.rb b/spec/lib/gitlab/email/message/repository_push_spec.rb
index f13d98ec9b9..bb68bca5dfa 100644
--- a/spec/lib/gitlab/email/message/repository_push_spec.rb
+++ b/spec/lib/gitlab/email/message/repository_push_spec.rb
@@ -45,7 +45,7 @@ RSpec.describe Gitlab::Email::Message::RepositoryPush do
describe '#project_name_with_namespace' do
subject { message.project_name_with_namespace }
- it { is_expected.to eq "#{group.name} / #{project.path}" }
+ it { is_expected.to eq "#{group.name} / #{project.name}" }
end
describe '#author' do
diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb
index 865e40d4ecb..e58da2478bf 100644
--- a/spec/lib/gitlab/email/receiver_spec.rb
+++ b/spec/lib/gitlab/email/receiver_spec.rb
@@ -11,9 +11,10 @@ RSpec.describe Gitlab::Email::Receiver do
shared_examples 'successful receive' do
let(:handler) { double(:handler, project: project, execute: true, metrics_event: nil, metrics_params: nil) }
let(:client_id) { 'email/jake@example.com' }
+ let(:mail_key) { 'gitlabhq/gitlabhq+auth_token' }
it 'correctly finds the mail key' do
- expect(Gitlab::Email::Handler).to receive(:for).with(an_instance_of(Mail::Message), 'gitlabhq/gitlabhq+auth_token').and_return(handler)
+ expect(Gitlab::Email::Handler).to receive(:for).with(an_instance_of(Mail::Message), mail_key).and_return(handler)
receiver.execute
end
@@ -92,6 +93,16 @@ RSpec.describe Gitlab::Email::Receiver do
it_behaves_like 'successful receive'
end
+ context 'when mail key is in the references header with a comma' do
+ let(:email_raw) { fixture_file('emails/valid_reply_with_references_in_comma.eml') }
+ let(:meta_key) { :references }
+ let(:meta_value) { ['"<reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>,<issue_1@localhost>,<exchange@microsoft.com>"'] }
+
+ it_behaves_like 'successful receive' do
+ let(:mail_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' }
+ end
+ end
+
context 'when all other headers are missing' do
let(:email_raw) { fixture_file('emails/missing_delivered_to_header.eml') }
let(:meta_key) { :received_recipients }
diff --git a/spec/lib/gitlab/email/reply_parser_spec.rb b/spec/lib/gitlab/email/reply_parser_spec.rb
index e4c68dbba92..35065b74eff 100644
--- a/spec/lib/gitlab/email/reply_parser_spec.rb
+++ b/spec/lib/gitlab/email/reply_parser_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
# Inspired in great part by Discourse's Email::Receiver
-RSpec.describe Gitlab::Email::ReplyParser do
+RSpec.describe Gitlab::Email::ReplyParser, feature_category: :team_planning do
describe '#execute' do
def test_parse_body(mail_string, params = {})
described_class.new(Mail::Message.new(mail_string), **params).execute
@@ -188,67 +188,36 @@ RSpec.describe Gitlab::Email::ReplyParser do
)
end
- context 'properly renders email reply from gmail web client' do
- context 'when feature flag is enabled' do
- it do
- expect(test_parse_body(fixture_file("emails/html_only.eml")))
- .to eq(
- <<-BODY.strip_heredoc.chomp
- ### This is a reply from standard GMail in Google Chrome.
-
- The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.
-
- Here's some **bold** text, **strong** text and *italic* in Markdown.
-
- Here's a link http://example.com
-
- Here's an img ![Miro](http://img.png)<details>
- <summary>
- One</summary>
- Some details</details>
-
- <details>
- <summary>
- Two</summary>
- Some details</details>
-
- Test reply.
-
- First paragraph.
-
- Second paragraph.
- BODY
- )
- end
- end
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(service_desk_html_to_text_email_handler: false)
- end
+ context 'properly renders email reply from gmail web client', feature_category: :service_desk do
+ it do
+ expect(test_parse_body(fixture_file("emails/html_only.eml")))
+ .to eq(
+ <<-BODY.strip_heredoc.chomp
+ ### This is a reply from standard GMail in Google Chrome.
- it do
- expect(test_parse_body(fixture_file("emails/html_only.eml")))
- .to eq(
- <<-BODY.strip_heredoc.chomp
- ### This is a reply from standard GMail in Google Chrome.
+ The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.
- The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.
+ Here's some **bold** text, **strong** text and *italic* in Markdown.
- Here's some **bold** text, strong text and italic in Markdown.
+ Here's a link http://example.com
- Here's a link http://example.com
+ Here's an img ![Miro](http://img.png)<details>
+ <summary>
+ One</summary>
+ Some details</details>
- Here's an img [Miro]One Some details Two Some details
+ <details>
+ <summary>
+ Two</summary>
+ Some details</details>
- Test reply.
+ Test reply.
- First paragraph.
+ First paragraph.
- Second paragraph.
- BODY
- )
- end
+ Second paragraph.
+ BODY
+ )
end
end
diff --git a/spec/lib/gitlab/email/service_desk_email_spec.rb b/spec/lib/gitlab/email/service_desk_email_spec.rb
new file mode 100644
index 00000000000..d59b8aa2cf7
--- /dev/null
+++ b/spec/lib/gitlab/email/service_desk_email_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Email::ServiceDeskEmail, feature_category: :service_desk do
+ let(:setting_name) { :service_desk_email }
+
+ it_behaves_like 'common email methods'
+
+ describe '.key_from_address' do
+ context 'when service desk address is set' do
+ before do
+ stub_service_desk_email_setting(address: 'address+%{key}@example.com')
+ end
+
+ it 'returns key' do
+ expect(described_class.key_from_address('address+key@example.com')).to eq('key')
+ end
+ end
+
+ context 'when service desk address is not set' do
+ before do
+ stub_service_desk_email_setting(address: nil)
+ end
+
+ it 'returns nil' do
+ expect(described_class.key_from_address('address+key@example.com')).to be_nil
+ end
+ end
+ end
+
+ describe '.address_for_key' do
+ context 'when service desk address is set' do
+ before do
+ stub_service_desk_email_setting(address: 'address+%{key}@example.com')
+ end
+
+ it 'returns address' do
+ expect(described_class.address_for_key('foo')).to eq('address+foo@example.com')
+ end
+ end
+
+ context 'when service desk address is not set' do
+ before do
+ stub_service_desk_email_setting(address: nil)
+ end
+
+ it 'returns nil' do
+ expect(described_class.key_from_address('foo')).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/emoji_spec.rb b/spec/lib/gitlab/emoji_spec.rb
index 0db3b5f3b11..44b2ec12246 100644
--- a/spec/lib/gitlab/emoji_spec.rb
+++ b/spec/lib/gitlab/emoji_spec.rb
@@ -3,23 +3,6 @@
require 'spec_helper'
RSpec.describe Gitlab::Emoji do
- describe '.emoji_image_tag' do
- it 'returns emoji image tag' do
- emoji_image = described_class.emoji_image_tag('emoji_one', 'src_url')
-
- expect(emoji_image).to eq("<img class=\"emoji\" src=\"src_url\" title=\":emoji_one:\" alt=\":emoji_one:\" height=\"20\" width=\"20\" align=\"absmiddle\" />")
- end
-
- it 'escapes emoji image attrs to prevent XSS' do
- xss_payload = "<script>alert(1)</script>"
- escaped_xss_payload = html_escape(xss_payload)
-
- emoji_image = described_class.emoji_image_tag(xss_payload, 'http://aaa#' + xss_payload)
-
- expect(emoji_image).to eq("<img class=\"emoji\" src=\"http://aaa##{escaped_xss_payload}\" title=\":#{escaped_xss_payload}:\" alt=\":#{escaped_xss_payload}:\" height=\"20\" width=\"20\" align=\"absmiddle\" />")
- end
- end
-
describe '.gl_emoji_tag' do
it 'returns gl emoji tag if emoji is found' do
emoji = TanukiEmoji.find_by_alpha_code('small_airplane')
diff --git a/spec/lib/gitlab/endpoint_attributes_spec.rb b/spec/lib/gitlab/endpoint_attributes_spec.rb
index 53f5b302f05..a623070c3eb 100644
--- a/spec/lib/gitlab/endpoint_attributes_spec.rb
+++ b/spec/lib/gitlab/endpoint_attributes_spec.rb
@@ -1,11 +1,8 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
-require_relative '../../support/matchers/be_request_urgency'
-require_relative '../../../lib/gitlab/endpoint_attributes/config'
-require_relative '../../../lib/gitlab/endpoint_attributes'
+require 'spec_helper'
-RSpec.describe Gitlab::EndpointAttributes do
+RSpec.describe Gitlab::EndpointAttributes, feature_category: :api do
let(:base_controller) do
Class.new do
include ::Gitlab::EndpointAttributes
diff --git a/spec/lib/gitlab/error_tracking_spec.rb b/spec/lib/gitlab/error_tracking_spec.rb
index 0f056ee9eac..79016335a40 100644
--- a/spec/lib/gitlab/error_tracking_spec.rb
+++ b/spec/lib/gitlab/error_tracking_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
require 'raven/transports/dummy'
require 'sentry/transport/dummy_transport'
-RSpec.describe Gitlab::ErrorTracking do
+RSpec.describe Gitlab::ErrorTracking, feature_category: :shared do
let(:exception) { RuntimeError.new('boom') }
let(:issue_url) { 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1' }
let(:extra) { { issue_url: issue_url, some_other_info: 'info' } }
@@ -58,7 +58,7 @@ RSpec.describe Gitlab::ErrorTracking do
stub_feature_flags(enable_new_sentry_integration: true)
stub_sentry_settings
- allow(described_class).to receive(:sentry_configurable?) { true }
+ allow(described_class).to receive(:sentry_configurable?).and_return(true)
allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('cid')
allow(I18n).to receive(:locale).and_return('en')
@@ -82,7 +82,7 @@ RSpec.describe Gitlab::ErrorTracking do
describe '.track_and_raise_for_dev_exception' do
context 'when exceptions for dev should be raised' do
before do
- expect(described_class).to receive(:should_raise_for_dev?).and_return(true)
+ allow(described_class).to receive(:should_raise_for_dev?).and_return(true)
end
it 'raises the exception' do
@@ -101,7 +101,7 @@ RSpec.describe Gitlab::ErrorTracking do
context 'when exceptions for dev should not be raised' do
before do
- expect(described_class).to receive(:should_raise_for_dev?).and_return(false)
+ allow(described_class).to receive(:should_raise_for_dev?).and_return(false)
end
it 'logs the exception with all attributes passed' do
@@ -219,7 +219,7 @@ RSpec.describe Gitlab::ErrorTracking do
end
end
- context 'the exception implements :sentry_extra_data' do
+ context 'when the exception implements :sentry_extra_data' do
let(:extra_info) { { event: 'explosion', size: :massive } }
before do
@@ -239,7 +239,7 @@ RSpec.describe Gitlab::ErrorTracking do
end
end
- context 'the exception implements :sentry_extra_data, which returns nil' do
+ context 'when the exception implements :sentry_extra_data, which returns nil' do
let(:extra) { { issue_url: issue_url } }
before do
@@ -260,7 +260,7 @@ RSpec.describe Gitlab::ErrorTracking do
end
end
- context 'event processors' do
+ describe 'event processors' do
subject(:track_exception) { described_class.track_exception(exception, extra) }
before do
@@ -269,7 +269,16 @@ RSpec.describe Gitlab::ErrorTracking do
allow(Gitlab::ErrorTracking::Logger).to receive(:error)
end
- context 'custom GitLab context when using Raven.capture_exception directly' do
+ # This is a workaround for restoring Raven's user context below.
+ # Raven.user_context(&block) does not restore the user context correctly.
+ around do |example|
+ previous_user_context = Raven.context.user.dup
+ example.run
+ ensure
+ Raven.context.user = previous_user_context
+ end
+
+ context 'with custom GitLab context when using Raven.capture_exception directly' do
subject(:track_exception) { Raven.capture_exception(exception) }
it 'merges a default set of tags into the existing tags' do
@@ -289,7 +298,7 @@ RSpec.describe Gitlab::ErrorTracking do
end
end
- context 'custom GitLab context when using Sentry.capture_exception directly' do
+ context 'with custom GitLab context when using Sentry.capture_exception directly' do
subject(:track_exception) { Sentry.capture_exception(exception) }
it 'merges a default set of tags into the existing tags' do
@@ -401,15 +410,17 @@ RSpec.describe Gitlab::ErrorTracking do
end
['Gitlab::SidekiqMiddleware::RetryError', 'SubclassRetryError'].each do |ex|
- let(:exception) { ex.constantize.new }
+ context "with #{ex} exception" do
+ let(:exception) { ex.constantize.new }
- it "does not report #{ex} exception to Sentry" do
- expect(Gitlab::ErrorTracking::Logger).to receive(:error)
+ it "does not report exception to Sentry" do
+ expect(Gitlab::ErrorTracking::Logger).to receive(:error)
- track_exception
+ track_exception
- expect(Raven.client.transport.events).to eq([])
- expect(Sentry.get_current_client.transport.events).to eq([])
+ expect(Raven.client.transport.events).to eq([])
+ expect(Sentry.get_current_client.transport.events).to eq([])
+ end
end
end
end
@@ -491,7 +502,7 @@ RSpec.describe Gitlab::ErrorTracking do
end
end
- context 'Sentry performance monitoring' do
+ describe 'Sentry performance monitoring' do
context 'when ENABLE_SENTRY_PERFORMANCE_MONITORING env is disabled' do
before do
stub_env('ENABLE_SENTRY_PERFORMANCE_MONITORING', false)
diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb
index fa0b3d1c6dd..d25511843ff 100644
--- a/spec/lib/gitlab/etag_caching/middleware_spec.rb
+++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb
@@ -145,8 +145,11 @@ RSpec.describe Gitlab::EtagCaching::Middleware, :clean_gitlab_redis_shared_state
expect(payload[:headers].env['HTTP_IF_NONE_MATCH']).to eq('W/"123"')
end
- it 'log subscriber processes action' do
- expect_any_instance_of(ActionController::LogSubscriber).to receive(:process_action)
+ it "publishes process_action.action_controller event to be picked up by lograge's subscriber" do
+ # Lograge unhooks the default Rails subscriber (ActionController::LogSubscriber)
+ # and replaces with its own (Lograge::LogSubscribers::ActionController).
+ # When `lograge.keep_original_rails_log = true`, ActionController::LogSubscriber is kept.
+ expect_any_instance_of(Lograge::LogSubscribers::ActionController).to receive(:process_action)
.with(instance_of(ActiveSupport::Notifications::Event))
.and_call_original
diff --git a/spec/lib/gitlab/exception_log_formatter_spec.rb b/spec/lib/gitlab/exception_log_formatter_spec.rb
index 7dda56f0bf5..82166971603 100644
--- a/spec/lib/gitlab/exception_log_formatter_spec.rb
+++ b/spec/lib/gitlab/exception_log_formatter_spec.rb
@@ -45,6 +45,12 @@ RSpec.describe Gitlab::ExceptionLogFormatter do
allow(exception).to receive(:cause).and_return(ActiveRecord::StatementInvalid.new(sql: 'SELECT "users".* FROM "users" WHERE "users"."id" = 1 AND "users"."foo" = $1'))
end
+ it 'adds the cause_class to payload' do
+ described_class.format!(exception, payload)
+
+ expect(payload['exception.cause_class']).to eq('ActiveRecord::StatementInvalid')
+ end
+
it 'adds the normalized SQL query to payload' do
described_class.format!(exception, payload)
diff --git a/spec/lib/gitlab/external_authorization/config_spec.rb b/spec/lib/gitlab/external_authorization/config_spec.rb
index 4231b0d3747..f1daa9249f4 100644
--- a/spec/lib/gitlab/external_authorization/config_spec.rb
+++ b/spec/lib/gitlab/external_authorization/config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ExternalAuthorization::Config, feature_category: :authentication_and_authorization do
+RSpec.describe Gitlab::ExternalAuthorization::Config, feature_category: :system_access do
it 'allows deploy tokens and keys when external authorization is disabled' do
stub_application_setting(external_authorization_service_enabled: false)
expect(described_class.allow_deploy_tokens_and_deploy_keys?).to be_eql(true)
diff --git a/spec/lib/gitlab/favicon_spec.rb b/spec/lib/gitlab/favicon_spec.rb
index 884425dab3b..033fa5d1b42 100644
--- a/spec/lib/gitlab/favicon_spec.rb
+++ b/spec/lib/gitlab/favicon_spec.rb
@@ -40,14 +40,22 @@ RSpec.describe Gitlab::Favicon, :request_store do
end
end
- describe '.status_overlay' do
- subject { described_class.status_overlay('favicon_status_created') }
+ describe '.ci_status_overlay' do
+ subject { described_class.ci_status_overlay('favicon_status_created') }
it 'returns the overlay for the status' do
expect(subject).to match_asset_path '/assets/ci_favicons/favicon_status_created.png'
end
end
+ describe '.mr_status_overlay' do
+ subject { described_class.mr_status_overlay('favicon_status_merged') }
+
+ it 'returns the overlay for the status' do
+ expect(subject).to match_asset_path '/assets/mr_favicons/favicon_status_merged.png'
+ end
+ end
+
describe '.available_status_names' do
subject { described_class.available_status_names }
diff --git a/spec/lib/gitlab/file_finder_spec.rb b/spec/lib/gitlab/file_finder_spec.rb
index 27750f10e87..8afaec3c381 100644
--- a/spec/lib/gitlab/file_finder_spec.rb
+++ b/spec/lib/gitlab/file_finder_spec.rb
@@ -13,124 +13,58 @@ RSpec.describe Gitlab::FileFinder, feature_category: :global_search do
let(:expected_file_by_content) { 'CHANGELOG' }
end
- context 'when code_basic_search_files_by_regexp is enabled' do
- before do
- stub_feature_flags(code_basic_search_files_by_regexp: true)
- end
-
- context 'with inclusive filters' do
- it 'filters by filename' do
- results = subject.find('files filename:wm.svg')
-
- expect(results.count).to eq(1)
- end
-
- it 'filters by path' do
- results = subject.find('white path:images')
-
- expect(results.count).to eq(2)
- end
-
- it 'filters by extension' do
- results = subject.find('files extension:md')
-
- expect(results.count).to eq(4)
- end
- end
-
- context 'with exclusive filters' do
- it 'filters by filename' do
- results = subject.find('files -filename:wm.svg')
-
- expect(results.count).to eq(26)
- end
-
- it 'filters by path' do
- results = subject.find('white -path:images')
-
- expect(results.count).to eq(5)
- end
-
- it 'filters by extension' do
- results = subject.find('files -extension:md')
+ context 'with inclusive filters' do
+ it 'filters by filename' do
+ results = subject.find('files filename:wm.svg')
- expect(results.count).to eq(23)
- end
+ expect(results.count).to eq(1)
end
- context 'with white space in the path' do
- it 'filters by path correctly' do
- results = subject.find('directory path:"with space/README.md"')
+ it 'filters by path' do
+ results = subject.find('white path:images')
- expect(results.count).to eq(1)
- end
+ expect(results.count).to eq(2)
end
- it 'does not cause N+1 query' do
- expect(Gitlab::GitalyClient).to receive(:call).at_most(10).times.and_call_original
+ it 'filters by extension' do
+ results = subject.find('files extension:md')
- subject.find(': filename:wm.svg')
+ expect(results.count).to eq(4)
end
end
- context 'when code_basic_search_files_by_regexp is disabled' do
- before do
- stub_feature_flags(code_basic_search_files_by_regexp: false)
- end
-
- context 'with inclusive filters' do
- it 'filters by filename' do
- results = subject.find('files filename:wm.svg')
-
- expect(results.count).to eq(1)
- end
-
- it 'filters by path' do
- results = subject.find('white path:images')
-
- expect(results.count).to eq(1)
- end
-
- it 'filters by extension' do
- results = subject.find('files extension:md')
+ context 'with exclusive filters' do
+ it 'filters by filename' do
+ results = subject.find('files -filename:wm.svg')
- expect(results.count).to eq(4)
- end
+ expect(results.count).to eq(26)
end
- context 'with exclusive filters' do
- it 'filters by filename' do
- results = subject.find('files -filename:wm.svg')
+ it 'filters by path' do
+ results = subject.find('white -path:images')
- expect(results.count).to eq(26)
- end
-
- it 'filters by path' do
- results = subject.find('white -path:images')
-
- expect(results.count).to eq(4)
- end
+ expect(results.count).to eq(5)
+ end
- it 'filters by extension' do
- results = subject.find('files -extension:md')
+ it 'filters by extension' do
+ results = subject.find('files -extension:md')
- expect(results.count).to eq(23)
- end
+ expect(results.count).to eq(23)
end
+ end
- context 'with white space in the path' do
- it 'filters by path correctly' do
- results = subject.find('directory path:"with space/README.md"')
+ context 'with white space in the path' do
+ it 'filters by path correctly' do
+ results = subject.find('directory path:"with space/README.md"')
- expect(results.count).to eq(1)
- end
+ expect(results.count).to eq(1)
end
+ end
- it 'does not cause N+1 query' do
- expect(Gitlab::GitalyClient).to receive(:call).at_most(10).times.and_call_original
+ it 'does not cause N+1 query' do
+ expect(Gitlab::GitalyClient).to receive(:call).at_most(10).times.and_call_original
- subject.find(': filename:wm.svg')
- end
+ subject.find(': filename:wm.svg')
end
end
end
diff --git a/spec/lib/gitlab/fogbugz_import/project_creator_spec.rb b/spec/lib/gitlab/fogbugz_import/project_creator_spec.rb
index 8be9f55dbb6..39dad1360a5 100644
--- a/spec/lib/gitlab/fogbugz_import/project_creator_spec.rb
+++ b/spec/lib/gitlab/fogbugz_import/project_creator_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::FogbugzImport::ProjectCreator do
+RSpec.describe Gitlab::FogbugzImport::ProjectCreator, feature_category: :importers do
let(:user) { create(:user) }
let(:repo) do
instance_double(Gitlab::FogbugzImport::Repository,
@@ -22,6 +22,10 @@ RSpec.describe Gitlab::FogbugzImport::ProjectCreator do
project_creator.execute
end
+ before do
+ stub_application_setting(import_sources: ['fogbugz'])
+ end
+
it 'creates project with private visibility level' do
expect(subject.persisted?).to eq(true)
expect(subject.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
diff --git a/spec/lib/gitlab/git/blame_mode_spec.rb b/spec/lib/gitlab/git/blame_mode_spec.rb
new file mode 100644
index 00000000000..3496b763f92
--- /dev/null
+++ b/spec/lib/gitlab/git/blame_mode_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Git::BlameMode, feature_category: :source_code_management do
+ subject(:blame_mode) { described_class.new(project, params) }
+
+ let_it_be(:project) { build(:project) }
+ let(:params) { {} }
+
+ describe '#streaming?' do
+ subject { blame_mode.streaming? }
+
+ it { is_expected.to be_falsey }
+
+ context 'when streaming param is provided' do
+ let(:params) { { streaming: true } }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '#pagination?' do
+ subject { blame_mode.pagination? }
+
+ it { is_expected.to be_truthy }
+
+ context 'when `streaming` params is enabled' do
+ let(:params) { { streaming: true } }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when `no_pagination` param is provided' do
+ let(:params) { { no_pagination: true } }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when `blame_page_pagination` is disabled' do
+ before do
+ stub_feature_flags(blame_page_pagination: false)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#full?' do
+ subject { blame_mode.full? }
+
+ it { is_expected.to be_falsey }
+
+ context 'when `blame_page_pagination` is disabled' do
+ before do
+ stub_feature_flags(blame_page_pagination: false)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/blame_pagination_spec.rb b/spec/lib/gitlab/git/blame_pagination_spec.rb
new file mode 100644
index 00000000000..1f3c0c0342e
--- /dev/null
+++ b/spec/lib/gitlab/git/blame_pagination_spec.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Git::BlamePagination, feature_category: :source_code_management do
+ subject(:blame_pagination) { described_class.new(blob, blame_mode, params) }
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:commit) { project.repository.commit }
+ let_it_be(:blob) { project.repository.blob_at('HEAD', 'README.md') }
+
+ let(:blame_mode) do
+ instance_double(
+ 'Gitlab::Git::BlameMode',
+ 'streaming?' => streaming_mode,
+ 'full?' => full_mode
+ )
+ end
+
+ let(:params) { { page: page } }
+ let(:page) { 1 }
+ let(:streaming_mode) { false }
+ let(:full_mode) { false }
+
+ using RSpec::Parameterized::TableSyntax
+
+ describe '#page' do
+ subject { blame_pagination.page }
+
+ where(:page, :expected_page) do
+ nil | 1
+ 1 | 1
+ 5 | 5
+ -1 | 1
+ 'a' | 1
+ end
+
+ with_them do
+ it { is_expected.to eq(expected_page) }
+ end
+ end
+
+ describe '#per_page' do
+ subject { blame_pagination.per_page }
+
+ it { is_expected.to eq(described_class::PAGINATION_PER_PAGE) }
+
+ context 'when blame mode is streaming' do
+ let(:streaming_mode) { true }
+
+ it { is_expected.to eq(described_class::STREAMING_PER_PAGE) }
+ end
+ end
+
+ describe '#total_pages' do
+ subject { blame_pagination.total_pages }
+
+ before do
+ stub_const("#{described_class.name}::PAGINATION_PER_PAGE", 2)
+ end
+
+ it { is_expected.to eq(2) }
+ end
+
+ describe '#total_extra_pages' do
+ subject { blame_pagination.total_extra_pages }
+
+ before do
+ stub_const("#{described_class.name}::PAGINATION_PER_PAGE", 2)
+ end
+
+ it { is_expected.to eq(1) }
+ end
+
+ describe '#pagination' do
+ subject { blame_pagination.paginator }
+
+ before do
+ stub_const("#{described_class.name}::PAGINATION_PER_PAGE", 2)
+ end
+
+ it 'returns a pagination object' do
+ is_expected.to be_kind_of(Kaminari::PaginatableArray)
+
+ expect(subject.current_page).to eq(1)
+ expect(subject.total_pages).to eq(2)
+ expect(subject.total_count).to eq(4)
+ end
+
+ context 'when user disabled the pagination' do
+ let(:full_mode) { true }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when user chose streaming' do
+ let(:streaming_mode) { true }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when per_page is above the global max per page limit' do
+ before do
+ stub_const("#{described_class.name}::PAGINATION_PER_PAGE", 1000)
+ allow(blob).to receive_message_chain(:data, :lines, :count) { 500 }
+ end
+
+ it 'returns a correct pagination object' do
+ is_expected.to be_kind_of(Kaminari::PaginatableArray)
+
+ expect(subject.current_page).to eq(1)
+ expect(subject.total_pages).to eq(1)
+ expect(subject.total_count).to eq(500)
+ end
+ end
+
+ describe 'Pagination attributes' do
+ where(:page, :current_page, :total_pages) do
+ 1 | 1 | 2
+ 2 | 2 | 2
+ 0 | 1 | 2 # Incorrect
+ end
+
+ with_them do
+ it 'returns the correct pagination attributes' do
+ expect(subject.current_page).to eq(current_page)
+ expect(subject.total_pages).to eq(total_pages)
+ end
+ end
+ end
+ end
+
+ describe '#blame_range' do
+ subject { blame_pagination.blame_range }
+
+ before do
+ stub_const("#{described_class.name}::PAGINATION_PER_PAGE", 2)
+ end
+
+ where(:page, :expected_range) do
+ 1 | (1..2)
+ 2 | (3..4)
+ 0 | (1..2)
+ end
+
+ with_them do
+ it { is_expected.to eq(expected_range) }
+ end
+
+ context 'when user disabled the pagination' do
+ let(:full_mode) { true }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when streaming is enabled' do
+ let(:streaming_mode) { true }
+
+ before do
+ stub_const("#{described_class.name}::STREAMING_FIRST_PAGE_SIZE", 1)
+ stub_const("#{described_class.name}::STREAMING_PER_PAGE", 1)
+ end
+
+ where(:page, :expected_range) do
+ 1 | (1..1)
+ 2 | (2..2)
+ 0 | (1..1)
+ end
+
+ with_them do
+ it { is_expected.to eq(expected_range) }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb
index d873151421d..e5f8918f7bb 100644
--- a/spec/lib/gitlab/git/commit_spec.rb
+++ b/spec/lib/gitlab/git/commit_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Gitlab::Git::Commit do
+RSpec.describe Gitlab::Git::Commit, feature_category: :source_code_management do
let(:repository) { create(:project, :repository).repository.raw }
let(:commit) { described_class.find(repository, SeedRepo::Commit::ID) }
@@ -61,10 +61,41 @@ RSpec.describe Gitlab::Git::Commit do
context 'body_size greater than threshold' do
let(:body_size) { described_class::MAX_COMMIT_MESSAGE_DISPLAY_SIZE + 1 }
- it 'returns the suject plus a notice about message size' do
+ it 'returns the subject plus a notice about message size' do
expect(commit.safe_message).to eq("My commit\n\n--commit message is too big")
end
end
+
+ context "large commit message" do
+ let(:user) { create(:user) }
+ let(:sha) { create_commit_with_large_message }
+ let(:commit) { repository.commit(sha) }
+
+ def create_commit_with_large_message
+ repository.commit_files(
+ user,
+ branch_name: 'HEAD',
+ message: "Repeat " * 10 * 1024,
+ actions: []
+ ).newrev
+ end
+
+ it 'returns a String' do
+ # When #message is called, its encoding is forced from
+ # ASCII-8BIT to UTF-8, and the method returns a
+ # string. Calling #message again may cause BatchLoader to
+ # return since the encoding has been modified to UTF-8, and
+ # the encoding helper will return the original object unmodified.
+ #
+ # To ensure #fetch_body_from_gitaly returns a String, invoke
+ # #to_s. In the test below, do a strict type check to ensure
+ # that a String is always returned. Note that the Rspec
+ # matcher be_instance_of(String) appears to evaluate the
+ # BatchLoader result, so we have to do a strict comparison
+ # here.
+ 2.times { expect(String === commit.message).to be true }
+ end
+ end
end
end
@@ -660,7 +691,8 @@ RSpec.describe Gitlab::Git::Commit do
id: SeedRepo::Commit::ID,
message: "tree css fixes",
parent_ids: ["874797c3a73b60d2187ed6e2fcabd289ff75171e"],
- trailers: {}
+ trailers: {},
+ referenced_by: []
}
end
end
diff --git a/spec/lib/gitlab/git/diff_collection_spec.rb b/spec/lib/gitlab/git/diff_collection_spec.rb
index 7fa5bd8a92b..5fa0447091c 100644
--- a/spec/lib/gitlab/git/diff_collection_spec.rb
+++ b/spec/lib/gitlab/git/diff_collection_spec.rb
@@ -777,6 +777,26 @@ RSpec.describe Gitlab::Git::DiffCollection do
end
end
+ describe '.limits' do
+ let(:options) { {} }
+
+ subject { described_class.limits(options) }
+
+ context 'when options do not include max_patch_bytes_for_file_extension' do
+ it 'sets max_patch_bytes_for_file_extension as empty' do
+ expect(subject[:max_patch_bytes_for_file_extension]).to eq({})
+ end
+ end
+
+ context 'when options include max_patch_bytes_for_file_extension' do
+ let(:options) { { max_patch_bytes_for_file_extension: { '.file' => 1 } } }
+
+ it 'sets value for max_patch_bytes_for_file_extension' do
+ expect(subject[:max_patch_bytes_for_file_extension]).to eq({ '.file' => 1 })
+ end
+ end
+ end
+
def fake_diff(line_length, line_count)
{ 'diff' => "#{'a' * line_length}\n" * line_count }
end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index a8423703716..06904849ef5 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -203,6 +203,25 @@ RSpec.describe Gitlab::Git::Repository, feature_category: :source_code_managemen
expect(metadata['CommitId']).to eq(expected_commit_id)
end
end
+
+ context 'when resolve_ambiguous_archives is disabled' do
+ before do
+ stub_feature_flags(resolve_ambiguous_archives: false)
+ end
+
+ where(:ref, :expected_commit_id, :desc) do
+ 'refs/heads/branch-merged' | ref(:branch_merged_commit_id) | 'when tag looks like a branch (difference!)'
+ 'branch-merged' | ref(:branch_master_commit_id) | 'when tag has the same name as a branch'
+ ref(:branch_merged_commit_id) | ref(:branch_merged_commit_id) | 'when tag looks like a commit id'
+ 'v0.0.0' | ref(:branch_master_commit_id) | 'when tag looks like a normal tag'
+ end
+
+ with_them do
+ it 'selects the correct commit' do
+ expect(metadata['CommitId']).to eq(expected_commit_id)
+ end
+ end
+ end
end
context 'when branch is ambiguous' do
@@ -222,6 +241,25 @@ RSpec.describe Gitlab::Git::Repository, feature_category: :source_code_managemen
expect(metadata['CommitId']).to eq(expected_commit_id)
end
end
+
+ context 'when resolve_ambiguous_archives is disabled' do
+ before do
+ stub_feature_flags(resolve_ambiguous_archives: false)
+ end
+
+ where(:ref, :expected_commit_id, :desc) do
+ 'refs/tags/v1.0.0' | ref(:tag_1_0_0_commit_id) | 'when branch looks like a tag (difference!)'
+ 'v1.0.0' | ref(:tag_1_0_0_commit_id) | 'when branch has the same name as a tag'
+ ref(:branch_merged_commit_id) | ref(:branch_merged_commit_id) | 'when branch looks like a commit id'
+ 'just-a-normal-branch' | ref(:branch_master_commit_id) | 'when branch looks like a normal branch'
+ end
+
+ with_them do
+ it 'selects the correct commit' do
+ expect(metadata['CommitId']).to eq(expected_commit_id)
+ end
+ end
+ end
end
context 'when ref is HEAD' do
@@ -1820,8 +1858,8 @@ RSpec.describe Gitlab::Git::Repository, feature_category: :source_code_managemen
context 'when Gitaly returns Internal error' do
before do
- expect(repository.gitaly_ref_client)
- .to receive(:find_tag)
+ expect(Gitlab::GitalyClient)
+ .to receive(:call)
.and_raise(GRPC::Internal, "tag not found")
end
@@ -1830,8 +1868,8 @@ RSpec.describe Gitlab::Git::Repository, feature_category: :source_code_managemen
context 'when Gitaly returns tag_not_found error' do
before do
- expect(repository.gitaly_ref_client)
- .to receive(:find_tag)
+ expect(Gitlab::GitalyClient)
+ .to receive(:call)
.and_raise(new_detailed_error(GRPC::Core::StatusCodes::NOT_FOUND,
"tag was not found",
Gitaly::FindTagError.new(tag_not_found: Gitaly::ReferenceNotFoundError.new)))
@@ -1862,47 +1900,37 @@ RSpec.describe Gitlab::Git::Repository, feature_category: :source_code_managemen
end
describe '#license' do
- where(from_gitaly: [true, false])
- with_them do
- subject(:license) { repository.license(from_gitaly) }
+ subject(:license) { repository.license }
- context 'when no license file can be found' do
- let_it_be(:project) { create(:project, :repository) }
- let(:repository) { project.repository.raw_repository }
+ context 'when no license file can be found' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:repository) { project.repository.raw_repository }
- before do
- project.repository.delete_file(project.owner, 'LICENSE', message: 'remove license', branch_name: 'master')
- end
-
- it { is_expected.to be_nil }
+ before do
+ project.repository.delete_file(project.owner, 'LICENSE', message: 'remove license', branch_name: 'master')
end
- context 'when an mit license is found' do
- it { is_expected.to have_attributes(key: 'mit') }
- end
+ it { is_expected.to be_nil }
+ end
- context 'when license is not recognized ' do
- let_it_be(:project) { create(:project, :repository) }
- let(:repository) { project.repository.raw_repository }
+ context 'when an mit license is found' do
+ it { is_expected.to have_attributes(key: 'mit') }
+ end
- before do
- project.repository.update_file(
- project.owner,
- 'LICENSE',
- 'This software is licensed under the Dummy license.',
- message: 'Update license',
- branch_name: 'master')
- end
+ context 'when license is not recognized ' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:repository) { project.repository.raw_repository }
- it { is_expected.to have_attributes(key: 'other', nickname: 'LICENSE') }
+ before do
+ project.repository.update_file(
+ project.owner,
+ 'LICENSE',
+ 'This software is licensed under the Dummy license.',
+ message: 'Update license',
+ branch_name: 'master')
end
- end
- it 'does not crash when license is invalid' do
- expect(Licensee::License).to receive(:new)
- .and_raise(Licensee::InvalidLicense)
-
- expect(repository.license(false)).to be_nil
+ it { is_expected.to have_attributes(key: 'other', nickname: 'LICENSE') }
end
end
@@ -2424,107 +2452,6 @@ RSpec.describe Gitlab::Git::Repository, feature_category: :source_code_managemen
end
end
- describe '#squash' do
- let(:branch_name) { 'fix' }
- let(:start_sha) { TestEnv::BRANCH_SHA['master'] }
- let(:end_sha) { '12d65c8dd2b2676fa3ac47d955accc085a37a9c1' }
-
- subject do
- opts = {
- branch: branch_name,
- start_sha: start_sha,
- end_sha: end_sha,
- author: user,
- message: 'Squash commit message'
- }
-
- repository.squash(user, opts)
- end
-
- # Should be ported to gitaly-ruby rspec suite https://gitlab.com/gitlab-org/gitaly/issues/1234
- skip 'sparse checkout' do
- let(:expected_files) { %w(files files/js files/js/application.js) }
-
- it 'checks out only the files in the diff' do
- allow(repository).to receive(:with_worktree).and_wrap_original do |m, *args|
- m.call(*args) do
- worktree_path = args[0]
- files_pattern = File.join(worktree_path, '**', '*')
- expected = expected_files.map do |path|
- File.expand_path(path, worktree_path)
- end
-
- expect(Dir[files_pattern]).to eq(expected)
- end
- end
-
- subject
- end
-
- context 'when the diff contains a rename' do
- let(:end_sha) do
- repository.commit_files(
- user,
- branch_name: repository.root_ref,
- message: 'Move CHANGELOG to encoding/',
- actions: [{
- action: :move,
- previous_path: 'CHANGELOG',
- file_path: 'encoding/CHANGELOG',
- content: 'CHANGELOG'
- }]
- ).newrev
- end
-
- after do
- # Erase our commits so other tests get the original repo
- repository.write_ref(repository.root_ref, TestEnv::BRANCH_SHA['master'])
- end
-
- it 'does not include the renamed file in the sparse checkout' do
- allow(repository).to receive(:with_worktree).and_wrap_original do |m, *args|
- m.call(*args) do
- worktree_path = args[0]
- files_pattern = File.join(worktree_path, '**', '*')
-
- expect(Dir[files_pattern]).not_to include('CHANGELOG')
- expect(Dir[files_pattern]).not_to include('encoding/CHANGELOG')
- end
- end
-
- subject
- end
- end
- end
-
- # Should be ported to gitaly-ruby rspec suite https://gitlab.com/gitlab-org/gitaly/issues/1234
- skip 'with an ASCII-8BIT diff' do
- let(:diff) { "diff --git a/README.md b/README.md\nindex faaf198..43c5edf 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,4 +1,4 @@\n-testme\n+✓ testme\n ======\n \n Sample repo for testing gitlab features\n" }
-
- it 'applies a ASCII-8BIT diff' do
- allow(repository).to receive(:run_git!).and_call_original
- allow(repository).to receive(:run_git!).with(%W(diff --binary #{start_sha}...#{end_sha})).and_return(diff.force_encoding('ASCII-8BIT'))
-
- expect(subject).to match(/\h{40}/)
- end
- end
-
- # Should be ported to gitaly-ruby rspec suite https://gitlab.com/gitlab-org/gitaly/issues/1234
- skip 'with trailing whitespace in an invalid patch' do
- let(:diff) { "diff --git a/README.md b/README.md\nindex faaf198..43c5edf 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,4 +1,4 @@\n-testme\n+ \n ====== \n \n Sample repo for testing gitlab features\n" }
-
- it 'does not include whitespace warnings in the error' do
- allow(repository).to receive(:run_git!).and_call_original
- allow(repository).to receive(:run_git!).with(%W(diff --binary #{start_sha}...#{end_sha})).and_return(diff.force_encoding('ASCII-8BIT'))
-
- expect { subject }.to raise_error do |error|
- expect(error).to be_a(described_class::GitError)
- expect(error.message).not_to include('trailing whitespace')
- end
- end
- end
- end
-
def create_remote_branch(remote_name, branch_name, source_branch_name)
source_branch = repository.find_branch(source_branch_name)
repository.write_ref("refs/remotes/#{remote_name}/#{branch_name}", source_branch.dereferenced_target.sha)
diff --git a/spec/lib/gitlab/git/wraps_gitaly_errors_spec.rb b/spec/lib/gitlab/git/wraps_gitaly_errors_spec.rb
index e551dfaa1c5..c321d4bbdb9 100644
--- a/spec/lib/gitlab/git/wraps_gitaly_errors_spec.rb
+++ b/spec/lib/gitlab/git/wraps_gitaly_errors_spec.rb
@@ -2,24 +2,81 @@
require 'spec_helper'
-RSpec.describe Gitlab::Git::WrapsGitalyErrors do
+RSpec.describe Gitlab::Git::WrapsGitalyErrors, feature_category: :gitaly do
subject(:wrapper) do
klazz = Class.new { include Gitlab::Git::WrapsGitalyErrors }
klazz.new
end
describe "#wrapped_gitaly_errors" do
- mapping = {
- GRPC::NotFound => Gitlab::Git::Repository::NoRepository,
- GRPC::InvalidArgument => ArgumentError,
- GRPC::DeadlineExceeded => Gitlab::Git::CommandTimedOut,
- GRPC::BadStatus => Gitlab::Git::CommandError
- }
-
- mapping.each do |grpc_error, error|
- it "wraps #{grpc_error} in a #{error}" do
- expect { wrapper.wrapped_gitaly_errors { raise grpc_error, 'wrapped' } }
- .to raise_error(error)
+ where(:original_error, :wrapped_error) do
+ [
+ [GRPC::NotFound, Gitlab::Git::Repository::NoRepository],
+ [GRPC::InvalidArgument, ArgumentError],
+ [GRPC::DeadlineExceeded, Gitlab::Git::CommandTimedOut],
+ [GRPC::BadStatus, Gitlab::Git::CommandError]
+ ]
+ end
+
+ with_them do
+ it "wraps #{params[:original_error]} in a #{params[:wrapped_error]}" do
+ expect { wrapper.wrapped_gitaly_errors { raise original_error, 'wrapped' } }
+ .to raise_error(wrapped_error)
+ end
+ end
+
+ context 'when wrap GRPC::ResourceExhausted' do
+ context 'with Gitaly::LimitError detail' do
+ let(:original_error) do
+ new_detailed_error(
+ GRPC::Core::StatusCodes::RESOURCE_EXHAUSTED,
+ 'resource exhausted',
+ Gitaly::LimitError.new(
+ error_message: "maximum time in concurrency queue reached",
+ retry_after: Google::Protobuf::Duration.new(seconds: 5, nanos: 1500)
+ )
+ )
+ end
+
+ it "wraps in a Gitlab::Git::ResourceExhaustedError with error message" do
+ expect { wrapper.wrapped_gitaly_errors { raise original_error } }.to raise_error do |wrapped_error|
+ expect(wrapped_error).to be_a(Gitlab::Git::ResourceExhaustedError)
+ expect(wrapped_error.message).to eql(
+ "Upstream Gitaly has been exhausted: maximum time in concurrency queue reached. Try again later"
+ )
+ expect(wrapped_error.headers).to eql({ 'Retry-After' => 5 })
+ end
+ end
+ end
+
+ context 'with Gitaly::LimitError detail without retry after' do
+ let(:original_error) do
+ new_detailed_error(
+ GRPC::Core::StatusCodes::RESOURCE_EXHAUSTED,
+ 'resource exhausted',
+ Gitaly::LimitError.new(error_message: "maximum time in concurrency queue reached")
+ )
+ end
+
+ it "wraps in a Gitlab::Git::ResourceExhaustedError with error message" do
+ expect { wrapper.wrapped_gitaly_errors { raise original_error } }.to raise_error do |wrapped_error|
+ expect(wrapped_error).to be_a(Gitlab::Git::ResourceExhaustedError)
+ expect(wrapped_error.message).to eql(
+ "Upstream Gitaly has been exhausted: maximum time in concurrency queue reached. Try again later"
+ )
+ expect(wrapped_error.headers).to eql({})
+ end
+ end
+ end
+
+ context 'without Gitaly::LimitError detail' do
+ it("wraps in a Gitlab::Git::ResourceExhaustedError with default message") {
+ expect { wrapper.wrapped_gitaly_errors { raise GRPC::ResourceExhausted } }.to raise_error do |wrapped_error|
+ expect(wrapped_error).to be_a(Gitlab::Git::ResourceExhaustedError)
+ expect(wrapped_error.message).to eql("Upstream Gitaly has been exhausted. Try again later")
+ expect(wrapped_error.headers).to eql({})
+ end
+ }
end
end
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index ea2c239df07..1b205aa5c85 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GitAccess, :aggregate_failures, feature_category: :authentication_and_authorization do
+RSpec.describe Gitlab::GitAccess, :aggregate_failures, feature_category: :system_access do
include TermsHelper
include AdminModeHelper
include ExternalAuthorizationServiceHelpers
@@ -869,11 +869,13 @@ RSpec.describe Gitlab::GitAccess, :aggregate_failures, feature_category: :authen
check = -> { push_changes(changes[action]) }
if allowed
- expect(&check).not_to raise_error,
- -> { "expected #{action} to be allowed" }
+ expect(&check).not_to raise_error, -> do
+ "expected #{action} for #{role} to be allowed while #{who_can_action}"
+ end
else
- expect(&check).to raise_error(Gitlab::GitAccess::ForbiddenError),
- -> { "expected #{action} to be disallowed" }
+ expect(&check).to raise_error(Gitlab::GitAccess::ForbiddenError), -> do
+ "expected #{action} for #{role} to be disallowed while #{who_can_action}"
+ end
end
end
end
@@ -886,12 +888,12 @@ RSpec.describe Gitlab::GitAccess, :aggregate_failures, feature_category: :authen
any: true,
push_new_branch: true,
push_master: true,
- push_protected_branch: true,
+ push_protected_branch: false,
push_remove_protected_branch: false,
push_tag: true,
push_new_tag: true,
- push_all: true,
- merge_into_protected_branch: true
+ push_all: false,
+ merge_into_protected_branch: false
},
admin_without_admin_mode: {
@@ -957,19 +959,22 @@ RSpec.describe Gitlab::GitAccess, :aggregate_failures, feature_category: :authen
[%w(feature exact), ['feat*', 'wildcard']].each do |protected_branch_name, protected_branch_type|
context do
- let(:protected_branch) { create(:protected_branch, :maintainers_can_push, name: protected_branch_name, project: project) }
+ let(:who_can_action) { :maintainers_can_push }
+ let(:protected_branch) { create(:protected_branch, who_can_action, name: protected_branch_name, project: project) }
run_permission_checks(permissions_matrix)
end
context "when developers are allowed to push into the #{protected_branch_type} protected branch" do
- let(:protected_branch) { create(:protected_branch, :developers_can_push, name: protected_branch_name, project: project) }
+ let(:who_can_action) { :developers_can_push }
+ let(:protected_branch) { create(:protected_branch, who_can_action, name: protected_branch_name, project: project) }
run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true }))
end
- context "developers are allowed to merge into the #{protected_branch_type} protected branch" do
- let(:protected_branch) { create(:protected_branch, :developers_can_merge, name: protected_branch_name, project: project) }
+ context "when developers are allowed to merge into the #{protected_branch_type} protected branch" do
+ let(:who_can_action) { :developers_can_merge }
+ let(:protected_branch) { create(:protected_branch, who_can_action, name: protected_branch_name, project: project) }
context "when a merge request exists for the given source/target branch" do
context "when the merge request is in progress" do
@@ -996,6 +1001,7 @@ RSpec.describe Gitlab::GitAccess, :aggregate_failures, feature_category: :authen
end
context "when developers are allowed to push and merge into the #{protected_branch_type} protected branch" do
+ let(:who_can_action) { :developers_can_push_and_merge }
let(:protected_branch) { create(:protected_branch, :developers_can_merge, :developers_can_push, name: protected_branch_name, project: project) }
run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true }))
diff --git a/spec/lib/gitlab/git_ref_validator_spec.rb b/spec/lib/gitlab/git_ref_validator_spec.rb
index 03dd4e7b89b..1a79817130c 100644
--- a/spec/lib/gitlab/git_ref_validator_spec.rb
+++ b/spec/lib/gitlab/git_ref_validator_spec.rb
@@ -37,6 +37,11 @@ RSpec.describe Gitlab::GitRefValidator do
it { expect(described_class.validate("\xA0\u0000\xB0")).to be false }
it { expect(described_class.validate("")).to be false }
it { expect(described_class.validate(nil)).to be false }
+ it { expect(described_class.validate('HEAD')).to be false }
+
+ context 'when skip_head_ref_check is true' do
+ it { expect(described_class.validate('HEAD', skip_head_ref_check: true)).to be true }
+ end
end
describe '.validate_merge_request_branch' do
diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
index 252d20d9c3a..05205ab6d6a 100644
--- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GitalyClient::CommitService do
+RSpec.describe Gitlab::GitalyClient::CommitService, feature_category: :gitaly do
let_it_be(:project) { create(:project, :repository) }
let(:storage_name) { project.repository_storage }
@@ -406,6 +406,18 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
end
shared_examples 'a #list_all_commits message' do
+ let(:objects_exist_repo) do
+ # The object directory of the repository must not be set so that we
+ # don't use the quarantine directory.
+ repository.gitaly_repository.dup.tap do |repo|
+ repo.git_object_directory = ''
+ end
+ end
+
+ let(:expected_object_exist_requests) do
+ [gitaly_request_with_params(repository: objects_exist_repo, revisions: gitaly_commits.map(&:id))]
+ end
+
it 'sends a list_all_commits message' do
expected_repository = repository.gitaly_repository.dup
expected_repository.git_alternate_object_directories = Google::Protobuf::RepeatedField.new(:string)
@@ -415,24 +427,12 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
.with(gitaly_request_with_params(repository: expected_repository), kind_of(Hash))
.and_return([Gitaly::ListAllCommitsResponse.new(commits: gitaly_commits)])
- # The object directory of the repository must not be set so that we
- # don't use the quarantine directory.
- objects_exist_repo = repository.gitaly_repository.dup
- objects_exist_repo.git_object_directory = ""
-
- # The first request contains the repository, the second request the
- # commit IDs we want to check for existence.
- objects_exist_request = [
- gitaly_request_with_params(repository: objects_exist_repo),
- gitaly_request_with_params(revisions: gitaly_commits.map(&:id))
- ]
-
objects_exist_response = Gitaly::CheckObjectsExistResponse.new(revisions: revision_existence.map do
|rev, exists| Gitaly::CheckObjectsExistResponse::RevisionExistence.new(name: rev, exists: exists)
end)
expect(service).to receive(:check_objects_exist)
- .with(objects_exist_request, kind_of(Hash))
+ .with(expected_object_exist_requests, kind_of(Hash))
.and_return([objects_exist_response])
end
@@ -495,6 +495,20 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
it_behaves_like 'a #list_all_commits message'
end
+
+ context 'with more than 100 commits' do
+ let(:gitaly_commits) { build_list(:gitaly_commit, 101) }
+ let(:revision_existence) { gitaly_commits.to_h { |c| [c.id, false] } }
+
+ it_behaves_like 'a #list_all_commits message' do
+ let(:expected_object_exist_requests) do
+ [
+ gitaly_request_with_params(repository: objects_exist_repo, revisions: gitaly_commits[0...100].map(&:id)),
+ gitaly_request_with_params(revisions: gitaly_commits[100..].map(&:id))
+ ]
+ end
+ end
+ end
end
context 'without hook environment' do
@@ -588,9 +602,7 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
it 'returns expected results' do
expect_next_instance_of(Gitaly::CommitService::Stub) do |service|
- expect(service)
- .to receive(:check_objects_exist)
- .and_call_original
+ expect(service).to receive(:check_objects_exist).and_call_original
end
expect(client.object_existence_map(revisions.keys)).to eq(revisions)
@@ -600,7 +612,11 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
context 'with empty request' do
let(:revisions) { {} }
- it_behaves_like 'a CheckObjectsExistRequest'
+ it 'doesnt call for Gitaly' do
+ expect(Gitaly::CommitService::Stub).not_to receive(:new)
+
+ expect(client.object_existence_map(revisions.keys)).to eq(revisions)
+ end
end
context 'when revision exists' do
diff --git a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
index 09d8ea3cc0a..7bdfa8922d3 100644
--- a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
@@ -213,8 +213,13 @@ RSpec.describe Gitlab::GitalyClient::RefService, feature_category: :gitaly do
client.local_branches(sort_by: 'name_asc')
end
- it 'raises an argument error if an invalid sort_by parameter is passed' do
- expect { client.local_branches(sort_by: 'invalid_sort') }.to raise_error(ArgumentError)
+ it 'uses default sort by name' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_local_branches)
+ .with(gitaly_request_with_params(sort_by: :NAME), kind_of(Hash))
+ .and_return([])
+
+ client.local_branches(sort_by: 'invalid')
end
end
@@ -270,6 +275,17 @@ RSpec.describe Gitlab::GitalyClient::RefService, feature_category: :gitaly do
client.tags(sort_by: 'version_asc')
end
end
+
+ context 'when sorting option is invalid' do
+ it 'uses default sort by name' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_all_tags)
+ .with(gitaly_request_with_params(sort_by: nil), kind_of(Hash))
+ .and_return([])
+
+ client.tags(sort_by: 'invalid')
+ end
+ end
end
context 'with pagination option' do
diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
index 434550186c1..f457ba06074 100644
--- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
@@ -275,7 +275,8 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do
it 'sends a create_repository message without arguments' do
expect_any_instance_of(Gitaly::RepositoryService::Stub)
.to receive(:create_repository)
- .with(gitaly_request_with_path(storage_name, relative_path).and(gitaly_request_with_params(default_branch: '')), kind_of(Hash))
+ .with(gitaly_request_with_path(storage_name, relative_path)
+ .and(gitaly_request_with_params(default_branch: '')), kind_of(Hash))
.and_return(double)
client.create_repository
@@ -284,11 +285,23 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do
it 'sends a create_repository message with default branch' do
expect_any_instance_of(Gitaly::RepositoryService::Stub)
.to receive(:create_repository)
- .with(gitaly_request_with_path(storage_name, relative_path).and(gitaly_request_with_params(default_branch: 'default-branch-name')), kind_of(Hash))
+ .with(gitaly_request_with_path(storage_name, relative_path)
+ .and(gitaly_request_with_params(default_branch: 'default-branch-name')), kind_of(Hash))
.and_return(double)
client.create_repository('default-branch-name')
end
+
+ it 'sends a create_repository message with default branch containing non ascii chars' do
+ expect_any_instance_of(Gitaly::RepositoryService::Stub)
+ .to receive(:create_repository)
+ .with(gitaly_request_with_path(storage_name, relative_path)
+ .and(gitaly_request_with_params(
+ default_branch: Gitlab::EncodingHelper.encode_binary('feature/新機能'))), kind_of(Hash)
+ ).and_return(double)
+
+ client.create_repository('feature/新機能')
+ end
end
describe '#create_from_snapshot' do
@@ -314,17 +327,31 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do
end
describe '#search_files_by_regexp' do
- subject(:result) { client.search_files_by_regexp('master', '.*') }
+ subject(:result) { client.search_files_by_regexp(ref, '.*') }
before do
expect_any_instance_of(Gitaly::RepositoryService::Stub)
.to receive(:search_files_by_name)
- .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
- .and_return([double(files: ['file1.txt']), double(files: ['file2.txt'])])
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return([double(files: ['file1.txt']), double(files: ['file2.txt'])])
end
- it 'sends a search_files_by_name message and returns a flatten array' do
- expect(result).to contain_exactly('file1.txt', 'file2.txt')
+ shared_examples 'a search for files by regexp' do
+ it 'sends a search_files_by_name message and returns a flatten array' do
+ expect(result).to contain_exactly('file1.txt', 'file2.txt')
+ end
+ end
+
+ context 'with ASCII ref' do
+ let(:ref) { 'master' }
+
+ it_behaves_like 'a search for files by regexp'
+ end
+
+ context 'with non-ASCII ref' do
+ let(:ref) { 'ref-ñéüçæøß-val' }
+
+ it_behaves_like 'a search for files by regexp'
end
end
diff --git a/spec/lib/gitlab/gitaly_client/with_feature_flag_actors_spec.rb b/spec/lib/gitlab/gitaly_client/with_feature_flag_actors_spec.rb
index 61945cc06b8..42153a9a3d8 100644
--- a/spec/lib/gitlab/gitaly_client/with_feature_flag_actors_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/with_feature_flag_actors_spec.rb
@@ -131,23 +131,23 @@ RSpec.describe Gitlab::GitalyClient::WithFeatureFlagActors do
end
context 'when project design' do
- let_it_be(:project) { create(:project, group: create(:group)) }
- let(:issue) { create(:issue, project: project) }
- let(:design) { create(:design, issue: issue) }
+ let_it_be(:design_repo) do
+ create(:design_management_repository, project: create(:project, group: create(:group)))
+ end
- let(:expected_project) { project }
- let(:expected_group) { project.group }
+ let(:expected_project) { design_repo.project }
+ let(:expected_group) { design_repo.project.group }
it_behaves_like 'Gitaly feature flag actors are inferred from repository' do
- let(:repository) { design.repository }
+ let(:repository) { design_repo.repository }
end
it_behaves_like 'Gitaly feature flag actors are inferred from repository' do
- let(:repository) { design.repository.raw }
+ let(:repository) { design_repo.repository.raw }
end
it_behaves_like 'Gitaly feature flag actors are inferred from repository' do
- let(:repository) { raw_repo_without_container(design.repository) }
+ let(:repository) { raw_repo_without_container(design_repo.repository) }
end
end
end
diff --git a/spec/lib/gitlab/github_import/bulk_importing_spec.rb b/spec/lib/gitlab/github_import/bulk_importing_spec.rb
index 136ddb566aa..28fbd4d883f 100644
--- a/spec/lib/gitlab/github_import/bulk_importing_spec.rb
+++ b/spec/lib/gitlab/github_import/bulk_importing_spec.rb
@@ -13,6 +13,8 @@ RSpec.describe Gitlab::GithubImport::BulkImporting, feature_category: :importers
:object_type
end
+ private
+
def model
Label
end
@@ -26,85 +28,153 @@ RSpec.describe Gitlab::GithubImport::BulkImporting, feature_category: :importers
end
describe '#build_database_rows' do
- it 'returns an Array containing the rows to insert and validation errors if object invalid' do
- object = double(:object, title: 'Foo')
-
- expect(importer)
- .to receive(:build_attributes)
- .with(object)
- .and_return({ title: 'Foo' })
-
- expect(Label)
- .to receive(:new)
- .with({ title: 'Foo' })
- .and_return(label)
-
- expect(importer)
- .to receive(:already_imported?)
- .with(object)
- .and_return(false)
-
- expect(Gitlab::Import::Logger)
- .to receive(:info)
- .with(
- import_type: :github,
- project_id: 1,
- importer: 'MyImporter',
- message: '1 object_types fetched'
- )
-
- expect(Gitlab::GithubImport::ObjectCounter)
- .to receive(:increment)
- .with(
- project,
- :object_type,
- :fetched,
- value: 1
- )
-
- enum = [[object, 1]].to_enum
-
- rows, errors = importer.build_database_rows(enum)
+ context 'without validation errors' do
+ let(:object) { double(:object, title: 'Foo') }
+
+ it 'returns an array containing the rows to insert' do
+ expect(importer)
+ .to receive(:build_attributes)
+ .with(object)
+ .and_return({ title: 'Foo' })
+
+ expect(Label)
+ .to receive(:new)
+ .with({ title: 'Foo' })
+ .and_return(label)
+
+ expect(importer)
+ .to receive(:already_imported?)
+ .with(object)
+ .and_return(false)
+
+ expect(Gitlab::Import::Logger)
+ .to receive(:info)
+ .with(
+ import_type: :github,
+ project_id: 1,
+ importer: 'MyImporter',
+ message: '1 object_types fetched'
+ )
+
+ expect(Gitlab::GithubImport::ObjectCounter)
+ .to receive(:increment)
+ .with(
+ project,
+ :object_type,
+ :fetched,
+ value: 1
+ )
+
+ enum = [[object, 1]].to_enum
+
+ rows, errors = importer.build_database_rows(enum)
+
+ expect(rows).to match_array([{ title: 'Foo' }])
+ expect(errors).to be_empty
+ end
- expect(rows).to match_array([{ title: 'Foo' }])
- expect(errors).to be_empty
+ it 'does not import objects that have already been imported' do
+ expect(importer)
+ .not_to receive(:build_attributes)
+
+ expect(importer)
+ .to receive(:already_imported?)
+ .with(object)
+ .and_return(true)
+
+ expect(Gitlab::Import::Logger)
+ .to receive(:info)
+ .with(
+ import_type: :github,
+ project_id: 1,
+ importer: 'MyImporter',
+ message: '0 object_types fetched'
+ )
+
+ expect(Gitlab::GithubImport::ObjectCounter)
+ .to receive(:increment)
+ .with(
+ project,
+ :object_type,
+ :fetched,
+ value: 0
+ )
+
+ enum = [[object, 1]].to_enum
+
+ rows, errors = importer.build_database_rows(enum)
+
+ expect(rows).to be_empty
+ expect(errors).to be_empty
+ end
end
- it 'does not import objects that have already been imported' do
- object = double(:object, title: 'Foo')
-
- expect(importer)
- .not_to receive(:build_attributes)
+ context 'with validation errors' do
+ let(:object) { double(:object, id: 12345, title: 'bug,bug') }
- expect(importer)
- .to receive(:already_imported?)
- .with(object)
- .and_return(true)
+ before do
+ allow(importer)
+ .to receive(:already_imported?)
+ .with(object)
+ .and_return(false)
- expect(Gitlab::Import::Logger)
- .to receive(:info)
- .with(
- import_type: :github,
- project_id: 1,
- importer: 'MyImporter',
- message: '0 object_types fetched'
- )
-
- expect(Gitlab::GithubImport::ObjectCounter)
- .to receive(:increment)
- .with(
- project,
- :object_type,
- :fetched,
- value: 0
- )
+ allow(importer)
+ .to receive(:build_attributes)
+ .with(object)
+ .and_return({ title: 'bug,bug' })
+ end
- enum = [[object, 1]].to_enum
+ context 'without implemented github_identifiers method' do
+ it 'raises NotImplementedError' do
+ enum = [[object, 1]].to_enum
- rows, errors = importer.build_database_rows(enum)
+ expect { importer.build_database_rows(enum) }.to raise_error(NotImplementedError)
+ end
+ end
- expect(rows).to be_empty
- expect(errors).to be_empty
+ context 'with implemented github_identifiers method' do
+ it 'returns an array containing the validation errors and logs them' do
+ expect(importer)
+ .to receive(:github_identifiers)
+ .with(object)
+ .and_return(
+ {
+ id: object.id,
+ title: object.title,
+ object_type: importer.object_type
+ }
+ )
+
+ expect(Gitlab::Import::Logger)
+ .to receive(:error)
+ .with(
+ import_type: :github,
+ project_id: 1,
+ importer: 'MyImporter',
+ message: ['Title is invalid'],
+ github_identifiers: { id: 12345, title: 'bug,bug', object_type: :object_type }
+ )
+
+ expect(Gitlab::GithubImport::ObjectCounter)
+ .to receive(:increment)
+ .with(
+ project,
+ :object_type,
+ :fetched,
+ value: 0
+ )
+
+ enum = [[object, 1]].to_enum
+
+ rows, errors = importer.build_database_rows(enum)
+
+ expect(rows).to be_empty
+ expect(errors).not_to be_empty
+
+ expect(errors[0][:validation_errors].full_messages).to match_array(['Title is invalid'])
+ expect(errors[0][:github_identifiers]).to eq({ id: 12345, title: 'bug,bug', object_type: :object_type })
+ end
+ end
end
end
@@ -157,7 +227,8 @@ RSpec.describe Gitlab::GithubImport::BulkImporting, feature_category: :importers
exception_message: 'Title invalid',
correlation_id_value: 'cid',
retry_count: nil,
- created_at: Time.zone.now
+ created_at: Time.zone.now,
+ external_identifiers: { id: 123456 }
}]
end
@@ -170,8 +241,23 @@ RSpec.describe Gitlab::GithubImport::BulkImporting, feature_category: :importers
expect(import_failures).to receive(:insert_all).with(formatted_errors)
expect(Labkit::Correlation::CorrelationId).to receive(:current_or_new_id).and_return('cid')
- importer.bulk_insert_failures([error])
+ importer.bulk_insert_failures([{
+ validation_errors: error,
+ github_identifiers: { id: 123456 }
+ }])
end
end
end
+
+ describe '#object_type' do
+ let(:importer_class) do
+ Class.new do
+ include Gitlab::GithubImport::BulkImporting
+ end
+ end
+
+ it 'raises NotImplementedError' do
+ expect { importer.object_type }.to raise_error(NotImplementedError)
+ end
+ end
end
diff --git a/spec/lib/gitlab/github_import/client_spec.rb b/spec/lib/gitlab/github_import/client_spec.rb
index e93d585bc3c..c9f7fd4f748 100644
--- a/spec/lib/gitlab/github_import/client_spec.rb
+++ b/spec/lib/gitlab/github_import/client_spec.rb
@@ -131,6 +131,16 @@ RSpec.describe Gitlab::GithubImport::Client, feature_category: :importers do
end
end
+ describe '#collaborators' do
+ it 'returns the collaborators' do
+ expect(client)
+ .to receive(:each_object)
+ .with(:collaborators, 'foo/bar')
+
+ client.collaborators('foo/bar')
+ end
+ end
+
describe '#branch_protection' do
it 'returns the protection details for the given branch' do
expect(client.octokit)
@@ -580,7 +590,10 @@ RSpec.describe Gitlab::GithubImport::Client, feature_category: :importers do
end
describe '#search_repos_by_name_graphql' do
- let(:expected_query) { 'test in:name is:public,private user:user repo:repo1 repo:repo2 org:org1 org:org2' }
+ let(:expected_query) do
+ 'test in:name is:public,private fork:true user:user repo:repo1 repo:repo2 org:org1 org:org2'
+ end
+
let(:expected_graphql_params) { "type: REPOSITORY, query: \"#{expected_query}\"" }
let(:expected_graphql) do
<<-TEXT
@@ -600,7 +613,8 @@ RSpec.describe Gitlab::GithubImport::Client, feature_category: :importers do
endCursor
hasNextPage
hasPreviousPage
- }
+ },
+ repositoryCount
}
}
TEXT
@@ -616,7 +630,7 @@ RSpec.describe Gitlab::GithubImport::Client, feature_category: :importers do
context 'when relation type option present' do
context 'when relation type is owned' do
- let(:expected_query) { 'test in:name is:public,private user:user' }
+ let(:expected_query) { 'test in:name is:public,private fork:true user:user' }
it 'searches for repositories within the organization based on name' do
expect(client.octokit).to receive(:post).with(
@@ -628,7 +642,7 @@ RSpec.describe Gitlab::GithubImport::Client, feature_category: :importers do
end
context 'when relation type is organization' do
- let(:expected_query) { 'test in:name is:public,private org:test-login' }
+ let(:expected_query) { 'test in:name is:public,private fork:true org:test-login' }
it 'searches for repositories within the organization based on name' do
expect(client.octokit).to receive(:post).with(
@@ -642,7 +656,7 @@ RSpec.describe Gitlab::GithubImport::Client, feature_category: :importers do
end
context 'when relation type is collaborated' do
- let(:expected_query) { 'test in:name is:public,private repo:repo1 repo:repo2' }
+ let(:expected_query) { 'test in:name is:public,private fork:true repo:repo1 repo:repo2' }
it 'searches for collaborated repositories based on name' do
expect(client.octokit).to receive(:post).with(
@@ -707,44 +721,30 @@ RSpec.describe Gitlab::GithubImport::Client, feature_category: :importers do
end
end
- describe '#search_repos_by_name' do
- let(:expected_query) { 'test in:name is:public,private user:user repo:repo1 repo:repo2 org:org1 org:org2' }
-
- it 'searches for repositories based on name' do
- expect(client.octokit).to receive(:search_repositories).with(expected_query, {})
+ describe '#count_repos_by_relation_type_graphql' do
+ relation_types = {
+ 'owned' => ' in:name is:public,private fork:true user:user',
+ 'collaborated' => ' in:name is:public,private fork:true repo:repo1 repo:repo2',
+ 'organization' => 'org:org1 org:org2'
+ }
- client.search_repos_by_name('test')
- end
+ relation_types.each do |relation_type, expected_query|
+ expected_graphql_params = "type: REPOSITORY, query: \"#{expected_query}\""
+ expected_graphql =
+ <<-TEXT
+ {
+ search(#{expected_graphql_params}) {
+ repositoryCount
+ }
+ }
+ TEXT
- context 'when pagination options present' do
- it 'searches for repositories via expected query' do
- expect(client.octokit).to receive(:search_repositories).with(
- expected_query, { page: 2, per_page: 25 }
+ it 'returns count by relation_type' do
+ expect(client.octokit).to receive(:post).with(
+ '/graphql', { query: expected_graphql }.to_json
)
- client.search_repos_by_name('test', { page: 2, per_page: 25 })
- end
- end
-
- context 'when Faraday error received from octokit', :aggregate_failures do
- let(:error_class) { described_class::CLIENT_CONNECTION_ERROR }
- let(:info_params) { { 'error.class': error_class } }
-
- it 'retries on error and succeeds' do
- allow_retry(:search_repositories)
-
- expect(Gitlab::Import::Logger).to receive(:info).with(hash_including(info_params)).once
-
- expect(client.search_repos_by_name('test')).to eq({})
- end
-
- it 'retries and does not succeed' do
- allow(client.octokit)
- .to receive(:search_repositories)
- .with(expected_query, {})
- .and_raise(error_class, 'execution expired')
-
- expect { client.search_repos_by_name('test') }.to raise_error(error_class, 'execution expired')
+ client.count_repos_by_relation_type_graphql(relation_type: relation_type)
end
end
end
diff --git a/spec/lib/gitlab/github_import/clients/proxy_spec.rb b/spec/lib/gitlab/github_import/clients/proxy_spec.rb
index 0baff7bafcb..7b2a8fa9d74 100644
--- a/spec/lib/gitlab/github_import/clients/proxy_spec.rb
+++ b/spec/lib/gitlab/github_import/clients/proxy_spec.rb
@@ -8,6 +8,10 @@ RSpec.describe Gitlab::GithubImport::Clients::Proxy, :manage, feature_category:
let(:access_token) { 'test_token' }
let(:client_options) { { foo: :bar } }
+ it { expect(client).to delegate_method(:each_object).to(:client) }
+ it { expect(client).to delegate_method(:user).to(:client) }
+ it { expect(client).to delegate_method(:octokit).to(:client) }
+
describe '#repos' do
let(:search_text) { 'search text' }
let(:pagination_options) { { limit: 10 } }
@@ -15,54 +19,32 @@ RSpec.describe Gitlab::GithubImport::Clients::Proxy, :manage, feature_category:
context 'when remove_legacy_github_client FF is enabled' do
let(:client_stub) { instance_double(Gitlab::GithubImport::Client) }
- context 'with github_client_fetch_repos_via_graphql FF enabled' do
- let(:client_response) do
- {
- data: {
- search: {
- nodes: [{ name: 'foo' }, { name: 'bar' }],
- pageInfo: { startCursor: 'foo', endCursor: 'bar' }
- }
+ let(:client_response) do
+ {
+ data: {
+ search: {
+ nodes: [{ name: 'foo' }, { name: 'bar' }],
+ pageInfo: { startCursor: 'foo', endCursor: 'bar' },
+ repositoryCount: 2
}
}
- end
-
- it 'fetches repos with Gitlab::GithubImport::Client (GraphQL API)' do
- expect(Gitlab::GithubImport::Client)
- .to receive(:new).with(access_token).and_return(client_stub)
- expect(client_stub)
- .to receive(:search_repos_by_name_graphql)
- .with(search_text, pagination_options).and_return(client_response)
-
- expect(client.repos(search_text, pagination_options)).to eq(
- {
- repos: [{ name: 'foo' }, { name: 'bar' }],
- page_info: { startCursor: 'foo', endCursor: 'bar' }
- }
- )
- end
+ }
end
- context 'with github_client_fetch_repos_via_graphql FF disabled' do
- let(:client_response) do
- { items: [{ name: 'foo' }, { name: 'bar' }] }
- end
-
- before do
- stub_feature_flags(github_client_fetch_repos_via_graphql: false)
- end
-
- it 'fetches repos with Gitlab::GithubImport::Client (REST API)' do
- expect(Gitlab::GithubImport::Client)
- .to receive(:new).with(access_token).and_return(client_stub)
- expect(client_stub)
- .to receive(:search_repos_by_name)
- .with(search_text, pagination_options).and_return(client_response)
+ it 'fetches repos with Gitlab::GithubImport::Client (GraphQL API)' do
+ expect(Gitlab::GithubImport::Client)
+ .to receive(:new).with(access_token).and_return(client_stub)
+ expect(client_stub)
+ .to receive(:search_repos_by_name_graphql)
+ .with(search_text, pagination_options).and_return(client_response)
- expect(client.repos(search_text, pagination_options)).to eq(
- { repos: [{ name: 'foo' }, { name: 'bar' }] }
- )
- end
+ expect(client.repos(search_text, pagination_options)).to eq(
+ {
+ repos: [{ name: 'foo' }, { name: 'bar' }],
+ page_info: { startCursor: 'foo', endCursor: 'bar' },
+ count: 2
+ }
+ )
end
end
@@ -99,4 +81,59 @@ RSpec.describe Gitlab::GithubImport::Clients::Proxy, :manage, feature_category:
end
end
end
+
+ describe '#count_by', :clean_gitlab_redis_cache do
+ context 'when remove_legacy_github_client FF is enabled' do
+ let(:client_stub) { instance_double(Gitlab::GithubImport::Client) }
+ let(:client_response) { { data: { search: { repositoryCount: 1 } } } }
+
+ before do
+ stub_feature_flags(remove_legacy_github_client: true)
+ end
+
+ context 'when value is cached' do
+ before do
+ Gitlab::Cache::Import::Caching.write('github-importer/provider-repo-count/owned/user_id', 3)
+ end
+
+ it 'returns repository count from cache' do
+ expect(Gitlab::GithubImport::Client)
+ .to receive(:new).with(access_token).and_return(client_stub)
+ expect(client_stub)
+ .not_to receive(:count_repos_by_relation_type_graphql)
+ .with({ relation_type: 'owned' })
+ expect(client.count_repos_by('owned', 'user_id')).to eq(3)
+ end
+ end
+
+ context 'when value is not cached' do
+ it 'returns repository count' do
+ expect(Gitlab::GithubImport::Client)
+ .to receive(:new).with(access_token).and_return(client_stub)
+ expect(client_stub)
+ .to receive(:count_repos_by_relation_type_graphql)
+ .with({ relation_type: 'owned' }).and_return(client_response)
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:write)
+ .with('github-importer/provider-repo-count/owned/user_id', 1, timeout: 5.minutes)
+ .and_call_original
+ expect(client.count_repos_by('owned', 'user_id')).to eq(1)
+ end
+ end
+ end
+
+ context 'when remove_legacy_github_client FF is disabled' do
+ let(:client_stub) { instance_double(Gitlab::LegacyGithubImport::Client) }
+
+ before do
+ stub_feature_flags(remove_legacy_github_client: false)
+ end
+
+ it 'returns nil' do
+ expect(Gitlab::LegacyGithubImport::Client)
+ .to receive(:new).with(access_token, client_options).and_return(client_stub)
+ expect(client.count_repos_by('owned', 'user_id')).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/github_import/importer/attachments/issues_importer_spec.rb b/spec/lib/gitlab/github_import/importer/attachments/issues_importer_spec.rb
index 85bc67376d3..7890561bf2d 100644
--- a/spec/lib/gitlab/github_import/importer/attachments/issues_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/attachments/issues_importer_spec.rb
@@ -17,6 +17,8 @@ RSpec.describe Gitlab::GithubImport::Importer::Attachments::IssuesImporter do
let(:importer_attrs) { [instance_of(Gitlab::GithubImport::Representation::NoteText), project, client] }
it 'imports each project issue attachments' do
+ expect(project.issues).to receive(:select).with(:id, :description, :iid).and_call_original
+
expect_next_instances_of(
Gitlab::GithubImport::Importer::NoteAttachmentsImporter, 2, false, *importer_attrs
) do |note_attachments_importer|
diff --git a/spec/lib/gitlab/github_import/importer/attachments/merge_requests_importer_spec.rb b/spec/lib/gitlab/github_import/importer/attachments/merge_requests_importer_spec.rb
index e4718c2d17c..e5aa17dd81e 100644
--- a/spec/lib/gitlab/github_import/importer/attachments/merge_requests_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/attachments/merge_requests_importer_spec.rb
@@ -17,6 +17,8 @@ RSpec.describe Gitlab::GithubImport::Importer::Attachments::MergeRequestsImporte
let(:importer_attrs) { [instance_of(Gitlab::GithubImport::Representation::NoteText), project, client] }
it 'imports each project merge request attachments' do
+ expect(project.merge_requests).to receive(:select).with(:id, :description, :iid).and_call_original
+
expect_next_instances_of(
Gitlab::GithubImport::Importer::NoteAttachmentsImporter, 2, false, *importer_attrs
) do |note_attachments_importer|
diff --git a/spec/lib/gitlab/github_import/importer/attachments/releases_importer_spec.rb b/spec/lib/gitlab/github_import/importer/attachments/releases_importer_spec.rb
index b989345ae09..e1b009c3eeb 100644
--- a/spec/lib/gitlab/github_import/importer/attachments/releases_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/attachments/releases_importer_spec.rb
@@ -17,6 +17,8 @@ RSpec.describe Gitlab::GithubImport::Importer::Attachments::ReleasesImporter do
let(:importer_attrs) { [instance_of(Gitlab::GithubImport::Representation::NoteText), project, client] }
it 'imports each project release' do
+ expect(project.releases).to receive(:select).with(:id, :description, :tag).and_call_original
+
expect(Gitlab::GithubImport::Importer::NoteAttachmentsImporter).to receive(:new)
.with(*importer_attrs).twice.and_return(importer_stub)
expect(importer_stub).to receive(:execute).twice
diff --git a/spec/lib/gitlab/github_import/importer/collaborator_importer_spec.rb b/spec/lib/gitlab/github_import/importer/collaborator_importer_spec.rb
new file mode 100644
index 00000000000..07c10fe57f0
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer/collaborator_importer_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Importer::CollaboratorImporter, feature_category: :importers do
+ subject(:importer) { described_class.new(collaborator, project, client) }
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :repository, group: group) }
+ let_it_be(:user) { create(:user) }
+
+ let(:client) { instance_double(Gitlab::GithubImport::Client) }
+ let(:github_user_id) { rand(1000) }
+ let(:collaborator) do
+ Gitlab::GithubImport::Representation::Collaborator.from_json_hash(
+ 'id' => github_user_id,
+ 'login' => user.username,
+ 'role_name' => github_role_name
+ )
+ end
+
+ let(:basic_member_attrs) do
+ {
+ source: project,
+ user_id: user.id,
+ member_namespace_id: project.project_namespace_id,
+ created_by_id: project.creator_id
+ }.stringify_keys
+ end
+
+ describe '#execute' do
+ before do
+ allow_next_instance_of(Gitlab::GithubImport::UserFinder) do |finder|
+ allow(finder).to receive(:find).with(github_user_id, user.username).and_return(user.id)
+ end
+ end
+
+ shared_examples 'role mapping' do |collaborator_role, member_access_level|
+ let(:github_role_name) { collaborator_role }
+
+ it 'creates expected member' do
+ expect { importer.execute }.to change { project.members.count }
+ .from(0).to(1)
+
+ expected_member_attrs = basic_member_attrs.merge(access_level: member_access_level)
+ expect(project.members.last).to have_attributes(expected_member_attrs)
+ end
+ end
+
+ it_behaves_like 'role mapping', 'read', Gitlab::Access::GUEST
+ it_behaves_like 'role mapping', 'triage', Gitlab::Access::REPORTER
+ it_behaves_like 'role mapping', 'write', Gitlab::Access::DEVELOPER
+ it_behaves_like 'role mapping', 'maintain', Gitlab::Access::MAINTAINER
+ it_behaves_like 'role mapping', 'admin', Gitlab::Access::OWNER
+
+ context 'when role name is unknown (custom role)' do
+ let(:github_role_name) { 'custom_role' }
+
+ it 'raises expected error' do
+ expect { importer.execute }.to raise_exception(
+ ::Gitlab::GithubImport::ObjectImporter::NotRetriableError
+ ).with_message("Unknown GitHub role: #{github_role_name}")
+ end
+ end
+
+ context 'when user has lower role in a project group' do
+ before do
+ create(:group_member, group: group, user: user, access_level: Gitlab::Access::DEVELOPER)
+ end
+
+ it_behaves_like 'role mapping', 'maintain', Gitlab::Access::MAINTAINER
+ end
+
+ context 'when user has higher role in a project group' do
+ let(:github_role_name) { 'write' }
+
+ before do
+ create(:group_member, group: group, user: user, access_level: Gitlab::Access::MAINTAINER)
+ end
+
+ it 'skips creating member for the project' do
+ expect { importer.execute }.not_to change { project.members.count }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/importer/collaborators_importer_spec.rb b/spec/lib/gitlab/github_import/importer/collaborators_importer_spec.rb
new file mode 100644
index 00000000000..dcb02f32a28
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer/collaborators_importer_spec.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Importer::CollaboratorsImporter, feature_category: :importers do
+ subject(:importer) { described_class.new(project, client, parallel: parallel) }
+
+ let(:parallel) { true }
+ let(:project) { instance_double(Project, id: 4, import_source: 'foo/bar', import_state: nil) }
+ let(:client) { instance_double(Gitlab::GithubImport::Client) }
+
+ let(:github_collaborator) do
+ {
+ id: 100500,
+ login: 'bob',
+ role_name: 'maintainer'
+ }
+ end
+
+ describe '#parallel?' do
+ context 'when parallel option is true' do
+ it { expect(importer).to be_parallel }
+ end
+
+ context 'when parallel option is false' do
+ let(:parallel) { false }
+
+ it { expect(importer).not_to be_parallel }
+ end
+ end
+
+ describe '#execute' do
+ context 'when running in parallel mode' do
+ it 'imports collaborators in parallel' do
+ expect(importer).to receive(:parallel_import)
+ importer.execute
+ end
+ end
+
+ context 'when running in sequential mode' do
+ let(:parallel) { false }
+
+ it 'imports collaborators in sequence' do
+ expect(importer).to receive(:sequential_import)
+ importer.execute
+ end
+ end
+ end
+
+ describe '#sequential_import' do
+ let(:parallel) { false }
+
+ it 'imports each collaborator in sequence' do
+ collaborator_importer = instance_double(Gitlab::GithubImport::Importer::CollaboratorImporter)
+
+ allow(importer)
+ .to receive(:each_object_to_import)
+ .and_yield(github_collaborator)
+
+ expect(Gitlab::GithubImport::Importer::CollaboratorImporter)
+ .to receive(:new)
+ .with(
+ an_instance_of(Gitlab::GithubImport::Representation::Collaborator),
+ project,
+ client
+ )
+ .and_return(collaborator_importer)
+
+ expect(collaborator_importer).to receive(:execute)
+
+ importer.sequential_import
+ end
+ end
+
+ describe '#parallel_import', :clean_gitlab_redis_cache do
+ before do
+ allow(client).to receive(:collaborators).with(project.import_source, affiliation: 'direct')
+ .and_return([github_collaborator])
+ allow(client).to receive(:collaborators).with(project.import_source, affiliation: 'outside')
+ .and_return([])
+ end
+
+ it 'imports each collaborator in parallel' do
+ expect(Gitlab::GithubImport::ImportCollaboratorWorker).to receive(:perform_in)
+ .with(1.second, project.id, an_instance_of(Hash), an_instance_of(String))
+
+ waiter = importer.parallel_import
+
+ expect(waiter).to be_an_instance_of(Gitlab::JobWaiter)
+ expect(waiter.jobs_remaining).to eq(1)
+ end
+
+ context 'when collaborator is already imported' do
+ before do
+ Gitlab::Cache::Import::Caching.set_add(
+ "github-importer/already-imported/#{project.id}/collaborators",
+ github_collaborator[:id]
+ )
+ end
+
+ it "doesn't run importer on it" do
+ expect(Gitlab::GithubImport::ImportCollaboratorWorker).not_to receive(:perform_in)
+
+ waiter = importer.parallel_import
+
+ expect(waiter).to be_an_instance_of(Gitlab::JobWaiter)
+ expect(waiter.jobs_remaining).to eq(0)
+ end
+ end
+ end
+
+ describe '#each_object_to_import', :clean_gitlab_redis_cache do
+ let(:github_collaborator_2) { { id: 100501, login: 'alice', role_name: 'owner' } }
+ let(:github_collaborator_3) { { id: 100502, login: 'tom', role_name: 'guest' } }
+
+ before do
+ allow(client).to receive(:collaborators).with(project.import_source, affiliation: 'direct')
+ .and_return([github_collaborator, github_collaborator_2, github_collaborator_3])
+ allow(client).to receive(:collaborators).with(project.import_source, affiliation: 'outside')
+ .and_return([github_collaborator_3])
+ allow(Gitlab::GithubImport::ObjectCounter).to receive(:increment)
+ .with(project, :collaborator, :fetched)
+ end
+
+ it 'yields every direct collaborator who is not an outside collaborator to the supplied block' do
+ expect { |b| importer.each_object_to_import(&b) }
+ .to yield_successive_args(github_collaborator, github_collaborator_2)
+
+ expect(Gitlab::GithubImport::ObjectCounter).to have_received(:increment).twice
+ end
+
+ context 'when a collaborator has been already imported' do
+ before do
+ allow(importer).to receive(:already_imported?).and_return(true)
+ end
+
+ it 'does not yield anything' do
+ expect(Gitlab::GithubImport::ObjectCounter)
+ .not_to receive(:increment)
+
+ expect(importer)
+ .not_to receive(:mark_as_imported)
+
+ expect { |b| importer.each_object_to_import(&b) }
+ .not_to yield_control
+ end
+ end
+ end
+
+ describe '#id_for_already_imported_cache' do
+ it 'returns the ID of the given note' do
+ expect(importer.id_for_already_imported_cache(github_collaborator))
+ .to eq(100500)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb b/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb
index e005d8eda84..16816dfbcea 100644
--- a/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb
@@ -44,6 +44,10 @@ RSpec.describe Gitlab::GithubImport::Importer::LabelLinksImporter do
end
it 'does not insert label links for non-existing labels' do
+ expect(importer)
+ .to receive(:find_target_id)
+ .and_return(4)
+
expect(importer.label_finder)
.to receive(:id_for)
.with('bug')
@@ -55,6 +59,20 @@ RSpec.describe Gitlab::GithubImport::Importer::LabelLinksImporter do
importer.create_labels
end
+
+ it 'does not insert label links for non-existing targets' do
+ expect(importer)
+ .to receive(:find_target_id)
+ .and_return(nil)
+
+ expect(importer.label_finder)
+ .not_to receive(:id_for)
+
+ expect(LabelLink)
+ .not_to receive(:bulk_insert!)
+
+ importer.create_labels
+ end
end
describe '#find_target_id' do
diff --git a/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb b/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb
index 9e295ab215a..fc8d9cee066 100644
--- a/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/labels_importer_spec.rb
@@ -56,14 +56,14 @@ feature_category: :importers do
project_id: project.id,
importer: described_class.name,
message: ['Title is invalid'],
- github_identifier: 1
+ github_identifiers: { title: 'bug,bug', object_type: :label }
)
rows, errors = importer.build_labels
expect(rows).to be_empty
expect(errors.length).to eq(1)
- expect(errors[0].full_messages).to match_array(['Title is invalid'])
+ expect(errors[0][:validation_errors].full_messages).to match_array(['Title is invalid'])
end
end
diff --git a/spec/lib/gitlab/github_import/importer/milestones_importer_spec.rb b/spec/lib/gitlab/github_import/importer/milestones_importer_spec.rb
index 47b9a41c364..cf44d510c80 100644
--- a/spec/lib/gitlab/github_import/importer/milestones_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/milestones_importer_spec.rb
@@ -74,7 +74,7 @@ RSpec.describe Gitlab::GithubImport::Importer::MilestonesImporter, :clean_gitlab
end
it 'does not build milestones that are invalid' do
- milestone = { id: 1, title: nil }
+ milestone = { id: 123456, title: nil, number: 2 }
expect(importer)
.to receive(:each_milestone)
@@ -86,14 +86,14 @@ RSpec.describe Gitlab::GithubImport::Importer::MilestonesImporter, :clean_gitlab
project_id: project.id,
importer: described_class.name,
message: ["Title can't be blank"],
- github_identifier: 1
+ github_identifiers: { iid: 2, object_type: :milestone, title: nil }
)
rows, errors = importer.build_milestones
expect(rows).to be_empty
expect(errors.length).to eq(1)
- expect(errors[0].full_messages).to match_array(["Title can't be blank"])
+ expect(errors[0][:validation_errors].full_messages).to match_array(["Title can't be blank"])
end
end
diff --git a/spec/lib/gitlab/github_import/importer/note_attachments_importer_spec.rb b/spec/lib/gitlab/github_import/importer/note_attachments_importer_spec.rb
index 7d4e3c3bcce..450ebe9a719 100644
--- a/spec/lib/gitlab/github_import/importer/note_attachments_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/note_attachments_importer_spec.rb
@@ -2,10 +2,10 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Importer::NoteAttachmentsImporter do
+RSpec.describe Gitlab::GithubImport::Importer::NoteAttachmentsImporter, feature_category: :importers do
subject(:importer) { described_class.new(note_text, project, client) }
- let_it_be(:project) { create(:project) }
+ let_it_be(:project) { create(:project, import_source: 'nickname/public-test-repo') }
let(:note_text) { Gitlab::GithubImport::Representation::NoteText.from_db_record(record) }
let(:client) { instance_double('Gitlab::GithubImport::Client') }
@@ -13,6 +13,8 @@ RSpec.describe Gitlab::GithubImport::Importer::NoteAttachmentsImporter do
let(:doc_url) { 'https://github.com/nickname/public-test-repo/files/9020437/git-cheat-sheet.txt' }
let(:image_url) { 'https://user-images.githubusercontent.com/6833842/0cf366b61ef2.jpeg' }
let(:image_tag_url) { 'https://user-images.githubusercontent.com/6833842/0cf366b61ea5.jpeg' }
+ let(:project_blob_url) { 'https://github.com/nickname/public-test-repo/blob/main/example.md' }
+ let(:other_project_blob_url) { 'https://github.com/nickname/other-repo/blob/main/README.md' }
let(:text) do
<<-TEXT.split("\n").map(&:strip).join("\n")
Some text...
@@ -20,11 +22,14 @@ RSpec.describe Gitlab::GithubImport::Importer::NoteAttachmentsImporter do
[special-doc](#{doc_url})
![image.jpeg](#{image_url})
<img width=\"248\" alt=\"tag-image\" src="#{image_tag_url}">
+
+ [link to project blob file](#{project_blob_url})
+ [link to other project blob file](#{other_project_blob_url})
TEXT
end
shared_examples 'updates record description' do
- it do
+ it 'changes attachment links' do
importer.execute
record.reload
@@ -32,6 +37,22 @@ RSpec.describe Gitlab::GithubImport::Importer::NoteAttachmentsImporter do
expect(record.description).to include('![image.jpeg](/uploads/')
expect(record.description).to include('<img width="248" alt="tag-image" src="/uploads')
end
+
+ it 'changes link to project blob files' do
+ importer.execute
+
+ record.reload
+ expected_blob_link = "[link to project blob file](http://localhost/#{project.full_path}/-/blob/main/example.md)"
+ expect(record.description).not_to include("[link to project blob file](#{project_blob_url})")
+ expect(record.description).to include(expected_blob_link)
+ end
+
+ it "doesn't change links to other projects" do
+ importer.execute
+
+ record.reload
+ expect(record.description).to include("[link to other project blob file](#{other_project_blob_url})")
+ end
end
describe '#execute' do
@@ -72,7 +93,7 @@ RSpec.describe Gitlab::GithubImport::Importer::NoteAttachmentsImporter do
context 'when importing note attachments' do
let(:record) { create(:note, project: project, note: text) }
- it 'updates note text with new attachment urls' do
+ it 'changes note text with new attachment urls' do
importer.execute
record.reload
@@ -80,6 +101,22 @@ RSpec.describe Gitlab::GithubImport::Importer::NoteAttachmentsImporter do
expect(record.note).to include('![image.jpeg](/uploads/')
expect(record.note).to include('<img width="248" alt="tag-image" src="/uploads')
end
+
+ it 'changes note links to project blob files' do
+ importer.execute
+
+ record.reload
+ expected_blob_link = "[link to project blob file](http://localhost/#{project.full_path}/-/blob/main/example.md)"
+ expect(record.note).not_to include("[link to project blob file](#{project_blob_url})")
+ expect(record.note).to include(expected_blob_link)
+ end
+
+ it "doesn't change note links to other projects" do
+ importer.execute
+
+ record.reload
+ expect(record.note).to include("[link to other project blob file](#{other_project_blob_url})")
+ end
end
end
end
diff --git a/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb
deleted file mode 100644
index 01d706beea2..00000000000
--- a/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :clean_gitlab_redis_cache do
- let_it_be(:merge_request) { create(:merged_merge_request) }
-
- let(:project) { merge_request.project }
- let(:merged_at) { Time.new(2017, 1, 1, 12, 00).utc }
- let(:client_double) { double(user: { id: 999, login: 'merger', email: 'merger@email.com' } ) }
- let(:merger_user) { { id: 999, login: 'merger' } }
-
- let(:pull_request) do
- Gitlab::GithubImport::Representation::PullRequest.from_api_response(
- {
- number: merge_request.iid,
- merged_at: merged_at,
- merged_by: merger_user
- }
- )
- end
-
- subject { described_class.new(pull_request, project, client_double) }
-
- shared_examples 'adds a note referencing the merger user' do
- it 'adds a note referencing the merger user' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
- .and not_change(merge_request, :updated_at)
-
- metrics = merge_request.metrics.reload
- expect(metrics.merged_by).to be_nil
- expect(metrics.merged_at).to eq(merged_at)
-
- last_note = merge_request.notes.last
- expect(last_note.created_at).to eq(merged_at)
- expect(last_note.author).to eq(project.creator)
- expect(last_note.note).to eq("*Merged by: merger at #{merged_at}*")
- end
- end
-
- context 'when the merger user can be mapped' do
- it 'assigns the merged by user when mapped' do
- merge_user = create(:user, email: 'merger@email.com')
-
- subject.execute
-
- metrics = merge_request.metrics.reload
- expect(metrics.merged_by).to eq(merge_user)
- expect(metrics.merged_at).to eq(merged_at)
- end
- end
-
- context 'when the merger user cannot be mapped to a gitlab user' do
- it_behaves_like 'adds a note referencing the merger user'
-
- context 'when original user cannot be found on github' do
- before do
- allow(client_double).to receive(:user).and_raise(Octokit::NotFound)
- end
-
- it_behaves_like 'adds a note referencing the merger user'
- end
- end
-
- context 'when the merger user is not provided' do
- let(:merger_user) { nil }
-
- it 'adds a note referencing the merger user' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
- .and not_change(merge_request, :updated_at)
-
- metrics = merge_request.metrics.reload
- expect(metrics.merged_by).to be_nil
- expect(metrics.merged_at).to eq(merged_at)
-
- last_note = merge_request.notes.last
- expect(last_note.created_at).to eq(merged_at)
- expect(last_note.author).to eq(project.creator)
- expect(last_note.note).to eq("*Merged by: ghost at #{merged_at}*")
- end
- end
-end
diff --git a/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb
deleted file mode 100644
index 3e62e8f473c..00000000000
--- a/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb
+++ /dev/null
@@ -1,314 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::GithubImport::Importer::PullRequestReviewImporter,
- :clean_gitlab_redis_cache, feature_category: :importers do
- using RSpec::Parameterized::TableSyntax
-
- let_it_be(:merge_request) { create(:merge_request) }
-
- let(:project) { merge_request.project }
- let(:submitted_at) { Time.new(2017, 1, 1, 12, 00).utc }
- let(:client_double) do
- instance_double(
- 'Gitlab::GithubImport::Client',
- user: { id: 999, login: 'author', email: 'author@email.com' }
- )
- end
-
- subject { described_class.new(review, project, client_double) }
-
- shared_examples 'imports a reviewer for the Merge Request' do
- it 'creates reviewer for the Merge Request' do
- expect { subject.execute }.to change(MergeRequestReviewer, :count).by(1)
-
- expect(merge_request.reviewers).to contain_exactly(author)
- end
-
- context 'when reviewer already exists' do
- before do
- create(
- :merge_request_reviewer,
- reviewer: author, merge_request: merge_request, state: 'unreviewed'
- )
- end
-
- it 'does not change Merge Request reviewers' do
- expect { subject.execute }.not_to change(MergeRequestReviewer, :count)
-
- expect(merge_request.reviewers).to contain_exactly(author)
- end
- end
-
- context 'when because of concurrency an attempt of duplication appeared' do
- before do
- allow(MergeRequestReviewer)
- .to receive(:create!).and_raise(ActiveRecord::RecordNotUnique)
- end
-
- it 'does not change Merge Request reviewers', :aggregate_failures do
- expect { subject.execute }.not_to change(MergeRequestReviewer, :count)
-
- expect(merge_request.reviewers).to contain_exactly(author)
- end
- end
- end
-
- shared_examples 'imports an approval for the Merge Request' do
- it 'creates an approval for the Merge Request' do
- expect { subject.execute }.to change(Approval, :count).by(1)
-
- expect(merge_request.approved_by_users.reload).to include(author)
- expect(merge_request.approvals.last.created_at).to eq(submitted_at)
- end
- end
-
- context 'when the review author can be mapped to a gitlab user' do
- let_it_be(:author) { create(:user, email: 'author@email.com') }
-
- context 'when the review has no note text' do
- context 'when the review is "APPROVED"' do
- let(:review) { create_review(type: 'APPROVED', note: '') }
-
- it_behaves_like 'imports an approval for the Merge Request'
- it_behaves_like 'imports a reviewer for the Merge Request'
-
- it 'creates a note for the review' do
- expect { subject.execute }.to change(Note, :count).by(1)
-
- last_note = merge_request.notes.last
- expect(last_note.note).to eq('approved this merge request')
- expect(last_note.author).to eq(author)
- expect(last_note.created_at).to eq(submitted_at)
- expect(last_note.system_note_metadata.action).to eq('approved')
- end
-
- context 'when the user already approved the merge request' do
- before do
- create(:approval, merge_request: merge_request, user: author)
- end
-
- it 'does not import second approve and note' do
- expect { subject.execute }
- .to change(Note, :count).by(0)
- .and change(Approval, :count).by(0)
- end
- end
- end
-
- context 'when the review is "COMMENTED"' do
- let(:review) { create_review(type: 'COMMENTED', note: '') }
-
- it_behaves_like 'imports a reviewer for the Merge Request'
-
- it 'does not create note for the review' do
- expect { subject.execute }.not_to change(Note, :count)
- end
- end
-
- context 'when the review is "CHANGES_REQUESTED"' do
- let(:review) { create_review(type: 'CHANGES_REQUESTED', note: '') }
-
- it_behaves_like 'imports a reviewer for the Merge Request'
-
- it 'does not create a note for the review' do
- expect { subject.execute }.not_to change(Note, :count)
- end
- end
- end
-
- context 'when the review has a note text' do
- context 'when the review is "APPROVED"' do
- let(:review) { create_review(type: 'APPROVED') }
-
- it_behaves_like 'imports an approval for the Merge Request'
- it_behaves_like 'imports a reviewer for the Merge Request'
-
- it 'creates a note for the review' do
- expect { subject.execute }.to change(Note, :count).by(2)
-
- note = merge_request.notes.where(system: false).last
- expect(note.note).to eq("**Review:** Approved\n\nnote")
- expect(note.author).to eq(author)
- expect(note.created_at).to eq(submitted_at)
-
- system_note = merge_request.notes.where(system: true).last
- expect(system_note.note).to eq('approved this merge request')
- expect(system_note.author).to eq(author)
- expect(system_note.created_at).to eq(submitted_at)
- expect(system_note.system_note_metadata.action).to eq('approved')
- end
- end
-
- context 'when the review is "COMMENTED"' do
- let(:review) { create_review(type: 'COMMENTED') }
-
- it 'creates a note for the review' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
- .and not_change(Approval, :count)
-
- last_note = merge_request.notes.last
-
- expect(last_note.note).to eq("**Review:** Commented\n\nnote")
- expect(last_note.author).to eq(author)
- expect(last_note.created_at).to eq(submitted_at)
- end
- end
-
- context 'when the review is "CHANGES_REQUESTED"' do
- let(:review) { create_review(type: 'CHANGES_REQUESTED') }
-
- it 'creates a note for the review' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
- .and not_change(Approval, :count)
-
- last_note = merge_request.notes.last
-
- expect(last_note.note).to eq("**Review:** Changes requested\n\nnote")
- expect(last_note.author).to eq(author)
- expect(last_note.created_at).to eq(submitted_at)
- end
- end
- end
- end
-
- context 'when the review author cannot be mapped to a gitlab user' do
- context 'when the review has no note text' do
- context 'when the review is "APPROVED"' do
- let(:review) { create_review(type: 'APPROVED', note: '') }
-
- it 'creates a note for the review with *Approved by by<author>*' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
-
- last_note = merge_request.notes.last
- expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Approved")
- expect(last_note.author).to eq(project.creator)
- expect(last_note.created_at).to eq(submitted_at)
- end
- end
-
- context 'when the review is "COMMENTED"' do
- let(:review) { create_review(type: 'COMMENTED', note: '') }
-
- it 'creates a note for the review with *Commented by<author>*' do
- expect { subject.execute }.not_to change(Note, :count)
- end
- end
-
- context 'when the review is "CHANGES_REQUESTED"' do
- let(:review) { create_review(type: 'CHANGES_REQUESTED', note: '') }
-
- it 'creates a note for the review with *Changes requested by <author>*' do
- expect { subject.execute }.not_to change(Note, :count)
- end
- end
- end
-
- context 'when original author was deleted in github' do
- let(:review) { create_review(type: 'APPROVED', note: '', author: nil) }
-
- it 'creates a note for the review without the author information' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
-
- last_note = merge_request.notes.last
- expect(last_note.note).to eq('**Review:** Approved')
- expect(last_note.author).to eq(project.creator)
- expect(last_note.created_at).to eq(submitted_at)
- end
- end
-
- context 'when original author cannot be found on github' do
- before do
- allow(client_double).to receive(:user).and_raise(Octokit::NotFound)
- end
-
- let(:review) { create_review(type: 'APPROVED', note: '') }
-
- it 'creates a note for the review with the author username' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
- last_note = merge_request.notes.last
- expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Approved")
- expect(last_note.author).to eq(project.creator)
- expect(last_note.created_at).to eq(submitted_at)
- end
- end
-
- context 'when the submitted_at is not provided' do
- let(:review) { create_review(type: 'APPROVED', note: '', submitted_at: nil) }
-
- it 'creates a note for the review without the author information' do
- expect { subject.execute }.to change(Note, :count).by(1)
-
- last_note = merge_request.notes.last
-
- expect(last_note.created_at)
- .to be_within(1.second).of(merge_request.updated_at)
- end
- end
-
- context 'when the review has a note text' do
- context 'when the review is "APPROVED"' do
- let(:review) { create_review(type: 'APPROVED') }
-
- it 'creates a note for the review with *Approved by by<author>*' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
-
- last_note = merge_request.notes.last
-
- expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Approved\n\nnote")
- expect(last_note.author).to eq(project.creator)
- expect(last_note.created_at).to eq(submitted_at)
- end
- end
-
- context 'when the review is "COMMENTED"' do
- let(:review) { create_review(type: 'COMMENTED') }
-
- it 'creates a note for the review with *Commented by<author>*' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
-
- last_note = merge_request.notes.last
-
- expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Commented\n\nnote")
- expect(last_note.author).to eq(project.creator)
- expect(last_note.created_at).to eq(submitted_at)
- end
- end
-
- context 'when the review is "CHANGES_REQUESTED"' do
- let(:review) { create_review(type: 'CHANGES_REQUESTED') }
-
- it 'creates a note for the review with *Changes requested by <author>*' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
-
- last_note = merge_request.notes.last
-
- expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Changes requested\n\nnote")
- expect(last_note.author).to eq(project.creator)
- expect(last_note.created_at).to eq(submitted_at)
- end
- end
- end
- end
-
- def create_review(type:, **extra)
- Gitlab::GithubImport::Representation::PullRequestReview.from_json_hash(
- extra.reverse_merge(
- author: { id: 999, login: 'author' },
- merge_request_id: merge_request.id,
- review_type: type,
- note: 'note',
- submitted_at: submitted_at.to_s
- )
- )
- end
-end
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests/all_merged_by_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests/all_merged_by_importer_spec.rb
new file mode 100644
index 00000000000..8e13b35eb6b
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer/pull_requests/all_merged_by_importer_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Importer::PullRequests::AllMergedByImporter, feature_category: :importers do
+ let(:client) { double }
+
+ let_it_be(:project) { create(:project, import_source: 'http://somegithub.com') }
+
+ subject { described_class.new(project, client) }
+
+ it { is_expected.to include_module(Gitlab::GithubImport::ParallelScheduling) }
+
+ describe '#representation_class' do
+ it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::PullRequest) }
+ end
+
+ describe '#importer_class' do
+ it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequests::MergedByImporter) }
+ end
+
+ describe '#sidekiq_worker_class' do
+ it { expect(subject.sidekiq_worker_class).to eq(Gitlab::GithubImport::PullRequests::ImportMergedByWorker) }
+ end
+
+ describe '#collection_method' do
+ it { expect(subject.collection_method).to eq(:pull_requests_merged_by) }
+ end
+
+ describe '#id_for_already_imported_cache' do
+ it { expect(subject.id_for_already_imported_cache(instance_double(MergeRequest, id: 1))).to eq(1) }
+ end
+
+ 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
+
+ it 'fetches the merged pull requests data' do
+ pull_request = double
+
+ allow(client)
+ .to receive(:pull_request)
+ .exactly(:once) # ensure to be cached on the second call
+ .with('http://somegithub.com', 999)
+ .and_return(pull_request)
+
+ expect { |b| subject.each_object_to_import(&b) }
+ .to yield_with_args(pull_request)
+
+ subject.each_object_to_import
+ end
+
+ it 'skips cached merge requests' do
+ Gitlab::Cache::Import::Caching.set_add(
+ "github-importer/already-imported/#{project.id}/pull_requests_merged_by",
+ merge_request.id
+ )
+
+ expect(client).not_to receive(:pull_request)
+
+ subject.each_object_to_import
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests/merged_by_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests/merged_by_importer_spec.rb
new file mode 100644
index 00000000000..25381594632
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer/pull_requests/merged_by_importer_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Importer::PullRequests::MergedByImporter,
+ :clean_gitlab_redis_cache, feature_category: :importers do
+ let_it_be(:merge_request) { create(:merged_merge_request) }
+
+ let(:project) { merge_request.project }
+ let(:merged_at) { Time.utc(2017, 1, 1, 12) }
+ let(:client_double) do
+ instance_double(Gitlab::GithubImport::Client, user: { id: 999, login: 'merger', email: 'merger@email.com' })
+ end
+
+ let(:merger_user) { { id: 999, login: 'merger' } }
+
+ let(:pull_request) do
+ Gitlab::GithubImport::Representation::PullRequest.from_api_response(
+ {
+ number: merge_request.iid,
+ merged_at: merged_at,
+ merged_by: merger_user
+ }
+ )
+ end
+
+ subject { described_class.new(pull_request, project, client_double) }
+
+ shared_examples 'adds a note referencing the merger user' do
+ it 'adds a note referencing the merger user' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+ .and not_change(merge_request, :updated_at)
+
+ metrics = merge_request.metrics.reload
+ expect(metrics.merged_by).to be_nil
+ expect(metrics.merged_at).to eq(merged_at)
+
+ last_note = merge_request.notes.last
+ expect(last_note.created_at).to eq(merged_at)
+ expect(last_note.author).to eq(project.creator)
+ expect(last_note.note).to eq("*Merged by: merger at #{merged_at}*")
+ end
+ end
+
+ context 'when the merger user can be mapped' do
+ it 'assigns the merged by user when mapped' do
+ merge_user = create(:user, email: 'merger@email.com')
+
+ subject.execute
+
+ metrics = merge_request.metrics.reload
+ expect(metrics.merged_by).to eq(merge_user)
+ expect(metrics.merged_at).to eq(merged_at)
+ end
+ end
+
+ context 'when the merger user cannot be mapped to a gitlab user' do
+ it_behaves_like 'adds a note referencing the merger user'
+
+ context 'when original user cannot be found on github' do
+ before do
+ allow(client_double).to receive(:user).and_raise(Octokit::NotFound)
+ end
+
+ it_behaves_like 'adds a note referencing the merger user'
+ end
+ end
+
+ context 'when the merger user is not provided' do
+ let(:merger_user) { nil }
+
+ it 'adds a note referencing the merger user' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+ .and not_change(merge_request, :updated_at)
+
+ metrics = merge_request.metrics.reload
+ expect(metrics.merged_by).to be_nil
+ expect(metrics.merged_at).to eq(merged_at)
+
+ last_note = merge_request.notes.last
+ expect(last_note.created_at).to eq(merged_at)
+ expect(last_note.author).to eq(project.creator)
+ expect(last_note.note).to eq("*Merged by: ghost at #{merged_at}*")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests/review_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests/review_importer_spec.rb
new file mode 100644
index 00000000000..ba14ea603e0
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer/pull_requests/review_importer_spec.rb
@@ -0,0 +1,314 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Importer::PullRequests::ReviewImporter,
+ :clean_gitlab_redis_cache, feature_category: :importers do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:merge_request) { create(:merge_request) }
+
+ let(:project) { merge_request.project }
+ let(:submitted_at) { Time.new(2017, 1, 1, 12).utc }
+ let(:client_double) do
+ instance_double(
+ 'Gitlab::GithubImport::Client',
+ user: { id: 999, login: 'author', email: 'author@email.com' }
+ )
+ end
+
+ subject { described_class.new(review, project, client_double) }
+
+ shared_examples 'imports a reviewer for the Merge Request' do
+ it 'creates reviewer for the Merge Request' do
+ expect { subject.execute }.to change { MergeRequestReviewer.count }.by(1)
+
+ expect(merge_request.reviewers).to contain_exactly(author)
+ end
+
+ context 'when reviewer already exists' do
+ before do
+ create(
+ :merge_request_reviewer,
+ reviewer: author, merge_request: merge_request, state: 'unreviewed'
+ )
+ end
+
+ it 'does not change Merge Request reviewers' do
+ expect { subject.execute }.not_to change { MergeRequestReviewer.count }
+
+ expect(merge_request.reviewers).to contain_exactly(author)
+ end
+ end
+
+ context 'when because of concurrency an attempt of duplication appeared' do
+ before do
+ allow(MergeRequestReviewer)
+ .to receive(:create!).and_raise(ActiveRecord::RecordNotUnique)
+ end
+
+ it 'does not change Merge Request reviewers', :aggregate_failures do
+ expect { subject.execute }.not_to change { MergeRequestReviewer.count }
+
+ expect(merge_request.reviewers).to contain_exactly(author)
+ end
+ end
+ end
+
+ shared_examples 'imports an approval for the Merge Request' do
+ it 'creates an approval for the Merge Request' do
+ expect { subject.execute }.to change { Approval.count }.by(1)
+
+ expect(merge_request.approved_by_users.reload).to include(author)
+ expect(merge_request.approvals.last.created_at).to eq(submitted_at)
+ end
+ end
+
+ context 'when the review author can be mapped to a gitlab user' do
+ let_it_be(:author) { create(:user, email: 'author@email.com') }
+
+ context 'when the review has no note text' do
+ context 'when the review is "APPROVED"' do
+ let(:review) { create_review(type: 'APPROVED', note: '') }
+
+ it_behaves_like 'imports an approval for the Merge Request'
+ it_behaves_like 'imports a reviewer for the Merge Request'
+
+ it 'creates a note for the review' do
+ expect { subject.execute }.to change { Note.count }.by(1)
+
+ last_note = merge_request.notes.last
+ expect(last_note.note).to eq('approved this merge request')
+ expect(last_note.author).to eq(author)
+ expect(last_note.created_at).to eq(submitted_at)
+ expect(last_note.system_note_metadata.action).to eq('approved')
+ end
+
+ context 'when the user already approved the merge request' do
+ before do
+ create(:approval, merge_request: merge_request, user: author)
+ end
+
+ it 'does not import second approve and note' do
+ expect { subject.execute }
+ .to change { Note.count }.by(0)
+ .and change { Approval.count }.by(0)
+ end
+ end
+ end
+
+ context 'when the review is "COMMENTED"' do
+ let(:review) { create_review(type: 'COMMENTED', note: '') }
+
+ it_behaves_like 'imports a reviewer for the Merge Request'
+
+ it 'does not create note for the review' do
+ expect { subject.execute }.not_to change { Note.count }
+ end
+ end
+
+ context 'when the review is "CHANGES_REQUESTED"' do
+ let(:review) { create_review(type: 'CHANGES_REQUESTED', note: '') }
+
+ it_behaves_like 'imports a reviewer for the Merge Request'
+
+ it 'does not create a note for the review' do
+ expect { subject.execute }.not_to change { Note.count }
+ end
+ end
+ end
+
+ context 'when the review has a note text' do
+ context 'when the review is "APPROVED"' do
+ let(:review) { create_review(type: 'APPROVED') }
+
+ it_behaves_like 'imports an approval for the Merge Request'
+ it_behaves_like 'imports a reviewer for the Merge Request'
+
+ it 'creates a note for the review' do
+ expect { subject.execute }.to change { Note.count }.by(2)
+
+ note = merge_request.notes.where(system: false).last
+ expect(note.note).to eq("**Review:** Approved\n\nnote")
+ expect(note.author).to eq(author)
+ expect(note.created_at).to eq(submitted_at)
+
+ system_note = merge_request.notes.where(system: true).last
+ expect(system_note.note).to eq('approved this merge request')
+ expect(system_note.author).to eq(author)
+ expect(system_note.created_at).to eq(submitted_at)
+ expect(system_note.system_note_metadata.action).to eq('approved')
+ end
+ end
+
+ context 'when the review is "COMMENTED"' do
+ let(:review) { create_review(type: 'COMMENTED') }
+
+ it 'creates a note for the review' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+ .and not_change(Approval, :count)
+
+ last_note = merge_request.notes.last
+
+ expect(last_note.note).to eq("**Review:** Commented\n\nnote")
+ expect(last_note.author).to eq(author)
+ expect(last_note.created_at).to eq(submitted_at)
+ end
+ end
+
+ context 'when the review is "CHANGES_REQUESTED"' do
+ let(:review) { create_review(type: 'CHANGES_REQUESTED') }
+
+ it 'creates a note for the review' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+ .and not_change(Approval, :count)
+
+ last_note = merge_request.notes.last
+
+ expect(last_note.note).to eq("**Review:** Changes requested\n\nnote")
+ expect(last_note.author).to eq(author)
+ expect(last_note.created_at).to eq(submitted_at)
+ end
+ end
+ end
+ end
+
+ context 'when the review author cannot be mapped to a gitlab user' do
+ context 'when the review has no note text' do
+ context 'when the review is "APPROVED"' do
+ let(:review) { create_review(type: 'APPROVED', note: '') }
+
+ it 'creates a note for the review with *Approved by by<author>*' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+
+ last_note = merge_request.notes.last
+ expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Approved")
+ expect(last_note.author).to eq(project.creator)
+ expect(last_note.created_at).to eq(submitted_at)
+ end
+ end
+
+ context 'when the review is "COMMENTED"' do
+ let(:review) { create_review(type: 'COMMENTED', note: '') }
+
+ it 'creates a note for the review with *Commented by<author>*' do
+ expect { subject.execute }.not_to change { Note.count }
+ end
+ end
+
+ context 'when the review is "CHANGES_REQUESTED"' do
+ let(:review) { create_review(type: 'CHANGES_REQUESTED', note: '') }
+
+ it 'creates a note for the review with *Changes requested by <author>*' do
+ expect { subject.execute }.not_to change { Note.count }
+ end
+ end
+ end
+
+ context 'when original author was deleted in github' do
+ let(:review) { create_review(type: 'APPROVED', note: '', author: nil) }
+
+ it 'creates a note for the review without the author information' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+
+ last_note = merge_request.notes.last
+ expect(last_note.note).to eq('**Review:** Approved')
+ expect(last_note.author).to eq(project.creator)
+ expect(last_note.created_at).to eq(submitted_at)
+ end
+ end
+
+ context 'when original author cannot be found on github' do
+ before do
+ allow(client_double).to receive(:user).and_raise(Octokit::NotFound)
+ end
+
+ let(:review) { create_review(type: 'APPROVED', note: '') }
+
+ it 'creates a note for the review with the author username' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+ last_note = merge_request.notes.last
+ expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Approved")
+ expect(last_note.author).to eq(project.creator)
+ expect(last_note.created_at).to eq(submitted_at)
+ end
+ end
+
+ context 'when the submitted_at is not provided' do
+ let(:review) { create_review(type: 'APPROVED', note: '', submitted_at: nil) }
+
+ it 'creates a note for the review without the author information' do
+ expect { subject.execute }.to change { Note.count }.by(1)
+
+ last_note = merge_request.notes.last
+
+ expect(last_note.created_at)
+ .to be_within(1.second).of(merge_request.updated_at)
+ end
+ end
+
+ context 'when the review has a note text' do
+ context 'when the review is "APPROVED"' do
+ let(:review) { create_review(type: 'APPROVED') }
+
+ it 'creates a note for the review with *Approved by by<author>*' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+
+ last_note = merge_request.notes.last
+
+ expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Approved\n\nnote")
+ expect(last_note.author).to eq(project.creator)
+ expect(last_note.created_at).to eq(submitted_at)
+ end
+ end
+
+ context 'when the review is "COMMENTED"' do
+ let(:review) { create_review(type: 'COMMENTED') }
+
+ it 'creates a note for the review with *Commented by<author>*' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+
+ last_note = merge_request.notes.last
+
+ expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Commented\n\nnote")
+ expect(last_note.author).to eq(project.creator)
+ expect(last_note.created_at).to eq(submitted_at)
+ end
+ end
+
+ context 'when the review is "CHANGES_REQUESTED"' do
+ let(:review) { create_review(type: 'CHANGES_REQUESTED') }
+
+ it 'creates a note for the review with *Changes requested by <author>*' do
+ expect { subject.execute }
+ .to change { Note.count }.by(1)
+
+ last_note = merge_request.notes.last
+
+ expect(last_note.note).to eq("*Created by: author*\n\n**Review:** Changes requested\n\nnote")
+ expect(last_note.author).to eq(project.creator)
+ expect(last_note.created_at).to eq(submitted_at)
+ end
+ end
+ end
+ end
+
+ def create_review(type:, **extra)
+ Gitlab::GithubImport::Representation::PullRequestReview.from_json_hash(
+ extra.reverse_merge(
+ author: { id: 999, login: 'author' },
+ merge_request_id: merge_request.id,
+ review_type: type,
+ note: 'note',
+ submitted_at: submitted_at.to_s
+ )
+ )
+ end
+end
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests/review_requests_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests/review_requests_importer_spec.rb
index 536983fea06..9e9d6c6e9cd 100644
--- a/spec/lib/gitlab/github_import/importer/pull_requests/review_requests_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/pull_requests/review_requests_importer_spec.rb
@@ -86,6 +86,7 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequests::ReviewRequestsImpor
project.id,
{
merge_request_id: merge_request_1.id,
+ merge_request_iid: merge_request_1.iid,
users: [
{ id: 4, login: 'alice' },
{ id: 5, login: 'bob' }
@@ -97,6 +98,7 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequests::ReviewRequestsImpor
project.id,
{
merge_request_id: merge_request_2.id,
+ merge_request_iid: merge_request_2.iid,
users: [
{ id: 4, login: 'alice' }
]
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests/reviews_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests/reviews_importer_spec.rb
new file mode 100644
index 00000000000..4321997815a
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer/pull_requests/reviews_importer_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Importer::PullRequests::ReviewsImporter, feature_category: :importers 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) }
+
+ describe '#representation_class' do
+ it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::PullRequestReview) }
+ end
+
+ describe '#importer_class' do
+ it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequests::ReviewImporter) }
+ end
+
+ describe '#sidekiq_worker_class' do
+ it { expect(subject.sidekiq_worker_class).to eq(Gitlab::GithubImport::PullRequests::ImportReviewWorker) }
+ end
+
+ describe '#collection_method' do
+ it { expect(subject.collection_method).to eq(:pull_request_reviews) }
+ end
+
+ describe '#object_type' do
+ it { expect(subject.object_type).to eq(:pull_request_review) }
+ end
+
+ describe '#id_for_already_imported_cache' do
+ it { expect(subject.id_for_already_imported_cache({ id: 1 })).to eq(1) }
+ end
+
+ 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(:review) { { id: 1 } }
+
+ it 'fetches the pull requests reviews data' do
+ page = Struct.new(:objects, :number).new([review], 1)
+
+ expect(client)
+ .to receive(:each_page)
+ .exactly(:once) # ensure to be cached on the second call
+ .with(:pull_request_reviews, 'github/repo', merge_request.iid, { page: 1 })
+ .and_yield(page)
+
+ expect { |b| subject.each_object_to_import(&b) }
+ .to yield_with_args(review)
+
+ subject.each_object_to_import
+
+ expect(review[:merge_request_id]).to eq(merge_request.id)
+ expect(review[:merge_request_iid]).to eq(merge_request.iid)
+ end
+
+ it 'skips cached pages' do
+ Gitlab::GithubImport::PageCounter
+ .new(project, "merge_request/#{merge_request.id}/pull_request_reviews")
+ .set(2)
+
+ expect(review).not_to receive(:merge_request_id=)
+
+ expect(client)
+ .to receive(:each_page)
+ .exactly(:once) # ensure to be cached on the second call
+ .with(:pull_request_reviews, '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/already-imported/#{project.id}",
+ merge_request.id
+ )
+
+ expect(review).not_to receive(:merge_request_id=)
+
+ expect(client).not_to receive(:each_page)
+
+ subject.each_object_to_import
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests_merged_by_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests_merged_by_importer_spec.rb
deleted file mode 100644
index b6c162aafa9..00000000000
--- a/spec/lib/gitlab/github_import/importer/pull_requests_merged_by_importer_spec.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::GithubImport::Importer::PullRequestsMergedByImporter do
- let(:client) { double }
-
- let_it_be(:project) { create(:project, import_source: 'http://somegithub.com') }
-
- subject { described_class.new(project, client) }
-
- it { is_expected.to include_module(Gitlab::GithubImport::ParallelScheduling) }
-
- describe '#representation_class' do
- it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::PullRequest) }
- end
-
- describe '#importer_class' do
- it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequestMergedByImporter) }
- end
-
- describe '#collection_method' do
- it { expect(subject.collection_method).to eq(:pull_requests_merged_by) }
- end
-
- describe '#id_for_already_imported_cache' do
- it { expect(subject.id_for_already_imported_cache(double(id: 1))).to eq(1) }
- end
-
- 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
-
- it 'fetches the merged pull requests data' do
- pull_request = double
-
- allow(client)
- .to receive(:pull_request)
- .exactly(:once) # ensure to be cached on the second call
- .with('http://somegithub.com', 999)
- .and_return(pull_request)
-
- expect { |b| subject.each_object_to_import(&b) }
- .to yield_with_args(pull_request)
-
- subject.each_object_to_import {}
- end
-
- it 'skips cached merge requests' do
- Gitlab::Cache::Import::Caching.set_add(
- "github-importer/already-imported/#{project.id}/pull_requests_merged_by",
- merge_request.id
- )
-
- expect(client).not_to receive(:pull_request)
-
- subject.each_object_to_import {}
- end
- end
-end
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests_reviews_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests_reviews_importer_spec.rb
deleted file mode 100644
index 5f9c73cbfff..00000000000
--- a/spec/lib/gitlab/github_import/importer/pull_requests_reviews_importer_spec.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::GithubImport::Importer::PullRequestsReviewsImporter 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) }
-
- describe '#representation_class' do
- it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::PullRequestReview) }
- end
-
- describe '#importer_class' do
- it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequestReviewImporter) }
- end
-
- describe '#collection_method' do
- it { expect(subject.collection_method).to eq(:pull_request_reviews) }
- end
-
- describe '#id_for_already_imported_cache' do
- it { expect(subject.id_for_already_imported_cache({ id: 1 })).to eq(1) }
- end
-
- 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(:review) { { id: 1 } }
-
- it 'fetches the pull requests reviews data' do
- page = double(objects: [review], number: 1)
-
- expect(client)
- .to receive(:each_page)
- .exactly(:once) # ensure to be cached on the second call
- .with(:pull_request_reviews, 'github/repo', merge_request.iid, { page: 1 })
- .and_yield(page)
-
- expect { |b| subject.each_object_to_import(&b) }
- .to yield_with_args(review)
-
- subject.each_object_to_import {}
-
- expect(review[:merge_request_id]).to eq(merge_request.id)
- end
-
- it 'skips cached pages' do
- Gitlab::GithubImport::PageCounter
- .new(project, "merge_request/#{merge_request.id}/pull_request_reviews")
- .set(2)
-
- expect(review).not_to receive(:merge_request_id=)
-
- expect(client)
- .to receive(:each_page)
- .exactly(:once) # ensure to be cached on the second call
- .with(:pull_request_reviews, '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/already-imported/#{project.id}",
- merge_request.id
- )
-
- expect(review).not_to receive(:merge_request_id=)
-
- expect(client).not_to receive(:each_page)
-
- subject.each_object_to_import {}
- end
- end
-end
diff --git a/spec/lib/gitlab/github_import/importer/releases_importer_spec.rb b/spec/lib/gitlab/github_import/importer/releases_importer_spec.rb
index fe4d3e9d90b..a3d20af22c7 100644
--- a/spec/lib/gitlab/github_import/importer/releases_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/releases_importer_spec.rb
@@ -18,6 +18,7 @@ RSpec.describe Gitlab::GithubImport::Importer::ReleasesImporter, feature_categor
let(:github_release) do
{
+ id: 123456,
tag_name: '1.0',
name: github_release_name,
body: 'This is my release',
@@ -144,7 +145,10 @@ RSpec.describe Gitlab::GithubImport::Importer::ReleasesImporter, feature_categor
expect(releases).to be_empty
expect(errors.length).to eq(1)
- expect(errors[0].full_messages).to match_array(['Description is too long (maximum is 1000000 characters)'])
+ expect(errors[0][:validation_errors].full_messages).to match_array(
+ ['Description is too long (maximum is 1000000 characters)']
+ )
+ expect(errors[0][:github_identifiers]).to eq({ tag: '1.0', object_type: :release })
end
end
diff --git a/spec/lib/gitlab/github_import/logger_spec.rb b/spec/lib/gitlab/github_import/logger_spec.rb
index 6fd0f5db93e..97806872746 100644
--- a/spec/lib/gitlab/github_import/logger_spec.rb
+++ b/spec/lib/gitlab/github_import/logger_spec.rb
@@ -5,37 +5,5 @@ require 'spec_helper'
RSpec.describe Gitlab::GithubImport::Logger do
subject(:logger) { described_class.new('/dev/null') }
- let(:now) { Time.zone.now }
-
- describe '#format_message' do
- before do
- allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('new-correlation-id')
- end
-
- it 'formats strings' do
- output = subject.format_message('INFO', now, 'test', 'Hello world')
-
- expect(Gitlab::Json.parse(output)).to eq({
- 'severity' => 'INFO',
- 'time' => now.utc.iso8601(3),
- 'message' => 'Hello world',
- 'correlation_id' => 'new-correlation-id',
- 'feature_category' => 'importers',
- 'import_type' => 'github'
- })
- end
-
- it 'formats hashes' do
- output = subject.format_message('INFO', now, 'test', { hello: 1 })
-
- expect(Gitlab::Json.parse(output)).to eq({
- 'severity' => 'INFO',
- 'time' => now.utc.iso8601(3),
- 'hello' => 1,
- 'correlation_id' => 'new-correlation-id',
- 'feature_category' => 'importers',
- 'import_type' => 'github'
- })
- end
- end
+ it_behaves_like 'a json logger', { 'feature_category' => 'importers', 'import_type' => 'github' }
end
diff --git a/spec/lib/gitlab/github_import/markdown/attachment_spec.rb b/spec/lib/gitlab/github_import/markdown/attachment_spec.rb
index 588a3076f59..84b0886ebcc 100644
--- a/spec/lib/gitlab/github_import/markdown/attachment_spec.rb
+++ b/spec/lib/gitlab/github_import/markdown/attachment_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Markdown::Attachment do
+RSpec.describe Gitlab::GithubImport::Markdown::Attachment, feature_category: :importers do
let(:name) { FFaker::Lorem.word }
let(:url) { FFaker::Internet.uri('https') }
@@ -101,6 +101,62 @@ RSpec.describe Gitlab::GithubImport::Markdown::Attachment do
end
end
+ describe '#part_of_project_blob?' do
+ let(:attachment) { described_class.new('test', url) }
+ let(:import_source) { 'nickname/public-test-repo' }
+
+ context 'when url is a part of project blob' do
+ let(:url) { "https://github.com/#{import_source}/blob/main/example.md" }
+
+ it { expect(attachment.part_of_project_blob?(import_source)).to eq true }
+ end
+
+ context 'when url is not a part of project blob' do
+ let(:url) { "https://github.com/#{import_source}/files/9020437/git-cheat-sheet.txt" }
+
+ it { expect(attachment.part_of_project_blob?(import_source)).to eq false }
+ end
+ end
+
+ describe '#doc_belongs_to_project?' do
+ let(:attachment) { described_class.new('test', url) }
+ let(:import_source) { 'nickname/public-test-repo' }
+
+ context 'when url relates to this project' do
+ let(:url) { "https://github.com/#{import_source}/files/9020437/git-cheat-sheet.txt" }
+
+ it { expect(attachment.doc_belongs_to_project?(import_source)).to eq true }
+ end
+
+ context 'when url is not related to this project' do
+ let(:url) { 'https://github.com/nickname/other-repo/files/9020437/git-cheat-sheet.txt' }
+
+ it { expect(attachment.doc_belongs_to_project?(import_source)).to eq false }
+ end
+
+ context 'when url is a part of project blob' do
+ let(:url) { "https://github.com/#{import_source}/blob/main/example.md" }
+
+ it { expect(attachment.doc_belongs_to_project?(import_source)).to eq false }
+ end
+ end
+
+ describe '#media?' do
+ let(:attachment) { described_class.new('test', url) }
+
+ context 'when it is a media link' do
+ let(:url) { 'https://user-images.githubusercontent.com/6833842/0cf366b61ef2.jpeg' }
+
+ it { expect(attachment.media?).to eq true }
+ end
+
+ context 'when it is not a media link' do
+ let(:url) { 'https://github.com/nickname/public-test-repo/files/9020437/git-cheat-sheet.txt' }
+
+ it { expect(attachment.media?).to eq false }
+ end
+ end
+
describe '#inspect' do
it 'returns attachment basic info' do
attachment = described_class.new(name, url)
diff --git a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb
index c351ead91eb..9de39a3ff7e 100644
--- a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb
+++ b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb
@@ -289,77 +289,52 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling, feature_category: :impo
.and_return({ title: 'One' }, { title: 'Two' }, { title: 'Three' })
end
- context 'with multiple objects' do
- before do
- stub_feature_flags(improved_spread_parallel_import: false)
-
- expect(importer).to receive(:each_object_to_import).and_yield(object).and_yield(object).and_yield(object)
- end
-
- it 'imports data in parallel batches with delays' do
- expect(worker_class).to receive(:bulk_perform_in)
- .with(1.second, [
- [project.id, { title: 'One' }, an_instance_of(String)],
- [project.id, { title: 'Two' }, an_instance_of(String)],
- [project.id, { title: 'Three' }, an_instance_of(String)]
- ], batch_size: batch_size, batch_delay: batch_delay)
-
- importer.parallel_import
- end
+ it 'imports data in parallel with delays respecting parallel_import_batch definition and return job waiter' do
+ allow(::Gitlab::JobWaiter).to receive(:generate_key).and_return('waiter-key')
+ allow(importer).to receive(:parallel_import_batch).and_return({ size: 2, delay: 1.minute })
+
+ expect(importer).to receive(:each_object_to_import)
+ .and_yield(object).and_yield(object).and_yield(object)
+ expect(worker_class).to receive(:perform_in)
+ .with(1.second, project.id, { title: 'One' }, 'waiter-key').ordered
+ expect(worker_class).to receive(:perform_in)
+ .with(1.second, project.id, { title: 'Two' }, 'waiter-key').ordered
+ expect(worker_class).to receive(:perform_in)
+ .with(1.minute + 1.second, project.id, { title: 'Three' }, 'waiter-key').ordered
+
+ job_waiter = importer.parallel_import
+
+ expect(job_waiter.key).to eq('waiter-key')
+ expect(job_waiter.jobs_remaining).to eq(3)
end
- context 'when the feature flag `improved_spread_parallel_import` is enabled' do
+ context 'when job restarts due to API rate limit or Sidekiq interruption' do
before do
- stub_feature_flags(improved_spread_parallel_import: true)
+ cache_key = format(described_class::JOB_WAITER_CACHE_KEY,
+ project: project.id, collection: importer.collection_method)
+ Gitlab::Cache::Import::Caching.write(cache_key, 'waiter-key')
+
+ cache_key = format(described_class::JOB_WAITER_REMAINING_CACHE_KEY,
+ project: project.id, collection: importer.collection_method)
+ Gitlab::Cache::Import::Caching.write(cache_key, 3)
end
- it 'imports data in parallel with delays respecting parallel_import_batch definition and return job waiter' do
- allow(::Gitlab::JobWaiter).to receive(:generate_key).and_return('waiter-key')
- allow(importer).to receive(:parallel_import_batch).and_return({ size: 2, delay: 1.minute })
+ it "restores job waiter's key and jobs_remaining" do
+ allow(importer).to receive(:parallel_import_batch).and_return({ size: 1, delay: 1.minute })
+
+ expect(importer).to receive(:each_object_to_import).and_yield(object).and_yield(object).and_yield(object)
- expect(importer).to receive(:each_object_to_import)
- .and_yield(object).and_yield(object).and_yield(object)
expect(worker_class).to receive(:perform_in)
.with(1.second, project.id, { title: 'One' }, 'waiter-key').ordered
expect(worker_class).to receive(:perform_in)
- .with(1.second, project.id, { title: 'Two' }, 'waiter-key').ordered
+ .with(1.minute + 1.second, project.id, { title: 'Two' }, 'waiter-key').ordered
expect(worker_class).to receive(:perform_in)
- .with(1.minute + 1.second, project.id, { title: 'Three' }, 'waiter-key').ordered
+ .with(2.minutes + 1.second, project.id, { title: 'Three' }, 'waiter-key').ordered
job_waiter = importer.parallel_import
expect(job_waiter.key).to eq('waiter-key')
- expect(job_waiter.jobs_remaining).to eq(3)
- end
-
- context 'when job restarts due to API rate limit or Sidekiq interruption' do
- before do
- cache_key = format(described_class::JOB_WAITER_CACHE_KEY,
- project: project.id, collection: importer.collection_method)
- Gitlab::Cache::Import::Caching.write(cache_key, 'waiter-key')
-
- cache_key = format(described_class::JOB_WAITER_REMAINING_CACHE_KEY,
- project: project.id, collection: importer.collection_method)
- Gitlab::Cache::Import::Caching.write(cache_key, 3)
- end
-
- it "restores job waiter's key and jobs_remaining" do
- allow(importer).to receive(:parallel_import_batch).and_return({ size: 1, delay: 1.minute })
-
- expect(importer).to receive(:each_object_to_import).and_yield(object).and_yield(object).and_yield(object)
-
- expect(worker_class).to receive(:perform_in)
- .with(1.second, project.id, { title: 'One' }, 'waiter-key').ordered
- expect(worker_class).to receive(:perform_in)
- .with(1.minute + 1.second, project.id, { title: 'Two' }, 'waiter-key').ordered
- expect(worker_class).to receive(:perform_in)
- .with(2.minutes + 1.second, project.id, { title: 'Three' }, 'waiter-key').ordered
-
- job_waiter = importer.parallel_import
-
- expect(job_waiter.key).to eq('waiter-key')
- expect(job_waiter.jobs_remaining).to eq(6)
- end
+ expect(job_waiter.jobs_remaining).to eq(6)
end
end
end
diff --git a/spec/lib/gitlab/github_import/project_relation_type_spec.rb b/spec/lib/gitlab/github_import/project_relation_type_spec.rb
new file mode 100644
index 00000000000..419cb6de121
--- /dev/null
+++ b/spec/lib/gitlab/github_import/project_relation_type_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::ProjectRelationType, :manage, feature_category: :importers do
+ subject(:project_relation_type) { described_class.new(client) }
+
+ let(:octokit) { instance_double(Octokit::Client) }
+ let(:client) do
+ instance_double(Gitlab::GithubImport::Clients::Proxy, octokit: octokit, user: { login: 'nickname' })
+ end
+
+ describe '#for', :use_clean_rails_redis_caching do
+ before do
+ allow(client).to receive(:each_object).with(:organizations).and_yield({ login: 'great-org' })
+ allow(octokit).to receive(:access_token).and_return('stub')
+ end
+
+ context "when it's user owned repo" do
+ let(:import_source) { 'nickname/repo_name' }
+
+ it { expect(project_relation_type.for(import_source)).to eq 'owned' }
+ end
+
+ context "when it's organization repo" do
+ let(:import_source) { 'great-org/repo_name' }
+
+ it { expect(project_relation_type.for(import_source)).to eq 'organization' }
+ end
+
+ context "when it's user collaborated repo" do
+ let(:import_source) { 'some-another-namespace/repo_name' }
+
+ it { expect(project_relation_type.for(import_source)).to eq 'collaborated' }
+ end
+
+ context 'with cache' do
+ let(:import_source) { 'some-another-namespace/repo_name' }
+
+ it 'calls client only once during 5 minutes timeframe', :request_store do
+ expect(project_relation_type.for(import_source)).to eq 'collaborated'
+ expect(project_relation_type.for('another/repo')).to eq 'collaborated'
+
+ expect(client).to have_received(:each_object).once
+ expect(client).to have_received(:user).once
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/representation/collaborator_spec.rb b/spec/lib/gitlab/github_import/representation/collaborator_spec.rb
new file mode 100644
index 00000000000..cc52c34ec74
--- /dev/null
+++ b/spec/lib/gitlab/github_import/representation/collaborator_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Representation::Collaborator, feature_category: :importers do
+ shared_examples 'a Collaborator' do
+ it 'returns an instance of Collaborator' do
+ expect(collaborator).to be_an_instance_of(described_class)
+ end
+
+ context 'with Collaborator' do
+ it 'includes the user ID' do
+ expect(collaborator.id).to eq(42)
+ end
+
+ it 'includes the username' do
+ expect(collaborator.login).to eq('alice')
+ end
+
+ it 'includes the role' do
+ expect(collaborator.role_name).to eq('maintainer')
+ end
+
+ describe '#github_identifiers' do
+ it 'returns a hash with needed identifiers' do
+ expect(collaborator.github_identifiers).to eq(
+ {
+ id: 42,
+ login: 'alice'
+ }
+ )
+ end
+ end
+ end
+ end
+
+ describe '.from_api_response' do
+ it_behaves_like 'a Collaborator' do
+ let(:response) { { id: 42, login: 'alice', role_name: 'maintainer' } }
+ let(:collaborator) { described_class.from_api_response(response) }
+ end
+ end
+
+ describe '.from_json_hash' do
+ it_behaves_like 'a Collaborator' do
+ let(:hash) { { 'id' => 42, 'login' => 'alice', role_name: 'maintainer' } }
+ let(:collaborator) { described_class.from_json_hash(hash) }
+ 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 56fabe854f9..3e76b4ae698 100644
--- a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb
@@ -131,7 +131,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote, :clean_gitlab_red
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
expect(note.github_identifiers).to eq(
- noteable_id: 42,
+ noteable_iid: 42,
noteable_type: 'MergeRequest',
note_id: 1
)
diff --git a/spec/lib/gitlab/github_import/representation/issue_event_spec.rb b/spec/lib/gitlab/github_import/representation/issue_event_spec.rb
index 0dd281cb3b0..6620dee0fd0 100644
--- a/spec/lib/gitlab/github_import/representation/issue_event_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/issue_event_spec.rb
@@ -156,7 +156,11 @@ RSpec.describe Gitlab::GithubImport::Representation::IssueEvent do
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
- expect(issue_event.github_identifiers).to eq({ id: 6501124486 })
+ expect(issue_event.github_identifiers).to eq(
+ id: 6501124486,
+ issuable_iid: 2,
+ event: 'closed'
+ )
end
end
end
diff --git a/spec/lib/gitlab/github_import/representation/issue_spec.rb b/spec/lib/gitlab/github_import/representation/issue_spec.rb
index 263ef8b1708..39447da0fac 100644
--- a/spec/lib/gitlab/github_import/representation/issue_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/issue_spec.rb
@@ -192,7 +192,8 @@ RSpec.describe Gitlab::GithubImport::Representation::Issue do
it 'returns a hash with needed identifiers' do
github_identifiers = {
iid: 42,
- issuable_type: 'MergeRequest'
+ issuable_type: 'MergeRequest',
+ title: 'Implement cool feature'
}
other_attributes = { pull_request: true, something_else: '_something_else_' }
issue = described_class.new(github_identifiers.merge(other_attributes))
diff --git a/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb b/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb
index 6663a7366a5..799a77afb0c 100644
--- a/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb
@@ -6,7 +6,8 @@ RSpec.describe Gitlab::GithubImport::Representation::LfsObject do
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
github_identifiers = {
- oid: 42
+ oid: 42,
+ size: 123456
}
other_attributes = { something_else: '_something_else_' }
lfs_object = described_class.new(github_identifiers.merge(other_attributes))
diff --git a/spec/lib/gitlab/github_import/representation/note_spec.rb b/spec/lib/gitlab/github_import/representation/note_spec.rb
index 49126dbe9c5..5c2cea3653f 100644
--- a/spec/lib/gitlab/github_import/representation/note_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/note_spec.rb
@@ -43,6 +43,16 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do
it 'includes the note ID' do
expect(note.note_id).to eq(1)
end
+
+ describe '#github_identifiers' do
+ it 'returns a hash with needed identifiers' do
+ expect(note.github_identifiers).to eq(
+ noteable_iid: 42,
+ noteable_type: 'Issue',
+ note_id: 1
+ )
+ end
+ end
end
end
@@ -103,18 +113,4 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do
expect(note.author).to be_nil
end
end
-
- describe '#github_identifiers' do
- it 'returns a hash with needed identifiers' do
- github_identifiers = {
- noteable_id: 42,
- noteable_type: 'Issue',
- note_id: 1
- }
- other_attributes = { something_else: '_something_else_' }
- note = described_class.new(github_identifiers.merge(other_attributes))
-
- expect(note.github_identifiers).to eq(github_identifiers)
- end
- end
end
diff --git a/spec/lib/gitlab/github_import/representation/note_text_spec.rb b/spec/lib/gitlab/github_import/representation/note_text_spec.rb
index 8b57c9a0373..7aa458a1c33 100644
--- a/spec/lib/gitlab/github_import/representation/note_text_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/note_text_spec.rb
@@ -22,35 +22,45 @@ RSpec.describe Gitlab::GithubImport::Representation::NoteText do
end
describe '.from_db_record' do
+ let(:representation) { described_class.from_db_record(record) }
+
context 'with Release' do
- let(:record) { build_stubbed(:release, id: 42, description: 'Some text here..') }
+ let(:record) { build_stubbed(:release, id: 42, description: 'Some text here..', tag: 'v1.0') }
+
+ it_behaves_like 'a Note text data', 'Release'
- it_behaves_like 'a Note text data', 'Release' do
- let(:representation) { described_class.from_db_record(record) }
+ it 'includes tag' do
+ expect(representation.tag).to eq 'v1.0'
end
end
context 'with Issue' do
- let(:record) { build_stubbed(:issue, id: 42, description: 'Some text here..') }
+ let(:record) { build_stubbed(:issue, id: 42, iid: 2, description: 'Some text here..') }
+
+ it_behaves_like 'a Note text data', 'Issue'
- it_behaves_like 'a Note text data', 'Issue' do
- let(:representation) { described_class.from_db_record(record) }
+ it 'includes noteable iid' do
+ expect(representation.iid).to eq 2
end
end
context 'with MergeRequest' do
- let(:record) { build_stubbed(:merge_request, id: 42, description: 'Some text here..') }
+ let(:record) { build_stubbed(:merge_request, id: 42, iid: 2, description: 'Some text here..') }
- it_behaves_like 'a Note text data', 'MergeRequest' do
- let(:representation) { described_class.from_db_record(record) }
+ it_behaves_like 'a Note text data', 'MergeRequest'
+
+ it 'includes noteable iid' do
+ expect(representation.iid).to eq 2
end
end
context 'with Note' do
- let(:record) { build_stubbed(:note, id: 42, note: 'Some text here..') }
+ let(:record) { build_stubbed(:note, id: 42, note: 'Some text here..', noteable_type: 'Issue') }
+
+ it_behaves_like 'a Note text data', 'Note'
- it_behaves_like 'a Note text data', 'Note' do
- let(:representation) { described_class.from_db_record(record) }
+ it 'includes noteable type' do
+ expect(representation.noteable_type).to eq 'Issue'
end
end
end
@@ -61,7 +71,8 @@ RSpec.describe Gitlab::GithubImport::Representation::NoteText do
{
'record_db_id' => 42,
'record_type' => 'Release',
- 'text' => 'Some text here..'
+ 'text' => 'Some text here..',
+ 'tag' => 'v1.0'
}
end
@@ -70,11 +81,76 @@ RSpec.describe Gitlab::GithubImport::Representation::NoteText do
end
describe '#github_identifiers' do
- it 'returns a hash with needed identifiers' do
- record_id = rand(100)
- representation = described_class.new(record_db_id: record_id, text: 'text')
+ let(:iid) { nil }
+ let(:tag) { nil }
+ let(:noteable_type) { nil }
+ let(:hash) do
+ {
+ 'record_db_id' => 42,
+ 'record_type' => record_type,
+ 'text' => 'Some text here..',
+ 'iid' => iid,
+ 'tag' => tag,
+ 'noteable_type' => noteable_type
+ }
+ end
+
+ subject { described_class.from_json_hash(hash) }
+
+ context 'with Release' do
+ let(:record_type) { 'Release' }
+ let(:tag) { 'v1.0' }
+
+ it 'returns a hash with needed identifiers' do
+ expect(subject.github_identifiers).to eq(
+ {
+ db_id: 42,
+ tag: 'v1.0'
+ }
+ )
+ end
+ end
+
+ context 'with Issue' do
+ let(:record_type) { 'Issue' }
+ let(:iid) { 2 }
+
+ it 'returns a hash with needed identifiers' do
+ expect(subject.github_identifiers).to eq(
+ {
+ db_id: 42,
+ noteable_iid: 2
+ }
+ )
+ end
+ end
- expect(representation.github_identifiers).to eq({ db_id: record_id })
+ context 'with Merge Request' do
+ let(:record_type) { 'MergeRequest' }
+ let(:iid) { 3 }
+
+ it 'returns a hash with needed identifiers' do
+ expect(subject.github_identifiers).to eq(
+ {
+ db_id: 42,
+ noteable_iid: 3
+ }
+ )
+ end
+ end
+
+ context 'with Note' do
+ let(:record_type) { 'Note' }
+ let(:noteable_type) { 'MergeRequest' }
+
+ it 'returns a hash with needed identifiers' do
+ expect(subject.github_identifiers).to eq(
+ {
+ db_id: 42,
+ noteable_type: 'MergeRequest'
+ }
+ )
+ end
end
end
end
diff --git a/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb b/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb
index 0203da9f4fb..8925f466e27 100644
--- a/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb
@@ -77,7 +77,7 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do
it 'returns a hash with needed identifiers' do
github_identifiers = {
review_id: 999,
- merge_request_id: 42
+ merge_request_iid: 1
}
other_attributes = { something_else: '_something_else_' }
review = described_class.new(github_identifiers.merge(other_attributes))
diff --git a/spec/lib/gitlab/github_import/representation/pull_request_spec.rb b/spec/lib/gitlab/github_import/representation/pull_request_spec.rb
index b8c1c67e07c..4b8e7401e9d 100644
--- a/spec/lib/gitlab/github_import/representation/pull_request_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/pull_request_spec.rb
@@ -287,7 +287,8 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequest do
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
github_identifiers = {
- iid: 1
+ iid: 1,
+ title: 'My Pull Request'
}
other_attributes = { something_else: '_something_else_' }
pr = described_class.new(github_identifiers.merge(other_attributes))
diff --git a/spec/lib/gitlab/github_import/representation/pull_requests/review_requests_spec.rb b/spec/lib/gitlab/github_import/representation/pull_requests/review_requests_spec.rb
index 0393f692a69..0259fbedee3 100644
--- a/spec/lib/gitlab/github_import/representation/pull_requests/review_requests_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/pull_requests/review_requests_spec.rb
@@ -46,4 +46,27 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequests::ReviewRequest
let(:review_requests) { described_class.from_json_hash(response) }
end
end
+
+ describe '#github_identifiers' do
+ it 'returns a hash with needed identifiers' do
+ review_requests = {
+ merge_request_iid: 2,
+ merge_request_id: merge_request_id,
+ users: [
+ { id: 4, login: 'alice' },
+ { id: 5, login: 'bob' }
+ ]
+ }
+
+ github_identifiers = {
+ merge_request_iid: 2,
+ requested_reviewers: %w[alice bob]
+ }
+
+ other_attributes = { merge_request_id: 123, something_else: '_something_else_' }
+ review_requests = described_class.new(review_requests.merge(other_attributes))
+
+ expect(review_requests.github_identifiers).to eq(github_identifiers)
+ end
+ end
end
diff --git a/spec/lib/gitlab/github_import/settings_spec.rb b/spec/lib/gitlab/github_import/settings_spec.rb
index ad0c47e8e8a..43e096863b8 100644
--- a/spec/lib/gitlab/github_import/settings_spec.rb
+++ b/spec/lib/gitlab/github_import/settings_spec.rb
@@ -11,7 +11,8 @@ RSpec.describe Gitlab::GithubImport::Settings do
{
single_endpoint_issue_events_import: true,
single_endpoint_notes_import: false,
- attachments_import: false
+ attachments_import: false,
+ collaborators_import: false
}
end
@@ -22,17 +23,26 @@ RSpec.describe Gitlab::GithubImport::Settings do
{
name: 'single_endpoint_issue_events_import',
label: stages[:single_endpoint_issue_events_import][:label],
+ selected: false,
details: stages[:single_endpoint_issue_events_import][:details]
},
{
name: 'single_endpoint_notes_import',
label: stages[:single_endpoint_notes_import][:label],
+ selected: false,
details: stages[:single_endpoint_notes_import][:details]
},
{
name: 'attachments_import',
label: stages[:attachments_import][:label].strip,
+ selected: false,
details: stages[:attachments_import][:details]
+ },
+ {
+ name: 'collaborators_import',
+ label: stages[:collaborators_import][:label].strip,
+ selected: true,
+ details: stages[:collaborators_import][:details]
}
]
end
@@ -48,6 +58,7 @@ RSpec.describe Gitlab::GithubImport::Settings do
single_endpoint_issue_events_import: true,
single_endpoint_notes_import: 'false',
attachments_import: nil,
+ collaborators_import: false,
foo: :bar
}.stringify_keys
end
@@ -67,6 +78,7 @@ RSpec.describe Gitlab::GithubImport::Settings do
expect(settings.enabled?(:single_endpoint_issue_events_import)).to eq true
expect(settings.enabled?(:single_endpoint_notes_import)).to eq false
expect(settings.enabled?(:attachments_import)).to eq false
+ expect(settings.enabled?(:collaborators_import)).to eq false
end
end
@@ -77,6 +89,7 @@ RSpec.describe Gitlab::GithubImport::Settings do
expect(settings.disabled?(:single_endpoint_issue_events_import)).to eq false
expect(settings.disabled?(:single_endpoint_notes_import)).to eq true
expect(settings.disabled?(:attachments_import)).to eq true
+ expect(settings.disabled?(:collaborators_import)).to eq true
end
end
end
diff --git a/spec/lib/gitlab/github_import/user_finder_spec.rb b/spec/lib/gitlab/github_import/user_finder_spec.rb
index d77aaa0e846..b6e369cb35b 100644
--- a/spec/lib/gitlab/github_import/user_finder_spec.rb
+++ b/spec/lib/gitlab/github_import/user_finder_spec.rb
@@ -259,6 +259,41 @@ RSpec.describe Gitlab::GithubImport::UserFinder, :clean_gitlab_redis_cache do
expect(finder.email_for_github_username('kittens')).to be_nil
end
+
+ context 'when a username does not exist on GitHub' do
+ context 'when github username inexistence is not cached' do
+ it 'caches github username inexistence' do
+ expect(client)
+ .to receive(:user)
+ .with('kittens')
+ .and_raise(::Octokit::NotFound)
+
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:write).with(
+ described_class::INEXISTENCE_OF_GITHUB_USERNAME_CACHE_KEY % 'kittens', true
+ )
+
+ expect(finder.email_for_github_username('kittens')).to be_nil
+ end
+ end
+
+ context 'when github username inexistence is already cached' do
+ it 'does not make request to the client' do
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:read).with(described_class::EMAIL_FOR_USERNAME_CACHE_KEY % 'kittens')
+
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:read).with(
+ described_class::INEXISTENCE_OF_GITHUB_USERNAME_CACHE_KEY % 'kittens'
+ ).and_return('true')
+
+ expect(client)
+ .not_to receive(:user)
+
+ expect(finder.email_for_github_username('kittens')).to be_nil
+ end
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/gitlab_import/client_spec.rb b/spec/lib/gitlab/gitlab_import/client_spec.rb
deleted file mode 100644
index 7f57d5fbf1b..00000000000
--- a/spec/lib/gitlab/gitlab_import/client_spec.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::GitlabImport::Client do
- include ImportSpecHelper
-
- let(:token) { '123456' }
- let(:client) { described_class.new(token) }
-
- before do
- stub_omniauth_provider('gitlab')
- end
-
- it 'all OAuth2 client options are symbols' do
- expect(client.client.options.keys).to all(be_kind_of(Symbol))
- end
-
- it 'uses membership and simple flags' do
- stub_request('/api/v4/projects?membership=true&page=1&per_page=100&simple=true')
-
- expect_next_instance_of(OAuth2::Response) do |instance|
- expect(instance).to receive(:parsed).and_return([])
- end
-
- expect(client.projects.to_a).to eq []
- end
-
- shared_examples 'pagination params' do
- before do
- allow_next_instance_of(OAuth2::Response) do |instance|
- allow(instance).to receive(:parsed).and_return([])
- end
- end
-
- it 'allows page_limit param' do
- allow_next_instance_of(OAuth2::Response) do |instance|
- allow(instance).to receive(:parsed).and_return(element_list)
- end
-
- expect(client).to receive(:lazy_page_iterator).with(hash_including(page_limit: 2)).and_call_original
-
- client.send(method, *args, page_limit: 2, per_page: 1).to_a
- end
-
- it 'allows per_page param' do
- expect(client).to receive(:lazy_page_iterator).with(hash_including(per_page: 2)).and_call_original
-
- client.send(method, *args, per_page: 2).to_a
- end
-
- it 'allows starting_page param' do
- expect(client).to receive(:lazy_page_iterator).with(hash_including(starting_page: 3)).and_call_original
-
- client.send(method, *args, starting_page: 3).to_a
- end
- end
-
- describe '#projects' do
- subject(:method) { :projects }
-
- let(:args) { [] }
- let(:element_list) { build_list(:project, 2) }
-
- before do
- stub_request('/api/v4/projects?membership=true&page=1&per_page=1&simple=true')
- stub_request('/api/v4/projects?membership=true&page=2&per_page=1&simple=true')
- stub_request('/api/v4/projects?membership=true&page=1&per_page=2&simple=true')
- stub_request('/api/v4/projects?membership=true&page=3&per_page=100&simple=true')
- end
-
- it_behaves_like 'pagination params'
- end
-
- describe '#issues' do
- subject(:method) { :issues }
-
- let(:args) { [1] }
- let(:element_list) { build_list(:issue, 2) }
-
- before do
- stub_request('/api/v4/projects/1/issues?page=1&per_page=1')
- stub_request('/api/v4/projects/1/issues?page=2&per_page=1')
- stub_request('/api/v4/projects/1/issues?page=1&per_page=2')
- stub_request('/api/v4/projects/1/issues?page=3&per_page=100')
- end
-
- it_behaves_like 'pagination params'
- end
-
- describe '#issue_comments' do
- subject(:method) { :issue_comments }
-
- let(:args) { [1, 1] }
- let(:element_list) { build_list(:note_on_issue, 2) }
-
- before do
- stub_request('/api/v4/projects/1/issues/1/notes?page=1&per_page=1')
- stub_request('/api/v4/projects/1/issues/1/notes?page=2&per_page=1')
- stub_request('/api/v4/projects/1/issues/1/notes?page=1&per_page=2')
- stub_request('/api/v4/projects/1/issues/1/notes?page=3&per_page=100')
- end
-
- it_behaves_like 'pagination params'
- end
-
- def stub_request(path)
- WebMock.stub_request(:get, "https://gitlab.com#{path}")
- .to_return(status: 200)
- end
-end
diff --git a/spec/lib/gitlab/gitlab_import/importer_spec.rb b/spec/lib/gitlab/gitlab_import/importer_spec.rb
deleted file mode 100644
index 984c690add6..00000000000
--- a/spec/lib/gitlab/gitlab_import/importer_spec.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::GitlabImport::Importer do
- include ImportSpecHelper
-
- describe '#execute' do
- before do
- stub_omniauth_provider('gitlab')
- stub_request('issues', [
- {
- 'id' => 2579857,
- 'iid' => 3,
- 'title' => 'Issue',
- 'description' => 'Lorem ipsum',
- 'state' => 'opened',
- 'confidential' => true,
- 'author' => {
- 'id' => 283999,
- 'name' => 'John Doe'
- }
- }
- ].to_json)
- stub_request('issues/3/notes', [].to_json)
- end
-
- it 'persists issues' do
- project = create(:project, import_source: 'asd/vim')
- project.build_import_data(credentials: { password: 'password' })
-
- subject = described_class.new(project)
- subject.execute
-
- expected_attributes = {
- iid: 3,
- title: 'Issue',
- description: "*Created by: John Doe*\n\nLorem ipsum",
- state: 'opened',
- confidential: true,
- author_id: project.creator_id
- }
-
- expect(project.issues.first).to have_attributes(expected_attributes)
- end
-
- def stub_request(path, body)
- url = "https://gitlab.com/api/v4/projects/asd%2Fvim/#{path}?page=1&per_page=100"
-
- WebMock.stub_request(:get, url)
- .to_return(
- headers: { 'Content-Type' => 'application/json' },
- body: body
- )
- end
- end
-end
diff --git a/spec/lib/gitlab/gitlab_import/project_creator_spec.rb b/spec/lib/gitlab/gitlab_import/project_creator_spec.rb
deleted file mode 100644
index 53bf1db3438..00000000000
--- a/spec/lib/gitlab/gitlab_import/project_creator_spec.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::GitlabImport::ProjectCreator do
- let(:user) { create(:user) }
- let(:repo) do
- {
- name: 'vim',
- path: 'vim',
- visibility_level: Gitlab::VisibilityLevel::PRIVATE,
- path_with_namespace: 'asd/vim',
- http_url_to_repo: "https://gitlab.com/asd/vim.git",
- owner: { name: "john" }
- }.with_indifferent_access
- end
-
- let(:namespace) { create(:group) }
- let(:token) { "asdffg" }
- let(:access_params) { { gitlab_access_token: token } }
-
- before do
- namespace.add_owner(user)
- end
-
- it 'creates project' do
- expect_next_instance_of(Project) do |project|
- expect(project).to receive(:add_import_job)
- end
-
- project_creator = described_class.new(repo, namespace, user, access_params)
- project = project_creator.execute
-
- expect(project.import_url).to eq("https://oauth2:asdffg@gitlab.com/asd/vim.git")
- expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
- end
-end
diff --git a/spec/lib/gitlab/gl_repository/identifier_spec.rb b/spec/lib/gitlab/gl_repository/identifier_spec.rb
index 0a8559dd800..dbdcafea6d6 100644
--- a/spec/lib/gitlab/gl_repository/identifier_spec.rb
+++ b/spec/lib/gitlab/gl_repository/identifier_spec.rb
@@ -68,10 +68,12 @@ RSpec.describe Gitlab::GlRepository::Identifier do
end
describe 'design' do
+ let(:design_repository_container) { project.design_repository.container }
+
it_behaves_like 'parsing gl_repository identifier' do
let(:record_id) { project.id }
- let(:identifier) { "design-#{project.id}" }
- let(:expected_container) { project }
+ let(:identifier) { "design-#{design_repository_container.id}" }
+ let(:expected_container) { design_repository_container }
let(:expected_type) { Gitlab::GlRepository::DESIGN }
end
end
diff --git a/spec/lib/gitlab/gl_repository/repo_type_spec.rb b/spec/lib/gitlab/gl_repository/repo_type_spec.rb
index 0ec94563cbb..2ac2fc1fd4b 100644
--- a/spec/lib/gitlab/gl_repository/repo_type_spec.rb
+++ b/spec/lib/gitlab/gl_repository/repo_type_spec.rb
@@ -12,6 +12,8 @@ RSpec.describe Gitlab::GlRepository::RepoType do
let(:personal_snippet_path) { "snippets/#{personal_snippet.id}" }
let(:project_snippet_path) { "#{project.full_path}/snippets/#{project_snippet.id}" }
+ let(:expected_repository_resolver) { expected_container }
+
describe Gitlab::GlRepository::PROJECT do
it_behaves_like 'a repo type' do
let(:expected_id) { project.id }
@@ -133,11 +135,12 @@ RSpec.describe Gitlab::GlRepository::RepoType do
describe Gitlab::GlRepository::DESIGN do
it_behaves_like 'a repo type' do
- let(:expected_identifier) { "design-#{project.id}" }
- let(:expected_id) { project.id }
+ let(:expected_repository) { project.design_repository }
+ let(:expected_container) { project.design_management_repository }
+ let(:expected_id) { expected_container.id }
+ let(:expected_identifier) { "design-#{expected_id}" }
let(:expected_suffix) { '.design' }
- let(:expected_repository) { ::DesignManagement::Repository.new(project) }
- let(:expected_container) { project }
+ let(:expected_repository_resolver) { project }
end
it 'uses the design access checker' do
@@ -162,5 +165,17 @@ RSpec.describe Gitlab::GlRepository::RepoType do
expect(described_class.valid?(project_snippet_path)).to be_falsey
end
end
+
+ describe '.project_for' do
+ it 'returns a project' do
+ expect(described_class.project_for(project.design_repository.container)).to be_instance_of(Project)
+ end
+ end
+
+ describe '.repository_for' do
+ it 'returns a DesignManagement::GitRepository when a project is passed' do
+ expect(described_class.repository_for(project)).to be_instance_of(DesignManagement::GitRepository)
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/gl_repository_spec.rb b/spec/lib/gitlab/gl_repository_spec.rb
index 05914f92c01..7be01507a82 100644
--- a/spec/lib/gitlab/gl_repository_spec.rb
+++ b/spec/lib/gitlab/gl_repository_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe ::Gitlab::GlRepository do
describe '.parse' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:snippet) { create(:personal_snippet) }
+ let(:design_repository_container) { project.design_repository.container }
it 'parses a project gl_repository' do
expect(described_class.parse("project-#{project.id}")).to eq([project, project, Gitlab::GlRepository::PROJECT])
@@ -20,7 +21,13 @@ RSpec.describe ::Gitlab::GlRepository do
end
it 'parses a design gl_repository' do
- expect(described_class.parse("design-#{project.id}")).to eq([project, project, Gitlab::GlRepository::DESIGN])
+ expect(described_class.parse("design-#{design_repository_container.id}")).to eq(
+ [
+ design_repository_container,
+ project,
+ Gitlab::GlRepository::DESIGN
+ ]
+ )
end
it 'throws an argument error on an invalid gl_repository type' do
diff --git a/spec/lib/gitlab/grape_logging/loggers/response_logger_spec.rb b/spec/lib/gitlab/grape_logging/loggers/response_logger_spec.rb
index 94e880d979d..449096a6faf 100644
--- a/spec/lib/gitlab/grape_logging/loggers/response_logger_spec.rb
+++ b/spec/lib/gitlab/grape_logging/loggers/response_logger_spec.rb
@@ -27,5 +27,11 @@ RSpec.describe Gitlab::GrapeLogging::Loggers::ResponseLogger do
it { expect(subject).to eq({}) }
end
+
+ context 'when response is a String' do
+ let(:response) { response1 }
+
+ it { expect(subject).to eq({ response_bytes: response1.bytesize }) }
+ end
end
end
diff --git a/spec/lib/gitlab/graphql/authorize/authorize_resource_spec.rb b/spec/lib/gitlab/graphql/authorize/authorize_resource_spec.rb
index ac512e28e7b..1cd93d7b364 100644
--- a/spec/lib/gitlab/graphql/authorize/authorize_resource_spec.rb
+++ b/spec/lib/gitlab/graphql/authorize/authorize_resource_spec.rb
@@ -76,13 +76,17 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeResource do
end
end
- context 'when the class does not define #find_object' do
+ describe '#find_object' do
let(:fake_class) do
Class.new { include Gitlab::Graphql::Authorize::AuthorizeResource }
end
- it 'raises a comprehensive error message' do
- expect { fake_class.new.find_object }.to raise_error(/Implement #find_object in #{fake_class.name}/)
+ let(:id) { "id" }
+ let(:return_value) { "return value" }
+
+ it 'calls GitlabSchema.find_by_gid' do
+ expect(GitlabSchema).to receive(:find_by_gid).with(id).and_return(return_value)
+ expect(fake_class.new.find_object(id: id)).to be return_value
end
end
diff --git a/spec/lib/gitlab/graphql/deprecations/deprecation_spec.rb b/spec/lib/gitlab/graphql/deprecations/deprecation_spec.rb
index 55650b0480e..172872fd7eb 100644
--- a/spec/lib/gitlab/graphql/deprecations/deprecation_spec.rb
+++ b/spec/lib/gitlab/graphql/deprecations/deprecation_spec.rb
@@ -55,7 +55,7 @@ RSpec.describe ::Gitlab::Graphql::Deprecations::Deprecation, feature_category: :
it 'raises an error' do
expect { parsed_deprecation }.to raise_error(ArgumentError,
- '`alpha` and `deprecated` arguments cannot be passed at the same time'
+ '`experiment` and `deprecated` arguments cannot be passed at the same time'
)
end
end
diff --git a/spec/lib/gitlab/graphql/known_operations_spec.rb b/spec/lib/gitlab/graphql/known_operations_spec.rb
index 3ebfefbb43c..c7bc47e1e6a 100644
--- a/spec/lib/gitlab/graphql/known_operations_spec.rb
+++ b/spec/lib/gitlab/graphql/known_operations_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
require 'rspec-parameterized'
-require "support/graphql/fake_query_type"
RSpec.describe Gitlab::Graphql::KnownOperations do
using RSpec::Parameterized::TableSyntax
diff --git a/spec/lib/gitlab/graphql/loaders/lazy_relation_loader/registry_spec.rb b/spec/lib/gitlab/graphql/loaders/lazy_relation_loader/registry_spec.rb
new file mode 100644
index 00000000000..265839d1236
--- /dev/null
+++ b/spec/lib/gitlab/graphql/loaders/lazy_relation_loader/registry_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Graphql::Loaders::LazyRelationLoader::Registry, feature_category: :vulnerability_management do
+ describe '#respond_to?' do
+ let(:relation) { Project.all }
+ let(:registry) { described_class.new(relation) }
+
+ subject { registry.respond_to?(method_name) }
+
+ context 'when the relation responds to given method' do
+ let(:method_name) { :sorted_by_updated_asc }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when the relation does not respond to given method' do
+ let(:method_name) { :this_method_does_not_exist }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/loaders/lazy_relation_loader/relation_proxy_spec.rb b/spec/lib/gitlab/graphql/loaders/lazy_relation_loader/relation_proxy_spec.rb
new file mode 100644
index 00000000000..f54fb6e77c5
--- /dev/null
+++ b/spec/lib/gitlab/graphql/loaders/lazy_relation_loader/relation_proxy_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Graphql::Loaders::LazyRelationLoader::RelationProxy, feature_category: :vulnerability_management do
+ describe '#respond_to?' do
+ let(:object) { double }
+ let(:registry) { instance_double(Gitlab::Graphql::Loaders::LazyRelationLoader::Registry) }
+ let(:relation_proxy) { described_class.new(object, registry) }
+
+ subject { relation_proxy.respond_to?(:foo) }
+
+ before do
+ allow(registry).to receive(:respond_to?).with(:foo, false).and_return(responds_to?)
+ end
+
+ context 'when the registry responds to given method' do
+ let(:responds_to?) { true }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when the registry does not respond to given method' do
+ let(:responds_to?) { false }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/loaders/lazy_relation_loader_spec.rb b/spec/lib/gitlab/graphql/loaders/lazy_relation_loader_spec.rb
new file mode 100644
index 00000000000..e56cb68c6cb
--- /dev/null
+++ b/spec/lib/gitlab/graphql/loaders/lazy_relation_loader_spec.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Graphql::Loaders::LazyRelationLoader, feature_category: :vulnerability_management do
+ let(:query_context) { {} }
+ let(:args) { {} }
+
+ let_it_be(:project) { create(:project) }
+
+ let(:loader) { loader_class.new(query_context, project, **args) }
+
+ describe '#load' do
+ subject(:load_relation) { loader.load }
+
+ context 'when the association is has many' do
+ let_it_be(:public_issue) { create(:issue, project: project) }
+ let_it_be(:confidential_issue) { create(:issue, :confidential, project: project) }
+
+ let(:loader_class) do
+ Class.new(described_class) do
+ self.model = Project
+ self.association = :issues
+
+ def relation(public_only: false)
+ relation = base_relation
+ relation = relation.public_only if public_only
+
+ relation
+ end
+ end
+ end
+
+ it { is_expected.to be_an_instance_of(described_class::RelationProxy) }
+
+ describe '#relation' do
+ subject { load_relation.load }
+
+ context 'without arguments' do
+ it { is_expected.to contain_exactly(public_issue, confidential_issue) }
+ end
+
+ context 'with arguments' do
+ let(:args) { { public_only: true } }
+
+ it { is_expected.to contain_exactly(public_issue) }
+ end
+ end
+
+ describe 'using the same context for different records' do
+ let_it_be(:another_project) { create(:project) }
+
+ let(:loader_for_another_project) { loader_class.new(query_context, another_project, **args) }
+ let(:records_for_another_project) { loader_for_another_project.load.load }
+ let(:records_for_project) { load_relation.load }
+
+ before do
+ loader # register the original loader to query context
+ end
+
+ it 'does not mix associated records' do
+ expect(records_for_another_project).to be_empty
+ expect(records_for_project).to contain_exactly(public_issue, confidential_issue)
+ end
+
+ it 'does not cause N+1 queries' do
+ expect { records_for_another_project }.not_to exceed_query_limit(1)
+ end
+ end
+
+ describe 'using Active Record querying methods' do
+ subject { load_relation.limit(1).load.count }
+
+ it { is_expected.to be(1) }
+ end
+
+ describe 'using Active Record finder methods' do
+ subject { load_relation.last(2) }
+
+ it { is_expected.to contain_exactly(public_issue, confidential_issue) }
+ end
+
+ describe 'calling a method that returns a non relation object' do
+ subject { load_relation.limit(1).limit_value }
+
+ it { is_expected.to be(1) }
+ end
+
+ describe 'calling a prohibited method' do
+ subject(:count) { load_relation.count }
+
+ it 'raises a `PrematureQueryExecutionTriggered` error' do
+ expect { count }.to raise_error(described_class::Registry::PrematureQueryExecutionTriggered)
+ end
+ end
+ end
+
+ context 'when the association is has one' do
+ let!(:project_setting) { create(:project_setting, project: project) }
+ let(:loader_class) do
+ Class.new(described_class) do
+ self.model = Project
+ self.association = :project_setting
+ end
+ end
+
+ it { is_expected.to eq(project_setting) }
+ end
+
+ context 'when the association is belongs to' do
+ let(:loader_class) do
+ Class.new(described_class) do
+ self.model = Project
+ self.association = :namespace
+ end
+ end
+
+ it 'raises error' do
+ expect { load_relation }.to raise_error(RuntimeError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/subscriptions/action_cable_with_load_balancing_spec.rb b/spec/lib/gitlab/graphql/subscriptions/action_cable_with_load_balancing_spec.rb
new file mode 100644
index 00000000000..8d8b879a90f
--- /dev/null
+++ b/spec/lib/gitlab/graphql/subscriptions/action_cable_with_load_balancing_spec.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Graphql::Subscriptions::ActionCableWithLoadBalancing, feature_category: :shared do
+ let(:field) { Types::SubscriptionType.fields.each_value.first }
+ let(:event) { ::GraphQL::Subscriptions::Event.new(name: 'test-event', arguments: {}, field: field) }
+ let(:object) { build(:project, id: 1) }
+ let(:action_cable) { instance_double(::ActionCable::Server::Broadcasting) }
+
+ subject(:subscriptions) { described_class.new(schema: GitlabSchema) }
+
+ include_context 'when tracking WAL location reference'
+
+ before do
+ allow(::ActionCable).to receive(:server).and_return(action_cable)
+ end
+
+ context 'when triggering subscription' do
+ shared_examples_for 'injecting WAL locations' do
+ it 'injects correct WAL location into message' do
+ expect(action_cable).to receive(:broadcast) do |topic, payload|
+ expect(topic).to match(/^graphql-event/)
+ expect(Gitlab::Json.parse(payload)).to match({
+ described_class::KEY_WAL_LOCATIONS => expected_locations,
+ described_class::KEY_PAYLOAD => { '__gid__' => 'Z2lkOi8vZ2l0bGFiL1Byb2plY3QvMQ' }
+ })
+ end
+
+ subscriptions.execute_all(event, object)
+ end
+ end
+
+ context 'when database load balancing is disabled' do
+ let!(:expected_locations) { {} }
+
+ before do
+ stub_load_balancing_disabled!
+ end
+
+ it_behaves_like 'injecting WAL locations'
+ end
+
+ context 'when database load balancing is enabled' do
+ before do
+ stub_load_balancing_enabled!
+ end
+
+ context 'when write was not performed' do
+ before do
+ stub_no_writes_performed!
+ end
+
+ context 'when replica hosts are available' do
+ let!(:expected_locations) { expect_tracked_locations_when_replicas_available.with_indifferent_access }
+
+ it_behaves_like 'injecting WAL locations'
+ end
+
+ context 'when no replica hosts are available' do
+ let!(:expected_locations) { expect_tracked_locations_when_no_replicas_available.with_indifferent_access }
+
+ it_behaves_like 'injecting WAL locations'
+ end
+ end
+
+ context 'when write was performed' do
+ let!(:expected_locations) { expect_tracked_locations_from_primary_only.with_indifferent_access }
+
+ before do
+ stub_write_performed!
+ end
+
+ it_behaves_like 'injecting WAL locations'
+ end
+ end
+ end
+
+ context 'when handling event' do
+ def handle_event!(wal_locations: nil)
+ subscriptions.execute_update('sub:123', event, {
+ described_class::KEY_WAL_LOCATIONS => wal_locations || {
+ 'main' => current_location
+ },
+ described_class::KEY_PAYLOAD => { '__gid__' => 'Z2lkOi8vZ2l0bGFiL1Byb2plY3QvMQ' }
+ })
+ end
+
+ before do
+ allow(action_cable).to receive(:broadcast)
+ end
+
+ context 'when event payload is not wrapped' do
+ it 'does not attempt to unwrap it' do
+ expect(object).not_to receive(:[]).with(described_class::KEY_PAYLOAD)
+
+ subscriptions.execute_update('sub:123', event, object)
+ end
+ end
+
+ context 'when WAL locations are not present' do
+ it 'uses the primary' do
+ expect(::Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary!)
+
+ handle_event!(wal_locations: {})
+ end
+ end
+
+ it 'strips out WAL location information before broadcasting payload' do
+ expect(action_cable).to receive(:broadcast) do |topic, payload|
+ expect(topic).to eq('graphql-subscription:sub:123')
+ expect(payload).to eq({ more: false })
+ end
+
+ handle_event!
+ end
+
+ context 'when database replicas are in sync' do
+ it 'does not use the primary' do
+ stub_replica_available!(true)
+
+ expect(::Gitlab::Database::LoadBalancing::Session.current).not_to receive(:use_primary!)
+
+ handle_event!
+ end
+ end
+
+ context 'when database replicas are not in sync' do
+ it 'uses the primary' do
+ stub_replica_available!(false)
+
+ expect(::Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary!)
+
+ handle_event!
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb b/spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb
index 168f5aa529e..f0312293469 100644
--- a/spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb
+++ b/spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb
@@ -2,7 +2,6 @@
require 'spec_helper'
require 'rspec-parameterized'
-require "support/graphql/fake_query_type"
RSpec.describe Gitlab::Graphql::Tracers::MetricsTracer do
using RSpec::Parameterized::TableSyntax
diff --git a/spec/lib/gitlab/graphql/tracers/timer_tracer_spec.rb b/spec/lib/gitlab/graphql/tracers/timer_tracer_spec.rb
index 986120dcd95..e42883aafd8 100644
--- a/spec/lib/gitlab/graphql/tracers/timer_tracer_spec.rb
+++ b/spec/lib/gitlab/graphql/tracers/timer_tracer_spec.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
require "fast_spec_helper"
-require "support/graphql/fake_tracer"
-require "support/graphql/fake_query_type"
RSpec.describe Gitlab::Graphql::Tracers::TimerTracer do
let(:expected_duration) { 5 }
diff --git a/spec/lib/gitlab/harbor/client_spec.rb b/spec/lib/gitlab/harbor/client_spec.rb
index 4e80b8b53e3..745e22191bd 100644
--- a/spec/lib/gitlab/harbor/client_spec.rb
+++ b/spec/lib/gitlab/harbor/client_spec.rb
@@ -265,18 +265,20 @@ RSpec.describe Gitlab::Harbor::Client do
end
end
- describe '#ping' do
+ describe '#check_project_availability' do
before do
- stub_request(:get, "https://demo.goharbor.io/api/v2.0/ping")
+ stub_request(:head, "https://demo.goharbor.io/api/v2.0/projects?project_name=testproject")
.with(
headers: {
+ 'Accept': 'application/json',
+ 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=',
'Content-Type': 'application/json'
})
- .to_return(status: 200, body: 'pong')
+ .to_return(status: 200, body: '', headers: {})
end
- it "calls api/v2.0/ping successfully" do
- expect(client.ping).to eq(success: true)
+ it "calls api/v2.0/projects successfully" do
+ expect(client.check_project_availability).to eq(success: true)
end
end
diff --git a/spec/lib/gitlab/http_connection_adapter_spec.rb b/spec/lib/gitlab/http_connection_adapter_spec.rb
index dbf0252da46..fac0c1a2a9f 100644
--- a/spec/lib/gitlab/http_connection_adapter_spec.rb
+++ b/spec/lib/gitlab/http_connection_adapter_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::HTTPConnectionAdapter do
+RSpec.describe Gitlab::HTTPConnectionAdapter, feature_category: :shared do
include StubRequests
let(:uri) { URI('https://example.org') }
@@ -111,17 +111,39 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do
end
end
- context 'when http(s) environment variable is set' do
+ context 'when proxy is enabled' do
before do
- stub_env('https_proxy' => 'https://my.proxy')
+ stub_env('http_proxy', 'http://proxy.example.com')
end
- it 'sets up the connection' do
- expect(connection).to be_a(Gitlab::NetHttpAdapter)
- expect(connection.address).to eq('example.org')
- expect(connection.hostname_override).to eq(nil)
- expect(connection.addr_port).to eq('example.org')
- expect(connection.port).to eq(443)
+ it 'proxy stays configured' do
+ expect(connection.proxy?).to be true
+ expect(connection.proxy_from_env?).to be true
+ expect(connection.proxy_address).to eq('proxy.example.com')
+ end
+
+ context 'when no_proxy matches the request' do
+ before do
+ stub_env('no_proxy', 'example.org')
+ end
+
+ it 'proxy is disabled' do
+ expect(connection.proxy?).to be false
+ expect(connection.proxy_from_env?).to be false
+ expect(connection.proxy_address).to be nil
+ end
+ end
+
+ context 'when no_proxy does not match the request' do
+ before do
+ stub_env('no_proxy', 'example.com')
+ end
+
+ it 'proxy stays configured' do
+ expect(connection.proxy?).to be true
+ expect(connection.proxy_from_env?).to be true
+ expect(connection.proxy_address).to eq('proxy.example.com')
+ end
end
end
diff --git a/spec/lib/gitlab/i18n/pluralization_spec.rb b/spec/lib/gitlab/i18n/pluralization_spec.rb
new file mode 100644
index 00000000000..857562d549c
--- /dev/null
+++ b/spec/lib/gitlab/i18n/pluralization_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rspec-parameterized'
+require 'gettext_i18n_rails'
+
+RSpec.describe Gitlab::I18n::Pluralization, feature_category: :internationalization do
+ describe '.call' do
+ subject(:rule) { described_class.call(1) }
+
+ context 'with available locales' do
+ around do |example|
+ Gitlab::I18n.with_locale(locale, &example)
+ end
+
+ where(:locale) do
+ Gitlab::I18n.available_locales
+ end
+
+ with_them do
+ it 'supports pluralization' do
+ expect(rule).not_to be_nil
+ end
+ end
+
+ context 'with missing rules' do
+ let(:locale) { "pl_PL" }
+
+ before do
+ stub_const("#{described_class}::MAP", described_class::MAP.except(locale))
+ end
+
+ it 'raises an ArgumentError' do
+ expect { rule }.to raise_error(ArgumentError,
+ /Missing pluralization rule for locale "#{locale}"/
+ )
+ end
+ end
+ end
+ end
+
+ describe '.install_on' do
+ let(:mod) { Module.new }
+
+ before do
+ described_class.install_on(mod)
+ end
+
+ it 'adds pluralisation_rule method' do
+ expect(mod.pluralisation_rule).to eq(described_class)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/i18n_spec.rb b/spec/lib/gitlab/i18n_spec.rb
index b752d89bf0d..ee92831922d 100644
--- a/spec/lib/gitlab/i18n_spec.rb
+++ b/spec/lib/gitlab/i18n_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::I18n do
+RSpec.describe Gitlab::I18n, feature_category: :internationalization do
let(:user) { create(:user, preferred_language: :es) }
describe '.selectable_locales' do
@@ -47,4 +47,19 @@ RSpec.describe Gitlab::I18n do
expect(::I18n.locale).to eq(:en)
end
end
+
+ describe '.pluralisation_rule' do
+ context 'when overridden' do
+ before do
+ # Internally, FastGettext sets
+ # Thread.current[:fast_gettext_pluralisation_rule].
+ # Our patch patches `FastGettext.pluralisation_rule` instead.
+ FastGettext.pluralisation_rule = :something
+ end
+
+ it 'returns custom definition regardless' do
+ expect(FastGettext.pluralisation_rule).to eq(Gitlab::I18n::Pluralization)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/import/errors_spec.rb b/spec/lib/gitlab/import/errors_spec.rb
new file mode 100644
index 00000000000..f89cb36bbb4
--- /dev/null
+++ b/spec/lib/gitlab/import/errors_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Import::Errors, feature_category: :importers do
+ let_it_be(:project) { create(:project) }
+
+ describe '.merge_nested_errors' do
+ it 'merges nested collection errors' do
+ issue = project.issues.new(
+ title: 'test',
+ notes: [
+ Note.new(
+ award_emoji: [AwardEmoji.new(name: 'test')]
+ )
+ ],
+ sentry_issue: SentryIssue.new
+ )
+
+ issue.validate
+
+ expect(issue.errors.full_messages)
+ .to contain_exactly(
+ "Author can't be blank",
+ "Notes is invalid",
+ "Sentry issue sentry issue identifier can't be blank"
+ )
+
+ described_class.merge_nested_errors(issue)
+
+ expect(issue.errors.full_messages)
+ .to contain_exactly(
+ "Notes is invalid",
+ "Author can't be blank",
+ "Sentry issue sentry issue identifier can't be blank",
+ "Award emoji is invalid",
+ "Note can't be blank",
+ "Project can't be blank",
+ "Noteable can't be blank",
+ "Author can't be blank",
+ "Project does not match noteable project",
+ "User can't be blank",
+ "Awardable can't be blank",
+ "Name is not a valid emoji name"
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import/logger_spec.rb b/spec/lib/gitlab/import/logger_spec.rb
index 60978aaa25c..a85ba84108e 100644
--- a/spec/lib/gitlab/import/logger_spec.rb
+++ b/spec/lib/gitlab/import/logger_spec.rb
@@ -5,35 +5,5 @@ require 'spec_helper'
RSpec.describe Gitlab::Import::Logger do
subject { described_class.new('/dev/null') }
- let(:now) { Time.zone.now }
-
- describe '#format_message' do
- before do
- allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('new-correlation-id')
- end
-
- it 'formats strings' do
- output = subject.format_message('INFO', now, 'test', 'Hello world')
-
- expect(Gitlab::Json.parse(output)).to eq({
- 'severity' => 'INFO',
- 'time' => now.utc.iso8601(3),
- 'message' => 'Hello world',
- 'correlation_id' => 'new-correlation-id',
- 'feature_category' => 'importers'
- })
- end
-
- it 'formats hashes' do
- output = subject.format_message('INFO', now, 'test', { hello: 1 })
-
- expect(Gitlab::Json.parse(output)).to eq({
- 'severity' => 'INFO',
- 'time' => now.utc.iso8601(3),
- 'hello' => 1,
- 'correlation_id' => 'new-correlation-id',
- 'feature_category' => 'importers'
- })
- end
- end
+ it_behaves_like 'a json logger', { 'feature_category' => 'importers' }
end
diff --git a/spec/lib/gitlab/import/metrics_spec.rb b/spec/lib/gitlab/import/metrics_spec.rb
index 9b8b58d00f3..9a7eb7b875e 100644
--- a/spec/lib/gitlab/import/metrics_spec.rb
+++ b/spec/lib/gitlab/import/metrics_spec.rb
@@ -11,7 +11,6 @@ RSpec.describe Gitlab::Import::Metrics, :aggregate_failures do
subject { described_class.new(importer, project) }
before do
- allow(Gitlab::Metrics).to receive(:counter) { counter }
allow(counter).to receive(:increment)
allow(histogram).to receive(:observe)
end
@@ -42,6 +41,13 @@ RSpec.describe Gitlab::Import::Metrics, :aggregate_failures do
context 'when project is not a github import' do
it 'does not emit importer metrics' do
expect(subject).not_to receive(:track_usage_event)
+ expect_no_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'github_import_project_state',
+ project: project,
+ import_type: 'github', state: 'failed'
+ )
subject.track_failed_import
end
@@ -50,39 +56,81 @@ RSpec.describe Gitlab::Import::Metrics, :aggregate_failures do
context 'when project is a github import' do
before do
project.import_type = 'github'
+ allow(project).to receive(:import_status).and_return('failed')
end
it 'emits importer metrics' do
expect(subject).to receive(:track_usage_event).with(:github_import_project_failure, project.id)
subject.track_failed_import
+
+ expect_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'github_import_project_state',
+ project: project,
+ import_type: 'github', state: 'failed'
+ )
end
end
end
describe '#track_finished_import' do
- before do
- allow(Gitlab::Metrics).to receive(:histogram) { histogram }
- end
+ context 'when project is a github import' do
+ before do
+ project.import_type = 'github'
+ allow(Gitlab::Metrics).to receive(:counter) { counter }
+ allow(Gitlab::Metrics).to receive(:histogram) { histogram }
+ allow(project).to receive(:beautified_import_status_name).and_return('completed')
+ end
- it 'emits importer metrics' do
- expect(Gitlab::Metrics).to receive(:counter).with(
- :test_importer_imported_projects_total,
- 'The number of imported projects'
- )
+ it 'emits importer metrics' do
+ expect(Gitlab::Metrics).to receive(:counter).with(
+ :test_importer_imported_projects_total,
+ 'The number of imported projects'
+ )
- expect(Gitlab::Metrics).to receive(:histogram).with(
- :test_importer_total_duration_seconds,
- 'Total time spent importing projects, in seconds',
- {},
- described_class::IMPORT_DURATION_BUCKETS
- )
+ expect(Gitlab::Metrics).to receive(:histogram).with(
+ :test_importer_total_duration_seconds,
+ 'Total time spent importing projects, in seconds',
+ {},
+ described_class::IMPORT_DURATION_BUCKETS
+ )
+
+ expect(counter).to receive(:increment)
- expect(counter).to receive(:increment)
+ subject.track_finished_import
- subject.track_finished_import
+ expect_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'github_import_project_state',
+ project: project,
+ import_type: 'github', state: 'completed'
+ )
+
+ expect(subject.duration).not_to be_nil
+ end
- expect(subject.duration).not_to be_nil
+ context 'when import is partially completed' do
+ before do
+ allow(project).to receive(:beautified_import_status_name).and_return('partially completed')
+ end
+
+ it 'emits snowplow metrics' do
+ expect(subject).to receive(:track_usage_event).with(:github_import_project_partially_completed, project.id)
+
+ subject.track_finished_import
+
+ expect_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'github_import_project_state',
+ project: project,
+ import_type: 'github', state: 'partially completed'
+ )
+ end
+ end
end
context 'when project is not a github import' do
@@ -91,7 +139,51 @@ RSpec.describe Gitlab::Import::Metrics, :aggregate_failures do
subject.track_finished_import
- expect(histogram).to have_received(:observe).with({ importer: :test_importer }, anything)
+ expect_no_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'github_import_project_state',
+ project: project,
+ import_type: 'github', state: 'completed'
+ )
+ end
+ end
+ end
+
+ describe '#track_cancelled_import' do
+ context 'when project is not a github import' do
+ it 'does not emit importer metrics' do
+ expect(subject).not_to receive(:track_usage_event)
+ expect_no_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'github_import_project_state',
+ project: project,
+ import_type: 'github', state: 'canceled'
+ )
+
+ subject.track_canceled_import
+ end
+ end
+
+ context 'when project is a github import' do
+ before do
+ project.import_type = 'github'
+ allow(project).to receive(:import_status).and_return('canceled')
+ end
+
+ it 'emits importer metrics' do
+ expect(subject).to receive(:track_usage_event).with(:github_import_project_cancelled, project.id)
+
+ subject.track_canceled_import
+
+ expect_snowplow_event(
+ category: 'Import::GithubService',
+ action: 'create',
+ label: 'github_import_project_state',
+ project: project,
+ import_type: 'github', state: 'canceled'
+ )
end
end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 0c2c3ffc664..34f9948b9dc 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -14,6 +14,7 @@ issues:
- resource_milestone_events
- resource_state_events
- resource_iteration_events
+- assignment_events
- sent_notifications
- sentry_issue
- issuable_severity
@@ -92,6 +93,25 @@ notes:
- suggestions
- diff_note_positions
- review
+- note_metadata
+note_metadata:
+ - note
+ - email_participant
+commit_notes:
+- award_emoji
+- noteable
+- author
+- updated_by
+- last_edited_by
+- resolved_by
+- todos
+- events
+- system_note_metadata
+- note_diff_file
+- suggestions
+- diff_note_positions
+- review
+- note_metadata
label_links:
- target
- label
@@ -166,6 +186,7 @@ merge_requests:
- resource_milestone_events
- resource_state_events
- resource_iteration_events
+- assignment_events
- label_links
- labels
- last_edited_by
@@ -202,7 +223,7 @@ merge_requests:
- approver_groups
- approved_by_users
- draft_notes
-- merge_train
+- merge_train_car
- blocks_as_blocker
- blocks_as_blockee
- blocking_merge_requests
@@ -246,6 +267,11 @@ ci_pipelines:
- statuses
- statuses_order_id_desc
- latest_statuses_ordered_by_stage
+- latest_statuses
+- all_jobs
+- current_jobs
+- all_processable_jobs
+- current_processable_jobs
- builds
- bridges
- processables
@@ -283,6 +309,7 @@ ci_pipelines:
- job_artifacts
- vulnerabilities_finding_pipelines
- vulnerability_findings
+- vulnerability_state_transitions
- pipeline_config
- security_scans
- security_findings
@@ -293,7 +320,6 @@ ci_pipelines:
- latest_builds_report_results
- messages
- pipeline_artifacts
-- latest_statuses
- dast_profile
- dast_profiles_pipeline
- dast_site_profile
@@ -317,6 +343,7 @@ stages:
- processables
- builds
- bridges
+- generic_commit_statuses
- latest_statuses
- retried_statuses
statuses:
@@ -327,6 +354,92 @@ statuses:
- auto_canceled_by
- needs
- ci_stage
+builds:
+- user
+- auto_canceled_by
+- ci_stage
+- needs
+- resource
+- pipeline
+- sourced_pipeline
+- resource_group
+- metadata
+- runner
+- trigger_request
+- erased_by
+- deployment
+- pending_state
+- queuing_entry
+- runtime_metadata
+- trace_chunks
+- report_results
+- namespace
+- job_artifacts
+- job_variables
+- sourced_pipelines
+- pages_deployments
+- job_artifacts_archive
+- job_artifacts_metadata
+- job_artifacts_trace
+- job_artifacts_junit
+- job_artifacts_sast
+- job_artifacts_dependency_scanning
+- job_artifacts_container_scanning
+- job_artifacts_dast
+- job_artifacts_codequality
+- job_artifacts_license_scanning
+- job_artifacts_performance
+- job_artifacts_metrics
+- job_artifacts_metrics_referee
+- job_artifacts_network_referee
+- job_artifacts_lsif
+- job_artifacts_dotenv
+- job_artifacts_cobertura
+- job_artifacts_terraform
+- job_artifacts_accessibility
+- job_artifacts_cluster_applications
+- job_artifacts_secret_detection
+- job_artifacts_requirements
+- job_artifacts_coverage_fuzzing
+- job_artifacts_browser_performance
+- job_artifacts_load_performance
+- job_artifacts_api_fuzzing
+- job_artifacts_cluster_image_scanning
+- job_artifacts_cyclonedx
+- job_artifacts_requirements_v2
+- runner_manager
+- runner_manager_build
+- runner_session
+- trace_metadata
+- terraform_state_versions
+- taggings
+- base_tags
+- tag_taggings
+- tags
+- security_scans
+- dast_site_profiles_build
+- dast_site_profile
+- dast_scanner_profiles_build
+- dast_scanner_profile
+bridges:
+- user
+- pipeline
+- auto_canceled_by
+- ci_stage
+- needs
+- resource
+- sourced_pipeline
+- resource_group
+- metadata
+- trigger_request
+- downstream_pipeline
+- upstream_pipeline
+generic_commit_statuses:
+- user
+- pipeline
+- auto_canceled_by
+- ci_stage
+- needs
variables:
- project
triggers:
@@ -391,6 +504,7 @@ container_repositories:
- project
- name
project:
+- catalog_resource
- external_status_checks
- base_tags
- project_topics
@@ -399,7 +513,9 @@ project:
- cluster
- clusters
- cluster_agents
+- ci_access_project_authorizations
- cluster_project
+- workspaces
- creator
- cycle_analytics_stages
- value_streams
@@ -408,6 +524,7 @@ project:
- project_namespace
- management_clusters
- boards
+- application_setting
- last_event
- integrations
- push_hooks_integrations
@@ -432,6 +549,7 @@ project:
- discord_integration
- drone_ci_integration
- emails_on_push_integration
+- google_play_integration
- pipelines_email_integration
- mattermost_slash_commands_integration
- shimo_integration
@@ -466,12 +584,14 @@ project:
- external_wiki_integration
- mock_ci_integration
- mock_monitoring_integration
+- squash_tm_integration
- forked_to_members
- forked_from_project
- forks
- merge_requests
- fork_merge_requests
- issues
+- work_items
- labels
- events
- milestones
@@ -527,6 +647,7 @@ project:
- redirect_routes
- statistics
- container_repositories
+- container_registry_data_repair_detail
- uploads
- file_uploads
- import_state
@@ -600,14 +721,15 @@ project:
- project_registry
- packages
- package_files
-- repository_files
+- rpm_repository_files
+- npm_metadata_caches
- packages_cleanup_policy
- alerting_setting
- project_setting
- webide_pipelines
- reviews
- incident_management_setting
-- merge_trains
+- merge_train_cars
- designs
- project_aliases
- external_pull_requests
@@ -618,6 +740,8 @@ project:
- upstream_project_subscriptions
- downstream_project_subscriptions
- service_desk_setting
+- service_desk_custom_email_verification
+- service_desk_custom_email_credential
- security_setting
- import_failures
- container_expiration_policy
@@ -673,6 +797,7 @@ project:
- sbom_occurrences
- analytics_dashboards_configuration_project
- analytics_dashboards_pointer
+- design_management_repository
award_emoji:
- awardable
- user
@@ -759,6 +884,7 @@ incident_management_setting:
- project
merge_trains:
- project
+merge_train_cars:
- merge_request
boards:
- group
@@ -859,6 +985,8 @@ bulk_import_export:
- group
service_desk_setting:
- file_template_project
+service_desk_custom_email_verification:
+ - triggerer
approvals:
- user
- merge_request
@@ -890,3 +1018,22 @@ resource_iteration_events:
iterations_cadence:
- group
- iterations
+catalog_resource:
+ - project
+approval_rules:
+ - users
+ - groups
+ - group_users
+ - security_orchestration_policy_configuration
+ - protected_branches
+ - approval_merge_request_rule_sources
+ - approval_merge_request_rules
+ - approval_project_rules_users
+ - approval_project_rules_protected_branches
+ - scan_result_policy_read
+approval_project_rules_users:
+ - user
+ - approval_project_rule
+approval_project_rules_protected_branches:
+ - protected_branch
+ - approval_project_rule
diff --git a/spec/lib/gitlab/import_export/attribute_configuration_spec.rb b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb
index 572f809e43b..1d84cba3825 100644
--- a/spec/lib/gitlab/import_export/attribute_configuration_spec.rb
+++ b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb
@@ -9,7 +9,7 @@ require 'spec_helper'
# to be included as part of the export, or blacklist them using the import_export.yml configuration file.
# Likewise, new models added to import_export.yml, will need to be added with their correspondent attributes
# to this spec.
-RSpec.describe 'Import/Export attribute configuration' do
+RSpec.describe 'Import/Export attribute configuration', feature_category: :importers do
include ConfigurationHelper
let(:safe_attributes_file) { 'spec/lib/gitlab/import_export/safe_model_attributes.yml' }
diff --git a/spec/lib/gitlab/import_export/attributes_finder_spec.rb b/spec/lib/gitlab/import_export/attributes_finder_spec.rb
index 6536b895b2f..f12cbe4f82f 100644
--- a/spec/lib/gitlab/import_export/attributes_finder_spec.rb
+++ b/spec/lib/gitlab/import_export/attributes_finder_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::ImportExport::AttributesFinder do
+RSpec.describe Gitlab::ImportExport::AttributesFinder, feature_category: :importers do
describe '#find_root' do
subject { described_class.new(config: config).find_root(model_key) }
@@ -177,7 +177,8 @@ RSpec.describe Gitlab::ImportExport::AttributesFinder do
end
def setup_yaml(hash)
- allow(YAML).to receive(:load_file).with(test_config).and_return(hash)
+ allow(YAML).to receive(:safe_load_file)
+ .with(test_config, aliases: true, permitted_classes: [Symbol]).and_return(hash)
end
end
end
@@ -207,6 +208,19 @@ RSpec.describe Gitlab::ImportExport::AttributesFinder do
it { is_expected.to be_nil }
end
+
+ context 'when include_import_only_tree is true' do
+ subject { described_class.new(config: config).find_relations_tree(model_key, include_import_only_tree: true) }
+
+ let(:config) do
+ {
+ tree: { project: { ci_pipelines: { stages: { builds: nil } } } },
+ import_only_tree: { project: { ci_pipelines: { stages: { statuses: nil } } } }
+ }
+ end
+
+ it { is_expected.to eq({ ci_pipelines: { stages: { builds: nil, statuses: nil } } }) }
+ end
end
describe '#find_excluded_keys' do
diff --git a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb
index c748f966463..8089b40cae8 100644
--- a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb
+++ b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::AttributesPermitter do
+RSpec.describe Gitlab::ImportExport::AttributesPermitter, feature_category: :importers do
let(:yml_config) do
<<-EOF
tree:
@@ -12,6 +12,15 @@ RSpec.describe Gitlab::ImportExport::AttributesPermitter do
- milestones:
- events:
- :push_event_payload
+ - ci_pipelines:
+ - stages:
+ - :builds
+
+ import_only_tree:
+ project:
+ - ci_pipelines:
+ - stages:
+ - :statuses
included_attributes:
labels:
@@ -43,12 +52,16 @@ RSpec.describe Gitlab::ImportExport::AttributesPermitter do
it 'builds permitted attributes hash' do
expect(subject.permitted_attributes).to match(
a_hash_including(
- project: [:labels, :milestones],
+ project: [:labels, :milestones, :ci_pipelines],
labels: [:priorities, :title, :description, :type],
events: [:push_event_payload],
milestones: [:events],
priorities: [],
- push_event_payload: []
+ push_event_payload: [],
+ ci_pipelines: [:stages],
+ stages: [:builds, :statuses],
+ statuses: [],
+ builds: []
)
)
end
@@ -129,6 +142,9 @@ RSpec.describe Gitlab::ImportExport::AttributesPermitter do
:external_pull_request | true
:external_pull_requests | true
:statuses | true
+ :builds | true
+ :generic_commit_statuses | true
+ :bridges | true
:ci_pipelines | true
:stages | true
:actions | true
diff --git a/spec/lib/gitlab/import_export/base/relation_object_saver_spec.rb b/spec/lib/gitlab/import_export/base/relation_object_saver_spec.rb
index a8b4b9a6f05..e42a1d0ff8b 100644
--- a/spec/lib/gitlab/import_export/base/relation_object_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/base/relation_object_saver_spec.rb
@@ -82,24 +82,13 @@ RSpec.describe Gitlab::ImportExport::Base::RelationObjectSaver, feature_category
it 'saves valid subrelations and logs invalid subrelation' do
expect(relation_object.notes).to receive(:<<).twice.and_call_original
expect(relation_object).to receive(:save).and_call_original
- expect(Gitlab::Import::Logger)
- .to receive(:info)
- .with(
- message: '[Project/Group Import] Invalid subrelation',
- project_id: project.id,
- relation_key: 'issues',
- error_messages: "Project does not match noteable project"
- )
saver.execute
issue = project.issues.last
- import_failure = project.import_failures.last
expect(invalid_note.persisted?).to eq(false)
expect(issue.notes.count).to eq(5)
- expect(import_failure.source).to eq('RelationObjectSaver#save!')
- expect(import_failure.exception_message).to eq('Project does not match noteable project')
end
context 'when invalid subrelation can still be persisted' do
@@ -111,7 +100,6 @@ RSpec.describe Gitlab::ImportExport::Base::RelationObjectSaver, feature_category
it 'saves the subrelation' do
expect(approval_1.valid?).to eq(false)
- expect(Gitlab::Import::Logger).not_to receive(:info)
saver.execute
@@ -128,24 +116,10 @@ RSpec.describe Gitlab::ImportExport::Base::RelationObjectSaver, feature_category
let(:invalid_priority) { build(:label_priority, priority: -1) }
let(:relation_object) { build(:group_label, group: importable, title: 'test', priorities: valid_priorities + [invalid_priority]) }
- it 'logs invalid subrelation for a group' do
- expect(Gitlab::Import::Logger)
- .to receive(:info)
- .with(
- message: '[Project/Group Import] Invalid subrelation',
- group_id: importable.id,
- relation_key: 'labels',
- error_messages: 'Priority must be greater than or equal to 0'
- )
-
+ it 'saves relation without invalid subrelations' do
saver.execute
- label = importable.labels.last
- import_failure = importable.import_failures.last
-
- expect(label.priorities.count).to eq(5)
- expect(import_failure.source).to eq('RelationObjectSaver#save!')
- expect(import_failure.exception_message).to eq('Priority must be greater than or equal to 0')
+ expect(importable.labels.last.priorities.count).to eq(5)
end
end
end
diff --git a/spec/lib/gitlab/import_export/command_line_util_spec.rb b/spec/lib/gitlab/import_export/command_line_util_spec.rb
index f47f1ab58a8..91cfab1688a 100644
--- a/spec/lib/gitlab/import_export/command_line_util_spec.rb
+++ b/spec/lib/gitlab/import_export/command_line_util_spec.rb
@@ -2,13 +2,14 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::CommandLineUtil do
+RSpec.describe Gitlab::ImportExport::CommandLineUtil, feature_category: :importers do
include ExportFileHelper
let(:path) { "#{Dir.tmpdir}/symlink_test" }
let(:archive) { 'spec/fixtures/symlink_export.tar.gz' }
let(:shared) { Gitlab::ImportExport::Shared.new(nil) }
let(:tmpdir) { Dir.mktmpdir }
+ let(:archive_dir) { Dir.mktmpdir }
subject do
Class.new do
@@ -25,20 +26,38 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do
before do
FileUtils.mkdir_p(path)
- subject.untar_zxf(archive: archive, dir: path)
end
after do
FileUtils.rm_rf(path)
+ FileUtils.rm_rf(archive_dir)
FileUtils.remove_entry(tmpdir)
end
- it 'has the right mask for project.json' do
- expect(file_permissions("#{path}/project.json")).to eq(0755) # originally 777
- end
-
- it 'has the right mask for uploads' do
- expect(file_permissions("#{path}/uploads")).to eq(0755) # originally 555
+ shared_examples 'deletes symlinks' do |compression, decompression|
+ it 'deletes the symlinks', :aggregate_failures do
+ Dir.mkdir("#{tmpdir}/.git")
+ Dir.mkdir("#{tmpdir}/folder")
+ FileUtils.touch("#{tmpdir}/file.txt")
+ FileUtils.touch("#{tmpdir}/folder/file.txt")
+ FileUtils.touch("#{tmpdir}/.gitignore")
+ FileUtils.touch("#{tmpdir}/.git/config")
+ File.symlink('file.txt', "#{tmpdir}/.symlink")
+ File.symlink('file.txt', "#{tmpdir}/.git/.symlink")
+ File.symlink('file.txt', "#{tmpdir}/folder/.symlink")
+ archive = File.join(archive_dir, 'archive')
+ subject.public_send(compression, archive: archive, dir: tmpdir)
+
+ subject.public_send(decompression, archive: archive, dir: archive_dir)
+
+ expect(File.exist?("#{archive_dir}/file.txt")).to eq(true)
+ expect(File.exist?("#{archive_dir}/folder/file.txt")).to eq(true)
+ expect(File.exist?("#{archive_dir}/.gitignore")).to eq(true)
+ expect(File.exist?("#{archive_dir}/.git/config")).to eq(true)
+ expect(File.exist?("#{archive_dir}/.symlink")).to eq(false)
+ expect(File.exist?("#{archive_dir}/.git/.symlink")).to eq(false)
+ expect(File.exist?("#{archive_dir}/folder/.symlink")).to eq(false)
+ end
end
describe '#download_or_copy_upload' do
@@ -228,12 +247,6 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do
end
describe '#tar_cf' do
- let(:archive_dir) { Dir.mktmpdir }
-
- after do
- FileUtils.remove_entry(archive_dir)
- end
-
it 'archives a folder without compression' do
archive_file = File.join(archive_dir, 'archive.tar')
@@ -256,12 +269,24 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do
end
end
- describe '#untar_xf' do
- let(:archive_dir) { Dir.mktmpdir }
+ describe '#untar_zxf' do
+ it_behaves_like 'deletes symlinks', :tar_czf, :untar_zxf
- after do
- FileUtils.remove_entry(archive_dir)
+ it 'has the right mask for project.json' do
+ subject.untar_zxf(archive: archive, dir: path)
+
+ expect(file_permissions("#{path}/project.json")).to eq(0755) # originally 777
+ end
+
+ it 'has the right mask for uploads' do
+ subject.untar_zxf(archive: archive, dir: path)
+
+ expect(file_permissions("#{path}/uploads")).to eq(0755) # originally 555
end
+ end
+
+ describe '#untar_xf' do
+ it_behaves_like 'deletes symlinks', :tar_cf, :untar_xf
it 'extracts archive without decompression' do
filename = 'archive.tar.gz'
diff --git a/spec/lib/gitlab/import_export/config_spec.rb b/spec/lib/gitlab/import_export/config_spec.rb
index 8f848af8bd3..2a52a0a2ff2 100644
--- a/spec/lib/gitlab/import_export/config_spec.rb
+++ b/spec/lib/gitlab/import_export/config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::Config do
+RSpec.describe Gitlab::ImportExport::Config, feature_category: :importers do
let(:yaml_file) { described_class.new }
describe '#to_h' do
@@ -21,7 +21,9 @@ RSpec.describe Gitlab::ImportExport::Config do
end
it 'parses default config' do
- expected_keys = [:tree, :excluded_attributes, :included_attributes, :methods, :preloads, :export_reorders]
+ expected_keys = [
+ :tree, :import_only_tree, :excluded_attributes, :included_attributes, :methods, :preloads, :export_reorders
+ ]
expected_keys << :include_if_exportable if ee
expect { subject }.not_to raise_error
@@ -82,7 +84,7 @@ RSpec.describe Gitlab::ImportExport::Config do
EOF
end
- let(:config_hash) { YAML.safe_load(config, [Symbol]) }
+ let(:config_hash) { YAML.safe_load(config, permitted_classes: [Symbol]) }
before do
allow_any_instance_of(described_class).to receive(:parse_yaml) do
@@ -110,6 +112,7 @@ RSpec.describe Gitlab::ImportExport::Config do
}
}
},
+ import_only_tree: {},
included_attributes: {
user: [:id]
},
@@ -153,6 +156,7 @@ RSpec.describe Gitlab::ImportExport::Config do
}
}
},
+ import_only_tree: {},
included_attributes: {
user: [:id, :name_ee]
},
diff --git a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
index f18d9e64f52..02419267f0e 100644
--- a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
+++ b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::FastHashSerializer, :with_license do
+RSpec.describe Gitlab::ImportExport::FastHashSerializer, :with_license, feature_category: :importers do
# FastHashSerializer#execute generates the hash which is not easily accessible
# and includes `JSONBatchRelation` items which are serialized at this point.
# Wrapping the result into JSON generating/parsing is for making
@@ -125,13 +125,13 @@ RSpec.describe Gitlab::ImportExport::FastHashSerializer, :with_license do
expect(subject.dig('ci_pipelines', 0, 'stages')).not_to be_empty
end
- it 'has pipeline statuses' do
- expect(subject.dig('ci_pipelines', 0, 'stages', 0, 'statuses')).not_to be_empty
+ it 'has pipeline builds' do
+ expect(subject.dig('ci_pipelines', 0, 'stages', 0, 'builds')).not_to be_empty
end
it 'has pipeline builds' do
builds_count = subject
- .dig('ci_pipelines', 0, 'stages', 0, 'statuses')
+ .dig('ci_pipelines', 0, 'stages', 0, 'builds')
.count { |hash| hash['type'] == 'Ci::Build' }
expect(builds_count).to eq(1)
@@ -141,8 +141,8 @@ RSpec.describe Gitlab::ImportExport::FastHashSerializer, :with_license do
expect(subject['ci_pipelines']).not_to be_empty
end
- it 'has ci pipeline notes' do
- expect(subject['ci_pipelines'].first['notes']).not_to be_empty
+ it 'has commit notes' do
+ expect(subject['commit_notes']).not_to be_empty
end
it 'has labels with no associations' do
diff --git a/spec/lib/gitlab/import_export/fork_spec.rb b/spec/lib/gitlab/import_export/fork_spec.rb
deleted file mode 100644
index 9d766eb3af1..00000000000
--- a/spec/lib/gitlab/import_export/fork_spec.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'forked project import' do
- include ProjectForksHelper
-
- let(:user) { create(:user) }
- let!(:project_with_repo) { create(:project, :repository, name: 'test-repo-restorer', path: 'test-repo-restorer') }
- let!(:project) { create(:project, name: 'test-repo-restorer-no-repo', path: 'test-repo-restorer-no-repo') }
- let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
- let(:shared) { project.import_export_shared }
- let(:forked_from_project) { create(:project, :repository) }
- let(:forked_project) { fork_project(project_with_repo, nil, repository: true) }
- let(:repo_saver) { Gitlab::ImportExport::RepoSaver.new(exportable: project_with_repo, shared: shared) }
- let(:bundle_path) { File.join(shared.export_path, Gitlab::ImportExport.project_bundle_filename) }
-
- let(:repo_restorer) do
- Gitlab::ImportExport::RepoRestorer.new(path_to_bundle: bundle_path, shared: shared, importable: project)
- end
-
- let!(:merge_request) do
- create(:merge_request, source_project: forked_project, target_project: project_with_repo)
- end
-
- let(:saver) do
- Gitlab::ImportExport::Project::TreeSaver.new(project: project_with_repo, current_user: user, shared: shared)
- end
-
- let(:restorer) do
- Gitlab::ImportExport::Project::TreeRestorer.new(user: user, shared: shared, project: project)
- end
-
- before do
- stub_feature_flags(project_export_as_ndjson: false)
-
- allow_next_instance_of(Gitlab::ImportExport) do |instance|
- allow(instance).to receive(:storage_path).and_return(export_path)
- end
-
- saver.save # rubocop:disable Rails/SaveBang
- repo_saver.save # rubocop:disable Rails/SaveBang
-
- repo_restorer.restore
- restorer.restore
- end
-
- after do
- FileUtils.rm_rf(export_path)
- project_with_repo.repository.remove
- project.repository.remove
- end
-
- it 'can access the MR', :sidekiq_might_not_need_inline do
- project.merge_requests.first.fetch_ref!
-
- expect(project.repository.ref_exists?('refs/merge-requests/1/head')).to be_truthy
- end
-end
diff --git a/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb
index 5e84284a060..495cefa002a 100644
--- a/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb
@@ -9,25 +9,31 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::Group::RelationTreeRestorer do
+RSpec.describe Gitlab::ImportExport::Group::RelationTreeRestorer, feature_category: :importers do
let(:group) { create(:group).tap { |g| g.add_owner(user) } }
let(:importable) { create(:group, parent: group) }
include_context 'relation tree restorer shared context' do
- let(:importable_name) { nil }
+ let(:importable_name) { 'groups/4353' }
end
- let(:path) { 'spec/fixtures/lib/gitlab/import_export/group_exports/no_children/group.json' }
+ let(:path) { Rails.root.join('spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree') }
let(:relation_reader) do
- Gitlab::ImportExport::Json::LegacyReader::File.new(
- path,
- relation_names: reader.group_relation_names)
+ Gitlab::ImportExport::Json::NdjsonReader.new(path)
end
let(:reader) do
Gitlab::ImportExport::Reader.new(
shared: shared,
- config: Gitlab::ImportExport::Config.new(config: Gitlab::ImportExport.legacy_group_config_file).to_h
+ config: Gitlab::ImportExport::Config.new(config: Gitlab::ImportExport.group_config_file).to_h
+ )
+ end
+
+ let(:members_mapper) do
+ Gitlab::ImportExport::MembersMapper.new(
+ exported_members: relation_reader.consume_relation(importable_name, 'members').map(&:first),
+ user: user,
+ importable: importable
)
end
@@ -41,7 +47,7 @@ RSpec.describe Gitlab::ImportExport::Group::RelationTreeRestorer do
relation_factory: Gitlab::ImportExport::Group::RelationFactory,
reader: reader,
importable: importable,
- importable_path: nil,
+ importable_path: importable_name,
importable_attributes: attributes
)
end
@@ -60,4 +66,74 @@ RSpec.describe Gitlab::ImportExport::Group::RelationTreeRestorer do
subject
end
+
+ describe 'relation object saving' do
+ before do
+ allow(shared.logger).to receive(:info).and_call_original
+ allow(relation_reader).to receive(:consume_relation).and_call_original
+
+ allow(relation_reader)
+ .to receive(:consume_relation)
+ .with(importable_name, 'labels')
+ .and_return([[label, 0]])
+ end
+
+ context 'when relation object is new' do
+ context 'when relation object has invalid subrelations' do
+ let(:label) do
+ {
+ 'title' => 'test',
+ 'priorities' => [LabelPriority.new, LabelPriority.new],
+ 'type' => 'GroupLabel'
+ }
+ end
+
+ it 'logs invalid subrelations' do
+ expect(shared.logger)
+ .to receive(:info)
+ .with(
+ message: '[Project/Group Import] Invalid subrelation',
+ group_id: importable.id,
+ relation_key: 'labels',
+ error_messages: "Project can't be blank, Priority can't be blank, and Priority is not a number"
+ )
+
+ subject
+
+ label = importable.labels.first
+ failure = importable.import_failures.first
+
+ expect(importable.import_failures.count).to eq(2)
+ expect(label.title).to eq('test')
+ expect(failure.exception_class).to eq('ActiveRecord::RecordInvalid')
+ expect(failure.source).to eq('RelationTreeRestorer#save_relation_object')
+ expect(failure.exception_message)
+ .to eq("Project can't be blank, Priority can't be blank, and Priority is not a number")
+ end
+ end
+ end
+
+ context 'when relation object is persisted' do
+ context 'when relation object is invalid' do
+ let(:label) { create(:group_label, group: group, title: 'test') }
+
+ it 'saves import failure with nested errors' do
+ label.priorities << [LabelPriority.new, LabelPriority.new]
+
+ subject
+
+ failure = importable.import_failures.first
+
+ expect(importable.labels.count).to eq(0)
+ expect(importable.import_failures.count).to eq(1)
+ expect(failure.exception_class).to eq('ActiveRecord::RecordInvalid')
+ expect(failure.source).to eq('process_relation_item!')
+ expect(failure.exception_message)
+ .to eq("Validation failed: Priorities is invalid, Project can't be blank, Priority can't be blank, " \
+ "Priority is not a number, Project can't be blank, Priority can't be blank, " \
+ "Priority is not a number")
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/import_export/group/tree_restorer_spec.rb b/spec/lib/gitlab/import_export/group/tree_restorer_spec.rb
index aa30e24296e..a6afd0a36ec 100644
--- a/spec/lib/gitlab/import_export/group/tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/group/tree_restorer_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::Group::TreeRestorer, feature: :subgroups do
+RSpec.describe Gitlab::ImportExport::Group::TreeRestorer, feature: :subgroups, feature_category: :importers do
include ImportExport::CommonUtil
shared_examples 'group restoration' do
@@ -171,7 +171,7 @@ RSpec.describe Gitlab::ImportExport::Group::TreeRestorer, feature: :subgroups do
allow(shared).to receive(:export_path).and_return(tmpdir)
expect(group_tree_restorer.restore).to eq(false)
- expect(shared.errors).to include('Incorrect JSON format')
+ expect(shared.errors).to include('Invalid file')
end
end
end
diff --git a/spec/lib/gitlab/import_export/import_export_equivalence_spec.rb b/spec/lib/gitlab/import_export/import_export_equivalence_spec.rb
deleted file mode 100644
index 6c997dc1361..00000000000
--- a/spec/lib/gitlab/import_export/import_export_equivalence_spec.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-# Verifies that given an exported project meta-data tree, when importing this
-# tree and then exporting it again, we should obtain the initial tree.
-#
-# This equivalence only works up to a certain extent, for instance we need
-# to ignore:
-#
-# - row IDs and foreign key IDs
-# - some timestamps
-# - randomly generated fields like tokens
-#
-# as these are expected to change between import/export cycles.
-RSpec.describe Gitlab::ImportExport, feature_category: :importers do
- include ImportExport::CommonUtil
- include ConfigurationHelper
- include ImportExport::ProjectTreeExpectations
-
- let(:json_fixture) { 'complex' }
-
- before do
- stub_feature_flags(project_export_as_ndjson: false)
- end
-
- it 'yields the initial tree when importing and exporting it again' do
- project = create(:project)
- user = create(:user, :admin)
-
- # We first generate a test fixture dynamically from a seed-fixture, so as to
- # account for any fields in the initial fixture that are missing and set to
- # defaults during import (ideally we should have realistic test fixtures
- # that "honestly" represent exports)
- expect(
- restore_then_save_project(
- project,
- user,
- import_path: seed_fixture_path,
- export_path: test_fixture_path)
- ).to be true
- # Import, then export again from the generated fixture. Any residual changes
- # in the JSON will count towards comparison i.e. test failures.
- expect(
- restore_then_save_project(
- project,
- user,
- import_path: test_fixture_path,
- export_path: test_tmp_path)
- ).to be true
-
- imported_json = Gitlab::Json.parse(File.read("#{test_fixture_path}/project.json"))
- exported_json = Gitlab::Json.parse(File.read("#{test_tmp_path}/project.json"))
-
- assert_relations_match(imported_json, exported_json)
- end
-
- private
-
- def seed_fixture_path
- "#{fixtures_path}/#{json_fixture}"
- end
-
- def test_fixture_path
- "#{test_tmp_path}/#{json_fixture}"
- end
-end
diff --git a/spec/lib/gitlab/import_export/import_failure_service_spec.rb b/spec/lib/gitlab/import_export/import_failure_service_spec.rb
index 51f1fc9c6a2..30d16347828 100644
--- a/spec/lib/gitlab/import_export/import_failure_service_spec.rb
+++ b/spec/lib/gitlab/import_export/import_failure_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::ImportFailureService do
+RSpec.describe Gitlab::ImportExport::ImportFailureService, feature_category: :importers do
let(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') }
let(:label) { create(:label) }
let(:subject) { described_class.new(importable) }
diff --git a/spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb b/spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb
deleted file mode 100644
index 793b3ebfb9e..00000000000
--- a/spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_relative 'shared_example'
-
-RSpec.describe Gitlab::ImportExport::Json::LegacyReader::File do
- it_behaves_like 'import/export json legacy reader' do
- let(:valid_path) { 'spec/fixtures/lib/gitlab/import_export/light/project.json' }
- let(:data) { valid_path }
- let(:json_data) { Gitlab::Json.parse(File.read(valid_path)) }
- end
-
- describe '#exist?' do
- let(:legacy_reader) do
- described_class.new(path, relation_names: [])
- end
-
- subject { legacy_reader.exist? }
-
- context 'given valid path' do
- let(:path) { 'spec/fixtures/lib/gitlab/import_export/light/project.json' }
-
- it { is_expected.to be true }
- end
-
- context 'given invalid path' do
- let(:path) { 'spec/non-existing-folder/do-not-create-this-file.json' }
-
- it { is_expected.to be false }
- end
- end
-end
diff --git a/spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb b/spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb
deleted file mode 100644
index 57d66dc0f50..00000000000
--- a/spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_relative 'shared_example'
-
-RSpec.describe Gitlab::ImportExport::Json::LegacyReader::Hash do
- it_behaves_like 'import/export json legacy reader' do
- let(:path) { 'spec/fixtures/lib/gitlab/import_export/light/project.json' }
-
- # the hash is modified by the `LegacyReader`
- # we need to deep-dup it
- let(:json_data) { Gitlab::Json.parse(File.read(path)) }
- let(:data) { Gitlab::Json.parse(File.read(path)) }
- end
-
- describe '#exist?' do
- let(:legacy_reader) do
- described_class.new(tree_hash, relation_names: [])
- end
-
- subject { legacy_reader.exist? }
-
- context 'tree_hash is nil' do
- let(:tree_hash) { nil }
-
- it { is_expected.to be_falsey }
- end
-
- context 'tree_hash presents' do
- let(:tree_hash) { { "issues": [] } }
-
- it { is_expected.to be_truthy }
- end
- end
-end
diff --git a/spec/lib/gitlab/import_export/json/legacy_reader/shared_example.rb b/spec/lib/gitlab/import_export/json/legacy_reader/shared_example.rb
deleted file mode 100644
index 3e9bd3fe741..00000000000
--- a/spec/lib/gitlab/import_export/json/legacy_reader/shared_example.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'import/export json legacy reader' do
- let(:relation_names) { [] }
-
- let(:legacy_reader) do
- described_class.new(
- data,
- relation_names: relation_names,
- allowed_path: "project")
- end
-
- describe '#consume_attributes' do
- context 'when valid path is passed' do
- subject { legacy_reader.consume_attributes("project") }
-
- context 'no excluded attributes' do
- let(:relation_names) { [] }
-
- it 'returns the whole tree from parsed JSON' do
- expect(subject).to eq(json_data)
- end
- end
-
- context 'some attributes are excluded' do
- let(:relation_names) { %w[milestones labels] }
-
- it 'returns hash without excluded attributes and relations' do
- expect(subject).not_to include('milestones', 'labels')
- end
- end
- end
-
- context 'when invalid path is passed' do
- it 'raises an exception' do
- expect { legacy_reader.consume_attributes("invalid-path") }
- .to raise_error(ArgumentError)
- end
- end
- end
-
- describe '#consume_relation' do
- context 'when valid path is passed' do
- let(:key) { 'labels' }
-
- subject { legacy_reader.consume_relation("project", key) }
-
- context 'key has not been consumed' do
- it 'returns an Enumerator' do
- expect(subject).to be_an_instance_of(Enumerator)
- end
-
- context 'value is nil' do
- before do
- expect(legacy_reader).to receive(:relations).and_return({ key => nil })
- end
-
- it 'yields nothing to the Enumerator' do
- expect(subject.to_a).to eq([])
- end
- end
-
- context 'value is an array' do
- before do
- expect(legacy_reader).to receive(:relations).and_return({ key => %w[label1 label2] })
- end
-
- it 'yields every relation value to the Enumerator' do
- expect(subject.to_a).to eq([['label1', 0], ['label2', 1]])
- end
- end
-
- context 'value is not array' do
- before do
- expect(legacy_reader).to receive(:relations).and_return({ key => 'non-array value' })
- end
-
- it 'yields the value with index 0 to the Enumerator' do
- expect(subject.to_a).to eq([['non-array value', 0]])
- end
- end
- end
-
- context 'key has been consumed' do
- before do
- legacy_reader.consume_relation("project", key).first
- end
-
- it 'yields nothing to the Enumerator' do
- expect(subject.to_a).to eq([])
- end
- end
- end
-
- context 'when invalid path is passed' do
- it 'raises an exception' do
- expect { legacy_reader.consume_relation("invalid") }
- .to raise_error(ArgumentError)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/import_export/json/legacy_writer_spec.rb b/spec/lib/gitlab/import_export/json/legacy_writer_spec.rb
deleted file mode 100644
index e8ecd98b1e1..00000000000
--- a/spec/lib/gitlab/import_export/json/legacy_writer_spec.rb
+++ /dev/null
@@ -1,101 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-
-RSpec.describe Gitlab::ImportExport::Json::LegacyWriter do
- let(:path) { "#{Dir.tmpdir}/legacy_writer_spec/test.json" }
-
- subject do
- described_class.new(path, allowed_path: "project")
- end
-
- after do
- FileUtils.rm_rf(path)
- end
-
- describe "#write_attributes" do
- it "writes correct json" do
- expected_hash = { "key" => "value_1", "key_1" => "value_2" }
- subject.write_attributes("project", expected_hash)
-
- expect(subject_json).to eq(expected_hash)
- end
-
- context 'when invalid path is used' do
- it 'raises an exception' do
- expect { subject.write_attributes("invalid", { "key" => "value" }) }
- .to raise_error(ArgumentError)
- end
- end
- end
-
- describe "#write_relation" do
- context "when key is already written" do
- it "raises exception" do
- subject.write_relation("project", "key", "old value")
-
- expect { subject.write_relation("project", "key", "new value") }
- .to raise_exception("key 'key' already written")
- end
- end
-
- context "when key is not already written" do
- context "when multiple key value pairs are stored" do
- it "writes correct json" do
- expected_hash = { "key" => "value_1", "key_1" => "value_2" }
- expected_hash.each do |key, value|
- subject.write_relation("project", key, value)
- end
-
- expect(subject_json).to eq(expected_hash)
- end
- end
- end
-
- context 'when invalid path is used' do
- it 'raises an exception' do
- expect { subject.write_relation("invalid", "key", "value") }
- .to raise_error(ArgumentError)
- end
- end
- end
-
- describe "#write_relation_array" do
- context 'when array is used' do
- it 'writes correct json' do
- subject.write_relation_array("project", "key", ["value"])
-
- expect(subject_json).to eq({ "key" => ["value"] })
- end
- end
-
- context 'when enumerable is used' do
- it 'writes correct json' do
- values = %w(value1 value2)
-
- enumerator = Enumerator.new do |items|
- values.each { |value| items << value }
- end
-
- subject.write_relation_array("project", "key", enumerator)
-
- expect(subject_json).to eq({ "key" => values })
- end
- end
-
- context "when key is already written" do
- it "raises an exception" do
- subject.write_relation_array("project", "key", %w(old_value))
-
- expect { subject.write_relation_array("project", "key", %w(new_value)) }
- .to raise_error(ArgumentError)
- end
- end
- end
-
- def subject_json
- subject.close
-
- ::JSON.parse(File.read(subject.path))
- end
-end
diff --git a/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb b/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb
index 0ca4c4ccc87..98afe01c08b 100644
--- a/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb
+++ b/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::Json::NdjsonReader do
+RSpec.describe Gitlab::ImportExport::Json::NdjsonReader, feature_category: :importers do
include ImportExport::CommonUtil
let(:fixture) { 'spec/fixtures/lib/gitlab/import_export/light/tree' }
@@ -26,14 +26,6 @@ RSpec.describe Gitlab::ImportExport::Json::NdjsonReader do
end
end
- describe '#legacy?' do
- let(:dir_path) { fixture }
-
- subject { ndjson_reader.legacy? }
-
- it { is_expected.to be false }
- end
-
describe '#consume_attributes' do
let(:dir_path) { fixture }
@@ -42,6 +34,20 @@ RSpec.describe Gitlab::ImportExport::Json::NdjsonReader do
it 'returns the whole root tree from parsed JSON' do
expect(subject).to eq(root_tree)
end
+
+ context 'when project.json is symlink' do
+ it 'raises error an error' do
+ Dir.mktmpdir do |tmpdir|
+ FileUtils.touch(File.join(tmpdir, 'passwd'))
+ File.symlink(File.join(tmpdir, 'passwd'), File.join(tmpdir, 'project.json'))
+
+ ndjson_reader = described_class.new(tmpdir)
+
+ expect { ndjson_reader.consume_attributes(importable_path) }
+ .to raise_error(Gitlab::ImportExport::Error, 'Invalid file')
+ end
+ end
+ end
end
describe '#consume_relation' do
@@ -91,6 +97,22 @@ RSpec.describe Gitlab::ImportExport::Json::NdjsonReader do
end
end
+ context 'when relation file is a symlink' do
+ it 'yields nothing to the Enumerator' do
+ Dir.mktmpdir do |tmpdir|
+ Dir.mkdir(File.join(tmpdir, 'project'))
+ File.write(File.join(tmpdir, 'passwd'), "{}\n{}")
+ File.symlink(File.join(tmpdir, 'passwd'), File.join(tmpdir, 'project', 'issues.ndjson'))
+
+ ndjson_reader = described_class.new(tmpdir)
+
+ result = ndjson_reader.consume_relation(importable_path, 'issues')
+
+ expect(result.to_a).to eq([])
+ end
+ end
+ end
+
context 'relation file is empty' do
let(:key) { 'empty' }
diff --git a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb
index 103d3512e8b..f4c9189030b 100644
--- a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb
+++ b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer, feature_category
let(:exportable_path) { 'project' }
let(:logger) { Gitlab::Export::Logger.build }
- let(:json_writer) { instance_double('Gitlab::ImportExport::Json::LegacyWriter') }
+ let(:json_writer) { instance_double('Gitlab::ImportExport::Json::NdjsonWriter') }
let(:hash) { { name: exportable.name, description: exportable.description }.stringify_keys }
let(:include) { [] }
let(:custom_orderer) { nil }
diff --git a/spec/lib/gitlab/import_export/model_configuration_spec.rb b/spec/lib/gitlab/import_export/model_configuration_spec.rb
index 4f01f470ce7..8e5fe96f3b4 100644
--- a/spec/lib/gitlab/import_export/model_configuration_spec.rb
+++ b/spec/lib/gitlab/import_export/model_configuration_spec.rb
@@ -5,11 +5,11 @@ require 'spec_helper'
# Part of the test security suite for the Import/Export feature
# Finds if a new model has been added that can potentially be part of the Import/Export
# If it finds a new model, it will show a +failure_message+ with the options available.
-RSpec.describe 'Import/Export model configuration' do
+RSpec.describe 'Import/Export model configuration', feature_category: :importers do
include ConfigurationHelper
let(:all_models_yml) { 'spec/lib/gitlab/import_export/all_models.yml' }
- let(:all_models_hash) { YAML.load_file(all_models_yml) }
+ let(:all_models_hash) { YAML.safe_load_file(all_models_yml, aliases: true) }
let(:current_models) { setup_models }
let(:model_names) { relation_names_for(:project) }
diff --git a/spec/lib/gitlab/import_export/project/export_task_spec.rb b/spec/lib/gitlab/import_export/project/export_task_spec.rb
index 3dd1e9257cc..95971d08175 100644
--- a/spec/lib/gitlab/import_export/project/export_task_spec.rb
+++ b/spec/lib/gitlab/import_export/project/export_task_spec.rb
@@ -10,14 +10,14 @@ RSpec.describe Gitlab::ImportExport::Project::ExportTask, :silence_stdout do
let(:measurement_enabled) { false }
let(:file_path) { 'spec/fixtures/gitlab/import_export/test_project_export.tar.gz' }
let(:project) { create(:project, creator: user, namespace: user.namespace) }
- let(:project_name) { project.name }
+ let(:project_path) { project.path }
let(:rake_task) { described_class.new(task_params) }
let(:task_params) do
{
username: username,
namespace_path: namespace_path,
- project_path: project_name,
+ project_path: project_path,
file_path: file_path,
measurement_enabled: measurement_enabled
}
@@ -48,10 +48,10 @@ RSpec.describe Gitlab::ImportExport::Project::ExportTask, :silence_stdout do
end
context 'when project is not found' do
- let(:project_name) { 'invalid project name' }
+ let(:project_path) { 'invalid project path' }
it 'logs an error' do
- expect { subject }.to output(/Project with path: #{project_name} was not found. Please provide correct project path/).to_stdout
+ expect { subject }.to output(/Project with path: #{project_path} was not found. Please provide correct project path/).to_stdout
end
it 'returns false' do
diff --git a/spec/lib/gitlab/import_export/project/exported_relations_merger_spec.rb b/spec/lib/gitlab/import_export/project/exported_relations_merger_spec.rb
index d70e89c6856..f8018e75879 100644
--- a/spec/lib/gitlab/import_export/project/exported_relations_merger_spec.rb
+++ b/spec/lib/gitlab/import_export/project/exported_relations_merger_spec.rb
@@ -64,8 +64,8 @@ RSpec.describe Gitlab::ImportExport::Project::ExportedRelationsMerger do
expect(result).to eq(false)
expect(shared.errors).to match_array(
[
- "undefined method `export_file' for nil:NilClass",
- "undefined method `export_file' for nil:NilClass"
+ /^undefined method `export_file' for nil:NilClass/,
+ /^undefined method `export_file' for nil:NilClass/
]
)
end
diff --git a/spec/lib/gitlab/import_export/project/import_task_spec.rb b/spec/lib/gitlab/import_export/project/import_task_spec.rb
index c847224cb9b..693f1984ce8 100644
--- a/spec/lib/gitlab/import_export/project/import_task_spec.rb
+++ b/spec/lib/gitlab/import_export/project/import_task_spec.rb
@@ -2,7 +2,7 @@
require 'rake_helper'
-RSpec.describe Gitlab::ImportExport::Project::ImportTask, :request_store, :silence_stdout do
+RSpec.describe Gitlab::ImportExport::Project::ImportTask, :request_store, :silence_stdout, feature_category: :importers do
let(:username) { 'root' }
let(:namespace_path) { username }
let!(:user) { create(:user, username: username) }
diff --git a/spec/lib/gitlab/import_export/project/object_builder_spec.rb b/spec/lib/gitlab/import_export/project/object_builder_spec.rb
index 189b798c2e8..5fa8590e8fd 100644
--- a/spec/lib/gitlab/import_export/project/object_builder_spec.rb
+++ b/spec/lib/gitlab/import_export/project/object_builder_spec.rb
@@ -86,13 +86,16 @@ RSpec.describe Gitlab::ImportExport::Project::ObjectBuilder do
'group' => group)).to eq(group_label)
end
- it 'creates a new label' do
+ it 'creates a new project label' do
label = described_class.build(Label,
'title' => 'group label',
'project' => project,
- 'group' => project.group)
+ 'group' => project.group,
+ 'group_id' => project.group.id)
expect(label.persisted?).to be true
+ expect(label).to be_an_instance_of(ProjectLabel)
+ expect(label.group_id).to be_nil
end
end
diff --git a/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb
index 6053df8ba97..180a6b6ff0a 100644
--- a/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb
@@ -50,58 +50,24 @@ RSpec.describe Gitlab::ImportExport::Project::RelationTreeRestorer, feature_cate
expect(project.custom_attributes.count).to eq(2)
expect(project.project_badges.count).to eq(2)
expect(project.snippets.count).to eq(1)
+ expect(project.commit_notes.count).to eq(3)
end
end
end
- context 'with legacy reader' do
- let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/project.json' }
- let(:relation_reader) do
- Gitlab::ImportExport::Json::LegacyReader::File.new(
- path,
- relation_names: reader.project_relation_names,
- allowed_path: 'project'
- )
- end
-
- let(:attributes) { relation_reader.consume_attributes('project') }
-
- it_behaves_like 'import project successfully'
-
- context 'with logging of relations creation' do
- let_it_be(:group) { create(:group).tap { |g| g.add_maintainer(user) } }
- let_it_be(:importable) do
- create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project', group: group)
- end
-
- it 'logs top-level relation creation' do
- expect(shared.logger)
- .to receive(:info)
- .with(hash_including(message: '[Project/Group Import] Created new object relation'))
- .at_least(:once)
-
- subject
- end
- end
- end
-
- context 'with ndjson reader' do
+ context 'when inside a group' do
let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/tree' }
let(:relation_reader) { Gitlab::ImportExport::Json::NdjsonReader.new(path) }
- it_behaves_like 'import project successfully'
-
- context 'when inside a group' do
- let_it_be(:group) do
- create(:group, :disabled_and_unoverridable).tap { |g| g.add_maintainer(user) }
- end
-
- before do
- importable.update!(shared_runners_enabled: false, group: group)
- end
+ let_it_be(:group) do
+ create(:group, :disabled_and_unoverridable).tap { |g| g.add_maintainer(user) }
+ end
- it_behaves_like 'import project successfully'
+ before do
+ importable.update!(shared_runners_enabled: false, group: group)
end
+
+ it_behaves_like 'import project successfully'
end
context 'with invalid relations' do
diff --git a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
index 125d1736b9b..5aa16f9508d 100644
--- a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
let(:shared) { project.import_export_shared }
- RSpec.shared_examples 'project tree restorer work properly' do |reader, ndjson_enabled|
+ RSpec.shared_examples 'project tree restorer work properly' do
describe 'restore project tree' do
before_all do
# Using an admin for import, so we can check assignment of existing members
@@ -27,10 +27,9 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
@shared = @project.import_export_shared
stub_all_feature_flags
- stub_feature_flags(project_import_ndjson: ndjson_enabled)
setup_import_export_config('complex')
- setup_reader(reader)
+ setup_reader
allow_any_instance_of(Repository).to receive(:fetch_source_branch!).and_return(true)
allow_any_instance_of(Gitlab::Git::Repository).to receive(:branch_exists?).and_return(false)
@@ -295,6 +294,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
it 'has project labels' do
expect(ProjectLabel.count).to eq(3)
+ expect(ProjectLabel.pluck(:group_id).compact).to be_empty
end
it 'has merge request approvals' do
@@ -528,7 +528,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
it 'has the correct number of pipelines and statuses' do
expect(@project.ci_pipelines.size).to eq(7)
- @project.ci_pipelines.order(:id).zip([2, 0, 2, 2, 2, 2, 0])
+ @project.ci_pipelines.order(:id).zip([2, 0, 2, 3, 2, 2, 0])
.each do |(pipeline, expected_status_size)|
expect(pipeline.statuses.size).to eq(expected_status_size)
end
@@ -548,8 +548,16 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
expect(Ci::Stage.all).to all(have_attributes(pipeline_id: a_value > 0))
end
- it 'restores statuses' do
- expect(CommitStatus.all.count).to be 10
+ it 'restores builds' do
+ expect(Ci::Build.all.count).to be 7
+ end
+
+ it 'restores bridges' do
+ expect(Ci::Bridge.all.count).to be 1
+ end
+
+ it 'restores generic commit statuses' do
+ expect(GenericCommitStatus.all.count).to be 1
end
it 'correctly restores association between a stage and a job' do
@@ -574,6 +582,10 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
expect(@project.import_failures.size).to eq 0
end
end
+
+ it 'restores commit notes' do
+ expect(@project.commit_notes.count).to eq(3)
+ end
end
end
@@ -593,23 +605,15 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
end
end
- context 'project.json file access check' do
+ context 'when expect tree structure is not present in the export path' do
let(:user) { create(:user) }
- let!(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') }
- let(:project_tree_restorer) do
- described_class.new(user: user, shared: shared, project: project)
- end
-
- let(:restored_project_json) { project_tree_restorer.restore }
+ let_it_be(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') }
- it 'does not read a symlink' do
- Dir.mktmpdir do |tmpdir|
- setup_symlink(tmpdir, 'project.json')
- allow(shared).to receive(:export_path).and_call_original
+ it 'fails to restore the project' do
+ result = described_class.new(user: user, shared: shared, project: project).restore
- expect(project_tree_restorer.restore).to eq(false)
- expect(shared.errors).to include('invalid import format')
- end
+ expect(result).to eq(false)
+ expect(shared.errors).to include('invalid import format')
end
end
@@ -622,7 +626,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
context 'with a simple project' do
before do
setup_import_export_config('light')
- setup_reader(reader)
+ setup_reader
expect(restored_project_json).to eq(true)
end
@@ -657,7 +661,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
context 'multiple pipelines reference the same external pull request' do
before do
setup_import_export_config('multi_pipeline_ref_one_external_pr')
- setup_reader(reader)
+ setup_reader
expect(restored_project_json).to eq(true)
end
@@ -685,7 +689,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
before do
setup_import_export_config('light')
- setup_reader(reader)
+ setup_reader
expect(project).to receive(:merge_requests).and_call_original
expect(project).to receive(:merge_requests).and_raise(exception)
@@ -702,7 +706,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
before do
setup_import_export_config('light')
- setup_reader(reader)
+ setup_reader
expect(project).to receive(:merge_requests).and_call_original
expect(project).to receive(:merge_requests).and_raise(exception)
@@ -734,7 +738,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
context 'when the project has overridden params in import data' do
before do
setup_import_export_config('light')
- setup_reader(reader)
+ setup_reader
end
it 'handles string versions of visibility_level' do
@@ -800,7 +804,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
before do
setup_import_export_config('group')
- setup_reader(reader)
+ setup_reader
expect(restored_project_json).to eq(true)
end
@@ -836,7 +840,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
before do
setup_import_export_config('light')
- setup_reader(reader)
+ setup_reader
end
it 'imports labels' do
@@ -872,7 +876,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
before do
setup_import_export_config('milestone-iid')
- setup_reader(reader)
+ setup_reader
end
it 'preserves the project milestone IID' do
@@ -888,7 +892,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
context 'with external authorization classification labels' do
before do
setup_import_export_config('light')
- setup_reader(reader)
+ setup_reader
end
it 'converts empty external classification authorization labels to nil' do
@@ -915,76 +919,80 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
described_class.new(user: user, shared: shared, project: project)
end
- before do
- allow_any_instance_of(Gitlab::ImportExport::Json::LegacyReader::File).to receive(:exist?).and_return(true)
- allow_any_instance_of(Gitlab::ImportExport::Json::NdjsonReader).to receive(:exist?).and_return(false)
- allow_any_instance_of(Gitlab::ImportExport::Json::LegacyReader::File).to receive(:tree_hash) { tree_hash }
- end
-
- context 'no group visibility' do
- let(:visibility) { Gitlab::VisibilityLevel::PRIVATE }
+ describe 'visibility level' do
+ before do
+ setup_import_export_config('light')
- it 'uses the project visibility' do
- expect(restorer.restore).to eq(true)
- expect(restorer.project.visibility_level).to eq(visibility)
+ allow_next_instance_of(Gitlab::ImportExport::Json::NdjsonReader) do |relation_reader|
+ allow(relation_reader).to receive(:consume_attributes).and_return(tree_hash)
+ end
end
- end
- context 'with restricted internal visibility' do
- describe 'internal project' do
- let(:visibility) { Gitlab::VisibilityLevel::INTERNAL }
-
- it 'uses private visibility' do
- stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
+ context 'no group visibility' do
+ let(:visibility) { Gitlab::VisibilityLevel::PRIVATE }
+ it 'uses the project visibility' do
expect(restorer.restore).to eq(true)
- expect(restorer.project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
+ expect(restorer.project.visibility_level).to eq(visibility)
end
end
- end
- context 'with group visibility' do
- before do
- group = create(:group, visibility_level: group_visibility)
- group.add_members([user], GroupMember::MAINTAINER)
- project.update!(group: group)
- end
+ context 'with restricted internal visibility' do
+ describe 'internal project' do
+ let(:visibility) { Gitlab::VisibilityLevel::INTERNAL }
- context 'private group visibility' do
- let(:group_visibility) { Gitlab::VisibilityLevel::PRIVATE }
- let(:visibility) { Gitlab::VisibilityLevel::PUBLIC }
+ it 'uses private visibility' do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
- it 'uses the group visibility' do
- expect(restorer.restore).to eq(true)
- expect(restorer.project.visibility_level).to eq(group_visibility)
+ expect(restorer.restore).to eq(true)
+ expect(restorer.project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
+ end
end
end
- context 'public group visibility' do
- let(:group_visibility) { Gitlab::VisibilityLevel::PUBLIC }
- let(:visibility) { Gitlab::VisibilityLevel::PRIVATE }
+ context 'with group visibility' do
+ before do
+ group = create(:group, visibility_level: group_visibility)
+ group.add_members([user], GroupMember::MAINTAINER)
+ project.update!(group: group)
+ end
- it 'uses the project visibility' do
- expect(restorer.restore).to eq(true)
- expect(restorer.project.visibility_level).to eq(visibility)
+ context 'private group visibility' do
+ let(:group_visibility) { Gitlab::VisibilityLevel::PRIVATE }
+ let(:visibility) { Gitlab::VisibilityLevel::PUBLIC }
+
+ it 'uses the group visibility' do
+ expect(restorer.restore).to eq(true)
+ expect(restorer.project.visibility_level).to eq(group_visibility)
+ end
end
- end
- context 'internal group visibility' do
- let(:group_visibility) { Gitlab::VisibilityLevel::INTERNAL }
- let(:visibility) { Gitlab::VisibilityLevel::PUBLIC }
+ context 'public group visibility' do
+ let(:group_visibility) { Gitlab::VisibilityLevel::PUBLIC }
+ let(:visibility) { Gitlab::VisibilityLevel::PRIVATE }
- it 'uses the group visibility' do
- expect(restorer.restore).to eq(true)
- expect(restorer.project.visibility_level).to eq(group_visibility)
+ it 'uses the project visibility' do
+ expect(restorer.restore).to eq(true)
+ expect(restorer.project.visibility_level).to eq(visibility)
+ end
end
- context 'with restricted internal visibility' do
- it 'sets private visibility' do
- stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
+ context 'internal group visibility' do
+ let(:group_visibility) { Gitlab::VisibilityLevel::INTERNAL }
+ let(:visibility) { Gitlab::VisibilityLevel::PUBLIC }
+ it 'uses the group visibility' do
expect(restorer.restore).to eq(true)
- expect(restorer.project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
+ expect(restorer.project.visibility_level).to eq(group_visibility)
+ end
+
+ context 'with restricted internal visibility' do
+ it 'sets private visibility' do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
+
+ expect(restorer.restore).to eq(true)
+ expect(restorer.project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
+ end
end
end
end
@@ -995,24 +1003,35 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
let(:user2) { create(:user) }
let(:project_members) do
[
- {
- "id" => 2,
- "access_level" => 40,
- "source_type" => "Project",
- "notification_level" => 3,
- "user" => {
- "id" => user2.id,
- "email" => user2.email,
- "username" => 'test'
- }
- }
+ [
+ {
+ "id" => 2,
+ "access_level" => 40,
+ "source_type" => "Project",
+ "notification_level" => 3,
+ "user" => {
+ "id" => user2.id,
+ "email" => user2.email,
+ "username" => 'test'
+ }
+ },
+ 0
+ ]
]
end
- let(:tree_hash) { { 'project_members' => project_members } }
-
before do
project.add_maintainer(user)
+
+ setup_import_export_config('light')
+
+ allow_next_instance_of(Gitlab::ImportExport::Json::NdjsonReader) do |relation_reader|
+ allow(relation_reader).to receive(:consume_relation).and_call_original
+
+ allow(relation_reader).to receive(:consume_relation)
+ .with('project', 'project_members')
+ .and_return(project_members)
+ end
end
it 'restores project members' do
@@ -1032,7 +1051,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
before do
setup_import_export_config('with_invalid_records')
- setup_reader(reader)
+ setup_reader
subject
end
@@ -1125,13 +1144,5 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer, feature_category: :i
end
end
- context 'enable ndjson import' do
- it_behaves_like 'project tree restorer work properly', :legacy_reader, true
-
- it_behaves_like 'project tree restorer work properly', :ndjson_reader, true
- end
-
- context 'disable ndjson import' do
- it_behaves_like 'project tree restorer work properly', :legacy_reader, false
- end
+ it_behaves_like 'project tree restorer work properly'
end
diff --git a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
index 74b6e039601..4166eba4e8e 100644
--- a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
@@ -2,35 +2,28 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::Project::TreeSaver, :with_license do
+RSpec.describe Gitlab::ImportExport::Project::TreeSaver, :with_license, feature_category: :importers do
let_it_be(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
let_it_be(:exportable_path) { 'project' }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { setup_project }
- shared_examples 'saves project tree successfully' do |ndjson_enabled|
+ shared_examples 'saves project tree successfully' do
include ImportExport::CommonUtil
- subject { get_json(full_path, exportable_path, relation_name, ndjson_enabled) }
+ subject { get_json(full_path, exportable_path, relation_name) }
describe 'saves project tree attributes' do
let_it_be(:shared) { project.import_export_shared }
let(:relation_name) { :projects }
- let_it_be(:full_path) do
- if ndjson_enabled
- File.join(shared.export_path, 'tree')
- else
- File.join(shared.export_path, Gitlab::ImportExport.project_filename)
- end
- end
+ let_it_be(:full_path) { File.join(shared.export_path, 'tree') }
before_all do
RSpec::Mocks.with_temporary_scope do
stub_all_feature_flags
- stub_feature_flags(project_export_as_ndjson: ndjson_enabled)
project.add_maintainer(user)
@@ -223,22 +216,31 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver, :with_license do
expect(subject.dig(0, 'stages')).not_to be_empty
end
- it 'has pipeline statuses' do
- expect(subject.dig(0, 'stages', 0, 'statuses')).not_to be_empty
+ it 'has pipeline builds' do
+ count = subject.dig(0, 'stages', 0, 'builds').count
+
+ expect(count).to eq(1)
end
- it 'has pipeline builds' do
- builds_count = subject.dig(0, 'stages', 0, 'statuses')
- .count { |hash| hash['type'] == 'Ci::Build' }
+ it 'has pipeline generic_commit_statuses' do
+ count = subject.dig(0, 'stages', 0, 'generic_commit_statuses').count
- expect(builds_count).to eq(1)
+ expect(count).to eq(1)
end
- it 'has ci pipeline notes' do
- expect(subject.first['notes']).not_to be_empty
+ it 'has pipeline bridges' do
+ count = subject.dig(0, 'stages', 0, 'bridges').count
+
+ expect(count).to eq(1)
end
end
+ context 'with commit_notes' do
+ let(:relation_name) { :commit_notes }
+
+ it { is_expected.not_to be_empty }
+ end
+
context 'with labels' do
let(:relation_name) { :labels }
@@ -291,13 +293,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver, :with_license do
let_it_be(:group) { create(:group) }
let(:project) { setup_project }
- let(:full_path) do
- if ndjson_enabled
- File.join(shared.export_path, 'tree')
- else
- File.join(shared.export_path, Gitlab::ImportExport.project_filename)
- end
- end
+ let(:full_path) { File.join(shared.export_path, 'tree') }
let(:shared) { project.import_export_shared }
let(:params) { {} }
@@ -305,7 +301,6 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver, :with_license do
let(:project_tree_saver ) { described_class.new(project: project, current_user: user, shared: shared, params: params) }
before do
- stub_feature_flags(project_export_as_ndjson: ndjson_enabled)
project.add_maintainer(user)
FileUtils.rm_rf(export_path)
@@ -416,13 +411,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver, :with_license do
end
end
- context 'with JSON' do
- it_behaves_like "saves project tree successfully", false
- end
-
- context 'with NDJSON' do
- it_behaves_like "saves project tree successfully", true
- end
+ it_behaves_like "saves project tree successfully"
context 'when streaming has to retry', :aggregate_failures do
let(:shared) { double('shared', export_path: exportable_path) }
@@ -468,6 +457,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver, :with_license do
end
end
+ # rubocop: disable Metrics/AbcSize
def setup_project
release = create(:release)
@@ -496,6 +486,8 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver, :with_license do
ci_build = create(:ci_build, project: project, when: nil)
ci_build.pipeline.update!(project: project)
create(:commit_status, project: project, pipeline: ci_build.pipeline)
+ create(:generic_commit_status, pipeline: ci_build.pipeline, ci_stage: ci_build.ci_stage, project: project)
+ create(:ci_bridge, pipeline: ci_build.pipeline, ci_stage: ci_build.ci_stage, project: project)
create(:milestone, project: project)
discussion_note = create(:discussion_note, noteable: issue, project: project)
@@ -528,4 +520,5 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver, :with_license do
project
end
+ # rubocop: enable Metrics/AbcSize
end
diff --git a/spec/lib/gitlab/import_export/references_configuration_spec.rb b/spec/lib/gitlab/import_export/references_configuration_spec.rb
index ad165790b77..84c5b564cb1 100644
--- a/spec/lib/gitlab/import_export/references_configuration_spec.rb
+++ b/spec/lib/gitlab/import_export/references_configuration_spec.rb
@@ -9,7 +9,7 @@ require 'spec_helper'
# or to be blacklisted by using the import_export.yml configuration file.
# Likewise, new models added to import_export.yml, will need to be added with their correspondent relations
# to this spec.
-RSpec.describe 'Import/Export Project configuration' do
+RSpec.describe 'Import/Export Project configuration', feature_category: :importers do
include ConfigurationHelper
where(:relation_path, :relation_name) do
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index e14e929faf3..faf345e8f78 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -86,6 +86,12 @@ Note:
- original_discussion_id
- confidential
- last_edited_at
+- internal
+Notes::NoteMetadata:
+- note_id
+- email_participant
+- created_at
+- updated_at
LabelLink:
- id
- target_type
@@ -347,7 +353,111 @@ Ci::Stage:
- pipeline_id
- created_at
- updated_at
-CommitStatus:
+Ci::Build:
+- id
+- project_id
+- status
+- finished_at
+- trace
+- created_at
+- updated_at
+- started_at
+- runner_id
+- coverage
+- commit_id
+- commands
+- job_id
+- name
+- deploy
+- options
+- allow_failure
+- stage
+- trigger_request_id
+- stage_idx
+- stage_id
+- tag
+- ref
+- user_id
+- type
+- target_url
+- description
+- artifacts_file
+- artifacts_file_store
+- artifacts_metadata
+- artifacts_metadata_store
+- erased_by_id
+- erased_at
+- artifacts_expire_at
+- environment
+- artifacts_size
+- when
+- yaml_variables
+- queued_at
+- token
+- lock_version
+- coverage_regex
+- auto_canceled_by_id
+- retried
+- protected
+- failure_reason
+- scheduled_at
+- upstream_pipeline_id
+- interruptible
+- processed
+- scheduling_type
+Ci::Bridge:
+- id
+- project_id
+- status
+- finished_at
+- trace
+- created_at
+- updated_at
+- started_at
+- runner_id
+- coverage
+- commit_id
+- commands
+- job_id
+- name
+- deploy
+- options
+- allow_failure
+- stage
+- trigger_request_id
+- stage_idx
+- stage_id
+- tag
+- ref
+- user_id
+- type
+- target_url
+- description
+- artifacts_file
+- artifacts_file_store
+- artifacts_metadata
+- artifacts_metadata_store
+- erased_by_id
+- erased_at
+- artifacts_expire_at
+- environment
+- artifacts_size
+- when
+- yaml_variables
+- queued_at
+- token
+- lock_version
+- coverage_regex
+- auto_canceled_by_id
+- retried
+- protected
+- failure_reason
+- scheduled_at
+- upstream_pipeline_id
+- interruptible
+- processed
+- scheduling_type
+GenericCommitStatus:
- id
- project_id
- status
@@ -822,6 +932,11 @@ DesignManagement::Version:
- created_at
- sha
- author_id
+DesignManagement::Repository:
+- id
+- project_id
+- created_at
+- updated_at
ZoomMeeting:
- id
- project_id
@@ -955,3 +1070,21 @@ ResourceIterationEvent:
- action
Iterations::Cadence:
- title
+ApprovalProjectRule:
+ - approvals_required
+ - name
+ - rule_type
+ - scanners
+ - vulnerabilities_allowed
+ - severity_levels
+ - report_type
+ - vulnerability_states
+ - orchestration_policy_idx
+ - applies_to_all_protected_branches
+ApprovalProjectRulesUser:
+ - user_id
+ - approval_project_rule_id
+ApprovalProjectRulesProtectedBranch:
+ - protected_branch_id
+ - approval_project_rule_id
+ - branch_name
diff --git a/spec/lib/gitlab/import_sources_spec.rb b/spec/lib/gitlab/import_sources_spec.rb
index 393e0a9be10..f1ea5f3e85e 100644
--- a/spec/lib/gitlab/import_sources_spec.rb
+++ b/spec/lib/gitlab/import_sources_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportSources do
+RSpec.describe Gitlab::ImportSources, feature_category: :importers do
describe '.options' do
it 'returns a hash' do
expected =
@@ -10,13 +10,11 @@ RSpec.describe Gitlab::ImportSources do
'GitHub' => 'github',
'Bitbucket Cloud' => 'bitbucket',
'Bitbucket Server' => 'bitbucket_server',
- 'GitLab.com' => 'gitlab',
'FogBugz' => 'fogbugz',
'Repository by URL' => 'git',
'GitLab export' => 'gitlab_project',
'Gitea' => 'gitea',
- 'Manifest file' => 'manifest',
- 'Phabricator' => 'phabricator'
+ 'Manifest file' => 'manifest'
}
expect(described_class.options).to eq(expected)
@@ -30,13 +28,11 @@ RSpec.describe Gitlab::ImportSources do
github
bitbucket
bitbucket_server
- gitlab
fogbugz
git
gitlab_project
gitea
manifest
- phabricator
)
expect(described_class.values).to eq(expected)
@@ -50,11 +46,9 @@ RSpec.describe Gitlab::ImportSources do
github
bitbucket
bitbucket_server
- gitlab
fogbugz
gitlab_project
gitea
- phabricator
)
expect(described_class.importer_names).to eq(expected)
@@ -66,13 +60,11 @@ RSpec.describe Gitlab::ImportSources do
'github' => Gitlab::GithubImport::ParallelImporter,
'bitbucket' => Gitlab::BitbucketImport::Importer,
'bitbucket_server' => Gitlab::BitbucketServerImport::Importer,
- 'gitlab' => Gitlab::GitlabImport::Importer,
'fogbugz' => Gitlab::FogbugzImport::Importer,
'git' => nil,
'gitlab_project' => Gitlab::ImportExport::Importer,
'gitea' => Gitlab::LegacyGithubImport::Importer,
- 'manifest' => nil,
- 'phabricator' => Gitlab::PhabricatorImport::Importer
+ 'manifest' => nil
}
import_sources.each do |name, klass|
@@ -87,13 +79,11 @@ RSpec.describe Gitlab::ImportSources do
'github' => 'GitHub',
'bitbucket' => 'Bitbucket Cloud',
'bitbucket_server' => 'Bitbucket Server',
- 'gitlab' => 'GitLab.com',
'fogbugz' => 'FogBugz',
'git' => 'Repository by URL',
'gitlab_project' => 'GitLab export',
'gitea' => 'Gitea',
- 'manifest' => 'Manifest file',
- 'phabricator' => 'Phabricator'
+ 'manifest' => 'Manifest file'
}
import_sources.each do |name, title|
@@ -104,7 +94,7 @@ RSpec.describe Gitlab::ImportSources do
end
describe 'imports_repository? checker' do
- let(:allowed_importers) { %w[github gitlab_project bitbucket_server phabricator] }
+ let(:allowed_importers) { %w[github gitlab_project bitbucket_server] }
it 'fails if any importer other than the allowed ones implements this method' do
current_importers = described_class.values.select { |kind| described_class.importer(kind).try(:imports_repository?) }
diff --git a/spec/lib/gitlab/incoming_email_spec.rb b/spec/lib/gitlab/incoming_email_spec.rb
deleted file mode 100644
index acd6634058f..00000000000
--- a/spec/lib/gitlab/incoming_email_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::IncomingEmail do
- let(:setting_name) { :incoming_email }
-
- it_behaves_like 'common email methods'
-
- describe 'self.key_from_address' do
- before do
- stub_incoming_email_setting(address: 'replies+%{key}@example.com')
- end
-
- it "returns reply key" do
- expect(described_class.key_from_address("replies+key@example.com")).to eq("key")
- end
-
- it 'does not match emails with extra bits' do
- expect(described_class.key_from_address('somereplies+somekey@example.com.someotherdomain.com')).to be nil
- end
-
- context 'when a custom wildcard address is used' do
- let(:wildcard_address) { 'custom.address+%{key}@example.com' }
-
- it 'finds key if email matches address pattern' do
- key = described_class.key_from_address(
- 'custom.address+foo@example.com', wildcard_address: wildcard_address
- )
- expect(key).to eq('foo')
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/instrumentation/redis_base_spec.rb b/spec/lib/gitlab/instrumentation/redis_base_spec.rb
index 656e6ffba05..426997f6e86 100644
--- a/spec/lib/gitlab/instrumentation/redis_base_spec.rb
+++ b/spec/lib/gitlab/instrumentation/redis_base_spec.rb
@@ -210,4 +210,16 @@ RSpec.describe Gitlab::Instrumentation::RedisBase, :request_store do
end
end
end
+
+ describe '.log_exception' do
+ it 'logs exception with storage details' do
+ expect(::Gitlab::ErrorTracking).to receive(:log_exception)
+ .with(
+ an_instance_of(StandardError),
+ storage: instrumentation_class_a.storage_key
+ )
+
+ instrumentation_class_a.log_exception(StandardError.new)
+ end
+ end
end
diff --git a/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb
index 187a6ff1739..be6586ca610 100644
--- a/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb
+++ b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb
@@ -64,16 +64,34 @@ RSpec.describe Gitlab::Instrumentation::RedisInterceptor, :clean_gitlab_redis_sh
end
end
- it 'counts exceptions' do
- expect(instrumentation_class).to receive(:instance_count_exception)
- .with(instance_of(Redis::CommandError)).and_call_original
- expect(instrumentation_class).to receive(:instance_count_request).and_call_original
+ context 'when encountering exceptions' do
+ where(:case_name, :exception, :exception_counter) do
+ 'generic exception' | Redis::CommandError | :instance_count_exception
+ 'moved redirection' | Redis::CommandError.new("MOVED 123 127.0.0.1:6380") | :instance_count_cluster_redirection
+ 'ask redirection' | Redis::CommandError.new("ASK 123 127.0.0.1:6380") | :instance_count_cluster_redirection
+ end
- expect do
- Gitlab::Redis::SharedState.with do |redis|
- redis.call(:auth, 'foo', 'bar')
+ with_them do
+ before do
+ Gitlab::Redis::SharedState.with do |redis|
+ # We need to go 1 layer deeper to stub _client as we monkey-patch Redis::Client
+ # with the interceptor. Stubbing `redis` will skip the instrumentation_class.
+ allow(redis._client).to receive(:process).and_raise(exception)
+ end
end
- end.to raise_exception(Redis::CommandError)
+
+ it 'counts exception' do
+ expect(instrumentation_class).to receive(exception_counter)
+ .with(instance_of(Redis::CommandError)).and_call_original
+ expect(instrumentation_class).to receive(:log_exception)
+ .with(instance_of(Redis::CommandError)).and_call_original
+ expect(instrumentation_class).to receive(:instance_count_request).and_call_original
+
+ expect do
+ Gitlab::Redis::SharedState.with { |redis| redis.call(:auth, 'foo', 'bar') }
+ end.to raise_exception(Redis::CommandError)
+ end
+ end
end
context 'in production environment' do
@@ -174,6 +192,7 @@ RSpec.describe Gitlab::Instrumentation::RedisInterceptor, :clean_gitlab_redis_sh
[['zadd', 'foobar', 1, 'a']] | ['bzpopmax', 'foobar', 0]
[['xadd', 'mystream', 1, 'myfield', 'mydata']] | ['xread', 'block', 1, 'streams', 'mystream', '0-0']
[['xadd', 'foobar', 1, 'myfield', 'mydata'], ['xgroup', 'create', 'foobar', 'mygroup', 0]] | ['xreadgroup', 'group', 'mygroup', 'myconsumer', 'block', 1, 'streams', 'foobar', '0-0']
+ [] | ['command']
end
with_them do
diff --git a/spec/lib/gitlab/internal_post_receive/response_spec.rb b/spec/lib/gitlab/internal_post_receive/response_spec.rb
index 23ea5191486..2792cf49d06 100644
--- a/spec/lib/gitlab/internal_post_receive/response_spec.rb
+++ b/spec/lib/gitlab/internal_post_receive/response_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe Gitlab::InternalPostReceive::Response do
describe '#add_alert_message' do
context 'when text is present' do
- it 'adds a alert message' do
+ it 'adds an alert message' do
subject.add_alert_message('hello')
expect(subject.messages.first.message).to eq('hello')
diff --git a/spec/lib/gitlab/issuable_sorter_spec.rb b/spec/lib/gitlab/issuable_sorter_spec.rb
index b8d0c7b0609..0d9940bab6f 100644
--- a/spec/lib/gitlab/issuable_sorter_spec.rb
+++ b/spec/lib/gitlab/issuable_sorter_spec.rb
@@ -4,16 +4,42 @@ require 'spec_helper'
RSpec.describe Gitlab::IssuableSorter do
let(:namespace1) { build_stubbed(:namespace, id: 1) }
- let(:project1) { build_stubbed(:project, id: 1, namespace: namespace1) }
-
- let(:project2) { build_stubbed(:project, id: 2, path: "a", namespace: project1.namespace) }
- let(:project3) { build_stubbed(:project, id: 3, path: "b", namespace: project1.namespace) }
-
let(:namespace2) { build_stubbed(:namespace, id: 2, path: "a") }
let(:namespace3) { build_stubbed(:namespace, id: 3, path: "b") }
- let(:project4) { build_stubbed(:project, id: 4, path: "a", namespace: namespace2) }
- let(:project5) { build_stubbed(:project, id: 5, path: "b", namespace: namespace2) }
- let(:project6) { build_stubbed(:project, id: 6, path: "a", namespace: namespace3) }
+
+ let(:project1) do
+ build_stubbed(:project, id: 1, namespace: namespace1, project_namespace: build_stubbed(:project_namespace))
+ end
+
+ let(:project2) do
+ build_stubbed(
+ :project, id: 2, path: "a", namespace: project1.namespace, project_namespace: build_stubbed(:project_namespace)
+ )
+ end
+
+ let(:project3) do
+ build_stubbed(
+ :project, id: 3, path: "b", namespace: project1.namespace, project_namespace: build_stubbed(:project_namespace)
+ )
+ end
+
+ let(:project4) do
+ build_stubbed(
+ :project, id: 4, path: "a", namespace: namespace2, project_namespace: build_stubbed(:project_namespace)
+ )
+ end
+
+ let(:project5) do
+ build_stubbed(
+ :project, id: 5, path: "b", namespace: namespace2, project_namespace: build_stubbed(:project_namespace)
+ )
+ end
+
+ let(:project6) do
+ build_stubbed(
+ :project, id: 6, path: "a", namespace: namespace3, project_namespace: build_stubbed(:project_namespace)
+ )
+ end
let(:unsorted) { [sorted[2], sorted[3], sorted[0], sorted[1]] }
diff --git a/spec/lib/gitlab/jira_import/issues_importer_spec.rb b/spec/lib/gitlab/jira_import/issues_importer_spec.rb
index 9f654bbcd15..36135c56dd9 100644
--- a/spec/lib/gitlab/jira_import/issues_importer_spec.rb
+++ b/spec/lib/gitlab/jira_import/issues_importer_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe Gitlab::JiraImport::IssuesImporter do
def mock_issue_serializer(count, raise_exception_on_even_mocks: false)
serializer = instance_double(Gitlab::JiraImport::IssueSerializer, execute: { key: 'data' })
- allow(Issue).to receive(:with_project_iid_supply).and_return('issue_iid')
+ allow(Issue).to receive(:with_namespace_iid_supply).and_return('issue_iid')
count.times do |i|
if raise_exception_on_even_mocks && i.even?
diff --git a/spec/lib/gitlab/json_logger_spec.rb b/spec/lib/gitlab/json_logger_spec.rb
index 801de357ddc..87df20c066b 100644
--- a/spec/lib/gitlab/json_logger_spec.rb
+++ b/spec/lib/gitlab/json_logger_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Gitlab::JsonLogger do
subject { described_class.new('/dev/null') }
- let(:now) { Time.now }
+ it_behaves_like 'a json logger', {}
describe '#file_name' do
let(:subclass) do
@@ -26,31 +26,4 @@ RSpec.describe Gitlab::JsonLogger do
expect(subclass.file_name).to eq('testlogger.log')
end
end
-
- describe '#format_message' do
- before do
- allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('new-correlation-id')
- end
-
- it 'formats strings' do
- output = subject.format_message('INFO', now, 'test', 'Hello world')
- data = Gitlab::Json.parse(output)
-
- expect(data['severity']).to eq('INFO')
- expect(data['time']).to eq(now.utc.iso8601(3))
- expect(data['message']).to eq('Hello world')
- expect(data['correlation_id']).to eq('new-correlation-id')
- end
-
- it 'formats hashes' do
- output = subject.format_message('INFO', now, 'test', { hello: 1 })
- data = Gitlab::Json.parse(output)
-
- expect(data['severity']).to eq('INFO')
- expect(data['time']).to eq(now.utc.iso8601(3))
- expect(data['hello']).to eq(1)
- expect(data['message']).to be_nil
- expect(data['correlation_id']).to eq('new-correlation-id')
- end
- end
end
diff --git a/spec/lib/gitlab/jwt_authenticatable_spec.rb b/spec/lib/gitlab/jwt_authenticatable_spec.rb
index 92d5feceb75..9a06f9b91df 100644
--- a/spec/lib/gitlab/jwt_authenticatable_spec.rb
+++ b/spec/lib/gitlab/jwt_authenticatable_spec.rb
@@ -172,11 +172,17 @@ RSpec.describe Gitlab::JwtAuthenticatable do
end
it 'raises an error if iat is invalid' do
- encoded_message = JWT.encode(payload.merge(iat: 'wrong'), test_class.secret, 'HS256')
+ encoded_message = JWT.encode(payload.merge(iat: Time.current.to_i + 1), test_class.secret, 'HS256')
expect { test_class.decode_jwt(encoded_message, iat_after: true) }.to raise_error(JWT::DecodeError)
end
+ it 'raises InvalidPayload exception if iat is a string' do
+ expect do
+ JWT.encode(payload.merge(iat: 'wrong'), test_class.secret, 'HS256')
+ end.to raise_error(JWT::InvalidPayload)
+ end
+
it 'raises an error if iat is absent' do
encoded_message = JWT.encode(payload, test_class.secret, 'HS256')
diff --git a/spec/lib/gitlab/kas/client_spec.rb b/spec/lib/gitlab/kas/client_spec.rb
index 9a0fa6c4067..5668c265611 100644
--- a/spec/lib/gitlab/kas/client_spec.rb
+++ b/spec/lib/gitlab/kas/client_spec.rb
@@ -109,6 +109,35 @@ RSpec.describe Gitlab::Kas::Client do
it { expect(subject).to eq(agent_configurations) }
end
+ describe '#send_git_push_event' do
+ let(:stub) { instance_double(Gitlab::Agent::Notifications::Rpc::Notifications::Stub) }
+ let(:request) { instance_double(Gitlab::Agent::Notifications::Rpc::GitPushEventRequest) }
+ let(:project_param) { instance_double(Gitlab::Agent::Notifications::Rpc::Project) }
+ let(:response) { double(Gitlab::Agent::Notifications::Rpc::GitPushEventResponse) }
+
+ subject { described_class.new.send_git_push_event(project: project) }
+
+ before do
+ expect(Gitlab::Agent::Notifications::Rpc::Notifications::Stub).to receive(:new)
+ .with('example.kas.internal', :this_channel_is_insecure, timeout: described_class::TIMEOUT)
+ .and_return(stub)
+
+ expect(Gitlab::Agent::Notifications::Rpc::Project).to receive(:new)
+ .with(id: project.id, full_path: project.full_path)
+ .and_return(project_param)
+
+ expect(Gitlab::Agent::Notifications::Rpc::GitPushEventRequest).to receive(:new)
+ .with(project: project_param)
+ .and_return(request)
+
+ expect(stub).to receive(:git_push_event)
+ .with(request, metadata: { 'authorization' => 'bearer test-token' })
+ .and_return(response)
+ end
+
+ it { expect(subject).to eq(response) }
+ end
+
describe 'with grpcs' do
let(:stub) { instance_double(Gitlab::Agent::ConfigurationProject::Rpc::ConfigurationProject::Stub) }
let(:credentials) { instance_double(GRPC::Core::ChannelCredentials) }
diff --git a/spec/lib/gitlab/kas/user_access_spec.rb b/spec/lib/gitlab/kas/user_access_spec.rb
new file mode 100644
index 00000000000..a8296d23a18
--- /dev/null
+++ b/spec/lib/gitlab/kas/user_access_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Kas::UserAccess, feature_category: :deployment_management do
+ describe '.enabled?' do
+ subject { described_class.enabled? }
+
+ before do
+ allow(::Gitlab::Kas).to receive(:enabled?).and_return true
+ end
+
+ it { is_expected.to be true }
+
+ context 'when flag kas_user_access is disabled' do
+ before do
+ stub_feature_flags(kas_user_access: false)
+ end
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe '.enabled_for?' do
+ subject { described_class.enabled_for?(agent) }
+
+ let(:agent) { build(:cluster_agent) }
+
+ before do
+ allow(::Gitlab::Kas).to receive(:enabled?).and_return true
+ end
+
+ it { is_expected.to be true }
+
+ context 'when flag kas_user_access is disabled' do
+ before do
+ stub_feature_flags(kas_user_access: false)
+ end
+
+ it { is_expected.to be false }
+ end
+
+ context 'when flag kas_user_access_project is disabled' do
+ before do
+ stub_feature_flags(kas_user_access_project: false)
+ end
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe '.{encrypt,decrypt}_public_session_id' do
+ let(:data) { 'the data' }
+ let(:encrypted) { described_class.encrypt_public_session_id(data) }
+ let(:decrypted) { described_class.decrypt_public_session_id(encrypted) }
+
+ it { expect(encrypted).not_to include data }
+ it { expect(decrypted).to eq data }
+ end
+
+ describe '.cookie_data' do
+ subject(:cookie_data) { described_class.cookie_data(public_session_id) }
+
+ let(:public_session_id) { 'the-public-session-id' }
+ let(:external_k8s_proxy_url) { 'https://example.com:1234' }
+
+ before do
+ stub_config(
+ gitlab: { host: 'example.com', https: true },
+ gitlab_kas: { external_k8s_proxy_url: external_k8s_proxy_url }
+ )
+ end
+
+ it 'is encrypted, secure, httponly', :aggregate_failures do
+ expect(cookie_data[:value]).not_to include public_session_id
+ expect(cookie_data).to include(httponly: true, secure: true, path: '/')
+ expect(cookie_data).not_to have_key(:domain)
+ end
+
+ context 'when on non-root path' do
+ let(:external_k8s_proxy_url) { 'https://example.com/k8s-proxy' }
+
+ it 'sets :path' do
+ expect(cookie_data).to include(httponly: true, secure: true, path: '/k8s-proxy')
+ end
+ end
+
+ context 'when on subdomain' do
+ let(:external_k8s_proxy_url) { 'https://k8s-proxy.example.com' }
+
+ it 'sets :domain' do
+ expect(cookie_data[:domain]).to eq "example.com"
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kroki_spec.rb b/spec/lib/gitlab/kroki_spec.rb
index 3d6ecf20377..6d8e6ecbf54 100644
--- a/spec/lib/gitlab/kroki_spec.rb
+++ b/spec/lib/gitlab/kroki_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::Kroki do
describe '.formats' do
def default_formats
- %w[bytefield c4plantuml ditaa erd graphviz nomnoml pikchr plantuml
+ %w[bytefield c4plantuml d2 dbml diagramsnet ditaa erd graphviz nomnoml pikchr plantuml
structurizr svgbob umlet vega vegalite wavedrom].freeze
end
diff --git a/spec/lib/gitlab/kubernetes/config_map_spec.rb b/spec/lib/gitlab/kubernetes/config_map_spec.rb
index 2d0d205ffb1..ebc2202921b 100644
--- a/spec/lib/gitlab/kubernetes/config_map_spec.rb
+++ b/spec/lib/gitlab/kubernetes/config_map_spec.rb
@@ -4,20 +4,23 @@ require 'spec_helper'
RSpec.describe Gitlab::Kubernetes::ConfigMap do
let(:kubeclient) { double('kubernetes client') }
- let(:application) { create(:clusters_applications_prometheus) }
- let(:config_map) { described_class.new(application.name, application.files) }
+ let(:name) { 'my-name' }
+ let(:files) { [] }
+ let(:config_map) { described_class.new(name, files) }
let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
let(:metadata) do
{
- name: "values-content-configuration-#{application.name}",
+ name: "values-content-configuration-#{name}",
namespace: namespace,
- labels: { name: "values-content-configuration-#{application.name}" }
+ labels: { name: "values-content-configuration-#{name}" }
}
end
describe '#generate' do
- let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: application.files) }
+ let(:resource) do
+ ::Kubeclient::Resource.new(metadata: metadata, data: files)
+ end
subject { config_map.generate }
@@ -28,7 +31,8 @@ RSpec.describe Gitlab::Kubernetes::ConfigMap do
describe '#config_map_name' do
it 'returns the config_map name' do
- expect(config_map.config_map_name).to eq("values-content-configuration-#{application.name}")
+ expect(config_map.config_map_name)
+ .to eq("values-content-configuration-#{name}")
end
end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
deleted file mode 100644
index e022f5bd912..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb
+++ /dev/null
@@ -1,269 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::API do
- let(:client) { double('kubernetes client') }
- let(:helm) { described_class.new(client) }
- let(:gitlab_namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
- let(:gitlab_namespace_labels) { Gitlab::Kubernetes::Helm::NAMESPACE_LABELS }
- let(:namespace) { Gitlab::Kubernetes::Namespace.new(gitlab_namespace, client, labels: gitlab_namespace_labels) }
- let(:application_name) { 'app-name' }
- let(:rbac) { false }
- let(:files) { {} }
-
- let(:command) do
- Gitlab::Kubernetes::Helm::V2::InstallCommand.new(
- name: application_name,
- chart: 'chart-name',
- rbac: rbac,
- files: files
- )
- end
-
- subject { helm }
-
- before do
- allow(Gitlab::Kubernetes::Namespace).to(
- receive(:new).with(gitlab_namespace, client, labels: gitlab_namespace_labels).and_return(namespace)
- )
- allow(client).to receive(:create_config_map)
- end
-
- describe '#initialize' do
- it 'creates a namespace object' do
- expect(Gitlab::Kubernetes::Namespace).to(
- receive(:new).with(gitlab_namespace, client, labels: gitlab_namespace_labels)
- )
-
- subject
- end
- end
-
- describe '#uninstall' do
- before do
- allow(client).to receive(:create_pod).and_return(nil)
- allow(client).to receive(:get_config_map).and_return(nil)
- allow(client).to receive(:create_config_map).and_return(nil)
- allow(client).to receive(:delete_pod).and_return(nil)
- allow(namespace).to receive(:ensure_exists!).once
- end
-
- it 'ensures the namespace exists before creating the POD' do
- expect(namespace).to receive(:ensure_exists!).once.ordered
- expect(client).to receive(:create_pod).once.ordered
-
- subject.uninstall(command)
- end
-
- it 'removes an existing pod before installing' do
- expect(client).to receive(:delete_pod).with('install-app-name', 'gitlab-managed-apps').once.ordered
- expect(client).to receive(:create_pod).once.ordered
-
- subject.uninstall(command)
- end
-
- context 'with a ConfigMap' do
- let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application_name, files).generate }
-
- it 'creates a ConfigMap on kubeclient' do
- expect(client).to receive(:create_config_map).with(resource).once
-
- subject.install(command)
- end
-
- context 'config map already exists' do
- before do
- expect(client).to receive(:get_config_map).with("values-content-configuration-#{application_name}", gitlab_namespace).and_return(resource)
- end
-
- it 'updates the config map' do
- expect(client).to receive(:update_config_map).with(resource).once
-
- subject.install(command)
- end
- end
- end
- end
-
- describe '#install' do
- before do
- allow(client).to receive(:create_pod).and_return(nil)
- allow(client).to receive(:get_config_map).and_return(nil)
- allow(client).to receive(:create_config_map).and_return(nil)
- allow(client).to receive(:create_service_account).and_return(nil)
- allow(client).to receive(:delete_pod).and_return(nil)
- allow(namespace).to receive(:ensure_exists!).once
- end
-
- it 'ensures the namespace exists before creating the POD' do
- expect(namespace).to receive(:ensure_exists!).once.ordered
- expect(client).to receive(:create_pod).once.ordered
-
- subject.install(command)
- end
-
- it 'removes an existing pod before installing' do
- expect(client).to receive(:delete_pod).with('install-app-name', 'gitlab-managed-apps').once.ordered
- expect(client).to receive(:create_pod).once.ordered
-
- subject.install(command)
- end
-
- context 'with a ConfigMap' do
- let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application_name, files).generate }
-
- it 'creates a ConfigMap on kubeclient' do
- expect(client).to receive(:create_config_map).with(resource).once
-
- subject.install(command)
- end
-
- context 'config map already exists' do
- before do
- expect(client).to receive(:get_config_map).with("values-content-configuration-#{application_name}", gitlab_namespace).and_return(resource)
- end
-
- it 'updates the config map' do
- expect(client).to receive(:update_config_map).with(resource).once
-
- subject.install(command)
- end
- end
- end
-
- context 'without a service account' do
- it 'does not create a service account on kubeclient' do
- expect(client).not_to receive(:create_service_account)
- expect(client).not_to receive(:update_cluster_role_binding)
-
- subject.install(command)
- end
- end
-
- context 'with a service account' do
- let(:command) { Gitlab::Kubernetes::Helm::V2::InitCommand.new(name: application_name, files: files, rbac: rbac) }
-
- context 'rbac-enabled cluster' do
- let(:rbac) { true }
-
- let(:service_account_resource) do
- Kubeclient::Resource.new(metadata: { name: 'tiller', namespace: 'gitlab-managed-apps' })
- end
-
- let(:cluster_role_binding_resource) do
- Kubeclient::Resource.new(
- metadata: { name: 'tiller-admin' },
- roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'ClusterRole', name: 'cluster-admin' },
- subjects: [{ kind: 'ServiceAccount', name: 'tiller', namespace: 'gitlab-managed-apps' }]
- )
- end
-
- context 'service account does not exist' do
- before do
- expect(client).to receive(:get_service_account).with('tiller', 'gitlab-managed-apps').and_raise(Kubeclient::ResourceNotFoundError.new(404, 'Not found', nil))
- end
-
- it 'creates a service account, followed the cluster role binding on kubeclient' do
- expect(client).to receive(:create_service_account).with(service_account_resource).once.ordered
- expect(client).to receive(:update_cluster_role_binding).with(cluster_role_binding_resource).once.ordered
-
- subject.install(command)
- end
- end
-
- context 'service account already exists' do
- before do
- expect(client).to receive(:get_service_account).with('tiller', 'gitlab-managed-apps').and_return(service_account_resource)
- end
-
- it 'updates the service account, followed by creating the cluster role binding' do
- expect(client).to receive(:update_service_account).with(service_account_resource).once.ordered
- expect(client).to receive(:update_cluster_role_binding).with(cluster_role_binding_resource).once.ordered
-
- subject.install(command)
- end
- end
-
- context 'a non-404 error is thrown' do
- before do
- expect(client).to receive(:get_service_account).with('tiller', 'gitlab-managed-apps').and_raise(Kubeclient::HttpError.new(401, 'Unauthorized', nil))
- end
-
- it 'raises an error' do
- expect { subject.install(command) }.to raise_error(Kubeclient::HttpError)
- end
- end
- end
-
- context 'legacy abac cluster' do
- it 'does not create a service account on kubeclient' do
- expect(client).not_to receive(:create_service_account)
- expect(client).not_to receive(:update_cluster_role_binding)
-
- subject.install(command)
- end
- end
- end
- end
-
- describe '#status' do
- let(:phase) { Gitlab::Kubernetes::Pod::RUNNING }
- let(:pod) { Kubeclient::Resource.new(status: { phase: phase }) } # partial representation
-
- it 'fetches POD phase from kubernetes cluster' do
- expect(client).to receive(:get_pod).with(command.pod_name, gitlab_namespace).once.and_return(pod)
-
- expect(subject.status(command.pod_name)).to eq(phase)
- end
- end
-
- describe '#log' do
- let(:log) { 'some output' }
- let(:response) { RestClient::Response.new(log) }
-
- it 'fetches POD phase from kubernetes cluster' do
- expect(client).to receive(:get_pod_log).with(command.pod_name, gitlab_namespace).once.and_return(response)
-
- expect(subject.log(command.pod_name)).to eq(log)
- end
- end
-
- describe '#delete_pod!' do
- it 'deletes the POD from kubernetes cluster' do
- expect(client).to receive(:delete_pod).with('install-app-name', 'gitlab-managed-apps').once
-
- subject.delete_pod!('install-app-name')
- end
-
- context 'when the resource being deleted does not exist' do
- it 'catches the error' do
- expect(client).to receive(:delete_pod).with('install-app-name', 'gitlab-managed-apps')
- .and_raise(Kubeclient::ResourceNotFoundError.new(404, 'Not found', nil))
-
- subject.delete_pod!('install-app-name')
- end
- end
- end
-
- describe '#get_config_map' do
- before do
- allow(namespace).to receive(:ensure_exists!).once
- allow(client).to receive(:get_config_map).and_return(nil)
- end
-
- it 'ensures the namespace exists before retrieving the config map' do
- expect(namespace).to receive(:ensure_exists!).once
-
- subject.get_config_map('example-config-map-name')
- end
-
- it 'gets the config map on kubeclient' do
- expect(client).to receive(:get_config_map)
- .with('example-config-map-name', namespace.name)
- .once
-
- subject.get_config_map('example-config-map-name')
- end
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
deleted file mode 100644
index e3763977add..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::Pod do
- describe '#generate' do
- using RSpec::Parameterized::TableSyntax
-
- where(:helm_major_version, :expected_helm_version, :expected_command_env) do
- 2 | '2.17.0' | [:TILLER_NAMESPACE]
- 3 | '3.2.4' | nil
- end
-
- with_them do
- let(:cluster) { create(:cluster, helm_major_version: helm_major_version) }
- let(:app) { create(:clusters_applications_prometheus, cluster: cluster) }
- let(:command) { app.install_command }
- let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
- let(:service_account_name) { nil }
-
- subject { described_class.new(command, namespace, service_account_name: service_account_name) }
-
- context 'with a command' do
- it 'generates a Kubeclient::Resource' do
- expect(subject.generate).to be_a_kind_of(Kubeclient::Resource)
- end
-
- it 'generates the appropriate metadata' do
- metadata = subject.generate.metadata
- expect(metadata.name).to eq("install-#{app.name}")
- expect(metadata.namespace).to eq('gitlab-managed-apps')
- expect(metadata.labels['gitlab.org/action']).to eq('install')
- expect(metadata.labels['gitlab.org/application']).to eq(app.name)
- end
-
- it 'generates a container spec' do
- spec = subject.generate.spec
- expect(spec.containers.count).to eq(1)
- end
-
- it 'generates the appropriate specifications for the container' do
- container = subject.generate.spec.containers.first
- expect(container.name).to eq('helm')
- expect(container.image).to eq("registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/#{expected_helm_version}-kube-1.13.12-alpine-3.12")
- expect(container.env.map(&:name)).to include(:HELM_VERSION, :COMMAND_SCRIPT, *expected_command_env)
- expect(container.command).to match_array(["/bin/sh"])
- expect(container.args).to match_array(["-c", "$(COMMAND_SCRIPT)"])
- end
-
- it 'includes a never restart policy' do
- spec = subject.generate.spec
- expect(spec.restartPolicy).to eq('Never')
- end
-
- it 'includes volumes for the container' do
- container = subject.generate.spec.containers.first
- expect(container.volumeMounts.first['name']).to eq('configuration-volume')
- expect(container.volumeMounts.first['mountPath']).to eq("/data/helm/#{app.name}/config")
- end
-
- it 'includes a volume inside the specification' do
- spec = subject.generate.spec
- expect(spec.volumes.first['name']).to eq('configuration-volume')
- end
-
- it 'mounts configMap specification in the volume' do
- volume = subject.generate.spec.volumes.first
- expect(volume.configMap['name']).to eq("values-content-configuration-#{app.name}")
- expect(volume.configMap['items'].first['key']).to eq(:'values.yaml')
- expect(volume.configMap['items'].first['path']).to eq(:'values.yaml')
- end
-
- it 'has no serviceAccountName' do
- spec = subject.generate.spec
- expect(spec.serviceAccountName).to be_nil
- end
-
- context 'with a service_account_name' do
- let(:service_account_name) { 'sa' }
-
- it 'uses the serviceAccountName provided' do
- spec = subject.generate.spec
- expect(spec.serviceAccountName).to eq(service_account_name)
- end
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v2/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/base_command_spec.rb
deleted file mode 100644
index 3d2b36b9094..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v2/base_command_spec.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V2::BaseCommand do
- subject(:base_command) do
- test_class.new(rbac)
- end
-
- let(:application) { create(:clusters_applications_helm) }
- let(:rbac) { false }
-
- let(:test_class) do
- Class.new(described_class) do
- def initialize(rbac)
- super(
- name: 'test-class-name',
- rbac: rbac,
- files: { some: 'value' }
- )
- end
- end
- end
-
- describe 'HELM_VERSION' do
- subject { described_class::HELM_VERSION }
-
- it { is_expected.to match /^2\.\d+\.\d+$/ }
- end
-
- describe '#env' do
- subject { base_command.env }
-
- it { is_expected.to include(TILLER_NAMESPACE: 'gitlab-managed-apps') }
- end
-
- it_behaves_like 'helm command generator' do
- let(:commands) { '' }
- end
-
- describe '#pod_name' do
- subject { base_command.pod_name }
-
- it { is_expected.to eq('install-test-class-name') }
- end
-
- it_behaves_like 'helm command' do
- let(:command) { base_command }
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v2/certificate_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/certificate_spec.rb
deleted file mode 100644
index 698b88c9fa1..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v2/certificate_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-require 'fast_spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V2::Certificate do
- describe '.generate_root' do
- subject { described_class.generate_root }
-
- it 'generates a root CA that expires a long way in the future' do
- expect(subject.cert.not_after).to be > 999.years.from_now
- end
- end
-
- describe '#issue' do
- subject { described_class.generate_root.issue }
-
- it 'generates a cert that expires soon' do
- expect(subject.cert.not_after).to be < 60.minutes.from_now
- end
-
- context 'passing in INFINITE_EXPIRY' do
- subject { described_class.generate_root.issue(expires_in: described_class::INFINITE_EXPIRY) }
-
- it 'generates a cert that expires a long way in the future' do
- expect(subject.cert.not_after).to be > 999.years.from_now
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v2/delete_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/delete_command_spec.rb
deleted file mode 100644
index 4a3a41dba4a..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v2/delete_command_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V2::DeleteCommand do
- subject(:delete_command) { described_class.new(name: app_name, rbac: rbac, files: files) }
-
- let(:app_name) { 'app-name' }
- let(:rbac) { true }
- let(:files) { {} }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm delete --purge app-name
- EOS
- end
- end
-
- describe '#pod_name' do
- subject { delete_command.pod_name }
-
- it { is_expected.to eq('uninstall-app-name') }
- end
-
- it_behaves_like 'helm command' do
- let(:command) { delete_command }
- end
-
- describe '#delete_command' do
- it 'deletes the release' do
- expect(subject.delete_command).to eq('helm delete --purge app-name')
- end
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v2/init_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/init_command_spec.rb
deleted file mode 100644
index 8ae78ada15c..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v2/init_command_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V2::InitCommand do
- subject(:init_command) { described_class.new(name: application.name, files: files, rbac: rbac) }
-
- let(:application) { create(:clusters_applications_helm) }
- let(:rbac) { false }
- let(:files) { {} }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem
- EOS
- end
- end
-
- context 'on a rbac-enabled cluster' do
- let(:rbac) { true }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem --service-account tiller
- EOS
- end
- end
- end
-
- it_behaves_like 'helm command' do
- let(:command) { init_command }
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v2/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/install_command_spec.rb
deleted file mode 100644
index 250d1a82e7a..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v2/install_command_spec.rb
+++ /dev/null
@@ -1,183 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V2::InstallCommand do
- subject(:install_command) do
- described_class.new(
- name: 'app-name',
- chart: 'chart-name',
- rbac: rbac,
- files: files,
- version: version,
- repository: repository,
- preinstall: preinstall,
- postinstall: postinstall
- )
- end
-
- let(:files) { { 'ca.pem': 'some file content' } }
- let(:repository) { 'https://repository.example.com' }
- let(:rbac) { false }
- let(:version) { '1.2.3' }
- let(:preinstall) { nil }
- let(:postinstall) { nil }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_install_comand}
- EOS
- end
-
- let(:helm_install_comand) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --version 1.2.3
- --set rbac.create\\=false,rbac.enabled\\=false
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
-
- context 'when rbac is true' do
- let(:rbac) { true }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_install_command}
- EOS
- end
-
- let(:helm_install_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --version 1.2.3
- --set rbac.create\\=true,rbac.enabled\\=true
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- context 'when there is a pre-install script' do
- let(:preinstall) { ['/bin/date', '/bin/true'] }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm repo add app-name https://repository.example.com
- helm repo update
- /bin/date
- /bin/true
- #{helm_install_command}
- EOS
- end
-
- let(:helm_install_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --version 1.2.3
- --set rbac.create\\=false,rbac.enabled\\=false
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- context 'when there is a post-install script' do
- let(:postinstall) { ['/bin/date', "/bin/false\n"] }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_install_command}
- /bin/date
- /bin/false
- EOS
- end
-
- let(:helm_install_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --version 1.2.3
- --set rbac.create\\=false,rbac.enabled\\=false
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- context 'when there is no version' do
- let(:version) { nil }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_install_command}
- EOS
- end
-
- let(:helm_install_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --set rbac.create\\=false,rbac.enabled\\=false
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- it_behaves_like 'helm command' do
- let(:command) { install_command }
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v2/patch_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/patch_command_spec.rb
deleted file mode 100644
index 98eb77d397c..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v2/patch_command_spec.rb
+++ /dev/null
@@ -1,87 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V2::PatchCommand do
- let(:files) { { 'ca.pem': 'some file content' } }
- let(:repository) { 'https://repository.example.com' }
- let(:rbac) { false }
- let(:version) { '1.2.3' }
-
- subject(:patch_command) do
- described_class.new(
- name: 'app-name',
- chart: 'chart-name',
- rbac: rbac,
- files: files,
- version: version,
- repository: repository
- )
- end
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_upgrade_comand}
- EOS
- end
-
- let(:helm_upgrade_comand) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --reuse-values
- --version 1.2.3
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
-
- context 'when rbac is true' do
- let(:rbac) { true }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_upgrade_command}
- EOS
- end
-
- let(:helm_upgrade_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --reuse-values
- --version 1.2.3
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- context 'when there is no version' do
- let(:version) { nil }
-
- it { expect { patch_command }.to raise_error(ArgumentError, 'version is required') }
- end
-
- describe '#pod_name' do
- subject { patch_command.pod_name }
-
- it { is_expected.to eq 'install-app-name' }
- end
-
- it_behaves_like 'helm command' do
- let(:command) { patch_command }
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v2/reset_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/reset_command_spec.rb
deleted file mode 100644
index 2a3a4cec2b0..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v2/reset_command_spec.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V2::ResetCommand do
- subject(:reset_command) { described_class.new(name: name, rbac: rbac, files: files) }
-
- let(:rbac) { true }
- let(:name) { 'helm' }
- let(:files) { {} }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm reset --force
- EOS
- end
- end
-
- describe '#pod_name' do
- subject { reset_command.pod_name }
-
- it { is_expected.to eq('uninstall-helm') }
- end
-
- it_behaves_like 'helm command' do
- let(:command) { reset_command }
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v3/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v3/base_command_spec.rb
deleted file mode 100644
index ad5ff13b4c9..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v3/base_command_spec.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V3::BaseCommand do
- subject(:base_command) do
- test_class.new(rbac)
- end
-
- let(:application) { create(:clusters_applications_helm) }
- let(:rbac) { false }
-
- let(:test_class) do
- Class.new(described_class) do
- def initialize(rbac)
- super(
- name: 'test-class-name',
- rbac: rbac,
- files: { some: 'value' }
- )
- end
- end
- end
-
- describe 'HELM_VERSION' do
- subject { described_class::HELM_VERSION }
-
- it { is_expected.to match /^3\.\d+\.\d+$/ }
- end
-
- it_behaves_like 'helm command generator' do
- let(:commands) { '' }
- end
-
- describe '#pod_name' do
- subject { base_command.pod_name }
-
- it { is_expected.to eq('install-test-class-name') }
- end
-
- it_behaves_like 'helm command' do
- let(:command) { base_command }
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v3/delete_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v3/delete_command_spec.rb
deleted file mode 100644
index 63e7a8d2f25..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v3/delete_command_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V3::DeleteCommand do
- subject(:delete_command) { described_class.new(name: app_name, rbac: rbac, files: files) }
-
- let(:app_name) { 'app-name' }
- let(:rbac) { true }
- let(:files) { {} }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm uninstall app-name --namespace gitlab-managed-apps
- EOS
- end
- end
-
- describe '#pod_name' do
- subject { delete_command.pod_name }
-
- it { is_expected.to eq('uninstall-app-name') }
- end
-
- it_behaves_like 'helm command' do
- let(:command) { delete_command }
- end
-
- describe '#delete_command' do
- it 'deletes the release' do
- expect(subject.delete_command).to eq('helm uninstall app-name --namespace gitlab-managed-apps')
- end
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v3/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v3/install_command_spec.rb
deleted file mode 100644
index 2bf1f713b3f..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v3/install_command_spec.rb
+++ /dev/null
@@ -1,168 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V3::InstallCommand do
- subject(:install_command) do
- described_class.new(
- name: 'app-name',
- chart: 'chart-name',
- rbac: rbac,
- files: files,
- version: version,
- repository: repository,
- preinstall: preinstall,
- postinstall: postinstall
- )
- end
-
- let(:files) { { 'ca.pem': 'some file content' } }
- let(:repository) { 'https://repository.example.com' }
- let(:rbac) { false }
- let(:version) { '1.2.3' }
- let(:preinstall) { nil }
- let(:postinstall) { nil }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_install_comand}
- EOS
- end
-
- let(:helm_install_comand) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --version 1.2.3
- --set rbac.create\\=false,rbac.enabled\\=false
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
-
- context 'when rbac is true' do
- let(:rbac) { true }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_install_command}
- EOS
- end
-
- let(:helm_install_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --version 1.2.3
- --set rbac.create\\=true,rbac.enabled\\=true
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- context 'when there is a pre-install script' do
- let(:preinstall) { ['/bin/date', '/bin/true'] }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm repo add app-name https://repository.example.com
- helm repo update
- /bin/date
- /bin/true
- #{helm_install_command}
- EOS
- end
-
- let(:helm_install_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --version 1.2.3
- --set rbac.create\\=false,rbac.enabled\\=false
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- context 'when there is a post-install script' do
- let(:postinstall) { ['/bin/date', "/bin/false\n"] }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_install_command}
- /bin/date
- /bin/false
- EOS
- end
-
- let(:helm_install_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --version 1.2.3
- --set rbac.create\\=false,rbac.enabled\\=false
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- context 'when there is no version' do
- let(:version) { nil }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_install_command}
- EOS
- end
-
- let(:helm_install_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --set rbac.create\\=false,rbac.enabled\\=false
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- it_behaves_like 'helm command' do
- let(:command) { install_command }
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/helm/v3/patch_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v3/patch_command_spec.rb
deleted file mode 100644
index 2f22e0f2e77..00000000000
--- a/spec/lib/gitlab/kubernetes/helm/v3/patch_command_spec.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::Helm::V3::PatchCommand do
- let(:files) { { 'ca.pem': 'some file content' } }
- let(:repository) { 'https://repository.example.com' }
- let(:rbac) { false }
- let(:version) { '1.2.3' }
-
- subject(:patch_command) do
- described_class.new(
- name: 'app-name',
- chart: 'chart-name',
- rbac: rbac,
- files: files,
- version: version,
- repository: repository
- )
- end
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_upgrade_comand}
- EOS
- end
-
- let(:helm_upgrade_comand) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --reuse-values
- --version 1.2.3
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
-
- context 'when rbac is true' do
- let(:rbac) { true }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_upgrade_command}
- EOS
- end
-
- let(:helm_upgrade_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --reuse-values
- --version 1.2.3
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
- context 'when there is no version' do
- let(:version) { nil }
-
- it { expect { patch_command }.to raise_error(ArgumentError, 'version is required') }
- end
-
- describe '#pod_name' do
- subject { patch_command.pod_name }
-
- it { is_expected.to eq 'install-app-name' }
- end
-
- it_behaves_like 'helm command' do
- let(:command) { patch_command }
- end
-end
diff --git a/spec/lib/gitlab/legacy_github_import/client_spec.rb b/spec/lib/gitlab/legacy_github_import/client_spec.rb
index 08679b7e9f1..d0f63d11469 100644
--- a/spec/lib/gitlab/legacy_github_import/client_spec.rb
+++ b/spec/lib/gitlab/legacy_github_import/client_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Gitlab::LegacyGithubImport::Client do
let(:token) { '123456' }
- let(:github_provider) { Settingslogic.new('app_id' => 'asd123', 'app_secret' => 'asd123', 'name' => 'github', 'args' => { 'client_options' => {} }) }
+ let(:github_provider) { GitlabSettings::Options.build('app_id' => 'asd123', 'app_secret' => 'asd123', 'name' => 'github', 'args' => { 'client_options' => {} }) }
let(:wait_for_rate_limit_reset) { true }
subject(:client) { described_class.new(token, wait_for_rate_limit_reset: wait_for_rate_limit_reset) }
@@ -17,7 +17,7 @@ RSpec.describe Gitlab::LegacyGithubImport::Client do
expect(client.client.options.keys).to all(be_kind_of(Symbol))
end
- it 'does not crash (e.g. Settingslogic::MissingSetting) when verify_ssl config is not present' do
+ it 'does not crash (e.g. GitlabSettings::MissingSetting) when verify_ssl config is not present' do
expect { client.api }.not_to raise_error
end
diff --git a/spec/lib/gitlab/legacy_github_import/importer_spec.rb b/spec/lib/gitlab/legacy_github_import/importer_spec.rb
index cd66b93eb8b..bb38f4b1bca 100644
--- a/spec/lib/gitlab/legacy_github_import/importer_spec.rb
+++ b/spec/lib/gitlab/legacy_github_import/importer_spec.rb
@@ -2,7 +2,9 @@
require 'spec_helper'
-RSpec.describe Gitlab::LegacyGithubImport::Importer do
+RSpec.describe Gitlab::LegacyGithubImport::Importer, feature_category: :importers do
+ subject(:importer) { described_class.new(project) }
+
shared_examples 'Gitlab::LegacyGithubImport::Importer#execute' do
let(:expected_not_called) { [] }
@@ -11,8 +13,6 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
end
it 'calls import methods' do
- importer = described_class.new(project)
-
expected_called = [
:import_labels, :import_milestones, :import_pull_requests, :import_issues,
:import_wiki, :import_releases, :handle_errors,
@@ -51,11 +51,13 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
allow_any_instance_of(Octokit::Client).to receive(:labels).and_return([label1, label2])
allow_any_instance_of(Octokit::Client).to receive(:milestones).and_return([milestone, milestone])
allow_any_instance_of(Octokit::Client).to receive(:issues).and_return([issue1, issue2])
- allow_any_instance_of(Octokit::Client).to receive(:pull_requests).and_return([pull_request, pull_request])
+ allow_any_instance_of(Octokit::Client).to receive(:pull_requests).and_return([pull_request, pull_request_missing_source_branch])
allow_any_instance_of(Octokit::Client).to receive(:issues_comments).and_raise(Octokit::NotFound)
allow_any_instance_of(Octokit::Client).to receive(:pull_requests_comments).and_return([])
allow_any_instance_of(Octokit::Client).to receive(:last_response).and_return(double(rels: { next: nil }))
allow_any_instance_of(Octokit::Client).to receive(:releases).and_return([release1, release2])
+
+ allow(importer).to receive(:restore_source_branch).and_raise(StandardError, 'Some error')
end
let(:label1) do
@@ -153,8 +155,6 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
}
end
- subject { described_class.new(project) }
-
it 'returns true' do
expect(subject.execute).to eq true
end
@@ -163,18 +163,19 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
expect { subject.execute }.not_to raise_error
end
- it 'stores error messages' do
+ it 'stores error messages', :unlimited_max_formatted_output_length do
error = {
message: 'The remote data could not be fully imported.',
errors: [
{ type: :label, url: "#{api_root}/repos/octocat/Hello-World/labels/bug", errors: "Validation failed: Title can't be blank, Title is invalid" },
+ { type: :pull_request, url: "#{api_root}/repos/octocat/Hello-World/pulls/1347", errors: 'Some error' },
{ type: :issue, url: "#{api_root}/repos/octocat/Hello-World/issues/1348", errors: "Validation failed: Title can't be blank" },
{ type: :issues_comments, errors: 'Octokit::NotFound' },
{ type: :wiki, errors: "Gitlab::Git::CommandError" }
]
}
- described_class.new(project).execute
+ importer.execute
expect(project.import_state.last_error).to eq error.to_json
end
@@ -182,8 +183,6 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
shared_examples 'Gitlab::LegacyGithubImport unit-testing' do
describe '#clean_up_restored_branches' do
- subject { described_class.new(project) }
-
before do
allow(gh_pull_request).to receive(:source_branch_exists?).at_least(:once) { false }
allow(gh_pull_request).to receive(:target_branch_exists?).at_least(:once) { false }
@@ -240,6 +239,16 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
}
end
+ let(:pull_request_missing_source_branch) do
+ pull_request.merge(
+ head: {
+ ref: 'missing',
+ repo: repository,
+ sha: RepoHelpers.another_sample_commit
+ }
+ )
+ end
+
let(:closed_pull_request) do
{
number: 1347,
@@ -264,8 +273,6 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
let(:api_root) { 'https://try.gitea.io/api/v1' }
let(:repo_root) { 'https://try.gitea.io' }
- subject { described_class.new(project) }
-
before do
project.update!(import_type: 'gitea', import_url: "#{repo_root}/foo/group/project.git")
end
diff --git a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb
index 17ecd183ac9..15624a0558e 100644
--- a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb
+++ b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb
@@ -20,9 +20,11 @@ RSpec.describe Gitlab::LegacyGithubImport::ProjectCreator do
before do
namespace.add_owner(user)
- expect_next_instance_of(Project) do |project|
+ allow_next_instance_of(Project) do |project|
allow(project).to receive(:add_import_job)
end
+
+ stub_application_setting(import_sources: ['github'])
end
describe '#execute' do
diff --git a/spec/lib/gitlab/legacy_github_import/user_formatter_spec.rb b/spec/lib/gitlab/legacy_github_import/user_formatter_spec.rb
index bc127f74e84..0844ab7eccc 100644
--- a/spec/lib/gitlab/legacy_github_import/user_formatter_spec.rb
+++ b/spec/lib/gitlab/legacy_github_import/user_formatter_spec.rb
@@ -5,14 +5,15 @@ require 'spec_helper'
RSpec.describe Gitlab::LegacyGithubImport::UserFormatter do
let(:client) { double }
let(:octocat) { { id: 123456, login: 'octocat', email: 'octocat@example.com' } }
+ let(:gitea_ghost) { { id: -1, login: 'Ghost', email: '' } }
- subject(:user) { described_class.new(client, octocat) }
+ describe '#gitlab_id' do
+ subject(:user) { described_class.new(client, octocat) }
- before do
- allow(client).to receive(:user).and_return(octocat)
- end
+ before do
+ allow(client).to receive(:user).and_return(octocat)
+ end
- describe '#gitlab_id' do
context 'when GitHub user is a GitLab user' do
it 'return GitLab user id when user associated their account with GitHub' do
gl_user = create(:omniauth_user, extern_uid: octocat[:id], provider: 'github')
@@ -51,4 +52,16 @@ RSpec.describe Gitlab::LegacyGithubImport::UserFormatter do
expect(user.gitlab_id).to be_nil
end
end
+
+ describe '.email' do
+ subject(:user) { described_class.new(client, gitea_ghost) }
+
+ before do
+ allow(client).to receive(:user).and_return(gitea_ghost)
+ end
+
+ it 'assigns a dummy email address when user is a Ghost gitea user' do
+ expect(subject.send(:email)).to eq described_class::GITEA_GHOST_EMAIL
+ end
+ end
end
diff --git a/spec/lib/gitlab/loggable_spec.rb b/spec/lib/gitlab/loggable_spec.rb
new file mode 100644
index 00000000000..8238e47014b
--- /dev/null
+++ b/spec/lib/gitlab/loggable_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Loggable, feature_category: :logging do
+ subject(:klass_instance) do
+ Class.new do
+ include Gitlab::Loggable
+
+ def self.name
+ 'MyTestClass'
+ end
+ end.new
+ end
+
+ describe '#build_structured_payload' do
+ it 'adds class and returns formatted json' do
+ expected = {
+ 'class' => 'MyTestClass',
+ 'message' => 'test'
+ }
+
+ expect(klass_instance.build_structured_payload(message: 'test')).to eq(expected)
+ end
+
+ it 'appends additional params and returns formatted json' do
+ expected = {
+ 'class' => 'MyTestClass',
+ 'message' => 'test',
+ 'extra_param' => 1
+ }
+
+ expect(klass_instance.build_structured_payload(message: 'test', extra_param: 1)).to eq(expected)
+ end
+
+ it 'does not raise an error in loggers when passed non-symbols' do
+ expected = {
+ 'class' => 'MyTestClass',
+ 'message' => 'test',
+ '["hello", "thing"]' => :world
+ }
+
+ payload = klass_instance.build_structured_payload(message: 'test', %w[hello thing] => :world)
+ expect(payload).to eq(expected)
+ expect { Gitlab::Export::Logger.info(payload) }.not_to raise_error
+ end
+
+ it 'handles anonymous classes' do
+ anonymous_klass_instance = Class.new { include Gitlab::Loggable }.new
+
+ expected = {
+ 'class' => '<Anonymous>',
+ 'message' => 'test'
+ }
+
+ expect(anonymous_klass_instance.build_structured_payload(message: 'test')).to eq(expected)
+ end
+
+ it 'handles duplicate keys' do
+ expected = {
+ 'class' => 'MyTestClass',
+ 'message' => 'test2'
+ }
+
+ expect(klass_instance.build_structured_payload(message: 'test', 'message' => 'test2')).to eq(expected)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/manifest_import/project_creator_spec.rb b/spec/lib/gitlab/manifest_import/project_creator_spec.rb
index 0ab5b277552..2d878e5496e 100644
--- a/spec/lib/gitlab/manifest_import/project_creator_spec.rb
+++ b/spec/lib/gitlab/manifest_import/project_creator_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ManifestImport::ProjectCreator do
+RSpec.describe Gitlab::ManifestImport::ProjectCreator, feature_category: :importers do
let(:group) { create(:group) }
let(:user) { create(:user) }
let(:repository) do
@@ -14,6 +14,8 @@ RSpec.describe Gitlab::ManifestImport::ProjectCreator do
before do
group.add_owner(user)
+
+ stub_application_setting(import_sources: ['manifest'])
end
subject { described_class.new(repository, group, user) }
diff --git a/spec/lib/gitlab/memory/watchdog/monitor/rss_memory_limit_spec.rb b/spec/lib/gitlab/memory/watchdog/monitor/rss_memory_limit_spec.rb
index 4780b1eba53..67d185fd2f1 100644
--- a/spec/lib/gitlab/memory/watchdog/monitor/rss_memory_limit_spec.rb
+++ b/spec/lib/gitlab/memory/watchdog/monitor/rss_memory_limit_spec.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
require 'fast_spec_helper'
+require 'prometheus/client'
require 'support/shared_examples/lib/gitlab/memory/watchdog/monitor_result_shared_examples'
-RSpec.describe Gitlab::Memory::Watchdog::Monitor::RssMemoryLimit do
+RSpec.describe Gitlab::Memory::Watchdog::Monitor::RssMemoryLimit, feature_category: :application_performance do
let(:max_rss_limit_gauge) { instance_double(::Prometheus::Client::Gauge) }
let(:memory_limit_bytes) { 2_097_152_000 }
let(:worker_memory_bytes) { 1_048_576_000 }
diff --git a/spec/lib/gitlab/metrics/boot_time_tracker_spec.rb b/spec/lib/gitlab/metrics/boot_time_tracker_spec.rb
index 8a17fa8dd2e..3175c0a6b32 100644
--- a/spec/lib/gitlab/metrics/boot_time_tracker_spec.rb
+++ b/spec/lib/gitlab/metrics/boot_time_tracker_spec.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
-RSpec.describe Gitlab::Metrics::BootTimeTracker do
+RSpec.describe Gitlab::Metrics::BootTimeTracker, feature_category: :metrics do
let(:logger) { double('logger') }
let(:gauge) { double('gauge') }
diff --git a/spec/lib/gitlab/metrics/dashboard/finder_spec.rb b/spec/lib/gitlab/metrics/dashboard/finder_spec.rb
index f922eff2980..d3cb9760052 100644
--- a/spec/lib/gitlab/metrics/dashboard/finder_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/finder_spec.rb
@@ -44,12 +44,6 @@ RSpec.describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store
it_behaves_like 'valid dashboard service response'
end
- context 'when the self-monitoring dashboard is specified' do
- let(:dashboard_path) { self_monitoring_dashboard_path }
-
- it_behaves_like 'valid dashboard service response'
- end
-
context 'when no dashboard is specified' do
let(:service_call) { described_class.find(project, user, environment: environment) }
@@ -180,36 +174,5 @@ RSpec.describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store
expect(all_dashboard_paths).to eq([project_dashboard2, k8s_pod_health_dashboard, project_dashboard1, system_dashboard])
end
end
-
- context 'when the project is self-monitoring' do
- let(:self_monitoring_dashboard) do
- {
- path: self_monitoring_dashboard_path,
- display_name: 'Overview',
- default: true,
- system_dashboard: true,
- out_of_the_box_dashboard: true
- }
- end
-
- let(:dashboard_path) { '.gitlab/dashboards/test.yml' }
- let(:project) { project_with_dashboard(dashboard_path) }
-
- before do
- stub_application_setting(self_monitoring_project_id: project.id)
- end
-
- it 'includes self-monitoring and project dashboards' do
- project_dashboard = {
- path: dashboard_path,
- display_name: 'test.yml',
- default: false,
- system_dashboard: false,
- out_of_the_box_dashboard: false
- }
-
- expect(all_dashboard_paths).to eq([self_monitoring_dashboard, project_dashboard])
- end
- end
end
end
diff --git a/spec/lib/gitlab/metrics/dashboard/service_selector_spec.rb b/spec/lib/gitlab/metrics/dashboard/service_selector_spec.rb
index b41b51f53c3..343596af5cf 100644
--- a/spec/lib/gitlab/metrics/dashboard/service_selector_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/service_selector_spec.rb
@@ -30,12 +30,6 @@ RSpec.describe Gitlab::Metrics::Dashboard::ServiceSelector do
end
end
- context 'when the path is for the self-monitoring dashboard' do
- let(:arguments) { { dashboard_path: self_monitoring_dashboard_path } }
-
- it { is_expected.to be Metrics::Dashboard::SelfMonitoringDashboardService }
- end
-
context 'when the embedded flag is provided' do
let(:arguments) { { embedded: true } }
diff --git a/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb b/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb
index 8a236f72a60..3cfdfafb0c5 100644
--- a/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter do
include GrafanaApiHelpers
- let_it_be(:namespace) { create(:namespace, name: 'foo') }
- let_it_be(:project) { create(:project, namespace: namespace, name: 'bar') }
+ let_it_be(:namespace) { create(:namespace, path: 'foo') }
+ let_it_be(:project) { create(:project, namespace: namespace, path: 'bar') }
describe '#transform!' do
let(:grafana_dashboard) { Gitlab::Json.parse(fixture_file('grafana/simplified_dashboard_response.json'), symbolize_names: true) }
diff --git a/spec/lib/gitlab/metrics/sidekiq_slis_spec.rb b/spec/lib/gitlab/metrics/sidekiq_slis_spec.rb
new file mode 100644
index 00000000000..eef9a9c79e6
--- /dev/null
+++ b/spec/lib/gitlab/metrics/sidekiq_slis_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Metrics::SidekiqSlis, feature_category: :error_budgets do
+ using RSpec::Parameterized::TableSyntax
+
+ describe ".initialize_slis!" do
+ let(:possible_labels) do
+ [
+ {
+ worker: "Projects::RecordTargetPlatformsWorker",
+ feature_category: "projects",
+ urgency: "low"
+ }
+ ]
+ end
+
+ it "initializes the apdex and error rate SLIs" do
+ expect(Gitlab::Metrics::Sli::Apdex).to receive(:initialize_sli).with(:sidekiq_execution, possible_labels)
+ expect(Gitlab::Metrics::Sli::ErrorRate).to receive(:initialize_sli).with(:sidekiq_execution, possible_labels)
+
+ described_class.initialize_slis!(possible_labels)
+ end
+ end
+
+ describe ".record_execution_apdex" do
+ where(:urgency, :duration, :success) do
+ "high" | 5 | true
+ "high" | 11 | false
+ "low" | 295 | true
+ "low" | 400 | false
+ "throttled" | 295 | true
+ "throttled" | 400 | false
+ "not_found" | 295 | true
+ "not_found" | 400 | false
+ end
+
+ with_them do
+ it "increments the apdex SLI with success based on urgency requirement" do
+ labels = { urgency: urgency }
+ expect(Gitlab::Metrics::Sli::Apdex[:sidekiq_execution]).to receive(:increment).with(
+ labels: labels,
+ success: success
+ )
+
+ described_class.record_execution_apdex(labels, duration)
+ end
+ end
+ end
+
+ describe ".record_execution_error" do
+ it "increments the error rate SLI with the given labels and error" do
+ labels = { urgency: :throttled }
+ error = StandardError.new("something went wrong")
+
+ expect(Gitlab::Metrics::Sli::ErrorRate[:sidekiq_execution]).to receive(:increment).with(
+ labels: labels,
+ error: error
+ )
+
+ described_class.record_execution_error(labels, error)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb b/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb
index 08437920e0c..54868bb6ca4 100644
--- a/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb
@@ -2,22 +2,25 @@
require 'spec_helper'
-RSpec.describe Gitlab::Metrics::Subscribers::ActionCable, :request_store do
+RSpec.describe Gitlab::Metrics::Subscribers::ActionCable, :request_store, feature_category: :application_performance do
let(:subscriber) { described_class.new }
let(:counter) { double(:counter) }
- let(:data) { { 'result' => { 'data' => { 'event' => 'updated' } } } }
+ let(:transmitted_bytes_counter) { double(:counter) }
let(:channel_class) { 'IssuesChannel' }
- let(:event) do
- double(
- :event,
- name: name,
- payload: payload
- )
+ let(:event) { double(:event, name: name, payload: payload) }
+
+ before do
+ allow(::Gitlab::Metrics).to receive(:counter).with(
+ :action_cable_single_client_transmissions_total, /transmit/
+ ).and_return(counter)
+ allow(::Gitlab::Metrics).to receive(:counter).with(
+ :action_cable_transmitted_bytes_total, /transmit/
+ ).and_return(transmitted_bytes_counter)
end
describe '#transmit' do
let(:name) { 'transmit.action_cable' }
- let(:via) { 'streamed from issues:Z2lkOi8vZs2l0bGFiL0lzc3VlLzQ0Ng' }
+ let(:via) { nil }
let(:payload) do
{
channel_class: channel_class,
@@ -26,25 +29,71 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActionCable, :request_store do
}
end
- it 'tracks the transmit event' do
- allow(::Gitlab::Metrics).to receive(:counter).with(
- :action_cable_single_client_transmissions_total, /transmit/
- ).and_return(counter)
+ let(:message_size) { ::Gitlab::Json.generate(data).bytesize }
- expect(counter).to receive(:increment)
+ context 'for transmissions initiated by Channel instance' do
+ let(:data) { {} }
+ let(:expected_labels) do
+ {
+ channel: channel_class,
+ broadcasting: nil,
+ caller: 'channel'
+ }
+ end
- subscriber.transmit(event)
+ it 'tracks the event with "caller" set to "channel"' do
+ expect(counter).to receive(:increment).with(expected_labels)
+ expect(transmitted_bytes_counter).to receive(:increment).with(expected_labels, message_size)
+
+ subscriber.transmit(event)
+ end
end
- it 'tracks size of payload as JSON' do
- allow(::Gitlab::Metrics).to receive(:histogram).with(
- :action_cable_transmitted_bytes, /transmit/
- ).and_return(counter)
- message_size = ::Gitlab::Json.generate(data).bytesize
+ context 'for transmissions initiated by GraphQL event subscriber' do
+ let(:via) { 'streamed from graphql-subscription:09ae595a-45c4-4ae0-b765-4e503203211d' }
+ let(:data) { { result: { 'data' => { 'issuableEpicUpdated' => '<GQL query result>' } } } }
+ let(:expected_labels) do
+ {
+ channel: channel_class,
+ broadcasting: 'graphql-event:issuableEpicUpdated',
+ caller: 'graphql-subscription'
+ }
+ end
+
+ it 'tracks the event with correct "caller" and "broadcasting"' do
+ expect(counter).to receive(:increment).with(expected_labels)
+ expect(transmitted_bytes_counter).to receive(:increment).with(expected_labels, message_size)
- expect(counter).to receive(:observe).with({ channel: channel_class, operation: 'event' }, message_size)
+ subscriber.transmit(event)
+ end
- subscriber.transmit(event)
+ it 'is indifferent to keys being symbols or strings in result payload' do
+ expect(counter).to receive(:increment).with(expected_labels)
+ expect(transmitted_bytes_counter).to receive(:increment).with(expected_labels, message_size)
+
+ event.payload[:data].deep_stringify_keys!
+
+ subscriber.transmit(event)
+ end
+ end
+
+ context 'when transmission is coming from unknown source' do
+ let(:via) { 'streamed from something else' }
+ let(:data) { {} }
+ let(:expected_labels) do
+ {
+ channel: channel_class,
+ broadcasting: nil,
+ caller: 'unknown'
+ }
+ end
+
+ it 'tracks the event with "caller" set to "unknown"' do
+ expect(counter).to receive(:increment).with(expected_labels)
+ expect(transmitted_bytes_counter).to receive(:increment).with(expected_labels, message_size)
+
+ subscriber.transmit(event)
+ end
end
end
@@ -55,7 +104,6 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActionCable, :request_store do
{ event: :updated }
end
- let(:broadcasting) { 'issues:Z2lkOi8vZ2l0bGFiL0lzc3VlLzQ0Ng' }
let(:payload) do
{
broadcasting: broadcasting,
@@ -64,14 +112,40 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActionCable, :request_store do
}
end
- it 'tracks the broadcast event' do
+ before do
allow(::Gitlab::Metrics).to receive(:counter).with(
:action_cable_broadcasts_total, /broadcast/
).and_return(counter)
+ end
- expect(counter).to receive(:increment)
+ context 'when broadcast is for a GraphQL event' do
+ let(:broadcasting) { 'graphql-event::issuableEpicUpdated:issuableId:Z2lkOi8vZ2l0bGFiL0lzc3VlLzM' }
+
+ it 'tracks the event with broadcasting set to event topic' do
+ expect(counter).to receive(:increment).with({ broadcasting: 'graphql-event:issuableEpicUpdated' })
+
+ subscriber.broadcast(event)
+ end
+ end
+
+ context 'when broadcast is for a GraphQL channel subscription' do
+ let(:broadcasting) { 'graphql-subscription:09ae595a-45c4-4ae0-b765-4e503203211d' }
+
+ it 'strips out subscription ID from broadcasting' do
+ expect(counter).to receive(:increment).with({ broadcasting: 'graphql-subscription' })
+
+ subscriber.broadcast(event)
+ end
+ end
+
+ context 'when broadcast is something else' do
+ let(:broadcasting) { 'unknown-topic' }
+
+ it 'tracks the event as "unknown"' do
+ expect(counter).to receive(:increment).with({ broadcasting: 'unknown' })
- subscriber.broadcast(event)
+ subscriber.broadcast(event)
+ end
end
end
diff --git a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
index 7ce5cbec18d..afb029a96cb 100644
--- a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
@@ -226,7 +226,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
# Emulate Marginalia pre-pending comments
def sql(query, comments: true)
- if comments && !%w[BEGIN COMMIT].include?(query)
+ if comments
"/*application:web,controller:badges,action:pipeline,correlation_id:01EYN39K9VMJC56Z7808N7RSRH*/ #{query}"
else
query
@@ -244,8 +244,9 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
'SQL' | 'UPDATE users SET admin = true WHERE id = 10' | true | true | false
'CACHE' | 'SELECT * FROM users WHERE id = 10' | true | false | true
'SCHEMA' | "SELECT attr.attname FROM pg_attribute attr INNER JOIN pg_constraint cons ON attr.attrelid = cons.conrelid AND attr.attnum = any(cons.conkey) WHERE cons.contype = 'p' AND cons.conrelid = '\"projects\"'::regclass" | false | false | false
- nil | 'BEGIN' | false | false | false
- nil | 'COMMIT' | false | false | false
+ 'TRANSACTION' | 'BEGIN' | false | false | false
+ 'TRANSACTION' | 'COMMIT' | false | false | false
+ 'TRANSACTION' | 'ROLLBACK' | false | false | false
end
with_them do
@@ -291,7 +292,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
# Emulate Marginalia pre-pending comments
def sql(query, comments: true)
- if comments && !%w[BEGIN COMMIT].include?(query)
+ if comments
"/*application:web,controller:badges,action:pipeline,correlation_id:01EYN39K9VMJC56Z7808N7RSRH*/ #{query}"
else
query
@@ -313,8 +314,9 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
'CACHE' | 'SELECT pg_last_wal_replay_lsn()::text AS location' | true | false | true | true
'CACHE' | 'SELECT * FROM users WHERE id = 10' | true | false | true | false
'SCHEMA' | "SELECT attr.attname FROM pg_attribute attr INNER JOIN pg_constraint cons ON attr.attrelid = cons.conrelid AND attr.attnum = any(cons.conkey) WHERE cons.contype = 'p' AND cons.conrelid = '\"projects\"'::regclass" | false | false | false | false
- nil | 'BEGIN' | false | false | false | false
- nil | 'COMMIT' | false | false | false | false
+ 'TRANSACTION' | 'BEGIN' | false | false | false | false
+ 'TRANSACTION' | 'COMMIT' | false | false | false | false
+ 'TRANSACTION' | 'ROLLBACK' | false | false | false | false
end
with_them do
diff --git a/spec/lib/gitlab/metrics/subscribers/external_http_spec.rb b/spec/lib/gitlab/metrics/subscribers/external_http_spec.rb
index e489ac97b9c..18a5d2c2c3f 100644
--- a/spec/lib/gitlab/metrics/subscribers/external_http_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/external_http_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Metrics::Subscribers::ExternalHttp, :request_store do
+RSpec.describe Gitlab::Metrics::Subscribers::ExternalHttp, :request_store, feature_category: :logging do
let(:transaction) { Gitlab::Metrics::WebTransaction.new({}) }
let(:subscriber) { described_class.new }
@@ -15,7 +15,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ExternalHttp, :request_store do
:event,
payload: {
method: 'POST', code: "200", duration: 0.321,
- scheme: 'https', host: 'gitlab.com', port: 80, path: '/api/v4/projects',
+ scheme: 'https', host: 'gitlab.com', port: 443, path: '/api/v4/projects',
query: 'current=true'
},
time: Time.current
@@ -95,6 +95,47 @@ RSpec.describe Gitlab::Metrics::Subscribers::ExternalHttp, :request_store do
expect(described_class.payload).to eql(external_http_count: 7, external_http_duration_s: 1.2)
end
end
+
+ context 'with multiple requests' do
+ let(:slow_requests) do
+ [
+ {
+ method: 'POST',
+ host: 'gitlab.com',
+ port: 80,
+ path: '/api/v4/projects/2/issues',
+ duration_s: 5.3
+ },
+ {
+ method: 'POST',
+ host: 'gitlab.com',
+ port: 443,
+ path: '/api/v4/projects',
+ duration_s: 0.321
+ }
+ ]
+ end
+
+ before do
+ stub_const("#{described_class}::MAX_SLOW_REQUESTS", 2)
+ stub_const("#{described_class}::THRESHOLD_SLOW_REQUEST_S", 0.01)
+
+ subscriber.request(event_1)
+ subscriber.request(event_2)
+ subscriber.request(event_3)
+ end
+
+ it 'returns a payload containing a limited set of slow requests' do
+ expect(described_class.payload).to eq(
+ external_http_count: 3,
+ external_http_duration_s: 5.741,
+ external_http_slow_requests: slow_requests
+ )
+ expect(described_class.top_slowest_requests).to eq(slow_requests)
+
+ expect(Gitlab::SafeRequestStore[:external_http_slow_requests].length).to eq(3)
+ end
+ end
end
describe '#request' do
@@ -153,7 +194,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ExternalHttp, :request_store do
expect(Gitlab::SafeRequestStore[:external_http_detail_store][0]).to match a_hash_including(
start: be_like_time(Time.current),
method: 'POST', code: "200", duration: 0.321,
- scheme: 'https', host: 'gitlab.com', port: 80, path: '/api/v4/projects',
+ scheme: 'https', host: 'gitlab.com', port: 443, path: '/api/v4/projects',
query: 'current=true', exception_object: nil,
backtrace: be_a(Array)
)
diff --git a/spec/lib/gitlab/metrics/subscribers/load_balancing_spec.rb b/spec/lib/gitlab/metrics/subscribers/load_balancing_spec.rb
index b401b7cc996..c2c3bb29b16 100644
--- a/spec/lib/gitlab/metrics/subscribers/load_balancing_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/load_balancing_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Metrics::Subscribers::LoadBalancing, :request_store, feature_category: :pods do
+RSpec.describe Gitlab::Metrics::Subscribers::LoadBalancing, :request_store, feature_category: :cell do
let(:subscriber) { described_class.new }
describe '#caught_up_replica_pick' do
diff --git a/spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb b/spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb
index 9f939d0d7d6..13965bf1244 100644
--- a/spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb
@@ -32,33 +32,6 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
end
end
- describe '#redis' do
- it 'accumulates per-request RackAttack cache usage' do
- freeze_time do
- subscriber.redis(
- ActiveSupport::Notifications::Event.new(
- 'redis.rack_attack', Time.current, Time.current + 1.second, '1', { operation: 'fetch' }
- )
- )
- subscriber.redis(
- ActiveSupport::Notifications::Event.new(
- 'redis.rack_attack', Time.current, Time.current + 2.seconds, '1', { operation: 'write' }
- )
- )
- subscriber.redis(
- ActiveSupport::Notifications::Event.new(
- 'redis.rack_attack', Time.current, Time.current + 3.seconds, '1', { operation: 'read' }
- )
- )
- end
-
- expect(Gitlab::SafeRequestStore[:rack_attack_instrumentation]).to eql(
- rack_attack_redis_count: 3,
- rack_attack_redis_duration_s: 6.0
- )
- end
- end
-
shared_examples 'log into auth logger' do
context 'when matched throttle does not require user information' do
let(:event) do
diff --git a/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb b/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb
index 59bfe2042fa..2d4c6d1cc56 100644
--- a/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb
@@ -6,13 +6,14 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
let(:env) { {} }
let(:transaction) { Gitlab::Metrics::WebTransaction.new(env) }
let(:subscriber) { described_class.new }
-
- let(:event) { double(:event, duration: 15.2, payload: { key: %w[a b c] }) }
+ let(:store) { 'Gitlab::CustomStore' }
+ let(:store_label) { 'CustomStore' }
+ let(:event) { double(:event, duration: 15.2, payload: { key: %w[a b c], store: store }) }
describe '#cache_read' do
it 'increments the cache_read duration' do
expect(subscriber).to receive(:observe)
- .with(:read, event.duration)
+ .with(:read, event)
subscriber.cache_read(event)
end
@@ -27,7 +28,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
let(:event) { double(:event, duration: 15.2, payload: { hit: true }) }
context 'when super operation is fetch' do
- let(:event) { double(:event, duration: 15.2, payload: { hit: true, super_operation: :fetch }) }
+ let(:event) { double(:event, duration: 15.2, payload: { hit: true, super_operation: :fetch, store: store }) }
it 'does not increment cache read miss total' do
expect(transaction).not_to receive(:increment)
@@ -39,7 +40,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
end
context 'with miss event' do
- let(:event) { double(:event, duration: 15.2, payload: { hit: false }) }
+ let(:event) { double(:event, duration: 15.2, payload: { hit: false, store: store }) }
it 'increments the cache_read_miss total' do
expect(transaction).to receive(:increment)
@@ -51,7 +52,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
end
context 'when super operation is fetch' do
- let(:event) { double(:event, duration: 15.2, payload: { hit: false, super_operation: :fetch }) }
+ let(:event) { double(:event, duration: 15.2, payload: { hit: false, super_operation: :fetch, store: store }) }
it 'does not increment cache read miss total' do
expect(transaction).not_to receive(:increment)
@@ -75,7 +76,9 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
it 'observes multi-key count' do
expect(transaction).to receive(:observe)
- .with(:gitlab_cache_read_multikey_count, event.payload[:key].size)
+ .with(:gitlab_cache_read_multikey_count,
+ event.payload[:key].size,
+ { store: store_label })
subject
end
@@ -92,7 +95,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
it 'observes read_multi duration' do
expect(subscriber).to receive(:observe)
- .with(:read_multi, event.duration)
+ .with(:read_multi, event)
subject
end
@@ -101,7 +104,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
describe '#cache_write' do
it 'observes write duration' do
expect(subscriber).to receive(:observe)
- .with(:write, event.duration)
+ .with(:write, event)
subscriber.cache_write(event)
end
@@ -110,7 +113,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
describe '#cache_delete' do
it 'observes delete duration' do
expect(subscriber).to receive(:observe)
- .with(:delete, event.duration)
+ .with(:delete, event)
subscriber.cache_delete(event)
end
@@ -119,7 +122,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
describe '#cache_exist?' do
it 'observes the exists duration' do
expect(subscriber).to receive(:observe)
- .with(:exists, event.duration)
+ .with(:exists, event)
subscriber.cache_exist?(event)
end
@@ -179,7 +182,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
it 'returns' do
expect(transaction).not_to receive(:increment)
- subscriber.observe(:foo, 15.2)
+ subscriber.observe(:foo, event)
end
end
@@ -192,17 +195,17 @@ RSpec.describe Gitlab::Metrics::Subscribers::RailsCache do
it 'observes cache metric' do
expect(subscriber.send(:metric_cache_operation_duration_seconds))
.to receive(:observe)
- .with({ operation: :delete }, event.duration / 1000.0)
+ .with({ operation: :delete, store: store_label }, event.duration / 1000.0)
- subscriber.observe(:delete, event.duration)
+ subscriber.observe(:delete, event)
end
it 'increments the operations total' do
expect(transaction)
.to receive(:increment)
- .with(:gitlab_cache_operations_total, 1, { operation: :delete })
+ .with(:gitlab_cache_operations_total, 1, { operation: :delete, store: store_label })
- subscriber.observe(:delete, event.duration)
+ subscriber.observe(:delete, event)
end
end
end
diff --git a/spec/lib/gitlab/middleware/compressed_json_spec.rb b/spec/lib/gitlab/middleware/compressed_json_spec.rb
index 1444e6a9881..5978b2422e0 100644
--- a/spec/lib/gitlab/middleware/compressed_json_spec.rb
+++ b/spec/lib/gitlab/middleware/compressed_json_spec.rb
@@ -49,21 +49,21 @@ RSpec.describe Gitlab::Middleware::CompressedJson do
end
end
- shared_examples 'handles non integer project ID' do
- context 'with a URL-encoded project ID' do
- let_it_be(:project_id) { 'gitlab-org%2fgitlab' }
+ shared_examples 'handles non integer ID' do
+ context 'with a URL-encoded ID' do
+ let(:id) { 'gitlab-org%2fgitlab' }
it_behaves_like 'decompress middleware'
end
- context 'with a non URL-encoded project ID' do
- let_it_be(:project_id) { '1/repository/files/api/v4' }
+ context 'with a non URL-encoded ID' do
+ let(:id) { '1/repository/files/api/v4' }
it_behaves_like 'passes input'
end
- context 'with a blank project ID' do
- let_it_be(:project_id) { '' }
+ context 'with a blank ID' do
+ let(:id) { '' }
it_behaves_like 'passes input'
end
@@ -116,44 +116,82 @@ RSpec.describe Gitlab::Middleware::CompressedJson do
end
context 'with project level endpoint' do
- let_it_be(:project_id) { 1 }
+ let(:id) { 1 }
context 'with npm advisory bulk url' do
- let(:path) { "/api/v4/projects/#{project_id}/packages/npm/-/npm/v1/security/advisories/bulk" }
+ let(:path) { "/api/v4/projects/#{id}/packages/npm/-/npm/v1/security/advisories/bulk" }
it_behaves_like 'decompress middleware'
include_context 'with relative url' do
- let(:path) { "#{relative_url_root}/api/v4/projects/#{project_id}/packages/npm/-/npm/v1/security/advisories/bulk" } # rubocop disable Layout/LineLength
+ let(:path) { "#{relative_url_root}/api/v4/projects/#{id}/packages/npm/-/npm/v1/security/advisories/bulk" } # rubocop disable Layout/LineLength
it_behaves_like 'decompress middleware'
end
- it_behaves_like 'handles non integer project ID'
+ it_behaves_like 'handles non integer ID'
end
context 'with npm quick audit url' do
- let(:path) { "/api/v4/projects/#{project_id}/packages/npm/-/npm/v1/security/audits/quick" }
+ let(:path) { "/api/v4/projects/#{id}/packages/npm/-/npm/v1/security/audits/quick" }
it_behaves_like 'decompress middleware'
include_context 'with relative url' do
- let(:path) { "#{relative_url_root}/api/v4/projects/#{project_id}/packages/npm/-/npm/v1/security/audits/quick" } # rubocop disable Layout/LineLength
+ let(:path) { "#{relative_url_root}/api/v4/projects/#{id}/packages/npm/-/npm/v1/security/audits/quick" } # rubocop disable Layout/LineLength
it_behaves_like 'decompress middleware'
end
- it_behaves_like 'handles non integer project ID'
+ it_behaves_like 'handles non integer ID'
end
end
end
+ context 'with group level endpoint' do
+ let(:id) { 1 }
+
+ context 'with npm advisory bulk url' do
+ let(:path) { "/api/v4/groups/#{id}/-/packages/npm/-/npm/v1/security/advisories/bulk" }
+
+ it_behaves_like 'decompress middleware'
+
+ include_context 'with relative url' do
+ let(:path) { "#{relative_url_root}/api/v4/groups/#{id}/-/packages/npm/-/npm/v1/security/advisories/bulk" } # rubocop disable Layout/LineLength
+
+ it_behaves_like 'decompress middleware'
+ end
+
+ it_behaves_like 'handles non integer ID'
+ end
+
+ context 'with npm quick audit url' do
+ let(:path) { "/api/v4/groups/#{id}/-/packages/npm/-/npm/v1/security/audits/quick" }
+
+ it_behaves_like 'decompress middleware'
+
+ include_context 'with relative url' do
+ let(:path) { "#{relative_url_root}/api/v4/groups/#{id}/-/packages/npm/-/npm/v1/security/audits/quick" } # rubocop disable Layout/LineLength
+
+ it_behaves_like 'decompress middleware'
+ end
+
+ it_behaves_like 'handles non integer ID'
+ end
+ end
+
context 'with some other route' do
let(:path) { '/api/projects/123' }
it_behaves_like 'passes input'
end
+ context 'with the wrong project path' do
+ let(:path) { '/api/v4/projects/123/-/packages/npm/-/npm/v1/security/advisories/bulk' }
+
+ it_behaves_like 'passes input'
+ end
+
context 'payload is too large' do
let(:body_limit) { Gitlab::Middleware::CompressedJson::MAXIMUM_BODY_SIZE }
let(:decompressed_input) { 'a' * (body_limit + 100) }
diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb
index aaa274e252d..83d4d3fb612 100644
--- a/spec/lib/gitlab/middleware/go_spec.rb
+++ b/spec/lib/gitlab/middleware/go_spec.rb
@@ -138,7 +138,7 @@ RSpec.describe Gitlab::Middleware::Go, feature_category: :source_code_management
context 'with a blacklisted ip' do
it 'returns forbidden' do
- expect(Gitlab::Auth).to receive(:find_for_git_client).and_raise(Gitlab::Auth::IpBlacklisted)
+ expect(Gitlab::Auth).to receive(:find_for_git_client).and_raise(Gitlab::Auth::IpBlocked)
response = go
expect(response[0]).to eq(403)
diff --git a/spec/lib/gitlab/middleware/multipart_spec.rb b/spec/lib/gitlab/middleware/multipart_spec.rb
index 294a5ee82ed..509a4bb921b 100644
--- a/spec/lib/gitlab/middleware/multipart_spec.rb
+++ b/spec/lib/gitlab/middleware/multipart_spec.rb
@@ -175,7 +175,7 @@ RSpec.describe Gitlab::Middleware::Multipart do
end
it 'raises an error' do
- expect { subject }.to raise_error(JWT::VerificationError, 'Signature verification raised')
+ expect { subject }.to raise_error(JWT::VerificationError, 'Signature verification failed')
end
end
@@ -191,7 +191,7 @@ RSpec.describe Gitlab::Middleware::Multipart do
end
it 'raises an error' do
- expect { subject }.to raise_error(JWT::VerificationError, 'Signature verification raised')
+ expect { subject }.to raise_error(JWT::VerificationError, 'Signature verification failed')
end
end
end
diff --git a/spec/lib/gitlab/middleware/request_context_spec.rb b/spec/lib/gitlab/middleware/request_context_spec.rb
index 6d5b581feaa..cd21209bcee 100644
--- a/spec/lib/gitlab/middleware/request_context_spec.rb
+++ b/spec/lib/gitlab/middleware/request_context_spec.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
require 'rack'
require 'request_store'
require_relative '../../../support/helpers/next_instance_of'
-RSpec.describe Gitlab::Middleware::RequestContext do
+RSpec.describe Gitlab::Middleware::RequestContext, feature_category: :application_instrumentation do
include NextInstanceOf
let(:app) { -> (env) {} }
@@ -55,6 +55,10 @@ RSpec.describe Gitlab::Middleware::RequestContext do
it 'sets the `request_start_time`' do
expect { subject }.to change { instance.request_start_time }.from(nil).to(Float)
end
+
+ it 'sets the `spam_params`' do
+ expect { subject }.to change { instance.spam_params }.from(nil).to(::Spam::SpamParams)
+ end
end
end
end
diff --git a/spec/lib/gitlab/monitor/demo_projects_spec.rb b/spec/lib/gitlab/monitor/demo_projects_spec.rb
index 262c78eb62e..6b0f855e38d 100644
--- a/spec/lib/gitlab/monitor/demo_projects_spec.rb
+++ b/spec/lib/gitlab/monitor/demo_projects_spec.rb
@@ -6,15 +6,13 @@ RSpec.describe Gitlab::Monitor::DemoProjects do
describe '#primary_keys' do
subject { described_class.primary_keys }
- it 'fetches primary_keys when on gitlab.com' do
- allow(Gitlab).to receive(:com?).and_return(true)
+ it 'fetches primary_keys when on SaaS', :saas do
allow(Gitlab).to receive(:staging?).and_return(false)
expect(subject).to eq(Gitlab::Monitor::DemoProjects::DOT_COM_IDS)
end
- it 'fetches primary_keys when on staging' do
- allow(Gitlab).to receive(:com?).and_return(true)
+ it 'fetches primary_keys when on staging', :saas do
allow(Gitlab).to receive(:staging?).and_return(true)
expect(subject).to eq(Gitlab::Monitor::DemoProjects::STAGING_IDS)
diff --git a/spec/lib/gitlab/multi_collection_paginator_spec.rb b/spec/lib/gitlab/multi_collection_paginator_spec.rb
index 080b3382684..25baa8913bf 100644
--- a/spec/lib/gitlab/multi_collection_paginator_spec.rb
+++ b/spec/lib/gitlab/multi_collection_paginator_spec.rb
@@ -5,6 +5,13 @@ require 'spec_helper'
RSpec.describe Gitlab::MultiCollectionPaginator do
subject(:paginator) { described_class.new(Project.all.order(:id), Group.all.order(:id), per_page: 3) }
+ it 'raises an error for invalid page size' do
+ expect { described_class.new(Project.all.order(:id), Group.all.order(:id), per_page: 0) }
+ .to raise_error(ArgumentError)
+ expect { described_class.new(Project.all.order(:id), Group.all.order(:id), per_page: -1) }
+ .to raise_error(ArgumentError)
+ end
+
it 'combines both collections' do
project = create(:project)
group = create(:group)
diff --git a/spec/lib/gitlab/nav/top_nav_menu_item_spec.rb b/spec/lib/gitlab/nav/top_nav_menu_item_spec.rb
index 6632a8106ca..1d3452a004a 100644
--- a/spec/lib/gitlab/nav/top_nav_menu_item_spec.rb
+++ b/spec/lib/gitlab/nav/top_nav_menu_item_spec.rb
@@ -14,7 +14,8 @@ RSpec.describe ::Gitlab::Nav::TopNavMenuItem, feature_category: :navigation do
view: 'view',
css_class: 'css_class',
data: {},
- emoji: 'smile'
+ partial: 'groups/some_view_partial_file',
+ component: '_some_component_used_as_a_trigger_for_frontend_dropdown_item_render_'
}
expect(described_class.build(**item)).to eq(item.merge(type: :item))
diff --git a/spec/lib/gitlab/net_http_adapter_spec.rb b/spec/lib/gitlab/net_http_adapter_spec.rb
index fdaf35be31e..cfb90578a4b 100644
--- a/spec/lib/gitlab/net_http_adapter_spec.rb
+++ b/spec/lib/gitlab/net_http_adapter_spec.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
+require 'net/http'
-RSpec.describe Gitlab::NetHttpAdapter do
+RSpec.describe Gitlab::NetHttpAdapter, feature_category: :api do
describe '#connect' do
let(:url) { 'https://example.org' }
let(:net_http_adapter) { described_class.new(url) }
diff --git a/spec/lib/gitlab/observability_spec.rb b/spec/lib/gitlab/observability_spec.rb
index 8068d2f8ec9..5082d193197 100644
--- a/spec/lib/gitlab/observability_spec.rb
+++ b/spec/lib/gitlab/observability_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Observability do
+RSpec.describe Gitlab::Observability, feature_category: :error_tracking do
describe '.observability_url' do
let(:gitlab_url) { 'https://example.com' }
@@ -31,29 +31,189 @@ RSpec.describe Gitlab::Observability do
end
end
- describe '.observability_enabled?' do
- let_it_be(:group) { build(:user) }
- let_it_be(:user) { build(:group) }
+ describe '.build_full_url' do
+ let_it_be(:group) { build_stubbed(:group, id: 123) }
+ let(:observability_url) { described_class.observability_url }
+
+ it 'returns the full observability url for the given params' do
+ url = described_class.build_full_url(group, '/foo?bar=baz', '/')
+ expect(url).to eq("https://observe.gitlab.com/-/123/foo?bar=baz")
+ end
+
+ it 'handles missing / from observability_path' do
+ url = described_class.build_full_url(group, 'foo?bar=baz', '/')
+ expect(url).to eq("https://observe.gitlab.com/-/123/foo?bar=baz")
+ end
+
+ it 'sanitises observability_path' do
+ url = described_class.build_full_url(group, "/test?groupId=<script>alert('attack!')</script>", '/')
+ expect(url).to eq("https://observe.gitlab.com/-/123/test?groupId=alert('attack!')")
+ end
+
+ context 'when observability_path is missing' do
+ it 'builds the url with the fallback_path' do
+ url = described_class.build_full_url(group, nil, '/fallback')
+ expect(url).to eq("https://observe.gitlab.com/-/123/fallback")
+ end
+
+ it 'defaults to / if fallback_path is also missing' do
+ url = described_class.build_full_url(group, nil, nil)
+ expect(url).to eq("https://observe.gitlab.com/-/123/")
+ end
+ end
+ end
+
+ describe '.embeddable_url' do
+ before do
+ stub_config_setting(url: "https://www.gitlab.com")
+ # Can't use build/build_stubbed as we want the routes to be generated as well
+ create(:group, path: 'test-path', id: 123)
+ end
+
+ context 'when URL is valid' do
+ where(:input, :expected) do
+ [
+ [
+ "https://www.gitlab.com/groups/test-path/-/observability/explore?observability_path=%2Fexplore%3FgroupId%3D14485840%26left%3D%255B%2522now-1h%2522,%2522now%2522,%2522new-sentry.gitlab.net%2522,%257B%257D%255D",
+ "https://observe.gitlab.com/-/123/explore?groupId=14485840&left=%5B%22now-1h%22,%22now%22,%22new-sentry.gitlab.net%22,%7B%7D%5D"
+ ],
+ [
+ "https://www.gitlab.com/groups/test-path/-/observability/explore?observability_path=/goto/foo",
+ "https://observe.gitlab.com/-/123/goto/foo"
+ ]
+ ]
+ end
+
+ with_them do
+ it 'returns an embeddable observability url' do
+ expect(described_class.embeddable_url(input)).to eq(expected)
+ end
+ end
+ end
+
+ context 'when URL is invalid' do
+ where(:input) do
+ [
+ # direct links to observe.gitlab.com
+ "https://observe.gitlab.com/-/123/explore",
+ 'https://observe.gitlab.com/v1/auth/start',
+
+ # invalid GitLab URL
+ "not a link",
+ "https://foo.bar/groups/test-path/-/observability/explore?observability_path=/explore",
+ "http://www.gitlab.com/groups/test-path/-/observability/explore?observability_path=/explore",
+ "https://www.gitlab.com:123/groups/test-path/-/observability/explore?observability_path=/explore",
+ "https://www.gitlab.com@example.com/groups/test-path/-/observability/explore?observability_path=/explore",
+ "https://www.gitlab.com/groups/test-path/-/observability/explore?observability_path=@example.com",
+
+ # invalid group/controller/actions
+ "https://www.gitlab.com/groups/INVALID_GROUP/-/observability/explore?observability_path=/explore",
+ "https://www.gitlab.com/groups/test-path/-/INVALID_CONTROLLER/explore?observability_path=/explore",
+ "https://www.gitlab.com/groups/test-path/-/observability/INVALID_ACTION?observability_path=/explore",
+
+ # invalid observablity path
+ "https://www.gitlab.com/groups/test-path/-/observability/explore",
+ "https://www.gitlab.com/groups/test-path/-/observability/explore?missing_observability_path=/explore",
+ "https://www.gitlab.com/groups/test-path/-/observability/explore?observability_path=/not_embeddable",
+ "https://www.gitlab.com/groups/test-path/-/observability/explore?observability_path=/datasources",
+ "https://www.gitlab.com/groups/test-path/-/observability/explore?observability_path=not a valid path"
+ ]
+ end
+
+ with_them do
+ it 'returns nil' do
+ expect(described_class.embeddable_url(input)).to be_nil
+ end
+ end
+
+ it 'returns nil if the path detection throws an error' do
+ test_url = "https://www.gitlab.com/groups/test-path/-/observability/explore"
+ allow(Rails.application.routes).to receive(:recognize_path).with(test_url) {
+ raise ActionController::RoutingError, 'test'
+ }
+ expect(described_class.embeddable_url(test_url)).to be_nil
+ end
+
+ it 'returns nil if parsing observaboility path throws an error' do
+ observability_path = 'some-path'
+ test_url = "https://www.gitlab.com/groups/test-path/-/observability/explore?observability_path=#{observability_path}"
+
+ allow(URI).to receive(:parse).and_call_original
+ allow(URI).to receive(:parse).with(observability_path) {
+ raise URI::InvalidURIError, 'test'
+ }
+
+ expect(described_class.embeddable_url(test_url)).to be_nil
+ end
+ end
+ end
+
+ describe '.allowed_for_action?' do
+ let(:group) { build_stubbed(:group) }
+ let(:user) { build_stubbed(:user) }
+
+ before do
+ allow(described_class).to receive(:allowed?).and_call_original
+ end
+
+ it 'returns false if action is nil' do
+ expect(described_class.allowed_for_action?(user, group, nil)).to eq(false)
+ end
+
+ describe 'allowed? calls' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:action, :permission) do
+ :foo | :admin_observability
+ :explore | :read_observability
+ :datasources | :admin_observability
+ :manage | :admin_observability
+ :dashboards | :read_observability
+ end
+
+ with_them do
+ it "calls allowed? with #{params[:permission]} when actions is #{params[:action]}" do
+ described_class.allowed_for_action?(user, group, action)
+ expect(described_class).to have_received(:allowed?).with(user, group, permission)
+ end
+ end
+ end
+ end
+
+ describe '.allowed?' do
+ let(:user) { build_stubbed(:user) }
+ let(:group) { build_stubbed(:group) }
+ let(:test_permission) { :read_observability }
+
+ before do
+ allow(Ability).to receive(:allowed?).and_return(false)
+ end
subject do
- described_class.observability_enabled?(user, group)
+ described_class.allowed?(user, group, test_permission)
end
- it 'checks if read_observability ability is allowed for the given user and group' do
+ it 'checks if ability is allowed for the given user and group' do
allow(Ability).to receive(:allowed?).and_return(true)
subject
- expect(Ability).to have_received(:allowed?).with(user, :read_observability, group)
+ expect(Ability).to have_received(:allowed?).with(user, test_permission, group)
end
- it 'returns true if the read_observability ability is allowed' do
+ it 'checks for admin_observability if permission is missing' do
+ described_class.allowed?(user, group)
+
+ expect(Ability).to have_received(:allowed?).with(user, :admin_observability, group)
+ end
+
+ it 'returns true if the ability is allowed' do
allow(Ability).to receive(:allowed?).and_return(true)
expect(subject).to eq(true)
end
- it 'returns false if the read_observability ability is not allowed' do
+ it 'returns false if the ability is not allowed' do
allow(Ability).to receive(:allowed?).and_return(false)
expect(subject).to eq(false)
@@ -64,5 +224,13 @@ RSpec.describe Gitlab::Observability do
expect(subject).to eq(false)
end
+
+ it 'returns false if group is missing' do
+ expect(described_class.allowed?(user, nil, :read_observability)).to eq(false)
+ end
+
+ it 'returns false if user is missing' do
+ expect(described_class.allowed?(nil, group, :read_observability)).to eq(false)
+ end
end
end
diff --git a/spec/lib/gitlab/octokit/middleware_spec.rb b/spec/lib/gitlab/octokit/middleware_spec.rb
index 5555990b113..f7063f2c4f2 100644
--- a/spec/lib/gitlab/octokit/middleware_spec.rb
+++ b/spec/lib/gitlab/octokit/middleware_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::Octokit::Middleware, feature_category: :importers do
let(:app) { double(:app) }
let(:middleware) { described_class.new(app) }
- shared_examples 'Public URL' do
+ shared_examples 'Allowed URL' do
it 'does not raise an error' do
expect(app).to receive(:call).with(env)
@@ -14,7 +14,7 @@ RSpec.describe Gitlab::Octokit::Middleware, feature_category: :importers do
end
end
- shared_examples 'Local URL' do
+ shared_examples 'Blocked URL' do
it 'raises an error' do
expect { middleware.call(env) }.to raise_error(Gitlab::UrlBlocker::BlockedUrlError)
end
@@ -24,7 +24,24 @@ RSpec.describe Gitlab::Octokit::Middleware, feature_category: :importers do
context 'when the URL is a public URL' do
let(:env) { { url: 'https://public-url.com' } }
- it_behaves_like 'Public URL'
+ it_behaves_like 'Allowed URL'
+
+ context 'with failed address check' do
+ before do
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+ allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError)
+ end
+
+ it_behaves_like 'Blocked URL'
+
+ context 'with disabled dns rebinding check' do
+ before do
+ stub_application_setting(dns_rebinding_protection_enabled: false)
+ end
+
+ it_behaves_like 'Allowed URL'
+ end
+ end
end
context 'when the URL is a localhost address' do
@@ -35,7 +52,7 @@ RSpec.describe Gitlab::Octokit::Middleware, feature_category: :importers do
stub_application_setting(allow_local_requests_from_web_hooks_and_services: false)
end
- it_behaves_like 'Local URL'
+ it_behaves_like 'Blocked URL'
end
context 'when localhost requests are allowed' do
@@ -43,7 +60,7 @@ RSpec.describe Gitlab::Octokit::Middleware, feature_category: :importers do
stub_application_setting(allow_local_requests_from_web_hooks_and_services: true)
end
- it_behaves_like 'Public URL'
+ it_behaves_like 'Allowed URL'
end
end
@@ -55,7 +72,7 @@ RSpec.describe Gitlab::Octokit::Middleware, feature_category: :importers do
stub_application_setting(allow_local_requests_from_web_hooks_and_services: false)
end
- it_behaves_like 'Local URL'
+ it_behaves_like 'Blocked URL'
end
context 'when local network requests are allowed' do
@@ -63,7 +80,7 @@ RSpec.describe Gitlab::Octokit::Middleware, feature_category: :importers do
stub_application_setting(allow_local_requests_from_web_hooks_and_services: true)
end
- it_behaves_like 'Public URL'
+ it_behaves_like 'Allowed URL'
end
end
diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb
index daef280dbaa..112fdb183ab 100644
--- a/spec/lib/gitlab/omniauth_initializer_spec.rb
+++ b/spec/lib/gitlab/omniauth_initializer_spec.rb
@@ -216,14 +216,6 @@ RSpec.describe Gitlab::OmniauthInitializer do
expect { subject.execute([hash_config]) }.to raise_error(NameError)
end
- it 'configures on_single_sign_out proc for cas3' do
- cas3_config = { 'name' => 'cas3', 'args' => {} }
-
- expect(devise_config).to receive(:omniauth).with(:cas3, { on_single_sign_out: an_instance_of(Proc) })
-
- subject.execute([cas3_config])
- end
-
it 'configures defaults for google_oauth2' do
google_config = {
'name' => 'google_oauth2',
diff --git a/spec/lib/gitlab/optimistic_locking_spec.rb b/spec/lib/gitlab/optimistic_locking_spec.rb
index 1d669573b74..34f197b5ddb 100644
--- a/spec/lib/gitlab/optimistic_locking_spec.rb
+++ b/spec/lib/gitlab/optimistic_locking_spec.rb
@@ -16,6 +16,19 @@ RSpec.describe Gitlab::OptimisticLocking do
describe '#retry_lock' do
let(:name) { 'optimistic_locking_spec' }
+ it 'does not change current_scope', :aggregate_failures do
+ instance = Class.new { include Gitlab::OptimisticLocking }.new
+ relation = pipeline.cancelable_statuses
+
+ expected_scope = Ci::Build.current_scope&.to_sql
+
+ instance.send(:retry_lock, relation, name: :test) do
+ expect(Ci::Build.current_scope&.to_sql).to eq(expected_scope)
+ end
+
+ expect(Ci::Build.current_scope&.to_sql).to eq(expected_scope)
+ end
+
context 'when state changed successfully without retries' do
subject do
described_class.retry_lock(pipeline, name: name) do |lock_subject|
diff --git a/spec/lib/gitlab/other_markup_spec.rb b/spec/lib/gitlab/other_markup_spec.rb
index 6b4b0e8fda6..74e2c5e26c1 100644
--- a/spec/lib/gitlab/other_markup_spec.rb
+++ b/spec/lib/gitlab/other_markup_spec.rb
@@ -35,8 +35,8 @@ RSpec.describe Gitlab::OtherMarkup do
end
it 'times out' do
- # expect twice because of timeout in SyntaxHighlightFilter
- expect(Gitlab::RenderTimeout).to receive(:timeout).twice.and_call_original
+ # expect 3 times because of timeout in SyntaxHighlightFilter and BlockquoteFenceFilter
+ expect(Gitlab::RenderTimeout).to receive(:timeout).exactly(3).times.and_call_original
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
instance_of(Timeout::Error),
project_id: context[:project].id, file_name: file_name,
diff --git a/spec/lib/gitlab/pages/random_domain_spec.rb b/spec/lib/gitlab/pages/random_domain_spec.rb
new file mode 100644
index 00000000000..978412bb72c
--- /dev/null
+++ b/spec/lib/gitlab/pages/random_domain_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pages::RandomDomain, feature_category: :pages do
+ let(:namespace_path) { 'namespace' }
+
+ subject(:generator) do
+ described_class.new(project_path: project_path, namespace_path: namespace_path)
+ end
+
+ RSpec.shared_examples 'random domain' do |domain|
+ it do
+ expect(SecureRandom)
+ .to receive(:hex)
+ .and_wrap_original do |_, size, _|
+ ('h' * size)
+ end
+
+ generated = generator.generate
+
+ expect(generated).to eq(domain)
+ expect(generated.length).to eq(63)
+ end
+ end
+
+ context 'when project path is less than 48 chars' do
+ let(:project_path) { 'p' }
+
+ it_behaves_like 'random domain', 'p-namespace-hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh'
+ end
+
+ context 'when project path is close to 48 chars' do
+ let(:project_path) { 'p' * 45 }
+
+ it_behaves_like 'random domain', 'ppppppppppppppppppppppppppppppppppppppppppppp-na-hhhhhhhhhhhhhh'
+ end
+
+ context 'when project path is larger than 48 chars' do
+ let(:project_path) { 'p' * 49 }
+
+ it_behaves_like 'random domain', 'pppppppppppppppppppppppppppppppppppppppppppppppp-hhhhhhhhhhhhhh'
+ end
+end
diff --git a/spec/lib/gitlab/pages/virtual_host_finder_spec.rb b/spec/lib/gitlab/pages/virtual_host_finder_spec.rb
new file mode 100644
index 00000000000..4b584a45503
--- /dev/null
+++ b/spec/lib/gitlab/pages/virtual_host_finder_spec.rb
@@ -0,0 +1,214 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pages::VirtualHostFinder, feature_category: :pages do
+ let_it_be(:project) { create(:project) }
+
+ before_all do
+ project.update_pages_deployment!(create(:pages_deployment, project: project))
+ end
+
+ it 'returns nil when host is empty' do
+ expect(described_class.new(nil).execute).to be_nil
+ expect(described_class.new('').execute).to be_nil
+ end
+
+ context 'when host is a pages custom domain host' do
+ let_it_be(:pages_domain) { create(:pages_domain, project: project) }
+
+ subject(:virtual_domain) { described_class.new(pages_domain.domain).execute }
+
+ context 'when there are no pages deployed for the project' do
+ before_all do
+ project.mark_pages_as_not_deployed
+ end
+
+ it 'returns nil' do
+ expect(virtual_domain).to be_nil
+ end
+ end
+
+ context 'when there are pages deployed for the project' do
+ before_all do
+ project.mark_pages_as_deployed
+ end
+
+ it 'returns the virual domain when there are pages deployed for the project' do
+ expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
+ expect(virtual_domain.cache_key).to match(/pages_domain_for_domain_#{pages_domain.id}_/)
+ expect(virtual_domain.lookup_paths.length).to eq(1)
+ expect(virtual_domain.lookup_paths.first.project_id).to eq(project.id)
+ end
+
+ context 'when :cache_pages_domain_api is disabled' do
+ before do
+ stub_feature_flags(cache_pages_domain_api: false)
+ end
+
+ it 'returns the virual domain when there are pages deployed for the project' do
+ expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
+ expect(virtual_domain.cache_key).to be_nil
+ expect(virtual_domain.lookup_paths.length).to eq(1)
+ expect(virtual_domain.lookup_paths.first.project_id).to eq(project.id)
+ end
+ end
+ end
+ end
+
+ context 'when host is a namespace domain' do
+ context 'when there are no pages deployed for the project' do
+ before_all do
+ project.mark_pages_as_not_deployed
+ end
+
+ it 'returns no result if the provided host is not subdomain of the Pages host' do
+ virtual_domain = described_class.new("#{project.namespace.path}.something.io").execute
+
+ expect(virtual_domain).to eq(nil)
+ end
+
+ it 'returns the virual domain with no lookup_paths' do
+ virtual_domain = described_class.new("#{project.namespace.path}.#{Settings.pages.host}").execute
+
+ expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
+ expect(virtual_domain.cache_key).to match(/pages_domain_for_namespace_#{project.namespace.id}_/)
+ expect(virtual_domain.lookup_paths.length).to eq(0)
+ end
+
+ context 'when :cache_pages_domain_api is disabled' do
+ before do
+ stub_feature_flags(cache_pages_domain_api: false)
+ end
+
+ it 'returns the virual domain with no lookup_paths' do
+ virtual_domain = described_class.new("#{project.namespace.path}.#{Settings.pages.host}".downcase).execute
+
+ expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
+ expect(virtual_domain.cache_key).to be_nil
+ expect(virtual_domain.lookup_paths.length).to eq(0)
+ end
+ end
+ end
+
+ context 'when there are pages deployed for the project' do
+ before_all do
+ project.mark_pages_as_deployed
+ project.namespace.update!(path: 'topNAMEspace')
+ end
+
+ it 'returns no result if the provided host is not subdomain of the Pages host' do
+ virtual_domain = described_class.new("#{project.namespace.path}.something.io").execute
+
+ expect(virtual_domain).to eq(nil)
+ end
+
+ it 'returns the virual domain when there are pages deployed for the project' do
+ virtual_domain = described_class.new("#{project.namespace.path}.#{Settings.pages.host}").execute
+
+ expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
+ expect(virtual_domain.cache_key).to match(/pages_domain_for_namespace_#{project.namespace.id}_/)
+ expect(virtual_domain.lookup_paths.length).to eq(1)
+ expect(virtual_domain.lookup_paths.first.project_id).to eq(project.id)
+ end
+
+ it 'finds domain with case-insensitive' do
+ virtual_domain = described_class.new("#{project.namespace.path}.#{Settings.pages.host.upcase}").execute
+
+ expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
+ expect(virtual_domain.cache_key).to match(/pages_domain_for_namespace_#{project.namespace.id}_/)
+ expect(virtual_domain.lookup_paths.length).to eq(1)
+ expect(virtual_domain.lookup_paths.first.project_id).to eq(project.id)
+ end
+
+ context 'when :cache_pages_domain_api is disabled' do
+ before_all do
+ stub_feature_flags(cache_pages_domain_api: false)
+ end
+
+ it 'returns the virual domain when there are pages deployed for the project' do
+ virtual_domain = described_class.new("#{project.namespace.path}.#{Settings.pages.host}").execute
+
+ expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
+ expect(virtual_domain.cache_key).to be_nil
+ expect(virtual_domain.lookup_paths.length).to eq(1)
+ expect(virtual_domain.lookup_paths.first.project_id).to eq(project.id)
+ end
+ end
+ end
+ end
+
+ context 'when host is a unique domain' do
+ before_all do
+ project.project_setting.update!(pages_unique_domain: 'unique-domain')
+ end
+
+ subject(:virtual_domain) { described_class.new("unique-domain.#{Settings.pages.host.upcase}").execute }
+
+ context 'when pages unique domain is enabled' do
+ before_all do
+ project.project_setting.update!(pages_unique_domain_enabled: true)
+ end
+
+ context 'when there are no pages deployed for the project' do
+ before_all do
+ project.mark_pages_as_not_deployed
+ end
+
+ it 'returns nil' do
+ expect(virtual_domain).to be_nil
+ end
+ end
+
+ context 'when there are pages deployed for the project' do
+ before_all do
+ project.mark_pages_as_deployed
+ end
+
+ it 'returns the virual domain when there are pages deployed for the project' do
+ expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
+ expect(virtual_domain.lookup_paths.length).to eq(1)
+ expect(virtual_domain.lookup_paths.first.project_id).to eq(project.id)
+ end
+
+ context 'when :cache_pages_domain_api is disabled' do
+ before do
+ stub_feature_flags(cache_pages_domain_api: false)
+ end
+
+ it 'returns the virual domain when there are pages deployed for the project' do
+ expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
+ expect(virtual_domain.lookup_paths.length).to eq(1)
+ expect(virtual_domain.lookup_paths.first.project_id).to eq(project.id)
+ end
+ end
+ end
+ end
+
+ context 'when pages unique domain is disabled' do
+ before_all do
+ project.project_setting.update!(pages_unique_domain_enabled: false)
+ end
+
+ context 'when there are no pages deployed for the project' do
+ before_all do
+ project.mark_pages_as_not_deployed
+ end
+
+ it 'returns nil' do
+ expect(virtual_domain).to be_nil
+ end
+ end
+
+ context 'when there are pages deployed for the project' do
+ before_all do
+ project.mark_pages_as_deployed
+ end
+
+ it 'returns nil' do
+ expect(virtual_domain).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/patch/draw_route_spec.rb b/spec/lib/gitlab/patch/draw_route_spec.rb
index 4d1c7bf9fcf..d983f6f15bb 100644
--- a/spec/lib/gitlab/patch/draw_route_spec.rb
+++ b/spec/lib/gitlab/patch/draw_route_spec.rb
@@ -20,8 +20,10 @@ RSpec.describe Gitlab::Patch::DrawRoute do
it 'evaluates CE only route' do
subject.draw(:help)
+ route_file_path = subject.route_path('config/routes/help.rb')
+
expect(subject).to have_received(:instance_eval)
- .with(File.read(subject.route_path('config/routes/help.rb')))
+ .with(File.read(route_file_path), route_file_path)
.once
expect(subject).to have_received(:instance_eval)
diff --git a/spec/lib/gitlab/patch/node_loader_spec.rb b/spec/lib/gitlab/patch/node_loader_spec.rb
new file mode 100644
index 00000000000..000083fc6d0
--- /dev/null
+++ b/spec/lib/gitlab/patch/node_loader_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Patch::NodeLoader, feature_category: :redis do
+ using RSpec::Parameterized::TableSyntax
+
+ describe '#fetch_node_info' do
+ let(:redis) { double(:redis) } # rubocop:disable RSpec/VerifiedDoubles
+
+ # rubocop:disable Naming/InclusiveLanguage
+ where(:case_name, :args, :value) do
+ [
+ [
+ 'when only ip address is present',
+ "07c37df 127.0.0.1:30004@31004 slave e7d1eec 0 1426238317239 4 connected
+67ed2db 127.0.0.1:30002@31002 master - 0 1426238316232 2 connected 5461-10922
+292f8b3 127.0.0.1:30003@31003 master - 0 1426238318243 3 connected 10923-16383
+6ec2392 127.0.0.1:30005@31005 slave 67ed2db 0 1426238316232 5 connected
+824fe11 127.0.0.1:30006@31006 slave 292f8b3 0 1426238317741 6 connected
+e7d1eec 127.0.0.1:30001@31001 myself,master - 0 0 1 connected 0-5460",
+ {
+ '127.0.0.1:30004' => 'slave', '127.0.0.1:30002' => 'master', '127.0.0.1:30003' => 'master',
+ '127.0.0.1:30005' => 'slave', '127.0.0.1:30006' => 'slave', '127.0.0.1:30001' => 'master'
+ }
+ ],
+ [
+ 'when hostname is present',
+ "07c37df 127.0.0.1:30004@31004,host1 slave e7d1eec 0 1426238317239 4 connected
+67ed2db 127.0.0.1:30002@31002,host2 master - 0 1426238316232 2 connected 5461-10922
+292f8b3 127.0.0.1:30003@31003,host3 master - 0 1426238318243 3 connected 10923-16383
+6ec2392 127.0.0.1:30005@31005,host4 slave 67ed2db 0 1426238316232 5 connected
+824fe11 127.0.0.1:30006@31006,host5 slave 292f8b3 0 1426238317741 6 connected
+e7d1eec 127.0.0.1:30001@31001,host6 myself,master - 0 0 1 connected 0-5460",
+ {
+ 'host1:30004' => 'slave', 'host2:30002' => 'master', 'host3:30003' => 'master',
+ 'host4:30005' => 'slave', 'host5:30006' => 'slave', 'host6:30001' => 'master'
+ }
+ ],
+ [
+ 'when auxiliary fields are present',
+ "07c37df 127.0.0.1:30004@31004,,shard-id=69bc slave e7d1eec 0 1426238317239 4 connected
+67ed2db 127.0.0.1:30002@31002,,shard-id=114f master - 0 1426238316232 2 connected 5461-10922
+292f8b3 127.0.0.1:30003@31003,,shard-id=fdb3 master - 0 1426238318243 3 connected 10923-16383
+6ec2392 127.0.0.1:30005@31005,,shard-id=114f slave 67ed2db 0 1426238316232 5 connected
+824fe11 127.0.0.1:30006@31006,,shard-id=fdb3 slave 292f8b3 0 1426238317741 6 connected
+e7d1eec 127.0.0.1:30001@31001,,shard-id=69bc myself,master - 0 0 1 connected 0-5460",
+ {
+ '127.0.0.1:30004' => 'slave', '127.0.0.1:30002' => 'master', '127.0.0.1:30003' => 'master',
+ '127.0.0.1:30005' => 'slave', '127.0.0.1:30006' => 'slave', '127.0.0.1:30001' => 'master'
+ }
+ ],
+ [
+ 'when hostname and auxiliary fields are present',
+ "07c37df 127.0.0.1:30004@31004,host1,shard-id=69bc slave e7d1eec 0 1426238317239 4 connected
+67ed2db 127.0.0.1:30002@31002,host2,shard-id=114f master - 0 1426238316232 2 connected 5461-10922
+292f8b3 127.0.0.1:30003@31003,host3,shard-id=fdb3 master - 0 1426238318243 3 connected 10923-16383
+6ec2392 127.0.0.1:30005@31005,host4,shard-id=114f slave 67ed2db 0 1426238316232 5 connected
+824fe11 127.0.0.1:30006@31006,host5,shard-id=fdb3 slave 292f8b3 0 1426238317741 6 connected
+e7d1eec 127.0.0.1:30001@31001,host6,shard-id=69bc myself,master - 0 0 1 connected 0-5460",
+ {
+ 'host1:30004' => 'slave', 'host2:30002' => 'master', 'host3:30003' => 'master',
+ 'host4:30005' => 'slave', 'host5:30006' => 'slave', 'host6:30001' => 'master'
+ }
+ ]
+ ]
+ end
+ # rubocop:enable Naming/InclusiveLanguage
+
+ with_them do
+ before do
+ allow(redis).to receive(:call).with([:cluster, :nodes]).and_return(args)
+ end
+
+ it do
+ expect(Redis::Cluster::NodeLoader.load_flags([redis])).to eq(value)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index 0a647befb50..718b20c59ed 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -177,7 +177,12 @@ RSpec.describe Gitlab::PathRegex do
missing_words: missing_words, additional_words: additional_words)
end
- expect(described_class::TOP_LEVEL_ROUTES)
+ # We have to account for routes that are added by gems into the RAILS_ENV=test only.
+ test_only_top_level_routes = [
+ '_system_test_entrypoint' # added by the view_component gem
+ ]
+
+ expect(described_class::TOP_LEVEL_ROUTES + test_only_top_level_routes)
.to contain_exactly(*top_level_words), failure_block
end
diff --git a/spec/lib/gitlab/phabricator_import/cache/map_spec.rb b/spec/lib/gitlab/phabricator_import/cache/map_spec.rb
deleted file mode 100644
index 157b3ca56c9..00000000000
--- a/spec/lib/gitlab/phabricator_import/cache/map_spec.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Cache::Map, :clean_gitlab_redis_cache do
- let_it_be(:project) { create(:project) }
-
- let(:redis) { Gitlab::Redis::Cache }
-
- subject(:map) { described_class.new(project) }
-
- describe '#get_gitlab_model' do
- it 'returns nil if there was nothing cached for the phabricator id' do
- expect(map.get_gitlab_model('does not exist')).to be_nil
- end
-
- it 'returns the object if it was set in redis' do
- issue = create(:issue, project: project)
- set_in_redis('exists', issue)
-
- expect(map.get_gitlab_model('exists')).to eq(issue)
- end
-
- it 'extends the TTL for the cache key' do
- set_in_redis('extend', create(:issue, project: project)) do |redis|
- redis.expire(cache_key('extend'), 10.seconds.to_i)
- end
-
- map.get_gitlab_model('extend')
-
- ttl = redis.with { |redis| redis.ttl(cache_key('extend')) }
-
- expect(ttl).to be > 10.seconds
- end
-
- it 'sets the object in redis once if a block was given and nothing was cached' do
- issue = create(:issue, project: project)
-
- expect(map.get_gitlab_model('does not exist') { issue }).to eq(issue)
-
- expect { |b| map.get_gitlab_model('does not exist', &b) }
- .not_to yield_control
- end
-
- it 'does not cache `nil` objects' do
- expect(map).not_to receive(:set_gitlab_model)
-
- map.get_gitlab_model('does not exist') { nil }
- end
- end
-
- describe '#set_gitlab_model' do
- around do |example|
- freeze_time { example.run }
- end
-
- it 'sets the class and id in redis with a ttl' do
- issue = create(:issue, project: project)
-
- map.set_gitlab_model(issue, 'it is set')
-
- set_data, ttl = redis.with do |redis|
- redis.pipelined do |p|
- p.mapped_hmget(cache_key('it is set'), :classname, :database_id)
- p.ttl(cache_key('it is set'))
- end
- end
-
- expect(set_data).to eq({ classname: 'Issue', database_id: issue.id.to_s })
- expect(ttl).to be_within(1.second).of(Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION)
- end
- end
-
- def set_in_redis(key, object)
- redis.with do |redis|
- redis.mapped_hmset(cache_key(key),
- { classname: object.class, database_id: object.id })
- yield(redis) if block_given?
- end
- end
-
- def cache_key(phabricator_id)
- subject.__send__(:cache_key_for_phabricator_id, phabricator_id)
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/conduit/client_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/client_spec.rb
deleted file mode 100644
index dad349f3255..00000000000
--- a/spec/lib/gitlab/phabricator_import/conduit/client_spec.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Conduit::Client do
- let(:client) do
- described_class.new('https://see-ya-later.phabricator', 'api-token')
- end
-
- describe '#get' do
- it 'performs and parses a request' do
- params = { some: 'extra', values: %w[are passed] }
- stub_valid_request(params)
-
- response = client.get('test', params: params)
-
- expect(response).to be_a(Gitlab::PhabricatorImport::Conduit::Response)
- expect(response).to be_success
- end
-
- it 'wraps request errors in an `ApiError`' do
- stub_timeout
-
- expect { client.get('test') }.to raise_error(Gitlab::PhabricatorImport::Conduit::ApiError)
- end
-
- it 'raises response error' do
- stub_error_response
-
- expect { client.get('test') }
- .to raise_error(Gitlab::PhabricatorImport::Conduit::ResponseError, /has the wrong length/)
- end
- end
-
- def stub_valid_request(params = {})
- WebMock.stub_request(
- :get, 'https://see-ya-later.phabricator/api/test'
- ).with(
- body: CGI.unescape(params.reverse_merge('api.token' => 'api-token').to_query)
- ).and_return(
- status: 200,
- body: fixture_file('phabricator_responses/maniphest.search.json')
- )
- end
-
- def stub_timeout
- WebMock.stub_request(
- :get, 'https://see-ya-later.phabricator/api/test'
- ).to_timeout
- end
-
- def stub_error_response
- WebMock.stub_request(
- :get, 'https://see-ya-later.phabricator/api/test'
- ).and_return(
- status: 200,
- body: fixture_file('phabricator_responses/auth_failed.json')
- )
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/conduit/maniphest_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/maniphest_spec.rb
deleted file mode 100644
index e655a39a28d..00000000000
--- a/spec/lib/gitlab/phabricator_import/conduit/maniphest_spec.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Conduit::Maniphest do
- let(:maniphest) do
- described_class.new(phabricator_url: 'https://see-ya-later.phabricator', api_token: 'api-token')
- end
-
- describe '#tasks' do
- let(:fake_client) { double('Phabricator client') }
-
- before do
- allow(maniphest).to receive(:client).and_return(fake_client)
- end
-
- it 'calls the api with the correct params' do
- expected_params = {
- after: '123',
- attachments: {
- projects: 1, subscribers: 1, columns: 1
- }
- }
-
- expect(fake_client).to receive(:get).with('maniphest.search',
- params: expected_params)
-
- maniphest.tasks(after: '123')
- end
-
- it 'returns a parsed response' do
- response = Gitlab::PhabricatorImport::Conduit::Response
- .new(fixture_file('phabricator_responses/maniphest.search.json'))
-
- allow(fake_client).to receive(:get).and_return(response)
-
- expect(maniphest.tasks).to be_a(Gitlab::PhabricatorImport::Conduit::TasksResponse)
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/conduit/response_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/response_spec.rb
deleted file mode 100644
index a444e7fdf47..00000000000
--- a/spec/lib/gitlab/phabricator_import/conduit/response_spec.rb
+++ /dev/null
@@ -1,79 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Conduit::Response do
- let(:response) { described_class.new(Gitlab::Json.parse(fixture_file('phabricator_responses/maniphest.search.json'))) }
- let(:error_response) { described_class.new(Gitlab::Json.parse(fixture_file('phabricator_responses/auth_failed.json'))) }
-
- describe '.parse!' do
- it 'raises a ResponseError if the http response was not successfull' do
- fake_response = double(:http_response, success?: false, status: 401)
-
- expect { described_class.parse!(fake_response) }
- .to raise_error(Gitlab::PhabricatorImport::Conduit::ResponseError, /responded with 401/)
- end
-
- it 'raises a ResponseError if the response contained a Phabricator error' do
- fake_response = double(:http_response,
- success?: true,
- status: 200,
- body: fixture_file('phabricator_responses/auth_failed.json'))
-
- expect { described_class.parse!(fake_response) }
- .to raise_error(Gitlab::PhabricatorImport::Conduit::ResponseError, /ERR-INVALID-AUTH: API token/)
- end
-
- it 'raises a ResponseError if JSON parsing failed' do
- fake_response = double(:http_response,
- success?: true,
- status: 200,
- body: 'This is no JSON')
-
- expect { described_class.parse!(fake_response) }
- .to raise_error(Gitlab::PhabricatorImport::Conduit::ResponseError, /unexpected character/)
- end
-
- it 'returns a parsed response for valid input' do
- fake_response = double(:http_response,
- success?: true,
- status: 200,
- body: fixture_file('phabricator_responses/maniphest.search.json'))
-
- expect(described_class.parse!(fake_response)).to be_a(described_class)
- end
- end
-
- describe '#success?' do
- it { expect(response).to be_success }
- it { expect(error_response).not_to be_success }
- end
-
- describe '#error_code' do
- it { expect(error_response.error_code).to eq('ERR-INVALID-AUTH') }
- it { expect(response.error_code).to be_nil }
- end
-
- describe '#error_info' do
- it 'returns the correct error info' do
- expected_message = 'API token "api-token" has the wrong length. API tokens should be 32 characters long.'
-
- expect(error_response.error_info).to eq(expected_message)
- end
-
- it { expect(response.error_info).to be_nil }
- end
-
- describe '#data' do
- it { expect(error_response.data).to be_nil }
- it { expect(response.data).to be_an(Array) }
- end
-
- describe '#pagination' do
- it { expect(error_response.pagination).to be_nil }
-
- it 'builds the pagination correctly' do
- expect(response.pagination).to be_a(Gitlab::PhabricatorImport::Conduit::Pagination)
- expect(response.pagination.next_page).to eq('284')
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb
deleted file mode 100644
index 4e56dead5c0..00000000000
--- a/spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Conduit::TasksResponse do
- let(:conduit_response) do
- Gitlab::PhabricatorImport::Conduit::Response
- .new(Gitlab::Json.parse(fixture_file('phabricator_responses/maniphest.search.json')))
- end
-
- subject(:response) { described_class.new(conduit_response) }
-
- describe '#pagination' do
- it 'delegates to the conduit reponse' do
- expect(response.pagination).to eq(conduit_response.pagination)
- end
- end
-
- describe '#tasks' do
- it 'builds the correct tasks representation' do
- tasks = response.tasks
-
- titles = tasks.map(&:issue_attributes).map { |attrs| attrs[:title] }
-
- expect(titles).to contain_exactly('Things are slow', 'Things are broken')
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/conduit/user_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/user_spec.rb
deleted file mode 100644
index d38421c9405..00000000000
--- a/spec/lib/gitlab/phabricator_import/conduit/user_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Conduit::User do
- let(:user_client) do
- described_class.new(phabricator_url: 'https://see-ya-later.phabricator', api_token: 'api-token')
- end
-
- describe '#users' do
- let(:fake_client) { double('Phabricator client') }
-
- before do
- allow(user_client).to receive(:client).and_return(fake_client)
- end
-
- it 'calls the api with the correct params' do
- expected_params = {
- constraints: { phids: %w[phid-1 phid-2] }
- }
-
- expect(fake_client).to receive(:get).with('user.search',
- params: expected_params)
-
- user_client.users(%w[phid-1 phid-2])
- end
-
- it 'returns an array of parsed responses' do
- response = Gitlab::PhabricatorImport::Conduit::Response
- .new(fixture_file('phabricator_responses/user.search.json'))
-
- allow(fake_client).to receive(:get).and_return(response)
-
- expect(user_client.users(%w[some phids])).to match_array([an_instance_of(Gitlab::PhabricatorImport::Conduit::UsersResponse)])
- end
-
- it 'performs multiple requests if more phids than the maximum page size are passed' do
- stub_const('Gitlab::PhabricatorImport::Conduit::User::MAX_PAGE_SIZE', 1)
- first_params = { constraints: { phids: ['phid-1'] } }
- second_params = { constraints: { phids: ['phid-2'] } }
-
- expect(fake_client).to receive(:get).with('user.search',
- params: first_params).once
- expect(fake_client).to receive(:get).with('user.search',
- params: second_params).once
-
- user_client.users(%w[phid-1 phid-2])
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb
deleted file mode 100644
index ebbb2c0598c..00000000000
--- a/spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Conduit::UsersResponse do
- let(:conduit_response) do
- Gitlab::PhabricatorImport::Conduit::Response
- .new(Gitlab::Json.parse(fixture_file('phabricator_responses/user.search.json')))
- end
-
- subject(:response) { described_class.new(conduit_response) }
-
- describe '#users' do
- it 'builds the correct users representation' do
- tasks = response.users
-
- usernames = tasks.map(&:username)
-
- expect(usernames).to contain_exactly('jane', 'john')
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/importer_spec.rb b/spec/lib/gitlab/phabricator_import/importer_spec.rb
deleted file mode 100644
index e78024c35c1..00000000000
--- a/spec/lib/gitlab/phabricator_import/importer_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Importer do
- it { expect(described_class).to be_async }
-
- it "acts like it's importing repositories" do
- expect(described_class).to be_imports_repository
- end
-
- describe '#execute' do
- let(:project) { create(:project, :import_scheduled) }
-
- subject(:importer) { described_class.new(project) }
-
- it 'sets a custom jid that will be kept up to date' do
- expect { importer.execute }.to change { project.import_state.reload.jid }
- end
-
- it 'starts importing tasks' do
- expect(Gitlab::PhabricatorImport::ImportTasksWorker).to receive(:schedule).with(project.id)
-
- importer.execute
- end
-
- it 'marks the import as failed when something goes wrong' do
- allow(importer).to receive(:schedule_first_tasks_page).and_raise('Stuff is broken')
-
- importer.execute
-
- expect(project.import_state).to be_failed
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/issues/importer_spec.rb b/spec/lib/gitlab/phabricator_import/issues/importer_spec.rb
deleted file mode 100644
index 63ba575aea3..00000000000
--- a/spec/lib/gitlab/phabricator_import/issues/importer_spec.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Issues::Importer do
- let(:project) { create(:project) }
-
- let(:response) do
- Gitlab::PhabricatorImport::Conduit::TasksResponse.new(
- Gitlab::PhabricatorImport::Conduit::Response
- .new(Gitlab::Json.parse(fixture_file('phabricator_responses/maniphest.search.json')))
- )
- end
-
- subject(:importer) { described_class.new(project, nil) }
-
- before do
- client = instance_double(Gitlab::PhabricatorImport::Conduit::Maniphest)
- allow(client).to receive(:tasks).and_return(response)
- allow(importer).to receive(:client).and_return(client)
- end
-
- describe '#execute' do
- it 'imports each task in the response' do
- response.tasks.each do |task|
- task_importer = instance_double(Gitlab::PhabricatorImport::Issues::TaskImporter)
-
- expect(task_importer).to receive(:execute)
- expect(Gitlab::PhabricatorImport::Issues::TaskImporter)
- .to receive(:new).with(project, task)
- .and_return(task_importer)
- end
-
- importer.execute
- end
-
- context 'stubbed task import' do
- before do
- # Stub out the actual importing so we don't perform aditional requests
- expect_next_instance_of(Gitlab::PhabricatorImport::Issues::TaskImporter) do |task_importer|
- allow(task_importer).to receive(:execute)
- end.at_least(1)
- end
-
- it 'schedules the next batch if there is one' do
- expect(Gitlab::PhabricatorImport::ImportTasksWorker)
- .to receive(:schedule).with(project.id, response.pagination.next_page)
-
- importer.execute
- end
-
- it 'does not reschedule when there is no next page' do
- allow(response.pagination).to receive(:has_next_page?).and_return(false)
-
- expect(Gitlab::PhabricatorImport::ImportTasksWorker)
- .not_to receive(:schedule)
-
- importer.execute
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/issues/task_importer_spec.rb b/spec/lib/gitlab/phabricator_import/issues/task_importer_spec.rb
deleted file mode 100644
index 0539bacba44..00000000000
--- a/spec/lib/gitlab/phabricator_import/issues/task_importer_spec.rb
+++ /dev/null
@@ -1,83 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Issues::TaskImporter do
- let_it_be(:project) { create(:project) }
-
- let(:task) do
- Gitlab::PhabricatorImport::Representation::Task.new(
- {
- 'phid' => 'the-phid',
- 'fields' => {
- 'name' => 'Title',
- 'description' => {
- 'raw' => '# This is markdown\n it can contain more text.'
- },
- 'authorPHID' => 'PHID-USER-456',
- 'ownerPHID' => 'PHID-USER-123',
- 'dateCreated' => '1518688921',
- 'dateClosed' => '1518789995'
- }
- }
- )
- end
-
- subject(:importer) { described_class.new(project, task) }
-
- describe '#execute' do
- let(:fake_user_finder) { instance_double(Gitlab::PhabricatorImport::UserFinder) }
-
- before do
- allow(fake_user_finder).to receive(:find)
- allow(importer).to receive(:user_finder).and_return(fake_user_finder)
- end
-
- it 'creates the issue with the expected attributes' do
- issue = importer.execute
-
- expect(issue.project).to eq(project)
- expect(issue).to be_persisted
- expect(issue.author).to eq(User.ghost)
- expect(issue.title).to eq('Title')
- expect(issue.description).to eq('# This is markdown\n it can contain more text.')
- expect(issue).to be_closed
- expect(issue.created_at).to eq(Time.at(1518688921))
- expect(issue.closed_at).to eq(Time.at(1518789995))
- end
-
- it 'does not recreate the issue when called multiple times' do
- expect { importer.execute }
- .to change { project.issues.reload.size }.from(0).to(1)
- expect { importer.execute }
- .not_to change { project.issues.reload.size }
- end
-
- it 'does not trigger a save when the object did not change' do
- existing_issue = create(:issue,
- task.issue_attributes.merge(author: User.ghost))
- allow(importer).to receive(:issue).and_return(existing_issue)
-
- expect(existing_issue).not_to receive(:save!)
-
- importer.execute
- end
-
- it 'links the author if the author can be found' do
- author = create(:user)
- expect(fake_user_finder).to receive(:find).with('PHID-USER-456').and_return(author)
-
- issue = importer.execute
-
- expect(issue.author).to eq(author)
- end
-
- it 'links an assignee if the user can be found' do
- assignee = create(:user)
- expect(fake_user_finder).to receive(:find).with('PHID-USER-123').and_return(assignee)
-
- issue = importer.execute
-
- expect(issue.assignees).to include(assignee)
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/project_creator_spec.rb b/spec/lib/gitlab/phabricator_import/project_creator_spec.rb
deleted file mode 100644
index 016aa0abe4d..00000000000
--- a/spec/lib/gitlab/phabricator_import/project_creator_spec.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::ProjectCreator do
- let(:user) { create(:user) }
- let(:params) do
- { path: 'new-phab-import',
- phabricator_server_url: 'http://phab.example.com',
- api_token: 'the-token' }
- end
-
- subject(:creator) { described_class.new(user, params) }
-
- describe '#execute' do
- it 'creates a project correctly and schedule an import', :sidekiq_might_not_need_inline do
- expect_next_instance_of(Gitlab::PhabricatorImport::Importer) do |importer|
- expect(importer).to receive(:execute)
- end
-
- project = creator.execute
-
- expect(project).to be_persisted
- expect(project).to be_import
- expect(project.import_type).to eq('phabricator')
- expect(project.import_data.credentials).to match(a_hash_including(api_token: 'the-token'))
- expect(project.import_data.data).to match(a_hash_including('phabricator_url' => 'http://phab.example.com'))
- expect(project.import_url).to eq(Project::UNKNOWN_IMPORT_URL)
- expect(project.namespace).to eq(user.namespace)
- end
-
- context 'when import params are missing' do
- let(:params) do
- { path: 'new-phab-import',
- phabricator_server_url: 'http://phab.example.com',
- api_token: '' }
- end
-
- it 'returns nil' do
- expect(creator.execute).to be_nil
- end
- end
-
- context 'when import params are invalid' do
- let(:params) do
- { path: 'new-phab-import',
- namespace_id: '-1',
- phabricator_server_url: 'http://phab.example.com',
- api_token: 'the-token' }
- end
-
- it 'returns an unpersisted project' do
- project = creator.execute
-
- expect(project).not_to be_persisted
- expect(project).not_to be_valid
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/representation/task_spec.rb b/spec/lib/gitlab/phabricator_import/representation/task_spec.rb
deleted file mode 100644
index 2b8570e4aff..00000000000
--- a/spec/lib/gitlab/phabricator_import/representation/task_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Representation::Task do
- subject(:task) do
- described_class.new(
- {
- 'phid' => 'the-phid',
- 'fields' => {
- 'name' => 'Title'.ljust(257, '.'), # A string padded to 257 chars
- 'authorPHID' => 'a phid',
- 'ownerPHID' => 'another user phid',
- 'description' => {
- 'raw' => '# This is markdown\n it can contain more text.'
- },
- 'dateCreated' => '1518688921',
- 'dateClosed' => '1518789995'
- }
- }
- )
- end
-
- describe '#issue_attributes' do
- it 'contains the expected values' do
- expected_attributes = {
- title: 'Title'.ljust(255, '.'),
- description: '# This is markdown\n it can contain more text.',
- state: :closed,
- created_at: Time.at(1518688921),
- closed_at: Time.at(1518789995)
- }
-
- expect(task.issue_attributes).to eq(expected_attributes)
- end
- end
-
- describe '#author_phid' do
- it 'returns the correct field' do
- expect(task.author_phid).to eq('a phid')
- end
- end
-
- describe '#owner_phid' do
- it 'returns the correct field' do
- expect(task.owner_phid).to eq('another user phid')
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/representation/user_spec.rb b/spec/lib/gitlab/phabricator_import/representation/user_spec.rb
deleted file mode 100644
index 6df26b905cc..00000000000
--- a/spec/lib/gitlab/phabricator_import/representation/user_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::Representation::User do
- subject(:user) do
- described_class.new(
- {
- 'phid' => 'the-phid',
- 'fields' => {
- 'username' => 'the-username'
- }
- }
- )
- end
-
- describe '#phabricator_id' do
- it 'returns the phabricator id' do
- expect(user.phabricator_id).to eq('the-phid')
- end
- end
-
- describe '#username' do
- it 'returns the username' do
- expect(user.username).to eq('the-username')
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/user_finder_spec.rb b/spec/lib/gitlab/phabricator_import/user_finder_spec.rb
deleted file mode 100644
index 2ec2571b7fe..00000000000
--- a/spec/lib/gitlab/phabricator_import/user_finder_spec.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::UserFinder, :clean_gitlab_redis_cache do
- let(:project) { create(:project, namespace: create(:group)) }
-
- subject(:finder) { described_class.new(project, %w[first-phid second-phid]) }
-
- before do
- project.namespace.add_developer(existing_user)
- end
-
- describe '#find' do
- let!(:existing_user) { create(:user, username: 'existing-user') }
- let(:cache) { Gitlab::PhabricatorImport::Cache::Map.new(project) }
-
- before do
- allow(finder).to receive(:object_map).and_return(cache)
- end
-
- context 'for a cached phid' do
- before do
- cache.set_gitlab_model(existing_user, 'first-phid')
- end
-
- it 'returns the existing user' do
- expect(finder.find('first-phid')).to eq(existing_user)
- end
-
- it 'does not perform a find using the API' do
- expect(finder).not_to receive(:find_user_for_phid)
-
- finder.find('first-phid')
- end
-
- it 'excludes the phid from the request if one needs to be made' do
- client = instance_double(Gitlab::PhabricatorImport::Conduit::User)
- allow(finder).to receive(:client).and_return(client)
-
- expect(client).to receive(:users).with(['second-phid']).and_return([])
-
- finder.find('first-phid')
- finder.find('second-phid')
- end
- end
-
- context 'when the phid is not cached' do
- let(:response) do
- [
- instance_double(
- Gitlab::PhabricatorImport::Conduit::UsersResponse,
- users: [instance_double(Gitlab::PhabricatorImport::Representation::User, phabricator_id: 'second-phid', username: 'existing-user')]
- ),
- instance_double(
- Gitlab::PhabricatorImport::Conduit::UsersResponse,
- users: [instance_double(Gitlab::PhabricatorImport::Representation::User, phabricator_id: 'first-phid', username: 'other-user')]
- )
- ]
- end
-
- let(:client) do
- client = instance_double(Gitlab::PhabricatorImport::Conduit::User)
- allow(client).to receive(:users).and_return(response)
-
- client
- end
-
- before do
- allow(finder).to receive(:client).and_return(client)
- end
-
- it 'loads the users from the API once' do
- expect(client).to receive(:users).and_return(response).once
-
- expect(finder.find('second-phid')).to eq(existing_user)
- expect(finder.find('first-phid')).to be_nil
- end
-
- it 'adds found users to the cache' do
- expect { finder.find('second-phid') }
- .to change { cache.get_gitlab_model('second-phid') }
- .from(nil).to(existing_user)
- end
-
- it 'only returns users that are members of the project' do
- create(:user, username: 'other-user')
-
- expect(finder.find('first-phid')).to eq(nil)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/phabricator_import/worker_state_spec.rb b/spec/lib/gitlab/phabricator_import/worker_state_spec.rb
deleted file mode 100644
index 4a07e28440f..00000000000
--- a/spec/lib/gitlab/phabricator_import/worker_state_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::WorkerState, :clean_gitlab_redis_shared_state do
- subject(:state) { described_class.new('weird-project-id') }
-
- let(:key) { 'phabricator-import/jobs/project-weird-project-id/job-count' }
-
- describe '#add_job' do
- it 'increments the counter for jobs' do
- set_value(3)
-
- expect { state.add_job }.to change { get_value }.from('3').to('4')
- end
- end
-
- describe '#remove_job' do
- it 'decrements the counter for jobs' do
- set_value(3)
-
- expect { state.remove_job }.to change { get_value }.from('3').to('2')
- end
- end
-
- describe '#running_count' do
- it 'reads the value' do
- set_value(9)
-
- expect(state.running_count).to eq(9)
- end
-
- it 'returns 0 when nothing was set' do
- expect(state.running_count).to eq(0)
- end
- end
-
- def set_value(value)
- redis.with { |r| r.set(key, value) }
- end
-
- def get_value
- redis.with { |r| r.get(key) }
- end
-
- def redis
- Gitlab::Redis::SharedState
- end
-end
diff --git a/spec/lib/gitlab/project_authorizations_spec.rb b/spec/lib/gitlab/project_authorizations_spec.rb
index 640cf9be453..b076bb65fb5 100644
--- a/spec/lib/gitlab/project_authorizations_spec.rb
+++ b/spec/lib/gitlab/project_authorizations_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ProjectAuthorizations do
+RSpec.describe Gitlab::ProjectAuthorizations, feature_category: :system_access do
def map_access_levels(rows)
rows.each_with_object({}) do |row, hash|
hash[row.project_id] = row.access_level
@@ -13,408 +13,421 @@ RSpec.describe Gitlab::ProjectAuthorizations do
described_class.new(user).calculate
end
- context 'user added to group and project' do
- let(:group) { create(:group) }
- let!(:other_project) { create(:project) }
- let!(:group_project) { create(:project, namespace: group) }
- let!(:owned_project) { create(:project) }
- let(:user) { owned_project.namespace.owner }
+ # Inline this shared example while cleaning up feature flag linear_project_authorization
+ RSpec.shared_examples 'project authorizations' do
+ context 'user added to group and project' do
+ let(:group) { create(:group) }
+ let!(:other_project) { create(:project) }
+ let!(:group_project) { create(:project, namespace: group) }
+ let!(:owned_project) { create(:project) }
+ let(:user) { owned_project.namespace.owner }
- before do
- other_project.add_reporter(user)
- group.add_developer(user)
- end
+ before do
+ other_project.add_reporter(user)
+ group.add_developer(user)
+ end
- it 'returns the correct number of authorizations' do
- expect(authorizations.length).to eq(3)
- end
+ it 'returns the correct number of authorizations' do
+ expect(authorizations.length).to eq(3)
+ end
- it 'includes the correct projects' do
- expect(authorizations.pluck(:project_id))
- .to include(owned_project.id, other_project.id, group_project.id)
- end
+ it 'includes the correct projects' do
+ expect(authorizations.pluck(:project_id))
+ .to include(owned_project.id, other_project.id, group_project.id)
+ end
- it 'includes the correct access levels' do
- mapping = map_access_levels(authorizations)
+ it 'includes the correct access levels' do
+ mapping = map_access_levels(authorizations)
- expect(mapping[owned_project.id]).to eq(Gitlab::Access::OWNER)
- expect(mapping[other_project.id]).to eq(Gitlab::Access::REPORTER)
- expect(mapping[group_project.id]).to eq(Gitlab::Access::DEVELOPER)
+ expect(mapping[owned_project.id]).to eq(Gitlab::Access::OWNER)
+ expect(mapping[other_project.id]).to eq(Gitlab::Access::REPORTER)
+ expect(mapping[group_project.id]).to eq(Gitlab::Access::DEVELOPER)
+ end
end
- end
- context 'unapproved access request' do
- let_it_be(:group) { create(:group) }
- let_it_be(:user) { create(:user) }
+ context 'unapproved access request' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
- subject(:mapping) { map_access_levels(authorizations) }
+ subject(:mapping) { map_access_levels(authorizations) }
- context 'group membership' do
- let!(:group_project) { create(:project, namespace: group) }
+ context 'group membership' do
+ let!(:group_project) { create(:project, namespace: group) }
- before do
- create(:group_member, :developer, :access_request, user: user, group: group)
- end
+ before do
+ create(:group_member, :developer, :access_request, user: user, group: group)
+ end
- it 'does not create authorization' do
- expect(mapping[group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[group_project.id]).to be_nil
+ end
end
- end
- context 'inherited group membership' do
- let!(:sub_group) { create(:group, parent: group) }
- let!(:sub_group_project) { create(:project, namespace: sub_group) }
+ context 'inherited group membership' do
+ let!(:sub_group) { create(:group, parent: group) }
+ let!(:sub_group_project) { create(:project, namespace: sub_group) }
- before do
- create(:group_member, :developer, :access_request, user: user, group: group)
- end
+ before do
+ create(:group_member, :developer, :access_request, user: user, group: group)
+ end
- it 'does not create authorization' do
- expect(mapping[sub_group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[sub_group_project.id]).to be_nil
+ end
end
- end
- context 'project membership' do
- let!(:group_project) { create(:project, namespace: group) }
+ context 'project membership' do
+ let!(:group_project) { create(:project, namespace: group) }
- before do
- create(:project_member, :developer, :access_request, user: user, project: group_project)
- end
+ before do
+ create(:project_member, :developer, :access_request, user: user, project: group_project)
+ end
- it 'does not create authorization' do
- expect(mapping[group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[group_project.id]).to be_nil
+ end
end
- end
- context 'shared group' do
- let!(:shared_group) { create(:group) }
- let!(:shared_group_project) { create(:project, namespace: shared_group) }
+ context 'shared group' do
+ let!(:shared_group) { create(:group) }
+ let!(:shared_group_project) { create(:project, namespace: shared_group) }
- before do
- create(:group_group_link, shared_group: shared_group, shared_with_group: group)
- create(:group_member, :developer, :access_request, user: user, group: group)
- end
+ before do
+ create(:group_group_link, shared_group: shared_group, shared_with_group: group)
+ create(:group_member, :developer, :access_request, user: user, group: group)
+ end
- it 'does not create authorization' do
- expect(mapping[shared_group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[shared_group_project.id]).to be_nil
+ end
end
- end
- context 'shared project' do
- let!(:another_group) { create(:group) }
- let!(:shared_project) { create(:project, namespace: another_group) }
+ context 'shared project' do
+ let!(:another_group) { create(:group) }
+ let!(:shared_project) { create(:project, namespace: another_group) }
- before do
- create(:project_group_link, group: group, project: shared_project)
- create(:group_member, :developer, :access_request, user: user, group: group)
- end
+ before do
+ create(:project_group_link, group: group, project: shared_project)
+ create(:group_member, :developer, :access_request, user: user, group: group)
+ end
- it 'does not create authorization' do
- expect(mapping[shared_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[shared_project.id]).to be_nil
+ end
end
end
- end
- context 'user with minimal access to group' do
- let_it_be(:group) { create(:group) }
- let_it_be(:user) { create(:user) }
+ context 'user with minimal access to group' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
- subject(:mapping) { map_access_levels(authorizations) }
+ subject(:mapping) { map_access_levels(authorizations) }
- context 'group membership' do
- let!(:group_project) { create(:project, namespace: group) }
+ context 'group membership' do
+ let!(:group_project) { create(:project, namespace: group) }
- before do
- create(:group_member, :minimal_access, user: user, source: group)
- end
+ before do
+ create(:group_member, :minimal_access, user: user, source: group)
+ end
- it 'does not create authorization' do
- expect(mapping[group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[group_project.id]).to be_nil
+ end
end
- end
- context 'inherited group membership' do
- let!(:sub_group) { create(:group, parent: group) }
- let!(:sub_group_project) { create(:project, namespace: sub_group) }
+ context 'inherited group membership' do
+ let!(:sub_group) { create(:group, parent: group) }
+ let!(:sub_group_project) { create(:project, namespace: sub_group) }
- before do
- create(:group_member, :minimal_access, user: user, source: group)
- end
+ before do
+ create(:group_member, :minimal_access, user: user, source: group)
+ end
- it 'does not create authorization' do
- expect(mapping[sub_group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[sub_group_project.id]).to be_nil
+ end
end
- end
- context 'shared group' do
- let!(:shared_group) { create(:group) }
- let!(:shared_group_project) { create(:project, namespace: shared_group) }
+ context 'shared group' do
+ let!(:shared_group) { create(:group) }
+ let!(:shared_group_project) { create(:project, namespace: shared_group) }
- before do
- create(:group_group_link, shared_group: shared_group, shared_with_group: group)
- create(:group_member, :minimal_access, user: user, source: group)
- end
+ before do
+ create(:group_group_link, shared_group: shared_group, shared_with_group: group)
+ create(:group_member, :minimal_access, user: user, source: group)
+ end
- it 'does not create authorization' do
- expect(mapping[shared_group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[shared_group_project.id]).to be_nil
+ end
end
- end
- context 'shared project' do
- let!(:another_group) { create(:group) }
- let!(:shared_project) { create(:project, namespace: another_group) }
+ context 'shared project' do
+ let!(:another_group) { create(:group) }
+ let!(:shared_project) { create(:project, namespace: another_group) }
- before do
- create(:project_group_link, group: group, project: shared_project)
- create(:group_member, :minimal_access, user: user, source: group)
- end
+ before do
+ create(:project_group_link, group: group, project: shared_project)
+ create(:group_member, :minimal_access, user: user, source: group)
+ end
- it 'does not create authorization' do
- expect(mapping[shared_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[shared_project.id]).to be_nil
+ end
end
end
- end
- context 'with nested groups' do
- let(:group) { create(:group) }
- let!(:nested_group) { create(:group, parent: group) }
- let!(:nested_project) { create(:project, namespace: nested_group) }
- let(:user) { create(:user) }
+ context 'with nested groups' do
+ let(:group) { create(:group) }
+ let!(:nested_group) { create(:group, parent: group) }
+ let!(:nested_project) { create(:project, namespace: nested_group) }
+ let(:user) { create(:user) }
- before do
- group.add_developer(user)
- end
+ before do
+ group.add_developer(user)
+ end
- it 'includes nested groups' do
- expect(authorizations.pluck(:project_id)).to include(nested_project.id)
- end
+ it 'includes nested groups' do
+ expect(authorizations.pluck(:project_id)).to include(nested_project.id)
+ end
- it 'inherits access levels when the user is not a member of a nested group' do
- mapping = map_access_levels(authorizations)
+ it 'inherits access levels when the user is not a member of a nested group' do
+ mapping = map_access_levels(authorizations)
- expect(mapping[nested_project.id]).to eq(Gitlab::Access::DEVELOPER)
- end
+ expect(mapping[nested_project.id]).to eq(Gitlab::Access::DEVELOPER)
+ end
- it 'uses the greatest access level when a user is a member of a nested group' do
- nested_group.add_maintainer(user)
+ it 'uses the greatest access level when a user is a member of a nested group' do
+ nested_group.add_maintainer(user)
- mapping = map_access_levels(authorizations)
+ mapping = map_access_levels(authorizations)
- expect(mapping[nested_project.id]).to eq(Gitlab::Access::MAINTAINER)
+ expect(mapping[nested_project.id]).to eq(Gitlab::Access::MAINTAINER)
+ end
end
- end
- context 'with shared projects' do
- let_it_be(:shared_with_group) { create(:group) }
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, group: create(:group)) }
+ context 'with shared projects' do
+ let_it_be(:shared_with_group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, group: create(:group)) }
- let(:mapping) { map_access_levels(authorizations) }
+ let(:mapping) { map_access_levels(authorizations) }
- before do
- create(:project_group_link, :developer, project: project, group: shared_with_group)
- shared_with_group.add_maintainer(user)
- end
-
- it 'creates proper authorizations' do
- expect(mapping[project.id]).to eq(Gitlab::Access::DEVELOPER)
- end
-
- context 'even when the `lock_memberships_to_ldap` setting has been turned ON' do
before do
- stub_application_setting(lock_memberships_to_ldap: true)
+ create(:project_group_link, :developer, project: project, group: shared_with_group)
+ shared_with_group.add_maintainer(user)
end
it 'creates proper authorizations' do
expect(mapping[project.id]).to eq(Gitlab::Access::DEVELOPER)
end
- end
- context 'when the group containing the project has forbidden group shares for any of its projects' do
- before do
- project.namespace.update!(share_with_group_lock: true)
+ context 'even when the `lock_memberships_to_ldap` setting has been turned ON' do
+ before do
+ stub_application_setting(lock_memberships_to_ldap: true)
+ end
+
+ it 'creates proper authorizations' do
+ expect(mapping[project.id]).to eq(Gitlab::Access::DEVELOPER)
+ end
end
- it 'does not create authorizations' do
- expect(mapping[project.id]).to be_nil
+ context 'when the group containing the project has forbidden group shares for any of its projects' do
+ before do
+ project.namespace.update!(share_with_group_lock: true)
+ end
+
+ it 'does not create authorizations' do
+ expect(mapping[project.id]).to be_nil
+ end
end
end
- end
- context 'with shared groups' do
- let(:parent_group_user) { create(:user) }
- let(:group_user) { create(:user) }
- let(:child_group_user) { create(:user) }
+ context 'with shared groups' do
+ let(:parent_group_user) { create(:user) }
+ let(:group_user) { create(:user) }
+ let(:child_group_user) { create(:user) }
- 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(: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) { create(:group, :private) }
- let_it_be(:shared_group) { create(:group, :private, parent: shared_group_parent) }
- let_it_be(:shared_group_child) { create(:group, :private, parent: shared_group) }
+ let_it_be(:shared_group_parent) { create(:group, :private) }
+ let_it_be(:shared_group) { create(:group, :private, parent: shared_group_parent) }
+ let_it_be(: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_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) }
- before do
- group_parent.add_owner(parent_group_user)
- group.add_owner(group_user)
- group_child.add_owner(child_group_user)
+ before do
+ group_parent.add_owner(parent_group_user)
+ group.add_owner(group_user)
+ group_child.add_owner(child_group_user)
- create(:group_group_link, shared_group: shared_group, shared_with_group: group)
- end
+ create(:group_group_link, shared_group: shared_group, shared_with_group: group)
+ end
- context 'group user' do
- let(:user) { group_user }
+ context 'group user' do
+ let(:user) { group_user }
- it 'creates proper authorizations' do
- mapping = map_access_levels(authorizations)
+ it 'creates proper authorizations' do
+ mapping = map_access_levels(authorizations)
- expect(mapping[project_parent.id]).to be_nil
- expect(mapping[project.id]).to eq(Gitlab::Access::DEVELOPER)
- expect(mapping[project_child.id]).to eq(Gitlab::Access::DEVELOPER)
+ expect(mapping[project_parent.id]).to be_nil
+ expect(mapping[project.id]).to eq(Gitlab::Access::DEVELOPER)
+ expect(mapping[project_child.id]).to eq(Gitlab::Access::DEVELOPER)
+ end
end
- end
- context 'with lower group access level than max access level for share' do
- let(:user) { create(:user) }
+ context 'with lower group access level than max access level for share' do
+ let(:user) { create(:user) }
- it 'creates proper authorizations' do
- group.add_reporter(user)
+ it 'creates proper authorizations' do
+ group.add_reporter(user)
- mapping = map_access_levels(authorizations)
+ mapping = map_access_levels(authorizations)
- expect(mapping[project_parent.id]).to be_nil
- expect(mapping[project.id]).to eq(Gitlab::Access::REPORTER)
- expect(mapping[project_child.id]).to eq(Gitlab::Access::REPORTER)
+ expect(mapping[project_parent.id]).to be_nil
+ expect(mapping[project.id]).to eq(Gitlab::Access::REPORTER)
+ expect(mapping[project_child.id]).to eq(Gitlab::Access::REPORTER)
+ end
end
- end
- context 'parent group user' do
- let(:user) { parent_group_user }
+ context 'parent group user' do
+ let(:user) { parent_group_user }
- it 'creates proper authorizations' do
- mapping = map_access_levels(authorizations)
+ it 'creates proper authorizations' do
+ mapping = map_access_levels(authorizations)
- expect(mapping[project_parent.id]).to be_nil
- expect(mapping[project.id]).to be_nil
- expect(mapping[project_child.id]).to be_nil
+ expect(mapping[project_parent.id]).to be_nil
+ expect(mapping[project.id]).to be_nil
+ expect(mapping[project_child.id]).to be_nil
+ end
end
- end
- context 'child group user' do
- let(:user) { child_group_user }
+ context 'child group user' do
+ let(:user) { child_group_user }
- it 'creates proper authorizations' do
- mapping = map_access_levels(authorizations)
+ it 'creates proper authorizations' do
+ mapping = map_access_levels(authorizations)
- expect(mapping[project_parent.id]).to be_nil
- expect(mapping[project.id]).to be_nil
- expect(mapping[project_child.id]).to be_nil
+ expect(mapping[project_parent.id]).to be_nil
+ expect(mapping[project.id]).to be_nil
+ expect(mapping[project_child.id]).to be_nil
+ end
end
- end
- context 'user without accepted access request' do
- let!(:user) { create(:user) }
+ context 'user without accepted access request' do
+ let!(:user) { create(:user) }
- it 'does not have access to group and its projects' do
- create(:group_member, :developer, :access_request, user: user, group: group)
+ it 'does not have access to group and its projects' do
+ create(:group_member, :developer, :access_request, user: user, group: group)
- mapping = map_access_levels(authorizations)
+ mapping = map_access_levels(authorizations)
- expect(mapping[project_parent.id]).to be_nil
- expect(mapping[project.id]).to be_nil
- expect(mapping[project_child.id]).to be_nil
+ expect(mapping[project_parent.id]).to be_nil
+ expect(mapping[project.id]).to be_nil
+ expect(mapping[project_child.id]).to be_nil
+ end
end
- end
- context 'unrelated project owner' do
- let(:common_id) { non_existing_record_id }
- let!(:group) { create(:group, id: common_id) }
- let!(:unrelated_project) { create(:project, id: common_id) }
- let(:user) { unrelated_project.first_owner }
+ context 'unrelated project owner' do
+ let(:common_id) { non_existing_record_id }
+ let!(:group) { create(:group, id: common_id) }
+ let!(:unrelated_project) { create(:project, id: common_id) }
+ let(:user) { unrelated_project.first_owner }
- it 'does not have access to group and its projects' do
- mapping = map_access_levels(authorizations)
+ it 'does not have access to group and its projects' do
+ mapping = map_access_levels(authorizations)
- expect(mapping[project_parent.id]).to be_nil
- expect(mapping[project.id]).to be_nil
- expect(mapping[project_child.id]).to be_nil
+ expect(mapping[project_parent.id]).to be_nil
+ expect(mapping[project.id]).to be_nil
+ expect(mapping[project_child.id]).to be_nil
+ end
end
end
- end
- context 'with pending memberships' do
- let_it_be(:group) { create(:group) }
- let_it_be(:user) { create(:user) }
+ context 'with pending memberships' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
- subject(:mapping) { map_access_levels(authorizations) }
+ subject(:mapping) { map_access_levels(authorizations) }
- context 'group membership' do
- let!(:group_project) { create(:project, namespace: group) }
+ context 'group membership' do
+ let!(:group_project) { create(:project, namespace: group) }
- before do
- create(:group_member, :developer, :awaiting, user: user, group: group)
- end
+ before do
+ create(:group_member, :developer, :awaiting, user: user, group: group)
+ end
- it 'does not create authorization' do
- expect(mapping[group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[group_project.id]).to be_nil
+ end
end
- end
- context 'inherited group membership' do
- let!(:sub_group) { create(:group, parent: group) }
- let!(:sub_group_project) { create(:project, namespace: sub_group) }
+ context 'inherited group membership' do
+ let!(:sub_group) { create(:group, parent: group) }
+ let!(:sub_group_project) { create(:project, namespace: sub_group) }
- before do
- create(:group_member, :developer, :awaiting, user: user, group: group)
- end
+ before do
+ create(:group_member, :developer, :awaiting, user: user, group: group)
+ end
- it 'does not create authorization' do
- expect(mapping[sub_group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[sub_group_project.id]).to be_nil
+ end
end
- end
- context 'project membership' do
- let!(:group_project) { create(:project, namespace: group) }
+ context 'project membership' do
+ let!(:group_project) { create(:project, namespace: group) }
- before do
- create(:project_member, :developer, :awaiting, user: user, project: group_project)
- end
+ before do
+ create(:project_member, :developer, :awaiting, user: user, project: group_project)
+ end
- it 'does not create authorization' do
- expect(mapping[group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[group_project.id]).to be_nil
+ end
end
- end
- context 'shared group' do
- let!(:shared_group) { create(:group) }
- let!(:shared_group_project) { create(:project, namespace: shared_group) }
+ context 'shared group' do
+ let!(:shared_group) { create(:group) }
+ let!(:shared_group_project) { create(:project, namespace: shared_group) }
- before do
- create(:group_group_link, shared_group: shared_group, shared_with_group: group)
- create(:group_member, :developer, :awaiting, user: user, group: group)
- end
+ before do
+ create(:group_group_link, shared_group: shared_group, shared_with_group: group)
+ create(:group_member, :developer, :awaiting, user: user, group: group)
+ end
- it 'does not create authorization' do
- expect(mapping[shared_group_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[shared_group_project.id]).to be_nil
+ end
end
- end
- context 'shared project' do
- let!(:another_group) { create(:group) }
- let!(:shared_project) { create(:project, namespace: another_group) }
+ context 'shared project' do
+ let!(:another_group) { create(:group) }
+ let!(:shared_project) { create(:project, namespace: another_group) }
- before do
- create(:project_group_link, group: group, project: shared_project)
- create(:group_member, :developer, :awaiting, user: user, group: group)
- end
+ before do
+ create(:project_group_link, group: group, project: shared_project)
+ create(:group_member, :developer, :awaiting, user: user, group: group)
+ end
- it 'does not create authorization' do
- expect(mapping[shared_project.id]).to be_nil
+ it 'does not create authorization' do
+ expect(mapping[shared_project.id]).to be_nil
+ end
end
end
end
+
+ context 'when feature_flag linear_project_authorization_is disabled' do
+ before do
+ stub_feature_flags(linear_project_authorization: false)
+ end
+
+ it_behaves_like 'project authorizations'
+ end
+
+ it_behaves_like 'project authorizations'
end
diff --git a/spec/lib/gitlab/prometheus/internal_spec.rb b/spec/lib/gitlab/prometheus/internal_spec.rb
index b08b8813470..ff5da301347 100644
--- a/spec/lib/gitlab/prometheus/internal_spec.rb
+++ b/spec/lib/gitlab/prometheus/internal_spec.rb
@@ -81,7 +81,7 @@ RSpec.describe Gitlab::Prometheus::Internal do
context 'when prometheus setting is not present in gitlab.yml' do
before do
- allow(Gitlab.config).to receive(:prometheus).and_raise(Settingslogic::MissingSetting)
+ allow(Gitlab.config).to receive(:prometheus).and_raise(GitlabSettings::MissingSetting)
end
it 'does not fail' do
@@ -97,7 +97,7 @@ RSpec.describe Gitlab::Prometheus::Internal do
context 'when prometheus setting is not present in gitlab.yml' do
before do
- allow(Gitlab.config).to receive(:prometheus).and_raise(Settingslogic::MissingSetting)
+ allow(Gitlab.config).to receive(:prometheus).and_raise(GitlabSettings::MissingSetting)
end
it 'does not fail' do
diff --git a/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb
deleted file mode 100644
index ff48b9ada90..00000000000
--- a/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Prometheus::Queries::KnativeInvocationQuery do
- include PrometheusHelpers
-
- let(:project) { create(:project) }
- let(:serverless_func) { ::Serverless::Function.new(project, 'test-name', 'test-ns') }
- let(:client) { double('prometheus_client') }
-
- subject { described_class.new(client) }
-
- context 'verify queries' do
- before do
- create(:prometheus_metric,
- :common,
- identifier: :system_metrics_knative_function_invocation_count,
- query: 'sum(ceil(rate(istio_requests_total{destination_service_namespace="%{kube_namespace}", destination_service=~"%{function_name}.*"}[1m])*60))')
- end
-
- it 'has the query, but no data' do
- expect(client).to receive(:query_range).with(
- 'sum(ceil(rate(istio_requests_total{destination_service_namespace="test-ns", destination_service=~"test-name.*"}[1m])*60))',
- hash_including(:start_time, :end_time)
- )
-
- subject.query(serverless_func.id)
- end
- end
-end
diff --git a/spec/lib/gitlab/quick_actions/extractor_spec.rb b/spec/lib/gitlab/quick_actions/extractor_spec.rb
index e2f289041ce..f91e8d2a7ef 100644
--- a/spec/lib/gitlab/quick_actions/extractor_spec.rb
+++ b/spec/lib/gitlab/quick_actions/extractor_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::QuickActions::Extractor do
+RSpec.describe Gitlab::QuickActions::Extractor, feature_category: :team_planning do
let(:definitions) do
Class.new do
include Gitlab::QuickActions::Dsl
@@ -19,7 +19,8 @@ RSpec.describe Gitlab::QuickActions::Extractor do
end.command_definitions
end
- let(:extractor) { described_class.new(definitions) }
+ let(:extractor) { described_class.new(definitions, keep_actions: keep_actions) }
+ let(:keep_actions) { false }
shared_examples 'command with no argument' do
it 'extracts command' do
@@ -176,6 +177,31 @@ RSpec.describe Gitlab::QuickActions::Extractor do
end
end
+ describe 'command with keep_actions' do
+ let(:keep_actions) { true }
+
+ context 'at the start of content' do
+ it_behaves_like 'command with a single argument' do
+ let(:original_msg) { "/assign @joe\nworld" }
+ let(:final_msg) { "\n/assign @joe\n\nworld" }
+ end
+ end
+
+ context 'in the middle of content' do
+ it_behaves_like 'command with a single argument' do
+ let(:original_msg) { "hello\n/assign @joe\nworld" }
+ let(:final_msg) { "hello\n\n/assign @joe\n\nworld" }
+ end
+ end
+
+ context 'at the end of content' do
+ it_behaves_like 'command with a single argument' do
+ let(:original_msg) { "hello\n/assign @joe" }
+ let(:final_msg) { "hello\n\n/assign @joe" }
+ end
+ end
+ end
+
it 'extracts command with multiple arguments and various prefixes' do
msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld)
msg, commands = extractor.extract_commands(msg)
@@ -244,10 +270,19 @@ RSpec.describe Gitlab::QuickActions::Extractor do
msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.)
msg, commands = extractor.extract_commands(msg)
- expect(commands).to eq [['reopen'], ['substitution', 'wow this is a thing.']]
+ expect(commands).to match_array [['reopen'], ['substitution', 'wow this is a thing.']]
expect(msg).to eq "hello\nworld\nfoo"
end
+ it 'extracts and performs substitution commands with keep_actions' do
+ extractor = described_class.new(definitions, keep_actions: true)
+ msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.)
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to match_array [['reopen'], ['substitution', 'wow this is a thing.']]
+ expect(msg).to eq "hello\nworld\n\n/reopen\n\nfoo"
+ end
+
it 'extracts multiple commands' do
msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/reopen)
msg, commands = extractor.extract_commands(msg)
diff --git a/spec/lib/gitlab/rack_attack/instrumented_cache_store_spec.rb b/spec/lib/gitlab/rack_attack/instrumented_cache_store_spec.rb
deleted file mode 100644
index 8151519ddec..00000000000
--- a/spec/lib/gitlab/rack_attack/instrumented_cache_store_spec.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::RackAttack::InstrumentedCacheStore do
- using RSpec::Parameterized::TableSyntax
-
- let(:store) { ::ActiveSupport::Cache::NullStore.new }
-
- subject { described_class.new(upstream_store: store) }
-
- where(:operation, :params, :test_proc) do
- :fetch | [:key] | ->(s) { s.fetch(:key) }
- :read | [:key] | ->(s) { s.read(:key) }
- :read_multi | [:key_1, :key_2, :key_3] | ->(s) { s.read_multi(:key_1, :key_2, :key_3) }
- :write_multi | [{ key_1: 1, key_2: 2, key_3: 3 }] | ->(s) { s.write_multi(key_1: 1, key_2: 2, key_3: 3) }
- :fetch_multi | [:key_1, :key_2, :key_3] | ->(s) { s.fetch_multi(:key_1, :key_2, :key_3) {} }
- :write | [:key, :value, { option_1: 1 }] | ->(s) { s.write(:key, :value, option_1: 1) }
- :delete | [:key] | ->(s) { s.delete(:key) }
- :exist? | [:key, { option_1: 1 }] | ->(s) { s.exist?(:key, option_1: 1) }
- :delete_matched | [/^key$/, { option_1: 1 }] | ->(s) { s.delete_matched(/^key$/, option_1: 1 ) }
- :increment | [:key, 1] | ->(s) { s.increment(:key, 1) }
- :decrement | [:key, 1] | ->(s) { s.decrement(:key, 1) }
- :cleanup | [] | ->(s) { s.cleanup }
- :clear | [] | ->(s) { s.clear }
- end
-
- with_them do
- it 'publishes a notification' do
- event = nil
-
- begin
- subscriber = ActiveSupport::Notifications.subscribe("redis.rack_attack") do |*args|
- event = ActiveSupport::Notifications::Event.new(*args)
- end
-
- test_proc.call(subject)
- ensure
- ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
- end
-
- expect(event).not_to be_nil
- expect(event.name).to eq("redis.rack_attack")
- expect(event.duration).to be_a(Float).and(be > 0.0)
- expect(event.payload[:operation]).to eql(operation)
- end
-
- it 'publishes a notification even if the cache store returns an error' do
- allow(store).to receive(operation).and_raise('Something went wrong')
-
- event = nil
- exception = nil
-
- begin
- subscriber = ActiveSupport::Notifications.subscribe("redis.rack_attack") do |*args|
- event = ActiveSupport::Notifications::Event.new(*args)
- end
-
- begin
- test_proc.call(subject)
- rescue StandardError => e
- exception = e
- end
- ensure
- ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
- end
-
- expect(event).not_to be_nil
- expect(event.name).to eq("redis.rack_attack")
- expect(event.duration).to be_a(Float).and(be > 0.0)
- expect(event.payload[:operation]).to eql(operation)
-
- expect(exception).not_to be_nil
- expect(exception.message).to eql('Something went wrong')
- end
-
- it 'delegates to the upstream store' do
- allow(store).to receive(operation).and_call_original
-
- if params.empty?
- expect(store).to receive(operation).with(no_args)
- else
- expect(store).to receive(operation).with(*params)
- end
-
- test_proc.call(subject)
- end
- end
-end
diff --git a/spec/lib/gitlab/rack_attack/request_spec.rb b/spec/lib/gitlab/rack_attack/request_spec.rb
index 5345205e15b..ae0abfd0bc5 100644
--- a/spec/lib/gitlab/rack_attack/request_spec.rb
+++ b/spec/lib/gitlab/rack_attack/request_spec.rb
@@ -259,7 +259,7 @@ RSpec.describe Gitlab::RackAttack::Request do
other_token = SecureRandom.base64(ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH)
where(:session, :env, :expected) do
- {} | {} | false # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
+ {} | {} | false
{} | { 'HTTP_X_CSRF_TOKEN' => valid_token } | false
{ _csrf_token: valid_token } | { 'HTTP_X_CSRF_TOKEN' => other_token } | false
{ _csrf_token: valid_token } | { 'HTTP_X_CSRF_TOKEN' => valid_token } | true
diff --git a/spec/lib/gitlab/rack_attack/store_spec.rb b/spec/lib/gitlab/rack_attack/store_spec.rb
new file mode 100644
index 00000000000..19b3f239d91
--- /dev/null
+++ b/spec/lib/gitlab/rack_attack/store_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::RackAttack::Store, :clean_gitlab_redis_rate_limiting, feature_category: :scalability do
+ let(:store) { described_class.new }
+ let(:key) { 'foobar' }
+ let(:namespaced_key) { "cache:gitlab:#{key}" }
+
+ def with_redis(&block)
+ Gitlab::Redis::RateLimiting.with(&block)
+ end
+
+ describe '#increment' do
+ it 'increments without expiry' do
+ 5.times do |i|
+ expect(store.increment(key, 1)).to eq(i + 1)
+
+ with_redis do |redis|
+ expect(redis.get(namespaced_key).to_i).to eq(i + 1)
+ expect(redis.ttl(namespaced_key)).to eq(-1)
+ end
+ end
+ end
+
+ it 'rejects amounts other than 1' do
+ expect { store.increment(key, 2) }.to raise_exception(described_class::InvalidAmount)
+ end
+
+ context 'with expiry' do
+ it 'increments and sets expiry' do
+ 5.times do |i|
+ expect(store.increment(key, 1, expires_in: 456)).to eq(i + 1)
+
+ with_redis do |redis|
+ expect(redis.get(namespaced_key).to_i).to eq(i + 1)
+ expect(redis.ttl(namespaced_key)).to be_within(10).of(456)
+ end
+ end
+ end
+ end
+ end
+
+ describe '#read' do
+ subject { store.read(key) }
+
+ it 'reads the namespaced key' do
+ with_redis { |r| r.set(namespaced_key, '123') }
+
+ expect(subject).to eq('123')
+ end
+ end
+
+ describe '#write' do
+ subject { store.write(key, '123', options) }
+
+ let(:options) { {} }
+
+ it 'sets the key' do
+ subject
+
+ with_redis do |redis|
+ expect(redis.get(namespaced_key)).to eq('123')
+ expect(redis.ttl(namespaced_key)).to eq(-1)
+ end
+ end
+
+ context 'with expiry' do
+ let(:options) { { expires_in: 456 } }
+
+ it 'sets the key with expiry' do
+ subject
+
+ with_redis do |redis|
+ expect(redis.get(namespaced_key)).to eq('123')
+ expect(redis.ttl(namespaced_key)).to be_within(10).of(456)
+ end
+ end
+ end
+ end
+
+ describe '#delete' do
+ subject { store.delete(key) }
+
+ it { expect(subject).to eq(0) }
+
+ context 'when the key exists' do
+ before do
+ with_redis { |r| r.set(namespaced_key, '123') }
+ end
+
+ it { expect(subject).to eq(1) }
+ end
+ end
+
+ describe '#with' do
+ subject { store.send(:with, &:ping) }
+
+ it { expect(subject).to eq('PONG') }
+
+ context 'when redis is unavailable' do
+ before do
+ broken_redis = Redis.new(
+ url: 'redis://127.0.0.0:0',
+ instrumentation_class: Gitlab::Redis::RateLimiting.instrumentation_class
+ )
+ allow(Gitlab::Redis::RateLimiting).to receive(:with).and_yield(broken_redis)
+ end
+
+ it { expect(subject).to eq(nil) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/reactive_cache_set_cache_spec.rb b/spec/lib/gitlab/reactive_cache_set_cache_spec.rb
index 207ac1c0eaa..a78d15134fa 100644
--- a/spec/lib/gitlab/reactive_cache_set_cache_spec.rb
+++ b/spec/lib/gitlab/reactive_cache_set_cache_spec.rb
@@ -46,17 +46,29 @@ RSpec.describe Gitlab::ReactiveCacheSetCache, :clean_gitlab_redis_cache do
end
describe '#clear_cache!', :use_clean_rails_redis_caching do
- it 'deletes the cached items' do
- # Cached key and value
- Rails.cache.write('test_item', 'test_value')
- # Add key to set
- cache.write(cache_prefix, 'test_item')
+ shared_examples 'clears cache' do
+ it 'deletes the cached items' do
+ # Cached key and value
+ Rails.cache.write('test_item', 'test_value')
+ # Add key to set
+ cache.write(cache_prefix, 'test_item')
- expect(cache.read(cache_prefix)).to contain_exactly('test_item')
- cache.clear_cache!(cache_prefix)
+ expect(cache.read(cache_prefix)).to contain_exactly('test_item')
+ cache.clear_cache!(cache_prefix)
+
+ expect(cache.read(cache_prefix)).to be_empty
+ end
+ end
- expect(cache.read(cache_prefix)).to be_empty
+ context 'when featuer flag disabled' do
+ before do
+ stub_feature_flags(use_pipeline_over_multikey: false)
+ end
+
+ it_behaves_like 'clears cache'
end
+
+ it_behaves_like 'clears cache'
end
describe '#include?' do
diff --git a/spec/lib/gitlab/redis/cache_spec.rb b/spec/lib/gitlab/redis/cache_spec.rb
index 64615c4d9ad..b7b4ba0eb2f 100644
--- a/spec/lib/gitlab/redis/cache_spec.rb
+++ b/spec/lib/gitlab/redis/cache_spec.rb
@@ -4,18 +4,9 @@ require 'spec_helper'
RSpec.describe Gitlab::Redis::Cache do
let(:instance_specific_config_file) { "config/redis.cache.yml" }
- let(:environment_config_file_name) { "GITLAB_REDIS_CACHE_CONFIG_FILE" }
include_examples "redis_shared_examples"
- describe '#raw_config_hash' do
- it 'has a legacy default URL' do
- expect(subject).to receive(:fetch_config) { false }
-
- expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6380' )
- end
- end
-
describe '.active_support_config' do
it 'has a default ttl of 8 hours' do
expect(described_class.active_support_config[:expires_in]).to eq(8.hours)
@@ -26,22 +17,5 @@ RSpec.describe Gitlab::Redis::Cache do
expect(described_class.active_support_config[:expires_in]).to eq(1.day)
end
-
- context 'when encountering an error' do
- let(:cache) { ActiveSupport::Cache::RedisCacheStore.new(**described_class.active_support_config) }
-
- subject { cache.read('x') }
-
- before do
- described_class.with do |redis|
- allow(redis).to receive(:get).and_raise(::Redis::CommandError)
- end
- end
-
- it 'logs error' do
- expect(::Gitlab::ErrorTracking).to receive(:log_exception)
- subject
- end
- end
end
end
diff --git a/spec/lib/gitlab/redis/cluster_rate_limiting_spec.rb b/spec/lib/gitlab/redis/cluster_rate_limiting_spec.rb
deleted file mode 100644
index 3eba3233f08..00000000000
--- a/spec/lib/gitlab/redis/cluster_rate_limiting_spec.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Redis::ClusterRateLimiting, feature_category: :redis do
- include_examples "redis_new_instance_shared_examples", 'cluster_rate_limiting', Gitlab::Redis::Cache
-end
diff --git a/spec/lib/gitlab/redis/db_load_balancing_spec.rb b/spec/lib/gitlab/redis/db_load_balancing_spec.rb
index d633413ddec..d3d3ced62a9 100644
--- a/spec/lib/gitlab/redis/db_load_balancing_spec.rb
+++ b/spec/lib/gitlab/redis/db_load_balancing_spec.rb
@@ -41,12 +41,4 @@ RSpec.describe Gitlab::Redis::DbLoadBalancing, feature_category: :scalability do
it_behaves_like 'multi store feature flags', :use_primary_and_secondary_stores_for_db_load_balancing,
:use_primary_store_as_default_for_db_load_balancing
end
-
- describe '#raw_config_hash' do
- it 'has a legacy default URL' do
- expect(subject).to receive(:fetch_config).and_return(false)
-
- expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6382')
- end
- end
end
diff --git a/spec/lib/gitlab/redis/feature_flag_spec.rb b/spec/lib/gitlab/redis/feature_flag_spec.rb
new file mode 100644
index 00000000000..49d15ea1b4a
--- /dev/null
+++ b/spec/lib/gitlab/redis/feature_flag_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Redis::FeatureFlag, feature_category: :redis do
+ include_examples "redis_new_instance_shared_examples", 'feature_flag', Gitlab::Redis::Cache
+
+ describe '.cache_store' do
+ it 'has a default ttl of 1 hour' do
+ expect(described_class.cache_store.options[:expires_in]).to eq(1.hour)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/redis/multi_store_spec.rb b/spec/lib/gitlab/redis/multi_store_spec.rb
index 423a7e80ead..e45c29a9dd2 100644
--- a/spec/lib/gitlab/redis/multi_store_spec.rb
+++ b/spec/lib/gitlab/redis/multi_store_spec.rb
@@ -210,47 +210,6 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
end
- RSpec.shared_examples_for 'fallback read from the non-default store' do
- let(:counter) { Gitlab::Metrics::NullMetric.instance }
-
- before do
- allow(Gitlab::Metrics).to receive(:counter).and_return(counter)
- end
-
- it 'fallback and execute on secondary instance' do
- expect(multi_store.fallback_store).to receive(name).with(*expected_args).and_call_original
-
- subject
- end
-
- it 'logs the ReadFromPrimaryError' do
- expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
- an_instance_of(Gitlab::Redis::MultiStore::ReadFromPrimaryError),
- hash_including(command_name: name, instance_name: instance_name)
- )
-
- subject
- end
-
- it 'increment read fallback count metrics' do
- expect(counter).to receive(:increment).with(command: name, instance_name: instance_name)
-
- subject
- end
-
- include_examples 'reads correct value'
-
- context 'when fallback read from the secondary instance raises an exception' do
- before do
- allow(multi_store.fallback_store).to receive(name).with(*expected_args).and_raise(StandardError)
- end
-
- it 'fails with exception' do
- expect { subject }.to raise_error(StandardError)
- end
- end
- end
-
RSpec.shared_examples_for 'secondary store' do
it 'execute on the secondary instance' do
expect(secondary_store).to receive(name).with(*expected_args).and_call_original
@@ -283,31 +242,21 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
subject
end
- unless params[:block]
- it 'does not execute on the secondary store' do
- expect(secondary_store).not_to receive(name)
-
- subject
- end
- end
-
include_examples 'reads correct value'
end
- context 'when reading from primary instance is raising an exception' do
+ context 'when reading from default instance is raising an exception' do
before do
allow(multi_store.default_store).to receive(name).with(*expected_args).and_raise(StandardError)
allow(Gitlab::ErrorTracking).to receive(:log_exception)
end
- it 'logs the exception' do
+ it 'logs the exception and re-raises the error' do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError),
hash_including(:multi_store_error_message, instance_name: instance_name, command_name: name))
- subject
+ expect { subject }.to raise_error(an_instance_of(StandardError))
end
-
- include_examples 'fallback read from the non-default store'
end
context 'when reading from empty default instance' do
@@ -316,7 +265,9 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
multi_store.default_store.flushdb
end
- include_examples 'fallback read from the non-default store'
+ it 'does not call the fallback store' do
+ expect(multi_store.fallback_store).not_to receive(name)
+ end
end
context 'when the command is executed within pipelined block' do
@@ -346,16 +297,16 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
context 'when block is provided' do
- it 'both stores yields to the block' do
+ it 'only default store yields to the block' do
expect(primary_store).to receive(name).and_yield(value)
- expect(secondary_store).to receive(name).and_yield(value)
+ expect(secondary_store).not_to receive(name).and_yield(value)
subject
end
- it 'both stores to execute' do
+ it 'only default store to execute' do
expect(primary_store).to receive(name).with(*expected_args).and_call_original
- expect(secondary_store).to receive(name).with(*expected_args).and_call_original
+ expect(secondary_store).not_to receive(name).with(*expected_args).and_call_original
subject
end
@@ -382,7 +333,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
- it 'executes only on secondary redis store', :aggregate_errors do
+ it 'executes only on secondary redis store', :aggregate_failures do
expect(secondary_store).to receive(name).with(*expected_args).and_call_original
expect(primary_store).not_to receive(name).with(*expected_args).and_call_original
@@ -391,7 +342,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
context 'when using primary store as default' do
- it 'executes only on primary redis store', :aggregate_errors do
+ it 'executes only on primary redis store', :aggregate_failures do
expect(primary_store).to receive(name).with(*expected_args).and_call_original
expect(secondary_store).not_to receive(name).with(*expected_args).and_call_original
@@ -421,27 +372,19 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
subject do
multi_store.mget(values) do |v|
multi_store.sadd(skey, v)
- multi_store.scard(skey)
- end
- end
-
- RSpec.shared_examples_for 'primary instance executes block' do
- it 'ensures primary instance is executing the block' do
- expect(primary_store).to receive(:send).with(:mget, values).and_call_original
- expect(primary_store).to receive(:send).with(:sadd, skey, %w[1 2 3]).and_call_original
- expect(primary_store).to receive(:send).with(:scard, skey).and_call_original
-
- expect(secondary_store).to receive(:send).with(:mget, values).and_call_original
- expect(secondary_store).to receive(:send).with(:sadd, skey, %w[10 20 30]).and_call_original
- expect(secondary_store).to receive(:send).with(:scard, skey).and_call_original
-
- subject
end
end
context 'when using both stores' do
context 'when primary instance is default store' do
- it_behaves_like 'primary instance executes block'
+ it 'ensures primary instance is executing the block' do
+ expect(primary_store).to receive(:send).with(:mget, values).and_call_original
+ expect(primary_store).to receive(:send).with(:sadd, skey, %w[1 2 3]).and_call_original
+
+ expect(secondary_store).not_to receive(:send)
+
+ subject
+ end
end
context 'when secondary instance is default store' do
@@ -449,8 +392,14 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
- # multistore read still favours the primary store
- it_behaves_like 'primary instance executes block'
+ it 'ensures secondary instance is executing the block' do
+ expect(primary_store).not_to receive(:send)
+
+ expect(secondary_store).to receive(:send).with(:mget, values).and_call_original
+ expect(secondary_store).to receive(:send).with(:sadd, skey, %w[10 20 30]).and_call_original
+
+ subject
+ end
end
end
@@ -465,7 +414,6 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
expect(primary_store).to receive(:send).with(:mget, values).and_call_original
expect(primary_store).to receive(:send).with(:sadd, skey, %w[1 2 3]).and_call_original
- expect(primary_store).to receive(:send).with(:scard, skey).and_call_original
subject
end
@@ -479,7 +427,6 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
it 'ensures only secondary instance is executing the block' do
expect(secondary_store).to receive(:send).with(:mget, values).and_call_original
expect(secondary_store).to receive(:send).with(:sadd, skey, %w[10 20 30]).and_call_original
- expect(secondary_store).to receive(:send).with(:scard, skey).and_call_original
expect(primary_store).not_to receive(:send)
@@ -490,7 +437,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
RSpec.shared_examples_for 'verify that store contains values' do |store|
- it "#{store} redis store contains correct values", :aggregate_errors do
+ it "#{store} redis store contains correct values", :aggregate_failures do
subject
redis_store = multi_store.send(store)
@@ -583,7 +530,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
context 'when executing on primary instance is successful' do
- it 'executes on both primary and secondary redis store', :aggregate_errors do
+ it 'executes on both primary and secondary redis store', :aggregate_failures do
expect(primary_store).to receive(name).with(*expected_args).and_call_original
expect(secondary_store).to receive(name).with(*expected_args).and_call_original
@@ -604,7 +551,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
- it 'executes only on secondary redis store', :aggregate_errors do
+ it 'executes only on secondary redis store', :aggregate_failures do
expect(secondary_store).to receive(name).with(*expected_args).and_call_original
expect(primary_store).not_to receive(name).with(*expected_args).and_call_original
@@ -613,7 +560,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
context 'when using primary store as default' do
- it 'executes only on primary redis store', :aggregate_errors do
+ it 'executes only on primary redis store', :aggregate_failures do
expect(primary_store).to receive(name).with(*expected_args).and_call_original
expect(secondary_store).not_to receive(name).with(*expected_args).and_call_original
@@ -628,7 +575,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
allow(Gitlab::ErrorTracking).to receive(:log_exception)
end
- it 'logs the exception and execute on secondary instance', :aggregate_errors do
+ it 'logs the exception and execute on secondary instance', :aggregate_failures do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError),
hash_including(:multi_store_error_message, command_name: name, instance_name: instance_name))
expect(secondary_store).to receive(name).with(*expected_args).and_call_original
@@ -646,7 +593,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
end
- it 'is executed only 1 time on each instance', :aggregate_errors do
+ it 'is executed only 1 time on each instance', :aggregate_failures do
expect(primary_store).to receive(:pipelined).and_call_original
expect_next_instance_of(Redis::PipelinedConnection) do |pipeline|
expect(pipeline).to receive(name).with(*expected_args).once.and_call_original
@@ -668,120 +615,6 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
# rubocop:enable RSpec/MultipleMemoizedHelpers
- context 'with ENUMERATOR_COMMANDS redis commands' do
- let_it_be(:hkey) { "redis:hash" }
- let_it_be(:skey) { "redis:set" }
- let_it_be(:zkey) { "redis:sortedset" }
- let_it_be(:rvalue) { "value1" }
- let_it_be(:scan_kwargs) { { match: 'redis:hash' } }
-
- where(:case_name, :name, :args, :kwargs) do
- 'execute :scan_each command' | :scan_each | nil | ref(:scan_kwargs)
- 'execute :sscan_each command' | :sscan_each | ref(:skey) | {}
- 'execute :hscan_each command' | :hscan_each | ref(:hkey) | {}
- 'execute :zscan_each command' | :zscan_each | ref(:zkey) | {}
- end
-
- before(:all) do
- primary_store.hset(hkey, rvalue, 1)
- primary_store.sadd?(skey, rvalue)
- primary_store.zadd(zkey, 1, rvalue)
-
- secondary_store.hset(hkey, rvalue, 1)
- secondary_store.sadd?(skey, rvalue)
- secondary_store.zadd(zkey, 1, rvalue)
- end
-
- RSpec.shared_examples_for 'enumerator commands execution' do |both_stores, default_primary|
- context 'without block passed in' do
- subject do
- multi_store.send(name, *args, **kwargs)
- end
-
- it 'returns an enumerator' do
- expect(subject).to be_instance_of(Enumerator)
- end
- end
-
- context 'with block passed in' do
- subject do
- multi_store.send(name, *args, **kwargs) { |key| multi_store.incr(rvalue) }
- end
-
- it 'returns nil' do
- expect(subject).to eq(nil)
- end
-
- it 'runs block on correct Redis instance' do
- if both_stores
- expect(primary_store).to receive(name).with(*expected_args).and_call_original
- expect(secondary_store).to receive(name).with(*expected_args).and_call_original
-
- expect(primary_store).to receive(:incr).with(rvalue)
- expect(secondary_store).to receive(:incr).with(rvalue)
- elsif default_primary
- expect(primary_store).to receive(name).with(*expected_args).and_call_original
- expect(primary_store).to receive(:incr).with(rvalue)
-
- expect(secondary_store).not_to receive(name)
- expect(secondary_store).not_to receive(:incr).with(rvalue)
- else
- expect(secondary_store).to receive(name).with(*expected_args).and_call_original
- expect(secondary_store).to receive(:incr).with(rvalue)
-
- expect(primary_store).not_to receive(name)
- expect(primary_store).not_to receive(:incr).with(rvalue)
- end
-
- subject
- end
- end
- end
-
- with_them do
- describe name.to_s do
- let(:expected_args) { kwargs.present? ? [*args, { **kwargs }] : Array(args) }
-
- before do
- allow(primary_store).to receive(name).and_call_original
- allow(secondary_store).to receive(name).and_call_original
- end
-
- context 'when only using 1 store' do
- before do
- stub_feature_flags(use_primary_and_secondary_stores_for_test_store: false)
- end
-
- context 'when using secondary store as default' do
- before do
- stub_feature_flags(use_primary_store_as_default_for_test_store: false)
- end
-
- it_behaves_like 'enumerator commands execution', false, false
- end
-
- context 'when using primary store as default' do
- it_behaves_like 'enumerator commands execution', false, true
- end
- end
-
- context 'when using both stores' do
- context 'when using secondary store as default' do
- before do
- stub_feature_flags(use_primary_store_as_default_for_test_store: false)
- end
-
- it_behaves_like 'enumerator commands execution', true, false
- end
-
- context 'when using primary store as default' do
- it_behaves_like 'enumerator commands execution', true, true
- end
- end
- end
- end
- end
-
RSpec.shared_examples_for 'pipelined command' do |name|
let_it_be(:key1) { "redis:{1}:key_a" }
let_it_be(:value1) { "redis_value1" }
@@ -812,7 +645,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
context 'when executing on primary instance is successful' do
- it 'executes on both primary and secondary redis store', :aggregate_errors do
+ it 'executes on both primary and secondary redis store', :aggregate_failures do
expect(primary_store).to receive(name).and_call_original
expect(secondary_store).to receive(name).and_call_original
@@ -829,7 +662,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
allow(Gitlab::ErrorTracking).to receive(:log_exception)
end
- it 'logs the exception and execute on secondary instance', :aggregate_errors do
+ it 'logs the exception and execute on secondary instance', :aggregate_failures do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError),
hash_including(:multi_store_error_message, command_name: name))
expect(secondary_store).to receive(name).and_call_original
@@ -927,7 +760,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
- it 'executes on secondary store', :aggregate_errors do
+ it 'executes on secondary store', :aggregate_failures do
expect(primary_store).not_to receive(:send).and_call_original
expect(secondary_store).to receive(:send).and_call_original
@@ -936,7 +769,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
context 'when using primary store as default' do
- it 'executes on primary store', :aggregate_errors do
+ it 'executes on primary store', :aggregate_failures do
expect(secondary_store).not_to receive(:send).and_call_original
expect(primary_store).to receive(:send).and_call_original
@@ -1097,7 +930,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
subject
end
- it 'fallback and executes only on the secondary store', :aggregate_errors do
+ it 'fallback and executes only on the secondary store', :aggregate_failures do
expect(primary_store).to receive(:command).and_call_original
expect(secondary_store).not_to receive(:command)
@@ -1122,7 +955,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
end
context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do
- it 'fallback and executes only on the secondary store', :aggregate_errors do
+ it 'fallback and executes only on the secondary store', :aggregate_failures do
expect(primary_store).to receive(:command).and_call_original
expect(secondary_store).not_to receive(:command)
@@ -1135,7 +968,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
- it 'fallback and executes only on the secondary store', :aggregate_errors do
+ it 'fallback and executes only on the secondary store', :aggregate_failures do
expect(secondary_store).to receive(:command).and_call_original
expect(primary_store).not_to receive(:command)
@@ -1148,7 +981,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do
multi_store.pipelined(&:command)
end
- it 'is executed only 1 time on each instance', :aggregate_errors do
+ it 'is executed only 1 time on each instance', :aggregate_failures do
expect(primary_store).to receive(:pipelined).once.and_call_original
expect(secondary_store).to receive(:pipelined).once.and_call_original
diff --git a/spec/lib/gitlab/redis/queues_spec.rb b/spec/lib/gitlab/redis/queues_spec.rb
index a0f73a654e7..62b30431f6f 100644
--- a/spec/lib/gitlab/redis/queues_spec.rb
+++ b/spec/lib/gitlab/redis/queues_spec.rb
@@ -4,7 +4,6 @@ require 'spec_helper'
RSpec.describe Gitlab::Redis::Queues do
let(:instance_specific_config_file) { "config/redis.queues.yml" }
- let(:environment_config_file_name) { "GITLAB_REDIS_QUEUES_CONFIG_FILE" }
include_examples "redis_shared_examples"
@@ -13,14 +12,6 @@ RSpec.describe Gitlab::Redis::Queues do
expect(subject).to receive(:fetch_config) { config }
end
- context 'when the config url is blank' do
- let(:config) { nil }
-
- it 'has a legacy default URL' do
- expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6381' )
- end
- end
-
context 'when the config url is present' do
let(:config) { { url: 'redis://localhost:1111' } }
diff --git a/spec/lib/gitlab/redis/rate_limiting_spec.rb b/spec/lib/gitlab/redis/rate_limiting_spec.rb
index d82228426f0..0bea7f8bcb2 100644
--- a/spec/lib/gitlab/redis/rate_limiting_spec.rb
+++ b/spec/lib/gitlab/redis/rate_limiting_spec.rb
@@ -6,19 +6,8 @@ RSpec.describe Gitlab::Redis::RateLimiting do
include_examples "redis_new_instance_shared_examples", 'rate_limiting', Gitlab::Redis::Cache
describe '.cache_store' do
- context 'when encountering an error' do
- subject { described_class.cache_store.read('x') }
-
- before do
- described_class.with do |redis|
- allow(redis).to receive(:get).and_raise(::Redis::CommandError)
- end
- end
-
- it 'logs error' do
- expect(::Gitlab::ErrorTracking).to receive(:log_exception)
- subject
- end
+ it 'uses the CACHE_NAMESPACE namespace' do
+ expect(described_class.cache_store.options[:namespace]).to eq(Gitlab::Redis::Cache::CACHE_NAMESPACE)
end
end
end
diff --git a/spec/lib/gitlab/redis/repository_cache_spec.rb b/spec/lib/gitlab/redis/repository_cache_spec.rb
index 2c167a6eb62..bc48ee208c1 100644
--- a/spec/lib/gitlab/redis/repository_cache_spec.rb
+++ b/spec/lib/gitlab/redis/repository_cache_spec.rb
@@ -5,32 +5,9 @@ require 'spec_helper'
RSpec.describe Gitlab::Redis::RepositoryCache, feature_category: :scalability do
include_examples "redis_new_instance_shared_examples", 'repository_cache', Gitlab::Redis::Cache
- describe '#raw_config_hash' do
- it 'has a legacy default URL' do
- expect(subject).to receive(:fetch_config).and_return(false)
-
- expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6380')
- end
- end
-
describe '.cache_store' do
it 'has a default ttl of 8 hours' do
expect(described_class.cache_store.options[:expires_in]).to eq(8.hours)
end
-
- context 'when encountering an error' do
- subject { described_class.cache_store.read('x') }
-
- before do
- described_class.with do |redis|
- allow(redis).to receive(:get).and_raise(::Redis::CommandError)
- end
- end
-
- it 'logs error' do
- expect(::Gitlab::ErrorTracking).to receive(:log_exception)
- subject
- end
- end
end
end
diff --git a/spec/lib/gitlab/redis/shared_state_spec.rb b/spec/lib/gitlab/redis/shared_state_spec.rb
index d240abfbf5b..a5247903d50 100644
--- a/spec/lib/gitlab/redis/shared_state_spec.rb
+++ b/spec/lib/gitlab/redis/shared_state_spec.rb
@@ -4,15 +4,6 @@ require 'spec_helper'
RSpec.describe Gitlab::Redis::SharedState do
let(:instance_specific_config_file) { "config/redis.shared_state.yml" }
- let(:environment_config_file_name) { "GITLAB_REDIS_SHARED_STATE_CONFIG_FILE" }
include_examples "redis_shared_examples"
-
- describe '#raw_config_hash' do
- it 'has a legacy default URL' do
- expect(subject).to receive(:fetch_config) { false }
-
- expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6382' )
- end
- end
end
diff --git a/spec/lib/gitlab/redis/sidekiq_status_spec.rb b/spec/lib/gitlab/redis/sidekiq_status_spec.rb
index bbfec13e6c8..45578030ca8 100644
--- a/spec/lib/gitlab/redis/sidekiq_status_spec.rb
+++ b/spec/lib/gitlab/redis/sidekiq_status_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe Gitlab::Redis::SidekiqStatus do
# to move away from `Sidekiq.redis` for sidekiq status data. Thus, we use the
# same store configuration as the former.
let(:instance_specific_config_file) { "config/redis.shared_state.yml" }
- let(:environment_config_file_name) { "GITLAB_REDIS_SHARED_STATE_CONFIG_FILE" }
include_examples "redis_shared_examples"
@@ -49,14 +48,6 @@ RSpec.describe Gitlab::Redis::SidekiqStatus do
:use_primary_store_as_default_for_sidekiq_status
end
- describe '#raw_config_hash' do
- it 'has a legacy default URL' do
- expect(subject).to receive(:fetch_config) { false }
-
- expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6382')
- end
- end
-
describe '#store_name' do
it 'returns the name of the SharedState store' do
expect(described_class.store_name).to eq('SharedState')
diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb
index 4d608c07736..62fcb4821fc 100644
--- a/spec/lib/gitlab/reference_extractor_spec.rb
+++ b/spec/lib/gitlab/reference_extractor_spec.rb
@@ -193,7 +193,7 @@ RSpec.describe Gitlab::ReferenceExtractor do
end
context 'with an external issue tracker' do
- let(:project) { create(:project, :with_jira_integration) }
+ let_it_be(:project) { create(:project, :with_jira_integration) }
let(:issue) { create(:issue, project: project) }
context 'when GitLab issues are enabled' do
@@ -301,7 +301,7 @@ RSpec.describe Gitlab::ReferenceExtractor do
describe 'referables prefixes' do
def prefixes
- described_class::REFERABLES.each_with_object({}) do |referable, result|
+ described_class.referrables.each_with_object({}) do |referable, result|
class_name = referable.to_s.camelize
klass = class_name.constantize if Object.const_defined?(class_name)
@@ -314,7 +314,7 @@ RSpec.describe Gitlab::ReferenceExtractor do
end
it 'returns all supported prefixes' do
- expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & [vulnerability: *iteration:))
+ expect(prefixes.keys.uniq).to include(*%w(@ # ~ % ! $ & [vulnerability:))
end
it 'does not allow one prefix for multiple referables if not allowed specificly' do
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index bc0f9e22d50..5e58282ff92 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -79,10 +79,10 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do
it {
is_expected
- .to eq("cannot start with a non-alphanumeric character except for periods or underscores, " \
- "can contain only alphanumeric characters, forward slashes, periods, and underscores, " \
- "cannot end with a period or forward slash, and has a relative path structure " \
- "with no http protocol chars or leading or trailing forward slashes")
+ .to eq("must have a relative path structure with no HTTP " \
+ "protocol characters, or leading or trailing forward slashes. Path segments must not start or " \
+ "end with a special character, and must not contain consecutive special characters."
+ )
}
end
@@ -101,22 +101,37 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do
it { is_expected.not_to match('good_for+you') }
it { is_expected.not_to match('source/') }
it { is_expected.not_to match('.source/full./path') }
+ it { is_expected.not_to match('.source/.full/.path') }
+ it { is_expected.not_to match('_source') }
+ it { is_expected.not_to match('.source') }
it { is_expected.to match('source') }
- it { is_expected.to match('.source') }
- it { is_expected.to match('_source') }
it { is_expected.to match('source/full') }
it { is_expected.to match('source/full/path') }
- it { is_expected.to match('.source/.full/.path') }
+ it { is_expected.to match('sou_rce/fu-ll/pa.th') }
it { is_expected.to match('domain_namespace') }
it { is_expected.to match('gitlab-migration-test') }
+ it { is_expected.to match('1-project-path') }
+ it { is_expected.to match('e-project-path') }
it { is_expected.to match('') } # it is possible to pass an empty string for destination_namespace in bulk_import POST request
end
+ describe '.bulk_import_source_full_path_regex_message' do
+ subject { described_class.bulk_import_source_full_path_regex_message }
+
+ it {
+ is_expected
+ .to eq(
+ "must have a relative path structure with no HTTP " \
+ "protocol characters, or leading or trailing forward slashes. Path segments must not start or " \
+ "end with a special character, and must not contain consecutive special characters."
+ )
+ }
+ end
+
describe '.bulk_import_source_full_path_regex' do
subject { described_class.bulk_import_source_full_path_regex }
- it { is_expected.not_to match('?gitlab') }
it { is_expected.not_to match("Users's something") }
it { is_expected.not_to match('/source') }
it { is_expected.not_to match('http:') }
@@ -124,20 +139,32 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do
it { is_expected.not_to match('example.com/?stuff=true') }
it { is_expected.not_to match('example.com:5000/?stuff=true') }
it { is_expected.not_to match('http://gitlab.example/gitlab-org/manage/import/gitlab-migration-test') }
- it { is_expected.not_to match('_good_for_me!') }
- it { is_expected.not_to match('good_for+you') }
it { is_expected.not_to match('source/') }
- it { is_expected.not_to match('.source/full./path') }
it { is_expected.not_to match('') }
+ it { is_expected.not_to match('.source/full./path') }
+ it { is_expected.not_to match('?gitlab') }
+ it { is_expected.not_to match('_good_for_me!') }
+ it { is_expected.not_to match('group/@*%_my_other-project-----') }
+ it { is_expected.not_to match('_foog-for-me!') }
+ it { is_expected.not_to match('.source/full/path.') }
+ it { is_expected.to match('good_for+you') }
it { is_expected.to match('source') }
it { is_expected.to match('.source') }
it { is_expected.to match('_source') }
it { is_expected.to match('source/full') }
it { is_expected.to match('source/full/path') }
- it { is_expected.to match('.source/.full/.path') }
it { is_expected.to match('domain_namespace') }
it { is_expected.to match('gitlab-migration-test') }
+ it { is_expected.to match('source/full/path-') }
+ it { is_expected.to match('.source/full/path') }
+ it { is_expected.to match('.source/.full/.path') }
+ it { is_expected.to match('source/full/.path') }
+ it { is_expected.to match('source/full/..path') }
+ it { is_expected.to match('source/full/---1path') }
+ it { is_expected.to match('source/full/-___path') }
+ it { is_expected.to match('source/full/path---') }
+ it { is_expected.to match('group/__my_other-project-----') }
end
describe '.group_path_regex' do
@@ -710,6 +737,7 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do
it { is_expected.to match('libsample0_1.2.3~alpha2_amd64.deb') }
it { is_expected.to match('sample-dev_1.2.3~binary_amd64.deb') }
it { is_expected.to match('sample-udeb_1.2.3~alpha2_amd64.udeb') }
+ it { is_expected.to match('sample-ddeb_1.2.3~alpha2_amd64.ddeb') }
it { is_expected.not_to match('sample_1.2.3~alpha2_amd64.buildinfo') }
it { is_expected.not_to match('sample_1.2.3~alpha2_amd64.changes') }
@@ -1015,6 +1043,34 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do
it { is_expected.not_to match('/api/v4/groups/1234/packages/debian/pool/compon/a/pkg/file.name') }
end
+ describe 'Packages::MAVEN_SNAPSHOT_DYNAMIC_PARTS' do
+ subject { described_class::Packages::MAVEN_SNAPSHOT_DYNAMIC_PARTS }
+
+ it { is_expected.to match('test-2.11-20230303.163304-1.jar') }
+ it { is_expected.to match('test-2.11-20230303.163304-1-javadoc.jar') }
+ it { is_expected.to match('test-2.11-20230303.163304-1-sources.jar') }
+ it { is_expected.to match('test-2.11-20230303.163304-1-20230303.163304-1.jar') }
+ it { is_expected.to match('test-2.11-20230303.163304-1-20230303.163304-1-javadoc.jar') }
+ it { is_expected.to match('test-2.11-20230303.163304-1-20230303.163304-1-sources.jar') }
+ it { is_expected.to match("#{'a' * 500}-20230303.163304-1-sources.jar") }
+ it { is_expected.to match("test-2.11-20230303.163304-1-#{'a' * 500}.jar") }
+ it { is_expected.to match("#{'a' * 500}-20230303.163304-1-#{'a' * 500}.jar") }
+
+ it { is_expected.not_to match('') }
+ it { is_expected.not_to match(nil) }
+ it { is_expected.not_to match('test') }
+ it { is_expected.not_to match('1.2.3') }
+ it { is_expected.not_to match('1.2.3-javadoc.jar') }
+ it { is_expected.not_to match('-202303039.163304-1.jar') }
+ it { is_expected.not_to match('test-2.11-202303039.163304-1.jar') }
+ it { is_expected.not_to match('test-2.11-20230303.16330-1.jar') }
+ it { is_expected.not_to match('test-2.11-202303039.163304.jar') }
+ it { is_expected.not_to match('test-2.11-202303039.163304-.jar') }
+ it { is_expected.not_to match("#{'a' * 2000}-20230303.163304-1-sources.jar") }
+ it { is_expected.not_to match("test-2.11-20230303.163304-1-#{'a' * 2000}.jar") }
+ it { is_expected.not_to match("#{'a' * 2000}-20230303.163304-1-#{'a' * 2000}.jar") }
+ end
+
describe '.composer_package_version_regex' do
subject { described_class.composer_package_version_regex }
@@ -1133,10 +1189,21 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do
MARKDOWN
end
- it { is_expected.to match(%(<section>\nsomething\n</section>)) }
- it { is_expected.not_to match(%(must start in first column <section>\nsomething\n</section>)) }
- it { is_expected.not_to match(%(<section>must be multi-line</section>)) }
- it { expect(subject.match(markdown)[:html]).to eq expected }
+ describe 'normal regular expression' do
+ it { is_expected.to match(%(<section>\nsomething\n</section>)) }
+ it { is_expected.not_to match(%(must start in first column <section>\nsomething\n</section>)) }
+ it { is_expected.not_to match(%(<section>must be multi-line</section>)) }
+ it { expect(subject.match(markdown)[:html]).to eq expected }
+ end
+
+ describe 'untrusted regular expression' do
+ subject { Gitlab::UntrustedRegexp.new(described_class::MARKDOWN_HTML_BLOCK_REGEX_UNTRUSTED, multiline: true) }
+
+ it { is_expected.to match(%(<section>\nsomething\n</section>)) }
+ it { is_expected.not_to match(%(must start in first column <section>\nsomething\n</section>)) }
+ it { is_expected.not_to match(%(<section>must be multi-line</section>)) }
+ it { expect(subject.match(markdown)[:html]).to eq expected }
+ end
end
context 'HTML comment lines' do
diff --git a/spec/lib/gitlab/repository_set_cache_spec.rb b/spec/lib/gitlab/repository_set_cache_spec.rb
index c93fd884347..65a50b68c44 100644
--- a/spec/lib/gitlab/repository_set_cache_spec.rb
+++ b/spec/lib/gitlab/repository_set_cache_spec.rb
@@ -72,48 +72,60 @@ RSpec.describe Gitlab::RepositorySetCache, :clean_gitlab_redis_cache do
end
describe '#expire' do
- subject { cache.expire(*keys) }
+ shared_examples 'expires varying amount of keys' do
+ subject { cache.expire(*keys) }
- before do
- cache.write(:foo, ['value'])
- cache.write(:bar, ['value2'])
- end
+ before do
+ cache.write(:foo, ['value'])
+ cache.write(:bar, ['value2'])
+ end
- it 'actually wrote the values' do
- expect(cache.read(:foo)).to contain_exactly('value')
- expect(cache.read(:bar)).to contain_exactly('value2')
- end
+ it 'actually wrote the values' do
+ expect(cache.read(:foo)).to contain_exactly('value')
+ expect(cache.read(:bar)).to contain_exactly('value2')
+ end
- context 'single key' do
- let(:keys) { %w(foo) }
+ context 'single key' do
+ let(:keys) { %w(foo) }
- it { is_expected.to eq(1) }
+ it { is_expected.to eq(1) }
- it 'deletes the given key from the cache' do
- subject
+ it 'deletes the given key from the cache' do
+ subject
- expect(cache.read(:foo)).to be_empty
+ expect(cache.read(:foo)).to be_empty
+ end
end
- end
- context 'multiple keys' do
- let(:keys) { %w(foo bar) }
+ context 'multiple keys' do
+ let(:keys) { %w(foo bar) }
- it { is_expected.to eq(2) }
+ it { is_expected.to eq(2) }
- it 'deletes the given keys from the cache' do
- subject
+ it 'deletes the given keys from the cache' do
+ subject
- expect(cache.read(:foo)).to be_empty
- expect(cache.read(:bar)).to be_empty
+ expect(cache.read(:foo)).to be_empty
+ expect(cache.read(:bar)).to be_empty
+ end
+ end
+
+ context 'no keys' do
+ let(:keys) { [] }
+
+ it { is_expected.to eq(0) }
end
end
- context 'no keys' do
- let(:keys) { [] }
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(use_pipeline_over_multikey: false)
+ end
- it { is_expected.to eq(0) }
+ it_behaves_like 'expires varying amount of keys'
end
+
+ it_behaves_like 'expires varying amount of keys'
end
describe '#exist?' do
diff --git a/spec/lib/gitlab/request_context_spec.rb b/spec/lib/gitlab/request_context_spec.rb
index b9acfa4a841..44664be7d39 100644
--- a/spec/lib/gitlab/request_context_spec.rb
+++ b/spec/lib/gitlab/request_context_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::RequestContext, :request_store do
+RSpec.describe Gitlab::RequestContext, :request_store, feature_category: :application_instrumentation do
subject { described_class.instance }
before do
@@ -11,6 +11,44 @@ RSpec.describe Gitlab::RequestContext, :request_store do
it { is_expected.to have_attributes(client_ip: nil, start_thread_cpu_time: nil, request_start_time: nil) }
+ describe '.start_request_context' do
+ let(:request) { ActionDispatch::Request.new({ 'REMOTE_ADDR' => '1.2.3.4' }) }
+ let(:start_request_context) { described_class.start_request_context(request: request) }
+
+ before do
+ allow(Gitlab::Metrics::System).to receive(:real_time).and_return(123)
+ end
+
+ it 'sets the client IP' do
+ expect { start_request_context }.to change { subject.client_ip }.from(nil).to('1.2.3.4')
+ end
+
+ it 'sets the spam params' do
+ expect { start_request_context }.to change { subject.spam_params }.from(nil).to(::Spam::SpamParams)
+ end
+
+ it 'sets the request start time' do
+ expect { start_request_context }.to change { subject.request_start_time }.from(nil).to(123)
+ end
+ end
+
+ describe '.start_thread_context' do
+ let(:start_thread_context) { described_class.start_thread_context }
+
+ before do
+ allow(Gitlab::Metrics::System).to receive(:thread_cpu_time).and_return(123)
+ allow(Gitlab::Memory::Instrumentation).to receive(:start_thread_memory_allocations).and_return(456)
+ end
+
+ it 'sets the thread cpu time' do
+ expect { start_thread_context }.to change { subject.start_thread_cpu_time }.from(nil).to(123)
+ end
+
+ it 'sets the thread memory allocations' do
+ expect { start_thread_context }.to change { subject.thread_memory_allocations }.from(nil).to(456)
+ end
+ end
+
describe '#request_deadline' do
let(:request_start_time) { 1575982156.206008 }
diff --git a/spec/lib/gitlab/resource_events/assignment_event_recorder_spec.rb b/spec/lib/gitlab/resource_events/assignment_event_recorder_spec.rb
new file mode 100644
index 00000000000..b15f95dbd9c
--- /dev/null
+++ b/spec/lib/gitlab/resource_events/assignment_event_recorder_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::ResourceEvents::AssignmentEventRecorder, feature_category: :value_stream_management do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user1) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+ let_it_be(:user3) { create(:user) }
+
+ let_it_be_with_refind(:issue_with_two_assignees) { create(:issue, assignees: [user1, user2]) }
+ let_it_be_with_refind(:mr_with_no_assignees) { create(:merge_request) }
+ let_it_be_with_refind(:mr_with_one_assignee) { create(:merge_request, assignee: [user3]) }
+
+ let(:parent_records) do
+ {
+ issue_with_two_assignees: issue_with_two_assignees,
+ mr_with_no_assignees: mr_with_no_assignees,
+ mr_with_one_assignee: mr_with_one_assignee
+ }
+ end
+
+ let(:user_records) do
+ {
+ user1: user1,
+ user2: user2,
+ user3: user3
+ }
+ end
+
+ where(:parent, :new_assignees, :assignee_history) do
+ :issue_with_two_assignees | [:user1, :user2, :user3] | [[:user3, :add]]
+ :issue_with_two_assignees | [:user1, :user3] | [[:user2, :remove], [:user3, :add]]
+ :issue_with_two_assignees | [:user1] | [[:user2, :remove]]
+ :issue_with_two_assignees | [] | [[:user1, :remove], [:user2, :remove]]
+ :mr_with_no_assignees | [:user1] | [[:user1, :add]]
+ :mr_with_no_assignees | [] | []
+ :mr_with_one_assignee | [:user3] | []
+ :mr_with_one_assignee | [:user1] | [[:user3, :remove], [:user1, :add]]
+ end
+
+ with_them do
+ it 'records the assignment history corrently' do
+ parent_record = parent_records[parent]
+ old_assignees = parent_record.assignees.to_a
+ parent_record.assignees = new_assignees.map { |user_variable_name| user_records[user_variable_name] }
+
+ described_class.new(parent: parent_record, old_assignees: old_assignees).record
+
+ expected_records = assignee_history.map do |user_variable_name, action|
+ have_attributes({
+ user_id: user_records[user_variable_name].id,
+ action: action.to_s
+ })
+ end
+
+ expect(parent_record.assignment_events).to match_array(expected_records)
+ end
+ end
+
+ context 'when batching' do
+ it 'invokes multiple insert queries' do
+ stub_const('Gitlab::ResourceEvents::AssignmentEventRecorder::BATCH_SIZE', 1)
+
+ expect(ResourceEvents::MergeRequestAssignmentEvent).to receive(:insert_all).twice
+
+ described_class.new(parent: mr_with_one_assignee, old_assignees: [user1]).record # 1 assignment, 1 unassignment
+ end
+ end
+
+ context 'when duplicated old assignees were given' do
+ it 'deduplicates the records' do
+ expect do
+ described_class.new(parent: mr_with_one_assignee, old_assignees: [user3, user2, user2]).record
+ end.to change { ResourceEvents::MergeRequestAssignmentEvent.count }.by(1)
+ end
+ end
+
+ context 'when the record_issue_and_mr_assignee_events FF is off' do
+ before do
+ stub_feature_flags(record_issue_and_mr_assignee_events: false)
+ end
+
+ it 'does nothing' do
+ expect do
+ described_class.new(parent: mr_with_one_assignee, old_assignees: [user2, user3]).record
+ end.not_to change { mr_with_one_assignee.assignment_events.count }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/runtime_spec.rb b/spec/lib/gitlab/runtime_spec.rb
index 181a911c667..fa0fad65520 100644
--- a/spec/lib/gitlab/runtime_spec.rb
+++ b/spec/lib/gitlab/runtime_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Runtime do
+RSpec.describe Gitlab::Runtime, feature_category: :application_performance do
shared_examples "valid runtime" do |runtime, max_threads|
it "identifies itself" do
expect(subject.identify).to eq(runtime)
@@ -39,9 +39,21 @@ RSpec.describe Gitlab::Runtime do
end
end
+ context 'with Puma' do
+ before do
+ stub_const('::Puma::Server', double)
+ end
+
+ describe '.puma?' do
+ it 'returns true' do
+ expect(subject.puma?).to be true
+ end
+ end
+ end
+
context "on multiple matches" do
before do
- stub_const('::Puma', double)
+ stub_const('::Puma::Server', double)
stub_const('::Rails::Console', double)
end
@@ -64,6 +76,7 @@ RSpec.describe Gitlab::Runtime do
before do
stub_const('::Puma', puma_type)
+ allow(described_class).to receive(:puma?).and_return(true)
end
it_behaves_like "valid runtime", :puma, 1 + Gitlab::ActionCable::Config.worker_pool_size
@@ -75,6 +88,7 @@ RSpec.describe Gitlab::Runtime do
before do
stub_const('::Puma', puma_type)
+ allow(described_class).to receive(:puma?).and_return(true)
allow(puma_type).to receive_message_chain(:cli_config, :options).and_return(max_threads: 2, workers: max_workers)
end
diff --git a/spec/lib/gitlab/safe_device_detector_spec.rb b/spec/lib/gitlab/safe_device_detector_spec.rb
index c37dc1e1c7e..56ba084c435 100644
--- a/spec/lib/gitlab/safe_device_detector_spec.rb
+++ b/spec/lib/gitlab/safe_device_detector_spec.rb
@@ -4,7 +4,7 @@ require 'fast_spec_helper'
require 'device_detector'
require_relative '../../../lib/gitlab/safe_device_detector'
-RSpec.describe Gitlab::SafeDeviceDetector, feature_category: :authentication_and_authorization do
+RSpec.describe Gitlab::SafeDeviceDetector, feature_category: :system_access do
it 'retains the behavior for normal user agents' do
chrome_user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
diff --git a/spec/lib/gitlab/sanitizers/exception_message_spec.rb b/spec/lib/gitlab/sanitizers/exception_message_spec.rb
index 8b54b353235..c2c4a5de32d 100644
--- a/spec/lib/gitlab/sanitizers/exception_message_spec.rb
+++ b/spec/lib/gitlab/sanitizers/exception_message_spec.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
require 'fast_spec_helper'
+require 'addressable'
require 'rspec-parameterized'
-RSpec.describe Gitlab::Sanitizers::ExceptionMessage do
+RSpec.describe Gitlab::Sanitizers::ExceptionMessage, feature_category: :compliance_management do
describe '.clean' do
let(:exception_name) { exception.class.name }
let(:exception_message) { exception.message }
diff --git a/spec/lib/gitlab/seeders/ci/runner/runner_fleet_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/runner/runner_fleet_seeder_spec.rb
index fe52b586d49..4597cc6b315 100644
--- a/spec/lib/gitlab/seeders/ci/runner/runner_fleet_seeder_spec.rb
+++ b/spec/lib/gitlab/seeders/ci/runner/runner_fleet_seeder_spec.rb
@@ -67,5 +67,29 @@ RSpec.describe ::Gitlab::Seeders::Ci::Runner::RunnerFleetSeeder, feature_categor
expect(::Ci::Build.where(runner_id: project[:runner_ids])).to be_empty
end
end
+
+ context 'when number of group runners exceeds plan limit' do
+ before do
+ create(:plan_limits, :default_plan, ci_registered_group_runners: 1)
+ end
+
+ it { is_expected.to be_nil }
+
+ it 'does not change runner count' do
+ expect { seed }.not_to change { Ci::Runner.count }
+ end
+ end
+
+ context 'when number of project runners exceeds plan limit' do
+ before do
+ create(:plan_limits, :default_plan, ci_registered_project_runners: 1)
+ end
+
+ it { is_expected.to be_nil }
+
+ it 'does not change runner count' do
+ expect { seed }.not_to change { Ci::Runner.count }
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/seeders/ci/variables_group_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/variables_group_seeder_spec.rb
new file mode 100644
index 00000000000..52898cb17a5
--- /dev/null
+++ b/spec/lib/gitlab/seeders/ci/variables_group_seeder_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Gitlab::Seeders::Ci::VariablesGroupSeeder, feature_category: :secrets_management do
+ let_it_be(:group) { create(:group) }
+
+ let(:seeder) { described_class.new(name: group.name) }
+
+ let(:custom_seeder) do
+ described_class.new(
+ name: group.name,
+ seed_count: 2,
+ environment_scope: 'staging',
+ prefix: 'STAGING_'
+ )
+ end
+
+ let(:unique_env_seeder) do
+ described_class.new(
+ name: group.name,
+ seed_count: 2,
+ environment_scope: 'unique'
+ )
+ end
+
+ let(:invalid_group_name_seeder) do
+ described_class.new(
+ name: 'nonexistent_group',
+ seed_count: 1
+ )
+ end
+
+ describe '#seed' do
+ it 'creates group-level CI variables with default values' do
+ expect { seeder.seed }.to change {
+ group.variables.count
+ }.by(Gitlab::Seeders::Ci::VariablesGroupSeeder::DEFAULT_SEED_COUNT)
+
+ ci_variable = group.reload.variables.last
+
+ expect(ci_variable.key.include?('GROUP_VAR_')).to eq true
+ expect(ci_variable.environment_scope).to eq '*'
+ end
+
+ it 'creates group-level CI variables with custom arguments' do
+ expect { custom_seeder.seed }.to change {
+ group.variables.count
+ }.by(2)
+
+ ci_variable = group.reload.variables.last
+
+ expect(ci_variable.key.include?('STAGING_')).to eq true
+ expect(ci_variable.environment_scope).to eq 'staging'
+ end
+
+ it 'creates group-level CI variables with unique environment scopes' do
+ unique_env_seeder.seed
+
+ ci_variable_first_env = group.reload.variables.first.environment_scope
+ ci_variable_last_env = group.reload.variables.last.environment_scope
+
+ expect(ci_variable_first_env).not_to eq ci_variable_last_env
+ end
+
+ it 'skips seeding when group name is invalid' do
+ expect { invalid_group_name_seeder.seed }.to change {
+ group.variables.count
+ }.by(0)
+ end
+
+ it 'skips CI variable creation if CI variable already exists' do
+ group.variables.create!(
+ environment_scope: '*',
+ key: "GROUP_VAR_#{group.variables.maximum(:id).to_i}",
+ value: SecureRandom.hex(32)
+ )
+
+ # first id is assigned randomly, so we're creating a new variable
+ # based on that id that is sure to be skipped during seed
+ group.variables.create!(
+ environment_scope: '*',
+ key: "GROUP_VAR_#{group.variables.maximum(:id).to_i + 2}",
+ value: SecureRandom.hex(32)
+ )
+
+ expect { seeder.seed }.to change {
+ group.variables.count
+ }.by(Gitlab::Seeders::Ci::VariablesGroupSeeder::DEFAULT_SEED_COUNT - 1)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/seeders/ci/variables_instance_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/variables_instance_seeder_spec.rb
new file mode 100644
index 00000000000..5b6d2471edd
--- /dev/null
+++ b/spec/lib/gitlab/seeders/ci/variables_instance_seeder_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Gitlab::Seeders::Ci::VariablesInstanceSeeder, feature_category: :secrets_management do
+ let(:seeder) { described_class.new }
+
+ let(:custom_seeder) do
+ described_class.new(
+ seed_count: 2,
+ prefix: 'STAGING_'
+ )
+ end
+
+ describe '#seed' do
+ it 'creates instance-level CI variables with default values' do
+ expect { seeder.seed }.to change {
+ Ci::InstanceVariable.all.count
+ }.by(Gitlab::Seeders::Ci::VariablesInstanceSeeder::DEFAULT_SEED_COUNT)
+
+ ci_variable = Ci::InstanceVariable.last
+
+ expect(ci_variable.key.include?('INSTANCE_VAR_')).to eq true
+ end
+
+ it 'creates instance-level CI variables with custom arguments' do
+ expect { custom_seeder.seed }.to change {
+ Ci::InstanceVariable.all.count
+ }.by(2)
+
+ ci_variable = Ci::InstanceVariable.last
+
+ expect(ci_variable.key.include?('STAGING_')).to eq true
+ end
+
+ it 'skips CI variable creation if CI variable already exists' do
+ ::Ci::InstanceVariable.new(
+ key: "INSTANCE_VAR_#{::Ci::InstanceVariable.maximum(:id).to_i}",
+ value: SecureRandom.hex(32)
+ ).save!
+
+ # first id is assigned randomly, so we're creating a new variable
+ # based on that id that is sure to be skipped during seed
+ ::Ci::InstanceVariable.new(
+ key: "INSTANCE_VAR_#{::Ci::InstanceVariable.maximum(:id).to_i + 2}",
+ value: SecureRandom.hex(32)
+ ).save!
+
+ expect { seeder.seed }.to change {
+ Ci::InstanceVariable.all.count
+ }.by(Gitlab::Seeders::Ci::VariablesInstanceSeeder::DEFAULT_SEED_COUNT - 1)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/seeders/ci/variables_project_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/variables_project_seeder_spec.rb
new file mode 100644
index 00000000000..45b6a0a51fd
--- /dev/null
+++ b/spec/lib/gitlab/seeders/ci/variables_project_seeder_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Gitlab::Seeders::Ci::VariablesProjectSeeder, feature_category: :secrets_management do
+ let_it_be(:project) { create(:project) }
+
+ let(:seeder) { described_class.new(project_path: project.full_path) }
+
+ let(:custom_seeder) do
+ described_class.new(
+ project_path: project.full_path,
+ seed_count: 2,
+ environment_scope: 'staging',
+ prefix: 'STAGING_'
+ )
+ end
+
+ let(:unique_env_seeder) do
+ described_class.new(
+ project_path: project.full_path,
+ seed_count: 2,
+ environment_scope: 'unique'
+ )
+ end
+
+ let(:invalid_project_path_seeder) do
+ described_class.new(
+ project_path: 'invalid_path',
+ seed_count: 1
+ )
+ end
+
+ describe '#seed' do
+ it 'creates project-level CI variables with default values' do
+ expect { seeder.seed }.to change {
+ project.variables.count
+ }.by(Gitlab::Seeders::Ci::VariablesProjectSeeder::DEFAULT_SEED_COUNT)
+
+ ci_variable = project.reload.variables.last
+
+ expect(ci_variable.key.include?('VAR_')).to eq true
+ expect(ci_variable.environment_scope).to eq '*'
+ end
+
+ it 'creates project-level CI variables with custom arguments' do
+ expect { custom_seeder.seed }.to change {
+ project.variables.count
+ }.by(2)
+
+ ci_variable = project.reload.variables.last
+
+ expect(ci_variable.key.include?('STAGING_')).to eq true
+ expect(ci_variable.environment_scope).to eq 'staging'
+ end
+
+ it 'creates project-level CI variables with unique environment scopes' do
+ unique_env_seeder.seed
+
+ ci_variable_first_env = project.reload.variables.first.environment_scope
+ ci_variable_last_env = project.reload.variables.last.environment_scope
+
+ expect(ci_variable_first_env).not_to eq ci_variable_last_env
+ end
+
+ it 'skips seeding when project path is invalid' do
+ expect { invalid_project_path_seeder.seed }.to change {
+ project.variables.count
+ }.by(0)
+ end
+
+ it 'skips CI variable creation if CI variable already exists' do
+ project.variables.create!(
+ environment_scope: '*',
+ key: "VAR_#{project.variables.maximum(:id).to_i}",
+ value: SecureRandom.hex(32)
+ )
+
+ # first id is assigned randomly, so we're creating a new variable
+ # based on that id that is sure to be skipped during seed
+ project.variables.create!(
+ environment_scope: '*',
+ key: "VAR_#{project.variables.maximum(:id).to_i + 2}",
+ value: SecureRandom.hex(32)
+ )
+
+ expect { seeder.seed }.to change {
+ project.variables.count
+ }.by(Gitlab::Seeders::Ci::VariablesProjectSeeder::DEFAULT_SEED_COUNT - 1)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/seeders/project_environment_seeder_spec.rb b/spec/lib/gitlab/seeders/project_environment_seeder_spec.rb
new file mode 100644
index 00000000000..8401d189373
--- /dev/null
+++ b/spec/lib/gitlab/seeders/project_environment_seeder_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Gitlab::Seeders::ProjectEnvironmentSeeder, feature_category: :secrets_management do
+ let_it_be(:project) { create(:project) }
+
+ let(:seeder) { described_class.new(project_path: project.full_path) }
+ let(:custom_seeder) do
+ described_class.new(project_path: project.full_path, seed_count: 2, prefix: 'staging_')
+ end
+
+ let(:invalid_project_path_seeder) do
+ described_class.new(project_path: 'invalid_path', seed_count: 1)
+ end
+
+ describe '#seed' do
+ it 'creates environments for the project' do
+ expect { seeder.seed }.to change {
+ project.environments.count
+ }.by(Gitlab::Seeders::ProjectEnvironmentSeeder::DEFAULT_SEED_COUNT)
+ end
+
+ it 'creates environments with custom arguments' do
+ expect { custom_seeder.seed }.to change {
+ project.environments.count
+ }.by(2)
+
+ env = project.environments.last
+
+ expect(env.name.include?('staging_')).to eq true
+ end
+
+ it 'skips seeding when project path is invalid' do
+ expect { invalid_project_path_seeder.seed }.to change {
+ project.environments.count
+ }.by(0)
+ end
+
+ it 'skips environment creation if environment already exists' do
+ project.environments.create!(name: "ENV_#{project.environments.maximum(:id).to_i}")
+
+ # first id is assigned randomly, so we're creating a new variable
+ # based on that id that is sure to be skipped during seed
+ project.environments.create!(name: "ENV_#{project.environments.maximum(:id).to_i + 2}")
+
+ expect { seeder.seed }.to change {
+ project.environments.count
+ }.by(Gitlab::Seeders::ProjectEnvironmentSeeder::DEFAULT_SEED_COUNT - 1)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/serverless/service_spec.rb b/spec/lib/gitlab/serverless/service_spec.rb
deleted file mode 100644
index 3400be5b48e..00000000000
--- a/spec/lib/gitlab/serverless/service_spec.rb
+++ /dev/null
@@ -1,136 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Serverless::Service do
- let(:cluster) { create(:cluster) }
- let(:environment) { create(:environment) }
- let(:attributes) do
- {
- 'apiVersion' => 'serving.knative.dev/v1alpha1',
- 'kind' => 'Service',
- 'metadata' => {
- 'creationTimestamp' => '2019-10-22T21:19:13Z',
- 'name' => 'kubetest',
- 'namespace' => 'project1-1-environment1'
- },
- 'spec' => {
- 'runLatest' => {
- 'configuration' => {
- 'build' => {
- 'template' => {
- 'name' => 'some-image'
- }
- }
- }
- }
- },
- 'environment_scope' => '*',
- 'cluster' => cluster,
- 'environment' => environment,
- 'podcount' => 0
- }
- end
-
- it 'exposes methods extracting data from the attributes hash' do
- service = Gitlab::Serverless::Service.new(attributes)
-
- expect(service.name).to eq('kubetest')
- expect(service.namespace).to eq('project1-1-environment1')
- expect(service.environment_scope).to eq('*')
- expect(service.podcount).to eq(0)
- expect(service.created_at).to eq(DateTime.parse('2019-10-22T21:19:13Z'))
- expect(service.image).to eq('some-image')
- expect(service.cluster).to eq(cluster)
- expect(service.environment).to eq(environment)
- end
-
- it 'returns nil for missing attributes' do
- service = Gitlab::Serverless::Service.new({})
-
- [:name, :namespace, :environment_scope, :cluster, :podcount, :created_at, :image, :description, :url, :environment].each do |method|
- expect(service.send(method)).to be_nil
- end
- end
-
- describe '#description' do
- it 'extracts the description in knative 7 format if available' do
- attributes = {
- 'spec' => {
- 'template' => {
- 'metadata' => {
- 'annotations' => {
- 'Description' => 'some description'
- }
- }
- }
- }
- }
- service = Gitlab::Serverless::Service.new(attributes)
-
- expect(service.description).to eq('some description')
- end
-
- it 'extracts the description in knative 5/6 format if 7 is not available' do
- attributes = {
- 'spec' => {
- 'runLatest' => {
- 'configuration' => {
- 'revisionTemplate' => {
- 'metadata' => {
- 'annotations' => {
- 'Description' => 'some description'
- }
- }
- }
- }
- }
- }
- }
- service = Gitlab::Serverless::Service.new(attributes)
-
- expect(service.description).to eq('some description')
- end
- end
-
- describe '#url' do
- let(:serverless_domain) { instance_double(::Serverless::Domain, uri: URI('https://proxy.example.com')) }
-
- it 'returns proxy URL if cluster has serverless domain' do
- # cluster = create(:cluster)
- knative = create(:clusters_applications_knative, :installed, cluster: cluster)
- create(:serverless_domain_cluster, clusters_applications_knative_id: knative.id)
- service = Gitlab::Serverless::Service.new(attributes.merge('cluster' => cluster))
-
- expect(::Serverless::Domain).to receive(:new).with(
- function_name: service.name,
- serverless_domain_cluster: service.cluster.serverless_domain,
- environment: service.environment
- ).and_return(serverless_domain)
-
- expect(service.url).to eq('https://proxy.example.com')
- end
-
- it 'returns the URL from the knative 6/7 format' do
- attributes = {
- 'status' => {
- 'url' => 'https://example.com'
- }
- }
- service = Gitlab::Serverless::Service.new(attributes)
-
- expect(service.url).to eq('https://example.com')
- end
-
- it 'returns the URL from the knative 5 format' do
- attributes = {
- 'status' => {
- 'domain' => 'example.com'
- }
- }
- service = Gitlab::Serverless::Service.new(attributes)
-
- expect(service.url).to eq('http://example.com')
- end
- end
-end
diff --git a/spec/lib/gitlab/service_desk_email_spec.rb b/spec/lib/gitlab/service_desk_email_spec.rb
deleted file mode 100644
index 69569c0f194..00000000000
--- a/spec/lib/gitlab/service_desk_email_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::ServiceDeskEmail do
- let(:setting_name) { :service_desk_email }
-
- it_behaves_like 'common email methods'
-
- describe '.key_from_address' do
- context 'when service desk address is set' do
- before do
- stub_service_desk_email_setting(address: 'address+%{key}@example.com')
- end
-
- it 'returns key' do
- expect(described_class.key_from_address('address+key@example.com')).to eq('key')
- end
- end
-
- context 'when service desk address is not set' do
- before do
- stub_service_desk_email_setting(address: nil)
- end
-
- it 'returns nil' do
- expect(described_class.key_from_address('address+key@example.com')).to be_nil
- end
- end
- end
-
- describe '.address_for_key' do
- context 'when service desk address is set' do
- before do
- stub_service_desk_email_setting(address: 'address+%{key}@example.com')
- end
-
- it 'returns address' do
- expect(described_class.address_for_key('foo')).to eq('address+foo@example.com')
- end
- end
-
- context 'when service desk address is not set' do
- before do
- stub_service_desk_email_setting(address: nil)
- end
-
- it 'returns nil' do
- expect(described_class.key_from_address('foo')).to be_nil
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/service_desk_spec.rb b/spec/lib/gitlab/service_desk_spec.rb
index f554840ec78..d6725f37d39 100644
--- a/spec/lib/gitlab/service_desk_spec.rb
+++ b/spec/lib/gitlab/service_desk_spec.rb
@@ -4,8 +4,8 @@ require 'spec_helper'
RSpec.describe Gitlab::ServiceDesk do
before do
- allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
- allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
end
describe 'enabled?' do
@@ -39,7 +39,7 @@ RSpec.describe Gitlab::ServiceDesk do
context 'when incoming emails are disabled' do
before do
- allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(false)
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(false)
end
it { is_expected.to be_falsy }
@@ -47,7 +47,7 @@ RSpec.describe Gitlab::ServiceDesk do
context 'when email key is not supported' do
before do
- allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(false)
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?).and_return(false)
end
it { is_expected.to be_falsy }
diff --git a/spec/lib/gitlab/sidekiq_config/worker_router_spec.rb b/spec/lib/gitlab/sidekiq_config/worker_router_spec.rb
index 4a8dbe69d36..ea9d77bcfa4 100644
--- a/spec/lib/gitlab/sidekiq_config/worker_router_spec.rb
+++ b/spec/lib/gitlab/sidekiq_config/worker_router_spec.rb
@@ -21,7 +21,6 @@ RSpec.describe Gitlab::SidekiqConfig::WorkerRouter do
create_worker('PostReceive', :git) | 'git:post_receive'
create_worker('PipelineHooksWorker', :pipeline_hooks) | 'pipeline_hooks:pipeline_hooks'
create_worker('Gitlab::JiraImport::AdvanceStageWorker') | 'jira_import_advance_stage'
- create_worker('Gitlab::PhabricatorImport::ImportTasksWorker', :importer) | 'importer:phabricator_import_import_tasks'
end
with_them do
@@ -127,6 +126,7 @@ RSpec.describe Gitlab::SidekiqConfig::WorkerRouter do
describe '.global' do
before do
described_class.remove_instance_variable(:@global_worker_router) if described_class.instance_variable_defined?(:@global_worker_router)
+ stub_config(sidekiq: { routing_rules: routing_rules })
end
after do
@@ -137,10 +137,6 @@ RSpec.describe Gitlab::SidekiqConfig::WorkerRouter do
include_context 'router examples setup'
with_them do
- before do
- stub_config(sidekiq: { routing_rules: routing_rules })
- end
-
it 'routes the worker to the correct queue' do
expect(described_class.global.route(worker)).to eql(expected_queue)
end
@@ -158,10 +154,6 @@ RSpec.describe Gitlab::SidekiqConfig::WorkerRouter do
end
end
- before do
- stub_config(sidekiq: { routing_rules: routing_rules })
- end
-
context 'invalid routing rules format' do
let(:routing_rules) { ['feature_category=a'] }
@@ -184,6 +176,26 @@ RSpec.describe Gitlab::SidekiqConfig::WorkerRouter do
end
end
end
+
+ context 'when routing rules is missing `*` as the last rule' do
+ let(:routing_rules) { [['resource_boundary=cpu', 'cpu']] }
+
+ it 'logs a warning' do
+ expect(Gitlab::AppLogger).to receive(:warn).with(a_string_matching('sidekiq.routing_rules config is missing'))
+
+ described_class.global
+ end
+ end
+
+ context 'when routing rules has a `*` rule as the last rule' do
+ let(:routing_rules) { [['resource_boundary=cpu', 'cpu'], ['*', 'default']] }
+
+ it 'does not log any warning' do
+ expect(Gitlab::AppLogger).not_to receive(:warn)
+
+ described_class.global
+ end
+ end
end
describe '#route' do
diff --git a/spec/lib/gitlab/sidekiq_config_spec.rb b/spec/lib/gitlab/sidekiq_config_spec.rb
index 5f72a3feba7..00b1666106f 100644
--- a/spec/lib/gitlab/sidekiq_config_spec.rb
+++ b/spec/lib/gitlab/sidekiq_config_spec.rb
@@ -17,6 +17,27 @@ RSpec.describe Gitlab::SidekiqConfig do
end
end
+ describe '.cron_jobs' do
+ it 'renames job_class to class and removes incomplete jobs' do
+ expect(Gitlab)
+ .to receive(:config)
+ .twice
+ .and_return(GitlabSettings::Options.build(
+ load_dynamic_cron_schedules!: true,
+ cron_jobs: {
+ job: { cron: '0 * * * *', job_class: 'SomeWorker' },
+ incomplete_job: { cron: '0 * * * *' }
+ }))
+
+ expect(Gitlab::AppLogger)
+ .to receive(:error)
+ .with("Invalid cron_jobs config key: 'incomplete_job'. Check your gitlab config file.")
+
+ expect(described_class.cron_jobs)
+ .to eq('job' => { 'class' => 'SomeWorker', 'cron' => '0 * * * *' })
+ end
+ end
+
describe '.worker_queues' do
it 'includes all queues' do
queues = described_class.worker_queues
diff --git a/spec/lib/gitlab/sidekiq_daemon/memory_killer_spec.rb b/spec/lib/gitlab/sidekiq_daemon/memory_killer_spec.rb
deleted file mode 100644
index 6f46a5aea3b..00000000000
--- a/spec/lib/gitlab/sidekiq_daemon/memory_killer_spec.rb
+++ /dev/null
@@ -1,562 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::SidekiqDaemon::MemoryKiller do
- let(:memory_killer) { described_class.new }
- let(:sidekiq_daemon_monitor) { instance_double(Gitlab::SidekiqDaemon::Monitor) }
- let(:running_jobs) { {} }
- let(:pid) { 12345 }
- let(:worker) do
- Class.new do
- def self.name
- 'DummyWorker'
- end
- end
- end
-
- before do
- stub_const('DummyWorker', worker)
- allow(Sidekiq.logger).to receive(:info)
- allow(Sidekiq.logger).to receive(:warn)
- allow(Gitlab::SidekiqDaemon::Monitor).to receive(:instance).and_return(sidekiq_daemon_monitor)
- allow(sidekiq_daemon_monitor).to receive(:jobs).and_return(running_jobs)
- allow(memory_killer).to receive(:pid).and_return(pid)
-
- # make sleep no-op
- allow(memory_killer).to receive(:sleep) {}
- end
-
- describe '#run_thread' do
- subject { memory_killer.send(:run_thread) }
-
- before do
- # let enabled? return 3 times: true, true, false
- allow(memory_killer).to receive(:enabled?).and_return(true, true, false)
- end
-
- context 'when structured logging is used' do
- it 'logs start message once' do
- expect(Sidekiq.logger).to receive(:info).once
- .with(
- class: described_class.to_s,
- action: 'start',
- pid: pid,
- message: 'Starting Gitlab::SidekiqDaemon::MemoryKiller Daemon')
-
- subject
- end
-
- it 'logs StandardError message twice' do
- expect(Sidekiq.logger).to receive(:warn).twice
- .with(
- class: described_class.to_s,
- pid: pid,
- message: "Exception from run_thread: My Exception")
-
- expect(memory_killer).to receive(:rss_within_range?)
- .twice
- .and_raise(StandardError, 'My Exception')
-
- expect { subject }.not_to raise_exception
- end
-
- it 'logs exception message once and raise exception and log stop message' do
- expect(Sidekiq.logger).to receive(:warn).once
- .with(
- class: described_class.to_s,
- pid: pid,
- message: "Exception from run_thread: My Exception")
-
- expect(memory_killer).to receive(:rss_within_range?)
- .once
- .and_raise(Exception, 'My Exception')
-
- expect(memory_killer).to receive(:sleep).with(Gitlab::SidekiqDaemon::MemoryKiller::CHECK_INTERVAL_SECONDS)
- expect(Sidekiq.logger).to receive(:warn).once
- .with(
- class: described_class.to_s,
- action: 'stop',
- pid: pid,
- message: 'Stopping Gitlab::SidekiqDaemon::MemoryKiller Daemon')
-
- expect { subject }.to raise_exception(Exception, 'My Exception')
- end
-
- it 'logs stop message once' do
- expect(Sidekiq.logger).to receive(:warn).once
- .with(
- class: described_class.to_s,
- action: 'stop',
- pid: pid,
- message: 'Stopping Gitlab::SidekiqDaemon::MemoryKiller Daemon')
-
- subject
- end
- end
-
- it 'not invoke restart_sidekiq when rss in range' do
- expect(memory_killer).to receive(:rss_within_range?)
- .twice
- .and_return(true)
-
- expect(memory_killer).not_to receive(:restart_sidekiq)
-
- subject
- end
-
- it 'invoke restart_sidekiq when rss not in range' do
- expect(memory_killer).to receive(:rss_within_range?)
- .at_least(:once)
- .and_return(false)
-
- expect(memory_killer).to receive(:restart_sidekiq)
- .at_least(:once)
-
- subject
- end
- end
-
- describe '#stop_working' do
- subject { memory_killer.send(:stop_working) }
-
- it 'changes enable? to false' do
- expect { subject }.to change { memory_killer.send(:enabled?) }
- .from(true).to(false)
- end
- end
-
- describe '#rss_within_range?' do
- let(:shutdown_timeout_seconds) { 7 }
- let(:check_interval_seconds) { 2 }
- let(:grace_balloon_seconds) { 5 }
-
- subject { memory_killer.send(:rss_within_range?) }
-
- before do
- stub_const("#{described_class}::SHUTDOWN_TIMEOUT_SECONDS", shutdown_timeout_seconds)
- stub_const("#{described_class}::CHECK_INTERVAL_SECONDS", check_interval_seconds)
- stub_const("#{described_class}::GRACE_BALLOON_SECONDS", grace_balloon_seconds)
- allow(Process).to receive(:getpgrp).and_return(pid)
- allow(Sidekiq).to receive(:[]).with(:timeout).and_return(9)
- end
-
- it 'return true when everything is within limit', :aggregate_failures do
- expect(memory_killer).to receive(:get_rss_kb).and_return(100)
- expect(memory_killer).to receive(:get_soft_limit_rss_kb).and_return(200)
- expect(memory_killer).to receive(:get_hard_limit_rss_kb).and_return(300)
- expect(memory_killer).to receive(:get_memory_total_kb).and_return(3072)
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:running)
- .and_call_original
-
- expect(Gitlab::Metrics::System).to receive(:monotonic_time).and_call_original
- expect(memory_killer).not_to receive(:log_rss_out_of_range)
-
- expect(subject).to be true
- end
-
- it 'return false when rss exceeds hard_limit_rss', :aggregate_failures do
- expect(memory_killer).to receive(:get_rss_kb).at_least(:once).and_return(400)
- expect(memory_killer).to receive(:get_soft_limit_rss_kb).at_least(:once).and_return(200)
- expect(memory_killer).to receive(:get_hard_limit_rss_kb).at_least(:once).and_return(300)
- expect(memory_killer).to receive(:get_memory_total_kb).at_least(:once).and_return(3072)
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:running)
- .and_call_original
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:above_soft_limit)
- .and_call_original
-
- expect(Gitlab::Metrics::System).to receive(:monotonic_time).and_call_original
-
- expect(memory_killer).to receive(:out_of_range_description).with(400, 300, 200, true)
-
- expect(subject).to be false
- end
-
- it 'return false when rss exceed hard_limit_rss after a while', :aggregate_failures do
- expect(memory_killer).to receive(:get_rss_kb).and_return(250, 400, 400)
- expect(memory_killer).to receive(:get_soft_limit_rss_kb).at_least(:once).and_return(200)
- expect(memory_killer).to receive(:get_hard_limit_rss_kb).at_least(:once).and_return(300)
- expect(memory_killer).to receive(:get_memory_total_kb).at_least(:once).and_return(3072)
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:running)
- .and_call_original
-
- expect(memory_killer).to receive(:refresh_state)
- .at_least(:once)
- .with(:above_soft_limit)
- .and_call_original
-
- expect(Gitlab::Metrics::System).to receive(:monotonic_time).twice.and_call_original
- expect(memory_killer).to receive(:sleep).with(check_interval_seconds)
- expect(memory_killer).to receive(:out_of_range_description).with(400, 300, 200, false)
- expect(memory_killer).to receive(:out_of_range_description).with(400, 300, 200, true)
-
- expect(subject).to be false
- end
-
- it 'return true when rss below soft_limit_rss after a while within GRACE_BALLOON_SECONDS', :aggregate_failures do
- expect(memory_killer).to receive(:get_rss_kb).and_return(250, 100)
- expect(memory_killer).to receive(:get_soft_limit_rss_kb).and_return(200, 200)
- expect(memory_killer).to receive(:get_hard_limit_rss_kb).and_return(300, 300)
- expect(memory_killer).to receive(:get_memory_total_kb).and_return(3072, 3072)
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:running)
- .and_call_original
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:above_soft_limit)
- .and_call_original
-
- expect(Gitlab::Metrics::System).to receive(:monotonic_time).twice.and_call_original
- expect(memory_killer).to receive(:sleep).with(check_interval_seconds)
-
- expect(memory_killer).to receive(:out_of_range_description).with(100, 300, 200, false)
-
- expect(subject).to be true
- end
-
- context 'when exceeds GRACE_BALLOON_SECONDS' do
- let(:grace_balloon_seconds) { 0 }
-
- it 'return false when rss exceed soft_limit_rss', :aggregate_failures do
- allow(memory_killer).to receive(:get_rss_kb).and_return(250)
- allow(memory_killer).to receive(:get_soft_limit_rss_kb).and_return(200)
- allow(memory_killer).to receive(:get_hard_limit_rss_kb).and_return(300)
- allow(memory_killer).to receive(:get_memory_total_kb).and_return(3072)
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:running)
- .and_call_original
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:above_soft_limit)
- .and_call_original
-
- expect(memory_killer).to receive(:out_of_range_description).with(250, 300, 200, true)
-
- expect(subject).to be false
- end
- end
- end
-
- describe '#restart_sidekiq' do
- let(:shutdown_timeout_seconds) { 7 }
-
- subject { memory_killer.send(:restart_sidekiq) }
-
- context 'when sidekiq_memory_killer_read_only_mode is enabled' do
- before do
- stub_feature_flags(sidekiq_memory_killer_read_only_mode: true)
- end
-
- it 'does not send signal' do
- expect(memory_killer).not_to receive(:refresh_state)
- expect(memory_killer).not_to receive(:signal_and_wait)
-
- subject
- end
- end
-
- context 'when sidekiq_memory_killer_read_only_mode is disabled' do
- before do
- stub_const("#{described_class}::SHUTDOWN_TIMEOUT_SECONDS", shutdown_timeout_seconds)
- stub_feature_flags(sidekiq_memory_killer_read_only_mode: false)
- allow(Sidekiq).to receive(:[]).with(:timeout).and_return(9)
- allow(memory_killer).to receive(:get_rss_kb).and_return(100)
- allow(memory_killer).to receive(:get_soft_limit_rss_kb).and_return(200)
- allow(memory_killer).to receive(:get_hard_limit_rss_kb).and_return(300)
- allow(memory_killer).to receive(:get_memory_total_kb).and_return(3072)
- end
-
- it 'send signal' do
- expect(memory_killer).to receive(:refresh_state)
- .with(:stop_fetching_new_jobs)
- .ordered
- .and_call_original
- expect(memory_killer).to receive(:signal_and_wait)
- .with(shutdown_timeout_seconds, 'SIGTSTP', 'stop fetching new jobs')
- .ordered
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:shutting_down)
- .ordered
- .and_call_original
- expect(memory_killer).to receive(:signal_and_wait)
- .with(11, 'SIGTERM', 'gracefully shut down')
- .ordered
-
- expect(memory_killer).to receive(:refresh_state)
- .with(:killing_sidekiq)
- .ordered
- .and_call_original
- expect(memory_killer).to receive(:signal_pgroup)
- .with('SIGKILL', 'die')
- .ordered
-
- subject
- end
- end
- end
-
- describe '#signal_and_wait' do
- let(:time) { 0.1 }
- let(:signal) { 'my-signal' }
- let(:explanation) { 'my-explanation' }
- let(:check_interval_seconds) { 0.1 }
-
- subject { memory_killer.send(:signal_and_wait, time, signal, explanation) }
-
- before do
- stub_const("#{described_class}::CHECK_INTERVAL_SECONDS", check_interval_seconds)
- end
-
- it 'send signal and wait till deadline' do
- expect(Process).to receive(:kill)
- .with(signal, pid)
- .ordered
-
- expect(Gitlab::Metrics::System).to receive(:monotonic_time)
- .and_call_original
- .at_least(3)
-
- expect(memory_killer).to receive(:enabled?).and_return(true).at_least(:twice)
- expect(memory_killer).to receive(:sleep).at_least(:once).and_call_original
-
- subject
- end
- end
-
- describe '#signal_pgroup' do
- let(:signal) { 'my-signal' }
- let(:explanation) { 'my-explanation' }
-
- subject { memory_killer.send(:signal_pgroup, signal, explanation) }
-
- it 'send signal to this process if it is not group leader' do
- expect(Process).to receive(:getpgrp).and_return(pid + 1)
-
- expect(Sidekiq.logger).to receive(:warn).once
- .with(
- class: described_class.to_s,
- signal: signal,
- pid: pid,
- message: "sending Sidekiq worker PID-#{pid} #{signal} (#{explanation})")
- expect(Process).to receive(:kill).with(signal, pid).ordered
-
- subject
- end
-
- it 'send signal to whole process group as group leader' do
- expect(Process).to receive(:getpgrp).and_return(pid)
-
- expect(Sidekiq.logger).to receive(:warn).once
- .with(
- class: described_class.to_s,
- signal: signal,
- pid: pid,
- message: "sending Sidekiq worker PGRP-#{pid} #{signal} (#{explanation})")
- expect(Process).to receive(:kill).with(signal, 0).ordered
-
- subject
- end
- end
-
- describe '#log_rss_out_of_range' do
- let(:current_rss) { 100 }
- let(:soft_limit_rss) { 200 }
- let(:hard_limit_rss) { 300 }
- let(:memory_total) { 3072 }
- let(:jid) { 1 }
- let(:reason) { 'rss out of range reason description' }
- let(:queue) { 'default' }
-
- let(:metrics) { memory_killer.instance_variable_get(:@metrics) }
- let(:running_jobs) { { jid => { worker_class: DummyWorker } } }
-
- before do
- allow(memory_killer).to receive(:get_rss_kb).and_return(*current_rss)
- allow(memory_killer).to receive(:get_soft_limit_rss_kb).and_return(soft_limit_rss)
- allow(memory_killer).to receive(:get_hard_limit_rss_kb).and_return(hard_limit_rss)
- allow(memory_killer).to receive(:get_memory_total_kb).and_return(memory_total)
-
- memory_killer.send(:refresh_state, :running)
- end
-
- subject { memory_killer.send(:log_rss_out_of_range) }
-
- it 'invoke sidekiq logger warn' do
- expect(memory_killer).to receive(:out_of_range_description).with(current_rss, hard_limit_rss, soft_limit_rss, true).and_return(reason)
- expect(Sidekiq.logger).to receive(:warn)
- .with(
- class: described_class.to_s,
- pid: pid,
- message: 'Sidekiq worker RSS out of range',
- current_rss: current_rss,
- hard_limit_rss: hard_limit_rss,
- soft_limit_rss: soft_limit_rss,
- reason: reason,
- running_jobs: [jid: jid, worker_class: 'DummyWorker'],
- memory_total_kb: memory_total)
-
- expect(metrics[:sidekiq_memory_killer_running_jobs]).to receive(:increment)
- .with({ worker_class: "DummyWorker", deadline_exceeded: true })
-
- subject
- end
- end
-
- describe '#out_of_range_description' do
- let(:hard_limit) { 300 }
- let(:soft_limit) { 200 }
- let(:grace_balloon_seconds) { 12 }
- let(:deadline_exceeded) { true }
-
- subject { memory_killer.send(:out_of_range_description, rss, hard_limit, soft_limit, deadline_exceeded) }
-
- context 'when rss > hard_limit' do
- let(:rss) { 400 }
-
- it 'tells reason' do
- expect(subject).to eq("current_rss(#{rss}) > hard_limit_rss(#{hard_limit})")
- end
- end
-
- context 'when rss <= hard_limit' do
- let(:rss) { 300 }
-
- context 'deadline exceeded' do
- let(:deadline_exceeded) { true }
-
- it 'tells reason' do
- stub_const("#{described_class}::GRACE_BALLOON_SECONDS", grace_balloon_seconds)
- expect(subject).to eq("current_rss(#{rss}) > soft_limit_rss(#{soft_limit}) longer than GRACE_BALLOON_SECONDS(#{grace_balloon_seconds})")
- end
- end
-
- context 'deadline not exceeded' do
- let(:deadline_exceeded) { false }
-
- it 'tells reason' do
- expect(subject).to eq("current_rss(#{rss}) > soft_limit_rss(#{soft_limit})")
- end
- end
- end
- end
-
- describe '#rss_increase_by_jobs' do
- let(:running_jobs) { { 'job1' => { worker_class: "Job1" }, 'job2' => { worker_class: "Job2" } } }
-
- subject { memory_killer.send(:rss_increase_by_jobs) }
-
- before do
- allow(memory_killer).to receive(:rss_increase_by_job).and_return(11, 22)
- end
-
- it 'adds up individual rss_increase_by_job' do
- expect(subject).to eq(33)
- end
-
- context 'when there is no running job' do
- let(:running_jobs) { {} }
-
- it 'return 0 if no job' do
- expect(subject).to eq(0)
- end
- end
- end
-
- describe '#rss_increase_by_job' do
- let(:worker_class) { Chaos::SleepWorker }
- let(:job) { { worker_class: worker_class, started_at: 321 } }
- let(:max_memory_kb) { 100000 }
-
- subject { memory_killer.send(:rss_increase_by_job, job) }
-
- before do
- stub_const("#{described_class}::DEFAULT_MAX_MEMORY_GROWTH_KB", max_memory_kb)
- end
-
- it 'return 0 if memory_growth_kb return 0' do
- expect(memory_killer).to receive(:get_job_options).with(job, 'memory_killer_memory_growth_kb', 0).and_return(0)
- expect(memory_killer).to receive(:get_job_options).with(job, 'memory_killer_max_memory_growth_kb', max_memory_kb).and_return(0)
-
- expect(Time).not_to receive(:now)
- expect(subject).to eq(0)
- end
-
- it 'return time factored growth value when it does not exceed max growth limit for whilited job' do
- expect(memory_killer).to receive(:get_job_options).with(job, 'memory_killer_memory_growth_kb', 0).and_return(10)
- expect(memory_killer).to receive(:get_job_options).with(job, 'memory_killer_max_memory_growth_kb', max_memory_kb).and_return(100)
-
- expect(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(323)
- expect(subject).to eq(20)
- end
-
- it 'return max growth limit when time factored growth value exceed max growth limit for whilited job' do
- expect(memory_killer).to receive(:get_job_options).with(job, 'memory_killer_memory_growth_kb', 0).and_return(10)
- expect(memory_killer).to receive(:get_job_options).with(job, 'memory_killer_max_memory_growth_kb', max_memory_kb).and_return(100)
-
- expect(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(332)
- expect(subject).to eq(100)
- end
- end
-
- describe '#get_job_options' do
- let(:worker_class) { Chaos::SleepWorker }
- let(:job) { { worker_class: worker_class, started_at: 321 } }
- let(:key) { 'my-key' }
- let(:default) { 'my-default' }
-
- subject { memory_killer.send(:get_job_options, job, key, default) }
-
- it 'return default if key is not defined' do
- expect(worker_class).to receive(:sidekiq_options).and_return({ "retry" => 5 })
-
- expect(subject).to eq(default)
- end
-
- it 'return default if get StandardError when retrieve sidekiq_options' do
- expect(worker_class).to receive(:sidekiq_options).and_raise(StandardError)
-
- expect(subject).to eq(default)
- end
-
- it 'return right value if sidekiq_options has the key' do
- expect(worker_class).to receive(:sidekiq_options).and_return({ key => 10 })
-
- expect(subject).to eq(10)
- end
- end
-
- describe '#refresh_state' do
- let(:metrics) { memory_killer.instance_variable_get(:@metrics) }
-
- subject { memory_killer.send(:refresh_state, :shutting_down) }
-
- it 'calls gitlab metrics gauge set methods' do
- expect(memory_killer).to receive(:get_rss_kb) { 1010 }
- expect(memory_killer).to receive(:get_soft_limit_rss_kb) { 1020 }
- expect(memory_killer).to receive(:get_hard_limit_rss_kb) { 1040 }
- expect(memory_killer).to receive(:get_memory_total_kb) { 3072 }
-
- expect(metrics[:sidekiq_memory_killer_phase]).to receive(:set)
- .with({}, described_class::PHASE[:shutting_down])
- expect(metrics[:sidekiq_current_rss]).to receive(:set)
- .with({}, 1010)
- expect(metrics[:sidekiq_memory_killer_soft_limit_rss]).to receive(:set)
- .with({}, 1020)
- expect(metrics[:sidekiq_memory_killer_hard_limit_rss]).to receive(:set)
- .with({}, 1040)
-
- subject
- end
- end
-end
diff --git a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
index e3d9549a3c0..4b589dc43af 100644
--- a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
+++ b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
@@ -309,7 +309,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
end
shared_examples 'performs database queries' do
- it 'logs the database time', :aggregate_errors do
+ it 'logs the database time', :aggregate_failures do
expect(logger).to receive(:info).with(expected_start_payload).ordered
expect(logger).to receive(:info).with(expected_end_payload_with_db).ordered
@@ -318,7 +318,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
end
end
- it 'prevents database time from leaking to the next job', :aggregate_errors do
+ it 'prevents database time from leaking to the next job', :aggregate_failures do
expect(logger).to receive(:info).with(expected_start_payload).ordered
expect(logger).to receive(:info).with(expected_end_payload_with_db).ordered
expect(logger).to receive(:info).with(expected_start_payload).ordered
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 6a515a2b8a5..a46275d90b6 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
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_redis_queues, :clean_gitlab_redis_shared_state do
+RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_redis_queues, :clean_gitlab_redis_shared_state,
+ feature_category: :shared do
using RSpec::Parameterized::TableSyntax
subject(:duplicate_job) do
@@ -79,10 +80,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
context 'with Redis cookies' do
def with_redis(&block)
- Sidekiq.redis(&block)
+ Gitlab::Redis::Queues.with(&block)
end
- let(:cookie_key) { "#{idempotency_key}:cookie:v2" }
+ let(:cookie_key) { "#{Gitlab::Redis::Queues::SIDEKIQ_NAMESPACE}:#{idempotency_key}:cookie:v2" }
let(:cookie) { get_redis_msgpack(cookie_key) }
describe '#check!' do
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb
index 1b01793d80d..f65f7a645ea 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb
@@ -40,10 +40,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Server, :clean_gitlab_r
describe '#call' do
it 'removes the stored job from redis before execution' do
bare_job = { 'class' => 'TestDeduplicationWorker', 'args' => ['hello'] }
- job_definition = Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob.new(bare_job.dup, 'test_deduplication')
+ job_definition = Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob.new(bare_job.dup, 'default')
expect(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob)
- .to receive(:new).with(a_hash_including(bare_job), 'test_deduplication')
+ .to receive(:new).with(a_hash_including(bare_job), 'default')
.and_return(job_definition).twice # once in client middleware
expect(job_definition).to receive(:delete!).ordered.and_call_original
@@ -59,10 +59,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Server, :clean_gitlab_r
it 'removes the stored job from redis after execution' do
bare_job = { 'class' => 'TestDeduplicationWorker', 'args' => ['hello'] }
- job_definition = Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob.new(bare_job.dup, 'test_deduplication')
+ job_definition = Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob.new(bare_job.dup, 'default')
expect(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob)
- .to receive(:new).with(a_hash_including(bare_job), 'test_deduplication')
+ .to receive(:new).with(a_hash_including(bare_job), 'default')
.and_return(job_definition).twice # once in client middleware
expect(TestDeduplicationWorker).to receive(:work).ordered.and_call_original
diff --git a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
index f7cee6beb58..965ca612b3f 100644
--- a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
@@ -59,6 +59,45 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
described_class.initialize_process_metrics
end
+ context 'when sidekiq_execution_application_slis FF is turned on' do
+ it 'initializes sidekiq SLIs for the workers in the current Sidekiq process' do
+ allow(Gitlab::SidekiqConfig)
+ .to receive(:current_worker_queue_mappings)
+ .and_return('MergeWorker' => 'merge', 'Ci::BuildFinishedWorker' => 'default')
+
+ allow(completion_seconds_metric).to receive(:get)
+
+ expect(Gitlab::Metrics::SidekiqSlis)
+ .to receive(:initialize_slis!).with([
+ {
+ worker: 'MergeWorker',
+ urgency: 'high',
+ feature_category: 'source_code_management'
+ },
+ {
+ worker: 'Ci::BuildFinishedWorker',
+ urgency: 'high',
+ feature_category: 'continuous_integration'
+ }
+ ])
+
+ described_class.initialize_process_metrics
+ end
+ end
+
+ context 'when sidekiq_execution_application_slis FF is turned off' do
+ before do
+ stub_feature_flags(sidekiq_execution_application_slis: false)
+ end
+
+ it 'does not initialize sidekiq SLIs' do
+ expect(Gitlab::Metrics::SidekiqSlis)
+ .not_to receive(:initialize_slis!)
+
+ described_class.initialize_process_metrics
+ end
+ end
+
context 'when the sidekiq_job_completion_metric_initialize feature flag is disabled' do
before do
stub_feature_flags(sidekiq_job_completion_metric_initialize: false)
@@ -79,6 +118,17 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
described_class.initialize_process_metrics
end
+
+ it 'does not initializes sidekiq SLIs' do
+ allow(Gitlab::SidekiqConfig)
+ .to receive(:current_worker_queue_mappings)
+ .and_return('MergeWorker' => 'merge', 'Ci::BuildFinishedWorker' => 'default')
+
+ expect(Gitlab::Metrics::SidekiqSlis)
+ .not_to receive(:initialize_slis!)
+
+ described_class.initialize_process_metrics
+ end
end
end
@@ -110,6 +160,12 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
expect(redis_requests_total).to receive(:increment).with(labels_with_job_status, redis_calls)
expect(elasticsearch_requests_total).to receive(:increment).with(labels_with_job_status, elasticsearch_calls)
expect(sidekiq_mem_total_bytes).to receive(:set).with(labels_with_job_status, mem_total_bytes)
+ expect(Gitlab::Metrics::SidekiqSlis).to receive(:record_execution_apdex).with(labels.slice(:worker,
+ :feature_category,
+ :urgency), monotonic_time_duration)
+ expect(Gitlab::Metrics::SidekiqSlis).to receive(:record_execution_error).with(labels.slice(:worker,
+ :feature_category,
+ :urgency), false)
subject.call(worker, job, :test) { nil }
end
@@ -159,6 +215,16 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
expect { subject.call(worker, job, :test) { raise StandardError, "Failed" } }.to raise_error(StandardError, "Failed")
end
+
+ it 'records sidekiq SLI error but does not record sidekiq SLI apdex' do
+ expect(failed_total_metric).to receive(:increment)
+ expect(Gitlab::Metrics::SidekiqSlis).not_to receive(:record_execution_apdex)
+ expect(Gitlab::Metrics::SidekiqSlis).to receive(:record_execution_error).with(labels.slice(:worker,
+ :feature_category,
+ :urgency), true)
+
+ expect { subject.call(worker, job, :test) { raise StandardError, "Failed" } }.to raise_error(StandardError, "Failed")
+ end
end
context 'when job is retried' do
@@ -180,6 +246,19 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
subject.call(worker, job, :test) { nil }
end
end
+
+ context 'when sidekiq_execution_application_slis FF is turned off' do
+ before do
+ stub_feature_flags(sidekiq_execution_application_slis: false)
+ end
+
+ it 'does not call record_execution_apdex nor record_execution_error' do
+ expect(Gitlab::Metrics::SidekiqSlis).not_to receive(:record_execution_apdex)
+ expect(Gitlab::Metrics::SidekiqSlis).not_to receive(:record_execution_error)
+
+ subject.call(worker, job, :test) { nil }
+ end
+ end
end
end
@@ -331,7 +410,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
include_context 'server metrics call'
context 'when a worker has a feature category' do
- let(:worker_category) { 'authentication_and_authorization' }
+ let(:worker_category) { 'system_access' }
it 'uses that category for metrics' do
expect(completion_seconds_metric).to receive(:observe).with(a_hash_including(feature_category: worker_category), anything)
diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb
index 1b6cd7ac5fb..4fbc64a45d6 100644
--- a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb
@@ -123,7 +123,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do
context 'when the feature category is already set in the surrounding block' do
it 'takes the feature category from the worker, not the caller' do
- Gitlab::ApplicationContext.with_context(feature_category: 'authentication_and_authorization') do
+ Gitlab::ApplicationContext.with_context(feature_category: 'system_access') do
TestWithContextWorker.bulk_perform_async_with_contexts(
%w(job1 job2),
arguments_proc: -> (name) { [name, 1, 2, 3] },
@@ -139,7 +139,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do
end
it 'takes the feature category from the caller if the worker is not owned' do
- Gitlab::ApplicationContext.with_context(feature_category: 'authentication_and_authorization') do
+ Gitlab::ApplicationContext.with_context(feature_category: 'system_access') do
TestNotOwnedWithContextWorker.bulk_perform_async_with_contexts(
%w(job1 job2),
arguments_proc: -> (name) { [name, 1, 2, 3] },
@@ -150,8 +150,8 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do
job1 = TestNotOwnedWithContextWorker.job_for_args(['job1', 1, 2, 3])
job2 = TestNotOwnedWithContextWorker.job_for_args(['job2', 1, 2, 3])
- expect(job1['meta.feature_category']).to eq('authentication_and_authorization')
- expect(job2['meta.feature_category']).to eq('authentication_and_authorization')
+ expect(job1['meta.feature_category']).to eq('system_access')
+ expect(job2['meta.feature_category']).to eq('system_access')
end
end
end
diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb
index 2deab3064eb..eb077a0371c 100644
--- a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Server do
context 'feature category' do
it 'takes the feature category from the worker' do
- Gitlab::ApplicationContext.with_context(feature_category: 'authentication_and_authorization') do
+ Gitlab::ApplicationContext.with_context(feature_category: 'system_access') do
TestWorker.perform_async('identifier', 1)
end
@@ -78,11 +78,11 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Server do
context 'when the worker is not owned' do
it 'takes the feature category from the surrounding context' do
- Gitlab::ApplicationContext.with_context(feature_category: 'authentication_and_authorization') do
+ Gitlab::ApplicationContext.with_context(feature_category: 'system_access') do
NotOwnedWorker.perform_async('identifier', 1)
end
- expect(NotOwnedWorker.contexts['identifier']).to include('meta.feature_category' => 'authentication_and_authorization')
+ expect(NotOwnedWorker.contexts['identifier']).to include('meta.feature_category' => 'system_access')
end
end
end
diff --git a/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb b/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb
index 9ed2a0642fc..c66e36c5621 100644
--- a/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb
+++ b/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb
@@ -54,7 +54,7 @@ RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do
expect(migrator.migrate_set(set_name)).to eq(scanned: 3, migrated: 0)
expect(set_after.length).to eq(3)
- expect(set_after.map(&:first)).to all(include('queue' => 'authorized_projects',
+ expect(set_after.map(&:first)).to all(include('queue' => 'default',
'class' => 'AuthorizedProjectsWorker'))
end
end
@@ -73,7 +73,7 @@ RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do
if item['class'] == 'AuthorizedProjectsWorker'
expect(item).to include('queue' => 'new_queue', 'args' => [i])
else
- expect(item).to include('queue' => 'post_receive', 'args' => [i])
+ expect(item).to include('queue' => 'default', 'args' => [i])
end
expect(score).to be_within(schedule_jitter).of(i.succ.hours.from_now.to_i)
@@ -134,7 +134,7 @@ RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do
expect(migrator.migrate_set(set_name)).to eq(scanned: 4, migrated: 0)
expect(set_after.length).to eq(3)
- expect(set_after.map(&:first)).to all(include('queue' => 'authorized_projects'))
+ expect(set_after.map(&:first)).to all(include('queue' => 'default'))
end
end
@@ -157,7 +157,7 @@ RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do
expect(migrator.migrate_set(set_name)).to eq(scanned: 4, migrated: 1)
expect(set_after.group_by { |job| job.first['queue'] }.transform_values(&:count))
- .to eq('authorized_projects' => 6, 'new_queue' => 1)
+ .to eq('default' => 6, 'new_queue' => 1)
end
it 'iterates through the entire set of jobs' do
diff --git a/spec/lib/gitlab/sidekiq_queue_spec.rb b/spec/lib/gitlab/sidekiq_queue_spec.rb
index 5e91282612e..93632848788 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('default').clear
+ Sidekiq::Queue.new('foobar').clear
Sidekiq::Testing.disable!(&example)
- Sidekiq::Queue.new('default').clear
+ Sidekiq::Queue.new('foobar').clear
end
def add_job(args, user:, klass: 'AuthorizedProjectsWorker')
Sidekiq::Client.push(
'class' => klass,
- 'queue' => 'default',
+ 'queue' => 'foobar',
'args' => args,
'meta.user' => user.username
)
@@ -20,7 +20,7 @@ RSpec.describe Gitlab::SidekiqQueue, :clean_gitlab_redis_queues do
describe '#drop_jobs!' do
shared_examples 'queue processing' do
- let(:sidekiq_queue) { described_class.new('default') }
+ let(:sidekiq_queue) { described_class.new('foobar') }
let_it_be(:sidekiq_queue_user) { create(:user) }
before do
@@ -80,7 +80,7 @@ RSpec.describe Gitlab::SidekiqQueue, :clean_gitlab_redis_queues do
it 'raises NoMetadataError' do
add_job([1], user: create(:user))
- expect { described_class.new('default').drop_jobs!({ username: 'sidekiq_queue_user' }, timeout: 1) }
+ expect { described_class.new('foobar').drop_jobs!({ username: 'sidekiq_queue_user' }, timeout: 1) }
.to raise_error(described_class::NoMetadataError)
end
end
diff --git a/spec/lib/gitlab/slash_commands/global_slack_handler_spec.rb b/spec/lib/gitlab/slash_commands/global_slack_handler_spec.rb
new file mode 100644
index 00000000000..4a58d65fc4a
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/global_slack_handler_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SlashCommands::GlobalSlackHandler, feature_category: :integrations do
+ include AfterNextHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ let_it_be_with_reload(:slack_integration) do
+ create(:gitlab_slack_application_integration, project: project).slack_integration
+ end
+
+ let(:chat_name) { instance_double('ChatName', user: user) }
+ let(:verification_token) { '123' }
+
+ before do
+ stub_application_setting(slack_app_verification_token: verification_token)
+ end
+
+ def handler(params)
+ described_class.new(params)
+ end
+
+ def handler_with_valid_token(params)
+ handler(params.merge(token: verification_token))
+ end
+
+ it 'does not serve a request if token is invalid' do
+ result = handler(token: '123456', text: 'help').trigger
+
+ expect(result).to be_falsey
+ end
+
+ context 'with valid token' do
+ context 'with incident declare command' do
+ it 'calls command handler with no project alias' do
+ expect_next(Gitlab::SlashCommands::Command).to receive(:execute)
+ expect_next(ChatNames::FindUserService).to receive(:execute).and_return(chat_name)
+
+ handler_with_valid_token(
+ text: "incident declare",
+ team_id: slack_integration.team_id
+ ).trigger
+ end
+ end
+
+ it 'calls command handler if project alias is valid' do
+ expect_next(Gitlab::SlashCommands::Command).to receive(:execute)
+ expect_next(ChatNames::FindUserService).to receive(:execute).and_return(chat_name)
+
+ slack_integration.update!(alias: project.full_path)
+
+ handler_with_valid_token(
+ text: "#{project.full_path} issue new title",
+ team_id: slack_integration.team_id
+ ).trigger
+ end
+
+ it 'returns error if project alias not found' do
+ expect_next(Gitlab::SlashCommands::Command).not_to receive(:execute)
+ expect_next(Gitlab::SlashCommands::Presenters::Error).to receive(:message)
+
+ handler_with_valid_token(
+ text: "fake/fake issue new title",
+ team_id: slack_integration.team_id
+ ).trigger
+ end
+
+ it 'returns authorization request' do
+ expect_next(ChatNames::AuthorizeUserService).to receive(:execute)
+ expect_next(Gitlab::SlashCommands::Presenters::Access).to receive(:authorize)
+
+ slack_integration.update!(alias: project.full_path)
+
+ handler_with_valid_token(
+ text: "#{project.full_path} issue new title",
+ team_id: slack_integration.team_id
+ ).trigger
+ end
+
+ it 'calls help presenter' do
+ expect_next(Gitlab::SlashCommands::ApplicationHelp).to receive(:execute)
+
+ handler_with_valid_token(
+ text: "help"
+ ).trigger
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slug/environment_spec.rb b/spec/lib/gitlab/slug/environment_spec.rb
index e8f0fba27b2..8e23ad118d4 100644
--- a/spec/lib/gitlab/slug/environment_spec.rb
+++ b/spec/lib/gitlab/slug/environment_spec.rb
@@ -1,38 +1,41 @@
# frozen_string_literal: true
require 'fast_spec_helper'
+require 'rspec-parameterized'
-RSpec.describe Gitlab::Slug::Environment do
+RSpec.describe Gitlab::Slug::Environment, feature_category: :environment_management do
describe '#generate' do
- {
- "staging-12345678901234567" => "staging-123456789-q517sa",
- "9-staging-123456789012345" => "env-9-staging-123-q517sa",
- "staging-1234567890123456" => "staging-1234567890123456",
- "staging-1234567890123456-" => "staging-123456789-q517sa",
- "production" => "production",
- "PRODUCTION" => "production-q517sa",
- "review/1-foo" => "review-1-foo-q517sa",
- "1-foo" => "env-1-foo-q517sa",
- "1/foo" => "env-1-foo-q517sa",
- "foo-" => "foo",
- "foo--bar" => "foo-bar-q517sa",
- "foo**bar" => "foo-bar-q517sa",
- "*-foo" => "env-foo-q517sa",
- "staging-12345678-" => "staging-12345678",
- "staging-12345678-01234567" => "staging-12345678-q517sa",
- "" => "env-q517sa",
- nil => "env-q517sa"
- }.each do |name, matcher|
- before do
- # ('a' * 64).to_i(16).to_s(36).last(6) gives 'q517sa'
- allow(Digest::SHA2).to receive(:hexdigest).with(name).and_return('a' * 64)
- end
+ using RSpec::Parameterized::TableSyntax
- it "returns a slug matching #{matcher}, given #{name}" do
- slug = described_class.new(name).generate
+ subject { described_class.new(name).generate }
- expect(slug).to match(/\A#{matcher}\z/)
- end
+ before do
+ # ('a' * 64).to_i(16).to_s(36).last(6) gives 'q517sa'
+ allow(Digest::SHA2).to receive(:hexdigest).with(name.to_s).and_return('a' * 64)
+ end
+
+ where(:name, :slug) do
+ "staging-12345678901234567" | "staging-123456789-q517sa"
+ "9-staging-123456789012345" | "env-9-staging-123-q517sa"
+ "staging-1234567890123456" | "staging-1234567890123456"
+ "staging-1234567890123456-" | "staging-123456789-q517sa"
+ "production" | "production"
+ "PRODUCTION" | "production-q517sa"
+ "review/1-foo" | "review-1-foo-q517sa"
+ "1-foo" | "env-1-foo-q517sa"
+ "1/foo" | "env-1-foo-q517sa"
+ "foo-" | "foo"
+ "foo--bar" | "foo-bar-q517sa"
+ "foo**bar" | "foo-bar-q517sa"
+ "*-foo" | "env-foo-q517sa"
+ "staging-12345678-" | "staging-12345678"
+ "staging-12345678-01234567" | "staging-12345678-q517sa"
+ "" | "env-q517sa"
+ nil | "env-q517sa"
+ end
+
+ with_them do
+ it { is_expected.to eq(slug) }
end
end
end
diff --git a/spec/lib/gitlab/slug/path_spec.rb b/spec/lib/gitlab/slug/path_spec.rb
index 9a7067e40a2..bbc2a05713d 100644
--- a/spec/lib/gitlab/slug/path_spec.rb
+++ b/spec/lib/gitlab/slug/path_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::Slug::Path, feature_category: :not_owned do
+RSpec.describe Gitlab::Slug::Path, feature_category: :shared do
describe '#generate' do
{
'name': 'name',
diff --git a/spec/lib/gitlab/source_spec.rb b/spec/lib/gitlab/source_spec.rb
new file mode 100644
index 00000000000..0b2515baf2b
--- /dev/null
+++ b/spec/lib/gitlab/source_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Source, feature_category: :shared do
+ include StubVersion
+
+ describe '.ref' do
+ subject(:ref) { described_class.ref }
+
+ context 'when not on a pre-release' do
+ before do
+ stub_version('15.0.0-ee', 'a123a123')
+ end
+
+ it { is_expected.to eq('v15.0.0-ee') }
+ end
+
+ context 'when on a pre-release' do
+ before do
+ stub_version('15.0.0-pre', 'a123a123')
+ end
+
+ it { is_expected.to eq('a123a123') }
+ end
+ end
+
+ describe '.release_url' do
+ subject(:release_url) { described_class.release_url }
+
+ def release_path
+ Gitlab::Utils.append_path(
+ described_class.send(:host_url),
+ "#{described_class.send(:group)}/#{described_class.send(:project)}")
+ end
+
+ context 'when not on a pre-release' do
+ before do
+ stub_version('15.0.0-ee', 'a123a123')
+ end
+
+ it 'returns a tag url' do
+ expect(release_url).to eq("#{release_path}/-/tags/v15.0.0-ee")
+ end
+ end
+
+ context 'when on a pre-release' do
+ before do
+ stub_version('15.0.0-pre', 'a123a123')
+ end
+
+ it 'returns a commit url' do
+ expect(release_url).to eq("#{release_path}/-/commits/a123a123")
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/spamcheck/client_spec.rb b/spec/lib/gitlab/spamcheck/client_spec.rb
index 2fe978125c4..ba07da51fb4 100644
--- a/spec/lib/gitlab/spamcheck/client_spec.rb
+++ b/spec/lib/gitlab/spamcheck/client_spec.rb
@@ -2,19 +2,14 @@
require 'spec_helper'
-RSpec.describe Gitlab::Spamcheck::Client do
+RSpec.describe Gitlab::Spamcheck::Client, feature_category: :instance_resiliency do
include_context 'includes Spam constants'
let(:endpoint) { 'grpc://grpc.test.url' }
let_it_be(:user) { create(:user, organization: 'GitLab') }
let(:verdict_value) { ::Spamcheck::SpamVerdict::Verdict::ALLOW }
- let(:error_value) { "" }
-
- let(:attribs_value) do
- extra_attributes = Google::Protobuf::Map.new(:string, :string)
- extra_attributes["monitorMode"] = "false"
- extra_attributes
- end
+ let(:verdict_score) { 0.01 }
+ let(:verdict_evaluated) { true }
let_it_be(:issue) { create(:issue, description: 'Test issue description') }
let_it_be(:snippet) { create(:personal_snippet, :public, description: 'Test issue description') }
@@ -22,8 +17,8 @@ RSpec.describe Gitlab::Spamcheck::Client do
let(:response) do
verdict = ::Spamcheck::SpamVerdict.new
verdict.verdict = verdict_value
- verdict.error = error_value
- verdict.extra_attributes = attribs_value
+ verdict.evaluated = verdict_evaluated
+ verdict.score = verdict_score
verdict
end
@@ -67,19 +62,19 @@ RSpec.describe Gitlab::Spamcheck::Client do
using RSpec::Parameterized::TableSyntax
- where(:verdict, :expected) do
- ::Spamcheck::SpamVerdict::Verdict::ALLOW | Spam::SpamConstants::ALLOW
- ::Spamcheck::SpamVerdict::Verdict::CONDITIONAL_ALLOW | Spam::SpamConstants::CONDITIONAL_ALLOW
- ::Spamcheck::SpamVerdict::Verdict::DISALLOW | Spam::SpamConstants::DISALLOW
- ::Spamcheck::SpamVerdict::Verdict::BLOCK | Spam::SpamConstants::BLOCK_USER
- ::Spamcheck::SpamVerdict::Verdict::NOOP | Spam::SpamConstants::NOOP
+ where(:verdict_value, :expected, :verdict_evaluated, :verdict_score) do
+ ::Spamcheck::SpamVerdict::Verdict::ALLOW | Spam::SpamConstants::ALLOW | true | 0.01
+ ::Spamcheck::SpamVerdict::Verdict::CONDITIONAL_ALLOW | Spam::SpamConstants::CONDITIONAL_ALLOW | true | 0.50
+ ::Spamcheck::SpamVerdict::Verdict::DISALLOW | Spam::SpamConstants::DISALLOW | true | 0.75
+ ::Spamcheck::SpamVerdict::Verdict::BLOCK | Spam::SpamConstants::BLOCK_USER | true | 0.99
+ ::Spamcheck::SpamVerdict::Verdict::NOOP | Spam::SpamConstants::NOOP | false | 0.0
end
with_them do
- let(:verdict_value) { verdict }
-
- it "returns expected spam constant" do
- expect(subject).to eq([expected, { "monitorMode" => "false" }, ""])
+ it "returns expected spam result", :aggregate_failures do
+ expect(subject.verdict).to eq(expected)
+ expect(subject.evaluated?).to eq(verdict_evaluated)
+ expect(subject.score).to be_within(0.000001).of(verdict_score)
end
end
@@ -106,6 +101,19 @@ RSpec.describe Gitlab::Spamcheck::Client do
end
describe "#build_protobuf", :aggregate_failures do
+ let_it_be(:generic_spammable) { Object }
+ let_it_be(:generic_created_at) { issue.created_at }
+ let_it_be(:generic_updated_at) { issue.updated_at }
+
+ before do
+ allow(generic_spammable).to receive_messages(
+ spammable_text: 'generic spam',
+ created_at: generic_created_at,
+ updated_at: generic_updated_at,
+ project: nil
+ )
+ end
+
it 'builds the expected issue protobuf object' do
cxt = { action: :create }
issue_pb, _ = described_class.new.send(:build_protobuf,
@@ -132,21 +140,37 @@ RSpec.describe Gitlab::Spamcheck::Client do
expect(snippet_pb.updated_at).to eq timestamp_to_protobuf_timestamp(snippet.updated_at)
expect(snippet_pb.action).to be ::Spamcheck::Action.lookup(::Spamcheck::Action::CREATE)
expect(snippet_pb.user.username).to eq user.username
- expect(snippet_pb.user.username).to eq user.username
expect(snippet_pb.files.first.path).to eq 'first.rb'
expect(snippet_pb.files.last.path).to eq 'second.rb'
end
+
+ it 'builds the expected generic protobuf object' do
+ cxt = { action: :create }
+ generic_pb, _ = described_class.new.send(:build_protobuf, spammable: generic_spammable, user: user, context: cxt, extra_features: {})
+
+ expect(generic_pb.text).to eq 'generic spam'
+ expect(generic_pb.created_at).to eq timestamp_to_protobuf_timestamp(generic_created_at)
+ expect(generic_pb.updated_at).to eq timestamp_to_protobuf_timestamp(generic_updated_at)
+ expect(generic_pb.action).to be ::Spamcheck::Action.lookup(::Spamcheck::Action::CREATE)
+ expect(generic_pb.user.username).to eq user.username
+ end
end
describe '#build_user_protobuf', :aggregate_failures do
+ before do
+ allow(user).to receive(:account_age_in_days).and_return(10)
+ end
+
it 'builds the expected protobuf object' do
user_pb = described_class.new.send(:build_user_protobuf, user)
expect(user_pb.username).to eq user.username
+ expect(user_pb.id).to eq user.id
expect(user_pb.org).to eq user.organization
expect(user_pb.created_at).to eq timestamp_to_protobuf_timestamp(user.created_at)
expect(user_pb.emails.count).to be 1
expect(user_pb.emails.first.email).to eq user.email
expect(user_pb.emails.first.verified).to eq user.confirmed?
+ expect(user_pb.abuse_metadata[:account_age]).to eq 10
end
context 'when user has multiple email addresses' do
@@ -176,15 +200,14 @@ RSpec.describe Gitlab::Spamcheck::Client do
end
describe "#get_spammable_mappings", :aggregate_failures do
- it 'is an expected spammable' do
+ it 'is a defined spammable' do
protobuf_class, _ = described_class.new.send(:get_spammable_mappings, issue)
expect(protobuf_class).to eq ::Spamcheck::Issue
end
- it 'is an unexpected spammable' do
- expect { described_class.new.send(:get_spammable_mappings, 'spam') }.to raise_error(
- ArgumentError, 'Not a spammable type: String'
- )
+ it 'is a generic spammable' do
+ protobuf_class, _ = described_class.new.send(:get_spammable_mappings, Object)
+ expect(protobuf_class).to eq ::Spamcheck::Generic
end
end
diff --git a/spec/lib/gitlab/spamcheck/result_spec.rb b/spec/lib/gitlab/spamcheck/result_spec.rb
new file mode 100644
index 00000000000..69bd61da8bf
--- /dev/null
+++ b/spec/lib/gitlab/spamcheck/result_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Spamcheck::Result, feature_category: :instance_resiliency do
+ include_context 'includes Spam constants'
+
+ describe "#initialize", :aggregate_failures do
+ using RSpec::Parameterized::TableSyntax
+
+ subject { described_class.new(response) }
+
+ where(:verdict_value, :expected, :verdict_evaluated, :verdict_score) do
+ ::Spamcheck::SpamVerdict::Verdict::ALLOW | Spam::SpamConstants::ALLOW | true | 0.01
+ ::Spamcheck::SpamVerdict::Verdict::CONDITIONAL_ALLOW | Spam::SpamConstants::CONDITIONAL_ALLOW | true | 0.50
+ ::Spamcheck::SpamVerdict::Verdict::DISALLOW | Spam::SpamConstants::DISALLOW | true | 0.75
+ ::Spamcheck::SpamVerdict::Verdict::BLOCK | Spam::SpamConstants::BLOCK_USER | true | 0.99
+ ::Spamcheck::SpamVerdict::Verdict::NOOP | Spam::SpamConstants::NOOP | false | 0.0
+ end
+
+ with_them do
+ let(:response) do
+ verdict = ::Spamcheck::SpamVerdict.new
+ verdict.verdict = verdict_value
+ verdict.evaluated = verdict_evaluated
+ verdict.score = verdict_score
+ verdict
+ end
+
+ it "returns expected verdict" do
+ expect(subject.verdict).to eq(expected)
+ end
+
+ it "returns expected evaluated?" do
+ expect(subject.evaluated?).to eq(verdict_evaluated)
+ end
+
+ it "returns expected score" do
+ expect(subject.score).to be_within(0.000001).of(verdict_score)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/subscription_portal_spec.rb b/spec/lib/gitlab/subscription_portal_spec.rb
index f93eb6f96cc..96d3e855843 100644
--- a/spec/lib/gitlab/subscription_portal_spec.rb
+++ b/spec/lib/gitlab/subscription_portal_spec.rb
@@ -12,61 +12,10 @@ RSpec.describe ::Gitlab::SubscriptionPortal do
stub_env('CUSTOMER_PORTAL_URL', env_value)
end
- describe '.default_subscriptions_url' do
- where(:test, :development, :result) do
- false | false | prod_customers_url
- false | true | staging_customers_url
- true | false | staging_customers_url
- end
-
- before do
- allow(Rails).to receive_message_chain(:env, :test?).and_return(test)
- allow(Rails).to receive_message_chain(:env, :development?).and_return(development)
- end
-
- with_them do
- subject { described_class.default_subscriptions_url }
-
- it { is_expected.to eq(result) }
- end
- end
-
- describe '.subscriptions_url' do
- subject { described_class.subscriptions_url }
-
- context 'when CUSTOMER_PORTAL_URL ENV is unset' do
- it { is_expected.to eq(staging_customers_url) }
- end
-
- context 'when CUSTOMER_PORTAL_URL ENV is set' do
- let(:env_value) { 'https://customers.example.com' }
-
- it { is_expected.to eq(env_value) }
- end
- end
-
- describe '.subscriptions_comparison_url' do
- subject { described_class.subscriptions_comparison_url }
-
- link_match = %r{\Ahttps://about\.gitlab\.((cn/pricing/saas)|(com/pricing/gitlab-com))/feature-comparison\z}
-
- it { is_expected.to match(link_match) }
- end
-
describe 'class methods' do
where(:method_name, :result) do
- :default_subscriptions_url | staging_customers_url
- :payment_form_url | "#{staging_customers_url}/payment_forms/cc_validation"
:payment_validation_form_id | 'payment_method_validation'
- :registration_validation_form_url | "#{staging_customers_url}/payment_forms/cc_registration_validation"
:registration_validation_form_id | 'cc_registration_validation'
- :subscriptions_graphql_url | "#{staging_customers_url}/graphql"
- :subscriptions_more_minutes_url | "#{staging_customers_url}/buy_pipeline_minutes"
- :subscriptions_more_storage_url | "#{staging_customers_url}/buy_storage"
- :subscriptions_manage_url | "#{staging_customers_url}/subscriptions"
- :subscriptions_instance_review_url | "#{staging_customers_url}/instance_review"
- :subscriptions_gitlab_plans_url | "#{staging_customers_url}/gitlab_plans"
- :edit_account_url | "#{staging_customers_url}/customers/edit"
end
with_them do
@@ -76,40 +25,6 @@ RSpec.describe ::Gitlab::SubscriptionPortal do
end
end
- describe '.add_extra_seats_url' do
- subject { described_class.add_extra_seats_url(group_id) }
-
- let(:group_id) { 153 }
-
- it do
- url = "#{staging_customers_url}/gitlab/namespaces/#{group_id}/extra_seats"
- is_expected.to eq(url)
- end
- end
-
- describe '.upgrade_subscription_url' do
- subject { described_class.upgrade_subscription_url(group_id, plan_id) }
-
- let(:group_id) { 153 }
- let(:plan_id) { 5 }
-
- it do
- url = "#{staging_customers_url}/gitlab/namespaces/#{group_id}/upgrade/#{plan_id}"
- is_expected.to eq(url)
- end
- end
-
- describe '.renew_subscription_url' do
- subject { described_class.renew_subscription_url(group_id) }
-
- let(:group_id) { 153 }
-
- it do
- url = "#{staging_customers_url}/gitlab/namespaces/#{group_id}/renew"
- is_expected.to eq(url)
- end
- end
-
describe 'constants' do
where(:constant_name, :result) do
'REGISTRATION_VALIDATION_FORM_ID' | 'cc_registration_validation'
diff --git a/spec/lib/gitlab/template/finders/global_template_finder_spec.rb b/spec/lib/gitlab/template/finders/global_template_finder_spec.rb
index 38ec28c2b9a..c1dfee3cccb 100644
--- a/spec/lib/gitlab/template/finders/global_template_finder_spec.rb
+++ b/spec/lib/gitlab/template/finders/global_template_finder_spec.rb
@@ -16,10 +16,12 @@ RSpec.describe Gitlab::Template::Finders::GlobalTemplateFinder do
end
subject(:finder) do
- described_class.new(base_dir, '',
- { 'General' => '', 'Bar' => 'Bar' },
- include_categories_for_file,
- excluded_patterns: excluded_patterns)
+ described_class.new(
+ base_dir, '',
+ { 'General' => '', 'Bar' => 'Bar' },
+ include_categories_for_file,
+ excluded_patterns: excluded_patterns
+ )
end
let(:excluded_patterns) { [] }
diff --git a/spec/lib/gitlab/timeless_spec.rb b/spec/lib/gitlab/timeless_spec.rb
new file mode 100644
index 00000000000..d806349d326
--- /dev/null
+++ b/spec/lib/gitlab/timeless_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Timeless, feature_category: :shared do
+ let(:model) { build(:user) }
+
+ it 'disables record_timestamps temporarily' do
+ expect(model.record_timestamps).to eq(true)
+
+ Gitlab::Timeless.timeless(model) do |m|
+ expect(m.record_timestamps).to eq(false)
+ expect(model.record_timestamps).to eq(false)
+ end
+
+ expect(model.record_timestamps).to eq(true)
+ end
+
+ it 'does not record created_at' do
+ Gitlab::Timeless.timeless(model) do
+ model.save!(username: "#{model.username}-a")
+ end
+
+ expect(model.created_at).to be(nil)
+ end
+
+ it 'does not record updated_at' do
+ model.save!
+ previous = model.updated_at
+
+ Gitlab::Timeless.timeless(model) do
+ model.update!(username: "#{model.username}-a")
+ end
+
+ expect(model.updated_at).to eq(previous)
+ end
+end
diff --git a/spec/lib/gitlab/tracking/destinations/database_events_snowplow_spec.rb b/spec/lib/gitlab/tracking/destinations/database_events_snowplow_spec.rb
new file mode 100644
index 00000000000..78a869b535a
--- /dev/null
+++ b/spec/lib/gitlab/tracking/destinations/database_events_snowplow_spec.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Tracking::Destinations::DatabaseEventsSnowplow, :do_not_stub_snowplow_by_default, feature_category: :application_instrumentation do
+ let(:emitter) { SnowplowTracker::Emitter.new(endpoint: 'localhost', options: { buffer_size: 1 }) }
+
+ let(:tracker) do
+ SnowplowTracker::Tracker
+ .new(
+ emitters: [emitter],
+ subject: SnowplowTracker::Subject.new,
+ namespace: 'namespace',
+ app_id: 'app_id'
+ )
+ end
+
+ before do
+ stub_application_setting(snowplow_app_id: '_abc123_')
+ end
+
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ context 'when snowplow is enabled' do
+ before do
+ allow(SnowplowTracker::AsyncEmitter)
+ .to receive(:new)
+ .with(endpoint: endpoint,
+ options:
+ {
+ protocol: 'https',
+ on_success: subject.method(:increment_successful_events_emissions),
+ on_failure: subject.method(:failure_callback)
+ }
+ ).and_return(emitter)
+
+ allow(SnowplowTracker::Tracker)
+ .to receive(:new)
+ .with(
+ emitters: [emitter],
+ subject: an_instance_of(SnowplowTracker::Subject),
+ namespace: described_class::SNOWPLOW_NAMESPACE,
+ app_id: '_abc123_'
+ ).and_return(tracker)
+ end
+
+ describe '#event' do
+ let(:endpoint) { 'localhost:9091' }
+ let(:event_params) do
+ {
+ category: 'category',
+ action: 'action',
+ label: 'label',
+ property: 'property',
+ value: 1.5,
+ context: nil,
+ tstamp: (Time.now.to_f * 1000).to_i
+ }
+ end
+
+ context 'when on gitlab.com environment' do
+ let(:endpoint) { 'db-snowplow.trx.gitlab.net' }
+
+ it 'sends event to tracker' do
+ allow(Gitlab).to receive(:com?).and_return(true)
+ allow(tracker).to receive(:track_struct_event).and_call_original
+
+ subject.event('category', 'action', label: 'label', property: 'property', value: 1.5)
+
+ expect(tracker).to have_received(:track_struct_event).with(event_params)
+ end
+ end
+
+ it 'sends event to tracker' do
+ allow(tracker).to receive(:track_struct_event).and_call_original
+
+ subject.event('category', 'action', label: 'label', property: 'property', value: 1.5)
+
+ expect(tracker).to have_received(:track_struct_event).with(event_params)
+ end
+
+ it 'increase total snowplow events counter' do
+ counter = double
+
+ expect(counter).to receive(:increment)
+ expect(Gitlab::Metrics).to receive(:counter)
+ .with(:gitlab_db_events_snowplow_events_total, 'Number of Snowplow events')
+ .and_return(counter)
+
+ subject.event('category', 'action', label: 'label', property: 'property', value: 1.5)
+ end
+ end
+ end
+
+ context 'for callbacks' do
+ describe 'on success' do
+ it 'increase gitlab_successful_snowplow_events_total counter' do
+ counter = double
+
+ expect(counter).to receive(:increment).with({}, 2)
+ expect(Gitlab::Metrics).to receive(:counter)
+ .with(
+ :gitlab_db_events_snowplow_successful_events_total,
+ 'Number of successful Snowplow events emissions').and_return(counter)
+
+ subject.method(:increment_successful_events_emissions).call(2)
+ end
+ end
+
+ describe 'on failure' do
+ it 'increase gitlab_failed_snowplow_events_total counter and logs failures', :aggregate_failures do
+ counter = double
+ error_message = "Issue database_event_update failed to be reported to collector at localhost:9091"
+ failures = [{ "e" => "se",
+ "se_ca" => "Issue",
+ "se_la" => "issues",
+ "se_ac" => "database_event_update" }]
+ allow(Gitlab::Metrics).to receive(:counter)
+ .with(
+ :gitlab_db_events_snowplow_successful_events_total,
+ 'Number of successful Snowplow events emissions').and_call_original
+
+ expect(Gitlab::AppLogger).to receive(:error).with(error_message)
+ expect(counter).to receive(:increment).with({}, 1)
+ expect(Gitlab::Metrics).to receive(:counter)
+ .with(
+ :gitlab_db_events_snowplow_failed_events_total,
+ 'Number of failed Snowplow events emissions').and_return(counter)
+
+ subject.method(:failure_callback).call(2, failures)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb b/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb
index 48092a33da3..ea3c030541f 100644
--- a/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb
+++ b/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb
@@ -45,7 +45,7 @@ RSpec.describe Gitlab::Tracking::Destinations::SnowplowMicro do
context 'when snowplow_micro config is not set' do
before do
- allow(Gitlab.config).to receive(:snowplow_micro).and_raise(Settingslogic::MissingSetting)
+ allow(Gitlab.config).to receive(:snowplow_micro).and_raise(GitlabSettings::MissingSetting)
end
it 'returns localhost hostname' do
diff --git a/spec/lib/gitlab/tracking/event_definition_spec.rb b/spec/lib/gitlab/tracking/event_definition_spec.rb
index c8e616b092b..b27aaa35695 100644
--- a/spec/lib/gitlab/tracking/event_definition_spec.rb
+++ b/spec/lib/gitlab/tracking/event_definition_spec.rb
@@ -12,7 +12,6 @@ RSpec.describe Gitlab::Tracking::EventDefinition do
property_description: 'The string "issue_id"',
value_description: 'ID of the issue',
extra_properties: { confidential: false },
- product_category: 'collection',
product_stage: 'growth',
product_section: 'dev',
product_group: 'group::product analytics',
@@ -47,7 +46,6 @@ RSpec.describe Gitlab::Tracking::EventDefinition do
:property_description | 1
:value_description | 1
:extra_properties | 'smth'
- :product_category | 1
:product_stage | 1
:product_section | nil
:product_group | nil
diff --git a/spec/lib/gitlab/tracking/standard_context_spec.rb b/spec/lib/gitlab/tracking/standard_context_spec.rb
index cfb83bc0528..e1ae362e797 100644
--- a/spec/lib/gitlab/tracking/standard_context_spec.rb
+++ b/spec/lib/gitlab/tracking/standard_context_spec.rb
@@ -70,7 +70,9 @@ RSpec.describe Gitlab::Tracking::StandardContext do
end
context 'when namespace is available' do
- subject { described_class.new(namespace: create(:namespace)) }
+ let(:namespace) { create(:namespace) }
+
+ subject { described_class.new(namespace_id: namespace.id, plan_name: namespace.actual_plan_name) }
it 'contains plan name' do
expect(snowplow_context.to_json.dig(:data, :plan)).to eq(Plan::DEFAULT)
@@ -93,7 +95,7 @@ RSpec.describe Gitlab::Tracking::StandardContext do
end
context 'with incorrect argument type' do
- subject { described_class.new(project: create(:group)) }
+ subject { described_class.new(project_id: create(:group)) }
it 'does call `track_and_raise_for_dev_exception`' do
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb
index e79bb2ef129..a353a3a512c 100644
--- a/spec/lib/gitlab/tracking_spec.rb
+++ b/spec/lib/gitlab/tracking_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Gitlab::Tracking do
+RSpec.describe Gitlab::Tracking, feature_category: :application_instrumentation do
include StubENV
before do
@@ -102,12 +102,28 @@ RSpec.describe Gitlab::Tracking do
end
end
- describe '.event' do
+ context 'event tracking' do
let(:namespace) { create(:namespace) }
- shared_examples 'delegates to destination' do |klass|
+ shared_examples 'rescued error raised by destination class' do
+ it 'rescues error' do
+ error = StandardError.new("something went wrong")
+ allow_any_instance_of(destination_class).to receive(:event).and_raise(error)
+
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+ .with(
+ error,
+ snowplow_category: category,
+ snowplow_action: action
+ )
+
+ expect { tracking_method }.not_to raise_error
+ end
+ end
+
+ shared_examples 'delegates to destination' do |klass, method|
before do
- allow_any_instance_of(Gitlab::Tracking::Destinations::Snowplow).to receive(:event)
+ allow_any_instance_of(klass).to receive(:event)
end
it "delegates to #{klass} destination" do
@@ -118,8 +134,8 @@ RSpec.describe Gitlab::Tracking do
expect(Gitlab::Tracking::StandardContext)
.to receive(:new)
- .with(project: project, user: user, namespace: namespace, extra_key_1: 'extra value 1', extra_key_2: 'extra value 2')
- .and_call_original
+ .with(project_id: project.id, user_id: user.id, namespace_id: namespace.id, plan_name: namespace.actual_plan_name, extra_key_1: 'extra value 1', extra_key_2: 'extra value 2')
+ .and_call_original
expect_any_instance_of(klass).to receive(:event) do |_, category, action, args|
expect(category).to eq('category')
@@ -132,7 +148,7 @@ RSpec.describe Gitlab::Tracking do
expect(args[:context].last).to eq(other_context)
end
- described_class.event('category', 'action',
+ described_class.method(method).call('category', 'action',
label: 'label',
property: 'property',
value: 1.5,
@@ -141,44 +157,95 @@ RSpec.describe Gitlab::Tracking do
user: user,
namespace: namespace,
extra_key_1: 'extra value 1',
- extra_key_2: 'extra value 2')
+ extra_key_2: 'extra value 2'
+ )
end
end
- context 'when the action is not passed in as a string' do
- it 'allows symbols' do
- expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
+ describe '.database_event' do
+ context 'when the action is not passed in as a string' do
+ it 'allows symbols' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
- described_class.event('category', :some_action)
- end
+ described_class.database_event('category', :some_action)
+ end
+
+ it 'allows nil' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
+
+ described_class.database_event('category', nil)
+ end
- it 'allows nil' do
- expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
+ it 'allows integers' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
- described_class.event('category', nil)
+ described_class.database_event('category', 1)
+ end
end
- it 'allows integers' do
- expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
+ it_behaves_like 'rescued error raised by destination class' do
+ let(:category) { 'Issue' }
+ let(:action) { 'created' }
+ let(:destination_class) { Gitlab::Tracking::Destinations::DatabaseEventsSnowplow }
- described_class.event('category', 1)
+ subject(:tracking_method) { described_class.database_event(category, action) }
end
+
+ it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::DatabaseEventsSnowplow, :database_event
end
- context 'when destination is Snowplow' do
- before do
- allow(Rails.env).to receive(:development?).and_return(true)
+ describe '.event' do
+ context 'when the action is not passed in as a string' do
+ it 'allows symbols' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
+
+ described_class.event('category', :some_action)
+ end
+
+ it 'allows nil' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
+
+ described_class.event('category', nil)
+ end
+
+ it 'allows integers' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
+
+ described_class.event('category', 1)
+ end
end
- it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::Snowplow
- end
+ context 'when destination is Snowplow' do
+ before do
+ allow(Rails.env).to receive(:development?).and_return(true)
+ end
- context 'when destination is SnowplowMicro' do
- before do
- allow(Rails.env).to receive(:development?).and_return(true)
+ it_behaves_like 'rescued error raised by destination class' do
+ let(:category) { 'category' }
+ let(:action) { 'action' }
+ let(:destination_class) { Gitlab::Tracking::Destinations::Snowplow }
+
+ subject(:tracking_method) { described_class.event(category, action) }
+ end
+
+ it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::Snowplow, :event
end
- it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::SnowplowMicro
+ context 'when destination is SnowplowMicro' do
+ before do
+ allow(Rails.env).to receive(:development?).and_return(true)
+ end
+
+ it_behaves_like 'rescued error raised by destination class' do
+ let(:category) { 'category' }
+ let(:action) { 'action' }
+ let(:destination_class) { Gitlab::Tracking::Destinations::Snowplow }
+
+ subject(:tracking_method) { described_class.event(category, action) }
+ end
+
+ it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::SnowplowMicro, :event
+ end
end
end
@@ -245,7 +312,7 @@ RSpec.describe Gitlab::Tracking do
end
it 'returns false when snowplow_micro is not configured' do
- allow(Gitlab.config).to receive(:snowplow_micro).and_raise(Settingslogic::MissingSetting)
+ allow(Gitlab.config).to receive(:snowplow_micro).and_raise(GitlabSettings::MissingSetting)
expect(described_class).not_to be_snowplow_micro_enabled
end
diff --git a/spec/lib/gitlab/untrusted_regexp_spec.rb b/spec/lib/gitlab/untrusted_regexp_spec.rb
index 66675b20107..8c3669d6773 100644
--- a/spec/lib/gitlab/untrusted_regexp_spec.rb
+++ b/spec/lib/gitlab/untrusted_regexp_spec.rb
@@ -3,7 +3,11 @@
require 'fast_spec_helper'
require 'support/shared_examples/lib/gitlab/malicious_regexp_shared_examples'
-RSpec.describe Gitlab::UntrustedRegexp do
+RSpec.describe Gitlab::UntrustedRegexp, feature_category: :shared do
+ def create_regex(regex_str, multiline: false)
+ described_class.new(regex_str, multiline: multiline).freeze
+ end
+
describe '#initialize' do
subject { described_class.new(pattern) }
@@ -16,15 +20,48 @@ RSpec.describe Gitlab::UntrustedRegexp do
describe '#replace_all' do
it 'replaces all instances of the match in a string' do
- result = described_class.new('foo').replace_all('foo bar foo', 'oof')
+ result = create_regex('foo').replace_all('foo bar foo', 'oof')
expect(result).to eq('oof bar oof')
end
end
+ describe '#replace_gsub' do
+ let(:regex_str) { '(?P<scheme>(ftp))' }
+ let(:regex) { create_regex(regex_str, multiline: true) }
+
+ def result(regex, text)
+ regex.replace_gsub(text) do |match|
+ if match[:scheme]
+ "http|#{match[:scheme]}|rss"
+ else
+ match.to_s
+ end
+ end
+ end
+
+ it 'replaces all instances of the match in a string' do
+ text = 'Use only https instead of ftp'
+
+ expect(result(regex, text)).to eq('Use only https instead of http|ftp|rss')
+ end
+
+ it 'replaces nothing when no match' do
+ text = 'Use only https instead of gopher'
+
+ expect(result(regex, text)).to eq(text)
+ end
+
+ it 'handles empty text' do
+ text = ''
+
+ expect(result(regex, text)).to eq('')
+ end
+ end
+
describe '#replace' do
it 'replaces the first instance of the match in a string' do
- result = described_class.new('foo').replace('foo bar foo', 'oof')
+ result = create_regex('foo').replace('foo bar foo', 'oof')
expect(result).to eq('oof bar foo')
end
@@ -32,19 +69,19 @@ RSpec.describe Gitlab::UntrustedRegexp do
describe '#===' do
it 'returns true for a match' do
- result = described_class.new('foo') === 'a foo here'
+ result = create_regex('foo') === 'a foo here'
expect(result).to be_truthy
end
it 'returns false for no match' do
- result = described_class.new('foo') === 'a bar here'
+ result = create_regex('foo') === 'a bar here'
expect(result).to be_falsy
end
it 'can handle regular expressions in multiline mode' do
- regexp = described_class.new('^\d', multiline: true)
+ regexp = create_regex('^\d', multiline: true)
result = regexp === "Header\n\n1. Content"
@@ -53,7 +90,7 @@ RSpec.describe Gitlab::UntrustedRegexp do
end
describe '#match?' do
- subject { described_class.new(regexp).match?(text) }
+ subject { create_regex(regexp).match?(text) }
context 'malicious regexp' do
let(:text) { malicious_text }
@@ -82,7 +119,7 @@ RSpec.describe Gitlab::UntrustedRegexp do
end
describe '#scan' do
- subject { described_class.new(regexp).scan(text) }
+ subject { create_regex(regexp).scan(text) }
context 'malicious regexp' do
let(:text) { malicious_text }
@@ -138,7 +175,7 @@ RSpec.describe Gitlab::UntrustedRegexp do
end
describe '#extract_named_group' do
- let(:re) { described_class.new('(?P<name>\w+) (?P<age>\d+)|(?P<name_only>\w+)') }
+ let(:re) { create_regex('(?P<name>\w+) (?P<age>\d+)|(?P<name_only>\w+)') }
let(:text) { 'Bob 40' }
it 'returns values for both named groups' do
@@ -172,7 +209,7 @@ RSpec.describe Gitlab::UntrustedRegexp do
describe '#match' do
context 'when there are matches' do
it 'returns a match object' do
- result = described_class.new('(?P<number>\d+)').match('hello 10')
+ result = create_regex('(?P<number>\d+)').match('hello 10')
expect(result[:number]).to eq('10')
end
@@ -180,7 +217,7 @@ RSpec.describe Gitlab::UntrustedRegexp do
context 'when there are no matches' do
it 'returns nil' do
- result = described_class.new('(?P<number>\d+)').match('hello')
+ result = create_regex('(?P<number>\d+)').match('hello')
expect(result).to be_nil
end
diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb
index 05f7af7606d..cfd40fb93b5 100644
--- a/spec/lib/gitlab/url_blocker_spec.rb
+++ b/spec/lib/gitlab/url_blocker_spec.rb
@@ -2,13 +2,18 @@
require 'spec_helper'
-RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
+RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only, feature_category: :shared do
include StubRequests
let(:schemes) { %w[http https] }
+ # This test ensures backward compatibliity for the validate! method.
+ # We shoud refactor all callers of validate! to handle a Result object:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/410890
describe '#validate!' do
- subject { described_class.validate!(import_url, schemes: schemes) }
+ let(:options) { { schemes: schemes } }
+
+ subject { described_class.validate!(import_url, **options) }
shared_examples 'validates URI and hostname' do
it 'runs the url validations' do
@@ -19,13 +24,113 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
end
+ context 'when the URL hostname is a domain' do
+ context 'when domain can be resolved' do
+ let(:import_url) { 'https://example.org' }
+
+ before do
+ stub_dns(import_url, ip_address: '93.184.216.34')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { 'https://93.184.216.34' }
+ let(:expected_hostname) { 'example.org' }
+ let(:expected_use_proxy) { false }
+ end
+ end
+ end
+ end
+
+ describe '#validate_url_with_proxy!' do
+ let(:options) { { schemes: schemes } }
+
+ subject { described_class.validate_url_with_proxy!(import_url, **options) }
+
+ shared_examples 'validates URI and hostname' do
+ it 'runs the url validations' do
+ expect(subject.uri).to eq(Addressable::URI.parse(expected_uri))
+ expect(subject.hostname).to eq(expected_hostname)
+ expect(subject.use_proxy).to eq(expected_use_proxy)
+ end
+ end
+
+ shared_context 'when instance configured to deny all requests' do
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:current_application_settings?).and_return(true)
+ stub_application_setting(deny_all_requests_except_allowed: true)
+ end
+ end
+
+ shared_examples 'a URI denied by `deny_all_requests_except_allowed`' do
+ context 'when instance setting is enabled' do
+ include_context 'when instance configured to deny all requests'
+
+ it 'blocks the request' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+
+ context 'when instance setting is not enabled' do
+ it 'does not block the request' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when passed as an argument' do
+ let(:options) { super().merge(deny_all_requests_except_allowed: arg_value) }
+
+ context 'when argument is a proc that evaluates to true' do
+ let(:arg_value) { proc { true } }
+
+ it 'blocks the request' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+
+ context 'when argument is a proc that evaluates to false' do
+ let(:arg_value) { proc { false } }
+
+ it 'does not block the request' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when argument is true' do
+ let(:arg_value) { true }
+
+ it 'blocks the request' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+
+ context 'when argument is false' do
+ let(:arg_value) { false }
+
+ it 'does not block the request' do
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+ end
+
+ shared_examples 'a URI exempt from `deny_all_requests_except_allowed`' do
+ include_context 'when instance configured to deny all requests'
+
+ it 'does not block the request' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
context 'when URI is nil' do
let(:import_url) { nil }
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { nil }
let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { true }
end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
end
context 'when URI is internal' do
@@ -38,7 +143,10 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { 'http://127.0.0.1' }
let(:expected_hostname) { 'localhost' }
+ let(:expected_use_proxy) { false }
end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
end
context 'when URI is for a local object storage' do
@@ -61,7 +169,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
context 'when allow_object_storage is true' do
- subject { described_class.validate!(import_url, allow_object_storage: true, schemes: schemes) }
+ let(:options) { { allow_object_storage: true, schemes: schemes } }
context 'with a local domain name' do
let(:host) { 'http://review-minio-svc.svc:9000' }
@@ -73,7 +181,10 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { 'http://127.0.0.1:9000/external-diffs/merge_request_diffs/mr-1/diff-1' }
let(:expected_hostname) { 'review-minio-svc.svc' }
+ let(:expected_use_proxy) { false }
end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
end
context 'with an IP address' do
@@ -82,15 +193,18 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { 'http://127.0.0.1:9000/external-diffs/merge_request_diffs/mr-1/diff-1' }
let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
end
context 'when LFS object storage is enabled' do
let(:lfs_config) do
{
'enabled' => lfs_enabled,
- # This nesting of Settingslogic is necessary to trigger the bug
- 'object_store' => Settingslogic.new({ 'enabled' => true })
+ # This nesting of settings is necessary to trigger the bug
+ 'object_store' => GitlabSettings::Options.build({ 'enabled' => true })
}
end
@@ -98,16 +212,15 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
{
'gitlab' => Gitlab.config.gitlab,
'repositories' => { 'storages' => { 'default' => 'test' } },
- 'lfs' => Settingslogic.new(lfs_config)
+ 'lfs' => GitlabSettings::Options.build(lfs_config)
}
end
let(:host) { 'http://127.0.0.1:9000' }
- let(:settings) { Settingslogic.new(config) }
+ let(:settings) { GitlabSettings::Options.build(config) }
before do
allow(Gitlab).to receive(:config).and_return(settings)
- # Triggers Settingslogic bug: https://gitlab.com/gitlab-org/gitlab/-/issues/286873
settings.repositories.storages.default
end
@@ -163,21 +276,52 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { 'https://93.184.216.34' }
let(:expected_hostname) { 'example.org' }
+ let(:expected_use_proxy) { false }
end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
end
context 'when domain cannot be resolved' do
let(:import_url) { 'http://foobar.x' }
- it 'raises an error' do
+ before do
stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+ end
+ it 'raises an error' do
expect { subject }.to raise_error(described_class::BlockedUrlError)
end
+
+ context 'with HTTP_PROXY' do
+ let(:import_url) { 'http://foobar.x' }
+
+ before do
+ stub_env('http_proxy', 'http://proxy.example.com')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { true }
+ end
+
+ context 'with no_proxy' do
+ before do
+ stub_env('no_proxy', 'foobar.x')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
+ end
+ end
+ end
end
context 'when domain is too long' do
- let(:import_url) { 'https://example' + 'a' * 1024 + '.com' }
+ let(:import_url) { "https://example#{'a' * 1024}.com" }
it 'raises an error' do
expect { subject }.to raise_error(described_class::BlockedUrlError)
@@ -191,8 +335,11 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { import_url }
let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
end
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
+
context 'when the address is invalid' do
let(:import_url) { 'http://1.1.1.1.1' }
@@ -204,7 +351,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
end
- context 'DNS rebinding protection with IP allowed' do
+ context 'when DNS rebinding protection with IP allowed' do
let(:import_url) { 'http://a.192.168.0.120.3times.127.0.0.1.1time.repeat.rebind.network:9121/scrape?target=unix:///var/opt/gitlab/redis/redis.socket&amp;check-keys=*' }
before do
@@ -216,11 +363,38 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { 'http://192.168.0.120:9121/scrape?target=unix:///var/opt/gitlab/redis/redis.socket&amp;check-keys=*' }
let(:expected_hostname) { 'a.192.168.0.120.3times.127.0.0.1.1time.repeat.rebind.network' }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
+
+ context 'with HTTP_PROXY' do
+ before do
+ stub_env('http_proxy', 'http://proxy.example.com')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { true }
+ end
+
+ context 'when domain is in no_proxy env' do
+ before do
+ stub_env('no_proxy', 'a.192.168.0.120.3times.127.0.0.1.1time.repeat.rebind.network')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { 'http://192.168.0.120:9121/scrape?target=unix:///var/opt/gitlab/redis/redis.socket&amp;check-keys=*' }
+ let(:expected_hostname) { 'a.192.168.0.120.3times.127.0.0.1.1time.repeat.rebind.network' }
+ let(:expected_use_proxy) { false }
+ end
+ end
end
end
- context 'disabled DNS rebinding protection' do
- subject { described_class.validate!(import_url, dns_rebind_protection: false, schemes: schemes) }
+ context 'with disabled DNS rebinding protection' do
+ let(:options) { { dns_rebind_protection: false, schemes: schemes } }
context 'when URI is internal' do
let(:import_url) { 'http://localhost' }
@@ -228,7 +402,10 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { import_url }
let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
end
context 'when the URL hostname is a domain' do
@@ -242,7 +419,10 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { import_url }
let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
end
context 'when domain cannot be resolved' do
@@ -251,7 +431,10 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { import_url }
let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
end
end
@@ -261,15 +444,21 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { import_url }
let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
end
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
+
context 'when it is invalid' do
let(:import_url) { 'http://1.1.1.1.1' }
it_behaves_like 'validates URI and hostname' do
let(:expected_uri) { import_url }
let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
end
end
end
@@ -390,7 +579,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git', schemes: schemes)).to be false
end
- context 'when allow_local_network is' do
+ describe 'allow_local_network' do
let(:shared_address_space_ips) { ['100.64.0.0', '100.64.127.127', '100.64.255.255'] }
let(:local_ips) do
@@ -471,11 +660,11 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
end
- context 'true (default)' do
+ context 'when true (default)' do
it_behaves_like 'allows local requests', { allow_localhost: true, allow_local_network: true, schemes: %w[http https] }
end
- context 'false' do
+ context 'when false' do
it 'blocks urls from private networks' do
local_ips.each do |ip|
stub_domain_resolv(fake_domain, ip) do
@@ -628,14 +817,14 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
end
- context 'when dns_rebinding_setting is' do
- context 'enabled' do
+ describe 'dns_rebinding_setting' do
+ context 'when enabled' do
let(:dns_rebind_value) { true }
it_behaves_like 'allowlists the domain'
end
- context 'disabled' do
+ context 'when disabled' do
let(:dns_rebind_value) { false }
it_behaves_like 'allowlists the domain'
@@ -675,8 +864,8 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
end
- context 'when enforce_user is' do
- context 'false (default)' do
+ describe 'enforce_user' do
+ context 'when false (default)' do
it 'does not block urls with a non-alphanumeric username' do
expect(described_class).not_to be_blocked_url('ssh://-oProxyCommand=whoami@example.com/a', schemes: ['ssh'])
@@ -688,7 +877,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
end
- context 'true' do
+ context 'when true' do
it 'blocks urls with a non-alphanumeric username' do
aggregate_failures do
expect(described_class).to be_blocked_url('ssh://-oProxyCommand=whoami@example.com/a', enforce_user: true, schemes: ['ssh'])
@@ -756,7 +945,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
end
- def stub_domain_resolv(domain, ip, port = 80, &block)
+ def stub_domain_resolv(domain, ip, port = 80)
address = instance_double(Addrinfo,
ip_address: ip,
ipv4_private?: true,
diff --git a/spec/lib/gitlab/url_blockers/ip_allowlist_entry_spec.rb b/spec/lib/gitlab/url_blockers/ip_allowlist_entry_spec.rb
index 8dcb402dfb2..c56e5ce4e7a 100644
--- a/spec/lib/gitlab/url_blockers/ip_allowlist_entry_spec.rb
+++ b/spec/lib/gitlab/url_blockers/ip_allowlist_entry_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::UrlBlockers::IpAllowlistEntry do
+RSpec.describe Gitlab::UrlBlockers::IpAllowlistEntry, feature_category: :integrations do
let(:ipv4) { IPAddr.new('192.168.1.1') }
describe '#initialize' do
@@ -65,11 +65,31 @@ RSpec.describe Gitlab::UrlBlockers::IpAllowlistEntry do
end
it 'matches IPv6 within IPv6 range' do
- ipv6_range = IPAddr.new('fd84:6d02:f6d8:c89e::/124')
+ ipv6_range = IPAddr.new('::ffff:192.168.1.0/8')
ip_allowlist_entry = described_class.new(ipv6_range)
expect(ip_allowlist_entry).to be_match(ipv6_range.to_range.last.to_s, 8080)
expect(ip_allowlist_entry).not_to be_match('fd84:6d02:f6d8:f::f', 8080)
end
+
+ it 'matches IPv4 to IPv6 mapped addresses in allow list' do
+ ipv6_range = IPAddr.new('::ffff:192.168.1.1')
+ ip_allowlist_entry = described_class.new(ipv6_range)
+
+ expect(ip_allowlist_entry).to be_match(ipv4, 8080)
+ expect(ip_allowlist_entry).to be_match(ipv6_range.to_range.last.to_s, 8080)
+ expect(ip_allowlist_entry).not_to be_match('::ffff:192.168.1.0', 8080)
+ expect(ip_allowlist_entry).not_to be_match('::ffff:169.254.168.101', 8080)
+ end
+
+ it 'matches IPv4 to IPv6 mapped addresses in requested IP' do
+ ipv4_range = IPAddr.new('192.168.1.1/24')
+ ip_allowlist_entry = described_class.new(ipv4_range)
+
+ expect(ip_allowlist_entry).to be_match(ipv4, 8080)
+ expect(ip_allowlist_entry).to be_match('::ffff:192.168.1.0', 8080)
+ expect(ip_allowlist_entry).to be_match('::ffff:192.168.1.1', 8080)
+ expect(ip_allowlist_entry).not_to be_match('::ffff:169.254.170.100/8', 8080)
+ end
end
end
diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb
index 2e9a444bd24..73627d3e6ff 100644
--- a/spec/lib/gitlab/url_builder_spec.rb
+++ b/spec/lib/gitlab/url_builder_spec.rb
@@ -22,8 +22,8 @@ RSpec.describe Gitlab::UrlBuilder do
:group_board | ->(board) { "/groups/#{board.group.full_path}/-/boards/#{board.id}" }
:commit | ->(commit) { "/#{commit.project.full_path}/-/commit/#{commit.id}" }
:issue | ->(issue) { "/#{issue.project.full_path}/-/issues/#{issue.iid}" }
- [:issue, :task] | ->(issue) { "/#{issue.project.full_path}/-/work_items/#{issue.iid}?iid_path=true" }
- :work_item | ->(work_item) { "/#{work_item.project.full_path}/-/work_items/#{work_item.iid}?iid_path=true" }
+ [:issue, :task] | ->(issue) { "/#{issue.project.full_path}/-/work_items/#{issue.iid}" }
+ :work_item | ->(work_item) { "/#{work_item.project.full_path}/-/work_items/#{work_item.iid}" }
:merge_request | ->(merge_request) { "/#{merge_request.project.full_path}/-/merge_requests/#{merge_request.iid}" }
:project_milestone | ->(milestone) { "/#{milestone.project.full_path}/-/milestones/#{milestone.iid}" }
:project_snippet | ->(snippet) { "/#{snippet.project.full_path}/-/snippets/#{snippet.id}" }
@@ -227,27 +227,5 @@ RSpec.describe Gitlab::UrlBuilder do
expect(subject.build(object, only_path: true)).to eq("/#{project.full_path}")
end
end
-
- context 'when use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- end
-
- context 'when a task issue is passed' do
- it 'returns a path using the work item\'s ID and no query params' do
- task = create(:issue, :task)
-
- expect(subject.build(task, only_path: true)).to eq("/#{task.project.full_path}/-/work_items/#{task.id}")
- end
- end
-
- context 'when a work item is passed' do
- it 'returns a path using the work item\'s ID and no query params' do
- work_item = create(:work_item)
-
- expect(subject.build(work_item, only_path: true)).to eq("/#{work_item.project.full_path}/-/work_items/#{work_item.id}")
- end
- end
- end
end
end
diff --git a/spec/lib/gitlab/usage/metric_definition_spec.rb b/spec/lib/gitlab/usage/metric_definition_spec.rb
index 4b835d11975..c336a4850d2 100644
--- a/spec/lib/gitlab/usage/metric_definition_spec.rb
+++ b/spec/lib/gitlab/usage/metric_definition_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
{
description: 'GitLab instance unique identifier',
value_type: 'string',
- product_category: 'collection',
product_stage: 'growth',
product_section: 'devops',
status: 'active',
@@ -263,7 +262,6 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
{
description: 'Test metric definition',
value_type: 'string',
- product_category: 'collection',
product_stage: 'growth',
product_section: 'devops',
status: 'active',
diff --git a/spec/lib/gitlab/usage/metric_spec.rb b/spec/lib/gitlab/usage/metric_spec.rb
index 8e0fce37e46..d0ea4e7aa16 100644
--- a/spec/lib/gitlab/usage/metric_spec.rb
+++ b/spec/lib/gitlab/usage/metric_spec.rb
@@ -13,7 +13,6 @@ RSpec.describe Gitlab::Usage::Metric do
product_section: "dev",
product_stage: "plan",
product_group: "plan",
- product_category: "issue_tracking",
value_type: "number",
status: "active",
time_frame: "all",
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb
index ce15d44b1e1..317929f77e6 100644
--- a/spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountBulkImportsEntitiesMetric do
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountBulkImportsEntitiesMetric, feature_category: :importers do
let_it_be(:user) { create(:user) }
let_it_be(:bulk_import_projects) do
create_list(:bulk_import_entity, 2, source_type: 'project_entity', created_at: 3.weeks.ago, status: 2)
@@ -163,4 +163,121 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountBulkImportsEntitie
options: { status: 2, source_type: 'project_entity' }
end
end
+
+ context 'with has_failures: true' do
+ before(:all) do
+ create_list(:bulk_import_entity, 3, :project_entity, :finished, created_at: 3.weeks.ago, has_failures: true)
+ create_list(:bulk_import_entity, 2, :project_entity, :finished, created_at: 2.months.ago, has_failures: true)
+ create_list(:bulk_import_entity, 3, :group_entity, :finished, created_at: 3.weeks.ago, has_failures: true)
+ create_list(:bulk_import_entity, 2, :group_entity, :finished, created_at: 2.months.ago, has_failures: true)
+ end
+
+ context 'with all time frame' do
+ context 'with project entity' do
+ let(:expected_value) { 5 }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\" " \
+ "WHERE \"bulk_import_entities\".\"source_type\" = 1 AND \"bulk_import_entities\".\"status\" = 2 " \
+ "AND \"bulk_import_entities\".\"has_failures\" = TRUE"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: 'all',
+ options: { status: 2, source_type: 'project_entity', has_failures: true }
+ end
+
+ context 'with group entity' do
+ let(:expected_value) { 5 }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\" " \
+ "WHERE \"bulk_import_entities\".\"source_type\" = 0 AND \"bulk_import_entities\".\"status\" = 2 " \
+ "AND \"bulk_import_entities\".\"has_failures\" = TRUE"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: 'all',
+ options: { status: 2, source_type: 'group_entity', has_failures: true }
+ end
+ end
+
+ context 'for 28d time frame' do
+ let(:expected_value) { 3 }
+ let(:start) { 30.days.ago.to_s(:db) }
+ let(:finish) { 2.days.ago.to_s(:db) }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\" " \
+ "WHERE \"bulk_import_entities\".\"created_at\" BETWEEN '#{start}' AND '#{finish}' " \
+ "AND \"bulk_import_entities\".\"source_type\" = 1 AND \"bulk_import_entities\".\"status\" = 2 " \
+ "AND \"bulk_import_entities\".\"has_failures\" = TRUE"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: '28d',
+ options: { status: 2, source_type: 'project_entity', has_failures: true }
+ end
+ end
+
+ context 'with has_failures: false' do
+ context 'with all time frame' do
+ context 'with project entity' do
+ let(:expected_value) { 3 }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\" " \
+ "WHERE \"bulk_import_entities\".\"source_type\" = 1 AND \"bulk_import_entities\".\"status\" = 2 " \
+ "AND \"bulk_import_entities\".\"has_failures\" = FALSE"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: 'all',
+ options: { status: 2, source_type: 'project_entity', has_failures: false }
+ end
+
+ context 'with group entity' do
+ let(:expected_value) { 2 }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\" " \
+ "WHERE \"bulk_import_entities\".\"source_type\" = 0 AND \"bulk_import_entities\".\"status\" = 2 " \
+ "AND \"bulk_import_entities\".\"has_failures\" = FALSE"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: 'all',
+ options: { status: 2, source_type: 'group_entity', has_failures: false }
+ end
+ end
+
+ context 'for 28d time frame' do
+ context 'with project entity' do
+ let(:expected_value) { 2 }
+ let(:start) { 30.days.ago.to_s(:db) }
+ let(:finish) { 2.days.ago.to_s(:db) }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\" " \
+ "WHERE \"bulk_import_entities\".\"created_at\" BETWEEN '#{start}' AND '#{finish}' " \
+ "AND \"bulk_import_entities\".\"source_type\" = 1 AND \"bulk_import_entities\".\"status\" = 2 " \
+ "AND \"bulk_import_entities\".\"has_failures\" = FALSE"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: '28d',
+ options: { status: 2, source_type: 'project_entity', has_failures: false }
+ end
+
+ context 'with group entity' do
+ let(:expected_value) { 2 }
+ let(:start) { 30.days.ago.to_s(:db) }
+ let(:finish) { 2.days.ago.to_s(:db) }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\" " \
+ "WHERE \"bulk_import_entities\".\"created_at\" BETWEEN '#{start}' AND '#{finish}' " \
+ "AND \"bulk_import_entities\".\"source_type\" = 0 AND \"bulk_import_entities\".\"status\" = 2 " \
+ "AND \"bulk_import_entities\".\"has_failures\" = FALSE"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: '28d',
+ options: { status: 2, source_type: 'group_entity', has_failures: false }
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_internal_pipelines_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_internal_pipelines_metric_spec.rb
index afd8fccd56c..77c49d448d7 100644
--- a/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_internal_pipelines_metric_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_internal_pipelines_metric_spec.rb
@@ -4,25 +4,23 @@ require 'spec_helper'
RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountCiInternalPipelinesMetric,
feature_category: :service_ping do
- let_it_be(:ci_pipeline_1) { create(:ci_pipeline, source: :external) }
- let_it_be(:ci_pipeline_2) { create(:ci_pipeline, source: :push) }
-
- let(:expected_value) { 1 }
- let(:expected_query) do
- 'SELECT COUNT("ci_pipelines"."id") FROM "ci_pipelines" ' \
- 'WHERE ("ci_pipelines"."source" IN (1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15) ' \
- 'OR "ci_pipelines"."source" IS NULL)'
- end
+ let_it_be(:ci_pipeline_1) { create(:ci_pipeline, source: :external, created_at: 3.days.ago) }
+ let_it_be(:ci_pipeline_2) { create(:ci_pipeline, source: :push, created_at: 3.days.ago) }
+ let_it_be(:old_pipeline) { create(:ci_pipeline, source: :push, created_at: 2.months.ago) }
+ let_it_be(:expected_value) { 2 }
it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
- context 'on Gitlab.com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
+ context 'for monthly counts' do
+ let_it_be(:expected_value) { 1 }
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: '28d', data_source: 'database' }
+ end
- let(:expected_value) { -1 }
+ context 'on SaaS', :saas do
+ let_it_be(:expected_value) { -1 }
it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+ it_behaves_like 'a correct instrumented metric value', { time_frame: '28d', data_source: 'database' }
end
end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_group_type_active_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_group_type_active_metric_spec.rb
new file mode 100644
index 00000000000..33605783671
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_group_type_active_metric_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountCiRunnersGroupTypeActiveMetric, feature_category: :runner do
+ let_it_be(:group) { create(:group) }
+ let(:expected_value) { 1 }
+
+ before do
+ create(:ci_runner,
+ :group,
+ groups: [group]
+ )
+ end
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_group_type_active_online_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_group_type_active_online_metric_spec.rb
new file mode 100644
index 00000000000..24d6ea6f1e9
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_group_type_active_online_metric_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountCiRunnersGroupTypeActiveOnlineMetric, feature_category: :runner do
+ let(:group) { create(:group) }
+ let(:expected_value) { 1 }
+
+ before do
+ create(:ci_runner,
+ :group,
+ groups: [group],
+ contacted_at: 1.second.ago
+ )
+ end
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_instance_type_active_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_instance_type_active_metric_spec.rb
new file mode 100644
index 00000000000..ae4829cceef
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_instance_type_active_metric_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountCiRunnersInstanceTypeActiveMetric, feature_category: :runner do
+ let(:expected_value) { 1 }
+
+ before do
+ create(:ci_runner)
+ end
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_instance_type_active_online_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_instance_type_active_online_metric_spec.rb
new file mode 100644
index 00000000000..b1b9a5a6cea
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_instance_type_active_online_metric_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountCiRunnersInstanceTypeActiveOnlineMetric, feature_category: :runner do
+ let(:expected_value) { 1 }
+
+ before do
+ create(:ci_runner, contacted_at: 1.second.ago)
+ end
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_metric_spec.rb
new file mode 100644
index 00000000000..6a3a8e6dd58
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_metric_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountCiRunnersMetric, feature_category: :runner do
+ let(:expected_value) { 1 }
+
+ before do
+ create(:ci_runner)
+ end
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_project_type_active_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_project_type_active_metric_spec.rb
new file mode 100644
index 00000000000..eeb699c1377
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_project_type_active_metric_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountCiRunnersProjectTypeActiveMetric, feature_category: :runner do
+ let(:project) { build(:project) }
+ let(:expected_value) { 1 }
+
+ before do
+ create(:ci_runner,
+ :project,
+ projects: [project]
+ )
+ end
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_project_type_active_online_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_project_type_active_online_metric_spec.rb
new file mode 100644
index 00000000000..c3ed752ae04
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_ci_runners_project_type_active_online_metric_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountCiRunnersProjectTypeActiveOnlineMetric, feature_category: :runner do
+ let(:project) { build(:project) }
+ let(:expected_value) { 1 }
+
+ before do
+ create(:ci_runner,
+ :project,
+ projects: [project],
+ contacted_at: 1.second.ago
+ )
+ end
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_issues_created_manually_from_alerts_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_issues_created_manually_from_alerts_metric_spec.rb
index 86f54c48666..65e514bf345 100644
--- a/spec/lib/gitlab/usage/metrics/instrumentations/count_issues_created_manually_from_alerts_metric_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_issues_created_manually_from_alerts_metric_spec.rb
@@ -16,11 +16,7 @@ feature_category: :service_ping do
it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
- context 'on Gitlab.com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
+ context 'on SaaS', :saas do
let(:expected_value) { -1 }
it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/database_mode_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/database_mode_spec.rb
new file mode 100644
index 00000000000..a6128b4df1f
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/database_mode_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::DatabaseMode, feature_category: :cell do
+ let(:expected_value) { Gitlab::Database.database_mode }
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'none' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/edition_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/edition_metric_spec.rb
new file mode 100644
index 00000000000..2e23b9f5a15
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/edition_metric_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::EditionMetric, feature_category: :service_ping do
+ before do
+ allow(Gitlab).to receive(:ee?).and_return(false)
+ end
+
+ let(:expected_value) { 'CE' }
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/gitlab_dedicated_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/gitlab_dedicated_metric_spec.rb
new file mode 100644
index 00000000000..a35022ec2c4
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/gitlab_dedicated_metric_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::GitlabDedicatedMetric, feature_category: :service_ping do
+ let(:expected_value) { Gitlab::CurrentSettings.gitlab_dedicated_instance }
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'none' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/incoming_email_encrypted_secrets_enabled_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/incoming_email_encrypted_secrets_enabled_metric_spec.rb
index ed35b2c8cde..b1b193c8d04 100644
--- a/spec/lib/gitlab/usage/metrics/instrumentations/incoming_email_encrypted_secrets_enabled_metric_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/incoming_email_encrypted_secrets_enabled_metric_spec.rb
@@ -5,6 +5,6 @@ require 'spec_helper'
RSpec.describe Gitlab::Usage::Metrics::Instrumentations::IncomingEmailEncryptedSecretsEnabledMetric,
feature_category: :service_ping do
it_behaves_like 'a correct instrumented metric value', { time_frame: 'none', data_source: 'ruby' } do
- let(:expected_value) { ::Gitlab::IncomingEmail.encrypted_secrets.active? }
+ let(:expected_value) { ::Gitlab::Email::IncomingEmail.encrypted_secrets.active? }
end
end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/index_inconsistencies_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/index_inconsistencies_metric_spec.rb
new file mode 100644
index 00000000000..92a576d1a9f
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/index_inconsistencies_metric_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::IndexInconsistenciesMetric, feature_category: :database do
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all' } do
+ let(:expected_value) do
+ [
+ { inconsistency_type: 'wrong_indexes', object_name: 'index_name_1' },
+ { inconsistency_type: 'missing_indexes', object_name: 'index_name_2' },
+ { inconsistency_type: 'extra_indexes', object_name: 'index_name_3' }
+ ]
+ end
+
+ let(:runner) { instance_double(Gitlab::Database::SchemaValidation::Runner, execute: inconsistencies) }
+ let(:inconsistency_class) { Gitlab::Database::SchemaValidation::Inconsistency }
+
+ let(:inconsistencies) do
+ [
+ instance_double(inconsistency_class, object_name: 'index_name_1', type: 'wrong_indexes'),
+ instance_double(inconsistency_class, object_name: 'index_name_2', type: 'missing_indexes'),
+ instance_double(inconsistency_class, object_name: 'index_name_3', type: 'extra_indexes')
+ ]
+ end
+
+ before do
+ allow(Gitlab::Database::SchemaValidation::Runner).to receive(:new).and_return(runner)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/installation_creation_date_approximation_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/installation_creation_date_approximation_metric_spec.rb
new file mode 100644
index 00000000000..11e1139e542
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/installation_creation_date_approximation_metric_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::InstallationCreationDateApproximationMetric,
+ feature_category: :service_ping do
+ let_it_be(:application_setting) { create(:application_setting) }
+
+ context 'with a root user' do
+ let_it_be(:root) { create(:user, id: 1, created_at: DateTime.current - 2.days) }
+ let_it_be(:expected_value) { root.reload.created_at } # reloading to get the timestamp from the database
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+ end
+
+ context 'without a root user' do
+ let_it_be(:another_user) { create(:user, id: 2, created_at: DateTime.current + 2.days) }
+ let_it_be(:expected_value) { application_setting.reload.created_at }
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+ end
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/installation_creation_date_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/installation_creation_date_metric_spec.rb
new file mode 100644
index 00000000000..ff6be56c13f
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/installation_creation_date_metric_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::InstallationCreationDateMetric,
+ feature_category: :service_ping do
+ context 'with a root user' do
+ let_it_be(:root) { create(:user, id: 1) }
+ let_it_be(:expected_value) { root.reload.created_at } # reloading to get the timestamp from the database
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+ end
+
+ context 'without a root user' do
+ let_it_be(:another_user) { create(:user, id: 2) }
+ let_it_be(:expected_value) { nil }
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+ end
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/installation_type_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/installation_type_metric_spec.rb
new file mode 100644
index 00000000000..7b59536e7d2
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/installation_type_metric_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::InstallationTypeMetric, feature_category: :service_ping do
+ context 'when Rails.env is production' do
+ before do
+ allow(Rails).to receive_message_chain(:env, :production?).and_return(true)
+ end
+
+ let(:expected_value) { Gitlab::INSTALLATION_TYPE }
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all' }
+ end
+
+ context 'with Rails.env is not production' do
+ let(:expected_value) { 'gitlab-development-kit' }
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all' }
+ end
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/service_desk_email_encrypted_secrets_enabled_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/service_desk_email_encrypted_secrets_enabled_metric_spec.rb
index d602eae3159..ea239e53d01 100644
--- a/spec/lib/gitlab/usage/metrics/instrumentations/service_desk_email_encrypted_secrets_enabled_metric_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/service_desk_email_encrypted_secrets_enabled_metric_spec.rb
@@ -5,6 +5,6 @@ require 'spec_helper'
RSpec.describe Gitlab::Usage::Metrics::Instrumentations::ServiceDeskEmailEncryptedSecretsEnabledMetric,
feature_category: :service_ping do
it_behaves_like 'a correct instrumented metric value', { time_frame: 'none', data_source: 'ruby' } do
- let(:expected_value) { ::Gitlab::ServiceDeskEmail.encrypted_secrets.active? }
+ let(:expected_value) { ::Gitlab::Email::ServiceDeskEmail.encrypted_secrets.active? }
end
end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/version_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/version_metric_spec.rb
new file mode 100644
index 00000000000..1f93a9632d0
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/version_metric_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::VersionMetric, feature_category: :service_ping do
+ let(:expected_value) { Gitlab::VERSION }
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' }
+end
diff --git a/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb b/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb
index 4f647c2700a..271e9595703 100644
--- a/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb
@@ -75,7 +75,7 @@ RSpec.describe Gitlab::Usage::Metrics::NamesSuggestions::Generator, feature_cate
end
end
- context 'for redis metrics' do
+ context 'for redis metrics', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/399421' do
it_behaves_like 'name suggestion' do
let(:key_path) { 'usage_activity_by_stage_monthly.create.merge_requests_users' }
let(:name_suggestion) { /<please fill metric name, suggested format is: {subject}_{verb}{ing|ed}_{object} eg: users_creating_epics or merge_requests_viewed_in_single_file_mode>/ }
diff --git a/spec/lib/gitlab/usage/service_ping_report_spec.rb b/spec/lib/gitlab/usage/service_ping_report_spec.rb
index 730c05b7dcb..f1ce48468fe 100644
--- a/spec/lib/gitlab/usage/service_ping_report_spec.rb
+++ b/spec/lib/gitlab/usage/service_ping_report_spec.rb
@@ -72,25 +72,34 @@ RSpec.describe Gitlab::Usage::ServicePingReport, :use_clean_rails_memory_store_c
context 'when using cached' do
let(:new_usage_data) { { 'uuid' => '1112' } }
+ let(:instrumented_payload) { { 'instrumented' => { 'metric' => 1 } } }
+ let(:full_payload) { usage_data.merge(instrumented_payload) }
+ let(:new_full_payload) { new_usage_data.merge(instrumented_payload) }
+
+ before do
+ allow_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload) do |instance|
+ allow(instance).to receive(:build).and_return(instrumented_payload)
+ end
+ end
context 'for cached: true' do
it 'caches the values' do
allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq(usage_data)
- expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(usage_data)
+ expect(described_class.for(output: :all_metrics_values)).to eq(full_payload)
+ expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(full_payload)
- expect(Rails.cache.fetch('usage_data')).to eq(usage_data)
+ expect(Rails.cache.fetch('usage_data')).to eq(full_payload)
end
it 'writes to cache and returns fresh data' do
allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq(usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data)
- expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(new_usage_data)
+ expect(described_class.for(output: :all_metrics_values)).to eq(full_payload)
+ expect(described_class.for(output: :all_metrics_values)).to eq(new_full_payload)
+ expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(new_full_payload)
- expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data)
+ expect(Rails.cache.fetch('usage_data')).to eq(new_full_payload)
end
end
@@ -98,10 +107,10 @@ RSpec.describe Gitlab::Usage::ServicePingReport, :use_clean_rails_memory_store_c
it 'returns fresh data' do
allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq(usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data)
+ expect(described_class.for(output: :all_metrics_values)).to eq(full_payload)
+ expect(described_class.for(output: :all_metrics_values)).to eq(new_full_payload)
- expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data)
+ expect(Rails.cache.fetch('usage_data')).to eq(new_full_payload)
end
end
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 63a1da490ed..8da86e4fae5 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
@@ -6,17 +6,23 @@ require 'spec_helper'
# NOTE: ONLY user related metrics to be added to the aggregates - otherwise add it to the exception list
RSpec.describe 'Code review events' do
it 'the aggregated metrics contain all the code review metrics' do
- code_review_events = Gitlab::UsageDataCounters::HLLRedisCounter.events_for_category("code_review")
+ mr_related_events = %w[i_code_review_create_mr i_code_review_mr_diffs i_code_review_mr_with_invalid_approvers i_code_review_mr_single_file_diffs i_code_review_total_suggestions_applied i_code_review_total_suggestions_added i_code_review_create_note_in_ipynb_diff i_code_review_create_note_in_ipynb_diff_mr i_code_review_create_note_in_ipynb_diff_commit i_code_review_merge_request_widget_license_compliance_warning]
+
+ all_code_review_events = Gitlab::Usage::MetricDefinition.all.flat_map do |definition|
+ next [] unless definition.attributes[:key_path].include?('.code_review.') &&
+ definition.attributes[:status] == 'active' &&
+ definition.attributes[:instrumentation_class] != 'AggregatedMetric'
+
+ definition.attributes.dig(:options, :events)
+ end.uniq.compact
+
code_review_aggregated_events = Gitlab::Usage::MetricDefinition.all.flat_map do |definition|
next [] unless code_review_aggregated_metric?(definition.attributes)
definition.attributes.dig(:options, :events)
end.uniq
- exceptions = %w[i_code_review_create_mr i_code_review_mr_diffs i_code_review_mr_with_invalid_approvers i_code_review_mr_single_file_diffs i_code_review_total_suggestions_applied i_code_review_total_suggestions_added i_code_review_create_note_in_ipynb_diff i_code_review_create_note_in_ipynb_diff_mr i_code_review_create_note_in_ipynb_diff_commit]
- code_review_aggregated_events += exceptions
-
- expect(code_review_events - code_review_aggregated_events).to be_empty
+ expect(all_code_review_events - (code_review_aggregated_events + mr_related_events)).to be_empty
end
def code_review_aggregated_metric?(attributes)
diff --git a/spec/lib/gitlab/usage_data_counters/container_registry_event_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/container_registry_event_counter_spec.rb
new file mode 100644
index 00000000000..052735db96b
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_counters/container_registry_event_counter_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UsageDataCounters::ContainerRegistryEventCounter, :clean_gitlab_redis_shared_state,
+ feature_category: :container_registry do
+ described_class::KNOWN_EVENTS.each do |event|
+ it_behaves_like 'a redis usage counter', 'ContainerRegistryEvent', event
+ it_behaves_like 'a redis usage counter with totals', :container_registry_events, "#{event}": 5
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
index f8a4603c1f8..19236cdbba0 100644
--- a/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
@@ -18,19 +18,35 @@ RSpec.describe Gitlab::UsageDataCounters::EditorUniqueCounter, :clean_gitlab_red
aggregate_failures do
expect(track_action(author: user1, project: project)).to be_truthy
expect(track_action(author: user2, project: project)).to be_truthy
- expect(track_action(author: user3, time: time - 3.days, project: project)).to be_truthy
+ expect(track_action(author: user3, time: time.end_of_week - 3.days, project: project)).to be_truthy
- expect(count_unique(date_from: time, date_to: Date.today)).to eq(2)
- expect(count_unique(date_from: time - 5.days, date_to: Date.tomorrow)).to eq(3)
+ expect(count_unique(date_from: time.beginning_of_week, date_to: 1.week.from_now)).to eq(3)
end
end
+ it 'track snowplow event' do
+ track_action(author: user1, project: project)
+
+ expect_snowplow_event(
+ category: described_class.name,
+ action: 'ide_edit',
+ label: 'usage_activity_by_stage_monthly.create.action_monthly_active_users_ide_edit',
+ namespace: project.namespace,
+ property: event_name,
+ project: project,
+ user: user1,
+ context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: event_name).to_h]
+ )
+ end
+
it 'does not track edit actions if author is not present' do
expect(track_action(author: nil, project: project)).to be_nil
end
end
context 'for web IDE edit actions' do
+ let(:event_name) { described_class::EDIT_BY_WEB_IDE }
+
it_behaves_like 'tracks and counts action' do
def track_action(params)
described_class.track_web_ide_edit_action(**params)
@@ -43,6 +59,8 @@ RSpec.describe Gitlab::UsageDataCounters::EditorUniqueCounter, :clean_gitlab_red
end
context 'for SFE edit actions' do
+ let(:event_name) { described_class::EDIT_BY_SFE }
+
it_behaves_like 'tracks and counts action' do
def track_action(params)
described_class.track_sfe_edit_action(**params)
@@ -55,6 +73,8 @@ RSpec.describe Gitlab::UsageDataCounters::EditorUniqueCounter, :clean_gitlab_red
end
context 'for snippet editor edit actions' do
+ let(:event_name) { described_class::EDIT_BY_SNIPPET_EDITOR }
+
it_behaves_like 'tracks and counts action' do
def track_action(params)
described_class.track_snippet_editor_edit_action(**params)
diff --git a/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb
index d6eb67e5c35..9cbac835a6f 100644
--- a/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb
@@ -7,9 +7,18 @@ RSpec.describe Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter, :clean
let(:user2) { build(:user, id: 2) }
let(:time) { Time.current }
let(:action) { described_class::GITLAB_CLI_API_REQUEST_ACTION }
- let(:user_agent) { { user_agent: 'GLab - GitLab CLI' } }
context 'when tracking a gitlab cli request' do
- it_behaves_like 'a request from an extension'
+ context 'with the old UserAgent' do
+ let(:user_agent) { { user_agent: 'GLab - GitLab CLI' } }
+
+ it_behaves_like 'a request from an extension'
+ end
+
+ context 'with the current UserAgent' do
+ let(:user_agent) { { user_agent: 'glab/v1.25.3-27-g7ec258fb (built 2023-02-16), darwin' } }
+
+ it_behaves_like 'a request from an extension'
+ end
end
end
diff --git a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
index f955fd265e5..2bf4c8bfca9 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
@@ -23,36 +23,69 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
described_class.clear_memoization(:known_events)
end
- describe '.categories' do
- it 'gets CE unique category names' do
- expect(described_class.categories).to include(
- 'analytics',
- 'ci_templates',
- 'ci_users',
- 'code_review',
- 'deploy_token_packages',
- 'ecosystem',
- 'environments',
- 'error_tracking',
- 'geo',
- 'ide_edit',
- 'importer',
- 'incident_management_alerts',
- 'incident_management',
- 'issues_edit',
- 'kubernetes_agent',
- 'manage',
- 'pipeline_authoring',
- 'quickactions',
- 'search',
- 'secure',
- 'snippets',
- 'source_code',
- 'terraform',
- 'testing',
- 'user_packages',
- 'work_items'
- )
+ describe '.track_event' do
+ # ToDo: remove during https://gitlab.com/groups/gitlab-org/-/epics/9542 cleanup
+ describe 'daily to weekly key migration precautions' do
+ let(:event_name) { 'example_event' }
+ let(:known_events) do
+ [
+ { name: event_name, aggregation: 'daily' }
+ ].map(&:with_indifferent_access)
+ end
+
+ let(:start_date) { (Date.current - 1.week).beginning_of_week }
+ let(:end_date) { Date.current }
+
+ let(:daily_event) { known_events.first }
+ let(:daily_key) { described_class.send(:redis_key, daily_event, start_date) }
+ let(:weekly_key) do
+ weekly_event = known_events.first.merge(aggregation: 'weekly')
+ described_class.send(:redis_key, weekly_event, start_date)
+ end
+
+ before do
+ allow(described_class).to receive(:known_events).and_return(known_events)
+ end
+
+ shared_examples 'writes daily events to daily and weekly keys' do
+ it :aggregate_failures do
+ expect(Gitlab::Redis::HLL).to receive(:add).with(expiry: 29.days, key: daily_key, value: 1).and_call_original
+ expect(Gitlab::Redis::HLL).to receive(:add).with(expiry: 6.weeks, key: weekly_key, value: 1).and_call_original
+
+ described_class.track_event(event_name, values: 1, time: start_date)
+ end
+ end
+
+ context 'when revert_daily_hll_events_to_weekly_aggregation FF is disabled' do
+ before do
+ stub_feature_flags(revert_daily_hll_events_to_weekly_aggregation: false)
+ end
+
+ it_behaves_like 'writes daily events to daily and weekly keys'
+
+ it 'aggregates weekly for daily keys', :aggregate_failures do
+ expect(Gitlab::Redis::HLL).to receive(:count).with(keys: [weekly_key]).and_call_original
+ expect(Gitlab::Redis::HLL).not_to receive(:count).with(keys: [daily_key]).and_call_original
+
+ described_class.unique_events(event_names: [event_name], start_date: start_date, end_date: end_date)
+ end
+ end
+
+ context 'when revert_daily_hll_events_to_weekly_aggregation FF is enabled' do
+ before do
+ stub_feature_flags(revert_daily_hll_events_to_weekly_aggregation: true)
+ end
+
+ # we want to write events no matter of the feature state
+ it_behaves_like 'writes daily events to daily and weekly keys'
+
+ it 'aggregates daily for daily keys', :aggregate_failures do
+ expect(Gitlab::Redis::HLL).to receive(:count).with(keys: [daily_key]).and_call_original
+ expect(Gitlab::Redis::HLL).not_to receive(:count).with(keys: [weekly_key]).and_call_original
+
+ described_class.unique_events(event_names: [event_name], start_date: start_date, end_date: start_date)
+ end
+ end
end
end
@@ -62,8 +95,6 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
let(:ce_event) do
{
"name" => "ce_event",
- "redis_slot" => "analytics",
- "category" => "analytics",
"aggregation" => "weekly"
}
end
@@ -84,8 +115,6 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
end
describe 'known_events' do
- let(:feature) { 'test_hll_redis_counter_ff_check' }
-
let(:weekly_event) { 'g_analytics_contribution' }
let(:daily_event) { 'g_analytics_search' }
let(:analytics_slot_event) { 'g_analytics_contribution' }
@@ -105,13 +134,13 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
let(:known_events) do
[
- { name: weekly_event, redis_slot: "analytics", category: analytics_category, aggregation: "weekly", feature_flag: feature },
- { name: daily_event, redis_slot: "analytics", category: analytics_category, aggregation: "daily" },
- { name: category_productivity_event, redis_slot: "analytics", category: productivity_category, aggregation: "weekly" },
- { name: compliance_slot_event, redis_slot: "compliance", category: compliance_category, aggregation: "weekly" },
- { name: no_slot, category: global_category, aggregation: "daily" },
- { name: different_aggregation, category: global_category, aggregation: "monthly" },
- { name: context_event, category: other_category, aggregation: 'weekly' }
+ { name: weekly_event, aggregation: "weekly" },
+ { name: daily_event, aggregation: "daily" },
+ { name: category_productivity_event, aggregation: "weekly" },
+ { name: compliance_slot_event, aggregation: "weekly" },
+ { name: no_slot, aggregation: "daily" },
+ { name: different_aggregation, aggregation: "monthly" },
+ { name: context_event, aggregation: 'weekly' }
].map(&:with_indifferent_access)
end
@@ -121,12 +150,6 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
allow(described_class).to receive(:known_events).and_return(known_events)
end
- describe '.events_for_category' do
- it 'gets the event names for given category' do
- expect(described_class.events_for_category(:analytics)).to contain_exactly(weekly_event, daily_event)
- end
- end
-
describe '.track_event' do
context 'with redis_hll_tracking' do
it 'tracks the event when feature enabled' do
@@ -146,32 +169,6 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
end
end
- context 'with event feature flag set' do
- it 'tracks the event when feature enabled' do
- stub_feature_flags(feature => true)
-
- expect(Gitlab::Redis::HLL).to receive(:add)
-
- described_class.track_event(weekly_event, values: 1)
- end
-
- it 'does not track the event with feature flag disabled' do
- stub_feature_flags(feature => false)
-
- expect(Gitlab::Redis::HLL).not_to receive(:add)
-
- described_class.track_event(weekly_event, values: 1)
- end
- end
-
- context 'with no event feature flag set' do
- it 'tracks the event' do
- expect(Gitlab::Redis::HLL).to receive(:add)
-
- described_class.track_event(daily_event, values: 1)
- end
- end
-
context 'when usage_ping is disabled' do
it 'does not track the event' do
allow(::ServicePing::ServicePingSettings).to receive(:enabled?).and_return(false)
@@ -195,7 +192,7 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
it 'tracks events with multiple values' do
values = [entity1, entity2]
- expect(Gitlab::Redis::HLL).to receive(:add).with(key: /g_{analytics}_contribution/, value: values,
+ expect(Gitlab::Redis::HLL).to receive(:add).with(key: /g_analytics_contribution/, value: values,
expiry: described_class::DEFAULT_WEEKLY_KEY_EXPIRY_LENGTH)
described_class.track_event(:g_analytics_contribution, values: values)
@@ -237,7 +234,7 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
described_class.track_event("g_compliance_dashboard", values: entity1)
Gitlab::Redis::SharedState.with do |redis|
- keys = redis.scan_each(match: "g_{compliance}_dashboard-*").to_a
+ keys = redis.scan_each(match: "{#{described_class::REDIS_SLOT}}_g_compliance_dashboard-*").to_a
expect(keys).not_to be_empty
keys.each do |key|
@@ -252,7 +249,7 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
described_class.track_event("no_slot", values: entity1)
Gitlab::Redis::SharedState.with do |redis|
- keys = redis.scan_each(match: "*-{no_slot}").to_a
+ keys = redis.scan_each(match: "*_no_slot").to_a
expect(keys).not_to be_empty
keys.each do |key|
@@ -276,7 +273,7 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
it 'tracks events with multiple values' do
values = [entity1, entity2]
- expect(Gitlab::Redis::HLL).to receive(:add).with(key: /g_{analytics}_contribution/,
+ expect(Gitlab::Redis::HLL).to receive(:add).with(key: /g_analytics_contribution/,
value: values,
expiry: described_class::DEFAULT_WEEKLY_KEY_EXPIRY_LENGTH)
@@ -340,18 +337,6 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
expect(described_class.unique_events(event_names: [weekly_event], start_date: Date.current, end_date: 4.weeks.ago)).to eq(-1)
end
- it 'raise error if metrics are not in the same slot' do
- expect do
- described_class.unique_events(event_names: [compliance_slot_event, analytics_slot_event], start_date: 4.weeks.ago, end_date: Date.current)
- end.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::SlotMismatch)
- end
-
- it 'raise error if metrics are not in the same category' do
- expect do
- described_class.unique_events(event_names: [category_analytics_event, category_productivity_event], start_date: 4.weeks.ago, end_date: Date.current)
- end.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::CategoryMismatch)
- end
-
it "raise error if metrics don't have same aggregation" do
expect do
described_class.unique_events(event_names: [daily_event, weekly_event], start_date: 4.weeks.ago, end_date: Date.current)
@@ -398,6 +383,10 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
let(:weekly_event) { 'i_search_total' }
let(:redis_event) { described_class.send(:event_for, weekly_event) }
+ let(:week_one) { "{#{described_class::REDIS_SLOT}}_i_search_total-2020-52" }
+ let(:week_two) { "{#{described_class::REDIS_SLOT}}_i_search_total-2020-53" }
+ let(:week_three) { "{#{described_class::REDIS_SLOT}}_i_search_total-2021-01" }
+ let(:week_four) { "{#{described_class::REDIS_SLOT}}_i_search_total-2021-02" }
subject(:weekly_redis_keys) { described_class.send(:weekly_redis_keys, events: [redis_event], start_date: DateTime.parse(start_date), end_date: DateTime.parse(end_date)) }
@@ -406,13 +395,13 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
'2020-12-21' | '2020-12-20' | []
'2020-12-21' | '2020-11-21' | []
'2021-01-01' | '2020-12-28' | []
- '2020-12-21' | '2020-12-28' | ['i_{search}_total-2020-52']
- '2020-12-21' | '2021-01-01' | ['i_{search}_total-2020-52']
- '2020-12-27' | '2021-01-01' | ['i_{search}_total-2020-52']
- '2020-12-26' | '2021-01-04' | ['i_{search}_total-2020-52', 'i_{search}_total-2020-53']
- '2020-12-26' | '2021-01-11' | ['i_{search}_total-2020-52', 'i_{search}_total-2020-53', 'i_{search}_total-2021-01']
- '2020-12-26' | '2021-01-17' | ['i_{search}_total-2020-52', 'i_{search}_total-2020-53', 'i_{search}_total-2021-01']
- '2020-12-26' | '2021-01-18' | ['i_{search}_total-2020-52', 'i_{search}_total-2020-53', 'i_{search}_total-2021-01', 'i_{search}_total-2021-02']
+ '2020-12-21' | '2020-12-28' | lazy { [week_one] }
+ '2020-12-21' | '2021-01-01' | lazy { [week_one] }
+ '2020-12-27' | '2021-01-01' | lazy { [week_one] }
+ '2020-12-26' | '2021-01-04' | lazy { [week_one, week_two] }
+ '2020-12-26' | '2021-01-11' | lazy { [week_one, week_two, week_three] }
+ '2020-12-26' | '2021-01-17' | lazy { [week_one, week_two, week_three] }
+ '2020-12-26' | '2021-01-18' | lazy { [week_one, week_two, week_three, week_four] }
end
with_them do
@@ -435,9 +424,9 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
let(:known_events) do
[
- { name: 'event_name_1', redis_slot: 'event', category: 'category1', aggregation: "weekly" },
- { name: 'event_name_2', redis_slot: 'event', category: 'category1', aggregation: "weekly" },
- { name: 'event_name_3', redis_slot: 'event', category: 'category1', aggregation: "weekly" }
+ { name: 'event_name_1', aggregation: "weekly" },
+ { name: 'event_name_2', aggregation: "weekly" },
+ { name: 'event_name_3', aggregation: "weekly" }
].map(&:with_indifferent_access)
end
@@ -476,11 +465,11 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
let(:time_range) { { start_date: 7.days.ago, end_date: DateTime.current } }
let(:known_events) do
[
- { name: 'event1_slot', redis_slot: "slot", category: 'category1', aggregation: "weekly" },
- { name: 'event2_slot', redis_slot: "slot", category: 'category2', aggregation: "weekly" },
- { name: 'event3_slot', redis_slot: "slot", category: 'category3', aggregation: "weekly" },
- { name: 'event5_slot', redis_slot: "slot", category: 'category4', aggregation: "daily" },
- { name: 'event4', category: 'category2', aggregation: "weekly" }
+ { name: 'event1_slot', aggregation: "weekly" },
+ { name: 'event2_slot', aggregation: "weekly" },
+ { name: 'event3_slot', aggregation: "weekly" },
+ { name: 'event5_slot', aggregation: "daily" },
+ { name: 'event4', aggregation: "weekly" }
].map(&:with_indifferent_access)
end
@@ -505,16 +494,11 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
described_class.track_event('event4', values: entity2, time: 2.days.ago)
end
- it 'calculates union of given events', :aggregate_failure do
+ it 'calculates union of given events', :aggregate_failures do
expect(described_class.calculate_events_union(**time_range.merge(event_names: %w[event4]))).to eq 2
expect(described_class.calculate_events_union(**time_range.merge(event_names: %w[event1_slot event2_slot event3_slot]))).to eq 3
end
- it 'validates and raise exception if events has mismatched slot or aggregation', :aggregate_failure do
- expect { described_class.calculate_events_union(**time_range.merge(event_names: %w[event1_slot event4])) }.to raise_error described_class::SlotMismatch
- expect { described_class.calculate_events_union(**time_range.merge(event_names: %w[event5_slot event3_slot])) }.to raise_error described_class::AggregationMismatch
- end
-
it 'returns 0 if there are no keys for given events' do
expect(Gitlab::Redis::HLL).not_to receive(:count)
expect(described_class.calculate_events_union(event_names: %w[event1_slot event2_slot event3_slot], start_date: Date.current, end_date: 4.weeks.ago)).to eq(-1)
diff --git a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
index 33e0d446fca..ba83d979cad 100644
--- a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
@@ -6,16 +6,17 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
let_it_be(:user1) { build(:user, id: 1) }
let_it_be(:user2) { build(:user, id: 2) }
let_it_be(:user3) { build(:user, id: 3) }
- let_it_be(:project) { build(:project) }
+ let_it_be(:project) { create(:project) }
let_it_be(:category) { Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_CATEGORY }
let_it_be(:event_action) { Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_ACTION }
let_it_be(:event_label) { Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_LABEL }
+ let(:original_params) { nil }
let(:event_property) { action }
let(:time) { Time.zone.now }
context 'for Issue title edit actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_TITLE_CHANGED }
def track_action(params)
@@ -25,7 +26,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue description edit actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_DESCRIPTION_CHANGED }
def track_action(params)
@@ -35,7 +36,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue assignee edit actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_ASSIGNEE_CHANGED }
def track_action(params)
@@ -45,7 +46,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue make confidential actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_MADE_CONFIDENTIAL }
def track_action(params)
@@ -55,7 +56,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue make visible actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_MADE_VISIBLE }
def track_action(params)
@@ -65,8 +66,9 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue created actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_CREATED }
+ let(:original_params) { { namespace: project.project_namespace.reload } }
def track_action(params)
described_class.track_issue_created_action(**params)
@@ -75,7 +77,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue closed actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_CLOSED }
def track_action(params)
@@ -85,7 +87,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue reopened actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_REOPENED }
def track_action(params)
@@ -95,7 +97,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue label changed actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_LABEL_CHANGED }
def track_action(params)
@@ -105,7 +107,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue label milestone actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_MILESTONE_CHANGED }
def track_action(params)
@@ -115,7 +117,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue cross-referenced actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_CROSS_REFERENCED }
def track_action(params)
@@ -125,7 +127,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue moved actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_MOVED }
def track_action(params)
@@ -135,7 +137,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue cloned actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let_it_be(:action) { described_class::ISSUE_CLONED }
def track_action(params)
@@ -145,7 +147,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue relate actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_RELATED }
def track_action(params)
@@ -155,7 +157,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue unrelate actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_UNRELATED }
def track_action(params)
@@ -165,7 +167,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue marked as duplicate actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_MARKED_AS_DUPLICATE }
def track_action(params)
@@ -175,7 +177,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue locked actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_LOCKED }
def track_action(params)
@@ -185,7 +187,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue unlocked actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_UNLOCKED }
def track_action(params)
@@ -195,7 +197,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue designs added actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_DESIGNS_ADDED }
def track_action(params)
@@ -205,7 +207,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue designs modified actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_DESIGNS_MODIFIED }
def track_action(params)
@@ -215,7 +217,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue designs removed actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_DESIGNS_REMOVED }
def track_action(params)
@@ -225,7 +227,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue due date changed actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_DUE_DATE_CHANGED }
def track_action(params)
@@ -235,7 +237,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue time estimate changed actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_TIME_ESTIMATE_CHANGED }
def track_action(params)
@@ -245,7 +247,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue time spent changed actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_TIME_SPENT_CHANGED }
def track_action(params)
@@ -255,7 +257,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue comment added actions', :snowplow do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_COMMENT_ADDED }
def track_action(params)
@@ -265,7 +267,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue comment edited actions', :snowplow do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_COMMENT_EDITED }
def track_action(params)
@@ -275,7 +277,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue comment removed actions', :snowplow do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_COMMENT_REMOVED }
def track_action(params)
@@ -285,7 +287,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue design comment removed actions' do
- it_behaves_like 'daily tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events with project' do
let(:action) { described_class::ISSUE_DESIGN_COMMENT_REMOVED }
def track_action(params)
@@ -294,23 +296,24 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
end
- it 'can return the count of actions per user deduplicated', :aggregate_failures do
- described_class.track_issue_title_changed_action(author: user1, project: project)
- described_class.track_issue_description_changed_action(author: user1, project: project)
- described_class.track_issue_assignee_changed_action(author: user1, project: project)
+ it 'can return the count of actions per user deduplicated' do
+ travel_to(Date.today.beginning_of_week) do # because events aggregated by week we need to emit events in the same week
+ described_class.track_issue_title_changed_action(author: user1, project: project)
+ described_class.track_issue_description_changed_action(author: user1, project: project)
+ described_class.track_issue_assignee_changed_action(author: user1, project: project)
+ end
- travel_to(2.days.ago) do
+ travel_to(Date.today.beginning_of_week + 2.days) do
described_class.track_issue_title_changed_action(author: user2, project: project)
described_class.track_issue_title_changed_action(author: user3, project: project)
described_class.track_issue_description_changed_action(author: user3, project: project)
described_class.track_issue_assignee_changed_action(author: user3, project: project)
end
- events = Gitlab::UsageDataCounters::HLLRedisCounter.events_for_category(described_class::ISSUE_CATEGORY)
- today_count = Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: events, start_date: time, end_date: time)
- week_count = Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: events, start_date: time - 5.days, end_date: 1.day.since(time))
+ events = [described_class::ISSUE_TITLE_CHANGED, described_class::ISSUE_DESCRIPTION_CHANGED, described_class::ISSUE_ASSIGNEE_CHANGED]
+ week_count = Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: events, start_date: time.beginning_of_week,
+ end_date: time + 1.week)
- expect(today_count).to eq(1)
expect(week_count).to eq(3)
end
end
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 42aa84c2c3e..e41da6d9ea2 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
@@ -69,7 +69,6 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
let(:project) { target_project }
let(:namespace) { project.namespace.reload }
let(:user) { project.creator }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:label) { 'redis_hll_counters.code_review.i_code_review_user_create_mr_monthly' }
let(:property) { described_class::MR_USER_CREATE_ACTION }
end
@@ -118,7 +117,6 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
let(:project) { target_project }
let(:namespace) { project.namespace.reload }
let(:user) { project.creator }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:label) { 'redis_hll_counters.code_review.i_code_review_user_approve_mr_monthly' }
let(:property) { described_class::MR_APPROVE_ACTION }
end
diff --git a/spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb b/spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb
deleted file mode 100644
index d1144dd0bc5..00000000000
--- a/spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb
+++ /dev/null
@@ -1,72 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::UsageDataCounters::TrackUniqueEvents, :clean_gitlab_redis_shared_state do
- subject(:track_unique_events) { described_class }
-
- let(:time) { Time.zone.now }
-
- def track_event(params)
- track_unique_events.track_event(**params)
- end
-
- def count_unique(params)
- track_unique_events.count_unique_events(**params)
- end
-
- context 'tracking an event' do
- context 'when tracking successfully' do
- context 'when the application setting is enabled' do
- context 'when the target and the action is valid' do
- before do
- stub_application_setting(usage_ping_enabled: true)
- end
-
- it 'tracks and counts the events as expected' do
- project = Event::TARGET_TYPES[:project]
- design = Event::TARGET_TYPES[:design]
- wiki = Event::TARGET_TYPES[:wiki]
-
- expect(track_event(event_action: :pushed, event_target: project, author_id: 1)).to be_truthy
- expect(track_event(event_action: :pushed, event_target: project, author_id: 1)).to be_truthy
- expect(track_event(event_action: :pushed, event_target: project, author_id: 2)).to be_truthy
- expect(track_event(event_action: :pushed, event_target: project, author_id: 3)).to be_truthy
- expect(track_event(event_action: :pushed, event_target: project, author_id: 4, time: time - 3.days)).to be_truthy
-
- expect(track_event(event_action: :destroyed, event_target: design, author_id: 3)).to be_truthy
- expect(track_event(event_action: :created, event_target: design, author_id: 4)).to be_truthy
- expect(track_event(event_action: :updated, event_target: design, author_id: 5)).to be_truthy
-
- expect(track_event(event_action: :destroyed, event_target: wiki, author_id: 5)).to be_truthy
- expect(track_event(event_action: :created, event_target: wiki, author_id: 3)).to be_truthy
- expect(track_event(event_action: :updated, event_target: wiki, author_id: 4)).to be_truthy
-
- expect(count_unique(event_action: described_class::PUSH_ACTION, date_from: time, date_to: Date.today)).to eq(3)
- expect(count_unique(event_action: described_class::PUSH_ACTION, date_from: time - 5.days, date_to: Date.tomorrow)).to eq(4)
- expect(count_unique(event_action: described_class::DESIGN_ACTION, date_from: time - 5.days, date_to: Date.today)).to eq(3)
- expect(count_unique(event_action: described_class::WIKI_ACTION, date_from: time - 5.days, date_to: Date.today)).to eq(3)
- expect(count_unique(event_action: described_class::PUSH_ACTION, date_from: time - 5.days, date_to: time - 2.days)).to eq(1)
- end
- end
- end
- end
-
- context 'when tracking unsuccessfully' do
- using RSpec::Parameterized::TableSyntax
-
- where(:target, :action) do
- Project | :invalid_action
- :invalid_target | :pushed
- Project | :created
- end
-
- with_them do
- it 'returns the expected values' do
- expect(track_event(event_action: action, event_target: target, author_id: 2)).to be_nil
- expect(count_unique(event_action: described_class::PUSH_ACTION, date_from: time, date_to: Date.today)).to eq(0)
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/usage_data_metrics_spec.rb b/spec/lib/gitlab/usage_data_metrics_spec.rb
index 6391b003096..1f52819fd9e 100644
--- a/spec/lib/gitlab/usage_data_metrics_spec.rb
+++ b/spec/lib/gitlab/usage_data_metrics_spec.rb
@@ -85,16 +85,4 @@ RSpec.describe Gitlab::UsageDataMetrics, :with_license, feature_category: :servi
end
end
end
-
- describe '.suggested_names' do
- subject { described_class.suggested_names }
-
- let(:suggested_names) do
- ::Gitlab::Usage::Metric.all.map(&:with_suggested_name).reduce({}, :deep_merge)
- end
-
- it 'includes Service Ping suggested names' do
- expect(subject).to match_array(suggested_names)
- end
- end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 5325ef5b5dd..4544cb2eb26 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -29,10 +29,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
.to include(:configure, :create, :manage, :monitor, :plan, :release, :verify)
expect(subject[:usage_activity_by_stage_monthly])
.to include(:configure, :create, :manage, :monitor, :plan, :release, :verify)
- expect(subject[:usage_activity_by_stage][:create])
- .not_to include(:merge_requests_users)
expect(subject[:usage_activity_by_stage_monthly][:create])
- .to include(:merge_requests_users)
+ .to include(:snippets)
end
it 'clears memoized values' do
@@ -265,7 +263,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
for_defined_days_back do
user = create(:user)
- %w(gitlab_project gitlab github bitbucket bitbucket_server gitea git manifest fogbugz phabricator).each do |type|
+ %w(gitlab_project github bitbucket bitbucket_server gitea git manifest fogbugz).each do |type|
create(:project, import_type: type, creator_id: user.id)
end
@@ -294,16 +292,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
git: 2,
gitea: 2,
github: 2,
- gitlab: 2,
gitlab_migration: 2,
gitlab_project: 2,
manifest: 2,
- total: 18
+ total: 16
},
issue_imports: {
jira: 2,
fogbugz: 2,
- phabricator: 2,
csv: 2
},
group_imports: {
@@ -323,16 +319,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
git: 1,
gitea: 1,
github: 1,
- gitlab: 1,
gitlab_migration: 1,
gitlab_project: 1,
manifest: 1,
- total: 9
+ total: 8
},
issue_imports: {
jira: 1,
fogbugz: 1,
- phabricator: 1,
csv: 1
},
group_imports: {
@@ -529,8 +523,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
expect(count_data[:projects_prometheus_active]).to eq(1)
expect(count_data[:projects_jenkins_active]).to eq(1)
expect(count_data[:projects_jira_active]).to eq(4)
- expect(count_data[:projects_jira_server_active]).to eq(2)
- expect(count_data[:projects_jira_cloud_active]).to eq(2)
expect(count_data[:jira_imports_projects_count]).to eq(2)
expect(count_data[:jira_imports_total_imported_count]).to eq(3)
expect(count_data[:jira_imports_total_imported_issues_count]).to eq(13)
@@ -614,14 +606,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
it 'raises an error' do
expect { subject }.to raise_error(ActiveRecord::StatementInvalid)
end
-
- context 'when metric calls find_in_batches' do
- let(:metric_method) { :find_in_batches }
-
- it 'raises an error for jira_usage' do
- expect { described_class.jira_usage }.to raise_error(ActiveRecord::StatementInvalid)
- end
- end
end
context 'with should_raise_for_dev? false' do
@@ -630,14 +614,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
it 'does not raise an error' do
expect { subject }.not_to raise_error
end
-
- context 'when metric calls find_in_batches' do
- let(:metric_method) { :find_in_batches }
-
- it 'does not raise an error for jira_usage' do
- expect { described_class.jira_usage }.not_to raise_error
- end
- end
end
end
@@ -663,8 +639,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
create(:alert_management_alert, project: project, created_at: n.days.ago)
end
- stub_application_setting(self_monitoring_project: project)
-
for_defined_days_back do
create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote')
end
@@ -687,37 +661,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
end
end
- describe '.runners_usage' do
- before do
- project = build(:project)
- create_list(:ci_runner, 2, :instance_type, :online)
- create(:ci_runner, :group, :online)
- create(:ci_runner, :group, :inactive)
- create_list(:ci_runner, 3, :project_type, :online, projects: [project])
- end
-
- subject { described_class.runners_usage }
-
- it 'gathers runner usage counts correctly' do
- expect(subject[:ci_runners]).to eq(7)
- expect(subject[:ci_runners_instance_type_active]).to eq(2)
- expect(subject[:ci_runners_group_type_active]).to eq(1)
- expect(subject[:ci_runners_project_type_active]).to eq(3)
-
- expect(subject[:ci_runners_instance_type_active_online]).to eq(2)
- expect(subject[:ci_runners_group_type_active_online]).to eq(1)
- expect(subject[:ci_runners_project_type_active_online]).to eq(3)
- end
- end
-
describe '.license_usage_data' do
subject { described_class.license_usage_data }
it 'gathers license data' do
- expect(subject[:uuid]).to eq(Gitlab::CurrentSettings.uuid)
- expect(subject[:version]).to eq(Gitlab::VERSION)
- expect(subject[:installation_type]).to eq('gitlab-development-kit')
- expect(subject[:active_user_count]).to eq(User.active.size)
expect(subject[:recorded_at]).to be_a(Time)
end
end
@@ -733,7 +680,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
expect(subject[:ldap_enabled]).to eq(Gitlab.config.ldap.enabled)
expect(subject[:gravatar_enabled]).to eq(Gitlab::CurrentSettings.gravatar_enabled?)
expect(subject[:omniauth_enabled]).to eq(Gitlab::Auth.omniauth_enabled?)
- expect(subject[:reply_by_email_enabled]).to eq(Gitlab::IncomingEmail.enabled?)
+ expect(subject[:reply_by_email_enabled]).to eq(Gitlab::Email::IncomingEmail.enabled?)
expect(subject[:container_registry_enabled]).to eq(Gitlab.config.registry.enabled)
expect(subject[:dependency_proxy_enabled]).to eq(Gitlab.config.dependency_proxy.enabled)
expect(subject[:gitlab_shared_runners_enabled]).to eq(Gitlab.config.gitlab_ci.shared_runners_enabled)
@@ -1039,28 +986,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
end
end
- describe '.merge_requests_users', :clean_gitlab_redis_shared_state do
- let(:time_period) { { created_at: 2.days.ago..time } }
- let(:time) { Time.current }
-
- before do
- counter = Gitlab::UsageDataCounters::TrackUniqueEvents
- merge_request = Event::TARGET_TYPES[:merge_request]
- design = Event::TARGET_TYPES[:design]
-
- counter.track_event(event_action: :commented, event_target: merge_request, author_id: 1, time: time)
- counter.track_event(event_action: :opened, event_target: merge_request, author_id: 1, time: time)
- counter.track_event(event_action: :merged, event_target: merge_request, author_id: 2, time: time)
- counter.track_event(event_action: :closed, event_target: merge_request, author_id: 3, time: time)
- counter.track_event(event_action: :opened, event_target: merge_request, author_id: 4, time: time - 3.days)
- counter.track_event(event_action: :created, event_target: design, author_id: 5, time: time)
- end
-
- it 'returns the distinct count of users using merge requests (via events table) within the specified time period' do
- expect(described_class.merge_requests_users(time_period)).to eq(3)
- end
- end
-
def for_defined_days_back(days: [31, 3])
days.each do |n|
travel_to(n.days.ago) do
@@ -1069,42 +994,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
end
end
- describe '#action_monthly_active_users', :clean_gitlab_redis_shared_state do
- let(:time_period) { { created_at: 2.days.ago..time } }
- let(:time) { Time.zone.now }
- let(:user1) { build(:user, id: 1) }
- let(:user2) { build(:user, id: 2) }
- let(:user3) { build(:user, id: 3) }
- let(:user4) { build(:user, id: 4) }
- let(:project) { build(:project) }
-
- before do
- counter = Gitlab::UsageDataCounters::EditorUniqueCounter
-
- counter.track_web_ide_edit_action(author: user1, project: project)
- counter.track_web_ide_edit_action(author: user1, project: project)
- counter.track_sfe_edit_action(author: user1, project: project)
- counter.track_snippet_editor_edit_action(author: user1, project: project)
- counter.track_snippet_editor_edit_action(author: user1, time: time - 3.days, project: project)
-
- counter.track_web_ide_edit_action(author: user2, project: project)
- counter.track_sfe_edit_action(author: user2, project: project)
-
- counter.track_web_ide_edit_action(author: user3, time: time - 3.days, project: project)
- counter.track_snippet_editor_edit_action(author: user3, project: project)
- end
-
- it 'returns the distinct count of user actions within the specified time period' do
- expect(described_class.action_monthly_active_users(time_period)).to eq(
- {
- action_monthly_active_users_web_ide_edit: 2,
- action_monthly_active_users_sfe_edit: 2,
- action_monthly_active_users_snippet_editor_edit: 2
- }
- )
- end
- end
-
describe '.service_desk_counts' do
subject { described_class.send(:service_desk_counts) }
@@ -1125,7 +1014,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures, feature_category: :servic
expect(result.duration).to be_an(Float)
end
- it 'records error and returns nil', :aggregated_errors do
+ it 'records error and returns nil', :aggregate_failures do
allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
result = described_class.with_metadata { raise }
diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb
index 1ae45d41f2d..7d09330d185 100644
--- a/spec/lib/gitlab/user_access_spec.rb
+++ b/spec/lib/gitlab/user_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::UserAccess do
+RSpec.describe Gitlab::UserAccess, feature_category: :system_access do
include ProjectForksHelper
let(:access) { described_class.new(user, container: project) }
@@ -85,10 +85,10 @@ RSpec.describe Gitlab::UserAccess do
let(:not_existing_branch) { create :protected_branch, :developers_can_merge, project: project }
context 'when admin mode is enabled', :enable_admin_mode do
- it 'returns true for admins' do
+ it 'returns false for admins' do
user.update!(admin: true)
- expect(access.can_push_to_branch?(branch.name)).to be_truthy
+ expect(access.can_push_to_branch?(branch.name)).to be_falsey
end
end
diff --git a/spec/lib/gitlab/utils/email_spec.rb b/spec/lib/gitlab/utils/email_spec.rb
index d7a881d8655..c81c2558f70 100644
--- a/spec/lib/gitlab/utils/email_spec.rb
+++ b/spec/lib/gitlab/utils/email_spec.rb
@@ -8,13 +8,20 @@ RSpec.describe Gitlab::Utils::Email, feature_category: :service_desk do
describe '.obfuscated_email' do
where(:input, :output) do
- 'alex@gitlab.com' | 'al**@g*****.com'
- 'alex@gl.co.uk' | 'al**@g****.uk'
- 'a@b.c' | 'a@b.c'
- 'q@example.com' | 'q@e******.com'
- 'q@w.' | 'q@w.'
- 'a@b' | 'a@b'
- 'no mail' | 'no mail'
+ 'alex@gitlab.com' | 'al**@g*****.com'
+ 'alex@gl.co.uk' | 'al**@g****.uk'
+ 'a@b.c' | 'aa@b.c'
+ 'qqwweerrttyy@example.com' | 'qq**********@e******.com'
+ 'getsuperfancysupport@paywhatyouwant.accounting' | 'ge******************@p*************.accounting'
+ 'q@example.com' | 'qq@e******.com'
+ 'q@w.' | 'qq@w.'
+ 'a@b' | 'aa@b'
+ 'trun"@"e@example.com' | 'tr******@e******.com'
+ '@' | '@'
+ 'n' | 'n'
+ 'no mail' | 'n******'
+ 'truncated@exa' | 'tr*******@exa'
+ '' | ''
end
with_them do
@@ -29,9 +36,14 @@ RSpec.describe Gitlab::Utils::Email, feature_category: :service_desk do
'qqwweerrttyy@example.com' | 'qq*****@e*****.c**'
'getsuperfancysupport@paywhatyouwant.accounting' | 'ge*****@p*****.a**'
'q@example.com' | 'qq*****@e*****.c**'
- 'q@w.' | 'q@w.'
- 'a@b' | 'a@b'
- 'no mail' | 'no mail'
+ 'q@w.' | 'qq*****@w*****.'
+ 'a@b' | 'aa*****@b**'
+ 'trun"@"e@example.com' | 'tr*****@e*****.c**'
+ '@' | '@'
+ 'no mail' | 'n**'
+ 'n' | 'n**'
+ 'truncated@exa' | 'tr*****@e**'
+ '' | ''
end
with_them do
diff --git a/spec/lib/gitlab/utils/error_message_spec.rb b/spec/lib/gitlab/utils/error_message_spec.rb
new file mode 100644
index 00000000000..a6de2520c5e
--- /dev/null
+++ b/spec/lib/gitlab/utils/error_message_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Utils::ErrorMessage, feature_category: :error_tracking do
+ let(:klass) do
+ Class.new do
+ include Gitlab::Utils::ErrorMessage
+ end
+ end
+
+ let(:message) { 'Something went wrong' }
+
+ subject(:object) { klass.new }
+
+ describe '#to_user_facing' do
+ it 'returns a user-facing error message with the UF prefix' do
+ expect(described_class.to_user_facing(message)).to eq("UF #{message}")
+ end
+ end
+
+ describe '#prefixed_error_message' do
+ it 'returns a message with the given prefix' do
+ prefix = 'ERROR'
+ expect(described_class.prefixed_error_message(message, prefix)).to eq("#{prefix} #{message}")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/utils/measuring_spec.rb b/spec/lib/gitlab/utils/measuring_spec.rb
index 5dad79b1c5f..4d2791f771f 100644
--- a/spec/lib/gitlab/utils/measuring_spec.rb
+++ b/spec/lib/gitlab/utils/measuring_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Gitlab::Utils::Measuring do
measurement.with_measuring { result }
end
- it 'measures and logs data', :aggregate_failure do
+ it 'measures and logs data', :aggregate_failures do
expect(measurement).to receive(:with_measure_time).and_call_original
expect(measurement).to receive(:with_count_queries).and_call_original
expect(measurement).to receive(:with_gc_stats).and_call_original
diff --git a/spec/lib/gitlab/utils/nokogiri_spec.rb b/spec/lib/gitlab/utils/nokogiri_spec.rb
index 7b4c63f9168..10f34ca706c 100644
--- a/spec/lib/gitlab/utils/nokogiri_spec.rb
+++ b/spec/lib/gitlab/utils/nokogiri_spec.rb
@@ -17,8 +17,8 @@ RSpec.describe Gitlab::Utils::Nokogiri do
'.js-render-metrics' | "descendant-or-self::*[contains(concat(' ',normalize-space(@class),' '),' js-render-metrics ')]"
'h1, h2, h3, h4, h5, h6' | "descendant-or-self::h1|descendant-or-self::h2|descendant-or-self::h3|descendant-or-self::h4|descendant-or-self::h5|descendant-or-self::h6"
'pre.code.language-math' | "descendant-or-self::pre[contains(concat(' ',normalize-space(@class),' '),' code ') and contains(concat(' ',normalize-space(@class),' '),' language-math ')]"
- 'pre > code[lang="plantuml"]' | "descendant-or-self::pre/code[@lang=\"plantuml\"]"
- 'pre[lang="mermaid"] > code' | "descendant-or-self::pre[@lang=\"mermaid\"]/code"
+ 'pre > code[data-canonical-lang="plantuml"]' | "descendant-or-self::pre/code[@data-canonical-lang=\"plantuml\"]"
+ 'pre[data-canonical-lang="mermaid"] > code' | "descendant-or-self::pre[@data-canonical-lang=\"mermaid\"]/code"
'pre.language-suggestion' | "descendant-or-self::pre[contains(concat(' ',normalize-space(@class),' '),' language-suggestion ')]"
'pre.language-suggestion > code' | "descendant-or-self::pre[contains(concat(' ',normalize-space(@class),' '),' language-suggestion ')]/code"
'a.gfm[data-reference-type="user"]' | "descendant-or-self::a[contains(concat(' ',normalize-space(@class),' '),' gfm ') and @data-reference-type=\"user\"]"
diff --git a/spec/lib/gitlab/utils/strong_memoize_spec.rb b/spec/lib/gitlab/utils/strong_memoize_spec.rb
index 71f2502b91c..ea8083e7d7f 100644
--- a/spec/lib/gitlab/utils/strong_memoize_spec.rb
+++ b/spec/lib/gitlab/utils/strong_memoize_spec.rb
@@ -3,12 +3,15 @@
require 'fast_spec_helper'
require 'rspec-benchmark'
require 'rspec-parameterized'
+require 'active_support/testing/time_helpers'
RSpec.configure do |config|
config.include RSpec::Benchmark::Matchers
end
-RSpec.describe Gitlab::Utils::StrongMemoize, feature_category: :not_owned do
+RSpec.describe Gitlab::Utils::StrongMemoize, feature_category: :shared do
+ include ActiveSupport::Testing::TimeHelpers
+
let(:klass) do
strong_memoize_class = described_class
@@ -30,6 +33,13 @@ RSpec.describe Gitlab::Utils::StrongMemoize, feature_category: :not_owned do
end
end
+ def method_name_with_expiration
+ strong_memoize_with_expiration(:method_name_with_expiration, 1) do
+ trace << value
+ value
+ end
+ end
+
def method_name_attr
trace << value
value
@@ -142,6 +152,43 @@ RSpec.describe Gitlab::Utils::StrongMemoize, feature_category: :not_owned do
end
end
+ describe '#strong_memoize_with_expiration' do
+ [nil, false, true, 'value', 0, [0]].each do |value|
+ context "with value #{value}" do
+ let(:value) { value }
+ let(:method_name) { :method_name_with_expiration }
+
+ it_behaves_like 'caching the value'
+
+ it 'raises exception for invalid type as key' do
+ expect { object.strong_memoize_with_expiration(10, 1) { 20 } }.to raise_error /Invalid type of '10'/
+ end
+
+ it 'raises exception for invalid characters in key' do
+ expect { object.strong_memoize_with_expiration(:enabled?, 1) { 20 } }
+ .to raise_error /is not allowed as an instance variable name/
+ end
+ end
+ end
+
+ context 'value memoization test' do
+ let(:value) { 'value' }
+
+ it 'caches the value for specified number of seconds' do
+ object.method_name_with_expiration
+ object.method_name_with_expiration
+
+ expect(object.trace.count).to eq(1)
+
+ travel_to(Time.current + 2.seconds) do
+ object.method_name_with_expiration
+
+ expect(object.trace.count).to eq(2)
+ end
+ end
+ end
+ end
+
describe '#strong_memoize_with' do
[nil, false, true, 'value', 0, [0]].each do |value|
context "with value #{value}" do
@@ -215,19 +262,21 @@ RSpec.describe Gitlab::Utils::StrongMemoize, feature_category: :not_owned do
describe '.strong_memoize_attr' do
[nil, false, true, 'value', 0, [0]].each do |value|
- let(:value) { value }
+ context "with value '#{value}'" do
+ let(:value) { value }
- context "memoized after method definition with value #{value}" do
- let(:method_name) { :method_name_attr }
+ context 'memoized after method definition' do
+ let(:method_name) { :method_name_attr }
- it_behaves_like 'caching the value'
+ it_behaves_like 'caching the value'
- it 'calls the existing .method_added' do
- expect(klass.method_added_list).to include(:method_name_attr)
- end
+ it 'calls the existing .method_added' do
+ expect(klass.method_added_list).to include(:method_name_attr)
+ end
- it 'retains method arity' do
- expect(klass.instance_method(method_name).arity).to eq(0)
+ it 'retains method arity' do
+ expect(klass.instance_method(method_name).arity).to eq(0)
+ end
end
end
end
diff --git a/spec/lib/gitlab/utils/uniquify_spec.rb b/spec/lib/gitlab/utils/uniquify_spec.rb
new file mode 100644
index 00000000000..df02fbe8c82
--- /dev/null
+++ b/spec/lib/gitlab/utils/uniquify_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Utils::Uniquify, feature_category: :shared do
+ subject(:uniquify) { described_class.new }
+
+ describe "#string" do
+ it 'returns the given string if it does not exist' do
+ result = uniquify.string('test_string') { |_s| false }
+
+ expect(result).to eq('test_string')
+ end
+
+ it 'returns the given string with a counter attached if the string exists' do
+ result = uniquify.string('test_string') { |s| s == 'test_string' }
+
+ expect(result).to eq('test_string1')
+ end
+
+ it 'increments the counter for each candidate string that also exists' do
+ result = uniquify.string('test_string') { |s| s == 'test_string' || s == 'test_string1' }
+
+ expect(result).to eq('test_string2')
+ end
+
+ it 'allows to pass an initial value for the counter' do
+ start_counting_from = 2
+ uniquify = described_class.new(start_counting_from)
+
+ result = uniquify.string('test_string') { |s| s == 'test_string' }
+
+ expect(result).to eq('test_string2')
+ end
+
+ it 'allows passing in a base function that defines the location of the counter' do
+ result = uniquify.string(->(counter) { "test_#{counter}_string" }) do |s|
+ s == 'test__string'
+ end
+
+ expect(result).to eq('test_1_string')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/utils/usage_data_spec.rb b/spec/lib/gitlab/utils/usage_data_spec.rb
index 2925ceef256..586ee04a835 100644
--- a/spec/lib/gitlab/utils/usage_data_spec.rb
+++ b/spec/lib/gitlab/utils/usage_data_spec.rb
@@ -487,12 +487,12 @@ RSpec.describe Gitlab::Utils::UsageData do
end
context 'when Redis HLL raises any error' do
- subject { described_class.redis_usage_data { raise Gitlab::UsageDataCounters::HLLRedisCounter::CategoryMismatch } }
+ subject { described_class.redis_usage_data { raise Gitlab::UsageDataCounters::HLLRedisCounter::EventError } }
let(:fallback) { 15 }
let(:failing_class) { nil }
- it_behaves_like 'failing hardening method', Gitlab::UsageDataCounters::HLLRedisCounter::CategoryMismatch
+ it_behaves_like 'failing hardening method', Gitlab::UsageDataCounters::HLLRedisCounter::EventError
end
it 'returns the evaluated block when given' do
diff --git a/spec/lib/gitlab/utils/username_and_email_generator_spec.rb b/spec/lib/gitlab/utils/username_and_email_generator_spec.rb
new file mode 100644
index 00000000000..45df8f08055
--- /dev/null
+++ b/spec/lib/gitlab/utils/username_and_email_generator_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Utils::UsernameAndEmailGenerator, feature_category: :system_access do
+ let(:username_prefix) { 'username_prefix' }
+ let(:email_domain) { 'example.com' }
+
+ subject { described_class.new(username_prefix: username_prefix, email_domain: email_domain) }
+
+ describe 'email domain' do
+ it 'defaults to `Gitlab.config.gitlab.host`' do
+ expect(described_class.new(username_prefix: username_prefix).email).to end_with("@#{Gitlab.config.gitlab.host}")
+ end
+
+ context 'when specified' do
+ it 'uses the specified email domain' do
+ expect(subject.email).to end_with("@#{email_domain}")
+ end
+ end
+ end
+
+ include_examples 'username and email pair is generated by Gitlab::Utils::UsernameAndEmailGenerator'
+end
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index 3c7542ea5f9..a1c2f7d667f 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -458,18 +458,42 @@ RSpec.describe Gitlab::Workhorse do
describe '.send_url' do
let(:url) { 'http://example.com' }
- subject { described_class.send_url(url) }
-
it 'sets the header correctly' do
- key, command, params = decode_workhorse_header(subject)
+ key, command, params = decode_workhorse_header(
+ described_class.send_url(url)
+ )
expect(key).to eq("Gitlab-Workhorse-Send-Data")
expect(command).to eq("send-url")
expect(params).to eq({
'URL' => url,
- 'AllowRedirects' => false
+ 'AllowRedirects' => false,
+ 'Body' => '',
+ 'Method' => 'GET'
}.deep_stringify_keys)
end
+
+ context 'when body, headers and method are specified' do
+ let(:body) { 'body' }
+ let(:headers) { { Authorization: ['Bearer token'] } }
+ let(:method) { 'POST' }
+
+ it 'sets the header correctly' do
+ key, command, params = decode_workhorse_header(
+ described_class.send_url(url, body: body, headers: headers, method: method)
+ )
+
+ expect(key).to eq("Gitlab-Workhorse-Send-Data")
+ expect(command).to eq("send-url")
+ expect(params).to eq({
+ 'URL' => url,
+ 'AllowRedirects' => false,
+ 'Body' => body,
+ 'Header' => headers,
+ 'Method' => method
+ }.deep_stringify_keys)
+ end
+ end
end
describe '.send_scaled_image' do
diff --git a/spec/lib/gitlab_settings/options_spec.rb b/spec/lib/gitlab_settings/options_spec.rb
new file mode 100644
index 00000000000..4b57e91c2e1
--- /dev/null
+++ b/spec/lib/gitlab_settings/options_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSettings::Options, :aggregate_failures, feature_category: :shared do
+ let(:config) { { foo: { bar: 'baz' } } }
+
+ subject(:options) { described_class.build(config) }
+
+ describe '.build' do
+ context 'when argument is a hash' do
+ it 'creates a new GitlabSettings::Options instance' do
+ options = described_class.build(config)
+
+ expect(options).to be_a described_class
+ expect(options.foo).to be_a described_class
+ expect(options.foo.bar).to eq 'baz'
+ end
+ end
+ end
+
+ describe '#[]' do
+ it 'accesses the configuration key as string' do
+ expect(options['foo']).to be_a described_class
+ expect(options['foo']['bar']).to eq 'baz'
+
+ expect(options['inexistent']).to be_nil
+ end
+
+ it 'accesses the configuration key as symbol' do
+ expect(options[:foo]).to be_a described_class
+ expect(options[:foo][:bar]).to eq 'baz'
+
+ expect(options[:inexistent]).to be_nil
+ end
+ end
+
+ describe '#[]=' do
+ it 'changes the configuration key as string' do
+ options['foo']['bar'] = 'anothervalue'
+
+ expect(options['foo']['bar']).to eq 'anothervalue'
+ end
+
+ it 'changes the configuration key as symbol' do
+ options[:foo][:bar] = 'anothervalue'
+
+ expect(options[:foo][:bar]).to eq 'anothervalue'
+ end
+
+ context 'when key does not exist' do
+ it 'creates a new configuration by string key' do
+ options['inexistent'] = 'value'
+
+ expect(options['inexistent']).to eq 'value'
+ end
+
+ it 'creates a new configuration by string key' do
+ options[:inexistent] = 'value'
+
+ expect(options[:inexistent]).to eq 'value'
+ end
+ end
+ end
+
+ describe '#key?' do
+ it 'checks if a string key exists' do
+ expect(options.key?('foo')).to be true
+ expect(options.key?('inexistent')).to be false
+ end
+
+ it 'checks if a symbol key exists' do
+ expect(options.key?(:foo)).to be true
+ expect(options.key?(:inexistent)).to be false
+ end
+ end
+
+ describe '#to_hash' do
+ it 'returns the hash representation of the config' do
+ expect(options.to_hash).to eq('foo' => { 'bar' => 'baz' })
+ end
+ end
+
+ describe '#merge' do
+ it 'merges a hash to the existing options' do
+ expect(options.merge(more: 'configs').to_hash).to eq(
+ 'foo' => { 'bar' => 'baz' },
+ 'more' => 'configs'
+ )
+ end
+
+ context 'when the merge hash replaces existing configs' do
+ it 'merges a hash to the existing options' do
+ expect(options.merge(foo: 'configs').to_hash).to eq('foo' => 'configs')
+ end
+ end
+ end
+
+ describe '#deep_merge' do
+ it 'merges a hash to the existing options' do
+ expect(options.deep_merge(foo: { more: 'configs' }).to_hash).to eq('foo' => {
+ 'bar' => 'baz',
+ 'more' => 'configs'
+ })
+ end
+
+ context 'when the merge hash replaces existing configs' do
+ it 'merges a hash to the existing options' do
+ expect(options.deep_merge(foo: { bar: 'configs' }).to_hash).to eq('foo' => {
+ 'bar' => 'configs'
+ })
+ end
+ end
+ end
+
+ describe '#is_a?' do
+ it 'returns false for anything different of Hash or GitlabSettings::Options' do
+ expect(options.is_a?(described_class)).to be true
+ expect(options.is_a?(Hash)).to be true
+ expect(options.is_a?(String)).to be false
+ end
+ end
+
+ describe '#method_missing' do
+ context 'when method is an option' do
+ it 'delegates methods to options keys' do
+ expect(options.foo.bar).to eq('baz')
+ end
+
+ it 'uses methods to change options values' do
+ expect { options.foo = 1 }
+ .to change { options.foo }
+ .to(1)
+ end
+ end
+
+ context 'when method is not an option' do
+ it 'delegates the method to the internal options hash' do
+ expect { options.foo.delete('bar') }
+ .to change { options.to_hash }
+ .to({ 'foo' => {} })
+ end
+ end
+
+ context 'when method is not an option and does not exist in hash' do
+ it 'raises GitlabSettings::MissingSetting' do
+ expect { options.anything }
+ .to raise_error(
+ ::GitlabSettings::MissingSetting,
+ "option 'anything' not defined"
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab_settings/settings_spec.rb b/spec/lib/gitlab_settings/settings_spec.rb
new file mode 100644
index 00000000000..161c26dbb9f
--- /dev/null
+++ b/spec/lib/gitlab_settings/settings_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSettings::Settings, :aggregate_failures, feature_category: :shared do
+ let(:config) do
+ {
+ section1: {
+ config1: {
+ value1: 1
+ }
+ }
+ }
+ end
+
+ let(:source) { Tempfile.new('config.yaml') }
+
+ before do
+ File.write(source, config.to_yaml)
+ end
+
+ subject(:settings) { described_class.new(source.path, 'section1') }
+
+ describe '#initialize' do
+ it 'requires a source' do
+ expect { described_class.new('', '') }
+ .to raise_error(ArgumentError, 'config source is required')
+ end
+
+ it 'requires a section' do
+ expect { described_class.new(source, '') }
+ .to raise_error(ArgumentError, 'config section is required')
+ end
+ end
+
+ describe '#reload!' do
+ it 'reloads the config' do
+ expect(settings.config1.value1).to eq(1)
+
+ File.write(source, { section1: { config1: { value1: 2 } } }.to_yaml)
+
+ # config doesn't change when source changes
+ expect(settings.config1.value1).to eq(1)
+
+ settings.reload!
+
+ # config changes after reload! if source changed
+ expect(settings.config1.value1).to eq(2)
+ end
+ end
+
+ it 'loads the given section config' do
+ expect(settings.config1.value1).to eq(1)
+ end
+
+ context 'on lazy loading' do
+ it 'does not raise exception on initialization if source does not exists' do
+ settings = nil
+
+ expect { settings = described_class.new('/tmp/any/inexisting/file.yml', 'section1') }
+ .not_to raise_error
+
+ expect { settings['any key'] }
+ .to raise_error(Errno::ENOENT)
+ end
+ end
+end
diff --git a/spec/lib/gitlab_spec.rb b/spec/lib/gitlab_spec.rb
index c44bb64a5c0..8ed0a2df586 100644
--- a/spec/lib/gitlab_spec.rb
+++ b/spec/lib/gitlab_spec.rb
@@ -132,6 +132,28 @@ RSpec.describe Gitlab do
end
end
+ describe '.com_except_jh?' do
+ subject { described_class.com_except_jh? }
+
+ before do
+ allow(described_class).to receive(:com?).and_return(com?)
+ allow(described_class).to receive(:jh?).and_return(jh?)
+ end
+
+ using RSpec::Parameterized::TableSyntax
+
+ where(:com?, :jh?, :expected) do
+ true | true | false
+ true | false | true
+ false | true | false
+ false | false | false
+ end
+
+ with_them do
+ it { is_expected.to eq(expected) }
+ end
+ end
+
describe '.com' do
subject { described_class.com { true } }
diff --git a/spec/lib/json_web_token/hmac_token_spec.rb b/spec/lib/json_web_token/hmac_token_spec.rb
index 016084eaf69..7c486b2fe1b 100644
--- a/spec/lib/json_web_token/hmac_token_spec.rb
+++ b/spec/lib/json_web_token/hmac_token_spec.rb
@@ -50,8 +50,8 @@ RSpec.describe JSONWebToken::HMACToken do
context 'that was generated using a different secret' do
let(:encoded_token) { described_class.new('some other secret').encoded }
- it "raises exception saying 'Signature verification raised" do
- expect { decoded_token }.to raise_error(JWT::VerificationError, 'Signature verification raised')
+ it "raises exception saying 'Signature verification failed" do
+ expect { decoded_token }.to raise_error(JWT::VerificationError, 'Signature verification failed')
end
end
diff --git a/spec/lib/object_storage/config_spec.rb b/spec/lib/object_storage/config_spec.rb
index 2a81142ea44..412fcb9b6b8 100644
--- a/spec/lib/object_storage/config_spec.rb
+++ b/spec/lib/object_storage/config_spec.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
require 'rspec-parameterized'
require 'fog/core'
-RSpec.describe ObjectStorage::Config do
+RSpec.describe ObjectStorage::Config, feature_category: :shared do
using RSpec::Parameterized::TableSyntax
let(:region) { 'us-east-1' }
@@ -130,6 +130,11 @@ RSpec.describe ObjectStorage::Config do
it { expect(subject.provider).to eq('AWS') }
it { expect(subject.aws?).to be true }
it { expect(subject.google?).to be false }
+ it { expect(subject.credentials).to eq(credentials) }
+
+ context 'with FIPS enabled', :fips_mode do
+ it { expect(subject.credentials).to eq(credentials.merge(disable_content_md5_validation: true)) }
+ end
end
context 'with Google credentials' do
diff --git a/spec/lib/object_storage/direct_upload_spec.rb b/spec/lib/object_storage/direct_upload_spec.rb
index 82eede96deb..4fcc0e3f306 100644
--- a/spec/lib/object_storage/direct_upload_spec.rb
+++ b/spec/lib/object_storage/direct_upload_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ObjectStorage::DirectUpload do
+RSpec.describe ObjectStorage::DirectUpload, feature_category: :shared do
let(:region) { 'us-east-1' }
let(:path_style) { false }
let(:use_iam_profile) { false }
diff --git a/spec/lib/object_storage/pending_direct_upload_spec.rb b/spec/lib/object_storage/pending_direct_upload_spec.rb
new file mode 100644
index 00000000000..af08b9c8188
--- /dev/null
+++ b/spec/lib/object_storage/pending_direct_upload_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ObjectStorage::PendingDirectUpload, :clean_gitlab_redis_shared_state, feature_category: :shared do
+ let(:location_identifier) { :artifacts }
+ let(:path) { 'some/path/123' }
+
+ describe '.prepare' do
+ it 'creates a redis entry for the given location identifier and path' do
+ freeze_time do
+ described_class.prepare(location_identifier, path)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ key = described_class.key(location_identifier, path)
+ expect(redis.hget('pending_direct_uploads', key)).to eq(Time.current.utc.to_i.to_s)
+ end
+ end
+ end
+ end
+
+ describe '.exists?' do
+ let(:path) { 'some/path/123' }
+
+ subject { described_class.exists?(given_identifier, given_path) }
+
+ before do
+ described_class.prepare(location_identifier, path)
+ end
+
+ context 'when there is a matching redis entry for the given path under the location identifier' do
+ let(:given_identifier) { location_identifier }
+ let(:given_path) { path }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when there is a matching redis entry for the given path under a different location identifier' do
+ let(:given_identifier) { :uploads }
+ let(:given_path) { path }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when there is no matching redis entry for the given path under the location identifier' do
+ let(:given_identifier) { location_identifier }
+ let(:given_path) { 'wrong/path/123' }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ describe '.complete' do
+ it 'deletes the redis entry for the given path' do
+ described_class.prepare(location_identifier, path)
+
+ expect(described_class.exists?(location_identifier, path)).to eq(true)
+
+ described_class.complete(location_identifier, path)
+
+ expect(described_class.exists?(location_identifier, path)).to eq(false)
+ end
+ end
+
+ describe '.key' do
+ subject { described_class.key(location_identifier, path) }
+
+ it { is_expected.to eq("#{location_identifier}:#{path}") }
+ end
+end
diff --git a/spec/lib/product_analytics/settings_spec.rb b/spec/lib/product_analytics/settings_spec.rb
new file mode 100644
index 00000000000..8e6ac3cf0ad
--- /dev/null
+++ b/spec/lib/product_analytics/settings_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ProductAnalytics::Settings, feature_category: :product_analytics do
+ let_it_be(:project) { create(:project) }
+
+ subject { described_class.for_project(project) }
+
+ describe 'config settings' do
+ context 'when configured' do
+ before do
+ mock_settings('test')
+ end
+
+ it 'will be configured' do
+ expect(subject.configured?).to be_truthy
+ end
+ end
+
+ context 'when not configured' do
+ before do
+ mock_settings('')
+ end
+
+ it 'will not be configured' do
+ expect(subject.configured?).to be_falsey
+ end
+ end
+
+ context 'when one configuration setting is missing' do
+ before do
+ missing_key = ProductAnalytics::Settings::CONFIG_KEYS.last
+ mock_settings('test', ProductAnalytics::Settings::CONFIG_KEYS - [missing_key])
+ allow(::Gitlab::CurrentSettings).to receive(missing_key).and_return('')
+ end
+
+ it 'will not be configured' do
+ expect(subject.configured?).to be_falsey
+ end
+ end
+
+ ProductAnalytics::Settings::CONFIG_KEYS.each do |key|
+ it "can read #{key}" do
+ expect(::Gitlab::CurrentSettings).to receive(key).and_return('test')
+
+ expect(subject.send(key)).to eq('test')
+ end
+
+ context 'with project' do
+ it "will override when provided a project #{key}" do
+ expect(::Gitlab::CurrentSettings).not_to receive(key)
+ expect(project.project_setting).to receive(key).and_return('test')
+
+ expect(subject.send(key)).to eq('test')
+ end
+
+ it "will will not override when provided a blank project #{key}" do
+ expect(::Gitlab::CurrentSettings).to receive(key).and_return('test')
+ expect(project.project_setting).to receive(key).and_return('')
+
+ expect(subject.send(key)).to eq('test')
+ end
+ end
+ end
+ end
+
+ describe '.enabled?' do
+ before do
+ allow(subject).to receive(:configured?).and_return(true)
+ end
+
+ context 'when enabled' do
+ before do
+ allow(::Gitlab::CurrentSettings).to receive(:product_analytics_enabled?).and_return(true)
+ end
+
+ it 'will be enabled' do
+ expect(subject.enabled?).to be_truthy
+ end
+ end
+
+ context 'when disabled' do
+ before do
+ allow(::Gitlab::CurrentSettings).to receive(:product_analytics_enabled?).and_return(false)
+ end
+
+ it 'will be enabled' do
+ expect(subject.enabled?).to be_falsey
+ end
+ end
+ end
+
+ private
+
+ def mock_settings(setting, keys = ProductAnalytics::Settings::CONFIG_KEYS)
+ keys.each do |key|
+ allow(::Gitlab::CurrentSettings).to receive(key).and_return(setting)
+ end
+ end
+end
diff --git a/spec/lib/product_analytics/tracker_spec.rb b/spec/lib/product_analytics/tracker_spec.rb
deleted file mode 100644
index 52470c9c039..00000000000
--- a/spec/lib/product_analytics/tracker_spec.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ProductAnalytics::Tracker do
- it { expect(described_class::URL).to eq('http://localhost/-/sp.js') }
- it { expect(described_class::COLLECTOR_URL).to eq('localhost/-/collector') }
-end
diff --git a/spec/lib/security/weak_passwords_spec.rb b/spec/lib/security/weak_passwords_spec.rb
index afa9448e746..14bab5ee6ec 100644
--- a/spec/lib/security/weak_passwords_spec.rb
+++ b/spec/lib/security/weak_passwords_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Security::WeakPasswords, feature_category: :authentication_and_authorization do
+RSpec.describe Security::WeakPasswords, feature_category: :system_access do
describe "#weak_for_user?" do
using RSpec::Parameterized::TableSyntax
diff --git a/spec/lib/service_ping/build_payload_spec.rb b/spec/lib/service_ping/build_payload_spec.rb
index 6c37168f5a0..6699310681a 100644
--- a/spec/lib/service_ping/build_payload_spec.rb
+++ b/spec/lib/service_ping/build_payload_spec.rb
@@ -6,13 +6,7 @@ RSpec.describe ServicePing::BuildPayload, feature_category: :service_ping do
describe '#execute', :without_license do
subject(:service_ping_payload) { described_class.new.execute }
- include_context 'stubbed service ping metrics definitions' do
- let(:subscription_metrics) do
- [
- metric_attributes('active_user_count', "subscription")
- ]
- end
- end
+ include_context 'stubbed service ping metrics definitions'
it_behaves_like 'complete service ping payload'
end
diff --git a/spec/lib/sidebars/admin/menus/abuse_reports_menu_spec.rb b/spec/lib/sidebars/admin/menus/abuse_reports_menu_spec.rb
new file mode 100644
index 00000000000..5926852ff57
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/abuse_reports_menu_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::AbuseReportsMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/abuse_reports',
+ title: _('Abuse Reports'),
+ icon: 'slight-frown'
+
+ it_behaves_like 'Admin menu without sub menus', active_routes: { controller: :abuse_reports }
+
+ describe '#pill_count' do
+ let_it_be(:user) { create(:user, :admin) }
+
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it 'returns zero when there are no abuse reports' do
+ expect(subject.pill_count).to eq 0
+ end
+
+ it 'memoizes the query' do
+ subject.pill_count
+
+ control = ActiveRecord::QueryRecorder.new do
+ subject.pill_count
+ end
+
+ expect(control.count).to eq 0
+ end
+
+ context 'when there are abuse reports' do
+ it 'returns the number of abuse reports' do
+ create_list(:abuse_report, 2)
+
+ expect(subject.pill_count).to eq 2
+ end
+ end
+ end
+end
diff --git a/spec/lib/sidebars/admin/menus/admin_overview_menu_spec.rb b/spec/lib/sidebars/admin/menus/admin_overview_menu_spec.rb
new file mode 100644
index 00000000000..d076e73fdd1
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/admin_overview_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::AdminOverviewMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin',
+ title: s_('Admin|Overview'),
+ icon: 'overview'
+
+ it_behaves_like 'Admin menu with sub menus'
+end
diff --git a/spec/lib/sidebars/admin/menus/admin_settings_menu_spec.rb b/spec/lib/sidebars/admin/menus/admin_settings_menu_spec.rb
new file mode 100644
index 00000000000..4c9f603e99f
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/admin_settings_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::AdminSettingsMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/application_settings/general',
+ title: s_('Admin|Settings'),
+ icon: 'settings',
+ separated: true
+
+ it_behaves_like 'Admin menu with sub menus'
+end
diff --git a/spec/lib/sidebars/admin/menus/analytics_menu_spec.rb b/spec/lib/sidebars/admin/menus/analytics_menu_spec.rb
new file mode 100644
index 00000000000..b4aa6e9aeb6
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/analytics_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::AnalyticsMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/dev_ops_reports',
+ title: s_('Admin|Analytics'),
+ icon: 'chart'
+
+ it_behaves_like 'Admin menu with sub menus'
+end
diff --git a/spec/lib/sidebars/admin/menus/applications_menu_spec.rb b/spec/lib/sidebars/admin/menus/applications_menu_spec.rb
new file mode 100644
index 00000000000..0346fa4adfa
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/applications_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::ApplicationsMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/applications',
+ title: s_('Admin|Applications'),
+ icon: 'applications'
+
+ it_behaves_like 'Admin menu without sub menus', active_routes: { controller: :applications }
+end
diff --git a/spec/lib/sidebars/admin/menus/ci_cd_menu_spec.rb b/spec/lib/sidebars/admin/menus/ci_cd_menu_spec.rb
new file mode 100644
index 00000000000..b0d46abbee2
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/ci_cd_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::CiCdMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/runners',
+ title: s_('Admin|CI/CD'),
+ icon: 'rocket'
+
+ it_behaves_like 'Admin menu with sub menus'
+end
diff --git a/spec/lib/sidebars/admin/menus/deploy_keys_menu_spec.rb b/spec/lib/sidebars/admin/menus/deploy_keys_menu_spec.rb
new file mode 100644
index 00000000000..f0ee846fb42
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/deploy_keys_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::DeployKeysMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/deploy_keys',
+ title: s_('Admin|Deploy Keys'),
+ icon: 'key'
+
+ it_behaves_like 'Admin menu without sub menus', active_routes: { controller: :deploy_keys }
+end
diff --git a/spec/lib/sidebars/admin/menus/labels_menu_spec.rb b/spec/lib/sidebars/admin/menus/labels_menu_spec.rb
new file mode 100644
index 00000000000..63e4927ab0d
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/labels_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::LabelsMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/labels',
+ title: s_('Admin|Labels'),
+ icon: 'labels'
+
+ it_behaves_like 'Admin menu without sub menus', active_routes: { controller: :labels }
+end
diff --git a/spec/lib/sidebars/admin/menus/messages_menu_spec.rb b/spec/lib/sidebars/admin/menus/messages_menu_spec.rb
new file mode 100644
index 00000000000..14979b7e47a
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/messages_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::MessagesMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/broadcast_messages',
+ title: s_('Admin|Messages'),
+ icon: 'messages'
+
+ it_behaves_like 'Admin menu without sub menus', active_routes: { controller: :broadcast_messages }
+end
diff --git a/spec/lib/sidebars/admin/menus/monitoring_menu_spec.rb b/spec/lib/sidebars/admin/menus/monitoring_menu_spec.rb
new file mode 100644
index 00000000000..0483159da7a
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/monitoring_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::MonitoringMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/system_info',
+ title: s_('Admin|Monitoring'),
+ icon: 'monitor'
+
+ it_behaves_like 'Admin menu with sub menus'
+end
diff --git a/spec/lib/sidebars/admin/menus/system_hooks_menu_spec.rb b/spec/lib/sidebars/admin/menus/system_hooks_menu_spec.rb
new file mode 100644
index 00000000000..a2d0b851091
--- /dev/null
+++ b/spec/lib/sidebars/admin/menus/system_hooks_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Menus::SystemHooksMenu, feature_category: :navigation do
+ it_behaves_like 'Admin menu',
+ link: '/admin/hooks',
+ title: s_('Admin|System Hooks'),
+ icon: 'hook'
+
+ it_behaves_like 'Admin menu without sub menus', active_routes: { controller: :hooks }
+end
diff --git a/spec/lib/sidebars/admin/panel_spec.rb b/spec/lib/sidebars/admin/panel_spec.rb
new file mode 100644
index 00000000000..9c362f527f5
--- /dev/null
+++ b/spec/lib/sidebars/admin/panel_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Admin::Panel, feature_category: :navigation do
+ let_it_be(:user) { build(:admin) }
+
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+ let(:panel) { described_class.new(context) }
+
+ subject { described_class.new(context) }
+
+ describe '#aria_label' do
+ it 'returns the correct aria label' do
+ expect(panel.aria_label).to eq(_('Admin Area'))
+ end
+ end
+
+ describe '#super_sidebar_context_header' do
+ it 'returns a hash with the correct title and icon' do
+ expected_header = {
+ title: panel.aria_label,
+ icon: 'admin'
+ }
+
+ expect(panel.super_sidebar_context_header).to eq(expected_header)
+ end
+ end
+
+ it_behaves_like 'a panel with uniquely identifiable menu items'
+end
diff --git a/spec/lib/sidebars/concerns/super_sidebar_panel_spec.rb b/spec/lib/sidebars/concerns/super_sidebar_panel_spec.rb
new file mode 100644
index 00000000000..e0c05379a9e
--- /dev/null
+++ b/spec/lib/sidebars/concerns/super_sidebar_panel_spec.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Sidebars::Concerns::SuperSidebarPanel, feature_category: :navigation do
+ let(:menu_class_foo) { Class.new(Sidebars::Menu) }
+ let(:menu_foo) { menu_class_foo.new({}) }
+
+ let(:menu_class_bar) do
+ Class.new(Sidebars::Menu) do
+ def title
+ "Bar"
+ end
+
+ def pick_into_super_sidebar?
+ true
+ end
+ end
+ end
+
+ let(:menu_bar) { menu_class_bar.new({}) }
+
+ subject do
+ Class.new(Sidebars::Panel) do
+ include Sidebars::Concerns::SuperSidebarPanel
+ end.new({})
+ end
+
+ before do
+ allow(menu_foo).to receive(:render?).and_return(true)
+ allow(menu_bar).to receive(:render?).and_return(true)
+ end
+
+ describe '#pick_from_old_menus' do
+ it 'removes items with #pick_into_super_sidebar? from a list and adds them to the panel menus' do
+ old_menus = [menu_foo, menu_bar]
+
+ subject.pick_from_old_menus(old_menus)
+
+ expect(old_menus).to include(menu_foo)
+ expect(subject.renderable_menus).not_to include(menu_foo)
+
+ expect(old_menus).not_to include(menu_bar)
+ expect(subject.renderable_menus).to include(menu_bar)
+ end
+ end
+
+ describe '#transform_old_menus' do
+ let(:uncategorized_menu) { ::Sidebars::UncategorizedMenu.new({}) }
+
+ let(:menu_item) do
+ Sidebars::MenuItem.new(title: 'foo3', link: 'foo3', active_routes: { controller: 'barc' },
+ super_sidebar_parent: menu_class_foo)
+ end
+
+ let(:nil_menu_item) { Sidebars::NilMenuItem.new(item_id: :nil_item) }
+ let(:existing_item) do
+ Sidebars::MenuItem.new(
+ item_id: :exists,
+ title: 'Existing item',
+ link: 'foo2',
+ active_routes: { controller: 'foo2' }
+ )
+ end
+
+ let(:current_menus) { [menu_foo, uncategorized_menu] }
+
+ before do
+ allow(menu_bar).to receive(:serialize_as_menu_item_args).and_return(nil)
+ menu_foo.add_item(existing_item)
+ end
+
+ context 'for Menus with Menu Items' do
+ before do
+ menu_bar.add_item(menu_item)
+ menu_bar.add_item(nil_menu_item)
+ end
+
+ it 'adds Menu Items to defined super_sidebar_parent' do
+ subject.transform_old_menus(current_menus, menu_bar)
+
+ expect(menu_foo.renderable_items).to eq([existing_item, menu_item])
+ expect(uncategorized_menu.renderable_items).to eq([])
+ end
+
+ it 'replaces placeholder Menu Items in the defined super_sidebar_parent' do
+ menu_foo.insert_item_before(:exists, nil_menu_item)
+ allow(menu_item).to receive(:item_id).and_return(:nil_item)
+
+ subject.transform_old_menus(current_menus, menu_bar)
+
+ expect(menu_foo.renderable_items).to eq([menu_item, existing_item])
+ expect(uncategorized_menu.renderable_items).to eq([])
+ end
+
+ it 'considers Menu Items uncategorized if super_sidebar_parent is nil' do
+ allow(menu_item).to receive(:super_sidebar_parent).and_return(nil)
+ subject.transform_old_menus(current_menus, menu_bar)
+
+ expect(menu_foo.renderable_items).to eq([existing_item])
+ expect(uncategorized_menu.renderable_items).to eq([menu_item])
+ end
+
+ it 'considers Menu Items uncategorized if super_sidebar_parent cannot be found' do
+ allow(menu_item).to receive(:super_sidebar_parent).and_return(menu_class_bar)
+ subject.transform_old_menus(current_menus, menu_bar)
+
+ expect(menu_foo.renderable_items).to eq([existing_item])
+ expect(uncategorized_menu.renderable_items).to eq([menu_item])
+ end
+
+ it 'considers Menu Items deleted if super_sidebar_parent is Sidebars::NilMenuItem' do
+ allow(menu_item).to receive(:super_sidebar_parent).and_return(::Sidebars::NilMenuItem)
+ subject.transform_old_menus(current_menus, menu_bar)
+
+ expect(menu_foo.renderable_items).to eq([existing_item])
+ expect(uncategorized_menu.renderable_items).to eq([])
+ end
+ end
+
+ it 'converts "solo" top-level Menu entry to Menu Item' do
+ allow(Sidebars::MenuItem).to receive(:new).and_return(menu_item)
+ allow(menu_bar).to receive(:serialize_as_menu_item_args).and_return({})
+
+ subject.transform_old_menus(current_menus, menu_bar)
+
+ expect(menu_foo.renderable_items).to eq([existing_item, menu_item])
+ expect(uncategorized_menu.renderable_items).to eq([])
+ end
+
+ it 'drops "solo" top-level Menu entries, if they serialize to nil' do
+ allow(Sidebars::MenuItem).to receive(:new).and_return(menu_item)
+ allow(menu_bar).to receive(:serialize_as_menu_item_args).and_return(nil)
+
+ subject.transform_old_menus(current_menus, menu_bar)
+
+ expect(menu_foo.renderable_items).to eq([existing_item])
+ expect(uncategorized_menu.renderable_items).to eq([])
+ end
+ end
+end
diff --git a/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb b/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb
index 1b27db53b6f..4a0301e2f2d 100644
--- a/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Groups::Menus::GroupInformationMenu do
+RSpec.describe Sidebars::Groups::Menus::GroupInformationMenu, feature_category: :navigation do
let_it_be(:owner) { create(:user) }
let_it_be(:root_group) do
build(:group, :private).tap do |g|
@@ -14,6 +14,10 @@ RSpec.describe Sidebars::Groups::Menus::GroupInformationMenu do
let(:user) { owner }
let(:context) { Sidebars::Groups::Context.new(current_user: user, container: group) }
+ it_behaves_like 'not serializable as super_sidebar_menu_args' do
+ let(:menu) { described_class.new(context) }
+ end
+
describe '#title' do
subject { described_class.new(context).title }
diff --git a/spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb b/spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb
deleted file mode 100644
index a79e5182f45..00000000000
--- a/spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Sidebars::Groups::Menus::InviteTeamMembersMenu do
- let_it_be(:owner) { create(:user) }
- let_it_be(:guest) { create(:user) }
- let_it_be(:group) do
- build(:group).tap do |g|
- g.add_owner(owner)
- end
- end
-
- let(:context) { Sidebars::Groups::Context.new(current_user: owner, container: group) }
-
- subject(:invite_menu) { described_class.new(context) }
-
- context 'when the group is viewed by an owner of the group' do
- describe '#render?' do
- it 'renders the Invite team members link' do
- expect(invite_menu.render?).to eq(true)
- end
-
- context 'when the group already has at least 2 members' do
- before do
- group.add_guest(guest)
- end
-
- it 'does not render the link' do
- expect(invite_menu.render?).to eq(false)
- end
- end
- end
-
- describe '#title' do
- it 'displays the correct Invite team members text for the link in the side nav' do
- expect(invite_menu.title).to eq('Invite members')
- end
- end
- end
-
- context 'when the group is viewed by a guest user without admin permissions' do
- let(:context) { Sidebars::Groups::Context.new(current_user: guest, container: group) }
-
- before do
- group.add_guest(guest)
- end
-
- describe '#render?' do
- it 'does not render the link' do
- expect(subject.render?).to eq(false)
- end
- end
- end
-end
diff --git a/spec/lib/sidebars/groups/menus/issues_menu_spec.rb b/spec/lib/sidebars/groups/menus/issues_menu_spec.rb
index 3d55eb3af40..415011e0027 100644
--- a/spec/lib/sidebars/groups/menus/issues_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/issues_menu_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Groups::Menus::IssuesMenu do
+RSpec.describe Sidebars::Groups::Menus::IssuesMenu, feature_category: :navigation do
let_it_be(:owner) { create(:user) }
let_it_be(:group) do
build(:group, :private).tap do |g|
@@ -51,4 +51,16 @@ RSpec.describe Sidebars::Groups::Menus::IssuesMenu do
it_behaves_like 'pill_count formatted results' do
let(:count_service) { ::Groups::OpenIssuesCountService }
end
+
+ it_behaves_like 'serializable as super_sidebar_menu_args' do
+ let(:extra_attrs) do
+ {
+ item_id: :group_issue_list,
+ active_routes: { path: 'groups#issues' },
+ pill_count: menu.pill_count,
+ has_pill: menu.has_pill?,
+ super_sidebar_parent: Sidebars::Groups::SuperSidebarMenus::PlanMenu
+ }
+ end
+ end
end
diff --git a/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb b/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb
index 5bf8be9d6e5..8eb9a22e3e1 100644
--- a/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Groups::Menus::KubernetesMenu, :request_store do
+RSpec.describe Sidebars::Groups::Menus::KubernetesMenu, :request_store, feature_category: :navigation do
let_it_be(:owner) { create(:user) }
let_it_be(:group) do
build(:group, :private).tap do |g|
@@ -14,6 +14,15 @@ RSpec.describe Sidebars::Groups::Menus::KubernetesMenu, :request_store do
let(:context) { Sidebars::Groups::Context.new(current_user: user, container: group) }
let(:menu) { described_class.new(context) }
+ it_behaves_like 'serializable as super_sidebar_menu_args' do
+ let(:extra_attrs) do
+ {
+ super_sidebar_parent: Sidebars::Groups::SuperSidebarMenus::OperationsMenu,
+ item_id: :group_kubernetes_clusters
+ }
+ end
+ end
+
describe '#render?' do
context 'when user can read clusters' do
it 'returns true' do
diff --git a/spec/lib/sidebars/groups/menus/merge_requests_menu_spec.rb b/spec/lib/sidebars/groups/menus/merge_requests_menu_spec.rb
index 3aceff29d6d..a4421226eeb 100644
--- a/spec/lib/sidebars/groups/menus/merge_requests_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/merge_requests_menu_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Groups::Menus::MergeRequestsMenu do
+RSpec.describe Sidebars::Groups::Menus::MergeRequestsMenu, feature_category: :navigation do
let_it_be(:owner) { create(:user) }
let_it_be(:group) do
build(:group, :private).tap do |g|
@@ -33,4 +33,15 @@ RSpec.describe Sidebars::Groups::Menus::MergeRequestsMenu do
it_behaves_like 'pill_count formatted results' do
let(:count_service) { ::Groups::MergeRequestsCountService }
end
+
+ it_behaves_like 'serializable as super_sidebar_menu_args' do
+ let(:extra_attrs) do
+ {
+ item_id: :group_merge_request_list,
+ pill_count: menu.pill_count,
+ has_pill: menu.has_pill?,
+ super_sidebar_parent: Sidebars::Groups::SuperSidebarMenus::CodeMenu
+ }
+ end
+ end
end
diff --git a/spec/lib/sidebars/groups/menus/observability_menu_spec.rb b/spec/lib/sidebars/groups/menus/observability_menu_spec.rb
index 5b993cd6f28..20af8ea00be 100644
--- a/spec/lib/sidebars/groups/menus/observability_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/observability_menu_spec.rb
@@ -20,26 +20,74 @@ RSpec.describe Sidebars::Groups::Menus::ObservabilityMenu do
allow(menu).to receive(:can?).and_call_original
end
- context 'when observability is enabled' do
+ context 'when observability#explore is allowed' do
before do
- allow(Gitlab::Observability).to receive(:observability_enabled?).and_return(true)
+ allow(Gitlab::Observability).to receive(:allowed_for_action?).with(user, group, :explore).and_return(true)
end
it 'returns true' do
expect(menu.render?).to eq true
- expect(Gitlab::Observability).to have_received(:observability_enabled?).with(user, group)
+ expect(Gitlab::Observability).to have_received(:allowed_for_action?).with(user, group, :explore)
end
end
- context 'when observability is disabled' do
+ context 'when observability#explore is not allowed' do
before do
- allow(Gitlab::Observability).to receive(:observability_enabled?).and_return(false)
+ allow(Gitlab::Observability).to receive(:allowed_for_action?).with(user, group, :explore).and_return(false)
end
it 'returns false' do
expect(menu.render?).to eq false
- expect(Gitlab::Observability).to have_received(:observability_enabled?).with(user, group)
+ expect(Gitlab::Observability).to have_received(:allowed_for_action?).with(user, group, :explore)
end
end
end
+
+ describe "Menu items" do
+ before do
+ allow(Gitlab::Observability).to receive(:allowed_for_action?).and_return(false)
+ end
+
+ subject { find_menu(menu, item_id) }
+
+ shared_examples 'observability menu entry' do
+ context 'when action is allowed' do
+ before do
+ allow(Gitlab::Observability).to receive(:allowed_for_action?).with(user, group, item_id).and_return(true)
+ end
+
+ it 'the menu item is added to list of menu items' do
+ is_expected.not_to be_nil
+ end
+ end
+
+ context 'when action is not allowed' do
+ before do
+ allow(Gitlab::Observability).to receive(:allowed_for_action?).with(user, group, item_id).and_return(false)
+ end
+
+ it 'the menu item is added to list of menu items' do
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe 'Explore' do
+ it_behaves_like 'observability menu entry' do
+ let(:item_id) { :explore }
+ end
+ end
+
+ describe 'Datasources' do
+ it_behaves_like 'observability menu entry' do
+ let(:item_id) { :datasources }
+ end
+ end
+ end
+
+ private
+
+ def find_menu(menu, item)
+ menu.renderable_items.find { |i| i.item_id == item }
+ end
end
diff --git a/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb b/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb
index ce368ad5bd6..382ee07e458 100644
--- a/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Groups::Menus::PackagesRegistriesMenu do
+RSpec.describe Sidebars::Groups::Menus::PackagesRegistriesMenu, feature_category: :navigation do
let_it_be(:owner) { create(:user) }
let_it_be_with_reload(:group) do
build(:group, :private).tap do |g|
@@ -16,6 +16,8 @@ RSpec.describe Sidebars::Groups::Menus::PackagesRegistriesMenu do
let(:context) { Sidebars::Groups::Context.new(current_user: user, container: group) }
let(:menu) { described_class.new(context) }
+ it_behaves_like 'not serializable as super_sidebar_menu_args'
+
describe '#render?' do
context 'when menu has menu items to show' do
it 'returns true' do
diff --git a/spec/lib/sidebars/groups/menus/scope_menu_spec.rb b/spec/lib/sidebars/groups/menus/scope_menu_spec.rb
index 4b77a09117a..d3aceaf422b 100644
--- a/spec/lib/sidebars/groups/menus/scope_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/scope_menu_spec.rb
@@ -2,14 +2,26 @@
require 'spec_helper'
-RSpec.describe Sidebars::Groups::Menus::ScopeMenu do
+RSpec.describe Sidebars::Groups::Menus::ScopeMenu, feature_category: :navigation do
let(:group) { build(:group) }
let(:user) { group.owner }
let(:context) { Sidebars::Groups::Context.new(current_user: user, container: group) }
+ let(:menu) { described_class.new(context) }
describe '#extra_nav_link_html_options' do
- subject { described_class.new(context).extra_nav_link_html_options }
+ subject { menu.extra_nav_link_html_options }
specify { is_expected.to match(hash_including(class: 'context-header has-tooltip', title: context.group.name)) }
end
+
+ it_behaves_like 'serializable as super_sidebar_menu_args' do
+ let(:extra_attrs) do
+ {
+ sprite_icon: 'group',
+ super_sidebar_parent: ::Sidebars::StaticMenu,
+ title: _('Group overview'),
+ item_id: :group_overview
+ }
+ end
+ end
end
diff --git a/spec/lib/sidebars/groups/menus/settings_menu_spec.rb b/spec/lib/sidebars/groups/menus/settings_menu_spec.rb
index c5246fe93dd..bc30d7628af 100644
--- a/spec/lib/sidebars/groups/menus/settings_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/settings_menu_spec.rb
@@ -25,6 +25,12 @@ RSpec.describe Sidebars::Groups::Menus::SettingsMenu, :with_license do
end
end
+ describe '#separated?' do
+ it 'returns true' do
+ expect(menu.separated?).to be true
+ end
+ end
+
describe 'Menu items' do
subject { menu.renderable_items.find { |e| e.item_id == item_id } }
diff --git a/spec/lib/sidebars/groups/super_sidebar_menus/analyze_menu_spec.rb b/spec/lib/sidebars/groups/super_sidebar_menus/analyze_menu_spec.rb
new file mode 100644
index 00000000000..3d3d304a5a0
--- /dev/null
+++ b/spec/lib/sidebars/groups/super_sidebar_menus/analyze_menu_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Groups::SuperSidebarMenus::AnalyzeMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Analyze"))
+ expect(subject.sprite_icon).to eq("chart")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :cycle_analytics,
+ :ci_cd_analytics,
+ :contribution_analytics,
+ :devops_adoption,
+ :insights,
+ :issues_analytics,
+ :productivity_analytics,
+ :repository_analytics
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/groups/super_sidebar_menus/build_menu_spec.rb b/spec/lib/sidebars/groups/super_sidebar_menus/build_menu_spec.rb
new file mode 100644
index 00000000000..9437e11c1b6
--- /dev/null
+++ b/spec/lib/sidebars/groups/super_sidebar_menus/build_menu_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Groups::SuperSidebarMenus::BuildMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Build"))
+ expect(subject.sprite_icon).to eq("rocket")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :runners
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/groups/super_sidebar_menus/manage_menu_spec.rb b/spec/lib/sidebars/groups/super_sidebar_menus/manage_menu_spec.rb
new file mode 100644
index 00000000000..916d0942db2
--- /dev/null
+++ b/spec/lib/sidebars/groups/super_sidebar_menus/manage_menu_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Groups::SuperSidebarMenus::ManageMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Manage"))
+ expect(subject.sprite_icon).to eq("users")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :activity,
+ :members,
+ :labels
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/groups/super_sidebar_menus/monitor_menu_spec.rb b/spec/lib/sidebars/groups/super_sidebar_menus/monitor_menu_spec.rb
new file mode 100644
index 00000000000..759975856b8
--- /dev/null
+++ b/spec/lib/sidebars/groups/super_sidebar_menus/monitor_menu_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Groups::SuperSidebarMenus::MonitorMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Monitor"))
+ expect(subject.sprite_icon).to eq("monitor")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :explore,
+ :datasources
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/groups/super_sidebar_menus/operations_menu_spec.rb b/spec/lib/sidebars/groups/super_sidebar_menus/operations_menu_spec.rb
new file mode 100644
index 00000000000..e9c2701021c
--- /dev/null
+++ b/spec/lib/sidebars/groups/super_sidebar_menus/operations_menu_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Groups::SuperSidebarMenus::OperationsMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Operate"))
+ expect(subject.sprite_icon).to eq("deployments")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :dependency_proxy,
+ :packages_registry,
+ :container_registry,
+ :group_kubernetes_clusters
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/groups/super_sidebar_menus/plan_menu_spec.rb b/spec/lib/sidebars/groups/super_sidebar_menus/plan_menu_spec.rb
new file mode 100644
index 00000000000..1ac2cf87236
--- /dev/null
+++ b/spec/lib/sidebars/groups/super_sidebar_menus/plan_menu_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Groups::SuperSidebarMenus::PlanMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Plan"))
+ expect(subject.sprite_icon).to eq("planning")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :group_issue_list,
+ :group_epic_list,
+ :issue_boards,
+ :epic_boards,
+ :roadmap,
+ :milestones,
+ :iterations,
+ :group_wiki,
+ :crm_contacts,
+ :crm_organizations
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/groups/super_sidebar_menus/secure_menu_spec.rb b/spec/lib/sidebars/groups/super_sidebar_menus/secure_menu_spec.rb
new file mode 100644
index 00000000000..9eb81dda462
--- /dev/null
+++ b/spec/lib/sidebars/groups/super_sidebar_menus/secure_menu_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Groups::SuperSidebarMenus::SecureMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Secure"))
+ expect(subject.sprite_icon).to eq("shield")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :security_dashboard,
+ :vulnerability_report,
+ :audit_events,
+ :compliance,
+ :scan_policies
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/groups/super_sidebar_panel_spec.rb b/spec/lib/sidebars/groups/super_sidebar_panel_spec.rb
new file mode 100644
index 00000000000..5035da9c488
--- /dev/null
+++ b/spec/lib/sidebars/groups/super_sidebar_panel_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Groups::SuperSidebarPanel, feature_category: :navigation do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group).tap { |group| group.add_owner(user) } }
+
+ let(:context) do
+ Sidebars::Groups::Context.new(
+ current_user: user,
+ container: group,
+ is_super_sidebar: true,
+ # Turn features off that do not add/remove menu items
+ show_promotions: false,
+ show_discover_group_security: false
+ )
+ end
+
+ subject { described_class.new(context) }
+
+ it 'implements #super_sidebar_context_header' do
+ expect(subject.super_sidebar_context_header).to eq(
+ {
+ title: group.name,
+ avatar: group.avatar_url,
+ id: group.id
+ })
+ end
+
+ describe '#renderable_menus' do
+ let(:category_menu) do
+ [
+ Sidebars::StaticMenu,
+ Sidebars::Groups::SuperSidebarMenus::ManageMenu,
+ Sidebars::Groups::SuperSidebarMenus::PlanMenu,
+ Sidebars::Groups::SuperSidebarMenus::CodeMenu,
+ Sidebars::Groups::SuperSidebarMenus::BuildMenu,
+ Sidebars::Groups::SuperSidebarMenus::SecureMenu,
+ Sidebars::Groups::SuperSidebarMenus::OperationsMenu,
+ Sidebars::Groups::SuperSidebarMenus::MonitorMenu,
+ Sidebars::Groups::SuperSidebarMenus::AnalyzeMenu,
+ Sidebars::UncategorizedMenu,
+ Sidebars::Groups::Menus::SettingsMenu
+ ]
+ end
+
+ it "is exposed as a renderable menu" do
+ expect(subject.instance_variable_get(:@menus).map(&:class)).to eq(category_menu)
+ end
+ end
+
+ it_behaves_like 'a panel with uniquely identifiable menu items'
+ it_behaves_like 'a panel with all menu_items categorized'
+end
diff --git a/spec/lib/sidebars/menu_item_spec.rb b/spec/lib/sidebars/menu_item_spec.rb
index 15804f51934..84bc3430260 100644
--- a/spec/lib/sidebars/menu_item_spec.rb
+++ b/spec/lib/sidebars/menu_item_spec.rb
@@ -18,4 +18,14 @@ RSpec.describe Sidebars::MenuItem do
expect(menu_item.container_html_options).to eq html_options
end
end
+
+ describe "#serialize_for_super_sidebar" do
+ let(:html_options) { { class: 'custom-class' } }
+
+ subject { menu_item.serialize_for_super_sidebar }
+
+ it 'includes custom CSS classes' do
+ expect(subject[:link_classes]).to be('custom-class')
+ end
+ end
end
diff --git a/spec/lib/sidebars/menu_spec.rb b/spec/lib/sidebars/menu_spec.rb
index 53a889c2db8..4f77cb3aed4 100644
--- a/spec/lib/sidebars/menu_spec.rb
+++ b/spec/lib/sidebars/menu_spec.rb
@@ -2,9 +2,10 @@
require 'spec_helper'
-RSpec.describe Sidebars::Menu do
+RSpec.describe Sidebars::Menu, feature_category: :navigation do
let(:menu) { described_class.new(context) }
let(:context) { Sidebars::Context.new(current_user: nil, container: nil) }
+
let(:nil_menu_item) { Sidebars::NilMenuItem.new(item_id: :foo) }
describe '#all_active_routes' do
@@ -21,6 +22,94 @@ RSpec.describe Sidebars::Menu do
end
end
+ describe '#serialize_for_super_sidebar' do
+ before do
+ allow(menu).to receive(:title).and_return('Title')
+ allow(menu).to receive(:active_routes).and_return({ path: 'foo' })
+ end
+
+ it 'returns a tree-like structure of itself and all menu items' do
+ menu.add_item(Sidebars::MenuItem.new(
+ item_id: 'id1',
+ title: 'Is active',
+ link: 'foo2',
+ active_routes: { controller: 'fooc' }
+ ))
+ menu.add_item(Sidebars::MenuItem.new(
+ item_id: 'id2',
+ title: 'Not active',
+ link: 'foo3',
+ active_routes: { controller: 'barc' },
+ has_pill: true,
+ pill_count: 10
+ ))
+ menu.add_item(nil_menu_item)
+
+ allow(context).to receive(:route_is_active).and_return(->(x) { x[:controller] == 'fooc' })
+
+ expect(menu.serialize_for_super_sidebar).to eq(
+ {
+ title: "Title",
+ icon: nil,
+ link: "foo2",
+ is_active: true,
+ pill_count: nil,
+ separated: false,
+ items: [
+ {
+ id: 'id1',
+ title: "Is active",
+ icon: nil,
+ link: "foo2",
+ is_active: true,
+ pill_count: nil,
+ link_classes: nil
+ },
+ {
+ id: 'id2',
+ title: "Not active",
+ icon: nil,
+ link: "foo3",
+ is_active: false,
+ pill_count: 10,
+ link_classes: nil
+ }
+ ]
+ })
+ end
+
+ it 'returns pill data if defined' do
+ allow(menu).to receive(:has_pill?).and_return(true)
+ allow(menu).to receive(:pill_count).and_return('foo')
+ expect(menu.serialize_for_super_sidebar).to eq(
+ {
+ title: "Title",
+ icon: nil,
+ link: nil,
+ is_active: false,
+ pill_count: 'foo',
+ separated: false,
+ items: []
+ })
+ end
+ end
+
+ describe '#serialize_as_menu_item_args' do
+ it 'returns hash of title, link, active_routes, container_html_options' do
+ allow(menu).to receive(:title).and_return('Title')
+ allow(menu).to receive(:active_routes).and_return({ path: 'foo' })
+ allow(menu).to receive(:container_html_options).and_return({ class: 'foo' })
+ allow(menu).to receive(:link).and_return('/link')
+
+ expect(menu.serialize_as_menu_item_args).to eq({
+ title: 'Title',
+ link: '/link',
+ active_routes: { path: 'foo' },
+ container_html_options: { class: 'foo' }
+ })
+ end
+ end
+
describe '#render?' do
context 'when the menus has no items' do
it 'returns false' do
@@ -153,6 +242,47 @@ RSpec.describe Sidebars::Menu do
end
end
+ describe '#replace_placeholder' do
+ let(:item1) { Sidebars::NilMenuItem.new(item_id: :foo1) }
+ let(:item2) { Sidebars::MenuItem.new(item_id: :foo2, title: 'foo2', link: 'foo2', active_routes: {}) }
+ let(:item3) { Sidebars::NilMenuItem.new(item_id: :foo3) }
+
+ subject { menu.instance_variable_get(:@items) }
+
+ before do
+ menu.add_item(item1)
+ menu.add_item(item2)
+ menu.add_item(item3)
+ end
+
+ context 'when a NilMenuItem reference element exists' do
+ it 'replaces the reference element with the provided item' do
+ item = Sidebars::MenuItem.new(item_id: :foo1, title: 'target', active_routes: {}, link: 'target')
+ menu.replace_placeholder(item)
+
+ expect(subject).to eq [item, item2, item3]
+ end
+ end
+
+ context 'when a MenuItem reference element exists' do
+ it 'does not replace the reference element and adds to the end of the list' do
+ item = Sidebars::MenuItem.new(item_id: :foo2, title: 'target', active_routes: {}, link: 'target')
+ menu.replace_placeholder(item)
+
+ expect(subject).to eq [item1, item2, item3, item]
+ end
+ end
+
+ context 'when reference element does not exist' do
+ it 'adds the element to the end of the list' do
+ item = Sidebars::MenuItem.new(item_id: :new_element, title: 'target', active_routes: {}, link: 'target')
+ menu.replace_placeholder(item)
+
+ expect(subject).to eq [item1, item2, item3, item]
+ end
+ end
+ end
+
describe '#remove_element' do
let(:item1) { Sidebars::MenuItem.new(title: 'foo1', link: 'foo1', active_routes: {}, item_id: :foo1) }
let(:item2) { Sidebars::MenuItem.new(title: 'foo2', link: 'foo2', active_routes: {}, item_id: :foo2) }
diff --git a/spec/lib/sidebars/panel_spec.rb b/spec/lib/sidebars/panel_spec.rb
index b70a79361d0..2c1b9c73595 100644
--- a/spec/lib/sidebars/panel_spec.rb
+++ b/spec/lib/sidebars/panel_spec.rb
@@ -2,11 +2,12 @@
require 'spec_helper'
-RSpec.describe Sidebars::Panel do
+RSpec.describe Sidebars::Panel, feature_category: :navigation do
let(:context) { Sidebars::Context.new(current_user: nil, container: nil) }
let(:panel) { Sidebars::Panel.new(context) }
let(:menu1) { Sidebars::Menu.new(context) }
let(:menu2) { Sidebars::Menu.new(context) }
+ let(:menu3) { Sidebars::Menu.new(context) }
describe '#renderable_menus' do
it 'returns only renderable menus' do
@@ -20,6 +21,31 @@ RSpec.describe Sidebars::Panel do
end
end
+ describe '#super_sidebar_menu_items' do
+ it "serializes every renderable menu and returns a flattened result" do
+ panel.add_menu(menu1)
+ panel.add_menu(menu2)
+ panel.add_menu(menu3)
+
+ allow(menu1).to receive(:render?).and_return(true)
+ allow(menu1).to receive(:serialize_for_super_sidebar).and_return("foo")
+
+ allow(menu2).to receive(:render?).and_return(false)
+ allow(menu2).to receive(:serialize_for_super_sidebar).and_return("i-should-not-appear-in-results")
+
+ allow(menu3).to receive(:render?).and_return(true)
+ allow(menu3).to receive(:serialize_for_super_sidebar).and_return(%w[bar baz])
+
+ expect(panel.super_sidebar_menu_items).to eq(%w[foo bar baz])
+ end
+ end
+
+ describe '#super_sidebar_context_header' do
+ it 'raises `NotImplementedError`' do
+ expect { panel.super_sidebar_context_header }.to raise_error(NotImplementedError)
+ end
+ end
+
describe '#has_renderable_menus?' do
it 'returns false when no renderable menus' do
expect(panel.has_renderable_menus?).to be false
diff --git a/spec/lib/sidebars/projects/menus/ci_cd_menu_spec.rb b/spec/lib/sidebars/projects/menus/ci_cd_menu_spec.rb
index 2ceb9dcada9..6116fff792a 100644
--- a/spec/lib/sidebars/projects/menus/ci_cd_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/ci_cd_menu_spec.rb
@@ -50,20 +50,8 @@ RSpec.describe Sidebars::Projects::Menus::CiCdMenu do
describe 'Artifacts' do
let(:item_id) { :artifacts }
- context 'when feature flag :artifacts_management_page is disabled' do
- it 'does not include artifacts menu item' do
- stub_feature_flags(artifacts_management_page: false)
-
- is_expected.to be_nil
- end
- end
-
- context 'when feature flag :artifacts_management_page is enabled' do
- it 'includes artifacts menu item' do
- stub_feature_flags(artifacts_management_page: true)
-
- is_expected.not_to be_nil
- end
+ it 'includes artifacts menu item' do
+ is_expected.not_to be_nil
end
end
end
diff --git a/spec/lib/sidebars/projects/menus/confluence_menu_spec.rb b/spec/lib/sidebars/projects/menus/confluence_menu_spec.rb
index 836c6d26c6c..55c55b70a43 100644
--- a/spec/lib/sidebars/projects/menus/confluence_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/confluence_menu_spec.rb
@@ -41,4 +41,13 @@ RSpec.describe Sidebars::Projects::Menus::ConfluenceMenu do
end
end
end
+
+ describe 'serialize_as_menu_item_args' do
+ it 'renders as part of the Plan section' do
+ expect(subject.serialize_as_menu_item_args).to include({
+ item_id: :confluence,
+ super_sidebar_parent: ::Sidebars::Projects::SuperSidebarMenus::PlanMenu
+ })
+ end
+ end
end
diff --git a/spec/lib/sidebars/projects/menus/deployments_menu_spec.rb b/spec/lib/sidebars/projects/menus/deployments_menu_spec.rb
index ce971915174..a63acdb5dc2 100644
--- a/spec/lib/sidebars/projects/menus/deployments_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/deployments_menu_spec.rb
@@ -2,12 +2,16 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Menus::DeploymentsMenu do
+RSpec.describe Sidebars::Projects::Menus::DeploymentsMenu, feature_category: :navigation do
let_it_be(:project, reload: true) { create(:project, :repository) }
let(:user) { project.first_owner }
let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
+ it_behaves_like 'not serializable as super_sidebar_menu_args' do
+ let(:menu) { described_class.new(context) }
+ end
+
describe '#render?' do
subject { described_class.new(context) }
@@ -47,7 +51,7 @@ RSpec.describe Sidebars::Projects::Menus::DeploymentsMenu do
end
end
- describe 'Feature Flags' do
+ describe 'Feature flags' do
let(:item_id) { :feature_flags }
it_behaves_like 'access rights checks'
diff --git a/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb
index 116948b7cb0..346c681625a 100644
--- a/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb
@@ -2,11 +2,15 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do
+RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu, feature_category: :navigation do
let(:project) { build(:project) }
let(:user) { project.first_owner }
let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project, show_cluster_hint: false) }
+ it_behaves_like 'not serializable as super_sidebar_menu_args' do
+ let(:menu) { described_class.new(context) }
+ end
+
describe '#render?' do
subject { described_class.new(context) }
@@ -60,13 +64,13 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do
subject.renderable_items.delete(find_menu_item(:kubernetes))
end
- it 'menu link points to Terraform page' do
- expect(subject.link).to eq find_menu_item(:terraform).link
+ it 'menu link points to Terraform states page' do
+ expect(subject.link).to eq find_menu_item(:terraform_states).link
end
- context 'when Terraform menu is not visible' do
+ context 'when Terraform states menu is not visible' do
before do
- subject.renderable_items.delete(find_menu_item(:terraform))
+ subject.renderable_items.delete(find_menu_item(:terraform_states))
end
it 'menu link points to Google Cloud page' do
@@ -99,10 +103,26 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do
it_behaves_like 'access rights checks'
end
- describe 'Terraform' do
- let(:item_id) { :terraform }
+ describe 'Terraform states' do
+ let(:item_id) { :terraform_states }
it_behaves_like 'access rights checks'
+
+ context 'if terraform_state.enabled=true' do
+ before do
+ stub_config(terraform_state: { enabled: true })
+ end
+
+ it_behaves_like 'access rights checks'
+ end
+
+ context 'if terraform_state.enabled=false' do
+ before do
+ stub_config(terraform_state: { enabled: false })
+ end
+
+ it { is_expected.to be_nil }
+ end
end
describe 'Google Cloud' do
@@ -141,6 +161,56 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do
it_behaves_like 'access rights checks'
end
end
+
+ context 'when instance is not configured for Google OAuth2' do
+ before do
+ stub_feature_flags(incubation_5mp_google_cloud: true)
+ unconfigured_google_oauth2 = Struct.new(:app_id, :app_secret).new('', '')
+ allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for)
+ .with('google_oauth2')
+ .and_return(unconfigured_google_oauth2)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe 'AWS' do
+ let(:item_id) { :aws }
+
+ it_behaves_like 'access rights checks'
+
+ context 'when feature flag is turned off globally' do
+ before do
+ stub_feature_flags(cloudseed_aws: false)
+ end
+
+ it { is_expected.to be_nil }
+
+ context 'when feature flag is enabled for specific project' do
+ before do
+ stub_feature_flags(cloudseed_aws: project)
+ end
+
+ it_behaves_like 'access rights checks'
+ end
+
+ context 'when feature flag is enabled for specific group' do
+ before do
+ stub_feature_flags(cloudseed_aws: project.group)
+ end
+
+ it_behaves_like 'access rights checks'
+ end
+
+ context 'when feature flag is enabled for specific project' do
+ before do
+ stub_feature_flags(cloudseed_aws: user)
+ end
+
+ it_behaves_like 'access rights checks'
+ end
+ end
end
end
end
diff --git a/spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb b/spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb
deleted file mode 100644
index 9838aa8c3e3..00000000000
--- a/spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Sidebars::Projects::Menus::InviteTeamMembersMenu do
- let_it_be(:project) { create(:project) }
- let_it_be(:guest) { create(:user) }
-
- let(:context) { Sidebars::Projects::Context.new(current_user: owner, container: project) }
-
- subject(:invite_menu) { described_class.new(context) }
-
- context 'when the project is viewed by an owner of the group' do
- let(:owner) { project.first_owner }
-
- describe '#render?' do
- it 'renders the Invite team members link' do
- expect(invite_menu.render?).to eq(true)
- end
-
- context 'when the project already has at least 2 members' do
- before do
- project.add_guest(guest)
- end
-
- it 'does not render the link' do
- expect(invite_menu.render?).to eq(false)
- end
- end
- end
-
- describe '#title' do
- it 'displays the correct Invite team members text for the link in the side nav' do
- expect(invite_menu.title).to eq('Invite members')
- end
- end
- end
-
- context 'when the project is viewed by a guest user without admin permissions' do
- let(:context) { Sidebars::Projects::Context.new(current_user: guest, container: project) }
-
- before do
- project.add_guest(guest)
- end
-
- describe '#render?' do
- it 'does not render' do
- expect(invite_menu.render?).to eq(false)
- end
- end
- end
-end
diff --git a/spec/lib/sidebars/projects/menus/issues_menu_spec.rb b/spec/lib/sidebars/projects/menus/issues_menu_spec.rb
index 4c0016a77a1..544cbcb956d 100644
--- a/spec/lib/sidebars/projects/menus/issues_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/issues_menu_spec.rb
@@ -2,13 +2,25 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Menus::IssuesMenu do
+RSpec.describe Sidebars::Projects::Menus::IssuesMenu, feature_category: :navigation do
let(:project) { build(:project) }
let(:user) { project.first_owner }
let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
subject { described_class.new(context) }
+ it_behaves_like 'serializable as super_sidebar_menu_args' do
+ let(:menu) { subject }
+ let(:extra_attrs) do
+ {
+ item_id: :project_issue_list,
+ pill_count: menu.pill_count,
+ has_pill: menu.has_pill?,
+ super_sidebar_parent: Sidebars::Projects::SuperSidebarMenus::PlanMenu
+ }
+ end
+ end
+
describe '#render?' do
context 'when user can read issues' do
it 'returns true' do
@@ -43,7 +55,7 @@ RSpec.describe Sidebars::Projects::Menus::IssuesMenu do
describe '#pill_count' do
it 'returns zero when there are no open issues' do
- expect(subject.pill_count).to eq 0
+ expect(subject.pill_count).to eq '0'
end
it 'memoizes the query' do
@@ -61,7 +73,14 @@ RSpec.describe Sidebars::Projects::Menus::IssuesMenu do
create_list(:issue, 2, :opened, project: project)
create(:issue, :closed, project: project)
- expect(subject.pill_count).to eq 2
+ expect(subject.pill_count).to eq '2'
+ end
+ end
+
+ describe 'formatting' do
+ it 'returns truncated digits for count value over 1000' do
+ allow(project).to receive(:open_issues_count).and_return 1001
+ expect(subject.pill_count).to eq('1k')
end
end
end
diff --git a/spec/lib/sidebars/projects/menus/merge_requests_menu_spec.rb b/spec/lib/sidebars/projects/menus/merge_requests_menu_spec.rb
index 45c49500e46..08f35b6acd0 100644
--- a/spec/lib/sidebars/projects/menus/merge_requests_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/merge_requests_menu_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Menus::MergeRequestsMenu do
+RSpec.describe Sidebars::Projects::Menus::MergeRequestsMenu, feature_category: :navigation do
let_it_be(:project) { create(:project, :repository) }
let(:user) { project.first_owner }
@@ -10,6 +10,18 @@ RSpec.describe Sidebars::Projects::Menus::MergeRequestsMenu do
subject { described_class.new(context) }
+ it_behaves_like 'serializable as super_sidebar_menu_args' do
+ let(:menu) { subject }
+ let(:extra_attrs) do
+ {
+ item_id: :project_merge_request_list,
+ pill_count: menu.pill_count,
+ has_pill: menu.has_pill?,
+ super_sidebar_parent: Sidebars::Projects::SuperSidebarMenus::CodeMenu
+ }
+ end
+ end
+
describe '#render?' do
context 'when repository is not present' do
let(:project) { build(:project) }
@@ -38,7 +50,7 @@ RSpec.describe Sidebars::Projects::Menus::MergeRequestsMenu do
describe '#pill_count' do
it 'returns zero when there are no open merge requests' do
- expect(subject.pill_count).to eq 0
+ expect(subject.pill_count).to eq '0'
end
it 'memoizes the query' do
@@ -56,7 +68,16 @@ RSpec.describe Sidebars::Projects::Menus::MergeRequestsMenu do
create_list(:merge_request, 2, :unique_branches, source_project: project, author: user, state: :opened)
create(:merge_request, source_project: project, state: :merged)
- expect(subject.pill_count).to eq 2
+ expect(subject.pill_count).to eq '2'
+ end
+ end
+
+ describe 'formatting' do
+ it 'returns truncated digits for count value over 1000' do
+ create_list(:merge_request, 1001, :unique_branches, source_project: project, author: user, state: :opened)
+ create(:merge_request, source_project: project, state: :merged)
+
+ expect(subject.pill_count).to eq('1k')
end
end
end
diff --git a/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb b/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb
index a1e6ae13e68..aa1e67085cd 100644
--- a/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb
@@ -57,6 +57,10 @@ RSpec.describe Sidebars::Projects::Menus::MonitorMenu do
end
context 'Menu items' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
subject { described_class.new(context).renderable_items.index { |e| e.item_id == item_id } }
shared_examples 'access rights checks' do
diff --git a/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb b/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb
index b03269c424a..860206dc6af 100644
--- a/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu do
+RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu, feature_category: :navigation do
let_it_be(:project) { create(:project) }
let_it_be(:harbor_integration) { create(:harbor_integration, project: project) }
@@ -12,6 +12,10 @@ RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu do
subject { described_class.new(context) }
+ it_behaves_like 'not serializable as super_sidebar_menu_args' do
+ let(:menu) { subject }
+ end
+
describe '#render?' do
context 'when menu does not have any menu item to show' do
it 'returns false' do
@@ -35,7 +39,7 @@ RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu do
before do
stub_container_registry_config(enabled: registry_enabled)
stub_config(packages: { enabled: packages_enabled })
- stub_feature_flags(harbor_registry_integration: false)
+ stub_feature_flags(harbor_registry_integration: false, ml_experiment_tracking: false)
end
context 'when Packages Registry is visible' do
@@ -164,6 +168,7 @@ RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu do
stub_feature_flags(harbor_registry_integration: true)
is_expected.not_to be_nil
+ expect(subject.active_routes[:controller]).to eq('projects/harbor/repositories')
end
end
@@ -176,5 +181,25 @@ RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu do
end
end
end
+
+ describe 'Model experiments' do
+ let(:item_id) { :model_experiments }
+
+ context 'when :ml_experiment_tracking is enabled' do
+ it 'shows the menu item' do
+ stub_feature_flags(ml_experiment_tracking: true)
+
+ is_expected.not_to be_nil
+ end
+ end
+
+ context 'when :ml_experiment_tracking is disabled' do
+ it 'does not show the menu item' do
+ stub_feature_flags(ml_experiment_tracking: false)
+
+ is_expected.to be_nil
+ end
+ end
+ end
end
end
diff --git a/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb b/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb
index 7ff06ac229e..7547f152b27 100644
--- a/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb
@@ -2,12 +2,16 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Menus::ProjectInformationMenu do
+RSpec.describe Sidebars::Projects::Menus::ProjectInformationMenu, feature_category: :navigation do
let_it_be_with_reload(:project) { create(:project, :repository) }
let(:user) { project.first_owner }
let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
+ it_behaves_like 'not serializable as super_sidebar_menu_args' do
+ let(:menu) { described_class.new(context) }
+ end
+
describe '#container_html_options' do
subject { described_class.new(context).container_html_options }
diff --git a/spec/lib/sidebars/projects/menus/repository_menu_spec.rb b/spec/lib/sidebars/projects/menus/repository_menu_spec.rb
index 40ca2107698..1aa0ea30d0a 100644
--- a/spec/lib/sidebars/projects/menus/repository_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/repository_menu_spec.rb
@@ -6,7 +6,11 @@ RSpec.describe Sidebars::Projects::Menus::RepositoryMenu, feature_category: :sou
let_it_be(:project) { create(:project, :repository) }
let(:user) { project.first_owner }
- let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project, current_ref: 'master') }
+ let(:is_super_sidebar) { false }
+ let(:context) do
+ Sidebars::Projects::Context.new(current_user: user, container: project, current_ref: 'master',
+ is_super_sidebar: is_super_sidebar)
+ end
subject { described_class.new(context) }
@@ -36,9 +40,8 @@ RSpec.describe Sidebars::Projects::Menus::RepositoryMenu, feature_category: :sou
end
context 'for menu items' do
- shared_examples_for 'repository menu item link for' do |item_id|
+ shared_examples_for 'repository menu item link for' do
let(:ref) { 'master' }
- let(:item_id) { item_id }
subject { described_class.new(context).renderable_items.find { |e| e.item_id == item_id }.link }
using RSpec::Parameterized::TableSyntax
@@ -77,15 +80,39 @@ RSpec.describe Sidebars::Projects::Menus::RepositoryMenu, feature_category: :sou
end
end
+ shared_examples_for 'repository menu item with different super sidebar title' do |title, super_sidebar_title|
+ subject { described_class.new(context).renderable_items.find { |e| e.item_id == item_id } }
+
+ specify do
+ expect(subject.title).to eq(title)
+ end
+
+ context 'when inside the super sidebar' do
+ let(:is_super_sidebar) { true }
+
+ specify do
+ expect(subject.title).to eq(super_sidebar_title)
+ end
+ end
+ end
+
+ describe 'Files' do
+ let_it_be(:item_id) { :files }
+
+ it_behaves_like 'repository menu item with different super sidebar title',
+ _('Files'),
+ _('Repository')
+ end
+
describe 'Commits' do
let_it_be(:item_id) { :commits }
- it_behaves_like 'repository menu item link for', :commits do
+ it_behaves_like 'repository menu item link for' do
let(:route) { "/#{project.full_path}/-/commits/#{ref}" }
end
end
- describe 'Contributors' do
+ describe 'Contributor statistics' do
let_it_be(:item_id) { :contributors }
context 'when analytics is disabled' do
@@ -103,16 +130,22 @@ RSpec.describe Sidebars::Projects::Menus::RepositoryMenu, feature_category: :sou
project.project_feature.update!(analytics_access_level: ProjectFeature::ENABLED)
end
- it_behaves_like 'repository menu item link for', :contributors do
+ it_behaves_like 'repository menu item link for' do
let(:route) { "/#{project.full_path}/-/graphs/#{ref}" }
end
end
end
describe 'Network' do
- it_behaves_like 'repository menu item link for', :graphs do
+ let_it_be(:item_id) { :graphs }
+
+ it_behaves_like 'repository menu item link for' do
let(:route) { "/#{project.full_path}/-/network/#{ref}" }
end
+
+ it_behaves_like 'repository menu item with different super sidebar title',
+ _('Graph'),
+ _('Repository graph')
end
end
end
diff --git a/spec/lib/sidebars/projects/menus/scope_menu_spec.rb b/spec/lib/sidebars/projects/menus/scope_menu_spec.rb
index 4e87f3b8ead..45464278880 100644
--- a/spec/lib/sidebars/projects/menus/scope_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/scope_menu_spec.rb
@@ -2,11 +2,23 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Menus::ScopeMenu do
+RSpec.describe Sidebars::Projects::Menus::ScopeMenu, feature_category: :navigation do
let(:project) { build(:project) }
let(:user) { project.first_owner }
let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
+ it_behaves_like 'serializable as super_sidebar_menu_args' do
+ let(:menu) { described_class.new(context) }
+ let(:extra_attrs) do
+ {
+ title: _('Project overview'),
+ sprite_icon: 'project',
+ super_sidebar_parent: ::Sidebars::StaticMenu,
+ item_id: :project_overview
+ }
+ end
+ end
+
describe '#container_html_options' do
subject { described_class.new(context).container_html_options }
diff --git a/spec/lib/sidebars/projects/menus/security_compliance_menu_spec.rb b/spec/lib/sidebars/projects/menus/security_compliance_menu_spec.rb
index 41158bd58dc..697359b7941 100644
--- a/spec/lib/sidebars/projects/menus/security_compliance_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/security_compliance_menu_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe Sidebars::Projects::Menus::SecurityComplianceMenu do
end
context 'when user is authenticated' do
- context 'when the Security & Compliance is disabled' do
+ context 'when the Security and Compliance is disabled' do
before do
allow(Ability).to receive(:allowed?).with(user, :access_security_and_compliance, project).and_return(false)
end
@@ -28,7 +28,7 @@ RSpec.describe Sidebars::Projects::Menus::SecurityComplianceMenu do
it { is_expected.to be_falsey }
end
- context 'when the Security & Compliance is not disabled' do
+ context 'when the Security and Compliance is not disabled' do
it { is_expected.to be_truthy }
end
end
diff --git a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb
index c7aca0fb97e..4be99892631 100644
--- a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb
@@ -22,6 +22,12 @@ RSpec.describe Sidebars::Projects::Menus::SettingsMenu do
end
end
+ describe '#separated?' do
+ it 'returns true' do
+ expect(subject.separated?).to be true
+ end
+ end
+
describe 'Menu items' do
subject { described_class.new(context).renderable_items.find { |e| e.item_id == item_id } }
diff --git a/spec/lib/sidebars/projects/menus/snippets_menu_spec.rb b/spec/lib/sidebars/projects/menus/snippets_menu_spec.rb
index 04b8c128e3d..9d50eb6f817 100644
--- a/spec/lib/sidebars/projects/menus/snippets_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/snippets_menu_spec.rb
@@ -2,13 +2,23 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Menus::SnippetsMenu do
+RSpec.describe Sidebars::Projects::Menus::SnippetsMenu, feature_category: :navigation do
let(:project) { build(:project) }
let(:user) { project.first_owner }
let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
subject { described_class.new(context) }
+ it_behaves_like 'serializable as super_sidebar_menu_args' do
+ let(:menu) { subject }
+ let(:extra_attrs) do
+ {
+ super_sidebar_parent: ::Sidebars::Projects::SuperSidebarMenus::CodeMenu,
+ item_id: :project_snippets
+ }
+ end
+ end
+
describe '#render?' do
context 'when user cannot access snippets' do
let(:user) { nil }
diff --git a/spec/lib/sidebars/projects/menus/wiki_menu_spec.rb b/spec/lib/sidebars/projects/menus/wiki_menu_spec.rb
index 362da3e7b50..64050e3e488 100644
--- a/spec/lib/sidebars/projects/menus/wiki_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/wiki_menu_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Menus::WikiMenu do
+RSpec.describe Sidebars::Projects::Menus::WikiMenu, feature_category: :navigation do
let(:project) { build(:project) }
let(:user) { project.first_owner }
let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
@@ -28,4 +28,14 @@ RSpec.describe Sidebars::Projects::Menus::WikiMenu do
end
end
end
+
+ it_behaves_like 'serializable as super_sidebar_menu_args' do
+ let(:menu) { subject }
+ let(:extra_attrs) do
+ {
+ super_sidebar_parent: ::Sidebars::Projects::SuperSidebarMenus::PlanMenu,
+ item_id: :project_wiki
+ }
+ end
+ end
end
diff --git a/spec/lib/sidebars/projects/panel_spec.rb b/spec/lib/sidebars/projects/panel_spec.rb
index ff253eedd08..ec1df438cf1 100644
--- a/spec/lib/sidebars/projects/panel_spec.rb
+++ b/spec/lib/sidebars/projects/panel_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Projects::Panel do
+RSpec.describe Sidebars::Projects::Panel, feature_category: :navigation do
let_it_be(:project) { create(:project) }
let(:context) { Sidebars::Projects::Context.new(current_user: nil, container: project) }
diff --git a/spec/lib/sidebars/projects/super_sidebar_menus/analyze_menu_spec.rb b/spec/lib/sidebars/projects/super_sidebar_menus/analyze_menu_spec.rb
new file mode 100644
index 00000000000..d459d47c31a
--- /dev/null
+++ b/spec/lib/sidebars/projects/super_sidebar_menus/analyze_menu_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::SuperSidebarMenus::AnalyzeMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Analyze"))
+ expect(subject.sprite_icon).to eq("chart")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :dashboards_analytics,
+ :cycle_analytics,
+ :contributors,
+ :ci_cd_analytics,
+ :repository_analytics,
+ :code_review,
+ :merge_request_analytics,
+ :issues,
+ :insights,
+ :model_experiments
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/projects/super_sidebar_menus/build_menu_spec.rb b/spec/lib/sidebars/projects/super_sidebar_menus/build_menu_spec.rb
new file mode 100644
index 00000000000..3f2a40e1c7d
--- /dev/null
+++ b/spec/lib/sidebars/projects/super_sidebar_menus/build_menu_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::SuperSidebarMenus::BuildMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Build"))
+ expect(subject.sprite_icon).to eq("rocket")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :pipelines,
+ :jobs,
+ :pipelines_editor,
+ :releases,
+ :environments,
+ :pipeline_schedules,
+ :feature_flags,
+ :test_cases,
+ :artifacts
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/projects/super_sidebar_menus/code_menu_spec.rb b/spec/lib/sidebars/projects/super_sidebar_menus/code_menu_spec.rb
new file mode 100644
index 00000000000..8f69717eb29
--- /dev/null
+++ b/spec/lib/sidebars/projects/super_sidebar_menus/code_menu_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::SuperSidebarMenus::CodeMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Code"))
+ expect(subject.sprite_icon).to eq("code")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :project_merge_request_list,
+ :files,
+ :branches,
+ :commits,
+ :tags,
+ :graphs,
+ :compare,
+ :project_snippets,
+ :file_locks
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/projects/super_sidebar_menus/manage_menu_spec.rb b/spec/lib/sidebars/projects/super_sidebar_menus/manage_menu_spec.rb
new file mode 100644
index 00000000000..afcdf2550d7
--- /dev/null
+++ b/spec/lib/sidebars/projects/super_sidebar_menus/manage_menu_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::SuperSidebarMenus::ManageMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Manage"))
+ expect(subject.sprite_icon).to eq("users")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :activity,
+ :members,
+ :labels
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/projects/super_sidebar_menus/monitor_menu_spec.rb b/spec/lib/sidebars/projects/super_sidebar_menus/monitor_menu_spec.rb
new file mode 100644
index 00000000000..9344bbc76db
--- /dev/null
+++ b/spec/lib/sidebars/projects/super_sidebar_menus/monitor_menu_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::SuperSidebarMenus::MonitorMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Monitor"))
+ expect(subject.sprite_icon).to eq("monitor")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :metrics,
+ :error_tracking,
+ :alert_management,
+ :incidents,
+ :on_call_schedules,
+ :escalation_policies,
+ :service_desk
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/projects/super_sidebar_menus/operations_menu_spec.rb b/spec/lib/sidebars/projects/super_sidebar_menus/operations_menu_spec.rb
new file mode 100644
index 00000000000..6ab070c40ae
--- /dev/null
+++ b/spec/lib/sidebars/projects/super_sidebar_menus/operations_menu_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::SuperSidebarMenus::OperationsMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Operate"))
+ expect(subject.sprite_icon).to eq("deployments")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :packages_registry,
+ :container_registry,
+ :kubernetes,
+ :terraform_states,
+ :infrastructure_registry,
+ :google_cloud,
+ :aws
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/projects/super_sidebar_menus/plan_menu_spec.rb b/spec/lib/sidebars/projects/super_sidebar_menus/plan_menu_spec.rb
new file mode 100644
index 00000000000..8d61c9d9a0e
--- /dev/null
+++ b/spec/lib/sidebars/projects/super_sidebar_menus/plan_menu_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::SuperSidebarMenus::PlanMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Plan"))
+ expect(subject.sprite_icon).to eq("planning")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :project_issue_list,
+ :boards,
+ :milestones,
+ :iterations,
+ :project_wiki,
+ :requirements
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/projects/super_sidebar_menus/secure_menu_spec.rb b/spec/lib/sidebars/projects/super_sidebar_menus/secure_menu_spec.rb
new file mode 100644
index 00000000000..74ef761332e
--- /dev/null
+++ b/spec/lib/sidebars/projects/super_sidebar_menus/secure_menu_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::SuperSidebarMenus::SecureMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ let(:items) { subject.instance_variable_get(:@items) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(s_("Navigation|Secure"))
+ expect(subject.sprite_icon).to eq("shield")
+ end
+
+ it 'defines list of NilMenuItem placeholders' do
+ expect(items.map(&:class).uniq).to eq([Sidebars::NilMenuItem])
+ expect(items.map(&:item_id)).to eq([
+ :discover_project_security,
+ :dashboard,
+ :vulnerability_report,
+ :dependency_list,
+ :license_compliance,
+ :audit_events,
+ :scan_policies,
+ :on_demand_scans,
+ :configuration
+ ])
+ end
+end
diff --git a/spec/lib/sidebars/projects/super_sidebar_panel_spec.rb b/spec/lib/sidebars/projects/super_sidebar_panel_spec.rb
new file mode 100644
index 00000000000..93f0072a111
--- /dev/null
+++ b/spec/lib/sidebars/projects/super_sidebar_panel_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::SuperSidebarPanel, feature_category: :navigation do
+ let_it_be(:project) { create(:project, :repository) }
+
+ let(:user) { project.first_owner }
+ let(:context) do
+ Sidebars::Projects::Context.new(
+ current_user: user,
+ container: project,
+ current_ref: project.repository.root_ref,
+ is_super_sidebar: true,
+ # Turn features on that impact the list of items rendered
+ can_view_pipeline_editor: true,
+ learn_gitlab_enabled: true,
+ show_discover_project_security: true,
+ # Turn features off that do not add/remove items
+ show_cluster_hint: false,
+ show_promotions: false
+ )
+ end
+
+ subject { described_class.new(context) }
+
+ before do
+ # Enable integrations with menu items
+ allow(project).to receive(:external_wiki).and_return(build(:external_wiki_integration, project: project))
+ allow(project).to receive(:external_issue_tracker).and_return(build(:bugzilla_integration, project: project))
+ end
+
+ it 'implements #super_sidebar_context_header' do
+ expect(subject.super_sidebar_context_header).to eq(
+ {
+ title: project.name,
+ avatar: project.avatar_url,
+ id: project.id
+ })
+ end
+
+ describe '#renderable_menus' do
+ let(:category_menu) do
+ [
+ Sidebars::StaticMenu,
+ Sidebars::Projects::SuperSidebarMenus::ManageMenu,
+ Sidebars::Projects::SuperSidebarMenus::PlanMenu,
+ Sidebars::Projects::SuperSidebarMenus::CodeMenu,
+ Sidebars::Projects::SuperSidebarMenus::BuildMenu,
+ Sidebars::Projects::SuperSidebarMenus::SecureMenu,
+ Sidebars::Projects::SuperSidebarMenus::OperationsMenu,
+ Sidebars::Projects::SuperSidebarMenus::MonitorMenu,
+ Sidebars::Projects::SuperSidebarMenus::AnalyzeMenu,
+ Sidebars::UncategorizedMenu,
+ Sidebars::Projects::Menus::SettingsMenu
+ ]
+ end
+
+ it "is exposed as a renderable menu" do
+ expect(subject.instance_variable_get(:@menus).map(&:class)).to eq(category_menu)
+ end
+ end
+
+ it_behaves_like 'a panel with uniquely identifiable menu items'
+ it_behaves_like 'a panel with all menu_items categorized'
+end
diff --git a/spec/lib/sidebars/search/panel_spec.rb b/spec/lib/sidebars/search/panel_spec.rb
new file mode 100644
index 00000000000..30801ff800e
--- /dev/null
+++ b/spec/lib/sidebars/search/panel_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Search::Panel, feature_category: :navigation do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+
+ let(:context) { Sidebars::Context.new(current_user: current_user, container: user) }
+ let(:panel) { described_class.new(context) }
+
+ subject { described_class.new(context) }
+
+ it_behaves_like 'a panel with uniquely identifiable menu items'
+
+ describe '#aria_label' do
+ it 'returns the correct aria label' do
+ expect(panel.aria_label).to eq(_('Search results'))
+ end
+ end
+
+ describe '#super_sidebar_context_header' do
+ it 'returns a hash with the correct title and icon' do
+ expected_header = {
+ title: 'Search results',
+ icon: 'search-results'
+ }
+ expect(panel.super_sidebar_context_header).to eq(expected_header)
+ end
+ end
+end
diff --git a/spec/lib/sidebars/static_menu_spec.rb b/spec/lib/sidebars/static_menu_spec.rb
new file mode 100644
index 00000000000..3d9feee0494
--- /dev/null
+++ b/spec/lib/sidebars/static_menu_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::StaticMenu, feature_category: :navigation do
+ let(:context) { {} }
+
+ subject { described_class.new(context) }
+
+ describe '#serialize_for_super_sidebar' do
+ it 'returns flat list of all menu items' do
+ subject.add_item(Sidebars::MenuItem.new(item_id: 'id1', title: 'Is active', link: 'foo2',
+ active_routes: { controller: 'fooc' }))
+ subject.add_item(Sidebars::MenuItem.new(item_id: 'id2', title: 'Not active', link: 'foo3',
+ active_routes: { controller: 'barc' }))
+ subject.add_item(Sidebars::NilMenuItem.new(item_id: 'nil_item'))
+
+ allow(context).to receive(:route_is_active).and_return(->(x) { x[:controller] == 'fooc' })
+
+ expect(subject.serialize_for_super_sidebar).to eq(
+ [
+ {
+ id: 'id1',
+ title: "Is active",
+ icon: nil,
+ link: "foo2",
+ is_active: true,
+ pill_count: nil,
+ link_classes: nil
+ },
+ {
+ id: 'id2',
+ title: "Not active",
+ icon: nil,
+ link: "foo3",
+ is_active: false,
+ pill_count: nil,
+ link_classes: nil
+ }
+ ]
+ )
+ end
+ end
+end
diff --git a/spec/lib/sidebars/uncategorized_menu_spec.rb b/spec/lib/sidebars/uncategorized_menu_spec.rb
new file mode 100644
index 00000000000..45e7c0c87e2
--- /dev/null
+++ b/spec/lib/sidebars/uncategorized_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UncategorizedMenu, feature_category: :navigation do
+ subject { described_class.new({}) }
+
+ it 'has title and sprite_icon' do
+ expect(subject.title).to eq(_("Uncategorized"))
+ expect(subject.sprite_icon).to eq("question")
+ end
+end
diff --git a/spec/lib/sidebars/user_profile/menus/activity_menu_spec.rb b/spec/lib/sidebars/user_profile/menus/activity_menu_spec.rb
new file mode 100644
index 00000000000..6689b8b2da3
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/menus/activity_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Menus::ActivityMenu, feature_category: :navigation do
+ it_behaves_like 'User profile menu',
+ title: s_('UserProfile|Activity'),
+ icon: 'history',
+ active_route: 'users#activity' do
+ let(:link) { "/users/#{user.username}/activity" }
+ end
+end
diff --git a/spec/lib/sidebars/user_profile/menus/contributed_projects_menu_spec.rb b/spec/lib/sidebars/user_profile/menus/contributed_projects_menu_spec.rb
new file mode 100644
index 00000000000..2677851247b
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/menus/contributed_projects_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Menus::ContributedProjectsMenu, feature_category: :navigation do
+ it_behaves_like 'User profile menu',
+ title: s_('UserProfile|Contributed projects'),
+ icon: 'project',
+ active_route: 'users#contributed' do
+ let(:link) { "/users/#{user.username}/contributed" }
+ end
+end
diff --git a/spec/lib/sidebars/user_profile/menus/followers_menu_spec.rb b/spec/lib/sidebars/user_profile/menus/followers_menu_spec.rb
new file mode 100644
index 00000000000..2d3d48f0a8c
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/menus/followers_menu_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Menus::FollowersMenu, feature_category: :navigation do
+ it_behaves_like 'User profile menu',
+ title: s_('UserProfile|Followers'),
+ icon: 'users',
+ active_route: 'users#followers' do
+ let(:link) { "/users/#{user.username}/followers" }
+ end
+
+ it_behaves_like 'Followers/followees counts', :followers
+end
diff --git a/spec/lib/sidebars/user_profile/menus/following_menu_spec.rb b/spec/lib/sidebars/user_profile/menus/following_menu_spec.rb
new file mode 100644
index 00000000000..8d8db2611e6
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/menus/following_menu_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Menus::FollowingMenu, feature_category: :navigation do
+ it_behaves_like 'User profile menu',
+ title: s_('UserProfile|Following'),
+ icon: 'users',
+ active_route: 'users#following' do
+ let(:link) { "/users/#{user.username}/following" }
+ end
+
+ it_behaves_like 'Followers/followees counts', :followees
+end
diff --git a/spec/lib/sidebars/user_profile/menus/groups_menu_spec.rb b/spec/lib/sidebars/user_profile/menus/groups_menu_spec.rb
new file mode 100644
index 00000000000..989cc1ff5ce
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/menus/groups_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Menus::GroupsMenu, feature_category: :navigation do
+ it_behaves_like 'User profile menu',
+ title: s_('UserProfile|Groups'),
+ icon: 'group',
+ active_route: 'users#groups' do
+ let(:link) { "/users/#{user.username}/groups" }
+ end
+end
diff --git a/spec/lib/sidebars/user_profile/menus/overview_menu_spec.rb b/spec/lib/sidebars/user_profile/menus/overview_menu_spec.rb
new file mode 100644
index 00000000000..7cf86676892
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/menus/overview_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Menus::OverviewMenu, feature_category: :navigation do
+ it_behaves_like 'User profile menu',
+ title: s_('UserProfile|Overview'),
+ icon: 'overview',
+ active_route: 'users#show' do
+ let(:link) { "/#{user.username}" }
+ end
+end
diff --git a/spec/lib/sidebars/user_profile/menus/personal_projects_menu_spec.rb b/spec/lib/sidebars/user_profile/menus/personal_projects_menu_spec.rb
new file mode 100644
index 00000000000..3e0bc269a66
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/menus/personal_projects_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Menus::PersonalProjectsMenu, feature_category: :navigation do
+ it_behaves_like 'User profile menu',
+ title: s_('UserProfile|Personal projects'),
+ icon: 'project',
+ active_route: 'users#projects' do
+ let(:link) { "/users/#{user.username}/projects" }
+ end
+end
diff --git a/spec/lib/sidebars/user_profile/menus/snippets_menu_spec.rb b/spec/lib/sidebars/user_profile/menus/snippets_menu_spec.rb
new file mode 100644
index 00000000000..b2363706113
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/menus/snippets_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Menus::SnippetsMenu, feature_category: :navigation do
+ it_behaves_like 'User profile menu',
+ title: s_('UserProfile|Snippets'),
+ icon: 'snippet',
+ active_route: 'users#snippets' do
+ let(:link) { "/users/#{user.username}/snippets" }
+ end
+end
diff --git a/spec/lib/sidebars/user_profile/menus/starred_projects_menu_spec.rb b/spec/lib/sidebars/user_profile/menus/starred_projects_menu_spec.rb
new file mode 100644
index 00000000000..aa6ad0a74e7
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/menus/starred_projects_menu_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Menus::StarredProjectsMenu, feature_category: :navigation do
+ it_behaves_like 'User profile menu',
+ title: s_('UserProfile|Starred projects'),
+ icon: 'star-o',
+ active_route: 'users#starred' do
+ let(:link) { "/users/#{user.username}/starred" }
+ end
+end
diff --git a/spec/lib/sidebars/user_profile/panel_spec.rb b/spec/lib/sidebars/user_profile/panel_spec.rb
new file mode 100644
index 00000000000..c62c7f9fd96
--- /dev/null
+++ b/spec/lib/sidebars/user_profile/panel_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserProfile::Panel, feature_category: :navigation do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+
+ let(:context) { Sidebars::Context.new(current_user: current_user, container: user) }
+
+ subject { described_class.new(context) }
+
+ it_behaves_like 'a panel with uniquely identifiable menu items'
+
+ it 'implements #aria_label' do
+ expect(subject.aria_label).to eq(s_('UserProfile|User profile navigation'))
+ end
+
+ it 'implements #super_sidebar_context_header' do
+ expect(subject.super_sidebar_context_header).to eq({
+ title: user.name,
+ avatar: user.avatar_url,
+ avatar_shape: 'circle'
+ })
+ end
+end
diff --git a/spec/lib/sidebars/user_settings/menus/access_tokens_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/access_tokens_menu_spec.rb
new file mode 100644
index 00000000000..fa33e7bedfb
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/access_tokens_menu_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::AccessTokensMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/personal_access_tokens',
+ title: _('Access Tokens'),
+ icon: 'token',
+ active_routes: { controller: :personal_access_tokens }
+
+ describe '#render?' do
+ subject { described_class.new(context) }
+
+ let_it_be(:user) { build(:user) }
+
+ context 'when personal access tokens are disabled' do
+ before do
+ allow(::Gitlab::CurrentSettings).to receive_messages(personal_access_tokens_disabled?: true)
+ end
+
+ context 'when user is logged in' do
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ it 'does not render' do
+ expect(subject.render?).to be false
+ end
+ end
+
+ context 'when user is not logged in' do
+ let(:context) { Sidebars::Context.new(current_user: nil, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it 'does not render' do
+ expect(subject.render?).to be false
+ end
+ end
+ end
+
+ context 'when personal access tokens are enabled' do
+ before do
+ allow(::Gitlab::CurrentSettings).to receive_messages(personal_access_tokens_disabled?: false)
+ end
+
+ context 'when user is logged in' do
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ it 'renders' do
+ expect(subject.render?).to be true
+ end
+ end
+
+ context 'when user is not logged in' do
+ let(:context) { Sidebars::Context.new(current_user: nil, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it 'does not render' do
+ expect(subject.render?).to be false
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/sidebars/user_settings/menus/account_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/account_menu_spec.rb
new file mode 100644
index 00000000000..d5810d9c5ae
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/account_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::AccountMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/account',
+ title: _('Account'),
+ icon: 'account',
+ active_routes: { controller: [:accounts, :two_factor_auths] }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/active_sessions_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/active_sessions_menu_spec.rb
new file mode 100644
index 00000000000..be5f826ee58
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/active_sessions_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::ActiveSessionsMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/active_sessions',
+ title: _('Active Sessions'),
+ icon: 'monitor-lines',
+ active_routes: { controller: :active_sessions }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/applications_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/applications_menu_spec.rb
new file mode 100644
index 00000000000..eeda4fb844c
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/applications_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::ApplicationsMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/applications',
+ title: _('Applications'),
+ icon: 'applications',
+ active_routes: { controller: 'oauth/applications' }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/authentication_log_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/authentication_log_menu_spec.rb
new file mode 100644
index 00000000000..33be5050c37
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/authentication_log_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::AuthenticationLogMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/audit_log',
+ title: _('Authentication Log'),
+ icon: 'log',
+ active_routes: { path: 'profiles#audit_log' }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/chat_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/chat_menu_spec.rb
new file mode 100644
index 00000000000..2a0587e2504
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/chat_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::ChatMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/chat',
+ title: _('Chat'),
+ icon: 'comment',
+ active_routes: { controller: :chat_names }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/comment_templates_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/comment_templates_menu_spec.rb
new file mode 100644
index 00000000000..37a383cfd9d
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/comment_templates_menu_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::CommentTemplatesMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/comment_templates',
+ title: _('Comment Templates'),
+ icon: 'comment-lines',
+ active_routes: { controller: :comment_templates }
+
+ describe '#render?' do
+ subject { described_class.new(context) }
+
+ let_it_be(:user) { build(:user) }
+
+ context 'when comment templates are enabled' do
+ before do
+ allow(subject).to receive(:saved_replies_enabled?).and_return(true)
+ end
+
+ context 'when user is logged in' do
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ it 'does not render' do
+ expect(subject.render?).to be true
+ end
+ end
+
+ context 'when user is not logged in' do
+ let(:context) { Sidebars::Context.new(current_user: nil, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it 'does not render' do
+ expect(subject.render?).to be false
+ end
+ end
+ end
+
+ context 'when comment templates are disabled' do
+ before do
+ allow(subject).to receive(:saved_replies_enabled?).and_return(false)
+ end
+
+ context 'when user is logged in' do
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ it 'renders' do
+ expect(subject.render?).to be false
+ end
+ end
+
+ context 'when user is not logged in' do
+ let(:context) { Sidebars::Context.new(current_user: nil, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it 'does not render' do
+ expect(subject.render?).to be false
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/sidebars/user_settings/menus/emails_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/emails_menu_spec.rb
new file mode 100644
index 00000000000..2f16c68e601
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/emails_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::EmailsMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/emails',
+ title: _('Emails'),
+ icon: 'mail',
+ active_routes: { controller: :emails }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/gpg_keys_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/gpg_keys_menu_spec.rb
new file mode 100644
index 00000000000..1f4340ad29c
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/gpg_keys_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::GpgKeysMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/gpg_keys',
+ title: _('GPG Keys'),
+ icon: 'key',
+ active_routes: { controller: :gpg_keys }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/notifications_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/notifications_menu_spec.rb
new file mode 100644
index 00000000000..282324056d4
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/notifications_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::NotificationsMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/notifications',
+ title: _('Notifications'),
+ icon: 'notifications',
+ active_routes: { controller: :notifications }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/password_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/password_menu_spec.rb
new file mode 100644
index 00000000000..168019fea5d
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/password_menu_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::PasswordMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/password',
+ title: _('Password'),
+ icon: 'lock',
+ active_routes: { controller: :passwords }
+
+ describe '#render?' do
+ subject { described_class.new(context) }
+
+ let_it_be(:user) { build(:user) }
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ context 'when password authentication is enabled' do
+ before do
+ allow(user).to receive(:allow_password_authentication?).and_return(true)
+ end
+
+ it 'renders' do
+ expect(subject.render?).to be true
+ end
+ end
+
+ context 'when password authentication is disabled' do
+ before do
+ allow(user).to receive(:allow_password_authentication?).and_return(false)
+ end
+
+ it 'renders' do
+ expect(subject.render?).to be false
+ end
+ end
+ end
+end
diff --git a/spec/lib/sidebars/user_settings/menus/preferences_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/preferences_menu_spec.rb
new file mode 100644
index 00000000000..83a67a40081
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/preferences_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::PreferencesMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/preferences',
+ title: _('Preferences'),
+ icon: 'preferences',
+ active_routes: { controller: :preferences }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/profile_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/profile_menu_spec.rb
new file mode 100644
index 00000000000..8410ba7cfcd
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/profile_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::ProfileMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile',
+ title: _('Profile'),
+ icon: 'profile',
+ active_routes: { path: 'profiles#show' }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/menus/ssh_keys_menu_spec.rb b/spec/lib/sidebars/user_settings/menus/ssh_keys_menu_spec.rb
new file mode 100644
index 00000000000..8c781cc743b
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/menus/ssh_keys_menu_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Menus::SshKeysMenu, feature_category: :navigation do
+ it_behaves_like 'User settings menu',
+ link: '/-/profile/keys',
+ title: _('SSH Keys'),
+ icon: 'key',
+ active_routes: { controller: :keys }
+
+ it_behaves_like 'User settings menu #render? method'
+end
diff --git a/spec/lib/sidebars/user_settings/panel_spec.rb b/spec/lib/sidebars/user_settings/panel_spec.rb
new file mode 100644
index 00000000000..0c02bf77d0e
--- /dev/null
+++ b/spec/lib/sidebars/user_settings/panel_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::UserSettings::Panel, feature_category: :navigation do
+ let_it_be(:user) { create(:user) }
+
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it_behaves_like 'a panel with uniquely identifiable menu items'
+
+ it 'implements #super_sidebar_context_header' do
+ expect(subject.super_sidebar_context_header).to eq({ title: _('User settings'), avatar: user.avatar_url })
+ end
+end
diff --git a/spec/lib/sidebars/your_work/panel_spec.rb b/spec/lib/sidebars/your_work/panel_spec.rb
new file mode 100644
index 00000000000..ae9c3aa18e6
--- /dev/null
+++ b/spec/lib/sidebars/your_work/panel_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::YourWork::Panel, feature_category: :navigation do
+ let_it_be(:user) { create(:user) }
+
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it_behaves_like 'a panel with uniquely identifiable menu items'
+
+ it 'implements #super_sidebar_context_header' do
+ expect(subject.super_sidebar_context_header).to eq({ title: 'Your work', icon: 'work' })
+ end
+end
diff --git a/spec/lib/slack/api_spec.rb b/spec/lib/slack/api_spec.rb
new file mode 100644
index 00000000000..360e0284164
--- /dev/null
+++ b/spec/lib/slack/api_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Slack::API, feature_category: :integrations do
+ describe '#post' do
+ let(:slack_installation) { build(:slack_integration) }
+ let(:api_method) { 'api_method_call' }
+ let(:api_url) { "#{described_class::BASE_URL}/#{api_method}" }
+ let(:payload) { { foo: 'bar' } }
+
+ subject(:post) { described_class.new(slack_installation).post(api_method, payload) }
+
+ before do
+ stub_request(:post, api_url)
+ end
+
+ it 'posts to the Slack API correctly' do
+ post
+
+ expect(WebMock).to have_requested(:post, api_url).with(
+ body: payload.to_json,
+ headers: {
+ 'Authorization' => "Bearer #{slack_installation.bot_access_token}",
+ 'Content-Type' => 'application/json; charset=utf-8'
+ })
+ end
+
+ it 'returns the response' do
+ is_expected.to be_kind_of(HTTParty::Response)
+ end
+
+ context 'when the slack installation has no bot token' do
+ let(:slack_installation) { build(:slack_integration, :legacy) }
+
+ it 'raises an error' do
+ expect { post }.to raise_error(ArgumentError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/slack/block_kit/app_home_opened_spec.rb b/spec/lib/slack/block_kit/app_home_opened_spec.rb
new file mode 100644
index 00000000000..5a5a9c6739c
--- /dev/null
+++ b/spec/lib/slack/block_kit/app_home_opened_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Slack::BlockKit::AppHomeOpened, feature_category: :integrations do
+ let_it_be(:slack_installation) { create(:slack_integration) }
+
+ let(:chat_name) { nil }
+
+ describe '#build' do
+ subject(:payload) do
+ described_class.new(slack_installation.user_id, slack_installation.team_id, chat_name, slack_installation).build
+ end
+
+ it 'generates blocks of type "home"' do
+ is_expected.to match({ type: 'home', blocks: kind_of(Array) })
+ end
+
+ it 'prompts the user to connect their GitLab account' do
+ expect(payload[:blocks]).to include(
+ hash_including(
+ {
+ type: 'actions',
+ elements: [
+ hash_including(
+ {
+ type: 'button',
+ text: include({ text: 'Connect your GitLab account' }),
+ url: include(Gitlab::Routing.url_helpers.new_profile_chat_name_url)
+ }
+ )
+ ]
+ }
+ )
+ )
+ end
+
+ context 'when the user has linked their GitLab account' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:chat_name) do
+ create(:chat_name,
+ user: user,
+ team_id: slack_installation.team_id,
+ chat_id: slack_installation.user_id
+ )
+ end
+
+ it 'displays the GitLab user they are linked to' do
+ account = "<#{Gitlab::UrlBuilder.build(user)}|#{user.to_reference}>"
+
+ expect(payload[:blocks]).to include(
+ hash_including(
+ {
+ type: 'section',
+ text: include({ text: "✅ Connected to GitLab account #{account}." })
+ }
+ )
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/slack/block_kit/incident_management/incident_modal_opened_spec.rb b/spec/lib/slack/block_kit/incident_management/incident_modal_opened_spec.rb
new file mode 100644
index 00000000000..0eb2475457a
--- /dev/null
+++ b/spec/lib/slack/block_kit/incident_management/incident_modal_opened_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Slack::BlockKit::IncidentManagement::IncidentModalOpened, feature_category: :incident_management do
+ let_it_be(:project1) { create(:project) }
+ let_it_be(:project2) { create(:project) }
+ let_it_be(:project_with_long_name) { create(:project, name: 'a b' * 76) }
+ let_it_be(:response_url) { 'https://response.slack.com/id/123' }
+
+ describe '#build' do
+ subject(:payload) do
+ described_class.new([project1, project2, project_with_long_name], response_url).build
+ end
+
+ it 'generates blocks for modal' do
+ is_expected.to include({ type: 'modal', blocks: kind_of(Array), private_metadata: response_url })
+ end
+
+ it 'sets projects in the project selection' do
+ project_list = payload[:blocks][1][:elements][0][:options]
+
+ expect(project_list.first[:value]).to eq(project1.id.to_s)
+ expect(project_list.last[:value]).to eq(project_with_long_name.id.to_s)
+ end
+
+ it 'sets initial project option as the first project path' do
+ initial_project = payload[:blocks][1][:elements][0][:initial_option]
+
+ expect(initial_project[:value]).to eq(project1.id.to_s)
+ end
+
+ it 'truncates the path value if more than 75 chars' do
+ project_list = payload[:blocks][1][:elements][0][:options]
+
+ expect(project_list.last.dig(:text, :text)).to eq(
+ project_with_long_name.full_path.truncate(described_class::MAX_CHAR_LENGTH)
+ )
+ end
+ end
+end
diff --git a/spec/lib/unnested_in_filters/rewriter_spec.rb b/spec/lib/unnested_in_filters/rewriter_spec.rb
index bba27276037..fe34fba579b 100644
--- a/spec/lib/unnested_in_filters/rewriter_spec.rb
+++ b/spec/lib/unnested_in_filters/rewriter_spec.rb
@@ -69,21 +69,15 @@ RSpec.describe UnnestedInFilters::Rewriter do
let(:recorded_queries) { ActiveRecord::QueryRecorder.new { rewriter.rewrite.load } }
let(:relation) { User.where(state: :active, user_type: %i(support_bot alert_bot)).limit(2) }
- let(:users_default_select_fields) do
- User.default_select_columns
- .map { |field| "\"users\".\"#{field.name}\"" }
- .join(',')
- end
-
let(:expected_query) do
<<~SQL
SELECT
- #{users_default_select_fields}
+ "users".*
FROM
unnest('{1,2}'::smallint[]) AS "user_types"("user_type"),
LATERAL (
SELECT
- #{users_default_select_fields}
+ "users".*
FROM
"users"
WHERE
@@ -107,13 +101,13 @@ RSpec.describe UnnestedInFilters::Rewriter do
let(:expected_query) do
<<~SQL
SELECT
- #{users_default_select_fields}
+ "users".*
FROM
unnest(ARRAY(SELECT "users"."state" FROM "users")::character varying[]) AS "states"("state"),
unnest('{1,2}'::smallint[]) AS "user_types"("user_type"),
LATERAL (
SELECT
- #{users_default_select_fields}
+ "users".*
FROM
"users"
WHERE
@@ -135,12 +129,12 @@ RSpec.describe UnnestedInFilters::Rewriter do
let(:expected_query) do
<<~SQL
SELECT
- #{users_default_select_fields}
+ "users".*
FROM
unnest('{active,blocked,banned}'::charactervarying[]) AS "states"("state"),
LATERAL (
SELECT
- #{users_default_select_fields}
+ "users".*
FROM
"users"
WHERE
@@ -187,6 +181,8 @@ RSpec.describe UnnestedInFilters::Rewriter do
let(:expected_query) do
<<~SQL
+ SELECT
+ "users".*
FROM
"users"
WHERE
@@ -221,7 +217,7 @@ RSpec.describe UnnestedInFilters::Rewriter do
end
it 'changes the query' do
- expect(issued_query.gsub(/\s/, '')).to include(expected_query.gsub(/\s/, ''))
+ expect(issued_query.gsub(/\s/, '')).to start_with(expected_query.gsub(/\s/, ''))
end
end
@@ -230,6 +226,8 @@ RSpec.describe UnnestedInFilters::Rewriter do
let(:expected_query) do
<<~SQL
+ SELECT
+ "users".*
FROM
"users"
WHERE
@@ -259,7 +257,7 @@ RSpec.describe UnnestedInFilters::Rewriter do
end
it 'does not rewrite the in statement for the joined table' do
- expect(issued_query.gsub(/\s/, '')).to include(expected_query.gsub(/\s/, ''))
+ expect(issued_query.gsub(/\s/, '')).to start_with(expected_query.gsub(/\s/, ''))
end
end
diff --git a/spec/lib/uploaded_file_spec.rb b/spec/lib/uploaded_file_spec.rb
index 0aba6cb0065..721b3d70feb 100644
--- a/spec/lib/uploaded_file_spec.rb
+++ b/spec/lib/uploaded_file_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UploadedFile do
+RSpec.describe UploadedFile, feature_category: :package_registry do
let(:temp_dir) { Dir.tmpdir }
let(:temp_file) { Tempfile.new(%w[test test], temp_dir) }
@@ -15,7 +15,7 @@ RSpec.describe UploadedFile do
end
context 'from_params functions' do
- RSpec.shared_examples 'using the file path' do |filename:, content_type:, sha256:, path_suffix:, upload_duration:|
+ RSpec.shared_examples 'using the file path' do |filename:, content_type:, sha256:, path_suffix:, upload_duration:, sha1:, md5:|
it { is_expected.not_to be_nil }
it 'sets properly the attributes' do
@@ -25,6 +25,8 @@ RSpec.describe UploadedFile do
expect(subject.remote_id).to be_nil
expect(subject.path).to end_with(path_suffix)
expect(subject.upload_duration).to eq(upload_duration)
+ expect(subject.sha1).to eq(sha1)
+ expect(subject.md5).to eq(md5)
end
it 'handles a blank path' do
@@ -38,7 +40,7 @@ RSpec.describe UploadedFile do
end
end
- RSpec.shared_examples 'using the remote id' do |filename:, content_type:, sha256:, size:, remote_id:, upload_duration:|
+ RSpec.shared_examples 'using the remote id' do |filename:, content_type:, sha256:, size:, remote_id:, upload_duration:, sha1:, md5:|
it { is_expected.not_to be_nil }
it 'sets properly the attributes' do
@@ -49,6 +51,8 @@ RSpec.describe UploadedFile do
expect(subject.size).to eq(size)
expect(subject.remote_id).to eq(remote_id)
expect(subject.upload_duration).to eq(upload_duration)
+ expect(subject.sha1).to eq(sha1)
+ expect(subject.md5).to eq(md5)
end
end
@@ -81,7 +85,9 @@ RSpec.describe UploadedFile do
'name' => 'dir/my file&.txt',
'type' => 'my/type',
'upload_duration' => '5.05',
- 'sha256' => 'sha256' }
+ 'sha256' => 'sha256',
+ 'sha1' => 'sha1',
+ 'md5' => 'md5' }
end
it_behaves_like 'using the file path',
@@ -89,7 +95,9 @@ RSpec.describe UploadedFile do
content_type: 'my/type',
sha256: 'sha256',
path_suffix: 'test',
- upload_duration: 5.05
+ upload_duration: 5.05,
+ sha1: 'sha1',
+ md5: 'md5'
end
context 'with a remote id' do
@@ -101,7 +109,9 @@ RSpec.describe UploadedFile do
'remote_id' => '1234567890',
'etag' => 'etag1234567890',
'upload_duration' => '5.05',
- 'size' => '123456'
+ 'size' => '123456',
+ 'sha1' => 'sha1',
+ 'md5' => 'md5'
}
end
@@ -111,7 +121,9 @@ RSpec.describe UploadedFile do
sha256: 'sha256',
size: 123456,
remote_id: '1234567890',
- upload_duration: 5.05
+ upload_duration: 5.05,
+ sha1: 'sha1',
+ md5: 'md5'
end
context 'with a path and a remote id' do
@@ -124,7 +136,9 @@ RSpec.describe UploadedFile do
'remote_id' => '1234567890',
'etag' => 'etag1234567890',
'upload_duration' => '5.05',
- 'size' => '123456'
+ 'size' => '123456',
+ 'sha1' => 'sha1',
+ 'md5' => 'md5'
}
end
@@ -134,7 +148,9 @@ RSpec.describe UploadedFile do
sha256: 'sha256',
size: 123456,
remote_id: '1234567890',
- upload_duration: 5.05
+ upload_duration: 5.05,
+ sha1: 'sha1',
+ md5: 'md5'
end
end
end
@@ -262,6 +278,14 @@ RSpec.describe UploadedFile do
end
end
end
+
+ context 'when unknown keyword params are provided' do
+ it 'raises an exception' do
+ expect do
+ described_class.new(temp_file.path, foo: 'param1', bar: 'param2')
+ end.to raise_error(ArgumentError, 'unknown keyword(s): foo, bar')
+ end
+ end
end
describe '#sanitize_filename' do
diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb
index 7c21e161ffe..5419c9e6798 100644
--- a/spec/mailers/emails/in_product_marketing_spec.rb
+++ b/spec/mailers/emails/in_product_marketing_spec.rb
@@ -10,11 +10,7 @@ RSpec.describe Emails::InProductMarketing do
let_it_be(:user) { create(:user) }
shared_examples 'has custom headers when on gitlab.com' do
- context 'when on gitlab.com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
+ context 'when on gitlab.com', :saas do
it 'has custom headers' do
aggregate_failures do
expect(subject).to deliver_from(described_class::FROM_ADDRESS)
diff --git a/spec/mailers/emails/issues_spec.rb b/spec/mailers/emails/issues_spec.rb
index 21e07c0252d..b5f3972f38e 100644
--- a/spec/mailers/emails/issues_spec.rb
+++ b/spec/mailers/emails/issues_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require 'email_spec'
-RSpec.describe Emails::Issues do
+RSpec.describe Emails::Issues, feature_category: :team_planning do
include EmailSpec::Matchers
it 'adds email methods to Notify' do
@@ -54,38 +54,6 @@ RSpec.describe Emails::Issues do
subject { Notify.issues_csv_email(user, empty_project, "dummy content", export_status) }
- include_context 'gitlab email notification'
-
- it 'attachment has csv mime type' do
- expect(attachment.mime_type).to eq 'text/csv'
- end
-
- it 'generates a useful filename' do
- expect(attachment.filename).to include(Date.today.year.to_s)
- expect(attachment.filename).to include('issues')
- expect(attachment.filename).to include('myproject')
- expect(attachment.filename).to end_with('.csv')
- end
-
- it 'mentions number of issues and project name' do
- expect(subject).to have_content '3'
- expect(subject).to have_content empty_project.name
- end
-
- it "doesn't need to mention truncation by default" do
- expect(subject).not_to have_content 'truncated'
- end
-
- context 'when truncated' do
- let(:export_status) { { truncated: true, rows_expected: 12, rows_written: 10 } }
-
- it 'mentions that the csv has been truncated' do
- expect(subject).to have_content 'truncated'
- end
-
- it 'mentions the number of issues written and expected' do
- expect(subject).to have_content '10 of 12 issues'
- end
- end
+ it_behaves_like 'export csv email', 'issues'
end
end
diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb
index 7682cf39450..9aece9538dc 100644
--- a/spec/mailers/emails/merge_requests_spec.rb
+++ b/spec/mailers/emails/merge_requests_spec.rb
@@ -13,12 +13,15 @@ RSpec.describe Emails::MergeRequests do
let_it_be(:reviewer, reload: true) { create(:user, email: 'reviewer@example.com', name: 'Jane Doe') }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:merge_request) do
- create(:merge_request, source_project: project,
- target_project: project,
- author: current_user,
- assignees: [assignee],
- reviewers: [reviewer],
- description: 'Awesome description')
+ create(
+ :merge_request,
+ source_project: project,
+ target_project: project,
+ author: current_user,
+ assignees: [assignee],
+ reviewers: [reviewer],
+ description: 'Awesome description'
+ )
end
let(:recipient) { assignee }
diff --git a/spec/mailers/emails/pages_domains_spec.rb b/spec/mailers/emails/pages_domains_spec.rb
index cf17f2e5ebf..ff446b83412 100644
--- a/spec/mailers/emails/pages_domains_spec.rb
+++ b/spec/mailers/emails/pages_domains_spec.rb
@@ -58,7 +58,7 @@ RSpec.describe Emails::PagesDomains do
end
describe '#pages_domain_enabled_email' do
- let(:email_subject) { "#{project.path} | GitLab Pages domain '#{domain.domain}' has been enabled" }
+ let(:email_subject) { "#{project.name} | GitLab Pages domain '#{domain.domain}' has been enabled" }
let(:link_anchor) { 'steps' }
subject { Notify.pages_domain_enabled_email(domain, user) }
@@ -69,7 +69,7 @@ RSpec.describe Emails::PagesDomains do
end
describe '#pages_domain_disabled_email' do
- let(:email_subject) { "#{project.path} | GitLab Pages domain '#{domain.domain}' has been disabled" }
+ let(:email_subject) { "#{project.name} | GitLab Pages domain '#{domain.domain}' has been disabled" }
let(:link_anchor) { '4-verify-the-domains-ownership' }
subject { Notify.pages_domain_disabled_email(domain, user) }
@@ -82,7 +82,7 @@ RSpec.describe Emails::PagesDomains do
end
describe '#pages_domain_verification_succeeded_email' do
- let(:email_subject) { "#{project.path} | Verification succeeded for GitLab Pages domain '#{domain.domain}'" }
+ let(:email_subject) { "#{project.name} | Verification succeeded for GitLab Pages domain '#{domain.domain}'" }
let(:link_anchor) { 'steps' }
subject { Notify.pages_domain_verification_succeeded_email(domain, user) }
@@ -93,7 +93,7 @@ RSpec.describe Emails::PagesDomains do
end
describe '#pages_domain_verification_failed_email' do
- let(:email_subject) { "#{project.path} | ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'" }
+ let(:email_subject) { "#{project.name} | ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'" }
let(:link_anchor) { 'steps' }
subject { Notify.pages_domain_verification_failed_email(domain, user) }
@@ -104,7 +104,7 @@ RSpec.describe Emails::PagesDomains do
end
describe '#pages_domain_auto_ssl_failed_email' do
- let(:email_subject) { "#{project.path} | ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '#{domain.domain}'" }
+ let(:email_subject) { "#{project.name} | ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '#{domain.domain}'" }
subject { Notify.pages_domain_auto_ssl_failed_email(domain, user) }
diff --git a/spec/mailers/emails/pipelines_spec.rb b/spec/mailers/emails/pipelines_spec.rb
index 1ac989cc46b..ae0876338f5 100644
--- a/spec/mailers/emails/pipelines_spec.rb
+++ b/spec/mailers/emails/pipelines_spec.rb
@@ -25,8 +25,13 @@ RSpec.describe Emails::Pipelines do
let(:pipeline) { create(:ci_pipeline, ref: 'master', sha: sha, project: project) }
let!(:merge_request) do
- create(:merge_request, source_branch: 'master', target_branch: 'feature',
- source_project: project, target_project: project)
+ create(
+ :merge_request,
+ source_branch: 'master',
+ target_branch: 'feature',
+ source_project: project,
+ target_project: project
+ )
end
it 'has correct information that there is no merge request link' do
@@ -55,8 +60,13 @@ RSpec.describe Emails::Pipelines do
context 'when branch pipeline is set to a merge request as a head pipeline' do
let(:pipeline) do
- create(:ci_pipeline, project: project, ref: ref, sha: sha,
- merge_requests_as_head_pipeline: [merge_request])
+ create(
+ :ci_pipeline,
+ project: project,
+ ref: ref,
+ sha: sha,
+ merge_requests_as_head_pipeline: [merge_request]
+ )
end
let(:merge_request) do
diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb
index f5fce559778..140b067f7aa 100644
--- a/spec/mailers/emails/profile_spec.rb
+++ b/spec/mailers/emails/profile_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require 'email_spec'
-RSpec.describe Emails::Profile do
+RSpec.describe Emails::Profile, feature_category: :user_profile do
include EmailSpec::Matchers
include_context 'gitlab email notification'
@@ -429,11 +429,16 @@ RSpec.describe Emails::Profile do
is_expected.to have_subject "#{Gitlab.config.gitlab.host} sign-in from new location"
end
+ it 'mentions the username' do
+ is_expected.to have_body_text user.name
+ is_expected.to have_body_text user.username
+ end
+
it 'mentions the new sign-in IP' do
is_expected.to have_body_text ip
end
- it 'mentioned the time' do
+ it 'mentions the time' do
is_expected.to have_body_text current_time.strftime('%Y-%m-%d %H:%M:%S %Z')
end
@@ -476,7 +481,7 @@ RSpec.describe Emails::Profile do
end
it 'has the correct subject' do
- is_expected.to have_subject "Attempted sign in to #{Gitlab.config.gitlab.host} using a wrong two-factor authentication code"
+ is_expected.to have_subject "Attempted sign in to #{Gitlab.config.gitlab.host} using an incorrect verification code"
end
it 'mentions the IP address' do
@@ -536,4 +541,31 @@ RSpec.describe Emails::Profile do
is_expected.to have_body_text /#{profile_emails_path}/
end
end
+
+ describe 'awarded a new achievement' do
+ let(:user) { build(:user) }
+ let(:achievement) { build(:achievement) }
+
+ subject { Notify.new_achievement_email(user, achievement) }
+
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
+
+ it 'is sent to the user' do
+ is_expected.to deliver_to user.email
+ end
+
+ it 'has the correct subject' do
+ is_expected.to have_subject("#{achievement.namespace.full_path} awarded you the #{achievement.name} achievement")
+ end
+
+ it 'includes a link to the profile page' do
+ is_expected.to have_body_text(group_url(achievement.namespace))
+ end
+
+ it 'includes a link to the awarding group' do
+ is_expected.to have_body_text(user_url(user))
+ end
+ end
end
diff --git a/spec/mailers/emails/service_desk_spec.rb b/spec/mailers/emails/service_desk_spec.rb
index 25afa8b48ce..c50d5ce2571 100644
--- a/spec/mailers/emails/service_desk_spec.rb
+++ b/spec/mailers/emails/service_desk_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require 'email_spec'
-RSpec.describe Emails::ServiceDesk do
+RSpec.describe Emails::ServiceDesk, feature_category: :service_desk do
include EmailSpec::Helpers
include EmailSpec::Matchers
include EmailHelpers
@@ -13,9 +13,12 @@ RSpec.describe Emails::ServiceDesk do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
- let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:issue) { create(:issue, project: project, description: 'Some **issue** description') }
let_it_be(:email) { 'someone@gitlab.com' }
let_it_be(:expected_unsubscribe_url) { unsubscribe_sent_notification_url('b7721fc7e8419911a8bea145236a0519') }
+ let_it_be(:credential) { create(:service_desk_custom_email_credential, project: project) }
+ let_it_be(:verification) { create(:service_desk_custom_email_verification, project: project) }
+ let_it_be(:service_desk_setting) { create(:service_desk_setting, project: project, custom_email: 'user@example.com') }
let(:template) { double(content: template_content) }
@@ -23,7 +26,25 @@ RSpec.describe Emails::ServiceDesk do
issue.issue_email_participants.create!(email: email)
end
- shared_examples 'handle template content' do |template_key, attachments_count|
+ shared_examples 'a service desk notification email' do |attachments_count|
+ it 'builds the email correctly' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(issue, include_project: false, reply: reply_in_subject)
+
+ expect(subject.attachments.count).to eq(attachments_count.to_i)
+
+ expect(subject.content_type).to include('multipart/alternative')
+
+ expect(subject.parts[0].body.to_s).to include(expected_text)
+ expect(subject.parts[0].content_type).to include('text/plain')
+
+ expect(subject.parts[1].body.to_s).to include(expected_html)
+ expect(subject.parts[1].content_type).to include('text/html')
+ end
+ end
+ end
+
+ shared_examples 'a service desk notification email with template content' do |template_key, attachments_count|
before do
expect(Gitlab::Template::ServiceDeskTemplate).to receive(:find)
.with(template_key, issue.project)
@@ -33,9 +54,18 @@ RSpec.describe Emails::ServiceDesk do
it 'builds the email correctly' do
aggregate_failures do
is_expected.to have_referable_subject(issue, include_project: false, reply: reply_in_subject)
- is_expected.to have_body_text(expected_body)
+ is_expected.to have_body_text(expected_template_html)
+
expect(subject.attachments.count).to eq(attachments_count.to_i)
- expect(subject.content_type).to include(attachments_count.to_i > 0 ? 'multipart/mixed' : 'text/html')
+
+ if attachments_count.to_i > 0
+ # Envelope for emails with attachments is always multipart/mixed
+ expect(subject.content_type).to include('multipart/mixed')
+ # Template content only renders a html body, so ensure its content type is set accordingly
+ expect(subject.parts.first.content_type).to include('text/html')
+ else
+ expect(subject.content_type).to include('text/html')
+ end
end
end
end
@@ -60,7 +90,8 @@ RSpec.describe Emails::ServiceDesk do
let(:project) { create(:project, :custom_repo, files: { ".gitlab/service_desk_templates/another_file.md" => template_content }) }
it 'uses the default template' do
- is_expected.to have_body_text(default_text)
+ expect(subject.text_part.to_s).to include(expected_text)
+ expect(subject.html_part.to_s).to include(expected_html)
end
end
@@ -68,7 +99,8 @@ RSpec.describe Emails::ServiceDesk do
let(:project) { create(:project, :custom_repo, files: { "other_directory/another_file.md" => template_content }) }
it 'uses the default template' do
- is_expected.to have_body_text(default_text)
+ expect(subject.text_part.to_s).to include(expected_text)
+ expect(subject.html_part.to_s).to include(expected_html)
end
end
@@ -76,27 +108,62 @@ RSpec.describe Emails::ServiceDesk do
let(:project) { create(:project) }
it 'uses the default template' do
- is_expected.to have_body_text(default_text)
+ expect(subject.text_part.to_s).to include(expected_text)
+ expect(subject.html_part.to_s).to include(expected_html)
+ end
+ end
+ end
+
+ shared_examples 'a custom email verification process email' do
+ it 'contains custom email and project in subject' do
+ expect(subject.subject).to include(service_desk_setting.custom_email)
+ expect(subject.subject).to include(service_desk_setting.project.name)
+ end
+ end
+
+ shared_examples 'a custom email verification process notification email' do
+ it 'has correct recipient' do
+ expect(subject.to).to eq(['owner@example.com'])
+ end
+
+ it 'contains custom email and project in body' do
+ is_expected.to have_body_text(service_desk_setting.custom_email)
+ is_expected.to have_body_text(service_desk_setting.project.name)
+ end
+ end
+
+ shared_examples 'a custom email verification process result email with error' do |error_identifier, expected_text|
+ context "when having #{error_identifier} error" do
+ before do
+ service_desk_setting.custom_email_verification.error = error_identifier
+ end
+
+ it 'contains correct error message headline in text part' do
+ # look for text part because we can ignore HTML tags then
+ expect(subject.text_part.body).to match(expected_text)
end
end
end
describe '.service_desk_thank_you_email' do
let_it_be(:reply_in_subject) { true }
- let_it_be(:default_text) do
+ let_it_be(:expected_text) do
"Thank you for your support request! We are tracking your request as ticket #{issue.to_reference}, and will respond as soon as we can."
end
+ let_it_be(:expected_html) { expected_text }
+
subject { ServiceEmailClass.service_desk_thank_you_email(issue.id) }
+ it_behaves_like 'a service desk notification email'
it_behaves_like 'read template from repository', 'thank_you'
context 'handling template markdown' do
context 'with a simple text' do
let(:template_content) { 'thank you, **your new issue** has been created.' }
- let(:expected_body) { 'thank you, <strong>your new issue</strong> has been created.' }
+ let(:expected_template_html) { 'thank you, <strong>your new issue</strong> has been created.' }
- it_behaves_like 'handle template content', 'thank_you'
+ it_behaves_like 'a service desk notification email with template content', 'thank_you'
end
context 'with an issue id, issue path and unsubscribe url placeholders' do
@@ -105,12 +172,12 @@ RSpec.describe Emails::ServiceDesk do
'[Unsubscribe](%{UNSUBSCRIBE_URL})'
end
- let(:expected_body) do
+ let(:expected_template_html) do
"<p dir=\"auto\">thank you, <strong>your new issue:</strong> ##{issue.iid}, path: #{project.full_path}##{issue.iid}" \
"<a href=\"#{expected_unsubscribe_url}\">Unsubscribe</a></p>"
end
- it_behaves_like 'handle template content', 'thank_you'
+ it_behaves_like 'a service desk notification email with template content', 'thank_you'
end
context 'with header and footer placeholders' do
@@ -126,35 +193,44 @@ RSpec.describe Emails::ServiceDesk do
context 'with an issue id placeholder with whitespace' do
let(:template_content) { 'thank you, **your new issue:** %{ ISSUE_ID}' }
- let(:expected_body) { "thank you, <strong>your new issue:</strong> ##{issue.iid}" }
+ let(:expected_template_html) { "thank you, <strong>your new issue:</strong> ##{issue.iid}" }
- it_behaves_like 'handle template content', 'thank_you'
+ it_behaves_like 'a service desk notification email with template content', 'thank_you'
end
context 'with unexpected placeholder' do
let(:template_content) { 'thank you, **your new issue:** %{this is issue}' }
- let(:expected_body) { "thank you, <strong>your new issue:</strong> %{this is issue}" }
+ let(:expected_template_html) { "thank you, <strong>your new issue:</strong> %{this is issue}" }
+
+ it_behaves_like 'a service desk notification email with template content', 'thank_you'
+ end
+
+ context 'when issue description placeholder is used' do
+ let(:template_content) { 'thank you, your new issue has been created. %{ISSUE_DESCRIPTION}' }
+ let(:expected_template_html) { "<p dir=\"auto\">thank you, your new issue has been created. </p>#{issue.description_html}" }
- it_behaves_like 'handle template content', 'thank_you'
+ it_behaves_like 'a service desk notification email with template content', 'thank_you'
end
end
end
describe '.service_desk_new_note_email' do
let_it_be(:reply_in_subject) { false }
- let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project) }
- let_it_be(:default_text) { note.note }
+ let_it_be(:expected_text) { 'My **note**' }
+ let_it_be(:expected_html) { 'My <strong>note</strong>' }
+ let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: expected_text) }
subject { ServiceEmailClass.service_desk_new_note_email(issue.id, note.id, email) }
+ it_behaves_like 'a service desk notification email'
it_behaves_like 'read template from repository', 'new_note'
- context 'handling template markdown' do
+ context 'with template' do
context 'with a simple text' do
let(:template_content) { 'thank you, **new note on issue** has been created.' }
- let(:expected_body) { 'thank you, <strong>new note on issue</strong> has been created.' }
+ let(:expected_template_html) { 'thank you, <strong>new note on issue</strong> has been created.' }
- it_behaves_like 'handle template content', 'new_note'
+ it_behaves_like 'a service desk notification email with template content', 'new_note'
end
context 'with an issue id, issue path, note and unsubscribe url placeholders' do
@@ -163,12 +239,12 @@ RSpec.describe Emails::ServiceDesk do
'[Unsubscribe](%{UNSUBSCRIBE_URL})'
end
- let(:expected_body) do
- "<p dir=\"auto\">thank you, <strong>new note on issue:</strong> ##{issue.iid}, path: #{project.full_path}##{issue.iid}: #{note.note}" \
+ let(:expected_template_html) do
+ "<p dir=\"auto\">thank you, <strong>new note on issue:</strong> ##{issue.iid}, path: #{project.full_path}##{issue.iid}: #{expected_html}" \
"<a href=\"#{expected_unsubscribe_url}\">Unsubscribe</a></p>"
end
- it_behaves_like 'handle template content', 'new_note'
+ it_behaves_like 'a service desk notification email with template content', 'new_note'
end
context 'with header and footer placeholders' do
@@ -184,125 +260,196 @@ RSpec.describe Emails::ServiceDesk do
context 'with an issue id placeholder with whitespace' do
let(:template_content) { 'thank you, **new note on issue:** %{ ISSUE_ID}: %{ NOTE_TEXT }' }
- let(:expected_body) { "thank you, <strong>new note on issue:</strong> ##{issue.iid}: #{note.note}" }
+ let(:expected_template_html) { "thank you, <strong>new note on issue:</strong> ##{issue.iid}: #{expected_html}" }
- it_behaves_like 'handle template content', 'new_note'
+ it_behaves_like 'a service desk notification email with template content', 'new_note'
end
context 'with unexpected placeholder' do
let(:template_content) { 'thank you, **new note on issue:** %{this is issue}' }
- let(:expected_body) { "thank you, <strong>new note on issue:</strong> %{this is issue}" }
+ let(:expected_template_html) { "thank you, <strong>new note on issue:</strong> %{this is issue}" }
- it_behaves_like 'handle template content', 'new_note'
+ it_behaves_like 'a service desk notification email with template content', 'new_note'
end
- context 'with upload link in the note' do
- let_it_be(:secret) { 'e90decf88d8f96fe9e1389afc2e4a91f' }
- let_it_be(:filename) { 'test.jpg' }
- let_it_be(:path) { "#{secret}/#{filename}" }
- let_it_be(:upload_path) { "/uploads/#{path}" }
- let_it_be(:template_content) { 'some text %{ NOTE_TEXT }' }
- let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "a new comment with [#{filename}](#{upload_path})") }
- let!(:upload) { create(:upload, :issuable_upload, :with_file, model: note.project, path: path, secret: secret) }
+ context 'with all-user reference in a an external author comment' do
+ let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "Hey @all, just a ping", author: User.support_bot) }
- context 'when total uploads size is more than 10mb' do
- before do
- allow_next_instance_of(FileUploader) do |instance|
- allow(instance).to receive(:size).and_return(10.1.megabytes)
- end
- end
+ let(:template_content) { 'some text %{ NOTE_TEXT }' }
+ let(:expected_template_html) { 'Hey , just a ping' }
- let_it_be(:expected_body) { %Q(some text a new comment with <a href="#{project.web_url}#{upload_path}" data-canonical-src="#{upload_path}" data-link="true" class="gfm">#{filename}</a>) }
+ it_behaves_like 'a service desk notification email with template content', 'new_note'
+ end
+ end
- it_behaves_like 'handle template content', 'new_note'
+ # handle email without and with template in this context to reduce code duplication
+ context 'with upload link in the note' do
+ let_it_be(:secret) { 'e90decf88d8f96fe9e1389afc2e4a91f' }
+ let_it_be(:filename) { 'test.jpg' }
+ let_it_be(:path) { "#{secret}/#{filename}" }
+ let_it_be(:upload_path) { "/uploads/#{path}" }
+ let_it_be(:template_content) { 'some text %{ NOTE_TEXT }' }
+ let_it_be(:expected_text) { "a new comment with [#{filename}](#{upload_path})" }
+ let_it_be(:expected_html) { "a new comment with <strong>#{filename}</strong>" }
+ let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: expected_text) }
+ let!(:upload) { create(:upload, :issuable_upload, :with_file, model: note.project, path: path, secret: secret) }
+
+ context 'when total uploads size is more than 10mb' do
+ before do
+ allow_next_instance_of(FileUploader) do |instance|
+ allow(instance).to receive(:size).and_return(10.1.megabytes)
+ end
end
- context 'when total uploads size is less or equal 10mb' do
- context 'when it has only one upload' do
- before do
- allow_next_instance_of(FileUploader) do |instance|
- allow(instance).to receive(:size).and_return(10.megabytes)
- end
- end
+ let_it_be(:expected_html) { %Q(a new comment with <a href="#{project.web_url}#{upload_path}" data-canonical-src="#{upload_path}" data-link="true" class="gfm">#{filename}</a>) }
+ let_it_be(:expected_template_html) { %Q(some text #{expected_html}) }
- context 'when upload name is not changed in markdown' do
- let_it_be(:expected_body) { %Q(some text a new comment with <strong>#{filename}</strong>) }
+ it_behaves_like 'a service desk notification email'
+ it_behaves_like 'a service desk notification email with template content', 'new_note'
+ end
- it_behaves_like 'handle template content', 'new_note', 1
+ context 'when total uploads size is less or equal 10mb' do
+ context 'when it has only one upload' do
+ before do
+ allow_next_instance_of(FileUploader) do |instance|
+ allow(instance).to receive(:size).and_return(10.megabytes)
+ allow(instance).to receive(:read).and_return('')
end
+ end
- context 'when upload name is changed in markdown' do
- let_it_be(:upload_name_in_markdown) { 'Custom name' }
- let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "a new comment with [#{upload_name_in_markdown}](#{upload_path})") }
- let_it_be(:expected_body) { %Q(some text a new comment with <strong>#{upload_name_in_markdown} (#{filename})</strong>) }
+ context 'when upload name is not changed in markdown' do
+ let_it_be(:expected_template_html) { %Q(some text a new comment with <strong>#{filename}</strong>) }
- it_behaves_like 'handle template content', 'new_note', 1
- end
+ it_behaves_like 'a service desk notification email', 1
+ it_behaves_like 'a service desk notification email with template content', 'new_note', 1
end
- context 'when it has more than one upload' do
+ context 'when upload name is changed in markdown' do
+ let_it_be(:upload_name_in_markdown) { 'Custom name' }
+ let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "a new comment with [#{upload_name_in_markdown}](#{upload_path})") }
+ let_it_be(:expected_text) { %Q(a new comment with [#{upload_name_in_markdown}](#{upload_path})) }
+ let_it_be(:expected_html) { %Q(a new comment with <strong>#{upload_name_in_markdown} (#{filename})</strong>) }
+ let_it_be(:expected_template_html) { %Q(some text #{expected_html}) }
+
+ it_behaves_like 'a service desk notification email', 1
+ it_behaves_like 'a service desk notification email with template content', 'new_note', 1
+ end
+ end
+
+ context 'when it has more than one upload' do
+ let_it_be(:secret_1) { '17817c73e368777e6f743392e334fb8a' }
+ let_it_be(:filename_1) { 'test1.jpg' }
+ let_it_be(:path_1) { "#{secret_1}/#{filename_1}" }
+ let_it_be(:upload_path_1) { "/uploads/#{path_1}" }
+ let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "a new comment with [#{filename}](#{upload_path}) [#{filename_1}](#{upload_path_1})") }
+
+ context 'when all uploads processed correct' do
before do
allow_next_instance_of(FileUploader) do |instance|
allow(instance).to receive(:size).and_return(5.megabytes)
+ allow(instance).to receive(:read).and_return('')
end
end
- let_it_be(:secret_1) { '17817c73e368777e6f743392e334fb8a' }
- let_it_be(:filename_1) { 'test1.jpg' }
- let_it_be(:path_1) { "#{secret_1}/#{filename_1}" }
- let_it_be(:upload_path_1) { "/uploads/#{path_1}" }
- let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "a new comment with [#{filename}](#{upload_path}) [#{filename_1}](#{upload_path_1})") }
+ let_it_be(:upload_1) { create(:upload, :issuable_upload, :with_file, model: note.project, path: path_1, secret: secret_1) }
- context 'when all uploads processed correct' do
- let_it_be(:upload_1) { create(:upload, :issuable_upload, :with_file, model: note.project, path: path_1, secret: secret_1) }
- let_it_be(:expected_body) { %Q(some text a new comment with <strong>#{filename}</strong> <strong>#{filename_1}</strong>) }
+ let_it_be(:expected_html) { %Q(a new comment with <strong>#{filename}</strong> <strong>#{filename_1}</strong>) }
+ let_it_be(:expected_template_html) { %Q(some text #{expected_html}) }
- it_behaves_like 'handle template content', 'new_note', 2
- end
+ it_behaves_like 'a service desk notification email', 2
+ it_behaves_like 'a service desk notification email with template content', 'new_note', 2
+ end
- context 'when not all uploads processed correct' do
- let_it_be(:expected_body) { %Q(some text a new comment with <strong>#{filename}</strong> <a href="#{project.web_url}#{upload_path_1}" data-canonical-src="#{upload_path_1}" data-link="true" class="gfm">#{filename_1}</a>) }
+ context 'when not all uploads processed correct' do
+ let_it_be(:expected_html) { %Q(a new comment with <strong>#{filename}</strong> <a href="#{project.web_url}#{upload_path_1}" data-canonical-src="#{upload_path_1}" data-link="true" class="gfm">#{filename_1}</a>) }
+ let_it_be(:expected_template_html) { %Q(some text #{expected_html}) }
- it_behaves_like 'handle template content', 'new_note', 1
- end
+ it_behaves_like 'a service desk notification email', 1
+ it_behaves_like 'a service desk notification email with template content', 'new_note', 1
end
end
+ end
- context 'when UploaderFinder is raising error' do
- before do
- allow_next_instance_of(UploaderFinder) do |instance|
- allow(instance).to receive(:execute).and_raise(StandardError)
- end
- expect(Gitlab::ErrorTracking).to receive(:track_exception).with(StandardError, project_id: note.project_id)
+ context 'when UploaderFinder is raising error' do
+ before do
+ allow_next_instance_of(UploaderFinder) do |instance|
+ allow(instance).to receive(:execute).and_raise(StandardError)
end
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(StandardError, project_id: note.project_id)
+ end
- let_it_be(:expected_body) { %Q(some text a new comment with <a href="#{project.web_url}#{upload_path}" data-canonical-src="#{upload_path}" data-link="true" class="gfm">#{filename}</a>) }
+ let_it_be(:expected_template_html) { %Q(some text a new comment with <a href="#{project.web_url}#{upload_path}" data-canonical-src="#{upload_path}" data-link="true" class="gfm">#{filename}</a>) }
- it_behaves_like 'handle template content', 'new_note'
- end
+ it_behaves_like 'a service desk notification email with template content', 'new_note'
+ end
- context 'when FileUploader is raising error' do
- before do
- allow_next_instance_of(FileUploader) do |instance|
- allow(instance).to receive(:read).and_raise(StandardError)
- end
- expect(Gitlab::ErrorTracking).to receive(:track_exception).with(StandardError, project_id: note.project_id)
+ context 'when FileUploader is raising error' do
+ before do
+ allow_next_instance_of(FileUploader) do |instance|
+ allow(instance).to receive(:read).and_raise(StandardError)
end
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(StandardError, project_id: note.project_id)
+ end
- let_it_be(:expected_body) { %Q(some text a new comment with <a href="#{project.web_url}#{upload_path}" data-canonical-src="#{upload_path}" data-link="true" class="gfm">#{filename}</a>) }
+ let_it_be(:expected_template_html) { %Q(some text a new comment with <a href="#{project.web_url}#{upload_path}" data-canonical-src="#{upload_path}" data-link="true" class="gfm">#{filename}</a>) }
- it_behaves_like 'handle template content', 'new_note'
- end
+ it_behaves_like 'a service desk notification email with template content', 'new_note'
end
+ end
+ end
- context 'with all-user reference in a an external author comment' do
- let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "Hey @all, just a ping", author: User.support_bot) }
+ describe '.service_desk_custom_email_verification_email' do
+ subject { Notify.service_desk_custom_email_verification_email(service_desk_setting) }
- let(:template_content) { 'some text %{ NOTE_TEXT }' }
- let(:expected_body) { 'Hey , just a ping' }
+ it_behaves_like 'a custom email verification process email'
- it_behaves_like 'handle template content', 'new_note'
- end
+ it 'uses service bot name and custom email as sender' do
+ expect_sender(User.support_bot, sender_email: service_desk_setting.custom_email)
+ end
+
+ it 'forcibly uses SMTP delivery method and has correct settings' do
+ expect_service_desk_custom_email_delivery_options(service_desk_setting)
+ end
+
+ it 'uses verification email address as recipient' do
+ expect(subject.to).to eq([service_desk_setting.custom_email_address_for_verification])
+ end
+
+ it 'contains verification token' do
+ is_expected.to have_body_text("Verification token: #{verification.token}")
+ end
+ end
+
+ describe '.service_desk_verification_triggered_email' do
+ before do
+ service_desk_setting.custom_email_verification.triggerer = user
+ end
+
+ subject { Notify.service_desk_verification_triggered_email(service_desk_setting, 'owner@example.com') }
+
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'a custom email verification process email'
+ it_behaves_like 'a custom email verification process notification email'
+
+ it 'contains triggerer username' do
+ is_expected.to have_body_text("@#{user.username}")
end
end
+
+ describe '.service_desk_verification_result_email' do
+ before do
+ service_desk_setting.custom_email_verification.triggerer = user
+ end
+
+ subject { Notify.service_desk_verification_result_email(service_desk_setting, 'owner@example.com') }
+
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'a custom email verification process email'
+ it_behaves_like 'a custom email verification process notification email'
+ it_behaves_like 'a custom email verification process result email with error', 'smtp_host_issue', 'SMTP host issue'
+ it_behaves_like 'a custom email verification process result email with error', 'invalid_credentials', 'Invalid credentials'
+ it_behaves_like 'a custom email verification process result email with error', 'mail_not_received_within_timeframe', 'Verification email not received within timeframe'
+ it_behaves_like 'a custom email verification process result email with error', 'incorrect_from', 'Incorrect From header'
+ it_behaves_like 'a custom email verification process result email with error', 'incorrect_token', 'Incorrect verification token'
+ end
end
diff --git a/spec/mailers/emails/work_items_spec.rb b/spec/mailers/emails/work_items_spec.rb
new file mode 100644
index 00000000000..eb2c751388d
--- /dev/null
+++ b/spec/mailers/emails/work_items_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'email_spec'
+
+RSpec.describe Emails::WorkItems, feature_category: :team_planning do
+ describe '#export_work_items_csv_email' do
+ let(:user) { build_stubbed(:user) }
+ let(:empty_project) { build_stubbed(:project, path: 'myproject') }
+ let(:export_status) { { truncated: false, rows_expected: 3, rows_written: 3 } }
+ let(:attachment) { subject.attachments.first }
+
+ subject { Notify.export_work_items_csv_email(user, empty_project, "dummy content", export_status) }
+
+ it_behaves_like 'export csv email', 'work_items'
+ end
+end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 7f838e0caf9..c2c32abbdc4 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -21,19 +21,25 @@ RSpec.describe Notify do
let_it_be(:reviewer, reload: true) { create(:user, email: 'reviewer@example.com', name: 'Jane Doe') }
let_it_be(:merge_request) do
- create(:merge_request, source_project: project,
- target_project: project,
- author: current_user,
- assignees: [assignee],
- reviewers: [reviewer],
- description: 'Awesome description')
+ create(
+ :merge_request,
+ source_project: project,
+ target_project: project,
+ author: current_user,
+ assignees: [assignee],
+ reviewers: [reviewer],
+ description: 'Awesome description'
+ )
end
let_it_be(:issue, reload: true) do
- create(:issue, author: current_user,
- assignees: [assignee],
- project: project,
- description: 'My awesome description!')
+ create(
+ :issue,
+ author: current_user,
+ assignees: [assignee],
+ project: project,
+ description: 'My awesome description!'
+ )
end
describe 'with HTML-encoded entities' do
@@ -78,7 +84,7 @@ RSpec.describe Notify do
end
end
- context 'for issues' do
+ context 'for issues', feature_category: :team_planning do
describe 'that are new' do
subject { described_class.new_issue_email(issue.assignees.first.id, issue.id) }
@@ -143,6 +149,8 @@ RSpec.describe Notify do
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
it 'is sent as the author' do
expect_sender(current_user)
@@ -151,9 +159,34 @@ RSpec.describe Notify do
it 'has the correct subject and body' do
aggregate_failures do
is_expected.to have_referable_subject(issue, reply: true)
- is_expected.to have_body_text(previous_assignee.name)
- is_expected.to have_body_text(assignee.name)
- is_expected.to have_body_text(project_issue_path(project, issue))
+ is_expected.to have_body_text("Assignee changed from <strong>#{previous_assignee.name}</strong> to <strong>#{assignee.name}</strong>")
+ is_expected.to have_plain_text_content("Assignee changed from #{previous_assignee.name} to #{assignee.name}")
+ end
+ end
+
+ context 'without new assignee' do
+ before do
+ issue.update!(assignees: [])
+ end
+
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
+ it 'uses "Unassigned" placeholder' do
+ is_expected.to have_body_text("Assignee changed from <strong>#{previous_assignee.name}</strong> to <strong>Unassigned</strong>")
+ is_expected.to have_plain_text_content("Assignee changed from #{previous_assignee.name} to Unassigned")
+ end
+ end
+
+ context 'without previous assignees' do
+ subject { described_class.reassigned_issue_email(recipient.id, issue.id, [], current_user.id) }
+
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
+ it 'uses short text' do
+ is_expected.to have_body_text("Assignee changed to <strong>#{assignee.name}</strong>")
+ is_expected.to have_plain_text_content("Assignee changed to #{assignee.name}")
end
end
@@ -270,6 +303,81 @@ RSpec.describe Notify do
end
end
+ describe 'closed' do
+ subject { described_class.closed_issue_email(recipient.id, issue.id, current_user.id) }
+
+ it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
+ let(:model) { issue }
+ end
+
+ it_behaves_like 'it should show Gmail Actions View Issue link'
+ it_behaves_like 'an unsubscribeable thread'
+ it_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
+ it 'is sent as the author' do
+ expect_sender(current_user)
+ end
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(issue, reply: true)
+ is_expected.to have_body_text("Issue was closed by #{current_user_sanitized}")
+ is_expected.to have_plain_text_content("Issue was closed by #{current_user_sanitized}")
+ end
+ end
+
+ context 'via commit' do
+ let(:closing_commit) { project.commit }
+
+ subject { described_class.closed_issue_email(recipient.id, issue.id, current_user.id, closed_via: closing_commit.id) }
+
+ before do
+ allow(Ability).to receive(:allowed?).with(recipient, :mark_note_as_internal, anything).and_return(true)
+ allow(Ability).to receive(:allowed?).with(recipient, :download_code, project).and_return(true)
+ end
+
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(issue, reply: true)
+ is_expected.to have_body_text("Issue was closed by #{current_user_sanitized} via #{closing_commit.id}")
+ is_expected.to have_plain_text_content("Issue was closed by #{current_user_sanitized} via #{closing_commit.id}")
+ end
+ end
+ end
+
+ context 'via merge request' do
+ let(:closing_merge_request) { merge_request }
+
+ subject { described_class.closed_issue_email(recipient.id, issue.id, current_user.id, closed_via: closing_merge_request) }
+
+ before do
+ allow(Ability).to receive(:allowed?).with(recipient, :read_cross_project, :global).and_return(true)
+ allow(Ability).to receive(:allowed?).with(recipient, :mark_note_as_internal, anything).and_return(true)
+ allow(Ability).to receive(:allowed?).with(recipient, :read_merge_request, anything).and_return(true)
+ end
+
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ url = project_merge_request_url(project, closing_merge_request)
+ is_expected.to have_referable_subject(issue, reply: true)
+ is_expected.to have_body_text("Issue was closed by #{current_user_sanitized} via merge request " +
+ %(<a href="#{url}">#{closing_merge_request.to_reference}</a>))
+ is_expected.to have_plain_text_content("Issue was closed by #{current_user_sanitized} via merge request " \
+ "#{closing_merge_request.to_reference} (#{url})")
+ end
+ end
+ end
+ end
+
describe 'moved to another project' do
let(:new_issue) { create(:issue) }
@@ -722,9 +830,11 @@ RSpec.describe Notify do
end
it 'has References header including the notes and issue of the discussion' do
- expect(subject.header['References'].message_ids).to include("issue_#{first_note.noteable.id}@#{host}",
- "note_#{first_note.id}@#{host}",
- "note_#{second_note.id}@#{host}")
+ expect(subject.header['References'].message_ids).to include(
+ "issue_#{first_note.noteable.id}@#{host}",
+ "note_#{first_note.id}@#{host}",
+ "note_#{second_note.id}@#{host}"
+ )
end
it 'has X-GitLab-Discussion-ID header' do
@@ -797,9 +907,11 @@ RSpec.describe Notify do
end
it 'links to the project snippet' do
- target_url = project_snippet_url(project,
- project_snippet_note.noteable,
- { anchor: "note_#{project_snippet_note.id}" })
+ target_url = project_snippet_url(
+ project,
+ project_snippet_note.noteable,
+ { anchor: "note_#{project_snippet_note.id}" }
+ )
is_expected.to have_body_text target_url
end
end
@@ -808,9 +920,7 @@ RSpec.describe Notify do
let_it_be(:design) { create(:design, :with_file) }
let_it_be(:recipient) { create(:user) }
let_it_be(:note) do
- create(:diff_note_on_design,
- noteable: design,
- note: "Hello #{recipient.to_reference}")
+ create(:diff_note_on_design, noteable: design, note: "Hello #{recipient.to_reference}")
end
let(:header_name) { 'X-Gitlab-DesignManagement-Design-ID' }
@@ -976,9 +1086,10 @@ RSpec.describe Notify do
is_expected.to have_body_text project.full_name
is_expected.to have_body_text project_member.human_access.downcase
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))
+ is_expected.to have_link(
+ 'Join now',
+ href: invite_url(project_member.invite_token, 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?")
@@ -994,9 +1105,10 @@ RSpec.describe Notify do
is_expected.to have_body_text project.full_name
is_expected.to have_body_text project_member.human_access.downcase
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))
+ is_expected.to have_link(
+ 'Join now',
+ href: invite_url(project_member.invite_token, invite_type: Emails::Members::INITIAL_INVITE)
+ )
is_expected.to have_content('Project details')
is_expected.to have_content("What's it about?")
end
@@ -1406,7 +1518,7 @@ RSpec.describe Notify do
issue.issue_email_participants.create!(email: 'service.desk@example.com')
end
- describe 'thank you email' do
+ describe 'thank you email', feature_category: :service_desk do
subject { described_class.service_desk_thank_you_email(issue.id) }
it_behaves_like 'an unsubscribeable thread'
@@ -1459,16 +1571,19 @@ RSpec.describe Notify do
end
context 'when custom email is enabled' do
+ let_it_be(:credentials) do
+ create(
+ :service_desk_custom_email_credential,
+ project: project
+ )
+ end
+
let_it_be(:settings) do
create(
:service_desk_setting,
project: project,
custom_email_enabled: true,
- custom_email: 'supersupport@example.com',
- custom_email_smtp_address: 'smtp.example.com',
- custom_email_smtp_port: 587,
- custom_email_smtp_username: 'supersupport@example.com',
- custom_email_smtp_password: 'supersecret'
+ custom_email: 'supersupport@example.com'
)
end
@@ -1483,7 +1598,7 @@ RSpec.describe Notify do
end
end
- describe 'new note email' do
+ describe 'new note email', feature_category: :service_desk do
let_it_be(:first_note) { create(:discussion_note_on_issue, note: 'Hello world') }
subject { described_class.service_desk_new_note_email(issue.id, first_note.id, 'service.desk@example.com') }
@@ -1520,16 +1635,19 @@ RSpec.describe Notify do
end
context 'when custom email is enabled' do
+ let_it_be(:credentials) do
+ create(
+ :service_desk_custom_email_credential,
+ project: project
+ )
+ end
+
let_it_be(:settings) do
create(
:service_desk_setting,
project: project,
custom_email_enabled: true,
- custom_email: 'supersupport@example.com',
- custom_email_smtp_address: 'smtp.example.com',
- custom_email_smtp_port: 587,
- custom_email_smtp_username: 'supersupport@example.com',
- custom_email_smtp_password: 'supersecret'
+ custom_email: 'supersupport@example.com'
)
end
@@ -2343,21 +2461,4 @@ RSpec.describe Notify do
expect(mail.body.parts.first.to_s).to include('Start a GitLab Ultimate trial today in less than one minute, no credit card required.')
end
end
-
- def expect_sender(user, sender_email: nil)
- sender = subject.header[:from].addrs[0]
- expect(sender.display_name).to eq("#{user.name} (@#{user.username})")
- expect(sender.address).to eq(sender_email.presence || gitlab_sender)
- end
-
- def expect_service_desk_custom_email_delivery_options(service_desk_setting)
- expect(subject.delivery_method).to be_a Mail::SMTP
- expect(subject.delivery_method.settings).to include(
- address: service_desk_setting.custom_email_smtp_address,
- port: service_desk_setting.custom_email_smtp_port,
- user_name: service_desk_setting.custom_email_smtp_username,
- password: service_desk_setting.custom_email_smtp_password,
- domain: service_desk_setting.custom_email.split('@').last
- )
- end
end
diff --git a/spec/metrics_server/metrics_server_spec.rb b/spec/metrics_server/metrics_server_spec.rb
index efa716754f1..ad80835549f 100644
--- a/spec/metrics_server/metrics_server_spec.rb
+++ b/spec/metrics_server/metrics_server_spec.rb
@@ -99,7 +99,7 @@ RSpec.describe MetricsServer, feature_category: :application_performance do # ru
context 'for Golang server' do
let(:log_enabled) { false }
let(:settings) do
- Settingslogic.new(
+ GitlabSettings::Options.build(
{
'web_exporter' => {
'enabled' => true,
@@ -304,7 +304,7 @@ RSpec.describe MetricsServer, feature_category: :application_performance do # ru
end
context 'for sidekiq' do
- let(:settings) { Settingslogic.new({ "sidekiq_exporter" => { "enabled" => true } }) }
+ let(:settings) { GitlabSettings::Options.build({ "sidekiq_exporter" => { "enabled" => true } }) }
before do
allow(::Settings).to receive(:monitoring).and_return(settings)
diff --git a/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb b/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb
deleted file mode 100644
index 4c7ef9ac1e8..00000000000
--- a/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe UpsertBaseWorkItemTypes, :migration, feature_category: :team_planning do
- include MigrationHelpers::WorkItemTypesHelper
-
- let!(:work_item_types) { table(:work_item_types) }
-
- let(:base_types) do
- {
- issue: 0,
- incident: 1,
- test_case: 2,
- requirement: 3
- }
- end
-
- append_after(:all) do
- # Make sure base types are recreated after running the migration
- # because migration specs are not run in a transaction
- reset_work_item_types
- 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
- work_item_types.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(base_types.values)
- }
- end
- end
- end
-
- context 'when default types already exist' do
- it 'does not create default types again' do
- # Database needs to be in a similar state as when this migration was created
- work_item_types.delete_all
- work_item_types.find_or_create_by!(name: 'Issue', namespace_id: nil, base_type: base_types[:issue], icon_name: 'issue-type-issue')
- work_item_types.find_or_create_by!(name: 'Incident', namespace_id: nil, base_type: base_types[:incident], icon_name: 'issue-type-incident')
- work_item_types.find_or_create_by!(name: 'Test Case', namespace_id: nil, base_type: base_types[:test_case], icon_name: 'issue-type-test-case')
- work_item_types.find_or_create_by!(name: 'Requirement', namespace_id: nil, base_type: base_types[:requirement], icon_name: 'issue-type-requirements')
-
- reversible_migration do |migration|
- migration.before -> {
- expect(work_item_types.all.pluck(:base_type)).to match_array(base_types.values)
- }
-
- migration.after -> {
- expect(work_item_types.count).to eq(4)
- expect(work_item_types.all.pluck(:base_type)).to match_array(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
deleted file mode 100644
index 0d89851cac1..00000000000
--- a/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildNeeds, feature_category: :pipeline_authoring 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
deleted file mode 100644
index eef4c7bc9fd..00000000000
--- a/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildTraceChunks, feature_category: :continuous_integration 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/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb b/spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb
deleted file mode 100644
index 208cbac2ae9..00000000000
--- a/spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe DropTemporaryColumnsAndTriggersForTaggings, feature_category: :continuous_integration do
- let(:taggings_table) { table(:taggings) }
-
- it 'correctly migrates up and down' do
- reversible_migration do |migration|
- migration.before -> {
- expect(taggings_table.column_names).to include('id_convert_to_bigint')
- expect(taggings_table.column_names).to include('taggable_id_convert_to_bigint')
- }
-
- migration.after -> {
- taggings_table.reset_column_information
- expect(taggings_table.column_names).not_to include('id_convert_to_bigint')
- expect(taggings_table.column_names).not_to include('taggable_id_convert_to_bigint')
- }
- end
- end
-end
diff --git a/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb b/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb
deleted file mode 100644
index 63664803fba..00000000000
--- a/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe CleanupBigintConversionForCiBuildsMetadata, feature_category: :continuous_integration do
- let(:ci_builds_metadata) { table(:ci_builds_metadata) }
-
- it 'correctly migrates up and down' do
- reversible_migration do |migration|
- migration.before -> {
- expect(ci_builds_metadata.column_names).to include('id_convert_to_bigint')
- expect(ci_builds_metadata.column_names).to include('build_id_convert_to_bigint')
- }
-
- migration.after -> {
- ci_builds_metadata.reset_column_information
- expect(ci_builds_metadata.column_names).not_to include('id_convert_to_bigint')
- expect(ci_builds_metadata.column_names).not_to include('build_id_convert_to_bigint')
- }
- end
- end
-end
diff --git a/spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb b/spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb
deleted file mode 100644
index 663b90f3fa7..00000000000
--- a/spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe FinalizeCiBuildsBigintConversion, :migration, schema: 20210907182359, feature_category: :continuous_integration do
- context 'with an unexpected FK fk_3f0c88d7dc' do
- it 'removes the FK and migrates successfully' do
- # Add the unexpected FK
- subject.add_foreign_key(:ci_sources_pipelines, :ci_builds, column: :source_job_id, name: 'fk_3f0c88d7dc')
-
- expect { migrate! }.to change { subject.foreign_key_exists?(:ci_sources_pipelines, :ci_builds, column: :source_job_id, name: 'fk_3f0c88d7dc') }.from(true).to(false)
-
- # Additional check: The actually expected FK should still exist
- expect(subject.foreign_key_exists?(:ci_sources_pipelines, :ci_builds, column: :source_job_id, name: 'fk_be5624bf37')).to be_truthy
- end
- end
-end
diff --git a/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb b/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb
deleted file mode 100644
index e9d34fad76d..00000000000
--- a/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe UpdateReportTypeForExistingApprovalProjectRules, :migration, feature_category: :source_code_management do
- using RSpec::Parameterized::TableSyntax
-
- let(:group) { table(:namespaces).create!(name: 'user', path: 'user') }
- let(:project) { table(:projects).create!(namespace_id: group.id) }
- let(:approval_project_rule) { table(:approval_project_rules).create!(name: rule_name, rule_type: rule_type, project_id: project.id) }
- let(:rule_type) { 2 }
- let(:rule_name) { 'Vulnerability-Check' }
-
- context 'with rule_type set to :report_approver' do
- where(:rule_name, :report_type) do
- [
- ['Vulnerability-Check', 1],
- ['License-Check', 2],
- ['Coverage-Check', 3]
- ]
- end
-
- with_them do
- context "with names associated with report type" do
- it 'updates report_type' do
- expect { migrate! }.to change { approval_project_rule.reload.report_type }.from(nil).to(report_type)
- end
- end
- end
- end
-
- context 'with rule_type set to another value (e.g., :regular)' do
- let(:rule_type) { 0 }
-
- it 'does not update report_type' do
- expect { migrate! }.not_to change { approval_project_rule.reload.report_type }
- end
- end
-
- context 'with the rule name set to another value (e.g., Test Rule)' do
- let(:rule_name) { 'Test Rule' }
-
- it 'does not update report_type' do
- expect { migrate! }.not_to change { approval_project_rule.reload.report_type }
- end
- end
-end
diff --git a/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb b/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb
deleted file mode 100644
index a198ae9e473..00000000000
--- a/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe CleanupOrphanProjectAccessTokens, :migration, feature_category: :user_profile do
- def create_user(**extra_options)
- defaults = { state: 'active', projects_limit: 0, email: "#{extra_options[:username]}@example.com" }
-
- table(:users).create!(defaults.merge(extra_options))
- end
-
- def create_membership(**extra_options)
- defaults = { access_level: 30, notification_level: 0, source_id: 1, source_type: 'Project' }
-
- table(:members).create!(defaults.merge(extra_options))
- end
-
- let!(:regular_user) { create_user(username: 'regular') }
- let!(:orphan_bot) { create_user(username: 'orphaned_bot', user_type: 6) }
- let!(:used_bot) do
- create_user(username: 'used_bot', user_type: 6).tap do |bot|
- create_membership(user_id: bot.id)
- end
- end
-
- it 'marks all bots without memberships as deactivated' do
- expect do
- migrate!
- regular_user.reload
- orphan_bot.reload
- used_bot.reload
- end.to change {
- [regular_user.state, orphan_bot.state, used_bot.state]
- }.from(%w[active active active]).to(%w[active deactivated active])
- end
-
- it 'schedules for deletion all bots without memberships' do
- job_class = 'DeleteUserWorker'.safe_constantize
-
- if job_class
- expect(job_class).to receive(:bulk_perform_async).with([[orphan_bot.id, orphan_bot.id, skip_authorization: true]])
-
- migrate!
- end
- end
-end
diff --git a/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb b/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb
deleted file mode 100644
index 808c5371018..00000000000
--- a/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe CleanupBigintConversionForCiBuilds, feature_category: :continuous_integration do
- let(:ci_builds) { table(:ci_builds) }
-
- it 'correctly migrates up and down' do
- reversible_migration do |migration|
- migration.before -> {
- expect(ci_builds.column_names).to include('id_convert_to_bigint')
- expect(ci_builds.column_names).to include('stage_id_convert_to_bigint')
- }
-
- migration.after -> {
- ci_builds.reset_column_information
- expect(ci_builds.column_names).not_to include('id_convert_to_bigint')
- expect(ci_builds.column_names).not_to include('stage_id_convert_to_bigint')
- }
- end
- end
-end
diff --git a/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb
deleted file mode 100644
index b3d1b41c330..00000000000
--- a/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-def create_background_migration_jobs(ids, status, created_at)
- proper_status = case status
- when :pending
- Gitlab::Database::BackgroundMigrationJob.statuses['pending']
- when :succeeded
- Gitlab::Database::BackgroundMigrationJob.statuses['succeeded']
- else
- raise ArgumentError
- end
-
- background_migration_jobs.create!(
- class_name: 'RecalculateVulnerabilitiesOccurrencesUuid',
- arguments: Array(ids),
- status: proper_status,
- created_at: created_at
- )
-end
-
-RSpec.describe RemoveOldPendingJobsForRecalculateVulnerabilitiesOccurrencesUuid, :migration,
-feature_category: :vulnerability_management do
- let!(:background_migration_jobs) { table(:background_migration_jobs) }
- let!(:before_target_date) { -Float::INFINITY..(DateTime.new(2021, 8, 17, 23, 59, 59)) }
- let!(:after_target_date) { (DateTime.new(2021, 8, 18, 0, 0, 0))..Float::INFINITY }
-
- context 'when old RecalculateVulnerabilitiesOccurrencesUuid jobs are pending' do
- before do
- create_background_migration_jobs([1, 2, 3], :succeeded, DateTime.new(2021, 5, 5, 0, 2))
- create_background_migration_jobs([4, 5, 6], :pending, DateTime.new(2021, 5, 5, 0, 4))
-
- create_background_migration_jobs([1, 2, 3], :succeeded, DateTime.new(2021, 8, 18, 0, 0))
- create_background_migration_jobs([4, 5, 6], :pending, DateTime.new(2021, 8, 18, 0, 2))
- create_background_migration_jobs([7, 8, 9], :pending, DateTime.new(2021, 8, 18, 0, 4))
- end
-
- it 'removes old, pending jobs' do
- migrate!
-
- expect(background_migration_jobs.where(created_at: before_target_date).count).to eq(1)
- expect(background_migration_jobs.where(created_at: after_target_date).count).to eq(3)
- end
- end
-end
diff --git a/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb b/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb
deleted file mode 100644
index c463f69c80c..00000000000
--- a/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe DropInt4ColumnsForCiJobArtifacts, feature_category: :build_artifacts do
- let(:ci_job_artifacts) { table(:ci_job_artifacts) }
-
- it 'correctly migrates up and down' do
- reversible_migration do |migration|
- migration.before -> {
- expect(ci_job_artifacts.column_names).to include('id_convert_to_bigint')
- expect(ci_job_artifacts.column_names).to include('job_id_convert_to_bigint')
- }
-
- migration.after -> {
- ci_job_artifacts.reset_column_information
- expect(ci_job_artifacts.column_names).not_to include('id_convert_to_bigint')
- expect(ci_job_artifacts.column_names).not_to include('job_id_convert_to_bigint')
- }
- end
- end
-end
diff --git a/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb b/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb
deleted file mode 100644
index 6b0c3a6db9a..00000000000
--- a/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe DropInt4ColumnForCiSourcesPipelines, feature_category: :pipeline_authoring do
- let(:ci_sources_pipelines) { table(:ci_sources_pipelines) }
-
- it 'correctly migrates up and down' do
- reversible_migration do |migration|
- migration.before -> {
- expect(ci_sources_pipelines.column_names).to include('source_job_id_convert_to_bigint')
- }
-
- migration.after -> {
- ci_sources_pipelines.reset_column_information
- expect(ci_sources_pipelines.column_names).not_to include('source_job_id_convert_to_bigint')
- }
- end
- end
-end
diff --git a/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb b/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb
deleted file mode 100644
index 49cf1a01f2a..00000000000
--- a/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe DropInt4ColumnForEvents, feature_category: :user_profile do
- let(:events) { table(:events) }
-
- it 'correctly migrates up and down' do
- reversible_migration do |migration|
- migration.before -> {
- expect(events.column_names).to include('id_convert_to_bigint')
- }
-
- migration.after -> {
- events.reset_column_information
- expect(events.column_names).not_to include('id_convert_to_bigint')
- }
- end
- end
-end
diff --git a/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb b/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb
deleted file mode 100644
index 3e241438339..00000000000
--- a/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe DropInt4ColumnForPushEventPayloads, feature_category: :user_profile do
- let(:push_event_payloads) { table(:push_event_payloads) }
-
- it 'correctly migrates up and down' do
- reversible_migration do |migration|
- migration.before -> {
- expect(push_event_payloads.column_names).to include('event_id_convert_to_bigint')
- }
-
- migration.after -> {
- push_event_payloads.reset_column_information
- expect(push_event_payloads.column_names).not_to include('event_id_convert_to_bigint')
- }
- end
- end
-end
diff --git a/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb b/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb
deleted file mode 100644
index 2f3903a20a9..00000000000
--- a/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe SchedulePopulateTopicsTotalProjectsCountCache, feature_category: :projects do
- let(:topics) { table(:topics) }
- let!(:topic_1) { topics.create!(name: 'Topic1') }
- let!(:topic_2) { topics.create!(name: 'Topic2') }
- let!(:topic_3) { topics.create!(name: 'Topic3') }
-
- describe '#up' do
- before do
- stub_const("#{described_class}::BATCH_SIZE", 2)
- 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, topic_1.id, topic_2.id)
- expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, topic_3.id, topic_3.id)
- expect(BackgroundMigrationWorker.jobs.size).to eq(2)
- end
- end
- end
- end
-end
diff --git a/spec/migrations/20211012134316_clean_up_migrate_merge_request_diff_commit_users_spec.rb b/spec/migrations/20211012134316_clean_up_migrate_merge_request_diff_commit_users_spec.rb
deleted file mode 100644
index a61e450d9ab..00000000000
--- a/spec/migrations/20211012134316_clean_up_migrate_merge_request_diff_commit_users_spec.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration! 'clean_up_migrate_merge_request_diff_commit_users'
-
-RSpec.describe CleanUpMigrateMergeRequestDiffCommitUsers, :migration, feature_category: :code_review_workflow do
- describe '#up' do
- context 'when there are pending jobs' do
- it 'processes the jobs immediately' do
- Gitlab::Database::BackgroundMigrationJob.create!(
- class_name: 'MigrateMergeRequestDiffCommitUsers',
- status: :pending,
- arguments: [10, 20]
- )
-
- spy = Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers
- migration = described_class.new
-
- allow(Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers)
- .to receive(:new)
- .and_return(spy)
-
- expect(migration).to receive(:say)
- expect(spy).to receive(:perform).with(10, 20)
-
- migration.up
- end
- end
-
- context 'when all jobs are completed' do
- it 'does nothing' do
- Gitlab::Database::BackgroundMigrationJob.create!(
- class_name: 'MigrateMergeRequestDiffCommitUsers',
- status: :succeeded,
- arguments: [10, 20]
- )
-
- migration = described_class.new
-
- expect(migration).not_to receive(:say)
- expect(Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers)
- .not_to receive(:new)
-
- migration.up
- end
- end
- end
-end
diff --git a/spec/migrations/20211018152654_schedule_remove_duplicate_vulnerabilities_findings3_spec.rb b/spec/migrations/20211018152654_schedule_remove_duplicate_vulnerabilities_findings3_spec.rb
deleted file mode 100644
index 3e8176a36a1..00000000000
--- a/spec/migrations/20211018152654_schedule_remove_duplicate_vulnerabilities_findings3_spec.rb
+++ /dev/null
@@ -1,166 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-require_migration!('schedule_remove_duplicate_vulnerabilities_findings3')
-
-RSpec.describe ScheduleRemoveDuplicateVulnerabilitiesFindings3, :migration, feature_category: :vulnerability_management do
- let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
- let(:users) { table(:users) }
- let(:user) { create_user! }
- let(:project) { table(:projects).create!(id: 14219619, namespace_id: namespace.id) }
- let(:scanners) { table(:vulnerability_scanners) }
- let!(:scanner1) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
- let!(:scanner2) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') }
- let!(:scanner3) { scanners.create!(project_id: project.id, external_id: 'test 3', name: 'test scanner 3') }
- let!(:unrelated_scanner) { scanners.create!(project_id: project.id, external_id: 'unreleated_scanner', name: 'unrelated scanner') }
- let(:vulnerabilities) { table(:vulnerabilities) }
- let(:vulnerability_findings) { table(:vulnerability_occurrences) }
- let(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
- let(:vulnerability_identifier) do
- vulnerability_identifiers.create!(
- id: 1244459,
- project_id: project.id,
- external_type: 'vulnerability-identifier',
- external_id: 'vulnerability-identifier',
- fingerprint: '0a203e8cd5260a1948edbedc76c7cb91ad6a2e45',
- name: 'vulnerability identifier')
- end
-
- let!(:vulnerability_for_first_duplicate) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:first_finding_duplicate) do
- create_finding!(
- id: 5606961,
- uuid: "bd95c085-71aa-51d7-9bb6-08ae669c262e",
- vulnerability_id: vulnerability_for_first_duplicate.id,
- report_type: 0,
- location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner1.id,
- project_id: project.id
- )
- end
-
- let!(:vulnerability_for_second_duplicate) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:second_finding_duplicate) do
- create_finding!(
- id: 8765432,
- uuid: "5b714f58-1176-5b26-8fd5-e11dfcb031b5",
- vulnerability_id: vulnerability_for_second_duplicate.id,
- report_type: 0,
- location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner2.id,
- project_id: project.id
- )
- end
-
- let!(:vulnerability_for_third_duplicate) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:third_finding_duplicate) do
- create_finding!(
- id: 8832995,
- uuid: "cfe435fa-b25b-5199-a56d-7b007cc9e2d4",
- vulnerability_id: vulnerability_for_third_duplicate.id,
- report_type: 0,
- location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner3.id,
- project_id: project.id
- )
- end
-
- let!(:unrelated_finding) do
- create_finding!(
- id: 9999999,
- vulnerability_id: nil,
- report_type: 1,
- location_fingerprint: 'random_location_fingerprint',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: unrelated_scanner.id,
- project_id: project.id
- )
- end
-
- before do
- stub_const("#{described_class}::BATCH_SIZE", 1)
- end
-
- around do |example|
- freeze_time { Sidekiq::Testing.fake! { example.run } }
- end
-
- it 'schedules background migration' do
- migrate!
-
- expect(BackgroundMigrationWorker.jobs.size).to eq(4)
- expect(described_class::MIGRATION).to be_scheduled_migration(first_finding_duplicate.id, first_finding_duplicate.id)
- expect(described_class::MIGRATION).to be_scheduled_migration(second_finding_duplicate.id, second_finding_duplicate.id)
- expect(described_class::MIGRATION).to be_scheduled_migration(third_finding_duplicate.id, third_finding_duplicate.id)
- expect(described_class::MIGRATION).to be_scheduled_migration(unrelated_finding.id, unrelated_finding.id)
- end
-
- private
-
- def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0)
- vulnerabilities.create!(
- project_id: project_id,
- author_id: author_id,
- title: title,
- severity: severity,
- confidence: confidence,
- report_type: report_type
- )
- end
-
- # rubocop:disable Metrics/ParameterLists
- def create_finding!(
- vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, id: nil,
- name: "test", severity: 7, confidence: 7, report_type: 0,
- project_fingerprint: '123qweasdzxc', location_fingerprint: 'test',
- metadata_version: 'test', raw_metadata: 'test', uuid: SecureRandom.uuid)
- vulnerability_findings.create!({
- id: id,
- vulnerability_id: vulnerability_id,
- project_id: project_id,
- name: name,
- severity: severity,
- confidence: confidence,
- report_type: report_type,
- project_fingerprint: project_fingerprint,
- scanner_id: scanner_id,
- primary_identifier_id: vulnerability_identifier.id,
- location_fingerprint: location_fingerprint,
- metadata_version: metadata_version,
- raw_metadata: raw_metadata,
- uuid: uuid
- }.compact)
- end
- # rubocop:enable Metrics/ParameterLists
-
- def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.zone.now, confirmed_at: Time.zone.now)
- users.create!(
- name: name,
- email: email,
- username: name,
- projects_limit: 0,
- user_type: user_type,
- confirmed_at: confirmed_at
- )
- end
-end
diff --git a/spec/migrations/20211028155449_schedule_fix_merge_request_diff_commit_users_migration_spec.rb b/spec/migrations/20211028155449_schedule_fix_merge_request_diff_commit_users_migration_spec.rb
deleted file mode 100644
index 968d9cf176c..00000000000
--- a/spec/migrations/20211028155449_schedule_fix_merge_request_diff_commit_users_migration_spec.rb
+++ /dev/null
@@ -1,63 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration! 'schedule_fix_merge_request_diff_commit_users_migration'
-
-RSpec.describe ScheduleFixMergeRequestDiffCommitUsersMigration, :migration, feature_category: :code_review_workflow do
- let(:migration) { described_class.new }
- let(:namespaces) { table(:namespaces) }
- let(:projects) { table(:projects) }
- let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
-
- describe '#up' do
- it 'does nothing when there are no projects to correct' do
- migration.up
-
- expect(Gitlab::Database::BackgroundMigrationJob.count).to be_zero
- end
-
- it 'schedules imported projects created after July' do
- project = projects.create!(
- namespace_id: namespace.id,
- import_type: 'gitlab_project',
- created_at: '2021-08-01'
- )
-
- expect(migration)
- .to receive(:migrate_in)
- .with(2.minutes, 'FixMergeRequestDiffCommitUsers', [project.id])
-
- migration.up
-
- expect(Gitlab::Database::BackgroundMigrationJob.count).to eq(1)
-
- job = Gitlab::Database::BackgroundMigrationJob.first
-
- expect(job.class_name).to eq('FixMergeRequestDiffCommitUsers')
- expect(job.arguments).to eq([project.id])
- end
-
- it 'ignores projects imported before July' do
- projects.create!(
- namespace_id: namespace.id,
- import_type: 'gitlab_project',
- created_at: '2020-08-01'
- )
-
- migration.up
-
- expect(Gitlab::Database::BackgroundMigrationJob.count).to be_zero
- end
-
- it 'ignores projects that are not imported' do
- projects.create!(
- namespace_id: namespace.id,
- created_at: '2021-08-01'
- )
-
- migration.up
-
- expect(Gitlab::Database::BackgroundMigrationJob.count).to be_zero
- end
- end
-end
diff --git a/spec/migrations/20211101222614_consume_remaining_user_namespace_jobs_spec.rb b/spec/migrations/20211101222614_consume_remaining_user_namespace_jobs_spec.rb
deleted file mode 100644
index 1688ebf7cb1..00000000000
--- a/spec/migrations/20211101222614_consume_remaining_user_namespace_jobs_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe ConsumeRemainingUserNamespaceJobs, feature_category: :subgroups do
- let(:namespaces) { table(:namespaces) }
- let!(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org', type: nil) }
-
- context 'when Namespaces with nil `type` still exist' do
- it 'steals sidekiq jobs from BackfillUserNamespace background migration' do
- expect(Gitlab::BackgroundMigration).to receive(:steal).with('BackfillUserNamespace')
-
- migrate!
- end
-
- it 'migrates namespaces without type' do
- expect { migrate! }.to change { namespaces.where(type: 'User').count }.from(0).to(1)
- end
- end
-end
diff --git a/spec/migrations/20211110143306_add_not_null_constraint_to_security_findings_uuid_spec.rb b/spec/migrations/20211110143306_add_not_null_constraint_to_security_findings_uuid_spec.rb
deleted file mode 100644
index 3b69169b2d6..00000000000
--- a/spec/migrations/20211110143306_add_not_null_constraint_to_security_findings_uuid_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-require_migration!
-
-RSpec.describe AddNotNullConstraintToSecurityFindingsUuid, feature_category: :vulnerability_management do
- let!(:security_findings) { table(:security_findings) }
- let!(:migration) { described_class.new }
-
- before do
- allow(migration).to receive(:transaction_open?).and_return(false)
- allow(migration).to receive(:with_lock_retries).and_yield
- end
-
- it 'adds a check constraint' do
- constraint = security_findings.connection.check_constraints(:security_findings).find { |constraint| constraint.expression == "uuid IS NOT NULL" }
- expect(constraint).to be_nil
-
- migration.up
-
- constraint = security_findings.connection.check_constraints(:security_findings).find { |constraint| constraint.expression == "uuid IS NOT NULL" }
- expect(constraint).to be_a(ActiveRecord::ConnectionAdapters::CheckConstraintDefinition)
- end
-end
diff --git a/spec/migrations/20211110151350_schedule_drop_invalid_security_findings_spec.rb b/spec/migrations/20211110151350_schedule_drop_invalid_security_findings_spec.rb
deleted file mode 100644
index d05828112e6..00000000000
--- a/spec/migrations/20211110151350_schedule_drop_invalid_security_findings_spec.rb
+++ /dev/null
@@ -1,72 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe ScheduleDropInvalidSecurityFindings, :migration, :suppress_gitlab_schemas_validate_connection, schema: 20211108211434,
- feature_category: :vulnerability_management do
- let!(:background_migration_jobs) { table(:background_migration_jobs) }
-
- let!(:namespace) { table(:namespaces).create!(name: 'user', path: 'user', type: Namespaces::UserNamespace.sti_name) }
- let!(:project) { table(:projects).create!(namespace_id: namespace.id) }
-
- let!(:pipelines) { table(:ci_pipelines) }
- let!(:pipeline) { pipelines.create!(project_id: project.id) }
-
- let!(:ci_builds) { table(:ci_builds) }
- let!(:ci_build) { ci_builds.create! }
-
- let!(:security_scans) { table(:security_scans) }
- let!(:security_scan) do
- security_scans.create!(
- scan_type: 1,
- status: 1,
- build_id: ci_build.id,
- project_id: project.id,
- pipeline_id: pipeline.id
- )
- end
-
- let!(:vulnerability_scanners) { table(:vulnerability_scanners) }
- let!(:vulnerability_scanner) { vulnerability_scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
-
- let!(:security_findings) { table(:security_findings) }
- let!(:security_finding_without_uuid) do
- security_findings.create!(
- severity: 1,
- confidence: 1,
- scan_id: security_scan.id,
- scanner_id: vulnerability_scanner.id,
- uuid: nil
- )
- end
-
- let!(:security_finding_with_uuid) do
- security_findings.create!(
- severity: 1,
- confidence: 1,
- scan_id: security_scan.id,
- scanner_id: vulnerability_scanner.id,
- uuid: 'bd95c085-71aa-51d7-9bb6-08ae669c262e'
- )
- end
-
- before do
- stub_const("#{described_class}::BATCH_SIZE", 1)
- stub_const("#{described_class}::SUB_BATCH_SIZE", 1)
- end
-
- around do |example|
- freeze_time { Sidekiq::Testing.fake! { example.run } }
- end
-
- it 'schedules background migrations' do
- migrate!
-
- expect(background_migration_jobs.count).to eq(1)
- expect(background_migration_jobs.first.arguments).to match_array([security_finding_without_uuid.id, security_finding_without_uuid.id, described_class::SUB_BATCH_SIZE])
-
- expect(BackgroundMigrationWorker.jobs.size).to eq(1)
- expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, security_finding_without_uuid.id, security_finding_without_uuid.id, described_class::SUB_BATCH_SIZE)
- end
-end
diff --git a/spec/migrations/20211116091751_change_namespace_type_default_to_user_spec.rb b/spec/migrations/20211116091751_change_namespace_type_default_to_user_spec.rb
deleted file mode 100644
index deba6f9b87c..00000000000
--- a/spec/migrations/20211116091751_change_namespace_type_default_to_user_spec.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-# With https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73495, we no longer allow
-# a Namespace type to be nil. There is nothing left to test for this migration,
-# but we'll keep this file here as a tombstone.
diff --git a/spec/migrations/20211116111644_schedule_remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb b/spec/migrations/20211116111644_schedule_remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb
deleted file mode 100644
index 18513656029..00000000000
--- a/spec/migrations/20211116111644_schedule_remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb
+++ /dev/null
@@ -1,190 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-require_migration!
-
-RSpec.describe ScheduleRemoveOccurrencePipelinesAndDuplicateVulnerabilitiesFindings,
- :suppress_gitlab_schemas_validate_connection, :migration, feature_category: :vulnerability_management do
- let!(:background_migration_jobs) { table(:background_migration_jobs) }
- let!(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
- let!(:users) { table(:users) }
- let!(:user) { create_user! }
- let!(:project) { table(:projects).create!(id: 14219619, namespace_id: namespace.id) }
- let!(:pipelines) { table(:ci_pipelines) }
- let!(:scanners) { table(:vulnerability_scanners) }
- let!(:scanner1) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
- let!(:scanner2) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') }
- let!(:scanner3) { scanners.create!(project_id: project.id, external_id: 'test 3', name: 'test scanner 3') }
- let!(:unrelated_scanner) { scanners.create!(project_id: project.id, external_id: 'unreleated_scanner', name: 'unrelated scanner') }
- let!(:vulnerabilities) { table(:vulnerabilities) }
- let!(:vulnerability_findings) { table(:vulnerability_occurrences) }
- let!(:vulnerability_finding_pipelines) { table(:vulnerability_occurrence_pipelines) }
- let!(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
- let!(:vulnerability_identifier) do
- vulnerability_identifiers.create!(
- id: 1244459,
- project_id: project.id,
- external_type: 'vulnerability-identifier',
- external_id: 'vulnerability-identifier',
- fingerprint: '0a203e8cd5260a1948edbedc76c7cb91ad6a2e45',
- name: 'vulnerability identifier')
- end
-
- let!(:vulnerability_for_first_duplicate) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:first_finding_duplicate) do
- create_finding!(
- id: 5606961,
- uuid: "bd95c085-71aa-51d7-9bb6-08ae669c262e",
- vulnerability_id: vulnerability_for_first_duplicate.id,
- report_type: 0,
- location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner1.id,
- project_id: project.id
- )
- end
-
- let!(:vulnerability_for_second_duplicate) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:second_finding_duplicate) do
- create_finding!(
- id: 8765432,
- uuid: "5b714f58-1176-5b26-8fd5-e11dfcb031b5",
- vulnerability_id: vulnerability_for_second_duplicate.id,
- report_type: 0,
- location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner2.id,
- project_id: project.id
- )
- end
-
- let!(:vulnerability_for_third_duplicate) do
- create_vulnerability!(
- project_id: project.id,
- author_id: user.id
- )
- end
-
- let!(:third_finding_duplicate) do
- create_finding!(
- id: 8832995,
- uuid: "cfe435fa-b25b-5199-a56d-7b007cc9e2d4",
- vulnerability_id: vulnerability_for_third_duplicate.id,
- report_type: 0,
- location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: scanner3.id,
- project_id: project.id
- )
- end
-
- let!(:unrelated_finding) do
- create_finding!(
- id: 9999999,
- vulnerability_id: nil,
- report_type: 1,
- location_fingerprint: 'random_location_fingerprint',
- primary_identifier_id: vulnerability_identifier.id,
- scanner_id: unrelated_scanner.id,
- project_id: project.id
- )
- end
-
- before do
- stub_const("#{described_class}::BATCH_SIZE", 1)
-
- 4.times do
- create_finding_pipeline!(project_id: project.id, finding_id: first_finding_duplicate.id)
- create_finding_pipeline!(project_id: project.id, finding_id: second_finding_duplicate.id)
- create_finding_pipeline!(project_id: project.id, finding_id: third_finding_duplicate.id)
- create_finding_pipeline!(project_id: project.id, finding_id: unrelated_finding.id)
- end
- end
-
- around do |example|
- freeze_time { Sidekiq::Testing.fake! { example.run } }
- end
-
- it 'schedules background migrations' do
- migrate!
-
- expect(background_migration_jobs.count).to eq(4)
- expect(background_migration_jobs.first.arguments).to match_array([first_finding_duplicate.id, first_finding_duplicate.id])
- expect(background_migration_jobs.second.arguments).to match_array([second_finding_duplicate.id, second_finding_duplicate.id])
- expect(background_migration_jobs.third.arguments).to match_array([third_finding_duplicate.id, third_finding_duplicate.id])
- expect(background_migration_jobs.fourth.arguments).to match_array([unrelated_finding.id, unrelated_finding.id])
-
- expect(BackgroundMigrationWorker.jobs.size).to eq(4)
- expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, first_finding_duplicate.id, first_finding_duplicate.id)
- expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, second_finding_duplicate.id, second_finding_duplicate.id)
- expect(described_class::MIGRATION).to be_scheduled_delayed_migration(6.minutes, third_finding_duplicate.id, third_finding_duplicate.id)
- expect(described_class::MIGRATION).to be_scheduled_delayed_migration(8.minutes, unrelated_finding.id, unrelated_finding.id)
- end
-
- private
-
- def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0)
- vulnerabilities.create!(
- project_id: project_id,
- author_id: author_id,
- title: title,
- severity: severity,
- confidence: confidence,
- report_type: report_type
- )
- end
-
- # rubocop:disable Metrics/ParameterLists
- def create_finding!(
- vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, id: nil,
- name: "test", severity: 7, confidence: 7, report_type: 0,
- project_fingerprint: '123qweasdzxc', location_fingerprint: 'test',
- metadata_version: 'test', raw_metadata: 'test', uuid: SecureRandom.uuid)
- params = {
- vulnerability_id: vulnerability_id,
- project_id: project_id,
- name: name,
- severity: severity,
- confidence: confidence,
- report_type: report_type,
- project_fingerprint: project_fingerprint,
- scanner_id: scanner_id,
- primary_identifier_id: vulnerability_identifier.id,
- location_fingerprint: location_fingerprint,
- metadata_version: metadata_version,
- raw_metadata: raw_metadata,
- uuid: uuid
- }
- params[:id] = id unless id.nil?
- vulnerability_findings.create!(params)
- end
- # rubocop:enable Metrics/ParameterLists
-
- def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.zone.now, confirmed_at: Time.zone.now)
- users.create!(
- name: name,
- email: email,
- username: name,
- projects_limit: 0,
- user_type: user_type,
- confirmed_at: confirmed_at
- )
- end
-
- def create_finding_pipeline!(project_id:, finding_id:)
- pipeline = pipelines.create!(project_id: project_id)
- vulnerability_finding_pipelines.create!(pipeline_id: pipeline.id, occurrence_id: finding_id)
- end
-end
diff --git a/spec/migrations/20211117084814_migrate_remaining_u2f_registrations_spec.rb b/spec/migrations/20211117084814_migrate_remaining_u2f_registrations_spec.rb
deleted file mode 100644
index bfe2b661a31..00000000000
--- a/spec/migrations/20211117084814_migrate_remaining_u2f_registrations_spec.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe MigrateRemainingU2fRegistrations, :migration, feature_category: :authentication_and_authorization do
- let(:u2f_registrations) { table(:u2f_registrations) }
- let(:webauthn_registrations) { table(:webauthn_registrations) }
- let(:users) { table(:users) }
-
- let(:user) { users.create!(email: 'email@email.com', name: 'foo', username: 'foo', projects_limit: 0) }
-
- before do
- create_u2f_registration(1, 'reg1')
- create_u2f_registration(2, 'reg2')
- create_u2f_registration(3, '')
- create_u2f_registration(4, nil)
- webauthn_registrations.create!({ name: 'reg1', u2f_registration_id: 1, credential_xid: '', public_key: '', user_id: user.id })
- end
-
- it 'correctly migrates u2f registrations previously not migrated' do
- expect { migrate! }.to change { webauthn_registrations.count }.from(1).to(4)
- end
-
- it 'migrates all valid u2f registrations depite errors' do
- create_u2f_registration(5, 'reg3', 'invalid!')
- create_u2f_registration(6, 'reg4')
-
- expect { migrate! }.to change { webauthn_registrations.count }.from(1).to(5)
- end
-
- def create_u2f_registration(id, name, public_key = nil)
- device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5), { key_handle: SecureRandom.random_bytes(255) })
- public_key ||= Base64.strict_encode64(device.origin_public_key_raw)
- u2f_registrations.create!({ id: id,
- certificate: Base64.strict_encode64(device.cert_raw),
- key_handle: U2F.urlsafe_encode64(device.key_handle_raw),
- public_key: public_key,
- counter: 5,
- name: name,
- user_id: user.id })
- end
-end
diff --git a/spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb b/spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb
deleted file mode 100644
index 09a8bb44d88..00000000000
--- a/spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe EncryptStaticObjectsExternalStorageAuthToken, :migration, feature_category: :source_code_management do
- let(:application_settings) do
- Class.new(ActiveRecord::Base) do
- self.table_name = 'application_settings'
- end
- end
-
- context 'when static_objects_external_storage_auth_token is not set' do
- it 'does nothing' do
- application_settings.create!
-
- reversible_migration do |migration|
- migration.before -> {
- settings = application_settings.first
-
- expect(settings.static_objects_external_storage_auth_token).to be_nil
- expect(settings.static_objects_external_storage_auth_token_encrypted).to be_nil
- }
-
- migration.after -> {
- settings = application_settings.first
-
- expect(settings.static_objects_external_storage_auth_token).to be_nil
- expect(settings.static_objects_external_storage_auth_token_encrypted).to be_nil
- }
- end
- end
- end
-
- context 'when static_objects_external_storage_auth_token is set' do
- it 'encrypts static_objects_external_storage_auth_token' do
- settings = application_settings.create!
- settings.update_column(:static_objects_external_storage_auth_token, 'Test')
-
- reversible_migration do |migration|
- migration.before -> {
- settings = application_settings.first
-
- expect(settings.static_objects_external_storage_auth_token).to eq('Test')
- expect(settings.static_objects_external_storage_auth_token_encrypted).to be_nil
- }
- migration.after -> {
- settings = application_settings.first
-
- expect(settings.static_objects_external_storage_auth_token).to eq('Test')
- expect(settings.static_objects_external_storage_auth_token_encrypted).to be_present
- }
- end
- end
- end
-
- context 'when static_objects_external_storage_auth_token is empty string' do
- it 'does not break' do
- settings = application_settings.create!
- settings.update_column(:static_objects_external_storage_auth_token, '')
-
- reversible_migration do |migration|
- migration.before -> {
- settings = application_settings.first
-
- expect(settings.static_objects_external_storage_auth_token).to eq('')
- expect(settings.static_objects_external_storage_auth_token_encrypted).to be_nil
- }
- migration.after -> {
- settings = application_settings.first
-
- expect(settings.static_objects_external_storage_auth_token).to eq('')
- expect(settings.static_objects_external_storage_auth_token_encrypted).to be_nil
- }
- end
- end
- end
-end
diff --git a/spec/migrations/20211126204445_add_task_to_work_item_types_spec.rb b/spec/migrations/20211126204445_add_task_to_work_item_types_spec.rb
deleted file mode 100644
index db68e895b61..00000000000
--- a/spec/migrations/20211126204445_add_task_to_work_item_types_spec.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe AddTaskToWorkItemTypes, :migration, feature_category: :team_planning do
- include MigrationHelpers::WorkItemTypesHelper
-
- let!(:work_item_types) { table(:work_item_types) }
-
- let(:base_types) do
- {
- issue: 0,
- incident: 1,
- test_case: 2,
- requirement: 3,
- task: 4
- }
- end
-
- append_after(:all) do
- # Make sure base types are recreated after running the migration
- # because migration specs are not run in a transaction
- reset_work_item_types
- end
-
- it 'skips creating the record if it already exists' do
- reset_db_state_prior_to_migration
- work_item_types.find_or_create_by!(name: 'Task', namespace_id: nil, base_type: base_types[:task], icon_name: 'issue-type-task')
-
- expect do
- migrate!
- end.to not_change(work_item_types, :count)
- end
-
- it 'adds task to base work item types' do
- reset_db_state_prior_to_migration
-
- expect do
- migrate!
- end.to change(work_item_types, :count).from(4).to(5)
-
- expect(work_item_types.all.pluck(:base_type)).to include(base_types[:task])
- end
-
- def reset_db_state_prior_to_migration
- # Database needs to be in a similar state as when this migration was created
- work_item_types.delete_all
- work_item_types.find_or_create_by!(name: 'Issue', namespace_id: nil, base_type: base_types[:issue], icon_name: 'issue-type-issue')
- work_item_types.find_or_create_by!(name: 'Incident', namespace_id: nil, base_type: base_types[:incident], icon_name: 'issue-type-incident')
- work_item_types.find_or_create_by!(name: 'Test Case', namespace_id: nil, base_type: base_types[:test_case], icon_name: 'issue-type-test-case')
- work_item_types.find_or_create_by!(name: 'Requirement', namespace_id: nil, base_type: base_types[:requirement], icon_name: 'issue-type-requirements')
- end
-end
diff --git a/spec/migrations/20211130165043_backfill_sequence_column_for_sprints_table_spec.rb b/spec/migrations/20211130165043_backfill_sequence_column_for_sprints_table_spec.rb
deleted file mode 100644
index 91646da4791..00000000000
--- a/spec/migrations/20211130165043_backfill_sequence_column_for_sprints_table_spec.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-require_migration!
-
-RSpec.describe BackfillSequenceColumnForSprintsTable, :migration, schema: 20211126042235, feature_category: :team_planning do
- let(:migration) { described_class.new }
- let(:namespaces) { table(:namespaces) }
- let(:sprints) { table(:sprints) }
- let(:iterations_cadences) { table(:iterations_cadences) }
-
- let!(:group) { namespaces.create!(name: 'foo', path: 'foo') }
- let!(:cadence_1) { iterations_cadences.create!(group_id: group.id, title: "cadence 1") }
- let!(:cadence_2) { iterations_cadences.create!(group_id: group.id, title: "cadence 2") }
- let!(:iteration_1) { sprints.create!(id: 1, group_id: group.id, iterations_cadence_id: cadence_1.id, start_date: Date.new(2021, 11, 1), due_date: Date.new(2021, 11, 5), iid: 1, title: 'a' ) }
- let!(:iteration_2) { sprints.create!(id: 2, group_id: group.id, iterations_cadence_id: cadence_1.id, start_date: Date.new(2021, 12, 1), due_date: Date.new(2021, 12, 5), iid: 2, title: 'b') }
- let!(:iteration_3) { sprints.create!(id: 3, group_id: group.id, iterations_cadence_id: cadence_2.id, start_date: Date.new(2021, 12, 1), due_date: Date.new(2021, 12, 5), iid: 4, title: 'd') }
- let!(:iteration_4) { sprints.create!(id: 4, group_id: group.id, iterations_cadence_id: nil, start_date: Date.new(2021, 11, 15), due_date: Date.new(2021, 11, 20), iid: 3, title: 'c') }
-
- describe '#up' do
- it "correctly sets the sequence attribute with idempotency" do
- migration.up
-
- expect(iteration_1.reload.sequence).to be 1
- expect(iteration_2.reload.sequence).to be 2
- expect(iteration_3.reload.sequence).to be 1
- expect(iteration_4.reload.sequence).to be nil
-
- iteration_5 = sprints.create!(id: 5, group_id: group.id, iterations_cadence_id: cadence_1.id, start_date: Date.new(2022, 1, 1), due_date: Date.new(2022, 1, 5), iid: 1, title: 'e' )
-
- migration.down
- migration.up
-
- expect(iteration_1.reload.sequence).to be 1
- expect(iteration_2.reload.sequence).to be 2
- expect(iteration_5.reload.sequence).to be 3
- expect(iteration_3.reload.sequence).to be 1
- expect(iteration_4.reload.sequence).to be nil
- end
- end
-end
diff --git a/spec/migrations/20211207125331_remove_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/migrations/20211207125331_remove_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb
index be89ee9d2aa..9fa2ac2313a 100644
--- a/spec/migrations/20211207125331_remove_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb
+++ b/spec/migrations/20211207125331_remove_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb
@@ -21,7 +21,7 @@ def create_background_migration_jobs(ids, status, created_at)
end
RSpec.describe RemoveJobsForRecalculateVulnerabilitiesOccurrencesUuid, :migration,
-feature_category: :vulnerability_management do
+ feature_category: :vulnerability_management do
let!(:background_migration_jobs) { table(:background_migration_jobs) }
context 'when RecalculateVulnerabilitiesOccurrencesUuid jobs are present' do
diff --git a/spec/migrations/20220124130028_dedup_runner_projects_spec.rb b/spec/migrations/20220124130028_dedup_runner_projects_spec.rb
index ee468f40908..b9189cbae7f 100644
--- a/spec/migrations/20220124130028_dedup_runner_projects_spec.rb
+++ b/spec/migrations/20220124130028_dedup_runner_projects_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe DedupRunnerProjects, :migration, :suppress_gitlab_schemas_validate_connection,
-schema: 20220120085655, feature_category: :runner do
+ schema: 20220120085655, feature_category: :runner do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:runners) { table(:ci_runners) }
diff --git a/spec/migrations/20220128155251_remove_dangling_running_builds_spec.rb b/spec/migrations/20220128155251_remove_dangling_running_builds_spec.rb
index ea88cf1a2ce..3abe173196f 100644
--- a/spec/migrations/20220128155251_remove_dangling_running_builds_spec.rb
+++ b/spec/migrations/20220128155251_remove_dangling_running_builds_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!('remove_dangling_running_builds')
RSpec.describe RemoveDanglingRunningBuilds, :suppress_gitlab_schemas_validate_connection,
-feature_category: :continuous_integration do
+ feature_category: :continuous_integration do
let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
let(:project) { table(:projects).create!(namespace_id: namespace.id) }
let(:runner) { table(:ci_runners).create!(runner_type: 1) }
diff --git a/spec/migrations/20220307192610_remove_duplicate_project_tag_releases_spec.rb b/spec/migrations/20220307192610_remove_duplicate_project_tag_releases_spec.rb
index 3bdd6e5fab9..98e2ba4816b 100644
--- a/spec/migrations/20220307192610_remove_duplicate_project_tag_releases_spec.rb
+++ b/spec/migrations/20220307192610_remove_duplicate_project_tag_releases_spec.rb
@@ -14,9 +14,7 @@ RSpec.describe RemoveDuplicateProjectTagReleases, feature_category: :release_orc
let(:dup_releases) do
Array.new(4).fill do |i|
- rel = releases.new(project_id: project.id,
- tag: "duplicate tag",
- released_at: (DateTime.now + i.days))
+ rel = releases.new(project_id: project.id, tag: "duplicate tag", released_at: (DateTime.now + i.days))
rel.save!(validate: false)
rel
end
diff --git a/spec/migrations/20220309084954_remove_leftover_external_pull_request_deletions_spec.rb b/spec/migrations/20220309084954_remove_leftover_external_pull_request_deletions_spec.rb
index c0b94313d4d..8df9907643e 100644
--- a/spec/migrations/20220309084954_remove_leftover_external_pull_request_deletions_spec.rb
+++ b/spec/migrations/20220309084954_remove_leftover_external_pull_request_deletions_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
require_migration!
-RSpec.describe RemoveLeftoverExternalPullRequestDeletions, feature_category: :pods do
+RSpec.describe RemoveLeftoverExternalPullRequestDeletions, feature_category: :cell do
let(:deleted_records) { table(:loose_foreign_keys_deleted_records) }
let(:pending_record1) { deleted_records.create!(id: 1, fully_qualified_table_name: 'public.external_pull_requests', primary_key_value: 1, status: 1) }
diff --git a/spec/migrations/20220310141349_remove_dependency_list_usage_data_from_redis_spec.rb b/spec/migrations/20220310141349_remove_dependency_list_usage_data_from_redis_spec.rb
index f40f9c70833..5d9be79e768 100644
--- a/spec/migrations/20220310141349_remove_dependency_list_usage_data_from_redis_spec.rb
+++ b/spec/migrations/20220310141349_remove_dependency_list_usage_data_from_redis_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe RemoveDependencyListUsageDataFromRedis, :migration, :clean_gitlab_redis_shared_state,
-feature_category: :dependency_management do
+ feature_category: :dependency_management do
let(:key) { "DEPENDENCY_LIST_USAGE_COUNTER" }
describe "#up" do
diff --git a/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb b/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb
index 15c16a2b232..6f9e70aa8c8 100644
--- a/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb
+++ b/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb
@@ -11,8 +11,9 @@ RSpec.describe MigrateShimoConfluenceServiceCategory, :migration, feature_catego
before do
namespace = namespaces.create!(name: 'test', path: 'test')
projects.create!(id: 1, namespace_id: namespace.id, name: 'gitlab', path: 'gitlab')
- integrations.create!(id: 1, active: true, type_new: "Integrations::SlackSlashCommands",
- category: 'chat', project_id: 1)
+ integrations.create!(
+ id: 1, active: true, type_new: "Integrations::SlackSlashCommands", category: 'chat', project_id: 1
+ )
integrations.create!(id: 3, active: true, type_new: "Integrations::Confluence", category: 'common', project_id: 1)
integrations.create!(id: 5, active: true, type_new: "Integrations::Shimo", category: 'common', project_id: 1)
end
diff --git a/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb b/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb
index e9bca42f37f..ca2ee6d8aba 100644
--- a/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb
+++ b/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
require_migration!
-RSpec.describe RemoveLeftoverCiJobArtifactDeletions, feature_category: :pods do
+RSpec.describe RemoveLeftoverCiJobArtifactDeletions, feature_category: :cell do
let(:deleted_records) { table(:loose_foreign_keys_deleted_records) }
target_table_name = Ci::JobArtifact.table_name
diff --git a/spec/migrations/20220505044348_fix_automatic_iterations_cadences_start_date_spec.rb b/spec/migrations/20220505044348_fix_automatic_iterations_cadences_start_date_spec.rb
index 3a6a8f5dbe5..16258eeb0fb 100644
--- a/spec/migrations/20220505044348_fix_automatic_iterations_cadences_start_date_spec.rb
+++ b/spec/migrations/20220505044348_fix_automatic_iterations_cadences_start_date_spec.rb
@@ -28,19 +28,19 @@ RSpec.describe FixAutomaticIterationsCadencesStartDate, feature_category: :team_
before do
sprints.create!(id: 2, start_date: jan2022, due_date: jan2022 + 1.week, iterations_cadence_id: cadence1.id,
- group_id: group1.id, iid: 1)
+ group_id: group1.id, iid: 1)
sprints.create!(id: 1, start_date: dec2022, due_date: dec2022 + 1.week, iterations_cadence_id: cadence1.id,
- group_id: group1.id, iid: 2)
+ group_id: group1.id, iid: 2)
sprints.create!(id: 4, start_date: feb2022, due_date: feb2022 + 1.week, iterations_cadence_id: cadence3.id,
- group_id: group2.id, iid: 1)
+ group_id: group2.id, iid: 1)
sprints.create!(id: 3, start_date: may2022, due_date: may2022 + 1.week, iterations_cadence_id: cadence3.id,
- group_id: group2.id, iid: 2)
+ group_id: group2.id, iid: 2)
sprints.create!(id: 5, start_date: may2022, due_date: may2022 + 1.week, iterations_cadence_id: cadence4.id,
- group_id: group2.id, iid: 4)
+ group_id: group2.id, iid: 4)
sprints.create!(id: 6, start_date: feb2022, due_date: feb2022 + 1.week, iterations_cadence_id: cadence4.id,
- group_id: group2.id, iid: 3)
+ group_id: group2.id, iid: 3)
end
describe '#up' do
diff --git a/spec/migrations/20220513043344_reschedule_expire_o_auth_tokens_spec.rb b/spec/migrations/20220513043344_reschedule_expire_o_auth_tokens_spec.rb
index 735232dfac7..b03849b61a2 100644
--- a/spec/migrations/20220513043344_reschedule_expire_o_auth_tokens_spec.rb
+++ b/spec/migrations/20220513043344_reschedule_expire_o_auth_tokens_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require_migration!
-RSpec.describe RescheduleExpireOAuthTokens, feature_category: :authentication_and_authorization do
+RSpec.describe RescheduleExpireOAuthTokens, feature_category: :system_access do
let!(:migration) { described_class::MIGRATION }
describe '#up' do
diff --git a/spec/migrations/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events_spec.rb b/spec/migrations/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events_spec.rb
index 1b8ec47f61b..c01d982c34e 100644
--- a/spec/migrations/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events_spec.rb
+++ b/spec/migrations/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe AddUserIdAndIpAddressSuccessIndexToAuthenticationEvents,
-feature_category: :authentication_and_authorization do
+ feature_category: :system_access do
let(:db) { described_class.new }
let(:old_index) { described_class::OLD_INDEX_NAME }
let(:new_index) { described_class::NEW_INDEX_NAME }
diff --git a/spec/migrations/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb b/spec/migrations/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb
index b74e15d804f..4ae40933541 100644
--- a/spec/migrations/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb
+++ b/spec/migrations/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb
@@ -5,7 +5,7 @@ require "spec_helper"
require_migration!
RSpec.describe AddTmpIndexForPotentiallyMisassociatedVulnerabilityOccurrences,
-feature_category: :vulnerability_management do
+ feature_category: :vulnerability_management do
let(:async_index) { Gitlab::Database::AsyncIndexes::PostgresAsyncIndex }
let(:index_name) { described_class::INDEX_NAME }
diff --git a/spec/migrations/20220607082910_add_sync_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb b/spec/migrations/20220607082910_add_sync_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb
index 8d3ef9a46d7..d4a800eb1db 100644
--- a/spec/migrations/20220607082910_add_sync_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb
+++ b/spec/migrations/20220607082910_add_sync_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb
@@ -5,7 +5,7 @@ require "spec_helper"
require_migration!
RSpec.describe AddSyncTmpIndexForPotentiallyMisassociatedVulnerabilityOccurrences,
-feature_category: :vulnerability_management do
+ feature_category: :vulnerability_management do
let(:table) { "vulnerability_occurrences" }
let(:index) { described_class::INDEX_NAME }
diff --git a/spec/migrations/20220628012902_finalise_project_namespace_members_spec.rb b/spec/migrations/20220628012902_finalise_project_namespace_members_spec.rb
index 55cabc21997..fb1a4782f3b 100644
--- a/spec/migrations/20220628012902_finalise_project_namespace_members_spec.rb
+++ b/spec/migrations/20220628012902_finalise_project_namespace_members_spec.rb
@@ -12,12 +12,16 @@ RSpec.describe FinaliseProjectNamespaceMembers, :migration, feature_category: :s
shared_examples 'finalizes the migration' do
it 'finalizes the migration' do
allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
- expect(runner).to receive(:finalize).with('BackfillProjectMemberNamespaceId', :members, :id, [])
+ expect(runner).to receive(:finalize).with(migration, :members, :id, [])
end
end
end
context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
@@ -29,7 +33,7 @@ RSpec.describe FinaliseProjectNamespaceMembers, :migration, feature_category: :s
context 'with migration present' do
let!(:project_member_namespace_id_backfill) do
batched_migrations.create!(
- job_class_name: 'BackfillProjectMemberNamespaceId',
+ job_class_name: migration,
table_name: :members,
column_name: :id,
job_arguments: [],
diff --git a/spec/migrations/20220801155858_schedule_disable_legacy_open_source_licence_for_recent_public_projects_spec.rb b/spec/migrations/20220801155858_schedule_disable_legacy_open_source_licence_for_recent_public_projects_spec.rb
index f8f1565fe4c..a9f0bdc8487 100644
--- a/spec/migrations/20220801155858_schedule_disable_legacy_open_source_licence_for_recent_public_projects_spec.rb
+++ b/spec/migrations/20220801155858_schedule_disable_legacy_open_source_licence_for_recent_public_projects_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
require_migration!
-RSpec.describe ScheduleDisableLegacyOpenSourceLicenceForRecentPublicProjects, schema: 20220801155858,
- feature_category: :projects do
+RSpec.describe ScheduleDisableLegacyOpenSourceLicenceForRecentPublicProjects,
+ schema: 20220801155858, feature_category: :projects do
context 'when on gitlab.com' do
let(:background_migration) { described_class::MIGRATION }
let(:migration) { described_class.new }
diff --git a/spec/migrations/20220802204737_remove_deactivated_user_highest_role_stats_spec.rb b/spec/migrations/20220802204737_remove_deactivated_user_highest_role_stats_spec.rb
index 36c65612bb9..b731a8c8c18 100644
--- a/spec/migrations/20220802204737_remove_deactivated_user_highest_role_stats_spec.rb
+++ b/spec/migrations/20220802204737_remove_deactivated_user_highest_role_stats_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require_migration!
-RSpec.describe RemoveDeactivatedUserHighestRoleStats, feature_category: :subscription_cost_management do
+RSpec.describe RemoveDeactivatedUserHighestRoleStats, feature_category: :seat_cost_management do
let!(:users) { table(:users) }
let!(:user_highest_roles) { table(:user_highest_roles) }
diff --git a/spec/migrations/20220816163444_update_start_date_for_iterations_cadences_spec.rb b/spec/migrations/20220816163444_update_start_date_for_iterations_cadences_spec.rb
index 25b2b5c2e18..0807f5d4e38 100644
--- a/spec/migrations/20220816163444_update_start_date_for_iterations_cadences_spec.rb
+++ b/spec/migrations/20220816163444_update_start_date_for_iterations_cadences_spec.rb
@@ -31,31 +31,31 @@ RSpec.describe UpdateStartDateForIterationsCadences, :freeze_time, feature_categ
before do
# Past iteratioin
sprints.create!(id: 1, iid: 1, **cadence_params(auto_cadence1),
- start_date: Date.current - 1.week, due_date: Date.current - 1.day)
+ start_date: Date.current - 1.week, due_date: Date.current - 1.day)
# Current iteraition
sprints.create!(id: 3, iid: 5, **cadence_params(auto_cadence1),
- start_date: Date.current, due_date: Date.current + 1.week)
+ start_date: Date.current, due_date: Date.current + 1.week)
# First upcoming iteration
sprints.create!(id: 4, iid: 8, **cadence_params(auto_cadence1),
- start_date: first_upcoming_start_date, due_date: first_upcoming_start_date + 1.week)
+ start_date: first_upcoming_start_date, due_date: first_upcoming_start_date + 1.week)
# Second upcoming iteration
sprints.create!(id: 5, iid: 9, **cadence_params(auto_cadence1),
- start_date: first_upcoming_start_date + 2.weeks, due_date: first_upcoming_start_date + 3.weeks)
+ start_date: first_upcoming_start_date + 2.weeks, due_date: first_upcoming_start_date + 3.weeks)
sprints.create!(id: 6, iid: 1, **cadence_params(manual_cadence2),
- start_date: Date.current, due_date: Date.current + 1.week)
+ start_date: Date.current, due_date: Date.current + 1.week)
sprints.create!(id: 7, iid: 5, **cadence_params(manual_cadence2),
- start_date: Date.current + 2.weeks, due_date: Date.current + 3.weeks)
+ start_date: Date.current + 2.weeks, due_date: Date.current + 3.weeks)
end
describe '#up' do
it "updates the start date of an automatic cadence to the start date of its first upcoming sprint record." do
expect { migration.up }
- .to change { auto_cadence1.reload.start_date }.to(first_upcoming_start_date)
- .and not_change { auto_cadence2.reload.start_date } # the cadence doesn't have any upcoming iteration.
- .and not_change { auto_cadence3.reload.start_date } # the cadence is empty; it has no iterations.
- .and not_change { manual_cadence1.reload.start_date } # manual cadence don't need to be touched.
- .and not_change { manual_cadence2.reload.start_date } # manual cadence don't need to be touched.
+ .to change { auto_cadence1.reload.start_date }.to(first_upcoming_start_date)
+ .and not_change { auto_cadence2.reload.start_date } # the cadence doesn't have any upcoming iteration.
+ .and not_change { auto_cadence3.reload.start_date } # the cadence is empty; it has no iterations.
+ .and not_change { manual_cadence1.reload.start_date } # manual cadence don't need to be touched.
+ .and not_change { manual_cadence2.reload.start_date } # manual cadence don't need to be touched.
end
end
@@ -64,10 +64,10 @@ RSpec.describe UpdateStartDateForIterationsCadences, :freeze_time, feature_categ
migration.up
expect { migration.down }
- .to change { auto_cadence1.reload.start_date }.to(original_cadence_start_date)
- .and not_change { auto_cadence2.reload.start_date } # the cadence is empty; it has no iterations.
- .and not_change { manual_cadence1.reload.start_date } # manual cadence don't need to be touched.
- .and not_change { manual_cadence2.reload.start_date } # manual cadence don't need to be touched.
+ .to change { auto_cadence1.reload.start_date }.to(original_cadence_start_date)
+ .and not_change { auto_cadence2.reload.start_date } # the cadence is empty; it has no iterations.
+ .and not_change { manual_cadence1.reload.start_date } # manual cadence don't need to be touched.
+ .and not_change { manual_cadence2.reload.start_date } # manual cadence don't need to be touched.
end
end
end
diff --git a/spec/migrations/20220819153725_add_vulnerability_advisory_foreign_key_to_sbom_vulnerable_component_versions_spec.rb b/spec/migrations/20220819153725_add_vulnerability_advisory_foreign_key_to_sbom_vulnerable_component_versions_spec.rb
index 5a61f49485c..1d18862c8ee 100644
--- a/spec/migrations/20220819153725_add_vulnerability_advisory_foreign_key_to_sbom_vulnerable_component_versions_spec.rb
+++ b/spec/migrations/20220819153725_add_vulnerability_advisory_foreign_key_to_sbom_vulnerable_component_versions_spec.rb
@@ -5,7 +5,7 @@ require "spec_helper"
require_migration!
RSpec.describe AddVulnerabilityAdvisoryForeignKeyToSbomVulnerableComponentVersions,
-feature_category: :dependency_management do
+ feature_category: :dependency_management do
let(:table) { described_class::SOURCE_TABLE }
let(:column) { described_class::COLUMN }
let(:foreign_key) { -> { described_class.new.foreign_keys_for(table, column).first } }
diff --git a/spec/migrations/20220819162852_add_sbom_component_version_foreign_key_to_sbom_vulnerable_component_versions_spec.rb b/spec/migrations/20220819162852_add_sbom_component_version_foreign_key_to_sbom_vulnerable_component_versions_spec.rb
index 999c833f9e3..a280795380d 100644
--- a/spec/migrations/20220819162852_add_sbom_component_version_foreign_key_to_sbom_vulnerable_component_versions_spec.rb
+++ b/spec/migrations/20220819162852_add_sbom_component_version_foreign_key_to_sbom_vulnerable_component_versions_spec.rb
@@ -5,7 +5,7 @@ require "spec_helper"
require_migration!
RSpec.describe AddSbomComponentVersionForeignKeyToSbomVulnerableComponentVersions,
-feature_category: :dependency_management do
+ feature_category: :dependency_management do
let(:table) { described_class::SOURCE_TABLE }
let(:column) { described_class::COLUMN }
let(:foreign_key) { -> { described_class.new.foreign_keys_for(table, column).first } }
diff --git a/spec/migrations/20220921144258_remove_orphan_group_token_users_spec.rb b/spec/migrations/20220921144258_remove_orphan_group_token_users_spec.rb
index 19cf3b2fb69..5cfcb2eb3dd 100644
--- a/spec/migrations/20220921144258_remove_orphan_group_token_users_spec.rb
+++ b/spec/migrations/20220921144258_remove_orphan_group_token_users_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
require_migration!
RSpec.describe RemoveOrphanGroupTokenUsers, :migration, :sidekiq_inline,
-feature_category: :authentication_and_authorization do
+ feature_category: :system_access do
subject(:migration) { described_class.new }
let(:users) { table(:users) }
@@ -18,12 +18,14 @@ feature_category: :authentication_and_authorization do
let!(:valid_used_bot) do
create_bot(username: 'used_bot', email: 'used_bot@bot.com').tap do |bot|
group = namespaces.create!(type: 'Group', path: 'used_bot_group', name: 'used_bot_group')
- members.create!(user_id: bot.id,
- source_id: group.id,
- member_namespace_id: group.id,
- source_type: 'Group',
- access_level: 10,
- notification_level: 0)
+ members.create!(
+ user_id: bot.id,
+ source_id: group.id,
+ member_namespace_id: group.id,
+ source_type: 'Group',
+ access_level: 10,
+ notification_level: 0
+ )
end
end
diff --git a/spec/migrations/20220928225711_schedule_update_ci_pipeline_artifacts_locked_status_spec.rb b/spec/migrations/20220928225711_schedule_update_ci_pipeline_artifacts_locked_status_spec.rb
index 5c1b5c8f2a7..085e9726663 100644
--- a/spec/migrations/20220928225711_schedule_update_ci_pipeline_artifacts_locked_status_spec.rb
+++ b/spec/migrations/20220928225711_schedule_update_ci_pipeline_artifacts_locked_status_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
require_migration!
-RSpec.describe ScheduleUpdateCiPipelineArtifactsLockedStatus, migration: :gitlab_ci,
- feature_category: :build_artifacts do
+RSpec.describe ScheduleUpdateCiPipelineArtifactsLockedStatus,
+ migration: :gitlab_ci, feature_category: :build_artifacts do
let!(:migration) { described_class::MIGRATION }
describe '#up' do
diff --git a/spec/migrations/20221002234454_finalize_group_member_namespace_id_migration_spec.rb b/spec/migrations/20221002234454_finalize_group_member_namespace_id_migration_spec.rb
index 4ff16111417..632b23a8384 100644
--- a/spec/migrations/20221002234454_finalize_group_member_namespace_id_migration_spec.rb
+++ b/spec/migrations/20221002234454_finalize_group_member_namespace_id_migration_spec.rb
@@ -12,12 +12,16 @@ RSpec.describe FinalizeGroupMemberNamespaceIdMigration, :migration, feature_cate
shared_examples 'finalizes the migration' do
it 'finalizes the migration' do
allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
- expect(runner).to receive(:finalize).with('BackfillMemberNamespaceForGroupMembers', :members, :id, [])
+ expect(runner).to receive(:finalize).with(migration, :members, :id, [])
end
end
end
context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
@@ -29,7 +33,7 @@ RSpec.describe FinalizeGroupMemberNamespaceIdMigration, :migration, feature_cate
context 'with migration present' do
let!(:group_member_namespace_id_backfill) do
batched_migrations.create!(
- job_class_name: 'BackfillMemberNamespaceForGroupMembers',
+ job_class_name: migration,
table_name: :members,
column_name: :id,
job_arguments: [],
diff --git a/spec/migrations/20221018050323_add_objective_and_keyresult_to_work_item_types_spec.rb b/spec/migrations/20221018050323_add_objective_and_keyresult_to_work_item_types_spec.rb
index 6284608becb..d591b370d65 100644
--- a/spec/migrations/20221018050323_add_objective_and_keyresult_to_work_item_types_spec.rb
+++ b/spec/migrations/20221018050323_add_objective_and_keyresult_to_work_item_types_spec.rb
@@ -28,10 +28,12 @@ RSpec.describe AddObjectiveAndKeyresultToWorkItemTypes, :migration, feature_cate
it 'skips creating both objective & keyresult type record if it already exists' do
reset_db_state_prior_to_migration
- work_item_types.find_or_create_by!(name: 'Key Result', namespace_id: nil, base_type: base_types[:key_result],
- icon_name: 'issue-type-keyresult')
- work_item_types.find_or_create_by!(name: 'Objective', namespace_id: nil, base_type: base_types[:objective],
- icon_name: 'issue-type-objective')
+ work_item_types.find_or_create_by!(
+ name: 'Key Result', namespace_id: nil, base_type: base_types[:key_result], icon_name: 'issue-type-keyresult'
+ )
+ work_item_types.find_or_create_by!(
+ name: 'Objective', namespace_id: nil, base_type: base_types[:objective], icon_name: 'issue-type-objective'
+ )
expect do
migrate!
@@ -52,15 +54,20 @@ RSpec.describe AddObjectiveAndKeyresultToWorkItemTypes, :migration, feature_cate
def reset_db_state_prior_to_migration
# Database needs to be in a similar state as when this migration was created
work_item_types.delete_all
- work_item_types.find_or_create_by!(name: 'Issue', namespace_id: nil, base_type: base_types[:issue],
- icon_name: 'issue-type-issue')
- work_item_types.find_or_create_by!(name: 'Incident', namespace_id: nil, base_type: base_types[:incident],
- icon_name: 'issue-type-incident')
- work_item_types.find_or_create_by!(name: 'Test Case', namespace_id: nil, base_type: base_types[:test_case],
- icon_name: 'issue-type-test-case')
- work_item_types.find_or_create_by!(name: 'Requirement', namespace_id: nil, base_type: base_types[:requirement],
- icon_name: 'issue-type-requirements')
- work_item_types.find_or_create_by!(name: 'Task', namespace_id: nil, base_type: base_types[:task],
- icon_name: 'issue-type-task')
+ work_item_types.find_or_create_by!(
+ name: 'Issue', namespace_id: nil, base_type: base_types[:issue], icon_name: 'issue-type-issue'
+ )
+ work_item_types.find_or_create_by!(
+ name: 'Incident', namespace_id: nil, base_type: base_types[:incident], icon_name: 'issue-type-incident'
+ )
+ work_item_types.find_or_create_by!(
+ name: 'Test Case', namespace_id: nil, base_type: base_types[:test_case], icon_name: 'issue-type-test-case'
+ )
+ work_item_types.find_or_create_by!(
+ name: 'Requirement', namespace_id: nil, base_type: base_types[:requirement], icon_name: 'issue-type-requirements'
+ )
+ work_item_types.find_or_create_by!(
+ name: 'Task', namespace_id: nil, base_type: base_types[:task], icon_name: 'issue-type-task'
+ )
end
end
diff --git a/spec/migrations/20221018193635_ensure_task_note_renaming_background_migration_finished_spec.rb b/spec/migrations/20221018193635_ensure_task_note_renaming_background_migration_finished_spec.rb
index 8b599881359..da1df92691e 100644
--- a/spec/migrations/20221018193635_ensure_task_note_renaming_background_migration_finished_spec.rb
+++ b/spec/migrations/20221018193635_ensure_task_note_renaming_background_migration_finished_spec.rb
@@ -25,6 +25,10 @@ RSpec.describe EnsureTaskNoteRenamingBackgroundMigrationFinished, :migration, fe
end
context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
@@ -36,7 +40,7 @@ RSpec.describe EnsureTaskNoteRenamingBackgroundMigrationFinished, :migration, fe
context 'with migration present' do
let!(:task_renaming_migration) do
batched_migrations.create!(
- job_class_name: 'RenameTaskSystemNoteToChecklistItem',
+ job_class_name: migration,
table_name: :system_note_metadata,
column_name: :id,
job_arguments: [],
diff --git a/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb b/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb
index 37bff128edd..da2f4364e5c 100644
--- a/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb
+++ b/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb
@@ -26,6 +26,10 @@ RSpec.describe FinalizeBackfillUserDetailsFields, :migration, feature_category:
end
context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
diff --git a/spec/migrations/20221104115712_backfill_project_statistics_storage_size_without_uploads_size_spec.rb b/spec/migrations/20221104115712_backfill_project_statistics_storage_size_without_uploads_size_spec.rb
index d86720365c4..9658b5a699a 100644
--- a/spec/migrations/20221104115712_backfill_project_statistics_storage_size_without_uploads_size_spec.rb
+++ b/spec/migrations/20221104115712_backfill_project_statistics_storage_size_without_uploads_size_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe BackfillProjectStatisticsStorageSizeWithoutUploadsSize,
- feature_category: :subscription_cost_management do
+ feature_category: :consumables_cost_management do
let!(:batched_migration) { described_class::MIGRATION_CLASS }
it 'does not schedule background jobs when Gitlab.org_or_com? is false' do
diff --git a/spec/migrations/20221115173607_ensure_work_item_type_backfill_migration_finished_spec.rb b/spec/migrations/20221115173607_ensure_work_item_type_backfill_migration_finished_spec.rb
index e9250625832..d560da40c21 100644
--- a/spec/migrations/20221115173607_ensure_work_item_type_backfill_migration_finished_spec.rb
+++ b/spec/migrations/20221115173607_ensure_work_item_type_backfill_migration_finished_spec.rb
@@ -13,6 +13,10 @@ RSpec.describe EnsureWorkItemTypeBackfillMigrationFinished, :migration, feature_
describe '#up', :redis do
context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration_class).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
diff --git a/spec/migrations/20221209235940_cleanup_o_auth_access_tokens_with_null_expires_in_spec.rb b/spec/migrations/20221209235940_cleanup_o_auth_access_tokens_with_null_expires_in_spec.rb
index da6532a822a..e5890ffce17 100644
--- a/spec/migrations/20221209235940_cleanup_o_auth_access_tokens_with_null_expires_in_spec.rb
+++ b/spec/migrations/20221209235940_cleanup_o_auth_access_tokens_with_null_expires_in_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require_migration!
-RSpec.describe CleanupOAuthAccessTokensWithNullExpiresIn, feature_category: :authentication_and_authorization do
+RSpec.describe CleanupOAuthAccessTokensWithNullExpiresIn, feature_category: :system_access do
let(:batched_migration) { described_class::MIGRATION }
it 'schedules background jobs for each batch of oauth_access_tokens' do
diff --git a/spec/migrations/20221215151822_schedule_backfill_releases_author_id_spec.rb b/spec/migrations/20221215151822_schedule_backfill_releases_author_id_spec.rb
index d7aa53ec35b..7cc0bd96a0d 100644
--- a/spec/migrations/20221215151822_schedule_backfill_releases_author_id_spec.rb
+++ b/spec/migrations/20221215151822_schedule_backfill_releases_author_id_spec.rb
@@ -10,21 +10,27 @@ RSpec.describe ScheduleBackfillReleasesAuthorId, feature_category: :release_orch
let(:date_time) { DateTime.now }
let!(:batched_migration) { described_class::MIGRATION }
let!(:test_user) do
- user_table.create!(name: 'test',
- email: 'test@example.com',
- username: 'test',
- projects_limit: 10)
+ user_table.create!(
+ name: 'test',
+ email: 'test@example.com',
+ username: 'test',
+ projects_limit: 10
+ )
end
before do
- releases_table.create!(tag: 'tag1', name: 'tag1',
- released_at: (date_time - 1.minute), author_id: test_user.id)
- releases_table.create!(tag: 'tag2', name: 'tag2',
- released_at: (date_time - 2.minutes), author_id: test_user.id)
- releases_table.new(tag: 'tag3', name: 'tag3',
- released_at: (date_time - 3.minutes), author_id: nil).save!(validate: false)
- releases_table.new(tag: 'tag4', name: 'tag4',
- released_at: (date_time - 4.minutes), author_id: nil).save!(validate: false)
+ releases_table.create!(
+ tag: 'tag1', name: 'tag1', released_at: (date_time - 1.minute), author_id: test_user.id
+ )
+ releases_table.create!(
+ tag: 'tag2', name: 'tag2', released_at: (date_time - 2.minutes), author_id: test_user.id
+ )
+ releases_table.new(
+ tag: 'tag3', name: 'tag3', released_at: (date_time - 3.minutes), author_id: nil
+ ).save!(validate: false)
+ releases_table.new(
+ tag: 'tag4', name: 'tag4', released_at: (date_time - 4.minutes), author_id: nil
+ ).save!(validate: false)
end
it 'schedules a new batched migration' do
diff --git a/spec/migrations/20221221110733_remove_temp_index_for_project_statistics_upload_size_migration_spec.rb b/spec/migrations/20221221110733_remove_temp_index_for_project_statistics_upload_size_migration_spec.rb
index 6f9cfe4764a..440a932c76b 100644
--- a/spec/migrations/20221221110733_remove_temp_index_for_project_statistics_upload_size_migration_spec.rb
+++ b/spec/migrations/20221221110733_remove_temp_index_for_project_statistics_upload_size_migration_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe RemoveTempIndexForProjectStatisticsUploadSizeMigration,
-feature_category: :subscription_cost_management do
+ feature_category: :consumables_cost_management do
let(:table_name) { 'project_statistics' }
let(:index_name) { described_class::INDEX_NAME }
diff --git a/spec/migrations/20230105172120_sync_new_amount_used_with_amount_used_on_ci_namespace_monthly_usages_table_spec.rb b/spec/migrations/20230105172120_sync_new_amount_used_with_amount_used_on_ci_namespace_monthly_usages_table_spec.rb
index aa82ca2661b..70c9c1333b8 100644
--- a/spec/migrations/20230105172120_sync_new_amount_used_with_amount_used_on_ci_namespace_monthly_usages_table_spec.rb
+++ b/spec/migrations/20230105172120_sync_new_amount_used_with_amount_used_on_ci_namespace_monthly_usages_table_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
require_migration!
RSpec.describe SyncNewAmountUsedWithAmountUsedOnCiNamespaceMonthlyUsagesTable, migration: :gitlab_ci,
-feature_category: :continuous_integration do
+ feature_category: :continuous_integration do
let(:namespace_usages) { table(:ci_namespace_monthly_usages) }
let(:migration) { described_class.new }
diff --git a/spec/migrations/20230118144623_schedule_migration_for_remediation_spec.rb b/spec/migrations/20230118144623_schedule_migration_for_remediation_spec.rb
new file mode 100644
index 00000000000..f6d0f32b87c
--- /dev/null
+++ b/spec/migrations/20230118144623_schedule_migration_for_remediation_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ScheduleMigrationForRemediation, :migration, feature_category: :vulnerability_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules a batched background migration' do
+ migrate!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230125195503_queue_backfill_compliance_violations_spec.rb b/spec/migrations/20230125195503_queue_backfill_compliance_violations_spec.rb
new file mode 100644
index 00000000000..a70f9820855
--- /dev/null
+++ b/spec/migrations/20230125195503_queue_backfill_compliance_violations_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueBackfillComplianceViolations, feature_category: :compliance_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules background jobs for each batch of merge_request_compliance_violations' do
+ migrate!
+
+ expect(migration).to(
+ have_scheduled_batched_migration(
+ table_name: :merge_requests_compliance_violations,
+ column_name: :id,
+ interval: described_class::INTERVAL,
+ batch_size: described_class::BATCH_SIZE
+ )
+ )
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230130182412_schedule_create_vulnerability_links_migration_spec.rb b/spec/migrations/20230130182412_schedule_create_vulnerability_links_migration_spec.rb
new file mode 100644
index 00000000000..58e27379ef7
--- /dev/null
+++ b/spec/migrations/20230130182412_schedule_create_vulnerability_links_migration_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ScheduleCreateVulnerabilityLinksMigration, feature_category: :vulnerability_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules background jobs for each batch of Vulnerabilities::Feedback' do
+ migrate!
+
+ expect(migration).to have_scheduled_batched_migration(
+ table_name: :vulnerability_feedback,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE
+ )
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230201171450_finalize_backfill_environment_tier_migration_spec.rb b/spec/migrations/20230201171450_finalize_backfill_environment_tier_migration_spec.rb
index 3fc9c7d8af7..e7a78f11f16 100644
--- a/spec/migrations/20230201171450_finalize_backfill_environment_tier_migration_spec.rb
+++ b/spec/migrations/20230201171450_finalize_backfill_environment_tier_migration_spec.rb
@@ -18,6 +18,10 @@ RSpec.describe FinalizeBackfillEnvironmentTierMigration, :migration, feature_cat
end
context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
@@ -29,7 +33,7 @@ RSpec.describe FinalizeBackfillEnvironmentTierMigration, :migration, feature_cat
context 'with migration present' do
let!(:group_member_namespace_id_backfill) do
batched_migrations.create!(
- job_class_name: 'BackfillEnvironmentTiers',
+ job_class_name: migration,
table_name: :environments,
column_name: :id,
job_arguments: [],
diff --git a/spec/migrations/20230202131928_encrypt_ci_trigger_token_spec.rb b/spec/migrations/20230202131928_encrypt_ci_trigger_token_spec.rb
index a8896e7d3cf..597cd7c1581 100644
--- a/spec/migrations/20230202131928_encrypt_ci_trigger_token_spec.rb
+++ b/spec/migrations/20230202131928_encrypt_ci_trigger_token_spec.rb
@@ -9,14 +9,6 @@ RSpec.describe EncryptCiTriggerToken, migration: :gitlab_ci, feature_category: :
let!(:migration) { described_class::MIGRATION }
describe '#up' do
- shared_examples 'finalizes the migration' do
- it 'finalizes the migration' do
- allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
- expect(runner).to receive(:finalize).with('EncryptCiTriggerToken', :ci_triggers, :id, [])
- end
- end
- end
-
context 'with migration present' do
let!(:ci_trigger_token_encryption_migration) do
batched_migrations.create!(
@@ -51,25 +43,6 @@ RSpec.describe EncryptCiTriggerToken, migration: :gitlab_ci, feature_category: :
)
end
end
-
- context 'with different migration statuses' do
- using RSpec::Parameterized::TableSyntax
-
- where(:status, :description) do
- 0 | 'paused'
- 1 | 'active'
- 4 | 'failed'
- 5 | 'finalizing'
- end
-
- with_them do
- before do
- ci_trigger_token_encryption_migration.update!(status: status)
- end
-
- it_behaves_like 'finalizes the migration'
- end
- end
end
end
diff --git a/spec/migrations/20230202211434_migrate_redis_slot_keys_spec.rb b/spec/migrations/20230202211434_migrate_redis_slot_keys_spec.rb
new file mode 100644
index 00000000000..ca2c50241bf
--- /dev/null
+++ b/spec/migrations/20230202211434_migrate_redis_slot_keys_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe MigrateRedisSlotKeys, :migration, feature_category: :service_ping do
+ let(:date) { Date.yesterday.strftime('%G-%j') }
+ let(:week) { Date.yesterday.strftime('%G-%V') }
+
+ before do
+ allow(described_class::BackupHLLRedisCounter).to receive(:known_events).and_return([{
+ redis_slot: 'analytics',
+ aggregation: 'daily',
+ name: 'users_viewing_analytics_group_devops_adoption'
+ }, {
+ aggregation: 'weekly',
+ name: 'wiki_action'
+ }])
+ end
+
+ describe "#up" do
+ it 'rename keys', :aggregate_failures do
+ expiry_daily = described_class::BackupHLLRedisCounter::DEFAULT_DAILY_KEY_EXPIRY_LENGTH
+ expiry_weekly = described_class::BackupHLLRedisCounter::DEFAULT_WEEKLY_KEY_EXPIRY_LENGTH
+
+ default_slot = described_class::BackupHLLRedisCounter::REDIS_SLOT
+
+ old_slot_a = "#{date}-users_viewing_{analytics}_group_devops_adoption"
+ old_slot_b = "{wiki_action}-#{week}"
+
+ new_slot_a = "#{date}-{#{default_slot}}_users_viewing_analytics_group_devops_adoption"
+ new_slot_b = "{#{default_slot}}_wiki_action-#{week}"
+
+ Gitlab::Redis::HLL.add(key: old_slot_a, value: 1, expiry: expiry_daily)
+ Gitlab::Redis::HLL.add(key: old_slot_b, value: 1, expiry: expiry_weekly)
+
+ # check that we merge values during migration
+ # i.e. we dont drop keys created after code deploy but before the migration
+ Gitlab::Redis::HLL.add(key: new_slot_a, value: 2, expiry: expiry_daily)
+ Gitlab::Redis::HLL.add(key: new_slot_b, value: 2, expiry: expiry_weekly)
+
+ migrate!
+
+ expect(Gitlab::Redis::HLL.count(keys: new_slot_a)).to eq(2)
+ expect(Gitlab::Redis::HLL.count(keys: new_slot_b)).to eq(2)
+ expect(with_redis { |r| r.ttl(new_slot_a) }).to be_within(600).of(expiry_daily)
+ expect(with_redis { |r| r.ttl(new_slot_b) }).to be_within(600).of(expiry_weekly)
+ end
+ end
+
+ def with_redis(&block)
+ Gitlab::Redis::SharedState.with(&block)
+ end
+end
diff --git a/spec/migrations/20230208125736_schedule_migration_for_links_spec.rb b/spec/migrations/20230208125736_schedule_migration_for_links_spec.rb
new file mode 100644
index 00000000000..035f13b8309
--- /dev/null
+++ b/spec/migrations/20230208125736_schedule_migration_for_links_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ScheduleMigrationForLinks, :migration, feature_category: :vulnerability_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules a batched background migration' do
+ migrate!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230209222452_schedule_remove_project_group_link_with_missing_groups_spec.rb b/spec/migrations/20230209222452_schedule_remove_project_group_link_with_missing_groups_spec.rb
new file mode 100644
index 00000000000..13ae12b2774
--- /dev/null
+++ b/spec/migrations/20230209222452_schedule_remove_project_group_link_with_missing_groups_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ScheduleRemoveProjectGroupLinkWithMissingGroups, feature_category: :subgroups do
+ let!(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules background migration' do
+ migrate!
+
+ expect(migration).to have_scheduled_batched_migration(
+ table_name: :project_group_links,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ max_batch_size: described_class::MAX_BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ end
+ end
+
+ describe '#down' do
+ it 'removes scheduled background migrations' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230214181633_finalize_ci_build_needs_big_int_conversion_spec.rb b/spec/migrations/20230214181633_finalize_ci_build_needs_big_int_conversion_spec.rb
new file mode 100644
index 00000000000..638fe2a12d5
--- /dev/null
+++ b/spec/migrations/20230214181633_finalize_ci_build_needs_big_int_conversion_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe FinalizeCiBuildNeedsBigIntConversion, migration: :gitlab_ci, feature_category: :continuous_integration do
+ describe '#up' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:dot_com, :dev_or_test, :jh, :expectation) do
+ true | true | true | :not_to
+ true | false | true | :not_to
+ false | true | true | :not_to
+ false | false | true | :not_to
+ true | true | false | :to
+ true | false | false | :to
+ false | true | false | :to
+ false | false | false | :not_to
+ end
+
+ with_them do
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ allow(Gitlab).to receive(:com?).and_return(dot_com)
+ allow(Gitlab).to receive(:dev_or_test_env?).and_return(dev_or_test)
+ allow(Gitlab).to receive(:jh?).and_return(jh)
+
+ migration_arguments = {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'ci_build_needs',
+ column_name: 'id',
+ job_arguments: [['id'], ['id_convert_to_bigint']]
+ }
+
+ expect(described_class).send(
+ expectation,
+ ensure_batched_background_migration_is_finished_for(migration_arguments)
+ )
+
+ migrate!
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230220102212_swap_columns_ci_build_needs_big_int_conversion_spec.rb b/spec/migrations/20230220102212_swap_columns_ci_build_needs_big_int_conversion_spec.rb
new file mode 100644
index 00000000000..1c21047c0c3
--- /dev/null
+++ b/spec/migrations/20230220102212_swap_columns_ci_build_needs_big_int_conversion_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapColumnsCiBuildNeedsBigIntConversion, feature_category: :continuous_integration do
+ describe '#up' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:dot_com, :dev_or_test, :jh, :swap) do
+ true | true | true | false
+ true | false | true | false
+ false | true | true | false
+ false | false | true | false
+ true | true | false | true
+ true | false | false | true
+ false | true | false | true
+ false | false | false | false
+ end
+
+ with_them do
+ before do
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE ci_build_needs ALTER COLUMN id TYPE integer')
+ connection.execute('ALTER TABLE ci_build_needs ALTER COLUMN id_convert_to_bigint TYPE bigint')
+ end
+
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow(Gitlab).to receive(:com?).and_return(dot_com)
+ allow(Gitlab).to receive(:dev_or_test_env?).and_return(dev_or_test)
+ allow(Gitlab).to receive(:jh?).and_return(jh)
+
+ ci_build_needs = table(:ci_build_needs)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ ci_build_needs.reset_column_information
+
+ expect(ci_build_needs.columns.find { |c| c.name == 'id' }.sql_type).to eq('integer')
+ expect(ci_build_needs.columns.find { |c| c.name == 'id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ ci_build_needs.reset_column_information
+
+ if swap
+ expect(ci_build_needs.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint')
+ expect(ci_build_needs.columns.find { |c| c.name == 'id_convert_to_bigint' }.sql_type).to eq('integer')
+ else
+ expect(ci_build_needs.columns.find { |c| c.name == 'id' }.sql_type).to eq('integer')
+ expect(ci_build_needs.columns.find { |c| c.name == 'id_convert_to_bigint' }.sql_type).to eq('bigint')
+ end
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230221093533_add_tmp_partial_index_on_vulnerability_report_types_spec.rb b/spec/migrations/20230221093533_add_tmp_partial_index_on_vulnerability_report_types_spec.rb
new file mode 100644
index 00000000000..cbf6b2882c4
--- /dev/null
+++ b/spec/migrations/20230221093533_add_tmp_partial_index_on_vulnerability_report_types_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+require_migration!
+
+RSpec.describe AddTmpPartialIndexOnVulnerabilityReportTypes, feature_category: :vulnerability_management do
+ let(:async_index) { Gitlab::Database::AsyncIndexes::PostgresAsyncIndex }
+ let(:index_name) { described_class::INDEX_NAME }
+
+ it "schedules the index" do
+ reversible_migration do |migration|
+ migration.before -> do
+ expect(async_index.where(name: index_name).count).to be(0)
+ end
+
+ migration.after -> do
+ expect(async_index.where(name: index_name).count).to be(1)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230221214519_remove_incorrectly_onboarded_namespaces_from_onboarding_progress_spec.rb b/spec/migrations/20230221214519_remove_incorrectly_onboarded_namespaces_from_onboarding_progress_spec.rb
new file mode 100644
index 00000000000..be49a3e919d
--- /dev/null
+++ b/spec/migrations/20230221214519_remove_incorrectly_onboarded_namespaces_from_onboarding_progress_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemoveIncorrectlyOnboardedNamespacesFromOnboardingProgress, feature_category: :onboarding do
+ let(:onboarding_progresses) { table(:onboarding_progresses) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+
+ # namespace to keep with name Learn Gitlab
+ let(:namespace1) { namespaces.create!(name: 'namespace1', type: 'Group', path: 'namespace1') }
+ let!(:onboard_keep_1) { onboarding_progresses.create!(namespace_id: namespace1.id) }
+ let!(:proj1) do
+ proj_namespace = namespaces.create!(name: 'proj1', path: 'proj1', type: 'Project', parent_id: namespace1.id)
+ projects.create!(name: 'project', namespace_id: namespace1.id, project_namespace_id: proj_namespace.id)
+ end
+
+ let!(:learn_gitlab) do
+ proj_namespace = namespaces.create!(name: 'projlg1', path: 'projlg1', type: 'Project', parent_id: namespace1.id)
+ projects.create!(name: 'Learn GitLab', namespace_id: namespace1.id, project_namespace_id: proj_namespace.id)
+ end
+
+ # namespace to keep with name Learn GitLab - Ultimate trial
+ let(:namespace2) { namespaces.create!(name: 'namespace2', type: 'Group', path: 'namespace2') }
+ let!(:onboard_keep_2) { onboarding_progresses.create!(namespace_id: namespace2.id) }
+ let!(:proj2) do
+ proj_namespace = namespaces.create!(name: 'proj2', path: 'proj2', type: 'Project', parent_id: namespace2.id)
+ projects.create!(name: 'project', namespace_id: namespace2.id, project_namespace_id: proj_namespace.id)
+ end
+
+ let!(:learn_gitlab2) do
+ proj_namespace = namespaces.create!(name: 'projlg2', path: 'projlg2', type: 'Project', parent_id: namespace2.id)
+ projects.create!(
+ name: 'Learn GitLab - Ultimate trial',
+ namespace_id: namespace2.id,
+ project_namespace_id: proj_namespace.id
+ )
+ end
+
+ # namespace to remove without learn gitlab project
+ let(:namespace3) { namespaces.create!(name: 'namespace3', type: 'Group', path: 'namespace3') }
+ let!(:onboarding_to_delete) { onboarding_progresses.create!(namespace_id: namespace3.id) }
+ let!(:proj3) do
+ proj_namespace = namespaces.create!(name: 'proj3', path: 'proj3', type: 'Project', parent_id: namespace3.id)
+ projects.create!(name: 'project', namespace_id: namespace3.id, project_namespace_id: proj_namespace.id)
+ end
+
+ # namespace to remove without any projects
+ let(:namespace4) { namespaces.create!(name: 'namespace4', type: 'Group', path: 'namespace4') }
+ let!(:onboarding_to_delete_without_project) { onboarding_progresses.create!(namespace_id: namespace4.id) }
+
+ describe '#up' do
+ it 'deletes the onboarding for namespaces without learn gitlab' do
+ expect { migrate! }.to change { onboarding_progresses.count }.by(-2)
+ expect(onboarding_progresses.all).to contain_exactly(onboard_keep_1, onboard_keep_2)
+ end
+ end
+end
diff --git a/spec/migrations/20230223065753_finalize_nullify_creator_id_of_orphaned_projects_spec.rb b/spec/migrations/20230223065753_finalize_nullify_creator_id_of_orphaned_projects_spec.rb
new file mode 100644
index 00000000000..e4adf3ca540
--- /dev/null
+++ b/spec/migrations/20230223065753_finalize_nullify_creator_id_of_orphaned_projects_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe FinalizeNullifyCreatorIdOfOrphanedProjects, :migration, feature_category: :projects do
+ let(:batched_migrations) { table(:batched_background_migrations) }
+ let(:batch_failed_status) { 2 }
+ let(:batch_finalized_status) { 3 }
+
+ let!(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ shared_examples 'finalizes the migration' do
+ it 'finalizes the migration' do
+ expect do
+ migrate!
+
+ migration_record.reload
+ failed_job.reload
+ end.to change { migration_record.status }.from(migration_record.status).to(3).and(
+ change { failed_job.status }.from(batch_failed_status).to(batch_finalized_status)
+ )
+ end
+ end
+
+ context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
+ it 'warns migration not found' do
+ expect(Gitlab::AppLogger)
+ .to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
+
+ migrate!
+ end
+ end
+
+ context 'with migration present' do
+ let!(:migration_record) do
+ batched_migrations.create!(
+ job_class_name: migration,
+ table_name: :projects,
+ column_name: :id,
+ job_arguments: [],
+ interval: 2.minutes,
+ min_value: 1,
+ max_value: 2,
+ batch_size: 1000,
+ sub_batch_size: 500,
+ max_batch_size: 5000,
+ gitlab_schema: :gitlab_main,
+ status: 3 # finished
+ )
+ end
+
+ context 'when migration finished successfully' do
+ it 'does not raise exception' do
+ expect { migrate! }.not_to raise_error
+ end
+ end
+
+ context 'with different migration statuses', :redis do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:status, :description) do
+ 0 | 'paused'
+ 1 | 'active'
+ 4 | 'failed'
+ 5 | 'finalizing'
+ end
+
+ with_them do
+ let!(:failed_job) do
+ table(:batched_background_migration_jobs).create!(
+ batched_background_migration_id: migration_record.id,
+ status: batch_failed_status,
+ min_value: 1,
+ max_value: 10,
+ attempts: 2,
+ batch_size: 100,
+ sub_batch_size: 10
+ )
+ end
+
+ before do
+ migration_record.update!(status: status)
+ end
+
+ it_behaves_like 'finalizes the migration'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230224085743_update_issues_internal_id_scope_spec.rb b/spec/migrations/20230224085743_update_issues_internal_id_scope_spec.rb
new file mode 100644
index 00000000000..7c7b58c7f0e
--- /dev/null
+++ b/spec/migrations/20230224085743_update_issues_internal_id_scope_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe UpdateIssuesInternalIdScope, feature_category: :team_planning do
+ describe '#up' do
+ it 'schedules background migration' do
+ migrate!
+
+ expect(described_class::MIGRATION).to have_scheduled_batched_migration(
+ table_name: :internal_ids,
+ column_name: :id,
+ interval: described_class::INTERVAL)
+ end
+ end
+
+ describe '#down' do
+ it 'does not schedule background migration' do
+ schema_migrate_down!
+
+ expect(described_class::MIGRATION).not_to have_scheduled_batched_migration(
+ table_name: :internal_ids,
+ column_name: :id,
+ interval: described_class::INTERVAL)
+ end
+ end
+end
diff --git a/spec/migrations/20230224144233_migrate_evidences_from_raw_metadata_spec.rb b/spec/migrations/20230224144233_migrate_evidences_from_raw_metadata_spec.rb
new file mode 100644
index 00000000000..6610f70be2b
--- /dev/null
+++ b/spec/migrations/20230224144233_migrate_evidences_from_raw_metadata_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe MigrateEvidencesFromRawMetadata, :migration, feature_category: :vulnerability_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules a batched background migration' do
+ migrate!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230228142350_add_notifications_work_item_widget_spec.rb b/spec/migrations/20230228142350_add_notifications_work_item_widget_spec.rb
new file mode 100644
index 00000000000..7161ca35edd
--- /dev/null
+++ b/spec/migrations/20230228142350_add_notifications_work_item_widget_spec.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddNotificationsWorkItemWidget, :migration, feature_category: :team_planning do
+ it_behaves_like 'migration that adds widget to work items definitions', widget_name: 'Notifications'
+end
diff --git a/spec/migrations/20230302185739_queue_fix_vulnerability_reads_has_issues_spec.rb b/spec/migrations/20230302185739_queue_fix_vulnerability_reads_has_issues_spec.rb
new file mode 100644
index 00000000000..54b1e231db2
--- /dev/null
+++ b/spec/migrations/20230302185739_queue_fix_vulnerability_reads_has_issues_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueFixVulnerabilityReadsHasIssues, feature_category: :vulnerability_management do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :vulnerability_issue_links,
+ column_name: :vulnerability_id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE,
+ max_batch_size: described_class::MAX_BATCH_SIZE
+ )
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230302811133_re_migrate_redis_slot_keys_spec.rb b/spec/migrations/20230302811133_re_migrate_redis_slot_keys_spec.rb
new file mode 100644
index 00000000000..4c6d4907c29
--- /dev/null
+++ b/spec/migrations/20230302811133_re_migrate_redis_slot_keys_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ReMigrateRedisSlotKeys, :migration, feature_category: :service_ping do
+ let(:date) { Date.yesterday.strftime('%G-%j') }
+ let(:week) { Date.yesterday.strftime('%G-%V') }
+ let(:known_events) do
+ [
+ {
+ redis_slot: 'analytics',
+ aggregation: 'daily',
+ name: 'users_viewing_analytics_group_devops_adoption'
+ }, {
+ aggregation: 'weekly',
+ name: 'wiki_action'
+ }, {
+ aggregation: 'weekly',
+ name: 'non_existing_event'
+ }, {
+ aggregation: 'weekly',
+ name: 'event_without_expiry'
+ }
+ ]
+ end
+
+ describe "#up" do
+ it 'rename keys', :aggregate_failures do
+ allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:known_events)
+ .and_return(known_events)
+
+ expiry_daily = Gitlab::UsageDataCounters::HLLRedisCounter::DEFAULT_DAILY_KEY_EXPIRY_LENGTH
+ expiry_weekly = Gitlab::UsageDataCounters::HLLRedisCounter::DEFAULT_WEEKLY_KEY_EXPIRY_LENGTH
+
+ default_slot = Gitlab::UsageDataCounters::HLLRedisCounter::REDIS_SLOT
+
+ old_slot_a = "#{date}-users_viewing_{analytics}_group_devops_adoption"
+ old_slot_b = "{wiki_action}-#{week}"
+ old_slot_without_expiry = "{event_without_expiry}-#{week}"
+
+ new_slot_a = "#{date}-{#{default_slot}}_users_viewing_analytics_group_devops_adoption"
+ new_slot_b = "{#{default_slot}}_wiki_action-#{week}"
+ new_slot_without_expiry = "{#{default_slot}}_event_without_expiry-#{week}"
+
+ Gitlab::Redis::HLL.add(key: old_slot_a, value: 1, expiry: expiry_daily)
+ Gitlab::Redis::HLL.add(key: old_slot_b, value: 1, expiry: expiry_weekly)
+ Gitlab::Redis::HLL.add(key: old_slot_a, value: 2, expiry: expiry_daily)
+ Gitlab::Redis::HLL.add(key: old_slot_b, value: 2, expiry: expiry_weekly)
+ Gitlab::Redis::HLL.add(key: old_slot_b, value: 2, expiry: expiry_weekly)
+ Gitlab::Redis::SharedState.with { |redis| redis.pfadd(old_slot_without_expiry, 1) }
+
+ # check that we merge values during migration
+ # i.e. we dont drop keys created after code deploy but before the migration
+ Gitlab::Redis::HLL.add(key: new_slot_a, value: 3, expiry: expiry_daily)
+ Gitlab::Redis::HLL.add(key: new_slot_b, value: 3, expiry: expiry_weekly)
+ Gitlab::Redis::HLL.add(key: new_slot_without_expiry, value: 2, expiry: expiry_weekly)
+
+ migrate!
+
+ expect(Gitlab::Redis::HLL.count(keys: new_slot_a)).to eq(3)
+ expect(Gitlab::Redis::HLL.count(keys: new_slot_b)).to eq(3)
+ expect(Gitlab::Redis::HLL.count(keys: new_slot_without_expiry)).to eq(2)
+ expect(with_redis { |r| r.ttl(new_slot_a) }).to be_within(600).of(expiry_daily)
+ expect(with_redis { |r| r.ttl(new_slot_b) }).to be_within(600).of(expiry_weekly)
+ expect(with_redis { |r| r.ttl(new_slot_without_expiry) }).to be_within(600).of(expiry_weekly)
+ end
+
+ it 'runs without errors' do
+ expect { migrate! }.not_to raise_error
+ end
+ end
+
+ def with_redis(&block)
+ Gitlab::Redis::SharedState.with(&block)
+ end
+end
diff --git a/spec/migrations/20230303105806_queue_delete_orphaned_packages_dependencies_spec.rb b/spec/migrations/20230303105806_queue_delete_orphaned_packages_dependencies_spec.rb
new file mode 100644
index 00000000000..7fe90a08763
--- /dev/null
+++ b/spec/migrations/20230303105806_queue_delete_orphaned_packages_dependencies_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueDeleteOrphanedPackagesDependencies, feature_category: :package_registry do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :packages_dependencies,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230309071242_delete_security_policy_bot_users_spec.rb b/spec/migrations/20230309071242_delete_security_policy_bot_users_spec.rb
new file mode 100644
index 00000000000..4dd44cad158
--- /dev/null
+++ b/spec/migrations/20230309071242_delete_security_policy_bot_users_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe DeleteSecurityPolicyBotUsers, feature_category: :security_policy_management do
+ let(:users) { table(:users) }
+
+ before do
+ users.create!(user_type: 10, projects_limit: 0, email: 'security_policy_bot@example.com')
+ users.create!(user_type: 1, projects_limit: 0, email: 'support_bot@example.com')
+ users.create!(projects_limit: 0, email: 'human@example.com')
+ end
+
+ describe '#up' do
+ it 'deletes security_policy_bot users' do
+ expect { migrate! }.to change { users.count }.by(-1)
+
+ expect(users.where(user_type: 10).count).to eq(0)
+ expect(users.where(user_type: 1).count).to eq(1)
+ expect(users.where(user_type: nil).count).to eq(1)
+ end
+ end
+end
diff --git a/spec/migrations/20230313142631_backfill_ml_candidates_package_id_spec.rb b/spec/migrations/20230313142631_backfill_ml_candidates_package_id_spec.rb
new file mode 100644
index 00000000000..57ddb0504ec
--- /dev/null
+++ b/spec/migrations/20230313142631_backfill_ml_candidates_package_id_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe BackfillMlCandidatesPackageId, feature_category: :mlops do
+ let(:migration) { described_class.new }
+
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+ let(:ml_experiments) { table(:ml_experiments) }
+ let(:ml_candidates) { table(:ml_candidates) }
+ let(:packages_packages) { table(:packages_packages) }
+
+ let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
+ let(:project) { projects.create!(project_namespace_id: namespace.id, namespace_id: namespace.id) }
+ let(:experiment) { ml_experiments.create!(project_id: project.id, iid: 1, name: 'experiment') }
+ let!(:candidate1) { ml_candidates.create!(experiment_id: experiment.id, iid: SecureRandom.uuid) }
+ let!(:candidate2) { ml_candidates.create!(experiment_id: experiment.id, iid: SecureRandom.uuid) }
+ let!(:package1) do
+ packages_packages.create!(
+ project_id: project.id,
+ name: "ml_candidate_#{candidate1.id}",
+ version: "-",
+ package_type: 7
+ )
+ end
+
+ let!(:package2) do
+ packages_packages.create!(
+ project_id: project.id,
+ name: "ml_candidate_1000",
+ version: "-",
+ package_type: 7)
+ end
+
+ let!(:package3) do
+ packages_packages.create!(
+ project_id: project.id,
+ name: "ml_candidate_abcde",
+ version: "-",
+ package_type: 7
+ )
+ end
+
+ describe '#up' do
+ it 'sets the correct package_ids with idempotency', :aggregate_failures do
+ migration.up
+
+ expect(candidate1.reload.package_id).to be(package1.id)
+ expect(candidate2.reload.package_id).to be(nil)
+
+ migration.down
+ migration.up
+
+ expect(candidate1.reload.package_id).to be(package1.id)
+ expect(candidate2.reload.package_id).to be(nil)
+ end
+ end
+end
diff --git a/spec/migrations/20230313150531_reschedule_migration_for_remediation_spec.rb b/spec/migrations/20230313150531_reschedule_migration_for_remediation_spec.rb
new file mode 100644
index 00000000000..00f0836285b
--- /dev/null
+++ b/spec/migrations/20230313150531_reschedule_migration_for_remediation_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RescheduleMigrationForRemediation, :migration, feature_category: :vulnerability_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules a batched background migration' do
+ migrate!
+
+ expect(migration).to have_scheduled_batched_migration(
+ table_name: :vulnerability_occurrences,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230314144640_reschedule_migration_for_links_spec.rb b/spec/migrations/20230314144640_reschedule_migration_for_links_spec.rb
new file mode 100644
index 00000000000..b28b1ea0730
--- /dev/null
+++ b/spec/migrations/20230314144640_reschedule_migration_for_links_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RescheduleMigrationForLinks, :migration, feature_category: :vulnerability_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules a batched background migration' do
+ migrate!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230317004428_migrate_daily_redis_hll_events_to_weekly_aggregation_spec.rb b/spec/migrations/20230317004428_migrate_daily_redis_hll_events_to_weekly_aggregation_spec.rb
new file mode 100644
index 00000000000..86787273fbc
--- /dev/null
+++ b/spec/migrations/20230317004428_migrate_daily_redis_hll_events_to_weekly_aggregation_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe MigrateDailyRedisHllEventsToWeeklyAggregation, :migration, :clean_gitlab_redis_cache, feature_category: :service_ping do
+ it 'calls HLLRedisCounter.known_events to get list of events' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:known_events).and_call_original.at_least(1).time
+
+ migrate!
+ end
+
+ describe '#redis_key' do
+ let(:date) { Date.today }
+
+ context 'with daily aggregation' do
+ let(:date_formatted) { date.strftime('%G-%j') }
+ let(:event) { { aggregation: 'daily', name: 'wiki_action' } }
+
+ it 'returns correct key' do
+ existing_key = "#{date_formatted}-{hll_counters}_wiki_action"
+
+ expect(described_class.new.redis_key(event, date, event[:aggregation])).to eq(existing_key)
+ end
+ end
+
+ context 'with weekly aggregation' do
+ let(:event) { { aggregation: 'weekly', name: 'wiki_action' } }
+
+ it 'returns correct key' do
+ existing_key = Gitlab::UsageDataCounters::HLLRedisCounter.send(:redis_key, event, date)
+
+ expect(described_class.new.redis_key(event, date, event[:aggregation])).to eq(existing_key)
+ end
+ end
+ end
+
+ context 'with weekly events' do
+ let(:events) { [{ aggregation: 'weekly', name: 'wiki_action' }] }
+
+ before do
+ allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:known_events).and_return(events)
+ end
+
+ it 'does not migrate weekly events' do
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis).not_to receive(:pfmerge)
+ expect(redis).not_to receive(:expire)
+ end
+
+ migrate!
+ end
+ end
+
+ context 'with daily events' do
+ let(:daily_expiry) { Gitlab::UsageDataCounters::HLLRedisCounter::DEFAULT_DAILY_KEY_EXPIRY_LENGTH }
+ let(:weekly_expiry) { Gitlab::UsageDataCounters::HLLRedisCounter::DEFAULT_WEEKLY_KEY_EXPIRY_LENGTH }
+
+ it 'doesnt override events from migrated keys (code deployed before migration)' do
+ events = [{ aggregation: 'daily', name: 'users_viewing_analytics' },
+ { aggregation: 'weekly', name: 'users_viewing_analytics' }]
+ allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:known_events).and_return(events)
+
+ day = (Date.today - 1.week).beginning_of_week
+ daily_event = events.first
+ key_daily1 = Gitlab::UsageDataCounters::HLLRedisCounter.send(:redis_key, daily_event, day)
+ Gitlab::Redis::HLL.add(key: key_daily1, value: 1, expiry: daily_expiry)
+ key_daily2 = Gitlab::UsageDataCounters::HLLRedisCounter.send(:redis_key, daily_event, day + 2.days)
+ Gitlab::Redis::HLL.add(key: key_daily2, value: 2, expiry: daily_expiry)
+ key_daily3 = Gitlab::UsageDataCounters::HLLRedisCounter.send(:redis_key, daily_event, day + 5.days)
+ Gitlab::Redis::HLL.add(key: key_daily3, value: 3, expiry: daily_expiry)
+
+ # the same event but with weekly aggregation and pre-Redis migration
+ weekly_event = events.second
+ key_weekly = Gitlab::UsageDataCounters::HLLRedisCounter.send(:redis_key, weekly_event, day + 5.days)
+ Gitlab::Redis::HLL.add(key: key_weekly, value: 3, expiry: weekly_expiry)
+
+ migrate!
+
+ expect(Gitlab::Redis::HLL.count(keys: key_weekly)).to eq(3)
+ end
+
+ it 'migrates with correct parameters', :aggregate_failures do
+ events = [{ aggregation: 'daily', name: 'users_viewing_analytics_group_devops_adoption' }]
+ allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:known_events).and_return(events)
+
+ event = events.first.dup.tap { |e| e[:aggregation] = 'weekly' }
+ # For every day in the last 30 days, add a value to the daily key with daily expiry (including today)
+ 31.times do |i|
+ key = Gitlab::UsageDataCounters::HLLRedisCounter.send(:redis_key, event, Date.today - i.days)
+ Gitlab::Redis::HLL.add(key: key, value: i, expiry: daily_expiry)
+ end
+
+ migrate!
+
+ new_key = Gitlab::UsageDataCounters::HLLRedisCounter.send(:redis_key, event, Date.today)
+ # for the current week we should have value eq to the day of the week (ie. 1 for Monday, 2 for Tuesday, etc.)
+ first_week_days = Date.today.cwday
+ expect(Gitlab::Redis::HLL.count(keys: new_key)).to eq(first_week_days)
+ expect(with_redis { |r| r.ttl(new_key) }).to be_within(600).of(weekly_expiry)
+
+ full_weeks = (31 - first_week_days) / 7
+ # for the next full weeks we should have value eq to 7 (ie. 7 days in a week)
+ (1..full_weeks).each do |i|
+ new_key = Gitlab::UsageDataCounters::HLLRedisCounter.send(:redis_key, event, Date.today - i.weeks)
+ expect(Gitlab::Redis::HLL.count(keys: new_key)).to eq(7)
+ expect(with_redis { |r| r.ttl(new_key) }).to be_within(600).of(weekly_expiry)
+ end
+
+ # for the last week we should have value eq to amount of rest of the days affected
+ last_week_days = 31 - ((full_weeks * 7) + first_week_days)
+ unless last_week_days.zero?
+ last_week = full_weeks + 1
+ new_key = Gitlab::UsageDataCounters::HLLRedisCounter.send(:redis_key, event, Date.today - last_week.weeks)
+ expect(Gitlab::Redis::HLL.count(keys: new_key)).to eq(last_week_days)
+ expect(with_redis { |r| r.ttl(new_key) }).to be_within(600).of(weekly_expiry)
+ end
+ end
+ end
+
+ def with_redis(&block)
+ Gitlab::Redis::SharedState.with(&block)
+ end
+end
diff --git a/spec/migrations/20230317162059_add_current_user_todos_work_item_widget_spec.rb b/spec/migrations/20230317162059_add_current_user_todos_work_item_widget_spec.rb
new file mode 100644
index 00000000000..1df80a519f2
--- /dev/null
+++ b/spec/migrations/20230317162059_add_current_user_todos_work_item_widget_spec.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddCurrentUserTodosWorkItemWidget, :migration, feature_category: :team_planning do
+ it_behaves_like 'migration that adds widget to work items definitions', widget_name: 'Current user todos'
+end
diff --git a/spec/migrations/20230321153035_add_package_id_created_at_desc_index_to_package_files_spec.rb b/spec/migrations/20230321153035_add_package_id_created_at_desc_index_to_package_files_spec.rb
new file mode 100644
index 00000000000..68f3b1f23a9
--- /dev/null
+++ b/spec/migrations/20230321153035_add_package_id_created_at_desc_index_to_package_files_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddPackageIdCreatedAtDescIndexToPackageFiles, feature_category: :package_registry do
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(ActiveRecord::Base.connection.indexes('packages_package_files').map(&:name))
+ .not_to include('index_packages_package_files_on_package_id_and_created_at_desc')
+ }
+
+ migration.after -> {
+ expect(ActiveRecord::Base.connection.indexes('packages_package_files').map(&:name))
+ .to include('index_packages_package_files_on_package_id_and_created_at_desc')
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230321163947_backfill_ml_candidates_project_id_spec.rb b/spec/migrations/20230321163947_backfill_ml_candidates_project_id_spec.rb
new file mode 100644
index 00000000000..da76794a74c
--- /dev/null
+++ b/spec/migrations/20230321163947_backfill_ml_candidates_project_id_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe BackfillMlCandidatesProjectId, feature_category: :mlops do
+ let(:migration) { described_class.new }
+
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+ let(:ml_experiments) { table(:ml_experiments) }
+ let(:ml_candidates) { table(:ml_candidates) }
+
+ let(:namespace1) { namespaces.create!(name: 'foo', path: 'foo') }
+ let(:namespace2) { namespaces.create!(name: 'bar', path: 'bar') }
+ let(:project1) { projects.create!(project_namespace_id: namespace1.id, namespace_id: namespace1.id) }
+ let(:project2) { projects.create!(project_namespace_id: namespace2.id, namespace_id: namespace2.id) }
+ let(:experiment1) { ml_experiments.create!(project_id: project1.id, iid: 1, name: 'experiment') }
+ let(:experiment2) { ml_experiments.create!(project_id: project2.id, iid: 1, name: 'experiment') }
+ let!(:candidate1) do
+ ml_candidates.create!(experiment_id: experiment1.id, project_id: nil, eid: SecureRandom.uuid)
+ end
+
+ let!(:candidate2) do
+ ml_candidates.create!(experiment_id: experiment2.id, project_id: nil, eid: SecureRandom.uuid)
+ end
+
+ let!(:candidate3) do
+ ml_candidates.create!(experiment_id: experiment1.id, project_id: project1.id, eid: SecureRandom.uuid)
+ end
+
+ describe '#up' do
+ it 'sets the correct project_id with idempotency', :aggregate_failures do
+ migration.up
+
+ expect(candidate1.reload.project_id).to be(project1.id)
+ expect(candidate2.reload.project_id).to be(project2.id)
+ # in case we have candidates added between the column addition and the migration
+ expect(candidate3.reload.project_id).to be(project1.id)
+
+ migration.down
+ migration.up
+
+ expect(candidate1.reload.project_id).to be(project1.id)
+ expect(candidate2.reload.project_id).to be(project2.id)
+ expect(candidate3.reload.project_id).to be(project1.id)
+ end
+ end
+end
diff --git a/spec/migrations/20230321170823_backfill_ml_candidates_internal_id_spec.rb b/spec/migrations/20230321170823_backfill_ml_candidates_internal_id_spec.rb
new file mode 100644
index 00000000000..c8f7b19490a
--- /dev/null
+++ b/spec/migrations/20230321170823_backfill_ml_candidates_internal_id_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe BackfillMlCandidatesInternalId, feature_category: :mlops do
+ let(:migration) { described_class.new }
+
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+ let(:ml_experiments) { table(:ml_experiments) }
+ let(:ml_candidates) { table(:ml_candidates) }
+
+ let(:namespace1) { namespaces.create!(name: 'foo', path: 'foo') }
+ let(:namespace2) { namespaces.create!(name: 'bar', path: 'bar') }
+ let(:project1) { projects.create!(project_namespace_id: namespace1.id, namespace_id: namespace1.id) }
+ let(:project2) { projects.create!(project_namespace_id: namespace2.id, namespace_id: namespace2.id) }
+ let(:experiment1) { ml_experiments.create!(project_id: project1.id, iid: 1, name: 'experiment1') }
+ let(:experiment2) { ml_experiments.create!(project_id: project1.id, iid: 2, name: 'experiment2') }
+ let(:experiment3) { ml_experiments.create!(project_id: project2.id, iid: 1, name: 'experiment3') }
+
+ let!(:candidate1) do
+ ml_candidates.create!(experiment_id: experiment1.id, project_id: project1.id, eid: SecureRandom.uuid)
+ end
+
+ let!(:candidate2) do
+ ml_candidates.create!(experiment_id: experiment2.id, project_id: project1.id, eid: SecureRandom.uuid)
+ end
+
+ let!(:candidate3) do
+ ml_candidates.create!(experiment_id: experiment1.id, project_id: project1.id, eid: SecureRandom.uuid)
+ end
+
+ let!(:candidate4) do
+ ml_candidates.create!(experiment_id: experiment1.id, project_id: project1.id, internal_id: 1,
+ eid: SecureRandom.uuid)
+ end
+
+ let!(:candidate5) do
+ ml_candidates.create!(experiment_id: experiment3.id, project_id: project2.id, eid: SecureRandom.uuid)
+ end
+
+ describe '#up' do
+ it 'sets the correct project_id with idempotency', :aggregate_failures do
+ migration.up
+
+ expect(candidate4.reload.internal_id).to be(1) # candidate 4 already has an internal_id
+ expect(candidate1.reload.internal_id).to be(2)
+ expect(candidate2.reload.internal_id).to be(3)
+ expect(candidate3.reload.internal_id).to be(4)
+ expect(candidate5.reload.internal_id).to be(1) # candidate 5 is a different project
+
+ migration.down
+ migration.up
+
+ expect(candidate4.reload.internal_id).to be(1)
+ expect(candidate1.reload.internal_id).to be(2)
+ expect(candidate2.reload.internal_id).to be(3)
+ expect(candidate3.reload.internal_id).to be(4)
+ expect(candidate5.reload.internal_id).to be(1)
+ end
+ end
+end
diff --git a/spec/migrations/20230322085041_remove_user_namespace_records_from_vsa_aggregation_spec.rb b/spec/migrations/20230322085041_remove_user_namespace_records_from_vsa_aggregation_spec.rb
new file mode 100644
index 00000000000..e5f64ef2e70
--- /dev/null
+++ b/spec/migrations/20230322085041_remove_user_namespace_records_from_vsa_aggregation_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemoveUserNamespaceRecordsFromVsaAggregation,
+ migration: :gitlab_main,
+ feature_category: :value_stream_management do
+ let(:migration) { described_class::MIGRATION }
+ let!(:namespaces) { table(:namespaces) }
+ let!(:aggregations) { table(:analytics_cycle_analytics_aggregations) }
+
+ let!(:group) { namespaces.create!(name: 'aaa', path: 'aaa', type: 'Group') }
+ let!(:user_namespace) { namespaces.create!(name: 'ccc', path: 'ccc', type: 'User') }
+ let!(:project_namespace) { namespaces.create!(name: 'bbb', path: 'bbb', type: 'Project') }
+
+ let!(:group_aggregation) { aggregations.create!(group_id: group.id) }
+ let!(:user_namespace_aggregation) { aggregations.create!(group_id: user_namespace.id) }
+ let!(:project_namespace_aggregation) { aggregations.create!(group_id: project_namespace.id) }
+
+ describe '#up' do
+ it 'deletes the non-group namespace aggregation records' do
+ stub_const('RemoveUserNamespaceRecordsFromVsaAggregation::BATCH_SIZE', 1)
+
+ expect { migrate! }.to change {
+ aggregations.order(:group_id)
+ }.from([group_aggregation, user_namespace_aggregation,
+ project_namespace_aggregation]).to([group_aggregation])
+ end
+ end
+
+ describe '#down' do
+ it 'does nothing' do
+ migrate!
+
+ expect { schema_migrate_down! }.not_to change {
+ aggregations.order(:group_id).pluck(:group_id)
+ }.from([group_aggregation.id])
+ end
+ end
+end
diff --git a/spec/migrations/20230322145403_add_project_id_foreign_key_to_packages_npm_metadata_caches_spec.rb b/spec/migrations/20230322145403_add_project_id_foreign_key_to_packages_npm_metadata_caches_spec.rb
new file mode 100644
index 00000000000..647c583aa39
--- /dev/null
+++ b/spec/migrations/20230322145403_add_project_id_foreign_key_to_packages_npm_metadata_caches_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe AddProjectIdForeignKeyToPackagesNpmMetadataCaches,
+ feature_category: :package_registry do
+ let(:table) { described_class::SOURCE_TABLE }
+ let(:column) { described_class::COLUMN }
+ let(:foreign_key) { -> { described_class.new.foreign_keys_for(table, column).first } }
+
+ it 'creates and drops the foreign key' do
+ reversible_migration do |migration|
+ migration.before -> do
+ expect(foreign_key.call).to be(nil)
+ end
+
+ migration.after -> do
+ expect(foreign_key.call).to have_attributes(column: column.to_s)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230323101138_add_award_emoji_work_item_widget_spec.rb b/spec/migrations/20230323101138_add_award_emoji_work_item_widget_spec.rb
new file mode 100644
index 00000000000..16a205c5da5
--- /dev/null
+++ b/spec/migrations/20230323101138_add_award_emoji_work_item_widget_spec.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddAwardEmojiWorkItemWidget, :migration, feature_category: :team_planning do
+ it_behaves_like 'migration that adds widget to work items definitions', widget_name: 'Award emoji'
+end
diff --git a/spec/migrations/20230327103401_queue_migrate_human_user_type_spec.rb b/spec/migrations/20230327103401_queue_migrate_human_user_type_spec.rb
new file mode 100644
index 00000000000..e5852c93471
--- /dev/null
+++ b/spec/migrations/20230327103401_queue_migrate_human_user_type_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueMigrateHumanUserType, feature_category: :user_management do
+ let(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :users,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230327123333_backfill_product_analytics_data_collector_host_spec.rb b/spec/migrations/20230327123333_backfill_product_analytics_data_collector_host_spec.rb
new file mode 100644
index 00000000000..253512c9194
--- /dev/null
+++ b/spec/migrations/20230327123333_backfill_product_analytics_data_collector_host_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+require_migration!
+
+RSpec.describe BackfillProductAnalyticsDataCollectorHost, feature_category: :product_analytics do
+ let!(:application_settings) { table(:application_settings) }
+
+ describe '#up' do
+ before do
+ create_application_settings!(id: 1, jitsu_host: "https://configurator.testing.my-product-analytics.com",
+ product_analytics_data_collector_host: nil)
+ create_application_settings!(id: 2, jitsu_host: "https://config-urator_1.testing.my-product-analytics.com",
+ product_analytics_data_collector_host: nil)
+ create_application_settings!(id: 3, jitsu_host: "https://configurator.testing.my-product-analytics.com",
+ product_analytics_data_collector_host: "https://existingcollector.my-product-analytics.com")
+ create_application_settings!(id: 4, jitsu_host: nil, product_analytics_data_collector_host: nil)
+ migrate!
+ end
+
+ describe 'when jitsu host is present' do
+ it 'backfills missing product_analytics_data_collector_host' do
+ expect(application_settings.find(1).product_analytics_data_collector_host).to eq("https://collector.testing.my-product-analytics.com")
+ expect(application_settings.find(2).product_analytics_data_collector_host).to eq("https://collector.testing.my-product-analytics.com")
+ end
+
+ it 'does not modify existing product_analytics_data_collector_host' do
+ expect(application_settings.find(3).product_analytics_data_collector_host).to eq("https://existingcollector.my-product-analytics.com")
+ end
+ end
+
+ describe 'when jitsu host is not present' do
+ it 'does not backfill product_analytics_data_collector_host' do
+ expect(application_settings.find(4).product_analytics_data_collector_host).to be_nil
+ end
+ end
+ end
+
+ def create_application_settings!(id:, jitsu_host:, product_analytics_data_collector_host:)
+ params = {
+ id: id,
+ jitsu_host: jitsu_host,
+ product_analytics_data_collector_host: product_analytics_data_collector_host
+ }
+ application_settings.create!(params)
+ end
+end
diff --git a/spec/migrations/20230328030101_add_secureflag_training_provider_spec.rb b/spec/migrations/20230328030101_add_secureflag_training_provider_spec.rb
new file mode 100644
index 00000000000..774ea89937a
--- /dev/null
+++ b/spec/migrations/20230328030101_add_secureflag_training_provider_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddSecureflagTrainingProvider, :migration, feature_category: :vulnerability_management do
+ include MigrationHelpers::WorkItemTypesHelper
+
+ let!(:security_training_providers) { table(:security_training_providers) }
+
+ it 'adds additional provider' do
+ # Need to delete all as security training providers are seeded before entire test suite
+ security_training_providers.delete_all
+
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(security_training_providers.count).to eq(0)
+ }
+
+ migration.after -> {
+ expect(security_training_providers.count).to eq(1)
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230328100534_truncate_error_tracking_tables_spec.rb b/spec/migrations/20230328100534_truncate_error_tracking_tables_spec.rb
new file mode 100644
index 00000000000..efbbe22fd1b
--- /dev/null
+++ b/spec/migrations/20230328100534_truncate_error_tracking_tables_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe TruncateErrorTrackingTables, :migration, feature_category: :redis do
+ let(:migration) { described_class.new }
+
+ context 'when on GitLab.com' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(true)
+ end
+
+ context 'when using Main db' do
+ it 'truncates the table' do
+ expect(described_class.connection).to receive(:execute).with('TRUNCATE table error_tracking_errors CASCADE')
+
+ migration.up
+ end
+ end
+
+ context 'when uses CI db', migration: :gitlab_ci do
+ before do
+ skip_if_multiple_databases_not_setup(:ci)
+ end
+
+ it 'does not truncate the table' do
+ expect(described_class.connection).not_to receive(:execute).with('TRUNCATE table error_tracking_errors CASCADE')
+
+ migration.up
+ end
+ end
+ end
+
+ context 'when on self-managed' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(false)
+ end
+
+ context 'when using Main db' do
+ it 'does not truncate the table' do
+ expect(described_class.connection).not_to receive(:execute).with('TRUNCATE table error_tracking_errors CASCADE')
+
+ migration.up
+ end
+ end
+
+ context 'when uses CI db', migration: :gitlab_ci do
+ it 'does not truncate the table' do
+ expect(described_class.connection).not_to receive(:execute).with('TRUNCATE table error_tracking_errors CASCADE')
+
+ migration.up
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230329100222_drop_software_licenses_temp_index_spec.rb b/spec/migrations/20230329100222_drop_software_licenses_temp_index_spec.rb
new file mode 100644
index 00000000000..d4d276980f8
--- /dev/null
+++ b/spec/migrations/20230329100222_drop_software_licenses_temp_index_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe DropSoftwareLicensesTempIndex, feature_category: :security_policy_management do
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(ActiveRecord::Base.connection.indexes('software_licenses').map(&:name))
+ .to include(described_class::INDEX_NAME)
+ }
+
+ migration.after -> {
+ expect(ActiveRecord::Base.connection.indexes('software_licenses').map(&:name))
+ .not_to include(described_class::INDEX_NAME)
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230330103104_reschedule_migrate_evidences_spec.rb b/spec/migrations/20230330103104_reschedule_migrate_evidences_spec.rb
new file mode 100644
index 00000000000..8dc8cd68acb
--- /dev/null
+++ b/spec/migrations/20230330103104_reschedule_migrate_evidences_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RescheduleMigrateEvidences, :migration, feature_category: :vulnerability_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'does not schedule a batched background migration' do
+ migrate!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230403085957_add_tmp_partial_index_on_vulnerability_report_types2_spec.rb b/spec/migrations/20230403085957_add_tmp_partial_index_on_vulnerability_report_types2_spec.rb
new file mode 100644
index 00000000000..5203e772d15
--- /dev/null
+++ b/spec/migrations/20230403085957_add_tmp_partial_index_on_vulnerability_report_types2_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+require_migration!
+
+RSpec.describe AddTmpPartialIndexOnVulnerabilityReportTypes2, feature_category: :vulnerability_management do
+ let(:async_index) { Gitlab::Database::AsyncIndexes::PostgresAsyncIndex }
+ let(:index_name) { described_class::INDEX_NAME }
+
+ before do
+ allow_any_instance_of(ActiveRecord::ConnectionAdapters::SchemaStatements) # rubocop:disable RSpec/AnyInstanceOf
+ .to receive(:index_exists?)
+ .with("vulnerability_occurrences", :id, hash_including(name: index_name))
+ .and_return(index_exists)
+ end
+
+ context "with index absent" do
+ let(:index_exists) { false }
+
+ it "schedules the index" do
+ reversible_migration do |migration|
+ migration.before -> do
+ expect(async_index.where(name: index_name).count).to be(0)
+ end
+
+ migration.after -> do
+ expect(async_index.where(name: index_name).count).to be(1)
+ end
+ end
+ end
+ end
+
+ context "with index present" do
+ let(:index_exists) { true }
+
+ it "does not schedule the index" do
+ reversible_migration do |migration|
+ migration.before -> do
+ expect(async_index.where(name: index_name).count).to be(0)
+ end
+
+ migration.after -> do
+ expect(async_index.where(name: index_name).count).to be(0)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230405200858_requeue_backfill_project_wiki_repositories_spec.rb b/spec/migrations/20230405200858_requeue_backfill_project_wiki_repositories_spec.rb
new file mode 100644
index 00000000000..cf42818152f
--- /dev/null
+++ b/spec/migrations/20230405200858_requeue_backfill_project_wiki_repositories_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RequeueBackfillProjectWikiRepositories, feature_category: :geo_replication do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :projects,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230406121544_queue_backfill_design_management_repositories_spec.rb b/spec/migrations/20230406121544_queue_backfill_design_management_repositories_spec.rb
new file mode 100644
index 00000000000..39ef769fd11
--- /dev/null
+++ b/spec/migrations/20230406121544_queue_backfill_design_management_repositories_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueBackfillDesignManagementRepositories, feature_category: :geo_replication do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :projects,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230411153310_cleanup_bigint_conversion_for_sent_notifications_spec.rb b/spec/migrations/20230411153310_cleanup_bigint_conversion_for_sent_notifications_spec.rb
new file mode 100644
index 00000000000..5780aa365da
--- /dev/null
+++ b/spec/migrations/20230411153310_cleanup_bigint_conversion_for_sent_notifications_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('cleanup_bigint_conversion_for_sent_notifications')
+
+RSpec.describe CleanupBigintConversionForSentNotifications, feature_category: :database do
+ let(:sent_notifications) { table(:sent_notifications) }
+
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(sent_notifications.column_names).to include('id_convert_to_bigint')
+ }
+
+ migration.after -> {
+ sent_notifications.reset_column_information
+ expect(sent_notifications.column_names).not_to include('id_convert_to_bigint')
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230412141541_reschedule_links_avoiding_duplication_spec.rb b/spec/migrations/20230412141541_reschedule_links_avoiding_duplication_spec.rb
new file mode 100644
index 00000000000..06eccf03ca4
--- /dev/null
+++ b/spec/migrations/20230412141541_reschedule_links_avoiding_duplication_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RescheduleLinksAvoidingDuplication, :migration, feature_category: :vulnerability_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules a batched background migration' do
+ migrate!
+
+ expect(migration).to have_scheduled_batched_migration(
+ table_name: :vulnerability_occurrences,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230412185837_queue_populate_vulnerability_dismissal_fields_spec.rb b/spec/migrations/20230412185837_queue_populate_vulnerability_dismissal_fields_spec.rb
new file mode 100644
index 00000000000..d39936abb90
--- /dev/null
+++ b/spec/migrations/20230412185837_queue_populate_vulnerability_dismissal_fields_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueuePopulateVulnerabilityDismissalFields, feature_category: :vulnerability_management do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :vulnerabilities,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ }
+ end
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230412214119_finalize_encrypt_ci_trigger_token_spec.rb b/spec/migrations/20230412214119_finalize_encrypt_ci_trigger_token_spec.rb
new file mode 100644
index 00000000000..c30cafc915d
--- /dev/null
+++ b/spec/migrations/20230412214119_finalize_encrypt_ci_trigger_token_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe FinalizeEncryptCiTriggerToken, migration: :gitlab_ci, feature_category: :continuous_integration do
+ let(:batched_migrations) { table(:batched_background_migrations) }
+ let(:batch_failed_status) { 2 }
+ let(:batch_finalized_status) { 3 }
+
+ let!(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
+ it 'warns migration not found' do
+ expect(Gitlab::AppLogger)
+ .to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
+
+ migrate!
+ end
+ end
+
+ context 'with migration present' do
+ let!(:migration_record) do
+ batched_migrations.create!(
+ job_class_name: migration,
+ table_name: :ci_triggers,
+ column_name: :id,
+ job_arguments: [],
+ interval: 2.minutes,
+ min_value: 1,
+ max_value: 2,
+ batch_size: 1000,
+ sub_batch_size: 100,
+ max_batch_size: 2000,
+ gitlab_schema: :gitlab_ci,
+ status: batch_finalized_status
+ )
+ end
+
+ context 'when migration finished successfully' do
+ it 'does not raise exception' do
+ expect { migrate! }.not_to raise_error
+ end
+ end
+
+ context 'with different migration statuses', :redis do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:status, :description) do
+ 0 | 'paused'
+ 1 | 'active'
+ 4 | 'failed'
+ 5 | 'finalizing'
+ end
+
+ with_them do
+ let!(:failed_job) do
+ table(:batched_background_migration_jobs).create!(
+ batched_background_migration_id: migration_record.id,
+ status: batch_failed_status,
+ min_value: 1,
+ max_value: 10,
+ attempts: 2,
+ batch_size: 100,
+ sub_batch_size: 10
+ )
+ end
+
+ before do
+ migration_record.update!(status: status)
+ end
+
+ it 'finalizes the migration' do
+ expect do
+ migrate!
+
+ migration_record.reload
+ failed_job.reload
+ end.to(
+ change { migration_record.status }.from(status).to(batch_finalized_status)
+ .and(
+ change { failed_job.status }.from(batch_failed_status).to(batch_finalized_status)
+ )
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230418215853_add_assignee_widget_to_incidents_spec.rb b/spec/migrations/20230418215853_add_assignee_widget_to_incidents_spec.rb
new file mode 100644
index 00000000000..d809d0e6a54
--- /dev/null
+++ b/spec/migrations/20230418215853_add_assignee_widget_to_incidents_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddAssigneeWidgetToIncidents, :migration, feature_category: :team_planning do
+ let(:migration) { described_class.new }
+ let(:work_item_definitions) { table(:work_item_widget_definitions) }
+ let(:work_item_types) { table(:work_item_types) }
+
+ let(:widget_name) { 'Assignees' }
+ let(:work_item_type) { 'Incident' }
+
+ describe '#up' do
+ it 'creates widget definition' do
+ type = work_item_types.find_by_name_and_namespace_id(work_item_type, nil)
+ work_item_definitions.where(work_item_type_id: type, name: widget_name).delete_all if type
+
+ expect { migrate! }.to change { work_item_definitions.count }.by(1)
+
+ type = work_item_types.find_by_name_and_namespace_id(work_item_type, nil)
+
+ expect(work_item_definitions.where(work_item_type_id: type, name: widget_name).count).to eq 1
+ end
+
+ it 'logs a warning if the type is missing' do
+ allow(described_class::WorkItemType).to receive(:find_by_name_and_namespace_id).and_call_original
+ allow(described_class::WorkItemType).to receive(:find_by_name_and_namespace_id)
+ .with(work_item_type, nil).and_return(nil)
+
+ expect(Gitlab::AppLogger).to receive(:warn).with(AddAssigneeWidgetToIncidents::FAILURE_MSG)
+ migrate!
+ end
+ end
+
+ describe '#down' do
+ it 'removes definitions for widget' do
+ migrate!
+
+ expect { migration.down }.to change { work_item_definitions.count }.by(-1)
+
+ type = work_item_types.find_by_name_and_namespace_id(work_item_type, nil)
+
+ expect(work_item_definitions.where(work_item_type_id: type, name: widget_name).count).to eq 0
+ end
+ end
+end
diff --git a/spec/migrations/20230419105225_remove_phabricator_from_application_settings_spec.rb b/spec/migrations/20230419105225_remove_phabricator_from_application_settings_spec.rb
new file mode 100644
index 00000000000..df84c8efd05
--- /dev/null
+++ b/spec/migrations/20230419105225_remove_phabricator_from_application_settings_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemovePhabricatorFromApplicationSettings, feature_category: :importers do
+ let(:settings) { table(:application_settings) }
+ let(:import_sources_with_phabricator) { %w[phabricator github git bitbucket bitbucket_server] }
+ let(:import_sources_without_phabricator) { %w[github git bitbucket bitbucket_server] }
+
+ describe "#up" do
+ it 'removes phabricator and preserves existing valid import sources' do
+ record = settings.create!(import_sources: import_sources_with_phabricator)
+
+ migrate!
+
+ expect(record.reload.import_sources).to start_with('---')
+
+ expect(ApplicationSetting.last.import_sources).to eq(import_sources_without_phabricator)
+ end
+ end
+end
diff --git a/spec/migrations/20230426102200_fix_import_sources_on_application_settings_after_phabricator_removal_spec.rb b/spec/migrations/20230426102200_fix_import_sources_on_application_settings_after_phabricator_removal_spec.rb
new file mode 100644
index 00000000000..aa5ec462871
--- /dev/null
+++ b/spec/migrations/20230426102200_fix_import_sources_on_application_settings_after_phabricator_removal_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe FixImportSourcesOnApplicationSettingsAfterPhabricatorRemoval, feature_category: :importers do
+ let(:settings) { table(:application_settings) }
+ let(:import_sources) { %w[github git bitbucket bitbucket_server] }
+
+ describe "#up" do
+ shared_examples 'fixes import_sources on application_settings' do
+ it 'ensures YAML is stored' do
+ record = settings.create!(import_sources: data)
+
+ migrate!
+
+ expect(record.reload.import_sources).to start_with('---')
+ expect(ApplicationSetting.last.import_sources).to eq(import_sources)
+ end
+ end
+
+ context 'when import_sources is a String' do
+ let(:data) { import_sources.to_s }
+
+ it_behaves_like 'fixes import_sources on application_settings'
+ end
+
+ context 'when import_sources is already YAML' do
+ let(:data) { import_sources.to_yaml }
+
+ it_behaves_like 'fixes import_sources on application_settings'
+ end
+ end
+end
diff --git a/spec/migrations/20230428085332_remove_shimo_zentao_integration_records_spec.rb b/spec/migrations/20230428085332_remove_shimo_zentao_integration_records_spec.rb
new file mode 100644
index 00000000000..1d2fbb6b95d
--- /dev/null
+++ b/spec/migrations/20230428085332_remove_shimo_zentao_integration_records_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe RemoveShimoZentaoIntegrationRecords, feature_category: :integrations do
+ let(:integrations) { table(:integrations) }
+ let(:zentao_tracker_data) { table(:zentao_tracker_data) }
+
+ before do
+ integrations.create!(id: 1, type_new: 'Integrations::MockMonitoring')
+ integrations.create!(id: 2, type_new: 'Integrations::Redmine')
+ integrations.create!(id: 3, type_new: 'Integrations::Confluence')
+
+ integrations.create!(id: 4, type_new: 'Integrations::Shimo')
+ integrations.create!(id: 5, type_new: 'Integrations::Zentao')
+ integrations.create!(id: 6, type_new: 'Integrations::Zentao')
+ zentao_tracker_data.create!(id: 1, integration_id: 5)
+ zentao_tracker_data.create!(id: 2, integration_id: 6)
+ end
+
+ context 'with CE/EE env' do
+ it 'destroys all shimo and zentao integrations' do
+ migrate!
+
+ expect(integrations.count).to eq(3) # keep other integrations
+ expect(integrations.where(type_new: described_class::TYPES).count).to eq(0)
+ expect(zentao_tracker_data.count).to eq(0)
+ end
+ end
+
+ context 'with JiHu env' do
+ before do
+ allow(Gitlab).to receive(:jh?).and_return(true)
+ end
+
+ it 'keeps shimo and zentao integrations' do
+ migrate!
+
+ expect(integrations.count).to eq(6)
+ expect(integrations.where(type_new: described_class::TYPES).count).to eq(3)
+ expect(zentao_tracker_data.count).to eq(2)
+ end
+ end
+end
diff --git a/spec/migrations/20230502102832_schedule_index_to_members_on_source_and_type_and_access_level_spec.rb b/spec/migrations/20230502102832_schedule_index_to_members_on_source_and_type_and_access_level_spec.rb
new file mode 100644
index 00000000000..34d69642802
--- /dev/null
+++ b/spec/migrations/20230502102832_schedule_index_to_members_on_source_and_type_and_access_level_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+require_migration!
+
+RSpec.describe ScheduleIndexToMembersOnSourceAndTypeAndAccessLevel, feature_category: :security_policy_management do
+ let(:async_index) { Gitlab::Database::AsyncIndexes::PostgresAsyncIndex }
+ let(:index_name) { described_class::INDEX_NAME }
+
+ it "schedules the index" do
+ reversible_migration do |migration|
+ migration.before -> do
+ expect(async_index.where(name: index_name).count).to be(0)
+ end
+
+ migration.after -> do
+ expect(async_index.where(name: index_name).count).to be(1)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230502120021_schedule_index_to_project_authorizations_on_project_user_access_level_spec.rb b/spec/migrations/20230502120021_schedule_index_to_project_authorizations_on_project_user_access_level_spec.rb
new file mode 100644
index 00000000000..9759fa7862d
--- /dev/null
+++ b/spec/migrations/20230502120021_schedule_index_to_project_authorizations_on_project_user_access_level_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+require_migration!
+
+RSpec.describe ScheduleIndexToProjectAuthorizationsOnProjectUserAccessLevel, feature_category: :security_policy_management do
+ let(:async_index) { Gitlab::Database::AsyncIndexes::PostgresAsyncIndex }
+ let(:index_name) { described_class::INDEX_NAME }
+
+ it "schedules the index" do
+ reversible_migration do |migration|
+ migration.before -> do
+ expect(async_index.where(name: index_name).count).to be(0)
+ end
+
+ migration.after -> do
+ expect(async_index.where(name: index_name).count).to be(1)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20230504084524_remove_gitlab_import_source_spec.rb b/spec/migrations/20230504084524_remove_gitlab_import_source_spec.rb
new file mode 100644
index 00000000000..b2442359e87
--- /dev/null
+++ b/spec/migrations/20230504084524_remove_gitlab_import_source_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemoveGitlabImportSource, feature_category: :importers do
+ let(:settings) { table(:application_settings) }
+ let(:import_sources_with_gitlab) { %w[github git gitlab bitbucket bitbucket_server] }
+ let(:import_sources_without_gitlab) { %w[github git bitbucket bitbucket_server] }
+
+ describe "#up" do
+ it 'removes gitlab and preserves existing valid import sources' do
+ record = settings.create!(import_sources: import_sources_with_gitlab)
+
+ migrate!
+
+ expect(record.reload.import_sources).to start_with('---')
+
+ expect(ApplicationSetting.last.import_sources).to eq(import_sources_without_gitlab)
+ end
+ end
+end
diff --git a/spec/migrations/20230508150219_reschedule_evidences_handling_unicode_spec.rb b/spec/migrations/20230508150219_reschedule_evidences_handling_unicode_spec.rb
new file mode 100644
index 00000000000..9ba44984372
--- /dev/null
+++ b/spec/migrations/20230508150219_reschedule_evidences_handling_unicode_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RescheduleEvidencesHandlingUnicode, :migration, feature_category: :vulnerability_management do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules a batched background migration' do
+ migrate!
+
+ expect(migration).to have_scheduled_batched_migration(
+ table_name: :vulnerability_occurrences,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/20230508175057_backfill_corrected_secure_files_expirations_spec.rb b/spec/migrations/20230508175057_backfill_corrected_secure_files_expirations_spec.rb
new file mode 100644
index 00000000000..570be0e02c7
--- /dev/null
+++ b/spec/migrations/20230508175057_backfill_corrected_secure_files_expirations_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe BackfillCorrectedSecureFilesExpirations, migration: :gitlab_ci, feature_category: :mobile_devops do
+ let(:migration) { described_class.new }
+ let(:ci_secure_files) { table(:ci_secure_files) }
+
+ let!(:file1) { ci_secure_files.create!(project_id: 1, name: "file.cer", file: "foo", checksum: 'bar') }
+ let!(:file2) { ci_secure_files.create!(project_id: 1, name: "file.p12", file: "foo", checksum: 'bar') }
+ let!(:file3) { ci_secure_files.create!(project_id: 1, name: "file.jks", file: "foo", checksum: 'bar') }
+
+ describe '#up' do
+ it 'enqueues the ParseSecureFileMetadataWorker job for relevant file types', :aggregate_failures do
+ expect(::Ci::ParseSecureFileMetadataWorker).to receive(:perform_async).with(file1.id)
+ expect(::Ci::ParseSecureFileMetadataWorker).to receive(:perform_async).with(file2.id)
+ expect(::Ci::ParseSecureFileMetadataWorker).not_to receive(:perform_async).with(file3.id)
+
+ migration.up
+ end
+ end
+end
diff --git a/spec/migrations/20230509131736_add_default_organization_spec.rb b/spec/migrations/20230509131736_add_default_organization_spec.rb
new file mode 100644
index 00000000000..539216c57ee
--- /dev/null
+++ b/spec/migrations/20230509131736_add_default_organization_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe AddDefaultOrganization, feature_category: :cell do
+ let(:organization) { table(:organizations) }
+
+ it "correctly migrates up and down" do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(organization.where(id: 1, name: 'Default')).to be_empty
+ }
+ migration.after -> {
+ expect(organization.where(id: 1, name: 'Default')).not_to be_empty
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20230510062502_queue_cleanup_personal_access_tokens_with_nil_expires_at_spec.rb b/spec/migrations/20230510062502_queue_cleanup_personal_access_tokens_with_nil_expires_at_spec.rb
new file mode 100644
index 00000000000..45ef85a49cf
--- /dev/null
+++ b/spec/migrations/20230510062502_queue_cleanup_personal_access_tokens_with_nil_expires_at_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueCleanupPersonalAccessTokensWithNilExpiresAt, feature_category: :system_access do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :personal_access_tokens,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ }
+ end
+ end
+end
diff --git a/spec/migrations/add_open_source_plan_spec.rb b/spec/migrations/add_open_source_plan_spec.rb
deleted file mode 100644
index f5d68f455e6..00000000000
--- a/spec/migrations/add_open_source_plan_spec.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-require_migration!
-
-RSpec.describe AddOpenSourcePlan, :migration, feature_category: :purchase do
- describe '#up' do
- before do
- allow(Gitlab).to receive(:com?).and_return true
- end
-
- it 'creates 1 entry within the plans table' do
- expect { migrate! }.to change { AddOpenSourcePlan::Plan.count }.by 1
- expect(AddOpenSourcePlan::Plan.last.name).to eql('opensource')
- end
-
- it 'creates 1 entry for plan limits' do
- expect { migrate! }.to change { AddOpenSourcePlan::PlanLimits.count }.by 1
- end
-
- context 'when the plan limits for gold and silver exists' do
- before do
- table(:plans).create!(id: 1, name: 'ultimate', title: 'Ultimate')
- table(:plan_limits).create!(id: 1, plan_id: 1, storage_size_limit: 2000)
- end
-
- it 'duplicates the gold and silvers plan limits entries' do
- migrate!
-
- opensource_limits = AddOpenSourcePlan::Plan.find_by(name: 'opensource').limits
- expect(opensource_limits.storage_size_limit).to be 2000
- end
- end
-
- context 'when the instance is not SaaS' do
- before do
- allow(Gitlab).to receive(:com?).and_return false
- end
-
- it 'does not create plans and plan limits and returns' do
- expect { migrate! }.not_to change { AddOpenSourcePlan::Plan.count }
- end
- end
- end
-
- describe '#down' do
- before do
- table(:plans).create!(id: 3, name: 'other')
- table(:plan_limits).create!(plan_id: 3)
- end
-
- context 'when the instance is SaaS' do
- before do
- allow(Gitlab).to receive(:com?).and_return true
- end
-
- it 'removes the newly added opensource entry' do
- migrate!
-
- expect { described_class.new.down }.to change { AddOpenSourcePlan::Plan.count }.by(-1)
- expect(AddOpenSourcePlan::Plan.find_by(name: 'opensource')).to be_nil
-
- other_plan = AddOpenSourcePlan::Plan.find_by(name: 'other')
- expect(other_plan).to be_persisted
- expect(AddOpenSourcePlan::PlanLimits.count).to eq(1)
- expect(AddOpenSourcePlan::PlanLimits.first.plan_id).to eq(other_plan.id)
- end
- end
-
- context 'when the instance is not SaaS' do
- before do
- allow(Gitlab).to receive(:com?).and_return false
- table(:plans).create!(id: 1, name: 'opensource', title: 'Open Source Program')
- table(:plan_limits).create!(id: 1, plan_id: 1)
- end
-
- it 'does not delete plans and plan limits and returns' do
- migrate!
-
- expect { described_class.new.down }.not_to change { AddOpenSourcePlan::Plan.count }
- expect(AddOpenSourcePlan::PlanLimits.count).to eq(2)
- end
- end
- end
-end
diff --git a/spec/migrations/backfill_current_value_with_progress_work_item_progresses_spec.rb b/spec/migrations/backfill_current_value_with_progress_work_item_progresses_spec.rb
new file mode 100644
index 00000000000..632925b23b2
--- /dev/null
+++ b/spec/migrations/backfill_current_value_with_progress_work_item_progresses_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe BackfillCurrentValueWithProgressWorkItemProgresses, :migration, feature_category: :team_planning do
+ let(:namespaces) { table(:namespaces) }
+ let(:users) { table(:users) }
+ let(:projects) { table(:projects) }
+ let(:issues) { table(:issues) }
+ let(:progresses) { table(:work_item_progresses) }
+ let(:issue_base_type_enum_value) { 5 }
+ let(:issue_type) { table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_base_type_enum_value) }
+
+ let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
+ let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) }
+ let(:project) do
+ projects.create!(namespace_id: namespace.id, project_namespace_id: namespace.id, name: 'Alpha Gamma',
+ path: 'alpha-gamma')
+ end
+
+ let(:work_item1) do
+ issues.create!(
+ id: 1, project_id: project.id, namespace_id: project.project_namespace_id,
+ title: 'issue1', author_id: user.id, work_item_type_id: issue_type.id
+ )
+ end
+
+ let(:work_item2) do
+ issues.create!(
+ id: 2, project_id: project.id, namespace_id: project.project_namespace_id,
+ title: 'issue2', author_id: user.id, work_item_type_id: issue_type.id
+ )
+ end
+
+ let(:progress1) { progresses.create!(issue_id: work_item1.id, progress: 10) }
+ let(:progress2) { progresses.create!(issue_id: work_item2.id, progress: 60) }
+
+ describe '#up' do
+ it 'back fills current_value from progress columns' do
+ expect { migrate! }
+ .to change { progress1.reload.current_value }.from(0).to(10)
+ .and change { progress2.reload.current_value }.from(0).to(60)
+ .and not_change(progress1, :progress)
+ .and not_change(progress2, :progress)
+ end
+ end
+end
diff --git a/spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb b/spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb
index 5029a861d31..83b47da3065 100644
--- a/spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb
+++ b/spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require_migration!
-RSpec.describe BackfillIntegrationsEnableSslVerification, feature_category: :authentication_and_authorization do
+RSpec.describe BackfillIntegrationsEnableSslVerification, feature_category: :system_access do
let!(:migration) { described_class::MIGRATION }
let!(:integrations) { described_class::Integration }
diff --git a/spec/migrations/backfill_user_namespace_spec.rb b/spec/migrations/backfill_user_namespace_spec.rb
deleted file mode 100644
index a58030803b1..00000000000
--- a/spec/migrations/backfill_user_namespace_spec.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe BackfillUserNamespace, feature_category: :subgroups do
- let!(:migration) { described_class::MIGRATION }
-
- describe '#up' do
- it 'schedules background jobs for each batch of namespaces' do
- migrate!
-
- expect(migration).to have_scheduled_batched_migration(
- table_name: :namespaces,
- column_name: :id,
- interval: described_class::INTERVAL
- )
- end
- end
-
- describe '#down' do
- it 'deletes all batched migration records' do
- migrate!
- schema_migrate_down!
-
- expect(migration).not_to have_scheduled_batched_migration
- end
- end
-end
diff --git a/spec/migrations/bulk_insert_cluster_enabled_grants_spec.rb b/spec/migrations/bulk_insert_cluster_enabled_grants_spec.rb
index e85489198ee..71ffdd66d62 100644
--- a/spec/migrations/bulk_insert_cluster_enabled_grants_spec.rb
+++ b/spec/migrations/bulk_insert_cluster_enabled_grants_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require_migration!
-RSpec.describe BulkInsertClusterEnabledGrants, :migration, feature_category: :kubernetes_management do
+RSpec.describe BulkInsertClusterEnabledGrants, :migration, feature_category: :deployment_management do
let(:migration) { described_class.new }
let(:cluster_enabled_grants) { table(:cluster_enabled_grants) }
diff --git a/spec/migrations/cleanup_backfill_integrations_enable_ssl_verification_spec.rb b/spec/migrations/cleanup_backfill_integrations_enable_ssl_verification_spec.rb
index 7aaa90ee985..01c85f85e0b 100644
--- a/spec/migrations/cleanup_backfill_integrations_enable_ssl_verification_spec.rb
+++ b/spec/migrations/cleanup_backfill_integrations_enable_ssl_verification_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe CleanupBackfillIntegrationsEnableSslVerification, :migration,
-feature_category: :authentication_and_authorization do
+ feature_category: :system_access do
let(:job_class_name) { 'BackfillIntegrationsEnableSslVerification' }
before do
diff --git a/spec/migrations/cleanup_vulnerability_state_transitions_with_same_from_state_to_state_spec.rb b/spec/migrations/cleanup_vulnerability_state_transitions_with_same_from_state_to_state_spec.rb
index b808f03428d..b270f2b100f 100644
--- a/spec/migrations/cleanup_vulnerability_state_transitions_with_same_from_state_to_state_spec.rb
+++ b/spec/migrations/cleanup_vulnerability_state_transitions_with_same_from_state_to_state_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe CleanupVulnerabilityStateTransitionsWithSameFromStateToState, :migration,
-feature_category: :vulnerability_management do
+ feature_category: :vulnerability_management do
let!(:namespace) { table(:namespaces).create!(name: 'namespace', type: 'Group', path: 'namespace') }
let!(:user) { table(:users).create!(email: 'author@example.com', username: 'author', projects_limit: 10) }
let!(:project) do
diff --git a/spec/migrations/delete_migrate_shared_vulnerability_scanners_spec.rb b/spec/migrations/delete_migrate_shared_vulnerability_scanners_spec.rb
index 562b1e25db4..8a0c0250cdf 100644
--- a/spec/migrations/delete_migrate_shared_vulnerability_scanners_spec.rb
+++ b/spec/migrations/delete_migrate_shared_vulnerability_scanners_spec.rb
@@ -9,37 +9,41 @@ RSpec.describe DeleteMigrateSharedVulnerabilityScanners, :migration, feature_cat
let(:batched_background_migration_jobs) { table(:batched_background_migration_jobs) }
let(:migration) do
- batched_background_migrations.create!(created_at: Time.zone.now,
- updated_at: Time.zone.now,
- min_value: 1,
- max_value: 1,
- batch_size: described_class::BATCH_SIZE,
- sub_batch_size: 100,
- interval: 300,
- status: 3,
- job_class_name: described_class::MIGRATION,
- batch_class_name: "PrimaryKeyBatchingStrategy",
- table_name: described_class::TABLE_NAME,
- column_name: described_class::BATCH_COLUMN,
- job_arguments: [],
- pause_ms: 100,
- max_batch_size: 1000,
- gitlab_schema: "gitlab_main")
+ batched_background_migrations.create!(
+ created_at: Time.zone.now,
+ updated_at: Time.zone.now,
+ min_value: 1,
+ max_value: 1,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: 100,
+ interval: 300,
+ status: 3,
+ job_class_name: described_class::MIGRATION,
+ batch_class_name: "PrimaryKeyBatchingStrategy",
+ table_name: described_class::TABLE_NAME,
+ column_name: described_class::BATCH_COLUMN,
+ job_arguments: [],
+ pause_ms: 100,
+ max_batch_size: 1000,
+ gitlab_schema: "gitlab_main"
+ )
end
let(:jobs) do
Array.new(10) do
- batched_background_migration_jobs.create!(batched_background_migration_id: migration.id,
- created_at: Time.zone.now,
- updated_at: Time.zone.now,
- min_value: 1,
- max_value: 1,
- batch_size: 1,
- sub_batch_size: 1,
- status: 0,
- attempts: 0,
- metrics: {},
- pause_ms: 100)
+ batched_background_migration_jobs.create!(
+ batched_background_migration_id: migration.id,
+ created_at: Time.zone.now,
+ updated_at: Time.zone.now,
+ min_value: 1,
+ max_value: 1,
+ batch_size: 1,
+ sub_batch_size: 1,
+ status: 0,
+ attempts: 0,
+ metrics: {},
+ pause_ms: 100
+ )
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
deleted file mode 100644
index fddf3594e2b..00000000000
--- a/spec/migrations/disable_job_token_scope_when_unused_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe DisableJobTokenScopeWhenUnused, feature_category: :continuous_integration do
- it 'is a no-op' do
- migrate!
- end
-end
diff --git a/spec/migrations/drop_packages_events_table_spec.rb b/spec/migrations/drop_packages_events_table_spec.rb
new file mode 100644
index 00000000000..539a3b88196
--- /dev/null
+++ b/spec/migrations/drop_packages_events_table_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+require_migration!
+
+RSpec.describe DropPackagesEventsTable, feature_category: :package_registry do
+ let(:table) { described_class::SOURCE_TABLE }
+ let(:column) { described_class::COLUMN }
+
+ subject { described_class.new }
+
+ it 'drops and creates the packages_events table' do
+ reversible_migration do |migration|
+ migration.before -> do
+ expect(subject.table_exists?(:packages_events)).to eq(true)
+ end
+
+ migration.after -> do
+ expect(subject.table_exists?(:packages_events)).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/ensure_award_emoji_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_award_emoji_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..826320ec6c2
--- /dev/null
+++ b/spec/migrations/ensure_award_emoji_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureAwardEmojiBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'award_emoji',
+ column_name: 'id',
+ job_arguments: [['awardable_id'], ['awardable_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_commit_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_commit_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..89e14650034
--- /dev/null
+++ b/spec/migrations/ensure_commit_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureCommitUserMentionsNoteIdBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'commit_user_mentions',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_design_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_design_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..ac763af1a70
--- /dev/null
+++ b/spec/migrations/ensure_design_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureDesignUserMentionsNoteIdBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'design_user_mentions',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_epic_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_epic_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..a6b2f751b3b
--- /dev/null
+++ b/spec/migrations/ensure_epic_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureEpicUserMentionsBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'epic_user_mentions',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_issue_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_issue_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..602dd87c593
--- /dev/null
+++ b/spec/migrations/ensure_issue_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureIssueUserMentionsBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'issue_user_mentions',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_merge_request_metrics_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_merge_request_metrics_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..118e0058922
--- /dev/null
+++ b/spec/migrations/ensure_merge_request_metrics_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureMergeRequestMetricsIdBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'merge_request_metrics',
+ column_name: 'id',
+ job_arguments: [['id'], ['id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_merge_request_metrics_id_bigint_backfill_is_finished_for_self_hosts_spec.rb b/spec/migrations/ensure_merge_request_metrics_id_bigint_backfill_is_finished_for_self_hosts_spec.rb
new file mode 100644
index 00000000000..b946f816f3b
--- /dev/null
+++ b/spec/migrations/ensure_merge_request_metrics_id_bigint_backfill_is_finished_for_self_hosts_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureMergeRequestMetricsIdBigintBackfillIsFinishedForSelfHosts, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'merge_request_metrics',
+ column_name: 'id',
+ job_arguments: [['id'], ['id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_mr_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_mr_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..af9fc3f3b07
--- /dev/null
+++ b/spec/migrations/ensure_mr_user_mentions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureMrUserMentionsNoteIdBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'merge_request_user_mentions',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_note_diff_files_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_note_diff_files_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..acafc211e8c
--- /dev/null
+++ b/spec/migrations/ensure_note_diff_files_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureNoteDiffFilesBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'note_diff_files',
+ column_name: 'id',
+ job_arguments: [['diff_note_id'], ['diff_note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_notes_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_notes_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..2832be38f9d
--- /dev/null
+++ b/spec/migrations/ensure_notes_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureNotesBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'notes',
+ column_name: 'id',
+ job_arguments: [['id'], ['id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_snippet_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_snippet_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..b942a9a67a3
--- /dev/null
+++ b/spec/migrations/ensure_snippet_user_mentions_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureSnippetUserMentionsBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'snippet_user_mentions',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_suggestions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_suggestions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..f8dd700b160
--- /dev/null
+++ b/spec/migrations/ensure_suggestions_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureSuggestionsNoteIdBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'suggestions',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_system_note_metadata_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_system_note_metadata_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..11e087b63e2
--- /dev/null
+++ b/spec/migrations/ensure_system_note_metadata_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureSystemNoteMetadataBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'system_note_metadata',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_timelogs_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_timelogs_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..9f733f1e1f4
--- /dev/null
+++ b/spec/migrations/ensure_timelogs_note_id_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureTimelogsNoteIdBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'timelogs',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_todos_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb b/spec/migrations/ensure_todos_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..2b9d319be08
--- /dev/null
+++ b/spec/migrations/ensure_todos_bigint_backfill_is_finished_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureTodosBigintBackfillIsFinishedForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'todos',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/ensure_unique_debian_packages_spec.rb b/spec/migrations/ensure_unique_debian_packages_spec.rb
new file mode 100644
index 00000000000..eaa87ebd45e
--- /dev/null
+++ b/spec/migrations/ensure_unique_debian_packages_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+require_migration! 'add_unique_packages_index_when_debian'
+require_migration! 'add_tmp_unique_packages_index_when_debian'
+
+RSpec.describe EnsureUniqueDebianPackages, feature_category: :package_registry do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:packages) { table(:packages_packages) }
+
+ let!(:group) { namespaces.create!(name: 'group', path: 'group_path') }
+ let!(:project_namespace1) { namespaces.create!(name: 'name1', path: 'path1') }
+ let!(:project_namespace2) { namespaces.create!(name: 'name2', path: 'path2') }
+
+ let!(:project1) { projects.create!(namespace_id: group.id, project_namespace_id: project_namespace1.id) }
+ let!(:project2) { projects.create!(namespace_id: group.id, project_namespace_id: project_namespace2.id) }
+
+ let!(:debian_package1_1) do
+ packages.create!(project_id: project1.id, package_type: 9, name: FFaker::Lorem.word, version: 'v1.0')
+ end
+
+ let(:debian_package1_2) do
+ packages.create!(project_id: project1.id, package_type: 9, name: debian_package1_1.name,
+ version: debian_package1_1.version)
+ end
+
+ let!(:pypi_package1_3) do
+ packages.create!(project_id: project1.id, package_type: 5, name: debian_package1_1.name,
+ version: debian_package1_1.version)
+ end
+
+ let!(:debian_package2_1) do
+ packages.create!(project_id: project2.id, package_type: 9, name: debian_package1_1.name,
+ version: debian_package1_1.version)
+ end
+
+ before do
+ # Remove unique indices
+ AddUniquePackagesIndexWhenDebian.new.down
+ AddTmpUniquePackagesIndexWhenDebian.new.down
+ # Then create the duplicate packages
+ debian_package1_2
+ end
+
+ it 'marks as pending destruction the duplicated packages', :aggregate_failures do
+ expect { migrate! }
+ .to change { packages.where(status: 0).count }.from(4).to(3)
+ .and not_change { packages.where(status: 1).count }
+ .and not_change { packages.where(status: 2).count }
+ .and not_change { packages.where(status: 3).count }
+ .and change { packages.where(status: 4).count }.from(0).to(1)
+ end
+end
diff --git a/spec/migrations/ensure_vum_bigint_backfill_is_finished_for_gl_dot_com_spec.rb b/spec/migrations/ensure_vum_bigint_backfill_is_finished_for_gl_dot_com_spec.rb
new file mode 100644
index 00000000000..d582a8a9460
--- /dev/null
+++ b/spec/migrations/ensure_vum_bigint_backfill_is_finished_for_gl_dot_com_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe EnsureVumBigintBackfillIsFinishedForGlDotCom, feature_category: :database do
+ describe '#up' do
+ let(:migration_arguments) do
+ {
+ job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
+ table_name: 'vulnerability_user_mentions',
+ column_name: 'id',
+ job_arguments: [['note_id'], ['note_id_convert_to_bigint']]
+ }
+ end
+
+ it 'ensures the migration is completed for GitLab.com, dev, or test' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+
+ migrate!
+ end
+
+ it 'skips the check for other instances' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished)
+ end
+
+ migrate!
+ end
+ end
+end
diff --git a/spec/migrations/finalize_invalid_member_cleanup_spec.rb b/spec/migrations/finalize_invalid_member_cleanup_spec.rb
index 29d03f8983c..c039edcc319 100644
--- a/spec/migrations/finalize_invalid_member_cleanup_spec.rb
+++ b/spec/migrations/finalize_invalid_member_cleanup_spec.rb
@@ -18,6 +18,10 @@ RSpec.describe FinalizeInvalidMemberCleanup, :migration, feature_category: :subg
end
context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
diff --git a/spec/migrations/finalize_issues_iid_scoping_to_namespace_spec.rb b/spec/migrations/finalize_issues_iid_scoping_to_namespace_spec.rb
new file mode 100644
index 00000000000..1834e8c6e0e
--- /dev/null
+++ b/spec/migrations/finalize_issues_iid_scoping_to_namespace_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe FinalizeIssuesIidScopingToNamespace, :migration, feature_category: :team_planning do
+ let(:batched_migrations) { table(:batched_background_migrations) }
+
+ let!(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ shared_examples 'finalizes the migration' do
+ it 'finalizes the migration' do
+ allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
+ expect(runner).to receive(:finalize).with('"IssuesInternalIdScopeUpdater"', :internal_ids, :id, [nil, "up"])
+ end
+ end
+ end
+
+ context 'when migration is missing' do
+ it 'warns migration not found' do
+ expect(Gitlab::AppLogger)
+ .to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
+
+ migrate!
+ end
+ end
+
+ context 'with migration present' do
+ let!(:migration) do
+ batched_migrations.create!(
+ job_class_name: 'IssuesInternalIdScopeUpdater',
+ table_name: :internal_ids,
+ column_name: :id,
+ job_arguments: [nil, 'up'],
+ interval: 2.minutes,
+ min_value: 1,
+ max_value: 2,
+ batch_size: 1000,
+ sub_batch_size: 200,
+ gitlab_schema: :gitlab_main,
+ status: 3 # finished
+ )
+ end
+
+ context 'when migration finished successfully' do
+ it 'does not raise exception' do
+ expect { migrate! }.not_to raise_error
+ end
+ end
+
+ context 'with different migration statuses' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:status, :description) do
+ 0 | 'paused'
+ 1 | 'active'
+ 4 | 'failed'
+ 5 | 'finalizing'
+ end
+
+ with_them do
+ before do
+ migration.update!(status: status)
+ end
+
+ it_behaves_like 'finalizes the migration'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/finalize_issues_namespace_id_backfilling_spec.rb b/spec/migrations/finalize_issues_namespace_id_backfilling_spec.rb
index d0c25fb3dd6..0800a049767 100644
--- a/spec/migrations/finalize_issues_namespace_id_backfilling_spec.rb
+++ b/spec/migrations/finalize_issues_namespace_id_backfilling_spec.rb
@@ -12,12 +12,16 @@ RSpec.describe FinalizeIssuesNamespaceIdBackfilling, :migration, feature_categor
shared_examples 'finalizes the migration' do
it 'finalizes the migration' do
allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
- expect(runner).to receive(:finalize).with('BackfillProjectNamespaceOnIssues', :projects, :id, [])
+ expect(runner).to receive(:finalize).with(migration, :projects, :id, [])
end
end
end
context 'when routes backfilling migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
@@ -29,7 +33,7 @@ RSpec.describe FinalizeIssuesNamespaceIdBackfilling, :migration, feature_categor
context 'with backfilling migration present' do
let!(:project_namespace_backfill) do
batched_migrations.create!(
- job_class_name: 'BackfillProjectNamespaceOnIssues',
+ job_class_name: migration,
table_name: :routes,
column_name: :id,
job_arguments: [],
diff --git a/spec/migrations/finalize_orphaned_routes_cleanup_spec.rb b/spec/migrations/finalize_orphaned_routes_cleanup_spec.rb
index 78546806039..215fdbb05ad 100644
--- a/spec/migrations/finalize_orphaned_routes_cleanup_spec.rb
+++ b/spec/migrations/finalize_orphaned_routes_cleanup_spec.rb
@@ -12,12 +12,16 @@ RSpec.describe FinalizeOrphanedRoutesCleanup, :migration, feature_category: :pro
shared_examples 'finalizes the migration' do
it 'finalizes the migration' do
allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
- expect(runner).to receive(:finalize).with('CleanupOrphanedRoutes', :projects, :id, [])
+ expect(runner).to receive(:finalize).with(migration, :projects, :id, [])
end
end
end
context 'when migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
@@ -29,7 +33,7 @@ RSpec.describe FinalizeOrphanedRoutesCleanup, :migration, feature_category: :pro
context 'with migration present' do
let!(:project_namespace_backfill) do
batched_migrations.create!(
- job_class_name: 'CleanupOrphanedRoutes',
+ job_class_name: migration,
table_name: :routes,
column_name: :id,
job_arguments: [],
diff --git a/spec/migrations/finalize_project_namespaces_backfill_spec.rb b/spec/migrations/finalize_project_namespaces_backfill_spec.rb
index 6cc3a694de8..880bb6661a4 100644
--- a/spec/migrations/finalize_project_namespaces_backfill_spec.rb
+++ b/spec/migrations/finalize_project_namespaces_backfill_spec.rb
@@ -12,12 +12,16 @@ RSpec.describe FinalizeProjectNamespacesBackfill, :migration, feature_category:
shared_examples 'finalizes the migration' do
it 'finalizes the migration' do
allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
- expect(runner).to receive(:finalize).with('"ProjectNamespaces::BackfillProjectNamespaces"', :projects, :id, [nil, "up"])
+ expect(runner).to receive(:finalize).with(migration, :projects, :id, [nil, "up"])
end
end
end
context 'when project namespace backfilling migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
@@ -29,7 +33,7 @@ RSpec.describe FinalizeProjectNamespacesBackfill, :migration, feature_category:
context 'with backfilling migration present' do
let!(:project_namespace_backfill) do
batched_migrations.create!(
- job_class_name: 'ProjectNamespaces::BackfillProjectNamespaces',
+ job_class_name: migration,
table_name: :projects,
column_name: :id,
job_arguments: [nil, 'up'],
diff --git a/spec/migrations/finalize_routes_backfilling_for_projects_spec.rb b/spec/migrations/finalize_routes_backfilling_for_projects_spec.rb
index b79fdc98425..7618957d2f7 100644
--- a/spec/migrations/finalize_routes_backfilling_for_projects_spec.rb
+++ b/spec/migrations/finalize_routes_backfilling_for_projects_spec.rb
@@ -12,12 +12,16 @@ RSpec.describe FinalizeRoutesBackfillingForProjects, :migration, feature_categor
shared_examples 'finalizes the migration' do
it 'finalizes the migration' do
allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
- expect(runner).to receive(:finalize).with('BackfillNamespaceIdForProjectRoute', :projects, :id, [])
+ expect(runner).to receive(:finalize).with(migration, :projects, :id, [])
end
end
end
context 'when routes backfilling migration is missing' do
+ before do
+ batched_migrations.where(job_class_name: migration).delete_all
+ end
+
it 'warns migration not found' do
expect(Gitlab::AppLogger)
.to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
@@ -29,7 +33,7 @@ RSpec.describe FinalizeRoutesBackfillingForProjects, :migration, feature_categor
context 'with backfilling migration present' do
let!(:project_namespace_backfill) do
batched_migrations.create!(
- job_class_name: 'BackfillNamespaceIdForProjectRoute',
+ job_class_name: migration,
table_name: :routes,
column_name: :id,
job_arguments: [],
diff --git a/spec/migrations/finalize_traversal_ids_background_migrations_spec.rb b/spec/migrations/finalize_traversal_ids_background_migrations_spec.rb
deleted file mode 100644
index 0cebe7b9f91..00000000000
--- a/spec/migrations/finalize_traversal_ids_background_migrations_spec.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!('finalize_traversal_ids_background_migrations')
-
-RSpec.describe FinalizeTraversalIdsBackgroundMigrations, :migration, feature_category: :database do
- shared_context 'incomplete background migration' do
- before do
- # Jobs enqueued in Sidekiq.
- Sidekiq::Testing.disable! do
- BackgroundMigrationWorker.perform_in(10, job_class_name, [1, 2, 100])
- BackgroundMigrationWorker.perform_in(20, job_class_name, [3, 4, 100])
- end
-
- # Jobs tracked in the database.
- # table(:background_migration_jobs).create!(
- Gitlab::Database::BackgroundMigrationJob.create!(
- class_name: job_class_name,
- arguments: [5, 6, 100],
- status: Gitlab::Database::BackgroundMigrationJob.statuses['pending']
- )
- # table(:background_migration_jobs).create!(
- Gitlab::Database::BackgroundMigrationJob.create!(
- class_name: job_class_name,
- arguments: [7, 8, 100],
- status: Gitlab::Database::BackgroundMigrationJob.statuses['succeeded']
- )
- end
- end
-
- context 'BackfillNamespaceTraversalIdsRoots background migration' do
- let(:job_class_name) { 'BackfillNamespaceTraversalIdsRoots' }
-
- include_context 'incomplete background migration'
-
- before do
- migrate!
- end
-
- it_behaves_like(
- 'finalized tracked background migration',
- Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsRoots
- )
- end
-
- context 'BackfillNamespaceTraversalIdsChildren background migration' do
- let(:job_class_name) { 'BackfillNamespaceTraversalIdsChildren' }
-
- include_context 'incomplete background migration'
-
- before do
- migrate!
- end
-
- it_behaves_like(
- 'finalized tracked background migration',
- Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsChildren
- )
- end
-end
diff --git a/spec/migrations/insert_daily_invites_trial_plan_limits_spec.rb b/spec/migrations/insert_daily_invites_trial_plan_limits_spec.rb
new file mode 100644
index 00000000000..ea1476b94a9
--- /dev/null
+++ b/spec/migrations/insert_daily_invites_trial_plan_limits_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe InsertDailyInvitesTrialPlanLimits, feature_category: :subgroups do
+ let(:plans) { table(:plans) }
+ let(:plan_limits) { table(:plan_limits) }
+ let!(:premium_trial_plan) { plans.create!(name: 'premium_trial') }
+ let!(:ultimate_trial_plan) { plans.create!(name: 'ultimate_trial') }
+
+ context 'when on gitlab.com' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(true)
+ end
+
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ migration.before -> {
+ trial_plan_ids = [premium_trial_plan.id, ultimate_trial_plan.id]
+ expect(plan_limits.where(plan_id: trial_plan_ids).where.not(daily_invites: 0)).to be_empty
+ }
+
+ migration.after -> {
+ expect(plan_limits.pluck(:plan_id, :daily_invites))
+ .to contain_exactly([premium_trial_plan.id, 50], [ultimate_trial_plan.id, 50])
+ }
+ end
+ end
+ end
+
+ context 'when on self-managed' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(false)
+ end
+
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ trial_plan_ids = [premium_trial_plan.id, ultimate_trial_plan.id]
+
+ migration.before -> {
+ expect(plan_limits.where(plan_id: trial_plan_ids).where.not(daily_invites: 0)).to be_empty
+ }
+
+ migration.after -> {
+ expect(plan_limits.where(plan_id: trial_plan_ids).where.not(daily_invites: 0)).to be_empty
+ }
+ end
+ end
+ end
+end
diff --git a/spec/migrations/queue_backfill_admin_mode_scope_for_personal_access_tokens_spec.rb b/spec/migrations/queue_backfill_admin_mode_scope_for_personal_access_tokens_spec.rb
deleted file mode 100644
index 8209f317550..00000000000
--- a/spec/migrations/queue_backfill_admin_mode_scope_for_personal_access_tokens_spec.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe QueueBackfillAdminModeScopeForPersonalAccessTokens,
- feature_category: :authentication_and_authorization do
- describe '#up' do
- it 'schedules background migration' do
- migrate!
-
- expect(described_class::MIGRATION).to have_scheduled_batched_migration(
- table_name: :personal_access_tokens,
- column_name: :id,
- interval: described_class::DELAY_INTERVAL)
- end
- end
-end
diff --git a/spec/migrations/queue_backfill_prepared_at_data_spec.rb b/spec/migrations/queue_backfill_prepared_at_data_spec.rb
new file mode 100644
index 00000000000..ac3ea2f59c5
--- /dev/null
+++ b/spec/migrations/queue_backfill_prepared_at_data_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueBackfillPreparedAtData, feature_category: :code_review_workflow do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :merge_requests,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL
+ )
+ }
+ end
+ end
+end
diff --git a/spec/migrations/recreate_index_security_ci_builds_on_name_and_id_parser_features_spec.rb b/spec/migrations/recreate_index_security_ci_builds_on_name_and_id_parser_features_spec.rb
deleted file mode 100644
index 80ecc23dfbe..00000000000
--- a/spec/migrations/recreate_index_security_ci_builds_on_name_and_id_parser_features_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-require_migration!
-
-RSpec.describe RecreateIndexSecurityCiBuildsOnNameAndIdParserFeatures, :migration, feature_category: :database do
- let(:db) { described_class.new }
- let(:pg_class) { table(:pg_class) }
- let(:pg_index) { table(:pg_index) }
- let(:async_indexes) { table(:postgres_async_indexes) }
-
- it "recreates index" do
- reversible_migration do |migration|
- migration.before -> {
- expect(async_indexes.where(name: described_class::OLD_INDEX_NAME).exists?).to be false
- expect(db.index_exists?(described_class::TABLE, described_class::COLUMNS, name: described_class::OLD_INDEX_NAME)).to be true
- expect(db.index_exists?(described_class::TABLE, described_class::COLUMNS, name: described_class::NEW_INDEX_NAME)).to be false
- }
-
- migration.after -> {
- expect(async_indexes.where(name: described_class::OLD_INDEX_NAME).exists?).to be true
- expect(db.index_exists?(described_class::TABLE, described_class::COLUMNS, name: described_class::OLD_INDEX_NAME)).to be false
- expect(db.index_exists?(described_class::TABLE, described_class::COLUMNS, name: described_class::NEW_INDEX_NAME)).to be true
- }
- end
- end
-end
diff --git a/spec/migrations/remove_invalid_deploy_access_level_spec.rb b/spec/migrations/remove_invalid_deploy_access_level_spec.rb
deleted file mode 100644
index cc0f5679dda..00000000000
--- a/spec/migrations/remove_invalid_deploy_access_level_spec.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-require_migration!
-
-RSpec.describe RemoveInvalidDeployAccessLevel, :migration, feature_category: :continuous_integration do
- let(:users) { table(:users) }
- let(:groups) { table(:namespaces) }
- let(:protected_environments) { table(:protected_environments) }
- let(:deploy_access_levels) { table(:protected_environment_deploy_access_levels) }
-
- let(:user) { users.create!(email: 'email@email.com', name: 'foo', username: 'foo', projects_limit: 0) }
- let(:group) { groups.create!(name: 'test-group', path: 'test-group') }
- let(:pe) do
- protected_environments.create!(name: 'test-pe', group_id: group.id)
- end
-
- let!(:invalid_access_level) do
- deploy_access_levels.create!(
- access_level: 40,
- user_id: user.id,
- group_id: group.id,
- protected_environment_id: pe.id)
- end
-
- let!(:group_access_level) do
- deploy_access_levels.create!(
- group_id: group.id,
- protected_environment_id: pe.id)
- end
-
- let!(:user_access_level) do
- deploy_access_levels.create!(
- user_id: user.id,
- protected_environment_id: pe.id)
- end
-
- it 'removes invalid access_level entries' do
- expect { migrate! }.to change {
- deploy_access_levels.where(
- protected_environment_id: pe.id,
- access_level: nil).count
- }.from(2).to(3)
-
- expect(invalid_access_level.reload.access_level).to be_nil
- end
-end
diff --git a/spec/migrations/remove_packages_events_package_id_fk_spec.rb b/spec/migrations/remove_packages_events_package_id_fk_spec.rb
new file mode 100644
index 00000000000..13e73de88bd
--- /dev/null
+++ b/spec/migrations/remove_packages_events_package_id_fk_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+require_migration!
+
+RSpec.describe RemovePackagesEventsPackageIdFk, feature_category: :package_registry do
+ let(:table) { described_class::SOURCE_TABLE }
+ let(:column) { described_class::COLUMN }
+ let(:foreign_key) { -> { described_class.new.foreign_keys_for(table, column).first } }
+
+ it 'drops and creates the foreign key' do
+ reversible_migration do |migration|
+ migration.before -> do
+ expect(foreign_key.call).to have_attributes(column: column.to_s)
+ end
+
+ migration.after -> do
+ expect(foreign_key.call).to be(nil)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/remove_saml_provider_and_identities_non_root_group_spec.rb b/spec/migrations/remove_saml_provider_and_identities_non_root_group_spec.rb
new file mode 100644
index 00000000000..07873d0ce79
--- /dev/null
+++ b/spec/migrations/remove_saml_provider_and_identities_non_root_group_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemoveSamlProviderAndIdentitiesNonRootGroup, feature_category: :system_access do
+ let(:namespaces) { table(:namespaces) }
+ let(:saml_providers) { table(:saml_providers) }
+ let(:identities) { table(:identities) }
+ let(:root_group) do
+ namespaces.create!(name: 'root_group', path: 'foo', parent_id: nil, type: 'Group')
+ end
+
+ let(:non_root_group) do
+ namespaces.create!(name: 'non_root_group', path: 'non_root', parent_id: root_group.id, type: 'Group')
+ end
+
+ it 'removes saml_providers that belong to non-root group and related identities' do
+ provider_root_group = saml_providers.create!(
+ group_id: root_group.id,
+ sso_url: 'https://saml.example.com/adfs/ls',
+ certificate_fingerprint: '55:44:33:22:11:aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77:88:99',
+ default_membership_role: ::Gitlab::Access::GUEST,
+ enabled: true
+ )
+
+ identity_root_group = identities.create!(
+ saml_provider_id: provider_root_group.id,
+ extern_uid: "12345"
+ )
+
+ provider_non_root_group = saml_providers.create!(
+ group_id: non_root_group.id,
+ sso_url: 'https://saml.example.com/adfs/ls',
+ certificate_fingerprint: '55:44:33:22:11:aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77:88:99',
+ default_membership_role: ::Gitlab::Access::GUEST,
+ enabled: true
+ )
+
+ identity_non_root_group = identities.create!(
+ saml_provider_id: provider_non_root_group.id,
+ extern_uid: "12345"
+ )
+
+ expect { migrate! }.to change { saml_providers.count }.from(2).to(1)
+
+ expect(identities.find_by_id(identity_non_root_group.id)).to be_nil
+ expect(saml_providers.find_by_id(provider_non_root_group.id)).to be_nil
+
+ expect(identities.find_by_id(identity_root_group.id)).not_to be_nil
+ expect(saml_providers.find_by_id(provider_root_group.id)).not_to be_nil
+ end
+end
diff --git a/spec/migrations/remove_schedule_and_status_from_pending_alert_escalations_spec.rb b/spec/migrations/remove_schedule_and_status_from_pending_alert_escalations_spec.rb
deleted file mode 100644
index 86e161cea43..00000000000
--- a/spec/migrations/remove_schedule_and_status_from_pending_alert_escalations_spec.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe RemoveScheduleAndStatusFromPendingAlertEscalations, feature_category: :incident_management do
- let(:escalations) { table(:incident_management_pending_alert_escalations) }
- let(:schedule_index) { 'index_incident_management_pending_alert_escalations_on_schedule' }
- let(:schedule_foreign_key) { 'fk_rails_fcbfd9338b' }
-
- it 'correctly migrates up and down' do
- reversible_migration do |migration|
- migration.before -> {
- expect(escalations.column_names).to include('schedule_id', 'status')
- expect(escalations_indexes).to include(schedule_index)
- expect(escalations_constraints).to include(schedule_foreign_key)
- }
-
- migration.after -> {
- escalations.reset_column_information
- expect(escalations.column_names).not_to include('schedule_id', 'status')
- expect(escalations_indexes).not_to include(schedule_index)
- expect(escalations_constraints).not_to include(schedule_foreign_key)
- }
- end
- end
-
- private
-
- def escalations_indexes
- ActiveRecord::Base.connection.indexes(:incident_management_pending_alert_escalations).collect(&:name)
- end
-
- def escalations_constraints
- ActiveRecord::Base.connection.foreign_keys(:incident_management_pending_alert_escalations).collect(&:name)
- end
-end
diff --git a/spec/migrations/remove_scim_token_and_scim_identity_non_root_group_spec.rb b/spec/migrations/remove_scim_token_and_scim_identity_non_root_group_spec.rb
new file mode 100644
index 00000000000..31915365c91
--- /dev/null
+++ b/spec/migrations/remove_scim_token_and_scim_identity_non_root_group_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemoveScimTokenAndScimIdentityNonRootGroup, feature_category: :system_access do
+ let(:namespaces) { table(:namespaces) }
+ let(:scim_oauth_access_tokens) { table(:scim_oauth_access_tokens) }
+ let(:scim_identities) { table(:scim_identities) }
+ let(:users) { table(:users) }
+ let(:root_group) do
+ namespaces.create!(name: 'root_group', path: 'foo', parent_id: nil, type: 'Group')
+ end
+
+ let(:non_root_group) do
+ namespaces.create!(name: 'non_root_group', path: 'non_root', parent_id: root_group.id, type: 'Group')
+ end
+
+ let(:root_group_user) do
+ users.create!(name: 'Example User', email: 'user@example.com', projects_limit: 0)
+ end
+
+ let(:non_root_group_user) do
+ users.create!(username: 'user2', email: 'user2@example.com', projects_limit: 10)
+ end
+
+ it 'removes scim_oauth_access_tokens that belong to non-root group and related scim_identities' do
+ scim_oauth_access_token_root_group = scim_oauth_access_tokens.create!(
+ group_id: root_group.id,
+ token_encrypted: Gitlab::CryptoHelper.aes256_gcm_encrypt(SecureRandom.hex(50))
+ )
+ scim_oauth_access_token_non_root_group = scim_oauth_access_tokens.create!(
+ group_id: non_root_group.id,
+ token_encrypted: Gitlab::CryptoHelper.aes256_gcm_encrypt(SecureRandom.hex(50))
+ )
+
+ scim_identity_root_group = scim_identities.create!(
+ group_id: root_group.id,
+ extern_uid: "12345",
+ user_id: root_group_user.id,
+ active: true
+ )
+
+ scim_identity_non_root_group = scim_identities.create!(
+ group_id: non_root_group.id,
+ extern_uid: "12345",
+ user_id: non_root_group_user.id,
+ active: true
+ )
+
+ expect { migrate! }.to change { scim_oauth_access_tokens.count }.from(2).to(1)
+ expect(scim_oauth_access_tokens.find_by_id(scim_oauth_access_token_non_root_group.id)).to be_nil
+ expect(scim_identities.find_by_id(scim_identity_non_root_group.id)).to be_nil
+
+ expect(scim_oauth_access_tokens.find_by_id(scim_oauth_access_token_root_group.id)).not_to be_nil
+ expect(scim_identities.find_by_id(scim_identity_root_group.id)).not_to be_nil
+ end
+end
diff --git a/spec/migrations/requeue_backfill_admin_mode_scope_for_personal_access_tokens_spec.rb b/spec/migrations/requeue_backfill_admin_mode_scope_for_personal_access_tokens_spec.rb
new file mode 100644
index 00000000000..b9af6d98beb
--- /dev/null
+++ b/spec/migrations/requeue_backfill_admin_mode_scope_for_personal_access_tokens_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RequeueBackfillAdminModeScopeForPersonalAccessTokens, feature_category: :system_access do
+ describe '#up' do
+ it 'schedules background migration' do
+ migrate!
+
+ expect(described_class::MIGRATION).to(
+ have_scheduled_batched_migration(
+ table_name: :personal_access_tokens,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL)
+ )
+ end
+ end
+end
diff --git a/spec/migrations/rerun_remove_invalid_deploy_access_level_spec.rb b/spec/migrations/rerun_remove_invalid_deploy_access_level_spec.rb
new file mode 100644
index 00000000000..72663e63996
--- /dev/null
+++ b/spec/migrations/rerun_remove_invalid_deploy_access_level_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe RerunRemoveInvalidDeployAccessLevel, :migration, feature_category: :continuous_integration do
+ let(:users) { table(:users) }
+ let(:groups) { table(:namespaces) }
+ let(:protected_environments) { table(:protected_environments) }
+ let(:deploy_access_levels) { table(:protected_environment_deploy_access_levels) }
+
+ let(:user) { users.create!(email: 'email@email.com', name: 'foo', username: 'foo', projects_limit: 0) }
+ let(:group) { groups.create!(name: 'test-group', path: 'test-group') }
+ let(:pe) do
+ protected_environments.create!(name: 'test-pe', group_id: group.id)
+ end
+
+ let!(:invalid_access_level) do
+ deploy_access_levels.create!(
+ access_level: 40,
+ user_id: user.id,
+ group_id: group.id,
+ protected_environment_id: pe.id)
+ end
+
+ let!(:access_level) do
+ deploy_access_levels.create!(
+ access_level: 40,
+ user_id: nil,
+ group_id: nil,
+ protected_environment_id: pe.id)
+ end
+
+ let!(:group_access_level) do
+ deploy_access_levels.create!(
+ group_id: group.id,
+ protected_environment_id: pe.id)
+ end
+
+ let!(:user_access_level) do
+ deploy_access_levels.create!(
+ user_id: user.id,
+ protected_environment_id: pe.id)
+ end
+
+ let!(:user_and_group_access_level) do
+ deploy_access_levels.create!(
+ user_id: user.id,
+ group_id: group.id,
+ protected_environment_id: pe.id)
+ end
+
+ it 'fixes invalid access_level entries and does not affect others' do
+ expect { migrate! }.to change {
+ deploy_access_levels.where(protected_environment_id: pe.id)
+ .where("num_nonnulls(user_id, group_id, access_level) = 1").count
+ }.from(3).to(5)
+
+ invalid_access_level.reload
+ access_level.reload
+ group_access_level.reload
+ user_access_level.reload
+ user_and_group_access_level.reload
+
+ expect(invalid_access_level.access_level).to be_nil
+ expect(invalid_access_level.user_id).to eq(user.id)
+ expect(invalid_access_level.group_id).to be_nil
+
+ expect(access_level.access_level).to eq(40)
+ expect(access_level.user_id).to be_nil
+ expect(access_level.group_id).to be_nil
+
+ expect(group_access_level.access_level).to be_nil
+ expect(group_access_level.user_id).to be_nil
+ expect(group_access_level.group_id).to eq(group.id)
+
+ expect(user_access_level.access_level).to be_nil
+ expect(user_access_level.user_id).to eq(user.id)
+ expect(user_access_level.group_id).to be_nil
+
+ expect(user_and_group_access_level.access_level).to be_nil
+ expect(user_and_group_access_level.user_id).to eq(user.id)
+ expect(user_and_group_access_level.group_id).to be_nil
+ end
+end
diff --git a/spec/migrations/reschedule_incident_work_item_type_id_backfill_spec.rb b/spec/migrations/reschedule_incident_work_item_type_id_backfill_spec.rb
new file mode 100644
index 00000000000..cb8773e9a9f
--- /dev/null
+++ b/spec/migrations/reschedule_incident_work_item_type_id_backfill_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RescheduleIncidentWorkItemTypeIdBackfill, :migration, feature_category: :team_planning do
+ include MigrationHelpers::WorkItemTypesHelper
+
+ let!(:migration) { described_class::MIGRATION }
+ let!(:interval) { 2.minutes }
+ let!(:incident_type_enum) { 1 }
+ let!(:issue_type_enum) { 0 }
+ let!(:incident_work_item_type) do
+ table(:work_item_types).find_by!(namespace_id: nil, base_type: incident_type_enum)
+ end
+
+ let!(:issue_work_item_type) do
+ table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_type_enum)
+ end
+
+ describe '#up' do
+ let!(:existing_incident_migration) { create_backfill_migration(incident_type_enum, incident_work_item_type.id) }
+ let!(:existing_issue_migration) { create_backfill_migration(issue_type_enum, issue_work_item_type.id) }
+
+ it 'correctly reschedules background migration only for incidents' do
+ migrate!
+
+ migration_ids = table(:batched_background_migrations).pluck(:id)
+
+ expect(migration).to have_scheduled_batched_migration(
+ table_name: :issues,
+ column_name: :id,
+ job_arguments: [incident_type_enum, incident_work_item_type.id],
+ interval: interval,
+ batch_size: described_class::BATCH_SIZE,
+ max_batch_size: described_class::MAX_BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ expect(migration_ids.count).to eq(2)
+ expect(migration_ids).not_to include(existing_incident_migration.id)
+ expect(migration_ids).to include(existing_issue_migration.id)
+ end
+
+ it "doesn't fail if work item types don't exist on the DB" do
+ table(:work_item_types).delete_all
+
+ migrate!
+
+ # Since migration specs run outside of a transaction, we need to make
+ # sure we recreate default types since this spec deletes them all
+ reset_work_item_types
+ end
+ end
+
+ def create_backfill_migration(base_type, type_id)
+ table(:batched_background_migrations).create!(
+ job_class_name: migration,
+ table_name: :issues,
+ column_name: :id,
+ job_arguments: [base_type, type_id],
+ interval: 2.minutes,
+ min_value: 1,
+ max_value: 2,
+ batch_size: 1000,
+ sub_batch_size: 200,
+ gitlab_schema: :gitlab_main,
+ status: 3
+ )
+ end
+end
diff --git a/spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb b/spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb
index a3bec40c3f0..abcdde7f075 100644
--- a/spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb
+++ b/spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
require_migration!
RSpec.describe ScheduleBackfillDraftStatusOnMergeRequestsCorrectedRegex,
- :sidekiq, feature_category: :code_review_workflow do
+ :sidekiq, feature_category: :code_review_workflow do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:merge_requests) { table(:merge_requests) }
diff --git a/spec/migrations/schedule_fixing_security_scan_statuses_spec.rb b/spec/migrations/schedule_fixing_security_scan_statuses_spec.rb
index c4c7819bda7..56d30e71676 100644
--- a/spec/migrations/schedule_fixing_security_scan_statuses_spec.rb
+++ b/spec/migrations/schedule_fixing_security_scan_statuses_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
require_migration!
-RSpec.describe ScheduleFixingSecurityScanStatuses, :suppress_gitlab_schemas_validate_connection,
- feature_category: :vulnerability_management do
+RSpec.describe ScheduleFixingSecurityScanStatuses,
+ :suppress_gitlab_schemas_validate_connection, feature_category: :vulnerability_management do
let!(:namespaces) { table(:namespaces) }
let!(:projects) { table(:projects) }
let!(:pipelines) { table(:ci_pipelines) }
diff --git a/spec/migrations/schedule_migrate_shared_vulnerability_identifiers_spec.rb b/spec/migrations/schedule_migrate_shared_vulnerability_identifiers_spec.rb
new file mode 100644
index 00000000000..c1802a1a339
--- /dev/null
+++ b/spec/migrations/schedule_migrate_shared_vulnerability_identifiers_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+require_migration!
+
+RSpec.describe ScheduleMigrateSharedVulnerabilityIdentifiers, :migration, feature_category: :vulnerability_management do
+ describe "#up" do
+ before do
+ migrate!
+ end
+
+ it "schedules" do
+ Gitlab::Database::BackgroundMigration::BatchedMigration.find_by!(
+ job_class_name: described_class::MIGRATION,
+ table_name: described_class::TABLE_NAME,
+ column_name: described_class::BATCH_COLUMN,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE)
+ end
+ end
+
+ describe '#down' do
+ before do
+ schema_migrate_down!
+ end
+
+ it "deletes" do
+ expect(described_class::MIGRATION).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/schedule_purging_stale_security_scans_spec.rb b/spec/migrations/schedule_purging_stale_security_scans_spec.rb
index b39baa145ff..906dc90bcc4 100644
--- a/spec/migrations/schedule_purging_stale_security_scans_spec.rb
+++ b/spec/migrations/schedule_purging_stale_security_scans_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe SchedulePurgingStaleSecurityScans, :suppress_gitlab_schemas_validate_connection,
-feature_category: :vulnerability_management do
+ feature_category: :vulnerability_management do
let!(:namespaces) { table(:namespaces) }
let!(:projects) { table(:projects) }
let!(:pipelines) { table(:ci_pipelines) }
diff --git a/spec/migrations/schedule_recalculate_vulnerability_finding_signatures_for_findings_spec.rb b/spec/migrations/schedule_recalculate_vulnerability_finding_signatures_for_findings_spec.rb
deleted file mode 100644
index 8903a32285e..00000000000
--- a/spec/migrations/schedule_recalculate_vulnerability_finding_signatures_for_findings_spec.rb
+++ /dev/null
@@ -1,90 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe ScheduleRecalculateVulnerabilityFindingSignaturesForFindings, :migration,
-feature_category: :vulnerability_management do
- before do
- allow(Gitlab).to receive(:ee?).and_return(ee?)
- stub_const("#{described_class.name}::BATCH_SIZE", 2)
- end
-
- context 'when the Gitlab instance is FOSS' do
- let(:ee?) { false }
-
- it 'does not run the migration' do
- expect { migrate! }.not_to change { BackgroundMigrationWorker.jobs.size }
- end
- end
-
- context 'when the Gitlab instance is EE' do
- let(:ee?) { true }
-
- let!(:namespaces) { table(:namespaces) }
- let!(:projects) { table(:projects) }
- let!(:findings) { table(:vulnerability_occurrences) }
- let!(:scanners) { table(:vulnerability_scanners) }
- let!(:identifiers) { table(:vulnerability_identifiers) }
- let!(:vulnerability_finding_signatures) { table(:vulnerability_finding_signatures) }
-
- let!(:namespace) { namespaces.create!(name: 'test', path: 'test') }
- let!(:project) { projects.create!(namespace_id: namespace.id, name: 'gitlab', path: 'gitlab') }
-
- let!(:scanner) do
- scanners.create!(project_id: project.id, external_id: 'trivy', name: 'Security Scanner')
- end
-
- let!(:identifier) do
- identifiers.create!(project_id: project.id,
- fingerprint: 'd432c2ad2953e8bd587a3a43b3ce309b5b0154c123',
- external_type: 'SECURITY_ID',
- external_id: 'SECURITY_0',
- name: 'SECURITY_IDENTIFIER 0')
- end
-
- let!(:finding1) { findings.create!(finding_params) }
- let!(:signature1) { vulnerability_finding_signatures.create!(finding_id: finding1.id, algorithm_type: 0, signature_sha: ::Digest::SHA1.digest(SecureRandom.hex(50))) }
-
- let!(:finding2) { findings.create!(finding_params) }
- let!(:signature2) { vulnerability_finding_signatures.create!(finding_id: finding2.id, algorithm_type: 0, signature_sha: ::Digest::SHA1.digest(SecureRandom.hex(50))) }
-
- let!(:finding3) { findings.create!(finding_params) }
- let!(:signature3) { vulnerability_finding_signatures.create!(finding_id: finding3.id, algorithm_type: 0, signature_sha: ::Digest::SHA1.digest(SecureRandom.hex(50))) }
-
- # this migration is now a no-op
- it 'does not schedule the background jobs', :aggregate_failure do
- Sidekiq::Testing.fake! do
- freeze_time do
- migrate!
-
- expect(BackgroundMigrationWorker.jobs.size).to eq(0)
- expect(described_class::MIGRATION)
- .not_to be_scheduled_migration_with_multiple_args(signature1.id, signature2.id)
- expect(described_class::MIGRATION)
- .not_to be_scheduled_migration_with_multiple_args(signature3.id, signature3.id)
- end
- end
- end
-
- def finding_params
- uuid = SecureRandom.uuid
-
- {
- severity: 0,
- confidence: 5,
- report_type: 2,
- project_id: project.id,
- scanner_id: scanner.id,
- primary_identifier_id: identifier.id,
- location: nil,
- project_fingerprint: SecureRandom.hex(20),
- location_fingerprint: Digest::SHA1.hexdigest(SecureRandom.hex(10)),
- uuid: uuid,
- name: "Vulnerability Finding #{uuid}",
- metadata_version: '1.3',
- raw_metadata: '{}'
- }
- end
- end
-end
diff --git a/spec/migrations/set_email_confirmation_setting_before_removing_send_user_confirmation_email_column_spec.rb b/spec/migrations/set_email_confirmation_setting_before_removing_send_user_confirmation_email_column_spec.rb
index 8e00fbe4b89..02ecbe90ee0 100644
--- a/spec/migrations/set_email_confirmation_setting_before_removing_send_user_confirmation_email_column_spec.rb
+++ b/spec/migrations/set_email_confirmation_setting_before_removing_send_user_confirmation_email_column_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe SetEmailConfirmationSettingBeforeRemovingSendUserConfirmationEmailColumn,
- feature_category: :user_profile do
+ feature_category: :user_profile do
let(:migration) { described_class.new }
let(:application_settings_table) { table(:application_settings) }
diff --git a/spec/migrations/set_email_confirmation_setting_from_soft_email_confirmation_ff_spec.rb b/spec/migrations/set_email_confirmation_setting_from_soft_email_confirmation_ff_spec.rb
new file mode 100644
index 00000000000..202baebf1da
--- /dev/null
+++ b/spec/migrations/set_email_confirmation_setting_from_soft_email_confirmation_ff_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SetEmailConfirmationSettingFromSoftEmailConfirmationFf, feature_category: :feature_flags do
+ let(:migration) { described_class.new }
+ let(:application_settings_table) { table(:application_settings) }
+ let(:feature_gates_table) { table(:feature_gates) }
+
+ describe '#up' do
+ context 'when feature gate for `soft_email_confirmation` does not exist' do
+ it 'does not update `email_confirmation_setting`' do
+ application_settings_table.create!(email_confirmation_setting: 0)
+
+ migration.up
+
+ expect(application_settings_table.last.email_confirmation_setting).to eq 0
+ end
+ end
+
+ context 'when feature gate for `soft_email_confirmation` does exist' do
+ context 'when feature gate value is `false`' do
+ before do
+ feature_gates_table.create!(feature_key: 'soft_email_confirmation', key: 'boolean', value: 'false')
+ end
+
+ it 'does not update `email_confirmation_setting`' do
+ application_settings_table.create!(email_confirmation_setting: 0)
+
+ migration.up
+
+ expect(application_settings_table.last.email_confirmation_setting).to eq 0
+ end
+ end
+
+ context 'when feature gate value is `true`' do
+ before do
+ feature_gates_table.create!(feature_key: 'soft_email_confirmation', key: 'boolean', value: 'true')
+ end
+
+ it "updates `email_confirmation_setting` to '1' (soft)" do
+ application_settings_table.create!(email_confirmation_setting: 0)
+
+ migration.up
+
+ expect(application_settings_table.last.email_confirmation_setting).to eq 1
+ end
+ end
+ end
+ end
+
+ describe '#down' do
+ it "updates 'email_confirmation_setting' to default value: '0' (off)" do
+ application_settings_table.create!(email_confirmation_setting: 1)
+
+ migration.down
+
+ expect(application_settings_table.last.email_confirmation_setting).to eq 0
+ 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
deleted file mode 100644
index ffd25152a45..00000000000
--- a/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb
+++ /dev/null
@@ -1,70 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe SliceMergeRequestDiffCommitMigrations, :migration, feature_category: :code_review_workflow 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/start_backfill_ci_queuing_tables_spec.rb b/spec/migrations/start_backfill_ci_queuing_tables_spec.rb
index c308a16d5b8..0a189b58c94 100644
--- a/spec/migrations/start_backfill_ci_queuing_tables_spec.rb
+++ b/spec/migrations/start_backfill_ci_queuing_tables_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe StartBackfillCiQueuingTables, :suppress_gitlab_schemas_validate_connection,
-feature_category: :continuous_integration do
+ feature_category: :continuous_integration do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:builds) { table(:ci_builds) }
diff --git a/spec/migrations/swap_award_emoji_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_award_emoji_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..c133deaf250
--- /dev/null
+++ b/spec/migrations/swap_award_emoji_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapAwardEmojiNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE award_emoji ALTER COLUMN awardable_id TYPE integer')
+ connection.execute('ALTER TABLE award_emoji ALTER COLUMN awardable_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ award_emoji = table(:award_emoji)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ award_emoji.reset_column_information
+
+ expect(award_emoji.columns.find { |c| c.name == 'awardable_id' }.sql_type).to eq('integer')
+ expect(award_emoji.columns.find { |c| c.name == 'awardable_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ award_emoji.reset_column_information
+
+ expect(award_emoji.columns.find { |c| c.name == 'awardable_id' }.sql_type).to eq('bigint')
+ expect(award_emoji.columns.find { |c| c.name == 'awardable_id_convert_to_bigint' }.sql_type)
+ .to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ award_emoji = table(:award_emoji)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ award_emoji.reset_column_information
+
+ expect(award_emoji.columns.find { |c| c.name == 'awardable_id' }.sql_type).to eq('integer')
+ expect(award_emoji.columns.find { |c| c.name == 'awardable_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ award_emoji.reset_column_information
+
+ expect(award_emoji.columns.find { |c| c.name == 'awardable_id' }.sql_type).to eq('integer')
+ expect(award_emoji.columns.find { |c| c.name == 'awardable_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_commit_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_commit_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..d219d544033
--- /dev/null
+++ b/spec/migrations/swap_commit_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapCommitUserMentionsNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE commit_user_mentions ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE commit_user_mentions ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ user_mentions = table(:commit_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ user_mentions = table(:commit_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_design_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_design_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..c7cbf7bfe2a
--- /dev/null
+++ b/spec/migrations/swap_design_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapDesignUserMentionsNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE design_user_mentions ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE design_user_mentions ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ user_mentions = table(:design_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ user_mentions = table(:design_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_epic_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_epic_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..41cc75672e1
--- /dev/null
+++ b/spec/migrations/swap_epic_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapEpicUserMentionsNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE epic_user_mentions ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE epic_user_mentions ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ user_mentions = table(:epic_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ user_mentions = table(:epic_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_issue_user_mentions_note_id_to_bigint_for_gitlab_dot_com_2_spec.rb b/spec/migrations/swap_issue_user_mentions_note_id_to_bigint_for_gitlab_dot_com_2_spec.rb
new file mode 100644
index 00000000000..2c561730d95
--- /dev/null
+++ b/spec/migrations/swap_issue_user_mentions_note_id_to_bigint_for_gitlab_dot_com_2_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+# rubocop: disable RSpec/FilePath
+RSpec.describe SwapIssueUserMentionsNoteIdToBigintForGitlabDotCom2, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE issue_user_mentions ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE issue_user_mentions ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ user_mentions = table(:issue_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ user_mentions = table(:issue_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op if columns are already swapped' do
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE issue_user_mentions ALTER COLUMN note_id TYPE bigint')
+ connection.execute('ALTER TABLE issue_user_mentions ALTER COLUMN note_id_convert_to_bigint TYPE integer')
+
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ migrate!
+
+ user_mentions = table(:issue_user_mentions)
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
+# rubocop: enable RSpec/FilePath
diff --git a/spec/migrations/swap_merge_request_metrics_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_merge_request_metrics_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..b819495aa89
--- /dev/null
+++ b/spec/migrations/swap_merge_request_metrics_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapMergeRequestMetricsIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # As we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE merge_request_metrics ALTER COLUMN id TYPE integer')
+ connection.execute('ALTER TABLE merge_request_metrics ALTER COLUMN id_convert_to_bigint TYPE bigint')
+ end
+
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ # rubocop: disable RSpec/AnyInstanceOf
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ # rubocop: enable RSpec/AnyInstanceOf
+
+ merge_request_metrics = table(:merge_request_metrics)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('integer')
+ expect(merge_request_metrics.columns.find do |c|
+ c.name == 'id_convert_to_bigint'
+ end.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint')
+ expect(merge_request_metrics.columns.find do |c|
+ c.name == 'id_convert_to_bigint'
+ end.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ # rubocop: disable RSpec/AnyInstanceOf
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ # rubocop: enable RSpec/AnyInstanceOf
+
+ merge_request_metrics = table(:merge_request_metrics)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('integer')
+ expect(merge_request_metrics.columns.find do |c|
+ c.name == 'id_convert_to_bigint'
+ end.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('integer')
+ expect(merge_request_metrics.columns.find do |c|
+ c.name == 'id_convert_to_bigint'
+ end.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/swap_merge_request_metrics_id_to_bigint_for_self_hosts_spec.rb b/spec/migrations/swap_merge_request_metrics_id_to_bigint_for_self_hosts_spec.rb
new file mode 100644
index 00000000000..a2d9887a6c0
--- /dev/null
+++ b/spec/migrations/swap_merge_request_metrics_id_to_bigint_for_self_hosts_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapMergeRequestMetricsIdToBigintForSelfHosts, feature_category: :database do
+ after do
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE merge_request_metrics DROP COLUMN IF EXISTS id_convert_to_bigint')
+ end
+
+ describe '#up' do
+ context 'when is GitLab.com, dev, or test' do
+ before do
+ # As we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE merge_request_metrics ALTER COLUMN id TYPE bigint')
+ connection.execute('ALTER TABLE merge_request_metrics DROP COLUMN IF EXISTS id_convert_to_bigint')
+ end
+
+ it 'does not swap the columns' do
+ # rubocop: disable RSpec/AnyInstanceOf
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+ # rubocop: enable RSpec/AnyInstanceOf
+
+ merge_request_metrics = table(:merge_request_metrics)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint')
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id_convert_to_bigint' }).to be nil
+ }
+
+ migration.after -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint')
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id_convert_to_bigint' }).to be nil
+ }
+ end
+ end
+ end
+ end
+
+ context 'when is a self-host customer with the swapped already completed' do
+ before do
+ # As we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE merge_request_metrics ALTER COLUMN id TYPE bigint')
+ connection.execute('ALTER TABLE merge_request_metrics ADD COLUMN IF NOT EXISTS id_convert_to_bigint integer')
+ end
+
+ it 'does not swap the columns' do
+ # rubocop: disable RSpec/AnyInstanceOf
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ # rubocop: enable RSpec/AnyInstanceOf
+
+ merge_request_metrics = table(:merge_request_metrics)
+
+ migrate!
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint')
+ expect(merge_request_metrics.columns.find do |c|
+ c.name == 'id_convert_to_bigint'
+ end.sql_type).to eq('integer')
+ end
+ end
+
+ context 'when is a self-host customer with the `id_convert_to_bigint` column already dropped ' do
+ before do
+ # As we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE merge_request_metrics ALTER COLUMN id TYPE bigint')
+ connection.execute('ALTER TABLE merge_request_metrics DROP COLUMN IF EXISTS id_convert_to_bigint')
+ end
+
+ it 'does not swap the columns' do
+ # rubocop: disable RSpec/AnyInstanceOf
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ # rubocop: enable RSpec/AnyInstanceOf
+
+ merge_request_metrics = table(:merge_request_metrics)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint')
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id_convert_to_bigint' }).to be nil
+ }
+
+ migration.after -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint')
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id_convert_to_bigint' }).to be nil
+ }
+ end
+ end
+ end
+ end
+
+ context 'when is a self-host customer' do
+ before do
+ # As we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE merge_request_metrics ALTER COLUMN id TYPE integer')
+ connection.execute('ALTER TABLE merge_request_metrics ADD COLUMN IF NOT EXISTS id_convert_to_bigint bigint')
+ connection.execute('ALTER TABLE merge_request_metrics ALTER COLUMN id_convert_to_bigint TYPE bigint')
+ connection.execute('DROP INDEX IF EXISTS index_merge_request_metrics_on_id_convert_to_bigint')
+ connection.execute('DROP INDEX IF EXISTS tmp_index_mr_metrics_on_target_project_id_merged_at_nulls_last')
+ connection.execute('CREATE OR REPLACE FUNCTION trigger_c7107f30d69d() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN NEW."id_convert_to_bigint" := NEW."id"; RETURN NEW; END; $$;')
+ end
+
+ it 'swaps the columns' do
+ # rubocop: disable RSpec/AnyInstanceOf
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+ # rubocop: enable RSpec/AnyInstanceOf
+
+ merge_request_metrics = table(:merge_request_metrics)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('integer')
+ expect(merge_request_metrics.columns.find do |c|
+ c.name == 'id_convert_to_bigint'
+ end.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ merge_request_metrics.reset_column_information
+
+ expect(merge_request_metrics.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint')
+ expect(merge_request_metrics.columns.find do |c|
+ c.name == 'id_convert_to_bigint'
+ end.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/swap_merge_request_user_mentions_note_id_to_bigint_spec.rb b/spec/migrations/swap_merge_request_user_mentions_note_id_to_bigint_spec.rb
new file mode 100644
index 00000000000..15b21d34714
--- /dev/null
+++ b/spec/migrations/swap_merge_request_user_mentions_note_id_to_bigint_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapMergeRequestUserMentionsNoteIdToBigint, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE merge_request_user_mentions ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE merge_request_user_mentions ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ user_mentions = table(:merge_request_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ user_mentions = table(:merge_request_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_note_diff_files_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_note_diff_files_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..b0147f3ef58
--- /dev/null
+++ b/spec/migrations/swap_note_diff_files_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapNoteDiffFilesNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE note_diff_files ALTER COLUMN diff_note_id TYPE integer')
+ connection.execute('ALTER TABLE note_diff_files ALTER COLUMN diff_note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ ndf = table(:note_diff_files)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ ndf.reset_column_information
+
+ expect(ndf.columns.find { |c| c.name == 'diff_note_id' }.sql_type).to eq('integer')
+ expect(ndf.columns.find { |c| c.name == 'diff_note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ ndf.reset_column_information
+
+ expect(ndf.columns.find { |c| c.name == 'diff_note_id' }.sql_type).to eq('bigint')
+ expect(ndf.columns.find { |c| c.name == 'diff_note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ ndf = table(:note_diff_files)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ ndf.reset_column_information
+
+ expect(ndf.columns.find { |c| c.name == 'diff_note_id' }.sql_type).to eq('integer')
+ expect(ndf.columns.find { |c| c.name == 'diff_note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ ndf.reset_column_information
+
+ expect(ndf.columns.find { |c| c.name == 'diff_note_id' }.sql_type).to eq('integer')
+ expect(ndf.columns.find { |c| c.name == 'diff_note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_sent_notifications_id_columns_spec.rb b/spec/migrations/swap_sent_notifications_id_columns_spec.rb
new file mode 100644
index 00000000000..2f681a2a587
--- /dev/null
+++ b/spec/migrations/swap_sent_notifications_id_columns_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapSentNotificationsIdColumns, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE sent_notifications ALTER COLUMN id TYPE integer')
+ connection.execute('ALTER TABLE sent_notifications ALTER COLUMN id_convert_to_bigint TYPE bigint')
+ # rubocop: disable RSpec/AnyInstanceOf
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(run_migration?)
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+
+ context 'when we are GitLab.com, dev, or test' do
+ let(:run_migration?) { true }
+
+ it 'swaps the integer and bigint columns' do
+ sent_notifications = table(:sent_notifications)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ sent_notifications.reset_column_information
+
+ expect(sent_notifications.columns.find { |c| c.name == 'id' }.sql_type).to eq('integer')
+ expect(sent_notifications.columns.find { |c| c.name == 'id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ sent_notifications.reset_column_information
+
+ expect(sent_notifications.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint')
+ expect(sent_notifications.columns.find { |c| c.name == 'id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+ end
+
+ context 'when we are NOT GitLab.com, dev, or test' do
+ let(:run_migration?) { false }
+
+ it 'does not swap the integer and bigint columns' do
+ sent_notifications = table(:sent_notifications)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ sent_notifications.reset_column_information
+
+ expect(sent_notifications.columns.find { |c| c.name == 'id' }.sql_type).to eq('integer')
+ expect(sent_notifications.columns.find { |c| c.name == 'id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ sent_notifications.reset_column_information
+
+ expect(sent_notifications.columns.find { |c| c.name == 'id' }.sql_type).to eq('integer')
+ expect(sent_notifications.columns.find { |c| c.name == 'id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/swap_snippet_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_snippet_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..628c0fba528
--- /dev/null
+++ b/spec/migrations/swap_snippet_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapSnippetUserMentionsNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE snippet_user_mentions ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE snippet_user_mentions ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ user_mentions = table(:snippet_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ user_mentions = table(:snippet_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_suggestions_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_suggestions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..48d72ec151e
--- /dev/null
+++ b/spec/migrations/swap_suggestions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapSuggestionsNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE suggestions ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE suggestions ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ suggestions = table(:suggestions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ suggestions.reset_column_information
+
+ expect(suggestions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(suggestions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ suggestions.reset_column_information
+
+ expect(suggestions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(suggestions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ suggestions = table(:suggestions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ suggestions.reset_column_information
+
+ expect(suggestions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(suggestions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ suggestions.reset_column_information
+
+ expect(suggestions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(suggestions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_system_note_metadata_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_system_note_metadata_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..4fa5814986a
--- /dev/null
+++ b/spec/migrations/swap_system_note_metadata_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapSystemNoteMetadataNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE system_note_metadata ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE system_note_metadata ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ metadata = table(:system_note_metadata)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ metadata.reset_column_information
+
+ expect(metadata.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(metadata.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ metadata.reset_column_information
+
+ expect(metadata.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(metadata.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ metadata = table(:system_note_metadata)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ metadata.reset_column_information
+
+ expect(metadata.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(metadata.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ metadata.reset_column_information
+
+ expect(metadata.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(metadata.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_timelogs_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_timelogs_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..708688ec446
--- /dev/null
+++ b/spec/migrations/swap_timelogs_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapTimelogsNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE timelogs ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE timelogs ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ timelogs = table(:timelogs)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ timelogs.reset_column_information
+
+ expect(timelogs.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(timelogs.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ timelogs.reset_column_information
+
+ expect(timelogs.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(timelogs.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ timelogs = table(:timelogs)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ timelogs.reset_column_information
+
+ expect(timelogs.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(timelogs.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ timelogs.reset_column_information
+
+ expect(timelogs.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(timelogs.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_todos_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_todos_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..e71c921998a
--- /dev/null
+++ b/spec/migrations/swap_todos_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapTodosNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE todos ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE todos ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ todos = table(:todos)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ todos.reset_column_information
+
+ expect(todos.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(todos.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ todos.reset_column_information
+
+ expect(todos.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(todos.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ todos = table(:todos)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ todos.reset_column_information
+
+ expect(todos.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(todos.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ todos.reset_column_information
+
+ expect(todos.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(todos.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/swap_vulnerability_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb b/spec/migrations/swap_vulnerability_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
new file mode 100644
index 00000000000..1e358387536
--- /dev/null
+++ b/spec/migrations/swap_vulnerability_user_mentions_note_id_to_bigint_for_gitlab_dot_com_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SwapVulnerabilityUserMentionsNoteIdToBigintForGitlabDotCom, feature_category: :database do
+ describe '#up' do
+ before do
+ # A we call `schema_migrate_down!` before each example, and for this migration
+ # `#down` is same as `#up`, we need to ensure we start from the expected state.
+ connection = described_class.new.connection
+ connection.execute('ALTER TABLE vulnerability_user_mentions ALTER COLUMN note_id TYPE integer')
+ connection.execute('ALTER TABLE vulnerability_user_mentions ALTER COLUMN note_id_convert_to_bigint TYPE bigint')
+ end
+
+ # rubocop: disable RSpec/AnyInstanceOf
+ it 'swaps the integer and bigint columns for GitLab.com, dev, or test' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true)
+
+ user_mentions = table(:vulnerability_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('bigint')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('integer')
+ }
+ end
+ end
+ end
+
+ it 'is a no-op for other instances' do
+ allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false)
+
+ user_mentions = table(:vulnerability_user_mentions)
+
+ disable_migrations_output do
+ reversible_migration do |migration|
+ migration.before -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+
+ migration.after -> {
+ user_mentions.reset_column_information
+
+ expect(user_mentions.columns.find { |c| c.name == 'note_id' }.sql_type).to eq('integer')
+ expect(user_mentions.columns.find { |c| c.name == 'note_id_convert_to_bigint' }.sql_type).to eq('bigint')
+ }
+ end
+ end
+ end
+ # rubocop: enable RSpec/AnyInstanceOf
+ end
+end
diff --git a/spec/migrations/sync_new_amount_used_for_ci_namespace_monthly_usages_spec.rb b/spec/migrations/sync_new_amount_used_for_ci_namespace_monthly_usages_spec.rb
index da8790f4450..c60447d04a1 100644
--- a/spec/migrations/sync_new_amount_used_for_ci_namespace_monthly_usages_spec.rb
+++ b/spec/migrations/sync_new_amount_used_for_ci_namespace_monthly_usages_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
require_migration!
RSpec.describe SyncNewAmountUsedForCiNamespaceMonthlyUsages, migration: :gitlab_ci,
- feature_category: :continuous_integration do
+ feature_category: :continuous_integration do
let(:namespace_usages) { table(:ci_namespace_monthly_usages) }
before do
diff --git a/spec/migrations/sync_new_amount_used_for_ci_project_monthly_usages_spec.rb b/spec/migrations/sync_new_amount_used_for_ci_project_monthly_usages_spec.rb
index 1c9b2711687..d7add66a97f 100644
--- a/spec/migrations/sync_new_amount_used_for_ci_project_monthly_usages_spec.rb
+++ b/spec/migrations/sync_new_amount_used_for_ci_project_monthly_usages_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
require_migration!
RSpec.describe SyncNewAmountUsedForCiProjectMonthlyUsages, migration: :gitlab_ci,
- feature_category: :continuous_integration do
+ feature_category: :continuous_integration do
let(:project_usages) { table(:ci_project_monthly_usages) }
before do
diff --git a/spec/migrations/update_application_settings_container_registry_exp_pol_worker_capacity_default_spec.rb b/spec/migrations/update_application_settings_container_registry_exp_pol_worker_capacity_default_spec.rb
index d249fcecf66..66da9e6653d 100644
--- a/spec/migrations/update_application_settings_container_registry_exp_pol_worker_capacity_default_spec.rb
+++ b/spec/migrations/update_application_settings_container_registry_exp_pol_worker_capacity_default_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe UpdateApplicationSettingsContainerRegistryExpPolWorkerCapacityDefault,
-feature_category: :container_registry do
+ feature_category: :container_registry do
let(:settings) { table(:application_settings) }
context 'with no rows in the application_settings table' do
diff --git a/spec/migrations/update_application_settings_protected_paths_spec.rb b/spec/migrations/update_application_settings_protected_paths_spec.rb
index d61eadf9f9c..c2bd4e8727d 100644
--- a/spec/migrations/update_application_settings_protected_paths_spec.rb
+++ b/spec/migrations/update_application_settings_protected_paths_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require_migration!
RSpec.describe UpdateApplicationSettingsProtectedPaths, :aggregate_failures,
-feature_category: :authentication_and_authorization do
+ feature_category: :system_access do
subject(:migration) { described_class.new }
let!(:application_settings) { table(:application_settings) }
diff --git a/spec/migrations/update_default_scan_method_of_dast_site_profile_spec.rb b/spec/migrations/update_default_scan_method_of_dast_site_profile_spec.rb
index ac7a4171063..15a8e79a610 100644
--- a/spec/migrations/update_default_scan_method_of_dast_site_profile_spec.rb
+++ b/spec/migrations/update_default_scan_method_of_dast_site_profile_spec.rb
@@ -14,13 +14,23 @@ RSpec.describe UpdateDefaultScanMethodOfDastSiteProfile, feature_category: :dyna
project = projects.create!(id: 12, namespace_id: namespace.id, name: 'gitlab', path: 'gitlab')
dast_site = dast_sites.create!(id: 1, url: 'https://www.gitlab.com', project_id: project.id)
- dast_site_profiles.create!(id: 1, project_id: project.id, dast_site_id: dast_site.id,
- name: "#{FFaker::Product.product_name.truncate(192)} #{SecureRandom.hex(4)} - 0",
- scan_method: 0, target_type: 0)
+ dast_site_profiles.create!(
+ id: 1,
+ project_id: project.id,
+ dast_site_id: dast_site.id,
+ name: "#{FFaker::Product.product_name.truncate(192)} #{SecureRandom.hex(4)} - 0",
+ scan_method: 0,
+ target_type: 0
+ )
- dast_site_profiles.create!(id: 2, project_id: project.id, dast_site_id: dast_site.id,
- name: "#{FFaker::Product.product_name.truncate(192)} #{SecureRandom.hex(4)} - 1",
- scan_method: 0, target_type: 1)
+ dast_site_profiles.create!(
+ id: 2,
+ project_id: project.id,
+ dast_site_id: dast_site.id,
+ name: "#{FFaker::Product.product_name.truncate(192)} #{SecureRandom.hex(4)} - 1",
+ scan_method: 0,
+ target_type: 1
+ )
end
it 'updates the scan_method to 1 for profiles with target_type 1' do
diff --git a/spec/models/abuse/trust_score_spec.rb b/spec/models/abuse/trust_score_spec.rb
new file mode 100644
index 00000000000..755309ac699
--- /dev/null
+++ b/spec/models/abuse/trust_score_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Abuse::TrustScore, feature_category: :instance_resiliency do
+ let_it_be(:user) { create(:user) }
+
+ let(:correlation_id) { nil }
+
+ let(:abuse_trust_score) do
+ create(:abuse_trust_score, user: user, correlation_id_value: correlation_id)
+ end
+
+ describe 'associations' 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(:score) }
+ it { is_expected.to validate_presence_of(:source) }
+ end
+
+ describe 'create' do
+ subject { abuse_trust_score }
+
+ before do
+ allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('123abc')
+ stub_const('Abuse::TrustScore::MAX_EVENTS', 2)
+ end
+
+ context 'if correlation ID is nil' do
+ it 'adds the correlation id' do
+ expect(subject.correlation_id_value).to eq('123abc')
+ end
+ end
+
+ context 'if correlation ID is set' do
+ let(:correlation_id) { 'already-set' }
+
+ it 'does not change the correlation id' do
+ expect(subject.correlation_id_value).to eq('already-set')
+ end
+ end
+
+ context 'if max events is exceeded' do
+ it 'removes the oldest events' do
+ first = create(:abuse_trust_score, user: user)
+ create(:abuse_trust_score, user: user)
+ create(:abuse_trust_score, user: user)
+
+ expect(user.abuse_trust_scores.count).to eq(2)
+ expect(described_class.find_by_id(first.id)).to eq(nil)
+ end
+ end
+ end
+end
diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb
index 7995cc36383..edfac39728f 100644
--- a/spec/models/abuse_report_spec.rb
+++ b/spec/models/abuse_report_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe AbuseReport, feature_category: :insider_threat do
+ include Gitlab::Routing.url_helpers
+
let_it_be(:report, reload: true) { create(:abuse_report) }
let_it_be(:user, reload: true) { create(:admin) }
@@ -13,6 +15,7 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do
describe 'associations' do
it { is_expected.to belong_to(:reporter).class_name('User') }
it { is_expected.to belong_to(:user) }
+ it { is_expected.to have_many(:events).class_name('ResourceEvents::AbuseReportEvent').inverse_of(:abuse_report) }
it "aliases reporter to author" do
expect(subject.author).to be(subject.reporter)
@@ -68,6 +71,53 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do
"https://gitlab.com/#{SecureRandom.alphanumeric(494)}"
]).for(:links_to_spam)
}
+
+ context 'for screenshot' do
+ let(:txt_file) { fixture_file_upload('spec/fixtures/doc_sample.txt', 'text/plain') }
+ let(:img_file) { fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') }
+
+ it { is_expected.not_to allow_value(txt_file).for(:screenshot) }
+ it { is_expected.to allow_value(img_file).for(:screenshot) }
+
+ it { is_expected.to allow_value(nil).for(:screenshot) }
+ it { is_expected.to allow_value('').for(:screenshot) }
+ end
+ end
+
+ describe 'scopes' do
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:report1) { create(:abuse_report, reporter: reporter) }
+ let_it_be(:report2) { create(:abuse_report, :closed, category: 'phishing') }
+
+ describe '.by_reporter_id' do
+ subject(:results) { described_class.by_reporter_id(reporter.id) }
+
+ it 'returns reports with reporter_id equal to the given user id' do
+ expect(subject).to match_array([report1])
+ end
+ end
+
+ describe '.open' do
+ subject(:results) { described_class.open }
+
+ it 'returns reports without resolved_at value' do
+ expect(subject).to match_array([report, report1])
+ end
+ end
+
+ describe '.closed' do
+ subject(:results) { described_class.closed }
+
+ it 'returns reports with resolved_at value' do
+ expect(subject).to match_array([report2])
+ end
+ end
+
+ describe '.by_category' do
+ it 'returns abuse reports with the specified category' do
+ expect(described_class.by_category('phishing')).to match_array([report2])
+ end
+ end
end
describe 'before_validation' do
@@ -109,6 +159,168 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do
end
end
+ describe '#screenshot_path' do
+ let(:report) { create(:abuse_report, :with_screenshot) }
+
+ context 'with asset host configured' do
+ let(:asset_host) { 'https://gitlab-assets.example.com' }
+
+ before do
+ allow(ActionController::Base).to receive(:asset_host) { asset_host }
+ end
+
+ it 'returns a full URL with the asset host and system path' do
+ expect(report.screenshot_path).to eq("#{asset_host}#{report.screenshot.url}")
+ end
+ end
+
+ context 'when no asset path configured' do
+ let(:base_url) { Gitlab.config.gitlab.base_url }
+
+ it 'returns a full URL with the base url and system path' do
+ expect(report.screenshot_path).to eq("#{base_url}#{report.screenshot.url}")
+ end
+ end
+ end
+
+ describe '#report_type' do
+ let(:report) { build_stubbed(:abuse_report, reported_from_url: url) }
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:merge_request) { create(:merge_request) }
+ let_it_be(:user) { create(:user) }
+
+ subject { report.report_type }
+
+ context 'when reported from an issue' do
+ let(:url) { project_issue_url(issue.project, issue) }
+
+ it { is_expected.to eq :issue }
+ end
+
+ context 'when reported from a merge request' do
+ let(:url) { project_merge_request_url(merge_request.project, merge_request) }
+
+ it { is_expected.to eq :merge_request }
+ end
+
+ context 'when reported from a profile' do
+ let(:url) { user_url(user) }
+
+ it { is_expected.to eq :profile }
+ end
+
+ describe 'comment type' do
+ context 'when reported from an issue comment' do
+ let(:url) { project_issue_url(issue.project, issue, anchor: 'note_123') }
+
+ it { is_expected.to eq :comment }
+ end
+
+ context 'when reported from a merge request comment' do
+ let(:url) { project_merge_request_url(merge_request.project, merge_request, anchor: 'note_123') }
+
+ it { is_expected.to eq :comment }
+ end
+
+ context 'when anchor exists not from an issue or merge request URL' do
+ let(:url) { user_url(user, anchor: 'note_123') }
+
+ it { is_expected.to eq :profile }
+ end
+
+ context 'when note id is invalid' do
+ let(:url) { project_merge_request_url(merge_request.project, merge_request, anchor: 'note_12x') }
+
+ it { is_expected.to eq :merge_request }
+ end
+ end
+
+ context 'when URL cannot be matched' do
+ let(:url) { '/xxx' }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#reported_content' do
+ let(:report) { build_stubbed(:abuse_report, reported_from_url: url) }
+ let_it_be(:issue) { create(:issue, description: 'issue description') }
+ let_it_be(:merge_request) { create(:merge_request, description: 'mr description') }
+ let_it_be(:user) { create(:user) }
+
+ subject { report.reported_content }
+
+ context 'when reported from an issue' do
+ let(:url) { project_issue_url(issue.project, issue) }
+
+ it { is_expected.to eq issue.description_html }
+ end
+
+ context 'when reported from a merge request' do
+ let(:url) { project_merge_request_url(merge_request.project, merge_request) }
+
+ it { is_expected.to eq merge_request.description_html }
+ end
+
+ context 'when reported from a merge request with an invalid note ID' do
+ let(:url) do
+ "#{project_merge_request_url(merge_request.project, merge_request)}#note_[]"
+ end
+
+ it { is_expected.to eq merge_request.description_html }
+ end
+
+ context 'when reported from a profile' do
+ let(:url) { user_url(user) }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when reported from an unknown URL' do
+ let(:url) { '/xxx' }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when reported from an invalid URL' do
+ let(:url) { 'http://example.com/[]' }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when reported from an issue comment' do
+ let(:note) { create(:note, noteable: issue, project: issue.project, note: 'comment in issue') }
+ let(:url) { project_issue_url(issue.project, issue, anchor: "note_#{note.id}") }
+
+ it { is_expected.to eq note.note_html }
+ end
+
+ context 'when reported from a merge request comment' do
+ let(:note) { create(:note, noteable: merge_request, project: merge_request.project, note: 'comment in mr') }
+ let(:url) { project_merge_request_url(merge_request.project, merge_request, anchor: "note_#{note.id}") }
+
+ it { is_expected.to eq note.note_html }
+ end
+
+ context 'when report type cannot be determined, because the comment does not exist' do
+ let(:url) do
+ project_merge_request_url(merge_request.project, merge_request, anchor: "note_#{non_existing_record_id}")
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#other_reports_for_user' do
+ let(:report) { create(:abuse_report) }
+ let(:another_user_report) { create(:abuse_report, user: report.user) }
+ let(:another_report) { create(:abuse_report) }
+
+ it 'returns other reports for the same user' do
+ expect(report.other_reports_for_user).to match_array(another_user_report)
+ end
+ end
+
describe 'enums' do
let(:categories) do
{
diff --git a/spec/models/achievements/user_achievement_spec.rb b/spec/models/achievements/user_achievement_spec.rb
index 9d88bfdd477..41eaadafa67 100644
--- a/spec/models/achievements/user_achievement_spec.rb
+++ b/spec/models/achievements/user_achievement_spec.rb
@@ -7,7 +7,34 @@ RSpec.describe Achievements::UserAchievement, type: :model, feature_category: :u
it { is_expected.to belong_to(:achievement).inverse_of(:user_achievements).required }
it { is_expected.to belong_to(:user).inverse_of(:user_achievements).required }
- it { is_expected.to belong_to(:awarded_by_user).class_name('User').inverse_of(:awarded_user_achievements).optional }
+ it { is_expected.to belong_to(:awarded_by_user).class_name('User').inverse_of(:awarded_user_achievements).required }
it { is_expected.to belong_to(:revoked_by_user).class_name('User').inverse_of(:revoked_user_achievements).optional }
+
+ describe '#revoked?' do
+ subject { achievement.revoked? }
+
+ context 'when revoked' do
+ let_it_be(:achievement) { create(:user_achievement, :revoked) }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when not revoked' do
+ let_it_be(:achievement) { create(:user_achievement) }
+
+ it { is_expected.to be false }
+ end
+ end
+ end
+
+ describe 'scopes' do
+ let_it_be(:user_achievement) { create(:user_achievement) }
+ let_it_be(:revoked_user_achievement) { create(:user_achievement, :revoked) }
+
+ describe '.not_revoked' do
+ it 'only returns user achievements which have not been revoked' do
+ expect(described_class.not_revoked).to contain_exactly(user_achievement)
+ end
+ end
end
end
diff --git a/spec/models/active_session_spec.rb b/spec/models/active_session_spec.rb
index 3665f13015e..8717b2a1075 100644
--- a/spec/models/active_session_spec.rb
+++ b/spec/models/active_session_spec.rb
@@ -190,8 +190,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do
Gitlab::Redis::Sessions.with do |redis|
expect(redis.scan_each.to_a).to include(
- described_class.key_name(user.id, session_id), # current session
- described_class.key_name_v1(user.id, session_id), # support for mixed deployment
+ described_class.key_name(user.id, session_id), # current session
lookup_key
)
end
@@ -217,19 +216,6 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do
end
end
- it 'is possible to log in only using the old session key' do
- session_id = "2::418729c72310bbf349a032f0bb6e3fce9f5a69df8f000d8ae0ac5d159d8f21ae"
- ActiveSession.set(user, request)
-
- Gitlab::Redis::SharedState.with do |redis|
- redis.del(described_class.key_name(user.id, session_id))
- end
-
- sessions = ActiveSession.list(user)
-
- expect(sessions).to be_present
- end
-
it 'keeps the created_at from the login on consecutive requests' do
created_at = Time.zone.parse('2018-03-12 09:06')
updated_at = created_at + 1.minute
@@ -593,7 +579,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do
let(:active_count) { 3 }
before do
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Sessions.with do |redis|
active_count.times do |number|
redis.set(
key_name(user.id, number),
@@ -608,13 +594,13 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do
end
it 'removes obsolete lookup entries' do
- active = Gitlab::Redis::SharedState.with do |redis|
+ active = Gitlab::Redis::Sessions.with do |redis|
ActiveSession.cleaned_up_lookup_entries(redis, user)
end
expect(active.count).to eq(active_count)
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Sessions.with do |redis|
lookup_entries = redis.smembers(lookup_key)
expect(lookup_entries.count).to eq(active_count)
@@ -627,7 +613,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do
it 'reports the removed entries' do
removed = []
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Sessions.with do |redis|
ActiveSession.cleaned_up_lookup_entries(redis, user, removed)
end
@@ -663,4 +649,26 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do
it_behaves_like 'cleaning up lookup entries'
end
end
+
+ describe '.set_active_user_cookie' do
+ let(:auth) { double(cookies: {}) }
+
+ it 'sets marketing cookie' do
+ ActiveSession.set_active_user_cookie(auth)
+ expect(auth.cookies[:about_gitlab_active_user][:value]).to be_truthy
+ end
+ end
+
+ describe '.unset_active_user_cookie' do
+ let(:auth) { double(cookies: {}) }
+
+ before do
+ ActiveSession.set_active_user_cookie(auth)
+ end
+
+ it 'unsets marketing cookie' do
+ ActiveSession.unset_active_user_cookie(auth)
+ expect(auth.cookies[:about_gitlab_active_user]).to be_nil
+ end
+ end
end
diff --git a/spec/models/airflow/dags_spec.rb b/spec/models/airflow/dags_spec.rb
deleted file mode 100644
index ff3c4522779..00000000000
--- a/spec/models/airflow/dags_spec.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Airflow::Dags, feature_category: :dataops do
- describe 'associations' do
- it { is_expected.to belong_to(:project) }
- end
-
- describe 'validations' do
- it { is_expected.to validate_presence_of(:project) }
- it { is_expected.to validate_presence_of(:dag_name) }
- it { is_expected.to validate_length_of(:dag_name).is_at_most(255) }
- it { is_expected.to validate_length_of(:schedule).is_at_most(255) }
- it { is_expected.to validate_length_of(:fileloc).is_at_most(255) }
- end
-end
diff --git a/spec/models/alert_management/alert_assignee_spec.rb b/spec/models/alert_management/alert_assignee_spec.rb
index c50a3ec0d01..647195380b3 100644
--- a/spec/models/alert_management/alert_assignee_spec.rb
+++ b/spec/models/alert_management/alert_assignee_spec.rb
@@ -5,7 +5,11 @@ require 'spec_helper'
RSpec.describe AlertManagement::AlertAssignee do
describe 'associations' do
it { is_expected.to belong_to(:alert) }
- it { is_expected.to belong_to(:assignee) }
+
+ it do
+ is_expected.to belong_to(:assignee).class_name('User')
+ .with_foreign_key(:user_id).inverse_of(:alert_assignees)
+ end
end
describe 'validations' do
diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb
index 685ed81ec84..ff77ca2ab64 100644
--- a/spec/models/alert_management/alert_spec.rb
+++ b/spec/models/alert_management/alert_spec.rb
@@ -16,9 +16,13 @@ RSpec.describe AlertManagement::Alert do
it { is_expected.to belong_to(:prometheus_alert).optional }
it { is_expected.to belong_to(:environment).optional }
it { is_expected.to have_many(:assignees).through(:alert_assignees) }
- it { is_expected.to have_many(:notes) }
- it { is_expected.to have_many(:ordered_notes) }
- it { is_expected.to have_many(:user_mentions) }
+ it { is_expected.to have_many(:notes).inverse_of(:noteable) }
+ it { is_expected.to have_many(:ordered_notes).class_name('Note').inverse_of(:noteable) }
+
+ it do
+ is_expected.to have_many(:user_mentions).class_name('AlertManagement::AlertUserMention')
+ .with_foreign_key(:alert_management_alert_id).inverse_of(:alert)
+ end
end
describe 'validations' do
diff --git a/spec/models/alert_management/alert_user_mention_spec.rb b/spec/models/alert_management/alert_user_mention_spec.rb
index 27c3d290dde..083bf667bea 100644
--- a/spec/models/alert_management/alert_user_mention_spec.rb
+++ b/spec/models/alert_management/alert_user_mention_spec.rb
@@ -4,7 +4,11 @@ require 'spec_helper'
RSpec.describe AlertManagement::AlertUserMention do
describe 'associations' do
- it { is_expected.to belong_to(:alert_management_alert) }
+ it do
+ is_expected.to belong_to(:alert).class_name('::AlertManagement::Alert')
+ .with_foreign_key(:alert_management_alert_id).inverse_of(:user_mentions)
+ end
+
it { is_expected.to belong_to(:note) }
end
diff --git a/spec/models/analytics/cycle_analytics/stage_spec.rb b/spec/models/analytics/cycle_analytics/stage_spec.rb
index 57748f8942e..960d8d3e964 100644
--- a/spec/models/analytics/cycle_analytics/stage_spec.rb
+++ b/spec/models/analytics/cycle_analytics/stage_spec.rb
@@ -32,24 +32,24 @@ RSpec.describe Analytics::CycleAnalytics::Stage, feature_category: :value_stream
before do
# event identifiers are the same
create(:cycle_analytics_stage, name: 'Stage A1', namespace: group,
- start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
+ start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
create(:cycle_analytics_stage, name: 'Stage A2', namespace: sub_group,
- start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
+ start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
create(:cycle_analytics_stage, name: 'Stage A3', namespace: sub_group,
- start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
+ start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
create(:cycle_analytics_stage, name: 'Stage A4', project: project,
- start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
+ start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
create(:cycle_analytics_stage,
- name: 'Stage B1',
- namespace: group,
- start_event_identifier: :merge_request_last_build_started,
- end_event_identifier: :merge_request_last_build_finished)
+ name: 'Stage B1',
+ namespace: group,
+ start_event_identifier: :merge_request_last_build_started,
+ end_event_identifier: :merge_request_last_build_finished)
create(:cycle_analytics_stage, name: 'Stage C1', project: project,
- start_event_identifier: :issue_created, end_event_identifier: :issue_deployed_to_production)
+ start_event_identifier: :issue_created, end_event_identifier: :issue_deployed_to_production)
create(:cycle_analytics_stage, name: 'Stage C2', project: project,
- start_event_identifier: :issue_created, end_event_identifier: :issue_deployed_to_production)
+ start_event_identifier: :issue_created, end_event_identifier: :issue_deployed_to_production)
end
it 'returns distinct stages by the event identifiers' do
@@ -69,14 +69,11 @@ RSpec.describe Analytics::CycleAnalytics::Stage, feature_category: :value_stream
end
end
- describe 'events tracking' do
- let(:category) { described_class.to_s }
- let(:label) { described_class.table_name }
+ it_behaves_like 'database events tracking' do
let(:namespace) { create(:group) }
- let(:action) { "database_event_#{property}" }
let(:value_stream) { create(:cycle_analytics_value_stream) }
- let(:feature_flag_name) { :product_intelligence_database_event_tracking }
- let(:stage) { described_class.create!(stage_params) }
+ let(:record) { described_class.create!(stage_params) }
+ let(:update_params) { { name: 'st 2' } }
let(:stage_params) do
{
namespace: namespace,
@@ -86,50 +83,5 @@ RSpec.describe Analytics::CycleAnalytics::Stage, feature_category: :value_stream
group_value_stream_id: value_stream.id
}
end
-
- let(:record_tracked_attributes) do
- {
- "id" => stage.id,
- "created_at" => stage.created_at,
- "updated_at" => stage.updated_at,
- "relative_position" => stage.relative_position,
- "start_event_identifier" => stage.start_event_identifier,
- "end_event_identifier" => stage.end_event_identifier,
- "group_id" => stage.group_id,
- "start_event_label_id" => stage.start_event_label_id,
- "end_event_label_id" => stage.end_event_label_id,
- "hidden" => stage.hidden,
- "custom" => stage.custom,
- "name" => stage.name,
- "group_value_stream_id" => stage.group_value_stream_id
- }
- end
-
- describe '#create' do
- it_behaves_like 'Snowplow event tracking' do
- let(:property) { 'create' }
- let(:extra) { record_tracked_attributes }
-
- subject(:new_group_stage) { stage }
- end
- end
-
- describe '#update', :freeze_time do
- it_behaves_like 'Snowplow event tracking' do
- subject(:create_group_stage) { stage.update!(name: 'st 2') }
-
- let(:extra) { record_tracked_attributes.merge('name' => 'st 2') }
- let(:property) { 'update' }
- end
- end
-
- describe '#destroy' do
- it_behaves_like 'Snowplow event tracking' do
- subject(:delete_stage_group) { stage.destroy! }
-
- let(:extra) { record_tracked_attributes }
- let(:property) { 'destroy' }
- end
- end
end
end
diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb
index c1cd44e9007..ee3065cf8f2 100644
--- a/spec/models/application_record_spec.rb
+++ b/spec/models/application_record_spec.rb
@@ -106,12 +106,12 @@ RSpec.describe ApplicationRecord do
describe '.where_not_exists' do
it 'produces a WHERE NOT EXISTS query' do
- create(:user, :two_factor_via_u2f)
+ create(:user, :two_factor_via_webauthn)
user_2 = create(:user)
expect(
User.where_not_exists(
- U2fRegistration.where(U2fRegistration.arel_table[:user_id].eq(User.arel_table[:id])))
+ WebauthnRegistration.where(WebauthnRegistration.arel_table[:user_id].eq(User.arel_table[:id])))
).to match_array([user_2])
end
end
@@ -258,7 +258,7 @@ RSpec.describe ApplicationRecord do
before do
ApplicationRecord.connection.execute(<<~SQL)
- create table tests (
+ create table _test_tests (
id bigserial primary key not null,
ignore_me text
)
@@ -267,7 +267,7 @@ RSpec.describe ApplicationRecord do
context 'without an ignored column' do
let(:test_model) do
Class.new(ApplicationRecord) do
- self.table_name = 'tests'
+ self.table_name = :_test_tests
end
end
@@ -278,7 +278,7 @@ RSpec.describe ApplicationRecord do
let(:test_model) do
Class.new(ApplicationRecord) do
include IgnorableColumns
- self.table_name = 'tests'
+ self.table_name = :_test_tests
ignore_columns :ignore_me, remove_after: '2100-01-01', remove_with: '99.12'
end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 5b99c68ec80..98bfb3366d2 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
+RSpec.describe ApplicationSetting, feature_category: :shared, type: :model do
using RSpec::Parameterized::TableSyntax
subject(:setting) { described_class.create_from_defaults }
@@ -31,6 +31,20 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
let(:ftp) { 'ftp://example.com' }
let(:javascript) { 'javascript:alert(window.opener.document.location)' }
+ let_it_be(:valid_database_apdex_settings) do
+ {
+ prometheus_api_url: 'Prometheus URL',
+ apdex_sli_query: {
+ main: 'Apdex SLI query main',
+ ci: 'Apdex SLI query ci'
+ },
+ apdex_slo: {
+ main: 0.99,
+ ci: 0.98
+ }
+ }
+ end
+
it { is_expected.to allow_value(nil).for(:home_page_url) }
it { is_expected.to allow_value(http).for(:home_page_url) }
it { is_expected.to allow_value(https).for(:home_page_url) }
@@ -132,6 +146,9 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
it { is_expected.to allow_value(false).for(:user_defaults_to_private_profile) }
it { is_expected.not_to allow_value(nil).for(:user_defaults_to_private_profile) }
+ it { is_expected.to allow_values([true, false]).for(:deny_all_requests_except_allowed) }
+ it { is_expected.not_to allow_value(nil).for(:deny_all_requests_except_allowed) }
+
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)
@@ -182,7 +199,8 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
it { is_expected.not_to allow_value('default' => 101).for(:repository_storages_weighted).with_message("value for 'default' must be between 0 and 100") }
it { is_expected.not_to allow_value('default' => 100, shouldntexist: 50).for(:repository_storages_weighted).with_message("can't include: shouldntexist") }
- %i[notes_create_limit search_rate_limit search_rate_limit_unauthenticated users_get_by_id_limit].each do |setting|
+ %i[notes_create_limit search_rate_limit search_rate_limit_unauthenticated users_get_by_id_limit
+ projects_api_rate_limit_unauthenticated].each do |setting|
it { is_expected.to allow_value(400).for(setting) }
it { is_expected.not_to allow_value('two').for(setting) }
it { is_expected.not_to allow_value(nil).for(setting) }
@@ -209,6 +227,12 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
it { is_expected.to allow_value('disabled').for(:whats_new_variant) }
it { is_expected.not_to allow_value(nil).for(:whats_new_variant) }
+ it { is_expected.to allow_value('http://example.com/').for(:public_runner_releases_url) }
+ it { is_expected.not_to allow_value(nil).for(:public_runner_releases_url) }
+
+ it { is_expected.to allow_value([true, false]).for(:update_runner_versions_enabled) }
+ it { is_expected.not_to allow_value(nil).for(:update_runner_versions_enabled) }
+
it { is_expected.not_to allow_value(['']).for(:valid_runner_registrars) }
it { is_expected.not_to allow_value(['OBVIOUSLY_WRONG']).for(:valid_runner_registrars) }
it { is_expected.not_to allow_value(%w(project project)).for(:valid_runner_registrars) }
@@ -228,6 +252,27 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
it { is_expected.to allow_value(false).for(:allow_runner_registration_token) }
it { is_expected.not_to allow_value(nil).for(:allow_runner_registration_token) }
+ it { is_expected.to allow_value(true).for(:gitlab_dedicated_instance) }
+ it { is_expected.to allow_value(false).for(:gitlab_dedicated_instance) }
+ it { is_expected.not_to allow_value(nil).for(:gitlab_dedicated_instance) }
+
+ it { is_expected.not_to allow_value(random: :value).for(:database_apdex_settings) }
+ it { is_expected.to allow_value(nil).for(:database_apdex_settings) }
+ it { is_expected.to allow_value(valid_database_apdex_settings).for(:database_apdex_settings) }
+
+ it { is_expected.to allow_value([true, false]).for(:silent_mode_enabled) }
+ it { is_expected.not_to allow_value(nil).for(:silent_mode_enabled) }
+
+ it { is_expected.to allow_value(0).for(:ci_max_includes) }
+ it { is_expected.to allow_value(200).for(:ci_max_includes) }
+ it { is_expected.not_to allow_value('abc').for(:ci_max_includes) }
+ it { is_expected.not_to allow_value(nil).for(:ci_max_includes) }
+ it { is_expected.not_to allow_value(10.5).for(:ci_max_includes) }
+ it { is_expected.not_to allow_value(-1).for(:ci_max_includes) }
+
+ it { is_expected.to allow_value([true, false]).for(:remember_me_enabled) }
+ it { is_expected.not_to allow_value(nil).for(:remember_me_enabled) }
+
context 'when deactivate_dormant_users is enabled' do
before do
stub_application_setting(deactivate_dormant_users: true)
@@ -268,6 +313,17 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
end
end
+ context 'import_sources validation' do
+ before do
+ subject.import_sources = %w[github bitbucket gitlab git gitlab_project gitea manifest phabricator]
+ end
+
+ it 'removes phabricator as an import source' do
+ subject.validate
+ expect(subject.import_sources).to eq(%w[github bitbucket git gitlab_project gitea manifest])
+ end
+ end
+
context 'grafana_url validations' do
before do
subject.instance_variable_set(:@parsed_grafana_url, nil)
@@ -318,7 +374,7 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
end
end
- describe 'default_branch_name validaitions' do
+ describe 'default_branch_name validations' do
context "when javascript tags get sanitized properly" do
it "gets sanitized properly" do
setting.update!(default_branch_name: "hello<script>alert(1)</script>")
@@ -506,6 +562,13 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
.is_less_than(65536)
end
+ specify do
+ is_expected.to validate_numericality_of(:archive_builds_in_seconds)
+ .only_integer
+ .is_greater_than_or_equal_to(1.day.seconds.to_i)
+ .with_message('must be at least 1 day')
+ end
+
describe 'usage_ping_enabled setting' do
shared_examples 'usage ping enabled' do
it do
@@ -585,6 +648,23 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
end
end
+ describe 'setting validated as `addressable_url` configured with external URI' do
+ before do
+ # Use any property that has the `addressable_url` validation.
+ setting.help_page_documentation_base_url = 'http://example.com'
+ end
+
+ it 'is valid by default' do
+ expect(setting).to be_valid
+ end
+
+ it 'is invalid when unpersisted `deny_all_requests_except_allowed` property is true' do
+ setting.deny_all_requests_except_allowed = true
+
+ expect(setting).not_to be_valid
+ end
+ end
+
context 'key restrictions' do
it 'does not allow all key types to be disabled' do
Gitlab::SSHPublicKey.supported_types.each do |type|
@@ -1124,6 +1204,11 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
it { is_expected.to allow_value(*Gitlab::I18n.available_locales).for(:default_preferred_language) }
it { is_expected.not_to allow_value(nil, '', 'invalid_locale').for(:default_preferred_language) }
end
+
+ context 'for default_syntax_highlighting_theme' do
+ it { is_expected.to allow_value(*Gitlab::ColorSchemes.valid_ids).for(:default_syntax_highlighting_theme) }
+ it { is_expected.not_to allow_value(nil, 0, Gitlab::ColorSchemes.available_schemes.size + 1).for(:default_syntax_highlighting_theme) }
+ end
end
context 'restrict creating duplicates' do
@@ -1144,6 +1229,17 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
end
end
+ describe 'ADDRESSABLE_URL_VALIDATION_OPTIONS' do
+ it 'is applied to all addressable_url validated properties' do
+ url_validators = described_class.validators.select { |validator| validator.is_a?(AddressableUrlValidator) }
+
+ url_validators.each do |validator|
+ expect(validator.options).to match(hash_including(described_class::ADDRESSABLE_URL_VALIDATION_OPTIONS)),
+ "#{validator.attributes} should use ADDRESSABLE_URL_VALIDATION_OPTIONS"
+ end
+ end
+ end
+
describe '#disabled_oauth_sign_in_sources=' do
before do
allow(Devise).to receive(:omniauth_providers).and_return([:github])
@@ -1451,7 +1547,7 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do
it { is_expected.to validate_numericality_of(:inactive_projects_min_size_mb).is_greater_than_or_equal_to(0) }
it "deletes the redis key used for tracking inactive projects deletion warning emails when setting is updated",
- :clean_gitlab_redis_shared_state do
+ :clean_gitlab_redis_shared_state do
Gitlab::Redis::SharedState.with do |redis|
redis.hset("inactive_projects_deletion_warning_email_notified", "project:1", "2020-01-01")
end
diff --git a/spec/models/audit_event_spec.rb b/spec/models/audit_event_spec.rb
index 9f2724cebee..9e667836b45 100644
--- a/spec/models/audit_event_spec.rb
+++ b/spec/models/audit_event_spec.rb
@@ -3,6 +3,10 @@
require 'spec_helper'
RSpec.describe AuditEvent do
+ describe 'associations' do
+ it { is_expected.to belong_to(:user).with_foreign_key(:author_id).inverse_of(:audit_events) }
+ end
+
describe 'validations' do
include_examples 'validates IP address' do
let(:attribute) { :ip_address }
diff --git a/spec/models/authentication_event_spec.rb b/spec/models/authentication_event_spec.rb
index 23e253c2a28..17fe10b5b4e 100644
--- a/spec/models/authentication_event_spec.rb
+++ b/spec/models/authentication_event_spec.rb
@@ -71,4 +71,19 @@ RSpec.describe AuthenticationEvent do
it { is_expected.to eq(false) }
end
end
+
+ describe '.most_used_ip_address_for_user' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:most_used_ip_address) { '::1' }
+ let_it_be(:another_ip_address) { '127.0.0.1' }
+
+ subject { described_class.most_used_ip_address_for_user(user) }
+
+ before do
+ create_list(:authentication_event, 2, user: user, ip_address: most_used_ip_address)
+ create(:authentication_event, user: user, ip_address: another_ip_address)
+ end
+
+ it { is_expected.to eq(most_used_ip_address) }
+ end
end
diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb
index 2593c9b3595..99006f8ddce 100644
--- a/spec/models/award_emoji_spec.rb
+++ b/spec/models/award_emoji_spec.rb
@@ -63,22 +63,43 @@ RSpec.describe AwardEmoji do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:emoji) { create(:custom_emoji, name: 'partyparrot', namespace: group) }
+ let_it_be(:project) { create(:project, namespace: group) }
- before do
+ before_all do
group.add_maintainer(user)
end
- %i[issue merge_request note_on_issue snippet].each do |awardable_type|
- let_it_be(:project) { create(:project, namespace: group) }
- let(:awardable) { create(awardable_type, project: project) }
-
- it "is accepted on #{awardable_type}" do
+ shared_examples 'awardable' do
+ it 'is accepted' do
new_award = build(:award_emoji, user: user, awardable: awardable, name: emoji.name)
-
expect(new_award).to be_valid
end
end
+ context 'with issue' do
+ let(:awardable) { create(:issue, project: project) }
+
+ include_examples 'awardable'
+ end
+
+ context 'with merge_request' do
+ let(:awardable) { create(:merge_request, source_project: project) }
+
+ include_examples 'awardable'
+ end
+
+ context 'with note_on_issue' do
+ let(:awardable) { create(:note_on_issue, project: project) }
+
+ include_examples 'awardable'
+ end
+
+ context 'with snippet' do
+ let(:awardable) { create(:snippet, project: project) }
+
+ include_examples 'awardable'
+ end
+
it 'is accepted on subgroup issue' do
subgroup = create(:group, parent: group)
project = create(:project, namespace: subgroup)
diff --git a/spec/models/awareness_session_spec.rb b/spec/models/awareness_session_spec.rb
deleted file mode 100644
index 854ce5957f7..00000000000
--- a/spec/models/awareness_session_spec.rb
+++ /dev/null
@@ -1,163 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe AwarenessSession, :clean_gitlab_redis_shared_state do
- subject { AwarenessSession.for(session_id) }
-
- let!(:user) { create(:user) }
- let(:session_id) { 1 }
-
- describe "when initiating a session" do
- it "provides a string representation of the model instance" do
- expected = "awareness_session=6b86b273ff34fce"
-
- expect(subject.to_s).to eql(expected)
- end
-
- it "provides a parameterized version of the session identifier" do
- expected = "6b86b273ff34fce"
-
- expect(subject.to_param).to eql(expected)
- end
- end
-
- describe "when a user joins a session" do
- let(:user2) { create(:user) }
-
- let(:presence_ttl) { 15.minutes }
-
- it "changes number of session members" do
- expect { subject.join(user) }.to change(subject, :size).by(1)
- end
-
- it "returns user as member of session with last_activity timestamp" do
- freeze_time do
- subject.join(user)
-
- session_users = subject.users_with_last_activity
- session_user, last_activity = session_users.first
-
- expect(session_user.id).to be(user.id)
- expect(last_activity).to be_eql(Time.now.utc)
- end
- end
-
- it "maintains user ID and last_activity pairs" do
- now = Time.zone.now
-
- travel_to now - 1.minute do
- subject.join(user2)
- end
-
- travel_to now do
- subject.join(user)
- end
-
- session_users = subject.users_with_last_activity
-
- expect(session_users[0].first.id).to eql(user.id)
- expect(session_users[0].last.to_i).to eql(now.to_i)
-
- expect(session_users[1].first.id).to eql(user2.id)
- expect(session_users[1].last.to_i).to eql((now - 1.minute).to_i)
- end
-
- it "reports user as present" do
- freeze_time do
- subject.join(user)
-
- expect(subject.present?(user, threshold: presence_ttl)).to be true
- end
- end
-
- it "reports user as away after a certain time on inactivity" do
- subject.join(user)
-
- travel_to((presence_ttl + 1.minute).from_now) do
- expect(subject.away?(user, threshold: presence_ttl)).to be true
- end
- end
-
- it "reports user as present still when there was some activity" do
- subject.join(user)
-
- travel_to((presence_ttl - 1.minute).from_now) do
- subject.touch!(user)
- end
-
- travel_to((presence_ttl + 1.minute).from_now) do
- expect(subject.present?(user, threshold: presence_ttl)).to be true
- end
- end
-
- it "creates user and session awareness keys in store" do
- subject.join(user)
-
- Gitlab::Redis::SharedState.with do |redis|
- keys = redis.scan_each(match: "gitlab:awareness:*").to_a
-
- expect(keys.size).to be(2)
- end
- end
-
- it "sets a timeout for user and session key" do
- subject.join(user)
- subject_id = Digest::SHA256.hexdigest(session_id.to_s)[0, 15]
-
- Gitlab::Redis::SharedState.with do |redis|
- ttl_session = redis.ttl("gitlab:awareness:session:#{subject_id}:users")
- ttl_user = redis.ttl("gitlab:awareness:user:#{user.id}:sessions")
-
- expect(ttl_session).to be > 0
- expect(ttl_user).to be > 0
- end
- end
-
- it "fetches user(s) from database" do
- subject.join(user)
-
- expect(subject.users.first).to eql(user)
- end
-
- it "fetches and filters online user(s) from database" do
- subject.join(user)
-
- travel 2.hours do
- subject.join(user2)
-
- online_users = subject.online_users_with_last_activity
- online_user, _ = online_users.first
-
- expect(online_users.size).to be 1
- expect(online_user).to eql(user2)
- end
- end
- end
-
- describe "when a user leaves a session" do
- it "changes number of session members" do
- subject.join(user)
-
- expect { subject.leave(user) }.to change(subject, :size).by(-1)
- end
-
- it "destroys the session when it was the last user" do
- subject.join(user)
-
- expect { subject.leave(user) }.to change(subject, :id).to(nil)
- end
- end
-
- describe "when last user leaves a session" do
- it "session and user keys are removed" do
- subject.join(user)
-
- Gitlab::Redis::SharedState.with do |redis|
- expect { subject.leave(user) }
- .to change { redis.scan_each(match: "gitlab:awareness:*").to_a.size }
- .to(0)
- end
- end
- end
-end
diff --git a/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb b/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb
index d28fa0bbe97..c9ac13eefc0 100644
--- a/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb
+++ b/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BlobViewer::MetricsDashboardYml do
+RSpec.describe BlobViewer::MetricsDashboardYml, feature_category: :metrics do
include FakeBlobHelpers
include RepoHelpers
@@ -119,4 +119,18 @@ RSpec.describe BlobViewer::MetricsDashboardYml do
expect(viewer.errors).to eq ["YAML syntax: The parsed YAML is too big"]
end
end
+
+ describe '.can_render?' do
+ subject { described_class.can_render?(blob) }
+
+ it { is_expected.to be false }
+
+ context 'when metrics dashboard feature is available' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
+ it { is_expected.to be true }
+ end
+ end
end
diff --git a/spec/models/blob_viewer/package_json_spec.rb b/spec/models/blob_viewer/package_json_spec.rb
index 1dcba3bcb4f..781623c0d3d 100644
--- a/spec/models/blob_viewer/package_json_spec.rb
+++ b/spec/models/blob_viewer/package_json_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BlobViewer::PackageJson do
+RSpec.describe BlobViewer::PackageJson, feature_category: :source_code_management do
include FakeBlobHelpers
let(:project) { build_stubbed(:project) }
@@ -59,6 +59,17 @@ RSpec.describe BlobViewer::PackageJson do
expect(subject.manager_url).to eq("https://yarnpkg.com/")
end
end
+
+ context 'when json is an array' do
+ let(:data) { '[]' }
+
+ it 'does not raise an error', :aggregate_failures do
+ expect(subject).to receive(:prepare!)
+
+ expect { subject.yarn? }.not_to raise_error
+ expect(subject.yarn?).to be_falsey
+ end
+ end
end
context 'npm' do
diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb
index 6017298e85b..f469dee5ba1 100644
--- a/spec/models/board_spec.rb
+++ b/spec/models/board_spec.rb
@@ -8,7 +8,13 @@ RSpec.describe Board do
describe 'relationships' do
it { is_expected.to belong_to(:project) }
- it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) }
+
+ it do
+ is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all)
+ .inverse_of(:board)
+ end
+
+ it { is_expected.to have_many(:destroyable_lists).order(list_type: :asc, position: :asc).inverse_of(:board) }
end
describe 'validations' do
diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb
index 8fdc9852f6e..5fcf6813b0a 100644
--- a/spec/models/broadcast_message_spec.rb
+++ b/spec/models/broadcast_message_spec.rb
@@ -24,7 +24,11 @@ RSpec.describe BroadcastMessage do
it { is_expected.to allow_value(1).for(:broadcast_type) }
it { is_expected.not_to allow_value(nil).for(:broadcast_type) }
it { is_expected.not_to allow_value(nil).for(:target_access_levels) }
- it { is_expected.to validate_inclusion_of(:target_access_levels).in_array(described_class::ALLOWED_TARGET_ACCESS_LEVELS) }
+
+ it do
+ is_expected.to validate_inclusion_of(:target_access_levels)
+ .in_array(described_class::ALLOWED_TARGET_ACCESS_LEVELS)
+ end
end
describe 'default values' do
@@ -187,32 +191,6 @@ RSpec.describe BroadcastMessage do
shared_examples "matches with user access level" do |broadcast_type|
let_it_be(:target_access_levels) { [Gitlab::Access::GUEST] }
- let(:feature_flag_state) { true }
-
- before do
- stub_feature_flags(role_targeted_broadcast_messages: feature_flag_state)
- end
-
- context 'when feature flag is disabled' do
- let(:feature_flag_state) { false }
-
- context 'when message is role-targeted' do
- let_it_be(:message) { create(:broadcast_message, target_access_levels: target_access_levels, broadcast_type: broadcast_type) }
-
- it 'does not return the message' do
- expect(subject.call(nil, Gitlab::Access::GUEST)).to be_empty
- end
- end
-
- context 'when message is not role-targeted' do
- let_it_be(:message) { create(:broadcast_message, target_access_levels: [], broadcast_type: broadcast_type) }
-
- it 'returns the message' do
- expect(subject.call(nil, Gitlab::Access::GUEST)).to include(message)
- end
- end
- end
-
context 'when target_access_levels is empty' do
let_it_be(:message) { create(:broadcast_message, target_access_levels: [], broadcast_type: broadcast_type) }
@@ -226,7 +204,9 @@ RSpec.describe BroadcastMessage do
end
context 'when target_access_levels is not empty' do
- let_it_be(:message) { create(:broadcast_message, target_access_levels: target_access_levels, broadcast_type: broadcast_type) }
+ let_it_be(:message) do
+ create(:broadcast_message, target_access_levels: target_access_levels, broadcast_type: broadcast_type)
+ end
it "does not return the message if user access level is nil" do
expect(subject.call).to be_empty
@@ -250,26 +230,18 @@ RSpec.describe BroadcastMessage do
before do
cache.write(described_class::BANNER_CACHE_KEY, [message])
- allow(BroadcastMessage).to receive(:cache) { cache }
+ allow(described_class).to receive(:cache) { cache }
end
it 'does not raise error (e.g. NoMethodError from nil.empty?)' do
expect { subject.call }.not_to raise_error
end
-
- context 'when feature flag is disabled' do
- it 'does not raise error (e.g. NoMethodError from nil.empty?)' do
- stub_feature_flags(role_targeted_broadcast_messages: false)
-
- expect { subject.call }.not_to raise_error
- end
- end
end
end
describe '.current', :use_clean_rails_memory_store_caching do
subject do
- -> (path = nil, user_access_level = nil) do
+ ->(path = nil, user_access_level = nil) do
described_class.current(current_path: path, user_access_level: user_access_level)
end
end
@@ -301,7 +273,7 @@ RSpec.describe BroadcastMessage do
describe '.current_banner_messages', :use_clean_rails_memory_store_caching do
subject do
- -> (path = nil, user_access_level = nil) do
+ ->(path = nil, user_access_level = nil) do
described_class.current_banner_messages(current_path: path, user_access_level: user_access_level)
end
end
@@ -331,7 +303,7 @@ RSpec.describe BroadcastMessage do
describe '.current_notification_messages', :use_clean_rails_memory_store_caching do
subject do
- -> (path = nil, user_access_level = nil) do
+ ->(path = nil, user_access_level = nil) do
described_class.current_notification_messages(current_path: path, user_access_level: user_access_level)
end
end
diff --git a/spec/models/bulk_import_spec.rb b/spec/models/bulk_import_spec.rb
index 3430da43f62..acb1f4a2ef7 100644
--- a/spec/models/bulk_import_spec.rb
+++ b/spec/models/bulk_import_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImport, type: :model do
+RSpec.describe BulkImport, type: :model, feature_category: :importers do
let_it_be(:created_bulk_import) { create(:bulk_import, :created) }
let_it_be(:started_bulk_import) { create(:bulk_import, :started) }
let_it_be(:finished_bulk_import) { create(:bulk_import, :finished) }
@@ -48,4 +48,31 @@ RSpec.describe BulkImport, type: :model do
expect(bulk_import.source_version_info.to_s).to eq(bulk_import.source_version)
end
end
+
+ describe '#update_has_failures' do
+ let(:import) { create(:bulk_import, :started) }
+ let(:entity) { create(:bulk_import_entity, bulk_import: import) }
+
+ context 'when entity has failures' do
+ it 'sets has_failures flag to true' do
+ expect(import.has_failures).to eq(false)
+
+ entity.update!(has_failures: true)
+ import.fail_op!
+
+ expect(import.has_failures).to eq(true)
+ end
+ end
+
+ context 'when entity does not have failures' do
+ it 'sets has_failures flag to false' do
+ expect(import.has_failures).to eq(false)
+
+ entity.update!(has_failures: false)
+ import.fail_op!
+
+ expect(import.has_failures).to eq(false)
+ end
+ end
+ end
end
diff --git a/spec/models/bulk_imports/batch_tracker_spec.rb b/spec/models/bulk_imports/batch_tracker_spec.rb
new file mode 100644
index 00000000000..336943228c7
--- /dev/null
+++ b/spec/models/bulk_imports/batch_tracker_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::BatchTracker, type: :model, feature_category: :importers do
+ describe 'associations' do
+ it { is_expected.to belong_to(:tracker) }
+ end
+
+ describe 'validations' do
+ subject { build(:bulk_import_batch_tracker) }
+
+ it { is_expected.to validate_presence_of(:batch_number) }
+ it { is_expected.to validate_uniqueness_of(:batch_number).scoped_to(:tracker_id) }
+ end
+end
diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb
index 56796aa1fe4..c7ace3d2b78 100644
--- a/spec/models/bulk_imports/entity_spec.rb
+++ b/spec/models/bulk_imports/entity_spec.rb
@@ -3,11 +3,18 @@
require 'spec_helper'
RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers do
+ subject { described_class.new(group: Group.new) }
+
describe 'associations' do
it { is_expected.to belong_to(:bulk_import).required }
it { is_expected.to belong_to(:parent) }
- it { is_expected.to belong_to(:group) }
+ it { is_expected.to belong_to(:group).optional.with_foreign_key(:namespace_id).inverse_of(:bulk_import_entities) }
it { is_expected.to belong_to(:project) }
+
+ it do
+ is_expected.to have_many(:trackers).class_name('BulkImports::Tracker')
+ .with_foreign_key(:bulk_import_entity_id).inverse_of(:entity)
+ end
end
describe 'validations' do
@@ -18,35 +25,8 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d
it { is_expected.to define_enum_for(:source_type).with_values(%i[group_entity project_entity]) }
context 'when formatting with regexes' do
- subject { described_class.new(group: Group.new) }
-
- it { is_expected.to allow_values('namespace', 'parent/namespace', 'parent/group/subgroup', '').for(:destination_namespace) }
- it { is_expected.not_to allow_values('parent/namespace/', '/namespace', 'parent group/subgroup', '@namespace').for(:destination_namespace) }
-
it { is_expected.to allow_values('source', 'source/path', 'source/full/path').for(:source_full_path) }
it { is_expected.not_to allow_values('/source', 'http://source/path', 'sou rce/full/path', '').for(:source_full_path) }
-
- it { is_expected.to allow_values('destination', 'destination-slug', 'new-destination-slug').for(:destination_slug) }
-
- # it { is_expected.not_to allow_values('destination/slug', '/destination-slug', 'destination slug').for(:destination_slug) } <-- this test should
- # succeed but it's failing possibly due to rspec caching. To ensure this case is covered see the more cumbersome test below:
- context 'when destination_slug is invalid' do
- let(:invalid_slugs) { ['destination/slug', '/destination-slug', 'destination slug'] }
- let(:error_message) do
- 'cannot start with a non-alphanumeric character except for periods or underscores, ' \
- 'can contain only alphanumeric characters, periods, and underscores, ' \
- 'cannot end with a period or forward slash, and has no ' \
- 'leading or trailing forward slashes'
- end
-
- it 'raises an error' do
- invalid_slugs.each do |slug|
- entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil, destination_slug: slug)
- expect(entity).not_to be_valid
- expect(entity.errors.errors[0].message).to include(error_message)
- end
- end
- end
end
context 'when associated with a group and project' do
@@ -93,8 +73,6 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d
end
it 'is invalid as a project_entity' do
- stub_feature_flags(bulk_import_projects: true)
-
entity = build(:bulk_import_entity, :project_entity, group: build(:group), project: nil)
expect(entity).not_to be_valid
@@ -104,8 +82,6 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d
context 'when associated with a project and no group' do
it 'is valid' do
- stub_feature_flags(bulk_import_projects: true)
-
entity = build(:bulk_import_entity, :project_entity, group: nil, project: build(:project))
expect(entity).to be_valid
@@ -135,8 +111,6 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d
context 'when the parent is a project import' do
it 'is invalid' do
- stub_feature_flags(bulk_import_projects: true)
-
entity = build(:bulk_import_entity, parent: build(:bulk_import_entity, :project_entity))
expect(entity).not_to be_valid
@@ -178,38 +152,13 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d
end
end
- context 'when bulk_import_projects feature flag is disabled and source_type is a project_entity' do
- it 'is invalid' do
- stub_feature_flags(bulk_import_projects: false)
-
- entity = build(:bulk_import_entity, :project_entity)
-
- expect(entity).not_to be_valid
- expect(entity.errors[:base]).to include('invalid entity source type')
- end
- end
-
- context 'when bulk_import_projects feature flag is enabled and source_type is a project_entity' do
+ context 'when source_type is a project_entity' do
it 'is valid' do
- stub_feature_flags(bulk_import_projects: true)
-
entity = build(:bulk_import_entity, :project_entity)
expect(entity).to be_valid
end
end
-
- context 'when bulk_import_projects feature flag is enabled on root ancestor level and source_type is a project_entity' do
- it 'is valid' do
- top_level_namespace = create(:group)
-
- stub_feature_flags(bulk_import_projects: top_level_namespace)
-
- entity = build(:bulk_import_entity, :project_entity, destination_namespace: top_level_namespace.full_path)
-
- expect(entity).to be_valid
- end
- end
end
describe '#encoded_source_full_path' do
@@ -412,4 +361,30 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d
end
end
end
+
+ describe '#update_has_failures' do
+ let(:entity) { create(:bulk_import_entity) }
+
+ context 'when entity has failures' do
+ it 'sets has_failures flag to true' do
+ expect(entity.has_failures).to eq(false)
+
+ create(:bulk_import_failure, entity: entity)
+
+ entity.fail_op!
+
+ expect(entity.has_failures).to eq(true)
+ end
+ end
+
+ context 'when entity does not have failures' do
+ it 'sets has_failures flag to false' do
+ expect(entity.has_failures).to eq(false)
+
+ entity.fail_op!
+
+ expect(entity.has_failures).to eq(false)
+ end
+ end
+ end
end
diff --git a/spec/models/bulk_imports/export_batch_spec.rb b/spec/models/bulk_imports/export_batch_spec.rb
new file mode 100644
index 00000000000..43209921b9c
--- /dev/null
+++ b/spec/models/bulk_imports/export_batch_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::ExportBatch, type: :model, feature_category: :importers do
+ describe 'associations' do
+ it { is_expected.to belong_to(:export) }
+ it { is_expected.to have_one(:upload) }
+ end
+
+ describe 'validations' do
+ subject { build(:bulk_import_export_batch) }
+
+ it { is_expected.to validate_presence_of(:batch_number) }
+ it { is_expected.to validate_uniqueness_of(:batch_number).scoped_to(:export_id) }
+ end
+end
diff --git a/spec/models/bulk_imports/export_spec.rb b/spec/models/bulk_imports/export_spec.rb
index d85b77d599b..7173d032bc2 100644
--- a/spec/models/bulk_imports/export_spec.rb
+++ b/spec/models/bulk_imports/export_spec.rb
@@ -2,11 +2,12 @@
require 'spec_helper'
-RSpec.describe BulkImports::Export, type: :model do
+RSpec.describe BulkImports::Export, type: :model, feature_category: :importers do
describe 'associations' do
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:project) }
it { is_expected.to have_one(:upload) }
+ it { is_expected.to have_many(:batches) }
end
describe 'validations' do
diff --git a/spec/models/bulk_imports/file_transfer/group_config_spec.rb b/spec/models/bulk_imports/file_transfer/group_config_spec.rb
index 8660114b719..e50f52c728f 100644
--- a/spec/models/bulk_imports/file_transfer/group_config_spec.rb
+++ b/spec/models/bulk_imports/file_transfer/group_config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::FileTransfer::GroupConfig do
+RSpec.describe BulkImports::FileTransfer::GroupConfig, feature_category: :importers do
let_it_be(:exportable) { create(:group) }
let_it_be(:hex) { '123' }
@@ -49,4 +49,51 @@ RSpec.describe BulkImports::FileTransfer::GroupConfig do
expect(subject.relation_excluded_keys('group')).to include('owner_id')
end
end
+
+ describe '#batchable_relation?' do
+ context 'when relation is batchable' do
+ it 'returns true' do
+ expect(subject.batchable_relation?('labels')).to eq(true)
+ end
+ end
+
+ context 'when relation is not batchable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('namespace_settings')).to eq(false)
+ end
+ end
+
+ context 'when relation is not listed as portable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('foo')).to eq(false)
+ end
+ end
+ end
+
+ describe '#batchable_relations' do
+ it 'returns a list of collection associations for a group' do
+ expect(subject.batchable_relations).to include('labels', 'boards', 'milestones')
+ expect(subject.batchable_relations).not_to include('namespace_settings')
+ end
+ end
+
+ describe '#export_service_for' do
+ context 'when relation is a tree' do
+ it 'returns TreeExportService' do
+ expect(subject.export_service_for('labels')).to eq(BulkImports::TreeExportService)
+ end
+ end
+
+ context 'when relation is a file' do
+ it 'returns FileExportService' do
+ expect(subject.export_service_for('uploads')).to eq(BulkImports::FileExportService)
+ end
+ end
+
+ context 'when relation is unknown' do
+ it 'raises' do
+ expect { subject.export_service_for('foo') }.to raise_error(BulkImports::Error, 'Unsupported export relation')
+ end
+ end
+ end
end
diff --git a/spec/models/bulk_imports/file_transfer/project_config_spec.rb b/spec/models/bulk_imports/file_transfer/project_config_spec.rb
index 0f02c5c546f..014f624165c 100644
--- a/spec/models/bulk_imports/file_transfer/project_config_spec.rb
+++ b/spec/models/bulk_imports/file_transfer/project_config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::FileTransfer::ProjectConfig do
+RSpec.describe BulkImports::FileTransfer::ProjectConfig, feature_category: :importers do
let_it_be(:exportable) { create(:project) }
let_it_be(:hex) { '123' }
@@ -42,6 +42,18 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do
it 'returns relation tree of a top level relation' do
expect(subject.top_relation_tree('labels')).to eq('priorities' => {})
end
+
+ it 'returns relation tree with merged with deprecated tree' do
+ expect(subject.top_relation_tree('ci_pipelines')).to match(
+ a_hash_including(
+ {
+ 'external_pull_request' => {},
+ 'merge_request' => {},
+ 'stages' => { 'bridges' => {}, 'builds' => {}, 'generic_commit_statuses' => {}, 'statuses' => {} }
+ }
+ )
+ )
+ end
end
describe '#relation_excluded_keys' do
@@ -97,4 +109,31 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do
expect(subject.file_relations).to contain_exactly('uploads', 'lfs_objects', 'repository', 'design')
end
end
+
+ describe '#batchable_relation?' do
+ context 'when relation is batchable' do
+ it 'returns true' do
+ expect(subject.batchable_relation?('issues')).to eq(true)
+ end
+ end
+
+ context 'when relation is not batchable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('project_feature')).to eq(false)
+ end
+ end
+
+ context 'when relation is not listed as portable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('foo')).to eq(false)
+ end
+ end
+ end
+
+ describe '#batchable_relations' do
+ it 'returns a list of collection associations for a project' do
+ expect(subject.batchable_relations).to include('issues', 'merge_requests', 'milestones')
+ expect(subject.batchable_relations).not_to include('project_feature', 'ci_cd_settings')
+ end
+ end
end
diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb
index 1516ab106cb..a618a12df6b 100644
--- a/spec/models/bulk_imports/tracker_spec.rb
+++ b/spec/models/bulk_imports/tracker_spec.rb
@@ -4,7 +4,10 @@ require 'spec_helper'
RSpec.describe BulkImports::Tracker, type: :model do
describe 'associations' do
- it { is_expected.to belong_to(:entity).required }
+ it do
+ is_expected.to belong_to(:entity).required.class_name('BulkImports::Entity')
+ .with_foreign_key(:bulk_import_entity_id).inverse_of(:trackers)
+ end
end
describe 'validations' do
diff --git a/spec/models/chat_name_spec.rb b/spec/models/chat_name_spec.rb
index 0838c232872..9d6b1a56458 100644
--- a/spec/models/chat_name_spec.rb
+++ b/spec/models/chat_name_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe ChatName, feature_category: :integrations do
subject { chat_name }
- it { is_expected.to belong_to(:integration) }
it { is_expected.to belong_to(:user) }
it { is_expected.to validate_presence_of(:user) }
@@ -16,12 +15,6 @@ RSpec.describe ChatName, feature_category: :integrations do
it { is_expected.to validate_uniqueness_of(:chat_id).scoped_to(:team_id) }
- it 'is not removed when the project is deleted' do
- expect { subject.reload.integration.project.delete }.not_to change { ChatName.count }
-
- expect(ChatName.where(id: subject.id)).to exist
- end
-
describe '#update_last_used_at', :clean_gitlab_redis_shared_state do
it 'updates the last_used_at timestamp' do
expect(subject.last_used_at).to be_nil
diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb
index 7b307de87c7..ac994735928 100644
--- a/spec/models/ci/bridge_spec.rb
+++ b/spec/models/ci/bridge_spec.rb
@@ -12,9 +12,7 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do
end
let(:bridge) do
- create(:ci_bridge, :variables, status: :created,
- options: options,
- pipeline: pipeline)
+ create(:ci_bridge, :variables, status: :created, options: options, pipeline: pipeline)
end
let(:options) do
@@ -40,16 +38,6 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do
it 'returns true' do
expect(bridge.retryable?).to eq(true)
end
-
- context 'without ci_recreate_downstream_pipeline ff' do
- before do
- stub_feature_flags(ci_recreate_downstream_pipeline: false)
- end
-
- it 'returns false' do
- expect(bridge.retryable?).to eq(false)
- end
- end
end
context 'when there is a pipeline loop detected' do
@@ -172,7 +160,9 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do
where(:downstream_status, :upstream_status) do
[
%w[success success],
- *::Ci::Pipeline.completed_statuses.without(:success).map { |status| [status.to_s, 'failed'] }
+ %w[canceled canceled],
+ %w[failed failed],
+ %w[skipped failed]
]
end
@@ -564,11 +554,13 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do
let!(:prepare2) { create(:ci_build, name: 'prepare2', pipeline: pipeline, stage_idx: 0) }
let!(:prepare3) { create(:ci_build, name: 'prepare3', pipeline: pipeline, stage_idx: 0) }
let!(:bridge) do
- create(:ci_bridge, pipeline: pipeline,
- stage_idx: 1,
- scheduling_type: 'dag',
- needs_attributes: [{ name: 'prepare1', artifacts: true },
- { name: 'prepare2', artifacts: false }])
+ create(
+ :ci_bridge,
+ pipeline: pipeline,
+ stage_idx: 1,
+ scheduling_type: 'dag',
+ needs_attributes: [{ name: 'prepare1', artifacts: true }, { name: 'prepare2', artifacts: false }]
+ )
end
let!(:job_variable_1) { create(:ci_job_variable, :dotenv_source, job: prepare1) }
@@ -581,7 +573,7 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do
end
end
- describe 'metadata partitioning', :ci_partitioning do
+ describe 'metadata partitioning', :ci_partitionable do
let(:pipeline) { create(:ci_pipeline, project: project, partition_id: ci_testing_partition_id) }
let(:bridge) do
diff --git a/spec/models/ci/build_dependencies_spec.rb b/spec/models/ci/build_dependencies_spec.rb
index 1dd0386060d..0709aa47ff1 100644
--- a/spec/models/ci/build_dependencies_spec.rb
+++ b/spec/models/ci/build_dependencies_spec.rb
@@ -7,10 +7,13 @@ RSpec.describe Ci::BuildDependencies do
let_it_be(:project, reload: true) { create(:project, :repository) }
let_it_be(:pipeline, reload: true) do
- create(:ci_pipeline, project: project,
- sha: project.commit.id,
- ref: project.default_branch,
- status: 'success')
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: project.commit.id,
+ ref: project.default_branch,
+ status: 'success'
+ )
end
let(:build_stage) { create(:ci_stage, name: 'build', pipeline: pipeline) }
diff --git a/spec/models/ci/build_metadata_spec.rb b/spec/models/ci/build_metadata_spec.rb
index fb50ba89cd3..8ed0e50e4b0 100644
--- a/spec/models/ci/build_metadata_spec.rb
+++ b/spec/models/ci/build_metadata_spec.rb
@@ -7,10 +7,13 @@ RSpec.describe Ci::BuildMetadata do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group, build_timeout: 2000) }
let_it_be(:pipeline) do
- create(:ci_pipeline, project: project,
- sha: project.commit.id,
- ref: project.default_branch,
- status: 'success')
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: project.commit.id,
+ ref: project.default_branch,
+ status: 'success'
+ )
end
let_it_be_with_reload(:runner) { create(:ci_runner) }
@@ -22,7 +25,6 @@ RSpec.describe Ci::BuildMetadata do
it { is_expected.to belong_to(:build) }
it { is_expected.to belong_to(:project) }
- it { is_expected.to belong_to(:runner_machine) }
describe '#update_timeout_state' do
subject { metadata }
diff --git a/spec/models/ci/build_need_spec.rb b/spec/models/ci/build_need_spec.rb
index aa1c57d1788..e46a2b8cf85 100644
--- a/spec/models/ci/build_need_spec.rb
+++ b/spec/models/ci/build_need_spec.rb
@@ -74,7 +74,7 @@ RSpec.describe Ci::BuildNeed, model: true, feature_category: :continuous_integra
stub_current_partition_id
end
- it 'creates build needs successfully', :aggregate_failures do
+ it 'creates build needs successfully', :aggregate_failures, :ci_partitionable do
ci_build.needs_attributes = [
{ name: "build", artifacts: true },
{ name: "build2", artifacts: true },
diff --git a/spec/models/ci/build_pending_state_spec.rb b/spec/models/ci/build_pending_state_spec.rb
index bff0b35f878..c978e3dba36 100644
--- a/spec/models/ci/build_pending_state_spec.rb
+++ b/spec/models/ci/build_pending_state_spec.rb
@@ -3,10 +3,15 @@
require 'spec_helper'
RSpec.describe Ci::BuildPendingState, feature_category: :continuous_integration do
+ describe 'associations' do
+ it do
+ is_expected.to belong_to(:build).class_name('Ci::Build').with_foreign_key(:build_id).inverse_of(:pending_state)
+ end
+ end
+
describe 'validations' do
subject(:pending_state) { build(:ci_build_pending_state) }
- it { is_expected.to belong_to(:build) }
it { is_expected.to validate_presence_of(:build) }
end
diff --git a/spec/models/ci/build_report_result_spec.rb b/spec/models/ci/build_report_result_spec.rb
index 90b23d3e824..90426f60c73 100644
--- a/spec/models/ci/build_report_result_spec.rb
+++ b/spec/models/ci/build_report_result_spec.rb
@@ -33,6 +33,19 @@ RSpec.describe Ci::BuildReportResult do
expect(build_report_result.errors.full_messages).to eq(["Data must be a valid json schema"])
end
end
+
+ context 'when data tests is invalid' do
+ it 'returns errors' do
+ build_report_result.data = {
+ 'tests' => {
+ 'invalid' => 'invalid'
+ }
+ }
+
+ expect(build_report_result).to be_invalid
+ expect(build_report_result.errors.full_messages).to eq(["Data must be a valid json schema"])
+ end
+ end
end
describe '#tests_name' do
diff --git a/spec/models/ci/build_runner_session_spec.rb b/spec/models/ci/build_runner_session_spec.rb
index 5e1a489ed8b..002aff25593 100644
--- a/spec/models/ci/build_runner_session_spec.rb
+++ b/spec/models/ci/build_runner_session_spec.rb
@@ -175,7 +175,7 @@ RSpec.describe Ci::BuildRunnerSession, model: true, feature_category: :continuou
end
end
- describe 'partitioning' do
+ describe 'partitioning', :ci_partitionable do
include Ci::PartitioningHelpers
let(:new_pipeline) { create(:ci_pipeline) }
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 80d6693e08e..e3e78acb7e5 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -11,10 +11,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
let_it_be(:project, reload: true) { create_default(:project, :repository, group: group) }
let_it_be(:pipeline, reload: true) do
- create_default(:ci_pipeline, project: project,
- sha: project.commit.id,
- ref: project.default_branch,
- status: 'success')
+ create_default(
+ :ci_pipeline,
+ project: project,
+ sha: project.commit.id,
+ ref: project.default_branch,
+ status: 'success'
+ )
end
let_it_be(:build, refind: true) { create(:ci_build, pipeline: pipeline) }
@@ -22,20 +25,35 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
it { is_expected.to belong_to(:runner) }
it { is_expected.to belong_to(:trigger_request) }
it { is_expected.to belong_to(:erased_by) }
+ it { is_expected.to belong_to(:pipeline).inverse_of(:builds) }
it { is_expected.to have_many(:needs).with_foreign_key(:build_id) }
- it { is_expected.to have_many(:sourced_pipelines).with_foreign_key(:source_job_id) }
- it { is_expected.to have_one(:sourced_pipeline).with_foreign_key(:source_job_id) }
+
+ it do
+ is_expected.to have_many(:sourced_pipelines).class_name('Ci::Sources::Pipeline').with_foreign_key(:source_job_id)
+ .inverse_of(:build)
+ end
+
it { is_expected.to have_many(:job_variables).with_foreign_key(:job_id) }
it { is_expected.to have_many(:report_results).with_foreign_key(:build_id) }
it { is_expected.to have_many(:pages_deployments).with_foreign_key(:ci_build_id) }
it { is_expected.to have_one(:deployment) }
- it { is_expected.to have_one(:runner_machine).through(:metadata) }
+ it { is_expected.to have_one(:runner_manager).through(:runner_manager_build) }
it { is_expected.to have_one(:runner_session).with_foreign_key(:build_id) }
it { is_expected.to have_one(:trace_metadata).with_foreign_key(:build_id) }
it { is_expected.to have_one(:runtime_metadata).with_foreign_key(:build_id) }
- it { is_expected.to have_one(:pending_state).with_foreign_key(:build_id) }
+ it { is_expected.to have_one(:pending_state).with_foreign_key(:build_id).inverse_of(:build) }
+
+ it do
+ is_expected.to have_one(:queuing_entry).class_name('Ci::PendingBuild').with_foreign_key(:build_id).inverse_of(:build)
+ end
+
+ it do
+ is_expected.to have_one(:runtime_metadata).class_name('Ci::RunningBuild').with_foreign_key(:build_id)
+ .inverse_of(:build)
+ end
+
it { is_expected.to have_many(:terraform_state_versions).inverse_of(:build).with_foreign_key(:ci_build_id) }
it { is_expected.to validate_presence_of(:ref) }
@@ -1040,7 +1058,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
context 'non public artifacts' do
- let(:build) { create(:ci_build, :artifacts, :non_public_artifacts, pipeline: pipeline) }
+ let(:build) { create(:ci_build, :artifacts, :with_private_artifacts_config, pipeline: pipeline) }
it { is_expected.to be_falsey }
end
@@ -1134,6 +1152,12 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
{ cache: [{ key: "key", paths: ["public"], policy: "pull-push" }] }
end
+ let(:options_with_fallback_keys) do
+ { cache: [
+ { key: "key", paths: ["public"], policy: "pull-push", fallback_keys: %w(key1 key2) }
+ ] }
+ end
+
subject { build.cache }
context 'when build has cache' do
@@ -1149,6 +1173,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
] }
end
+ let(:options_with_fallback_keys) do
+ { cache: [
+ { key: "key", paths: ["public"], policy: "pull-push", fallback_keys: %w(key3 key4) },
+ { key: "key2", paths: ["public"], policy: "pull-push", fallback_keys: %w(key5 key6) }
+ ] }
+ end
+
before do
allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1)
end
@@ -1160,8 +1191,21 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
allow(build.pipeline).to receive(:protected_ref?).and_return(true)
end
- it do
- is_expected.to all(a_hash_including(key: a_string_matching(/-protected$/)))
+ context 'without the `unprotect` option' do
+ it do
+ is_expected.to all(a_hash_including(key: a_string_matching(/-protected$/)))
+ end
+
+ context 'and the caches have fallback keys' do
+ let(:options) { options_with_fallback_keys }
+
+ it do
+ is_expected.to all(a_hash_including({
+ key: a_string_matching(/-protected$/),
+ fallback_keys: array_including(a_string_matching(/-protected$/))
+ }))
+ end
+ end
end
context 'and the cache has the `unprotect` option' do
@@ -1175,6 +1219,20 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
it do
is_expected.to all(a_hash_including(key: a_string_matching(/-non_protected$/)))
end
+
+ context 'and the caches have fallback keys' do
+ let(:options) do
+ options_with_fallback_keys[:cache].each { |entry| entry[:unprotect] = true }
+ options_with_fallback_keys
+ end
+
+ it do
+ is_expected.to all(a_hash_including({
+ key: a_string_matching(/-non_protected$/),
+ fallback_keys: array_including(a_string_matching(/-non_protected$/))
+ }))
+ end
+ end
end
end
@@ -1186,6 +1244,17 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
it do
is_expected.to all(a_hash_including(key: a_string_matching(/-non_protected$/)))
end
+
+ context 'and the caches have fallback keys' do
+ let(:options) { options_with_fallback_keys }
+
+ it do
+ is_expected.to all(a_hash_including({
+ key: a_string_matching(/-non_protected$/),
+ fallback_keys: array_including(a_string_matching(/-non_protected$/))
+ }))
+ end
+ end
end
context 'when separated caches are disabled' do
@@ -1201,6 +1270,23 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
it 'is expected to have no type suffix' do
is_expected.to match([a_hash_including(key: 'key-1'), a_hash_including(key: 'key2-1')])
end
+
+ context 'and the caches have fallback keys' do
+ let(:options) { options_with_fallback_keys }
+
+ it do
+ is_expected.to match([
+ a_hash_including({
+ key: 'key-1',
+ fallback_keys: %w(key3-1 key4-1)
+ }),
+ a_hash_including({
+ key: 'key2-1',
+ fallback_keys: %w(key5-1 key6-1)
+ })
+ ])
+ end
+ end
end
context 'running on not protected ref' do
@@ -1211,6 +1297,23 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
it 'is expected to have no type suffix' do
is_expected.to match([a_hash_including(key: 'key-1'), a_hash_including(key: 'key2-1')])
end
+
+ context 'and the caches have fallback keys' do
+ let(:options) { options_with_fallback_keys }
+
+ it do
+ is_expected.to match([
+ a_hash_including({
+ key: 'key-1',
+ fallback_keys: %w(key3-1 key4-1)
+ }),
+ a_hash_including({
+ key: 'key2-1',
+ fallback_keys: %w(key5-1 key6-1)
+ })
+ ])
+ end
+ end
end
end
end
@@ -1221,6 +1324,17 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
it { is_expected.to be_an(Array).and all(include(key: a_string_matching(/^key-1-(?>protected|non_protected)/))) }
+
+ context 'and the cache have fallback keys' do
+ let(:options) { options_with_fallback_keys }
+
+ it do
+ is_expected.to be_an(Array).and all(include({
+ key: a_string_matching(/^key-1-(?>protected|non_protected)/),
+ fallback_keys: array_including(a_string_matching(/^key\d-1-(?>protected|non_protected)/))
+ }))
+ end
+ end
end
context 'when project does not have jobs_cache_index' do
@@ -1231,6 +1345,21 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
it do
is_expected.to eq(options[:cache].map { |entry| entry.merge(key: "#{entry[:key]}-non_protected") })
end
+
+ context 'and the cache have fallback keys' do
+ let(:options) { options_with_fallback_keys }
+
+ it do
+ is_expected.to eq(
+ options[:cache].map do |entry|
+ entry[:key] = "#{entry[:key]}-non_protected"
+ entry[:fallback_keys].map! { |key| "#{key}-non_protected" }
+
+ entry
+ end
+ )
+ end
+ end
end
end
@@ -1243,6 +1372,29 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
end
+ describe '#fallback_cache_keys_defined?' do
+ subject { build }
+
+ it 'returns false when fallback keys are not defined' do
+ expect(subject.fallback_cache_keys_defined?).to be false
+ end
+
+ context "with fallbacks keys" do
+ before do
+ allow(build).to receive(:options).and_return({
+ cache: [{
+ key: "key1",
+ fallback_keys: %w(key2)
+ }]
+ })
+ end
+
+ it 'returns true when fallback keys are defined' do
+ expect(subject.fallback_cache_keys_defined?).to be true
+ end
+ end
+ end
+
describe '#triggered_by?' do
subject { build.triggered_by?(user) }
@@ -1422,11 +1574,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
let(:build) { create(:ci_build, trait, pipeline: pipeline) }
let(:event) { state }
- context "when transitioning to #{params[:state]}" do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
+ context "when transitioning to #{params[:state]}", :saas do
it 'increments build_completed_report_type metric' do
expect(
::Gitlab::Ci::Artifacts::Metrics
@@ -1463,7 +1611,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
ActiveRecord::QueryRecorder.new { subject }
end
- index_for_build = recorded.log.index { |l| l.include?("UPDATE \"ci_builds\"") }
+ index_for_build = recorded.log.index { |l| l.include?("UPDATE #{described_class.quoted_table_name}") }
index_for_deployment = recorded.log.index { |l| l.include?("UPDATE \"deployments\"") }
expect(index_for_build).to be < index_for_deployment
@@ -1688,10 +1836,12 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
context 'when environment uses $CI_COMMIT_REF_NAME' do
let(:build) do
- create(:ci_build,
- ref: 'master',
- environment: 'review/$CI_COMMIT_REF_NAME',
- pipeline: pipeline)
+ create(
+ :ci_build,
+ ref: 'master',
+ environment: 'review/$CI_COMMIT_REF_NAME',
+ pipeline: pipeline
+ )
end
it { is_expected.to eq('review/master') }
@@ -1699,10 +1849,12 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
context 'when environment uses yaml_variables containing symbol keys' do
let(:build) do
- create(:ci_build,
- yaml_variables: [{ key: :APP_HOST, value: 'host' }],
- environment: 'review/$APP_HOST',
- pipeline: pipeline)
+ create(
+ :ci_build,
+ yaml_variables: [{ key: :APP_HOST, value: 'host' }],
+ environment: 'review/$APP_HOST',
+ pipeline: pipeline
+ )
end
it 'returns an expanded environment name with a list of variables' do
@@ -1724,12 +1876,26 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
context 'when using persisted variables' do
let(:build) do
- create(:ci_build, environment: 'review/x$CI_BUILD_ID', pipeline: pipeline)
+ create(:ci_build, environment: 'review/x$CI_JOB_ID', pipeline: pipeline)
end
it { is_expected.to eq('review/x') }
end
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ context 'when using persisted variables' do
+ let(:build) do
+ create(:ci_build, environment: 'review/x$CI_BUILD_ID', pipeline: pipeline)
+ end
+
+ it { is_expected.to eq('review/x') }
+ end
+ end
+
context 'when environment name uses a nested variable' do
let(:yaml_variables) do
[
@@ -1738,11 +1904,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
let(:build) do
- create(:ci_build,
- ref: 'master',
- yaml_variables: yaml_variables,
- environment: 'review/$ENVIRONMENT_NAME',
- pipeline: pipeline)
+ create(
+ :ci_build,
+ ref: 'master',
+ yaml_variables: yaml_variables,
+ environment: 'review/$ENVIRONMENT_NAME',
+ pipeline: pipeline
+ )
end
it { is_expected.to eq('review/master') }
@@ -1926,62 +2094,6 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
end
- describe '#failed_but_allowed?' do
- subject { build.failed_but_allowed? }
-
- context 'when build is not allowed to fail' do
- before do
- build.allow_failure = false
- end
-
- context 'and build.status is success' do
- before do
- build.status = 'success'
- end
-
- it { is_expected.to be_falsey }
- end
-
- context 'and build.status is failed' do
- before do
- build.status = 'failed'
- end
-
- it { is_expected.to be_falsey }
- end
- end
-
- context 'when build is allowed to fail' do
- before do
- build.allow_failure = true
- end
-
- context 'and build.status is success' do
- before do
- build.status = 'success'
- end
-
- it { is_expected.to be_falsey }
- end
-
- context 'and build status is failed' do
- before do
- build.status = 'failed'
- end
-
- it { is_expected.to be_truthy }
- end
-
- context 'when build is a manual action' do
- before do
- build.status = 'manual'
- end
-
- it { is_expected.to be_falsey }
- end
- end
- end
-
describe 'flags' do
describe '#cancelable?' do
subject { build }
@@ -2058,14 +2170,14 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
end
- describe '#runner_machine' do
+ describe '#runner_manager' do
let_it_be(:runner) { create(:ci_runner) }
- let_it_be(:runner_machine) { create(:ci_runner_machine, runner: runner) }
- let_it_be(:build) { create(:ci_build, runner_machine: runner_machine) }
+ let_it_be(:runner_manager) { create(:ci_runner_machine, runner: runner) }
+ let_it_be(:build) { create(:ci_build, runner_manager: runner_manager) }
- subject(:build_runner_machine) { described_class.find(build.id).runner_machine }
+ subject(:build_runner_manager) { described_class.find(build.id).runner_manager }
- it { is_expected.to eq(runner_machine) }
+ it { is_expected.to eq(runner_manager) }
end
describe '#tag_list' do
@@ -2130,8 +2242,14 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
describe 'build auto retry feature' do
context 'with deployment job' do
let(:build) do
- create(:ci_build, :deploy_to_production, :with_deployment,
- user: user, pipeline: pipeline, project: project)
+ create(
+ :ci_build,
+ :deploy_to_production,
+ :with_deployment,
+ user: user,
+ pipeline: pipeline,
+ project: project
+ )
end
before do
@@ -2762,6 +2880,89 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
{ key: 'CI_JOB_URL', value: project.web_url + "/-/jobs/#{build.id}", public: true, masked: false },
{ key: 'CI_JOB_TOKEN', value: 'my-token', public: false, masked: true },
{ key: 'CI_JOB_STARTED_AT', value: build.started_at&.iso8601, public: true, masked: false },
+ { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true, masked: false },
+ { key: 'CI_REGISTRY_PASSWORD', value: 'my-token', public: false, masked: true },
+ { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false, masked: false },
+ { key: 'CI_DEPENDENCY_PROXY_USER', value: 'gitlab-ci-token', public: true, masked: false },
+ { key: 'CI_DEPENDENCY_PROXY_PASSWORD', value: 'my-token', public: false, masked: true },
+ { key: 'CI_JOB_JWT', value: 'ci.job.jwt', public: false, masked: true },
+ { key: 'CI_JOB_JWT_V1', value: 'ci.job.jwt', public: false, masked: true },
+ { key: 'CI_JOB_JWT_V2', value: 'ci.job.jwtv2', public: false, masked: true },
+ { key: 'CI_JOB_NAME', value: 'test', public: true, masked: false },
+ { key: 'CI_JOB_NAME_SLUG', value: 'test', public: true, masked: false },
+ { key: 'CI_JOB_STAGE', value: 'test', public: true, masked: false },
+ { key: 'CI_NODE_TOTAL', value: '1', public: true, masked: false },
+ { key: 'CI', value: 'true', public: true, masked: false },
+ { key: 'GITLAB_CI', value: 'true', public: true, masked: false },
+ { key: 'CI_SERVER_URL', value: Gitlab.config.gitlab.url, public: true, masked: false },
+ { key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host, public: true, masked: false },
+ { key: 'CI_SERVER_PORT', value: Gitlab.config.gitlab.port.to_s, public: true, masked: false },
+ { key: 'CI_SERVER_PROTOCOL', value: Gitlab.config.gitlab.protocol, public: true, masked: false },
+ { key: 'CI_SERVER_SHELL_SSH_HOST', value: Gitlab.config.gitlab_shell.ssh_host.to_s, public: true, masked: false },
+ { key: 'CI_SERVER_SHELL_SSH_PORT', value: Gitlab.config.gitlab_shell.ssh_port.to_s, public: true, masked: false },
+ { key: 'CI_SERVER_NAME', value: 'GitLab', public: true, masked: false },
+ { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true, masked: false },
+ { key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s, public: true, masked: false },
+ { key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s, public: true, masked: false },
+ { key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s, public: true, masked: false },
+ { key: 'CI_SERVER_REVISION', value: Gitlab.revision, public: true, masked: false },
+ { key: 'GITLAB_FEATURES', value: project.licensed_features.join(','), public: true, masked: false },
+ { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true, masked: false },
+ { key: 'CI_PROJECT_NAME', value: project.path, public: true, masked: false },
+ { key: 'CI_PROJECT_TITLE', value: project.title, public: true, masked: false },
+ { key: 'CI_PROJECT_DESCRIPTION', value: project.description, public: true, masked: false },
+ { key: 'CI_PROJECT_PATH', value: project.full_path, public: true, masked: false },
+ { key: 'CI_PROJECT_PATH_SLUG', value: project.full_path_slug, public: true, masked: false },
+ { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true, masked: false },
+ { key: 'CI_PROJECT_NAMESPACE_ID', value: project.namespace.id.to_s, public: true, masked: false },
+ { key: 'CI_PROJECT_ROOT_NAMESPACE', value: project.namespace.root_ancestor.path, public: true, masked: false },
+ { key: 'CI_PROJECT_URL', value: project.web_url, public: true, masked: false },
+ { key: 'CI_PROJECT_VISIBILITY', value: 'private', public: true, masked: false },
+ { key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: project.repository_languages.map(&:name).join(',').downcase, public: true, masked: false },
+ { key: 'CI_PROJECT_CLASSIFICATION_LABEL', value: project.external_authorization_classification_label, public: true, masked: false },
+ { key: 'CI_DEFAULT_BRANCH', value: project.default_branch, public: true, masked: false },
+ { key: 'CI_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false },
+ { key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host, public: true, masked: false },
+ { key: 'CI_PAGES_URL', value: project.pages_url, public: true, masked: false },
+ { key: 'CI_DEPENDENCY_PROXY_SERVER', value: Gitlab.host_with_port, public: true, masked: false },
+ { key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX',
+ 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_API_GRAPHQL_URL', value: 'http://localhost/api/graphql', public: true, masked: false },
+ { key: 'CI_TEMPLATE_REGISTRY_HOST', value: template_registry_host, public: true, masked: false },
+ { key: 'CI_PIPELINE_IID', value: pipeline.iid.to_s, public: true, masked: false },
+ { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true, masked: false },
+ { key: 'CI_PIPELINE_CREATED_AT', value: pipeline.created_at.iso8601, public: true, masked: false },
+ { key: 'CI_COMMIT_SHA', value: build.sha, public: true, masked: false },
+ { key: 'CI_COMMIT_SHORT_SHA', value: build.short_sha, public: true, masked: false },
+ { key: 'CI_COMMIT_BEFORE_SHA', value: build.before_sha, public: true, masked: false },
+ { key: 'CI_COMMIT_REF_NAME', value: build.ref, public: true, masked: false },
+ { key: 'CI_COMMIT_REF_SLUG', value: build.ref_slug, public: true, masked: false },
+ { key: 'CI_COMMIT_BRANCH', value: build.ref, public: true, masked: false },
+ { key: 'CI_COMMIT_MESSAGE', value: pipeline.git_commit_message, public: true, masked: false },
+ { key: 'CI_COMMIT_TITLE', value: pipeline.git_commit_title, public: true, masked: false },
+ { key: 'CI_COMMIT_DESCRIPTION', value: pipeline.git_commit_description, public: true, masked: false },
+ { key: 'CI_COMMIT_REF_PROTECTED', value: (!!pipeline.protected_ref?).to_s, public: true, masked: false },
+ { key: 'CI_COMMIT_TIMESTAMP', value: pipeline.git_commit_timestamp, public: true, masked: false },
+ { key: 'CI_COMMIT_AUTHOR', value: pipeline.git_author_full_text, public: true, masked: false }
+ ]
+ end
+
+ # Remove this definition when FF `ci_remove_legacy_predefined_variables` is removed
+ let(:predefined_with_legacy_variables) do
+ [
+ { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true, masked: false },
+ { key: 'CI_PIPELINE_URL', value: project.web_url + "/-/pipelines/#{pipeline.id}", public: true, masked: false },
+ { key: 'CI_JOB_ID', value: build.id.to_s, public: true, masked: false },
+ { key: 'CI_JOB_URL', value: project.web_url + "/-/jobs/#{build.id}", public: true, masked: false },
+ { key: 'CI_JOB_TOKEN', value: 'my-token', public: false, masked: true },
+ { key: 'CI_JOB_STARTED_AT', value: build.started_at&.iso8601, public: true, masked: false },
{ key: 'CI_BUILD_ID', value: build.id.to_s, public: true, masked: false },
{ key: 'CI_BUILD_TOKEN', value: 'my-token', public: false, masked: true },
{ key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true, masked: false },
@@ -2784,6 +2985,8 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
{ key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host, public: true, masked: false },
{ key: 'CI_SERVER_PORT', value: Gitlab.config.gitlab.port.to_s, public: true, masked: false },
{ key: 'CI_SERVER_PROTOCOL', value: Gitlab.config.gitlab.protocol, public: true, masked: false },
+ { key: 'CI_SERVER_SHELL_SSH_HOST', value: Gitlab.config.gitlab_shell.ssh_host.to_s, public: true, masked: false },
+ { key: 'CI_SERVER_SHELL_SSH_PORT', value: Gitlab.config.gitlab_shell.ssh_port.to_s, public: true, masked: false },
{ key: 'CI_SERVER_NAME', value: 'GitLab', public: true, masked: false },
{ key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true, masked: false },
{ key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s, public: true, masked: false },
@@ -2818,6 +3021,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
public: true,
masked: false },
{ key: 'CI_API_V4_URL', value: 'http://localhost/api/v4', public: true, masked: false },
+ { key: 'CI_API_GRAPHQL_URL', value: 'http://localhost/api/graphql', public: true, masked: false },
{ key: 'CI_TEMPLATE_REGISTRY_HOST', value: template_registry_host, 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 },
@@ -2851,6 +3055,14 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
it { is_expected.to be_instance_of(Gitlab::Ci::Variables::Collection) }
it { expect(subject.to_runner_variables).to eq(predefined_variables) }
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ it { expect(subject.to_runner_variables).to eq(predefined_with_legacy_variables) }
+ end
+
it 'excludes variables that require an environment or user' do
environment_based_variables_collection = subject.filter do |variable|
%w[
@@ -2877,14 +3089,6 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
end
- context 'when the opt_in_jwt project setting is true' do
- it 'does not include the JWT variables' do
- project.ci_cd_settings.update!(opt_in_jwt: true)
-
- expect(subject.pluck(:key)).not_to include('CI_JOB_JWT', 'CI_JOB_JWT_V1', 'CI_JOB_JWT_V2')
- end
- end
-
describe 'variables ordering' do
context 'when variables hierarchy is stubbed' do
let(:build_pre_var) { { key: 'build', value: 'value', public: true, masked: false } }
@@ -2941,16 +3145,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
before do
- create(:environment, project: build.project,
- name: 'staging')
+ create(:environment, project: build.project, name: 'staging')
- build.yaml_variables = [{ key: 'YAML_VARIABLE',
- value: 'var',
- public: true }]
+ build.yaml_variables = [{ key: 'YAML_VARIABLE', value: 'var', public: true }]
build.environment = 'staging'
# CI_ENVIRONMENT_NAME is set in predefined_variables when job environment is provided
- predefined_variables.insert(20, { key: 'CI_ENVIRONMENT_NAME', value: 'staging', public: true, masked: false })
+ predefined_variables.insert(18, { key: 'CI_ENVIRONMENT_NAME', value: 'staging', public: true, masked: false })
end
it 'matches explicit variables ordering' do
@@ -3003,6 +3204,97 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
end
end
+
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ context 'when build has environment and user-provided variables' do
+ let(:expected_variables) do
+ predefined_with_legacy_variables.map { |variable| variable.fetch(:key) } +
+ %w[YAML_VARIABLE CI_ENVIRONMENT_NAME CI_ENVIRONMENT_SLUG
+ CI_ENVIRONMENT_ACTION CI_ENVIRONMENT_TIER CI_ENVIRONMENT_URL]
+ end
+
+ before do
+ create(:environment, project: build.project, name: 'staging')
+
+ build.yaml_variables = [{ key: 'YAML_VARIABLE', value: 'var', public: true }]
+ build.environment = 'staging'
+
+ # CI_ENVIRONMENT_NAME is set in predefined_variables when job environment is provided
+ predefined_with_legacy_variables.insert(20, { key: 'CI_ENVIRONMENT_NAME', value: 'staging', public: true, masked: false })
+ end
+
+ it 'matches explicit variables ordering' do
+ received_variables = subject.map { |variable| variable[:key] }
+
+ expect(received_variables).to eq expected_variables
+ end
+
+ describe 'CI_ENVIRONMENT_ACTION' do
+ let(:enviroment_action_variable) { subject.find { |variable| variable[:key] == 'CI_ENVIRONMENT_ACTION' } }
+
+ shared_examples 'defaults value' do
+ it 'value matches start' do
+ expect(enviroment_action_variable[:value]).to eq('start')
+ end
+ end
+
+ it_behaves_like 'defaults value'
+
+ context 'when options is set' do
+ before do
+ build.update!(options: options)
+ end
+
+ context 'when options is empty' do
+ let(:options) { {} }
+
+ it_behaves_like 'defaults value'
+ end
+
+ context 'when options is nil' do
+ let(:options) { nil }
+
+ it_behaves_like 'defaults value'
+ end
+
+ context 'when options environment is specified' do
+ let(:options) { { environment: {} } }
+
+ it_behaves_like 'defaults value'
+ end
+
+ context 'when options environment action specified' do
+ let(:options) { { environment: { action: 'stop' } } }
+
+ it 'matches the specified action' do
+ expect(enviroment_action_variable[:value]).to eq('stop')
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ context 'when the build has ID tokens' do
+ before do
+ build.update!(
+ id_tokens: { 'TEST_ID_TOKEN' => { 'aud' => 'https://client.test' } }
+ )
+ end
+
+ it 'includes the tokens and excludes the predefined JWT variables' do
+ runner_vars = subject.to_runner_variables.pluck(:key)
+
+ expect(runner_vars).to include('TEST_ID_TOKEN')
+ expect(runner_vars).not_to include('CI_JOB_JWT')
+ expect(runner_vars).not_to include('CI_JOB_JWT_V1')
+ expect(runner_vars).not_to include('CI_JOB_JWT_V2')
+ end
end
end
@@ -3046,12 +3338,14 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
let!(:environment) do
- create(:environment,
- project: build.project,
- name: 'production',
- slug: 'prod-slug',
- tier: 'production',
- external_url: '')
+ create(
+ :environment,
+ project: build.project,
+ name: 'production',
+ slug: 'prod-slug',
+ tier: 'production',
+ external_url: ''
+ )
end
before do
@@ -3184,10 +3478,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
let(:tag_message) { project.repository.tags.first.message }
let!(:pipeline) do
- create(:ci_pipeline, project: project,
- sha: project.commit.id,
- ref: tag_name,
- status: 'success')
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: project.commit.id,
+ ref: tag_name,
+ status: 'success'
+ )
end
let!(:build) { create(:ci_build, pipeline: pipeline, ref: tag_name) }
@@ -3218,8 +3515,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
before do
- create(:ci_variable,
- ci_variable.slice(:key, :value).merge(project: project))
+ create(:ci_variable, ci_variable.slice(:key, :value).merge(project: project))
end
it { is_expected.to include(ci_variable) }
@@ -3233,9 +3529,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
before do
- create(:ci_variable,
- :protected,
- protected_variable.slice(:key, :value).merge(project: project))
+ create(:ci_variable, :protected, protected_variable.slice(:key, :value).merge(project: project))
end
context 'when the branch is protected' do
@@ -3265,8 +3559,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
before do
- create(:ci_group_variable,
- ci_variable.slice(:key, :value).merge(group: group))
+ create(:ci_group_variable, ci_variable.slice(:key, :value).merge(group: group))
end
it { is_expected.to include(ci_variable) }
@@ -3280,9 +3573,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
before do
- create(:ci_group_variable,
- :protected,
- protected_variable.slice(:key, :value).merge(group: group))
+ create(:ci_group_variable, :protected, protected_variable.slice(:key, :value).merge(group: group))
end
context 'when the branch is protected' do
@@ -3335,9 +3626,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) }
let!(:pipeline_schedule_variable) do
- create(:ci_pipeline_schedule_variable,
- key: 'SCHEDULE_VARIABLE_KEY',
- pipeline_schedule: pipeline_schedule)
+ create(:ci_pipeline_schedule_variable, key: 'SCHEDULE_VARIABLE_KEY', pipeline_schedule: pipeline_schedule)
end
before do
@@ -3352,10 +3641,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
let_it_be_with_reload(:project) { create(:project, :public, :repository, group: group) }
let_it_be_with_reload(:pipeline) do
- create(:ci_pipeline, project: project,
- sha: project.commit.id,
- ref: project.default_branch,
- status: 'success')
+ create(:ci_pipeline, project: project, sha: project.commit.id, ref: project.default_branch, status: 'success')
end
let_it_be_with_refind(:build) { create(:ci_build, pipeline: pipeline) }
@@ -3600,7 +3886,8 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
[
{ key: 'APP_STORE_CONNECT_API_KEY_ISSUER_ID', value: apple_app_store_integration.app_store_issuer_id, masked: true, public: false },
{ key: 'APP_STORE_CONNECT_API_KEY_KEY', value: Base64.encode64(apple_app_store_integration.app_store_private_key), masked: true, public: false },
- { key: 'APP_STORE_CONNECT_API_KEY_KEY_ID', value: apple_app_store_integration.app_store_key_id, masked: true, public: false }
+ { key: 'APP_STORE_CONNECT_API_KEY_KEY_ID', value: apple_app_store_integration.app_store_key_id, masked: true, public: false },
+ { key: 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64', value: "true", masked: false, public: false }
]
end
@@ -3626,6 +3913,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_ISSUER_ID' }).to be_nil
expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_KEY' }).to be_nil
expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_KEY_ID' }).to be_nil
+ expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64' }).to be_nil
end
end
end
@@ -3635,6 +3923,47 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_ISSUER_ID' }).to be_nil
expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_KEY' }).to be_nil
expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_KEY_ID' }).to be_nil
+ expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64' }).to be_nil
+ end
+ end
+ end
+
+ context 'for the google_play integration' do
+ let_it_be(:google_play_integration) { create(:google_play_integration) }
+
+ let(:google_play_variables) do
+ [
+ { key: 'SUPPLY_JSON_KEY_DATA', value: google_play_integration.service_account_key, masked: true, public: false }
+ ]
+ end
+
+ context 'when the google_play integration exists' do
+ context 'when a build is protected' do
+ before do
+ allow(build.pipeline).to receive(:protected_ref?).and_return(true)
+ build.project.update!(google_play_integration: google_play_integration)
+ end
+
+ it 'includes google_play variables' do
+ is_expected.to include(*google_play_variables)
+ end
+ end
+
+ context 'when a build is not protected' do
+ before do
+ allow(build.pipeline).to receive(:protected_ref?).and_return(false)
+ build.project.update!(google_play_integration: google_play_integration)
+ end
+
+ it 'does not include the google_play variable' do
+ expect(subject[:key] == 'SUPPLY_JSON_KEY_DATA').to eq(false)
+ end
+ end
+ end
+
+ context 'when the googel_play integration does not exist' do
+ it 'does not include google_play variable' do
+ expect(subject[:key] == 'SUPPLY_JSON_KEY_DATA').to eq(false)
end
end
end
@@ -3656,6 +3985,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
'ID_TOKEN_1' => { aud: 'developers' },
'ID_TOKEN_2' => { aud: 'maintainers' }
})
+ build.runner = build_stubbed(:ci_runner)
end
subject(:runner_vars) { build.variables.to_runner_variables }
@@ -3750,8 +4080,6 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
keys = %w[CI_JOB_ID
CI_JOB_URL
CI_JOB_TOKEN
- CI_BUILD_ID
- CI_BUILD_TOKEN
CI_REGISTRY_USER
CI_REGISTRY_PASSWORD
CI_REPOSITORY_URL
@@ -3763,6 +4091,30 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
expect(names).not_to include(*keys)
end
end
+
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ it 'does not return prohibited variables' do
+ keys = %w[CI_JOB_ID
+ CI_JOB_URL
+ CI_JOB_TOKEN
+ CI_BUILD_ID
+ CI_BUILD_TOKEN
+ CI_REGISTRY_USER
+ CI_REGISTRY_PASSWORD
+ CI_REPOSITORY_URL
+ CI_ENVIRONMENT_URL
+ CI_DEPLOY_USER
+ CI_DEPLOY_PASSWORD]
+
+ build.scoped_variables.map { |env| env[:key] }.tap do |names|
+ expect(names).not_to include(*keys)
+ end
+ end
+ end
end
context 'with dependency variables' do
@@ -5720,9 +6072,11 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
describe '#runtime_hooks' do
let(:build1) do
- FactoryBot.build(:ci_build,
- options: { hooks: { pre_get_sources_script: ["echo 'hello pre_get_sources_script'"] } },
- pipeline: pipeline)
+ FactoryBot.build(
+ :ci_build,
+ options: { hooks: { pre_get_sources_script: ["echo 'hello pre_get_sources_script'"] } },
+ pipeline: pipeline
+ )
end
subject(:runtime_hooks) { build1.runtime_hooks }
@@ -5784,15 +6138,9 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
expect(build.token).to be_nil
expect(build.changes).to be_empty
end
-
- it 'does not remove the token when FF is disabled' do
- stub_feature_flags(remove_job_token_on_completion: false)
-
- expect { build.remove_token! }.not_to change(build, :token)
- end
end
- describe 'metadata partitioning', :ci_partitioning do
+ describe 'metadata partitioning', :ci_partitionable do
let(:pipeline) { create(:ci_pipeline, project: project, partition_id: ci_testing_partition_id) }
let(:build) do
@@ -5905,4 +6253,31 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def
end
end
end
+
+ describe 'token format for builds transiting into pending' do
+ let(:partition_id) { 100 }
+ let(:ci_build) { described_class.new(partition_id: partition_id) }
+
+ context 'when build is initialized without a token and transits to pending' do
+ let(:partition_id_prefix_in_16_bit_encode) { partition_id.to_s(16) + '_' }
+
+ it 'generates a token' do
+ expect { ci_build.enqueue }
+ .to change { ci_build.token }.from(nil).to(a_string_starting_with(partition_id_prefix_in_16_bit_encode))
+ end
+ end
+
+ context 'when build is initialized with a token and transits to pending' do
+ let(:token) { 'an_existing_secret_token' }
+
+ before do
+ ci_build.set_token(token)
+ end
+
+ it 'does not change the existing token' do
+ expect { ci_build.enqueue }
+ .not_to change { ci_build.token }.from(token)
+ end
+ end
+ end
end
diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb
index ac0a18a176d..355905cdabd 100644
--- a/spec/models/ci/build_trace_chunk_spec.rb
+++ b/spec/models/ci/build_trace_chunk_spec.rb
@@ -20,6 +20,12 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state, :clean_git
stub_artifacts_object_storage
end
+ describe 'associations' do
+ it do
+ is_expected.to belong_to(:build).class_name('Ci::Build').with_foreign_key(:build_id).inverse_of(:trace_chunks)
+ end
+ end
+
it_behaves_like 'having unique enum values'
def redis_instance
@@ -633,8 +639,7 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state, :clean_git
build_trace_chunk.checksum = '12345'
expect { build_trace_chunk.persist_data! }
- .to raise_error(described_class::FailedToPersistDataError,
- /Modifed build trace chunk detected/)
+ .to raise_error(described_class::FailedToPersistDataError, /Modifed build trace chunk detected/)
end
end
diff --git a/spec/models/ci/build_trace_metadata_spec.rb b/spec/models/ci/build_trace_metadata_spec.rb
index 2ab300e4054..866d94b4cbe 100644
--- a/spec/models/ci/build_trace_metadata_spec.rb
+++ b/spec/models/ci/build_trace_metadata_spec.rb
@@ -159,7 +159,7 @@ RSpec.describe Ci::BuildTraceMetadata, feature_category: :continuous_integration
end
end
- describe 'partitioning' do
+ describe 'partitioning', :ci_partitionable do
include Ci::PartitioningHelpers
let_it_be(:pipeline) { create(:ci_pipeline) }
diff --git a/spec/models/ci/build_trace_spec.rb b/spec/models/ci/build_trace_spec.rb
index 907b49dc180..30e4ef760d7 100644
--- a/spec/models/ci/build_trace_spec.rb
+++ b/spec/models/ci/build_trace_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::BuildTrace do
+RSpec.describe Ci::BuildTrace, feature_category: :continuous_integration do
let(:build) { build_stubbed(:ci_build) }
let(:state) { nil }
let(:data) { StringIO.new('the-stream') }
@@ -13,7 +13,7 @@ RSpec.describe Ci::BuildTrace do
subject { described_class.new(build: build, stream: stream, state: state) }
- shared_examples 'delegates methods' do
+ describe 'delegated methods' do
it { is_expected.to delegate_method(:state).to(:trace) }
it { is_expected.to delegate_method(:append).to(:trace) }
it { is_expected.to delegate_method(:truncated).to(:trace) }
@@ -25,8 +25,6 @@ RSpec.describe Ci::BuildTrace do
it { is_expected.to delegate_method(:complete?).to(:build).with_prefix }
end
- it_behaves_like 'delegates methods'
-
it 'returns formatted trace' do
expect(subject.lines).to eq(
[
diff --git a/spec/models/ci/catalog/listing_spec.rb b/spec/models/ci/catalog/listing_spec.rb
new file mode 100644
index 00000000000..93d70a3f63e
--- /dev/null
+++ b/spec/models/ci/catalog/listing_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::Catalog::Listing, feature_category: :pipeline_composition do
+ let_it_be(:namespace) { create(:group) }
+ let_it_be(:project_1) { create(:project, namespace: namespace) }
+ let_it_be(:project_2) { create(:project, namespace: namespace) }
+ let_it_be(:project_3) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ let(:list) { described_class.new(namespace, user) }
+
+ describe '#new' do
+ context 'when namespace is not a root namespace' do
+ let(:namespace) { create(:group, :nested) }
+
+ it 'raises an exception' do
+ expect { list }.to raise_error(ArgumentError, 'Namespace is not a root namespace')
+ end
+ end
+ end
+
+ describe '#resources' do
+ subject(:resources) { list.resources }
+
+ context 'when the user has access to all projects in the namespace' do
+ before do
+ namespace.add_developer(user)
+ end
+
+ context 'when the namespace has no catalog resources' do
+ it { is_expected.to be_empty }
+ end
+
+ context 'when the namespace has catalog resources' do
+ let!(:resource) { create(:catalog_resource, project: project_1) }
+ let!(:other_namespace_resource) { create(:catalog_resource, project: project_3) }
+
+ it 'contains only catalog resources for projects in that namespace' do
+ is_expected.to contain_exactly(resource)
+ end
+ end
+ end
+
+ context 'when the user only has access to some projects in the namespace' do
+ let!(:resource_1) { create(:catalog_resource, project: project_1) }
+ let!(:resource_2) { create(:catalog_resource, project: project_2) }
+
+ before do
+ project_1.add_developer(user)
+ project_2.add_guest(user)
+ end
+
+ it 'only returns catalog resources for projects the user has access to' do
+ is_expected.to contain_exactly(resource_1)
+ end
+ end
+
+ context 'when the user does not have access to the namespace' do
+ let!(:resource) { create(:catalog_resource, project: project_1) }
+
+ it { is_expected.to be_empty }
+ end
+ end
+end
diff --git a/spec/models/ci/catalog/resource_spec.rb b/spec/models/ci/catalog/resource_spec.rb
new file mode 100644
index 00000000000..a239bbad857
--- /dev/null
+++ b/spec/models/ci/catalog/resource_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::Catalog::Resource, feature_category: :pipeline_composition do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:resource) { create(:catalog_resource, project: project) }
+
+ let_it_be(:releases) do
+ [
+ create(:release, project: project, released_at: Time.zone.now - 2.days),
+ create(:release, project: project, released_at: Time.zone.now - 1.day),
+ create(:release, project: project, released_at: Time.zone.now)
+ ]
+ end
+
+ it { is_expected.to belong_to(:project) }
+
+ it { is_expected.to delegate_method(:avatar_path).to(:project) }
+ it { is_expected.to delegate_method(:description).to(:project) }
+ it { is_expected.to delegate_method(:name).to(:project) }
+
+ describe '.for_projects' do
+ it 'returns catalog resources for the given project IDs' do
+ resources_for_projects = described_class.for_projects(project.id)
+
+ expect(resources_for_projects).to contain_exactly(resource)
+ end
+ end
+
+ describe '#versions' do
+ it 'returns releases ordered by released date descending' do
+ expect(resource.versions).to eq(releases.reverse)
+ end
+ end
+
+ describe '#latest_version' do
+ it 'returns the latest release' do
+ expect(resource.latest_version).to eq(releases.last)
+ end
+ end
+end
diff --git a/spec/models/ci/commit_with_pipeline_spec.rb b/spec/models/ci/commit_with_pipeline_spec.rb
index 320143535e2..766e99288c0 100644
--- a/spec/models/ci/commit_with_pipeline_spec.rb
+++ b/spec/models/ci/commit_with_pipeline_spec.rb
@@ -2,9 +2,9 @@
require 'spec_helper'
-RSpec.describe Ci::CommitWithPipeline do
- let(:project) { create(:project, :public, :repository) }
- let(:commit) { described_class.new(project.commit) }
+RSpec.describe Ci::CommitWithPipeline, feature_category: :continuous_integration do
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let(:commit) { described_class.new(project.commit) }
describe '#last_pipeline' do
let!(:first_pipeline) do
@@ -27,28 +27,43 @@ RSpec.describe Ci::CommitWithPipeline do
end
describe '#lazy_latest_pipeline' do
- let(:commit_1) do
- described_class.new(Commit.new(RepoHelpers.sample_commit, project))
+ let_it_be(:other_project) { create(:project, :repository) }
+
+ let_it_be(:commits_with_pipelines) do
+ [
+ described_class.new(Commit.new(RepoHelpers.sample_commit, project)),
+ described_class.new(Commit.new(RepoHelpers.another_sample_commit, project)),
+ described_class.new(Commit.new(RepoHelpers.sample_big_commit, project)),
+ described_class.new(Commit.new(RepoHelpers.sample_commit, other_project))
+ ]
end
- let(:commit_2) do
- described_class.new(Commit.new(RepoHelpers.another_sample_commit, project))
+ let_it_be(:commits) do
+ commits_with_pipelines + [
+ described_class.new(Commit.new(RepoHelpers.another_sample_commit, other_project))
+ ]
end
- let!(:commits) { [commit_1, commit_2] }
+ before(:all) do
+ commits_with_pipelines.each do |commit|
+ create(:ci_empty_pipeline, project: commit.project, sha: commit.sha)
+ end
+ end
- it 'executes only 1 SQL query' do
+ it 'returns the correct pipelines with only 1 SQL query per project', :aggregate_failures do
recorder = ActiveRecord::QueryRecorder.new do
- # Running this first ensures we don't run one query for every
- # commit.
+ # batch commits
commits.each(&:lazy_latest_pipeline)
- # This forces the execution of the SQL queries necessary to load the
- # data.
- commits.each { |c| c.latest_pipeline.try(:id) }
+ # assert result correctness
+ commits_with_pipelines.each do |commit|
+ expect(commit.lazy_latest_pipeline.project).to eq(commit.project)
+ end
+
+ expect(commits.last.lazy_latest_pipeline&.itself).to be_nil
end
- expect(recorder.count).to eq(1)
+ expect(recorder.count).to eq(2)
end
end
diff --git a/spec/models/ci/daily_build_group_report_result_spec.rb b/spec/models/ci/daily_build_group_report_result_spec.rb
index cd55817243f..6f73d89d760 100644
--- a/spec/models/ci/daily_build_group_report_result_spec.rb
+++ b/spec/models/ci/daily_build_group_report_result_spec.rb
@@ -6,7 +6,11 @@ RSpec.describe Ci::DailyBuildGroupReportResult do
let(:daily_build_group_report_result) { build(:ci_daily_build_group_report_result) }
describe 'associations' do
- it { is_expected.to belong_to(:last_pipeline) }
+ it do
+ is_expected.to belong_to(:last_pipeline).class_name('Ci::Pipeline')
+ .with_foreign_key(:last_pipeline_id).inverse_of(:daily_build_group_report_results)
+ end
+
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:group) }
end
diff --git a/spec/models/ci/group_spec.rb b/spec/models/ci/group_spec.rb
index 4900bc792af..82e4f7ce8fc 100644
--- a/spec/models/ci/group_spec.rb
+++ b/spec/models/ci/group_spec.rb
@@ -111,11 +111,13 @@ RSpec.describe Ci::Group do
end
def create_build(type, status: 'success', **opts)
- create(type, pipeline: pipeline,
- stage: stage.name,
- status: status,
- stage_id: stage.id,
- **opts)
+ create(
+ type, pipeline: pipeline,
+ stage: stage.name,
+ status: status,
+ stage_id: stage.id,
+ **opts
+ )
end
end
end
diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb
index e73319cfcd7..a2751b9fb20 100644
--- a/spec/models/ci/group_variable_spec.rb
+++ b/spec/models/ci/group_variable_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::GroupVariable, feature_category: :pipeline_authoring do
+RSpec.describe Ci::GroupVariable, feature_category: :secrets_management do
let_it_be_with_refind(:group) { create(:group) }
subject { build(:ci_group_variable, group: group) }
@@ -65,4 +65,16 @@ RSpec.describe Ci::GroupVariable, feature_category: :pipeline_authoring do
expect(subject.audit_details).to eq(subject.key)
end
end
+
+ describe '#group_name' do
+ it "equals to the name of the group the variable belongs to" do
+ expect(subject.group_name).to eq(subject.group.name)
+ end
+ end
+
+ describe '#group_ci_cd_settings_path' do
+ it "equals to the path of the CI/CD settings of the group the variable belongs to" do
+ expect(subject.group_ci_cd_settings_path).to eq(Gitlab::Routing.url_helpers.group_settings_ci_cd_path(subject.group))
+ end
+ end
end
diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb
index e94445f17cd..a34657adf60 100644
--- a/spec/models/ci/job_artifact_spec.rb
+++ b/spec/models/ci/job_artifact_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Ci::JobArtifact, feature_category: :build_artifacts do
describe "Associations" do
it { is_expected.to belong_to(:project) }
- it { is_expected.to belong_to(:job) }
+ it { is_expected.to belong_to(:job).class_name('Ci::Build').with_foreign_key(:job_id).inverse_of(:job_artifacts) }
it { is_expected.to validate_presence_of(:job) }
it { is_expected.to validate_presence_of(:partition_id) }
end
@@ -243,6 +243,29 @@ RSpec.describe Ci::JobArtifact, feature_category: :build_artifacts do
end
end
+ describe '.non_trace' do
+ subject { described_class.non_trace }
+
+ context 'when there is only a trace job artifact' do
+ let!(:trace) { create(:ci_job_artifact, :trace) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when there is only a non-trace job artifact' do
+ let!(:junit) { create(:ci_job_artifact, :junit) }
+
+ it { is_expected.to eq([junit]) }
+ end
+
+ context 'when there are both trace and non-trace job artifacts' do
+ let!(:trace) { create(:ci_job_artifact, :trace) }
+ let!(:junit) { create(:ci_job_artifact, :junit) }
+
+ it { is_expected.to eq([junit]) }
+ end
+ end
+
describe '.downloadable' do
subject { described_class.downloadable }
@@ -460,9 +483,11 @@ RSpec.describe Ci::JobArtifact, feature_category: :build_artifacts do
context "when #{file_type} type with other formats" do
described_class.file_formats.except(file_format).values.each do |other_format|
- let(:artifact) { build(:ci_job_artifact, file_type: file_type, file_format: other_format) }
+ context "with #{other_format}" do
+ let(:artifact) { build(:ci_job_artifact, file_type: file_type, file_format: other_format) }
- it { is_expected.not_to be_valid }
+ it { is_expected.not_to be_valid }
+ end
end
end
end
diff --git a/spec/models/ci/job_token/allowlist_spec.rb b/spec/models/ci/job_token/allowlist_spec.rb
index 3a2673c7c26..3d29a637d68 100644
--- a/spec/models/ci/job_token/allowlist_spec.rb
+++ b/spec/models/ci/job_token/allowlist_spec.rb
@@ -16,10 +16,12 @@ RSpec.describe Ci::JobToken::Allowlist, feature_category: :continuous_integratio
context 'when no projects are added to the scope' do
[:inbound, :outbound].each do |d|
- let(:direction) { d }
+ context "with #{d}" do
+ let(:direction) { d }
- it 'returns the project defining the scope' do
- expect(projects).to contain_exactly(source_project)
+ it 'returns the project defining the scope' do
+ expect(projects).to contain_exactly(source_project)
+ end
end
end
end
@@ -47,15 +49,17 @@ RSpec.describe Ci::JobToken::Allowlist, feature_category: :continuous_integratio
subject { allowlist.add!(added_project, user: user) }
[:inbound, :outbound].each do |d|
- let(:direction) { d }
+ context "with #{d}" do
+ let(:direction) { d }
- it 'adds the project' do
- subject
+ it 'adds the project' do
+ subject
- expect(allowlist.projects).to contain_exactly(source_project, added_project)
- expect(subject.added_by_id).to eq(user.id)
- expect(subject.source_project_id).to eq(source_project.id)
- expect(subject.target_project_id).to eq(added_project.id)
+ expect(allowlist.projects).to contain_exactly(source_project, added_project)
+ expect(subject.added_by_id).to eq(user.id)
+ expect(subject.source_project_id).to eq(source_project.id)
+ expect(subject.target_project_id).to eq(added_project.id)
+ end
end
end
end
diff --git a/spec/models/ci/job_token/scope_spec.rb b/spec/models/ci/job_token/scope_spec.rb
index 9ae061a3702..7aa861a3dab 100644
--- a/spec/models/ci/job_token/scope_spec.rb
+++ b/spec/models/ci/job_token/scope_spec.rb
@@ -63,12 +63,14 @@ RSpec.describe Ci::JobToken::Scope, feature_category: :continuous_integration, f
subject { scope.add!(new_project, direction: direction, user: user) }
[:inbound, :outbound].each do |d|
- let(:direction) { d }
+ context "with #{d}" do
+ let(:direction) { d }
- it 'adds the project' do
- subject
+ it 'adds the project' do
+ subject
- expect(scope.send("#{direction}_projects")).to contain_exactly(current_project, new_project)
+ expect(scope.send("#{direction}_projects")).to contain_exactly(current_project, new_project)
+ end
end
end
@@ -160,13 +162,5 @@ RSpec.describe Ci::JobToken::Scope, feature_category: :continuous_integration, f
include_examples 'enforces outbound scope only'
end
-
- context 'when inbound scope flag disabled' do
- before do
- stub_feature_flags(ci_inbound_job_token_scope: false)
- end
-
- include_examples 'enforces outbound scope only'
- end
end
end
diff --git a/spec/models/ci/job_variable_spec.rb b/spec/models/ci/job_variable_spec.rb
index 0a65708160a..a56e6b6be43 100644
--- a/spec/models/ci/job_variable_spec.rb
+++ b/spec/models/ci/job_variable_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Ci::JobVariable, feature_category: :continuous_integration do
describe 'associations' do
let!(:job_variable) { create(:ci_job_variable) }
- it { is_expected.to belong_to(:job) }
+ it { is_expected.to belong_to(:job).class_name('Ci::Build').with_foreign_key(:job_id).inverse_of(:job_variables) }
it { is_expected.to validate_uniqueness_of(:key).scoped_to(:job_id) }
end
diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb
index 9b70f7c2839..c441be58edf 100644
--- a/spec/models/ci/pipeline_schedule_spec.rb
+++ b/spec/models/ci/pipeline_schedule_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Ci::PipelineSchedule, feature_category: :continuous_integration d
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:owner) }
- it { is_expected.to have_many(:pipelines) }
+ it { is_expected.to have_many(:pipelines).dependent(:nullify) }
it { is_expected.to have_many(:variables) }
it { is_expected.to respond_to(:ref) }
@@ -281,4 +281,19 @@ RSpec.describe Ci::PipelineSchedule, feature_category: :continuous_integration d
let!(:model) { create(:ci_pipeline_schedule, project: parent) }
end
end
+
+ describe 'before_destroy' do
+ let_it_be_with_reload(:pipeline_schedule) { create(:ci_pipeline_schedule, cron: ' 0 0 * * * ') }
+ let_it_be_with_reload(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) }
+
+ it 'nullifys associated pipelines' do
+ expect(pipeline_schedule).to receive(:nullify_dependent_associations_in_batches).and_call_original
+
+ result = pipeline_schedule.destroy
+
+ expect(result).to be_truthy
+ expect(pipeline.reload.pipeline_schedule).to be_nil
+ expect(described_class.find_by(id: pipeline_schedule.id)).to be_nil
+ end
+ end
end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 61422978df7..5b67cbbc86b 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -19,32 +19,67 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:user) }
- it { is_expected.to belong_to(:auto_canceled_by) }
+ it { is_expected.to belong_to(:auto_canceled_by).class_name('Ci::Pipeline').inverse_of(:auto_canceled_pipelines) }
it { is_expected.to belong_to(:pipeline_schedule) }
it { is_expected.to belong_to(:merge_request) }
it { is_expected.to belong_to(:external_pull_request) }
it { is_expected.to have_many(:statuses) }
- it { is_expected.to have_many(:trigger_requests) }
+ it { is_expected.to have_many(:trigger_requests).with_foreign_key(:commit_id).inverse_of(:pipeline) }
it { is_expected.to have_many(:variables) }
it { is_expected.to have_many(:builds) }
- it { is_expected.to have_many(:statuses_order_id_desc) }
+
+ it do
+ is_expected.to have_many(:statuses_order_id_desc)
+ .class_name('CommitStatus').with_foreign_key(:commit_id).inverse_of(:pipeline)
+ end
+
it { is_expected.to have_many(:bridges) }
it { is_expected.to have_many(:job_artifacts).through(:builds) }
it { is_expected.to have_many(:build_trace_chunks).through(:builds) }
- it { is_expected.to have_many(:auto_canceled_pipelines) }
- it { is_expected.to have_many(:auto_canceled_jobs) }
- it { is_expected.to have_many(:sourced_pipelines) }
+
it { is_expected.to have_many(:triggered_pipelines) }
it { is_expected.to have_many(:pipeline_artifacts) }
- it { is_expected.to have_one(:chat_data) }
+ it do
+ is_expected.to have_many(:failed_builds).class_name('Ci::Build')
+ .with_foreign_key(:commit_id).inverse_of(:pipeline)
+ end
+
+ it do
+ is_expected.to have_many(:cancelable_statuses).class_name('CommitStatus')
+ .with_foreign_key(:commit_id).inverse_of(:pipeline)
+ end
+
+ it do
+ is_expected.to have_many(:auto_canceled_pipelines).class_name('Ci::Pipeline')
+ .with_foreign_key(:auto_canceled_by_id).inverse_of(:auto_canceled_by)
+ end
+
+ it do
+ is_expected.to have_many(:auto_canceled_jobs).class_name('CommitStatus')
+ .with_foreign_key(:auto_canceled_by_id).inverse_of(:auto_canceled_by)
+ end
+
+ it do
+ is_expected.to have_many(:sourced_pipelines).class_name('Ci::Sources::Pipeline')
+ .with_foreign_key(:source_pipeline_id).inverse_of(:source_pipeline)
+ end
+
it { is_expected.to have_one(:source_pipeline) }
+ it { is_expected.to have_one(:chat_data) }
it { is_expected.to have_one(:triggered_by_pipeline) }
it { is_expected.to have_one(:source_job) }
it { is_expected.to have_one(:pipeline_config) }
it { is_expected.to have_one(:pipeline_metadata) }
+ it do
+ is_expected.to have_many(:daily_build_group_report_results).class_name('Ci::DailyBuildGroupReportResult')
+ .with_foreign_key(:last_pipeline_id).inverse_of(:last_pipeline)
+ end
+
+ it { is_expected.to have_many(:latest_builds_report_results).through(:latest_builds).source(:report_results) }
+
it { is_expected.to respond_to :git_author_name }
it { is_expected.to respond_to :git_author_email }
it { is_expected.to respond_to :git_author_full_text }
@@ -409,6 +444,16 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
end
end
+ describe '.preload_pipeline_metadata' do
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project, user: user, name: 'Chatops pipeline') }
+
+ it 'loads associations' do
+ result = described_class.preload_pipeline_metadata.first
+
+ expect(result.association(:pipeline_metadata).loaded?).to be(true)
+ end
+ end
+
describe '.ci_sources' do
subject { described_class.ci_sources }
@@ -462,11 +507,13 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
let!(:other_pipeline) { create(:ci_pipeline, project: project) }
before do
- create(:ci_sources_pipeline,
- source_job: create(:ci_build, pipeline: upstream_pipeline),
- source_project: project,
- pipeline: child_pipeline,
- project: project)
+ create(
+ :ci_sources_pipeline,
+ source_job: create(:ci_build, pipeline: upstream_pipeline),
+ source_project: project,
+ pipeline: child_pipeline,
+ project: project
+ )
end
it 'only returns pipelines outside pipeline family' do
@@ -485,11 +532,13 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
let!(:other_pipeline) { create(:ci_pipeline, project: project) }
before do
- create(:ci_sources_pipeline,
- source_job: create(:ci_build, pipeline: upstream_pipeline),
- source_project: project,
- pipeline: child_pipeline,
- project: project)
+ create(
+ :ci_sources_pipeline,
+ source_job: create(:ci_build, pipeline: upstream_pipeline),
+ source_project: project,
+ pipeline: child_pipeline,
+ project: project
+ )
end
it 'only returns older pipelines outside pipeline family' do
@@ -497,6 +546,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
end
end
+ describe '.order_id_desc' do
+ subject(:pipelines_ordered_by_id) { described_class.order_id_desc }
+
+ let(:older_pipeline) { create(:ci_pipeline, id: 99, project: project) }
+ let(:newest_pipeline) { create(:ci_pipeline, id: 100, project: project) }
+
+ it 'only returns the pipelines ordered by id' do
+ expect(pipelines_ordered_by_id).to eq([newest_pipeline, older_pipeline])
+ end
+ end
+
describe '.jobs_count_in_alive_pipelines' do
before do
::Ci::HasStatus::ALIVE_STATUSES.each do |status|
@@ -872,6 +932,26 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
expect(pipeline).to be_valid
end
end
+
+ context 'when source is unknown' do
+ subject(:pipeline) { create(:ci_empty_pipeline, :created) }
+
+ let(:attr) { :source }
+ let(:attr_value) { :unknown }
+
+ it_behaves_like 'having enum with nil value'
+ end
+ end
+
+ describe '#config_source' do
+ context 'when source is unknown' do
+ subject(:pipeline) { create(:ci_empty_pipeline, :created) }
+
+ let(:attr) { :config_source }
+ let(:attr_value) { :unknown_source }
+
+ it_behaves_like 'having enum with nil value'
+ end
end
describe '#block' do
@@ -1131,29 +1211,41 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
describe 'legacy stages' do
before do
- create(:commit_status, pipeline: pipeline,
- stage: 'build',
- name: 'linux',
- stage_idx: 0,
- status: 'success')
-
- create(:commit_status, pipeline: pipeline,
- stage: 'build',
- name: 'mac',
- stage_idx: 0,
- status: 'failed')
-
- create(:commit_status, pipeline: pipeline,
- stage: 'deploy',
- name: 'staging',
- stage_idx: 2,
- status: 'running')
-
- create(:commit_status, pipeline: pipeline,
- stage: 'test',
- name: 'rspec',
- stage_idx: 1,
- status: 'success')
+ create(
+ :commit_status,
+ pipeline: pipeline,
+ stage: 'build',
+ name: 'linux',
+ stage_idx: 0,
+ status: 'success'
+ )
+
+ create(
+ :commit_status,
+ pipeline: pipeline,
+ stage: 'build',
+ name: 'mac',
+ stage_idx: 0,
+ status: 'failed'
+ )
+
+ create(
+ :commit_status,
+ pipeline: pipeline,
+ stage: 'deploy',
+ name: 'staging',
+ stage_idx: 2,
+ status: 'running'
+ )
+
+ create(
+ :commit_status,
+ pipeline: pipeline,
+ stage: 'test',
+ name: 'rspec',
+ stage_idx: 1,
+ status: 'success'
+ )
end
describe '#stages_count' do
@@ -1604,8 +1696,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
before do
upstream_bridge = create(:ci_bridge, :strategy_depend, pipeline: upstream_of_upstream_pipeline)
- create(:ci_sources_pipeline, pipeline: upstream_pipeline,
- source_job: upstream_bridge)
+ create(:ci_sources_pipeline, pipeline: upstream_pipeline, source_job: upstream_bridge)
end
context 'when the downstream pipeline first fails then retries and succeeds' do
@@ -1661,13 +1752,163 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
end
end
+ describe 'merge status subscription trigger' do
+ shared_examples 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated' do
+ context 'when state transitions to running' do
+ it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.run }
+ end
+ end
+
+ context 'when state transitions to success' do
+ it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.succeed }
+ end
+ end
+
+ context 'when state transitions to failed' do
+ it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.drop }
+ end
+ end
+
+ context 'when state transitions to canceled' do
+ it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.cancel }
+ end
+ end
+
+ context 'when state transitions to skipped' do
+ it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.skip }
+ end
+ end
+ end
+
+ shared_examples 'state transition triggering GraphQL subscription mergeRequestMergeStatusUpdated' do
+ context 'when state transitions to running' do
+ it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.run }
+ end
+ end
+
+ context 'when state transitions to success' do
+ it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.succeed }
+ end
+ end
+
+ context 'when state transitions to failed' do
+ it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.drop }
+ end
+ end
+
+ context 'when state transitions to canceled' do
+ it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.cancel }
+ end
+ end
+
+ context 'when state transitions to skipped' do
+ it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do
+ let(:action) { pipeline.skip }
+ end
+ end
+
+ context 'when only_allow_merge_if_pipeline_succeeds? returns false' do
+ let(:only_allow_merge_if_pipeline_succeeds?) { false }
+
+ it_behaves_like 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated'
+ end
+
+ context 'when pipeline_trigger_merge_status feature flag is disabled' do
+ before do
+ stub_feature_flags(pipeline_trigger_merge_status: false)
+ end
+
+ it_behaves_like 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated'
+ end
+ end
+
+ context 'when pipeline has merge requests' do
+ let(:merge_request) do
+ create(
+ :merge_request,
+ :simple,
+ source_project: project,
+ target_project: project
+ )
+ end
+
+ let(:only_allow_merge_if_pipeline_succeeds?) { true }
+
+ before do
+ allow(project)
+ .to receive(:only_allow_merge_if_pipeline_succeeds?)
+ .and_return(only_allow_merge_if_pipeline_succeeds?)
+ end
+
+ context 'when for a specific merge request' do
+ let(:pipeline) do
+ create(
+ :ci_pipeline,
+ project: project,
+ merge_request: merge_request
+ )
+ end
+
+ it_behaves_like 'state transition triggering GraphQL subscription mergeRequestMergeStatusUpdated'
+
+ context 'when pipeline is a child' do
+ let(:parent_pipeline) do
+ create(
+ :ci_pipeline,
+ project: project,
+ merge_request: merge_request
+ )
+ end
+
+ let(:pipeline) do
+ create(
+ :ci_pipeline,
+ child_of: parent_pipeline,
+ merge_request: merge_request
+ )
+ end
+
+ it_behaves_like 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated'
+ end
+ end
+
+ context 'when for merge requests matching the source branch and SHA' do
+ let(:pipeline) do
+ create(
+ :ci_pipeline,
+ project: project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha
+ )
+ end
+
+ it_behaves_like 'state transition triggering GraphQL subscription mergeRequestMergeStatusUpdated'
+ end
+ end
+
+ context 'when pipeline has no merge requests' do
+ it_behaves_like 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated'
+ end
+ end
+
def create_build(name, *traits, queued_at: current, started_from: 0, **opts)
- create(:ci_build, *traits,
- name: name,
- pipeline: pipeline,
- queued_at: queued_at,
- started_at: queued_at + started_from,
- **opts)
+ create(
+ :ci_build, *traits,
+ name: name,
+ pipeline: pipeline,
+ queued_at: queued_at,
+ started_at: queued_at + started_from,
+ **opts
+ )
end
end
@@ -1715,9 +1956,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
let(:pipeline) { build(:ci_pipeline, merge_request: merge_request) }
let(:merge_request) do
- create(:merge_request, :simple,
- source_project: project,
- target_project: project)
+ create(:merge_request, :simple, source_project: project, target_project: project)
end
it 'returns false' do
@@ -1758,17 +1997,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
context 'when ref is merge request' do
let(:pipeline) do
- create(:ci_pipeline,
- source: :merge_request_event,
- merge_request: merge_request)
+ create(:ci_pipeline, source: :merge_request_event, merge_request: merge_request)
end
let(:merge_request) do
- create(:merge_request,
- source_project: project,
- source_branch: 'feature',
- target_project: project,
- target_branch: 'master')
+ create(
+ :merge_request,
+ source_project: project,
+ source_branch: 'feature',
+ target_project: project,
+ target_branch: 'master'
+ )
end
it 'returns branch ref' do
@@ -1812,35 +2051,63 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
context 'with non-empty project' do
let(:pipeline) do
- create(:ci_pipeline,
- ref: project.default_branch,
- sha: project.commit.sha)
+ create(
+ :ci_pipeline,
+ project: project,
+ ref: project.default_branch,
+ sha: project.commit.sha
+ )
end
describe '#lazy_ref_commit' do
let(:another) do
- create(:ci_pipeline,
- ref: 'feature',
- sha: project.commit('feature').sha)
+ create(
+ :ci_pipeline,
+ project: project,
+ ref: 'feature',
+ sha: project.commit('feature').sha
+ )
end
let(:unicode) do
- create(:ci_pipeline,
- ref: 'ü/unicode/multi-byte')
+ create(
+ :ci_pipeline,
+ project: project,
+ ref: 'ü/unicode/multi-byte'
+ )
+ end
+
+ let(:in_another_project) do
+ other_project = create(:project, :repository)
+ create(
+ :ci_pipeline,
+ project: other_project,
+ ref: other_project.default_branch,
+ sha: other_project.commit.sha
+ )
end
- it 'returns the latest commit for a ref lazily' do
+ it 'returns the latest commit for a ref lazily', :aggregate_failures do
expect(project.repository)
.to receive(:list_commits_by_ref_name).once
.and_call_original
+ requests_before = Gitlab::GitalyClient.get_request_count
pipeline.lazy_ref_commit
another.lazy_ref_commit
unicode.lazy_ref_commit
+ in_another_project.lazy_ref_commit
+ requests_after = Gitlab::GitalyClient.get_request_count
+
+ expect(requests_after - requests_before).to eq(0)
expect(pipeline.lazy_ref_commit.id).to eq pipeline.sha
expect(another.lazy_ref_commit.id).to eq another.sha
- expect(unicode.lazy_ref_commit).to be_nil
+ expect(unicode.lazy_ref_commit.itself).to be_nil
+ expect(in_another_project.lazy_ref_commit.id).to eq in_another_project.sha
+
+ expect(pipeline.lazy_ref_commit.repository.container).to eq project
+ expect(in_another_project.lazy_ref_commit.repository.container).to eq in_another_project.project
end
end
@@ -1969,9 +2236,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
end
let(:merge_request) do
- create(:merge_request, :simple,
- source_project: project,
- target_project: project)
+ create(:merge_request, :simple, source_project: project, target_project: project)
end
it 'returns merge request modified paths' do
@@ -1996,8 +2261,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
describe '#modified_paths_since' do
let(:project) do
- create(:project, :custom_repo,
- files: { 'file1.txt' => 'file 1' })
+ create(:project, :custom_repo, files: { 'file1.txt' => 'file 1' })
end
let(:user) { project.owner }
@@ -3270,19 +3534,23 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
let(:target_branch) { 'master' }
let!(:pipeline) do
- create(:ci_pipeline,
- source: :merge_request_event,
- project: pipeline_project,
- ref: source_branch,
- merge_request: merge_request)
+ create(
+ :ci_pipeline,
+ source: :merge_request_event,
+ project: pipeline_project,
+ ref: source_branch,
+ merge_request: merge_request
+ )
end
let(:merge_request) do
- create(:merge_request,
- source_project: pipeline_project,
- source_branch: source_branch,
- target_project: project,
- target_branch: target_branch)
+ create(
+ :merge_request,
+ source_project: pipeline_project,
+ source_branch: source_branch,
+ target_project: project,
+ target_branch: target_branch
+ )
end
it 'returns an associated merge request' do
@@ -3293,19 +3561,23 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
let(:target_branch_2) { 'merge-test' }
let!(:pipeline_2) do
- create(:ci_pipeline,
- source: :merge_request_event,
- project: pipeline_project,
- ref: source_branch,
- merge_request: merge_request_2)
+ create(
+ :ci_pipeline,
+ source: :merge_request_event,
+ project: pipeline_project,
+ ref: source_branch,
+ merge_request: merge_request_2
+ )
end
let(:merge_request_2) do
- create(:merge_request,
- source_project: pipeline_project,
- source_branch: source_branch,
- target_project: project,
- target_branch: target_branch_2)
+ create(
+ :merge_request,
+ source_project: pipeline_project,
+ source_branch: source_branch,
+ target_project: project,
+ target_branch: target_branch_2
+ )
end
it 'does not return an associated merge request' do
@@ -3701,10 +3973,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
let(:project) { create(:project, :repository, namespace: namespace) }
let(:pipeline) do
- create(:ci_pipeline,
- project: project,
- sha: project.commit('master').sha,
- user: project.first_owner)
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: project.commit('master').sha,
+ user: project.first_owner
+ )
end
before do
@@ -4488,10 +4762,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
let(:stage_name) { 'test' }
let(:stage) do
- create(:ci_stage,
- pipeline: pipeline,
- project: pipeline.project,
- name: 'test')
+ create(:ci_stage, pipeline: pipeline, project: pipeline.project, name: 'test')
end
before do
@@ -5208,11 +5479,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
describe '#cluster_agent_authorizations' do
let(:pipeline) { create(:ci_empty_pipeline, :created) }
- let(:authorization) { instance_double(Clusters::Agents::GroupAuthorization) }
+ let(:authorization) { instance_double(Clusters::Agents::Authorizations::CiAccess::GroupAuthorization) }
let(:finder) { double(execute: [authorization]) }
it 'retrieves authorization records from the finder and caches the result' do
- expect(Clusters::AgentAuthorizationsFinder).to receive(:new).once
+ expect(Clusters::Agents::Authorizations::CiAccess::Finder).to receive(:new).once
.with(pipeline.project)
.and_return(finder)
diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb
index db22d8f3a6c..34a56162dd9 100644
--- a/spec/models/ci/processable_spec.rb
+++ b/spec/models/ci/processable_spec.rb
@@ -43,11 +43,13 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do
let_it_be(:another_pipeline) { create(:ci_empty_pipeline, project: project) }
let_it_be_with_refind(:processable) do
- create(:ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags,
- :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group,
- description: 'my-job', stage: 'test', stage_id: stage.id,
- pipeline: pipeline, auto_canceled_by: another_pipeline,
- scheduled_at: 10.seconds.since)
+ create(
+ :ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags,
+ :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group,
+ description: 'my-job', stage: 'test', stage_id: stage.id,
+ pipeline: pipeline, auto_canceled_by: another_pipeline,
+ scheduled_at: 10.seconds.since
+ )
end
let_it_be(:internal_job_variable) { create(:ci_job_variable, job: processable) }
@@ -83,7 +85,7 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do
runner_id tag_taggings taggings tags trigger_request_id
user_id auto_canceled_by_id retried failure_reason
sourced_pipelines sourced_pipeline artifacts_file_store artifacts_metadata_store
- metadata runner_machine_id runner_machine runner_session trace_chunks upstream_pipeline_id
+ metadata runner_manager_build runner_manager runner_session trace_chunks upstream_pipeline_id
artifacts_file artifacts_metadata artifacts_size commands
resource resource_group_id processed security_scans author
pipeline_id report_results pending_state pages_deployments
@@ -95,8 +97,7 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do
before_all do
# Create artifacts to check that the associations are rejected when cloning
Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.each do |file_type, file_format|
- create(:ci_job_artifact, file_format,
- file_type: file_type, job: processable, expire_at: processable.artifacts_expire_at)
+ create(:ci_job_artifact, file_format, file_type: file_type, job: processable, expire_at: processable.artifacts_expire_at)
end
create(:ci_job_variable, :dotenv_source, job: processable)
@@ -193,8 +194,7 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do
context 'when it has a deployment' do
let!(:processable) do
- create(:ci_build, :with_deployment, :deploy_to_production,
- pipeline: pipeline, stage_id: stage.id, project: project)
+ create(:ci_build, :with_deployment, :deploy_to_production, pipeline: pipeline, stage_id: stage.id, project: project)
end
it 'persists the expanded environment name' do
diff --git a/spec/models/ci/ref_spec.rb b/spec/models/ci/ref_spec.rb
index ffbda4b459f..eab5a40bc30 100644
--- a/spec/models/ci/ref_spec.rb
+++ b/spec/models/ci/ref_spec.rb
@@ -105,8 +105,11 @@ RSpec.describe Ci::Ref do
context 'when pipeline is a detached merge request pipeline' do
let(:merge_request) do
- create(:merge_request, target_project: project, target_branch: 'master',
- source_project: project, source_branch: 'feature')
+ create(
+ :merge_request,
+ target_project: project, target_branch: 'master',
+ source_project: project, source_branch: 'feature'
+ )
end
let!(:pipeline) do
diff --git a/spec/models/ci/resource_group_spec.rb b/spec/models/ci/resource_group_spec.rb
index 01acf5194f0..6d518d5c874 100644
--- a/spec/models/ci/resource_group_spec.rb
+++ b/spec/models/ci/resource_group_spec.rb
@@ -118,7 +118,7 @@ RSpec.describe Ci::ResourceGroup do
let!(:resource_group) { create(:ci_resource_group, process_mode: process_mode, project: project) }
- Ci::HasStatus::STATUSES_ENUM.keys.each do |status|
+ Ci::HasStatus::STATUSES_ENUM.keys.each do |status| # rubocop:diable RSpec/UselessDynamicDefinition
let!("build_1_#{status}") { create(:ci_build, pipeline: pipeline_1, status: status, resource_group: resource_group) }
let!("build_2_#{status}") { create(:ci_build, pipeline: pipeline_2, status: status, resource_group: resource_group) }
end
@@ -165,4 +165,23 @@ RSpec.describe Ci::ResourceGroup do
end
end
end
+
+ describe '#current_processable' do
+ subject { resource_group.current_processable }
+
+ let(:build) { create(:ci_build) }
+ let(:resource_group) { create(:ci_resource_group) }
+
+ context 'when resource is retained by a build' do
+ before do
+ resource_group.assign_resource_to(build)
+ end
+
+ it { is_expected.to eq(build) }
+ end
+
+ context 'when resource is not retained by a build' do
+ it { is_expected.to be_nil }
+ end
+ end
end
diff --git a/spec/models/ci/runner_machine_spec.rb b/spec/models/ci/runner_machine_spec.rb
deleted file mode 100644
index d0979d8a485..00000000000
--- a/spec/models/ci/runner_machine_spec.rb
+++ /dev/null
@@ -1,197 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Ci::RunnerMachine, feature_category: :runner_fleet, type: :model do
- it_behaves_like 'having unique enum values'
-
- it { is_expected.to belong_to(:runner) }
- it { is_expected.to belong_to(:runner_version).with_foreign_key(:version) }
- it { is_expected.to have_many(:build_metadata) }
- it { is_expected.to have_many(:builds).through(:build_metadata) }
-
- describe 'validation' do
- it { is_expected.to validate_presence_of(:runner) }
- it { is_expected.to validate_presence_of(:system_xid) }
- it { is_expected.to validate_length_of(:system_xid).is_at_most(64) }
- it { is_expected.to validate_length_of(:version).is_at_most(2048) }
- it { is_expected.to validate_length_of(:revision).is_at_most(255) }
- it { is_expected.to validate_length_of(:platform).is_at_most(255) }
- it { is_expected.to validate_length_of(:architecture).is_at_most(255) }
- it { is_expected.to validate_length_of(:ip_address).is_at_most(1024) }
-
- context 'when runner has config' do
- it 'is valid' do
- runner_machine = build(:ci_runner_machine, config: { gpus: "all" })
-
- expect(runner_machine).to be_valid
- end
- end
-
- context 'when runner has an invalid config' do
- it 'is invalid' do
- runner_machine = build(:ci_runner_machine, config: { test: 1 })
-
- expect(runner_machine).not_to be_valid
- end
- end
- end
-
- describe '.stale', :freeze_time do
- subject { described_class.stale.ids }
-
- let!(:runner_machine1) { create(:ci_runner_machine, :stale) }
- let!(:runner_machine2) { create(:ci_runner_machine, :stale, contacted_at: nil) }
- let!(:runner_machine3) { create(:ci_runner_machine, created_at: 6.months.ago, contacted_at: Time.current) }
- let!(:runner_machine4) { create(:ci_runner_machine, created_at: 5.days.ago) }
- let!(:runner_machine5) do
- create(:ci_runner_machine, created_at: (7.days - 1.second).ago, contacted_at: (7.days - 1.second).ago)
- end
-
- it 'returns stale runner machines' do
- is_expected.to match_array([runner_machine1.id, runner_machine2.id])
- end
- end
-
- describe '#heartbeat', :freeze_time do
- let(:runner_machine) { create(:ci_runner_machine) }
- let(:executor) { 'shell' }
- let(:version) { '15.0.1' }
- let(:values) do
- {
- ip_address: '8.8.8.8',
- architecture: '18-bit',
- config: { gpus: "all" },
- executor: executor,
- version: version
- }
- end
-
- subject(:heartbeat) do
- runner_machine.heartbeat(values)
- end
-
- context 'when database was updated recently' do
- before do
- runner_machine.contacted_at = Time.current
- end
-
- it 'schedules version update' do
- expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version).once
-
- heartbeat
-
- expect(runner_machine.runner_version).to be_nil
- end
-
- it 'updates cache' do
- expect_redis_update
-
- heartbeat
- end
-
- context 'with only ip_address specified' do
- let(:values) do
- { ip_address: '1.1.1.1' }
- end
-
- it 'updates only ip_address' do
- attrs = Gitlab::Json.dump(ip_address: '1.1.1.1', contacted_at: Time.current)
-
- Gitlab::Redis::Cache.with do |redis|
- redis_key = runner_machine.send(:cache_attribute_key)
- expect(redis).to receive(:set).with(redis_key, attrs, any_args)
- end
-
- heartbeat
- end
- end
- end
-
- context 'when database was not updated recently' do
- before do
- runner_machine.contacted_at = 2.hours.ago
-
- allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version).once
- end
-
- context 'with invalid runner_machine' do
- before do
- runner_machine.runner = nil
- end
-
- it 'still updates redis cache and database' do
- expect(runner_machine).to be_invalid
-
- expect_redis_update
- does_db_update
-
- expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async)
- .with(version).once
- end
- end
-
- context 'with unchanged runner_machine version' do
- let(:runner_machine) { create(:ci_runner_machine, version: version) }
-
- it 'does not schedule ci_runner_versions update' do
- heartbeat
-
- expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async)
- end
- end
-
- it 'updates redis cache and database' do
- expect_redis_update
- does_db_update
-
- expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async)
- .with(version).once
- end
-
- Ci::Runner::EXECUTOR_NAME_TO_TYPES.each_key do |executor|
- context "with #{executor} executor" do
- let(:executor) { executor }
-
- it 'updates with expected executor type' do
- expect_redis_update
-
- heartbeat
-
- expect(runner_machine.reload.read_attribute(:executor_type)).to eq(expected_executor_type)
- end
-
- def expected_executor_type
- executor.gsub(/[+-]/, '_')
- end
- end
- end
-
- context "with an unknown executor type" do
- let(:executor) { 'some-unknown-type' }
-
- it 'updates with unknown executor type' do
- expect_redis_update
-
- heartbeat
-
- expect(runner_machine.reload.read_attribute(:executor_type)).to eq('unknown')
- end
- end
- end
-
- def expect_redis_update
- Gitlab::Redis::Cache.with do |redis|
- redis_key = runner_machine.send(:cache_attribute_key)
- expect(redis).to receive(:set).with(redis_key, anything, any_args).and_call_original
- end
- end
-
- def does_db_update
- expect { heartbeat }.to change { runner_machine.reload.read_attribute(:contacted_at) }
- .and change { runner_machine.reload.read_attribute(:architecture) }
- .and change { runner_machine.reload.read_attribute(:config) }
- .and change { runner_machine.reload.read_attribute(:executor_type) }
- end
- end
-end
diff --git a/spec/models/ci/runner_manager_build_spec.rb b/spec/models/ci/runner_manager_build_spec.rb
new file mode 100644
index 00000000000..3a381313b76
--- /dev/null
+++ b/spec/models/ci/runner_manager_build_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::RunnerManagerBuild, model: true, feature_category: :runner_fleet do
+ let_it_be(:runner) { create(:ci_runner) }
+ let_it_be(:runner_manager) { create(:ci_runner_machine, runner: runner) }
+ let_it_be(:build) { create(:ci_build, runner_manager: runner_manager) }
+
+ it { is_expected.to belong_to(:build) }
+ it { is_expected.to belong_to(:runner_manager) }
+
+ describe 'partitioning' do
+ context 'with build' do
+ let(:build) { FactoryBot.build(:ci_build, partition_id: ci_testing_partition_id) }
+ let(:runner_manager_build) { FactoryBot.build(:ci_runner_machine_build, build: build) }
+
+ it 'sets partition_id to the current partition value' do
+ expect { runner_manager_build.valid? }.to change { runner_manager_build.partition_id }
+ .to(ci_testing_partition_id)
+ end
+
+ context 'when it is already set' do
+ let(:runner_manager_build) { FactoryBot.build(:ci_runner_machine_build, partition_id: 125) }
+
+ it 'does not change the partition_id value' do
+ expect { runner_manager_build.valid? }.not_to change { runner_manager_build.partition_id }
+ end
+ end
+ end
+
+ context 'without build' do
+ let(:runner_manager_build) { FactoryBot.build(:ci_runner_machine_build, build: nil) }
+
+ it { is_expected.to validate_presence_of(:partition_id) }
+
+ it 'does not change the partition_id value' do
+ expect { runner_manager_build.valid? }.not_to change { runner_manager_build.partition_id }
+ end
+ end
+ end
+
+ describe 'ci_sliding_list partitioning' do
+ let(:connection) { described_class.connection }
+ let(:partition_manager) { Gitlab::Database::Partitioning::PartitionManager.new(described_class) }
+
+ let(:partitioning_strategy) { described_class.partitioning_strategy }
+
+ it { expect(partitioning_strategy.missing_partitions).to be_empty }
+ it { expect(partitioning_strategy.extra_partitions).to be_empty }
+ it { expect(partitioning_strategy.current_partitions).to include partitioning_strategy.initial_partition }
+ it { expect(partitioning_strategy.active_partition).to be_present }
+ end
+
+ context 'loose foreign key on p_ci_runner_manager_builds.runner_manager_id' do # rubocop:disable RSpec/ContextWording
+ it_behaves_like 'cleanup by a loose foreign key' do
+ let!(:parent) { create(:ci_runner_machine) }
+ let!(:model) { create(:ci_runner_machine_build, runner_manager: parent) }
+ end
+ end
+
+ describe '.for_build' do
+ subject(:for_build) { described_class.for_build(build_id) }
+
+ context 'with valid build_id' do
+ let(:build_id) { build.id }
+
+ it { is_expected.to contain_exactly(described_class.find_by_build_id(build_id)) }
+ end
+
+ context 'with valid build_ids' do
+ let(:build2) { create(:ci_build, runner_manager: runner_manager) }
+ let(:build_id) { [build, build2] }
+
+ it { is_expected.to eq(described_class.where(build_id: build_id)) }
+ end
+
+ context 'with non-existeng build_id' do
+ let(:build_id) { non_existing_record_id }
+
+ it { is_expected.to be_empty }
+ end
+ end
+
+ describe '.pluck_runner_manager_id_and_build_id' do
+ subject { scope.pluck_build_id_and_runner_manager_id }
+
+ context 'with default scope' do
+ let(:scope) { described_class }
+
+ it { is_expected.to eq({ build.id => runner_manager.id }) }
+ end
+
+ context 'with scope excluding build' do
+ let(:scope) { described_class.where(build_id: non_existing_record_id) }
+
+ it { is_expected.to be_empty }
+ end
+ end
+end
diff --git a/spec/models/ci/runner_manager_spec.rb b/spec/models/ci/runner_manager_spec.rb
new file mode 100644
index 00000000000..d69c9ef845e
--- /dev/null
+++ b/spec/models/ci/runner_manager_spec.rb
@@ -0,0 +1,291 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::RunnerManager, feature_category: :runner_fleet, type: :model do
+ it_behaves_like 'having unique enum values'
+
+ it_behaves_like 'it has loose foreign keys' do
+ let(:factory_name) { :ci_runner_machine }
+ end
+
+ it { is_expected.to belong_to(:runner) }
+ it { is_expected.to belong_to(:runner_version).with_foreign_key(:version) }
+ it { is_expected.to have_many(:runner_manager_builds) }
+ it { is_expected.to have_many(:builds).through(:runner_manager_builds) }
+
+ describe 'validation' do
+ it { is_expected.to validate_presence_of(:runner) }
+ it { is_expected.to validate_presence_of(:system_xid) }
+ it { is_expected.to validate_length_of(:system_xid).is_at_most(64) }
+ it { is_expected.to validate_length_of(:version).is_at_most(2048) }
+ it { is_expected.to validate_length_of(:revision).is_at_most(255) }
+ it { is_expected.to validate_length_of(:platform).is_at_most(255) }
+ it { is_expected.to validate_length_of(:architecture).is_at_most(255) }
+ it { is_expected.to validate_length_of(:ip_address).is_at_most(1024) }
+
+ context 'when runner has config' do
+ it 'is valid' do
+ runner_manager = build(:ci_runner_machine, config: { gpus: "all" })
+
+ expect(runner_manager).to be_valid
+ end
+ end
+
+ context 'when runner has an invalid config' do
+ it 'is invalid' do
+ runner_manager = build(:ci_runner_machine, config: { test: 1 })
+
+ expect(runner_manager).not_to be_valid
+ end
+ end
+ end
+
+ describe '.stale', :freeze_time do
+ subject { described_class.stale.ids }
+
+ let!(:runner_manager1) { create(:ci_runner_machine, :stale) }
+ let!(:runner_manager2) { create(:ci_runner_machine, :stale, contacted_at: nil) }
+ let!(:runner_manager3) { create(:ci_runner_machine, created_at: 6.months.ago, contacted_at: Time.current) }
+ let!(:runner_manager4) { create(:ci_runner_machine, created_at: 5.days.ago) }
+ let!(:runner_manager5) do
+ create(:ci_runner_machine, created_at: (7.days - 1.second).ago, contacted_at: (7.days - 1.second).ago)
+ end
+
+ it 'returns stale runner managers' do
+ is_expected.to match_array([runner_manager1.id, runner_manager2.id])
+ end
+ end
+
+ describe '.online_contact_time_deadline', :freeze_time do
+ subject { described_class.online_contact_time_deadline }
+
+ it { is_expected.to eq(2.hours.ago) }
+ end
+
+ describe '.stale_deadline', :freeze_time do
+ subject { described_class.stale_deadline }
+
+ it { is_expected.to eq(7.days.ago) }
+ end
+
+ describe '#status', :freeze_time do
+ let(:runner_manager) { build(:ci_runner_machine, created_at: 8.days.ago) }
+
+ subject { runner_manager.status }
+
+ context 'if never connected' do
+ before do
+ runner_manager.contacted_at = nil
+ end
+
+ it { is_expected.to eq(:stale) }
+
+ context 'if created recently' do
+ before do
+ runner_manager.created_at = 1.day.ago
+ end
+
+ it { is_expected.to eq(:never_contacted) }
+ end
+ end
+
+ context 'if contacted 1s ago' do
+ before do
+ runner_manager.contacted_at = 1.second.ago
+ end
+
+ it { is_expected.to eq(:online) }
+ end
+
+ context 'if contacted recently' do
+ before do
+ runner_manager.contacted_at = 2.hours.ago
+ end
+
+ it { is_expected.to eq(:offline) }
+ end
+
+ context 'if contacted long time ago' do
+ before do
+ runner_manager.contacted_at = 7.days.ago
+ end
+
+ it { is_expected.to eq(:stale) }
+ end
+ end
+
+ describe '#heartbeat', :freeze_time do
+ let(:runner_manager) { create(:ci_runner_machine, version: '15.0.0') }
+ let(:executor) { 'shell' }
+ let(:values) do
+ {
+ ip_address: '8.8.8.8',
+ architecture: '18-bit',
+ config: { gpus: "all" },
+ executor: executor,
+ version: version
+ }
+ end
+
+ subject(:heartbeat) do
+ runner_manager.heartbeat(values)
+ end
+
+ context 'when database was updated recently' do
+ before do
+ runner_manager.contacted_at = Time.current
+ end
+
+ context 'when version is changed' do
+ let(:version) { '15.0.1' }
+
+ before do
+ allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version)
+ end
+
+ it 'schedules version information update' do
+ heartbeat
+
+ expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).with(version).once
+ end
+
+ it 'updates cache' do
+ expect_redis_update
+
+ heartbeat
+
+ expect(runner_manager.runner_version).to be_nil
+ end
+
+ context 'when fetching runner releases is disabled' do
+ before do
+ stub_application_setting(update_runner_versions_enabled: false)
+ end
+
+ it 'does not schedule version information update' do
+ heartbeat
+
+ expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async)
+ end
+ end
+ end
+
+ context 'with only ip_address specified' do
+ let(:values) do
+ { ip_address: '1.1.1.1' }
+ end
+
+ it 'updates only ip_address' do
+ expect_redis_update(values.merge(contacted_at: Time.current))
+
+ heartbeat
+ end
+
+ context 'with new version having been cached' do
+ let(:version) { '15.0.1' }
+
+ before do
+ runner_manager.cache_attributes(version: version)
+ end
+
+ it 'does not lose cached version value' do
+ expect { heartbeat }.not_to change { runner_manager.version }.from(version)
+ end
+ end
+ end
+ end
+
+ context 'when database was not updated recently' do
+ before do
+ runner_manager.contacted_at = 2.hours.ago
+
+ allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version)
+ end
+
+ context 'when version is changed' do
+ let(:version) { '15.0.1' }
+
+ context 'with invalid runner_manager' do
+ before do
+ runner_manager.runner = nil
+ end
+
+ it 'still updates redis cache and database' do
+ expect(runner_manager).to be_invalid
+
+ expect_redis_update
+ does_db_update
+
+ expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async)
+ .with(version).once
+ end
+ end
+
+ it 'updates redis cache and database' do
+ expect_redis_update
+ does_db_update
+
+ expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async)
+ .with(version).once
+ end
+ end
+
+ context 'with unchanged runner_manager version' do
+ let(:version) { runner_manager.version }
+
+ it 'does not schedule ci_runner_versions update' do
+ heartbeat
+
+ expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async)
+ end
+
+ Ci::Runner::EXECUTOR_NAME_TO_TYPES.each_key do |executor|
+ context "with #{executor} executor" do
+ let(:executor) { executor }
+
+ it 'updates with expected executor type' do
+ expect_redis_update
+
+ heartbeat
+
+ expect(runner_manager.reload.read_attribute(:executor_type)).to eq(expected_executor_type)
+ end
+
+ def expected_executor_type
+ executor.gsub(/[+-]/, '_')
+ end
+ end
+ end
+
+ context 'with an unknown executor type' do
+ let(:executor) { 'some-unknown-type' }
+
+ it 'updates with unknown executor type' do
+ expect_redis_update
+
+ heartbeat
+
+ expect(runner_manager.reload.read_attribute(:executor_type)).to eq('unknown')
+ end
+ end
+ end
+ end
+
+ def expect_redis_update(values = anything)
+ values_json = values == anything ? anything : Gitlab::Json.dump(values)
+
+ Gitlab::Redis::Cache.with do |redis|
+ redis_key = runner_manager.send(:cache_attribute_key)
+ expect(redis).to receive(:set).with(redis_key, values_json, any_args).and_call_original
+ end
+ end
+
+ def does_db_update
+ expect { heartbeat }.to change { runner_manager.reload.read_attribute(:contacted_at) }
+ .and change { runner_manager.reload.read_attribute(:architecture) }
+ .and change { runner_manager.reload.read_attribute(:config) }
+ .and change { runner_manager.reload.read_attribute(:executor_type) }
+ end
+ end
+end
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index 01d5fe7f90b..d202fef0ed0 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -273,7 +273,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
end
end
- shared_examples '.belonging_to_parent_group_of_project' do
+ shared_examples '.belonging_to_parent_groups_of_project' do
let_it_be(:group1) { create(:group) }
let_it_be(:project1) { create(:project, group: group1) }
let_it_be(:runner1) { create(:ci_runner, :group, groups: [group1]) }
@@ -284,7 +284,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
let(:project_id) { project1.id }
- subject(:result) { described_class.belonging_to_parent_group_of_project(project_id) }
+ subject(:result) { described_class.belonging_to_parent_groups_of_project(project_id) }
it 'returns the group runner' do
expect(result).to contain_exactly(runner1)
@@ -310,7 +310,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
end
context 'when use_traversal_ids* are enabled' do
- it_behaves_like '.belonging_to_parent_group_of_project'
+ it_behaves_like '.belonging_to_parent_groups_of_project'
end
context 'when use_traversal_ids* are disabled' do
@@ -322,7 +322,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
)
end
- it_behaves_like '.belonging_to_parent_group_of_project'
+ it_behaves_like '.belonging_to_parent_groups_of_project'
end
context 'with instance runners sharing enabled' do
@@ -541,9 +541,9 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
describe '.stale', :freeze_time do
subject { described_class.stale }
- let!(:runner1) { create(:ci_runner, :instance, created_at: 4.months.ago, contacted_at: 3.months.ago + 10.seconds) }
- let!(:runner2) { create(:ci_runner, :instance, created_at: 4.months.ago, contacted_at: 3.months.ago - 1.second) }
- let!(:runner3) { create(:ci_runner, :instance, created_at: 3.months.ago - 1.second, contacted_at: nil) }
+ let!(:runner1) { create(:ci_runner, :instance, created_at: 4.months.ago, contacted_at: 3.months.ago + 1.second) }
+ let!(:runner2) { create(:ci_runner, :instance, created_at: 4.months.ago, contacted_at: 3.months.ago) }
+ let!(:runner3) { create(:ci_runner, :instance, created_at: 3.months.ago, contacted_at: nil) }
let!(:runner4) { create(:ci_runner, :instance, created_at: 2.months.ago, contacted_at: nil) }
it 'returns stale runners' do
@@ -551,7 +551,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
end
end
- describe '#stale?', :clean_gitlab_redis_cache do
+ describe '#stale?', :clean_gitlab_redis_cache, :freeze_time do
let(:runner) { build(:ci_runner, :instance) }
subject { runner.stale? }
@@ -570,11 +570,11 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
using RSpec::Parameterized::TableSyntax
where(:created_at, :contacted_at, :expected_stale?) do
- nil | nil | false
- 3.months.ago - 1.second | 3.months.ago - 0.001.seconds | true
- 3.months.ago - 1.second | 3.months.ago + 1.hour | false
- 3.months.ago - 1.second | nil | true
- 3.months.ago + 1.hour | nil | false
+ nil | nil | false
+ 3.months.ago | 3.months.ago | true
+ 3.months.ago | (3.months - 1.hour).ago | false
+ 3.months.ago | nil | true
+ (3.months - 1.hour).ago | nil | false
end
with_them do
@@ -588,9 +588,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
runner.contacted_at = contacted_at
end
- specify do
- is_expected.to eq(expected_stale?)
- end
+ it { is_expected.to eq(expected_stale?) }
end
context 'with cache value' do
@@ -599,9 +597,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
stub_redis_runner_contacted_at(contacted_at.to_s)
end
- specify do
- is_expected.to eq(expected_stale?)
- end
+ it { is_expected.to eq(expected_stale?) }
end
def stub_redis_runner_contacted_at(value)
@@ -617,7 +613,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
end
end
- describe '.online' do
+ describe '.online', :freeze_time do
subject { described_class.online }
let!(:runner1) { create(:ci_runner, :instance, contacted_at: 2.hours.ago) }
@@ -626,7 +622,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
it { is_expected.to match_array([runner2]) }
end
- describe '#online?', :clean_gitlab_redis_cache do
+ describe '#online?', :clean_gitlab_redis_cache, :freeze_time do
let(:runner) { build(:ci_runner, :instance) }
subject { runner.online? }
@@ -891,26 +887,17 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
end
end
- describe '#status' do
- let(:runner) { build(:ci_runner, :instance, created_at: 4.months.ago) }
- let(:legacy_mode) {}
+ describe '#status', :freeze_time do
+ let(:runner) { build(:ci_runner, :instance, created_at: 3.months.ago) }
- subject { runner.status(legacy_mode) }
+ subject { runner.status }
context 'never connected' do
before do
runner.contacted_at = nil
end
- context 'with legacy_mode enabled' do
- let(:legacy_mode) { '14.5' }
-
- it { is_expected.to eq(:stale) }
- end
-
- context 'with legacy_mode disabled' do
- it { is_expected.to eq(:stale) }
- end
+ it { is_expected.to eq(:stale) }
context 'created recently' do
before do
@@ -927,15 +914,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
runner.active = false
end
- context 'with legacy_mode enabled' do
- let(:legacy_mode) { '14.5' }
-
- it { is_expected.to eq(:paused) }
- end
-
- context 'with legacy_mode disabled' do
- it { is_expected.to eq(:online) }
- end
+ it { is_expected.to eq(:online) }
end
context 'contacted 1s ago' do
@@ -948,7 +927,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
context 'contacted recently' do
before do
- runner.contacted_at = (3.months - 1.hour).ago
+ runner.contacted_at = (3.months - 1.second).ago
end
it { is_expected.to eq(:offline) }
@@ -956,22 +935,14 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
context 'contacted long time ago' do
before do
- runner.contacted_at = (3.months + 1.second).ago
+ runner.contacted_at = 3.months.ago
end
- context 'with legacy_mode enabled' do
- let(:legacy_mode) { '14.5' }
-
- it { is_expected.to eq(:stale) }
- end
-
- context 'with legacy_mode disabled' do
- it { is_expected.to eq(:stale) }
- end
+ it { is_expected.to eq(:stale) }
end
end
- describe '#deprecated_rest_status' do
+ describe '#deprecated_rest_status', :freeze_time do
let(:runner) { create(:ci_runner, :instance, contacted_at: 1.second.ago) }
subject { runner.deprecated_rest_status }
@@ -994,8 +965,8 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
context 'contacted long time ago' do
before do
- runner.created_at = 1.year.ago
- runner.contacted_at = 1.year.ago
+ runner.created_at = 3.months.ago
+ runner.contacted_at = 3.months.ago
end
it { is_expected.to eq(:stale) }
@@ -1076,13 +1047,13 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
end
end
- describe '#heartbeat' do
- let(:runner) { create(:ci_runner, :project) }
+ describe '#heartbeat', :freeze_time do
+ let(:runner) { create(:ci_runner, :project, version: '15.0.0') }
let(:executor) { 'shell' }
- let(:version) { '15.0.1' }
+ let(:values) { { architecture: '18-bit', config: { gpus: "all" }, executor: executor, version: version } }
subject(:heartbeat) do
- runner.heartbeat(architecture: '18-bit', config: { gpus: "all" }, executor: executor, version: version)
+ runner.heartbeat(values)
end
context 'when database was updated recently' do
@@ -1090,29 +1061,61 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
runner.contacted_at = Time.current
end
- it 'updates cache' do
- expect_redis_update
- expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to receive(:perform_async)
+ context 'when version is changed' do
+ let(:version) { '15.0.1' }
- heartbeat
+ before do
+ allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version)
+ end
- expect(runner.runner_version).to be_nil
+ it 'updates cache' do
+ expect_redis_update
+
+ heartbeat
+
+ expect(runner.runner_version).to be_nil
+ end
+
+ it 'schedules version information update' do
+ heartbeat
+
+ expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).with(version).once
+ end
+
+ context 'when fetching runner releases is disabled' do
+ before do
+ stub_application_setting(update_runner_versions_enabled: false)
+ end
+
+ it 'does not schedule version information update' do
+ heartbeat
+
+ expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async)
+ end
+ end
end
context 'with only ip_address specified', :freeze_time do
- subject(:heartbeat) do
- runner.heartbeat(ip_address: '1.1.1.1')
+ let(:values) do
+ { ip_address: '1.1.1.1' }
end
it 'updates only ip_address' do
- attrs = Gitlab::Json.dump(ip_address: '1.1.1.1', contacted_at: Time.current)
+ expect_redis_update(values.merge(contacted_at: Time.current))
- Gitlab::Redis::Cache.with do |redis|
- redis_key = runner.send(:cache_attribute_key)
- expect(redis).to receive(:set).with(redis_key, attrs, any_args)
+ heartbeat
+ end
+
+ context 'with new version having been cached' do
+ let(:version) { '15.0.1' }
+
+ before do
+ runner.cache_attributes(version: version)
end
- heartbeat
+ it 'does not lose cached version value' do
+ expect { heartbeat }.not_to change { runner.version }.from(version)
+ end
end
end
end
@@ -1121,65 +1124,81 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
before do
runner.contacted_at = 2.hours.ago
- allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async)
+ allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version)
end
- context 'with invalid runner' do
- before do
- runner.runner_projects.delete_all
- end
+ context 'when version is changed' do
+ let(:version) { '15.0.1' }
- it 'still updates redis cache and database' do
- expect(runner).to be_invalid
+ context 'with invalid runner' do
+ before do
+ runner.runner_projects.delete_all
+ end
+
+ it 'still updates redis cache and database' do
+ expect(runner).to be_invalid
+
+ expect_redis_update
+ does_db_update
+ expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).with(version).once
+ end
+ end
+
+ it 'updates redis cache and database' do
expect_redis_update
does_db_update
-
- expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).once
+ expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).with(version).once
end
end
context 'with unchanged runner version' do
- let(:runner) { create(:ci_runner, version: version) }
+ let(:version) { runner.version }
it 'does not schedule ci_runner_versions update' do
heartbeat
expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async)
end
- end
- it 'updates redis cache and database' do
- expect_redis_update
- does_db_update
- expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).once
- end
+ Ci::Runner::EXECUTOR_NAME_TO_TYPES.each_key do |executor|
+ context "with #{executor} executor" do
+ let(:executor) { executor }
- %w(custom shell docker docker-windows docker-ssh ssh parallels virtualbox docker+machine docker-ssh+machine kubernetes some-unknown-type).each do |executor|
- context "with #{executor} executor" do
- let(:executor) { executor }
+ it 'updates with expected executor type' do
+ expect_redis_update
- it 'updates with expected executor type' do
- expect_redis_update
+ heartbeat
- heartbeat
+ expect(runner.reload.read_attribute(:executor_type)).to eq(expected_executor_type)
+ end
- expect(runner.reload.read_attribute(:executor_type)).to eq(expected_executor_type)
+ def expected_executor_type
+ executor.gsub(/[+-]/, '_')
+ end
end
+ end
- def expected_executor_type
- return 'unknown' if executor == 'some-unknown-type'
+ context 'with an unknown executor type' do
+ let(:executor) { 'some-unknown-type' }
+
+ it 'updates with unknown executor type' do
+ expect_redis_update
- executor.gsub(/[+-]/, '_')
+ heartbeat
+
+ expect(runner.reload.read_attribute(:executor_type)).to eq('unknown')
end
end
end
end
- def expect_redis_update
+ def expect_redis_update(values = anything)
+ values_json = values == anything ? anything : Gitlab::Json.dump(values)
+
Gitlab::Redis::Cache.with do |redis|
redis_key = runner.send(:cache_attribute_key)
- expect(redis).to receive(:set).with(redis_key, anything, any_args)
+ expect(redis).to receive(:set).with(redis_key, values_json, any_args).and_call_original
end
end
@@ -1994,4 +2013,85 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do
end
end
end
+
+ describe '.with_creator' do
+ subject { described_class.with_creator }
+
+ let!(:user) { create(:admin) }
+ let!(:runner) { create(:ci_runner, creator: user) }
+
+ it { is_expected.to contain_exactly(runner) }
+ end
+
+ describe '#ensure_token' do
+ let(:runner) { described_class.new(registration_type: registration_type) }
+ let(:token) { 'an_existing_secret_token' }
+ let(:static_prefix) { described_class::CREATED_RUNNER_TOKEN_PREFIX }
+
+ context 'when runner is initialized without a token' do
+ context 'with registration_token' do
+ let(:registration_type) { :registration_token }
+
+ it 'generates a token' do
+ expect { runner.ensure_token }.to change { runner.token }.from(nil)
+ end
+ end
+
+ context 'with authenticated_user' do
+ let(:registration_type) { :authenticated_user }
+
+ it 'generates a token with prefix' do
+ expect { runner.ensure_token }.to change { runner.token }.from(nil).to(a_string_starting_with(static_prefix))
+ end
+ end
+ end
+
+ context 'when runner is initialized with a token' do
+ before do
+ runner.set_token(token)
+ end
+
+ context 'with registration_token' do
+ let(:registration_type) { :registration_token }
+
+ it 'does not change the existing token' do
+ expect { runner.ensure_token }.not_to change { runner.token }.from(token)
+ end
+ end
+
+ context 'with authenticated_user' do
+ let(:registration_type) { :authenticated_user }
+
+ it 'does not change the existing token' do
+ expect { runner.ensure_token }.not_to change { runner.token }.from(token)
+ end
+ end
+ end
+ end
+
+ describe '#gitlab_hosted?' do
+ using RSpec::Parameterized::TableSyntax
+
+ subject(:runner) { build_stubbed(:ci_runner) }
+
+ where(:saas, :runner_type, :expected_value) do
+ true | :instance_type | true
+ true | :group_type | false
+ true | :project_type | false
+ false | :instance_type | false
+ false | :group_type | false
+ false | :project_type | false
+ end
+
+ with_them do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(saas)
+ runner.runner_type = runner_type
+ end
+
+ it 'returns the correct value based on saas and runner type' do
+ expect(runner.gitlab_hosted?).to eq(expected_value)
+ end
+ end
+ end
end
diff --git a/spec/models/ci/runner_version_spec.rb b/spec/models/ci/runner_version_spec.rb
index 51a2f14c57c..bce1f2a6c39 100644
--- a/spec/models/ci/runner_version_spec.rb
+++ b/spec/models/ci/runner_version_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Ci::RunnerVersion, feature_category: :runner_fleet do
create(:ci_runner_version, version: 'abc123', status: :unavailable)
end
- it { is_expected.to have_many(:runner_machines).with_foreign_key(:version) }
+ it { is_expected.to have_many(:runner_managers).with_foreign_key(:version) }
it_behaves_like 'having unique enum values'
@@ -39,4 +39,15 @@ RSpec.describe Ci::RunnerVersion, feature_category: :runner_fleet do
describe 'validation' do
it { is_expected.to validate_length_of(:version).is_at_most(2048) }
end
+
+ describe '#status' do
+ context 'when is not processed' do
+ subject(:ci_runner_version) { create(:ci_runner_version, version: 'abc124', status: :not_processed) }
+
+ let(:attr) { :status }
+ let(:attr_value) { :not_processed }
+
+ it_behaves_like 'having enum with nil value'
+ end
+ end
end
diff --git a/spec/models/ci/secure_file_spec.rb b/spec/models/ci/secure_file_spec.rb
index 38ae908fb00..1043da33022 100644
--- a/spec/models/ci/secure_file_spec.rb
+++ b/spec/models/ci/secure_file_spec.rb
@@ -144,36 +144,43 @@ RSpec.describe Ci::SecureFile do
describe '#update_metadata!' do
it 'assigns the expected metadata when a parsable .cer file is supplied' do
- file = create(:ci_secure_file, name: 'file1.cer',
- file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.cer')))
+ file = create(
+ :ci_secure_file,
+ name: 'file1.cer',
+ file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.cer'))
+ )
file.update_metadata!
file.reload
- expect(file.expires_at).to eq(DateTime.parse('2022-04-26 19:20:40'))
+ expect(file.expires_at).to eq(DateTime.parse('2023-04-26 19:20:39'))
expect(file.metadata['id']).to eq('33669367788748363528491290218354043267')
expect(file.metadata['issuer']['CN']).to eq('Apple Worldwide Developer Relations Certification Authority')
expect(file.metadata['subject']['OU']).to eq('N7SYAN8PX8')
end
it 'assigns the expected metadata when a parsable .p12 file is supplied' do
- file = create(:ci_secure_file, name: 'file1.p12',
- file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.p12')))
+ file = create(
+ :ci_secure_file,
+ name: 'file1.p12',
+ file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.p12'))
+ )
file.update_metadata!
file.reload
- expect(file.expires_at).to eq(DateTime.parse('2022-09-21 14:56:00'))
+ expect(file.expires_at).to eq(DateTime.parse('2023-09-21 14:55:59'))
expect(file.metadata['id']).to eq('75949910542696343243264405377658443914')
expect(file.metadata['issuer']['CN']).to eq('Apple Worldwide Developer Relations Certification Authority')
expect(file.metadata['subject']['OU']).to eq('N7SYAN8PX8')
end
it 'assigns the expected metadata when a parsable .mobileprovision file is supplied' do
- file = create(:ci_secure_file, name: 'file1.mobileprovision',
- file: CarrierWaveStringFile.new(
- fixture_file('ci_secure_files/sample.mobileprovision')
- ))
+ file = create(
+ :ci_secure_file,
+ name: 'file1.mobileprovision',
+ file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.mobileprovision'))
+ )
file.update_metadata!
file.reload
@@ -187,7 +194,7 @@ RSpec.describe Ci::SecureFile do
it 'logs an error when something goes wrong with the file parsing' do
corrupt_file = create(:ci_secure_file, name: 'file1.cer', file: CarrierWaveStringFile.new('11111111'))
- message = 'Validation failed: Metadata must be a valid json schema - not enough data.'
+ message = 'Validation failed: Metadata must be a valid json schema - PEM_read_bio_X509: no start line.'
expect(Gitlab::AppLogger).to receive(:error).with("Secure File Parser Failure (#{corrupt_file.id}): #{message}")
corrupt_file.update_metadata!
end
diff --git a/spec/models/ci/sources/pipeline_spec.rb b/spec/models/ci/sources/pipeline_spec.rb
index 707872d0a15..036708ed61e 100644
--- a/spec/models/ci/sources/pipeline_spec.rb
+++ b/spec/models/ci/sources/pipeline_spec.rb
@@ -6,6 +6,11 @@ RSpec.describe Ci::Sources::Pipeline, feature_category: :continuous_integration
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:pipeline) }
+ it do
+ is_expected.to belong_to(:build).class_name('Ci::Build')
+ .with_foreign_key(:source_job_id).inverse_of(:sourced_pipelines)
+ end
+
it { is_expected.to belong_to(:source_project).class_name('::Project') }
it { is_expected.to belong_to(:source_job) }
it { is_expected.to belong_to(:source_bridge) }
@@ -32,7 +37,7 @@ RSpec.describe Ci::Sources::Pipeline, feature_category: :continuous_integration
end
end
- describe 'partitioning', :ci_partitioning do
+ describe 'partitioning', :ci_partitionable do
include Ci::PartitioningHelpers
let(:new_pipeline) { create(:ci_pipeline) }
diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb
index b392ab4ed11..79e92082ee1 100644
--- a/spec/models/ci/stage_spec.rb
+++ b/spec/models/ci/stage_spec.rb
@@ -223,10 +223,13 @@ RSpec.describe Ci::Stage, :models do
with_them do
before do
statuses.each do |status|
- create(:commit_status, project: stage.project,
- pipeline: stage.pipeline,
- stage_id: stage.id,
- status: status)
+ create(
+ :commit_status,
+ project: stage.project,
+ pipeline: stage.pipeline,
+ stage_id: stage.id,
+ status: status
+ )
stage.update_legacy_status
end
@@ -239,11 +242,14 @@ RSpec.describe Ci::Stage, :models do
context 'when stage has warnings' do
before do
- create(:ci_build, project: stage.project,
- pipeline: stage.pipeline,
- stage_id: stage.id,
- status: :failed,
- allow_failure: true)
+ create(
+ :ci_build,
+ project: stage.project,
+ pipeline: stage.pipeline,
+ stage_id: stage.id,
+ status: :failed,
+ allow_failure: true
+ )
stage.update_legacy_status
end
diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb
index ce64b3ea158..85327dbeb34 100644
--- a/spec/models/ci/variable_spec.rb
+++ b/spec/models/ci/variable_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::Variable, feature_category: :pipeline_authoring do
+RSpec.describe Ci::Variable, feature_category: :secrets_management do
let_it_be_with_reload(:project) { create(:project) }
subject { build(:ci_variable, project: project) }
diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb
index de67bdb32aa..10081b955f4 100644
--- a/spec/models/clusters/agent_spec.rb
+++ b/spec/models/clusters/agent_spec.rb
@@ -2,16 +2,17 @@
require 'spec_helper'
-RSpec.describe Clusters::Agent do
+RSpec.describe Clusters::Agent, feature_category: :deployment_management do
subject { create(:cluster_agent) }
it { is_expected.to belong_to(:created_by_user).class_name('User').optional }
it { is_expected.to belong_to(:project).class_name('::Project') }
it { is_expected.to have_many(:agent_tokens).class_name('Clusters::AgentToken').order(Clusters::AgentToken.arel_table[:last_used_at].desc.nulls_last) }
- 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 have_many(:active_agent_tokens).class_name('Clusters::AgentToken').conditions(status: 0).order(Clusters::AgentToken.arel_table[:last_used_at].desc.nulls_last) }
+ it { is_expected.to have_many(:ci_access_group_authorizations).class_name('Clusters::Agents::Authorizations::CiAccess::GroupAuthorization') }
+ it { is_expected.to have_many(:ci_access_authorized_groups).through(:ci_access_group_authorizations) }
+ it { is_expected.to have_many(:ci_access_project_authorizations).class_name('Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization') }
+ it { is_expected.to have_many(:ci_access_authorized_projects).through(:ci_access_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) }
@@ -163,4 +164,188 @@ RSpec.describe Clusters::Agent do
it { is_expected.to be_like_time(event2.recorded_at) }
end
+
+ describe '#ci_access_authorized_for?' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:organization) { create(:group) }
+ let_it_be(:agent_management_project) { create(:project, group: organization) }
+ let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) }
+ let_it_be(:deployment_project) { create(:project, group: organization) }
+
+ let(:user) { create(:user) }
+
+ subject { agent.ci_access_authorized_for?(user) }
+
+ it { is_expected.to eq(false) }
+
+ context 'with project-level authorization' do
+ let!(:authorization) { create(:agent_ci_access_project_authorization, agent: agent, project: deployment_project) }
+
+ where(:user_role, :allowed) do
+ :guest | false
+ :reporter | false
+ :developer | true
+ :maintainer | true
+ :owner | true
+ end
+
+ with_them do
+ before do
+ deployment_project.add_member(user, user_role)
+ end
+
+ it { is_expected.to eq(allowed) }
+ end
+
+ context 'when expose_authorized_cluster_agents feature flag is disabled' do
+ before do
+ stub_feature_flags(expose_authorized_cluster_agents: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'with group-level authorization' do
+ let!(:authorization) { create(:agent_ci_access_group_authorization, agent: agent, group: organization) }
+
+ where(:user_role, :allowed) do
+ :guest | false
+ :reporter | false
+ :developer | true
+ :maintainer | true
+ :owner | true
+ end
+
+ with_them do
+ before do
+ organization.add_member(user, user_role)
+ end
+
+ it { is_expected.to eq(allowed) }
+ end
+
+ context 'when expose_authorized_cluster_agents feature flag is disabled' do
+ before do
+ stub_feature_flags(expose_authorized_cluster_agents: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+ end
+
+ describe '#user_access_authorized_for?' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:organization) { create(:group) }
+ let_it_be(:agent_management_project) { create(:project, group: organization) }
+ let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) }
+ let_it_be(:deployment_project) { create(:project, group: organization) }
+
+ let(:user) { create(:user) }
+
+ subject { agent.user_access_authorized_for?(user) }
+
+ it { is_expected.to eq(false) }
+
+ context 'with project-level authorization' do
+ let!(:authorization) { create(:agent_user_access_project_authorization, agent: agent, project: deployment_project) }
+
+ where(:user_role, :allowed) do
+ :guest | false
+ :reporter | false
+ :developer | true
+ :maintainer | true
+ :owner | true
+ end
+
+ with_them do
+ before do
+ deployment_project.add_member(user, user_role)
+ end
+
+ it { is_expected.to eq(allowed) }
+ end
+
+ context 'when expose_authorized_cluster_agents feature flag is disabled' do
+ before do
+ stub_feature_flags(expose_authorized_cluster_agents: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'with group-level authorization' do
+ let!(:authorization) { create(:agent_user_access_group_authorization, agent: agent, group: organization) }
+
+ where(:user_role, :allowed) do
+ :guest | false
+ :reporter | false
+ :developer | true
+ :maintainer | true
+ :owner | true
+ end
+
+ with_them do
+ before do
+ organization.add_member(user, user_role)
+ end
+
+ it { is_expected.to eq(allowed) }
+ end
+
+ context 'when expose_authorized_cluster_agents feature flag is disabled' do
+ before do
+ stub_feature_flags(expose_authorized_cluster_agents: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+ end
+
+ describe '#user_access_config' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project) }
+ let_it_be_with_refind(:agent) { create(:cluster_agent, project: project) }
+
+ subject { agent.user_access_config }
+
+ it { is_expected.to be_nil }
+
+ context 'with user_access project authorizations' do
+ before do
+ create(:agent_user_access_project_authorization, agent: agent, project: project, config: config)
+ end
+
+ let(:config) { {} }
+
+ it { is_expected.to eq(config) }
+
+ context 'when access_as keyword exists' do
+ let(:config) { { 'access_as' => { 'agent' => {} } } }
+
+ it { is_expected.to eq(config) }
+ end
+ end
+
+ context 'with user_access group authorizations' do
+ before do
+ create(:agent_user_access_group_authorization, agent: agent, group: group, config: config)
+ end
+
+ let(:config) { {} }
+
+ it { is_expected.to eq(config) }
+
+ context 'when access_as keyword exists' do
+ let(:config) { { 'access_as' => { 'agent' => {} } } }
+
+ it { is_expected.to eq(config) }
+ end
+ end
+ end
end
diff --git a/spec/models/clusters/agent_token_spec.rb b/spec/models/clusters/agent_token_spec.rb
index 74723e3abd8..41f8215b713 100644
--- a/spec/models/clusters/agent_token_spec.rb
+++ b/spec/models/clusters/agent_token_spec.rb
@@ -24,13 +24,29 @@ RSpec.describe Clusters::AgentToken do
end
end
- describe '.with_status' do
+ describe 'status-related scopes' do
let!(:active_token) { create(:cluster_agent_token) }
let!(:revoked_token) { create(:cluster_agent_token, :revoked) }
- subject { described_class.with_status(:active) }
+ describe '.with_status' do
+ context 'when filtering by active status' do
+ subject { described_class.with_status(:active) }
- it { is_expected.to contain_exactly(active_token) }
+ it { is_expected.to contain_exactly(active_token) }
+ end
+
+ context 'when filtering by revoked status' do
+ subject { described_class.with_status(:revoked) }
+
+ it { is_expected.to contain_exactly(revoked_token) }
+ end
+ end
+
+ describe '.active' do
+ subject { described_class.active }
+
+ it { is_expected.to contain_exactly(active_token) }
+ end
end
end
diff --git a/spec/models/clusters/agents/authorizations/ci_access/group_authorization_spec.rb b/spec/models/clusters/agents/authorizations/ci_access/group_authorization_spec.rb
new file mode 100644
index 00000000000..deabebde760
--- /dev/null
+++ b/spec/models/clusters/agents/authorizations/ci_access/group_authorization_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::CiAccess::GroupAuthorization, feature_category: :deployment_management 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']) }
+
+ describe '#config_project' do
+ let(:record) { create(:agent_ci_access_group_authorization) }
+
+ it { expect(record.config_project).to eq(record.agent.project) }
+ end
+end
diff --git a/spec/models/clusters/agents/authorizations/ci_access/implicit_authorization_spec.rb b/spec/models/clusters/agents/authorizations/ci_access/implicit_authorization_spec.rb
new file mode 100644
index 00000000000..427858c7529
--- /dev/null
+++ b/spec/models/clusters/agents/authorizations/ci_access/implicit_authorization_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::CiAccess::ImplicitAuthorization, feature_category: :deployment_management 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.config_project).to eq(agent.project) }
+ it { expect(subject.config).to eq({}) }
+end
diff --git a/spec/models/clusters/agents/authorizations/ci_access/project_authorization_spec.rb b/spec/models/clusters/agents/authorizations/ci_access/project_authorization_spec.rb
new file mode 100644
index 00000000000..fe5f3cb10ea
--- /dev/null
+++ b/spec/models/clusters/agents/authorizations/ci_access/project_authorization_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization, feature_category: :deployment_management 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']) }
+
+ describe '#config_project' do
+ let(:record) { create(:agent_ci_access_project_authorization) }
+
+ it { expect(record.config_project).to eq(record.agent.project) }
+ end
+end
diff --git a/spec/models/clusters/agents/authorizations/user_access/group_authorization_spec.rb b/spec/models/clusters/agents/authorizations/user_access/group_authorization_spec.rb
new file mode 100644
index 00000000000..da94654268b
--- /dev/null
+++ b/spec/models/clusters/agents/authorizations/user_access/group_authorization_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::UserAccess::GroupAuthorization, feature_category: :deployment_management 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']) }
+
+ describe '.for_user' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+
+ let!(:authorization) { create(:agent_user_access_group_authorization, group: group) }
+ let(:user) { create(:user) }
+
+ subject { described_class.for_user(user) }
+
+ where(:user_role, :expected_access_level) do
+ :guest | nil
+ :reporter | nil
+ :developer | Gitlab::Access::DEVELOPER
+ :maintainer | Gitlab::Access::MAINTAINER
+ :owner | Gitlab::Access::OWNER
+ end
+
+ with_them do
+ before do
+ group.add_member(user, user_role)
+ end
+
+ it 'returns the expected result' do
+ if expected_access_level
+ expect(subject).to contain_exactly(authorization)
+ expect(subject.first.access_level).to eq(expected_access_level)
+ else
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when authorization belongs to sub-group' do
+ let!(:authorization) { create(:agent_user_access_group_authorization, group: subgroup) }
+
+ it 'respects the role inheritance' do
+ if expected_access_level
+ expect(subject).to contain_exactly(authorization)
+ expect(subject.first.access_level).to eq(expected_access_level)
+ else
+ expect(subject).to be_empty
+ end
+ end
+
+ it 'respects the role override' do
+ subgroup.add_member(user, :owner)
+
+ expect(subject).to contain_exactly(authorization)
+ expect(subject.first.access_level).to eq(Gitlab::Access::OWNER)
+ end
+ end
+ end
+ end
+
+ describe '#config_project' do
+ let(:record) { create(:agent_user_access_group_authorization) }
+
+ it { expect(record.config_project).to eq(record.agent.project) }
+ end
+end
diff --git a/spec/models/clusters/agents/authorizations/user_access/project_authorization_spec.rb b/spec/models/clusters/agents/authorizations/user_access/project_authorization_spec.rb
new file mode 100644
index 00000000000..f6f6b328ad9
--- /dev/null
+++ b/spec/models/clusters/agents/authorizations/user_access/project_authorization_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization, feature_category: :deployment_management 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']) }
+
+ describe '.for_user' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:authorization) { create(:agent_user_access_project_authorization, project: project) }
+
+ let(:user) { create(:user) }
+
+ subject { described_class.for_user(user) }
+
+ where(:user_role, :expected_access_level) do
+ :guest | nil
+ :reporter | nil
+ :developer | Gitlab::Access::DEVELOPER
+ :maintainer | Gitlab::Access::MAINTAINER
+ :owner | Gitlab::Access::OWNER
+ end
+
+ with_them do
+ before do
+ project.add_member(user, user_role)
+ end
+
+ it 'returns the expected result' do
+ if expected_access_level
+ expect(subject).to contain_exactly(authorization)
+ expect(subject.first.access_level).to eq(expected_access_level)
+ else
+ expect(subject).to be_empty
+ end
+ end
+ end
+ end
+
+ describe '#config_project' do
+ let(:record) { create(:agent_user_access_project_authorization) }
+
+ it { expect(record.config_project).to eq(record.agent.project) }
+ end
+end
diff --git a/spec/models/clusters/agents/group_authorization_spec.rb b/spec/models/clusters/agents/group_authorization_spec.rb
deleted file mode 100644
index baeb8f5464e..00000000000
--- a/spec/models/clusters/agents/group_authorization_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# 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']) }
-
- describe '#config_project' do
- let(:record) { create(:agent_group_authorization) }
-
- it { expect(record.config_project).to eq(record.agent.project) }
- end
-end
diff --git a/spec/models/clusters/agents/implicit_authorization_spec.rb b/spec/models/clusters/agents/implicit_authorization_spec.rb
deleted file mode 100644
index 1f4c5b1ac9e..00000000000
--- a/spec/models/clusters/agents/implicit_authorization_spec.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# 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.config_project).to eq(agent.project) }
- it { expect(subject.config).to eq({}) }
-end
diff --git a/spec/models/clusters/agents/project_authorization_spec.rb b/spec/models/clusters/agents/project_authorization_spec.rb
deleted file mode 100644
index 9ba259356c7..00000000000
--- a/spec/models/clusters/agents/project_authorization_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# 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']) }
-
- describe '#config_project' do
- let(:record) { create(:agent_project_authorization) }
-
- it { expect(record.config_project).to eq(record.agent.project) }
- end
-end
diff --git a/spec/models/clusters/applications/crossplane_spec.rb b/spec/models/clusters/applications/crossplane_spec.rb
deleted file mode 100644
index d1abaa52c7f..00000000000
--- a/spec/models/clusters/applications/crossplane_spec.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Applications::Crossplane do
- let(:crossplane) { create(:clusters_applications_crossplane) }
-
- include_examples 'cluster application core specs', :clusters_applications_crossplane
- include_examples 'cluster application status specs', :clusters_applications_crossplane
- include_examples 'cluster application version specs', :clusters_applications_crossplane
- include_examples 'cluster application initial status specs'
-
- describe 'validations' do
- it { is_expected.to validate_presence_of(:stack) }
- end
-
- describe 'default values' do
- it { expect(subject.version).to eq(described_class::VERSION) }
- it { expect(subject.stack).to be_empty }
- end
-
- describe '#can_uninstall?' do
- subject { crossplane.can_uninstall? }
-
- it { is_expected.to be_truthy }
- end
-
- describe '#install_command' do
- let(:stack) { 'gcp' }
-
- subject { crossplane.install_command }
-
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
-
- it 'is initialized with crossplane arguments' do
- expect(subject.name).to eq('crossplane')
- expect(subject.chart).to eq('crossplane/crossplane')
- expect(subject.repository).to eq('https://charts.crossplane.io/alpha')
- expect(subject.version).to eq('0.4.1')
- expect(subject).to be_rbac
- end
-
- context 'application failed to install previously' do
- let(:crossplane) { create(:clusters_applications_crossplane, :errored, version: '0.0.1') }
-
- it 'is initialized with the locked version' do
- expect(subject.version).to eq('0.4.1')
- end
- end
- end
-
- describe '#files' do
- let(:application) { crossplane }
- let(:values) { subject[:'values.yaml'] }
-
- subject { application.files }
-
- it 'includes crossplane specific keys in the values.yaml file' do
- expect(values).to include('clusterStacks')
- end
- end
-end
diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb
deleted file mode 100644
index 1b8be92475a..00000000000
--- a/spec/models/clusters/applications/helm_spec.rb
+++ /dev/null
@@ -1,116 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Applications::Helm do
- include_examples 'cluster application core specs', :clusters_applications_helm
-
- describe 'default values' do
- it { expect(subject.version).to eq(Gitlab::Kubernetes::Helm::V2::BaseCommand::HELM_VERSION) }
- end
-
- describe '.available' do
- subject { described_class.available }
-
- let!(:installed_cluster) { create(:clusters_applications_helm, :installed) }
- let!(:updated_cluster) { create(:clusters_applications_helm, :updated) }
-
- before do
- create(:clusters_applications_helm, :errored)
- end
-
- it { is_expected.to contain_exactly(installed_cluster, updated_cluster) }
- end
-
- describe '#can_uninstall?' do
- subject(:application) { build(:clusters_applications_helm).can_uninstall? }
-
- it { is_expected.to eq true }
- end
-
- describe '#issue_client_cert' do
- let(:application) { create(:clusters_applications_helm) }
-
- subject { application.issue_client_cert }
-
- it 'returns a new cert' do
- is_expected.to be_kind_of(Gitlab::Kubernetes::Helm::V2::Certificate)
- expect(subject.cert_string).not_to eq(application.ca_cert)
- expect(subject.key_string).not_to eq(application.ca_key)
- end
- end
-
- describe '#install_command' do
- let(:helm) { create(:clusters_applications_helm) }
-
- subject { helm.install_command }
-
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V2::InitCommand) }
-
- it 'is initialized with 1 arguments' do
- expect(subject.name).to eq('helm')
- end
-
- it 'has cert files' do
- expect(subject.files[:'ca.pem']).to be_present
- expect(subject.files[:'ca.pem']).to eq(helm.ca_cert)
-
- expect(subject.files[:'cert.pem']).to be_present
- expect(subject.files[:'key.pem']).to be_present
-
- cert = OpenSSL::X509::Certificate.new(subject.files[:'cert.pem'])
- expect(cert.not_after).to be > 999.years.from_now
- end
-
- describe 'rbac' do
- context 'rbac cluster' do
- it { expect(subject).to be_rbac }
- end
-
- context 'non rbac cluster' do
- before do
- helm.cluster.platform_kubernetes.abac!
- end
-
- it { expect(subject).not_to be_rbac }
- end
- end
- end
-
- describe '#uninstall_command' do
- let(:helm) { create(:clusters_applications_helm) }
-
- subject { helm.uninstall_command }
-
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V2::ResetCommand) }
-
- it 'has name' do
- expect(subject.name).to eq('helm')
- end
-
- it 'has cert files' do
- expect(subject.files[:'ca.pem']).to be_present
- expect(subject.files[:'ca.pem']).to eq(helm.ca_cert)
-
- expect(subject.files[:'cert.pem']).to be_present
- expect(subject.files[:'key.pem']).to be_present
-
- cert = OpenSSL::X509::Certificate.new(subject.files[:'cert.pem'])
- expect(cert.not_after).to be > 999.years.from_now
- end
-
- describe 'rbac' do
- context 'rbac cluster' do
- it { expect(subject).to be_rbac }
- end
-
- context 'non rbac cluster' do
- before do
- helm.cluster.platform_kubernetes.abac!
- end
-
- it { expect(subject).not_to be_rbac }
- end
- end
- end
-end
diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb
deleted file mode 100644
index 2be59e5f515..00000000000
--- a/spec/models/clusters/applications/ingress_spec.rb
+++ /dev/null
@@ -1,180 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Applications::Ingress do
- let(:ingress) { create(:clusters_applications_ingress) }
-
- before do
- allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in)
- allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async)
- end
-
- it_behaves_like 'having unique enum values'
-
- include_examples 'cluster application core specs', :clusters_applications_ingress
- include_examples 'cluster application status specs', :clusters_applications_ingress
- include_examples 'cluster application version specs', :clusters_applications_ingress
- include_examples 'cluster application helm specs', :clusters_applications_ingress
- include_examples 'cluster application initial status specs'
-
- describe 'default values' do
- it { expect(subject.ingress_type).to eq("nginx") }
- it { expect(subject.version).to eq(described_class::VERSION) }
- end
-
- describe '#can_uninstall?' do
- subject { ingress.can_uninstall? }
-
- context 'with jupyter installed' do
- before do
- create(:clusters_applications_jupyter, :installed, cluster: ingress.cluster)
- end
-
- it 'returns false if external_ip_or_hostname? is true' do
- ingress.external_ip = 'IP'
-
- is_expected.to be_falsey
- end
-
- it 'returns false if external_ip_or_hostname? is false' do
- is_expected.to be_falsey
- end
- end
-
- context 'with jupyter installable' do
- before do
- create(:clusters_applications_jupyter, :installable, cluster: ingress.cluster)
- end
-
- it 'returns true if external_ip_or_hostname? is true' do
- ingress.external_ip = 'IP'
-
- is_expected.to be_truthy
- end
-
- it 'returns false if external_ip_or_hostname? is false' do
- is_expected.to be_falsey
- end
- end
-
- context 'with jupyter nil' do
- it 'returns false if external_ip_or_hostname? is false' do
- is_expected.to be_falsey
- end
-
- context 'if external_ip_or_hostname? is true' do
- context 'with IP' do
- before do
- ingress.external_ip = 'IP'
- end
-
- it { is_expected.to be_truthy }
- end
-
- context 'with hostname' do
- before do
- ingress.external_hostname = 'example.com'
- end
-
- it { is_expected.to be_truthy }
- end
- end
- end
- end
-
- describe '#make_installed!' do
- before do
- application.make_installed!
- end
-
- let(:application) { create(:clusters_applications_ingress, :installing) }
-
- it 'schedules a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_in)
- .with(Clusters::Applications::Ingress::FETCH_IP_ADDRESS_DELAY, 'ingress', application.id)
- end
- end
-
- describe '#schedule_status_update' do
- let(:application) { create(:clusters_applications_ingress, :installed) }
-
- before do
- application.schedule_status_update
- end
-
- it 'schedules a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_async)
- .with('ingress', application.id)
- end
-
- context 'when the application is not installed' do
- let(:application) { create(:clusters_applications_ingress, :installing) }
-
- it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_async)
- end
- end
-
- context 'when there is already an external_ip' do
- let(:application) { create(:clusters_applications_ingress, :installed, external_ip: '111.222.222.111') }
-
- it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in)
- end
- end
-
- context 'when there is already an external_hostname' do
- let(:application) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') }
-
- it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in)
- end
- end
- end
-
- describe '#install_command' do
- subject { ingress.install_command }
-
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
-
- it 'is initialized with ingress arguments' do
- expect(subject.name).to eq('ingress')
- expect(subject.chart).to eq('ingress/nginx-ingress')
- expect(subject.version).to eq('1.40.2')
- expect(subject).to be_rbac
- expect(subject.files).to eq(ingress.files)
- end
-
- context 'on a non rbac enabled cluster' do
- before do
- ingress.cluster.platform_kubernetes.abac!
- end
-
- it { is_expected.not_to be_rbac }
- end
-
- context 'application failed to install previously' do
- let(:ingress) { create(:clusters_applications_ingress, :errored, version: 'nginx') }
-
- it 'is initialized with the locked version' do
- expect(subject.version).to eq('1.40.2')
- end
- end
- end
-
- describe '#files' do
- let(:application) { ingress }
- let(:values) { subject[:'values.yaml'] }
-
- subject { application.files }
-
- it 'includes ingress valid keys in values' do
- expect(values).to include('image')
- expect(values).to include('repository')
- expect(values).to include('stats')
- expect(values).to include('podAnnotations')
- expect(values).to include('clusterIP')
- end
- end
-end
diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb
deleted file mode 100644
index 9336d2352f8..00000000000
--- a/spec/models/clusters/applications/jupyter_spec.rb
+++ /dev/null
@@ -1,130 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Applications::Jupyter do
- include_examples 'cluster application core specs', :clusters_applications_jupyter
- include_examples 'cluster application status specs', :clusters_applications_jupyter
- include_examples 'cluster application version specs', :clusters_applications_jupyter
- include_examples 'cluster application helm specs', :clusters_applications_jupyter
-
- it { is_expected.to belong_to(:oauth_application) }
-
- describe 'default values' do
- it { expect(subject.version).to eq(described_class::VERSION) }
- end
-
- describe '#can_uninstall?' do
- let(:ingress) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') }
- let(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) }
-
- subject { jupyter.can_uninstall? }
-
- it { is_expected.to be_truthy }
- end
-
- describe '#set_initial_status' do
- before do
- jupyter.set_initial_status
- end
-
- context 'when ingress is not installed' do
- let(:cluster) { create(:cluster, :provided_by_gcp) }
- let(:jupyter) { create(:clusters_applications_jupyter, cluster: cluster) }
-
- it { expect(jupyter).to be_not_installable }
- end
-
- context 'when ingress is installed and external_ip is assigned' do
- let(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') }
- let(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) }
-
- it { expect(jupyter).to be_installable }
- end
-
- context 'when ingress is installed and external_hostname is assigned' do
- let(:ingress) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') }
- let(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) }
-
- it { expect(jupyter).to be_installable }
- end
- end
-
- describe '#install_command' do
- let!(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') }
- let!(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) }
-
- subject { jupyter.install_command }
-
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
-
- it 'is initialized with 4 arguments' do
- expect(subject.name).to eq('jupyter')
- expect(subject.chart).to eq('jupyter/jupyterhub')
- expect(subject.version).to eq('0.9.0')
-
- expect(subject).to be_rbac
- expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/')
- expect(subject.files).to eq(jupyter.files)
- end
-
- context 'on a non rbac enabled cluster' do
- before do
- jupyter.cluster.platform_kubernetes.abac!
- end
-
- it { is_expected.not_to be_rbac }
- end
-
- context 'application failed to install previously' do
- let(:jupyter) { create(:clusters_applications_jupyter, :errored, version: '0.0.1') }
-
- it 'is initialized with the locked version' do
- expect(subject.version).to eq('0.9.0')
- end
- end
- end
-
- describe '#files' do
- let(:cluster) { create(:cluster, :with_installed_helm, :provided_by_gcp, :project) }
- let(:application) { create(:clusters_applications_jupyter, cluster: cluster) }
- let(:values) { subject[:'values.yaml'] }
-
- subject { application.files }
-
- context 'when cluster belongs to a project' do
- it 'includes valid values' do
- expect(values).to include('ingress')
- expect(values).to include('hub')
- expect(values).to include('proxy')
- expect(values).to include('auth')
- expect(values).to include('singleuser')
- expect(values).to match(/clientId: '?#{application.oauth_application.uid}/)
- expect(values).to match(/callbackUrl: '?#{application.callback_url}/)
- expect(values).to include("gitlabProjectIdWhitelist:\n - #{application.cluster.project.id}")
- expect(values).to include("c.GitLabOAuthenticator.scope = ['api read_repository write_repository']")
- expect(values).to match(/GITLAB_HOST: '?#{Gitlab.config.gitlab.host}/)
- expect(values).to match(/GITLAB_CLUSTER_ID: '?#{application.cluster.id}/)
- end
- end
-
- context 'when cluster belongs to a group' do
- let(:group) { create(:group) }
- let(:cluster) { create(:cluster, :with_installed_helm, :provided_by_gcp, :group, groups: [group]) }
-
- it 'includes valid values' do
- expect(values).to include('ingress')
- expect(values).to include('hub')
- expect(values).to include('proxy')
- expect(values).to include('auth')
- expect(values).to include('singleuser')
- expect(values).to match(/clientId: '?#{application.oauth_application.uid}/)
- expect(values).to match(/callbackUrl: '?#{application.callback_url}/)
- expect(values).to include("gitlabGroupWhitelist:\n - #{group.to_param}")
- expect(values).to include("c.GitLabOAuthenticator.scope = ['api read_repository write_repository']")
- expect(values).to match(/GITLAB_HOST: '?#{Gitlab.config.gitlab.host}/)
- expect(values).to match(/GITLAB_CLUSTER_ID: '?#{application.cluster.id}/)
- end
- end
- end
-end
diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb
deleted file mode 100644
index f6b13f4a93f..00000000000
--- a/spec/models/clusters/applications/knative_spec.rb
+++ /dev/null
@@ -1,260 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Applications::Knative do
- let(:knative) { create(:clusters_applications_knative) }
-
- before do
- allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in)
- allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async)
- allow(ClusterConfigureIstioWorker).to receive(:perform_async)
- end
-
- include_examples 'cluster application core specs', :clusters_applications_knative
- include_examples 'cluster application status specs', :clusters_applications_knative
- include_examples 'cluster application helm specs', :clusters_applications_knative
- include_examples 'cluster application version specs', :clusters_applications_knative
- include_examples 'cluster application initial status specs'
-
- describe 'associations' do
- it { is_expected.to have_one(:serverless_domain_cluster).class_name('::Serverless::DomainCluster').with_foreign_key('clusters_applications_knative_id').inverse_of(:knative) }
- end
-
- describe 'default values' do
- it { expect(subject.version).to eq(described_class::VERSION) }
- end
-
- describe 'when cloud run is enabled' do
- let(:cluster) { create(:cluster, :provided_by_gcp, :cloud_run_enabled) }
- let(:knative_cloud_run) { create(:clusters_applications_knative, cluster: cluster) }
-
- it { expect(knative_cloud_run).to be_not_installable }
- end
-
- describe 'when rbac is not enabled' do
- let(:cluster) { create(:cluster, :provided_by_gcp, :rbac_disabled) }
- let(:knative_no_rbac) { create(:clusters_applications_knative, cluster: cluster) }
-
- it { expect(knative_no_rbac).to be_not_installable }
- end
-
- describe 'make_installed with external_ip' do
- before do
- application.make_installed!
- end
-
- let(:application) { create(:clusters_applications_knative, :installing) }
-
- it 'schedules a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_in)
- .with(Clusters::Applications::Knative::FETCH_IP_ADDRESS_DELAY, 'knative', application.id)
- end
- end
-
- describe 'configuring istio ingress gateway' do
- context 'after installed' do
- let(:application) { create(:clusters_applications_knative, :installing) }
-
- before do
- application.make_installed!
- end
-
- it 'schedules a ClusterConfigureIstioWorker' do
- expect(ClusterConfigureIstioWorker).to have_received(:perform_async).with(application.cluster_id)
- end
- end
-
- context 'after updated' do
- let(:application) { create(:clusters_applications_knative, :updating) }
-
- before do
- application.make_installed!
- end
-
- it 'schedules a ClusterConfigureIstioWorker' do
- expect(ClusterConfigureIstioWorker).to have_received(:perform_async).with(application.cluster_id)
- end
- end
- end
-
- describe '#can_uninstall?' do
- subject { knative.can_uninstall? }
-
- it { is_expected.to be_truthy }
- end
-
- describe '#schedule_status_update with external_ip' do
- let(:application) { create(:clusters_applications_knative, :installed) }
-
- before do
- application.schedule_status_update
- end
-
- it 'schedules a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_async)
- .with('knative', application.id)
- end
-
- context 'when the application is not installed' do
- let(:application) { create(:clusters_applications_knative, :installing) }
-
- it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_async)
- end
- end
-
- context 'when there is already an external_ip' do
- let(:application) { create(:clusters_applications_knative, :installed, external_ip: '111.222.222.111') }
-
- it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in)
- end
- end
-
- context 'when there is already an external_hostname' do
- let(:application) { create(:clusters_applications_knative, :installed, external_hostname: 'localhost.localdomain') }
-
- it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do
- expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in)
- end
- end
- end
-
- shared_examples 'a command' do
- it 'is an instance of Helm::InstallCommand' do
- expect(subject).to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand)
- end
-
- it 'is initialized with knative arguments' do
- expect(subject.name).to eq('knative')
- expect(subject.chart).to eq('knative/knative')
- expect(subject.files).to eq(knative.files)
- end
-
- it 'does not install metrics for prometheus' do
- expect(subject.postinstall).to be_empty
- end
-
- context 'with prometheus installed' do
- let(:prometheus) { create(:clusters_applications_prometheus, :installed) }
- let(:knative) { create(:clusters_applications_knative, cluster: prometheus.cluster) }
-
- subject { knative.install_command }
-
- it 'installs metrics' do
- expect(subject.postinstall).not_to be_empty
- expect(subject.postinstall.length).to be(1)
- expect(subject.postinstall[0]).to eql("kubectl apply -f #{Clusters::Applications::Knative::METRICS_CONFIG}")
- end
- end
- end
-
- describe '#install_command' do
- subject { knative.install_command }
-
- it 'is initialized with latest version' do
- expect(subject.version).to eq('0.10.0')
- end
-
- it_behaves_like 'a command'
- end
-
- describe '#update_command' do
- let!(:current_installed_version) { knative.version = '0.1.0' }
-
- subject { knative.update_command }
-
- it 'is initialized with current version' do
- expect(subject.version).to eq(current_installed_version)
- end
-
- it_behaves_like 'a command'
- end
-
- describe '#uninstall_command' do
- subject { knative.uninstall_command }
-
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand) }
-
- it "removes knative deployed services before uninstallation" do
- 2.times do |i|
- cluster_project = create(:cluster_project, cluster: knative.cluster)
-
- create(:cluster_kubernetes_namespace,
- cluster: cluster_project.cluster,
- cluster_project: cluster_project,
- project: cluster_project.project,
- namespace: "namespace_#{i}")
- end
-
- remove_namespaced_services_script = [
- "kubectl delete ksvc --all -n #{knative.cluster.kubernetes_namespaces.first.namespace}",
- "kubectl delete ksvc --all -n #{knative.cluster.kubernetes_namespaces.second.namespace}"
- ]
-
- expect(subject.predelete).to match_array(remove_namespaced_services_script)
- end
-
- it "initializes command with all necessary postdelete script" do
- api_groups = YAML.safe_load(File.read(Rails.root.join(Clusters::Applications::Knative::API_GROUPS_PATH)))
-
- remove_knative_istio_leftovers_script = [
- "kubectl delete --ignore-not-found ns knative-serving",
- "kubectl delete --ignore-not-found ns knative-build"
- ]
-
- full_delete_commands_size = api_groups.size + remove_knative_istio_leftovers_script.size
-
- expect(subject.postdelete).to include(*remove_knative_istio_leftovers_script)
- expect(subject.postdelete.size).to eq(full_delete_commands_size)
- expect(subject.postdelete[2]).to include("kubectl api-resources -o name --api-group #{api_groups[0]} | xargs -r kubectl delete --ignore-not-found crd")
- expect(subject.postdelete[3]).to include("kubectl api-resources -o name --api-group #{api_groups[1]} | xargs -r kubectl delete --ignore-not-found crd")
- end
- end
-
- describe '#files' do
- let(:application) { knative }
- let(:values) { subject[:'values.yaml'] }
-
- subject { application.files }
-
- it 'includes knative specific keys in the values.yaml file' do
- expect(values).to include('domain')
- end
- end
-
- describe 'validations' do
- it { is_expected.to validate_presence_of(:hostname) }
- end
-
- describe '#available_domains' do
- let!(:domain) { create(:pages_domain, :instance_serverless) }
-
- it 'returns all instance serverless domains' do
- expect(PagesDomain).to receive(:instance_serverless).and_call_original
-
- domains = subject.available_domains
-
- expect(domains.length).to eq(1)
- expect(domains).to include(domain)
- end
- end
-
- describe '#find_available_domain' do
- let!(:domain) { create(:pages_domain, :instance_serverless) }
-
- it 'returns the domain scoped to available domains' do
- expect(subject).to receive(:available_domains).and_call_original
- expect(subject.find_available_domain(domain.id)).to eq(domain)
- end
- end
-
- describe '#pages_domain' do
- let!(:sdc) { create(:serverless_domain_cluster, knative: knative) }
-
- it 'returns the the associated pages domain' do
- expect(knative.reload.pages_domain).to eq(sdc.pages_domain)
- end
- end
-end
diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb
deleted file mode 100644
index 15c3162270e..00000000000
--- a/spec/models/clusters/applications/prometheus_spec.rb
+++ /dev/null
@@ -1,349 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Applications::Prometheus do
- include KubernetesHelpers
- include StubRequests
-
- include_examples 'cluster application core specs', :clusters_applications_prometheus
- include_examples 'cluster application status specs', :clusters_applications_prometheus
- include_examples 'cluster application version specs', :clusters_applications_prometheus
- include_examples 'cluster application helm specs', :clusters_applications_prometheus
- include_examples 'cluster application initial status specs'
-
- describe 'default values' do
- subject(:prometheus) { build(:clusters_applications_prometheus) }
-
- it { expect(prometheus.alert_manager_token).to be_an_instance_of(String) }
- it { expect(prometheus.version).to eq(described_class::VERSION) }
- end
-
- describe 'after_destroy' do
- let(:cluster) { create(:cluster, :with_installed_helm) }
- let(:application) { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
-
- it 'disables the corresponding integration' do
- application.destroy!
-
- expect(cluster.integration_prometheus).not_to be_enabled
- end
- end
-
- describe 'transition to installed' do
- let(:project) { create(:project) }
- let(:cluster) { create(:cluster, :with_installed_helm) }
- let(:application) { create(:clusters_applications_prometheus, :installing, cluster: cluster) }
-
- it 'enables the corresponding integration' do
- application.make_installed
-
- expect(cluster.integration_prometheus).to be_enabled
- end
- end
-
- describe 'transition to externally_installed' do
- let(:project) { create(:project) }
- let(:cluster) { create(:cluster, :with_installed_helm) }
- let(:application) { create(:clusters_applications_prometheus, :installing, cluster: cluster) }
-
- it 'enables the corresponding integration' do
- application.make_externally_installed!
-
- expect(cluster.integration_prometheus).to be_enabled
- end
- end
-
- describe 'transition to updating' do
- let(:project) { create(:project) }
- let(:cluster) { create(:cluster, projects: [project]) }
-
- subject { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
-
- it 'sets last_update_started_at to now' do
- freeze_time do
- expect { subject.make_updating }.to change { subject.reload.last_update_started_at }.to be_within(1.second).of(Time.current)
- end
- end
- end
-
- describe '#managed_prometheus?' do
- subject { prometheus.managed_prometheus? }
-
- let(:prometheus) { build(:clusters_applications_prometheus) }
-
- it { is_expected.to be_truthy }
-
- context 'externally installed' do
- let(:prometheus) { build(:clusters_applications_prometheus, :externally_installed) }
-
- it { is_expected.to be_falsey }
- end
-
- context 'uninstalled' do
- let(:prometheus) { build(:clusters_applications_prometheus, :uninstalled) }
-
- it { is_expected.to be_falsey }
- end
- end
-
- describe '#can_uninstall?' do
- let(:prometheus) { create(:clusters_applications_prometheus) }
-
- subject { prometheus.can_uninstall? }
-
- it { is_expected.to be_truthy }
- end
-
- describe '#prometheus_client' do
- include_examples '#prometheus_client shared' do
- let(:factory) { :clusters_applications_prometheus }
- end
- end
-
- describe '#install_command' do
- let(:prometheus) { create(:clusters_applications_prometheus) }
-
- subject { prometheus.install_command }
-
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
-
- it 'is initialized with 3 arguments' do
- expect(subject.name).to eq('prometheus')
- expect(subject.chart).to eq('prometheus/prometheus')
- expect(subject.version).to eq('10.4.1')
- expect(subject).to be_rbac
- expect(subject.files).to eq(prometheus.files)
- end
-
- context 'on a non rbac enabled cluster' do
- before do
- prometheus.cluster.platform_kubernetes.abac!
- end
-
- it { is_expected.not_to be_rbac }
- end
-
- context 'application failed to install previously' do
- let(:prometheus) { create(:clusters_applications_prometheus, :errored, version: '2.0.0') }
-
- it 'is initialized with the locked version' do
- expect(subject.version).to eq('10.4.1')
- end
- end
-
- it 'does not install knative metrics' do
- expect(subject.postinstall).to be_empty
- end
-
- context 'with knative installed' do
- let(:knative) { create(:clusters_applications_knative, :updated) }
- let(:prometheus) { create(:clusters_applications_prometheus, cluster: knative.cluster) }
-
- subject { prometheus.install_command }
-
- it 'installs knative metrics' do
- expect(subject.postinstall).to include("kubectl apply -f #{Clusters::Applications::Knative::METRICS_CONFIG}")
- end
- end
- end
-
- describe '#uninstall_command' do
- let(:prometheus) { create(:clusters_applications_prometheus) }
-
- subject { prometheus.uninstall_command }
-
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand) }
-
- it 'has the application name' do
- expect(subject.name).to eq('prometheus')
- end
-
- it 'has files' do
- expect(subject.files).to eq(prometheus.files)
- end
-
- it 'is rbac' do
- expect(subject).to be_rbac
- end
-
- describe '#predelete' do
- let(:knative) { create(:clusters_applications_knative, :updated) }
- let(:prometheus) { create(:clusters_applications_prometheus, cluster: knative.cluster) }
-
- subject { prometheus.uninstall_command.predelete }
-
- it 'deletes knative metrics' do
- metrics_config = Clusters::Applications::Knative::METRICS_CONFIG
- is_expected.to include("kubectl delete -f #{metrics_config} --ignore-not-found")
- end
- end
-
- context 'on a non rbac enabled cluster' do
- before do
- prometheus.cluster.platform_kubernetes.abac!
- end
-
- it { is_expected.not_to be_rbac }
- end
- end
-
- describe '#patch_command' do
- subject(:patch_command) { prometheus.patch_command(values) }
-
- let(:prometheus) { build(:clusters_applications_prometheus) }
- let(:values) { prometheus.values }
-
- it { is_expected.to be_an_instance_of(::Gitlab::Kubernetes::Helm::V3::PatchCommand) }
-
- it 'is initialized with 3 arguments' do
- expect(patch_command.name).to eq('prometheus')
- expect(patch_command.chart).to eq('prometheus/prometheus')
- expect(patch_command.version).to eq('10.4.1')
- expect(patch_command.files).to eq(prometheus.files)
- end
- end
-
- describe '#update_in_progress?' do
- context 'when app is updating' do
- it 'returns true' do
- cluster = create(:cluster)
- prometheus_app = build(:clusters_applications_prometheus, :updating, cluster: cluster)
-
- expect(prometheus_app.update_in_progress?).to be true
- end
- end
- end
-
- describe '#update_errored?' do
- context 'when app errored' do
- it 'returns true' do
- cluster = create(:cluster)
- prometheus_app = build(:clusters_applications_prometheus, :update_errored, cluster: cluster)
-
- expect(prometheus_app.update_errored?).to be true
- end
- end
- end
-
- describe '#files' do
- let(:application) { create(:clusters_applications_prometheus) }
- let(:values) { subject[:'values.yaml'] }
-
- subject { application.files }
-
- it 'includes prometheus valid values' do
- expect(values).to include('alertmanager')
- expect(values).to include('kubeStateMetrics')
- expect(values).to include('nodeExporter')
- expect(values).to include('pushgateway')
- expect(values).to include('serverFiles')
- end
- end
-
- describe '#files_with_replaced_values' do
- let(:application) { build(:clusters_applications_prometheus) }
- let(:files) { application.files }
-
- subject { application.files_with_replaced_values({ hello: :world }) }
-
- it 'does not modify #files' do
- expect(subject[:'values.yaml']).not_to eq(files[:'values.yaml'])
-
- expect(files[:'values.yaml']).to eq(application.values)
- end
-
- it 'returns values.yaml with replaced values' do
- expect(subject[:'values.yaml']).to eq({ hello: :world })
- end
-
- it 'uses values from #files, except for values.yaml' do
- allow(application).to receive(:files).and_return({
- 'values.yaml': 'some value specific to files',
- 'file_a.txt': 'file_a',
- 'file_b.txt': 'file_b'
- })
-
- expect(subject.except(:'values.yaml')).to eq({
- 'file_a.txt': 'file_a',
- 'file_b.txt': 'file_b'
- })
- end
- end
-
- describe '#configured?' do
- let(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
-
- subject { prometheus.configured? }
-
- context 'when a kubenetes client is present' do
- let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
-
- it { is_expected.to be_truthy }
-
- context 'when it is not availalble' do
- let(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) }
-
- it { is_expected.to be_falsey }
- end
-
- context 'when the kubernetes URL is blocked' do
- before do
- blocked_ip = '127.0.0.1' # localhost addresses are blocked by default
-
- stub_all_dns(cluster.platform.api_url, ip_address: blocked_ip)
- end
-
- it { is_expected.to be_falsey }
- end
- end
-
- context 'when a kubenetes client is not present' do
- let(:cluster) { create(:cluster) }
-
- it { is_expected.to be_falsy }
- end
- end
-
- describe '#updated_since?' do
- let(:cluster) { create(:cluster) }
- let(:prometheus_app) { build(:clusters_applications_prometheus, cluster: cluster) }
- let(:timestamp) { Time.current - 5.minutes }
-
- around do |example|
- freeze_time { example.run }
- end
-
- before do
- prometheus_app.last_update_started_at = Time.current
- end
-
- context 'when app does not have status failed' do
- it 'returns true when last update started after the timestamp' do
- expect(prometheus_app.updated_since?(timestamp)).to be true
- end
-
- it 'returns false when last update started before the timestamp' do
- expect(prometheus_app.updated_since?(Time.current + 5.minutes)).to be false
- end
- end
-
- context 'when app has status failed' do
- it 'returns false when last update started after the timestamp' do
- prometheus_app.status = 6
-
- expect(prometheus_app.updated_since?(timestamp)).to be false
- end
- end
- end
-
- describe 'alert manager token' do
- subject { create(:clusters_applications_prometheus) }
-
- it 'is autogenerated on creation' do
- expect(subject.alert_manager_token).to match(/\A\h{32}\z/)
- expect(subject.encrypted_alert_manager_token).not_to be_nil
- expect(subject.encrypted_alert_manager_token_iv).not_to be_nil
- end
- end
-end
diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb
deleted file mode 100644
index 04b5ae9641d..00000000000
--- a/spec/models/clusters/applications/runner_spec.rb
+++ /dev/null
@@ -1,127 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Applications::Runner do
- let(:ci_runner) { create(:ci_runner) }
-
- include_examples 'cluster application core specs', :clusters_applications_runner
- include_examples 'cluster application status specs', :clusters_applications_runner
- include_examples 'cluster application version specs', :clusters_applications_runner
- include_examples 'cluster application helm specs', :clusters_applications_runner
- include_examples 'cluster application initial status specs'
-
- it { is_expected.to belong_to(:runner) }
-
- describe 'default values' do
- it { expect(subject.version).to eq(described_class::VERSION) }
- end
-
- describe '#can_uninstall?' do
- let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) }
-
- subject { gitlab_runner.can_uninstall? }
-
- it { is_expected.to be_truthy }
- end
-
- describe '#install_command' do
- let(:kubeclient) { double('kubernetes client') }
- let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) }
-
- subject { gitlab_runner.install_command }
-
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
-
- it 'is initialized with 4 arguments' do
- expect(subject.name).to eq('runner')
- expect(subject.chart).to eq('runner/gitlab-runner')
- expect(subject.version).to eq(Clusters::Applications::Runner::VERSION)
- expect(subject).to be_rbac
- expect(subject.repository).to eq('https://charts.gitlab.io')
- expect(subject.files).to eq(gitlab_runner.files)
- end
-
- context 'on a non rbac enabled cluster' do
- before do
- gitlab_runner.cluster.platform_kubernetes.abac!
- end
-
- it { is_expected.not_to be_rbac }
- end
-
- context 'application failed to install previously' do
- let(:gitlab_runner) { create(:clusters_applications_runner, :errored, runner: ci_runner, version: '0.1.13') }
-
- it 'is initialized with the locked version' do
- expect(subject.version).to eq(Clusters::Applications::Runner::VERSION)
- end
- end
- end
-
- describe '#files' do
- let(:application) { create(:clusters_applications_runner, runner: ci_runner) }
- let(:values) { subject[:'values.yaml'] }
-
- subject { application.files }
-
- it 'includes runner valid values' do
- expect(values).to include('concurrent')
- expect(values).to include('checkInterval')
- expect(values).to include('rbac')
- expect(values).to include('runners')
- expect(values).to include('privileged: true')
- expect(values).to include('image: ubuntu:16.04')
- expect(values).to include('resources')
- expect(values).to match(/gitlabUrl: ['"]?#{Regexp.escape(Gitlab::Routing.url_helpers.root_url)}/)
- end
-
- context 'with duplicated values on vendor/runner/values.yaml' do
- let(:stub_values) do
- {
- "concurrent" => 4,
- "checkInterval" => 3,
- "rbac" => {
- "create" => false
- },
- "clusterWideAccess" => false,
- "runners" => {
- "privileged" => false,
- "image" => "ubuntu:16.04",
- "builds" => {},
- "services" => {},
- "helpers" => {}
- }
- }
- end
-
- before do
- allow(application).to receive(:chart_values).and_return(stub_values)
- end
-
- it 'overwrites values.yaml' do
- expect(values).to match(/privileged: '?#{application.privileged}/)
- end
- end
- end
-
- describe '#make_uninstalling!' do
- subject { create(:clusters_applications_runner, :scheduled, runner: ci_runner) }
-
- it 'calls prepare_uninstall' do
- expect_next_instance_of(described_class) do |instance|
- expect(instance).to receive(:prepare_uninstall).and_call_original
- end
-
- subject.make_uninstalling!
- end
- end
-
- describe '#post_uninstall' do
- it 'destroys its runner' do
- application_runner = create(:clusters_applications_runner, :scheduled, runner: ci_runner)
-
- expect { application_runner.post_uninstall }.to change { Ci::Runner.count }.by(-1)
- end
- end
-end
diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index 2a2e2899d24..d501325dd90 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching,
-feature_category: :kubernetes_management do
+ feature_category: :deployment_management do
include ReactiveCachingHelpers
include KubernetesHelpers
@@ -23,10 +23,6 @@ feature_category: :kubernetes_management do
it { is_expected.to have_one(:provider_aws) }
it { is_expected.to have_one(:platform_kubernetes) }
it { is_expected.to have_one(:integration_prometheus) }
- it { is_expected.to have_one(:application_helm) }
- it { is_expected.to have_one(:application_ingress) }
- it { is_expected.to have_one(:application_prometheus) }
- it { is_expected.to have_one(:application_runner) }
it { is_expected.to have_many(:kubernetes_namespaces) }
it { is_expected.to have_one(:cluster_project) }
it { is_expected.to have_many(:deployment_clusters) }
@@ -36,8 +32,6 @@ feature_category: :kubernetes_management do
it { is_expected.to delegate_method(:status).to(:provider) }
it { is_expected.to delegate_method(:status_reason).to(:provider) }
- it { is_expected.to delegate_method(:external_ip).to(:application_ingress).with_prefix }
- it { is_expected.to delegate_method(:external_hostname).to(:application_ingress).with_prefix }
it { is_expected.to respond_to :project }
it { is_expected.to be_namespace_per_environment }
@@ -50,15 +44,6 @@ feature_category: :kubernetes_management do
let(:factory_name) { :cluster }
end
- describe 'applications have inverse_of: :cluster option' do
- let(:cluster) { create(:cluster) }
- let!(:helm) { create(:clusters_applications_helm, cluster: cluster) }
-
- it 'does not do a third query when referencing cluster again' do
- expect { cluster.application_helm.cluster }.not_to exceed_query_limit(2)
- end
- end
-
describe '.enabled' do
subject { described_class.enabled }
@@ -693,103 +678,6 @@ feature_category: :kubernetes_management do
end
end
- describe '.with_persisted_applications' do
- let(:cluster) { create(:cluster) }
- let!(:helm) { create(:clusters_applications_helm, :installed, cluster: cluster) }
-
- it 'preloads persisted applications' do
- query_rec = ActiveRecord::QueryRecorder.new do
- described_class.with_persisted_applications.find_by_id(cluster.id).application_helm
- end
-
- expect(query_rec.count).to eq(1)
- end
- end
-
- describe '#persisted_applications' do
- let(:cluster) { create(:cluster) }
-
- subject { cluster.persisted_applications }
-
- context 'when all applications are created' do
- let!(:helm) { create(:clusters_applications_helm, cluster: cluster) }
- let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) }
- let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) }
- let!(:runner) { create(:clusters_applications_runner, cluster: cluster) }
- let!(:jupyter) { create(:clusters_applications_jupyter, cluster: cluster) }
- let!(:knative) { create(:clusters_applications_knative, cluster: cluster) }
-
- it 'returns a list of created applications' do
- is_expected.to contain_exactly(helm, ingress, prometheus, runner, jupyter, knative)
- end
- end
-
- context 'when not all were created' do
- let!(:helm) { create(:clusters_applications_helm, cluster: cluster) }
- let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) }
-
- it 'returns a list of created applications' do
- is_expected.to contain_exactly(helm, ingress)
- end
- end
- end
-
- describe '#applications' do
- let_it_be(:cluster, reload: true) { create(:cluster) }
-
- subject { cluster.applications }
-
- context 'when none of applications are created' do
- it 'returns a list of a new objects' do
- is_expected.not_to be_empty
- end
- end
-
- context 'when applications are created' do
- let(:cluster) { create(:cluster, :with_all_applications) }
-
- it 'returns a list of created applications', :aggregate_failures do
- is_expected.to have_attributes(size: described_class::APPLICATIONS.size)
- is_expected.to all(be_kind_of(::Clusters::Concerns::ApplicationCore))
- is_expected.to all(be_persisted)
- end
- end
- end
-
- describe '#find_or_build_application' do
- let_it_be(:cluster, reload: true) { create(:cluster) }
-
- it 'rejects classes that are not applications' do
- expect do
- cluster.find_or_build_application(Project)
- end.to raise_error(ArgumentError)
- end
-
- context 'when none of applications are created' do
- it 'returns the new application', :aggregate_failures do
- described_class::APPLICATIONS.values.each do |application_class|
- application = cluster.find_or_build_application(application_class)
-
- expect(application).to be_a(application_class)
- expect(application).not_to be_persisted
- end
- end
- end
-
- context 'when application is persisted' do
- let(:cluster) { create(:cluster, :with_all_applications) }
-
- it 'returns the persisted application', :aggregate_failures do
- described_class::APPLICATIONS.each_value do |application_class|
- application = cluster.find_or_build_application(application_class)
-
- expect(application).to be_kind_of(::Clusters::Concerns::ApplicationCore)
- expect(application).to be_persisted
- end
- end
- end
- end
-
describe '#allow_user_defined_namespace?' do
subject { cluster.allow_user_defined_namespace? }
@@ -839,7 +727,7 @@ feature_category: :kubernetes_management do
describe '#all_projects' do
context 'cluster_type is project_type' do
let(:project) { create(:project) }
- let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) }
+ let(:cluster) { create(:cluster, projects: [project]) }
it 'returns projects' do
expect(cluster.all_projects).to match_array [project]
@@ -849,7 +737,7 @@ feature_category: :kubernetes_management do
context 'cluster_type is group_type' do
let(:group) { create(:group) }
let!(:project) { create(:project, group: group) }
- let(:cluster) { create(:cluster_for_group, :with_installed_helm, groups: [group]) }
+ let(:cluster) { create(:cluster_for_group, groups: [group]) }
it 'returns group projects' do
expect(cluster.all_projects.ids).to match_array [project.id]
@@ -1342,22 +1230,6 @@ feature_category: :kubernetes_management do
expect(cluster.prometheus_adapter).to eq(integration)
end
end
-
- context 'has application_prometheus' do
- let_it_be(:application) { create(:clusters_applications_prometheus, :no_helm_installed, cluster: cluster) }
-
- it 'returns nil' do
- expect(cluster.prometheus_adapter).to be_nil
- end
-
- context 'also has a integration_prometheus' do
- let_it_be(:integration) { create(:clusters_integrations_prometheus, cluster: cluster) }
-
- it 'returns the integration' do
- expect(cluster.prometheus_adapter).to eq(integration)
- end
- end
- end
end
describe '#delete_cached_resources!' do
@@ -1444,36 +1316,6 @@ feature_category: :kubernetes_management do
end
end
- describe '#knative_pre_installed?' do
- subject(:knative_pre_installed?) { cluster.knative_pre_installed? }
-
- before do
- allow(cluster).to receive(:provider).and_return(provider)
- end
-
- context 'without provider' do
- let(:provider) {}
-
- it { is_expected.to eq(false) }
- end
-
- context 'with provider' do
- let(:provider) { instance_double(Clusters::Providers::Aws, knative_pre_installed?: knative_pre_installed?) }
-
- context 'with knative_pre_installed? set to true' do
- let(:knative_pre_installed?) { true }
-
- it { is_expected.to eq(true) }
- end
-
- context 'with knative_pre_installed? set to false' do
- let(:knative_pre_installed?) { false }
-
- it { is_expected.to eq(false) }
- end
- end
- end
-
describe '#platform_kubernetes_active?' do
subject(:platform_kubernetes_active?) { cluster.platform_kubernetes_active? }
@@ -1533,94 +1375,4 @@ feature_category: :kubernetes_management do
end
end
end
-
- describe '#application_helm_available?' do
- subject(:application_helm_available?) { cluster.application_helm_available? }
-
- before do
- allow(cluster).to receive(:application_helm).and_return(application_helm)
- end
-
- context 'without application_helm' do
- let(:application_helm) {}
-
- it { is_expected.to eq(false) }
- end
-
- context 'with application_helm' do
- let(:application_helm) { instance_double(Clusters::Applications::Helm, available?: available?) }
-
- context 'with available? set to true' do
- let(:available?) { true }
-
- it { is_expected.to eq(true) }
- end
-
- context 'with available? set to false' do
- let(:available?) { false }
-
- it { is_expected.to eq(false) }
- end
- end
- end
-
- describe '#application_ingress_available?' do
- subject(:application_ingress_available?) { cluster.application_ingress_available? }
-
- before do
- allow(cluster).to receive(:application_ingress).and_return(application_ingress)
- end
-
- context 'without application_ingress' do
- let(:application_ingress) {}
-
- it { is_expected.to eq(false) }
- end
-
- context 'with application_ingress' do
- let(:application_ingress) { instance_double(Clusters::Applications::Ingress, available?: available?) }
-
- context 'with available? set to true' do
- let(:available?) { true }
-
- it { is_expected.to eq(true) }
- end
-
- context 'with available? set to false' do
- let(:available?) { false }
-
- it { is_expected.to eq(false) }
- end
- end
- end
-
- describe '#application_knative_available?' do
- subject(:application_knative_available?) { cluster.application_knative_available? }
-
- before do
- allow(cluster).to receive(:application_knative).and_return(application_knative)
- end
-
- context 'without application_knative' do
- let(:application_knative) {}
-
- it { is_expected.to eq(false) }
- end
-
- context 'with application_knative' do
- let(:application_knative) { instance_double(Clusters::Applications::Knative, available?: available?) }
-
- context 'with available? set to true' do
- let(:available?) { true }
-
- it { is_expected.to eq(true) }
- end
-
- context 'with available? set to false' do
- let(:available?) { false }
-
- it { is_expected.to eq(false) }
- end
- end
- end
end
diff --git a/spec/models/clusters/integrations/prometheus_spec.rb b/spec/models/clusters/integrations/prometheus_spec.rb
index d6d1105cdb1..f7ab0ae067c 100644
--- a/spec/models/clusters/integrations/prometheus_spec.rb
+++ b/spec/models/clusters/integrations/prometheus_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Clusters::Integrations::Prometheus do
describe 'after_destroy' do
subject(:integration) { create(:clusters_integrations_prometheus, cluster: cluster, enabled: true) }
- let(:cluster) { create(:cluster, :with_installed_helm) }
+ let(:cluster) { create(:cluster) }
it 'deactivates prometheus_integration' do
expect(Clusters::Applications::DeactivateIntegrationWorker)
@@ -41,7 +41,7 @@ RSpec.describe Clusters::Integrations::Prometheus do
describe 'after_save' do
subject(:integration) { create(:clusters_integrations_prometheus, cluster: cluster, enabled: enabled) }
- let(:cluster) { create(:cluster, :with_installed_helm) }
+ let(:cluster) { create(:cluster) }
let(:enabled) { true }
context 'when no change to enabled status' do
diff --git a/spec/models/clusters/kubernetes_namespace_spec.rb b/spec/models/clusters/kubernetes_namespace_spec.rb
index e70cd15baca..40d381e4dd6 100644
--- a/spec/models/clusters/kubernetes_namespace_spec.rb
+++ b/spec/models/clusters/kubernetes_namespace_spec.rb
@@ -69,10 +69,12 @@ RSpec.describe Clusters::KubernetesNamespace, type: :model do
context 'when cluster is using the namespace' do
before do
- create(:cluster_kubernetes_namespace,
- cluster: kubernetes_namespace.cluster,
- environment: kubernetes_namespace.environment,
- namespace: 'my-namespace')
+ create(
+ :cluster_kubernetes_namespace,
+ cluster: kubernetes_namespace.cluster,
+ environment: kubernetes_namespace.environment,
+ namespace: 'my-namespace'
+ )
end
it { is_expected.not_to be_valid }
diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb
index b280275c2e5..c32abaf50f5 100644
--- a/spec/models/clusters/platforms/kubernetes_spec.rb
+++ b/spec/models/clusters/platforms/kubernetes_spec.rb
@@ -198,10 +198,12 @@ RSpec.describe Clusters::Platforms::Kubernetes do
subject { kubernetes.kubeclient }
before do
- create(:cluster_kubernetes_namespace,
- cluster: kubernetes.cluster,
- cluster_project: kubernetes.cluster.cluster_project,
- project: kubernetes.cluster.cluster_project.project)
+ create(
+ :cluster_kubernetes_namespace,
+ cluster: kubernetes.cluster,
+ cluster_project: kubernetes.cluster.cluster_project,
+ project: kubernetes.cluster.cluster_project.project
+ )
end
it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::KubeClient) }
@@ -209,11 +211,13 @@ RSpec.describe Clusters::Platforms::Kubernetes do
context 'ca_pem is a single certificate' do
let(:ca_pem) { File.read(Rails.root.join('spec/fixtures/clusters/root_certificate.pem')) }
let(:kubernetes) do
- build(:cluster_platform_kubernetes,
- :configured,
- namespace: 'a-namespace',
- cluster: cluster,
- ca_pem: ca_pem)
+ build(
+ :cluster_platform_kubernetes,
+ :configured,
+ namespace: 'a-namespace',
+ cluster: cluster,
+ ca_pem: ca_pem
+ )
end
it 'adds it to cert_store' do
@@ -227,11 +231,13 @@ RSpec.describe Clusters::Platforms::Kubernetes do
context 'ca_pem is a chain' do
let(:cert_chain) { File.read(Rails.root.join('spec/fixtures/clusters/chain_certificates.pem')) }
let(:kubernetes) do
- build(:cluster_platform_kubernetes,
- :configured,
- namespace: 'a-namespace',
- cluster: cluster,
- ca_pem: cert_chain)
+ build(
+ :cluster_platform_kubernetes,
+ :configured,
+ namespace: 'a-namespace',
+ cluster: cluster,
+ ca_pem: cert_chain
+ )
end
where(:fixture_path) do
@@ -930,4 +936,13 @@ RSpec.describe Clusters::Platforms::Kubernetes do
end
end
end
+
+ describe '#authorization_type' do
+ subject(:kubernetes) { create(:cluster_platform_kubernetes) }
+
+ let(:attr) { :authorization_type }
+ let(:attr_value) { :unknown_authorization }
+
+ it_behaves_like 'having enum with nil value'
+ end
end
diff --git a/spec/models/commit_collection_spec.rb b/spec/models/commit_collection_spec.rb
index 6dd34c3e21f..1d2d89573bb 100644
--- a/spec/models/commit_collection_spec.rb
+++ b/spec/models/commit_collection_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CommitCollection do
+RSpec.describe CommitCollection, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:commit) { project.commit("c1c67abbaf91f624347bb3ae96eabe3a1b742478") }
@@ -45,6 +45,24 @@ RSpec.describe CommitCollection do
end
end
+ describe '#committer_user_ids' do
+ subject(:collection) { described_class.new(project, [commit]) }
+
+ it 'returns an array of committer user IDs' do
+ user = create(:user, email: commit.committer_email)
+
+ expect(collection.committer_user_ids).to contain_exactly(user.id)
+ end
+
+ context 'when there are no committers' do
+ subject(:collection) { described_class.new(project, []) }
+
+ it 'returns an empty array' do
+ expect(collection.committer_user_ids).to be_empty
+ end
+ end
+ end
+
describe '#without_merge_commits' do
it 'returns all commits except merge commits' do
merge_commit = project.commit("60ecb67744cb56576c30214ff52294f8ce2def98")
@@ -191,6 +209,19 @@ RSpec.describe CommitCollection do
end
end
+ describe '#load_tags' do
+ let(:gitaly_commit_with_tags) { project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
+ let(:collection) { described_class.new(project, [gitaly_commit_with_tags]) }
+
+ subject { collection.load_tags }
+
+ it 'loads tags' do
+ subject
+
+ expect(collection.commits[0].referenced_by).to contain_exactly('refs/tags/v1.1.0')
+ end
+ end
+
describe '#respond_to_missing?' do
it 'returns true when the underlying Array responds to the message' do
collection = described_class.new(project, [])
diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb
index 36d0e37454d..edb856d34df 100644
--- a/spec/models/commit_spec.rb
+++ b/spec/models/commit_spec.rb
@@ -92,9 +92,11 @@ RSpec.describe Commit do
end
it 'parses date strings into Time instances' do
- commit = described_class.build_from_sidekiq_hash(project,
- id: '123',
- authored_date: Time.current.to_s)
+ commit = described_class.build_from_sidekiq_hash(
+ project,
+ id: '123',
+ authored_date: Time.current.to_s
+ )
expect(commit.authored_date).to be_a_kind_of(Time)
end
@@ -551,18 +553,22 @@ eos
let(:repository) { project.repository }
let(:merge_request) do
- create(:merge_request,
- source_branch: 'video',
- target_branch: 'master',
- source_project: project,
- author: user)
+ create(
+ :merge_request,
+ source_branch: 'video',
+ target_branch: 'master',
+ source_project: project,
+ author: user
+ )
end
let(:merge_commit) do
- merge_commit_id = repository.merge(user,
- merge_request.diff_head_sha,
- merge_request,
- 'Test message')
+ merge_commit_id = repository.merge(
+ user,
+ merge_request.diff_head_sha,
+ merge_request,
+ 'Test message'
+ )
repository.commit(merge_commit_id)
end
@@ -640,17 +646,21 @@ eos
let(:user2) { build(:user) }
let!(:note1) do
- create(:note_on_commit,
- commit_id: commit.id,
- project: project,
- note: 'foo')
+ create(
+ :note_on_commit,
+ commit_id: commit.id,
+ project: project,
+ note: 'foo'
+ )
end
let!(:note2) do
- create(:note_on_commit,
- commit_id: commit.id,
- project: project,
- note: 'bar')
+ create(
+ :note_on_commit,
+ commit_id: commit.id,
+ project: project,
+ note: 'bar'
+ )
end
before do
@@ -854,11 +864,13 @@ eos
let(:issue) { create(:issue, author: user, project: project) }
it 'returns true if the commit has been reverted' do
- create(:note_on_issue,
- noteable: issue,
- system: true,
- note: commit.revert_description(user),
- project: issue.project)
+ create(
+ :note_on_issue,
+ noteable: issue,
+ system: true,
+ note: commit.revert_description(user),
+ project: issue.project
+ )
expect_next_instance_of(Commit) do |revert_commit|
expect(revert_commit).to receive(:reverts_commit?)
@@ -873,4 +885,94 @@ eos
expect(commit.has_been_reverted?(user, issue.notes_with_associations)).to eq(false)
end
end
+
+ describe '#tipping_refs' do
+ let_it_be(:tag_name) { 'v1.1.0' }
+ let_it_be(:branch_names) { %w[master not-merged-branch v1.1.0] }
+
+ shared_examples 'tipping ref names' do
+ context 'when called without limits' do
+ it 'return tipping refs names' do
+ expect(called_method.call).to eq(expected)
+ end
+ end
+
+ context 'when called with limits' do
+ it 'return tipping refs names' do
+ limit = 1
+ expect(called_method.call(limit).size).to be <= limit
+ end
+ end
+
+ describe '#tipping_branches' do
+ let(:called_method) { ->(limit = 0) { commit.tipping_branches(limit: limit) } }
+ let(:expected) { branch_names }
+
+ it_behaves_like 'with tipping ref names'
+ end
+
+ describe '#tipping_tags' do
+ let(:called_method) { ->(limit = 0) { commit.tipping_tags(limit: limit) } }
+ let(:expected) { [tag_name] }
+
+ it_behaves_like 'with tipping ref names'
+ end
+ end
+ end
+
+ context 'containing refs' do
+ shared_examples 'containing ref names' do
+ context 'without arguments' do
+ it 'returns branch names containing the commit' do
+ expect(ref_containing.call).to eq(containing_refs)
+ end
+ end
+
+ context 'with limit argument' do
+ it 'returns the appropriate amount branch names' do
+ limit = 2
+ expect(ref_containing.call(limit: limit).size).to be <= limit
+ end
+ end
+
+ context 'with tipping refs excluded' do
+ let(:excluded_refs) do
+ project.repository.refs_by_oid(oid: commit_sha, ref_patterns: [ref_prefix]).map { |n| n.delete_prefix(ref_prefix) }
+ end
+
+ it 'returns branch names containing the commit without the one with the commit at tip' do
+ expect(ref_containing.call(excluded_tipped: true)).to eq(containing_refs - excluded_refs)
+ end
+
+ it 'returns the appropriate amount branch names with limit argument' do
+ limit = 2
+ expect(ref_containing.call(limit: limit, excluded_tipped: true).size).to be <= limit
+ end
+ end
+ end
+
+ describe '#branches_containing' do
+ let_it_be(:commit_sha) { project.commit.sha }
+ let_it_be(:containing_refs) { project.repository.branch_names_contains(commit_sha) }
+
+ let(:ref_prefix) { Gitlab::Git::BRANCH_REF_PREFIX }
+
+ let(:ref_containing) { ->(limit: 0, excluded_tipped: false) { commit.branches_containing(exclude_tipped: excluded_tipped, limit: limit) } }
+
+ it_behaves_like 'containing ref names'
+ end
+
+ describe '#tags_containing' do
+ let_it_be(:tag_name) { 'v1.1.0' }
+ let_it_be(:commit_sha) { project.repository.find_tag(tag_name).target_commit.sha }
+ let_it_be(:containing_refs) { %w[v1.1.0 v1.1.1] }
+
+ let(:ref_prefix) { Gitlab::Git::TAG_REF_PREFIX }
+
+ let(:commit) { project.repository.commit(commit_sha) }
+ let(:ref_containing) { ->(limit: 0, excluded_tipped: false) { commit.tags_containing(exclude_tipped: excluded_tipped, limit: limit) } }
+
+ it_behaves_like 'containing ref names'
+ end
+ end
end
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index 4ff451af9de..38c45e8c975 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CommitStatus do
+RSpec.describe CommitStatus, feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:pipeline) do
@@ -17,7 +17,11 @@ RSpec.describe CommitStatus do
it_behaves_like 'having unique enum values'
- it { is_expected.to belong_to(:pipeline) }
+ it do
+ is_expected.to belong_to(:pipeline).class_name('Ci::Pipeline')
+ .with_foreign_key(:commit_id).inverse_of(:statuses)
+ end
+
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:auto_canceled_by) }
@@ -25,6 +29,11 @@ RSpec.describe CommitStatus do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_inclusion_of(:status).in_array(%w(pending running failed success canceled)) }
+ it { is_expected.to validate_length_of(:stage).is_at_most(255) }
+ it { is_expected.to validate_length_of(:ref).is_at_most(255) }
+ it { is_expected.to validate_length_of(:target_url).is_at_most(255) }
+ it { is_expected.to validate_length_of(:description).is_at_most(255) }
+
it { is_expected.to delegate_method(:sha).to(:pipeline) }
it { is_expected.to delegate_method(:short_sha).to(:pipeline) }
@@ -33,6 +42,7 @@ RSpec.describe CommitStatus do
it { is_expected.to respond_to :running? }
it { is_expected.to respond_to :pending? }
it { is_expected.not_to be_retried }
+ it { expect(described_class.primary_key).to eq('id') }
describe '#author' do
subject { commit_status.author }
@@ -422,29 +432,6 @@ RSpec.describe CommitStatus do
end
end
- describe '.exclude_ignored' do
- subject { described_class.exclude_ignored.order(:id) }
-
- let(:statuses) do
- [create_status(when: 'manual', status: 'skipped'),
- create_status(when: 'manual', status: 'success'),
- create_status(when: 'manual', status: 'failed'),
- create_status(when: 'on_failure', status: 'skipped'),
- create_status(when: 'on_failure', status: 'success'),
- create_status(when: 'on_failure', status: 'failed'),
- create_status(allow_failure: true, status: 'success'),
- create_status(allow_failure: true, status: 'failed'),
- create_status(allow_failure: false, status: 'success'),
- create_status(allow_failure: false, status: 'failed'),
- create_status(allow_failure: true, status: 'manual'),
- create_status(allow_failure: false, status: 'manual')]
- end
-
- it 'returns statuses without what we want to ignore' do
- is_expected.to eq(statuses.values_at(0, 1, 2, 3, 4, 5, 6, 8, 9, 11))
- end
- end
-
describe '.failed_but_allowed' do
subject { described_class.failed_but_allowed.order(:id) }
@@ -578,6 +565,15 @@ RSpec.describe CommitStatus do
end
end
+ describe '.with_type' do
+ let_it_be(:build_job) { create_status(name: 'build job', type: ::Ci::Build) }
+ let_it_be(:bridge_job) { create_status(name: 'bridge job', type: ::Ci::Bridge) }
+
+ it 'returns statuses that match type' do
+ expect(described_class.with_type(::Ci::Build)).to contain_exactly(have_attributes(name: 'build job'))
+ end
+ end
+
describe '#before_sha' do
subject { commit_status.before_sha }
@@ -1062,4 +1058,13 @@ RSpec.describe CommitStatus do
end
end
end
+
+ describe '#failure_reason' do
+ subject(:status) { commit_status }
+
+ let(:attr) { :failure_reason }
+ let(:attr_value) { :unknown_failure }
+
+ it_behaves_like 'having enum with nil value'
+ end
end
diff --git a/spec/models/compare_spec.rb b/spec/models/compare_spec.rb
index dc8429fe77e..2206ed7bfe8 100644
--- a/spec/models/compare_spec.rb
+++ b/spec/models/compare_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Compare do
+RSpec.describe Compare, feature_category: :source_code_management do
include RepoHelpers
let(:project) { create(:project, :public, :repository) }
@@ -10,10 +10,11 @@ RSpec.describe Compare do
let(:start_commit) { sample_image_commit }
let(:head_commit) { sample_commit }
+ let(:straight) { false }
let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, start_commit.id, head_commit.id) }
- subject(:compare) { described_class.new(raw_compare, project) }
+ subject(:compare) { described_class.new(raw_compare, project, straight: straight) }
describe '#cache_key' do
subject { compare.cache_key }
@@ -147,4 +148,33 @@ RSpec.describe Compare do
end
end
end
+
+ describe '#to_param' do
+ subject { compare.to_param }
+
+ let(:start_commit) { another_sample_commit }
+ let(:base_commit) { head_commit }
+
+ it 'returns the range between base and head commits' do
+ is_expected.to eq(from: base_commit.id, to: head_commit.id)
+ end
+
+ context 'when straight mode is on' do
+ let(:straight) { true }
+
+ it 'returns the range between start and head commits' do
+ is_expected.to eq(from: start_commit.id, to: head_commit.id)
+ end
+ end
+
+ context 'when there are no merge base between commits' do
+ before do
+ allow(project).to receive(:merge_base_commit).and_return(nil)
+ end
+
+ it 'returns the range between start and head commits' do
+ is_expected.to eq(from: start_commit.id, to: head_commit.id)
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/atomic_internal_id_spec.rb b/spec/models/concerns/atomic_internal_id_spec.rb
index 5fe3141eb17..625d8fec0fb 100644
--- a/spec/models/concerns/atomic_internal_id_spec.rb
+++ b/spec/models/concerns/atomic_internal_id_spec.rb
@@ -250,11 +250,104 @@ RSpec.describe AtomicInternalId do
end
end
- describe '.track_project_iid!' do
+ describe '.track_namespace_iid!' do
it 'tracks the present value' do
expect do
- ::Issue.track_project_iid!(milestone.project, external_iid)
- end.to change { InternalId.find_by(project: milestone.project, usage: :issues)&.last_value.to_i }.to(external_iid)
+ ::Issue.track_namespace_iid!(milestone.project.project_namespace, external_iid)
+ end.to change {
+ InternalId.find_by(namespace: milestone.project.project_namespace, usage: :issues)&.last_value.to_i
+ }.to(external_iid)
+ end
+ end
+
+ context 'when transitioning a model from one scope to another' do
+ let!(:issue) { build(:issue, project: project) }
+ let(:old_issue_model) do
+ Class.new(ApplicationRecord) do
+ include AtomicInternalId
+
+ self.table_name = :issues
+
+ belongs_to :project
+ belongs_to :namespace
+
+ has_internal_id :iid, scope: :project
+
+ def self.name
+ 'TestClassA'
+ end
+ end
+ end
+
+ let(:old_issue_instance) { old_issue_model.new(issue.attributes) }
+ let(:new_issue_instance) { Issue.new(issue.attributes) }
+
+ it 'generates the iid on the new scope' do
+ # set a random iid, just so that it does not start at 1
+ old_issue_instance.iid = 123
+ old_issue_instance.save!
+
+ # creating a new old_issue_instance increments the iid.
+ expect { old_issue_model.new(issue.attributes).save! }.to change {
+ InternalId.find_by(project: project, usage: :issues)&.last_value.to_i
+ }.from(123).to(124).and(not_change { InternalId.count })
+
+ # creating a new Issue creates a new record in internal_ids, scoped to the namespace.
+ # Given the Issue#has_internal_id -> init definition the internal_ids#last_value would be the
+ # maximum between the old iid value in internal_ids, scoped to the project and max(iid) value from issues
+ # table by namespace_id.
+ # see Issue#has_internal_id
+ expect { new_issue_instance.save! }.to change {
+ InternalId.find_by(namespace: project.project_namespace, usage: :issues)&.last_value.to_i
+ }.to(125).and(change { InternalId.count }.by(1))
+
+ # transition back to project scope would generate overlapping IIDs and raise a duplicate key value error, unless
+ # we cleanup the issues usage scoped to the project first
+ expect { old_issue_model.new(issue.attributes).save! }.to raise_error(ActiveRecord::RecordNotUnique)
+
+ # delete issues usage scoped to te project
+ InternalId.where(project: project, usage: :issues).delete_all
+
+ expect { old_issue_model.new(issue.attributes).save! }.to change {
+ InternalId.find_by(project: project, usage: :issues)&.last_value.to_i
+ }.to(126).and(change { InternalId.count }.by(1))
+ end
+ end
+
+ context 'when models is scoped to namespace and does not have an init proc' do
+ let!(:issue) { build(:issue, namespace: create(:group)) }
+
+ let(:issue_model) do
+ Class.new(ApplicationRecord) do
+ include AtomicInternalId
+
+ self.table_name = :issues
+
+ belongs_to :project
+ belongs_to :namespace
+
+ has_internal_id :iid, scope: :namespace
+
+ def self.name
+ 'TestClass'
+ end
+ end
+ end
+
+ let(:model_instance) { issue_model.new(issue.attributes) }
+
+ it 'generates the iid on the new scope' do
+ expect { model_instance.save! }.to change {
+ InternalId.find_by(namespace: model_instance.namespace, usage: :issues)&.last_value.to_i
+ }.to(1).and(change { InternalId.count }.by(1))
+ end
+
+ it 'supplies a stream of iid values' do
+ expect do
+ issue_model.with_namespace_iid_supply(model_instance.namespace) do |supply|
+ 4.times { supply.next_value }
+ end
+ end.to change { InternalId.find_by(namespace: model_instance.namespace, usage: :issues)&.last_value.to_i }.by(4)
end
end
end
diff --git a/spec/models/concerns/awareness_spec.rb b/spec/models/concerns/awareness_spec.rb
deleted file mode 100644
index 67acacc7bb1..00000000000
--- a/spec/models/concerns/awareness_spec.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Awareness, :clean_gitlab_redis_shared_state do
- subject { create(:user) }
-
- let(:session) { AwarenessSession.for(1) }
-
- describe "when joining a session" do
- it "increases the number of sessions" do
- expect { subject.join(session) }
- .to change { subject.session_ids.size }
- .by(1)
- end
- end
-
- describe "when leaving session" do
- it "decreases the number of sessions" do
- subject.join(session)
-
- expect { subject.leave(session) }
- .to change { subject.session_ids.size }
- .by(-1)
- end
- end
-
- describe "when joining multiple sessions" do
- let(:session2) { AwarenessSession.for(2) }
-
- it "increases number of active sessions for user" do
- expect do
- subject.join(session)
- subject.join(session2)
- end.to change { subject.session_ids.size }
- .by(2)
- end
- end
-end
diff --git a/spec/models/concerns/ci/has_status_spec.rb b/spec/models/concerns/ci/has_status_spec.rb
index 4ef690ca4c1..5e0a430aa13 100644
--- a/spec/models/concerns/ci/has_status_spec.rb
+++ b/spec/models/concerns/ci/has_status_spec.rb
@@ -393,24 +393,26 @@ RSpec.describe Ci::HasStatus, feature_category: :continuous_integration do
subject { object.blocked? }
%w[ci_pipeline ci_stage ci_build generic_commit_status].each do |type|
- let(:object) { build(type, status: status) }
+ context "when #{type}" do
+ let(:object) { build(type, status: status) }
- context 'when status is scheduled' do
- let(:status) { :scheduled }
+ context 'when status is scheduled' do
+ let(:status) { :scheduled }
- it { is_expected.to be_truthy }
- end
+ it { is_expected.to be_truthy }
+ end
- context 'when status is manual' do
- let(:status) { :manual }
+ context 'when status is manual' do
+ let(:status) { :manual }
- it { is_expected.to be_truthy }
- end
+ it { is_expected.to be_truthy }
+ end
- context 'when status is created' do
- let(:status) { :created }
+ context 'when status is created' do
+ let(:status) { :created }
- it { is_expected.to be_falsy }
+ it { is_expected.to be_falsy }
+ end
end
end
end
diff --git a/spec/models/concerns/ci/maskable_spec.rb b/spec/models/concerns/ci/maskable_spec.rb
index b57b2b15608..6e648a39f8f 100644
--- a/spec/models/concerns/ci/maskable_spec.rb
+++ b/spec/models/concerns/ci/maskable_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::Maskable, feature_category: :pipeline_authoring do
+RSpec.describe Ci::Maskable, feature_category: :secrets_management do
let(:variable) { build(:ci_variable) }
describe 'masked value validations' do
diff --git a/spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb b/spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb
deleted file mode 100644
index bb25d7d1665..00000000000
--- a/spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb
+++ /dev/null
@@ -1,80 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Ci::Partitionable::PartitionedFilter, :aggregate_failures, feature_category: :continuous_integration do
- before do
- create_tables(<<~SQL)
- CREATE TABLE _test_ci_jobs_metadata (
- id serial NOT NULL,
- partition_id int NOT NULL DEFAULT 10,
- name text,
- PRIMARY KEY (id, partition_id)
- ) PARTITION BY LIST(partition_id);
-
- CREATE TABLE _test_ci_jobs_metadata_1
- PARTITION OF _test_ci_jobs_metadata
- FOR VALUES IN (10);
- SQL
- end
-
- let(:model) do
- Class.new(Ci::ApplicationRecord) do
- include Ci::Partitionable::PartitionedFilter
-
- self.primary_key = :id
- self.table_name = :_test_ci_jobs_metadata
-
- def self.name
- 'TestCiJobMetadata'
- end
- end
- end
-
- let!(:record) { model.create! }
-
- let(:where_filter) do
- /WHERE "_test_ci_jobs_metadata"."id" = #{record.id} AND "_test_ci_jobs_metadata"."partition_id" = 10/
- end
-
- describe '#save' do
- it 'uses id and partition_id' do
- record.name = 'test'
- recorder = ActiveRecord::QueryRecorder.new { record.save! }
-
- expect(recorder.log).to include(where_filter)
- expect(record.name).to eq('test')
- end
- end
-
- describe '#update' do
- it 'uses id and partition_id' do
- recorder = ActiveRecord::QueryRecorder.new { record.update!(name: 'test') }
-
- expect(recorder.log).to include(where_filter)
- expect(record.name).to eq('test')
- end
- end
-
- describe '#delete' do
- it 'uses id and partition_id' do
- recorder = ActiveRecord::QueryRecorder.new { record.delete }
-
- expect(recorder.log).to include(where_filter)
- expect(model.count).to be_zero
- end
- end
-
- describe '#destroy' do
- it 'uses id and partition_id' do
- recorder = ActiveRecord::QueryRecorder.new { record.destroy! }
-
- expect(recorder.log).to include(where_filter)
- expect(model.count).to be_zero
- end
- end
-
- def create_tables(table_sql)
- Ci::ApplicationRecord.connection.execute(table_sql)
- end
-end
diff --git a/spec/models/concerns/ci/partitionable/switch_spec.rb b/spec/models/concerns/ci/partitionable/switch_spec.rb
index d955ad223f8..551ae111fa4 100644
--- a/spec/models/concerns/ci/partitionable/switch_spec.rb
+++ b/spec/models/concerns/ci/partitionable/switch_spec.rb
@@ -59,13 +59,14 @@ RSpec.describe Ci::Partitionable::Switch, :aggregate_failures do
model.include(Ci::Partitionable)
model.partitionable scope: ->(r) { 1 },
- through: { table: :_test_p_ci_jobs_metadata, flag: table_rollout_flag }
+ through: { table: :_test_p_ci_jobs_metadata, flag: table_rollout_flag }
model.belongs_to :job, anonymous_class: jobs_model
- jobs_model.has_one :metadata, anonymous_class: model,
- foreign_key: :job_id, inverse_of: :job,
- dependent: :destroy
+ jobs_model.has_one :metadata,
+ anonymous_class: model,
+ foreign_key: :job_id, inverse_of: :job,
+ dependent: :destroy
allow(Feature::Definition).to receive(:get).and_call_original
allow(Feature::Definition).to receive(:get).with(table_rollout_flag)
diff --git a/spec/models/concerns/ci/partitionable_spec.rb b/spec/models/concerns/ci/partitionable_spec.rb
index 430ef57d493..d41654e547e 100644
--- a/spec/models/concerns/ci/partitionable_spec.rb
+++ b/spec/models/concerns/ci/partitionable_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe Ci::Partitionable do
ci_model.include(described_class)
ci_model.partitionable scope: ->(r) { 1 },
- through: { table: :_test_table_name, flag: :some_flag }
+ through: { table: :_test_table_name, flag: :some_flag }
end
it { expect(ci_model.routing_table_name).to eq(:_test_table_name) }
@@ -52,16 +52,16 @@ RSpec.describe Ci::Partitionable do
context 'when partitioned is true' do
let(:partitioned) { true }
- it { expect(ci_model.ancestors).to include(described_class::PartitionedFilter) }
- it { expect(ci_model).to be_partitioned }
+ it { expect(ci_model.ancestors).to include(PartitionedTable) }
+ it { expect(ci_model.partitioning_strategy).to be_a(Gitlab::Database::Partitioning::CiSlidingListStrategy) }
+ it { expect(ci_model.partitioning_strategy.partitioning_key).to eq(:partition_id) }
end
context 'when partitioned is false' do
let(:partitioned) { false }
- it { expect(ci_model.ancestors).not_to include(described_class::PartitionedFilter) }
-
- it { expect(ci_model).not_to be_partitioned }
+ it { expect(ci_model.ancestors).not_to include(PartitionedTable) }
+ it { expect(ci_model).not_to respond_to(:partitioning_strategy) }
end
end
end
diff --git a/spec/models/concerns/ci/track_environment_usage_spec.rb b/spec/models/concerns/ci/track_environment_usage_spec.rb
index d75972c49b5..ad89973eee5 100644
--- a/spec/models/concerns/ci/track_environment_usage_spec.rb
+++ b/spec/models/concerns/ci/track_environment_usage_spec.rb
@@ -8,10 +8,12 @@ RSpec.describe Ci::TrackEnvironmentUsage do
context 'when build is the verify action for the environment' do
let(:build) do
- build_stubbed(:ci_build,
- ref: 'master',
- environment: 'staging',
- options: { environment: { action: 'verify' } })
+ build_stubbed(
+ :ci_build,
+ ref: 'master',
+ environment: 'staging',
+ options: { environment: { action: 'verify' } }
+ )
end
it { is_expected.to be_truthy }
@@ -19,10 +21,12 @@ RSpec.describe Ci::TrackEnvironmentUsage do
context 'when build is not the verify action for the environment' do
let(:build) do
- build_stubbed(:ci_build,
- ref: 'master',
- environment: 'staging',
- options: { environment: { action: 'start' } })
+ build_stubbed(
+ :ci_build,
+ ref: 'master',
+ environment: 'staging',
+ options: { environment: { action: 'start' } }
+ )
end
it { is_expected.to be_falsey }
diff --git a/spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb b/spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb
deleted file mode 100644
index a4d1a33b3d5..00000000000
--- a/spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Agents::AuthorizationConfigScopes do
- describe '.with_available_ci_access_fields' do
- let(:project) { create(:project) }
-
- let!(:agent_authorization_0) { create(:agent_project_authorization, project: project) }
- let!(:agent_authorization_1) { create(:agent_project_authorization, project: project, config: { access_as: {} }) }
- let!(:agent_authorization_2) { create(:agent_project_authorization, project: project, config: { access_as: { agent: {} } }) }
- let!(:impersonate_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { impersonate: {} } }) }
- let!(:ci_user_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { ci_user: {} } }) }
- let!(:ci_job_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { ci_job: {} } }) }
- let!(:unexpected_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { unexpected: {} } }) }
-
- subject { Clusters::Agents::ProjectAuthorization.with_available_ci_access_fields(project) }
-
- it { is_expected.to contain_exactly(agent_authorization_0, agent_authorization_1, agent_authorization_2) }
- end
-end
diff --git a/spec/models/concerns/clusters/agents/authorizations/ci_access/config_scopes_spec.rb b/spec/models/concerns/clusters/agents/authorizations/ci_access/config_scopes_spec.rb
new file mode 100644
index 00000000000..5c69ede11fc
--- /dev/null
+++ b/spec/models/concerns/clusters/agents/authorizations/ci_access/config_scopes_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::CiAccess::ConfigScopes, feature_category: :deployment_management do
+ describe '.with_available_ci_access_fields' do
+ let(:project) { create(:project) }
+
+ let!(:agent_authorization_0) { create(:agent_ci_access_project_authorization, project: project) }
+ let!(:agent_authorization_1) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: {} }) }
+ let!(:agent_authorization_2) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { agent: {} } }) }
+ let!(:impersonate_authorization) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { impersonate: {} } }) }
+ let!(:ci_user_authorization) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { ci_user: {} } }) }
+ let!(:ci_job_authorization) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { ci_job: {} } }) }
+ let!(:unexpected_authorization) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { unexpected: {} } }) }
+
+ subject { Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization.with_available_ci_access_fields(project) }
+
+ it { is_expected.to contain_exactly(agent_authorization_0, agent_authorization_1, agent_authorization_2) }
+ end
+end
diff --git a/spec/models/concerns/clusters/agents/authorizations/user_access/scopes_spec.rb b/spec/models/concerns/clusters/agents/authorizations/user_access/scopes_spec.rb
new file mode 100644
index 00000000000..9da7dde0afd
--- /dev/null
+++ b/spec/models/concerns/clusters/agents/authorizations/user_access/scopes_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::UserAccess::Scopes, feature_category: :deployment_management do
+ describe '.for_agent' do
+ let_it_be(:agent_1) { create(:cluster_agent) }
+ let_it_be(:agent_2) { create(:cluster_agent) }
+ let_it_be(:authorization_1) { create(:agent_user_access_project_authorization, agent: agent_1) }
+ let_it_be(:authorization_2) { create(:agent_user_access_project_authorization, agent: agent_2) }
+
+ subject { Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization.for_agent(agent_1) }
+
+ it { is_expected.to contain_exactly(authorization_1) }
+ end
+
+ describe '.preloaded' do
+ let_it_be(:authorization) { create(:agent_user_access_project_authorization) }
+
+ subject { Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization.preloaded }
+
+ it 'preloads the associated entities' do
+ expect(subject.first.association(:agent)).to be_loaded
+ expect(subject.first.agent.association(:project)).to be_loaded
+ end
+ end
+end
diff --git a/spec/models/concerns/database_event_tracking_spec.rb b/spec/models/concerns/database_event_tracking_spec.rb
index 976462b4174..502cecaaf76 100644
--- a/spec/models/concerns/database_event_tracking_spec.rb
+++ b/spec/models/concerns/database_event_tracking_spec.rb
@@ -3,6 +3,10 @@
require 'spec_helper'
RSpec.describe DatabaseEventTracking, :snowplow do
+ before do
+ allow(Gitlab::Tracking).to receive(:database_event).and_call_original
+ end
+
let(:test_class) do
Class.new(ActiveRecord::Base) do
include DatabaseEventTracking
@@ -17,7 +21,7 @@ RSpec.describe DatabaseEventTracking, :snowplow do
context 'if event emmiter failed' do
before do
- allow(Gitlab::Tracking).to receive(:event).and_raise(StandardError) # rubocop:disable RSpec/ExpectGitlabTracking
+ allow(Gitlab::Tracking).to receive(:database_event).and_raise(StandardError) # rubocop:disable RSpec/ExpectGitlabTracking
end
it 'tracks the exception' do
@@ -35,7 +39,7 @@ RSpec.describe DatabaseEventTracking, :snowplow do
it 'does not track the event' do
create_test_class_record
- expect_no_snowplow_event
+ expect_no_snowplow_event(tracking_method: :database_event)
end
end
@@ -46,24 +50,48 @@ RSpec.describe DatabaseEventTracking, :snowplow do
it 'when created' do
create_test_class_record
- expect_snowplow_event(category: category, action: "#{event}_create", label: 'application_setting_terms',
- property: 'create', namespace: nil, "id" => 1)
+ expect_snowplow_event(
+ tracking_method: :database_event,
+ category: category,
+ action: "#{event}_create",
+ label: 'application_setting_terms',
+ property: 'create',
+ namespace: nil,
+ project: nil,
+ "id" => 1
+ )
end
it 'when updated' do
create_test_class_record
test_class.first.update!(id: 3)
- expect_snowplow_event(category: category, action: "#{event}_update", label: 'application_setting_terms',
- property: 'update', namespace: nil, "id" => 3)
+ expect_snowplow_event(
+ tracking_method: :database_event,
+ category: category,
+ action: "#{event}_update",
+ label: 'application_setting_terms',
+ property: 'update',
+ namespace: nil,
+ project: nil,
+ "id" => 3
+ )
end
it 'when destroyed' do
create_test_class_record
test_class.first.destroy!
- expect_snowplow_event(category: category, action: "#{event}_destroy", label: 'application_setting_terms',
- property: 'destroy', namespace: nil, "id" => 1)
+ expect_snowplow_event(
+ tracking_method: :database_event,
+ category: category,
+ action: "#{event}_destroy",
+ label: 'application_setting_terms',
+ property: 'destroy',
+ namespace: nil,
+ project: nil,
+ "id" => 1
+ )
end
end
end
diff --git a/spec/models/concerns/deployment_platform_spec.rb b/spec/models/concerns/deployment_platform_spec.rb
index bd1afe844ac..9b086e9785e 100644
--- a/spec/models/concerns/deployment_platform_spec.rb
+++ b/spec/models/concerns/deployment_platform_spec.rb
@@ -56,13 +56,23 @@ RSpec.describe DeploymentPlatform do
context 'when project does not have a cluster but has group clusters' do
let!(:default_cluster) do
- create(:cluster, :provided_by_user,
- cluster_type: :group_type, groups: [group], environment_scope: '*')
+ create(
+ :cluster,
+ :provided_by_user,
+ cluster_type: :group_type,
+ groups: [group],
+ environment_scope: '*'
+ )
end
let!(:cluster) do
- create(:cluster, :provided_by_user,
- cluster_type: :group_type, environment_scope: 'review/*', groups: [group])
+ create(
+ :cluster,
+ :provided_by_user,
+ cluster_type: :group_type,
+ environment_scope: 'review/*',
+ groups: [group]
+ )
end
let(:environment) { 'review/name' }
@@ -99,8 +109,13 @@ RSpec.describe DeploymentPlatform do
context 'when parent_group has a cluster with default scope' do
let!(:parent_group_cluster) do
- create(:cluster, :provided_by_user,
- cluster_type: :group_type, environment_scope: '*', groups: [parent_group])
+ create(
+ :cluster,
+ :provided_by_user,
+ cluster_type: :group_type,
+ environment_scope: '*',
+ groups: [parent_group]
+ )
end
it_behaves_like 'matching environment scope'
@@ -108,8 +123,13 @@ RSpec.describe DeploymentPlatform do
context 'when parent_group has a cluster that is an exact match' do
let!(:parent_group_cluster) do
- create(:cluster, :provided_by_user,
- cluster_type: :group_type, environment_scope: 'review/name', groups: [parent_group])
+ create(
+ :cluster,
+ :provided_by_user,
+ cluster_type: :group_type,
+ environment_scope: 'review/name',
+ groups: [parent_group]
+ )
end
it_behaves_like 'matching environment scope'
@@ -160,8 +180,13 @@ RSpec.describe DeploymentPlatform do
let!(:cluster) { create(:cluster, :provided_by_user, environment_scope: 'review/*', projects: [project]) }
let!(:group_default_cluster) do
- create(:cluster, :provided_by_user,
- cluster_type: :group_type, groups: [group], environment_scope: '*')
+ create(
+ :cluster,
+ :provided_by_user,
+ cluster_type: :group_type,
+ groups: [group],
+ environment_scope: '*'
+ )
end
let(:environment) { 'review/name' }
diff --git a/spec/models/concerns/each_batch_spec.rb b/spec/models/concerns/each_batch_spec.rb
index 2c75d4d5c41..75c5cac899b 100644
--- a/spec/models/concerns/each_batch_spec.rb
+++ b/spec/models/concerns/each_batch_spec.rb
@@ -171,4 +171,36 @@ RSpec.describe EachBatch do
end
end
end
+
+ describe '.each_batch_count' do
+ let_it_be(:users) { create_list(:user, 5, updated_at: 1.day.ago) }
+
+ it 'counts the records' do
+ count, last_value = User.each_batch_count
+
+ expect(count).to eq(5)
+ expect(last_value).to eq(nil)
+ end
+
+ context 'when using a different column' do
+ it 'returns correct count' do
+ count, _ = User.each_batch_count(column: :email, of: 2)
+
+ expect(count).to eq(5)
+ end
+ end
+
+ context 'when stopping and resuming the counting' do
+ it 'returns the correct count' do
+ count, last_value = User.each_batch_count(of: 1) do |current_count, _current_value|
+ current_count == 3 # stop when count reaches 3
+ end
+
+ expect(count).to eq(3)
+
+ final_count, _ = User.each_batch_count(of: 1, last_value: last_value, last_count: count)
+ expect(final_count).to eq(5)
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/expirable_spec.rb b/spec/models/concerns/expirable_spec.rb
index 50dfb138ac9..68a25917ce1 100644
--- a/spec/models/concerns/expirable_spec.rb
+++ b/spec/models/concerns/expirable_spec.rb
@@ -3,40 +3,52 @@
require 'spec_helper'
RSpec.describe Expirable do
- describe 'ProjectMember' do
- let_it_be(:no_expire) { create(:project_member) }
- let_it_be(:expire_later) { create(:project_member, expires_at: 8.days.from_now) }
- let_it_be(:expired) { create(:project_member, expires_at: 1.day.from_now) }
+ let_it_be(:no_expire) { create(:project_member) }
+ let_it_be(:expire_later) { create(:project_member, expires_at: 8.days.from_now) }
+ let_it_be(:expired) { create(:project_member, expires_at: 1.day.from_now) }
- before do
- travel_to(3.days.from_now)
- end
+ before do
+ travel_to(3.days.from_now)
+ end
- describe '.expired' do
- it { expect(ProjectMember.expired).to match_array([expired]) }
- end
+ describe '.expired' do
+ it { expect(ProjectMember.expired).to match_array([expired]) }
- describe '.not_expired' do
- it { expect(ProjectMember.not_expired).to include(no_expire, expire_later) }
- it { expect(ProjectMember.not_expired).not_to include(expired) }
- end
+ it 'scopes the query when multiple models are expirable' do
+ expired_access_token = create(:personal_access_token, :expired, user: no_expire.user)
- describe '#expired?' do
- it { expect(no_expire.expired?).to eq(false) }
- it { expect(expire_later.expired?).to eq(false) }
- it { expect(expired.expired?).to eq(true) }
+ expect(PersonalAccessToken.expired.joins(user: :members)).to match_array([expired_access_token])
+ expect(PersonalAccessToken.joins(user: :members).merge(ProjectMember.expired)).to eq([])
end
- describe '#expires?' do
- it { expect(no_expire.expires?).to eq(false) }
- it { expect(expire_later.expires?).to eq(true) }
- it { expect(expired.expires?).to eq(true) }
- end
+ it 'works with a timestamp expired_at field', time_travel_to: '2022-03-14T11:30:00Z' do
+ expired_deploy_token = create(:deploy_token, expires_at: 5.minutes.ago.iso8601)
- describe '#expires_soon?' do
- it { expect(no_expire.expires_soon?).to eq(false) }
- it { expect(expire_later.expires_soon?).to eq(true) }
- it { expect(expired.expires_soon?).to eq(true) }
+ # Here verify that `expires_at` in the SQL uses `Time.current` instead of `Date.current`
+ expect(DeployToken.expired).to match_array([expired_deploy_token])
end
end
+
+ describe '.not_expired' do
+ it { expect(ProjectMember.not_expired).to include(no_expire, expire_later) }
+ it { expect(ProjectMember.not_expired).not_to include(expired) }
+ end
+
+ describe '#expired?' do
+ it { expect(no_expire.expired?).to eq(false) }
+ it { expect(expire_later.expired?).to eq(false) }
+ it { expect(expired.expired?).to eq(true) }
+ end
+
+ describe '#expires?' do
+ it { expect(no_expire.expires?).to eq(false) }
+ it { expect(expire_later.expires?).to eq(true) }
+ it { expect(expired.expires?).to eq(true) }
+ end
+
+ describe '#expires_soon?' do
+ it { expect(no_expire.expires_soon?).to eq(false) }
+ it { expect(expire_later.expires_soon?).to eq(true) }
+ it { expect(expired.expires_soon?).to eq(true) }
+ end
end
diff --git a/spec/models/concerns/has_user_type_spec.rb b/spec/models/concerns/has_user_type_spec.rb
index bd128112113..b5abd114f9a 100644
--- a/spec/models/concerns/has_user_type_spec.rb
+++ b/spec/models/concerns/has_user_type_spec.rb
@@ -2,18 +2,19 @@
require 'spec_helper'
-RSpec.describe User do
+RSpec.describe User, feature_category: :system_access do
specify 'types consistency checks', :aggregate_failures do
expect(described_class::USER_TYPES.keys)
- .to match_array(%w[human ghost alert_bot project_bot support_bot service_user security_bot visual_review_bot
- migration_bot automation_bot admin_bot suggested_reviewers_bot])
+ .to match_array(%w[human human_deprecated ghost alert_bot project_bot support_bot service_user security_bot
+ visual_review_bot migration_bot automation_bot security_policy_bot admin_bot suggested_reviewers_bot
+ service_account llm_bot])
expect(described_class::USER_TYPES).to include(*described_class::BOT_USER_TYPES)
expect(described_class::USER_TYPES).to include(*described_class::NON_INTERNAL_USER_TYPES)
expect(described_class::USER_TYPES).to include(*described_class::INTERNAL_USER_TYPES)
end
describe 'scopes & predicates' do
- User::USER_TYPES.keys.each do |type|
+ User::USER_TYPES.keys.each do |type| # rubocop:disable RSpec/UselessDynamicDefinition
let_it_be(type) { create(:user, username: type, user_type: type) }
end
let(:bots) { User::BOT_USER_TYPES.map { |type| public_send(type) } }
@@ -22,7 +23,13 @@ RSpec.describe User do
describe '.humans' do
it 'includes humans only' do
- expect(described_class.humans).to match_array([human])
+ expect(described_class.humans).to match_array([human, human_deprecated])
+ end
+ end
+
+ describe '.human' do
+ it 'includes humans only' do
+ expect(described_class.human).to match_array([human, human_deprecated])
end
end
@@ -69,6 +76,7 @@ RSpec.describe User do
describe '#human?' do
it 'is true for humans only' do
expect(human).to be_human
+ expect(human_deprecated).to be_human
expect(alert_bot).not_to be_human
expect(User.new).to be_human
end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 206b3ae61cf..e11c7e98287 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -150,8 +150,10 @@ RSpec.describe Issuable do
end
it 'gives preference to state_id if present' do
- issuable = MergeRequest.new('state' => 'opened',
- 'state_id' => described_class::STATE_ID_MAP['merged'])
+ issuable = MergeRequest.new(
+ 'state' => 'opened',
+ 'state_id' => described_class::STATE_ID_MAP['merged']
+ )
expect(issuable.state).to eq('merged')
expect(issuable.state_id).to eq(described_class::STATE_ID_MAP['merged'])
@@ -858,22 +860,16 @@ RSpec.describe Issuable do
end
end
- describe '#first_contribution?' do
+ describe '#first_contribution?', feature_category: :code_review_workflow do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:other_project) { create(:project) }
- let(:owner) { create(:owner) }
- let(:maintainer) { create(:user) }
- let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:contributor) { create(:user) }
let(:first_time_contributor) { create(:user) }
before do
- group.add_owner(owner)
- project.add_maintainer(maintainer)
- project.add_reporter(reporter)
project.add_guest(guest)
project.add_guest(contributor)
project.add_guest(first_time_contributor)
@@ -884,24 +880,6 @@ RSpec.describe Issuable do
let(:merged_mr_other_project) { create(:merge_request, :merged, author: first_time_contributor, target_project: other_project, source_project: other_project) }
context "for merge requests" do
- it "is false for MAINTAINER" do
- mr = create(:merge_request, author: maintainer, target_project: project, source_project: project)
-
- expect(mr).not_to be_first_contribution
- end
-
- it "is false for OWNER" do
- mr = create(:merge_request, author: owner, target_project: project, source_project: project)
-
- expect(mr).not_to be_first_contribution
- end
-
- it "is false for REPORTER" do
- mr = create(:merge_request, author: reporter, target_project: project, source_project: project)
-
- expect(mr).not_to be_first_contribution
- end
-
it "is true when you don't have any merged MR" do
expect(open_mr).to be_first_contribution
expect(merged_mr).not_to be_first_contribution
@@ -998,7 +976,7 @@ RSpec.describe Issuable do
end
end
- describe '#incident?' do
+ describe '#incident_type_issue?' do
where(:issuable_type, :incident) do
:issue | false
:incident | true
@@ -1008,7 +986,7 @@ RSpec.describe Issuable do
with_them do
let(:issuable) { build_stubbed(issuable_type) }
- subject { issuable.incident? }
+ subject { issuable.incident_type_issue? }
it { is_expected.to eq(incident) }
end
diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb
index 7bbbd10ec8d..d9e53fb7e9a 100644
--- a/spec/models/concerns/mentionable_spec.rb
+++ b/spec/models/concerns/mentionable_spec.rb
@@ -225,7 +225,7 @@ RSpec.describe Commit, 'Mentionable' do
end
context 'with external issue tracker' do
- let(:project) { create(:project, :with_jira_integration, :repository) }
+ let_it_be(:project) { create(:project, :with_jira_integration, :repository) }
it 'is true if external issues referenced' do
allow(commit.raw).to receive(:message).and_return 'JIRA-123'
diff --git a/spec/models/concerns/prometheus_adapter_spec.rb b/spec/models/concerns/prometheus_adapter_spec.rb
index d3a44ac8403..31ab8c23a84 100644
--- a/spec/models/concerns/prometheus_adapter_spec.rb
+++ b/spec/models/concerns/prometheus_adapter_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe PrometheusAdapter, :use_clean_rails_memory_store_caching do
include PrometheusHelpers
include ReactiveCachingHelpers
- let(:project) { create(:project, :with_prometheus_integration) }
+ let_it_be(:project) { create(:project, :with_prometheus_integration) }
let(:integration) { project.prometheus_integration }
let(:described_class) do
diff --git a/spec/models/concerns/protected_ref_access_spec.rb b/spec/models/concerns/protected_ref_access_spec.rb
deleted file mode 100644
index 750a5eba303..00000000000
--- a/spec/models/concerns/protected_ref_access_spec.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ProtectedRefAccess do
- include ExternalAuthorizationServiceHelpers
-
- subject(:protected_ref_access) do
- create(:protected_branch, :maintainers_can_push).push_access_levels.first
- end
-
- let(:project) { protected_ref_access.project }
-
- describe '#check_access' do
- it 'is always true for admins' do
- admin = create(:admin)
-
- expect(protected_ref_access.check_access(admin)).to be_truthy
- end
-
- it 'is true for maintainers' do
- maintainer = create(:user)
- project.add_maintainer(maintainer)
-
- expect(protected_ref_access.check_access(maintainer)).to be_truthy
- end
-
- it 'is for developers of the project' do
- developer = create(:user)
- project.add_developer(developer)
-
- expect(protected_ref_access.check_access(developer)).to be_falsy
- end
-
- context 'external authorization' do
- it 'is false if external authorization denies access' do
- maintainer = create(:user)
- project.add_maintainer(maintainer)
- external_service_deny_access(maintainer, project)
-
- expect(protected_ref_access.check_access(maintainer)).to be_falsey
- end
- end
- end
-end
diff --git a/spec/models/concerns/redis_cacheable_spec.rb b/spec/models/concerns/redis_cacheable_spec.rb
index c270f23defb..bf277b094ea 100644
--- a/spec/models/concerns/redis_cacheable_spec.rb
+++ b/spec/models/concerns/redis_cacheable_spec.rb
@@ -50,6 +50,58 @@ RSpec.describe RedisCacheable do
subject
end
+
+ context 'with existing cached attributes' do
+ before do
+ instance.cache_attributes({ existing_attr: 'value' })
+ end
+
+ it 'sets the cache attributes' do
+ Gitlab::Redis::Cache.with do |redis|
+ expect(redis).to receive(:set).with(cache_key, payload.to_json, anything).and_call_original
+ end
+
+ expect { subject }.to change { instance.cached_attribute(:existing_attr) }.from('value').to(nil)
+ end
+ end
+ end
+
+ describe '#merge_cache_attributes' do
+ subject { instance.merge_cache_attributes(payload) }
+
+ let(:existing_attributes) { { existing_attr: 'value', name: 'value' } }
+
+ before do
+ instance.cache_attributes(existing_attributes)
+ end
+
+ context 'with different attribute values' do
+ let(:payload) { { name: 'new_value' } }
+
+ it 'merges the cache attributes with existing values' do
+ Gitlab::Redis::Cache.with do |redis|
+ expect(redis).to receive(:set).with(cache_key, existing_attributes.merge(payload).to_json, anything)
+ .and_call_original
+ end
+
+ subject
+
+ expect(instance.cached_attribute(:existing_attr)).to eq 'value'
+ expect(instance.cached_attribute(:name)).to eq 'new_value'
+ end
+ end
+
+ context 'with no new or changed attribute values' do
+ let(:payload) { { name: 'value' } }
+
+ it 'does not try to set Redis key' do
+ Gitlab::Redis::Cache.with do |redis|
+ expect(redis).not_to receive(:set)
+ end
+
+ subject
+ end
+ end
end
describe '#cached_attr_reader', :clean_gitlab_redis_cache do
diff --git a/spec/models/concerns/require_email_verification_spec.rb b/spec/models/concerns/require_email_verification_spec.rb
index 0a6293f852e..1fb54e4276f 100644
--- a/spec/models/concerns/require_email_verification_spec.rb
+++ b/spec/models/concerns/require_email_verification_spec.rb
@@ -15,24 +15,20 @@ RSpec.describe RequireEmailVerification, feature_category: :insider_threat do
using RSpec::Parameterized::TableSyntax
- where(:feature_flag_enabled, :two_factor_enabled, :skipped, :overridden) do
- false | false | false | false
- false | false | true | false
- false | true | false | false
- false | true | true | false
- true | false | false | true
- true | false | true | false
- true | true | false | false
- true | true | true | false
- end
+ where(feature_flag_enabled: [true, false],
+ two_factor_enabled: [true, false],
+ oauth_user: [true, false],
+ skipped: [true, false])
with_them do
let(:instance) { model.new(id: 1) }
let(:another_instance) { model.new(id: 2) }
+ let(:overridden) { feature_flag_enabled && !two_factor_enabled && !oauth_user && !skipped }
before do
stub_feature_flags(require_email_verification: feature_flag_enabled ? instance : another_instance)
allow(instance).to receive(:two_factor_enabled?).and_return(two_factor_enabled)
+ allow(instance).to receive(:identities).and_return(oauth_user ? [:google] : [])
stub_feature_flags(skip_require_email_verification: skipped ? instance : another_instance)
end
diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb
index dc1002f3560..0bbe3dea812 100644
--- a/spec/models/concerns/routable_spec.rb
+++ b/spec/models/concerns/routable_spec.rb
@@ -74,6 +74,17 @@ RSpec.shared_examples 'routable resource with parent' do
describe '#full_name' do
it { expect(record.full_name).to eq "#{record.parent.human_name} / #{record.name}" }
+ context 'without route name' do
+ before do
+ stub_feature_flags(cached_route_lookups: true)
+ record.route.update_attribute(:name, nil)
+ end
+
+ it 'builds full name' do
+ expect(record.full_name).to eq("#{record.parent.human_name} / #{record.name}")
+ end
+ end
+
it 'hits the cache when not preloaded' do
forcibly_hit_cached_lookup(record, :full_name)
diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb
index a60a0a5e26d..80060802de8 100644
--- a/spec/models/concerns/subscribable_spec.rb
+++ b/spec/models/concerns/subscribable_spec.rb
@@ -215,5 +215,31 @@ RSpec.describe Subscribable, 'Subscribable' do
expect(lazy_queries.count).to eq(0)
expect(preloaded_queries.count).to eq(1)
end
+
+ context 'with work items' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:work_item) { create(:work_item, :task, project: project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ before do
+ [issue, work_item].each do |item|
+ create(:subscription, user: user, subscribable: item, subscribed: true, project: project)
+ end
+ end
+
+ it 'loads correct subscribable type' do
+ expect(issue).to receive(:subscribable_type).and_return('Issue')
+ issue.lazy_subscription(user, project)
+
+ expect(work_item).to receive(:subscribable_type).and_return('Issue')
+ work_item.lazy_subscription(user, project)
+ end
+
+ it 'matches existing subscription type' do
+ expect(issue.subscribed?(user, project)).to eq(true)
+ expect(work_item.subscribed?(user, project)).to eq(true)
+ end
+ end
end
end
diff --git a/spec/models/concerns/taskable_spec.rb b/spec/models/concerns/taskable_spec.rb
index 20de8995d13..dad0db2d898 100644
--- a/spec/models/concerns/taskable_spec.rb
+++ b/spec/models/concerns/taskable_spec.rb
@@ -27,6 +27,9 @@ RSpec.describe Taskable, feature_category: :team_planning do
+ [ ] Narrow no-break space (U+202F)
+ [ ] Thin space (U+2009)
+
+ 1. [ ] Numbered 1
+ 2) [x] Numbered 2
MARKDOWN
end
@@ -35,7 +38,9 @@ RSpec.describe Taskable, feature_category: :team_planning do
TaskList::Item.new('- [ ]', 'First item'),
TaskList::Item.new('- [x]', 'Second item'),
TaskList::Item.new('* [x]', 'First item'),
- TaskList::Item.new('* [ ]', 'Second item')
+ TaskList::Item.new('* [ ]', 'Second item'),
+ TaskList::Item.new('1. [ ]', 'Numbered 1'),
+ TaskList::Item.new('2) [x]', 'Numbered 2')
]
end
diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb
index e53fdafe3b1..7367577914c 100644
--- a/spec/models/concerns/token_authenticatable_spec.rb
+++ b/spec/models/concerns/token_authenticatable_spec.rb
@@ -130,10 +130,7 @@ RSpec.describe PersonalAccessToken, 'TokenAuthenticatable' do
let(:token_digest) { Gitlab::CryptoHelper.sha256(token_value) }
let(:user) { create(:user) }
let(:personal_access_token) do
- described_class.new(name: 'test-pat-01',
- user_id: user.id,
- scopes: [:api],
- token_digest: token_digest)
+ described_class.new(name: 'test-pat-01', user_id: user.id, scopes: [:api], token_digest: token_digest)
end
before do
diff --git a/spec/models/concerns/token_authenticatable_strategies/base_spec.rb b/spec/models/concerns/token_authenticatable_strategies/base_spec.rb
index 89ddc797a9d..2679bd7d93b 100644
--- a/spec/models/concerns/token_authenticatable_strategies/base_spec.rb
+++ b/spec/models/concerns/token_authenticatable_strategies/base_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TokenAuthenticatableStrategies::Base do
+RSpec.describe TokenAuthenticatableStrategies::Base, feature_category: :system_access do
let(:instance) { double(:instance) }
let(:field) { double(:field) }
@@ -24,6 +24,41 @@ RSpec.describe TokenAuthenticatableStrategies::Base do
end
end
+ describe '#format_token' do
+ let(:strategy) { described_class.new(instance, field, options) }
+
+ let(:instance) { build(:ci_build, name: 'build_name_for_format_option', partition_id: partition_id) }
+ let(:partition_id) { 100 }
+ let(:field) { 'token' }
+ let(:options) { {} }
+
+ let(:token) { 'a_secret_token' }
+
+ it 'returns the origin token' do
+ expect(strategy.format_token(instance, token)).to eq(token)
+ end
+
+ context 'when format_with_prefix option is provided' do
+ context 'with symbol' do
+ let(:options) { { format_with_prefix: :partition_id_prefix_in_16_bit_encode } }
+ let(:partition_id_in_16_bit_encode_with_underscore) { "#{partition_id.to_s(16)}_" }
+ let(:formatted_token) { "#{partition_id_in_16_bit_encode_with_underscore}#{token}" }
+
+ it 'returns a formatted token from the format_with_prefix option' do
+ expect(strategy.format_token(instance, token)).to eq(formatted_token)
+ end
+ end
+
+ context 'with something else' do
+ let(:options) { { format_with_prefix: false } }
+
+ it 'raise not implemented' do
+ expect { strategy.format_token(instance, token) }.to raise_error(NotImplementedError)
+ end
+ end
+ end
+ end
+
describe '.fabricate' do
context 'when digest stragegy is specified' do
it 'fabricates digest strategy object' do
diff --git a/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb b/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb
index 2df86804f34..3bdb0647d62 100644
--- a/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb
+++ b/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb
@@ -2,16 +2,20 @@
require 'spec_helper'
-RSpec.describe TokenAuthenticatableStrategies::Encrypted do
+RSpec.describe TokenAuthenticatableStrategies::Encrypted, feature_category: :system_access do
let(:model) { double(:model) }
let(:instance) { double(:instance) }
+ let(:original_token) { 'my-value' }
+ let(:resource) { double(:resource) }
+ let(:options) { other_options.merge(encrypted: encrypted_option) }
+ let(:other_options) { {} }
let(:encrypted) do
- TokenAuthenticatableStrategies::EncryptionHelper.encrypt_token('my-value')
+ TokenAuthenticatableStrategies::EncryptionHelper.encrypt_token(original_token)
end
let(:encrypted_with_static_iv) do
- Gitlab::CryptoHelper.aes256_gcm_encrypt('my-value')
+ Gitlab::CryptoHelper.aes256_gcm_encrypt(original_token)
end
subject(:strategy) do
@@ -19,7 +23,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do
end
describe '#token_fields' do
- let(:options) { { encrypted: :required } }
+ let(:encrypted_option) { :required }
it 'includes the encrypted field' do
expect(strategy.token_fields).to contain_exactly('some_field', 'some_field_encrypted')
@@ -27,50 +31,70 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do
end
describe '#find_token_authenticatable' do
+ shared_examples 'finds the resource' do
+ it 'finds the resource by cleartext' do
+ expect(subject.find_token_authenticatable(original_token))
+ .to eq(resource)
+ end
+ end
+
+ shared_examples 'does not find any resource' do
+ it 'does not find any resource by cleartext' do
+ expect(subject.find_token_authenticatable(original_token))
+ .to be_nil
+ end
+ end
+
+ shared_examples 'finds the resource with/without setting require_prefix_for_validation' do
+ let(:standard_runner_token_prefix) { 'GR1348941' }
+ it_behaves_like 'finds the resource'
+
+ context 'when a require_prefix_for_validation is provided' do
+ let(:other_options) { { format_with_prefix: :format_with_prefix_method, require_prefix_for_validation: true } }
+
+ before do
+ allow(resource).to receive(:format_with_prefix_method).and_return(standard_runner_token_prefix)
+ end
+
+ it_behaves_like 'does not find any resource'
+
+ context 'when token starts with prefix' do
+ let(:original_token) { "#{standard_runner_token_prefix}plain_token" }
+
+ it_behaves_like 'finds the resource'
+ end
+ end
+ end
+
context 'when encryption is required' do
- let(:options) { { encrypted: :required } }
+ let(:encrypted_option) { :required }
+ let(:resource) { double(:encrypted_resource) }
- it 'finds the encrypted resource by cleartext' do
+ before do
allow(model).to receive(:where)
.and_return(model)
allow(model).to receive(:find_by)
.with('some_field_encrypted' => [encrypted, encrypted_with_static_iv])
- .and_return('encrypted resource')
-
- expect(subject.find_token_authenticatable('my-value'))
- .to eq 'encrypted resource'
+ .and_return(resource)
end
- context 'when a prefix is required' do
- let(:options) { { encrypted: :required, prefix: 'GR1348941' } }
-
- it 'finds the encrypted resource by cleartext' do
- allow(model).to receive(:where)
- .and_return(model)
- allow(model).to receive(:find_by)
- .with('some_field_encrypted' => [encrypted, encrypted_with_static_iv])
- .and_return('encrypted resource')
-
- expect(subject.find_token_authenticatable('my-value'))
- .to be_nil
- end
- end
+ it_behaves_like 'finds the resource with/without setting require_prefix_for_validation'
end
context 'when encryption is optional' do
- let(:options) { { encrypted: :optional } }
+ let(:encrypted_option) { :optional }
+ let(:resource) { double(:encrypted_resource) }
- it 'finds the encrypted resource by cleartext' do
+ before do
allow(model).to receive(:where)
.and_return(model)
allow(model).to receive(:find_by)
.with('some_field_encrypted' => [encrypted, encrypted_with_static_iv])
- .and_return('encrypted resource')
-
- expect(subject.find_token_authenticatable('my-value'))
- .to eq 'encrypted resource'
+ .and_return(resource)
end
+ it_behaves_like 'finds the resource with/without setting require_prefix_for_validation'
+
it 'uses insecure strategy when encrypted token cannot be found' do
allow(subject.send(:insecure_strategy))
.to receive(:find_token_authenticatable)
@@ -85,68 +109,27 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do
expect(subject.find_token_authenticatable('my-value'))
.to eq 'plaintext resource'
end
-
- context 'when a prefix is required' do
- let(:options) { { encrypted: :optional, prefix: 'GR1348941' } }
-
- it 'finds the encrypted resource by cleartext' do
- allow(model).to receive(:where)
- .and_return(model)
- allow(model).to receive(:find_by)
- .with('some_field_encrypted' => [encrypted, encrypted_with_static_iv])
- .and_return('encrypted resource')
-
- expect(subject.find_token_authenticatable('my-value'))
- .to be_nil
- end
- end
end
context 'when encryption is migrating' do
- let(:options) { { encrypted: :migrating } }
+ let(:encrypted_option) { :migrating }
+ let(:resource) { double(:cleartext_resource) }
- it 'finds the cleartext resource by cleartext' do
+ before do
allow(model).to receive(:where)
.and_return(model)
allow(model).to receive(:find_by)
- .with('some_field' => 'my-value')
- .and_return('cleartext resource')
-
- expect(subject.find_token_authenticatable('my-value'))
- .to eq 'cleartext resource'
+ .with('some_field' => original_token)
+ .and_return(resource)
end
- it 'returns nil if resource cannot be found' do
- allow(model).to receive(:where)
- .and_return(model)
- allow(model).to receive(:find_by)
- .with('some_field' => 'my-value')
- .and_return(nil)
-
- expect(subject.find_token_authenticatable('my-value'))
- .to be_nil
- end
-
- context 'when a prefix is required' do
- let(:options) { { encrypted: :migrating, prefix: 'GR1348941' } }
-
- it 'finds the encrypted resource by cleartext' do
- allow(model).to receive(:where)
- .and_return(model)
- allow(model).to receive(:find_by)
- .with('some_field' => 'my-value')
- .and_return('cleartext resource')
-
- expect(subject.find_token_authenticatable('my-value'))
- .to be_nil
- end
- end
+ it_behaves_like 'finds the resource with/without setting require_prefix_for_validation'
end
end
describe '#get_token' do
context 'when encryption is required' do
- let(:options) { { encrypted: :required } }
+ let(:encrypted_option) { :required }
it 'returns decrypted token when an encrypted with static iv token is present' do
allow(instance).to receive(:read_attribute)
@@ -166,7 +149,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do
end
context 'when encryption is optional' do
- let(:options) { { encrypted: :optional } }
+ let(:encrypted_option) { :optional }
it 'returns decrypted token when an encrypted token is present' do
allow(instance).to receive(:read_attribute)
@@ -198,7 +181,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do
end
context 'when encryption is migrating' do
- let(:options) { { encrypted: :migrating } }
+ let(:encrypted_option) { :migrating }
it 'returns cleartext token when an encrypted token is present' do
allow(instance).to receive(:read_attribute)
@@ -228,7 +211,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do
describe '#set_token' do
context 'when encryption is required' do
- let(:options) { { encrypted: :required } }
+ let(:encrypted_option) { :required }
it 'writes encrypted token and returns it' do
expect(instance).to receive(:[]=)
@@ -239,7 +222,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do
end
context 'when encryption is optional' do
- let(:options) { { encrypted: :optional } }
+ let(:encrypted_option) { :optional }
it 'writes encrypted token and removes plaintext token and returns it' do
expect(instance).to receive(:[]=)
@@ -252,7 +235,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do
end
context 'when encryption is migrating' do
- let(:options) { { encrypted: :migrating } }
+ let(:encrypted_option) { :migrating }
it 'writes encrypted token and writes plaintext token' do
expect(instance).to receive(:[]=)
diff --git a/spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb b/spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb
index 671e51e3913..004298bbdd9 100644
--- a/spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb
+++ b/spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb
@@ -6,96 +6,26 @@ RSpec.describe TokenAuthenticatableStrategies::EncryptionHelper do
let(:encrypted_token) { described_class.encrypt_token('my-value-my-value-my-value') }
describe '.encrypt_token' do
- context 'when dynamic_nonce feature flag is switched on' do
- it 'adds nonce identifier on the beginning' do
- expect(encrypted_token.first).to eq(described_class::DYNAMIC_NONCE_IDENTIFIER)
- end
-
- it 'adds nonce at the end' do
- nonce = encrypted_token.last(described_class::NONCE_SIZE)
-
- expect(nonce).to eq(::Digest::SHA256.hexdigest('my-value-my-value-my-value').bytes.take(described_class::NONCE_SIZE).pack('c*'))
- end
-
- it 'encrypts token' do
- expect(encrypted_token[1...-described_class::NONCE_SIZE]).not_to eq('my-value-my-value-my-value')
- end
+ it 'adds nonce identifier on the beginning' do
+ expect(encrypted_token.first).to eq(described_class::DYNAMIC_NONCE_IDENTIFIER)
end
- context 'when dynamic_nonce feature flag is switched off' do
- before do
- stub_feature_flags(dynamic_nonce: false)
- end
-
- it 'does not add nonce identifier on the beginning' do
- expect(encrypted_token.first).not_to eq(described_class::DYNAMIC_NONCE_IDENTIFIER)
- end
-
- it 'does not add nonce in the end' do
- nonce = encrypted_token.last(described_class::NONCE_SIZE)
-
- expect(nonce).not_to eq(::Digest::SHA256.hexdigest('my-value-my-value-my-value').bytes.take(described_class::NONCE_SIZE).pack('c*'))
- end
+ it 'adds nonce at the end' do
+ nonce = encrypted_token.last(described_class::NONCE_SIZE)
- it 'encrypts token with static iv' do
- token = Gitlab::CryptoHelper.aes256_gcm_encrypt('my-value-my-value-my-value')
+ expect(nonce).to eq(::Digest::SHA256.hexdigest('my-value-my-value-my-value').bytes.take(described_class::NONCE_SIZE).pack('c*'))
+ end
- expect(encrypted_token).to eq(token)
- end
+ it 'encrypts token' do
+ expect(encrypted_token[1...-described_class::NONCE_SIZE]).not_to eq('my-value-my-value-my-value')
end
end
describe '.decrypt_token' do
- context 'with feature flag switched off' do
- before do
- stub_feature_flags(dynamic_nonce: false)
- end
-
- it 'decrypts token with static iv' do
- encrypted_token = described_class.encrypt_token('my-value')
-
- expect(described_class.decrypt_token(encrypted_token)).to eq('my-value')
- end
-
- it 'decrypts token if feature flag changed after encryption' do
- encrypted_token = described_class.encrypt_token('my-value')
-
- expect(encrypted_token).not_to eq('my-value')
-
- stub_feature_flags(dynamic_nonce: true)
-
- expect(described_class.decrypt_token(encrypted_token)).to eq('my-value')
- end
-
- it 'decrypts token with dynamic iv' do
- iv = ::Digest::SHA256.hexdigest('my-value').bytes.take(described_class::NONCE_SIZE).pack('c*')
- token = Gitlab::CryptoHelper.aes256_gcm_encrypt('my-value', nonce: iv)
- encrypted_token = "#{described_class::DYNAMIC_NONCE_IDENTIFIER}#{token}#{iv}"
-
- expect(described_class.decrypt_token(encrypted_token)).to eq('my-value')
- end
- end
-
- context 'with feature flag switched on' do
- before do
- stub_feature_flags(dynamic_nonce: true)
- end
-
- it 'decrypts token with dynamic iv' do
- encrypted_token = described_class.encrypt_token('my-value')
-
- expect(described_class.decrypt_token(encrypted_token)).to eq('my-value')
- end
-
- it 'decrypts token if feature flag changed after encryption' do
- encrypted_token = described_class.encrypt_token('my-value')
-
- expect(encrypted_token).not_to eq('my-value')
-
- stub_feature_flags(dynamic_nonce: false)
+ it 'decrypts token with dynamic iv' do
+ encrypted_token = described_class.encrypt_token('my-value')
- expect(described_class.decrypt_token(encrypted_token)).to eq('my-value')
- end
+ expect(described_class.decrypt_token(encrypted_token)).to eq('my-value')
end
end
end
diff --git a/spec/models/concerns/uniquify_spec.rb b/spec/models/concerns/uniquify_spec.rb
deleted file mode 100644
index 9b79e4d4154..00000000000
--- a/spec/models/concerns/uniquify_spec.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Uniquify do
- let(:uniquify) { described_class.new }
-
- describe "#string" do
- it 'returns the given string if it does not exist' do
- result = uniquify.string('test_string') { |s| false }
-
- expect(result).to eq('test_string')
- end
-
- it 'returns the given string with a counter attached if the string exists' do
- result = uniquify.string('test_string') { |s| s == 'test_string' }
-
- expect(result).to eq('test_string1')
- end
-
- it 'increments the counter for each candidate string that also exists' do
- result = uniquify.string('test_string') { |s| s == 'test_string' || s == 'test_string1' }
-
- expect(result).to eq('test_string2')
- end
-
- it 'allows to pass an initial value for the counter' do
- start_counting_from = 2
- uniquify = described_class.new(start_counting_from)
-
- result = uniquify.string('test_string') { |s| s == 'test_string' }
-
- expect(result).to eq('test_string2')
- end
-
- it 'allows passing in a base function that defines the location of the counter' do
- result = uniquify.string(-> (counter) { "test_#{counter}_string" }) do |s|
- s == 'test__string'
- end
-
- expect(result).to eq('test_1_string')
- end
- end
-end
diff --git a/spec/models/concerns/web_hooks/has_web_hooks_spec.rb b/spec/models/concerns/web_hooks/has_web_hooks_spec.rb
new file mode 100644
index 00000000000..afb2406a969
--- /dev/null
+++ b/spec/models/concerns/web_hooks/has_web_hooks_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WebHooks::HasWebHooks, feature_category: :integrations do
+ let(:minimal_test_class) do
+ Class.new do
+ include WebHooks::HasWebHooks
+
+ def id
+ 1
+ end
+ end
+ end
+
+ before do
+ stub_const('MinimalTestClass', minimal_test_class)
+ end
+
+ describe '#last_failure_redis_key' do
+ subject { MinimalTestClass.new.last_failure_redis_key }
+
+ it { is_expected.to eq('web_hooks:last_failure:minimal_test_class-1') }
+ end
+
+ describe 'last_webhook_failure', :clean_gitlab_redis_shared_state do
+ subject { MinimalTestClass.new.last_webhook_failure }
+
+ it { is_expected.to eq(nil) }
+
+ context 'when there was an older failure', :clean_gitlab_redis_shared_state do
+ let(:last_failure_date) { 1.month.ago.iso8601 }
+
+ before do
+ Gitlab::Redis::SharedState.with { |r| r.set('web_hooks:last_failure:minimal_test_class-1', last_failure_date) }
+ end
+
+ it { is_expected.to eq(last_failure_date) }
+ end
+ end
+end
diff --git a/spec/models/container_registry/data_repair_detail_spec.rb b/spec/models/container_registry/data_repair_detail_spec.rb
new file mode 100644
index 00000000000..4d2ac5fff42
--- /dev/null
+++ b/spec/models/container_registry/data_repair_detail_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ContainerRegistry::DataRepairDetail, type: :model, feature_category: :container_registry do
+ let_it_be(:project) { create(:project) }
+
+ subject { described_class.new(project: project) }
+
+ it { is_expected.to belong_to(:project).required }
+
+ it_behaves_like 'having unique enum values'
+
+ describe '.ongoing_since' do
+ let_it_be(:repair_detail1) { create(:container_registry_data_repair_detail, :ongoing, updated_at: 1.day.ago) }
+ let_it_be(:repair_detail2) { create(:container_registry_data_repair_detail, :ongoing, updated_at: 20.minutes.ago) }
+ let_it_be(:repair_detail3) do
+ create(:container_registry_data_repair_detail, :completed, updated_at: 20.minutes.ago)
+ end
+
+ let_it_be(:repair_detail4) do
+ create(:container_registry_data_repair_detail, :completed, updated_at: 31.minutes.ago)
+ end
+
+ subject { described_class.ongoing_since(30.minutes.ago) }
+
+ it { is_expected.to contain_exactly(repair_detail1) }
+ end
+end
diff --git a/spec/models/container_registry/event_spec.rb b/spec/models/container_registry/event_spec.rb
index 07ac35f7b6a..d5c3a5fc066 100644
--- a/spec/models/container_registry/event_spec.rb
+++ b/spec/models/container_registry/event_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe ContainerRegistry::Event do
using RSpec::Parameterized::TableSyntax
let_it_be(:group) { create(:group, name: 'group') }
- let_it_be(:project) { create(:project, name: 'test', namespace: group) }
+ let_it_be(:project) { create(:project, path: 'test', namespace: group) }
describe '#supported?' do
let(:raw_event) { { 'action' => action } }
@@ -225,6 +225,28 @@ RSpec.describe ContainerRegistry::Event do
end
end
+ context 'when it is a manifest delete event' do
+ let(:raw_event) { { 'action' => 'delete', 'target' => { 'digest' => 'x' }, 'actor' => {} } }
+
+ it 'calls the ContainerRegistryEventCounter' do
+ expect(::Gitlab::UsageDataCounters::ContainerRegistryEventCounter)
+ .to receive(:count).with('i_container_registry_delete_manifest')
+
+ subject
+ end
+ end
+
+ context 'when it is not a manifest delete event' do
+ let(:raw_event) { { 'action' => 'push', 'target' => { 'digest' => 'x' }, 'actor' => {} } }
+
+ it 'does not call the ContainerRegistryEventCounter' do
+ expect(::Gitlab::UsageDataCounters::ContainerRegistryEventCounter)
+ .not_to receive(:count).with('i_container_registry_delete_manifest')
+
+ subject
+ end
+ end
+
context 'without an actor name' do
let(:raw_event) { { 'action' => 'push', 'target' => {}, 'actor' => { 'user_type' => 'personal_access_token' } } }
diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb
index da7b54644bd..d8019e74c71 100644
--- a/spec/models/container_repository_spec.rb
+++ b/spec/models/container_repository_spec.rb
@@ -13,9 +13,9 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
end
before do
- stub_container_registry_config(enabled: true,
- api_url: 'http://registry.gitlab',
- host_port: 'registry.gitlab')
+ stub_container_registry_config(
+ enabled: true, api_url: 'http://registry.gitlab', host_port: 'registry.gitlab'
+ )
stub_request(:get, "http://registry.gitlab/v2/group/test/my_image/tags/list?n=#{::ContainerRegistry::Client::DEFAULT_TAGS_PAGE_SIZE}")
.with(headers: { 'Accept' => ContainerRegistry::Client::ACCEPTED_TYPES.join(', ') })
@@ -92,7 +92,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
end
end
- shared_examples 'transitioning to pre_importing', skip_pre_import_success: true do
+ shared_examples 'transitioning to pre_importing' do
before do
repository.update_column(:migration_pre_import_done_at, Time.zone.now)
end
@@ -145,7 +145,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
end
end
- shared_examples 'transitioning to importing', skip_import_success: true do
+ shared_examples 'transitioning to importing' do
before do
repository.update_columns(migration_import_done_at: Time.zone.now)
end
@@ -219,9 +219,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
subject { repository.start_pre_import }
before do |example|
- unless example.metadata[:skip_pre_import_success]
- allow(repository).to receive(:migration_pre_import).and_return(:ok)
- end
+ allow(repository).to receive(:migration_pre_import).and_return(:ok)
end
it_behaves_like 'transitioning from allowed states', %w[default pre_importing importing import_aborted]
@@ -234,9 +232,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
subject { repository.retry_pre_import }
before do |example|
- unless example.metadata[:skip_pre_import_success]
- allow(repository).to receive(:migration_pre_import).and_return(:ok)
- end
+ allow(repository).to receive(:migration_pre_import).and_return(:ok)
end
it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted]
@@ -264,9 +260,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
subject { repository.start_import }
before do |example|
- unless example.metadata[:skip_import_success]
- allow(repository).to receive(:migration_import).and_return(:ok)
- end
+ allow(repository).to receive(:migration_import).and_return(:ok)
end
it_behaves_like 'transitioning from allowed states', %w[pre_import_done pre_importing importing import_aborted]
@@ -279,9 +273,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
subject { repository.retry_import }
before do |example|
- unless example.metadata[:skip_import_success]
- allow(repository).to receive(:migration_import).and_return(:ok)
- end
+ allow(repository).to receive(:migration_import).and_return(:ok)
end
it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted]
@@ -374,9 +366,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
subject { repository.finish_pre_import_and_start_import }
before do |example|
- unless example.metadata[:skip_import_success]
- allow(repository).to receive(:migration_import).and_return(:ok)
- end
+ allow(repository).to receive(:migration_import).and_return(:ok)
end
it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted]
@@ -528,6 +518,10 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
describe '#each_tags_page' do
let(:page_size) { 100 }
+ before do
+ allow(repository).to receive(:migrated?).and_return(true)
+ end
+
shared_examples 'iterating through a page' do |expected_tags: true|
it 'iterates through one page' do
expect(repository.gitlab_api_client).to receive(:tags)
@@ -660,7 +654,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
context 'calling on a non migrated repository' do
before do
- repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days)
+ allow(repository).to receive(:migrated?).and_return(false)
end
it 'raises an Argument error' do
@@ -695,9 +689,12 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
describe '#delete_tags!' do
let(:repository) do
- create(:container_repository, name: 'my_image',
- tags: { latest: '123', rc1: '234' },
- project: project)
+ create(
+ :container_repository,
+ name: 'my_image',
+ tags: { latest: '123', rc1: '234' },
+ project: project
+ )
end
context 'when action succeeds' do
@@ -725,9 +722,12 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
describe '#delete_tag_by_name' do
let(:repository) do
- create(:container_repository, name: 'my_image',
- tags: { latest: '123', rc1: '234' },
- project: project)
+ create(
+ :container_repository,
+ name: 'my_image',
+ tags: { latest: '123', rc1: '234' },
+ project: project
+ )
end
context 'when action succeeds' do
@@ -756,9 +756,11 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
describe '#location' do
context 'when registry is running on a custom port' do
before do
- stub_container_registry_config(enabled: true,
- api_url: 'http://registry.gitlab:5000',
- host_port: 'registry.gitlab:5000')
+ stub_container_registry_config(
+ enabled: true,
+ api_url: 'http://registry.gitlab:5000',
+ host_port: 'registry.gitlab:5000'
+ )
end
it 'returns a full location of the repository' do
@@ -795,6 +797,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
freeze_time do
expect { subject }
.to change { repository.expiration_policy_started_at }.from(nil).to(Time.zone.now)
+ .and change { repository.expiration_policy_cleanup_status }.from('cleanup_unscheduled').to('cleanup_ongoing')
.and change { repository.last_cleanup_deleted_tags_count }.from(10).to(nil)
end
end
@@ -1236,6 +1239,8 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
end
before do
+ allow(group).to receive(:first_project_with_container_registry_tags).and_return(nil)
+
group.parent = test_group
group.save!
end
@@ -1561,22 +1566,20 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
describe '#migrated?' do
subject { repository.migrated? }
- it { is_expected.to eq(true) }
-
- context 'with a created_at older than phase 1 ends' do
+ context 'on gitlab.com' do
before do
- repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days)
+ allow(::Gitlab).to receive(:com?).and_return(true)
end
- it { is_expected.to eq(false) }
-
- context 'with migration state set to import_done' do
- before do
- repository.update!(migration_state: 'import_done')
- end
+ it { is_expected.to eq(true) }
+ end
- it { is_expected.to eq(true) }
+ context 'not on gitlab.com' do
+ before do
+ allow(::Gitlab).to receive(:com?).and_return(false)
end
+
+ it { is_expected.to eq(false) }
end
end
@@ -1717,4 +1720,19 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont
it { is_expected.to contain_exactly(*stale_migrations) }
end
+
+ describe '#registry' do
+ it 'caches the client' do
+ registry = repository.registry
+ registry1 = repository.registry
+ registry2 = nil
+
+ travel_to(Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes) do
+ registry2 = repository.registry
+ end
+
+ expect(registry1.object_id).to be(registry.object_id)
+ expect(registry2.object_id).not_to be(registry.object_id)
+ end
+ end
end
diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb
index 487af404a7c..6beb5323f60 100644
--- a/spec/models/customer_relations/contact_spec.rb
+++ b/spec/models/customer_relations/contact_spec.rb
@@ -241,8 +241,8 @@ RSpec.describe CustomerRelations::Contact, type: :model do
end
describe 'sorting' do
- let_it_be(:organization_a) { create(:organization, name: 'a') }
- let_it_be(:organization_b) { create(:organization, name: 'b') }
+ let_it_be(:crm_organization_a) { create(:crm_organization, name: 'a') }
+ let_it_be(:crm_organization_b) { create(:crm_organization, name: 'b') }
let_it_be(:contact_a) { create(:contact, group: group, first_name: "c", last_name: "d") }
let_it_be(:contact_b) do
create(:contact,
@@ -250,7 +250,7 @@ RSpec.describe CustomerRelations::Contact, type: :model do
first_name: "a",
last_name: "b",
phone: "123",
- organization: organization_a)
+ organization: crm_organization_a)
end
let_it_be(:contact_c) do
@@ -259,7 +259,7 @@ RSpec.describe CustomerRelations::Contact, type: :model do
first_name: "e",
last_name: "d",
phone: "456",
- organization: organization_b)
+ organization: crm_organization_b)
end
describe '.sort_by_name' do
diff --git a/spec/models/customer_relations/organization_spec.rb b/spec/models/customer_relations/organization_spec.rb
index d19a0bdf6c7..7fab9fd0e80 100644
--- a/spec/models/customer_relations/organization_spec.rb
+++ b/spec/models/customer_relations/organization_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe CustomerRelations::Organization, type: :model do
end
describe 'validations' do
- subject { build(:organization) }
+ subject { build(:crm_organization) }
it { is_expected.to validate_presence_of(:group) }
it { is_expected.to validate_presence_of(:name) }
@@ -21,13 +21,13 @@ RSpec.describe CustomerRelations::Organization, type: :model do
describe '#root_group' do
context 'when root group' do
- subject { build(:organization, group: group) }
+ subject { build(:crm_organization, group: group) }
it { is_expected.to be_valid }
end
context 'when subgroup' do
- subject { build(:organization, group: create(:group, parent: group)) }
+ subject { build(:crm_organization, group: create(:group, parent: group)) }
it { is_expected.to be_invalid }
end
@@ -43,23 +43,25 @@ RSpec.describe CustomerRelations::Organization, type: :model do
end
describe '#find_by_name' do
- let!(:organiztion1) { create(:organization, group: group, name: 'Test') }
- let!(:organiztion2) { create(:organization, group: create(:group), name: 'Test') }
+ let!(:crm_organiztion1) { create(:crm_organization, group: group, name: 'Test') }
+ let!(:crm_organiztion2) { create(:crm_organization, group: create(:group), name: 'Test') }
it 'strips name' do
- expect(described_class.find_by_name(group.id, 'TEST')).to eq([organiztion1])
+ expect(described_class.find_by_name(group.id, 'TEST')).to eq([crm_organiztion1])
end
end
describe '#self.move_to_root_group' do
let!(:old_root_group) { create(:group) }
- let!(:organizations) { create_list(:organization, 4, group: old_root_group) }
+ let!(:crm_organizations) { create_list(:crm_organization, 4, group: old_root_group) }
let!(:new_root_group) { create(:group) }
- let!(:contact1) { create(:contact, group: new_root_group, organization: organizations[0]) }
- let!(:contact2) { create(:contact, group: new_root_group, organization: organizations[1]) }
+ let!(:contact1) { create(:contact, group: new_root_group, organization: crm_organizations[0]) }
+ let!(:contact2) { create(:contact, group: new_root_group, organization: crm_organizations[1]) }
- let!(:dupe_organization1) { create(:organization, group: new_root_group, name: organizations[1].name) }
- let!(:dupe_organization2) { create(:organization, group: new_root_group, name: organizations[3].name.upcase) }
+ let!(:dupe_crm_organization1) { create(:crm_organization, group: new_root_group, name: crm_organizations[1].name) }
+ let!(:dupe_crm_organization2) do
+ create(:crm_organization, group: new_root_group, name: crm_organizations[3].name.upcase)
+ end
before do
old_root_group.update!(parent: new_root_group)
@@ -67,22 +69,22 @@ RSpec.describe CustomerRelations::Organization, type: :model do
end
it 'moves organizations with unique names and deletes the rest' do
- expect(organizations[0].reload.group_id).to eq(new_root_group.id)
- expect(organizations[2].reload.group_id).to eq(new_root_group.id)
- expect { organizations[1].reload }.to raise_error(ActiveRecord::RecordNotFound)
- expect { organizations[3].reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect(crm_organizations[0].reload.group_id).to eq(new_root_group.id)
+ expect(crm_organizations[2].reload.group_id).to eq(new_root_group.id)
+ expect { crm_organizations[1].reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { crm_organizations[3].reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'updates contact.organization_id for dupes and leaves the rest untouched' do
- expect(contact1.reload.organization_id).to eq(organizations[0].id)
- expect(contact2.reload.organization_id).to eq(dupe_organization1.id)
+ expect(contact1.reload.organization_id).to eq(crm_organizations[0].id)
+ expect(contact2.reload.organization_id).to eq(dupe_crm_organization1.id)
end
end
describe '.search' do
- let_it_be(:organization_a) do
+ let_it_be(:crm_organization_a) do
create(
- :organization,
+ :crm_organization,
group: group,
name: "DEF",
description: "ghi_st",
@@ -90,9 +92,9 @@ RSpec.describe CustomerRelations::Organization, type: :model do
)
end
- let_it_be(:organization_b) do
+ let_it_be(:crm_organization_b) do
create(
- :organization,
+ :crm_organization,
group: group,
name: "ABC_st",
description: "JKL",
@@ -106,7 +108,7 @@ RSpec.describe CustomerRelations::Organization, type: :model do
let(:search_term) { "" }
it 'returns all group organizations' do
- expect(found_organizations).to contain_exactly(organization_a, organization_b)
+ expect(found_organizations).to contain_exactly(crm_organization_a, crm_organization_b)
end
end
@@ -114,42 +116,42 @@ RSpec.describe CustomerRelations::Organization, type: :model do
context 'when searching for name' do
let(:search_term) { "aBc" }
- it { is_expected.to contain_exactly(organization_b) }
+ it { is_expected.to contain_exactly(crm_organization_b) }
end
context 'when searching for description' do
let(:search_term) { "ghI" }
- it { is_expected.to contain_exactly(organization_a) }
+ it { is_expected.to contain_exactly(crm_organization_a) }
end
context 'when searching for name and description' do
let(:search_term) { "_st" }
- it { is_expected.to contain_exactly(organization_a, organization_b) }
+ it { is_expected.to contain_exactly(crm_organization_a, crm_organization_b) }
end
end
end
describe '.search_by_state' do
- let_it_be(:organization_a) { create(:organization, group: group, state: "inactive") }
- let_it_be(:organization_b) { create(:organization, group: group, state: "active") }
+ let_it_be(:crm_organization_a) { create(:crm_organization, group: group, state: "inactive") }
+ let_it_be(:crm_organization_b) { create(:crm_organization, group: group, state: "active") }
context 'when searching for organizations state' do
it 'returns only inactive organizations' do
- expect(group.organizations.search_by_state(:inactive)).to contain_exactly(organization_a)
+ expect(group.organizations.search_by_state(:inactive)).to contain_exactly(crm_organization_a)
end
it 'returns only active organizations' do
- expect(group.organizations.search_by_state(:active)).to contain_exactly(organization_b)
+ expect(group.organizations.search_by_state(:active)).to contain_exactly(crm_organization_b)
end
end
end
describe '.counts_by_state' do
before do
- create_list(:organization, 3, group: group)
- create_list(:organization, 2, group: group, state: 'inactive')
+ create_list(:crm_organization, 3, group: group)
+ create_list(:crm_organization, 2, group: group, state: 'inactive')
end
it 'returns correct organization counts' do
@@ -168,20 +170,20 @@ RSpec.describe CustomerRelations::Organization, type: :model do
end
describe 'sorting' do
- let_it_be(:organization_a) { create(:organization, group: group, name: "c", description: "1") }
- let_it_be(:organization_b) { create(:organization, group: group, name: "a") }
- let_it_be(:organization_c) { create(:organization, group: group, name: "b", description: "2") }
+ let_it_be(:crm_organization_a) { create(:crm_organization, group: group, name: "c", description: "1") }
+ let_it_be(:crm_organization_b) { create(:crm_organization, group: group, name: "a") }
+ let_it_be(:crm_organization_c) { create(:crm_organization, group: group, name: "b", description: "2") }
describe '.sort_by_name' do
it 'sorts them by name in ascendent order' do
- expect(group.organizations.sort_by_name).to eq([organization_b, organization_c, organization_a])
+ expect(group.organizations.sort_by_name).to eq([crm_organization_b, crm_organization_c, crm_organization_a])
end
end
describe '.sort_by_field' do
it 'sorts them by description in descending order' do
expect(group.organizations.sort_by_field('description', :desc))
- .to eq([organization_c, organization_a, organization_b])
+ .to eq([crm_organization_c, crm_organization_a, crm_organization_b])
end
end
end
diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb
index 46a1b4ce588..2a7a8d50895 100644
--- a/spec/models/deployment_spec.rb
+++ b/spec/models/deployment_spec.rb
@@ -170,13 +170,6 @@ RSpec.describe Deployment, feature_category: :continuous_delivery do
deployment.run!
end
end
-
- it 'does not execute Deployments::DropOlderDeploymentsWorker' do
- expect(Deployments::DropOlderDeploymentsWorker)
- .not_to receive(:perform_async).with(deployment.id)
-
- deployment.run!
- end
end
context 'when deployment succeeded' do
@@ -383,8 +376,14 @@ RSpec.describe Deployment, feature_category: :continuous_delivery do
let_it_be(:commits) { project.repository.commits('master', limit: 2) }
let!(:deployment) do
- create(:deployment, :success, project: project, environment: environment,
- finished_at: 1.year.ago, sha: commits[0].sha)
+ create(
+ :deployment,
+ :success,
+ project: project,
+ environment: environment,
+ finished_at: 1.year.ago,
+ sha: commits[0].sha
+ )
end
let!(:last_deployment) do
@@ -1355,10 +1354,9 @@ RSpec.describe Deployment, feature_category: :continuous_delivery do
subject { deployment.tags }
it 'will return tags related to this deployment' do
- expect(project.repository).to receive(:refs_by_oid).with(oid: deployment.sha,
- limit: 100,
- ref_patterns: [Gitlab::Git::TAG_REF_PREFIX])
- .and_return(["#{Gitlab::Git::TAG_REF_PREFIX}test"])
+ expect(project.repository).to receive(:refs_by_oid).with(
+ oid: deployment.sha, limit: 100, ref_patterns: [Gitlab::Git::TAG_REF_PREFIX]
+ ).and_return(["#{Gitlab::Git::TAG_REF_PREFIX}test"])
is_expected.to match_array(['refs/tags/test'])
end
diff --git a/spec/models/design_management/design_collection_spec.rb b/spec/models/design_management/design_collection_spec.rb
index bc8330c7dd3..06596d37fde 100644
--- a/spec/models/design_management/design_collection_spec.rb
+++ b/spec/models/design_management/design_collection_spec.rb
@@ -128,7 +128,7 @@ RSpec.describe DesignManagement::DesignCollection do
describe "#repository" do
it "builds a design repository" do
- expect(collection.repository).to be_a(DesignManagement::Repository)
+ expect(collection.repository).to be_a(DesignManagement::GitRepository)
end
end
diff --git a/spec/models/design_management/design_spec.rb b/spec/models/design_management/design_spec.rb
index 57e0d1cad8b..72c0d1d1a64 100644
--- a/spec/models/design_management/design_spec.rb
+++ b/spec/models/design_management/design_spec.rb
@@ -55,6 +55,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do
it { is_expected.to validate_presence_of(:issue) }
it { is_expected.to validate_presence_of(:filename) }
it { is_expected.to validate_length_of(:filename).is_at_most(255) }
+ it { is_expected.to validate_length_of(:description).is_at_most(Gitlab::Database::MAX_TEXT_SIZE_LIMIT) }
it { is_expected.to validate_uniqueness_of(:filename).scoped_to(:issue_id) }
it "validates that the extension is an image" do
@@ -462,7 +463,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do
it 'is a design repository' do
design = build(:design, issue: issue)
- expect(design.repository).to be_a(DesignManagement::Repository)
+ expect(design.repository).to be_a(DesignManagement::GitRepository)
end
end
@@ -512,11 +513,11 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do
end
describe '#to_reference' do
- let(:namespace) { build(:namespace, id: non_existing_record_id, path: 'sample-namespace') }
- let(:project) { build(:project, name: 'sample-project', namespace: namespace) }
- let(:group) { create(:group, name: 'Group', path: 'sample-group') }
- let(:issue) { build(:issue, iid: 1, project: project) }
let(:filename) { 'homescreen.jpg' }
+ let(:namespace) { build(:namespace, id: non_existing_record_id) }
+ let(:project) { build(:project, namespace: namespace) }
+ let(:group) { build(:group) }
+ let(:issue) { build(:issue, iid: 1, project: project) }
let(:design) { build(:design, filename: filename, issue: issue, project: project) }
context 'when nil argument' do
@@ -535,7 +536,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do
design.to_reference(group, full: true)
]
- expect(refs).to all(eq 'sample-namespace/sample-project#1/designs[homescreen.jpg]')
+ expect(refs).to all(eq "#{project.full_path}#1/designs[homescreen.jpg]")
end
end
@@ -546,7 +547,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do
design.to_reference(group, full: false)
]
- expect(refs).to all(eq 'sample-namespace/sample-project#1[homescreen.jpg]')
+ expect(refs).to all(eq "#{project.full_path}#1[homescreen.jpg]")
end
end
@@ -595,7 +596,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do
'url_filename' => filename,
'issue' => issue.iid.to_s,
'namespace' => design.project.namespace.to_param,
- 'project' => design.project.name
+ 'project' => design.project.to_param
)
end
diff --git a/spec/models/design_management/git_repository_spec.rb b/spec/models/design_management/git_repository_spec.rb
new file mode 100644
index 00000000000..736f2ad45cf
--- /dev/null
+++ b/spec/models/design_management/git_repository_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DesignManagement::GitRepository, feature_category: :design_management do
+ let_it_be(:container_repo) { DesignManagement::Repository.new(project: create(:project)) }
+ let(:git_repository) { container_repo.repository }
+
+ shared_examples 'returns parsed git attributes that enable LFS for all file types' do
+ it do
+ expect(subject.patterns).to be_a_kind_of(Hash)
+ expect(subject.patterns).to have_key('/designs/*')
+ expect(subject.patterns['/designs/*']).to eql(
+ { "filter" => "lfs", "diff" => "lfs", "merge" => "lfs", "text" => false }
+ )
+ end
+ end
+
+ describe '.container' do
+ it 'is of class DesignManagement::Repository' do
+ expect(git_repository.container).to be_a_kind_of(DesignManagement::Repository)
+ end
+ end
+
+ describe "#info_attributes" do
+ subject { git_repository.info_attributes }
+
+ include_examples 'returns parsed git attributes that enable LFS for all file types'
+ end
+
+ describe '#attributes_at' do
+ subject { git_repository.attributes_at }
+
+ include_examples 'returns parsed git attributes that enable LFS for all file types'
+ end
+
+ describe '#gitattribute' do
+ it 'returns a gitattribute when path has gitattributes' do
+ expect(git_repository.gitattribute('/designs/file.txt', 'filter')).to eq('lfs')
+ end
+
+ it 'returns nil when path has no gitattributes' do
+ expect(git_repository.gitattribute('/invalid/file.txt', 'filter')).to be_nil
+ end
+ end
+
+ describe '#copy_gitattributes' do
+ it 'always returns regardless of whether given a valid or invalid ref' do
+ expect(git_repository.copy_gitattributes('master')).to be true
+ expect(git_repository.copy_gitattributes('invalid')).to be true
+ end
+ end
+
+ describe '#attributes' do
+ it 'confirms that all files are LFS enabled' do
+ %w[png zip anything].each do |filetype|
+ path = "/#{DesignManagement.designs_directory}/file.#{filetype}"
+ attributes = git_repository.attributes(path)
+
+ expect(attributes['filter']).to eq('lfs')
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/repository_spec.rb b/spec/models/design_management/repository_spec.rb
index 0115e0c139c..74f393306dc 100644
--- a/spec/models/design_management/repository_spec.rb
+++ b/spec/models/design_management/repository_spec.rb
@@ -2,57 +2,24 @@
require 'spec_helper'
-RSpec.describe DesignManagement::Repository do
- let(:project) { create(:project) }
- let(:repository) { described_class.new(project) }
+RSpec.describe DesignManagement::Repository, feature_category: :design_management do
+ let_it_be(:project) { create(:project) }
+ let(:subject) { described_class.new({ project: project }) }
- shared_examples 'returns parsed git attributes that enable LFS for all file types' do
- it do
- expect(subject.patterns).to be_a_kind_of(Hash)
- expect(subject.patterns).to have_key('/designs/*')
- expect(subject.patterns['/designs/*']).to eql(
- { "filter" => "lfs", "diff" => "lfs", "merge" => "lfs", "text" => false }
- )
- end
+ describe 'associations' do
+ it { is_expected.to belong_to(:project).inverse_of(:design_management_repository) }
end
- describe "#info_attributes" do
- subject { repository.info_attributes }
-
- include_examples 'returns parsed git attributes that enable LFS for all file types'
- end
-
- describe '#attributes_at' do
- subject { repository.attributes_at }
-
- include_examples 'returns parsed git attributes that enable LFS for all file types'
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_uniqueness_of(:project) }
end
- describe '#gitattribute' do
- it 'returns a gitattribute when path has gitattributes' do
- expect(repository.gitattribute('/designs/file.txt', 'filter')).to eq('lfs')
- end
-
- it 'returns nil when path has no gitattributes' do
- expect(repository.gitattribute('/invalid/file.txt', 'filter')).to be_nil
- end
- end
-
- describe '#copy_gitattributes' do
- it 'always returns regardless of whether given a valid or invalid ref' do
- expect(repository.copy_gitattributes('master')).to be true
- expect(repository.copy_gitattributes('invalid')).to be true
- end
+ it "returns the project's full path" do
+ expect(subject.full_path).to eq(project.full_path + Gitlab::GlRepository::DESIGN.path_suffix)
end
- describe '#attributes' do
- it 'confirms that all files are LFS enabled' do
- %w(png zip anything).each do |filetype|
- path = "/#{DesignManagement.designs_directory}/file.#{filetype}"
- attributes = repository.attributes(path)
-
- expect(attributes['filter']).to eq('lfs')
- end
- end
+ it "returns the project's disk path" do
+ expect(subject.disk_path).to eq(project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix)
end
end
diff --git a/spec/models/design_management/version_spec.rb b/spec/models/design_management/version_spec.rb
index 8c0d7e99ae5..748bcbffd29 100644
--- a/spec/models/design_management/version_spec.rb
+++ b/spec/models/design_management/version_spec.rb
@@ -247,10 +247,12 @@ RSpec.describe DesignManagement::Version do
context 'there are a bunch of different designs in a variety of states' do
let_it_be(:version) do
- create(:design_version,
- created_designs: create_list(:design, 3),
- modified_designs: create_list(:design, 4),
- deleted_designs: create_list(:design, 5))
+ create(
+ :design_version,
+ created_designs: create_list(:design, 3),
+ modified_designs: create_list(:design, 4),
+ deleted_designs: create_list(:design, 5)
+ )
end
it 'puts them in the right buckets' do
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index a526f91ddc1..0c2a0ce45d4 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -115,11 +115,13 @@ RSpec.describe DiffNote do
describe '#create_diff_file callback' do
context 'merge request' do
let(:position) do
- Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
- new_path: "files/ruby/popen.rb",
- old_line: nil,
- new_line: 9,
- diff_refs: merge_request.diff_refs)
+ Gitlab::Diff::Position.new(
+ old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 9,
+ diff_refs: merge_request.diff_refs
+ )
end
subject { build(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) }
@@ -131,10 +133,12 @@ RSpec.describe DiffNote do
let(:diff_file) do
diffs = merge_request.diffs
raw_diff = diffs.diffable.raw_diffs(diffs.diff_options.merge(paths: ['files/ruby/popen.rb'])).first
- Gitlab::Diff::File.new(raw_diff,
- repository: diffs.project.repository,
- diff_refs: diffs.diff_refs,
- fallback_diff_refs: diffs.fallback_diff_refs)
+ Gitlab::Diff::File.new(
+ raw_diff,
+ repository: diffs.project.repository,
+ diff_refs: diffs.diff_refs,
+ fallback_diff_refs: diffs.fallback_diff_refs
+ )
end
let(:diff_line) { diff_file.diff_lines.first }
@@ -188,10 +192,12 @@ RSpec.describe DiffNote do
end
it 'raises an error' do
- expect { subject.save! }.to raise_error(::DiffNote::NoteDiffFileCreationError,
- "Failed to find diff line for: #{diff_file.file_path}, "\
- "old_line: #{position.old_line}"\
- ", new_line: #{position.new_line}")
+ expect { subject.save! }.to raise_error(
+ ::DiffNote::NoteDiffFileCreationError,
+ "Failed to find diff line for: #{diff_file.file_path}, "\
+ "old_line: #{position.old_line}"\
+ ", new_line: #{position.new_line}"
+ )
end
end
@@ -383,8 +389,7 @@ RSpec.describe DiffNote do
subject { create(:diff_note_on_commit, project: project, position: position, commit_id: commit.id) }
it "doesn't update the position" do
- is_expected.to have_attributes(original_position: position,
- position: position)
+ is_expected.to have_attributes(original_position: position, position: position)
end
end
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index dfb7de34993..87beba680d8 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -126,7 +126,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
expect(environment.errors[:external_url].first).to eq(expected_error_message)
else
expect(environment.errors[:external_url]).to be_empty,
- "There were unexpected errors: #{environment.errors.full_messages}"
+ "There were unexpected errors: #{environment.errors.full_messages}"
expect(environment.external_url).to eq(source_external_url)
end
end
@@ -660,15 +660,17 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
let(:build) { create(:ci_build, :success) }
let!(:deployment) do
- create(:deployment, :success,
- environment: environment,
- deployable: build,
- on_stop: 'close_app')
+ create(
+ :deployment,
+ :success,
+ environment: environment,
+ deployable: build,
+ on_stop: 'close_app'
+ )
end
let!(:close_action) do
- create(:ci_build, :manual, pipeline: build.pipeline,
- name: 'close_app')
+ create(:ci_build, :manual, pipeline: build.pipeline, name: 'close_app')
end
context 'when environment is available' do
@@ -750,8 +752,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
before do
project.add_developer(user)
- create(:protected_branch, :developers_can_merge,
- name: 'master', project: project)
+ create(:protected_branch, :developers_can_merge, name: 'master', project: project)
end
context 'when action did not yet finish' do
@@ -774,8 +775,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
context 'if action did finish' do
let!(:close_action) do
- create(:ci_build, :manual, :success,
- pipeline: pipeline, name: 'close_app_a')
+ create(:ci_build, :manual, :success, pipeline: pipeline, name: 'close_app_a')
end
it 'returns a new action of the same type' do
@@ -1258,8 +1258,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
describe '#deployment_platform' do
context 'when there is a deployment platform for environment' do
let!(:cluster) do
- create(:cluster, :provided_by_gcp,
- environment_scope: '*', projects: [project])
+ create(:cluster, :provided_by_gcp, environment_scope: '*', projects: [project])
end
it 'finds a deployment platform' do
@@ -1387,7 +1386,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
context 'when the environment is available' do
context 'with a deployment service' do
- let(:project) { create(:project, :with_prometheus_integration, :repository) }
+ let_it_be(:project) { create(:project, :with_prometheus_integration, :repository) }
context 'and a deployment' do
let!(:deployment) { create(:deployment, environment: environment) }
@@ -1460,7 +1459,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
end
context 'when the environment is unavailable' do
- let(:project) { create(:project, :with_prometheus_integration) }
+ let_it_be(:project) { create(:project, :with_prometheus_integration) }
before do
environment.stop
@@ -1487,7 +1486,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
end
describe '#metrics' do
- let(:project) { create(:project, :with_prometheus_integration) }
+ let_it_be(:project) { create(:project, :with_prometheus_integration) }
subject { environment.metrics }
@@ -1523,7 +1522,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
end
describe '#additional_metrics' do
- let(:project) { create(:project, :with_prometheus_integration) }
+ let_it_be(:project) { create(:project, :with_prometheus_integration) }
let(:metric_params) { [] }
subject { environment.additional_metrics(*metric_params) }
diff --git a/spec/models/environment_status_spec.rb b/spec/models/environment_status_spec.rb
index 2f1edf9ab94..9814eed8b45 100644
--- a/spec/models/environment_status_spec.rb
+++ b/spec/models/environment_status_spec.rb
@@ -17,6 +17,7 @@ RSpec.describe EnvironmentStatus do
it { is_expected.to delegate_method(:name).to(:environment) }
it { is_expected.to delegate_method(:deployed_at).to(:deployment) }
it { is_expected.to delegate_method(:status).to(:deployment) }
+ it { is_expected.to delegate_method(:deployable).to(:deployment) }
describe '#project' do
subject { environment_status.project }
@@ -177,11 +178,13 @@ RSpec.describe EnvironmentStatus do
let(:pipeline) { create(:ci_pipeline, sha: sha, project: forked) }
let(:merge_request) do
- create(:merge_request,
- source_project: forked,
- target_project: project,
- target_branch: 'master',
- head_pipeline: pipeline)
+ create(
+ :merge_request,
+ source_project: forked,
+ target_project: project,
+ target_branch: 'master',
+ head_pipeline: pipeline
+ )
end
it 'returns environment status' do
@@ -198,12 +201,14 @@ RSpec.describe EnvironmentStatus do
let(:pipeline) { create(:ci_pipeline, sha: sha, project: project) }
let(:merge_request) do
- create(:merge_request,
- source_project: project,
- source_branch: 'feature',
- target_project: project,
- target_branch: 'master',
- head_pipeline: pipeline)
+ create(
+ :merge_request,
+ source_project: project,
+ source_branch: 'feature',
+ target_project: project,
+ target_branch: 'master',
+ head_pipeline: pipeline
+ )
end
it 'returns environment status' do
@@ -265,6 +270,7 @@ RSpec.describe EnvironmentStatus do
context 'when environment is stopped' do
before do
+ stub_feature_flags(review_apps_redeploy_mr_widget: false)
environment.stop!
end
@@ -272,6 +278,17 @@ RSpec.describe EnvironmentStatus do
expect(subject.count).to eq(0)
end
end
+
+ context 'when environment is stopped and review_apps_redeploy_mr_widget is turned on' do
+ before do
+ stub_feature_flags(review_apps_redeploy_mr_widget: true)
+ environment.stop!
+ end
+
+ it 'returns environment regardless of status' do
+ expect(subject.count).to eq(1)
+ end
+ end
end
end
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 26795c0ea7f..34be6ec7fa9 100644
--- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb
+++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
+RSpec.describe ErrorTracking::ProjectErrorTrackingSetting, feature_category: :error_tracking do
include ReactiveCachingHelpers
include Gitlab::Routing
@@ -93,9 +93,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
context 'with sentry backend' do
- before do
- subject.integrated = false
- end
+ subject { build(:project_error_tracking_setting, project: project) }
it 'does not create a new client key' do
expect { subject.save! }.not_to change { ErrorTracking::ClientKey.count }
@@ -302,7 +300,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
it { expect(result[:issue].gitlab_commit_path).to eq(nil) }
end
- context 'when repo commit matches first release version' do
+ context 'when repo commit matches first relase version' do
let(:commit) { instance_double(Commit, id: commit_id) }
let(:repository) { instance_double(Repository, commit: commit) }
@@ -341,18 +339,19 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
describe '#update_issue' do
let(:result) { subject.update_issue(**opts) }
- let(:opts) { { issue_id: 1, params: {} } }
+ let(:issue_id) { 1 }
+ let(:opts) { { issue_id: issue_id, params: {} } }
before do
allow(subject).to receive(:sentry_client).and_return(sentry_client)
allow(sentry_client).to receive(:issue_details)
- .with({ issue_id: 1 })
+ .with({ issue_id: issue_id })
.and_return(Gitlab::ErrorTracking::DetailedError.new(project_id: sentry_project_id))
end
context 'when sentry response is successful' do
before do
- allow(sentry_client).to receive(:update_issue).with(opts).and_return(true)
+ allow(sentry_client).to receive(:update_issue).with(**opts).and_return(true)
end
it 'returns the successful response' do
@@ -362,7 +361,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
context 'when sentry raises an error' do
before do
- allow(sentry_client).to receive(:update_issue).with(opts).and_raise(StandardError)
+ allow(sentry_client).to receive(:update_issue).with(**opts).and_raise(StandardError)
end
it 'returns the successful response' do
@@ -391,7 +390,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
setting.update!(sentry_project_id: nil)
allow(sentry_client).to receive(:projects).and_return(sentry_projects)
- allow(sentry_client).to receive(:update_issue).with(opts).and_return(true)
+ allow(sentry_client).to receive(:update_issue).with(**opts).and_return(true)
end
it 'tries to backfill it from sentry API' do
@@ -419,6 +418,25 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
end
+
+ describe 'passing parameters to sentry client' do
+ include SentryClientHelpers
+
+ let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' }
+ let(:sentry_request_url) { "#{sentry_url}/issues/#{issue_id}/" }
+ let(:token) { 'test-token' }
+ let(:sentry_client) { ErrorTracking::SentryClient.new(sentry_url, token) }
+
+ before do
+ stub_sentry_request(sentry_request_url, :put, body: true)
+
+ allow(sentry_client).to receive(:update_issue).and_call_original
+ end
+
+ it 'returns the successful response' do
+ expect(result).to eq(updated: true)
+ end
+ end
end
describe 'slugs' do
diff --git a/spec/models/event_collection_spec.rb b/spec/models/event_collection_spec.rb
index 13983dcfde3..cab2444c174 100644
--- a/spec/models/event_collection_spec.rb
+++ b/spec/models/event_collection_spec.rb
@@ -19,8 +19,7 @@ RSpec.describe EventCollection do
context 'with project events' do
let_it_be(:push_event_payloads) do
Array.new(9) do
- create(:push_event_payload,
- event: create(:push_event, project: project, author: user))
+ create(:push_event_payload, event: create(:push_event, project: project, author: user))
end
end
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index 931d12b7109..3e4fe57c59b 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -203,9 +203,7 @@ RSpec.describe Event, feature_category: :user_profile do
let(:target) { nil }
let(:event) do
- described_class.new(project: project,
- target: target,
- author_id: author.id)
+ described_class.new(project: project, target: target, author_id: author.id)
end
context 'for an issue' do
@@ -304,9 +302,7 @@ RSpec.describe Event, feature_category: :user_profile do
let(:note_on_design) { create(:note_on_design, author: author, noteable: design, project: project) }
let(:milestone_on_project) { create(:milestone, project: project) }
let(:event) do
- described_class.new(project: project,
- target: target,
- author_id: author.id)
+ described_class.new(project: project, target: target, author_id: author.id)
end
before do
@@ -902,8 +898,10 @@ RSpec.describe Event, feature_category: :user_profile do
it "deletes the redis key for if the project was inactive" do
Gitlab::Redis::SharedState.with do |redis|
- expect(redis).to receive(:hdel).with('inactive_projects_deletion_warning_email_notified',
- "project:#{project.id}")
+ expect(redis).to receive(:hdel).with(
+ 'inactive_projects_deletion_warning_email_notified',
+ "project:#{project.id}"
+ )
end
project.touch(:last_activity_at, time: 1.year.ago)
@@ -1138,11 +1136,13 @@ RSpec.describe Event, feature_category: :user_profile do
def create_push_event(project, user)
event = create(:push_event, project: project, author: user)
- create(:push_event_payload,
- event: event,
- commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
- commit_count: 0,
- ref: 'master')
+ create(
+ :push_event_payload,
+ event: event,
+ commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2',
+ commit_count: 0,
+ ref: 'master'
+ )
event
end
diff --git a/spec/models/generic_commit_status_spec.rb b/spec/models/generic_commit_status_spec.rb
index fe22b20ecf9..c477dec237f 100644
--- a/spec/models/generic_commit_status_spec.rb
+++ b/spec/models/generic_commit_status_spec.rb
@@ -8,8 +8,7 @@ RSpec.describe GenericCommitStatus do
let(:external_url) { 'http://example.gitlab.com/status' }
let(:generic_commit_status) do
- create(:generic_commit_status, pipeline: pipeline,
- target_url: external_url)
+ create(:generic_commit_status, pipeline: pipeline, target_url: external_url)
end
describe 'validations' do
diff --git a/spec/models/group_group_link_spec.rb b/spec/models/group_group_link_spec.rb
index eec8fe0ef71..780196e6c8c 100644
--- a/spec/models/group_group_link_spec.rb
+++ b/spec/models/group_group_link_spec.rb
@@ -5,9 +5,28 @@ require 'spec_helper'
RSpec.describe GroupGroupLink do
let_it_be(:group) { create(:group) }
let_it_be(:shared_group) { create(:group) }
- let_it_be(:group_group_link) do
- create(:group_group_link, shared_group: shared_group,
- shared_with_group: group)
+
+ describe 'validation' do
+ let_it_be(:group_group_link) do
+ create(:group_group_link, shared_group: shared_group, shared_with_group: group)
+ end
+
+ it { is_expected.to validate_presence_of(:shared_group) }
+
+ it do
+ is_expected.to(
+ validate_uniqueness_of(:shared_group_id)
+ .scoped_to(:shared_with_group_id)
+ .with_message('The group has already been shared with this group'))
+ end
+
+ it { is_expected.to validate_presence_of(:shared_with_group) }
+ it { is_expected.to validate_presence_of(:group_access) }
+
+ it do
+ is_expected.to(
+ validate_inclusion_of(:group_access).in_array(Gitlab::Access.values))
+ end
end
describe 'relations' do
@@ -16,42 +35,51 @@ RSpec.describe GroupGroupLink do
end
describe 'scopes' do
- describe '.non_guests' do
- let!(:group_group_link_reporter) { create :group_group_link, :reporter }
- let!(:group_group_link_maintainer) { create :group_group_link, :maintainer }
- let!(:group_group_link_owner) { create :group_group_link, :owner }
- let!(:group_group_link_guest) { create :group_group_link, :guest }
-
- it 'returns all records which are greater than Guests access' do
- expect(described_class.non_guests).to match_array([
- group_group_link_reporter, group_group_link,
- group_group_link_maintainer, group_group_link_owner
- ])
- end
- end
-
- describe '.with_owner_or_maintainer_access' do
+ context 'for scopes fetching records based on access levels' do
+ let_it_be(:group_group_link_guest) { create :group_group_link, :guest }
+ let_it_be(:group_group_link_reporter) { create :group_group_link, :reporter }
+ let_it_be(:group_group_link_developer) { create :group_group_link, :developer }
let_it_be(:group_group_link_maintainer) { create :group_group_link, :maintainer }
let_it_be(:group_group_link_owner) { create :group_group_link, :owner }
- let_it_be(:group_group_link_reporter) { create :group_group_link, :reporter }
- let_it_be(:group_group_link_guest) { create :group_group_link, :guest }
- it 'returns all records which have OWNER or MAINTAINER access' do
- expect(described_class.with_owner_or_maintainer_access).to match_array([
- group_group_link_maintainer,
- group_group_link_owner
- ])
+ describe '.non_guests' do
+ it 'returns all records which are greater than Guests access' do
+ expect(described_class.non_guests).to match_array([
+ group_group_link_reporter, group_group_link_developer,
+ group_group_link_maintainer, group_group_link_owner
+ ])
+ end
end
- end
- describe '.with_owner_access' do
- let_it_be(:group_group_link_maintainer) { create :group_group_link, :maintainer }
- let_it_be(:group_group_link_owner) { create :group_group_link, :owner }
- let_it_be(:group_group_link_reporter) { create :group_group_link, :reporter }
- let_it_be(:group_group_link_guest) { create :group_group_link, :guest }
+ describe '.with_owner_or_maintainer_access' do
+ it 'returns all records which have OWNER or MAINTAINER access' do
+ expect(described_class.with_owner_or_maintainer_access).to match_array([
+ group_group_link_maintainer,
+ group_group_link_owner
+ ])
+ end
+ end
- it 'returns all records which have OWNER access' do
- expect(described_class.with_owner_access).to match_array([group_group_link_owner])
+ describe '.with_owner_access' do
+ it 'returns all records which have OWNER access' do
+ expect(described_class.with_owner_access).to match_array([group_group_link_owner])
+ end
+ end
+
+ describe '.with_developer_access' do
+ it 'returns all records which have DEVELOPER access' do
+ expect(described_class.with_developer_access).to match_array([group_group_link_developer])
+ end
+ end
+
+ describe '.with_developer_maintainer_owner_access' do
+ it 'returns all records which have DEVELOPER, MAINTAINER or OWNER access' do
+ expect(described_class.with_developer_maintainer_owner_access).to match_array([
+ group_group_link_developer,
+ group_group_link_owner,
+ group_group_link_maintainer
+ ])
+ end
end
end
@@ -93,6 +121,15 @@ RSpec.describe GroupGroupLink do
let_it_be(:sub_shared_group) { create(:group, parent: shared_group) }
let_it_be(:other_group) { create(:group) }
+ let_it_be(:group_group_link_1) do
+ create(
+ :group_group_link,
+ shared_group: shared_group,
+ shared_with_group: group,
+ group_access: Gitlab::Access::DEVELOPER
+ )
+ end
+
let_it_be(:group_group_link_2) do
create(
:group_group_link,
@@ -125,7 +162,7 @@ RSpec.describe GroupGroupLink do
expect(described_class.all.count).to eq(4)
expect(distinct_group_group_links.count).to eq(2)
- expect(distinct_group_group_links).to include(group_group_link)
+ expect(distinct_group_group_links).to include(group_group_link_1)
expect(distinct_group_group_links).not_to include(group_group_link_2)
expect(distinct_group_group_links).not_to include(group_group_link_3)
expect(distinct_group_group_links).to include(group_group_link_4)
@@ -133,27 +170,9 @@ RSpec.describe GroupGroupLink do
end
end
- describe 'validation' do
- it { is_expected.to validate_presence_of(:shared_group) }
-
- it do
- is_expected.to(
- validate_uniqueness_of(:shared_group_id)
- .scoped_to(:shared_with_group_id)
- .with_message('The group has already been shared with this group'))
- end
-
- it { is_expected.to validate_presence_of(:shared_with_group) }
- it { is_expected.to validate_presence_of(:group_access) }
-
- it do
- is_expected.to(
- validate_inclusion_of(:group_access).in_array(Gitlab::Access.values))
- end
- end
-
describe '#human_access' do
it 'delegates to Gitlab::Access' do
+ group_group_link = create(:group_group_link, :reporter)
expect(Gitlab::Access).to receive(:human_access).with(group_group_link.group_access)
group_group_link.human_access
@@ -161,6 +180,8 @@ RSpec.describe GroupGroupLink do
end
describe 'search by group name' do
+ let_it_be(:group_group_link) { create(:group_group_link, :reporter, shared_with_group: group) }
+
it { expect(described_class.search(group.name)).to eq([group_group_link]) }
it { expect(described_class.search('not-a-group-name')).to be_empty }
end
diff --git a/spec/models/group_label_spec.rb b/spec/models/group_label_spec.rb
index ec9244d5eb5..701348baf48 100644
--- a/spec/models/group_label_spec.rb
+++ b/spec/models/group_label_spec.rb
@@ -41,11 +41,13 @@ RSpec.describe GroupLabel do
context 'cross-project' do
let(:namespace) { build_stubbed(:namespace) }
- let(:source_project) { build_stubbed(:project, name: 'project-1', namespace: namespace) }
- let(:target_project) { build_stubbed(:project, name: 'project-2', namespace: namespace) }
+ let(:source_project) { build_stubbed(:project, namespace: namespace) }
+ let(:target_project) { build_stubbed(:project, namespace: namespace) }
it 'returns a String reference to the object' do
- expect(label.to_reference(source_project, target_project: target_project)).to eq %(project-1~#{label.id})
+ expect(label.to_reference(source_project, target_project: target_project)).to(
+ eq("#{source_project.path}~#{label.id}")
+ )
end
end
@@ -56,4 +58,39 @@ RSpec.describe GroupLabel do
end
end
end
+
+ describe '#preloaded_parent_container' do
+ let_it_be(:label) { create(:group_label) }
+
+ before do
+ label.reload # ensure associations are not loaded
+ end
+
+ context 'when group is loaded' do
+ it 'does not invoke a DB query' do
+ label.group
+
+ count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count
+ expect(count).to eq(0)
+ expect(label.preloaded_parent_container).to eq(label.group)
+ end
+ end
+
+ context 'when parent_container is loaded' do
+ it 'does not invoke a DB query' do
+ label.parent_container
+
+ count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count
+ expect(count).to eq(0)
+ expect(label.preloaded_parent_container).to eq(label.parent_container)
+ end
+ end
+
+ context 'when none of them are loaded' do
+ it 'invokes a DB query' do
+ count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count
+ expect(count).to eq(1)
+ end
+ end
+ end
end
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 0a05c558d45..67e4e128019 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -40,7 +40,14 @@ RSpec.describe Group, feature_category: :subgroups do
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) }
+
it { is_expected.to have_many(:bulk_import_exports).class_name('BulkImports::Export') }
+
+ it do
+ is_expected.to have_many(:bulk_import_entities).class_name('BulkImports::Entity')
+ .with_foreign_key(:namespace_id).inverse_of(:group)
+ end
+
it { is_expected.to have_many(:contacts).class_name('CustomerRelations::Contact') }
it { is_expected.to have_many(:organizations).class_name('CustomerRelations::Organization') }
it { is_expected.to have_many(:protected_branches).inverse_of(:group).with_foreign_key(:namespace_id) }
@@ -448,6 +455,8 @@ RSpec.describe Group, feature_category: :subgroups do
it_behaves_like 'a BulkUsersByEmailLoad model'
+ it_behaves_like 'ensures runners_token is prefixed', :group
+
context 'after initialized' do
it 'has a group_feature' do
expect(described_class.new.group_feature).to be_present
@@ -955,6 +964,23 @@ RSpec.describe Group, feature_category: :subgroups do
end
end
+ describe '.with_project_creation_levels' do
+ let_it_be(:group_1) { create(:group, project_creation_level: Gitlab::Access::NO_ONE_PROJECT_ACCESS) }
+ let_it_be(:group_2) { create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) }
+ let_it_be(:group_3) { create(:group, project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS) }
+ let_it_be(:group_4) { create(:group, project_creation_level: nil) }
+
+ it 'returns groups with the specified project creation levels' do
+ result = described_class.with_project_creation_levels([
+ Gitlab::Access::NO_ONE_PROJECT_ACCESS,
+ Gitlab::Access::MAINTAINER_PROJECT_ACCESS
+ ])
+
+ expect(result).to include(group_1, group_3)
+ expect(result).not_to include(group_2, group_4)
+ end
+ end
+
describe '.project_creation_allowed' do
let_it_be(:group_1) { create(:group, project_creation_level: Gitlab::Access::NO_ONE_PROJECT_ACCESS) }
let_it_be(:group_2) { create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) }
@@ -967,6 +993,22 @@ RSpec.describe Group, feature_category: :subgroups do
expect(result).to include(group_2, group_3, group_4)
expect(result).not_to include(group_1)
end
+
+ context 'when the application_setting is set to `NO_ONE_PROJECT_ACCESS`' do
+ before do
+ stub_application_setting(default_project_creation: Gitlab::Access::NO_ONE_PROJECT_ACCESS)
+ end
+
+ it 'only includes groups where project creation is allowed' do
+ result = described_class.project_creation_allowed
+
+ expect(result).to include(group_2, group_3)
+
+ # group_4 won't be included because it has `project_creation_level: nil`,
+ # and that means it behaves like the value of the application_setting will inherited.
+ expect(result).not_to include(group_1, group_4)
+ end
+ end
end
describe 'by_ids_or_paths' do
@@ -2409,8 +2451,7 @@ RSpec.describe Group, feature_category: :subgroups do
let(:shared_with_group) { create(:group, parent: group) }
before do
- create(:group_group_link, shared_group: nested_group,
- shared_with_group: shared_with_group)
+ create(:group_group_link, shared_group: nested_group, shared_with_group: shared_with_group)
end
subject(:related_group_ids) { nested_group.related_group_ids }
@@ -3115,11 +3156,11 @@ RSpec.describe Group, feature_category: :subgroups do
describe '.organizations' do
it 'returns organizations belonging to the group' do
- organization1 = create(:organization, group: group)
- create(:organization)
- organization3 = create(:organization, group: group)
+ crm_organization1 = create(:crm_organization, group: group)
+ create(:crm_organization)
+ crm_organization3 = create(:crm_organization, group: group)
- expect(group.organizations).to contain_exactly(organization1, organization3)
+ expect(group.organizations).to contain_exactly(crm_organization1, crm_organization3)
end
end
@@ -3584,6 +3625,13 @@ RSpec.describe Group, feature_category: :subgroups do
end
end
+ describe '#content_editor_on_issues_feature_flag_enabled?' do
+ it_behaves_like 'checks self and root ancestor feature flag' do
+ let(:feature_flag) { :content_editor_on_issues }
+ let(:feature_flag_method) { :content_editor_on_issues_feature_flag_enabled? }
+ end
+ end
+
describe '#work_items_feature_flag_enabled?' do
it_behaves_like 'checks self and root ancestor feature flag' do
let(:feature_flag) { :work_items }
@@ -3696,7 +3744,7 @@ RSpec.describe Group, feature_category: :subgroups do
end
end
- describe '#usage_quotas_enabled?', feature_category: :subscription_cost_management, unless: Gitlab.ee? do
+ describe '#usage_quotas_enabled?', feature_category: :consumables_cost_management, unless: Gitlab.ee? do
using RSpec::Parameterized::TableSyntax
where(:feature_enabled, :root_group, :result) do
diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb
index ad5f01fe056..254b8c2520b 100644
--- a/spec/models/hooks/web_hook_spec.rb
+++ b/spec/models/hooks/web_hook_spec.rb
@@ -267,7 +267,7 @@ RSpec.describe WebHook, feature_category: :integrations do
end
context 'without url variables' do
- subject(:hook) { build_stubbed(:project_hook, project: project, url: 'http://example.com') }
+ subject(:hook) { build_stubbed(:project_hook, project: project, url: 'http://example.com', url_variables: nil) }
it 'does not reset url variables' do
hook.url = 'http://example.com/{one}/{two}'
@@ -346,7 +346,7 @@ RSpec.describe WebHook, feature_category: :integrations do
end
it 'is 10 minutes' do
- expect(hook.next_backoff).to eq(described_class::INITIAL_BACKOFF)
+ expect(hook.next_backoff).to eq(WebHooks::AutoDisabling::INITIAL_BACKOFF)
end
end
@@ -356,7 +356,7 @@ RSpec.describe WebHook, feature_category: :integrations do
end
it 'is twice the initial value' do
- expect(hook.next_backoff).to eq(2 * described_class::INITIAL_BACKOFF)
+ expect(hook.next_backoff).to eq(2 * WebHooks::AutoDisabling::INITIAL_BACKOFF)
end
end
@@ -366,7 +366,7 @@ RSpec.describe WebHook, feature_category: :integrations do
end
it 'grows exponentially' do
- expect(hook.next_backoff).to eq(2 * 2 * 2 * described_class::INITIAL_BACKOFF)
+ expect(hook.next_backoff).to eq(2 * 2 * 2 * WebHooks::AutoDisabling::INITIAL_BACKOFF)
end
end
@@ -376,7 +376,7 @@ RSpec.describe WebHook, feature_category: :integrations do
end
it 'does not exceed the max backoff value' do
- expect(hook.next_backoff).to eq(described_class::MAX_BACKOFF)
+ expect(hook.next_backoff).to eq(WebHooks::AutoDisabling::MAX_BACKOFF)
end
end
end
@@ -498,13 +498,13 @@ RSpec.describe WebHook, feature_category: :integrations do
end
it 'reduces to MAX_FAILURES' do
- expect { hook.backoff! }.to change(hook, :recent_failures).to(described_class::MAX_FAILURES)
+ expect { hook.backoff! }.to change(hook, :recent_failures).to(WebHooks::AutoDisabling::MAX_FAILURES)
end
end
context 'when the recent failure value is MAX_FAILURES' do
before do
- hook.update!(recent_failures: described_class::MAX_FAILURES, disabled_until: 1.hour.ago)
+ hook.update!(recent_failures: WebHooks::AutoDisabling::MAX_FAILURES, disabled_until: 1.hour.ago)
end
it 'does not change recent_failures' do
@@ -514,7 +514,7 @@ RSpec.describe WebHook, feature_category: :integrations do
context 'when we have exhausted the grace period' do
before do
- hook.update!(recent_failures: described_class::FAILURE_THRESHOLD)
+ hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD)
end
it 'sets disabled_until to the next backoff' do
@@ -527,8 +527,8 @@ RSpec.describe WebHook, feature_category: :integrations do
context 'when we have backed off MAX_FAILURES times' do
before do
- stub_const("#{described_class}::MAX_FAILURES", 5)
- (described_class::FAILURE_THRESHOLD + 5).times { hook.backoff! }
+ stub_const("WebHooks::AutoDisabling::MAX_FAILURES", 5)
+ (WebHooks::AutoDisabling::FAILURE_THRESHOLD + 5).times { hook.backoff! }
end
it 'does not let the backoff count exceed the maximum failure count' do
@@ -544,7 +544,7 @@ RSpec.describe WebHook, feature_category: :integrations do
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)
+ expect(hook.backoff_count).to eq(WebHooks::AutoDisabling::MAX_FAILURES)
end
end
end
@@ -567,7 +567,7 @@ RSpec.describe WebHook, feature_category: :integrations do
end
it 'does not update the hook if the the failure count exceeds the maximum value' do
- hook.recent_failures = described_class::MAX_FAILURES
+ hook.recent_failures = WebHooks::AutoDisabling::MAX_FAILURES
sql_count = ActiveRecord::QueryRecorder.new { hook.failed! }.count
diff --git a/spec/models/import_export_upload_spec.rb b/spec/models/import_export_upload_spec.rb
index e13f504b82a..9811dbf60e3 100644
--- a/spec/models/import_export_upload_spec.rb
+++ b/spec/models/import_export_upload_spec.rb
@@ -51,7 +51,7 @@ RSpec.describe ImportExportUpload do
let(:after_commit_callbacks) { described_class._commit_callbacks.select { |cb| cb.kind == :after } }
def find_callback(callbacks, key)
- callbacks.find { |cb| cb.instance_variable_get(:@key) == key }
+ callbacks.find { |cb| cb.filter == key }
end
it 'export file is stored in after_commit callback' do
diff --git a/spec/models/import_failure_spec.rb b/spec/models/import_failure_spec.rb
index 9fee1b0ae7b..101da1212cf 100644
--- a/spec/models/import_failure_spec.rb
+++ b/spec/models/import_failure_spec.rb
@@ -8,9 +8,18 @@ RSpec.describe ImportFailure do
let_it_be(:correlation_id) { 'ABC' }
let_it_be(:hard_failure) { create(:import_failure, :hard_failure, project: project, correlation_id_value: correlation_id) }
let_it_be(:soft_failure) { create(:import_failure, :soft_failure, project: project, correlation_id_value: correlation_id) }
+ let_it_be(:github_import_failure) { create(:import_failure, :github_import_failure, project: project) }
let_it_be(:unrelated_failure) { create(:import_failure, project: project) }
- it 'returns hard failures given a correlation ID' do
+ it 'returns failures with external_identifiers' do
+ expect(ImportFailure.with_external_identifiers).to match_array([github_import_failure])
+ end
+
+ it 'returns failures for the given correlation ID' do
+ expect(ImportFailure.failures_by_correlation_id(correlation_id)).to match_array([hard_failure, soft_failure])
+ end
+
+ it 'returns hard failures for the given correlation ID' do
expect(ImportFailure.hard_failures_by_correlation_id(correlation_id)).to eq([hard_failure])
end
@@ -45,5 +54,21 @@ RSpec.describe ImportFailure do
it { is_expected.to validate_presence_of(:group) }
end
+
+ describe '#external_identifiers' do
+ it { is_expected.to allow_value({ note_id: 234, noteable_id: 345, noteable_type: 'MergeRequest' }).for(:external_identifiers) }
+ it { is_expected.not_to allow_value(nil).for(:external_identifiers) }
+ it { is_expected.not_to allow_value({ ids: [123] }).for(:external_identifiers) }
+
+ it 'allows up to 3 fields' do
+ is_expected.not_to allow_value({
+ note_id: 234,
+ noteable_id: 345,
+ noteable_type: 'MergeRequest',
+ object_type: 'pull_request',
+ extra_attribute: 'abc'
+ }).for(:external_identifiers)
+ end
+ end
end
end
diff --git a/spec/models/instance_configuration_spec.rb b/spec/models/instance_configuration_spec.rb
index f57667cc5d6..7710a05820c 100644
--- a/spec/models/instance_configuration_spec.rb
+++ b/spec/models/instance_configuration_spec.rb
@@ -73,7 +73,7 @@ RSpec.describe InstanceConfiguration do
it 'returns Settings.pages' do
gitlab_pages.delete(:ip_address)
- expect(gitlab_pages).to eq(Settings.pages.symbolize_keys)
+ expect(gitlab_pages).to eq(Settings.pages.to_hash.deep_symbolize_keys)
end
it 'returns the GitLab\'s pages host ip address' do
@@ -189,7 +189,6 @@ RSpec.describe InstanceConfiguration do
plan: plan1,
ci_pipeline_size: 1001,
ci_active_jobs: 1002,
- ci_active_pipelines: 1003,
ci_project_subscriptions: 1004,
ci_pipeline_schedules: 1005,
ci_needs_size_limit: 1006,
@@ -200,7 +199,6 @@ RSpec.describe InstanceConfiguration do
plan: plan2,
ci_pipeline_size: 1101,
ci_active_jobs: 1102,
- ci_active_pipelines: 1103,
ci_project_subscriptions: 1104,
ci_pipeline_schedules: 1105,
ci_needs_size_limit: 1106,
@@ -214,7 +212,6 @@ RSpec.describe InstanceConfiguration do
expect(ci_cd_size_limits[:Plan1]).to eq({
ci_active_jobs: 1002,
- ci_active_pipelines: 1003,
ci_needs_size_limit: 1006,
ci_pipeline_schedules: 1005,
ci_pipeline_size: 1001,
@@ -224,7 +221,6 @@ RSpec.describe InstanceConfiguration do
})
expect(ci_cd_size_limits[:Plan2]).to eq({
ci_active_jobs: 1102,
- ci_active_pipelines: 1103,
ci_needs_size_limit: 1106,
ci_pipeline_schedules: 1105,
ci_pipeline_size: 1101,
diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb
index a247881899f..46c30074ae7 100644
--- a/spec/models/integration_spec.rb
+++ b/spec/models/integration_spec.rb
@@ -1092,7 +1092,7 @@ RSpec.describe Integration, feature_category: :integrations do
field :foo_dt, storage: :data_fields
field :bar, type: 'password'
- field :password
+ field :password, is_secret: true
field :webhook
diff --git a/spec/models/integrations/apple_app_store_spec.rb b/spec/models/integrations/apple_app_store_spec.rb
index 1a57f556895..70b32a15148 100644
--- a/spec/models/integrations/apple_app_store_spec.rb
+++ b/spec/models/integrations/apple_app_store_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do
it { is_expected.to validate_presence_of :app_store_issuer_id }
it { is_expected.to validate_presence_of :app_store_key_id }
it { is_expected.to validate_presence_of :app_store_private_key }
+ it { is_expected.to validate_presence_of :app_store_private_key_file_name }
it { is_expected.to allow_value('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee').for(:app_store_issuer_id) }
it { is_expected.not_to allow_value('abcde').for(:app_store_issuer_id) }
it { is_expected.to allow_value(File.read('spec/fixtures/ssl_key.pem')).for(:app_store_private_key) }
@@ -28,8 +29,8 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do
describe '#fields' do
it 'returns custom fields' do
- expect(apple_app_store_integration.fields.pluck(:name)).to eq(%w[app_store_issuer_id app_store_key_id
- app_store_private_key])
+ expect(apple_app_store_integration.fields.pluck(:name)).to match_array(%w[app_store_issuer_id app_store_key_id
+ app_store_private_key app_store_private_key_file_name])
end
end
@@ -40,8 +41,8 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do
end
it 'returns false for an invalid request' do
- allow(AppStoreConnect::Client).to receive_message_chain(:new,
-:apps).and_return({ errors: [title: "error title"] })
+ allow(AppStoreConnect::Client).to receive_message_chain(:new, :apps)
+ .and_return({ errors: [title: "error title"] })
expect(apple_app_store_integration.test[:success]).to be false
end
end
@@ -80,6 +81,12 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do
value: apple_app_store_integration.app_store_key_id,
masked: true,
public: false
+ },
+ {
+ key: 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64',
+ value: described_class::IS_KEY_CONTENT_BASE64,
+ masked: false,
+ public: false
}
]
diff --git a/spec/models/integrations/buildkite_spec.rb b/spec/models/integrations/buildkite_spec.rb
index 5f62c68bd2b..29c649af6c6 100644
--- a/spec/models/integrations/buildkite_spec.rb
+++ b/spec/models/integrations/buildkite_spec.rb
@@ -146,9 +146,10 @@ RSpec.describe Integrations::Buildkite, :use_clean_rails_memory_store_caching do
def stub_request(status: 200, body: nil)
body ||= %q({"status":"success"})
- stub_full_request(buildkite_full_url)
- .to_return(status: status,
- headers: { 'Content-Type' => 'application/json' },
- body: body)
+ stub_full_request(buildkite_full_url).to_return(
+ status: status,
+ headers: { 'Content-Type' => 'application/json' },
+ body: body
+ )
end
end
diff --git a/spec/models/integrations/campfire_spec.rb b/spec/models/integrations/campfire_spec.rb
index ae923cd38fc..38d3d89cdbf 100644
--- a/spec/models/integrations/campfire_spec.rb
+++ b/spec/models/integrations/campfire_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Integrations::Campfire do
+RSpec.describe Integrations::Campfire, feature_category: :integrations do
include StubRequests
it_behaves_like Integrations::ResetSecretFields do
@@ -88,4 +88,16 @@ RSpec.describe Integrations::Campfire do
expect(WebMock).not_to have_requested(:post, '*/room/.*/speak.json')
end
end
+
+ describe '#log_error' do
+ subject { described_class.new.log_error('error') }
+
+ it 'logs an error' do
+ expect(Gitlab::IntegrationsLogger).to receive(:error).with(
+ hash_including(integration_class: 'Integrations::Campfire', message: 'error')
+ ).and_call_original
+
+ is_expected.to be_truthy
+ end
+ end
end
diff --git a/spec/models/integrations/chat_message/deployment_message_spec.rb b/spec/models/integrations/chat_message/deployment_message_spec.rb
index 8da27ef5aa0..d16c191bd08 100644
--- a/spec/models/integrations/chat_message/deployment_message_spec.rb
+++ b/spec/models/integrations/chat_message/deployment_message_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Integrations::ChatMessage::DeploymentMessage do
let_it_be(:user) { create(:user, name: 'John Smith', username: 'smith') }
let_it_be(:namespace) { create(:namespace, name: 'myspace') }
- let_it_be(:project) { create(:project, :repository, namespace: namespace, name: 'myproject') }
+ let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'myproject') }
let_it_be(:commit) { project.commit('HEAD') }
let_it_be(:ci_build) { create(:ci_build, project: project) }
let_it_be(:environment) { create(:environment, name: 'myenvironment', project: project) }
diff --git a/spec/models/integrations/every_integration_spec.rb b/spec/models/integrations/every_integration_spec.rb
index 8666ef512fc..c39a3486eb4 100644
--- a/spec/models/integrations/every_integration_spec.rb
+++ b/spec/models/integrations/every_integration_spec.rb
@@ -11,9 +11,9 @@ RSpec.describe 'Every integration' do
let(:integration) { integration_class.new }
context 'secret fields', :aggregate_failures do
- it "uses type: 'password' for all secret fields, except when bypassed" do
+ it "uses type: 'password' for all secret fields" do
integration.fields.each do |field|
- next unless Integrations::Field::SECRET_NAME.match?(field[:name]) && field[:is_secret]
+ next unless field[:is_secret]
expect(field[:type]).to eq('password'),
"Field '#{field[:name]}' should use type 'password'"
diff --git a/spec/models/integrations/ewm_spec.rb b/spec/models/integrations/ewm_spec.rb
index dc48a2c982f..4f4ff038b19 100644
--- a/spec/models/integrations/ewm_spec.rb
+++ b/spec/models/integrations/ewm_spec.rb
@@ -31,27 +31,27 @@ RSpec.describe Integrations::Ewm do
describe "ReferencePatternValidation" do
it "extracts bug" do
- expect(described_class.reference_pattern.match("This is bug 123")[:issue]).to eq("bug 123")
+ expect(subject.reference_pattern.match("This is bug 123")[:issue]).to eq("bug 123")
end
it "extracts task" do
- expect(described_class.reference_pattern.match("This is task 123.")[:issue]).to eq("task 123")
+ expect(subject.reference_pattern.match("This is task 123.")[:issue]).to eq("task 123")
end
it "extracts work item" do
- expect(described_class.reference_pattern.match("This is work item 123 now")[:issue]).to eq("work item 123")
+ expect(subject.reference_pattern.match("This is work item 123 now")[:issue]).to eq("work item 123")
end
it "extracts workitem" do
- expect(described_class.reference_pattern.match("workitem 123 at the beginning")[:issue]).to eq("workitem 123")
+ expect(subject.reference_pattern.match("workitem 123 at the beginning")[:issue]).to eq("workitem 123")
end
it "extracts defect" do
- expect(described_class.reference_pattern.match("This is defect 123 defect")[:issue]).to eq("defect 123")
+ expect(subject.reference_pattern.match("This is defect 123 defect")[:issue]).to eq("defect 123")
end
it "extracts rtcwi" do
- expect(described_class.reference_pattern.match("This is rtcwi 123")[:issue]).to eq("rtcwi 123")
+ expect(subject.reference_pattern.match("This is rtcwi 123")[:issue]).to eq("rtcwi 123")
end
end
end
diff --git a/spec/models/integrations/field_spec.rb b/spec/models/integrations/field_spec.rb
index c30f9ef0d7b..ca71dd0e6d3 100644
--- a/spec/models/integrations/field_spec.rb
+++ b/spec/models/integrations/field_spec.rb
@@ -15,8 +15,8 @@ RSpec.describe ::Integrations::Field do
end
describe '#initialize' do
- it 'sets type password for secret names' do
- attrs[:name] = 'token'
+ it 'sets type password for secret fields' do
+ attrs[:is_secret] = true
attrs[:type] = 'text'
expect(field[:type]).to eq('password')
@@ -84,7 +84,7 @@ RSpec.describe ::Integrations::Field do
when :type
eq 'text'
when :is_secret
- eq true
+ eq false
else
be_nil
end
@@ -175,16 +175,6 @@ RSpec.describe ::Integrations::Field do
it { is_expected.to be_secret }
end
- %w[token api_token api_key secret_key secret_sauce password passphrase].each do |name|
- context "when named #{name}" do
- before do
- attrs[:name] = name
- end
-
- it { is_expected.to be_secret }
- end
- end
-
context "when named url" do
before do
attrs[:name] = :url
diff --git a/spec/models/integrations/gitlab_slack_application_spec.rb b/spec/models/integrations/gitlab_slack_application_spec.rb
new file mode 100644
index 00000000000..68476dde2a3
--- /dev/null
+++ b/spec/models/integrations/gitlab_slack_application_spec.rb
@@ -0,0 +1,337 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::GitlabSlackApplication, feature_category: :integrations do
+ include AfterNextHelpers
+
+ it_behaves_like Integrations::BaseSlackNotification, factory: :gitlab_slack_application_integration do
+ before do
+ stub_request(:post, "#{::Slack::API::BASE_URL}/chat.postMessage").to_return(body: '{"ok":true}')
+ end
+ end
+
+ describe 'validations' do
+ it { is_expected.not_to validate_presence_of(:webhook) }
+ end
+
+ describe 'default values' do
+ it { expect(subject.category).to eq(:chat) }
+
+ it { is_expected.not_to be_alert_events }
+ it { is_expected.not_to be_commit_events }
+ it { is_expected.not_to be_confidential_issues_events }
+ it { is_expected.not_to be_confidential_note_events }
+ it { is_expected.not_to be_deployment_events }
+ it { is_expected.not_to be_issues_events }
+ it { is_expected.not_to be_job_events }
+ it { is_expected.not_to be_merge_requests_events }
+ it { is_expected.not_to be_note_events }
+ it { is_expected.not_to be_pipeline_events }
+ it { is_expected.not_to be_push_events }
+ it { is_expected.not_to be_tag_push_events }
+ it { is_expected.not_to be_vulnerability_events }
+ it { is_expected.not_to be_wiki_page_events }
+ end
+
+ describe '#execute' do
+ let_it_be(:user) { build_stubbed(:user) }
+
+ let(:slack_integration) { build(:slack_integration) }
+ let(:data) { Gitlab::DataBuilder::Push.build_sample(integration.project, user) }
+ let(:slack_api_method_uri) { "#{::Slack::API::BASE_URL}/chat.postMessage" }
+
+ let(:mock_message) do
+ instance_double(Integrations::ChatMessage::PushMessage, attachments: ['foo'], pretext: 'bar')
+ end
+
+ subject(:integration) { build(:gitlab_slack_application_integration, slack_integration: slack_integration) }
+
+ before do
+ allow(integration).to receive(:get_message).and_return(mock_message)
+ allow(integration).to receive(:log_usage)
+ end
+
+ def stub_slack_request(channel: '#push_channel', success: true)
+ post_body = {
+ body: {
+ attachments: mock_message.attachments,
+ text: mock_message.pretext,
+ unfurl_links: false,
+ unfurl_media: false,
+ channel: channel
+ }
+ }
+
+ response = { ok: success }.to_json
+
+ stub_request(:post, slack_api_method_uri).with(post_body)
+ .to_return(body: response, headers: { 'Content-Type' => 'application/json; charset=utf-8' })
+ end
+
+ it 'notifies Slack' do
+ stub_slack_request
+
+ expect(integration.execute(data)).to be true
+ end
+
+ context 'when the integration is not configured for event' do
+ before do
+ integration.push_channel = nil
+ end
+
+ it 'does not notify Slack' do
+ expect(integration.execute(data)).to be false
+ end
+ end
+
+ context 'when Slack API responds with an error' do
+ it 'logs the error and API response' do
+ stub_slack_request(success: false)
+
+ expect(Gitlab::IntegrationsLogger).to receive(:error).with(
+ {
+ integration_class: described_class.name,
+ integration_id: integration.id,
+ project_id: integration.project_id,
+ project_path: kind_of(String),
+ message: 'Slack API error when notifying',
+ api_response: { 'ok' => false }
+ }
+ )
+ expect(integration.execute(data)).to be false
+ end
+ end
+
+ context 'when there is an HTTP error' do
+ it 'logs the error' do
+ expect_next(Slack::API).to receive(:post).and_raise(Net::ReadTimeout)
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(
+ kind_of(Net::ReadTimeout),
+ {
+ slack_integration_id: slack_integration.id,
+ integration_id: integration.id
+ }
+ )
+ expect(integration.execute(data)).to be false
+ end
+ end
+
+ context 'when configured to post to multiple Slack channels' do
+ before do
+ push_channels = '#first_channel, #second_channel'
+ integration.push_channel = push_channels
+ end
+
+ it 'posts to both Slack channels and returns true' do
+ stub_slack_request(channel: '#first_channel')
+ stub_slack_request(channel: '#second_channel')
+
+ expect(integration.execute(data)).to be true
+ end
+
+ context 'when one of the posts responds with an error' do
+ it 'posts to both channels and returns true' do
+ stub_slack_request(channel: '#first_channel', success: false)
+ stub_slack_request(channel: '#second_channel')
+
+ expect(Gitlab::IntegrationsLogger).to receive(:error).once
+ expect(integration.execute(data)).to be true
+ end
+ end
+
+ context 'when both of the posts respond with an error' do
+ it 'posts to both channels and returns false' do
+ stub_slack_request(channel: '#first_channel', success: false)
+ stub_slack_request(channel: '#second_channel', success: false)
+
+ expect(Gitlab::IntegrationsLogger).to receive(:error).twice
+ expect(integration.execute(data)).to be false
+ end
+ end
+
+ context 'when one of the posts raises an HTTP exception' do
+ it 'posts to one channel and returns true' do
+ stub_slack_request(channel: '#second_channel')
+
+ expect_next_instance_of(Slack::API) do |api_client|
+ expect(api_client).to receive(:post)
+ .with('chat.postMessage', hash_including(channel: '#first_channel')).and_raise(Net::ReadTimeout)
+ expect(api_client).to receive(:post)
+ .with('chat.postMessage', hash_including(channel: '#second_channel')).and_call_original
+ end
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).once
+ expect(integration.execute(data)).to be true
+ end
+ end
+
+ context 'when both of the posts raise an HTTP exception' do
+ it 'posts to one channel and returns true' do
+ stub_slack_request(channel: '#second_channel')
+
+ expect_next(Slack::API).to receive(:post).twice.and_raise(Net::ReadTimeout)
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).twice
+ expect(integration.execute(data)).to be false
+ end
+ end
+ end
+ end
+
+ describe '#test' do
+ let(:integration) { build(:gitlab_slack_application_integration) }
+
+ let(:slack_api_method_uri) { "#{::Slack::API::BASE_URL}/chat.postEphemeral" }
+ let(:response_failure) { { error: 'channel_not_found' } }
+ let(:response_success) { { error: 'user_not_in_channel' } }
+ let(:response_headers) { { 'Content-Type' => 'application/json; charset=utf-8' } }
+ let(:request_body) do
+ {
+ text: 'Test',
+ user: integration.bot_user_id
+ }
+ end
+
+ subject(:result) { integration.test({}) }
+
+ def stub_slack_request(channel:, success:)
+ response_body = success ? response_success : response_failure
+
+ stub_request(:post, slack_api_method_uri)
+ .with(body: request_body.merge(channel: channel))
+ .to_return(body: response_body.to_json, headers: response_headers)
+ end
+
+ context 'when all channels can be posted to' do
+ before do
+ stub_slack_request(channel: anything, success: true)
+ end
+
+ it 'is successful' do
+ is_expected.to eq({ success: true, result: nil })
+ end
+ end
+
+ context 'when the same channel is used for multiple events' do
+ let(:integration) do
+ build(:gitlab_slack_application_integration, all_channels: false, push_channel: '#foo', issue_channel: '#foo')
+ end
+
+ it 'only tests the channel once' do
+ stub_slack_request(channel: '#foo', success: true)
+
+ is_expected.to eq({ success: true, result: nil })
+ expect(WebMock).to have_requested(:post, slack_api_method_uri).once
+ end
+ end
+
+ context 'when there are channels that cannot be posted to' do
+ let(:unpostable_channels) { ['#push_channel', '#issue_channel'] }
+
+ before do
+ stub_slack_request(channel: anything, success: true)
+
+ unpostable_channels.each do |channel|
+ stub_slack_request(channel: channel, success: false)
+ end
+ end
+
+ it 'returns an error message informing which channels cannot be posted to' do
+ expected_message = "Unable to post to #{unpostable_channels.to_sentence}, " \
+ 'please add the GitLab Slack app to any private Slack channels'
+
+ is_expected.to eq({ success: false, result: expected_message })
+ end
+
+ context 'when integration is not configured for notifications' do
+ let_it_be(:integration) { build(:gitlab_slack_application_integration, all_channels: false) }
+
+ it 'is successful' do
+ is_expected.to eq({ success: true, result: nil })
+ end
+ end
+ end
+
+ context 'when integration is using legacy version of Slack app' do
+ before do
+ integration.slack_integration = build(:slack_integration, :legacy)
+ end
+
+ it 'returns an error to inform the user to update their integration' do
+ expected_message = 'GitLab for Slack app must be reinstalled to enable notifications'
+
+ is_expected.to eq({ success: false, result: expected_message })
+ end
+ end
+ end
+
+ context 'when the integration is active' do
+ before do
+ subject.active = true
+ end
+
+ it 'is editable, and presents editable fields' do
+ expect(subject).to be_editable
+ expect(subject.fields).not_to be_empty
+ expect(subject.configurable_events).not_to be_empty
+ end
+
+ it 'includes the expected sections' do
+ section_types = subject.sections.pluck(:type)
+
+ expect(section_types).to eq(
+ [
+ described_class::SECTION_TYPE_TRIGGER,
+ described_class::SECTION_TYPE_CONFIGURATION
+ ]
+ )
+ end
+ end
+
+ context 'when the integration is not active' do
+ before do
+ subject.active = false
+ end
+
+ it 'is not editable, and presents no editable fields' do
+ expect(subject).not_to be_editable
+ expect(subject.fields).to be_empty
+ expect(subject.configurable_events).to be_empty
+ end
+
+ it 'does not include sections' do
+ section_types = subject.sections.pluck(:type)
+
+ expect(section_types).to be_empty
+ end
+ end
+
+ describe '#description' do
+ specify { expect(subject.description).to be_present }
+ end
+
+ describe '#upgrade_needed?' do
+ context 'with all_features_supported' do
+ subject(:integration) { create(:gitlab_slack_application_integration, :all_features_supported) }
+
+ it 'is false' do
+ expect(integration).not_to be_upgrade_needed
+ end
+ end
+
+ context 'without all_features_supported' do
+ subject(:integration) { create(:gitlab_slack_application_integration) }
+
+ it 'is true' do
+ expect(integration).to be_upgrade_needed
+ end
+ end
+
+ context 'without slack_integration' do
+ subject(:integration) { create(:gitlab_slack_application_integration, slack_integration: nil) }
+
+ it 'is false' do
+ expect(integration).not_to be_upgrade_needed
+ end
+ end
+ end
+end
diff --git a/spec/models/integrations/google_play_spec.rb b/spec/models/integrations/google_play_spec.rb
new file mode 100644
index 00000000000..8349ac71bc9
--- /dev/null
+++ b/spec/models/integrations/google_play_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::GooglePlay, feature_category: :mobile_devops do
+ describe 'Validations' do
+ context 'when active' do
+ before do
+ subject.active = true
+ end
+
+ it { is_expected.to validate_presence_of :service_account_key_file_name }
+ it { is_expected.to validate_presence_of :service_account_key }
+ it { is_expected.to validate_presence_of :package_name }
+ it { is_expected.to allow_value(File.read('spec/fixtures/service_account.json')).for(:service_account_key) }
+ it { is_expected.not_to allow_value(File.read('spec/fixtures/group.json')).for(:service_account_key) }
+ it { is_expected.to allow_value('com.example.myapp').for(:package_name) }
+ it { is_expected.to allow_value('com.example.myorg.myapp').for(:package_name) }
+ it { is_expected.to allow_value('com_us.example.my_org.my_app').for(:package_name) }
+ it { is_expected.to allow_value('a.a.a').for(:package_name) }
+ it { is_expected.to allow_value('com.example').for(:package_name) }
+ it { is_expected.not_to allow_value('com').for(:package_name) }
+ it { is_expected.not_to allow_value('com.example.my app').for(:package_name) }
+ it { is_expected.not_to allow_value('1com.example.myapp').for(:package_name) }
+ it { is_expected.not_to allow_value('com.1example.myapp').for(:package_name) }
+ it { is_expected.not_to allow_value('com.example._myapp').for(:package_name) }
+ end
+ end
+
+ context 'when integration is enabled' do
+ let(:google_play_integration) { build(:google_play_integration) }
+
+ describe '#fields' do
+ it 'returns custom fields' do
+ expect(google_play_integration.fields.pluck(:name)).to match_array(%w[package_name service_account_key
+ service_account_key_file_name])
+ end
+ end
+
+ describe '#test' do
+ it 'returns true for a successful request' do
+ allow_next_instance_of(Google::Apis::AndroidpublisherV3::AndroidPublisherService) do |instance|
+ allow(instance).to receive(:list_reviews)
+ end
+ expect(google_play_integration.test[:success]).to be true
+ end
+
+ it 'returns false for an invalid request' do
+ allow_next_instance_of(Google::Apis::AndroidpublisherV3::AndroidPublisherService) do |instance|
+ allow(instance).to receive(:list_reviews).and_raise(Google::Apis::ClientError.new('error'))
+ end
+ expect(google_play_integration.test[:success]).to be false
+ end
+ end
+
+ describe '#help' do
+ it 'renders prompt information' do
+ expect(google_play_integration.help).not_to be_empty
+ end
+ end
+
+ describe '.to_param' do
+ it 'returns the name of the integration' do
+ expect(described_class.to_param).to eq('google_play')
+ end
+ end
+
+ describe '#ci_variables' do
+ let(:google_play_integration) { build_stubbed(:google_play_integration) }
+
+ it 'returns vars when the integration is activated' do
+ ci_vars = [
+ {
+ key: 'SUPPLY_PACKAGE_NAME',
+ value: google_play_integration.package_name,
+ masked: false,
+ public: false
+ },
+ {
+ key: 'SUPPLY_JSON_KEY_DATA',
+ value: google_play_integration.service_account_key,
+ masked: true,
+ public: false
+ }
+ ]
+
+ expect(google_play_integration.ci_variables).to match_array(ci_vars)
+ end
+ end
+ end
+
+ context 'when integration is disabled' do
+ let(:google_play_integration) { build_stubbed(:google_play_integration, active: false) }
+
+ describe '#ci_variables' do
+ it 'returns an empty array' do
+ expect(google_play_integration.ci_variables).to match_array([])
+ end
+ end
+ end
+end
diff --git a/spec/models/integrations/hangouts_chat_spec.rb b/spec/models/integrations/hangouts_chat_spec.rb
index 288478b494e..1ebf2ec3005 100644
--- a/spec/models/integrations/hangouts_chat_spec.rb
+++ b/spec/models/integrations/hangouts_chat_spec.rb
@@ -116,10 +116,13 @@ RSpec.describe Integrations::HangoutsChat do
context 'when commit comment event executed' do
let(:commit_note) do
- create(:note_on_commit, author: user,
- project: project,
- commit_id: project.repository.commit.id,
- note: 'a comment on a commit')
+ create(
+ :note_on_commit,
+ author: user,
+ project: project,
+ commit_id: project.repository.commit.id,
+ note: 'a comment on a commit'
+ )
end
it "adds thread key" do
@@ -135,8 +138,7 @@ RSpec.describe Integrations::HangoutsChat do
context 'when merge request comment event executed' do
let(:merge_request_note) do
- create(:note_on_merge_request, project: project,
- note: "merge request note")
+ create(:note_on_merge_request, project: project, note: "merge request note")
end
it "adds thread key" do
@@ -168,8 +170,7 @@ RSpec.describe Integrations::HangoutsChat do
context 'when snippet comment event executed' do
let(:snippet_note) do
- create(:note_on_project_snippet, project: project,
- note: "snippet note")
+ create(:note_on_project_snippet, project: project, note: "snippet note")
end
it "adds thread key" do
diff --git a/spec/models/integrations/harbor_spec.rb b/spec/models/integrations/harbor_spec.rb
index b4580028112..c4da876a0dd 100644
--- a/spec/models/integrations/harbor_spec.rb
+++ b/spec/models/integrations/harbor_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe Integrations::Harbor do
before do
allow_next_instance_of(Gitlab::Harbor::Client) do |client|
- allow(client).to receive(:ping).and_return(test_response)
+ allow(client).to receive(:check_project_availability).and_return(test_response)
end
end
diff --git a/spec/models/integrations/jira_spec.rb b/spec/models/integrations/jira_spec.rb
index a4ccae459cf..d3cb386e8e0 100644
--- a/spec/models/integrations/jira_spec.rb
+++ b/spec/models/integrations/jira_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Integrations::Jira do
+RSpec.describe Integrations::Jira, feature_category: :integrations do
include AssetsHelpers
let_it_be(:project) { create(:project, :repository) }
@@ -11,6 +11,9 @@ RSpec.describe Integrations::Jira do
let(:url) { 'http://jira.example.com' }
let(:api_url) { 'http://api-jira.example.com' }
let(:username) { 'jira-username' }
+ let(:jira_auth_type) { 0 }
+ let(:jira_issue_prefix) { '' }
+ let(:jira_issue_regex) { '' }
let(:password) { 'jira-password' }
let(:project_key) { nil }
let(:transition_id) { 'test27' }
@@ -48,9 +51,39 @@ RSpec.describe Integrations::Jira do
it { is_expected.to validate_presence_of(:url) }
it { is_expected.to validate_presence_of(:username) }
it { is_expected.to validate_presence_of(:password) }
+ it { is_expected.to validate_presence_of(:jira_auth_type) }
+ it { is_expected.to validate_length_of(:jira_issue_regex).is_at_most(255) }
+ it { is_expected.to validate_length_of(:jira_issue_prefix).is_at_most(255) }
+ it { is_expected.to validate_inclusion_of(:jira_auth_type).in_array([0, 1]) }
it_behaves_like 'issue tracker integration URL attribute', :url
it_behaves_like 'issue tracker integration URL attribute', :api_url
+
+ context 'with personal_access_token_authorization' do
+ before do
+ jira_integration.jira_auth_type = 1
+ end
+
+ it { is_expected.not_to validate_presence_of(:username) }
+ end
+
+ context 'when URL is for Jira Cloud' do
+ before do
+ jira_integration.url = 'https://test.atlassian.net'
+ end
+
+ it 'is valid when jira_auth_type is basic' do
+ jira_integration.jira_auth_type = 0
+
+ expect(jira_integration).to be_valid
+ end
+
+ it 'is invalid when jira_auth_type is PAT' do
+ jira_integration.jira_auth_type = 1
+
+ expect(jira_integration).not_to be_valid
+ end
+ end
end
context 'when integration is inactive' do
@@ -62,6 +95,10 @@ RSpec.describe Integrations::Jira do
it { is_expected.not_to validate_presence_of(:url) }
it { is_expected.not_to validate_presence_of(:username) }
it { is_expected.not_to validate_presence_of(:password) }
+ it { is_expected.not_to validate_presence_of(:jira_auth_type) }
+ it { is_expected.not_to validate_length_of(:jira_issue_regex).is_at_most(255) }
+ it { is_expected.not_to validate_length_of(:jira_issue_prefix).is_at_most(255) }
+ it { is_expected.not_to validate_inclusion_of(:jira_auth_type).in_array([0, 1]) }
end
describe 'jira_issue_transition_id' do
@@ -167,7 +204,7 @@ RSpec.describe Integrations::Jira do
subject(:fields) { integration.fields }
it 'returns custom fields' do
- expect(fields.pluck(:name)).to eq(%w[url api_url username password jira_issue_transition_id])
+ expect(fields.pluck(:name)).to eq(%w[url api_url jira_auth_type username password jira_issue_regex jira_issue_prefix jira_issue_transition_id])
end
end
@@ -202,7 +239,7 @@ RSpec.describe Integrations::Jira do
end
end
- describe '.reference_pattern' do
+ describe '#reference_pattern' do
using RSpec::Parameterized::TableSyntax
where(:key, :result) do
@@ -216,11 +253,77 @@ RSpec.describe Integrations::Jira do
'3EXT_EXT-1234' | ''
'CVE-2022-123' | ''
'CVE-123' | 'CVE-123'
+ 'abc-JIRA-1234' | 'JIRA-1234'
end
with_them do
specify do
- expect(described_class.reference_pattern.match(key).to_s).to eq(result)
+ expect(jira_integration.reference_pattern.match(key).to_s).to eq(result)
+ end
+ end
+
+ context 'with match prefix' do
+ before do
+ jira_integration.jira_issue_prefix = 'jira#'
+ end
+
+ where(:key, :result, :issue_key) do
+ 'jira##123' | '' | ''
+ 'jira#1#23#12' | '' | ''
+ 'jira#JIRA-1234A' | 'jira#JIRA-1234' | 'JIRA-1234'
+ 'jira#JIRA-1234-some_tag' | 'jira#JIRA-1234' | 'JIRA-1234'
+ 'JIRA-1234A' | '' | ''
+ 'JIRA-1234-some_tag' | '' | ''
+ 'myjira#JIRA-1234-some_tag' | '' | ''
+ 'MYjira#JIRA-1234-some_tag' | '' | ''
+ 'my-jira#JIRA-1234-some_tag' | 'jira#JIRA-1234' | 'JIRA-1234'
+ end
+
+ with_them do
+ specify do
+ expect(jira_integration.reference_pattern.match(key).to_s).to eq(result)
+
+ expect(jira_integration.reference_pattern.match(key)[:issue]).to eq(issue_key) unless result.empty?
+ end
+ end
+ end
+
+ context 'with trailing space in jira_issue_prefix' do
+ before do
+ jira_integration.jira_issue_prefix = 'Jira# '
+ end
+
+ it 'leaves the trailing space' do
+ expect(jira_integration.jira_issue_prefix).to eq('Jira# ')
+ end
+
+ it 'pulls the issue ID without a prefix' do
+ expect(jira_integration.reference_pattern.match('Jira# FOO-123')[:issue]).to eq('FOO-123')
+ end
+ end
+
+ context 'with custom issue pattern' do
+ before do
+ jira_integration.jira_issue_regex = '[A-Z][0-9]-[0-9]+'
+ end
+
+ where(:key, :result) do
+ 'J1-123' | 'J1-123'
+ 'AAbJ J1-123' | 'J1-123'
+ '#A1-123' | 'A1-123'
+ 'J1-1234-some_tag' | 'J1-1234'
+ 'J1-1234A' | 'J1-1234'
+ 'J1-1234-some_tag' | 'J1-1234'
+ 'JI1-123' | ''
+ 'J1I-123' | ''
+ 'JI-123' | ''
+ '#123' | ''
+ end
+
+ with_them do
+ specify do
+ expect(jira_integration.reference_pattern.match(key).to_s).to eq(result)
+ end
end
end
end
@@ -251,7 +354,10 @@ RSpec.describe Integrations::Jira do
project: project,
url: url,
api_url: api_url,
+ jira_auth_type: jira_auth_type,
username: username, password: password,
+ jira_issue_regex: jira_issue_regex,
+ jira_issue_prefix: jira_issue_prefix,
jira_issue_transition_id: transition_id
}
end
@@ -265,8 +371,11 @@ RSpec.describe Integrations::Jira do
it 'stores data in data_fields correctly' do
expect(integration.jira_tracker_data.url).to eq(url)
expect(integration.jira_tracker_data.api_url).to eq(api_url)
+ expect(integration.jira_tracker_data.jira_auth_type).to eq(jira_auth_type)
expect(integration.jira_tracker_data.username).to eq(username)
expect(integration.jira_tracker_data.password).to eq(password)
+ expect(integration.jira_tracker_data.jira_issue_regex).to eq(jira_issue_regex)
+ expect(integration.jira_tracker_data.jira_issue_prefix).to eq(jira_issue_prefix)
expect(integration.jira_tracker_data.jira_issue_transition_id).to eq(transition_id)
expect(integration.jira_tracker_data.deployment_cloud?).to be_truthy
end
@@ -469,15 +578,54 @@ RSpec.describe Integrations::Jira do
end
describe '#client' do
+ before do
+ stub_request(:get, 'http://jira.example.com/foo')
+ end
+
it 'uses the default GitLab::HTTP timeouts' do
timeouts = Gitlab::HTTP::DEFAULT_TIMEOUT_OPTIONS
- stub_request(:get, 'http://jira.example.com/foo')
expect(Gitlab::HTTP).to receive(:httparty_perform_request)
.with(Net::HTTP::Get, '/foo', hash_including(timeouts)).and_call_original
jira_integration.client.get('/foo')
end
+
+ context 'with basic auth' do
+ before do
+ jira_integration.jira_auth_type = 0
+ end
+
+ it 'uses correct authorization options' do
+ expect_next_instance_of(JIRA::Client) do |instance|
+ expect(instance.request_client.options).to include(
+ additional_cookies: ['OBBasicAuth=fromDialog'],
+ auth_type: :basic,
+ use_cookies: true,
+ password: jira_integration.password,
+ username: jira_integration.username
+ )
+ end
+
+ jira_integration.client.get('/foo')
+ end
+ end
+
+ context 'with personal access token auth' do
+ before do
+ jira_integration.jira_auth_type = 1
+ end
+
+ it 'uses correct authorization options' do
+ expect_next_instance_of(JIRA::Client) do |instance|
+ expect(instance.request_client.options).to include(
+ default_headers: { "Authorization" => "Bearer #{password}" }
+ )
+ end
+
+ jira_integration.client.get('/foo')
+ end
+ end
end
describe '#find_issue' do
@@ -590,7 +738,6 @@ RSpec.describe Integrations::Jira do
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
subject { close_issue }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { 'Integrations::Jira' }
let(:action) { 'perform_integrations_action' }
let(:namespace) { project.namespace }
@@ -944,7 +1091,6 @@ RSpec.describe Integrations::Jira do
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { 'Integrations::Jira' }
let(:action) { 'perform_integrations_action' }
let(:namespace) { project.namespace }
@@ -1095,9 +1241,7 @@ RSpec.describe Integrations::Jira do
expect(integration.web_url).to eq('')
end
- it 'includes Atlassian referrer for gitlab.com' do
- allow(Gitlab).to receive(:com?).and_return(true)
-
+ it 'includes Atlassian referrer for SaaS', :saas do
expect(integration.web_url).to eq("http://jira.test.com/path?#{described_class::ATLASSIAN_REFERRER_GITLAB_COM.to_query}")
allow(Gitlab).to receive(:staging?).and_return(true)
diff --git a/spec/models/integrations/mattermost_slash_commands_spec.rb b/spec/models/integrations/mattermost_slash_commands_spec.rb
index 070adb9ba93..e393a905f45 100644
--- a/spec/models/integrations/mattermost_slash_commands_spec.rb
+++ b/spec/models/integrations/mattermost_slash_commands_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Integrations::MattermostSlashCommands do
+RSpec.describe Integrations::MattermostSlashCommands, feature_category: :integrations do
it_behaves_like Integrations::BaseSlashCommands
describe 'Mattermost API' do
@@ -123,12 +123,5 @@ RSpec.describe Integrations::MattermostSlashCommands do
end
end
end
-
- describe '#chat_responder' do
- it 'returns the responder to use for Mattermost' do
- expect(described_class.new.chat_responder)
- .to eq(Gitlab::Chat::Responder::Mattermost)
- end
- end
end
end
diff --git a/spec/models/integrations/prometheus_spec.rb b/spec/models/integrations/prometheus_spec.rb
index aa248abd3bb..8aa9b12c4f0 100644
--- a/spec/models/integrations/prometheus_spec.rb
+++ b/spec/models/integrations/prometheus_spec.rb
@@ -90,37 +90,6 @@ RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching,
end
end
end
-
- context 'with self-monitoring project and internal Prometheus' do
- before do
- integration.api_url = 'http://localhost:9090'
-
- stub_application_setting(self_monitoring_project_id: project.id)
- stub_config(prometheus: { enable: true, server_address: 'localhost:9090' })
- end
-
- it 'allows self-monitoring project to connect to internal Prometheus' do
- aggregate_failures do
- ['127.0.0.1', '192.168.2.3'].each do |url|
- allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)])
-
- expect(integration.can_query?).to be true
- end
- end
- end
-
- it 'does not allow self-monitoring project to connect to other local URLs' do
- integration.api_url = 'http://localhost:8000'
-
- aggregate_failures do
- ['127.0.0.1', '192.168.2.3'].each do |url|
- allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)])
-
- expect(integration.can_query?).to be false
- end
- end
- end
- end
end
end
@@ -218,23 +187,6 @@ RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching,
it 'blocks local requests' do
expect(integration.prometheus_client).to be_nil
end
-
- context 'with self-monitoring project and internal Prometheus URL' do
- before do
- stub_application_setting(allow_local_requests_from_web_hooks_and_services: false)
- stub_application_setting(self_monitoring_project_id: project.id)
-
- stub_config(prometheus: {
- enable: true,
- server_address: api_url
- })
- end
-
- it 'allows local requests' do
- expect(integration.prometheus_client).not_to be_nil
- expect { integration.prometheus_client.ping }.not_to raise_error
- end
- end
end
context 'behind IAP' do
@@ -332,7 +284,7 @@ RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching,
context 'cluster belongs to projects group' do
let_it_be(:group) { create(:group) }
- let(:project) { create(:project, :with_prometheus_integration, group: group) }
+ let_it_be(:project) { create(:project, :with_prometheus_integration, group: group) }
let_it_be(:cluster) { create(:cluster_for_group, groups: [group]) }
it 'returns true' do
diff --git a/spec/models/integrations/redmine_spec.rb b/spec/models/integrations/redmine_spec.rb
index 59997d2b6f6..8785fc8a1ed 100644
--- a/spec/models/integrations/redmine_spec.rb
+++ b/spec/models/integrations/redmine_spec.rb
@@ -38,11 +38,11 @@ RSpec.describe Integrations::Redmine do
end
end
- describe '.reference_pattern' do
+ describe '#reference_pattern' do
it_behaves_like 'allows project key on reference pattern'
it 'does allow # on the reference' do
- expect(described_class.reference_pattern.match('#123')[:issue]).to eq('123')
+ expect(subject.reference_pattern.match('#123')[:issue]).to eq('123')
end
end
end
diff --git a/spec/models/integrations/slack_slash_commands_spec.rb b/spec/models/integrations/slack_slash_commands_spec.rb
index 22cbaa777cd..f373fc2a2de 100644
--- a/spec/models/integrations/slack_slash_commands_spec.rb
+++ b/spec/models/integrations/slack_slash_commands_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Integrations::SlackSlashCommands do
+RSpec.describe Integrations::SlackSlashCommands, feature_category: :integrations do
it_behaves_like Integrations::BaseSlashCommands
describe '#trigger' do
@@ -40,11 +40,4 @@ RSpec.describe Integrations::SlackSlashCommands do
end
end
end
-
- describe '#chat_responder' do
- it 'returns the responder to use for Slack' do
- expect(described_class.new.chat_responder)
- .to eq(Gitlab::Chat::Responder::Slack)
- end
- end
end
diff --git a/spec/models/integrations/slack_workspace/api_scope_spec.rb b/spec/models/integrations/slack_workspace/api_scope_spec.rb
new file mode 100644
index 00000000000..92052983242
--- /dev/null
+++ b/spec/models/integrations/slack_workspace/api_scope_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackWorkspace::ApiScope, feature_category: :integrations do
+ describe '.find_or_initialize_by_names' do
+ it 'acts as insert into a global set of scope names' do
+ expect { described_class.find_or_initialize_by_names(%w[foo bar baz]) }
+ .to change { described_class.count }.by(3)
+
+ expect { described_class.find_or_initialize_by_names(%w[bar baz foo buzz]) }
+ .to change { described_class.count }.by(1)
+
+ expect { described_class.find_or_initialize_by_names(%w[baz foo]) }
+ .to change { described_class.count }.by(0)
+
+ expect(described_class.pluck(:name)).to match_array(%w[foo bar baz buzz])
+ end
+ end
+end
diff --git a/spec/models/integrations/squash_tm_spec.rb b/spec/models/integrations/squash_tm_spec.rb
new file mode 100644
index 00000000000..f12e37dae6d
--- /dev/null
+++ b/spec/models/integrations/squash_tm_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SquashTm, feature_category: :integrations do
+ it_behaves_like Integrations::HasWebHook do
+ let_it_be(:project) { create(:project) }
+
+ let(:integration) { build(:squash_tm_integration, project: project) }
+ let(:hook_url) { "#{integration.url}?token={token}" }
+ end
+
+ it_behaves_like Integrations::ResetSecretFields do
+ let(:integration) { subject }
+ end
+
+ describe 'Validations' do
+ context 'when integration is active' do
+ before do
+ subject.active = true
+ end
+
+ it { is_expected.to validate_presence_of(:url) }
+ it { is_expected.to allow_value('https://example.com').for(:url) }
+ it { is_expected.not_to allow_value(nil).for(:url) }
+ it { is_expected.not_to allow_value('').for(:url) }
+ it { is_expected.not_to allow_value('foo').for(:url) }
+ it { is_expected.not_to allow_value('example.com').for(:url) }
+
+ it { is_expected.not_to validate_presence_of(:token) }
+ it { is_expected.to validate_length_of(:token).is_at_most(255) }
+ it { is_expected.to allow_value(nil).for(:token) }
+ it { is_expected.to allow_value('foo').for(:token) }
+ end
+
+ context 'when integration is inactive' do
+ before do
+ subject.active = false
+ end
+
+ it { is_expected.not_to validate_presence_of(:url) }
+ it { is_expected.not_to validate_presence_of(:token) }
+ end
+ end
+
+ describe '#execute' do
+ let(:integration) { build(:squash_tm_integration, project: build(:project)) }
+
+ let(:squash_tm_hook_url) do
+ "#{integration.url}?token=#{integration.token}"
+ end
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:issue) { create(:issue) }
+ let(:data) { issue.to_hook_data(user) }
+
+ before do
+ stub_request(:post, squash_tm_hook_url)
+ end
+
+ it 'calls Squash TM API' do
+ integration.execute(data)
+
+ expect(a_request(:post, squash_tm_hook_url)).to have_been_made.once
+ end
+ end
+
+ describe '#test' do
+ let(:integration) { build(:squash_tm_integration) }
+
+ let(:squash_tm_hook_url) do
+ "#{integration.url}?token=#{integration.token}"
+ end
+
+ subject(:result) { integration.test({}) }
+
+ context 'when server is responding' do
+ let(:body) { 'OK' }
+ let(:status) { 200 }
+
+ before do
+ stub_request(:post, squash_tm_hook_url)
+ .to_return(status: status, body: body)
+ end
+
+ it { is_expected.to eq(success: true, result: 'OK') }
+ end
+
+ context 'when server rejects the request' do
+ let(:body) { 'Unauthorized' }
+ let(:status) { 401 }
+
+ before do
+ stub_request(:post, squash_tm_hook_url)
+ .to_return(status: status, body: body)
+ end
+
+ it { is_expected.to eq(success: false, result: body) }
+ end
+
+ context 'when test request executes with errors' do
+ before do
+ allow(integration).to receive(:execute_web_hook!)
+ .with({}, "Test Configuration Hook")
+ .and_raise(StandardError, 'error message')
+ end
+
+ it { is_expected.to eq(success: false, result: 'error message') }
+ end
+ end
+
+ describe '.default_test_event' do
+ subject { described_class.default_test_event }
+
+ it { is_expected.to eq('issue') }
+ end
+end
diff --git a/spec/models/integrations/youtrack_spec.rb b/spec/models/integrations/youtrack_spec.rb
index 618ebcbb76a..69dda244413 100644
--- a/spec/models/integrations/youtrack_spec.rb
+++ b/spec/models/integrations/youtrack_spec.rb
@@ -26,15 +26,15 @@ RSpec.describe Integrations::Youtrack do
end
end
- describe '.reference_pattern' do
+ describe '#reference_pattern' do
it_behaves_like 'allows project key on reference pattern'
it 'does allow project prefix on the reference' do
- expect(described_class.reference_pattern.match('YT-123')[:issue]).to eq('YT-123')
+ expect(subject.reference_pattern.match('YT-123')[:issue]).to eq('YT-123')
end
it 'allows lowercase project key on the reference' do
- expect(described_class.reference_pattern.match('yt-123')[:issue]).to eq('yt-123')
+ expect(subject.reference_pattern.match('yt-123')[:issue]).to eq('yt-123')
end
end
diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb
index f0007e1203c..59ade8783e5 100644
--- a/spec/models/internal_id_spec.rb
+++ b/spec/models/internal_id_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe InternalId do
let(:usage) { :issues }
let(:issue) { build(:issue, project: project) }
let(:id_subject) { issue }
- let(:scope) { { project: project } }
+ let(:scope) { { namespace: project.project_namespace } }
let(:init) { ->(issue, scope) { issue&.project&.issues&.size || Issue.where(**scope).count } }
it_behaves_like 'having unique enum values'
@@ -17,7 +17,7 @@ RSpec.describe InternalId do
end
describe '.flush_records!' do
- subject { described_class.flush_records!(project: project) }
+ subject { described_class.flush_records!(namespace: project.project_namespace) }
let(:another_project) { create(:project) }
@@ -27,11 +27,11 @@ RSpec.describe InternalId do
end
it 'deletes all records for the given project' do
- expect { subject }.to change { described_class.where(project: project).count }.from(1).to(0)
+ expect { subject }.to change { described_class.where(namespace: project.project_namespace).count }.from(1).to(0)
end
it 'retains records for other projects' do
- expect { subject }.not_to change { described_class.where(project: another_project).count }
+ expect { subject }.not_to change { described_class.where(namespace: another_project.project_namespace).count }
end
it 'does not allow an empty filter' do
@@ -51,7 +51,7 @@ RSpec.describe InternalId do
subject
described_class.first.tap do |record|
- expect(record.project).to eq(project)
+ expect(record.namespace).to eq(project.project_namespace)
expect(record.usage).to eq(usage.to_s)
end
end
@@ -182,7 +182,7 @@ RSpec.describe InternalId do
subject
described_class.first.tap do |record|
- expect(record.project).to eq(project)
+ expect(record.namespace).to eq(project.project_namespace)
expect(record.usage).to eq(usage.to_s)
expect(record.last_value).to eq(value)
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index e29318a7e83..6ae33fe2642 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -12,7 +12,6 @@ RSpec.describe Issue, feature_category: :team_planning do
describe "Associations" do
it { is_expected.to belong_to(:milestone) }
- it { is_expected.to belong_to(:iteration) }
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:work_item_type).class_name('WorkItems::Type') }
it { is_expected.to belong_to(:moved_to).class_name('Issue') }
@@ -38,6 +37,7 @@ RSpec.describe Issue, feature_category: :team_planning do
it { is_expected.to have_many(:issue_customer_relations_contacts) }
it { is_expected.to have_many(:customer_relations_contacts) }
it { is_expected.to have_many(:incident_management_timeline_events) }
+ it { is_expected.to have_many(:assignment_events).class_name('ResourceEvents::IssueAssignmentEvent').inverse_of(:issue) }
describe 'versions.most_recent' do
it 'returns the most recent version' do
@@ -63,13 +63,18 @@ RSpec.describe Issue, feature_category: :team_planning do
it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid }
let(:instance) { build(:issue) }
- let(:scope) { :project }
- let(:scope_attrs) { { project: instance.project } }
+ let(:scope) { :namespace }
+ let(:scope_attrs) { { namespace: instance.project.project_namespace } }
let(:usage) { :issues }
end
end
describe 'validations' do
+ it { is_expected.not_to allow_value(nil).for(:confidential) }
+ it { is_expected.to allow_value(true, false).for(:confidential) }
+ end
+
+ describe 'custom validations' do
subject(:valid?) { issue.valid? }
describe 'due_date_after_start_date' do
@@ -156,7 +161,7 @@ RSpec.describe Issue, feature_category: :team_planning do
it 'is possible to change type only between selected types' do
issue = create(:issue, old_type, project: reusable_project)
- issue.work_item_type_id = WorkItems::Type.default_by_type(new_type).id
+ issue.assign_attributes(work_item_type: WorkItems::Type.default_by_type(new_type), issue_type: new_type)
expect(issue.valid?).to eq(is_valid)
end
@@ -215,7 +220,7 @@ RSpec.describe Issue, feature_category: :team_planning do
subject { create(:issue, project: reusable_project) }
describe 'callbacks' do
- describe '#ensure_metrics' do
+ describe '#ensure_metrics!' do
it 'creates metrics after saving' do
expect(subject.metrics).to be_persisted
expect(Issue::Metrics.count).to eq(1)
@@ -250,7 +255,7 @@ RSpec.describe Issue, feature_category: :team_planning do
describe '#ensure_work_item_type' do
let_it_be(:issue_type) { create(:work_item_type, :issue, :default) }
- let_it_be(:task_type) { create(:work_item_type, :issue, :default) }
+ let_it_be(:incident_type) { create(:work_item_type, :incident, :default) }
let_it_be(:project) { create(:project) }
context 'when a type was already set' do
@@ -267,9 +272,9 @@ RSpec.describe Issue, feature_category: :team_planning do
expect(issue.work_item_type_id).to eq(issue_type.id)
expect(WorkItems::Type).not_to receive(:default_by_type)
- issue.update!(work_item_type: task_type, issue_type: 'task')
+ issue.update!(work_item_type: incident_type, issue_type: :incident)
- expect(issue.work_item_type_id).to eq(task_type.id)
+ expect(issue.work_item_type_id).to eq(incident_type.id)
end
it 'ensures a work item type if updated to nil' do
@@ -296,13 +301,36 @@ RSpec.describe Issue, feature_category: :team_planning do
expect(issue.work_item_type_id).to be_nil
expect(WorkItems::Type).not_to receive(:default_by_type)
- issue.update!(work_item_type: task_type, issue_type: 'task')
+ issue.update!(work_item_type: incident_type, issue_type: :incident)
- expect(issue.work_item_type_id).to eq(task_type.id)
+ expect(issue.work_item_type_id).to eq(incident_type.id)
end
end
end
+ describe '#check_issue_type_in_sync' do
+ it 'raises an error if issue_type is out of sync' do
+ issue = build(:issue, issue_type: :issue, work_item_type: WorkItems::Type.default_by_type(:task))
+
+ expect do
+ issue.save!
+ end.to raise_error(Issue::IssueTypeOutOfSyncError)
+ end
+
+ it 'uses attributes to compare both issue_type values' do
+ issue_type = WorkItems::Type.default_by_type(:issue)
+ issue = build(:issue, issue_type: :issue, work_item_type: issue_type)
+
+ attributes = double(:attributes)
+ allow(issue).to receive(:attributes).and_return(attributes)
+
+ expect(attributes).to receive(:[]).with('issue_type').twice.and_return('issue')
+ expect(issue_type).to receive(:base_type).and_call_original
+
+ issue.save!
+ end
+ end
+
describe '#record_create_action' do
it 'records the creation action after saving' do
expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_created_action)
@@ -575,47 +603,47 @@ RSpec.describe Issue, feature_category: :team_planning do
end
describe '#to_reference' do
- let(:namespace) { build(:namespace, path: 'sample-namespace') }
- let(:project) { build(:project, name: 'sample-project', namespace: namespace) }
- let(:issue) { build(:issue, iid: 1, project: project) }
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, namespace: namespace) }
+ let_it_be(:issue) { create(:issue, project: project) }
context 'when nil argument' do
it 'returns issue id' do
- expect(issue.to_reference).to eq "#1"
+ expect(issue.to_reference).to eq "##{issue.iid}"
end
it 'returns complete path to the issue with full: true' do
- expect(issue.to_reference(full: true)).to eq 'sample-namespace/sample-project#1'
+ expect(issue.to_reference(full: true)).to eq "#{project.full_path}##{issue.iid}"
end
end
context 'when argument is a project' do
context 'when same project' do
it 'returns issue id' do
- expect(issue.to_reference(project)).to eq("#1")
+ expect(issue.to_reference(project)).to eq("##{issue.iid}")
end
it 'returns full reference with full: true' do
- expect(issue.to_reference(project, full: true)).to eq 'sample-namespace/sample-project#1'
+ expect(issue.to_reference(project, full: true)).to eq "#{project.full_path}##{issue.iid}"
end
end
context 'when cross-project in same namespace' do
let(:another_project) do
- build(:project, name: 'another-project', namespace: project.namespace)
+ create(:project, namespace: project.namespace)
end
it 'returns a cross-project reference' do
- expect(issue.to_reference(another_project)).to eq "sample-project#1"
+ expect(issue.to_reference(another_project)).to eq "#{project.path}##{issue.iid}"
end
end
context 'when cross-project in different namespace' do
let(:another_namespace) { build(:namespace, id: non_existing_record_id, path: 'another-namespace') }
- let(:another_namespace_project) { build(:project, path: 'another-project', namespace: another_namespace) }
+ let(:another_namespace_project) { build(:project, namespace: another_namespace) }
it 'returns complete path to the issue' do
- expect(issue.to_reference(another_namespace_project)).to eq 'sample-namespace/sample-project#1'
+ expect(issue.to_reference(another_namespace_project)).to eq "#{project.full_path}##{issue.iid}"
end
end
end
@@ -623,11 +651,11 @@ RSpec.describe Issue, feature_category: :team_planning do
context 'when argument is a namespace' do
context 'when same as issue' do
it 'returns path to the issue with the project name' do
- expect(issue.to_reference(namespace)).to eq 'sample-project#1'
+ expect(issue.to_reference(namespace)).to eq "#{project.path}##{issue.iid}"
end
it 'returns full reference with full: true' do
- expect(issue.to_reference(namespace, full: true)).to eq 'sample-namespace/sample-project#1'
+ expect(issue.to_reference(namespace, full: true)).to eq "#{project.full_path}##{issue.iid}"
end
end
@@ -635,12 +663,111 @@ RSpec.describe Issue, feature_category: :team_planning do
let(:group) { build(:group, name: 'Group', path: 'sample-group') }
it 'returns full path to the issue with full: true' do
- expect(issue.to_reference(group)).to eq 'sample-namespace/sample-project#1'
+ expect(issue.to_reference(group)).to eq "#{project.full_path}##{issue.iid}"
end
end
end
end
+ describe '#to_reference with table syntax' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user_namespace) { user.namespace }
+
+ let_it_be(:parent) { create(:group) }
+ let_it_be(:group) { create(:group, parent: parent) }
+ let_it_be(:another_group) { create(:group) }
+
+ let_it_be(:project) { create(:project, namespace: group) }
+ let_it_be(:project_namespace) { project.project_namespace }
+ let_it_be(:same_namespace_project) { create(:project, namespace: group) }
+ let_it_be(:same_namespace_project_namespace) { same_namespace_project.project_namespace }
+
+ let_it_be(:another_namespace_project) { create(:project) }
+ let_it_be(:another_namespace_project_namespace) { another_namespace_project.project_namespace }
+
+ let_it_be(:project_issue) { build(:issue, project: project, iid: 123) }
+ let_it_be(:project_issue_full_reference) { "#{project.full_path}##{project_issue.iid}" }
+
+ let_it_be(:group_issue) { build(:issue, namespace: group, iid: 123) }
+ let_it_be(:group_issue_full_reference) { "#{group.full_path}##{group_issue.iid}" }
+
+ # this one is just theoretically possible, not smth to be supported for real
+ let_it_be(:user_issue) { build(:issue, namespace: user_namespace, iid: 123) }
+ let_it_be(:user_issue_full_reference) { "#{user_namespace.full_path}##{user_issue.iid}" }
+
+ # namespace would be group, project namespace or user namespace
+ where(:issue, :full, :from, :result) do
+ ref(:project_issue) | false | nil | lazy { "##{issue.iid}" }
+ ref(:project_issue) | true | nil | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:group) | lazy { "#{project.path}##{issue.iid}" }
+ ref(:project_issue) | true | ref(:group) | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:parent) | ref(:project_issue_full_reference)
+ ref(:project_issue) | true | ref(:parent) | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:project) | lazy { "##{issue.iid}" }
+ ref(:project_issue) | true | ref(:project) | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:project_namespace) | lazy { "##{issue.iid}" }
+ ref(:project_issue) | true | ref(:project_namespace) | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:same_namespace_project) | lazy { "#{project.path}##{issue.iid}" }
+ ref(:project_issue) | true | ref(:same_namespace_project) | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:same_namespace_project_namespace) | lazy { "#{project.path}##{issue.iid}" }
+ ref(:project_issue) | true | ref(:same_namespace_project_namespace) | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:another_group) | ref(:project_issue_full_reference)
+ ref(:project_issue) | true | ref(:another_group) | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:another_namespace_project) | ref(:project_issue_full_reference)
+ ref(:project_issue) | true | ref(:another_namespace_project) | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:another_namespace_project_namespace) | ref(:project_issue_full_reference)
+ ref(:project_issue) | true | ref(:another_namespace_project_namespace) | ref(:project_issue_full_reference)
+ ref(:project_issue) | false | ref(:user_namespace) | ref(:project_issue_full_reference)
+ ref(:project_issue) | true | ref(:user_namespace) | ref(:project_issue_full_reference)
+
+ ref(:group_issue) | false | nil | lazy { "##{issue.iid}" }
+ ref(:group_issue) | true | nil | ref(:group_issue_full_reference)
+ ref(:group_issue) | false | ref(:user_namespace) | ref(:group_issue_full_reference)
+ ref(:group_issue) | true | ref(:user_namespace) | ref(:group_issue_full_reference)
+ ref(:group_issue) | false | ref(:group) | lazy { "##{issue.iid}" }
+ ref(:group_issue) | true | ref(:group) | ref(:group_issue_full_reference)
+ ref(:group_issue) | false | ref(:parent) | lazy { "#{group.path}##{issue.iid}" }
+ ref(:group_issue) | true | ref(:parent) | ref(:group_issue_full_reference)
+ ref(:group_issue) | false | ref(:project) | lazy { "#{group.path}##{issue.iid}" }
+ ref(:group_issue) | true | ref(:project) | ref(:group_issue_full_reference)
+ ref(:group_issue) | false | ref(:project_namespace) | lazy { "#{group.path}##{issue.iid}" }
+ ref(:group_issue) | true | ref(:project_namespace) | ref(:group_issue_full_reference)
+ ref(:group_issue) | false | ref(:another_group) | ref(:group_issue_full_reference)
+ ref(:group_issue) | true | ref(:another_group) | ref(:group_issue_full_reference)
+ ref(:group_issue) | false | ref(:another_namespace_project) | ref(:group_issue_full_reference)
+ ref(:group_issue) | true | ref(:another_namespace_project) | ref(:group_issue_full_reference)
+ ref(:group_issue) | false | ref(:another_namespace_project_namespace) | ref(:group_issue_full_reference)
+ ref(:group_issue) | true | ref(:another_namespace_project_namespace) | ref(:group_issue_full_reference)
+
+ ref(:user_issue) | false | nil | lazy { "##{issue.iid}" }
+ ref(:user_issue) | true | nil | ref(:user_issue_full_reference)
+ ref(:user_issue) | false | ref(:user_namespace) | lazy { "##{issue.iid}" }
+ ref(:user_issue) | true | ref(:user_namespace) | ref(:user_issue_full_reference)
+ ref(:user_issue) | false | ref(:group) | ref(:user_issue_full_reference)
+ ref(:user_issue) | true | ref(:group) | ref(:user_issue_full_reference)
+ ref(:user_issue) | false | ref(:parent) | ref(:user_issue_full_reference)
+ ref(:user_issue) | true | ref(:parent) | ref(:user_issue_full_reference)
+ ref(:user_issue) | false | ref(:project) | ref(:user_issue_full_reference)
+ ref(:user_issue) | true | ref(:project) | ref(:user_issue_full_reference)
+ ref(:user_issue) | false | ref(:project_namespace) | ref(:user_issue_full_reference)
+ ref(:user_issue) | true | ref(:project_namespace) | ref(:user_issue_full_reference)
+ ref(:user_issue) | false | ref(:another_group) | ref(:user_issue_full_reference)
+ ref(:user_issue) | true | ref(:another_group) | ref(:user_issue_full_reference)
+ ref(:user_issue) | false | ref(:another_namespace_project) | ref(:user_issue_full_reference)
+ ref(:user_issue) | true | ref(:another_namespace_project) | ref(:user_issue_full_reference)
+ ref(:user_issue) | false | ref(:another_namespace_project_namespace) | ref(:user_issue_full_reference)
+ ref(:user_issue) | true | ref(:another_namespace_project_namespace) | ref(:user_issue_full_reference)
+ end
+
+ with_them do
+ it 'returns correct reference' do
+ expect(issue.to_reference(from, full: full)).to eq(result)
+ end
+ end
+ end
+
describe '#assignee_or_author?' do
let(:issue) { create(:issue, project: reusable_project) }
@@ -1637,7 +1764,8 @@ RSpec.describe Issue, feature_category: :team_planning do
end
context 'when project in user namespace' do
- let(:project) { build_stubbed(:project_empty_repo) }
+ let(:project_namespace) { build_stubbed(:project_namespace) }
+ let(:project) { build_stubbed(:project_empty_repo, project_namespace: project_namespace) }
let(:project_id) { project.id }
let(:namespace_id) { nil }
@@ -1646,7 +1774,8 @@ RSpec.describe Issue, feature_category: :team_planning do
context 'when project in a group namespace' do
let(:group) { create(:group) }
- let(:project) { build_stubbed(:project_empty_repo, group: group) }
+ let(:project_namespace) { build_stubbed(:project_namespace) }
+ let(:project) { build_stubbed(:project_empty_repo, group: group, project_namespace: project_namespace) }
let(:project_id) { nil }
let(:namespace_id) { group.id }
@@ -1662,6 +1791,46 @@ RSpec.describe Issue, feature_category: :team_planning do
end
end
+ describe '#issue_type' do
+ let_it_be(:issue) { create(:issue) }
+
+ context 'when the issue_type_uses_work_item_types_table feature flag is enabled' do
+ it 'gets the type field from the work_item_types table' do
+ expect(issue).to receive_message_chain(:work_item_type, :base_type)
+
+ issue.issue_type
+ end
+
+ context 'when the issue is not persisted' do
+ it 'uses the default work item type' do
+ non_persisted_issue = build(:issue, work_item_type: nil)
+
+ expect(non_persisted_issue.issue_type).to eq(described_class::DEFAULT_ISSUE_TYPE.to_s)
+ end
+ end
+ end
+
+ context 'when the issue_type_uses_work_item_types_table feature flag is disabled' do
+ before do
+ stub_feature_flags(issue_type_uses_work_item_types_table: false)
+ end
+
+ it 'does not get the value from the work_item_types table' do
+ expect(issue).not_to receive(:work_item_type)
+
+ issue.issue_type
+ end
+
+ context 'when the issue is not persisted' do
+ it 'uses the default work item type' do
+ non_persisted_issue = build(:issue, work_item_type: nil)
+
+ expect(non_persisted_issue.issue_type).to eq(described_class::DEFAULT_ISSUE_TYPE.to_s)
+ end
+ end
+ end
+ end
+
describe '#issue_type_supports?' do
let_it_be(:issue) { create(:issue) }
@@ -1670,6 +1839,17 @@ RSpec.describe Issue, feature_category: :team_planning do
end
end
+ describe '#supports_assignee?' do
+ Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter::WIDGETS_FOR_TYPE.each_pair do |base_type, widgets|
+ specify do
+ issue = build(:issue, base_type)
+ supports_assignee = widgets.include?(:assignees)
+
+ expect(issue.supports_assignee?).to eq(supports_assignee)
+ end
+ end
+ end
+
describe '#supports_time_tracking?' do
let_it_be(:project) { create(:project) }
let_it_be_with_refind(:issue) { create(:incident, project: project) }
@@ -1681,10 +1861,10 @@ RSpec.describe Issue, feature_category: :team_planning do
with_them do
before do
- issue.update!(issue_type: issue_type)
+ issue.update!(issue_type: issue_type, work_item_type: WorkItems::Type.default_by_type(issue_type))
end
- it do
+ specify do
expect(issue.supports_time_tracking?).to eq(supports_time_tracking)
end
end
@@ -1701,10 +1881,10 @@ RSpec.describe Issue, feature_category: :team_planning do
with_them do
before do
- issue.update!(issue_type: issue_type)
+ issue.update!(issue_type: issue_type, work_item_type: WorkItems::Type.default_by_type(issue_type))
end
- it do
+ specify do
expect(issue.supports_move_and_clone?).to eq(supports_move_and_clone)
end
end
@@ -1815,4 +1995,24 @@ RSpec.describe Issue, feature_category: :team_planning do
end
end
end
+
+ describe '#work_item_type_with_default' do
+ subject { Issue.new.work_item_type_with_default }
+
+ it { is_expected.to eq(WorkItems::Type.default_by_type(::Issue::DEFAULT_ISSUE_TYPE)) }
+ end
+
+ describe 'issue_type enum generated methods' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:issue) { create(:issue, project: reusable_project) }
+
+ where(issue_type: WorkItems::Type.base_types.keys)
+
+ with_them do
+ it 'raises an error if called' do
+ expect { issue.public_send("#{issue_type}?".to_sym) }.to raise_error(Issue::ForbiddenColumnUsed)
+ end
+ end
+ end
end
diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb
index f1bc7b41cee..7a46e5e7e53 100644
--- a/spec/models/key_spec.rb
+++ b/spec/models/key_spec.rb
@@ -165,7 +165,7 @@ RSpec.describe Key, :mailer do
.with(key)
.and_return(service)
- expect(service).to receive(:execute)
+ expect(service).to receive(:execute_async)
key.update_last_used_at
end
diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb
index ff7ac0ebd2a..26a1edcbcff 100644
--- a/spec/models/label_spec.rb
+++ b/spec/models/label_spec.rb
@@ -257,4 +257,15 @@ RSpec.describe Label do
end
end
end
+
+ describe '.pluck_titles' do
+ subject(:pluck_titles) { described_class.pluck_titles }
+
+ it 'returns the audit event type of the event type filter' do
+ label1 = create(:label, title: "TITLE1")
+ label2 = create(:label, title: "TITLE2")
+
+ expect(pluck_titles).to contain_exactly(label1.title, label2.title)
+ end
+ end
end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 6a52f12553f..eea96e5e4ae 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -16,7 +16,6 @@ RSpec.describe Member, feature_category: :subgroups do
describe 'Associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:member_namespace) }
- it { is_expected.to belong_to(:member_role) }
it { is_expected.to have_one(:member_task) }
end
@@ -173,96 +172,6 @@ RSpec.describe Member, feature_category: :subgroups do
end
end
end
-
- context 'member role access level' do
- let_it_be_with_reload(:member) { create(:group_member, access_level: Gitlab::Access::DEVELOPER) }
-
- context 'when no member role is associated' do
- it 'is valid' do
- expect(member).to be_valid
- end
- end
-
- context 'when member role is associated' do
- let!(:member_role) do
- create(
- :member_role,
- members: [member],
- base_access_level: Gitlab::Access::DEVELOPER,
- namespace: member.member_namespace
- )
- end
-
- context 'when member role matches access level' do
- it 'is valid' do
- expect(member).to be_valid
- end
- end
-
- context 'when member role does not match access level' do
- it 'is invalid' do
- member_role.base_access_level = Gitlab::Access::MAINTAINER
-
- expect(member).not_to be_valid
- end
- end
-
- context 'when access_level is changed' do
- it 'is invalid' do
- member.access_level = Gitlab::Access::MAINTAINER
-
- expect(member).not_to be_valid
- expect(member.errors[:access_level]).to include(
- _("cannot be changed since member is associated with a custom role")
- )
- end
- end
- end
- end
-
- context 'member role namespace' do
- let_it_be_with_reload(:member) { create(:group_member) }
-
- context 'when no member role is associated' do
- it 'is valid' do
- expect(member).to be_valid
- end
- end
-
- context 'when member role is associated' do
- let_it_be(:member_role) do
- create(:member_role, members: [member], namespace: member.group, base_access_level: member.access_level)
- end
-
- context 'when member#member_namespace is a group within hierarchy of member_role#namespace' do
- it 'is valid' do
- member.member_namespace = create(:group, parent: member_role.namespace)
-
- expect(member).to be_valid
- end
- end
-
- context 'when member#member_namespace is a project within hierarchy of member_role#namespace' do
- it 'is valid' do
- project = create(:project, group: member_role.namespace)
- member.member_namespace = Namespace.find(project.parent_id)
-
- expect(member).to be_valid
- end
- end
-
- context 'when member#member_namespace is outside hierarchy of member_role#namespace' do
- it 'is invalid' do
- member.member_namespace = create(:group)
-
- expect(member).not_to be_valid
- expect(member.errors[:member_namespace]).to include(
- _("must be in same hierarchy as custom role's namespace")
- )
- end
- end
- end
- end
end
describe 'Scopes & finders' do
@@ -774,6 +683,24 @@ RSpec.describe Member, feature_category: :subgroups do
end
end
+ describe '.filter_by_user_type' do
+ let_it_be(:service_account) { create(:user, :service_account) }
+ let_it_be(:service_account_member) { create(:group_member, user: service_account) }
+ let_it_be(:other_member) { create(:group_member) }
+
+ context 'when the user type is valid' do
+ it 'returns service accounts' do
+ expect(described_class.filter_by_user_type('service_account')).to match_array([service_account_member])
+ end
+ end
+
+ context 'when the user type is invalid' do
+ it 'returns nil' do
+ expect(described_class.filter_by_user_type('invalid_type')).to eq(nil)
+ end
+ end
+ end
+
describe '#accept_request' do
let(:member) { create(:project_member, requested_at: Time.current.utc) }
@@ -971,6 +898,14 @@ RSpec.describe Member, feature_category: :subgroups do
end
end
+ describe '.pluck_user_ids' do
+ let(:member) { create(:group_member) }
+
+ it 'plucks the user ids' do
+ expect(described_class.where(id: member).pluck_user_ids).to match([member.user_id])
+ end
+ end
+
describe '#send_invitation_reminder' do
subject { member.send_invitation_reminder(0) }
diff --git a/spec/models/members/member_role_spec.rb b/spec/models/members/member_role_spec.rb
deleted file mode 100644
index 4bf33eb1fce..00000000000
--- a/spec/models/members/member_role_spec.rb
+++ /dev/null
@@ -1,107 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe MemberRole, feature_category: :authentication_and_authorization do
- describe 'associations' do
- it { is_expected.to belong_to(:namespace) }
- it { is_expected.to have_many(:members) }
- end
-
- describe 'validation' do
- subject(:member_role) { build(:member_role) }
-
- it { is_expected.to validate_presence_of(:namespace) }
- it { is_expected.to validate_presence_of(:base_access_level) }
-
- context 'for attributes_locked_after_member_associated' do
- context 'when assigned to member' do
- it 'cannot be changed' do
- member_role.save!
- member_role.members << create(:project_member)
-
- expect(member_role).not_to be_valid
- expect(member_role.errors.messages[:base]).to include(
- s_("MemberRole|cannot be changed because it is already assigned to a user. "\
- "Please create a new Member Role instead")
- )
- end
- end
-
- context 'when not assigned to member' do
- it 'can be changed' do
- expect(member_role).to be_valid
- end
- end
- end
-
- context 'when for namespace' do
- let_it_be(:root_group) { create(:group) }
-
- context 'when namespace is a subgroup' do
- it 'is invalid' do
- subgroup = create(:group, parent: root_group)
- member_role.namespace = subgroup
-
- expect(member_role).to be_invalid
- expect(member_role.errors[:namespace]).to include(
- s_("MemberRole|must be top-level namespace")
- )
- end
- end
-
- context 'when namespace is a root group' do
- it 'is valid' do
- member_role.namespace = root_group
-
- expect(member_role).to be_valid
- end
- end
-
- context 'when namespace is not present' do
- it 'is invalid with a different error message' do
- member_role.namespace = nil
-
- expect(member_role).to be_invalid
- expect(member_role.errors[:namespace]).to include(_("can't be blank"))
- end
- end
-
- context 'when namespace is outside hierarchy of member' do
- it 'creates a validation error' do
- member_role.save!
- member_role.namespace = create(:group)
-
- expect(member_role).not_to be_valid
- expect(member_role.errors[:namespace]).to include(s_("MemberRole|can't be changed"))
- end
- end
- end
- end
-
- describe 'callbacks' do
- context 'for preventing deletion after member is associated' do
- let_it_be(:member_role) { create(:member_role) }
-
- subject(:destroy_member_role) { member_role.destroy } # rubocop: disable Rails/SaveBang
-
- it 'allows deletion without any member associated' do
- expect(destroy_member_role).to be_truthy
- end
-
- it 'prevent deletion when member is associated' do
- create(:group_member, { group: member_role.namespace,
- access_level: Gitlab::Access::DEVELOPER,
- member_role: member_role })
- member_role.members.reload
-
- expect(destroy_member_role).to be_falsey
- expect(member_role.errors.messages[:base])
- .to(
- include(s_("MemberRole|cannot be deleted because it is already assigned to a user. "\
- "Please disassociate the member role from all users before deletion."))
- )
- end
- end
- end
-end
diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb
index f0069b89494..20dae056646 100644
--- a/spec/models/members/project_member_spec.rb
+++ b/spec/models/members/project_member_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe ProjectMember do
describe 'validations' do
it { is_expected.to allow_value('Project').for(:source_type) }
- it { is_expected.not_to allow_value('project').for(:source_type) }
+ it { is_expected.not_to allow_value('Group').for(:source_type) }
it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) }
end
@@ -136,24 +136,6 @@ RSpec.describe ProjectMember do
end
end
- describe '.add_members_to_projects' do
- it 'adds the given users to the given projects' do
- projects = create_list(:project, 2)
- users = create_list(:user, 2)
-
- described_class.add_members_to_projects(
- [projects.first.id, projects.second.id],
- [users.first.id, users.second],
- described_class::MAINTAINER)
-
- expect(projects.first.users).to include(users.first)
- expect(projects.first.users).to include(users.second)
-
- expect(projects.second.users).to include(users.first)
- expect(projects.second.users).to include(users.second)
- end
- end
-
describe '.truncate_teams' do
before do
@project_1 = create(:project)
diff --git a/spec/models/merge_request/diff_llm_summary_spec.rb b/spec/models/merge_request/diff_llm_summary_spec.rb
new file mode 100644
index 00000000000..a94adae9fa5
--- /dev/null
+++ b/spec/models/merge_request/diff_llm_summary_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::MergeRequest::DiffLlmSummary, feature_category: :code_review_workflow do
+ let_it_be_with_reload(:project) { create(:project, :repository) }
+
+ subject(:merge_request_diff_llm_summary) { build(:merge_request_diff_llm_summary) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:merge_request_diff) }
+ it { is_expected.to belong_to(:user).optional }
+ it { is_expected.to validate_presence_of(:content) }
+ it { is_expected.to validate_length_of(:content).is_at_most(2056) }
+ it { is_expected.to validate_presence_of(:provider) }
+ end
+end
diff --git a/spec/models/merge_request/metrics_spec.rb b/spec/models/merge_request/metrics_spec.rb
index 8d1d503b323..b1c2a9b1111 100644
--- a/spec/models/merge_request/metrics_spec.rb
+++ b/spec/models/merge_request/metrics_spec.rb
@@ -93,4 +93,12 @@ RSpec.describe MergeRequest::Metrics do
end
end
end
+
+ it_behaves_like 'database events tracking batch 2' do
+ let(:merge_request) { create(:merge_request) }
+
+ let(:record) { merge_request.metrics }
+ let(:namespace) { nil }
+ let(:update_params) { { pipeline_id: 1, updated_at: Date.tomorrow } }
+ end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index a3c0c9a0a74..a5e68829c5d 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -20,12 +20,16 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
it { is_expected.to belong_to(:target_project).class_name('Project') }
it { is_expected.to belong_to(:source_project).class_name('Project') }
it { is_expected.to belong_to(:merge_user).class_name("User") }
+
+ it do
+ is_expected.to belong_to(:head_pipeline).class_name('Ci::Pipeline').inverse_of(:merge_requests_as_head_pipeline)
+ end
+
it { is_expected.to have_many(:assignees).through(:merge_request_assignees) }
it { is_expected.to have_many(:reviewers).through(:merge_request_reviewers) }
it { is_expected.to have_many(:merge_request_diffs) }
it { is_expected.to have_many(:user_mentions).class_name("MergeRequestUserMention") }
it { is_expected.to belong_to(:milestone) }
- it { is_expected.to belong_to(:iteration) }
it { is_expected.to have_many(:resource_milestone_events) }
it { is_expected.to have_many(:resource_state_events) }
it { is_expected.to have_many(:draft_notes) }
@@ -33,6 +37,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
it { is_expected.to have_many(:reviewed_by_users).through(:reviews).source(:author) }
it { is_expected.to have_one(:cleanup_schedule).inverse_of(:merge_request) }
it { is_expected.to have_many(:created_environments).class_name('Environment').inverse_of(:merge_request) }
+ it { is_expected.to have_many(:assignment_events).class_name('ResourceEvents::MergeRequestAssignmentEvent').inverse_of(:merge_request) }
context 'for forks' do
let!(:project) { create(:project) }
@@ -393,8 +398,8 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
instance1 = MergeRequest.find(merge_request.id)
instance2 = MergeRequest.find(merge_request.id)
- instance1.ensure_metrics
- instance2.ensure_metrics
+ instance1.ensure_metrics!
+ instance2.ensure_metrics!
metrics_records = MergeRequest::Metrics.where(merge_request_id: merge_request.id)
expect(metrics_records.size).to eq(1)
@@ -760,6 +765,23 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
end
end
+ context 'when scoped with :merged_before and :merged_after' do
+ before do
+ mr2.metrics.update!(merged_at: mr1.metrics.merged_at - 1.week)
+ mr3.metrics.update!(merged_at: mr1.metrics.merged_at + 1.week)
+ end
+
+ it 'excludes merge requests outside of the date range' do
+ expect(
+ project.merge_requests.merge(
+ MergeRequest::Metrics
+ .merged_before(mr1.metrics.merged_at + 1.day)
+ .merged_after(mr1.metrics.merged_at - 1.day)
+ ).total_time_to_merge
+ ).to be_within(1).of(expected_total_time([mr1]))
+ end
+ end
+
def expected_total_time(mrs)
mrs = mrs.reject { |mr| mr.merged_at.nil? }
mrs.reduce(0.0) do |sum, mr|
@@ -1027,7 +1049,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
end
describe '#to_reference' do
- let(:project) { build(:project, name: 'sample-project') }
+ let(:project) { build(:project) }
let(:merge_request) { build(:merge_request, target_project: project, iid: 1) }
it 'returns a String reference to the object' do
@@ -1035,12 +1057,12 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
end
it 'supports a cross-project reference' do
- another_project = build(:project, name: 'another-project', namespace: project.namespace)
- expect(merge_request.to_reference(another_project)).to eq "sample-project!1"
+ another_project = build(:project, namespace: project.namespace)
+ expect(merge_request.to_reference(another_project)).to eq "#{project.path}!1"
end
it 'returns a String reference with the full path' do
- expect(merge_request.to_reference(full: true)).to eq(project.full_path + '!1')
+ expect(merge_request.to_reference(full: true)).to eq("#{project.full_path}!1")
end
end
@@ -4024,26 +4046,11 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
describe '#use_merge_base_pipeline_for_comparison?' do
let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, :with_codequality_reports, source_project: project) }
+ let(:service_class) { Ci::CompareReportsBaseService }
subject { merge_request.use_merge_base_pipeline_for_comparison?(service_class) }
- context 'when service class is Ci::CompareMetricsReportsService' do
- let(:service_class) { 'Ci::CompareMetricsReportsService' }
-
- it { is_expected.to be_truthy }
- end
-
- context 'when service class is Ci::CompareCodequalityReportsService' do
- let(:service_class) { 'Ci::CompareCodequalityReportsService' }
-
- it { is_expected.to be_truthy }
- end
-
- context 'when service class is different' do
- let(:service_class) { 'Ci::GenerateCoverageReportsService' }
-
- it { is_expected.to be_falsey }
- end
+ it { is_expected.to eq(false) }
end
describe '#comparison_base_pipeline' do
@@ -4051,6 +4058,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, :with_codequality_reports, source_project: project) }
+ let(:service_class) { ::Ci::CompareReportsBaseService }
let!(:base_pipeline) do
create(:ci_pipeline,
:with_test_reports,
@@ -4060,20 +4068,24 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
)
end
- context 'when service class is Ci::CompareCodequalityReportsService' do
- let(:service_class) { 'Ci::CompareCodequalityReportsService' }
+ before do
+ allow(merge_request).to receive(:use_merge_base_pipeline_for_comparison?)
+ .with(service_class).and_return(uses_merge_base)
+ end
+
+ context 'when service class uses merge base pipeline' do
+ let(:uses_merge_base) { true }
context 'when merge request has a merge request pipeline' do
let(:merge_request) do
- create(:merge_request, :with_merge_request_pipeline)
+ create(:merge_request, :with_merge_request_pipeline, source_project: project)
end
- let(:merge_base_pipeline) do
- create(:ci_pipeline, ref: merge_request.target_branch, sha: merge_request.target_branch_sha)
+ let!(:merge_base_pipeline) do
+ create(:ci_pipeline, project: project, ref: merge_request.target_branch, sha: merge_request.target_branch_sha)
end
before do
- merge_base_pipeline
merge_request.update_head_pipeline
end
@@ -4082,19 +4094,27 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
end
end
- context 'when merge does not have a merge request pipeline' do
- it 'returns the base_pipeline' do
- expect(pipeline).to eq(base_pipeline)
- end
+ it 'returns the base_pipeline when merge does not have a merge request pipeline' do
+ expect(pipeline).to eq(base_pipeline)
end
end
- context 'when service_class is different' do
- let(:service_class) { 'Ci::GenerateCoverageReportsService' }
+ context 'when service_class does not use merge base pipeline' do
+ let(:uses_merge_base) { false }
it 'returns the base_pipeline' do
expect(pipeline).to eq(base_pipeline)
end
+
+ context 'when merge request has a merge request pipeline' do
+ let(:merge_request) do
+ create(:merge_request, :with_merge_request_pipeline, source_project: project)
+ end
+
+ it 'returns the base pipeline' do
+ expect(pipeline).to eq(base_pipeline)
+ end
+ end
end
end
@@ -4279,8 +4299,11 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
it 'refreshes the number of open merge requests of the target project' do
project = subject.target_project
- expect { subject.destroy! }
- .to change { project.open_merge_requests_count }.from(1).to(0)
+ expect do
+ subject.destroy!
+
+ BatchLoader::Executor.clear_current
+ end.to change { project.open_merge_requests_count }.from(1).to(0)
end
end
@@ -4306,6 +4329,14 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
transition!
end
+ context 'when skip_merge_status_trigger is set to true' do
+ before do
+ subject.skip_merge_status_trigger = true
+ end
+
+ it_behaves_like 'transition not triggering mergeRequestMergeStatusUpdated GraphQL subscription'
+ end
+
context 'when transaction is not committed' do
it_behaves_like 'transition not triggering mergeRequestMergeStatusUpdated GraphQL subscription' do
def transition!
@@ -4459,7 +4490,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
let(:expected_merge_status) { 'checking' }
include_examples 'for a valid state transition'
- it_behaves_like 'transition triggering mergeRequestMergeStatusUpdated GraphQL subscription'
+ it_behaves_like 'transition not triggering mergeRequestMergeStatusUpdated GraphQL subscription'
end
context 'when the status is checking' do
@@ -4479,7 +4510,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
let(:expected_merge_status) { 'cannot_be_merged_rechecking' }
include_examples 'for a valid state transition'
- it_behaves_like 'transition triggering mergeRequestMergeStatusUpdated GraphQL subscription'
+ it_behaves_like 'transition not triggering mergeRequestMergeStatusUpdated GraphQL subscription'
end
context 'when the status is cannot_be_merged' do
@@ -4615,6 +4646,29 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
.from(true)
.to(false)
end
+
+ context 'when it is a first contribution' do
+ let(:new_user) { create(:user) }
+
+ before do
+ subject.update!(author: new_user)
+ end
+
+ it 'sets first_contribution' do
+ subject.mark_as_merged
+
+ expect(subject.state).to eq('merged')
+ expect(subject.reload.first_contribution?).to be_truthy
+ end
+
+ it "doesn't set first_contribution not first contribution" do
+ create(:merged_merge_request, author: new_user)
+
+ subject.mark_as_merged
+
+ expect(subject.first_contribution?).to be_falsey
+ end
+ end
end
describe 'transition to cannot_be_merged' do
@@ -4697,9 +4751,9 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
end
[:closed, :merged].each do |state|
- let(:state) { state }
-
context state do
+ let(:state) { state }
+
it 'does not notify' do
expect(notification_service).not_to receive(:merge_request_unmergeable)
expect(todo_service).not_to receive(:merge_request_became_unmergeable)
diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb
index 9f6b1f8016b..1c43eafb576 100644
--- a/spec/models/milestone_spec.rb
+++ b/spec/models/milestone_spec.rb
@@ -439,8 +439,8 @@ RSpec.describe Milestone do
describe '#to_reference' do
let(:group) { build_stubbed(:group) }
- let(:project) { build_stubbed(:project, name: 'sample-project') }
- let(:another_project) { build_stubbed(:project, name: 'another-project', namespace: project.namespace) }
+ let(:project) { build_stubbed(:project, path: 'sample-project') }
+ let(:another_project) { build_stubbed(:project, path: 'another-project', namespace: project.namespace) }
context 'for a project milestone' do
let(:milestone) { build_stubbed(:milestone, iid: 1, project: project, name: 'milestone') }
@@ -724,4 +724,12 @@ RSpec.describe Milestone do
end
end
end
+
+ describe '#lock_version' do
+ let_it_be(:milestone) { create(:milestone, project: project) }
+
+ it 'ensures that lock_version and optimistic locking is enabled' do
+ expect(milestone.lock_version).to be_present
+ end
+ end
end
diff --git a/spec/models/ml/candidate_spec.rb b/spec/models/ml/candidate_spec.rb
index 374e49aea01..fa19b723ee2 100644
--- a/spec/models/ml/candidate_spec.rb
+++ b/spec/models/ml/candidate_spec.rb
@@ -3,48 +3,74 @@
require 'spec_helper'
RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops do
- let_it_be(:candidate) { create(:ml_candidates, :with_metrics_and_params, name: 'candidate0') }
+ let_it_be(:candidate) { create(:ml_candidates, :with_metrics_and_params, :with_artifact, name: 'candidate0') }
let_it_be(:candidate2) do
create(:ml_candidates, experiment: candidate.experiment, user: create(:user), name: 'candidate2')
end
- let_it_be(:candidate_artifact) do
- FactoryBot.create(:generic_package,
- name: candidate.package_name,
- version: candidate.package_version,
- project: candidate.project)
- end
-
- let(:project) { candidate.experiment.project }
+ let(:project) { candidate.project }
describe 'associations' do
it { is_expected.to belong_to(:experiment) }
+ it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:user) }
+ it { is_expected.to belong_to(:package) }
+ it { is_expected.to belong_to(:ci_build).class_name('Ci::Build') }
it { is_expected.to have_many(:params) }
it { is_expected.to have_many(:metrics) }
it { is_expected.to have_many(:metadata) }
end
+ describe 'modules' do
+ it_behaves_like 'AtomicInternalId' do
+ let(:internal_id_attribute) { :internal_id }
+ let(:instance) { build(:ml_candidates, experiment: candidate.experiment) }
+ let(:scope) { :project }
+ let(:scope_attrs) { { project: instance.project } }
+ let(:usage) { :ml_candidates }
+ end
+ end
+
describe 'default values' do
- it { expect(described_class.new.iid).to be_present }
+ it { expect(described_class.new.eid).to be_present }
end
- describe '.artifact_root' do
- subject { candidate.artifact_root }
+ describe '.destroy' do
+ let_it_be(:candidate_to_destroy) do
+ create(:ml_candidates, :with_metrics_and_params, :with_metadata, :with_artifact)
+ end
- it { is_expected.to eq("/ml_candidate_#{candidate.id}/-/") }
+ it 'destroys metrics, params and metadata, but not the artifact', :aggregate_failures do
+ expect { candidate_to_destroy.destroy! }
+ .to change { Ml::CandidateMetadata.count }.by(-2)
+ .and change { Ml::CandidateParam.count }.by(-2)
+ .and change { Ml::CandidateMetric.count }.by(-2)
+ .and not_change { Packages::Package.count }
+ end
end
- describe '.package_name' do
- subject { candidate.package_name }
+ describe '.artifact_root' do
+ subject { candidate.artifact_root }
- it { is_expected.to eq("ml_candidate_#{candidate.id}") }
+ it { is_expected.to eq("/#{candidate.package_name}/#{candidate.iid}/") }
end
describe '.package_version' do
subject { candidate.package_version }
- it { is_expected.to eq('-') }
+ it { is_expected.to eq(candidate.iid) }
+ end
+
+ describe '.eid' do
+ let_it_be(:eid) { SecureRandom.uuid }
+
+ let_it_be(:candidate3) do
+ build(:ml_candidates, :with_metrics_and_params, name: 'candidate0', eid: eid)
+ end
+
+ subject { candidate3.eid }
+
+ it { is_expected.to eq(eid) }
end
describe '.artifact' do
@@ -52,10 +78,6 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d
subject { tested_candidate.artifact }
- before do
- candidate_artifact
- end
-
context 'when has logged artifacts' do
it 'returns the package' do
expect(subject.name).to eq(tested_candidate.package_name)
@@ -69,21 +91,26 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d
end
end
- describe '.artifact_lazy' do
- context 'when candidates have same the same iid' do
- before do
- BatchLoader::Executor.clear_current
- end
+ describe '#by_project_id_and_eid' do
+ let(:project_id) { candidate.experiment.project_id }
+ let(:eid) { candidate.eid }
- it 'loads the correct artifacts', :aggregate_failures do
- candidate.artifact_lazy
- candidate2.artifact_lazy
+ subject { described_class.with_project_id_and_eid(project_id, eid) }
- expect(Packages::Package).to receive(:joins).once.and_call_original # Only one database call
+ context 'when eid exists', 'and belongs to project' do
+ it { is_expected.to eq(candidate) }
+ end
- expect(candidate.artifact.name).to eq(candidate.package_name)
- expect(candidate2.artifact).to be_nil
- end
+ context 'when eid exists', 'and does not belong to project' do
+ let(:project_id) { non_existing_record_id }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when eid does not exist' do
+ let(:eid) { 'a' }
+
+ it { is_expected.to be_nil }
end
end
@@ -93,18 +120,18 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d
subject { described_class.with_project_id_and_iid(project_id, iid) }
- context 'when iid exists', 'and belongs to project' do
+ context 'when internal_id exists', 'and belongs to project' do
it { is_expected.to eq(candidate) }
end
- context 'when iid exists', 'and does not belong to project' do
+ context 'when internal_id exists', 'and does not belong to project' do
let(:project_id) { non_existing_record_id }
it { is_expected.to be_nil }
end
- context 'when iid does not exist' do
- let(:iid) { 'a' }
+ context 'when internal_id does not exist' do
+ let(:iid) { non_existing_record_id }
it { is_expected.to be_nil }
end
@@ -130,6 +157,9 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d
expect(subject.association_cached?(:latest_metrics)).to be(true)
expect(subject.association_cached?(:params)).to be(true)
expect(subject.association_cached?(:user)).to be(true)
+ expect(subject.association_cached?(:project)).to be(true)
+ expect(subject.association_cached?(:package)).to be(true)
+ expect(subject.association_cached?(:ci_build)).to be(true)
end
end
@@ -161,6 +191,22 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d
end
end
+ describe 'from_ci?' do
+ subject { candidate }
+
+ it 'is false if candidate does not have ci_build_id' do
+ allow(candidate).to receive(:ci_build_id).and_return(nil)
+
+ is_expected.not_to be_from_ci
+ end
+
+ it 'is true if candidate does has ci_build_id' do
+ allow(candidate).to receive(:ci_build_id).and_return(1)
+
+ is_expected.to be_from_ci
+ end
+ end
+
describe '#order_by_metric' do
let_it_be(:auc_metrics) do
create(:ml_candidate_metrics, name: 'auc', value: 0.4, candidate: candidate)
@@ -183,4 +229,11 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d
end
end
end
+
+ context 'with loose foreign key on ml_candidates.ci_build_id' do
+ it_behaves_like 'cleanup by a loose foreign key' do
+ let!(:parent) { create(:ci_build) }
+ let!(:model) { create(:ml_candidates, ci_build: parent) }
+ end
+ end
end
diff --git a/spec/models/ml/experiment_spec.rb b/spec/models/ml/experiment_spec.rb
index c75331a2ab5..9738a88b5b8 100644
--- a/spec/models/ml/experiment_spec.rb
+++ b/spec/models/ml/experiment_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ml::Experiment do
+RSpec.describe Ml::Experiment, feature_category: :mlops do
let_it_be(:exp) { create(:ml_experiments) }
let_it_be(:exp2) { create(:ml_experiments, project: exp.project) }
@@ -16,6 +16,12 @@ RSpec.describe Ml::Experiment do
it { is_expected.to have_many(:metadata) }
end
+ describe '.package_name' do
+ describe '.package_name' do
+ it { expect(exp.package_name).to eq("ml_experiment_#{exp.iid}") }
+ end
+ end
+
describe '#by_project_id_and_iid' do
subject { described_class.by_project_id_and_iid(exp.project_id, iid) }
@@ -74,4 +80,22 @@ RSpec.describe Ml::Experiment do
expect(subject[exp3.id]).to eq(3)
end
end
+
+ describe '#package_for_experiment?' do
+ using RSpec::Parameterized::TableSyntax
+
+ subject { described_class.package_for_experiment?(package_name) }
+
+ where(:package_name, :id) do
+ 'ml_experiment_1234' | true
+ 'ml_experiment_1234abc' | false
+ 'ml_experiment_abc' | false
+ 'ml_experiment_' | false
+ 'blah' | false
+ end
+
+ with_them do
+ it { is_expected.to be(id) }
+ end
+ end
end
diff --git a/spec/models/namespace/aggregation_schedule_spec.rb b/spec/models/namespace/aggregation_schedule_spec.rb
index 45b66fa12dd..0289e4a5462 100644
--- a/spec/models/namespace/aggregation_schedule_spec.rb
+++ b/spec/models/namespace/aggregation_schedule_spec.rb
@@ -5,27 +5,33 @@ require 'spec_helper'
RSpec.describe Namespace::AggregationSchedule, :clean_gitlab_redis_shared_state, type: :model do
include ExclusiveLeaseHelpers
- let(:default_timeout) { described_class.default_lease_timeout }
+ let(:namespace) { create(:namespace) }
+ let(:aggregation_schedule) { namespace.build_aggregation_schedule }
+ let(:default_timeout) { aggregation_schedule.default_lease_timeout }
it { is_expected.to belong_to :namespace }
describe "#default_lease_timeout" do
- subject(:default_lease_timeout) { default_timeout }
-
- it { is_expected.to eq 30.minutes.to_i }
+ before do
+ aggregation_schedule.save!
+ end
- context 'when remove_namespace_aggregator_delay FF is disabled' do
- before do
- stub_feature_flags(remove_namespace_aggregator_delay: false)
+ context 'when reduce_aggregation_schedule_lease FF is enabled' do
+ it 'is 2 minutes' do
+ stub_feature_flags(reduce_aggregation_schedule_lease: true)
+ expect(aggregation_schedule.default_lease_timeout).to eq 2.minutes.to_i
end
+ end
- it { is_expected.to eq 1.hour.to_i }
+ context 'when reduce_aggregation_schedule_lease FF is disabled' do
+ it 'is 30 minutes' do
+ stub_feature_flags(reduce_aggregation_schedule_lease: false)
+ expect(aggregation_schedule.default_lease_timeout).to eq 30.minutes.to_i
+ end
end
end
describe '#schedule_root_storage_statistics' do
- let(:namespace) { create(:namespace) }
- let(:aggregation_schedule) { namespace.build_aggregation_schedule }
let(:lease_key) { "namespace:namespaces_root_statistics:#{namespace.id}" }
context "when we can't obtain the lease" do
diff --git a/spec/models/namespace/package_setting_spec.rb b/spec/models/namespace/package_setting_spec.rb
index 2584fa597ad..fca929600a4 100644
--- a/spec/models/namespace/package_setting_spec.rb
+++ b/spec/models/namespace/package_setting_spec.rb
@@ -47,28 +47,30 @@ RSpec.describe Namespace::PackageSetting do
context 'package types with package_settings' do
# As more package types gain settings they will be added to this list
[:maven_package, :generic_package].each do |format|
- let_it_be(:package) { create(format, name: 'foo', version: 'beta') } # rubocop:disable Rails/SaveBang
- let_it_be(:package_type) { package.package_type }
- let_it_be(:package_setting) { package.project.namespace.package_settings }
-
- where(:duplicates_allowed, :duplicate_exception_regex, :result) do
- true | '' | true
- false | '' | false
- false | '.*' | true
- false | 'fo.*' | true
- false | 'be.*' | true
- end
+ context "with package_type:#{format}" do
+ let_it_be(:package) { create(format, name: 'foo', version: 'beta') } # rubocop:disable Rails/SaveBang
+ let_it_be(:package_type) { package.package_type }
+ let_it_be(:package_setting) { package.project.namespace.package_settings }
+
+ where(:duplicates_allowed, :duplicate_exception_regex, :result) do
+ true | '' | true
+ false | '' | false
+ false | '.*' | true
+ false | 'fo.*' | true
+ false | 'be.*' | true
+ end
- with_them do
- context "for #{format}" do
- before do
- package_setting.update!(
- "#{package_type}_duplicates_allowed" => duplicates_allowed,
- "#{package_type}_duplicate_exception_regex" => duplicate_exception_regex
- )
- end
+ with_them do
+ context "for #{format}" do
+ before do
+ package_setting.update!(
+ "#{package_type}_duplicates_allowed" => duplicates_allowed,
+ "#{package_type}_duplicate_exception_regex" => duplicate_exception_regex
+ )
+ end
- it { is_expected.to be(result) }
+ it { is_expected.to be(result) }
+ end
end
end
end
@@ -76,11 +78,13 @@ RSpec.describe Namespace::PackageSetting do
context 'package types without package_settings' do
[:npm_package, :conan_package, :nuget_package, :pypi_package, :composer_package, :golang_package, :debian_package].each do |format|
- let_it_be(:package) { create(format) } # rubocop:disable Rails/SaveBang
- let_it_be(:package_setting) { package.project.namespace.package_settings }
+ context "with package_type:#{format}" do
+ let_it_be(:package) { create(format) } # rubocop:disable Rails/SaveBang
+ let_it_be(:package_setting) { package.project.namespace.package_settings }
- it 'raises an error' do
- expect { subject }.to raise_error(Namespace::PackageSetting::PackageSettingNotImplemented)
+ it 'raises an error' do
+ expect { subject }.to raise_error(Namespace::PackageSetting::PackageSettingNotImplemented)
+ end
end
end
end
diff --git a/spec/models/namespace/root_storage_statistics_spec.rb b/spec/models/namespace/root_storage_statistics_spec.rb
index 14ac08b545a..c2a0c8c8a7c 100644
--- a/spec/models/namespace/root_storage_statistics_spec.rb
+++ b/spec/models/namespace/root_storage_statistics_spec.rb
@@ -22,15 +22,13 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
end
describe '#recalculate!' do
- let(:namespace) { create(:group) }
+ let_it_be(:namespace) { create(:group) }
+
let(:root_storage_statistics) { create(:namespace_root_storage_statistics, namespace: namespace) }
let(:project1) { create(:project, namespace: namespace) }
let(:project2) { create(:project, namespace: namespace) }
- let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) }
- let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) }
-
shared_examples 'project data refresh' do
it 'aggregates project statistics' do
root_storage_statistics.recalculate!
@@ -96,8 +94,13 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
end
end
- it_behaves_like 'project data refresh'
- it_behaves_like 'does not include personal snippets'
+ context 'with project statistics' do
+ let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) }
+ let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) }
+
+ it_behaves_like 'project data refresh'
+ it_behaves_like 'does not include personal snippets'
+ end
context 'with subgroups' do
let(:subgroup1) { create(:group, parent: namespace) }
@@ -106,6 +109,9 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
let(:project1) { create(:project, namespace: subgroup1) }
let(:project2) { create(:project, namespace: subgroup2) }
+ let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) }
+ let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) }
+
it_behaves_like 'project data refresh'
it_behaves_like 'does not include personal snippets'
end
@@ -122,6 +128,9 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
let(:namespace) { root_group }
+ let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) }
+ let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) }
+
it 'aggregates namespace statistics' do
# This group is not a descendant of the root_group so it shouldn't be included in the final stats.
other_group = create(:group)
@@ -168,6 +177,9 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
let(:namespace) { user.namespace }
+ let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) }
+ let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) }
+
it_behaves_like 'project data refresh'
it 'does not aggregate namespace statistics' do
@@ -210,5 +222,143 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
end
end
end
+
+ context 'with forks of projects' do
+ it 'aggregates total private forks size' do
+ project = create_project(visibility_level: :private, size_multiplier: 150)
+ project_fork = create_fork(project, size_multiplier: 100)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.private_forks_storage_size).to eq(project_fork.statistics.storage_size)
+ end
+
+ it 'aggregates total public forks size' do
+ project = create_project(visibility_level: :public, size_multiplier: 250)
+ project_fork = create_fork(project, size_multiplier: 200)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.public_forks_storage_size).to eq(project_fork.statistics.storage_size)
+ end
+
+ it 'aggregates total internal forks size' do
+ project = create_project(visibility_level: :internal, size_multiplier: 70)
+ project_fork = create_fork(project, size_multiplier: 50)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.internal_forks_storage_size).to eq(project_fork.statistics.storage_size)
+ end
+
+ it 'aggregates multiple forks' do
+ project = create_project(size_multiplier: 175)
+ fork_a = create_fork(project, size_multiplier: 50)
+ fork_b = create_fork(project, size_multiplier: 60)
+
+ root_storage_statistics.recalculate!
+
+ total_size = fork_a.statistics.storage_size + fork_b.statistics.storage_size
+ expect(root_storage_statistics.reload.private_forks_storage_size).to eq(total_size)
+ end
+
+ it 'aggregates only forks in the namespace' do
+ other_namespace = create(:group)
+ project = create_project(size_multiplier: 175)
+ fork_a = create_fork(project, size_multiplier: 50)
+ create_fork(project, size_multiplier: 50, namespace: other_namespace)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.private_forks_storage_size).to eq(fork_a.statistics.storage_size)
+ end
+
+ it 'aggregates forks in subgroups' do
+ subgroup = create(:group, parent: namespace)
+ project = create_project(size_multiplier: 100)
+ project_fork = create_fork(project, namespace: subgroup, size_multiplier: 300)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.private_forks_storage_size).to eq(project_fork.statistics.storage_size)
+ end
+
+ it 'aggregates forks along with total storage size' do
+ project = create_project(size_multiplier: 240)
+ project_fork = create_fork(project, size_multiplier: 100)
+
+ root_storage_statistics.recalculate!
+
+ root_storage_statistics.reload
+ expect(root_storage_statistics.private_forks_storage_size).to eq(project_fork.statistics.storage_size)
+ expect(root_storage_statistics.storage_size).to eq(project.statistics.storage_size + project_fork.statistics.storage_size)
+ end
+
+ it 'sets the public forks storage size back to zero' do
+ root_storage_statistics.update!(public_forks_storage_size: 200)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.public_forks_storage_size).to eq(0)
+ end
+
+ it 'sets the private forks storage size back to zero' do
+ root_storage_statistics.update!(private_forks_storage_size: 100)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.private_forks_storage_size).to eq(0)
+ end
+
+ it 'sets the internal forks storage size back to zero' do
+ root_storage_statistics.update!(internal_forks_storage_size: 50)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.internal_forks_storage_size).to eq(0)
+ end
+
+ context 'when the feature flag is off' do
+ before do
+ stub_feature_flags(root_storage_statistics_calculate_forks: false)
+ end
+
+ it 'does not aggregate fork storage sizes' do
+ project = create_project(size_multiplier: 150)
+ create_fork(project, size_multiplier: 100)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.private_forks_storage_size).to eq(0)
+ end
+
+ it 'aggregates fork sizes for enabled namespaces' do
+ stub_feature_flags(root_storage_statistics_calculate_forks: namespace)
+ project = create_project(size_multiplier: 150)
+ project_fork = create_fork(project, size_multiplier: 100)
+
+ root_storage_statistics.recalculate!
+
+ expect(root_storage_statistics.reload.private_forks_storage_size).to eq(project_fork.statistics.storage_size)
+ end
+ end
+ end
+ end
+
+ def create_project(size_multiplier:, visibility_level: :private)
+ project = create(:project, visibility_level, namespace: namespace)
+ create(:project_statistics, project: project, with_data: true, size_multiplier: size_multiplier)
+
+ project
+ end
+
+ def create_fork(project, size_multiplier:, namespace: nil)
+ fork_namespace = namespace || project.namespace
+ project_fork = create(:project, namespace: fork_namespace, visibility_level: project.visibility_level)
+ create(:project_statistics, project: project_fork, with_data: true, size_multiplier: size_multiplier)
+ fork_network = project.fork_network || (create(:fork_network, root_project: project) && project.reload.fork_network)
+ create(:fork_network_member, project: project_fork, fork_network: fork_network)
+
+ project_fork
end
end
diff --git a/spec/models/namespace_setting_spec.rb b/spec/models/namespace_setting_spec.rb
index b7cc59b5af3..ba0ce7d6f7f 100644
--- a/spec/models/namespace_setting_spec.rb
+++ b/spec/models/namespace_setting_spec.rb
@@ -15,6 +15,8 @@ RSpec.describe NamespaceSetting, feature_category: :subgroups, type: :model do
it { is_expected.to define_enum_for(:enabled_git_access_protocol).with_values([:all, :ssh, :http]).with_suffix }
describe "validations" do
+ it { is_expected.to validate_inclusion_of(:code_suggestions).in_array([true, false]) }
+
describe "#default_branch_name_content" do
let_it_be(:group) { create(:group) }
@@ -192,7 +194,7 @@ RSpec.describe NamespaceSetting, feature_category: :subgroups, type: :model do
context 'when a group has parent groups' do
let(:grandparent) { create(:group, namespace_settings: settings) }
let(:parent) { create(:group, parent: grandparent) }
- let!(:group) { create(:group, parent: parent) }
+ let!(:group) { create(:group, parent: parent) }
context "when a parent group has disabled diff previews" do
let(:settings) { create(:namespace_settings, show_diff_preview_in_email: false) }
@@ -212,61 +214,87 @@ RSpec.describe NamespaceSetting, feature_category: :subgroups, type: :model do
end
end
- describe '#runner_registration_enabled?' do
- context 'when not a subgroup' do
- let_it_be(:settings) { create(:namespace_settings) }
- let_it_be(:group) { create(:group, namespace_settings: settings) }
+ context 'runner registration settings' do
+ shared_context 'with runner registration settings changing in hierarchy' do
+ context 'when there are no parents' do
+ let_it_be(:group) { create(:group) }
- before do
- group.update!(runner_registration_enabled: runner_registration_enabled)
+ it { is_expected.to be_truthy }
+
+ context 'when no group can register runners' do
+ before do
+ stub_application_setting(valid_runner_registrars: [])
+ end
+
+ it { is_expected.to be_falsey }
+ end
end
- context 'when :runner_registration_enabled is false' do
- let(:runner_registration_enabled) { false }
+ context 'when there are parents' do
+ let_it_be(:grandparent) { create(:group) }
+ let_it_be(:parent) { create(:group, parent: grandparent) }
+ let_it_be(:group) { create(:group, parent: parent) }
- it 'returns false' do
- expect(group.runner_registration_enabled?).to be_falsey
+ before do
+ grandparent.update!(runner_registration_enabled: grandparent_runner_registration_enabled)
end
- it 'does not query the db' do
- expect { group.runner_registration_enabled? }.not_to exceed_query_limit(0)
+ context 'when a parent group has runner registration disabled' do
+ let(:grandparent_runner_registration_enabled) { false }
+
+ it { is_expected.to be_falsey }
end
- end
- context 'when :runner_registration_enabled is true' do
- let(:runner_registration_enabled) { true }
+ context 'when all parent groups have runner registration enabled' do
+ let(:grandparent_runner_registration_enabled) { true }
- it 'returns true' do
- expect(group.runner_registration_enabled?).to be_truthy
+ it { is_expected.to be_truthy }
end
end
end
- context 'when a group has parent groups' do
- let_it_be(:grandparent) { create(:group) }
- let_it_be(:parent) { create(:group, parent: grandparent) }
- let_it_be(:group) { create(:group, parent: parent) }
+ describe '#runner_registration_enabled?' do
+ subject(:group_setting) { group.runner_registration_enabled? }
+
+ let_it_be(:settings) { create(:namespace_settings) }
+ let_it_be(:group) { create(:group, namespace_settings: settings) }
before do
- grandparent.update!(runner_registration_enabled: runner_registration_enabled)
+ group.update!(runner_registration_enabled: group_runner_registration_enabled)
end
- context 'when a parent group has runner registration disabled' do
- let(:runner_registration_enabled) { false }
+ context 'when runner registration is enabled' do
+ let(:group_runner_registration_enabled) { true }
- it 'returns false' do
- expect(group.runner_registration_enabled?).to be_falsey
- end
+ it { is_expected.to be_truthy }
+
+ it_behaves_like 'with runner registration settings changing in hierarchy'
end
- context 'when all parent groups have runner registration enabled' do
- let(:runner_registration_enabled) { true }
+ context 'when runner registration is disabled' do
+ let(:group_runner_registration_enabled) { false }
- it 'returns true' do
- expect(group.runner_registration_enabled?).to be_truthy
+ it { is_expected.to be_falsey }
+
+ it 'does not query the db' do
+ expect { group.runner_registration_enabled? }.not_to exceed_query_limit(0)
+ end
+
+ context 'when group runner registration is disallowed' do
+ before do
+ stub_application_setting(valid_runner_registrars: [])
+ end
+
+ it { is_expected.to be_falsey }
end
end
end
+
+ describe '#all_ancestors_have_runner_registration_enabled?' do
+ subject(:group_setting) { group.all_ancestors_have_runner_registration_enabled? }
+
+ it_behaves_like 'with runner registration settings changing in hierarchy'
+ end
end
describe '#allow_runner_registration_token?' do
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index a0698ac30f5..3ff49938de5 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -31,7 +31,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
it { is_expected.to have_many :pending_builds }
it { is_expected.to have_one :namespace_route }
it { is_expected.to have_many :namespace_members }
- it { is_expected.to have_many :member_roles }
it { is_expected.to have_one :cluster_enabled_grant }
it { is_expected.to have_many(:work_items) }
it { is_expected.to have_many :achievements }
@@ -91,7 +90,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
context 'validating the parent of a namespace' do
using RSpec::Parameterized::TableSyntax
- # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
where(:parent_type, :child_type, :error) do
nil | ref(:user_sti_name) | nil
nil | ref(:group_sti_name) | nil
@@ -106,7 +104,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
ref(:user_sti_name) | ref(:group_sti_name) | 'user namespace cannot be the parent of another namespace'
ref(:user_sti_name) | ref(:project_sti_name) | nil
end
- # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands
with_them do
it 'validates namespace parent' do
@@ -171,19 +168,26 @@ RSpec.describe Namespace, feature_category: :subgroups do
let_it_be(:parent) { create(:namespace) }
- # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
where(:namespace_type, :path, :valid) do
- ref(:project_sti_name) | 'j' | true
- ref(:project_sti_name) | 'path.' | true
- ref(:project_sti_name) | 'blob' | false
- ref(:group_sti_name) | 'j' | false
- ref(:group_sti_name) | 'path.' | false
- ref(:group_sti_name) | 'blob' | true
- ref(:user_sti_name) | 'j' | false
- ref(:user_sti_name) | 'path.' | false
- ref(:user_sti_name) | 'blob' | true
- end
- # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands
+ ref(:project_sti_name) | 'j' | true
+ ref(:project_sti_name) | 'path.' | false
+ ref(:project_sti_name) | '.path' | false
+ ref(:project_sti_name) | 'path.git' | false
+ ref(:project_sti_name) | 'namespace__path' | false
+ ref(:project_sti_name) | 'blob' | false
+ ref(:group_sti_name) | 'j' | false
+ ref(:group_sti_name) | 'path.' | false
+ ref(:group_sti_name) | '.path' | false
+ ref(:group_sti_name) | 'path.git' | false
+ ref(:group_sti_name) | 'namespace__path' | false
+ ref(:group_sti_name) | 'blob' | true
+ ref(:user_sti_name) | 'j' | false
+ ref(:user_sti_name) | 'path.' | false
+ ref(:user_sti_name) | '.path' | false
+ ref(:user_sti_name) | 'path.git' | false
+ ref(:user_sti_name) | 'namespace__path' | false
+ ref(:user_sti_name) | 'blob' | true
+ end
with_them do
it 'validates namespace path' do
@@ -193,6 +197,26 @@ RSpec.describe Namespace, feature_category: :subgroups do
expect(namespace.valid?).to be(valid)
end
end
+
+ context 'when path starts or ends with a special character' do
+ it 'does not raise validation error for path for existing namespaces' do
+ parent.update_attribute(:path, '_path_')
+
+ expect { parent.update!(name: 'Foo') }.not_to raise_error
+ end
+ end
+
+ context 'when restrict_special_characters_in_namespace_path feature flag is disabled' do
+ before do
+ stub_feature_flags(restrict_special_characters_in_namespace_path: false)
+ end
+
+ it 'allows special character at the start or end of project namespace path' do
+ namespace = build(:namespace, type: project_sti_name, parent: parent, path: '_path_')
+
+ expect(namespace).to be_valid
+ end
+ end
end
describe '1 char path length' do
@@ -237,6 +261,117 @@ RSpec.describe Namespace, feature_category: :subgroups do
end
end
+ describe "ReferencePatternValidation" do
+ subject { described_class.reference_pattern }
+
+ it { is_expected.to match("@group1") }
+ it { is_expected.to match("@group1/group2/group3") }
+ it { is_expected.to match("@1234/1234/1234") }
+ it { is_expected.to match("@.q-w_e") }
+ end
+
+ describe '#to_reference_base' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user_namespace) { user.namespace }
+
+ let_it_be(:parent) { create(:group) }
+ let_it_be(:group) { create(:group, parent: parent) }
+ let_it_be(:another_group) { create(:group) }
+
+ let_it_be(:project) { create(:project, namespace: group) }
+ let_it_be(:project_namespace) { project.project_namespace }
+
+ let_it_be(:another_namespace_project) { create(:project) }
+ let_it_be(:another_namespace_project_namespace) { another_namespace_project.project_namespace }
+
+ # testing references with namespace being: group, project namespace and user namespace
+ where(:namespace, :full, :from, :result) do
+ ref(:parent) | false | nil | nil
+ ref(:parent) | true | nil | lazy { parent.full_path }
+ ref(:parent) | false | ref(:group) | lazy { parent.path }
+ ref(:parent) | true | ref(:group) | lazy { parent.full_path }
+ ref(:parent) | false | ref(:parent) | nil
+ ref(:parent) | true | ref(:parent) | lazy { parent.full_path }
+ ref(:parent) | false | ref(:project) | lazy { parent.path }
+ ref(:parent) | true | ref(:project) | lazy { parent.full_path }
+ ref(:parent) | false | ref(:project_namespace) | lazy { parent.path }
+ ref(:parent) | true | ref(:project_namespace) | lazy { parent.full_path }
+ ref(:parent) | false | ref(:another_group) | lazy { parent.full_path }
+ ref(:parent) | true | ref(:another_group) | lazy { parent.full_path }
+ ref(:parent) | false | ref(:another_namespace_project) | lazy { parent.full_path }
+ ref(:parent) | true | ref(:another_namespace_project) | lazy { parent.full_path }
+ ref(:parent) | false | ref(:another_namespace_project_namespace) | lazy { parent.full_path }
+ ref(:parent) | true | ref(:another_namespace_project_namespace) | lazy { parent.full_path }
+ ref(:parent) | false | ref(:user_namespace) | lazy { parent.full_path }
+ ref(:parent) | true | ref(:user_namespace) | lazy { parent.full_path }
+
+ ref(:group) | false | nil | nil
+ ref(:group) | true | nil | lazy { group.full_path }
+ ref(:group) | false | ref(:group) | nil
+ ref(:group) | true | ref(:group) | lazy { group.full_path }
+ ref(:group) | false | ref(:parent) | lazy { group.path }
+ ref(:group) | true | ref(:parent) | lazy { group.full_path }
+ ref(:group) | false | ref(:project) | lazy { group.path }
+ ref(:group) | true | ref(:project) | lazy { group.full_path }
+ ref(:group) | false | ref(:project_namespace) | lazy { group.path }
+ ref(:group) | true | ref(:project_namespace) | lazy { group.full_path }
+ ref(:group) | false | ref(:another_group) | lazy { group.full_path }
+ ref(:group) | true | ref(:another_group) | lazy { group.full_path }
+ ref(:group) | false | ref(:another_namespace_project) | lazy { group.full_path }
+ ref(:group) | true | ref(:another_namespace_project) | lazy { group.full_path }
+ ref(:group) | false | ref(:another_namespace_project_namespace) | lazy { group.full_path }
+ ref(:group) | true | ref(:another_namespace_project_namespace) | lazy { group.full_path }
+ ref(:group) | false | ref(:user_namespace) | lazy { group.full_path }
+ ref(:group) | true | ref(:user_namespace) | lazy { group.full_path }
+
+ ref(:project_namespace) | false | nil | nil
+ ref(:project_namespace) | true | nil | lazy { project_namespace.full_path }
+ ref(:project_namespace) | false | ref(:group) | lazy { project_namespace.path }
+ ref(:project_namespace) | true | ref(:group) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | false | ref(:parent) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | true | ref(:parent) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | false | ref(:project) | nil
+ ref(:project_namespace) | true | ref(:project) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | false | ref(:project_namespace) | nil
+ ref(:project_namespace) | true | ref(:project_namespace) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | false | ref(:another_group) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | true | ref(:another_group) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | false | ref(:another_namespace_project) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | true | ref(:another_namespace_project) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | false | ref(:another_namespace_project_namespace) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | true | ref(:another_namespace_project_namespace) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | false | ref(:user_namespace) | lazy { project_namespace.full_path }
+ ref(:project_namespace) | true | ref(:user_namespace) | lazy { project_namespace.full_path }
+
+ ref(:user_namespace) | false | nil | nil
+ ref(:user_namespace) | true | nil | lazy { user_namespace.full_path }
+ ref(:user_namespace) | false | ref(:user_namespace) | nil
+ ref(:user_namespace) | true | ref(:user_namespace) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | false | ref(:group) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | true | ref(:group) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | false | ref(:parent) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | true | ref(:parent) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | false | ref(:project) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | true | ref(:project) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | false | ref(:project_namespace) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | true | ref(:project_namespace) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | false | ref(:another_group) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | true | ref(:another_group) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | false | ref(:another_namespace_project) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | true | ref(:another_namespace_project) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | false | ref(:another_namespace_project_namespace) | lazy { user_namespace.full_path }
+ ref(:user_namespace) | true | ref(:another_namespace_project_namespace) | lazy { user_namespace.full_path }
+ end
+
+ with_them do
+ it 'returns correct path' do
+ expect(namespace.to_reference_base(from, full: full)).to eq(result)
+ end
+ end
+ end
+
describe 'handling STI', :aggregate_failures do
let(:namespace_type) { nil }
let(:parent) { nil }
@@ -369,7 +504,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
it { is_expected.to delegate_method(:runner_registration_enabled).to(:namespace_settings) }
it { is_expected.to delegate_method(:runner_registration_enabled?).to(:namespace_settings) }
it { is_expected.to delegate_method(:allow_runner_registration_token).to(:namespace_settings) }
- it { is_expected.to delegate_method(:allow_runner_registration_token?).to(:namespace_settings) }
it { is_expected.to delegate_method(:maven_package_requests_forwarding).to(:package_settings) }
it { is_expected.to delegate_method(:pypi_package_requests_forwarding).to(:package_settings) }
it { is_expected.to delegate_method(:npm_package_requests_forwarding).to(:package_settings) }
@@ -388,6 +522,38 @@ RSpec.describe Namespace, feature_category: :subgroups do
is_expected.to delegate_method(:allow_runner_registration_token=).to(:namespace_settings)
.with_arguments(:args)
end
+
+ describe '#allow_runner_registration_token?' do
+ subject { namespace.allow_runner_registration_token? }
+
+ context 'when namespace_settings is nil' do
+ let_it_be(:namespace) { create(:namespace) }
+
+ it { is_expected.to eq false }
+ end
+
+ context 'when namespace_settings is not nil' do
+ let_it_be(:namespace) { create(:namespace, :with_namespace_settings) }
+
+ it { is_expected.to eq true }
+
+ context 'when namespace_settings.allow_runner_registration_token? is false' do
+ before do
+ namespace.allow_runner_registration_token = false
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context 'when namespace_settings.allow_runner_registration_token? is true' do
+ before do
+ namespace.allow_runner_registration_token = true
+ end
+
+ it { is_expected.to eq true }
+ end
+ end
+ end
end
describe "Respond to" do
@@ -436,6 +602,14 @@ RSpec.describe Namespace, feature_category: :subgroups do
end
end
+ context 'when parent is nil' do
+ let(:namespace) { build(:group, parent: nil) }
+
+ it 'returns []' do
+ expect(namespace.traversal_ids).to eq []
+ end
+ end
+
context 'when made a child group' do
let!(:namespace) { create(:group) }
let!(:parent_namespace) { create(:group, children: [namespace]) }
@@ -458,6 +632,17 @@ RSpec.describe Namespace, feature_category: :subgroups do
expect(namespace.root_ancestor).to eq new_root
end
end
+
+ context 'within a transaction' do
+ # We would like traversal_ids to be defined within a transaction, but it's not possible yet.
+ # This spec exists to assert that the behavior is known.
+ it 'is not defined yet' do
+ Namespace.transaction do
+ group = create(:group)
+ expect(group.traversal_ids).to be_empty
+ end
+ end
+ end
end
context 'traversal scopes' do
@@ -542,17 +727,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
it { expect(child.traversal_ids).to eq [parent.id, child.id] }
it { expect(parent.sync_events.count).to eq 1 }
it { expect(child.sync_events.count).to eq 1 }
-
- context 'when set_traversal_ids_on_save feature flag is disabled' do
- before do
- stub_feature_flags(set_traversal_ids_on_save: false)
- end
-
- it 'only sets traversal_ids on reload' do
- expect { parent.reload }.to change(parent, :traversal_ids).from([]).to([parent.id])
- expect { child.reload }.to change(child, :traversal_ids).from([]).to([parent.id, child.id])
- end
- end
end
context 'traversal_ids on update' do
@@ -565,18 +739,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
it 'sets the traversal_ids attribute' do
expect { subject }.to change { namespace1.traversal_ids }.from([namespace1.id]).to([namespace2.id, namespace1.id])
end
-
- context 'when set_traversal_ids_on_save feature flag is disabled' do
- before do
- stub_feature_flags(set_traversal_ids_on_save: false)
- end
-
- it 'sets traversal_ids after reload' do
- subject
-
- expect { namespace1.reload }.to change(namespace1, :traversal_ids).from([]).to([namespace2.id, namespace1.id])
- end
- end
end
it 'creates a Namespaces::SyncEvent using triggers' do
@@ -677,24 +839,41 @@ RSpec.describe Namespace, feature_category: :subgroups do
describe '#any_project_has_container_registry_tags?' do
subject { namespace.any_project_has_container_registry_tags? }
- let!(:project_without_registry) { create(:project, namespace: namespace) }
+ let(:project) { create(:project, namespace: namespace) }
+
+ it 'returns true if there is a project with container registry tags' do
+ expect(namespace).to receive(:first_project_with_container_registry_tags).and_return(project)
- context 'without tags' do
- it { is_expected.to be_falsey }
+ expect(subject).to be_truthy
end
- context 'with tags' do
- before do
- repositories = create_list(:container_repository, 3)
- create(:project, namespace: namespace, container_repositories: repositories)
+ it 'returns false if there is no project with container registry tags' do
+ expect(namespace).to receive(:first_project_with_container_registry_tags).and_return(nil)
+
+ expect(subject).to be_falsey
+ end
+ end
+ describe '#first_project_with_container_registry_tags' do
+ let(:container_repository) { create(:container_repository) }
+ let!(:project) { create(:project, namespace: namespace, container_repositories: [container_repository]) }
+
+ context 'when Gitlab API is not supported' do
+ before do
stub_container_registry_config(enabled: true)
+ allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(false)
end
- it 'finds tags' do
+ it 'returns the project' do
stub_container_registry_tags(repository: :any, tags: ['tag'])
- is_expected.to be_truthy
+ expect(namespace.first_project_with_container_registry_tags).to eq(project)
+ end
+
+ it 'returns no project' do
+ stub_container_registry_tags(repository: :any, tags: nil)
+
+ expect(namespace.first_project_with_container_registry_tags).to be_nil
end
it 'does not cause N+1 query in fetching registries' do
@@ -704,29 +883,24 @@ RSpec.describe Namespace, feature_category: :subgroups do
other_repositories = create_list(:container_repository, 2)
create(:project, namespace: namespace, container_repositories: other_repositories)
- expect { namespace.any_project_has_container_registry_tags? }.not_to exceed_query_limit(control_count + 1)
+ expect { namespace.first_project_with_container_registry_tags }.not_to exceed_query_limit(control_count + 1)
end
end
- end
-
- describe '#first_project_with_container_registry_tags' do
- let(:container_repository) { create(:container_repository) }
- let!(:project) { create(:project, namespace: namespace, container_repositories: [container_repository]) }
- before do
- stub_container_registry_config(enabled: true)
- end
-
- it 'returns the project' do
- stub_container_registry_tags(repository: :any, tags: ['tag'])
-
- expect(namespace.first_project_with_container_registry_tags).to eq(project)
- end
+ context 'when Gitlab API is supported' do
+ before do
+ allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(true)
+ stub_container_registry_config(enabled: true, api_url: 'http://container-registry', key: 'spec/fixtures/x509_certificate_pk.key')
+ end
- it 'returns no project' do
- stub_container_registry_tags(repository: :any, tags: nil)
+ it 'calls and returns GitlabApiClient.one_project_with_container_registry_tag' do
+ expect(ContainerRegistry::GitlabApiClient)
+ .to receive(:one_project_with_container_registry_tag)
+ .with(namespace.full_path)
+ .and_return(project)
- expect(namespace.first_project_with_container_registry_tags).to be_nil
+ expect(namespace.first_project_with_container_registry_tags).to eq(project)
+ end
end
end
@@ -755,6 +929,7 @@ RSpec.describe Namespace, feature_category: :subgroups do
with_them do
before do
+ allow(ContainerRegistry::GitlabApiClient).to receive(:one_project_with_container_registry_tag).and_return(nil)
stub_container_registry_config(enabled: true, api_url: 'http://container-registry', key: 'spec/fixtures/x509_certificate_pk.key')
allow(Gitlab).to receive(:com?).and_return(true)
allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(gitlab_api_supported)
@@ -975,43 +1150,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
end
end
- describe '.find_by_pages_host' do
- it 'finds namespace by GitLab Pages host and is case-insensitive' do
- namespace = create(:namespace, name: 'topNAMEspace', path: 'topNAMEspace')
- create(:namespace, name: 'annother_namespace')
- host = "TopNamespace.#{Settings.pages.host.upcase}"
-
- expect(described_class.find_by_pages_host(host)).to eq(namespace)
- end
-
- context 'when there is non-top-level group with searched name' do
- before do
- create(:group, :nested, path: 'pages')
- end
-
- it 'ignores this group' do
- host = "pages.#{Settings.pages.host.upcase}"
-
- expect(described_class.find_by_pages_host(host)).to be_nil
- end
-
- it 'finds right top level group' do
- group = create(:group, path: 'pages')
-
- host = "pages.#{Settings.pages.host.upcase}"
-
- expect(described_class.find_by_pages_host(host)).to eq(group)
- end
- end
-
- it "returns no result if the provided host is not subdomain of the Pages host" do
- create(:namespace, name: 'namespace.io')
- host = "namespace.io"
-
- expect(described_class.find_by_pages_host(host)).to eq(nil)
- end
- end
-
describe '.top_most' do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:group) { create(:group) }
@@ -1037,6 +1175,7 @@ RSpec.describe Namespace, feature_category: :subgroups do
allow(namespace).to receive(:path_was).and_return(namespace.path)
allow(namespace).to receive(:path).and_return('new_path')
+ allow(namespace).to receive(:first_project_with_container_registry_tags).and_return(project)
end
it 'raises an error about not movable project' do
@@ -1447,30 +1586,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
end
end
- describe '#use_traversal_ids_for_root_ancestor?' do
- let_it_be(:namespace, reload: true) { create(:namespace) }
-
- subject { namespace.use_traversal_ids_for_root_ancestor? }
-
- context 'when use_traversal_ids_for_root_ancestor feature flag is true' do
- before do
- stub_feature_flags(use_traversal_ids_for_root_ancestor: true)
- end
-
- it { is_expected.to eq true }
-
- it_behaves_like 'disabled feature flag when traversal_ids is blank'
- end
-
- context 'when use_traversal_ids_for_root_ancestor feature flag is false' do
- before do
- stub_feature_flags(use_traversal_ids_for_root_ancestor: false)
- end
-
- it { is_expected.to eq false }
- end
- end
-
describe '#use_traversal_ids_for_ancestors?' do
let_it_be(:namespace, reload: true) { create(:namespace) }
@@ -1618,14 +1733,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
end
describe '#all_projects' do
- context 'when recursive approach is disabled' do
- before do
- stub_feature_flags(recursive_approach_for_all_projects: false)
- end
-
- include_examples '#all_projects'
- end
-
context 'with use_traversal_ids feature flag enabled' do
before do
stub_feature_flags(use_traversal_ids: true)
@@ -1917,6 +2024,62 @@ RSpec.describe Namespace, feature_category: :subgroups do
expect(very_deep_nested_group.root_ancestor).to eq(root_group)
end
end
+
+ context 'when parent is changed' do
+ let(:group) { create(:group) }
+ let(:new_parent) { create(:group) }
+
+ shared_examples 'updates root_ancestor' do
+ it do
+ expect { subject }.to change { group.root_ancestor }.from(group).to(new_parent)
+ end
+ end
+
+ context 'by object' do
+ subject { group.parent = new_parent }
+
+ include_examples 'updates root_ancestor'
+ end
+
+ context 'by id' do
+ subject { group.parent_id = new_parent.id }
+
+ include_examples 'updates root_ancestor'
+ end
+ end
+
+ context 'within a transaction' do
+ context 'with a persisted parent' do
+ let(:parent) { create(:group) }
+
+ it do
+ Namespace.transaction do
+ group = create(:group, parent: parent)
+ expect(group.root_ancestor).to eq parent
+ end
+ end
+ end
+
+ context 'with a non-persisted parent' do
+ let(:parent) { build(:group) }
+
+ it do
+ Namespace.transaction do
+ group = create(:group, parent: parent)
+ expect(group.root_ancestor).to eq parent
+ end
+ end
+ end
+
+ context 'without a parent' do
+ it do
+ Namespace.transaction do
+ group = create(:group)
+ expect(group.root_ancestor).to eq group
+ end
+ end
+ end
+ end
end
describe '#full_path_before_last_save' do
@@ -2105,34 +2268,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
end
end
- describe '#pages_virtual_domain' do
- let(:project) { create(:project, namespace: namespace) }
- let(:virtual_domain) { namespace.pages_virtual_domain }
-
- before do
- project.mark_pages_as_deployed
- project.update_pages_deployment!(create(:pages_deployment, project: project))
- end
-
- it 'returns the virual domain' do
- expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
- expect(virtual_domain.lookup_paths).not_to be_empty
- expect(virtual_domain.cache_key).to match(/pages_domain_for_namespace_#{namespace.root_ancestor.id}_/)
- end
-
- context 'when :cache_pages_domain_api is disabled' do
- before do
- stub_feature_flags(cache_pages_domain_api: false)
- end
-
- it 'returns the virual domain' do
- expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
- expect(virtual_domain.lookup_paths).not_to be_empty
- expect(virtual_domain.cache_key).to be_nil
- end
- end
- end
-
describe '#any_project_with_pages_deployed?' do
it 'returns true if any project nested under the group has pages deployed' do
parent_1 = create(:group) # Three projects, one with pages
@@ -2505,28 +2640,6 @@ RSpec.describe Namespace, feature_category: :subgroups do
end
end
- describe 'storage_enforcement_date', :freeze_time do
- let_it_be(:namespace) { create(:group) }
-
- before do
- stub_feature_flags(namespace_storage_limit_bypass_date_check: false)
- end
-
- it 'returns correct date' do
- expect(namespace.storage_enforcement_date).to eql(3.months.from_now.to_date)
- end
-
- context 'when :storage_banner_bypass_date_check is enabled' do
- before do
- stub_feature_flags(namespace_storage_limit_bypass_date_check: true)
- end
-
- it 'returns the current date' do
- expect(namespace.storage_enforcement_date).to eq(Date.current)
- end
- end
- end
-
describe 'serialization' do
let(:object) { build(:namespace) }
diff --git a/spec/models/namespaces/randomized_suffix_path_spec.rb b/spec/models/namespaces/randomized_suffix_path_spec.rb
index a2484030f3c..fc5ccd95ce6 100644
--- a/spec/models/namespaces/randomized_suffix_path_spec.rb
+++ b/spec/models/namespaces/randomized_suffix_path_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Namespaces::RandomizedSuffixPath, feature_category: :not_owned do
+RSpec.describe Namespaces::RandomizedSuffixPath, feature_category: :shared do
let(:path) { 'backintime' }
subject(:suffixed_path) { described_class.new(path) }
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 013070f7be5..f722415d428 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Note do
+RSpec.describe Note, feature_category: :team_planning do
include RepoHelpers
describe 'associations' do
@@ -11,6 +11,7 @@ RSpec.describe Note do
it { is_expected.to belong_to(:author).class_name('User') }
it { is_expected.to have_many(:todos) }
+ it { is_expected.to have_one(:note_metadata).inverse_of(:note).class_name('Notes::NoteMetadata') }
it { is_expected.to belong_to(:review).inverse_of(:notes) }
end
@@ -799,20 +800,22 @@ RSpec.describe Note do
describe '#system_note_with_references?' do
it 'falsey for user-generated notes' do
- note = create(:note, system: false)
+ note = build_stubbed(:note, system: false)
expect(note.system_note_with_references?).to be_falsy
end
context 'when the note might contain cross references' do
SystemNoteMetadata.new.cross_reference_types.each do |type|
- let(:note) { create(:note, :system) }
- let!(:metadata) { create(:system_note_metadata, note: note, action: type) }
+ context "with #{type}" do
+ let(:note) { build_stubbed(:note, :system) }
+ let!(:metadata) { build_stubbed(:system_note_metadata, note: note, action: type) }
- it 'delegates to the cross-reference regex' do
- expect(note).to receive(:matches_cross_reference_regex?).and_return(false)
+ it 'delegates to the cross-reference regex' do
+ expect(note).to receive(:matches_cross_reference_regex?).and_return(false)
- note.system_note_with_references?
+ note.system_note_with_references?
+ end
end
end
end
@@ -1100,6 +1103,16 @@ RSpec.describe Note do
end
end
+ describe '#for_work_item?' do
+ it 'returns true for a work item' do
+ expect(build(:note_on_work_item).for_work_item?).to be true
+ end
+
+ it 'returns false for an issue' do
+ expect(build(:note_on_issue).for_work_item?).to be false
+ end
+ end
+
describe '#for_project_snippet?' do
it 'returns true for a project snippet note' do
expect(build(:note_on_project_snippet).for_project_snippet?).to be true
@@ -1656,6 +1669,32 @@ RSpec.describe Note do
end
end
end
+
+ describe '.without_hidden' do
+ subject { described_class.without_hidden }
+
+ context 'when a note with a banned author exists' do
+ let_it_be(:banned_user) { create(:banned_user).user }
+ let_it_be(:banned_note) { create(:note, author: banned_user) }
+
+ context 'when the :hidden_notes feature is disabled' do
+ before do
+ stub_feature_flags(hidden_notes: false)
+ end
+
+ it { is_expected.to include(banned_note, note1) }
+ end
+
+ context 'when the :hidden_notes feature is enabled' do
+ before do
+ stub_feature_flags(hidden_notes: true)
+ end
+
+ it { is_expected.not_to include(banned_note) }
+ it { is_expected.to include(note1) }
+ end
+ end
+ end
end
describe 'banzai_render_context' do
diff --git a/spec/models/notes/note_metadata_spec.rb b/spec/models/notes/note_metadata_spec.rb
new file mode 100644
index 00000000000..d1b35e0cdf9
--- /dev/null
+++ b/spec/models/notes/note_metadata_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Notes::NoteMetadata, feature_category: :team_planning do
+ describe 'associations' do
+ it { is_expected.to belong_to(:note) }
+ end
+
+ describe 'callbacks' do
+ let_it_be(:note) { create(:note) }
+ let_it_be(:email) { "#{'a' * 255}@example.com" }
+
+ context 'with before_save :ensure_email_participant_length' do
+ let(:note_metadata) { create(:note_metadata, note: note, email_participant: email) }
+
+ context 'when email length is > 255' do
+ let(:expected_email) { "#{'a' * 252}..." }
+
+ it 'rewrites the email within max length' do
+ expect(note_metadata.email_participant.length).to eq(255)
+ expect(note.note_metadata.email_participant).to eq(expected_email)
+ end
+ end
+
+ context 'when email is within permissible length' do
+ let(:email) { 'email@example.com' }
+
+ it 'saves the email as-is' do
+ expect(note_metadata.email_participant).to eq(email)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/oauth_access_token_spec.rb b/spec/models/oauth_access_token_spec.rb
index fc53d926dd6..5fa590eab58 100644
--- a/spec/models/oauth_access_token_spec.rb
+++ b/spec/models/oauth_access_token_spec.rb
@@ -59,7 +59,7 @@ RSpec.describe OauthAccessToken do
it 'uses the expires_in value' do
token = OauthAccessToken.new(expires_in: 1.minute)
- expect(token.expires_in).to eq 1.minute
+ expect(token).to be_valid
end
end
@@ -67,7 +67,7 @@ RSpec.describe OauthAccessToken do
it 'uses default value' do
token = OauthAccessToken.new(expires_in: nil)
- expect(token.expires_in).to eq 2.hours
+ expect(token).to be_invalid
end
end
end
diff --git a/spec/models/onboarding/completion_spec.rb b/spec/models/onboarding/completion_spec.rb
index e1fad4255bc..dd7648f7799 100644
--- a/spec/models/onboarding/completion_spec.rb
+++ b/spec/models/onboarding/completion_spec.rb
@@ -2,24 +2,24 @@
require 'spec_helper'
-RSpec.describe Onboarding::Completion do
+RSpec.describe Onboarding::Completion, feature_category: :onboarding do
+ let(:completed_actions) { {} }
+ let(:project) { build(:project, namespace: namespace) }
+ let!(:onboarding_progress) { create(:onboarding_progress, namespace: namespace, **completed_actions) }
+
+ let_it_be(:namespace) { create(:namespace) }
+
describe '#percentage' do
- let(:completed_actions) { {} }
- let!(:onboarding_progress) { create(:onboarding_progress, namespace: namespace, **completed_actions) }
let(:tracked_action_columns) do
- [
- *described_class::ACTION_ISSUE_IDS.keys,
- *described_class::ACTION_PATHS,
- :security_scan_enabled
- ].map { |key| ::Onboarding::Progress.column_name(key) }
+ [*described_class::ACTION_PATHS, :security_scan_enabled].map do |key|
+ ::Onboarding::Progress.column_name(key)
+ end
end
- let_it_be(:namespace) { create(:namespace) }
-
- subject { described_class.new(namespace).percentage }
+ subject(:percentage) { described_class.new(project).percentage }
context 'when no onboarding_progress exists' do
- subject { described_class.new(build(:namespace)).percentage }
+ subject(:percentage) { described_class.new(build(:project)).percentage }
it { is_expected.to eq(0) }
end
@@ -29,30 +29,55 @@ RSpec.describe Onboarding::Completion do
end
context 'when all tracked actions have been completed' do
+ let(:project) { build(:project, :stubbed_commit_count, namespace: namespace) }
+
let(:completed_actions) do
tracked_action_columns.index_with { Time.current }
end
it { is_expected.to eq(100) }
end
+ end
+
+ describe '#completed?' do
+ subject(:completed?) { described_class.new(project).completed?(column) }
+
+ context 'when code_added' do
+ let(:column) { :code_added }
+
+ context 'when commit_count > 1' do
+ let(:project) { build(:project, :stubbed_commit_count, namespace: namespace) }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when branch_count > 1' do
+ let(:project) { build(:project, :stubbed_branch_count, namespace: namespace) }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when empty repository' do
+ let(:project) { build(:project, namespace: namespace) }
+
+ it { is_expected.to eq(false) }
+ end
+ end
- context 'with security_actions_continuous_onboarding experiment' do
- let(:completed_actions) { Hash[tracked_action_columns.first, Time.current] }
+ context 'when secure_dast_run' do
+ let(:column) { :secure_dast_run_at }
+ let(:completed_actions) { { secure_dast_run_at: secure_dast_run_at } }
- context 'when control' do
- before do
- stub_experiments(security_actions_continuous_onboarding: :control)
- end
+ context 'when is completed' do
+ let(:secure_dast_run_at) { Time.current }
- it { is_expected.to eq(11) }
+ it { is_expected.to eq(true) }
end
- context 'when candidate' do
- before do
- stub_experiments(security_actions_continuous_onboarding: :candidate)
- end
+ context 'when is not completed' do
+ let(:secure_dast_run_at) { nil }
- it { is_expected.to eq(9) }
+ it { is_expected.to eq(false) }
end
end
end
diff --git a/spec/models/onboarding/progress_spec.rb b/spec/models/onboarding/progress_spec.rb
index 9d91af2487a..7d169464462 100644
--- a/spec/models/onboarding/progress_spec.rb
+++ b/spec/models/onboarding/progress_spec.rb
@@ -187,7 +187,7 @@ RSpec.describe Onboarding::Progress do
end
context 'for multiple actions' do
- let(:action1) { :security_scan_enabled }
+ let(:action1) { :secure_dast_run }
let(:action2) { :secure_dependency_scanning_run }
let(:actions) { [action1, action2] }
@@ -206,11 +206,11 @@ RSpec.describe Onboarding::Progress do
it 'does not override timestamp', :aggregate_failures do
described_class.register(namespace, [action1])
- expect(described_class.find_by_namespace_id(namespace.id).security_scan_enabled_at).not_to be_nil
+ expect(described_class.find_by_namespace_id(namespace.id).secure_dast_run_at).not_to be_nil
expect(described_class.find_by_namespace_id(namespace.id).secure_dependency_scanning_run_at).to be_nil
expect { described_class.register(namespace, [action1, action2]) }.not_to change {
- described_class.find_by_namespace_id(namespace.id).security_scan_enabled_at
+ described_class.find_by_namespace_id(namespace.id).secure_dast_run_at
}
expect(described_class.find_by_namespace_id(namespace.id).secure_dependency_scanning_run_at).not_to be_nil
end
diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb
index dd1ff95a16d..4a39c5dc234 100644
--- a/spec/models/operations/feature_flag_spec.rb
+++ b/spec/models/operations/feature_flag_spec.rb
@@ -37,8 +37,8 @@ RSpec.describe Operations::FeatureFlag do
end
describe '#to_reference' do
- let(:namespace) { build(:namespace, path: 'sample-namespace') }
- let(:project) { build(:project, name: 'sample-project', namespace: namespace) }
+ let(:namespace) { build(:namespace) }
+ let(:project) { build(:project, namespace: namespace) }
let(:feature_flag) { build(:operations_feature_flag, iid: 1, project: project) }
it 'returns feature flag id' do
@@ -46,7 +46,7 @@ RSpec.describe Operations::FeatureFlag do
end
it 'returns complete path to the feature flag with full: true' do
- expect(feature_flag.to_reference(full: true)).to eq '[feature_flag:sample-namespace/sample-project/1]'
+ expect(feature_flag.to_reference(full: true)).to eq "[feature_flag:#{project.full_path}/1]"
end
end
diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb
new file mode 100644
index 00000000000..e1aac88e640
--- /dev/null
+++ b/spec/models/organization_spec.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Organization, type: :model, feature_category: :cell do
+ let_it_be(:organization) { create(:organization) }
+ let_it_be(:default_organization) { create(:organization, :default) }
+
+ describe 'validations' do
+ subject { create(:organization) }
+
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_uniqueness_of(:name).case_insensitive }
+ it { is_expected.to validate_length_of(:name).is_at_most(255) }
+ end
+
+ context 'when using scopes' do
+ describe '.without_default' do
+ it 'excludes default organization' do
+ expect(described_class.without_default).not_to include(default_organization)
+ end
+
+ it 'includes other organizations organization' do
+ expect(described_class.without_default).to include(organization)
+ end
+ end
+ end
+
+ describe '#id' do
+ context 'when organization is default' do
+ it 'has id 1' do
+ expect(default_organization.id).to eq(1)
+ end
+ end
+
+ context 'when organization is not default' do
+ it 'does not have id 1' do
+ expect(organization.id).not_to eq(1)
+ end
+ end
+ end
+
+ describe '#destroy!' do
+ context 'when trying to delete the default organization' do
+ it 'raises an error' do
+ expect do
+ default_organization.destroy!
+ end.to raise_error(ActiveRecord::RecordNotDestroyed, _('Cannot delete the default organization'))
+ end
+ end
+
+ context 'when trying to delete a non-default organization' do
+ let(:to_be_removed) { create(:organization) }
+
+ it 'does not raise error' do
+ expect { to_be_removed.destroy! }.not_to raise_error
+ end
+ end
+ end
+
+ describe '#destroy' do
+ context 'when trying to delete the default organization' do
+ it 'returns false' do
+ expect(default_organization.destroy).to eq(false)
+ end
+ end
+
+ context 'when trying to delete a non-default organization' do
+ let(:to_be_removed) { create(:organization) }
+
+ it 'returns true' do
+ expect(to_be_removed.destroy).to eq(to_be_removed)
+ end
+ end
+ end
+
+ describe '#default?' do
+ context 'when organization is default' do
+ it 'returns true' do
+ expect(default_organization.default?).to eq(true)
+ end
+ end
+
+ context 'when organization is not default' do
+ it 'returns false' do
+ expect(organization.default?).to eq(false)
+ end
+ end
+ end
+
+ describe '#name' do
+ context 'when organization is default' do
+ it 'returns Default' do
+ expect(default_organization.name).to eq('Default')
+ end
+ end
+ end
+end
diff --git a/spec/models/packages/debian/file_metadatum_spec.rb b/spec/models/packages/debian/file_metadatum_spec.rb
index 1215adfa6a1..e86c0a71c9a 100644
--- a/spec/models/packages/debian/file_metadatum_spec.rb
+++ b/spec/models/packages/debian/file_metadatum_spec.rb
@@ -2,15 +2,15 @@
require 'spec_helper'
-RSpec.describe Packages::Debian::FileMetadatum, type: :model do
- RSpec.shared_context 'Debian file metadatum' do |factory, trait|
- let_it_be_with_reload(:debian_package_file) { create(factory, trait) }
+RSpec.describe Packages::Debian::FileMetadatum, type: :model, feature_category: :package_registry do
+ RSpec.shared_context 'with Debian file metadatum' do |package_file_trait|
+ let_it_be_with_reload(:debian_package_file) { create(:debian_package_file, package_file_trait) }
let(:debian_file_metadatum) { debian_package_file.debian_file_metadatum }
subject { debian_file_metadatum }
end
- RSpec.shared_examples 'Test Debian file metadatum' do |has_component, has_architecture, has_fields, has_outdated|
+ RSpec.shared_examples 'Test Debian file metadatum' do |has_component, has_architecture, has_fields|
describe 'relationships' do
it { is_expected.to belong_to(:package_file) }
end
@@ -51,8 +51,8 @@ RSpec.describe Packages::Debian::FileMetadatum, type: :model do
describe '#fields' do
if has_fields
it { is_expected.to validate_presence_of(:fields) }
- it { is_expected.to allow_value({ 'a': 'b' }).for(:fields) }
- it { is_expected.not_to allow_value({ 'a': { 'b': 'c' } }).for(:fields) }
+ it { is_expected.to allow_value({ a: 'b' }).for(:fields) }
+ it { is_expected.not_to allow_value({ a: { b: 'c' } }).for(:fields) }
else
it { is_expected.to validate_absence_of(:fields) }
end
@@ -69,23 +69,35 @@ RSpec.describe Packages::Debian::FileMetadatum, type: :model do
end
end
end
+
+ describe 'scopes' do
+ describe '.with_file_type' do
+ subject { described_class.with_file_type(package_file_trait) }
+
+ it 'returns the matching file metadatum' do
+ expect(subject).to match_array([debian_file_metadatum])
+ end
+ end
+ end
end
using RSpec::Parameterized::TableSyntax
- where(:factory, :trait, :has_component, :has_architecture, :has_fields) do
- :debian_package_file | :unknown | false | false | false
- :debian_package_file | :source | true | false | false
- :debian_package_file | :dsc | true | false | true
- :debian_package_file | :deb | true | true | true
- :debian_package_file | :udeb | true | true | true
- :debian_package_file | :buildinfo | true | false | true
- :debian_package_file | :changes | false | false | true
+ where(:package_file_trait, :has_component, :has_architecture, :has_fields) do
+ :unknown | false | false | false
+ :source | true | false | false
+ :dsc | true | false | true
+ :deb | true | true | true
+ :udeb | true | true | true
+ :ddeb | true | true | true
+ :buildinfo | true | false | true
+ :changes | false | false | true
end
with_them do
- include_context 'Debian file metadatum', params[:factory], params[:trait] do
- it_behaves_like 'Test Debian file metadatum', params[:has_component], params[:has_architecture], params[:has_fields], params[:has_outdated]
+ include_context 'with Debian file metadatum', params[:package_file_trait] do
+ it_behaves_like 'Test Debian file metadatum',
+ params[:has_component], params[:has_architecture], params[:has_fields]
end
end
end
diff --git a/spec/models/packages/debian/group_distribution_spec.rb b/spec/models/packages/debian/group_distribution_spec.rb
index 90fb0d0e7d8..1af23ad3ac0 100644
--- a/spec/models/packages/debian/group_distribution_spec.rb
+++ b/spec/models/packages/debian/group_distribution_spec.rb
@@ -2,6 +2,9 @@
require 'spec_helper'
-RSpec.describe Packages::Debian::GroupDistribution do
- it_behaves_like 'Debian Distribution', :debian_group_distribution, :group, false
+RSpec.describe Packages::Debian::GroupDistribution, feature_category: :package_registry do
+ include_context 'for Debian Distribution', :debian_group_distribution, false
+
+ it_behaves_like 'Debian Distribution for common behavior'
+ it_behaves_like 'Debian Distribution with group container'
end
diff --git a/spec/models/packages/debian/project_distribution_spec.rb b/spec/models/packages/debian/project_distribution_spec.rb
index 5f4041ad9fe..2b79cdb539f 100644
--- a/spec/models/packages/debian/project_distribution_spec.rb
+++ b/spec/models/packages/debian/project_distribution_spec.rb
@@ -2,6 +2,9 @@
require 'spec_helper'
-RSpec.describe Packages::Debian::ProjectDistribution do
- it_behaves_like 'Debian Distribution', :debian_project_distribution, :project, true
+RSpec.describe Packages::Debian::ProjectDistribution, feature_category: :package_registry do
+ include_context 'for Debian Distribution', :debian_project_distribution, true
+
+ it_behaves_like 'Debian Distribution for common behavior'
+ it_behaves_like 'Debian Distribution with project container'
end
diff --git a/spec/models/packages/dependency_spec.rb b/spec/models/packages/dependency_spec.rb
index 1575dec98c9..80ec7f77fda 100644
--- a/spec/models/packages/dependency_spec.rb
+++ b/spec/models/packages/dependency_spec.rb
@@ -1,7 +1,11 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Dependency, type: :model do
+RSpec.describe Packages::Dependency, type: :model, feature_category: :package_registry do
+ describe 'included modules' do
+ it { is_expected.to include_module(EachBatch) }
+ end
+
describe 'relationships' do
it { is_expected.to have_many(:dependency_links) }
end
@@ -110,6 +114,19 @@ RSpec.describe Packages::Dependency, type: :model do
end
end
+ describe '.orphaned' do
+ let_it_be(:orphaned_dependencies) { create_list(:packages_dependency, 2) }
+ let_it_be(:linked_dependency) do
+ create(:packages_dependency).tap do |dependency|
+ create(:packages_dependency_link, dependency: dependency)
+ end
+ end
+
+ it 'returns orphaned dependency records' do
+ expect(described_class.orphaned).to contain_exactly(*orphaned_dependencies)
+ end
+ end
+
def build_names_and_version_patterns(*package_dependencies)
result = Hash.new { |h, dependency| h[dependency.name] = dependency.version_pattern }
package_dependencies.each { |dependency| result[dependency] }
diff --git a/spec/models/packages/event_spec.rb b/spec/models/packages/event_spec.rb
new file mode 100644
index 00000000000..58c1c1e6e92
--- /dev/null
+++ b/spec/models/packages/event_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Event, feature_category: :package_registry do
+ let(:event_type) { :push_package }
+ let(:event_scope) { :npm }
+ let(:originator_type) { :deploy_token }
+
+ shared_examples 'handle forbidden event type' do |result: []|
+ let(:event_type) { :search }
+
+ it { is_expected.to eq(result) }
+ end
+
+ describe '.event_allowed?' do
+ subject { described_class.event_allowed?(event_type) }
+
+ it { is_expected.to eq(true) }
+
+ it_behaves_like 'handle forbidden event type', result: false
+ end
+
+ describe '.unique_counters_for' do
+ subject { described_class.unique_counters_for(event_scope, event_type, originator_type) }
+
+ it { is_expected.to contain_exactly('i_package_npm_deploy_token') }
+
+ it_behaves_like 'handle forbidden event type'
+
+ context 'when an originator type is quest' do
+ let(:originator_type) { :guest }
+
+ it { is_expected.to eq([]) }
+ end
+ end
+
+ describe '.counters_for' do
+ subject { described_class.counters_for(event_scope, event_type, originator_type) }
+
+ it do
+ is_expected.to contain_exactly(
+ 'i_package_push_package',
+ 'i_package_push_package_by_deploy_token',
+ 'i_package_npm_push_package'
+ )
+ end
+
+ it_behaves_like 'handle forbidden event type'
+ end
+end
diff --git a/spec/models/packages/npm/metadata_cache_spec.rb b/spec/models/packages/npm/metadata_cache_spec.rb
new file mode 100644
index 00000000000..5e7a710baf8
--- /dev/null
+++ b/spec/models/packages/npm/metadata_cache_spec.rb
@@ -0,0 +1,150 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Npm::MetadataCache, type: :model, feature_category: :package_registry do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:package_name) { '@root/test' }
+
+ it { is_expected.to be_a FileStoreMounter }
+
+ describe 'relationships' do
+ it { is_expected.to belong_to(:project).inverse_of(:npm_metadata_caches) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:file) }
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:size) }
+
+ describe '#package_name' do
+ let_it_be(:npm_metadata_cache) { create(:npm_metadata_cache, package_name: package_name, project: project) }
+
+ it { is_expected.to validate_presence_of(:package_name) }
+
+ describe 'uniqueness' do
+ it 'ensures the package name is unique within a given project' do
+ expect do
+ create(:npm_metadata_cache, package_name: package_name, project: project)
+ end.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Package name has already been taken')
+ end
+
+ it 'allows duplicate file names in different projects' do
+ expect do
+ create(:npm_metadata_cache, package_name: package_name, project: create(:project))
+ end.not_to raise_error
+ end
+ end
+
+ describe 'format' do
+ it { is_expected.to allow_value('my.app-11.07.2018').for(:package_name) }
+ it { is_expected.to allow_value('@group-1/package').for(:package_name) }
+ it { is_expected.to allow_value('@any-scope/package').for(:package_name) }
+ it { is_expected.to allow_value('unscoped-package').for(:package_name) }
+
+ it { is_expected.not_to allow_value('my(dom$$$ain)com.my-app').for(:package_name) }
+ it { is_expected.not_to allow_value('@inv@lid-scope/package').for(:package_name) }
+ it { is_expected.not_to allow_value('@scope/../../package').for(:package_name) }
+ it { is_expected.not_to allow_value('@scope%2e%2e%fpackage').for(:package_name) }
+ it { is_expected.not_to allow_value('@scope/sub/package').for(:package_name) }
+ end
+ end
+ end
+
+ describe '.find_or_build' do
+ subject { described_class.find_or_build(package_name: package_name, project_id: project.id) }
+
+ context 'when a metadata cache exists' do
+ let_it_be(:npm_metadata_cache) { create(:npm_metadata_cache, package_name: package_name, project: project) }
+
+ it 'finds an existing metadata cache' do
+ expect(subject).to eq(npm_metadata_cache)
+ end
+ end
+
+ context 'when a metadata cache not found' do
+ let(:package_name) { 'not_existing' }
+
+ it 'builds a new instance', :aggregate_failures do
+ expect(subject).not_to be_persisted
+ expect(subject.package_name).to eq(package_name)
+ expect(subject.project_id).to eq(project.id)
+ end
+ end
+ end
+
+ describe 'save callbacks' do
+ describe 'object_storage_key' do
+ let(:object_storage_key) do
+ Gitlab::HashedPath.new(
+ 'packages', 'metadata_caches', 'npm', OpenSSL::Digest::SHA256.hexdigest(package_name),
+ root_hash: project.id
+ )
+ end
+
+ before do
+ allow(Gitlab::HashedPath).to receive(:new).and_return(object_storage_key)
+ end
+
+ context 'when the record is created' do
+ let(:npm_metadata_cache) { build(:npm_metadata_cache, package_name: package_name, project: project) }
+
+ it 'sets object_storage_key' do
+ npm_metadata_cache.save!
+
+ expect(npm_metadata_cache.object_storage_key).to eq(object_storage_key.to_s)
+ end
+
+ context 'when using `update!`' do
+ let(:metadata_content) { {}.to_json }
+
+ it 'sets object_storage_key' do
+ npm_metadata_cache.update!(
+ file: CarrierWaveStringFile.new(metadata_content),
+ size: metadata_content.bytesize
+ )
+
+ expect(npm_metadata_cache.object_storage_key).to eq(object_storage_key.to_s)
+ end
+ end
+ end
+
+ context 'when the record is updated' do
+ let_it_be(:npm_metadata_cache) { create(:npm_metadata_cache, package_name: package_name, project: project) }
+
+ let(:existing_object_storage_key) { npm_metadata_cache.object_storage_key }
+ let(:new_package_name) { 'updated_package_name' }
+
+ it 'does not update object_storage_key' do
+ existing_object_storage_key = npm_metadata_cache.object_storage_key
+
+ npm_metadata_cache.update!(package_name: new_package_name)
+
+ expect(npm_metadata_cache.object_storage_key).to eq(existing_object_storage_key)
+ end
+ end
+ end
+ end
+
+ describe 'readonly attributes' do
+ describe 'object_storage_key' do
+ let_it_be(:npm_metadata_cache) { create(:npm_metadata_cache) }
+
+ it 'sets object_storage_key' do
+ expect(npm_metadata_cache.object_storage_key).to be_present
+ end
+
+ context 'when the record is persisted' do
+ let(:new_object_storage_key) { 'object/storage/updated_key' }
+
+ it 'does not re-set object_storage_key' do
+ npm_metadata_cache.object_storage_key = new_object_storage_key
+
+ npm_metadata_cache.save!
+
+ expect(npm_metadata_cache.object_storage_key).not_to eq(new_object_storage_key)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/packages/npm/metadatum_spec.rb b/spec/models/packages/npm/metadatum_spec.rb
index ff8cce5310e..92daddded7e 100644
--- a/spec/models/packages/npm/metadatum_spec.rb
+++ b/spec/models/packages/npm/metadatum_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Npm::Metadatum, type: :model do
+RSpec.describe Packages::Npm::Metadatum, type: :model, feature_category: :package_registry do
describe 'relationships' do
it { is_expected.to belong_to(:package).inverse_of(:npm_metadatum) }
end
@@ -47,4 +47,16 @@ RSpec.describe Packages::Npm::Metadatum, type: :model do
end
end
end
+
+ describe 'scopes' do
+ describe '.package_id_in' do
+ let_it_be(:package) { create(:npm_package) }
+ let_it_be(:metadatum_1) { create(:npm_metadatum, package: package) }
+ let_it_be(:metadatum_2) { create(:npm_metadatum) }
+
+ it 'returns metadatums with the given package ids' do
+ expect(described_class.package_id_in([package.id])).to contain_exactly(metadatum_1)
+ end
+ end
+ end
end
diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb
index 9b341034aaa..c9db1efc64a 100644
--- a/spec/models/packages/package_file_spec.rb
+++ b/spec/models/packages/package_file_spec.rb
@@ -153,6 +153,7 @@ RSpec.describe Packages::PackageFile, type: :model do
let_it_be(:debian_changes) { debian_package.package_files.last }
let_it_be(:debian_deb) { create(:debian_package_file, package: debian_package) }
let_it_be(:debian_udeb) { create(:debian_package_file, :udeb, package: debian_package) }
+ let_it_be(:debian_ddeb) { create(:debian_package_file, :ddeb, package: debian_package) }
let_it_be(:debian_contrib) do
create(:debian_package_file, package: debian_package).tap do |pf|
@@ -177,6 +178,17 @@ RSpec.describe Packages::PackageFile, type: :model do
describe '#with_debian_architecture_name' do
it { expect(described_class.with_debian_architecture_name('mipsel')).to contain_exactly(debian_mipsel) }
end
+
+ describe '#with_debian_unknown_since' do
+ let_it_be(:incoming) { create(:debian_incoming, project: project) }
+
+ before do
+ incoming.package_files.first.debian_file_metadatum.update! updated_at: 1.day.ago
+ incoming.package_files.second.update! updated_at: 1.day.ago, status: :error
+ end
+
+ it { expect(described_class.with_debian_unknown_since(1.hour.ago)).to contain_exactly(incoming.package_files.first) }
+ end
end
describe '.for_helm_with_channel' do
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index 992cc5c4354..e79459e0c7c 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -682,24 +682,20 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
end
end
- describe "#unique_debian_package_name" do
+ describe "uniqueness for package type debian" do
let!(:package) { create(:debian_package) }
- it "will allow a Debian package with same project, name and version, but different distribution" do
- new_package = build(:debian_package, project: package.project, name: package.name, version: package.version)
- expect(new_package).to be_valid
- end
-
it "will not allow a Debian package with same project, name, version and distribution" do
new_package = build(:debian_package, project: package.project, name: package.name, version: package.version)
new_package.debian_publication.distribution = package.debian_publication.distribution
expect(new_package).not_to be_valid
- expect(new_package.errors.to_a).to include('Debian package already exists in Distribution')
+ expect(new_package.errors.to_a).to include('Name has already been taken')
end
- it "will allow a Debian package with same project, name, version, but no distribution" do
+ it "will not allow a Debian package with same project, name, version, but no distribution" do
new_package = build(:debian_package, project: package.project, name: package.name, version: package.version, published_in: nil)
- expect(new_package).to be_valid
+ expect(new_package).not_to be_valid
+ expect(new_package.errors.to_a).to include('Name has already been taken')
end
context 'with pending_destruction package' do
@@ -713,7 +709,7 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
end
end
- Packages::Package.package_types.keys.without('conan', 'debian').each do |pt|
+ Packages::Package.package_types.keys.without('conan').each do |pt|
context "project id, name, version and package type uniqueness for package type #{pt}" do
let(:package) { create("#{pt}_package") }
@@ -722,6 +718,15 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
expect(new_package).not_to be_valid
expect(new_package.errors.to_a).to include("Name has already been taken")
end
+
+ context 'with pending_destruction package' do
+ let!(:package) { create("#{pt}_package", :pending_destruction) }
+
+ it "will allow a #{pt} package with same project, name, version and package_type" do
+ new_package = build("#{pt}_package", project: package.project, name: package.name, version: package.version)
+ expect(new_package).to be_valid
+ end
+ end
end
end
end
@@ -842,6 +847,14 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
is_expected.to match_array([package])
end
end
+
+ describe '.preload_conan_metadatum' do
+ subject { described_class.preload_conan_metadatum }
+
+ it 'loads conan metadatum' do
+ expect(subject.first.association(:conan_metadatum)).to be_loaded
+ end
+ end
end
describe '.without_nuget_temporary_name' do
@@ -1235,8 +1248,18 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
let_it_be(:first_build_info) { create(:package_build_info, :with_pipeline, package: package) }
let_it_be(:second_build_info) { create(:package_build_info, :with_pipeline, package: package) }
- it 'returns the first build info' do
- expect(package.original_build_info).to eq(first_build_info)
+ it 'returns the last build info' do
+ expect(package.original_build_info).to eq(second_build_info)
+ end
+
+ context 'with packages_display_last_pipeline disabled' do
+ before do
+ stub_feature_flags(packages_display_last_pipeline: false)
+ end
+
+ it 'returns the first build info' do
+ expect(package.original_build_info).to eq(first_build_info)
+ end
end
end
end
@@ -1402,4 +1425,36 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
.to change(package, :last_downloaded_at).from(nil).to(instance_of(ActiveSupport::TimeWithZone))
end
end
+
+ describe "#publish_creation_event" do
+ let_it_be(:project) { create(:project) }
+
+ let(:version) { '-' }
+ let(:package_type) { :generic }
+
+ subject { described_class.create!(project: project, name: 'incoming', version: version, package_type: package_type) }
+
+ context 'when package is generic' do
+ it 'publishes an event' do
+ expect { subject }
+ .to publish_event(::Packages::PackageCreatedEvent)
+ .with({
+ project_id: project.id,
+ id: kind_of(Numeric),
+ name: "incoming",
+ version: "-",
+ package_type: 'generic'
+ })
+ end
+ end
+
+ context 'when package is not generic' do
+ let(:package_type) { :debian }
+ let(:version) { 1 }
+
+ it 'does not create event' do
+ expect { subject }.not_to publish_event(::Packages::PackageCreatedEvent)
+ end
+ end
+ end
end
diff --git a/spec/models/pages/lookup_path_spec.rb b/spec/models/pages/lookup_path_spec.rb
index ef79ba28d5d..88fd1bd9e56 100644
--- a/spec/models/pages/lookup_path_spec.rb
+++ b/spec/models/pages/lookup_path_spec.rb
@@ -61,15 +61,13 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do
it 'uses deployment from object storage' do
freeze_time do
- expect(source).to(
- eq({
- type: 'zip',
- path: deployment.file.url(expire_at: 1.day.from_now),
- global_id: "gid://gitlab/PagesDeployment/#{deployment.id}",
- sha256: deployment.file_sha256,
- file_size: deployment.size,
- file_count: deployment.file_count
- })
+ expect(source).to eq(
+ type: 'zip',
+ path: deployment.file.url(expire_at: 1.day.from_now),
+ global_id: "gid://gitlab/PagesDeployment/#{deployment.id}",
+ sha256: deployment.file_sha256,
+ file_size: deployment.size,
+ file_count: deployment.file_count
)
end
end
@@ -87,15 +85,13 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do
it 'uses file protocol' do
freeze_time do
- expect(source).to(
- eq({
- type: 'zip',
- path: 'file://' + deployment.file.path,
- global_id: "gid://gitlab/PagesDeployment/#{deployment.id}",
- sha256: deployment.file_sha256,
- file_size: deployment.size,
- file_count: deployment.file_count
- })
+ expect(source).to eq(
+ type: 'zip',
+ path: "file://#{deployment.file.path}",
+ global_id: "gid://gitlab/PagesDeployment/#{deployment.id}",
+ sha256: deployment.file_sha256,
+ file_size: deployment.size,
+ file_count: deployment.file_count
)
end
end
@@ -108,15 +104,13 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do
it 'uses deployment from object storage' do
freeze_time do
- expect(source).to(
- eq({
- type: 'zip',
- path: deployment.file.url(expire_at: 1.day.from_now),
- global_id: "gid://gitlab/PagesDeployment/#{deployment.id}",
- sha256: deployment.file_sha256,
- file_size: deployment.size,
- file_count: deployment.file_count
- })
+ expect(source).to eq(
+ type: 'zip',
+ path: deployment.file.url(expire_at: 1.day.from_now),
+ global_id: "gid://gitlab/PagesDeployment/#{deployment.id}",
+ sha256: deployment.file_sha256,
+ file_size: deployment.size,
+ file_count: deployment.file_count
)
end
end
@@ -143,4 +137,48 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do
expect(lookup_path.prefix).to eq('/myproject/')
end
end
+
+ describe '#unique_host' do
+ let(:project) { build(:project) }
+
+ context 'when unique domain is disabled' do
+ it 'returns nil' do
+ project.project_setting.pages_unique_domain_enabled = false
+
+ expect(lookup_path.unique_host).to be_nil
+ end
+ end
+
+ context 'when unique domain is enabled' do
+ it 'returns the project unique domain' do
+ project.project_setting.pages_unique_domain_enabled = true
+ project.project_setting.pages_unique_domain = 'unique-domain'
+
+ expect(lookup_path.unique_host).to eq('unique-domain.example.com')
+ end
+ end
+ end
+
+ describe '#root_directory' do
+ subject(:lookup_path) { described_class.new(project) }
+
+ context 'when there is no deployment' do
+ it 'returns nil' do
+ expect(lookup_path.root_directory).to be_nil
+ end
+ end
+
+ context 'when there is a deployment' do
+ let(:deployment) { create(:pages_deployment, project: project, root_directory: 'foo') }
+
+ before do
+ project.mark_pages_as_deployed
+ project.pages_metadatum.update!(pages_deployment: deployment)
+ end
+
+ it 'returns the deployment\'s root_directory' do
+ expect(lookup_path.root_directory).to eq('foo')
+ end
+ end
+ end
end
diff --git a/spec/models/pages_deployment_spec.rb b/spec/models/pages_deployment_spec.rb
index 268c5006a88..767db511d85 100644
--- a/spec/models/pages_deployment_spec.rb
+++ b/spec/models/pages_deployment_spec.rb
@@ -59,6 +59,66 @@ RSpec.describe PagesDeployment, feature_category: :pages do
end
end
+ context 'when uploading the file' do
+ before do
+ stub_pages_object_storage(::Pages::DeploymentUploader)
+ end
+
+ describe '#store_after_commit?' do
+ context 'when feature flag pages_deploy_upload_file_outside_transaction is disabled' do
+ it 'returns false' do
+ Feature.disable(:pages_deploy_upload_file_outside_transaction)
+
+ deployment = create(:pages_deployment, project: project)
+ expect(deployment.store_after_commit?).to eq(false)
+ end
+ end
+
+ context 'when feature flag pages_deploy_upload_file_outside_transaction is enabled' do
+ it 'returns true' do
+ deployment = create(:pages_deployment, project: project)
+ expect(deployment.store_after_commit?).to eq(true)
+ end
+ end
+ end
+
+ context 'when feature flag pages_deploy_upload_file_outside_transaction is disabled' do
+ before do
+ Feature.disable(:pages_deploy_upload_file_outside_transaction)
+ end
+
+ it 'stores the file within the transaction' do
+ expect_next_instance_of(PagesDeployment) do |deployment|
+ expect(deployment).not_to receive(:store_file_now!)
+ end
+
+ create(:pages_deployment, project: project)
+ end
+ end
+
+ context 'when feature flag pages_deploy_upload_file_outside_transaction is enabled' do
+ before do
+ Feature.enable(:pages_deploy_upload_file_outside_transaction)
+ end
+
+ it 'stores the file outsize of the transaction' do
+ expect_next_instance_of(PagesDeployment) do |deployment|
+ expect(deployment).to receive(:store_file_now!)
+ end
+
+ create(:pages_deployment, project: project)
+ end
+
+ it 'does nothing when the file did not change' do
+ deployment = create(:pages_deployment, project: project)
+
+ expect(deployment).not_to receive(:store_file_now!)
+
+ deployment.touch
+ end
+ end
+ end
+
describe '#migrated?' do
it 'returns false for normal deployment' do
deployment = create(:pages_deployment)
diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb
index f054fde78e7..b218d4dce09 100644
--- a/spec/models/pages_domain_spec.rb
+++ b/spec/models/pages_domain_spec.rb
@@ -9,7 +9,6 @@ RSpec.describe PagesDomain do
describe 'associations' do
it { is_expected.to belong_to(:project) }
- it { is_expected.to have_many(:serverless_domain_clusters) }
end
describe '.for_project' do
@@ -546,44 +545,6 @@ RSpec.describe PagesDomain do
end
end
- describe '#pages_virtual_domain' do
- let(:project) { create(:project) }
- let(:pages_domain) { create(:pages_domain, project: project) }
-
- context 'when there are no pages deployed for the project' do
- it 'returns nil' do
- expect(pages_domain.pages_virtual_domain).to be_nil
- end
- end
-
- context 'when there are pages deployed for the project' do
- let(:virtual_domain) { pages_domain.pages_virtual_domain }
-
- before do
- project.mark_pages_as_deployed
- project.update_pages_deployment!(create(:pages_deployment, project: project))
- end
-
- it 'returns the virual domain when there are pages deployed for the project' do
- expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
- expect(virtual_domain.lookup_paths).not_to be_empty
- expect(virtual_domain.cache_key).to match(/pages_domain_for_domain_#{pages_domain.id}_/)
- end
-
- context 'when :cache_pages_domain_api is disabled' do
- before do
- stub_feature_flags(cache_pages_domain_api: false)
- end
-
- it 'returns the virual domain when there are pages deployed for the project' do
- expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain)
- expect(virtual_domain.lookup_paths).not_to be_empty
- expect(virtual_domain.cache_key).to be_nil
- end
- end
- end
- end
-
describe '#validate_custom_domain_count_per_project' do
let_it_be(:project) { create(:project) }
diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb
index 2320ff669d0..bd6a7c156c4 100644
--- a/spec/models/personal_access_token_spec.rb
+++ b/spec/models/personal_access_token_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PersonalAccessToken, feature_category: :authentication_and_authorization do
+RSpec.describe PersonalAccessToken, feature_category: :system_access do
subject { described_class }
describe '.build' do
@@ -267,6 +267,41 @@ RSpec.describe PersonalAccessToken, feature_category: :authentication_and_author
expect(personal_access_token).not_to be_valid
expect(personal_access_token.errors[:scopes].first).to eq "can only contain available scopes"
end
+
+ context 'validates expires_at' do
+ let(:max_expiration_date) { described_class::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now }
+
+ context 'when default_pat_expiration feature flag is true' do
+ context 'when expires_in is less than MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS days' do
+ it 'is valid' do
+ personal_access_token.expires_at = max_expiration_date - 1.day
+
+ expect(personal_access_token).to be_valid
+ end
+ end
+
+ context 'when expires_in is more than MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS days' do
+ it 'is invalid' do
+ personal_access_token.expires_at = max_expiration_date + 1.day
+
+ expect(personal_access_token).not_to be_valid
+ expect(personal_access_token.errors[:expires_at].first).to eq('must expire in 365 days')
+ end
+ end
+ end
+
+ context 'when default_pat_expiration feature flag is false' do
+ before do
+ stub_feature_flags(default_pat_expiration: false)
+ end
+
+ it 'allows any expires_at value' do
+ personal_access_token.expires_at = max_expiration_date + 1.day
+
+ expect(personal_access_token).to be_valid
+ end
+ end
+ end
end
describe 'scopes' do
@@ -289,7 +324,7 @@ RSpec.describe PersonalAccessToken, feature_category: :authentication_and_author
let_it_be(:revoked_token) { create(:personal_access_token, revoked: true) }
let_it_be(:valid_token_and_notified) { create(:personal_access_token, expires_at: 2.days.from_now, expire_notification_delivered: true) }
let_it_be(:valid_token) { create(:personal_access_token, expires_at: 2.days.from_now) }
- let_it_be(:long_expiry_token) { create(:personal_access_token, expires_at: '999999-12-31'.to_date) }
+ let_it_be(:long_expiry_token) { create(:personal_access_token, expires_at: described_class::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now) }
context 'in one day' do
it "doesn't have any tokens" do
@@ -405,4 +440,58 @@ RSpec.describe PersonalAccessToken, feature_category: :authentication_and_author
end
end
end
+
+ describe 'token format' do
+ let(:personal_access_token) { described_class.new }
+
+ it 'generates a token' do
+ expect { personal_access_token.ensure_token }
+ .to change { personal_access_token.token }.from(nil).to(a_string_starting_with(described_class.token_prefix))
+ end
+
+ context 'when there is an existing token' do
+ let(:token) { 'an_existing_secret_token' }
+
+ before do
+ personal_access_token.set_token(token)
+ end
+
+ it 'does not change the existing token' do
+ expect { personal_access_token.ensure_token }
+ .not_to change { personal_access_token.token }.from(token)
+ end
+ end
+ end
+
+ describe '#expires_at=' do
+ let(:personal_access_token) { described_class.new }
+
+ context 'when default_pat_expiration feature flag is true' do
+ context 'expires_at set to empty value' do
+ [nil, ""].each do |expires_in_value|
+ it 'defaults to PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS' do
+ personal_access_token.expires_at = expires_in_value
+
+ freeze_time do
+ expect(personal_access_token.expires_at).to eq(
+ PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now.to_date
+ )
+ end
+ end
+ end
+ end
+ end
+
+ context 'when default_pat_expiration feature flag is false' do
+ before do
+ stub_feature_flags(default_pat_expiration: false)
+ end
+
+ it 'does not set a default' do
+ personal_access_token.expires_at = nil
+
+ expect(personal_access_token.expires_at).to eq(nil)
+ end
+ end
+ end
end
diff --git a/spec/models/plan_limits_spec.rb b/spec/models/plan_limits_spec.rb
index 3705cab7ef5..962bb21d761 100644
--- a/spec/models/plan_limits_spec.rb
+++ b/spec/models/plan_limits_spec.rb
@@ -206,6 +206,8 @@ RSpec.describe PlanLimits do
]
end
+ # Remove ci_active_pipelines when db column is removed
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/408141
let(:columns_with_zero) do
%w[
ci_active_pipelines
@@ -221,6 +223,7 @@ RSpec.describe PlanLimits do
security_policy_scan_execution_schedules
enforcement_limit
notification_limit
+ project_access_token_limit
] + disabled_max_artifact_size_columns
end
diff --git a/spec/models/preloaders/labels_preloader_spec.rb b/spec/models/preloaders/labels_preloader_spec.rb
index 07f148a0a6c..3d2a5edc8f0 100644
--- a/spec/models/preloaders/labels_preloader_spec.rb
+++ b/spec/models/preloaders/labels_preloader_spec.rb
@@ -18,14 +18,24 @@ RSpec.describe Preloaders::LabelsPreloader do
context 'project labels' do
let_it_be(:projects) { create_list(:project, 3, :public, :repository) }
- let_it_be(:labels) { projects.each { |p| create(:label, project: p) } }
+ let_it_be(:labels) { projects.map { |p| create(:label, project: p) } }
it_behaves_like 'an efficient database query'
+
+ it 'preloads the max access level', :request_store do
+ labels_with_preloaded_data
+
+ query_count = ActiveRecord::QueryRecorder.new do
+ projects.first.team.max_member_access_for_user_ids([user.id])
+ end.count
+
+ expect(query_count).to eq(0)
+ end
end
context 'group labels' do
let_it_be(:groups) { create_list(:group, 3) }
- let_it_be(:labels) { groups.each { |g| create(:group_label, group: g) } }
+ let_it_be(:labels) { groups.map { |g| create(:group_label, group: g) } }
it_behaves_like 'an efficient database query'
end
diff --git a/spec/models/preloaders/runner_manager_policy_preloader_spec.rb b/spec/models/preloaders/runner_manager_policy_preloader_spec.rb
new file mode 100644
index 00000000000..1977e2c5787
--- /dev/null
+++ b/spec/models/preloaders/runner_manager_policy_preloader_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Preloaders::RunnerManagerPolicyPreloader, feature_category: :runner_fleet do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:runner1) { create(:ci_runner) }
+ let_it_be(:runner2) { create(:ci_runner) }
+ let_it_be(:runner_manager1) { create(:ci_runner_machine, runner: runner1) }
+ let_it_be(:runner_manager2) { create(:ci_runner_machine, runner: runner2) }
+
+ let(:base_runner_managers) do
+ Project.where(id: [runner_manager1, runner_manager2])
+ end
+
+ it 'avoids N+1 queries when authorizing a list of runner managers', :request_store do
+ preload_runner_managers_for_policy(user)
+ control = ActiveRecord::QueryRecorder.new { authorize_all_runner_managers(user) }
+
+ new_runner1 = create(:ci_runner)
+ new_runner2 = create(:ci_runner)
+ new_runner_manager1 = create(:ci_runner_machine, runner: new_runner1)
+ new_runner_manager2 = create(:ci_runner_machine, runner: new_runner2)
+
+ pristine_runner_managers = Project.where(id: base_runner_managers + [new_runner_manager1, new_runner_manager2])
+
+ preload_runner_managers_for_policy(user, pristine_runner_managers)
+ expect { authorize_all_runner_managers(user, pristine_runner_managers) }.not_to exceed_query_limit(control)
+ end
+
+ def authorize_all_runner_managers(current_user, runner_manager_list = base_runner_managers)
+ runner_manager_list.each { |runner_manager| current_user.can?(:read_runner_manager, runner_manager) }
+ end
+
+ def preload_runner_managers_for_policy(current_user, runner_manager_list = base_runner_managers)
+ described_class.new(runner_manager_list, current_user).execute
+ 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
index 7d04817b621..3fba2ac003b 100644
--- 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
@@ -66,22 +66,6 @@ RSpec.describe Preloaders::UserMaxAccessLevelInGroupsPreloader do
create(:group_group_link, :guest, shared_with_group: group1, shared_group: group4)
end
- context 'when `include_memberships_from_group_shares_in_preloader` feature flag is disabled' do
- before do
- stub_feature_flags(include_memberships_from_group_shares_in_preloader: false)
- end
-
- it 'sets access_level to `NO_ACCESS` in cache for groups arising from group shares' do
- described_class.new(groups, user).execute
-
- groups.each do |group|
- cached_access_level = group.max_member_access_for_user(user)
-
- expect(cached_access_level).to eq(Gitlab::Access::NO_ACCESS)
- end
- end
- end
-
it 'sets the right access level in cache for groups arising from group shares' do
described_class.new(groups, user).execute
diff --git a/spec/models/preloaders/users_max_access_level_by_project_preloader_spec.rb b/spec/models/preloaders/users_max_access_level_by_project_preloader_spec.rb
new file mode 100644
index 00000000000..f5bc0c8c2f8
--- /dev/null
+++ b/spec/models/preloaders/users_max_access_level_by_project_preloader_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Preloaders::UsersMaxAccessLevelByProjectPreloader, feature_category: :projects do
+ let_it_be(:user_1) { create(:user) }
+ let_it_be(:user_2) { create(:user) }
+ let_it_be(:user_with_no_access) { create(:user) } # ensures we correctly cache NO_ACCESS
+
+ let_it_be(:project_1) { create(:project) }
+ let_it_be(:project_2) { create(:project) }
+ let_it_be(:project_3) { create(:project) }
+
+ before do
+ project_1.add_developer(user_1)
+ project_1.add_developer(user_2)
+
+ project_2.add_developer(user_1)
+ project_2.add_developer(user_2)
+
+ project_3.add_developer(user_1)
+ project_3.add_developer(user_2)
+ end
+
+ describe '#execute', :request_store do
+ let(:project_users) do
+ {
+ project_1 => [user_1, user_with_no_access],
+ project_2 => user_2
+ }
+ end
+
+ it 'avoids N+1 queries' do
+ control_input = project_users
+ control = ActiveRecord::QueryRecorder.new do
+ described_class.new(project_users: control_input).execute
+ end
+
+ sample_input = control_input.merge(project_3 => user_2)
+ sample = ActiveRecord::QueryRecorder.new do
+ described_class.new(project_users: sample_input).execute
+ end
+
+ expect(sample).not_to exceed_query_limit(control)
+ end
+
+ it 'preloads the max access level used by project policies' do
+ described_class.new(project_users: project_users).execute
+
+ policy_queries = ActiveRecord::QueryRecorder.new do
+ project_users.each do |project, users|
+ Array.wrap(users).each do |user|
+ user.can?(:read_project, project)
+ end
+ end
+ end
+
+ expect(policy_queries).not_to exceed_query_limit(0)
+ end
+ end
+end
diff --git a/spec/models/preloaders/users_max_access_level_in_projects_preloader_spec.rb b/spec/models/preloaders/users_max_access_level_in_projects_preloader_spec.rb
deleted file mode 100644
index 7ecb6bb9861..00000000000
--- a/spec/models/preloaders/users_max_access_level_in_projects_preloader_spec.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-RSpec.describe Preloaders::UsersMaxAccessLevelInProjectsPreloader do
- let_it_be(:user1) { create(:user) }
- let_it_be(:user2) { create(:user) }
-
- let_it_be(:project_1) { create(:project) }
- let_it_be(:project_2) { create(:project) }
- let_it_be(:project_3) { create(:project) }
-
- let(:projects) { [project_1, project_2, project_3] }
- let(:users) { [user1, user2] }
-
- before do
- project_1.add_developer(user1)
- project_1.add_developer(user2)
-
- project_2.add_developer(user1)
- project_2.add_developer(user2)
-
- project_3.add_developer(user1)
- project_3.add_developer(user2)
- end
-
- context 'preload maximum access level to avoid querying project_authorizations', :request_store do
- it 'avoids N+1 queries', :request_store do
- Preloaders::UsersMaxAccessLevelInProjectsPreloader.new(projects: projects, users: users).execute
-
- expect(count_queries).to eq(0)
- end
-
- it 'runs N queries without preloading' do
- query_count_without_preload = count_queries
-
- Preloaders::UsersMaxAccessLevelInProjectsPreloader.new(projects: projects, users: users).execute
- count_queries_with_preload = count_queries
-
- expect(count_queries_with_preload).to be < query_count_without_preload
- end
- end
-
- def count_queries
- ActiveRecord::QueryRecorder.new do
- projects.each do |project|
- user1.can?(:read_project, project)
- user2.can?(:read_project, project)
- end
- end.count
- end
-end
diff --git a/spec/models/project_ci_cd_setting_spec.rb b/spec/models/project_ci_cd_setting_spec.rb
index 2c490c33747..0a818147bfc 100644
--- a/spec/models/project_ci_cd_setting_spec.rb
+++ b/spec/models/project_ci_cd_setting_spec.rb
@@ -27,22 +27,8 @@ RSpec.describe ProjectCiCdSetting do
end
end
- describe '#set_default_for_inbound_job_token_scope_enabled' do
- context 'when feature flag ci_inbound_job_token_scope is enabled' do
- before do
- stub_feature_flags(ci_inbound_job_token_scope: true)
- end
-
- it { is_expected.to be_inbound_job_token_scope_enabled }
- end
-
- context 'when feature flag ci_inbound_job_token_scope is disabled' do
- before do
- stub_feature_flags(ci_inbound_job_token_scope: false)
- end
-
- it { is_expected.not_to be_inbound_job_token_scope_enabled }
- end
+ describe '#default_for_inbound_job_token_scope_enabled' do
+ it { is_expected.to be_inbound_job_token_scope_enabled }
end
describe '#default_git_depth' do
diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb
index fe0b46c3117..87bfdd15773 100644
--- a/spec/models/project_feature_spec.rb
+++ b/spec/models/project_feature_spec.rb
@@ -288,7 +288,6 @@ RSpec.describe ProjectFeature, feature_category: :projects do
end
context 'sync packages_enabled' do
- # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
where(:initial_value, :new_value, :expected_result) do
ProjectFeature::DISABLED | ProjectFeature::DISABLED | false
ProjectFeature::DISABLED | ProjectFeature::ENABLED | true
@@ -300,7 +299,6 @@ RSpec.describe ProjectFeature, feature_category: :projects do
ProjectFeature::PUBLIC | ProjectFeature::ENABLED | true
ProjectFeature::PUBLIC | ProjectFeature::PUBLIC | true
end
- # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands
with_them do
it 'set correct value' do
@@ -314,6 +312,40 @@ RSpec.describe ProjectFeature, feature_category: :projects do
end
end
+ describe '#public_packages?' do
+ let_it_be(:public_project) { create(:project, :public) }
+
+ context 'with packages config enabled' do
+ context 'when project is private' do
+ it 'returns false' do
+ expect(project.project_feature.public_packages?).to eq(false)
+ end
+
+ context 'with package_registry_access_level set to public' do
+ before do
+ project.project_feature.update!(package_registry_access_level: ProjectFeature::PUBLIC)
+ end
+
+ it 'returns true' do
+ expect(project.project_feature.public_packages?).to eq(true)
+ end
+ end
+ end
+
+ context 'when project is public' do
+ it 'returns true' do
+ expect(public_project.project_feature.public_packages?).to eq(true)
+ end
+ end
+ end
+
+ it 'returns false if packages config is not enabled' do
+ stub_config(packages: { enabled: false })
+
+ expect(public_project.project_feature.public_packages?).to eq(false)
+ end
+ end
+
# rubocop:disable Gitlab/FeatureAvailableUsage
describe '#feature_available?' do
let(:features) { ProjectFeature::FEATURES }
diff --git a/spec/models/project_label_spec.rb b/spec/models/project_label_spec.rb
index f451c2905e6..ba9ea759c6a 100644
--- a/spec/models/project_label_spec.rb
+++ b/spec/models/project_label_spec.rb
@@ -119,4 +119,39 @@ RSpec.describe ProjectLabel do
end
end
end
+
+ describe '#preloaded_parent_container' do
+ let_it_be(:label) { create(:label) }
+
+ before do
+ label.reload # ensure associations are not loaded
+ end
+
+ context 'when project is loaded' do
+ it 'does not invoke a DB query' do
+ label.project
+
+ count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count
+ expect(count).to eq(0)
+ expect(label.preloaded_parent_container).to eq(label.project)
+ end
+ end
+
+ context 'when parent_container is loaded' do
+ it 'does not invoke a DB query' do
+ label.parent_container
+
+ count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count
+ expect(count).to eq(0)
+ expect(label.preloaded_parent_container).to eq(label.parent_container)
+ end
+ end
+
+ context 'when none of them are loaded' do
+ it 'invokes a DB query' do
+ count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count
+ expect(count).to eq(1)
+ end
+ end
+ end
end
diff --git a/spec/models/project_setting_spec.rb b/spec/models/project_setting_spec.rb
index feb5985818b..0a2ead0aa6b 100644
--- a/spec/models/project_setting_spec.rb
+++ b/spec/models/project_setting_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProjectSetting, type: :model do
+RSpec.describe ProjectSetting, type: :model, feature_category: :projects do
using RSpec::Parameterized::TableSyntax
it { is_expected.to belong_to(:project) }
@@ -39,6 +39,44 @@ RSpec.describe ProjectSetting, type: :model do
[nil, 'not_allowed', :invalid].each do |invalid_value|
it { is_expected.not_to allow_value([invalid_value]).for(:target_platforms) }
end
+
+ context "when pages_unique_domain is required", feature_category: :pages do
+ it "is not required if pages_unique_domain_enabled is false" do
+ project_setting = build(:project_setting, pages_unique_domain_enabled: false)
+
+ expect(project_setting).to be_valid
+ expect(project_setting.errors.full_messages).not_to include("Pages unique domain can't be blank")
+ end
+
+ it "is required when pages_unique_domain_enabled is true" do
+ project_setting = build(:project_setting, pages_unique_domain_enabled: true)
+
+ expect(project_setting).not_to be_valid
+ expect(project_setting.errors.full_messages).to include("Pages unique domain can't be blank")
+ end
+
+ it "is required if it is already saved in the database" do
+ project_setting = create(
+ :project_setting,
+ pages_unique_domain: "random-unique-domain-here",
+ pages_unique_domain_enabled: true
+ )
+
+ project_setting.pages_unique_domain = nil
+
+ expect(project_setting).not_to be_valid
+ expect(project_setting.errors.full_messages).to include("Pages unique domain can't be blank")
+ end
+ end
+
+ it "validates uniqueness of pages_unique_domain", feature_category: :pages do
+ create(:project_setting, pages_unique_domain: "random-unique-domain-here")
+
+ project_setting = build(:project_setting, pages_unique_domain: "random-unique-domain-here")
+
+ expect(project_setting).not_to be_valid
+ expect(project_setting.errors.full_messages).to include("Pages unique domain has already been taken")
+ end
end
describe 'target_platforms=' do
@@ -169,4 +207,34 @@ RSpec.describe ProjectSetting, type: :model do
end
end
end
+
+ describe '#runner_registration_enabled' do
+ let_it_be(:settings) { create(:project_setting) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, project_setting: settings, group: group) }
+
+ it 'returns true' do
+ expect(project.runner_registration_enabled).to eq true
+ end
+
+ context 'when project has runner registration disabled' do
+ before do
+ project.update!(runner_registration_enabled: false)
+ end
+
+ it 'returns false' do
+ expect(project.runner_registration_enabled).to eq false
+ end
+ end
+
+ context 'when all projects have runner registration disabled' do
+ before do
+ stub_application_setting(valid_runner_registrars: ['group'])
+ end
+
+ it 'returns false' do
+ expect(project.runner_registration_enabled).to eq false
+ end
+ end
+ end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index dfc8919e19d..e9bb01f4b23 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -14,6 +14,8 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
it_behaves_like 'having unique enum values'
+ it_behaves_like 'ensures runners_token is prefixed', :project
+
describe 'associations' do
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:namespace) }
@@ -25,6 +27,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
it { is_expected.to have_many(:merge_requests) }
it { is_expected.to have_many(:merge_request_metrics).class_name('MergeRequest::Metrics') }
it { is_expected.to have_many(:issues) }
+ it { is_expected.to have_many(:work_items) }
it { is_expected.to have_many(:incident_management_issuable_escalation_statuses).through(:issues).inverse_of(:project).class_name('IncidentManagement::IssuableEscalationStatus') }
it { is_expected.to have_many(:milestones) }
it { is_expected.to have_many(:project_members).dependent(:delete_all) }
@@ -40,7 +43,9 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
it { is_expected.to have_many(:protected_branches) }
it { is_expected.to have_many(:exported_protected_branches) }
it { is_expected.to have_one(:wiki_repository).class_name('Projects::WikiRepository').inverse_of(:project) }
+ it { is_expected.to have_one(:design_management_repository).class_name('DesignManagement::Repository').inverse_of(:project) }
it { is_expected.to have_one(:slack_integration) }
+ it { is_expected.to have_one(:catalog_resource) }
it { is_expected.to have_one(:microsoft_teams_integration) }
it { is_expected.to have_one(:mattermost_integration) }
it { is_expected.to have_one(:hangouts_chat_integration) }
@@ -50,6 +55,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
it { is_expected.to have_one(:packagist_integration) }
it { is_expected.to have_one(:pushover_integration) }
it { is_expected.to have_one(:apple_app_store_integration) }
+ it { is_expected.to have_one(:google_play_integration) }
it { is_expected.to have_one(:asana_integration) }
it { is_expected.to have_many(:boards) }
it { is_expected.to have_one(:campfire_integration) }
@@ -88,6 +94,8 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
it { is_expected.to have_one(:alerting_setting).class_name('Alerting::ProjectAlertingSetting') }
it { is_expected.to have_one(:mock_ci_integration) }
it { is_expected.to have_one(:mock_monitoring_integration) }
+ it { is_expected.to have_one(:service_desk_custom_email_verification).class_name('ServiceDesk::CustomEmailVerification') }
+ it { is_expected.to have_one(:container_registry_data_repair_detail).class_name('ContainerRegistry::DataRepairDetail') }
it { is_expected.to have_many(:commit_statuses) }
it { is_expected.to have_many(:ci_pipelines) }
it { is_expected.to have_many(:ci_refs) }
@@ -135,7 +143,9 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
it { is_expected.to have_many(:reviews).inverse_of(:project) }
it { is_expected.to have_many(:packages).class_name('Packages::Package') }
it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') }
+ it { is_expected.to have_many(:rpm_repository_files).class_name('Packages::Rpm::RepositoryFile').inverse_of(:project).dependent(:destroy) }
it { is_expected.to have_many(:debian_distributions).class_name('Packages::Debian::ProjectDistribution').dependent(:destroy) }
+ it { is_expected.to have_many(:npm_metadata_caches).class_name('Packages::Npm::MetadataCache') }
it { is_expected.to have_one(:packages_cleanup_policy).class_name('Packages::Cleanup::Policy').inverse_of(:project) }
it { is_expected.to have_many(:pipeline_artifacts).dependent(:restrict_with_error) }
it { is_expected.to have_many(:terraform_states).class_name('Terraform::State').inverse_of(:project) }
@@ -780,7 +790,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
let(:new_project) do
build(:project,
- name: project_pending_deletion.name,
+ path: project_pending_deletion.path,
namespace: project_pending_deletion.namespace)
end
@@ -874,46 +884,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
- describe '#has_packages?' do
- let_it_be(:project) { create(:project, :public) }
-
- subject { project.has_packages?(package_type) }
-
- shared_examples 'returning true examples' do
- let!(:package) { create("#{package_type}_package", project: project) }
-
- it { is_expected.to be true }
- end
-
- shared_examples 'returning false examples' do
- it { is_expected.to be false }
- end
-
- context 'with maven packages' do
- it_behaves_like 'returning true examples' do
- let(:package_type) { :maven }
- end
- end
-
- context 'with npm packages' do
- it_behaves_like 'returning true examples' do
- let(:package_type) { :npm }
- end
- end
-
- context 'with conan packages' do
- it_behaves_like 'returning true examples' do
- let(:package_type) { :conan }
- end
- end
-
- context 'with no package type' do
- it_behaves_like 'returning false examples' do
- let(:package_type) { nil }
- end
- end
- end
-
describe '#ci_pipelines' do
let_it_be(:project) { create(:project) }
@@ -939,6 +909,17 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
+ describe '#commit_notes' do
+ let_it_be(:project) { create(:project) }
+
+ it "returns project's commit notes" do
+ note_1 = create(:note_on_commit, project: project, commit_id: 'commit_id_1')
+ note_2 = create(:note_on_commit, project: project, commit_id: 'commit_id_2')
+
+ expect(project.commit_notes).to match_array([note_1, note_2])
+ end
+ end
+
describe '#personal_namespace_holder?' do
let_it_be(:group) { create(:group) }
let_it_be(:namespace_user) { create(:user) }
@@ -1115,7 +1096,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
'restrict_user_defined_variables' => '',
'runner_token_expiration_interval' => '',
'separated_caches' => 'ci_',
- 'opt_in_jwt' => 'ci_',
'allow_fork_pipelines_to_run_in_parent_project' => 'ci_',
'inbound_job_token_scope_enabled' => 'ci_',
'job_token_scope_enabled' => 'ci_outbound_'
@@ -1151,7 +1131,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
describe '#ci_inbound_job_token_scope_enabled?' do
- it_behaves_like 'a ci_cd_settings predicate method', prefix: 'ci_' do
+ it_behaves_like 'a ci_cd_settings predicate method', prefix: 'ci_', default: true do
let(:delegated_method) { :inbound_job_token_scope_enabled? }
end
end
@@ -1380,6 +1360,60 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
+ describe '#to_reference_base' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user_namespace) { user.namespace }
+
+ let_it_be(:parent) { create(:group) }
+ let_it_be(:group) { create(:group, parent: parent) }
+ let_it_be(:another_group) { create(:group) }
+
+ let_it_be(:project1) { create(:project, namespace: group) }
+ let_it_be(:project_namespace) { project1.project_namespace }
+
+ # different project same group
+ let_it_be(:project2) { create(:project, namespace: group) }
+ let_it_be(:project_namespace2) { project2.project_namespace }
+
+ # different project from different group
+ let_it_be(:project3) { create(:project) }
+ let_it_be(:project_namespace3) { project3.project_namespace }
+
+ # testing references with namespace being: group, project namespace and user namespace
+ where(:project, :full, :from, :result) do
+ ref(:project1) | false | nil | nil
+ ref(:project1) | true | nil | lazy { project.full_path }
+ ref(:project1) | false | ref(:group) | lazy { project.path }
+ ref(:project1) | true | ref(:group) | lazy { project.full_path }
+ ref(:project1) | false | ref(:parent) | lazy { project.full_path }
+ ref(:project1) | true | ref(:parent) | lazy { project.full_path }
+ ref(:project1) | false | ref(:project1) | nil
+ ref(:project1) | true | ref(:project1) | lazy { project.full_path }
+ ref(:project1) | false | ref(:project_namespace) | nil
+ ref(:project1) | true | ref(:project_namespace) | lazy { project.full_path }
+ ref(:project1) | false | ref(:project2) | lazy { project.path }
+ ref(:project1) | true | ref(:project2) | lazy { project.full_path }
+ ref(:project1) | false | ref(:project_namespace2) | lazy { project.path }
+ ref(:project1) | true | ref(:project_namespace2) | lazy { project.full_path }
+ ref(:project1) | false | ref(:another_group) | lazy { project.full_path }
+ ref(:project1) | true | ref(:another_group) | lazy { project.full_path }
+ ref(:project1) | false | ref(:project3) | lazy { project.full_path }
+ ref(:project1) | true | ref(:project3) | lazy { project.full_path }
+ ref(:project1) | false | ref(:project_namespace3) | lazy { project.full_path }
+ ref(:project1) | true | ref(:project_namespace3) | lazy { project.full_path }
+ ref(:project1) | false | ref(:user_namespace) | lazy { project.full_path }
+ ref(:project1) | true | ref(:user_namespace) | lazy { project.full_path }
+ end
+
+ with_them do
+ it 'returns correct path' do
+ expect(project.to_reference_base(from, full: full)).to eq(result)
+ end
+ end
+ end
+
describe '#merge_method' do
where(:ff, :rebase, :method) do
true | true | :ff
@@ -1619,7 +1653,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
context 'with namespace' do
before do
@group = create(:group, name: 'gitlab')
- @project = create(:project, name: 'gitlabhq', namespace: @group)
+ @project = create(:project, path: 'gitlabhq', namespace: @group)
end
it { expect(@project.to_param).to eq('gitlabhq') }
@@ -2213,8 +2247,8 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
subject(:project) { build(:project, :private, namespace: namespace, service_desk_enabled: true) }
before do
- allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
- allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
end
it 'is enabled' do
@@ -2254,7 +2288,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
context 'when service_desk_email is disabled' do
before do
- allow(::Gitlab::ServiceDeskEmail).to receive(:enabled?).and_return(false)
+ allow(::Gitlab::Email::ServiceDeskEmail).to receive(:enabled?).and_return(false)
end
it_behaves_like 'with incoming email address'
@@ -2263,7 +2297,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
context 'when service_desk_email is enabled' do
before do
config = double(enabled: true, address: 'foo+%{key}@bar.com')
- allow(::Gitlab::ServiceDeskEmail).to receive(:config).and_return(config)
+ allow(::Gitlab::Email::ServiceDeskEmail).to receive(:config).and_return(config)
end
context 'when project_key is set' do
@@ -2666,7 +2700,11 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
describe '#pages_url', feature_category: :pages do
+ let(:group_name) { 'group' }
+ let(:project_name) { 'project' }
+
let(:group) { create(:group, name: group_name) }
+ let(:nested_group) { create(:group, parent: group) }
let(:project_path) { project_name.downcase }
let(:project) do
@@ -2689,101 +2727,168 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
.and_return(['http://example.com', port].compact.join(':'))
end
- context 'group page' do
- let(:group_name) { 'Group' }
- let(:project_name) { 'group.example.com' }
-
- it { is_expected.to eq("http://group.example.com") }
+ context 'when not using pages_unique_domain' do
+ subject { project.pages_url(with_unique_domain: false) }
- context 'mixed case path' do
- let(:project_path) { 'Group.example.com' }
+ context 'when pages_unique_domain feature flag is disabled' do
+ before do
+ stub_feature_flags(pages_unique_domain: false)
+ end
- it { is_expected.to eq("http://group.example.com") }
+ it { is_expected.to eq('http://group.example.com/project') }
end
- end
- context 'project page' do
- let(:group_name) { 'Group' }
- let(:project_name) { 'Project' }
+ context 'when pages_unique_domain feature flag is enabled' do
+ before do
+ stub_feature_flags(pages_unique_domain: true)
- it { is_expected.to eq("http://group.example.com/project") }
+ project.project_setting.update!(
+ pages_unique_domain_enabled: pages_unique_domain_enabled,
+ pages_unique_domain: 'unique-domain'
+ )
+ end
- context 'mixed case path' do
- let(:project_path) { 'Project' }
+ context 'when pages_unique_domain_enabled is false' do
+ let(:pages_unique_domain_enabled) { false }
+
+ it { is_expected.to eq('http://group.example.com/project') }
+ end
- it { is_expected.to eq("http://group.example.com/Project") }
+ context 'when pages_unique_domain_enabled is true' do
+ let(:pages_unique_domain_enabled) { true }
+
+ it { is_expected.to eq('http://group.example.com/project') }
+ end
end
end
- context 'when there is an explicit port' do
- let(:port) { 3000 }
+ context 'when using pages_unique_domain' do
+ subject { project.pages_url(with_unique_domain: true) }
- context 'when not in dev mode' do
+ context 'when pages_unique_domain feature flag is disabled' do
before do
- stub_rails_env('production')
+ stub_feature_flags(pages_unique_domain: false)
end
- context 'group page' do
- let(:group_name) { 'Group' }
- let(:project_name) { 'group.example.com' }
-
- it { is_expected.to eq('http://group.example.com:3000/group.example.com') }
+ it { is_expected.to eq('http://group.example.com/project') }
+ end
- context 'mixed case path' do
- let(:project_path) { 'Group.example.com' }
+ context 'when pages_unique_domain feature flag is enabled' do
+ before do
+ stub_feature_flags(pages_unique_domain: true)
- it { is_expected.to eq('http://group.example.com:3000/Group.example.com') }
- end
+ project.project_setting.update!(
+ pages_unique_domain_enabled: pages_unique_domain_enabled,
+ pages_unique_domain: 'unique-domain'
+ )
end
- context 'project page' do
- let(:group_name) { 'Group' }
- let(:project_name) { 'Project' }
+ context 'when pages_unique_domain_enabled is false' do
+ let(:pages_unique_domain_enabled) { false }
- it { is_expected.to eq("http://group.example.com:3000/project") }
+ it { is_expected.to eq('http://group.example.com/project') }
+ end
- context 'mixed case path' do
- let(:project_path) { 'Project' }
+ context 'when pages_unique_domain_enabled is true' do
+ let(:pages_unique_domain_enabled) { true }
- it { is_expected.to eq("http://group.example.com:3000/Project") }
- end
+ it { is_expected.to eq('http://unique-domain.example.com') }
end
end
+ end
- context 'when in dev mode' do
- before do
- stub_rails_env('development')
- end
+ context 'with nested group' do
+ let(:project) { create(:project, namespace: nested_group, name: project_name) }
+ let(:expected_url) { "http://group.example.com/#{nested_group.path}/#{project.path}" }
- context 'group page' do
- let(:group_name) { 'Group' }
- let(:project_name) { 'group.example.com' }
+ context 'group page' do
+ let(:project_name) { 'group.example.com' }
- it { is_expected.to eq('http://group.example.com:3000') }
+ it { is_expected.to eq(expected_url) }
+ end
+
+ context 'project page' do
+ let(:project_name) { 'Project' }
- context 'mixed case path' do
- let(:project_path) { 'Group.example.com' }
+ it { is_expected.to eq(expected_url) }
+ end
+ end
- it { is_expected.to eq('http://group.example.com:3000') }
- end
- end
+ context 'when the project matches its namespace url' do
+ let(:project_name) { 'group.example.com' }
+
+ it { is_expected.to eq('http://group.example.com') }
- context 'project page' do
- let(:group_name) { 'Group' }
- let(:project_name) { 'Project' }
+ context 'with different group name capitalization' do
+ let(:group_name) { 'Group' }
- it { is_expected.to eq("http://group.example.com:3000/project") }
+ it { is_expected.to eq("http://group.example.com") }
+ end
+
+ context 'with different project path capitalization' do
+ let(:project_path) { 'Group.example.com' }
+
+ it { is_expected.to eq("http://group.example.com") }
+ end
+
+ context 'with different project name capitalization' do
+ let(:project_name) { 'Project' }
- context 'mixed case path' do
- let(:project_path) { 'Project' }
+ it { is_expected.to eq("http://group.example.com/project") }
+ end
+
+ context 'when there is an explicit port' do
+ let(:port) { 3000 }
- it { is_expected.to eq("http://group.example.com:3000/Project") }
+ context 'when not in dev mode' do
+ before do
+ stub_rails_env('production')
end
+
+ it { is_expected.to eq('http://group.example.com:3000/group.example.com') }
+ end
+
+ context 'when in dev mode' do
+ before do
+ stub_rails_env('development')
+ end
+
+ it { is_expected.to eq('http://group.example.com:3000') }
end
end
end
end
+ describe '#pages_unique_url', feature_category: :pages do
+ let(:project_settings) { create(:project_setting, pages_unique_domain: 'unique-domain') }
+ let(:project) { build(:project, project_setting: project_settings) }
+ let(:domain) { 'example.com' }
+
+ before do
+ allow(Settings.pages).to receive(:host).and_return(domain)
+ allow(Gitlab.config.pages).to receive(:url).and_return("http://#{domain}")
+ end
+
+ it 'returns the pages unique url' do
+ expect(project.pages_unique_url).to eq('http://unique-domain.example.com')
+ end
+ end
+
+ describe '#pages_unique_host', feature_category: :pages do
+ let(:project_settings) { create(:project_setting, pages_unique_domain: 'unique-domain') }
+ let(:project) { build(:project, project_setting: project_settings) }
+ let(:domain) { 'example.com' }
+
+ before do
+ allow(Settings.pages).to receive(:host).and_return(domain)
+ allow(Gitlab.config.pages).to receive(:url).and_return("http://#{domain}")
+ end
+
+ it 'returns the pages unique url' do
+ expect(project.pages_unique_host).to eq('unique-domain.example.com')
+ end
+ end
+
describe '#pages_namespace_url', feature_category: :pages do
let(:group) { create(:group, name: group_name) }
let(:project) { create(:project, namespace: group, name: project_name) }
@@ -3030,6 +3135,12 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
expect(project.create_repository).to eq(false)
expect(project.errors).not_to be_empty
end
+
+ it 'passes through default branch' do
+ expect(project.repository).to receive(:create_repository).with('pineapple')
+
+ expect(project.create_repository(default_branch: 'pineapple')).to eq(true)
+ end
end
context 'using a forked repository' do
@@ -3565,6 +3676,44 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
+ describe '#beautified_import_status_name' do
+ context 'when import not finished' do
+ it 'returns the right beautified import status' do
+ project = create(:project, :import_started)
+
+ expect(project.beautified_import_status_name).to eq('started')
+ end
+ end
+
+ context 'when import is finished' do
+ context 'when import is partially completed' do
+ it 'returns partially completed' do
+ project = create(:project)
+
+ create(:import_state, project: project, status: 'finished', checksums: {
+ 'fetched' => { 'labels' => 10 },
+ 'imported' => { 'labels' => 9 }
+ })
+
+ expect(project.beautified_import_status_name).to eq('partially completed')
+ end
+ end
+
+ context 'when import is fully completed' do
+ it 'returns completed' do
+ project = create(:project)
+
+ create(:import_state, project: project, status: 'finished', checksums: {
+ 'fetched' => { 'labels' => 10 },
+ 'imported' => { 'labels' => 10 }
+ })
+
+ expect(project.beautified_import_status_name).to eq('completed')
+ end
+ end
+ end
+ end
+
describe '#add_import_job' do
let(:import_jid) { '123' }
@@ -3843,21 +3992,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
describe '#ancestors' do
- context 'with linear_project_ancestors feature flag enabled' do
- before do
- stub_feature_flags(linear_project_ancestors: true)
- end
-
- include_examples '#ancestors'
- end
-
- context 'with linear_project_ancestors feature flag disabled' do
- before do
- stub_feature_flags(linear_project_ancestors: false)
- end
-
- include_examples '#ancestors'
- end
+ include_examples '#ancestors'
end
describe '#ancestors_upto' do
@@ -4643,52 +4778,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
- describe '#pages_url' do
- let(:group) { create(:group, name: 'Group') }
- let(:nested_group) { create(:group, parent: group) }
- let(:domain) { 'Example.com' }
-
- subject { project.pages_url }
-
- before do
- allow(Settings.pages).to receive(:host).and_return(domain)
- allow(Gitlab.config.pages).to receive(:url).and_return('http://example.com')
- end
-
- context 'top-level group' do
- let(:project) { create(:project, namespace: group, name: project_name) }
-
- context 'group page' do
- let(:project_name) { 'group.example.com' }
-
- it { is_expected.to eq("http://group.example.com") }
- end
-
- context 'project page' do
- let(:project_name) { 'Project' }
-
- it { is_expected.to eq("http://group.example.com/project") }
- end
- end
-
- context 'nested group' do
- let(:project) { create(:project, namespace: nested_group, name: project_name) }
- let(:expected_url) { "http://group.example.com/#{nested_group.path}/#{project.path}" }
-
- context 'group page' do
- let(:project_name) { 'group.example.com' }
-
- it { is_expected.to eq(expected_url) }
- end
-
- context 'project page' do
- let(:project_name) { 'Project' }
-
- it { is_expected.to eq(expected_url) }
- end
- end
- end
-
describe '#lfs_http_url_to_repo' do
let(:project) { create(:project) }
@@ -5740,8 +5829,19 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
let_it_be(:project) { create(:project) }
it 'exposes API v4 URL' do
- expect(project.api_variables.first[:key]).to eq 'CI_API_V4_URL'
- expect(project.api_variables.first[:value]).to include '/api/v4'
+ v4_variable = project.api_variables.find { |variable| variable[:key] == "CI_API_V4_URL" }
+
+ expect(v4_variable).not_to be_nil
+ expect(v4_variable[:key]).to eq 'CI_API_V4_URL'
+ expect(v4_variable[:value]).to end_with '/api/v4'
+ end
+
+ it 'exposes API GraphQL URL' do
+ graphql_variable = project.api_variables.find { |variable| variable[:key] == "CI_API_GRAPHQL_URL" }
+
+ expect(graphql_variable).not_to be_nil
+ expect(graphql_variable[:key]).to eq 'CI_API_GRAPHQL_URL'
+ expect(graphql_variable[:value]).to end_with '/api/graphql'
end
it 'contains a URL variable for every supported API version' do
@@ -5756,7 +5856,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
expect(project.api_variables.map { |variable| variable[:key] })
- .to contain_exactly(*required_variables)
+ .to include(*required_variables)
end
end
@@ -5854,7 +5954,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
expect(project).to receive(:after_create_default_branch)
expect(project).to receive(:refresh_markdown_cache!)
expect(InternalId).to receive(:flush_records!).with(project: project)
- expect(ProjectCacheWorker).to receive(:perform_async).with(project.id, [], [:repository_size])
+ expect(ProjectCacheWorker).to receive(:perform_async).with(project.id, [], [:repository_size, :wiki_size])
expect(DetectRepositoryLanguagesWorker).to receive(:perform_async).with(project.id)
expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to receive(:perform_async).with(project.id)
expect(project).to receive(:set_full_path)
@@ -6049,7 +6149,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
it 'executes hooks which were backed off and are no longer backed off' do
project = create(:project)
hook = create(:project_hook, project: project, push_events: true)
- WebHook::FAILURE_THRESHOLD.succ.times { hook.backoff! }
+ WebHooks::AutoDisabling::FAILURE_THRESHOLD.succ.times { hook.backoff! }
expect_any_instance_of(ProjectHook).to receive(:async_execute).once
@@ -6701,6 +6801,19 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
+ describe '.pending_data_repair_analysis' do
+ it 'returns projects that are not in ContainerRegistry::DataRepairDetail' do
+ project_1 = create(:project)
+ project_2 = create(:project)
+
+ expect(described_class.pending_data_repair_analysis).to match_array([project_1, project_2])
+
+ create(:container_registry_data_repair_detail, project: project_1)
+
+ expect(described_class.pending_data_repair_analysis).to match_array([project_2])
+ end
+ end
+
describe '.deployments' do
subject { project.deployments }
@@ -7305,20 +7418,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
- describe 'with integrations and chat names' do
- subject { create(:project) }
-
- let(:integration) { create(:integration, project: subject) }
-
- before do
- create_list(:chat_name, 5, integration: integration)
- end
-
- it 'does not remove chat names on removal' do
- expect { subject.destroy! }.not_to change { ChatName.count }
- end
- end
-
describe 'with_issues_or_mrs_available_for_user' do
before do
Project.delete_all
@@ -7380,6 +7479,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
context 'when feature flag `group_protected_branches` enabled' do
before do
stub_feature_flags(group_protected_branches: true)
+ stub_feature_flags(allow_protected_branches_for_group: true)
end
it 'return all protected branches' do
@@ -7390,6 +7490,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
context 'when feature flag `group_protected_branches` disabled' do
before do
stub_feature_flags(group_protected_branches: false)
+ stub_feature_flags(allow_protected_branches_for_group: false)
end
it 'return only project-level protected branches' do
@@ -7463,24 +7564,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
- describe '#self_monitoring?' do
- let_it_be(:project) { create(:project) }
-
- subject { project.self_monitoring? }
-
- context 'when the project is instance self-monitoring' do
- before do
- stub_application_setting(self_monitoring_project_id: project.id)
- end
-
- it { is_expected.to be true }
- end
-
- context 'when the project is not self-monitoring' do
- it { is_expected.to be false }
- end
- end
-
describe '#add_export_job' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
@@ -7578,48 +7661,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
- describe '#has_packages?' do
- let(:project) { create(:project, :public) }
-
- subject { project.has_packages?(package_type) }
-
- shared_examples 'has_package' do
- context 'package of package_type exists' do
- let!(:package) { create("#{package_type}_package", project: project) }
-
- it { is_expected.to be true }
- end
-
- context 'package of package_type does not exist' do
- it { is_expected.to be false }
- end
- end
-
- context 'with maven packages' do
- it_behaves_like 'has_package' do
- let(:package_type) { :maven }
- end
- end
-
- context 'with npm packages' do
- it_behaves_like 'has_package' do
- let(:package_type) { :npm }
- end
- end
-
- context 'with conan packages' do
- it_behaves_like 'has_package' do
- let(:package_type) { :conan }
- end
- end
-
- context 'calling has_package? with nil' do
- let(:package_type) { nil }
-
- it { is_expected.to be false }
- end
- end
-
describe 'with Debian Distributions' do
subject { create(:project) }
@@ -7740,6 +7781,29 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
+ describe '#has_namespaced_npm_packages?' do
+ let_it_be(:namespace) { create(:namespace, path: 'test') }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+
+ subject { project.has_namespaced_npm_packages? }
+
+ context 'with scope of the namespace path' do
+ let_it_be(:package) { create(:npm_package, project: project, name: "@#{namespace.path}/foo") }
+
+ it { is_expected.to be true }
+ end
+
+ context 'without scope of the namespace path' do
+ let_it_be(:package) { create(:npm_package, project: project, name: "@someotherscope/foo") }
+
+ it { is_expected.to be false }
+ end
+
+ context 'without packages' do
+ it { is_expected.to be false }
+ end
+ end
+
describe '#package_already_taken?' do
let_it_be(:namespace) { create(:namespace, path: 'test') }
let_it_be(:project) { create(:project, :public, namespace: namespace) }
@@ -7913,7 +7977,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
describe '#activity_path' do
it 'returns the project activity_path' do
- expected_path = "/#{project.namespace.path}/#{project.name}/activity"
+ expected_path = "/#{project.full_path}/activity"
expect(project.activity_path).to eq(expected_path)
end
@@ -7952,8 +8016,8 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects 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
+ ['topicA', 'topicB'] | %w[topicA topicB] # rubocop:disable Style/WordArray
+ ['topicB', 'topicA'] | %w[topicB topicA] # rubocop:disable Style/WordArray
[' topicC ', ' topicD '] | %w[topicC topicD]
['topicE', 'topicF', 'topicE'] | %w[topicE topicF] # rubocop:disable Style/WordArray
['topicE ', 'topicF', ' topicE'] | %w[topicE topicF]
@@ -8028,7 +8092,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
using RSpec::Parameterized::TableSyntax
- # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
where(:initial_visibility, :new_visibility, :new_topic_list, :expected_count_changes) do
ref(:private) | nil | 't2, t3' | [0, 0, 0]
ref(:internal) | nil | 't2, t3' | [-1, 0, 1]
@@ -8052,7 +8115,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
ref(:public) | ref(:internal) | 't2, t3' | [-1, 0, 1]
ref(:public) | ref(:private) | 't2, t3' | [-1, -1, 0]
end
- # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands
with_them do
it 'increments or decrements counters of topics' do
@@ -8519,6 +8581,16 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
+ describe '#content_editor_on_issues_feature_flag_enabled?' do
+ let_it_be(:group_project) { create(:project, :in_subgroup) }
+
+ it_behaves_like 'checks parent group feature flag' do
+ let(:feature_flag_method) { :content_editor_on_issues_feature_flag_enabled? }
+ let(:feature_flag) { :content_editor_on_issues }
+ let(:subject_project) { group_project }
+ end
+ end
+
describe '#work_items_mvc_feature_flag_enabled?' do
let_it_be(:group_project) { create(:project, :in_subgroup) }
@@ -8842,6 +8914,32 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do
end
end
+ describe 'deprecated project attributes' do
+ where(:project_attr, :project_method, :project_feature_attr) do
+ :wiki_enabled | :wiki_enabled? | :wiki_access_level
+ :builds_enabled | :builds_enabled? | :builds_access_level
+ :merge_requests_enabled | :merge_requests_enabled? | :merge_requests_access_level
+ :issues_enabled | :issues_enabled? | :issues_access_level
+ :snippets_enabled | :snippets_enabled? | :snippets_access_level
+ end
+
+ with_them do
+ it 'delegates the attributes to project feature' do
+ project = Project.new(project_attr => false)
+
+ expect(project.public_send(project_method)).to eq(false)
+ expect(project.project_feature.public_send(project_feature_attr)).to eq(ProjectFeature::DISABLED)
+ end
+
+ it 'sets the default value' do
+ project = Project.new
+
+ expect(project.public_send(project_method)).to eq(true)
+ expect(project.project_feature.public_send(project_feature_attr)).to eq(ProjectFeature::ENABLED)
+ end
+ end
+ end
+
private
def finish_job(export_job)
diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb
index c04fc70deca..efa5403dad9 100644
--- a/spec/models/project_wiki_spec.rb
+++ b/spec/models/project_wiki_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProjectWiki do
+RSpec.describe ProjectWiki, feature_category: :wiki do
it_behaves_like 'wiki model' do
let(:wiki_container) { create(:project, :wiki_repo, namespace: user.namespace) }
let(:wiki_container_without_repo) { create(:project, namespace: user.namespace) }
@@ -18,6 +18,33 @@ RSpec.describe ProjectWiki do
end
end
+ describe '#create_wiki_repository' do
+ context 'when a project_wiki_repositories record does not exist' do
+ let_it_be(:wiki_container) { create(:project) }
+
+ it 'creates a new record' do
+ expect { subject.create_wiki_repository }.to change { wiki_container.wiki_repository }
+ .from(nil).to(kind_of(Projects::WikiRepository))
+ end
+
+ context 'on a read-only instance' do
+ before do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(true)
+ end
+
+ it 'does not attempt to create a new record' do
+ expect { subject.create_wiki_repository }.not_to change { wiki_container.wiki_repository }
+ end
+ end
+ end
+
+ context 'when a project_wiki_repositories record exists' do
+ it 'does not create a new record in the database' do
+ expect { subject.create_wiki_repository }.not_to change { wiki_container.wiki_repository }
+ end
+ end
+ end
+
describe '#after_wiki_activity' do
it 'updates project activity' do
wiki_container.update!(
diff --git a/spec/models/projects/data_transfer_spec.rb b/spec/models/projects/data_transfer_spec.rb
index 6d3ddbdd74e..49be35662c8 100644
--- a/spec/models/projects/data_transfer_spec.rb
+++ b/spec/models/projects/data_transfer_spec.rb
@@ -7,12 +7,24 @@ RSpec.describe Projects::DataTransfer, feature_category: :source_code_management
it { expect(subject).to be_valid }
+ # tests DataTransferCounterAttribute with the appropiate attributes
+ it_behaves_like CounterAttribute,
+ %i[repository_egress artifacts_egress packages_egress registry_egress] do
+ let(:model) { create(:project_data_transfer, project: project) }
+ end
+
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:namespace) }
end
describe 'scopes' do
+ let(:dates) { %w[2023-01-01 2023-02-01 2023-03-01] }
+
+ before do
+ dates.each { |date| create(:project_data_transfer, project: project, date: date) }
+ end
+
describe '.current_month' do
subject { described_class.current_month }
@@ -25,6 +37,26 @@ RSpec.describe Projects::DataTransfer, feature_category: :source_code_management
end
end
end
+
+ describe '.with_project_between_dates' do
+ subject do
+ described_class.with_project_between_dates(project, Date.new(2023, 2, 1), Date.new(2023, 3, 1))
+ end
+
+ it 'returns the correct number of results' do
+ expect(subject.size).to eq(2)
+ end
+ end
+
+ describe '.with_namespace_between_dates' do
+ subject do
+ described_class.with_namespace_between_dates(project.namespace, Date.new(2023, 2, 1), Date.new(2023, 3, 1))
+ end
+
+ it 'returns the correct number of results' do
+ expect(subject.select(:namespace_id).to_a.size).to eq(2)
+ end
+ end
end
describe '.beginning_of_month' do
diff --git a/spec/models/projects/forks/details_spec.rb b/spec/models/projects/forks/details_spec.rb
new file mode 100644
index 00000000000..4c0a2e3453a
--- /dev/null
+++ b/spec/models/projects/forks/details_spec.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Forks::Details, feature_category: :source_code_management do
+ include ExclusiveLeaseHelpers
+ include ProjectForksHelper
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:source_repo) { create(:project, :repository, :public).repository }
+ let_it_be(:fork_repo) { fork_project(source_repo.project, user, { repository: true }).repository }
+
+ let(:fork_branch) { 'fork-branch' }
+ let(:cache_key) { ['project_fork_details', fork_repo.project.id, fork_branch].join(':') }
+
+ describe '#counts', :use_clean_rails_redis_caching do
+ def expect_cached_counts(value)
+ counts = described_class.new(fork_repo.project, fork_branch).counts
+
+ ahead, behind = value
+ expect(counts).to eq({ ahead: ahead, behind: behind })
+
+ cached_value = {
+ source_sha: source_repo.commit.sha,
+ sha: fork_repo.commit(fork_branch).sha,
+ counts: value
+ }
+ expect(Rails.cache.read(cache_key)).to eq(cached_value)
+ end
+
+ it 'shows how far behind/ahead a fork is from the upstream' do
+ fork_repo.create_branch(fork_branch)
+
+ expect_cached_counts([0, 0])
+
+ fork_repo.commit_files(
+ user,
+ branch_name: fork_branch, message: 'Committing something',
+ actions: [{ action: :create, file_path: 'encoding/CHANGELOG', content: 'New file' }]
+ )
+
+ expect_cached_counts([1, 0])
+
+ fork_repo.commit_files(
+ user,
+ branch_name: fork_branch, message: 'Committing something else',
+ actions: [{ action: :create, file_path: 'encoding/ONE-MORE-CHANGELOG', content: 'One more new file' }]
+ )
+
+ expect_cached_counts([2, 0])
+
+ source_repo.commit_files(
+ user,
+ branch_name: source_repo.root_ref, message: 'Commit to root ref',
+ actions: [{ action: :create, file_path: 'encoding/CHANGELOG', content: 'One more' }]
+ )
+
+ expect_cached_counts([2, 1])
+
+ source_repo.commit_files(
+ user,
+ branch_name: source_repo.root_ref, message: 'Another commit to root ref',
+ actions: [{ action: :create, file_path: 'encoding/NEW-CHANGELOG', content: 'One more time' }]
+ )
+
+ expect_cached_counts([2, 2])
+
+ # When the fork is too far ahead
+ stub_const("#{described_class}::LATEST_COMMITS_COUNT", 1)
+ fork_repo.commit_files(
+ user,
+ branch_name: fork_branch, message: 'Another commit to fork',
+ actions: [{ action: :create, file_path: 'encoding/TOO-NEW-CHANGELOG', content: 'New file' }]
+ )
+
+ expect_cached_counts(nil)
+ end
+
+ context 'when counts calculated from a branch that exists upstream' do
+ let_it_be(:source_repo) { create(:project, :repository, :public).repository }
+ let_it_be(:fork_repo) { fork_project(source_repo.project, user, { repository: true }).repository }
+
+ let(:fork_branch) { 'feature' }
+
+ it 'compares the fork branch to upstream default branch' do
+ # The branch itself diverges from the upstream default branch
+ expect_cached_counts([1, 29])
+
+ source_repo.commit_files(
+ user,
+ branch_name: source_repo.root_ref, message: 'Commit to root ref',
+ actions: [{ action: :create, file_path: 'encoding/CHANGELOG', content: 'New file' }]
+ )
+
+ fork_repo.commit_files(
+ user,
+ branch_name: fork_branch, message: 'Committing to feature branch',
+ actions: [{ action: :create, file_path: 'encoding/FEATURE-BRANCH', content: 'New file' }]
+ )
+
+ # It takes into account diverged commits from upstream AND from fork
+ expect_cached_counts([2, 30])
+ end
+ end
+
+ context 'when specified branch does not exist' do
+ it 'returns nils as counts' do
+ counts = described_class.new(fork_repo.project, 'non-existent-branch').counts
+ expect(counts).to eq({ ahead: nil, behind: nil })
+ end
+ end
+ end
+
+ describe '#update!', :use_clean_rails_redis_caching do
+ it 'updates the cache with the specified value' do
+ value = { source_sha: source_repo.commit.sha, sha: fork_repo.commit.sha, counts: [0, 0], has_conflicts: true }
+
+ described_class.new(fork_repo.project, fork_branch).update!(value)
+
+ expect(Rails.cache.read(cache_key)).to eq(value)
+ end
+ end
+
+ describe '#has_conflicts', :use_clean_rails_redis_caching do
+ it 'returns whether merge for the stored commits failed due to conflicts' do
+ details = described_class.new(fork_repo.project, fork_branch)
+
+ expect do
+ value = { source_sha: source_repo.commit.sha, sha: fork_repo.commit.sha, counts: [0, 0], has_conflicts: true }
+
+ details.update!(value)
+ end.to change { details.has_conflicts? }.from(false).to(true)
+ end
+ end
+
+ describe '#exclusive_lease' do
+ it 'returns exclusive lease to the details' do
+ key = ['project_details', fork_repo.project.id, fork_branch].join(':')
+ uuid = SecureRandom.uuid
+ details = described_class.new(fork_repo.project, fork_branch)
+
+ expect(Gitlab::ExclusiveLease).to receive(:get_uuid).with(key).and_return(uuid)
+ expect(Gitlab::ExclusiveLease).to receive(:new).with(
+ key, uuid: uuid, timeout: described_class::LEASE_TIMEOUT
+ ).and_call_original
+
+ expect(details.exclusive_lease).to be_a(Gitlab::ExclusiveLease)
+ end
+ end
+
+ describe 'syncing?', :use_clean_rails_redis_caching do
+ it 'returns whether there is a sync in progress' do
+ details = described_class.new(fork_repo.project, fork_branch)
+
+ expect(details.exclusive_lease.try_obtain).to be_present
+ expect(details.syncing?).to eq(true)
+
+ details.exclusive_lease.cancel
+ expect(details.syncing?).to eq(false)
+ end
+ end
+end
diff --git a/spec/models/projects/forks/divergence_counts_spec.rb b/spec/models/projects/forks/divergence_counts_spec.rb
deleted file mode 100644
index fd69cc0f3e7..00000000000
--- a/spec/models/projects/forks/divergence_counts_spec.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::Forks::DivergenceCounts, feature_category: :source_code_management do
- include ProjectForksHelper
-
- let_it_be(:user) { create(:user) }
-
- describe '#counts', :use_clean_rails_redis_caching do
- let(:source_repo) { create(:project, :repository, :public).repository }
- let(:fork_repo) { fork_project(source_repo.project, user, { repository: true }).repository }
- let(:fork_branch) { 'fork-branch' }
- let(:cache_key) { ['project_forks', fork_repo.project.id, fork_branch, 'divergence_counts'] }
-
- def expect_cached_counts(value)
- counts = described_class.new(fork_repo.project, fork_branch).counts
-
- ahead, behind = value
- expect(counts).to eq({ ahead: ahead, behind: behind })
-
- cached_value = [source_repo.commit.sha, fork_repo.commit(fork_branch).sha, value]
- expect(Rails.cache.read(cache_key)).to eq(cached_value)
- end
-
- it 'shows how far behind/ahead a fork is from the upstream' do
- fork_repo.create_branch(fork_branch)
-
- expect_cached_counts([0, 0])
-
- fork_repo.commit_files(
- user,
- branch_name: fork_branch, message: 'Committing something',
- actions: [{ action: :create, file_path: 'encoding/CHANGELOG', content: 'New file' }]
- )
-
- expect_cached_counts([1, 0])
-
- fork_repo.commit_files(
- user,
- branch_name: fork_branch, message: 'Committing something else',
- actions: [{ action: :create, file_path: 'encoding/ONE-MORE-CHANGELOG', content: 'One more new file' }]
- )
-
- expect_cached_counts([2, 0])
-
- source_repo.commit_files(
- user,
- branch_name: source_repo.root_ref, message: 'Commit to root ref',
- actions: [{ action: :create, file_path: 'encoding/CHANGELOG', content: 'One more' }]
- )
-
- expect_cached_counts([2, 1])
-
- source_repo.commit_files(
- user,
- branch_name: source_repo.root_ref, message: 'Another commit to root ref',
- actions: [{ action: :create, file_path: 'encoding/NEW-CHANGELOG', content: 'One more time' }]
- )
-
- expect_cached_counts([2, 2])
-
- # When the fork is too far ahead
- stub_const("#{described_class}::LATEST_COMMITS_COUNT", 1)
- fork_repo.commit_files(
- user,
- branch_name: fork_branch, message: 'Another commit to fork',
- actions: [{ action: :create, file_path: 'encoding/TOO-NEW-CHANGELOG', content: 'New file' }]
- )
-
- expect_cached_counts(nil)
- end
-
- context 'when counts calculated from a branch that exists upstream' do
- let(:fork_branch) { 'feature' }
-
- it 'compares the fork branch to upstream default branch' do
- # The branch itself diverges from the upstream default branch
- expect_cached_counts([1, 29])
-
- source_repo.commit_files(
- user,
- branch_name: source_repo.root_ref, message: 'Commit to root ref',
- actions: [{ action: :create, file_path: 'encoding/CHANGELOG', content: 'New file' }]
- )
-
- fork_repo.commit_files(
- user,
- branch_name: fork_branch, message: 'Committing to feature branch',
- actions: [{ action: :create, file_path: 'encoding/FEATURE-BRANCH', content: 'New file' }]
- )
-
- # It takes into account diverged commits from upstream AND from fork
- expect_cached_counts([2, 30])
- end
- end
- end
-end
diff --git a/spec/models/projects/import_export/relation_export_spec.rb b/spec/models/projects/import_export/relation_export_spec.rb
index 8643fbc7b46..c724f30250d 100644
--- a/spec/models/projects/import_export/relation_export_spec.rb
+++ b/spec/models/projects/import_export/relation_export_spec.rb
@@ -2,9 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ImportExport::RelationExport, type: :model do
- subject { create(:project_relation_export) }
-
+RSpec.describe Projects::ImportExport::RelationExport, type: :model, feature_category: :importers do
describe 'associations' do
it { is_expected.to belong_to(:project_export_job) }
it { is_expected.to have_one(:upload) }
@@ -13,12 +11,16 @@ RSpec.describe Projects::ImportExport::RelationExport, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of(:project_export_job) }
it { is_expected.to validate_presence_of(:relation) }
- it { is_expected.to validate_uniqueness_of(:relation).scoped_to(:project_export_job_id) }
it { is_expected.to validate_presence_of(:status) }
it { is_expected.to validate_numericality_of(:status).only_integer }
it { is_expected.to validate_length_of(:relation).is_at_most(255) }
it { is_expected.to validate_length_of(:jid).is_at_most(255) }
it { is_expected.to validate_length_of(:export_error).is_at_most(300) }
+
+ it 'validates uniquness of the relation attribute' do
+ create(:project_relation_export)
+ expect(subject).to validate_uniqueness_of(:relation).scoped_to(:project_export_job_id)
+ end
end
describe '.by_relation' do
@@ -52,4 +54,16 @@ RSpec.describe Projects::ImportExport::RelationExport, type: :model do
expect(described_class.relation_names_list).not_to include('events', 'notes')
end
end
+
+ describe '#mark_as_failed' do
+ it 'sets status to failed and sets the export error', :aggregate_failures do
+ relation_export = create(:project_relation_export)
+
+ relation_export.mark_as_failed("Error message")
+ relation_export.reload
+
+ expect(relation_export.failed?).to eq(true)
+ expect(relation_export.export_error).to eq("Error message")
+ end
+ end
end
diff --git a/spec/models/protected_branch/merge_access_level_spec.rb b/spec/models/protected_branch/merge_access_level_spec.rb
index b6c2d527d1b..92aa5fa3eee 100644
--- a/spec/models/protected_branch/merge_access_level_spec.rb
+++ b/spec/models/protected_branch/merge_access_level_spec.rb
@@ -2,6 +2,7 @@
require 'spec_helper'
-RSpec.describe ProtectedBranch::MergeAccessLevel do
- it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) }
+RSpec.describe ProtectedBranch::MergeAccessLevel, feature_category: :source_code_management do
+ include_examples 'protected branch access'
+ include_examples 'protected ref access allowed_access_levels'
end
diff --git a/spec/models/protected_branch/push_access_level_spec.rb b/spec/models/protected_branch/push_access_level_spec.rb
index 008ae6275f0..e56ff2241b1 100644
--- a/spec/models/protected_branch/push_access_level_spec.rb
+++ b/spec/models/protected_branch/push_access_level_spec.rb
@@ -2,8 +2,9 @@
require 'spec_helper'
-RSpec.describe ProtectedBranch::PushAccessLevel do
- it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) }
+RSpec.describe ProtectedBranch::PushAccessLevel, feature_category: :source_code_management do
+ include_examples 'protected branch access'
+ include_examples 'protected ref access allowed_access_levels'
describe 'associations' do
it { is_expected.to belong_to(:deploy_key) }
diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb
index 71e22f848cc..d14a7dd1a7e 100644
--- a/spec/models/protected_branch_spec.rb
+++ b/spec/models/protected_branch_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProtectedBranch do
+RSpec.describe ProtectedBranch, feature_category: :source_code_management do
subject { build_stubbed(:protected_branch) }
describe 'Associations' do
@@ -13,6 +13,30 @@ RSpec.describe ProtectedBranch do
describe 'Validation' do
it { is_expected.to validate_presence_of(:name) }
+ context 'uniqueness' do
+ let(:protected_branch) { build(:protected_branch) }
+
+ subject { protected_branch }
+
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to([:project_id, :namespace_id]) }
+
+ context 'when the protected_branch was saved previously' do
+ before do
+ protected_branch.save!
+ end
+
+ it { is_expected.not_to validate_uniqueness_of(:name) }
+
+ context 'and name is changed' do
+ before do
+ protected_branch.name = "#{protected_branch.name} + something else"
+ end
+
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to([:project_id, :namespace_id]) }
+ end
+ end
+ end
+
describe '#validate_either_project_or_top_group' do
context 'when protected branch does not have project or group association' do
it 'validate failed' do
@@ -214,85 +238,52 @@ RSpec.describe ProtectedBranch do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:protected_branch) { create(:protected_branch, project: project, name: "“jawn”") }
- let(:rely_on_new_cache) { true }
-
- shared_examples_for 'hash based cache implementation' do
- it 'calls only hash based cache implementation' do
- expect_next_instance_of(ProtectedBranches::CacheService) do |instance|
- expect(instance).to receive(:fetch).with('missing-branch', anything).and_call_original
- end
-
- expect(Rails.cache).not_to receive(:fetch)
-
- described_class.protected?(project, 'missing-branch')
- end
- end
-
before do
- stub_feature_flags(rely_on_protected_branches_cache: rely_on_new_cache)
allow(described_class).to receive(:matching).and_call_original
# the original call works and warms the cache
described_class.protected?(project, protected_branch.name)
end
- context 'Dry-run: true (rely_on_protected_branches_cache is off, new hash-based is used)' do
- let(:rely_on_new_cache) { false }
+ it 'correctly invalidates a cache' do
+ expect(described_class).to receive(:matching).with(protected_branch.name, protected_refs: anything).exactly(3).times.and_call_original
- it 'recalculates a fresh value every time in order to check the cache is not returning stale data' do
- expect(described_class).to receive(:matching).with(protected_branch.name, protected_refs: anything).twice
+ create_params = { name: 'bar', merge_access_levels_attributes: [{ access_level: Gitlab::Access::DEVELOPER }] }
+ branch = ProtectedBranches::CreateService.new(project, project.owner, create_params).execute
+ expect(described_class.protected?(project, protected_branch.name)).to eq(true)
- 2.times { described_class.protected?(project, protected_branch.name) }
- end
+ ProtectedBranches::UpdateService.new(project, project.owner, name: 'ber').execute(branch)
+ expect(described_class.protected?(project, protected_branch.name)).to eq(true)
- it_behaves_like 'hash based cache implementation'
+ ProtectedBranches::DestroyService.new(project, project.owner).execute(branch)
+ expect(described_class.protected?(project, protected_branch.name)).to eq(true)
end
- context 'Dry-run: false (rely_on_protected_branches_cache is enabled, new hash-based cache is used)' do
- let(:rely_on_new_cache) { true }
-
- it 'correctly invalidates a cache' do
- expect(described_class).to receive(:matching).with(protected_branch.name, protected_refs: anything).exactly(3).times.and_call_original
-
- create_params = { name: 'bar', merge_access_levels_attributes: [{ access_level: Gitlab::Access::DEVELOPER }] }
- branch = ProtectedBranches::CreateService.new(project, project.owner, create_params).execute
- expect(described_class.protected?(project, protected_branch.name)).to eq(true)
-
- ProtectedBranches::UpdateService.new(project, project.owner, name: 'ber').execute(branch)
- expect(described_class.protected?(project, protected_branch.name)).to eq(true)
-
- ProtectedBranches::DestroyService.new(project, project.owner).execute(branch)
- expect(described_class.protected?(project, protected_branch.name)).to eq(true)
- end
-
- it_behaves_like 'hash based cache implementation'
-
- context 'when project is updated' do
- it 'does not invalidate a cache' do
- expect(described_class).not_to receive(:matching).with(protected_branch.name, protected_refs: anything)
+ context 'when project is updated' do
+ it 'does not invalidate a cache' do
+ expect(described_class).not_to receive(:matching).with(protected_branch.name, protected_refs: anything)
- project.touch
+ project.touch
- described_class.protected?(project, protected_branch.name)
- end
+ described_class.protected?(project, protected_branch.name)
end
+ end
- context 'when other project protected branch is updated' do
- it 'does not invalidate the current project cache' do
- expect(described_class).not_to receive(:matching).with(protected_branch.name, protected_refs: anything)
+ context 'when other project protected branch is updated' do
+ it 'does not invalidate the current project cache' do
+ expect(described_class).not_to receive(:matching).with(protected_branch.name, protected_refs: anything)
- another_project = create(:project)
- ProtectedBranches::CreateService.new(another_project, another_project.owner, name: 'bar').execute
+ another_project = create(:project)
+ ProtectedBranches::CreateService.new(another_project, another_project.owner, name: 'bar').execute
- described_class.protected?(project, protected_branch.name)
- end
+ described_class.protected?(project, protected_branch.name)
end
+ end
- it 'correctly uses the cached version' do
- expect(described_class).not_to receive(:matching)
+ it 'correctly uses the cached version' do
+ expect(described_class).not_to receive(:matching)
- expect(described_class.protected?(project, protected_branch.name)).to eq(true)
- end
+ expect(described_class.protected?(project, protected_branch.name)).to eq(true)
end
end
end
@@ -344,6 +335,7 @@ RSpec.describe ProtectedBranch do
context "when feature flag disabled" do
before do
stub_feature_flags(group_protected_branches: false)
+ stub_feature_flags(allow_protected_branches_for_group: false)
end
let(:subject_branch) { create(:protected_branch, allow_force_push: allow_force_push, name: "foo") }
@@ -383,6 +375,7 @@ RSpec.describe ProtectedBranch do
with_them do
before do
stub_feature_flags(group_protected_branches: true)
+ stub_feature_flags(allow_protected_branches_for_group: true)
unless group_level_value.nil?
create(:protected_branch, allow_force_push: group_level_value, name: "foo", project: nil, group: group)
@@ -436,6 +429,7 @@ RSpec.describe ProtectedBranch do
context 'when feature flag enabled' do
before do
stub_feature_flags(group_protected_branches: true)
+ stub_feature_flags(allow_protected_branches_for_group: true)
end
it 'call `all_protected_branches`' do
@@ -448,6 +442,7 @@ RSpec.describe ProtectedBranch do
context 'when feature flag disabled' do
before do
stub_feature_flags(group_protected_branches: false)
+ stub_feature_flags(allow_protected_branches_for_group: false)
end
it 'call `protected_branches`' do
@@ -458,6 +453,62 @@ RSpec.describe ProtectedBranch do
end
end
+ describe '.protected_ref_accessible_to?' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:owner) { create(:user) }
+ let_it_be(:admin) { create(:user, :admin) }
+
+ before do
+ project.add_guest(guest)
+ project.add_reporter(reporter)
+ project.add_developer(developer)
+ project.add_maintainer(maintainer)
+ project.add_owner(owner)
+ end
+
+ subject { described_class.protected_ref_accessible_to?(anything, current_user, project: project, action: :push) }
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'with developer' do
+ let(:current_user) { developer }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'with maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'with owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'with admin' do
+ let(:current_user) { admin }
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
describe '.by_name' do
let!(:protected_branch) { create(:protected_branch, name: 'master') }
let!(:another_protected_branch) { create(:protected_branch, name: 'stable') }
diff --git a/spec/models/protected_tag/create_access_level_spec.rb b/spec/models/protected_tag/create_access_level_spec.rb
index 566f8695388..8eeccdc9b34 100644
--- a/spec/models/protected_tag/create_access_level_spec.rb
+++ b/spec/models/protected_tag/create_access_level_spec.rb
@@ -3,6 +3,9 @@
require 'spec_helper'
RSpec.describe ProtectedTag::CreateAccessLevel, feature_category: :source_code_management do
+ include_examples 'protected tag access'
+ include_examples 'protected ref access allowed_access_levels'
+
describe 'associations' do
it { is_expected.to belong_to(:deploy_key) }
end
@@ -10,16 +13,6 @@ RSpec.describe ProtectedTag::CreateAccessLevel, feature_category: :source_code_m
describe 'validations', :aggregate_failures do
let_it_be(:protected_tag) { create(:protected_tag) }
- it 'verifies access levels' do
- is_expected.to validate_inclusion_of(:access_level).in_array(
- [
- Gitlab::Access::MAINTAINER,
- Gitlab::Access::DEVELOPER,
- Gitlab::Access::NO_ACCESS
- ]
- )
- end
-
context 'when deploy key enabled for the project' do
let(:deploy_key) { create(:deploy_key, projects: [protected_tag.project]) }
diff --git a/spec/models/releases/link_spec.rb b/spec/models/releases/link_spec.rb
index 4910de61c22..c4c9fba32d9 100644
--- a/spec/models/releases/link_spec.rb
+++ b/spec/models/releases/link_spec.rb
@@ -80,15 +80,6 @@ RSpec.describe Releases::Link do
end
end
- describe '#external?' do
- subject { link.external? }
-
- let(:link) { build(:release_link, release: release, url: url) }
- let(:url) { 'https://google.com/-/jobs/140463678/artifacts/download' }
-
- it { is_expected.to be_truthy }
- end
-
describe 'supported protocols' do
where(:protocol) do
%w(http https ftp)
diff --git a/spec/models/releases/source_spec.rb b/spec/models/releases/source_spec.rb
index 227085951c0..fadc2fd6f53 100644
--- a/spec/models/releases/source_spec.rb
+++ b/spec/models/releases/source_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Releases::Source do
- let_it_be(:project) { create(:project, :repository, name: 'finance-cal') }
+ let_it_be(:project) { create(:project, :repository) }
let(:tag_name) { 'v1.0' }
@@ -27,7 +27,7 @@ RSpec.describe Releases::Source do
it 'returns zip archived source url' do
is_expected
- .to eq("#{project.web_url}/-/archive/v1.0/finance-cal-v1.0.zip")
+ .to eq("#{project.web_url}/-/archive/v1.0/#{project.path}-v1.0.zip")
end
context 'when ref is directory structure' do
@@ -35,7 +35,7 @@ RSpec.describe Releases::Source do
it 'converts slash to dash' do
is_expected
- .to eq("#{project.web_url}/-/archive/beta/v1.0/finance-cal-beta-v1.0.zip")
+ .to eq("#{project.web_url}/-/archive/beta/v1.0/#{project.path}-beta-v1.0.zip")
end
end
end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 44fcf87526a..aa2ac52a9ab 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -20,10 +20,12 @@ RSpec.describe Repository, feature_category: :source_code_management do
let(:merge_commit) do
merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project)
- merge_commit_id = repository.merge(user,
- merge_request.diff_head_sha,
- merge_request,
- message)
+ merge_commit_id = repository.merge(
+ user,
+ merge_request.diff_head_sha,
+ merge_request,
+ message
+ )
repository.commit(merge_commit_id)
end
@@ -413,6 +415,27 @@ RSpec.describe Repository, feature_category: :source_code_management do
repository.commits('master', limit: 1)
end
end
+
+ context 'when include_referenced_by is passed' do
+ context 'when commit has references' do
+ let(:ref) { '5937ac0a7beb003549fc5fd26fc247adbce4a52e' }
+ let(:include_referenced_by) { ['refs/tags'] }
+
+ subject { repository.commits(ref, limit: 1, include_referenced_by: include_referenced_by).first }
+
+ it 'returns commits with referenced_by excluding that match the patterns' do
+ expect(subject.referenced_by).to match_array(['refs/tags/v1.1.0'])
+ end
+
+ context 'when matching multiple references' do
+ let(:include_referenced_by) { ['refs/tags', 'refs/heads'] }
+
+ it 'returns commits with referenced_by that match the patterns' do
+ expect(subject.referenced_by).to match_array(['refs/tags/v1.1.0', 'refs/heads/improve/awesome', 'refs/heads/merge-test'])
+ end
+ end
+ end
+ end
end
context "when 'author' is set" do
@@ -576,6 +599,15 @@ RSpec.describe Repository, feature_category: :source_code_management do
end
describe '#list_commits_by' do
+ it 'returns commits when no filter is applied' do
+ commit_ids = repository.list_commits_by(nil, 'master', limit: 2).map(&:id)
+
+ expect(commit_ids).to include(
+ 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
+ '498214de67004b1da3d820901307bed2a68a8ef6'
+ )
+ end
+
it 'returns commits with messages containing a given string' do
commit_ids = repository.list_commits_by('test text', 'master').map(&:id)
@@ -682,6 +714,14 @@ RSpec.describe Repository, feature_category: :source_code_management do
it { is_expected.to be_nil }
end
+
+ context 'when root reference is empty' do
+ subject { empty_repo.merged_to_root_ref?('master') }
+
+ let(:empty_repo) { build(:project, :empty_repo).repository }
+
+ it { is_expected.to be_nil }
+ end
end
describe "#root_ref_sha" do
@@ -690,7 +730,7 @@ RSpec.describe Repository, feature_category: :source_code_management do
subject { repository.root_ref_sha }
before do
- allow(repository).to receive(:commit).with(repository.root_ref) { commit }
+ allow(repository).to receive(:head_commit) { commit }
end
it { is_expected.to eq(commit.sha) }
@@ -925,9 +965,11 @@ RSpec.describe Repository, feature_category: :source_code_management do
describe "#create_file" do
it 'commits new file successfully' do
expect do
- repository.create_file(user, 'NEWCHANGELOG', 'Changelog!',
- message: 'Create changelog',
- branch_name: 'master')
+ repository.create_file(
+ user, 'NEWCHANGELOG', 'Changelog!',
+ message: 'Create changelog',
+ branch_name: 'master'
+ )
end.to change { repository.count_commits(ref: 'master') }.by(1)
blob = repository.blob_at('master', 'NEWCHANGELOG')
@@ -937,9 +979,11 @@ RSpec.describe Repository, feature_category: :source_code_management do
it 'creates new file and dir when file_path has a forward slash' do
expect do
- repository.create_file(user, 'new_dir/new_file.txt', 'File!',
- message: 'Create new_file with new_dir',
- branch_name: 'master')
+ repository.create_file(
+ user, 'new_dir/new_file.txt', 'File!',
+ message: 'Create new_file with new_dir',
+ branch_name: 'master'
+ )
end.to change { repository.count_commits(ref: 'master') }.by(1)
expect(repository.tree('master', 'new_dir').path).to eq('new_dir')
@@ -947,9 +991,11 @@ RSpec.describe Repository, feature_category: :source_code_management do
end
it 'respects the autocrlf setting' do
- repository.create_file(user, 'hello.txt', "Hello,\r\nWorld",
- message: 'Add hello world',
- branch_name: 'master')
+ repository.create_file(
+ user, 'hello.txt', "Hello,\r\nWorld",
+ message: 'Add hello world',
+ branch_name: 'master'
+ )
blob = repository.blob_at('master', 'hello.txt')
@@ -959,11 +1005,13 @@ RSpec.describe Repository, feature_category: :source_code_management do
context "when an author is specified" do
it "uses the given email/name to set the commit's author" do
expect do
- repository.create_file(user, 'NEWREADME', 'README!',
- message: 'Add README',
- branch_name: 'master',
- author_email: author_email,
- author_name: author_name)
+ repository.create_file(
+ user, 'NEWREADME', 'README!',
+ message: 'Add README',
+ branch_name: 'master',
+ author_email: author_email,
+ author_name: author_name
+ )
end.to change { repository.count_commits(ref: 'master') }.by(1)
last_commit = repository.commit
@@ -977,9 +1025,11 @@ RSpec.describe Repository, feature_category: :source_code_management do
describe "#update_file" do
it 'updates file successfully' do
expect do
- repository.update_file(user, 'CHANGELOG', 'Changelog!',
- message: 'Update changelog',
- branch_name: 'master')
+ repository.update_file(
+ user, 'CHANGELOG', 'Changelog!',
+ message: 'Update changelog',
+ branch_name: 'master'
+ )
end.to change { repository.count_commits(ref: 'master') }.by(1)
blob = repository.blob_at('master', 'CHANGELOG')
@@ -989,10 +1039,12 @@ RSpec.describe Repository, feature_category: :source_code_management do
it 'updates filename successfully' do
expect do
- repository.update_file(user, 'NEWLICENSE', 'Copyright!',
- branch_name: 'master',
- previous_path: 'LICENSE',
- message: 'Changes filename')
+ repository.update_file(
+ user, 'NEWLICENSE', 'Copyright!',
+ branch_name: 'master',
+ previous_path: 'LICENSE',
+ message: 'Changes filename'
+ )
end.to change { repository.count_commits(ref: 'master') }.by(1)
files = repository.ls_files('master')
@@ -1004,12 +1056,14 @@ RSpec.describe Repository, feature_category: :source_code_management do
context "when an author is specified" do
it "uses the given email/name to set the commit's author" do
expect do
- repository.update_file(user, 'README', 'Updated README!',
- branch_name: 'master',
- previous_path: 'README',
- message: 'Update README',
- author_email: author_email,
- author_name: author_name)
+ repository.update_file(
+ user, 'README', 'Updated README!',
+ branch_name: 'master',
+ previous_path: 'README',
+ message: 'Update README',
+ author_email: author_email,
+ author_name: author_name
+ )
end.to change { repository.count_commits(ref: 'master') }.by(1)
last_commit = repository.commit
@@ -1020,13 +1074,45 @@ RSpec.describe Repository, feature_category: :source_code_management do
end
end
+ describe "#move_dir_files" do
+ it 'move directory files successfully' do
+ expect do
+ repository.move_dir_files(
+ user, 'files/new_js', 'files/js',
+ branch_name: 'master',
+ message: 'move directory images to new_images',
+ author_email: author_email,
+ author_name: author_name
+ )
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
+ files = repository.ls_files('master')
+
+ expect(files).not_to include('files/js/application.js')
+ expect(files).to include('files/new_js/application.js')
+ end
+
+ it 'skips commit with same path' do
+ expect do
+ repository.move_dir_files(
+ user, 'files/js', 'files/js',
+ branch_name: 'master',
+ message: 'no commit',
+ author_email: author_email,
+ author_name: author_name
+ )
+ end.to change { repository.count_commits(ref: 'master') }.by(0)
+ end
+ end
+
describe "#delete_file" do
- let(:project) { create(:project, :repository) }
+ let_it_be(:project) { create(:project, :repository) }
it 'removes file successfully' do
expect do
- repository.delete_file(user, 'README',
- message: 'Remove README', branch_name: 'master')
+ repository.delete_file(
+ user, 'README',
+ message: 'Remove README', branch_name: 'master'
+ )
end.to change { repository.count_commits(ref: 'master') }.by(1)
expect(repository.blob_at('master', 'README')).to be_nil
@@ -1035,9 +1121,11 @@ RSpec.describe Repository, feature_category: :source_code_management do
context "when an author is specified" do
it "uses the given email/name to set the commit's author" do
expect do
- repository.delete_file(user, 'README',
+ repository.delete_file(
+ user, 'README',
message: 'Remove README', branch_name: 'master',
- author_email: author_email, author_name: author_name)
+ author_email: author_email, author_name: author_name
+ )
end.to change { repository.count_commits(ref: 'master') }.by(1)
last_commit = repository.commit
@@ -1155,10 +1243,12 @@ RSpec.describe Repository, feature_category: :source_code_management do
let(:path) { '*.md' }
it 'returns files matching the path in the root folder' do
- expect(result).to contain_exactly('CONTRIBUTING.md',
- 'MAINTENANCE.md',
- 'PROCESS.md',
- 'README.md')
+ expect(result).to contain_exactly(
+ 'CONTRIBUTING.md',
+ 'MAINTENANCE.md',
+ 'PROCESS.md',
+ 'README.md'
+ )
end
end
@@ -1166,12 +1256,14 @@ RSpec.describe Repository, feature_category: :source_code_management do
let(:path) { '**.md' }
it 'returns all matching files in all folders' do
- expect(result).to contain_exactly('CONTRIBUTING.md',
- 'MAINTENANCE.md',
- 'PROCESS.md',
- 'README.md',
- 'files/markdown/ruby-style-guide.md',
- 'with space/README.md')
+ expect(result).to contain_exactly(
+ 'CONTRIBUTING.md',
+ 'MAINTENANCE.md',
+ 'PROCESS.md',
+ 'README.md',
+ 'files/markdown/ruby-style-guide.md',
+ 'with space/README.md'
+ )
end
end
@@ -1203,10 +1295,12 @@ RSpec.describe Repository, feature_category: :source_code_management do
let(:path) { '**/*.rb' }
it 'returns all matched files in all subfolders' do
- expect(result).to contain_exactly('encoding/russian.rb',
- 'files/ruby/popen.rb',
- 'files/ruby/regex.rb',
- 'files/ruby/version_info.rb')
+ expect(result).to contain_exactly(
+ 'encoding/russian.rb',
+ 'files/ruby/popen.rb',
+ 'files/ruby/regex.rb',
+ 'files/ruby/version_info.rb'
+ )
end
end
@@ -1422,46 +1516,47 @@ RSpec.describe Repository, feature_category: :source_code_management do
end
end
- [true, false].each do |ff|
- context "with feature flag license_from_gitaly=#{ff}" do
- before do
- stub_feature_flags(license_from_gitaly: ff)
- end
-
- describe '#license', :use_clean_rails_memory_store_caching, :clean_gitlab_redis_cache do
- let(:project) { create(:project, :repository) }
+ describe '#license', :use_clean_rails_memory_store_caching, :clean_gitlab_redis_cache do
+ let(:project) { create(:project, :repository) }
- before do
- repository.delete_file(user, 'LICENSE',
- message: 'Remove LICENSE', branch_name: 'master')
- end
+ before do
+ repository.delete_file(
+ user, 'LICENSE',
+ message: 'Remove LICENSE',
+ branch_name: 'master'
+ )
+ end
- it 'returns nil when no license is detected' do
- expect(repository.license).to be_nil
- end
+ it 'returns nil when no license is detected' do
+ expect(repository.license).to be_nil
+ end
- it 'returns nil when the repository does not exist' do
- expect(repository).to receive(:exists?).and_return(false)
+ it 'returns nil when the repository does not exist' do
+ expect(repository).to receive(:exists?).and_return(false)
- expect(repository.license).to be_nil
- end
+ expect(repository.license).to be_nil
+ end
- it 'returns other when the content is not recognizable' do
- repository.create_file(user, 'LICENSE', 'Gitlab B.V.',
- message: 'Add LICENSE', branch_name: 'master')
+ it 'returns other when the content is not recognizable' do
+ repository.create_file(
+ user, 'LICENSE', 'Gitlab B.V.',
+ message: 'Add LICENSE',
+ branch_name: 'master'
+ )
- expect(repository.license_key).to eq('other')
- end
+ expect(repository.license_key).to eq('other')
+ end
- it 'returns the license' do
- license = Licensee::License.new('mit')
- repository.create_file(user, 'LICENSE',
- license.content,
- message: 'Add LICENSE', branch_name: 'master')
+ it 'returns the license' do
+ license = Licensee::License.new('mit')
+ repository.create_file(
+ user, 'LICENSE',
+ license.content,
+ message: 'Add LICENSE',
+ branch_name: 'master'
+ )
- expect(repository.license_key).to eq(license.key)
- end
- end
+ expect(repository.license_key).to eq(license.key)
end
end
@@ -1987,19 +2082,23 @@ RSpec.describe Repository, feature_category: :source_code_management do
describe '#merge_to_ref' do
let(:merge_request) do
- create(:merge_request, source_branch: 'feature',
- target_branch: 'master',
- source_project: project)
+ create(
+ :merge_request,
+ source_branch: 'feature',
+ target_branch: 'master',
+ source_project: project
+ )
end
it 'writes merge of source SHA and first parent ref to MR merge_ref_path' do
- merge_commit_id =
- repository.merge_to_ref(user,
- source_sha: merge_request.diff_head_sha,
- branch: merge_request.target_branch,
- target_ref: merge_request.merge_ref_path,
- message: 'Custom message',
- first_parent_ref: merge_request.target_branch_ref)
+ merge_commit_id = repository.merge_to_ref(
+ user,
+ source_sha: merge_request.diff_head_sha,
+ branch: merge_request.target_branch,
+ target_ref: merge_request.merge_ref_path,
+ message: 'Custom message',
+ first_parent_ref: merge_request.target_branch_ref
+ )
merge_commit = repository.commit(merge_commit_id)
@@ -2021,11 +2120,13 @@ RSpec.describe Repository, feature_category: :source_code_management do
end
it 'merges the code and return the commit id' do
- merge_commit_id = repository.ff_merge(user,
- merge_request.diff_head_sha,
- merge_request.target_branch,
- target_sha: repository.commit(merge_request.target_branch).sha,
- merge_request: merge_request)
+ merge_commit_id = repository.ff_merge(
+ user,
+ merge_request.diff_head_sha,
+ merge_request.target_branch,
+ target_sha: repository.commit(merge_request.target_branch).sha,
+ merge_request: merge_request
+ )
merge_commit = repository.commit(merge_commit_id)
expect(merge_commit).to be_present
@@ -2033,11 +2134,13 @@ RSpec.describe Repository, feature_category: :source_code_management do
end
it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do
- merge_commit_id = repository.ff_merge(user,
- merge_request.diff_head_sha,
- merge_request.target_branch,
- target_sha: repository.commit(merge_request.target_branch).sha,
- merge_request: merge_request)
+ merge_commit_id = repository.ff_merge(
+ user,
+ merge_request.diff_head_sha,
+ merge_request.target_branch,
+ target_sha: repository.commit(merge_request.target_branch).sha,
+ merge_request: merge_request
+ )
expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id)
end
@@ -2302,7 +2405,6 @@ RSpec.describe Repository, feature_category: :source_code_management do
:contribution_guide,
:changelog,
:license_blob,
- :license_licensee,
:license_gitaly,
:gitignore,
:gitlab_ci_yml,
@@ -2957,11 +3059,10 @@ RSpec.describe Repository, feature_category: :source_code_management do
describe '#refresh_method_caches' do
it 'refreshes the caches of the given types' do
expect(repository).to receive(:expire_method_caches)
- .with(%i(readme_path license_blob license_licensee license_gitaly))
+ .with(%i(readme_path license_blob license_gitaly))
expect(repository).to receive(:readme_path)
expect(repository).to receive(:license_blob)
- expect(repository).to receive(:license_licensee)
expect(repository).to receive(:license_gitaly)
repository.refresh_method_caches(%i(readme license))
diff --git a/spec/models/resource_events/abuse_report_event_spec.rb b/spec/models/resource_events/abuse_report_event_spec.rb
new file mode 100644
index 00000000000..1c709ae4f21
--- /dev/null
+++ b/spec/models/resource_events/abuse_report_event_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ResourceEvents::AbuseReportEvent, feature_category: :instance_resiliency, type: :model do
+ subject(:event) { build(:abuse_report_event) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:abuse_report).required }
+ it { is_expected.to belong_to(:user).optional }
+ end
+
+ describe 'validations' do
+ it { is_expected.to be_valid }
+ it { is_expected.to validate_presence_of(:action) }
+ end
+end
diff --git a/spec/models/resource_events/issue_assignment_event_spec.rb b/spec/models/resource_events/issue_assignment_event_spec.rb
new file mode 100644
index 00000000000..bc217da2812
--- /dev/null
+++ b/spec/models/resource_events/issue_assignment_event_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ResourceEvents::IssueAssignmentEvent, feature_category: :value_stream_management, type: :model do
+ subject(:event) { build(:issue_assignment_event) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:user) }
+ it { is_expected.to belong_to(:issue) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to be_valid }
+ it { is_expected.to validate_presence_of(:issue) }
+ end
+end
diff --git a/spec/models/resource_events/merge_request_assignment_event_spec.rb b/spec/models/resource_events/merge_request_assignment_event_spec.rb
new file mode 100644
index 00000000000..15f4c088333
--- /dev/null
+++ b/spec/models/resource_events/merge_request_assignment_event_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ResourceEvents::MergeRequestAssignmentEvent, feature_category: :value_stream_management, type: :model do
+ subject(:event) { build(:merge_request_assignment_event) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:user) }
+ it { is_expected.to belong_to(:merge_request) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to be_valid }
+ it { is_expected.to validate_presence_of(:merge_request) }
+ end
+end
diff --git a/spec/models/resource_state_event_spec.rb b/spec/models/resource_state_event_spec.rb
index a6d6b507b69..de101107268 100644
--- a/spec/models/resource_state_event_spec.rb
+++ b/spec/models/resource_state_event_spec.rb
@@ -45,8 +45,11 @@ RSpec.describe ResourceStateEvent, feature_category: :team_planning, type: :mode
describe '#issue_usage_metrics' do
describe 'when an issue is closed' do
subject(:close_issue) do
- create(described_class.name.underscore.to_sym, issue: issue,
- state: described_class.states[:closed])
+ create(
+ described_class.name.underscore.to_sym,
+ issue: issue,
+ state: described_class.states[:closed]
+ )
end
it 'tracks closed issues' do
@@ -65,8 +68,11 @@ RSpec.describe ResourceStateEvent, feature_category: :team_planning, type: :mode
describe 'when an issue is reopened' do
subject(:reopen_issue) do
- create(described_class.name.underscore.to_sym, issue: issue,
- state: described_class.states[:reopened])
+ create(
+ described_class.name.underscore.to_sym,
+ issue: issue,
+ state: described_class.states[:reopened]
+ )
end
it 'tracks reopened issues' do
diff --git a/spec/models/serverless/domain_cluster_spec.rb b/spec/models/serverless/domain_cluster_spec.rb
deleted file mode 100644
index 487385c62c1..00000000000
--- a/spec/models/serverless/domain_cluster_spec.rb
+++ /dev/null
@@ -1,75 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ::Serverless::DomainCluster do
- subject { create(:serverless_domain_cluster) }
-
- describe 'default values' do
- subject(:domain_cluster) { build(:serverless_domain_cluster) }
-
- before do
- allow(::Serverless::Domain).to receive(:generate_uuid).and_return('randomtoken')
- end
-
- it { expect(domain_cluster.uuid).to eq('randomtoken') }
- end
-
- describe 'validations' do
- it { is_expected.to validate_presence_of(:pages_domain) }
- it { is_expected.to validate_presence_of(:knative) }
-
- it { is_expected.to validate_presence_of(:uuid) }
- it { is_expected.to validate_length_of(:uuid).is_equal_to(::Serverless::Domain::UUID_LENGTH) }
- it { is_expected.to validate_uniqueness_of(:uuid) }
-
- it 'validates that uuid has only hex characters' do
- subject = build(:serverless_domain_cluster, uuid: 'z1234567890123')
- subject.valid?
-
- expect(subject.errors[:uuid]).to include('only allows hex characters')
- end
- end
-
- describe 'associations' do
- it { is_expected.to belong_to(:pages_domain) }
- it { is_expected.to belong_to(:knative) }
- it { is_expected.to belong_to(:creator).optional }
- end
-
- describe 'uuid' do
- context 'when nil' do
- it 'generates a value by default' do
- attributes = build(:serverless_domain_cluster).attributes.merge(uuid: nil)
- expect(::Serverless::Domain).to receive(:generate_uuid).and_call_original
-
- subject = Serverless::DomainCluster.new(attributes)
-
- expect(subject.uuid).not_to be_blank
- end
- end
-
- context 'when not nil' do
- it 'does not override the existing value' do
- uuid = 'abcd1234567890'
- expect(build(:serverless_domain_cluster, uuid: uuid).uuid).to eq(uuid)
- end
- end
- end
-
- describe 'cluster' do
- it { is_expected.to respond_to(:cluster) }
- end
-
- describe 'domain' do
- it { is_expected.to respond_to(:domain) }
- end
-
- describe 'certificate' do
- it { is_expected.to respond_to(:certificate) }
- end
-
- describe 'key' do
- it { is_expected.to respond_to(:key) }
- end
-end
diff --git a/spec/models/serverless/domain_spec.rb b/spec/models/serverless/domain_spec.rb
deleted file mode 100644
index f997b28b149..00000000000
--- a/spec/models/serverless/domain_spec.rb
+++ /dev/null
@@ -1,97 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ::Serverless::Domain do
- let(:function_name) { 'test-function' }
- let(:pages_domain_name) { 'serverless.gitlab.io' }
- let(:pages_domain) { create(:pages_domain, :instance_serverless, domain: pages_domain_name) }
- let!(:serverless_domain_cluster) { create(:serverless_domain_cluster, uuid: 'abcdef12345678', pages_domain: pages_domain) }
- let(:valid_cluster_uuid) { 'aba1cdef123456f278' }
- let(:invalid_cluster_uuid) { 'aba1cdef123456f178' }
- let!(:environment) { create(:environment, name: 'test') }
-
- let(:valid_uri) { "https://#{function_name}-#{valid_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{pages_domain_name}" }
- let(:valid_fqdn) { "#{function_name}-#{valid_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{pages_domain_name}" }
- let(:invalid_uri) { "https://#{function_name}-#{invalid_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{pages_domain_name}" }
-
- shared_examples 'a valid Domain' do
- describe '#uri' do
- it 'matches valid URI' do
- expect(subject.uri.to_s).to eq valid_uri
- end
- end
-
- describe '#function_name' do
- it 'returns function_name' do
- expect(subject.function_name).to eq function_name
- end
- end
-
- describe '#serverless_domain_cluster' do
- it 'returns serverless_domain_cluster' do
- expect(subject.serverless_domain_cluster).to eq serverless_domain_cluster
- end
- end
-
- describe '#environment' do
- it 'returns environment' do
- expect(subject.environment).to eq environment
- end
- end
- end
-
- describe '.new' do
- context 'with valid arguments' do
- subject do
- described_class.new(
- function_name: function_name,
- serverless_domain_cluster: serverless_domain_cluster,
- environment: environment
- )
- end
-
- it_behaves_like 'a valid Domain'
- end
-
- context 'with invalid arguments' do
- subject do
- described_class.new(
- function_name: function_name,
- environment: environment
- )
- end
-
- it { is_expected.not_to be_valid }
- end
-
- context 'with nil cluster argument' do
- subject do
- described_class.new(
- function_name: function_name,
- serverless_domain_cluster: nil,
- environment: environment
- )
- end
-
- it { is_expected.not_to be_valid }
- end
- end
-
- describe '.generate_uuid' do
- it 'has 14 characters' do
- expect(described_class.generate_uuid.length).to eq(described_class::UUID_LENGTH)
- end
-
- it 'consists of only hexadecimal characters' do
- expect(described_class.generate_uuid).to match(/\A\h+\z/)
- end
-
- it 'uses random characters' do
- uuid = 'abcd1234567890'
-
- expect(SecureRandom).to receive(:hex).with(described_class::UUID_LENGTH / 2).and_return(uuid)
- expect(described_class.generate_uuid).to eq(uuid)
- end
- end
-end
diff --git a/spec/models/serverless/function_spec.rb b/spec/models/serverless/function_spec.rb
deleted file mode 100644
index 632f5eba5c3..00000000000
--- a/spec/models/serverless/function_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ::Serverless::Function do
- let(:project) { create(:project) }
- let(:func) { described_class.new(project, 'test', 'test-ns') }
-
- it 'has a proper id' do
- expect(func.id).to eql("#{project.id}/test/test-ns")
- expect(func.name).to eql("test")
- expect(func.namespace).to eql("test-ns")
- end
-
- it 'can decode an identifier' do
- f = described_class.find_by_id("#{project.id}/testfunc/dummy-ns")
-
- expect(f.name).to eql("testfunc")
- expect(f.namespace).to eql("dummy-ns")
- end
-end
diff --git a/spec/models/service_desk/custom_email_credential_spec.rb b/spec/models/service_desk/custom_email_credential_spec.rb
new file mode 100644
index 00000000000..a990b77128e
--- /dev/null
+++ b/spec/models/service_desk/custom_email_credential_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ServiceDesk::CustomEmailCredential, feature_category: :service_desk do
+ let(:project) { build_stubbed(:project) }
+ let(:credential) { build_stubbed(:service_desk_custom_email_credential, project: project) }
+ let(:smtp_username) { "user@example.com" }
+ let(:smtp_password) { "supersecret" }
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+
+ it { is_expected.to validate_presence_of(:smtp_address) }
+ it { is_expected.to validate_length_of(:smtp_address).is_at_most(255) }
+ it { is_expected.to allow_value('smtp.gmail.com').for(:smtp_address) }
+ it { is_expected.to allow_value('1.1.1.1').for(:smtp_address) }
+ it { is_expected.to allow_value('199.1.1.1').for(:smtp_address) }
+ it { is_expected.not_to allow_value('https://example.com').for(:smtp_address) }
+ it { is_expected.not_to allow_value('file://example').for(:smtp_address) }
+ it { is_expected.not_to allow_value('/example').for(:smtp_address) }
+ it { is_expected.not_to allow_value('localhost').for(:smtp_address) }
+ it { is_expected.not_to allow_value('127.0.0.1').for(:smtp_address) }
+ it { is_expected.not_to allow_value('192.168.12.12').for(:smtp_address) } # disallow local network
+
+ it { is_expected.to validate_presence_of(:smtp_port) }
+ it { is_expected.to validate_numericality_of(:smtp_port).only_integer.is_greater_than(0) }
+
+ it { is_expected.to validate_presence_of(:smtp_username) }
+ it { is_expected.to validate_length_of(:smtp_username).is_at_most(255) }
+
+ it { is_expected.to validate_presence_of(:smtp_password) }
+ it { is_expected.to validate_length_of(:smtp_password).is_at_least(8).is_at_most(128) }
+ end
+
+ describe 'encrypted #smtp_username' do
+ subject { build_stubbed(:service_desk_custom_email_credential, smtp_username: smtp_username) }
+
+ it 'saves and retrieves the encrypted smtp username and iv correctly' do
+ expect(subject.encrypted_smtp_username).not_to be_nil
+ expect(subject.encrypted_smtp_username_iv).not_to be_nil
+
+ expect(subject.smtp_username).to eq(smtp_username)
+ end
+ end
+
+ describe 'encrypted #smtp_password' do
+ subject { build_stubbed(:service_desk_custom_email_credential, smtp_password: smtp_password) }
+
+ it 'saves and retrieves the encrypted smtp password and iv correctly' do
+ expect(subject.encrypted_smtp_password).not_to be_nil
+ expect(subject.encrypted_smtp_password_iv).not_to be_nil
+
+ expect(subject.smtp_password).to eq(smtp_password)
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+
+ it 'can access service desk setting from project' do
+ setting = build_stubbed(:service_desk_setting, project: project)
+
+ expect(credential.service_desk_setting).to eq(setting)
+ end
+ end
+end
diff --git a/spec/models/service_desk/custom_email_verification_spec.rb b/spec/models/service_desk/custom_email_verification_spec.rb
new file mode 100644
index 00000000000..f114367cfbf
--- /dev/null
+++ b/spec/models/service_desk/custom_email_verification_spec.rb
@@ -0,0 +1,170 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ServiceDesk::CustomEmailVerification, feature_category: :service_desk do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ let(:generate_token_pattern) { /\A\p{Alnum}{12}\z/ }
+
+ describe '.generate_token' do
+ it 'matches expected output' do
+ expect(described_class.generate_token).to match(generate_token_pattern)
+ end
+ end
+
+ describe 'validations' do
+ subject { build(:service_desk_custom_email_verification, project: project) }
+
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:state) }
+
+ context 'when status is :started' do
+ before do
+ subject.mark_as_started!(user)
+ end
+
+ it { is_expected.to validate_presence_of(:token) }
+ it { is_expected.to validate_length_of(:token).is_equal_to(12) }
+
+ it 'matches .generate_token pattern' do
+ expect(subject.token).to match(generate_token_pattern)
+ end
+
+ it { is_expected.to validate_presence_of(:triggerer) }
+ it { is_expected.to validate_presence_of(:triggered_at) }
+ it { is_expected.to validate_absence_of(:error) }
+ end
+
+ context 'when status is :finished' do
+ before do
+ subject.mark_as_started!(user)
+ subject.mark_as_finished!
+ end
+
+ it { is_expected.to validate_absence_of(:token) }
+ it { is_expected.to validate_absence_of(:error) }
+ end
+
+ context 'when status is :failed' do
+ before do
+ subject.mark_as_started!(user)
+ subject.mark_as_failed!(:smtp_host_issue)
+ end
+
+ it { is_expected.to validate_presence_of(:error) }
+ it { is_expected.to validate_absence_of(:token) }
+ end
+ end
+
+ describe 'status state machine' do
+ subject { build(:service_desk_custom_email_verification, project: project) }
+
+ describe 'transitioning to started' do
+ it 'records the started at time and generates token' do
+ subject.mark_as_started!(user)
+
+ is_expected.to be_started
+ expect(subject.token).to be_present
+ expect(subject.triggered_at).to be_present
+ expect(subject.triggerer).to eq(user)
+ end
+ end
+
+ describe 'transitioning to finished' do
+ it 'removes the generated token' do
+ subject.mark_as_started!(user)
+ subject.mark_as_finished!
+
+ is_expected.to be_finished
+ expect(subject.token).not_to be_present
+ end
+ end
+
+ describe 'transitioning to failed' do
+ let(:error) { :smtp_host_issue }
+
+ it 'removes the generated token' do
+ subject.mark_as_started!(user)
+ subject.mark_as_failed!(error)
+
+ is_expected.to be_failed
+ expect(subject.token).not_to be_present
+ expect(subject.error).to eq(error.to_s)
+ end
+ end
+ end
+
+ describe '#accepted_until' do
+ it 'returns nil' do
+ expect(subject.accepted_until).to be_nil
+ end
+
+ context 'when state is :started and successfully transitioned' do
+ let(:triggered_at) { 2.minutes.ago }
+
+ before do
+ subject.project = project
+ subject.mark_as_started!(user)
+ end
+
+ it 'returns correct timeframe end time' do
+ expect(subject.accepted_until).to eq(described_class::TIMEFRAME.since(subject.triggered_at))
+ end
+
+ context 'when triggered_at is not set' do
+ it 'returns nil' do
+ subject.triggered_at = nil
+ expect(subject.accepted_until).to be nil
+ end
+ end
+ end
+ end
+
+ describe '#in_timeframe?' do
+ it { is_expected.not_to be_in_timeframe }
+
+ context 'when state is :started and successfully transitioned' do
+ before do
+ subject.project = project
+ subject.mark_as_started!(user)
+ end
+
+ it { is_expected.to be_in_timeframe }
+
+ context 'and timeframe was missed' do
+ before do
+ subject.triggered_at = (described_class::TIMEFRAME + 1).ago
+ end
+
+ it { is_expected.not_to be_in_timeframe }
+ end
+ end
+ end
+
+ describe 'encrypted #token' do
+ let(:token) { 'XXXXXXXXXXXX' }
+
+ subject { build_stubbed(:service_desk_custom_email_verification, token: token) }
+
+ it 'saves and retrieves the encrypted token and iv correctly' do
+ expect(subject.encrypted_token).not_to be_nil
+ expect(subject.encrypted_token_iv).not_to be_nil
+
+ expect(subject.token).to eq(token)
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:triggerer) }
+
+ it 'can access service desk setting from project' do
+ subject.project = project
+ setting = build_stubbed(:service_desk_setting, project: subject.project)
+
+ expect(subject.service_desk_setting).to eq(setting)
+ end
+ end
+end
diff --git a/spec/models/service_desk_setting_spec.rb b/spec/models/service_desk_setting_spec.rb
index 32c36375a3d..b9679b82bd0 100644
--- a/spec/models/service_desk_setting_spec.rb
+++ b/spec/models/service_desk_setting_spec.rb
@@ -3,6 +3,9 @@
require 'spec_helper'
RSpec.describe ServiceDeskSetting, feature_category: :service_desk do
+ let(:verification) { build(:service_desk_custom_email_verification) }
+ let(:project) { build(:project) }
+
describe 'validations' do
subject(:service_desk_setting) { create(:service_desk_setting) }
@@ -13,8 +16,6 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do
it { is_expected.not_to allow_value('abc 12').for(:project_key).with_message("can contain only lowercase letters, digits, and '_'.") }
it { is_expected.not_to allow_value('Big val').for(:project_key) }
it { is_expected.to validate_length_of(:custom_email).is_at_most(255) }
- it { is_expected.to validate_length_of(:custom_email_smtp_address).is_at_most(255) }
- it { is_expected.to validate_length_of(:custom_email_smtp_username).is_at_most(255) }
describe '#custom_email_enabled' do
it { expect(subject.custom_email_enabled).to be_falsey }
@@ -23,6 +24,7 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do
context 'when custom_email_enabled is true' do
before do
+ # Test without ServiceDesk::CustomEmailVerification for simplicity
subject.custom_email_enabled = true
end
@@ -42,20 +44,9 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do
it { is_expected.not_to allow_value('"><script>alert(1);</script>"@example.org').for(:custom_email) }
it { is_expected.not_to allow_value('file://example').for(:custom_email) }
it { is_expected.not_to allow_value('no email at all').for(:custom_email) }
-
- it { is_expected.to validate_presence_of(:custom_email_smtp_username) }
-
- it { is_expected.to validate_presence_of(:custom_email_smtp_port) }
- it { is_expected.to validate_numericality_of(:custom_email_smtp_port).only_integer.is_greater_than(0) }
-
- it { is_expected.to validate_presence_of(:custom_email_smtp_address) }
- it { is_expected.to allow_value('smtp.gmail.com').for(:custom_email_smtp_address) }
- it { is_expected.not_to allow_value('https://example.com').for(:custom_email_smtp_address) }
- it { is_expected.not_to allow_value('file://example').for(:custom_email_smtp_address) }
- it { is_expected.not_to allow_value('/example').for(:custom_email_smtp_address) }
end
- describe '.valid_issue_template' do
+ describe '#valid_issue_template' do
let_it_be(:project) { create(:project, :custom_repo, files: { '.gitlab/issue_templates/service_desk.md' => 'template' }) }
it 'is not valid if template does not exist' do
@@ -73,13 +64,26 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do
end
end
- describe '.valid_project_key' do
+ describe '#custom_email_address_for_verification' do
+ it 'returns nil' do
+ expect(subject.custom_email_address_for_verification).to be_nil
+ end
+
+ context 'when custom_email exists' do
+ it 'returns correct verification address' do
+ subject.custom_email = 'support@example.com'
+ expect(subject.custom_email_address_for_verification).to eq('support+verify@example.com')
+ end
+ end
+ end
+
+ describe '#valid_project_key' do
# Creates two projects with same full path slug
# group1/test/one and group1/test-one will both have 'group-test-one' slug
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group, name: 'test') }
- let_it_be(:project1) { create(:project, name: 'test-one', group: group) }
- let_it_be(:project2) { create(:project, name: 'one', group: subgroup) }
+ let_it_be(:project1) { create(:project, path: 'test-one', group: group) }
+ let_it_be(:project2) { create(:project, path: 'one', group: subgroup) }
let_it_be(:project_key) { 'key' }
let!(:setting) do
create(:service_desk_setting, project: project1, project_key: project_key)
@@ -109,28 +113,21 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do
end
end
- describe 'encrypted password' do
- let_it_be(:settings) do
- create(
+ describe 'associations' do
+ let(:custom_email_settings) do
+ build_stubbed(
:service_desk_setting,
- custom_email_enabled: true,
- custom_email: 'supersupport@example.com',
- custom_email_smtp_address: 'smtp.example.com',
- custom_email_smtp_port: 587,
- custom_email_smtp_username: 'supersupport@example.com',
- custom_email_smtp_password: 'supersecret'
+ custom_email: 'support@example.com'
)
end
- it 'saves and retrieves the encrypted custom email smtp password and iv correctly' do
- expect(settings.encrypted_custom_email_smtp_password).not_to be_nil
- expect(settings.encrypted_custom_email_smtp_password_iv).not_to be_nil
+ it { is_expected.to belong_to(:project) }
- expect(settings.custom_email_smtp_password).to eq('supersecret')
- end
- end
+ it 'can access custom email verification from project' do
+ project.service_desk_custom_email_verification = verification
+ custom_email_settings.project = project
- describe 'associations' do
- it { is_expected.to belong_to(:project) }
+ expect(custom_email_settings.custom_email_verification).to eq(verification)
+ end
end
end
diff --git a/spec/models/slack_integration_spec.rb b/spec/models/slack_integration_spec.rb
new file mode 100644
index 00000000000..41beeee598c
--- /dev/null
+++ b/spec/models/slack_integration_spec.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SlackIntegration, feature_category: :integrations do
+ describe "Associations" do
+ it { is_expected.to belong_to(:integration) }
+ end
+
+ describe 'authorized_scope_names' do
+ subject(:slack_integration) { create(:slack_integration) }
+
+ it 'accepts assignment to nil' do
+ slack_integration.update!(authorized_scope_names: nil)
+
+ expect(slack_integration.authorized_scope_names).to be_empty
+ end
+
+ it 'accepts assignment to a string' do
+ slack_integration.update!(authorized_scope_names: 'foo')
+
+ expect(slack_integration.authorized_scope_names).to contain_exactly('foo')
+ end
+
+ it 'accepts assignment to an array of strings' do
+ slack_integration.update!(authorized_scope_names: %w[foo bar])
+
+ expect(slack_integration.authorized_scope_names).to contain_exactly('foo', 'bar')
+ end
+
+ it 'accepts assignment to a comma-separated string' do
+ slack_integration.update!(authorized_scope_names: 'foo,bar')
+
+ expect(slack_integration.authorized_scope_names).to contain_exactly('foo', 'bar')
+ end
+
+ it 'strips white-space' do
+ slack_integration.update!(authorized_scope_names: 'foo , bar,baz')
+
+ expect(slack_integration.authorized_scope_names).to contain_exactly('foo', 'bar', 'baz')
+ end
+ end
+
+ describe 'all_features_supported?/upgrade_needed?' do
+ subject(:slack_integration) { create(:slack_integration) }
+
+ context 'with enough scopes' do
+ before do
+ slack_integration.update!(authorized_scope_names: %w[chat:write.public chat:write commands])
+ end
+
+ it { is_expected.to be_all_features_supported }
+ it { is_expected.not_to be_upgrade_needed }
+ end
+
+ %w[chat:write.public chat:write].each do |scope_name|
+ context "without #{scope_name}" do
+ before do
+ scopes = %w[chat:write.public chat:write] - [scope_name]
+ slack_integration.update!(authorized_scope_names: scopes)
+ end
+
+ it { is_expected.not_to be_all_features_supported }
+ it { is_expected.to be_upgrade_needed }
+ end
+ end
+ end
+
+ describe 'feature_available?' do
+ subject(:slack_integration) { create(:slack_integration) }
+
+ context 'without any scopes' do
+ it 'is always true for :commands' do
+ expect(slack_integration).to be_feature_available(:commands)
+ end
+
+ it 'is always false for others' do
+ expect(slack_integration).not_to be_feature_available(:notifications)
+ expect(slack_integration).not_to be_feature_available(:foo)
+ end
+ end
+
+ context 'with enough scopes for notifications' do
+ before do
+ slack_integration.update!(authorized_scope_names: %w[chat:write.public chat:write foo])
+ end
+
+ it 'only has the correct features' do
+ expect(slack_integration).to be_feature_available(:commands)
+ expect(slack_integration).to be_feature_available(:notifications)
+ expect(slack_integration).not_to be_feature_available(:foo)
+ end
+ end
+
+ context 'with enough scopes for commands' do
+ before do
+ slack_integration.update!(authorized_scope_names: %w[commands foo])
+ end
+
+ it 'only has the correct features' do
+ expect(slack_integration).to be_feature_available(:commands)
+ expect(slack_integration).not_to be_feature_available(:notifications)
+ expect(slack_integration).not_to be_feature_available(:foo)
+ end
+ end
+
+ context 'with all scopes' do
+ before do
+ slack_integration.update!(authorized_scope_names: %w[commands chat:write chat:write.public])
+ end
+
+ it 'only has the correct features' do
+ expect(slack_integration).to be_feature_available(:commands)
+ expect(slack_integration).to be_feature_available(:notifications)
+ expect(slack_integration).not_to be_feature_available(:foo)
+ end
+ end
+ end
+
+ describe 'Scopes' do
+ let_it_be(:slack_integration) { create(:slack_integration) }
+ let_it_be(:legacy_slack_integration) { create(:slack_integration, :legacy) }
+
+ describe '#with_bot' do
+ it 'returns records with bot data' do
+ expect(described_class.with_bot).to contain_exactly(slack_integration)
+ end
+ end
+
+ describe '#by_team' do
+ it 'returns records with shared team_id' do
+ team_id = slack_integration.team_id
+ team_slack_integration = create(:slack_integration, team_id: team_id)
+
+ expect(described_class.by_team(team_id)).to contain_exactly(slack_integration, team_slack_integration)
+ end
+ end
+ end
+
+ describe 'Validations' do
+ it { is_expected.to validate_presence_of(:team_id) }
+ it { is_expected.to validate_presence_of(:team_name) }
+ it { is_expected.to validate_presence_of(:alias) }
+ it { is_expected.to validate_presence_of(:user_id) }
+ it { is_expected.to validate_presence_of(:integration) }
+ end
+end
diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb
index da1f2653676..6a5456fce3f 100644
--- a/spec/models/snippet_spec.rb
+++ b/spec/models/snippet_spec.rb
@@ -41,8 +41,8 @@ RSpec.describe Snippet do
is_expected
.to validate_length_of(:content)
- .is_at_most(Gitlab::CurrentSettings.snippet_size_limit)
- .with_message("is too long (2 Bytes). The maximum size is 1 Byte.")
+ .is_at_most(Gitlab::CurrentSettings.snippet_size_limit)
+ .with_message("is too long (2 Bytes). The maximum size is 1 Byte.")
end
context 'content validations' do
@@ -145,7 +145,7 @@ RSpec.describe Snippet do
describe '#to_reference' do
context 'when snippet belongs to a project' do
- let(:project) { build(:project, name: 'sample-project') }
+ let(:project) { build(:project) }
let(:snippet) { build(:snippet, id: 1, project: project) }
it 'returns a String reference to the object' do
@@ -153,8 +153,8 @@ RSpec.describe Snippet do
end
it 'supports a cross-project reference' do
- another_project = build(:project, name: 'another-project', namespace: project.namespace)
- expect(snippet.to_reference(another_project)).to eq "sample-project$1"
+ another_project = build(:project, namespace: project.namespace)
+ expect(snippet.to_reference(another_project)).to eq "#{project.path}$1"
end
end
@@ -166,7 +166,7 @@ RSpec.describe Snippet do
end
it 'still returns shortest reference when project arg present' do
- another_project = build(:project, name: 'another-project')
+ another_project = build(:project)
expect(snippet.to_reference(another_project)).to eq "$1"
end
end
@@ -492,17 +492,21 @@ RSpec.describe Snippet do
let_it_be(:snippet) { create(:snippet, content: 'foo', project: project) }
let_it_be(:note1) do
- create(:note_on_project_snippet,
- noteable: snippet,
- project: project,
- note: 'a')
+ create(
+ :note_on_project_snippet,
+ noteable: snippet,
+ project: project,
+ note: 'a'
+ )
end
let_it_be(:note2) do
- create(:note_on_project_snippet,
- noteable: snippet,
- project: project,
- note: 'b')
+ create(
+ :note_on_project_snippet,
+ noteable: snippet,
+ project: project,
+ note: 'b'
+ )
end
it 'includes the snippet author and note authors' do
diff --git a/spec/models/spam_log_spec.rb b/spec/models/spam_log_spec.rb
index 564710b31d0..549a4e92ce0 100644
--- a/spec/models/spam_log_spec.rb
+++ b/spec/models/spam_log_spec.rb
@@ -30,8 +30,7 @@ RSpec.describe SpamLog do
end
expect(
- Users::GhostUserMigration.where(user: user,
- initiator_user: admin)
+ Users::GhostUserMigration.where(user: user, initiator_user: admin)
).to be_exists
end
end
diff --git a/spec/models/terraform/state_spec.rb b/spec/models/terraform/state_spec.rb
index 533e6e4bd7b..fc0a6432149 100644
--- a/spec/models/terraform/state_spec.rb
+++ b/spec/models/terraform/state_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Terraform::State do
+RSpec.describe Terraform::State, feature_category: :infrastructure_as_code do
subject { create(:terraform_state, :with_version) }
it { is_expected.to belong_to(:project) }
diff --git a/spec/models/terraform/state_version_spec.rb b/spec/models/terraform/state_version_spec.rb
index 477041117cb..a476b9e79ae 100644
--- a/spec/models/terraform/state_version_spec.rb
+++ b/spec/models/terraform/state_version_spec.rb
@@ -2,11 +2,11 @@
require 'spec_helper'
-RSpec.describe Terraform::StateVersion do
+RSpec.describe Terraform::StateVersion, feature_category: :infrastructure_as_code do
it { is_expected.to be_a FileStoreMounter }
it { is_expected.to be_a EachBatch }
- it { is_expected.to belong_to(:terraform_state).required }
+ it { is_expected.to belong_to(:terraform_state).required.touch }
it { is_expected.to belong_to(:created_by_user).class_name('User').optional }
it { is_expected.to belong_to(:build).class_name('Ci::Build').optional }
diff --git a/spec/models/u2f_registration_spec.rb b/spec/models/u2f_registration_spec.rb
deleted file mode 100644
index 1fab3882c2a..00000000000
--- a/spec/models/u2f_registration_spec.rb
+++ /dev/null
@@ -1,141 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe U2fRegistration do
- let_it_be(:user) { create(:user) }
-
- let(:u2f_registration_name) { 'u2f_device' }
- let(:app_id) { FFaker::BaconIpsum.characters(5) }
- let(:device) { U2F::FakeU2F.new(app_id) }
-
- describe '.authenticate' do
- context 'when registration is found' do
- it 'returns true' do
- create_u2f_registration
- device_challenge = U2F.urlsafe_encode64(SecureRandom.random_bytes(32))
- sign_response_json = device.sign_response(device_challenge)
-
- response = U2fRegistration.authenticate(
- user,
- app_id,
- sign_response_json,
- device_challenge
- )
-
- expect(response).to eq true
- end
- end
-
- context 'when registration not found' do
- it 'returns nil' do
- device_challenge = U2F.urlsafe_encode64(SecureRandom.random_bytes(32))
- sign_response_json = device.sign_response(device_challenge)
-
- # data is valid but user does not have any u2f_registrations
- response = U2fRegistration.authenticate(
- user,
- app_id,
- sign_response_json,
- device_challenge
- )
-
- expect(response).to eq nil
- end
- end
-
- context 'when args passed in are invalid' do
- it 'returns false' do
- some_app_id = 123
- invalid_json = 'invalid JSON'
- challenges = 'whatever'
-
- response = U2fRegistration.authenticate(
- user,
- some_app_id,
- invalid_json,
- challenges
- )
-
- expect(response).to eq false
- end
- end
- end
-
- describe 'callbacks' do
- describe 'after create' do
- shared_examples_for 'creates webauthn registration' do
- it 'creates webauthn registration' do
- u2f_registration = create_u2f_registration
- webauthn_registration = WebauthnRegistration.where(u2f_registration_id: u2f_registration.id)
- expect(webauthn_registration).to exist
- end
- end
-
- it_behaves_like 'creates webauthn registration'
-
- context 'when the u2f_registration has a blank name' do
- let(:u2f_registration_name) { '' }
-
- it_behaves_like 'creates webauthn registration'
- end
-
- context 'when the u2f_registration has the name as `nil`' do
- let(:u2f_registration_name) { nil }
-
- it_behaves_like 'creates webauthn registration'
- end
-
- it 'logs error' do
- allow(Gitlab::Auth::U2fWebauthnConverter).to receive(:new).and_raise('boom!')
-
- allow_next_instance_of(U2fRegistration) do |u2f_registration|
- allow(u2f_registration).to receive(:id).and_return(123)
- end
-
- expect(Gitlab::ErrorTracking).to(
- receive(:track_exception).with(kind_of(StandardError),
- u2f_registration_id: 123))
-
- create_u2f_registration
- end
- end
-
- describe 'after update' do
- context 'when counter is updated' do
- it 'updates the webauthn registration counter to be the same value' do
- u2f_registration = create_u2f_registration
- new_counter = u2f_registration.counter + 1
- webauthn_registration = WebauthnRegistration.find_by(u2f_registration_id: u2f_registration.id)
-
- u2f_registration.update!(counter: new_counter)
-
- expect(u2f_registration.reload.counter).to eq(new_counter)
- expect(webauthn_registration.reload.counter).to eq(new_counter)
- end
- end
-
- context 'when sign count of registration is not updated' do
- it 'does not update the counter' do
- u2f_registration = create_u2f_registration
- webauthn_registration = WebauthnRegistration.find_by(u2f_registration_id: u2f_registration.id)
-
- expect do
- u2f_registration.update!(name: 'a new name')
- end.not_to change { webauthn_registration.counter }
- end
- end
- end
- end
-
- def create_u2f_registration
- create(
- :u2f_registration,
- name: u2f_registration_name,
- user: user,
- certificate: Base64.strict_encode64(device.cert_raw),
- key_handle: U2F.urlsafe_encode64(device.key_handle_raw),
- public_key: Base64.strict_encode64(device.origin_public_key_raw)
- )
- end
-end
diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb
index cdf73b203af..27e2060a94b 100644
--- a/spec/models/upload_spec.rb
+++ b/spec/models/upload_spec.rb
@@ -96,9 +96,11 @@ RSpec.describe Upload do
describe '#calculate_checksum!' do
let(:upload) do
- described_class.new(path: __FILE__,
- size: described_class::CHECKSUM_THRESHOLD - 1.megabyte,
- store: ObjectStorage::Store::LOCAL)
+ described_class.new(
+ path: __FILE__,
+ size: described_class::CHECKSUM_THRESHOLD - 1.megabyte,
+ store: ObjectStorage::Store::LOCAL
+ )
end
it 'sets `checksum` to SHA256 sum of the file' do
diff --git a/spec/models/user_detail_spec.rb b/spec/models/user_detail_spec.rb
index 7d433896cf8..428fd5470c3 100644
--- a/spec/models/user_detail_spec.rb
+++ b/spec/models/user_detail_spec.rb
@@ -91,15 +91,17 @@ RSpec.describe UserDetail do
describe '#save' do
let(:user_detail) do
- create(:user_detail,
- bio: 'bio',
- discord: '1234567890123456789',
- linkedin: 'linkedin',
- location: 'location',
- organization: 'organization',
- skype: 'skype',
- twitter: 'twitter',
- website_url: 'https://example.com')
+ create(
+ :user_detail,
+ bio: 'bio',
+ discord: '1234567890123456789',
+ linkedin: 'linkedin',
+ location: 'location',
+ organization: 'organization',
+ skype: 'skype',
+ twitter: 'twitter',
+ website_url: 'https://example.com'
+ )
end
shared_examples 'prevents `nil` value' do |attr|
diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb
index a6f64c90657..1d7ecb724bf 100644
--- a/spec/models/user_preference_spec.rb
+++ b/spec/models/user_preference_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UserPreference do
+RSpec.describe UserPreference, feature_category: :user_profile do
let_it_be(:user) { create(:user) }
let(:user_preference) { create(:user_preference, user: user) }
@@ -48,11 +48,21 @@ RSpec.describe UserPreference do
end
end
- describe 'use_legacy_web_ide' do
- it { is_expected.to allow_value(true).for(:use_legacy_web_ide) }
- it { is_expected.to allow_value(false).for(:use_legacy_web_ide) }
- it { is_expected.not_to allow_value(nil).for(:use_legacy_web_ide) }
- it { is_expected.not_to allow_value("").for(:use_legacy_web_ide) }
+ describe 'pass_user_identities_to_ci_jwt' do
+ it { is_expected.to allow_value(true).for(:pass_user_identities_to_ci_jwt) }
+ it { is_expected.to allow_value(false).for(:pass_user_identities_to_ci_jwt) }
+ it { is_expected.not_to allow_value(nil).for(:pass_user_identities_to_ci_jwt) }
+ it { is_expected.not_to allow_value("").for(:pass_user_identities_to_ci_jwt) }
+ end
+
+ describe 'visibility_pipeline_id_type' do
+ it 'is set to 0 by default' do
+ pref = described_class.new
+
+ expect(pref.visibility_pipeline_id_type).to eq('id')
+ end
+
+ it { is_expected.to define_enum_for(:visibility_pipeline_id_type).with_values(id: 0, iid: 1) }
end
end
@@ -215,47 +225,6 @@ RSpec.describe UserPreference do
end
end
- describe '#time_format_in_24h' do
- it 'is set to false by default' do
- pref = described_class.new
-
- expect(pref.time_format_in_24h).to eq(false)
- end
-
- it 'returns default value when assigning nil' do
- pref = described_class.new(time_format_in_24h: nil)
-
- expect(pref.time_format_in_24h).to eq(false)
- end
-
- it 'returns default value when the value is NULL' do
- pref = create(:user_preference, user: user)
- pref.update_column(:time_format_in_24h, nil)
-
- expect(pref.reload.time_format_in_24h).to eq(false)
- end
-
- it 'returns assigned value' do
- pref = described_class.new(time_format_in_24h: true)
-
- expect(pref.time_format_in_24h).to eq(true)
- end
- end
-
- describe '#time_format_in_24h=' do
- it 'sets to default value when nil' do
- pref = described_class.new(time_format_in_24h: nil)
-
- expect(pref.read_attribute(:time_format_in_24h)).to eq(false)
- end
-
- it 'sets user values' do
- pref = described_class.new(time_format_in_24h: true)
-
- expect(pref.read_attribute(:time_format_in_24h)).to eq(true)
- end
- end
-
describe '#render_whitespace_in_code' do
it 'is set to false by default' do
pref = described_class.new
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index e87667d9604..c73dac7251e 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -42,9 +42,6 @@ RSpec.describe User, feature_category: :user_profile do
it { is_expected.to delegate_method(:time_display_relative).to(:user_preference) }
it { is_expected.to delegate_method(:time_display_relative=).to(:user_preference).with_arguments(:args) }
- it { is_expected.to delegate_method(:time_format_in_24h).to(:user_preference) }
- it { is_expected.to delegate_method(:time_format_in_24h=).to(:user_preference).with_arguments(:args) }
-
it { is_expected.to delegate_method(:show_whitespace_in_diffs).to(:user_preference) }
it { is_expected.to delegate_method(:show_whitespace_in_diffs=).to(:user_preference).with_arguments(:args) }
@@ -78,12 +75,15 @@ RSpec.describe User, feature_category: :user_profile do
it { is_expected.to delegate_method(:diffs_addition_color).to(:user_preference) }
it { is_expected.to delegate_method(:diffs_addition_color=).to(:user_preference).with_arguments(:args) }
- it { is_expected.to delegate_method(:use_legacy_web_ide).to(:user_preference) }
- it { is_expected.to delegate_method(:use_legacy_web_ide=).to(:user_preference).with_arguments(:args) }
-
it { is_expected.to delegate_method(:use_new_navigation).to(:user_preference) }
it { is_expected.to delegate_method(:use_new_navigation=).to(:user_preference).with_arguments(:args) }
+ it { is_expected.to delegate_method(:pinned_nav_items).to(:user_preference) }
+ it { is_expected.to delegate_method(:pinned_nav_items=).to(:user_preference).with_arguments(:args) }
+
+ it { is_expected.to delegate_method(:achievements_enabled).to(:user_preference) }
+ it { is_expected.to delegate_method(:achievements_enabled=).to(:user_preference).with_arguments(:args) }
+
it { is_expected.to delegate_method(:job_title).to(:user_detail).allow_nil }
it { is_expected.to delegate_method(:job_title=).to(:user_detail).with_arguments(:args).allow_nil }
@@ -99,9 +99,6 @@ RSpec.describe User, feature_category: :user_profile do
it { is_expected.to delegate_method(:registration_objective).to(:user_detail).allow_nil }
it { is_expected.to delegate_method(:registration_objective=).to(:user_detail).with_arguments(:args).allow_nil }
- it { is_expected.to delegate_method(:requires_credit_card_verification).to(:user_detail).allow_nil }
- it { is_expected.to delegate_method(:requires_credit_card_verification=).to(:user_detail).with_arguments(:args).allow_nil }
-
it { is_expected.to delegate_method(:discord).to(:user_detail).allow_nil }
it { is_expected.to delegate_method(:discord=).to(:user_detail).with_arguments(:args).allow_nil }
@@ -174,6 +171,14 @@ RSpec.describe User, feature_category: :user_profile do
it { is_expected.to have_many(:revoked_user_achievements).class_name('Achievements::UserAchievement').with_foreign_key('revoked_by_user_id').inverse_of(:revoked_by_user) }
it { is_expected.to have_many(:achievements).through(:user_achievements).class_name('Achievements::Achievement').inverse_of(:users) }
it { is_expected.to have_many(:namespace_commit_emails).class_name('Users::NamespaceCommitEmail') }
+ it { is_expected.to have_many(:audit_events).with_foreign_key(:author_id).inverse_of(:user) }
+ it { is_expected.to have_many(:abuse_trust_scores).class_name('Abuse::TrustScore') }
+ it { is_expected.to have_many(:issue_assignment_events).class_name('ResourceEvents::IssueAssignmentEvent') }
+ it { is_expected.to have_many(:merge_request_assignment_events).class_name('ResourceEvents::MergeRequestAssignmentEvent') }
+
+ it do
+ is_expected.to have_many(:alert_assignees).class_name('::AlertManagement::AlertAssignee').inverse_of(:assignee)
+ end
describe 'default values' do
let(:user) { described_class.new }
@@ -188,6 +193,7 @@ RSpec.describe User, feature_category: :user_profile do
it { expect(user.notified_of_own_activity).to be_falsey }
it { expect(user.preferred_language).to eq(Gitlab::CurrentSettings.default_preferred_language) }
it { expect(user.theme_id).to eq(described_class.gitlab_config.default_theme) }
+ it { expect(user.color_scheme_id).to eq(Gitlab::CurrentSettings.default_syntax_highlighting_theme) }
end
describe '#user_detail' do
@@ -282,6 +288,31 @@ RSpec.describe User, feature_category: :user_profile do
end
end
+ describe '#abuse_metadata' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:contribution_calendar) { Gitlab::ContributionsCalendar.new(user) }
+
+ before do
+ allow(Gitlab::ContributionsCalendar).to receive(:new).and_return(contribution_calendar)
+ allow(contribution_calendar).to receive(:activity_dates).and_return({ first: 3, second: 5, third: 4 })
+
+ allow(user).to receive_messages(
+ account_age_in_days: 10,
+ two_factor_enabled?: true
+ )
+ end
+
+ it 'returns the expected hash' do
+ abuse_metadata = user.abuse_metadata
+
+ expect(abuse_metadata.length).to eq 2
+ expect(abuse_metadata).to include(
+ account_age: 10,
+ two_factor_enabled: 1
+ )
+ end
+ end
+
describe '#group_members' do
it 'does not include group memberships for which user is a requester' do
user = create(:user)
@@ -474,6 +505,37 @@ RSpec.describe User, feature_category: :user_profile do
expect(user).to be_valid
end
end
+
+ context 'namespace_move_dir_allowed' do
+ context 'when the user is not a new record' do
+ before do
+ expect(user.new_record?).to eq(false)
+ end
+
+ it 'checks when username changes' do
+ expect(user).to receive(:namespace_move_dir_allowed)
+
+ user.username = 'newuser'
+ user.validate
+ end
+
+ it 'does not check if the username did not change' do
+ expect(user).not_to receive(:namespace_move_dir_allowed)
+ expect(user.username_changed?).to eq(false)
+
+ user.validate
+ end
+ end
+
+ it 'does not check if the user is a new record' do
+ user = User.new(username: 'newuser')
+
+ expect(user.new_record?).to eq(true)
+ expect(user).not_to receive(:namespace_move_dir_allowed)
+
+ user.validate
+ end
+ end
end
describe 'name' do
@@ -978,10 +1040,6 @@ RSpec.describe User, feature_category: :user_profile do
end
end
- describe 'and U2F' do
- it_behaves_like "returns the right users", :two_factor_via_u2f
- end
-
describe 'and WebAuthn' do
it_behaves_like "returns the right users", :two_factor_via_webauthn
end
@@ -997,26 +1055,6 @@ RSpec.describe User, feature_category: :user_profile do
expect(users_without_two_factor).not_to include(user_with_2fa.id)
end
- describe 'and u2f' do
- it 'excludes users with 2fa enabled via U2F' do
- user_with_2fa = create(:user, :two_factor_via_u2f)
- user_without_2fa = create(:user)
- users_without_two_factor = described_class.without_two_factor.pluck(:id)
-
- expect(users_without_two_factor).to include(user_without_2fa.id)
- expect(users_without_two_factor).not_to include(user_with_2fa.id)
- end
-
- it 'excludes users with 2fa enabled via OTP and U2F' do
- user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f)
- user_without_2fa = create(:user)
- users_without_two_factor = described_class.without_two_factor.pluck(:id)
-
- expect(users_without_two_factor).to include(user_without_2fa.id)
- expect(users_without_two_factor).not_to include(user_with_2fa.id)
- end
- end
-
describe 'and webauthn' do
it 'excludes users with 2fa enabled via WebAuthn' do
user_with_2fa = create(:user, :two_factor_via_webauthn)
@@ -2076,7 +2114,7 @@ RSpec.describe User, feature_category: :user_profile do
let_it_be(:incoming_email_token) { 'ilqx6jm1u945macft4eff0nw' }
it 'returns incoming email token when supported' do
- allow(Gitlab::IncomingEmail).to receive(:supports_issue_creation?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_issue_creation?).and_return(true)
user = create(:user, incoming_email_token: incoming_email_token)
@@ -2084,7 +2122,7 @@ RSpec.describe User, feature_category: :user_profile do
end
it 'returns `nil` when not supported' do
- allow(Gitlab::IncomingEmail).to receive(:supports_issue_creation?).and_return(false)
+ allow(Gitlab::Email::IncomingEmail).to receive(:supports_issue_creation?).and_return(false)
user = create(:user, incoming_email_token: incoming_email_token)
@@ -2112,6 +2150,59 @@ RSpec.describe User, feature_category: :user_profile do
end
end
+ describe '#remember_me!' do
+ let(:user) { create(:user) }
+
+ context 'when remember me application setting is enabled' do
+ before do
+ stub_application_setting(remember_me_enabled: true)
+ end
+
+ it 'sets rememberable attributes' do
+ expect(user.remember_created_at).to be_nil
+
+ user.remember_me!
+
+ expect(user.remember_created_at).not_to be_nil
+ end
+ end
+
+ context 'when remember me application setting is not enabled' do
+ before do
+ stub_application_setting(remember_me_enabled: false)
+ end
+
+ it 'sets rememberable attributes' do
+ expect(user.remember_created_at).to be_nil
+
+ user.remember_me!
+
+ expect(user.remember_created_at).to be_nil
+ end
+ end
+ end
+
+ describe '#forget_me!' do
+ let(:user) { create(:user) }
+
+ context 'when remember me application setting is disabled' do
+ before do
+ stub_application_setting(remember_me_enabled: true)
+ end
+
+ it 'allows user to be forgotten when previously remembered' do
+ user.remember_me!
+
+ expect(user.remember_created_at).not_to be_nil
+
+ stub_application_setting(remember_me_enabled: false)
+ user.forget_me!
+
+ expect(user.remember_created_at).to be_nil
+ end
+ end
+ end
+
describe '#disable_two_factor!' do
it 'clears all 2FA-related fields' do
user = create(:user, :two_factor)
@@ -2174,6 +2265,24 @@ RSpec.describe User, feature_category: :user_profile do
end
end
+ context 'Duo Auth' do
+ context 'when enabled via GitLab settings' do
+ before do
+ allow(::Gitlab.config.duo_auth).to receive(:enabled).and_return(true)
+ end
+
+ it { expect(user.two_factor_otp_enabled?).to eq(true) }
+ end
+
+ context 'when disabled via GitLab settings' do
+ before do
+ allow(::Gitlab.config.duo_auth).to receive(:enabled).and_return(false)
+ end
+
+ it { expect(user.two_factor_otp_enabled?).to eq(false) }
+ end
+ end
+
context 'FortiTokenCloud' do
context 'when enabled via GitLab settings' do
before do
@@ -2207,54 +2316,6 @@ RSpec.describe User, feature_category: :user_profile do
end
end
- context 'two_factor_u2f_enabled?' do
- let_it_be(:user) { create(:user, :two_factor) }
-
- context 'when webauthn feature flag is enabled' do
- context 'user has no U2F registration' do
- it { expect(user.two_factor_u2f_enabled?).to eq(false) }
- end
-
- context 'user has existing U2F registration' do
- it 'returns false' do
- device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5))
- create(:u2f_registration,
- name: 'my u2f device',
- user: user,
- certificate: Base64.strict_encode64(device.cert_raw),
- key_handle: U2F.urlsafe_encode64(device.key_handle_raw),
- public_key: Base64.strict_encode64(device.origin_public_key_raw))
-
- expect(user.two_factor_u2f_enabled?).to eq(false)
- end
- end
- end
-
- context 'when webauthn feature flag is disabled' do
- before do
- stub_feature_flags(webauthn: false)
- end
-
- context 'user has no U2F registration' do
- it { expect(user.two_factor_u2f_enabled?).to eq(false) }
- end
-
- context 'user has existing U2F registration' do
- it 'returns true' do
- device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5))
- create(:u2f_registration,
- name: 'my u2f device',
- user: user,
- certificate: Base64.strict_encode64(device.cert_raw),
- key_handle: U2F.urlsafe_encode64(device.key_handle_raw),
- public_key: Base64.strict_encode64(device.origin_public_key_raw))
-
- expect(user.two_factor_u2f_enabled?).to eq(true)
- end
- end
- end
- end
-
describe 'needs_new_otp_secret?', :freeze_time do
let(:user) { create(:user) }
@@ -2380,6 +2441,20 @@ RSpec.describe User, feature_category: :user_profile do
expect(user.forkable_namespaces).to contain_exactly(user.namespace, group, subgroup, developer_group)
end
+
+ it 'includes groups where the user has access via group shares to create projects' do
+ shared_group = create(:group)
+ create(
+ :group_group_link,
+ :maintainer,
+ shared_with_group: group,
+ shared_group: shared_group
+ )
+
+ expect(user.forkable_namespaces).to contain_exactly(
+ user.namespace, group, subgroup, shared_group
+ )
+ end
end
describe '#manageable_groups' do
@@ -2396,14 +2471,6 @@ RSpec.describe User, feature_category: :user_profile do
end
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
-
- it_behaves_like 'manageable groups examples'
- end
end
end
end
@@ -3847,6 +3914,54 @@ RSpec.describe User, feature_category: :user_profile do
expect(user.following?(followee)).to be_falsey
end
+
+ it 'does not follow if user disabled following' do
+ user = create(:user)
+ user.enabled_following = false
+
+ followee = create(:user)
+
+ expect(user.follow(followee)).to eq(false)
+
+ expect(user.following?(followee)).to be_falsey
+ end
+
+ it 'does not follow if followee user disabled following' do
+ user = create(:user)
+
+ followee = create(:user)
+ followee.enabled_following = false
+
+ expect(user.follow(followee)).to eq(false)
+
+ expect(user.following?(followee)).to be_falsey
+ end
+
+ context 'when disable_follow_users feature flag is off' do
+ before do
+ stub_feature_flags(disable_follow_users: false)
+ end
+
+ it 'follows user even if user disabled following' do
+ user = create(:user)
+ user.enabled_following = false
+
+ followee = create(:user)
+
+ expect(user.follow(followee)).to be_truthy
+ expect(user.following?(followee)).to be_truthy
+ end
+
+ it 'follows user even if followee user disabled following' do
+ user = create(:user)
+
+ followee = create(:user)
+ followee.enabled_following = false
+
+ expect(user.follow(followee)).to be_truthy
+ expect(user.following?(followee)).to be_truthy
+ end
+ end
end
describe '#unfollow' do
@@ -3889,6 +4004,39 @@ RSpec.describe User, feature_category: :user_profile do
end
end
+ describe '#following_users_allowed?' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:followee) { create(:user) }
+
+ where(:user_enabled_following, :followee_enabled_following, :feature_flag_status, :result) do
+ true | true | false | true
+ true | false | false | true
+ true | true | true | true
+ true | false | true | false
+ false | true | false | true
+ false | true | true | false
+ false | false | false | true
+ false | false | true | false
+ end
+
+ with_them do
+ before do
+ user.enabled_following = user_enabled_following
+ followee.enabled_following = followee_enabled_following
+ followee.save!
+ stub_feature_flags(disable_follow_users: feature_flag_status)
+ end
+
+ it { expect(user.following_users_allowed?(followee)).to eq result }
+ end
+
+ it 'is false when user and followee is the same user' do
+ expect(user.following_users_allowed?(user)).to eq(false)
+ end
+ end
+
describe '#notification_email_or_default' do
let(:email) { 'gonzo@muppets.com' }
@@ -5225,13 +5373,13 @@ RSpec.describe User, feature_category: :user_profile do
end
describe '#source_groups_of_two_factor_authentication_requirement' do
- let_it_be(:group_not_requiring_2FA) { create :group }
+ let_it_be(:group_not_requiring_2fa) { create :group }
let(:user) { create :user }
before do
group.add_member(user, GroupMember::OWNER)
- group_not_requiring_2FA.add_member(user, GroupMember::OWNER)
+ group_not_requiring_2fa.add_member(user, GroupMember::OWNER)
end
context 'when user is direct member of group requiring 2FA' do
@@ -5713,6 +5861,34 @@ RSpec.describe User, feature_category: :user_profile do
expect(user).not_to be_blocked
end
+
+ context 'when target user is the same as deleted_by' do
+ let(:deleted_by) { user }
+
+ it 'blocks the user and schedules the record for deletion with the correct delay' do
+ freeze_time do
+ expect(DeleteUserWorker).to receive(:perform_in).with(7.days, user.id, user.id, {})
+
+ user.delete_async(deleted_by: deleted_by)
+
+ expect(user).to be_blocked
+ end
+ end
+
+ context 'when delay_delete_own_user feature flag is disabled' do
+ before do
+ stub_feature_flags(delay_delete_own_user: false)
+ end
+
+ it 'schedules user for deletion without blocking them' do
+ expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id, {})
+
+ user.delete_async(deleted_by: deleted_by)
+
+ expect(user).not_to be_blocked
+ end
+ end
+ end
end
describe '#max_member_access_for_project_ids' do
@@ -5777,8 +5953,10 @@ RSpec.describe User, feature_category: :user_profile do
all_projects = projects + [second_maintainer_project.id, second_developer_project.id]
- expected_all = expected.merge(second_maintainer_project.id => Gitlab::Access::MAINTAINER,
- second_developer_project.id => Gitlab::Access::DEVELOPER)
+ expected_all = expected.merge(
+ second_maintainer_project.id => Gitlab::Access::MAINTAINER,
+ second_developer_project.id => Gitlab::Access::DEVELOPER
+ )
access_levels(projects)
@@ -5856,8 +6034,10 @@ RSpec.describe User, feature_category: :user_profile do
all_groups = groups + [second_maintainer_group.id, second_developer_group.id]
- expected_all = expected.merge(second_maintainer_group.id => Gitlab::Access::MAINTAINER,
- second_developer_group.id => Gitlab::Access::DEVELOPER)
+ expected_all = expected.merge(
+ second_maintainer_group.id => Gitlab::Access::MAINTAINER,
+ second_developer_group.id => Gitlab::Access::DEVELOPER
+ )
access_levels(groups)
@@ -6566,11 +6746,13 @@ RSpec.describe User, feature_category: :user_profile do
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)
+ 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
@@ -6600,11 +6782,13 @@ RSpec.describe User, feature_category: :user_profile do
context 'when dismissed callout exists' do
before_all do
- create(:project_callout,
- user: user,
- project_id: project.id,
- feature_name: feature_name,
- dismissed_at: 4.months.ago)
+ create(
+ :project_callout,
+ user: user,
+ project_id: project.id,
+ feature_name: feature_name,
+ dismissed_at: 4.months.ago
+ )
end
it 'returns true when no ignore_dismissal_earlier_than provided' do
@@ -6840,7 +7024,8 @@ RSpec.describe User, feature_category: :user_profile do
{ user_type: :support_bot },
{ user_type: :security_bot },
{ user_type: :automation_bot },
- { user_type: :admin_bot }
+ { user_type: :admin_bot },
+ { user_type: :llm_bot }
]
end
@@ -7002,6 +7187,14 @@ RSpec.describe User, feature_category: :user_profile do
it_behaves_like 'does not require password to be present'
end
+
+ context 'when user is a security_policy bot user' do
+ before do
+ user.update!(user_type: 'security_policy_bot')
+ end
+
+ it_behaves_like 'does not require password to be present'
+ end
end
describe 'can_trigger_notifications?' do
@@ -7104,42 +7297,103 @@ RSpec.describe User, feature_category: :user_profile do
context 'when user is confirmed' do
let(:user) { create(:user) }
- it 'is falsey' do
- expect(user.confirmed?).to be_truthy
- expect(subject).to be_falsey
+ it 'is false' do
+ expect(user.confirmed?).to be(true)
+ expect(subject).to be(false)
end
end
context 'when user is not confirmed' do
let_it_be(:user) { build_stubbed(:user, :unconfirmed, confirmation_sent_at: Time.current) }
- it 'is truthy when soft_email_confirmation feature is disabled' do
- stub_feature_flags(soft_email_confirmation: false)
- expect(subject).to be_truthy
+ context 'when email confirmation setting is set to `off`' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'off')
+ end
+
+ it { is_expected.to be(false) }
end
- context 'when soft_email_confirmation feature is enabled' do
+ context 'when email confirmation setting is set to `soft`' do
before do
- stub_feature_flags(soft_email_confirmation: true)
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
end
- it 'is falsey when confirmation period is valid' do
- expect(subject).to be_falsey
+ context 'when confirmation period is valid' do
+ it { is_expected.to be(false) }
end
- it 'is truthy when confirmation period is expired' do
- travel_to(User.allow_unconfirmed_access_for.from_now + 1.day) do
- expect(subject).to be_truthy
+ context 'when confirmation period is expired' do
+ before do
+ travel_to(User.allow_unconfirmed_access_for.from_now + 1.day)
end
+
+ it { is_expected.to be(true) }
end
context 'when user has no confirmation email sent' do
let(:user) { build(:user, :unconfirmed, confirmation_sent_at: nil) }
- it 'is truthy' do
- expect(subject).to be_truthy
- end
+ it { is_expected.to be(true) }
+ end
+ end
+
+ context 'when email confirmation setting is set to `hard`' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'hard')
+ end
+
+ it { is_expected.to be(true) }
+ end
+ end
+ end
+
+ describe '#confirmation_period_valid?' do
+ subject { user.send(:confirmation_period_valid?) }
+
+ let_it_be(:user) { create(:user) }
+
+ context 'when email confirmation setting is set to `off`' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'off')
+ end
+
+ it { is_expected.to be(true) }
+ end
+
+ context 'when email confirmation setting is set to `soft`' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
+ end
+
+ context 'when within confirmation window' do
+ before do
+ user.update!(confirmation_sent_at: Date.today)
end
+
+ it { is_expected.to be(true) }
+ end
+
+ context 'when outside confirmation window' do
+ before do
+ user.update!(confirmation_sent_at: Date.today - described_class.confirm_within - 7.days)
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
+
+ context 'when email confirmation setting is set to `hard`' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'hard')
+ end
+
+ it { is_expected.to be(true) }
+ end
+
+ describe '#in_confirmation_period?' do
+ it 'is expected to be an alias' do
+ expect(user.method(:in_confirmation_period?).original_name).to eq(:confirmation_period_valid?)
end
end
end
@@ -7433,9 +7687,11 @@ RSpec.describe User, feature_category: :user_profile do
context 'with a defined project namespace_commit_email' do
it 'returns the defined namespace_commit_email' do
- project_commit_email = create(:namespace_commit_email,
- user: user,
- namespace: project.project_namespace)
+ project_commit_email = create(
+ :namespace_commit_email,
+ user: user,
+ namespace: project.project_namespace
+ )
expect(emails).to eq(project_commit_email)
end
@@ -7455,9 +7711,11 @@ RSpec.describe User, feature_category: :user_profile do
context 'with a defined project namespace_commit_email' do
it 'returns the defined namespace_commit_email' do
- project_commit_email = create(:namespace_commit_email,
- user: user,
- namespace: project.project_namespace)
+ project_commit_email = create(
+ :namespace_commit_email,
+ user: user,
+ namespace: project.project_namespace
+ )
expect(emails).to eq(project_commit_email)
end
@@ -7476,9 +7734,11 @@ RSpec.describe User, feature_category: :user_profile do
context 'with a defined project namespace_commit_email' do
it 'returns the defined namespace_commit_email' do
- project_commit_email = create(:namespace_commit_email,
- user: user,
- namespace: project.project_namespace)
+ project_commit_email = create(
+ :namespace_commit_email,
+ user: user,
+ namespace: project.project_namespace
+ )
expect(emails).to eq(project_commit_email)
end
diff --git a/spec/models/users/credit_card_validation_spec.rb b/spec/models/users/credit_card_validation_spec.rb
index 58b529ff18a..4db3683c057 100644
--- a/spec/models/users/credit_card_validation_spec.rb
+++ b/spec/models/users/credit_card_validation_spec.rb
@@ -51,4 +51,107 @@ RSpec.describe Users::CreditCardValidation do
end
end
end
+
+ describe 'scopes' do
+ describe '.by_banned_user' do
+ let(:banned_user) { create(:banned_user) }
+ let!(:credit_card) { create(:credit_card_validation) }
+ let!(:banned_user_credit_card) { create(:credit_card_validation, user: banned_user.user) }
+
+ it 'returns only records associated to banned users' do
+ expect(described_class.by_banned_user).to match_array([banned_user_credit_card])
+ end
+ end
+
+ describe '.similar_by_holder_name' do
+ let!(:credit_card) { create(:credit_card_validation, holder_name: 'CARD MCHODLER') }
+ let!(:credit_card2) { create(:credit_card_validation, holder_name: 'RICHIE RICH') }
+
+ it 'returns only records that case-insensitive match the given holder name' do
+ expect(described_class.similar_by_holder_name('card mchodler')).to match_array([credit_card])
+ end
+
+ context 'when given holder name is falsey' do
+ it 'returns [] when given holder name is ""' do
+ expect(described_class.similar_by_holder_name('')).to match_array([])
+ end
+
+ it 'returns [] when given holder name is nil' do
+ expect(described_class.similar_by_holder_name(nil)).to match_array([])
+ end
+ end
+ end
+
+ describe '.similar_to' do
+ let(:credit_card) { create(:credit_card_validation) }
+
+ let!(:credit_card2) do
+ create(:credit_card_validation,
+ expiration_date: credit_card.expiration_date,
+ last_digits: credit_card.last_digits,
+ network: credit_card.network
+ )
+ end
+
+ let!(:credit_card3) do
+ create(:credit_card_validation,
+ expiration_date: credit_card.expiration_date,
+ last_digits: credit_card.last_digits,
+ network: 'UnknownCCNetwork'
+ )
+ end
+
+ it 'returns only records with similar expiration_date, last_digits, and network attribute values' do
+ expect(described_class.similar_to(credit_card)).to match_array([credit_card, credit_card2])
+ end
+ end
+ end
+
+ describe '#used_by_banned_user?' do
+ let(:credit_card_details) do
+ {
+ holder_name: 'Christ McLovin',
+ expiration_date: 2.years.from_now.end_of_month,
+ last_digits: 4242,
+ network: 'Visa'
+ }
+ end
+
+ let!(:credit_card) { create(:credit_card_validation, credit_card_details) }
+
+ subject { credit_card }
+
+ context 'when there is a similar credit card associated to a banned user' do
+ let_it_be(:banned_user) { create(:banned_user) }
+
+ let(:attrs) { credit_card_details.merge({ user: banned_user.user }) }
+ let!(:similar_credit_card) { create(:credit_card_validation, attrs) }
+
+ it { is_expected.to be_used_by_banned_user }
+
+ context 'when holder names do not match' do
+ let!(:similar_credit_card) do
+ create(:credit_card_validation, attrs.merge({ holder_name: 'Mary Goody' }))
+ end
+
+ it { is_expected.not_to be_used_by_banned_user }
+ end
+
+ context 'when .similar_to returns nothing' do
+ let!(:similar_credit_card) do
+ create(:credit_card_validation, attrs.merge({ network: 'DifferentNetwork' }))
+ end
+
+ it { is_expected.not_to be_used_by_banned_user }
+ end
+ end
+
+ context 'when there is a similar credit card not associated to a banned user' do
+ let!(:similar_credit_card) do
+ create(:credit_card_validation, credit_card_details)
+ end
+
+ it { is_expected.not_to be_used_by_banned_user }
+ end
+ end
end
diff --git a/spec/models/wiki_directory_spec.rb b/spec/models/wiki_directory_spec.rb
index 1b177934ace..c30e79f79ce 100644
--- a/spec/models/wiki_directory_spec.rb
+++ b/spec/models/wiki_directory_spec.rb
@@ -13,6 +13,8 @@ RSpec.describe WikiDirectory do
let_it_be(:toplevel1) { build(:wiki_page, title: 'aaa-toplevel1') }
let_it_be(:toplevel2) { build(:wiki_page, title: 'zzz-toplevel2') }
let_it_be(:toplevel3) { build(:wiki_page, title: 'zzz-toplevel3') }
+ let_it_be(:home) { build(:wiki_page, title: 'home') }
+ let_it_be(:homechild) { build(:wiki_page, title: 'Home/homechild') }
let_it_be(:parent1) { build(:wiki_page, title: 'parent1') }
let_it_be(:parent2) { build(:wiki_page, title: 'parent2') }
let_it_be(:child1) { build(:wiki_page, title: 'parent1/child1') }
@@ -24,13 +26,18 @@ RSpec.describe WikiDirectory do
it 'returns a nested array of entries' do
entries = described_class.group_pages(
- [toplevel1, toplevel2, toplevel3,
+ [toplevel1, toplevel2, toplevel3, home, homechild,
parent1, parent2, child1, child2, child3,
subparent, grandchild1, grandchild2].sort_by(&:title)
)
expect(entries).to match(
[
+ a_kind_of(WikiDirectory).and(
+ having_attributes(
+ slug: 'Home', entries: [homechild]
+ )
+ ),
toplevel1,
a_kind_of(WikiDirectory).and(
having_attributes(
diff --git a/spec/models/wiki_page/meta_spec.rb b/spec/models/wiki_page/meta_spec.rb
index 4d1a2dc1c98..5808be128e0 100644
--- a/spec/models/wiki_page/meta_spec.rb
+++ b/spec/models/wiki_page/meta_spec.rb
@@ -167,10 +167,12 @@ RSpec.describe WikiPage::Meta do
end
def create_previous_version(title: old_title, slug: last_known_slug, date: wiki_page.version.commit.committed_date)
- create(:wiki_page_meta,
- title: title, project: project,
- created_at: date, updated_at: date,
- canonical_slug: slug)
+ create(
+ :wiki_page_meta,
+ title: title, project: project,
+ created_at: date, updated_at: date,
+ canonical_slug: slug
+ )
end
def create_context
diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb
index 21da06a222f..efade74688a 100644
--- a/spec/models/wiki_page_spec.rb
+++ b/spec/models/wiki_page_spec.rb
@@ -588,6 +588,20 @@ RSpec.describe WikiPage do
expect(page.content).to eq new_content
end
+ context 'when page combine with directory' do
+ it 'moving the file and directory' do
+ wiki.create_page('testpage/testtitle', 'content')
+ wiki.create_page('testpage', 'content')
+
+ page = wiki.find_page('testpage')
+ page.update(title: 'testfolder/testpage')
+
+ page = wiki.find_page('testfolder/testpage/testtitle')
+
+ expect(page.slug).to eq 'testfolder/testpage/testtitle'
+ end
+ end
+
describe 'in subdir' do
it 'moves the page to the root folder if the title is preceded by /' do
page = create_wiki_page(container, title: 'foo/Existing Page')
diff --git a/spec/models/work_item_spec.rb b/spec/models/work_item_spec.rb
index 6aacaa3c119..5a525d83c3b 100644
--- a/spec/models/work_item_spec.rb
+++ b/spec/models/work_item_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe WorkItem, feature_category: :portfolio_management do
+ using RSpec::Parameterized::TableSyntax
+
let_it_be(:reusable_project) { create(:project) }
describe 'associations' do
@@ -99,15 +101,28 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do
end
end
- describe '#supports_assignee?' do
- let(:work_item) { build(:work_item, :task) }
+ describe '#get_widget' do
+ let(:work_item) { build(:work_item, description: 'foo') }
- before do
- allow(work_item.work_item_type).to receive(:supports_assignee?).and_return(false)
+ it 'returns widget object' do
+ expect(work_item.get_widget(:description)).to be_an_instance_of(WorkItems::Widgets::Description)
end
- it 'delegates the call to its work item type' do
- expect(work_item.supports_assignee?).to be(false)
+ context 'when widget does not exist' do
+ it 'returns nil' do
+ expect(work_item.get_widget(:nop)).to be_nil
+ end
+ end
+ end
+
+ describe '#supports_assignee?' do
+ Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter::WIDGETS_FOR_TYPE.each_pair do |base_type, widgets|
+ specify do
+ work_item = build(:work_item, base_type)
+ supports_assignee = widgets.include?(:assignees)
+
+ expect(work_item.supports_assignee?).to eq(supports_assignee)
+ end
end
end
@@ -117,7 +132,7 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do
subject { work_item.supported_quick_action_commands }
it 'returns quick action commands supported for all work items' do
- is_expected.to include(:title, :reopen, :close, :cc, :tableflip, :shrug)
+ is_expected.to include(:title, :reopen, :close, :cc, :tableflip, :shrug, :type)
end
context 'when work item supports the assignee widget' do
@@ -127,7 +142,7 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do
end
context 'when work item does not the assignee widget' do
- let(:work_item) { build(:work_item, :incident) }
+ let(:work_item) { build(:work_item, :test_case) }
it 'omits assignee related quick action commands' do
is_expected.not_to include(:assign, :unassign, :reassign)
@@ -163,6 +178,30 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do
end
end
+ describe 'transform_quick_action_params' do
+ let(:work_item) { build(:work_item, :task) }
+
+ subject(:transformed_params) do
+ work_item.transform_quick_action_params({
+ title: 'bar',
+ assignee_ids: ['foo']
+ })
+ end
+
+ it 'correctly separates widget params from regular params' do
+ expect(transformed_params).to eq({
+ common: {
+ title: 'bar'
+ },
+ widgets: {
+ assignees_widget: {
+ assignee_ids: ['foo']
+ }
+ }
+ })
+ end
+ end
+
describe 'callbacks' do
describe 'record_create_action' do
it 'records the creation action after saving' do
@@ -290,6 +329,20 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do
end
end
+ describe '#link_reference_pattern' do
+ let(:match_data) { described_class.link_reference_pattern.match(link_reference_url) }
+
+ context 'with work item url' do
+ let(:link_reference_url) { 'http://localhost/namespace/project/-/work_items/1' }
+
+ it 'matches with expected attributes' do
+ expect(match_data['namespace']).to eq('namespace')
+ expect(match_data['project']).to eq('project')
+ expect(match_data['work_item']).to eq('1')
+ end
+ end
+ end
+
context 'with hierarchy' do
let_it_be(:type1) { create(:work_item_type, namespace: reusable_project.namespace) }
let_it_be(:type2) { create(:work_item_type, namespace: reusable_project.namespace) }
@@ -344,4 +397,179 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do
end
end
end
+
+ describe '#allowed_work_item_type_change' do
+ let_it_be(:all_types) { WorkItems::Type::BASE_TYPES.keys }
+
+ it 'is possible to change between all types', :aggregate_failures do
+ all_types.each do |type|
+ work_item = build(:work_item, type, project: reusable_project)
+
+ (all_types - [type]).each do |new_type|
+ work_item.work_item_type_id = WorkItems::Type.default_by_type(new_type).id
+
+ expect(work_item).to be_valid, "#{type} to #{new_type}"
+ end
+ end
+ end
+
+ context 'with ParentLink relation' do
+ let_it_be(:old_type) { create(:work_item_type) }
+ let_it_be(:new_type) { create(:work_item_type) }
+
+ context 'with hierarchy restrictions' do
+ let_it_be(:child_type) { create(:work_item_type) }
+
+ let_it_be_with_reload(:parent) { create(:work_item, work_item_type: old_type, project: reusable_project) }
+ let_it_be_with_reload(:child) { create(:work_item, work_item_type: child_type, project: reusable_project) }
+
+ let_it_be(:hierarchy_restriction) do
+ create(:hierarchy_restriction, parent_type: old_type, child_type: child_type)
+ end
+
+ let_it_be(:link) { create(:parent_link, work_item_parent: parent, work_item: child) }
+
+ context 'when child items restrict the type change' do
+ before do
+ parent.work_item_type = new_type
+ end
+
+ context 'when child items are compatible with the new type' do
+ let_it_be(:hierarchy_restriction_new_type) do
+ create(:hierarchy_restriction, parent_type: new_type, child_type: child_type)
+ end
+
+ it 'allows to change types' do
+ expect(parent).to be_valid
+ expect(parent.errors).to be_empty
+ end
+ end
+
+ context 'when child items are not compatible with the new type' do
+ it 'does not allow to change types' do
+ expect(parent).not_to be_valid
+ expect(parent.errors[:work_item_type_id])
+ .to include("cannot be changed to #{new_type.name} with these child item types.")
+ end
+ end
+ end
+
+ context 'when the parent restricts the type change' do
+ before do
+ child.work_item_type = new_type
+ end
+
+ it 'does not allow to change types' do
+ expect(child.valid?).to eq(false)
+ expect(child.errors[:work_item_type_id])
+ .to include("cannot be changed to #{new_type.name} with #{parent.work_item_type.name} as parent type.")
+ end
+ end
+ end
+
+ context 'with hierarchy depth restriction' do
+ let_it_be_with_reload(:item1) { create(:work_item, work_item_type: new_type, project: reusable_project) }
+ let_it_be_with_reload(:item2) { create(:work_item, work_item_type: new_type, project: reusable_project) }
+ let_it_be_with_reload(:item3) { create(:work_item, work_item_type: new_type, project: reusable_project) }
+ let_it_be_with_reload(:item4) { create(:work_item, work_item_type: new_type, project: reusable_project) }
+
+ let_it_be(:hierarchy_restriction1) do
+ create(:hierarchy_restriction, parent_type: old_type, child_type: new_type)
+ end
+
+ let_it_be(:hierarchy_restriction2) do
+ create(:hierarchy_restriction, parent_type: new_type, child_type: old_type)
+ end
+
+ let_it_be_with_reload(:hierarchy_restriction3) do
+ create(:hierarchy_restriction, parent_type: new_type, child_type: new_type, maximum_depth: 4)
+ end
+
+ let_it_be(:link1) { create(:parent_link, work_item_parent: item1, work_item: item2) }
+ let_it_be(:link2) { create(:parent_link, work_item_parent: item2, work_item: item3) }
+ let_it_be(:link3) { create(:parent_link, work_item_parent: item3, work_item: item4) }
+
+ before do
+ hierarchy_restriction3.update!(maximum_depth: maximum_depth)
+ end
+
+ shared_examples 'validates the depth correctly' do
+ before do
+ work_item.update!(work_item_type: old_type)
+ end
+
+ context 'when it is valid' do
+ let(:maximum_depth) { 4 }
+
+ it 'allows to change types' do
+ work_item.work_item_type = new_type
+
+ expect(work_item).to be_valid
+ end
+ end
+
+ context 'when it is not valid' do
+ let(:maximum_depth) { 3 }
+
+ it 'does not allow to change types' do
+ work_item.work_item_type = new_type
+
+ expect(work_item).not_to be_valid
+ expect(work_item.errors[:work_item_type_id]).to include("reached maximum depth")
+ end
+ end
+ end
+
+ context 'with the highest ancestor' do
+ let_it_be_with_reload(:work_item) { item1 }
+
+ it_behaves_like 'validates the depth correctly'
+ end
+
+ context 'with a child item' do
+ let_it_be_with_reload(:work_item) { item2 }
+
+ it_behaves_like 'validates the depth correctly'
+ end
+
+ context 'with the last child item' do
+ let_it_be_with_reload(:work_item) { item4 }
+
+ it_behaves_like 'validates the depth correctly'
+ end
+
+ context 'when ancestor is still the old type' do
+ let_it_be(:hierarchy_restriction4) do
+ create(:hierarchy_restriction, parent_type: old_type, child_type: old_type)
+ end
+
+ before do
+ item1.update!(work_item_type: old_type)
+ item2.update!(work_item_type: old_type)
+ end
+
+ context 'when it exceeds maximum depth' do
+ let(:maximum_depth) { 2 }
+
+ it 'does not allow to change types' do
+ item2.work_item_type = new_type
+
+ expect(item2).not_to be_valid
+ expect(item2.errors[:work_item_type_id]).to include("reached maximum depth")
+ end
+ end
+
+ context 'when it does not exceed maximum depth' do
+ let(:maximum_depth) { 3 }
+
+ it 'does allow to change types' do
+ item2.work_item_type = new_type
+
+ expect(item2).to be_valid
+ end
+ end
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/work_items/resource_link_event_spec.rb b/spec/models/work_items/resource_link_event_spec.rb
new file mode 100644
index 00000000000..67ca9e72bbc
--- /dev/null
+++ b/spec/models/work_items/resource_link_event_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::ResourceLinkEvent, type: :model, feature_category: :team_planning do
+ it_behaves_like 'a resource event'
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:work_item) }
+ it { is_expected.to belong_to(:child_work_item) }
+ end
+
+ describe 'validation' do
+ it { is_expected.to validate_presence_of(:child_work_item) }
+ end
+end
diff --git a/spec/models/work_items/widget_definition_spec.rb b/spec/models/work_items/widget_definition_spec.rb
index 08f8f4d9663..a33e08a1bf2 100644
--- a/spec/models/work_items/widget_definition_spec.rb
+++ b/spec/models/work_items/widget_definition_spec.rb
@@ -11,7 +11,10 @@ RSpec.describe WorkItems::WidgetDefinition, feature_category: :team_planning do
::WorkItems::Widgets::Assignees,
::WorkItems::Widgets::StartAndDueDate,
::WorkItems::Widgets::Milestone,
- ::WorkItems::Widgets::Notes
+ ::WorkItems::Widgets::Notes,
+ ::WorkItems::Widgets::Notifications,
+ ::WorkItems::Widgets::CurrentUserTodos,
+ ::WorkItems::Widgets::AwardEmoji
]
if Gitlab.ee?
diff --git a/spec/models/work_items/widgets/award_emoji_spec.rb b/spec/models/work_items/widgets/award_emoji_spec.rb
new file mode 100644
index 00000000000..bb61aa41669
--- /dev/null
+++ b/spec/models/work_items/widgets/award_emoji_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::Widgets::AwardEmoji, feature_category: :team_planning do
+ let_it_be(:work_item) { create(:work_item) }
+ let_it_be(:emoji1) { create(:award_emoji, name: 'star', awardable: work_item) }
+ let_it_be(:emoji2) { create(:award_emoji, :upvote, awardable: work_item) }
+ let_it_be(:emoji3) { create(:award_emoji, :downvote, awardable: work_item) }
+
+ describe '.type' do
+ it { expect(described_class.type).to eq(:award_emoji) }
+ end
+
+ describe '#type' do
+ it { expect(described_class.new(work_item).type).to eq(:award_emoji) }
+ end
+
+ describe '#downvotes' do
+ it { expect(described_class.new(work_item).downvotes).to eq(1) }
+ end
+
+ describe '#upvotes' do
+ it { expect(described_class.new(work_item).upvotes).to eq(1) }
+ end
+
+ describe '#award_emoji' do
+ it { expect(described_class.new(work_item).award_emoji).to match_array([emoji1, emoji2, emoji3]) }
+ end
+end
diff --git a/spec/models/work_items/widgets/notifications_spec.rb b/spec/models/work_items/widgets/notifications_spec.rb
new file mode 100644
index 00000000000..2942c149660
--- /dev/null
+++ b/spec/models/work_items/widgets/notifications_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::Widgets::Notifications, feature_category: :team_planning do
+ let_it_be(:work_item) { create(:work_item) }
+
+ describe '.type' do
+ it { expect(described_class.type).to eq(:notifications) }
+ end
+
+ describe '#type' do
+ it { expect(described_class.new(work_item).type).to eq(:notifications) }
+ end
+
+ describe '#subscribed?' do
+ it { expect(described_class.new(work_item).subscribed?(work_item.author, work_item.project)).to eq(true) }
+ end
+end
diff --git a/spec/policies/abuse_report_policy_spec.rb b/spec/policies/abuse_report_policy_spec.rb
new file mode 100644
index 00000000000..b17b6886b9a
--- /dev/null
+++ b/spec/policies/abuse_report_policy_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AbuseReportPolicy, feature_category: :insider_threat do
+ let(:abuse_report) { build_stubbed(:abuse_report) }
+
+ subject(:policy) { described_class.new(user, abuse_report) }
+
+ context 'when the user is not an admin' do
+ let(:user) { create(:user) }
+
+ it 'cannot read_abuse_report' do
+ expect(policy).to be_disallowed(:read_abuse_report)
+ end
+ end
+
+ context 'when the user is an admin', :enable_admin_mode do
+ let(:user) { create(:admin) }
+
+ it 'can read_abuse_report' do
+ expect(policy).to be_allowed(:read_abuse_report)
+ end
+ end
+end
diff --git a/spec/policies/achievements/user_achievement_policy_spec.rb b/spec/policies/achievements/user_achievement_policy_spec.rb
new file mode 100644
index 00000000000..c3148e882fa
--- /dev/null
+++ b/spec/policies/achievements/user_achievement_policy_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Achievements::UserAchievementPolicy, feature_category: :user_profile do
+ let(:maintainer) { create(:user) }
+
+ let(:group) { create(:group, :public) }
+
+ let(:current_user) { create(:user) }
+ let(:achievement) { create(:achievement, namespace: group) }
+ let(:achievement_owner) { create(:user) }
+ let(:user_achievement) { create(:user_achievement, achievement: achievement, user: achievement_owner) }
+
+ before do
+ group.add_maintainer(maintainer)
+ end
+
+ subject { described_class.new(current_user, user_achievement) }
+
+ it 'is readable to everyone when user has public profile' do
+ is_expected.to be_allowed(:read_user_achievement)
+ end
+
+ context 'when user has private profile' do
+ before do
+ achievement_owner.update!(private_profile: true)
+ end
+
+ context 'for achievement owner' do
+ let(:current_user) { achievement_owner }
+
+ it 'is visible' do
+ is_expected.to be_allowed(:read_user_achievement)
+ end
+ end
+
+ context 'for group maintainer' do
+ let(:current_user) { maintainer }
+
+ it 'is visible' do
+ is_expected.to be_allowed(:read_user_achievement)
+ end
+ end
+
+ context 'for others' do
+ it 'is hidden' do
+ is_expected.not_to be_allowed(:read_user_achievement)
+ end
+ end
+ end
+
+ context 'when group is private' do
+ let(:group) { create(:group, :private) }
+
+ context 'for achievement owner' do
+ let(:current_user) { achievement_owner }
+
+ it 'is visible' do
+ is_expected.to be_allowed(:read_user_achievement)
+ end
+ end
+
+ context 'for group maintainer' do
+ let(:current_user) { maintainer }
+
+ it 'is visible' do
+ is_expected.to be_allowed(:read_user_achievement)
+ end
+ end
+
+ context 'for others' do
+ it 'is visible' do
+ is_expected.to be_allowed(:read_user_achievement)
+ end
+ end
+ end
+end
diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb
index fee4d76ca8f..ec3b3fde719 100644
--- a/spec/policies/ci/build_policy_spec.rb
+++ b/spec/policies/ci/build_policy_spec.rb
@@ -121,8 +121,7 @@ RSpec.describe Ci::BuildPolicy do
context 'when no one can push or merge to the branch' do
before do
- create(:protected_branch, :no_one_can_push,
- name: build.ref, project: project)
+ create(:protected_branch, :no_one_can_push, name: build.ref, project: project)
end
it 'does not include ability to update build' do
@@ -132,8 +131,7 @@ RSpec.describe Ci::BuildPolicy do
context 'when developers can push to the branch' do
before do
- create(:protected_branch, :developers_can_merge,
- name: build.ref, project: project)
+ create(:protected_branch, :developers_can_merge, name: build.ref, project: project)
end
it 'includes ability to update build' do
@@ -143,8 +141,7 @@ RSpec.describe Ci::BuildPolicy do
context 'when no one can create the tag' do
before do
- create(:protected_tag, :no_one_can_create,
- name: build.ref, project: project)
+ create(:protected_tag, :no_one_can_create, name: build.ref, project: project)
build.update!(tag: true)
end
@@ -156,8 +153,7 @@ RSpec.describe Ci::BuildPolicy do
context 'when no one can create the tag but it is not a tag' do
before do
- create(:protected_tag, :no_one_can_create,
- name: build.ref, project: project)
+ create(:protected_tag, :no_one_can_create, name: build.ref, project: project)
end
it 'includes ability to update build' do
@@ -181,8 +177,7 @@ RSpec.describe Ci::BuildPolicy do
context 'when the build was created for a protected ref' do
before do
- create(:protected_branch, :developers_can_push,
- name: build.ref, project: project)
+ create(:protected_branch, :developers_can_push, name: build.ref, project: project)
end
it { expect(policy).to be_disallowed :erase_build }
@@ -204,8 +199,7 @@ RSpec.describe Ci::BuildPolicy do
let(:owner) { user }
before do
- create(:protected_branch, :no_one_can_push, :no_one_can_merge,
- name: build.ref, project: project)
+ create(:protected_branch, :no_one_can_push, :no_one_can_merge, name: build.ref, project: project)
end
it { expect(policy).to be_disallowed :erase_build }
@@ -219,8 +213,7 @@ RSpec.describe Ci::BuildPolicy do
context 'when maintainers can push to the branch' do
before do
- create(:protected_branch, :maintainers_can_push,
- name: build.ref, project: project)
+ create(:protected_branch, :maintainers_can_push, name: build.ref, project: project)
end
context 'when the build was created by the maintainer' do
@@ -240,8 +233,7 @@ RSpec.describe Ci::BuildPolicy do
let(:owner) { user }
before do
- create(:protected_branch, :no_one_can_push, :no_one_can_merge,
- name: build.ref, project: project)
+ create(:protected_branch, :no_one_can_push, :no_one_can_merge, name: build.ref, project: project)
end
it { expect(policy).to be_disallowed :erase_build }
@@ -257,8 +249,7 @@ RSpec.describe Ci::BuildPolicy do
context 'when the build was created for a protected branch' do
before do
- create(:protected_branch, :developers_can_push,
- name: build.ref, project: project)
+ create(:protected_branch, :developers_can_push, name: build.ref, project: project)
end
it { expect(policy).to be_allowed :erase_build }
@@ -266,8 +257,9 @@ RSpec.describe Ci::BuildPolicy do
context 'when the build was created for a protected tag' do
before do
- create(:protected_tag, :developers_can_create,
- name: build.ref, project: project)
+ create(:protected_tag, :developers_can_create, name: build.ref, project: project)
+
+ build.update!(tag: true)
end
it { expect(policy).to be_allowed :erase_build }
diff --git a/spec/policies/ci/pipeline_policy_spec.rb b/spec/policies/ci/pipeline_policy_spec.rb
index b68bb966820..8a5b80e3051 100644
--- a/spec/policies/ci/pipeline_policy_spec.rb
+++ b/spec/policies/ci/pipeline_policy_spec.rb
@@ -20,8 +20,7 @@ RSpec.describe Ci::PipelinePolicy, :models do
context 'when no one can push or merge to the branch' do
before do
- create(:protected_branch, :no_one_can_push,
- name: pipeline.ref, project: project)
+ create(:protected_branch, :no_one_can_push, name: pipeline.ref, project: project)
end
it 'does not include ability to update pipeline' do
@@ -31,8 +30,7 @@ RSpec.describe Ci::PipelinePolicy, :models do
context 'when developers can push to the branch' do
before do
- create(:protected_branch, :developers_can_merge,
- name: pipeline.ref, project: project)
+ create(:protected_branch, :developers_can_merge, name: pipeline.ref, project: project)
end
it 'includes ability to update pipeline' do
@@ -42,8 +40,7 @@ RSpec.describe Ci::PipelinePolicy, :models do
context 'when no one can create the tag' do
before do
- create(:protected_tag, :no_one_can_create,
- name: pipeline.ref, project: project)
+ create(:protected_tag, :no_one_can_create, name: pipeline.ref, project: project)
pipeline.update!(tag: true)
end
@@ -55,8 +52,7 @@ RSpec.describe Ci::PipelinePolicy, :models do
context 'when no one can create the tag but it is not a tag' do
before do
- create(:protected_tag, :no_one_can_create,
- name: pipeline.ref, project: project)
+ create(:protected_tag, :no_one_can_create, name: pipeline.ref, project: project)
end
it 'includes ability to update pipeline' do
@@ -119,8 +115,7 @@ RSpec.describe Ci::PipelinePolicy, :models do
before do
project.add_developer(user)
- create(:protected_branch, :developers_can_merge,
- name: pipeline.ref, project: project)
+ create(:protected_branch, :developers_can_merge, name: pipeline.ref, project: project)
end
it 'is enabled' do
@@ -133,8 +128,7 @@ RSpec.describe Ci::PipelinePolicy, :models do
before do
project.add_developer(user)
- create(:protected_branch, :developers_can_merge,
- name: pipeline.ref, project: project)
+ create(:protected_branch, :developers_can_merge, name: pipeline.ref, project: project)
end
it 'is disabled' do
diff --git a/spec/policies/ci/pipeline_schedule_policy_spec.rb b/spec/policies/ci/pipeline_schedule_policy_spec.rb
index 9aa50876b55..7025eda1ba1 100644
--- a/spec/policies/ci/pipeline_schedule_policy_spec.rb
+++ b/spec/policies/ci/pipeline_schedule_policy_spec.rb
@@ -19,8 +19,7 @@ RSpec.describe Ci::PipelineSchedulePolicy, :models, :clean_gitlab_redis_cache do
context 'when no one can push or merge to the branch' do
before do
- create(:protected_branch, :no_one_can_push,
- name: pipeline_schedule.ref, project: project)
+ create(:protected_branch, :no_one_can_push, name: pipeline_schedule.ref, project: project)
end
it 'does not include ability to play pipeline schedule' do
@@ -30,8 +29,7 @@ RSpec.describe Ci::PipelineSchedulePolicy, :models, :clean_gitlab_redis_cache do
context 'when developers can push to the branch' do
before do
- create(:protected_branch, :developers_can_merge,
- name: pipeline_schedule.ref, project: project)
+ create(:protected_branch, :developers_can_merge, name: pipeline_schedule.ref, project: project)
end
it 'includes ability to update pipeline' do
@@ -45,8 +43,7 @@ RSpec.describe Ci::PipelineSchedulePolicy, :models, :clean_gitlab_redis_cache do
before do
pipeline_schedule.update!(ref: tag)
- create(:protected_tag, :no_one_can_create,
- name: pipeline_schedule.ref, project: project)
+ create(:protected_tag, :no_one_can_create, name: pipeline_schedule.ref, project: project)
end
it 'does not include ability to play pipeline schedule' do
@@ -56,8 +53,7 @@ RSpec.describe Ci::PipelineSchedulePolicy, :models, :clean_gitlab_redis_cache do
context 'when no one can create the tag but it is not a tag' do
before do
- create(:protected_tag, :no_one_can_create,
- name: pipeline_schedule.ref, project: project)
+ create(:protected_tag, :no_one_can_create, name: pipeline_schedule.ref, project: project)
end
it 'includes ability to play pipeline schedule' do
@@ -104,7 +100,7 @@ RSpec.describe Ci::PipelineSchedulePolicy, :models, :clean_gitlab_redis_cache do
end
it 'includes abilities to take ownership' do
- expect(policy).to be_allowed :take_ownership_pipeline_schedule
+ expect(policy).to be_allowed :admin_pipeline_schedule
end
end
end
diff --git a/spec/policies/ci/runner_manager_policy_spec.rb b/spec/policies/ci/runner_manager_policy_spec.rb
new file mode 100644
index 00000000000..d7004033ceb
--- /dev/null
+++ b/spec/policies/ci/runner_manager_policy_spec.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::RunnerManagerPolicy, feature_category: :runner_fleet do
+ let_it_be(:owner) { create(:user) }
+
+ describe 'ability :read_runner_manager' do
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+
+ let_it_be_with_reload(:group) { create(:group, name: 'top-level', path: 'top-level') }
+ let_it_be_with_reload(:subgroup) { create(:group, name: 'subgroup', path: 'subgroup', parent: group) }
+ let_it_be_with_reload(:project) { create(:project, group: subgroup) }
+
+ let_it_be(:instance_runner) { create(:ci_runner, :instance, :with_runner_manager) }
+ let_it_be(:group_runner) { create(:ci_runner, :group, :with_runner_manager, groups: [group]) }
+ let_it_be(:project_runner) { create(:ci_runner, :project, :with_runner_manager, projects: [project]) }
+
+ let(:runner_manager) { runner.runner_managers.first }
+
+ subject(:policy) { described_class.new(user, runner_manager) }
+
+ before_all do
+ group.add_guest(guest)
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ group.add_owner(owner)
+ end
+
+ shared_examples 'a policy allowing reading instance runner manager depending on runner sharing' do
+ context 'with instance runner' do
+ let(:runner) { instance_runner }
+
+ it { expect_allowed :read_runner_manager }
+
+ context 'with shared runners disabled on projects' do
+ before do
+ project.update!(shared_runners_enabled: false)
+ end
+
+ it { expect_allowed :read_runner_manager }
+ end
+
+ context 'with shared runners disabled for groups and projects' do
+ before do
+ group.update!(shared_runners_enabled: false)
+ project.update!(shared_runners_enabled: false)
+ end
+
+ it { expect_disallowed :read_runner_manager }
+ end
+ end
+ end
+
+ shared_examples 'a policy allowing reading group runner manager depending on runner sharing' do
+ context 'with group runner' do
+ let(:runner) { group_runner }
+
+ it { expect_allowed :read_runner_manager }
+
+ context 'with sharing of group runners disabled' do
+ before do
+ project.update!(group_runners_enabled: false)
+ end
+
+ it { expect_disallowed :read_runner_manager }
+ end
+ end
+ end
+
+ shared_examples 'does not allow reading runners managers on any scope' do
+ context 'with instance runner' do
+ let(:runner) { instance_runner }
+
+ it { expect_disallowed :read_runner_manager }
+
+ context 'with shared runners disabled for groups and projects' do
+ before do
+ group.update!(shared_runners_enabled: false)
+ project.update!(shared_runners_enabled: false)
+ end
+
+ it { expect_disallowed :read_runner_manager }
+ end
+ end
+
+ context 'with group runner' do
+ let(:runner) { group_runner }
+
+ it { expect_disallowed :read_runner_manager }
+
+ context 'with sharing of group runners disabled' do
+ before do
+ project.update!(group_runners_enabled: false)
+ end
+
+ it { expect_disallowed :read_runner_manager }
+ end
+ end
+
+ context 'with project runner' do
+ let(:runner) { project_runner }
+
+ it { expect_disallowed :read_runner_manager }
+ end
+ end
+
+ context 'without access' do
+ let_it_be(:user) { create(:user) }
+
+ it_behaves_like 'does not allow reading runners managers on any scope'
+ end
+
+ context 'with guest access' do
+ let(:user) { guest }
+
+ it_behaves_like 'does not allow reading runners managers on any scope'
+ end
+
+ context 'with developer access' do
+ let(:user) { developer }
+
+ it_behaves_like 'a policy allowing reading instance runner manager depending on runner sharing'
+
+ it_behaves_like 'a policy allowing reading group runner manager depending on runner sharing'
+
+ context 'with project runner' do
+ let(:runner) { project_runner }
+
+ it { expect_disallowed :read_runner_manager }
+ end
+ end
+
+ context 'with maintainer access' do
+ let(:user) { maintainer }
+
+ it_behaves_like 'a policy allowing reading instance runner manager depending on runner sharing'
+
+ it_behaves_like 'a policy allowing reading group runner manager depending on runner sharing'
+
+ context 'with project runner' do
+ let(:runner) { project_runner }
+
+ it { expect_allowed :read_runner_manager }
+ end
+ end
+
+ context 'with owner access' do
+ let(:user) { owner }
+
+ it_behaves_like 'a policy allowing reading instance runner manager depending on runner sharing'
+
+ context 'with group runner' do
+ let(:runner) { group_runner }
+
+ it { expect_allowed :read_runner_manager }
+
+ context 'with sharing of group runners disabled' do
+ before do
+ project.update!(group_runners_enabled: false)
+ end
+
+ it { expect_allowed :read_runner_manager }
+ end
+ end
+
+ context 'with project runner' do
+ let(:runner) { project_runner }
+
+ it { expect_allowed :read_runner_manager }
+ end
+ end
+ end
+end
diff --git a/spec/policies/clusters/agent_policy_spec.rb b/spec/policies/clusters/agent_policy_spec.rb
index 8f778d318ed..b3c43647a84 100644
--- a/spec/policies/clusters/agent_policy_spec.rb
+++ b/spec/policies/clusters/agent_policy_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe Clusters::AgentPolicy do
let(:project) { cluster_agent.project }
describe 'rules' do
+ it { expect(policy).to be_disallowed :read_cluster_agent }
+
context 'when developer' do
before do
project.add_developer(user)
@@ -24,5 +26,21 @@ RSpec.describe Clusters::AgentPolicy do
it { expect(policy).to be_allowed :admin_cluster }
end
+
+ context 'when agent is ci_access authorized for project members' do
+ before do
+ allow(cluster_agent).to receive(:ci_access_authorized_for?).with(user).and_return(true)
+ end
+
+ it { expect(policy).to be_allowed :read_cluster_agent }
+ end
+
+ context 'when agent is user_access authorized for project members' do
+ before do
+ allow(cluster_agent).to receive(:user_access_authorized_for?).with(user).and_return(true)
+ end
+
+ it { expect(policy).to be_allowed :read_cluster_agent }
+ end
end
end
diff --git a/spec/policies/concerns/policy_actor_spec.rb b/spec/policies/concerns/policy_actor_spec.rb
index 7271cbb4a9d..7fd9db67032 100644
--- a/spec/policies/concerns/policy_actor_spec.rb
+++ b/spec/policies/concerns/policy_actor_spec.rb
@@ -2,7 +2,17 @@
require 'spec_helper'
-RSpec.describe PolicyActor do
+RSpec.describe PolicyActor, feature_category: :shared do
+ let(:policy_actor_test_class) do
+ Class.new do
+ include PolicyActor
+ end
+ end
+
+ before do
+ stub_const('PolicyActorTestClass', policy_actor_test_class)
+ end
+
it 'implements all the methods from user' do
methods = subject.instance_methods
@@ -10,4 +20,10 @@ RSpec.describe PolicyActor do
# initialized. So here we just use an instance
expect(build(:user).methods).to include(*methods)
end
+
+ describe '#security_policy_bot?' do
+ subject { PolicyActorTestClass.new.security_policy_bot? }
+
+ it { is_expected.to eq(false) }
+ end
end
diff --git a/spec/policies/design_management/design_policy_spec.rb b/spec/policies/design_management/design_policy_spec.rb
index c62e97dcdb9..1c0270a969e 100644
--- a/spec/policies/design_management/design_policy_spec.rb
+++ b/spec/policies/design_management/design_policy_spec.rb
@@ -1,11 +1,11 @@
# frozen_string_literal: true
require "spec_helper"
-RSpec.describe DesignManagement::DesignPolicy do
+RSpec.describe DesignManagement::DesignPolicy, feature_category: :portfolio_management do
include DesignManagementTestHelpers
let(:guest_design_abilities) { %i[read_design] }
- let(:developer_design_abilities) { %i[create_design destroy_design move_design] }
+ let(:developer_design_abilities) { %i[create_design destroy_design move_design update_design] }
let(:design_abilities) { guest_design_abilities + developer_design_abilities }
let_it_be(:guest) { create(:user) }
diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb
index 701fc7ac9ae..f0957ff5cc9 100644
--- a/spec/policies/environment_policy_spec.rb
+++ b/spec/policies/environment_policy_spec.rb
@@ -50,8 +50,7 @@ RSpec.describe EnvironmentPolicy do
with_them do
before do
project.add_member(user, access_level) unless access_level.nil?
- create(:protected_branch, :no_one_can_push,
- name: 'master', project: project)
+ create(:protected_branch, :no_one_can_push, name: 'master', project: project)
end
it { expect(policy).to be_disallowed :stop_environment }
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
index 0575ba3237b..dce97fab252 100644
--- a/spec/policies/global_policy_spec.rb
+++ b/spec/policies/global_policy_spec.rb
@@ -7,8 +7,11 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
let_it_be(:admin_user) { create(:admin) }
let_it_be(:project_bot) { create(:user, :project_bot) }
+ let_it_be(:service_account) { create(:user, :service_account) }
let_it_be(:migration_bot) { create(:user, :migration_bot) }
let_it_be(:security_bot) { create(:user, :security_bot) }
+ let_it_be(:security_policy_bot) { create(:user, :security_policy_bot) }
+ let_it_be(:llm_bot) { create(:user, :llm_bot) }
let_it_be_with_reload(:current_user) { create(:user) }
let_it_be(:user) { create(:user) }
@@ -219,6 +222,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
it { is_expected.to be_allowed(:access_api) }
end
+ context 'service account' do
+ let(:current_user) { service_account }
+
+ it { is_expected.to be_allowed(:access_api) }
+ end
+
context 'migration bot' do
let(:current_user) { migration_bot }
@@ -231,6 +240,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
it { is_expected.to be_disallowed(:access_api) }
end
+ context 'llm bot' do
+ let(:current_user) { llm_bot }
+
+ it { is_expected.to be_disallowed(:access_api) }
+ end
+
context 'user blocked pending approval' do
before do
current_user.block_pending_approval
@@ -285,6 +300,7 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
context 'inactive user' do
before do
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
current_user.update!(confirmed_at: nil, confirmation_sent_at: 5.days.ago)
end
@@ -345,6 +361,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
it { is_expected.to be_disallowed(:receive_notifications) }
end
+ context 'service account' do
+ let(:current_user) { service_account }
+
+ it { is_expected.to be_disallowed(:receive_notifications) }
+ end
+
context 'migration bot' do
let(:current_user) { migration_bot }
@@ -389,6 +411,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
it { is_expected.to be_allowed(:access_git) }
end
+ context 'security policy bot' do
+ let(:current_user) { security_policy_bot }
+
+ it { is_expected.to be_allowed(:access_git) }
+ end
+
describe 'deactivated user' do
before do
current_user.deactivate
@@ -399,6 +427,7 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
describe 'inactive user' do
before do
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
current_user.update!(confirmed_at: nil)
end
@@ -433,6 +462,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
it { is_expected.to be_allowed(:access_git) }
end
+ context 'service account' do
+ let(:current_user) { service_account }
+
+ it { is_expected.to be_allowed(:access_git) }
+ end
+
context 'user blocked pending approval' do
before do
current_user.block_pending_approval
@@ -497,6 +532,7 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
describe 'inactive user' do
before do
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
current_user.update!(confirmed_at: nil)
end
@@ -517,6 +553,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
it { is_expected.to be_allowed(:use_slash_commands) }
end
+ context 'service account' do
+ let(:current_user) { service_account }
+
+ it { is_expected.to be_allowed(:use_slash_commands) }
+ end
+
context 'migration bot' do
let(:current_user) { migration_bot }
@@ -571,6 +613,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
it { is_expected.to be_disallowed(:log_in) }
end
+ context 'service account' do
+ let(:current_user) { service_account }
+
+ it { is_expected.to be_disallowed(:log_in) }
+ end
+
context 'migration bot' do
let(:current_user) { migration_bot }
@@ -583,6 +631,12 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
it { is_expected.to be_disallowed(:log_in) }
end
+ context 'llm bot' do
+ let(:current_user) { llm_bot }
+
+ it { is_expected.to be_disallowed(:log_in) }
+ end
+
context 'user blocked pending approval' do
before do
current_user.block_pending_approval
@@ -592,100 +646,106 @@ RSpec.describe GlobalPolicy, feature_category: :shared do
end
end
- describe 'create_instance_runners' do
- context 'create_runner_workflow flag enabled' do
- before do
- stub_feature_flags(create_runner_workflow: true)
+ describe 'create_instance_runner' do
+ context 'admin' do
+ let(:current_user) { admin_user }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:create_instance_runner) }
end
- context 'admin' do
- let(:current_user) { admin_user }
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(:create_instance_runner) }
+ end
+ end
- context 'when admin mode is enabled', :enable_admin_mode do
- it { is_expected.to be_allowed(:create_instance_runners) }
- end
+ context 'with project_bot' do
+ let(:current_user) { project_bot }
- context 'when admin mode is disabled' do
- it { is_expected.to be_disallowed(:create_instance_runners) }
- end
- end
+ it { is_expected.to be_disallowed(:create_instance_runner) }
+ end
- context 'with project_bot' do
- let(:current_user) { project_bot }
+ context 'with migration_bot' do
+ let(:current_user) { migration_bot }
- it { is_expected.to be_disallowed(:create_instance_runners) }
- end
+ it { is_expected.to be_disallowed(:create_instance_runner) }
+ end
- context 'with migration_bot' do
- let(:current_user) { migration_bot }
+ context 'with security_bot' do
+ let(:current_user) { security_bot }
- it { is_expected.to be_disallowed(:create_instance_runners) }
- end
+ it { is_expected.to be_disallowed(:create_instance_runner) }
+ end
- context 'with security_bot' do
- let(:current_user) { security_bot }
+ context 'with llm_bot' do
+ let(:current_user) { llm_bot }
- it { is_expected.to be_disallowed(:create_instance_runners) }
- end
+ it { is_expected.to be_disallowed(:create_instance_runners) }
+ end
- context 'with regular user' do
- let(:current_user) { user }
+ context 'with regular user' do
+ let(:current_user) { user }
- it { is_expected.to be_disallowed(:create_instance_runners) }
- end
+ it { is_expected.to be_disallowed(:create_instance_runner) }
+ end
- context 'with anonymous' do
- let(:current_user) { nil }
+ context 'with anonymous' do
+ let(:current_user) { nil }
- it { is_expected.to be_disallowed(:create_instance_runners) }
- end
+ it { is_expected.to be_disallowed(:create_instance_runner) }
end
- context 'create_runner_workflow flag disabled' do
+ context 'create_runner_workflow_for_admin flag disabled' do
before do
- stub_feature_flags(create_runner_workflow: false)
+ stub_feature_flags(create_runner_workflow_for_admin: false)
end
context 'admin' do
let(:current_user) { admin_user }
context 'when admin mode is enabled', :enable_admin_mode do
- it { is_expected.to be_disallowed(:create_instance_runners) }
+ it { is_expected.to be_disallowed(:create_instance_runner) }
end
context 'when admin mode is disabled' do
- it { is_expected.to be_disallowed(:create_instance_runners) }
+ it { is_expected.to be_disallowed(:create_instance_runner) }
end
end
context 'with project_bot' do
let(:current_user) { project_bot }
- it { is_expected.to be_disallowed(:create_instance_runners) }
+ it { is_expected.to be_disallowed(:create_instance_runner) }
end
context 'with migration_bot' do
let(:current_user) { migration_bot }
- it { is_expected.to be_disallowed(:create_instance_runners) }
+ it { is_expected.to be_disallowed(:create_instance_runner) }
end
context 'with security_bot' do
let(:current_user) { security_bot }
+ it { is_expected.to be_disallowed(:create_instance_runner) }
+ end
+
+ context 'with llm_bot' do
+ let(:current_user) { llm_bot }
+
it { is_expected.to be_disallowed(:create_instance_runners) }
end
context 'with regular user' do
let(:current_user) { user }
- it { is_expected.to be_disallowed(:create_instance_runners) }
+ it { is_expected.to be_disallowed(:create_instance_runner) }
end
context 'with anonymous' do
let(:current_user) { nil }
- it { is_expected.to be_disallowed(:create_instance_runners) }
+ it { is_expected.to be_disallowed(:create_instance_runner) }
end
end
end
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index 451db9eaf9c..5e85a6e187b 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -2,9 +2,10 @@
require 'spec_helper'
-RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization do
+RSpec.describe GroupPolicy, feature_category: :system_access do
include AdminModeHelper
include_context 'GroupPolicy context'
+ using RSpec::Parameterized::TableSyntax
context 'public group with no user' do
let(:group) { create(:group, :public, :crm_enabled) }
@@ -668,6 +669,177 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
it { is_expected.to be_allowed(:create_projects) }
end
end
+
+ context 'with visibility levels restricted by the administrator' do
+ let_it_be(:public) { Gitlab::VisibilityLevel::PUBLIC }
+ let_it_be(:internal) { Gitlab::VisibilityLevel::INTERNAL }
+ let_it_be(:private) { Gitlab::VisibilityLevel::PRIVATE }
+ let_it_be(:policy) { :create_projects }
+
+ where(:restricted_visibility_levels, :group_visibility, :can_create_project?) do
+ [] | ref(:public) | true
+ [] | ref(:internal) | true
+ [] | ref(:private) | true
+ [ref(:public)] | ref(:public) | true
+ [ref(:public)] | ref(:internal) | true
+ [ref(:public)] | ref(:private) | true
+ [ref(:internal)] | ref(:public) | true
+ [ref(:internal)] | ref(:internal) | true
+ [ref(:internal)] | ref(:private) | true
+ [ref(:private)] | ref(:public) | true
+ [ref(:private)] | ref(:internal) | true
+ [ref(:private)] | ref(:private) | false
+ [ref(:public), ref(:internal)] | ref(:public) | true
+ [ref(:public), ref(:internal)] | ref(:internal) | true
+ [ref(:public), ref(:internal)] | ref(:private) | true
+ [ref(:public), ref(:private)] | ref(:public) | true
+ [ref(:public), ref(:private)] | ref(:internal) | true
+ [ref(:public), ref(:private)] | ref(:private) | false
+ [ref(:private), ref(:internal)] | ref(:public) | true
+ [ref(:private), ref(:internal)] | ref(:internal) | false
+ [ref(:private), ref(:internal)] | ref(:private) | false
+ [ref(:public), ref(:internal), ref(:private)] | ref(:public) | false
+ [ref(:public), ref(:internal), ref(:private)] | ref(:internal) | false
+ [ref(:public), ref(:internal), ref(:private)] | ref(:private) | false
+ end
+
+ with_them do
+ before do
+ group.update!(visibility_level: group_visibility)
+ stub_application_setting(restricted_visibility_levels: restricted_visibility_levels)
+ end
+
+ context 'with non-admin user' do
+ let(:current_user) { owner }
+
+ it { is_expected.to(can_create_project? ? be_allowed(policy) : be_disallowed(policy)) }
+ end
+
+ context 'with admin user', :enable_admin_mode do
+ let(:current_user) { admin }
+
+ it { is_expected.to be_allowed(policy) }
+ end
+ end
+ end
+ end
+
+ context 'import_projects' do
+ before do
+ group.update!(project_creation_level: project_creation_level)
+ end
+
+ context 'when group has no project creation level set' do
+ let(:project_creation_level) { nil }
+
+ context 'reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+
+ context 'developer' do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+
+ context 'maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_allowed(:import_projects) }
+ end
+
+ context 'owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:import_projects) }
+ end
+ end
+
+ context 'when group has project creation level set to no one' do
+ let(:project_creation_level) { ::Gitlab::Access::NO_ONE_PROJECT_ACCESS }
+
+ context 'reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+
+ context 'developer' do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+
+ context 'maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+
+ context 'owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+ end
+
+ context 'when group has project creation level set to maintainer only' do
+ let(:project_creation_level) { ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS }
+
+ context 'reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+
+ context 'developer' do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+
+ context 'maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_allowed(:import_projects) }
+ end
+
+ context 'owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:import_projects) }
+ end
+ end
+
+ context 'when group has project creation level set to developers + maintainer' do
+ let(:project_creation_level) { ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS }
+
+ context 'reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+
+ context 'developer' do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+
+ context 'maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_allowed(:import_projects) }
+ end
+
+ context 'owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:import_projects) }
+ end
+ end
end
context 'create_subgroup' do
@@ -735,10 +907,7 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
it_behaves_like 'clusterable policies' do
let(:clusterable) { create(:group, :crm_enabled) }
let(:cluster) do
- create(:cluster,
- :provided_by_gcp,
- :group,
- groups: [clusterable])
+ create(:cluster, :provided_by_gcp, :group, groups: [clusterable])
end
end
@@ -763,7 +932,7 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
end
end
- %w(guest reporter developer maintainer owner).each do |role|
+ %w[guest reporter developer maintainer owner].each do |role|
context role do
let(:current_user) { send(role) }
@@ -931,15 +1100,14 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
end
describe 'observability' do
- using RSpec::Parameterized::TableSyntax
-
- let(:allowed) { be_allowed(:read_observability) }
- let(:disallowed) { be_disallowed(:read_observability) }
+ let(:allowed_admin) { be_allowed(:read_observability) && be_allowed(:admin_observability) }
+ let(:allowed_read) { be_allowed(:read_observability) && be_disallowed(:admin_observability) }
+ let(:disallowed) { be_disallowed(:read_observability) && be_disallowed(:admin_observability) }
# rubocop:disable Layout/LineLength
where(:feature_enabled, :admin_matcher, :owner_matcher, :maintainer_matcher, :developer_matcher, :reporter_matcher, :guest_matcher, :non_member_matcher, :anonymous_matcher) do
false | ref(:disallowed) | ref(:disallowed) | ref(:disallowed) | ref(:disallowed) | ref(:disallowed) | ref(:disallowed) | ref(:disallowed) | ref(:disallowed)
- true | ref(:allowed) | ref(:allowed) | ref(:allowed) | ref(:allowed) | ref(:disallowed) | ref(:disallowed) | ref(:disallowed) | ref(:disallowed)
+ true | ref(:allowed_admin) | ref(:allowed_admin) | ref(:allowed_admin) | ref(:allowed_read) | ref(:disallowed) | ref(:disallowed) | ref(:disallowed) | ref(:disallowed)
end
# rubocop:enable Layout/LineLength
@@ -1274,7 +1442,7 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
end
end
- describe 'create_group_runners' do
+ describe 'create_runner' do
shared_examples 'disallowed when group runner registration disabled' do
context 'with group runner registration disabled' do
before do
@@ -1285,34 +1453,34 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
context 'with specific group runner registration enabled' do
let(:runner_registration_enabled) { true }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with specific group runner registration disabled' do
let(:runner_registration_enabled) { false }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
end
- context 'create_runner_workflow flag enabled' do
+ context 'create_runner_workflow_for_namespace flag enabled' do
before do
- stub_feature_flags(create_runner_workflow: true)
+ stub_feature_flags(create_runner_workflow_for_namespace: [group])
end
context 'admin' do
let(:current_user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
- it { is_expected.to be_allowed(:create_group_runners) }
+ it { is_expected.to be_allowed(:create_runner) }
context 'with specific group runner registration disabled' do
before do
group.runner_registration_enabled = false
end
- it { is_expected.to be_allowed(:create_group_runners) }
+ it { is_expected.to be_allowed(:create_runner) }
end
context 'with group runner registration disabled' do
@@ -1324,26 +1492,26 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
context 'with specific group runner registration enabled' do
let(:runner_registration_enabled) { true }
- it { is_expected.to be_allowed(:create_group_runners) }
+ it { is_expected.to be_allowed(:create_runner) }
end
context 'with specific group runner registration disabled' do
let(:runner_registration_enabled) { false }
- it { is_expected.to be_allowed(:create_group_runners) }
+ it { is_expected.to be_allowed(:create_runner) }
end
end
end
context 'when admin mode is disabled' do
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
context 'with owner' do
let(:current_user) { owner }
- it { is_expected.to be_allowed(:create_group_runners) }
+ it { is_expected.to be_allowed(:create_runner) }
it_behaves_like 'disallowed when group runner registration disabled'
end
@@ -1351,65 +1519,67 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
context 'with maintainer' do
let(:current_user) { maintainer }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with reporter' do
let(:current_user) { reporter }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with guest' do
let(:current_user) { guest }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with developer' do
let(:current_user) { developer }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with anonymous' do
let(:current_user) { nil }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
- context 'with create_runner_workflow flag disabled' do
+ context 'with create_runner_workflow_for_namespace flag disabled' do
before do
- stub_feature_flags(create_runner_workflow: false)
+ stub_feature_flags(create_runner_workflow_for_namespace: [other_group])
end
+ let_it_be(:other_group) { create(:group) }
+
context 'admin' do
let(:current_user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
context 'with specific group runner registration disabled' do
before do
group.runner_registration_enabled = false
end
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
it_behaves_like 'disallowed when group runner registration disabled'
end
context 'when admin mode is disabled' do
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
context 'with owner' do
let(:current_user) { owner }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
it_behaves_like 'disallowed when group runner registration disabled'
end
@@ -1417,31 +1587,31 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
context 'with maintainer' do
let(:current_user) { maintainer }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with reporter' do
let(:current_user) { reporter }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with guest' do
let(:current_user) { guest }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with developer' do
let(:current_user) { developer }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with anonymous' do
let(:current_user) { nil }
- it { is_expected.to be_disallowed(:create_group_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
end
@@ -1543,8 +1713,6 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
describe 'read_usage_quotas policy' do
context 'reading usage quotas' do
- using RSpec::Parameterized::TableSyntax
-
let(:policy) { :read_usage_quotas }
where(:role, :admin_mode, :allowed) do
@@ -1568,4 +1736,28 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
end
end
end
+
+ describe 'achievements' do
+ let(:current_user) { owner }
+
+ specify { is_expected.to be_allowed(:read_achievement) }
+ specify { is_expected.to be_allowed(:admin_achievement) }
+ specify { is_expected.to be_allowed(:award_achievement) }
+
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(achievements: false)
+ end
+
+ specify { is_expected.to be_disallowed(:read_achievement) }
+ specify { is_expected.to be_disallowed(:admin_achievement) }
+ specify { is_expected.to be_disallowed(:award_achievement) }
+ end
+
+ context 'when current user can not see the group' do
+ let(:current_user) { non_group_member }
+
+ specify { is_expected.to be_allowed(:read_achievement) }
+ end
+ end
end
diff --git a/spec/policies/identity_provider_policy_spec.rb b/spec/policies/identity_provider_policy_spec.rb
index f6b4e15cff9..cd617a28364 100644
--- a/spec/policies/identity_provider_policy_spec.rb
+++ b/spec/policies/identity_provider_policy_spec.rb
@@ -19,13 +19,11 @@ RSpec.describe IdentityProviderPolicy do
it { is_expected.not_to be_allowed(:unlink) }
end
- %w[saml cas3].each do |provider_name|
- context "when provider is #{provider_name}" do
- let(:provider) { provider_name }
+ context "when provider is saml" do
+ let(:provider) { 'saml' }
- it { is_expected.to be_allowed(:link) }
- it { is_expected.not_to be_allowed(:unlink) }
- end
+ it { is_expected.to be_allowed(:link) }
+ it { is_expected.not_to be_allowed(:unlink) }
end
end
end
diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb
index 17558787966..1142d6f80fd 100644
--- a/spec/policies/issue_policy_spec.rb
+++ b/spec/policies/issue_policy_spec.rb
@@ -27,8 +27,8 @@ RSpec.describe IssuePolicy, feature_category: :team_planning do
shared_examples 'support bot with service desk enabled' do
before do
- allow(::Gitlab::IncomingEmail).to receive(:enabled?) { true }
- allow(::Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true }
+ allow(::Gitlab::Email::IncomingEmail).to receive(:enabled?) { true }
+ allow(::Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?) { true }
project.update!(service_desk_enabled: true)
end
diff --git a/spec/policies/metrics/dashboard/annotation_policy_spec.rb b/spec/policies/metrics/dashboard/annotation_policy_spec.rb
index 9ea9f843f2c..2d1ef0ee0cb 100644
--- a/spec/policies/metrics/dashboard/annotation_policy_spec.rb
+++ b/spec/policies/metrics/dashboard/annotation_policy_spec.rb
@@ -14,9 +14,7 @@ RSpec.describe Metrics::Dashboard::AnnotationPolicy, :models do
end
it { expect(policy).to be_disallowed :read_metrics_dashboard_annotation }
- it { expect(policy).to be_disallowed :create_metrics_dashboard_annotation }
- it { expect(policy).to be_disallowed :update_metrics_dashboard_annotation }
- it { expect(policy).to be_disallowed :delete_metrics_dashboard_annotation }
+ it { expect(policy).to be_disallowed :admin_metrics_dashboard_annotation }
end
context 'when reporter' do
@@ -25,9 +23,7 @@ RSpec.describe Metrics::Dashboard::AnnotationPolicy, :models do
end
it { expect(policy).to be_allowed :read_metrics_dashboard_annotation }
- it { expect(policy).to be_disallowed :create_metrics_dashboard_annotation }
- it { expect(policy).to be_disallowed :update_metrics_dashboard_annotation }
- it { expect(policy).to be_disallowed :delete_metrics_dashboard_annotation }
+ it { expect(policy).to be_disallowed :admin_metrics_dashboard_annotation }
end
context 'when developer' do
@@ -36,9 +32,7 @@ RSpec.describe Metrics::Dashboard::AnnotationPolicy, :models do
end
it { expect(policy).to be_allowed :read_metrics_dashboard_annotation }
- it { expect(policy).to be_allowed :create_metrics_dashboard_annotation }
- it { expect(policy).to be_allowed :update_metrics_dashboard_annotation }
- it { expect(policy).to be_allowed :delete_metrics_dashboard_annotation }
+ it { expect(policy).to be_allowed :admin_metrics_dashboard_annotation }
end
context 'when maintainer' do
@@ -47,9 +41,7 @@ RSpec.describe Metrics::Dashboard::AnnotationPolicy, :models do
end
it { expect(policy).to be_allowed :read_metrics_dashboard_annotation }
- it { expect(policy).to be_allowed :create_metrics_dashboard_annotation }
- it { expect(policy).to be_allowed :update_metrics_dashboard_annotation }
- it { expect(policy).to be_allowed :delete_metrics_dashboard_annotation }
+ it { expect(policy).to be_allowed :admin_metrics_dashboard_annotation }
end
end
diff --git a/spec/policies/namespaces/user_namespace_policy_spec.rb b/spec/policies/namespaces/user_namespace_policy_spec.rb
index bb821490e30..3488f33f15c 100644
--- a/spec/policies/namespaces/user_namespace_policy_spec.rb
+++ b/spec/policies/namespaces/user_namespace_policy_spec.rb
@@ -2,13 +2,13 @@
require 'spec_helper'
-RSpec.describe Namespaces::UserNamespacePolicy do
+RSpec.describe Namespaces::UserNamespacePolicy, feature_category: :subgroups do
let_it_be(:user) { create(:user) }
let_it_be(:owner) { create(:user) }
let_it_be(:admin) { create(:admin) }
let_it_be(:namespace) { create(:user_namespace, owner: owner) }
- let(:owner_permissions) { [:owner_access, :create_projects, :admin_namespace, :read_namespace, :read_statistics, :transfer_projects, :admin_package, :read_billing, :edit_billing] }
+ let(:owner_permissions) { [:owner_access, :create_projects, :admin_namespace, :read_namespace, :read_statistics, :transfer_projects, :admin_package, :read_billing, :edit_billing, :import_projects] }
subject { described_class.new(current_user, namespace) }
@@ -34,6 +34,7 @@ RSpec.describe Namespaces::UserNamespacePolicy do
it { is_expected.to be_disallowed(:create_projects) }
it { is_expected.to be_disallowed(:transfer_projects) }
+ it { is_expected.to be_disallowed(:import_projects) }
end
context 'bot user' do
@@ -41,6 +42,7 @@ RSpec.describe Namespaces::UserNamespacePolicy do
it { is_expected.to be_disallowed(:create_projects) }
it { is_expected.to be_disallowed(:transfer_projects) }
+ it { is_expected.to be_disallowed(:import_projects) }
end
end
@@ -103,4 +105,26 @@ RSpec.describe Namespaces::UserNamespacePolicy do
it { is_expected.to be_disallowed(:create_projects) }
end
end
+
+ describe 'import projects' do
+ context 'when user can import projects' do
+ let(:current_user) { owner }
+
+ before do
+ allow(current_user).to receive(:can_import_project?).and_return(true)
+ end
+
+ it { is_expected.to be_allowed(:import_projects) }
+ end
+
+ context 'when user cannot create projects' do
+ let(:current_user) { user }
+
+ before do
+ allow(current_user).to receive(:can_import_project?).and_return(false)
+ end
+
+ it { is_expected.to be_disallowed(:import_projects) }
+ end
+ end
end
diff --git a/spec/policies/project_group_link_policy_spec.rb b/spec/policies/project_group_link_policy_spec.rb
index 7c8a4619e47..9461f33decb 100644
--- a/spec/policies/project_group_link_policy_spec.rb
+++ b/spec/policies/project_group_link_policy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProjectGroupLinkPolicy, feature_category: :authentication_and_authorization do
+RSpec.describe ProjectGroupLinkPolicy, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:group2) { create(:group, :private) }
diff --git a/spec/policies/project_hook_policy_spec.rb b/spec/policies/project_hook_policy_spec.rb
index cfa7b6ee4bf..a71940c319e 100644
--- a/spec/policies/project_hook_policy_spec.rb
+++ b/spec/policies/project_hook_policy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProjectHookPolicy do
+RSpec.describe ProjectHookPolicy, feature_category: :integrations do
let_it_be(:user) { create(:user) }
let(:hook) { create(:project_hook) }
@@ -15,7 +15,7 @@ RSpec.describe ProjectHookPolicy do
end
it "cannot read and destroy web-hooks" do
- expect(policy).to be_disallowed(:read_web_hook, :destroy_web_hook)
+ expect(policy).to be_disallowed(:destroy_web_hook)
end
end
@@ -25,7 +25,7 @@ RSpec.describe ProjectHookPolicy do
end
it "can read and destroy web-hooks" do
- expect(policy).to be_allowed(:read_web_hook, :destroy_web_hook)
+ expect(policy).to be_allowed(:destroy_web_hook)
end
end
end
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index 0c359b80fb5..ae2a11bdbf0 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorization do
+RSpec.describe ProjectPolicy, feature_category: :system_access do
include ExternalAuthorizationServiceHelpers
include AdminModeHelper
include_context 'ProjectPolicy context'
@@ -441,6 +441,36 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
end
end
+ context 'importing work items' do
+ %w(reporter developer maintainer owner).each do |role|
+ context "with #{role}" do
+ let(:current_user) { send(role) }
+
+ it { is_expected.to be_allowed(:import_work_items) }
+ end
+ end
+
+ %w(guest anonymous).each do |role|
+ context "with #{role}" do
+ let(:current_user) { send(role) }
+
+ it { is_expected.to be_disallowed(:import_work_items) }
+ end
+ end
+
+ context 'with an admin' do
+ let(:current_user) { admin }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect_allowed(:import_work_items) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { expect_disallowed(:import_work_items) }
+ end
+ end
+ end
+
context 'reading usage quotas' do
%w(maintainer owner).each do |role|
context "with #{role}" do
@@ -1259,7 +1289,7 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
it { is_expected.to be_allowed(:create_package) }
it { is_expected.to be_allowed(:read_package) }
it { is_expected.to be_allowed(:read_project) }
- it { is_expected.to be_disallowed(:destroy_package) }
+ it { is_expected.to be_allowed(:destroy_package) }
it_behaves_like 'package access with repository disabled'
end
@@ -1440,6 +1470,28 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
end
end
+ context 'infrastructure aws feature' do
+ %w(guest reporter developer).each do |role|
+ context role do
+ let(:current_user) { send(role) }
+
+ it 'disallows managing aws' do
+ expect_disallowed(:admin_project_aws)
+ end
+ end
+ end
+
+ %w(maintainer owner).each do |role|
+ context role do
+ let(:current_user) { send(role) }
+
+ it 'allows managing aws' do
+ expect_allowed(:admin_project_aws)
+ end
+ end
+ end
+ end
+
describe 'design permissions' do
include DesignManagementTestHelpers
@@ -2315,6 +2367,12 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
describe 'infrastructure feature' do
using RSpec::Parameterized::TableSyntax
+ before do
+ # assuming the default setting terraform_state.enabled=true
+ # the terraform_state permissions should follow the same logic as the other features
+ stub_config(terraform_state: { enabled: true })
+ end
+
let(:guest_permissions) { [] }
let(:developer_permissions) do
@@ -2378,10 +2436,35 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
end
end
end
+
+ context 'when terraform state management is disabled' do
+ before do
+ stub_config(terraform_state: { enabled: false })
+ end
+
+ with_them do
+ let(:current_user) { user_subject(role) }
+ let(:project) { project_subject(project_visibility) }
+
+ let(:developer_permissions) do
+ [:read_terraform_state]
+ end
+
+ let(:maintainer_permissions) do
+ developer_permissions + [:admin_terraform_state]
+ end
+
+ it 'always disallows the terraform_state feature' do
+ project.project_feature.update!(infrastructure_access_level: access_level)
+
+ expect_disallowed(*permissions_abilities(role))
+ end
+ end
+ end
end
describe 'access_security_and_compliance' do
- context 'when the "Security & Compliance" is enabled' do
+ context 'when the "Security and Compliance" is enabled' do
before do
project.project_feature.update!(security_and_compliance_access_level: Featurable::PRIVATE)
end
@@ -2427,7 +2510,7 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
end
end
- context 'when the "Security & Compliance" is not enabled' do
+ context 'when the "Security and Compliance" is not enabled' do
before do
project.project_feature.update!(security_and_compliance_access_level: Featurable::DISABLED)
end
@@ -2727,6 +2810,14 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
it { is_expected.to be_allowed(:register_project_runners) }
end
+
+ context 'with specific project runner registration disabled' do
+ before do
+ project.update!(runner_registration_enabled: false)
+ end
+
+ it { is_expected.to be_allowed(:register_project_runners) }
+ end
end
context 'when admin mode is disabled' do
@@ -2746,6 +2837,14 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
it { is_expected.to be_disallowed(:register_project_runners) }
end
+
+ context 'with specific project runner registration disabled' do
+ before do
+ project.update!(runner_registration_enabled: false)
+ end
+
+ it { is_expected.to be_disallowed(:register_project_runners) }
+ end
end
context 'with maintainer' do
@@ -2779,148 +2878,258 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
end
end
- describe 'create_project_runners' do
- context 'create_runner_workflow flag enabled' do
+ describe 'create_runner' do
+ context 'create_runner_workflow_for_namespace flag enabled' do
before do
- stub_feature_flags(create_runner_workflow: true)
+ stub_feature_flags(create_runner_workflow_for_namespace: [project.namespace])
end
context 'admin' do
let(:current_user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
- it { is_expected.to be_allowed(:create_project_runners) }
+ it { is_expected.to be_allowed(:create_runner) }
context 'with project runner registration disabled' do
before do
stub_application_setting(valid_runner_registrars: ['group'])
end
- it { is_expected.to be_allowed(:create_project_runners) }
+ it { is_expected.to be_allowed(:create_runner) }
+ end
+
+ context 'with specific project runner registration disabled' do
+ before do
+ project.update!(runner_registration_enabled: false)
+ end
+
+ it { is_expected.to be_allowed(:create_runner) }
end
end
context 'when admin mode is disabled' do
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
context 'with owner' do
let(:current_user) { owner }
- it { is_expected.to be_allowed(:create_project_runners) }
+ it { is_expected.to be_allowed(:create_runner) }
context 'with project runner registration disabled' do
before do
stub_application_setting(valid_runner_registrars: ['group'])
end
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
+ end
+
+ context 'with specific project runner registration disabled' do
+ before do
+ project.update!(runner_registration_enabled: false)
+ end
+
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
context 'with maintainer' do
let(:current_user) { maintainer }
- it { is_expected.to be_allowed(:create_project_runners) }
+ it { is_expected.to be_allowed(:create_runner) }
end
context 'with reporter' do
let(:current_user) { reporter }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with guest' do
let(:current_user) { guest }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with developer' do
let(:current_user) { developer }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with anonymous' do
let(:current_user) { nil }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
- context 'create_runner_workflow flag disabled' do
+ context 'create_runner_workflow_for_namespace flag disabled' do
before do
- stub_feature_flags(create_runner_workflow: false)
+ stub_feature_flags(create_runner_workflow_for_namespace: [group])
end
context 'admin' do
let(:current_user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
context 'with project runner registration disabled' do
before do
stub_application_setting(valid_runner_registrars: ['group'])
end
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
+ end
+
+ context 'with specific project runner registration disabled' do
+ before do
+ project.update!(runner_registration_enabled: false)
+ end
+
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
context 'when admin mode is disabled' do
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
context 'with owner' do
let(:current_user) { owner }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
context 'with project runner registration disabled' do
before do
stub_application_setting(valid_runner_registrars: ['group'])
end
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
+ end
+
+ context 'with specific project runner registration disabled' do
+ before do
+ project.update!(runner_registration_enabled: false)
+ end
+
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
context 'with maintainer' do
let(:current_user) { maintainer }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with reporter' do
let(:current_user) { reporter }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with guest' do
let(:current_user) { guest }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with developer' do
let(:current_user) { developer }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
context 'with anonymous' do
let(:current_user) { nil }
- it { is_expected.to be_disallowed(:create_project_runners) }
+ it { is_expected.to be_disallowed(:create_runner) }
end
end
end
+ describe 'admin_project_runners' do
+ context 'admin' do
+ let(:current_user) { admin }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:create_runner) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(:create_runner) }
+ end
+ end
+
+ context 'with owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:create_runner) }
+ end
+
+ context 'with maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_allowed(:create_runner) }
+ end
+
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(:create_runner) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:create_runner) }
+ end
+
+ context 'with developer' do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_disallowed(:create_runner) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:create_runner) }
+ end
+ end
+
+ describe 'read_project_runners' do
+ subject(:policy) { described_class.new(user, project) }
+
+ context 'with maintainer' do
+ let(:user) { maintainer }
+
+ it { is_expected.to be_allowed(:read_project_runners) }
+ end
+
+ context 'with admin', :enable_admin_mode do
+ let(:user) { admin }
+
+ it { is_expected.to be_allowed(:read_project_runners) }
+ end
+
+ context 'with reporter' do
+ let(:user) { reporter }
+
+ it { is_expected.to be_disallowed(:read_project_runners) }
+ end
+
+ context 'when the user is not part of the project' do
+ let(:user) { non_member }
+
+ it { is_expected.to be_disallowed(:read_project_runners) }
+ end
+ end
+
describe 'update_sentry_issue' do
using RSpec::Parameterized::TableSyntax
@@ -3042,6 +3251,18 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
end
end
+ describe 'read_namespace_catalog' do
+ let(:current_user) { owner }
+
+ specify { is_expected.to be_disallowed(:read_namespace_catalog) }
+ end
+
+ describe 'add_catalog_resource' do
+ let(:current_user) { owner }
+
+ specify { is_expected.to be_disallowed(:read_namespace_catalog) }
+ end
+
private
def project_subject(project_type)
diff --git a/spec/presenters/blob_presenter_spec.rb b/spec/presenters/blob_presenter_spec.rb
index f8cba8e9203..f10150b819a 100644
--- a/spec/presenters/blob_presenter_spec.rb
+++ b/spec/presenters/blob_presenter_spec.rb
@@ -60,9 +60,13 @@ RSpec.describe BlobPresenter do
describe '#pipeline_editor_path' do
context 'when blob is .gitlab-ci.yml' do
before do
- project.repository.create_file(user, '.gitlab-ci.yml', '',
- message: 'Add a ci file',
- branch_name: 'main')
+ project.repository.create_file(
+ user,
+ '.gitlab-ci.yml',
+ '',
+ message: 'Add a ci file',
+ branch_name: 'main'
+ )
end
let(:blob) { repository.blob_at('main', '.gitlab-ci.yml') }
diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb
index 6bf36a52419..58d19ae2332 100644
--- a/spec/presenters/ci/build_presenter_spec.rb
+++ b/spec/presenters/ci/build_presenter_spec.rb
@@ -174,6 +174,22 @@ RSpec.describe Ci::BuildPresenter do
end
end
+ describe '#failure_message' do
+ let_it_be(:build) { create(:ci_build, :failed, failure_reason: 2) }
+
+ it 'returns a verbose failure message' do
+ expect(subject.failure_message).to eq('There has been an API failure, please try again')
+ end
+
+ context 'when the build has not failed' do
+ let_it_be(:build) { create(:ci_build, :success, failure_reason: 2) }
+
+ it 'does not return any failure message' do
+ expect(subject.failure_message).to be_nil
+ end
+ end
+ end
+
describe '#callout_failure_message' do
let(:build) { create(:ci_build, :failed, :api_failure) }
diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb
index dedfe6925c5..3f30127b07f 100644
--- a/spec/presenters/ci/build_runner_presenter_spec.rb
+++ b/spec/presenters/ci/build_runner_presenter_spec.rb
@@ -228,16 +228,20 @@ RSpec.describe Ci::BuildRunnerPresenter do
let(:pipeline) { build.pipeline }
it 'returns the correct refspecs' do
- is_expected.to contain_exactly("+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}",
- "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}")
+ is_expected.to contain_exactly(
+ "+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}",
+ "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}"
+ )
end
context 'when ref is tag' do
let(:build) { create(:ci_build, :tag) }
it 'returns the correct refspecs' do
- is_expected.to contain_exactly("+refs/tags/#{build.ref}:refs/tags/#{build.ref}",
- "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}")
+ is_expected.to contain_exactly(
+ "+refs/tags/#{build.ref}:refs/tags/#{build.ref}",
+ "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}"
+ )
end
context 'when GIT_DEPTH is zero' do
@@ -246,9 +250,11 @@ RSpec.describe Ci::BuildRunnerPresenter do
end
it 'returns the correct refspecs' do
- is_expected.to contain_exactly('+refs/tags/*:refs/tags/*',
- '+refs/heads/*:refs/remotes/origin/*',
- "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}")
+ is_expected.to contain_exactly(
+ '+refs/tags/*:refs/tags/*',
+ '+refs/heads/*:refs/remotes/origin/*',
+ "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}"
+ )
end
end
end
@@ -273,10 +279,11 @@ RSpec.describe Ci::BuildRunnerPresenter do
end
it 'returns the correct refspecs' do
- is_expected
- .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
- '+refs/heads/*:refs/remotes/origin/*',
- '+refs/tags/*:refs/tags/*')
+ is_expected.to contain_exactly(
+ "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ '+refs/heads/*:refs/remotes/origin/*',
+ '+refs/tags/*:refs/tags/*'
+ )
end
end
@@ -284,8 +291,10 @@ RSpec.describe Ci::BuildRunnerPresenter do
let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) }
it 'returns the correct refspecs' do
- is_expected.to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
- "+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}")
+ is_expected.to contain_exactly(
+ "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ "+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}"
+ )
end
end
end
@@ -301,9 +310,10 @@ RSpec.describe Ci::BuildRunnerPresenter do
end
it 'exposes the persistent pipeline ref' do
- is_expected
- .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
- "+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}")
+ is_expected.to contain_exactly(
+ "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ "+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}"
+ )
end
end
end
@@ -327,16 +337,14 @@ RSpec.describe Ci::BuildRunnerPresenter do
context 'when there is a file variable to expand' do
before_all do
- create(:ci_variable, project: project,
- key: 'regular_var',
- value: 'value 1')
- create(:ci_variable, project: project,
- key: 'file_var',
- value: 'value 2',
- variable_type: :file)
- create(:ci_variable, project: project,
- key: 'var_with_variables',
- value: 'value 3 and $regular_var and $file_var and $undefined_var')
+ create(:ci_variable, project: project, key: 'regular_var', value: 'value 1')
+ create(:ci_variable, project: project, key: 'file_var', value: 'value 2', variable_type: :file)
+ create(
+ :ci_variable,
+ project: project,
+ key: 'var_with_variables',
+ value: 'value 3 and $regular_var and $file_var and $undefined_var'
+ )
end
it 'returns variables with expanded' do
@@ -353,16 +361,14 @@ RSpec.describe Ci::BuildRunnerPresenter do
context 'when there is a raw variable to expand' do
before_all do
- create(:ci_variable, project: project,
- key: 'regular_var',
- value: 'value 1')
- create(:ci_variable, project: project,
- key: 'raw_var',
- value: 'value 2',
- raw: true)
- create(:ci_variable, project: project,
- key: 'var_with_variables',
- value: 'value 3 and $regular_var and $raw_var and $undefined_var')
+ create(:ci_variable, project: project, key: 'regular_var', value: 'value 1')
+ create(:ci_variable, project: project, key: 'raw_var', value: 'value 2', raw: true)
+ create(
+ :ci_variable,
+ project: project,
+ key: 'var_with_variables',
+ value: 'value 3 and $regular_var and $raw_var and $undefined_var'
+ )
end
it 'returns expanded variables without expanding raws' do
diff --git a/spec/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter_spec.rb b/spec/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter_spec.rb
index 94a743d4d89..99c82795210 100644
--- a/spec/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter_spec.rb
+++ b/spec/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineArtifacts::CodeQualityMrDiffPresenter do
+RSpec.describe Ci::PipelineArtifacts::CodeQualityMrDiffPresenter, feature_category: :code_quality do
let(:pipeline_artifact) { create(:ci_pipeline_artifact, :with_codequality_mr_diff_report) }
let(:merge_request) { double(id: 123456789, new_paths: filenames) }
diff --git a/spec/presenters/commit_presenter_spec.rb b/spec/presenters/commit_presenter_spec.rb
index eba393da2b7..5ac270a8df8 100644
--- a/spec/presenters/commit_presenter_spec.rb
+++ b/spec/presenters/commit_presenter_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CommitPresenter do
+RSpec.describe CommitPresenter, feature_category: :source_code_management do
let(:commit) { project.commit }
let(:presenter) { described_class.new(commit, current_user: user) }
@@ -95,4 +95,15 @@ RSpec.describe CommitPresenter do
expect(presenter.signature_html).to eq(signature)
end
end
+
+ describe '#tags_for_display' do
+ subject { presenter.tags_for_display }
+
+ let(:stubbed_tags) { %w[refs/tags/v1.0 refs/tags/v1.1] }
+
+ it 'removes the refs prefix from tags' do
+ allow(commit).to receive(:referenced_by).and_return(stubbed_tags)
+ expect(subject).to eq(%w[v1.0 v1.1])
+ end
+ end
end
diff --git a/spec/presenters/issue_email_participant_presenter_spec.rb b/spec/presenters/issue_email_participant_presenter_spec.rb
index c270fae3058..993cc9c235b 100644
--- a/spec/presenters/issue_email_participant_presenter_spec.rb
+++ b/spec/presenters/issue_email_participant_presenter_spec.rb
@@ -3,54 +3,49 @@
require 'spec_helper'
RSpec.describe IssueEmailParticipantPresenter, feature_category: :service_desk do
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/389247
- # for details around build_stubbed for access level
- let_it_be(:non_member) { create(:user) } # rubocop:todo RSpec/FactoryBot/AvoidCreate
- let_it_be(:guest) { create(:user) } # rubocop:todo RSpec/FactoryBot/AvoidCreate
- let_it_be(:reporter) { create(:user) } # rubocop:todo RSpec/FactoryBot/AvoidCreate
- let_it_be(:developer) { create(:user) } # rubocop:todo RSpec/FactoryBot/AvoidCreate
- let_it_be(:group) { create(:group) } # rubocop:todo RSpec/FactoryBot/AvoidCreate
- let_it_be(:project) { create(:project, group: group) } # rubocop:todo RSpec/FactoryBot/AvoidCreate
- let_it_be(:issue) { build_stubbed(:issue, project: project) }
- let_it_be(:participant) { build_stubbed(:issue_email_participant, issue: issue, email: 'any@email.com') }
-
- let(:user) { nil }
- let(:presenter) { described_class.new(participant, current_user: user) }
+ let(:user) { build_stubbed(:user) }
+ let(:project) { build_stubbed(:project) }
+ let(:issue) { build_stubbed(:issue, project: project) }
+ let(:participant) { build_stubbed(:issue_email_participant, issue: issue, email: 'any@example.com') }
let(:obfuscated_email) { 'an*****@e*****.c**' }
- let(:email) { 'any@email.com' }
+ let(:email) { 'any@example.com' }
- before_all do
- group.add_guest(guest)
- group.add_reporter(reporter)
- group.add_developer(developer)
- end
+ subject(:presenter) { described_class.new(participant, current_user: user) }
describe '#email' do
subject { presenter.email }
- it { is_expected.to eq(obfuscated_email) }
+ context 'when anonymous' do
+ let(:user) { nil }
+
+ it { is_expected.to eq(obfuscated_email) }
+ end
context 'with signed in user' do
+ before do
+ stub_member_access_level(project, access_level => user) if access_level
+ end
+
context 'when user has no role in project' do
- let(:user) { non_member }
+ let(:access_level) { nil }
it { is_expected.to eq(obfuscated_email) }
end
context 'when user has guest role in project' do
- let(:user) { guest }
+ let(:access_level) { :guest }
it { is_expected.to eq(obfuscated_email) }
end
context 'when user has reporter role in project' do
- let(:user) { reporter }
+ let(:access_level) { :reporter }
it { is_expected.to eq(email) }
end
context 'when user has developer role in project' do
- let(:user) { developer }
+ let(:access_level) { :developer }
it { is_expected.to eq(email) }
end
diff --git a/spec/presenters/issue_presenter_spec.rb b/spec/presenters/issue_presenter_spec.rb
index 22a86d04a5a..f9a3be9bbed 100644
--- a/spec/presenters/issue_presenter_spec.rb
+++ b/spec/presenters/issue_presenter_spec.rb
@@ -29,25 +29,15 @@ RSpec.describe IssuePresenter do
describe '#web_url' do
it 'returns correct path' do
- expect(presenter.web_url).to eq("http://localhost/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}")
+ expect(presenter.web_url).to eq("http://localhost/#{project.full_path}/-/issues/#{presented_issue.iid}")
end
context 'when issue type is task' do
let(:presented_issue) { task }
- context 'when use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- end
-
- it 'returns a work item url for the task' do
- expect(presenter.web_url).to eq(project_work_items_url(project, work_items_path: presented_issue.id))
- end
- end
-
it 'returns a work item url using iid for the task' do
expect(presenter.web_url).to eq(
- project_work_items_url(project, work_items_path: presented_issue.iid, iid_path: true)
+ project_work_items_url(project, work_items_path: presented_issue.iid)
)
end
end
@@ -69,25 +59,15 @@ RSpec.describe IssuePresenter do
describe '#issue_path' do
it 'returns correct path' do
- expect(presenter.issue_path).to eq("/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}")
+ expect(presenter.issue_path).to eq("/#{project.full_path}/-/issues/#{presented_issue.iid}")
end
context 'when issue type is task' do
let(:presented_issue) { task }
- context 'when use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- end
-
- it 'returns a work item path for the task' do
- expect(presenter.issue_path).to eq(project_work_items_path(project, work_items_path: presented_issue.id))
- end
- end
-
it 'returns a work item path using iid for the task' do
expect(presenter.issue_path).to eq(
- project_work_items_path(project, work_items_path: presented_issue.iid, iid_path: true)
+ project_work_items_path(project, work_items_path: presented_issue.iid)
)
end
end
diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb
index 31aa4778d3c..6f40d3f5b48 100644
--- a/spec/presenters/merge_request_presenter_spec.rb
+++ b/spec/presenters/merge_request_presenter_spec.rb
@@ -125,9 +125,12 @@ RSpec.describe MergeRequestPresenter do
let_it_be(:issue_b) { create(:issue, project: project) }
let_it_be(:resource) do
- create(:merge_request,
- source_project: project, target_project: project,
- description: "Fixes #{issue_a.to_reference} Related #{issue_b.to_reference}")
+ create(
+ :merge_request,
+ source_project: project,
+ target_project: project,
+ description: "Fixes #{issue_a.to_reference} Related #{issue_b.to_reference}"
+ )
end
before_all do
diff --git a/spec/presenters/ml/candidate_details_presenter_spec.rb b/spec/presenters/ml/candidate_details_presenter_spec.rb
new file mode 100644
index 00000000000..d83ffbc7129
--- /dev/null
+++ b/spec/presenters/ml/candidate_details_presenter_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ml::CandidateDetailsPresenter, feature_category: :mlops do
+ let_it_be(:project) { create(:project, :private) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+ let_it_be(:user) { project.creator }
+ let_it_be(:experiment) { create(:ml_experiments, user: user, project: project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+ let_it_be(:candidate) do
+ create(:ml_candidates, :with_artifact, experiment: experiment, user: user, project: project) # rubocop:disable RSpec/FactoryBot/AvoidCreate
+ end
+
+ let_it_be(:metrics) do
+ [
+ build_stubbed(:ml_candidate_metrics, name: 'metric1', value: 0.1, candidate: candidate),
+ build_stubbed(:ml_candidate_metrics, name: 'metric2', value: 0.2, candidate: candidate),
+ build_stubbed(:ml_candidate_metrics, name: 'metric3', value: 0.3, candidate: candidate)
+ ]
+ end
+
+ let_it_be(:params) do
+ [
+ build_stubbed(:ml_candidate_params, name: 'param1', value: 'p1', candidate: candidate),
+ build_stubbed(:ml_candidate_params, name: 'param2', value: 'p2', candidate: candidate)
+ ]
+ end
+
+ subject { Gitlab::Json.parse(described_class.new(candidate).present)['candidate'] }
+
+ before do
+ allow(candidate).to receive(:latest_metrics).and_return(metrics)
+ allow(candidate).to receive(:params).and_return(params)
+ end
+
+ describe '#execute' do
+ context 'when candidate has metrics, params and artifacts' do
+ it 'generates the correct params' do
+ expect(subject['params']).to include(
+ hash_including('name' => 'param1', 'value' => 'p1'),
+ hash_including('name' => 'param2', 'value' => 'p2')
+ )
+ end
+
+ it 'generates the correct metrics' do
+ expect(subject['metrics']).to include(
+ hash_including('name' => 'metric1', 'value' => 0.1),
+ hash_including('name' => 'metric2', 'value' => 0.2),
+ hash_including('name' => 'metric3', 'value' => 0.3)
+ )
+ end
+
+ it 'generates the correct info' do
+ expected_info = {
+ 'iid' => candidate.iid,
+ 'eid' => candidate.eid,
+ 'path_to_artifact' => "/#{project.full_path}/-/packages/#{candidate.artifact.id}",
+ 'experiment_name' => candidate.experiment.name,
+ 'path_to_experiment' => "/#{project.full_path}/-/ml/experiments/#{experiment.iid}",
+ 'status' => 'running',
+ 'path' => "/#{project.full_path}/-/ml/candidates/#{candidate.iid}"
+ }
+
+ expect(subject['info']).to include(expected_info)
+ end
+ end
+
+ context 'when candidate has job' do
+ let_it_be(:pipeline) { build_stubbed(:ci_pipeline, project: project, user: user) }
+ let_it_be(:build) { candidate.ci_build = build_stubbed(:ci_build, pipeline: pipeline, user: user) }
+
+ it 'generates the correct ci' do
+ expected_info = {
+ 'path' => "/#{project.full_path}/-/jobs/#{build.id}",
+ 'name' => 'test',
+ 'user' => {
+ 'path' => "/#{pipeline.user.username}",
+ 'username' => pipeline.user.username
+ }
+ }
+
+ expect(subject.dig('info', 'ci_job')).to include(expected_info)
+ end
+
+ context 'when build user is nil' do
+ it 'does not include build user info' do
+ expected_info = {
+ 'path' => "/#{project.full_path}/-/jobs/#{build.id}",
+ 'name' => 'test'
+ }
+
+ allow(build).to receive(:user).and_return(nil)
+
+ expect(subject.dig('info', 'ci_job')).to eq(expected_info)
+ end
+ end
+
+ context 'and job is from MR' do
+ let_it_be(:mr) { pipeline.merge_request = build_stubbed(:merge_request, source_project: project) }
+
+ it 'generates the correct ci' do
+ expected_info = {
+ 'path' => "/#{project.full_path}/-/merge_requests/#{mr.iid}",
+ 'title' => mr.title
+ }
+
+ expect(subject.dig('info', 'ci_job', 'merge_request')).to include(expected_info)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/presenters/ml/candidates_csv_presenter_spec.rb b/spec/presenters/ml/candidates_csv_presenter_spec.rb
new file mode 100644
index 00000000000..fea00565859
--- /dev/null
+++ b/spec/presenters/ml/candidates_csv_presenter_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ml::CandidatesCsvPresenter, feature_category: :mlops do
+ # rubocop:disable RSpec/FactoryBot/AvoidCreate
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:experiment) { create(:ml_experiments, user_id: project.creator, project: project) }
+
+ let_it_be(:candidate0) do
+ create(:ml_candidates, experiment: experiment, user: project.creator,
+ project: project, start_time: 1234, end_time: 5678).tap do |c|
+ c.params.create!([{ name: 'param1', value: 'p1' }, { name: 'param2', value: 'p2' }])
+ c.metrics.create!(
+ [{ name: 'metric1', value: 0.1 }, { name: 'metric2', value: 0.2 }, { name: 'metric3', value: 0.3 }]
+ )
+ end
+ end
+
+ let_it_be(:candidate1) do
+ create(:ml_candidates, experiment: experiment, user: project.creator, name: 'candidate1',
+ project: project, start_time: 1111, end_time: 2222).tap do |c|
+ c.params.create([{ name: 'param2', value: 'p3' }, { name: 'param3', value: 'p4' }])
+ c.metrics.create!(name: 'metric3', value: 0.4)
+ end
+ end
+ # rubocop:enable RSpec/FactoryBot/AvoidCreate
+
+ describe '.present' do
+ subject { described_class.new(::Ml::Candidate.where(id: [candidate0.id, candidate1.id])).present }
+
+ it 'generates header row correctly' do
+ expected_header = %w[project_id experiment_iid candidate_iid name external_id start_time end_time param1 param2
+ param3 metric1 metric2 metric3].join(',')
+ header = subject.split("\n")[0]
+
+ expect(header).to eq(expected_header)
+ end
+
+ it 'generates the first row correctly' do
+ expected_row = [
+ candidate0.project_id,
+ 1, # experiment.iid
+ 1, # candidate0.internal_id
+ '', # candidate0 has no name, column is empty
+ candidate0.eid,
+ candidate0.start_time,
+ candidate0.end_time,
+ candidate0.params[0].value,
+ candidate0.params[1].value,
+ '', # candidate0 has no param3, column is empty
+ candidate0.metrics[0].value,
+ candidate0.metrics[1].value,
+ candidate0.metrics[2].value
+ ].map(&:to_s)
+
+ row = subject.split("\n")[1].split(",")
+
+ expect(row).to match_array(expected_row)
+ end
+
+ it 'generates the second row correctly' do
+ expected_row = [
+ candidate1.project_id,
+ 1, # experiment.iid
+ 2, # candidate1.internal_id
+ 'candidate1',
+ candidate1.eid,
+ candidate1.start_time,
+ candidate1.end_time,
+ '', # candidate1 has no param1, column is empty
+ candidate1.params[0].value,
+ candidate1.params[1].value,
+ '', # candidate1 has no metric1, column is empty
+ '', # candidate1 has no metric2, column is empty
+ candidate1.metrics[0].value
+ ].map(&:to_s)
+
+ row = subject.split("\n")[2].split(",")
+
+ expect(row).to match_array(expected_row)
+ end
+ end
+end
diff --git a/spec/presenters/packages/detail/package_presenter_spec.rb b/spec/presenters/packages/detail/package_presenter_spec.rb
index 71ec3ee2d67..8caa70c988e 100644
--- a/spec/presenters/packages/detail/package_presenter_spec.rb
+++ b/spec/presenters/packages/detail/package_presenter_spec.rb
@@ -89,8 +89,7 @@ RSpec.describe ::Packages::Detail::PackagePresenter do
let_it_be(:package) { create(:npm_package, :with_build, project: project) }
let_it_be(:package_file_build_info) do
- create(:package_file_build_info, package_file: package.package_files.first,
- pipeline: package.pipelines.first)
+ create(:package_file_build_info, package_file: package.package_files.first, pipeline: package.pipelines.first)
end
it 'returns details with package_file pipeline' do
diff --git a/spec/presenters/packages/npm/package_presenter_spec.rb b/spec/presenters/packages/npm/package_presenter_spec.rb
index 4fa469c7cd2..fe4773a9cad 100644
--- a/spec/presenters/packages/npm/package_presenter_spec.rb
+++ b/spec/presenters/packages/npm/package_presenter_spec.rb
@@ -2,157 +2,32 @@
require 'spec_helper'
-RSpec.describe ::Packages::Npm::PackagePresenter do
- using RSpec::Parameterized::TableSyntax
-
- 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(:packages) { project.packages.npm.with_name(package_name).last_of_each_version }
- let(:presenter) { described_class.new(package_name, packages) }
-
- describe '#versions' do
- let_it_be('package_json') do
- {
- 'name': package_name,
- 'version': '2.0.4',
- 'deprecated': 'warning!',
- 'bin': './cli.js',
- 'directories': ['lib'],
- 'engines': { 'npm': '^7.5.6' },
- '_hasShrinkwrap': false,
- 'dist': {
- 'tarball': 'http://localhost/tarball.tgz',
- 'shasum': '1234567890'
- },
- 'custom_field': 'foo_bar'
- }
- end
-
- let(:presenter) { described_class.new(package_name, packages) }
-
- subject { presenter.versions }
-
- where(:has_dependencies, :has_metadatum) do
- true | true
- false | true
- true | false
- false | false
- end
-
- with_them do
- if params[:has_dependencies]
- ::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
- end
-
- if params[:has_metadatum]
- let_it_be('package_metadatadum') { create(:npm_metadatum, package: package1, package_json: package_json) }
- 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') }
- it { expect(subject[package1.version]['custom_field']).to be_blank }
-
- context 'dependencies' do
- ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type|
- if params[:has_dependencies]
- it { expect(subject.dig(package1.version, dependency_type.to_s)).to be_any }
- else
- it { expect(subject.dig(package1.version, dependency_type)).to be nil }
- end
-
- it { expect(subject.dig(package2.version, dependency_type)).to be nil }
- end
- end
-
- context 'metadatum' do
- ::Packages::Npm::PackagePresenter::PACKAGE_JSON_ALLOWED_FIELDS.each do |metadata_field|
- if params[:has_metadatum]
- it { expect(subject.dig(package1.version, metadata_field)).not_to be nil }
- else
- it { expect(subject.dig(package1.version, metadata_field)).to be nil }
- end
-
- it { expect(subject.dig(package2.version, metadata_field)).to be nil }
- end
- end
+RSpec.describe Packages::Npm::PackagePresenter, feature_category: :package_registry do
+ let_it_be(:metadata) do
+ {
+ name: 'foo',
+ versions: { '1.0.0' => { 'dist' => { 'tarball' => 'http://localhost/tarball.tgz' } } },
+ dist_tags: { 'latest' => '1.0.0' }
+ }
+ 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|
- next unless has_dependencies
+ subject { described_class.new(metadata) }
- ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type|
- create(:packages_dependency_link, package: npm_package, dependency_type: dependency_type)
- end
- end
- end
- end
+ describe '#name' do
+ it 'returns the name' do
+ expect(subject.name).to eq('foo')
end
+ end
- context 'with package files pending destruction' do
- let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: package2, file_sha1: 'pending_destruction_sha1') }
-
- let(:shasums) { subject.values.map { |v| v.dig(:dist, :shasum) } }
-
- it 'does not return them' do
- expect(shasums).not_to include(package_file_pending_destruction.file_sha1)
- end
+ describe '#versions' do
+ it 'returns the versions' do
+ expect(subject.versions).to eq({ '1.0.0' => { 'dist' => { 'tarball' => 'http://localhost/tarball.tgz' } } })
end
end
describe '#dist_tags' do
- subject { presenter.dist_tags }
-
- 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_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) }
- it { expect(subject[package_tag2.name]).to eq(package1.version) }
- 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
+ it 'returns the dist_tags' do
+ expect(subject.dist_tags).to eq({ 'latest' => '1.0.0' })
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/pages_domain_presenter_spec.rb b/spec/presenters/pages_domain_presenter_spec.rb
index 731279ce5b9..f197daab759 100644
--- a/spec/presenters/pages_domain_presenter_spec.rb
+++ b/spec/presenters/pages_domain_presenter_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PagesDomainPresenter do
+RSpec.describe PagesDomainPresenter, feature_category: :pages do
using RSpec::Parameterized::TableSyntax
include LetsEncryptHelpers
@@ -62,4 +62,46 @@ RSpec.describe PagesDomainPresenter do
end
end
end
+
+ describe 'user_defined_certificate?' do
+ subject { presenter.user_defined_certificate? }
+
+ let(:domain) { create(:pages_domain) }
+
+ context "when domain certificate is user provided" do
+ it { is_expected.to eq(true) }
+ end
+
+ context "when domain is not persisted" do
+ before do
+ domain.destroy!
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context "when domain certificate is blank" do
+ before do
+ domain.update!(certificate: nil, key: nil)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context "when domain certificate source is gitlab_provided" do
+ before do
+ domain.update!(certificate_source: :gitlab_provided)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context "when domain certificate has error" do
+ before do
+ domain.errors.add(:certificate, "certificate error")
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
end
diff --git a/spec/presenters/project_clusterable_presenter_spec.rb b/spec/presenters/project_clusterable_presenter_spec.rb
index dfe4a191ae5..4727bce02a5 100644
--- a/spec/presenters/project_clusterable_presenter_spec.rb
+++ b/spec/presenters/project_clusterable_presenter_spec.rb
@@ -2,15 +2,15 @@
require 'spec_helper'
-RSpec.describe ProjectClusterablePresenter do
+RSpec.describe ProjectClusterablePresenter, feature_category: :environment_management do
include Gitlab::Routing.url_helpers
let(:presenter) { described_class.new(project) }
- let(:project) { create(:project) }
- let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
+ let(:project) { build_stubbed(:project) }
+ let(:cluster) { build_stubbed(:cluster, :provided_by_gcp, projects: [project]) }
describe '#can_create_cluster?' do
- let(:user) { create(:user) }
+ let(:user) { build_stubbed(:user) }
subject { presenter.can_create_cluster? }
@@ -20,7 +20,7 @@ RSpec.describe ProjectClusterablePresenter do
context 'when user can create' do
before do
- project.add_maintainer(user)
+ stub_member_access_level(project, maintainer: user)
end
it { is_expected.to be_truthy }
diff --git a/spec/presenters/project_hook_presenter_spec.rb b/spec/presenters/project_hook_presenter_spec.rb
index a85865652d8..7c02548af08 100644
--- a/spec/presenters/project_hook_presenter_spec.rb
+++ b/spec/presenters/project_hook_presenter_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe ProjectHookPresenter do
subject { web_hook.present.logs_details_path(web_hook_log) }
let(:expected_path) do
- "/#{project.namespace.path}/#{project.name}/-/hooks/#{web_hook.id}/hook_logs/#{web_hook_log.id}"
+ "/#{project.full_path}/-/hooks/#{web_hook.id}/hook_logs/#{web_hook_log.id}"
end
it { is_expected.to eq(expected_path) }
@@ -21,7 +21,7 @@ RSpec.describe ProjectHookPresenter do
subject { web_hook.present.logs_retry_path(web_hook_log) }
let(:expected_path) do
- "/#{project.namespace.path}/#{project.name}/-/hooks/#{web_hook.id}/hook_logs/#{web_hook_log.id}/retry"
+ "/#{project.full_path}/-/hooks/#{web_hook.id}/hook_logs/#{web_hook_log.id}/retry"
end
it { is_expected.to eq(expected_path) }
diff --git a/spec/presenters/releases/link_presenter_spec.rb b/spec/presenters/releases/link_presenter_spec.rb
index e52c68ffb38..79cfab0bc0a 100644
--- a/spec/presenters/releases/link_presenter_spec.rb
+++ b/spec/presenters/releases/link_presenter_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Releases::LinkPresenter do
context 'when filepath is provided' do
let(:filepath) { '/bin/bigfile.exe' }
let(:expected_url) do
- "http://localhost/#{release.project.namespace.path}/#{release.project.name}" \
+ "http://localhost/#{release.project.full_path}" \
"/-/releases/#{release.tag}/downloads/bin/bigfile.exe"
end
diff --git a/spec/presenters/service_hook_presenter_spec.rb b/spec/presenters/service_hook_presenter_spec.rb
index c7703593327..48c3f6f0a22 100644
--- a/spec/presenters/service_hook_presenter_spec.rb
+++ b/spec/presenters/service_hook_presenter_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe ServiceHookPresenter do
subject { service_hook.present.logs_details_path(web_hook_log) }
let(:expected_path) do
- "/#{project.namespace.path}/#{project.name}/-/settings/integrations/#{integration.to_param}/hook_logs/#{web_hook_log.id}"
+ "/#{project.full_path}/-/settings/integrations/#{integration.to_param}/hook_logs/#{web_hook_log.id}"
end
it { is_expected.to eq(expected_path) }
@@ -22,7 +22,7 @@ RSpec.describe ServiceHookPresenter do
subject { service_hook.present.logs_retry_path(web_hook_log) }
let(:expected_path) do
- "/#{project.namespace.path}/#{project.name}/-/settings/integrations/#{integration.to_param}/hook_logs/#{web_hook_log.id}/retry"
+ "/#{project.full_path}/-/settings/integrations/#{integration.to_param}/hook_logs/#{web_hook_log.id}/retry"
end
it { is_expected.to eq(expected_path) }
diff --git a/spec/rails_autoload.rb b/spec/rails_autoload.rb
new file mode 100644
index 00000000000..d3518acf8b2
--- /dev/null
+++ b/spec/rails_autoload.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+# Mimics Rails autoloading with zeitwerk when used outside of Rails.
+# This is used in:
+# * fast_spec_helper
+# * scripts/setup-test-env
+
+require 'zeitwerk'
+require 'active_support/string_inquirer'
+
+module Rails
+ extend self
+
+ def root
+ Pathname.new(File.expand_path('..', __dir__))
+ end
+
+ def env
+ @_env ||= ActiveSupport::StringInquirer.new(ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "test")
+ end
+
+ def autoloaders
+ @autoloaders ||= [
+ Zeitwerk::Loader.new.tap do |loader|
+ loader.inflector = _autoloader_inflector
+ end
+ ]
+ end
+
+ private
+
+ def _autoloader_inflector
+ # Try Rails 7 first.
+ require 'rails/autoloaders/inflector'
+
+ Rails::Autoloaders::Inflector
+ rescue LoadError
+ # Fallback to Rails 6.
+ require 'active_support/dependencies'
+ require 'active_support/dependencies/zeitwerk_integration'
+
+ ActiveSupport::Dependencies::ZeitwerkIntegration::Inflector
+ end
+end
+
+require_relative '../lib/gitlab'
+require_relative '../config/initializers/0_inject_enterprise_edition_module'
+require_relative '../config/initializers_before_autoloader/000_inflections'
+require_relative '../config/initializers_before_autoloader/004_zeitwerk'
+
+Rails.autoloaders.each do |autoloader|
+ autoloader.push_dir('lib')
+ autoloader.push_dir('ee/lib') if Gitlab.ee?
+ autoloader.push_dir('jh/lib') if Gitlab.jh?
+ autoloader.setup
+end
diff --git a/spec/requests/abuse_reports_controller_spec.rb b/spec/requests/abuse_reports_controller_spec.rb
index 934f123e45b..4b81394aea3 100644
--- a/spec/requests/abuse_reports_controller_spec.rb
+++ b/spec/requests/abuse_reports_controller_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe AbuseReportsController, feature_category: :insider_threat do
attributes_for(:abuse_report) do |hash|
hash[:user_id] = user.id
hash[:category] = abuse_category
+ hash[:screenshot] = fixture_file_upload('spec/fixtures/dk.png')
end
end
diff --git a/spec/requests/admin/abuse_reports_controller_spec.rb b/spec/requests/admin/abuse_reports_controller_spec.rb
new file mode 100644
index 00000000000..0b5aaabaa61
--- /dev/null
+++ b/spec/requests/admin/abuse_reports_controller_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::AbuseReportsController, type: :request, feature_category: :insider_threat do
+ include AdminModeHelper
+
+ let_it_be(:admin) { create(:admin) }
+
+ before do
+ enable_admin_mode!(admin)
+ sign_in(admin)
+ end
+
+ describe 'GET #index' do
+ let!(:open_report) { create(:abuse_report) }
+ let!(:closed_report) { create(:abuse_report, :closed) }
+
+ it 'returns open reports by default' do
+ get admin_abuse_reports_path
+
+ expect(assigns(:abuse_reports).count).to eq 1
+ expect(assigns(:abuse_reports).first.open?).to eq true
+ end
+
+ it 'returns reports by specified status' do
+ get admin_abuse_reports_path, params: { status: 'closed' }
+
+ expect(assigns(:abuse_reports).count).to eq 1
+ expect(assigns(:abuse_reports).first.closed?).to eq true
+ end
+
+ context 'when abuse_reports_list flag is disabled' do
+ before do
+ stub_feature_flags(abuse_reports_list: false)
+ end
+
+ it 'returns all reports by default' do
+ get admin_abuse_reports_path
+
+ expect(assigns(:abuse_reports).count).to eq 2
+ end
+ end
+ end
+
+ describe 'GET #show' do
+ let!(:report) { create(:abuse_report) }
+
+ it 'returns the requested report' do
+ get admin_abuse_report_path(report)
+
+ expect(assigns(:abuse_report)).to eq report
+ end
+ end
+
+ describe 'PUT #update' do
+ let(:report) { create(:abuse_report) }
+ let(:params) { { user_action: 'block_user', close: 'true', reason: 'spam', comment: 'obvious spam' } }
+ let(:expected_params) { ActionController::Parameters.new(params).permit! }
+
+ it 'invokes the Admin::AbuseReportUpdateService' do
+ expect_next_instance_of(Admin::AbuseReportUpdateService, report, admin, expected_params) do |service|
+ expect(service).to receive(:execute)
+ end
+
+ put admin_abuse_report_path(report, params)
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ let!(:report) { create(:abuse_report) }
+ let(:params) { {} }
+
+ subject { delete admin_abuse_report_path(report, params) }
+
+ it 'destroys the report' do
+ expect { subject }.to change { AbuseReport.count }.by(-1)
+ end
+
+ context 'when passing the `remove_user` parameter' do
+ let(:params) { { remove_user: true } }
+
+ it 'calls the `remove_user` method' do
+ expect_next_found_instance_of(AbuseReport) do |report|
+ expect(report).to receive(:remove_user).with(deleted_by: admin)
+ end
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/requests/admin/applications_controller_spec.rb b/spec/requests/admin/applications_controller_spec.rb
index c83137ebbce..367697b1289 100644
--- a/spec/requests/admin/applications_controller_spec.rb
+++ b/spec/requests/admin/applications_controller_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Admin::ApplicationsController, :enable_admin_mode,
-feature_category: :authentication_and_authorization do
+feature_category: :system_access do
let_it_be(:admin) { create(:admin) }
let_it_be(:application) { create(:oauth_application, owner_id: nil, owner_type: nil) }
let_it_be(:show_path) { admin_application_path(application) }
diff --git a/spec/requests/admin/background_migrations_controller_spec.rb b/spec/requests/admin/background_migrations_controller_spec.rb
index 88d81766e67..2681ece7d8a 100644
--- a/spec/requests/admin/background_migrations_controller_spec.rb
+++ b/spec/requests/admin/background_migrations_controller_spec.rb
@@ -67,6 +67,17 @@ RSpec.describe Admin::BackgroundMigrationsController, :enable_admin_mode, featur
expect(assigns(:migrations)).to match_array([main_database_migration])
end
+
+ context 'for finalizing tab' do
+ let!(:finalizing_migration) { create(:batched_background_migration, :finalizing) }
+
+ it 'returns only finalizing migration' do
+ get admin_background_migrations_path(tab: 'finalizing')
+
+ expect(Gitlab::Database::BackgroundMigration::BatchedMigration.queued).not_to be_empty
+ expect(assigns(:migrations)).to match_array(Array.wrap(finalizing_migration))
+ end
+ end
end
context 'when multiple database is enabled', :add_ci_connection do
diff --git a/spec/requests/admin/broadcast_messages_controller_spec.rb b/spec/requests/admin/broadcast_messages_controller_spec.rb
index 69b84d6d795..0143c9ce030 100644
--- a/spec/requests/admin/broadcast_messages_controller_spec.rb
+++ b/spec/requests/admin/broadcast_messages_controller_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe Admin::BroadcastMessagesController, :enable_admin_mode, feature_c
let_it_be(:invalid_broadcast_message) { { broadcast_message: { message: '' } } }
let_it_be(:test_message) { 'you owe me a new acorn' }
+ let_it_be(:test_preview) { '<p>Hello, world!</p>' }
before do
sign_in(create(:admin))
@@ -23,11 +24,11 @@ RSpec.describe Admin::BroadcastMessagesController, :enable_admin_mode, feature_c
end
describe 'POST /preview' do
- it 'renders preview partial' do
+ it 'renders preview html' do
post preview_admin_broadcast_messages_path, params: { broadcast_message: { message: "Hello, world!" } }
expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to render_template(:_preview)
+ expect(response.body).to eq(test_preview)
end
end
diff --git a/spec/requests/admin/impersonation_tokens_controller_spec.rb b/spec/requests/admin/impersonation_tokens_controller_spec.rb
index 15212db0e77..11fc5d94292 100644
--- a/spec/requests/admin/impersonation_tokens_controller_spec.rb
+++ b/spec/requests/admin/impersonation_tokens_controller_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Admin::ImpersonationTokensController, :enable_admin_mode,
-feature_category: :authentication_and_authorization do
+feature_category: :system_access do
let(:admin) { create(:admin) }
let!(:user) { create(:user) }
diff --git a/spec/requests/admin/integrations_controller_spec.rb b/spec/requests/admin/integrations_controller_spec.rb
index efd0e3d91ee..6240c2406ea 100644
--- a/spec/requests/admin/integrations_controller_spec.rb
+++ b/spec/requests/admin/integrations_controller_spec.rb
@@ -9,6 +9,20 @@ RSpec.describe Admin::IntegrationsController, :enable_admin_mode, feature_catego
sign_in(admin)
end
+ describe 'GET #edit' do
+ context 'when remove_monitor_metrics is true' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'renders a 404 for the prometheus integration' do
+ get edit_admin_application_settings_integration_path(:prometheus)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
describe 'GET #overrides' do
let_it_be(:integration) { create(:jira_integration, :instance) }
let_it_be(:overridden_integration) { create(:jira_integration) }
diff --git a/spec/requests/admin/projects_controller_spec.rb b/spec/requests/admin/projects_controller_spec.rb
new file mode 100644
index 00000000000..2462152b7c2
--- /dev/null
+++ b/spec/requests/admin/projects_controller_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::ProjectsController, :enable_admin_mode, feature_category: :projects do
+ let_it_be(:project) { create(:project, :public, name: 'test', description: 'test') }
+ let_it_be(:admin) { create(:admin) }
+
+ describe 'PUT #update' do
+ let(:project_params) { {} }
+ let(:params) { { project: project_params } }
+ let(:path_params) { { namespace_id: project.namespace.to_param, id: project.to_param } }
+
+ before do
+ sign_in(admin)
+ end
+
+ subject do
+ put admin_namespace_project_path(path_params), params: params
+ end
+
+ context 'when changing the name' do
+ let(:project_params) { { name: 'new name' } }
+
+ it 'returns success' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ end
+
+ it 'changes the name' do
+ expect { subject }.to change { project.reload.name }.to('new name')
+ end
+ end
+
+ context 'when changing the description' do
+ let(:project_params) { { description: 'new description' } }
+
+ it 'returns success' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ end
+
+ it 'changes the project description' do
+ expect { subject }.to change { project.reload.description }.to('new description')
+ end
+ end
+
+ context 'when changing the name to an invalid name' do
+ let(:project_params) { { name: 'invalid/project/name' } }
+
+ it 'does not change the name' do
+ expect { subject }.not_to change { project.reload.name }
+ end
+ end
+
+ context 'when disabling runner registration' do
+ let(:project_params) { { runner_registration_enabled: false } }
+
+ it 'changes runner registration' do
+ expect { subject }.to change { project.reload.runner_registration_enabled }.to(false)
+ end
+
+ it 'resets the registration token' do
+ expect { subject }.to change { project.reload.runners_token }
+ end
+ end
+
+ context 'when enabling runner registration' do
+ before do
+ project.update!(runner_registration_enabled: false)
+ end
+
+ let(:project_params) { { runner_registration_enabled: true } }
+
+ it 'changes runner registration' do
+ expect { subject }.to change { project.reload.runner_registration_enabled }.to(true)
+ end
+
+ it 'does not reset the registration token' do
+ expect { subject }.not_to change { project.reload.runners_token }
+ end
+ end
+ end
+end
diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb
new file mode 100644
index 00000000000..5344a2c2bb7
--- /dev/null
+++ b/spec/requests/admin/users_controller_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::UsersController, :enable_admin_mode, feature_category: :user_management do
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:user) { create(:user) }
+
+ describe 'PUT #block' do
+ context 'when request format is :json' do
+ before do
+ sign_in(admin)
+ end
+
+ subject(:request) { put block_admin_user_path(user, format: :json) }
+
+ context 'when user was blocked' do
+ it 'returns 200 and json data with notice' do
+ request
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include('notice' => 'Successfully blocked')
+ end
+ end
+
+ context 'when user was not blocked' do
+ before do
+ allow_next_instance_of(::Users::BlockService) do |service|
+ allow(service).to receive(:execute).and_return({ status: :failed })
+ end
+ end
+
+ it 'returns 200 and json data with error' do
+ request
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include('error' => 'Error occurred. User was not blocked')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/admin/version_check_controller_spec.rb b/spec/requests/admin/version_check_controller_spec.rb
index 47221bf37e5..a998c2f426b 100644
--- a/spec/requests/admin/version_check_controller_spec.rb
+++ b/spec/requests/admin/version_check_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Admin::VersionCheckController, :enable_admin_mode, feature_category: :not_owned do
+RSpec.describe Admin::VersionCheckController, :enable_admin_mode, feature_category: :shared do
let(:admin) { create(:admin) }
before do
diff --git a/spec/requests/api/access_requests_spec.rb b/spec/requests/api/access_requests_spec.rb
index 8c14ead9e42..45d1594c734 100644
--- a/spec/requests/api/access_requests_spec.rb
+++ b/spec/requests/api/access_requests_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::AccessRequests, feature_category: :authentication_and_authorization do
+RSpec.describe API::AccessRequests, feature_category: :system_access do
let_it_be(:maintainer) { create(:user) }
let_it_be(:developer) { create(:user) }
let_it_be(:access_requester) { create(:user) }
diff --git a/spec/requests/api/admin/batched_background_migrations_spec.rb b/spec/requests/api/admin/batched_background_migrations_spec.rb
index d946ac17f3f..e88fba3fbe7 100644
--- a/spec/requests/api/admin/batched_background_migrations_spec.rb
+++ b/spec/requests/api/admin/batched_background_migrations_spec.rb
@@ -4,22 +4,23 @@ require 'spec_helper'
RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :database do
let(:admin) { create(:admin) }
- let(:unauthorized_user) { create(:user) }
describe 'GET /admin/batched_background_migrations/:id' do
let!(:migration) { create(:batched_background_migration, :paused) }
let(:database) { :main }
let(:params) { { database: database } }
+ let(:path) { "/admin/batched_background_migrations/#{migration.id}" }
+
+ it_behaves_like "GET request permissions for admin mode"
subject(:show_migration) do
- get api("/admin/batched_background_migrations/#{migration.id}", admin), params: { database: database }
+ get api(path, admin, admin_mode: true), params: { database: database }
end
it 'fetches the batched background migration' do
show_migration
aggregate_failures "testing response" do
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(migration.id)
expect(json_response['status']).to eq('paused')
expect(json_response['job_class_name']).to eq(migration.job_class_name)
@@ -29,7 +30,8 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
context 'when the batched background migration does not exist' do
it 'returns 404' do
- get api("/admin/batched_background_migrations/#{non_existing_record_id}", admin), params: params
+ get api("/admin/batched_background_migrations/#{non_existing_record_id}", admin, admin_mode: true),
+ params: params
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -50,19 +52,11 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
end
end
- context 'when authenticated as a non-admin user' do
- it 'returns 403' do
- get api("/admin/batched_background_migrations/#{migration.id}", unauthorized_user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
context 'when the database name does not exist' do
let(:database) { :wrong_database }
- it 'returns bad request' do
- get api("/admin/batched_background_migrations/#{migration.id}", admin), params: params
+ it 'returns bad request', :aggregate_failures do
+ get api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.body).to include('database does not have a valid value')
@@ -72,13 +66,15 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
describe 'GET /admin/batched_background_migrations' do
let!(:migration) { create(:batched_background_migration) }
+ let(:path) { '/admin/batched_background_migrations' }
+
+ it_behaves_like "GET request permissions for admin mode"
context 'when is an admin user' do
it 'returns batched background migrations' do
- get api('/admin/batched_background_migrations', admin)
+ get api(path, admin, admin_mode: true)
aggregate_failures "testing response" do
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq(1)
expect(json_response.first['id']).to eq(migration.id)
expect(json_response.first['job_class_name']).to eq(migration.job_class_name)
@@ -105,14 +101,14 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(ci_model.connection).and_yield
- get api('/admin/batched_background_migrations', admin), params: params
+ get api(path, admin, admin_mode: true), params: params
end
context 'when the database name does not exist' do
let(:database) { :wrong_database }
- it 'returns bad request' do
- get api("/admin/batched_background_migrations", admin), params: params
+ it 'returns bad request', :aggregate_failures do
+ get api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.body).to include('database does not have a valid value')
@@ -127,10 +123,9 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
create(:batched_background_migration, :active, gitlab_schema: schema)
end
- get api('/admin/batched_background_migrations', admin), params: params
+ get api(path, admin, admin_mode: true), params: params
aggregate_failures "testing response" do
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq(1)
expect(json_response.first['id']).to eq(ci_database_migration.id)
expect(json_response.first['job_class_name']).to eq(ci_database_migration.job_class_name)
@@ -142,30 +137,24 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
end
end
end
-
- context 'when authenticated as a non-admin user' do
- it 'returns 403' do
- get api('/admin/batched_background_migrations', unauthorized_user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
end
describe 'PUT /admin/batched_background_migrations/:id/resume' do
let!(:migration) { create(:batched_background_migration, :paused) }
let(:database) { :main }
let(:params) { { database: database } }
+ let(:path) { "/admin/batched_background_migrations/#{migration.id}/resume" }
+
+ it_behaves_like "PUT request permissions for admin mode"
subject(:resume) do
- put api("/admin/batched_background_migrations/#{migration.id}/resume", admin), params: params
+ put api(path, admin, admin_mode: true), params: params
end
it 'pauses the batched background migration' do
resume
aggregate_failures "testing response" do
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(migration.id)
expect(json_response['status']).to eq('active')
end
@@ -173,7 +162,8 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
context 'when the batched background migration does not exist' do
it 'returns 404' do
- put api("/admin/batched_background_migrations/#{non_existing_record_id}/resume", admin), params: params
+ put api("/admin/batched_background_migrations/#{non_existing_record_id}/resume", admin, admin_mode: true),
+ params: params
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -183,7 +173,7 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
let!(:migration) { create(:batched_background_migration, :failed) }
it 'returns 422' do
- put api("/admin/batched_background_migrations/#{migration.id}/resume", admin), params: params
+ put api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
@@ -206,34 +196,28 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
context 'when the database name does not exist' do
let(:database) { :wrong_database }
- it 'returns bad request' do
- put api("/admin/batched_background_migrations/#{migration.id}/resume", admin), params: params
+ it 'returns bad request', :aggregate_failures do
+ put api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.body).to include('database does not have a valid value')
end
end
end
-
- context 'when authenticated as a non-admin user' do
- it 'returns 403' do
- put api("/admin/batched_background_migrations/#{migration.id}/resume", unauthorized_user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
end
describe 'PUT /admin/batched_background_migrations/:id/pause' do
let!(:migration) { create(:batched_background_migration, :active) }
let(:database) { :main }
let(:params) { { database: database } }
+ let(:path) { "/admin/batched_background_migrations/#{migration.id}/pause" }
+
+ it_behaves_like "PUT request permissions for admin mode"
it 'pauses the batched background migration' do
- put api("/admin/batched_background_migrations/#{migration.id}/pause", admin), params: params
+ put api(path, admin, admin_mode: true), params: params
aggregate_failures "testing response" do
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(migration.id)
expect(json_response['status']).to eq('paused')
end
@@ -241,7 +225,8 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
context 'when the batched background migration does not exist' do
it 'returns 404' do
- put api("/admin/batched_background_migrations/#{non_existing_record_id}/pause", admin), params: params
+ put api("/admin/batched_background_migrations/#{non_existing_record_id}/pause", admin, admin_mode: true),
+ params: params
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -251,7 +236,7 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
let!(:migration) { create(:batched_background_migration, :failed) }
it 'returns 422' do
- put api("/admin/batched_background_migrations/#{migration.id}/pause", admin), params: params
+ put api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
@@ -268,27 +253,19 @@ RSpec.describe API::Admin::BatchedBackgroundMigrations, feature_category: :datab
it 'uses the correct connection' do
expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(ci_model.connection).and_yield
- put api("/admin/batched_background_migrations/#{migration.id}/pause", admin), params: params
+ put api(path, admin, admin_mode: true), params: params
end
context 'when the database name does not exist' do
let(:database) { :wrong_database }
- it 'returns bad request' do
- put api("/admin/batched_background_migrations/#{migration.id}/pause", admin), params: params
+ it 'returns bad request', :aggregate_failures do
+ put api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.body).to include('database does not have a valid value')
end
end
end
-
- context 'when authenticated as a non-admin user' do
- it 'returns 403' do
- put api("/admin/batched_background_migrations/#{non_existing_record_id}/pause", unauthorized_user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
end
end
diff --git a/spec/requests/api/admin/ci/variables_spec.rb b/spec/requests/api/admin/ci/variables_spec.rb
index 4bdc44cb583..cd57cde74ff 100644
--- a/spec/requests/api/admin/ci/variables_spec.rb
+++ b/spec/requests/api/admin/ci/variables_spec.rb
@@ -2,71 +2,63 @@
require 'spec_helper'
-RSpec.describe ::API::Admin::Ci::Variables do
+RSpec.describe ::API::Admin::Ci::Variables, :aggregate_failures, feature_category: :pipeline_composition do
let_it_be(:admin) { create(:admin) }
let_it_be(:user) { create(:user) }
+ let_it_be(:variable) { create(:ci_instance_variable) }
+ let_it_be(:path) { '/admin/ci/variables' }
describe 'GET /admin/ci/variables' do
- let!(:variable) { create(:ci_instance_variable) }
+ it_behaves_like 'GET request permissions for admin mode'
- it 'returns instance-level variables for admins', :aggregate_failures do
- get api('/admin/ci/variables', admin)
+ it 'returns instance-level variables for admins' do
+ get api(path, admin, admin_mode: true)
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_a(Array)
end
- it 'does not return instance-level variables for regular users' do
- get api('/admin/ci/variables', user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
-
it 'does not return instance-level variables for unauthorized users' do
- get api('/admin/ci/variables')
+ get api(path, admin_mode: true)
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
describe 'GET /admin/ci/variables/:key' do
- let!(:variable) { create(:ci_instance_variable) }
+ let_it_be(:path) { "/admin/ci/variables/#{variable.key}" }
+
+ it_behaves_like 'GET request permissions for admin mode'
- it 'returns instance-level variable details for admins', :aggregate_failures do
- get api("/admin/ci/variables/#{variable.key}", admin)
+ it 'returns instance-level variable details for admins' do
+ get api(path, admin, admin_mode: true)
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response['value']).to eq(variable.value)
expect(json_response['protected']).to eq(variable.protected?)
expect(json_response['variable_type']).to eq(variable.variable_type)
end
it 'responds with 404 Not Found if requesting non-existing variable' do
- get api('/admin/ci/variables/non_existing_variable', admin)
+ get api('/admin/ci/variables/non_existing_variable', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
- it 'does not return instance-level variable details for regular users' do
- get api("/admin/ci/variables/#{variable.key}", user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
-
it 'does not return instance-level variable details for unauthorized users' do
- get api("/admin/ci/variables/#{variable.key}")
+ get api(path, admin_mode: true)
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
describe 'POST /admin/ci/variables' do
- context 'authorized user with proper permissions' do
- let!(:variable) { create(:ci_instance_variable) }
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { { key: 'KEY', value: 'VALUE' } }
+ end
- it 'creates variable for admins', :aggregate_failures do
+ context 'authorized user with proper permissions' do
+ it 'creates variable for admins' do
expect do
- post api('/admin/ci/variables', admin),
+ post api(path, admin, admin_mode: true),
params: {
key: 'TEST_VARIABLE_2',
value: 'PROTECTED_VALUE_2',
@@ -76,7 +68,6 @@ RSpec.describe ::API::Admin::Ci::Variables do
}
end.to change { ::Ci::InstanceVariable.count }.by(1)
- expect(response).to have_gitlab_http_status(:created)
expect(json_response['key']).to eq('TEST_VARIABLE_2')
expect(json_response['value']).to eq('PROTECTED_VALUE_2')
expect(json_response['protected']).to be_truthy
@@ -90,13 +81,13 @@ RSpec.describe ::API::Admin::Ci::Variables do
expect(::API::API::LOGGER).to receive(:info).with(include(params: include(masked_params)))
- post api("/admin/ci/variables", user),
+ post api(path, user, admin_mode: true),
params: { key: 'VAR_KEY', value: 'SENSITIVE', protected: true, masked: true }
end
- it 'creates variable with optional attributes', :aggregate_failures do
+ it 'creates variable with optional attributes' do
expect do
- post api('/admin/ci/variables', admin),
+ post api(path, admin, admin_mode: true),
params: {
variable_type: 'file',
key: 'TEST_VARIABLE_2',
@@ -104,7 +95,6 @@ RSpec.describe ::API::Admin::Ci::Variables do
}
end.to change { ::Ci::InstanceVariable.count }.by(1)
- expect(response).to have_gitlab_http_status(:created)
expect(json_response['key']).to eq('TEST_VARIABLE_2')
expect(json_response['value']).to eq('VALUE_2')
expect(json_response['protected']).to be_falsey
@@ -115,7 +105,7 @@ RSpec.describe ::API::Admin::Ci::Variables do
it 'does not allow to duplicate variable key' do
expect do
- post api('/admin/ci/variables', admin),
+ post api(path, admin, admin_mode: true),
params: { key: variable.key, value: 'VALUE_2' }
end.not_to change { ::Ci::InstanceVariable.count }
@@ -128,7 +118,7 @@ RSpec.describe ::API::Admin::Ci::Variables do
MESSAGE
expect do
- post api('/admin/ci/variables', admin),
+ post api(path, admin, admin_mode: true),
params: { key: 'too_long', value: SecureRandom.hex(10_001) }
end.not_to change { ::Ci::InstanceVariable.count }
@@ -138,17 +128,9 @@ RSpec.describe ::API::Admin::Ci::Variables do
end
end
- context 'authorized user with invalid permissions' do
- it 'does not create variable' do
- post api('/admin/ci/variables', user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
context 'unauthorized user' do
it 'does not create variable' do
- post api('/admin/ci/variables')
+ post api(path, admin_mode: true)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -156,20 +138,23 @@ RSpec.describe ::API::Admin::Ci::Variables do
end
describe 'PUT /admin/ci/variables/:key' do
- let!(:variable) { create(:ci_instance_variable) }
+ let_it_be(:path) { "/admin/ci/variables/#{variable.key}" }
+ let_it_be(:params) do
+ {
+ variable_type: 'file',
+ value: 'VALUE_1_UP',
+ protected: true,
+ masked: true,
+ raw: true
+ }
+ end
+
+ it_behaves_like 'PUT request permissions for admin mode'
context 'authorized user with proper permissions' do
- it 'updates variable data', :aggregate_failures do
- put api("/admin/ci/variables/#{variable.key}", admin),
- params: {
- variable_type: 'file',
- value: 'VALUE_1_UP',
- protected: true,
- masked: true,
- raw: true
- }
-
- expect(response).to have_gitlab_http_status(:ok)
+ it 'updates variable data' do
+ put api(path, admin, admin_mode: true), params: params
+
expect(variable.reload.value).to eq('VALUE_1_UP')
expect(variable.reload).to be_protected
expect(json_response['variable_type']).to eq('file')
@@ -182,28 +167,20 @@ RSpec.describe ::API::Admin::Ci::Variables do
expect(::API::API::LOGGER).to receive(:info).with(include(params: include(masked_params)))
- put api("/admin/ci/variables/#{variable.key}", admin),
+ put api(path, admin, admin_mode: true),
params: { value: 'SENSITIVE', protected: true, masked: true }
end
it 'responds with 404 Not Found if requesting non-existing variable' do
- put api('/admin/ci/variables/non_existing_variable', admin)
+ put api('/admin/ci/variables/non_existing_variable', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
- context 'authorized user with invalid permissions' do
- it 'does not update variable' do
- put api("/admin/ci/variables/#{variable.key}", user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
context 'unauthorized user' do
it 'does not update variable' do
- put api("/admin/ci/variables/#{variable.key}")
+ put api(path, admin_mode: true)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -211,35 +188,27 @@ RSpec.describe ::API::Admin::Ci::Variables do
end
describe 'DELETE /admin/ci/variables/:key' do
- let!(:variable) { create(:ci_instance_variable) }
+ let_it_be(:path) { "/admin/ci/variables/#{variable.key}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
context 'authorized user with proper permissions' do
it 'deletes variable' do
expect do
- delete api("/admin/ci/variables/#{variable.key}", admin)
-
- expect(response).to have_gitlab_http_status(:no_content)
+ delete api(path, admin, admin_mode: true)
end.to change { ::Ci::InstanceVariable.count }.by(-1)
end
it 'responds with 404 Not Found if requesting non-existing variable' do
- delete api('/admin/ci/variables/non_existing_variable', admin)
+ delete api('/admin/ci/variables/non_existing_variable', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
- context 'authorized user with invalid permissions' do
- it 'does not delete variable' do
- delete api("/admin/ci/variables/#{variable.key}", user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
context 'unauthorized user' do
it 'does not delete variable' do
- delete api("/admin/ci/variables/#{variable.key}")
+ delete api(path, admin_mode: true)
expect(response).to have_gitlab_http_status(:unauthorized)
end
diff --git a/spec/requests/api/admin/instance_clusters_spec.rb b/spec/requests/api/admin/instance_clusters_spec.rb
index 7b510f74fd4..f2e62533b78 100644
--- a/spec/requests/api/admin/instance_clusters_spec.rb
+++ b/spec/requests/api/admin/instance_clusters_spec.rb
@@ -2,10 +2,9 @@
require 'spec_helper'
-RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_management do
+RSpec.describe ::API::Admin::InstanceClusters, feature_category: :deployment_management do
include KubernetesHelpers
- let_it_be(:regular_user) { create(:user) }
let_it_be(:admin_user) { create(:admin) }
let_it_be(:project) { create(:project) }
let_it_be(:project_cluster) do
@@ -17,35 +16,27 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
let(:project_cluster_id) { project_cluster.id }
describe "GET /admin/clusters" do
+ let_it_be(:path) { "/admin/clusters" }
let_it_be(:clusters) do
create_list(:cluster, 3, :provided_by_gcp, :instance, :production_environment)
end
- include_examples ':certificate_based_clusters feature flag API responses' do
- let(:subject) { get api("/admin/clusters", admin_user) }
- end
+ it_behaves_like 'GET request permissions for admin mode'
- context "when authenticated as a non-admin user" do
- it 'returns 403' do
- get api('/admin/clusters', regular_user)
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ include_examples ':certificate_based_clusters feature flag API responses' do
+ let(:subject) { get api(path, admin_user, admin_mode: true) }
end
context "when authenticated as admin" do
before do
- get api("/admin/clusters", admin_user)
- end
-
- it 'returns 200' do
- expect(response).to have_gitlab_http_status(:ok)
+ get api(path, admin_user, admin_mode: true)
end
it 'includes pagination headers' do
expect(response).to include_pagination_headers
end
- it 'only returns the instance clusters' do
+ it 'only returns the instance clusters', :aggregate_failures do
cluster_ids = json_response.map { |cluster| cluster['id'] }
expect(cluster_ids).to match_array(clusters.pluck(:id))
expect(cluster_ids).not_to include(project_cluster_id)
@@ -60,19 +51,23 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
let_it_be(:cluster) do
create(:cluster, :instance, :provided_by_gcp, :with_domain,
- platform_kubernetes: platform_kubernetes,
- user: admin_user)
+ { platform_kubernetes: platform_kubernetes,
+ user: admin_user })
end
let(:cluster_id) { cluster.id }
+ let(:path) { "/admin/clusters/#{cluster_id}" }
+
+ it_behaves_like 'GET request permissions for admin mode'
+
include_examples ':certificate_based_clusters feature flag API responses' do
- let(:subject) { get api("/admin/clusters/#{cluster_id}", admin_user) }
+ let(:subject) { get api(path, admin_user, admin_mode: true) }
end
context "when authenticated as admin" do
before do
- get api("/admin/clusters/#{cluster_id}", admin_user)
+ get api(path, admin_user, admin_mode: true)
end
context "when no cluster associated to the ID" do
@@ -84,15 +79,11 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
end
context "when cluster with cluster_id exists" do
- it 'returns 200' do
- expect(response).to have_gitlab_http_status(:ok)
- end
-
it 'returns the cluster with cluster_id' do
expect(json_response['id']).to eq(cluster.id)
end
- it 'returns the cluster information' do
+ it 'returns the cluster information', :aggregate_failures do
expect(json_response['provider_type']).to eq('gcp')
expect(json_response['platform_type']).to eq('kubernetes')
expect(json_response['environment_scope']).to eq('*')
@@ -102,21 +93,21 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
expect(json_response['managed']).to be_truthy
end
- it 'returns kubernetes platform information' do
+ it 'returns kubernetes platform information', :aggregate_failures do
platform = json_response['platform_kubernetes']
expect(platform['api_url']).to eq('https://kubernetes.example.com')
expect(platform['ca_cert']).to be_present
end
- it 'returns user information' do
+ it 'returns user information', :aggregate_failures do
user = json_response['user']
expect(user['id']).to eq(admin_user.id)
expect(user['username']).to eq(admin_user.username)
end
- it 'returns GCP provider information' do
+ it 'returns GCP provider information', :aggregate_failures do
gcp_provider = json_response['provider_gcp']
expect(gcp_provider['cluster_id']).to eq(cluster.id)
@@ -140,18 +131,11 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
context 'when trying to get a project cluster via the instance cluster endpoint' do
it 'returns 404' do
- get api("/admin/clusters/#{project_cluster_id}", admin_user)
+ get api("/admin/clusters/#{project_cluster_id}", admin_user, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
-
- context "when authenticated as a non-admin user" do
- it 'returns 403' do
- get api("/admin/clusters/#{cluster_id}", regular_user)
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
end
end
@@ -159,6 +143,7 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
let(:api_url) { 'https://example.com' }
let(:authorization_type) { 'rbac' }
let(:clusterable) { Clusters::Instance.new }
+ let_it_be(:path) { '/admin/clusters/add' }
let(:platform_kubernetes_attributes) do
{
@@ -196,20 +181,20 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
}
end
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { cluster_params }
+ end
+
include_examples ':certificate_based_clusters feature flag API responses' do
- let(:subject) { post api('/admin/clusters/add', admin_user), params: cluster_params }
+ let(:subject) { post api(path, admin_user, admin_mode: true), params: cluster_params }
end
context 'authorized user' do
before do
- post api('/admin/clusters/add', admin_user), params: cluster_params
+ post api(path, admin_user, admin_mode: true), params: cluster_params
end
context 'with valid params' do
- it 'responds with 201' do
- expect(response).to have_gitlab_http_status(:created)
- end
-
it 'creates a new Clusters::Cluster', :aggregate_failures do
cluster_result = Clusters::Cluster.find(json_response["id"])
platform_kubernetes = cluster_result.platform
@@ -271,7 +256,7 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
context 'when an instance cluster already exists' do
it 'allows user to add multiple clusters' do
- post api('/admin/clusters/add', admin_user), params: multiple_cluster_params
+ post api(path, admin_user, admin_mode: true), params: multiple_cluster_params
expect(Clusters::Instance.new.clusters.count).to eq(2)
end
@@ -280,8 +265,8 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
context 'with invalid params' do
context 'when missing a required parameter' do
- it 'responds with 400' do
- post api('/admin/clusters/add', admin_user), params: invalid_cluster_params
+ it 'responds with 400', :aggregate_failures do
+ post api(path, admin_user, admin_mode: true), params: invalid_cluster_params
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eql('name is missing')
end
@@ -300,14 +285,6 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
end
end
end
-
- context 'non-authorized user' do
- it 'responds with 403' do
- post api('/admin/clusters/add', regular_user), params: cluster_params
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
end
describe 'PUT /admin/clusters/:cluster_id' do
@@ -329,23 +306,25 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
create(:cluster, :instance, :provided_by_gcp, domain: 'old-domain.com')
end
+ let(:path) { "/admin/clusters/#{cluster.id}" }
+
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:params) { update_params }
+ end
+
include_examples ':certificate_based_clusters feature flag API responses' do
- let(:subject) { put api("/admin/clusters/#{cluster.id}", admin_user), params: update_params }
+ let(:subject) { put api(path, admin_user, admin_mode: true), params: update_params }
end
context 'authorized user' do
before do
- put api("/admin/clusters/#{cluster.id}", admin_user), params: update_params
+ put api(path, admin_user, admin_mode: true), params: update_params
cluster.reload
end
context 'with valid params' do
- it 'responds with 200' do
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- it 'updates cluster attributes' do
+ it 'updates cluster attributes', :aggregate_failures do
expect(cluster.domain).to eq('new-domain.com')
expect(cluster.managed).to be_falsy
expect(cluster.enabled).to be_falsy
@@ -359,7 +338,7 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
expect(response).to have_gitlab_http_status(:bad_request)
end
- it 'does not update cluster attributes' do
+ it 'does not update cluster attributes', :aggregate_failures do
expect(cluster.domain).to eq('old-domain.com')
expect(cluster.managed).to be_truthy
expect(cluster.enabled).to be_truthy
@@ -422,7 +401,7 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
expect(response).to have_gitlab_http_status(:ok)
end
- it 'updates platform kubernetes attributes' do
+ it 'updates platform kubernetes attributes', :aggregate_failures do
platform_kubernetes = cluster.platform_kubernetes
expect(cluster.name).to eq('new-name')
@@ -435,26 +414,18 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
let(:cluster_id) { 1337 }
it 'returns 404' do
- put api("/admin/clusters/#{cluster_id}", admin_user), params: update_params
+ put api("/admin/clusters/#{cluster_id}", admin_user, admin_mode: true), params: update_params
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when trying to update a project cluster via the instance cluster endpoint' do
it 'returns 404' do
- put api("/admin/clusters/#{project_cluster_id}", admin_user), params: update_params
+ put api("/admin/clusters/#{project_cluster_id}", admin_user, admin_mode: true), params: update_params
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
-
- context 'non-authorized user' do
- it 'responds with 403' do
- put api("/admin/clusters/#{cluster.id}", regular_user), params: update_params
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
end
describe 'DELETE /admin/clusters/:cluster_id' do
@@ -464,17 +435,17 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
create(:cluster, :instance, :provided_by_gcp)
end
+ let_it_be(:path) { "/admin/clusters/#{cluster.id}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
+
include_examples ':certificate_based_clusters feature flag API responses' do
- let(:subject) { delete api("/admin/clusters/#{cluster.id}", admin_user), params: cluster_params }
+ let(:subject) { delete api(path, admin_user, admin_mode: true), params: cluster_params }
end
context 'authorized user' do
before do
- delete api("/admin/clusters/#{cluster.id}", admin_user), params: cluster_params
- end
-
- it 'responds with 204' do
- expect(response).to have_gitlab_http_status(:no_content)
+ delete api(path, admin_user, admin_mode: true), params: cluster_params
end
it 'deletes the cluster' do
@@ -485,25 +456,17 @@ RSpec.describe ::API::Admin::InstanceClusters, feature_category: :kubernetes_man
let(:cluster_id) { 1337 }
it 'returns 404' do
- delete api("/admin/clusters/#{cluster_id}", admin_user)
+ delete api(path, admin_user, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when trying to update a project cluster via the instance cluster endpoint' do
it 'returns 404' do
- delete api("/admin/clusters/#{project_cluster_id}", admin_user)
+ delete api("/admin/clusters/#{project_cluster_id}", admin_user, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
-
- context 'non-authorized user' do
- it 'responds with 403' do
- delete api("/admin/clusters/#{cluster.id}", regular_user), params: cluster_params
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
end
end
diff --git a/spec/requests/api/admin/plan_limits_spec.rb b/spec/requests/api/admin/plan_limits_spec.rb
index 2de7a66d803..6085b48c7c2 100644
--- a/spec/requests/api/admin/plan_limits_spec.rb
+++ b/spec/requests/api/admin/plan_limits_spec.rb
@@ -2,30 +2,22 @@
require 'spec_helper'
-RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owned do
- let_it_be(:user) { create(:user) }
+RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :shared do
let_it_be(:admin) { create(:admin) }
let_it_be(:plan) { create(:plan, name: 'default') }
+ let_it_be(:path) { '/application/plan_limits' }
describe 'GET /application/plan_limits' do
- context 'as a non-admin user' do
- it 'returns 403' do
- get api('/application/plan_limits', user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
+ it_behaves_like 'GET request permissions for admin mode'
context 'as an admin user' do
context 'no params' do
- it 'returns plan limits' do
- get api('/application/plan_limits', admin)
+ it 'returns plan limits', :aggregate_failures do
+ get api(path, admin, admin_mode: true)
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Hash
expect(json_response['ci_pipeline_size']).to eq(Plan.default.actual_limits.ci_pipeline_size)
expect(json_response['ci_active_jobs']).to eq(Plan.default.actual_limits.ci_active_jobs)
- expect(json_response['ci_active_pipelines']).to eq(Plan.default.actual_limits.ci_active_pipelines)
expect(json_response['ci_project_subscriptions']).to eq(Plan.default.actual_limits.ci_project_subscriptions)
expect(json_response['ci_pipeline_schedules']).to eq(Plan.default.actual_limits.ci_pipeline_schedules)
expect(json_response['ci_needs_size_limit']).to eq(Plan.default.actual_limits.ci_needs_size_limit)
@@ -49,14 +41,13 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
@params = { plan_name: 'default' }
end
- it 'returns plan limits' do
- get api('/application/plan_limits', admin), params: @params
+ it 'returns plan limits', :aggregate_failures do
+ get api(path, admin, admin_mode: true), params: @params
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Hash
expect(json_response['ci_pipeline_size']).to eq(Plan.default.actual_limits.ci_pipeline_size)
expect(json_response['ci_active_jobs']).to eq(Plan.default.actual_limits.ci_active_jobs)
- expect(json_response['ci_active_pipelines']).to eq(Plan.default.actual_limits.ci_active_pipelines)
expect(json_response['ci_project_subscriptions']).to eq(Plan.default.actual_limits.ci_project_subscriptions)
expect(json_response['ci_pipeline_schedules']).to eq(Plan.default.actual_limits.ci_pipeline_schedules)
expect(json_response['ci_needs_size_limit']).to eq(Plan.default.actual_limits.ci_needs_size_limit)
@@ -80,8 +71,8 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
@params = { plan_name: 'my-plan' }
end
- it 'returns validation error' do
- get api('/application/plan_limits', admin), params: @params
+ it 'returns validation error', :aggregate_failures do
+ get api(path, admin, admin_mode: true), params: @params
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('plan_name does not have a valid value')
@@ -91,22 +82,17 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
end
describe 'PUT /application/plan_limits' do
- context 'as a non-admin user' do
- it 'returns 403' do
- put api('/application/plan_limits', user), params: { plan_name: 'default' }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:params) { { 'plan_name': 'default' } }
end
context 'as an admin user' do
context 'correct params' do
- it 'updates multiple plan limits' do
- put api('/application/plan_limits', admin), params: {
+ it 'updates multiple plan limits', :aggregate_failures do
+ put api(path, admin, admin_mode: true), params: {
'plan_name': 'default',
'ci_pipeline_size': 101,
'ci_active_jobs': 102,
- 'ci_active_pipelines': 103,
'ci_project_subscriptions': 104,
'ci_pipeline_schedules': 105,
'ci_needs_size_limit': 106,
@@ -124,11 +110,9 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
'pipeline_hierarchy_size': 250
}
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Hash
expect(json_response['ci_pipeline_size']).to eq(101)
expect(json_response['ci_active_jobs']).to eq(102)
- expect(json_response['ci_active_pipelines']).to eq(103)
expect(json_response['ci_project_subscriptions']).to eq(104)
expect(json_response['ci_pipeline_schedules']).to eq(105)
expect(json_response['ci_needs_size_limit']).to eq(106)
@@ -146,8 +130,8 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
expect(json_response['pipeline_hierarchy_size']).to eq(250)
end
- it 'updates single plan limits' do
- put api('/application/plan_limits', admin), params: {
+ it 'updates single plan limits', :aggregate_failures do
+ put api(path, admin, admin_mode: true), params: {
'plan_name': 'default',
'maven_max_file_size': 100
}
@@ -159,8 +143,8 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
end
context 'empty params' do
- it 'fails to update plan limits' do
- put api('/application/plan_limits', admin), params: {}
+ it 'fails to update plan limits', :aggregate_failures do
+ put api(path, admin, admin_mode: true), params: {}
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to match('plan_name is missing')
@@ -168,12 +152,11 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
end
context 'params with wrong type' do
- it 'fails to update plan limits' do
- put api('/application/plan_limits', admin), params: {
+ it 'fails to update plan limits', :aggregate_failures do
+ put api(path, admin, admin_mode: true), params: {
'plan_name': 'default',
'ci_pipeline_size': 'z',
'ci_active_jobs': 'y',
- 'ci_active_pipelines': 'x',
'ci_project_subscriptions': 'w',
'ci_pipeline_schedules': 'v',
'ci_needs_size_limit': 'u',
@@ -195,7 +178,6 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
expect(json_response['error']).to include(
'ci_pipeline_size is invalid',
'ci_active_jobs is invalid',
- 'ci_active_pipelines is invalid',
'ci_project_subscriptions is invalid',
'ci_pipeline_schedules is invalid',
'ci_needs_size_limit is invalid',
@@ -216,8 +198,8 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
end
context 'missing plan_name in params' do
- it 'fails to update plan limits' do
- put api('/application/plan_limits', admin), params: { 'conan_max_file_size': 0 }
+ it 'fails to update plan limits', :aggregate_failures do
+ put api(path, admin, admin_mode: true), params: { 'conan_max_file_size': 0 }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to match('plan_name is missing')
@@ -229,8 +211,8 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits', feature_category: :not_owne
Plan.default.actual_limits.update!({ 'golang_max_file_size': 1000 })
end
- it 'updates only declared plan limits' do
- put api('/application/plan_limits', admin), params: {
+ it 'updates only declared plan limits', :aggregate_failures do
+ put api(path, admin, admin_mode: true), params: {
'plan_name': 'default',
'pypi_max_file_size': 200,
'golang_max_file_size': 999
diff --git a/spec/requests/api/admin/sidekiq_spec.rb b/spec/requests/api/admin/sidekiq_spec.rb
index 0b456721d4f..eca12c8e433 100644
--- a/spec/requests/api/admin/sidekiq_spec.rb
+++ b/spec/requests/api/admin/sidekiq_spec.rb
@@ -2,18 +2,10 @@
require 'spec_helper'
-RSpec.describe API::Admin::Sidekiq, :clean_gitlab_redis_queues, feature_category: :not_owned do
+RSpec.describe API::Admin::Sidekiq, :clean_gitlab_redis_queues, feature_category: :shared do
let_it_be(:admin) { create(:admin) }
describe 'DELETE /admin/sidekiq/queues/:queue_name' do
- context 'when the user is not an admin' do
- it 'returns a 403' do
- delete api("/admin/sidekiq/queues/authorized_projects?user=#{admin.username}", create(:user))
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
context 'when the user is an admin' do
around do |example|
Sidekiq::Queue.new('authorized_projects').clear
@@ -31,14 +23,21 @@ RSpec.describe API::Admin::Sidekiq, :clean_gitlab_redis_queues, feature_category
end
context 'valid request' do
- it 'returns info about the deleted jobs' do
+ before do
add_job(admin, [1])
add_job(admin, [2])
add_job(create(:user), [3])
+ end
+
+ let_it_be(:path) { "/admin/sidekiq/queues/authorized_projects?user=#{admin.username}&worker_class=AuthorizedProjectsWorker" }
- delete api("/admin/sidekiq/queues/authorized_projects?user=#{admin.username}&worker_class=AuthorizedProjectsWorker", admin)
+ it_behaves_like 'DELETE request permissions for admin mode' do
+ let(:success_status_code) { :ok }
+ end
+
+ it 'returns info about the deleted jobs' do
+ delete api(path, admin, admin_mode: true)
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq('completed' => true,
'deleted_jobs' => 2,
'queue_size' => 1)
@@ -47,7 +46,7 @@ RSpec.describe API::Admin::Sidekiq, :clean_gitlab_redis_queues, feature_category
context 'when no required params are provided' do
it 'returns a 400' do
- delete api("/admin/sidekiq/queues/authorized_projects?user_2=#{admin.username}", admin)
+ delete api("/admin/sidekiq/queues/authorized_projects?user_2=#{admin.username}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -55,7 +54,7 @@ RSpec.describe API::Admin::Sidekiq, :clean_gitlab_redis_queues, feature_category
context 'when the queue does not exist' do
it 'returns a 404' do
- delete api("/admin/sidekiq/queues/authorized_projects_2?user=#{admin.username}", admin)
+ delete api("/admin/sidekiq/queues/authorized_projects_2?user=#{admin.username}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/requests/api/api_guard/admin_mode_middleware_spec.rb b/spec/requests/api/api_guard/admin_mode_middleware_spec.rb
index 21f3691c20b..7268fa2c90b 100644
--- a/spec/requests/api/api_guard/admin_mode_middleware_spec.rb
+++ b/spec/requests/api/api_guard/admin_mode_middleware_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::APIGuard::AdminModeMiddleware, :request_store, feature_category: :not_owned do
+RSpec.describe API::APIGuard::AdminModeMiddleware, :request_store, feature_category: :shared do
let(:user) { create(:admin) }
it 'is loaded' do
diff --git a/spec/requests/api/api_guard/response_coercer_middleware_spec.rb b/spec/requests/api/api_guard/response_coercer_middleware_spec.rb
index 77498c2e2b3..4a993d0b255 100644
--- a/spec/requests/api/api_guard/response_coercer_middleware_spec.rb
+++ b/spec/requests/api/api_guard/response_coercer_middleware_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::APIGuard::ResponseCoercerMiddleware, feature_category: :not_owned do
+RSpec.describe API::APIGuard::ResponseCoercerMiddleware, feature_category: :shared do
using RSpec::Parameterized::TableSyntax
it 'is loaded' do
diff --git a/spec/requests/api/api_spec.rb b/spec/requests/api/api_spec.rb
index 35851fff6c8..219c7dbdbc5 100644
--- a/spec/requests/api/api_spec.rb
+++ b/spec/requests/api/api_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::API, feature_category: :authentication_and_authorization do
+RSpec.describe API::API, feature_category: :system_access do
include GroupAPIHelpers
describe 'Record user last activity in after hook' do
@@ -359,4 +359,26 @@ RSpec.describe API::API, feature_category: :authentication_and_authorization do
end
end
end
+
+ describe 'Handle Gitlab::Git::ResourceExhaustedError exception' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository, creator: user) }
+
+ before do
+ project.add_maintainer(user)
+ allow(Gitlab::GitalyClient).to receive(:call).with(any_args).and_raise(
+ Gitlab::Git::ResourceExhaustedError.new("Upstream Gitaly has been exhausted. Try again later", 50)
+ )
+ end
+
+ it 'returns 429 status with exhausted' do
+ get api("/projects/#{project.id}/repository/commits", user)
+
+ expect(response).to have_gitlab_http_status(:too_many_requests)
+ expect(response.headers['Retry-After']).to be(50)
+ expect(json_response).to eql(
+ 'message' => 'Upstream Gitaly has been exhausted. Try again later'
+ )
+ end
+ end
end
diff --git a/spec/requests/api/appearance_spec.rb b/spec/requests/api/appearance_spec.rb
index c08ecae28e8..2ea4dcce7d8 100644
--- a/spec/requests/api/appearance_spec.rb
+++ b/spec/requests/api/appearance_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Appearance, 'Appearance', feature_category: :navigation do
+RSpec.describe API::Appearance, 'Appearance', :aggregate_failures, feature_category: :navigation do
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create(:admin) }
let_it_be(:path) { "/application/appearance" }
@@ -12,7 +12,7 @@ RSpec.describe API::Appearance, 'Appearance', feature_category: :navigation do
context 'as an admin user' do
it "returns appearance" do
- get api("/application/appearance", admin, admin_mode: true)
+ get api(path, admin, admin_mode: true)
expect(json_response).to be_an Hash
expect(json_response['description']).to eq('')
@@ -36,12 +36,14 @@ RSpec.describe API::Appearance, 'Appearance', feature_category: :navigation do
end
describe "PUT /application/appearance" do
- it_behaves_like 'PUT request permissions for admin mode', { title: "Test" }
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:params) { { title: "Test" } }
+ end
context 'as an admin user' do
context "instance basics" do
it "allows updating the settings" do
- put api("/application/appearance", admin, admin_mode: true), params: {
+ put api(path, admin, admin_mode: true), params: {
title: "GitLab Test Instance",
description: "gitlab-test.example.com",
pwa_name: "GitLab PWA Test",
@@ -81,7 +83,7 @@ RSpec.describe API::Appearance, 'Appearance', feature_category: :navigation do
email_header_and_footer_enabled: true
}
- put api("/application/appearance", admin, admin_mode: true), params: settings
+ put api(path, admin, admin_mode: true), params: settings
expect(response).to have_gitlab_http_status(:ok)
settings.each do |attribute, value|
@@ -91,14 +93,14 @@ RSpec.describe API::Appearance, 'Appearance', feature_category: :navigation do
context "fails on invalid color values" do
it "with message_font_color" do
- put api("/application/appearance", admin, admin_mode: true), params: { message_font_color: "No Color" }
+ put api(path, admin, admin_mode: true), params: { message_font_color: "No Color" }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['message_font_color']).to contain_exactly('must be a valid color code')
end
it "with message_background_color" do
- put api("/application/appearance", admin, admin_mode: true), params: { message_background_color: "#1" }
+ put api(path, admin, admin_mode: true), params: { message_background_color: "#1" }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['message_background_color']).to contain_exactly('must be a valid color code')
@@ -110,7 +112,7 @@ RSpec.describe API::Appearance, 'Appearance', feature_category: :navigation do
let_it_be(:appearance) { create(:appearance) }
it "allows updating the image files" do
- put api("/application/appearance", admin, admin_mode: true), params: {
+ put api(path, admin, admin_mode: true), params: {
logo: fixture_file_upload("spec/fixtures/dk.png", "image/png"),
header_logo: fixture_file_upload("spec/fixtures/dk.png", "image/png"),
pwa_icon: fixture_file_upload("spec/fixtures/dk.png", "image/png"),
@@ -126,14 +128,14 @@ RSpec.describe API::Appearance, 'Appearance', feature_category: :navigation do
context "fails on invalid color images" do
it "with string instead of file" do
- put api("/application/appearance", admin, admin_mode: true), params: { logo: 'not-a-file.png' }
+ put api(path, admin, admin_mode: true), params: { logo: 'not-a-file.png' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq("logo is invalid")
end
it "with .svg file instead of .png" do
- put api("/application/appearance", admin, admin_mode: true), params: { favicon: fixture_file_upload("spec/fixtures/logo_sample.svg", "image/svg") }
+ put api(path, admin, admin_mode: true), params: { favicon: fixture_file_upload("spec/fixtures/logo_sample.svg", "image/svg") }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['favicon']).to contain_exactly("You are not allowed to upload \"svg\" files, allowed types: png, ico")
diff --git a/spec/requests/api/applications_spec.rb b/spec/requests/api/applications_spec.rb
index b81cdcfea8e..16e24807e67 100644
--- a/spec/requests/api/applications_spec.rb
+++ b/spec/requests/api/applications_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Applications, :api, feature_category: :authentication_and_authorization do
+RSpec.describe API::Applications, :aggregate_failures, :api, feature_category: :system_access do
let_it_be(:admin) { create(:admin) }
let_it_be(:user) { create(:user) }
let_it_be(:scopes) { 'api' }
@@ -10,7 +10,9 @@ RSpec.describe API::Applications, :api, feature_category: :authentication_and_au
let!(:application) { create(:application, name: 'another_application', owner: nil, redirect_uri: 'http://other_application.url', scopes: scopes) }
describe 'POST /applications' do
- it_behaves_like 'POST request permissions for admin mode', { name: 'application_name', redirect_uri: 'http://application.url', scopes: 'api' }
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { { name: 'application_name', redirect_uri: 'http://application.url', scopes: 'api' } }
+ end
context 'authenticated and authorized user' do
it 'creates and returns an OAuth application' do
@@ -22,7 +24,7 @@ RSpec.describe API::Applications, :api, feature_category: :authentication_and_au
expect(json_response).to be_a Hash
expect(json_response['application_id']).to eq application.uid
- expect(json_response['secret']).to eq application.secret
+ expect(application.secret_matches?(json_response['secret'])).to eq(true)
expect(json_response['callback_url']).to eq application.redirect_uri
expect(json_response['confidential']).to eq application.confidential
expect(application.scopes.to_s).to eq('api')
@@ -133,7 +135,7 @@ RSpec.describe API::Applications, :api, feature_category: :authentication_and_au
context 'authorized user without authorization' do
it 'does not create application' do
expect do
- post api('/applications', user), params: { name: 'application_name', redirect_uri: 'http://application.url', scopes: scopes }
+ post api(path, user), params: { name: 'application_name', redirect_uri: 'http://application.url', scopes: scopes }
end.not_to change { Doorkeeper::Application.count }
end
end
diff --git a/spec/requests/api/avatar_spec.rb b/spec/requests/api/avatar_spec.rb
index fcef5b6ca78..0a77b6e228e 100644
--- a/spec/requests/api/avatar_spec.rb
+++ b/spec/requests/api/avatar_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe API::Avatar, feature_category: :user_profile do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['avatar_url']).to eql("#{::Settings.gitlab.base_url}#{user.avatar.local_url}")
+ is_expected.to have_request_urgency(:medium)
end
end
diff --git a/spec/requests/api/award_emoji_spec.rb b/spec/requests/api/award_emoji_spec.rb
index 87dc06b7d15..22c67a253e3 100644
--- a/spec/requests/api/award_emoji_spec.rb
+++ b/spec/requests/api/award_emoji_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::AwardEmoji, feature_category: :not_owned do
+RSpec.describe API::AwardEmoji, feature_category: :shared do
let_it_be_with_reload(:project) { create(:project, :private) }
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
diff --git a/spec/requests/api/badges_spec.rb b/spec/requests/api/badges_spec.rb
index 6c6a7cc7cc6..1c09c1129a2 100644
--- a/spec/requests/api/badges_spec.rb
+++ b/spec/requests/api/badges_spec.rb
@@ -72,9 +72,9 @@ RSpec.describe API::Badges, feature_category: :projects do
context 'when authenticated as a non-member' do
%i[maintainer developer access_requester stranger].each do |type|
- let(:badge) { source.badges.first }
-
context "as a #{type}" do
+ let(:badge) { source.badges.first }
+
it 'returns 200', :quarantine do
user = public_send(type)
diff --git a/spec/requests/api/broadcast_messages_spec.rb b/spec/requests/api/broadcast_messages_spec.rb
index 5cbb7dbfa12..530c81364a8 100644
--- a/spec/requests/api/broadcast_messages_spec.rb
+++ b/spec/requests/api/broadcast_messages_spec.rb
@@ -2,16 +2,16 @@
require 'spec_helper'
-RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
- let_it_be(:user) { create(:user) }
+RSpec.describe API::BroadcastMessages, :aggregate_failures, feature_category: :onboarding do
let_it_be(:admin) { create(:admin) }
let_it_be(:message) { create(:broadcast_message) }
+ let_it_be(:path) { '/broadcast_messages' }
describe 'GET /broadcast_messages' do
it 'returns an Array of BroadcastMessages' do
create(:broadcast_message)
- get api('/broadcast_messages')
+ get api(path)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -22,8 +22,10 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
end
describe 'GET /broadcast_messages/:id' do
+ let_it_be(:path) { "#{path}/#{message.id}" }
+
it 'returns the specified message' do
- get api("/broadcast_messages/#{message.id}")
+ get api(path)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq message.id
@@ -33,16 +35,14 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
end
describe 'POST /broadcast_messages' do
- it 'returns a 401 for anonymous users' do
- post api('/broadcast_messages'), params: attributes_for(:broadcast_message)
-
- expect(response).to have_gitlab_http_status(:unauthorized)
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { { message: 'Test message' } }
end
- it 'returns a 403 for users' do
- post api('/broadcast_messages', user), params: attributes_for(:broadcast_message)
+ it 'returns a 401 for anonymous users' do
+ post api(path), params: attributes_for(:broadcast_message)
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:unauthorized)
end
context 'as an admin' do
@@ -50,7 +50,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
attrs = attributes_for(:broadcast_message)
attrs.delete(:message)
- post api('/broadcast_messages', admin), params: attrs
+ post api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq 'message is missing'
@@ -59,7 +59,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'defines sane default start and end times' do
time = Time.zone.parse('2016-07-02 10:11:12')
travel_to(time) do
- post api('/broadcast_messages', admin), params: { message: 'Test message' }
+ post api(path, admin, admin_mode: true), params: { message: 'Test message' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['starts_at']).to eq '2016-07-02T10:11:12.000Z'
@@ -70,7 +70,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'accepts a custom background and foreground color' do
attrs = attributes_for(:broadcast_message, color: '#000000', font: '#cecece')
- post api('/broadcast_messages', admin), params: attrs
+ post api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:created)
expect(json_response['color']).to eq attrs[:color]
@@ -81,7 +81,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
target_access_levels = [Gitlab::Access::GUEST, Gitlab::Access::DEVELOPER]
attrs = attributes_for(:broadcast_message, target_access_levels: target_access_levels)
- post api('/broadcast_messages', admin), params: attrs
+ post api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:created)
expect(json_response['target_access_levels']).to eq attrs[:target_access_levels]
@@ -90,7 +90,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'accepts a target path' do
attrs = attributes_for(:broadcast_message, target_path: "*/welcome")
- post api('/broadcast_messages', admin), params: attrs
+ post api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:created)
expect(json_response['target_path']).to eq attrs[:target_path]
@@ -99,7 +99,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'accepts a broadcast type' do
attrs = attributes_for(:broadcast_message, broadcast_type: 'notification')
- post api('/broadcast_messages', admin), params: attrs
+ post api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:created)
expect(json_response['broadcast_type']).to eq attrs[:broadcast_type]
@@ -108,7 +108,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'uses default broadcast type' do
attrs = attributes_for(:broadcast_message)
- post api('/broadcast_messages', admin), params: attrs
+ post api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:created)
expect(json_response['broadcast_type']).to eq 'banner'
@@ -117,7 +117,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'errors for invalid broadcast type' do
attrs = attributes_for(:broadcast_message, broadcast_type: 'invalid-type')
- post api('/broadcast_messages', admin), params: attrs
+ post api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -125,7 +125,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'accepts an active dismissable value' do
attrs = { message: 'new message', dismissable: true }
- post api('/broadcast_messages', admin), params: attrs
+ post api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:created)
expect(json_response['dismissable']).to eq true
@@ -134,27 +134,25 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
end
describe 'PUT /broadcast_messages/:id' do
- it 'returns a 401 for anonymous users' do
- put api("/broadcast_messages/#{message.id}"),
- params: attributes_for(:broadcast_message)
+ let_it_be(:path) { "#{path}/#{message.id}" }
- expect(response).to have_gitlab_http_status(:unauthorized)
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:params) { { message: 'Test message' } }
end
- it 'returns a 403 for users' do
- put api("/broadcast_messages/#{message.id}", user),
+ it 'returns a 401 for anonymous users' do
+ put api(path),
params: attributes_for(:broadcast_message)
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:unauthorized)
end
context 'as an admin' do
it 'accepts new background and foreground colors' do
attrs = { color: '#000000', font: '#cecece' }
- put api("/broadcast_messages/#{message.id}", admin), params: attrs
+ put api(path, admin, admin_mode: true), params: attrs
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response['color']).to eq attrs[:color]
expect(json_response['font']).to eq attrs[:font]
end
@@ -164,7 +162,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
travel_to(time) do
attrs = { starts_at: Time.zone.now, ends_at: 3.hours.from_now }
- put api("/broadcast_messages/#{message.id}", admin), params: attrs
+ put api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['starts_at']).to eq '2016-07-02T10:11:12.000Z'
@@ -175,7 +173,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'accepts a new message' do
attrs = { message: 'new message' }
- put api("/broadcast_messages/#{message.id}", admin), params: attrs
+ put api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:ok)
expect { message.reload }.to change { message.message }.to('new message')
@@ -184,7 +182,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'accepts a new target_access_levels' do
attrs = { target_access_levels: [Gitlab::Access::MAINTAINER] }
- put api("/broadcast_messages/#{message.id}", admin), params: attrs
+ put api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['target_access_levels']).to eq attrs[:target_access_levels]
@@ -193,7 +191,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'accepts a new target_path' do
attrs = { target_path: '*/welcome' }
- put api("/broadcast_messages/#{message.id}", admin), params: attrs
+ put api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['target_path']).to eq attrs[:target_path]
@@ -202,7 +200,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'accepts a new broadcast_type' do
attrs = { broadcast_type: 'notification' }
- put api("/broadcast_messages/#{message.id}", admin), params: attrs
+ put api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['broadcast_type']).to eq attrs[:broadcast_type]
@@ -211,7 +209,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'errors for invalid broadcast type' do
attrs = { broadcast_type: 'invalid-type' }
- put api("/broadcast_messages/#{message.id}", admin), params: attrs
+ put api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -219,7 +217,7 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
it 'accepts a new dismissable value' do
attrs = { message: 'new message', dismissable: true }
- put api("/broadcast_messages/#{message.id}", admin), params: attrs
+ put api(path, admin, admin_mode: true), params: attrs
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['dismissable']).to eq true
@@ -228,27 +226,24 @@ RSpec.describe API::BroadcastMessages, feature_category: :onboarding do
end
describe 'DELETE /broadcast_messages/:id' do
- it 'returns a 401 for anonymous users' do
- delete api("/broadcast_messages/#{message.id}"),
- params: attributes_for(:broadcast_message)
+ let_it_be(:path) { "#{path}/#{message.id}" }
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
+ it_behaves_like 'DELETE request permissions for admin mode'
- it 'returns a 403 for users' do
- delete api("/broadcast_messages/#{message.id}", user),
+ it 'returns a 401 for anonymous users' do
+ delete api(path),
params: attributes_for(:broadcast_message)
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:unauthorized)
end
it_behaves_like '412 response' do
- let(:request) { api("/broadcast_messages/#{message.id}", admin) }
+ let(:request) { api("/broadcast_messages/#{message.id}", admin, admin_mode: true) }
end
it 'deletes the broadcast message for admins' do
expect do
- delete api("/broadcast_messages/#{message.id}", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { BroadcastMessage.count }.by(-1)
diff --git a/spec/requests/api/bulk_imports_spec.rb b/spec/requests/api/bulk_imports_spec.rb
index 23dfe865ba3..b159d4ad445 100644
--- a/spec/requests/api/bulk_imports_spec.rb
+++ b/spec/requests/api/bulk_imports_spec.rb
@@ -75,6 +75,8 @@ RSpec.describe API::BulkImports, feature_category: :importers do
end
describe 'POST /bulk_imports' do
+ let_it_be(:destination_namespace) { create(:group) }
+
let(:request) { post api('/bulk_imports', user), params: params }
let(:destination_param) { { destination_slug: 'destination_slug' } }
let(:params) do
@@ -87,12 +89,15 @@ RSpec.describe API::BulkImports, feature_category: :importers do
{
source_type: 'group_entity',
source_full_path: 'full_path',
- destination_namespace: 'destination_namespace'
+ destination_namespace: destination_namespace.path
}.merge(destination_param)
]
}
end
+ let(:source_entity_type) { BulkImports::CreateService::ENTITY_TYPES_MAPPING.fetch(params[:entities][0][:source_type]) }
+ let(:source_entity_identifier) { ERB::Util.url_encode(params[:entities][0][:source_full_path]) }
+
before do
allow_next_instance_of(BulkImports::Clients::HTTP) do |instance|
allow(instance)
@@ -103,6 +108,10 @@ RSpec.describe API::BulkImports, feature_category: :importers do
.to receive(:instance_enterprise)
.and_return(false)
end
+ stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=access_token")
+ .to_return(status: 200, body: "", headers: {})
+
+ destination_namespace.add_owner(user)
end
shared_examples 'starting a new migration' do
@@ -192,7 +201,7 @@ RSpec.describe API::BulkImports, feature_category: :importers do
{
source_type: 'group_entity',
source_full_path: 'full_path',
- destination_namespace: 'destination_namespace'
+ destination_namespace: destination_namespace.path
}
]
}
@@ -214,20 +223,17 @@ RSpec.describe API::BulkImports, feature_category: :importers do
request
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq("entities[0][source_full_path] must be a relative path and not include protocol, sub-domain, " \
- "or domain information. E.g. 'source/full/path' not 'https://example.com/source/full/path'")
+ "or domain information. For example, 'source/full/path' not 'https://example.com/source/full/path'")
end
end
- context 'when the destination_namespace is invalid' do
+ context 'when the destination_namespace does not exist' do
it 'returns invalid error' do
- params[:entities][0][:destination_namespace] = "?not a destination-namespace"
+ params[:entities][0][:destination_namespace] = "invalid-destination-namespace"
request
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq("entities[0][destination_namespace] cannot start with a dash or forward slash, " \
- "or end with a period or forward slash. It can only contain alphanumeric " \
- "characters, periods, underscores, forward slashes and dashes. " \
- "E.g. 'destination_namespace' or 'destination/namespace'")
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response['message']).to eq("Import failed. Destination 'invalid-destination-namespace' is invalid, or you don't have permission.")
end
end
@@ -243,15 +249,35 @@ RSpec.describe API::BulkImports, feature_category: :importers do
end
context 'when the destination_slug is invalid' do
- it 'returns invalid error' do
+ it 'returns invalid error when restricting special characters is disabled' do
+ Feature.disable(:restrict_special_characters_in_namespace_path)
+
+ params[:entities][0][:destination_slug] = 'des?tin?atoi-slugg'
+
+ request
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to include("entities[0][destination_slug] cannot start with " \
+ "a non-alphanumeric character except for periods or " \
+ "underscores, can contain only alphanumeric characters, " \
+ "periods, and underscores, cannot end with a period or " \
+ "forward slash, and has no leading or trailing forward " \
+ "slashes. It can only contain alphanumeric characters, " \
+ "periods, underscores, and dashes. For example, " \
+ "'destination_namespace' not 'destination/namespace'")
+ end
+
+ it 'returns invalid error when restricting special characters is enabled' do
+ Feature.enable(:restrict_special_characters_in_namespace_path)
+
params[:entities][0][:destination_slug] = 'des?tin?atoi-slugg'
request
expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to include("entities[0][destination_slug] cannot start with a dash " \
- "or forward slash, or end with a period or forward slash. " \
- "It can only contain alphanumeric characters, periods, underscores, and dashes. " \
- "E.g. 'destination_namespace' not 'destination/namespace'")
+ expect(json_response['error']).to include("entities[0][destination_slug] must not start or " \
+ "end with a special character and must not contain " \
+ "consecutive special characters. It can only contain " \
+ "alphanumeric characters, periods, underscores, and " \
+ "dashes. For example, 'destination_namespace' not 'destination/namespace'")
end
end
@@ -271,12 +297,41 @@ RSpec.describe API::BulkImports, feature_category: :importers do
}
end
+ it 'returns blocked url message in the error' do
+ request
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+
+ expect(json_response['message']).to include("Url is blocked: Only allowed schemes are http, https")
+ end
+ end
+
+ context 'when source instance setting is disabled' do
+ let(:params) do
+ {
+ configuration: {
+ url: 'http://gitlab.example',
+ access_token: 'access_token'
+ },
+ entities: [
+ source_type: 'group_entity',
+ source_full_path: 'full_path',
+ destination_slug: 'destination_slug',
+ destination_namespace: 'destination_namespace'
+ ]
+ }
+ end
+
it 'returns blocked url error' do
+ stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=access_token")
+ .to_return(status: 404, body: "", headers: {})
+
request
expect(response).to have_gitlab_http_status(:unprocessable_entity)
- expect(json_response['message']).to eq('Validation failed: Url is blocked: Only allowed schemes are http, https')
+ expect(json_response['message']).to include("Group import disabled on source or destination instance. " \
+ "Ask an administrator to enable it on both instances and try again.")
end
end
diff --git a/spec/requests/api/ci/job_artifacts_spec.rb b/spec/requests/api/ci/job_artifacts_spec.rb
index ee390773f29..7cea744cdb9 100644
--- a/spec/requests/api/ci/job_artifacts_spec.rb
+++ b/spec/requests/api/ci/job_artifacts_spec.rb
@@ -190,7 +190,7 @@ RSpec.describe API::Ci::JobArtifacts, feature_category: :build_artifacts do
end
context 'when project is public with artifacts that are non public' do
- let(:job) { create(:ci_build, :artifacts, :non_public_artifacts, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :artifacts, :with_private_artifacts_config, pipeline: pipeline) }
it 'rejects access to artifacts' do
project.update_column(:visibility_level,
@@ -439,7 +439,7 @@ RSpec.describe API::Ci::JobArtifacts, feature_category: :build_artifacts do
context 'when public project guest and artifacts are non public' do
let(:api_user) { guest }
- let(:job) { create(:ci_build, :artifacts, :non_public_artifacts, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :artifacts, :with_private_artifacts_config, pipeline: pipeline) }
before do
project.update_column(:visibility_level,
@@ -644,7 +644,7 @@ RSpec.describe API::Ci::JobArtifacts, feature_category: :build_artifacts do
end
context 'when project is public with non public artifacts' do
- let(:job) { create(:ci_build, :artifacts, :non_public_artifacts, pipeline: pipeline, user: api_user) }
+ let(:job) { create(:ci_build, :artifacts, :with_private_artifacts_config, pipeline: pipeline, user: api_user) }
let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC }
let(:public_builds) { true }
diff --git a/spec/requests/api/ci/jobs_spec.rb b/spec/requests/api/ci/jobs_spec.rb
index 8b3ec59b785..ed0cec46a42 100644
--- a/spec/requests/api/ci/jobs_spec.rb
+++ b/spec/requests/api/ci/jobs_spec.rb
@@ -198,22 +198,22 @@ RSpec.describe API::Ci::Jobs, feature_category: :continuous_integration do
let_it_be(:agent_authorizations_without_env) do
[
- create(:agent_group_authorization, agent: create(:cluster_agent, project: other_project), group: group),
- create(:agent_project_authorization, agent: create(:cluster_agent, project: project), project: project),
- Clusters::Agents::ImplicitAuthorization.new(agent: create(:cluster_agent, project: project))
+ create(:agent_ci_access_group_authorization, agent: create(:cluster_agent, project: other_project), group: group),
+ create(:agent_ci_access_project_authorization, agent: create(:cluster_agent, project: project), project: project),
+ Clusters::Agents::Authorizations::CiAccess::ImplicitAuthorization.new(agent: create(:cluster_agent, project: project))
]
end
let_it_be(:agent_authorizations_with_review_and_production_env) do
[
create(
- :agent_group_authorization,
+ :agent_ci_access_group_authorization,
agent: create(:cluster_agent, project: other_project),
group: group,
environments: ['production', 'review/*']
),
create(
- :agent_project_authorization,
+ :agent_ci_access_project_authorization,
agent: create(:cluster_agent, project: project),
project: project,
environments: ['production', 'review/*']
@@ -224,13 +224,13 @@ RSpec.describe API::Ci::Jobs, feature_category: :continuous_integration do
let_it_be(:agent_authorizations_with_staging_env) do
[
create(
- :agent_group_authorization,
+ :agent_ci_access_group_authorization,
agent: create(:cluster_agent, project: other_project),
group: group,
environments: ['staging']
),
create(
- :agent_project_authorization,
+ :agent_ci_access_project_authorization,
agent: create(:cluster_agent, project: project),
project: project,
environments: ['staging']
@@ -546,40 +546,18 @@ RSpec.describe API::Ci::Jobs, feature_category: :continuous_integration do
describe 'GET /projects/:id/jobs rate limited' do
let(:query) { {} }
- context 'with the ci_enforce_rate_limits_jobs_api feature flag on' do
- before do
- stub_feature_flags(ci_enforce_rate_limits_jobs_api: true)
-
- allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
- threshold = Gitlab::ApplicationRateLimiter.rate_limits[:jobs_index][:threshold]
- allow(strategy).to receive(:increment).and_return(threshold + 1)
- end
-
- get api("/projects/#{project.id}/jobs", api_user), params: query
+ before do
+ allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
+ threshold = Gitlab::ApplicationRateLimiter.rate_limits[:jobs_index][:threshold]
+ allow(strategy).to receive(:increment).and_return(threshold + 1)
end
- it 'enforces rate limits for the endpoint' do
- expect(response).to have_gitlab_http_status :too_many_requests
- expect(json_response['message']['error']).to eq('This endpoint has been requested too many times. Try again later.')
- end
+ get api("/projects/#{project.id}/jobs", api_user), params: query
end
- context 'with the ci_enforce_rate_limits_jobs_api feature flag off' do
- before do
- stub_feature_flags(ci_enforce_rate_limits_jobs_api: false)
-
- allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
- threshold = Gitlab::ApplicationRateLimiter.rate_limits[:jobs_index][:threshold]
- allow(strategy).to receive(:increment).and_return(threshold + 1)
- end
-
- get api("/projects/#{project.id}/jobs", api_user), params: query
- end
-
- it 'makes a successful request' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_limited_pagination_headers
- end
+ it 'enforces rate limits for the endpoint' do
+ expect(response).to have_gitlab_http_status :too_many_requests
+ expect(json_response['message']['error']).to eq('This endpoint has been requested too many times. Try again later.')
end
end
diff --git a/spec/requests/api/ci/pipeline_schedules_spec.rb b/spec/requests/api/ci/pipeline_schedules_spec.rb
index 2a2c5f65aee..d760e4ddf28 100644
--- a/spec/requests/api/ci/pipeline_schedules_spec.rb
+++ b/spec/requests/api/ci/pipeline_schedules_spec.rb
@@ -473,12 +473,12 @@ RSpec.describe API::Ci::PipelineSchedules, feature_category: :continuous_integra
end
context 'as the existing owner of the schedule' do
- it 'rejects the request and leaves the schedule unchanged' do
+ it 'accepts the request and leaves the schedule unchanged' do
expect do
post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership", developer)
end.not_to change { pipeline_schedule.reload.owner }
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:success)
end
end
end
diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb
index 6d69da85449..869b0ec9dca 100644
--- a/spec/requests/api/ci/pipelines_spec.rb
+++ b/spec/requests/api/ci/pipelines_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let_it_be(:pipeline) do
create(:ci_empty_pipeline, project: project, sha: project.commit.id,
- ref: project.default_branch, user: user)
+ ref: project.default_branch, user: user, name: 'Build pipeline')
end
before do
@@ -25,7 +25,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
it_behaves_like 'pipelines visibility table'
context 'authorized user' do
- it 'returns project pipelines' do
+ it 'returns project pipelines', :aggregate_failures do
get api("/projects/#{project.id}/pipelines", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -41,8 +41,44 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
it 'includes pipeline source' do
get api("/projects/#{project.id}/pipelines", user)
- expect(json_response.first.keys).to contain_exactly(*%w[id iid project_id sha ref status web_url created_at updated_at source])
+ expect(json_response.first.keys).to contain_exactly(*%w[id iid project_id sha ref status web_url created_at updated_at source name])
end
+
+ context 'when pipeline_name_in_api feature flag is off' do
+ before do
+ stub_feature_flags(pipeline_name_in_api: false)
+ end
+
+ it 'does not include pipeline name in response and ignores name parameter' do
+ get api("/projects/#{project.id}/pipelines", user), params: { name: 'Chatops pipeline' }
+
+ expect(json_response.length).to eq(1)
+ expect(json_response.first.keys).not_to include('name')
+ end
+ end
+ end
+
+ it 'avoids N+1 queries' do
+ # Call to trigger any one time queries
+ get api("/projects/#{project.id}/pipelines", user), params: {}
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get api("/projects/#{project.id}/pipelines", user), params: {}
+ end
+
+ 3.times do
+ create(
+ :ci_empty_pipeline,
+ project: project,
+ sha: project.commit.id,
+ ref: project.default_branch,
+ user: user,
+ name: 'Build pipeline')
+ end
+
+ expect do
+ get api("/projects/#{project.id}/pipelines", user), params: {}
+ end.not_to exceed_all_query_limit(control)
end
context 'when parameter is passed' do
@@ -52,7 +88,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
create(:ci_pipeline, project: project, status: target)
end
- it 'returns matched pipelines' do
+ it 'returns matched pipelines', :aggregate_failures do
get api("/projects/#{project.id}/pipelines", user), params: { scope: target }
expect(response).to have_gitlab_http_status(:ok)
@@ -303,11 +339,24 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
end
end
+
+ context 'when name is provided' do
+ let_it_be(:pipeline2) { create(:ci_empty_pipeline, project: project, user: user, name: 'Chatops pipeline') }
+
+ it 'filters by name' do
+ get api("/projects/#{project.id}/pipelines", user), params: { name: 'Build pipeline' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['name']).to eq('Build pipeline')
+ end
+ end
end
end
context 'unauthorized user' do
- it 'does not return project pipelines' do
+ it 'does not return project pipelines', :aggregate_failures do
get api("/projects/#{project.id}/pipelines", non_member)
expect(response).to have_gitlab_http_status(:not_found)
@@ -335,13 +384,13 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'authorized user' do
- it 'returns pipeline jobs' do
+ it 'returns pipeline jobs', :aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
end
- it 'returns correct values' do
+ it 'returns correct values', :aggregate_failures do
expect(json_response).not_to be_empty
expect(json_response.first['commit']['id']).to eq project.commit.id
expect(Time.parse(json_response.first['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at)
@@ -354,7 +403,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" }
end
- it 'returns pipeline data' do
+ it 'returns pipeline data', :aggregate_failures do
json_job = json_response.first
expect(json_job['pipeline']).not_to be_empty
@@ -368,7 +417,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'filter jobs with one scope element' do
let(:query) { { 'scope' => 'pending' } }
- it do
+ it :aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
@@ -382,7 +431,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'when filtering to only running jobs' do
let(:query) { { 'scope' => 'running' } }
- it do
+ it :aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
@@ -402,7 +451,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'filter jobs with array of scope elements' do
let(:query) { { scope: %w(pending running) } }
- it do
+ it :aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
end
@@ -442,7 +491,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let_it_be(:successor) { create(:ci_build, :success, name: 'build', pipeline: pipeline) }
- it 'does not return retried jobs by default' do
+ it 'does not return retried jobs by default', :aggregate_failures do
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
end
@@ -450,7 +499,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'when include_retried is false' do
let(:query) { { include_retried: false } }
- it 'does not return retried jobs' do
+ it 'does not return retried jobs', :aggregate_failures do
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
end
@@ -459,7 +508,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'when include_retried is true' do
let(:query) { { include_retried: true } }
- it 'returns retried jobs' do
+ it 'returns retried jobs', :aggregate_failures do
expect(json_response).to be_an Array
expect(json_response.length).to eq(2)
expect(json_response[0]['name']).to eq(json_response[1]['name'])
@@ -469,7 +518,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'no pipeline is found' do
- it 'does not return jobs' do
+ it 'does not return jobs', :aggregate_failures do
get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/jobs", user)
expect(json_response['message']).to eq '404 Project Not Found'
@@ -481,7 +530,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'when user is not logged in' do
let(:api_user) { nil }
- it 'does not return jobs' do
+ it 'does not return jobs', :aggregate_failures do
expect(json_response['message']).to eq '404 Project Not Found'
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -523,13 +572,13 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'authorized user' do
- it 'returns pipeline bridges' do
+ it 'returns pipeline bridges', :aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
end
- it 'returns correct values' do
+ it 'returns correct values', :aggregate_failures do
expect(json_response).not_to be_empty
expect(json_response.first['commit']['id']).to eq project.commit.id
expect(json_response.first['id']).to eq bridge.id
@@ -537,7 +586,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
expect(json_response.first['stage']).to eq bridge.stage
end
- it 'returns pipeline data' do
+ it 'returns pipeline data', :aggregate_failures do
json_bridge = json_response.first
expect(json_bridge['pipeline']).not_to be_empty
@@ -548,7 +597,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status
end
- it 'returns downstream pipeline data' do
+ it 'returns downstream pipeline data', :aggregate_failures do
json_bridge = json_response.first
expect(json_bridge['downstream_pipeline']).not_to be_empty
@@ -568,7 +617,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'with one scope element' do
let(:query) { { 'scope' => 'pending' } }
- it :skip_before_request do
+ it :skip_before_request, :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
expect(response).to have_gitlab_http_status(:ok)
@@ -581,7 +630,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'with array of scope elements' do
let(:query) { { scope: %w(pending running) } }
- it :skip_before_request do
+ it :skip_before_request, :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
expect(response).to have_gitlab_http_status(:ok)
@@ -635,7 +684,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'no pipeline is found' do
- it 'does not return bridges' do
+ it 'does not return bridges', :aggregate_failures do
get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/bridges", user)
expect(json_response['message']).to eq '404 Project Not Found'
@@ -647,7 +696,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'when user is not logged in' do
let(:api_user) { nil }
- it 'does not return bridges' do
+ it 'does not return bridges', :aggregate_failures do
expect(json_response['message']).to eq '404 Project Not Found'
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -704,7 +753,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
stub_ci_pipeline_to_return_yaml_file
end
- it 'creates and returns a new pipeline' do
+ it 'creates and returns a new pipeline', :aggregate_failures do
expect do
post api("/projects/#{project.id}/pipeline", user), params: { ref: project.default_branch }
end.to change { project.ci_pipelines.count }.by(1)
@@ -717,7 +766,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'variables given' do
let(:variables) { [{ 'variable_type' => 'file', 'key' => 'UPLOAD_TO_S3', 'value' => 'true' }] }
- it 'creates and returns a new pipeline using the given variables' do
+ it 'creates and returns a new pipeline using the given variables', :aggregate_failures do
expect do
post api("/projects/#{project.id}/pipeline", user), params: { ref: project.default_branch, variables: variables }
end.to change { project.ci_pipelines.count }.by(1)
@@ -738,7 +787,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
stub_ci_pipeline_yaml_file(config)
end
- it 'creates and returns a new pipeline using the given variables' do
+ it 'creates and returns a new pipeline using the given variables', :aggregate_failures do
expect do
post api("/projects/#{project.id}/pipeline", user), params: { ref: project.default_branch, variables: variables }
end.to change { project.ci_pipelines.count }.by(1)
@@ -763,7 +812,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
end
- it 'fails when using an invalid ref' do
+ it 'fails when using an invalid ref', :aggregate_failures do
post api("/projects/#{project.id}/pipeline", user), params: { ref: 'invalid_ref' }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -778,7 +827,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
project.update!(auto_devops_attributes: { enabled: false })
end
- it 'fails to create pipeline' do
+ it 'fails to create pipeline', :aggregate_failures do
post api("/projects/#{project.id}/pipeline", user), params: { ref: project.default_branch }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -790,7 +839,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'unauthorized user' do
- it 'does not create pipeline' do
+ it 'does not create pipeline', :aggregate_failures do
post api("/projects/#{project.id}/pipeline", non_member), params: { ref: project.default_branch }
expect(response).to have_gitlab_http_status(:not_found)
@@ -811,21 +860,22 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'authorized user' do
- it 'exposes known attributes' do
+ it 'exposes known attributes', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}", user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/pipeline/detail')
end
- it 'returns project pipeline' do
+ it 'returns project pipeline', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}", user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['sha']).to match(/\A\h{40}\z/)
+ expect(json_response['name']).to eq('Build pipeline')
end
- it 'returns 404 when it does not exist' do
+ it 'returns 404 when it does not exist', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{non_existing_record_id}", user)
expect(response).to have_gitlab_http_status(:not_found)
@@ -844,10 +894,23 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
expect(json_response["coverage"]).to eq('30.00')
end
end
+
+ context 'with pipeline_name_in_api disabled' do
+ before do
+ stub_feature_flags(pipeline_name_in_api: false)
+ end
+
+ it 'does not return name', :aggregate_failures do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.keys).not_to include('name')
+ end
+ end
end
context 'unauthorized user' do
- it 'does not return a project pipeline' do
+ it 'does not return a project pipeline', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}", non_member)
expect(response).to have_gitlab_http_status(:not_found)
@@ -863,7 +926,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
create(:ci_pipeline, source: dangling_source, project: project)
end
- it 'returns the specified pipeline' do
+ it 'returns the specified pipeline', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{dangling_pipeline.id}", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -878,7 +941,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let!(:second_pipeline) do
create(:ci_empty_pipeline, project: project, sha: second_branch.target,
- ref: second_branch.name, user: user)
+ ref: second_branch.name, user: user, name: 'Build pipeline')
end
before do
@@ -887,18 +950,19 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'default repository branch' do
- it 'gets the latest pipleine' do
+ it 'gets the latest pipleine', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/latest", user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/pipeline/detail')
expect(json_response['ref']).to eq(project.default_branch)
expect(json_response['sha']).to eq(project.commit.id)
+ expect(json_response['name']).to eq('Build pipeline')
end
end
context 'ref parameter' do
- it 'gets the latest pipleine' do
+ it 'gets the latest pipleine', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/latest", user), params: { ref: second_branch.name }
expect(response).to have_gitlab_http_status(:ok)
@@ -907,10 +971,23 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
expect(json_response['sha']).to eq(second_branch.target)
end
end
+
+ context 'with pipeline_name_in_api disabled' do
+ before do
+ stub_feature_flags(pipeline_name_in_api: false)
+ end
+
+ it 'does not return name', :aggregate_failures do
+ get api("/projects/#{project.id}/pipelines/latest", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.keys).not_to include('name')
+ end
+ end
end
context 'unauthorized user' do
- it 'does not return a project pipeline' do
+ it 'does not return a project pipeline', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}", non_member)
expect(response).to have_gitlab_http_status(:not_found)
@@ -926,7 +1003,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let(:api_user) { user }
context 'user is a mantainer' do
- it 'returns pipeline variables empty' do
+ it 'returns pipeline variables empty', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -936,7 +1013,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'with variables' do
let!(:variable) { create(:ci_pipeline_variable, pipeline: pipeline, key: 'foo', value: 'bar') }
- it 'returns pipeline variables' do
+ it 'returns pipeline variables', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -962,7 +1039,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let(:api_user) { pipeline_owner_user }
let!(:variable) { create(:ci_pipeline_variable, pipeline: pipeline, key: 'foo', value: 'bar') }
- it 'returns pipeline variables' do
+ it 'returns pipeline variables', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -987,7 +1064,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'user is not a project member' do
- it 'does not return pipeline variables' do
+ it 'does not return pipeline variables', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}/variables", non_member)
expect(response).to have_gitlab_http_status(:not_found)
@@ -1000,14 +1077,14 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'authorized user' do
let(:owner) { project.first_owner }
- it 'destroys the pipeline' do
+ it 'destroys the pipeline', :aggregate_failures do
delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", owner)
expect(response).to have_gitlab_http_status(:no_content)
expect { pipeline.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
- it 'returns 404 when it does not exist' do
+ it 'returns 404 when it does not exist', :aggregate_failures do
delete api("/projects/#{project.id}/pipelines/#{non_existing_record_id}", owner)
expect(response).to have_gitlab_http_status(:not_found)
@@ -1021,7 +1098,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'when the pipeline has jobs' do
let_it_be(:build) { create(:ci_build, project: project, pipeline: pipeline) }
- it 'destroys associated jobs' do
+ it 'destroys associated jobs', :aggregate_failures do
delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", owner)
expect(response).to have_gitlab_http_status(:no_content)
@@ -1044,7 +1121,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'unauthorized user' do
context 'when user is not member' do
- it 'returns a 404' do
+ it 'returns a 404', :aggregate_failures do
delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", non_member)
expect(response).to have_gitlab_http_status(:not_found)
@@ -1059,7 +1136,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
project.add_developer(developer)
end
- it 'returns a 403' do
+ it 'returns a 403', :aggregate_failures do
delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", developer)
expect(response).to have_gitlab_http_status(:forbidden)
@@ -1078,7 +1155,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let_it_be(:build) { create(:ci_build, :failed, pipeline: pipeline) }
- it 'retries failed builds' do
+ it 'retries failed builds', :aggregate_failures do
expect do
post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", user)
end.to change { pipeline.builds.count }.from(1).to(2)
@@ -1089,7 +1166,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'unauthorized user' do
- it 'does not return a project pipeline' do
+ it 'does not return a project pipeline', :aggregate_failures do
post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", non_member)
expect(response).to have_gitlab_http_status(:not_found)
@@ -1106,7 +1183,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
end
- it 'returns error' do
+ it 'returns error', :aggregate_failures do
post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", user)
expect(response).to have_gitlab_http_status(:forbidden)
@@ -1124,7 +1201,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline) }
- context 'authorized user' do
+ context 'authorized user', :aggregate_failures do
it 'retries failed builds', :sidekiq_might_not_need_inline do
post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", user)
@@ -1140,7 +1217,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
project.add_reporter(reporter)
end
- it 'rejects the action' do
+ it 'rejects the action', :aggregate_failures do
post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", reporter)
expect(response).to have_gitlab_http_status(:forbidden)
@@ -1156,7 +1233,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let(:pipeline) { create(:ci_pipeline, project: project) }
context 'when pipeline does not have a test report' do
- it 'returns an empty test report' do
+ it 'returns an empty test report', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -1167,7 +1244,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'when pipeline has a test report' do
let(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project) }
- it 'returns the test report' do
+ it 'returns the test report', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -1180,7 +1257,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
create(:ci_build, :broken_test_reports, name: 'rspec', pipeline: pipeline)
end
- it 'returns a suite_error' do
+ it 'returns a suite_error', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -1190,7 +1267,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'unauthorized user' do
- it 'does not return project pipelines' do
+ it 'does not return project pipelines', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}/test_report", non_member)
expect(response).to have_gitlab_http_status(:not_found)
@@ -1208,7 +1285,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
let(:pipeline) { create(:ci_pipeline, project: project) }
context 'when pipeline does not have a test report summary' do
- it 'returns an empty test report summary' do
+ it 'returns an empty test report summary', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -1219,7 +1296,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
context 'when pipeline has a test report summary' do
let(:pipeline) { create(:ci_pipeline, :with_report_results, project: project) }
- it 'returns the test report summary' do
+ it 'returns the test report summary', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -1229,7 +1306,7 @@ RSpec.describe API::Ci::Pipelines, feature_category: :continuous_integration do
end
context 'unauthorized user' do
- it 'does not return project pipelines' do
+ it 'does not return project pipelines', :aggregate_failures do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}/test_report_summary", non_member)
expect(response).to have_gitlab_http_status(:not_found)
diff --git a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
index 3d3d699542b..596af1110cc 100644
--- a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
@@ -174,8 +174,21 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
expect(json_response['RemoteObject']).to have_key('StoreURL')
expect(json_response['RemoteObject']).to have_key('DeleteURL')
expect(json_response['RemoteObject']).to have_key('MultipartUpload')
+ expect(json_response['RemoteObject']['SkipDelete']).to eq(true)
expect(json_response['MaximumSize']).not_to be_nil
end
+
+ context 'when ci_artifacts_upload_to_final_location flag is disabled' do
+ before do
+ stub_feature_flags(ci_artifacts_upload_to_final_location: false)
+ end
+
+ it 'does not skip delete' do
+ subject
+
+ expect(json_response['RemoteObject']['SkipDelete']).to eq(false)
+ end
+ end
end
context 'when direct upload is disabled' do
@@ -255,8 +268,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
it 'tracks code_intelligence usage ping' do
tracking_params = {
event_names: 'i_source_code_code_intelligence',
- start_date: Date.yesterday,
- end_date: Date.today
+ start_date: Date.today.beginning_of_week,
+ end_date: 1.week.from_now
}
expect { authorize_artifacts_with_token_in_headers(artifact_type: :lsif) }
@@ -374,29 +387,53 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
let(:object) do
fog_connection.directories.new(key: 'artifacts').files.create( # rubocop:disable Rails/SaveBang
- key: 'tmp/uploads/12312300',
+ key: remote_path,
body: 'content'
)
end
let(:file_upload) { fog_to_uploaded_file(object) }
- before do
- upload_artifacts(file_upload, headers_with_token, 'file.remote_id' => remote_id)
- end
+ context 'when uploaded file has matching pending remote upload to its final location' do
+ let(:remote_path) { '12345/foo-bar-123' }
+ let(:object_remote_id) { remote_path }
+ let(:remote_id) { remote_path }
+
+ before do
+ allow(JobArtifactUploader).to receive(:generate_final_store_path).and_return(remote_path)
- context 'when valid remote_id is used' do
- let(:remote_id) { '12312300' }
+ ObjectStorage::PendingDirectUpload.prepare(
+ JobArtifactUploader.storage_location_identifier,
+ remote_path
+ )
+
+ upload_artifacts(file_upload, headers_with_token, 'file.remote_id' => remote_path)
+ end
it_behaves_like 'successful artifacts upload'
end
- context 'when invalid remote_id is used' do
- let(:remote_id) { 'invalid id' }
+ context 'when uploaded file is uploaded to temporary location' do
+ let(:object_remote_id) { JobArtifactUploader.generate_remote_id }
+ let(:remote_path) { File.join(ObjectStorage::TMP_UPLOAD_PATH, object_remote_id) }
+
+ before do
+ upload_artifacts(file_upload, headers_with_token, 'file.remote_id' => remote_id)
+ end
+
+ context 'and matching temporary remote_id is used' do
+ let(:remote_id) { object_remote_id }
+
+ it_behaves_like 'successful artifacts upload'
+ end
+
+ context 'and invalid remote_id is used' do
+ let(:remote_id) { JobArtifactUploader.generate_remote_id }
- it 'responds with bad request' do
- expect(response).to have_gitlab_http_status(:internal_server_error)
- expect(json_response['message']).to eq("Missing file")
+ it 'responds with internal server error' do
+ expect(response).to have_gitlab_http_status(:internal_server_error)
+ expect(json_response['message']).to eq("Missing file")
+ end
end
end
end
diff --git a/spec/requests/api/ci/runner/jobs_put_spec.rb b/spec/requests/api/ci/runner/jobs_put_spec.rb
index ef3b38e3fc4..ab7ab4e74f8 100644
--- a/spec/requests/api/ci/runner/jobs_put_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_put_spec.rb
@@ -21,13 +21,13 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
let_it_be(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
let_it_be(:runner) { create(:ci_runner, :project, projects: [project]) }
- let_it_be(:runner_machine) { create(:ci_runner_machine, runner: runner) }
+ let_it_be(:runner_manager) { create(:ci_runner_machine, runner: runner) }
let_it_be(:user) { create(:user) }
describe 'PUT /api/v4/jobs/:id' do
let_it_be_with_reload(:job) do
create(:ci_build, :pending, :trace_live, pipeline: pipeline, project: project, user: user,
- runner_id: runner.id, runner_machine: runner_machine)
+ runner_id: runner.id, runner_manager: runner_manager)
end
before do
@@ -40,7 +40,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
it 'updates runner info' do
expect { update_job(state: 'success') }.to change { runner.reload.contacted_at }
- .and change { runner_machine.reload.contacted_at }
+ .and change { runner_manager.reload.contacted_at }
end
context 'when status is given' do
diff --git a/spec/requests/api/ci/runner/jobs_request_post_spec.rb b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
index 6e721d40560..0164eda7680 100644
--- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
@@ -122,56 +122,33 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
context 'when system_id parameter is specified' do
subject(:request) { request_job(**args) }
- context 'with create_runner_machine FF enabled' do
- before do
- stub_feature_flags(create_runner_machine: true)
- end
-
- context 'when ci_runner_machines with same system_xid does not exist' do
- let(:args) { { system_id: 's_some_system_id' } }
-
- it 'creates respective ci_runner_machines record', :freeze_time do
- expect { request }.to change { runner.runner_machines.reload.count }.from(0).to(1)
-
- machine = runner.runner_machines.last
- expect(machine.system_xid).to eq args[:system_id]
- expect(machine.runner).to eq runner
- expect(machine.contacted_at).to eq Time.current
- end
- end
-
- context 'when ci_runner_machines with same system_xid already exists', :freeze_time do
- let(:args) { { system_id: 's_existing_system_id' } }
- let!(:runner_machine) do
- create(:ci_runner_machine, runner: runner, system_xid: args[:system_id], contacted_at: 1.hour.ago)
- end
-
- it 'does not create new ci_runner_machines record' do
- expect { request }.not_to change { Ci::RunnerMachine.count }
- end
+ context 'when ci_runner_machines with same system_xid does not exist' do
+ let(:args) { { system_id: 's_some_system_id' } }
- it 'updates the contacted_at field' do
- request
+ it 'creates respective ci_runner_machines record', :freeze_time do
+ expect { request }.to change { runner.runner_managers.reload.count }.from(0).to(1)
- expect(runner_machine.reload.contacted_at).to eq Time.current
- end
+ runner_manager = runner.runner_managers.last
+ expect(runner_manager.system_xid).to eq args[:system_id]
+ expect(runner_manager.runner).to eq runner
+ expect(runner_manager.contacted_at).to eq Time.current
end
end
- context 'with create_runner_machine FF disabled' do
- before do
- stub_feature_flags(create_runner_machine: false)
+ context 'when ci_runner_machines with same system_xid already exists', :freeze_time do
+ let(:args) { { system_id: 's_existing_system_id' } }
+ let!(:runner_manager) do
+ create(:ci_runner_machine, runner: runner, system_xid: args[:system_id], contacted_at: 1.hour.ago)
end
- context 'when ci_runner_machines with same system_xid does not exist' do
- let(:args) { { system_id: 's_some_system_id' } }
+ it 'does not create new ci_runner_machines record' do
+ expect { request }.not_to change { Ci::RunnerManager.count }
+ end
- it 'does not create respective ci_runner_machines record', :freeze_time, :aggregate_failures do
- expect { request }.not_to change { runner.runner_machines.reload.count }
+ it 'updates the contacted_at field' do
+ request
- expect(response).to have_gitlab_http_status(:created)
- expect(runner.runner_machines).to be_empty
- end
+ expect(runner_manager.reload.contacted_at).to eq Time.current
end
end
end
@@ -253,11 +230,14 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
end
let(:expected_cache) do
- [{ 'key' => a_string_matching(/^cache_key-(?>protected|non_protected)$/),
- 'untracked' => false,
- 'paths' => ['vendor/*'],
- 'policy' => 'pull-push',
- 'when' => 'on_success' }]
+ [{
+ 'key' => a_string_matching(/^cache_key-(?>protected|non_protected)$/),
+ 'untracked' => false,
+ 'paths' => ['vendor/*'],
+ 'policy' => 'pull-push',
+ 'when' => 'on_success',
+ 'fallback_keys' => []
+ }]
end
let(:expected_features) do
@@ -366,36 +346,6 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
end
end
- context 'when job filtered by job_age' do
- let!(:job) do
- create(:ci_build, :pending, :queued, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, queued_at: 60.seconds.ago)
- end
-
- before do
- job.queuing_entry&.update!(created_at: 60.seconds.ago)
- end
-
- context 'job is queued less than job_age parameter' do
- let(:job_age) { 120 }
-
- it 'gives 204' do
- request_job(job_age: job_age)
-
- expect(response).to have_gitlab_http_status(:no_content)
- end
- end
-
- context 'job is queued more than job_age parameter' do
- let(:job_age) { 30 }
-
- it 'picks a job' do
- request_job(job_age: job_age)
-
- expect(response).to have_gitlab_http_status(:created)
- end
- end
- end
-
context 'when job is made for branch' do
it 'sets tag as ref_type' do
request_job
@@ -831,19 +781,6 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
end
end
end
-
- context 'when the FF ci_hooks_pre_get_sources_script is disabled' do
- before do
- stub_feature_flags(ci_hooks_pre_get_sources_script: false)
- end
-
- it 'does not return the pre_get_sources_script' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response).not_to have_key('hooks')
- end
- end
end
describe 'port support' do
diff --git a/spec/requests/api/ci/runner/runners_delete_spec.rb b/spec/requests/api/ci/runner/runners_delete_spec.rb
index 65c287a9535..681dd4d701e 100644
--- a/spec/requests/api/ci/runner/runners_delete_spec.rb
+++ b/spec/requests/api/ci/runner/runners_delete_spec.rb
@@ -7,16 +7,19 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
include RedisHelpers
include WorkhorseHelpers
- let(:registration_token) { 'abcdefg123456' }
-
before do
stub_feature_flags(ci_enable_live_trace: true)
stub_gitlab_calls
- stub_application_setting(runners_registration_token: registration_token)
- allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes)
+ allow_next_instance_of(::Ci::Runner) { |runner| allow(runner).to receive(:cache_attributes) }
end
describe '/api/v4/runners' do
+ let(:registration_token) { 'abcdefg123456' }
+
+ before do
+ stub_application_setting(runners_registration_token: registration_token)
+ end
+
describe 'DELETE /api/v4/runners' do
context 'when no token is provided' do
it 'returns 400 error' do
@@ -57,4 +60,85 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
end
end
end
+
+ describe '/api/v4/runners/managers' do
+ describe 'DELETE /api/v4/runners/managers' do
+ subject(:delete_request) { delete api('/runners/managers'), params: delete_params }
+
+ context 'with created runner' do
+ let!(:runner) { create(:ci_runner, :with_runner_manager, registration_type: :authenticated_user) }
+
+ context 'with matching system_id' do
+ context 'when no token is provided' do
+ let(:delete_params) { { system_id: runner.runner_managers.first.system_xid } }
+
+ it 'returns 400 error' do
+ delete_request
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'when invalid token is provided' do
+ let(:delete_params) { { token: 'invalid', system_id: runner.runner_managers.first.system_xid } }
+
+ it 'returns 403 error' do
+ delete_request
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ context 'when valid token is provided' do
+ context 'with created runner' do
+ let!(:runner) { create(:ci_runner, :with_runner_manager, registration_type: :authenticated_user) }
+
+ context 'with matching system_id' do
+ let(:delete_params) { { token: runner.token, system_id: runner.runner_managers.first.system_xid } }
+
+ it 'deletes runner manager' do
+ expect do
+ delete_request
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end.to change { runner.runner_managers.count }.from(1).to(0)
+
+ expect(::Ci::Runner.count).to eq(1)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) { api('/runners/managers') }
+ let(:params) { delete_params }
+ end
+
+ it_behaves_like 'storing arguments in the application context for the API' do
+ let(:expected_params) { { client_id: "runner/#{runner.id}" } }
+ end
+ end
+
+ context 'with unknown system_id' do
+ let(:delete_params) { { token: runner.token, system_id: 'unknown_system_id' } }
+
+ it 'returns 404 error' do
+ delete_request
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'without system_id' do
+ let(:delete_params) { { token: runner.token } }
+
+ it 'does not delete runner manager nor runner' do
+ delete_request
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+ end
+ end
+ end
end
diff --git a/spec/requests/api/ci/runner/runners_post_spec.rb b/spec/requests/api/ci/runner/runners_post_spec.rb
index 73f8e87a9fb..a36ea2115cf 100644
--- a/spec/requests/api/ci/runner/runners_post_spec.rb
+++ b/spec/requests/api/ci/runner/runners_post_spec.rb
@@ -15,14 +15,10 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
context 'when invalid token is provided' do
it 'returns 403 error' do
- allow_next_instance_of(::Ci::Runners::RegisterRunnerService) do |service|
- allow(service).to receive(:execute)
- .and_return(ServiceResponse.error(message: 'invalid token supplied', http_status: :forbidden))
- end
-
post api('/runners'), params: { token: 'invalid' }
expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('403 Forbidden - invalid token supplied')
end
end
@@ -44,21 +40,24 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
let_it_be(:new_runner) { create(:ci_runner) }
before do
- allow_next_instance_of(::Ci::Runners::RegisterRunnerService) do |service|
- expected_params = {
- description: 'server.hostname',
- maintenance_note: 'Some maintainer notes',
- run_untagged: false,
- tag_list: %w(tag1 tag2),
- locked: true,
- active: true,
- access_level: 'ref_protected',
- maximum_timeout: 9000
- }.stringify_keys
+ expected_params = {
+ description: 'server.hostname',
+ maintenance_note: 'Some maintainer notes',
+ run_untagged: false,
+ tag_list: %w(tag1 tag2),
+ locked: true,
+ active: true,
+ access_level: 'ref_protected',
+ maximum_timeout: 9000
+ }.stringify_keys
+ allow_next_instance_of(
+ ::Ci::Runners::RegisterRunnerService,
+ 'valid token',
+ a_hash_including(expected_params)
+ ) do |service|
expect(service).to receive(:execute)
.once
- .with('valid token', a_hash_including(expected_params))
.and_return(ServiceResponse.success(payload: { runner: new_runner }))
end
end
@@ -109,11 +108,14 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
let(:new_runner) { create(:ci_runner) }
it 'converts to maintenance_note param' do
- allow_next_instance_of(::Ci::Runners::RegisterRunnerService) do |service|
+ allow_next_instance_of(
+ ::Ci::Runners::RegisterRunnerService,
+ 'valid token',
+ a_hash_including('maintenance_note' => 'Some maintainer notes')
+ .and(excluding('maintainter_note' => anything))
+ ) do |service|
expect(service).to receive(:execute)
.once
- .with('valid token', a_hash_including('maintenance_note' => 'Some maintainer notes')
- .and(excluding('maintainter_note' => anything)))
.and_return(ServiceResponse.success(payload: { runner: new_runner }))
end
@@ -134,12 +136,13 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
let_it_be(:new_runner) { build(:ci_runner) }
it 'uses active value in registration' do
- expect_next_instance_of(::Ci::Runners::RegisterRunnerService) do |service|
- expected_params = { active: false }.stringify_keys
-
+ expect_next_instance_of(
+ ::Ci::Runners::RegisterRunnerService,
+ 'valid token',
+ a_hash_including({ active: false }.stringify_keys)
+ ) do |service|
expect(service).to receive(:execute)
.once
- .with('valid token', a_hash_including(expected_params))
.and_return(ServiceResponse.success(payload: { runner: new_runner }))
end
@@ -197,12 +200,13 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
let(:tag_list) { (1..::Ci::Runner::TAG_LIST_MAX_LENGTH + 1).map { |i| "tag#{i}" } }
it 'uses tag_list value in registration and returns error' do
- expect_next_instance_of(::Ci::Runners::RegisterRunnerService) do |service|
- expected_params = { tag_list: tag_list }.stringify_keys
-
+ expect_next_instance_of(
+ ::Ci::Runners::RegisterRunnerService,
+ registration_token,
+ a_hash_including({ tag_list: tag_list }.stringify_keys)
+ ) do |service|
expect(service).to receive(:execute)
.once
- .with(registration_token, a_hash_including(expected_params))
.and_call_original
end
@@ -217,12 +221,13 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
let(:tag_list) { (1..20).map { |i| "tag#{i}" } }
it 'uses tag_list value in registration and successfully creates runner' do
- expect_next_instance_of(::Ci::Runners::RegisterRunnerService) do |service|
- expected_params = { tag_list: tag_list }.stringify_keys
-
+ expect_next_instance_of(
+ ::Ci::Runners::RegisterRunnerService,
+ registration_token,
+ a_hash_including({ tag_list: tag_list }.stringify_keys)
+ ) do |service|
expect(service).to receive(:execute)
.once
- .with(registration_token, a_hash_including(expected_params))
.and_call_original
end
@@ -232,6 +237,18 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
end
end
end
+
+ context 'when runner registration is disallowed' do
+ before do
+ stub_application_setting(allow_runner_registration_token: false)
+ end
+
+ it 'returns 410 Gone status' do
+ post api('/runners'), params: { token: registration_token }
+
+ expect(response).to have_gitlab_http_status(:gone)
+ end
+ end
end
end
end
diff --git a/spec/requests/api/ci/runner/runners_verify_post_spec.rb b/spec/requests/api/ci/runner/runners_verify_post_spec.rb
index a6a1ad947aa..f1b33826f5e 100644
--- a/spec/requests/api/ci/runner/runners_verify_post_spec.rb
+++ b/spec/requests/api/ci/runner/runners_verify_post_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
end
describe '/api/v4/runners' do
- describe 'POST /api/v4/runners/verify' do
+ describe 'POST /api/v4/runners/verify', :freeze_time do
let_it_be_with_reload(:runner) { create(:ci_runner, token_expires_at: 3.days.from_now) }
let(:params) {}
@@ -45,9 +45,12 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
context 'when valid token is provided' do
let(:params) { { token: runner.token } }
- context 'with create_runner_machine FF enabled' do
- before do
- stub_feature_flags(create_runner_machine: true)
+ context 'with glrt-prefixed token' do
+ let_it_be(:registration_token) { 'glrt-abcdefg123456' }
+ let_it_be(:registration_type) { :authenticated_user }
+ let_it_be(:runner) do
+ create(:ci_runner, registration_type: registration_type,
+ token: registration_token, token_expires_at: 3.days.from_now)
end
it 'verifies Runner credentials' do
@@ -61,39 +64,29 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
})
end
- context 'with non-expiring runner token' do
- before do
- runner.update!(token_expires_at: nil)
- end
-
- it 'verifies Runner credentials' do
- verify
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to eq({
- 'id' => runner.id,
- 'token' => runner.token,
- 'token_expires_at' => nil
- })
- end
+ it 'does not update contacted_at' do
+ expect { verify }.not_to change { runner.reload.contacted_at }.from(nil)
end
+ end
- it_behaves_like 'storing arguments in the application context for the API' do
- let(:expected_params) { { client_id: "runner/#{runner.id}" } }
- end
+ it 'verifies Runner credentials' do
+ verify
- context 'when system_id is provided' do
- let(:params) { { token: runner.token, system_id: 's_some_system_id' } }
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq({
+ 'id' => runner.id,
+ 'token' => runner.token,
+ 'token_expires_at' => runner.token_expires_at.iso8601(3)
+ })
+ end
- it 'creates a runner_machine' do
- expect { verify }.to change { Ci::RunnerMachine.count }.by(1)
- end
- end
+ it 'updates contacted_at' do
+ expect { verify }.to change { runner.reload.contacted_at }.from(nil).to(Time.current)
end
- context 'with create_runner_machine FF disabled' do
+ context 'with non-expiring runner token' do
before do
- stub_feature_flags(create_runner_machine: false)
+ runner.update!(token_expires_at: nil)
end
it 'verifies Runner credentials' do
@@ -103,18 +96,20 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state, feature_catego
expect(json_response).to eq({
'id' => runner.id,
'token' => runner.token,
- 'token_expires_at' => runner.token_expires_at.iso8601(3)
+ 'token_expires_at' => nil
})
end
+ end
- context 'when system_id is provided' do
- let(:params) { { token: runner.token, system_id: 's_some_system_id' } }
+ it_behaves_like 'storing arguments in the application context for the API' do
+ let(:expected_params) { { client_id: "runner/#{runner.id}" } }
+ end
- it 'does not create a runner_machine', :aggregate_failures do
- expect { verify }.not_to change { Ci::RunnerMachine.count }
+ context 'when system_id is provided' do
+ let(:params) { { token: runner.token, system_id: 's_some_system_id' } }
- expect(response).to have_gitlab_http_status(:ok)
- end
+ it 'creates a runner_manager' do
+ expect { verify }.to change { Ci::RunnerManager.count }.by(1)
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
index 1110dbf5fbc..98edde93e95 100644
--- a/spec/requests/api/ci/runners_reset_registration_token_spec.rb
+++ b/spec/requests/api/ci/runners_reset_registration_token_spec.rb
@@ -3,10 +3,12 @@
require 'spec_helper'
RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
- subject { post api("#{prefix}/runners/reset_registration_token", user) }
+ let_it_be(:admin_mode) { false }
+
+ subject { post api("#{prefix}/runners/reset_registration_token", user, admin_mode: admin_mode) }
shared_examples 'bad request' do |result|
- it 'returns 400 error' do
+ it 'returns 400 error', :aggregate_failures do
expect { subject }.not_to change { get_token }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -15,7 +17,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
shared_examples 'unauthenticated' do
- it 'returns 401 error' do
+ it 'returns 401 error', :aggregate_failures do
expect { subject }.not_to change { get_token }
expect(response).to have_gitlab_http_status(:unauthorized)
@@ -23,7 +25,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
shared_examples 'unauthorized' do
- it 'returns 403 error' do
+ it 'returns 403 error', :aggregate_failures do
expect { subject }.not_to change { get_token }
expect(response).to have_gitlab_http_status(:forbidden)
@@ -31,7 +33,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
shared_examples 'not found' do |scope|
- it 'returns 404 error' do
+ it 'returns 404 error', :aggregate_failures do
expect { subject }.not_to change { get_token }
expect(response).to have_gitlab_http_status(:not_found)
@@ -58,7 +60,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
shared_context 'when authorized' do |scope|
- it 'resets runner registration token' do
+ it 'resets runner registration token', :aggregate_failures do
expect { subject }.to change { get_token }
expect(response).to have_gitlab_http_status(:success)
@@ -99,6 +101,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
include_context 'when authorized', 'instance' do
let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:admin_mode) { true }
def get_token
ApplicationSetting.current_without_cache.runners_registration_token
diff --git a/spec/requests/api/ci/runners_spec.rb b/spec/requests/api/ci/runners_spec.rb
index ca051386265..2b2d2e0def8 100644
--- a/spec/requests/api/ci/runners_spec.rb
+++ b/spec/requests/api/ci/runners_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
+RSpec.describe API::Ci::Runners, :aggregate_failures, feature_category: :runner_fleet do
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:user) { create(:user) }
let_it_be(:user2) { create(:user) }
@@ -134,17 +134,21 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
describe 'GET /runners/all' do
+ let(:path) { '/runners/all' }
+
+ it_behaves_like 'GET request permissions for admin mode'
+
context 'authorized user' do
context 'with admin privileges' do
it 'returns response status and headers' do
- get api('/runners/all', admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
end
it 'returns all runners' do
- get api('/runners/all', admin)
+ get api(path, admin, admin_mode: true)
expect(json_response).to match_array [
a_hash_including('description' => 'Project runner', 'is_shared' => false, 'active' => true, 'paused' => false, 'runner_type' => 'project_type'),
@@ -156,7 +160,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'filters runners by scope' do
- get api('/runners/all?scope=shared', admin)
+ get api('/runners/all?scope=shared', admin, admin_mode: true)
shared = json_response.all? { |r| r['is_shared'] }
expect(response).to have_gitlab_http_status(:ok)
@@ -167,7 +171,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'filters runners by scope' do
- get api('/runners/all?scope=specific', admin)
+ get api('/runners/all?scope=specific', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -181,12 +185,12 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'avoids filtering if scope is invalid' do
- get api('/runners/all?scope=unknown', admin)
+ get api('/runners/all?scope=unknown', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'filters runners by project type' do
- get api('/runners/all?type=project_type', admin)
+ get api('/runners/all?type=project_type', admin, admin_mode: true)
expect(json_response).to match_array [
a_hash_including('description' => 'Project runner'),
@@ -195,7 +199,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'filters runners by group type' do
- get api('/runners/all?type=group_type', admin)
+ get api('/runners/all?type=group_type', admin, admin_mode: true)
expect(json_response).to match_array [
a_hash_including('description' => 'Group runner A'),
@@ -204,7 +208,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'does not filter by invalid type' do
- get api('/runners/all?type=bogus', admin)
+ get api('/runners/all?type=bogus', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -213,7 +217,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
let_it_be(:runner) { create(:ci_runner, :project, :inactive, description: 'Inactive project runner', projects: [project]) }
it 'filters runners by status' do
- get api('/runners/all?paused=true', admin)
+ get api('/runners/all?paused=true', admin, admin_mode: true)
expect(json_response).to match_array [
a_hash_including('description' => 'Inactive project runner')
@@ -221,7 +225,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'filters runners by status' do
- get api('/runners/all?status=paused', admin)
+ get api('/runners/all?status=paused', admin, admin_mode: true)
expect(json_response).to match_array [
a_hash_including('description' => 'Inactive project runner')
@@ -230,7 +234,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'does not filter by invalid status' do
- get api('/runners/all?status=bogus', admin)
+ get api('/runners/all?status=bogus', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -239,7 +243,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
create(:ci_runner, :project, description: 'Runner tagged with tag1 and tag2', projects: [project], tag_list: %w[tag1 tag2])
create(:ci_runner, :project, description: 'Runner tagged with tag2', projects: [project], tag_list: ['tag2'])
- get api('/runners/all?tag_list=tag1,tag2', admin)
+ get api('/runners/all?tag_list=tag1,tag2', admin, admin_mode: true)
expect(json_response).to match_array [
a_hash_including('description' => 'Runner tagged with tag1 and tag2')
@@ -249,7 +253,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'without admin privileges' do
it 'does not return runners list' do
- get api('/runners/all', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -266,6 +270,10 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
describe 'GET /runners/:id' do
+ let(:path) { "/runners/#{project_runner.id}" }
+
+ it_behaves_like 'GET request permissions for admin mode'
+
context 'admin user' do
context 'when runner is shared' do
it "returns runner's details" do
@@ -286,7 +294,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
it 'deletes unused runner' do
expect do
- delete api("/runners/#{unused_project_runner.id}", admin)
+ delete api("/runners/#{unused_project_runner.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { ::Ci::Runner.project_type.count }.by(-1)
@@ -294,21 +302,21 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it "returns runner's details" do
- get api("/runners/#{project_runner.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['description']).to eq(project_runner.description)
end
it "returns the project's details for a project runner" do
- get api("/runners/#{project_runner.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(json_response['projects'].first['id']).to eq(project.id)
end
end
it 'returns 404 if runner does not exist' do
- get api('/runners/0', admin)
+ get api("/runners/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -316,7 +324,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when the runner is a group runner' do
it "returns the runner's details" do
- get api("/runners/#{group_runner_a.id}", admin)
+ get api("/runners/#{group_runner_a.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['description']).to eq(group_runner_a.description)
@@ -327,7 +335,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context "runner project's administrative user" do
context 'when runner is not shared' do
it "returns runner's details" do
- get api("/runners/#{project_runner.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['description']).to eq(project_runner.description)
@@ -346,7 +354,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'other authorized user' do
it "does not return project runner's details" do
- get api("/runners/#{project_runner.id}", user2)
+ get api(path, user2)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -354,7 +362,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'unauthorized user' do
it "does not return project runner's details" do
- get api("/runners/#{project_runner.id}")
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -362,6 +370,12 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
describe 'PUT /runners/:id' do
+ let(:path) { "/runners/#{project_runner.id}" }
+
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:params) { { description: 'test' } }
+ end
+
context 'admin user' do
# see https://gitlab.com/gitlab-org/gitlab-foss/issues/48625
context 'single parameter update' do
@@ -492,20 +506,22 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'returns 404 if runner does not exist' do
- update_runner(0, admin, description: 'test')
+ update_runner(non_existing_record_id, admin, description: 'test')
expect(response).to have_gitlab_http_status(:not_found)
end
def update_runner(id, user, args)
- put api("/runners/#{id}", user), params: args
+ put api("/runners/#{id}", user, admin_mode: true), params: args
end
end
context 'authorized user' do
+ let_it_be(:params) { { description: 'test' } }
+
context 'when runner is shared' do
it 'does not update runner' do
- put api("/runners/#{shared_runner.id}", user), params: { description: 'test' }
+ put api("/runners/#{shared_runner.id}", user), params: params
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -513,17 +529,16 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when runner is not shared' do
it 'does not update project runner without access to it' do
- put api("/runners/#{project_runner.id}", user2), params: { description: 'test' }
+ put api(path, user2), params: { description: 'test' }
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'updates project runner with access to it' do
description = project_runner.description
- put api("/runners/#{project_runner.id}", admin), params: { description: 'test' }
+ put api(path, admin, admin_mode: true), params: params
project_runner.reload
- expect(response).to have_gitlab_http_status(:ok)
expect(project_runner.description).to eq('test')
expect(project_runner.description).not_to eq(description)
end
@@ -532,7 +547,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'unauthorized user' do
it 'does not delete project runner' do
- put api("/runners/#{project_runner.id}")
+ put api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -540,6 +555,10 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
describe 'DELETE /runners/:id' do
+ let(:path) { "/runners/#{shared_runner.id}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
+
context 'admin user' do
context 'when runner is shared' do
it 'deletes runner' do
@@ -548,14 +567,14 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
expect do
- delete api("/runners/#{shared_runner.id}", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { ::Ci::Runner.instance_type.count }.by(-1)
end
it_behaves_like '412 response' do
- let(:request) { api("/runners/#{shared_runner.id}", admin) }
+ let(:request) { api(path, admin, admin_mode: true) }
end
end
@@ -566,7 +585,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
expect do
- delete api("/runners/#{project_runner.id}", admin)
+ delete api("/runners/#{project_runner.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { ::Ci::Runner.project_type.count }.by(-1)
@@ -578,7 +597,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
expect(service).not_to receive(:execute)
end
- delete api('/runners/0', admin)
+ delete api("/runners/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -587,7 +606,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'authorized user' do
context 'when runner is shared' do
it 'does not delete runner' do
- delete api("/runners/#{shared_runner.id}", user)
+ delete api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
end
@@ -671,10 +690,16 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
describe 'POST /runners/:id/reset_authentication_token' do
+ let(:path) { "/runners/#{shared_runner.id}/reset_authentication_token" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { {} }
+ end
+
context 'admin user' do
it 'resets shared runner authentication token' do
expect do
- post api("/runners/#{shared_runner.id}/reset_authentication_token", admin)
+ post api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:success)
expect(json_response).to eq({ 'token' => shared_runner.reload.token, 'token_expires_at' => nil })
@@ -682,7 +707,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'returns 404 if runner does not exist' do
- post api('/runners/0/reset_authentication_token', admin)
+ post api("/runners/#{non_existing_record_id}/reset_authentication_token", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -765,7 +790,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'unauthorized user' do
it 'does not reset authentication token' do
expect do
- post api("/runners/#{shared_runner.id}/reset_authentication_token")
+ post api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end.not_to change { shared_runner.reload.token }
@@ -779,12 +804,15 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
let_it_be(:job_3) { create(:ci_build, :failed, runner: shared_runner, project: project) }
let_it_be(:job_4) { create(:ci_build, :running, runner: project_runner, project: project) }
let_it_be(:job_5) { create(:ci_build, :failed, runner: project_runner, project: project) }
+ let(:path) { "/runners/#{project_runner.id}/jobs" }
+
+ it_behaves_like 'GET request permissions for admin mode'
context 'admin user' do
context 'when runner exists' do
context 'when runner is shared' do
it 'return jobs' do
- get api("/runners/#{shared_runner.id}/jobs", admin)
+ get api("/runners/#{shared_runner.id}/jobs", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -796,7 +824,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when runner is a project runner' do
it 'return jobs' do
- get api("/runners/#{project_runner.id}/jobs", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -825,7 +853,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when valid status is provided' do
it 'return filtered jobs' do
- get api("/runners/#{project_runner.id}/jobs?status=failed", admin)
+ get api("/runners/#{project_runner.id}/jobs?status=failed", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -839,7 +867,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when valid order_by is provided' do
context 'when sort order is not specified' do
it 'return jobs in descending order' do
- get api("/runners/#{project_runner.id}/jobs?order_by=id", admin)
+ get api("/runners/#{project_runner.id}/jobs?order_by=id", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -852,7 +880,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when sort order is specified as asc' do
it 'return jobs sorted in ascending order' do
- get api("/runners/#{project_runner.id}/jobs?order_by=id&sort=asc", admin)
+ get api("/runners/#{project_runner.id}/jobs?order_by=id&sort=asc", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -866,7 +894,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when invalid status is provided' do
it 'return 400' do
- get api("/runners/#{project_runner.id}/jobs?status=non-existing", admin)
+ get api("/runners/#{project_runner.id}/jobs?status=non-existing", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -874,7 +902,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when invalid order_by is provided' do
it 'return 400' do
- get api("/runners/#{project_runner.id}/jobs?order_by=non-existing", admin)
+ get api("/runners/#{project_runner.id}/jobs?order_by=non-existing", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -882,7 +910,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when invalid sort is provided' do
it 'return 400' do
- get api("/runners/#{project_runner.id}/jobs?sort=non-existing", admin)
+ get api("/runners/#{project_runner.id}/jobs?sort=non-existing", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -890,16 +918,16 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'avoids N+1 DB queries' do
- get api("/runners/#{shared_runner.id}/jobs", admin)
+ get api("/runners/#{shared_runner.id}/jobs", admin, admin_mode: true)
control = ActiveRecord::QueryRecorder.new do
- get api("/runners/#{shared_runner.id}/jobs", admin)
+ get api("/runners/#{shared_runner.id}/jobs", admin, admin_mode: true)
end
create(:ci_build, :failed, runner: shared_runner, project: project)
expect do
- get api("/runners/#{shared_runner.id}/jobs", admin)
+ get api("/runners/#{shared_runner.id}/jobs", admin, admin_mode: true)
end.not_to exceed_query_limit(control.count)
end
@@ -925,12 +953,12 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
]).once.and_call_original
end
- get api("/runners/#{shared_runner.id}/jobs", admin), params: { per_page: 2, order_by: 'id', sort: 'desc' }
+ get api("/runners/#{shared_runner.id}/jobs", admin, admin_mode: true), params: { per_page: 2, order_by: 'id', sort: 'desc' }
end
context "when runner doesn't exist" do
it 'returns 404' do
- get api('/runners/0/jobs', admin)
+ get api('/runners/0/jobs', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -949,7 +977,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'when runner is a project runner' do
it 'return jobs' do
- get api("/runners/#{project_runner.id}/jobs", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -992,7 +1020,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'other authorized user' do
it 'does not return jobs' do
- get api("/runners/#{project_runner.id}/jobs", user2)
+ get api(path, user2)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -1000,7 +1028,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'unauthorized user' do
it 'does not return jobs' do
- get api("/runners/#{project_runner.id}/jobs")
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -1028,7 +1056,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
describe 'GET /projects/:id/runners' do
context 'authorized user with maintainer privileges' do
it 'returns response status and headers' do
- get api('/runners/all', admin)
+ get api('/runners/all', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1200,19 +1228,27 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
describe 'POST /projects/:id/runners' do
+ let(:path) { "/projects/#{project.id}/runners" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let!(:new_project_runner) { create(:ci_runner, :project) }
+ let(:params) { { runner_id: new_project_runner.id } }
+ let(:failed_status_code) { :not_found }
+ end
+
context 'authorized user' do
let_it_be(:project_runner2) { create(:ci_runner, :project, projects: [project2]) }
it 'enables project runner' do
expect do
- post api("/projects/#{project.id}/runners", user), params: { runner_id: project_runner2.id }
+ post api(path, user), params: { runner_id: project_runner2.id }
end.to change { project.runners.count }.by(+1)
expect(response).to have_gitlab_http_status(:created)
end
it 'avoids changes when enabling already enabled runner' do
expect do
- post api("/projects/#{project.id}/runners", user), params: { runner_id: project_runner.id }
+ post api(path, user), params: { runner_id: project_runner.id }
end.to change { project.runners.count }.by(0)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -1221,20 +1257,20 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
project_runner2.update!(locked: true)
expect do
- post api("/projects/#{project.id}/runners", user), params: { runner_id: project_runner2.id }
+ post api(path, user), params: { runner_id: project_runner2.id }
end.to change { project.runners.count }.by(0)
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'does not enable shared runner' do
- post api("/projects/#{project.id}/runners", user), params: { runner_id: shared_runner.id }
+ post api(path, user), params: { runner_id: shared_runner.id }
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'does not enable group runner' do
- post api("/projects/#{project.id}/runners", user), params: { runner_id: group_runner_a.id }
+ post api(path, user), params: { runner_id: group_runner_a.id }
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -1245,7 +1281,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
it 'enables any project runner' do
expect do
- post api("/projects/#{project.id}/runners", admin), params: { runner_id: new_project_runner.id }
+ post api(path, admin, admin_mode: true), params: { runner_id: new_project_runner.id }
end.to change { project.runners.count }.by(+1)
expect(response).to have_gitlab_http_status(:created)
end
@@ -1257,7 +1293,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
it 'does not enable project runner' do
expect do
- post api("/projects/#{project.id}/runners", admin), params: { runner_id: new_project_runner.id }
+ post api(path, admin, admin_mode: true), params: { runner_id: new_project_runner.id }
end.not_to change { project.runners.count }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -1266,7 +1302,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
end
it 'raises an error when no runner_id param is provided' do
- post api("/projects/#{project.id}/runners", admin)
+ post api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -1276,7 +1312,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
let!(:new_project_runner) { create(:ci_runner, :project) }
it 'does not enable runner without access to' do
- post api("/projects/#{project.id}/runners", user), params: { runner_id: new_project_runner.id }
+ post api(path, user), params: { runner_id: new_project_runner.id }
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -1284,7 +1320,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'authorized user without permissions' do
it 'does not enable runner' do
- post api("/projects/#{project.id}/runners", user2)
+ post api(path, user2)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -1292,7 +1328,7 @@ RSpec.describe API::Ci::Runners, feature_category: :runner_fleet do
context 'unauthorized user' do
it 'does not enable runner' do
- post api("/projects/#{project.id}/runners")
+ post api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
diff --git a/spec/requests/api/ci/secure_files_spec.rb b/spec/requests/api/ci/secure_files_spec.rb
index fc988800b56..db12576154e 100644
--- a/spec/requests/api/ci/secure_files_spec.rb
+++ b/spec/requests/api/ci/secure_files_spec.rb
@@ -136,7 +136,7 @@ RSpec.describe API::Ci::SecureFiles, feature_category: :mobile_devops do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq(secure_file_with_metadata.name)
- expect(json_response['expires_at']).to eq('2022-04-26T19:20:40.000Z')
+ expect(json_response['expires_at']).to eq('2023-04-26T19:20:39.000Z')
expect(json_response['metadata'].keys).to match_array(%w[id issuer subject expires_at])
expect(json_response['file_extension']).to eq('cer')
end
diff --git a/spec/requests/api/ci/variables_spec.rb b/spec/requests/api/ci/variables_spec.rb
index 0f9f1bc80d6..e937c4c2b8f 100644
--- a/spec/requests/api/ci/variables_spec.rb
+++ b/spec/requests/api/ci/variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Ci::Variables, feature_category: :pipeline_authoring do
+RSpec.describe API::Ci::Variables, feature_category: :secrets_management do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let!(:project) { create(:project, creator_id: user.id) }
diff --git a/spec/requests/api/clusters/agent_tokens_spec.rb b/spec/requests/api/clusters/agent_tokens_spec.rb
index b2d996e8002..2647684c9f8 100644
--- a/spec/requests/api/clusters/agent_tokens_spec.rb
+++ b/spec/requests/api/clusters/agent_tokens_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Clusters::AgentTokens, feature_category: :kubernetes_management do
+RSpec.describe API::Clusters::AgentTokens, feature_category: :deployment_management do
let_it_be(:agent) { create(:cluster_agent) }
let_it_be(:agent_token_one) { create(:cluster_agent_token, agent: agent) }
let_it_be(:revoked_agent_token) { create(:cluster_agent_token, :revoked, agent: agent) }
@@ -17,18 +17,16 @@ RSpec.describe API::Clusters::AgentTokens, feature_category: :kubernetes_managem
describe 'GET /projects/:id/cluster_agents/:agent_id/tokens' do
context 'with authorized user' do
- it 'returns tokens regardless of status' do
+ it 'only returns active agent tokens' do
get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", user)
aggregate_failures "testing response" do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(response).to match_response_schema('public_api/v4/agent_tokens')
- expect(json_response.count).to eq(2)
+ expect(json_response.count).to eq(1)
expect(json_response.first['name']).to eq(agent_token_one.name)
expect(json_response.first['agent_id']).to eq(agent.id)
- expect(json_response.second['name']).to eq(revoked_agent_token.name)
- expect(json_response.second['agent_id']).to eq(agent.id)
end
end
@@ -80,17 +78,10 @@ RSpec.describe API::Clusters::AgentTokens, feature_category: :kubernetes_managem
end
end
- it 'returns an agent token that is revoked' do
+ it 'returns a 404 if agent token is revoked' do
get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens/#{revoked_agent_token.id}", user)
- aggregate_failures "testing response" do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/agent_token')
- expect(json_response['id']).to eq(revoked_agent_token.id)
- expect(json_response['name']).to eq(revoked_agent_token.name)
- expect(json_response['agent_id']).to eq(agent.id)
- expect(json_response['status']).to eq('revoked')
- end
+ expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns a 404 if agent does not exist' do
diff --git a/spec/requests/api/clusters/agents_spec.rb b/spec/requests/api/clusters/agents_spec.rb
index a09713bd6e7..12056567e9d 100644
--- a/spec/requests/api/clusters/agents_spec.rb
+++ b/spec/requests/api/clusters/agents_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Clusters::Agents, feature_category: :kubernetes_management do
+RSpec.describe API::Clusters::Agents, feature_category: :deployment_management do
let_it_be(:agent) { create(:cluster_agent) }
let(:user) { agent.created_by_user }
diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb
index 025d065df7b..7540e19e278 100644
--- a/spec/requests/api/commit_statuses_spec.rb
+++ b/spec/requests/api/commit_statuses_spec.rb
@@ -533,8 +533,8 @@ RSpec.describe API::CommitStatuses, feature_category: :continuous_integration do
end
end
- context 'with partitions' do
- let(:current_partition_id) { 123 }
+ context 'with partitions', :ci_partitionable do
+ let(:current_partition_id) { ci_testing_partition_id }
before do
allow(Ci::Pipeline)
diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb
index bcc27a80cf8..28126f1bdc2 100644
--- a/spec/requests/api/commits_spec.rb
+++ b/spec/requests/api/commits_spec.rb
@@ -132,6 +132,42 @@ RSpec.describe API::Commits, feature_category: :source_code_management do
it_behaves_like 'project commits'
end
+ context 'with author parameter' do
+ let(:params) { { author: 'Zaporozhets' } }
+
+ it 'returns only this author commits' do
+ get api(route, user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ author_names = json_response.map { |commit| commit['author_name'] }.uniq
+
+ expect(author_names).to contain_exactly('Dmitriy Zaporozhets')
+ end
+
+ context 'when author is missing' do
+ let(:params) { { author: '' } }
+
+ it 'returns all commits' do
+ get api(route, user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.count).to eq(20)
+ end
+ end
+
+ context 'when author does not exists' do
+ let(:params) { { author: 'does not exist' } }
+
+ it 'returns an empty list' do
+ get api(route, user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+ end
+
context 'when repository does not exist' do
let(:project) { create(:project, creator: user, path: 'my.project') }
@@ -425,6 +461,27 @@ RSpec.describe API::Commits, feature_category: :source_code_management do
describe "POST /projects/:id/repository/commits" do
let!(:url) { "/projects/#{project_id}/repository/commits" }
+ context 'when unauthenticated', 'and project is public' do
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let(:params) do
+ {
+ branch: 'master',
+ commit_message: 'message',
+ actions: [
+ {
+ action: 'create',
+ file_path: '/test.rb',
+ content: 'puts 8'
+ }
+ ]
+ }
+ end
+
+ it_behaves_like '401 response' do
+ let(:request) { post api(url), params: params }
+ end
+ end
+
it 'returns a 403 unauthorized for user without permissions' do
post api(url, guest)
@@ -523,7 +580,6 @@ RSpec.describe API::Commits, feature_category: :source_code_management do
let(:property) { 'g_edit_by_web_ide' }
let(:label) { 'usage_activity_by_stage_monthly.create.action_monthly_active_users_ide_edit' }
let(:context) { [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: event_name).to_context] }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
end
context 'counts.web_ide_commits Snowplow event tracking' do
@@ -536,7 +592,6 @@ RSpec.describe API::Commits, feature_category: :source_code_management do
let(:category) { described_class.to_s }
let(:namespace) { project.namespace.reload }
let(:label) { 'counts.web_ide_commits' }
- let(:feature_flag_name) { 'route_hll_to_snowplow_phase3' }
let(:context) do
[Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: 'counts.web_ide_commits').to_context.to_json]
end
@@ -1776,7 +1831,7 @@ RSpec.describe API::Commits, feature_category: :source_code_management do
context 'when unauthenticated', 'and project is public' do
let_it_be(:project) { create(:project, :public, :repository) }
- it_behaves_like '403 response' do
+ it_behaves_like '401 response' do
let(:request) { post api(route), params: { branch: 'master' } }
end
end
@@ -1956,7 +2011,7 @@ RSpec.describe API::Commits, feature_category: :source_code_management do
context 'when unauthenticated', 'and project is public' do
let_it_be(:project) { create(:project, :public, :repository) }
- it_behaves_like '403 response' do
+ it_behaves_like '401 response' do
let(:request) { post api(route), params: { branch: branch } }
end
end
diff --git a/spec/requests/api/composer_packages_spec.rb b/spec/requests/api/composer_packages_spec.rb
index 0c726d46a01..2bb2ffa03c4 100644
--- a/spec/requests/api/composer_packages_spec.rb
+++ b/spec/requests/api/composer_packages_spec.rb
@@ -504,7 +504,11 @@ RSpec.describe API::ComposerPackages, feature_category: :package_registry do
include_context 'Composer user type', params[:user_role], params[:member] do
if params[:expected_status] == :success
let(:snowplow_gitlab_standard_context) do
- { project: project, namespace: project.namespace, property: 'i_package_composer_user' }
+ if user_role == :anonymous || (project_visibility_level == 'PUBLIC' && user_token == false)
+ { project: project, namespace: project.namespace, property: 'i_package_composer_user' }
+ else
+ { project: project, namespace: project.namespace, property: 'i_package_composer_user', user: user }
+ end
end
it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
diff --git a/spec/requests/api/conan_project_packages_spec.rb b/spec/requests/api/conan_project_packages_spec.rb
index 814745f9e29..06f175233db 100644
--- a/spec/requests/api/conan_project_packages_spec.rb
+++ b/spec/requests/api/conan_project_packages_spec.rb
@@ -33,6 +33,29 @@ RSpec.describe API::ConanProjectPackages, feature_category: :package_registry do
subject { get api(url), params: params }
end
+
+ context 'with access to package registry for everyone' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ project.project_feature.update!(package_registry_access_level: ProjectFeature::PUBLIC)
+
+ get api(url), params: params
+ end
+
+ subject { json_response['results'] }
+
+ context 'with a matching name' do
+ let(:params) { { q: package.conan_recipe } }
+
+ it { is_expected.to contain_exactly(package.conan_recipe) }
+ end
+
+ context 'with a * wildcard' do
+ let(:params) { { q: "#{package.name[0, 3]}*" } }
+
+ it { is_expected.to contain_exactly(package.conan_recipe) }
+ end
+ end
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/users/authenticate' do
diff --git a/spec/requests/api/debian_group_packages_spec.rb b/spec/requests/api/debian_group_packages_spec.rb
index 0c80b7d830f..9c726e5a5f7 100644
--- a/spec/requests/api/debian_group_packages_spec.rb
+++ b/spec/requests/api/debian_group_packages_spec.rb
@@ -6,76 +6,119 @@ RSpec.describe API::DebianGroupPackages, feature_category: :package_registry do
include WorkhorseHelpers
include_context 'Debian repository shared context', :group, false do
+ shared_examples 'a Debian package tracking event' do |action|
+ include_context 'Debian repository access', :public, :developer, :basic do
+ let(:snowplow_gitlab_standard_context) do
+ { project: nil, namespace: container, user: user, property: 'i_package_debian_user' }
+ end
+
+ it_behaves_like 'a package tracking event', described_class.name, action
+ end
+ end
+
+ shared_examples 'not a Debian package tracking event' do
+ include_context 'Debian repository access', :public, :developer, :basic do
+ it_behaves_like 'not a package tracking event', described_class.name, /.*/
+ end
+ end
+
context 'with invalid parameter' do
let(:url) { "/groups/1/-/packages/debian/dists/with+space/InRelease" }
it_behaves_like 'Debian packages GET request', :bad_request, /^distribution is invalid$/
+ it_behaves_like 'not a Debian package tracking event'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/Release.gpg' do
let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/Release.gpg" }
it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNATURE-----/
+ it_behaves_like 'not a Debian package tracking event'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/Release' do
let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/Release" }
it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Codename: fixture-distribution\n$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/InRelease' do
let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/InRelease" }
it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNED MESSAGE-----/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/Packages' do
- let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/Packages" }
+ let(:target_component_file) { component_file }
+ let(:target_component_name) { component.name }
+ let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{target_component_name}/binary-#{architecture.name}/Packages" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Packages file/
+ it_behaves_like 'Debian packages index endpoint', /Description: This is an incomplete Packages file/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/Packages.gz' do
let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/Packages.gz" }
it_behaves_like 'Debian packages read endpoint', 'GET', :not_found, /Format gz is not supported/
+ it_behaves_like 'not a Debian package tracking event'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/by-hash/SHA256/:file_sha256' do
- let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/by-hash/SHA256/#{component_file_older_sha256.file_sha256}" }
+ let(:target_component_file) { component_file_older_sha256 }
+ let(:target_component_name) { component.name }
+ let(:target_sha256) { target_component_file.file_sha256 }
+ let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{target_component_name}/binary-#{architecture.name}/by-hash/SHA256/#{target_sha256}" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Other SHA256$/
+ it_behaves_like 'Debian packages index sha256 endpoint', /^Other SHA256$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/source/Sources' do
- let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/source/Sources" }
+ let(:target_component_file) { component_file_sources }
+ let(:target_component_name) { component.name }
+ let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{target_component_name}/source/Sources" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Sources file/
+ it_behaves_like 'Debian packages index endpoint', /^Description: This is an incomplete Sources file$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/source/by-hash/SHA256/:file_sha256' do
- let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/source/by-hash/SHA256/#{component_file_sources_older_sha256.file_sha256}" }
+ let(:target_component_file) { component_file_sources_older_sha256 }
+ let(:target_component_name) { component.name }
+ let(:target_sha256) { target_component_file.file_sha256 }
+ let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{target_component_name}/source/by-hash/SHA256/#{target_sha256}" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Other SHA256$/
+ it_behaves_like 'Debian packages index sha256 endpoint', /^Other SHA256$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/Packages' do
- let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/Packages" }
+ let(:target_component_file) { component_file_di }
+ let(:target_component_name) { component.name }
+ let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{target_component_name}/debian-installer/binary-#{architecture.name}/Packages" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete D-I Packages file/
+ it_behaves_like 'Debian packages index endpoint', /Description: This is an incomplete D-I Packages file/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/Packages.gz' do
let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/Packages.gz" }
it_behaves_like 'Debian packages read endpoint', 'GET', :not_found, /Format gz is not supported/
+ it_behaves_like 'not a Debian package tracking event'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/by-hash/SHA256/:file_sha256' do
- let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/by-hash/SHA256/#{component_file_di_older_sha256.file_sha256}" }
+ let(:target_component_file) { component_file_di_older_sha256 }
+ let(:target_component_name) { component.name }
+ let(:target_sha256) { target_component_file.file_sha256 }
+ let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{target_component_name}/debian-installer/binary-#{architecture.name}/by-hash/SHA256/#{target_sha256}" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Other SHA256$/
+ it_behaves_like 'Debian packages index sha256 endpoint', /^Other SHA256$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
end
describe 'GET groups/:id/-/packages/debian/pool/:codename/:project_id/:letter/:package_name/:package_version/:file_name' do
@@ -89,12 +132,14 @@ RSpec.describe API::DebianGroupPackages, feature_category: :package_registry do
'sample_1.2.3~alpha2.dsc' | /^Format: 3.0 \(native\)/
'libsample0_1.2.3~alpha2_amd64.deb' | /^!<arch>/
'sample-udeb_1.2.3~alpha2_amd64.udeb' | /^!<arch>/
+ 'sample-ddeb_1.2.3~alpha2_amd64.ddeb' | /^!<arch>/
'sample_1.2.3~alpha2_amd64.buildinfo' | /Build-Tainted-By/
'sample_1.2.3~alpha2_amd64.changes' | /urgency=medium/
end
with_them do
it_behaves_like 'Debian packages read endpoint', 'GET', :success, params[:success_body]
+ it_behaves_like 'a Debian package tracking event', 'pull_package'
context 'for bumping last downloaded at' do
include_context 'Debian repository access', :public, :developer, :basic do
diff --git a/spec/requests/api/debian_project_packages_spec.rb b/spec/requests/api/debian_project_packages_spec.rb
index 46f79efd928..030962044c6 100644
--- a/spec/requests/api/debian_project_packages_spec.rb
+++ b/spec/requests/api/debian_project_packages_spec.rb
@@ -6,6 +6,22 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
include WorkhorseHelpers
include_context 'Debian repository shared context', :project, false do
+ shared_examples 'a Debian package tracking event' do |action|
+ include_context 'Debian repository access', :public, :developer, :basic do
+ let(:snowplow_gitlab_standard_context) do
+ { project: container, namespace: container.namespace, user: user, property: 'i_package_debian_user' }
+ end
+
+ it_behaves_like 'a package tracking event', described_class.name, action
+ end
+ end
+
+ shared_examples 'not a Debian package tracking event' do
+ include_context 'Debian repository access', :public, :developer, :basic do
+ it_behaves_like 'not a package tracking event', described_class.name, /.*/
+ end
+ end
+
shared_examples 'accept GET request on private project with access to package registry for everyone' do
include_context 'Debian repository access', :private, :anonymous, :basic do
before do
@@ -20,12 +36,14 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
let(:url) { "/projects/1/packages/debian/dists/with+space/InRelease" }
it_behaves_like 'Debian packages GET request', :bad_request, /^distribution is invalid$/
+ it_behaves_like 'not a Debian package tracking event'
end
describe 'GET projects/:id/packages/debian/dists/*distribution/Release.gpg' do
let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/Release.gpg" }
it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNATURE-----/
+ it_behaves_like 'not a Debian package tracking event'
it_behaves_like 'accept GET request on private project with access to package registry for everyone'
end
@@ -33,6 +51,7 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/Release" }
it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Codename: fixture-distribution\n$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
it_behaves_like 'accept GET request on private project with access to package registry for everyone'
end
@@ -40,13 +59,17 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/InRelease" }
it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNED MESSAGE-----/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
it_behaves_like 'accept GET request on private project with access to package registry for everyone'
end
describe 'GET projects/:id/packages/debian/dists/*distribution/:component/binary-:architecture/Packages' do
- let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/Packages" }
+ let(:target_component_file) { component_file }
+ let(:target_component_name) { component.name }
+ let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{target_component_name}/binary-#{architecture.name}/Packages" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Packages file/
+ it_behaves_like 'Debian packages index endpoint', /Description: This is an incomplete Packages file/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
it_behaves_like 'accept GET request on private project with access to package registry for everyone'
end
@@ -54,33 +77,48 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/Packages.gz" }
it_behaves_like 'Debian packages read endpoint', 'GET', :not_found, /Format gz is not supported/
+ it_behaves_like 'not a Debian package tracking event'
end
describe 'GET projects/:id/packages/debian/dists/*distribution/:component/binary-:architecture/by-hash/SHA256/:file_sha256' do
- let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/by-hash/SHA256/#{component_file_older_sha256.file_sha256}" }
+ let(:target_component_file) { component_file_older_sha256 }
+ let(:target_component_name) { component.name }
+ let(:target_sha256) { target_component_file.file_sha256 }
+ let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{target_component_name}/binary-#{architecture.name}/by-hash/SHA256/#{target_sha256}" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Other SHA256$/
+ it_behaves_like 'Debian packages index sha256 endpoint', /^Other SHA256$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
it_behaves_like 'accept GET request on private project with access to package registry for everyone'
end
describe 'GET projects/:id/packages/debian/dists/*distribution/:component/source/Sources' do
- let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/source/Sources" }
+ let(:target_component_file) { component_file_sources }
+ let(:target_component_name) { component.name }
+ let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{target_component_name}/source/Sources" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Sources file/
+ it_behaves_like 'Debian packages index endpoint', /^Description: This is an incomplete Sources file$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
it_behaves_like 'accept GET request on private project with access to package registry for everyone'
end
describe 'GET projects/:id/packages/debian/dists/*distribution/:component/source/by-hash/SHA256/:file_sha256' do
- let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/source/by-hash/SHA256/#{component_file_sources_older_sha256.file_sha256}" }
+ let(:target_component_file) { component_file_sources_older_sha256 }
+ let(:target_component_name) { component.name }
+ let(:target_sha256) { target_component_file.file_sha256 }
+ let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{target_component_name}/source/by-hash/SHA256/#{target_sha256}" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Other SHA256$/
+ it_behaves_like 'Debian packages index sha256 endpoint', /^Other SHA256$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
it_behaves_like 'accept GET request on private project with access to package registry for everyone'
end
describe 'GET projects/:id/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/Packages' do
- let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/Packages" }
+ let(:target_component_file) { component_file_di }
+ let(:target_component_name) { component.name }
+ let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{target_component_name}/debian-installer/binary-#{architecture.name}/Packages" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete D-I Packages file/
+ it_behaves_like 'Debian packages index endpoint', /Description: This is an incomplete D-I Packages file/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
it_behaves_like 'accept GET request on private project with access to package registry for everyone'
end
@@ -88,12 +126,17 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/Packages.gz" }
it_behaves_like 'Debian packages read endpoint', 'GET', :not_found, /Format gz is not supported/
+ it_behaves_like 'not a Debian package tracking event'
end
describe 'GET projects/:id/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/by-hash/SHA256/:file_sha256' do
- let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/by-hash/SHA256/#{component_file_di_older_sha256.file_sha256}" }
+ let(:target_component_file) { component_file_di_older_sha256 }
+ let(:target_component_name) { component.name }
+ let(:target_sha256) { target_component_file.file_sha256 }
+ let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{target_component_name}/debian-installer/binary-#{architecture.name}/by-hash/SHA256/#{target_sha256}" }
- it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Other SHA256$/
+ it_behaves_like 'Debian packages index sha256 endpoint', /^Other SHA256$/
+ it_behaves_like 'a Debian package tracking event', 'list_package'
it_behaves_like 'accept GET request on private project with access to package registry for everyone'
end
@@ -108,12 +151,14 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
'sample_1.2.3~alpha2.dsc' | /^Format: 3.0 \(native\)/
'libsample0_1.2.3~alpha2_amd64.deb' | /^!<arch>/
'sample-udeb_1.2.3~alpha2_amd64.udeb' | /^!<arch>/
+ 'sample-ddeb_1.2.3~alpha2_amd64.ddeb' | /^!<arch>/
'sample_1.2.3~alpha2_amd64.buildinfo' | /Build-Tainted-By/
'sample_1.2.3~alpha2_amd64.changes' | /urgency=medium/
end
with_them do
it_behaves_like 'Debian packages read endpoint', 'GET', :success, params[:success_body]
+ it_behaves_like 'a Debian package tracking event', 'pull_package'
context 'for bumping last downloaded at' do
include_context 'Debian repository access', :public, :developer, :basic do
@@ -130,17 +175,19 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
describe 'PUT projects/:id/packages/debian/:file_name' do
let(:method) { :put }
let(:url) { "/projects/#{container.id}/packages/debian/#{file_name}" }
- let(:snowplow_gitlab_standard_context) { { project: container, user: user, namespace: container.namespace } }
context 'with a deb' do
let(:file_name) { 'libsample0_1.2.3~alpha2_amd64.deb' }
it_behaves_like 'Debian packages write endpoint', 'upload', :created, nil
+ it_behaves_like 'Debian packages endpoint catching ObjectStorage::RemoteStoreError'
+ it_behaves_like 'a Debian package tracking event', 'push_package'
context 'with codename and component' do
let(:extra_params) { { distribution: distribution.codename, component: 'main' } }
it_behaves_like 'Debian packages write endpoint', 'upload', :created, nil
+ it_behaves_like 'a Debian package tracking event', 'push_package'
end
context 'with codename and without component' do
@@ -149,6 +196,8 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
include_context 'Debian repository access', :public, :developer, :basic do
it_behaves_like 'Debian packages GET request', :bad_request, /component is missing/
end
+
+ it_behaves_like 'not a Debian package tracking event'
end
end
@@ -157,13 +206,19 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
include_context 'Debian repository access', :public, :developer, :basic do
it_behaves_like "Debian packages upload request", :created, nil
+ end
- context 'with codename and component' do
- let(:extra_params) { { distribution: distribution.codename, component: 'main' } }
+ it_behaves_like 'a Debian package tracking event', 'push_package'
+ context 'with codename and component' do
+ let(:extra_params) { { distribution: distribution.codename, component: 'main' } }
+
+ include_context 'Debian repository access', :public, :developer, :basic do
it_behaves_like "Debian packages upload request", :bad_request,
- /^file_name Only debs and udebs can be directly added to a distribution$/
+ /^file_name Only debs, udebs and ddebs can be directly added to a distribution$/
end
+
+ it_behaves_like 'not a Debian package tracking event'
end
end
@@ -171,6 +226,7 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
let(:file_name) { 'sample_1.2.3~alpha2_amd64.changes' }
it_behaves_like 'Debian packages write endpoint', 'upload', :created, nil
+ it_behaves_like 'a Debian package tracking event', 'push_package'
end
end
@@ -180,6 +236,7 @@ RSpec.describe API::DebianProjectPackages, feature_category: :package_registry d
let(:url) { "/projects/#{container.id}/packages/debian/#{file_name}/authorize" }
it_behaves_like 'Debian packages write endpoint', 'upload authorize', :created, nil
+ it_behaves_like 'not a Debian package tracking event'
end
end
end
diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb
index 15880d920c5..18a9211df3e 100644
--- a/spec/requests/api/deploy_keys_spec.rb
+++ b/spec/requests/api/deploy_keys_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
+RSpec.describe API::DeployKeys, :aggregate_failures, feature_category: :continuous_delivery do
let_it_be(:user) { create(:user) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:admin) { create(:admin) }
@@ -11,33 +11,29 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
let_it_be(:project3) { create(:project, creator_id: user.id) }
let_it_be(:deploy_key) { create(:deploy_key, public: true) }
let_it_be(:deploy_key_private) { create(:deploy_key, public: false) }
+ let_it_be(:path) { '/deploy_keys' }
+ let_it_be(:project_path) { "/projects/#{project.id}#{path}" }
let!(:deploy_keys_project) do
create(:deploy_keys_project, project: project, deploy_key: deploy_key)
end
describe 'GET /deploy_keys' do
+ it_behaves_like 'GET request permissions for admin mode'
+
context 'when unauthenticated' do
it 'returns authentication error' do
- get api('/deploy_keys')
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
- context 'when authenticated as non-admin user' do
- it 'returns a 403 error' do
- get api('/deploy_keys', user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
context 'when authenticated as admin' do
- let_it_be(:pat) { create(:personal_access_token, user: admin) }
+ let_it_be(:pat) { create(:personal_access_token, :admin_mode, user: admin) }
def make_api_request(params = {})
- get api('/deploy_keys', personal_access_token: pat), params: params
+ get api(path, personal_access_token: pat), params: params
end
it 'returns all deploy keys' do
@@ -91,14 +87,18 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
describe 'GET /projects/:id/deploy_keys' do
let(:deploy_key) { create(:deploy_key, public: true, user: admin) }
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { project_path }
+ let(:failed_status_code) { :not_found }
+ end
+
def perform_request
- get api("/projects/#{project.id}/deploy_keys", admin)
+ get api(project_path, admin, admin_mode: true)
end
it 'returns array of ssh keys' do
perform_request
- expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.first['title']).to eq(deploy_key.title)
@@ -117,31 +117,59 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
end
describe 'GET /projects/:id/deploy_keys/:key_id' do
+ let_it_be(:path) { "#{project_path}/#{deploy_key.id}" }
+ let_it_be(:unfindable_path) { "#{project_path}/404" }
+
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
+
it 'returns a single key' do
- get api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin)
+ get api(path, admin, admin_mode: true)
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(deploy_key.title)
expect(json_response).not_to have_key(:projects_with_write_access)
end
it 'returns 404 Not Found with invalid ID' do
- get api("/projects/#{project.id}/deploy_keys/404", admin)
+ get api(unfindable_path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ context 'when deploy key has expiry date' do
+ let(:deploy_key) { create(:deploy_key, :expired, public: true) }
+ let(:deploy_keys_project) { create(:deploy_keys_project, project: project, deploy_key: deploy_key) }
+
+ it 'returns expiry date' do
+ get api("#{project_path}/#{deploy_key.id}", admin, admin_mode: true)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(Time.parse(json_response['expires_at'])).to be_like_time(deploy_key.expires_at)
+ end
+ end
end
describe 'POST /projects/:id/deploy_keys' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ it_behaves_like 'POST request permissions for admin mode', :not_found do
+ let(:params) { attributes_for :another_key }
+ let(:path) { project_path }
+ let(:failed_status_code) { :not_found }
+ end
+
it 'does not create an invalid ssh key' do
- post api("/projects/#{project.id}/deploy_keys", admin), params: { title: 'invalid key' }
+ post api(project_path, admin, admin_mode: true), params: { title: 'invalid key' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('key is missing')
end
it 'does not create a key without title' do
- post api("/projects/#{project.id}/deploy_keys", admin), params: { key: 'some key' }
+ post api(project_path, admin, admin_mode: true), params: { key: 'some key' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('title is missing')
@@ -151,7 +179,7 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
key_attrs = attributes_for :another_key
expect do
- post api("/projects/#{project.id}/deploy_keys", admin), params: key_attrs
+ post api(project_path, admin, admin_mode: true), params: key_attrs
end.to change { project.deploy_keys.count }.by(1)
new_key = project.deploy_keys.last
@@ -161,7 +189,7 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
it 'returns an existing ssh key when attempting to add a duplicate' do
expect do
- post api("/projects/#{project.id}/deploy_keys", admin), params: { key: deploy_key.key, title: deploy_key.title }
+ post api(project_path, admin, admin_mode: true), params: { key: deploy_key.key, title: deploy_key.title }
end.not_to change { project.deploy_keys.count }
expect(response).to have_gitlab_http_status(:created)
@@ -169,7 +197,7 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
it 'joins an existing ssh key to a new project' do
expect do
- post api("/projects/#{project2.id}/deploy_keys", admin), params: { key: deploy_key.key, title: deploy_key.title }
+ post api("/projects/#{project2.id}/deploy_keys", admin, admin_mode: true), params: { key: deploy_key.key, title: deploy_key.title }
end.to change { project2.deploy_keys.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -178,18 +206,34 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
it 'accepts can_push parameter' do
key_attrs = attributes_for(:another_key).merge(can_push: true)
- post api("/projects/#{project.id}/deploy_keys", admin), params: key_attrs
+ post api(project_path, admin, admin_mode: true), params: key_attrs
expect(response).to have_gitlab_http_status(:created)
expect(json_response['can_push']).to eq(true)
end
+
+ it 'accepts expires_at parameter' do
+ key_attrs = attributes_for(:another_key).merge(expires_at: 2.days.since.iso8601)
+
+ post api(project_path, admin, admin_mode: true), params: key_attrs
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(Time.parse(json_response['expires_at'])).to be_like_time(2.days.since)
+ end
end
describe 'PUT /projects/:id/deploy_keys/:key_id' do
+ let(:path) { "#{project_path}/#{deploy_key.id}" }
let(:extra_params) { {} }
+ let(:admin_mode) { false }
+
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:params) { { title: 'new title', can_push: true } }
+ let(:failed_status_code) { :not_found }
+ end
subject do
- put api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", api_user), params: extra_params
+ put api(path, api_user, admin_mode: admin_mode), params: extra_params
end
context 'with non-admin' do
@@ -204,6 +248,7 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
context 'with admin' do
let(:api_user) { admin }
+ let(:admin_mode) { true }
context 'public deploy key attached to project' do
let(:extra_params) { { title: 'new title', can_push: true } }
@@ -258,9 +303,13 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
context 'public deploy key attached to project' do
let(:extra_params) { { title: 'new title', can_push: true } }
- it 'updates the title of the deploy key' do
- expect { subject }.to change { deploy_key.reload.title }.to 'new title'
- expect(response).to have_gitlab_http_status(:ok)
+ context 'with admin mode on' do
+ let(:admin_mode) { true }
+
+ it 'updates the title of the deploy key' do
+ expect { subject }.to change { deploy_key.reload.title }.to 'new title'
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
it 'updates can_push of deploy_keys_project' do
@@ -298,18 +347,22 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
deploy_key
end
+ let(:path) { "#{project_path}/#{deploy_key.id}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
+
it 'removes existing key from project' do
expect do
- delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin)
-
- expect(response).to have_gitlab_http_status(:no_content)
+ delete api(path, admin, admin_mode: true)
end.to change { project.deploy_keys.count }.by(-1)
end
context 'when the deploy key is public' do
it 'does not delete the deploy key' do
expect do
- delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.not_to change { DeployKey.count }
@@ -322,7 +375,7 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
context 'when the deploy key is only used by this project' do
it 'deletes the deploy key' do
expect do
- delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { DeployKey.count }.by(-1)
@@ -336,7 +389,7 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
it 'does not delete the deploy key' do
expect do
- delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.not_to change { DeployKey.count }
@@ -345,26 +398,31 @@ RSpec.describe API::DeployKeys, feature_category: :continuous_delivery do
end
it 'returns 404 Not Found with invalid ID' do
- delete api("/projects/#{project.id}/deploy_keys/404", admin)
+ delete api("#{project_path}/404", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
it_behaves_like '412 response' do
- let(:request) { api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin) }
+ let(:request) { api("#{project_path}/#{deploy_key.id}", admin, admin_mode: true) }
end
end
describe 'POST /projects/:id/deploy_keys/:key_id/enable' do
- let(:project2) { create(:project) }
+ let_it_be(:project2) { create(:project) }
+ let_it_be(:path) { "/projects/#{project2.id}/deploy_keys/#{deploy_key.id}/enable" }
+ let_it_be(:params) { {} }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
context 'when the user can admin the project' do
it 'enables the key' do
expect do
- post api("/projects/#{project2.id}/deploy_keys/#{deploy_key.id}/enable", admin)
+ post api(path, admin, admin_mode: true)
end.to change { project2.deploy_keys.count }.from(0).to(1)
- expect(response).to have_gitlab_http_status(:created)
expect(json_response['id']).to eq(deploy_key.id)
end
end
diff --git a/spec/requests/api/deploy_tokens_spec.rb b/spec/requests/api/deploy_tokens_spec.rb
index 4efe49e843f..c0e36bf03bf 100644
--- a/spec/requests/api/deploy_tokens_spec.rb
+++ b/spec/requests/api/deploy_tokens_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::DeployTokens, feature_category: :continuous_delivery do
+RSpec.describe API::DeployTokens, :aggregate_failures, feature_category: :continuous_delivery do
let_it_be(:user) { create(:user) }
let_it_be(:creator) { create(:user) }
let_it_be(:project) { create(:project, creator_id: creator.id) }
@@ -17,26 +17,25 @@ RSpec.describe API::DeployTokens, feature_category: :continuous_delivery do
describe 'GET /deploy_tokens' do
subject do
- get api('/deploy_tokens', user)
+ get api('/deploy_tokens', user, admin_mode: admin_mode)
response
end
- context 'when unauthenticated' do
- let(:user) { nil }
+ let_it_be(:admin_mode) { false }
- it { is_expected.to have_gitlab_http_status(:unauthorized) }
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { '/deploy_tokens' }
end
- context 'when authenticated as non-admin user' do
- let(:user) { creator }
+ context 'when unauthenticated' do
+ let(:user) { nil }
- it { is_expected.to have_gitlab_http_status(:forbidden) }
+ it { is_expected.to have_gitlab_http_status(:unauthorized) }
end
context 'when authenticated as admin' do
let(:user) { create(:admin) }
-
- it { is_expected.to have_gitlab_http_status(:ok) }
+ let_it_be(:admin_mode) { true }
it 'returns all deploy tokens' do
subject
@@ -57,7 +56,7 @@ RSpec.describe API::DeployTokens, feature_category: :continuous_delivery do
context 'and active=true' do
it 'only returns active deploy tokens' do
- get api('/deploy_tokens?active=true', user)
+ get api('/deploy_tokens?active=true', user, admin_mode: true)
token_ids = json_response.map { |token| token['id'] }
expect(response).to have_gitlab_http_status(:ok)
@@ -73,8 +72,10 @@ RSpec.describe API::DeployTokens, feature_category: :continuous_delivery do
end
describe 'GET /projects/:id/deploy_tokens' do
+ let(:path) { "/projects/#{project.id}/deploy_tokens" }
+
subject do
- get api("/projects/#{project.id}/deploy_tokens", user)
+ get api(path, user)
response
end
@@ -134,8 +135,10 @@ RSpec.describe API::DeployTokens, feature_category: :continuous_delivery do
end
describe 'GET /projects/:id/deploy_tokens/:token_id' do
+ let(:path) { "/projects/#{project.id}/deploy_tokens/#{deploy_token.id}" }
+
subject do
- get api("/projects/#{project.id}/deploy_tokens/#{deploy_token.id}", user)
+ get api(path, user)
response
end
@@ -183,8 +186,10 @@ RSpec.describe API::DeployTokens, feature_category: :continuous_delivery do
end
describe 'GET /groups/:id/deploy_tokens' do
+ let(:path) { "/groups/#{group.id}/deploy_tokens" }
+
subject do
- get api("/groups/#{group.id}/deploy_tokens", user)
+ get api(path, user)
response
end
@@ -241,8 +246,10 @@ RSpec.describe API::DeployTokens, feature_category: :continuous_delivery do
end
describe 'GET /groups/:id/deploy_tokens/:token_id' do
+ let(:path) { "/groups/#{group.id}/deploy_tokens/#{group_deploy_token.id}" }
+
subject do
- get api("/groups/#{group.id}/deploy_tokens/#{group_deploy_token.id}", user)
+ get api(path, user)
response
end
@@ -290,8 +297,10 @@ RSpec.describe API::DeployTokens, feature_category: :continuous_delivery do
end
describe 'DELETE /projects/:id/deploy_tokens/:token_id' do
+ let(:path) { "/projects/#{project.id}/deploy_tokens/#{deploy_token.id}" }
+
subject do
- delete api("/projects/#{project.id}/deploy_tokens/#{deploy_token.id}", user)
+ delete api(path, user)
response
end
@@ -455,8 +464,10 @@ RSpec.describe API::DeployTokens, feature_category: :continuous_delivery do
end
describe 'DELETE /groups/:id/deploy_tokens/:token_id' do
+ let(:path) { "/groups/#{group.id}/deploy_tokens/#{group_deploy_token.id}" }
+
subject do
- delete api("/groups/#{group.id}/deploy_tokens/#{group_deploy_token.id}", user)
+ delete api(path, user)
response
end
diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb
index efe76c9cfda..3ca54cd40d0 100644
--- a/spec/requests/api/deployments_spec.rb
+++ b/spec/requests/api/deployments_spec.rb
@@ -47,11 +47,15 @@ RSpec.describe API::Deployments, feature_category: :continuous_delivery do
end
context 'when forbidden order_by is specified' do
+ before do
+ stub_feature_flags(deployments_raise_updated_at_inefficient_error_override: false)
+ end
+
it 'returns an error' do
perform_request({ updated_before: 30.minutes.ago, updated_after: 90.minutes.ago, order_by: :id })
expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['message']).to include('`updated_at` filter and `updated_at` sorting must be paired')
+ expect(json_response['message']).to include('`updated_at` filter requires `updated_at` sort')
end
end
end
diff --git a/spec/requests/api/doorkeeper_access_spec.rb b/spec/requests/api/doorkeeper_access_spec.rb
index 5116f074894..888220c2251 100644
--- a/spec/requests/api/doorkeeper_access_spec.rb
+++ b/spec/requests/api/doorkeeper_access_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'doorkeeper access', feature_category: :authentication_and_authorization do
+RSpec.describe 'doorkeeper access', feature_category: :system_access do
let!(:user) { create(:user) }
let!(:application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) }
let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "api" }
diff --git a/spec/requests/api/draft_notes_spec.rb b/spec/requests/api/draft_notes_spec.rb
index e8f519e004d..3911bb8bc00 100644
--- a/spec/requests/api/draft_notes_spec.rb
+++ b/spec/requests/api/draft_notes_spec.rb
@@ -8,11 +8,16 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
let_it_be(:project) { create(:project, :public) }
let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) }
+ let_it_be(:private_project) { create(:project, :private) }
+ let_it_be(:private_merge_request) do
+ create(:merge_request, source_project: private_project, target_project: private_project)
+ end
+
let_it_be(:merge_request_note) { create(:note, noteable: merge_request, project: project, author: user) }
let!(:draft_note_by_current_user) { create(:draft_note, merge_request: merge_request, author: user) }
let!(:draft_note_by_random_user) { create(:draft_note, merge_request: merge_request) }
- let_it_be(:api_stub) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}" }
+ let_it_be(:base_url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes" }
before do
project.add_developer(user)
@@ -20,13 +25,13 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
describe "Get a list of merge request draft notes" do
it "returns 200 OK status" do
- get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes", user)
+ get api(base_url, user)
expect(response).to have_gitlab_http_status(:ok)
end
it "returns only draft notes authored by the current user" do
- get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes", user)
+ get api(base_url, user)
draft_note_ids = json_response.pluck("id")
@@ -40,7 +45,7 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
context "when requesting an existing draft note by the user" do
before do
get api(
- "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes/#{draft_note_by_current_user.id}",
+ "#{base_url}/#{draft_note_by_current_user.id}",
user
)
end
@@ -56,7 +61,7 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
context "when requesting a non-existent draft note" do
it "returns a 404 Not Found response" do
get api(
- "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes/#{DraftNote.last.id + 1}",
+ "#{base_url}/#{DraftNote.last.id + 1}",
user
)
@@ -67,7 +72,7 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
context "when requesting an existing draft note by another user" do
it "returns a 404 Not Found response" do
get api(
- "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes/#{draft_note_by_random_user.id}",
+ "#{base_url}/#{draft_note_by_random_user.id}",
user
)
@@ -83,7 +88,7 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
before do
delete api(
- "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes/#{draft_note_by_current_user.id}",
+ "#{base_url}/#{draft_note_by_current_user.id}",
user
)
end
@@ -100,7 +105,7 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
context "when deleting a non-existent draft note" do
it "returns a 404 Not Found" do
delete api(
- "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes/#{non_existing_record_id}",
+ "#{base_url}/#{non_existing_record_id}",
user
)
@@ -111,7 +116,7 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
context "when deleting a draft note by a different user" do
it "returns a 404 Not Found" do
delete api(
- "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes/#{draft_note_by_random_user.id}",
+ "#{base_url}/#{draft_note_by_random_user.id}",
user
)
@@ -120,10 +125,152 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
end
end
+ def create_draft_note(params = {}, url = base_url)
+ post api(url, user), params: params
+ end
+
+ describe "Create a new draft note" do
+ let(:basic_create_params) do
+ {
+ note: "Example body string"
+ }
+ end
+
+ context "when creating a new draft note" do
+ context "with required params" do
+ it "returns 201 Created status" do
+ create_draft_note(basic_create_params)
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+
+ it "creates a new draft note with the submitted params" do
+ expect { create_draft_note(basic_create_params) }.to change { DraftNote.count }.by(1)
+
+ expect(json_response["note"]).to eq(basic_create_params[:note])
+ expect(json_response["merge_request_id"]).to eq(merge_request.id)
+ expect(json_response["author_id"]).to eq(user.id)
+ end
+ end
+
+ context "without required params" do
+ it "returns 400 Bad Request status" do
+ create_draft_note({})
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context "when providing a non-existing commit_id" do
+ it "returns a 400 Bad Request" do
+ create_draft_note(
+ basic_create_params.merge(
+ commit_id: 'bad SHA'
+ )
+ )
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context "when targeting a merge request the user doesn't have access to" do
+ it "returns a 404 Not Found" do
+ create_draft_note(
+ basic_create_params,
+ "/projects/#{private_project.id}/merge_requests/#{private_merge_request.iid}"
+ )
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context "when attempting to resolve a disscussion" do
+ context "when providing a non-existant ID" do
+ it "returns a 400 Bad Request" do
+ create_draft_note(
+ basic_create_params.merge(
+ resolve_discussion: true,
+ in_reply_to_discussion_id: non_existing_record_id
+ )
+ )
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context "when not providing an ID" do
+ it "returns a 400 Bad Request" do
+ create_draft_note(basic_create_params.merge(resolve_discussion: true))
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it "returns a validation error message" do
+ create_draft_note(basic_create_params.merge(resolve_discussion: true))
+
+ expect(response.body)
+ .to eq("{\"message\":{\"base\":[\"User is not allowed to resolve thread\"]}}")
+ end
+ end
+ end
+ end
+ end
+
+ def update_draft_note(params = {}, url = base_url)
+ put api("#{url}/#{draft_note_by_current_user.id}", user), params: params
+ end
+
+ describe "Update a draft note" do
+ let(:basic_update_params) do
+ {
+ note: "Example updated body string"
+ }
+ end
+
+ context "when updating an existing draft note" do
+ context "with required params" do
+ it "returns 200 Success status" do
+ update_draft_note(basic_update_params)
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+
+ it "updates draft note with the new content" do
+ update_draft_note(basic_update_params)
+
+ expect(json_response["note"]).to eq(basic_update_params[:note])
+ end
+ end
+
+ context "without including an update to the note body" do
+ it "returns the draft note with no changes" do
+ expect { update_draft_note({}) }
+ .not_to change { draft_note_by_current_user.note }
+ end
+ end
+
+ context "when updating a non-existent draft note" do
+ it "returns a 404 Not Found" do
+ put api("#{base_url}/#{non_existing_record_id}", user), params: basic_update_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context "when updating a draft note by a different user" do
+ it "returns a 404 Not Found" do
+ put api("#{base_url}/#{draft_note_by_random_user.id}", user), params: basic_update_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
describe "Publishing a draft note" do
let(:publish_draft_note) do
put api(
- "#{api_stub}/draft_notes/#{draft_note_by_current_user.id}/publish",
+ "#{base_url}/#{draft_note_by_current_user.id}/publish",
user
)
end
@@ -144,7 +291,7 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
context "when publishing a non-existent draft note" do
it "returns a 404 Not Found" do
put api(
- "#{api_stub}/draft_notes/#{non_existing_record_id}/publish",
+ "#{base_url}/#{non_existing_record_id}/publish",
user
)
@@ -155,7 +302,7 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
context "when publishing a draft note by a different user" do
it "returns a 404 Not Found" do
put api(
- "#{api_stub}/draft_notes/#{draft_note_by_random_user.id}/publish",
+ "#{base_url}/#{draft_note_by_random_user.id}/publish",
user
)
@@ -175,4 +322,47 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
end
end
end
+
+ describe "Bulk publishing draft notes" do
+ let(:bulk_publish_draft_notes) do
+ post api(
+ "#{base_url}/bulk_publish",
+ user
+ )
+ end
+
+ let!(:draft_note_by_current_user_2) { create(:draft_note, merge_request: merge_request, author: user) }
+
+ context "when publishing an existing draft note by the user" do
+ it "returns 204 No Content status" do
+ bulk_publish_draft_notes
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+
+ it "publishes the specified draft notes" do
+ expect { bulk_publish_draft_notes }.to change { Note.count }.by(2)
+ expect(DraftNote.exists?(draft_note_by_current_user.id)).to eq(false)
+ expect(DraftNote.exists?(draft_note_by_current_user_2.id)).to eq(false)
+ end
+
+ it "only publishes the user's draft notes" do
+ bulk_publish_draft_notes
+
+ expect(DraftNote.exists?(draft_note_by_random_user.id)).to eq(true)
+ end
+ end
+
+ context "when DraftNotes::PublishService returns a non-success" do
+ it "returns an :internal_server_error and a message" do
+ expect_next_instance_of(DraftNotes::PublishService) do |instance|
+ expect(instance).to receive(:execute).and_return({ status: :failure, message: "Error message" })
+ end
+
+ bulk_publish_draft_notes
+
+ expect(response).to have_gitlab_http_status(:internal_server_error)
+ end
+ end
+ end
end
diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb
index 6164555ad19..9a435b3bce9 100644
--- a/spec/requests/api/environments_spec.rb
+++ b/spec/requests/api/environments_spec.rb
@@ -72,30 +72,11 @@ RSpec.describe API::Environments, feature_category: :continuous_delivery do
end
context "when params[:search] is less than #{described_class::MIN_SEARCH_LENGTH} characters" do
- before do
- stub_feature_flags(environment_search_api_min_chars: false)
- end
-
- it 'returns a normal response' do
+ it 'returns with status 400' do
get api("/projects/#{project.id}/environments?search=ab", user)
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.size).to eq(0)
- end
-
- context 'and environment_search_api_min_chars flag is enabled for the project' do
- before do
- stub_feature_flags(environment_search_api_min_chars: project)
- end
-
- it 'returns with status 400' do
- get api("/projects/#{project.id}/environments?search=ab", user)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['message']).to include("Search query is less than #{described_class::MIN_SEARCH_LENGTH} characters")
- end
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to include("Search query is less than #{described_class::MIN_SEARCH_LENGTH} characters")
end
end
@@ -229,14 +210,13 @@ RSpec.describe API::Environments, feature_category: :continuous_delivery do
end
describe 'PUT /projects/:id/environments/:environment_id' do
- it 'returns a 200 if name and external_url are changed' do
+ it 'returns a 200 if external_url is changed' do
url = 'https://mepmep.whatever.ninja'
put api("/projects/#{project.id}/environments/#{environment.id}", user),
- params: { name: 'Mepmep', external_url: url }
+ params: { external_url: url }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/environment')
- expect(json_response['name']).to eq('Mepmep')
expect(json_response['external_url']).to eq(url)
end
@@ -258,16 +238,6 @@ RSpec.describe API::Environments, feature_category: :continuous_delivery do
expect(json_response["error"]).to eq("slug is automatically generated and cannot be changed")
end
- it "won't update the external_url if only the name is passed" do
- url = environment.external_url
- put api("/projects/#{project.id}/environments/#{environment.id}", user),
- params: { name: 'Mepmep' }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['name']).to eq('Mepmep')
- expect(json_response['external_url']).to eq(url)
- end
-
it 'returns a 404 if the environment does not exist' do
put api("/projects/#{project.id}/environments/#{non_existing_record_id}", user)
diff --git a/spec/requests/api/error_tracking/project_settings_spec.rb b/spec/requests/api/error_tracking/project_settings_spec.rb
index 5906cdf105a..bde90627983 100644
--- a/spec/requests/api/error_tracking/project_settings_spec.rb
+++ b/spec/requests/api/error_tracking/project_settings_spec.rb
@@ -4,9 +4,9 @@ require 'spec_helper'
RSpec.describe API::ErrorTracking::ProjectSettings, feature_category: :error_tracking do
let_it_be(:user) { create(:user) }
-
- let(:setting) { create(:project_error_tracking_setting) }
- let(:project) { setting.project }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:setting) { create(:project_error_tracking_setting, project: project) }
+ let_it_be(:project_without_setting) { create(:project) }
shared_examples 'returns project settings' do
it 'returns correct project settings' do
@@ -38,7 +38,7 @@ RSpec.describe API::ErrorTracking::ProjectSettings, feature_category: :error_tra
end
end
- shared_examples 'returns 404' do
+ shared_examples 'returns no project settings' do
it 'returns no project settings' do
make_request
@@ -48,8 +48,60 @@ RSpec.describe API::ErrorTracking::ProjectSettings, feature_category: :error_tra
end
end
+ shared_examples 'returns 400' do
+ it 'rejects request' do
+ make_request
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ shared_examples 'returns 401' do
+ it 'rejects request' do
+ make_request
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ shared_examples 'returns 403' do
+ it 'rejects request' do
+ make_request
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ shared_examples 'returns 404' do
+ it 'rejects request' do
+ make_request
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ shared_examples 'returns 400 with `integrated` param required or invalid' do |error|
+ it 'returns 400' do
+ make_request
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error'])
+ .to eq(error)
+ end
+ end
+
+ shared_examples "returns error from UpdateService" do
+ it "returns errors" do
+ make_request
+
+ expect(json_response['http_status']).to eq('forbidden')
+ expect(json_response['message']).to eq('An error occurred')
+ end
+ end
+
describe "PATCH /projects/:id/error_tracking/settings" do
- let(:params) { { active: false } }
+ let(:params) { { active: false, integrated: integrated } }
+ let(:integrated) { false }
def make_request
patch api("/projects/#{project.id}/error_tracking/settings", user), params: params
@@ -60,95 +112,97 @@ RSpec.describe API::ErrorTracking::ProjectSettings, feature_category: :error_tra
project.add_maintainer(user)
end
- context 'patch settings' do
- context 'integrated_error_tracking feature enabled' do
- it_behaves_like 'returns project settings'
- end
-
- context 'integrated_error_tracking feature disabled' do
- before do
- stub_feature_flags(integrated_error_tracking: false)
- end
+ context 'with integrated_error_tracking feature enabled' do
+ it_behaves_like 'returns project settings'
+ end
- it_behaves_like 'returns project settings with false for integrated'
+ context 'with integrated_error_tracking feature disabled' do
+ before do
+ stub_feature_flags(integrated_error_tracking: false)
end
- it 'updates enabled flag' do
- expect(setting).to be_enabled
+ it_behaves_like 'returns project settings with false for integrated'
+ end
- make_request
+ it 'updates enabled flag' do
+ expect(setting).to be_enabled
- expect(json_response).to include('active' => false)
- expect(setting.reload).not_to be_enabled
- end
+ make_request
+
+ expect(json_response).to include('active' => false)
+ expect(setting.reload).not_to be_enabled
+ end
- context 'active is invalid' do
- let(:params) { { active: "randomstring" } }
+ context 'when active is invalid' do
+ let(:params) { { active: "randomstring" } }
- it 'returns active is invalid if non boolean' do
- make_request
+ it 'returns active is invalid if non boolean' do
+ make_request
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error'])
- .to eq('active is invalid')
- end
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error'])
+ .to eq('active is invalid')
end
+ end
- context 'active is empty' do
- let(:params) { { active: '' } }
+ context 'when active is empty' do
+ let(:params) { { active: '' } }
- it 'returns 400' do
- make_request
+ it 'returns 400' do
+ make_request
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error'])
- .to eq('active is empty')
- end
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error'])
+ .to eq('active is empty')
end
+ end
- context 'with integrated param' do
- let(:params) { { active: true, integrated: true } }
+ context 'with integrated param' do
+ let(:params) { { active: true, integrated: true } }
- context 'integrated_error_tracking feature enabled' do
- before do
- stub_feature_flags(integrated_error_tracking: true)
- end
+ context 'when integrated_error_tracking feature enabled' do
+ before do
+ stub_feature_flags(integrated_error_tracking: true)
+ end
- it 'updates the integrated flag' do
- expect(setting.integrated).to be_falsey
+ it 'updates the integrated flag' do
+ expect(setting.integrated).to be_falsey
- make_request
+ make_request
- expect(json_response).to include('integrated' => true)
- expect(setting.reload.integrated).to be_truthy
- end
+ expect(json_response).to include('integrated' => true)
+ expect(setting.reload.integrated).to be_truthy
end
end
end
context 'without a project setting' do
- let(:project) { create(:project) }
+ let(:project) { project_without_setting }
before do
project.add_maintainer(user)
end
- context 'patch settings' do
- it_behaves_like 'returns 404'
- end
+ it_behaves_like 'returns no project settings'
end
- end
- context 'when authenticated as reporter' do
- before do
- project.add_reporter(user)
- end
+ context "when ::Projects::Operations::UpdateService responds with an error" do
+ before do
+ allow_next_instance_of(::Projects::Operations::UpdateService) do |service|
+ allow(service)
+ .to receive(:execute)
+ .and_return({ status: :error, message: 'An error occurred', http_status: :forbidden })
+ end
+ end
- context 'patch request' do
- it 'returns 403' do
- make_request
+ context "when integrated" do
+ let(:integrated) { true }
- expect(response).to have_gitlab_http_status(:forbidden)
+ it_behaves_like 'returns error from UpdateService'
+ end
+
+ context "without integrated" do
+ it_behaves_like 'returns error from UpdateService'
end
end
end
@@ -158,35 +212,17 @@ RSpec.describe API::ErrorTracking::ProjectSettings, feature_category: :error_tra
project.add_developer(user)
end
- context 'patch request' do
- it 'returns 403' do
- make_request
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
+ it_behaves_like 'returns 403'
end
context 'when authenticated as non-member' do
- context 'patch request' do
- it 'returns 404' do
- make_request
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
+ it_behaves_like 'returns 404'
end
context 'when unauthenticated' do
let(:user) { nil }
- context 'patch request' do
- it 'returns 401 for update request' do
- make_request
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
+ it_behaves_like 'returns 401'
end
end
@@ -200,77 +236,152 @@ RSpec.describe API::ErrorTracking::ProjectSettings, feature_category: :error_tra
project.add_maintainer(user)
end
- context 'get settings' do
- context 'integrated_error_tracking feature enabled' do
- before do
- stub_feature_flags(integrated_error_tracking: true)
- end
+ it_behaves_like 'returns project settings'
- it_behaves_like 'returns project settings'
+ context 'when integrated_error_tracking feature disabled' do
+ before do
+ stub_feature_flags(integrated_error_tracking: false)
end
- context 'integrated_error_tracking feature disabled' do
- before do
- stub_feature_flags(integrated_error_tracking: false)
- end
-
- it_behaves_like 'returns project settings with false for integrated'
- end
+ it_behaves_like 'returns project settings with false for integrated'
end
end
context 'without a project setting' do
- let(:project) { create(:project) }
+ let(:project) { project_without_setting }
before do
project.add_maintainer(user)
end
- context 'get settings' do
- it_behaves_like 'returns 404'
- end
+ it_behaves_like 'returns no project settings'
end
- context 'when authenticated as reporter' do
+ context 'when authenticated as developer' do
before do
- project.add_reporter(user)
+ project.add_developer(user)
end
- it 'returns 403' do
- make_request
+ it_behaves_like 'returns 403'
+ end
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ context 'when authenticated as non-member' do
+ it_behaves_like 'returns 404'
end
- context 'when authenticated as developer' do
- before do
- project.add_developer(user)
- end
+ context 'when unauthenticated' do
+ let(:user) { nil }
- it 'returns 403' do
- make_request
+ it_behaves_like 'returns 401'
+ end
+ end
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ describe "PUT /projects/:id/error_tracking/settings" do
+ let(:params) { { active: active, integrated: integrated } }
+ let(:active) { true }
+ let(:integrated) { true }
+
+ def make_request
+ put api("/projects/#{project.id}/error_tracking/settings", user), params: params
end
- context 'when authenticated as non-member' do
- it 'returns 404' do
- make_request
+ context 'when authenticated' do
+ context 'as maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context "when integrated" do
+ context "with existing setting" do
+ let(:project) { setting.project }
+ let(:setting) { create(:project_error_tracking_setting, :integrated) }
+ let(:active) { false }
+
+ it "updates a setting" do
+ expect { make_request }.not_to change { ErrorTracking::ProjectErrorTrackingSetting.count }
- expect(response).to have_gitlab_http_status(:not_found)
+ expect(response).to have_gitlab_http_status(:ok)
+
+ expect(json_response).to eq(
+ "active" => false,
+ "api_url" => nil,
+ "integrated" => integrated,
+ "project_name" => nil,
+ "sentry_external_url" => nil
+ )
+ end
+ end
+
+ context "without setting" do
+ let(:project) { project_without_setting }
+ let(:active) { true }
+
+ it "creates a setting" do
+ expect { make_request }.to change { ErrorTracking::ProjectErrorTrackingSetting.count }
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ expect(json_response).to eq(
+ "active" => true,
+ "api_url" => nil,
+ "integrated" => true,
+ "project_name" => nil,
+ "sentry_external_url" => nil
+ )
+ end
+ end
+
+ context "when ::Projects::Operations::UpdateService responds with an error" do
+ before do
+ allow_next_instance_of(::Projects::Operations::UpdateService) do |service|
+ allow(service)
+ .to receive(:execute)
+ .and_return({ status: :error, message: 'An error occurred', http_status: :forbidden })
+ end
+ end
+
+ it_behaves_like 'returns error from UpdateService'
+ end
+ end
+
+ context "when integrated_error_tracking feature disabled" do
+ before do
+ stub_feature_flags(integrated_error_tracking: false)
+ end
+
+ it_behaves_like 'returns 404'
+ end
+
+ context "when integrated param is invalid" do
+ let(:params) { { active: active, integrated: 'invalid_string' } }
+
+ it_behaves_like 'returns 400 with `integrated` param required or invalid', 'integrated is invalid'
+ end
+
+ context "when integrated param is missing" do
+ let(:params) { { active: active } }
+
+ it_behaves_like 'returns 400 with `integrated` param required or invalid', 'integrated is missing'
+ end
end
- end
- context 'when unauthenticated' do
- let(:user) { nil }
+ context "as developer" do
+ before do
+ project.add_developer(user)
+ end
- it 'returns 401' do
- make_request
+ it_behaves_like 'returns 403'
+ end
- expect(response).to have_gitlab_http_status(:unauthorized)
+ context 'as non-member' do
+ it_behaves_like 'returns 404'
end
end
+
+ context "when unauthorized" do
+ let(:user) { nil }
+
+ it_behaves_like 'returns 401'
+ end
end
end
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index f4066c54c47..ed84e3e5f48 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -55,6 +55,11 @@ RSpec.describe API::Files, feature_category: :source_code_management do
}
end
+ let(:last_commit_for_path) do
+ Gitlab::Git::Commit
+ .last_for_path(project.repository, 'master', Addressable::URI.unencode_component(file_path))
+ end
+
shared_context 'with author parameters' do
let(:author_email) { 'user@example.org' }
let(:author_name) { 'John Doe' }
@@ -136,6 +141,12 @@ RSpec.describe API::Files, feature_category: :source_code_management do
it 'caches sha256 of the content', :use_clean_rails_redis_caching do
head api(route(file_path), current_user, **options), params: params
+ expect(Gitlab::Cache::Client).to receive(:build_with_metadata).with(
+ cache_identifier: 'API::Files#content_sha',
+ feature_category: :source_code_management,
+ backing_resource: :gitaly
+ ).and_call_original
+
expect(Rails.cache.fetch("blob_content_sha256:#{project.full_path}:#{response.headers['X-Gitlab-Blob-Id']}"))
.to eq(content_sha256)
@@ -829,7 +840,6 @@ RSpec.describe API::Files, feature_category: :source_code_management do
expect_to_send_git_blob(api(url, current_user), params)
expect(response.headers['Cache-Control']).to eq('max-age=0, private, must-revalidate, no-store, no-cache')
- expect(response.headers['Pragma']).to eq('no-cache')
expect(response.headers['Expires']).to eq('Fri, 01 Jan 1990 00:00:00 GMT')
end
@@ -1180,7 +1190,7 @@ RSpec.describe API::Files, feature_category: :source_code_management do
end
context 'when updating an existing file with stale last commit id' do
- let(:params_with_stale_id) { params.merge(last_commit_id: 'stale') }
+ let(:params_with_stale_id) { params.merge(last_commit_id: last_commit_for_path.parent_id) }
it 'returns a 400 bad request' do
put api(route(file_path), user), params: params_with_stale_id
@@ -1191,12 +1201,7 @@ RSpec.describe API::Files, feature_category: :source_code_management do
end
context 'with correct last commit id' do
- let(:last_commit) do
- Gitlab::Git::Commit
- .last_for_path(project.repository, 'master', Addressable::URI.unencode_component(file_path))
- end
-
- let(:params_with_correct_id) { params.merge(last_commit_id: last_commit.id) }
+ let(:params_with_correct_id) { params.merge(last_commit_id: last_commit_for_path.id) }
it 'updates existing file in project repo' do
put api(route(file_path), user), params: params_with_correct_id
@@ -1206,12 +1211,7 @@ RSpec.describe API::Files, feature_category: :source_code_management do
end
context 'when file path is invalid' do
- let(:last_commit) do
- Gitlab::Git::Commit
- .last_for_path(project.repository, 'master', Addressable::URI.unencode_component(file_path))
- end
-
- let(:params_with_correct_id) { params.merge(last_commit_id: last_commit.id) }
+ let(:params_with_correct_id) { params.merge(last_commit_id: last_commit_for_path.id) }
it 'returns a 400 bad request' do
put api(route(invalid_file_path), user), params: params_with_correct_id
@@ -1222,12 +1222,7 @@ RSpec.describe API::Files, feature_category: :source_code_management do
end
it_behaves_like 'when path is absolute' do
- let(:last_commit) do
- Gitlab::Git::Commit
- .last_for_path(project.repository, 'master', Addressable::URI.unencode_component(file_path))
- end
-
- let(:params_with_correct_id) { params.merge(last_commit_id: last_commit.id) }
+ let(:params_with_correct_id) { params.merge(last_commit_id: last_commit_for_path.id) }
subject { put api(route(absolute_path), user), params: params_with_correct_id }
end
diff --git a/spec/requests/api/freeze_periods_spec.rb b/spec/requests/api/freeze_periods_spec.rb
index 170871706dc..b582c2e0f4e 100644
--- a/spec/requests/api/freeze_periods_spec.rb
+++ b/spec/requests/api/freeze_periods_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
+RSpec.describe API::FreezePeriods, :aggregate_failures, feature_category: :continuous_delivery do
let_it_be(:project) { create(:project, :repository, :private) }
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create(:admin) }
@@ -12,11 +12,18 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
let(:last_freeze_period) { project.freeze_periods.last }
describe 'GET /projects/:id/freeze_periods' do
+ let(:path) { "/projects/#{project.id}/freeze_periods" }
+
+ it_behaves_like 'GET request permissions for admin mode' do
+ let!(:freeze_period) { create(:ci_freeze_period, project: project, created_at: 2.days.ago) }
+ let(:failed_status_code) { :not_found }
+ end
+
context 'when the user is the admin' do
let!(:freeze_period) { create(:ci_freeze_period, project: project, created_at: 2.days.ago) }
it 'returns 200 HTTP status' do
- get api("/projects/#{project.id}/freeze_periods", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -32,20 +39,20 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
let!(:freeze_period_2) { create(:ci_freeze_period, project: project, created_at: 1.day.ago) }
it 'returns 200 HTTP status' do
- get api("/projects/#{project.id}/freeze_periods", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
end
it 'returns freeze_periods ordered by created_at ascending' do
- get api("/projects/#{project.id}/freeze_periods", user)
+ get api(path, user)
expect(json_response.count).to eq(2)
expect(freeze_period_ids).to eq([freeze_period_1.id, freeze_period_2.id])
end
it 'matches response schema' do
- get api("/projects/#{project.id}/freeze_periods", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/freeze_periods')
end
@@ -53,13 +60,13 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
context 'when there are no freeze_periods' do
it 'returns 200 HTTP status' do
- get api("/projects/#{project.id}/freeze_periods", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
end
it 'returns an empty response' do
- get api("/projects/#{project.id}/freeze_periods", user)
+ get api(path, user)
expect(json_response).to be_empty
end
@@ -76,7 +83,7 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
end
it 'responds 403 Forbidden' do
- get api("/projects/#{project.id}/freeze_periods", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -84,7 +91,7 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
context 'when user is not a project member' do
it 'responds 404 Not Found' do
- get api("/projects/#{project.id}/freeze_periods", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -93,7 +100,7 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
let(:project) { create(:project, :public) }
it 'responds 403 Forbidden' do
- get api("/projects/#{project.id}/freeze_periods", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -102,6 +109,16 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
end
describe 'GET /projects/:id/freeze_periods/:freeze_period_id' do
+ let(:path) { "/projects/#{project.id}/freeze_periods/#{freeze_period.id}" }
+
+ it_behaves_like 'GET request permissions for admin mode' do
+ let!(:freeze_period) do
+ create(:ci_freeze_period, project: project)
+ end
+
+ let(:failed_status_code) { :not_found }
+ end
+
context 'when there is a freeze period' do
let!(:freeze_period) do
create(:ci_freeze_period, project: project)
@@ -111,7 +128,7 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
let!(:freeze_period) { create(:ci_freeze_period, project: project, created_at: 2.days.ago) }
it 'responds 200 OK' do
- get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -123,13 +140,13 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
end
it 'responds 200 OK' do
- get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
end
it 'returns a freeze period' do
- get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+ get api(path, user)
expect(json_response).to include(
'id' => freeze_period.id,
@@ -139,7 +156,7 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
end
it 'matches response schema' do
- get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/freeze_period')
end
@@ -151,7 +168,7 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
end
it 'responds 403 Forbidden' do
- get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -161,7 +178,7 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
context 'when freeze_period exists' do
it 'responds 403 Forbidden' do
- get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -188,7 +205,15 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
}
end
- subject { post api("/projects/#{project.id}/freeze_periods", api_user), params: params }
+ let(:path) { "/projects/#{project.id}/freeze_periods" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
+
+ subject do
+ post api(path, api_user, admin_mode: api_user.admin?), params: params
+ end
context 'when the user is the admin' do
let(:api_user) { admin }
@@ -310,7 +335,10 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
let(:params) { { freeze_start: '0 22 * * 5', freeze_end: '5 4 * * sun' } }
let!(:freeze_period) { create :ci_freeze_period, project: project }
- subject { put api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", api_user), params: params }
+ subject do
+ put api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", api_user, admin_mode: api_user.admin?),
+ params: params
+ end
context 'when user is the admin' do
let(:api_user) { admin }
@@ -397,7 +425,9 @@ RSpec.describe API::FreezePeriods, feature_category: :continuous_delivery do
let!(:freeze_period) { create :ci_freeze_period, project: project }
let(:freeze_period_id) { freeze_period.id }
- subject { delete api("/projects/#{project.id}/freeze_periods/#{freeze_period_id}", api_user) }
+ subject do
+ delete api("/projects/#{project.id}/freeze_periods/#{freeze_period_id}", api_user, admin_mode: api_user.admin?)
+ end
context 'when user is the admin' do
let(:api_user) { admin }
diff --git a/spec/requests/api/graphql/achievements/user_achievements_query_spec.rb b/spec/requests/api/graphql/achievements/user_achievements_query_spec.rb
new file mode 100644
index 00000000000..080f375245d
--- /dev/null
+++ b/spec/requests/api/graphql/achievements/user_achievements_query_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'UserAchievements', feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+ let_it_be(:non_revoked_achievement1) { create(:user_achievement, achievement: achievement, user: user) }
+ let_it_be(:non_revoked_achievement2) { create(:user_achievement, :revoked, achievement: achievement, user: user) }
+ let_it_be(:fields) do
+ <<~HEREDOC
+ id
+ achievements {
+ nodes {
+ userAchievements {
+ nodes {
+ id
+ achievement {
+ id
+ }
+ user {
+ id
+ }
+ awardedByUser {
+ id
+ }
+ revokedByUser {
+ id
+ }
+ }
+ }
+ }
+ }
+ HEREDOC
+ end
+
+ let_it_be(:query) do
+ graphql_query_for('namespace', { full_path: group.full_path }, fields)
+ end
+
+ before_all do
+ group.add_guest(user)
+ end
+
+ before do
+ post_graphql(query, current_user: user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns all non_revoked user_achievements' do
+ expect(graphql_data_at(:namespace, :achievements, :nodes, :userAchievements, :nodes))
+ .to contain_exactly(
+ a_graphql_entity_for(non_revoked_achievement1)
+ )
+ end
+
+ it 'can lookahead to eliminate N+1 queries', :use_clean_rails_memory_store_caching do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post_graphql(query, current_user: user)
+ end.count
+
+ user2 = create(:user)
+ create(:user_achievement, achievement: achievement, user: user2)
+
+ expect { post_graphql(query, current_user: user) }.not_to exceed_all_query_limit(control_count)
+ end
+
+ context 'when the achievements feature flag is disabled' do
+ before do
+ stub_feature_flags(achievements: false)
+ post_graphql(query, current_user: user)
+ end
+
+ specify { expect(graphql_data_at(:namespace, :achievements, :nodes, :userAchievements, :nodes)).to be_empty }
+ end
+end
diff --git a/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb b/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb
index 95cabfea2fc..0437a30eccd 100644
--- a/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb
+++ b/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb
@@ -50,7 +50,6 @@ RSpec.describe 'Getting Ci Cd Setting', feature_category: :continuous_integratio
expect(settings_data['jobTokenScopeEnabled']).to eql project.ci_cd_settings.job_token_scope_enabled?
expect(settings_data['inboundJobTokenScopeEnabled']).to eql(
project.ci_cd_settings.inbound_job_token_scope_enabled?)
- expect(settings_data['optInJwt']).to eql project.ci_cd_settings.opt_in_jwt?
end
end
end
diff --git a/spec/requests/api/graphql/ci/config_variables_spec.rb b/spec/requests/api/graphql/ci/config_variables_spec.rb
index f76bb8ff837..4bad5dec684 100644
--- a/spec/requests/api/graphql/ci/config_variables_spec.rb
+++ b/spec/requests/api/graphql/ci/config_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Query.project(fullPath).ciConfigVariables(sha)', feature_category: :pipeline_authoring do
+RSpec.describe 'Query.project(fullPath).ciConfigVariables(ref)', feature_category: :secrets_management do
include GraphqlHelpers
include ReactiveCachingHelpers
@@ -20,7 +20,7 @@ RSpec.describe 'Query.project(fullPath).ciConfigVariables(sha)', feature_categor
%(
query {
project(fullPath: "#{project.full_path}") {
- ciConfigVariables(sha: "#{ref}") {
+ ciConfigVariables(ref: "#{ref}") {
key
value
valueOptions
diff --git a/spec/requests/api/graphql/ci/group_variables_spec.rb b/spec/requests/api/graphql/ci/group_variables_spec.rb
index d78b30787c9..3b8eeefb707 100644
--- a/spec/requests/api/graphql/ci/group_variables_spec.rb
+++ b/spec/requests/api/graphql/ci/group_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Query.group(fullPath).ciVariables', feature_category: :pipeline_authoring do
+RSpec.describe 'Query.group(fullPath).ciVariables', feature_category: :secrets_management do
include GraphqlHelpers
let_it_be(:group) { create(:group) }
diff --git a/spec/requests/api/graphql/ci/inherited_ci_variables_spec.rb b/spec/requests/api/graphql/ci/inherited_ci_variables_spec.rb
new file mode 100644
index 00000000000..3b4014c178c
--- /dev/null
+++ b/spec/requests/api/graphql/ci/inherited_ci_variables_spec.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.project(fullPath).inheritedCiVariables', feature_category: :secrets_management do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:project) { create(:project, group: subgroup) }
+
+ let(:query) do
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ inheritedCiVariables {
+ nodes {
+ id
+ key
+ environmentScope
+ groupName
+ groupCiCdSettingsPath
+ masked
+ protected
+ raw
+ variableType
+ }
+ }
+ }
+ }
+ )
+ end
+
+ def create_variables
+ create(:ci_group_variable, group: group)
+ create(:ci_group_variable, group: subgroup)
+ end
+
+ context 'when user is not a project maintainer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'returns nothing' do
+ post_graphql(query, current_user: user)
+
+ expect(graphql_data.dig('project', 'inheritedCiVariables')).to be_nil
+ end
+ end
+
+ context 'when user is a project maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it "returns the project's CI variables inherited from its parent group and ancestors" do
+ group_var = create(:ci_group_variable, group: group, key: 'GROUP_VAR_A',
+ environment_scope: 'production', masked: false, protected: true, raw: true)
+
+ subgroup_var = create(:ci_group_variable, group: subgroup, key: 'SUBGROUP_VAR_B',
+ masked: true, protected: false, raw: false, variable_type: 'file')
+
+ post_graphql(query, current_user: user)
+
+ expect(graphql_data.dig('project', 'inheritedCiVariables', 'nodes')).to eq([
+ {
+ 'id' => group_var.to_global_id.to_s,
+ 'key' => 'GROUP_VAR_A',
+ 'environmentScope' => 'production',
+ 'groupName' => group.name,
+ 'groupCiCdSettingsPath' => group_var.group_ci_cd_settings_path,
+ 'masked' => false,
+ 'protected' => true,
+ 'raw' => true,
+ 'variableType' => 'ENV_VAR'
+ },
+ {
+ 'id' => subgroup_var.to_global_id.to_s,
+ 'key' => 'SUBGROUP_VAR_B',
+ 'environmentScope' => '*',
+ 'groupName' => subgroup.name,
+ 'groupCiCdSettingsPath' => subgroup_var.group_ci_cd_settings_path,
+ 'masked' => true,
+ 'protected' => false,
+ 'raw' => false,
+ 'variableType' => 'FILE'
+ }
+ ])
+ end
+
+ it 'avoids N+1 database queries' do
+ create_variables
+
+ baseline = ActiveRecord::QueryRecorder.new do
+ run_with_clean_state(query, context: { current_user: user })
+ end
+
+ create_variables
+
+ multi = ActiveRecord::QueryRecorder.new do
+ run_with_clean_state(query, context: { current_user: user })
+ end
+
+ expect(multi).not_to exceed_query_limit(baseline)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/ci/instance_variables_spec.rb b/spec/requests/api/graphql/ci/instance_variables_spec.rb
index 5b65ae88426..a612b4c91b6 100644
--- a/spec/requests/api/graphql/ci/instance_variables_spec.rb
+++ b/spec/requests/api/graphql/ci/instance_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Query.ciVariables', feature_category: :pipeline_authoring do
+RSpec.describe 'Query.ciVariables', feature_category: :secrets_management do
include GraphqlHelpers
let(:query) do
diff --git a/spec/requests/api/graphql/ci/job_spec.rb b/spec/requests/api/graphql/ci/job_spec.rb
index 8121c5e5c85..960697db239 100644
--- a/spec/requests/api/graphql/ci/job_spec.rb
+++ b/spec/requests/api/graphql/ci/job_spec.rb
@@ -52,7 +52,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.job(id)', feature_category: :c
'duration' => 25,
'kind' => 'BUILD',
'queuedDuration' => 2.0,
- 'status' => job_2.status.upcase
+ 'status' => job_2.status.upcase,
+ 'failureMessage' => job_2.present.failure_message
)
end
diff --git a/spec/requests/api/graphql/ci/jobs_spec.rb b/spec/requests/api/graphql/ci/jobs_spec.rb
index 674407c0a0e..0d5ac725edd 100644
--- a/spec/requests/api/graphql/ci/jobs_spec.rb
+++ b/spec/requests/api/graphql/ci/jobs_spec.rb
@@ -1,6 +1,130 @@
# frozen_string_literal: true
require 'spec_helper'
+RSpec.describe 'Query.jobs', feature_category: :continuous_integration do
+ include GraphqlHelpers
+
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:project) { create(:project, :repository, :public) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be(:runner) { create(:ci_runner) }
+ let_it_be(:build) do
+ create(:ci_build, pipeline: pipeline, name: 'my test job', ref: 'HEAD', tag_list: %w[tag1 tag2], runner: runner)
+ end
+
+ let(:query) do
+ %(
+ query {
+ jobs {
+ nodes {
+ id
+ #{fields.join(' ')}
+ }
+ }
+ }
+ )
+ end
+
+ let(:jobs_graphql_data) { graphql_data_at(:jobs, :nodes) }
+
+ let(:fields) do
+ %w[commitPath refPath webPath browseArtifactsPath playPath tags runner{id}]
+ end
+
+ it 'returns the paths in each job of a pipeline' do
+ post_graphql(query, current_user: admin)
+
+ expect(jobs_graphql_data).to contain_exactly(
+ a_graphql_entity_for(
+ build,
+ commit_path: "/#{project.full_path}/-/commit/#{build.sha}",
+ ref_path: "/#{project.full_path}/-/commits/HEAD",
+ web_path: "/#{project.full_path}/-/jobs/#{build.id}",
+ browse_artifacts_path: "/#{project.full_path}/-/jobs/#{build.id}/artifacts/browse",
+ play_path: "/#{project.full_path}/-/jobs/#{build.id}/play",
+ tags: build.tag_list,
+ runner: a_graphql_entity_for(runner)
+ )
+ )
+ end
+
+ context 'when requesting individual fields' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:admin2) { create(:admin) }
+ let_it_be(:project2) { create(:project) }
+ let_it_be(:pipeline2) { create(:ci_pipeline, project: project2) }
+
+ where(:field) { fields }
+
+ with_them do
+ let(:fields) do
+ [field]
+ end
+
+ it 'does not generate N+1 queries', :request_store, :use_sql_query_cache do
+ # warm-up cache and so on:
+ args = { current_user: admin }
+ args2 = { current_user: admin2 }
+ post_graphql(query, **args2)
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post_graphql(query, **args)
+ end
+
+ create(:ci_build, pipeline: pipeline2, name: 'my test job2', ref: 'HEAD', tag_list: %w[tag3])
+ post_graphql(query, **args)
+
+ expect { post_graphql(query, **args) }.not_to exceed_all_query_limit(control)
+ end
+ end
+ end
+end
+
+RSpec.describe 'Query.jobs.runner', feature_category: :continuous_integration do
+ include GraphqlHelpers
+
+ let_it_be(:admin) { create(:admin) }
+
+ let(:jobs_runner_graphql_data) { graphql_data_at(:jobs, :nodes, :runner) }
+ let(:query) do
+ %(
+ query {
+ jobs {
+ nodes {
+ runner{
+ id
+ adminUrl
+ description
+ }
+ }
+ }
+ }
+ )
+ end
+
+ context 'when job has no runner' do
+ let_it_be(:build) { create(:ci_build) }
+
+ it 'returns nil' do
+ post_graphql(query, current_user: admin)
+
+ expect(jobs_runner_graphql_data).to eq([nil])
+ end
+ end
+
+ context 'when job has runner' do
+ let_it_be(:runner) { create(:ci_runner) }
+ let_it_be(:build_with_runner) { create(:ci_build, runner: runner) }
+
+ it 'returns runner attributes' do
+ post_graphql(query, current_user: admin)
+
+ expect(jobs_runner_graphql_data).to contain_exactly(a_graphql_entity_for(runner, :description, 'adminUrl' => "http://localhost/admin/runners/#{runner.id}"))
+ end
+ end
+end
+
RSpec.describe 'Query.project.pipeline', feature_category: :continuous_integration do
include GraphqlHelpers
@@ -260,6 +384,68 @@ RSpec.describe 'Query.project.pipeline', feature_category: :continuous_integrati
end
end
+ describe '.jobs.runnerManager' do
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:runner_manager) { create(:ci_runner_machine, created_at: Time.current, contacted_at: Time.current) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be(:build) do
+ create(:ci_build, pipeline: pipeline, name: 'my test job', runner_manager: runner_manager)
+ end
+
+ let(:query) do
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ pipeline(iid: "#{pipeline.iid}") {
+ jobs {
+ nodes {
+ id
+ name
+ runnerManager {
+ #{all_graphql_fields_for('CiRunnerManager', excluded: [:runner], max_depth: 1)}
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ end
+
+ let(:jobs_graphql_data) { graphql_data_at(:project, :pipeline, :jobs, :nodes) }
+
+ it 'returns the runner manager in each job of a pipeline' do
+ post_graphql(query, current_user: admin)
+
+ expect(jobs_graphql_data).to contain_exactly(
+ a_graphql_entity_for(
+ build,
+ name: build.name,
+ runner_manager: a_graphql_entity_for(
+ runner_manager,
+ system_id: runner_manager.system_xid,
+ created_at: runner_manager.created_at.iso8601,
+ contacted_at: runner_manager.contacted_at.iso8601,
+ status: runner_manager.status.to_s.upcase
+ )
+ )
+ )
+ end
+
+ it 'does not generate N+1 queries', :request_store, :use_sql_query_cache do
+ admin2 = create(:admin)
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post_graphql(query, current_user: admin)
+ end
+
+ runner_manager2 = create(:ci_runner_machine)
+ create(:ci_build, pipeline: pipeline, name: 'my test job2', runner_manager: runner_manager2)
+
+ expect { post_graphql(query, current_user: admin2) }.not_to exceed_all_query_limit(control)
+ end
+ end
+
describe '.jobs.count' do
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
let_it_be(:successful_job) { create(:ci_build, :success, pipeline: pipeline) }
diff --git a/spec/requests/api/graphql/ci/manual_variables_spec.rb b/spec/requests/api/graphql/ci/manual_variables_spec.rb
index 921c69e535d..47dccc0deb6 100644
--- a/spec/requests/api/graphql/ci/manual_variables_spec.rb
+++ b/spec/requests/api/graphql/ci/manual_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables', feature_category: :pipeline_authoring do
+RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables', feature_category: :secrets_management do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
diff --git a/spec/requests/api/graphql/ci/project_variables_spec.rb b/spec/requests/api/graphql/ci/project_variables_spec.rb
index 0ddcac89b34..62fc2623a0f 100644
--- a/spec/requests/api/graphql/ci/project_variables_spec.rb
+++ b/spec/requests/api/graphql/ci/project_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Query.project(fullPath).ciVariables', feature_category: :pipeline_authoring do
+RSpec.describe 'Query.project(fullPath).ciVariables', feature_category: :secrets_management do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
diff --git a/spec/requests/api/graphql/ci/runner_spec.rb b/spec/requests/api/graphql/ci/runner_spec.rb
index 986e3ce9e52..52b548ce8b9 100644
--- a/spec/requests/api/graphql/ci/runner_spec.rb
+++ b/spec/requests/api/graphql/ci/runner_spec.rb
@@ -6,11 +6,13 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
include GraphqlHelpers
let_it_be(:user) { create(:user, :admin) }
- let_it_be(:group) { create(:group) }
+ let_it_be(:another_admin) { create(:user, :admin) }
+ let_it_be_with_reload(:group) { create(:group) }
let_it_be(:active_instance_runner) do
- create(:ci_runner, :instance,
+ create(:ci_runner, :instance, :with_runner_manager,
description: 'Runner 1',
+ creator: user,
contacted_at: 2.hours.ago,
active: true,
version: 'adfe156',
@@ -28,6 +30,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
let_it_be(:inactive_instance_runner) do
create(:ci_runner, :instance,
description: 'Runner 2',
+ creator: another_admin,
contacted_at: 1.day.ago,
active: false,
version: 'adfe157',
@@ -55,7 +58,9 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
end
let_it_be(:project1) { create(:project) }
- let_it_be(:active_project_runner) { create(:ci_runner, :project, projects: [project1]) }
+ let_it_be(:active_project_runner) do
+ create(:ci_runner, :project, :with_runner_manager, projects: [project1])
+ end
shared_examples 'runner details fetch' do
let(:query) do
@@ -77,6 +82,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
expect(runner_data).to match a_graphql_entity_for(
runner,
description: runner.description,
+ created_by: runner.creator ? a_graphql_entity_for(runner.creator) : nil,
created_at: runner.created_at&.iso8601,
contacted_at: runner.contacted_at&.iso8601,
version: runner.version,
@@ -85,7 +91,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
locked: false,
active: runner.active,
paused: !runner.active,
- status: runner.status('14.5').to_s.upcase,
+ status: runner.status.to_s.upcase,
job_execution_status: runner.builds.running.any? ? 'RUNNING' : 'IDLE',
maximum_timeout: runner.maximum_timeout,
access_level: runner.access_level.to_s.upcase,
@@ -107,15 +113,39 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
),
project_count: nil,
admin_url: "http://localhost/admin/runners/#{runner.id}",
+ edit_admin_url: "http://localhost/admin/runners/#{runner.id}/edit",
+ register_admin_url: runner.registration_available? ? "http://localhost/admin/runners/#{runner.id}/register" : nil,
user_permissions: {
'readRunner' => true,
'updateRunner' => true,
'deleteRunner' => true,
'assignRunner' => true
- }
+ },
+ managers: a_hash_including(
+ "count" => runner.runner_managers.count,
+ "nodes" => an_instance_of(Array),
+ "pageInfo" => anything
+ )
)
expect(runner_data['tagList']).to match_array runner.tag_list
end
+
+ it 'does not execute more queries per runner', :use_sql_query_cache, :aggregate_failures do
+ # warm-up license cache and so on:
+ personal_access_token = create(:personal_access_token, user: user)
+ args = { current_user: user, token: { personal_access_token: personal_access_token } }
+ post_graphql(query, **args)
+ expect(graphql_data_at(:runner)).not_to be_nil
+
+ personal_access_token = create(:personal_access_token, user: another_admin)
+ args = { current_user: another_admin, token: { personal_access_token: personal_access_token } }
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) { post_graphql(query, **args) }
+
+ create(:ci_runner, :instance, version: '14.0.0', tag_list: %w[tag5 tag6], creator: another_admin)
+ create(:ci_runner, :project, version: '14.0.1', projects: [project1], tag_list: %w[tag3 tag8], creator: another_admin)
+
+ expect { post_graphql(query, **args) }.not_to exceed_all_query_limit(control)
+ end
end
shared_examples 'retrieval with no admin url' do
@@ -135,7 +165,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
runner_data = graphql_data_at(:runner)
expect(runner_data).not_to be_nil
- expect(runner_data).to match a_graphql_entity_for(runner, admin_url: nil)
+ expect(runner_data).to match a_graphql_entity_for(runner, admin_url: nil, edit_admin_url: nil)
expect(runner_data['tagList']).to match_array runner.tag_list
end
end
@@ -307,6 +337,24 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
it_behaves_like 'runner details fetch'
end
+ describe 'for registration type' do
+ context 'when registered with registration token' do
+ let(:runner) do
+ create(:ci_runner, registration_type: :registration_token)
+ end
+
+ it_behaves_like 'runner details fetch'
+ end
+
+ context 'when registered with authenticated user' do
+ let(:runner) do
+ create(:ci_runner, registration_type: :authenticated_user)
+ end
+
+ it_behaves_like 'runner details fetch'
+ end
+ end
+
describe 'for group runner request' do
let(:query) do
%(
@@ -330,24 +378,110 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
end
end
- describe 'for runner with status' do
- let_it_be(:stale_runner) { create(:ci_runner, description: 'Stale runner 1', created_at: 3.months.ago) }
- let_it_be(:never_contacted_instance_runner) { create(:ci_runner, description: 'Missing runner 1', created_at: 1.month.ago, contacted_at: nil) }
-
- let(:status_fragment) do
+ describe 'ephemeralRegisterUrl' do
+ let(:runner_args) { { registration_type: :authenticated_user, creator: creator } }
+ let(:query) do
%(
- status
- legacyStatusWithExplicitVersion: status(legacyMode: "14.5")
- newStatus: status(legacyMode: null)
+ query {
+ runner(id: "#{runner.to_global_id}") {
+ ephemeralRegisterUrl
+ }
+ }
)
end
+ shared_examples 'has register url' do
+ it 'retrieves register url' do
+ post_graphql(query, current_user: user)
+ expect(graphql_data_at(:runner, :ephemeral_register_url)).to eq(expected_url)
+ end
+ end
+
+ shared_examples 'has no register url' do
+ it 'retrieves no register url' do
+ post_graphql(query, current_user: user)
+ expect(graphql_data_at(:runner, :ephemeral_register_url)).to eq(nil)
+ end
+ end
+
+ context 'with an instance runner', :freeze_time do
+ let(:creator) { user }
+ let(:runner) { create(:ci_runner, **runner_args) }
+
+ context 'with valid ephemeral registration' do
+ it_behaves_like 'has register url' do
+ let(:expected_url) { "http://localhost/admin/runners/#{runner.id}/register" }
+ end
+ end
+
+ context 'when runner ephemeral registration has expired' do
+ let(:runner) do
+ create(:ci_runner, created_at: (Ci::Runner::REGISTRATION_AVAILABILITY_TIME + 1.second).ago, **runner_args)
+ end
+
+ it_behaves_like 'has no register url'
+ end
+
+ context 'when runner has already been registered' do
+ let(:runner) { create(:ci_runner, :with_runner_manager, **runner_args) }
+
+ it_behaves_like 'has no register url'
+ end
+ end
+
+ context 'with a group runner' do
+ let(:creator) { user }
+ let(:runner) { create(:ci_runner, :group, groups: [group], **runner_args) }
+
+ context 'with valid ephemeral registration' do
+ it_behaves_like 'has register url' do
+ let(:expected_url) { "http://localhost/groups/#{group.path}/-/runners/#{runner.id}/register" }
+ end
+ end
+
+ context 'when request not from creator' do
+ let(:creator) { another_admin }
+
+ before do
+ group.add_owner(another_admin)
+ end
+
+ it_behaves_like 'has no register url'
+ end
+ end
+
+ context 'with a project runner' do
+ let(:creator) { user }
+ let(:runner) { create(:ci_runner, :project, projects: [project1], **runner_args) }
+
+ context 'with valid ephemeral registration' do
+ it_behaves_like 'has register url' do
+ let(:expected_url) { "http://localhost/#{project1.full_path}/-/runners/#{runner.id}/register" }
+ end
+ end
+
+ context 'when request not from creator' do
+ let(:creator) { another_admin }
+
+ before do
+ project1.add_owner(another_admin)
+ end
+
+ it_behaves_like 'has no register url'
+ end
+ end
+ end
+
+ describe 'for runner with status' do
+ let_it_be(:stale_runner) { create(:ci_runner, description: 'Stale runner 1', created_at: 3.months.ago) }
+ let_it_be(:never_contacted_instance_runner) { create(:ci_runner, description: 'Missing runner 1', created_at: 1.month.ago, contacted_at: nil) }
+
let(:query) do
%(
query {
- staleRunner: runner(id: "#{stale_runner.to_global_id}") { #{status_fragment} }
- pausedRunner: runner(id: "#{inactive_instance_runner.to_global_id}") { #{status_fragment} }
- neverContactedInstanceRunner: runner(id: "#{never_contacted_instance_runner.to_global_id}") { #{status_fragment} }
+ staleRunner: runner(id: "#{stale_runner.to_global_id}") { status }
+ pausedRunner: runner(id: "#{inactive_instance_runner.to_global_id}") { status }
+ neverContactedInstanceRunner: runner(id: "#{never_contacted_instance_runner.to_global_id}") { status }
}
)
end
@@ -357,23 +491,17 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
stale_runner_data = graphql_data_at(:stale_runner)
expect(stale_runner_data).to match a_hash_including(
- 'status' => 'STALE',
- 'legacyStatusWithExplicitVersion' => 'STALE',
- 'newStatus' => 'STALE'
+ 'status' => 'STALE'
)
paused_runner_data = graphql_data_at(:paused_runner)
expect(paused_runner_data).to match a_hash_including(
- 'status' => 'PAUSED',
- 'legacyStatusWithExplicitVersion' => 'PAUSED',
- 'newStatus' => 'OFFLINE'
+ 'status' => 'OFFLINE'
)
never_contacted_instance_runner_data = graphql_data_at(:never_contacted_instance_runner)
expect(never_contacted_instance_runner_data).to match a_hash_including(
- 'status' => 'NEVER_CONTACTED',
- 'legacyStatusWithExplicitVersion' => 'NEVER_CONTACTED',
- 'newStatus' => 'NEVER_CONTACTED'
+ 'status' => 'NEVER_CONTACTED'
)
end
end
@@ -568,34 +696,34 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
end
end
- context 'with request made by creator' do
+ context 'with request made by creator', :frozen_time do
let(:user) { creator }
context 'with runner created in UI' do
let(:registration_type) { :authenticated_user }
- context 'with runner created in last 3 hours' do
- let(:created_at) { (3.hours - 1.second).ago }
+ context 'with runner created in last hour' do
+ let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago }
- context 'with no runner machine registed yet' do
+ context 'with no runner manager registered yet' do
it_behaves_like 'an ephemeral_authentication_token'
end
- context 'with first runner machine already registed' do
- let!(:runner_machine) { create(:ci_runner_machine, runner: runner) }
+ context 'with first runner manager already registered' do
+ let!(:runner_manager) { create(:ci_runner_machine, runner: runner) }
it_behaves_like 'a protected ephemeral_authentication_token'
end
end
context 'with runner created almost too long ago' do
- let(:created_at) { (3.hours - 1.second).ago }
+ let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago }
it_behaves_like 'an ephemeral_authentication_token'
end
context 'with runner created too long ago' do
- let(:created_at) { 3.hours.ago }
+ let(:created_at) { Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago }
it_behaves_like 'a protected ephemeral_authentication_token'
end
@@ -604,8 +732,8 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
context 'with runner registered from command line' do
let(:registration_type) { :registration_token }
- context 'with runner created in last 3 hours' do
- let(:created_at) { (3.hours - 1.second).ago }
+ context 'with runner created in last 1 hour' do
+ let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago }
it_behaves_like 'a protected ephemeral_authentication_token'
end
@@ -628,6 +756,12 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
<<~SINGLE
runner(id: "#{runner.to_global_id}") {
#{all_graphql_fields_for('CiRunner', excluded: excluded_fields)}
+ createdBy {
+ id
+ username
+ webPath
+ webUrl
+ }
groups {
nodes {
id
@@ -658,7 +792,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
let(:active_group_runner2) { create(:ci_runner, :group) }
# Exclude fields that are already hardcoded above
- let(:excluded_fields) { %w[jobs groups projects ownerProject] }
+ let(:excluded_fields) { %w[createdBy jobs groups projects ownerProject] }
let(:single_query) do
<<~QUERY
@@ -691,6 +825,8 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
control = ActiveRecord::QueryRecorder.new { post_graphql(single_query, **args) }
+ personal_access_token = create(:personal_access_token, user: another_admin)
+ args = { current_user: another_admin, token: { personal_access_token: personal_access_token } }
expect { post_graphql(double_query, **args) }.not_to exceed_query_limit(control)
expect(graphql_data.count).to eq 6
@@ -721,20 +857,20 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
end
describe 'Query limits with jobs' do
- let!(:group1) { create(:group) }
- let!(:group2) { create(:group) }
- let!(:project1) { create(:project, :repository, group: group1) }
- let!(:project2) { create(:project, :repository, group: group1) }
- let!(:project3) { create(:project, :repository, group: group2) }
+ let_it_be(:group1) { create(:group) }
+ let_it_be(:group2) { create(:group) }
+ let_it_be(:project1) { create(:project, :repository, group: group1) }
+ let_it_be(:project2) { create(:project, :repository, group: group1) }
+ let_it_be(:project3) { create(:project, :repository, group: group2) }
- let!(:merge_request1) { create(:merge_request, source_project: project1) }
- let!(:merge_request2) { create(:merge_request, source_project: project3) }
+ let_it_be(:merge_request1) { create(:merge_request, source_project: project1) }
+ let_it_be(:merge_request2) { create(:merge_request, source_project: project3) }
let(:project_runner2) { create(:ci_runner, :project, projects: [project1, project2]) }
let!(:build1) { create(:ci_build, :success, name: 'Build One', runner: project_runner2, pipeline: pipeline1) }
- let!(:pipeline1) do
+ let_it_be(:pipeline1) do
create(:ci_pipeline, project: project1, source: :merge_request_event, merge_request: merge_request1, ref: 'main',
- target_sha: 'xxx')
+ target_sha: 'xxx')
end
let(:query) do
@@ -745,24 +881,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
jobs {
nodes {
id
- detailedStatus {
- id
- detailsPath
- group
- icon
- text
- }
- project {
- id
- name
- webUrl
- }
- shortSha
- commitPath
- finishedAt
- duration
- queuedDuration
- tags
+ #{field}
}
}
}
@@ -770,42 +889,69 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
QUERY
end
- it 'does not execute more queries per job', :aggregate_failures do
- # warm-up license cache and so on:
- personal_access_token = create(:personal_access_token, user: user)
- args = { current_user: user, token: { personal_access_token: personal_access_token } }
- post_graphql(query, **args)
-
- control = ActiveRecord::QueryRecorder.new(query_recorder_debug: true) { post_graphql(query, **args) }
-
- # Add a new build to project_runner2
- project_runner2.runner_projects << build(:ci_runner_project, runner: project_runner2, project: project3)
- pipeline2 = create(:ci_pipeline, project: project3, source: :merge_request_event, merge_request: merge_request2,
- ref: 'main', target_sha: 'xxx')
- build2 = create(:ci_build, :success, name: 'Build Two', runner: project_runner2, pipeline: pipeline2)
+ context 'when requesting individual fields' do
+ using RSpec::Parameterized::TableSyntax
- args[:current_user] = create(:user, :admin) # do not reuse same user
- expect { post_graphql(query, **args) }.not_to exceed_all_query_limit(control)
+ where(:field) do
+ [
+ 'detailedStatus { id detailsPath group icon text }',
+ 'project { id name webUrl }'
+ ] + %w[
+ shortSha
+ browseArtifactsPath
+ commitPath
+ playPath
+ refPath
+ webPath
+ finishedAt
+ duration
+ queuedDuration
+ tags
+ ]
+ end
- expect(graphql_data.count).to eq 1
- expect(graphql_data).to match(
- a_hash_including(
- 'runner' => a_graphql_entity_for(
- project_runner2,
- jobs: { 'nodes' => containing_exactly(a_graphql_entity_for(build1), a_graphql_entity_for(build2)) }
- )
- ))
+ with_them do
+ it 'does not execute more queries per job', :use_sql_query_cache, :aggregate_failures do
+ admin2 = create(:user, :admin) # do not reuse same user
+
+ # warm-up license cache and so on:
+ personal_access_token = create(:personal_access_token, user: user)
+ personal_access_token2 = create(:personal_access_token, user: admin2)
+ args = { current_user: user, token: { personal_access_token: personal_access_token } }
+ args2 = { current_user: admin2, token: { personal_access_token: personal_access_token2 } }
+ post_graphql(query, **args2)
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) { post_graphql(query, **args) }
+
+ # Add a new build to project_runner2
+ project_runner2.runner_projects << build(:ci_runner_project, runner: project_runner2, project: project3)
+ pipeline2 = create(:ci_pipeline, project: project3, source: :merge_request_event, merge_request: merge_request2,
+ ref: 'main', target_sha: 'xxx')
+ build2 = create(:ci_build, :success, name: 'Build Two', runner: project_runner2, pipeline: pipeline2)
+
+ expect { post_graphql(query, **args2) }.not_to exceed_all_query_limit(control)
+
+ expect(graphql_data.count).to eq 1
+ expect(graphql_data).to match(
+ a_hash_including(
+ 'runner' => a_graphql_entity_for(
+ project_runner2,
+ jobs: { 'nodes' => containing_exactly(a_graphql_entity_for(build1), a_graphql_entity_for(build2)) }
+ )
+ ))
+ end
+ end
end
end
describe 'sorting and pagination' do
let(:query) do
<<~GQL
- query($id: CiRunnerID!, $projectSearchTerm: String, $n: Int, $cursor: String) {
- runner(id: $id) {
- #{fields}
+ query($id: CiRunnerID!, $projectSearchTerm: String, $n: Int, $cursor: String) {
+ runner(id: $id) {
+ #{fields}
+ }
}
- }
GQL
end
@@ -824,18 +970,18 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do
let(:fields) do
<<~QUERY
- projects(search: $projectSearchTerm, first: $n, after: $cursor) {
- count
- nodes {
- id
- }
- pageInfo {
- hasPreviousPage
- startCursor
- endCursor
- hasNextPage
+ projects(search: $projectSearchTerm, first: $n, after: $cursor) {
+ count
+ nodes {
+ id
+ }
+ pageInfo {
+ hasPreviousPage
+ startCursor
+ endCursor
+ hasNextPage
+ }
}
- }
QUERY
end
diff --git a/spec/requests/api/graphql/ci/runners_spec.rb b/spec/requests/api/graphql/ci/runners_spec.rb
index 75d8609dc38..c8706ae9698 100644
--- a/spec/requests/api/graphql/ci/runners_spec.rb
+++ b/spec/requests/api/graphql/ci/runners_spec.rb
@@ -11,16 +11,24 @@ RSpec.describe 'Query.runners', feature_category: :runner_fleet do
let_it_be(:instance_runner) { create(:ci_runner, :instance, version: 'abc', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') }
let_it_be(:project_runner) { create(:ci_runner, :project, active: false, version: 'def', revision: '456', description: 'Project runner', projects: [project], ip_address: '127.0.0.1') }
- let(:runners_graphql_data) { graphql_data['runners'] }
+ let(:runners_graphql_data) { graphql_data_at(:runners) }
let(:params) { {} }
let(:fields) do
<<~QUERY
nodes {
- #{all_graphql_fields_for('CiRunner', excluded: %w[ownerProject])}
+ #{all_graphql_fields_for('CiRunner', excluded: %w[createdBy ownerProject])}
+ createdBy {
+ username
+ webPath
+ webUrl
+ }
ownerProject {
id
+ path
+ fullPath
+ webUrl
}
}
QUERY
@@ -50,6 +58,25 @@ RSpec.describe 'Query.runners', feature_category: :runner_fleet do
it 'returns expected runner' do
expect(runners_graphql_data['nodes']).to contain_exactly(a_graphql_entity_for(expected_runner))
end
+
+ it 'does not execute more queries per runner', :aggregate_failures do
+ # warm-up license cache and so on:
+ personal_access_token = create(:personal_access_token, user: current_user)
+ args = { current_user: current_user, token: { personal_access_token: personal_access_token } }
+ post_graphql(query, **args)
+ expect(graphql_data_at(:runners, :nodes)).not_to be_empty
+
+ admin2 = create(:admin)
+ personal_access_token = create(:personal_access_token, user: admin2)
+ args = { current_user: admin2, token: { personal_access_token: personal_access_token } }
+ control = ActiveRecord::QueryRecorder.new { post_graphql(query, **args) }
+
+ create(:ci_runner, :instance, version: '14.0.0', tag_list: %w[tag5 tag6], creator: admin2)
+ create(:ci_runner, :project, version: '14.0.1', projects: [project], tag_list: %w[tag3 tag8],
+ creator: current_user)
+
+ expect { post_graphql(query, **args) }.not_to exceed_query_limit(control)
+ end
end
context 'runner_type is INSTANCE_TYPE and status is ACTIVE' do
diff --git a/spec/requests/api/graphql/current_user/todos_query_spec.rb b/spec/requests/api/graphql/current_user/todos_query_spec.rb
index f7e23aeb241..ee019a99f8d 100644
--- a/spec/requests/api/graphql/current_user/todos_query_spec.rb
+++ b/spec/requests/api/graphql/current_user/todos_query_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe 'Query current user todos', feature_category: :source_code_manage
let(:fields) do
<<~QUERY
nodes {
- #{all_graphql_fields_for('todos'.classify, max_depth: 2)}
+ #{all_graphql_fields_for('todos'.classify, max_depth: 2, excluded: ['productAnalyticsState'])}
}
QUERY
end
diff --git a/spec/requests/api/graphql/current_user_query_spec.rb b/spec/requests/api/graphql/current_user_query_spec.rb
index 53d2580caee..aceef77920d 100644
--- a/spec/requests/api/graphql/current_user_query_spec.rb
+++ b/spec/requests/api/graphql/current_user_query_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'getting project information', feature_category: :authentication_and_authorization do
+RSpec.describe 'getting project information', feature_category: :system_access do
include GraphqlHelpers
let(:fields) do
diff --git a/spec/requests/api/graphql/custom_emoji_query_spec.rb b/spec/requests/api/graphql/custom_emoji_query_spec.rb
index 7b804623e01..1858ea831dd 100644
--- a/spec/requests/api/graphql/custom_emoji_query_spec.rb
+++ b/spec/requests/api/graphql/custom_emoji_query_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'getting custom emoji within namespace', feature_category: :not_owned do
+RSpec.describe 'getting custom emoji within namespace', feature_category: :shared do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
diff --git a/spec/requests/api/graphql/group/data_transfer_spec.rb b/spec/requests/api/graphql/group/data_transfer_spec.rb
new file mode 100644
index 00000000000..b7c038afa54
--- /dev/null
+++ b/spec/requests/api/graphql/group/data_transfer_spec.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'group data transfers', feature_category: :source_code_management do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project_1) { create(:project, group: group) }
+ let_it_be(:project_2) { create(:project, group: group) }
+
+ let(:fields) do
+ <<~QUERY
+ #{all_graphql_fields_for('GroupDataTransfer'.classify)}
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'group',
+ { fullPath: group.full_path },
+ query_graphql_field('DataTransfer', params, fields)
+ )
+ end
+
+ let(:from) { Date.new(2022, 1, 1) }
+ let(:to) { Date.new(2023, 1, 1) }
+ let(:params) { { from: from, to: to } }
+ let(:egress_data) do
+ graphql_data.dig('group', 'dataTransfer', 'egressNodes', 'nodes')
+ end
+
+ before do
+ create(:project_data_transfer, project: project_1, date: '2022-01-01', repository_egress: 1)
+ create(:project_data_transfer, project: project_1, date: '2022-02-01', repository_egress: 2)
+ create(:project_data_transfer, project: project_2, date: '2022-02-01', repository_egress: 4)
+ end
+
+ subject { post_graphql(query, current_user: current_user) }
+
+ context 'with anonymous access' do
+ let_it_be(:current_user) { nil }
+
+ before do
+ subject
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns no data' do
+ expect(graphql_data_at(:group, :data_transfer)).to be_nil
+ expect(graphql_errors).to be_nil
+ end
+ end
+
+ context 'with authorized user but without enough permissions' do
+ before do
+ group.add_developer(current_user)
+ subject
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns empty results' do
+ expect(graphql_data_at(:group, :data_transfer)).to be_nil
+ expect(graphql_errors).to be_nil
+ end
+ end
+
+ context 'when user has enough permissions' do
+ before do
+ group.add_owner(current_user)
+ end
+
+ context 'when data_transfer_monitoring_mock_data is NOT enabled' do
+ before do
+ stub_feature_flags(data_transfer_monitoring_mock_data: false)
+ subject
+ end
+
+ it 'returns real results' do
+ expect(response).to have_gitlab_http_status(:ok)
+
+ expect(egress_data.count).to eq(2)
+
+ expect(egress_data.first.keys).to match_array(
+ %w[date totalEgress repositoryEgress artifactsEgress packagesEgress registryEgress]
+ )
+
+ expect(egress_data.pluck('repositoryEgress')).to match_array(%w[1 6])
+ end
+
+ it_behaves_like 'a working graphql query'
+ end
+
+ context 'when data_transfer_monitoring_mock_data is enabled' do
+ before do
+ stub_feature_flags(data_transfer_monitoring_mock_data: true)
+ subject
+ end
+
+ it 'returns mock results' do
+ expect(response).to have_gitlab_http_status(:ok)
+
+ expect(egress_data.count).to eq(12)
+ expect(egress_data.first.keys).to match_array(
+ %w[date totalEgress repositoryEgress artifactsEgress packagesEgress registryEgress]
+ )
+ end
+
+ it_behaves_like 'a working graphql query'
+ 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
index 2c4770a31a7..a6eb114a279 100644
--- a/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb
+++ b/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb
@@ -26,6 +26,7 @@ RSpec.describe 'getting dependency proxy blobs in a group', feature_category: :d
#{query_graphql_field('dependency_proxy_blobs', {}, dependency_proxy_blob_fields)}
dependencyProxyBlobCount
dependencyProxyTotalSize
+ dependencyProxyTotalSizeInBytes
GQL
end
@@ -42,6 +43,7 @@ RSpec.describe 'getting dependency proxy blobs in a group', feature_category: :d
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') }
+ let(:dependency_proxy_total_size_in_bytes_response) { graphql_data.dig('group', 'dependencyProxyTotalSizeInBytes') }
before do
stub_config(dependency_proxy: { enabled: true })
@@ -121,7 +123,13 @@ RSpec.describe 'getting dependency proxy blobs in a group', feature_category: :d
it 'returns the total size' do
subject
+ expected_size = ActiveSupport::NumberHelper.number_to_human_size(blobs.inject(0) { |sum, blob| sum + blob.size })
+ expect(dependency_proxy_total_size_response).to eq(expected_size)
+ end
+
+ it 'returns the total size in bytes' 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))
+ expect(dependency_proxy_total_size_in_bytes_response).to eq(expected_size)
end
end
diff --git a/spec/requests/api/graphql/group/labels_query_spec.rb b/spec/requests/api/graphql/group/labels_query_spec.rb
deleted file mode 100644
index 28886f8d80b..00000000000
--- a/spec/requests/api/graphql/group/labels_query_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'getting group label information', feature_category: :team_planning do
- include GraphqlHelpers
-
- let_it_be(:group) { create(:group, :public) }
- let_it_be(:label_factory) { :group_label }
- let_it_be(:label_attrs) { { group: group } }
-
- it_behaves_like 'querying a GraphQL type with labels' do
- let(:path_prefix) { ['group'] }
-
- def make_query(fields)
- graphql_query_for('group', { full_path: group.full_path }, fields)
- end
- end
-end
diff --git a/spec/requests/api/graphql/group/milestones_spec.rb b/spec/requests/api/graphql/group/milestones_spec.rb
index 28cd68493c0..209588835f2 100644
--- a/spec/requests/api/graphql/group/milestones_spec.rb
+++ b/spec/requests/api/graphql/group/milestones_spec.rb
@@ -35,12 +35,6 @@ RSpec.describe 'Milestones through GroupQuery', feature_category: :team_planning
end
context 'when filtering by timeframe' do
- it 'fetches milestones between start_date and due_date' do
- fetch_milestones(user, { start_date: now.to_s, end_date: (now + 2.days).to_s })
-
- expect_array_response(milestone_2.to_global_id.to_s, milestone_3.to_global_id.to_s)
- end
-
it 'fetches milestones between timeframe start and end arguments' do
today = Date.today
fetch_milestones(user, { timeframe: { start: today.to_s, end: (today + 2.days).to_s } })
diff --git a/spec/requests/api/graphql/issues_spec.rb b/spec/requests/api/graphql/issues_spec.rb
index e437e1bbcb0..a12049a9b2e 100644
--- a/spec/requests/api/graphql/issues_spec.rb
+++ b/spec/requests/api/graphql/issues_spec.rb
@@ -15,6 +15,7 @@ RSpec.describe 'getting an issue list at root level', feature_category: :team_pl
let_it_be(:project_b) { create(:project, :repository, :private, group: group1) }
let_it_be(:project_c) { create(:project, :repository, :public, group: group2) }
let_it_be(:project_d) { create(:project, :repository, :private, group: group2) }
+ let_it_be(:archived_project) { create(:project, :repository, :archived, group: group2) }
let_it_be(:milestone1) { create(:milestone, project: project_c, due_date: 10.days.from_now) }
let_it_be(:milestone2) { create(:milestone, project: project_d, due_date: 20.days.from_now) }
let_it_be(:milestone3) { create(:milestone, project: project_d, due_date: 30.days.from_now) }
@@ -83,6 +84,7 @@ RSpec.describe 'getting an issue list at root level', feature_category: :team_pl
)
end
+ let_it_be(:archived_issue) { create(:issue, project: archived_project) }
let_it_be(:issues, reload: true) { [issue_a, issue_b, issue_c, issue_d, issue_e] }
# we need to always provide at least one filter to the query so it doesn't fail
let_it_be(:base_params) { { iids: issues.map { |issue| issue.iid.to_s } } }
@@ -109,6 +111,38 @@ RSpec.describe 'getting an issue list at root level', feature_category: :team_pl
end
end
+ describe 'includeArchived filter' do
+ let(:base_params) { { iids: [archived_issue.iid.to_s] } }
+
+ it 'excludes issues from archived projects' do
+ post_query
+
+ issue_ids = graphql_dig_at(graphql_data_at('issues', 'nodes'), :id)
+
+ expect(issue_ids).not_to include(archived_issue.to_gid.to_s)
+ end
+
+ context 'when includeArchived is true' do
+ let(:issue_filter_params) { { include_archived: true } }
+
+ it 'includes issues from archived projects' do
+ post_query
+
+ issue_ids = graphql_dig_at(graphql_data_at('issues', 'nodes'), :id)
+
+ expect(issue_ids).to include(archived_issue.to_gid.to_s)
+ end
+ end
+ end
+
+ it 'excludes issues from archived projects' do
+ post_query
+
+ issue_ids = graphql_dig_at(graphql_data_at('issues', 'nodes'), :id)
+
+ expect(issue_ids).not_to include(archived_issue.to_gid.to_s)
+ end
+
context 'when no filters are provided' do
let(:all_query_params) { {} }
diff --git a/spec/requests/api/graphql/jobs_query_spec.rb b/spec/requests/api/graphql/jobs_query_spec.rb
index 0aea8e4c253..7607aeac6e0 100644
--- a/spec/requests/api/graphql/jobs_query_spec.rb
+++ b/spec/requests/api/graphql/jobs_query_spec.rb
@@ -5,17 +5,26 @@ require 'spec_helper'
RSpec.describe 'getting job information', feature_category: :continuous_integration do
include GraphqlHelpers
- let_it_be(:job) { create(:ci_build, :success, name: 'job1') }
-
let(:query) do
- graphql_query_for(:jobs)
+ graphql_query_for(
+ :jobs, {}, %(
+ count
+ nodes {
+ #{all_graphql_fields_for(::Types::Ci::JobType, max_depth: 1)}
+ })
+ )
end
+ let_it_be(:runner) { create(:ci_runner) }
+ let_it_be(:job) { create(:ci_build, :success, name: 'job1', runner: runner) }
+
+ subject(:request) { post_graphql(query, current_user: current_user) }
+
context 'when user is admin' do
let_it_be(:current_user) { create(:admin) }
- it 'has full access to all jobs', :aggregate_failure do
- post_graphql(query, current_user: current_user)
+ it 'has full access to all jobs', :aggregate_failures do
+ request
expect(graphql_data_at(:jobs, :count)).to eq(1)
expect(graphql_data_at(:jobs, :nodes)).to contain_exactly(a_graphql_entity_for(job))
@@ -25,14 +34,14 @@ RSpec.describe 'getting job information', feature_category: :continuous_integrat
let_it_be(:pending_job) { create(:ci_build, :pending) }
let_it_be(:failed_job) { create(:ci_build, :failed) }
- it 'gets pending jobs', :aggregate_failure do
+ it 'gets pending jobs', :aggregate_failures do
post_graphql(graphql_query_for(:jobs, { statuses: :PENDING }), current_user: current_user)
expect(graphql_data_at(:jobs, :count)).to eq(1)
expect(graphql_data_at(:jobs, :nodes)).to contain_exactly(a_graphql_entity_for(pending_job))
end
- it 'gets pending and failed jobs', :aggregate_failure do
+ it 'gets pending and failed jobs', :aggregate_failures do
post_graphql(graphql_query_for(:jobs, { statuses: [:PENDING, :FAILED] }), current_user: current_user)
expect(graphql_data_at(:jobs, :count)).to eq(2)
@@ -40,13 +49,27 @@ RSpec.describe 'getting job information', feature_category: :continuous_integrat
a_graphql_entity_for(failed_job)])
end
end
+
+ context 'when N+1 queries' do
+ it 'avoids N+1 queries successfully', :use_sql_query_cache do
+ post_graphql(query, current_user: current_user) # warmup
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post_graphql(query, current_user: current_user)
+ end
+
+ create(:ci_build, :success, name: 'job2', runner: create(:ci_runner))
+
+ expect { post_graphql(query, current_user: current_user) }.not_to exceed_all_query_limit(control)
+ end
+ end
end
context 'if the user is not an admin' do
let_it_be(:current_user) { create(:user) }
- it 'has no access to the jobs', :aggregate_failure do
- post_graphql(query, current_user: current_user)
+ it 'has no access to the jobs', :aggregate_failures do
+ request
expect(graphql_data_at(:jobs, :count)).to eq(0)
expect(graphql_data_at(:jobs, :nodes)).to match_array([])
diff --git a/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb b/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb
index 4dd47142c40..143bc1672f8 100644
--- a/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb
+++ b/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb
@@ -22,6 +22,7 @@ RSpec.describe 'Getting Metrics Dashboard Annotations', feature_category: :metri
create(:metrics_dashboard_annotation, environment: environment, starting_at: to.advance(minutes: 5), dashboard_path: path)
end
+ let(:remove_monitor_metrics) { false }
let(:args) { "from: \"#{from}\", to: \"#{to}\"" }
let(:fields) do
<<~QUERY
@@ -50,6 +51,7 @@ RSpec.describe 'Getting Metrics Dashboard Annotations', feature_category: :metri
end
before do
+ stub_feature_flags(remove_monitor_metrics: remove_monitor_metrics)
project.add_developer(current_user)
post_graphql(query, current_user: current_user)
end
@@ -85,4 +87,18 @@ RSpec.describe 'Getting Metrics Dashboard Annotations', feature_category: :metri
it_behaves_like 'a working graphql query'
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ let(:remove_monitor_metrics) { true }
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns nil' do
+ annotations = graphql_data.dig(
+ 'project', 'environments', 'nodes', 0, 'metricsDashboard', 'annotations'
+ )
+
+ expect(annotations).to be_nil
+ end
+ end
end
diff --git a/spec/requests/api/graphql/metrics/dashboard_query_spec.rb b/spec/requests/api/graphql/metrics/dashboard_query_spec.rb
index 8db0844c6d7..b7d9b59f5fe 100644
--- a/spec/requests/api/graphql/metrics/dashboard_query_spec.rb
+++ b/spec/requests/api/graphql/metrics/dashboard_query_spec.rb
@@ -45,7 +45,10 @@ RSpec.describe 'Getting Metrics Dashboard', feature_category: :metrics do
end
context 'for user with developer access' do
+ let(:remove_monitor_metrics) { false }
+
before do
+ stub_feature_flags(remove_monitor_metrics: remove_monitor_metrics)
project.add_developer(current_user)
post_graphql(query, current_user: current_user)
end
@@ -82,6 +85,18 @@ RSpec.describe 'Getting Metrics Dashboard', feature_category: :metrics do
expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["dashboard: can't be blank", "panel_groups: should be an array of panel_groups objects"])
end
end
+
+ context 'metrics dashboard feature is unavailable' do
+ let(:remove_monitor_metrics) { true }
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns nil' do
+ dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
+
+ expect(dashboard).to be_nil
+ end
+ end
end
context 'requested dashboard can not be found' do
diff --git a/spec/requests/api/graphql/multiplexed_queries_spec.rb b/spec/requests/api/graphql/multiplexed_queries_spec.rb
index 4d615d3eaa4..0a5c87ebef8 100644
--- a/spec/requests/api/graphql/multiplexed_queries_spec.rb
+++ b/spec/requests/api/graphql/multiplexed_queries_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe 'Multiplexed queries', feature_category: :not_owned do
+RSpec.describe 'Multiplexed queries', feature_category: :shared do
include GraphqlHelpers
it 'returns responses for multiple queries' do
diff --git a/spec/requests/api/graphql/mutations/achievements/award_spec.rb b/spec/requests/api/graphql/mutations/achievements/award_spec.rb
new file mode 100644
index 00000000000..9bc0751e924
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/achievements/award_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Achievements::Award, feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+ let_it_be(:recipient) { create(:user) }
+
+ let(:mutation) { graphql_mutation(:achievements_award, params) }
+ let(:achievement_id) { achievement&.to_global_id }
+ let(:recipient_id) { recipient&.to_global_id }
+ let(:params) do
+ {
+ achievement_id: achievement_id,
+ user_id: recipient_id
+ }
+ end
+
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
+ def mutation_response
+ graphql_mutation_response(:achievements_create)
+ end
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when the user does not have permission' do
+ let(:current_user) { developer }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not create an achievement' do
+ expect { subject }.not_to change { Achievements::UserAchievement.count }
+ end
+ end
+
+ context 'when the user has permission' do
+ let(:current_user) { maintainer }
+
+ context 'when the params are invalid' do
+ let(:achievement) { nil }
+
+ it 'returns the validation error' do
+ subject
+
+ expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)')
+ end
+ end
+
+ context 'when the recipient_id is invalid' do
+ let(:recipient_id) { "gid://gitlab/User/#{non_existing_record_id}" }
+
+ it 'returns the validation error' do
+ subject
+
+ expect(graphql_data_at(:achievements_award,
+ :errors)).to include("Couldn't find User with 'id'=#{non_existing_record_id}")
+ end
+ end
+
+ context 'when the achievement_id is invalid' do
+ let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" }
+
+ it 'returns the validation error' do
+ subject
+
+ expect(graphql_errors.to_s)
+ .to include("The resource that you are attempting to access does not exist or you don't have permission")
+ end
+ end
+
+ context 'when the feature flag is disabled' do
+ before do
+ stub_feature_flags(achievements: false)
+ end
+
+ it 'returns the relevant error' do
+ subject
+
+ expect(graphql_errors.to_s)
+ .to include("The resource that you are attempting to access does not exist or you don't have permission")
+ end
+ end
+
+ it 'creates an achievement' do
+ expect { subject }.to change { Achievements::UserAchievement.count }.by(1)
+ end
+
+ it 'returns the new achievement' do
+ subject
+
+ expect(graphql_data_at(:achievements_award, :user_achievement, :achievement, :id))
+ .to eq(achievement.to_global_id.to_s)
+ expect(graphql_data_at(:achievements_award, :user_achievement, :user, :id))
+ .to eq(recipient.to_global_id.to_s)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/achievements/delete_spec.rb b/spec/requests/api/graphql/mutations/achievements/delete_spec.rb
new file mode 100644
index 00000000000..276da4f46a8
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/achievements/delete_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Achievements::Delete, feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ let!(:achievement) { create(:achievement, namespace: group) }
+ let(:mutation) { graphql_mutation(:achievements_delete, params) }
+ let(:achievement_id) { achievement&.to_global_id }
+ let(:params) { { achievement_id: achievement_id } }
+
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
+ def mutation_response
+ graphql_mutation_response(:achievements_delete)
+ end
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when the user does not have permission' do
+ let(:current_user) { developer }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not revoke any achievements' do
+ expect { subject }.not_to change { Achievements::Achievement.count }
+ end
+ end
+
+ context 'when the user has permission' do
+ let(:current_user) { maintainer }
+
+ context 'when the params are invalid' do
+ let(:achievement) { nil }
+
+ it 'returns the validation error' do
+ subject
+
+ expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)')
+ end
+ end
+
+ context 'when the achievement_id is invalid' do
+ let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" }
+
+ it 'returns the validation error' do
+ subject
+
+ expect(graphql_errors.to_s)
+ .to include("The resource that you are attempting to access does not exist or you don't have permission")
+ end
+ end
+
+ context 'when the feature flag is disabled' do
+ before do
+ stub_feature_flags(achievements: false)
+ end
+
+ it 'returns the relevant error' do
+ subject
+
+ expect(graphql_errors.to_s)
+ .to include("The resource that you are attempting to access does not exist or you don't have permission")
+ end
+ end
+
+ it 'deletes the achievement' do
+ expect { subject }.to change { Achievements::Achievement.count }.by(-1)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/achievements/revoke_spec.rb b/spec/requests/api/graphql/mutations/achievements/revoke_spec.rb
new file mode 100644
index 00000000000..925a1bb9fcc
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/achievements/revoke_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Achievements::Revoke, feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+ let_it_be(:user_achievement) { create(:user_achievement, achievement: achievement) }
+
+ let(:mutation) { graphql_mutation(:achievements_revoke, params) }
+ let(:user_achievement_id) { user_achievement&.to_global_id }
+ let(:params) { { user_achievement_id: user_achievement_id } }
+
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
+ def mutation_response
+ graphql_mutation_response(:achievements_create)
+ end
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when the user does not have permission' do
+ let(:current_user) { developer }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not revoke any achievements' do
+ expect { subject }.not_to change { Achievements::UserAchievement.where(revoked_by_user_id: nil).count }
+ end
+ end
+
+ context 'when the user has permission' do
+ let(:current_user) { maintainer }
+
+ context 'when the params are invalid' do
+ let(:user_achievement) { nil }
+
+ it 'returns the validation error' do
+ subject
+
+ expect(graphql_errors.to_s).to include('invalid value for userAchievementId (Expected value to not be null)')
+ end
+ end
+
+ context 'when the user_achievement_id is invalid' do
+ let(:user_achievement_id) { "gid://gitlab/Achievements::UserAchievement/#{non_existing_record_id}" }
+
+ it 'returns the validation error' do
+ subject
+
+ expect(graphql_errors.to_s)
+ .to include("The resource that you are attempting to access does not exist or you don't have permission")
+ end
+ end
+
+ context 'when the feature flag is disabled' do
+ before do
+ stub_feature_flags(achievements: false)
+ end
+
+ it 'returns the relevant error' do
+ subject
+
+ expect(graphql_errors.to_s)
+ .to include("The resource that you are attempting to access does not exist or you don't have permission")
+ end
+ end
+
+ it 'revokes an achievement' do
+ expect { subject }.to change { Achievements::UserAchievement.where(revoked_by_user_id: nil).count }.by(-1)
+ end
+
+ it 'returns the revoked achievement' do
+ subject
+
+ expect(graphql_data_at(:achievements_revoke, :user_achievement, :achievement, :id))
+ .to eq(achievement.to_global_id.to_s)
+ expect(graphql_data_at(:achievements_revoke, :user_achievement, :revoked_by_user, :id))
+ .to eq(current_user.to_global_id.to_s)
+ expect(graphql_data_at(:achievements_revoke, :user_achievement, :revoked_at))
+ .not_to be_nil
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/achievements/update_spec.rb b/spec/requests/api/graphql/mutations/achievements/update_spec.rb
new file mode 100644
index 00000000000..b2bb01b564c
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/achievements/update_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Achievements::Update, feature_category: :user_profile do
+ include GraphqlHelpers
+ include WorkhorseHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ let!(:achievement) { create(:achievement, namespace: group) }
+ let(:mutation) { graphql_mutation(:achievements_update, params) }
+ let(:achievement_id) { achievement&.to_global_id }
+ let(:params) { { achievement_id: achievement_id, name: 'GitLab', avatar: avatar } }
+ let(:avatar) { nil }
+
+ subject { post_graphql_mutation_with_uploads(mutation, current_user: current_user) }
+
+ def mutation_response
+ graphql_mutation_response(:achievements_update)
+ end
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when the user does not have permission' do
+ let(:current_user) { developer }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not update the achievement' do
+ expect { subject }.not_to change { achievement.reload.name }
+ end
+ end
+
+ context 'when the user has permission' do
+ let(:current_user) { maintainer }
+
+ context 'when the params are invalid' do
+ let(:achievement) { nil }
+
+ it 'returns the validation error' do
+ subject
+
+ expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)')
+ end
+ end
+
+ context 'when the achievement_id is invalid' do
+ let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" }
+
+ it 'returns the validation error' do
+ subject
+
+ expect(graphql_errors.to_s)
+ .to include("The resource that you are attempting to access does not exist or you don't have permission")
+ end
+ end
+
+ context 'when the feature flag is disabled' do
+ before do
+ stub_feature_flags(achievements: false)
+ end
+
+ it 'returns the relevant permission error' do
+ subject
+
+ expect(graphql_errors.to_s)
+ .to include("The resource that you are attempting to access does not exist or you don't have permission")
+ end
+ end
+
+ context 'with a new avatar' do
+ let(:avatar) { fixture_file_upload("spec/fixtures/dk.png") }
+
+ it 'updates the achievement' do
+ subject
+
+ achievement.reload
+
+ expect(achievement.name).to eq('GitLab')
+ expect(achievement.avatar.file).not_to be_nil
+ end
+ end
+ 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 64ea6d32f5f..b3d25155a6f 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Deleting Sidekiq jobs', :clean_gitlab_redis_queues, feature_category: :not_owned do
+RSpec.describe 'Deleting Sidekiq jobs', :clean_gitlab_redis_queues, feature_category: :shared do
include GraphqlHelpers
let_it_be(:admin) { create(:admin) }
diff --git a/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb b/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb
index fdbff0f93cd..18cc85d36e0 100644
--- a/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb
+++ b/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Adding an AwardEmoji', feature_category: :not_owned do
+RSpec.describe 'Adding an AwardEmoji', feature_category: :shared do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
diff --git a/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb b/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb
index e200bfc2d18..7ec2b061a88 100644
--- a/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb
+++ b/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Removing an AwardEmoji', feature_category: :not_owned do
+RSpec.describe 'Removing an AwardEmoji', feature_category: :shared do
include GraphqlHelpers
let(:current_user) { create(:user) }
diff --git a/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb b/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb
index 6dba2b58357..7c6a487cdd0 100644
--- a/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb
+++ b/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Toggling an AwardEmoji', feature_category: :not_owned do
+RSpec.describe 'Toggling an AwardEmoji', feature_category: :shared do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
diff --git a/spec/requests/api/graphql/mutations/ci/job/cancel_spec.rb b/spec/requests/api/graphql/mutations/ci/job/cancel_spec.rb
new file mode 100644
index 00000000000..abad1ae0812
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/job/cancel_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "JobCancel", feature_category: :continuous_integration do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+ let_it_be(:job) { create(:ci_build, pipeline: pipeline, name: 'build') }
+
+ let(:mutation) do
+ variables = {
+ id: job.to_global_id.to_s
+ }
+ graphql_mutation(:job_cancel, variables,
+ <<-QL
+ errors
+ job {
+ id
+ }
+ QL
+ )
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:job_cancel) }
+
+ it 'returns an error if the user is not allowed to cancel the job' do
+ project.add_developer(user)
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(graphql_errors).not_to be_empty
+ end
+
+ it 'cancels a job' do
+ job_id = ::Gitlab::GlobalId.build(job, id: job.id).to_s
+ project.add_maintainer(user)
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['job']['id']).to eq(job_id)
+ expect(job.reload.status).to eq('canceled')
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/ci/job/play_spec.rb b/spec/requests/api/graphql/mutations/ci/job/play_spec.rb
new file mode 100644
index 00000000000..0c700248f85
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/job/play_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'JobPlay', feature_category: :continuous_integration do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+ let_it_be(:job) { create(:ci_build, :playable, pipeline: pipeline, name: 'build') }
+
+ let(:variables) do
+ {
+ id: job.to_global_id.to_s
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(:job_play, variables,
+ <<-QL
+ errors
+ job {
+ id
+ manualVariables {
+ nodes {
+ key
+ }
+ }
+ }
+ QL
+ )
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:job_play) }
+
+ before_all do
+ project.add_maintainer(user)
+ end
+
+ it 'returns an error if the user is not allowed to play the job' do
+ post_graphql_mutation(mutation, current_user: create(:user))
+
+ expect(graphql_errors).not_to be_empty
+ end
+
+ it 'plays a job' do
+ job_id = ::Gitlab::GlobalId.build(job, id: job.id).to_s
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['job']['id']).to eq(job_id)
+ end
+
+ context 'when given variables' do
+ let(:variables) do
+ {
+ id: job.to_global_id.to_s,
+ variables: [
+ { key: 'MANUAL_VAR_1', value: 'test var' },
+ { key: 'MANUAL_VAR_2', value: 'test var 2' }
+ ]
+ }
+ end
+
+ it 'provides those variables to the job', :aggregate_failures do
+ expect_next_instance_of(Ci::PlayBuildService) do |instance|
+ expect(instance).to receive(:execute).with(an_instance_of(Ci::Build), variables[:variables]).and_call_original
+ end
+
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['job']['manualVariables']['nodes'].pluck('key')).to contain_exactly(
+ 'MANUAL_VAR_1', 'MANUAL_VAR_2'
+ )
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/ci/job/retry_spec.rb b/spec/requests/api/graphql/mutations/ci/job/retry_spec.rb
new file mode 100644
index 00000000000..4114c77491b
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/job/retry_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'JobRetry', feature_category: :continuous_integration do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+
+ let(:job) { create(:ci_build, :success, pipeline: pipeline, name: 'build') }
+
+ let(:mutation) do
+ variables = {
+ id: job.to_global_id.to_s
+ }
+ graphql_mutation(:job_retry, variables,
+ <<-QL
+ errors
+ job {
+ id
+ }
+ QL
+ )
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:job_retry) }
+
+ before_all do
+ project.add_maintainer(user)
+ end
+
+ it 'returns an error if the user is not allowed to retry the job' do
+ post_graphql_mutation(mutation, current_user: create(:user))
+
+ expect(graphql_errors).not_to be_empty
+ end
+
+ it 'retries a job' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ new_job_id = GitlabSchema.object_from_id(mutation_response['job']['id']).sync.id
+
+ new_job = ::Ci::Build.find(new_job_id)
+ expect(new_job).not_to be_retried
+ end
+
+ context 'when given CI variables' do
+ let(:job) { create(:ci_build, :success, :actionable, pipeline: pipeline, name: 'build') }
+
+ let(:mutation) do
+ variables = {
+ id: job.to_global_id.to_s,
+ variables: { key: 'MANUAL_VAR', value: 'test manual var' }
+ }
+
+ graphql_mutation(:job_retry, variables,
+ <<-QL
+ errors
+ job {
+ id
+ }
+ QL
+ )
+ end
+
+ it 'applies them to a retried manual job' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+
+ new_job_id = GitlabSchema.object_from_id(mutation_response['job']['id']).sync.id
+ new_job = ::Ci::Build.find(new_job_id)
+ expect(new_job.job_variables.count).to be(1)
+ expect(new_job.job_variables.first.key).to eq('MANUAL_VAR')
+ expect(new_job.job_variables.first.value).to eq('test manual var')
+ end
+ end
+
+ context 'when the job is not retryable' do
+ let(:job) { create(:ci_build, :retried, pipeline: pipeline) }
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(mutation_response['job']).to be(nil)
+ expect(mutation_response['errors']).to match_array(['Job cannot be retried'])
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/ci/job/unschedule_spec.rb b/spec/requests/api/graphql/mutations/ci/job/unschedule_spec.rb
new file mode 100644
index 00000000000..08e155e808b
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/job/unschedule_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'JobUnschedule', feature_category: :continuous_integration do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+ let_it_be(:job) { create(:ci_build, :scheduled, pipeline: pipeline, name: 'build') }
+
+ let(:mutation) do
+ variables = {
+ id: job.to_global_id.to_s
+ }
+ graphql_mutation(:job_unschedule, variables,
+ <<-QL
+ errors
+ job {
+ id
+ }
+ QL
+ )
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:job_unschedule) }
+
+ it 'returns an error if the user is not allowed to unschedule the job' do
+ project.add_developer(user)
+
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(graphql_errors).not_to be_empty
+ expect(job.reload.status).to eq('scheduled')
+ end
+
+ it 'unschedules a job' do
+ project.add_maintainer(user)
+
+ job_id = ::Gitlab::GlobalId.build(job, id: job.id).to_s
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['job']['id']).to eq(job_id)
+ expect(job.reload.status).to eq('manual')
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb b/spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb
new file mode 100644
index 00000000000..4e25669a0ca
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb
@@ -0,0 +1,197 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'BulkDestroy', feature_category: :build_artifacts do
+ include GraphqlHelpers
+
+ let(:maintainer) { create(:user) }
+ let(:developer) { create(:user) }
+ let(:first_artifact) { create(:ci_job_artifact) }
+ let(:second_artifact) { create(:ci_job_artifact, project: project) }
+ let(:second_artifact_another_project) { create(:ci_job_artifact) }
+ let(:project) { first_artifact.job.project }
+ let(:ids) { [first_artifact.to_global_id.to_s] }
+ let(:not_authorized_project_error_message) do
+ "The resource that you are attempting to access " \
+ "does not exist or you don't have permission to perform this action"
+ end
+
+ let(:mutation) do
+ variables = {
+ project_id: project.to_global_id.to_s,
+ ids: ids
+ }
+ graphql_mutation(:bulk_destroy_job_artifacts, variables, <<~FIELDS)
+ destroyedCount
+ destroyedIds
+ errors
+ FIELDS
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:bulk_destroy_job_artifacts) }
+
+ it 'fails to destroy the artifact if a user not in a project' do
+ post_graphql_mutation(mutation, current_user: maintainer)
+
+ expect(graphql_errors).to include(
+ a_hash_including('message' => not_authorized_project_error_message)
+ )
+
+ expect(first_artifact.reload).to be_persisted
+ end
+
+ context 'when the `ci_job_artifact_bulk_destroy` feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_job_artifact_bulk_destroy: false)
+ project.add_maintainer(maintainer)
+ end
+
+ it 'returns a resource not available error' do
+ post_graphql_mutation(mutation, current_user: maintainer)
+
+ expect(graphql_errors).to contain_exactly(
+ hash_including(
+ 'message' => '`ci_job_artifact_bulk_destroy` feature flag is disabled.'
+ )
+ )
+ end
+ end
+
+ context "when the user is a developer in a project" do
+ before do
+ project.add_developer(developer)
+ end
+
+ it 'fails to destroy the artifact' do
+ post_graphql_mutation(mutation, current_user: developer)
+
+ expect(graphql_errors).to include(
+ a_hash_including('message' => not_authorized_project_error_message)
+ )
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(first_artifact.reload).to be_persisted
+ end
+ end
+
+ context "when the user is a maintainer in a project" do
+ before do
+ project.add_maintainer(maintainer)
+ end
+
+ shared_examples 'failing mutation' do
+ it 'rejects the request' do
+ post_graphql_mutation(mutation, current_user: maintainer)
+
+ expect(graphql_errors(mutation_response)).to include(expected_error_message)
+
+ expected_not_found_artifacts.each do |artifact|
+ expect { artifact.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ expected_found_artifacts.each do |artifact|
+ expect(artifact.reload).to be_persisted
+ end
+ end
+ end
+
+ it 'destroys the artifact' do
+ post_graphql_mutation(mutation, current_user: maintainer)
+
+ expect(mutation_response).to include("destroyedCount" => 1, "destroyedIds" => [gid_string(first_artifact)])
+ expect(response).to have_gitlab_http_status(:success)
+ expect { first_artifact.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ context "and one artifact doesn't belong to the project" do
+ let(:not_owned_artifact) { create(:ci_job_artifact) }
+ let(:ids) { [first_artifact.to_global_id.to_s, not_owned_artifact.to_global_id.to_s] }
+ let(:expected_error_message) { "Not all artifacts belong to requested project" }
+ let(:expected_not_found_artifacts) { [] }
+ let(:expected_found_artifacts) { [first_artifact, not_owned_artifact] }
+
+ it_behaves_like 'failing mutation'
+ end
+
+ context "and multiple artifacts belong to the maintainer's project" do
+ let(:ids) { [first_artifact.to_global_id.to_s, second_artifact.to_global_id.to_s] }
+
+ it 'destroys all artifacts' do
+ post_graphql_mutation(mutation, current_user: maintainer)
+
+ expect(mutation_response).to include(
+ "destroyedCount" => 2,
+ "destroyedIds" => [gid_string(first_artifact), gid_string(second_artifact)]
+ )
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect { first_artifact.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { second_artifact.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ context "and one artifact belongs to a different maintainer's project" do
+ let(:ids) { [first_artifact.to_global_id.to_s, second_artifact_another_project.to_global_id.to_s] }
+ let(:expected_found_artifacts) { [first_artifact, second_artifact_another_project] }
+ let(:expected_not_found_artifacts) { [] }
+ let(:expected_error_message) { "Not all artifacts belong to requested project" }
+
+ it_behaves_like 'failing mutation'
+ end
+
+ context "and not found" do
+ let(:ids) { [first_artifact.to_global_id.to_s, second_artifact.to_global_id.to_s] }
+ let(:not_found_ids) { expected_not_found_artifacts.map(&:id).join(',') }
+ let(:expected_error_message) { "Artifacts (#{not_found_ids}) not found" }
+
+ before do
+ expected_not_found_artifacts.each(&:destroy!)
+ end
+
+ context "with one artifact" do
+ let(:expected_not_found_artifacts) { [second_artifact] }
+ let(:expected_found_artifacts) { [first_artifact] }
+
+ it_behaves_like 'failing mutation'
+ end
+
+ context "with all artifact" do
+ let(:expected_not_found_artifacts) { [first_artifact, second_artifact] }
+ let(:expected_found_artifacts) { [] }
+
+ it_behaves_like 'failing mutation'
+ end
+ end
+
+ context 'when empty request' do
+ before do
+ project.add_maintainer(maintainer)
+ end
+
+ context 'with nil value' do
+ let(:ids) { nil }
+
+ it 'does nothing and returns empty answer' do
+ post_graphql_mutation(mutation, current_user: maintainer)
+
+ expect_graphql_errors_to_include(/was provided invalid value for ids \(Expected value to not be null\)/)
+ end
+ end
+
+ context 'with empty array' do
+ let(:ids) { [] }
+
+ it 'raises argument error' do
+ post_graphql_mutation(mutation, current_user: maintainer)
+
+ expect_graphql_errors_to_include(/IDs array of job artifacts can not be empty/)
+ end
+ end
+ end
+
+ def gid_string(object)
+ Gitlab::GlobalId.build(object, id: object.id).to_s
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/ci/job_cancel_spec.rb b/spec/requests/api/graphql/mutations/ci/job_cancel_spec.rb
deleted file mode 100644
index 468a9e57f56..00000000000
--- a/spec/requests/api/graphql/mutations/ci/job_cancel_spec.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe "JobCancel", feature_category: :continuous_integration do
- include GraphqlHelpers
-
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
- let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
- let_it_be(:job) { create(:ci_build, pipeline: pipeline, name: 'build') }
-
- let(:mutation) do
- variables = {
- id: job.to_global_id.to_s
- }
- graphql_mutation(:job_cancel, variables,
- <<-QL
- errors
- job {
- id
- }
- QL
- )
- end
-
- let(:mutation_response) { graphql_mutation_response(:job_cancel) }
-
- it 'returns an error if the user is not allowed to cancel the job' do
- project.add_developer(user)
- post_graphql_mutation(mutation, current_user: user)
-
- expect(graphql_errors).not_to be_empty
- end
-
- it 'cancels a job' do
- job_id = ::Gitlab::GlobalId.build(job, id: job.id).to_s
- project.add_maintainer(user)
- post_graphql_mutation(mutation, current_user: user)
-
- expect(response).to have_gitlab_http_status(:success)
- expect(mutation_response['job']['id']).to eq(job_id)
- expect(job.reload.status).to eq('canceled')
- end
-end
diff --git a/spec/requests/api/graphql/mutations/ci/job_play_spec.rb b/spec/requests/api/graphql/mutations/ci/job_play_spec.rb
deleted file mode 100644
index 9ba80e51dee..00000000000
--- a/spec/requests/api/graphql/mutations/ci/job_play_spec.rb
+++ /dev/null
@@ -1,79 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'JobPlay', feature_category: :continuous_integration do
- include GraphqlHelpers
-
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
- let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
- let_it_be(:job) { create(:ci_build, :playable, pipeline: pipeline, name: 'build') }
-
- let(:variables) do
- {
- id: job.to_global_id.to_s
- }
- end
-
- let(:mutation) do
- graphql_mutation(:job_play, variables,
- <<-QL
- errors
- job {
- id
- manualVariables {
- nodes {
- key
- }
- }
- }
- QL
- )
- end
-
- let(:mutation_response) { graphql_mutation_response(:job_play) }
-
- before_all do
- project.add_maintainer(user)
- end
-
- it 'returns an error if the user is not allowed to play the job' do
- post_graphql_mutation(mutation, current_user: create(:user))
-
- expect(graphql_errors).not_to be_empty
- end
-
- it 'plays a job' do
- job_id = ::Gitlab::GlobalId.build(job, id: job.id).to_s
- post_graphql_mutation(mutation, current_user: user)
-
- expect(response).to have_gitlab_http_status(:success)
- expect(mutation_response['job']['id']).to eq(job_id)
- end
-
- context 'when given variables' do
- let(:variables) do
- {
- id: job.to_global_id.to_s,
- variables: [
- { key: 'MANUAL_VAR_1', value: 'test var' },
- { key: 'MANUAL_VAR_2', value: 'test var 2' }
- ]
- }
- end
-
- it 'provides those variables to the job', :aggregated_errors do
- expect_next_instance_of(Ci::PlayBuildService) do |instance|
- expect(instance).to receive(:execute).with(an_instance_of(Ci::Build), variables[:variables]).and_call_original
- end
-
- post_graphql_mutation(mutation, current_user: user)
-
- expect(response).to have_gitlab_http_status(:success)
- expect(mutation_response['job']['manualVariables']['nodes'].pluck('key')).to contain_exactly(
- 'MANUAL_VAR_1', 'MANUAL_VAR_2'
- )
- end
- end
-end
diff --git a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb b/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb
deleted file mode 100644
index e49ee6f3163..00000000000
--- a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb
+++ /dev/null
@@ -1,92 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'JobRetry', feature_category: :continuous_integration do
- include GraphqlHelpers
-
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
- let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
-
- let(:job) { create(:ci_build, :success, pipeline: pipeline, name: 'build') }
-
- let(:mutation) do
- variables = {
- id: job.to_global_id.to_s
- }
- graphql_mutation(:job_retry, variables,
- <<-QL
- errors
- job {
- id
- }
- QL
- )
- end
-
- let(:mutation_response) { graphql_mutation_response(:job_retry) }
-
- before_all do
- project.add_maintainer(user)
- end
-
- it 'returns an error if the user is not allowed to retry the job' do
- post_graphql_mutation(mutation, current_user: create(:user))
-
- expect(graphql_errors).not_to be_empty
- end
-
- it 'retries a job' do
- post_graphql_mutation(mutation, current_user: user)
-
- expect(response).to have_gitlab_http_status(:success)
- new_job_id = GitlabSchema.object_from_id(mutation_response['job']['id']).sync.id
-
- new_job = ::Ci::Build.find(new_job_id)
- expect(new_job).not_to be_retried
- end
-
- context 'when given CI variables' do
- let(:job) { create(:ci_build, :success, :actionable, pipeline: pipeline, name: 'build') }
-
- let(:mutation) do
- variables = {
- id: job.to_global_id.to_s,
- variables: { key: 'MANUAL_VAR', value: 'test manual var' }
- }
-
- graphql_mutation(:job_retry, variables,
- <<-QL
- errors
- job {
- id
- }
- QL
- )
- end
-
- it 'applies them to a retried manual job' do
- post_graphql_mutation(mutation, current_user: user)
-
- expect(response).to have_gitlab_http_status(:success)
-
- new_job_id = GitlabSchema.object_from_id(mutation_response['job']['id']).sync.id
- new_job = ::Ci::Build.find(new_job_id)
- expect(new_job.job_variables.count).to be(1)
- expect(new_job.job_variables.first.key).to eq('MANUAL_VAR')
- expect(new_job.job_variables.first.value).to eq('test manual var')
- end
- end
-
- context 'when the job is not retryable' do
- let(:job) { create(:ci_build, :retried, pipeline: pipeline) }
-
- it 'returns an error' do
- post_graphql_mutation(mutation, current_user: user)
-
- expect(mutation_response['job']).to be(nil)
- expect(mutation_response['errors']).to match_array(['Job cannot be retried'])
- end
- end
-end
diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb
index 55e728b2141..8791d793cb4 100644
--- a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb
+++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb
@@ -53,14 +53,29 @@ RSpec.describe 'CiJobTokenScopeAddProject', feature_category: :continuous_integr
before do
target_project.add_developer(current_user)
+ stub_feature_flags(frozen_outbound_job_token_scopes_override: false)
end
- it 'adds the target project to the job token scope' do
+ it 'adds the target project to the inbound job token scope' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty
- end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1)
+ end.to change { Ci::JobToken::ProjectScopeLink.inbound.count }.by(1)
+ end
+
+ context 'when FF frozen_outbound_job_token_scopes is disabled' do
+ before do
+ stub_feature_flags(frozen_outbound_job_token_scopes: false)
+ end
+
+ it 'adds the target project to the outbound job token scope' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty
+ end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1)
+ end
end
context 'when invalid target project is provided' do
diff --git a/spec/requests/api/graphql/mutations/ci/job_unschedule_spec.rb b/spec/requests/api/graphql/mutations/ci/job_unschedule_spec.rb
deleted file mode 100644
index 6868b0ea279..00000000000
--- a/spec/requests/api/graphql/mutations/ci/job_unschedule_spec.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'JobUnschedule', feature_category: :continuous_integration do
- include GraphqlHelpers
-
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
- let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
- let_it_be(:job) { create(:ci_build, :scheduled, pipeline: pipeline, name: 'build') }
-
- let(:mutation) do
- variables = {
- id: job.to_global_id.to_s
- }
- graphql_mutation(:job_unschedule, variables,
- <<-QL
- errors
- job {
- id
- }
- QL
- )
- end
-
- let(:mutation_response) { graphql_mutation_response(:job_unschedule) }
-
- it 'returns an error if the user is not allowed to unschedule the job' do
- project.add_developer(user)
-
- post_graphql_mutation(mutation, current_user: user)
-
- expect(graphql_errors).not_to be_empty
- expect(job.reload.status).to eq('scheduled')
- end
-
- it 'unschedules a job' do
- project.add_maintainer(user)
-
- job_id = ::Gitlab::GlobalId.build(job, id: job.id).to_s
- post_graphql_mutation(mutation, current_user: user)
-
- expect(response).to have_gitlab_http_status(:success)
- expect(mutation_response['job']['id']).to eq(job_id)
- expect(job.reload.status).to eq('manual')
- end
-end
diff --git a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb
index 99e55c44773..aa00069b241 100644
--- a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb
+++ b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integration do
include GraphqlHelpers
+ before do
+ stub_feature_flags(frozen_outbound_job_token_scopes_override: false)
+ end
+
let_it_be(:project) do
create(:project,
keep_latest_artifact: true,
@@ -18,12 +22,11 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr
full_path: project.full_path,
keep_latest_artifact: false,
job_token_scope_enabled: false,
- inbound_job_token_scope_enabled: false,
- opt_in_jwt: true
+ inbound_job_token_scope_enabled: false
}
end
- let(:mutation) { graphql_mutation(:ci_cd_settings_update, variables) }
+ let(:mutation) { graphql_mutation(:project_ci_cd_settings_update, variables) }
context 'when unauthorized' do
let(:user) { create(:user) }
@@ -61,7 +64,36 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr
expect(project.keep_latest_artifact).to eq(false)
end
- it 'updates job_token_scope_enabled' do
+ describe 'ci_cd_settings_update deprecated mutation' do
+ let(:mutation) { graphql_mutation(:ci_cd_settings_update, variables) }
+
+ it 'returns error' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(graphql_errors).to(
+ include(
+ hash_including('message' => '`remove_cicd_settings_update` feature flag is enabled.')
+ )
+ )
+ end
+
+ context 'when remove_cicd_settings_update FF is disabled' do
+ before do
+ stub_feature_flags(remove_cicd_settings_update: false)
+ end
+
+ it 'updates ci cd settings' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ project.reload
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(project.keep_latest_artifact).to eq(false)
+ end
+ end
+ end
+
+ it 'allows setting job_token_scope_enabled to false' do
post_graphql_mutation(mutation, current_user: user)
project.reload
@@ -70,6 +102,50 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr
expect(project.ci_outbound_job_token_scope_enabled).to eq(false)
end
+ context 'when job_token_scope_enabled: true' do
+ let(:variables) do
+ {
+ full_path: project.full_path,
+ keep_latest_artifact: false,
+ job_token_scope_enabled: true,
+ inbound_job_token_scope_enabled: false
+ }
+ end
+
+ it 'prevents the update', :aggregate_failures do
+ project.update!(ci_outbound_job_token_scope_enabled: false)
+ post_graphql_mutation(mutation, current_user: user)
+
+ project.reload
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(graphql_errors).to(
+ include(
+ hash_including(
+ 'message' => 'job_token_scope_enabled can only be set to false'
+ )
+ )
+ )
+ expect(project.ci_outbound_job_token_scope_enabled).to eq(false)
+ end
+ end
+
+ context 'when FF frozen_outbound_job_token_scopes is disabled' do
+ before do
+ stub_feature_flags(frozen_outbound_job_token_scopes: false)
+ end
+
+ it 'allows setting job_token_scope_enabled to true' do
+ project.update!(ci_outbound_job_token_scope_enabled: true)
+ post_graphql_mutation(mutation, current_user: user)
+
+ project.reload
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(project.ci_outbound_job_token_scope_enabled).to eq(false)
+ end
+ end
+
it 'does not update job_token_scope_enabled if not specified' do
variables.except!(:job_token_scope_enabled)
@@ -101,30 +177,6 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr
expect(response).to have_gitlab_http_status(:success)
expect(project.ci_inbound_job_token_scope_enabled).to eq(true)
end
-
- context 'when ci_inbound_job_token_scope disabled' do
- before do
- stub_feature_flags(ci_inbound_job_token_scope: false)
- end
-
- it 'does not update inbound_job_token_scope_enabled' do
- post_graphql_mutation(mutation, current_user: user)
-
- project.reload
-
- expect(response).to have_gitlab_http_status(:success)
- expect(project.ci_inbound_job_token_scope_enabled).to eq(true)
- end
- end
- end
-
- it 'updates ci_opt_in_jwt' do
- post_graphql_mutation(mutation, current_user: user)
-
- project.reload
-
- expect(response).to have_gitlab_http_status(:success)
- expect(project.ci_opt_in_jwt).to eq(true)
end
context 'when bad arguments are provided' do
diff --git a/spec/requests/api/graphql/mutations/ci/runner/create_spec.rb b/spec/requests/api/graphql/mutations/ci/runner/create_spec.rb
new file mode 100644
index 00000000000..1658c277ed0
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/runner/create_spec.rb
@@ -0,0 +1,313 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'RunnerCreate', feature_category: :runner_fleet do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group_owner) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:other_group) { create(:group) }
+
+ let(:mutation_params) do
+ {
+ description: 'create description',
+ maintenance_note: 'create maintenance note',
+ maximum_timeout: 900,
+ access_level: 'REF_PROTECTED',
+ paused: true,
+ run_untagged: false,
+ tag_list: %w[tag1 tag2]
+ }.deep_merge(mutation_scope_params)
+ end
+
+ let(:mutation) do
+ variables = {
+ **mutation_params
+ }
+
+ graphql_mutation(
+ :runner_create,
+ variables,
+ <<-QL
+ runner {
+ ephemeralAuthenticationToken
+
+ runnerType
+ description
+ maintenanceNote
+ paused
+ tagList
+ accessLevel
+ locked
+ maximumTimeout
+ runUntagged
+ }
+ errors
+ QL
+ )
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:runner_create) }
+
+ before do
+ group.add_owner(group_owner)
+ end
+
+ shared_context 'when model is invalid returns error' do
+ let(:mutation_params) do
+ {
+ description: '',
+ maintenanceNote: '',
+ paused: true,
+ accessLevel: 'NOT_PROTECTED',
+ runUntagged: false,
+ tagList: [],
+ maximumTimeout: 1
+ }.deep_merge(mutation_scope_params)
+ end
+
+ it do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+
+ expect(mutation_response['errors']).to contain_exactly(
+ 'Tags list can not be empty when runner is not allowed to pick untagged jobs',
+ 'Maximum timeout needs to be at least 10 minutes'
+ )
+ end
+ end
+
+ shared_context 'when user does not have permissions' do
+ let(:current_user) { user }
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect_graphql_errors_to_include(
+ 'The resource that you are attempting to access does not exist ' \
+ "or you don't have permission to perform this action"
+ )
+ end
+ end
+
+ shared_context 'when :create_runner_workflow_for_namespace feature flag is disabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: [other_group])
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect_graphql_errors_to_include('`create_runner_workflow_for_namespace` feature flag is disabled.')
+ end
+ end
+
+ shared_examples 'when runner is created successfully' do
+ it do
+ expected_args = { user: current_user, params: anything }
+ expect_next_instance_of(::Ci::Runners::CreateRunnerService, expected_args) do |service|
+ expect(service).to receive(:execute).and_call_original
+ end
+
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+
+ expect(mutation_response['errors']).to eq([])
+ expect(mutation_response['runner']).not_to be_nil
+ mutation_params.except(:group_id, :project_id).each_key do |key|
+ expect(mutation_response['runner'][key.to_s.camelize(:lower)]).to eq mutation_params[key]
+ end
+
+ expect(mutation_response['runner']['ephemeralAuthenticationToken'])
+ .to start_with Ci::Runner::CREATED_RUNNER_TOKEN_PREFIX
+ end
+ end
+
+ context 'when runnerType is INSTANCE_TYPE' do
+ let(:mutation_scope_params) do
+ { runner_type: 'INSTANCE_TYPE' }
+ end
+
+ it_behaves_like 'when user does not have permissions'
+
+ context 'when user has permissions', :enable_admin_mode do
+ let(:current_user) { admin }
+
+ context 'when :create_runner_workflow_for_admin feature flag is disabled' do
+ before do
+ stub_feature_flags(create_runner_workflow_for_admin: false)
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect_graphql_errors_to_include('`create_runner_workflow_for_admin` feature flag is disabled.')
+ end
+ end
+
+ it_behaves_like 'when runner is created successfully'
+ it_behaves_like 'when model is invalid returns error'
+ end
+ end
+
+ context 'when runnerType is GROUP_TYPE' do
+ let(:mutation_scope_params) do
+ {
+ runner_type: 'GROUP_TYPE',
+ group_id: group.to_global_id
+ }
+ end
+
+ before do
+ stub_feature_flags(create_runner_workflow_for_namespace: [group])
+ end
+
+ it_behaves_like 'when user does not have permissions'
+
+ context 'when user has permissions' do
+ context 'when user is group owner' do
+ let(:current_user) { group_owner }
+
+ it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled'
+ it_behaves_like 'when runner is created successfully'
+ it_behaves_like 'when model is invalid returns error'
+
+ context 'when group_id is missing' do
+ let(:mutation_scope_params) do
+ { runner_type: 'GROUP_TYPE' }
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect_graphql_errors_to_include('`group_id` is missing')
+ end
+ end
+
+ context 'when group_id is malformed' do
+ let(:mutation_scope_params) do
+ {
+ runner_type: 'GROUP_TYPE',
+ group_id: ''
+ }
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect_graphql_errors_to_include(
+ "RunnerCreateInput! was provided invalid value for groupId"
+ )
+ end
+ end
+
+ context 'when group_id does not exist' do
+ let(:mutation_scope_params) do
+ {
+ runner_type: 'GROUP_TYPE',
+ group_id: "gid://gitlab/Group/#{non_existing_record_id}"
+ }
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(flattened_errors).not_to be_empty
+ end
+ end
+ end
+
+ context 'when user is admin in admin mode', :enable_admin_mode do
+ let(:current_user) { admin }
+
+ it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled'
+ it_behaves_like 'when runner is created successfully'
+ it_behaves_like 'when model is invalid returns error'
+ end
+ end
+ end
+
+ context 'when runnerType is PROJECT_TYPE' do
+ let_it_be(:project) { create(:project, namespace: group) }
+
+ let(:mutation_scope_params) do
+ {
+ runner_type: 'PROJECT_TYPE',
+ project_id: project.to_global_id
+ }
+ end
+
+ it_behaves_like 'when user does not have permissions'
+
+ context 'when user has permissions' do
+ context 'when user is group owner' do
+ let(:current_user) { group_owner }
+
+ it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled'
+ it_behaves_like 'when runner is created successfully'
+ it_behaves_like 'when model is invalid returns error'
+
+ context 'when project_id is missing' do
+ let(:mutation_scope_params) do
+ { runner_type: 'PROJECT_TYPE' }
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect_graphql_errors_to_include('`project_id` is missing')
+ end
+ end
+
+ context 'when project_id is malformed' do
+ let(:mutation_scope_params) do
+ {
+ runner_type: 'PROJECT_TYPE',
+ project_id: ''
+ }
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect_graphql_errors_to_include(
+ "RunnerCreateInput! was provided invalid value for projectId"
+ )
+ end
+ end
+
+ context 'when project_id does not exist' do
+ let(:mutation_scope_params) do
+ {
+ runner_type: 'PROJECT_TYPE',
+ project_id: "gid://gitlab/Project/#{non_existing_record_id}"
+ }
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect_graphql_errors_to_include(
+ 'The resource that you are attempting to access does not exist ' \
+ "or you don't have permission to perform this action"
+ )
+ end
+ end
+ end
+
+ context 'when user is admin in admin mode', :enable_admin_mode do
+ let(:current_user) { admin }
+
+ it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled'
+ it_behaves_like 'when runner is created successfully'
+ it_behaves_like 'when model is invalid returns error'
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb
index f544cef8864..ef0d44395bf 100644
--- a/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Create a new cluster agent token', feature_category: :kubernetes_management do
+RSpec.describe 'Create a new cluster agent token', feature_category: :deployment_management do
include GraphqlHelpers
let_it_be(:cluster_agent) { create(:cluster_agent) }
diff --git a/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb
index 66e6c5cc629..1d1e72dcff9 100644
--- a/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Create a new cluster agent', feature_category: :kubernetes_management do
+RSpec.describe 'Create a new cluster agent', feature_category: :deployment_management do
include GraphqlHelpers
let(:project) { create(:project, :public, :repository) }
diff --git a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb
index 27a566dfb8c..b70a6282a7a 100644
--- a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb
+++ b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Delete a cluster agent', feature_category: :kubernetes_management do
+RSpec.describe 'Delete a cluster agent', feature_category: :deployment_management do
include GraphqlHelpers
let(:cluster_agent) { create(:cluster_agent) }
diff --git a/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb b/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb
index 8b76c19cda6..ef159e41d3d 100644
--- a/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb
+++ b/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe 'Destroying a container repository', feature_category: :container
expect(DeleteContainerRepositoryWorker)
.not_to receive(:perform_async)
- expect { subject }.to change { ::Packages::Event.count }.by(1)
+ subject
expect(container_repository_mutation_response).to match_schema('graphql/container_repository')
expect(container_repository_mutation_response['status']).to eq('DELETE_SCHEDULED')
@@ -53,7 +53,7 @@ RSpec.describe 'Destroying a container repository', feature_category: :container
expect(DeleteContainerRepositoryWorker)
.not_to receive(:perform_async).with(user.id, container_repository.id)
- expect { subject }.not_to change { ::Packages::Event.count }
+ subject
expect(mutation_response).to be_nil
end
diff --git a/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb b/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb
index 9e07a831076..0cb607e13ec 100644
--- a/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb
+++ b/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont
it 'destroys the container repository tags' do
expect(Projects::ContainerRepository::DeleteTagsService)
.to receive(:new).and_call_original
- expect { subject }.to change { ::Packages::Event.count }.by(1)
+ subject
expect(tag_names_response).to eq(tags)
expect(errors_response).to eq([])
@@ -50,7 +50,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont
expect(Projects::ContainerRepository::DeleteTagsService)
.not_to receive(:new)
- expect { subject }.not_to change { ::Packages::Event.count }
+ subject
expect(mutation_response).to be_nil
end
@@ -89,7 +89,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont
let(:tags) { Array.new(Mutations::ContainerRepositories::DestroyTags::LIMIT + 1, 'x') }
it 'returns too many tags error' do
- expect { subject }.not_to change { ::Packages::Event.count }
+ subject
explanation = graphql_errors.dig(0, 'message')
expect(explanation).to eq(Mutations::ContainerRepositories::DestroyTags::TOO_MANY_TAGS_ERROR_MESSAGE)
@@ -113,7 +113,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont
it 'does not create a package event' do
expect(::Packages::CreateEventService).not_to receive(:new)
- expect { subject }.not_to change { ::Packages::Event.count }
+ subject
end
end
end
diff --git a/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb
index ea2ce8a13e2..19a52086f34 100644
--- a/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Creation of a new Custom Emoji', feature_category: :not_owned do
+RSpec.describe 'Creation of a new Custom Emoji', feature_category: :shared do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
diff --git a/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb
index ad7a043909a..2623d3d8410 100644
--- a/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb
+++ b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Deletion of custom emoji', feature_category: :not_owned do
+RSpec.describe 'Deletion of custom emoji', feature_category: :shared do
include GraphqlHelpers
let_it_be(:group) { create(:group) }
diff --git a/spec/requests/api/graphql/mutations/design_management/update_spec.rb b/spec/requests/api/graphql/mutations/design_management/update_spec.rb
new file mode 100644
index 00000000000..9558f2538f1
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/design_management/update_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "updating designs", feature_category: :design_management do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue) { create(:issue) }
+ let_it_be_with_reload(:design) { create(:design, description: 'old description', issue: issue) }
+ let_it_be(:developer) { create(:user, developer_projects: [issue.project]) }
+
+ let(:user) { developer }
+ let(:description) { 'new description' }
+
+ let(:mutation) do
+ input = {
+ id: design.to_global_id.to_s,
+ description: description
+ }.compact
+
+ graphql_mutation(:design_management_update, input, <<~FIELDS)
+ errors
+ design {
+ description
+ descriptionHtml
+ }
+ FIELDS
+ end
+
+ let(:update_design) { post_graphql_mutation(mutation, current_user: user) }
+ let(:mutation_response) { graphql_mutation_response(:design_management_update) }
+
+ before do
+ enable_design_management
+ end
+
+ it 'updates design' do
+ update_design
+
+ expect(graphql_errors).not_to be_present
+ expect(mutation_response).to eq(
+ 'errors' => [],
+ 'design' => {
+ 'description' => description,
+ 'descriptionHtml' => "<p data-sourcepos=\"1:1-1:15\" dir=\"auto\">#{description}</p>"
+ }
+ )
+ end
+
+ context 'when the user is not allowed to update designs' do
+ let(:user) { create(:user) }
+
+ it 'returns an error' do
+ update_design
+
+ expect(graphql_errors).to be_present
+ end
+ end
+
+ context 'when update fails' do
+ let(:description) { 'x' * 1_000_001 }
+
+ it 'returns an error' do
+ update_design
+
+ expect(graphql_errors).not_to be_present
+ expect(mutation_response).to eq(
+ 'errors' => ["Description is too long (maximum is 1000000 characters)"],
+ 'design' => {
+ 'description' => 'old description',
+ 'descriptionHtml' => '<p data-sourcepos="1:1-1:15" dir="auto">old description</p>'
+ }
+ )
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb b/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb
index b9c83311908..b729585a89b 100644
--- a/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb
+++ b/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb
@@ -8,7 +8,9 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do
let_it_be(:developer) { create(:user) }
let_it_be(:group) { create(:group).tap { |group| group.add_developer(developer) } }
let_it_be(:project) { create(:project, group: group) }
- let_it_be(:updatable_issues, reload: true) { create_list(:issue, 2, project: project) }
+ let_it_be(:label1) { create(:group_label, group: group) }
+ let_it_be(:label2) { create(:group_label, group: group) }
+ let_it_be(:updatable_issues, reload: true) { create_list(:issue, 2, project: project, label_ids: [label1.id]) }
let_it_be(:milestone) { create(:milestone, group: group) }
let(:parent) { project }
@@ -21,10 +23,36 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do
let(:additional_arguments) do
{
assignee_ids: [current_user.to_gid.to_s],
- milestone_id: milestone.to_gid.to_s
+ milestone_id: milestone.to_gid.to_s,
+ state_event: :CLOSE,
+ add_label_ids: [label2.to_gid.to_s],
+ remove_label_ids: [label1.to_gid.to_s],
+ subscription_event: :UNSUBSCRIBE
}
end
+ before_all do
+ updatable_issues.each { |i| i.subscribe(developer, project) }
+ end
+
+ context 'when Gitlab is FOSS only' do
+ unless Gitlab.ee?
+ context 'when parent is a group' do
+ let(:parent) { group }
+
+ it 'does not allow bulk updating issues at the group level' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_errors).to contain_exactly(
+ hash_including(
+ 'message' => match(/does not represent an instance of IssueParent/)
+ )
+ )
+ end
+ end
+ end
+ end
+
context 'when the `bulk_update_issues_mutation` feature flag is disabled' do
before do
stub_feature_flags(bulk_update_issues_mutation: false)
@@ -67,6 +95,11 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do
updatable_issues.each(&:reload)
end.to change { updatable_issues.flat_map(&:assignee_ids) }.from([]).to([current_user.id] * 2)
.and(change { updatable_issues.map(&:milestone_id) }.from([nil] * 2).to([milestone.id] * 2))
+ .and(change { updatable_issues.map(&:state) }.from(['opened'] * 2).to(['closed'] * 2))
+ .and(change { updatable_issues.flat_map(&:label_ids) }.from([label1.id] * 2).to([label2.id] * 2))
+ .and(
+ change { updatable_issues.map { |i| i.subscribed?(developer, project) } }.from([true] * 2).to([false] * 2)
+ )
expect(mutation_response).to include(
'updatedIssueCount' => updatable_issues.count
@@ -88,37 +121,6 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do
end
end
- context 'when scoping to a parent group' do
- let(:parent) { group }
-
- it 'updates all issues' do
- expect do
- post_graphql_mutation(mutation, current_user: current_user)
- updatable_issues.each(&:reload)
- end.to change { updatable_issues.flat_map(&:assignee_ids) }.from([]).to([current_user.id] * 2)
- .and(change { updatable_issues.map(&:milestone_id) }.from([nil] * 2).to([milestone.id] * 2))
-
- expect(mutation_response).to include(
- 'updatedIssueCount' => updatable_issues.count
- )
- end
-
- context 'when current user cannot read the specified group' do
- let(:parent) { create(:group, :private) }
-
- it 'returns a resource not found error' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- expect(graphql_errors).to contain_exactly(
- hash_including(
- 'message' => "The resource that you are attempting to access does not exist or you don't have " \
- 'permission to perform this action'
- )
- )
- end
- end
- end
-
context 'when setting arguments to null or none' do
let(:additional_arguments) { { assignee_ids: [], milestone_id: nil } }
diff --git a/spec/requests/api/graphql/mutations/issues/create_spec.rb b/spec/requests/api/graphql/mutations/issues/create_spec.rb
index d2d2f0014d6..b5a9c549045 100644
--- a/spec/requests/api/graphql/mutations/issues/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/issues/create_spec.rb
@@ -66,7 +66,6 @@ RSpec.describe 'Create an issue', feature_category: :team_planning do
created_issue = Issue.last
expect(created_issue.work_item_type.base_type).to eq('task')
- expect(created_issue.issue_type).to eq('task')
end
end
diff --git a/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb b/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb
index ad70129a7bc..f15b52f53a3 100644
--- a/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb
+++ b/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb
@@ -5,126 +5,14 @@ require 'spec_helper'
RSpec.describe 'GroupMemberBulkUpdate', feature_category: :subgroups do
include GraphqlHelpers
- let_it_be(:current_user) { create(:user) }
- let_it_be(:user1) { create(:user) }
- let_it_be(:user2) { create(:user) }
- let_it_be(:group) { create(:group) }
- let_it_be(:group_member1) { create(:group_member, group: group, user: user1) }
- let_it_be(:group_member2) { create(:group_member, group: group, user: user2) }
+ let_it_be(:parent_group) { create(:group) }
+ let_it_be(:parent_group_member) { create(:group_member, group: parent_group) }
+ let_it_be(:group) { create(:group, parent: parent_group) }
+ let_it_be(:source) { group }
+ let_it_be(:member_type) { :group_member }
let_it_be(:mutation_name) { :group_member_bulk_update }
+ let_it_be(:source_id_key) { 'group_id' }
+ let_it_be(:response_member_field) { 'groupMembers' }
- let(:input) do
- {
- 'group_id' => group.to_global_id.to_s,
- 'user_ids' => [user1.to_global_id.to_s, user2.to_global_id.to_s],
- 'access_level' => 'GUEST'
- }
- end
-
- let(:extra_params) { { expires_at: 10.days.from_now } }
- let(:input_params) { input.merge(extra_params) }
- let(:mutation) { graphql_mutation(mutation_name, input_params) }
- let(:mutation_response) { graphql_mutation_response(mutation_name) }
-
- context 'when user is not logged-in' do
- it_behaves_like 'a mutation that returns a top-level access error'
- end
-
- context 'when user is not an owner' do
- before do
- group.add_maintainer(current_user)
- end
-
- it_behaves_like 'a mutation that returns a top-level access error'
- end
-
- context 'when user is an owner' do
- before do
- group.add_owner(current_user)
- end
-
- shared_examples 'updates the user access role' do
- specify do
- post_graphql_mutation(mutation, current_user: current_user)
-
- new_access_levels = mutation_response['groupMembers'].map { |member| member['accessLevel']['integerValue'] }
- expect(response).to have_gitlab_http_status(:success)
- expect(mutation_response['errors']).to be_empty
- expect(new_access_levels).to all(be Gitlab::Access::GUEST)
- end
- end
-
- it_behaves_like 'updates the user access role'
-
- context 'when inherited members are passed' do
- let_it_be(:subgroup) { create(:group, parent: group) }
- let_it_be(:subgroup_member) { create(:group_member, group: subgroup) }
-
- let(:input) do
- {
- 'group_id' => group.to_global_id.to_s,
- 'user_ids' => [user1.to_global_id.to_s, user2.to_global_id.to_s, subgroup_member.user.to_global_id.to_s],
- 'access_level' => 'GUEST'
- }
- end
-
- it 'does not update the members' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- error = Mutations::Members::Groups::BulkUpdate::INVALID_MEMBERS_ERROR
- expect(json_response['errors'].first['message']).to include(error)
- end
- end
-
- context 'when members count is more than the allowed limit' do
- let(:max_members_update_limit) { 1 }
-
- before do
- stub_const('Mutations::Members::Groups::BulkUpdate::MAX_MEMBERS_UPDATE_LIMIT', max_members_update_limit)
- end
-
- it 'does not update the members' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- error = Mutations::Members::Groups::BulkUpdate::MAX_MEMBERS_UPDATE_ERROR
- expect(json_response['errors'].first['message']).to include(error)
- end
- end
-
- context 'when the update service raises access denied error' do
- before do
- allow_next_instance_of(Members::UpdateService) do |instance|
- allow(instance).to receive(:execute).and_raise(Gitlab::Access::AccessDeniedError)
- end
- end
-
- it 'does not update the members' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- expect(mutation_response['groupMembers']).to be_nil
- expect(mutation_response['errors'])
- .to contain_exactly("Unable to update members, please check user permissions.")
- end
- end
-
- context 'when the update service returns an error message' do
- before do
- allow_next_instance_of(Members::UpdateService) do |instance|
- error_result = {
- message: 'Expires at cannot be a date in the past',
- status: :error,
- members: [group_member1]
- }
- allow(instance).to receive(:execute).and_return(error_result)
- end
- end
-
- it 'will pass through the error' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- expect(mutation_response['groupMembers'].first['id']).to eq(group_member1.to_global_id.to_s)
- expect(mutation_response['errors']).to contain_exactly('Expires at cannot be a date in the past')
- end
- end
- end
+ it_behaves_like 'members bulk update mutation'
end
diff --git a/spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb b/spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb
new file mode 100644
index 00000000000..cbef9715cbe
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'ProjectMemberBulkUpdate', feature_category: :projects do
+ include GraphqlHelpers
+
+ let_it_be(:parent_group) { create(:group) }
+ let_it_be(:parent_group_member) { create(:group_member, group: parent_group) }
+ let_it_be(:project) { create(:project, group: parent_group) }
+ let_it_be(:source) { project }
+ let_it_be(:member_type) { :project_member }
+ let_it_be(:mutation_name) { :project_member_bulk_update }
+ let_it_be(:source_id_key) { 'project_id' }
+ let_it_be(:response_member_field) { 'projectMembers' }
+
+ it_behaves_like 'members bulk update mutation'
+end
diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
index b5f2042c42a..d41628704a1 100644
--- a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
+++ b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
@@ -106,7 +106,7 @@ RSpec.describe 'Setting assignees of a merge request', :assume_throttled, featur
end
context 'when passing an empty list of assignees' do
- let(:db_query_limit) { 31 }
+ let(:db_query_limit) { 35 }
let(:input) { { assignee_usernames: [] } }
before do
diff --git a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb
index bce57b47aab..d81744abe1b 100644
--- a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb
@@ -19,7 +19,11 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create, feature_categ
graphql_mutation_response(:create_annotation)
end
- specify { expect(described_class).to require_graphql_authorizations(:create_metrics_dashboard_annotation) }
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_metrics_dashboard_annotation) }
context 'when annotation source is environment' do
let(:mutation) do
@@ -103,6 +107,15 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create, feature_categ
it_behaves_like 'an invalid argument to the mutation', argument_name: :environment_id
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
+ end
end
end
diff --git a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb
index f505dc25dc0..09977cd19d7 100644
--- a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb
+++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb
@@ -17,7 +17,11 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete, feature_categ
graphql_mutation_response(:delete_annotation)
end
- specify { expect(described_class).to require_graphql_authorizations(:delete_metrics_dashboard_annotation) }
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_metrics_dashboard_annotation) }
context 'when the user has permission to delete the annotation' do
before do
@@ -54,6 +58,15 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete, feature_categ
expect(mutation_response['errors']).to eq([service_response[:message]])
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
+ end
end
context 'when the user does not have permission to delete the annotation' do
diff --git a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb
index a6253ba424b..e6feba059c4 100644
--- a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb
@@ -104,7 +104,8 @@ RSpec.describe 'Adding a Note', feature_category: :team_planning do
end
context 'as work item' do
- let(:noteable) { create(:work_item, :issue, project: project) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:noteable) { create(:work_item, :issue, project: project) }
context 'when using internal param' do
let(:variables_extra) { { internal: true } }
@@ -130,6 +131,20 @@ RSpec.describe 'Adding a Note', feature_category: :team_planning do
it_behaves_like 'a mutation that returns top-level errors',
errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
end
+
+ context 'when body contains quick actions' do
+ let_it_be(:noteable) { create(:work_item, :task, project: project) }
+
+ let(:variables_extra) { {} }
+
+ it_behaves_like 'work item supports labels widget updates via quick actions'
+ it_behaves_like 'work item does not support labels widget updates via quick actions'
+ it_behaves_like 'work item supports assignee widget updates via quick actions'
+ it_behaves_like 'work item does not support assignee widget updates via quick actions'
+ it_behaves_like 'work item supports start and due date widget updates via quick actions'
+ it_behaves_like 'work item does not support start and due date widget updates via quick actions'
+ it_behaves_like 'work item supports type change via quick actions'
+ end
end
end
diff --git a/spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb b/spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb
new file mode 100644
index 00000000000..c5dc6f390d9
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "Sync project fork", feature_category: :source_code_management do
+ include GraphqlHelpers
+ include ProjectForksHelper
+ include ExclusiveLeaseHelpers
+
+ let_it_be(:source_project) { create(:project, :repository, :public) }
+ let_it_be(:current_user) { create(:user, maintainer_projects: [source_project]) }
+ let_it_be(:project, refind: true) { fork_project(source_project, current_user, { repository: true }) }
+ let_it_be(:target_branch) { project.default_branch }
+
+ let(:mutation) do
+ params = { project_path: project.full_path, target_branch: target_branch }
+
+ graphql_mutation(:project_sync_fork, params) do
+ <<-QL.strip_heredoc
+ details {
+ ahead
+ behind
+ isSyncing
+ hasConflicts
+ }
+ errors
+ QL
+ end
+ end
+
+ before do
+ source_project.change_head('feature')
+ end
+
+ context 'when synchronize_fork feature flag is disabled' do
+ before do
+ stub_feature_flags(synchronize_fork: false)
+ end
+
+ it 'does not call the sync service' do
+ expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async)
+
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_mutation_response(:project_sync_fork)).to eq(
+ {
+ 'details' => nil,
+ 'errors' => ['Feature flag is disabled']
+ })
+ end
+ end
+
+ context 'when the branch is protected', :use_clean_rails_redis_caching do
+ let_it_be(:protected_branch) do
+ create(:protected_branch, :no_one_can_push, project: project, name: target_branch)
+ end
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not call the sync service' do
+ expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async)
+
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+ end
+
+ context 'when the user does not have permission' do
+ let_it_be(:current_user) { create(:user) }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not call the sync service' do
+ expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async)
+
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+ end
+
+ context 'when the user has permission' do
+ context 'and the sync service executes successfully', :sidekiq_inline do
+ it 'calls the sync service' do
+ expect(::Projects::Forks::SyncWorker).to receive(:perform_async).and_call_original
+
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_mutation_response(:project_sync_fork)).to eq(
+ {
+ 'details' => { 'ahead' => 30, 'behind' => 0, "hasConflicts" => false, "isSyncing" => false },
+ 'errors' => []
+ })
+ end
+ end
+
+ context 'and the sync service fails to execute' do
+ let(:target_branch) { 'markdown' }
+
+ def expect_error_response(message)
+ expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async)
+
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_mutation_response(:project_sync_fork)['errors']).to eq([message])
+ end
+
+ context 'when fork details cannot be resolved' do
+ let_it_be(:project) { source_project }
+
+ it 'returns an error' do
+ expect_error_response('This branch of this project cannot be updated from the upstream')
+ end
+ end
+
+ context 'when the specified branch does not exist' do
+ let(:target_branch) { 'non-existent-branch' }
+
+ it 'returns an error' do
+ expect_error_response('Target branch does not exist')
+ end
+ end
+
+ context 'when the previous execution resulted in a conflict' do
+ it 'returns an error' do
+ expect_next_instance_of(::Projects::Forks::Details) do |instance|
+ expect(instance).to receive(:has_conflicts?).twice.and_return(true)
+ end
+
+ expect_error_response('The synchronization cannot happen due to the merge conflict')
+ expect(graphql_mutation_response(:project_sync_fork)['details']['hasConflicts']).to eq(true)
+ end
+ end
+
+ context 'when the request is rate limited' do
+ it 'returns an error' do
+ expect(Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true)
+
+ expect_error_response('This service has been called too many times.')
+ end
+ end
+
+ context 'when another fork sync is in progress' do
+ it 'returns an error' do
+ expect_next_instance_of(Projects::Forks::Details) do |instance|
+ lease = instance_double(Gitlab::ExclusiveLease, try_obtain: false, exists?: true)
+ expect(instance).to receive(:exclusive_lease).twice.and_return(lease)
+ end
+
+ expect_error_response('Another fork sync is already in progress')
+ expect(graphql_mutation_response(:project_sync_fork)['details']['isSyncing']).to eq(true)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb
index 418a0e47a36..311ff48a846 100644
--- a/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb
@@ -32,7 +32,6 @@ RSpec.describe 'Creation of a new release asset link', feature_category: :releas
url
linkType
directAssetUrl
- external
}
errors
FIELDS
@@ -49,8 +48,7 @@ RSpec.describe 'Creation of a new release asset link', feature_category: :releas
name: mutation_arguments[:name],
url: mutation_arguments[:url],
linkType: mutation_arguments[:linkType],
- directAssetUrl: end_with(mutation_arguments[:directAssetPath]),
- external: true
+ directAssetUrl: end_with(mutation_arguments[:directAssetPath])
}.with_indifferent_access
expect(mutation_response[:link]).to include(expected_response)
diff --git a/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb
index b6d2c3f691d..cda1030c6d6 100644
--- a/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb
+++ b/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb
@@ -22,7 +22,6 @@ RSpec.describe 'Deletes a release asset link', feature_category: :release_orches
url
linkType
directAssetUrl
- external
}
errors
FIELDS
@@ -39,8 +38,7 @@ RSpec.describe 'Deletes a release asset link', feature_category: :release_orches
name: release_link.name,
url: release_link.url,
linkType: release_link.link_type.upcase,
- directAssetUrl: end_with(release_link.filepath),
- external: true
+ directAssetUrl: end_with(release_link.filepath)
}.with_indifferent_access
expect(mutation_response[:link]).to match(expected_response)
diff --git a/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb
index 61395cc4042..45028cba3ae 100644
--- a/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb
@@ -40,7 +40,6 @@ RSpec.describe 'Updating an existing release asset link', feature_category: :rel
url
linkType
directAssetUrl
- external
}
errors
FIELDS
@@ -57,8 +56,7 @@ RSpec.describe 'Updating an existing release asset link', feature_category: :rel
name: mutation_arguments[:name],
url: mutation_arguments[:url],
linkType: mutation_arguments[:linkType],
- directAssetUrl: end_with(mutation_arguments[:directAssetPath]),
- external: true
+ directAssetUrl: end_with(mutation_arguments[:directAssetPath])
}.with_indifferent_access
expect(mutation_response[:link]).to include(expected_response)
diff --git a/spec/requests/api/graphql/mutations/releases/create_spec.rb b/spec/requests/api/graphql/mutations/releases/create_spec.rb
index 295b8c0e97e..7cb421f17a3 100644
--- a/spec/requests/api/graphql/mutations/releases/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/releases/create_spec.rb
@@ -59,7 +59,6 @@ RSpec.describe 'Creation of a new release', feature_category: :release_orchestra
name
url
linkType
- external
directAssetUrl
}
}
@@ -135,7 +134,6 @@ RSpec.describe 'Creation of a new release', feature_category: :release_orchestra
name: asset_link[:name],
url: asset_link[:url],
linkType: asset_link[:linkType],
- external: true,
directAssetUrl: expected_direct_asset_url
}]
}
diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
index fa087e6773c..3b98ee3c2e9 100644
--- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
@@ -193,7 +193,6 @@ RSpec.describe 'Updating a Snippet', feature_category: :source_code_management d
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:user) { current_user }
let(:property) { 'g_edit_by_snippet_ide' }
let(:namespace) { project.namespace }
@@ -203,8 +202,6 @@ RSpec.describe 'Updating a Snippet', feature_category: :source_code_management d
let(:context) do
[Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: event_name).to_context]
end
-
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
end
end
end
diff --git a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb
index 967ad75c906..65b8083c74f 100644
--- a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb
@@ -11,7 +11,8 @@ RSpec.describe Mutations::UserPreferences::Update, feature_category: :user_profi
let(:input) do
{
- 'issuesSort' => sort_value
+ 'issuesSort' => sort_value,
+ 'visibilityPipelineIdType' => 'IID'
}
end
@@ -24,15 +25,20 @@ RSpec.describe Mutations::UserPreferences::Update, feature_category: :user_profi
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['userPreferences']['issuesSort']).to eq(sort_value)
+ expect(mutation_response['userPreferences']['visibilityPipelineIdType']).to eq('IID')
expect(current_user.user_preference.persisted?).to eq(true)
expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s)
+ expect(current_user.user_preference.visibility_pipeline_id_type).to eq('iid')
end
end
context 'when user has existing preference' do
before do
- current_user.create_user_preference!(issues_sort: Types::IssueSortEnum.values['TITLE_DESC'].value)
+ current_user.create_user_preference!(
+ issues_sort: Types::IssueSortEnum.values['TITLE_DESC'].value,
+ visibility_pipeline_id_type: 'id'
+ )
end
it 'updates the existing value' do
@@ -42,8 +48,10 @@ RSpec.describe Mutations::UserPreferences::Update, feature_category: :user_profi
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['userPreferences']['issuesSort']).to eq(sort_value)
+ expect(mutation_response['userPreferences']['visibilityPipelineIdType']).to eq('IID')
expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s)
+ expect(current_user.user_preference.visibility_pipeline_id_type).to eq('iid')
end
end
end
diff --git a/spec/requests/api/graphql/mutations/work_items/convert_spec.rb b/spec/requests/api/graphql/mutations/work_items/convert_spec.rb
new file mode 100644
index 00000000000..97289597331
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/work_items/convert_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "Converts a work item to a new type", feature_category: :team_planning do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
+ let_it_be(:new_type) { create(:work_item_type, :incident, :default) }
+ let_it_be(:work_item, refind: true) do
+ create(:work_item, :task, project: project, milestone: create(:milestone, project: project))
+ end
+
+ let(:work_item_type_id) { new_type.to_global_id.to_s }
+ let(:mutation) { graphql_mutation(:workItemConvert, input) }
+ let(:mutation_response) { graphql_mutation_response(:work_item_convert) }
+ let(:input) do
+ {
+ 'id' => work_item.to_global_id.to_s,
+ 'work_item_type_id' => work_item_type_id
+ }
+ end
+
+ context 'when user is not allowed to update a work item' do
+ let(:current_user) { create(:user) }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user has permissions to convert the work item type' do
+ let(:current_user) { developer }
+
+ context 'when work item type does not exist' do
+ let(:work_item_type_id) { "gid://gitlab/WorkItems::Type/#{non_existing_record_id}" }
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_errors).to include(
+ a_hash_including('message' => "Work Item type with id #{non_existing_record_id} was not found")
+ )
+ end
+ end
+
+ it 'converts the work item', :aggregate_failures do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.to change { work_item.reload.work_item_type }.to(new_type)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(work_item.reload.work_item_type.base_type).to eq('incident')
+ expect(mutation_response['workItem']).to include('id' => work_item.to_global_id.to_s)
+ expect(work_item.reload.milestone).to be_nil
+ end
+
+ it_behaves_like 'has spam protection' do
+ let(:mutation_class) { ::Mutations::WorkItems::Convert }
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb
index 97bf060356a..6a6ad1b14fd 100644
--- a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb
+++ b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe "Create a work item from a task in a work item's description", fe
}
end
- let(:mutation) { graphql_mutation(:workItemCreateFromTask, input) }
+ let(:mutation) { graphql_mutation(:workItemCreateFromTask, input, nil, ['productAnalyticsState']) }
let(:mutation_response) { graphql_mutation_response(:work_item_create_from_task) }
context 'the user is not allowed to update a work item' do
@@ -45,7 +45,6 @@ RSpec.describe "Create a work item from a task in a work item's description", fe
expect(response).to have_gitlab_http_status(:success)
expect(work_item.description).to eq("- [ ] #{created_work_item.to_reference}+")
- expect(created_work_item.issue_type).to eq('task')
expect(created_work_item.work_item_type.base_type).to eq('task')
expect(created_work_item.work_item_parent).to eq(work_item)
expect(created_work_item).to be_confidential
diff --git a/spec/requests/api/graphql/mutations/work_items/create_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_spec.rb
index 16f78b67b5c..fca3c84e534 100644
--- a/spec/requests/api/graphql/mutations/work_items/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/work_items/create_spec.rb
@@ -5,52 +5,43 @@ require 'spec_helper'
RSpec.describe 'Create a work item', feature_category: :team_planning do
include GraphqlHelpers
- let_it_be(:project) { create(:project) }
- let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:developer) { create(:user).tap { |user| group.add_developer(user) } }
let(:input) do
{
'title' => 'new title',
'description' => 'new description',
'confidential' => true,
- 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s
+ 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_gid.to_s
}
end
- let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path)) }
-
+ let(:fields) { nil }
let(:mutation_response) { graphql_mutation_response(:work_item_create) }
+ let(:current_user) { developer }
- context 'the user is not allowed to create a work item' do
- let(:current_user) { create(:user) }
-
- it_behaves_like 'a mutation that returns a top-level access error'
- end
-
- context 'when user has permissions to create a work item' do
- let(:current_user) { developer }
-
+ RSpec.shared_examples 'creates work item' do
it 'creates the work item' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.to change(WorkItem, :count).by(1)
created_work_item = WorkItem.last
-
expect(response).to have_gitlab_http_status(:success)
- expect(created_work_item.issue_type).to eq('task')
expect(created_work_item).to be_confidential
expect(created_work_item.work_item_type.base_type).to eq('task')
expect(mutation_response['workItem']).to include(
input.except('workItemTypeId').merge(
- 'id' => created_work_item.to_global_id.to_s,
+ 'id' => created_work_item.to_gid.to_s,
'workItemType' => hash_including('name' => 'Task')
)
)
end
context 'when input is invalid' do
- let(:input) { { 'title' => '', 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s } }
+ let(:input) { { 'title' => '', 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_gid.to_s } }
it 'does not create and returns validation errors' do
expect do
@@ -90,16 +81,14 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do
FIELDS
end
- let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) }
-
context 'when setting parent' do
- let_it_be(:parent) { create(:work_item, project: project) }
+ let_it_be(:parent) { create(:work_item, **container_params) }
let(:input) do
{
title: 'item1',
- workItemTypeId: WorkItems::Type.default_by_type(:task).to_global_id.to_s,
- hierarchyWidget: { 'parentId' => parent.to_global_id.to_s }
+ workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s,
+ hierarchyWidget: { 'parentId' => parent.to_gid.to_s }
}
end
@@ -110,14 +99,14 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do
expect(widgets_response).to include(
{
'children' => { 'edges' => [] },
- 'parent' => { 'id' => parent.to_global_id.to_s },
+ 'parent' => { 'id' => parent.to_gid.to_s },
'type' => 'HIERARCHY'
}
)
end
context 'when parent work item type is invalid' do
- let_it_be(:parent) { create(:work_item, :task, project: project) }
+ let_it_be(:parent) { create(:work_item, :task, **container_params) }
it 'returns error' do
post_graphql_mutation(mutation, current_user: current_user)
@@ -137,6 +126,40 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do
expect(graphql_errors.first['message']).to include('No object found for `parentId')
end
end
+
+ context 'when adjacent is already in place' do
+ let_it_be(:adjacent) { create(:work_item, :task, **container_params) }
+
+ let(:work_item) { WorkItem.last }
+
+ let(:input) do
+ {
+ title: 'item1',
+ workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s,
+ hierarchyWidget: { 'parentId' => parent.to_gid.to_s }
+ }
+ end
+
+ before(:all) do
+ create(:parent_link, work_item_parent: parent, work_item: adjacent, relative_position: 0)
+ end
+
+ it 'creates work item and sets the relative position to be AFTER adjacent' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.to change(WorkItem, :count).by(1)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(widgets_response).to include(
+ {
+ 'children' => { 'edges' => [] },
+ 'parent' => { 'id' => parent.to_gid.to_s },
+ 'type' => 'HIERARCHY'
+ }
+ )
+ expect(work_item.parent_link.relative_position).to be > adjacent.parent_link.relative_position
+ end
+ end
end
context 'when unsupported widget input is sent' do
@@ -144,7 +167,7 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do
{
'title' => 'new title',
'description' => 'new description',
- 'workItemTypeId' => WorkItems::Type.default_by_type(:test_case).to_global_id.to_s,
+ 'workItemTypeId' => WorkItems::Type.default_by_type(:test_case).to_gid.to_s,
'hierarchyWidget' => {}
}
end
@@ -172,17 +195,15 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do
FIELDS
end
- let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) }
-
context 'when setting milestone on work item creation' do
let_it_be(:project_milestone) { create(:milestone, project: project) }
- let_it_be(:group_milestone) { create(:milestone, project: project) }
+ let_it_be(:group_milestone) { create(:milestone, group: group) }
let(:input) do
{
title: 'some WI',
- workItemTypeId: WorkItems::Type.default_by_type(:task).to_global_id.to_s,
- milestoneWidget: { 'milestoneId' => milestone.to_global_id.to_s }
+ workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s,
+ milestoneWidget: { 'milestoneId' => milestone.to_gid.to_s }
}
end
@@ -196,13 +217,18 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do
expect(widgets_response).to include(
{
'type' => 'MILESTONE',
- 'milestone' => { 'id' => milestone.to_global_id.to_s }
+ 'milestone' => { 'id' => milestone.to_gid.to_s }
}
)
end
end
context 'when assigning a project milestone' do
+ before do
+ group_work_item = container_params[:namespace].present?
+ skip('cannot set a project level milestone to a group level work item') if group_work_item
+ end
+
it_behaves_like "work item's milestone is set" do
let(:milestone) { project_milestone }
end
@@ -216,4 +242,66 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do
end
end
end
+
+ context 'the user is not allowed to create a work item' do
+ let(:current_user) { create(:user) }
+ let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user has permissions to create a work item' do
+ context 'when creating work items in a project' do
+ context 'with projectPath' do
+ let_it_be(:container_params) { { project: project } }
+ let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) }
+
+ it_behaves_like 'creates work item'
+ end
+
+ context 'with namespacePath' do
+ let_it_be(:container_params) { { project: project } }
+ let(:mutation) { graphql_mutation(:workItemCreate, input.merge('namespacePath' => project.full_path), fields) }
+
+ it_behaves_like 'creates work item'
+ end
+ end
+
+ context 'when creating work items in a group' do
+ let_it_be(:container_params) { { namespace: group } }
+ let(:mutation) { graphql_mutation(:workItemCreate, input.merge(namespacePath: group.full_path), fields) }
+
+ it_behaves_like 'creates work item'
+ end
+
+ context 'when both projectPath and namespacePath are passed' do
+ let_it_be(:container_params) { { project: project } }
+ let(:mutation) do
+ graphql_mutation(
+ :workItemCreate,
+ input.merge('projectPath' => project.full_path, 'namespacePath' => project.full_path),
+ fields
+ )
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors', errors: [
+ Mutations::WorkItems::Create::MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR
+ ]
+ end
+
+ context 'when neither of projectPath nor namespacePath are passed' do
+ let_it_be(:container_params) { { project: project } }
+ let(:mutation) do
+ graphql_mutation(
+ :workItemCreate,
+ input,
+ fields
+ )
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors', errors: [
+ Mutations::WorkItems::Create::MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR
+ ]
+ end
+ end
end
diff --git a/spec/requests/api/graphql/mutations/work_items/export_spec.rb b/spec/requests/api/graphql/mutations/work_items/export_spec.rb
new file mode 100644
index 00000000000..d5d07ea65f8
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/work_items/export_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Export work items', feature_category: :team_planning do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } }
+ let_it_be(:guest) { create(:user).tap { |user| project.add_guest(user) } }
+ let_it_be(:work_item) { create(:work_item, project: project) }
+
+ let(:input) { { 'projectPath' => project.full_path } }
+ let(:mutation) { graphql_mutation(:workItemExport, input) }
+ let(:mutation_response) { graphql_mutation_response(:work_item_export) }
+
+ context 'when user is not allowed to export work items' do
+ let(:current_user) { guest }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when import_export_work_items_csv feature flag is disabled' do
+ let(:current_user) { reporter }
+
+ before do
+ stub_feature_flags(import_export_work_items_csv: false)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: ['`import_export_work_items_csv` feature flag is disabled.']
+ end
+
+ context 'when user has permissions to export work items' do
+ let(:current_user) { reporter }
+ let(:input) do
+ super().merge(
+ 'selectedFields' => %w[TITLE DESCRIPTION AUTHOR TYPE AUTHOR_USERNAME CREATED_AT],
+ 'authorUsername' => 'admin',
+ 'iids' => [work_item.iid.to_s],
+ 'state' => 'opened',
+ 'types' => 'TASK',
+ 'search' => 'any',
+ 'in' => 'TITLE'
+ )
+ end
+
+ it 'schedules export job with given arguments', :aggregate_failures do
+ expected_arguments = {
+ selected_fields: ['title', 'description', 'author', 'type', 'author username', 'created_at'],
+ author_username: 'admin',
+ iids: [work_item.iid.to_s],
+ state: 'opened',
+ issue_types: ['task'],
+ search: 'any',
+ in: ['title']
+ }
+
+ expect(IssuableExportCsvWorker)
+ .to receive(:perform_async).with(:work_item, current_user.id, project.id, expected_arguments)
+
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(mutation_response['message']).to eq(
+ 'Your CSV export request has succeeded. The result will be emailed to ' \
+ "#{reporter.notification_email_or_default}."
+ )
+ expect(mutation_response['errors']).to be_empty
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_spec.rb
index ddd294e8f82..ce1c2c01faa 100644
--- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb
@@ -7,20 +7,21 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:author) { create(:user).tap { |user| project.add_reporter(user) } }
let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } }
let_it_be(:guest) { create(:user).tap { |user| project.add_guest(user) } }
- let_it_be(:work_item, refind: true) { create(:work_item, project: project) }
+ let_it_be(:work_item, refind: true) { create(:work_item, project: project, author: author) }
let(:work_item_event) { 'CLOSE' }
let(:input) { { 'stateEvent' => work_item_event, 'title' => 'updated title' } }
let(:fields) do
<<~FIELDS
- workItem {
- state
- title
- }
- errors
+ workItem {
+ state
+ title
+ }
+ errors
FIELDS
end
@@ -81,10 +82,10 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
context 'when updating confidentiality' do
let(:fields) do
<<~FIELDS
- workItem {
- confidential
- }
- errors
+ workItem {
+ confidential
+ }
+ errors
FIELDS
end
@@ -126,18 +127,18 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
context 'with description widget input' do
let(:fields) do
<<~FIELDS
- workItem {
- title
- description
- state
- widgets {
- type
- ... on WorkItemWidgetDescription {
- description
+ workItem {
+ title
+ description
+ state
+ widgets {
+ type
+ ... on WorkItemWidgetDescription {
+ description
+ }
}
}
- }
- errors
+ errors
FIELDS
end
@@ -445,31 +446,84 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
let(:widgets_response) { mutation_response['workItem']['widgets'] }
let(:fields) do
<<~FIELDS
- workItem {
- description
- widgets {
- type
- ... on WorkItemWidgetHierarchy {
- parent {
- id
- }
- children {
- edges {
- node {
- id
+ workItem {
+ description
+ widgets {
+ type
+ ... on WorkItemWidgetHierarchy {
+ parent {
+ id
+ }
+ children {
+ edges {
+ node {
+ id
+ }
}
}
}
}
}
- }
- errors
+ errors
FIELDS
end
+ let_it_be(:valid_parent) { create(:work_item, project: project) }
+ let_it_be(:valid_child1) { create(:work_item, :task, project: project, created_at: 5.minutes.ago) }
+ let_it_be(:valid_child2) { create(:work_item, :task, project: project, created_at: 5.minutes.from_now) }
+ let(:input_base) { { parentId: valid_parent.to_gid.to_s } }
+ let(:child1_ref) { { adjacentWorkItemId: valid_child1.to_global_id.to_s } }
+ let(:child2_ref) { { adjacentWorkItemId: valid_child2.to_global_id.to_s } }
+ let(:relative_range) { [valid_child1, valid_child2].map(&:parent_link).map(&:relative_position) }
+
+ let(:invalid_relative_position_error) do
+ WorkItems::Widgets::HierarchyService::UpdateService::INVALID_RELATIVE_POSITION_ERROR
+ end
+
+ shared_examples 'updates work item parent and sets the relative position' do
+ it do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to change(work_item, :work_item_parent).from(nil).to(valid_parent)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] },
+ 'parent' => { 'id' => valid_parent.to_global_id.to_s } })
+
+ expect(work_item.parent_link.relative_position).to be_between(*relative_range)
+ end
+ end
+
+ shared_examples 'sets the relative position and does not update work item parent' do
+ it do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to not_change(work_item, :work_item_parent)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] },
+ 'parent' => { 'id' => valid_parent.to_global_id.to_s } })
+
+ expect(work_item.parent_link.relative_position).to be_between(*relative_range)
+ end
+ end
+
+ shared_examples 'returns "relative position is not valid" error message' do
+ it do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to not_change(work_item, :work_item_parent)
+
+ expect(mutation_response['workItem']).to be_nil
+ expect(mutation_response['errors']).to match_array([invalid_relative_position_error])
+ end
+ end
+
context 'when updating parent' do
let_it_be(:work_item, reload: true) { create(:work_item, :task, project: project) }
- let_it_be(:valid_parent) { create(:work_item, project: project) }
let_it_be(:invalid_parent) { create(:work_item, :task, project: project) }
context 'when parent work item type is invalid' do
@@ -492,20 +546,15 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
context 'when parent work item has a valid type' do
let(:input) { { 'hierarchyWidget' => { 'parentId' => valid_parent.to_global_id.to_s } } }
- it 'sets the parent for the work item' do
+ it 'updates work item parent' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to change(work_item, :work_item_parent).from(nil).to(valid_parent)
expect(response).to have_gitlab_http_status(:success)
- expect(widgets_response).to include(
- {
- 'children' => { 'edges' => [] },
- 'parent' => { 'id' => valid_parent.to_global_id.to_s },
- 'type' => 'HIERARCHY'
- }
- )
+ expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] },
+ 'parent' => { 'id' => valid_parent.to_global_id.to_s } })
end
context 'when a parent is already present' do
@@ -522,6 +571,31 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
end.to change(work_item, :work_item_parent).from(existing_parent).to(valid_parent)
end
end
+
+ context 'when updating relative position' do
+ before(:all) do
+ create(:parent_link, work_item_parent: valid_parent, work_item: valid_child1)
+ create(:parent_link, work_item_parent: valid_parent, work_item: valid_child2)
+ end
+
+ context "when incomplete positioning arguments are given" do
+ let(:input) { { hierarchyWidget: input_base.merge(child1_ref) } }
+
+ it_behaves_like 'returns "relative position is not valid" error message'
+ end
+
+ context 'when moving after adjacent' do
+ let(:input) { { hierarchyWidget: input_base.merge(child1_ref).merge(relativePosition: 'AFTER') } }
+
+ it_behaves_like 'updates work item parent and sets the relative position'
+ end
+
+ context 'when moving before adjacent' do
+ let(:input) { { hierarchyWidget: input_base.merge(child2_ref).merge(relativePosition: 'BEFORE') } }
+
+ it_behaves_like 'updates work item parent and sets the relative position'
+ end
+ end
end
context 'when parentId is null' do
@@ -577,9 +651,37 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
end
end
+ context 'when reordering existing child' do
+ let_it_be(:work_item, reload: true) { create(:work_item, :task, project: project) }
+
+ context "when parent is already assigned" do
+ before(:all) do
+ create(:parent_link, work_item_parent: valid_parent, work_item: work_item)
+ create(:parent_link, work_item_parent: valid_parent, work_item: valid_child1)
+ create(:parent_link, work_item_parent: valid_parent, work_item: valid_child2)
+ end
+
+ context "when incomplete positioning arguments are given" do
+ let(:input) { { hierarchyWidget: child1_ref } }
+
+ it_behaves_like 'returns "relative position is not valid" error message'
+ end
+
+ context 'when moving after adjacent' do
+ let(:input) { { hierarchyWidget: child1_ref.merge(relativePosition: 'AFTER') } }
+
+ it_behaves_like 'sets the relative position and does not update work item parent'
+ end
+
+ context 'when moving before adjacent' do
+ let(:input) { { hierarchyWidget: child2_ref.merge(relativePosition: 'BEFORE') } }
+
+ it_behaves_like 'sets the relative position and does not update work item parent'
+ end
+ end
+ end
+
context 'when updating children' do
- let_it_be(:valid_child1) { create(:work_item, :task, project: project) }
- let_it_be(:valid_child2) { create(:work_item, :task, project: project) }
let_it_be(:invalid_child) { create(:work_item, project: project) }
let(:input) { { 'hierarchyWidget' => { 'childrenIds' => children_ids } } }
@@ -639,23 +741,29 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
context 'when updating assignees' do
let(:fields) do
<<~FIELDS
- workItem {
- widgets {
- type
- ... on WorkItemWidgetAssignees {
- assignees {
- nodes {
- id
- username
+ workItem {
+ title
+ workItemType { name }
+ widgets {
+ type
+ ... on WorkItemWidgetAssignees {
+ assignees {
+ nodes {
+ id
+ username
+ }
}
}
- }
- ... on WorkItemWidgetDescription {
- description
+ ... on WorkItemWidgetDescription {
+ description
+ }
+ ... on WorkItemWidgetStartAndDueDate {
+ startDate
+ dueDate
+ }
}
}
- }
- errors
+ errors
FIELDS
end
@@ -728,6 +836,79 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
)
end
end
+
+ context 'when changing work item type' do
+ let_it_be(:work_item) { create(:work_item, :task, project: project) }
+ let(:description) { "/type Issue" }
+
+ let(:input) { { 'descriptionWidget' => { 'description' => description } } }
+
+ context 'with multiple commands' do
+ let_it_be(:work_item) { create(:work_item, :task, project: project) }
+
+ let(:description) { "Updating work item\n/type Issue\n/due tomorrow\n/title Foo" }
+
+ it 'updates the work item type and other attributes' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to change { work_item.work_item_type.base_type }.from('task').to('issue')
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['workItemType']['name']).to eq('Issue')
+ expect(mutation_response['workItem']['title']).to eq('Foo')
+ expect(mutation_response['workItem']['widgets']).to include(
+ 'type' => 'START_AND_DUE_DATE',
+ 'dueDate' => Date.tomorrow.strftime('%Y-%m-%d'),
+ 'startDate' => nil
+ )
+ end
+ end
+
+ context 'when conversion is not permitted' do
+ let_it_be(:issue) { create(:work_item, project: project) }
+ let_it_be(:link) { create(:parent_link, work_item_parent: issue, work_item: work_item) }
+
+ let(:error_msg) { 'Work item type cannot be changed to Issue with Issue as parent type.' }
+
+ it 'does not update the work item type' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.not_to change { work_item.work_item_type.base_type }
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['errors']).to include(error_msg)
+ end
+ end
+
+ context 'when new type does not support a widget' do
+ before do
+ work_item.update!(start_date: Date.current, due_date: Date.tomorrow)
+ WorkItems::Type.default_by_type(:issue).widget_definitions
+ .find_by_widget_type(:start_and_due_date).update!(disabled: true)
+ end
+
+ it 'updates the work item type and clear widget attributes' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to change { work_item.work_item_type.base_type }.from('task').to('issue')
+ .and change { work_item.start_date }.to(nil)
+ .and change { work_item.start_date }.to(nil)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['workItemType']['name']).to eq('Issue')
+ expect(mutation_response['workItem']['widgets']).to include(
+ {
+ 'type' => 'START_AND_DUE_DATE',
+ 'startDate' => nil,
+ 'dueDate' => nil
+ }
+ )
+ end
+ end
+ end
end
context 'when the work item type does not support the assignees widget' do
@@ -766,17 +947,17 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
let(:fields) do
<<~FIELDS
- workItem {
- widgets {
- type
- ... on WorkItemWidgetMilestone {
- milestone {
- id
+ workItem {
+ widgets {
+ type
+ ... on WorkItemWidgetMilestone {
+ milestone {
+ id
+ }
}
}
}
- }
- errors
+ errors
FIELDS
end
@@ -843,18 +1024,427 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do
end
end
+ context 'when updating notifications subscription' do
+ let_it_be(:current_user) { reporter }
+ let(:input) { { 'notificationsWidget' => { 'subscribed' => desired_state } } }
+
+ let(:fields) do
+ <<~FIELDS
+ workItem {
+ widgets {
+ type
+ ... on WorkItemWidgetNotifications {
+ subscribed
+ }
+ }
+ }
+ errors
+ FIELDS
+ end
+
+ subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ shared_examples 'subscription updated successfully' do
+ let_it_be(:subscription) do
+ create(
+ :subscription, project: project,
+ user: current_user,
+ subscribable: work_item,
+ subscribed: !desired_state
+ )
+ end
+
+ it "updates existing work item's subscription state" do
+ expect do
+ update_work_item
+ subscription.reload
+ end.to change(subscription, :subscribed).to(desired_state)
+ .and(change { work_item.reload.subscribed?(reporter, project) }.to(desired_state))
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include(
+ {
+ 'subscribed' => desired_state,
+ 'type' => 'NOTIFICATIONS'
+ }
+ )
+ end
+ end
+
+ shared_examples 'subscription update ignored' do
+ context 'when user is subscribed with a subscription record' do
+ let_it_be(:subscription) do
+ create(
+ :subscription, project: project,
+ user: current_user,
+ subscribable: work_item,
+ subscribed: !desired_state
+ )
+ end
+
+ it 'ignores the update request' do
+ expect do
+ update_work_item
+ subscription.reload
+ end.to not_change(subscription, :subscribed)
+ .and(not_change { work_item.subscribed?(current_user, project) })
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+ end
+
+ context 'when user is subscribed by being a participant' do
+ let_it_be(:current_user) { author }
+
+ it 'ignores the update request' do
+ expect do
+ update_work_item
+ end.to not_change(Subscription, :count)
+ .and(not_change { work_item.subscribed?(current_user, project) })
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+ end
+ end
+
+ context 'when work item update fails' do
+ let_it_be(:desired_state) { false }
+ let(:input) { { 'title' => nil, 'notificationsWidget' => { 'subscribed' => desired_state } } }
+
+ it_behaves_like 'subscription update ignored'
+ end
+
+ context 'when user cannot update work item' do
+ let_it_be(:desired_state) { false }
+
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?)
+ .with(current_user, :update_subscription, work_item).and_return(false)
+ end
+
+ it_behaves_like 'subscription update ignored'
+ end
+
+ context 'when user can update work item' do
+ context 'when subscribing to notifications' do
+ let_it_be(:desired_state) { true }
+
+ it_behaves_like 'subscription updated successfully'
+ end
+
+ context 'when unsubscribing from notifications' do
+ let_it_be(:desired_state) { false }
+
+ it_behaves_like 'subscription updated successfully'
+
+ context 'when user is subscribed by being a participant' do
+ let_it_be(:current_user) { author }
+
+ it 'creates a subscription with desired state' do
+ expect { update_work_item }.to change(Subscription, :count).by(1)
+ .and(change { work_item.reload.subscribed?(author, project) }.to(desired_state))
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include(
+ {
+ 'subscribed' => desired_state,
+ 'type' => 'NOTIFICATIONS'
+ }
+ )
+ end
+ end
+ end
+ end
+ end
+
+ context 'when updating currentUserTodos' do
+ let_it_be(:current_user) { reporter }
+
+ let(:fields) do
+ <<~FIELDS
+ workItem {
+ widgets {
+ type
+ ... on WorkItemWidgetCurrentUserTodos {
+ currentUserTodos {
+ nodes {
+ id
+ state
+ }
+ }
+ }
+ }
+ }
+ errors
+ FIELDS
+ end
+
+ subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ context 'when adding a new todo' do
+ let(:input) { { 'currentUserTodosWidget' => { 'action' => 'ADD' } } }
+
+ context 'when user has access to the work item' do
+ it 'adds a new todo for the user on the work item' do
+ expect { update_work_item }.to change { current_user.todos.count }.by(1)
+
+ created_todo = current_user.todos.last
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include(
+ {
+ 'type' => 'CURRENT_USER_TODOS',
+ 'currentUserTodos' => {
+ 'nodes' => [
+ { 'id' => created_todo.to_global_id.to_s, 'state' => 'pending' }
+ ]
+ }
+ }
+ )
+ end
+ end
+
+ context 'when user has no access' do
+ let_it_be(:current_user) { create(:user) }
+
+ it 'does not create a new todo' do
+ expect { update_work_item }.to change { Todo.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+ end
+ end
+
+ context 'when marking all todos of the work item as done' do
+ let_it_be(:pending_todo1) do
+ create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending)
+ end
+
+ let_it_be(:pending_todo2) do
+ create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending)
+ end
+
+ let(:input) { { 'currentUserTodosWidget' => { 'action' => 'MARK_AS_DONE' } } }
+
+ context 'when user has access' do
+ it 'marks all todos of the user on the work item as done' do
+ expect { update_work_item }.to change { current_user.todos.done.count }.by(2)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include(
+ {
+ 'type' => 'CURRENT_USER_TODOS',
+ 'currentUserTodos' => {
+ 'nodes' => match_array([
+ { 'id' => pending_todo1.to_global_id.to_s, 'state' => 'done' },
+ { 'id' => pending_todo2.to_global_id.to_s, 'state' => 'done' }
+ ])
+ }
+ }
+ )
+ end
+ end
+
+ context 'when user has no access' do
+ let_it_be(:current_user) { create(:user) }
+
+ it 'does not mark todos as done' do
+ expect { update_work_item }.to change { Todo.done.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+ end
+ end
+
+ context 'when marking one todo of the work item as done' do
+ let_it_be(:pending_todo1) do
+ create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending)
+ end
+
+ let_it_be(:pending_todo2) do
+ create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending)
+ end
+
+ let(:input) do
+ { 'currentUserTodosWidget' => { 'action' => 'MARK_AS_DONE', todo_id: global_id_of(pending_todo1) } }
+ end
+
+ context 'when user has access' do
+ it 'marks the todo of the work item as done' do
+ expect { update_work_item }.to change { current_user.todos.done.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include(
+ {
+ 'type' => 'CURRENT_USER_TODOS',
+ 'currentUserTodos' => {
+ 'nodes' => match_array([
+ { 'id' => pending_todo1.to_global_id.to_s, 'state' => 'done' },
+ { 'id' => pending_todo2.to_global_id.to_s, 'state' => 'pending' }
+ ])
+ }
+ }
+ )
+ end
+ end
+
+ context 'when user has no access' do
+ let_it_be(:current_user) { create(:user) }
+
+ it 'does not mark the todo as done' do
+ expect { update_work_item }.to change { Todo.done.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+ end
+ end
+ end
+
+ context 'when updating awardEmoji' do
+ let_it_be(:current_user) { work_item.author }
+ let_it_be(:upvote) { create(:award_emoji, :upvote, awardable: work_item, user: current_user) }
+ let(:award_action) { 'ADD' }
+ let(:award_name) { 'star' }
+ let(:input) { { 'awardEmojiWidget' => { 'action' => award_action, 'name' => award_name } } }
+
+ let(:fields) do
+ <<~FIELDS
+ workItem {
+ widgets {
+ type
+ ... on WorkItemWidgetAwardEmoji {
+ upvotes
+ downvotes
+ awardEmoji {
+ nodes {
+ name
+ user { id }
+ }
+ }
+ }
+ }
+ }
+ errors
+ FIELDS
+ end
+
+ subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ context 'when user cannot award work item' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?)
+ .with(current_user, :award_emoji, work_item).and_return(false)
+ end
+
+ it 'ignores the update request' do
+ expect do
+ update_work_item
+ end.to not_change(AwardEmoji, :count)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['errors']).to be_empty
+ expect(graphql_errors).to be_blank
+ end
+ end
+
+ context 'when user can award work item' do
+ shared_examples 'request with error' do |message|
+ it 'ignores update and returns an error' do
+ expect do
+ update_work_item
+ end.not_to change(AwardEmoji, :count)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']).to be_nil
+ expect(mutation_response['errors'].first).to include(message)
+ end
+ end
+
+ shared_examples 'request that removes emoji' do
+ it "updates work item's award emoji" do
+ expect do
+ update_work_item
+ end.to change(AwardEmoji, :count).by(-1)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include(
+ {
+ 'upvotes' => 0,
+ 'downvotes' => 0,
+ 'awardEmoji' => { 'nodes' => [] },
+ 'type' => 'AWARD_EMOJI'
+ }
+ )
+ end
+ end
+
+ shared_examples 'request that adds emoji' do
+ it "updates work item's award emoji" do
+ expect do
+ update_work_item
+ end.to change(AwardEmoji, :count).by(1)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']['widgets']).to include(
+ {
+ 'upvotes' => 1,
+ 'downvotes' => 0,
+ 'awardEmoji' => { 'nodes' => [
+ { 'name' => 'thumbsup', 'user' => { 'id' => current_user.to_gid.to_s } },
+ { 'name' => award_name, 'user' => { 'id' => current_user.to_gid.to_s } }
+ ] },
+ 'type' => 'AWARD_EMOJI'
+ }
+ )
+ end
+ end
+
+ context 'when adding award emoji' do
+ it_behaves_like 'request that adds emoji'
+
+ context 'when the emoji name is not valid' do
+ let(:award_name) { 'xxqq' }
+
+ it_behaves_like 'request with error', 'Name is not a valid emoji name'
+ end
+ end
+
+ context 'when removing award emoji' do
+ let(:award_action) { 'REMOVE' }
+
+ context 'when emoji was awarded by current user' do
+ let(:award_name) { 'thumbsup' }
+
+ it_behaves_like 'request that removes emoji'
+ end
+
+ context 'when emoji was awarded by a different user' do
+ let(:award_name) { 'thumbsdown' }
+
+ before do
+ create(:award_emoji, :downvote, awardable: work_item)
+ end
+
+ it_behaves_like 'request with error',
+ 'User has not awarded emoji of type thumbsdown on the awardable'
+ end
+ end
+ end
+ end
+
context 'when unsupported widget input is sent' do
- let_it_be(:test_case) { create(:work_item_type, :default, :test_case) }
- let_it_be(:work_item) { create(:work_item, work_item_type: test_case, project: project) }
+ let_it_be(:work_item) { create(:work_item, :test_case, project: project) }
let(:input) do
{
- 'hierarchyWidget' => {}
+ 'assigneesWidget' => { 'assigneeIds' => [developer.to_gid.to_s] }
}
end
it_behaves_like 'a mutation that returns top-level errors',
- errors: ["Following widget keys are not supported by Test Case type: [:hierarchy_widget]"]
+ errors: ["Following widget keys are not supported by Test Case type: [:assignees_widget]"]
end
end
end
diff --git a/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb
index 999c685ac6a..717de983871 100644
--- a/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb
+++ b/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe 'Update a work item task', feature_category: :team_planning do
let(:task_params) { { 'title' => 'UPDATED' } }
let(:task_input) { { 'id' => task.to_global_id.to_s }.merge(task_params) }
let(:input) { { 'id' => work_item.to_global_id.to_s, 'taskData' => task_input } }
- let(:mutation) { graphql_mutation(:workItemUpdateTask, input) }
+ let(:mutation) { graphql_mutation(:workItemUpdateTask, input, nil, ['productAnalyticsState']) }
let(:mutation_response) { graphql_mutation_response(:work_item_update_task) }
context 'the user is not allowed to read a work item' do
diff --git a/spec/requests/api/graphql/namespace/projects_spec.rb b/spec/requests/api/graphql/namespace/projects_spec.rb
index 4e12da3e3ab..83edacaf831 100644
--- a/spec/requests/api/graphql/namespace/projects_spec.rb
+++ b/spec/requests/api/graphql/namespace/projects_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe 'getting projects', feature_category: :projects do
projects(includeSubgroups: #{include_subgroups}) {
edges {
node {
- #{all_graphql_fields_for('Project', max_depth: 1)}
+ #{all_graphql_fields_for('Project', max_depth: 1, excluded: ['productAnalyticsState'])}
}
}
}
diff --git a/spec/requests/api/graphql/packages/package_spec.rb b/spec/requests/api/graphql/packages/package_spec.rb
index 82fcc5254ad..7610a4aaac1 100644
--- a/spec/requests/api/graphql/packages/package_spec.rb
+++ b/spec/requests/api/graphql/packages/package_spec.rb
@@ -270,6 +270,31 @@ RSpec.describe 'package details', feature_category: :package_registry do
it 'returns composer_config_repository_url correctly' do
expect(graphql_data_at(:package, :composer_config_repository_url)).to eq("localhost/#{group.id}")
end
+
+ context 'with access to package registry for everyone' do
+ before do
+ project.project_feature.update!(package_registry_access_level: ProjectFeature::PUBLIC)
+ subject
+ end
+
+ it 'returns pypi_url correctly' do
+ expect(graphql_data_at(:package, :pypi_url)).to eq("http://__token__:<your_personal_token>@localhost/api/v4/projects/#{project.id}/packages/pypi/simple")
+ end
+ end
+
+ context 'when project is public' do
+ let_it_be(:public_project) { create(:project, :public, group: group) }
+ let_it_be(:composer_package) { create(:composer_package, project: public_project) }
+ let(:package_global_id) { global_id_of(composer_package) }
+
+ before do
+ subject
+ end
+
+ it 'returns pypi_url correctly' do
+ expect(graphql_data_at(:package, :pypi_url)).to eq("http://localhost/api/v4/projects/#{public_project.id}/packages/pypi/simple")
+ end
+ end
end
context 'web_path' do
diff --git a/spec/requests/api/graphql/project/alert_management/alert/metrics_dashboard_url_spec.rb b/spec/requests/api/graphql/project/alert_management/alert/metrics_dashboard_url_spec.rb
index b430fdeb18f..3417f9529bd 100644
--- a/spec/requests/api/graphql/project/alert_management/alert/metrics_dashboard_url_spec.rb
+++ b/spec/requests/api/graphql/project/alert_management/alert/metrics_dashboard_url_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :projects do
+RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :incident_management do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
@@ -29,6 +29,7 @@ RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :pr
let(:first_alert) { alerts.first }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
project.add_developer(current_user)
end
@@ -44,6 +45,17 @@ RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :pr
expect(first_alert).to include('metricsDashboardUrl' => dashboard_url_for_alert)
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns nil' do
+ post_graphql(graphql_query, current_user: current_user)
+ expect(first_alert['metricsDashboardUrl']).to be_nil
+ end
+ end
end
context 'with gitlab-managed prometheus payload' do
@@ -58,5 +70,16 @@ RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :pr
expect(first_alert).to include('metricsDashboardUrl' => dashboard_url_for_alert)
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns nil' do
+ post_graphql(graphql_query, current_user: current_user)
+ expect(first_alert['metricsDashboardUrl']).to be_nil
+ end
+ end
end
end
diff --git a/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb b/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb
index 16dd0dfcfcb..c1ac0367853 100644
--- a/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb
+++ b/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb
@@ -51,7 +51,7 @@ RSpec.describe 'getting Alert Management Alert Notes', feature_category: :team_p
expect(first_notes_result.first).to include(
'id' => first_system_note.to_global_id.to_s,
- 'systemNoteIconName' => 'git-merge',
+ 'systemNoteIconName' => 'merge',
'body' => first_system_note.note
)
end
diff --git a/spec/requests/api/graphql/project/base_service_spec.rb b/spec/requests/api/graphql/project/base_service_spec.rb
index 7b1b95eaf58..b27cddea07b 100644
--- a/spec/requests/api/graphql/project/base_service_spec.rb
+++ b/spec/requests/api/graphql/project/base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'query Jira service', feature_category: :authentication_and_authorization do
+RSpec.describe 'query Jira service', feature_category: :system_access do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
diff --git a/spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb b/spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb
new file mode 100644
index 00000000000..dd76f6425fe
--- /dev/null
+++ b/spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Project.ci_access_authorized_agents', feature_category: :deployment_management do
+ include GraphqlHelpers
+
+ let_it_be(:organization) { create(:group) }
+ let_it_be(:agent_management_project) { create(:project, :private, group: organization) }
+ let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) }
+
+ let_it_be(:deployment_project) { create(:project, :private, group: organization) }
+ let_it_be(:deployment_developer) { create(:user).tap { |u| deployment_project.add_developer(u) } }
+ let_it_be(:deployment_reporter) { create(:user).tap { |u| deployment_project.add_reporter(u) } }
+
+ let(:user) { deployment_developer }
+
+ let(:query) do
+ %(
+ query {
+ project(fullPath: "#{deployment_project.full_path}") {
+ ciAccessAuthorizedAgents {
+ nodes {
+ agent {
+ id
+ name
+ project {
+ name
+ }
+ }
+ config
+ }
+ }
+ }
+ }
+ )
+ end
+
+ subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
+
+ context 'with project authorization' do
+ let!(:ci_access) { create(:agent_ci_access_project_authorization, agent: agent, project: deployment_project) }
+
+ it 'returns the authorized agent' do
+ authorized_agents = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes')
+
+ expect(authorized_agents.count).to eq(1)
+
+ authorized_agent = authorized_agents.first
+
+ expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s)
+ expect(authorized_agent['agent']['name']).to eq(agent.name)
+ expect(authorized_agent['config']).to eq({ "default_namespace" => "production" })
+ expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources.
+ end
+
+ context 'when user is developer in the agent management project' do
+ before do
+ agent_management_project.add_developer(deployment_developer)
+ end
+
+ it 'returns the project information as well' do
+ authorized_agent = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes').first
+
+ expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name)
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:user) { deployment_reporter }
+
+ it 'returns nothing' do
+ expect(subject['data']['project']['ciAccessAuthorizedAgents']).to be_nil
+ end
+ end
+ end
+
+ context 'with group authorization' do
+ let!(:ci_access) { create(:agent_ci_access_group_authorization, agent: agent, group: organization) }
+
+ it 'returns the authorized agent' do
+ authorized_agents = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes')
+
+ expect(authorized_agents.count).to eq(1)
+
+ authorized_agent = authorized_agents.first
+
+ expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s)
+ expect(authorized_agent['agent']['name']).to eq(agent.name)
+ expect(authorized_agent['config']).to eq({ "default_namespace" => "production" })
+ expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources.
+ end
+
+ context 'when user is developer in the agent management project' do
+ before do
+ agent_management_project.add_developer(deployment_developer)
+ end
+
+ it 'returns the project information as well' do
+ authorized_agent = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes').first
+
+ expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name)
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:user) { deployment_reporter }
+
+ it 'returns nothing' do
+ expect(subject['data']['project']['ciAccessAuthorizedAgents']).to be_nil
+ end
+ end
+ end
+
+ context 'when deployment project is not authorized to ci_access to the agent' do
+ it 'returns empty' do
+ authorized_agents = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes')
+
+ expect(authorized_agents).to be_empty
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/cluster_agents_spec.rb b/spec/requests/api/graphql/project/cluster_agents_spec.rb
index 0881eb9cdc3..181f21001ea 100644
--- a/spec/requests/api/graphql/project/cluster_agents_spec.rb
+++ b/spec/requests/api/graphql/project/cluster_agents_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Project.cluster_agents', feature_category: :kubernetes_management do
+RSpec.describe 'Project.cluster_agents', feature_category: :deployment_management do
include GraphqlHelpers
let_it_be(:project) { create(:project, :public) }
@@ -53,10 +53,11 @@ RSpec.describe 'Project.cluster_agents', feature_category: :kubernetes_managemen
let_it_be(:token_1) { create(:cluster_agent_token, agent: agents.second) }
let_it_be(:token_2) { create(:cluster_agent_token, agent: agents.second, last_used_at: 3.days.ago) }
let_it_be(:token_3) { create(:cluster_agent_token, agent: agents.second, last_used_at: 2.days.ago) }
+ let_it_be(:revoked_token) { create(:cluster_agent_token, :revoked, agent: agents.second) }
let(:cluster_agents_fields) { [:id, query_nodes(:tokens, of: 'ClusterAgentToken')] }
- it 'can select tokens in last_used_at order' do
+ it 'can select active tokens in last_used_at order' do
post_graphql(query, current_user: current_user)
tokens = graphql_data_at(:project, :cluster_agents, :nodes, :tokens, :nodes)
diff --git a/spec/requests/api/graphql/project/commit_references_spec.rb b/spec/requests/api/graphql/project/commit_references_spec.rb
new file mode 100644
index 00000000000..4b545adee12
--- /dev/null
+++ b/spec/requests/api/graphql/project/commit_references_spec.rb
@@ -0,0 +1,240 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.project(fullPath).commitReferences(commitSha)', feature_category: :source_code_management do
+ include GraphqlHelpers
+ include Presentable
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:repository) { project.repository.raw }
+ let_it_be(:current_user) { project.first_owner }
+ let_it_be(:branches_names) { %w[master not-merged-branch v1.1.0] }
+ let_it_be(:tag_name) { 'v1.0.0' }
+ let_it_be(:commit_sha) { repository.commit.id }
+
+ let(:post_query) { post_graphql(query, current_user: current_user) }
+ let(:data) { graphql_data.dig(*path) }
+ let(:base_args) { {} }
+ let(:args) { base_args }
+
+ shared_context 'with the limit argument' do
+ context 'with limit of 2' do
+ let(:args) { { limit: 2 } }
+
+ it 'returns the right amount of refs' do
+ post_query
+ expect(data.count).to be <= 2
+ end
+ end
+
+ context 'with limit of -2' do
+ let(:args) { { limit: -2 } }
+
+ it 'casts an argument error "limit must be greater then 0"' do
+ post_query
+ expect(graphql_errors).to include(custom_graphql_error(path - ['names'],
+ 'limit must be within 1..1000'))
+ end
+ end
+
+ context 'with limit of 1001' do
+ let(:args) { { limit: 1001 } }
+
+ it 'casts an argument error "limit must be greater then 0"' do
+ post_query
+ expect(graphql_errors).to include(custom_graphql_error(path - ['names'],
+ 'limit must be within 1..1000'))
+ end
+ end
+ end
+
+ describe 'the path commitReferences should return nil' do
+ let(:path) { %w[project commitReferences] }
+
+ let(:query) do
+ graphql_query_for(:project, { fullPath: project.full_path },
+ query_graphql_field(
+ :commitReferences,
+ { commitSha: commit_sha },
+ query_graphql_field(:tippingTags, :names)
+ )
+ )
+ end
+
+ context 'when commit does not exist' do
+ let(:commit_sha) { '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff4' }
+
+ it 'commitReferences returns nil' do
+ post_query
+ expect(data).to eq(nil)
+ end
+ end
+
+ context 'when sha length is incorrect' do
+ let(:commit_sha) { 'foo' }
+
+ it 'commitReferences returns nil' do
+ post_query
+ expect(data).to eq(nil)
+ end
+ end
+
+ context 'when user is not authorized' do
+ let(:commit_sha) { repository.commit.id }
+ let(:current_user) { create(:user) }
+
+ it 'commitReferences returns nil' do
+ post_query
+ expect(data).to eq(nil)
+ end
+ end
+ end
+
+ context 'with containing refs' do
+ let(:base_args) { { excludeTipped: false } }
+ let(:excluded_tipped_args) do
+ hash = base_args.dup
+ hash[:excludeTipped] = true
+ hash
+ end
+
+ context 'with path Query.project(fullPath).commitReferences(commitSha).containingTags' do
+ let_it_be(:commit_sha) { repository.find_tag(tag_name).target_commit.sha }
+ let_it_be(:path) { %w[project commitReferences containingTags names] }
+ let(:query) do
+ graphql_query_for(
+ :project,
+ { fullPath: project.full_path },
+ query_graphql_field(
+ :commitReferences,
+ { commitSha: commit_sha },
+ query_graphql_field(:containingTags, args, :names)
+ )
+ )
+ end
+
+ context 'without excludeTipped argument' do
+ it 'returns tags names containing the commit' do
+ post_query
+ expect(data).to eq(%w[v1.0.0 v1.1.0 v1.1.1])
+ end
+ end
+
+ context 'with excludeTipped argument' do
+ let_it_be(:ref_prefix) { Gitlab::Git::TAG_REF_PREFIX }
+
+ let(:args) { excluded_tipped_args }
+
+ it 'returns tags names containing the commit without the tipped tags' do
+ excluded_refs = project.repository
+ .refs_by_oid(oid: commit_sha, ref_patterns: [ref_prefix])
+ .map { |n| n.delete_prefix(ref_prefix) }
+
+ post_query
+ expect(data).to eq(%w[v1.0.0 v1.1.0 v1.1.1] - excluded_refs)
+ end
+ end
+
+ include_context 'with the limit argument'
+ end
+
+ context 'with path Query.project(fullPath).commitReferences(commitSha).containingBranches' do
+ let_it_be(:ref_prefix) { Gitlab::Git::BRANCH_REF_PREFIX }
+ let_it_be(:path) { %w[project commitReferences containingBranches names] }
+
+ let(:query) do
+ graphql_query_for(
+ :project,
+ { fullPath: project.full_path },
+ query_graphql_field(
+ :commitReferences,
+ { commitSha: commit_sha },
+ query_graphql_field(:containingBranches, args, :names)
+ )
+ )
+ end
+
+ context 'without excludeTipped argument' do
+ it 'returns branch names containing the commit' do
+ refs = project.repository.branch_names_contains(commit_sha)
+
+ post_query
+
+ expect(data).to eq(refs)
+ end
+ end
+
+ context 'with excludeTipped argument' do
+ let(:args) { excluded_tipped_args }
+
+ it 'returns branch names containing the commit without the tipped branch' do
+ refs = project.repository.branch_names_contains(commit_sha)
+
+ excluded_refs = project.repository
+ .refs_by_oid(oid: commit_sha, ref_patterns: [ref_prefix])
+ .map { |n| n.delete_prefix(ref_prefix) }
+
+ post_query
+
+ expect(data).to eq(refs - excluded_refs)
+ end
+ end
+
+ include_context 'with the limit argument'
+ end
+ end
+
+ context 'with tipping refs' do
+ context 'with path Query.project(fullPath).commitReferences(commitSha).tippingTags' do
+ let(:commit_sha) { repository.find_tag(tag_name).dereferenced_target.sha }
+ let(:path) { %w[project commitReferences tippingTags names] }
+
+ let(:query) do
+ graphql_query_for(
+ :project,
+ { fullPath: project.full_path },
+ query_graphql_field(
+ :commitReferences,
+ { commitSha: commit_sha },
+ query_graphql_field(:tippingTags, args, :names)
+ )
+ )
+ end
+
+ context 'with authorized user' do
+ it 'returns tags names tipping the commit' do
+ post_query
+
+ expect(data).to eq([tag_name])
+ end
+ end
+
+ include_context 'with the limit argument'
+ end
+
+ context 'with path Query.project(fullPath).commitReferences(commitSha).tippingBranches' do
+ let(:path) { %w[project commitReferences tippingBranches names] }
+
+ let(:query) do
+ graphql_query_for(
+ :project,
+ { fullPath: project.full_path },
+ query_graphql_field(
+ :commitReferences,
+ { commitSha: commit_sha },
+ query_graphql_field(:tippingBranches, args, :names)
+ )
+ )
+ end
+
+ it 'returns branches names tipping the commit' do
+ post_query
+
+ expect(data).to eq(branches_names)
+ end
+
+ include_context 'with the limit argument'
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/container_repositories_spec.rb b/spec/requests/api/graphql/project/container_repositories_spec.rb
index 7ccf8a6f5bf..9a40a972256 100644
--- a/spec/requests/api/graphql/project/container_repositories_spec.rb
+++ b/spec/requests/api/graphql/project/container_repositories_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe 'getting container repositories in a project', feature_category:
let_it_be(:container_repositories) { [container_repository, container_repositories_delete_scheduled, container_repositories_delete_failed].flatten }
let_it_be(:container_expiration_policy) { project.container_expiration_policy }
- let(:excluded_fields) { %w[pipeline jobs] }
+ let(:excluded_fields) { %w[pipeline jobs productAnalyticsState] }
let(:container_repositories_fields) do
<<~GQL
edges {
@@ -155,7 +155,7 @@ RSpec.describe 'getting container repositories in a project', feature_category:
it_behaves_like 'handling graphql network errors with the container registry'
it_behaves_like 'not hitting graphql network errors with the container registry' do
- let(:excluded_fields) { %w[pipeline jobs tags tagsCount] }
+ let(:excluded_fields) { %w[pipeline jobs tags tagsCount productAnalyticsState] }
end
it 'returns the total count of container repositories' do
diff --git a/spec/requests/api/graphql/project/data_transfer_spec.rb b/spec/requests/api/graphql/project/data_transfer_spec.rb
new file mode 100644
index 00000000000..aafa8d65eb9
--- /dev/null
+++ b/spec/requests/api/graphql/project/data_transfer_spec.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'project data transfers', feature_category: :source_code_management do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ let(:fields) do
+ <<~QUERY
+ #{all_graphql_fields_for('ProjectDataTransfer'.classify)}
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'project',
+ { fullPath: project.full_path },
+ query_graphql_field('DataTransfer', params, fields)
+ )
+ end
+
+ let(:from) { Date.new(2022, 1, 1) }
+ let(:to) { Date.new(2023, 1, 1) }
+ let(:params) { { from: from, to: to } }
+ let(:egress_data) do
+ graphql_data.dig('project', 'dataTransfer', 'egressNodes', 'nodes')
+ end
+
+ before do
+ create(:project_data_transfer, project: project, date: '2022-01-01', repository_egress: 1)
+ create(:project_data_transfer, project: project, date: '2022-02-01', repository_egress: 2)
+ end
+
+ subject { post_graphql(query, current_user: current_user) }
+
+ context 'with anonymous access' do
+ let_it_be(:current_user) { nil }
+
+ before do
+ subject
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns no data' do
+ expect(graphql_data_at(:project, :data_transfer)).to be_nil
+ expect(graphql_errors).to be_nil
+ end
+ end
+
+ context 'with authorized user but without enough permissions' do
+ before do
+ project.add_developer(current_user)
+ subject
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns empty results' do
+ expect(graphql_data_at(:project, :data_transfer)).to be_nil
+ expect(graphql_errors).to be_nil
+ end
+ end
+
+ context 'when user has enough permissions' do
+ before do
+ project.add_owner(current_user)
+ end
+
+ context 'when data_transfer_monitoring_mock_data is NOT enabled' do
+ before do
+ stub_feature_flags(data_transfer_monitoring_mock_data: false)
+ subject
+ end
+
+ it 'returns real results' do
+ expect(response).to have_gitlab_http_status(:ok)
+
+ expect(egress_data.count).to eq(2)
+
+ expect(egress_data.first.keys).to match_array(
+ %w[date totalEgress repositoryEgress artifactsEgress packagesEgress registryEgress]
+ )
+
+ expect(egress_data.pluck('repositoryEgress')).to match_array(%w[1 2])
+ end
+
+ it_behaves_like 'a working graphql query'
+ end
+
+ context 'when data_transfer_monitoring_mock_data is enabled' do
+ before do
+ stub_feature_flags(data_transfer_monitoring_mock_data: true)
+ subject
+ end
+
+ it 'returns mock results' do
+ expect(response).to have_gitlab_http_status(:ok)
+
+ expect(egress_data.count).to eq(12)
+ expect(egress_data.first.keys).to match_array(
+ %w[date totalEgress repositoryEgress artifactsEgress packagesEgress registryEgress]
+ )
+ end
+
+ it_behaves_like 'a working graphql query'
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/environments_spec.rb b/spec/requests/api/graphql/project/environments_spec.rb
index 618f591affa..bb1763ee228 100644
--- a/spec/requests/api/graphql/project/environments_spec.rb
+++ b/spec/requests/api/graphql/project/environments_spec.rb
@@ -102,7 +102,7 @@ RSpec.describe 'Project Environments query', feature_category: :continuous_deliv
end
describe 'last deployments of environments' do
- ::Deployment.statuses.each do |status, _|
+ ::Deployment.statuses.each do |status, _| # rubocop:disable RSpec/UselessDynamicDefinition
let_it_be(:"production_#{status}_deployment") do
create(:deployment, status.to_sym, environment: production, project: project)
end
diff --git a/spec/requests/api/graphql/project/flow_metrics_spec.rb b/spec/requests/api/graphql/project/flow_metrics_spec.rb
new file mode 100644
index 00000000000..3b5758b3a2e
--- /dev/null
+++ b/spec/requests/api/graphql/project/flow_metrics_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting project flow metrics', feature_category: :value_stream_management do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project1) { create(:project, :repository, group: group) }
+ # This is done so we can use the same count expectations in the shared examples and
+ # reuse the shared example for the group-level test.
+ let_it_be(:project2) { project1 }
+ let_it_be(:production_environment1) { create(:environment, :production, project: project1) }
+ let_it_be(:production_environment2) { production_environment1 }
+ let_it_be(:current_user) { create(:user, maintainer_projects: [project1]) }
+
+ let(:full_path) { project1.full_path }
+ let(:context) { :project }
+
+ it_behaves_like 'value stream analytics flow metrics issueCount examples'
+
+ it_behaves_like 'value stream analytics flow metrics deploymentCount examples'
+end
diff --git a/spec/requests/api/graphql/project/fork_details_spec.rb b/spec/requests/api/graphql/project/fork_details_spec.rb
index efd48b00833..91a04dc7c50 100644
--- a/spec/requests/api/graphql/project/fork_details_spec.rb
+++ b/spec/requests/api/graphql/project/fork_details_spec.rb
@@ -10,12 +10,13 @@ RSpec.describe 'getting project fork details', feature_category: :source_code_ma
let_it_be(:current_user) { create(:user, maintainer_projects: [project]) }
let_it_be(:forked_project) { fork_project(project, current_user, repository: true) }
+ let(:ref) { 'feature' }
let(:queried_project) { forked_project }
let(:query) do
graphql_query_for(:project,
{ full_path: queried_project.full_path }, <<~QUERY
- forkDetails(ref: "feature"){
+ forkDetails(ref: "#{ref}"){
ahead
behind
}
@@ -23,12 +24,23 @@ RSpec.describe 'getting project fork details', feature_category: :source_code_ma
)
end
- it 'returns fork details' do
- post_graphql(query, current_user: current_user)
+ context 'when a ref is specified' do
+ using RSpec::Parameterized::TableSyntax
- expect(graphql_data['project']['forkDetails']).to eq(
- { 'ahead' => 1, 'behind' => 29 }
- )
+ where(:ref, :counts) do
+ 'feature' | { 'ahead' => 1, 'behind' => 29 }
+ 'v1.1.1' | { 'ahead' => 5, 'behind' => 0 }
+ '7b5160f9bb23a3d58a0accdbe89da13b96b1ece9' | { 'ahead' => 9, 'behind' => 0 }
+ 'non-existent-branch' | { 'ahead' => nil, 'behind' => nil }
+ end
+
+ with_them do
+ it 'returns fork details' do
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_data['project']['forkDetails']).to eq(counts)
+ end
+ end
end
context 'when a project is not a fork' do
@@ -41,6 +53,16 @@ RSpec.describe 'getting project fork details', feature_category: :source_code_ma
end
end
+ context 'when project source is not visible' do
+ it 'does not return fork details' do
+ project.team.truncate
+
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_data['project']['forkDetails']).to be_nil
+ end
+ end
+
context 'when a user cannot read the code' do
let_it_be(:current_user) { create(:user) }
diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb
index 76e5d687fd1..80c7258c05d 100644
--- a/spec/requests/api/graphql/project/merge_request_spec.rb
+++ b/spec/requests/api/graphql/project/merge_request_spec.rb
@@ -480,4 +480,31 @@ RSpec.describe 'getting merge request information nested in a project', feature_
merge_request.assignees << user
end
end
+
+ context 'when selecting `awardEmoji`' do
+ let_it_be(:award_emoji) { create(:award_emoji, awardable: merge_request, user: current_user) }
+
+ let(:mr_fields) do
+ <<~QUERY
+ awardEmoji {
+ nodes {
+ user {
+ username
+ }
+ name
+ }
+ }
+ QUERY
+ end
+
+ it 'includes award emojis' do
+ post_graphql(query, current_user: current_user)
+
+ response = merge_request_graphql_data['awardEmoji']['nodes']
+
+ expect(response.length).to eq(1)
+ expect(response.first['user']['username']).to eq(current_user.username)
+ expect(response.first['name']).to eq(award_emoji.name)
+ end
+ end
end
diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb
index 8407faa967e..e3c4396e7d8 100644
--- a/spec/requests/api/graphql/project/merge_requests_spec.rb
+++ b/spec/requests/api/graphql/project/merge_requests_spec.rb
@@ -226,6 +226,28 @@ RSpec.describe 'getting merge request listings nested in a project', feature_cat
it_behaves_like 'when searching with parameters'
end
+ context 'when searching by approved' do
+ let(:approved_mr) { create(:merge_request, target_project: project, source_project: project) }
+
+ before do
+ create(:approval, merge_request: approved_mr)
+ end
+
+ context 'when true' do
+ let(:search_params) { { approved: true } }
+ let(:mrs) { [approved_mr] }
+
+ it_behaves_like 'when searching with parameters'
+ end
+
+ context 'when false' do
+ let(:search_params) { { approved: false } }
+ let(:mrs) { all_merge_requests }
+
+ it_behaves_like 'when searching with parameters'
+ end
+ end
+
context 'when requesting `approved_by`' do
let(:search_params) { { iids: [merge_request_a.iid.to_s, merge_request_b.iid.to_s] } }
let(:extra_iid_for_second_query) { merge_request_c.iid.to_s }
@@ -331,7 +353,7 @@ RSpec.describe 'getting merge request listings nested in a project', feature_cat
end
context 'when award emoji votes' do
- let(:requested_fields) { [:upvotes, :downvotes] }
+ let(:requested_fields) { 'upvotes downvotes awardEmoji { nodes { name } }' }
before do
create_list(:award_emoji, 2, name: 'thumbsup', awardable: merge_request_a)
@@ -588,8 +610,9 @@ RSpec.describe 'getting merge request listings nested in a project', feature_cat
end
let(:query) do
+ # Adding a no-op `not` filter to mimic the same query as the frontend does
graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY)
- mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0) {
+ mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0, not: { labels: null }) {
totalTimeToMerge
count
}
diff --git a/spec/requests/api/graphql/project/milestones_spec.rb b/spec/requests/api/graphql/project/milestones_spec.rb
index 3b31da77a75..7a79bf2184a 100644
--- a/spec/requests/api/graphql/project/milestones_spec.rb
+++ b/spec/requests/api/graphql/project/milestones_spec.rb
@@ -137,18 +137,6 @@ RSpec.describe 'getting milestone listings nested in a project', feature_categor
it_behaves_like 'searching with parameters'
end
- context 'searching by custom range' do
- let(:expected) { [no_end, fully_future] }
- let(:search_params) do
- {
- start_date: (today + 6.days).iso8601,
- end_date: (today + 7.days).iso8601
- }
- end
-
- it_behaves_like 'searching with parameters'
- end
-
context 'using timeframe argument' do
let(:expected) { [no_end, fully_future] }
let(:search_params) do
@@ -188,23 +176,6 @@ RSpec.describe 'getting milestone listings nested in a project', feature_categor
end
end
- it 'is invalid to provide timeframe and start_date/end_date' do
- query = <<~GQL
- query($path: ID!, $tstart: Date!, $tend: Date!, $start: Time!, $end: Time!) {
- project(fullPath: $path) {
- milestones(timeframe: { start: $tstart, end: $tend }, startDate: $start, endDate: $end) {
- nodes { id }
- }
- }
- }
- GQL
-
- post_graphql(query, current_user: current_user,
- variables: vars.merge(vars.transform_keys { |k| :"t#{k}" }))
-
- expect(graphql_errors).to contain_exactly(a_hash_including('message' => include('deprecated in favor of timeframe')))
- end
-
it 'is invalid to invert the timeframe arguments' do
query = <<~GQL
query($path: ID!, $start: Date!, $end: Date!) {
diff --git a/spec/requests/api/graphql/project/project_statistics_redirect_spec.rb b/spec/requests/api/graphql/project/project_statistics_redirect_spec.rb
new file mode 100644
index 00000000000..8049a75ace3
--- /dev/null
+++ b/spec/requests/api/graphql/project/project_statistics_redirect_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'rendering project storage type routes', feature_category: :shared do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:user) { create(:user) }
+
+ let(:query) do
+ graphql_query_for('project',
+ { 'fullPath' => project.full_path },
+ "statisticsDetailsPaths { #{all_graphql_fields_for('ProjectStatisticsRedirect')} }")
+ end
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ post_graphql(query, current_user: user)
+ end
+ end
+
+ shared_examples 'valid routes for storage type' do
+ it 'contains all keys' do
+ post_graphql(query, current_user: user)
+
+ expect(graphql_data['project']['statisticsDetailsPaths'].keys).to match_array(
+ %w[repository buildArtifacts wiki packages snippets containerRegistry]
+ )
+ end
+
+ it 'contains valid paths' do
+ repository_url = Gitlab::Routing.url_helpers.project_tree_url(project, "master")
+ wiki_url = Gitlab::Routing.url_helpers.project_wikis_pages_url(project)
+ build_artifacts_url = Gitlab::Routing.url_helpers.project_artifacts_url(project)
+ packages_url = Gitlab::Routing.url_helpers.project_packages_url(project)
+ snippets_url = Gitlab::Routing.url_helpers.project_snippets_url(project)
+ container_registry_url = Gitlab::Routing.url_helpers.project_container_registry_index_url(project)
+
+ post_graphql(query, current_user: user)
+
+ expect(graphql_data['project']['statisticsDetailsPaths'].values).to match_array [repository_url,
+ wiki_url,
+ build_artifacts_url,
+ packages_url,
+ snippets_url,
+ container_registry_url]
+ end
+ end
+
+ context 'when project is public' do
+ it_behaves_like 'valid routes for storage type'
+
+ context 'when user is nil' do
+ let_it_be(:user) { nil }
+
+ it_behaves_like 'valid routes for storage type'
+ end
+ end
+
+ context 'when project is private' do
+ let_it_be(:project) { create(:project, :private) }
+
+ before do
+ project.add_reporter(user)
+ end
+
+ it_behaves_like 'valid routes for storage type'
+
+ context 'when user is nil' do
+ it 'hides statisticsDetailsPaths for nil users' do
+ post_graphql(query, current_user: nil)
+
+ expect(graphql_data['project']).to be_blank
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/release_spec.rb b/spec/requests/api/graphql/project/release_spec.rb
index 477388585ca..8d4a39d6b30 100644
--- a/spec/requests/api/graphql/project/release_spec.rb
+++ b/spec/requests/api/graphql/project/release_spec.rb
@@ -132,7 +132,7 @@ RSpec.describe 'Query.project(fullPath).release(tagName)', feature_category: :re
let(:release_fields) do
query_graphql_field(:assets, nil,
- query_graphql_field(:links, nil, 'nodes { id name url external, directAssetUrl }'))
+ query_graphql_field(:links, nil, 'nodes { id name url, directAssetUrl }'))
end
it 'finds all release links' do
@@ -141,7 +141,6 @@ RSpec.describe 'Query.project(fullPath).release(tagName)', feature_category: :re
expected = release.links.map do |link|
a_graphql_entity_for(
link, :name, :url,
- 'external' => link.external?,
'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << "/downloads#{link.filepath}" : link.url
)
end
@@ -322,16 +321,15 @@ RSpec.describe 'Query.project(fullPath).release(tagName)', feature_category: :re
let(:release_fields) do
query_graphql_field(:assets, nil,
- query_graphql_field(:links, nil, 'nodes { id name url external, directAssetUrl }'))
+ query_graphql_field(:links, nil, 'nodes { id name url, directAssetUrl }'))
end
- it 'finds all non source external release links' do
+ it 'finds all non source release links' do
post_query
expected = release.links.map do |link|
a_graphql_entity_for(
link, :name, :url,
- 'external' => true,
'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << "/downloads#{link.filepath}" : link.url
)
end
diff --git a/spec/requests/api/graphql/project/user_access_authorized_agents_spec.rb b/spec/requests/api/graphql/project/user_access_authorized_agents_spec.rb
new file mode 100644
index 00000000000..b8017171fd1
--- /dev/null
+++ b/spec/requests/api/graphql/project/user_access_authorized_agents_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Project.user_access_authorized_agents', feature_category: :deployment_management do
+ include GraphqlHelpers
+
+ let_it_be(:organization) { create(:group) }
+ let_it_be(:agent_management_project) { create(:project, :private, group: organization) }
+ let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) }
+
+ let_it_be(:deployment_project) { create(:project, :private, group: organization) }
+ let_it_be(:deployment_developer) { create(:user).tap { |u| deployment_project.add_developer(u) } }
+ let_it_be(:deployment_reporter) { create(:user).tap { |u| deployment_project.add_reporter(u) } }
+
+ let(:user) { deployment_developer }
+
+ let(:query) do
+ %(
+ query {
+ project(fullPath: "#{deployment_project.full_path}") {
+ userAccessAuthorizedAgents {
+ nodes {
+ agent {
+ id
+ name
+ project {
+ name
+ }
+ }
+ config
+ }
+ }
+ }
+ }
+ )
+ end
+
+ subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
+
+ context 'with project authorization' do
+ let!(:user_access) { create(:agent_user_access_project_authorization, agent: agent, project: deployment_project) }
+
+ it 'returns the authorized agent' do
+ authorized_agents = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes')
+
+ expect(authorized_agents.count).to eq(1)
+
+ authorized_agent = authorized_agents.first
+
+ expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s)
+ expect(authorized_agent['agent']['name']).to eq(agent.name)
+ expect(authorized_agent['config']).to eq({})
+ expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources.
+ end
+
+ context 'when user is developer in the agent management project' do
+ before do
+ agent_management_project.add_developer(deployment_developer)
+ end
+
+ it 'returns the project information as well' do
+ authorized_agent = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes').first
+
+ expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name)
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:user) { deployment_reporter }
+
+ it 'returns nothing' do
+ expect(subject['data']['project']['userAccessAuthorizedAgents']).to be_nil
+ end
+ end
+ end
+
+ context 'with group authorization' do
+ let_it_be(:deployment_group) { create(:group, :private, parent: organization) }
+
+ let!(:user_access) { create(:agent_user_access_group_authorization, agent: agent, group: deployment_group) }
+
+ before_all do
+ deployment_group.add_developer(deployment_developer)
+ deployment_group.add_reporter(deployment_reporter)
+ end
+
+ it 'returns the authorized agent' do
+ authorized_agents = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes')
+
+ expect(authorized_agents.count).to eq(1)
+
+ authorized_agent = authorized_agents.first
+
+ expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s)
+ expect(authorized_agent['agent']['name']).to eq(agent.name)
+ expect(authorized_agent['config']).to eq({})
+ expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources.
+ end
+
+ context 'when user is developer in the agent management project' do
+ before do
+ agent_management_project.add_developer(deployment_developer)
+ end
+
+ it 'returns the project information as well' do
+ authorized_agent = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes').first
+
+ expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name)
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:user) { deployment_reporter }
+
+ it 'returns nothing' do
+ expect(subject['data']['project']['userAccessAuthorizedAgents']).to be_nil
+ end
+ end
+ end
+
+ context 'when deployment project is not authorized to user_access to the agent' do
+ it 'returns empty' do
+ authorized_agents = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes')
+
+ expect(authorized_agents).to be_empty
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/work_items_spec.rb b/spec/requests/api/graphql/project/work_items_spec.rb
index f49165a88ea..628a2117e9d 100644
--- a/spec/requests/api/graphql/project/work_items_spec.rb
+++ b/spec/requests/api/graphql/project/work_items_spec.rb
@@ -120,24 +120,55 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team
end
context 'when querying WorkItemWidgetHierarchy' do
- let_it_be(:children) { create_list(:work_item, 3, :task, project: project) }
+ let_it_be(:children) { create_list(:work_item, 4, :task, project: project) }
let_it_be(:child_link1) { create(:parent_link, work_item_parent: item1, work_item: children[0]) }
+ let_it_be(:child_link2) { create(:parent_link, work_item_parent: item1, work_item: children[1]) }
let(:fields) do
<<~GRAPHQL
- nodes {
- widgets {
- type
- ... on WorkItemWidgetHierarchy {
- hasChildren
- parent { id }
- children { nodes { id } }
- }
+ nodes {
+ id
+ widgets {
+ type
+ ... on WorkItemWidgetHierarchy {
+ hasChildren
+ parent { id }
+ children { nodes { id } }
}
}
+ }
GRAPHQL
end
+ context 'with ordered children' do
+ let(:items_data) { graphql_data['project']['workItems']['nodes'] }
+ let(:work_item_data) { items_data.find { |item| item['id'] == item1.to_gid.to_s } }
+ let(:work_item_widget) { work_item_data["widgets"].find { |widget| widget.key?("children") } }
+ let(:children_ids) { work_item_widget.dig("children", "nodes").pluck("id") }
+
+ let(:first_child) { children[0].to_gid.to_s }
+ let(:second_child) { children[1].to_gid.to_s }
+
+ it 'returns children ordered by created_at by default' do
+ post_graphql(query, current_user: current_user)
+
+ expect(children_ids).to eq([first_child, second_child])
+ end
+
+ context 'when ordered by relative position' do
+ before do
+ child_link1.update!(relative_position: 20)
+ child_link2.update!(relative_position: 10)
+ end
+
+ it 'returns children in correct order' do
+ post_graphql(query, current_user: current_user)
+
+ expect(children_ids).to eq([second_child, first_child])
+ end
+ end
+ end
+
it 'executes limited number of N+1 queries' do
post_graphql(query, current_user: current_user) # warm-up
@@ -146,13 +177,11 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team
end
parent_work_items = create_list(:work_item, 2, project: project)
- create(:parent_link, work_item_parent: parent_work_items[0], work_item: children[1])
- create(:parent_link, work_item_parent: parent_work_items[1], work_item: children[2])
+ create(:parent_link, work_item_parent: parent_work_items[0], work_item: children[2])
+ create(:parent_link, work_item_parent: parent_work_items[1], work_item: children[3])
- # There are 2 extra queries for fetching the children field
- # See: https://gitlab.com/gitlab-org/gitlab/-/issues/363569
expect { post_graphql(query, current_user: current_user) }
- .not_to exceed_query_limit(control).with_threshold(2)
+ .not_to exceed_query_limit(control)
end
it 'avoids N+1 queries when children are added to a work item' do
@@ -162,8 +191,8 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team
post_graphql(query, current_user: current_user)
end
- create(:parent_link, work_item_parent: item1, work_item: children[1])
create(:parent_link, work_item_parent: item1, work_item: children[2])
+ create(:parent_link, work_item_parent: item1, work_item: children[3])
expect { post_graphql(query, current_user: current_user) }
.not_to exceed_query_limit(control)
@@ -313,6 +342,79 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team
end
end
+ context 'when fetching work item notifications widget' do
+ let(:fields) do
+ <<~GRAPHQL
+ nodes {
+ widgets {
+ type
+ ... on WorkItemWidgetNotifications {
+ subscribed
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ it 'executes limited number of N+1 queries', :use_sql_query_cache do
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post_graphql(query, current_user: current_user)
+ end
+
+ create_list(:work_item, 3, project: project)
+
+ # Performs 1 extra query per item to fetch subscriptions
+ expect { post_graphql(query, current_user: current_user) }
+ .not_to exceed_all_query_limit(control).with_threshold(3)
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'when fetching work item award emoji widget' do
+ let(:fields) do
+ <<~GRAPHQL
+ nodes {
+ widgets {
+ type
+ ... on WorkItemWidgetAwardEmoji {
+ awardEmoji {
+ nodes {
+ name
+ emoji
+ user { id }
+ }
+ }
+ upvotes
+ downvotes
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ before do
+ create(:award_emoji, name: 'star', user: current_user, awardable: item1)
+ create(:award_emoji, :upvote, awardable: item1)
+ create(:award_emoji, :downvote, awardable: item1)
+ end
+
+ it 'executes limited number of N+1 queries', :use_sql_query_cache do
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post_graphql(query, current_user: current_user)
+ end
+
+ create_list(:work_item, 2, project: project) do |item|
+ create(:award_emoji, name: 'rocket', awardable: item)
+ create_list(:award_emoji, 2, :upvote, awardable: item)
+ create_list(:award_emoji, 2, :downvote, awardable: item)
+ end
+
+ expect { post_graphql(query, current_user: current_user) }
+ .not_to exceed_all_query_limit(control)
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
def item_ids
graphql_dig_at(items_data, :node, :id)
end
diff --git a/spec/requests/api/graphql/project_query_spec.rb b/spec/requests/api/graphql/project_query_spec.rb
index 281a08e6548..9f51258c163 100644
--- a/spec/requests/api/graphql/project_query_spec.rb
+++ b/spec/requests/api/graphql/project_query_spec.rb
@@ -120,6 +120,67 @@ RSpec.describe 'getting project information', feature_category: :projects do
end
end
+ describe 'is_catalog_resource' do
+ before do
+ project.add_owner(current_user)
+ end
+
+ let(:catalog_resource_query) do
+ <<~GRAPHQL
+ {
+ project(fullPath: "#{project.full_path}") {
+ isCatalogResource
+ }
+ }
+ GRAPHQL
+ end
+
+ context 'when the project is not a catalog resource' do
+ it 'is false' do
+ post_graphql(catalog_resource_query, current_user: current_user)
+
+ expect(graphql_data.dig('project', 'isCatalogResource')).to be(false)
+ end
+ end
+
+ context 'when the project is a catalog resource' do
+ before do
+ create(:catalog_resource, project: project)
+ end
+
+ it 'is true' do
+ post_graphql(catalog_resource_query, current_user: current_user)
+
+ expect(graphql_data.dig('project', 'isCatalogResource')).to be(true)
+ end
+ end
+
+ context 'for N+1 queries with isCatalogResource' do
+ let_it_be(:project1) { create(:project, group: group) }
+ let_it_be(:project2) { create(:project, group: group) }
+
+ it 'avoids N+1 database queries' do
+ pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/403634')
+ ctx = { current_user: current_user }
+
+ baseline_query = graphql_query_for(:project, { full_path: project1.full_path }, 'isCatalogResource')
+
+ query = <<~GQL
+ query {
+ a: #{query_graphql_field(:project, { full_path: project1.full_path }, 'isCatalogResource')}
+ b: #{query_graphql_field(:project, { full_path: project2.full_path }, 'isCatalogResource')}
+ }
+ GQL
+
+ control = ActiveRecord::QueryRecorder.new do
+ run_with_clean_state(baseline_query, context: ctx)
+ end
+
+ expect { run_with_clean_state(query, context: ctx) }.not_to exceed_query_limit(control)
+ end
+ end
+ end
+
context 'when the user has reporter access to the project' do
let(:statistics_query) do
<<~GRAPHQL
diff --git a/spec/requests/api/graphql/query_spec.rb b/spec/requests/api/graphql/query_spec.rb
index 2b9d66ec744..0602cfec149 100644
--- a/spec/requests/api/graphql/query_spec.rb
+++ b/spec/requests/api/graphql/query_spec.rb
@@ -2,10 +2,10 @@
require 'spec_helper'
-RSpec.describe 'Query', feature_category: :not_owned do
+RSpec.describe 'Query', feature_category: :shared do
include GraphqlHelpers
- let_it_be(:project) { create(:project) }
+ let_it_be(:project) { create(:project, public_builds: false) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:developer) { create(:user) }
@@ -116,4 +116,36 @@ RSpec.describe 'Query', feature_category: :not_owned do
end
end
end
+
+ describe '.ciPipelineStage' do
+ let_it_be(:ci_stage) { create(:ci_stage, name: 'graphql test stage', project: project) }
+
+ let(:query) do
+ <<~GRAPHQL
+ {
+ ciPipelineStage(id: "#{ci_stage.to_global_id}") {
+ name
+ }
+ }
+ GRAPHQL
+ end
+
+ context 'when the current user has access to the stage' do
+ it 'fetches the stage for the given ID' do
+ project.add_developer(developer)
+
+ post_graphql(query, current_user: developer)
+
+ expect(graphql_data.dig('ciPipelineStage', 'name')).to eq('graphql test stage')
+ end
+ end
+
+ context 'when the current user does not have access to the stage' do
+ it 'returns nil' do
+ post_graphql(query, current_user: developer)
+
+ expect(graphql_data['ciPipelineStage']).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/requests/api/graphql/user/user_achievements_query_spec.rb b/spec/requests/api/graphql/user/user_achievements_query_spec.rb
new file mode 100644
index 00000000000..27d32d07372
--- /dev/null
+++ b/spec/requests/api/graphql/user/user_achievements_query_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'UserAchievements', feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+ let_it_be(:non_revoked_achievement) { create(:user_achievement, achievement: achievement, user: user) }
+ let_it_be(:revoked_achievement) { create(:user_achievement, :revoked, achievement: achievement, user: user) }
+ let_it_be(:fields) do
+ <<~HEREDOC
+ userAchievements {
+ nodes {
+ id
+ achievement {
+ id
+ }
+ user {
+ id
+ }
+ awardedByUser {
+ id
+ }
+ revokedByUser {
+ id
+ }
+ }
+ }
+ HEREDOC
+ end
+
+ let_it_be(:query) do
+ graphql_query_for('user', { id: user.to_global_id.to_s }, fields)
+ end
+
+ let(:current_user) { user }
+
+ before_all do
+ group.add_guest(user)
+ end
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns all non_revoked user_achievements' do
+ expect(graphql_data_at(:user, :userAchievements, :nodes)).to contain_exactly(
+ a_graphql_entity_for(non_revoked_achievement)
+ )
+ end
+
+ it 'can lookahead to eliminate N+1 queries', :use_clean_rails_memory_store_caching do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post_graphql(query, current_user: user)
+ end.count
+
+ achievement2 = create(:achievement, namespace: group)
+ create_list(:user_achievement, 2, achievement: achievement2, user: user)
+
+ expect { post_graphql(query, current_user: user) }.not_to exceed_all_query_limit(control_count)
+ end
+
+ context 'when the achievements feature flag is disabled for a namespace' do
+ let_it_be(:group2) { create(:group) }
+ let_it_be(:achievement2) { create(:achievement, namespace: group2) }
+ let_it_be(:user_achievement2) { create(:user_achievement, achievement: achievement2, user: user) }
+
+ before do
+ stub_feature_flags(achievements: false)
+ stub_feature_flags(achievements: group2)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'does not return user_achievements for that namespace' do
+ expect(graphql_data_at(:user, :userAchievements, :nodes)).to contain_exactly(
+ a_graphql_entity_for(user_achievement2)
+ )
+ end
+ end
+
+ context 'when current user is not a member of the private group' do
+ let(:current_user) { create(:user) }
+
+ it 'returns all achievements' do
+ expect(graphql_data_at(:user, :userAchievements, :nodes)).to contain_exactly(
+ a_graphql_entity_for(non_revoked_achievement)
+ )
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/user_spec.rb b/spec/requests/api/graphql/user_spec.rb
index c19dfa6f3f3..41ee233dfc5 100644
--- a/spec/requests/api/graphql/user_spec.rb
+++ b/spec/requests/api/graphql/user_spec.rb
@@ -10,6 +10,12 @@ RSpec.describe 'User', feature_category: :user_profile do
shared_examples 'a working user query' do
it_behaves_like 'a working graphql query' do
before do
+ # TODO: This license stub is necessary because the remote development workspaces field
+ # defined in the EE version of UserInterface gets picked up here and thus the license
+ # check happens. This comes from the `ancestors` call in
+ # lib/graphql/schema/member/has_fields.rb#fields in the graphql library.
+ stub_licensed_features(remote_development: true)
+
post_graphql(query, current_user: current_user)
end
end
@@ -36,9 +42,17 @@ RSpec.describe 'User', feature_category: :user_profile do
end
context 'when username parameter is used' do
- let(:query) { graphql_query_for(:user, { username: current_user.username.to_s }) }
+ context 'when username is identically cased' do
+ let(:query) { graphql_query_for(:user, { username: current_user.username.to_s }) }
- it_behaves_like 'a working user query'
+ it_behaves_like 'a working user query'
+ end
+
+ context 'when username is differently cased' do
+ let(:query) { graphql_query_for(:user, { username: current_user.username.to_s.upcase }) }
+
+ it_behaves_like 'a working user query'
+ end
end
context 'when username and id parameter are used' do
diff --git a/spec/requests/api/graphql/work_item_spec.rb b/spec/requests/api/graphql/work_item_spec.rb
index 0fad4f4ff3a..dc5004a121b 100644
--- a/spec/requests/api/graphql/work_item_spec.rb
+++ b/spec/requests/api/graphql/work_item_spec.rb
@@ -36,9 +36,15 @@ RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do
end
context 'when the user can read the work item' do
+ let(:incoming_email_token) { current_user.incoming_email_token }
+ let(:work_item_email) do
+ "p+#{project.full_path_slug}-#{project.project_id}-#{incoming_email_token}-issue-#{work_item.iid}@gl.ab"
+ end
+
before do
project.add_developer(developer)
project.add_guest(guest)
+ stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab")
post_graphql(query, current_user: current_user)
end
@@ -55,11 +61,15 @@ RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do
'title' => work_item.title,
'confidential' => work_item.confidential,
'workItemType' => hash_including('id' => work_item.work_item_type.to_gid.to_s),
+ 'reference' => work_item.to_reference,
+ 'createNoteEmail' => work_item_email,
'userPermissions' => {
'readWorkItem' => true,
'updateWorkItem' => true,
'deleteWorkItem' => false,
- 'adminWorkItem' => true
+ 'adminWorkItem' => true,
+ 'adminParentLink' => true,
+ 'setWorkItemMetadata' => true
},
'project' => hash_including('id' => project.to_gid.to_s, 'fullPath' => project.full_path)
)
@@ -373,6 +383,161 @@ RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do
)
end
end
+
+ describe 'notifications widget' do
+ let(:work_item_fields) do
+ <<~GRAPHQL
+ id
+ widgets {
+ type
+ ... on WorkItemWidgetNotifications {
+ subscribed
+ }
+ }
+ GRAPHQL
+ end
+
+ it 'returns widget information' do
+ expect(work_item_data).to include(
+ 'id' => work_item.to_gid.to_s,
+ 'widgets' => include(
+ hash_including(
+ 'type' => 'NOTIFICATIONS',
+ 'subscribed' => work_item.subscribed?(current_user, project)
+ )
+ )
+ )
+ end
+ end
+
+ describe 'currentUserTodos widget' do
+ let_it_be(:current_user) { developer }
+ let_it_be(:other_todo) { create(:todo, state: :pending, user: current_user) }
+
+ let_it_be(:done_todo) do
+ create(:todo, state: :done, target: work_item, target_type: work_item.class.name, user: current_user)
+ end
+
+ let_it_be(:pending_todo) do
+ create(:todo, state: :pending, target: work_item, target_type: work_item.class.name, user: current_user)
+ end
+
+ let_it_be(:other_user_todo) do
+ create(:todo, state: :pending, target: work_item, target_type: work_item.class.name, user: create(:user))
+ end
+
+ let(:work_item_fields) do
+ <<~GRAPHQL
+ id
+ widgets {
+ type
+ ... on WorkItemWidgetCurrentUserTodos {
+ currentUserTodos {
+ nodes {
+ id
+ state
+ }
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ context 'with access' do
+ it 'returns widget information' do
+ expect(work_item_data).to include(
+ 'id' => work_item.to_gid.to_s,
+ 'widgets' => include(
+ hash_including(
+ 'type' => 'CURRENT_USER_TODOS',
+ 'currentUserTodos' => {
+ 'nodes' => match_array(
+ [done_todo, pending_todo].map { |t| { 'id' => t.to_gid.to_s, 'state' => t.state } }
+ )
+ }
+ )
+ )
+ )
+ end
+ end
+
+ context 'with filter' do
+ let(:work_item_fields) do
+ <<~GRAPHQL
+ id
+ widgets {
+ type
+ ... on WorkItemWidgetCurrentUserTodos {
+ currentUserTodos(state: done) {
+ nodes {
+ id
+ state
+ }
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ it 'returns widget information' do
+ expect(work_item_data).to include(
+ 'id' => work_item.to_gid.to_s,
+ 'widgets' => include(
+ hash_including(
+ 'type' => 'CURRENT_USER_TODOS',
+ 'currentUserTodos' => {
+ 'nodes' => match_array(
+ [done_todo].map { |t| { 'id' => t.to_gid.to_s, 'state' => t.state } }
+ )
+ }
+ )
+ )
+ )
+ end
+ end
+ end
+
+ describe 'award emoji widget' do
+ let_it_be(:emoji) { create(:award_emoji, name: 'star', awardable: work_item) }
+ let_it_be(:upvote) { create(:award_emoji, :upvote, awardable: work_item) }
+ let_it_be(:downvote) { create(:award_emoji, :downvote, awardable: work_item) }
+
+ let(:work_item_fields) do
+ <<~GRAPHQL
+ id
+ widgets {
+ type
+ ... on WorkItemWidgetAwardEmoji {
+ upvotes
+ downvotes
+ awardEmoji {
+ nodes {
+ name
+ }
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ it 'returns widget information' do
+ expect(work_item_data).to include(
+ 'id' => work_item.to_gid.to_s,
+ 'widgets' => include(
+ hash_including(
+ 'type' => 'AWARD_EMOJI',
+ 'upvotes' => work_item.upvotes,
+ 'downvotes' => work_item.downvotes,
+ 'awardEmoji' => {
+ 'nodes' => match_array(
+ [emoji, upvote, downvote].map { |e| { 'name' => e.name } }
+ )
+ }
+ )
+ )
+ )
+ end
+ end
end
context 'when an Issue Global ID is provided' do
@@ -398,4 +563,23 @@ RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do
)
end
end
+
+ context 'when the user cannot set work item metadata' do
+ let(:current_user) { guest }
+
+ before do
+ project.add_guest(guest)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns correct user permission' do
+ expect(work_item_data).to include(
+ 'id' => work_item.to_gid.to_s,
+ 'userPermissions' =>
+ hash_including(
+ 'setWorkItemMetadata' => false
+ )
+ )
+ end
+ end
end
diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb
index d7724371cce..8a3c5261eb6 100644
--- a/spec/requests/api/graphql_spec.rb
+++ b/spec/requests/api/graphql_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe 'GraphQL', feature_category: :not_owned do
+RSpec.describe 'GraphQL', feature_category: :shared do
include GraphqlHelpers
include AfterNextHelpers
diff --git a/spec/requests/api/group_clusters_spec.rb b/spec/requests/api/group_clusters_spec.rb
index 68c3af01e56..58d0e6a1eb5 100644
--- a/spec/requests/api/group_clusters_spec.rb
+++ b/spec/requests/api/group_clusters_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::GroupClusters, feature_category: :kubernetes_management do
+RSpec.describe API::GroupClusters, feature_category: :deployment_management do
include KubernetesHelpers
let(:current_user) { create(:user) }
diff --git a/spec/requests/api/group_milestones_spec.rb b/spec/requests/api/group_milestones_spec.rb
index 91f64d02d43..2f05b0fcf21 100644
--- a/spec/requests/api/group_milestones_spec.rb
+++ b/spec/requests/api/group_milestones_spec.rb
@@ -4,35 +4,41 @@ require 'spec_helper'
RSpec.describe API::GroupMilestones, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
- let_it_be(:group) { create(:group, :private) }
+ let_it_be_with_refind(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:group_member) { create(:group_member, group: group, user: user) }
- let_it_be(:closed_milestone) { create(:closed_milestone, group: group, title: 'version1', description: 'closed milestone') }
- let_it_be(:milestone) { create(:milestone, group: group, title: 'version2', description: 'open milestone') }
+ let_it_be(:closed_milestone) do
+ create(:closed_milestone, group: group, title: 'version1', description: 'closed milestone')
+ end
+
+ let_it_be_with_reload(:milestone) do
+ create(:milestone, group: group, title: 'version2', description: 'open milestone', updated_at: 4.days.ago)
+ end
let(:route) { "/groups/#{group.id}/milestones" }
+ shared_examples 'listing all milestones' do
+ it 'returns correct list of milestones' do
+ get api(route, user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.size).to eq(milestones.size)
+ expect(json_response.map { |entry| entry["id"] }).to eq(milestones.map(&:id))
+ end
+ end
+
it_behaves_like 'group and project milestones', "/groups/:id/milestones"
describe 'GET /groups/:id/milestones' do
- context 'when include_parent_milestones is true' do
- let_it_be(:ancestor_group) { create(:group, :private) }
- let_it_be(:ancestor_group_milestone) { create(:milestone, group: ancestor_group) }
- let_it_be(:params) { { include_parent_milestones: true } }
-
- before_all do
- group.update!(parent: ancestor_group)
- end
+ let_it_be(:ancestor_group) { create(:group, :private) }
+ let_it_be(:ancestor_group_milestone) { create(:milestone, group: ancestor_group, updated_at: 2.days.ago) }
- shared_examples 'listing all milestones' do
- it 'returns correct list of milestones' do
- get api(route, user), params: params
+ before_all do
+ group.update!(parent: ancestor_group)
+ end
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.size).to eq(milestones.size)
- expect(json_response.map { |entry| entry["id"] }).to eq(milestones.map(&:id))
- end
- end
+ context 'when include_parent_milestones is true' do
+ let(:params) { { include_parent_milestones: true } }
context 'when user has access to ancestor groups' do
let(:milestones) { [ancestor_group_milestone, milestone, closed_milestone] }
@@ -45,10 +51,26 @@ RSpec.describe API::GroupMilestones, feature_category: :team_planning do
it_behaves_like 'listing all milestones'
context 'when iids param is present' do
- let_it_be(:params) { { include_parent_milestones: true, iids: [milestone.iid] } }
+ let(:params) { { include_parent_milestones: true, iids: [milestone.iid] } }
it_behaves_like 'listing all milestones'
end
+
+ context 'when updated_before param is present' do
+ let(:params) { { updated_before: 1.day.ago.iso8601, include_parent_milestones: true } }
+
+ it_behaves_like 'listing all milestones' do
+ let(:milestones) { [ancestor_group_milestone, milestone] }
+ end
+ end
+
+ context 'when updated_after param is present' do
+ let(:params) { { updated_after: 1.day.ago.iso8601, include_parent_milestones: true } }
+
+ it_behaves_like 'listing all milestones' do
+ let(:milestones) { [closed_milestone] }
+ end
+ end
end
context 'when user has no access to ancestor groups' do
@@ -63,6 +85,22 @@ RSpec.describe API::GroupMilestones, feature_category: :team_planning do
end
end
end
+
+ context 'when updated_before param is present' do
+ let(:params) { { updated_before: 1.day.ago.iso8601 } }
+
+ it_behaves_like 'listing all milestones' do
+ let(:milestones) { [milestone] }
+ end
+ end
+
+ context 'when updated_after param is present' do
+ let(:params) { { updated_after: 1.day.ago.iso8601 } }
+
+ it_behaves_like 'listing all milestones' do
+ let(:milestones) { [closed_milestone] }
+ end
+ end
end
describe 'GET /groups/:id/milestones/:milestone_id/issues' do
diff --git a/spec/requests/api/group_variables_spec.rb b/spec/requests/api/group_variables_spec.rb
index e3d538d72ba..6849b087211 100644
--- a/spec/requests/api/group_variables_spec.rb
+++ b/spec/requests/api/group_variables_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::GroupVariables, feature_category: :pipeline_authoring do
+RSpec.describe API::GroupVariables, feature_category: :secrets_management do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let_it_be(:variable) { create(:ci_group_variable, group: group) }
diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb
index 12a6553f51a..84d48b4edb4 100644
--- a/spec/requests/api/groups_spec.rb
+++ b/spec/requests/api/groups_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
include GroupAPIHelpers
include UploadHelpers
include WorkhorseHelpers
+ include KeysetPaginationHelpers
let_it_be(:user1) { create(:user, can_create_group: false) }
let_it_be(:user2) { create(:user) }
@@ -39,7 +40,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'when invalid' do
shared_examples 'invalid file upload request' do
- it 'returns 400' do
+ it 'returns 400', :aggregate_failures do
make_upload_request
expect(response).to have_gitlab_http_status(:bad_request)
@@ -65,7 +66,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
shared_examples 'skips searching in full path' do
- it 'does not find groups by full path' do
+ it 'does not find groups by full path', :aggregate_failures do
subgroup = create(:group, parent: parent, path: "#{parent.path}-subgroup")
create(:group, parent: parent, path: 'not_matching_path')
@@ -79,7 +80,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
describe "GET /groups" do
context "when unauthenticated" do
- it "returns public groups" do
+ it "returns public groups", :aggregate_failures do
get api("/groups")
expect(response).to have_gitlab_http_status(:ok)
@@ -93,18 +94,18 @@ RSpec.describe API::Groups, feature_category: :subgroups do
it 'avoids N+1 queries', :use_sql_query_cache do
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/groups", admin)
+ get api("/groups")
end
create(:group)
expect do
- get api("/groups", admin)
+ get api("/groups")
end.not_to exceed_all_query_limit(control)
end
context 'when statistics are requested' do
- it 'does not include statistics' do
+ it 'does not include statistics', :aggregate_failures do
get api("/groups"), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -116,7 +117,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context "when authenticated as user" do
- it "normal user: returns an array of groups of user1" do
+ it "normal user: returns an array of groups of user1", :aggregate_failures do
get api("/groups", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -127,7 +128,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
.to satisfy_one { |group| group['name'] == group1.name }
end
- it "does not include runners_token information" do
+ it "does not include runners_token information", :aggregate_failures do
get api("/groups", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -137,7 +138,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first).not_to include('runners_token')
end
- it "does not include statistics" do
+ it "does not include statistics", :aggregate_failures do
get api("/groups", user1), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -146,7 +147,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first).not_to include 'statistics'
end
- it "includes a created_at timestamp" do
+ it "includes a created_at timestamp", :aggregate_failures do
get api("/groups", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -175,7 +176,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'on making requests below the allowed offset pagination threshold' do
- it 'paginates the records' do
+ it 'paginates the records', :aggregate_failures do
get api('/groups'), params: { page: 1, per_page: 1 }
expect(response).to have_gitlab_http_status(:ok)
@@ -196,25 +197,8 @@ RSpec.describe API::Groups, feature_category: :subgroups do
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
+ it 'paginates the records correctly', :aggregate_failures do
# first page
get api('/groups'), params: { pagination: 'keyset', per_page: 1 }
@@ -223,7 +207,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(records.size).to eq(1)
expect(records.first['id']).to eq(group_1.id)
- params_for_next_page = params_for_next_page(response)
+ params_for_next_page = pagination_params_from_next_url(response)
expect(params_for_next_page).to include('cursor')
get api('/groups'), params: params_for_next_page
@@ -236,7 +220,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'on making requests with unsupported ordering structure' do
- it 'returns error' do
+ it 'returns error', :aggregate_failures 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)
@@ -248,8 +232,8 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context "when authenticated as admin" do
- it "admin: returns an array of all groups" do
- get api("/groups", admin)
+ it "admin: returns an array of all groups", :aggregate_failures do
+ get api("/groups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -257,8 +241,8 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.length).to eq(2)
end
- it "does not include runners_token information" do
- get api("/groups", admin)
+ it "does not include runners_token information", :aggregate_failures do
+ get api("/groups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -267,8 +251,8 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first).not_to include('runners_token')
end
- it "does not include statistics by default" do
- get api("/groups", admin)
+ it "does not include statistics by default", :aggregate_failures do
+ get api("/groups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -276,8 +260,8 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first).not_to include('statistics')
end
- it "includes a created_at timestamp" do
- get api("/groups", admin)
+ it "includes a created_at timestamp", :aggregate_failures do
+ get api("/groups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -285,7 +269,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['created_at']).to be_present
end
- it "includes statistics if requested" do
+ it "includes statistics if requested", :aggregate_failures do
attributes = {
storage_size: 4093,
repository_size: 123,
@@ -302,7 +286,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
project1.statistics.update!(attributes)
- get api("/groups", admin), params: { statistics: true }
+ get api("/groups", admin, admin_mode: true), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -313,8 +297,8 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context "when using skip_groups in request" do
- it "returns all groups excluding skipped groups" do
- get api("/groups", admin), params: { skip_groups: [group2.id] }
+ it "returns all groups excluding skipped groups", :aggregate_failures do
+ get api("/groups", admin, admin_mode: true), params: { skip_groups: [group2.id] }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -326,7 +310,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context "when using all_available in request" do
let(:response_groups) { json_response.map { |group| group['name'] } }
- it "returns all groups you have access to" do
+ it "returns all groups you have access to", :aggregate_failures do
public_group = create :group, :public
get api("/groups", user1), params: { all_available: true }
@@ -348,7 +332,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
subgroup.add_owner(user1)
end
- it "doesn't return subgroups" do
+ it "doesn't return subgroups", :aggregate_failures do
get api("/groups", user1), params: { top_level_only: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -373,7 +357,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
group5.add_owner(user1)
end
- it "sorts by name ascending by default" do
+ it "sorts by name ascending by default", :aggregate_failures do
get api("/groups", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -382,7 +366,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response_groups).to eq(groups_visible_to_user(user1).order(:name).pluck(:name))
end
- it "sorts in descending order when passed" do
+ it "sorts in descending order when passed", :aggregate_failures do
get api("/groups", user1), params: { sort: "desc" }
expect(response).to have_gitlab_http_status(:ok)
@@ -391,7 +375,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response_groups).to eq(groups_visible_to_user(user1).order(name: :desc).pluck(:name))
end
- it "sorts by path in order_by param" do
+ it "sorts by path in order_by param", :aggregate_failures do
get api("/groups", user1), params: { order_by: "path" }
expect(response).to have_gitlab_http_status(:ok)
@@ -400,7 +384,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response_groups).to eq(groups_visible_to_user(user1).order(:path).pluck(:name))
end
- it "sorts by id in the order_by param" do
+ it "sorts by id in the order_by param", :aggregate_failures do
get api("/groups", user1), params: { order_by: "id" }
expect(response).to have_gitlab_http_status(:ok)
@@ -409,7 +393,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response_groups).to eq(groups_visible_to_user(user1).order(:id).pluck(:name))
end
- it "sorts also by descending id with pagination fix" do
+ it "sorts also by descending id with pagination fix", :aggregate_failures do
get api("/groups", user1), params: { order_by: "id", sort: "desc" }
expect(response).to have_gitlab_http_status(:ok)
@@ -418,7 +402,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response_groups).to eq(groups_visible_to_user(user1).order(id: :desc).pluck(:name))
end
- it "sorts identical keys by id for good pagination" do
+ it "sorts identical keys by id for good pagination", :aggregate_failures do
get api("/groups", user1), params: { search: "same-name", order_by: "name" }
expect(response).to have_gitlab_http_status(:ok)
@@ -427,7 +411,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response_groups_ids).to eq(Group.select { |group| group['name'] == 'same-name' }.map { |group| group['id'] }.sort)
end
- it "sorts descending identical keys by id for good pagination" do
+ it "sorts descending identical keys by id for good pagination", :aggregate_failures do
get api("/groups", user1), params: { search: "same-name", order_by: "name", sort: "desc" }
expect(response).to have_gitlab_http_status(:ok)
@@ -449,7 +433,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
subject { get api('/groups', user1), params: params }
- it 'sorts top level groups before subgroups with exact matches first' do
+ it 'sorts top level groups before subgroups with exact matches first', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -462,7 +446,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'when `search` parameter is not given' do
let(:params) { { order_by: 'similarity' } }
- it 'sorts items ordered by name' do
+ it 'sorts items ordered by name', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -480,7 +464,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when using owned in the request' do
- it 'returns an array of groups the user owns' do
+ it 'returns an array of groups the user owns', :aggregate_failures do
group1.add_maintainer(user2)
get api('/groups', user2), params: { owned: true }
@@ -503,7 +487,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'with min_access_level parameter' do
- it 'returns an array of groups the user has at least master access' do
+ it 'returns an array of groups the user has at least master access', :aggregate_failures do
get api('/groups', user2), params: { min_access_level: 40 }
expect(response).to have_gitlab_http_status(:ok)
@@ -512,24 +496,15 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response_groups).to contain_exactly(group2.id, group3.id)
end
- context 'distinct count with present_groups_select_all feature flag' do
+ context 'distinct count' do
subject { get api('/groups', user2), params: { min_access_level: 40 } }
+ # Prevent Rails from optimizing the count query and inadvertadly creating a poor performing databse query.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/368969
it 'counts with *' do
count_sql = /#{Regexp.escape('SELECT count(*)')}/i
expect { subject }.to make_queries_matching count_sql
end
-
- context 'when present_groups_select_all feature flag is disabled' do
- before do
- stub_feature_flags(present_groups_select_all: false)
- end
-
- it 'counts with count_column' do
- count_sql = /#{Regexp.escape('SELECT count(count_column)')}/i
- expect { subject }.to make_queries_matching count_sql
- end
- end
end
end
end
@@ -541,7 +516,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
subject { get api('/groups', user1), params: { search: group1.path } }
- it 'finds also groups with full path matching search param' do
+ it 'finds also groups with full path matching search param', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -587,7 +562,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response).to have_gitlab_http_status(:not_found)
end
- it 'returns 200 for a public group' do
+ it 'returns 200 for a public group', :aggregate_failures do
get api("/groups/#{group1.id}")
expect(response).to have_gitlab_http_status(:ok)
@@ -617,7 +592,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context "when authenticated as user" do
- it "returns one of user1's groups" do
+ it "returns one of user1's groups", :aggregate_failures do
project = create(:project, namespace: group2, path: 'Foo')
create(:project_group_link, project: project, group: group1)
group = create(:group)
@@ -661,7 +636,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response['shared_projects'][0]['id']).to eq(project.id)
end
- it "returns one of user1's groups without projects when with_projects option is set to false" do
+ it "returns one of user1's groups without projects when with_projects option is set to false", :aggregate_failures do
project = create(:project, namespace: group2, path: 'Foo')
create(:project_group_link, project: project, group: group1)
@@ -673,14 +648,14 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response).not_to include('runners_token')
end
- it "doesn't return runners_token if the user is not the owner of the group" do
+ it "doesn't return runners_token if the user is not the owner of the group", :aggregate_failures do
get api("/groups/#{group1.id}", user3)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).not_to include('runners_token')
end
- it "returns runners_token if the user is the owner of the group" do
+ it "returns runners_token if the user is the owner of the group", :aggregate_failures do
group1.add_owner(user3)
get api("/groups/#{group1.id}", user3)
@@ -720,8 +695,9 @@ RSpec.describe API::Groups, feature_category: :subgroups do
.to contain_exactly(projects[:public].id, projects[:internal].id)
end
- it 'avoids N+1 queries with project links' do
+ it 'avoids N+1 queries with project links', :aggregate_failures do
get api("/groups/#{group1.id}", user1)
+ expect(response).to have_gitlab_http_status(:ok)
control_count = ActiveRecord::QueryRecorder.new do
get api("/groups/#{group1.id}", user1)
@@ -754,25 +730,25 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context "when authenticated as admin" do
- it "returns any existing group" do
- get api("/groups/#{group2.id}", admin)
+ it "returns any existing group", :aggregate_failures do
+ get api("/groups/#{group2.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq(group2.name)
end
- it "returns information of the runners_token for the group" do
- get api("/groups/#{group2.id}", admin)
+ it "returns information of the runners_token for the group", :aggregate_failures do
+ get api("/groups/#{group2.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to include('runners_token')
end
- it "returns runners_token and no projects when with_projects option is set to false" do
+ it "returns runners_token and no projects when with_projects option is set to false", :aggregate_failures do
project = create(:project, namespace: group2, path: 'Foo')
create(:project_group_link, project: project, group: group1)
- get api("/groups/#{group2.id}", admin), params: { with_projects: false }
+ get api("/groups/#{group2.id}", admin, admin_mode: true), params: { with_projects: false }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['projects']).to be_nil
@@ -781,14 +757,14 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
it "does not return a non existing group" do
- get api("/groups/#{non_existing_record_id}", admin)
+ get api("/groups/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when using group path in URL' do
- it 'returns any existing group' do
+ it 'returns any existing group', :aggregate_failures do
get api("/groups/#{group1.path}", admin)
expect(response).to have_gitlab_http_status(:ok)
@@ -796,7 +772,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
it 'does not return a non existing group' do
- get api('/groups/unknown', admin)
+ get api('/groups/unknown', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -826,7 +802,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
end
- it 'limits projects and shared_projects' do
+ it 'limits projects and shared_projects', :aggregate_failures do
get api("/groups/#{group1.id}")
expect(json_response['projects'].count).to eq(limit)
@@ -843,8 +819,8 @@ RSpec.describe API::Groups, feature_category: :subgroups do
subject(:shared_with_groups) { json_response['shared_with_groups'].map { _1['group_id']} }
context 'when authenticated as admin' do
- it 'returns all groups that share the group' do
- get api("/groups/#{shared_group.id}", admin)
+ it 'returns all groups that share the group', :aggregate_failures do
+ get api("/groups/#{shared_group.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(shared_with_groups).to contain_exactly(group_link_1.shared_with_group_id, group_link_2.shared_with_group_id)
@@ -852,7 +828,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when unauthenticated' do
- it 'returns only public groups that share the group' do
+ it 'returns only public groups that share the group', :aggregate_failures do
get api("/groups/#{shared_group.id}")
expect(response).to have_gitlab_http_status(:ok)
@@ -861,7 +837,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when authenticated as a member of a parent group that has shared the group' do
- it 'returns private group if direct member' do
+ it 'returns private group if direct member', :aggregate_failures do
group2_sub.add_guest(user3)
get api("/groups/#{shared_group.id}", user3)
@@ -870,7 +846,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(shared_with_groups).to contain_exactly(group_link_1.shared_with_group_id, group_link_2.shared_with_group_id)
end
- it 'returns private group if inherited member' do
+ it 'returns private group if inherited member', :aggregate_failures do
inherited_guest_member = create(:user)
group2.add_guest(inherited_guest_member)
@@ -902,7 +878,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when authenticated as the group owner' do
- it 'updates the group' do
+ it 'updates the group', :aggregate_failures do
workhorse_form_with_file(
api("/groups/#{group1.id}", user1),
method: :put,
@@ -942,7 +918,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response['prevent_sharing_groups_outside_hierarchy']).to eq(true)
end
- it 'removes the group avatar' do
+ it 'removes the group avatar', :aggregate_failures do
put api("/groups/#{group1.id}", user1), params: { avatar: '' }
aggregate_failures "testing response" do
@@ -952,7 +928,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
end
- it 'does not update visibility_level if it is restricted' do
+ it 'does not update visibility_level if it is restricted', :aggregate_failures do
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
put api("/groups/#{group1.id}", user1), params: { visibility: 'internal' }
@@ -967,7 +943,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'for users who have the ability to update default_branch_protection' do
- it 'updates the attribute' do
+ it 'updates the attribute', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -976,7 +952,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'for users who does not have the ability to update default_branch_protection`' do
- it 'does not update the attribute' do
+ it 'does not update the attribute', :aggregate_failures do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(user1, :update_default_branch_protection, group1) { false }
@@ -1016,21 +992,21 @@ RSpec.describe API::Groups, feature_category: :subgroups do
group3.add_owner(user3)
end
- it 'does not change visibility when not requested' do
+ it 'does not change visibility when not requested', :aggregate_failures do
put api("/groups/#{group3.id}", user3), params: { description: 'Bug #23083' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['visibility']).to eq('public')
end
- it 'prevents making private a group containing public subgroups' do
+ it 'prevents making private a group containing public subgroups', :aggregate_failures do
put api("/groups/#{group3.id}", user3), params: { visibility: 'private' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['visibility_level']).to contain_exactly('private is not allowed since there are sub-groups with higher visibility.')
end
- it 'does not update prevent_sharing_groups_outside_hierarchy' do
+ it 'does not update prevent_sharing_groups_outside_hierarchy', :aggregate_failures do
put api("/groups/#{subgroup.id}", user3), params: { description: 'it works', prevent_sharing_groups_outside_hierarchy: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -1042,17 +1018,17 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when authenticated as the admin' do
- it 'updates the group' do
- put api("/groups/#{group1.id}", admin), params: { name: new_group_name }
+ it 'updates the group', :aggregate_failures do
+ put api("/groups/#{group1.id}", admin, admin_mode: true), params: { name: new_group_name }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq(new_group_name)
end
- it 'ignores visibility level restrictions' do
+ it 'ignores visibility level restrictions', :aggregate_failures do
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
- put api("/groups/#{group1.id}", admin), params: { visibility: 'internal' }
+ put api("/groups/#{group1.id}", admin, admin_mode: true), params: { visibility: 'internal' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['visibility']).to eq('internal')
@@ -1094,7 +1070,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
end
- it "returns the group's projects" do
+ it "returns the group's projects", :aggregate_failures do
get api("/groups/#{group1.id}/projects", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -1106,7 +1082,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'and using archived' do
- it "returns the group's archived projects" do
+ it "returns the group's archived projects", :aggregate_failures do
get api("/groups/#{group1.id}/projects?archived=true", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -1116,7 +1092,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.map { |project| project['id'] }).to include(archived_project.id)
end
- it "returns the group's non-archived projects" do
+ it "returns the group's non-archived projects", :aggregate_failures do
get api("/groups/#{group1.id}/projects?archived=false", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -1126,7 +1102,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.map { |project| project['id'] }).not_to include(archived_project.id)
end
- it "returns all of the group's projects" do
+ it "returns all of the group's projects", :aggregate_failures do
get api("/groups/#{group1.id}/projects", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -1150,7 +1126,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
group_with_projects.add_owner(user1)
end
- it 'returns items based ordered by similarity' do
+ it 'returns items based ordered by similarity', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -1166,7 +1142,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
params.delete(:search)
end
- it 'returns items ordered by name' do
+ it 'returns items ordered by name', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -1179,7 +1155,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
end
- it "returns the group's projects with simple representation" do
+ it "returns the group's projects with simple representation", :aggregate_failures do
get api("/groups/#{group1.id}/projects", user1), params: { simple: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -1190,7 +1166,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['visibility']).not_to be_present
end
- it "filters the groups projects" do
+ it "filters the groups projects", :aggregate_failures do
public_project = create(:project, :public, path: 'test1', group: group1)
get api("/groups/#{group1.id}/projects", user1), params: { visibility: 'public' }
@@ -1202,7 +1178,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['name']).to eq(public_project.name)
end
- it "returns projects excluding shared" do
+ it "returns projects excluding shared", :aggregate_failures do
create(:project_group_link, project: create(:project), group: group1)
create(:project_group_link, project: create(:project), group: group1)
create(:project_group_link, project: create(:project), group: group1)
@@ -1227,7 +1203,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
group1.reload
end
- it "returns projects including those in subgroups" do
+ it "returns projects including those in subgroups", :aggregate_failures do
get api("/groups/#{group1.id}/projects", user1), params: { include_subgroups: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -1236,7 +1212,10 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.length).to eq(6)
end
- it 'avoids N+1 queries', :use_sql_query_cache, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/383788' do
+ it 'avoids N+1 queries', :aggregate_failures, :use_sql_query_cache, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/383788' do
+ get api("/groups/#{group1.id}/projects", user1), params: { include_subgroups: true }
+ expect(respone).to have_gitlab_http_status(:ok)
+
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
get api("/groups/#{group1.id}/projects", user1), params: { include_subgroups: true }
end
@@ -1250,7 +1229,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when include_ancestor_groups is true' do
- it 'returns ancestors groups projects' do
+ it 'returns ancestors groups projects', :aggregate_failures do
subgroup = create(:group, parent: group1)
subgroup_project = create(:project, group: subgroup)
@@ -1275,7 +1254,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response).to have_gitlab_http_status(:not_found)
end
- it "only returns projects to which user has access" do
+ it "only returns projects to which user has access", :aggregate_failures do
project3.add_developer(user3)
get api("/groups/#{group1.id}/projects", user3)
@@ -1286,7 +1265,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['name']).to eq(project3.name)
end
- it 'only returns the projects owned by user' do
+ it 'only returns the projects owned by user', :aggregate_failures do
project2.group.add_owner(user3)
get api("/groups/#{project2.group.id}/projects", user3), params: { owned: true }
@@ -1296,7 +1275,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['name']).to eq(project2.name)
end
- it 'only returns the projects starred by user' do
+ it 'only returns the projects starred by user', :aggregate_failures do
user1.starred_projects = [project1]
get api("/groups/#{group1.id}/projects", user1), params: { starred: true }
@@ -1306,8 +1285,9 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['name']).to eq(project1.name)
end
- it 'avoids N+1 queries' do
+ it 'avoids N+1 queries', :aggregate_failures do
get api("/groups/#{group1.id}/projects", user1)
+ expect(response).to have_gitlab_http_status(:ok)
control_count = ActiveRecord::QueryRecorder.new do
get api("/groups/#{group1.id}/projects", user1)
@@ -1322,8 +1302,8 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context "when authenticated as admin" do
- it "returns any existing group" do
- get api("/groups/#{group2.id}/projects", admin)
+ it "returns any existing group", :aggregate_failures do
+ get api("/groups/#{group2.id}/projects", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1332,15 +1312,15 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
it "does not return a non existing group" do
- get api("/groups/#{non_existing_record_id}/projects", admin)
+ get api("/groups/#{non_existing_record_id}/projects", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when using group path in URL' do
- it 'returns any existing group' do
- get api("/groups/#{group1.path}/projects", admin)
+ it 'returns any existing group', :aggregate_failures do
+ get api("/groups/#{group1.path}/projects", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1349,7 +1329,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
it 'does not return a non existing group' do
- get api('/groups/unknown/projects', admin)
+ get api('/groups/unknown/projects', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -1375,7 +1355,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when authenticated as user' do
- it 'returns the shared projects in the group' do
+ it 'returns the shared projects in the group', :aggregate_failures do
get api(path, user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -1386,7 +1366,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['visibility']).to be_present
end
- it 'returns shared projects with min access level or higher' do
+ it 'returns shared projects with min access level or higher', :aggregate_failures do
user = create(:user)
project2.add_guest(user)
@@ -1399,7 +1379,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['id']).to eq(project4.id)
end
- it 'returns the shared projects of the group with simple representation' do
+ it 'returns the shared projects of the group with simple representation', :aggregate_failures do
get api(path, user1), params: { simple: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -1410,7 +1390,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['visibility']).not_to be_present
end
- it 'filters the shared projects in the group based on visibility' do
+ it 'filters the shared projects in the group based on visibility', :aggregate_failures do
internal_project = create(:project, :internal, namespace: create(:group))
create(:project_group_link, project: internal_project, group: group1)
@@ -1424,7 +1404,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['id']).to eq(internal_project.id)
end
- it 'filters the shared projects in the group based on search params' do
+ it 'filters the shared projects in the group based on search params', :aggregate_failures do
get api(path, user1), params: { search: 'test_project' }
expect(response).to have_gitlab_http_status(:ok)
@@ -1434,7 +1414,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['id']).to eq(project4.id)
end
- it 'does not return the projects owned by the group' do
+ it 'does not return the projects owned by the group', :aggregate_failures do
get api(path, user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -1459,7 +1439,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response).to have_gitlab_http_status(:not_found)
end
- it 'only returns shared projects to which user has access' do
+ it 'only returns shared projects to which user has access', :aggregate_failures do
project4.add_developer(user3)
get api(path, user3)
@@ -1470,7 +1450,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response.first['id']).to eq(project4.id)
end
- it 'only returns the projects starred by user' do
+ it 'only returns the projects starred by user', :aggregate_failures do
user1.starred_projects = [project2]
get api(path, user1), params: { starred: true }
@@ -1482,9 +1462,9 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context "when authenticated as admin" do
- subject { get api(path, admin) }
+ subject { get api(path, admin, admin_mode: true) }
- it "returns shared projects of an existing group" do
+ it "returns shared projects of an existing group", :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
@@ -1504,7 +1484,10 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
end
- it 'avoids N+1 queries' do
+ it 'avoids N+1 queries', :aggregate_failures, :use_sql_query_cache do
+ subject
+ expect(response).to have_gitlab_http_status(:ok)
+
control_count = ActiveRecord::QueryRecorder.new do
subject
end.count
@@ -1520,8 +1503,8 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'when using group path in URL' do
let(:path) { "/groups/#{group1.path}/projects/shared" }
- it 'returns the right details' do
- get api(path, admin)
+ it 'returns the right details', :aggregate_failures do
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1531,7 +1514,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
it 'returns 404 for a non-existent group' do
- get api('/groups/unknown/projects/shared', admin)
+ get api('/groups/unknown/projects/shared', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -1544,7 +1527,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
let!(:subgroup3) { create(:group, :private, parent: group2) }
context 'when unauthenticated' do
- it 'returns only public subgroups' do
+ it 'returns only public subgroups', :aggregate_failures do
get api("/groups/#{group1.id}/subgroups")
expect(response).to have_gitlab_http_status(:ok)
@@ -1562,7 +1545,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when statistics are requested' do
- it 'does not include statistics' do
+ it 'does not include statistics', :aggregate_failures do
get api("/groups/#{group1.id}/subgroups"), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -1575,7 +1558,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'when authenticated as user' do
context 'when user is not member of a public group' do
- it 'returns no subgroups for the public group' do
+ it 'returns no subgroups for the public group', :aggregate_failures do
get api("/groups/#{group1.id}/subgroups", user2)
expect(response).to have_gitlab_http_status(:ok)
@@ -1584,7 +1567,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when using all_available in request' do
- it 'returns public subgroups' do
+ it 'returns public subgroups', :aggregate_failures do
get api("/groups/#{group1.id}/subgroups", user2), params: { all_available: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -1609,7 +1592,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
group1.add_guest(user2)
end
- it 'returns private subgroups' do
+ it 'returns private subgroups', :aggregate_failures do
get api("/groups/#{group1.id}/subgroups", user2)
expect(response).to have_gitlab_http_status(:ok)
@@ -1623,7 +1606,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when using statistics in request' do
- it 'does not include statistics' do
+ it 'does not include statistics', :aggregate_failures do
get api("/groups/#{group1.id}/subgroups", user2), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -1638,7 +1621,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
group2.add_guest(user1)
end
- it 'returns subgroups' do
+ it 'returns subgroups', :aggregate_failures do
get api("/groups/#{group2.id}/subgroups", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -1651,32 +1634,32 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when authenticated as admin' do
- it 'returns private subgroups of a public group' do
- get api("/groups/#{group1.id}/subgroups", admin)
+ it 'returns private subgroups of a public group', :aggregate_failures do
+ get api("/groups/#{group1.id}/subgroups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.length).to eq(2)
end
- it 'returns subgroups of a private group' do
- get api("/groups/#{group2.id}/subgroups", admin)
+ it 'returns subgroups of a private group', :aggregate_failures do
+ get api("/groups/#{group2.id}/subgroups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
end
- it 'does not include statistics by default' do
- get api("/groups/#{group1.id}/subgroups", admin)
+ it 'does not include statistics by default', :aggregate_failures do
+ get api("/groups/#{group1.id}/subgroups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.first).not_to include('statistics')
end
- it 'includes statistics if requested' do
- get api("/groups/#{group1.id}/subgroups", admin), params: { statistics: true }
+ it 'includes statistics if requested', :aggregate_failures do
+ get api("/groups/#{group1.id}/subgroups", admin, admin_mode: true), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
@@ -1700,7 +1683,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
let(:response_groups) { json_response.map { |group| group['name'] } }
context 'when unauthenticated' do
- it 'returns only public descendants' do
+ it 'returns only public descendants', :aggregate_failures do
get api("/groups/#{group1.id}/descendant_groups")
expect(response).to have_gitlab_http_status(:ok)
@@ -1719,7 +1702,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'when authenticated as user' do
context 'when user is not member of a public group' do
- it 'returns no descendants for the public group' do
+ it 'returns no descendants for the public group', :aggregate_failures do
get api("/groups/#{group1.id}/descendant_groups", user2)
expect(response).to have_gitlab_http_status(:ok)
@@ -1728,7 +1711,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when using all_available in request' do
- it 'returns public descendants' do
+ it 'returns public descendants', :aggregate_failures do
get api("/groups/#{group1.id}/descendant_groups", user2), params: { all_available: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -1752,7 +1735,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
group1.add_guest(user2)
end
- it 'returns private descendants' do
+ it 'returns private descendants', :aggregate_failures do
get api("/groups/#{group1.id}/descendant_groups", user2)
expect(response).to have_gitlab_http_status(:ok)
@@ -1763,7 +1746,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when using statistics in request' do
- it 'does not include statistics' do
+ it 'does not include statistics', :aggregate_failures do
get api("/groups/#{group1.id}/descendant_groups", user2), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
@@ -1778,7 +1761,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
group2.add_guest(user1)
end
- it 'returns descendants' do
+ it 'returns descendants', :aggregate_failures do
get api("/groups/#{group2.id}/descendant_groups", user1)
expect(response).to have_gitlab_http_status(:ok)
@@ -1790,32 +1773,32 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when authenticated as admin' do
- it 'returns private descendants of a public group' do
- get api("/groups/#{group1.id}/descendant_groups", admin)
+ it 'returns private descendants of a public group', :aggregate_failures do
+ get api("/groups/#{group1.id}/descendant_groups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
end
- it 'returns descendants of a private group' do
- get api("/groups/#{group2.id}/descendant_groups", admin)
+ it 'returns descendants of a private group', :aggregate_failures do
+ get api("/groups/#{group2.id}/descendant_groups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.length).to eq(2)
end
- it 'does not include statistics by default' do
- get api("/groups/#{group1.id}/descendant_groups", admin)
+ it 'does not include statistics by default', :aggregate_failures do
+ get api("/groups/#{group1.id}/descendant_groups", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.first).not_to include('statistics')
end
- it 'includes statistics if requested' do
- get api("/groups/#{group1.id}/descendant_groups", admin), params: { statistics: true }
+ it 'includes statistics if requested', :aggregate_failures do
+ get api("/groups/#{group1.id}/descendant_groups", admin, admin_mode: true), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
@@ -1880,7 +1863,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context "when authenticated as user with group permissions" do
- it "creates group" do
+ it "creates group", :aggregate_failures do
group = attributes_for_group_api request_access_enabled: false
post api("/groups", user3), params: group
@@ -1893,7 +1876,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(json_response["visibility"]).to eq(Gitlab::VisibilityLevel.string_level(Gitlab::CurrentSettings.current_application_settings.default_group_visibility))
end
- it "creates a nested group" do
+ it "creates a nested group", :aggregate_failures do
parent = create(:group)
parent.add_owner(user3)
group = attributes_for_group_api parent_id: parent.id
@@ -1926,7 +1909,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
subject { post api("/groups", user3), params: params }
context 'for users who have the ability to create a group with `default_branch_protection`' do
- it 'creates group with the specified branch protection level' do
+ it 'creates group with the specified branch protection level', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:created)
@@ -1935,7 +1918,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'for users who do not have the ability to create a group with `default_branch_protection`' do
- it 'does not create the group with the specified branch protection level' do
+ it 'does not create the group with the specified branch protection level', :aggregate_failures do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(user3, :create_group_with_default_branch_protection) { false }
@@ -1947,7 +1930,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
end
- it "does not create group, duplicate" do
+ it "does not create group, duplicate", :aggregate_failures do
post api("/groups", user3), params: { name: 'Duplicate Test', path: group2.path }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -2007,13 +1990,13 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context "when authenticated as admin" do
it "removes any existing group" do
- delete api("/groups/#{group2.id}", admin)
+ delete api("/groups/#{group2.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:accepted)
end
it "does not remove a non existing group" do
- delete api("/groups/#{non_existing_record_id}", admin)
+ delete api("/groups/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -2040,7 +2023,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context "when authenticated as admin" do
it "transfers project to group" do
- post api("/groups/#{group1.id}/projects/#{project.id}", admin)
+ post api("/groups/#{group1.id}/projects/#{project.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:created)
end
@@ -2048,7 +2031,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'when using project path in URL' do
context 'with a valid project path' do
it "transfers project to group" do
- post api("/groups/#{group1.id}/projects/#{project_path}", admin)
+ post api("/groups/#{group1.id}/projects/#{project_path}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:created)
end
@@ -2056,7 +2039,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'with a non-existent project path' do
it "does not transfer project to group" do
- post api("/groups/#{group1.id}/projects/nogroup%2Fnoproject", admin)
+ post api("/groups/#{group1.id}/projects/nogroup%2Fnoproject", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -2066,7 +2049,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'when using a group path in URL' do
context 'with a valid group path' do
it "transfers project to group" do
- post api("/groups/#{group1.path}/projects/#{project_path}", admin)
+ post api("/groups/#{group1.path}/projects/#{project_path}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:created)
end
@@ -2074,7 +2057,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'with a non-existent group path' do
it "does not transfer project to group" do
- post api("/groups/noexist/projects/#{project_path}", admin)
+ post api("/groups/noexist/projects/#{project_path}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -2183,7 +2166,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
context 'when promoting a subgroup to a root group' do
shared_examples_for 'promotes the subgroup to a root group' do
- it 'returns success' do
+ it 'returns success', :aggregate_failures do
make_request(user)
expect(response).to have_gitlab_http_status(:created)
@@ -2207,7 +2190,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
let(:group) { create(:group) }
let(:params) { { group_id: '' } }
- it 'returns error' do
+ it 'returns error', :aggregate_failures do
make_request(user)
expect(response).to have_gitlab_http_status(:bad_request)
@@ -2258,7 +2241,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
end
- it 'returns error' do
+ it 'returns error', :aggregate_failures do
make_request(user)
expect(response).to have_gitlab_http_status(:bad_request)
@@ -2267,7 +2250,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
end
context 'when the transfer succceds' do
- it 'returns success' do
+ it 'returns success', :aggregate_failures do
make_request(user)
expect(response).to have_gitlab_http_status(:created)
@@ -2289,11 +2272,13 @@ RSpec.describe API::Groups, feature_category: :subgroups do
describe "POST /groups/:id/share" do
shared_examples 'shares group with group' do
- it "shares group with group" do
+ let_it_be(:admin_mode) { false }
+
+ it "shares group with group", :aggregate_failures do
expires_at = 10.days.from_now.to_date
expect do
- post api("/groups/#{group.id}/share", user), params: { group_id: shared_with_group.id, group_access: Gitlab::Access::DEVELOPER, expires_at: expires_at }
+ post api("/groups/#{group.id}/share", user, admin_mode: admin_mode), params: { group_id: shared_with_group.id, group_access: Gitlab::Access::DEVELOPER, expires_at: expires_at }
end.to change { group.shared_with_group_links.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -2322,7 +2307,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
expect(response).to have_gitlab_http_status(:not_found)
end
- it "returns a 400 error when wrong params passed" do
+ it "returns a 400 error when wrong params passed", :aggregate_failures do
post api("/groups/#{group.id}/share", user), params: { group_id: shared_with_group.id, group_access: non_existing_record_access_level }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -2375,15 +2360,18 @@ RSpec.describe API::Groups, feature_category: :subgroups do
let(:user) { admin }
let(:group) { create(:group) }
let(:shared_with_group) { create(:group) }
+ let(:admin_mode) { true }
end
end
end
describe 'DELETE /groups/:id/share/:group_id' do
shared_examples 'deletes group share' do
- it 'deletes a group share' do
+ let_it_be(:admin_mode) { false }
+
+ it 'deletes a group share', :aggregate_failures do
expect do
- delete api("/groups/#{shared_group.id}/share/#{shared_with_group.id}", user)
+ delete api("/groups/#{shared_group.id}/share/#{shared_with_group.id}", user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:no_content)
expect(shared_group.shared_with_group_links).to be_empty
@@ -2432,7 +2420,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
create(:group_group_link, shared_group: group1, shared_with_group: group_a)
end
- it 'does not remove group share' do
+ it 'does not remove group share', :aggregate_failures do
expect do
delete api("/groups/#{group1.id}/share/#{group_a.id}", user4)
@@ -2452,6 +2440,7 @@ RSpec.describe API::Groups, feature_category: :subgroups do
let(:user) { admin }
let(:shared_group) { group2 }
let(:shared_with_group) { group_b }
+ let(:admin_mode) { true }
end
end
end
diff --git a/spec/requests/api/helm_packages_spec.rb b/spec/requests/api/helm_packages_spec.rb
index 584f6e3c7d4..d6afd6f86ff 100644
--- a/spec/requests/api/helm_packages_spec.rb
+++ b/spec/requests/api/helm_packages_spec.rb
@@ -17,7 +17,15 @@ RSpec.describe API::HelmPackages, feature_category: :package_registry do
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) }
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_helm_user' } }
+ let(:snowplow_gitlab_standard_context) { snowplow_context }
+
+ def snowplow_context(user_role: :developer)
+ if user_role == :anonymous
+ { project: project, namespace: project.namespace, property: 'i_package_helm_user' }
+ else
+ { project: project, namespace: project.namespace, property: 'i_package_helm_user', user: user }
+ end
+ end
describe 'GET /api/v4/projects/:id/packages/helm/:channel/index.yaml' do
let(:project_id) { project.id }
@@ -65,6 +73,7 @@ RSpec.describe API::HelmPackages, feature_category: :package_registry do
with_them do
let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, personal_access_token.token) }
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: user_role) }
before do
project.update!(visibility: visibility.to_s)
@@ -75,6 +84,8 @@ RSpec.describe API::HelmPackages, feature_category: :package_registry do
end
context 'with access to package registry for everyone' do
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: :anonymous) }
+
before do
project.update!(visibility: Gitlab::VisibilityLevel::PRIVATE)
project.project_feature.update!(package_registry_access_level: ProjectFeature::PUBLIC)
@@ -116,6 +127,7 @@ RSpec.describe API::HelmPackages, feature_category: :package_registry do
with_them do
let(:user_headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, personal_access_token.token) }
let(:headers) { user_headers.merge(workhorse_headers) }
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: user_role) }
before do
project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility_level.to_s))
@@ -178,6 +190,7 @@ RSpec.describe API::HelmPackages, feature_category: :package_registry do
with_them do
let(:user_headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, personal_access_token.token) }
let(:headers) { user_headers.merge(workhorse_headers) }
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: user_role) }
before do
project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility_level.to_s))
diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb
index 38275ce0057..0be9df41e8f 100644
--- a/spec/requests/api/helpers_spec.rb
+++ b/spec/requests/api/helpers_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require 'raven/transports/dummy'
require_relative '../../../config/initializers/sentry'
-RSpec.describe API::Helpers, :enable_admin_mode, feature_category: :authentication_and_authorization do
+RSpec.describe API::Helpers, :enable_admin_mode, feature_category: :system_access do
include API::APIGuard::HelperMethods
include described_class
include TermsHelper
diff --git a/spec/requests/api/import_github_spec.rb b/spec/requests/api/import_github_spec.rb
index 0d75bb94144..9b5ae72526c 100644
--- a/spec/requests/api/import_github_spec.rb
+++ b/spec/requests/api/import_github_spec.rb
@@ -174,72 +174,54 @@ RSpec.describe API::ImportGithub, feature_category: :importers do
let_it_be(:user) { create(:user) }
let(:params) { { personal_access_token: token } }
- context 'when feature github_import_gists is enabled' do
+ context 'when gists import was started' do
before do
- stub_feature_flags(github_import_gists: true)
+ allow(Import::Github::GistsImportService)
+ .to receive(:new).with(user, client, access_params)
+ .and_return(double(execute: { status: :success }))
end
- context 'when gists import was started' do
- before do
- allow(Import::Github::GistsImportService)
- .to receive(:new).with(user, client, access_params)
- .and_return(double(execute: { status: :success }))
- end
-
- it 'returns 202' do
- post api('/import/github/gists', user), params: params
+ it 'returns 202' do
+ post api('/import/github/gists', user), params: params
- expect(response).to have_gitlab_http_status(:accepted)
- end
+ expect(response).to have_gitlab_http_status(:accepted)
end
+ end
- context 'when gists import is in progress' do
- before do
- allow(Import::Github::GistsImportService)
- .to receive(:new).with(user, client, access_params)
- .and_return(double(execute: { status: :error, message: 'Import already in progress', http_status: :unprocessable_entity }))
- end
-
- it 'returns 422 error' do
- post api('/import/github/gists', user), params: params
-
- expect(response).to have_gitlab_http_status(:unprocessable_entity)
- expect(json_response['errors']).to eq('Import already in progress')
- end
+ context 'when gists import is in progress' do
+ before do
+ allow(Import::Github::GistsImportService)
+ .to receive(:new).with(user, client, access_params)
+ .and_return(double(execute: { status: :error, message: 'Import already in progress', http_status: :unprocessable_entity }))
end
- context 'when unauthenticated user' do
- it 'returns 403 error' do
- post api('/import/github/gists'), params: params
+ it 'returns 422 error' do
+ post api('/import/github/gists', user), params: params
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response['errors']).to eq('Import already in progress')
end
+ end
- context 'when rate limit reached' do
- before do
- allow(Import::Github::GistsImportService)
- .to receive(:new).with(user, client, access_params)
- .and_raise(Gitlab::GithubImport::RateLimitError)
- end
-
- it 'returns 429 error' do
- post api('/import/github/gists', user), params: params
+ context 'when unauthenticated user' do
+ it 'returns 403 error' do
+ post api('/import/github/gists'), params: params
- expect(response).to have_gitlab_http_status(:too_many_requests)
- end
+ expect(response).to have_gitlab_http_status(:unauthorized)
end
end
- context 'when feature github_import_gists is disabled' do
+ context 'when rate limit reached' do
before do
- stub_feature_flags(github_import_gists: false)
+ allow(Import::Github::GistsImportService)
+ .to receive(:new).with(user, client, access_params)
+ .and_raise(Gitlab::GithubImport::RateLimitError)
end
- it 'returns 404 error' do
+ it 'returns 429 error' do
post api('/import/github/gists', user), params: params
- expect(response).to have_gitlab_http_status(:not_found)
+ expect(response).to have_gitlab_http_status(:too_many_requests)
end
end
end
diff --git a/spec/requests/api/integrations/slack/events_spec.rb b/spec/requests/api/integrations/slack/events_spec.rb
new file mode 100644
index 00000000000..438715db4f0
--- /dev/null
+++ b/spec/requests/api/integrations/slack/events_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Integrations::Slack::Events, feature_category: :integrations do
+ describe 'POST /integrations/slack/events' do
+ let_it_be(:slack_installation) { create(:slack_integration) }
+
+ let(:params) { {} }
+ let(:headers) do
+ {
+ ::API::Integrations::Slack::Request::VERIFICATION_TIMESTAMP_HEADER => Time.current.to_i.to_s,
+ ::API::Integrations::Slack::Request::VERIFICATION_SIGNATURE_HEADER => 'mock_verified_signature'
+ }
+ end
+
+ before do
+ allow(ActiveSupport::SecurityUtils).to receive(:secure_compare) do |signature|
+ signature == 'mock_verified_signature'
+ end
+
+ stub_application_setting(slack_app_signing_secret: 'mock_key')
+ end
+
+ subject { post api('/integrations/slack/events'), params: params, headers: headers }
+
+ it_behaves_like 'Slack request verification'
+
+ context 'when type param is unknown' do
+ let(:params) do
+ { type: 'unknown_type' }
+ end
+
+ it 'generates a tracked error' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).once
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.body).to be_empty
+ end
+ end
+
+ context 'when type param is url_verification' do
+ let(:params) do
+ {
+ type: 'url_verification',
+ challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P'
+ }
+ end
+
+ it 'responds in-request with the challenge' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq({ 'challenge' => '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P' })
+ end
+ end
+
+ context 'when event.type param is app_home_opened' do
+ let(:params) do
+ {
+ type: 'event_callback',
+ team_id: slack_installation.team_id,
+ event_id: 'Ev03SA75UJKB',
+ event: {
+ type: 'app_home_opened',
+ user: 'U0123ABCDEF'
+ }
+ }
+ end
+
+ it 'calls the Slack API (integration-style test)', :sidekiq_inline, :clean_gitlab_redis_shared_state do
+ api_url = "#{Slack::API::BASE_URL}/views.publish"
+
+ stub_request(:post, api_url)
+ .to_return(
+ status: 200,
+ body: { ok: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ subject
+
+ expect(WebMock).to have_requested(:post, api_url)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq('{}')
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/integrations/slack/interactions_spec.rb b/spec/requests/api/integrations/slack/interactions_spec.rb
new file mode 100644
index 00000000000..35a96be75e0
--- /dev/null
+++ b/spec/requests/api/integrations/slack/interactions_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Integrations::Slack::Interactions, feature_category: :integrations do
+ describe 'POST /integrations/slack/interactions' do
+ let_it_be(:slack_installation) { create(:slack_integration) }
+
+ let(:payload) { {} }
+ let(:params) { { payload: Gitlab::Json.dump(payload) } }
+
+ let(:headers) do
+ {
+ ::API::Integrations::Slack::Request::VERIFICATION_TIMESTAMP_HEADER => Time.current.to_i.to_s,
+ ::API::Integrations::Slack::Request::VERIFICATION_SIGNATURE_HEADER => 'mock_verified_signature'
+ }
+ end
+
+ before do
+ allow(ActiveSupport::SecurityUtils).to receive(:secure_compare) do |signature|
+ signature == 'mock_verified_signature'
+ end
+
+ stub_application_setting(slack_app_signing_secret: 'mock_key')
+ end
+
+ subject { post api('/integrations/slack/interactions'), params: params, headers: headers }
+
+ it_behaves_like 'Slack request verification'
+
+ context 'when type param is unknown' do
+ let(:payload) do
+ { type: 'unknown_type' }
+ end
+
+ it 'generates a tracked error' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).once
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.body).to be_empty
+ end
+ end
+
+ context 'when event.type param is view_closed' do
+ let(:payload) do
+ {
+ type: 'view_closed',
+ team_id: slack_installation.team_id,
+ event: {
+ type: 'view_closed',
+ user: 'U0123ABCDEF'
+ }
+ }
+ end
+
+ it 'calls the Slack Interactivity Service' do
+ expect_next_instance_of(::Integrations::SlackInteractionService) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/integrations/slack/options_spec.rb b/spec/requests/api/integrations/slack/options_spec.rb
new file mode 100644
index 00000000000..eef993d0329
--- /dev/null
+++ b/spec/requests/api/integrations/slack/options_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Integrations::Slack::Options, feature_category: :integrations do
+ describe 'POST /integrations/slack/options' do
+ let_it_be(:slack_installation) { create(:slack_integration) }
+
+ let(:payload) { {} }
+ let(:params) { { payload: Gitlab::Json.dump(payload) } }
+
+ let(:headers) do
+ {
+ ::API::Integrations::Slack::Request::VERIFICATION_TIMESTAMP_HEADER => Time.current.to_i.to_s,
+ ::API::Integrations::Slack::Request::VERIFICATION_SIGNATURE_HEADER => 'mock_verified_signature'
+ }
+ end
+
+ before do
+ allow(ActiveSupport::SecurityUtils).to receive(:secure_compare) do |signature|
+ signature == 'mock_verified_signature'
+ end
+
+ stub_application_setting(slack_app_signing_secret: 'mock_key')
+ end
+
+ subject(:post_to_slack_api) { post api('/integrations/slack/options'), params: params, headers: headers }
+
+ it_behaves_like 'Slack request verification'
+
+ context 'when type param is unknown' do
+ let(:payload) do
+ { action_id: 'unknown_action' }
+ end
+
+ it 'generates a tracked error' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).once
+
+ post_to_slack_api
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.body).to be_empty
+ end
+ end
+
+ context 'when action_id param is assignee' do
+ let(:payload) do
+ {
+ action_id: 'assignee'
+ }
+ end
+
+ it 'calls the Slack Interactivity Service' do
+ expect_next_instance_of(::Integrations::SlackOptionService) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ post_to_slack_api
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/integrations_spec.rb b/spec/requests/api/integrations_spec.rb
index c35b9bab0ec..8d348dc0a54 100644
--- a/spec/requests/api/integrations_spec.rb
+++ b/spec/requests/api/integrations_spec.rb
@@ -10,14 +10,6 @@ RSpec.describe API::Integrations, feature_category: :integrations do
create(:project, creator_id: user.id, namespace: user.namespace)
end
- # The API supports all integrations except the GitLab Slack Application
- # integration; this integration must be installed via the UI.
- def self.integration_names
- names = Integration.available_integration_names
- names.delete(Integrations::GitlabSlackApplication.to_param) if Gitlab.ee?
- names
- end
-
%w[integrations services].each do |endpoint|
describe "GET /projects/:id/#{endpoint}" do
it 'returns authentication error when unauthenticated' do
@@ -51,9 +43,19 @@ RSpec.describe API::Integrations, feature_category: :integrations do
end
end
- integration_names.each do |integration|
+ where(:integration) do
+ # The API supports all integrations except the GitLab Slack Application
+ # integration; this integration must be installed via the UI.
+ names = Integration.available_integration_names
+ names.delete(Integrations::GitlabSlackApplication.to_param) if Gitlab.ee?
+ names - %w[shimo zentao]
+ end
+
+ with_them do
+ integration = params[:integration]
+
describe "PUT /projects/:id/#{endpoint}/#{integration.dasherize}" do
- include_context integration
+ include_context 'with integration'
# NOTE: Some attributes are not supported for PUT requests, even though they probably should be.
# We can fix these manually, or with a generic approach like https://gitlab.com/gitlab-org/gitlab/-/issues/348208
@@ -62,7 +64,7 @@ RSpec.describe API::Integrations, feature_category: :integrations do
datadog: %i[archive_trace_events],
discord: %i[branches_to_be_notified notify_only_broken_pipelines],
hangouts_chat: %i[notify_only_broken_pipelines],
- jira: %i[issues_enabled project_key vulnerabilities_enabled vulnerabilities_issuetype],
+ jira: %i[issues_enabled project_key jira_issue_regex jira_issue_prefix vulnerabilities_enabled vulnerabilities_issuetype],
mattermost: %i[deployment_channel labels_to_be_notified],
mock_ci: %i[enable_ssl_verification],
prometheus: %i[manual_configuration],
@@ -119,7 +121,7 @@ RSpec.describe API::Integrations, feature_category: :integrations do
end
describe "DELETE /projects/:id/#{endpoint}/#{integration.dasherize}" do
- include_context integration
+ include_context 'with integration'
before do
initialize_integration(integration)
@@ -135,7 +137,7 @@ RSpec.describe API::Integrations, feature_category: :integrations do
end
describe "GET /projects/:id/#{endpoint}/#{integration.dasherize}" do
- include_context integration
+ include_context 'with integration'
let!(:initialized_integration) { initialize_integration(integration, active: true) }
@@ -367,7 +369,7 @@ RSpec.describe API::Integrations, feature_category: :integrations do
describe 'Jira integration' do
let(:integration_name) { 'jira' }
let(:params) do
- { url: 'https://jira.example.com', username: 'username', password: 'password' }
+ { url: 'https://jira.example.com', username: 'username', password: 'password', jira_auth_type: 0 }
end
before do
@@ -426,4 +428,28 @@ RSpec.describe API::Integrations, feature_category: :integrations do
expect(response_keys).not_to include(*integration.secret_fields)
end
end
+
+ describe 'POST /slack/trigger' do
+ before_all do
+ create(:gitlab_slack_application_integration, project: project)
+ end
+
+ before do
+ stub_application_setting(slack_app_verification_token: 'token')
+ end
+
+ it 'returns status 200' do
+ post api('/slack/trigger'), params: { token: 'token', text: 'help' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['response_type']).to eq("ephemeral")
+ end
+
+ it 'returns status 404 when token is invalid' do
+ post api('/slack/trigger'), params: { token: 'invalid', text: 'foo' }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['response_type']).to be_blank
+ end
+ end
end
diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb
index ca32271f573..6414b1efe6a 100644
--- a/spec/requests/api/internal/base_spec.rb
+++ b/spec/requests/api/internal/base_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Internal::Base, feature_category: :authentication_and_authorization do
+RSpec.describe API::Internal::Base, feature_category: :system_access do
include GitlabShellHelpers
include APIInternalBaseHelpers
@@ -10,6 +10,9 @@ RSpec.describe API::Internal::Base, feature_category: :authentication_and_author
let_it_be(:project, reload: true) { create(:project, :repository, :wiki_repo) }
let_it_be(:personal_snippet) { create(:personal_snippet, :repository, author: user) }
let_it_be(:project_snippet) { create(:project_snippet, :repository, author: user, project: project) }
+ let_it_be(:max_pat_access_token_lifetime) do
+ PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now.to_date.freeze
+ end
let(:key) { create(:key, user: user) }
let(:secret_token) { Gitlab::Shell.secret_token }
@@ -194,39 +197,68 @@ RSpec.describe API::Internal::Base, feature_category: :authentication_and_author
expect(json_response['message']).to match(/\AInvalid scope: 'badscope'. Valid scopes are: /)
end
- it 'returns a token without expiry when the expires_at parameter is missing' do
- token_size = (PersonalAccessToken.token_prefix || '').size + 20
+ it 'returns a token with expiry when it receives a valid expires_at parameter' do
+ freeze_time do
+ token_size = (PersonalAccessToken.token_prefix || '').size + 20
+
+ post api('/internal/personal_access_token'),
+ params: {
+ key_id: key.id,
+ name: 'newtoken',
+ scopes: %w(read_api read_repository),
+ expires_at: max_pat_access_token_lifetime
+ },
+ headers: gitlab_shell_internal_api_request_header
- post api('/internal/personal_access_token'),
- params: {
- key_id: key.id,
- name: 'newtoken',
- scopes: %w(read_api read_repository)
- },
- headers: gitlab_shell_internal_api_request_header
+ expect(json_response['success']).to be_truthy
+ expect(json_response['token']).to match(/\A\S{#{token_size}}\z/)
+ expect(json_response['scopes']).to match_array(%w(read_api read_repository))
+ expect(json_response['expires_at']).to eq(max_pat_access_token_lifetime.iso8601)
+ end
+ end
- expect(json_response['success']).to be_truthy
- expect(json_response['token']).to match(/\A\S{#{token_size}}\z/)
- expect(json_response['scopes']).to match_array(%w(read_api read_repository))
- expect(json_response['expires_at']).to be_nil
+ context 'when default_pat_expiration feature flag is true' do
+ it 'returns token with expiry as PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS' do
+ freeze_time do
+ token_size = (PersonalAccessToken.token_prefix || '').size + 20
+
+ post api('/internal/personal_access_token'),
+ params: {
+ key_id: key.id,
+ name: 'newtoken',
+ scopes: %w(read_api read_repository)
+ },
+ headers: gitlab_shell_internal_api_request_header
+
+ expect(json_response['success']).to be_truthy
+ expect(json_response['token']).to match(/\A\S{#{token_size}}\z/)
+ expect(json_response['scopes']).to match_array(%w(read_api read_repository))
+ expect(json_response['expires_at']).to eq(max_pat_access_token_lifetime.iso8601)
+ end
+ end
end
- it 'returns a token with expiry when it receives a valid expires_at parameter' do
- token_size = (PersonalAccessToken.token_prefix || '').size + 20
+ context 'when default_pat_expiration feature flag is false' do
+ before do
+ stub_feature_flags(default_pat_expiration: false)
+ end
- post api('/internal/personal_access_token'),
- params: {
- key_id: key.id,
- name: 'newtoken',
- scopes: %w(read_api read_repository),
- expires_at: '9001-11-17'
- },
- headers: gitlab_shell_internal_api_request_header
+ it 'uses nil expiration value' do
+ token_size = (PersonalAccessToken.token_prefix || '').size + 20
+
+ post api('/internal/personal_access_token'),
+ params: {
+ key_id: key.id,
+ name: 'newtoken',
+ scopes: %w(read_api read_repository)
+ },
+ headers: gitlab_shell_internal_api_request_header
- expect(json_response['success']).to be_truthy
- expect(json_response['token']).to match(/\A\S{#{token_size}}\z/)
- expect(json_response['scopes']).to match_array(%w(read_api read_repository))
- expect(json_response['expires_at']).to eq('9001-11-17')
+ expect(json_response['success']).to be_truthy
+ expect(json_response['token']).to match(/\A\S{#{token_size}}\z/)
+ expect(json_response['scopes']).to match_array(%w(read_api read_repository))
+ expect(json_response['expires_at']).to be_nil
+ end
end
end
@@ -514,7 +546,7 @@ RSpec.describe API::Internal::Base, feature_category: :authentication_and_author
expect(json_response["gl_repository"]).to eq("wiki-#{project.id}")
expect(json_response["gl_key_type"]).to eq("key")
expect(json_response["gl_key_id"]).to eq(key.id)
- expect(user.reload.last_activity_on).to be_nil
+ expect(user.reload.last_activity_on).to eql(Date.today)
end
it_behaves_like 'sets hook env' do
@@ -553,7 +585,7 @@ RSpec.describe API::Internal::Base, feature_category: :authentication_and_author
expect(json_response["status"]).to be_truthy
expect(json_response["gl_project_path"]).to eq(personal_snippet.repository.full_path)
expect(json_response["gl_repository"]).to eq("snippet-#{personal_snippet.id}")
- expect(user.reload.last_activity_on).to be_nil
+ expect(user.reload.last_activity_on).to eql(Date.today)
end
it_behaves_like 'sets hook env' do
@@ -585,7 +617,7 @@ RSpec.describe API::Internal::Base, feature_category: :authentication_and_author
expect(json_response["status"]).to be_truthy
expect(json_response["gl_project_path"]).to eq(project_snippet.repository.full_path)
expect(json_response["gl_repository"]).to eq("snippet-#{project_snippet.id}")
- expect(user.reload.last_activity_on).to be_nil
+ expect(user.reload.last_activity_on).to eql(Date.today)
end
it_behaves_like 'sets hook env' do
@@ -703,7 +735,7 @@ RSpec.describe API::Internal::Base, feature_category: :authentication_and_author
expect(json_response["gitaly"]["repository"]["relative_path"]).to eq(project.repository.gitaly_repository.relative_path)
expect(json_response["gitaly"]["address"]).to eq(Gitlab::GitalyClient.address(project.repository_storage))
expect(json_response["gitaly"]["token"]).to eq(Gitlab::GitalyClient.token(project.repository_storage))
- expect(user.reload.last_activity_on).to be_nil
+ expect(user.reload.last_activity_on).to eql(Date.today)
end
it_behaves_like 'rate limited request' do
@@ -862,7 +894,7 @@ RSpec.describe API::Internal::Base, feature_category: :authentication_and_author
expect(json_response['status']).to be_truthy
expect(json_response['payload']).to eql(payload)
expect(json_response['gl_console_messages']).to eql(console_messages)
- expect(user.reload.last_activity_on).to be_nil
+ expect(user.reload.last_activity_on).to eql(Date.today)
end
end
end
diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb
index be76e55269a..c07382a6e04 100644
--- a/spec/requests/api/internal/kubernetes_spec.rb
+++ b/spec/requests/api/internal/kubernetes_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Internal::Kubernetes, feature_category: :kubernetes_management do
+RSpec.describe API::Internal::Kubernetes, feature_category: :deployment_management do
let(:jwt_auth_headers) do
jwt_token = JWT.encode({ 'iss' => Gitlab::Kas::JWT_ISSUER }, Gitlab::Kas.secret, 'HS256')
@@ -59,12 +59,29 @@ RSpec.describe API::Internal::Kubernetes, feature_category: :kubernetes_manageme
end
end
+ shared_examples 'error handling' do
+ let!(:agent_token) { create(:cluster_agent_token) }
+
+ # this test verifies fix for an issue where AgentToken passed in Authorization
+ # header broke error handling in the api_helpers.rb. It can be removed after
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/406582 is done
+ it 'returns correct error for the endpoint' do
+ allow(Gitlab::Kas).to receive(:verify_api_request).and_raise(StandardError.new('Unexpected Error'))
+
+ send_request(headers: { 'Authorization' => "Bearer #{agent_token.token}" })
+
+ expect(response).to have_gitlab_http_status(:internal_server_error)
+ expect(response.body).to include("Unexpected Error")
+ end
+ end
+
describe 'POST /internal/kubernetes/usage_metrics', :clean_gitlab_redis_shared_state do
def send_request(headers: {}, params: {})
post api('/internal/kubernetes/usage_metrics'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
end
include_examples 'authorization'
+ include_examples 'error handling'
context 'is authenticated for an agent' do
let!(:agent_token) { create(:cluster_agent_token) }
@@ -147,19 +164,30 @@ RSpec.describe API::Internal::Kubernetes, feature_category: :kubernetes_manageme
projects: [
{ id: project.full_path, default_namespace: 'staging' }
]
+ },
+ user_access: {
+ groups: [
+ { id: group.full_path }
+ ],
+ projects: [
+ { id: project.full_path }
+ ]
}
}
end
include_examples 'authorization'
+ include_examples 'error handling'
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)
+ expect(agent.ci_access_authorized_groups).to contain_exactly(group)
+ expect(agent.ci_access_authorized_projects).to contain_exactly(project)
+ expect(agent.user_access_authorized_groups).to contain_exactly(group)
+ expect(agent.user_access_authorized_projects).to contain_exactly(project)
end
end
@@ -179,6 +207,7 @@ RSpec.describe API::Internal::Kubernetes, feature_category: :kubernetes_manageme
include_examples 'authorization'
include_examples 'agent authentication'
+ include_examples 'error handling'
context 'an agent is found' do
let!(:agent_token) { create(:cluster_agent_token) }
@@ -223,6 +252,7 @@ RSpec.describe API::Internal::Kubernetes, feature_category: :kubernetes_manageme
include_examples 'authorization'
include_examples 'agent authentication'
+ include_examples 'error handling'
context 'an agent is found' do
let_it_be(:agent_token) { create(:cluster_agent_token) }
@@ -306,4 +336,145 @@ RSpec.describe API::Internal::Kubernetes, feature_category: :kubernetes_manageme
end
end
end
+
+ describe 'POST /internal/kubernetes/authorize_proxy_user', :clean_gitlab_redis_sessions do
+ include SessionHelpers
+
+ def send_request(headers: {}, params: {})
+ post api('/internal/kubernetes/authorize_proxy_user'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
+ end
+
+ def stub_user_session(user, csrf_token)
+ stub_session(
+ {
+ 'warden.user.user.key' => [[user.id], user.authenticatable_salt],
+ '_csrf_token' => csrf_token
+ }
+ )
+ end
+
+ def stub_user_session_with_no_user_id(user, csrf_token)
+ stub_session(
+ {
+ 'warden.user.user.key' => [[nil], user.authenticatable_salt],
+ '_csrf_token' => csrf_token
+ }
+ )
+ end
+
+ def mask_token(encoded_token)
+ controller = ActionController::Base.new
+ raw_token = controller.send(:decode_csrf_token, encoded_token)
+ controller.send(:mask_token, raw_token)
+ end
+
+ def new_token
+ ActionController::Base.new.send(:generate_csrf_token)
+ end
+
+ let_it_be(:organization) { create(:group) }
+ let_it_be(:configuration_project) { create(:project, group: organization) }
+ let_it_be(:agent) { create(:cluster_agent, name: 'the-agent', project: configuration_project) }
+ let_it_be(:another_agent) { create(:cluster_agent) }
+ let_it_be(:deployment_project) { create(:project, group: organization) }
+ let_it_be(:deployment_group) { create(:group, parent: organization) }
+
+ let(:user_access_config) do
+ {
+ 'user_access' => {
+ 'access_as' => { 'agent' => {} },
+ 'projects' => [{ 'id' => deployment_project.full_path }],
+ 'groups' => [{ 'id' => deployment_group.full_path }]
+ }
+ }
+ end
+
+ let(:user) { create(:user) }
+
+ before do
+ allow(::Gitlab::Kas).to receive(:enabled?).and_return true
+ Clusters::Agents::Authorizations::UserAccess::RefreshService.new(agent, config: user_access_config).execute
+ end
+
+ it 'returns 400 when cookie is invalid' do
+ send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: '123', csrf_token: mask_token(new_token) })
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 401 when session is not found' do
+ access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id('abc')
+ send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(new_token) })
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'returns 401 when CSRF token does not match' do
+ public_id = stub_user_session(user, new_token)
+ access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id)
+ send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(new_token) })
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'returns 404 for non-existent agent' do
+ token = new_token
+ public_id = stub_user_session(user, token)
+ access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id)
+ send_request(params: { agent_id: non_existing_record_id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) })
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns 403 when user has no access' do
+ token = new_token
+ public_id = stub_user_session(user, token)
+ access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id)
+ send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) })
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'returns 200 when user has access' do
+ deployment_project.add_member(user, :developer)
+ token = new_token
+ public_id = stub_user_session(user, token)
+ access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id)
+ send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) })
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+
+ it 'returns 401 when user has valid KAS cookie and CSRF token but has no access to requested agent' do
+ deployment_project.add_member(user, :developer)
+ token = new_token
+ public_id = stub_user_session(user, token)
+ access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id)
+ send_request(params: { agent_id: another_agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) })
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'returns 401 when global flag is disabled' do
+ stub_feature_flags(kas_user_access: false)
+
+ deployment_project.add_member(user, :developer)
+ token = new_token
+ public_id = stub_user_session(user, token)
+ access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id)
+ send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) })
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'returns 401 when user id is not found in session' do
+ deployment_project.add_member(user, :developer)
+ token = new_token
+ public_id = stub_user_session_with_no_user_id(user, token)
+ access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id)
+ send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) })
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
end
diff --git a/spec/requests/api/internal/pages_spec.rb b/spec/requests/api/internal/pages_spec.rb
index 56f1089843b..1006319eabf 100644
--- a/spec/requests/api/internal/pages_spec.rb
+++ b/spec/requests/api/internal/pages_spec.rb
@@ -3,193 +3,97 @@
require 'spec_helper'
RSpec.describe API::Internal::Pages, feature_category: :pages do
- let(:auth_headers) do
- jwt_token = JWT.encode({ 'iss' => 'gitlab-pages' }, Gitlab::Pages.secret, 'HS256')
- { Gitlab::Pages::INTERNAL_API_REQUEST_HEADER => jwt_token }
+ let_it_be(:group) { create(:group) }
+ let_it_be_with_reload(:project) { create(:project, group: group) }
+
+ let(:auth_header) do
+ {
+ Gitlab::Pages::INTERNAL_API_REQUEST_HEADER => JWT.encode(
+ { 'iss' => 'gitlab-pages' },
+ Gitlab::Pages.secret, 'HS256')
+ }
end
- let(:pages_secret) { SecureRandom.random_bytes(Gitlab::Pages::SECRET_LENGTH) }
-
before do
- allow(Gitlab::Pages).to receive(:secret).and_return(pages_secret)
+ allow(Gitlab::Pages)
+ .to receive(:secret)
+ .and_return(SecureRandom.random_bytes(Gitlab::Pages::SECRET_LENGTH))
+
stub_pages_object_storage(::Pages::DeploymentUploader)
end
- describe "GET /internal/pages/status" do
- def query_enabled(headers = {})
- get api("/internal/pages/status"), headers: headers
- end
-
+ describe 'GET /internal/pages/status' do
it 'responds with 401 Unauthorized' do
- query_enabled
+ get api('/internal/pages/status')
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 204 no content' do
- query_enabled(auth_headers)
+ get api('/internal/pages/status'), headers: auth_header
expect(response).to have_gitlab_http_status(:no_content)
expect(response.body).to be_empty
end
end
- describe "GET /internal/pages" do
- def query_host(host, headers = {})
- get api("/internal/pages"), headers: headers, params: { host: host }
- end
-
- around do |example|
- freeze_time do
- example.run
- end
- end
-
- context 'not authenticated' do
+ describe 'GET /internal/pages' do
+ context 'when not authenticated' do
it 'responds with 401 Unauthorized' do
- query_host('pages.gitlab.io')
+ get api('/internal/pages')
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
- context 'authenticated' do
- def query_host(host)
- jwt_token = JWT.encode({ 'iss' => 'gitlab-pages' }, Gitlab::Pages.secret, 'HS256')
- headers = { Gitlab::Pages::INTERNAL_API_REQUEST_HEADER => jwt_token }
-
- super(host, headers)
+ context 'when authenticated' do
+ before do
+ project.update_pages_deployment!(create(:pages_deployment, project: project))
end
- def deploy_pages(project)
- deployment = create(:pages_deployment, project: project)
- project.mark_pages_as_deployed
- project.update_pages_deployment!(deployment)
+ around do |example|
+ freeze_time do
+ example.run
+ end
end
- context 'domain does not exist' do
+ context 'when domain does not exist' do
it 'responds with 204 no content' do
- query_host('pages.gitlab.io')
+ get api('/internal/pages'), headers: auth_header, params: { host: 'any-domain.gitlab.io' }
expect(response).to have_gitlab_http_status(:no_content)
expect(response.body).to be_empty
end
end
- context 'serverless domain' do
- let(:namespace) { create(:namespace, name: 'gitlab-org') }
- let(:project) { create(:project, namespace: namespace, name: 'gitlab-ce') }
- let(:environment) { create(:environment, project: project) }
- let(:pages_domain) { create(:pages_domain, domain: 'serverless.gitlab.io') }
- let(:knative_without_ingress) { create(:clusters_applications_knative) }
- let(:knative_with_ingress) { create(:clusters_applications_knative, external_ip: '10.0.0.1') }
-
- context 'without a knative ingress gateway IP' do
- let!(:serverless_domain_cluster) do
- create(
- :serverless_domain_cluster,
- uuid: 'abcdef12345678',
- pages_domain: pages_domain,
- knative: knative_without_ingress
- )
- end
-
- let(:serverless_domain) do
- create(
- :serverless_domain,
- serverless_domain_cluster: serverless_domain_cluster,
- environment: environment
- )
- end
-
- it 'responds with 204 no content' do
- query_host(serverless_domain.uri.host)
-
- expect(response).to have_gitlab_http_status(:no_content)
- expect(response.body).to be_empty
- end
- end
-
- context 'with a knative ingress gateway IP' do
- let!(:serverless_domain_cluster) do
- create(
- :serverless_domain_cluster,
- uuid: 'abcdef12345678',
- pages_domain: pages_domain,
- knative: knative_with_ingress
- )
- end
-
- let(:serverless_domain) do
- create(
- :serverless_domain,
- serverless_domain_cluster: serverless_domain_cluster,
- environment: environment
- )
- end
-
- it 'responds with 204 because of feature deprecation' do
- query_host(serverless_domain.uri.host)
+ context 'when querying a custom domain' do
+ let_it_be(:pages_domain) { create(:pages_domain, domain: 'pages.io', project: project) }
- expect(response).to have_gitlab_http_status(:no_content)
- expect(response.body).to be_empty
-
- ##
- # Serverless serving and reverse proxy to Kubernetes / Knative has
- # been deprecated and disabled, as per
- # https://gitlab.com/gitlab-org/gitlab-pages/-/issues/467
- #
- # expect(response).to match_response_schema('internal/serverless/virtual_domain')
- # expect(json_response['certificate']).to eq(pages_domain.certificate)
- # expect(json_response['key']).to eq(pages_domain.key)
- #
- # expect(json_response['lookup_paths']).to eq(
- # [
- # {
- # 'source' => {
- # 'type' => 'serverless',
- # 'service' => "test-function.#{project.name}-#{project.id}-#{environment.slug}.#{serverless_domain_cluster.knative.hostname}",
- # 'cluster' => {
- # 'hostname' => serverless_domain_cluster.knative.hostname,
- # 'address' => serverless_domain_cluster.knative.external_ip,
- # 'port' => 443,
- # 'cert' => serverless_domain_cluster.certificate,
- # 'key' => serverless_domain_cluster.key
- # }
- # }
- # }
- # ]
- # )
+ context 'when there are no pages deployed for the related project' do
+ before do
+ project.mark_pages_as_not_deployed
end
- end
- end
- context 'custom domain' do
- let(:namespace) { create(:namespace, name: 'gitlab-org') }
- let(:project) { create(:project, namespace: namespace, name: 'gitlab-ce') }
- let!(:pages_domain) { create(:pages_domain, domain: 'pages.io', project: project) }
-
- context 'when there are no pages deployed for the related project' do
it 'responds with 204 No Content' do
- query_host('pages.io')
+ get api('/internal/pages'), headers: auth_header, params: { host: 'pages.io' }
expect(response).to have_gitlab_http_status(:no_content)
end
end
context 'when there are pages deployed for the related project' do
- it 'domain lookup is case insensitive' do
- deploy_pages(project)
+ before do
+ project.mark_pages_as_deployed
+ end
- query_host('Pages.IO')
+ it 'domain lookup is case insensitive' do
+ get api('/internal/pages'), headers: auth_header, params: { host: 'Pages.IO' }
expect(response).to have_gitlab_http_status(:ok)
end
it 'responds with the correct domain configuration' do
- deploy_pages(project)
-
- query_host('pages.io')
+ get api('/internal/pages'), headers: auth_header, params: { host: 'pages.io' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('internal/pages/virtual_domain')
@@ -212,7 +116,9 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do
'sha256' => deployment.file_sha256,
'file_size' => deployment.size,
'file_count' => deployment.file_count
- }
+ },
+ 'unique_host' => nil,
+ 'root_directory' => deployment.root_directory
}
]
)
@@ -220,20 +126,67 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do
end
end
- context 'namespaced domain' do
- let(:group) { create(:group, name: 'mygroup') }
+ context 'when querying a unique domain' do
+ before_all do
+ project.project_setting.update!(
+ pages_unique_domain: 'unique-domain',
+ pages_unique_domain_enabled: true
+ )
+ end
- before do
- allow(Settings.pages).to receive(:host).and_return('gitlab-pages.io')
- allow(Gitlab.config.pages).to receive(:url).and_return("http://gitlab-pages.io")
+ context 'when there are no pages deployed for the related project' do
+ before do
+ project.mark_pages_as_not_deployed
+ end
+
+ it 'responds with 204 No Content' do
+ get api('/internal/pages'), headers: auth_header, params: { host: 'unique-domain.example.com' }
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
end
- context 'regular project' do
- it 'responds with the correct domain configuration' do
- project = create(:project, group: group, name: 'myproject')
- deploy_pages(project)
+ context 'when there are pages deployed for the related project' do
+ before do
+ project.mark_pages_as_deployed
+ end
+
+ context 'when the feature flag is disabled' do
+ before do
+ stub_feature_flags(pages_unique_domain: false)
+ end
- query_host('mygroup.gitlab-pages.io')
+ context 'when there are no pages deployed for the related project' do
+ it 'responds with 204 No Content' do
+ get api('/internal/pages'), headers: auth_header, params: { host: 'unique-domain.example.com' }
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
+ context 'when the unique domain is disabled' do
+ before do
+ project.project_setting.update!(pages_unique_domain_enabled: false)
+ end
+
+ context 'when there are no pages deployed for the related project' do
+ it 'responds with 204 No Content' do
+ get api('/internal/pages'), headers: auth_header, params: { host: 'unique-domain.example.com' }
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
+ it 'domain lookup is case insensitive' do
+ get api('/internal/pages'), headers: auth_header, params: { host: 'Unique-Domain.example.com' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'responds with the correct domain configuration' do
+ get api('/internal/pages'), headers: auth_header, params: { host: 'unique-domain.example.com' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('internal/pages/virtual_domain')
@@ -245,7 +198,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do
'project_id' => project.id,
'access_control' => false,
'https_only' => false,
- 'prefix' => '/myproject/',
+ 'prefix' => '/',
'source' => {
'type' => 'zip',
'path' => deployment.file.url(expire_at: 1.day.from_now),
@@ -253,56 +206,119 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do
'sha256' => deployment.file_sha256,
'file_size' => deployment.size,
'file_count' => deployment.file_count
- }
+ },
+ 'unique_host' => 'unique-domain.example.com',
+ 'root_directory' => 'public'
}
]
)
end
end
+ end
- it 'avoids N+1 queries' do
- project = create(:project, group: group)
- deploy_pages(project)
-
- control = ActiveRecord::QueryRecorder.new { query_host('mygroup.gitlab-pages.io') }
+ context 'when querying a namespaced domain' do
+ before do
+ allow(Settings.pages).to receive(:host).and_return('gitlab-pages.io')
+ allow(Gitlab.config.pages).to receive(:url).and_return("http://gitlab-pages.io")
+ end
- 3.times do
- project = create(:project, group: group)
- deploy_pages(project)
+ context 'when there are no pages deployed for the related project' do
+ before do
+ project.mark_pages_as_not_deployed
end
- expect { query_host('mygroup.gitlab-pages.io') }.not_to exceed_query_limit(control)
+ it 'responds with 204 No Content' do
+ get api('/internal/pages'), headers: auth_header, params: { host: "#{group.path}.gitlab-pages.io" }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('internal/pages/virtual_domain')
+ expect(json_response['lookup_paths']).to eq([])
+ end
end
- context 'group root project' do
- it 'responds with the correct domain configuration' do
- project = create(:project, group: group, name: 'mygroup.gitlab-pages.io')
- deploy_pages(project)
+ context 'when there are pages deployed for the related project' do
+ before do
+ project.mark_pages_as_deployed
+ end
- query_host('mygroup.gitlab-pages.io')
+ context 'with a regular project' do
+ it 'responds with the correct domain configuration' do
+ get api('/internal/pages'), headers: auth_header, params: { host: "#{group.path}.gitlab-pages.io" }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('internal/pages/virtual_domain')
+
+ deployment = project.pages_metadatum.pages_deployment
+ expect(json_response['lookup_paths']).to eq(
+ [
+ {
+ 'project_id' => project.id,
+ 'access_control' => false,
+ 'https_only' => false,
+ 'prefix' => "/#{project.path}/",
+ 'source' => {
+ 'type' => 'zip',
+ 'path' => deployment.file.url(expire_at: 1.day.from_now),
+ 'global_id' => "gid://gitlab/PagesDeployment/#{deployment.id}",
+ 'sha256' => deployment.file_sha256,
+ 'file_size' => deployment.size,
+ 'file_count' => deployment.file_count
+ },
+ 'unique_host' => nil,
+ 'root_directory' => 'public'
+ }
+ ]
+ )
+ end
+ end
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('internal/pages/virtual_domain')
+ it 'avoids N+1 queries' do
+ control = ActiveRecord::QueryRecorder.new do
+ get api('/internal/pages'), headers: auth_header, params: { host: "#{group.path}.gitlab-pages.io" }
+ end
- deployment = project.pages_metadatum.pages_deployment
- expect(json_response['lookup_paths']).to eq(
- [
- {
- 'project_id' => project.id,
- 'access_control' => false,
- 'https_only' => false,
- 'prefix' => '/',
- 'source' => {
- 'type' => 'zip',
- 'path' => deployment.file.url(expire_at: 1.day.from_now),
- 'global_id' => "gid://gitlab/PagesDeployment/#{deployment.id}",
- 'sha256' => deployment.file_sha256,
- 'file_size' => deployment.size,
- 'file_count' => deployment.file_count
+ 3.times do
+ project = create(:project, group: group)
+ project.mark_pages_as_deployed
+ end
+
+ expect { get api('/internal/pages'), headers: auth_header, params: { host: "#{group.path}.gitlab-pages.io" } }
+ .not_to exceed_query_limit(control)
+ end
+
+ context 'with a group root project' do
+ before do
+ project.update!(path: "#{group.path}.gitlab-pages.io")
+ end
+
+ it 'responds with the correct domain configuration' do
+ get api('/internal/pages'), headers: auth_header, params: { host: "#{group.path}.gitlab-pages.io" }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('internal/pages/virtual_domain')
+
+ deployment = project.pages_metadatum.pages_deployment
+ expect(json_response['lookup_paths']).to eq(
+ [
+ {
+ 'project_id' => project.id,
+ 'access_control' => false,
+ 'https_only' => false,
+ 'prefix' => '/',
+ 'source' => {
+ 'type' => 'zip',
+ 'path' => deployment.file.url(expire_at: 1.day.from_now),
+ 'global_id' => "gid://gitlab/PagesDeployment/#{deployment.id}",
+ 'sha256' => deployment.file_sha256,
+ 'file_size' => deployment.size,
+ 'file_count' => deployment.file_count
+ },
+ 'unique_host' => nil,
+ 'root_directory' => 'public'
}
- }
- ]
- )
+ ]
+ )
+ end
end
end
end
diff --git a/spec/requests/api/internal/workhorse_spec.rb b/spec/requests/api/internal/workhorse_spec.rb
index 99d0ecabbb7..2657abffae6 100644
--- a/spec/requests/api/internal/workhorse_spec.rb
+++ b/spec/requests/api/internal/workhorse_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Internal::Workhorse, :allow_forgery_protection, feature_category: :not_owned do
+RSpec.describe API::Internal::Workhorse, :allow_forgery_protection, feature_category: :shared do
include WorkhorseHelpers
context '/authorize_upload' do
diff --git a/spec/requests/api/issue_links_spec.rb b/spec/requests/api/issue_links_spec.rb
index 40d8f6d2395..fcb199a91a4 100644
--- a/spec/requests/api/issue_links_spec.rb
+++ b/spec/requests/api/issue_links_spec.rb
@@ -87,7 +87,7 @@ RSpec.describe API::IssueLinks, feature_category: :team_planning do
end
context 'when user does not have write access to given issue' do
- it 'returns 404' do
+ it 'returns 403' do
unauthorized_project = create(:project)
target_issue = create(:issue, project: unauthorized_project)
unauthorized_project.add_guest(user)
@@ -95,8 +95,8 @@ RSpec.describe API::IssueLinks, feature_category: :team_planning do
post api("/projects/#{project.id}/issues/#{issue.iid}/links", user),
params: { target_project_id: unauthorized_project.id, target_issue_iid: target_issue.iid }
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('No matching issue found. Make sure that you are adding a valid issue URL.')
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq("Couldn't link issue. You must have at least the Reporter role in both projects.")
end
end
diff --git a/spec/requests/api/issues/get_group_issues_spec.rb b/spec/requests/api/issues/get_group_issues_spec.rb
index 0641c2135c1..eaa3c46d0ca 100644
--- a/spec/requests/api/issues/get_group_issues_spec.rb
+++ b/spec/requests/api/issues/get_group_issues_spec.rb
@@ -74,7 +74,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
let(:base_url) { "/groups/#{group.id}/issues" }
shared_examples 'group issues statistics' do
- it 'returns issues statistics' do
+ it 'returns issues statistics', :aggregate_failures do
get api("/groups/#{group.id}/issues_statistics", user), params: params
expect(response).to have_gitlab_http_status(:ok)
@@ -346,7 +346,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
group_project.add_reporter(user)
end
- it 'exposes known attributes' do
+ it 'exposes known attributes', :aggregate_failures do
get api(base_url, admin)
expect(response).to have_gitlab_http_status(:ok)
@@ -355,7 +355,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
it 'returns all group issues (including opened and closed)' do
- get api(base_url, admin)
+ get api(base_url, admin, admin_mode: true)
expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id])
end
@@ -385,7 +385,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
it 'returns group confidential issues for admin' do
- get api(base_url, admin), params: { state: :opened }
+ get api(base_url, admin, admin_mode: true), params: { state: :opened }
expect_paginated_array_response([group_confidential_issue.id, group_issue.id])
end
@@ -403,7 +403,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
context 'labels parameter' do
- it 'returns an array of labeled group issues' do
+ it 'returns an array of labeled group issues', :aggregate_failures do
get api(base_url, user), params: { labels: group_label.title }
expect_paginated_array_response(group_issue.id)
@@ -486,7 +486,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
end
- it 'returns an array of issues found by iids' do
+ it 'returns an array of issues found by iids', :aggregate_failures do
get api(base_url, user), params: { iids: [group_issue.iid] }
expect_paginated_array_response(group_issue.id)
@@ -505,14 +505,14 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect_paginated_array_response([])
end
- it 'returns an array of group issues with any label' do
+ it 'returns an array of group issues with any label', :aggregate_failures do
get api(base_url, user), params: { labels: IssuableFinder::Params::FILTER_ANY }
expect_paginated_array_response(group_issue.id)
expect(json_response.first['id']).to eq(group_issue.id)
end
- it 'returns an array of group issues with any label with labels param as array' do
+ it 'returns an array of group issues with any label with labels param as array', :aggregate_failures do
get api(base_url, user), params: { labels: [IssuableFinder::Params::FILTER_ANY] }
expect_paginated_array_response(group_issue.id)
@@ -555,7 +555,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect_paginated_array_response(group_closed_issue.id)
end
- it 'returns an array of issues with no milestone' do
+ it 'returns an array of issues with no milestone', :aggregate_failures do
get api(base_url, user), params: { milestone: no_milestone_title }
expect(response).to have_gitlab_http_status(:ok)
@@ -688,28 +688,28 @@ RSpec.describe API::Issues, feature_category: :team_planning do
let!(:issue2) { create(:issue, author: user2, project: group_project, created_at: 2.days.ago) }
let!(:issue3) { create(:issue, author: user2, assignees: [assignee, another_assignee], project: group_project, created_at: 1.day.ago) }
- it 'returns issues with by assignee_username' do
+ it 'returns issues with by assignee_username', :aggregate_failures do
get api(base_url, user), params: { assignee_username: [assignee.username], scope: 'all' }
expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id])
expect_paginated_array_response([issue3.id, group_confidential_issue.id])
end
- it 'returns issues by assignee_username as string' do
+ it 'returns issues by assignee_username as string', :aggregate_failures do
get api(base_url, user), params: { assignee_username: assignee.username, scope: 'all' }
expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id])
expect_paginated_array_response([issue3.id, group_confidential_issue.id])
end
- it 'returns error when multiple assignees are passed' do
+ it 'returns error when multiple assignees are passed', :aggregate_failures do
get api(base_url, user), params: { assignee_username: [assignee.username, another_assignee.username], scope: 'all' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response["error"]).to include("allows one value, but found 2")
end
- it 'returns error when assignee_username and assignee_id are passed together' do
+ it 'returns error when assignee_username and assignee_id are passed together', :aggregate_failures do
get api(base_url, user), params: { assignee_username: [assignee.username], assignee_id: another_assignee.id, scope: 'all' }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -719,7 +719,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
describe "#to_reference" do
- it 'exposes reference path in context of group' do
+ it 'exposes reference path in context of group', :aggregate_failures do
get api(base_url, user)
expect(json_response.first['references']['short']).to eq("##{group_closed_issue.iid}")
@@ -735,7 +735,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
group_closed_issue.reload
end
- it 'exposes reference path in context of parent group' do
+ it 'exposes reference path in context of parent group', :aggregate_failures do
get api("/groups/#{parent_group.id}/issues")
expect(json_response.first['references']['short']).to eq("##{group_closed_issue.iid}")
diff --git a/spec/requests/api/issues/get_project_issues_spec.rb b/spec/requests/api/issues/get_project_issues_spec.rb
index 6fc3903103b..137fba66eaa 100644
--- a/spec/requests/api/issues/get_project_issues_spec.rb
+++ b/spec/requests/api/issues/get_project_issues_spec.rb
@@ -99,7 +99,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
shared_examples 'project issues statistics' do
- it 'returns project issues statistics' do
+ it 'returns project issues statistics', :aggregate_failures do
get api("/projects/#{project.id}/issues_statistics", current_user), params: params
expect(response).to have_gitlab_http_status(:ok)
@@ -317,7 +317,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
it 'returns project confidential issues for admin' do
- get api("#{base_url}/issues", admin)
+ get api("#{base_url}/issues", admin, admin_mode: true)
expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id])
end
@@ -526,7 +526,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect_paginated_array_response([closed_issue.id, confidential_issue.id, issue.id])
end
- it 'exposes known attributes' do
+ it 'exposes known attributes', :aggregate_failures do
get api("#{base_url}/issues", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -607,28 +607,28 @@ RSpec.describe API::Issues, feature_category: :team_planning do
let!(:issue2) { create(:issue, author: user2, project: project, created_at: 2.days.ago) }
let!(:issue3) { create(:issue, author: user2, assignees: [assignee, another_assignee], project: project, created_at: 1.day.ago) }
- it 'returns issues by assignee_username' do
+ it 'returns issues by assignee_username', :aggregate_failures do
get api("/issues", user), params: { assignee_username: [assignee.username], scope: 'all' }
expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id])
expect_paginated_array_response([confidential_issue.id, issue3.id])
end
- it 'returns issues by assignee_username as string' do
+ it 'returns issues by assignee_username as string', :aggregate_failures do
get api("/issues", user), params: { assignee_username: assignee.username, scope: 'all' }
expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id])
expect_paginated_array_response([confidential_issue.id, issue3.id])
end
- it 'returns error when multiple assignees are passed' do
+ it 'returns error when multiple assignees are passed', :aggregate_failures do
get api("/issues", user), params: { assignee_username: [assignee.username, another_assignee.username], scope: 'all' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response["error"]).to include("allows one value, but found 2")
end
- it 'returns error when assignee_username and assignee_id are passed together' do
+ it 'returns error when assignee_username and assignee_id are passed together', :aggregate_failures do
get api("/issues", user), params: { assignee_username: [assignee.username], assignee_id: another_assignee.id, scope: 'all' }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -638,6 +638,12 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
describe 'GET /projects/:id/issues/:issue_iid' do
+ let(:path) { "/projects/#{project.id}/issues/#{confidential_issue.iid}" }
+
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
+
context 'when unauthenticated' do
it 'returns public issues' do
get api("/projects/#{project.id}/issues/#{issue.iid}")
@@ -646,7 +652,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
end
- it 'exposes known attributes' do
+ it 'exposes known attributes', :aggregate_failures do
get api("/projects/#{project.id}/issues/#{issue.iid}", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -686,7 +692,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
end
- it 'exposes the closed_at attribute' do
+ it 'exposes the closed_at attribute', :aggregate_failures do
get api("/projects/#{project.id}/issues/#{closed_issue.iid}", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -694,7 +700,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
context 'links exposure' do
- it 'exposes related resources full URIs' do
+ it 'exposes related resources full URIs', :aggregate_failures do
get api("/projects/#{project.id}/issues/#{issue.iid}", user)
links = json_response['_links']
@@ -706,7 +712,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
end
- it 'returns a project issue by internal id' do
+ it 'returns a project issue by internal id', :aggregate_failures do
get api("/projects/#{project.id}/issues/#{issue.iid}", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -727,43 +733,43 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'confidential issues' do
it 'returns 404 for non project members' do
- get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", non_member)
+ get api(path, non_member)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns 404 for project members with guest role' do
- get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", guest)
+ get api(path, guest)
expect(response).to have_gitlab_http_status(:not_found)
end
- it 'returns confidential issue for project members' do
- get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user)
+ it 'returns confidential issue for project members', :aggregate_failures do
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(confidential_issue.title)
expect(json_response['iid']).to eq(confidential_issue.iid)
end
- it 'returns confidential issue for author' do
- get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", author)
+ it 'returns confidential issue for author', :aggregate_failures do
+ get api(path, author)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(confidential_issue.title)
expect(json_response['iid']).to eq(confidential_issue.iid)
end
- it 'returns confidential issue for assignee' do
- get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", assignee)
+ it 'returns confidential issue for assignee', :aggregate_failures do
+ get api(path, assignee)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(confidential_issue.title)
expect(json_response['iid']).to eq(confidential_issue.iid)
end
- it 'returns confidential issue for admin' do
- get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", admin)
+ it 'returns confidential issue for admin', :aggregate_failures do
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(confidential_issue.title)
@@ -829,7 +835,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
let!(:related_mr) { create_referencing_mr(user, project, issue) }
context 'when unauthenticated' do
- it 'return list of referenced merge requests from issue' do
+ it 'return list of referenced merge requests from issue', :aggregate_failures do
get_related_merge_requests(project.id, issue.iid)
expect_paginated_array_response(related_mr.id)
@@ -890,6 +896,10 @@ RSpec.describe API::Issues, feature_category: :team_planning do
describe 'GET /projects/:id/issues/:issue_iid/user_agent_detail' do
let!(:user_agent_detail) { create(:user_agent_detail, subject: issue) }
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { "/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail" }
+ end
+
context 'when unauthenticated' do
it 'returns unauthorized' do
get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail")
@@ -898,8 +908,8 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
end
- it 'exposes known attributes' do
- get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", admin)
+ it 'exposes known attributes', :aggregate_failures do
+ get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['user_agent']).to eq(user_agent_detail.user_agent)
@@ -936,7 +946,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
)
end
- it 'returns a full list of participants' do
+ it 'returns a full list of participants', :aggregate_failures do
get api("/projects/#{project.id}/issues/#{issue.iid}/participants", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -945,7 +955,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
context 'when user cannot see a confidential note' do
- it 'returns a limited list of participants' do
+ it 'returns a limited list of participants', :aggregate_failures do
get api("/projects/#{project.id}/issues/#{issue.iid}/participants", create(:user))
expect(response).to have_gitlab_http_status(:ok)
diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb
index 4b60eaadcbc..af289352778 100644
--- a/spec/requests/api/issues/issues_spec.rb
+++ b/spec/requests/api/issues/issues_spec.rb
@@ -78,7 +78,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
shared_examples 'issues statistics' do
- it 'returns issues statistics' do
+ it 'returns issues statistics', :aggregate_failures do
get api("/issues_statistics", user), params: params
expect(response).to have_gitlab_http_status(:ok)
@@ -90,9 +90,13 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
describe 'GET /issues/:id' do
+ let(:path) { "/issues/#{issue.id}" }
+
+ it_behaves_like 'GET request permissions for admin mode'
+
context 'when unauthorized' do
it 'returns unauthorized' do
- get api("/issues/#{issue.id}")
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -101,7 +105,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'when authorized' do
context 'as a normal user' do
it 'returns forbidden' do
- get api("/issues/#{issue.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -109,8 +113,8 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'as an admin' do
context 'when issue exists' do
- it 'returns the issue' do
- get api("/issues/#{issue.id}", admin)
+ it 'returns the issue', :aggregate_failures do
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.dig('author', 'id')).to eq(issue.author.id)
@@ -121,7 +125,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'when issue does not exist' do
it 'returns 404' do
- get api("/issues/0", admin)
+ get api("/issues/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -132,7 +136,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
describe 'GET /issues' do
context 'when unauthenticated' do
- it 'returns an array of all issues' do
+ it 'returns an array of all issues', :aggregate_failures do
get api('/issues'), params: { scope: 'all' }
expect(response).to have_gitlab_http_status(:ok)
@@ -162,14 +166,14 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(response).to have_gitlab_http_status(:unauthorized)
end
- it 'returns an array of issues matching state in milestone' do
+ it 'returns an array of issues matching state in milestone', :aggregate_failures do
get api('/issues'), params: { milestone: 'foo', scope: 'all' }
expect(response).to have_gitlab_http_status(:ok)
expect_paginated_array_response([])
end
- it 'returns an array of issues matching state in milestone' do
+ it 'returns an array of issues matching state in milestone', :aggregate_failures do
get api('/issues'), params: { milestone: milestone.title, scope: 'all' }
expect(response).to have_gitlab_http_status(:ok)
@@ -273,7 +277,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
context 'when authenticated' do
- it 'returns an array of issues' do
+ it 'returns an array of issues', :aggregate_failures do
get api('/issues', user)
expect_paginated_array_response([issue.id, closed_issue.id])
@@ -532,7 +536,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'with incident issues' do
let_it_be(:incident) { create(:incident, project: project) }
- it 'avoids N+1 queries' do
+ it 'avoids N+1 queries', :aggregate_failures do
get api('/issues', user) # warm up
control = ActiveRecord::QueryRecorder.new do
@@ -553,7 +557,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'with issues closed as duplicates' do
let_it_be(:dup_issue_1) { create(:issue, :closed_as_duplicate, project: project) }
- it 'avoids N+1 queries' do
+ it 'avoids N+1 queries', :aggregate_failures do
get api('/issues', user) # warm up
control = ActiveRecord::QueryRecorder.new do
@@ -639,7 +643,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect_paginated_array_response([])
end
- it 'returns an array of labeled issues matching given state' do
+ it 'returns an array of labeled issues matching given state', :aggregate_failures do
get api('/issues', user), params: { labels: label.title, state: :opened }
expect_paginated_array_response(issue.id)
@@ -647,7 +651,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(json_response.first['state']).to eq('opened')
end
- it 'returns an array of labeled issues matching given state with labels param as array' do
+ it 'returns an array of labeled issues matching given state with labels param as array', :aggregate_failures do
get api('/issues', user), params: { labels: [label.title], state: :opened }
expect_paginated_array_response(issue.id)
@@ -917,14 +921,14 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
end
- it 'matches V4 response schema' do
+ it 'matches V4 response schema', :aggregate_failures do
get api('/issues', user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/issues')
end
- it 'returns a related merge request count of 0 if there are no related merge requests' do
+ it 'returns a related merge request count of 0 if there are no related merge requests', :aggregate_failures do
get api('/issues', user)
expect(response).to have_gitlab_http_status(:ok)
@@ -932,7 +936,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(json_response.first).to include('merge_requests_count' => 0)
end
- it 'returns a related merge request count > 0 if there are related merge requests' do
+ it 'returns a related merge request count > 0 if there are related merge requests', :aggregate_failures do
create(:merge_requests_closing_issues, issue: issue)
get api('/issues', user)
@@ -1013,28 +1017,28 @@ RSpec.describe API::Issues, feature_category: :team_planning do
let!(:issue2) { create(:issue, author: user2, project: project, created_at: 2.days.ago) }
let!(:issue3) { create(:issue, author: user2, assignees: [assignee, another_assignee], project: project, created_at: 1.day.ago) }
- it 'returns issues with by assignee_username' do
+ it 'returns issues with by assignee_username', :aggregate_failures do
get api("/issues", user), params: { assignee_username: [assignee.username], scope: 'all' }
expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id])
expect_paginated_array_response([confidential_issue.id, issue3.id])
end
- it 'returns issues by assignee_username as string' do
+ it 'returns issues by assignee_username as string', :aggregate_failures do
get api("/issues", user), params: { assignee_username: assignee.username, scope: 'all' }
expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id])
expect_paginated_array_response([confidential_issue.id, issue3.id])
end
- it 'returns error when multiple assignees are passed' do
+ it 'returns error when multiple assignees are passed', :aggregate_failures do
get api("/issues", user), params: { assignee_username: [assignee.username, another_assignee.username], scope: 'all' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response["error"]).to include("allows one value, but found 2")
end
- it 'returns error when assignee_username and assignee_id are passed together' do
+ it 'returns error when assignee_username and assignee_id are passed together', :aggregate_failures do
get api("/issues", user), params: { assignee_username: [assignee.username], assignee_id: another_assignee.id, scope: 'all' }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -1088,7 +1092,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
describe 'GET /projects/:id/issues/:issue_iid' do
- it 'exposes full reference path' do
+ it 'exposes full reference path', :aggregate_failures do
get api("/projects/#{project.id}/issues/#{issue.iid}", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -1106,7 +1110,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
context 'user does not have permission to view new issue' do
- it 'does not return the issue as closed_as_duplicate_of' do
+ it 'does not return the issue as closed_as_duplicate_of', :aggregate_failures do
get api("/projects/#{project.id}/issues/#{issue_closed_as_dup.iid}", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -1119,7 +1123,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
new_issue.project.add_guest(user)
end
- it 'returns the issue as closed_as_duplicate_of' do
+ it 'returns the issue as closed_as_duplicate_of', :aggregate_failures do
get api("/projects/#{project.id}/issues/#{issue_closed_as_dup.iid}", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -1131,7 +1135,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
describe "POST /projects/:id/issues" do
- it 'creates a new project issue' do
+ it 'creates a new project issue', :aggregate_failures do
post api("/projects/#{project.id}/issues", user), params: { title: 'new issue' }
expect(response).to have_gitlab_http_status(:created)
@@ -1139,6 +1143,15 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(json_response['issue_type']).to eq('issue')
end
+ context 'when confidential is null' do
+ it 'responds with 400 error', :aggregate_failures do
+ post api("/projects/#{project.id}/issues", user), params: { title: 'issue', confidential: nil }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('confidential is empty')
+ end
+ end
+
context 'when issue create service returns an unrecoverable error' do
before do
allow_next_instance_of(Issues::CreateService) do |create_service|
@@ -1146,7 +1159,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
end
- it 'returns and error message and status code from the service' do
+ it 'returns and error message and status code from the service', :aggregate_failures do
post api("/projects/#{project.id}/issues", user), params: { title: 'new issue' }
expect(response).to have_gitlab_http_status(:forbidden)
@@ -1160,6 +1173,11 @@ RSpec.describe API::Issues, feature_category: :team_planning do
let(:entity) { issue }
end
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:path) { "/projects/#{project.id}/issues/#{issue.iid}" }
+ let(:params) { { labels: 'label1', updated_at: Time.new(2000, 1, 1) } }
+ end
+
describe 'updated_at param' do
let(:fixed_time) { Time.new(2001, 1, 1) }
let(:updated_at) { Time.new(2000, 1, 1) }
@@ -1168,15 +1186,15 @@ RSpec.describe API::Issues, feature_category: :team_planning do
travel_to fixed_time
end
- it 'allows admins to set the timestamp' do
- put api("/projects/#{project.id}/issues/#{issue.iid}", admin), params: { labels: 'label1', updated_at: updated_at }
+ it 'allows admins to set the timestamp', :aggregate_failures do
+ put api("/projects/#{project.id}/issues/#{issue.iid}", admin, admin_mode: true), params: { labels: 'label1', updated_at: updated_at }
expect(response).to have_gitlab_http_status(:ok)
expect(Time.parse(json_response['updated_at'])).to be_like_time(updated_at)
expect(ResourceLabelEvent.last.created_at).to be_like_time(updated_at)
end
- it 'does not allow other users to set the timestamp' do
+ it 'does not allow other users to set the timestamp', :aggregate_failures do
reporter = create(:user)
project.add_developer(reporter)
@@ -1192,7 +1210,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
it 'allows issue type to be converted' do
put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: { issue_type: 'incident' }
- expect(issue.reload.incident?).to be(true)
+ expect(issue.reload.work_item_type.incident?).to be(true)
end
end
end
@@ -1259,7 +1277,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
context 'with valid params' do
- it 'reorders issues and returns a successful 200 response' do
+ it 'reorders issues and returns a successful 200 response', :aggregate_failures do
put api("/projects/#{project.id}/issues/#{issue1.iid}/reorder", user), params: { move_after_id: issue2.id, move_before_id: issue3.id }
expect(response).to have_gitlab_http_status(:ok)
@@ -1286,7 +1304,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
let(:other_project) { create(:project, group: group) }
let(:other_issue) { create(:issue, project: other_project, relative_position: 80) }
- it 'reorders issues and returns a successful 200 response' do
+ it 'reorders issues and returns a successful 200 response', :aggregate_failures do
put api("/projects/#{other_project.id}/issues/#{other_issue.iid}/reorder", user), params: { move_after_id: issue2.id, move_before_id: issue3.id }
expect(response).to have_gitlab_http_status(:ok)
diff --git a/spec/requests/api/issues/post_projects_issues_spec.rb b/spec/requests/api/issues/post_projects_issues_spec.rb
index 265091fa698..5a15a0b6dad 100644
--- a/spec/requests/api/issues/post_projects_issues_spec.rb
+++ b/spec/requests/api/issues/post_projects_issues_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Issues, feature_category: :team_planning do
+RSpec.describe API::Issues, :aggregate_failures, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) do
create(:project, :public, creator_id: user.id, namespace: user.namespace)
@@ -123,7 +123,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'an internal ID is provided' do
context 'by an admin' do
it 'sets the internal ID on the new issue' do
- post api("/projects/#{project.id}/issues", admin),
+ post api("/projects/#{project.id}/issues", admin, admin_mode: true),
params: { title: 'new issue', iid: 9001 }
expect(response).to have_gitlab_http_status(:created)
@@ -167,7 +167,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'when an issue with the same IID exists on database' do
it 'returns 409' do
- post api("/projects/#{project.id}/issues", admin),
+ post api("/projects/#{project.id}/issues", admin, admin_mode: true),
params: { title: 'new issue', iid: issue.iid }
expect(response).to have_gitlab_http_status(:conflict)
@@ -337,7 +337,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'by an admin' do
it 'sets the creation time on the new issue' do
- post api("/projects/#{project.id}/issues", admin), params: params
+ post api("/projects/#{project.id}/issues", admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:created)
expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time)
@@ -475,9 +475,15 @@ RSpec.describe API::Issues, feature_category: :team_planning do
describe '/projects/:id/issues/:issue_iid/move' do
let!(:target_project) { create(:project, creator_id: user.id, namespace: user.namespace) }
let!(:target_project2) { create(:project, creator_id: non_member.id, namespace: non_member.namespace) }
+ let(:path) { "/projects/#{project.id}/issues/#{issue.iid}/move" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { { to_project_id: target_project2.id } }
+ let(:failed_status_code) { 400 }
+ end
it 'moves an issue' do
- post api("/projects/#{project.id}/issues/#{issue.iid}/move", user),
+ post api(path, user),
params: { to_project_id: target_project.id }
expect(response).to have_gitlab_http_status(:created)
@@ -486,7 +492,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'when source and target projects are the same' do
it 'returns 400 when trying to move an issue' do
- post api("/projects/#{project.id}/issues/#{issue.iid}/move", user),
+ post api(path, user),
params: { to_project_id: project.id }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -496,7 +502,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'when the user does not have the permission to move issues' do
it 'returns 400 when trying to move an issue' do
- post api("/projects/#{project.id}/issues/#{issue.iid}/move", user),
+ post api(path, user),
params: { to_project_id: target_project2.id }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -505,7 +511,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
it 'moves the issue to another namespace if I am admin' do
- post api("/projects/#{project.id}/issues/#{issue.iid}/move", admin),
+ post api(path, admin, admin_mode: true),
params: { to_project_id: target_project2.id }
expect(response).to have_gitlab_http_status(:created)
@@ -544,7 +550,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
context 'when target project does not exist' do
it 'returns 404 when trying to move an issue' do
- post api("/projects/#{project.id}/issues/#{issue.iid}/move", user),
+ post api(path, user),
params: { to_project_id: 0 }
expect(response).to have_gitlab_http_status(:not_found)
diff --git a/spec/requests/api/issues/put_projects_issues_spec.rb b/spec/requests/api/issues/put_projects_issues_spec.rb
index f0d174c9e78..217788c519f 100644
--- a/spec/requests/api/issues/put_projects_issues_spec.rb
+++ b/spec/requests/api/issues/put_projects_issues_spec.rb
@@ -80,7 +80,12 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
describe 'PUT /projects/:id/issues/:issue_iid to update only title' do
- it 'updates a project issue' do
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:path) { "/projects/#{project.id}/issues/#{confidential_issue.iid}" }
+ let(:params) { { title: updated_title } }
+ end
+
+ it 'updates a project issue', :aggregate_failures do
put api_for_user, params: { title: updated_title }
expect(response).to have_gitlab_http_status(:ok)
@@ -88,7 +93,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
it 'returns 404 error if issue iid not found' do
- put api("/projects/#{project.id}/issues/44444", user), params: { title: updated_title }
+ put api("/projects/#{project.id}/issues/#{non_existing_record_id}", user), params: { title: updated_title }
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -109,7 +114,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(response).to have_gitlab_http_status(:ok)
end
- it 'allows special label names with labels param as array' do
+ it 'allows special label names with labels param as array', :aggregate_failures do
put api_for_user,
params: {
title: updated_title,
@@ -135,42 +140,42 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(response).to have_gitlab_http_status(:forbidden)
end
- it 'updates a confidential issue for project members' do
+ it 'updates a confidential issue for project members', :aggregate_failures do
put api(confidential_issue_path, user), params: { title: updated_title }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(updated_title)
end
- it 'updates a confidential issue for author' do
+ it 'updates a confidential issue for author', :aggregate_failures do
put api(confidential_issue_path, author), params: { title: updated_title }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(updated_title)
end
- it 'updates a confidential issue for admin' do
- put api(confidential_issue_path, admin), params: { title: updated_title }
+ it 'updates a confidential issue for admin', :aggregate_failures do
+ put api(confidential_issue_path, admin, admin_mode: true), params: { title: updated_title }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(updated_title)
end
- it 'sets an issue to confidential' do
+ it 'sets an issue to confidential', :aggregate_failures do
put api_for_user, params: { confidential: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['confidential']).to be_truthy
end
- it 'makes a confidential issue public' do
+ it 'makes a confidential issue public', :aggregate_failures do
put api(confidential_issue_path, user), params: { confidential: false }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['confidential']).to be_falsy
end
- it 'does not update a confidential issue with wrong confidential flag' do
+ it 'does not update a confidential issue with wrong confidential flag', :aggregate_failures do
put api(confidential_issue_path, user), params: { confidential: 'foo' }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -209,7 +214,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect { update_issue }.not_to change { issue.reload.title }
end
- it 'returns correct status and message' do
+ it 'returns correct status and message', :aggregate_failures do
update_issue
expect(response).to have_gitlab_http_status(:bad_request)
@@ -246,14 +251,14 @@ RSpec.describe API::Issues, feature_category: :team_planning do
describe 'PUT /projects/:id/issues/:issue_iid to update assignee' do
context 'support for deprecated assignee_id' do
- it 'removes assignee' do
+ it 'removes assignee', :aggregate_failures do
put api_for_user, params: { assignee_id: 0 }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['assignee']).to be_nil
end
- it 'updates an issue with new assignee' do
+ it 'updates an issue with new assignee', :aggregate_failures do
put api_for_user, params: { assignee_id: user2.id }
expect(response).to have_gitlab_http_status(:ok)
@@ -261,21 +266,21 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
end
- it 'removes assignee' do
+ it 'removes assignee', :aggregate_failures do
put api_for_user, params: { assignee_ids: [0] }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['assignees']).to be_empty
end
- it 'updates an issue with new assignee' do
+ it 'updates an issue with new assignee', :aggregate_failures do
put api_for_user, params: { assignee_ids: [user2.id] }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['assignees'].first['name']).to eq(user2.name)
end
- context 'single assignee restrictions' do
+ context 'single assignee restrictions', :aggregate_failures do
it 'updates an issue with several assignees but only one has been applied' do
put api_for_user, params: { assignee_ids: [user2.id, guest.id] }
@@ -289,7 +294,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
let!(:label) { create(:label, title: 'dummy', project: project) }
let!(:label_link) { create(:label_link, label: label, target: issue) }
- it 'adds relevant labels' do
+ it 'adds relevant labels', :aggregate_failures do
put api_for_user, params: { add_labels: '1, 2' }
expect(response).to have_gitlab_http_status(:ok)
@@ -300,14 +305,14 @@ RSpec.describe API::Issues, feature_category: :team_planning do
let!(:label2) { create(:label, title: 'a-label', project: project) }
let!(:label_link2) { create(:label_link, label: label2, target: issue) }
- it 'removes relevant labels' do
+ it 'removes relevant labels', :aggregate_failures do
put api_for_user, params: { remove_labels: label2.title }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to eq([label.title])
end
- it 'removes all labels' do
+ it 'removes all labels', :aggregate_failures do
put api_for_user, params: { remove_labels: "#{label.title}, #{label2.title}" }
expect(response).to have_gitlab_http_status(:ok)
@@ -315,14 +320,14 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
end
- it 'does not update labels if not present' do
+ it 'does not update labels if not present', :aggregate_failures do
put api_for_user, params: { title: updated_title }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to eq([label.title])
end
- it 'removes all labels and touches the record' do
+ it 'removes all labels and touches the record', :aggregate_failures do
travel_to(2.minutes.from_now) do
put api_for_user, params: { labels: '' }
end
@@ -332,7 +337,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(json_response['updated_at']).to be > Time.current
end
- it 'removes all labels and touches the record with labels param as array' do
+ it 'removes all labels and touches the record with labels param as array', :aggregate_failures do
travel_to(2.minutes.from_now) do
put api_for_user, params: { labels: [''] }
end
@@ -342,7 +347,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(json_response['updated_at']).to be > Time.current
end
- it 'updates labels and touches the record' do
+ it 'updates labels and touches the record', :aggregate_failures do
travel_to(2.minutes.from_now) do
put api_for_user, params: { labels: 'foo,bar' }
end
@@ -352,7 +357,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(json_response['updated_at']).to be > Time.current
end
- it 'updates labels and touches the record with labels param as array' do
+ it 'updates labels and touches the record with labels param as array', :aggregate_failures do
travel_to(2.minutes.from_now) do
put api_for_user, params: { labels: %w(foo bar) }
end
@@ -363,21 +368,21 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(json_response['updated_at']).to be > Time.current
end
- it 'allows special label names' do
+ it 'allows special label names', :aggregate_failures do
put api_for_user, params: { labels: 'label:foo, label-bar,label_bar,label/bar,label?bar,label&bar,?,&' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to contain_exactly('label:foo', 'label-bar', 'label_bar', 'label/bar', 'label?bar', 'label&bar', '?', '&')
end
- it 'allows special label names with labels param as array' do
+ it 'allows special label names with labels param as array', :aggregate_failures do
put api_for_user, params: { labels: ['label:foo', 'label-bar', 'label_bar', 'label/bar,label?bar,label&bar,?,&'] }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to contain_exactly('label:foo', 'label-bar', 'label_bar', 'label/bar', 'label?bar', 'label&bar', '?', '&')
end
- it 'returns 400 if title is too long' do
+ it 'returns 400 if title is too long', :aggregate_failures do
put api_for_user, params: { title: 'g' * 256 }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -386,7 +391,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
describe 'PUT /projects/:id/issues/:issue_iid to update state and label' do
- it 'updates a project issue' do
+ it 'updates a project issue', :aggregate_failures do
put api_for_user, params: { labels: 'label2', state_event: 'close' }
expect(response).to have_gitlab_http_status(:ok)
@@ -394,7 +399,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(json_response['state']).to eq 'closed'
end
- it 'reopens a project isssue' do
+ it 'reopens a project isssue', :aggregate_failures do
put api(issue_path, user), params: { state_event: 'reopen' }
expect(response).to have_gitlab_http_status(:ok)
@@ -404,7 +409,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
describe 'PUT /projects/:id/issues/:issue_iid to update updated_at param' do
context 'when reporter makes request' do
- it 'accepts the update date to be set' do
+ it 'accepts the update date to be set', :aggregate_failures do
update_time = 2.weeks.ago
put api_for_user, params: { title: 'some new title', updated_at: update_time }
@@ -436,7 +441,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
expect(response).to have_gitlab_http_status(:bad_request)
end
- it 'accepts the update date to be set' do
+ it 'accepts the update date to be set', :aggregate_failures do
update_time = 2.weeks.ago
put api_for_owner, params: { title: 'some new title', updated_at: update_time }
@@ -448,7 +453,7 @@ RSpec.describe API::Issues, feature_category: :team_planning do
end
describe 'PUT /projects/:id/issues/:issue_iid to update due date' do
- it 'creates a new project issue' do
+ it 'creates a new project issue', :aggregate_failures do
due_date = 2.weeks.from_now.strftime('%Y-%m-%d')
put api_for_user, params: { due_date: due_date }
diff --git a/spec/requests/api/keys_spec.rb b/spec/requests/api/keys_spec.rb
index d9a0f061156..3f600d24891 100644
--- a/spec/requests/api/keys_spec.rb
+++ b/spec/requests/api/keys_spec.rb
@@ -2,31 +2,35 @@
require 'spec_helper'
-RSpec.describe API::Keys, feature_category: :authentication_and_authorization do
+RSpec.describe API::Keys, :aggregate_failures, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create(:admin) }
let_it_be(:email) { create(:email, user: user) }
let_it_be(:key) { create(:rsa_key_4096, user: user, expires_at: 1.day.from_now) }
let_it_be(:fingerprint_md5) { 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7' }
+ let_it_be(:path) { "/keys/#{key.id}" }
describe 'GET /keys/:uid' do
+ it_behaves_like 'GET request permissions for admin mode'
+
context 'when unauthenticated' do
it 'returns authentication error' do
- get api("/keys/#{key.id}")
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when authenticated' do
it 'returns 404 for non-existing key' do
- get api('/keys/0', admin)
+ get api('/keys/0', admin, admin_mode: true)
+
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Not found')
end
it 'returns single ssh key with user information' do
- get api("/keys/#{key.id}", admin)
- expect(response).to have_gitlab_http_status(:ok)
+ get api(path, admin, admin_mode: true)
+
expect(json_response['title']).to eq(key.title)
expect(Time.parse(json_response['expires_at'])).to be_like_time(key.expires_at)
expect(json_response['user']['id']).to eq(user.id)
@@ -34,7 +38,7 @@ RSpec.describe API::Keys, feature_category: :authentication_and_authorization do
end
it "does not include the user's `is_admin` flag" do
- get api("/keys/#{key.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(json_response['user']['is_admin']).to be_nil
end
@@ -42,31 +46,28 @@ RSpec.describe API::Keys, feature_category: :authentication_and_authorization do
end
describe 'GET /keys?fingerprint=' do
- it 'returns authentication error' do
- get api("/keys?fingerprint=#{fingerprint_md5}")
+ let_it_be(:path) { "/keys?fingerprint=#{fingerprint_md5}" }
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
+ it_behaves_like 'GET request permissions for admin mode'
- it 'returns authentication error when authenticated as user' do
- get api("/keys?fingerprint=#{fingerprint_md5}", user)
+ it 'returns authentication error' do
+ get api(path, admin_mode: true)
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:unauthorized)
end
context 'when authenticated as admin' do
context 'MD5 fingerprint' do
it 'returns 404 for non-existing SSH md5 fingerprint' do
- get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin)
+ get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Key Not Found')
end
it 'returns user if SSH md5 fingerprint found' do
- get api("/keys?fingerprint=#{fingerprint_md5}", admin)
+ get api(path, admin, admin_mode: true)
- expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(key.title)
expect(json_response['user']['id']).to eq(user.id)
expect(json_response['user']['username']).to eq(user.username)
@@ -74,14 +75,14 @@ RSpec.describe API::Keys, feature_category: :authentication_and_authorization do
context 'with FIPS mode', :fips_mode do
it 'returns 404 for non-existing SSH md5 fingerprint' do
- get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin)
+ get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq('Failed to return the key')
end
it 'returns 404 for existing SSH md5 fingerprint' do
- get api("/keys?fingerprint=#{fingerprint_md5}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq('Failed to return the key')
@@ -90,14 +91,14 @@ RSpec.describe API::Keys, feature_category: :authentication_and_authorization do
end
it 'returns 404 for non-existing SSH sha256 fingerprint' do
- get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo1lCg")}", admin)
+ get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo1lCg")}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Key Not Found')
end
it 'returns user if SSH sha256 fingerprint found' do
- get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:" + key.fingerprint_sha256)}", admin)
+ get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:" + key.fingerprint_sha256)}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(key.title)
@@ -106,7 +107,7 @@ RSpec.describe API::Keys, feature_category: :authentication_and_authorization do
end
it 'returns user if SSH sha256 fingerprint found' do
- get api("/keys?fingerprint=#{URI.encode_www_form_component("sha256:" + key.fingerprint_sha256)}", admin)
+ get api("/keys?fingerprint=#{URI.encode_www_form_component("sha256:" + key.fingerprint_sha256)}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(key.title)
@@ -115,7 +116,7 @@ RSpec.describe API::Keys, feature_category: :authentication_and_authorization do
end
it "does not include the user's `is_admin` flag" do
- get api("/keys?fingerprint=#{URI.encode_www_form_component("sha256:" + key.fingerprint_sha256)}", admin)
+ get api("/keys?fingerprint=#{URI.encode_www_form_component("sha256:" + key.fingerprint_sha256)}", admin, admin_mode: true)
expect(json_response['user']['is_admin']).to be_nil
end
@@ -136,7 +137,7 @@ RSpec.describe API::Keys, feature_category: :authentication_and_authorization do
it 'returns user and projects if SSH sha256 fingerprint for DeployKey found' do
user.keys << deploy_key
- get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:" + deploy_key.fingerprint_sha256)}", admin)
+ get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:" + deploy_key.fingerprint_sha256)}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(deploy_key.title)
diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb
index 82b87007a9b..05a9d98a9d0 100644
--- a/spec/requests/api/lint_spec.rb
+++ b/spec/requests/api/lint_spec.rb
@@ -2,224 +2,14 @@
require 'spec_helper'
-RSpec.describe API::Lint, feature_category: :pipeline_authoring do
+RSpec.describe API::Lint, feature_category: :pipeline_composition do
describe 'POST /ci/lint' do
- context 'when signup settings are disabled' do
- before do
- Gitlab::CurrentSettings.signup_enabled = false
- end
-
- context 'when unauthenticated' do
- it 'returns authentication error' do
- post api('/ci/lint'), params: { content: 'content' }
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
-
- context 'when authenticated' do
- let_it_be(:api_user) { create(:user) }
-
- it 'returns authorized' do
- post api('/ci/lint', api_user), params: { content: 'content' }
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
-
- context 'when authenticated as external user' do
- let(:project) { create(:project) }
- let(:api_user) { create(:user, :external) }
-
- context 'when reporter in a project' do
- before do
- project.add_reporter(api_user)
- end
-
- it 'returns authorization failure' do
- post api('/ci/lint', api_user), params: { content: 'content' }
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
-
- context 'when developer in a project' do
- before do
- project.add_developer(api_user)
- end
-
- it 'returns authorization success' do
- post api('/ci/lint', api_user), params: { content: 'content' }
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
- end
+ it 'responds with a 410' do
+ user = create(:user)
- context 'when signup is enabled and not limited' do
- before do
- Gitlab::CurrentSettings.signup_enabled = true
- stub_application_setting(domain_allowlist: [], email_restrictions_enabled: false, require_admin_approval_after_user_signup: false)
- end
-
- context 'when unauthenticated' do
- it 'returns authorized success' do
- post api('/ci/lint'), params: { content: 'content' }
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
-
- context 'when authenticated' do
- let_it_be(:api_user) { create(:user) }
-
- it 'returns authentication success' do
- post api('/ci/lint', api_user), params: { content: 'content' }
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
-
- context 'when limited signup is enabled' do
- before do
- stub_application_setting(domain_allowlist: ['www.gitlab.com'])
- Gitlab::CurrentSettings.signup_enabled = true
- end
-
- context 'when unauthenticated' do
- it 'returns unauthorized' do
- post api('/ci/lint'), params: { content: 'content' }
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
-
- context 'when authenticated' do
- let_it_be(:api_user) { create(:user) }
-
- it 'returns authentication success' do
- post api('/ci/lint', api_user), params: { content: 'content' }
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
-
- context 'when authenticated' do
- let_it_be(:api_user) { create(:user) }
+ post api('/ci/lint', user), params: { content: "test_job:\n script: ls" }
- context 'with valid .gitlab-ci.yml content' do
- let(:yaml_content) do
- File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
- end
-
- it 'passes validation without warnings or errors' do
- post api('/ci/lint', api_user), params: { content: yaml_content }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Hash
- expect(json_response['status']).to eq('valid')
- expect(json_response['warnings']).to match_array([])
- expect(json_response['errors']).to match_array([])
- expect(json_response['includes']).to eq([])
- end
-
- it 'outputs expanded yaml content' do
- post api('/ci/lint', api_user), params: { content: yaml_content, include_merged_yaml: true }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to have_key('merged_yaml')
- end
-
- it 'outputs jobs' do
- post api('/ci/lint', api_user), params: { content: yaml_content, include_jobs: true }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to have_key('jobs')
- end
- end
-
- context 'with valid .gitlab-ci.yml with warnings' do
- let(:yaml_content) { { job: { script: 'ls', rules: [{ when: 'always' }] } }.to_yaml }
-
- it 'passes validation but returns warnings' do
- post api('/ci/lint', api_user), params: { content: yaml_content }
-
- 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['errors']).to match_array([])
- end
- end
-
- context 'with an invalid .gitlab-ci.yml' do
- context 'with invalid syntax' do
- let(:yaml_content) { 'invalid content' }
-
- it 'responds with errors about invalid syntax' do
- post api('/ci/lint', api_user), params: { content: yaml_content }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['status']).to eq('invalid')
- expect(json_response['warnings']).to eq([])
- expect(json_response['errors']).to eq(['Invalid configuration format'])
- expect(json_response['includes']).to eq(nil)
- end
-
- it 'outputs expanded yaml content' do
- post api('/ci/lint', api_user), params: { content: yaml_content, include_merged_yaml: true }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to have_key('merged_yaml')
- end
-
- it 'outputs jobs' do
- post api('/ci/lint', api_user), params: { content: yaml_content, include_jobs: true }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to have_key('jobs')
- end
- end
-
- context 'with invalid configuration' do
- let(:yaml_content) { '{ image: "image:1.0", services: ["postgres"] }' }
-
- it 'responds with errors about invalid configuration' do
- post api('/ci/lint', api_user), params: { content: yaml_content }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['status']).to eq('invalid')
- expect(json_response['warnings']).to eq([])
- expect(json_response['errors']).to eq(['jobs config should contain at least one visible job'])
- expect(json_response['includes']).to eq([])
- end
-
- it 'outputs expanded yaml content' do
- post api('/ci/lint', api_user), params: { content: yaml_content, include_merged_yaml: true }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to have_key('merged_yaml')
- end
-
- it 'outputs jobs' do
- post api('/ci/lint', api_user), params: { content: yaml_content, include_jobs: true }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to have_key('jobs')
- end
- end
- end
-
- context 'without the content parameter' do
- it 'responds with validation error about missing content' do
- post api('/ci/lint', api_user)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq('content is missing')
- end
- end
+ expect(response).to have_gitlab_http_status(:gone)
end
end
@@ -245,8 +35,8 @@ RSpec.describe API::Lint, feature_category: :pipeline_authoring do
it 'passes validation' do
ci_lint
- included_config = YAML.safe_load(included_content, [Symbol])
- root_config = YAML.safe_load(yaml_content, [Symbol])
+ included_config = YAML.safe_load(included_content, permitted_classes: [Symbol])
+ root_config = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
expected_yaml = included_config.merge(root_config).except(:include).deep_stringify_keys.to_yaml
expect(response).to have_gitlab_http_status(:ok)
@@ -535,8 +325,8 @@ RSpec.describe API::Lint, feature_category: :pipeline_authoring do
it 'passes validation' do
ci_lint
- included_config = YAML.safe_load(included_content, [Symbol])
- root_config = YAML.safe_load(yaml_content, [Symbol])
+ included_config = YAML.safe_load(included_content, permitted_classes: [Symbol])
+ root_config = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
expected_yaml = included_config.merge(root_config).except(:include).deep_stringify_keys.to_yaml
expect(response).to have_gitlab_http_status(:ok)
diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb
index 20aa660d95b..60e91973b5d 100644
--- a/spec/requests/api/maven_packages_spec.rb
+++ b/spec/requests/api/maven_packages_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe API::MavenPackages, feature_category: :package_registry do
using RSpec::Parameterized::TableSyntax
include WorkhorseHelpers
+ include HttpBasicAuthHelpers
include_context 'workhorse headers'
@@ -22,7 +23,7 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
let_it_be(:deploy_token_for_group) { create(:deploy_token, :group, read_package_registry: true, write_package_registry: true) }
let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: deploy_token_for_group, group: group) }
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_maven_user' } }
+ let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user, property: 'i_package_maven_user' } }
let(:package_name) { 'com/example/my-app' }
let(:headers) { workhorse_headers }
@@ -159,56 +160,149 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
end
end
- shared_examples 'downloads with a deploy token' do
- context 'successful download' do
+ shared_examples 'allowing the download' do
+ it 'allows download' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
+ end
+
+ shared_examples 'not allowing the download with' do |not_found_response|
+ it 'does not allow the download' do
+ subject
+
+ expect(response).to have_gitlab_http_status(not_found_response)
+ end
+ end
+
+ shared_examples 'downloads with a personal access token' do |not_found_response|
+ where(:valid, :sent_using) do
+ true | :custom_header
+ false | :custom_header
+ true | :basic_auth
+ false | :basic_auth
+ end
+
+ with_them do
+ let(:token) { valid ? personal_access_token.token : 'not_valid' }
+ let(:headers) do
+ case sent_using
+ when :custom_header
+ { 'Private-Token' => token }
+ when :basic_auth
+ basic_auth_header(user.username, token)
+ end
+ end
+
subject do
download_file(
file_name: package_file.file_name,
- request_headers: { Gitlab::Auth::AuthFinders::DEPLOY_TOKEN_HEADER => deploy_token.token }
+ request_headers: headers
)
end
- it 'allows download with deploy token' do
- subject
+ if params[:valid]
+ it_behaves_like 'allowing the download'
+ else
+ expected_status_code = not_found_response
+ # invalid PAT values sent through headers are validated.
+ # Invalid values will trigger an :unauthorized response (and not set current_user to nil)
+ expected_status_code = :unauthorized if params[:sent_using] == :custom_header && !params[:valid]
+ it_behaves_like 'not allowing the download with', expected_status_code
+ end
+ end
+ end
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
+ shared_examples 'downloads with a deploy token' do |not_found_response|
+ where(:valid, :sent_using) do
+ true | :custom_header
+ false | :custom_header
+ true | :basic_auth
+ false | :basic_auth
+ end
+
+ with_them do
+ let(:token) { valid ? deploy_token.token : 'not_valid' }
+ let(:headers) do
+ case sent_using
+ when :custom_header
+ { Gitlab::Auth::AuthFinders::DEPLOY_TOKEN_HEADER => token }
+ when :basic_auth
+ basic_auth_header(deploy_token.username, token)
+ end
end
- it 'allows download with deploy token with only write_package_registry scope' do
- deploy_token.update!(read_package_registry: false)
+ subject do
+ download_file(
+ file_name: package_file.file_name,
+ request_headers: headers
+ )
+ end
- subject
+ if params[:valid]
+ it_behaves_like 'allowing the download'
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
+ context 'with only write_package_registry scope' do
+ it_behaves_like 'allowing the download' do
+ before do
+ deploy_token.update!(read_package_registry: false)
+ end
+ end
+ end
+ else
+ it_behaves_like 'not allowing the download with', not_found_response
end
end
end
shared_examples 'downloads with a job token' do
- context 'with a running job' do
- it 'allows download with job token' do
- download_file(file_name: package_file.file_name, params: { job_token: job.token })
+ where(:valid, :sent_using) do
+ true | :custom_params
+ false | :custom_params
+ true | :basic_auth
+ false | :basic_auth
+ end
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
+ with_them do
+ let(:token) { valid ? job.token : 'not_valid' }
+ let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, token) }
+ let(:params) { { job_token: token } }
+
+ subject do
+ case sent_using
+ when :custom_params
+ download_file(file_name: package_file.file_name, params: params)
+ when :basic_auth
+ download_file(file_name: package_file.file_name, request_headers: headers)
+ end
end
- end
- context 'with a finished job' do
- before do
- job.update!(status: :failed)
+ context 'with a running job' do
+ if params[:valid]
+ it_behaves_like 'allowing the download'
+ else
+ it_behaves_like 'not allowing the download with', :unauthorized
+ end
end
- it 'returns unauthorized error' do
- download_file(file_name: package_file.file_name, params: { job_token: job.token })
+ context 'with a finished job' do
+ before do
+ job.update!(status: :failed)
+ end
- expect(response).to have_gitlab_http_status(:unauthorized)
+ it_behaves_like 'not allowing the download with', :unauthorized
end
end
end
+ shared_examples 'downloads with different tokens' do |not_found_response|
+ it_behaves_like 'downloads with a personal access token', not_found_response
+ it_behaves_like 'downloads with a deploy token', not_found_response
+ it_behaves_like 'downloads with a job token'
+ end
+
shared_examples 'successfully returning the file' do
it 'returns the file', :aggregate_failures do
subject
@@ -285,6 +379,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
describe 'GET /api/v4/packages/maven/*path/:file_name' do
context 'a public project' do
+ let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_maven_user' } }
+
subject { download_file(file_name: package_file.file_name) }
shared_examples 'getting a file' do
@@ -336,11 +432,10 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
it 'denies download when no private token' do
download_file(file_name: package_file.file_name)
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:unauthorized)
end
- it_behaves_like 'downloads with a job token'
- it_behaves_like 'downloads with a deploy token'
+ it_behaves_like 'downloads with different tokens', :unauthorized
context 'with a non existing maven path' do
subject { download_file_with_token(file_name: package_file.file_name, path: 'foo/bar/1.2.3') }
@@ -377,11 +472,10 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
it 'denies download when no private token' do
download_file(file_name: package_file.file_name)
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:unauthorized)
end
- it_behaves_like 'downloads with a job token'
- it_behaves_like 'downloads with a deploy token'
+ it_behaves_like 'downloads with different tokens', :unauthorized
it 'does not allow download by a unauthorized deploy token with same id as a user with access' do
unauthorized_deploy_token = create(:deploy_token, read_package_registry: true, write_package_registry: true)
@@ -411,12 +505,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
end
context 'project name is different from a package name' do
- before do
- maven_metadatum.update!(path: "wrong_name/#{package.version}")
- end
-
it 'rejects request' do
- download_file(file_name: package_file.file_name)
+ download_file(file_name: package_file.file_name, path: "wrong_name/#{package.version}")
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -451,6 +541,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
it_behaves_like 'forwarding package requests'
context 'a public project' do
+ let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_maven_user' } }
+
subject { download_file(file_name: package_file.file_name) }
shared_examples 'getting a file for a group' do
@@ -496,8 +588,7 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
expect(response).to have_gitlab_http_status(not_found_response)
end
- it_behaves_like 'downloads with a job token'
- it_behaves_like 'downloads with a deploy token'
+ it_behaves_like 'downloads with different tokens', not_found_response
context 'with a non existing maven path' do
subject { download_file_with_token(file_name: package_file.file_name, path: 'foo/bar/1.2.3') }
@@ -506,7 +597,7 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
end
end
- it_behaves_like 'handling groups and subgroups for', 'getting a file for a group', visibilities: { internal: :not_found, public: :redirect }
+ it_behaves_like 'handling groups and subgroups for', 'getting a file for a group', visibilities: { internal: :unauthorized, public: :redirect }
end
context 'private project' do
@@ -535,8 +626,7 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
expect(response).to have_gitlab_http_status(not_found_response)
end
- it_behaves_like 'downloads with a job token'
- it_behaves_like 'downloads with a deploy token'
+ it_behaves_like 'downloads with different tokens', not_found_response
context 'with a non existing maven path' do
subject { download_file_with_token(file_name: package_file.file_name, path: 'foo/bar/1.2.3') }
@@ -566,7 +656,7 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
end
end
- it_behaves_like 'handling groups and subgroups for', 'getting a file for a group', visibilities: { private: :not_found, internal: :not_found, public: :redirect }
+ it_behaves_like 'handling groups and subgroups for', 'getting a file for a group', visibilities: { private: :unauthorized, internal: :unauthorized, public: :redirect }
context 'with a reporter from a subgroup accessing the root group' do
let_it_be(:root_group) { create(:group, :private) }
@@ -660,6 +750,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
describe 'GET /api/v4/projects/:id/packages/maven/*path/:file_name' do
context 'a public project' do
+ let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_maven_user' } }
+
subject { download_file(file_name: package_file.file_name) }
it_behaves_like 'tracking the file download event'
@@ -718,7 +810,7 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
it 'denies download when no private token' do
download_file(file_name: package_file.file_name)
- expect(response).to have_gitlab_http_status(:not_found)
+ expect(response).to have_gitlab_http_status(:unauthorized)
end
context 'with access to package registry for everyone' do
@@ -731,8 +823,7 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
it_behaves_like 'successfully returning the file'
end
- it_behaves_like 'downloads with a job token'
- it_behaves_like 'downloads with a deploy token'
+ it_behaves_like 'downloads with different tokens', :unauthorized
context 'with a non existing maven path' do
subject { download_file_with_token(file_name: package_file.file_name, path: 'foo/bar/1.2.3') }
@@ -901,8 +992,6 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
it_behaves_like 'package workhorse uploads'
context 'event tracking' do
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user, property: 'i_package_maven_user' } }
-
it_behaves_like 'a package tracking event', described_class.name, 'push_package'
context 'when the package file fails to be created' do
@@ -917,6 +1006,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
end
it 'creates package and stores package file' do
+ expect_use_primary
+
expect { upload_file_with_token(params: params) }.to change { project.packages.count }.by(1)
.and change { Packages::Maven::Metadatum.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
@@ -962,6 +1053,17 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
expect(response).to have_gitlab_http_status(:forbidden)
end
+ context 'file name is too long' do
+ let(:file_name) { 'a' * (Packages::Maven::FindOrCreatePackageService::MAX_FILE_NAME_LENGTH + 1) }
+
+ it 'rejects request' do
+ expect { upload_file_with_token(params: params, file_name: file_name) }.not_to change { project.packages.count }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to include('File name is too long')
+ end
+ end
+
context 'version is not correct' do
let(:version) { '$%123' }
@@ -981,9 +1083,9 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
package_settings.update!(maven_duplicates_allowed: false)
end
- shared_examples 'storing the package file' do
+ shared_examples 'storing the package file' do |file_name: 'my-app-1.0-20180724.124855-1'|
it 'stores the file', :aggregate_failures do
- expect { upload_file_with_token(params: params) }.to change { package.package_files.count }.by(1)
+ expect { upload_file_with_token(params: params, file_name: file_name) }.to change { package.package_files.count }.by(1)
expect(response).to have_gitlab_http_status(:ok)
expect(jar_file.file_name).to eq(file_upload.original_filename)
@@ -1023,6 +1125,10 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
it_behaves_like 'storing the package file'
end
+
+ context 'when uploading a similar package file name with a classifier' do
+ it_behaves_like 'storing the package file', file_name: 'my-app-1.0-20180724.124855-1-javadoc'
+ end
end
context 'for sha1 file' do
@@ -1043,6 +1149,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
end
it 'returns no content' do
+ expect_use_primary
+
upload
expect(response).to have_gitlab_http_status(:no_content)
@@ -1072,6 +1180,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
subject { upload_file_with_token(params: params, file_extension: 'jar.md5') }
it 'returns an empty body' do
+ expect_use_primary
+
subject
expect(response.body).to eq('')
@@ -1086,10 +1196,40 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
end
end
end
+
+ context 'reading fingerprints from UploadedFile instance' do
+ let(:file) { Packages::Package.last.package_files.with_format('%.jar').last }
+
+ subject { upload_file_with_token(params: params) }
+
+ before do
+ allow_next_instance_of(UploadedFile) do |uploaded_file|
+ allow(uploaded_file).to receive(:size).and_return(123)
+ allow(uploaded_file).to receive(:sha1).and_return('sha1')
+ allow(uploaded_file).to receive(:md5).and_return('md5')
+ end
+ end
+
+ it 'reads size, sha1 and md5 fingerprints from uploaded_file instance' do
+ subject
+
+ expect(file.size).to eq(123)
+ expect(file.file_sha1).to eq('sha1')
+ expect(file.file_md5).to eq('md5')
+ end
+ end
+
+ def expect_use_primary
+ lb_session = ::Gitlab::Database::LoadBalancing::Session.current
+
+ expect(lb_session).to receive(:use_primary).and_call_original
+
+ allow(::Gitlab::Database::LoadBalancing::Session).to receive(:current).and_return(lb_session)
+ end
end
- def upload_file(params: {}, request_headers: headers, file_extension: 'jar')
- url = "/projects/#{project.id}/packages/maven/#{param_path}/my-app-1.0-20180724.124855-1.#{file_extension}"
+ def upload_file(params: {}, request_headers: headers, file_extension: 'jar', file_name: 'my-app-1.0-20180724.124855-1')
+ url = "/projects/#{project.id}/packages/maven/#{param_path}/#{file_name}.#{file_extension}"
workhorse_finalize(
api(url),
method: :put,
@@ -1100,8 +1240,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do
)
end
- def upload_file_with_token(params: {}, request_headers: headers_with_token, file_extension: 'jar')
- upload_file(params: params, request_headers: request_headers, file_extension: file_extension)
+ def upload_file_with_token(params: {}, request_headers: headers_with_token, file_extension: 'jar', file_name: 'my-app-1.0-20180724.124855-1')
+ upload_file(params: params, request_headers: request_headers, file_name: file_name, file_extension: file_extension)
end
end
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index 4eff5e96e9c..353fddcb08d 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -130,6 +130,8 @@ RSpec.describe API::Members, feature_category: :subgroups do
let(:project_user) { create(:user) }
let(:linked_group_user) { create(:user) }
let!(:project_group_link) { create(:project_group_link, project: project, group: linked_group) }
+ let(:invited_group_developer) { create(:user, username: 'invited_group_developer') }
+ let(:invited_group) { create(:group) { |group| group.add_developer(invited_group_developer) } }
let(:project) do
create(:project, :public, group: nested_group) do |project|
@@ -146,19 +148,21 @@ RSpec.describe API::Members, feature_category: :subgroups do
let(:nested_group) do
create(:group, parent: group) do |nested_group|
nested_group.add_developer(nested_user)
+ create(:group_group_link, :guest, shared_with_group: invited_group, shared_group: nested_group)
end
end
- it 'finds all project members including inherited members' do
+ it 'finds all project members including inherited members and members shared into ancestor groups' do
get api("/projects/#{project.id}/members/all", developer)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
- expect(json_response.map { |u| u['id'] }).to match_array [maintainer.id, developer.id, nested_user.id, project_user.id, linked_group_user.id]
+ expected_user_ids = [maintainer.id, developer.id, nested_user.id, project_user.id, linked_group_user.id, invited_group_developer.id]
+ expect(json_response.map { |u| u['id'] }).to match_array expected_user_ids
end
- it 'returns only one member for each user without returning duplicated members' do
+ it 'returns only one member for each user without returning duplicated members with correct access levels' do
linked_group.add_developer(developer)
get api("/projects/#{project.id}/members/all", developer)
@@ -172,7 +176,8 @@ RSpec.describe API::Members, feature_category: :subgroups do
[maintainer.id, Gitlab::Access::OWNER],
[nested_user.id, Gitlab::Access::DEVELOPER],
[project_user.id, Gitlab::Access::DEVELOPER],
- [linked_group_user.id, Gitlab::Access::DEVELOPER]
+ [linked_group_user.id, Gitlab::Access::DEVELOPER],
+ [invited_group_developer.id, Gitlab::Access::GUEST]
]
expect(json_response.map { |u| [u['id'], u['access_level']] }).to match_array(expected_users_and_access_levels)
end
@@ -183,7 +188,8 @@ RSpec.describe API::Members, feature_category: :subgroups do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
- expect(json_response.map { |u| u['id'] }).to match_array [maintainer.id, developer.id, nested_user.id]
+ expected_user_ids = [maintainer.id, developer.id, nested_user.id, invited_group_developer.id]
+ expect(json_response.map { |u| u['id'] }).to match_array expected_user_ids
end
context 'with a subgroup' do
@@ -739,6 +745,30 @@ RSpec.describe API::Members, feature_category: :subgroups do
end.to change { source.members.count }.by(-1)
end
+ it_behaves_like 'rate limited endpoint', rate_limit_key: :member_delete do
+ let(:current_user) { maintainer }
+
+ let(:another_member) { create(:user) }
+
+ before do
+ source.add_developer(another_member)
+ end
+
+ # We rate limit scoped by the group / project
+ let(:delete_paths) do
+ [
+ api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", maintainer),
+ api("/#{source_type.pluralize}/#{source.id}/members/#{another_member.id}", maintainer)
+ ]
+ end
+
+ def request
+ delete_member_path = delete_paths.shift
+
+ delete delete_member_path
+ end
+ end
+
it_behaves_like '412 response' do
let(:request) { api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", maintainer) }
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index 19a630e5218..50e70a9dc0f 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe API::MergeRequests, feature_category: :source_code_management do
+RSpec.describe API::MergeRequests, :aggregate_failures, feature_category: :source_code_management do
include ProjectForksHelper
let_it_be(:base_time) { Time.now }
@@ -50,6 +50,27 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
expect_successful_response_with_paginated_array
end
+ context 'when merge request is unchecked' do
+ let(:check_service_class) { MergeRequests::MergeabilityCheckService }
+ let(:mr_entity) { json_response.find { |mr| mr['id'] == merge_request.id } }
+ let(:merge_request) { create(:merge_request, :simple, author: user, source_project: project, title: "Test") }
+
+ before do
+ merge_request.mark_as_unchecked!
+ end
+
+ context 'with merge status recheck projection' do
+ it 'does not enqueue a merge status recheck' do
+ expect(check_service_class).not_to receive(:new)
+
+ get(api(endpoint_path), params: { with_merge_status_recheck: true })
+
+ expect_successful_response_with_paginated_array
+ expect(mr_entity['merge_status']).to eq('unchecked')
+ end
+ end
+ end
+
it_behaves_like 'issuable API rate-limited search' do
let(:url) { endpoint_path }
let(:issuable) { merge_request }
@@ -85,28 +106,67 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
merge_request.mark_as_unchecked!
end
- context 'with merge status recheck projection' do
- it 'checks mergeability asynchronously' do
- expect_next_instances_of(check_service_class, (1..2)) do |service|
- expect(service).not_to receive(:execute)
- expect(service).to receive(:async_execute).and_call_original
+ context 'with a developer+ role' do
+ before do
+ project.add_developer(user2)
+ end
+
+ context 'with merge status recheck projection' do
+ it 'checks mergeability asynchronously' do
+ expect_next_instances_of(check_service_class, (1..2)) do |service|
+ expect(service).not_to receive(:execute)
+ expect(service).to receive(:async_execute).and_call_original
+ end
+
+ get(api(endpoint_path, user2), params: { with_merge_status_recheck: true })
+
+ expect_successful_response_with_paginated_array
+ expect(mr_entity['merge_status']).to eq('checking')
end
+ end
- get(api(endpoint_path, user), params: { with_merge_status_recheck: true })
+ context 'without merge status recheck projection' do
+ it 'does not enqueue a merge status recheck' do
+ expect(check_service_class).not_to receive(:new)
- expect_successful_response_with_paginated_array
- expect(mr_entity['merge_status']).to eq('checking')
+ get api(endpoint_path, user2)
+
+ expect_successful_response_with_paginated_array
+ expect(mr_entity['merge_status']).to eq('unchecked')
+ end
end
end
- context 'without merge status recheck projection' do
- it 'does not enqueue a merge status recheck' do
- expect(check_service_class).not_to receive(:new)
+ context 'with a reporter role' do
+ context 'with merge status recheck projection' do
+ it 'does not enqueue a merge status recheck' do
+ expect(check_service_class).not_to receive(:new)
- get api(endpoint_path, user)
+ get(api(endpoint_path, user2), params: { with_merge_status_recheck: true })
- expect_successful_response_with_paginated_array
- expect(mr_entity['merge_status']).to eq('unchecked')
+ expect_successful_response_with_paginated_array
+ expect(mr_entity['merge_status']).to eq('unchecked')
+ end
+ end
+
+ context 'when restrict_merge_status_recheck FF is disabled' do
+ before do
+ stub_feature_flags(restrict_merge_status_recheck: false)
+ end
+
+ context 'with merge status recheck projection' do
+ it 'does enqueue a merge status recheck' do
+ expect_next_instances_of(check_service_class, (1..2)) do |service|
+ expect(service).not_to receive(:execute)
+ expect(service).to receive(:async_execute).and_call_original
+ end
+
+ get(api(endpoint_path, user2), params: { with_merge_status_recheck: true })
+
+ expect_successful_response_with_paginated_array
+ expect(mr_entity['merge_status']).to eq('checking')
+ end
+ end
end
end
end
@@ -168,6 +228,17 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
end
end
+ context 'when DB timeouts occur' do
+ it 'returns a :request_timeout status' do
+ allow(MergeRequestsFinder).to receive(:new).and_raise(ActiveRecord::QueryCanceled)
+
+ path = endpoint_path + '?view=simple'
+ get api(path, user)
+
+ expect(response).to have_gitlab_http_status(:request_timeout)
+ end
+ end
+
it 'returns an array of all merge_requests using simple mode' do
path = endpoint_path + '?view=simple'
@@ -238,6 +309,35 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
expect(response).to match_response_schema('public_api/v4/merge_requests')
end
+ context 'with approved param' do
+ let(:approved_mr) { create(:merge_request, target_project: project, source_project: project) }
+
+ before do
+ create(:approval, merge_request: approved_mr)
+ end
+
+ it 'returns only approved merge requests' do
+ path = endpoint_path + '?approved=yes'
+
+ get api(path, user)
+
+ expect_paginated_array_response([approved_mr.id])
+ end
+
+ it 'returns only non-approved merge requests' do
+ path = endpoint_path + '?approved=no'
+
+ get api(path, user)
+
+ expect_paginated_array_response([
+ merge_request_merged.id,
+ merge_request_locked.id,
+ merge_request_closed.id,
+ merge_request.id
+ ])
+ end
+ end
+
it 'returns an empty array if no issue matches milestone' do
get api(endpoint_path, user), params: { milestone: '1.0.0' }
@@ -483,7 +583,7 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
create(:label_link, label: label2, target: merge_request2)
end
- it 'returns merge requests without any of the labels given', :aggregate_failures do
+ it 'returns merge requests without any of the labels given' do
get api(endpoint_path, user), params: { not: { labels: ["#{label.title}, #{label2.title}"] } }
expect(response).to have_gitlab_http_status(:ok)
@@ -494,7 +594,7 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
end
end
- it 'returns merge requests without any of the milestones given', :aggregate_failures do
+ it 'returns merge requests without any of the milestones given' do
get api(endpoint_path, user), params: { not: { milestone: milestone.title } }
expect(response).to have_gitlab_http_status(:ok)
@@ -505,7 +605,7 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
end
end
- it 'returns merge requests without the author given', :aggregate_failures do
+ it 'returns merge requests without the author given' do
get api(endpoint_path, user), params: { not: { author_id: user2.id } }
expect(response).to have_gitlab_http_status(:ok)
@@ -516,7 +616,7 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
end
end
- it 'returns merge requests without the assignee given', :aggregate_failures do
+ it 'returns merge requests without the assignee given' do
get api(endpoint_path, user), params: { not: { assignee_id: user2.id } }
expect(response).to have_gitlab_http_status(:ok)
@@ -1326,7 +1426,7 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
expect(json_response['merge_error']).to eq(merge_request.merge_error)
expect(json_response['user']['can_merge']).to be_truthy
expect(json_response).not_to include('rebase_in_progress')
- expect(json_response['first_contribution']).to be false
+ expect(json_response['first_contribution']).to be true
expect(json_response['has_conflicts']).to be false
expect(json_response['blocking_discussions_resolved']).to be_truthy
expect(json_response['references']['short']).to eq("!#{merge_request.iid}")
@@ -3437,8 +3537,13 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
end
describe 'POST :id/merge_requests/:merge_request_iid/subscribe' do
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:path) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe" }
+ let(:params) { {} }
+ end
+
it 'subscribes to a merge request' do
- post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", admin)
+ post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['subscribed']).to eq(true)
@@ -3481,7 +3586,7 @@ RSpec.describe API::MergeRequests, feature_category: :source_code_management do
end
it 'returns 304 if not subscribed' do
- post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", admin)
+ post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_modified)
end
diff --git a/spec/requests/api/metadata_spec.rb b/spec/requests/api/metadata_spec.rb
index b9bdadb01cc..e15186c48a5 100644
--- a/spec/requests/api/metadata_spec.rb
+++ b/spec/requests/api/metadata_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Metadata, feature_category: :not_owned do
+RSpec.describe API::Metadata, feature_category: :shared do
shared_examples_for 'GET /metadata' do
context 'when unauthenticated' do
it 'returns authentication error' do
diff --git a/spec/requests/api/metrics/dashboard/annotations_spec.rb b/spec/requests/api/metrics/dashboard/annotations_spec.rb
index 7932dd29e4d..250fe2a3ee3 100644
--- a/spec/requests/api/metrics/dashboard/annotations_spec.rb
+++ b/spec/requests/api/metrics/dashboard/annotations_spec.rb
@@ -16,6 +16,7 @@ RSpec.describe API::Metrics::Dashboard::Annotations, feature_category: :metrics
let(:url) { "/#{source_type.pluralize}/#{source.id}/metrics_dashboard/annotations" }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
project.add_developer(user)
end
@@ -35,7 +36,7 @@ RSpec.describe API::Metrics::Dashboard::Annotations, feature_category: :metrics
end
context 'with invalid parameters' do
- it 'returns error messsage' do
+ it 'returns error message' do
post api(url, user), params: { dashboard_path: '', starting_at: nil, description: nil }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -104,6 +105,18 @@ RSpec.describe API::Metrics::Dashboard::Annotations, feature_category: :metrics
expect(response).to have_gitlab_http_status(:forbidden)
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 404 not found' do
+ post api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
end
diff --git a/spec/requests/api/metrics/user_starred_dashboards_spec.rb b/spec/requests/api/metrics/user_starred_dashboards_spec.rb
index 38d3c0be8b2..6fc98de0777 100644
--- a/spec/requests/api/metrics/user_starred_dashboards_spec.rb
+++ b/spec/requests/api/metrics/user_starred_dashboards_spec.rb
@@ -15,6 +15,10 @@ RSpec.describe API::Metrics::UserStarredDashboards, feature_category: :metrics d
}
end
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
describe 'POST /projects/:id/metrics/user_starred_dashboards' do
before do
project.add_reporter(user)
@@ -84,6 +88,18 @@ RSpec.describe API::Metrics::UserStarredDashboards, feature_category: :metrics d
expect(response).to have_gitlab_http_status(:not_found)
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 404 not found' do
+ post api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
describe 'DELETE /projects/:id/metrics/user_starred_dashboards' do
@@ -161,5 +177,17 @@ RSpec.describe API::Metrics::UserStarredDashboards, feature_category: :metrics d
expect(response).to have_gitlab_http_status(:not_found)
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 404 not found' do
+ delete api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
end
diff --git a/spec/requests/api/ml/mlflow/experiments_spec.rb b/spec/requests/api/ml/mlflow/experiments_spec.rb
new file mode 100644
index 00000000000..1a2577e69e7
--- /dev/null
+++ b/spec/requests/api/ml/mlflow/experiments_spec.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ml::Mlflow::Experiments, feature_category: :mlops do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+ let_it_be(:another_project) { build(:project).tap { |p| p.add_developer(developer) } }
+ let_it_be(:experiment) do
+ create(:ml_experiments, :with_metadata, project: project)
+ end
+
+ let_it_be(:tokens) do
+ {
+ write: create(:personal_access_token, scopes: %w[read_api api], user: developer),
+ read: create(:personal_access_token, scopes: %w[read_api], user: developer),
+ no_access: create(:personal_access_token, scopes: %w[read_user], user: developer),
+ different_user: create(:personal_access_token, scopes: %w[read_api api], user: build(:user))
+ }
+ end
+
+ let(:current_user) { developer }
+ let(:ff_value) { true }
+ let(:access_token) { tokens[:write] }
+ let(:headers) { { 'Authorization' => "Bearer #{access_token.token}" } }
+ let(:project_id) { project.id }
+ let(:default_params) { {} }
+ let(:params) { default_params }
+ let(:request) { get api(route), params: params, headers: headers }
+ let(:json_response) { Gitlab::Json.parse(api_response.body) }
+ let(:presented_experiment) do
+ {
+ 'experiment_id' => experiment.iid.to_s,
+ 'name' => experiment.name,
+ 'lifecycle_stage' => 'active',
+ 'artifact_location' => 'not_implemented',
+ 'tags' => [
+ {
+ 'key' => experiment.metadata[0].name,
+ 'value' => experiment.metadata[0].value
+ },
+ {
+ 'key' => experiment.metadata[1].name,
+ 'value' => experiment.metadata[1].value
+ }
+ ]
+ }
+ end
+
+ subject(:api_response) do
+ request
+ response
+ end
+
+ before do
+ stub_feature_flags(ml_experiment_tracking: ff_value)
+ end
+
+ describe 'GET /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/get' do
+ let(:experiment_iid) { experiment.iid.to_s }
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/get?experiment_id=#{experiment_iid}" }
+
+ it 'returns the experiment', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ is_expected.to match_response_schema('ml/get_experiment')
+ expect(json_response).to include({ 'experiment' => presented_experiment })
+ end
+
+ describe 'Error States' do
+ context 'when has access' do
+ context 'and experiment does not exist' do
+ let(:experiment_iid) { non_existing_record_iid.to_s }
+
+ it_behaves_like 'MLflow|Not Found - Resource Does Not Exist'
+ end
+
+ context 'and experiment_id is not passed' do
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/get" }
+
+ it_behaves_like 'MLflow|Not Found - Resource Does Not Exist'
+ end
+ end
+
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires read_api scope'
+ end
+ end
+
+ describe 'GET /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/list' do
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/list" }
+
+ it 'returns the experiments', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ is_expected.to match_response_schema('ml/list_experiments')
+ expect(json_response).to include({ 'experiments' => [presented_experiment] })
+ end
+
+ context 'when there are no experiments' do
+ let(:project_id) { another_project.id }
+
+ it 'returns an empty list' do
+ expect(json_response).to include({ 'experiments' => [] })
+ end
+ end
+
+ describe 'Error States' do
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires read_api scope'
+ end
+ end
+
+ describe 'GET /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/get-by-name' do
+ let(:experiment_name) { experiment.name }
+ let(:route) do
+ "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/get-by-name?experiment_name=#{experiment_name}"
+ end
+
+ it 'returns the experiment', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ is_expected.to match_response_schema('ml/get_experiment')
+ expect(json_response).to include({ 'experiment' => presented_experiment })
+ end
+
+ describe 'Error States' do
+ context 'when has access but experiment does not exist' do
+ let(:experiment_name) { "random_experiment" }
+
+ it_behaves_like 'MLflow|Not Found - Resource Does Not Exist'
+ end
+
+ context 'when has access but experiment_name is not passed' do
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/get-by-name" }
+
+ it_behaves_like 'MLflow|Not Found - Resource Does Not Exist'
+ end
+
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires read_api scope'
+ end
+ end
+
+ describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/create' do
+ let(:route) do
+ "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/create"
+ end
+
+ let(:params) { { name: 'new_experiment' } }
+ let(:request) { post api(route), params: params, headers: headers }
+
+ it 'creates the experiment', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ expect(json_response).to include('experiment_id')
+ end
+
+ describe 'Error States' do
+ context 'when experiment name is not passed' do
+ let(:params) { {} }
+
+ it_behaves_like 'MLflow|Bad Request'
+ end
+
+ context 'when experiment name already exists' do
+ let(:existing_experiment) do
+ create(:ml_experiments, user: current_user, project: project)
+ end
+
+ let(:params) { { name: existing_experiment.name } }
+
+ it "is Bad Request", :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:bad_request)
+
+ expect(json_response).to include({ 'error_code' => 'RESOURCE_ALREADY_EXISTS' })
+ end
+ end
+
+ context 'when project does not exist' do
+ let(:route) { "/projects/#{non_existing_record_id}/ml/mlflow/api/2.0/mlflow/experiments/create" }
+
+ it "is Not Found", :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:not_found)
+
+ expect(json_response['message']).to eq('404 Project Not Found')
+ end
+ end
+
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires api scope'
+ end
+ end
+
+ describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/set-experiment-tag' do
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/set-experiment-tag" }
+ let(:default_params) { { experiment_id: experiment.iid.to_s, key: 'some_key', value: 'value' } }
+ let(:params) { default_params }
+ let(:request) { post api(route), params: params, headers: headers }
+
+ it 'logs the tag', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ expect(json_response).to be_empty
+ expect(experiment.reload.metadata.map(&:name)).to include('some_key')
+ end
+
+ describe 'Error Cases' do
+ context 'when tag was already set' do
+ let(:params) { default_params.merge(key: experiment.metadata[0].name) }
+
+ it_behaves_like 'MLflow|Bad Request'
+ end
+
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires api scope'
+ it_behaves_like 'MLflow|Bad Request on missing required', [:key, :value]
+ end
+ end
+end
diff --git a/spec/requests/api/ml/mlflow/runs_spec.rb b/spec/requests/api/ml/mlflow/runs_spec.rb
new file mode 100644
index 00000000000..746372b7978
--- /dev/null
+++ b/spec/requests/api/ml/mlflow/runs_spec.rb
@@ -0,0 +1,354 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ml::Mlflow::Runs, feature_category: :mlops do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+ let_it_be(:another_project) { build(:project).tap { |p| p.add_developer(developer) } }
+ let_it_be(:experiment) do
+ create(:ml_experiments, :with_metadata, project: project)
+ end
+
+ let_it_be(:candidate) do
+ create(:ml_candidates,
+ :with_metrics_and_params, :with_metadata,
+ user: experiment.user, start_time: 1234, experiment: experiment, project: project)
+ end
+
+ let_it_be(:tokens) do
+ {
+ write: create(:personal_access_token, scopes: %w[read_api api], user: developer),
+ read: create(:personal_access_token, scopes: %w[read_api], user: developer),
+ no_access: create(:personal_access_token, scopes: %w[read_user], user: developer),
+ different_user: create(:personal_access_token, scopes: %w[read_api api], user: build(:user))
+ }
+ end
+
+ let(:current_user) { developer }
+ let(:ff_value) { true }
+ let(:access_token) { tokens[:write] }
+ let(:headers) { { 'Authorization' => "Bearer #{access_token.token}" } }
+ let(:project_id) { project.id }
+ let(:default_params) { {} }
+ let(:params) { default_params }
+ let(:request) { get api(route), params: params, headers: headers }
+ let(:json_response) { Gitlab::Json.parse(api_response.body) }
+
+ subject(:api_response) do
+ request
+ response
+ end
+
+ before do
+ stub_feature_flags(ml_experiment_tracking: ff_value)
+ end
+
+ RSpec.shared_examples 'MLflow|run_id param error cases' do
+ context 'when run id is not passed' do
+ let(:params) { {} }
+
+ it "is Bad Request" do
+ is_expected.to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'when run_id is invalid' do
+ let(:params) { default_params.merge(run_id: non_existing_record_iid.to_s) }
+
+ it "is Resource Does Not Exist", :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:not_found)
+
+ expect(json_response).to include({ "error_code" => 'RESOURCE_DOES_NOT_EXIST' })
+ end
+ end
+
+ context 'when run_id is not in in the project' do
+ let(:project_id) { another_project.id }
+
+ it "is Resource Does Not Exist", :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:not_found)
+
+ expect(json_response).to include({ "error_code" => 'RESOURCE_DOES_NOT_EXIST' })
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/create' do
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/create" }
+ let(:params) do
+ {
+ experiment_id: experiment.iid.to_s,
+ start_time: Time.now.to_i,
+ run_name: "A new Run",
+ tags: [
+ { key: 'hello', value: 'world' }
+ ]
+ }
+ end
+
+ let(:request) { post api(route), params: params, headers: headers }
+
+ it 'creates the run', :aggregate_failures do
+ expected_properties = {
+ 'experiment_id' => params[:experiment_id],
+ 'user_id' => current_user.id.to_s,
+ 'run_name' => "A new Run",
+ 'start_time' => params[:start_time],
+ 'status' => 'RUNNING',
+ 'lifecycle_stage' => 'active'
+ }
+
+ is_expected.to have_gitlab_http_status(:ok)
+ is_expected.to match_response_schema('ml/run')
+ expect(json_response['run']).to include('info' => hash_including(**expected_properties),
+ 'data' => {
+ 'metrics' => [],
+ 'params' => [],
+ 'tags' => [{ 'key' => 'hello', 'value' => 'world' }]
+ })
+ end
+
+ describe 'Error States' do
+ context 'when experiment id is not passed' do
+ let(:params) { {} }
+
+ it_behaves_like 'MLflow|Bad Request'
+ end
+
+ context 'when experiment id does not exist' do
+ let(:params) { { experiment_id: non_existing_record_iid.to_s } }
+
+ it_behaves_like 'MLflow|Not Found - Resource Does Not Exist'
+ end
+
+ context 'when experiment exists but is not part of the project' do
+ let(:project_id) { another_project.id }
+
+ it_behaves_like 'MLflow|Not Found - Resource Does Not Exist'
+ end
+
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires api scope'
+ end
+ end
+
+ describe 'GET /projects/:id/ml/mlflow/api/2.0/mlflow/runs/get' do
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/get" }
+ let(:default_params) { { 'run_id' => candidate.eid } }
+
+ it 'gets the run', :aggregate_failures do
+ expected_properties = {
+ 'experiment_id' => candidate.experiment.iid.to_s,
+ 'user_id' => candidate.user.id.to_s,
+ 'start_time' => candidate.start_time,
+ 'artifact_uri' => "http://www.example.com/api/v4/projects/#{project_id}/packages/generic/ml_experiment_#{experiment.iid}/#{candidate.iid}/",
+ 'status' => "RUNNING",
+ 'lifecycle_stage' => "active"
+ }
+
+ is_expected.to have_gitlab_http_status(:ok)
+ is_expected.to match_response_schema('ml/run')
+ expect(json_response['run']).to include(
+ 'info' => hash_including(**expected_properties),
+ 'data' => {
+ 'metrics' => [
+ hash_including('key' => candidate.metrics[0].name),
+ hash_including('key' => candidate.metrics[1].name)
+ ],
+ 'params' => [
+ { 'key' => candidate.params[0].name, 'value' => candidate.params[0].value },
+ { 'key' => candidate.params[1].name, 'value' => candidate.params[1].value }
+ ],
+ 'tags' => [
+ { 'key' => candidate.metadata[0].name, 'value' => candidate.metadata[0].value },
+ { 'key' => candidate.metadata[1].name, 'value' => candidate.metadata[1].value }
+ ]
+ })
+ end
+
+ describe 'Error States' do
+ it_behaves_like 'MLflow|run_id param error cases'
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires read_api scope'
+ end
+ end
+
+ describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/update' do
+ let(:default_params) { { run_id: candidate.eid.to_s, status: 'FAILED', end_time: Time.now.to_i } }
+ let(:request) { post api(route), params: params, headers: headers }
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/update" }
+
+ it 'updates the run', :aggregate_failures do
+ expected_properties = {
+ 'experiment_id' => candidate.experiment.iid.to_s,
+ 'user_id' => candidate.user.id.to_s,
+ 'start_time' => candidate.start_time,
+ 'end_time' => params[:end_time],
+ 'artifact_uri' => "http://www.example.com/api/v4/projects/#{project_id}/packages/generic/ml_experiment_#{experiment.iid}/#{candidate.iid}/",
+ 'status' => 'FAILED',
+ 'lifecycle_stage' => 'active'
+ }
+
+ is_expected.to have_gitlab_http_status(:ok)
+ is_expected.to match_response_schema('ml/update_run')
+ expect(json_response).to include('run_info' => hash_including(**expected_properties))
+ end
+
+ describe 'Error States' do
+ context 'when status in invalid' do
+ let(:params) { default_params.merge(status: 'YOLO') }
+
+ it_behaves_like 'MLflow|Bad Request'
+ end
+
+ context 'when end_time is invalid' do
+ let(:params) { default_params.merge(end_time: 's') }
+
+ it_behaves_like 'MLflow|Bad Request'
+ end
+
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires api scope'
+ it_behaves_like 'MLflow|run_id param error cases'
+ end
+ end
+
+ describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/log-metric' do
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/log-metric" }
+ let(:default_params) { { run_id: candidate.eid.to_s, key: 'some_key', value: 10.0, timestamp: Time.now.to_i } }
+ let(:request) { post api(route), params: params, headers: headers }
+
+ it 'logs the metric', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ expect(json_response).to be_empty
+ expect(candidate.metrics.reload.length).to eq(3)
+ end
+
+ describe 'Error Cases' do
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires api scope'
+ it_behaves_like 'MLflow|run_id param error cases'
+ it_behaves_like 'MLflow|Bad Request on missing required', [:key, :value, :timestamp]
+ end
+ end
+
+ describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/log-parameter' do
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/log-parameter" }
+ let(:default_params) { { run_id: candidate.eid.to_s, key: 'some_key', value: 'value' } }
+ let(:request) { post api(route), params: params, headers: headers }
+
+ it 'logs the parameter', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ expect(json_response).to be_empty
+ expect(candidate.params.reload.length).to eq(3)
+ end
+
+ describe 'Error Cases' do
+ context 'when parameter was already logged' do
+ let(:params) { default_params.tap { |p| p[:key] = candidate.params[0].name } }
+
+ it_behaves_like 'MLflow|Bad Request'
+ end
+
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires api scope'
+ it_behaves_like 'MLflow|run_id param error cases'
+ it_behaves_like 'MLflow|Bad Request on missing required', [:key, :value]
+ end
+ end
+
+ describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/set-tag' do
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/set-tag" }
+ let(:default_params) { { run_id: candidate.eid.to_s, key: 'some_key', value: 'value' } }
+ let(:request) { post api(route), params: params, headers: headers }
+
+ it 'logs the tag', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ expect(json_response).to be_empty
+ expect(candidate.reload.metadata.map(&:name)).to include('some_key')
+ end
+
+ describe 'Error Cases' do
+ context 'when tag was already logged' do
+ let(:params) { default_params.tap { |p| p[:key] = candidate.metadata[0].name } }
+
+ it_behaves_like 'MLflow|Bad Request'
+ end
+
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires api scope'
+ it_behaves_like 'MLflow|run_id param error cases'
+ it_behaves_like 'MLflow|Bad Request on missing required', [:key, :value]
+ end
+ end
+
+ describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/log-batch' do
+ let_it_be(:candidate2) do
+ create(:ml_candidates, user: experiment.user, start_time: 1234, experiment: experiment, project: project)
+ end
+
+ let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/log-batch" }
+ let(:default_params) do
+ {
+ run_id: candidate2.eid.to_s,
+ metrics: [
+ { key: 'mae', value: 2.5, timestamp: 1552550804 },
+ { key: 'rmse', value: 2.7, timestamp: 1552550804 }
+ ],
+ params: [{ key: 'model_class', value: 'LogisticRegression' }],
+ tags: [{ key: 'tag1', value: 'tag.value.1' }]
+ }
+ end
+
+ let(:request) { post api(route), params: params, headers: headers }
+
+ it 'logs parameters and metrics', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ expect(json_response).to be_empty
+ expect(candidate2.params.size).to eq(1)
+ expect(candidate2.metadata.size).to eq(1)
+ expect(candidate2.metrics.size).to eq(2)
+ end
+
+ context 'when parameter was already logged' do
+ let(:params) do
+ default_params.tap { |p| p[:params] = [{ key: 'hello', value: 'a' }, { key: 'hello', value: 'b' }] }
+ end
+
+ it 'does not log', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ expect(candidate2.params.reload.size).to eq(1)
+ end
+ end
+
+ context 'when tag was already logged' do
+ let(:params) do
+ default_params.tap { |p| p[:tags] = [{ key: 'tag1', value: 'a' }, { key: 'tag1', value: 'b' }] }
+ end
+
+ it 'logs only 1', :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:ok)
+ expect(candidate2.metadata.reload.size).to eq(1)
+ end
+ end
+
+ describe 'Error Cases' do
+ context 'when required metric key is missing' do
+ let(:params) { default_params.tap { |p| p[:metrics] = [p[:metrics][0].delete(:key)] } }
+
+ it_behaves_like 'MLflow|Bad Request'
+ end
+
+ context 'when required param key is missing' do
+ let(:params) { default_params.tap { |p| p[:params] = [p[:params][0].delete(:key)] } }
+
+ it_behaves_like 'MLflow|Bad Request'
+ end
+
+ it_behaves_like 'MLflow|shared error cases'
+ it_behaves_like 'MLflow|Requires api scope'
+ it_behaves_like 'MLflow|run_id param error cases'
+ end
+ end
+end
diff --git a/spec/requests/api/ml/mlflow_spec.rb b/spec/requests/api/ml/mlflow_spec.rb
deleted file mode 100644
index fdf115f7e92..00000000000
--- a/spec/requests/api/ml/mlflow_spec.rb
+++ /dev/null
@@ -1,630 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require 'mime/types'
-
-RSpec.describe API::Ml::Mlflow, feature_category: :mlops do
- include SessionHelpers
- include ApiHelpers
- include HttpBasicAuthHelpers
-
- let_it_be(:project) { create(:project, :private) }
- let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
- let_it_be(:another_project) { build(:project).tap { |p| p.add_developer(developer) } }
- let_it_be(:experiment) do
- create(:ml_experiments, :with_metadata, project: project)
- end
-
- let_it_be(:candidate) do
- create(:ml_candidates,
- :with_metrics_and_params, :with_metadata,
- user: experiment.user, start_time: 1234, experiment: experiment)
- end
-
- let_it_be(:tokens) do
- {
- write: create(:personal_access_token, scopes: %w[read_api api], user: developer),
- read: create(:personal_access_token, scopes: %w[read_api], user: developer),
- no_access: create(:personal_access_token, scopes: %w[read_user], user: developer),
- different_user: create(:personal_access_token, scopes: %w[read_api api], user: build(:user))
- }
- end
-
- let(:current_user) { developer }
- let(:ff_value) { true }
- let(:access_token) { tokens[:write] }
- let(:headers) do
- { 'Authorization' => "Bearer #{access_token.token}" }
- end
-
- let(:project_id) { project.id }
- let(:default_params) { {} }
- let(:params) { default_params }
- let(:request) { get api(route), params: params, headers: headers }
-
- before do
- stub_feature_flags(ml_experiment_tracking: ff_value)
-
- request
- end
-
- shared_examples 'Not Found' do |message|
- it "is Not Found" do
- expect(response).to have_gitlab_http_status(:not_found)
-
- expect(json_response['message']).to eq(message) if message.present?
- end
- end
-
- shared_examples 'Not Found - Resource Does Not Exist' do
- it "is Resource Does Not Exist" do
- expect(response).to have_gitlab_http_status(:not_found)
-
- expect(json_response).to include({ "error_code" => 'RESOURCE_DOES_NOT_EXIST' })
- end
- end
-
- shared_examples 'Requires api scope' do
- context 'when user has access but token has wrong scope' do
- let(:access_token) { tokens[:read] }
-
- it { expect(response).to have_gitlab_http_status(:forbidden) }
- end
- end
-
- shared_examples 'Requires read_api scope' do
- context 'when user has access but token has wrong scope' do
- let(:access_token) { tokens[:no_access] }
-
- it { expect(response).to have_gitlab_http_status(:forbidden) }
- end
- end
-
- shared_examples 'Bad Request' do |error_code = nil|
- it "is Bad Request" do
- expect(response).to have_gitlab_http_status(:bad_request)
-
- expect(json_response).to include({ 'error_code' => error_code }) if error_code.present?
- end
- end
-
- shared_examples 'shared error cases' do
- context 'when not authenticated' do
- let(:headers) { {} }
-
- it "is Unauthorized" do
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
-
- context 'when user does not have access' do
- let(:access_token) { tokens[:different_user] }
-
- it_behaves_like 'Not Found'
- end
-
- context 'when ff is disabled' do
- let(:ff_value) { false }
-
- it_behaves_like 'Not Found'
- end
- end
-
- shared_examples 'run_id param error cases' do
- context 'when run id is not passed' do
- let(:params) { {} }
-
- it_behaves_like 'Bad Request'
- end
-
- context 'when run_id is invalid' do
- let(:params) { default_params.merge(run_id: non_existing_record_iid.to_s) }
-
- it_behaves_like 'Not Found - Resource Does Not Exist'
- end
-
- context 'when run_id is not in in the project' do
- let(:project_id) { another_project.id }
-
- it_behaves_like 'Not Found - Resource Does Not Exist'
- end
- end
-
- shared_examples 'Bad Request on missing required' do |keys|
- keys.each do |key|
- context "when \"#{key}\" is missing" do
- let(:params) { default_params.tap { |p| p.delete(key) } }
-
- it_behaves_like 'Bad Request'
- end
- end
- end
-
- describe 'GET /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/get' do
- let(:experiment_iid) { experiment.iid.to_s }
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/get?experiment_id=#{experiment_iid}" }
-
- it 'returns the experiment', :aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('ml/get_experiment')
- expect(json_response).to include({
- 'experiment' => {
- 'experiment_id' => experiment_iid,
- 'name' => experiment.name,
- 'lifecycle_stage' => 'active',
- 'artifact_location' => 'not_implemented',
- 'tags' => [
- {
- 'key' => experiment.metadata[0].name,
- 'value' => experiment.metadata[0].value
- },
- {
- 'key' => experiment.metadata[1].name,
- 'value' => experiment.metadata[1].value
- }
- ]
- }
- })
- end
-
- describe 'Error States' do
- context 'when has access' do
- context 'and experiment does not exist' do
- let(:experiment_iid) { non_existing_record_iid.to_s }
-
- it_behaves_like 'Not Found - Resource Does Not Exist'
- end
-
- context 'and experiment_id is not passed' do
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/get" }
-
- it_behaves_like 'Not Found - Resource Does Not Exist'
- end
- end
-
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires read_api scope'
- end
- end
-
- describe 'GET /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/list' do
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/list" }
-
- it 'returns the experiments' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('ml/list_experiments')
- expect(json_response).to include({
- 'experiments' => [
- 'experiment_id' => experiment.iid.to_s,
- 'name' => experiment.name,
- 'lifecycle_stage' => 'active',
- 'artifact_location' => 'not_implemented',
- 'tags' => [
- {
- 'key' => experiment.metadata[0].name,
- 'value' => experiment.metadata[0].value
- },
- {
- 'key' => experiment.metadata[1].name,
- 'value' => experiment.metadata[1].value
- }
- ]
- ]
- })
- end
-
- context 'when there are no experiments' do
- let(:project_id) { another_project.id }
-
- it 'returns an empty list' do
- expect(json_response).to include({ 'experiments' => [] })
- end
- end
-
- describe 'Error States' do
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires read_api scope'
- end
- end
-
- describe 'GET /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/get-by-name' do
- let(:experiment_name) { experiment.name }
- let(:route) do
- "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/get-by-name?experiment_name=#{experiment_name}"
- end
-
- it 'returns the experiment', :aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('ml/get_experiment')
- expect(json_response).to include({
- 'experiment' => {
- 'experiment_id' => experiment.iid.to_s,
- 'name' => experiment_name,
- 'lifecycle_stage' => 'active',
- 'artifact_location' => 'not_implemented',
- 'tags' => [
- {
- 'key' => experiment.metadata[0].name,
- 'value' => experiment.metadata[0].value
- },
- {
- 'key' => experiment.metadata[1].name,
- 'value' => experiment.metadata[1].value
- }
- ]
- }
- })
- end
-
- describe 'Error States' do
- context 'when has access but experiment does not exist' do
- let(:experiment_name) { "random_experiment" }
-
- it_behaves_like 'Not Found - Resource Does Not Exist'
- end
-
- context 'when has access but experiment_name is not passed' do
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/get-by-name" }
-
- it_behaves_like 'Not Found - Resource Does Not Exist'
- end
-
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires read_api scope'
- end
- end
-
- describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/create' do
- let(:route) do
- "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/create"
- end
-
- let(:params) { { name: 'new_experiment' } }
- let(:request) { post api(route), params: params, headers: headers }
-
- it 'creates the experiment', :aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to include('experiment_id')
- end
-
- describe 'Error States' do
- context 'when experiment name is not passed' do
- let(:params) { {} }
-
- it_behaves_like 'Bad Request'
- end
-
- context 'when experiment name already exists' do
- let(:existing_experiment) do
- create(:ml_experiments, user: current_user, project: project)
- end
-
- let(:params) { { name: existing_experiment.name } }
-
- it_behaves_like 'Bad Request', 'RESOURCE_ALREADY_EXISTS'
- end
-
- context 'when project does not exist' do
- let(:route) { "/projects/#{non_existing_record_id}/ml/mlflow/api/2.0/mlflow/experiments/create" }
-
- it_behaves_like 'Not Found', '404 Project Not Found'
- end
-
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires api scope'
- end
- end
-
- describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/set-experiment-tag' do
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/experiments/set-experiment-tag" }
- let(:default_params) { { experiment_id: experiment.iid.to_s, key: 'some_key', value: 'value' } }
- let(:params) { default_params }
- let(:request) { post api(route), params: params, headers: headers }
-
- it 'logs the tag', :aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_empty
- expect(experiment.reload.metadata.map(&:name)).to include('some_key')
- end
-
- describe 'Error Cases' do
- context 'when tag was already set' do
- let(:params) { default_params.merge(key: experiment.metadata[0].name) }
-
- it_behaves_like 'Bad Request'
- end
-
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires api scope'
- it_behaves_like 'Bad Request on missing required', [:key, :value]
- end
- end
-
- describe 'Runs' do
- describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/create' do
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/create" }
- let(:params) do
- {
- experiment_id: experiment.iid.to_s,
- start_time: Time.now.to_i,
- run_name: "A new Run",
- tags: [
- { key: 'hello', value: 'world' }
- ]
- }
- end
-
- let(:request) { post api(route), params: params, headers: headers }
-
- it 'creates the run', :aggregate_failures do
- expected_properties = {
- 'experiment_id' => params[:experiment_id],
- 'user_id' => current_user.id.to_s,
- 'run_name' => "A new Run",
- 'start_time' => params[:start_time],
- 'status' => 'RUNNING',
- 'lifecycle_stage' => 'active'
- }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('ml/run')
- expect(json_response['run']).to include('info' => hash_including(**expected_properties),
- 'data' => {
- 'metrics' => [],
- 'params' => [],
- 'tags' => [{ 'key' => 'hello', 'value' => 'world' }]
- })
- end
-
- describe 'Error States' do
- context 'when experiment id is not passed' do
- let(:params) { {} }
-
- it_behaves_like 'Bad Request'
- end
-
- context 'when experiment id does not exist' do
- let(:params) { { experiment_id: non_existing_record_iid.to_s } }
-
- it_behaves_like 'Not Found - Resource Does Not Exist'
- end
-
- context 'when experiment exists but is not part of the project' do
- let(:project_id) { another_project.id }
-
- it_behaves_like 'Not Found - Resource Does Not Exist'
- end
-
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires api scope'
- end
- end
-
- describe 'GET /projects/:id/ml/mlflow/api/2.0/mlflow/runs/get' do
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/get" }
- let(:default_params) { { 'run_id' => candidate.iid } }
-
- it 'gets the run', :aggregate_failures do
- expected_properties = {
- 'experiment_id' => candidate.experiment.iid.to_s,
- 'user_id' => candidate.user.id.to_s,
- 'start_time' => candidate.start_time,
- 'artifact_uri' => "http://www.example.com/api/v4/projects/#{project_id}/packages/generic/ml_candidate_#{candidate.id}/-/",
- 'status' => "RUNNING",
- 'lifecycle_stage' => "active"
- }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('ml/run')
- expect(json_response['run']).to include(
- 'info' => hash_including(**expected_properties),
- 'data' => {
- 'metrics' => [
- hash_including('key' => candidate.metrics[0].name),
- hash_including('key' => candidate.metrics[1].name)
- ],
- 'params' => [
- { 'key' => candidate.params[0].name, 'value' => candidate.params[0].value },
- { 'key' => candidate.params[1].name, 'value' => candidate.params[1].value }
- ],
- 'tags' => [
- { 'key' => candidate.metadata[0].name, 'value' => candidate.metadata[0].value },
- { 'key' => candidate.metadata[1].name, 'value' => candidate.metadata[1].value }
- ]
- })
- end
-
- describe 'Error States' do
- it_behaves_like 'run_id param error cases'
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires read_api scope'
- end
- end
-
- describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/update' do
- let(:default_params) { { run_id: candidate.iid.to_s, status: 'FAILED', end_time: Time.now.to_i } }
- let(:request) { post api(route), params: params, headers: headers }
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/update" }
-
- it 'updates the run', :aggregate_failures do
- expected_properties = {
- 'experiment_id' => candidate.experiment.iid.to_s,
- 'user_id' => candidate.user.id.to_s,
- 'start_time' => candidate.start_time,
- 'end_time' => params[:end_time],
- 'artifact_uri' => "http://www.example.com/api/v4/projects/#{project_id}/packages/generic/ml_candidate_#{candidate.id}/-/",
- 'status' => 'FAILED',
- 'lifecycle_stage' => 'active'
- }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('ml/update_run')
- expect(json_response).to include('run_info' => hash_including(**expected_properties))
- end
-
- describe 'Error States' do
- context 'when status in invalid' do
- let(:params) { default_params.merge(status: 'YOLO') }
-
- it_behaves_like 'Bad Request'
- end
-
- context 'when end_time is invalid' do
- let(:params) { default_params.merge(end_time: 's') }
-
- it_behaves_like 'Bad Request'
- end
-
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires api scope'
- it_behaves_like 'run_id param error cases'
- end
- end
-
- describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/log-metric' do
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/log-metric" }
- let(:default_params) { { run_id: candidate.iid.to_s, key: 'some_key', value: 10.0, timestamp: Time.now.to_i } }
- let(:request) { post api(route), params: params, headers: headers }
-
- it 'logs the metric', :aggregate_failures do
- candidate.metrics.reload
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_empty
- expect(candidate.metrics.length).to eq(3)
- end
-
- describe 'Error Cases' do
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires api scope'
- it_behaves_like 'run_id param error cases'
- it_behaves_like 'Bad Request on missing required', [:key, :value, :timestamp]
- end
- end
-
- describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/log-parameter' do
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/log-parameter" }
- let(:default_params) { { run_id: candidate.iid.to_s, key: 'some_key', value: 'value' } }
- let(:request) { post api(route), params: params, headers: headers }
-
- it 'logs the parameter', :aggregate_failures do
- candidate.params.reload
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_empty
- expect(candidate.params.length).to eq(3)
- end
-
- describe 'Error Cases' do
- context 'when parameter was already logged' do
- let(:params) { default_params.tap { |p| p[:key] = candidate.params[0].name } }
-
- it_behaves_like 'Bad Request'
- end
-
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires api scope'
- it_behaves_like 'run_id param error cases'
- it_behaves_like 'Bad Request on missing required', [:key, :value]
- end
- end
-
- describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/set-tag' do
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/set-tag" }
- let(:default_params) { { run_id: candidate.iid.to_s, key: 'some_key', value: 'value' } }
- let(:request) { post api(route), params: params, headers: headers }
-
- it 'logs the tag', :aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_empty
- expect(candidate.reload.metadata.map(&:name)).to include('some_key')
- end
-
- describe 'Error Cases' do
- context 'when tag was already logged' do
- let(:params) { default_params.tap { |p| p[:key] = candidate.metadata[0].name } }
-
- it_behaves_like 'Bad Request'
- end
-
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires api scope'
- it_behaves_like 'run_id param error cases'
- it_behaves_like 'Bad Request on missing required', [:key, :value]
- end
- end
-
- describe 'POST /projects/:id/ml/mlflow/api/2.0/mlflow/runs/log-batch' do
- let(:candidate2) do
- create(:ml_candidates, user: experiment.user, start_time: 1234, experiment: experiment)
- end
-
- let(:route) { "/projects/#{project_id}/ml/mlflow/api/2.0/mlflow/runs/log-batch" }
- let(:default_params) do
- {
- run_id: candidate2.iid.to_s,
- metrics: [
- { key: 'mae', value: 2.5, timestamp: 1552550804 },
- { key: 'rmse', value: 2.7, timestamp: 1552550804 }
- ],
- params: [{ key: 'model_class', value: 'LogisticRegression' }],
- tags: [{ key: 'tag1', value: 'tag.value.1' }]
- }
- end
-
- let(:request) { post api(route), params: params, headers: headers }
-
- it 'logs parameters and metrics', :aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_empty
- expect(candidate2.params.size).to eq(1)
- expect(candidate2.metadata.size).to eq(1)
- expect(candidate2.metrics.size).to eq(2)
- end
-
- context 'when parameter was already logged' do
- let(:params) do
- default_params.tap { |p| p[:params] = [{ key: 'hello', value: 'a' }, { key: 'hello', value: 'b' }] }
- end
-
- it 'does not log', :aggregate_failures do
- candidate.params.reload
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(candidate2.params.size).to eq(1)
- end
- end
-
- context 'when tag was already logged' do
- let(:params) do
- default_params.tap { |p| p[:tags] = [{ key: 'tag1', value: 'a' }, { key: 'tag1', value: 'b' }] }
- end
-
- it 'logs only 1', :aggregate_failures do
- candidate.metadata.reload
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(candidate2.metadata.size).to eq(1)
- end
- end
-
- describe 'Error Cases' do
- context 'when required metric key is missing' do
- let(:params) { default_params.tap { |p| p[:metrics] = [p[:metrics][0].delete(:key)] } }
-
- it_behaves_like 'Bad Request'
- end
-
- context 'when required param key is missing' do
- let(:params) { default_params.tap { |p| p[:params] = [p[:params][0].delete(:key)] } }
-
- it_behaves_like 'Bad Request'
- end
-
- it_behaves_like 'shared error cases'
- it_behaves_like 'Requires api scope'
- it_behaves_like 'run_id param error cases'
- end
- end
- end
-end
diff --git a/spec/requests/api/namespaces_spec.rb b/spec/requests/api/namespaces_spec.rb
index 44574caf54a..f268a092034 100644
--- a/spec/requests/api/namespaces_spec.rb
+++ b/spec/requests/api/namespaces_spec.rb
@@ -2,25 +2,27 @@
require 'spec_helper'
-RSpec.describe API::Namespaces, feature_category: :subgroups do
+RSpec.describe API::Namespaces, :aggregate_failures, feature_category: :subgroups do
let_it_be(:admin) { create(:admin) }
let_it_be(:user) { create(:user) }
let_it_be(:group1) { create(:group, name: 'group.one') }
let_it_be(:group2) { create(:group, :nested) }
let_it_be(:project) { create(:project, namespace: group2, name: group2.name, path: group2.path) }
let_it_be(:project_namespace) { project.project_namespace }
+ let_it_be(:path) { "/namespaces" }
describe "GET /namespaces" do
context "when unauthenticated" do
it "returns authentication error" do
- get api("/namespaces")
+ get api(path)
+
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context "when authenticated as admin" do
it "returns correct attributes" do
- get api("/namespaces", admin)
+ get api(path, admin, admin_mode: true)
group_kind_json_response = json_response.find { |resource| resource['kind'] == 'group' }
user_kind_json_response = json_response.find { |resource| resource['kind'] == 'user' }
@@ -34,7 +36,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it "admin: returns an array of all namespaces" do
- get api("/namespaces", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -44,7 +46,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it "admin: returns an array of matched namespaces" do
- get api("/namespaces?search=#{group2.name}", admin)
+ get api("/namespaces?search=#{group2.name}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -59,7 +61,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
it "returns correct attributes when user can admin group" do
group1.add_owner(user)
- get api("/namespaces", user)
+ get api(path, user)
owned_group_response = json_response.find { |resource| resource['id'] == group1.id }
@@ -70,7 +72,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
it "returns correct attributes when user cannot admin group" do
group1.add_guest(user)
- get api("/namespaces", user)
+ get api(path, user)
guest_group_response = json_response.find { |resource| resource['id'] == group1.id }
@@ -78,7 +80,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it "user: returns an array of namespaces" do
- get api("/namespaces", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -115,9 +117,19 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
let_it_be(:user2) { create(:user) }
- shared_examples 'can access namespace' do
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { "/namespaces/#{group2.id}" }
+ let(:failed_status_code) { :not_found }
+ end
+
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { "/namespaces/#{user2.namespace.id}" }
+ let(:failed_status_code) { :not_found }
+ end
+
+ shared_examples 'can access namespace' do |admin_mode: false|
it 'returns namespace details' do
- get api("/namespaces/#{namespace_id}", request_actor)
+ get api("#{path}/#{namespace_id}", request_actor, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:ok)
@@ -153,7 +165,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
let(:namespace_id) { project_namespace.id }
it 'returns not-found' do
- get api("/namespaces/#{namespace_id}", request_actor)
+ get api("#{path}/#{namespace_id}", request_actor)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -188,7 +200,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
context "when namespace doesn't exist" do
it 'returns not-found' do
- get api('/namespaces/0', request_actor)
+ get api("#{path}/0", request_actor)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -197,13 +209,13 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
context 'when unauthenticated' do
it 'returns authentication error' do
- get api("/namespaces/#{group1.id}")
+ get api("#{path}/#{group1.id}")
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'returns authentication error' do
- get api("/namespaces/#{project_namespace.id}")
+ get api("#{path}/#{project_namespace.id}")
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -215,7 +227,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
context 'when requested namespace is not owned by user' do
context 'when requesting group' do
it 'returns not-found' do
- get api("/namespaces/#{group2.id}", request_actor)
+ get api("#{path}/#{group2.id}", request_actor)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -223,7 +235,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
context 'when requesting personal namespace' do
it 'returns not-found' do
- get api("/namespaces/#{user2.namespace.id}", request_actor)
+ get api("#{path}/#{user2.namespace.id}", request_actor)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -243,14 +255,14 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
let(:namespace_id) { group2.id }
let(:requested_namespace) { group2 }
- it_behaves_like 'can access namespace'
+ it_behaves_like 'can access namespace', admin_mode: true
end
context 'when requesting personal namespace' do
let(:namespace_id) { user2.namespace.id }
let(:requested_namespace) { user2.namespace }
- it_behaves_like 'can access namespace'
+ it_behaves_like 'can access namespace', admin_mode: true
end
end
@@ -269,7 +281,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
context 'when unauthenticated' do
it 'returns authentication error' do
- get api("/namespaces/#{namespace1.path}/exists")
+ get api("#{path}/#{namespace1.path}/exists")
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -278,7 +290,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
let(:namespace_id) { project_namespace.id }
it 'returns authentication error' do
- get api("/namespaces/#{project_namespace.path}/exists"), params: { parent_id: group2.id }
+ get api("#{path}/#{project_namespace.path}/exists"), params: { parent_id: group2.id }
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -290,12 +302,12 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
let(:current_user) { user }
def request
- get api("/namespaces/#{namespace1.path}/exists", current_user)
+ get api("#{path}/#{namespace1.path}/exists", current_user)
end
end
it 'returns JSON indicating the namespace exists and a suggestion' do
- get api("/namespaces/#{namespace1.path}/exists", user)
+ get api("#{path}/#{namespace1.path}/exists", user)
expected_json = { exists: true, suggests: ["#{namespace1.path}1"] }.to_json
expect(response).to have_gitlab_http_status(:ok)
@@ -303,7 +315,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it 'supports dot in namespace path' do
- get api("/namespaces/#{namespace_with_dot.path}/exists", user)
+ get api("#{path}/#{namespace_with_dot.path}/exists", user)
expected_json = { exists: true, suggests: ["#{namespace_with_dot.path}1"] }.to_json
expect(response).to have_gitlab_http_status(:ok)
@@ -311,7 +323,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it 'returns JSON indicating the namespace does not exist without a suggestion' do
- get api("/namespaces/non-existing-namespace/exists", user)
+ get api("#{path}/non-existing-namespace/exists", user)
expected_json = { exists: false, suggests: [] }.to_json
expect(response).to have_gitlab_http_status(:ok)
@@ -319,7 +331,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it 'checks the existence of a namespace in case-insensitive manner' do
- get api("/namespaces/#{namespace1.path.upcase}/exists", user)
+ get api("#{path}/#{namespace1.path.upcase}/exists", user)
expected_json = { exists: true, suggests: ["#{namespace1.path.upcase}1"] }.to_json
expect(response).to have_gitlab_http_status(:ok)
@@ -327,7 +339,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it 'checks the existence within the parent namespace only' do
- get api("/namespaces/#{namespace1sub.path}/exists", user), params: { parent_id: namespace1.id }
+ get api("#{path}/#{namespace1sub.path}/exists", user), params: { parent_id: namespace1.id }
expected_json = { exists: true, suggests: ["#{namespace1sub.path}1"] }.to_json
expect(response).to have_gitlab_http_status(:ok)
@@ -335,7 +347,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it 'ignores nested namespaces when checking for top-level namespace' do
- get api("/namespaces/#{namespace1sub.path}/exists", user)
+ get api("#{path}/#{namespace1sub.path}/exists", user)
expected_json = { exists: false, suggests: [] }.to_json
expect(response).to have_gitlab_http_status(:ok)
@@ -349,7 +361,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
create(:group, name: 'mygroup', path: 'mygroup', parent: namespace1)
- get api("/namespaces/mygroup/exists", user), params: { parent_id: namespace1.id }
+ get api("#{path}/mygroup/exists", user), params: { parent_id: namespace1.id }
# if the paths of groups present in hierachies aren't ignored, the suggestion generated would have
# been `mygroup3`, just because groups with path `mygroup1` and `mygroup2` exists somewhere else.
@@ -361,7 +373,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it 'ignores top-level namespaces when checking with parent_id' do
- get api("/namespaces/#{namespace1.path}/exists", user), params: { parent_id: namespace1.id }
+ get api("#{path}/#{namespace1.path}/exists", user), params: { parent_id: namespace1.id }
expected_json = { exists: false, suggests: [] }.to_json
expect(response).to have_gitlab_http_status(:ok)
@@ -369,7 +381,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
end
it 'ignores namespaces of other parent namespaces when checking with parent_id' do
- get api("/namespaces/#{namespace2sub.path}/exists", user), params: { parent_id: namespace1.id }
+ get api("#{path}/#{namespace2sub.path}/exists", user), params: { parent_id: namespace1.id }
expected_json = { exists: false, suggests: [] }.to_json
expect(response).to have_gitlab_http_status(:ok)
@@ -380,7 +392,7 @@ RSpec.describe API::Namespaces, feature_category: :subgroups do
let(:namespace_id) { project_namespace.id }
it 'returns JSON indicating the namespace does not exist without a suggestion' do
- get api("/namespaces/#{project_namespace.path}/exists", user), params: { parent_id: group2.id }
+ get api("#{path}/#{project_namespace.path}/exists", user), params: { parent_id: group2.id }
expected_json = { exists: false, suggests: [] }.to_json
expect(response).to have_gitlab_http_status(:ok)
diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb
index c0276e02eb7..d535629ea0d 100644
--- a/spec/requests/api/notes_spec.rb
+++ b/spec/requests/api/notes_spec.rb
@@ -70,7 +70,7 @@ RSpec.describe API::Notes, feature_category: :team_planning do
describe "GET /projects/:id/noteable/:noteable_id/notes" do
context "current user cannot view the notes" do
- it "returns an empty array" do
+ it "returns an empty array", :aggregate_failures do
get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -93,7 +93,7 @@ RSpec.describe API::Notes, feature_category: :team_planning do
end
context "current user can view the note" do
- it "returns a non-empty array" do
+ it "returns a non-empty array", :aggregate_failures do
get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", private_user)
expect(response).to have_gitlab_http_status(:ok)
@@ -114,7 +114,7 @@ RSpec.describe API::Notes, feature_category: :team_planning do
let(:test_url) { "/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes" }
shared_examples 'a notes request' do
- it 'is a note array response' do
+ it 'is a note array response', :aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
@@ -164,7 +164,7 @@ RSpec.describe API::Notes, feature_category: :team_planning do
it_behaves_like 'a notes request'
- it "properly filters the returned notables" do
+ it "properly filters the returned notables", :aggregate_failures do
expect(json_response.count).to eq(count)
expect(json_response.first["system"]).to be system_notable
end
@@ -195,7 +195,7 @@ RSpec.describe API::Notes, feature_category: :team_planning do
end
context "current user can view the note" do
- it "returns an issue note by id" do
+ it "returns an issue note by id", :aggregate_failures do
get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes/#{cross_reference_note.id}", private_user)
expect(response).to have_gitlab_http_status(:ok)
diff --git a/spec/requests/api/npm_instance_packages_spec.rb b/spec/requests/api/npm_instance_packages_spec.rb
index dcd2e4ae677..591a8ee68dc 100644
--- a/spec/requests/api/npm_instance_packages_spec.rb
+++ b/spec/requests/api/npm_instance_packages_spec.rb
@@ -11,8 +11,38 @@ RSpec.describe API::NpmInstancePackages, feature_category: :package_registry do
include_context 'npm api setup'
describe 'GET /api/v4/packages/npm/*package_name' do
- it_behaves_like 'handling get metadata requests', scope: :instance do
- let(:url) { api("/packages/npm/#{package_name}") }
+ let(:url) { api("/packages/npm/#{package_name}") }
+
+ it_behaves_like 'handling get metadata requests', scope: :instance
+
+ context 'with a duplicate package name in another project' do
+ subject { get(url) }
+
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:package2) do
+ create(:npm_package,
+ project: project2,
+ name: "@#{group.path}/scoped_package",
+ version: '1.2.0')
+ end
+
+ it 'includes all matching package versions in the response' do
+ subject
+
+ expect(json_response['versions'].keys).to match_array([package.version, package2.version])
+ end
+
+ context 'with the feature flag disabled' do
+ before do
+ stub_feature_flags(npm_allow_packages_in_multiple_projects: false)
+ end
+
+ it 'returns matching package versions from only one project' do
+ subject
+
+ expect(json_response['versions'].keys).to match_array([package2.version])
+ end
+ end
end
end
diff --git a/spec/requests/api/npm_project_packages_spec.rb b/spec/requests/api/npm_project_packages_spec.rb
index c62c0849776..1f5ebc80824 100644
--- a/spec/requests/api/npm_project_packages_spec.rb
+++ b/spec/requests/api/npm_project_packages_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe API::NpmProjectPackages, feature_category: :package_registry do
+ include ExclusiveLeaseHelpers
+
include_context 'npm api setup'
shared_examples 'accept get request on private project with access to package registry for everyone' do
@@ -115,6 +117,8 @@ RSpec.describe API::NpmProjectPackages, feature_category: :package_registry do
end
context 'private project' do
+ let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user, property: 'i_package_npm_user' } }
+
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
end
@@ -143,6 +147,8 @@ RSpec.describe API::NpmProjectPackages, feature_category: :package_registry do
end
context 'internal project' do
+ let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user, property: 'i_package_npm_user' } }
+
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
end
@@ -208,6 +214,14 @@ RSpec.describe API::NpmProjectPackages, feature_category: :package_registry do
it_behaves_like 'not a package tracking event'
end
end
+
+ context 'invalid package attachment data' do
+ let(:package_name) { "@#{group.path}/my_package_name" }
+ let(:params) { upload_params(package_name: package_name, file: 'npm/payload_with_empty_attachment.json') }
+
+ it_behaves_like 'handling invalid record with 400 error'
+ it_behaves_like 'not a package tracking event'
+ end
end
context 'valid package params' do
@@ -220,15 +234,7 @@ RSpec.describe API::NpmProjectPackages, feature_category: :package_registry do
context 'with access token' do
it_behaves_like 'a package tracking event', 'API::NpmPackages', 'push_package'
- it 'creates npm package with file' do
- expect { subject }
- .to change { project.packages.count }.by(1)
- .and change { Packages::PackageFile.count }.by(1)
- .and change { Packages::Tag.count }.by(1)
- .and change { Packages::Npm::Metadatum.count }.by(1)
-
- expect(response).to have_gitlab_http_status(:ok)
- end
+ it_behaves_like 'a successful package creation'
end
it 'creates npm package with file with job token' do
@@ -364,12 +370,13 @@ RSpec.describe API::NpmProjectPackages, feature_category: :package_registry do
end
end
- context 'with a too large metadata structure' do
- let(:package_name) { "@#{group.path}/my_package_name" }
- let(:params) do
- upload_params(package_name: package_name, package_version: '1.2.3').tap do |h|
- h['versions']['1.2.3']['test'] = 'test' * 10000
- end
+ context 'when the lease to create a package is already taken' do
+ let(:version) { '1.0.1' }
+ let(:params) { upload_params(package_name: package_name, package_version: version) }
+ let(:lease_key) { "packages:npm:create_package_service:packages:#{project.id}_#{package_name}_#{version}" }
+
+ before do
+ stub_exclusive_lease_taken(lease_key, timeout: Packages::Npm::CreatePackageService::DEFAULT_LEASE_TIMEOUT)
end
it_behaves_like 'not a package tracking event'
@@ -379,7 +386,95 @@ RSpec.describe API::NpmProjectPackages, feature_category: :package_registry do
.not_to change { project.packages.count }
expect(response).to have_gitlab_http_status(:bad_request)
- expect(response.body).to include('Validation failed: Package json structure is too large')
+ expect(response.body).to include('Could not obtain package lease.')
+ end
+ end
+
+ context 'with a too large metadata structure' do
+ let(:package_name) { "@#{group.path}/my_package_name" }
+
+ ::Packages::Npm::CreatePackageService::PACKAGE_JSON_NOT_ALLOWED_FIELDS.each do |field|
+ context "when a large value for #{field} is set" do
+ let(:params) do
+ upload_params(package_name: package_name, package_version: '1.2.3').tap do |h|
+ h['versions']['1.2.3'][field] = 'test' * 10000
+ end
+ end
+
+ it_behaves_like 'a successful package creation'
+ end
+ end
+
+ context 'when the large field is not one of the ignored fields' do
+ let(:params) do
+ upload_params(package_name: package_name, package_version: '1.2.3').tap do |h|
+ h['versions']['1.2.3']['test'] = 'test' * 10000
+ end
+ end
+
+ it_behaves_like 'not a package tracking event'
+
+ it 'returns an error' do
+ expect { upload_package_with_token }
+ .not_to change { project.packages.count }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response.body).to include('Validation failed: Package json structure is too large')
+ end
+ end
+ end
+
+ context 'when the Npm-Command in headers is deprecate' do
+ let(:package_name) { "@#{group.path}/my_package_name" }
+ let(:headers) { build_token_auth_header(token.plaintext_token).merge('Npm-Command' => 'deprecate') }
+ let(:params) do
+ {
+ 'id' => project.id.to_s,
+ 'package_name' => package_name,
+ 'versions' => {
+ '1.0.1' => {
+ 'name' => package_name,
+ 'deprecated' => 'This version is deprecated'
+ },
+ '1.0.2' => {
+ 'name' => package_name
+ }
+ }
+ }
+ end
+
+ subject(:request) { put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params, headers: headers }
+
+ context 'when the user is not authorized to destroy the package' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'does not call DeprecatePackageService' do
+ expect(::Packages::Npm::DeprecatePackageService).not_to receive(:new)
+
+ request
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when the user is authorized to destroy the package' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'calls DeprecatePackageService with the correct arguments' do
+ expect(::Packages::Npm::DeprecatePackageService).to receive(:new).with(project, params) do
+ double.tap do |service|
+ expect(service).to receive(:execute).with(async: true)
+ end
+ end
+
+ request
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
end
end
diff --git a/spec/requests/api/nuget_group_packages_spec.rb b/spec/requests/api/nuget_group_packages_spec.rb
index 4335ad75ab6..facbc01220d 100644
--- a/spec/requests/api/nuget_group_packages_spec.rb
+++ b/spec/requests/api/nuget_group_packages_spec.rb
@@ -12,8 +12,17 @@ RSpec.describe API::NugetGroupPackages, feature_category: :package_registry do
let_it_be(:deploy_token) { create(:deploy_token, :group, read_package_registry: true, write_package_registry: true) }
let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: deploy_token, group: group) }
- let(:snowplow_gitlab_standard_context) { { namespace: project.group, property: 'i_package_nuget_user' } }
let(:target_type) { 'groups' }
+ let(:snowplow_gitlab_standard_context) { snowplow_context }
+ let(:target) { subgroup }
+
+ def snowplow_context(user_role: :developer)
+ if user_role == :anonymous
+ { namespace: target, property: 'i_package_nuget_user' }
+ else
+ { namespace: target, property: 'i_package_nuget_user', user: user }
+ end
+ end
shared_examples 'handling all endpoints' do
describe 'GET /api/v4/groups/:id/-/packages/nuget' do
@@ -84,7 +93,6 @@ RSpec.describe API::NugetGroupPackages, feature_category: :package_registry do
context 'a group' do
let(:target) { group }
- let(:snowplow_gitlab_standard_context) { { namespace: target, property: 'i_package_nuget_user' } }
it_behaves_like 'handling all endpoints'
diff --git a/spec/requests/api/nuget_project_packages_spec.rb b/spec/requests/api/nuget_project_packages_spec.rb
index 1e0d35ad451..887dfd4beeb 100644
--- a/spec/requests/api/nuget_project_packages_spec.rb
+++ b/spec/requests/api/nuget_project_packages_spec.rb
@@ -13,7 +13,15 @@ RSpec.describe API::NugetProjectPackages, feature_category: :package_registry do
let(:target) { project }
let(:target_type) { 'projects' }
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_nuget_user' } }
+ let(:snowplow_gitlab_standard_context) { snowplow_context }
+
+ def snowplow_context(user_role: :developer)
+ if user_role == :anonymous
+ { project: target, namespace: target.namespace, property: 'i_package_nuget_user' }
+ else
+ { project: target, namespace: target.namespace, property: 'i_package_nuget_user', user: user }
+ end
+ end
shared_examples 'accept get request on private project with access to package registry for everyone' do
subject { get api(url) }
@@ -149,6 +157,7 @@ RSpec.describe API::NugetProjectPackages, feature_category: :package_registry do
with_them do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) }
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: user_role) }
subject { get api(url), headers: headers }
diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb
index b29f1e9e661..19a943477d2 100644
--- a/spec/requests/api/oauth_tokens_spec.rb
+++ b/spec/requests/api/oauth_tokens_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'OAuth tokens', feature_category: :authentication_and_authorization do
+RSpec.describe 'OAuth tokens', feature_category: :system_access do
include HttpBasicAuthHelpers
context 'Resource Owner Password Credentials' do
@@ -124,6 +124,8 @@ RSpec.describe 'OAuth tokens', feature_category: :authentication_and_authorizati
context 'when user account is not confirmed' do
before do
+ stub_application_setting_enum('email_confirmation_setting', 'soft')
+
user.update!(confirmed_at: nil)
request_oauth_token(user, client_basic_auth_header(client))
diff --git a/spec/requests/api/package_files_spec.rb b/spec/requests/api/package_files_spec.rb
index f47dca387ef..3b4bd2f3cf4 100644
--- a/spec/requests/api/package_files_spec.rb
+++ b/spec/requests/api/package_files_spec.rb
@@ -10,6 +10,15 @@ RSpec.describe API::PackageFiles, feature_category: :package_registry do
describe 'GET /projects/:id/packages/:package_id/package_files' do
let(:url) { "/projects/#{project.id}/packages/#{package.id}/package_files" }
+ shared_examples 'handling job token and returning' do |status:|
+ it "returns status #{status}" do
+ get api(url, job_token: job.token)
+
+ expect(response).to have_gitlab_http_status(status)
+ expect(response).to match_response_schema('public_api/v4/packages/package_files') if status == :ok
+ end
+ end
+
before do
project.add_developer(user)
end
@@ -27,6 +36,12 @@ RSpec.describe API::PackageFiles, feature_category: :package_registry do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ context 'with JOB-TOKEN auth' do
+ let(:job) { create(:ci_build, :running, user: user, project: project) }
+
+ it_behaves_like 'handling job token and returning', status: :ok
+ end
end
context 'project is private' do
@@ -52,6 +67,28 @@ RSpec.describe API::PackageFiles, feature_category: :package_registry do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/packages/package_files')
end
+
+ context 'with JOB-TOKEN auth' do
+ let(:job) { create(:ci_build, :running, user: user, project: project) }
+
+ context 'a non authenticated user' do
+ let(:user) { nil }
+
+ it_behaves_like 'handling job token and returning', status: :not_found
+ end
+
+ context 'a user without access to the project', :sidekiq_inline do
+ before do
+ project.team.truncate
+ end
+
+ it_behaves_like 'handling job token and returning', status: :not_found
+ end
+
+ context 'a user with access to the project' do
+ it_behaves_like 'handling job token and returning', status: :ok
+ end
+ end
end
context 'with pagination params' do
@@ -97,6 +134,18 @@ RSpec.describe API::PackageFiles, feature_category: :package_registry do
subject(:api_request) { delete api(url, user) }
+ shared_examples 'handling job token and returning' do |status:|
+ it "returns status #{status}", :aggregate_failures do
+ if status == :no_content
+ expect { api_request }.to change { package.package_files.pending_destruction.count }.by(1)
+ else
+ expect { api_request }.not_to change { package.package_files.pending_destruction.count }
+ end
+
+ expect(response).to have_gitlab_http_status(status)
+ end
+ end
+
context 'project is public' do
context 'without user' do
let(:user) { nil }
@@ -108,6 +157,14 @@ RSpec.describe API::PackageFiles, feature_category: :package_registry do
end
end
+ context 'with JOB-TOKEN auth' do
+ subject(:api_request) { delete api(url, job_token: job.token) }
+
+ let(:job) { create(:ci_build, :running, user: user, project: project) }
+
+ it_behaves_like 'handling job token and returning', status: :forbidden
+ end
+
it 'returns 403 for a user without access to the project', :aggregate_failures do
expect { api_request }.not_to change { package.package_files.pending_destruction.count }
@@ -175,6 +232,33 @@ RSpec.describe API::PackageFiles, feature_category: :package_registry do
expect(response).to have_gitlab_http_status(:not_found)
end
end
+
+ context 'with JOB-TOKEN auth' do
+ subject(:api_request) { delete api(url, job_token: job.token) }
+
+ let(:job) { create(:ci_build, :running, user: user, project: project) }
+ let_it_be_with_refind(:project) { create(:project, :private) }
+
+ context 'a user without access to the project' do
+ it_behaves_like 'handling job token and returning', status: :not_found
+ end
+
+ context 'a user without enough permissions' do
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'handling job token and returning', status: :forbidden
+ end
+
+ context 'a user with the right permissions' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it_behaves_like 'handling job token and returning', status: :no_content
+ end
+ end
end
end
end
diff --git a/spec/requests/api/pages/internal_access_spec.rb b/spec/requests/api/pages/internal_access_spec.rb
index fdc25ecdcd3..3e7837866ae 100644
--- a/spec/requests/api/pages/internal_access_spec.rb
+++ b/spec/requests/api/pages/internal_access_spec.rb
@@ -35,39 +35,39 @@ RSpec.describe "Internal Project Pages Access", feature_category: :pages do
describe "GET /projects/:id/pages_access" do
context 'access depends on the level' do
- where(:pages_access_level, :with_user, :expected_result) do
- ProjectFeature::DISABLED | "admin" | 403
- ProjectFeature::DISABLED | "owner" | 403
- ProjectFeature::DISABLED | "master" | 403
- ProjectFeature::DISABLED | "developer" | 403
- ProjectFeature::DISABLED | "reporter" | 403
- ProjectFeature::DISABLED | "guest" | 403
- ProjectFeature::DISABLED | "user" | 403
- ProjectFeature::DISABLED | nil | 404
- ProjectFeature::PUBLIC | "admin" | 200
- ProjectFeature::PUBLIC | "owner" | 200
- ProjectFeature::PUBLIC | "master" | 200
- ProjectFeature::PUBLIC | "developer" | 200
- ProjectFeature::PUBLIC | "reporter" | 200
- ProjectFeature::PUBLIC | "guest" | 200
- ProjectFeature::PUBLIC | "user" | 200
- ProjectFeature::PUBLIC | nil | 404
- ProjectFeature::ENABLED | "admin" | 200
- ProjectFeature::ENABLED | "owner" | 200
- ProjectFeature::ENABLED | "master" | 200
- ProjectFeature::ENABLED | "developer" | 200
- ProjectFeature::ENABLED | "reporter" | 200
- ProjectFeature::ENABLED | "guest" | 200
- ProjectFeature::ENABLED | "user" | 200
- ProjectFeature::ENABLED | nil | 404
- ProjectFeature::PRIVATE | "admin" | 200
- ProjectFeature::PRIVATE | "owner" | 200
- ProjectFeature::PRIVATE | "master" | 200
- ProjectFeature::PRIVATE | "developer" | 200
- ProjectFeature::PRIVATE | "reporter" | 200
- ProjectFeature::PRIVATE | "guest" | 200
- ProjectFeature::PRIVATE | "user" | 403
- ProjectFeature::PRIVATE | nil | 404
+ where(:pages_access_level, :with_user, :admin_mode, :expected_result) do
+ ProjectFeature::DISABLED | "admin" | true | 403
+ ProjectFeature::DISABLED | "owner" | false | 403
+ ProjectFeature::DISABLED | "master" | false | 403
+ ProjectFeature::DISABLED | "developer" | false | 403
+ ProjectFeature::DISABLED | "reporter" | false | 403
+ ProjectFeature::DISABLED | "guest" | false | 403
+ ProjectFeature::DISABLED | "user" | false | 403
+ ProjectFeature::DISABLED | nil | false | 404
+ ProjectFeature::PUBLIC | "admin" | false | 200
+ ProjectFeature::PUBLIC | "owner" | false | 200
+ ProjectFeature::PUBLIC | "master" | false | 200
+ ProjectFeature::PUBLIC | "developer" | false | 200
+ ProjectFeature::PUBLIC | "reporter" | false | 200
+ ProjectFeature::PUBLIC | "guest" | false | 200
+ ProjectFeature::PUBLIC | "user" | false | 200
+ ProjectFeature::PUBLIC | nil | false | 404
+ ProjectFeature::ENABLED | "admin" | false | 200
+ ProjectFeature::ENABLED | "owner" | false | 200
+ ProjectFeature::ENABLED | "master" | false | 200
+ ProjectFeature::ENABLED | "developer" | false | 200
+ ProjectFeature::ENABLED | "reporter" | false | 200
+ ProjectFeature::ENABLED | "guest" | false | 200
+ ProjectFeature::ENABLED | "user" | false | 200
+ ProjectFeature::ENABLED | nil | false | 404
+ ProjectFeature::PRIVATE | "admin" | true | 200
+ ProjectFeature::PRIVATE | "owner" | false | 200
+ ProjectFeature::PRIVATE | "master" | false | 200
+ ProjectFeature::PRIVATE | "developer" | false | 200
+ ProjectFeature::PRIVATE | "reporter" | false | 200
+ ProjectFeature::PRIVATE | "guest" | false | 200
+ ProjectFeature::PRIVATE | "user" | false | 403
+ ProjectFeature::PRIVATE | nil | false | 404
end
with_them do
@@ -77,7 +77,7 @@ RSpec.describe "Internal Project Pages Access", feature_category: :pages do
it "correct return value" do
if !with_user.nil?
user = public_send(with_user)
- get api("/projects/#{project.id}/pages_access", user)
+ get api("/projects/#{project.id}/pages_access", user, admin_mode: admin_mode)
else
get api("/projects/#{project.id}/pages_access")
end
diff --git a/spec/requests/api/pages/pages_spec.rb b/spec/requests/api/pages/pages_spec.rb
index c426f2a433c..aa1869eaa84 100644
--- a/spec/requests/api/pages/pages_spec.rb
+++ b/spec/requests/api/pages/pages_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe API::Pages, feature_category: :pages do
- let_it_be(:project) { create(:project, path: 'my.project', pages_https_only: false) }
+ let_it_be_with_reload(:project) { create(:project, path: 'my.project', pages_https_only: false) }
let_it_be(:admin) { create(:admin) }
let_it_be(:user) { create(:user) }
@@ -13,13 +13,23 @@ RSpec.describe API::Pages, feature_category: :pages do
end
describe 'DELETE /projects/:id/pages' do
+ let(:path) { "/projects/#{project.id}/pages" }
+
+ it_behaves_like 'DELETE request permissions for admin mode' do
+ before do
+ allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
+ end
+
+ let(:succes_status_code) { :no_content }
+ end
+
context 'when Pages is disabled' do
before do
allow(Gitlab.config.pages).to receive(:enabled).and_return(false)
end
it_behaves_like '404 response' do
- let(:request) { delete api("/projects/#{project.id}/pages", admin) }
+ let(:request) { delete api(path, admin, admin_mode: true) }
end
end
@@ -30,13 +40,13 @@ RSpec.describe API::Pages, feature_category: :pages do
context 'when Pages are deployed' do
it 'returns 204' do
- delete api("/projects/#{project.id}/pages", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end
it 'removes the pages' do
- delete api("/projects/#{project.id}/pages", admin)
+ delete api(path, admin, admin_mode: true)
expect(project.reload.pages_metadatum.deployed?).to be(false)
end
@@ -48,7 +58,7 @@ RSpec.describe API::Pages, feature_category: :pages do
end
it 'returns 204' do
- delete api("/projects/#{project.id}/pages", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end
@@ -58,7 +68,7 @@ RSpec.describe API::Pages, feature_category: :pages do
it 'returns 404' do
id = -1
- delete api("/projects/#{id}/pages", admin)
+ delete api("/projects/#{id}/pages", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/requests/api/pages/private_access_spec.rb b/spec/requests/api/pages/private_access_spec.rb
index 5cc1b8f9a69..602eff73b0a 100644
--- a/spec/requests/api/pages/private_access_spec.rb
+++ b/spec/requests/api/pages/private_access_spec.rb
@@ -35,39 +35,39 @@ RSpec.describe "Private Project Pages Access", feature_category: :pages do
describe "GET /projects/:id/pages_access" do
context 'access depends on the level' do
- where(:pages_access_level, :with_user, :expected_result) do
- ProjectFeature::DISABLED | "admin" | 403
- ProjectFeature::DISABLED | "owner" | 403
- ProjectFeature::DISABLED | "master" | 403
- ProjectFeature::DISABLED | "developer" | 403
- ProjectFeature::DISABLED | "reporter" | 403
- ProjectFeature::DISABLED | "guest" | 403
- ProjectFeature::DISABLED | "user" | 404
- ProjectFeature::DISABLED | nil | 404
- ProjectFeature::PUBLIC | "admin" | 200
- ProjectFeature::PUBLIC | "owner" | 200
- ProjectFeature::PUBLIC | "master" | 200
- ProjectFeature::PUBLIC | "developer" | 200
- ProjectFeature::PUBLIC | "reporter" | 200
- ProjectFeature::PUBLIC | "guest" | 200
- ProjectFeature::PUBLIC | "user" | 404
- ProjectFeature::PUBLIC | nil | 404
- ProjectFeature::ENABLED | "admin" | 200
- ProjectFeature::ENABLED | "owner" | 200
- ProjectFeature::ENABLED | "master" | 200
- ProjectFeature::ENABLED | "developer" | 200
- ProjectFeature::ENABLED | "reporter" | 200
- ProjectFeature::ENABLED | "guest" | 200
- ProjectFeature::ENABLED | "user" | 404
- ProjectFeature::ENABLED | nil | 404
- ProjectFeature::PRIVATE | "admin" | 200
- ProjectFeature::PRIVATE | "owner" | 200
- ProjectFeature::PRIVATE | "master" | 200
- ProjectFeature::PRIVATE | "developer" | 200
- ProjectFeature::PRIVATE | "reporter" | 200
- ProjectFeature::PRIVATE | "guest" | 200
- ProjectFeature::PRIVATE | "user" | 404
- ProjectFeature::PRIVATE | nil | 404
+ where(:pages_access_level, :with_user, :admin_mode, :expected_result) do
+ ProjectFeature::DISABLED | "admin" | true | 403
+ ProjectFeature::DISABLED | "owner" | false | 403
+ ProjectFeature::DISABLED | "master" | false | 403
+ ProjectFeature::DISABLED | "developer" | false | 403
+ ProjectFeature::DISABLED | "reporter" | false | 403
+ ProjectFeature::DISABLED | "guest" | false | 403
+ ProjectFeature::DISABLED | "user" | false | 404
+ ProjectFeature::DISABLED | nil | false | 404
+ ProjectFeature::PUBLIC | "admin" | true | 200
+ ProjectFeature::PUBLIC | "owner" | false | 200
+ ProjectFeature::PUBLIC | "master" | false | 200
+ ProjectFeature::PUBLIC | "developer" | false | 200
+ ProjectFeature::PUBLIC | "reporter" | false | 200
+ ProjectFeature::PUBLIC | "guest" | false | 200
+ ProjectFeature::PUBLIC | "user" | false | 404
+ ProjectFeature::PUBLIC | nil | false | 404
+ ProjectFeature::ENABLED | "admin" | true | 200
+ ProjectFeature::ENABLED | "owner" | false | 200
+ ProjectFeature::ENABLED | "master" | false | 200
+ ProjectFeature::ENABLED | "developer" | false | 200
+ ProjectFeature::ENABLED | "reporter" | false | 200
+ ProjectFeature::ENABLED | "guest" | false | 200
+ ProjectFeature::ENABLED | "user" | false | 404
+ ProjectFeature::ENABLED | nil | false | 404
+ ProjectFeature::PRIVATE | "admin" | true | 200
+ ProjectFeature::PRIVATE | "owner" | false | 200
+ ProjectFeature::PRIVATE | "master" | false | 200
+ ProjectFeature::PRIVATE | "developer" | false | 200
+ ProjectFeature::PRIVATE | "reporter" | false | 200
+ ProjectFeature::PRIVATE | "guest" | false | 200
+ ProjectFeature::PRIVATE | "user" | false | 404
+ ProjectFeature::PRIVATE | nil | false | 404
end
with_them do
@@ -77,7 +77,7 @@ RSpec.describe "Private Project Pages Access", feature_category: :pages do
it "correct return value" do
if !with_user.nil?
user = public_send(with_user)
- get api("/projects/#{project.id}/pages_access", user)
+ get api("/projects/#{project.id}/pages_access", user, admin_mode: admin_mode)
else
get api("/projects/#{project.id}/pages_access")
end
diff --git a/spec/requests/api/pages/public_access_spec.rb b/spec/requests/api/pages/public_access_spec.rb
index 1137f91f4b0..8b0ed7c59ab 100644
--- a/spec/requests/api/pages/public_access_spec.rb
+++ b/spec/requests/api/pages/public_access_spec.rb
@@ -35,39 +35,39 @@ RSpec.describe "Public Project Pages Access", feature_category: :pages do
describe "GET /projects/:id/pages_access" do
context 'access depends on the level' do
- where(:pages_access_level, :with_user, :expected_result) do
- ProjectFeature::DISABLED | "admin" | 403
- ProjectFeature::DISABLED | "owner" | 403
- ProjectFeature::DISABLED | "master" | 403
- ProjectFeature::DISABLED | "developer" | 403
- ProjectFeature::DISABLED | "reporter" | 403
- ProjectFeature::DISABLED | "guest" | 403
- ProjectFeature::DISABLED | "user" | 403
- ProjectFeature::DISABLED | nil | 403
- ProjectFeature::PUBLIC | "admin" | 200
- ProjectFeature::PUBLIC | "owner" | 200
- ProjectFeature::PUBLIC | "master" | 200
- ProjectFeature::PUBLIC | "developer" | 200
- ProjectFeature::PUBLIC | "reporter" | 200
- ProjectFeature::PUBLIC | "guest" | 200
- ProjectFeature::PUBLIC | "user" | 200
- ProjectFeature::PUBLIC | nil | 200
- ProjectFeature::ENABLED | "admin" | 200
- ProjectFeature::ENABLED | "owner" | 200
- ProjectFeature::ENABLED | "master" | 200
- ProjectFeature::ENABLED | "developer" | 200
- ProjectFeature::ENABLED | "reporter" | 200
- ProjectFeature::ENABLED | "guest" | 200
- ProjectFeature::ENABLED | "user" | 200
- ProjectFeature::ENABLED | nil | 200
- ProjectFeature::PRIVATE | "admin" | 200
- ProjectFeature::PRIVATE | "owner" | 200
- ProjectFeature::PRIVATE | "master" | 200
- ProjectFeature::PRIVATE | "developer" | 200
- ProjectFeature::PRIVATE | "reporter" | 200
- ProjectFeature::PRIVATE | "guest" | 200
- ProjectFeature::PRIVATE | "user" | 403
- ProjectFeature::PRIVATE | nil | 403
+ where(:pages_access_level, :with_user, :admin_mode, :expected_result) do
+ ProjectFeature::DISABLED | "admin" | false | 403
+ ProjectFeature::DISABLED | "owner" | false | 403
+ ProjectFeature::DISABLED | "master" | false | 403
+ ProjectFeature::DISABLED | "developer" | false | 403
+ ProjectFeature::DISABLED | "reporter" | false | 403
+ ProjectFeature::DISABLED | "guest" | false | 403
+ ProjectFeature::DISABLED | "user" | false | 403
+ ProjectFeature::DISABLED | nil | false | 403
+ ProjectFeature::PUBLIC | "admin" | false | 200
+ ProjectFeature::PUBLIC | "owner" | false | 200
+ ProjectFeature::PUBLIC | "master" | false | 200
+ ProjectFeature::PUBLIC | "developer" | false | 200
+ ProjectFeature::PUBLIC | "reporter" | false | 200
+ ProjectFeature::PUBLIC | "guest" | false | 200
+ ProjectFeature::PUBLIC | "user" | false | 200
+ ProjectFeature::PUBLIC | nil | false | 200
+ ProjectFeature::ENABLED | "admin" | false | 200
+ ProjectFeature::ENABLED | "owner" | false | 200
+ ProjectFeature::ENABLED | "master" | false | 200
+ ProjectFeature::ENABLED | "developer" | false | 200
+ ProjectFeature::ENABLED | "reporter" | false | 200
+ ProjectFeature::ENABLED | "guest" | false | 200
+ ProjectFeature::ENABLED | "user" | false | 200
+ ProjectFeature::ENABLED | nil | false | 200
+ ProjectFeature::PRIVATE | "admin" | true | 200
+ ProjectFeature::PRIVATE | "owner" | false | 200
+ ProjectFeature::PRIVATE | "master" | false | 200
+ ProjectFeature::PRIVATE | "developer" | false | 200
+ ProjectFeature::PRIVATE | "reporter" | false | 200
+ ProjectFeature::PRIVATE | "guest" | false | 200
+ ProjectFeature::PRIVATE | "user" | false | 403
+ ProjectFeature::PRIVATE | nil | false | 403
end
with_them do
@@ -77,7 +77,7 @@ RSpec.describe "Public Project Pages Access", feature_category: :pages do
it "correct return value" do
if !with_user.nil?
user = public_send(with_user)
- get api("/projects/#{project.id}/pages_access", user)
+ get api("/projects/#{project.id}/pages_access", user, admin_mode: admin_mode)
else
get api("/projects/#{project.id}/pages_access")
end
diff --git a/spec/requests/api/pages_domains_spec.rb b/spec/requests/api/pages_domains_spec.rb
index ba1fb5105b8..9ca027c2edc 100644
--- a/spec/requests/api/pages_domains_spec.rb
+++ b/spec/requests/api/pages_domains_spec.rb
@@ -35,20 +35,24 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
end
describe 'GET /pages/domains' do
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { '/pages/domains' }
+ end
+
context 'when pages is disabled' do
before do
allow(Gitlab.config.pages).to receive(:enabled).and_return(false)
end
it_behaves_like '404 response' do
- let(:request) { get api('/pages/domains', admin) }
+ let(:request) { get api('/pages/domains', admin, admin_mode: true) }
end
end
context 'when pages is enabled' do
context 'when authenticated as an admin' do
- it 'returns paginated all pages domains' do
- get api('/pages/domains', admin)
+ it 'returns paginated all pages domains', :aggregate_failures do
+ get api('/pages/domains', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/pages_domain_basics')
@@ -74,7 +78,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
describe 'GET /projects/:project_id/pages/domains' do
shared_examples_for 'get pages domains' do
- it 'returns paginated pages domains' do
+ it 'returns paginated pages domains', :aggregate_failures do
get api(route, user)
expect(response).to have_gitlab_http_status(:ok)
@@ -145,7 +149,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
describe 'GET /projects/:project_id/pages/domains/:domain' do
shared_examples_for 'get pages domain' do
- it 'returns pages domain' do
+ it 'returns pages domain', :aggregate_failures do
get api(route_domain, user)
expect(response).to have_gitlab_http_status(:ok)
@@ -155,7 +159,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(json_response['certificate']).to be_nil
end
- it 'returns pages domain with project path' do
+ it 'returns pages domain with project path', :aggregate_failures do
get api(route_domain_path, user)
expect(response).to have_gitlab_http_status(:ok)
@@ -165,7 +169,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(json_response['certificate']).to be_nil
end
- it 'returns pages domain with a certificate' do
+ it 'returns pages domain with a certificate', :aggregate_failures do
get api(route_secure_domain, user)
expect(response).to have_gitlab_http_status(:ok)
@@ -177,7 +181,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(json_response['auto_ssl_enabled']).to be false
end
- it 'returns pages domain with an expired certificate' do
+ it 'returns pages domain with an expired certificate', :aggregate_failures do
get api(route_expired_domain, user)
expect(response).to have_gitlab_http_status(:ok)
@@ -185,7 +189,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(json_response['certificate']['expired']).to be true
end
- it 'returns pages domain with letsencrypt' do
+ it 'returns pages domain with letsencrypt', :aggregate_failures do
get api(route_letsencrypt_domain, user)
expect(response).to have_gitlab_http_status(:ok)
@@ -258,7 +262,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
let(:params_secure) { pages_domain_secure_params.slice(:domain, :certificate, :key) }
shared_examples_for 'post pages domains' do
- it 'creates a new pages domain' do
+ it 'creates a new pages domain', :aggregate_failures do
expect { post api(route, user), params: params }
.to publish_event(PagesDomains::PagesDomainCreatedEvent)
.with(
@@ -279,7 +283,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(pages_domain.auto_ssl_enabled).to be false
end
- it 'creates a new secure pages domain' do
+ it 'creates a new secure pages domain', :aggregate_failures do
post api(route, user), params: params_secure
pages_domain = PagesDomain.find_by(domain: json_response['domain'])
@@ -291,7 +295,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(pages_domain.auto_ssl_enabled).to be false
end
- it 'creates domain with letsencrypt enabled' do
+ it 'creates domain with letsencrypt enabled', :aggregate_failures do
post api(route, user), params: pages_domain_with_letsencrypt_params
pages_domain = PagesDomain.find_by(domain: json_response['domain'])
@@ -301,7 +305,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(pages_domain.auto_ssl_enabled).to be true
end
- it 'creates domain with letsencrypt enabled and provided certificate' do
+ it 'creates domain with letsencrypt enabled and provided certificate', :aggregate_failures do
post api(route, user), params: params_secure.merge(auto_ssl_enabled: true)
pages_domain = PagesDomain.find_by(domain: json_response['domain'])
@@ -376,7 +380,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
let(:params_secure_nokey) { pages_domain_secure_params.slice(:certificate) }
shared_examples_for 'put pages domain' do
- it 'updates pages domain removing certificate' do
+ it 'updates pages domain removing certificate', :aggregate_failures do
put api(route_secure_domain, user), params: { certificate: nil, key: nil }
pages_domain_secure.reload
@@ -399,7 +403,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
)
end
- it 'updates pages domain adding certificate' do
+ it 'updates pages domain adding certificate', :aggregate_failures do
put api(route_domain, user), params: params_secure
pages_domain.reload
@@ -409,7 +413,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(pages_domain.key).to eq(params_secure[:key])
end
- it 'updates pages domain adding certificate with letsencrypt' do
+ it 'updates pages domain adding certificate with letsencrypt', :aggregate_failures do
put api(route_domain, user), params: params_secure.merge(auto_ssl_enabled: true)
pages_domain.reload
@@ -420,7 +424,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(pages_domain.auto_ssl_enabled).to be true
end
- it 'updates pages domain enabling letsencrypt' do
+ it 'updates pages domain enabling letsencrypt', :aggregate_failures do
put api(route_domain, user), params: { auto_ssl_enabled: true }
pages_domain.reload
@@ -429,7 +433,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(pages_domain.auto_ssl_enabled).to be true
end
- it 'updates pages domain disabling letsencrypt while preserving the certificate' do
+ it 'updates pages domain disabling letsencrypt while preserving the certificate', :aggregate_failures do
put api(route_letsencrypt_domain, user), params: { auto_ssl_enabled: false }
pages_domain_with_letsencrypt.reload
@@ -440,7 +444,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(pages_domain_with_letsencrypt.certificate).to be
end
- it 'updates pages domain with expired certificate' do
+ it 'updates pages domain with expired certificate', :aggregate_failures do
put api(route_expired_domain, user), params: params_secure
pages_domain_expired.reload
@@ -450,7 +454,7 @@ RSpec.describe API::PagesDomains, feature_category: :pages do
expect(pages_domain_expired.key).to eq(params_secure[:key])
end
- it 'updates pages domain with expired certificate not updating key' do
+ it 'updates pages domain with expired certificate not updating key', :aggregate_failures do
put api(route_secure_domain, user), params: params_secure_nokey
pages_domain_secure.reload
diff --git a/spec/requests/api/personal_access_tokens/self_information_spec.rb b/spec/requests/api/personal_access_tokens/self_information_spec.rb
index 4a3c0ad8904..3cfaaaf7d3f 100644
--- a/spec/requests/api/personal_access_tokens/self_information_spec.rb
+++ b/spec/requests/api/personal_access_tokens/self_information_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::PersonalAccessTokens::SelfInformation, feature_category: :authentication_and_authorization do
+RSpec.describe API::PersonalAccessTokens::SelfInformation, feature_category: :system_access do
let(:path) { '/personal_access_tokens/self' }
let(:token) { create(:personal_access_token, user: current_user) }
@@ -12,7 +12,7 @@ RSpec.describe API::PersonalAccessTokens::SelfInformation, feature_category: :au
subject(:delete_token) { delete api(path, personal_access_token: token) }
shared_examples 'revoking token succeeds' do
- it 'revokes token' do
+ it 'revokes token', :aggregate_failures do
delete_token
expect(response).to have_gitlab_http_status(:no_content)
@@ -72,7 +72,7 @@ RSpec.describe API::PersonalAccessTokens::SelfInformation, feature_category: :au
context "with a '#{scope}' scoped token" do
let(:token) { create(:personal_access_token, scopes: [scope], user: current_user) }
- it 'shows token info' do
+ it 'shows token info', :aggregate_failures do
get api(path, personal_access_token: token)
expect(response).to have_gitlab_http_status(:ok)
diff --git a/spec/requests/api/personal_access_tokens_spec.rb b/spec/requests/api/personal_access_tokens_spec.rb
index 32adc7ebd61..166768ea605 100644
--- a/spec/requests/api/personal_access_tokens_spec.rb
+++ b/spec/requests/api/personal_access_tokens_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::PersonalAccessTokens, feature_category: :authentication_and_authorization do
+RSpec.describe API::PersonalAccessTokens, :aggregate_failures, feature_category: :system_access do
let_it_be(:path) { '/personal_access_tokens' }
describe 'GET /personal_access_tokens' do
@@ -30,9 +30,13 @@ RSpec.describe API::PersonalAccessTokens, feature_category: :authentication_and_
end
end
+ # Since all user types pass the same test successfully, we can avoid using
+ # shared examples and test each user type separately for its expected
+ # returned value.
+
context 'logged in as an Administrator' do
let_it_be(:current_user) { create(:admin) }
- let_it_be(:current_users_token) { create(:personal_access_token, user: current_user) }
+ let_it_be(:current_users_token) { create(:personal_access_token, :admin_mode, user: current_user) }
it 'returns all PATs by default' do
get api(path, current_user)
@@ -46,7 +50,7 @@ RSpec.describe API::PersonalAccessTokens, feature_category: :authentication_and_
let_it_be(:token_impersonated) { create(:personal_access_token, impersonation: true, user: token.user) }
it 'returns only PATs belonging to that user' do
- get api(path, current_user), params: { user_id: token.user.id }
+ get api(path, current_user, admin_mode: true), params: { user_id: token.user.id }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq(2)
@@ -444,6 +448,68 @@ RSpec.describe API::PersonalAccessTokens, feature_category: :authentication_and_
end
end
+ describe 'POST /personal_access_tokens/:id/rotate' do
+ let_it_be(:token) { create(:personal_access_token) }
+
+ let(:path) { "/personal_access_tokens/#{token.id}/rotate" }
+
+ it "rotates user's own token", :freeze_time do
+ post api(path, token.user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['token']).not_to eq(token.token)
+ expect(json_response['expires_at']).to eq((Date.today + 1.week).to_s)
+ end
+
+ context 'without permission' do
+ it 'returns an error message' do
+ another_user = create(:user)
+ post api(path, another_user)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when service raises an error' do
+ let(:error_message) { 'boom!' }
+
+ before do
+ allow_next_instance_of(PersonalAccessTokens::RotateService) do |service|
+ allow(service).to receive(:execute).and_return(ServiceResponse.error(message: error_message))
+ end
+ end
+
+ it 'returns the same error message' do
+ post api(path, token.user)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq("400 Bad request - #{error_message}")
+ end
+ end
+
+ context 'when token does not exist' do
+ let(:invalid_path) { "/personal_access_tokens/#{non_existing_record_id}/rotate" }
+
+ context 'for non-admin user' do
+ it 'returns unauthorized' do
+ user = create(:user)
+ post api(invalid_path, user)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'for admin user', :enable_admin_mode do
+ it 'returns not found' do
+ admin = create(:admin)
+ post api(invalid_path, admin)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
describe 'DELETE /personal_access_tokens/:id' do
let_it_be(:current_user) { create(:user) }
let_it_be(:token1) { create(:personal_access_token) }
diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml
index 60406f380a5..e9581265bb0 100644
--- a/spec/requests/api/project_attributes.yml
+++ b/spec/requests/api/project_attributes.yml
@@ -42,7 +42,6 @@ itself: # project
- runners_token_encrypted
- storage_version
- topic_list
- - updated_at
- mirror_branch_regex
remapped_attributes:
avatar: avatar_url
@@ -75,6 +74,7 @@ itself: # project
- tag_list
- topics
- web_url
+ - description_html
build_auto_devops: # auto_devops
unexposed_attributes:
@@ -99,7 +99,6 @@ ci_cd_settings:
forward_deployment_enabled: ci_forward_deployment_enabled
job_token_scope_enabled: ci_job_token_scope_enabled
separated_caches: ci_separated_caches
- opt_in_jwt: ci_opt_in_jwt
allow_fork_pipelines_to_run_in_parent_project: ci_allow_fork_pipelines_to_run_in_parent_project
build_import_state: # import_state
@@ -127,6 +126,7 @@ project_feature:
- package_registry_access_level
- project_id
- updated_at
+ - operations_access_level
computed_attributes:
- issues_enabled
- jobs_enabled
@@ -164,6 +164,22 @@ project_setting:
- emails_enabled
- pages_unique_domain_enabled
- pages_unique_domain
+ - runner_registration_enabled
+ - product_analytics_instrumentation_key
+ - jitsu_host
+ - jitsu_project_xid
+ - jitsu_administrator_email
+ - jitsu_administrator_password
+ - encrypted_jitsu_administrator_password
+ - encrypted_jitsu_administrator_password_iv
+ - product_analytics_data_collector_host
+ - product_analytics_clickhouse_connection_string
+ - encrypted_product_analytics_clickhouse_connection_string
+ - encrypted_product_analytics_clickhouse_connection_string_iv
+ - cube_api_base_url
+ - cube_api_key
+ - encrypted_cube_api_key
+ - encrypted_cube_api_key_iv
build_service_desk_setting: # service_desk_setting
unexposed_attributes:
diff --git a/spec/requests/api/project_clusters_spec.rb b/spec/requests/api/project_clusters_spec.rb
index 895192252da..c52948a4cb0 100644
--- a/spec/requests/api/project_clusters_spec.rb
+++ b/spec/requests/api/project_clusters_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::ProjectClusters, feature_category: :kubernetes_management do
+RSpec.describe API::ProjectClusters, feature_category: :deployment_management do
include KubernetesHelpers
let_it_be(:maintainer_user) { create(:user) }
diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb
index 096f0b73b4c..22d7ea36f6c 100644
--- a/spec/requests/api/project_export_spec.rb
+++ b/spec/requests/api/project_export_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category: :importers do
+RSpec.describe API::ProjectExport, :aggregate_failures, :clean_gitlab_redis_cache, feature_category: :importers do
let_it_be(:project) { create(:project) }
let_it_be(:project_none) { create(:project) }
let_it_be(:project_started) { create(:project) }
@@ -45,21 +45,27 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
describe 'GET /projects/:project_id/export' do
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
+
shared_examples_for 'get project export status not found' do
it_behaves_like '404 response' do
- let(:request) { get api(path, user) }
+ subject(:request) { get api(path, user) }
end
end
shared_examples_for 'get project export status denied' do
it_behaves_like '403 response' do
- let(:request) { get api(path, user) }
+ subject(:request) { get api(path, user) }
end
end
shared_examples_for 'get project export status ok' do
+ let_it_be(:admin_mode) { false }
+
it 'is none' do
- get api(path_none, user)
+ get api(path_none, user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/project/export_status')
@@ -72,7 +78,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
it 'returns status started' do
- get api(path_started, user)
+ get api(path_started, user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/project/export_status')
@@ -82,7 +88,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
context 'when project export has finished' do
it 'returns status finished' do
- get api(path_finished, user)
+ get api(path_finished, user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/project/export_status')
@@ -96,7 +102,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
it 'returns status regeneration_in_progress' do
- get api(path_finished, user)
+ get api(path_finished, user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/project/export_status')
@@ -106,14 +112,16 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
it_behaves_like 'when project export is disabled' do
- let(:request) { get api(path, admin) }
+ subject(:request) { get api(path, admin, admin_mode: true) }
end
context 'when project export is enabled' do
context 'when user is an admin' do
let(:user) { admin }
- it_behaves_like 'get project export status ok'
+ it_behaves_like 'get project export status ok' do
+ let(:admin_mode) { true }
+ end
end
context 'when user is a maintainer' do
@@ -159,29 +167,34 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
describe 'GET /projects/:project_id/export/download' do
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { download_path_finished }
+ let(:failed_status_code) { :not_found }
+ end
+
shared_examples_for 'get project export download not found' do
it_behaves_like '404 response' do
- let(:request) { get api(download_path, user) }
+ subject(:request) { get api(download_path, user) }
end
end
shared_examples_for 'get project export download denied' do
it_behaves_like '403 response' do
- let(:request) { get api(download_path, user) }
+ subject(:request) { get api(download_path, user) }
end
end
shared_examples_for 'get project export download' do
it_behaves_like '404 response' do
- let(:request) { get api(download_path_none, user) }
+ subject(:request) { get api(download_path_none, user, admin_mode: admin_mode) }
end
it_behaves_like '404 response' do
- let(:request) { get api(download_path_started, user) }
+ subject(:request) { get api(download_path_started, user, admin_mode: admin_mode) }
end
it 'downloads' do
- get api(download_path_finished, user)
+ get api(download_path_finished, user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -190,7 +203,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
shared_examples_for 'get project export upload after action' do
context 'and is uploading' do
it 'downloads' do
- get api(download_path_export_action, user)
+ get api(download_path_export_action, user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -202,7 +215,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
it 'returns 404' do
- get api(download_path_export_action, user)
+ get api(download_path_export_action, user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('The project export file is not available yet')
@@ -219,12 +232,14 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
it_behaves_like '404 response' do
- let(:request) { get api(download_path_export_action, user) }
+ subject(:request) { get api(download_path_export_action, user, admin_mode: admin_mode) }
end
end
end
shared_examples_for 'get project download by strategy' do
+ let_it_be(:admin_mode) { false }
+
context 'when upload strategy set' do
it_behaves_like 'get project export upload after action'
end
@@ -235,17 +250,19 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
it_behaves_like 'when project export is disabled' do
- let(:request) { get api(download_path, admin) }
+ subject(:request) { get api(download_path, admin, admin_mode: true) }
end
context 'when project export is enabled' do
context 'when user is an admin' do
let(:user) { admin }
- it_behaves_like 'get project download by strategy'
+ it_behaves_like 'get project download by strategy' do
+ let(:admin_mode) { true }
+ end
context 'when rate limit is exceeded' do
- let(:request) { get api(download_path, admin) }
+ subject(:request) { get api(download_path, admin, admin_mode: true) }
before do
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
@@ -271,7 +288,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
# simulate prior request to the same namespace, which increments the rate limit counter for that scope
Gitlab::ApplicationRateLimiter.throttled?(:project_download_export, scope: [user, project_finished.namespace])
- get api(download_path_finished, user)
+ get api(download_path_finished, user, admin_mode: true)
expect(response).to have_gitlab_http_status(:too_many_requests)
end
@@ -280,7 +297,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
Gitlab::ApplicationRateLimiter.throttled?(:project_download_export,
scope: [user, create(:project, :with_export).namespace])
- get api(download_path_finished, user)
+ get api(download_path_finished, user, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
end
end
@@ -345,30 +362,41 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
describe 'POST /projects/:project_id/export' do
+ let(:admin_mode) { false }
+ let(:params) { {} }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { { 'upload[url]' => 'http://gitlab.com' } }
+ let(:failed_status_code) { :not_found }
+ let(:success_status_code) { :accepted }
+ end
+
+ subject(:request) { post api(path, user, admin_mode: admin_mode), params: params }
+
shared_examples_for 'post project export start not found' do
- it_behaves_like '404 response' do
- let(:request) { post api(path, user) }
- end
+ it_behaves_like '404 response'
end
shared_examples_for 'post project export start denied' do
- it_behaves_like '403 response' do
- let(:request) { post api(path, user) }
- end
+ it_behaves_like '403 response'
end
shared_examples_for 'post project export start' do
+ let_it_be(:admin_mode) { false }
+
context 'with upload strategy' do
context 'when params invalid' do
it_behaves_like '400 response' do
- let(:request) { post(api(path, user), params: { 'upload[url]' => 'whatever' }) }
+ let(:params) { { 'upload[url]' => 'whatever' } }
end
end
it 'starts' do
allow_any_instance_of(Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy).to receive(:send_file)
- post(api(path, user), params: { 'upload[url]' => 'http://gitlab.com' })
+ request do
+ let(:params) { { 'upload[url]' => 'http://gitlab.com' } }
+ end
expect(response).to have_gitlab_http_status(:accepted)
end
@@ -388,7 +416,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
it 'starts' do
expect_any_instance_of(Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy).not_to receive(:send_file)
- post api(path, user)
+ request
expect(response).to have_gitlab_http_status(:accepted)
end
@@ -396,20 +424,21 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
it 'removes previously exported archive file' do
expect(project).to receive(:remove_exports).once
- post api(path, user)
+ request
end
end
end
- it_behaves_like 'when project export is disabled' do
- let(:request) { post api(path, admin) }
- end
+ it_behaves_like 'when project export is disabled'
context 'when project export is enabled' do
context 'when user is an admin' do
let(:user) { admin }
+ let(:admin_mode) { true }
- it_behaves_like 'post project export start'
+ it_behaves_like 'post project export start' do
+ let(:admin_mode) { true }
+ end
context 'with project export size limit' do
before do
@@ -417,7 +446,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
it 'starts if limit not exceeded' do
- post api(path, user)
+ request
expect(response).to have_gitlab_http_status(:accepted)
end
@@ -425,7 +454,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
it '400 response if limit exceeded' do
project.statistics.update!(lfs_objects_size: 2.megabytes, repository_size: 2.megabytes)
- post api(path, user)
+ request
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response["message"]).to include('The project size exceeds the export limit.')
@@ -441,7 +470,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
it 'prevents requesting project export' do
- post api(path, admin)
+ request
expect(response).to have_gitlab_http_status(:too_many_requests)
expect(json_response['message']['error']).to eq('This endpoint has been requested too many times. Try again later.')
@@ -559,7 +588,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
let(:relation) { ::BulkImports::FileTransfer::ProjectConfig.new(project).skipped_relations.first }
it_behaves_like '400 response' do
- let(:request) { get api(download_path, user) }
+ subject(:request) { get api(download_path, user) }
end
end
@@ -595,7 +624,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
describe 'POST /projects/:id/export_relations' do
it_behaves_like '404 response' do
- let(:request) { post api(path, user) }
+ subject(:request) { post api(path, user) }
end
end
@@ -608,13 +637,13 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
end
it_behaves_like '404 response' do
- let(:request) { post api(path, user) }
+ subject(:request) { post api(path, user) }
end
end
describe 'GET /projects/:id/export_relations/status' do
it_behaves_like '404 response' do
- let(:request) { get api(status_path, user) }
+ subject(:request) { get api(status_path, user) }
end
end
end
@@ -629,26 +658,26 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache, feature_category:
describe 'POST /projects/:id/export_relations' do
it_behaves_like '403 response' do
- let(:request) { post api(path, developer) }
+ subject(:request) { post api(path, developer) }
end
end
describe 'GET /projects/:id/export_relations/download' do
it_behaves_like '403 response' do
- let(:request) { get api(download_path, developer) }
+ subject(:request) { get api(download_path, developer) }
end
end
describe 'GET /projects/:id/export_relations/status' do
it_behaves_like '403 response' do
- let(:request) { get api(status_path, developer) }
+ subject(:request) { get api(status_path, developer) }
end
end
end
context 'when bulk import is disabled' do
it_behaves_like '404 response' do
- let(:request) { get api(path, user) }
+ subject(:request) { get api(path, user) }
end
end
end
diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb
index 027c61bb9e1..4496e3aa7c3 100644
--- a/spec/requests/api/project_import_spec.rb
+++ b/spec/requests/api/project_import_spec.rb
@@ -14,6 +14,8 @@ RSpec.describe API::ProjectImport, :aggregate_failures, feature_category: :impor
before do
namespace.add_owner(user) if user
+
+ stub_application_setting(import_sources: ['gitlab_project'])
end
shared_examples 'requires authentication' do
@@ -26,6 +28,20 @@ RSpec.describe API::ProjectImport, :aggregate_failures, feature_category: :impor
end
end
+ shared_examples 'requires import source to be enabled' do
+ context 'when gitlab_project import_sources is disabled' do
+ before do
+ stub_application_setting(import_sources: [])
+ end
+
+ it 'returns 403' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
describe 'POST /projects/import' do
subject { upload_archive(file_upload, workhorse_headers, params) }
@@ -43,6 +59,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures, feature_category: :impor
end
it_behaves_like 'requires authentication'
+ it_behaves_like 'requires import source to be enabled'
it 'executes a limited number of queries', :use_clean_rails_redis_caching do
control_count = ActiveRecord::QueryRecorder.new { subject }.count
@@ -247,7 +264,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures, feature_category: :impor
subject
expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['message']).to eq('Project namespace name has already been taken')
+ expect(json_response['message']).to eq('Path has already been taken')
end
context 'when param overwrite is true' do
@@ -337,6 +354,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures, feature_category: :impor
end
it_behaves_like 'requires authentication'
+ it_behaves_like 'requires import source to be enabled'
context 'when the response is successful' do
it 'schedules the import successfully' do
@@ -402,64 +420,51 @@ RSpec.describe API::ProjectImport, :aggregate_failures, feature_category: :impor
end
it_behaves_like 'requires authentication'
+ it_behaves_like 'requires import source to be enabled'
- it 'returns NOT FOUND when the feature is disabled' do
- stub_feature_flags(import_project_from_remote_file_s3: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
- context 'when the feature flag is enabled' do
- before do
- stub_feature_flags(import_project_from_remote_file_s3: true)
- end
-
- context 'when the response is successful' do
- it 'schedules the import successfully' do
- project = create(
- :project,
- namespace: user.namespace,
- name: 'test-import',
- path: 'test-import'
- )
+ context 'when the response is successful' do
+ it 'schedules the import successfully' do
+ project = create(
+ :project,
+ namespace: user.namespace,
+ name: 'test-import',
+ path: 'test-import'
+ )
- service_response = ServiceResponse.success(payload: project)
- expect_next(::Import::GitlabProjects::CreateProjectService)
- .to receive(:execute)
- .and_return(service_response)
+ service_response = ServiceResponse.success(payload: project)
+ expect_next(::Import::GitlabProjects::CreateProjectService)
+ .to receive(:execute)
+ .and_return(service_response)
- subject
+ subject
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response).to include({
- 'id' => project.id,
- 'name' => 'test-import',
- 'name_with_namespace' => "#{user.namespace.name} / test-import",
- 'path' => 'test-import',
- 'path_with_namespace' => "#{user.namespace.path}/test-import"
- })
- end
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to include({
+ 'id' => project.id,
+ 'name' => 'test-import',
+ 'name_with_namespace' => "#{user.namespace.name} / test-import",
+ 'path' => 'test-import',
+ 'path_with_namespace' => "#{user.namespace.path}/test-import"
+ })
end
+ end
- context 'when the service returns an error' do
- it 'fails to schedule the import' do
- service_response = ServiceResponse.error(
- message: 'Failed to import',
- http_status: :bad_request
- )
- expect_next(::Import::GitlabProjects::CreateProjectService)
- .to receive(:execute)
- .and_return(service_response)
+ context 'when the service returns an error' do
+ it 'fails to schedule the import' do
+ service_response = ServiceResponse.error(
+ message: 'Failed to import',
+ http_status: :bad_request
+ )
+ expect_next(::Import::GitlabProjects::CreateProjectService)
+ .to receive(:execute)
+ .and_return(service_response)
- subject
+ subject
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response).to eq({
- 'message' => 'Failed to import'
- })
- end
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({
+ 'message' => 'Failed to import'
+ })
end
end
end
@@ -510,6 +515,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures, feature_category: :impor
subject { post api('/projects/import/authorize', user), headers: workhorse_headers }
it_behaves_like 'requires authentication'
+ it_behaves_like 'requires import source to be enabled'
it 'authorizes importing project with workhorse header' do
subject
diff --git a/spec/requests/api/project_job_token_scope_spec.rb b/spec/requests/api/project_job_token_scope_spec.rb
new file mode 100644
index 00000000000..df210a00012
--- /dev/null
+++ b/spec/requests/api/project_job_token_scope_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::ProjectJobTokenScope, feature_category: :secrets_management do
+ describe 'GET /projects/:id/job_token_scope' do
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:user) { create(:user) }
+
+ let(:get_job_token_scope_path) { "/projects/#{project.id}/job_token_scope" }
+
+ subject { get api(get_job_token_scope_path, user) }
+
+ context 'when unauthenticated user (missing user)' do
+ context 'for public project' do
+ it 'does not return ci cd settings of job token' do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+
+ get api(get_job_token_scope_path)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+
+ context 'when authenticated user as maintainer' do
+ before_all { project.add_maintainer(user) }
+
+ it 'returns ci cd settings for job token scope' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ "inbound_enabled" => true,
+ "outbound_enabled" => false
+ )
+ end
+
+ it 'returns the correct ci cd settings for job token scope after change' do
+ project.update!(ci_inbound_job_token_scope_enabled: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ "inbound_enabled" => false,
+ "outbound_enabled" => false
+ )
+ end
+
+ it 'returns unauthorized and blank response when invalid auth credentials are given' do
+ invalid_personal_access_token = build(:personal_access_token, user: user)
+
+ get api(get_job_token_scope_path, user, personal_access_token: invalid_personal_access_token)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(json_response).not_to include("inbound_enabled", "outbound_enabled")
+ end
+ end
+
+ context 'when authenticated user as developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'returns forbidden and no ci cd settings for public project' do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response).not_to include("inbound_enabled", "outbound_enabled")
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/project_milestones_spec.rb b/spec/requests/api/project_milestones_spec.rb
index 9d722e4a445..978ac28ef73 100644
--- a/spec/requests/api/project_milestones_spec.rb
+++ b/spec/requests/api/project_milestones_spec.rb
@@ -6,8 +6,12 @@ RSpec.describe API::ProjectMilestones, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be_with_reload(:project) { create(:project, namespace: user.namespace) }
let_it_be(:closed_milestone) { create(:closed_milestone, project: project, title: 'version1', description: 'closed milestone') }
- let_it_be(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') }
let_it_be(:route) { "/projects/#{project.id}/milestones" }
+ let_it_be(:milestone) do
+ create(:milestone, project: project, title: 'version2', description: 'open milestone', updated_at: 5.days.ago)
+ end
+
+ let(:params) { {} }
before_all do
project.add_reporter(user)
@@ -15,38 +19,43 @@ RSpec.describe API::ProjectMilestones, feature_category: :team_planning do
it_behaves_like 'group and project milestones', "/projects/:id/milestones"
+ shared_examples 'listing all milestones' do
+ it 'returns correct list of milestones' do
+ get api(route, user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.size).to eq(milestones.size)
+ expect(json_response.map { |entry| entry["id"] }).to match_array(milestones.map(&:id))
+ end
+ end
+
describe 'GET /projects/:id/milestones' do
- context 'when include_parent_milestones is true' do
- let_it_be(:ancestor_group) { create(:group, :private) }
- let_it_be(:group) { create(:group, :private, parent: ancestor_group) }
- let_it_be(:ancestor_group_milestone) { create(:milestone, group: ancestor_group) }
- let_it_be(:group_milestone) { create(:milestone, group: group) }
+ let_it_be(:ancestor_group) { create(:group, :private) }
+ let_it_be(:group) { create(:group, :private, parent: ancestor_group) }
+ let_it_be(:ancestor_group_milestone) { create(:milestone, group: ancestor_group, updated_at: 1.day.ago) }
+ let_it_be(:group_milestone) { create(:milestone, group: group, updated_at: 3.days.ago) }
- let(:params) { { include_parent_milestones: true } }
+ context 'when project parent is a namespace' do
+ let(:milestones) { [milestone, closed_milestone] }
- shared_examples 'listing all milestones' do
- it 'returns correct list of milestones' do
- get api(route, user), params: params
+ it_behaves_like 'listing all milestones'
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.size).to eq(milestones.size)
- expect(json_response.map { |entry| entry["id"] }).to eq(milestones.map(&:id))
- end
+ context 'when include_parent_milestones is true' do
+ let(:params) { { include_parent_milestones: true } }
+
+ it_behaves_like 'listing all milestones'
end
+ end
- context 'when project parent is a namespace' do
- it_behaves_like 'listing all milestones' do
- let(:milestones) { [milestone, closed_milestone] }
- end
+ context 'when project parent is a group' do
+ before_all do
+ project.update!(namespace: group)
end
- context 'when project parent is a group' do
+ context 'when include_parent_milestones is true' do
+ let(:params) { { include_parent_milestones: true } }
let(:milestones) { [group_milestone, ancestor_group_milestone, milestone, closed_milestone] }
- before_all do
- project.update!(namespace: group)
- end
-
it_behaves_like 'listing all milestones'
context 'when iids param is present' do
@@ -64,6 +73,38 @@ RSpec.describe API::ProjectMilestones, feature_category: :team_planning do
expect(response).to have_gitlab_http_status(:not_found)
end
end
+
+ context 'when updated_before param is present' do
+ let(:params) { { updated_before: 12.hours.ago.iso8601, include_parent_milestones: true } }
+
+ it_behaves_like 'listing all milestones' do
+ let(:milestones) { [group_milestone, ancestor_group_milestone, milestone] }
+ end
+ end
+
+ context 'when updated_after param is present' do
+ let(:params) { { updated_after: 2.days.ago.iso8601, include_parent_milestones: true } }
+
+ it_behaves_like 'listing all milestones' do
+ let(:milestones) { [ancestor_group_milestone, closed_milestone] }
+ end
+ end
+ end
+
+ context 'when updated_before param is present' do
+ let(:params) { { updated_before: 12.hours.ago.iso8601 } }
+
+ it_behaves_like 'listing all milestones' do
+ let(:milestones) { [milestone] }
+ end
+ end
+
+ context 'when updated_after param is present' do
+ let(:params) { { updated_after: 2.days.ago.iso8601 } }
+
+ it_behaves_like 'listing all milestones' do
+ let(:milestones) { [closed_milestone] }
+ end
end
end
end
diff --git a/spec/requests/api/project_snapshots_spec.rb b/spec/requests/api/project_snapshots_spec.rb
index 5d3c596e605..cbf6907f9a3 100644
--- a/spec/requests/api/project_snapshots_spec.rb
+++ b/spec/requests/api/project_snapshots_spec.rb
@@ -2,11 +2,12 @@
require 'spec_helper'
-RSpec.describe API::ProjectSnapshots, feature_category: :source_code_management do
+RSpec.describe API::ProjectSnapshots, :aggregate_failures, feature_category: :source_code_management do
include WorkhorseHelpers
let(:project) { create(:project) }
let(:admin) { create(:admin) }
+ let(:path) { "/projects/#{project.id}/snapshot" }
before do
allow(Feature::Gitaly).to receive(:server_feature_flags).and_return({
@@ -32,27 +33,29 @@ RSpec.describe API::ProjectSnapshots, feature_category: :source_code_management
expect(response.parsed_body).to be_empty
end
+ it_behaves_like 'GET request permissions for admin mode'
+
it 'returns authentication error as project owner' do
- get api("/projects/#{project.id}/snapshot", project.first_owner)
+ get api(path, project.first_owner)
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'returns authentication error as unauthenticated user' do
- get api("/projects/#{project.id}/snapshot", nil)
+ get api(path, nil)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'requests project repository raw archive as administrator' do
- get api("/projects/#{project.id}/snapshot", admin), params: { wiki: '0' }
+ get api(path, admin, admin_mode: true), params: { wiki: '0' }
expect(response).to have_gitlab_http_status(:ok)
expect_snapshot_response_for(project.repository)
end
it 'requests wiki repository raw archive as administrator' do
- get api("/projects/#{project.id}/snapshot", admin), params: { wiki: '1' }
+ get api(path, admin, admin_mode: true), params: { wiki: '1' }
expect(response).to have_gitlab_http_status(:ok)
expect_snapshot_response_for(project.wiki.repository)
diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb
index 267557b8137..f0aa61c688b 100644
--- a/spec/requests/api/project_snippets_spec.rb
+++ b/spec/requests/api/project_snippets_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::ProjectSnippets, feature_category: :source_code_management do
+RSpec.describe API::ProjectSnippets, :aggregate_failures, feature_category: :source_code_management do
include SnippetHelpers
let_it_be(:project) { create(:project, :public) }
@@ -14,8 +14,12 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
describe "GET /projects/:project_id/snippets/:id/user_agent_detail" do
let_it_be(:user_agent_detail) { create(:user_agent_detail, subject: public_snippet) }
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { "/projects/#{public_snippet.project.id}/snippets/#{public_snippet.id}/user_agent_detail" }
+ end
+
it 'exposes known attributes' do
- get api("/projects/#{project.id}/snippets/#{public_snippet.id}/user_agent_detail", admin)
+ get api("/projects/#{project.id}/snippets/#{public_snippet.id}/user_agent_detail", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['user_agent']).to eq(user_agent_detail.user_agent)
@@ -26,7 +30,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
it 'respects project scoping' do
other_project = create(:project)
- get api("/projects/#{other_project.id}/snippets/#{public_snippet.id}/user_agent_detail", admin)
+ get api("/projects/#{other_project.id}/snippets/#{public_snippet.id}/user_agent_detail", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -38,7 +42,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
context 'with snippets disabled' do
it_behaves_like '403 response' do
- let(:request) { get api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}/user_agent_detail", admin) }
+ subject(:request) { get api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}/user_agent_detail", admin, admin_mode: true) }
end
end
end
@@ -72,7 +76,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
context 'with snippets disabled' do
it_behaves_like '403 response' do
- let(:request) { get api("/projects/#{project_no_snippets.id}/snippets", user) }
+ subject(:request) { get api("/projects/#{project_no_snippets.id}/snippets", user) }
end
end
end
@@ -83,16 +87,14 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
it 'returns snippet json' do
get api("/projects/#{project.id}/snippets/#{snippet.id}", user)
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['title']).to eq(snippet.title)
- expect(json_response['description']).to eq(snippet.description)
- expect(json_response['file_name']).to eq(snippet.file_name_on_repo)
- expect(json_response['files']).to eq(snippet.blobs.map { |blob| snippet_blob_file(blob) })
- expect(json_response['ssh_url_to_repo']).to eq(snippet.ssh_url_to_repo)
- expect(json_response['http_url_to_repo']).to eq(snippet.http_url_to_repo)
- end
+ expect(json_response['title']).to eq(snippet.title)
+ expect(json_response['description']).to eq(snippet.description)
+ expect(json_response['file_name']).to eq(snippet.file_name_on_repo)
+ expect(json_response['files']).to eq(snippet.blobs.map { |blob| snippet_blob_file(blob) })
+ expect(json_response['ssh_url_to_repo']).to eq(snippet.ssh_url_to_repo)
+ expect(json_response['http_url_to_repo']).to eq(snippet.http_url_to_repo)
end
it 'returns 404 for invalid snippet id' do
@@ -104,7 +106,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
context 'with snippets disabled' do
it_behaves_like '403 response' do
- let(:request) { get api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}", user) }
+ subject(:request) { get api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}", user) }
end
end
@@ -126,22 +128,25 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
let(:file_content) { 'puts "hello world"' }
let(:file_params) { { files: [{ file_path: file_path, content: file_content }] } }
let(:params) { base_params.merge(file_params) }
+ let(:admin_mode) { false }
+
+ subject(:request) { post api("/projects/#{project.id}/snippets/", actor, admin_mode: admin_mode), params: params }
- subject { post api("/projects/#{project.id}/snippets/", actor), params: params }
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:path) { "/projects/#{project.id}/snippets/" }
+ end
shared_examples 'project snippet repository actions' do
let(:snippet) { ProjectSnippet.find(json_response['id']) }
it 'commit the files to the repository' do
- subject
+ request
- aggregate_failures do
- expect(snippet.repository.exists?).to be_truthy
+ expect(snippet.repository.exists?).to be_truthy
- blob = snippet.repository.blob_at(snippet.default_branch, file_path)
+ blob = snippet.repository.blob_at(snippet.default_branch, file_path)
- expect(blob.data).to eq file_content
- end
+ expect(blob.data).to eq file_content
end
end
@@ -152,7 +157,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
it 'creates a new snippet' do
project.add_developer(actor)
- subject
+ request
expect(response).to have_gitlab_http_status(:created)
end
@@ -160,7 +165,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
context 'that does not belong to the project' do
it 'does not create a new snippet' do
- subject
+ request
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -180,7 +185,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
end
it 'creates a new snippet' do
- subject
+ request
expect(response).to have_gitlab_http_status(:created)
snippet = ProjectSnippet.find(json_response['id'])
@@ -196,9 +201,10 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
context 'with an admin' do
let(:actor) { admin }
+ let(:admin_mode) { true }
it 'creates a new snippet' do
- subject
+ request
expect(response).to have_gitlab_http_status(:created)
snippet = ProjectSnippet.find(json_response['id'])
@@ -214,7 +220,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
it 'returns 400 for missing parameters' do
params.delete(:title)
- subject
+ request
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -226,7 +232,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
it 'returns 400 if title is blank' do
params[:title] = ''
- subject
+ request
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq 'title is empty'
@@ -235,6 +241,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
context 'when save fails because the repository could not be created' do
let(:actor) { admin }
+ let(:admin_mode) { true }
before do
allow_next_instance_of(Snippets::CreateService) do |instance|
@@ -243,7 +250,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
end
it 'returns 400' do
- subject
+ request
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -264,7 +271,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
it 'creates the snippet' do
params['visibility'] = 'private'
- expect { subject }.to change { Snippet.count }.by(1)
+ expect { request }.to change { Snippet.count }.by(1)
end
end
@@ -274,13 +281,13 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
end
it 'rejects the snippet' do
- expect { subject }.not_to change { Snippet.count }
+ expect { request }.not_to change { Snippet.count }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['error']).to match(/snippet has been recognized as spam/)
end
it 'creates a spam log' do
- expect { subject }
+ expect { request }
.to log_spam(title: 'Test Title', user_id: user.id, noteable_type: 'ProjectSnippet')
end
end
@@ -288,7 +295,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
context 'with snippets disabled' do
it_behaves_like '403 response' do
- let(:request) { post api("/projects/#{project_no_snippets.id}/snippets", user), params: params }
+ subject(:request) { post api("/projects/#{project_no_snippets.id}/snippets", user), params: params }
end
end
end
@@ -296,6 +303,11 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
describe 'PUT /projects/:project_id/snippets/:id/' do
let(:visibility_level) { Snippet::PUBLIC }
let(:snippet) { create(:project_snippet, :repository, author: admin, visibility_level: visibility_level, project: project) }
+ let(:params) { { title: 'Foo' } }
+
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:path) { "/projects/#{snippet.project.id}/snippets/#{snippet.id}" }
+ end
it_behaves_like 'snippet file updates'
it_behaves_like 'snippet non-file updates'
@@ -317,7 +329,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
let(:visibility_level) { Snippet::PRIVATE }
it 'creates the snippet' do
- expect { update_snippet(params: { title: 'Foo' }) }
+ expect { update_snippet(admin_mode: true, params: params) }
.to change { snippet.reload.title }.to('Foo')
end
end
@@ -326,12 +338,12 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the snippet' do
- expect { update_snippet(params: { title: 'Foo' }) }
+ expect { update_snippet(params: params) }
.not_to change { snippet.reload.title }
end
it 'creates a spam log' do
- expect { update_snippet(params: { title: 'Foo' }) }
+ expect { update_snippet(params: params) }
.to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet')
end
end
@@ -340,7 +352,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
let(:visibility_level) { Snippet::PRIVATE }
it 'rejects the snippet' do
- expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) }
+ expect { update_snippet(admin_mode: true, params: { title: 'Foo', visibility: 'public' }) }
.not_to change { snippet.reload.title }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -348,7 +360,7 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
end
it 'creates a spam log' do
- expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) }
+ expect { update_snippet(admin_mode: true, params: { title: 'Foo', visibility: 'public' }) }
.to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet')
end
end
@@ -356,47 +368,58 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
context 'with snippets disabled' do
it_behaves_like '403 response' do
- let(:request) { put api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}", admin), params: { description: 'foo' } }
+ subject(:request) { put api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}", admin, admin_mode: true), params: { description: 'foo' } }
end
end
- def update_snippet(snippet_id: snippet.id, params: {})
- put api("/projects/#{snippet.project.id}/snippets/#{snippet_id}", admin), params: params
+ def update_snippet(snippet_id: snippet.id, admin_mode: false, params: {})
+ put api("/projects/#{snippet.project.id}/snippets/#{snippet_id}", admin, admin_mode: admin_mode), params: params
end
end
describe 'DELETE /projects/:project_id/snippets/:id/' do
let_it_be(:snippet, refind: true) { public_snippet }
+ let(:path) { "/projects/#{snippet.project.id}/snippets/#{snippet.id}/" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
it 'deletes snippet' do
- delete api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end
it 'returns 404 for invalid snippet id' do
- delete api("/projects/#{snippet.project.id}/snippets/#{non_existing_record_id}", admin)
+ delete api("/projects/#{snippet.project.id}/snippets/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Snippet Not Found')
end
it_behaves_like '412 response' do
- let(:request) { api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin) }
+ subject(:request) { api(path, admin, admin_mode: true) }
end
context 'with snippets disabled' do
it_behaves_like '403 response' do
- let(:request) { delete api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}", admin) }
+ subject(:request) { delete api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}", admin, admin_mode: true) }
end
end
end
describe 'GET /projects/:project_id/snippets/:id/raw' do
let_it_be(:snippet) { create(:project_snippet, :repository, :public, author: admin, project: project) }
+ let(:path) { "/projects/#{snippet.project.id}/snippets/#{snippet.id}/raw" }
+
+ it_behaves_like 'GET request permissions for admin mode' do
+ let_it_be(:snippet_with_empty_repo) { create(:project_snippet, :empty_repo, author: admin, project: project) }
+
+ let(:snippet) { snippet_with_empty_repo }
+ let(:failed_status_code) { :not_found }
+ end
it 'returns raw text' do
- get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/raw", admin)
+ get api(path, admin)
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq 'text/plain'
@@ -404,38 +427,41 @@ RSpec.describe API::ProjectSnippets, feature_category: :source_code_management d
end
it 'returns 404 for invalid snippet id' do
- get api("/projects/#{snippet.project.id}/snippets/#{non_existing_record_id}/raw", admin)
+ get api("/projects/#{snippet.project.id}/snippets/#{non_existing_record_id}/raw", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Snippet Not Found')
end
- it_behaves_like 'project snippet access levels' do
- let(:path) { "/projects/#{snippet.project.id}/snippets/#{snippet.id}/raw" }
- end
+ it_behaves_like 'project snippet access levels'
context 'with snippets disabled' do
it_behaves_like '403 response' do
- let(:request) { get api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}/raw", admin) }
+ subject(:request) { get api("/projects/#{project_no_snippets.id}/snippets/#{non_existing_record_id}/raw", admin, admin_mode: true) }
end
end
it_behaves_like 'snippet blob content' do
let_it_be(:snippet_with_empty_repo) { create(:project_snippet, :empty_repo, author: admin, project: project) }
+ let_it_be(:admin_mode) { snippet.author.admin? }
- subject { get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/raw", snippet.author) }
+ subject { get api(path, snippet.author, admin_mode: admin_mode) }
end
end
describe 'GET /projects/:project_id/snippets/:id/files/:ref/:file_path/raw' do
let_it_be(:snippet) { create(:project_snippet, :repository, author: admin, project: project) }
+ let(:path) { "/projects/#{snippet.project.id}/snippets/#{snippet.id}/files/master/%2Egitattributes/raw" }
+
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
+
it_behaves_like 'raw snippet files' do
let(:api_path) { "/projects/#{snippet.project.id}/snippets/#{snippet_id}/files/#{ref}/#{file_path}/raw" }
end
- it_behaves_like 'project snippet access levels' do
- let(:path) { "/projects/#{snippet.project.id}/snippets/#{snippet.id}/files/master/%2Egitattributes/raw" }
- end
+ it_behaves_like 'project snippet access levels'
end
end
diff --git a/spec/requests/api/project_templates_spec.rb b/spec/requests/api/project_templates_spec.rb
index 38d6a05a104..91e5ed76c37 100644
--- a/spec/requests/api/project_templates_spec.rb
+++ b/spec/requests/api/project_templates_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe API::ProjectTemplates, feature_category: :source_code_management
let(:url_encoded_path) { "#{public_project.namespace.path}%2F#{public_project.path}" }
before do
+ stub_feature_flags(remove_monitor_metrics: false)
private_project.add_developer(developer)
end
@@ -71,6 +72,18 @@ RSpec.describe API::ProjectTemplates, feature_category: :source_code_management
expect(json_response).to satisfy_one { |template| template['key'] == 'Default' }
end
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 400 bad request like other unknown types' do
+ get api("/projects/#{public_project.id}/templates/metrics_dashboard_ymls")
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
it 'returns issue templates' do
get api("/projects/#{private_project.id}/templates/issues", developer)
@@ -171,6 +184,18 @@ RSpec.describe API::ProjectTemplates, feature_category: :source_code_management
expect(json_response['name']).to eq('Default')
end
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns 400 bad request like other unknown types' do
+ get api("/projects/#{public_project.id}/templates/metrics_dashboard_ymls/Default")
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
it 'returns a specific license' do
get api("/projects/#{public_project.id}/templates/licenses/mit")
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index e78ef2f7630..349101a092f 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -15,7 +15,7 @@ RSpec.shared_examples 'languages and percentages JSON response' do
end
context "when the languages haven't been detected yet" do
- it 'returns expected language values', :sidekiq_might_not_need_inline do
+ it 'returns expected language values', :aggregate_failures, :sidekiq_might_not_need_inline do
get api("/projects/#{project.id}/languages", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -33,7 +33,7 @@ RSpec.shared_examples 'languages and percentages JSON response' do
Projects::DetectRepositoryLanguagesService.new(project, project.first_owner).execute
end
- it 'returns the detection from the database' do
+ it 'returns the detection from the database', :aggregate_failures do
# Allow this to happen once, so the expected languages can be determined
expect(project.repository).to receive(:languages).once
@@ -46,7 +46,7 @@ RSpec.shared_examples 'languages and percentages JSON response' do
end
end
-RSpec.describe API::Projects, feature_category: :projects do
+RSpec.describe API::Projects, :aggregate_failures, feature_category: :projects do
include ProjectForksHelper
include WorkhorseHelpers
include StubRequests
@@ -55,16 +55,14 @@ RSpec.describe API::Projects, feature_category: :projects do
let_it_be(:user2) { create(:user) }
let_it_be(:user3) { create(:user) }
let_it_be(:admin) { create(:admin) }
- let_it_be(:project, reload: true) { create(:project, :repository, create_branch: 'something_else', namespace: user.namespace) }
- let_it_be(:project2, reload: true) { create(:project, namespace: user.namespace) }
+ let_it_be(:project, reload: true) { create(:project, :repository, create_branch: 'something_else', namespace: user.namespace, updated_at: 5.days.ago) }
+ let_it_be(:project2, reload: true) { create(:project, namespace: user.namespace, updated_at: 4.days.ago) }
let_it_be(:project_member) { create(:project_member, :developer, user: user3, project: project) }
let_it_be(:user4) { create(:user, username: 'user.withdot') }
let_it_be(:project3, reload: true) do
create(:project,
:private,
:repository,
- name: 'second_project',
- path: 'second_project',
creator_id: user.id,
namespace: user.namespace,
merge_requests_enabled: false,
@@ -82,8 +80,6 @@ RSpec.describe API::Projects, feature_category: :projects do
let_it_be(:project4, reload: true) do
create(:project,
- name: 'third_project',
- path: 'third_project',
creator_id: user4.id,
namespace: user4.namespace)
end
@@ -149,9 +145,15 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'GET /projects' do
+ let(:path) { '/projects' }
+
+ let_it_be(:public_project) { create(:project, :public, name: 'public_project') }
+
shared_examples_for 'projects response' do
+ let_it_be(:admin_mode) { false }
+
it 'returns an array of projects' do
- get api('/projects', current_user), params: filter
+ get api(path, current_user, admin_mode: admin_mode), params: filter
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -160,7 +162,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns the proper security headers' do
- get api('/projects', current_user), params: filter
+ get api(path, current_user, admin_mode: admin_mode), params: filter
expect(response).to include_security_headers
end
@@ -171,22 +173,20 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'avoids N + 1 queries', :use_sql_query_cache do
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api('/projects', current_user)
+ get api(path, current_user)
end
additional_project
expect do
- get api('/projects', current_user)
+ get api(path, current_user)
end.not_to exceed_all_query_limit(control).with_threshold(threshold)
end
end
- let_it_be(:public_project) { create(:project, :public, name: 'public_project') }
-
context 'when unauthenticated' do
it_behaves_like 'projects response' do
- let(:filter) { { search: project.name } }
+ let(:filter) { { search: project.path } }
let(:current_user) { user }
let(:projects) { [project] }
end
@@ -208,10 +208,10 @@ RSpec.describe API::Projects, feature_category: :projects do
end
shared_examples 'includes container_registry_access_level' do
- it do
+ specify do
project.project_feature.update!(container_registry_access_level: ProjectFeature::DISABLED)
- get api('/projects', user)
+ get api(path, user)
project_response = json_response.find { |p| p['id'] == project.id }
expect(response).to have_gitlab_http_status(:ok)
@@ -231,8 +231,8 @@ RSpec.describe API::Projects, feature_category: :projects do
include_examples 'includes container_registry_access_level'
end
- it 'includes various project feature fields', :aggregate_failures do
- get api('/projects', user)
+ it 'includes various project feature fields' do
+ get api(path, user)
project_response = json_response.find { |p| p['id'] == project.id }
expect(response).to have_gitlab_http_status(:ok)
@@ -254,10 +254,10 @@ RSpec.describe API::Projects, feature_category: :projects do
end
end
- it 'includes correct value of container_registry_enabled', :aggregate_failures do
+ it 'includes correct value of container_registry_enabled' do
project.project_feature.update!(container_registry_access_level: ProjectFeature::DISABLED)
- get api('/projects', user)
+ get api(path, user)
project_response = json_response.find { |p| p['id'] == project.id }
expect(response).to have_gitlab_http_status(:ok)
@@ -266,7 +266,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'includes project topics' do
- get api('/projects', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -276,7 +276,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'includes open_issues_count' do
- get api('/projects', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -287,7 +287,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'does not include projects marked for deletion' do
project.update!(pending_delete: true)
- get api('/projects', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
@@ -297,7 +297,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'does not include open_issues_count if issues are disabled' do
project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
- get api('/projects', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -311,7 +311,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns no projects' do
- get api('/projects', user), params: { topic: 'foo' }
+ get api(path, user), params: { topic: 'foo' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -319,7 +319,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns matching project for a single topic' do
- get api('/projects', user), params: { topic: 'ruby' }
+ get api(path, user), params: { topic: 'ruby' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -327,7 +327,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns matching project for multiple topics' do
- get api('/projects', user), params: { topic: 'ruby, javascript' }
+ get api(path, user), params: { topic: 'ruby, javascript' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -335,7 +335,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns no projects if project match only some topic' do
- get api('/projects', user), params: { topic: 'ruby, foo' }
+ get api(path, user), params: { topic: 'ruby, foo' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -343,7 +343,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'ignores topic if it is empty' do
- get api('/projects', user), params: { topic: '' }
+ get api(path, user), params: { topic: '' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -404,7 +404,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it "does not include statistics by default" do
- get api('/projects', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -413,7 +413,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it "includes statistics if requested" do
- get api('/projects', user), params: { statistics: true }
+ get api(path, user), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -425,7 +425,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it "does not include license by default" do
- get api('/projects', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -434,7 +434,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it "does not include license if requested" do
- get api('/projects', user), params: { license: true }
+ get api(path, user), params: { license: true }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -446,7 +446,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let!(:jira_integration) { create(:jira_integration, project: project) }
it 'includes open_issues_count' do
- get api('/projects', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -458,7 +458,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'does not include open_issues_count if issues are disabled' do
project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
- get api('/projects', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -501,7 +501,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns every project' do
- get api('/projects', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -510,9 +510,38 @@ RSpec.describe API::Projects, feature_category: :projects do
end
end
+ context 'filter by updated_at' do
+ let(:filter) { { updated_before: 2.days.ago.iso8601, updated_after: 6.days.ago, order_by: :updated_at } }
+
+ it_behaves_like 'projects response' do
+ let(:current_user) { user }
+ let(:projects) { [project2, project] }
+ end
+
+ it 'returns projects sorted by updated_at' do
+ get api(path, user), params: filter
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |p| p['id'] }).to match([project2, project].map(&:id))
+ end
+
+ context 'when filtering by updated_at and sorting by a different column' do
+ let(:filter) { { updated_before: 2.days.ago.iso8601, updated_after: 6.days.ago, order_by: 'id' } }
+
+ it 'returns an error' do
+ get api(path, user), params: filter
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq(
+ '400 Bad request - `updated_at` filter and `updated_at` sorting must be paired'
+ )
+ end
+ end
+ end
+
context 'and using search' do
it_behaves_like 'projects response' do
- let(:filter) { { search: project.name } }
+ let(:filter) { { search: project.path } }
let(:current_user) { user }
let(:projects) { [project] }
end
@@ -583,7 +612,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'and using the visibility filter' do
it 'filters based on private visibility param' do
- get api('/projects', user), params: { visibility: 'private' }
+ get api(path, user), params: { visibility: 'private' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -594,7 +623,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'filters based on internal visibility param' do
project2.update_attribute(:visibility_level, Gitlab::VisibilityLevel::INTERNAL)
- get api('/projects', user), params: { visibility: 'internal' }
+ get api(path, user), params: { visibility: 'internal' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -603,7 +632,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'filters based on public visibility param' do
- get api('/projects', user), params: { visibility: 'public' }
+ get api(path, user), params: { visibility: 'public' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -616,7 +645,7 @@ RSpec.describe API::Projects, feature_category: :projects do
include_context 'with language detection'
it 'filters case-insensitively by programming language' do
- get api('/projects', user), params: { with_programming_language: 'javascript' }
+ get api(path, user), params: { with_programming_language: 'javascript' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -627,7 +656,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'and using sorting' do
it 'returns the correct order when sorted by id' do
- get api('/projects', user), params: { order_by: 'id', sort: 'desc' }
+ get api(path, user), params: { order_by: 'id', sort: 'desc' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -638,7 +667,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'and with owned=true' do
it 'returns an array of projects the user owns' do
- get api('/projects', user4), params: { owned: true }
+ get api(path, user4), params: { owned: true }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -659,7 +688,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'does not list as owned project for admin' do
- get api('/projects', admin), params: { owned: true }
+ get api(path, admin, admin_mode: true), params: { owned: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_empty
@@ -675,7 +704,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns the starred projects viewable by the user' do
- get api('/projects', user3), params: { starred: true }
+ get api(path, user3), params: { starred: true }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -697,7 +726,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'including owned filter' do
it 'returns only projects that satisfy all query parameters' do
- get api('/projects', user), params: { visibility: 'public', owned: true, starred: true, search: 'gitlab' }
+ get api(path, user), params: { visibility: 'public', owned: true, starred: true, search: 'gitlab' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -716,7 +745,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns only projects that satisfy all query parameters' do
- get api('/projects', user), params: { visibility: 'public', membership: true, starred: true, search: 'gitlab' }
+ get api(path, user), params: { visibility: 'public', membership: true, starred: true, search: 'gitlab' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -735,7 +764,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns an array of projects the user has at least developer access' do
- get api('/projects', user2), params: { min_access_level: 30 }
+ get api(path, user2), params: { min_access_level: 30 }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -797,6 +826,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it_behaves_like 'projects response' do
let(:filter) { {} }
let(:current_user) { admin }
+ let(:admin_mode) { true }
let(:projects) { Project.all }
end
end
@@ -810,7 +840,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:current_user) { user }
let(:params) { {} }
- subject { get api('/projects', current_user), params: params }
+ subject(:request) { get api(path, current_user), params: params }
before do
group_with_projects.add_owner(current_user) if current_user
@@ -818,7 +848,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'orders by id desc instead' do
projects_ordered_by_id_desc = /SELECT "projects".+ORDER BY "projects"."id" DESC/i
- expect { subject }.to make_queries_matching projects_ordered_by_id_desc
+ expect { request }.to make_queries_matching projects_ordered_by_id_desc
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -842,7 +872,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context "when sorting by #{order_by} ascendingly" do
it 'returns a properly sorted list of projects' do
- get api('/projects', current_user), params: { order_by: order_by, sort: :asc }
+ get api(path, current_user, admin_mode: true), params: { order_by: order_by, sort: :asc }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -853,7 +883,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context "when sorting by #{order_by} descendingly" do
it 'returns a properly sorted list of projects' do
- get api('/projects', current_user), params: { order_by: order_by, sort: :desc }
+ get api(path, current_user, admin_mode: true), params: { order_by: order_by, sort: :desc }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -867,7 +897,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:current_user) { user }
it 'returns projects ordered normally' do
- get api('/projects', current_user), params: { order_by: order_by }
+ get api(path, current_user), params: { order_by: order_by }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -879,7 +909,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
end
- context 'by similarity', :aggregate_failures do
+ context 'by similarity' do
let_it_be(:group_with_projects) { create(:group) }
let_it_be(:project_1) { create(:project, name: 'Project', path: 'project', group: group_with_projects) }
let_it_be(:project_2) { create(:project, name: 'Test Project', path: 'test-project', group: group_with_projects) }
@@ -889,14 +919,14 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:current_user) { user }
let(:params) { { order_by: 'similarity', search: 'test' } }
- subject { get api('/projects', current_user), params: params }
+ subject(:request) { get api(path, current_user), params: params }
before do
group_with_projects.add_owner(current_user) if current_user
end
it 'returns non-public items based ordered by similarity' do
- subject
+ request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -910,14 +940,14 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:params) { { order_by: 'similarity' } }
it 'returns items ordered by created_at descending' do
- subject
+ request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response.length).to eq(8)
project_names = json_response.map { |proj| proj['name'] }
- expect(project_names).to contain_exactly(project.name, project2.name, 'second_project', 'public_project', 'Project', 'Test Project', 'Test Public Project', 'Test')
+ expect(project_names).to match_array([project, project2, project3, public_project, project_1, project_2, project_4, project_3].map(&:name))
end
end
@@ -925,14 +955,14 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:current_user) { nil }
it 'returns items ordered by created_at descending' do
- subject
+ request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response.length).to eq(1)
project_names = json_response.map { |proj| proj['name'] }
- expect(project_names).to contain_exactly('Test Public Project')
+ expect(project_names).to contain_exactly(project_4.name)
end
end
end
@@ -952,6 +982,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it_behaves_like 'projects response' do
let(:filter) { { repository_storage: 'nfs-11' } }
let(:current_user) { admin }
+ let(:admin_mode) { true }
let(:projects) { [project, project3] }
end
end
@@ -974,7 +1005,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:params) { { pagination: 'keyset', order_by: :id, sort: :asc, per_page: 1 } }
it 'includes a pagination header with link to the next page' do
- get api('/projects', current_user), params: params
+ get api(path, current_user), params: params
expect(response.header).to include('Link')
expect(response.header['Link']).to include('pagination=keyset')
@@ -982,7 +1013,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'contains only the first project with per_page = 1' do
- get api('/projects', current_user), params: params
+ get api(path, current_user), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
@@ -990,7 +1021,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'still includes a link if the end has reached and there is no more data after this page' do
- get api('/projects', current_user), params: params.merge(id_after: project2.id)
+ get api(path, current_user), params: params.merge(id_after: project2.id)
expect(response.header).to include('Link')
expect(response.header['Link']).to include('pagination=keyset')
@@ -998,20 +1029,20 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'does not include a next link when the page does not have any records' do
- get api('/projects', current_user), params: params.merge(id_after: Project.maximum(:id))
+ get api(path, current_user), params: params.merge(id_after: Project.maximum(:id))
expect(response.header).not_to include('Link')
end
it 'returns an empty array when the page does not have any records' do
- get api('/projects', current_user), params: params.merge(id_after: Project.maximum(:id))
+ get api(path, current_user), params: params.merge(id_after: Project.maximum(:id))
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq([])
end
it 'responds with 501 if order_by is different from id' do
- get api('/projects', current_user), params: params.merge(order_by: :created_at)
+ get api(path, current_user), params: params.merge(order_by: :created_at)
expect(response).to have_gitlab_http_status(:method_not_allowed)
end
@@ -1021,7 +1052,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:params) { { pagination: 'keyset', order_by: :id, sort: :desc, per_page: 1 } }
it 'includes a pagination header with link to the next page' do
- get api('/projects', current_user), params: params
+ get api(path, current_user), params: params
expect(response.header).to include('Link')
expect(response.header['Link']).to include('pagination=keyset')
@@ -1029,7 +1060,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'contains only the last project with per_page = 1' do
- get api('/projects', current_user), params: params
+ get api(path, current_user), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
@@ -1041,7 +1072,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:params) { { pagination: 'keyset', order_by: :id, sort: :desc, per_page: 2 } }
it 'returns all projects' do
- url = '/projects'
+ url = path
requests = 0
ids = []
@@ -1067,8 +1098,11 @@ RSpec.describe API::Projects, feature_category: :projects do
let_it_be(:admin) { create(:admin) }
+ subject(:request) { get api(path, admin) }
+
it 'avoids N+1 queries', :use_sql_query_cache do
- get api('/projects', admin)
+ request
+ expect(response).to have_gitlab_http_status(:ok)
base_project = create(:project, :public, namespace: admin.namespace)
@@ -1076,53 +1110,94 @@ RSpec.describe API::Projects, feature_category: :projects do
fork_project2 = fork_project(fork_project1, admin, namespace: create(:user).namespace)
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api('/projects', admin)
+ request
end
fork_project(fork_project2, admin, namespace: create(:user).namespace)
expect do
- get api('/projects', admin)
- end.not_to exceed_query_limit(control.count)
+ request
+ end.not_to exceed_all_query_limit(control.count)
end
end
context 'when service desk is enabled', :use_clean_rails_memory_store_caching do
let_it_be(:admin) { create(:admin) }
+ subject(:request) { get api(path, admin) }
+
it 'avoids N+1 queries' do
- allow(Gitlab::ServiceDeskEmail).to receive(:enabled?).and_return(true)
- allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
+ allow(Gitlab::Email::ServiceDeskEmail).to receive(:enabled?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(true)
- get api('/projects', admin)
+ request
+ expect(response).to have_gitlab_http_status(:ok)
create(:project, :public, :service_desk_enabled, namespace: admin.namespace)
control = ActiveRecord::QueryRecorder.new do
- get api('/projects', admin)
+ request
end
create_list(:project, 2, :public, :service_desk_enabled, namespace: admin.namespace)
expect do
- get api('/projects', admin)
- end.not_to exceed_query_limit(control)
+ request
+ end.not_to exceed_all_query_limit(control)
+ end
+ end
+
+ context 'rate limiting' do
+ let_it_be(:current_user) { create(:user) }
+
+ shared_examples_for 'does not log request and does not block the request' do
+ specify do
+ request
+ request
+
+ expect(response).not_to have_gitlab_http_status(:too_many_requests)
+ expect(Gitlab::AuthLogger).not_to receive(:error)
+ end
+ end
+
+ before do
+ stub_application_setting(projects_api_rate_limit_unauthenticated: 1)
+ end
+
+ context 'when the user is signed in' do
+ it_behaves_like 'does not log request and does not block the request' do
+ def request
+ get api(path, current_user)
+ end
+ end
+ end
+
+ context 'when the user is not signed in' do
+ let_it_be(:current_user) { nil }
+
+ it_behaves_like 'rate limited endpoint', rate_limit_key: :projects_api_rate_limit_unauthenticated do
+ def request
+ get api(path, current_user)
+ end
+ end
end
end
end
describe 'POST /projects' do
+ let(:path) { '/projects' }
+
context 'maximum number of projects reached' do
it 'does not create new project and respond with 403' do
allow_any_instance_of(User).to receive(:projects_limit_left).and_return(0)
- expect { post api('/projects', user2), params: { name: 'foo' } }
+ expect { post api(path, user2), params: { name: 'foo' } }
.to change { Project.count }.by(0)
expect(response).to have_gitlab_http_status(:forbidden)
end
end
it 'creates new project without path but with name and returns 201' do
- expect { post api('/projects', user), params: { name: 'Foo Project' } }
+ expect { post api(path, user), params: { name: 'Foo Project' } }
.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -1133,7 +1208,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'creates new project without name but with path and returns 201' do
- expect { post api('/projects', user), params: { path: 'foo_project' } }
+ expect { post api(path, user), params: { path: 'foo_project' } }
.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -1144,7 +1219,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'creates new project with name and path and returns 201' do
- expect { post api('/projects', user), params: { path: 'path-project-Foo', name: 'Foo Project' } }
+ expect { post api(path, user), params: { path: 'path-project-Foo', name: 'Foo Project' } }
.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -1155,21 +1230,21 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it_behaves_like 'create project with default branch parameter' do
- let(:request) { post api('/projects', user), params: params }
+ subject(:request) { post api(path, user), params: params }
end
it 'creates last project before reaching project limit' do
allow_any_instance_of(User).to receive(:projects_limit_left).and_return(1)
- post api('/projects', user2), params: { name: 'foo' }
+ post api(path, user2), params: { name: 'foo' }
expect(response).to have_gitlab_http_status(:created)
end
it 'does not create new project without name or path and returns 400' do
- expect { post api('/projects', user) }.not_to change { Project.count }
+ expect { post api(path, user) }.not_to change { Project.count }
expect(response).to have_gitlab_http_status(:bad_request)
end
- it 'assigns attributes to project', :aggregate_failures do
+ it 'assigns attributes to project' do
project = attributes_for(:project, {
path: 'camelCasePath',
issues_enabled: false,
@@ -1189,7 +1264,6 @@ RSpec.describe API::Projects, feature_category: :projects do
merge_method: 'ff',
squash_option: 'always'
}).tap do |attrs|
- attrs[:operations_access_level] = 'disabled'
attrs[:analytics_access_level] = 'disabled'
attrs[:container_registry_access_level] = 'private'
attrs[:security_and_compliance_access_level] = 'private'
@@ -1205,7 +1279,7 @@ RSpec.describe API::Projects, feature_category: :projects do
attrs[:issues_access_level] = 'disabled'
end
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(response).to have_gitlab_http_status(:created)
@@ -1224,7 +1298,6 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(project.project_feature.issues_access_level).to eq(ProjectFeature::DISABLED)
expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::DISABLED)
expect(project.project_feature.wiki_access_level).to eq(ProjectFeature::DISABLED)
- expect(project.operations_access_level).to eq(ProjectFeature::DISABLED)
expect(project.project_feature.analytics_access_level).to eq(ProjectFeature::DISABLED)
expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::PRIVATE)
expect(project.project_feature.security_and_compliance_access_level).to eq(ProjectFeature::PRIVATE)
@@ -1240,10 +1313,10 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(project.project_feature.snippets_access_level).to eq(ProjectFeature::DISABLED)
end
- it 'assigns container_registry_enabled to project', :aggregate_failures do
+ it 'assigns container_registry_enabled to project' do
project = attributes_for(:project, { container_registry_enabled: true })
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(response).to have_gitlab_http_status(:created)
expect(json_response['container_registry_enabled']).to eq(true)
@@ -1254,7 +1327,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'assigns container_registry_enabled to project' do
project = attributes_for(:project, { container_registry_enabled: true })
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(response).to have_gitlab_http_status(:created)
expect(json_response['container_registry_enabled']).to eq(true)
@@ -1262,7 +1335,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'creates a project using a template' do
- expect { post api('/projects', user), params: { template_name: 'rails', name: 'rails-test' } }
+ expect { post api(path, user), params: { template_name: 'rails', name: 'rails-test' } }
.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -1273,7 +1346,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns 400 for an invalid template' do
- expect { post api('/projects', user), params: { template_name: 'unknown', name: 'rails-test' } }
+ expect { post api(path, user), params: { template_name: 'unknown', name: 'rails-test' } }
.not_to change { Project.count }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -1282,7 +1355,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'disallows creating a project with an import_url and template' do
project_params = { import_url: 'http://example.com', template_name: 'rails', name: 'rails-test' }
- expect { post api('/projects', user), params: project_params }
+ expect { post api(path, user), params: project_params }
.not_to change { Project.count }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -1299,34 +1372,34 @@ RSpec.describe API::Projects, feature_category: :projects do
headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' } })
project_params = { import_url: url, path: 'path-project-Foo', name: 'Foo Project' }
- expect { post api('/projects', user), params: project_params }
+ expect { post api(path, user), params: project_params }
.not_to change { Project.count }
expect(response).to have_gitlab_http_status(:forbidden)
end
- it 'allows creating a project without an import_url when git import source is disabled', :aggregate_failures do
+ it 'allows creating a project without an import_url when git import source is disabled' do
stub_application_setting(import_sources: nil)
project_params = { path: 'path-project-Foo' }
- expect { post api('/projects', user), params: project_params }.to change { Project.count }.by(1)
+ expect { post api(path, user), params: project_params }.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
end
- it 'disallows creating a project with an import_url that is not reachable', :aggregate_failures do
+ it 'disallows creating a project with an import_url that is not reachable' do
url = 'http://example.com'
endpoint_url = "#{url}/info/refs?service=git-upload-pack"
stub_full_request(endpoint_url, method: :get).to_return({ status: 301, body: '', headers: nil })
project_params = { import_url: url, path: 'path-project-Foo', name: 'Foo Project' }
- expect { post api('/projects', user), params: project_params }.not_to change { Project.count }
+ expect { post api(path, user), params: project_params }.not_to change { Project.count }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['message']).to eq("#{url} is not a valid HTTP Git repository")
end
- it 'creates a project with an import_url that is valid', :aggregate_failures do
+ it 'creates a project with an import_url that is valid' do
url = 'http://example.com'
endpoint_url = "#{url}/info/refs?service=git-upload-pack"
git_response = {
@@ -1334,10 +1407,11 @@ RSpec.describe API::Projects, feature_category: :projects do
body: '001e# service=git-upload-pack',
headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' }
}
+ stub_application_setting(import_sources: ['git'])
stub_full_request(endpoint_url, method: :get).to_return(git_response)
project_params = { import_url: url, path: 'path-project-Foo', name: 'Foo Project' }
- expect { post api('/projects', user), params: project_params }.to change { Project.count }.by(1)
+ expect { post api(path, user), params: project_params }.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
end
@@ -1345,7 +1419,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as public' do
project = attributes_for(:project, visibility: 'public')
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['visibility']).to eq('public')
end
@@ -1353,7 +1427,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as internal' do
project = attributes_for(:project, visibility: 'internal')
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['visibility']).to eq('internal')
end
@@ -1361,23 +1435,23 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as private' do
project = attributes_for(:project, visibility: 'private')
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['visibility']).to eq('private')
end
it 'creates a new project initialized with a README.md' do
- project = attributes_for(:project, initialize_with_readme: 1, name: 'somewhere')
+ project = attributes_for(:project, initialize_with_readme: 1)
- post api('/projects', user), params: project
+ post api(path, user), params: project
- expect(json_response['readme_url']).to eql("#{Gitlab.config.gitlab.url}/#{json_response['namespace']['full_path']}/somewhere/-/blob/master/README.md")
+ expect(json_response['readme_url']).to eql("#{Gitlab.config.gitlab.url}/#{json_response['namespace']['full_path']}/#{json_response['path']}/-/blob/master/README.md")
end
it 'sets tag list to a project (deprecated)' do
project = attributes_for(:project, tag_list: %w[tagFirst tagSecond])
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['topics']).to eq(%w[tagFirst tagSecond])
end
@@ -1385,7 +1459,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets topics to a project' do
project = attributes_for(:project, topics: %w[topic1 topics2])
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['topics']).to eq(%w[topic1 topics2])
end
@@ -1394,7 +1468,7 @@ RSpec.describe API::Projects, feature_category: :projects do
project = attributes_for(:project, avatar: fixture_file_upload('spec/fixtures/banana_sample.gif', 'image/gif'))
workhorse_form_with_file(
- api('/projects', user),
+ api(path, user),
method: :post,
file_key: :avatar,
params: project
@@ -1407,7 +1481,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as not allowing outdated diff discussions to automatically resolve' do
project = attributes_for(:project, resolve_outdated_diff_discussions: false)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['resolve_outdated_diff_discussions']).to be_falsey
end
@@ -1415,7 +1489,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing outdated diff discussions to automatically resolve' do
project = attributes_for(:project, resolve_outdated_diff_discussions: true)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['resolve_outdated_diff_discussions']).to be_truthy
end
@@ -1423,7 +1497,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as not removing source branches' do
project = attributes_for(:project, remove_source_branch_after_merge: false)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['remove_source_branch_after_merge']).to be_falsey
end
@@ -1431,7 +1505,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as removing source branches' do
project = attributes_for(:project, remove_source_branch_after_merge: true)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['remove_source_branch_after_merge']).to be_truthy
end
@@ -1439,7 +1513,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge even if build fails' do
project = attributes_for(:project, only_allow_merge_if_pipeline_succeeds: false)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['only_allow_merge_if_pipeline_succeeds']).to be_falsey
end
@@ -1447,7 +1521,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge only if merge_when_pipeline_succeeds' do
project = attributes_for(:project, only_allow_merge_if_pipeline_succeeds: true)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['only_allow_merge_if_pipeline_succeeds']).to be_truthy
end
@@ -1455,7 +1529,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as not allowing merge when pipeline is skipped' do
project_params = attributes_for(:project, allow_merge_on_skipped_pipeline: false)
- post api('/projects', user), params: project_params
+ post api(path, user), params: project_params
expect(json_response['allow_merge_on_skipped_pipeline']).to be_falsey
end
@@ -1463,7 +1537,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge when pipeline is skipped' do
project_params = attributes_for(:project, allow_merge_on_skipped_pipeline: true)
- post api('/projects', user), params: project_params
+ post api(path, user), params: project_params
expect(json_response['allow_merge_on_skipped_pipeline']).to be_truthy
end
@@ -1471,7 +1545,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge even if discussions are unresolved' do
project = attributes_for(:project, only_allow_merge_if_all_discussions_are_resolved: false)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_falsey
end
@@ -1479,7 +1553,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge if only_allow_merge_if_all_discussions_are_resolved is nil' do
project = attributes_for(:project, only_allow_merge_if_all_discussions_are_resolved: nil)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_falsey
end
@@ -1487,7 +1561,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge only if all discussions are resolved' do
project = attributes_for(:project, only_allow_merge_if_all_discussions_are_resolved: true)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_truthy
end
@@ -1495,7 +1569,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as enabling auto close referenced issues' do
project = attributes_for(:project, autoclose_referenced_issues: true)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['autoclose_referenced_issues']).to be_truthy
end
@@ -1503,7 +1577,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as disabling auto close referenced issues' do
project = attributes_for(:project, autoclose_referenced_issues: false)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['autoclose_referenced_issues']).to be_falsey
end
@@ -1511,7 +1585,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets the merge method of a project to rebase merge' do
project = attributes_for(:project, merge_method: 'rebase_merge')
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(json_response['merge_method']).to eq('rebase_merge')
end
@@ -1519,7 +1593,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'rejects invalid values for merge_method' do
project = attributes_for(:project, merge_method: 'totally_not_valid_method')
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -1527,7 +1601,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'ignores import_url when it is nil' do
project = attributes_for(:project, import_url: nil)
- post api('/projects', user), params: project
+ post api(path, user), params: project
expect(response).to have_gitlab_http_status(:created)
end
@@ -1540,7 +1614,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'does not allow a non-admin to use a restricted visibility level' do
- post api('/projects', user), params: project_param
+ post api(path, user), params: project_param
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['visibility_level'].first).to(
@@ -1549,7 +1623,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'allows an admin to override restricted visibility settings' do
- post api('/projects', admin), params: project_param
+ post api(path, admin), params: project_param
expect(json_response['visibility']).to eq('public')
end
@@ -1557,7 +1631,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'GET /users/:user_id/projects/' do
- let!(:public_project) { create(:project, :public, name: 'public_project', creator_id: user4.id, namespace: user4.namespace) }
+ let_it_be(:public_project) { create(:project, :public, creator_id: user4.id, namespace: user4.namespace) }
it 'returns error when user not found' do
get api("/users/#{non_existing_record_id}/projects/")
@@ -1575,7 +1649,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id)
end
- it 'includes container_registry_access_level', :aggregate_failures do
+ it 'includes container_registry_access_level' do
get api("/users/#{user4.id}/projects/", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -1583,8 +1657,18 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(json_response.first.keys).to include('container_registry_access_level')
end
+ context 'filter by updated_at' do
+ it 'returns only projects updated on the given timeframe' do
+ get api("/users/#{user.id}/projects", user),
+ params: { updated_before: 2.days.ago.iso8601, updated_after: 6.days.ago }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |project| project['id'] }).to contain_exactly(project2.id, project.id)
+ end
+ end
+
context 'and using id_after' do
- let!(:another_public_project) { create(:project, :public, name: 'another_public_project', creator_id: user4.id, namespace: user4.namespace) }
+ let_it_be(:another_public_project) { create(:project, :public, creator_id: user4.id, namespace: user4.namespace) }
it 'only returns projects with id_after filter given' do
get api("/users/#{user4.id}/projects?id_after=#{public_project.id}", user)
@@ -1606,7 +1690,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
context 'and using id_before' do
- let!(:another_public_project) { create(:project, :public, name: 'another_public_project', creator_id: user4.id, namespace: user4.namespace) }
+ let_it_be(:another_public_project) { create(:project, :public, creator_id: user4.id, namespace: user4.namespace) }
it 'only returns projects with id_before filter given' do
get api("/users/#{user4.id}/projects?id_before=#{another_public_project.id}", user)
@@ -1628,7 +1712,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
context 'and using both id_before and id_after' do
- let!(:more_projects) { create_list(:project, 5, :public, creator_id: user4.id, namespace: user4.namespace) }
+ let_it_be(:more_projects) { create_list(:project, 5, :public, creator_id: user4.id, namespace: user4.namespace) }
it 'only returns projects with id matching the range' do
get api("/users/#{user4.id}/projects?id_after=#{more_projects.first.id}&id_before=#{more_projects.last.id}", user)
@@ -1663,7 +1747,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(json_response.map { |project| project['id'] }).to contain_exactly(private_project1.id)
end
- context 'and using an admin to search', :enable_admin_mode, :aggregate_errors do
+ context 'and using an admin to search', :enable_admin_mode do
it 'returns users projects when authenticated as admin' do
private_project1 = create(:project, :private, name: 'private_project1', creator_id: user4.id, namespace: user4.namespace)
@@ -1697,6 +1781,8 @@ RSpec.describe API::Projects, feature_category: :projects do
user3.reload
end
+ let(:path) { "/users/#{user3.id}/starred_projects/" }
+
it 'returns error when user not found' do
get api("/users/#{non_existing_record_id}/starred_projects/")
@@ -1706,7 +1792,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'with a public profile' do
it 'returns projects filtered by user' do
- get api("/users/#{user3.id}/starred_projects/", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1714,6 +1800,16 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(json_response.map { |project| project['id'] })
.to contain_exactly(project.id, project2.id, project3.id)
end
+
+ context 'filter by updated_at' do
+ it 'returns only projects updated on the given timeframe' do
+ get api(path, user),
+ params: { updated_before: 2.days.ago.iso8601, updated_after: 6.days.ago }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |project| project['id'] }).to contain_exactly(project2.id, project.id)
+ end
+ end
end
context 'with a private profile' do
@@ -1724,7 +1820,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'user does not have access to view the private profile' do
it 'returns no projects' do
- get api("/users/#{user3.id}/starred_projects/", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1735,7 +1831,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'user has access to view the private profile' do
it 'returns projects filtered by user' do
- get api("/users/#{user3.id}/starred_projects/", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1748,8 +1844,14 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'POST /projects/user/:id' do
+ let(:path) { "/projects/user/#{user.id}" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { { name: 'Foo Project' } }
+ end
+
it 'creates new project without path but with name and return 201' do
- expect { post api("/projects/user/#{user.id}", admin), params: { name: 'Foo Project' } }.to change { Project.count }.by(1)
+ expect { post api(path, admin, admin_mode: true), params: { name: 'Foo Project' } }.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
project = Project.find(json_response['id'])
@@ -1759,7 +1861,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'creates new project with name and path and returns 201' do
- expect { post api("/projects/user/#{user.id}", admin), params: { path: 'path-project-Foo', name: 'Foo Project' } }
+ expect { post api(path, admin, admin_mode: true), params: { path: 'path-project-Foo', name: 'Foo Project' } }
.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -1770,11 +1872,11 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it_behaves_like 'create project with default branch parameter' do
- let(:request) { post api("/projects/user/#{user.id}", admin), params: params }
+ subject(:request) { post api(path, admin, admin_mode: true), params: params }
end
it 'responds with 400 on failure and not project' do
- expect { post api("/projects/user/#{user.id}", admin) }
+ expect { post api(path, admin, admin_mode: true) }
.not_to change { Project.count }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -1786,7 +1888,7 @@ RSpec.describe API::Projects, feature_category: :projects do
attrs[:container_registry_enabled] = true
end
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(response).to have_gitlab_http_status(:created)
expect(json_response['container_registry_enabled']).to eq(true)
@@ -1802,7 +1904,7 @@ RSpec.describe API::Projects, feature_category: :projects do
jobs_enabled: true
})
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(response).to have_gitlab_http_status(:created)
@@ -1816,7 +1918,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as public' do
project = attributes_for(:project, visibility: 'public')
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(response).to have_gitlab_http_status(:created)
expect(json_response['visibility']).to eq('public')
@@ -1825,7 +1927,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as internal' do
project = attributes_for(:project, visibility: 'internal')
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(response).to have_gitlab_http_status(:created)
expect(json_response['visibility']).to eq('internal')
@@ -1834,7 +1936,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as private' do
project = attributes_for(:project, visibility: 'private')
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['visibility']).to eq('private')
end
@@ -1842,7 +1944,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as not allowing outdated diff discussions to automatically resolve' do
project = attributes_for(:project, resolve_outdated_diff_discussions: false)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['resolve_outdated_diff_discussions']).to be_falsey
end
@@ -1850,7 +1952,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing outdated diff discussions to automatically resolve' do
project = attributes_for(:project, resolve_outdated_diff_discussions: true)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['resolve_outdated_diff_discussions']).to be_truthy
end
@@ -1858,7 +1960,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as not removing source branches' do
project = attributes_for(:project, remove_source_branch_after_merge: false)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['remove_source_branch_after_merge']).to be_falsey
end
@@ -1866,7 +1968,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as removing source branches' do
project = attributes_for(:project, remove_source_branch_after_merge: true)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['remove_source_branch_after_merge']).to be_truthy
end
@@ -1874,7 +1976,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge even if build fails' do
project = attributes_for(:project, only_allow_merge_if_pipeline_succeeds: false)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['only_allow_merge_if_pipeline_succeeds']).to be_falsey
end
@@ -1882,7 +1984,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge only if pipeline succeeds' do
project = attributes_for(:project, only_allow_merge_if_pipeline_succeeds: true)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['only_allow_merge_if_pipeline_succeeds']).to be_truthy
end
@@ -1890,7 +1992,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as not allowing merge when pipeline is skipped' do
project = attributes_for(:project, allow_merge_on_skipped_pipeline: false)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['allow_merge_on_skipped_pipeline']).to be_falsey
end
@@ -1898,7 +2000,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge when pipeline is skipped' do
project = attributes_for(:project, allow_merge_on_skipped_pipeline: true)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['allow_merge_on_skipped_pipeline']).to be_truthy
end
@@ -1906,7 +2008,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge even if discussions are unresolved' do
project = attributes_for(:project, only_allow_merge_if_all_discussions_are_resolved: false)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_falsey
end
@@ -1914,7 +2016,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets a project as allowing merge only if all discussions are resolved' do
project = attributes_for(:project, only_allow_merge_if_all_discussions_are_resolved: true)
- post api("/projects/user/#{user.id}", admin), params: project
+ post api(path, admin, admin_mode: true), params: project
expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_truthy
end
@@ -1928,12 +2030,12 @@ RSpec.describe API::Projects, feature_category: :projects do
end
with_them do
- it 'setting container_registry_enabled also sets container_registry_access_level', :aggregate_failures do
+ it 'setting container_registry_enabled also sets container_registry_access_level' do
project_attributes = attributes_for(:project).tap do |attrs|
attrs[:container_registry_enabled] = container_registry_enabled
end
- post api("/projects/user/#{user.id}", admin), params: project_attributes
+ post api(path, admin, admin_mode: true), params: project_attributes
project = Project.find_by(path: project_attributes[:path])
expect(response).to have_gitlab_http_status(:created)
@@ -1955,12 +2057,12 @@ RSpec.describe API::Projects, feature_category: :projects do
end
with_them do
- it 'setting container_registry_access_level also sets container_registry_enabled', :aggregate_failures do
+ it 'setting container_registry_access_level also sets container_registry_enabled' do
project_attributes = attributes_for(:project).tap do |attrs|
attrs[:container_registry_access_level] = container_registry_access_level
end
- post api("/projects/user/#{user.id}", admin), params: project_attributes
+ post api(path, admin, admin_mode: true), params: project_attributes
project = Project.find_by(path: project_attributes[:path])
expect(response).to have_gitlab_http_status(:created)
@@ -1975,10 +2077,11 @@ RSpec.describe API::Projects, feature_category: :projects do
describe "POST /projects/:id/uploads/authorize" do
let(:headers) { workhorse_internal_api_request_header.merge({ 'HTTP_GITLAB_WORKHORSE' => 1 }) }
+ let(:path) { "/projects/#{project.id}/uploads/authorize" }
context 'with authorized user' do
it "returns 200" do
- post api("/projects/#{project.id}/uploads/authorize", user), headers: headers
+ post api(path, user), headers: headers
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['MaximumSize']).to eq(project.max_attachment_size)
@@ -1987,7 +2090,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'with unauthorized user' do
it "returns 404" do
- post api("/projects/#{project.id}/uploads/authorize", user2), headers: headers
+ post api(path, user2), headers: headers
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -1999,20 +2102,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it "returns 200" do
- post api("/projects/#{project.id}/uploads/authorize", user), headers: headers
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['MaximumSize']).to eq(1.gigabyte)
- end
- end
-
- context 'with upload size enforcement disabled' do
- before do
- stub_feature_flags(enforce_max_attachment_size_upload_api: false)
- end
-
- it "returns 200" do
- post api("/projects/#{project.id}/uploads/authorize", user), headers: headers
+ post api(path, user), headers: headers
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['MaximumSize']).to eq(1.gigabyte)
@@ -2021,7 +2111,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'with no Workhorse headers' do
it "returns 403" do
- post api("/projects/#{project.id}/uploads/authorize", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -2030,6 +2120,7 @@ RSpec.describe API::Projects, feature_category: :projects do
describe "POST /projects/:id/uploads" do
let(:file) { fixture_file_upload("spec/fixtures/dk.png", "image/png") }
+ let(:path) { "/projects/#{project.id}/uploads" }
before do
project
@@ -2040,7 +2131,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(instance).to receive(:override_max_attachment_size=).with(project.max_attachment_size).and_call_original
end
- post api("/projects/#{project.id}/uploads", user), params: { file: file }
+ post api(path, user), params: { file: file }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['alt']).to eq("dk")
@@ -2060,7 +2151,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(path).not_to be(nil)
expect(Rack::Multipart::Parser::TEMPFILE_FACTORY).to receive(:call).and_return(tempfile)
- post api("/projects/#{project.id}/uploads", user), params: { file: fixture_file_upload("spec/fixtures/dk.png", "image/png") }
+ post api(path, user), params: { file: fixture_file_upload("spec/fixtures/dk.png", "image/png") }
expect(tempfile.path).to be(nil)
expect(File.exist?(path)).to be(false)
@@ -2072,7 +2163,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(instance).to receive(:override_max_attachment_size=).with(1.gigabyte).and_call_original
end
- post api("/projects/#{project.id}/uploads", user), params: { file: file }
+ post api(path, user), params: { file: file }
expect(response).to have_gitlab_http_status(:created)
end
@@ -2084,7 +2175,7 @@ RSpec.describe API::Projects, feature_category: :projects do
hash_including(message: 'File exceeds maximum size', upload_allowed: upload_allowed))
.and_call_original
- post api("/projects/#{project.id}/uploads", user), params: { file: file }
+ post api(path, user), params: { file: file }
end
end
@@ -2095,14 +2186,6 @@ RSpec.describe API::Projects, feature_category: :projects do
it_behaves_like 'capped upload attachments', true
end
-
- context 'with upload size enforcement disabled' do
- before do
- stub_feature_flags(enforce_max_attachment_size_upload_api: false)
- end
-
- it_behaves_like 'capped upload attachments', false
- end
end
describe "GET /projects/:id/groups" do
@@ -2113,33 +2196,37 @@ RSpec.describe API::Projects, feature_category: :projects do
let_it_be(:private_project) { create(:project, :private, group: project_group) }
let_it_be(:public_project) { create(:project, :public, group: project_group) }
+ let(:path) { "/projects/#{private_project.id}/groups" }
+
before_all do
create(:project_group_link, :developer, group: shared_group_with_dev_access, project: private_project)
create(:project_group_link, :reporter, group: shared_group_with_reporter_access, project: private_project)
end
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
+
shared_examples_for 'successful groups response' do
it 'returns an array of groups' do
request
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.map { |g| g['name'] }).to match_array(expected_groups.map(&:name))
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |g| g['name'] }).to match_array(expected_groups.map(&:name))
end
end
context 'when unauthenticated' do
it 'does not return groups for private projects' do
- get api("/projects/#{private_project.id}/groups")
+ get api(path)
expect(response).to have_gitlab_http_status(:not_found)
end
context 'for public projects' do
- let(:request) { get api("/projects/#{public_project.id}/groups") }
+ subject(:request) { get api("/projects/#{public_project.id}/groups") }
it_behaves_like 'successful groups response' do
let(:expected_groups) { [root_group, project_group] }
@@ -2150,14 +2237,15 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'when authenticated as user' do
context 'when user does not have access to the project' do
it 'does not return groups' do
- get api("/projects/#{private_project.id}/groups", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when user has access to the project' do
- let(:request) { get api("/projects/#{private_project.id}/groups", user), params: params }
+ subject(:request) { get api(path, user), params: params }
+
let(:params) { {} }
before do
@@ -2219,7 +2307,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
context 'when authenticated as admin' do
- let(:request) { get api("/projects/#{private_project.id}/groups", admin) }
+ subject(:request) { get api(path, admin, admin_mode: true) }
it_behaves_like 'successful groups response' do
let(:expected_groups) { [root_group, project_group] }
@@ -2228,27 +2316,30 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'GET /project/:id/share_locations' do
- let_it_be(:root_group) { create(:group, :public, name: 'root group') }
- let_it_be(:project_group1) { create(:group, :public, parent: root_group, name: 'group1') }
- let_it_be(:project_group2) { create(:group, :public, parent: root_group, name: 'group2') }
+ let_it_be(:root_group) { create(:group, :public, name: 'root group', path: 'root-group-path') }
+ let_it_be(:project_group1) { create(:group, :public, parent: root_group, name: 'group1', path: 'group-1-path') }
+ let_it_be(:project_group2) { create(:group, :public, parent: root_group, name: 'group2', path: 'group-2-path') }
let_it_be(:project) { create(:project, :private, group: project_group1) }
+ let(:path) { "/projects/#{project.id}/share_locations" }
+
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
shared_examples_for 'successful groups response' do
it 'returns an array of groups' do
request
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.map { |g| g['name'] }).to match_array(expected_groups.map(&:name))
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |g| g['name'] }).to match_array(expected_groups.map(&:name))
end
end
context 'when unauthenticated' do
it 'does not return the groups for the given project' do
- get api("/projects/#{project.id}/share_locations")
+ get api(path)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -2257,14 +2348,15 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'when authenticated' do
context 'when user is not the owner of the project' do
it 'does not return the groups' do
- get api("/projects/#{project.id}/share_locations", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when user is the owner of the project' do
- let(:request) { get api("/projects/#{project.id}/share_locations", user), params: params }
+ subject(:request) { get api(path, user), params: params }
+
let(:params) { {} }
before do
@@ -2275,26 +2367,38 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'with default search' do
it_behaves_like 'successful groups response' do
- let(:expected_groups) { [project_group1, project_group2] }
+ let(:expected_groups) { [project_group2] }
end
end
context 'when searching by group name' do
- let(:params) { { search: 'group1' } }
+ context 'searching by group name' do
+ it_behaves_like 'successful groups response' do
+ let(:params) { { search: 'group2' } }
+ let(:expected_groups) { [project_group2] }
+ end
+ end
- it_behaves_like 'successful groups response' do
- let(:expected_groups) { [project_group1] }
+ context 'searching by full group path' do
+ let_it_be(:project_group2_subgroup) do
+ create(:group, :public, parent: project_group2, name: 'subgroup', path: 'subgroup-path')
+ end
+
+ it_behaves_like 'successful groups response' do
+ let(:params) { { search: 'root-group-path/group-2-path/subgroup-path' } }
+ let(:expected_groups) { [project_group2_subgroup] }
+ end
end
end
end
end
context 'when authenticated as admin' do
- let(:request) { get api("/projects/#{project.id}/share_locations", admin), params: {} }
+ subject(:request) { get api(path, admin, admin_mode: true), params: {} }
context 'without share_with_group_lock' do
it_behaves_like 'successful groups response' do
- let(:expected_groups) { [root_group, project_group1, project_group2] }
+ let(:expected_groups) { [project_group2] }
end
end
@@ -2311,6 +2415,12 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'GET /projects/:id' do
+ let(:path) { "/projects/#{project.id}" }
+
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
+
context 'when unauthenticated' do
it 'does not return private projects' do
private_project = create(:project, :private)
@@ -2350,7 +2460,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:protected_attributes) { %w(default_branch ci_config_path) }
it 'hides protected attributes of private repositories if user is not a member' do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
protected_attributes.each do |attribute|
@@ -2361,7 +2471,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'exposes protected attributes of private repositories if user is a member' do
project.add_developer(user)
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
protected_attributes.each do |attribute|
@@ -2408,17 +2518,18 @@ RSpec.describe API::Projects, feature_category: :projects do
keys
end
- it 'returns a project by id', :aggregate_failures do
+ it 'returns a project by id' do
project
project_member
group = create(:group)
link = create(:project_group_link, project: project, group: group)
- get api("/projects/#{project.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(project.id)
expect(json_response['description']).to eq(project.description)
+ expect(json_response['description_html']).to eq(project.description_html)
expect(json_response['default_branch']).to eq(project.default_branch)
expect(json_response['tag_list']).to be_an Array # deprecated in favor of 'topics'
expect(json_response['topics']).to be_an Array
@@ -2440,6 +2551,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(json_response['container_registry_enabled']).to be_present
expect(json_response['container_registry_access_level']).to be_present
expect(json_response['created_at']).to be_present
+ expect(json_response['updated_at']).to be_present
expect(json_response['last_activity_at']).to be_present
expect(json_response['shared_runners_enabled']).to be_present
expect(json_response['group_runners_enabled']).to be_present
@@ -2458,7 +2570,6 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(json_response['allow_merge_on_skipped_pipeline']).to eq(project.allow_merge_on_skipped_pipeline)
expect(json_response['restrict_user_defined_variables']).to eq(project.restrict_user_defined_variables?)
expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to eq(project.only_allow_merge_if_all_discussions_are_resolved)
- expect(json_response['operations_access_level']).to be_present
expect(json_response['security_and_compliance_access_level']).to be_present
expect(json_response['releases_access_level']).to be_present
expect(json_response['environments_access_level']).to be_present
@@ -2470,19 +2581,19 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'exposes all necessary attributes' do
create(:project_group_link, project: project)
- get api("/projects/#{project.id}", admin)
+ get api(path, admin, admin_mode: true)
diff = Set.new(json_response.keys) ^ Set.new(expected_keys)
expect(diff).to be_empty, failure_message(diff)
end
- def failure_message(diff)
+ def failure_message(_diff)
<<~MSG
It looks like project's set of exposed attributes is different from the expected set.
The following attributes are missing or newly added:
- #{diff.to_a.to_sentence}
+ {diff.to_a.to_sentence}
Please update #{project_attributes_file} file"
MSG
@@ -2496,11 +2607,11 @@ RSpec.describe API::Projects, feature_category: :projects do
stub_container_registry_config(enabled: true, host_port: 'registry.example.org:5000')
end
- it 'returns a project by id', :aggregate_failures do
+ it 'returns a project by id' do
group = create(:group)
link = create(:project_group_link, project: project, group: group)
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(project.id)
@@ -2532,7 +2643,6 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(json_response['analytics_access_level']).to be_present
expect(json_response['wiki_access_level']).to be_present
expect(json_response['builds_access_level']).to be_present
- expect(json_response['operations_access_level']).to be_present
expect(json_response['security_and_compliance_access_level']).to be_present
expect(json_response['releases_access_level']).to be_present
expect(json_response['environments_access_level']).to be_present
@@ -2584,7 +2694,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expires_at = 5.days.from_now.to_date
link = create(:project_group_link, project: project, group: group, expires_at: expires_at)
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(json_response['shared_with_groups']).to be_an Array
expect(json_response['shared_with_groups'].length).to eq(1)
@@ -2596,7 +2706,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns a project by path name' do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq(project.name)
end
@@ -2609,7 +2719,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'returns a 404 error if user is not a member' do
other_user = create(:user)
- get api("/projects/#{project.id}", other_user)
+ get api(path, other_user)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -2623,7 +2733,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'exposes namespace fields' do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['namespace']).to eq({
@@ -2639,14 +2749,14 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it "does not include license fields by default" do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).not_to include('license', 'license_url')
end
it 'includes license fields when requested' do
- get api("/projects/#{project.id}", user), params: { license: true }
+ get api(path, user), params: { license: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['license']).to eq({
@@ -2659,14 +2769,14 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it "does not include statistics by default" do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).not_to include 'statistics'
end
it "includes statistics if requested" do
- get api("/projects/#{project.id}", user), params: { statistics: true }
+ get api(path, user), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to include 'statistics'
@@ -2676,7 +2786,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:project) { create(:project, :public, :repository, :repository_private) }
it "does not include statistics if user is not a member" do
- get api("/projects/#{project.id}", user), params: { statistics: true }
+ get api(path, user), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).not_to include 'statistics'
@@ -2685,7 +2795,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it "includes statistics if user is a member" do
project.add_developer(user)
- get api("/projects/#{project.id}", user), params: { statistics: true }
+ get api(path, user), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to include 'statistics'
@@ -2695,7 +2805,7 @@ RSpec.describe API::Projects, feature_category: :projects do
project.add_developer(user)
project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
- get api("/projects/#{project.id}", user), params: { statistics: true }
+ get api(path, user), params: { statistics: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to include 'statistics'
@@ -2703,14 +2813,14 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it "includes import_error if user can admin project" do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to include("import_error")
end
it "does not include import_error if user cannot admin project" do
- get api("/projects/#{project.id}", user3)
+ get api(path, user3)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).not_to include("import_error")
@@ -2719,7 +2829,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'returns 404 when project is marked for deletion' do
project.update!(pending_delete: true)
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Project Not Found')
@@ -2727,7 +2837,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'links exposure' do
it 'exposes related resources full URIs' do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
links = json_response['_links']
@@ -2801,7 +2911,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'personal project' do
it 'sets project access and returns 200' do
project.add_maintainer(user)
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['permissions']['project_access']['access_level'])
@@ -2868,7 +2978,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let!(:project_member) { create(:project_member, :developer, user: user, project: project) }
it 'returns group web_url and avatar_url' do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
@@ -2883,7 +2993,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:project) { create(:project, namespace: user.namespace) }
it 'returns user web_url and avatar_url' do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
@@ -2894,21 +3004,61 @@ RSpec.describe API::Projects, feature_category: :projects do
end
end
+ context 'when authenticated as a developer' do
+ before do
+ project
+ project_member
+ end
+
+ it 'hides sensitive admin attributes' do
+ get api(path, user3)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['id']).to eq(project.id)
+ expect(json_response['description']).to eq(project.description)
+ expect(json_response['default_branch']).to eq(project.default_branch)
+ expect(json_response['ci_config_path']).to eq(project.ci_config_path)
+ expect(json_response['forked_from_project']).to eq(project.forked_from_project)
+ expect(json_response['service_desk_address']).to eq(project.service_desk_address)
+ expect(json_response).not_to include(
+ 'ci_default_git_depth',
+ 'ci_forward_deployment_enabled',
+ 'ci_job_token_scope_enabled',
+ 'ci_separated_caches',
+ 'ci_allow_fork_pipelines_to_run_in_parent_project',
+ 'build_git_strategy',
+ 'keep_latest_artifact',
+ 'restrict_user_defined_variables',
+ 'runners_token',
+ 'runner_token_expiration_interval',
+ 'group_runners_enabled',
+ 'auto_cancel_pending_pipelines',
+ 'build_timeout',
+ 'auto_devops_enabled',
+ 'auto_devops_deploy_strategy',
+ 'import_error'
+ )
+ end
+ end
+
it_behaves_like 'storing arguments in the application context for the API' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let(:expected_params) { { user: user.username, project: project.full_path } }
- subject { get api("/projects/#{project.id}", user) }
+ subject { get api(path, user) }
end
describe 'repository_storage attribute' do
+ let_it_be(:admin_mode) { false }
+
before do
- get api("/projects/#{project.id}", user)
+ get api(path, user, admin_mode: admin_mode)
end
context 'when authenticated as an admin' do
let(:user) { create(:admin) }
+ let_it_be(:admin_mode) { true }
it 'returns repository_storage attribute' do
expect(response).to have_gitlab_http_status(:ok)
@@ -2924,31 +3074,34 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'exposes service desk attributes' do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
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
+ it 'avoids N+1 queries', :use_sql_query_cache do
create(:project_group_link, project: project)
- get api("/projects/#{project.id}", user)
+ get api(path, user)
+ expect(response).to have_gitlab_http_status(:ok)
control = ActiveRecord::QueryRecorder.new do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
end
create(:project_group_link, project: project)
expect do
- get api("/projects/#{project.id}", user)
+ get api(path, user)
end.not_to exceed_query_limit(control)
end
end
end
describe 'GET /projects/:id/users' do
+ let(:path) { "/projects/#{project.id}/users" }
+
shared_examples_for 'project users response' do
let(:reporter_1) { create(:user) }
let(:reporter_2) { create(:user) }
@@ -2959,7 +3112,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns the project users' do
- get api("/projects/#{project.id}/users", current_user)
+ get api(path, current_user)
user = project.namespace.first_owner
@@ -2978,6 +3131,10 @@ RSpec.describe API::Projects, feature_category: :projects do
end
end
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:failed_status_code) { :not_found }
+ end
+
context 'when unauthenticated' do
it_behaves_like 'project users response' do
let(:project) { create(:project, :public) }
@@ -3003,7 +3160,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'returns a 404 error if user is not a member' do
other_user = create(:user)
- get api("/projects/#{project.id}/users", other_user)
+ get api(path, other_user)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -3022,18 +3179,25 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'fork management' do
- let(:project_fork_target) { create(:project) }
- let(:project_fork_source) { create(:project, :public) }
- let(:private_project_fork_source) { create(:project, :private) }
+ let_it_be_with_refind(:project_fork_target) { create(:project) }
+ let_it_be_with_refind(:project_fork_source) { create(:project, :public) }
+ let_it_be_with_refind(:private_project_fork_source) { create(:project, :private) }
describe 'POST /projects/:id/fork/:forked_from_id' do
+ let(:path) { "/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { {} }
+ let(:failed_status_code) { :not_found }
+ end
+
context 'user is a developer' do
before do
project_fork_target.add_developer(user)
end
it 'denies project to be forked from an existing project' do
- post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -3051,7 +3215,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'allows project to be forked from an existing project' do
expect(project_fork_target).not_to be_forked
- post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", user)
+ post api(path, user)
project_fork_target.reload
expect(response).to have_gitlab_http_status(:created)
@@ -3063,7 +3227,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'fails without permission from forked_from project' do
project_fork_source.project_feature.update_attribute(:forking_access_level, ProjectFeature::PRIVATE)
- post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
expect(project_fork_target.forked_from_project).to be_nil
@@ -3082,25 +3246,25 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'allows project to be forked from an existing project' do
expect(project_fork_target).not_to be_forked
- post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", admin)
+ post api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:created)
end
it 'allows project to be forked from a private project' do
- post api("/projects/#{project_fork_target.id}/fork/#{private_project_fork_source.id}", admin)
+ post api("/projects/#{project_fork_target.id}/fork/#{private_project_fork_source.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:created)
end
it 'refreshes the forks count cachce' do
expect do
- post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", admin)
+ post api(path, admin, admin_mode: true)
end.to change(project_fork_source, :forks_count).by(1)
end
it 'fails if forked_from project which does not exist' do
- post api("/projects/#{project_fork_target.id}/fork/#{non_existing_record_id}", admin)
+ post api("/projects/#{project_fork_target.id}/fork/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -3109,7 +3273,7 @@ RSpec.describe API::Projects, feature_category: :projects do
Projects::ForkService.new(project_fork_source, admin).execute(project_fork_target)
- post api("/projects/#{project_fork_target.id}/fork/#{other_project_fork_source.id}", admin)
+ post api("/projects/#{project_fork_target.id}/fork/#{other_project_fork_source.id}", admin, admin_mode: true)
project_fork_target.reload
expect(response).to have_gitlab_http_status(:conflict)
@@ -3120,8 +3284,10 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'DELETE /projects/:id/fork' do
+ let(:path) { "/projects/#{project_fork_target.id}/fork" }
+
it "is not visible to users outside group" do
- delete api("/projects/#{project_fork_target.id}/fork", user)
+ delete api(path, user)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -3135,14 +3301,19 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'for a forked project' do
before do
- post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", admin)
+ post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", admin, admin_mode: true)
project_fork_target.reload
expect(project_fork_target.forked_from_project).to be_present
expect(project_fork_target).to be_forked
end
+ it_behaves_like 'DELETE request permissions for admin mode' do
+ let(:success_status_code) { :no_content }
+ let(:failed_status_code) { :not_found }
+ end
+
it 'makes forked project unforked' do
- delete api("/projects/#{project_fork_target.id}/fork", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
project_fork_target.reload
@@ -3151,18 +3322,18 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it_behaves_like '412 response' do
- let(:request) { api("/projects/#{project_fork_target.id}/fork", admin) }
+ subject(:request) { api(path, admin, admin_mode: true) }
end
end
it 'is forbidden to non-owner users' do
- delete api("/projects/#{project_fork_target.id}/fork", user2)
+ delete api(path, user2)
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'is idempotent if not forked' do
expect(project_fork_target.forked_from_project).to be_nil
- delete api("/projects/#{project_fork_target.id}/fork", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_modified)
expect(project_fork_target.reload.forked_from_project).to be_nil
end
@@ -3170,17 +3341,17 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'GET /projects/:id/forks' do
- let(:private_fork) { create(:project, :private, :empty_repo) }
- let(:member) { create(:user) }
- let(:non_member) { create(:user) }
+ let_it_be_with_refind(:private_fork) { create(:project, :private, :empty_repo) }
+ let_it_be(:member) { create(:user) }
+ let_it_be(:non_member) { create(:user) }
- before do
+ before_all do
private_fork.add_developer(member)
end
context 'for a forked project' do
before do
- post api("/projects/#{private_fork.id}/fork/#{project_fork_source.id}", admin)
+ post api("/projects/#{private_fork.id}/fork/#{project_fork_source.id}", admin, admin_mode: true)
private_fork.reload
expect(private_fork.forked_from_project).to be_present
expect(private_fork).to be_forked
@@ -3198,6 +3369,20 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(json_response.length).to eq(1)
expect(json_response[0]['name']).to eq(private_fork.name)
end
+
+ context 'filter by updated_at' do
+ before do
+ private_fork.update!(updated_at: 4.days.ago)
+ end
+
+ it 'returns only forks updated on the given timeframe' do
+ get api("/projects/#{project_fork_source.id}/forks", member),
+ params: { updated_before: 2.days.ago.iso8601, updated_after: 6.days.ago }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |project| project['id'] }).to contain_exactly(private_fork.id)
+ end
+ end
end
context 'for a user that cannot access the forks' do
@@ -3226,6 +3411,7 @@ RSpec.describe API::Projects, feature_category: :projects do
describe "POST /projects/:id/share" do
let_it_be(:group) { create(:group, :private) }
let_it_be(:group_user) { create(:user) }
+ let(:path) { "/projects/#{project.id}/share" }
before do
group.add_developer(user)
@@ -3236,7 +3422,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expires_at = 10.days.from_now.to_date
expect do
- post api("/projects/#{project.id}/share", user), params: { group_id: group.id, group_access: Gitlab::Access::DEVELOPER, expires_at: expires_at }
+ post api(path, user), params: { group_id: group.id, group_access: Gitlab::Access::DEVELOPER, expires_at: expires_at }
end.to change { ProjectGroupLink.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -3247,51 +3433,51 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'updates project authorization', :sidekiq_inline do
expect do
- post api("/projects/#{project.id}/share", user), params: { group_id: group.id, group_access: Gitlab::Access::DEVELOPER }
+ post api(path, user), params: { group_id: group.id, group_access: Gitlab::Access::DEVELOPER }
end.to(
change { group_user.can?(:read_project, project) }.from(false).to(true)
)
end
it "returns a 400 error when group id is not given" do
- post api("/projects/#{project.id}/share", user), params: { group_access: Gitlab::Access::DEVELOPER }
+ post api(path, user), params: { group_access: Gitlab::Access::DEVELOPER }
expect(response).to have_gitlab_http_status(:bad_request)
end
it "returns a 400 error when access level is not given" do
- post api("/projects/#{project.id}/share", user), params: { group_id: group.id }
+ post api(path, user), params: { group_id: group.id }
expect(response).to have_gitlab_http_status(:bad_request)
end
it "returns a 400 error when sharing is disabled" do
project.namespace.update!(share_with_group_lock: true)
- post api("/projects/#{project.id}/share", user), params: { group_id: group.id, group_access: Gitlab::Access::DEVELOPER }
+ post api(path, user), params: { group_id: group.id, group_access: Gitlab::Access::DEVELOPER }
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'returns a 404 error when user cannot read group' do
private_group = create(:group, :private)
- post api("/projects/#{project.id}/share", user), params: { group_id: private_group.id, group_access: Gitlab::Access::DEVELOPER }
+ post api(path, user), params: { group_id: private_group.id, group_access: Gitlab::Access::DEVELOPER }
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns a 404 error when group does not exist' do
- post api("/projects/#{project.id}/share", user), params: { group_id: non_existing_record_id, group_access: Gitlab::Access::DEVELOPER }
+ post api(path, user), params: { group_id: non_existing_record_id, group_access: Gitlab::Access::DEVELOPER }
expect(response).to have_gitlab_http_status(:not_found)
end
it "returns a 400 error when wrong params passed" do
- post api("/projects/#{project.id}/share", user), params: { group_id: group.id, group_access: non_existing_record_access_level }
+ post api(path, user), params: { group_id: group.id, group_access: non_existing_record_access_level }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq 'group_access does not have a valid value'
end
it "returns a 400 error when the project-group share is created with an OWNER access level" do
- post api("/projects/#{project.id}/share", user), params: { group_id: group.id, group_access: Gitlab::Access::OWNER }
+ post api(path, user), params: { group_id: group.id, group_access: Gitlab::Access::OWNER }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq 'group_access does not have a valid value'
@@ -3301,10 +3487,22 @@ RSpec.describe API::Projects, feature_category: :projects do
allow(::Projects::GroupLinks::CreateService).to receive_message_chain(:new, :execute)
.and_return({ status: :error, http_status: 409, message: 'error' })
- post api("/projects/#{project.id}/share", user), params: { group_id: group.id, group_access: Gitlab::Access::DEVELOPER }
+ post api(path, user), params: { group_id: group.id, group_access: Gitlab::Access::DEVELOPER }
expect(response).to have_gitlab_http_status(:conflict)
end
+
+ context 'when project is forked' do
+ let(:forked_project) { fork_project(project) }
+ let(:path) { "/projects/#{forked_project.id}/share" }
+
+ it 'returns a 404 error when group does not exist' do
+ forked_project.add_maintainer(user)
+ post api(path, user), params: { group_id: non_existing_record_id, group_access: Gitlab::Access::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
describe 'DELETE /projects/:id/share/:group_id' do
@@ -3334,7 +3532,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it_behaves_like '412 response' do
- let(:request) { api("/projects/#{project.id}/share/#{group.id}", user) }
+ subject(:request) { api("/projects/#{project.id}/share/#{group.id}", user) }
end
end
@@ -3360,6 +3558,7 @@ RSpec.describe API::Projects, feature_category: :projects do
describe 'POST /projects/:id/import_project_members/:project_id' do
let_it_be(:project2) { create(:project) }
let_it_be(:project2_user) { create(:user) }
+ let(:path) { "/projects/#{project.id}/import_project_members/#{project2.id}" }
before_all do
project.add_maintainer(user)
@@ -3368,7 +3567,8 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'records the query', :request_store, :use_sql_query_cache do
- post api("/projects/#{project.id}/import_project_members/#{project2.id}", user)
+ post api(path, user)
+ expect(response).to have_gitlab_http_status(:created)
control_project = create(:project)
control_project.add_maintainer(user)
@@ -3392,7 +3592,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'returns 200 when it successfully imports members from another project' do
expect do
- post api("/projects/#{project.id}/import_project_members/#{project2.id}", user)
+ post api(path, user)
end.to change { project.members.count }.by(2)
expect(response).to have_gitlab_http_status(:created)
@@ -3435,7 +3635,7 @@ RSpec.describe API::Projects, feature_category: :projects do
project2.add_developer(user2)
expect do
- post api("/projects/#{project.id}/import_project_members/#{project2.id}", user2)
+ post api(path, user2)
end.not_to change { project.members.count }
expect(response).to have_gitlab_http_status(:forbidden)
@@ -3448,7 +3648,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
expect do
- post api("/projects/#{project.id}/import_project_members/#{project2.id}", user)
+ post api(path, user)
end.not_to change { project.members.count }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
@@ -3457,6 +3657,8 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'PUT /projects/:id' do
+ let(:path) { "/projects/#{project.id}" }
+
before do
expect(project).to be_persisted
expect(user).to be_persisted
@@ -3468,13 +3670,18 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(project_member).to be_persisted
end
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:params) { { visibility: 'internal' } }
+ let(:failed_status_code) { :not_found }
+ end
+
describe 'updating packages_enabled attribute' do
it 'is enabled by default' do
expect(project.packages_enabled).to be true
end
it 'disables project packages feature' do
- put(api("/projects/#{project.id}", user), params: { packages_enabled: false })
+ put(api(path, user), params: { packages_enabled: false })
expect(response).to have_gitlab_http_status(:ok)
expect(project.reload.packages_enabled).to be false
@@ -3482,8 +3689,8 @@ RSpec.describe API::Projects, feature_category: :projects do
end
end
- it 'sets container_registry_access_level', :aggregate_failures do
- put api("/projects/#{project.id}", user), params: { container_registry_access_level: 'private' }
+ it 'sets container_registry_access_level' do
+ put api(path, user), params: { container_registry_access_level: 'private' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['container_registry_access_level']).to eq('private')
@@ -3493,31 +3700,23 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'sets container_registry_enabled' do
project.project_feature.update!(container_registry_access_level: ProjectFeature::DISABLED)
- put(api("/projects/#{project.id}", user), params: { container_registry_enabled: true })
+ put(api(path, user), params: { container_registry_enabled: true })
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['container_registry_enabled']).to eq(true)
expect(project.reload.container_registry_access_level).to eq(ProjectFeature::ENABLED)
end
- it 'sets security_and_compliance_access_level', :aggregate_failures do
- put api("/projects/#{project.id}", user), params: { security_and_compliance_access_level: 'private' }
+ it 'sets security_and_compliance_access_level' do
+ put api(path, user), params: { security_and_compliance_access_level: 'private' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['security_and_compliance_access_level']).to eq('private')
expect(Project.find_by(path: project[:path]).security_and_compliance_access_level).to eq(ProjectFeature::PRIVATE)
end
- it 'sets operations_access_level', :aggregate_failures do
- put api("/projects/#{project.id}", user), params: { operations_access_level: 'private' }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['operations_access_level']).to eq('private')
- expect(Project.find_by(path: project[:path]).operations_access_level).to eq(ProjectFeature::PRIVATE)
- end
-
- it 'sets analytics_access_level', :aggregate_failures do
- put api("/projects/#{project.id}", user), params: { analytics_access_level: 'private' }
+ it 'sets analytics_access_level' do
+ put api(path, user), params: { analytics_access_level: 'private' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['analytics_access_level']).to eq('private')
@@ -3525,8 +3724,8 @@ RSpec.describe API::Projects, feature_category: :projects do
end
%i(releases_access_level environments_access_level feature_flags_access_level infrastructure_access_level monitor_access_level).each do |field|
- it "sets #{field}", :aggregate_failures do
- put api("/projects/#{project.id}", user), params: { field => 'private' }
+ it "sets #{field}" do
+ put api(path, user), params: { field => 'private' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response[field.to_s]).to eq('private')
@@ -3537,7 +3736,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'returns 400 when nothing sent' do
project_param = {}
- put api("/projects/#{project.id}", user), params: project_param
+ put api(path, user), params: project_param
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to match('at least one parameter must be provided')
@@ -3547,7 +3746,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'returns authentication error' do
project_param = { name: 'bar' }
- put api("/projects/#{project.id}"), params: project_param
+ put api(path), params: project_param
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -3593,7 +3792,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'does not update name to existing name' do
project_param = { name: project3.name }
- put api("/projects/#{project.id}", user), params: project_param
+ put api(path, user), params: project_param
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['name']).to eq(['has already been taken'])
@@ -3602,7 +3801,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'updates request_access_enabled' do
project_param = { request_access_enabled: false }
- put api("/projects/#{project.id}", user), params: project_param
+ put api(path, user), params: project_param
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['request_access_enabled']).to eq(false)
@@ -3623,7 +3822,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'updates default_branch' do
project_param = { default_branch: 'something_else' }
- put api("/projects/#{project.id}", user), params: project_param
+ put api(path, user), params: project_param
expect(response).to have_gitlab_http_status(:ok)
@@ -3712,7 +3911,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(response).to have_gitlab_http_status(:bad_request)
end
- it 'updates restrict_user_defined_variables', :aggregate_failures do
+ it 'updates restrict_user_defined_variables' do
project_param = { restrict_user_defined_variables: true }
put api("/projects/#{project3.id}", user), params: project_param
@@ -3914,7 +4113,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'updates name' do
project_param = { name: 'bar' }
- put api("/projects/#{project.id}", user), params: project_param
+ put api(path, user), params: project_param
expect(response).to have_gitlab_http_status(:ok)
@@ -3989,7 +4188,7 @@ RSpec.describe API::Projects, feature_category: :projects do
merge_requests_enabled: true,
description: 'new description',
request_access_enabled: true }
- put api("/projects/#{project.id}", user3), params: project_param
+ put api(path, user3), params: project_param
expect(response).to have_gitlab_http_status(:forbidden)
end
end
@@ -4000,7 +4199,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'ignores visibility level restrictions' do
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
- put api("/projects/#{project3.id}", admin), params: { visibility: 'internal' }
+ put api("/projects/#{project3.id}", admin, admin_mode: true), params: { visibility: 'internal' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['visibility']).to eq('internal')
@@ -4031,7 +4230,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:admin) { create(:admin) }
it 'returns 400 when repository storage is unknown' do
- put(api("/projects/#{new_project.id}", admin), params: { repository_storage: unknown_storage })
+ put(api("/projects/#{new_project.id}", admin, admin_mode: true), params: { repository_storage: unknown_storage })
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['repository_storage_moves']).to eq(['is invalid'])
@@ -4042,7 +4241,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expect do
Sidekiq::Testing.fake! do
- put(api("/projects/#{new_project.id}", admin), params: { repository_storage: 'test_second_storage' })
+ put(api("/projects/#{new_project.id}", admin, admin_mode: true), params: { repository_storage: 'test_second_storage' })
end
end.to change(Projects::UpdateRepositoryStorageWorker.jobs, :size).by(1)
@@ -4052,40 +4251,42 @@ RSpec.describe API::Projects, feature_category: :projects do
end
context 'when updating service desk' do
- subject { put(api("/projects/#{project.id}", user), params: { service_desk_enabled: true }) }
+ let(:params) { { service_desk_enabled: true } }
+
+ subject(:request) { put(api(path, user), params: params) }
before do
project.update!(service_desk_enabled: false)
- allow(::Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
+ allow(::Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(true)
end
it 'returns 200' do
- subject
+ request
expect(response).to have_gitlab_http_status(:ok)
end
it 'enables the service_desk' do
- expect { subject }.to change { project.reload.service_desk_enabled }.to(true)
+ expect { request }.to change { project.reload.service_desk_enabled }.to(true)
end
end
context 'when updating keep latest artifact' do
- subject { put(api("/projects/#{project.id}", user), params: { keep_latest_artifact: true }) }
+ subject(:request) { put(api(path, user), params: { keep_latest_artifact: true }) }
before do
project.update!(keep_latest_artifact: false)
end
it 'returns 200' do
- subject
+ request
expect(response).to have_gitlab_http_status(:ok)
end
it 'enables keep_latest_artifact' do
- expect { subject }.to change { project.reload.keep_latest_artifact }.to(true)
+ expect { request }.to change { project.reload.keep_latest_artifact }.to(true)
end
end
@@ -4131,9 +4332,11 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'POST /projects/:id/archive' do
+ let(:path) { "/projects/#{project.id}/archive" }
+
context 'on an unarchived project' do
it 'archives the project' do
- post api("/projects/#{project.id}/archive", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['archived']).to be_truthy
@@ -4146,7 +4349,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'remains archived' do
- post api("/projects/#{project.id}/archive", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['archived']).to be_truthy
@@ -4159,7 +4362,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'rejects the action' do
- post api("/projects/#{project.id}/archive", user3)
+ post api(path, user3)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -4167,9 +4370,11 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'POST /projects/:id/unarchive' do
+ let(:path) { "/projects/#{project.id}/unarchive" }
+
context 'on an unarchived project' do
it 'remains unarchived' do
- post api("/projects/#{project.id}/unarchive", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['archived']).to be_falsey
@@ -4182,7 +4387,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'unarchives the project' do
- post api("/projects/#{project.id}/unarchive", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['archived']).to be_falsey
@@ -4195,7 +4400,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'rejects the action' do
- post api("/projects/#{project.id}/unarchive", user3)
+ post api(path, user3)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -4203,9 +4408,11 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'POST /projects/:id/star' do
+ let(:path) { "/projects/#{project.id}/star" }
+
context 'on an unstarred project' do
it 'stars the project' do
- expect { post api("/projects/#{project.id}/star", user) }.to change { project.reload.star_count }.by(1)
+ expect { post api(path, user) }.to change { project.reload.star_count }.by(1)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['star_count']).to eq(1)
@@ -4219,7 +4426,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'does not modify the star count' do
- expect { post api("/projects/#{project.id}/star", user) }.not_to change { project.reload.star_count }
+ expect { post api(path, user) }.not_to change { project.reload.star_count }
expect(response).to have_gitlab_http_status(:not_modified)
end
@@ -4227,6 +4434,8 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'POST /projects/:id/unstar' do
+ let(:path) { "/projects/#{project.id}/unstar" }
+
context 'on a starred project' do
before do
user.toggle_star(project)
@@ -4234,7 +4443,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'unstars the project' do
- expect { post api("/projects/#{project.id}/unstar", user) }.to change { project.reload.star_count }.by(-1)
+ expect { post api(path, user) }.to change { project.reload.star_count }.by(-1)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['star_count']).to eq(0)
@@ -4243,7 +4452,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'on an unstarred project' do
it 'does not modify the star count' do
- expect { post api("/projects/#{project.id}/unstar", user) }.not_to change { project.reload.star_count }
+ expect { post api(path, user) }.not_to change { project.reload.star_count }
expect(response).to have_gitlab_http_status(:not_modified)
end
@@ -4251,9 +4460,13 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'GET /projects/:id/starrers' do
+ let(:path) { "/projects/#{public_project.id}/starrers" }
+ let(:public_project) { create(:project, :public) }
+ let(:private_user) { create(:user, private_profile: true) }
+
shared_examples_for 'project starrers response' do
it 'returns an array of starrers' do
- get api("/projects/#{public_project.id}/starrers", current_user)
+ get api(path, current_user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -4263,15 +4476,12 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns the proper security headers' do
- get api("/projects/#{public_project.id}/starrers", current_user)
+ get api(path, current_user)
expect(response).to include_security_headers
end
end
- let(:public_project) { create(:project, :public) }
- let(:private_user) { create(:user, private_profile: true) }
-
before do
user.update!(starred_projects: [public_project])
private_user.update!(starred_projects: [public_project])
@@ -4289,7 +4499,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns only starrers with a public profile' do
- get api("/projects/#{public_project.id}/starrers", nil)
+ get api(path, nil)
user_ids = json_response.map { |s| s['user']['id'] }
expect(user_ids).to include(user.id)
@@ -4303,7 +4513,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns current user with a private profile' do
- get api("/projects/#{public_project.id}/starrers", private_user)
+ get api(path, private_user)
user_ids = json_response.map { |s| s['user']['id'] }
expect(user_ids).to include(user.id, private_user.id)
@@ -4366,9 +4576,16 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'DELETE /projects/:id' do
+ let(:path) { "/projects/#{project.id}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode' do
+ let(:success_status_code) { :accepted }
+ let(:failed_status_code) { :not_found }
+ end
+
context 'when authenticated as user' do
it 'removes project' do
- delete api("/projects/#{project.id}", user)
+ delete api(path, user)
expect(response).to have_gitlab_http_status(:accepted)
expect(json_response['message']).to eql('202 Accepted')
@@ -4376,13 +4593,13 @@ RSpec.describe API::Projects, feature_category: :projects do
it_behaves_like '412 response' do
let(:success_status) { 202 }
- let(:request) { api("/projects/#{project.id}", user) }
+ subject(:request) { api(path, user) }
end
it 'does not remove a project if not an owner' do
user3 = create(:user)
project.add_developer(user3)
- delete api("/projects/#{project.id}", user3)
+ delete api(path, user3)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -4392,27 +4609,27 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'does not remove a project not attached to user' do
- delete api("/projects/#{project.id}", user2)
+ delete api(path, user2)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when authenticated as admin' do
it 'removes any existing project' do
- delete api("/projects/#{project.id}", admin)
+ delete api("/projects/#{project.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:accepted)
expect(json_response['message']).to eql('202 Accepted')
end
it 'does not remove a non existing project' do
- delete api("/projects/#{non_existing_record_id}", admin)
+ delete api("/projects/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
it_behaves_like '412 response' do
let(:success_status) { 202 }
- let(:request) { api("/projects/#{project.id}", admin) }
+ subject(:request) { api("/projects/#{project.id}", admin, admin_mode: true) }
end
end
end
@@ -4422,6 +4639,8 @@ RSpec.describe API::Projects, feature_category: :projects do
create(:project, :repository, creator: user, namespace: user.namespace)
end
+ let(:path) { "/projects/#{project.id}/fork" }
+
let(:project2) do
create(:project, :repository, creator: user, namespace: user.namespace)
end
@@ -4438,9 +4657,14 @@ RSpec.describe API::Projects, feature_category: :projects do
project2.add_reporter(user2)
end
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { {} }
+ let(:failed_status_code) { :not_found }
+ end
+
context 'when authenticated' do
it 'forks if user has sufficient access to project' do
- post api("/projects/#{project.id}/fork", user2)
+ post api(path, user2)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(project.name)
@@ -4453,7 +4677,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'forks if user is admin' do
- post api("/projects/#{project.id}/fork", admin)
+ post api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(project.name)
@@ -4467,7 +4691,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'fails on missing project access for the project to fork' do
new_user = create(:user)
- post api("/projects/#{project.id}/fork", new_user)
+ post api(path, new_user)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Project Not Found')
@@ -4492,41 +4716,41 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'forks with explicit own user namespace id' do
- post api("/projects/#{project.id}/fork", user2), params: { namespace: user2.namespace.id }
+ post api(path, user2), params: { namespace: user2.namespace.id }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['owner']['id']).to eq(user2.id)
end
it 'forks with explicit own user name as namespace' do
- post api("/projects/#{project.id}/fork", user2), params: { namespace: user2.username }
+ post api(path, user2), params: { namespace: user2.username }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['owner']['id']).to eq(user2.id)
end
it 'forks to another user when admin' do
- post api("/projects/#{project.id}/fork", admin), params: { namespace: user2.username }
+ post api(path, admin, admin_mode: true), params: { namespace: user2.username }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['owner']['id']).to eq(user2.id)
end
it 'fails if trying to fork to another user when not admin' do
- post api("/projects/#{project.id}/fork", user2), params: { namespace: admin.namespace.id }
+ post api(path, user2), params: { namespace: admin.namespace.id }
expect(response).to have_gitlab_http_status(:not_found)
end
it 'fails if trying to fork to non-existent namespace' do
- post api("/projects/#{project.id}/fork", user2), params: { namespace: non_existing_record_id }
+ post api(path, user2), params: { namespace: non_existing_record_id }
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Namespace Not Found')
end
it 'forks to owned group' do
- post api("/projects/#{project.id}/fork", user2), params: { namespace: group2.name }
+ post api(path, user2), params: { namespace: group2.name }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['namespace']['name']).to eq(group2.name)
@@ -4543,7 +4767,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'and namespace_id is specified alone' do
before do
- post api("/projects/#{project.id}/fork", user2), params: { namespace_id: user2.namespace.id }
+ post api(path, user2), params: { namespace_id: user2.namespace.id }
end
it_behaves_like 'forking to specified namespace_id'
@@ -4551,7 +4775,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'and namespace_id and namespace are both specified' do
before do
- post api("/projects/#{project.id}/fork", user2), params: { namespace_id: user2.namespace.id, namespace: admin.namespace.id }
+ post api(path, user2), params: { namespace_id: user2.namespace.id, namespace: admin.namespace.id }
end
it_behaves_like 'forking to specified namespace_id'
@@ -4559,7 +4783,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'and namespace_id and namespace_path are both specified' do
before do
- post api("/projects/#{project.id}/fork", user2), params: { namespace_id: user2.namespace.id, namespace_path: admin.namespace.path }
+ post api(path, user2), params: { namespace_id: user2.namespace.id, namespace_path: admin.namespace.path }
end
it_behaves_like 'forking to specified namespace_id'
@@ -4577,7 +4801,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'and namespace_path is specified alone' do
before do
- post api("/projects/#{project.id}/fork", user2), params: { namespace_path: user2.namespace.path }
+ post api(path, user2), params: { namespace_path: user2.namespace.path }
end
it_behaves_like 'forking to specified namespace_path'
@@ -4585,7 +4809,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'and namespace_path and namespace are both specified' do
before do
- post api("/projects/#{project.id}/fork", user2), params: { namespace_path: user2.namespace.path, namespace: admin.namespace.path }
+ post api(path, user2), params: { namespace_path: user2.namespace.path, namespace: admin.namespace.path }
end
it_behaves_like 'forking to specified namespace_path'
@@ -4594,7 +4818,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'forks to owned subgroup' do
full_path = "#{group2.path}/#{group3.path}"
- post api("/projects/#{project.id}/fork", user2), params: { namespace: full_path }
+ post api(path, user2), params: { namespace: full_path }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['namespace']['name']).to eq(group3.name)
@@ -4602,21 +4826,21 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'fails to fork to not owned group' do
- post api("/projects/#{project.id}/fork", user2), params: { namespace: group.name }
+ post api(path, user2), params: { namespace: group.name }
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq("404 Target Namespace Not Found")
end
it 'forks to not owned group when admin' do
- post api("/projects/#{project.id}/fork", admin), params: { namespace: group.name }
+ post api(path, admin, admin_mode: true), params: { namespace: group.name }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['namespace']['name']).to eq(group.name)
end
it 'accepts a path for the target project' do
- post api("/projects/#{project.id}/fork", user2), params: { path: 'foobar' }
+ post api(path, user2), params: { path: 'foobar' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(project.name)
@@ -4629,7 +4853,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'fails to fork if path is already taken' do
- post api("/projects/#{project.id}/fork", user2), params: { path: 'foobar' }
+ post api(path, user2), params: { path: 'foobar' }
post api("/projects/#{project2.id}/fork", user2), params: { path: 'foobar' }
expect(response).to have_gitlab_http_status(:conflict)
@@ -4637,7 +4861,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'accepts custom parameters for the target project' do
- post api("/projects/#{project.id}/fork", user2),
+ post api(path, user2),
params: {
name: 'My Random Project',
description: 'A description',
@@ -4659,7 +4883,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'fails to fork if name is already taken' do
- post api("/projects/#{project.id}/fork", user2), params: { name: 'My Random Project' }
+ post api(path, user2), params: { name: 'My Random Project' }
post api("/projects/#{project2.id}/fork", user2), params: { name: 'My Random Project' }
expect(response).to have_gitlab_http_status(:conflict)
@@ -4667,7 +4891,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'forks to the same namespace with alternative path and name' do
- post api("/projects/#{project.id}/fork", user), params: { path: 'path_2', name: 'name_2' }
+ post api(path, user), params: { path: 'path_2', name: 'name_2' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq('name_2')
@@ -4679,7 +4903,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'fails to fork to the same namespace without alternative path and name' do
- post api("/projects/#{project.id}/fork", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:conflict)
expect(json_response['message']['path']).to eq(['has already been taken'])
@@ -4687,7 +4911,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'fails to fork with an unknown visibility level' do
- post api("/projects/#{project.id}/fork", user2), params: { visibility: 'something' }
+ post api(path, user2), params: { visibility: 'something' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('visibility does not have a valid value')
@@ -4696,7 +4920,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'when unauthenticated' do
it 'returns authentication error' do
- post api("/projects/#{project.id}/fork")
+ post api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
expect(json_response['message']).to eq('401 Unauthorized')
@@ -4710,7 +4934,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'denies project to be forked' do
- post api("/projects/#{project.id}/fork", admin)
+ post api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -4720,8 +4944,9 @@ RSpec.describe API::Projects, feature_category: :projects do
describe 'POST /projects/:id/housekeeping' do
let(:housekeeping) { Repositories::HousekeepingService.new(project) }
let(:params) { {} }
+ let(:path) { "/projects/#{project.id}/housekeeping" }
- subject { post api("/projects/#{project.id}/housekeeping", user), params: params }
+ subject(:request) { post api(path, user), params: params }
before do
allow(Repositories::HousekeepingService).to receive(:new).with(project, :eager).and_return(housekeeping)
@@ -4731,7 +4956,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'starts the housekeeping process' do
expect(housekeeping).to receive(:execute).once
- subject
+ request
expect(response).to have_gitlab_http_status(:created)
end
@@ -4746,7 +4971,7 @@ RSpec.describe API::Projects, feature_category: :projects do
message: "Housekeeping task: eager"
))
- subject
+ request
end
context 'when requesting prune' do
@@ -4756,7 +4981,7 @@ RSpec.describe API::Projects, feature_category: :projects do
expect(Repositories::HousekeepingService).to receive(:new).with(project, :prune).and_return(housekeeping)
expect(housekeeping).to receive(:execute).once
- subject
+ request
expect(response).to have_gitlab_http_status(:created)
end
@@ -4768,7 +4993,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'responds with bad_request' do
expect(Repositories::HousekeepingService).not_to receive(:new)
- subject
+ request
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -4778,7 +5003,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'returns conflict' do
expect(housekeeping).to receive(:execute).once.and_raise(Repositories::HousekeepingService::LeaseTaken)
- subject
+ request
expect(response).to have_gitlab_http_status(:conflict)
expect(json_response['message']).to match(/Somebody already triggered housekeeping for this resource/)
@@ -4792,7 +5017,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns forbidden error' do
- post api("/projects/#{project.id}/housekeeping", user3)
+ post api(path, user3)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -4800,7 +5025,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'when unauthenticated' do
it 'returns authentication error' do
- post api("/projects/#{project.id}/housekeeping")
+ post api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -4809,6 +5034,7 @@ RSpec.describe API::Projects, feature_category: :projects do
describe 'POST /projects/:id/repository_size' do
let(:update_statistics_service) { Projects::UpdateStatisticsService.new(project, nil, statistics: [:repository_size, :lfs_objects_size]) }
+ let(:path) { "/projects/#{project.id}/repository_size" }
before do
allow(Projects::UpdateStatisticsService).to receive(:new).with(project, nil, statistics: [:repository_size, :lfs_objects_size]).and_return(update_statistics_service)
@@ -4818,7 +5044,7 @@ RSpec.describe API::Projects, feature_category: :projects do
it 'starts the housekeeping process' do
expect(update_statistics_service).to receive(:execute).once
- post api("/projects/#{project.id}/repository_size", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:created)
end
@@ -4830,7 +5056,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'returns forbidden error' do
- post api("/projects/#{project.id}/repository_size", user3)
+ post api(path, user3)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -4838,7 +5064,7 @@ RSpec.describe API::Projects, feature_category: :projects do
context 'when unauthenticated' do
it 'returns authentication error' do
- post api("/projects/#{project.id}/repository_size")
+ post api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -4846,31 +5072,33 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'PUT /projects/:id/transfer' do
+ let(:path) { "/projects/#{project.id}/transfer" }
+
context 'when authenticated as owner' do
let(:group) { create :group }
it 'transfers the project to the new namespace' do
group.add_owner(user)
- put api("/projects/#{project.id}/transfer", user), params: { namespace: group.id }
+ put api(path, user), params: { namespace: group.id }
expect(response).to have_gitlab_http_status(:ok)
end
it 'fails when transferring to a non owned namespace' do
- put api("/projects/#{project.id}/transfer", user), params: { namespace: group.id }
+ put api(path, user), params: { namespace: group.id }
expect(response).to have_gitlab_http_status(:not_found)
end
it 'fails when transferring to an unknown namespace' do
- put api("/projects/#{project.id}/transfer", user), params: { namespace: 'unknown' }
+ put api(path, user), params: { namespace: 'unknown' }
expect(response).to have_gitlab_http_status(:not_found)
end
it 'fails on missing namespace' do
- put api("/projects/#{project.id}/transfer", user)
+ put api(path, user)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -4885,7 +5113,7 @@ RSpec.describe API::Projects, feature_category: :projects do
let(:group) { create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) }
it 'fails transferring the project to the target namespace' do
- put api("/projects/#{project.id}/transfer", user), params: { namespace: group.id }
+ put api(path, user), params: { namespace: group.id }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -4988,16 +5216,20 @@ RSpec.describe API::Projects, feature_category: :projects do
end
describe 'GET /projects/:id/storage' do
+ let(:path) { "/projects/#{project.id}/storage" }
+
+ it_behaves_like 'GET request permissions for admin mode'
+
context 'when unauthenticated' do
it 'does not return project storage data' do
- get api("/projects/#{project.id}/storage")
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
it 'returns project storage data when user is admin' do
- get api("/projects/#{project.id}/storage", create(:admin))
+ get api(path, create(:admin), admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['project_id']).to eq(project.id)
@@ -5007,7 +5239,7 @@ RSpec.describe API::Projects, feature_category: :projects do
end
it 'does not return project storage data when user is not admin' do
- get api("/projects/#{project.id}/storage", user3)
+ get api(path, user3)
expect(response).to have_gitlab_http_status(:forbidden)
end
diff --git a/spec/requests/api/protected_branches_spec.rb b/spec/requests/api/protected_branches_spec.rb
index 8e8a25a8dc2..04d5f7ac20a 100644
--- a/spec/requests/api/protected_branches_spec.rb
+++ b/spec/requests/api/protected_branches_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe API::ProtectedBranches, feature_category: :source_code_management do
let_it_be_with_reload(:project) { create(:project, :repository) }
let_it_be(:maintainer) { create(:user) }
+ let_it_be(:developer) { create(:user) }
let_it_be(:guest) { create(:user) }
let(:protected_name) { 'feature' }
@@ -16,12 +17,14 @@ RSpec.describe API::ProtectedBranches, feature_category: :source_code_management
before_all do
project.add_maintainer(maintainer)
+ project.add_developer(developer)
project.add_guest(guest)
end
describe "GET /projects/:id/protected_branches" do
let(:params) { {} }
let(:route) { "/projects/#{project.id}/protected_branches" }
+ let(:expected_branch_names) { project.protected_branches.map { |x| x['name'] } }
shared_examples_for 'protected branches' do
it 'returns the protected branches' do
@@ -39,9 +42,7 @@ RSpec.describe API::ProtectedBranches, feature_category: :source_code_management
let(:user) { maintainer }
context 'when search param is not present' do
- it_behaves_like 'protected branches' do
- let(:expected_branch_names) { project.protected_branches.map { |x| x['name'] } }
- end
+ it_behaves_like 'protected branches'
end
context 'when search param is present' do
@@ -53,6 +54,12 @@ RSpec.describe API::ProtectedBranches, feature_category: :source_code_management
end
end
+ context 'when authenticated as a developer' do
+ let(:user) { developer }
+
+ it_behaves_like 'protected branches'
+ end
+
context 'when authenticated as a guest' do
let(:user) { guest }
@@ -103,6 +110,27 @@ RSpec.describe API::ProtectedBranches, feature_category: :source_code_management
it_behaves_like 'protected branch'
end
+
+ context 'when a deploy key is present' do
+ let(:deploy_key) do
+ create(:deploy_key, deploy_keys_projects: [create(:deploy_keys_project, :write_access, project: project)])
+ end
+
+ it 'returns deploy key information' do
+ create(:protected_branch_push_access_level, protected_branch: protected_branch, deploy_key: deploy_key)
+ get api(route, user)
+
+ expect(json_response['push_access_levels']).to include(
+ a_hash_including('access_level_description' => 'Deploy key', 'deploy_key_id' => deploy_key.id)
+ )
+ end
+ end
+ end
+
+ context 'when authenticated as a developer' do
+ let(:user) { developer }
+
+ it_behaves_like 'protected branch'
end
context 'when authenticated as a guest' do
@@ -243,10 +271,20 @@ RSpec.describe API::ProtectedBranches, feature_category: :source_code_management
end
end
+ context 'when authenticated as a developer' do
+ let(:user) { developer }
+
+ it "returns a 403 error" do
+ post post_endpoint, params: { name: branch_name }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
context 'when authenticated as a guest' do
let(:user) { guest }
- it "returns a 403 error if guest" do
+ it "returns a 403 error" do
post post_endpoint, params: { name: branch_name }
expect(response).to have_gitlab_http_status(:forbidden)
@@ -266,6 +304,15 @@ RSpec.describe API::ProtectedBranches, feature_category: :source_code_management
end.to change { protected_branch.reload.allow_force_push }.from(false).to(true)
expect(response).to have_gitlab_http_status(:ok)
end
+
+ context 'when allow_force_push is not set' do
+ it 'responds with a bad request error' do
+ patch api(route, user), params: { allow_force_push: nil }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq 'allow_force_push is empty'
+ end
+ end
end
context 'when returned protected branch is invalid' do
@@ -286,6 +333,16 @@ RSpec.describe API::ProtectedBranches, feature_category: :source_code_management
end
end
+ context 'when authenticated as a developer' do
+ let(:user) { developer }
+
+ it "returns a 403 error" do
+ patch api(route, user), params: { allow_force_push: true }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
context 'when authenticated as a guest' do
let(:user) { guest }
@@ -298,42 +355,65 @@ RSpec.describe API::ProtectedBranches, feature_category: :source_code_management
end
describe "DELETE /projects/:id/protected_branches/unprotect/:branch" do
- let(:user) { maintainer }
let(:delete_endpoint) { api("/projects/#{project.id}/protected_branches/#{branch_name}", user) }
- it "unprotects a single branch" do
- delete delete_endpoint
+ context "when authenticated as a maintainer" do
+ let(:user) { maintainer }
- expect(response).to have_gitlab_http_status(:no_content)
- end
+ it "unprotects a single branch" do
+ delete delete_endpoint
- it_behaves_like '412 response' do
- let(:request) { delete_endpoint }
- end
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) { delete_endpoint }
+ end
+
+ it "returns 404 if branch does not exist" do
+ delete api("/projects/#{project.id}/protected_branches/barfoo", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'when a policy restricts rule deletion' do
+ it "prevents deletion of the protected branch rule" do
+ disallow(:destroy_protected_branch, protected_branch)
- it "returns 404 if branch does not exist" do
- delete api("/projects/#{project.id}/protected_branches/barfoo", user)
+ delete delete_endpoint
- expect(response).to have_gitlab_http_status(:not_found)
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when branch has a wildcard in its name' do
+ let(:protected_name) { 'feature*' }
+
+ it "unprotects a wildcard branch" do
+ delete delete_endpoint
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
end
- context 'when a policy restricts rule deletion' do
- it "prevents deletion of the protected branch rule" do
- disallow(:destroy_protected_branch, protected_branch)
+ context 'when authenticated as a developer' do
+ let(:user) { developer }
+ it "returns a 403 error" do
delete delete_endpoint
expect(response).to have_gitlab_http_status(:forbidden)
end
end
- context 'when branch has a wildcard in its name' do
- let(:protected_name) { 'feature*' }
+ context 'when authenticated as a guest' do
+ let(:user) { guest }
- it "unprotects a wildcard branch" do
+ it "returns a 403 error" do
delete delete_endpoint
- expect(response).to have_gitlab_http_status(:no_content)
+ expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
diff --git a/spec/requests/api/protected_tags_spec.rb b/spec/requests/api/protected_tags_spec.rb
index 5b128d4ec9e..c6398e624f8 100644
--- a/spec/requests/api/protected_tags_spec.rb
+++ b/spec/requests/api/protected_tags_spec.rb
@@ -84,6 +84,21 @@ RSpec.describe API::ProtectedTags, feature_category: :source_code_management do
it_behaves_like 'protected tag'
end
+
+ context 'when a deploy key is present' do
+ let(:deploy_key) do
+ create(:deploy_key, deploy_keys_projects: [create(:deploy_keys_project, :write_access, project: project)])
+ end
+
+ it 'returns deploy key information' do
+ create(:protected_tag_create_access_level, protected_tag: protected_tag, deploy_key: deploy_key)
+ get api(route, user)
+
+ expect(json_response['create_access_levels']).to include(
+ a_hash_including('access_level_description' => 'Deploy key', 'deploy_key_id' => deploy_key.id)
+ )
+ end
+ end
end
context 'when authenticated as a guest' do
diff --git a/spec/requests/api/pypi_packages_spec.rb b/spec/requests/api/pypi_packages_spec.rb
index 978d4f72a4a..0b2641b062c 100644
--- a/spec/requests/api/pypi_packages_spec.rb
+++ b/spec/requests/api/pypi_packages_spec.rb
@@ -14,10 +14,18 @@ RSpec.describe API::PypiPackages, feature_category: :package_registry do
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, project: project) }
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_pypi_user' } }
+ let(:snowplow_gitlab_standard_context) { snowplow_context }
let(:headers) { {} }
+ def snowplow_context(user_role: :developer)
+ if user_role == :anonymous
+ { project: project, namespace: project.namespace, property: 'i_package_pypi_user' }
+ else
+ { project: project, namespace: project.namespace, property: 'i_package_pypi_user', user: user }
+ end
+ end
+
context 'simple index API endpoint' do
let_it_be(:package) { create(:pypi_package, project: project) }
let_it_be(:package2) { create(:pypi_package, project: project) }
@@ -26,7 +34,6 @@ RSpec.describe API::PypiPackages, feature_category: :package_registry do
describe 'GET /api/v4/groups/:id/-/packages/pypi/simple' do
let(:url) { "/groups/#{group.id}/-/packages/pypi/simple" }
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_pypi_user' } }
it_behaves_like 'pypi simple index API endpoint'
it_behaves_like 'rejects PyPI access with unknown group id'
@@ -82,13 +89,13 @@ RSpec.describe API::PypiPackages, feature_category: :package_registry do
context 'simple package API endpoint' do
let_it_be(:package) { create(:pypi_package, project: project) }
- let(:snowplow_gitlab_standard_context) { { project: nil, namespace: group, property: 'i_package_pypi_user' } }
subject { get api(url), headers: headers }
describe 'GET /api/v4/groups/:id/-/packages/pypi/simple/:package_name' do
let(:package_name) { package.name }
let(:url) { "/groups/#{group.id}/-/packages/pypi/simple/#{package_name}" }
+ let(:snowplow_context) { { project: nil, namespace: project.namespace, property: 'i_package_pypi_user' } }
it_behaves_like 'pypi simple API endpoint'
it_behaves_like 'rejects PyPI access with unknown group id'
@@ -126,7 +133,7 @@ RSpec.describe API::PypiPackages, feature_category: :package_registry do
describe 'GET /api/v4/projects/:id/packages/pypi/simple/:package_name' do
let(:package_name) { package.name }
let(:url) { "/projects/#{project.id}/packages/pypi/simple/#{package_name}" }
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_pypi_user' } }
+ let(:snowplow_context) { { project: project, namespace: project.namespace, property: 'i_package_pypi_user' } }
it_behaves_like 'pypi simple API endpoint'
it_behaves_like 'rejects PyPI access with unknown project id'
@@ -242,6 +249,13 @@ RSpec.describe API::PypiPackages, feature_category: :package_registry do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:user_headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) }
let(:headers) { user_headers.merge(workhorse_headers) }
+ let(:snowplow_gitlab_standard_context) do
+ if user_role == :anonymous || (visibility_level == :public && !user_token)
+ { project: project, namespace: project.namespace, property: 'i_package_pypi_user' }
+ else
+ { project: project, namespace: project.namespace, property: 'i_package_pypi_user', user: user }
+ end
+ end
before do
project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility_level.to_s))
@@ -379,6 +393,14 @@ RSpec.describe API::PypiPackages, feature_category: :package_registry do
let_it_be(:package_name) { 'Dummy-Package' }
let_it_be(:package) { create(:pypi_package, project: project, name: package_name, version: '1.0.0') }
+ let(:snowplow_gitlab_standard_context) do
+ if user_role == :anonymous || (visibility_level == :public && !user_token)
+ { project: project, namespace: project.namespace, property: 'i_package_pypi_user' }
+ else
+ { project: project, namespace: project.namespace, property: 'i_package_pypi_user', user: user }
+ end
+ end
+
subject { get api(url), headers: headers }
describe 'GET /api/v4/groups/:id/-/packages/pypi/files/:sha256/*file_identifier' do
diff --git a/spec/requests/api/release/links_spec.rb b/spec/requests/api/release/links_spec.rb
index 462cc1e3b5d..b8c10de2302 100644
--- a/spec/requests/api/release/links_spec.rb
+++ b/spec/requests/api/release/links_spec.rb
@@ -174,7 +174,7 @@ RSpec.describe API::Release::Links, feature_category: :release_orchestration do
specify do
get api("/projects/#{project.id}/releases/v0.1/assets/links/#{link.id}", maintainer)
- expect(json_response['direct_asset_url']).to eq("http://localhost/#{project.namespace.path}/#{project.name}/-/releases/#{release.tag}/downloads/bin/bigfile.exe")
+ expect(json_response['direct_asset_url']).to eq("http://localhost/#{project.full_path}/-/releases/#{release.tag}/downloads/bin/bigfile.exe")
end
end
@@ -377,12 +377,21 @@ RSpec.describe API::Release::Links, feature_category: :release_orchestration do
expect(response).to match_response_schema('release/link')
end
+ context 'when params are invalid' do
+ it 'returns 400 error' do
+ put api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}", maintainer),
+ params: params.merge(url: 'wrong_url')
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
context 'when using `direct_asset_path`' do
it 'updates the release link' do
put api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}", maintainer),
params: params.merge(direct_asset_path: '/binaries/awesome-app.msi')
- expect(json_response['direct_asset_url']).to eq("http://localhost/#{project.namespace.path}/#{project.name}/-/releases/#{release.tag}/downloads/binaries/awesome-app.msi")
+ expect(json_response['direct_asset_url']).to eq("http://localhost/#{project.full_path}/-/releases/#{release.tag}/downloads/binaries/awesome-app.msi")
end
end
@@ -534,6 +543,21 @@ RSpec.describe API::Release::Links, feature_category: :release_orchestration do
end
end
+ context 'when destroy process fails' do
+ before do
+ allow_next_instance_of(::Releases::Links::DestroyService) do |service|
+ allow(service).to receive(:execute).and_return(ServiceResponse.error(message: 'error'))
+ end
+ end
+
+ it_behaves_like '400 response' do
+ let(:message) { 'error' }
+ let(:request) do
+ delete api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}", maintainer)
+ end
+ end
+ end
+
context 'when there are no corresponding release link' do
let!(:release_link) {}
diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb
index c3f99872cef..0b5cc3611bd 100644
--- a/spec/requests/api/releases_spec.rb
+++ b/spec/requests/api/releases_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Releases, feature_category: :release_orchestration do
+RSpec.describe API::Releases, :aggregate_failures, feature_category: :release_orchestration do
let(:project) { create(:project, :repository, :private) }
let(:maintainer) { create(:user) }
let(:reporter) { create(:user) }
@@ -422,22 +422,6 @@ RSpec.describe API::Releases, feature_category: :release_orchestration do
.to eq('release-18.04.dmg')
expect(json_response['assets']['links'].first['url'])
.to eq('https://my-external-hosting.example.com/scrambled-url/app.zip')
- expect(json_response['assets']['links'].first['external'])
- .to be_truthy
- end
-
- context 'when link is internal' do
- let(:url) do
- "#{project.web_url}/-/jobs/artifacts/v11.6.0-rc4/download?" \
- "job=rspec-mysql+41%2F50"
- end
-
- it 'has external false' do
- get api("/projects/#{project.id}/releases/v0.1", maintainer)
-
- expect(json_response['assets']['links'].first['external'])
- .to be_falsy
- end
end
end
@@ -480,7 +464,7 @@ RSpec.describe API::Releases, feature_category: :release_orchestration do
end
context 'when specified tag is not found in the project' do
- it 'returns 404 for maintater' do
+ it 'returns 404 for maintainer' do
get api("/projects/#{project.id}/releases/non_exist_tag", maintainer)
expect(response).to have_gitlab_http_status(:not_found)
@@ -1665,7 +1649,11 @@ RSpec.describe API::Releases, feature_category: :release_orchestration do
let_it_be(:release2) { create(:release, project: project2) }
let_it_be(:release3) { create(:release, project: project3) }
- context 'when authenticated as owner' do
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { "/groups/#{group1.id}/releases" }
+ end
+
+ context 'when authenticated as owner', :enable_admin_mode do
it 'gets releases from all projects in the group' do
get api("/groups/#{group1.id}/releases", admin)
@@ -1715,9 +1703,14 @@ RSpec.describe API::Releases, feature_category: :release_orchestration do
context 'with subgroups' do
let(:group) { create(:group) }
- it 'include_subgroups avoids N+1 queries' do
+ subject { get api("/groups/#{group.id}/releases", admin, admin_mode: true), params: query_params.merge({ include_subgroups: true }) }
+
+ it 'include_subgroups avoids N+1 queries', :use_sql_query_cache do
+ subject
+ expect(response).to have_gitlab_http_status(:ok)
+
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/groups/#{group.id}/releases", admin), params: query_params.merge({ include_subgroups: true })
+ subject
end.count
subgroups = create_list(:group, 10, parent: group1)
@@ -1725,7 +1718,7 @@ RSpec.describe API::Releases, feature_category: :release_orchestration do
create_list(:release, 10, project: projects[0], author: admin)
expect do
- get api("/groups/#{group.id}/releases", admin), params: query_params.merge({ include_subgroups: true })
+ subject
end.not_to exceed_all_query_limit(control_count)
end
end
diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb
index be26fe24061..8853eff0b3e 100644
--- a/spec/requests/api/repositories_spec.rb
+++ b/spec/requests/api/repositories_spec.rb
@@ -236,7 +236,6 @@ RSpec.describe API::Repositories, feature_category: :source_code_management do
get api(route, current_user)
expect(response.headers["Cache-Control"]).to eq("max-age=0, private, must-revalidate, no-store, no-cache")
- expect(response.headers["Pragma"]).to eq("no-cache")
expect(response.headers["Expires"]).to eq("Fri, 01 Jan 1990 00:00:00 GMT")
end
diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb
index 6a89e9a56df..ce05fa2b383 100644
--- a/spec/requests/api/resource_access_tokens_spec.rb
+++ b/spec/requests/api/resource_access_tokens_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe API::ResourceAccessTokens, feature_category: :authentication_and_authorization do
+RSpec.describe API::ResourceAccessTokens, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let_it_be(:user_non_priviledged) { create(:user) }
@@ -336,13 +336,33 @@ RSpec.describe API::ResourceAccessTokens, feature_category: :authentication_and_
context "when 'expires_at' is not set" do
let(:expires_at) { nil }
- it "creates a #{source_type} access token with the params", :aggregate_failures do
- create_token
+ context 'when default_pat_expiration feature flag is true' do
+ it "creates a #{source_type} access token with the default expires_at value", :aggregate_failures do
+ freeze_time do
+ create_token
+ expires_at = PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response["name"]).to eq("test")
+ expect(json_response["scopes"]).to eq(["api"])
+ expect(json_response["expires_at"]).to eq(expires_at.to_date.iso8601)
+ end
+ end
+ end
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response["name"]).to eq("test")
- expect(json_response["scopes"]).to eq(["api"])
- expect(json_response["expires_at"]).to eq(nil)
+ context 'when default_pat_expiration feature flag is false' do
+ before do
+ stub_feature_flags(default_pat_expiration: false)
+ end
+
+ it "creates a #{source_type} access token with the params", :aggregate_failures do
+ create_token
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response["name"]).to eq("test")
+ expect(json_response["scopes"]).to eq(["api"])
+ expect(json_response["expires_at"]).to eq(nil)
+ end
end
end
@@ -468,10 +488,86 @@ RSpec.describe API::ResourceAccessTokens, feature_category: :authentication_and_
end
end
end
+
+ context "POST #{source_type}s/:id/access_tokens/:token_id/rotate" do
+ let_it_be(:project_bot) { create(:user, :project_bot) }
+ let_it_be(:token) { create(:personal_access_token, user: project_bot) }
+ let_it_be(:resource_id) { resource.id }
+ let_it_be(:token_id) { token.id }
+
+ let(:path) { "/#{source_type}s/#{resource_id}/access_tokens/#{token_id}/rotate" }
+
+ before do
+ resource.add_maintainer(project_bot)
+ resource.add_owner(user)
+ end
+
+ subject(:rotate_token) { post api(path, user) }
+
+ it "allows owner to rotate token", :freeze_time do
+ rotate_token
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['token']).not_to eq(token.token)
+ expect(json_response['expires_at']).to eq((Date.today + 1.week).to_s)
+ end
+
+ context 'without permission' do
+ it 'returns an error message' do
+ another_user = create(:user)
+ resource.add_developer(another_user)
+
+ post api(path, another_user)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when service raises an error' do
+ let(:error_message) { 'boom!' }
+
+ before do
+ allow_next_instance_of(PersonalAccessTokens::RotateService) do |service|
+ allow(service).to receive(:execute).and_return(ServiceResponse.error(message: error_message))
+ end
+ end
+
+ it 'returns the same error message' do
+ rotate_token
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq("400 Bad request - #{error_message}")
+ end
+ end
+
+ context 'when token does not exist' do
+ let(:invalid_path) { "/#{source_type}s/#{resource_id}/access_tokens/#{non_existing_record_id}/rotate" }
+
+ context 'for non-admin user' do
+ it 'returns unauthorized' do
+ user = create(:user)
+ resource.add_developer(user)
+
+ post api(invalid_path, user)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'for admin user', :enable_admin_mode do
+ it 'returns not found' do
+ admin = create(:admin)
+ post api(invalid_path, admin)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
end
context 'when the resource is a project' do
- let_it_be(:resource) { create(:project) }
+ let_it_be(:resource) { create(:project, group: create(:group)) }
let_it_be(:other_resource) { create(:project) }
let_it_be(:unknown_resource) { create(:project) }
diff --git a/spec/requests/api/rubygem_packages_spec.rb b/spec/requests/api/rubygem_packages_spec.rb
index 34cf6033811..1774b43ccb3 100644
--- a/spec/requests/api/rubygem_packages_spec.rb
+++ b/spec/requests/api/rubygem_packages_spec.rb
@@ -8,6 +8,14 @@ RSpec.describe API::RubygemPackages, feature_category: :package_registry do
using RSpec::Parameterized::TableSyntax
let_it_be_with_reload(:project) { create(:project) }
+ let(:tokens) do
+ {
+ personal_access_token: personal_access_token.token,
+ deploy_token: deploy_token.token,
+ job_token: job.token
+ }
+ end
+
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, project: project) }
@@ -15,14 +23,14 @@ RSpec.describe API::RubygemPackages, feature_category: :package_registry do
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
let_it_be(:headers) { {} }
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user, property: 'i_package_rubygems_user' } }
+ let(:snowplow_gitlab_standard_context) { snowplow_context }
- let(:tokens) do
- {
- personal_access_token: personal_access_token.token,
- deploy_token: deploy_token.token,
- job_token: job.token
- }
+ def snowplow_context(user_role: :developer)
+ if user_role == :anonymous
+ { project: project, namespace: project.namespace, property: 'i_package_rubygems_user' }
+ else
+ { project: project, namespace: project.namespace, property: 'i_package_rubygems_user', user: user }
+ end
end
shared_examples 'when feature flag is disabled' do
@@ -164,7 +172,13 @@ RSpec.describe API::RubygemPackages, feature_category: :package_registry do
with_them do
let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' }
let(:headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } }
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_rubygems_user' } }
+ let(:snowplow_gitlab_standard_context) do
+ if token_type == :deploy_token
+ snowplow_context.merge(user: deploy_token)
+ else
+ snowplow_context(user_role: user_role)
+ end
+ end
before do
project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility.to_s))
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index 035f53db12e..a315bca58d1 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Search, feature_category: :global_search do
+RSpec.describe API::Search, :clean_gitlab_redis_rate_limiting, feature_category: :global_search do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project, reload: true) { create(:project, :wiki_repo, :public, name: 'awesome project', group: group) }
@@ -10,8 +10,6 @@ RSpec.describe API::Search, feature_category: :global_search do
before do
allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).and_return(0)
- allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit).and_return(1000)
- allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit_unauthenticated).and_return(1000)
end
shared_examples 'response is correct' do |schema:, size: 1|
@@ -141,7 +139,7 @@ RSpec.describe API::Search, feature_category: :global_search do
end
end
- context 'when DB timeouts occur from global searches', :aggregate_errors do
+ context 'when DB timeouts occur from global searches', :aggregate_failures do
%w(
issues
merge_requests
@@ -174,6 +172,23 @@ RSpec.describe API::Search, feature_category: :global_search do
end
end
+ context 'when there is a search error' do
+ let(:results) { instance_double('Gitlab::SearchResults', failed?: true, error: 'failed to parse query') }
+
+ before do
+ allow_next_instance_of(SearchService) do |service|
+ allow(service).to receive(:search_objects).and_return([])
+ allow(service).to receive(:search_results).and_return(results)
+ end
+ end
+
+ it 'returns 400 error' do
+ get api(endpoint, user), params: { scope: 'issues', search: 'expected to fail' }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
context 'with correct params' do
context 'for projects scope' do
before do
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 4d85849cff3..3f66cbaf2b7 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting, feature_category: :not_owned do
+RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting, feature_category: :shared do
let(:user) { create(:user) }
let_it_be(:admin) { create(:admin) }
@@ -66,6 +66,16 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting, featu
expect(json_response['jira_connect_application_key']).to eq(nil)
expect(json_response['jira_connect_proxy_url']).to eq(nil)
expect(json_response['user_defaults_to_private_profile']).to eq(false)
+ expect(json_response['default_syntax_highlighting_theme']).to eq(1)
+ expect(json_response['projects_api_rate_limit_unauthenticated']).to eq(400)
+ expect(json_response['silent_mode_enabled']).to be(false)
+ expect(json_response['slack_app_enabled']).to be(false)
+ expect(json_response['slack_app_id']).to be_nil
+ expect(json_response['slack_app_secret']).to be_nil
+ expect(json_response['slack_app_signing_secret']).to be_nil
+ expect(json_response['slack_app_verification_token']).to be_nil
+ expect(json_response['valid_runner_registrars']).to match_array(%w(project group))
+ expect(json_response['ci_max_includes']).to eq(150)
end
end
@@ -169,7 +179,16 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting, featu
jira_connect_proxy_url: 'http://example.com',
bulk_import_enabled: false,
allow_runner_registration_token: true,
- user_defaults_to_private_profile: true
+ user_defaults_to_private_profile: true,
+ default_syntax_highlighting_theme: 2,
+ projects_api_rate_limit_unauthenticated: 100,
+ silent_mode_enabled: true,
+ slack_app_enabled: true,
+ slack_app_id: 'SLACK_APP_ID',
+ slack_app_secret: 'SLACK_APP_SECRET',
+ slack_app_signing_secret: 'SLACK_APP_SIGNING_SECRET',
+ slack_app_verification_token: 'SLACK_APP_VERIFICATION_TOKEN',
+ valid_runner_registrars: ['group']
}
expect(response).to have_gitlab_http_status(:ok)
@@ -237,6 +256,15 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting, featu
expect(json_response['bulk_import_enabled']).to be(false)
expect(json_response['allow_runner_registration_token']).to be(true)
expect(json_response['user_defaults_to_private_profile']).to be(true)
+ expect(json_response['default_syntax_highlighting_theme']).to eq(2)
+ expect(json_response['projects_api_rate_limit_unauthenticated']).to be(100)
+ expect(json_response['silent_mode_enabled']).to be(true)
+ expect(json_response['slack_app_enabled']).to be(true)
+ expect(json_response['slack_app_id']).to eq('SLACK_APP_ID')
+ expect(json_response['slack_app_secret']).to eq('SLACK_APP_SECRET')
+ expect(json_response['slack_app_signing_secret']).to eq('SLACK_APP_SIGNING_SECRET')
+ expect(json_response['slack_app_verification_token']).to eq('SLACK_APP_VERIFICATION_TOKEN')
+ expect(json_response['valid_runner_registrars']).to eq(['group'])
end
end
@@ -807,6 +835,40 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting, featu
end
end
+ context 'with ci_max_includes' do
+ it 'updates the settings' do
+ put api("/application/settings", admin), params: {
+ ci_max_includes: 200
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ 'ci_max_includes' => 200
+ )
+ end
+
+ it 'allows a zero value' do
+ put api("/application/settings", admin), params: {
+ ci_max_includes: 0
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ 'ci_max_includes' => 0
+ )
+ end
+
+ it 'does not allow a nil value' do
+ put api("/application/settings", admin), params: {
+ ci_max_includes: nil
+ }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']['ci_max_includes'])
+ .to include(a_string_matching('is not a number'))
+ end
+ end
+
context 'with housekeeping enabled' do
it 'at least one of housekeeping_incremental_repack_period or housekeeping_optimize_repository_period is required' do
put api("/application/settings", admin), params: {
diff --git a/spec/requests/api/sidekiq_metrics_spec.rb b/spec/requests/api/sidekiq_metrics_spec.rb
index 1085df97cc7..1ac065f0c0c 100644
--- a/spec/requests/api/sidekiq_metrics_spec.rb
+++ b/spec/requests/api/sidekiq_metrics_spec.rb
@@ -2,12 +2,19 @@
require 'spec_helper'
-RSpec.describe API::SidekiqMetrics, feature_category: :not_owned do
+RSpec.describe API::SidekiqMetrics, :aggregate_failures, feature_category: :shared do
let(:admin) { create(:user, :admin) }
describe 'GET sidekiq/*' do
+ %w[/sidekiq/queue_metrics /sidekiq/process_metrics /sidekiq/job_stats
+ /sidekiq/compound_metrics].each do |path|
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { path }
+ end
+ end
+
it 'defines the `queue_metrics` endpoint' do
- get api('/sidekiq/queue_metrics', admin)
+ get api('/sidekiq/queue_metrics', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to match a_hash_including(
@@ -25,14 +32,14 @@ RSpec.describe API::SidekiqMetrics, feature_category: :not_owned do
end
it 'defines the `process_metrics` endpoint' do
- get api('/sidekiq/process_metrics', admin)
+ get api('/sidekiq/process_metrics', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['processes']).to be_an Array
end
it 'defines the `job_stats` endpoint' do
- get api('/sidekiq/job_stats', admin)
+ get api('/sidekiq/job_stats', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_a Hash
@@ -43,7 +50,7 @@ RSpec.describe API::SidekiqMetrics, feature_category: :not_owned do
end
it 'defines the `compound_metrics` endpoint' do
- get api('/sidekiq/compound_metrics', admin)
+ get api('/sidekiq/compound_metrics', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_a Hash
diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb
index 2bc4c177bc9..4ba2a768e01 100644
--- a/spec/requests/api/snippets_spec.rb
+++ b/spec/requests/api/snippets_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Snippets, factory_default: :keep, feature_category: :source_code_management do
+RSpec.describe API::Snippets, :aggregate_failures, factory_default: :keep, feature_category: :source_code_management do
include SnippetHelpers
let_it_be(:admin) { create(:user, :admin) }
@@ -448,7 +448,7 @@ RSpec.describe API::Snippets, factory_default: :keep, feature_category: :source_
end
context "when admin" do
- let_it_be(:token) { create(:personal_access_token, user: admin, scopes: [:sudo]) }
+ let_it_be(:token) { create(:personal_access_token, :admin_mode, user: admin, scopes: [:sudo]) }
subject do
put api("/snippets/#{snippet.id}", personal_access_token: token), params: { visibility: 'private', sudo: user.id }
@@ -499,23 +499,19 @@ RSpec.describe API::Snippets, factory_default: :keep, feature_category: :source_
end
describe "GET /snippets/:id/user_agent_detail" do
- let(:snippet) { public_snippet }
+ let(:path) { "/snippets/#{public_snippet.id}/user_agent_detail" }
- it 'exposes known attributes' do
- user_agent_detail = create(:user_agent_detail, subject: snippet)
+ let_it_be(:user_agent_detail) { create(:user_agent_detail, subject: public_snippet) }
+
+ it_behaves_like 'GET request permissions for admin mode'
- get api("/snippets/#{snippet.id}/user_agent_detail", admin)
+ it 'exposes known attributes' do
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['user_agent']).to eq(user_agent_detail.user_agent)
expect(json_response['ip_address']).to eq(user_agent_detail.ip_address)
expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted)
end
-
- it "returns unauthorized for non-admin users" do
- get api("/snippets/#{snippet.id}/user_agent_detail", user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
end
end
diff --git a/spec/requests/api/statistics_spec.rb b/spec/requests/api/statistics_spec.rb
index 85fed48a077..baac39abf2c 100644
--- a/spec/requests/api/statistics_spec.rb
+++ b/spec/requests/api/statistics_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Statistics, 'Statistics', feature_category: :devops_reports do
+RSpec.describe API::Statistics, 'Statistics', :aggregate_failures, feature_category: :devops_reports do
include ProjectForksHelper
tables_to_analyze = %w[
projects
@@ -21,6 +21,8 @@ RSpec.describe API::Statistics, 'Statistics', feature_category: :devops_reports
let(:path) { "/application/statistics" }
describe "GET /application/statistics" do
+ it_behaves_like 'GET request permissions for admin mode'
+
context 'when no user' do
it "returns authentication error" do
get api(path, nil)
@@ -43,7 +45,7 @@ RSpec.describe API::Statistics, 'Statistics', feature_category: :devops_reports
let(:admin) { create(:admin) }
it 'matches the response schema' do
- get api(path, admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('statistics')
@@ -66,7 +68,7 @@ RSpec.describe API::Statistics, 'Statistics', feature_category: :devops_reports
ApplicationRecord.connection.execute("ANALYZE #{table}")
end
- get api(path, admin)
+ get api(path, admin, admin_mode: true)
expected_statistics = {
issues: 2,
diff --git a/spec/requests/api/tags_spec.rb b/spec/requests/api/tags_spec.rb
index ab5e04246e8..604631bbf7f 100644
--- a/spec/requests/api/tags_spec.rb
+++ b/spec/requests/api/tags_spec.rb
@@ -178,7 +178,7 @@ RSpec.describe API::Tags, feature_category: :source_code_management do
end
end
- context 'with keyset pagination option', :aggregate_errors do
+ context 'with keyset pagination option', :aggregate_failures do
let(:base_params) { { pagination: 'keyset' } }
context 'with gitaly pagination params' do
diff --git a/spec/requests/api/terraform/modules/v1/packages_spec.rb b/spec/requests/api/terraform/modules/v1/packages_spec.rb
index 2bd7cb027aa..f479ca25f3c 100644
--- a/spec/requests/api/terraform/modules/v1/packages_spec.rb
+++ b/spec/requests/api/terraform/modules/v1/packages_spec.rb
@@ -415,12 +415,15 @@ RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package
with_them do
let(:snowplow_gitlab_standard_context) do
- {
+ context = {
project: project,
- user: user_role == :anonymous ? nil : user,
namespace: project.namespace,
property: 'i_package_terraform_module_user'
}
+
+ context[:user] = user if user_role != :anonymous
+
+ context
end
before do
diff --git a/spec/requests/api/terraform/state_spec.rb b/spec/requests/api/terraform/state_spec.rb
index fd34345d814..4c9f930df2f 100644
--- a/spec/requests/api/terraform/state_spec.rb
+++ b/spec/requests/api/terraform/state_spec.rb
@@ -21,6 +21,7 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
before do
stub_terraform_state_object_storage
+ stub_config(terraform_state: { enabled: true })
end
shared_examples 'endpoint with unique user tracking' do
@@ -51,7 +52,6 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
subject(:api_request) { request }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'terraform_state_api_request' }
let(:label) { 'redis_hll_counters.terraform.p_terraform_state_api_unique_users_monthly' }
@@ -82,6 +82,7 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
subject(:request) { get api(state_path), headers: auth_header }
it_behaves_like 'endpoint with unique user tracking'
+ it_behaves_like 'it depends on value of the `terraform_state.enabled` config'
context 'without authentication' do
let(:auth_header) { basic_auth_header('bad', 'token') }
@@ -113,17 +114,6 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
end
end
- context 'allow_dots_on_tf_state_names is disabled, and the state name contains a dot' do
- let(:state_name) { 'state-name-with-dot' }
- let(:state_path) { "/projects/#{project_id}/terraform/state/#{state_name}.tfstate" }
-
- before do
- stub_feature_flags(allow_dots_on_tf_state_names: false)
- end
-
- it_behaves_like 'can access terraform state'
- end
-
context 'for a project that does not exist' do
let(:project_id) { '0000' }
@@ -194,6 +184,7 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
subject(:request) { post api(state_path), headers: auth_header, as: :json, params: params }
it_behaves_like 'endpoint with unique user tracking'
+ it_behaves_like 'it depends on value of the `terraform_state.enabled` config'
context 'when terraform state with a given name is already present' do
context 'with maintainer permissions' do
@@ -275,21 +266,6 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
expect(Gitlab::Json.parse(response.body)).to be_empty
end
end
-
- context 'allow_dots_on_tf_state_names is disabled, and the state name contains a dot' do
- let(:non_existing_state_name) { 'state-name-with-dot.tfstate' }
-
- before do
- stub_feature_flags(allow_dots_on_tf_state_names: false)
- end
-
- it 'strips characters after the dot' do
- expect { request }.to change { Terraform::State.count }.by(1)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(Terraform::State.last.name).to eq('state-name-with-dot')
- end
- end
end
context 'without body' do
@@ -372,6 +348,7 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
subject(:request) { delete api(state_path), headers: auth_header }
it_behaves_like 'endpoint with unique user tracking'
+ it_behaves_like 'it depends on value of the `terraform_state.enabled` config'
shared_examples 'schedules the state for deletion' do
it 'returns empty body' do
@@ -396,18 +373,6 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
it_behaves_like 'schedules the state for deletion'
end
- context 'allow_dots_on_tf_state_names is disabled, and the state name contains a dot' do
- let(:state_name) { 'state-name-with-dot' }
- let(:state_name_with_dot) { "#{state_name}.tfstate" }
- let(:state_path) { "/projects/#{project_id}/terraform/state/#{state_name_with_dot}" }
-
- before do
- stub_feature_flags(allow_dots_on_tf_state_names: false)
- end
-
- it_behaves_like 'schedules the state for deletion'
- end
-
context 'with invalid state name' do
let(:state_name) { 'foo/bar' }
@@ -469,6 +434,7 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
request
expect(response).to have_gitlab_http_status(:conflict)
+ expect(Gitlab::Json.parse(response.body)).to include('Who' => current_user.username)
end
end
@@ -496,30 +462,10 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
context 'with a dot in the state name' do
let(:state_name) { 'test.state' }
- context 'with allow_dots_on_tf_state_names ff enabled' do
- before do
- stub_feature_flags(allow_dots_on_tf_state_names: true)
- end
-
- let(:state_name) { 'test.state' }
-
- it 'locks the terraform state' do
- request
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
-
- context 'with allow_dots_on_tf_state_names ff disabled' do
- before do
- stub_feature_flags(allow_dots_on_tf_state_names: false)
- end
-
- it 'returns 404' do
- request
+ it 'locks the terraform state' do
+ request
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ expect(response).to have_gitlab_http_status(:ok)
end
end
end
@@ -540,7 +486,6 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
before do
state.lock_xid = '123.456'
state.save!
- stub_feature_flags(allow_dots_on_tf_state_names: true)
end
subject(:request) { delete api("#{state_path}/lock"), headers: auth_header, params: params }
@@ -553,6 +498,10 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
let(:lock_id) { 'irrelevant to this test, just needs to be present' }
end
+ it_behaves_like 'it depends on value of the `terraform_state.enabled` config' do
+ let(:lock_id) { '123.456' }
+ end
+
where(given_state_name: %w[test-state test.state test%2Ffoo])
with_them do
let(:state_name) { given_state_name }
@@ -567,23 +516,6 @@ RSpec.describe API::Terraform::State, :snowplow, feature_category: :infrastructu
end
end
- context 'with allow_dots_on_tf_state_names ff disabled' do
- before do
- stub_feature_flags(allow_dots_on_tf_state_names: false)
- end
-
- context 'with dots in the state name' do
- let(:lock_id) { '123.456' }
- let(:state_name) { 'test.state' }
-
- it 'returns 404' do
- request
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
-
context 'with no lock id (force-unlock)' do
let(:params) { {} }
diff --git a/spec/requests/api/terraform/state_version_spec.rb b/spec/requests/api/terraform/state_version_spec.rb
index 28abbb5749d..94fd2984435 100644
--- a/spec/requests/api/terraform/state_version_spec.rb
+++ b/spec/requests/api/terraform/state_version_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe API::Terraform::StateVersion, feature_category: :infrastructure_a
let_it_be(:maintainer) { create(:user, maintainer_projects: [project]) }
let_it_be(:user_without_access) { create(:user) }
- let_it_be(:state) { create(:terraform_state, project: project) }
+ let_it_be_with_reload(:state) { create(:terraform_state, project: project) }
let!(:versions) { create_list(:terraform_state_version, 3, terraform_state: state) }
@@ -22,9 +22,15 @@ RSpec.describe API::Terraform::StateVersion, feature_category: :infrastructure_a
let(:version_serial) { version.version }
let(:state_version_path) { "/projects/#{project_id}/terraform/state/#{state_name}/versions/#{version_serial}" }
+ before do
+ stub_config(terraform_state: { enabled: true })
+ end
+
describe 'GET /projects/:id/terraform/state/:name/versions/:serial' do
subject(:request) { get api(state_version_path), headers: auth_header }
+ it_behaves_like 'it depends on value of the `terraform_state.enabled` config'
+
context 'with invalid authentication' do
let(:auth_header) { basic_auth_header('bad', 'token') }
@@ -147,6 +153,8 @@ RSpec.describe API::Terraform::StateVersion, feature_category: :infrastructure_a
describe 'DELETE /projects/:id/terraform/state/:name/versions/:serial' do
subject(:request) { delete api(state_version_path), headers: auth_header }
+ it_behaves_like 'it depends on value of the `terraform_state.enabled` config', { success_status: :no_content }
+
context 'with invalid authentication' do
let(:auth_header) { basic_auth_header('bad', 'token') }
diff --git a/spec/requests/api/topics_spec.rb b/spec/requests/api/topics_spec.rb
index 14719292557..560f22c94be 100644
--- a/spec/requests/api/topics_spec.rb
+++ b/spec/requests/api/topics_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Topics, feature_category: :projects do
+RSpec.describe API::Topics, :aggregate_failures, feature_category: :projects do
include WorkhorseHelpers
let_it_be(:file) { fixture_file_upload('spec/fixtures/dk.png') }
@@ -14,9 +14,11 @@ RSpec.describe API::Topics, feature_category: :projects do
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:user) { create(:user) }
- describe 'GET /topics', :aggregate_failures do
+ let(:path) { '/topics' }
+
+ describe 'GET /topics' do
it 'returns topics ordered by total_projects_count' do
- get api('/topics')
+ get api(path)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -40,13 +42,13 @@ RSpec.describe API::Topics, feature_category: :projects do
let_it_be(:topic_4) { create(:topic, name: 'unassigned topic', total_projects_count: 0) }
it 'returns topics without assigned projects' do
- get api('/topics'), params: { without_projects: true }
+ get api(path), params: { without_projects: true }
expect(json_response.map { |t| t['id'] }).to contain_exactly(topic_4.id)
end
it 'returns topics without assigned projects' do
- get api('/topics'), params: { without_projects: false }
+ get api(path), params: { without_projects: false }
expect(json_response.map { |t| t['id'] }).to contain_exactly(topic_1.id, topic_2.id, topic_3.id, topic_4.id)
end
@@ -66,7 +68,7 @@ RSpec.describe API::Topics, feature_category: :projects do
with_them do
it 'returns filtered topics' do
- get api('/topics'), params: { search: search }
+ get api(path), params: { search: search }
expect(json_response.map { |t| t['name'] }).to eq(result)
end
@@ -97,7 +99,7 @@ RSpec.describe API::Topics, feature_category: :projects do
with_them do
it 'returns paginated topics' do
- get api('/topics'), params: params
+ get api(path), params: params
expect(json_response.map { |t| t['name'] }).to eq(result)
end
@@ -105,7 +107,7 @@ RSpec.describe API::Topics, feature_category: :projects do
end
end
- describe 'GET /topic/:id', :aggregate_failures do
+ describe 'GET /topic/:id' do
it 'returns topic' do
get api("/topics/#{topic_2.id}")
@@ -130,10 +132,14 @@ RSpec.describe API::Topics, feature_category: :projects do
end
end
- describe 'POST /topics', :aggregate_failures do
+ describe 'POST /topics' do
+ let(:params) { { name: 'my-topic', title: 'My Topic' } }
+
+ it_behaves_like 'POST request permissions for admin mode'
+
context 'as administrator' do
it 'creates a topic' do
- post api('/topics/', admin), params: { name: 'my-topic', title: 'My Topic' }
+ post api('/topics/', admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq('my-topic')
@@ -142,7 +148,7 @@ RSpec.describe API::Topics, feature_category: :projects do
it 'creates a topic with avatar and description' do
workhorse_form_with_file(
- api('/topics/', admin),
+ api('/topics/', admin, admin_mode: true),
file_key: :avatar,
params: { name: 'my-topic', title: 'My Topic', description: 'my description...', avatar: file }
)
@@ -160,14 +166,14 @@ RSpec.describe API::Topics, feature_category: :projects do
end
it 'returns 400 if name is not unique (case insensitive)' do
- post api('/topics/', admin), params: { name: topic_1.name.downcase, title: 'My Topic' }
+ post api('/topics/', admin, admin_mode: true), params: { name: topic_1.name.downcase, title: 'My Topic' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['name']).to eq(['has already been taken'])
end
it 'returns 400 if title is missing' do
- post api('/topics/', admin), params: { name: 'my-topic' }
+ post api('/topics/', admin, admin_mode: true), params: { name: 'my-topic' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eql('title is missing')
@@ -176,7 +182,7 @@ RSpec.describe API::Topics, feature_category: :projects do
context 'as normal user' do
it 'returns 403 Forbidden' do
- post api('/topics/', user), params: { name: 'my-topic', title: 'My Topic' }
+ post api('/topics/', user), params: params
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -184,17 +190,23 @@ RSpec.describe API::Topics, feature_category: :projects do
context 'as anonymous' do
it 'returns 401 Unauthorized' do
- post api('/topics/'), params: { name: 'my-topic', title: 'My Topic' }
+ post api('/topics/'), params: params
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
end
- describe 'PUT /topics', :aggregate_failures do
+ describe 'PUT /topics' do
+ let(:params) { { name: 'my-topic' } }
+
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:path) { "/topics/#{topic_3.id}" }
+ end
+
context 'as administrator' do
it 'updates a topic' do
- put api("/topics/#{topic_3.id}", admin), params: { name: 'my-topic' }
+ put api("/topics/#{topic_3.id}", admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq('my-topic')
@@ -203,7 +215,7 @@ RSpec.describe API::Topics, feature_category: :projects do
it 'updates a topic with avatar and description' do
workhorse_form_with_file(
- api("/topics/#{topic_3.id}", admin),
+ api("/topics/#{topic_3.id}", admin, admin_mode: true),
method: :put,
file_key: :avatar,
params: { description: 'my description...', avatar: file }
@@ -215,7 +227,7 @@ RSpec.describe API::Topics, feature_category: :projects do
end
it 'keeps avatar when updating other fields' do
- put api("/topics/#{topic_1.id}", admin), params: { name: 'my-topic' }
+ put api("/topics/#{topic_1.id}", admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq('my-topic')
@@ -223,13 +235,13 @@ RSpec.describe API::Topics, feature_category: :projects do
end
it 'returns 404 for non existing id' do
- put api("/topics/#{non_existing_record_id}", admin), params: { name: 'my-topic' }
+ put api("/topics/#{non_existing_record_id}", admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns 400 for invalid `id` parameter' do
- put api('/topics/invalid', admin), params: { name: 'my-topic' }
+ put api('/topics/invalid', admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eql('id is invalid')
@@ -237,7 +249,7 @@ RSpec.describe API::Topics, feature_category: :projects do
context 'with blank avatar' do
it 'removes avatar' do
- put api("/topics/#{topic_1.id}", admin), params: { avatar: '' }
+ put api("/topics/#{topic_1.id}", admin, admin_mode: true), params: { avatar: '' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['avatar_url']).to be_nil
@@ -245,7 +257,7 @@ RSpec.describe API::Topics, feature_category: :projects do
end
it 'removes avatar besides other changes' do
- put api("/topics/#{topic_1.id}", admin), params: { name: 'new-topic-name', avatar: '' }
+ put api("/topics/#{topic_1.id}", admin, admin_mode: true), params: { name: 'new-topic-name', avatar: '' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq('new-topic-name')
@@ -254,7 +266,7 @@ RSpec.describe API::Topics, feature_category: :projects do
end
it 'does not remove avatar in case of other errors' do
- put api("/topics/#{topic_1.id}", admin), params: { name: topic_2.name, avatar: '' }
+ put api("/topics/#{topic_1.id}", admin, admin_mode: true), params: { name: topic_2.name, avatar: '' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(topic_1.reload.avatar_url).not_to be_nil
@@ -264,7 +276,7 @@ RSpec.describe API::Topics, feature_category: :projects do
context 'as normal user' do
it 'returns 403 Forbidden' do
- put api("/topics/#{topic_3.id}", user), params: { name: 'my-topic' }
+ put api("/topics/#{topic_3.id}", user), params: params
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -272,29 +284,37 @@ RSpec.describe API::Topics, feature_category: :projects do
context 'as anonymous' do
it 'returns 401 Unauthorized' do
- put api("/topics/#{topic_3.id}"), params: { name: 'my-topic' }
+ put api("/topics/#{topic_3.id}"), params: params
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
end
- describe 'DELETE /topics', :aggregate_failures do
+ describe 'DELETE /topics/:id' do
+ let(:params) { { name: 'my-topic' } }
+
context 'as administrator' do
- it 'deletes a topic' do
- delete api("/topics/#{topic_3.id}", admin), params: { name: 'my-topic' }
+ it 'deletes a topic with admin mode' do
+ delete api("/topics/#{topic_3.id}", admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:no_content)
end
+ it 'deletes a topic without admin mode' do
+ delete api("/topics/#{topic_3.id}", admin, admin_mode: false), params: params
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
it 'returns 404 for non existing id' do
- delete api("/topics/#{non_existing_record_id}", admin), params: { name: 'my-topic' }
+ delete api("/topics/#{non_existing_record_id}", admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns 400 for invalid `id` parameter' do
- delete api('/topics/invalid', admin), params: { name: 'my-topic' }
+ delete api('/topics/invalid', admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eql('id is invalid')
@@ -303,7 +323,7 @@ RSpec.describe API::Topics, feature_category: :projects do
context 'as normal user' do
it 'returns 403 Forbidden' do
- delete api("/topics/#{topic_3.id}", user), params: { name: 'my-topic' }
+ delete api("/topics/#{topic_3.id}", user), params: params
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -311,16 +331,21 @@ RSpec.describe API::Topics, feature_category: :projects do
context 'as anonymous' do
it 'returns 401 Unauthorized' do
- delete api("/topics/#{topic_3.id}"), params: { name: 'my-topic' }
+ delete api("/topics/#{topic_3.id}"), params: params
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
end
- describe 'POST /topics/merge', :aggregate_failures do
+ describe 'POST /topics/merge' do
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:path) { '/topics/merge' }
+ let(:params) { { source_topic_id: topic_3.id, target_topic_id: topic_2.id } }
+ end
+
context 'as administrator' do
- let_it_be(:api_url) { api('/topics/merge', admin) }
+ let_it_be(:api_url) { api('/topics/merge', admin, admin_mode: true) }
it 'merge topics' do
post api_url, params: { source_topic_id: topic_3.id, target_topic_id: topic_2.id }
diff --git a/spec/requests/api/unleash_spec.rb b/spec/requests/api/unleash_spec.rb
index 5daf7cd7b75..75b26b98228 100644
--- a/spec/requests/api/unleash_spec.rb
+++ b/spec/requests/api/unleash_spec.rb
@@ -88,6 +88,14 @@ RSpec.describe API::Unleash, feature_category: :feature_flags do
end
end
+ describe 'GET /feature_flags/unleash/:project_id/client/features', :use_clean_rails_redis_caching do
+ specify do
+ get api("/feature_flags/unleash/#{project_id}/client/features"), params: params, headers: headers
+
+ is_expected.to have_request_urgency(:medium)
+ end
+ end
+
%w(/feature_flags/unleash/:project_id/features /feature_flags/unleash/:project_id/client/features).each do |features_endpoint|
describe "GET #{features_endpoint}", :use_clean_rails_redis_caching do
let(:features_url) { features_endpoint.sub(':project_id', project_id.to_s) }
diff --git a/spec/requests/api/usage_data_non_sql_metrics_spec.rb b/spec/requests/api/usage_data_non_sql_metrics_spec.rb
index 0a6f248af2c..b2929caf676 100644
--- a/spec/requests/api/usage_data_non_sql_metrics_spec.rb
+++ b/spec/requests/api/usage_data_non_sql_metrics_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::UsageDataNonSqlMetrics, feature_category: :service_ping do
+RSpec.describe API::UsageDataNonSqlMetrics, :aggregate_failures, feature_category: :service_ping do
include UsageDataHelpers
let_it_be(:admin) { create(:user, admin: true) }
@@ -21,8 +21,12 @@ RSpec.describe API::UsageDataNonSqlMetrics, feature_category: :service_ping do
stub_database_flavor_check
end
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { endpoint }
+ end
+
it 'returns non sql metrics if user is admin' do
- get api(endpoint, admin)
+ get api(endpoint, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['counts']).to be_a(Hash)
@@ -53,7 +57,7 @@ RSpec.describe API::UsageDataNonSqlMetrics, feature_category: :service_ping do
end
it 'returns not_found for admin' do
- get api(endpoint, admin)
+ get api(endpoint, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/requests/api/usage_data_queries_spec.rb b/spec/requests/api/usage_data_queries_spec.rb
index e556064025c..ab3c38adb81 100644
--- a/spec/requests/api/usage_data_queries_spec.rb
+++ b/spec/requests/api/usage_data_queries_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require 'rake_helper'
-RSpec.describe API::UsageDataQueries, feature_category: :service_ping do
+RSpec.describe API::UsageDataQueries, :aggregate_failures, feature_category: :service_ping do
include UsageDataHelpers
let_it_be(:admin) { create(:user, admin: true) }
@@ -22,8 +22,12 @@ RSpec.describe API::UsageDataQueries, feature_category: :service_ping do
stub_feature_flags(usage_data_queries_api: true)
end
+ it_behaves_like 'GET request permissions for admin mode' do
+ let(:path) { endpoint }
+ end
+
it 'returns queries if user is admin' do
- get api(endpoint, admin)
+ get api(endpoint, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['active_user_count']).to start_with('SELECT COUNT("users"."id") FROM "users"')
@@ -54,7 +58,7 @@ RSpec.describe API::UsageDataQueries, feature_category: :service_ping do
end
it 'returns not_found for admin' do
- get api(endpoint, admin)
+ get api(endpoint, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -81,7 +85,7 @@ RSpec.describe API::UsageDataQueries, feature_category: :service_ping do
it 'matches the generated query' do
travel_to(Time.utc(2021, 1, 1)) do
- get api(endpoint, admin)
+ get api(endpoint, admin, admin_mode: true)
end
data = Gitlab::Json.parse(File.read(file))
diff --git a/spec/requests/api/users_preferences_spec.rb b/spec/requests/api/users_preferences_spec.rb
index ef9735fd8b0..067acd150f3 100644
--- a/spec/requests/api/users_preferences_spec.rb
+++ b/spec/requests/api/users_preferences_spec.rb
@@ -10,17 +10,20 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns a success status and the value has been changed' do
put api("/user/preferences", user), params: {
view_diffs_file_by_file: true,
- show_whitespace_in_diffs: true
+ show_whitespace_in_diffs: true,
+ pass_user_identities_to_ci_jwt: true
}
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['view_diffs_file_by_file']).to eq(true)
expect(json_response['show_whitespace_in_diffs']).to eq(true)
+ expect(json_response['pass_user_identities_to_ci_jwt']).to eq(true)
user.reload
expect(user.view_diffs_file_by_file).to be_truthy
expect(user.show_whitespace_in_diffs).to be_truthy
+ expect(user.pass_user_identities_to_ci_jwt).to be_truthy
end
end
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 34867b13db2..cc8be312c71 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Users, feature_category: :user_profile do
+RSpec.describe API::Users, :aggregate_failures, feature_category: :user_profile do
include WorkhorseHelpers
let_it_be(:admin) { create(:admin) }
@@ -27,9 +27,15 @@ RSpec.describe API::Users, feature_category: :user_profile do
let_it_be(:user, reload: true) { create(:user, note: '2018-11-05 | 2FA removed | user requested | www.gitlab.com') }
describe 'POST /users' do
+ let(:path) { '/users' }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { attributes_for(:user).merge({ note: 'Awesome Note' }) }
+ end
+
context 'when unauthenticated' do
it 'return authentication error' do
- post api('/users')
+ post api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -41,7 +47,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
optional_attributes = { note: 'Awesome Note' }
attributes = attributes_for(:user).merge(optional_attributes)
- post api('/users', admin), params: attributes
+ post api(path, admin, admin_mode: true), params: attributes
expect(response).to have_gitlab_http_status(:created)
expect(json_response['note']).to eq(optional_attributes[:note])
@@ -50,7 +56,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'as a regular user' do
it 'does not allow creating new user' do
- post api('/users', user), params: attributes_for(:user)
+ post api(path, user), params: attributes_for(:user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -59,12 +65,18 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "PUT /users/:id" do
+ let(:path) { "/users/#{user.id}" }
+
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:params) { { note: 'new note' } }
+ end
+
context 'when user is an admin' do
it "updates note of the user" do
new_note = '2019-07-07 | Email changed | user requested | www.gitlab.com'
expect do
- put api("/users/#{user.id}", admin), params: { note: new_note }
+ put api(path, admin, admin_mode: true), params: { note: new_note }
end.to change { user.reload.note }
.from('2018-11-05 | 2FA removed | user requested | www.gitlab.com')
.to(new_note)
@@ -77,7 +89,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when user is not an admin' do
it "cannot update their own note" do
expect do
- put api("/users/#{user.id}", user), params: { note: 'new note' }
+ put api(path, user), params: { note: 'new note' }
end.not_to change { user.reload.note }
expect(response).to have_gitlab_http_status(:forbidden)
@@ -89,7 +101,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context "when current user is an admin" do
it "returns a 204 when 2FA is disabled for the target user" do
expect do
- patch api("/users/#{user_with_2fa.id}/disable_two_factor", admin)
+ patch api("/users/#{user_with_2fa.id}/disable_two_factor", admin, admin_mode: true)
end.to change { user_with_2fa.reload.two_factor_enabled? }
.from(true)
.to(false)
@@ -103,14 +115,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
.and_return(destroy_service)
expect(destroy_service).to receive(:execute)
- patch api("/users/#{user_with_2fa.id}/disable_two_factor", admin)
+ patch api("/users/#{user_with_2fa.id}/disable_two_factor", admin, admin_mode: true)
end
it "returns a 400 if 2FA is not enabled for the target user" do
expect(TwoFactor::DestroyService).to receive(:new).and_call_original
expect do
- patch api("/users/#{user.id}/disable_two_factor", admin)
+ patch api("/users/#{user.id}/disable_two_factor", admin, admin_mode: true)
end.not_to change { user.reload.two_factor_enabled? }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -121,7 +133,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
expect(TwoFactor::DestroyService).not_to receive(:new)
expect do
- patch api("/users/#{admin_with_2fa.id}/disable_two_factor", admin)
+ patch api("/users/#{admin_with_2fa.id}/disable_two_factor", admin, admin_mode: true)
end.not_to change { admin_with_2fa.reload.two_factor_enabled? }
expect(response).to have_gitlab_http_status(:forbidden)
@@ -131,7 +143,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns a 404 if the target user cannot be found" do
expect(TwoFactor::DestroyService).not_to receive(:new)
- patch api("/users/#{non_existing_record_id}/disable_two_factor", admin)
+ patch api("/users/#{non_existing_record_id}/disable_two_factor", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq("404 User Not Found")
@@ -159,9 +171,11 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'GET /users/' do
+ let(:path) { '/users' }
+
context 'when unauthenticated' do
it "does not contain certain fields" do
- get api("/users"), params: { username: user.username }
+ get api(path), params: { username: user.username }
expect(json_response.first).not_to have_key('note')
expect(json_response.first).not_to have_key('namespace_id')
@@ -172,8 +186,9 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when authenticated' do
context 'as a regular user' do
it 'does not contain certain fields' do
- get api("/users", user), params: { username: user.username }
+ get api(path, user), params: { username: user.username }
+ expect(response).to have_gitlab_http_status(:ok)
expect(json_response.first).not_to have_key('note')
expect(json_response.first).not_to have_key('namespace_id')
expect(json_response.first).not_to have_key('created_by')
@@ -182,7 +197,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'as an admin' do
it 'contains the note of users' do
- get api("/users", admin), params: { username: user.username }
+ get api(path, admin, admin_mode: true), params: { username: user.username }
expect(response).to have_gitlab_http_status(:success)
expect(json_response.first).to have_key('note')
@@ -191,7 +206,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'with `created_by` details' do
it 'has created_by as nil with a self-registered account' do
- get api("/users", admin), params: { username: user.username }
+ get api(path, admin, admin_mode: true), params: { username: user.username }
expect(response).to have_gitlab_http_status(:success)
expect(json_response.first).to have_key('created_by')
@@ -201,7 +216,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'is created_by a user and has those details' do
created = create(:user, created_by_id: user.id)
- get api("/users", admin), params: { username: created.username }
+ get api(path, admin, admin_mode: true), params: { username: created.username }
expect(response).to have_gitlab_http_status(:success)
expect(json_response.first['created_by'].symbolize_keys)
@@ -217,7 +232,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'avoids N+1 queries when requested by admin' do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/users", admin)
+ get api(path, admin)
end.count
create_list(:user, 3)
@@ -227,19 +242,19 @@ RSpec.describe API::Users, feature_category: :user_profile do
# Refer issue https://gitlab.com/gitlab-org/gitlab/-/issues/367080
expect do
- get api("/users", admin)
+ get api(path, admin)
end.not_to exceed_all_query_limit(control_count + 3)
end
it 'avoids N+1 queries when requested by a regular user' do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/users", user)
+ get api(path, user)
end.count
create_list(:user, 3)
expect do
- get api("/users", user)
+ get api(path, user)
end.not_to exceed_all_query_limit(control_count)
end
end
@@ -247,11 +262,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'GET /user' do
+ let(:path) { '/user' }
+
context 'when authenticated' do
context 'as an admin' do
context 'accesses their own profile' do
it 'contains the note of the user' do
- get api("/user", admin)
+ get api(path, admin, admin_mode: true)
expect(json_response).to have_key('note')
expect(json_response['note']).to eq(admin.note)
@@ -259,7 +276,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
context 'sudo' do
- let(:admin_personal_access_token) { create(:personal_access_token, user: admin, scopes: %w[api sudo]).token }
+ let(:admin_personal_access_token) { create(:personal_access_token, :admin_mode, user: admin, scopes: %w[api sudo]).token }
context 'accesses the profile of another regular user' do
it 'does not contain the note of the user' do
@@ -286,7 +303,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'as a regular user' do
it 'does not contain the note of the user' do
- get api("/user", user)
+ get api(path, user)
expect(json_response).not_to have_key('note')
expect(json_response).not_to have_key('namespace_id')
@@ -318,15 +335,17 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'GET /users' do
+ let(:path) { '/users' }
+
context "when unauthenticated" do
it "returns authorization error when the `username` parameter is not passed" do
- get api("/users")
+ get api(path)
expect(response).to have_gitlab_http_status(:forbidden)
end
it "returns the user when a valid `username` parameter is passed" do
- get api("/users"), params: { username: user.username }
+ get api(path), params: { username: user.username }
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(json_response.size).to eq(1)
@@ -335,7 +354,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns the user when a valid `username` parameter is passed (case insensitive)" do
- get api("/users"), params: { username: user.username.upcase }
+ get api(path), params: { username: user.username.upcase }
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(json_response.size).to eq(1)
@@ -344,7 +363,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns an empty response when an invalid `username` parameter is passed" do
- get api("/users"), params: { username: 'invalid' }
+ get api(path), params: { username: 'invalid' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
@@ -352,14 +371,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "does not return the highest role" do
- get api("/users"), params: { username: user.username }
+ get api(path), params: { username: user.username }
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(json_response.first.keys).not_to include 'highest_role'
end
it "does not return the current or last sign-in ip addresses" do
- get api("/users"), params: { username: user.username }
+ get api(path), params: { username: user.username }
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(json_response.first.keys).not_to include 'current_sign_in_ip'
@@ -372,13 +391,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns authorization error when the `username` parameter refers to an inaccessible user" do
- get api("/users"), params: { username: user.username }
+ get api(path), params: { username: user.username }
expect(response).to have_gitlab_http_status(:forbidden)
end
it "returns authorization error when the `username` parameter is not passed" do
- get api("/users")
+ get api(path)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -394,7 +413,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when authenticate as a regular user' do
it "renders 200" do
- get api("/users", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/user/basics')
end
@@ -402,7 +421,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when authenticate as an admin' do
it "renders 200" do
- get api("/users", admin)
+ get api(path, admin)
expect(response).to match_response_schema('public_api/v4/user/basics')
end
@@ -410,7 +429,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns an array of users" do
- get api("/users", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(response).to include_pagination_headers
@@ -466,7 +485,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'does not reveal the `is_admin` flag of the user' do
- get api('/users', user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(json_response.first.keys).not_to include 'is_admin'
@@ -528,7 +547,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context "when admin" do
context 'when sudo is defined' do
it 'does not return 500' do
- admin_personal_access_token = create(:personal_access_token, user: admin, scopes: [:sudo])
+ admin_personal_access_token = create(:personal_access_token, :admin_mode, user: admin, scopes: [:sudo])
get api("/users?sudo=#{user.id}", admin, personal_access_token: admin_personal_access_token)
expect(response).to have_gitlab_http_status(:success)
@@ -536,14 +555,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns an array of users" do
- get api("/users", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admins')
expect(response).to include_pagination_headers
end
it "users contain the `namespace_id` field" do
- get api("/users", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:success)
expect(response).to match_response_schema('public_api/v4/user/admins')
@@ -554,7 +573,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns an array of external users" do
create(:user, external: true)
- get api("/users?external=true", admin)
+ get api("/users?external=true", admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admins')
expect(response).to include_pagination_headers
@@ -562,7 +581,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns one user by external UID" do
- get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}&provider=#{omniauth_user.identities.first.provider}", admin)
+ get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}&provider=#{omniauth_user.identities.first.provider}", admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admins')
expect(json_response.size).to eq(1)
@@ -570,13 +589,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns 400 error if provider with no extern_uid" do
- get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}", admin)
+ get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
it "returns 400 error if provider with no extern_uid" do
- get api("/users?provider=#{omniauth_user.identities.first.provider}", admin)
+ get api("/users?provider=#{omniauth_user.identities.first.provider}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -584,7 +603,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns a user created before a specific date" do
user = create(:user, created_at: Date.new(2000, 1, 1))
- get api("/users?created_before=2000-01-02T00:00:00.060Z", admin)
+ get api("/users?created_before=2000-01-02T00:00:00.060Z", admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admins')
expect(json_response.size).to eq(1)
@@ -594,7 +613,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns no users created before a specific date" do
create(:user, created_at: Date.new(2001, 1, 1))
- get api("/users?created_before=2000-01-02T00:00:00.060Z", admin)
+ get api("/users?created_before=2000-01-02T00:00:00.060Z", admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admins')
expect(json_response.size).to eq(0)
@@ -603,7 +622,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns users created before and after a specific date" do
user = create(:user, created_at: Date.new(2001, 1, 1))
- get api("/users?created_before=2001-01-02T00:00:00.060Z&created_after=1999-01-02T00:00:00.060", admin)
+ get api("/users?created_before=2001-01-02T00:00:00.060Z&created_after=1999-01-02T00:00:00.060", admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admins')
expect(json_response.size).to eq(1)
@@ -615,7 +634,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
# - admin
# - user
- get api('/users', admin), params: { order_by: 'id', sort: 'asc' }
+ get api(path, admin, admin_mode: true), params: { order_by: 'id', sort: 'asc' }
expect(response).to match_response_schema('public_api/v4/user/admins')
expect(json_response.size).to eq(2)
@@ -626,7 +645,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns users with 2fa enabled' do
user_with_2fa = create(:user, :two_factor_via_otp)
- get api('/users', admin), params: { two_factor: 'enabled' }
+ get api(path, admin, admin_mode: true), params: { two_factor: 'enabled' }
expect(response).to match_response_schema('public_api/v4/user/admins')
expect(json_response.size).to eq(1)
@@ -638,7 +657,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
create(:project, namespace: user.namespace)
create(:project, namespace: admin.namespace)
- get api('/users', admin), params: { without_projects: true }
+ get api(path, admin, admin_mode: true), params: { without_projects: true }
expect(response).to match_response_schema('public_api/v4/user/admins')
expect(json_response.size).to eq(1)
@@ -646,7 +665,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns 400 when provided incorrect sort params' do
- get api('/users', admin), params: { order_by: 'magic', sort: 'asc' }
+ get api(path, admin, admin_mode: true), params: { order_by: 'magic', sort: 'asc' }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -654,7 +673,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'admins param' do
it 'returns only admins' do
- get api("/users?admins=true", admin)
+ get api("/users?admins=true", admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(json_response.size).to eq(1)
@@ -666,34 +685,36 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe "GET /users/:id" do
let_it_be(:user2, reload: true) { create(:user, username: 'another_user') }
+ let(:path) { "/users/#{user.id}" }
+
before do
allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?)
.with(:users_get_by_id, scope: user, users_allowlist: []).and_return(false)
end
it "returns a user by id" do
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response['username']).to eq(user.username)
end
it "does not return the user's `is_admin` flag" do
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'is_admin'
end
it "does not return the user's `highest_role`" do
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'highest_role'
end
it "does not return the user's sign in IPs" do
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'current_sign_in_ip'
@@ -701,7 +722,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "does not contain plan or trial data" do
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'plan'
@@ -760,7 +781,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'does not contain the note of the user' do
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(json_response).not_to have_key('note')
expect(json_response).not_to have_key('sign_in_count')
@@ -772,7 +793,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
.to receive(:throttled?).with(:users_get_by_id, scope: user, users_allowlist: [])
.and_return(false)
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -785,7 +806,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
.to receive(:throttled?).with(:users_get_by_id, scope: user, users_allowlist: [])
.and_return(true)
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:too_many_requests)
end
@@ -794,7 +815,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
expect(Gitlab::ApplicationRateLimiter)
.not_to receive(:throttled?)
- get api("/users/#{user.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -812,7 +833,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
.to receive(:throttled?).with(:users_get_by_id, scope: user, users_allowlist: allowlist)
.and_call_original
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -827,7 +848,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns job title of a user' do
- get api("/users/#{user.id}", user)
+ get api(path, user)
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response['job_title']).to eq(job_title)
@@ -836,7 +857,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when authenticated as admin' do
it 'contains the note of the user' do
- get api("/users/#{user.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(json_response).to have_key('note')
expect(json_response['note']).to eq(user.note)
@@ -844,28 +865,28 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'includes the `is_admin` field' do
- get api("/users/#{user.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admin')
expect(json_response['is_admin']).to be(false)
end
it "includes the `created_at` field for private users" do
- get api("/users/#{private_user.id}", admin)
+ get api("/users/#{private_user.id}", admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admin')
expect(json_response.keys).to include 'created_at'
end
it 'includes the `highest_role` field' do
- get api("/users/#{user.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admin')
expect(json_response['highest_role']).to be(0)
end
it 'includes the `namespace_id` field' do
- get api("/users/#{user.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:success)
expect(response).to match_response_schema('public_api/v4/user/admin')
@@ -874,13 +895,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
if Gitlab.ee?
it 'does not include values for plan or trial' do
- get api("/users/#{user.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/basic')
end
else
it 'does not include plan or trial data' do
- get api("/users/#{user.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'plan'
@@ -890,7 +911,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when user has not logged in' do
it 'does not include the sign in IPs' do
- get api("/users/#{user.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admin')
expect(json_response).to include('current_sign_in_ip' => nil, 'last_sign_in_ip' => nil)
@@ -901,7 +922,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let_it_be(:signed_in_user) { create(:user, :with_sign_ins) }
it 'includes the sign in IPs' do
- get api("/users/#{signed_in_user.id}", admin)
+ get api("/users/#{signed_in_user.id}", admin, admin_mode: true)
expect(response).to match_response_schema('public_api/v4/user/admin')
expect(json_response['current_sign_in_ip']).to eq('127.0.0.1')
@@ -912,14 +933,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'for an anonymous user' do
it 'returns 403' do
- get api("/users/#{user.id}")
+ get api(path)
expect(response).to have_gitlab_http_status(:forbidden)
end
end
it "returns a 404 error if user id not found" do
- get api("/users/0", user)
+ get api("/users/#{non_existing_record_id}", user)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
@@ -954,10 +975,11 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe 'POST /users/:id/follow' do
let(:followee) { create(:user) }
+ let(:path) { "/users/#{followee.id}/follow" }
context 'on an unfollowed user' do
it 'follows the user' do
- post api("/users/#{followee.id}/follow", user)
+ post api(path, user)
expect(user.followees).to contain_exactly(followee)
expect(response).to have_gitlab_http_status(:created)
@@ -967,7 +989,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
stub_const('Users::UserFollowUser::MAX_FOLLOWEE_LIMIT', 2)
Users::UserFollowUser::MAX_FOLLOWEE_LIMIT.times { user.follow(create(:user)) }
- post api("/users/#{followee.id}/follow", user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:bad_request)
expected_message = format(_("You can't follow more than %{limit} users. To follow more users, unfollow some others."), limit: Users::UserFollowUser::MAX_FOLLOWEE_LIMIT)
expect(json_response['message']).to eq(expected_message)
@@ -981,16 +1003,31 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'does not change following' do
- post api("/users/#{followee.id}/follow", user)
+ post api(path, user)
expect(user.followees).to contain_exactly(followee)
expect(response).to have_gitlab_http_status(:not_modified)
end
end
+
+ context 'on a user with disabled following' do
+ before do
+ user.enabled_following = false
+ user.save!
+ end
+
+ it 'does not change following' do
+ post api("/users/#{followee.id}/follow", user)
+
+ expect(user.followees).to be_empty
+ expect(response).to have_gitlab_http_status(:not_modified)
+ end
+ end
end
describe 'POST /users/:id/unfollow' do
let(:followee) { create(:user) }
+ let(:path) { "/users/#{followee.id}/unfollow" }
context 'on a followed user' do
before do
@@ -998,7 +1035,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'unfollow the user' do
- post api("/users/#{followee.id}/unfollow", user)
+ post api(path, user)
expect(user.followees).to be_empty
expect(response).to have_gitlab_http_status(:created)
@@ -1007,7 +1044,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'on an unfollowed user' do
it 'does not change following' do
- post api("/users/#{followee.id}/unfollow", user)
+ post api(path, user)
expect(user.followees).to be_empty
expect(response).to have_gitlab_http_status(:not_modified)
@@ -1017,6 +1054,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe 'GET /users/:id/followers' do
let(:follower) { create(:user) }
+ let(:path) { "/users/#{user.id}/followers" }
context 'for an anonymous user' do
it 'returns 403' do
@@ -1030,7 +1068,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'lists followers' do
follower.follow(user)
- get api("/users/#{user.id}/followers", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1049,7 +1087,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'user does not have any follower' do
it 'does list nothing' do
- get api("/users/#{user.id}/followers", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1060,6 +1098,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe 'GET /users/:id/following' do
let(:followee) { create(:user) }
+ let(:path) { "/users/#{user.id}/followers" }
context 'for an anonymous user' do
it 'returns 403' do
@@ -1073,7 +1112,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'lists following user' do
user.follow(followee)
- get api("/users/#{user.id}/following", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1092,7 +1131,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'user does not have any follower' do
it 'does list nothing' do
- get api("/users/#{user.id}/following", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -1102,14 +1141,20 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "POST /users" do
+ let(:path) { '/users' }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { attributes_for(:user, projects_limit: 3) }
+ end
+
it "creates user" do
expect do
- post api("/users", admin), params: attributes_for(:user, projects_limit: 3)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user, projects_limit: 3)
end.to change { User.count }.by(1)
end
it "creates user with correct attributes" do
- post api('/users', admin), params: attributes_for(:user, admin: true, can_create_group: true)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user, admin: true, can_create_group: true)
expect(response).to have_gitlab_http_status(:created)
user_id = json_response['id']
new_user = User.find(user_id)
@@ -1121,13 +1166,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
optional_attributes = { confirm: true, theme_id: 2, color_scheme_id: 4 }
attributes = attributes_for(:user).merge(optional_attributes)
- post api('/users', admin), params: attributes
+ post api(path, admin, admin_mode: true), params: attributes
expect(response).to have_gitlab_http_status(:created)
end
it "creates non-admin user" do
- post api('/users', admin), params: attributes_for(:user, admin: false, can_create_group: false)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user, admin: false, can_create_group: false)
expect(response).to have_gitlab_http_status(:created)
user_id = json_response['id']
new_user = User.find(user_id)
@@ -1136,7 +1181,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "creates non-admin users by default" do
- post api('/users', admin), params: attributes_for(:user)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user)
expect(response).to have_gitlab_http_status(:created)
user_id = json_response['id']
new_user = User.find(user_id)
@@ -1144,13 +1189,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns 201 Created on success" do
- post api("/users", admin), params: attributes_for(:user, projects_limit: 3)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user, projects_limit: 3)
expect(response).to match_response_schema('public_api/v4/user/admin')
expect(response).to have_gitlab_http_status(:created)
end
it 'creates non-external users by default' do
- post api("/users", admin), params: attributes_for(:user)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user)
expect(response).to have_gitlab_http_status(:created)
user_id = json_response['id']
@@ -1159,7 +1204,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'allows an external user to be created' do
- post api("/users", admin), params: attributes_for(:user, external: true)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user, external: true)
expect(response).to have_gitlab_http_status(:created)
user_id = json_response['id']
@@ -1168,7 +1213,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "creates user with reset password" do
- post api('/users', admin), params: attributes_for(:user, reset_password: true).except(:password)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user, reset_password: true).except(:password)
expect(response).to have_gitlab_http_status(:created)
@@ -1181,7 +1226,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "creates user with random password" do
params = attributes_for(:user, force_random_password: true)
params.delete(:password)
- post api('/users', admin), params: params
+ post api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:created)
@@ -1192,7 +1237,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "creates user with private profile" do
- post api('/users', admin), params: attributes_for(:user, private_profile: true)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user, private_profile: true)
expect(response).to have_gitlab_http_status(:created)
@@ -1204,7 +1249,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "creates user with view_diffs_file_by_file" do
- post api('/users', admin), params: attributes_for(:user, view_diffs_file_by_file: true)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user, view_diffs_file_by_file: true)
expect(response).to have_gitlab_http_status(:created)
@@ -1217,7 +1262,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "creates user with avatar" do
workhorse_form_with_file(
- api('/users', admin),
+ api(path, admin, admin_mode: true),
method: :post,
file_key: :avatar,
params: attributes_for(:user, avatar: fixture_file_upload('spec/fixtures/banana_sample.gif', 'image/gif'))
@@ -1232,7 +1277,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "does not create user with invalid email" do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
email: 'invalid email',
password: User.random_password,
@@ -1242,22 +1287,22 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns 400 error if name not given' do
- post api('/users', admin), params: attributes_for(:user).except(:name)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user).except(:name)
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'returns 400 error if password not given' do
- post api('/users', admin), params: attributes_for(:user).except(:password)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user).except(:password)
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'returns 400 error if email not given' do
- post api('/users', admin), params: attributes_for(:user).except(:email)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user).except(:email)
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'returns 400 error if username not given' do
- post api('/users', admin), params: attributes_for(:user).except(:username)
+ post api(path, admin, admin_mode: true), params: attributes_for(:user).except(:username)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -1265,13 +1310,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
optional_attributes = { theme_id: 50, color_scheme_id: 50 }
attributes = attributes_for(:user).merge(optional_attributes)
- post api('/users', admin), params: attributes
+ post api(path, admin, admin_mode: true), params: attributes
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'returns 400 error if user does not validate' do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
password: 'pass',
email: 'test@example.com',
@@ -1288,12 +1333,12 @@ RSpec.describe API::Users, feature_category: :user_profile do
expect(json_response['message']['projects_limit'])
.to eq(['must be greater than or equal to 0'])
expect(json_response['message']['username'])
- .to eq([Gitlab::PathRegex.namespace_format_message])
+ .to match_array([Gitlab::PathRegex.namespace_format_message, Gitlab::Regex.oci_repository_path_regex_message])
end
it 'tracks weak password errors' do
attributes = attributes_for(:user).merge({ password: "password" })
- post api('/users', admin), params: attributes
+ post api(path, admin, admin_mode: true), params: attributes
expect(json_response['message']['password'])
.to eq(['must not contain commonly used combinations of words and letters'])
@@ -1306,13 +1351,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "is not available for non admin users" do
- post api("/users", user), params: attributes_for(:user)
+ post api(path, user), params: attributes_for(:user)
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'with existing user' do
before do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
email: 'test@example.com',
password: User.random_password,
@@ -1323,7 +1368,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 409 conflict error if user with same email exists' do
expect do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
name: 'foo',
email: 'test@example.com',
@@ -1337,7 +1382,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 409 conflict error if same username exists' do
expect do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
name: 'foo',
email: 'foo@example.com',
@@ -1351,7 +1396,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 409 conflict error if same username exists (case insensitive)' do
expect do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
name: 'foo',
email: 'foo@example.com',
@@ -1364,7 +1409,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'creates user with new identity' do
- post api("/users", admin), params: attributes_for(:user, provider: 'github', extern_uid: '67890')
+ post api(path, admin, admin_mode: true), params: attributes_for(:user, provider: 'github', extern_uid: '67890')
expect(response).to have_gitlab_http_status(:created)
expect(json_response['identities'].first['extern_uid']).to eq('67890')
@@ -1378,7 +1423,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 409 conflict error' do
expect do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
name: 'foo',
email: confirmed_user.email,
@@ -1396,7 +1441,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 409 conflict error' do
expect do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
name: 'foo',
email: unconfirmed_user.email,
@@ -1416,7 +1461,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 409 conflict error' do
expect do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
name: 'foo',
email: email.email,
@@ -1434,7 +1479,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'does not create user' do
expect do
- post api('/users', admin),
+ post api(path, admin, admin_mode: true),
params: {
name: 'foo',
email: email.email,
@@ -1465,7 +1510,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
shared_examples_for 'creates the user with the value of `private_profile` based on the application setting' do
specify do
- post api("/users", admin), params: params
+ post api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:created)
user = User.find_by(id: json_response['id'], private_profile: true)
@@ -1479,7 +1524,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when the attribute is overridden in params' do
it 'creates the user with the value of `private_profile` same as the value of the overridden param' do
- post api("/users", admin), params: params.merge(private_profile: false)
+ post api(path, admin, admin_mode: true), params: params.merge(private_profile: false)
expect(response).to have_gitlab_http_status(:created)
user = User.find_by(id: json_response['id'], private_profile: false)
@@ -1497,8 +1542,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "PUT /users/:id" do
+ let(:path) { "/users/#{user.id}" }
+
+ it_behaves_like 'PUT request permissions for admin mode' do
+ let(:params) { { bio: 'new test bio' } }
+ end
+
it "returns 200 OK on success" do
- put api("/users/#{user.id}", admin), params: { bio: 'new test bio' }
+ put api(path, admin, admin_mode: true), params: { bio: 'new test bio' }
expect(response).to match_response_schema('public_api/v4/user/admin')
expect(response).to have_gitlab_http_status(:ok)
@@ -1506,7 +1557,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'updating password' do
def update_password(user, admin, password = User.random_password)
- put api("/users/#{user.id}", admin), params: { password: password }
+ put api("/users/#{user.id}", admin, admin_mode: true), params: { password: password }
end
context 'admin updates their own password' do
@@ -1564,7 +1615,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "updates user with new bio" do
- put api("/users/#{user.id}", admin), params: { bio: 'new test bio' }
+ put api(path, admin, admin_mode: true), params: { bio: 'new test bio' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['bio']).to eq('new test bio')
@@ -1574,7 +1625,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "updates user with empty bio" do
user.update!(bio: 'previous bio')
- put api("/users/#{user.id}", admin), params: { bio: '' }
+ put api(path, admin, admin_mode: true), params: { bio: '' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['bio']).to eq('')
@@ -1582,7 +1633,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'updates user with nil bio' do
- put api("/users/#{user.id}", admin), params: { bio: nil }
+ put api(path, admin, admin_mode: true), params: { bio: nil }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['bio']).to eq('')
@@ -1590,7 +1641,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "updates user with organization" do
- put api("/users/#{user.id}", admin), params: { organization: 'GitLab' }
+ put api(path, admin, admin_mode: true), params: { organization: 'GitLab' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['organization']).to eq('GitLab')
@@ -1599,7 +1650,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'updates user with avatar' do
workhorse_form_with_file(
- api("/users/#{user.id}", admin),
+ api(path, admin, admin_mode: true),
method: :put,
file_key: :avatar,
params: { avatar: fixture_file_upload('spec/fixtures/banana_sample.gif', 'image/gif') }
@@ -1615,7 +1666,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'updates user with a new email' do
old_email = user.email
old_notification_email = user.notification_email_or_default
- put api("/users/#{user.id}", admin), params: { email: 'new@email.com' }
+ put api(path, admin, admin_mode: true), params: { email: 'new@email.com' }
user.reload
@@ -1627,7 +1678,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'skips reconfirmation when requested' do
- put api("/users/#{user.id}", admin), params: { email: 'new@email.com', skip_reconfirmation: true }
+ put api(path, admin, admin_mode: true), params: { email: 'new@email.com', skip_reconfirmation: true }
user.reload
@@ -1637,7 +1688,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'updates user with their own username' do
- put api("/users/#{user.id}", admin), params: { username: user.username }
+ put api(path, admin, admin_mode: true), params: { username: user.username }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['username']).to eq(user.username)
@@ -1645,14 +1696,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "updates user's existing identity" do
- put api("/users/#{ldap_user.id}", admin), params: { provider: 'ldapmain', extern_uid: '654321' }
+ put api("/users/#{ldap_user.id}", admin, admin_mode: true), params: { provider: 'ldapmain', extern_uid: '654321' }
expect(response).to have_gitlab_http_status(:ok)
expect(ldap_user.reload.identities.first.extern_uid).to eq('654321')
end
it 'updates user with new identity' do
- put api("/users/#{user.id}", admin), params: { provider: 'github', extern_uid: 'john' }
+ put api(path, admin, admin_mode: true), params: { provider: 'github', extern_uid: 'john' }
expect(response).to have_gitlab_http_status(:ok)
expect(user.reload.identities.first.extern_uid).to eq('john')
@@ -1660,14 +1711,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "updates admin status" do
- put api("/users/#{user.id}", admin), params: { admin: true }
+ put api(path, admin, admin_mode: true), params: { admin: true }
expect(response).to have_gitlab_http_status(:ok)
expect(user.reload.admin).to eq(true)
end
it "updates external status" do
- put api("/users/#{user.id}", admin), params: { external: true }
+ put api(path, admin, admin_mode: true), params: { external: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['external']).to eq(true)
@@ -1675,14 +1726,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "does have default values for theme and color-scheme ID" do
- put api("/users/#{user.id}", admin), params: {}
+ put api(path, admin, admin_mode: true), params: {}
expect(user.reload.theme_id).to eq(Gitlab::Themes.default.id)
expect(user.reload.color_scheme_id).to eq(Gitlab::ColorSchemes.default.id)
end
it "updates viewing diffs file by file" do
- put api("/users/#{user.id}", admin), params: { view_diffs_file_by_file: true }
+ put api(path, admin, admin_mode: true), params: { view_diffs_file_by_file: true }
expect(response).to have_gitlab_http_status(:ok)
expect(user.reload.user_preference.view_diffs_file_by_file?).to eq(true)
@@ -1693,7 +1744,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
current_value = user.private_profile
new_value = !current_value
- put api("/users/#{user.id}", admin), params: { private_profile: new_value }
+ put api(path, admin, admin_mode: true), params: { private_profile: new_value }
expect(response).to have_gitlab_http_status(:ok)
expect(user.reload.private_profile).to eq(new_value)
@@ -1707,7 +1758,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "updates private_profile to value of the application setting" do
user.update!(private_profile: false)
- put api("/users/#{user.id}", admin), params: { private_profile: nil }
+ put api(path, admin, admin_mode: true), params: { private_profile: nil }
expect(response).to have_gitlab_http_status(:ok)
expect(user.reload.private_profile).to eq(true)
@@ -1717,7 +1768,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "does not modify private profile when field is not provided" do
user.update!(private_profile: true)
- put api("/users/#{user.id}", admin), params: {}
+ put api(path, admin, admin_mode: true), params: {}
expect(response).to have_gitlab_http_status(:ok)
expect(user.reload.private_profile).to eq(true)
@@ -1730,7 +1781,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
user.update!(theme_id: theme.id, color_scheme_id: scheme.id)
- put api("/users/#{user.id}", admin), params: {}
+ put api(path, admin, admin_mode: true), params: {}
expect(response).to have_gitlab_http_status(:ok)
expect(user.reload.theme_id).to eq(theme.id)
@@ -1740,7 +1791,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "does not update admin status" do
admin_user = create(:admin)
- put api("/users/#{admin_user.id}", admin), params: { can_create_group: false }
+ put api("/users/#{admin_user.id}", admin, admin_mode: true), params: { can_create_group: false }
expect(response).to have_gitlab_http_status(:ok)
expect(admin_user.reload.admin).to eq(true)
@@ -1748,35 +1799,35 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "does not allow invalid update" do
- put api("/users/#{user.id}", admin), params: { email: 'invalid email' }
+ put api(path, admin, admin_mode: true), params: { email: 'invalid email' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(user.reload.email).not_to eq('invalid email')
end
it "updates theme id" do
- put api("/users/#{user.id}", admin), params: { theme_id: 5 }
+ put api(path, admin, admin_mode: true), params: { theme_id: 5 }
expect(response).to have_gitlab_http_status(:ok)
expect(user.reload.theme_id).to eq(5)
end
it "does not update invalid theme id" do
- put api("/users/#{user.id}", admin), params: { theme_id: 50 }
+ put api(path, admin, admin_mode: true), params: { theme_id: 50 }
expect(response).to have_gitlab_http_status(:bad_request)
expect(user.reload.theme_id).not_to eq(50)
end
it "updates color scheme id" do
- put api("/users/#{user.id}", admin), params: { color_scheme_id: 5 }
+ put api(path, admin, admin_mode: true), params: { color_scheme_id: 5 }
expect(response).to have_gitlab_http_status(:ok)
expect(user.reload.color_scheme_id).to eq(5)
end
it "does not update invalid color scheme id" do
- put api("/users/#{user.id}", admin), params: { color_scheme_id: 50 }
+ put api(path, admin, admin_mode: true), params: { color_scheme_id: 50 }
expect(response).to have_gitlab_http_status(:bad_request)
expect(user.reload.color_scheme_id).not_to eq(50)
@@ -1785,7 +1836,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when the current user is not an admin' do
it "is not available" do
expect do
- put api("/users/#{user.id}", user), params: attributes_for(:user)
+ put api(path, user), params: attributes_for(:user)
end.not_to change { user.reload.attributes }
expect(response).to have_gitlab_http_status(:forbidden)
@@ -1793,20 +1844,20 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns 404 for non-existing user" do
- put api("/users/0", admin), params: { bio: 'update should fail' }
+ put api("/users/#{non_existing_record_id}", admin, admin_mode: true), params: { bio: 'update should fail' }
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it "returns a 404 if invalid ID" do
- put api("/users/ASDF", admin)
+ put api("/users/ASDF", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns 400 error if user does not validate' do
- put api("/users/#{user.id}", admin),
+ put api(path, admin, admin_mode: true),
params: {
password: 'pass',
email: 'test@example.com',
@@ -1823,30 +1874,30 @@ RSpec.describe API::Users, feature_category: :user_profile do
expect(json_response['message']['projects_limit'])
.to eq(['must be greater than or equal to 0'])
expect(json_response['message']['username'])
- .to eq([Gitlab::PathRegex.namespace_format_message])
+ .to match_array([Gitlab::PathRegex.namespace_format_message, Gitlab::Regex.oci_repository_path_regex_message])
end
it 'returns 400 if provider is missing for identity update' do
- put api("/users/#{omniauth_user.id}", admin), params: { extern_uid: '654321' }
+ put api("/users/#{omniauth_user.id}", admin, admin_mode: true), params: { extern_uid: '654321' }
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'returns 400 if external UID is missing for identity update' do
- put api("/users/#{omniauth_user.id}", admin), params: { provider: 'ldap' }
+ put api("/users/#{omniauth_user.id}", admin, admin_mode: true), params: { provider: 'ldap' }
expect(response).to have_gitlab_http_status(:bad_request)
end
context "with existing user" do
before do
- post api("/users", admin), params: { email: 'test@example.com', password: User.random_password, username: 'test', name: 'test' }
- post api("/users", admin), params: { email: 'foo@bar.com', password: User.random_password, username: 'john', name: 'john' }
+ post api("/users", admin, admin_mode: true), params: { email: 'test@example.com', password: User.random_password, username: 'test', name: 'test' }
+ post api("/users", admin, admin_mode: true), params: { email: 'foo@bar.com', password: User.random_password, username: 'john', name: 'john' }
@user = User.all.last
end
it 'returns 409 conflict error if email address exists' do
- put api("/users/#{@user.id}", admin), params: { email: 'test@example.com' }
+ put api("/users/#{@user.id}", admin, admin_mode: true), params: { email: 'test@example.com' }
expect(response).to have_gitlab_http_status(:conflict)
expect(@user.reload.email).to eq(@user.email)
@@ -1854,7 +1905,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 409 conflict error if username taken' do
@user_id = User.all.last.id
- put api("/users/#{@user.id}", admin), params: { username: 'test' }
+ put api("/users/#{@user.id}", admin, admin_mode: true), params: { username: 'test' }
expect(response).to have_gitlab_http_status(:conflict)
expect(@user.reload.username).to eq(@user.username)
@@ -1862,7 +1913,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 409 conflict error if username taken (case insensitive)' do
@user_id = User.all.last.id
- put api("/users/#{@user.id}", admin), params: { username: 'TEST' }
+ put api("/users/#{@user.id}", admin, admin_mode: true), params: { username: 'TEST' }
expect(response).to have_gitlab_http_status(:conflict)
expect(@user.reload.username).to eq(@user.username)
@@ -1874,7 +1925,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let!(:confirmed_user) { create(:user, email: 'foo@example.com') }
it 'returns 409 conflict error' do
- put api("/users/#{user.id}", admin), params: { email: confirmed_user.email }
+ put api(path, admin, admin_mode: true), params: { email: confirmed_user.email }
expect(response).to have_gitlab_http_status(:conflict)
expect(user.reload.email).not_to eq(confirmed_user.email)
@@ -1885,7 +1936,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let!(:unconfirmed_user) { create(:user, :unconfirmed, email: 'foo@example.com') }
it 'returns 409 conflict error' do
- put api("/users/#{user.id}", admin), params: { email: unconfirmed_user.email }
+ put api(path, admin, admin_mode: true), params: { email: unconfirmed_user.email }
expect(response).to have_gitlab_http_status(:conflict)
expect(user.reload.email).not_to eq(unconfirmed_user.email)
@@ -1898,7 +1949,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let!(:email) { create(:email, :confirmed, email: 'foo@example.com') }
it 'returns 409 conflict error' do
- put api("/users/#{user.id}", admin), params: { email: email.email }
+ put api(path, admin, admin_mode: true), params: { email: email.email }
expect(response).to have_gitlab_http_status(:conflict)
expect(user.reload.email).not_to eq(email.email)
@@ -1909,7 +1960,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let!(:email) { create(:email, email: 'foo@example.com') }
it 'does not update email' do
- put api("/users/#{user.id}", admin), params: { email: email.email }
+ put api(path, admin, admin_mode: true), params: { email: email.email }
expect(response).to have_gitlab_http_status(:bad_request)
expect(user.reload.email).not_to eq(email.email)
@@ -1921,6 +1972,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe "PUT /user/:id/credit_card_validation" do
let(:credit_card_validated_time) { Time.utc(2020, 1, 1) }
let(:expiration_year) { Date.today.year + 10 }
+ let(:path) { "/user/#{user.id}/credit_card_validation" }
let(:params) do
{
credit_card_validated_at: credit_card_validated_time,
@@ -1932,25 +1984,27 @@ RSpec.describe API::Users, feature_category: :user_profile do
}
end
+ it_behaves_like 'PUT request permissions for admin mode'
+
context 'when unauthenticated' do
it 'returns authentication error' do
- put api("/user/#{user.id}/credit_card_validation"), params: {}
+ put api(path), params: {}
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when authenticated as non-admin' do
- it "does not allow updating user's credit card validation", :aggregate_failures do
- put api("/user/#{user.id}/credit_card_validation", user), params: params
+ it "does not allow updating user's credit card validation" do
+ put api(path, user), params: params
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'when authenticated as admin' do
- it "updates user's credit card validation", :aggregate_failures do
- put api("/user/#{user.id}/credit_card_validation", admin), params: params
+ it "updates user's credit card validation" do
+ put api(path, admin, admin_mode: true), params: params
user.reload
@@ -1965,13 +2019,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns 400 error if credit_card_validated_at is missing" do
- put api("/user/#{user.id}/credit_card_validation", admin), params: {}
+ put api(path, admin, admin_mode: true), params: {}
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'returns 404 error if user not found' do
- put api("/user/#{non_existing_record_id}/credit_card_validation", admin), params: params
+ put api("/user/#{non_existing_record_id}/credit_card_validation", admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
@@ -1981,10 +2035,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe "DELETE /users/:id/identities/:provider" do
let(:test_user) { create(:omniauth_user, provider: 'ldapmain') }
+ let(:path) { "/users/#{test_user.id}/identities/ldapmain" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
context 'when unauthenticated' do
it 'returns authentication error' do
- delete api("/users/#{test_user.id}/identities/ldapmain")
+ delete api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -1993,24 +2050,24 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when authenticated' do
it 'deletes identity of given provider' do
expect do
- delete api("/users/#{test_user.id}/identities/ldapmain", admin)
+ delete api(path, admin, admin_mode: true)
end.to change { test_user.identities.count }.by(-1)
expect(response).to have_gitlab_http_status(:no_content)
end
it_behaves_like '412 response' do
- let(:request) { api("/users/#{test_user.id}/identities/ldapmain", admin) }
+ let(:request) { api(path, admin, admin_mode: true) }
end
it 'returns 404 error if user not found' do
- delete api("/users/0/identities/ldapmain", admin)
+ delete api("/users/#{non_existing_record_id}/identities/ldapmain", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns 404 error if identity not found' do
- delete api("/users/#{test_user.id}/identities/saml", admin)
+ delete api("/users/#{test_user.id}/identities/saml", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Identity Not Found')
@@ -2019,25 +2076,31 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "POST /users/:id/keys" do
+ let(:path) { "/users/#{user.id}/keys" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { attributes_for(:key, usage_type: :signing) }
+ end
+
it "does not create invalid ssh key" do
- post api("/users/#{user.id}/keys", admin), params: { title: "invalid key" }
+ post api(path, admin, admin_mode: true), params: { title: "invalid key" }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('key is missing')
end
it 'does not create key without title' do
- post api("/users/#{user.id}/keys", admin), params: { key: 'some key' }
+ post api(path, admin, admin_mode: true), params: { key: 'some key' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('title is missing')
end
- it "creates ssh key", :aggregate_failures do
+ it "creates ssh key" do
key_attrs = attributes_for(:key, usage_type: :signing)
expect do
- post api("/users/#{user.id}/keys", admin), params: key_attrs
+ post api(path, admin, admin_mode: true), params: key_attrs
end.to change { user.keys.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -2052,20 +2115,21 @@ RSpec.describe API::Users, feature_category: :user_profile do
optional_attributes = { expires_at: 3.weeks.from_now }
attributes = attributes_for(:key).merge(optional_attributes)
- post api("/users/#{user.id}/keys", admin), params: attributes
+ post api(path, admin, admin_mode: true), params: attributes
expect(response).to have_gitlab_http_status(:created)
expect(json_response['expires_at'].to_date).to eq(optional_attributes[:expires_at].to_date)
end
it "returns 400 for invalid ID" do
- post api("/users/0/keys", admin)
+ post api("/users/#{non_existing_record_id}/keys", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
end
describe 'GET /users/:id/project_deploy_keys' do
let(:project) { create(:project) }
+ let(:path) { "/users/#{user.id}/project_deploy_keys" }
before do
project.add_maintainer(user)
@@ -2082,7 +2146,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns array of project deploy keys with pagination' do
- get api("/users/#{user.id}/project_deploy_keys", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -2094,7 +2158,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
dev_user = create(:user)
project.add_developer(dev_user)
- get api("/users/#{user.id}/project_deploy_keys", dev_user)
+ get api(path, dev_user)
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden - No common authorized project found')
@@ -2113,7 +2177,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'when no common projects for user and current_user' do
it 'forbids' do
- get api("/users/#{user.id}/project_deploy_keys", second_user)
+ get api(path, second_user)
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden - No common authorized project found')
@@ -2125,11 +2189,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
project.add_maintainer(second_user)
end
+ let(:path) { "/users/#{second_user.id}/project_deploy_keys" }
+
it 'lists only common project keys' do
expect(second_user.project_deploy_keys).to contain_exactly(
project.deploy_keys.first, second_project.deploy_keys.first)
- get api("/users/#{second_user.id}/project_deploy_keys", user)
+ get api(path, user)
expect(json_response.count).to eq(1)
expect(json_response.first['key']).to eq(project.deploy_keys.first.key)
@@ -2144,7 +2210,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
create(:deploy_key, user: second_user)
create(:deploy_key, user: third_user)
- get api("/users/#{second_user.id}/project_deploy_keys", third_user)
+ get api(path, third_user)
expect(json_response.count).to eq(2)
expect([json_response.first['key'], json_response.second['key']]).to contain_exactly(
@@ -2155,14 +2221,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
second_project.add_maintainer(user)
control_count = ActiveRecord::QueryRecorder.new do
- get api("/users/#{second_user.id}/project_deploy_keys", user)
+ get api(path, user)
end.count
deploy_key = create(:deploy_key, user: second_user)
create(:deploy_keys_project, project: second_project, deploy_key_id: deploy_key.id)
expect do
- get api("/users/#{second_user.id}/project_deploy_keys", user)
+ get api(path, user)
end.not_to exceed_query_limit(control_count)
end
end
@@ -2170,6 +2236,10 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'GET /user/:id/keys' do
+ subject(:request) { get api(path) }
+
+ let(:path) { "/users/#{user.id}/keys" }
+
it 'returns 404 for non-existing user' do
get api("/users/#{non_existing_record_id}/keys")
@@ -2180,7 +2250,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns array of ssh keys' do
user.keys << key
- get api("/users/#{user.id}/keys")
+ request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -2190,7 +2260,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns array of ssh keys with comments replaced with'\
'a simple identifier of username + hostname' do
- get api("/users/#{user.id}/keys")
+ request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -2202,24 +2272,26 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'N+1 queries' do
before do
- get api("/users/#{user.id}/keys")
+ request
end
it 'avoids N+1 queries', :request_store do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/users/#{user.id}/keys")
+ request
end.count
create_list(:key, 2, user: user)
expect do
- get api("/users/#{user.id}/keys")
+ request
end.not_to exceed_all_query_limit(control_count)
end
end
end
describe 'GET /user/:user_id/keys' do
+ let(:path) { "/users/#{user.username}/keys" }
+
it 'returns 404 for non-existing user' do
get api("/users/#{non_existing_record_id}/keys")
@@ -2230,7 +2302,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns array of ssh keys' do
user.keys << key
- get api("/users/#{user.username}/keys")
+ get api(path)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -2240,25 +2312,27 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'GET /user/:id/keys/:key_id' do
- it 'gets existing key', :aggregate_failures do
+ let(:path) { "/users/#{user.id}/keys/#{key.id}" }
+
+ it 'gets existing key' do
user.keys << key
- get api("/users/#{user.id}/keys/#{key.id}")
+ get api(path)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(key.title)
end
- it 'returns 404 error if user not found', :aggregate_failures do
+ it 'returns 404 error if user not found' do
user.keys << key
- get api("/users/0/keys/#{key.id}")
+ get api("/users/#{non_existing_record_id}/keys/#{key.id}")
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
- it 'returns 404 error if key not found', :aggregate_failures do
+ it 'returns 404 error if key not found' do
get api("/users/#{user.id}/keys/#{non_existing_record_id}")
expect(response).to have_gitlab_http_status(:not_found)
@@ -2267,6 +2341,10 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'DELETE /user/:id/keys/:key_id' do
+ let(:path) { "/users/#{user.id}/keys/#{key.id}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
+
context 'when unauthenticated' do
it 'returns authentication error' do
delete api("/users/#{user.id}/keys/#{non_existing_record_id}")
@@ -2279,26 +2357,26 @@ RSpec.describe API::Users, feature_category: :user_profile do
user.keys << key
expect do
- delete api("/users/#{user.id}/keys/#{key.id}", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { user.keys.count }.by(-1)
end
it_behaves_like '412 response' do
- let(:request) { api("/users/#{user.id}/keys/#{key.id}", admin) }
+ let(:request) { api(path, admin, admin_mode: true) }
end
it 'returns 404 error if user not found' do
user.keys << key
- delete api("/users/0/keys/#{key.id}", admin)
+ delete api("/users/#{non_existing_record_id}/keys/#{key.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns 404 error if key not foud' do
- delete api("/users/#{user.id}/keys/#{non_existing_record_id}", admin)
+ delete api("/users/#{user.id}/keys/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Key Not Found')
end
@@ -2306,8 +2384,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'POST /users/:id/gpg_keys' do
+ let(:path) { "/users/#{user.id}/gpg_keys" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { attributes_for :gpg_key, key: GpgHelpers::User2.public_key }
+ end
+
it 'does not create invalid GPG key' do
- post api("/users/#{user.id}/gpg_keys", admin)
+ post api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('key is missing')
@@ -2317,22 +2401,24 @@ RSpec.describe API::Users, feature_category: :user_profile do
key_attrs = attributes_for :gpg_key, key: GpgHelpers::User2.public_key
expect do
- post api("/users/#{user.id}/gpg_keys", admin), params: key_attrs
+ post api(path, admin, admin_mode: true), params: key_attrs
expect(response).to have_gitlab_http_status(:created)
end.to change { user.gpg_keys.count }.by(1)
end
it 'returns 400 for invalid ID' do
- post api('/users/0/gpg_keys', admin)
+ post api("/users/#{non_existing_record_id}/gpg_keys", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
end
describe 'GET /user/:id/gpg_keys' do
+ let(:path) { "/users/#{user.id}/gpg_keys" }
+
it 'returns 404 for non-existing user' do
- get api('/users/0/gpg_keys')
+ get api("/users/#{non_existing_record_id}/gpg_keys")
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
@@ -2341,7 +2427,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns array of GPG keys' do
user.gpg_keys << gpg_key
- get api("/users/#{user.id}/gpg_keys")
+ get api(path)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -2351,15 +2437,17 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'GET /user/:id/gpg_keys/:key_id' do
+ let(:path) { "/users/#{user.id}/gpg_keys/#{gpg_key.id}" }
+
it 'returns 404 for non-existing user' do
- get api('/users/0/gpg_keys/1')
+ get api("/users/#{non_existing_record_id}/gpg_keys/1")
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns 404 for non-existing key' do
- get api("/users/#{user.id}/gpg_keys/0")
+ get api("/users/#{user.id}/gpg_keys/#{non_existing_record_id}")
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 GPG Key Not Found')
@@ -2368,7 +2456,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns a single GPG key' do
user.gpg_keys << gpg_key
- get api("/users/#{user.id}/gpg_keys/#{gpg_key.id}")
+ get api(path)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['key']).to eq(gpg_key.key)
@@ -2376,6 +2464,10 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'DELETE /user/:id/gpg_keys/:key_id' do
+ let(:path) { "/users/#{user.id}/gpg_keys/#{gpg_key.id}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
+
context 'when unauthenticated' do
it 'returns authentication error' do
delete api("/users/#{user.id}/keys/#{non_existing_record_id}")
@@ -2389,7 +2481,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
user.gpg_keys << gpg_key
expect do
- delete api("/users/#{user.id}/gpg_keys/#{gpg_key.id}", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { user.gpg_keys.count }.by(-1)
@@ -2398,14 +2490,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 404 error if user not found' do
user.keys << key
- delete api("/users/0/gpg_keys/#{gpg_key.id}", admin)
+ delete api("/users/#{non_existing_record_id}/gpg_keys/#{gpg_key.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns 404 error if key not foud' do
- delete api("/users/#{user.id}/gpg_keys/#{non_existing_record_id}", admin)
+ delete api("/users/#{user.id}/gpg_keys/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 GPG Key Not Found')
@@ -2414,6 +2506,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'POST /user/:id/gpg_keys/:key_id/revoke' do
+ let(:path) { "/users/#{user.id}/gpg_keys/#{gpg_key.id}/revoke" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { {} }
+ let(:success_status_code) { :accepted }
+ end
+
context 'when unauthenticated' do
it 'returns authentication error' do
post api("/users/#{user.id}/gpg_keys/#{non_existing_record_id}/revoke")
@@ -2427,7 +2526,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
user.gpg_keys << gpg_key
expect do
- post api("/users/#{user.id}/gpg_keys/#{gpg_key.id}/revoke", admin)
+ post api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:accepted)
end.to change { user.gpg_keys.count }.by(-1)
@@ -2436,14 +2535,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 404 error if user not found' do
user.gpg_keys << gpg_key
- post api("/users/0/gpg_keys/#{gpg_key.id}/revoke", admin)
+ post api("/users/#{non_existing_record_id}/gpg_keys/#{gpg_key.id}/revoke", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns 404 error if key not foud' do
- post api("/users/#{user.id}/gpg_keys/#{non_existing_record_id}/revoke", admin)
+ post api("/users/#{user.id}/gpg_keys/#{non_existing_record_id}/revoke", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 GPG Key Not Found')
@@ -2452,8 +2551,19 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "POST /users/:id/emails", :mailer do
+ let(:path) { "/users/#{user.id}/emails" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ before do
+ email_attrs[:skip_confirmation] = true
+ end
+
+ let(:email_attrs) { attributes_for :email }
+ let(:params) { email_attrs }
+ end
+
it "does not create invalid email" do
- post api("/users/#{user.id}/emails", admin), params: {}
+ post api(path, admin, admin_mode: true), params: {}
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('email is missing')
@@ -2464,7 +2574,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
perform_enqueued_jobs do
expect do
- post api("/users/#{user.id}/emails", admin), params: email_attrs
+ post api(path, admin, admin_mode: true), params: email_attrs
end.to change { user.emails.count }.by(1)
end
@@ -2473,7 +2583,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns a 400 for invalid ID" do
- post api("/users/0/emails", admin)
+ post api("/users/#{non_existing_record_id}/emails", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -2482,7 +2592,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
email_attrs = attributes_for :email
email_attrs[:skip_confirmation] = true
- post api("/users/#{user.id}/emails", admin), params: email_attrs
+ post api(path, admin, admin_mode: true), params: email_attrs
expect(response).to have_gitlab_http_status(:created)
@@ -2494,7 +2604,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let!(:confirmed_user) { create(:user, email: 'foo@example.com') }
it 'returns 400 error' do
- post api("/users/#{user.id}/emails", admin), params: { email: confirmed_user.email }
+ post api(path, admin, admin_mode: true), params: { email: confirmed_user.email }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -2504,7 +2614,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let!(:unconfirmed_user) { create(:user, :unconfirmed, email: 'foo@example.com') }
it 'returns 400 error' do
- post api("/users/#{user.id}/emails", admin), params: { email: unconfirmed_user.email }
+ post api(path, admin, admin_mode: true), params: { email: unconfirmed_user.email }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -2516,7 +2626,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let!(:email) { create(:email, :confirmed, email: 'foo@example.com') }
it 'returns 400 error' do
- post api("/users/#{user.id}/emails", admin), params: { email: email.email }
+ post api(path, admin, admin_mode: true), params: { email: email.email }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -2526,7 +2636,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let!(:email) { create(:email, email: 'foo@example.com') }
it 'returns 400 error' do
- post api("/users/#{user.id}/emails", admin), params: { email: email.email }
+ post api(path, admin, admin_mode: true), params: { email: email.email }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -2535,16 +2645,18 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'GET /user/:id/emails' do
+ let(:path) { "/users/#{user.id}/emails" }
+
context 'when unauthenticated' do
it 'returns authentication error' do
- get api("/users/#{user.id}/emails")
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when authenticated' do
it 'returns 404 for non-existing user' do
- get api('/users/0/emails', admin)
+ get api("/users/#{non_existing_record_id}/emails", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
@@ -2552,7 +2664,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns array of emails' do
user.emails << email
- get api("/users/#{user.id}/emails", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -2562,7 +2674,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "returns a 404 for invalid ID" do
- get api("/users/ASDF/emails", admin)
+ get api("/users/ASDF/emails", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -2570,6 +2682,10 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'DELETE /user/:id/emails/:email_id' do
+ let(:path) { "/users/#{user.id}/emails/#{email.id}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
+
context 'when unauthenticated' do
it 'returns authentication error' do
delete api("/users/#{user.id}/emails/#{non_existing_record_id}")
@@ -2582,26 +2698,26 @@ RSpec.describe API::Users, feature_category: :user_profile do
user.emails << email
expect do
- delete api("/users/#{user.id}/emails/#{email.id}", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { user.emails.count }.by(-1)
end
it_behaves_like '412 response' do
- let(:request) { api("/users/#{user.id}/emails/#{email.id}", admin) }
+ subject(:request) { api(path, admin, admin_mode: true) }
end
it 'returns 404 error if user not found' do
user.emails << email
- delete api("/users/0/emails/#{email.id}", admin)
+ delete api("/users/#{non_existing_record_id}/emails/#{email.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns 404 error if email not foud' do
- delete api("/users/#{user.id}/emails/#{non_existing_record_id}", admin)
+ delete api("/users/#{user.id}/emails/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Email Not Found')
end
@@ -2616,9 +2732,12 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe "DELETE /users/:id" do
let_it_be(:issue) { create(:issue, author: user) }
+ let(:path) { "/users/#{user.id}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
it "deletes user", :sidekiq_inline do
- perform_enqueued_jobs { delete api("/users/#{user.id}", admin) }
+ perform_enqueued_jobs { delete api(path, admin, admin_mode: true) }
expect(response).to have_gitlab_http_status(:no_content)
expect(Users::GhostUserMigration.where(user: user,
@@ -2630,14 +2749,14 @@ RSpec.describe API::Users, feature_category: :user_profile do
context "hard delete disabled" do
it "does not delete user" do
- perform_enqueued_jobs { delete api("/users/#{user.id}", admin) }
+ perform_enqueued_jobs { delete api(path, admin, admin_mode: true) }
expect(response).to have_gitlab_http_status(:conflict)
end
end
context "hard delete enabled" do
it "delete user and group", :sidekiq_inline do
- perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin) }
+ perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin, admin_mode: true) }
expect(response).to have_gitlab_http_status(:no_content)
expect(Group.exists?(group.id)).to be_falsy
end
@@ -2652,7 +2771,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "delete only user", :sidekiq_inline do
- perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin) }
+ perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin, admin_mode: true) }
expect(response).to have_gitlab_http_status(:no_content)
expect(Group.exists?(subgroup.id)).to be_truthy
end
@@ -2661,34 +2780,34 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it_behaves_like '412 response' do
- let(:request) { api("/users/#{user.id}", admin) }
+ let(:request) { api(path, admin, admin_mode: true) }
end
it "does not delete for unauthenticated user" do
- perform_enqueued_jobs { delete api("/users/#{user.id}") }
+ perform_enqueued_jobs { delete api(path) }
expect(response).to have_gitlab_http_status(:unauthorized)
end
it "is not available for non admin users" do
- perform_enqueued_jobs { delete api("/users/#{user.id}", user) }
+ perform_enqueued_jobs { delete api(path, user) }
expect(response).to have_gitlab_http_status(:forbidden)
end
it "returns 404 for non-existing user" do
- perform_enqueued_jobs { delete api("/users/0", admin) }
+ perform_enqueued_jobs { delete api("/users/#{non_existing_record_id}", admin, admin_mode: true) }
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it "returns a 404 for invalid ID" do
- perform_enqueued_jobs { delete api("/users/ASDF", admin) }
+ perform_enqueued_jobs { delete api("/users/ASDF", admin, admin_mode: true) }
expect(response).to have_gitlab_http_status(:not_found)
end
context "hard delete disabled" do
it "moves contributions to the ghost user", :sidekiq_might_not_need_inline do
- perform_enqueued_jobs { delete api("/users/#{user.id}", admin) }
+ perform_enqueued_jobs { delete api(path, admin, admin_mode: true) }
expect(response).to have_gitlab_http_status(:no_content)
expect(issue.reload).to be_persisted
@@ -2700,7 +2819,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context "hard delete enabled" do
it "removes contributions", :sidekiq_might_not_need_inline do
- perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin) }
+ perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin, admin_mode: true) }
expect(response).to have_gitlab_http_status(:no_content)
expect(Users::GhostUserMigration.where(user: user,
@@ -2711,6 +2830,8 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "GET /user" do
+ let(:path) { '/user' }
+
shared_examples 'get user info' do |version|
context 'with regular user' do
context 'with personal access token' do
@@ -2724,7 +2845,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns current user without private token when sudo not defined' do
- get api("/user", user, version: version)
+ get api(path, user, version: version)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/user/public')
@@ -2732,7 +2853,6 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
context "scopes" do
- let(:path) { "/user" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope', version
@@ -2740,7 +2860,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
context 'with admin' do
- let(:admin_personal_access_token) { create(:personal_access_token, user: admin).token }
+ let(:admin_personal_access_token) { create(:personal_access_token, :admin_mode, user: admin).token }
context 'with personal access token' do
it 'returns 403 without private token when sudo defined' do
@@ -2761,7 +2881,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'with unauthenticated user' do
it "returns 401 error if user is unauthenticated" do
- get api("/user", version: version)
+ get api(path, version: version)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -2773,9 +2893,11 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "GET /user/preferences" do
+ let(:path) { '/user/preferences' }
+
context "when unauthenticated" do
it "returns authentication error" do
- get api("/user/preferences")
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
@@ -2786,7 +2908,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
user.user_preference.show_whitespace_in_diffs = true
user.save!
- get api("/user/preferences", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response["view_diffs_file_by_file"]).to eq(user.user_preference.view_diffs_file_by_file)
@@ -2796,6 +2918,10 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "GET /user/keys" do
+ subject(:request) { get api(path, user) }
+
+ let(:path) { "/user/keys" }
+
context "when unauthenticated" do
it "returns authentication error" do
get api("/user/keys")
@@ -2807,7 +2933,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns array of ssh keys" do
user.keys << key
- get api("/user/keys", user)
+ request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -2817,7 +2943,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns array of ssh keys with comments replaced with'\
'a simple identifier of username + hostname' do
- get api("/user/keys", user)
+ request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -2829,24 +2955,23 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'N+1 queries' do
before do
- get api("/user/keys", user)
+ request
end
it 'avoids N+1 queries', :request_store do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/user/keys", user)
+ request
end.count
create_list(:key, 2, user: user)
expect do
- get api("/user/keys", user)
+ request
end.not_to exceed_all_query_limit(control_count)
end
end
context "scopes" do
- let(:path) { "/user/keys" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
@@ -2855,16 +2980,18 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "GET /user/keys/:key_id" do
+ let(:path) { "/user/keys/#{key.id}" }
+
it "returns single key" do
user.keys << key
- get api("/user/keys/#{key.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response["title"]).to eq(key.title)
end
it 'exposes SSH key comment as a simple identifier of username + hostname' do
- get api("/user/keys/#{key.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['key']).to include("#{key.user_name} (#{Gitlab.config.gitlab.host})")
@@ -2881,19 +3008,18 @@ RSpec.describe API::Users, feature_category: :user_profile do
user.keys << key
admin
- get api("/user/keys/#{key.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Key Not Found')
end
it "returns 404 for invalid ID" do
- get api("/users/keys/ASDF", admin)
+ get api("/users/keys/ASDF", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
context "scopes" do
- let(:path) { "/user/keys/#{key.id}" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
@@ -2901,11 +3027,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "POST /user/keys" do
- it "creates ssh key", :aggregate_failures do
+ let(:path) { "/user/keys" }
+
+ it "creates ssh key" do
key_attrs = attributes_for(:key, usage_type: :signing)
expect do
- post api("/user/keys", user), params: key_attrs
+ post api(path, user), params: key_attrs
end.to change { user.keys.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -2920,19 +3048,19 @@ RSpec.describe API::Users, feature_category: :user_profile do
optional_attributes = { expires_at: 3.weeks.from_now }
attributes = attributes_for(:key).merge(optional_attributes)
- post api("/user/keys", user), params: attributes
+ post api(path, user), params: attributes
expect(response).to have_gitlab_http_status(:created)
expect(json_response['expires_at'].to_date).to eq(optional_attributes[:expires_at].to_date)
end
it "returns a 401 error if unauthorized" do
- post api("/user/keys"), params: { title: 'some title', key: 'some key' }
+ post api(path), params: { title: 'some title', key: 'some key' }
expect(response).to have_gitlab_http_status(:unauthorized)
end
it "does not create ssh key without key" do
- post api("/user/keys", user), params: { title: 'title' }
+ post api(path, user), params: { title: 'title' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('key is missing')
@@ -2946,24 +3074,26 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "does not create ssh key without title" do
- post api("/user/keys", user), params: { key: "somekey" }
+ post api(path, user), params: { key: "somekey" }
expect(response).to have_gitlab_http_status(:bad_request)
end
end
describe "DELETE /user/keys/:key_id" do
+ let(:path) { "/user/keys/#{key.id}" }
+
it "deletes existed key" do
user.keys << key
expect do
- delete api("/user/keys/#{key.id}", user)
+ delete api(path, user)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { user.keys.count }.by(-1)
end
it_behaves_like '412 response' do
- let(:request) { api("/user/keys/#{key.id}", user) }
+ let(:request) { api(path, user) }
end
it "returns 404 if key ID not found" do
@@ -2976,21 +3106,23 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns 401 error if unauthorized" do
user.keys << key
- delete api("/user/keys/#{key.id}")
+ delete api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it "returns a 404 for invalid ID" do
- delete api("/users/keys/ASDF", admin)
+ delete api("/users/keys/ASDF", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
describe 'GET /user/gpg_keys' do
+ let(:path) { '/user/gpg_keys' }
+
context 'when unauthenticated' do
it 'returns authentication error' do
- get api('/user/gpg_keys')
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -3000,7 +3132,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns array of GPG keys' do
user.gpg_keys << gpg_key
- get api('/user/gpg_keys', user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -3009,7 +3141,6 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
context 'scopes' do
- let(:path) { '/user/gpg_keys' }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
@@ -3018,10 +3149,12 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'GET /user/gpg_keys/:key_id' do
+ let(:path) { "/user/gpg_keys/#{gpg_key.id}" }
+
it 'returns a single key' do
user.gpg_keys << gpg_key
- get api("/user/gpg_keys/#{gpg_key.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['key']).to eq(gpg_key.key)
@@ -3037,20 +3170,19 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns 404 error if admin accesses user's GPG key" do
user.gpg_keys << gpg_key
- get api("/user/gpg_keys/#{gpg_key.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 GPG Key Not Found')
end
it 'returns 404 for invalid ID' do
- get api('/users/gpg_keys/ASDF', admin)
+ get api('/users/gpg_keys/ASDF', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
context 'scopes' do
- let(:path) { "/user/gpg_keys/#{gpg_key.id}" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
@@ -3058,24 +3190,26 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'POST /user/gpg_keys' do
+ let(:path) { '/user/gpg_keys' }
+
it 'creates a GPG key' do
key_attrs = attributes_for :gpg_key, key: GpgHelpers::User2.public_key
expect do
- post api('/user/gpg_keys', user), params: key_attrs
+ post api(path, user), params: key_attrs
expect(response).to have_gitlab_http_status(:created)
end.to change { user.gpg_keys.count }.by(1)
end
it 'returns a 401 error if unauthorized' do
- post api('/user/gpg_keys'), params: { key: 'some key' }
+ post api(path), params: { key: 'some key' }
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'does not create GPG key without key' do
- post api('/user/gpg_keys', user)
+ post api(path, user)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('key is missing')
@@ -3109,18 +3243,20 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns a 404 for invalid ID' do
- post api('/users/gpg_keys/ASDF/revoke', admin)
+ post api('/users/gpg_keys/ASDF/revoke', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
describe 'DELETE /user/gpg_keys/:key_id' do
+ let(:path) { "/user/gpg_keys/#{gpg_key.id}" }
+
it 'deletes existing GPG key' do
user.gpg_keys << gpg_key
expect do
- delete api("/user/gpg_keys/#{gpg_key.id}", user)
+ delete api(path, user)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { user.gpg_keys.count }.by(-1)
@@ -3136,22 +3272,24 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'returns 401 error if unauthorized' do
user.gpg_keys << gpg_key
- delete api("/user/gpg_keys/#{gpg_key.id}")
+ delete api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'returns a 404 for invalid ID' do
- delete api('/users/gpg_keys/ASDF', admin)
+ delete api('/users/gpg_keys/ASDF', admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
end
describe "GET /user/emails" do
+ let(:path) { '/user/emails' }
+
context "when unauthenticated" do
it "returns authentication error" do
- get api("/user/emails")
+ get api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
@@ -3160,7 +3298,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns array of emails" do
user.emails << email
- get api("/user/emails", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -3170,7 +3308,6 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
context "scopes" do
- let(:path) { "/user/emails" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
@@ -3179,10 +3316,12 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "GET /user/emails/:email_id" do
+ let(:path) { "/user/emails/#{email.id}" }
+
it "returns single email" do
user.emails << email
- get api("/user/emails/#{email.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response["email"]).to eq(email.email)
end
@@ -3197,19 +3336,18 @@ RSpec.describe API::Users, feature_category: :user_profile do
user.emails << email
admin
- get api("/user/emails/#{email.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Email Not Found')
end
it "returns 404 for invalid ID" do
- get api("/users/emails/ASDF", admin)
+ get api("/users/emails/ASDF", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
context "scopes" do
- let(:path) { "/user/emails/#{email.id}" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
@@ -3217,21 +3355,23 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "POST /user/emails" do
+ let(:path) { '/user/emails' }
+
it "creates email" do
email_attrs = attributes_for :email
expect do
- post api("/user/emails", user), params: email_attrs
+ post api(path, user), params: email_attrs
end.to change { user.emails.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
end
it "returns a 401 error if unauthorized" do
- post api("/user/emails"), params: { email: 'some email' }
+ post api(path), params: { email: 'some email' }
expect(response).to have_gitlab_http_status(:unauthorized)
end
it "does not create email with invalid email" do
- post api("/user/emails", user), params: {}
+ post api(path, user), params: {}
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('email is missing')
@@ -3239,18 +3379,20 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe "DELETE /user/emails/:email_id" do
+ let(:path) { "/user/emails/#{email.id}" }
+
it "deletes existed email" do
user.emails << email
expect do
- delete api("/user/emails/#{email.id}", user)
+ delete api(path, user)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { user.emails.count }.by(-1)
end
it_behaves_like '412 response' do
- let(:request) { api("/user/emails/#{email.id}", user) }
+ let(:request) { api(path, user) }
end
it "returns 404 if email ID not found" do
@@ -3263,12 +3405,12 @@ RSpec.describe API::Users, feature_category: :user_profile do
it "returns 401 error if unauthorized" do
user.emails << email
- delete api("/user/emails/#{email.id}")
+ delete api(path)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it "returns 400 for invalid ID" do
- delete api("/user/emails/ASDF", admin)
+ delete api("/user/emails/ASDF", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -3283,12 +3425,18 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'POST /users/:id/activate' do
- subject(:activate) { post api("/users/#{user_id}/activate", api_user) }
+ subject(:activate) { post api(path, api_user, **params) }
let(:user_id) { user.id }
+ let(:path) { "/users/#{user_id}/activate" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { {} }
+ end
context 'performed by a non-admin user' do
let(:api_user) { user }
+ let(:params) { { admin_mode: false } }
it 'is not authorized to perform the action' do
activate
@@ -3299,6 +3447,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'performed by an admin user' do
let(:api_user) { admin }
+ let(:params) { { admin_mode: true } }
context 'for a deactivated user' do
let(:user_id) { deactivated_user.id }
@@ -3351,7 +3500,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
context 'for a user that does not exist' do
- let(:user_id) { 0 }
+ let(:user_id) { non_existing_record_id }
before do
activate
@@ -3363,12 +3512,18 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'POST /users/:id/deactivate' do
- subject(:deactivate) { post api("/users/#{user_id}/deactivate", api_user) }
+ subject(:deactivate) { post api(path, api_user, **params) }
let(:user_id) { user.id }
+ let(:path) { "/users/#{user_id}/deactivate" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { {} }
+ end
context 'performed by a non-admin user' do
let(:api_user) { user }
+ let(:params) { { admin_mode: false } }
it 'is not authorized to perform the action' do
deactivate
@@ -3379,6 +3534,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'performed by an admin user' do
let(:api_user) { admin }
+ let(:params) { { admin_mode: true } }
context 'for an active user' do
let(:activity) { {} }
@@ -3402,7 +3558,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
deactivate
expect(response).to have_gitlab_http_status(:forbidden)
- expect(json_response['message']).to eq("403 Forbidden - The user you are trying to deactivate has been active in the past #{Gitlab::CurrentSettings.deactivate_dormant_users_period} days and cannot be deactivated")
+ expect(json_response['message']).to eq("The user you are trying to deactivate has been active in the past #{Gitlab::CurrentSettings.deactivate_dormant_users_period} days and cannot be deactivated")
expect(user.reload.state).to eq('active')
end
end
@@ -3426,7 +3582,7 @@ RSpec.describe API::Users, feature_category: :user_profile 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(json_response['message']).to eq('Error occurred. A blocked user cannot be deactivated')
expect(blocked_user.reload.state).to eq('blocked')
end
end
@@ -3440,7 +3596,7 @@ RSpec.describe API::Users, feature_category: :user_profile 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(json_response['message']).to eq('Error occurred. A blocked user cannot be deactivated')
expect(user.reload.state).to eq('ldap_blocked')
end
end
@@ -3452,12 +3608,12 @@ RSpec.describe API::Users, feature_category: :user_profile do
deactivate
expect(response).to have_gitlab_http_status(:forbidden)
- expect(json_response['message']).to eq('403 Forbidden - An internal user cannot be deactivated by the API')
+ expect(json_response['message']).to eq('Internal users cannot be deactivated')
end
end
context 'for a user that does not exist' do
- let(:user_id) { 0 }
+ let(:user_id) { non_existing_record_id }
before do
deactivate
@@ -3480,11 +3636,19 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'POST /users/:id/approve' do
- subject(:approve) { post api("/users/#{user_id}/approve", api_user) }
+ subject(:approve) { post api(path, api_user, **params) }
+
+ let(:path) { "/users/#{user_id}/approve" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:user_id) { pending_user.id }
+ let(:params) { {} }
+ end
context 'performed by a non-admin user' do
let(:api_user) { user }
let(:user_id) { pending_user.id }
+ let(:params) { { admin_mode: false } }
it 'is not authorized to perform the action' do
expect { approve }.not_to change { pending_user.reload.state }
@@ -3495,6 +3659,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'performed by an admin user' do
let(:api_user) { admin }
+ let(:params) { { admin_mode: true } }
context 'for a deactivated user' do
let(:user_id) { deactivated_user.id }
@@ -3558,8 +3723,16 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
end
- describe 'POST /users/:id/reject', :aggregate_failures do
- subject(:reject) { post api("/users/#{user_id}/reject", api_user) }
+ describe 'POST /users/:id/reject' do
+ subject(:reject) { post api(path, api_user, **params) }
+
+ let(:path) { "/users/#{user_id}/reject" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:user_id) { pending_user.id }
+ let(:params) { {} }
+ let(:success_status_code) { :success }
+ end
shared_examples 'returns 409' do
it 'returns 409' do
@@ -3573,6 +3746,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'performed by a non-admin user' do
let(:api_user) { user }
let(:user_id) { pending_user.id }
+ let(:params) { { admin_mode: false } }
it 'returns 403' do
expect { reject }.not_to change { pending_user.reload.state }
@@ -3583,6 +3757,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'performed by an admin user' do
let(:api_user) { admin }
+ let(:params) { { admin_mode: true } }
context 'for an pending approval user' do
let(:user_id) { pending_user.id }
@@ -3648,13 +3823,21 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
end
- describe 'POST /users/:id/block', :aggregate_failures do
+ describe 'POST /users/:id/block' do
+ subject(:block_user) { post api(path, api_user, **params) }
+
+ let(:user_id) { user.id }
+ let(:path) { "/users/#{user_id}/block" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { {} }
+ end
+
context 'when admin' do
- subject(:block_user) { post api("/users/#{user_id}/block", admin) }
+ let(:api_user) { admin }
+ let(:params) { { admin_mode: true } }
context 'with an existing user' do
- let(:user_id) { user.id }
-
it 'blocks existing user' do
block_user
@@ -3730,21 +3913,34 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
end
- it 'is not available for non admin users' do
- post api("/users/#{user.id}/block", user)
+ context 'performed by a non-admin user' do
+ let(:api_user) { user }
+ let(:params) { { admin_mode: false } }
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(user.reload.state).to eq('active')
+ it 'returns 403' do
+ block_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(user.reload.state).to eq('active')
+ end
end
end
- describe 'POST /users/:id/unblock', :aggregate_failures do
+ describe 'POST /users/:id/unblock' do
+ subject(:unblock_user) { post api(path, api_user, **params) }
+
+ let(:path) { "/users/#{user_id}/unblock" }
+ let(:user_id) { user.id }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { {} }
+ end
+
context 'when admin' do
- subject(:unblock_user) { post api("/users/#{user_id}/unblock", admin) }
+ let(:api_user) { admin }
+ let(:params) { { admin_mode: true } }
context 'with an existing user' do
- let(:user_id) { user.id }
-
it 'unblocks existing user' do
unblock_user
@@ -3817,20 +4013,34 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
end
- 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(user.reload.state).to eq('active')
+ context 'performed by a non-admin user' do
+ let(:api_user) { user }
+ let(:params) { { admin_mode: false } }
+
+ it 'returns 403' do
+ unblock_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(user.reload.state).to eq('active')
+ end
end
end
- describe 'POST /users/:id/ban', :aggregate_failures do
+ describe 'POST /users/:id/ban' do
+ subject(:ban_user) { post api(path, api_user, **params) }
+
+ let(:path) { "/users/#{user_id}/ban" }
+ let(:user_id) { user.id }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:params) { {} }
+ end
+
context 'when admin' do
- subject(:ban_user) { post api("/users/#{user_id}/ban", admin) }
+ let(:api_user) { admin }
+ let(:params) { { admin_mode: true } }
context 'with an active user' do
- let(:user_id) { user.id }
-
it 'bans an active user' do
ban_user
@@ -3898,17 +4108,32 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
end
- it 'is not available for non-admin users' do
- post api("/users/#{user.id}/ban", user)
+ context 'performed by a non-admin user' do
+ let(:api_user) { user }
+ let(:params) { { admin_mode: false } }
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(user.reload.state).to eq('active')
+ it 'returns 403' do
+ ban_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(user.reload.state).to eq('active')
+ end
end
end
- describe 'POST /users/:id/unban', :aggregate_failures do
+ describe 'POST /users/:id/unban' do
+ subject(:unban_user) { post api(path, api_user, **params) }
+
+ let(:path) { "/users/#{user_id}/unban" }
+
+ it_behaves_like 'POST request permissions for admin mode' do
+ let(:user_id) { banned_user.id }
+ let(:params) { {} }
+ end
+
context 'when admin' do
- subject(:unban_user) { post api("/users/#{user_id}/unban", admin) }
+ let(:api_user) { admin }
+ let(:params) { { admin_mode: true } }
context 'with a banned user' do
let(:user_id) { banned_user.id }
@@ -3979,37 +4204,42 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
end
- it 'is not available for non admin users' do
- post api("/users/#{banned_user.id}/unban", user)
+ context 'performed by a non-admin user' do
+ let(:api_user) { user }
+ let(:params) { { admin_mode: false } }
+ let(:user_id) { banned_user.id }
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(user.reload.state).to eq('active')
+ it 'returns 403' do
+ unban_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(user.reload.state).to eq('active')
+ end
end
end
describe "GET /users/:id/memberships" do
+ subject(:request) { get api(path, requesting_user, admin_mode: true) }
+
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
let(:requesting_user) { create(:user) }
+ let(:path) { "/users/#{user.id}/memberships" }
before_all do
project.add_guest(user)
group.add_guest(user)
end
- it "responses with 403" do
- get api("/users/#{user.id}/memberships", requesting_user)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ it_behaves_like 'GET request permissions for admin mode'
context 'requested by admin user' do
let(:requesting_user) { create(:user, :admin) }
it "responses successfully" do
- get api("/users/#{user.id}/memberships", requesting_user)
+ request
aggregate_failures 'expect successful response including groups and projects' do
expect(response).to have_gitlab_http_status(:ok)
@@ -4024,22 +4254,23 @@ RSpec.describe API::Users, feature_category: :user_profile do
it 'does not submit N+1 DB queries' do
# Avoid setup queries
- get api("/users/#{user.id}/memberships", requesting_user)
+ request
+ expect(response).to have_gitlab_http_status(:ok)
control = ActiveRecord::QueryRecorder.new do
- get api("/users/#{user.id}/memberships", requesting_user)
+ request
end
create_list(:project, 5).map { |project| project.add_guest(user) }
expect do
- get api("/users/#{user.id}/memberships", requesting_user)
+ request
end.not_to exceed_query_limit(control)
end
context 'with type filter' do
it "only returns project memberships" do
- get api("/users/#{user.id}/memberships?type=Project", requesting_user)
+ get api("/users/#{user.id}/memberships?type=Project", requesting_user, admin_mode: true)
aggregate_failures do
expect(json_response).to contain_exactly(a_hash_including('source_type' => 'Project'))
@@ -4048,7 +4279,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "only returns group memberships" do
- get api("/users/#{user.id}/memberships?type=Namespace", requesting_user)
+ get api("/users/#{user.id}/memberships?type=Namespace", requesting_user, admin_mode: true)
aggregate_failures do
expect(json_response).to contain_exactly(a_hash_including('source_type' => 'Namespace'))
@@ -4057,7 +4288,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it "recognizes unsupported types" do
- get api("/users/#{user.id}/memberships?type=foo", requesting_user)
+ get api("/users/#{user.id}/memberships?type=foo", requesting_user, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -4068,10 +4299,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
context "user activities", :clean_gitlab_redis_shared_state do
let_it_be(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) }
let_it_be(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) }
+ let(:path) { '/user/activities' }
+
+ it_behaves_like 'GET request permissions for admin mode'
context 'last activity as normal user' do
it 'has no permission' do
- get api("/user/activities", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -4079,7 +4313,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'as admin' do
it 'returns the activities from the last 6 months' do
- get api("/user/activities", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to include_pagination_headers
expect(json_response.size).to eq(1)
@@ -4093,7 +4327,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'passing a :from parameter' do
it 'returns the activities from the given date' do
- get api("/user/activities?from=2000-1-1", admin)
+ get api("#{path}?from=2000-1-1", admin, admin_mode: true)
expect(response).to include_pagination_headers
expect(json_response.size).to eq(2)
@@ -4113,6 +4347,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
let(:user_with_status) { user_status.user }
let(:params) { {} }
let(:request_user) { user }
+ let(:path) { '/user/status' }
shared_examples '/user/status successful response' do
context 'when request is successful' do
@@ -4150,7 +4385,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
set_user_status
expect(response).to have_gitlab_http_status(:success)
- expect(user_with_status.status).to be_nil
+ expect(user_with_status.reset.status).to be_nil
end
end
end
@@ -4178,7 +4413,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
set_user_status
expect(response).to have_gitlab_http_status(:success)
- expect(user_with_status.status.clear_status_at).to be_nil
+ expect(user_with_status.reset.status.clear_status_at).to be_nil
end
end
@@ -4194,13 +4429,11 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
describe 'GET' do
- let(:path) { '/user/status' }
-
it_behaves_like 'rendering user status'
end
describe 'PUT' do
- subject(:set_user_status) { put api('/user/status', request_user), params: params }
+ subject(:set_user_status) { put api(path, request_user), params: params }
include_examples '/user/status successful response'
@@ -4217,7 +4450,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
set_user_status
expect(response).to have_gitlab_http_status(:success)
- expect(user_with_status.status).to be_nil
+ expect(user_with_status.reset.status).to be_nil
end
end
@@ -4229,13 +4462,13 @@ RSpec.describe API::Users, feature_category: :user_profile do
set_user_status
expect(response).to have_gitlab_http_status(:success)
- expect(user_with_status.status.clear_status_at).to be_nil
+ expect(user_with_status.reset.status.clear_status_at).to be_nil
end
end
end
describe 'PATCH' do
- subject(:set_user_status) { patch api('/user/status', request_user), params: params }
+ subject(:set_user_status) { patch api(path, request_user), params: params }
include_examples '/user/status successful response'
@@ -4274,57 +4507,41 @@ RSpec.describe API::Users, feature_category: :user_profile do
let(:name) { 'new pat' }
let(:expires_at) { 3.days.from_now.to_date.to_s }
let(:scopes) { %w(api read_user) }
+ let(:path) { "/users/#{user.id}/personal_access_tokens" }
+ let(:params) { { name: name, scopes: scopes, expires_at: expires_at } }
+
+ it_behaves_like 'POST request permissions for admin mode'
it 'returns error if required attributes are missing' do
- post api("/users/#{user.id}/personal_access_tokens", admin)
+ post api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('name is missing, scopes is missing, scopes does not have a valid value')
end
it 'returns a 404 error if user not found' do
- post api("/users/#{non_existing_record_id}/personal_access_tokens", admin),
- params: {
- name: name,
- scopes: scopes,
- expires_at: expires_at
- }
+ post api("/users/#{non_existing_record_id}/personal_access_tokens", admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns a 401 error when not authenticated' do
- post api("/users/#{user.id}/personal_access_tokens"),
- params: {
- name: name,
- scopes: scopes,
- expires_at: expires_at
- }
+ post api(path), params: params
expect(response).to have_gitlab_http_status(:unauthorized)
expect(json_response['message']).to eq('401 Unauthorized')
end
it 'returns a 403 error when authenticated as normal user' do
- post api("/users/#{user.id}/personal_access_tokens", user),
- params: {
- name: name,
- scopes: scopes,
- expires_at: expires_at
- }
+ post api(path, user), params: params
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden')
end
it 'creates a personal access token when authenticated as admin' do
- post api("/users/#{user.id}/personal_access_tokens", admin),
- params: {
- name: name,
- expires_at: expires_at,
- scopes: scopes
- }
+ post api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(name)
@@ -4338,7 +4555,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
context 'when an error is thrown by the model' do
- let!(:admin_personal_access_token) { create(:personal_access_token, user: admin) }
+ let!(:admin_personal_access_token) { create(:personal_access_token, :admin_mode, user: admin) }
let(:error_message) { 'error message' }
before do
@@ -4351,12 +4568,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns the error' do
- post api("/users/#{user.id}/personal_access_tokens", personal_access_token: admin_personal_access_token),
- params: {
- name: name,
- expires_at: expires_at,
- scopes: scopes
- }
+ post api(path, personal_access_token: admin_personal_access_token), params: params
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['message']).to eq(error_message)
@@ -4370,9 +4582,12 @@ RSpec.describe API::Users, feature_category: :user_profile do
let_it_be(:expired_personal_access_token) { create(:personal_access_token, :expired, user: user) }
let_it_be(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) }
let_it_be(:revoked_impersonation_token) { create(:personal_access_token, :impersonation, :revoked, user: user) }
+ let(:path) { "/users/#{user.id}/impersonation_tokens" }
+
+ it_behaves_like 'GET request permissions for admin mode'
it 'returns a 404 error if user not found' do
- get api("/users/#{non_existing_record_id}/impersonation_tokens", admin)
+ get api("/users/#{non_existing_record_id}/impersonation_tokens", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
@@ -4386,7 +4601,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns an array of all impersonated tokens' do
- get api("/users/#{user.id}/impersonation_tokens", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -4395,7 +4610,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns an array of active impersonation tokens if state active' do
- get api("/users/#{user.id}/impersonation_tokens?state=active", admin)
+ get api("#{path}?state=active", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -4405,7 +4620,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns an array of inactive personal access tokens if active is set to false' do
- get api("/users/#{user.id}/impersonation_tokens?state=inactive", admin)
+ get api("#{path}?state=inactive", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
@@ -4419,16 +4634,20 @@ RSpec.describe API::Users, feature_category: :user_profile do
let(:expires_at) { '2016-12-28' }
let(:scopes) { %w(api read_user) }
let(:impersonation) { true }
+ let(:path) { "/users/#{user.id}/impersonation_tokens" }
+ let(:params) { { name: name, expires_at: expires_at, scopes: scopes, impersonation: impersonation } }
+
+ it_behaves_like 'POST request permissions for admin mode'
it 'returns validation error if impersonation token misses some attributes' do
- post api("/users/#{user.id}/impersonation_tokens", admin)
+ post api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('name is missing')
end
it 'returns a 404 error if user not found' do
- post api("/users/#{non_existing_record_id}/impersonation_tokens", admin),
+ post api("/users/#{non_existing_record_id}/impersonation_tokens", admin, admin_mode: true),
params: {
name: name,
expires_at: expires_at
@@ -4439,7 +4658,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'returns a 403 error when authenticated as normal user' do
- post api("/users/#{user.id}/impersonation_tokens", user),
+ post api(path, user),
params: {
name: name,
expires_at: expires_at
@@ -4450,13 +4669,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
end
it 'creates a impersonation token' do
- post api("/users/#{user.id}/impersonation_tokens", admin),
- params: {
- name: name,
- expires_at: expires_at,
- scopes: scopes,
- impersonation: impersonation
- }
+ post api(path, admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(name)
@@ -4474,37 +4687,40 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe 'GET /users/:user_id/impersonation_tokens/:impersonation_token_id' do
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
let_it_be(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) }
+ let(:path) { "/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}" }
+
+ it_behaves_like 'GET request permissions for admin mode'
it 'returns 404 error if user not found' do
- get api("/users/#{non_existing_record_id}/impersonation_tokens/1", admin)
+ get api("/users/#{non_existing_record_id}/impersonation_tokens/1", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns a 404 error if impersonation token not found' do
- get api("/users/#{user.id}/impersonation_tokens/#{non_existing_record_id}", admin)
+ get api("/users/#{user.id}/impersonation_tokens/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Impersonation Token Not Found')
end
it 'returns a 404 error if token is not impersonation token' do
- get api("/users/#{user.id}/impersonation_tokens/#{personal_access_token.id}", admin)
+ get api("/users/#{user.id}/impersonation_tokens/#{personal_access_token.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Impersonation Token Not Found')
end
it 'returns a 403 error when authenticated as normal user' do
- get api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden')
end
it 'returns an impersonation token' do
- get api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['token']).not_to be_present
@@ -4515,41 +4731,44 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe 'DELETE /users/:user_id/impersonation_tokens/:impersonation_token_id' do
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
let_it_be(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) }
+ let(:path) { "/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}" }
+
+ it_behaves_like 'DELETE request permissions for admin mode'
it 'returns a 404 error if user not found' do
- delete api("/users/#{non_existing_record_id}/impersonation_tokens/1", admin)
+ delete api("/users/#{non_existing_record_id}/impersonation_tokens/1", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns a 404 error if impersonation token not found' do
- delete api("/users/#{user.id}/impersonation_tokens/#{non_existing_record_id}", admin)
+ delete api("/users/#{user.id}/impersonation_tokens/#{non_existing_record_id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Impersonation Token Not Found')
end
it 'returns a 404 error if token is not impersonation token' do
- delete api("/users/#{user.id}/impersonation_tokens/#{personal_access_token.id}", admin)
+ delete api("/users/#{user.id}/impersonation_tokens/#{personal_access_token.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Impersonation Token Not Found')
end
it 'returns a 403 error when authenticated as normal user' do
- delete api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", user)
+ delete api(path, user)
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden')
end
it_behaves_like '412 response' do
- let(:request) { api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", admin) }
+ let(:request) { api(path, admin, admin_mode: true) }
end
it 'revokes a impersonation token' do
- delete api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", admin)
+ delete api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:no_content)
expect(impersonation_token.revoked).to be_falsey
@@ -4560,6 +4779,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
describe 'GET /users/:id/associations_count' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :public, group: group) }
+ let(:path) { "/users/#{user.id}/associations_count" }
let(:associations) do
{
groups_count: 1,
@@ -4576,9 +4796,11 @@ RSpec.describe API::Users, feature_category: :user_profile do
create_list(:issue, 2, project: project, author: user)
end
+ it_behaves_like 'GET request permissions for admin mode'
+
context 'as an unauthorized user' do
it 'returns 401 unauthorized' do
- get api("/users/#{user.id}/associations_count", nil)
+ get api(path, nil)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -4595,7 +4817,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'with the current user id' do
it 'returns valid JSON response' do
- get api("/users/#{user.id}/associations_count", user)
+ get api(path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_a Hash
@@ -4607,7 +4829,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'as an admin user' do
context 'with invalid user id' do
it 'returns 404 User Not Found' do
- get api("/users/#{non_existing_record_id}/associations_count", admin)
+ get api("/users/#{non_existing_record_id}/associations_count", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -4615,7 +4837,7 @@ RSpec.describe API::Users, feature_category: :user_profile do
context 'with valid user id' do
it 'returns valid JSON response' do
- get api("/users/#{user.id}/associations_count", admin)
+ get api(path, admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_a Hash
@@ -4629,4 +4851,169 @@ RSpec.describe API::Users, feature_category: :user_profile do
let(:attributable) { user }
let(:other_attributable) { admin }
end
+
+ describe 'POST /user/runners', feature_category: :runner_fleet do
+ subject(:request) { post api(path, current_user, **post_args), params: runner_attrs }
+
+ let_it_be(:group_owner) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, namespace: group) }
+
+ let(:post_args) { { admin_mode: true } }
+ let(:runner_attrs) { { runner_type: 'instance_type' } }
+ let(:path) { '/user/runners' }
+
+ before do
+ group.add_owner(group_owner)
+ end
+
+ shared_context 'returns forbidden when user does not have sufficient permissions' do
+ let(:current_user) { admin }
+ let(:post_args) { { admin_mode: false } }
+
+ it 'does not create a runner' do
+ expect do
+ request
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end.not_to change { Ci::Runner.count }
+ end
+ end
+
+ shared_examples 'creates a runner' do
+ it 'creates a runner' do
+ expect do
+ request
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.to change { Ci::Runner.count }.by(1)
+ end
+ end
+
+ shared_examples 'fails to create runner with :bad_request' do
+ it 'does not create runner' do
+ expect do
+ request
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to include(expected_error)
+ end.not_to change { Ci::Runner.count }
+ end
+ end
+
+ context 'when runner_type is :instance_type' do
+ let(:runner_attrs) { { runner_type: 'instance_type' } }
+
+ context 'when user has sufficient permissions' do
+ let(:current_user) { admin }
+
+ it_behaves_like 'creates a runner'
+ end
+
+ it_behaves_like 'returns forbidden when user does not have sufficient permissions'
+
+ context 'when model validation fails' do
+ let(:runner_attrs) { { runner_type: 'instance_type', run_untagged: false, tag_list: [] } }
+ let(:current_user) { admin }
+
+ it_behaves_like 'fails to create runner with :bad_request' do
+ let(:expected_error) { 'Tags list can not be empty' }
+ end
+ end
+ end
+
+ context 'when runner_type is :group_type' do
+ let(:post_args) { {} }
+
+ context 'when group_id is specified' do
+ let(:runner_attrs) { { runner_type: 'group_type', group_id: group.id } }
+
+ context 'when user has sufficient permissions' do
+ let(:current_user) { group_owner }
+
+ it_behaves_like 'creates a runner'
+ end
+
+ it_behaves_like 'returns forbidden when user does not have sufficient permissions'
+ end
+
+ context 'when group_id is not specified' do
+ let(:runner_attrs) { { runner_type: 'group_type' } }
+ let(:current_user) { group_owner }
+
+ it 'fails to create runner with :bad_request' do
+ expect do
+ request
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to include('group_id is missing')
+ end.not_to change { Ci::Runner.count }
+ end
+ end
+ end
+
+ context 'when runner_type is :project_type' do
+ let(:post_args) { {} }
+
+ context 'when project_id is specified' do
+ let(:runner_attrs) { { runner_type: 'project_type', project_id: project.id } }
+
+ context 'when user has sufficient permissions' do
+ let(:current_user) { group_owner }
+
+ it_behaves_like 'creates a runner'
+ end
+
+ it_behaves_like 'returns forbidden when user does not have sufficient permissions'
+ end
+
+ context 'when project_id is not specified' do
+ let(:runner_attrs) { { runner_type: 'project_type' } }
+ let(:current_user) { group_owner }
+
+ it 'fails to create runner with :bad_request' do
+ expect do
+ request
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to include('project_id is missing')
+ end.not_to change { Ci::Runner.count }
+ end
+ end
+ end
+
+ context 'with missing runner_type' do
+ let(:runner_attrs) { {} }
+ let(:current_user) { admin }
+
+ it 'fails to create runner with :bad_request' do
+ expect do
+ request
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('runner_type is missing, runner_type does not have a valid value')
+ end.not_to change { Ci::Runner.count }
+ end
+ end
+
+ context 'with unknown runner_type' do
+ let(:runner_attrs) { { runner_type: 'unknown' } }
+ let(:current_user) { admin }
+
+ it 'fails to create runner with :bad_request' do
+ expect do
+ request
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('runner_type does not have a valid value')
+ end.not_to change { Ci::Runner.count }
+ end
+ end
+
+ it 'returns a 401 error if unauthorized' do
+ post api(path), params: runner_attrs
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
end
diff --git a/spec/requests/api/v3/github_spec.rb b/spec/requests/api/v3/github_spec.rb
index 0b8fac5c55c..b6fccd9b7cb 100644
--- a/spec/requests/api/v3/github_spec.rb
+++ b/spec/requests/api/v3/github_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::V3::Github, feature_category: :integrations do
+RSpec.describe API::V3::Github, :aggregate_failures, feature_category: :integrations do
let_it_be(:user) { create(:user) }
let_it_be(:unauthorized_user) { create(:user) }
let_it_be(:admin) { create(:user, :admin) }
@@ -13,6 +13,13 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
end
describe 'GET /orgs/:namespace/repos' do
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject do
+ group = create(:group)
+ jira_get v3_api("/orgs/#{group.path}/repos", user)
+ end
+ end
+
it 'returns an empty array' do
group = create(:group)
@@ -32,6 +39,10 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
end
describe 'GET /user/repos' do
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject { jira_get v3_api('/user/repos', user) }
+ end
+
it 'returns an empty array' do
jira_get v3_api('/user/repos', user)
@@ -117,6 +128,10 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
describe 'GET /users/:username' do
let!(:user1) { create(:user, username: 'jane.porter') }
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject { jira_get v3_api("/users/#{user.username}", user) }
+ end
+
context 'user exists' do
it 'responds with the expected user' do
jira_get v3_api("/users/#{user.username}", user)
@@ -155,6 +170,10 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
let(:project) { create(:project, :empty_repo, path: 'project.with.dot', group: group) }
let(:events_path) { "/repos/#{group.path}/#{project.path}/events" }
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject { jira_get v3_api(events_path, user) }
+ end
+
context 'if there are no merge requests' do
it 'returns an empty array' do
jira_get v3_api(events_path, user)
@@ -232,6 +251,10 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
describe 'GET /-/jira/pulls' do
let(:route) { '/repos/-/jira/pulls' }
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject { perform_request }
+ end
+
it 'returns an array of merge requests with github format' do
perform_request
@@ -258,6 +281,10 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
describe 'GET /repos/:namespace/:project/pulls' do
let(:route) { "/repos/#{project.namespace.path}/#{project.path}/pulls" }
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject { perform_request }
+ end
+
it 'returns an array of merge requests for the proper project in github format' do
perform_request
@@ -279,6 +306,10 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
end
describe 'GET /repos/:namespace/:project/pulls/:id' do
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject { jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls/#{merge_request.id}", user) }
+ end
+
context 'when user has access to the merge requests' do
it 'returns the requested merge request in github format' do
jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls/#{merge_request.id}", user)
@@ -300,7 +331,7 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
context 'when instance admin' do
it 'returns the requested merge request in github format' do
- jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls/#{merge_request.id}", admin)
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls/#{merge_request.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('entities/github/pull_request')
@@ -312,8 +343,8 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
describe 'GET /users/:namespace/repos' do
let(:group) { create(:group, name: 'foo') }
- def expect_project_under_namespace(projects, namespace, user)
- jira_get v3_api("/users/#{namespace.path}/repos", user)
+ def expect_project_under_namespace(projects, namespace, user, admin_mode = false)
+ jira_get v3_api("/users/#{namespace.path}/repos", user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -331,6 +362,10 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
expect(json_response.size).to eq(projects.size)
end
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject { jira_get v3_api("/users/#{user.namespace.path}/repos", user) }
+ end
+
context 'group namespace' do
let(:project) { create(:project, group: group) }
let!(:project2) { create(:project, :public, group: group) }
@@ -343,7 +378,7 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
let(:user) { create(:user, :admin) }
it 'returns an array of projects belonging to group' do
- expect_project_under_namespace([project, project2], group, user)
+ expect_project_under_namespace([project, project2], group, user, true)
end
context 'with a private group' do
@@ -351,7 +386,7 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
let!(:project2) { create(:project, :private, group: group) }
it 'returns an array of projects belonging to group' do
- expect_project_under_namespace([project, project2], group, user)
+ expect_project_under_namespace([project, project2], group, user, true)
end
end
end
@@ -423,6 +458,10 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
describe 'GET /repos/:namespace/:project/branches' do
context 'authenticated' do
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject { jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user) }
+ end
+
context 'updating project feature usage' do
it 'counts Jira Cloud integration as enabled' do
user_agent = 'Jira DVCS Connector Vertigo/4.42.0'
@@ -473,7 +512,7 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
expect(response).to have_gitlab_http_status(:ok)
end
- context 'when the project has no repository', :aggregate_failures do
+ context 'when the project has no repository' do
let_it_be(:project) { create(:project, creator: user) }
it 'returns an empty collection response' do
@@ -516,7 +555,11 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
end
context 'authenticated' do
- it 'returns commit with github format', :aggregate_failures do
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject { call_api }
+ end
+
+ it 'returns commit with github format' do
call_api
expect(response).to have_gitlab_http_status(:ok)
@@ -552,7 +595,7 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
.and_call_original
end
- it 'handles the error, logs it, and returns empty diff files', :aggregate_failures do
+ it 'handles the error, logs it, and returns empty diff files' do
allow(Gitlab::GitalyClient).to receive(:call)
.with(*commit_diff_args)
.and_raise(GRPC::DeadlineExceeded)
@@ -567,7 +610,7 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
expect(response_diff_files(response)).to be_blank
end
- it 'only calls Gitaly once for all attempts within a period of time', :aggregate_failures do
+ it 'only calls Gitaly once for all attempts within a period of time' do
expect(Gitlab::GitalyClient).to receive(:call)
.with(*commit_diff_args)
.once # <- once
@@ -581,7 +624,7 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
end
end
- it 'calls Gitaly again after a period of time', :aggregate_failures do
+ it 'calls Gitaly again after a period of time' do
expect(Gitlab::GitalyClient).to receive(:call)
.with(*commit_diff_args)
.twice # <- twice
@@ -648,13 +691,14 @@ RSpec.describe API::V3::Github, feature_category: :integrations do
get path, headers: { 'User-Agent' => user_agent }
end
- def v3_api(path, user = nil, personal_access_token: nil, oauth_access_token: nil)
+ def v3_api(path, user = nil, personal_access_token: nil, oauth_access_token: nil, admin_mode: false)
api(
path,
user,
version: 'v3',
personal_access_token: personal_access_token,
- oauth_access_token: oauth_access_token
+ oauth_access_token: oauth_access_token,
+ admin_mode: admin_mode
)
end
end
diff --git a/spec/requests/dashboard_controller_spec.rb b/spec/requests/dashboard_controller_spec.rb
index 1c8ab843ebe..d7f01b8a7ab 100644
--- a/spec/requests/dashboard_controller_spec.rb
+++ b/spec/requests/dashboard_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DashboardController, feature_category: :authentication_and_authorization do
+RSpec.describe DashboardController, feature_category: :system_access do
context 'token authentication' do
it_behaves_like 'authenticates sessionless user for the request spec', 'issues atom', public_resource: false do
let(:url) { issues_dashboard_url(:atom, assignee_username: user.username) }
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index 02b99eba8ce..5b50e8a1021 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -230,6 +230,17 @@ RSpec.describe 'Git HTTP requests', feature_category: :source_code_management do
context 'when authenticated' do
it 'creates a new project under the existing namespace' do
+ # current scenario does not matter with the user activity case,
+ # so stub/double it to escape more sql running times limit
+ activity_service = instance_double(::Users::ActivityService)
+ allow(::Users::ActivityService).to receive(:new).and_return(activity_service)
+ allow(activity_service).to receive(:execute)
+
+ # During project creation, we need to track the project wiki
+ # repository. So it is over the query limit threshold, and we
+ # have to adjust it.
+ allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(101)
+
expect do
upload(path, user: user.username, password: user.password) do |response|
expect(response).to have_gitlab_http_status(:ok)
@@ -472,10 +483,11 @@ RSpec.describe 'Git HTTP requests', feature_category: :source_code_management do
end
context 'when the request is not from gitlab-workhorse' do
- it 'raises an exception' do
- expect do
- get("/#{project.full_path}.git/info/refs?service=git-upload-pack")
- end.to raise_error(JWT::DecodeError)
+ it 'responds with 403 Forbidden' do
+ get("/#{project.full_path}.git/info/refs?service=git-upload-pack")
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response.body).to eq('Nil JSON web token')
end
end
@@ -1112,10 +1124,11 @@ RSpec.describe 'Git HTTP requests', feature_category: :source_code_management do
end
context 'when the request is not from gitlab-workhorse' do
- it 'raises an exception' do
- expect do
- get("/#{project.full_path}.git/info/refs?service=git-upload-pack")
- end.to raise_error(JWT::DecodeError)
+ it 'responds with 403 Forbidden' do
+ get("/#{project.full_path}.git/info/refs?service=git-upload-pack")
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response.body).to eq('Nil JSON web token')
end
end
diff --git a/spec/requests/groups/achievements_controller_spec.rb b/spec/requests/groups/achievements_controller_spec.rb
new file mode 100644
index 00000000000..26ca0039984
--- /dev/null
+++ b/spec/requests/groups/achievements_controller_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::AchievementsController, feature_category: :user_profile do
+ let_it_be(:user) { create(:user) }
+
+ shared_examples 'response with 404 status' do
+ it 'returns 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ shared_examples 'ok response with index template' do
+ it 'renders the index template' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:index)
+ end
+ end
+
+ shared_examples 'ok response with index template if authorized' do
+ context 'with a private group' do
+ let(:group) { create(:group, :private) }
+
+ context 'with authorized user' do
+ before do
+ group.add_guest(user)
+ sign_in(user)
+ end
+
+ it_behaves_like 'ok response with index template'
+
+ context 'when achievements ff is disabled' do
+ before do
+ stub_feature_flags(achievements: false)
+ end
+
+ it_behaves_like 'response with 404 status'
+ end
+ end
+
+ context 'with unauthorized user' do
+ before do
+ sign_in(user)
+ end
+
+ it_behaves_like 'response with 404 status'
+ end
+
+ context 'with anonymous user' do
+ it 'redirects to sign_in page' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+ end
+
+ context 'with a public group' do
+ let(:group) { create(:group, :public) }
+
+ context 'with anonymous user' do
+ it_behaves_like 'ok response with index template'
+ end
+ end
+ end
+
+ describe 'GET #index' do
+ subject { get group_achievements_path(group) }
+
+ it_behaves_like 'ok response with index template if authorized'
+ end
+end
diff --git a/spec/requests/groups/email_campaigns_controller_spec.rb b/spec/requests/groups/email_campaigns_controller_spec.rb
index 7db5c084793..b6e765eba37 100644
--- a/spec/requests/groups/email_campaigns_controller_spec.rb
+++ b/spec/requests/groups/email_campaigns_controller_spec.rb
@@ -38,11 +38,7 @@ RSpec.describe Groups::EmailCampaignsController, feature_category: :navigation d
expect(subject).to have_gitlab_http_status(:redirect)
end
- context 'on .com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
+ context 'on SaaS', :saas do
it 'emits a snowplow event', :snowplow do
subject
diff --git a/spec/requests/groups/observability_controller_spec.rb b/spec/requests/groups/observability_controller_spec.rb
index 471cad40c90..b82cf2b0bad 100644
--- a/spec/requests/groups/observability_controller_spec.rb
+++ b/spec/requests/groups/observability_controller_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe Groups::ObservabilityController, feature_category: :tracing do
end
end
- context 'when user is not a developer' do
+ context 'when user is a guest' do
before do
sign_in(user)
end
@@ -36,10 +36,10 @@ RSpec.describe Groups::ObservabilityController, feature_category: :tracing do
end
end
- context 'when user is authenticated and a developer' do
+ context 'when user has the correct permissions' do
before do
sign_in(user)
- group.add_developer(user)
+ set_permissions
end
context 'when observability url is missing' do
@@ -75,13 +75,21 @@ RSpec.describe Groups::ObservabilityController, feature_category: :tracing do
let(:path) { group_observability_explore_path(group) }
let(:expected_observability_path) { "#{observability_url}/-/#{group.id}/explore" }
- it_behaves_like 'observability route request'
+ it_behaves_like 'observability route request' do
+ let(:set_permissions) do
+ group.add_developer(user)
+ end
+ end
end
describe 'GET #datasources' do
let(:path) { group_observability_datasources_path(group) }
let(:expected_observability_path) { "#{observability_url}/-/#{group.id}/datasources" }
- it_behaves_like 'observability route request'
+ it_behaves_like 'observability route request' do
+ let(:set_permissions) do
+ group.add_maintainer(user)
+ end
+ end
end
end
diff --git a/spec/requests/groups/settings/access_tokens_controller_spec.rb b/spec/requests/groups/settings/access_tokens_controller_spec.rb
index f26b69f8d30..0204af8ea8e 100644
--- a/spec/requests/groups/settings/access_tokens_controller_spec.rb
+++ b/spec/requests/groups/settings/access_tokens_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::Settings::AccessTokensController, feature_category: :authentication_and_authorization do
+RSpec.describe Groups::Settings::AccessTokensController, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let_it_be(:resource) { create(:group) }
let_it_be(:access_token_user) { create(:user, :project_bot) }
diff --git a/spec/requests/groups/settings/applications_controller_spec.rb b/spec/requests/groups/settings/applications_controller_spec.rb
index fb91cd8bdab..2fcf80658b2 100644
--- a/spec/requests/groups/settings/applications_controller_spec.rb
+++ b/spec/requests/groups/settings/applications_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::Settings::ApplicationsController, feature_category: :authentication_and_authorization do
+RSpec.describe Groups::Settings::ApplicationsController, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:application) { create(:oauth_application, owner_id: group.id, owner_type: 'Namespace') }
diff --git a/spec/requests/groups/usage_quotas_controller_spec.rb b/spec/requests/groups/usage_quotas_controller_spec.rb
index a329398aab3..67aef23704a 100644
--- a/spec/requests/groups/usage_quotas_controller_spec.rb
+++ b/spec/requests/groups/usage_quotas_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::UsageQuotasController, :with_license, feature_category: :subscription_cost_management do
+RSpec.describe Groups::UsageQuotasController, :with_license, feature_category: :consumables_cost_management do
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) }
let_it_be(:user) { create(:user) }
diff --git a/spec/requests/ide_controller_spec.rb b/spec/requests/ide_controller_spec.rb
index b287ded799d..fe7210e4372 100644
--- a/spec/requests/ide_controller_spec.rb
+++ b/spec/requests/ide_controller_spec.rb
@@ -19,16 +19,15 @@ RSpec.describe IdeController, feature_category: :web_ide do
let_it_be(:top_nav_partial) { 'layouts/header/_default' }
let(:user) { creator }
- let(:branch) { '' }
- def find_csp_frame_src
+ def find_csp_source(key)
csp = response.headers['Content-Security-Policy']
- # Transform "frame-src foo bar; connect-src foo bar; script-src ..."
- # into array of connect-src values
+ # Transform "default-src foo bar; connect-src foo bar; script-src ..."
+ # into array of values for a single directive based on the given key
csp.split(';')
.map(&:strip)
- .find { |entry| entry.starts_with?('frame-src') }
+ .find { |entry| entry.starts_with?(key) }
.split(' ')
.drop(1)
end
@@ -42,14 +41,14 @@ RSpec.describe IdeController, feature_category: :web_ide do
subject { get route }
shared_examples 'user access rights check' do
- context 'user can read project' do
+ context 'when user can read project' do
it 'increases the views counter' do
expect(Gitlab::UsageDataCounters::WebIdeCounter).to receive(:increment_views_count)
subject
end
- context 'user can read project but cannot push code' do
+ context 'when user can read project but cannot push code' do
include ProjectForksHelper
let(:user) { reporter }
@@ -60,7 +59,15 @@ RSpec.describe IdeController, feature_category: :web_ide do
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:project)).to eq project
- expect(assigns(:fork_info)).to eq({ fork_path: controller.helpers.ide_fork_and_edit_path(project, branch, '', with_notice: false) })
+
+ expect(assigns(:fork_info)).to eq({
+ fork_path: controller.helpers.ide_fork_and_edit_path(
+ project,
+ '',
+ '',
+ with_notice: false
+ )
+ })
end
it 'has nil fork_info if user cannot fork' do
@@ -81,13 +88,13 @@ RSpec.describe IdeController, feature_category: :web_ide do
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:project)).to eq project
- expect(assigns(:fork_info)).to eq({ ide_path: controller.helpers.ide_edit_path(fork, branch, '') })
+ expect(assigns(:fork_info)).to eq({ ide_path: controller.helpers.ide_edit_path(fork, '', '') })
end
end
end
end
- context 'user cannot read project' do
+ context 'when user cannot read project' do
let(:user) { other_user }
it 'returns 404' do
@@ -98,7 +105,7 @@ RSpec.describe IdeController, feature_category: :web_ide do
end
end
- context '/-/ide' do
+ context 'with /-/ide' do
let(:route) { '/-/ide' }
it 'returns 404' do
@@ -108,7 +115,7 @@ RSpec.describe IdeController, feature_category: :web_ide do
end
end
- context '/-/ide/project' do
+ context 'with /-/ide/project' do
let(:route) { '/-/ide/project' }
it 'returns 404' do
@@ -118,7 +125,7 @@ RSpec.describe IdeController, feature_category: :web_ide do
end
end
- context '/-/ide/project/:project' do
+ context 'with /-/ide/project/:project' do
let(:route) { "/-/ide/project/#{project.full_path}" }
it 'instantiates project instance var and returns 200' do
@@ -126,16 +133,13 @@ RSpec.describe IdeController, feature_category: :web_ide do
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:project)).to eq project
- expect(assigns(:branch)).to be_nil
- expect(assigns(:path)).to be_nil
- expect(assigns(:merge_request)).to be_nil
expect(assigns(:fork_info)).to be_nil
end
it_behaves_like 'user access rights check'
- %w(edit blob tree).each do |action|
- context "/-/ide/project/:project/#{action}" do
+ %w[edit blob tree].each do |action|
+ context "with /-/ide/project/:project/#{action}" do
let(:route) { "/-/ide/project/#{project.full_path}/#{action}" }
it 'instantiates project instance var and returns 200' do
@@ -143,89 +147,13 @@ RSpec.describe IdeController, feature_category: :web_ide do
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:project)).to eq project
- expect(assigns(:branch)).to be_nil
- expect(assigns(:path)).to be_nil
- expect(assigns(:merge_request)).to be_nil
expect(assigns(:fork_info)).to be_nil
end
it_behaves_like 'user access rights check'
-
- context "/-/ide/project/:project/#{action}/:branch" do
- let(:branch) { 'master' }
- let(:route) { "/-/ide/project/#{project.full_path}/#{action}/#{branch}" }
-
- it 'instantiates project and branch instance vars and returns 200' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:project)).to eq project
- expect(assigns(:branch)).to eq branch
- expect(assigns(:path)).to be_nil
- expect(assigns(:merge_request)).to be_nil
- expect(assigns(:fork_info)).to be_nil
- end
-
- it_behaves_like 'user access rights check'
-
- context "/-/ide/project/:project/#{action}/:branch/-" do
- let(:branch) { 'branch/slash' }
- let(:route) { "/-/ide/project/#{project.full_path}/#{action}/#{branch}/-" }
-
- it 'instantiates project and branch instance vars and returns 200' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:project)).to eq project
- expect(assigns(:branch)).to eq branch
- expect(assigns(:path)).to be_nil
- expect(assigns(:merge_request)).to be_nil
- expect(assigns(:fork_info)).to be_nil
- end
-
- it_behaves_like 'user access rights check'
-
- context "/-/ide/project/:project/#{action}/:branch/-/:path" do
- let(:branch) { 'master' }
- let(:route) { "/-/ide/project/#{project.full_path}/#{action}/#{branch}/-/foo/.bar" }
-
- it 'instantiates project, branch, and path instance vars and returns 200' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:project)).to eq project
- expect(assigns(:branch)).to eq branch
- expect(assigns(:path)).to eq 'foo/.bar'
- expect(assigns(:merge_request)).to be_nil
- expect(assigns(:fork_info)).to be_nil
- end
-
- it_behaves_like 'user access rights check'
- end
- end
- end
end
end
- context '/-/ide/project/:project/merge_requests/:merge_request_id' do
- let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
-
- let(:route) { "/-/ide/project/#{project.full_path}/merge_requests/#{merge_request.id}" }
-
- it 'instantiates project and merge_request instance vars and returns 200' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:project)).to eq project
- expect(assigns(:branch)).to be_nil
- expect(assigns(:path)).to be_nil
- expect(assigns(:merge_request)).to eq merge_request.id.to_s
- expect(assigns(:fork_info)).to be_nil
- end
-
- it_behaves_like 'user access rights check'
- end
-
describe 'Snowplow view event', :snowplow do
it 'is tracked' do
subject
@@ -237,33 +165,18 @@ RSpec.describe IdeController, feature_category: :web_ide do
user: user
)
end
-
- context 'when route_hll_to_snowplow_phase2 FF is disabled' do
- before do
- stub_feature_flags(route_hll_to_snowplow_phase2: false)
- end
-
- it 'does not track Snowplow event' do
- subject
-
- expect_no_snowplow_event
- end
- end
end
# This indirectly tests that `minimal: true` was passed to the fullscreen layout
describe 'layout' do
- where(:ff_state, :use_legacy_web_ide, :expect_top_nav) do
- false | false | true
- false | true | true
- true | true | true
- true | false | false
+ where(:ff_state, :expect_top_nav) do
+ false | true
+ true | false
end
with_them do
before do
stub_feature_flags(vscode_web_ide: ff_state)
- allow(user).to receive(:use_legacy_web_ide).and_return(use_legacy_web_ide)
subject
end
@@ -279,15 +192,23 @@ RSpec.describe IdeController, feature_category: :web_ide do
end
end
- describe 'frame-src content security policy' do
+ describe 'content security policy' do
let(:route) { '/-/ide' }
- before do
+ it 'updates the content security policy with the correct frame sources' do
subject
+
+ expect(find_csp_source('frame-src')).to include("http://www.example.com/assets/webpack/", "https://*.vscode-cdn.net/")
+ expect(find_csp_source('worker-src')).to include("http://www.example.com/assets/webpack/")
end
- it 'adds https://*.vscode-cdn.net in frame-src CSP policy' do
- expect(find_csp_frame_src).to include("https://*.vscode-cdn.net/")
+ it 'with relative_url_root, updates the content security policy with the correct frame sources' do
+ stub_config_setting(relative_url_root: '/gitlab')
+
+ subject
+
+ expect(find_csp_source('frame-src')).to include("http://www.example.com/gitlab/assets/webpack/")
+ expect(find_csp_source('worker-src')).to include("http://www.example.com/gitlab/assets/webpack/")
end
end
end
diff --git a/spec/requests/import/github_controller_spec.rb b/spec/requests/import/github_controller_spec.rb
new file mode 100644
index 00000000000..8d57c2895de
--- /dev/null
+++ b/spec/requests/import/github_controller_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Import::GithubController, feature_category: :importers do
+ describe 'GET details' do
+ subject { get details_import_github_path }
+
+ let_it_be(:user) { create(:user) }
+
+ before do
+ stub_application_setting(import_sources: ['github'])
+
+ login_as(user)
+ end
+
+ context 'with feature enabled' do
+ before do
+ stub_feature_flags(import_details_page: true)
+
+ subject
+ end
+
+ it 'responds with a 200 and shows the template' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:details)
+ end
+ end
+
+ context 'with feature disabled' do
+ before do
+ stub_feature_flags(import_details_page: false)
+
+ subject
+ end
+
+ it 'responds with a 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/requests/import/github_groups_controller_spec.rb b/spec/requests/import/github_groups_controller_spec.rb
index 6393dd35a98..dada84758f3 100644
--- a/spec/requests/import/github_groups_controller_spec.rb
+++ b/spec/requests/import/github_groups_controller_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe Import::GithubGroupsController, feature_category: :importers do
let(:params) { {} }
before do
+ stub_application_setting(import_sources: ['github'])
+
login_as(user)
end
diff --git a/spec/requests/import/gitlab_projects_controller_spec.rb b/spec/requests/import/gitlab_projects_controller_spec.rb
index b2c2d306e53..732851c7828 100644
--- a/spec/requests/import/gitlab_projects_controller_spec.rb
+++ b/spec/requests/import/gitlab_projects_controller_spec.rb
@@ -12,6 +12,8 @@ RSpec.describe Import::GitlabProjectsController, feature_category: :importers do
before do
login_as(user)
+
+ stub_application_setting(import_sources: ['gitlab_project'])
end
describe 'POST create' do
@@ -90,4 +92,16 @@ RSpec.describe Import::GitlabProjectsController, feature_category: :importers do
subject { post authorize_import_gitlab_project_path, headers: workhorse_headers }
end
end
+
+ describe 'GET new' do
+ context 'when the user is not allowed to import projects' do
+ let!(:group) { create(:group).tap { |group| group.add_developer(user) } }
+
+ it 'returns 404' do
+ get new_import_gitlab_project_path, params: { namespace_id: group.id }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
end
diff --git a/spec/requests/jira_authorizations_spec.rb b/spec/requests/jira_authorizations_spec.rb
index 8c27b61712c..704db7fba08 100644
--- a/spec/requests/jira_authorizations_spec.rb
+++ b/spec/requests/jira_authorizations_spec.rb
@@ -39,6 +39,16 @@ RSpec.describe 'Jira authorization requests', feature_category: :integrations do
expect(oauth_response_access_token).not_to eql(jira_response_access_token)
end
+ it_behaves_like 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ subject do
+ post '/login/oauth/access_token', params: {
+ client_id: client_id,
+ client_secret: client_secret,
+ code: generate_access_grant.token
+ }
+ end
+ end
+
context 'when authorization fails' do
before do
post '/login/oauth/access_token', params: {
diff --git a/spec/requests/jira_connect/oauth_application_ids_controller_spec.rb b/spec/requests/jira_connect/oauth_application_ids_controller_spec.rb
index d111edd06da..2f6113c6dd7 100644
--- a/spec/requests/jira_connect/oauth_application_ids_controller_spec.rb
+++ b/spec/requests/jira_connect/oauth_application_ids_controller_spec.rb
@@ -38,11 +38,7 @@ RSpec.describe JiraConnect::OauthApplicationIdsController, feature_category: :in
end
end
- context 'on GitLab.com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
+ context 'on SaaS', :saas do
it 'renders not found' do
get '/-/jira_connect/oauth_application_id'
diff --git a/spec/requests/jira_connect/public_keys_controller_spec.rb b/spec/requests/jira_connect/public_keys_controller_spec.rb
index 7f0262eaf65..62a81d43e65 100644
--- a/spec/requests/jira_connect/public_keys_controller_spec.rb
+++ b/spec/requests/jira_connect/public_keys_controller_spec.rb
@@ -5,11 +5,10 @@ require 'spec_helper'
RSpec.describe JiraConnect::PublicKeysController, feature_category: :integrations do
describe 'GET /-/jira_connect/public_keys/:uuid' do
let(:uuid) { non_existing_record_id }
- let(:public_key_storage_enabled_config) { true }
+ let(:public_key_storage_enabled) { true }
before do
- allow(Gitlab.config.jira_connect).to receive(:enable_public_keys_storage)
- .and_return(public_key_storage_enabled_config)
+ stub_application_setting(jira_connect_public_key_storage_enabled: public_key_storage_enabled)
end
it 'renders 404' do
@@ -30,26 +29,14 @@ RSpec.describe JiraConnect::PublicKeysController, feature_category: :integration
expect(response.body).to eq(public_key.key)
end
- context 'when public key storage config disabled' do
- let(:public_key_storage_enabled_config) { false }
+ context 'when public key storage setting disabled' do
+ let(:public_key_storage_enabled) { false }
it 'renders 404' do
get jira_connect_public_key_path(id: uuid)
expect(response).to have_gitlab_http_status(:not_found)
end
-
- context 'when public key storage setting is enabled' do
- before do
- stub_application_setting(jira_connect_public_key_storage_enabled: true)
- end
-
- it 'renders 404' do
- get jira_connect_public_key_path(id: uuid)
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
end
end
end
diff --git a/spec/requests/jira_connect/users_controller_spec.rb b/spec/requests/jira_connect/users_controller_spec.rb
deleted file mode 100644
index c02bd324708..00000000000
--- a/spec/requests/jira_connect/users_controller_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe JiraConnect::UsersController, feature_category: :integrations do
- describe 'GET /-/jira_connect/users' do
- let_it_be(:user) { create(:user) }
-
- before do
- sign_in(user)
- end
-
- context 'with a valid host' do
- let(:return_to) { 'https://testcompany.atlassian.net/plugins/servlet/ac/gitlab-jira-connect-staging.gitlab.com/gitlab-configuration' }
-
- it 'includes a return url' do
- get '/-/jira_connect/users', params: { return_to: return_to }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to include('Return to GitLab')
- end
- end
-
- context 'with an invalid host' do
- let(:return_to) { 'https://evil.com' }
-
- it 'does not include a return url' do
- get '/-/jira_connect/users', params: { return_to: return_to }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).not_to include('Return to GitLab')
- end
- end
-
- context 'with a script injected' do
- let(:return_to) { 'javascript://test.atlassian.net/%250dalert(document.domain)' }
-
- it 'does not include a return url' do
- get '/-/jira_connect/users', params: { return_to: return_to }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).not_to include('Return to GitLab')
- end
- end
- end
-end
diff --git a/spec/requests/jwks_controller_spec.rb b/spec/requests/jwks_controller_spec.rb
index ac9765c35d8..f756c1758e4 100644
--- a/spec/requests/jwks_controller_spec.rb
+++ b/spec/requests/jwks_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JwksController, feature_category: :authentication_and_authorization do
+RSpec.describe JwksController, feature_category: :system_access do
describe 'Endpoints from the parent Doorkeeper::OpenidConnect::DiscoveryController' do
it 'respond successfully' do
[
@@ -35,6 +35,15 @@ RSpec.describe JwksController, feature_category: :authentication_and_authorizati
expect(ids).to contain_exactly(ci_jwk['kid'], oidc_jwk['kid'])
end
+ it 'includes the OIDC signing key ID' do
+ get jwks_url
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ ids = json_response['keys'].map { |jwk| jwk['kid'] }
+ expect(ids).to include(Doorkeeper::OpenidConnect.signing_key_normalized.symbolize_keys[:kid])
+ end
+
it 'does not leak private key data' do
get jwks_url
diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb
index 00222cb1977..69127a7526e 100644
--- a/spec/requests/jwt_controller_spec.rb
+++ b/spec/requests/jwt_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JwtController, feature_category: :authentication_and_authorization do
+RSpec.describe JwtController, feature_category: :system_access do
include_context 'parsed logs'
let(:service) { double(execute: {} ) }
@@ -53,6 +53,14 @@ RSpec.describe JwtController, feature_category: :authentication_and_authorizatio
end
end
+ context 'POST /jwt/auth' do
+ it 'returns 404' do
+ post '/jwt/auth'
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'authenticating against container registry' do
context 'existing service' do
subject! { get '/jwt/auth', params: parameters }
diff --git a/spec/requests/oauth/applications_controller_spec.rb b/spec/requests/oauth/applications_controller_spec.rb
index 94ee08f6272..8c2856b87d1 100644
--- a/spec/requests/oauth/applications_controller_spec.rb
+++ b/spec/requests/oauth/applications_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Oauth::ApplicationsController, feature_category: :authentication_and_authorization do
+RSpec.describe Oauth::ApplicationsController, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let_it_be(:application) { create(:oauth_application, owner: user) }
let_it_be(:show_path) { oauth_application_path(application) }
diff --git a/spec/requests/oauth/authorizations_controller_spec.rb b/spec/requests/oauth/authorizations_controller_spec.rb
index 52188717210..257f238d9ef 100644
--- a/spec/requests/oauth/authorizations_controller_spec.rb
+++ b/spec/requests/oauth/authorizations_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Oauth::AuthorizationsController, feature_category: :authentication_and_authorization do
+RSpec.describe Oauth::AuthorizationsController, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let_it_be(:application) { create(:oauth_application, redirect_uri: 'custom://test') }
let_it_be(:oauth_authorization_path) do
diff --git a/spec/requests/oauth/tokens_controller_spec.rb b/spec/requests/oauth/tokens_controller_spec.rb
index cdfad8cb59c..58203a81bac 100644
--- a/spec/requests/oauth/tokens_controller_spec.rb
+++ b/spec/requests/oauth/tokens_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Oauth::TokensController, feature_category: :authentication_and_authorization do
+RSpec.describe Oauth::TokensController, feature_category: :system_access do
let(:cors_request_headers) { { 'Origin' => 'http://notgitlab.com' } }
let(:other_headers) { {} }
let(:headers) { cors_request_headers.merge(other_headers) }
diff --git a/spec/requests/oauth_tokens_spec.rb b/spec/requests/oauth_tokens_spec.rb
index 053bd317fcc..67c676fdb40 100644
--- a/spec/requests/oauth_tokens_spec.rb
+++ b/spec/requests/oauth_tokens_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'OAuth Tokens requests', feature_category: :authentication_and_authorization do
+RSpec.describe 'OAuth Tokens requests', feature_category: :system_access do
let(:user) { create :user }
let(:application) { create :oauth_application, scopes: 'api' }
let(:grant_type) { 'authorization_code' }
diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb
index 9035e723abe..82f972e7f94 100644
--- a/spec/requests/openid_connect_spec.rb
+++ b/spec/requests/openid_connect_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'OpenID Connect requests', feature_category: :authentication_and_authorization do
+RSpec.describe 'OpenID Connect requests', feature_category: :system_access do
let(:user) do
create(
:user,
@@ -276,7 +276,7 @@ RSpec.describe 'OpenID Connect requests', feature_category: :authentication_and_
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['issuer']).to eq('http://localhost')
expect(json_response['jwks_uri']).to eq('http://www.example.com/oauth/discovery/keys')
- expect(json_response['scopes_supported']).to match_array %w[admin_mode api read_user read_api read_repository write_repository sudo openid profile email]
+ expect(json_response['scopes_supported']).to match_array %w[admin_mode api read_user read_api read_repository write_repository sudo openid profile email read_observability write_observability]
end
context 'with a cross-origin request' do
@@ -286,7 +286,7 @@ RSpec.describe 'OpenID Connect requests', feature_category: :authentication_and_
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['issuer']).to eq('http://localhost')
expect(json_response['jwks_uri']).to eq('http://www.example.com/oauth/discovery/keys')
- expect(json_response['scopes_supported']).to match_array %w[admin_mode api read_user read_api read_repository write_repository sudo openid profile email]
+ expect(json_response['scopes_supported']).to match_array %w[admin_mode api read_user read_api read_repository write_repository sudo openid profile email read_observability write_observability]
end
it_behaves_like 'cross-origin GET request'
diff --git a/spec/requests/profiles/comment_templates_controller_spec.rb b/spec/requests/profiles/comment_templates_controller_spec.rb
new file mode 100644
index 00000000000..cdbfbb0a346
--- /dev/null
+++ b/spec/requests/profiles/comment_templates_controller_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Profiles::CommentTemplatesController, feature_category: :user_profile do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET #index' do
+ describe 'feature flag disabled' do
+ before do
+ stub_feature_flags(saved_replies: false)
+
+ get '/-/profile/comment_templates'
+ end
+
+ it { expect(response).to have_gitlab_http_status(:not_found) }
+ end
+
+ describe 'feature flag enabled' do
+ before do
+ get '/-/profile/comment_templates'
+ end
+
+ it { expect(response).to have_gitlab_http_status(:ok) }
+
+ it 'sets hide search settings ivar' do
+ expect(assigns(:hide_search_settings)).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/requests/profiles/saved_replies_controller_spec.rb b/spec/requests/profiles/saved_replies_controller_spec.rb
deleted file mode 100644
index 27a961a201f..00000000000
--- a/spec/requests/profiles/saved_replies_controller_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Profiles::SavedRepliesController, feature_category: :user_profile do
- let_it_be(:user) { create(:user) }
-
- before do
- sign_in(user)
- end
-
- describe 'GET #index' do
- describe 'feature flag disabled' do
- before do
- stub_feature_flags(saved_replies: false)
-
- get '/-/profile/saved_replies'
- end
-
- it { expect(response).to have_gitlab_http_status(:not_found) }
- end
-
- describe 'feature flag enabled' do
- before do
- get '/-/profile/saved_replies'
- end
-
- it { expect(response).to have_gitlab_http_status(:ok) }
-
- it 'sets hide search settings ivar' do
- expect(assigns(:hide_search_settings)).to eq(true)
- end
- end
- end
-end
diff --git a/spec/requests/projects/airflow/dags_controller_spec.rb b/spec/requests/projects/airflow/dags_controller_spec.rb
deleted file mode 100644
index 2dcedf5f128..00000000000
--- a/spec/requests/projects/airflow/dags_controller_spec.rb
+++ /dev/null
@@ -1,105 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::Airflow::DagsController, feature_category: :dataops do
- let_it_be(:non_member) { create(:user) }
- let_it_be(:user) { create(:user) }
- let_it_be(:group) { create(:group).tap { |p| p.add_developer(user) } }
- let_it_be(:project) { create(:project, group: group).tap { |p| p.add_developer(user) } }
-
- let(:current_user) { user }
- let(:feature_flag) { true }
-
- let_it_be(:dags) do
- create_list(:airflow_dags, 5, project: project)
- end
-
- let(:params) { { namespace_id: project.namespace.to_param, project_id: project } }
- let(:extra_params) { {} }
-
- before do
- sign_in(current_user) if current_user
- stub_feature_flags(airflow_dags: false)
- stub_feature_flags(airflow_dags: project) if feature_flag
- list_dags
- end
-
- shared_examples 'returns a 404 if feature flag disabled' do
- context 'when :airflow_dags disabled' do
- let(:feature_flag) { false }
-
- it 'is 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
-
- describe 'GET index' do
- it 'renders the template' do
- expect(response).to render_template('projects/airflow/dags/index')
- end
-
- describe 'pagination' do
- before do
- stub_const("Projects::Airflow::DagsController::MAX_DAGS_PER_PAGE", 2)
- dags
-
- list_dags
- end
-
- context 'when out of bounds' do
- let(:params) { extra_params.merge(page: 10000) }
-
- it 'redirects to last page' do
- last_page = (dags.size + 1) / 2
- expect(response).to redirect_to(project_airflow_dags_path(project, page: last_page))
- end
- end
-
- context 'when bad page' do
- let(:params) { extra_params.merge(page: 's') }
-
- it 'uses first page' do
- expect(assigns(:pagination)).to include(
- page: 1,
- is_last_page: false,
- per_page: 2,
- total_items: dags.size)
- end
- end
- end
-
- it 'does not perform N+1 sql queries' do
- control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { list_dags }
-
- create_list(:airflow_dags, 1, project: project)
-
- expect { list_dags }.not_to exceed_all_query_limit(control_count)
- end
-
- context 'when user is not logged in' do
- let(:current_user) { nil }
-
- it 'redirects to login' do
- expect(response).to redirect_to(new_user_session_path)
- end
- end
-
- context 'when user is not a member' do
- let(:current_user) { non_member }
-
- it 'returns a 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- it_behaves_like 'returns a 404 if feature flag disabled'
- end
-
- private
-
- def list_dags
- get project_airflow_dags_path(project), params: params
- end
-end
diff --git a/spec/requests/projects/aws/configuration_controller_spec.rb b/spec/requests/projects/aws/configuration_controller_spec.rb
new file mode 100644
index 00000000000..af9460eb76c
--- /dev/null
+++ b/spec/requests/projects/aws/configuration_controller_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Aws::ConfigurationController, feature_category: :five_minute_production_app do
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:url) { project_aws_configuration_path(project) }
+
+ let_it_be(:user_guest) { create(:user) }
+ let_it_be(:user_developer) { create(:user) }
+ let_it_be(:user_maintainer) { create(:user) }
+
+ let_it_be(:unauthorized_members) { [user_guest, user_developer] }
+ let_it_be(:authorized_members) { [user_maintainer] }
+
+ before do
+ project.add_guest(user_guest)
+ project.add_developer(user_developer)
+ project.add_maintainer(user_maintainer)
+ end
+
+ context 'when accessed by unauthorized members' do
+ it 'returns not found on GET request' do
+ unauthorized_members.each do |unauthorized_member|
+ sign_in(unauthorized_member)
+
+ get url
+ expect_snowplow_event(
+ category: 'Projects::Aws::ConfigurationController',
+ action: 'error_invalid_user',
+ label: nil,
+ project: project,
+ user: unauthorized_member
+ )
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when accessed by authorized members' do
+ it 'returns successful' do
+ authorized_members.each do |authorized_member|
+ sign_in(authorized_member)
+
+ get url
+
+ expect(response).to be_successful
+ expect(response).to render_template('projects/aws/configuration/index')
+ end
+ end
+
+ include_examples 'requires feature flag `cloudseed_aws` enabled' do
+ subject { get url }
+
+ let_it_be(:user) { user_maintainer }
+ end
+ end
+end
diff --git a/spec/requests/projects/ci/promeheus_metrics/histograms_controller_spec.rb b/spec/requests/projects/ci/promeheus_metrics/histograms_controller_spec.rb
index b0c7427fa81..11f962e0e96 100644
--- a/spec/requests/projects/ci/promeheus_metrics/histograms_controller_spec.rb
+++ b/spec/requests/projects/ci/promeheus_metrics/histograms_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Projects::Ci::PrometheusMetrics::HistogramsController', feature_category: :pipeline_authoring do
+RSpec.describe 'Projects::Ci::PrometheusMetrics::HistogramsController', feature_category: :pipeline_composition do
let_it_be(:project) { create(:project, :public) }
describe 'POST /*namespace_id/:project_id/-/ci/prometheus_metrics/histograms' do
diff --git a/spec/requests/projects/cluster_agents_controller_spec.rb b/spec/requests/projects/cluster_agents_controller_spec.rb
index d7c791fa0c1..643160ad9f3 100644
--- a/spec/requests/projects/cluster_agents_controller_spec.rb
+++ b/spec/requests/projects/cluster_agents_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ClusterAgentsController, feature_category: :kubernetes_management do
+RSpec.describe Projects::ClusterAgentsController, feature_category: :deployment_management do
let_it_be(:cluster_agent) { create(:cluster_agent) }
let(:project) { cluster_agent.project }
diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb
index 3f9dd74c145..0adf0b525a9 100644
--- a/spec/requests/projects/cycle_analytics_events_spec.rb
+++ b/spec/requests/projects/cycle_analytics_events_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'value stream analytics events', feature_category: :planning_analytics do
+RSpec.describe 'value stream analytics events', feature_category: :team_planning do
include CycleAnalyticsHelpers
let(:user) { create(:user) }
diff --git a/spec/requests/projects/environments_controller_spec.rb b/spec/requests/projects/environments_controller_spec.rb
index 41ae2d434fa..5dd83fedf8d 100644
--- a/spec/requests/projects/environments_controller_spec.rb
+++ b/spec/requests/projects/environments_controller_spec.rb
@@ -18,9 +18,7 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d
end
def environment_params(opts = {})
- opts.reverse_merge(namespace_id: project.namespace,
- project_id: project,
- id: environment.id)
+ opts.reverse_merge(namespace_id: project.namespace, project_id: project, id: environment.id)
end
def create_deployment_with_associations(commit_depth:)
diff --git a/spec/requests/projects/google_cloud/configuration_controller_spec.rb b/spec/requests/projects/google_cloud/configuration_controller_spec.rb
index 1aa44d1a49a..b807ff7930e 100644
--- a/spec/requests/projects/google_cloud/configuration_controller_spec.rb
+++ b/spec/requests/projects/google_cloud/configuration_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GoogleCloud::ConfigurationController, feature_category: :kubernetes_management do
+RSpec.describe Projects::GoogleCloud::ConfigurationController, feature_category: :deployment_management do
let_it_be(:project) { create(:project, :public) }
let_it_be(:url) { project_google_cloud_configuration_path(project) }
diff --git a/spec/requests/projects/google_cloud/databases_controller_spec.rb b/spec/requests/projects/google_cloud/databases_controller_spec.rb
index 98e83610600..fa978a3921f 100644
--- a/spec/requests/projects/google_cloud/databases_controller_spec.rb
+++ b/spec/requests/projects/google_cloud/databases_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GoogleCloud::DatabasesController, :snowplow, feature_category: :kubernetes_management do
+RSpec.describe Projects::GoogleCloud::DatabasesController, :snowplow, feature_category: :deployment_management do
shared_examples 'shared examples for database controller endpoints' do
include_examples 'requires `admin_project_google_cloud` role'
diff --git a/spec/requests/projects/google_cloud/deployments_controller_spec.rb b/spec/requests/projects/google_cloud/deployments_controller_spec.rb
index d564a31f835..e9eac1e7ecd 100644
--- a/spec/requests/projects/google_cloud/deployments_controller_spec.rb
+++ b/spec/requests/projects/google_cloud/deployments_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GoogleCloud::DeploymentsController, feature_category: :kubernetes_management do
+RSpec.describe Projects::GoogleCloud::DeploymentsController, feature_category: :deployment_management do
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:repository) { project.repository }
@@ -108,66 +108,104 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController, feature_category: :
end
end
- it 'redirects to google cloud deployments on enable service error' do
- get url
-
- expect(response).to redirect_to(project_google_cloud_deployments_path(project))
- # since GPC_PROJECT_ID is not set, enable cloud run service should return an error
- expect_snowplow_event(
- category: 'Projects::GoogleCloud::DeploymentsController',
- action: 'error_enable_services',
- label: nil,
- project: project,
- user: user_maintainer
- )
- end
+ context 'when enable service fails' do
+ before do
+ allow_next_instance_of(GoogleCloud::EnableCloudRunService) do |service|
+ allow(service)
+ .to receive(:execute)
+ .and_return(
+ status: :error,
+ message: 'No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable'
+ )
+ end
+ end
- it 'redirects to google cloud deployments with error' do
- mock_gcp_error = Google::Apis::ClientError.new('some_error')
+ it 'redirects to google cloud deployments and tracks event on enable service error' do
+ get url
- allow_next_instance_of(GoogleCloud::EnableCloudRunService) do |service|
- allow(service).to receive(:execute).and_raise(mock_gcp_error)
+ expect(response).to redirect_to(project_google_cloud_deployments_path(project))
+ # since GPC_PROJECT_ID is not set, enable cloud run service should return an error
+ expect_snowplow_event(
+ category: 'Projects::GoogleCloud::DeploymentsController',
+ action: 'error_enable_services',
+ label: nil,
+ project: project,
+ user: user_maintainer
+ )
end
- get url
+ it 'shows a flash alert' do
+ get url
- expect(response).to redirect_to(project_google_cloud_deployments_path(project))
- expect_snowplow_event(
- category: 'Projects::GoogleCloud::DeploymentsController',
- action: 'error_google_api',
- label: nil,
- project: project,
- user: user_maintainer
- )
+ expect(flash[:alert])
+ .to eq('No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable')
+ end
end
- context 'GCP_PROJECT_IDs are defined' do
- it 'redirects to google_cloud deployments on generate pipeline error' do
- allow_next_instance_of(GoogleCloud::EnableCloudRunService) do |enable_cloud_run_service|
- allow(enable_cloud_run_service).to receive(:execute).and_return({ status: :success })
- end
+ context 'when enable service raises an error' do
+ before do
+ mock_gcp_error = Google::Apis::ClientError.new('some_error')
- allow_next_instance_of(GoogleCloud::GeneratePipelineService) do |generate_pipeline_service|
- allow(generate_pipeline_service).to receive(:execute).and_return({ status: :error })
+ allow_next_instance_of(GoogleCloud::EnableCloudRunService) do |service|
+ allow(service).to receive(:execute).and_raise(mock_gcp_error)
end
+ end
+ it 'redirects to google cloud deployments with error' do
get url
expect(response).to redirect_to(project_google_cloud_deployments_path(project))
expect_snowplow_event(
category: 'Projects::GoogleCloud::DeploymentsController',
- action: 'error_generate_cloudrun_pipeline',
+ action: 'error_google_api',
label: nil,
project: project,
user: user_maintainer
)
end
- it 'redirects to create merge request form' do
- allow_next_instance_of(GoogleCloud::EnableCloudRunService) do |service|
- allow(service).to receive(:execute).and_return({ status: :success })
+ it 'shows a flash warning' do
+ get url
+
+ expect(flash[:warning]).to eq(format(_('Google Cloud Error - %{error}'), error: 'some_error'))
+ end
+ end
+
+ context 'GCP_PROJECT_IDs are defined' do
+ before do
+ allow_next_instance_of(GoogleCloud::EnableCloudRunService) do |enable_cloud_run_service|
+ allow(enable_cloud_run_service).to receive(:execute).and_return({ status: :success })
+ end
+ end
+
+ context 'when generate pipeline service fails' do
+ before do
+ allow_next_instance_of(GoogleCloud::GeneratePipelineService) do |generate_pipeline_service|
+ allow(generate_pipeline_service).to receive(:execute).and_return({ status: :error })
+ end
+ end
+
+ it 'redirects to google_cloud deployments and tracks event on generate pipeline error' do
+ get url
+
+ expect(response).to redirect_to(project_google_cloud_deployments_path(project))
+ expect_snowplow_event(
+ category: 'Projects::GoogleCloud::DeploymentsController',
+ action: 'error_generate_cloudrun_pipeline',
+ label: nil,
+ project: project,
+ user: user_maintainer
+ )
+ end
+
+ it 'shows a flash alert' do
+ get url
+
+ expect(flash[:alert]).to eq('Failed to generate pipeline')
end
+ end
+ it 'redirects to create merge request form' do
allow_next_instance_of(GoogleCloud::GeneratePipelineService) do |service|
allow(service).to receive(:execute).and_return({ status: :success })
end
diff --git a/spec/requests/projects/google_cloud/gcp_regions_controller_spec.rb b/spec/requests/projects/google_cloud/gcp_regions_controller_spec.rb
index de4b96a2e01..da000ec00c0 100644
--- a/spec/requests/projects/google_cloud/gcp_regions_controller_spec.rb
+++ b/spec/requests/projects/google_cloud/gcp_regions_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GoogleCloud::GcpRegionsController, feature_category: :kubernetes_management do
+RSpec.describe Projects::GoogleCloud::GcpRegionsController, feature_category: :deployment_management do
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:repository) { project.repository }
diff --git a/spec/requests/projects/google_cloud/revoke_oauth_controller_spec.rb b/spec/requests/projects/google_cloud/revoke_oauth_controller_spec.rb
index 5965953cf6f..427eff8cd76 100644
--- a/spec/requests/projects/google_cloud/revoke_oauth_controller_spec.rb
+++ b/spec/requests/projects/google_cloud/revoke_oauth_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GoogleCloud::RevokeOauthController, feature_category: :kubernetes_management do
+RSpec.describe Projects::GoogleCloud::RevokeOauthController, feature_category: :deployment_management do
include SessionHelpers
describe 'POST #create', :snowplow, :clean_gitlab_redis_sessions, :aggregate_failures do
diff --git a/spec/requests/projects/google_cloud/service_accounts_controller_spec.rb b/spec/requests/projects/google_cloud/service_accounts_controller_spec.rb
index 9b048f814ef..29d4154329f 100644
--- a/spec/requests/projects/google_cloud/service_accounts_controller_spec.rb
+++ b/spec/requests/projects/google_cloud/service_accounts_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GoogleCloud::ServiceAccountsController, feature_category: :kubernetes_management do
+RSpec.describe Projects::GoogleCloud::ServiceAccountsController, feature_category: :deployment_management do
let_it_be(:project) { create(:project, :public) }
describe 'GET index', :snowplow do
diff --git a/spec/requests/projects/incident_management/timeline_events_spec.rb b/spec/requests/projects/incident_management/timeline_events_spec.rb
index 22a1f654ee2..b827ec07ae1 100644
--- a/spec/requests/projects/incident_management/timeline_events_spec.rb
+++ b/spec/requests/projects/incident_management/timeline_events_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe 'Timeline Events', feature_category: :incident_management do
it 'renders JSON in a correct format' do
post preview_markdown_project_incident_management_timeline_events_path(project, format: :json),
- params: { text: timeline_text }
+ params: { text: timeline_text }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq({
@@ -51,7 +51,7 @@ RSpec.describe 'Timeline Events', feature_category: :incident_management do
context 'when not authorized' do
it 'returns 302' do
post preview_markdown_project_incident_management_timeline_events_path(project, format: :json),
- params: { text: timeline_text }
+ params: { text: timeline_text }
expect(response).to have_gitlab_http_status(:found)
end
diff --git a/spec/requests/projects/issue_links_controller_spec.rb b/spec/requests/projects/issue_links_controller_spec.rb
index 0535156b4b8..c242f762cde 100644
--- a/spec/requests/projects/issue_links_controller_spec.rb
+++ b/spec/requests/projects/issue_links_controller_spec.rb
@@ -28,28 +28,12 @@ RSpec.describe Projects::IssueLinksController, feature_category: :team_planning
context 'when linked issue is a task' do
let(:issue_b) { create :issue, :task, project: project }
- context 'when the use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- end
-
- it 'returns a work item path for the linked task' do
- get namespace_project_issue_links_path(issue_links_params)
-
- expect(json_response.count).to eq(1)
- expect(json_response.first).to include(
- 'path' => project_work_items_path(issue_b.project, issue_b.id),
- 'type' => 'TASK'
- )
- end
- end
-
it 'returns a work item path for the linked task using the iid in the path' do
get namespace_project_issue_links_path(issue_links_params)
expect(json_response.count).to eq(1)
expect(json_response.first).to include(
- 'path' => project_work_items_path(issue_b.project, issue_b.iid, iid_path: true),
+ 'path' => project_work_items_path(issue_b.project, issue_b.iid),
'type' => 'TASK'
)
end
@@ -74,8 +58,7 @@ RSpec.describe Projects::IssueLinksController, feature_category: :team_planning
list_service_response = IssueLinks::ListService.new(issue, user).execute
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to eq('message' => nil,
- 'issuables' => list_service_response.as_json)
+ expect(json_response).to eq('message' => nil, 'issuables' => list_service_response.as_json)
end
end
@@ -178,9 +161,6 @@ RSpec.describe Projects::IssueLinksController, feature_category: :team_planning
end
def issue_links_params(opts = {})
- opts.reverse_merge(namespace_id: issue.project.namespace,
- project_id: issue.project,
- issue_id: issue,
- format: :json)
+ opts.reverse_merge(namespace_id: issue.project.namespace, project_id: issue.project, issue_id: issue, format: :json)
end
end
diff --git a/spec/requests/projects/issues_controller_spec.rb b/spec/requests/projects/issues_controller_spec.rb
index 67a73834f2d..583fd5f586e 100644
--- a/spec/requests/projects/issues_controller_spec.rb
+++ b/spec/requests/projects/issues_controller_spec.rb
@@ -25,33 +25,28 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
end
describe 'GET #show' do
- include_context 'group project issue'
+ before do
+ login_as(user)
+ end
it_behaves_like "observability csp policy", described_class do
+ include_context 'group project issue'
let(:tested_path) do
project_issue_path(project, issue)
end
end
- end
- describe 'GET #index.json' do
- let_it_be(:public_project) { create(:project, :public) }
+ describe 'incident tabs' do
+ let_it_be(:incident) { create(:incident, project: project) }
- it_behaves_like 'rate limited endpoint', rate_limit_key: :search_rate_limit do
- let_it_be(:current_user) { create(:user) }
-
- before do
- sign_in current_user
- end
-
- def request
- get project_issues_path(public_project, format: :json), params: { scope: 'all', search: 'test' }
+ it 'redirects to the issues route for non-incidents' do
+ get incident_issue_project_issue_path(project, issue, 'timeline')
+ expect(response).to redirect_to project_issue_path(project, issue)
end
- end
- it_behaves_like 'rate limited endpoint', rate_limit_key: :search_rate_limit_unauthenticated do
- def request
- get project_issues_path(public_project, format: :json), params: { scope: 'all', search: 'test' }
+ it 'responds with selected tab for incidents' do
+ get incident_issue_project_issue_path(project, incident, 'timeline')
+ expect(response.body).to match(/&quot;currentTab&quot;:&quot;timeline&quot;/)
end
end
end
@@ -119,8 +114,9 @@ RSpec.describe Projects::IssuesController, feature_category: :team_planning do
context 'when private project' do
let_it_be(:private_project) { create(:project, :private) }
- it_behaves_like 'authenticates sessionless user for the request spec', 'index atom', public_resource: false,
-ignore_metrics: true do
+ it_behaves_like 'authenticates sessionless user for the request spec', 'index atom',
+ public_resource: false,
+ ignore_metrics: true do
let(:url) { project_issues_url(private_project, format: :atom) }
before do
@@ -128,8 +124,9 @@ ignore_metrics: true do
end
end
- it_behaves_like 'authenticates sessionless user for the request spec', 'calendar ics', public_resource: false,
-ignore_metrics: true do
+ it_behaves_like 'authenticates sessionless user for the request spec', 'calendar ics',
+ public_resource: false,
+ ignore_metrics: true do
let(:url) { project_issues_url(private_project, format: :ics) }
before do
diff --git a/spec/requests/projects/merge_requests_controller_spec.rb b/spec/requests/projects/merge_requests_controller_spec.rb
index f441438a95a..955e6822211 100644
--- a/spec/requests/projects/merge_requests_controller_spec.rb
+++ b/spec/requests/projects/merge_requests_controller_spec.rb
@@ -120,8 +120,9 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :source_code
context 'when private project' do
let_it_be(:private_project) { create(:project, :private) }
- it_behaves_like 'authenticates sessionless user for the request spec', 'index atom', public_resource: false,
- ignore_metrics: true do
+ it_behaves_like 'authenticates sessionless user for the request spec', 'index atom',
+ public_resource: false,
+ ignore_metrics: true do
let(:url) { project_merge_requests_url(private_project, format: :atom) }
before do
diff --git a/spec/requests/projects/merge_requests_discussions_spec.rb b/spec/requests/projects/merge_requests_discussions_spec.rb
index d82fa284a42..caf62c251b6 100644
--- a/spec/requests/projects/merge_requests_discussions_spec.rb
+++ b/spec/requests/projects/merge_requests_discussions_spec.rb
@@ -27,6 +27,21 @@ RSpec.describe 'merge requests discussions', feature_category: :source_code_mana
end
# rubocop:enable RSpec/InstanceVariable
+ shared_examples 'N+1 queries' do
+ it 'avoids N+1 DB queries', :request_store do
+ send_request # warm up
+
+ create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.project)
+ control = ActiveRecord::QueryRecorder.new { send_request }
+
+ create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.project)
+
+ expect do
+ send_request
+ end.not_to exceed_query_limit(control).with_threshold(notes_metadata_threshold)
+ end
+ end
+
it 'returns 200' do
send_request
@@ -34,17 +49,20 @@ RSpec.describe 'merge requests discussions', feature_category: :source_code_mana
end
# https://docs.gitlab.com/ee/development/query_recorder.html#use-request-specs-instead-of-controller-specs
- it 'avoids N+1 DB queries', :request_store do
- send_request # warm up
+ context 'with notes_metadata_threshold' do
+ let(:notes_metadata_threshold) { 1 }
- create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.project)
- control = ActiveRecord::QueryRecorder.new { send_request }
+ it_behaves_like 'N+1 queries'
- create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.project)
+ context 'when external_note_author_service_desk feature flag is disabled' do
+ let(:notes_metadata_threshold) { 0 }
- expect do
- send_request
- end.not_to exceed_query_limit(control)
+ before do
+ stub_feature_flags(external_note_author_service_desk: false)
+ end
+
+ it_behaves_like 'N+1 queries'
+ end
end
it 'limits Gitaly queries', :request_store do
@@ -59,7 +77,7 @@ RSpec.describe 'merge requests discussions', feature_category: :source_code_mana
.to change { Gitlab::GitalyClient.get_request_count }.by_at_most(4)
end
- context 'caching', :use_clean_rails_memory_store_caching do
+ context 'caching' do
let(:reference) { create(:issue, project: project) }
let(:author) { create(:user) }
let!(:first_note) { create(:diff_note_on_merge_request, author: author, noteable: merge_request, project: project, note: "reference: #{reference.to_reference}") }
@@ -81,193 +99,180 @@ RSpec.describe 'merge requests discussions', feature_category: :source_code_mana
shared_examples 'cache hit' do
it 'gets cached on subsequent requests' do
- expect_next_instance_of(DiscussionSerializer) do |serializer|
- expect(serializer).not_to receive(:represent)
- end
+ expect(DiscussionSerializer).not_to receive(:new)
send_request
end
end
- context 'when mr_discussions_http_cache and disabled_mr_discussions_redis_cache are enabled' do
- before do
- send_request
- end
+ before do
+ send_request
+ end
- it_behaves_like 'cache hit'
+ it_behaves_like 'cache hit'
- context 'when a note in a discussion got updated' do
- before do
- first_note.update!(updated_at: 1.minute.from_now)
- end
-
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ context 'when a note in a discussion got updated' do
+ before do
+ first_note.update!(updated_at: 1.minute.from_now)
end
- context 'when a note in a discussion got its reference state updated' do
- before do
- reference.close!
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
+ end
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ context 'when a note in a discussion got its reference state updated' do
+ before do
+ reference.close!
end
- context 'when a note in a discussion got resolved' do
- before do
- travel_to(1.minute.from_now) do
- first_note.resolve!(user)
- end
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
+ end
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
+ context 'when a note in a discussion got resolved' do
+ before do
+ travel_to(1.minute.from_now) do
+ first_note.resolve!(user)
end
end
- context 'when a note is added to a discussion' do
- let!(:third_note) { create(:diff_note_on_merge_request, in_reply_to: first_note, noteable: merge_request, project: project) }
-
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note, third_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
end
+ end
- context 'when a note is removed from a discussion' do
- before do
- second_note.destroy!
- end
+ context 'when a note is added to a discussion' do
+ let!(:third_note) { create(:diff_note_on_merge_request, in_reply_to: first_note, noteable: merge_request, project: project) }
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note, third_note] }
end
+ end
- context 'when an emoji is awarded to a note in discussion' do
- before do
- travel_to(1.minute.from_now) do
- create(:award_emoji, awardable: first_note)
- end
- end
+ context 'when a note is removed from a discussion' do
+ before do
+ second_note.destroy!
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note] }
end
+ end
- context 'when an award emoji is removed from a note in discussion' do
- before do
- travel_to(1.minute.from_now) do
- award_emoji.destroy!
- end
+ context 'when an emoji is awarded to a note in discussion' do
+ before do
+ travel_to(1.minute.from_now) do
+ create(:award_emoji, awardable: first_note)
end
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
end
+ end
- context 'when the diff note position changes' do
- before do
- # This replicates a position change wherein timestamps aren't updated
- # which is why `Gitlab::Timeless.timeless` is utilized. This is the
- # same approach being used in Discussions::UpdateDiffPositionService
- # which is responsible for updating the positions of diff discussions
- # when MR updates.
- first_note.position = Gitlab::Diff::Position.new(
- old_path: first_note.position.old_path,
- new_path: first_note.position.new_path,
- old_line: first_note.position.old_line,
- new_line: first_note.position.new_line + 1,
- diff_refs: first_note.position.diff_refs
- )
-
- Gitlab::Timeless.timeless(first_note, &:save)
+ context 'when an award emoji is removed from a note in discussion' do
+ before do
+ travel_to(1.minute.from_now) do
+ award_emoji.destroy!
end
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
end
+ end
- context 'when the HEAD diff note position changes' do
- before do
- # This replicates a DiffNotePosition change. This is the same approach
- # being used in Discussions::CaptureDiffNotePositionService which is
- # responsible for updating/creating DiffNotePosition of a diff discussions
- # in relation to HEAD diff.
- new_position = Gitlab::Diff::Position.new(
- old_path: first_note.position.old_path,
- new_path: first_note.position.new_path,
- old_line: first_note.position.old_line,
- new_line: first_note.position.new_line + 1,
- diff_refs: first_note.position.diff_refs
- )
-
- DiffNotePosition.create_or_update_for(
- first_note,
- diff_type: :head,
- position: new_position,
- line_code: 'bd4b7bfff3a247ccf6e3371c41ec018a55230bcc_534_521'
- )
- end
+ context 'when the diff note position changes' do
+ before do
+ # This replicates a position change wherein timestamps aren't updated
+ # which is why `save(touch: false)` is utilized. This is the same
+ # approach being used in Discussions::UpdateDiffPositionService which
+ # is responsible for updating the positions of diff discussions when
+ # MR updates.
+ first_note.position = Gitlab::Diff::Position.new(
+ old_path: first_note.position.old_path,
+ new_path: first_note.position.new_path,
+ old_line: first_note.position.old_line,
+ new_line: first_note.position.new_line + 1,
+ diff_refs: first_note.position.diff_refs
+ )
+
+ first_note.save!(touch: false)
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
end
+ end
- context 'when author detail changes' do
- before do
- author.update!(name: "#{author.name} (Updated)")
- end
+ context 'when the HEAD diff note position changes' do
+ before do
+ # This replicates a DiffNotePosition change. This is the same approach
+ # being used in Discussions::CaptureDiffNotePositionService which is
+ # responsible for updating/creating DiffNotePosition of a diff discussions
+ # in relation to HEAD diff.
+ new_position = Gitlab::Diff::Position.new(
+ old_path: first_note.position.old_path,
+ new_path: first_note.position.new_path,
+ old_line: first_note.position.old_line,
+ new_line: first_note.position.new_line + 1,
+ diff_refs: first_note.position.diff_refs
+ )
+
+ DiffNotePosition.create_or_update_for(
+ first_note,
+ diff_type: :head,
+ position: new_position,
+ line_code: 'bd4b7bfff3a247ccf6e3371c41ec018a55230bcc_534_521'
+ )
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
end
+ end
- context 'when author status changes' do
- before do
- Users::SetStatusService.new(author, message: "updated status").execute
- end
+ context 'when author detail changes' do
+ before do
+ author.update!(name: "#{author.name} (Updated)")
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
end
+ end
- context 'when author role changes' do
- before do
- Members::UpdateService.new(owner, access_level: Gitlab::Access::GUEST).execute(author_membership)
- end
+ context 'when author status changes' do
+ before do
+ Users::SetStatusService.new(author, message: "updated status").execute
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
end
+ end
- context 'when current_user role changes' do
- before do
- Members::UpdateService.new(owner, access_level: Gitlab::Access::GUEST).execute(project.member(user))
- end
+ context 'when author role changes' do
+ before do
+ Members::UpdateService.new(owner, access_level: Gitlab::Access::GUEST).execute(author_membership)
+ end
- it_behaves_like 'cache miss' do
- let(:changed_notes) { [first_note, second_note] }
- end
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
end
end
- context 'when disabled_mr_discussions_redis_cache is disabled' do
+ context 'when current_user role changes' do
before do
- stub_feature_flags(disabled_mr_discussions_redis_cache: false)
- send_request
+ Members::UpdateService.new(owner, access_level: Gitlab::Access::GUEST).execute(project.member(user))
end
- it_behaves_like 'cache hit'
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
+ end
end
end
end
diff --git a/spec/requests/projects/merge_requests_spec.rb b/spec/requests/projects/merge_requests_spec.rb
index 9600d1a3656..e57808e6728 100644
--- a/spec/requests/projects/merge_requests_spec.rb
+++ b/spec/requests/projects/merge_requests_spec.rb
@@ -6,10 +6,13 @@ RSpec.describe 'merge requests actions', feature_category: :source_code_manageme
let_it_be(:project) { create(:project, :repository) }
let(:merge_request) do
- create(:merge_request_with_diffs, target_project: project,
- source_project: project,
- assignees: [user],
- reviewers: [user2])
+ create(
+ :merge_request_with_diffs,
+ target_project: project,
+ source_project: project,
+ assignees: [user],
+ reviewers: [user2]
+ )
end
let(:user) { project.first_owner }
diff --git a/spec/requests/projects/metrics/dashboards/builder_spec.rb b/spec/requests/projects/metrics/dashboards/builder_spec.rb
index c929beaed70..8af2d1f1d25 100644
--- a/spec/requests/projects/metrics/dashboards/builder_spec.rb
+++ b/spec/requests/projects/metrics/dashboards/builder_spec.rb
@@ -49,6 +49,10 @@ RSpec.describe 'Projects::Metrics::Dashboards::BuilderController', feature_categ
end
describe 'POST /:namespace/:project/-/metrics/dashboards/builder' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
context 'as anonymous user' do
it 'redirects user to sign in page' do
send_request
@@ -102,6 +106,18 @@ RSpec.describe 'Projects::Metrics::Dashboards::BuilderController', feature_categ
expect(json_response['message']).to eq('Invalid configuration format')
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'returns not found' do
+ send_request
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
end
end
diff --git a/spec/requests/projects/metrics_dashboard_spec.rb b/spec/requests/projects/metrics_dashboard_spec.rb
index 01925f8345b..d0181275927 100644
--- a/spec/requests/projects/metrics_dashboard_spec.rb
+++ b/spec/requests/projects/metrics_dashboard_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe 'Projects::MetricsDashboardController', feature_category: :metric
before do
project.add_developer(user)
login_as(user)
+ stub_feature_flags(remove_monitor_metrics: false)
end
describe 'GET /:namespace/:project/-/metrics' do
@@ -37,6 +38,17 @@ RSpec.describe 'Projects::MetricsDashboardController', feature_category: :metric
expect(response).to redirect_to(dashboard_route(params.merge(environment: environment.id)))
end
+ context 'with remove_monitor_metrics returning true' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'renders 404 page' do
+ send_request
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'with anonymous user and public dashboard visibility' do
let(:anonymous_user) { create(:user) }
let(:project) do
diff --git a/spec/requests/projects/ml/candidates_controller_spec.rb b/spec/requests/projects/ml/candidates_controller_spec.rb
index d3f9d92bc44..78c8e99e3f3 100644
--- a/spec/requests/projects/ml/candidates_controller_spec.rb
+++ b/spec/requests/projects/ml/candidates_controller_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Projects::Ml::CandidatesController, feature_category: :mlops do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
let_it_be(:experiment) { create(:ml_experiments, project: project, user: user) }
- let_it_be(:candidate) { create(:ml_candidates, experiment: experiment, user: user) }
+ let_it_be(:candidate) { create(:ml_candidates, experiment: experiment, user: user, project: project) }
let(:ff_value) { true }
let(:candidate_iid) { candidate.iid }
@@ -18,19 +18,29 @@ RSpec.describe Projects::Ml::CandidatesController, feature_category: :mlops do
sign_in(user)
end
+ shared_examples 'renders 404' do
+ it 'renders 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ shared_examples '404 if candidate does not exist' do
+ context 'when experiment does not exist' do
+ let(:candidate_iid) { non_existing_record_id }
+
+ it_behaves_like 'renders 404'
+ end
+ end
+
shared_examples '404 if feature flag disabled' do
context 'when :ml_experiment_tracking disabled' do
let(:ff_value) { false }
- it 'is 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ it_behaves_like 'renders 404'
end
end
describe 'GET show' do
- let(:params) { basic_params.merge(id: experiment.iid) }
-
before do
show_candidate
end
@@ -48,20 +58,39 @@ RSpec.describe Projects::Ml::CandidatesController, feature_category: :mlops do
expect { show_candidate }.not_to exceed_all_query_limit(control_count)
end
- context 'when candidate does not exist' do
- let(:candidate_iid) { non_existing_record_id.to_s }
+ it_behaves_like '404 if candidate does not exist'
+ it_behaves_like '404 if feature flag disabled'
+ end
+
+ describe 'DELETE #destroy' do
+ let_it_be(:candidate_for_deletion) do
+ create(:ml_candidates, project: project, experiment: experiment, user: user)
+ end
+
+ let(:candidate_iid) { candidate_for_deletion.iid }
- it 'returns 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ before do
+ destroy_candidate
end
+ it 'deletes the experiment', :aggregate_failures do
+ expect(response).to have_gitlab_http_status(:found)
+ expect(flash[:notice]).to eq('Candidate removed')
+ expect(response).to redirect_to("/#{project.full_path}/-/ml/experiments/#{experiment.iid}")
+ expect { Ml::Candidate.find(id: candidate_for_deletion.id) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it_behaves_like '404 if candidate does not exist'
it_behaves_like '404 if feature flag disabled'
end
private
def show_candidate
- get project_ml_candidate_path(project, candidate_iid)
+ get project_ml_candidate_path(project, iid: candidate_iid)
+ end
+
+ def destroy_candidate
+ delete project_ml_candidate_path(project, candidate_iid)
end
end
diff --git a/spec/requests/projects/ml/experiments_controller_spec.rb b/spec/requests/projects/ml/experiments_controller_spec.rb
index 9b071efc1f1..5a8496a250a 100644
--- a/spec/requests/projects/ml/experiments_controller_spec.rb
+++ b/spec/requests/projects/ml/experiments_controller_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe Projects::Ml::ExperimentsController, feature_category: :mlops do
let(:ff_value) { true }
let(:project) { project_with_feature }
let(:basic_params) { { namespace_id: project.namespace.to_param, project_id: project } }
+ let(:experiment_iid) { experiment.iid }
before do
stub_feature_flags(ml_experiment_tracking: false)
@@ -27,13 +28,25 @@ RSpec.describe Projects::Ml::ExperimentsController, feature_category: :mlops do
sign_in(user)
end
+ shared_examples 'renders 404' do
+ it 'renders 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ shared_examples '404 if experiment does not exist' do
+ context 'when experiment does not exist' do
+ let(:experiment_iid) { non_existing_record_id }
+
+ it_behaves_like 'renders 404'
+ end
+ end
+
shared_examples '404 if feature flag disabled' do
context 'when :ml_experiment_tracking disabled' do
let(:ff_value) { false }
- it 'is 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ it_behaves_like 'renders 404'
end
end
@@ -109,119 +122,184 @@ RSpec.describe Projects::Ml::ExperimentsController, feature_category: :mlops do
end
describe 'GET show' do
- let(:params) { basic_params.merge(id: experiment.iid) }
+ describe 'html' do
+ it 'renders the template' do
+ show_experiment
+
+ expect(response).to render_template('projects/ml/experiments/show')
+ end
- it 'renders the template' do
- show_experiment
+ describe 'pagination' do
+ let_it_be(:candidates) do
+ create_list(:ml_candidates, 5, experiment: experiment).tap do |c|
+ c.first.metrics.create!(name: 'metric1', value: 0.3)
+ c[1].metrics.create!(name: 'metric1', value: 0.2)
+ c.last.metrics.create!(name: 'metric1', value: 0.6)
+ end
+ end
- expect(response).to render_template('projects/ml/experiments/show')
- end
+ let(:params) { basic_params.merge(id: experiment.iid) }
- describe 'pagination' do
- let_it_be(:candidates) do
- create_list(:ml_candidates, 5, experiment: experiment).tap do |c|
- c.first.metrics.create!(name: 'metric1', value: 0.3)
- c[1].metrics.create!(name: 'metric1', value: 0.2)
- c.last.metrics.create!(name: 'metric1', value: 0.6)
+ before do
+ stub_const("Projects::Ml::ExperimentsController::MAX_CANDIDATES_PER_PAGE", 2)
+
+ show_experiment
end
- end
- let(:params) { basic_params.merge(id: experiment.iid) }
+ it 'fetches only MAX_CANDIDATES_PER_PAGE candidates' do
+ expect(assigns(:candidates).size).to eq(2)
+ end
- before do
- stub_const("Projects::Ml::ExperimentsController::MAX_CANDIDATES_PER_PAGE", 2)
+ it 'paginates' do
+ received = assigns(:page_info)
- show_experiment
- end
+ expect(received).to include({
+ has_next_page: true,
+ has_previous_page: false,
+ start_cursor: nil
+ })
+ end
- it 'fetches only MAX_CANDIDATES_PER_PAGE candidates' do
- expect(assigns(:candidates).size).to eq(2)
- end
+ context 'when order by metric' do
+ let(:params) do
+ {
+ order_by: "metric1",
+ order_by_type: "metric",
+ sort: "desc"
+ }
+ end
+
+ it 'paginates', :aggregate_failures do
+ page = assigns(:candidates)
+
+ expect(page.first).to eq(candidates.last)
+ expect(page.last).to eq(candidates.first)
- it 'paginates' do
- received = assigns(:page_info)
+ new_params = params.merge(cursor: assigns(:page_info)[:end_cursor])
- expect(received).to include({
- has_next_page: true,
- has_previous_page: false,
- start_cursor: nil
- })
+ show_experiment(new_params: new_params)
+
+ new_page = assigns(:candidates)
+
+ expect(new_page.first).to eq(candidates[1])
+ end
+ end
end
- context 'when order by metric' do
+ describe 'search' do
let(:params) do
- {
- order_by: "metric1",
- order_by_type: "metric",
- sort: "desc"
- }
+ basic_params.merge(
+ name: 'some_name',
+ orderBy: 'name',
+ orderByType: 'metric',
+ sort: 'asc',
+ invalid: 'invalid'
+ )
end
- it 'paginates', :aggregate_failures do
- page = assigns(:candidates)
-
- expect(page.first).to eq(candidates.last)
- expect(page.last).to eq(candidates.first)
+ it 'formats and filters the parameters' do
+ expect(Projects::Ml::CandidateFinder).to receive(:new).and_call_original do |exp, params|
+ expect(params.to_h).to include({
+ name: 'some_name',
+ order_by: 'name',
+ order_by_type: 'metric',
+ sort: 'asc'
+ })
+ end
+
+ show_experiment
+ end
+ end
- new_params = params.merge(cursor: assigns(:page_info)[:end_cursor])
+ it 'does not perform N+1 sql queries' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { show_experiment }
- show_experiment(new_params)
+ create_list(:ml_candidates, 2, :with_metrics_and_params, experiment: experiment)
- new_page = assigns(:candidates)
+ expect { show_experiment }.not_to exceed_all_query_limit(control_count)
+ end
- expect(new_page.first).to eq(candidates[1])
+ describe '404' do
+ before do
+ show_experiment
end
+
+ it_behaves_like '404 if experiment does not exist'
+ it_behaves_like '404 if feature flag disabled'
end
end
- describe 'search' do
- let(:params) do
- basic_params.merge(
- id: experiment.iid,
- name: 'some_name',
- orderBy: 'name',
- orderByType: 'metric',
- sort: 'asc',
- invalid: 'invalid'
- )
- end
-
- it 'formats and filters the parameters' do
- expect(Projects::Ml::CandidateFinder).to receive(:new).and_call_original do |exp, params|
- expect(params.to_h).to include({
- name: 'some_name',
- order_by: 'name',
- order_by_type: 'metric',
- sort: 'asc'
- })
+ describe 'csv' do
+ it 'responds with :ok', :aggregate_failures do
+ show_experiment_csv
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Content-Type']).to eq('text/csv; charset=utf-8')
+ end
+
+ it 'calls the presenter' do
+ allow(::Ml::CandidatesCsvPresenter).to receive(:new).and_call_original
+
+ show_experiment_csv
+ end
+
+ it 'does not perform N+1 sql queries' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { show_experiment_csv }
+
+ create_list(:ml_candidates, 2, :with_metrics_and_params, experiment: experiment)
+
+ expect { show_experiment_csv }.not_to exceed_all_query_limit(control_count)
+ end
+
+ describe '404' do
+ before do
+ show_experiment_csv
end
- show_experiment
+ it_behaves_like '404 if experiment does not exist'
+ it_behaves_like '404 if feature flag disabled'
end
end
+ end
- it 'does not perform N+1 sql queries' do
- control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { show_experiment }
+ describe 'DELETE #destroy' do
+ let_it_be(:experiment_for_deletion) do
+ create(:ml_experiments, project: project_with_feature, user: user).tap do |e|
+ create(:ml_candidates, experiment: e, user: user)
+ end
+ end
+
+ let_it_be(:candidate_for_deletion) { experiment_for_deletion.candidates.first }
- create_list(:ml_candidates, 2, :with_metrics_and_params, experiment: experiment)
+ let(:params) { basic_params.merge(id: experiment.iid) }
- expect { show_experiment }.not_to exceed_all_query_limit(control_count)
+ before do
+ destroy_experiment
end
- it_behaves_like '404 if feature flag disabled' do
- before do
- show_experiment
- end
+ it 'deletes the experiment' do
+ expect { experiment.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
+
+ it_behaves_like '404 if experiment does not exist'
+ it_behaves_like '404 if feature flag disabled'
end
private
- def show_experiment(new_params = nil)
- get project_ml_experiment_path(project, experiment.iid), params: new_params || params
+ def show_experiment(new_params: nil, format: :html)
+ get project_ml_experiment_path(project, experiment_iid, format: format), params: new_params || params
+ end
+
+ def show_experiment_csv
+ show_experiment(format: :csv)
end
def list_experiments(new_params = nil)
get project_ml_experiments_path(project), params: new_params || params
end
+
+ def destroy_experiment
+ delete project_ml_experiment_path(project, experiment_iid), params: params
+ end
end
diff --git a/spec/requests/projects/pipelines_controller_spec.rb b/spec/requests/projects/pipelines_controller_spec.rb
index 73e002b63b1..7bdb66755db 100644
--- a/spec/requests/projects/pipelines_controller_spec.rb
+++ b/spec/requests/projects/pipelines_controller_spec.rb
@@ -23,18 +23,25 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
it 'does not execute N+1 queries' do
get_pipelines_index
- control_count = ActiveRecord::QueryRecorder.new do
+ create_pipelines
+
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
get_pipelines_index
end.count
- %w[pending running success failed canceled].each do |status|
- create(:ci_pipeline, project: project, status: status)
- end
+ create_pipelines
# There appears to be one extra query for Pipelines#has_warnings? for some reason
- expect { get_pipelines_index }.not_to exceed_query_limit(control_count + 1)
+ expect { get_pipelines_index }.not_to exceed_all_query_limit(control_count + 1)
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['pipelines'].count).to eq 6
+ expect(json_response['pipelines'].count).to eq(11)
+ end
+
+ def create_pipelines
+ %w[pending running success failed canceled].each do |status|
+ pipeline = create(:ci_pipeline, project: project, status: status)
+ create(:ci_build, :failed, pipeline: pipeline)
+ end
end
def get_pipelines_index
@@ -49,13 +56,21 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
it 'does not execute N+1 queries' do
request_build_stage
- control_count = ActiveRecord::QueryRecorder.new do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
request_build_stage
end.count
create(:ci_build, pipeline: pipeline, stage: 'build')
- expect { request_build_stage }.not_to exceed_query_limit(control_count)
+ 2.times do |i|
+ create(:ci_build,
+ name: "test retryable #{i}",
+ pipeline: pipeline,
+ stage: 'build',
+ status: :failed)
+ end
+
+ expect { request_build_stage }.not_to exceed_all_query_limit(control_count)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -66,13 +81,14 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte
request_build_stage(retried: true)
- control_count = ActiveRecord::QueryRecorder.new do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
request_build_stage(retried: true)
end.count
create(:ci_build, :retried, :failed, pipeline: pipeline, stage: 'build')
+ create(:ci_build, :failed, pipeline: pipeline, stage: 'build')
- expect { request_build_stage(retried: true) }.not_to exceed_query_limit(control_count)
+ expect { request_build_stage(retried: true) }.not_to exceed_all_query_limit(control_count)
expect(response).to have_gitlab_http_status(:ok)
end
diff --git a/spec/requests/projects/settings/access_tokens_controller_spec.rb b/spec/requests/projects/settings/access_tokens_controller_spec.rb
index defb35fd496..666dc42bcab 100644
--- a/spec/requests/projects/settings/access_tokens_controller_spec.rb
+++ b/spec/requests/projects/settings/access_tokens_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::Settings::AccessTokensController, feature_category: :authentication_and_authorization do
+RSpec.describe Projects::Settings::AccessTokensController, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:resource) { create(:project, group: group) }
diff --git a/spec/requests/projects/uploads_spec.rb b/spec/requests/projects/uploads_spec.rb
index aec2636b69c..a591f479763 100644
--- a/spec/requests/projects/uploads_spec.rb
+++ b/spec/requests/projects/uploads_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'File uploads', feature_category: :not_owned do
+RSpec.describe 'File uploads', feature_category: :shared do
include WorkhorseHelpers
let(:project) { create(:project, :public, :repository) }
diff --git a/spec/requests/projects/usage_quotas_spec.rb b/spec/requests/projects/usage_quotas_spec.rb
index 60ab64c30c3..33b206c8dc0 100644
--- a/spec/requests/projects/usage_quotas_spec.rb
+++ b/spec/requests/projects/usage_quotas_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Project Usage Quotas', feature_category: :subscription_cost_management do
+RSpec.describe 'Project Usage Quotas', feature_category: :consumables_cost_management do
let_it_be(:project) { create(:project) }
let_it_be(:role) { :maintainer }
let_it_be(:user) { create(:user) }
diff --git a/spec/requests/projects/wikis_controller_spec.rb b/spec/requests/projects/wikis_controller_spec.rb
new file mode 100644
index 00000000000..3c434b36b21
--- /dev/null
+++ b/spec/requests/projects/wikis_controller_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::WikisController, feature_category: :wiki do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
+ let_it_be(:project_wiki) { create(:project_wiki, project: project, user: user) }
+ let_it_be(:wiki_page) do
+ create(:wiki_page,
+ wiki: project_wiki,
+ title: 'home', content: "Look at this [image](#{path})\n\n ![alt text](#{path})")
+ end
+
+ let_it_be(:csp_nonce) { 'just=some=noncense' }
+
+ before do
+ sign_in(user)
+
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:content_security_policy_nonce).and_return(csp_nonce)
+ end
+ end
+
+ shared_examples 'embed.diagrams.net frame-src directive' do
+ it 'adds drawio frame-src directive to the Content Security Policy header' do
+ frame_src = response.headers['Content-Security-Policy'].split(';')
+ .map(&:strip)
+ .find { |entry| entry.starts_with?('frame-src') }
+
+ expect(frame_src).to include('https://embed.diagrams.net')
+ end
+ end
+
+ describe 'CSP policy' do
+ describe '#new' do
+ before do
+ get wiki_path(project_wiki, action: :new)
+ end
+
+ it_behaves_like 'embed.diagrams.net frame-src directive'
+ end
+
+ describe '#edit' do
+ before do
+ get wiki_page_path(project_wiki, wiki_page, action: 'edit')
+ end
+
+ it_behaves_like 'embed.diagrams.net frame-src directive'
+ end
+
+ describe '#create' do
+ before do
+ # Creating a page with an invalid title to render edit page
+ post wiki_path(project_wiki, action: 'create'), params: { wiki: { title: 'home' } }
+ end
+
+ it_behaves_like 'embed.diagrams.net frame-src directive'
+ end
+
+ describe '#update' do
+ before do
+ # Setting an invalid page title to render edit page
+ put wiki_page_path(project_wiki, wiki_page), params: { wiki: { title: '' } }
+ end
+
+ it_behaves_like 'embed.diagrams.net frame-src directive'
+ end
+ end
+end
diff --git a/spec/requests/projects/work_items_spec.rb b/spec/requests/projects/work_items_spec.rb
index 056416d380d..c02f76d2c65 100644
--- a/spec/requests/projects/work_items_spec.rb
+++ b/spec/requests/projects/work_items_spec.rb
@@ -3,22 +3,192 @@
require 'spec_helper'
RSpec.describe 'Work Items', feature_category: :team_planning do
+ include WorkhorseHelpers
+
+ include_context 'workhorse headers'
+
let_it_be(:work_item) { create(:work_item) }
- let_it_be(:developer) { create(:user) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ let(:file) { fixture_file_upload("spec/fixtures/#{filename}") }
before_all do
- work_item.project.add_developer(developer)
+ work_item.project.add_developer(current_user)
+ end
+
+ shared_examples 'response with 404 status' do
+ it 'returns 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ shared_examples 'safely handles uploaded files' do
+ it 'ensures the upload is handled safely', :aggregate_failures do
+ allow(Gitlab::Utils).to receive(:check_path_traversal!).and_call_original
+ expect(Gitlab::Utils).to receive(:check_path_traversal!).with(filename).at_least(:once)
+ expect(FileUploader).not_to receive(:cache)
+
+ subject
+ end
end
describe 'GET /:namespace/:project/work_items/:id' do
before do
- sign_in(developer)
+ sign_in(current_user)
end
it 'renders index' do
- get project_work_items_url(work_item.project, work_items_path: work_item.id)
+ get project_work_items_url(work_item.project, work_items_path: work_item.iid)
expect(response).to have_gitlab_http_status(:ok)
end
end
+
+ describe 'POST /:namespace/:project/work_items/import_csv' do
+ let(:filename) { 'work_items_valid_types.csv' }
+ let(:params) { { namespace_id: project.namespace.id, path: 'test' } }
+
+ subject { upload_file(file, workhorse_headers, params) }
+
+ shared_examples 'handles authorisation' do
+ context 'when unauthorized' do
+ context 'with non-member' do
+ let_it_be(:current_user) { create(:user) }
+
+ before do
+ sign_in(current_user)
+ end
+
+ it 'responds with error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'with anonymous user' do
+ it 'responds with error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to be_redirect
+ end
+ end
+ end
+
+ context 'when authorized' do
+ before do
+ sign_in(current_user)
+ project.add_reporter(current_user)
+ end
+
+ context 'when import/export work items feature is available and member is a reporter' do
+ shared_examples 'response with success status' do
+ it 'returns 200 status and success message' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response).to eq(
+ 'message' => "Your work items are being imported. Once finished, you'll receive a confirmation email.")
+ end
+ end
+
+ it_behaves_like 'response with success status'
+ it_behaves_like 'safely handles uploaded files'
+
+ it 'shows error when upload fails' do
+ expect_next_instance_of(UploadService) do |upload_service|
+ expect(upload_service).to receive(:execute).and_return(nil)
+ end
+
+ subject
+
+ expect(json_response).to eq('errors' => 'File upload error.')
+ end
+
+ context 'when file extension is not csv' do
+ let(:filename) { 'sample_doc.md' }
+
+ it 'returns error message' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq(
+ 'errors' => "The uploaded file was invalid. Supported file extensions are .csv.")
+ end
+ end
+ end
+
+ context 'when work items import/export feature is not available' do
+ before do
+ stub_feature_flags(import_export_work_items_csv: false)
+ end
+
+ it_behaves_like 'response with 404 status'
+ end
+ end
+ end
+
+ context 'with public project' do
+ let_it_be(:project) { create(:project, :public) }
+
+ it_behaves_like 'handles authorisation'
+ end
+
+ context 'with private project' do
+ it_behaves_like 'handles authorisation'
+ end
+
+ def upload_file(file, headers = {}, params = {})
+ workhorse_finalize(
+ import_csv_project_work_items_path(project),
+ method: :post,
+ file_key: :file,
+ params: params.merge(file: file),
+ headers: headers,
+ send_rewritten_field: true
+ )
+ end
+ end
+
+ describe 'POST #authorize' do
+ subject do
+ post import_csv_authorize_project_work_items_path(project),
+ headers: workhorse_headers
+ end
+
+ before do
+ sign_in(current_user)
+ end
+
+ context 'with authorized user' do
+ before do
+ project.add_reporter(current_user)
+ end
+
+ context 'when work items import/export feature is enabled' do
+ let(:user) { current_user }
+
+ it_behaves_like 'handle uploads authorize request' do
+ let(:uploader_class) { FileUploader }
+ let(:maximum_size) { Gitlab::CurrentSettings.max_attachment_size.megabytes }
+ end
+ end
+
+ context 'when work items import/export feature is disabled' do
+ before do
+ stub_feature_flags(import_export_work_items_csv: false)
+ end
+
+ it_behaves_like 'response with 404 status'
+ end
+ end
+
+ context 'with unauthorized user' do
+ 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 91595f7826a..0dd8a15c3a4 100644
--- a/spec/requests/rack_attack_global_spec.rb
+++ b/spec/requests/rack_attack_global_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_caching,
-feature_category: :authentication_and_authorization do
+feature_category: :system_access do
include RackAttackSpecHelpers
include SessionHelpers
diff --git a/spec/requests/registrations_controller_spec.rb b/spec/requests/registrations_controller_spec.rb
new file mode 100644
index 00000000000..8b857046a4d
--- /dev/null
+++ b/spec/requests/registrations_controller_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RegistrationsController, type: :request, feature_category: :system_access do
+ describe 'POST #create' do
+ let_it_be(:user_attrs) { build_stubbed(:user).slice(:first_name, :last_name, :username, :email, :password) }
+
+ subject(:create_user) { post user_registration_path, params: { user: user_attrs } }
+
+ context 'when email confirmation is required' do
+ before do
+ stub_application_setting_enum('email_confirmation_setting', 'hard')
+ stub_application_setting(require_admin_approval_after_user_signup: false)
+ end
+
+ it 'redirects to the `users_almost_there_path`', unless: Gitlab.ee? do
+ create_user
+
+ expect(response).to redirect_to(users_almost_there_path(email: user_attrs[:email]))
+ end
+ end
+ end
+end
diff --git a/spec/requests/sandbox_controller_spec.rb b/spec/requests/sandbox_controller_spec.rb
index 77913065380..26a7422680c 100644
--- a/spec/requests/sandbox_controller_spec.rb
+++ b/spec/requests/sandbox_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SandboxController, feature_category: :not_owned do
+RSpec.describe SandboxController, feature_category: :shared do
describe 'GET #mermaid' do
it 'renders page without template' do
get sandbox_mermaid_path
diff --git a/spec/requests/search_controller_spec.rb b/spec/requests/search_controller_spec.rb
index 98dda75a2b0..f2d4e288ddc 100644
--- a/spec/requests/search_controller_spec.rb
+++ b/spec/requests/search_controller_spec.rb
@@ -66,13 +66,9 @@ RSpec.describe SearchController, type: :request, feature_category: :global_searc
let(:creation_args) { { name: 'project' } }
let(:params) { { search: 'project', scope: 'projects' } }
# some N+1 queries still exist
- # each project requires 3 extra queries
- # - one count for forks
- # - one count for open MRs
- # - one count for open Issues
- # there are 4 additional queries run for the logged in user:
- # (1) user preferences, (1) user statuses, (1) user details, (1) users
- let(:threshold) { 17 }
+ # 1 for users
+ # 1 for root ancestor for each project
+ let(:threshold) { 7 }
it_behaves_like 'an efficient database result'
end
diff --git a/spec/requests/self_monitoring_project_spec.rb b/spec/requests/self_monitoring_project_spec.rb
deleted file mode 100644
index ce4dd10a52d..00000000000
--- a/spec/requests/self_monitoring_project_spec.rb
+++ /dev/null
@@ -1,213 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Self-Monitoring project requests', feature_category: :projects do
- let(:admin) { create(:admin) }
-
- describe 'POST #create_self_monitoring_project' do
- let(:worker_class) { SelfMonitoringProjectCreateWorker }
-
- subject { post create_self_monitoring_project_admin_application_settings_path }
-
- it_behaves_like 'not accessible to non-admin users'
-
- context 'with admin user', :enable_admin_mode do
- before do
- login_as(admin)
- end
-
- context 'when the self-monitoring project is created' do
- let(:status_api) { status_create_self_monitoring_project_admin_application_settings_path }
-
- it_behaves_like 'triggers async worker, returns sidekiq job_id with response accepted'
- end
- end
- end
-
- describe 'GET #status_create_self_monitoring_project' do
- let(:worker_class) { SelfMonitoringProjectCreateWorker }
- let(:job_id) { 'job_id' }
-
- subject do
- get status_create_self_monitoring_project_admin_application_settings_path,
- params: { job_id: job_id }
- end
-
- it_behaves_like 'not accessible to non-admin users'
-
- context 'with admin user', :enable_admin_mode do
- before do
- login_as(admin)
- end
-
- context 'when the self-monitoring project is being created' do
- it_behaves_like 'handles invalid job_id'
-
- context 'when job is in progress' do
- before do
- allow(worker_class).to receive(:in_progress?)
- .with(job_id)
- .and_return(true)
- end
-
- it_behaves_like 'sets polling header and returns accepted' do
- let(:in_progress_message) { 'Job to create self-monitoring project is in progress' }
- end
- end
-
- context 'when self-monitoring project and job do not exist' do
- let(:job_id) { nil }
-
- it 'returns bad_request' do
- create(:application_setting)
-
- subject
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response).to eq(
- 'message' => 'Self-monitoring project does not exist. Please check logs ' \
- 'for any error messages'
- )
- end
- end
- end
-
- context 'when self-monitoring project exists' do
- let(:project) { create(:project) }
-
- before do
- create(:application_setting, self_monitoring_project_id: project.id)
- end
-
- it 'does not need job_id' do
- get status_create_self_monitoring_project_admin_application_settings_path
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:success)
- expect(json_response).to eq(
- 'project_id' => project.id,
- 'project_full_path' => project.full_path
- )
- end
- end
-
- it 'returns success with job_id' do
- subject
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:success)
- expect(json_response).to eq(
- 'project_id' => project.id,
- 'project_full_path' => project.full_path
- )
- end
- end
- end
- end
- end
- end
-
- describe 'DELETE #delete_self_monitoring_project' do
- let(:worker_class) { SelfMonitoringProjectDeleteWorker }
-
- subject { delete delete_self_monitoring_project_admin_application_settings_path }
-
- it_behaves_like 'not accessible to non-admin users'
-
- context 'with admin user', :enable_admin_mode do
- before do
- login_as(admin)
- end
-
- context 'when the self-monitoring project is deleted' do
- let(:status_api) { status_delete_self_monitoring_project_admin_application_settings_path }
-
- it_behaves_like 'triggers async worker, returns sidekiq job_id with response accepted'
- end
- end
- end
-
- describe 'GET #status_delete_self_monitoring_project' do
- let(:worker_class) { SelfMonitoringProjectDeleteWorker }
- let(:job_id) { 'job_id' }
-
- subject do
- get status_delete_self_monitoring_project_admin_application_settings_path,
- params: { job_id: job_id }
- end
-
- it_behaves_like 'not accessible to non-admin users'
-
- context 'with admin user', :enable_admin_mode do
- before do
- login_as(admin)
- end
-
- context 'when the self-monitoring project is being deleted' do
- it_behaves_like 'handles invalid job_id'
-
- context 'when job is in progress' do
- before do
- allow(worker_class).to receive(:in_progress?)
- .with(job_id)
- .and_return(true)
-
- stub_application_setting(self_monitoring_project_id: 1)
- end
-
- it_behaves_like 'sets polling header and returns accepted' do
- let(:in_progress_message) { 'Job to delete self-monitoring project is in progress' }
- end
- end
-
- context 'when self-monitoring project exists and job does not exist' do
- before do
- create(:application_setting, self_monitoring_project_id: create(:project).id)
- end
-
- it 'returns bad_request' do
- subject
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response).to eq(
- 'message' => 'Self-monitoring project was not deleted. Please check logs ' \
- 'for any error messages'
- )
- end
- end
- end
-
- context 'when self-monitoring project does not exist' do
- before do
- create(:application_setting)
- end
-
- it 'does not need job_id' do
- get status_delete_self_monitoring_project_admin_application_settings_path
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:success)
- expect(json_response).to eq(
- 'message' => 'Self-monitoring project has been successfully deleted'
- )
- end
- end
-
- it 'returns success with job_id' do
- subject
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:success)
- expect(json_response).to eq(
- 'message' => 'Self-monitoring project has been successfully deleted'
- )
- end
- end
- end
- end
- end
- end
-end
diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb
index 7b3fd23980a..3bff9555834 100644
--- a/spec/requests/sessions_spec.rb
+++ b/spec/requests/sessions_spec.rb
@@ -2,7 +2,9 @@
require 'spec_helper'
-RSpec.describe 'Sessions', feature_category: :authentication_and_authorization do
+RSpec.describe 'Sessions', feature_category: :system_access do
+ include SessionHelpers
+
context 'authentication', :allow_forgery_protection do
let(:user) { create(:user) }
@@ -14,4 +16,48 @@ RSpec.describe 'Sessions', feature_category: :authentication_and_authorization d
expect(response).to redirect_to(new_user_session_path)
end
end
+
+ describe 'about_gitlab_active_user' do
+ before do
+ allow(::Gitlab).to receive(:com?).and_return(true)
+ end
+
+ let(:user) { create(:user) }
+
+ context 'when user signs in' do
+ it 'sets marketing cookie' do
+ post user_session_path(user: { login: user.username, password: user.password })
+ expect(response.cookies['about_gitlab_active_user']).to be_present
+ end
+ end
+
+ context 'when user uses remember_me' do
+ it 'sets marketing cookie' do
+ post user_session_path(user: { login: user.username, password: user.password, remember_me: true })
+ expect(response.cookies['about_gitlab_active_user']).to be_present
+ end
+ end
+
+ context 'when user signs out' do
+ before do
+ post user_session_path(user: { login: user.username, password: user.password })
+ end
+
+ it 'deletes marketing cookie' do
+ post(destroy_user_session_path)
+ expect(response.cookies['about_gitlab_active_user']).to be_nil
+ end
+ end
+
+ context 'when user is not using GitLab SaaS' do
+ before do
+ allow(::Gitlab).to receive(:com?).and_return(false)
+ end
+
+ it 'does not set marketing cookie' do
+ post user_session_path(user: { login: user.username, password: user.password })
+ expect(response.cookies['about_gitlab_active_user']).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/requests/time_tracking/timelogs_controller_spec.rb b/spec/requests/time_tracking/timelogs_controller_spec.rb
new file mode 100644
index 00000000000..68eecf9b137
--- /dev/null
+++ b/spec/requests/time_tracking/timelogs_controller_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe TimeTracking::TimelogsController, feature_category: :team_planning do
+ let_it_be(:user) { create(:user) }
+
+ describe 'GET #index' do
+ subject { get timelogs_path }
+
+ context 'when user is not logged in' do
+ it 'responds with a redirect to the login page' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:redirect)
+ end
+ end
+
+ context 'when user is logged in' do
+ before do
+ sign_in(user)
+ end
+
+ context 'when global_time_tracking_report FF is enabled' do
+ it 'responds with the global time tracking page', :aggregate_failures do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:index)
+ end
+ end
+
+ context 'when global_time_tracking_report FF is disable' do
+ before do
+ stub_feature_flags(global_time_tracking_report: false)
+ end
+
+ it 'returns a 404 page' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/users/pins_spec.rb b/spec/requests/users/pins_spec.rb
new file mode 100644
index 00000000000..9a32d7e9d76
--- /dev/null
+++ b/spec/requests/users/pins_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Pinning navigation menu items', feature_category: :navigation do
+ let(:user) { create(:user) }
+ let(:menu_item_ids) { %w[item4 item7] }
+ let(:other_panel_data) { { 'group' => ['some_item_id'] } }
+
+ before do
+ user.update!(pinned_nav_items: other_panel_data)
+ sign_in(user)
+ end
+
+ describe 'PUT /-/users/pins' do
+ before do
+ put pins_path, params: params, headers: { 'ACCEPT' => 'application/json' }
+ end
+
+ context 'with valid params' do
+ let(:panel) { 'project' }
+ let(:params) { { menu_item_ids: menu_item_ids, panel: panel } }
+
+ it 'saves the menu_item_ids for the correct panel' do
+ expect(user.pinned_nav_items).to include(panel => menu_item_ids)
+ end
+
+ it 'does not change menu_item_ids of other panels' do
+ expect(user.pinned_nav_items).to include(other_panel_data)
+ end
+
+ it 'responds OK' do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'with invalid params' do
+ shared_examples 'unchanged data and error response' do
+ it 'does not modify existing panel data' do
+ expect(user.reload.pinned_nav_items).to eq(other_panel_data)
+ end
+
+ it 'responds with error' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'when panel name is unknown' do
+ let(:params) { { menu_item_ids: menu_item_ids, panel: 'something_else' } }
+
+ it_behaves_like 'unchanged data and error response'
+ end
+
+ context 'when menu_item_ids is not array of strings' do
+ let(:params) { { menu_item_ids: 'not_an_array', panel: 'project' } }
+
+ it_behaves_like 'unchanged data and error response'
+ end
+
+ context 'when params are not permitted' do
+ let(:params) { { random_param: 'random_value' } }
+
+ it_behaves_like 'unchanged data and error response'
+ end
+ end
+ end
+end
diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb
index 11d8be24e06..c49dbb6a269 100644
--- a/spec/requests/users_controller_spec.rb
+++ b/spec/requests/users_controller_spec.rb
@@ -174,39 +174,95 @@ RSpec.describe UsersController, feature_category: :user_management do
end
context 'requested in json format' do
- let(:project) { create(:project) }
+ context 'when profile_tabs_vue feature flag is turned OFF' do
+ let(:project) { create(:project) }
- before do
- project.add_developer(user)
- Gitlab::DataBuilder::Push.build_sample(project, user)
+ before do
+ project.add_developer(user)
+ Gitlab::DataBuilder::Push.build_sample(project, user)
+ stub_feature_flags(profile_tabs_vue: false)
+ sign_in(user)
+ end
- sign_in(user)
- end
+ it 'loads events' do
+ get user_activity_url user.username, format: :json
- it 'loads events' do
- get user_activity_url user.username, format: :json
+ expect(response.media_type).to eq('application/json')
+ expect(Gitlab::Json.parse(response.body)['count']).to eq(1)
+ end
- expect(response.media_type).to eq('application/json')
- expect(Gitlab::Json.parse(response.body)['count']).to eq(1)
- end
+ it 'hides events if the user cannot read cross project' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
- it 'hides events if the user cannot read cross project' do
- allow(Ability).to receive(:allowed?).and_call_original
- expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+ get user_activity_url user.username, format: :json
- get user_activity_url user.username, format: :json
+ expect(response.media_type).to eq('application/json')
+ expect(Gitlab::Json.parse(response.body)['count']).to eq(0)
+ end
- expect(response.media_type).to eq('application/json')
- expect(Gitlab::Json.parse(response.body)['count']).to eq(0)
+ it 'hides events if the user has a private profile' do
+ Gitlab::DataBuilder::Push.build_sample(project, private_user)
+
+ get user_activity_url private_user.username, format: :json
+
+ expect(response.media_type).to eq('application/json')
+ expect(Gitlab::Json.parse(response.body)['count']).to eq(0)
+ end
end
- it 'hides events if the user has a private profile' do
- Gitlab::DataBuilder::Push.build_sample(project, private_user)
+ context 'when profile_tabs_vue feature flag is turned ON' do
+ let(:project) { create(:project) }
+
+ before do
+ project.add_developer(user)
+ Gitlab::DataBuilder::Push.build_sample(project, user)
+ stub_feature_flags(profile_tabs_vue: true)
+ sign_in(user)
+ end
+
+ it 'loads events' do
+ get user_activity_url user.username, format: :json
- get user_activity_url private_user.username, format: :json
+ expect(response.media_type).to eq('application/json')
+ expect(Gitlab::Json.parse(response.body).count).to eq(1)
+ end
- expect(response.media_type).to eq('application/json')
- expect(Gitlab::Json.parse(response.body)['count']).to eq(0)
+ it 'hides events if the user cannot read cross project' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+
+ get user_activity_url user.username, format: :json
+
+ expect(response.media_type).to eq('application/json')
+ expect(Gitlab::Json.parse(response.body).count).to eq(0)
+ end
+
+ it 'hides events if the user has a private profile' do
+ Gitlab::DataBuilder::Push.build_sample(project, private_user)
+
+ get user_activity_url private_user.username, format: :json
+
+ expect(response.media_type).to eq('application/json')
+ expect(Gitlab::Json.parse(response.body).count).to eq(0)
+ end
+
+ it 'hides events if the user has a private profile' do
+ project = create(:project, :private)
+ private_event_user = create(:user, include_private_contributions: true)
+ push_data = Gitlab::DataBuilder::Push.build_sample(project, private_event_user)
+ EventCreateService.new.push(project, private_event_user, push_data)
+
+ get user_activity_url private_event_user.username, format: :json
+
+ response_body = Gitlab::Json.parse(response.body)
+ event = response_body.first
+ expect(response.media_type).to eq('application/json')
+ expect(response_body.count).to eq(1)
+ expect(event).to include('created_at', 'author', 'action')
+ expect(event['action']).to eq('private')
+ expect(event).not_to include('ref', 'commit', 'target', 'resource_parent')
+ end
end
end
end
@@ -472,7 +528,7 @@ RSpec.describe UsersController, feature_category: :user_management do
get user_calendar_activities_url public_user.username
- expect(response.body).to include(project_work_items_path(project, work_item.iid, iid_path: true))
+ expect(response.body).to include(project_work_items_path(project, work_item.iid))
expect(response.body).to include(project_issue_path(project, issue))
end
@@ -714,6 +770,17 @@ RSpec.describe UsersController, feature_category: :user_management do
expect(response.body).to eq(expected_json)
end
end
+
+ context 'when a project has the same name as a desired username' do
+ let_it_be(:project) { create(:project, name: 'project-name') }
+
+ it 'returns JSON indicating a user by that username does not exist' do
+ get user_exists_url 'project-name'
+
+ expected_json = { exists: false }.to_json
+ expect(response.body).to eq(expected_json)
+ end
+ end
end
context 'when the rate limit has been reached' do
@@ -858,6 +925,35 @@ RSpec.describe UsersController, feature_category: :user_management do
expect(user).not_to be_following(public_user)
end
end
+
+ context 'when user or followee disabled following' do
+ before do
+ sign_in(user)
+ end
+
+ it 'alerts and not follow if user disabled following' do
+ user.enabled_following = false
+
+ post user_follow_url(username: public_user.username)
+ expect(response).to be_redirect
+
+ expected_message = format(_('Action not allowed.'))
+ expect(flash[:alert]).to eq(expected_message)
+ expect(user).not_to be_following(public_user)
+ end
+
+ it 'alerts and not follow if followee disabled following' do
+ public_user.enabled_following = false
+ public_user.save!
+
+ post user_follow_url(username: public_user.username)
+ expect(response).to be_redirect
+
+ expected_message = format(_('Action not allowed.'))
+ expect(flash[:alert]).to eq(expected_message)
+ expect(user).not_to be_following(public_user)
+ end
+ end
end
context 'token authentication' do
diff --git a/spec/requests/verifies_with_email_spec.rb b/spec/requests/verifies_with_email_spec.rb
index 8a6a7e717ff..6325ecc1184 100644
--- a/spec/requests/verifies_with_email_spec.rb
+++ b/spec/requests/verifies_with_email_spec.rb
@@ -42,7 +42,7 @@ feature_category: :user_management do
shared_examples_for 'two factor prompt or successful login' do
it 'shows the 2FA prompt when enabled or redirects to the root path' do
if user.two_factor_enabled?
- expect(response.body).to include('Two-factor authentication code')
+ expect(response.body).to include('Enter verification code')
else
expect(response).to redirect_to(root_path)
end
@@ -135,7 +135,7 @@ feature_category: :user_management do
describe 'verify_with_email' do
context 'when user is locked and a verification_user_id session variable exists' do
before do
- encrypted_token = Devise.token_generator.digest(User, :unlock_token, 'token')
+ encrypted_token = Devise.token_generator.digest(User, user.email, 'token')
user.update!(locked_at: Time.current, unlock_token: encrypted_token)
stub_session(verification_user_id: user.id)
end
diff --git a/spec/requests/web_ide/remote_ide_controller_spec.rb b/spec/requests/web_ide/remote_ide_controller_spec.rb
index 367c7527f10..9e9d3dfc703 100644
--- a/spec/requests/web_ide/remote_ide_controller_spec.rb
+++ b/spec/requests/web_ide/remote_ide_controller_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe WebIde::RemoteIdeController, feature_category: :remote_developmen
end
it "updates the content security policy with the correct frame sources" do
- expect(find_csp_source('frame-src')).to include("https://*.vscode-cdn.net/")
+ expect(find_csp_source('frame-src')).to include("http://www.example.com/assets/webpack/", "https://*.vscode-cdn.net/")
end
end
diff --git a/spec/routing/directs/subscription_portal_spec.rb b/spec/routing/directs/subscription_portal_spec.rb
new file mode 100644
index 00000000000..768990fae62
--- /dev/null
+++ b/spec/routing/directs/subscription_portal_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Custom URLs', 'Subscription Portal', feature_category: :subscription_management do
+ using RSpec::Parameterized::TableSyntax
+ include SubscriptionPortalHelper
+
+ let(:env_value) { nil }
+ let(:staging_env_value) { nil }
+
+ before do
+ stub_env('CUSTOMER_PORTAL_URL', env_value)
+ stub_env('STAGING_CUSTOMER_PORTAL_URL', staging_env_value)
+ end
+
+ describe 'subscription_portal_staging_url' do
+ subject { subscription_portal_staging_url }
+
+ context 'when STAGING_CUSTOMER_PORTAL_URL is unset' do
+ it { is_expected.to eq(staging_customers_url) }
+ end
+
+ context 'when STAGING_CUSTOMER_PORTAL_URL is set' do
+ let(:staging_env_value) { 'https://customers.staging.example.com' }
+
+ it { is_expected.to eq(staging_env_value) }
+ end
+ end
+
+ describe 'subscription_portal_url' do
+ subject { subscription_portal_url }
+
+ context 'when CUSTOMER_PORTAL_URL ENV is unset' do
+ where(:test, :development, :expected_url) do
+ false | false | prod_customers_url
+ false | true | subscription_portal_staging_url
+ true | false | subscription_portal_staging_url
+ end
+
+ before do
+ allow(Rails).to receive_message_chain(:env, :test?).and_return(test)
+ allow(Rails).to receive_message_chain(:env, :development?).and_return(development)
+ end
+
+ with_them do
+ it { is_expected.to eq(expected_url) }
+ end
+ end
+
+ context 'when CUSTOMER_PORTAL_URL ENV is set' do
+ let(:env_value) { 'https://customers.example.com' }
+
+ it { is_expected.to eq(env_value) }
+ end
+ end
+
+ describe 'subscription_portal_instance_review_url' do
+ subject { subscription_portal_instance_review_url }
+
+ it { is_expected.to eq("#{staging_customers_url}/instance_review") }
+ end
+end
diff --git a/spec/routing/import_routing_spec.rb b/spec/routing/import_routing_spec.rb
index ac3f2a4b7ca..8254d79e994 100644
--- a/spec/routing/import_routing_spec.rb
+++ b/spec/routing/import_routing_spec.rb
@@ -62,7 +62,7 @@ end
# realtime_changes_import_github GET /import/github/realtime_changes(.:format) import/github#jobs
# import_github POST /import/github(.:format) import/github#create
# new_import_github GET /import/github/new(.:format) import/github#new
-RSpec.describe Import::GithubController, 'routing' do
+RSpec.describe Import::GithubController, 'routing', feature_category: :importers do
it_behaves_like 'importer routing' do
let(:provider) { 'github' }
let(:is_realtime) { true }
@@ -75,6 +75,10 @@ RSpec.describe Import::GithubController, 'routing' do
it 'to #cancel_all' do
expect(post('/import/github/cancel_all')).to route_to('import/github#cancel_all')
end
+
+ it 'to #counts' do
+ expect(get('/import/github/counts')).to route_to('import/github#counts')
+ end
end
# personal_access_token_import_gitea POST /import/gitea/personal_access_token(.:format) import/gitea#personal_access_token
@@ -82,7 +86,7 @@ end
# realtime_changes_import_gitea GET /import/gitea/realtime_changes(.:format) import/gitea#jobs
# import_gitea POST /import/gitea(.:format) import/gitea#create
# new_import_gitea GET /import/gitea/new(.:format) import/gitea#new
-RSpec.describe Import::GiteaController, 'routing' do
+RSpec.describe Import::GiteaController, 'routing', feature_category: :importers do
it_behaves_like 'importer routing' do
let(:except_actions) { [:callback] }
let(:provider) { 'gitea' }
@@ -94,23 +98,11 @@ RSpec.describe Import::GiteaController, 'routing' do
end
end
-# status_import_gitlab GET /import/gitlab/status(.:format) import/gitlab#status
-# callback_import_gitlab GET /import/gitlab/callback(.:format) import/gitlab#callback
-# realtime_changes_import_gitlab GET /import/gitlab/realtime_changes(.:format) import/gitlab#realtime_changes
-# import_gitlab POST /import/gitlab(.:format) import/gitlab#create
-RSpec.describe Import::GitlabController, 'routing' do
- it_behaves_like 'importer routing' do
- let(:except_actions) { [:new] }
- let(:provider) { 'gitlab' }
- let(:is_realtime) { true }
- end
-end
-
# status_import_bitbucket GET /import/bitbucket/status(.:format) import/bitbucket#status
# callback_import_bitbucket GET /import/bitbucket/callback(.:format) import/bitbucket#callback
# realtime_changes_import_bitbucket GET /import/bitbucket/realtime_changes(.:format) import/bitbucket#realtime_changes
# import_bitbucket POST /import/bitbucket(.:format) import/bitbucket#create
-RSpec.describe Import::BitbucketController, 'routing' do
+RSpec.describe Import::BitbucketController, 'routing', feature_category: :importers do
it_behaves_like 'importer routing' do
let(:except_actions) { [:new] }
let(:provider) { 'bitbucket' }
@@ -123,7 +115,7 @@ end
# realtime_changes_import_bitbucket_server GET /import/bitbucket_server/realtime_changes(.:format) import/bitbucket_server#realtime_changes
# new_import_bitbucket_server GET /import/bitbucket_server/new(.:format) import/bitbucket_server#new
# import_bitbucket_server POST /import/bitbucket_server(.:format) import/bitbucket_server#create
-RSpec.describe Import::BitbucketServerController, 'routing' do
+RSpec.describe Import::BitbucketServerController, 'routing', feature_category: :importers do
it_behaves_like 'importer routing' do
let(:provider) { 'bitbucket_server' }
let(:is_realtime) { true }
@@ -137,7 +129,7 @@ end
# create_user_map_import_fogbugz POST /import/fogbugz/user_map(.:format) import/fogbugz#create_user_map
# import_fogbugz POST /import/fogbugz(.:format) import/fogbugz#create
# new_import_fogbugz GET /import/fogbugz/new(.:format) import/fogbugz#new
-RSpec.describe Import::FogbugzController, 'routing' do
+RSpec.describe Import::FogbugzController, 'routing', feature_category: :importers do
it_behaves_like 'importer routing' do
let(:except_actions) { [:callback] }
let(:provider) { 'fogbugz' }
@@ -160,7 +152,7 @@ end
# import_gitlab_project POST /import/gitlab_project(.:format) import/gitlab_projects#create
# POST /import/gitlab_project(.:format) import/gitlab_projects#create
# new_import_gitlab_project GET /import/gitlab_project/new(.:format) import/gitlab_projects#new
-RSpec.describe Import::GitlabProjectsController, 'routing' do
+RSpec.describe Import::GitlabProjectsController, 'routing', feature_category: :importers do
it 'to #create' do
expect(post('/import/gitlab_project')).to route_to('import/gitlab_projects#create')
end
@@ -170,20 +162,8 @@ RSpec.describe Import::GitlabProjectsController, 'routing' do
end
end
-# new_import_phabricator GET /import/phabricator/new(.:format) import/phabricator#new
-# import_phabricator POST /import/phabricator(.:format) import/phabricator#create
-RSpec.describe Import::PhabricatorController, 'routing' do
- it 'to #create' do
- expect(post("/import/phabricator")).to route_to("import/phabricator#create")
- end
-
- it 'to #new' do
- expect(get("/import/phabricator/new")).to route_to("import/phabricator#new")
- end
-end
-
# status_import_github_group GET /import/github_group/status(.:format) import/github_groups#status
-RSpec.describe Import::GithubGroupsController, 'routing' do
+RSpec.describe Import::GithubGroupsController, 'routing', feature_category: :importers do
it 'to #status' do
expect(get('/import/github_group/status')).to route_to('import/github_groups#status')
end
diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb
index 664fc7dde7a..aebb68ec822 100644
--- a/spec/routing/project_routing_spec.rb
+++ b/spec/routing/project_routing_spec.rb
@@ -107,9 +107,6 @@ RSpec.describe 'project routing' do
it_behaves_like 'wiki routing' do
let(:base_path) { '/gitlab/gitlabhq/-/wikis' }
end
-
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/wikis", "/gitlab/gitlabhq/-/wikis"
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/wikis/home/edit", "/gitlab/gitlabhq/-/wikis/home/edit"
end
# branches_project_repository GET /:project_id/repository/branches(.:format) projects/repositories#branches
@@ -128,6 +125,18 @@ RSpec.describe 'project routing' do
it 'to #archive with "/" in route' do
expect(get('/gitlab/gitlabhq/-/archive/improve/awesome/gitlabhq-improve-awesome.tar.gz')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.gz', id: 'improve/awesome/gitlabhq-improve-awesome')
end
+
+ it 'to #archive format:html' do
+ expect(get('/gitlab/gitlabhq/-/archive/master.html')).to route_to_route_not_found
+ end
+
+ it 'to #archive format:yaml' do
+ expect(get('/gitlab/gitlabhq/-/archive/master.yaml')).to route_to_route_not_found
+ end
+
+ it 'to #archive format:yml' do
+ expect(get('/gitlab/gitlabhq/-/archive/master.yml')).to route_to_route_not_found
+ end
end
describe Projects::BranchesController, 'routing' do
@@ -152,8 +161,6 @@ RSpec.describe 'project routing' do
expect(delete('/gitlab/gitlabhq/-/tags/feature%2B45/foo/bar/baz')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature+45/foo/bar/baz')
expect(delete('/gitlab/gitlabhq/-/tags/feature@45/foo/bar/baz')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature@45/foo/bar/baz')
end
-
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/tags", "/gitlab/gitlabhq/-/tags"
end
# project_deploy_keys GET /:project_id/deploy_keys(.:format) deploy_keys#index
@@ -205,20 +212,6 @@ RSpec.describe 'project routing' do
namespace_id: 'gitlab', project_id: 'gitlabhq',
id: "stable", path: "new\n\nline.txt" })
end
-
- it_behaves_like 'redirecting a legacy path', '/gitlab/gitlabhq/refs/switch', '/gitlab/gitlabhq/-/refs/switch'
-
- it_behaves_like 'redirecting a legacy path',
- '/gitlab/gitlabhq/refs/feature%2345/logs_tree',
- '/gitlab/gitlabhq/-/refs/feature%2345/logs_tree'
-
- it_behaves_like 'redirecting a legacy path',
- '/gitlab/gitlabhq/refs/stable/logs_tree/new%0A%0Aline.txt',
- '/gitlab/gitlabhq/-/refs/stable/logs_tree/new%0A%0Aline.txt'
-
- it_behaves_like 'redirecting a legacy path',
- '/gitlab/gitlabhq/refs/feature%2345/logs_tree/../../../../../@example.com/tree/a',
- '/gitlab/gitlabhq/-/refs/feature#45/logs_tree/../../../../../-/example.com/tree/a'
end
describe Projects::MergeRequestsController, 'routing' do
@@ -255,9 +248,6 @@ RSpec.describe 'project routing' do
let(:actions) { %i[index edit show update] }
let(:base_path) { '/gitlab/gitlabhq/-/merge_requests' }
end
-
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/merge_requests", "/gitlab/gitlabhq/-/merge_requests"
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/merge_requests/1/diffs", "/gitlab/gitlabhq/-/merge_requests/1/diffs"
end
describe Projects::MergeRequests::CreationsController, 'routing' do
@@ -286,8 +276,6 @@ RSpec.describe 'project routing' do
it 'to #diffs' do
expect(get('/gitlab/gitlabhq/-/merge_requests/new/diffs.json')).to route_to('projects/merge_requests/creations#diffs', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'json')
end
-
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/merge_requests/new", "/gitlab/gitlabhq/-/merge_requests/new"
end
describe Projects::MergeRequests::DiffsController, 'routing' do
@@ -331,8 +319,6 @@ RSpec.describe 'project routing' do
it 'to #raw from unscope routing' do
expect(get('/gitlab/gitlabhq/snippets/1/raw')).to route_to('projects/snippets#raw', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1')
end
-
- it_behaves_like 'redirecting a legacy path', '/gitlab/gitlabhq/snippets/1', '/gitlab/gitlabhq/-/snippets/1'
end
# test_project_hook POST /:project_id/-/hooks/:id/test(.:format) hooks#test
@@ -350,8 +336,6 @@ RSpec.describe 'project routing' do
let(:actions) { %i[index create destroy edit update] }
let(:base_path) { '/gitlab/gitlabhq/-/hooks' }
end
-
- it_behaves_like 'redirecting a legacy path', '/gitlab/gitlabhq/hooks', '/gitlab/gitlabhq/-/hooks'
end
# retry_namespace_project_hook_hook_log POST /:project_id/-/hooks/:hook_id/hook_logs/:id/retry(.:format) projects/hook_logs#retry
@@ -364,8 +348,6 @@ RSpec.describe 'project routing' do
it 'to #show' do
expect(get('/gitlab/gitlabhq/-/hooks/1/hook_logs/1')).to route_to('projects/hook_logs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', hook_id: '1', id: '1')
end
-
- it_behaves_like 'redirecting a legacy path', '/gitlab/gitlabhq/hooks/hook_logs/1', '/gitlab/gitlabhq/-/hooks/hook_logs/1'
end
# project_commit GET /:project_id/commit/:id(.:format) commit#show {id: /\h{7,40}/, project_id: /[^\/]+/}
@@ -376,8 +358,6 @@ RSpec.describe 'project routing' do
expect(get('/gitlab/gitlabhq/-/commit/4246fbd.patch')).to route_to('projects/commit#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '4246fbd', format: 'patch')
expect(get('/gitlab/gitlabhq/-/commit/4246fbd13872934f72a8fd0d6fb1317b47b59cb5')).to route_to('projects/commit#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '4246fbd13872934f72a8fd0d6fb1317b47b59cb5')
end
-
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/commit/4246fbd", "/gitlab/gitlabhq/-/commit/4246fbd"
end
# patch_project_commit GET /:project_id/commits/:id/patch(.:format) commits#patch
@@ -393,8 +373,6 @@ RSpec.describe 'project routing' do
it 'to #show' do
expect(get('/gitlab/gitlabhq/-/commits/master.atom')).to route_to('projects/commits#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master.atom')
end
-
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/commits/master", "/gitlab/gitlabhq/-/commits/master"
end
# project_project_members GET /:project_id/project_members(.:format) project_members#index
@@ -453,9 +431,6 @@ RSpec.describe 'project routing' do
let(:actions) { %i[index create new edit show update] }
let(:base_path) { '/gitlab/gitlabhq/-/issues' }
end
-
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/issues", "/gitlab/gitlabhq/-/issues"
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/issues/1/edit", "/gitlab/gitlabhq/-/issues/1/edit"
end
# project_noteable_notes GET /:project_id/noteable/:target_type/:target_id/notes notes#index
@@ -491,6 +466,14 @@ RSpec.describe 'project routing' do
namespace_id: 'gitlab', project_id: 'gitlabhq',
id: "master/#{newline_file}" })
end
+
+ it 'to #streaming' do
+ expect(get('/gitlab/gitlabhq/-/blame/master/app/models/project.rb/streaming')).to route_to('projects/blame#streaming', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/app/models/project.rb', streaming: true)
+ end
+
+ it 'to #page' do
+ expect(get('/gitlab/gitlabhq/-/blame_page/master/app/models/project.rb')).to route_to('projects/blame#page', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/app/models/project.rb')
+ end
end
# project_blob GET /:project_id/-/blob/:id(.:format) blob#show {id: /[^\0]+/, project_id: /[^\/]+/}
@@ -562,9 +545,6 @@ RSpec.describe 'project routing' do
namespace_id: 'gitlab', project_id: 'gitlabhq',
id: newline_file.to_s })
end
-
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/find_file", "/gitlab/gitlabhq/-/find_file"
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/files/master", "/gitlab/gitlabhq/-/files/master"
end
describe Projects::BlobController, 'routing' do
@@ -595,9 +575,6 @@ RSpec.describe 'project routing' do
namespace_id: 'gitlab', project_id: 'gitlabhq',
id: "master/docs/#{newline_file}" })
end
-
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/new/master", "/gitlab/gitlabhq/-/new/master"
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/edit/master/README", "/gitlab/gitlabhq/-/edit/master/README"
end
# project_raw GET /:project_id/-/raw/:id(.:format) raw#show {id: /[^\0]+/, project_id: /[^\/]+/}
@@ -633,9 +610,6 @@ RSpec.describe 'project routing' do
expect(get('/gitlab/gitlabhq/-/compare/master...stable')).to route_to('projects/compare#show', namespace_id: 'gitlab', project_id: 'gitlabhq', from: 'master', to: 'stable')
expect(get('/gitlab/gitlabhq/-/compare/issue/1234...stable')).to route_to('projects/compare#show', namespace_id: 'gitlab', project_id: 'gitlabhq', from: 'issue/1234', to: 'stable')
end
-
- it_behaves_like 'redirecting a legacy path', '/gitlab/gitlabhq/compare', '/gitlab/gitlabhq/-/compare'
- it_behaves_like 'redirecting a legacy path', '/gitlab/gitlabhq/compare/master...stable', '/gitlab/gitlabhq/-/compare/master...stable'
end
describe Projects::NetworkController, 'routing' do
@@ -744,16 +718,12 @@ RSpec.describe 'project routing' do
it 'to #show' do
expect(get('/gitlab/gitlabhq/-/pipelines/12')).to route_to('projects/pipelines#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '12')
end
-
- it_behaves_like 'redirecting a legacy path', '/gitlab/gitlabhq/pipelines', '/gitlab/gitlabhq/-/pipelines'
end
describe Projects::PipelineSchedulesController, 'routing' do
it 'to #index' do
expect(get('/gitlab/gitlabhq/-/pipeline_schedules')).to route_to('projects/pipeline_schedules#index', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
-
- it_behaves_like 'redirecting a legacy path', '/gitlab/gitlabhq/pipeline_schedules', '/gitlab/gitlabhq/-/pipeline_schedules'
end
describe Projects::Settings::OperationsController, 'routing' do
@@ -849,26 +819,26 @@ RSpec.describe 'project routing' do
end
describe Projects::EnvironmentsController, 'routing' do
- describe 'legacy routing' do
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/environments", "/gitlab/gitlabhq/-/environments"
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/411431
+ it 'routes to projects/environments#index' do
+ expect(get('/gitlab/gitlabhq/-/environments'))
+ .to route_to('projects/environments#index', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
end
describe Projects::ClustersController, 'routing' do
- describe 'legacy routing' do
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/clusters", "/gitlab/gitlabhq/-/clusters"
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/411434
+ it 'routes to projects/clusters#index' do
+ expect(get('/gitlab/gitlabhq/-/clusters'))
+ .to route_to('projects/clusters#index', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
end
describe Projects::ErrorTrackingController, 'routing' do
- describe 'legacy routing' do
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/error_tracking", "/gitlab/gitlabhq/-/error_tracking"
- end
- end
-
- describe Projects::Serverless, 'routing' do
- describe 'legacy routing' do
- it_behaves_like 'redirecting a legacy path', "/gitlab/gitlabhq/serverless", "/gitlab/gitlabhq/-/serverless"
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/411436
+ it 'routes to projects/clusters#index' do
+ expect(get('/gitlab/gitlabhq/-/error_tracking'))
+ .to route_to('projects/error_tracking#index', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
end
@@ -897,7 +867,7 @@ RSpec.describe 'project routing' do
end
describe Projects::MetricsDashboardController, 'routing' do
- it 'routes to #show with no dashboard_path and no page' do
+ it 'routes to #show with no dashboard_path' do
expect(get: "/gitlab/gitlabhq/-/metrics").to route_to(
"projects/metrics_dashboard#show",
**base_params
@@ -912,19 +882,17 @@ RSpec.describe 'project routing' do
)
end
- it 'routes to #show with only page' do
+ it 'routes to #show' do
expect(get: "/gitlab/gitlabhq/-/metrics/panel/new").to route_to(
"projects/metrics_dashboard#show",
- page: 'panel/new',
**base_params
)
end
- it 'routes to #show with dashboard_path and page' do
+ it 'routes to #show with dashboard_path' do
expect(get: "/gitlab/gitlabhq/-/metrics/config%2Fprometheus%2Fcommon_metrics.yml/panel/new").to route_to(
"projects/metrics_dashboard#show",
dashboard_path: 'config/prometheus/common_metrics.yml',
- page: 'panel/new',
**base_params
)
end
diff --git a/spec/routing/user_routing_spec.rb b/spec/routing/user_routing_spec.rb
index 7bb589565fa..b155560c9f0 100644
--- a/spec/routing/user_routing_spec.rb
+++ b/spec/routing/user_routing_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'user routing', :clean_gitlab_redis_sessions, feature_category: :authentication_and_authorization do
+RSpec.describe 'user routing', :clean_gitlab_redis_sessions, feature_category: :system_access do
include SessionHelpers
context 'when GitHub OAuth on project import is cancelled' do
diff --git a/spec/rubocop/cop/background_migration/feature_category_spec.rb b/spec/rubocop/cop/background_migration/feature_category_spec.rb
index 359520b1d9f..1d1b6cfad5a 100644
--- a/spec/rubocop/cop/background_migration/feature_category_spec.rb
+++ b/spec/rubocop/cop/background_migration/feature_category_spec.rb
@@ -4,8 +4,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/background_migration/feature_category'
RSpec.describe RuboCop::Cop::BackgroundMigration::FeatureCategory, feature_category: :database do
- let(:cop) { described_class.new }
-
context 'for non background migrations' do
before do
allow(cop).to receive(:in_background_migration?).and_return(false)
diff --git a/spec/rubocop/cop/background_migration/missing_dictionary_file_spec.rb b/spec/rubocop/cop/background_migration/missing_dictionary_file_spec.rb
new file mode 100644
index 00000000000..32b958426b9
--- /dev/null
+++ b/spec/rubocop/cop/background_migration/missing_dictionary_file_spec.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require 'rubocop_spec_helper'
+require_relative '../../../../rubocop/cop/background_migration/missing_dictionary_file'
+
+RSpec.describe RuboCop::Cop::BackgroundMigration::MissingDictionaryFile, feature_category: :database do
+ let(:config) do
+ RuboCop::Config.new(
+ 'BackgroundMigration/MissingDictionaryFile' => {
+ 'EnforcedSince' => 20230307160251
+ }
+ )
+ end
+
+ context 'for non post migrations' do
+ before do
+ allow(cop).to receive(:in_post_deployment_migration?).and_return(false)
+ end
+
+ it 'does not throw any offense' do
+ expect_no_offenses(<<~RUBY)
+ class QueueMyMigration < Gitlab::Database::Migration[2.1]
+ MIGRATION = 'MyMigration'
+
+ def up
+ queue_batched_background_migration(
+ MIGRATION,
+ :users,
+ :id
+ )
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'for post migrations' do
+ before do
+ allow(cop).to receive(:in_post_deployment_migration?).and_return(true)
+ end
+
+ context 'without enqueuing batched migrations' do
+ it 'does not throw any offense' do
+ expect_no_offenses(<<~RUBY)
+ class CreateTestTable < Gitlab::Database::Migration[2.1]
+ def change
+ create_table(:tests)
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'with enqueuing batched migration' do
+ let(:rails_root) { File.expand_path('../../../..', __dir__) }
+ let(:dictionary_file_path) { File.join(rails_root, 'db/docs/batched_background_migrations/my_migration.yml') }
+
+ context 'for migrations before enforced time' do
+ before do
+ allow(cop).to receive(:version).and_return(20230307160250)
+ end
+
+ it 'does not throw any offenses' do
+ expect_no_offenses(<<~RUBY)
+ class QueueMyMigration < Gitlab::Database::Migration[2.1]
+ MIGRATION = 'MyMigration'
+
+ def up
+ queue_batched_background_migration(
+ MIGRATION,
+ :users,
+ :id
+ )
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'for migrations after enforced time' do
+ before do
+ allow(cop).to receive(:version).and_return(20230307160252)
+ end
+
+ it 'throws offense on not having the appropriate dictionary file with migration name as a constant' do
+ expect_offense(<<~RUBY)
+ class QueueMyMigration < Gitlab::Database::Migration[2.1]
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{format("Missing %{file_name}. Use the generator 'batched_background_migration' to create dictionary files automatically. For more details refer: https://docs.gitlab.com/ee/development/database/batched_background_migrations.html#generator", file_name: dictionary_file_path)}
+ MIGRATION = 'MyMigration'
+
+ def up
+ queue_batched_background_migration(
+ MIGRATION,
+ :users,
+ :id
+ )
+ end
+ end
+ RUBY
+ end
+
+ it 'throws offense on not having the appropriate dictionary file with migration name as a variable' do
+ expect_offense(<<~RUBY)
+ class QueueMyMigration < Gitlab::Database::Migration[2.1]
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{format("Missing %{file_name}. Use the generator 'batched_background_migration' to create dictionary files automatically. For more details refer: https://docs.gitlab.com/ee/development/database/batched_background_migrations.html#generator", file_name: dictionary_file_path)}
+ def up
+ queue_batched_background_migration(
+ 'MyMigration',
+ :users,
+ :id
+ )
+ end
+ end
+ RUBY
+ end
+
+ it 'does not throw offense with appropriate dictionary file' do
+ expect(File).to receive(:exist?).with(dictionary_file_path).and_return(true)
+
+ expect_no_offenses(<<~RUBY)
+ class QueueMyMigration < Gitlab::Database::Migration[2.1]
+ MIGRATION = 'MyMigration'
+
+ def up
+ queue_batched_background_migration(
+ MIGRATION,
+ :users,
+ :id
+ )
+ end
+ end
+ RUBY
+ end
+ end
+ end
+ end
+end
diff --git a/spec/rubocop/cop/gettext/static_identifier_spec.rb b/spec/rubocop/cop/gettext/static_identifier_spec.rb
new file mode 100644
index 00000000000..a0c15d8ad48
--- /dev/null
+++ b/spec/rubocop/cop/gettext/static_identifier_spec.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+require 'rubocop_spec_helper'
+require 'rspec-parameterized'
+
+require_relative '../../../../rubocop/cop/gettext/static_identifier'
+
+RSpec.describe RuboCop::Cop::Gettext::StaticIdentifier, feature_category: :internationalization do
+ describe '#_()' do
+ it 'does not flag correct use' do
+ expect_no_offenses(<<~'RUBY')
+ _('Hello')
+ _('Hello #{name}')
+
+ _('Hello %{name}') % { name: name }
+ format(_('Hello %{name}') % { name: name })
+
+ _('Hello' \
+ 'Multiline')
+ _('Hello' \
+ 'Multiline %{name}') % { name: name }
+
+ var = "Hello"
+ _(var)
+ _(method_name)
+ list.each { |item| _(item) }
+ _(CONST)
+ RUBY
+ end
+
+ it 'flags incorrect use' do
+ expect_offense(<<~'RUBY')
+ _('Hello' + ' concat')
+ ^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `_(...)`.
+ _('Hello'.concat(' concat'))
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `_(...)`.
+ _("Hello #{name}")
+ ^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `_(...)`.
+ _('Hello %{name}' % { name: name })
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `_(...)`.
+ _(format('Hello %{name}') % { name: name })
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `_(...)`.
+ RUBY
+ end
+ end
+
+ describe '#N_()' do
+ it 'does not flag correct use' do
+ expect_no_offenses(<<~'RUBY')
+ N_('Hello')
+ N_('Hello #{name}')
+ N_('Hello %{name}') % { name: name }
+ format(_('Hello %{name}') % { name: name })
+
+ N_('Hello' \
+ 'Multiline')
+
+ var = "Hello"
+ N_(var)
+ N_(method_name)
+ list.each { |item| N_(item) }
+ N_(CONST)
+ RUBY
+ end
+
+ it 'flags incorrect use' do
+ expect_offense(<<~'RUBY')
+ N_('Hello' + ' concat')
+ ^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `N_(...)`.
+ N_("Hello #{name}")
+ ^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `N_(...)`.
+ N_('Hello %{name}' % { name: name })
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `N_(...)`.
+ N_('Hello' \
+ ^^^^^^^^^ Ensure to pass static strings to translation method `N_(...)`.
+ 'Multiline %{name}' % { name: name })
+ N_(format('Hello %{name}') % { name: name })
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `N_(...)`.
+ RUBY
+ end
+ end
+
+ describe '#s_()' do
+ it 'does not flag correct use' do
+ expect_no_offenses(<<~'RUBY')
+ s_('World|Hello')
+ s_('World|Hello #{name}')
+ s_('World|Hello %{name}') % { name: name }
+ format(s_('World|Hello %{name}') % { name: name })
+
+ s_('World|Hello' \
+ 'Multiline')
+
+ var = "Hello"
+ s_(var)
+ s_(method_name)
+ list.each { |item| s_(item) }
+ s_(CONST)
+ RUBY
+ end
+
+ it 'flags incorrect use' do
+ expect_offense(<<~'RUBY')
+ s_("World|Hello #{name}")
+ ^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `s_(...)`.
+ s_('World|Hello' + ' concat')
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `s_(...)`.
+ s_('World|Hello %{name}' % { name: name })
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `s_(...)`.
+ s_('World|Hello' \
+ ^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `s_(...)`.
+ 'Multiline %{name}' % { name: name })
+ s_(format('World|Hello %{name}') % { name: name })
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `s_(...)`.
+ RUBY
+ end
+ end
+
+ describe '#n_()' do
+ it 'does not flag correct use' do
+ expect_no_offenses(<<~'RUBY')
+ n_('Hello', 'Hellos', 2)
+ n_('Hello', 'Hellos', count)
+
+ n_('Hello' ' concat', 'Hellos', 2)
+ n_('Hello', 'Hello' 's', 2)
+
+ n_('Hello %{name}', 'Hellos %{name}', 2) % { name: name }
+ format(n_('Hello %{name}', 'Hellos %{name}', count) % { name: name })
+
+ n_('Hello', 'Hellos' \
+ 'Multiline', 2)
+
+ n_('Hello' \
+ 'Multiline', 'Hellos', 2)
+
+ n_('Hello' \
+ 'Multiline %{name}', 'Hellos %{name}', 2) % { name: name }
+
+ var = "Hello"
+ n_(var, var, 1)
+ n_(method_name, method_name, count)
+ list.each { |item| n_(item, item, 2) }
+ n_(CONST, CONST, 2)
+ RUBY
+ end
+
+ it 'flags incorrect use' do
+ expect_offense(<<~'RUBY')
+ n_('Hello' + ' concat', 'Hellos', 2)
+ ^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `n_(...)`.
+ n_('Hello', 'Hello' + 's', 2)
+ ^^^^^^^^^^^^^ Ensure to pass static strings to translation method `n_(...)`.
+ n_("Hello #{name}", "Hellos", 2)
+ ^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `n_(...)`.
+ n_('Hello %{name}' % { name: name }, 'Hellos', 2)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `n_(...)`.
+ n_('Hello' \
+ ^^^^^^^^^ Ensure to pass static strings to translation method `n_(...)`.
+ 'Multiline %{name}' % { name: name }, 'Hellos %{name}', 2)
+ n_('Hello', format('Hellos %{name}') % { name: name }, count)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ensure to pass static strings to translation method `n_(...)`.
+ RUBY
+ end
+ end
+
+ describe 'edge cases' do
+ it 'does not flag' do
+ expect_no_offenses(<<~RUBY)
+ n_(s_('World|Hello'), s_('World|Hellos'), 2)
+ RUBY
+ end
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/avoid_feature_get_spec.rb b/spec/rubocop/cop/gitlab/avoid_feature_get_spec.rb
index b5017bebd28..1531042c23a 100644
--- a/spec/rubocop/cop/gitlab/avoid_feature_get_spec.rb
+++ b/spec/rubocop/cop/gitlab/avoid_feature_get_spec.rb
@@ -7,8 +7,6 @@ require_relative '../../../../rubocop/cop/gitlab/avoid_feature_get'
RSpec.describe RuboCop::Cop::Gitlab::AvoidFeatureGet do
let(:msg) { described_class::MSG }
- subject(:cop) { described_class.new }
-
it 'bans use of Feature.ban' do
expect_offense(<<~RUBY)
Feature.get
diff --git a/spec/rubocop/cop/gitlab/deprecate_track_redis_hll_event_spec.rb b/spec/rubocop/cop/gitlab/deprecate_track_redis_hll_event_spec.rb
deleted file mode 100644
index eed30e11a98..00000000000
--- a/spec/rubocop/cop/gitlab/deprecate_track_redis_hll_event_spec.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-require 'rubocop_spec_helper'
-require_relative '../../../../rubocop/cop/gitlab/deprecate_track_redis_hll_event'
-
-RSpec.describe RuboCop::Cop::Gitlab::DeprecateTrackRedisHLLEvent do
- it 'does not flag the use of track_event' do
- expect_no_offenses('track_event :show, name: "p_analytics_insights"')
- end
-
- it 'flags the use of track_redis_hll_event' do
- expect_offense(<<~SOURCE)
- track_redis_hll_event :show, name: 'p_analytics_valuestream'
- ^^^^^^^^^^^^^^^^^^^^^ `track_redis_hll_event` is deprecated[...]
- SOURCE
- end
-end
diff --git a/spec/rubocop/cop/gitlab/doc_url_spec.rb b/spec/rubocop/cop/gitlab/doc_url_spec.rb
index 4a7ef14ccbc..957edc8286b 100644
--- a/spec/rubocop/cop/gitlab/doc_url_spec.rb
+++ b/spec/rubocop/cop/gitlab/doc_url_spec.rb
@@ -3,7 +3,7 @@
require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/gitlab/doc_url'
-RSpec.describe RuboCop::Cop::Gitlab::DocUrl, feature_category: :not_owned do
+RSpec.describe RuboCop::Cop::Gitlab::DocUrl, feature_category: :shared do
context 'when string literal is added with docs url prefix' do
context 'when inlined' do
it 'registers an offense' do
diff --git a/spec/rubocop/cop/gitlab/keys_first_and_values_first_spec.rb b/spec/rubocop/cop/gitlab/keys_first_and_values_first_spec.rb
index 073c78e78c0..b62742d52e2 100644
--- a/spec/rubocop/cop/gitlab/keys_first_and_values_first_spec.rb
+++ b/spec/rubocop/cop/gitlab/keys_first_and_values_first_spec.rb
@@ -7,8 +7,6 @@ require_relative '../../../../rubocop/cop/gitlab/keys_first_and_values_first'
RSpec.describe RuboCop::Cop::Gitlab::KeysFirstAndValuesFirst do
let(:msg) { described_class::MSG }
- subject(:cop) { described_class.new }
-
shared_examples 'inspect use of keys or values first' do |method, autocorrect|
describe ".#{method}.first" do
it 'flags and autocorrects' do
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 bfc0cebe203..96ff01108c3 100644
--- a/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb
+++ b/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb
@@ -201,18 +201,13 @@ RSpec.describe RuboCop::Cop::Gitlab::MarkUsedFeatureFlags do
include_examples 'does not set any flags as used', 'data_consistency :delayed'
end
+ describe 'Class with included WorkerAttributes `data_consistency` method' do
+ include_examples 'sets flag as used', 'ActionMailer::MailDeliveryJob.data_consistency :delayed, feature_flag: :foo', 'foo'
+ include_examples 'does not set any flags as used', 'data_consistency :delayed'
+ end
+
describe 'Worker `deduplicate` method' do
include_examples 'sets flag as used', 'deduplicate :delayed, feature_flag: :foo', 'foo'
include_examples 'does not set any flags as used', 'deduplicate :delayed'
end
-
- describe "tracking of usage data metrics known events happens at the beginning of inspection" do
- let(:usage_data_counters_known_event_feature_flags) { ['an_event_feature_flag'] }
-
- before do
- allow(cop).to receive(:usage_data_counters_known_event_feature_flags).and_return(usage_data_counters_known_event_feature_flags)
- end
-
- include_examples 'sets flag as used', "FEATURE_FLAG = :foo", %w[foo an_event_feature_flag]
- end
end
diff --git a/spec/rubocop/cop/gitlab/service_response_spec.rb b/spec/rubocop/cop/gitlab/service_response_spec.rb
index 84cf0dbff52..f90c84701c6 100644
--- a/spec/rubocop/cop/gitlab/service_response_spec.rb
+++ b/spec/rubocop/cop/gitlab/service_response_spec.rb
@@ -4,8 +4,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/gitlab/service_response'
RSpec.describe RuboCop::Cop::Gitlab::ServiceResponse do
- subject(:cop) { described_class.new }
-
it 'does not flag the `http_status:` param on a homonym method' do
expect_no_offenses("MyClass.error(http_status: :ok)")
end
diff --git a/spec/rubocop/cop/graphql/authorize_types_spec.rb b/spec/rubocop/cop/graphql/authorize_types_spec.rb
index a30cd5a1688..932991c7b76 100644
--- a/spec/rubocop/cop/graphql/authorize_types_spec.rb
+++ b/spec/rubocop/cop/graphql/authorize_types_spec.rb
@@ -17,6 +17,28 @@ RSpec.describe RuboCop::Cop::Graphql::AuthorizeTypes do
TYPE
end
+ it 'adds add an offense when authorize has no arguments' do
+ expect_offense(<<~TYPE.strip)
+ module Types
+ class AType < SuperClassWithFields
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Add an `authorize :ability` call to the type: https://docs.gitlab.com/ee/development/graphql_guide/authorization.html#type-authorization
+ authorize
+ end
+ end
+ TYPE
+ end
+
+ it 'adds add an offense when authorize is empty' do
+ expect_offense(<<~TYPE.strip)
+ module Types
+ class AType < SuperClassWithFields
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Add an `authorize :ability` call to the type: https://docs.gitlab.com/ee/development/graphql_guide/authorization.html#type-authorization
+ authorize []
+ end
+ end
+ TYPE
+ end
+
it 'does not add an offense for classes that have an authorize call' do
expect_no_offenses(<<~TYPE.strip)
module Types
diff --git a/spec/rubocop/cop/lint/last_keyword_argument_spec.rb b/spec/rubocop/cop/lint/last_keyword_argument_spec.rb
index 53f19cd01ee..edd54a40b79 100644
--- a/spec/rubocop/cop/lint/last_keyword_argument_spec.rb
+++ b/spec/rubocop/cop/lint/last_keyword_argument_spec.rb
@@ -3,7 +3,7 @@
require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/lint/last_keyword_argument'
-RSpec.describe RuboCop::Cop::Lint::LastKeywordArgument, :ruby27, feature_category: :not_owned do
+RSpec.describe RuboCop::Cop::Lint::LastKeywordArgument, :ruby27, feature_category: :shared do
before do
described_class.instance_variable_set(:@keyword_warnings, nil)
allow(Dir).to receive(:glob).and_call_original
diff --git a/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb b/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb
index 7cc88946cf1..c2f0053718a 100644
--- a/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb
+++ b/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb
@@ -4,8 +4,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/migration/add_columns_to_wide_tables'
RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do
- let(:cop) { described_class.new }
-
context 'when outside of a migration' do
it 'does not register any offenses' do
expect_no_offenses(<<~RUBY)
diff --git a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
index aa39f5f1603..98cfcb5c2e2 100644
--- a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
+++ b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
@@ -4,8 +4,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/migration/add_concurrent_foreign_key'
RSpec.describe RuboCop::Cop::Migration::AddConcurrentForeignKey do
- let(:cop) { described_class.new }
-
context 'when outside of a migration' do
it 'does not register any offenses' do
expect_no_offenses('def up; add_foreign_key(:projects, :users, column: :user_id); end')
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 a6a072e2caf..032cc12ab94 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
@@ -78,30 +78,6 @@ 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/add_reference_spec.rb b/spec/rubocop/cop/migration/add_reference_spec.rb
index bb3fe7068b4..7e6d14261c8 100644
--- a/spec/rubocop/cop/migration/add_reference_spec.rb
+++ b/spec/rubocop/cop/migration/add_reference_spec.rb
@@ -4,8 +4,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/migration/add_reference'
RSpec.describe RuboCop::Cop::Migration::AddReference do
- let(:cop) { described_class.new }
-
context 'when outside of a migration' do
it 'does not register any offenses' do
expect_no_offenses(<<~RUBY)
diff --git a/spec/rubocop/cop/migration/background_migrations_spec.rb b/spec/rubocop/cop/migration/background_migrations_spec.rb
index 681bbd84562..78c38f669ad 100644
--- a/spec/rubocop/cop/migration/background_migrations_spec.rb
+++ b/spec/rubocop/cop/migration/background_migrations_spec.rb
@@ -4,8 +4,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/migration/background_migrations'
RSpec.describe RuboCop::Cop::Migration::BackgroundMigrations do
- let(:cop) { described_class.new }
-
context 'when queue_background_migration_jobs_by_range_at_intervals is used' do
it 'registers an offense' do
expect_offense(<<~RUBY)
diff --git a/spec/rubocop/cop/migration/batch_migrations_post_only_spec.rb b/spec/rubocop/cop/migration/batch_migrations_post_only_spec.rb
index b5e2e83e788..a33557dc1ce 100644
--- a/spec/rubocop/cop/migration/batch_migrations_post_only_spec.rb
+++ b/spec/rubocop/cop/migration/batch_migrations_post_only_spec.rb
@@ -4,8 +4,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/migration/batch_migrations_post_only'
RSpec.describe RuboCop::Cop::Migration::BatchMigrationsPostOnly do
- let(:cop) { described_class.new }
-
before do
allow(cop).to receive(:in_post_deployment_migration?).and_return post_migration?
end
diff --git a/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb b/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb
index 072edb5827b..feea5cb3958 100644
--- a/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb
+++ b/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb
@@ -4,8 +4,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/migration/create_table_with_foreign_keys'
RSpec.describe RuboCop::Cop::Migration::CreateTableWithForeignKeys do
- let(:cop) { described_class.new }
-
context 'outside of a migration' do
it 'does not register any offenses' do
expect_no_offenses(<<~RUBY)
@@ -148,9 +146,11 @@ RSpec.describe RuboCop::Cop::Migration::CreateTableWithForeignKeys do
users
web_hook_logs
].each do |table|
- let(:table_name) { table }
+ context "with #{table}" do
+ let(:table_name) { table }
- it_behaves_like 'target to high traffic table', dsl_method, table
+ it_behaves_like 'target to high traffic table', dsl_method, table
+ end
end
end
diff --git a/spec/rubocop/cop/migration/schedule_async_spec.rb b/spec/rubocop/cop/migration/schedule_async_spec.rb
index 59e03db07c0..30f774c48b0 100644
--- a/spec/rubocop/cop/migration/schedule_async_spec.rb
+++ b/spec/rubocop/cop/migration/schedule_async_spec.rb
@@ -5,7 +5,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/migration/schedule_async'
RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do
- let(:cop) { described_class.new }
let(:source) do
<<~SOURCE
def up
diff --git a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
index 005d3fb6b2a..25381fc0281 100644
--- a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
+++ b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
@@ -5,7 +5,6 @@ require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/migration/update_column_in_batches'
RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
- let(:cop) { described_class.new }
let(:tmp_rails_root) { rails_root_join('tmp', 'rails_root') }
let(:migration_code) do
<<-END
diff --git a/spec/rubocop/cop/rspec/avoid_conditional_statements_spec.rb b/spec/rubocop/cop/rspec/avoid_conditional_statements_spec.rb
new file mode 100644
index 00000000000..3f45f660aa5
--- /dev/null
+++ b/spec/rubocop/cop/rspec/avoid_conditional_statements_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'rubocop_spec_helper'
+
+require_relative '../../../../rubocop/cop/rspec/avoid_conditional_statements'
+
+RSpec.describe RuboCop::Cop::RSpec::AvoidConditionalStatements, feature_category: :tooling do
+ context 'when using conditionals' do
+ it 'flags if conditional' do
+ expect_offense(<<~RUBY)
+ if page.has_css?('[data-testid="begin-commit-button"]')
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't use `if` conditional statement in specs, it might create flakiness. See https://gitlab.com/gitlab-org/gitlab/-/issues/385304#note_1345437109
+ find('[data-testid="begin-commit-button"]').click
+ end
+ RUBY
+ end
+
+ it 'flags unless conditional' do
+ expect_offense(<<~RUBY)
+ RSpec.describe 'Multi-file editor new directory', :js, feature_category: :web_ide do
+ it 'creates directory in current directory' do
+ unless page.has_css?('[data-testid="begin-commit-button"]')
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't use `unless` conditional statement in specs, it might create flakiness. See https://gitlab.com/gitlab-org/gitlab/-/issues/385304#note_1345437109
+ find('[data-testid="begin-commit-button"]').click
+ end
+ end
+ end
+ RUBY
+ end
+
+ it 'flags ternary operator' do
+ expect_offense(<<~RUBY)
+ RSpec.describe 'Multi-file editor new directory', :js, feature_category: :web_ide do
+ it 'creates directory in current directory' do
+ user.present ? user : nil
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Don't use `user.present ? user : nil` conditional statement in specs, it might create flakiness. See https://gitlab.com/gitlab-org/gitlab/-/issues/385304#note_1345437109
+ end
+ end
+ RUBY
+ end
+ end
+end
diff --git a/spec/rubocop/cop/rspec/avoid_test_prof_spec.rb b/spec/rubocop/cop/rspec/avoid_test_prof_spec.rb
index b180134b226..db8c7b1d783 100644
--- a/spec/rubocop/cop/rspec/avoid_test_prof_spec.rb
+++ b/spec/rubocop/cop/rspec/avoid_test_prof_spec.rb
@@ -5,7 +5,7 @@ require 'rspec-parameterized'
require_relative '../../../../rubocop/cop/rspec/avoid_test_prof'
-RSpec.describe RuboCop::Cop::RSpec::AvoidTestProf, feature_category: :not_owned do
+RSpec.describe RuboCop::Cop::RSpec::AvoidTestProf, feature_category: :shared do
using RSpec::Parameterized::TableSyntax
context 'when there are offenses' do
diff --git a/spec/rubocop/cop/rspec/htt_party_basic_auth_spec.rb b/spec/rubocop/cop/rspec/htt_party_basic_auth_spec.rb
deleted file mode 100644
index 537a7a9a7e9..00000000000
--- a/spec/rubocop/cop/rspec/htt_party_basic_auth_spec.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-require 'rubocop_spec_helper'
-
-require_relative '../../../../rubocop/cop/rspec/httparty_basic_auth'
-
-RSpec.describe RuboCop::Cop::RSpec::HTTPartyBasicAuth do
- context 'when passing `basic_auth: { user: ... }`' do
- it 'registers an offense and corrects', :aggregate_failures do
- expect_offense(<<~SOURCE, 'spec/foo.rb')
- HTTParty.put(
- url,
- basic_auth: { user: user, password: token },
- ^^^^ #{described_class::MESSAGE}
- body: body
- )
- SOURCE
-
- expect_correction(<<~SOURCE)
- HTTParty.put(
- url,
- basic_auth: { username: user, password: token },
- body: body
- )
- SOURCE
- end
- end
-
- context 'when passing `basic_auth: { username: ... }`' do
- it 'does not register an offense' do
- expect_no_offenses(<<~SOURCE, 'spec/frontend/fixtures/foo.rb')
- HTTParty.put(
- url,
- basic_auth: { username: user, password: token },
- body: body
- )
- SOURCE
- end
- end
-end
diff --git a/spec/rubocop/cop/rspec/httparty_basic_auth_spec.rb b/spec/rubocop/cop/rspec/httparty_basic_auth_spec.rb
new file mode 100644
index 00000000000..c7f4c61b501
--- /dev/null
+++ b/spec/rubocop/cop/rspec/httparty_basic_auth_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'rubocop_spec_helper'
+
+require_relative '../../../../rubocop/cop/rspec/httparty_basic_auth'
+
+RSpec.describe RuboCop::Cop::RSpec::HTTPartyBasicAuth, feature_category: :shared do
+ context 'when passing `basic_auth: { user: ... }`' do
+ it 'registers an offense and corrects', :aggregate_failures do
+ expect_offense(<<~SOURCE, 'spec/foo.rb')
+ HTTParty.put(
+ url,
+ basic_auth: { user: user, password: token },
+ ^^^^ #{described_class::MESSAGE}
+ body: body
+ )
+ SOURCE
+
+ expect_correction(<<~SOURCE)
+ HTTParty.put(
+ url,
+ basic_auth: { username: user, password: token },
+ body: body
+ )
+ SOURCE
+ end
+ end
+
+ context 'when passing `basic_auth: { username: ... }`' do
+ it 'does not register an offense' do
+ expect_no_offenses(<<~SOURCE, 'spec/frontend/fixtures/foo.rb')
+ HTTParty.put(
+ url,
+ basic_auth: { username: user, password: token },
+ body: body
+ )
+ SOURCE
+ end
+ end
+end
diff --git a/spec/rubocop/cop/rspec/invalid_feature_category_spec.rb b/spec/rubocop/cop/rspec/invalid_feature_category_spec.rb
index 0d2fd029a13..e5287f7105e 100644
--- a/spec/rubocop/cop/rspec/invalid_feature_category_spec.rb
+++ b/spec/rubocop/cop/rspec/invalid_feature_category_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe RuboCop::Cop::RSpec::InvalidFeatureCategory, feature_category: :t
it 'flags invalid feature category in nested context' do
expect_offense(<<~RUBY, valid: valid_category, invalid: invalid_category)
- RSpec.describe 'foo', feature_category: :%{valid} do
+ RSpec.describe 'foo', feature_category: :"%{valid}" do
context 'bar', foo: :bar, feature_category: :%{invalid} do
^^{invalid} Please use a valid feature category. See https://docs.gitlab.com/ee/development/feature_categorization/#rspec-examples.
end
@@ -27,7 +27,7 @@ RSpec.describe RuboCop::Cop::RSpec::InvalidFeatureCategory, feature_category: :t
it 'flags invalid feature category in examples' do
expect_offense(<<~RUBY, valid: valid_category, invalid: invalid_category)
- RSpec.describe 'foo', feature_category: :%{valid} do
+ RSpec.describe 'foo', feature_category: :"%{valid}" do
it 'bar', feature_category: :%{invalid} do
^^{invalid} Please use a valid feature category. See https://docs.gitlab.com/ee/development/feature_categorization/#rspec-examples.
end
@@ -37,9 +37,9 @@ RSpec.describe RuboCop::Cop::RSpec::InvalidFeatureCategory, feature_category: :t
it 'does not flag if feature category is valid' do
expect_no_offenses(<<~RUBY)
- RSpec.describe 'foo', feature_category: :#{valid_category} do
- context 'bar', feature_category: :#{valid_category} do
- it 'baz', feature_category: :#{valid_category} do
+ RSpec.describe 'foo', feature_category: :"#{valid_category}" do
+ context 'bar', feature_category: :"#{valid_category}" do
+ it 'baz', feature_category: :"#{valid_category}" do
end
end
end
@@ -50,8 +50,8 @@ RSpec.describe RuboCop::Cop::RSpec::InvalidFeatureCategory, feature_category: :t
mistyped = make_typo(valid_category)
expect_offense(<<~RUBY, invalid: mistyped, valid: valid_category)
- RSpec.describe 'foo', feature_category: :%{invalid} do
- ^^{invalid} Please use a valid feature category. Did you mean `:%{valid}`? See [...]
+ RSpec.describe 'foo', feature_category: :"%{invalid}" do
+ ^^^^{invalid} Please use a valid feature category. Did you mean `:%{valid}`? See [...]
end
RUBY
end
diff --git a/spec/rubocop/cop/rspec/misspelled_aggregate_failures_spec.rb b/spec/rubocop/cop/rspec/misspelled_aggregate_failures_spec.rb
new file mode 100644
index 00000000000..c551c03b896
--- /dev/null
+++ b/spec/rubocop/cop/rspec/misspelled_aggregate_failures_spec.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require 'rubocop_spec_helper'
+require 'rspec-parameterized'
+
+require_relative '../../../../rubocop/cop/rspec/misspelled_aggregate_failures'
+
+RSpec.describe RuboCop::Cop::RSpec::MisspelledAggregateFailures, feature_category: :shared do
+ shared_examples 'misspelled tag' do |misspelled|
+ it 'flags and auto-corrects misspelled tags in describe' do
+ expect_offense(<<~'RUBY', misspelled: misspelled)
+ RSpec.describe 'a feature', :%{misspelled} do
+ ^^{misspelled} Use `:aggregate_failures` to aggregate failures.
+ describe 'inner', :%{misspelled} do
+ ^^{misspelled} Use `:aggregate_failures` to aggregate failures.
+ end
+ end
+ RUBY
+
+ expect_correction(<<~'RUBY')
+ RSpec.describe 'a feature', :aggregate_failures do
+ describe 'inner', :aggregate_failures do
+ end
+ end
+ RUBY
+ end
+
+ it 'flags and auto-corrects misspelled tags in context' do
+ expect_offense(<<~'RUBY', misspelled: misspelled)
+ context 'a feature', :%{misspelled} do
+ ^^{misspelled} Use `:aggregate_failures` to aggregate failures.
+ end
+ RUBY
+
+ expect_correction(<<~'RUBY')
+ context 'a feature', :aggregate_failures do
+ end
+ RUBY
+ end
+
+ it 'flags and auto-corrects misspelled tags in examples' do
+ expect_offense(<<~'RUBY', misspelled: misspelled)
+ it 'aggregates', :%{misspelled} do
+ ^^{misspelled} Use `:aggregate_failures` to aggregate failures.
+ end
+
+ specify :%{misspelled} do
+ ^^{misspelled} Use `:aggregate_failures` to aggregate failures.
+ end
+
+ it :%{misspelled} do
+ ^^{misspelled} Use `:aggregate_failures` to aggregate failures.
+ end
+ RUBY
+
+ expect_correction(<<~'RUBY')
+ it 'aggregates', :aggregate_failures do
+ end
+
+ specify :aggregate_failures do
+ end
+
+ it :aggregate_failures do
+ end
+ RUBY
+ end
+
+ it 'flags and auto-corrects misspelled tags in any order' do
+ expect_offense(<<~'RUBY', misspelled: misspelled)
+ it 'aggregates', :foo, :%{misspelled} do
+ ^^{misspelled} Use `:aggregate_failures` to aggregate failures.
+ end
+
+ it 'aggregates', :%{misspelled}, :bar do
+ ^^{misspelled} Use `:aggregate_failures` to aggregate failures.
+ end
+ RUBY
+
+ expect_correction(<<~'RUBY')
+ it 'aggregates', :foo, :aggregate_failures do
+ end
+
+ it 'aggregates', :aggregate_failures, :bar do
+ end
+ RUBY
+ end
+ end
+
+ shared_examples 'legit tag' do |legit_tag|
+ it 'does not flag' do
+ expect_no_offenses(<<~RUBY)
+ RSpec.describe 'a feature', :#{legit_tag} do
+ end
+
+ it 'is ok', :#{legit_tag} do
+ end
+ RUBY
+ end
+ end
+
+ context 'with misspelled tags' do
+ where(:tag) do
+ # From https://gitlab.com/gitlab-org/gitlab/-/issues/396356#list
+ %w[
+ aggregate_errors
+ aggregate_failure
+ aggregated_failures
+ aggregate_results
+ aggregated_errors
+ aggregates_failures
+ aggregate_failues
+
+ aggregate_bar
+ aggregate_foo
+ ]
+ end
+
+ with_them do
+ it_behaves_like 'misspelled tag', params[:tag]
+ end
+ end
+
+ context 'with legit tags' do
+ where(:tag) do
+ %w[
+ aggregate
+ aggregations
+ aggregate_two_underscores
+ ]
+ end
+
+ with_them do
+ it_behaves_like 'legit tag', params[:tag]
+ end
+ end
+end
diff --git a/spec/rubocop/cop/rspec/shared_groups_metadata_spec.rb b/spec/rubocop/cop/rspec/shared_groups_metadata_spec.rb
new file mode 100644
index 00000000000..3dd568e7dcd
--- /dev/null
+++ b/spec/rubocop/cop/rspec/shared_groups_metadata_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'rubocop_spec_helper'
+require 'rspec-parameterized'
+
+require_relative '../../../../rubocop/cop/rspec/shared_groups_metadata'
+
+RSpec.describe RuboCop::Cop::RSpec::SharedGroupsMetadata, feature_category: :tooling do
+ context 'with hash metadata' do
+ it 'flags metadata in shared example' do
+ expect_offense(<<~RUBY)
+ RSpec.shared_examples 'foo', feature_category: :shared do
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid using metadata on shared examples and shared context. They might cause flaky tests. See https://gitlab.com/gitlab-org/gitlab/-/issues/404388
+ end
+
+ shared_examples 'foo', feature_category: :shared do
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid using metadata on shared examples and shared context. They might cause flaky tests. See https://gitlab.com/gitlab-org/gitlab/-/issues/404388
+ end
+ RUBY
+ end
+
+ it 'flags metadata in shared context' do
+ expect_offense(<<~RUBY)
+ RSpec.shared_context 'foo', feature_category: :shared do
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid using metadata on shared examples and shared context. They might cause flaky tests. See https://gitlab.com/gitlab-org/gitlab/-/issues/404388
+ end
+
+ shared_context 'foo', feature_category: :shared do
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid using metadata on shared examples and shared context. They might cause flaky tests. See https://gitlab.com/gitlab-org/gitlab/-/issues/404388
+ end
+ RUBY
+ end
+ end
+
+ context 'with symbol metadata' do
+ it 'flags metadata in shared example' do
+ expect_offense(<<~RUBY)
+ RSpec.shared_examples 'foo', :aggregate_failures do
+ ^^^^^^^^^^^^^^^^^^^ Avoid using metadata on shared examples and shared context. They might cause flaky tests. See https://gitlab.com/gitlab-org/gitlab/-/issues/404388
+ end
+
+ shared_examples 'foo', :aggregate_failures do
+ ^^^^^^^^^^^^^^^^^^^ Avoid using metadata on shared examples and shared context. They might cause flaky tests. See https://gitlab.com/gitlab-org/gitlab/-/issues/404388
+ end
+ RUBY
+ end
+
+ it 'flags metadata in shared context' do
+ expect_offense(<<~RUBY)
+ RSpec.shared_context 'foo', :aggregate_failures do
+ ^^^^^^^^^^^^^^^^^^^ Avoid using metadata on shared examples and shared context. They might cause flaky tests. See https://gitlab.com/gitlab-org/gitlab/-/issues/404388
+ end
+
+ shared_context 'foo', :aggregate_failures do
+ ^^^^^^^^^^^^^^^^^^^ Avoid using metadata on shared examples and shared context. They might cause flaky tests. See https://gitlab.com/gitlab-org/gitlab/-/issues/404388
+ end
+ RUBY
+ end
+ end
+
+ it 'does not flag if feature category is missing' do
+ expect_no_offenses(<<~RUBY)
+ RSpec.shared_examples 'foo' do
+ end
+
+ shared_examples 'foo' do
+ end
+ RUBY
+ end
+end
diff --git a/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb b/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb
deleted file mode 100644
index b687e91601c..00000000000
--- a/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-require 'rubocop_spec_helper'
-
-require_relative '../../../rubocop/cop/ruby_interpolation_in_translation'
-
-# Disabling interpolation check as we deliberately want to have #{} in strings.
-# rubocop:disable Lint/InterpolationCheck
-RSpec.describe RuboCop::Cop::RubyInterpolationInTranslation do
- let(:msg) { "Don't use ruby interpolation \#{} inside translated strings, instead use %{}" }
-
- it 'does not add an offense for a regular messages' do
- expect_no_offenses('_("Hello world")')
- end
-
- it 'adds the correct offense when using interpolation in a string' do
- expect_offense(<<~CODE)
- _("Hello \#{world}")
- ^^^^^ #{msg}
- ^^^^^^^^ #{msg}
- CODE
- end
-
- it 'detects when using a ruby interpolation in the first argument of a pluralized string' do
- expect_offense(<<~CODE)
- n_("Hello \#{world}", "Hello world")
- ^^^^^ #{msg}
- ^^^^^^^^ #{msg}
- CODE
- end
-
- it 'detects when using a ruby interpolation in the second argument of a pluralized string' do
- expect_offense(<<~CODE)
- n_("Hello world", "Hello \#{world}")
- ^^^^^ #{msg}
- ^^^^^^^^ #{msg}
- CODE
- end
-
- it 'detects when using interpolation in a namespaced translation' do
- expect_offense(<<~CODE)
- s_("Hello|\#{world}")
- ^^^^^ #{msg}
- ^^^^^^^^ #{msg}
- CODE
- end
-end
-# rubocop:enable Lint/InterpolationCheck
diff --git a/spec/rubocop/cop/search/namespaced_class_spec.rb b/spec/rubocop/cop/search/namespaced_class_spec.rb
new file mode 100644
index 00000000000..6e10909389e
--- /dev/null
+++ b/spec/rubocop/cop/search/namespaced_class_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'rubocop_spec_helper'
+require_relative '../../../../rubocop/cop/search/namespaced_class'
+
+RSpec.describe RuboCop::Cop::Search::NamespacedClass, feature_category: :global_search do
+ %w[Search Zoekt Elastic].each do |keyword|
+ context 'when Search root namespace is not used' do
+ it 'flags a class definition without Search namespace' do
+ expect_offense(<<~'SOURCE', keyword: keyword, msg: described_class::MSG)
+ class My%{keyword}Class
+ ^^^{keyword}^^^^^ %{msg}
+ end
+ SOURCE
+
+ expect_offense(<<~'SOURCE', keyword: keyword, msg: described_class::MSG)
+ class %{keyword}::MyClass < ApplicationRecord
+ ^{keyword}^^^^^^^^^ %{msg}
+ def some_method
+ true
+ end
+ end
+ SOURCE
+
+ expect_offense(<<~'SOURCE', keyword: keyword, msg: described_class::MSG)
+ class MyClass < %{keyword}::Class
+ ^^^^^^^ %{msg}
+ def some_method
+ true
+ end
+ end
+ SOURCE
+ end
+
+ it "flags a class definition with #{keyword} in root namespace module" do
+ expect_offense(<<~'SOURCE', keyword: keyword, msg: described_class::MSG)
+ module %{keyword}Module
+ class MyClass < ApplicationRecord
+ ^^^^^^^ %{msg}
+ def some_method
+ true
+ end
+ end
+ end
+ SOURCE
+ end
+
+ it 'flags a module in EE module' do
+ expect_offense(<<~'SOURCE', keyword: keyword, msg: described_class::MSG)
+ module EE
+ module %{keyword}Controller
+ ^{keyword}^^^^^^^^^^ %{msg}
+ def some_method
+ true
+ end
+ end
+ end
+ SOURCE
+ end
+ end
+
+ context 'when Search root namespace is used' do
+ it 'does not flag a class definition with Search as root namespace module' do
+ expect_no_offenses(<<~SOURCE, keyword: keyword)
+ module Search
+ class %{keyword}::MyClass < ApplicationRecord
+ def some_method
+ true
+ end
+ end
+ end
+ SOURCE
+ end
+
+ it 'does not a flag a class definition with Search as root namespace inline' do
+ expect_no_offenses(<<~SOURCE, keyword: keyword)
+ class Search::%{keyword}::MyClass < ApplicationRecord
+ def some_method
+ true
+ end
+ end
+ SOURCE
+ end
+
+ it 'does not a flag a class definition with Search as root namespace in EE' do
+ expect_no_offenses(<<~SOURCE, keyword: keyword)
+ module EE
+ module Search
+ class %{keyword}::MyClass < ApplicationRecord
+ def some_method
+ true
+ end
+ end
+ end
+ end
+ SOURCE
+ end
+ end
+ end
+end
diff --git a/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb
index 7b6578a0744..f41a441d6a6 100644
--- a/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb
+++ b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb
@@ -3,46 +3,95 @@
require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/sidekiq_load_balancing/worker_data_consistency'
-RSpec.describe RuboCop::Cop::SidekiqLoadBalancing::WorkerDataConsistency do
- before do
- allow(cop)
- .to receive(:in_worker?)
- .and_return(true)
- end
+RSpec.describe RuboCop::Cop::SidekiqLoadBalancing::WorkerDataConsistency, feature_category: :scalability do
+ context 'when data_consistency is not set' do
+ it 'adds an offense when not defining data_consistency' do
+ expect_offense(<<~CODE)
+ class SomeWorker
+ ^^^^^^^^^^^^^^^^ Should define data_consistency expectation.[...]
+ include ApplicationWorker
- it 'adds an offense when not defining data_consistency' do
- expect_offense(<<~CODE)
- class SomeWorker
- ^^^^^^^^^^^^^^^^ Should define data_consistency expectation.[...]
- include ApplicationWorker
-
- queue_namespace :pipeline_hooks
- feature_category :continuous_integration
- urgency :high
- end
- CODE
- end
+ queue_namespace :pipeline_hooks
+ feature_category :continuous_integration
+ urgency :high
+ end
+ CODE
+ end
+
+ it 'adds no offense when defining data_consistency' do
+ expect_no_offenses(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
- it 'adds no offense when defining data_consistency' do
- expect_no_offenses(<<~CODE)
- class SomeWorker
- include ApplicationWorker
-
- queue_namespace :pipeline_hooks
- feature_category :continuous_integration
- data_consistency :delayed
- urgency :high
- end
- CODE
+ queue_namespace :pipeline_hooks
+ feature_category :continuous_integration
+ data_consistency :delayed
+ urgency :high
+ end
+ CODE
+ end
+
+ it 'adds no offense when worker is not an ApplicationWorker' do
+ expect_no_offenses(<<~CODE)
+ class SomeWorker
+ queue_namespace :pipeline_hooks
+ feature_category :continuous_integration
+ urgency :high
+ end
+ CODE
+ end
end
- it 'adds no offense when worker is not an ApplicationWorker' do
- expect_no_offenses(<<~CODE)
- class SomeWorker
- queue_namespace :pipeline_hooks
- feature_category :continuous_integration
- urgency :high
- end
- CODE
+ context 'when data_consistency set to :always' do
+ it 'adds an offense when using `always` data_consistency' do
+ expect_offense(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+ data_consistency :always
+ ^^^^^^^ Refrain from using `:always` if possible.[...]
+
+ queue_namespace :pipeline_hooks
+ feature_category :continuous_integration
+ urgency :high
+ end
+ CODE
+ end
+
+ it 'adds no offense when using `sticky` data_consistency' do
+ expect_no_offenses(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :sticky
+ queue_namespace :pipeline_hooks
+ feature_category :continuous_integration
+ urgency :high
+ end
+ CODE
+ end
+
+ it 'adds no offense when using `delayed` data_consistency' do
+ expect_no_offenses(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :delayed
+ queue_namespace :pipeline_hooks
+ feature_category :continuous_integration
+ urgency :high
+ end
+ CODE
+ end
+
+ it 'adds no offense when worker is not an ApplicationWorker' do
+ expect_no_offenses(<<~CODE)
+ class SomeWorker
+ data_consistency :always
+ queue_namespace :pipeline_hooks
+ feature_category :continuous_integration
+ urgency :high
+ end
+ CODE
+ end
end
end
diff --git a/spec/scripts/api/create_merge_request_discussion_spec.rb b/spec/scripts/api/create_merge_request_discussion_spec.rb
new file mode 100644
index 00000000000..3867f6532a9
--- /dev/null
+++ b/spec/scripts/api/create_merge_request_discussion_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../scripts/api/create_merge_request_discussion'
+
+RSpec.describe CreateMergeRequestDiscussion, feature_category: :tooling do
+ describe '#execute' do
+ let(:project_id) { 12345 }
+ let(:iid) { 1 }
+ let(:content) { 'test123' }
+
+ let(:options) do
+ {
+ api_token: 'token',
+ endpoint: 'https://example.gitlab.com',
+ project: project_id,
+ merge_request: {
+ 'iid' => iid
+ }
+ }
+ end
+
+ subject { described_class.new(options).execute(content) }
+
+ it 'requests commit_merge_requests from the gitlab client' do
+ expected_result = true
+ client = double('Gitlab::Client') # rubocop:disable RSpec/VerifiedDoubles
+
+ expect(Gitlab).to receive(:client)
+ .with(endpoint: options[:endpoint], private_token: options[:api_token])
+ .and_return(client)
+
+ expect(client).to receive(:create_merge_request_discussion).with(
+ project_id, iid, body: content
+ ).and_return(expected_result)
+
+ expect(subject).to eq(expected_result)
+ end
+ end
+end
diff --git a/spec/scripts/api/get_package_and_test_job_spec.rb b/spec/scripts/api/get_package_and_test_job_spec.rb
new file mode 100644
index 00000000000..aa8f288c255
--- /dev/null
+++ b/spec/scripts/api/get_package_and_test_job_spec.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+# rubocop:disable RSpec/VerifiedDoubles
+
+require 'fast_spec_helper'
+require_relative '../../../scripts/api/get_package_and_test_job'
+
+RSpec.describe GetPackageAndTestJob, feature_category: :tooling do
+ describe '#execute' do
+ let(:options) do
+ {
+ api_token: 'token',
+ endpoint: 'https://example.gitlab.com',
+ project: 12345,
+ pipeline_id: 1
+ }
+ end
+
+ let(:client) { double('Gitlab::Client') }
+ let(:client_response) { double('Gitlab::ClientResponse') }
+ let(:bridge_status) { 'success' }
+
+ let(:bridges_response) do
+ [
+ double(:bridge, id: 1, name: 'foo'),
+ double(:bridge, id: 2, name: 'bar'),
+ double(
+ :bridge,
+ id: 3,
+ name: 'e2e:package-and-test-ee',
+ downstream_pipeline: double(:downstream_pipeline, id: 1),
+ status: bridge_status
+ )
+ ]
+ end
+
+ let(:detailed_status_label) { 'passed with warnings' }
+
+ let(:package_and_test_pipeline) do
+ double(
+ :pipeline,
+ id: 1,
+ name: 'e2e:package-and-test-ee',
+ detailed_status: double(
+ :detailed_status,
+ text: 'passed',
+ label: detailed_status_label
+ )
+ )
+ end
+
+ before do
+ allow(Gitlab)
+ .to receive(:client)
+ .and_return(client)
+
+ allow(client)
+ .to receive(:pipeline_bridges)
+ .and_return(double(auto_paginate: bridges_response))
+
+ allow(client)
+ .to receive(:pipeline)
+ .and_return(package_and_test_pipeline)
+ end
+
+ subject { described_class.new(options).execute }
+
+ it 'returns a package-and-test pipeline that passed with warnings' do
+ expect(subject).to eq(package_and_test_pipeline)
+ end
+
+ context 'when the bridge can not be found' do
+ let(:bridges_response) { [] }
+
+ it 'returns nothing' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when the downstream pipeline can not be found' do
+ let(:bridges_response) do
+ [
+ double(:bridge, id: 1, name: 'foo'),
+ double(:bridge, id: 2, name: 'bar'),
+ double(
+ :bridge,
+ id: 3,
+ name: 'e2e:package-and-test-ee',
+ downstream_pipeline: nil
+ )
+ ]
+ end
+
+ it 'returns nothing' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when the bridge fails' do
+ let(:bridge_status) { 'failed' }
+
+ it 'returns the downstream_pipeline' do
+ expect(subject).to eq(package_and_test_pipeline)
+ end
+ end
+
+ context 'when the package-and-test can not be found' do
+ let(:package_and_test_pipeline) { nil }
+
+ it 'returns nothing' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when the package-and-test does not include a detailed status' do
+ let(:package_and_test_pipeline) do
+ double(
+ :pipeline,
+ name: 'e2e:package-and-test-ee',
+ detailed_status: nil
+ )
+ end
+
+ it 'returns nothing' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when the package-and-test succeeds' do
+ let(:detailed_status_label) { 'passed' }
+
+ it 'returns nothing' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when the package-and-test is canceled' do
+ let(:detailed_status_label) { 'canceled' }
+
+ it 'returns a failed package-and-test pipeline' do
+ expect(subject).to eq(package_and_test_pipeline)
+ end
+ end
+ end
+end
+
+# rubocop:enable RSpec/VerifiedDoubles
diff --git a/spec/scripts/create_pipeline_failure_incident_spec.rb b/spec/scripts/create_pipeline_failure_incident_spec.rb
deleted file mode 100644
index 8549cec1b12..00000000000
--- a/spec/scripts/create_pipeline_failure_incident_spec.rb
+++ /dev/null
@@ -1,120 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-require_relative '../../scripts/create-pipeline-failure-incident'
-require_relative '../support/helpers/stub_env'
-
-RSpec.describe CreatePipelineFailureIncident, feature_category: :tooling do
- include StubENV
-
- describe '#execute' do
- let(:create_issue) { instance_double(CreateIssue) }
- let(:issue) { double('Issue', iid: 1) } # rubocop:disable RSpec/VerifiedDoubles
- let(:create_issue_discussion) { instance_double(CreateIssueDiscussion, execute: true) }
- let(:failed_jobs) { instance_double(PipelineFailedJobs, execute: []) }
-
- let(:options) do
- {
- project: 1234,
- api_token: 'asdf1234'
- }
- end
-
- let(:issue_params) do
- {
- issue_type: 'incident',
- title: title,
- description: description,
- labels: incident_labels
- }
- end
-
- subject { described_class.new(options).execute }
-
- before do
- stub_env(
- 'CI_COMMIT_SHA' => 'bfcd2b9b5cad0b889494ce830697392c8ca11257',
- 'CI_PROJECT_PATH' => 'gitlab.com/gitlab-org/gitlab',
- 'CI_PROJECT_NAME' => 'gitlab',
- 'GITLAB_USER_ID' => '1111',
- 'CI_PROJECT_ID' => '13083',
- 'CI_PIPELINE_ID' => '1234567',
- 'CI_PIPELINE_URL' => 'https://gitlab.com/gitlab-org/gitlab/-/pipelines/1234567',
- 'CI_PROJECT_URL' => 'https://gitlab.com/gitlab-org/gitlab',
- 'CI_PIPELINE_CREATED_AT' => '2023-01-24 00:00:00',
- 'CI_COMMIT_TITLE' => 'Commit title',
- 'CI_PIPELINE_SOURCE' => 'push',
- 'GITLAB_USER_NAME' => 'Foo User',
- 'PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE' => 'asdf1234',
- 'CI_SERVER_URL' => 'https://gitlab.com',
- 'GITLAB_USER_LOGIN' => 'foo'
- )
- end
-
- shared_examples 'creating an issue' do
- it 'successfully creates an issue' do
- allow(PipelineFailedJobs).to receive(:new)
- .with(API::DEFAULT_OPTIONS.merge(exclude_allowed_to_fail_jobs: true))
- .and_return(failed_jobs)
-
- expect(CreateIssue).to receive(:new)
- .with(project: options[:project], api_token: options[:api_token])
- .and_return(create_issue)
-
- expect(CreateIssueDiscussion).to receive(:new)
- .with(project: options[:project], api_token: options[:api_token])
- .and_return(create_issue_discussion).twice
-
- expect(create_issue).to receive(:execute)
- .with(issue_params).and_return(issue)
-
- expect(subject).to eq(issue)
- end
- end
-
- context 'when stable branch' do
- let(:incident_labels) { ['release-blocker'] }
- let(:title) { /broken `15-6-stable-ee`/ }
- let(:description) { /A broken stable branch prevents patch releases/ }
-
- let(:commit_merge_request) do
- {
- 'author' => {
- 'id' => '2'
- },
- 'title' => 'foo',
- 'web_url' => 'https://gitlab.com/test'
- }
- end
-
- let(:merge_request) { instance_double(CommitMergeRequests, execute: [commit_merge_request]) }
- let(:issue_params) { super().merge(assignee_ids: [1111, 2]) }
-
- before do
- stub_env(
- 'CI_COMMIT_REF_NAME' => '15-6-stable-ee'
- )
-
- allow(CommitMergeRequests).to receive(:new)
- .with(API::DEFAULT_OPTIONS.merge(sha: ENV['CI_COMMIT_SHA']))
- .and_return(merge_request)
- end
-
- it_behaves_like 'creating an issue'
- end
-
- context 'when other branch' do
- let(:incident_labels) { ['Engineering Productivity', 'master-broken::undetermined', 'master:broken'] }
- let(:title) { /broken `master`/ }
- let(:description) { /Follow the \[Broken `master` handbook guide\]/ }
-
- before do
- stub_env(
- 'CI_COMMIT_REF_NAME' => 'master'
- )
- end
-
- it_behaves_like 'creating an issue'
- end
- end
-end
diff --git a/spec/scripts/database/schema_validator_spec.rb b/spec/scripts/database/schema_validator_spec.rb
new file mode 100644
index 00000000000..13be8e291da
--- /dev/null
+++ b/spec/scripts/database/schema_validator_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+require_relative '../../../scripts/database/schema_validator'
+
+RSpec.describe SchemaValidator, feature_category: :database do
+ subject(:validator) { described_class.new }
+
+ describe "#validate!" do
+ before do
+ allow(validator).to receive(:committed_migrations).and_return(committed_migrations)
+ allow(validator).to receive(:run).and_return(schema_changes)
+ end
+
+ context 'when schema changes are introduced without migrations' do
+ let(:committed_migrations) { [] }
+ let(:schema_changes) { 'db/structure.sql' }
+
+ it 'terminates the execution' do
+ expect { validator.validate! }.to raise_error(SystemExit)
+ end
+ end
+
+ context 'when schema changes are introduced with migrations' do
+ let(:committed_migrations) { ['20211006103122_my_migration.rb'] }
+ let(:schema_changes) { 'db/structure.sql' }
+ let(:command) { 'git diff db/structure.sql -- db/structure.sql' }
+ let(:base_message) { 'db/structure.sql was changed, and no migrations were added' }
+
+ before do
+ allow(validator).to receive(:die)
+ end
+
+ it 'skips schema validations' do
+ expect(validator.validate!).to be_nil
+ end
+ end
+
+ context 'when skipping validations through ENV variable' do
+ let(:committed_migrations) { [] }
+ let(:schema_changes) { 'db/structure.sql' }
+
+ before do
+ stub_env('ALLOW_SCHEMA_CHANGES', true)
+ end
+
+ it 'skips schema validations' do
+ expect(validator.validate!).to be_nil
+ end
+ end
+
+ context 'when skipping validations through commit message' do
+ let(:committed_migrations) { [] }
+ let(:schema_changes) { 'db/structure.sql' }
+ let(:commit_message) { "Changes db/strucure.sql file\nskip-db-structure-check" }
+
+ before do
+ allow(validator).to receive(:run).and_return(commit_message)
+ end
+
+ it 'skips schema validations' do
+ expect(validator.validate!).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/scripts/failed_tests_spec.rb b/spec/scripts/failed_tests_spec.rb
index ce0ec66cdb6..c9fe6eecd11 100644
--- a/spec/scripts/failed_tests_spec.rb
+++ b/spec/scripts/failed_tests_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe FailedTests do
'suites' => [
{
'failed_count' => 1,
- 'name' => 'rspec unit pg12 10/12',
+ 'name' => 'rspec unit pg13 10/12',
'test_cases' => [
{
'status' => 'failed',
@@ -23,7 +23,7 @@ RSpec.describe FailedTests do
},
{
'failed_count' => 1,
- 'name' => 'rspec-ee unit pg12',
+ 'name' => 'rspec-ee unit pg13',
'test_cases' => [
{
'status' => 'failed',
@@ -33,7 +33,7 @@ RSpec.describe FailedTests do
},
{
'failed_count' => 1,
- 'name' => 'rspec unit pg13 10/12',
+ 'name' => 'rspec unit pg14 10/12',
'test_cases' => [
{
'status' => 'failed',
diff --git a/spec/scripts/generate_failed_package_and_test_mr_message_spec.rb b/spec/scripts/generate_failed_package_and_test_mr_message_spec.rb
new file mode 100644
index 00000000000..79e63c95f65
--- /dev/null
+++ b/spec/scripts/generate_failed_package_and_test_mr_message_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../scripts/generate-failed-package-and-test-mr-message'
+require_relative '../support/helpers/stub_env'
+
+RSpec.describe GenerateFailedPackageAndTestMrMessage, feature_category: :tooling do
+ include StubENV
+
+ describe '#execute' do
+ let(:options) do
+ {
+ project: 1234,
+ api_token: 'asdf1234'
+ }
+ end
+
+ let(:commit_merge_request) do
+ {
+ 'author' => {
+ 'id' => '2',
+ 'username' => 'test_user'
+ }
+ }
+ end
+
+ let(:package_and_test_job) do
+ { 'web_url' => 'http://example.com' }
+ end
+
+ let(:merge_request) { instance_double(CommitMergeRequests, execute: [commit_merge_request]) }
+ let(:content) { /The `e2e:package-and-test-ee` job has failed./ }
+ let(:merge_request_discussion_client) { instance_double(CreateMergeRequestDiscussion, execute: true) }
+ let(:package_and_test_job_client) { instance_double(GetPackageAndTestJob, execute: package_and_test_job) }
+
+ subject { described_class.new(options).execute }
+
+ before do
+ stub_env(
+ 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA' => 'bfcd2b9b5cad0b889494ce830697392c8ca11257',
+ 'CI_PROJECT_ID' => '13083',
+ 'CI_PIPELINE_ID' => '1234567',
+ 'CI_PIPELINE_URL' => 'https://gitlab.com/gitlab-org/gitlab/-/pipelines/1234567',
+ 'PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE' => 'asdf1234'
+ )
+
+ allow(GetPackageAndTestJob).to receive(:new)
+ .with(API::DEFAULT_OPTIONS)
+ .and_return(package_and_test_job_client)
+ end
+
+ context 'when package-and-test fails' do
+ before do
+ allow(CommitMergeRequests).to receive(:new)
+ .with(API::DEFAULT_OPTIONS.merge(sha: ENV['CI_MERGE_REQUEST_SOURCE_BRANCH_SHA']))
+ .and_return(merge_request)
+ end
+
+ it 'successfully creates a discussion' do
+ expect(CreateMergeRequestDiscussion).to receive(:new)
+ .with(API::DEFAULT_OPTIONS.merge(merge_request: commit_merge_request))
+ .and_return(merge_request_discussion_client)
+
+ expect(merge_request_discussion_client).to receive(:execute).with(content)
+
+ expect(subject).to eq(true)
+ end
+ end
+
+ context 'when package-and-test is did not fail' do
+ let(:package_and_test_job_client) { instance_double(GetPackageAndTestJob, execute: nil) }
+
+ it 'does not add a discussion' do
+ expect(CreateMergeRequestDiscussion).not_to receive(:new)
+ expect(subject).to eq(nil)
+ end
+ end
+ end
+end
diff --git a/spec/scripts/generate_rspec_pipeline_spec.rb b/spec/scripts/generate_rspec_pipeline_spec.rb
new file mode 100644
index 00000000000..91b5739cf63
--- /dev/null
+++ b/spec/scripts/generate_rspec_pipeline_spec.rb
@@ -0,0 +1,253 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'tempfile'
+
+require_relative '../../scripts/generate_rspec_pipeline'
+
+RSpec.describe GenerateRspecPipeline, :silence_stdout, feature_category: :tooling do
+ describe '#generate!' do
+ let!(:rspec_files) { Tempfile.new(['rspec_files_path', '.txt']) }
+ let(:rspec_files_content) do
+ "spec/migrations/a_spec.rb spec/migrations/b_spec.rb " \
+ "spec/lib/gitlab/background_migration/a_spec.rb spec/lib/gitlab/background_migration/b_spec.rb " \
+ "spec/models/a_spec.rb spec/models/b_spec.rb " \
+ "spec/controllers/a_spec.rb spec/controllers/b_spec.rb " \
+ "spec/features/a_spec.rb spec/features/b_spec.rb " \
+ "ee/spec/features/a_spec.rb"
+ end
+
+ let(:pipeline_template) { Tempfile.new(['pipeline_template', '.yml.erb']) }
+ let(:pipeline_template_content) do
+ <<~YAML
+ <% if test_suite_prefix.nil? && rspec_files_per_test_level[:migration][:files].size > 0 %>
+ rspec migration:
+ <% if rspec_files_per_test_level[:migration][:parallelization] > 1 %>
+ parallel: <%= rspec_files_per_test_level[:migration][:parallelization] %>
+ <% end %>
+ <% end %>
+ <% if test_suite_prefix.nil? && rspec_files_per_test_level[:background_migration][:files].size > 0 %>
+ rspec background_migration:
+ <% if rspec_files_per_test_level[:background_migration][:parallelization] > 1 %>
+ parallel: <%= rspec_files_per_test_level[:background_migration][:parallelization] %>
+ <% end %>
+ <% end %>
+ <% if test_suite_prefix.nil? && rspec_files_per_test_level[:unit][:files].size > 0 %>
+ rspec unit:
+ <% if rspec_files_per_test_level[:unit][:parallelization] > 1 %>
+ parallel: <%= rspec_files_per_test_level[:unit][:parallelization] %>
+ <% end %>
+ <% end %>
+ <% if test_suite_prefix.nil? && rspec_files_per_test_level[:integration][:files].size > 0 %>
+ rspec integration:
+ <% if rspec_files_per_test_level[:integration][:parallelization] > 1 %>
+ parallel: <%= rspec_files_per_test_level[:integration][:parallelization] %>
+ <% end %>
+ <% end %>
+ <% if test_suite_prefix.nil? && rspec_files_per_test_level[:system][:files].size > 0 %>
+ rspec system:
+ <% if rspec_files_per_test_level[:system][:parallelization] > 1 %>
+ parallel: <%= rspec_files_per_test_level[:system][:parallelization] %>
+ <% end %>
+ <% end %>
+ <% if test_suite_prefix == 'ee/' && rspec_files_per_test_level[:system][:files].size > 0 %>
+ rspec-ee system:
+ <% if rspec_files_per_test_level[:system][:parallelization] > 1 %>
+ parallel: <%= rspec_files_per_test_level[:system][:parallelization] %>
+ <% end %>
+ <% end %>
+ YAML
+ end
+
+ let(:knapsack_report) { Tempfile.new(['knapsack_report', '.json']) }
+ let(:knapsack_report_content) do
+ <<~JSON
+ {
+ "spec/migrations/a_spec.rb": 360.3,
+ "spec/migrations/b_spec.rb": 180.1,
+ "spec/lib/gitlab/background_migration/a_spec.rb": 60.5,
+ "spec/lib/gitlab/background_migration/b_spec.rb": 180.3,
+ "spec/models/a_spec.rb": 360.2,
+ "spec/models/b_spec.rb": 180.6,
+ "spec/controllers/a_spec.rb": 60.2,
+ "spec/controllers/ab_spec.rb": 180.4,
+ "spec/features/a_spec.rb": 360.1,
+ "spec/features/b_spec.rb": 180.5,
+ "ee/spec/features/a_spec.rb": 180.5
+ }
+ JSON
+ end
+
+ around do |example|
+ rspec_files.write(rspec_files_content)
+ rspec_files.rewind
+ pipeline_template.write(pipeline_template_content)
+ pipeline_template.rewind
+ knapsack_report.write(knapsack_report_content)
+ knapsack_report.rewind
+ example.run
+ ensure
+ rspec_files.close
+ rspec_files.unlink
+ pipeline_template.close
+ pipeline_template.unlink
+ knapsack_report.close
+ knapsack_report.unlink
+ end
+
+ context 'when rspec_files and pipeline_template_path exists' do
+ subject do
+ described_class.new(
+ rspec_files_path: rspec_files.path,
+ pipeline_template_path: pipeline_template.path
+ )
+ end
+
+ it 'generates the pipeline config with default parallelization' do
+ subject.generate!
+
+ expect(File.read("#{pipeline_template.path}.yml"))
+ .to eq(
+ "rspec migration:\nrspec background_migration:\nrspec unit:\n" \
+ "rspec integration:\nrspec system:"
+ )
+ end
+
+ context 'when parallelization > 0' do
+ before do
+ stub_const("#{described_class}::DEFAULT_AVERAGE_TEST_FILE_DURATION_IN_SECONDS", 360)
+ end
+
+ it 'generates the pipeline config' do
+ subject.generate!
+
+ expect(File.read("#{pipeline_template.path}.yml"))
+ .to eq(
+ "rspec migration:\n parallel: 2\nrspec background_migration:\n parallel: 2\n" \
+ "rspec unit:\n parallel: 2\nrspec integration:\n parallel: 2\n" \
+ "rspec system:\n parallel: 2"
+ )
+ end
+ end
+
+ context 'when parallelization > MAX_NODES_COUNT' do
+ let(:rspec_files_content) do
+ Array.new(51) { |i| "spec/migrations/#{i}_spec.rb" }.join(' ')
+ end
+
+ before do
+ stub_const(
+ "#{described_class}::DEFAULT_AVERAGE_TEST_FILE_DURATION_IN_SECONDS",
+ described_class::OPTIMAL_TEST_JOB_DURATION_IN_SECONDS
+ )
+ end
+
+ it 'generates the pipeline config with max parallelization of 50' do
+ subject.generate!
+
+ expect(File.read("#{pipeline_template.path}.yml")).to eq("rspec migration:\n parallel: 50")
+ end
+ end
+ end
+
+ context 'when knapsack_report_path is given' do
+ subject do
+ described_class.new(
+ rspec_files_path: rspec_files.path,
+ pipeline_template_path: pipeline_template.path,
+ knapsack_report_path: knapsack_report.path
+ )
+ end
+
+ it 'generates the pipeline config with parallelization based on Knapsack' do
+ subject.generate!
+
+ expect(File.read("#{pipeline_template.path}.yml"))
+ .to eq(
+ "rspec migration:\n parallel: 2\nrspec background_migration:\n" \
+ "rspec unit:\n parallel: 2\nrspec integration:\n" \
+ "rspec system:\n parallel: 2"
+ )
+ end
+
+ context 'and Knapsack report does not contain valid JSON' do
+ let(:knapsack_report_content) { "#{super()}," }
+
+ it 'generates the pipeline config with default parallelization' do
+ subject.generate!
+
+ expect(File.read("#{pipeline_template.path}.yml"))
+ .to eq(
+ "rspec migration:\nrspec background_migration:\nrspec unit:\n" \
+ "rspec integration:\nrspec system:"
+ )
+ end
+ end
+ end
+
+ context 'when test_suite_prefix is given' do
+ subject do
+ described_class.new(
+ rspec_files_path: rspec_files.path,
+ pipeline_template_path: pipeline_template.path,
+ knapsack_report_path: knapsack_report.path,
+ test_suite_prefix: 'ee/'
+ )
+ end
+
+ it 'generates the pipeline config based on the test_suite_prefix' do
+ subject.generate!
+
+ expect(File.read("#{pipeline_template.path}.yml"))
+ .to eq("rspec-ee system:")
+ end
+ end
+
+ context 'when generated_pipeline_path is given' do
+ let(:custom_pipeline_filename) { Tempfile.new(['custom_pipeline_filename', '.yml']) }
+
+ around do |example|
+ example.run
+ ensure
+ custom_pipeline_filename.close
+ custom_pipeline_filename.unlink
+ end
+
+ subject do
+ described_class.new(
+ rspec_files_path: rspec_files.path,
+ pipeline_template_path: pipeline_template.path,
+ generated_pipeline_path: custom_pipeline_filename.path
+ )
+ end
+
+ it 'writes the pipeline config in the given generated_pipeline_path' do
+ subject.generate!
+
+ expect(File.read(custom_pipeline_filename.path))
+ .to eq(
+ "rspec migration:\nrspec background_migration:\nrspec unit:\n" \
+ "rspec integration:\nrspec system:"
+ )
+ end
+ end
+
+ context 'when rspec_files does not exist' do
+ subject { described_class.new(rspec_files_path: nil, pipeline_template_path: pipeline_template.path) }
+
+ it 'generates the pipeline config using the no-op template' do
+ subject.generate!
+
+ expect(File.read("#{pipeline_template.path}.yml")).to include("no-op:")
+ end
+ end
+
+ context 'when pipeline_template_path does not exist' do
+ subject { described_class.new(rspec_files_path: rspec_files.path, pipeline_template_path: nil) }
+
+ it 'generates the pipeline config using the no-op template' do
+ expect { subject }.to raise_error(ArgumentError)
+ end
+ end
+ end
+end
diff --git a/spec/scripts/lib/glfm/shared_spec.rb b/spec/scripts/lib/glfm/shared_spec.rb
index 3717c7ce18f..d407bd49d75 100644
--- a/spec/scripts/lib/glfm/shared_spec.rb
+++ b/spec/scripts/lib/glfm/shared_spec.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
+require 'tmpdir'
require_relative '../../../../scripts/lib/glfm/shared'
-RSpec.describe Glfm::Shared do
+RSpec.describe Glfm::Shared, feature_category: :team_planning do
let(:instance) do
Class.new do
include Glfm::Shared
diff --git a/spec/scripts/lib/glfm/update_example_snapshots_spec.rb b/spec/scripts/lib/glfm/update_example_snapshots_spec.rb
index bfc25877f98..f2194f46ab4 100644
--- a/spec/scripts/lib/glfm/update_example_snapshots_spec.rb
+++ b/spec/scripts/lib/glfm/update_example_snapshots_spec.rb
@@ -818,6 +818,7 @@ RSpec.describe Glfm::UpdateExampleSnapshots, '#process', feature_category: :team
"href": "/uploads/groups-test-file",
"target": "_blank",
"class": null,
+ "uploading": false,
"title": null,
"canonicalSrc": "/uploads/groups-test-file",
"isReference": false
@@ -846,6 +847,7 @@ RSpec.describe Glfm::UpdateExampleSnapshots, '#process', feature_category: :team
"href": "projects-test-file",
"target": "_blank",
"class": null,
+ "uploading": false,
"title": null,
"canonicalSrc": "projects-test-file",
"isReference": false
@@ -904,6 +906,7 @@ RSpec.describe Glfm::UpdateExampleSnapshots, '#process', feature_category: :team
"href": "project-wikis-test-file",
"target": "_blank",
"class": null,
+ "uploading": false,
"title": null,
"canonicalSrc": "project-wikis-test-file",
"isReference": false
diff --git a/spec/scripts/pipeline/create_test_failure_issues_spec.rb b/spec/scripts/pipeline/create_test_failure_issues_spec.rb
new file mode 100644
index 00000000000..2a5910f5238
--- /dev/null
+++ b/spec/scripts/pipeline/create_test_failure_issues_spec.rb
@@ -0,0 +1,188 @@
+# frozen_string_literal: true
+
+# rubocop:disable RSpec/VerifiedDoubles
+
+require 'fast_spec_helper'
+require 'active_support/testing/time_helpers'
+require 'rspec-parameterized'
+
+require_relative '../../../scripts/pipeline/create_test_failure_issues'
+
+RSpec.describe CreateTestFailureIssues, feature_category: :tooling do
+ describe CreateTestFailureIssue do
+ include ActiveSupport::Testing::TimeHelpers
+
+ let(:server_host) { 'example.com' }
+ let(:project_path) { 'group/project' }
+
+ let(:env) do
+ {
+ 'CI_SERVER_HOST' => server_host,
+ 'CI_PROJECT_PATH' => project_path,
+ 'CI_PIPELINE_URL' => "https://#{server_host}/#{project_path}/-/pipelines/1234"
+ }
+ end
+
+ let(:api_token) { 'api_token' }
+ let(:creator) { described_class.new(project: project_path, api_token: api_token) }
+ let(:test_name) { 'The test description' }
+ let(:test_file) { 'spec/path/to/file_spec.rb' }
+ let(:test_file_content) do
+ <<~CONTENT
+ # comment
+
+ RSpec.describe Foo, feature_category: :source_code_management do
+ end
+
+ CONTENT
+ end
+
+ let(:test_file_stub) { double(read: test_file_content) }
+ let(:failed_test) do
+ {
+ 'name' => test_name,
+ 'file' => test_file,
+ 'job_url' => "https://#{server_host}/#{project_path}/-/jobs/5678"
+ }
+ end
+
+ let(:categories_mapping) do
+ {
+ 'source_code_management' => {
+ 'group' => 'source_code',
+ 'label' => 'Category:Source Code Management'
+ }
+ }
+ end
+
+ let(:groups_mapping) do
+ {
+ 'source_code' => {
+ 'label' => 'group::source_code'
+ }
+ }
+ end
+
+ let(:test_hash) { Digest::SHA256.hexdigest(failed_test['file'] + failed_test['name'])[0...12] }
+ let(:latest_format_issue_title) { "#{failed_test['file']} [test-hash:#{test_hash}]" }
+ let(:latest_format_issue_description) do
+ <<~DESCRIPTION
+ ### Test description
+
+ `#{failed_test['name']}`
+
+ ### Test file path
+
+ [`#{failed_test['file']}`](https://#{server_host}/#{project_path}/-/blob/master/#{failed_test['file']})
+
+ <!-- Don't add anything after the report list since it's updated automatically -->
+ ### Reports (1)
+
+ #{failed_test_report_line}
+ DESCRIPTION
+ end
+
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ before do
+ stub_env(env)
+ allow(creator).to receive(:puts)
+ end
+
+ describe '#upsert' do
+ let(:expected_search_payload) do
+ {
+ state: :opened,
+ search: test_hash,
+ in: :title,
+ per_page: 1
+ }
+ end
+
+ let(:find_issue_stub) { double('FindIssues') }
+ let(:issue_stub) { double('Issue', title: latest_format_issue_title, web_url: 'issue_web_url') }
+
+ let(:failed_test_report_line) do
+ "1. #{Time.new.utc.strftime('%F')}: #{failed_test['job_url']} (#{env['CI_PIPELINE_URL']})"
+ end
+
+ before do
+ allow(File).to receive(:open).and_call_original
+ allow(File).to receive(:open).with(File.expand_path(File.join('..', '..', '..', test_file), __dir__))
+ .and_return(test_file_stub)
+
+ allow(FindIssues).to receive(:new).with(project: project_path, api_token: api_token).and_return(find_issue_stub)
+
+ allow(creator).to receive(:categories_mapping).and_return(categories_mapping)
+ allow(creator).to receive(:groups_mapping).and_return(groups_mapping)
+ end
+
+ context 'when no issues are found' do
+ let(:create_issue_stub) { double('CreateIssue') }
+ let(:expected_create_payload) do
+ {
+ title: latest_format_issue_title,
+ description: latest_format_issue_description,
+ labels: described_class::DEFAULT_LABELS.map { |label| "wip-#{label}" } + [
+ "wip-#{categories_mapping['source_code_management']['label']}",
+ "wip-#{groups_mapping['source_code']['label']}"
+ ],
+ weight: 1
+ }
+ end
+
+ before do
+ allow(find_issue_stub).to receive(:execute).with(expected_search_payload).and_return([])
+ end
+
+ it 'calls CreateIssue#execute(payload)' do
+ expect(CreateIssue).to receive(:new).with(project: project_path, api_token: api_token)
+ .and_return(create_issue_stub)
+ expect(create_issue_stub).to receive(:execute).with(expected_create_payload).and_return(issue_stub)
+
+ creator.upsert(failed_test)
+ end
+ end
+
+ context 'when issues are found' do
+ let(:issue_stub) do
+ double('Issue', iid: 42, title: issue_title, description: issue_description, web_url: 'issue_web_url')
+ end
+
+ before do
+ allow(find_issue_stub).to receive(:execute).with(expected_search_payload).and_return([issue_stub])
+ end
+
+ # This shared example can be useful if we want to test migration to a new format in the future
+ shared_examples 'existing issue update' do
+ let(:update_issue_stub) { double('UpdateIssue') }
+ let(:expected_update_payload) do
+ {
+ description: latest_format_issue_description.sub(/^### Reports.*$/, '### Reports (2)') +
+ "\n#{failed_test_report_line}",
+ weight: 2
+ }
+ end
+
+ it 'calls UpdateIssue#execute(payload)' do
+ expect(UpdateIssue).to receive(:new).with(project: project_path, api_token: api_token)
+ .and_return(update_issue_stub)
+ expect(update_issue_stub).to receive(:execute).with(42, **expected_update_payload)
+
+ creator.upsert(failed_test)
+ end
+ end
+
+ context 'when issue already has the latest format' do
+ let(:issue_description) { latest_format_issue_description }
+ let(:issue_title) { latest_format_issue_title }
+
+ it_behaves_like 'existing issue update'
+ end
+ end
+ end
+ end
+end
+# rubocop:enable RSpec/VerifiedDoubles
diff --git a/spec/scripts/pipeline_test_report_builder_spec.rb b/spec/scripts/pipeline_test_report_builder_spec.rb
index e7529eb0d41..bee2a4a5835 100644
--- a/spec/scripts/pipeline_test_report_builder_spec.rb
+++ b/spec/scripts/pipeline_test_report_builder_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe PipelineTestReportBuilder, feature_category: :tooling do
let(:options) do
described_class::DEFAULT_OPTIONS.merge(
target_project: 'gitlab-org/gitlab',
+ current_pipeline_id: '42',
mr_id: '999',
instance_base_url: 'https://gitlab.com',
output_file_path: output_file_path
@@ -191,10 +192,14 @@ RSpec.describe PipelineTestReportBuilder, feature_category: :tooling do
context 'for latest pipeline' do
let(:failed_build_uri) { "#{latest_pipeline_url}/tests/suite.json?build_ids[]=#{failed_build_id}" }
+ let(:current_pipeline_uri) do
+ "#{options[:api_endpoint]}/projects/#{options[:target_project]}/pipelines/#{options[:current_pipeline_id]}"
+ end
subject { described_class.new(options.merge(pipeline_index: :latest)) }
it 'fetches builds from pipeline related to MR' do
+ expect(subject).to receive(:fetch).with(current_pipeline_uri).and_return(mr_pipelines[0])
expect(subject).to receive(:fetch).with(failed_build_uri).and_return(test_report_for_build)
subject.test_report_for_pipeline
diff --git a/spec/scripts/review_apps/automated_cleanup_spec.rb b/spec/scripts/review_apps/automated_cleanup_spec.rb
new file mode 100644
index 00000000000..a8b8353d2ef
--- /dev/null
+++ b/spec/scripts/review_apps/automated_cleanup_spec.rb
@@ -0,0 +1,262 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'time'
+require_relative '../../../scripts/review_apps/automated_cleanup'
+
+RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do
+ let(:instance) { described_class.new(options: options) }
+ let(:options) do
+ {
+ project_path: 'my-project-path',
+ gitlab_token: 'glpat-test-secret-token',
+ api_endpoint: 'gitlab.test/api/v4',
+ dry_run: dry_run
+ }
+ end
+
+ let(:kubernetes_client) { instance_double(Tooling::KubernetesClient) }
+ let(:helm_client) { instance_double(Tooling::Helm3Client) }
+ let(:gitlab_client) { double('GitLab') } # rubocop:disable RSpec/VerifiedDoubles
+ let(:dry_run) { false }
+ let(:now) { Time.now }
+ let(:one_day_ago) { (now - (1 * 24 * 3600)) }
+ let(:two_days_ago) { (now - (2 * 24 * 3600)) }
+ let(:three_days_ago) { (now - (3 * 24 * 3600)) }
+
+ before do
+ allow(instance).to receive(:gitlab).and_return(gitlab_client)
+ allow(Time).to receive(:now).and_return(now)
+ allow(Tooling::Helm3Client).to receive(:new).and_return(helm_client)
+ allow(Tooling::KubernetesClient).to receive(:new).and_return(kubernetes_client)
+
+ allow(kubernetes_client).to receive(:cleanup_namespaces_by_created_at)
+ end
+
+ shared_examples 'the days argument is an integer in the correct range' do
+ context 'when days is nil' do
+ let(:days) { nil }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error('days should be an integer between 1 and 365 inclusive! Got 0')
+ end
+ end
+
+ context 'when days is zero' do
+ let(:days) { 0 }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error('days should be an integer between 1 and 365 inclusive! Got 0')
+ end
+ end
+
+ context 'when days is above 365' do
+ let(:days) { 366 }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error('days should be an integer between 1 and 365 inclusive! Got 366')
+ end
+ end
+
+ context 'when days is a string' do
+ let(:days) { '10' }
+
+ it 'does not raise an error' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when days is a float' do
+ let(:days) { 3.0 }
+
+ it 'does not raise an error' do
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+
+ describe '.parse_args' do
+ subject { described_class.parse_args(argv) }
+
+ context 'when no arguments are provided' do
+ let(:argv) { %w[] }
+
+ it 'returns the default options' do
+ expect(subject).to eq(dry_run: false)
+ end
+ end
+
+ describe '--dry-run' do
+ context 'when no DRY_RUN variable is provided' do
+ let(:argv) { ['--dry-run='] }
+
+ # This is the default behavior of OptionParser.
+ # We should always pass an environment variable with a value, or not pass the flag at all.
+ it 'raises an error' do
+ expect { subject }.to raise_error(OptionParser::InvalidArgument, 'invalid argument: --dry-run=')
+ end
+ end
+
+ context 'when the DRY_RUN variable is not set to true' do
+ let(:argv) { %w[--dry-run=false] }
+
+ it 'returns the default options' do
+ expect(subject).to eq(dry_run: false)
+ end
+ end
+
+ context 'when the DRY_RUN variable is set to true' do
+ let(:argv) { %w[--dry-run=true] }
+
+ it 'returns the correct dry_run value' do
+ expect(subject).to eq(dry_run: true)
+ end
+ end
+
+ context 'when the short version of the flag is used' do
+ let(:argv) { %w[-d true] }
+
+ it 'returns the correct dry_run value' do
+ expect(subject).to eq(dry_run: true)
+ end
+ end
+ end
+ end
+
+ describe '#perform_stale_namespace_cleanup!' do
+ subject { instance.perform_stale_namespace_cleanup!(days: days) }
+
+ let(:days) { 2 }
+
+ it_behaves_like 'the days argument is an integer in the correct range'
+
+ it 'performs Kubernetes cleanup for review apps namespaces' do
+ expect(kubernetes_client).to receive(:cleanup_namespaces_by_created_at).with(created_before: two_days_ago)
+
+ subject
+ end
+
+ context 'when the dry-run flag is true' do
+ let(:dry_run) { true }
+
+ it 'does not delete anything' do
+ expect(kubernetes_client).not_to receive(:cleanup_namespaces_by_created_at)
+ end
+ end
+ end
+
+ describe '#perform_helm_releases_cleanup!' do
+ subject { instance.perform_helm_releases_cleanup!(days: days) }
+
+ let(:days) { 2 }
+ let(:helm_releases) { [] }
+
+ before do
+ allow(helm_client).to receive(:releases).and_return(helm_releases)
+
+ # Silence outputs to stdout
+ allow(instance).to receive(:puts)
+ end
+
+ shared_examples 'deletes the helm release' do
+ let(:releases_names) { helm_releases.map(&:name) }
+
+ before do
+ allow(helm_client).to receive(:delete)
+ allow(kubernetes_client).to receive(:delete_namespaces)
+ end
+
+ it 'deletes the helm release' do
+ expect(helm_client).to receive(:delete).with(release_name: releases_names)
+
+ subject
+ end
+
+ it 'deletes the associated k8s namespace' do
+ expect(kubernetes_client).to receive(:delete_namespaces).with(releases_names)
+
+ subject
+ end
+ end
+
+ shared_examples 'does not delete the helm release' do
+ it 'does not delete the helm release' do
+ expect(helm_client).not_to receive(:delete)
+
+ subject
+ end
+
+ it 'does not delete the associated k8s namespace' do
+ expect(kubernetes_client).not_to receive(:delete_namespaces)
+
+ subject
+ end
+ end
+
+ shared_examples 'does nothing on a dry run' do
+ it_behaves_like 'does not delete the helm release'
+ end
+
+ it_behaves_like 'the days argument is an integer in the correct range'
+
+ context 'when the helm release is not a review-app release' do
+ let(:helm_releases) do
+ [
+ Tooling::Helm3Client::Release.new(
+ name: 'review-apps', namespace: 'review-apps', revision: 1, status: 'success', updated: three_days_ago.to_s
+ )
+ ]
+ end
+
+ it_behaves_like 'does not delete the helm release'
+ end
+
+ context 'when the helm release is a review-app release' do
+ let(:helm_releases) do
+ [
+ Tooling::Helm3Client::Release.new(
+ name: 'review-test', namespace: 'review-test', revision: 1, status: status, updated: updated_at
+ )
+ ]
+ end
+
+ context 'when the helm release was deployed recently enough' do
+ let(:updated_at) { one_day_ago.to_s }
+
+ context 'when the helm release is in failed state' do
+ let(:status) { 'failed' }
+
+ it_behaves_like 'deletes the helm release'
+
+ context 'when the dry-run flag is true' do
+ let(:dry_run) { true }
+
+ it_behaves_like 'does nothing on a dry run'
+ end
+ end
+
+ context 'when the helm release is not in failed state' do
+ let(:status) { 'success' }
+
+ it_behaves_like 'does not delete the helm release'
+ end
+ end
+
+ context 'when the helm release was deployed a while ago' do
+ let(:updated_at) { three_days_ago.to_s }
+
+ context 'when the helm release is in failed state' do
+ let(:status) { 'failed' }
+
+ it_behaves_like 'deletes the helm release'
+ end
+
+ context 'when the helm release is not in failed state' do
+ let(:status) { 'success' }
+
+ it_behaves_like 'deletes the helm release'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/serializers/access_token_entity_base_spec.rb b/spec/serializers/access_token_entity_base_spec.rb
index e14a07a346a..8a92a53d0c1 100644
--- a/spec/serializers/access_token_entity_base_spec.rb
+++ b/spec/serializers/access_token_entity_base_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe AccessTokenEntityBase do
revoked: false,
created_at: token.created_at,
scopes: token.scopes,
- expires_at: nil,
+ expires_at: token.expires_at.iso8601,
expired: false,
expires_soon: false
))
diff --git a/spec/serializers/admin/abuse_report_details_entity_spec.rb b/spec/serializers/admin/abuse_report_details_entity_spec.rb
new file mode 100644
index 00000000000..0e5e6a62ce1
--- /dev/null
+++ b/spec/serializers/admin/abuse_report_details_entity_spec.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::AbuseReportDetailsEntity, feature_category: :insider_threat do
+ include Gitlab::Routing
+
+ let(:report) { build_stubbed(:abuse_report) }
+ let(:user) { report.user }
+ let(:reporter) { report.reporter }
+ let!(:other_report) { create(:abuse_report, user: user) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+
+ let(:entity) do
+ described_class.new(report)
+ end
+
+ describe '#as_json' do
+ subject(:entity_hash) { entity.as_json }
+
+ it 'exposes correct attributes' do
+ expect(entity_hash.keys).to include(
+ :user,
+ :reporter,
+ :report,
+ :actions
+ )
+ end
+
+ it 'correctly exposes `user`', :aggregate_failures do
+ user_hash = entity_hash[:user]
+
+ expect(user_hash.keys).to match_array([
+ :name,
+ :username,
+ :avatar_url,
+ :email,
+ :created_at,
+ :last_activity_on,
+ :path,
+ :admin_path,
+ :plan,
+ :verification_state,
+ :other_reports,
+ :most_used_ip,
+ :last_sign_in_ip,
+ :snippets_count,
+ :groups_count,
+ :notes_count
+ ])
+
+ expect(user_hash[:verification_state].keys).to match_array([
+ :email,
+ :phone,
+ :credit_card
+ ])
+
+ expect(user_hash[:other_reports][0].keys).to match_array([
+ :created_at,
+ :category,
+ :report_path
+ ])
+ end
+
+ describe 'users plan' do
+ it 'does not include the plan' do
+ expect(entity_hash[:user][:plan]).to be_nil
+ end
+
+ context 'when on .com', :saas, if: Gitlab.ee? do
+ before do
+ stub_ee_application_setting(should_check_namespace_plan: true)
+ create(:namespace_with_plan, plan: :bronze_plan, owner: user) # rubocop:disable RSpec/FactoryBot/AvoidCreate
+ end
+
+ it 'includes the plan' do
+ expect(entity_hash[:user][:plan]).to eq('Bronze')
+ end
+ end
+ end
+
+ describe 'users credit card' do
+ let(:credit_card_hash) { entity_hash[:user][:credit_card] }
+
+ context 'when the user has no verified credit card' do
+ it 'does not expose the credit card' do
+ expect(credit_card_hash).to be_nil
+ end
+ end
+
+ context 'when the user does have a verified credit card' do
+ let!(:credit_card) { build_stubbed(:credit_card_validation, user: user) }
+
+ it 'exposes the credit card' do
+ expect(credit_card_hash.keys).to match_array([
+ :name,
+ :similar_records_count,
+ :card_matches_link
+ ])
+ end
+
+ context 'when not on ee', unless: Gitlab.ee? do
+ it 'does not include the path to the admin card matches page' do
+ expect(credit_card_hash[:card_matches_link]).to be_nil
+ end
+ end
+
+ context 'when on ee', if: Gitlab.ee? do
+ it 'includes the path to the admin card matches page' do
+ expect(credit_card_hash[:card_matches_link]).not_to be_nil
+ end
+ end
+ end
+ end
+
+ it 'correctly exposes `reporter`' do
+ reporter_hash = entity_hash[:reporter]
+
+ expect(reporter_hash.keys).to match_array([
+ :name,
+ :username,
+ :avatar_url,
+ :path
+ ])
+ end
+
+ it 'correctly exposes `report`' do
+ report_hash = entity_hash[:report]
+
+ expect(report_hash.keys).to match_array([
+ :message,
+ :reported_at,
+ :category,
+ :type,
+ :content,
+ :url,
+ :screenshot
+ ])
+ end
+
+ it 'correctly exposes `actions`', :aggregate_failures do
+ actions_hash = entity_hash[:actions]
+
+ expect(actions_hash.keys).to match_array([
+ :user_blocked,
+ :block_user_path,
+ :remove_user_and_report_path,
+ :remove_report_path,
+ :reported_user,
+ :redirect_path
+ ])
+
+ expect(actions_hash[:reported_user].keys).to match_array([
+ :name,
+ :created_at
+ ])
+ end
+ end
+end
diff --git a/spec/serializers/admin/abuse_report_details_serializer_spec.rb b/spec/serializers/admin/abuse_report_details_serializer_spec.rb
new file mode 100644
index 00000000000..f22d92a1763
--- /dev/null
+++ b/spec/serializers/admin/abuse_report_details_serializer_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::AbuseReportDetailsSerializer, feature_category: :insider_threat do
+ let_it_be(:resource) { build_stubbed(:abuse_report) }
+
+ subject { described_class.new.represent(resource).keys }
+
+ describe '#represent' do
+ it 'serializes an abuse report' do
+ is_expected.to include(
+ :user,
+ :reporter,
+ :report,
+ :actions
+ )
+ end
+ end
+end
diff --git a/spec/serializers/admin/abuse_report_entity_spec.rb b/spec/serializers/admin/abuse_report_entity_spec.rb
new file mode 100644
index 00000000000..003d76a172f
--- /dev/null
+++ b/spec/serializers/admin/abuse_report_entity_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Admin::AbuseReportEntity, feature_category: :insider_threat do
+ include Gitlab::Routing
+
+ let(:abuse_report) { build_stubbed(:abuse_report) }
+
+ let(:entity) do
+ described_class.new(abuse_report)
+ end
+
+ describe '#as_json' do
+ subject(:entity_hash) { entity.as_json }
+
+ it 'exposes correct attributes' do
+ expect(entity_hash.keys).to include(
+ :category,
+ :created_at,
+ :updated_at,
+ :reported_user,
+ :reporter,
+ :report_path
+ )
+ end
+
+ it 'correctly exposes `reported user`' do
+ expect(entity_hash[:reported_user].keys).to match_array([:name])
+ end
+
+ it 'correctly exposes `reporter`' do
+ expect(entity_hash[:reporter].keys).to match_array([:name])
+ end
+
+ it 'correctly exposes :report_path' do
+ expect(entity_hash[:report_path]).to eq admin_abuse_report_path(abuse_report)
+ end
+ end
+end
diff --git a/spec/serializers/admin/abuse_report_serializer_spec.rb b/spec/serializers/admin/abuse_report_serializer_spec.rb
new file mode 100644
index 00000000000..a56ef8816b1
--- /dev/null
+++ b/spec/serializers/admin/abuse_report_serializer_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Admin::AbuseReportSerializer, feature_category: :insider_threat do
+ let_it_be(:resource) { build_stubbed(:abuse_report) }
+
+ subject { described_class.new.represent(resource) }
+
+ describe '#represent' do
+ it 'serializes an abuse report' do
+ expect(subject[:updated_at]).to eq resource.updated_at
+ end
+
+ context 'when multiple objects are being serialized' do
+ let_it_be(:resource) { create_list(:abuse_report, 2) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+
+ it 'serializers the array of abuse reports' do
+ expect(subject).not_to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/serializers/analytics_issue_entity_spec.rb b/spec/serializers/analytics_issue_entity_spec.rb
index ca1e0705d77..e86c0d9d883 100644
--- a/spec/serializers/analytics_issue_entity_spec.rb
+++ b/spec/serializers/analytics_issue_entity_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe AnalyticsIssueEntity do
end
context 'without subgroup' do
- let_it_be(:project) { create(:project, name: 'my project') }
+ let_it_be(:project) { create(:project) }
subject { entity.as_json }
@@ -80,14 +80,14 @@ RSpec.describe AnalyticsIssueEntity do
end
context 'with subgroup' do
- let_it_be(:project) { create(:project, :in_subgroup, name: 'my project') }
+ let_it_be(:project) { create(:project, :in_subgroup) }
subject { entity.as_json }
it_behaves_like 'generic entity'
it 'has URL containing subgroup' do
- expect(subject[:url]).to include("#{project.group.parent.name}/#{project.group.name}/my_project/")
+ expect(subject[:url]).to include("#{project.group.parent.name}/#{project.group.name}/#{project.path}/")
end
end
end
diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb
index 916798c669c..86eaf160b38 100644
--- a/spec/serializers/build_details_entity_spec.rb
+++ b/spec/serializers/build_details_entity_spec.rb
@@ -17,9 +17,7 @@ RSpec.describe BuildDetailsEntity do
let(:request) { double('request', project: project) }
let(:entity) do
- described_class.new(build, request: request,
- current_user: user,
- project: project)
+ described_class.new(build, request: request, current_user: user, project: project)
end
subject { entity.as_json }
@@ -69,9 +67,7 @@ RSpec.describe BuildDetailsEntity do
end
let(:merge_request) do
- create(:merge_request, source_project: forked_project,
- target_project: project,
- source_branch: build.ref)
+ create(:merge_request, source_project: forked_project, target_project: project, source_branch: build.ref)
end
it 'contains the needed key value pairs' do
@@ -285,7 +281,7 @@ RSpec.describe BuildDetailsEntity do
end
context 'when the build has non public archive type artifacts' do
- let(:build) { create(:ci_build, :artifacts, :non_public_artifacts, pipeline: pipeline) }
+ let(:build) { create(:ci_build, :artifacts, :with_private_artifacts_config, pipeline: pipeline) }
it 'does not expose non public artifacts' do
expect(subject.keys).not_to include(:artifact)
diff --git a/spec/serializers/ci/codequality_mr_diff_entity_spec.rb b/spec/serializers/ci/codequality_mr_diff_entity_spec.rb
index 4f161c36b06..19b872b68db 100644
--- a/spec/serializers/ci/codequality_mr_diff_entity_spec.rb
+++ b/spec/serializers/ci/codequality_mr_diff_entity_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CodequalityMrDiffEntity do
+RSpec.describe Ci::CodequalityMrDiffEntity, feature_category: :code_quality do
let(:entity) { described_class.new(mr_diff_report) }
let(:mr_diff_report) { Gitlab::Ci::Reports::CodequalityMrDiff.new(codequality_report.all_degradations) }
let(:codequality_report) { Gitlab::Ci::Reports::CodequalityReports.new }
diff --git a/spec/serializers/ci/downloadable_artifact_entity_spec.rb b/spec/serializers/ci/downloadable_artifact_entity_spec.rb
index 3142b03581d..66a975e54ab 100644
--- a/spec/serializers/ci/downloadable_artifact_entity_spec.rb
+++ b/spec/serializers/ci/downloadable_artifact_entity_spec.rb
@@ -18,8 +18,7 @@ RSpec.describe Ci::DownloadableArtifactEntity do
context 'when user cannot read job artifact' do
let!(:build) do
- create(:ci_build, :success, :private_artifacts,
- pipeline: pipeline)
+ create(:ci_build, :success, :private_artifacts, pipeline: pipeline)
end
it 'returns only artifacts readable by user', :aggregate_failures do
diff --git a/spec/serializers/ci/job_entity_spec.rb b/spec/serializers/ci/job_entity_spec.rb
index 174d9a0aadb..6dce87a1fc5 100644
--- a/spec/serializers/ci/job_entity_spec.rb
+++ b/spec/serializers/ci/job_entity_spec.rb
@@ -97,8 +97,7 @@ RSpec.describe Ci::JobEntity do
before do
project.add_developer(user)
- create(:protected_branch, :developers_can_merge,
- name: job.ref, project: job.project)
+ create(:protected_branch, :developers_can_merge, name: job.ref, project: job.project)
end
it 'contains path to play action' do
@@ -114,8 +113,7 @@ RSpec.describe Ci::JobEntity do
before do
allow(job.project).to receive(:empty_repo?).and_return(false)
- create(:protected_branch, :no_one_can_push,
- name: job.ref, project: job.project)
+ create(:protected_branch, :no_one_can_push, name: job.ref, project: job.project)
end
it 'does not contain path to play action' do
diff --git a/spec/serializers/ci/pipeline_entity_spec.rb b/spec/serializers/ci/pipeline_entity_spec.rb
index 4df542e3c98..7f232a08622 100644
--- a/spec/serializers/ci/pipeline_entity_spec.rb
+++ b/spec/serializers/ci/pipeline_entity_spec.rb
@@ -43,10 +43,10 @@ RSpec.describe Ci::PipelineEntity do
end
it 'contains flags' do
- expect(subject).to include :flags
- expect(subject[:flags])
- .to include :stuck, :auto_devops, :yaml_errors,
- :retryable, :cancelable, :merge_request
+ expect(subject).to include(:flags)
+ expect(subject[:flags]).to include(
+ :stuck, :auto_devops, :yaml_errors, :retryable, :cancelable, :merge_request
+ )
end
end
diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb
deleted file mode 100644
index 1e71e45948c..00000000000
--- a/spec/serializers/cluster_application_entity_spec.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ClusterApplicationEntity do
- describe '#as_json' do
- let(:application) { build(:clusters_applications_helm, version: '0.1.1') }
-
- subject { described_class.new(application).as_json }
-
- it 'has name' do
- expect(subject[:name]).to eq(application.name)
- end
-
- it 'has status' do
- expect(subject[:status]).to eq(:not_installable)
- end
-
- it 'has version' do
- expect(subject[:version]).to eq('0.1.1')
- end
-
- it 'has no status_reason' do
- expect(subject[:status_reason]).to be_nil
- end
-
- it 'has can_uninstall' do
- expect(subject[:can_uninstall]).to be_truthy
- end
-
- context 'non-helm application' do
- let(:application) { build(:clusters_applications_runner, version: '0.0.0') }
-
- it 'has update_available' do
- expect(subject[:update_available]).to be_truthy
- end
- end
-
- context 'when application is errored' do
- let(:application) { build(:clusters_applications_helm, :errored) }
-
- it 'has corresponded data' do
- expect(subject[:status]).to eq(:errored)
- expect(subject[:status_reason]).not_to be_nil
- expect(subject[:status_reason]).to eq(application.status_reason)
- end
- end
-
- context 'for ingress application' do
- let(:application) do
- build(
- :clusters_applications_ingress,
- :installed,
- external_ip: '111.222.111.222'
- )
- end
-
- it 'includes external_ip' do
- expect(subject[:external_ip]).to eq('111.222.111.222')
- end
- end
-
- context 'for knative application' do
- let(:pages_domain) { create(:pages_domain, :instance_serverless) }
- let(:application) { build(:clusters_applications_knative, :installed) }
-
- before do
- create(:serverless_domain_cluster, knative: application, pages_domain: pages_domain)
- end
-
- it 'includes available domains' do
- expect(subject[:available_domains].length).to eq(1)
- expect(subject[:available_domains].first).to eq(id: pages_domain.id, domain: pages_domain.domain)
- end
-
- it 'includes pages_domain' do
- expect(subject[:pages_domain]).to eq(id: pages_domain.id, domain: pages_domain.domain)
- end
- end
- end
-end
diff --git a/spec/serializers/cluster_entity_spec.rb b/spec/serializers/cluster_entity_spec.rb
index 2de27deeffe..ff1a9a4feac 100644
--- a/spec/serializers/cluster_entity_spec.rb
+++ b/spec/serializers/cluster_entity_spec.rb
@@ -41,19 +41,5 @@ RSpec.describe ClusterEntity do
expect(subject[:status_reason]).to be_nil
end
end
-
- context 'when no application has been installed' do
- let(:cluster) { create(:cluster, :instance) }
-
- subject { described_class.new(cluster, request: request).as_json[:applications] }
-
- it 'contains helm as not_installable' do
- expect(subject).not_to be_empty
-
- helm = subject[0]
- expect(helm[:name]).to eq('helm')
- expect(helm[:status]).to eq(:not_installable)
- end
- end
end
end
diff --git a/spec/serializers/cluster_serializer_spec.rb b/spec/serializers/cluster_serializer_spec.rb
index 7ec6d3c8bb8..cf102e11b90 100644
--- a/spec/serializers/cluster_serializer_spec.rb
+++ b/spec/serializers/cluster_serializer_spec.rb
@@ -34,13 +34,13 @@ RSpec.describe ClusterSerializer do
end
it 'serializes attrs correctly' do
- is_expected.to contain_exactly(:status, :status_reason, :applications)
+ is_expected.to contain_exactly(:status, :status_reason)
end
end
context 'when provider type is user' do
it 'serializes attrs correctly' do
- is_expected.to contain_exactly(:status, :status_reason, :applications)
+ is_expected.to contain_exactly(:status, :status_reason)
end
end
end
diff --git a/spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb b/spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb
index 7ea72351594..7df6413f416 100644
--- a/spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb
+++ b/spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb
@@ -29,6 +29,7 @@ RSpec.describe DeployKeys::BasicDeployKeyEntity do
destroyed_when_orphaned: true,
almost_orphaned: false,
created_at: deploy_key.created_at,
+ expires_at: deploy_key.expires_at,
updated_at: deploy_key.updated_at,
can_edit: false
}
diff --git a/spec/serializers/deploy_keys/deploy_key_entity_spec.rb b/spec/serializers/deploy_keys/deploy_key_entity_spec.rb
index 4302ed3a097..837e30e1343 100644
--- a/spec/serializers/deploy_keys/deploy_key_entity_spec.rb
+++ b/spec/serializers/deploy_keys/deploy_key_entity_spec.rb
@@ -29,6 +29,7 @@ RSpec.describe DeployKeys::DeployKeyEntity do
destroyed_when_orphaned: true,
almost_orphaned: false,
created_at: deploy_key.created_at,
+ expires_at: deploy_key.expires_at,
updated_at: deploy_key.updated_at,
can_edit: false,
deploy_keys_projects: [
diff --git a/spec/serializers/diff_file_entity_spec.rb b/spec/serializers/diff_file_entity_spec.rb
index fbb45162136..5eee9c34e1e 100644
--- a/spec/serializers/diff_file_entity_spec.rb
+++ b/spec/serializers/diff_file_entity_spec.rb
@@ -84,8 +84,8 @@ RSpec.describe DiffFileEntity do
let(:options) { { conflicts: {} } }
it 'calls diff_lines_for_serializer on diff_file' do
- # #diff_lines_for_serializer gets called in #fully_expanded? as well so we expect twice
- expect(diff_file).to receive(:diff_lines_for_serializer).twice.and_return([])
+ # #diff_lines_for_serializer gets called in #fully_expanded? and whitespace_only as well so we expect three calls
+ expect(diff_file).to receive(:diff_lines_for_serializer).exactly(3).times.and_return([])
expect(subject[:highlighted_diff_lines]).to eq([])
end
end
diff --git a/spec/serializers/diff_viewer_entity_spec.rb b/spec/serializers/diff_viewer_entity_spec.rb
index 53601fcff61..84d2bdceb78 100644
--- a/spec/serializers/diff_viewer_entity_spec.rb
+++ b/spec/serializers/diff_viewer_entity_spec.rb
@@ -12,10 +12,51 @@ RSpec.describe DiffViewerEntity do
let(:diff) { commit.raw_diffs.first }
let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) }
let(:viewer) { diff_file.simple_viewer }
+ let(:options) { {} }
- subject { described_class.new(viewer).as_json }
+ subject { described_class.new(viewer).as_json(options) }
- it 'serializes diff file viewer' do
- expect(subject.with_indifferent_access).to match_schema('entities/diff_viewer')
+ context 'when add_ignore_all_white_spaces is enabled' do
+ before do
+ stub_feature_flags(add_ignore_all_white_spaces: true)
+ end
+
+ it 'serializes diff file viewer' do
+ expect(subject.with_indifferent_access).to match_schema('entities/diff_viewer')
+ end
+
+ it 'contains whitespace_only attribute' do
+ expect(subject.with_indifferent_access).to include(:whitespace_only)
+ end
+
+ context 'when whitespace_only option is true' do
+ let(:options) { { whitespace_only: true } }
+
+ it 'returns the whitespace_only attribute true' do
+ expect(subject.with_indifferent_access[:whitespace_only]).to eq true
+ end
+ end
+
+ context 'when whitespace_only option is false' do
+ let(:options) { { whitespace_only: false } }
+
+ it 'returns the whitespace_only attribute false' do
+ expect(subject.with_indifferent_access[:whitespace_only]).to eq false
+ end
+ end
+ end
+
+ context 'when add_ignore_all_white_spaces is disabled ' do
+ before do
+ stub_feature_flags(add_ignore_all_white_spaces: false)
+ end
+
+ it 'serializes diff file viewer' do
+ expect(subject.with_indifferent_access).to match_schema('entities/diff_viewer')
+ end
+
+ it 'does not contain whitespace_only attribute' do
+ expect(subject.with_indifferent_access).not_to include(:whitespace_only)
+ end
end
end
diff --git a/spec/serializers/discussion_diff_file_entity_spec.rb b/spec/serializers/discussion_diff_file_entity_spec.rb
index 05438450d78..33c3ebc506f 100644
--- a/spec/serializers/discussion_diff_file_entity_spec.rb
+++ b/spec/serializers/discussion_diff_file_entity_spec.rb
@@ -32,8 +32,7 @@ RSpec.describe DiscussionDiffFileEntity do
end
it 'exposes no diff lines' do
- expect(subject).not_to include(:highlighted_diff_lines,
- :parallel_diff_lines)
+ expect(subject).not_to include(:highlighted_diff_lines, :parallel_diff_lines)
end
end
end
diff --git a/spec/serializers/entity_date_helper_spec.rb b/spec/serializers/entity_date_helper_spec.rb
index 5a4571339b3..70094991c09 100644
--- a/spec/serializers/entity_date_helper_spec.rb
+++ b/spec/serializers/entity_date_helper_spec.rb
@@ -47,8 +47,10 @@ RSpec.describe EntityDateHelper do
end
describe '#remaining_days_in_words' do
+ let(:current_time) { Time.utc(2017, 3, 17) }
+
around do |example|
- travel_to(Time.utc(2017, 3, 17)) { example.run }
+ travel_to(current_time) { example.run }
end
context 'when less than 31 days remaining' do
@@ -74,10 +76,10 @@ RSpec.describe EntityDateHelper do
expect(milestone_remaining).to eq("<strong>1</strong> day remaining")
end
- it 'returns 1 day remaining when queried mid-day' do
- travel_back
+ context 'when queried mid-day' do
+ let(:current_time) { Time.utc(2017, 3, 17, 13, 10) }
- travel_to(Time.utc(2017, 3, 17, 13, 10)) do
+ it 'returns 1 day remaining' do
expect(milestone_remaining).to eq("<strong>1</strong> day remaining")
end
end
diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb
index cbe32600941..c60bead12c2 100644
--- a/spec/serializers/environment_entity_spec.rb
+++ b/spec/serializers/environment_entity_spec.rb
@@ -83,37 +83,49 @@ RSpec.describe EnvironmentEntity do
end
end
- context 'metrics disabled' do
+ context 'when metrics dashboard feature is available' do
before do
- allow(environment).to receive(:has_metrics?).and_return(false)
+ stub_feature_flags(remove_monitor_metrics: false)
end
- it "doesn't expose metrics path" do
- expect(subject).not_to include(:metrics_path)
- end
- end
+ context 'metrics disabled' do
+ before do
+ allow(environment).to receive(:has_metrics?).and_return(false)
+ end
- context 'metrics enabled' do
- before do
- allow(environment).to receive(:has_metrics?).and_return(true)
+ it "doesn't expose metrics path" do
+ expect(subject).not_to include(:metrics_path)
+ end
end
- it 'exposes metrics path' do
- expect(subject).to include(:metrics_path)
+ context 'metrics enabled' do
+ before do
+ allow(environment).to receive(:has_metrics?).and_return(true)
+ end
+
+ it 'exposes metrics path' do
+ expect(subject).to include(:metrics_path)
+ end
end
end
+ it "doesn't expose metrics path" do
+ expect(subject).not_to include(:metrics_path)
+ end
+
context 'with deployment platform' do
let(:project) { create(:project, :repository) }
let(:environment) { create(:environment, project: project) }
context 'when deployment platform is a cluster' do
before do
- create(:cluster,
- :provided_by_gcp,
- :project,
- environment_scope: '*',
- projects: [project])
+ create(
+ :cluster,
+ :provided_by_gcp,
+ :project,
+ environment_scope: '*',
+ projects: [project]
+ )
end
it 'includes cluster_type' do
diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb
index 01d1e47b5bb..c85727a08d8 100644
--- a/spec/serializers/environment_serializer_spec.rb
+++ b/spec/serializers/environment_serializer_spec.rb
@@ -262,8 +262,9 @@ RSpec.describe EnvironmentSerializer do
def create_environment_with_associations(project)
create(:environment, project: project).tap do |environment|
create(:ci_pipeline, project: project).tap do |pipeline|
- create(:ci_build, :manual, project: project, pipeline: pipeline, name: 'stop-action',
- environment: environment.name)
+ create(
+ :ci_build, :manual, project: project, pipeline: pipeline, name: 'stop-action', environment: environment.name
+ )
create(:ci_build, :scheduled, project: project, pipeline: pipeline,
environment: environment.name).tap do |scheduled_build|
diff --git a/spec/serializers/environment_status_entity_spec.rb b/spec/serializers/environment_status_entity_spec.rb
index 2ee4e8ade8f..2289b41b828 100644
--- a/spec/serializers/environment_status_entity_spec.rb
+++ b/spec/serializers/environment_status_entity_spec.rb
@@ -36,7 +36,9 @@ RSpec.describe EnvironmentStatusEntity do
it { is_expected.to include(:details) }
it { is_expected.to include(:changes) }
it { is_expected.to include(:status) }
+ it { is_expected.to include(:environment_available) }
+ it { is_expected.not_to include(:retry_url) }
it { is_expected.not_to include(:stop_url) }
it { is_expected.not_to include(:metrics_url) }
it { is_expected.not_to include(:metrics_monitoring_url) }
@@ -45,6 +47,7 @@ RSpec.describe EnvironmentStatusEntity do
let(:user) { maintainer }
it { is_expected.to include(:stop_url) }
+ it { is_expected.to include(:retry_url) }
end
context 'when deployment has metrics' do
diff --git a/spec/serializers/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb
index 469189c0768..5af704a42da 100644
--- a/spec/serializers/group_child_entity_spec.rb
+++ b/spec/serializers/group_child_entity_spec.rb
@@ -43,8 +43,7 @@ RSpec.describe GroupChildEntity do
describe 'for a project' do
let(:object) do
- create(:project, :with_avatar,
- description: 'Awesomeness')
+ create(:project, :with_avatar, description: 'Awesomeness')
end
before do
@@ -73,8 +72,7 @@ RSpec.describe GroupChildEntity do
describe 'for a group' do
let(:description) { 'Awesomeness' }
let(:object) do
- create(:group, :nested, :with_avatar,
- description: description)
+ create(:group, :nested, :with_avatar, description: description)
end
before do
@@ -171,8 +169,7 @@ RSpec.describe GroupChildEntity do
describe 'for a project with external authorization enabled' do
let(:object) do
- create(:project, :with_avatar,
- description: 'Awesomeness')
+ create(:project, :with_avatar, description: 'Awesomeness')
end
before do
diff --git a/spec/serializers/group_deploy_key_entity_spec.rb b/spec/serializers/group_deploy_key_entity_spec.rb
index e6cef2f10b3..c502923db6a 100644
--- a/spec/serializers/group_deploy_key_entity_spec.rb
+++ b/spec/serializers/group_deploy_key_entity_spec.rb
@@ -25,6 +25,7 @@ RSpec.describe GroupDeployKeyEntity do
fingerprint: group_deploy_key.fingerprint,
fingerprint_sha256: group_deploy_key.fingerprint_sha256,
created_at: group_deploy_key.created_at,
+ expires_at: group_deploy_key.expires_at,
updated_at: group_deploy_key.updated_at,
can_edit: false,
group_deploy_keys_groups: [
diff --git a/spec/serializers/group_issuable_autocomplete_entity_spec.rb b/spec/serializers/group_issuable_autocomplete_entity_spec.rb
index 86ef9dea23b..977239c67da 100644
--- a/spec/serializers/group_issuable_autocomplete_entity_spec.rb
+++ b/spec/serializers/group_issuable_autocomplete_entity_spec.rb
@@ -4,7 +4,8 @@ require 'spec_helper'
RSpec.describe GroupIssuableAutocompleteEntity do
let(:group) { build_stubbed(:group) }
- let(:project) { build_stubbed(:project, group: group) }
+ let(:project_namespace) { build_stubbed(:project_namespace) }
+ let(:project) { build_stubbed(:project, group: group, project_namespace: project_namespace) }
let(:issue) { build_stubbed(:issue, project: project) }
describe '#represent' do
diff --git a/spec/serializers/import/bulk_import_entity_spec.rb b/spec/serializers/import/bulk_import_entity_spec.rb
index 3dfc659daf7..f2f8854174a 100644
--- a/spec/serializers/import/bulk_import_entity_spec.rb
+++ b/spec/serializers/import/bulk_import_entity_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::BulkImportEntity do
+RSpec.describe Import::BulkImportEntity, feature_category: :importers do
let(:importable_data) do
{
'id' => 1,
diff --git a/spec/serializers/import/github_failure_entity_spec.rb b/spec/serializers/import/github_failure_entity_spec.rb
new file mode 100644
index 00000000000..0de710f22cc
--- /dev/null
+++ b/spec/serializers/import/github_failure_entity_spec.rb
@@ -0,0 +1,319 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Import::GithubFailureEntity, feature_category: :importers do
+ let(:project) { instance_double(Project, id: 123456, import_url: 'https://github.com/example/repo.git', import_source: 'example/repo') }
+ let(:source) { 'Gitlab::GithubImport::Importer::PullRequestImporter' }
+ let(:github_identifiers) { { 'iid' => 2, 'object_type' => 'pull_request', 'title' => 'Implement cool feature' } }
+ let(:import_failure) do
+ instance_double(
+ ImportFailure,
+ project: project,
+ exception_class: 'Some class',
+ exception_message: 'Something went wrong',
+ source: source,
+ correlation_id_value: '2ea9c4b8587b6df49f35a3fb703688aa',
+ external_identifiers: github_identifiers,
+ created_at: Time.current
+ )
+ end
+
+ let(:failure_details) do
+ {
+ exception_class: import_failure.exception_class,
+ exception_message: import_failure.exception_message,
+ correlation_id_value: import_failure.correlation_id_value,
+ source: import_failure.source,
+ github_identifiers: github_identifiers,
+ created_at: import_failure.created_at
+ }
+ end
+
+ subject(:entity) { described_class.new(import_failure).as_json.with_indifferent_access }
+
+ shared_examples 'import failure entity' do
+ it 'exposes required fields for import entity' do
+ expect(entity).to eq(
+ {
+ type: import_failure.external_identifiers['object_type'],
+ title: title,
+ provider_url: provider_url,
+ details: failure_details
+ }.with_indifferent_access
+ )
+ end
+ end
+
+ it 'exposes correct attributes' do
+ expect(entity.keys).to match_array(%w[type title provider_url details])
+ end
+
+ context 'with `pull_request` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:title) { 'Implement cool feature' }
+ let(:provider_url) { 'https://github.com/example/repo/pull/2' }
+ end
+ end
+
+ context 'with `pull_request_merged_by` failure' do
+ before do
+ import_failure.external_identifiers.merge!({ 'object_type' => 'pull_request_merged_by' })
+ end
+
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::PullRequests::MergedByImporter' }
+ let(:title) { 'Pull request 2 merger' }
+ let(:provider_url) { 'https://github.com/example/repo/pull/2' }
+ end
+ end
+
+ context 'with `pull_request_review_request` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::PullRequests::ReviewRequestImporter' }
+ let(:title) { 'Pull request 2 review request' }
+ let(:provider_url) { 'https://github.com/example/repo/pull/2' }
+ let(:github_identifiers) do
+ {
+ 'merge_request_iid' => 2,
+ 'requested_reviewers' => %w[alice bob],
+ 'object_type' => 'pull_request_review_request'
+ }
+ end
+ end
+ end
+
+ context 'with `pull_request_review` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::PullRequests::ReviewImporter' }
+ let(:title) { 'Pull request review 123456' }
+ let(:provider_url) { 'https://github.com/example/repo/pull/2#pullrequestreview-123456' }
+ let(:github_identifiers) do
+ {
+ 'merge_request_iid' => 2,
+ 'review_id' => 123456,
+ 'object_type' => 'pull_request_review'
+ }
+ end
+ end
+ end
+
+ context 'with `issue` failure' do
+ before do
+ import_failure.external_identifiers.merge!({ 'object_type' => 'issue' })
+ end
+
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::IssueAndLabelLinksImporter' }
+ let(:title) { 'Implement cool feature' }
+ let(:provider_url) { 'https://github.com/example/repo/issues/2' }
+ end
+ end
+
+ context 'with `collaborator` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::CollaboratorImporter' }
+ let(:title) { 'alice' }
+ let(:provider_url) { 'https://github.com/alice' }
+ let(:github_identifiers) do
+ {
+ 'id' => 123456,
+ 'login' => 'alice',
+ 'object_type' => 'collaborator'
+ }
+ end
+ end
+ end
+
+ context 'with `protected_branch` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::ProtectedBranchImporter' }
+ let(:title) { 'main' }
+ let(:provider_url) { 'https://github.com/example/repo/tree/main' }
+ let(:github_identifiers) do
+ {
+ 'id' => 'main',
+ 'object_type' => 'protected_branch'
+ }
+ end
+ end
+ end
+
+ context 'with `issue_event` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::IssueEventImporter' }
+ let(:title) { 'closed' }
+ let(:provider_url) { 'https://github.com/example/repo/issues/2#event-123456' }
+ let(:github_identifiers) do
+ {
+ 'id' => 123456,
+ 'issuable_iid' => 2,
+ 'event' => 'closed',
+ 'object_type' => 'issue_event'
+ }
+ end
+ end
+ end
+
+ context 'with `label` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::LabelsImporter' }
+ let(:title) { 'bug' }
+ let(:provider_url) { 'https://github.com/example/repo/labels/bug' }
+ let(:github_identifiers) { { 'title' => 'bug', 'object_type' => 'label' } }
+ end
+ end
+
+ context 'with `milestone` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::MilestonesImporter' }
+ let(:title) { '1 release' }
+ let(:provider_url) { 'https://github.com/example/repo/milestone/1' }
+ let(:github_identifiers) { { 'iid' => 1, 'title' => '1 release', 'object_type' => 'milestone' } }
+ end
+ end
+
+ context 'with `release` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::ReleasesImporter' }
+ let(:title) { 'v1.0' }
+ let(:provider_url) { 'https://github.com/example/repo/releases/tag/v1.0' }
+ let(:github_identifiers) do
+ {
+ 'tag' => 'v1.0',
+ 'object_type' => 'release'
+ }
+ end
+ end
+ end
+
+ context 'with `note` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::NoteImporter' }
+ let(:title) { 'MergeRequest comment 123456' }
+ let(:provider_url) { 'https://github.com/example/repo/issues/2#issuecomment-123456' }
+ let(:github_identifiers) do
+ {
+ 'note_id' => 123456,
+ 'noteable_iid' => 2,
+ 'noteable_type' => 'MergeRequest',
+ 'object_type' => 'note'
+ }
+ end
+ end
+ end
+
+ context 'with `diff_note` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::DiffNoteImporter' }
+ let(:title) { 'Pull request review comment 123456' }
+ let(:provider_url) { 'https://github.com/example/repo/pull/2#discussion_r123456' }
+ let(:github_identifiers) do
+ {
+ 'note_id' => 123456,
+ 'noteable_iid' => 2,
+ 'noteable_type' => 'MergeRequest',
+ 'object_type' => 'diff_note'
+ }
+ end
+ end
+ end
+
+ context 'with `issue_attachment` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::NoteAttachmentsImporter' }
+ let(:title) { 'Issue 2 attachment' }
+ let(:provider_url) { 'https://github.com/example/repo/issues/2' }
+ let(:github_identifiers) do
+ {
+ 'db_id' => 123456,
+ 'noteable_iid' => 2,
+ 'object_type' => 'issue_attachment'
+ }
+ end
+ end
+ end
+
+ context 'with `merge_request_attachment` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::NoteAttachmentsImporter' }
+ let(:title) { 'Merge request 2 attachment' }
+ let(:provider_url) { 'https://github.com/example/repo/pull/2' }
+ let(:github_identifiers) do
+ {
+ 'db_id' => 123456,
+ 'noteable_iid' => 2,
+ 'object_type' => 'merge_request_attachment'
+ }
+ end
+ end
+ end
+
+ context 'with `release_attachment` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::NoteAttachmentsImporter' }
+ let(:title) { 'Release v1.0 attachment' }
+ let(:provider_url) { 'https://github.com/example/repo/releases/tag/v1.0' }
+ let(:github_identifiers) do
+ {
+ 'db_id' => 123456,
+ 'tag' => 'v1.0',
+ 'object_type' => 'release_attachment'
+ }
+ end
+ end
+ end
+
+ context 'with `note_attachment` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::NoteAttachmentsImporter' }
+ let(:title) { 'Note attachment' }
+ let(:provider_url) { '' }
+ let(:github_identifiers) do
+ {
+ 'db_id' => 123456,
+ 'noteable_type' => 'Issue',
+ 'object_type' => 'note_attachment'
+ }
+ end
+ end
+ end
+
+ context 'with `lfs_object` failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::LfsObjectImporter' }
+ let(:title) { '42' }
+ let(:provider_url) { '' }
+ let(:github_identifiers) do
+ {
+ 'oid' => 42,
+ 'size' => 123456,
+ 'object_type' => 'lfs_object'
+ }
+ end
+ end
+ end
+
+ context 'with unknown failure' do
+ it_behaves_like 'import failure entity' do
+ let(:source) { 'Gitlab::GithubImport::Importer::NewObjectTypeImporter' }
+ let(:title) { '' }
+ let(:provider_url) { '' }
+ let(:github_identifiers) do
+ {
+ 'id' => 123456,
+ 'object_type' => 'new_object_type'
+ }
+ end
+ end
+ end
+
+ context 'with an invalid import_url' do
+ let(:project) { instance_double(Project, id: 123456, import_url: 'Invalid url', import_source: 'example/repo') }
+
+ it_behaves_like 'import failure entity' do
+ let(:title) { 'Implement cool feature' }
+ let(:provider_url) { '' }
+ end
+ end
+end
diff --git a/spec/serializers/import/github_failure_serializer_spec.rb b/spec/serializers/import/github_failure_serializer_spec.rb
new file mode 100644
index 00000000000..170b2739cfc
--- /dev/null
+++ b/spec/serializers/import/github_failure_serializer_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Import::GithubFailureSerializer, feature_category: :importers do
+ subject(:serializer) { described_class.new }
+
+ it 'represents GithubFailureEntity entities' do
+ expect(described_class.entity_class).to eq(Import::GithubFailureEntity)
+ end
+
+ describe '#represent' do
+ let(:timestamp) { Time.new(2023, 1, 1).utc }
+ let(:github_identifiers) { { 'iid' => 2, 'object_type' => 'pull_request', 'title' => 'Implement cool feature' } }
+ let(:project) do
+ instance_double(
+ Project,
+ id: 123456,
+ import_status: 'finished',
+ import_url: 'https://github.com/example/repo.git',
+ import_source: 'example/repo'
+ )
+ end
+
+ let(:import_failure) do
+ instance_double(
+ ImportFailure,
+ project: project,
+ exception_class: 'Some class',
+ exception_message: 'Something went wrong',
+ source: 'Gitlab::GithubImport::Importer::PullRequestImporter',
+ correlation_id_value: '2ea9c4b8587b6df49f35a3fb703688aa',
+ external_identifiers: github_identifiers,
+ created_at: timestamp
+ )
+ end
+
+ let(:expected_data) do
+ {
+ type: 'pull_request',
+ title: 'Implement cool feature',
+ provider_url: 'https://github.com/example/repo/pull/2',
+ details: {
+ exception_class: import_failure.exception_class,
+ exception_message: import_failure.exception_message,
+ correlation_id_value: import_failure.correlation_id_value,
+ source: import_failure.source,
+ github_identifiers: github_identifiers,
+ created_at: timestamp.iso8601(3)
+ }
+ }.deep_stringify_keys
+ end
+
+ context 'when a single object is being serialized' do
+ let(:resource) { import_failure }
+
+ it 'serializes import failure' do
+ expect(serializer.represent(resource).as_json).to eq expected_data
+ end
+ end
+
+ context 'when multiple objects are being serialized' do
+ let(:count) { 3 }
+ let(:resource) { Array.new(count, import_failure) }
+
+ it 'serializes array of import failures' do
+ expect(serializer.represent(resource).as_json).to all(eq(expected_data))
+ end
+ end
+ end
+end
diff --git a/spec/serializers/integrations/field_entity_spec.rb b/spec/serializers/integrations/field_entity_spec.rb
index 1ca1545c11a..4d190b9a98e 100644
--- a/spec/serializers/integrations/field_entity_spec.rb
+++ b/spec/serializers/integrations/field_entity_spec.rb
@@ -23,10 +23,11 @@ RSpec.describe Integrations::FieldEntity, feature_category: :integrations do
section: 'connection',
type: 'text',
name: 'username',
- title: 'Username or email',
+ title: 'Email or username',
placeholder: nil,
- help: 'Username for the server version or an email for the cloud version',
- required: true,
+ help: 'Only required for Basic authentication. ' \
+ 'Email for Jira Cloud or username for Jira Data Center and Jira Server',
+ required: false,
choices: nil,
value: 'jira_username',
checkbox_label: nil
@@ -44,9 +45,9 @@ RSpec.describe Integrations::FieldEntity, feature_category: :integrations do
section: 'connection',
type: 'password',
name: 'password',
- title: 'Enter new password or API token',
+ title: 'New API token, password, or Jira personal access token',
placeholder: nil,
- help: 'Leave blank to use your current password or API token.',
+ help: 'Leave blank to use your current configuration',
required: true,
choices: nil,
value: 'true',
diff --git a/spec/serializers/issue_board_entity_spec.rb b/spec/serializers/issue_board_entity_spec.rb
index 0c9c8f05e17..6042dea249f 100644
--- a/spec/serializers/issue_board_entity_spec.rb
+++ b/spec/serializers/issue_board_entity_spec.rb
@@ -16,13 +16,17 @@ RSpec.describe IssueBoardEntity do
subject { described_class.new(resource, request: request).as_json }
it 'has basic attributes' do
- expect(subject).to include(:id, :iid, :title, :confidential, :due_date, :project_id, :relative_position,
- :labels, :assignees, project: hash_including(:id, :path, :path_with_namespace))
+ expect(subject).to include(
+ :id, :iid, :title, :confidential, :due_date, :project_id, :relative_position,
+ :labels, :assignees, project: hash_including(:id, :path, :path_with_namespace)
+ )
end
it 'has path and endpoints' do
- expect(subject).to include(:reference_path, :real_path, :issue_sidebar_endpoint,
- :toggle_subscription_endpoint, :assignable_labels_endpoint)
+ expect(subject).to include(
+ :reference_path, :real_path, :issue_sidebar_endpoint,
+ :toggle_subscription_endpoint, :assignable_labels_endpoint
+ )
end
it 'has milestone attributes' do
@@ -57,18 +61,8 @@ RSpec.describe IssueBoardEntity do
context 'when issue is of type task' do
let(:resource) { create(:issue, :task, project: project) }
- context 'when the use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- end
-
- it 'has a work item path' do
- expect(subject[:real_path]).to eq(project_work_items_path(project, resource.id))
- end
- end
-
it 'has a work item path with iid' do
- expect(subject[:real_path]).to eq(project_work_items_path(project, resource.iid, iid_path: true))
+ expect(subject[:real_path]).to eq(project_work_items_path(project, resource.iid))
end
end
end
diff --git a/spec/serializers/issue_entity_spec.rb b/spec/serializers/issue_entity_spec.rb
index 06d8523b2e7..38c81257a7d 100644
--- a/spec/serializers/issue_entity_spec.rb
+++ b/spec/serializers/issue_entity_spec.rb
@@ -17,19 +17,9 @@ RSpec.describe IssueEntity do
context 'when issue is of type task' do
let(:resource) { create(:issue, :task, project: project) }
- context 'when use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- end
-
- # This was already a path and not a url when the work items change was introduced
- it 'has a work item path' do
- expect(subject[:web_url]).to eq(project_work_items_path(project, resource.id))
- end
- end
-
+ # This was already a path and not a url when the work items change was introduced
it 'has a work item path with iid' do
- expect(subject[:web_url]).to eq(project_work_items_path(project, resource.iid, iid_path: true))
+ expect(subject[:web_url]).to eq(project_work_items_path(project, resource.iid))
end
end
end
@@ -41,8 +31,10 @@ RSpec.describe IssueEntity do
end
it 'has Issuable attributes' do
- expect(subject).to include(:id, :iid, :author_id, :description, :lock_version, :milestone_id,
- :title, :updated_by_id, :created_at, :updated_at, :milestone, :labels)
+ expect(subject).to include(
+ :id, :iid, :author_id, :description, :lock_version, :milestone_id,
+ :title, :updated_by_id, :created_at, :updated_at, :milestone, :labels
+ )
end
it 'has time estimation attributes' do
@@ -51,8 +43,9 @@ RSpec.describe IssueEntity do
describe 'current_user' do
it 'has the exprected permissions' do
- expect(subject[:current_user]).to include(:can_create_note, :can_update, :can_set_issue_metadata,
- :can_award_emoji)
+ expect(subject[:current_user]).to include(
+ :can_create_note, :can_update, :can_set_issue_metadata, :can_award_emoji
+ )
end
end
diff --git a/spec/serializers/issue_sidebar_basic_entity_spec.rb b/spec/serializers/issue_sidebar_basic_entity_spec.rb
index 64a271e359a..d81d87f4060 100644
--- a/spec/serializers/issue_sidebar_basic_entity_spec.rb
+++ b/spec/serializers/issue_sidebar_basic_entity_spec.rb
@@ -44,7 +44,10 @@ RSpec.describe IssueSidebarBasicEntity do
context 'for an incident issue' do
before do
- issue.update!(issue_type: Issue.issue_types[:incident])
+ issue.update!(
+ issue_type: WorkItems::Type.base_types[:incident],
+ work_item_type: WorkItems::Type.default_by_type(:incident)
+ )
end
it 'is present and true' do
diff --git a/spec/serializers/jira_connect/app_data_serializer_spec.rb b/spec/serializers/jira_connect/app_data_serializer_spec.rb
index 9c10a8a54a1..1ade3dea6e7 100644
--- a/spec/serializers/jira_connect/app_data_serializer_spec.rb
+++ b/spec/serializers/jira_connect/app_data_serializer_spec.rb
@@ -4,12 +4,10 @@ require 'spec_helper'
RSpec.describe JiraConnect::AppDataSerializer do
describe '#as_json' do
- subject(:app_data_json) { described_class.new(subscriptions, signed_in).as_json }
+ subject(:app_data_json) { described_class.new(subscriptions).as_json }
let_it_be(:subscriptions) { create_list(:jira_connect_subscription, 2) }
- let(:signed_in) { false }
-
it 'uses the subscription entity' do
expect(JiraConnect::SubscriptionEntity).to receive(:represent).with(subscriptions)
@@ -23,12 +21,5 @@ RSpec.describe JiraConnect::AppDataSerializer do
end
it { is_expected.to include(subscriptions_path: '/-/jira_connect/subscriptions') }
- it { is_expected.to include(login_path: '/-/jira_connect/users') }
-
- context 'when signed in' do
- let(:signed_in) { true }
-
- it { is_expected.to include(login_path: nil) }
- end
end
end
diff --git a/spec/serializers/linked_project_issue_entity_spec.rb b/spec/serializers/linked_project_issue_entity_spec.rb
index d415d1cbcb2..2f7fb912115 100644
--- a/spec/serializers/linked_project_issue_entity_spec.rb
+++ b/spec/serializers/linked_project_issue_entity_spec.rb
@@ -47,19 +47,9 @@ RSpec.describe LinkedProjectIssueEntity do
context 'when related issue is a task' do
let_it_be(:issue_link) { create(:issue_link, target: create(:issue, :task)) }
- context 'when use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- end
-
- it 'returns a work items path' do
- expect(serialized_entity).to include(path: project_work_items_path(related_issue.project, related_issue.id))
- end
- end
-
it 'returns a work items path using iid' do
expect(serialized_entity).to include(
- path: project_work_items_path(related_issue.project, related_issue.iid, iid_path: true)
+ path: project_work_items_path(related_issue.project, related_issue.iid)
)
end
end
diff --git a/spec/serializers/merge_request_metrics_helper_spec.rb b/spec/serializers/merge_request_metrics_helper_spec.rb
index ec764bf7853..4aba7ff5e9c 100644
--- a/spec/serializers/merge_request_metrics_helper_spec.rb
+++ b/spec/serializers/merge_request_metrics_helper_spec.rb
@@ -55,12 +55,12 @@ RSpec.describe MergeRequestMetricsHelper do
closed_event = merge_request.closed_event
merge_event = merge_request.merge_event
- expect(MergeRequest::Metrics).to receive(:new)
- .with(latest_closed_at: closed_event&.updated_at,
- latest_closed_by: closed_event&.author,
- merged_at: merge_event&.updated_at,
- merged_by: merge_event&.author)
- .and_call_original
+ expect(MergeRequest::Metrics).to receive(:new).with(
+ latest_closed_at: closed_event&.updated_at,
+ latest_closed_by: closed_event&.author,
+ merged_at: merge_event&.updated_at,
+ merged_by: merge_event&.author
+ ).and_call_original
subject
end
diff --git a/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb b/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb
index f883156628a..458d9ecd916 100644
--- a/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequestPollCachedWidgetEntity do
+RSpec.describe MergeRequestPollCachedWidgetEntity, feature_category: :code_review_workflow do
using RSpec::Parameterized::TableSyntax
let_it_be(:project, refind: true) { create :project, :repository }
@@ -49,8 +49,9 @@ RSpec.describe MergeRequestPollCachedWidgetEntity do
describe 'diverged_commits_count' do
context 'when MR open and its diverging' do
it 'returns diverged commits count' do
- allow(resource).to receive_messages(open?: true, diverged_from_target_branch?: true,
- diverged_commits_count: 10)
+ allow(resource).to receive_messages(
+ open?: true, diverged_from_target_branch?: true, diverged_commits_count: 10
+ )
expect(subject[:diverged_commits_count]).to eq(10)
end
@@ -330,4 +331,39 @@ RSpec.describe MergeRequestPollCachedWidgetEntity do
end
end
end
+
+ describe 'favicon overlay path' do
+ context 'when merged' do
+ before do
+ resource.mark_as_merged!
+ resource.metrics.update!(merged_by: user)
+ end
+
+ it 'returns merged favicon overlay' do
+ expect(subject[:favicon_overlay_path]).to match_asset_path('/assets/mr_favicons/favicon_status_merged.png')
+ end
+
+ context 'with pipeline' do
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project, ref: resource.source_branch, sha: resource.source_branch_sha, head_pipeline_of: resource) }
+
+ it 'returns merged favicon overlay' do
+ expect(subject[:favicon_overlay_path]).to match_asset_path('/assets/mr_favicons/favicon_status_merged.png')
+ end
+ end
+ end
+
+ context 'when not merged' do
+ it 'returns no favicon overlay' do
+ expect(subject[:favicon_overlay_path]).to be_nil
+ end
+
+ context 'with pipeline' do
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project, ref: resource.source_branch, sha: resource.source_branch_sha, head_pipeline_of: resource) }
+
+ it 'returns pipeline favicon overlay' do
+ expect(subject[:favicon_overlay_path]).to match_asset_path('/assets/ci_favicons/favicon_status_pending.png')
+ end
+ end
+ end
+ end
end
diff --git a/spec/serializers/merge_request_poll_widget_entity_spec.rb b/spec/serializers/merge_request_poll_widget_entity_spec.rb
index 418f629a301..726f35418a1 100644
--- a/spec/serializers/merge_request_poll_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_poll_widget_entity_spec.rb
@@ -62,9 +62,7 @@ RSpec.describe MergeRequestPollWidgetEntity do
context 'when head pipeline is running' do
before do
- create(:ci_pipeline, :running, project: project,
- ref: resource.source_branch,
- sha: resource.diff_head_sha)
+ create(:ci_pipeline, :running, project: project, ref: resource.source_branch, sha: resource.diff_head_sha)
resource.update_head_pipeline
end
@@ -96,9 +94,7 @@ RSpec.describe MergeRequestPollWidgetEntity do
context 'when head pipeline is finished' do
before do
- create(:ci_pipeline, :success, project: project,
- ref: resource.source_branch,
- sha: resource.diff_head_sha)
+ create(:ci_pipeline, :success, project: project, ref: resource.source_branch, sha: resource.diff_head_sha)
resource.update_head_pipeline
end
diff --git a/spec/serializers/note_entity_spec.rb b/spec/serializers/note_entity_spec.rb
index 19438e69a10..bbb1d2ca164 100644
--- a/spec/serializers/note_entity_spec.rb
+++ b/spec/serializers/note_entity_spec.rb
@@ -14,4 +14,67 @@ RSpec.describe NoteEntity do
subject { entity.as_json }
it_behaves_like 'note entity'
+
+ shared_examples 'external author' do
+ context 'when anonymous' do
+ let(:user) { nil }
+
+ it { is_expected.to eq(obfuscated_email) }
+ end
+
+ context 'with signed in user' do
+ before do
+ stub_member_access_level(note.project, access_level => user) if access_level
+ end
+
+ context 'when user has no role in project' do
+ let(:access_level) { nil }
+
+ it { is_expected.to eq(obfuscated_email) }
+ end
+
+ context 'when user has guest role in project' do
+ let(:access_level) { :guest }
+
+ it { is_expected.to eq(obfuscated_email) }
+ end
+
+ context 'when user has reporter role in project' do
+ let(:access_level) { :reporter }
+
+ it { is_expected.to eq(email) }
+ end
+
+ context 'when user has developer role in project' do
+ let(:access_level) { :developer }
+
+ it { is_expected.to eq(email) }
+ end
+ end
+ end
+
+ describe 'with email participant' do
+ let_it_be(:note) { create(:note) }
+ let_it_be(:note_metadata) { create(:note_metadata, note: note) }
+
+ subject { entity.as_json[:external_author] }
+
+ context 'when external_note_author_service_desk feature flag is enabled' do
+ let(:obfuscated_email) { 'em*****@e*****.c**' }
+ let(:email) { 'email@example.com' }
+
+ it_behaves_like 'external author'
+ end
+
+ context 'when external_note_author_service_desk feature flag is disabled' do
+ let(:email) { nil }
+ let(:obfuscated_email) { nil }
+
+ before do
+ stub_feature_flags(external_note_author_service_desk: false)
+ end
+
+ it_behaves_like 'external author'
+ end
+ end
end
diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb
index de05d2afd2b..71b088e4e0d 100644
--- a/spec/serializers/pipeline_details_entity_spec.rb
+++ b/spec/serializers/pipeline_details_entity_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PipelineDetailsEntity do
+RSpec.describe PipelineDetailsEntity, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let(:request) { double('request') }
@@ -32,15 +32,37 @@ RSpec.describe PipelineDetailsEntity do
expect(subject[:details])
.to include :duration, :finished_at
expect(subject[:details])
- .to include :stages, :manual_actions, :scheduled_actions
+ .to include :stages, :manual_actions, :has_manual_actions, :scheduled_actions, :has_scheduled_actions
expect(subject[:details][:status]).to include :icon, :favicon, :text, :label
end
it 'contains flags' do
- expect(subject).to include :flags
- expect(subject[:flags])
- .to include :latest, :stuck,
- :yaml_errors, :retryable, :cancelable
+ expect(subject).to include(:flags)
+ expect(subject[:flags]).to include(:latest, :stuck, :yaml_errors, :retryable, :cancelable)
+ end
+ end
+
+ context 'when disable_manual_and_scheduled_actions is true' do
+ let(:pipeline) { create(:ci_pipeline, status: :success) }
+ let(:subject) do
+ described_class.represent(pipeline, request: request, disable_manual_and_scheduled_actions: true).as_json
+ end
+
+ it 'does not contain manual and scheduled actions' do
+ expect(subject[:details])
+ .not_to include :manual_actions, :scheduled_actions
+ end
+ end
+
+ context 'when pipeline has manual builds' do
+ let(:pipeline) { create(:ci_pipeline, status: :success) }
+
+ before do
+ create(:ci_build, :manual, pipeline: pipeline)
+ end
+
+ it 'sets :has_manual_actions to true' do
+ expect(subject[:details][:has_manual_actions]).to eq true
end
end
diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb
index 33fee68a2f2..d1c74bd5ec0 100644
--- a/spec/serializers/pipeline_serializer_spec.rb
+++ b/spec/serializers/pipeline_serializer_spec.rb
@@ -99,21 +99,25 @@ RSpec.describe PipelineSerializer do
let(:resource) { Ci::Pipeline.all }
let!(:merge_request_1) do
- create(:merge_request,
- :with_detached_merge_request_pipeline,
- target_project: project,
- target_branch: 'master',
- source_project: project,
- source_branch: 'feature')
+ create(
+ :merge_request,
+ :with_detached_merge_request_pipeline,
+ target_project: project,
+ target_branch: 'master',
+ source_project: project,
+ source_branch: 'feature'
+ )
end
let!(:merge_request_2) do
- create(:merge_request,
- :with_detached_merge_request_pipeline,
- target_project: project,
- target_branch: 'master',
- source_project: project,
- source_branch: '2-mb-file')
+ create(
+ :merge_request,
+ :with_detached_merge_request_pipeline,
+ target_project: project,
+ target_branch: 'master',
+ source_project: project,
+ source_branch: '2-mb-file'
+ )
end
before_all do
@@ -235,11 +239,13 @@ RSpec.describe PipelineSerializer do
end
def create_pipeline(status)
- create(:ci_empty_pipeline,
- project: project,
- status: status,
- name: 'Build pipeline',
- ref: 'feature').tap do |pipeline|
+ create(
+ :ci_empty_pipeline,
+ project: project,
+ status: status,
+ name: 'Build pipeline',
+ ref: 'feature'
+ ).tap do |pipeline|
Ci::Build::AVAILABLE_STATUSES.each do |build_status|
create_build(pipeline, status, build_status)
end
@@ -247,9 +253,11 @@ RSpec.describe PipelineSerializer do
end
def create_build(pipeline, stage, status)
- create(:ci_build, :tags, :triggered, :artifacts,
- pipeline: pipeline, stage: stage,
- name: stage, status: status, ref: pipeline.ref)
+ create(
+ :ci_build, :tags, :triggered, :artifacts,
+ pipeline: pipeline, stage: stage,
+ name: stage, status: status, ref: pipeline.ref
+ )
end
end
end
diff --git a/spec/serializers/profile/event_entity_spec.rb b/spec/serializers/profile/event_entity_spec.rb
new file mode 100644
index 00000000000..1551fc76466
--- /dev/null
+++ b/spec/serializers/profile/event_entity_spec.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Profile::EventEntity, feature_category: :user_profile do
+ let_it_be(:group) { create(:group) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+ let_it_be(:project) { build(:project_empty_repo, group: group) }
+ let_it_be(:user) { create(:user) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+ let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+
+ let(:target_user) { user }
+ let(:event) { build(:event, :merged, author: user, project: project, target: merge_request) }
+ let(:request) { double(described_class, current_user: user, target_user: target_user) } # rubocop:disable RSpec/VerifiedDoubles
+ let(:entity) { described_class.new(event, request: request) }
+
+ subject { entity.as_json }
+
+ before do
+ group.add_maintainer(user)
+ end
+
+ it 'exposes fields', :aggregate_failures do
+ expect(subject[:created_at]).to eq(event.created_at)
+ expect(subject[:action]).to eq(event.action)
+ expect(subject[:author][:id]).to eq(target_user.id)
+ expect(subject[:author][:name]).to eq(target_user.name)
+ expect(subject[:author][:path]).to eq(target_user.username)
+ end
+
+ context 'for push events' do
+ let_it_be(:commit_from) { Gitlab::Git::BLANK_SHA }
+ let_it_be(:commit_title) { 'My commit' }
+ let(:event) { build(:push_event, project: project, author: target_user) }
+
+ it 'exposes ref fields' do
+ build(:push_event_payload, event: event, ref_count: 3)
+
+ expect(subject[:ref][:type]).to eq(event.ref_type)
+ expect(subject[:ref][:count]).to eq(event.ref_count)
+ expect(subject[:ref][:name]).to eq(event.ref_name)
+ expect(subject[:ref][:path]).to be_nil
+ end
+
+ shared_examples 'returns ref path' do
+ specify do
+ expect(subject[:ref][:path]).to be_present
+ end
+ end
+
+ context 'with tag' do
+ before do
+ allow(project.repository).to receive(:tag_exists?).and_return(true)
+ build(:push_event_payload, event: event, ref_type: :tag)
+ end
+
+ it_behaves_like 'returns ref path'
+ end
+
+ context 'with branch' do
+ before do
+ allow(project.repository).to receive(:branch_exists?).and_return(true)
+ build(:push_event_payload, event: event, ref_type: :branch)
+ end
+
+ it_behaves_like 'returns ref path'
+ end
+
+ it 'exposes commit fields' do
+ build(:push_event_payload, event: event, commit_title: commit_title, commit_from: commit_from, commit_count: 2)
+
+ compare_path = "/#{group.path}/#{project.path}/-/compare/#{commit_from}...#{event.commit_to}"
+ expect(subject[:commit][:compare_path]).to eq(compare_path)
+ expect(event.commit_id).to include(subject[:commit][:truncated_sha])
+ expect(subject[:commit][:path]).to be_present
+ expect(subject[:commit][:title]).to eq(commit_title)
+ expect(subject[:commit][:count]).to eq(2)
+ expect(commit_from).to include(subject[:commit][:from_truncated_sha])
+ expect(event.commit_to).to include(subject[:commit][:to_truncated_sha])
+ expect(subject[:commit][:create_mr_path]).to be_nil
+ end
+
+ it 'exposes create_mr_path' do
+ allow(project).to receive(:default_branch).and_return('main')
+ allow(project.repository).to receive(:branch_exists?).and_return(true)
+ build(:push_event_payload, event: event, action: :created, commit_from: commit_from, commit_count: 2)
+
+ new_mr_path = "/#{group.path}/#{project.path}/-/merge_requests/new?" \
+ "merge_request%5Bsource_branch%5D=#{event.branch_name}"
+ expect(subject[:commit][:create_mr_path]).to eq(new_mr_path)
+ end
+ end
+
+ context 'with target' do
+ let_it_be(:note) { build(:note_on_merge_request, :with_attachment, noteable: merge_request, project: project) }
+
+ context 'when target does not responds to :reference_link_text' do
+ let(:event) { build(:event, :commented, project: project, target: note, author: target_user) }
+
+ it 'exposes target fields' do
+ expect(subject[:target]).not_to include(:reference_link_text)
+ expect(subject[:target][:target_type]).to eq(note.class.to_s)
+ expect(subject[:target][:target_url]).to be_present
+ expect(subject[:target][:title]).to eq(note.title)
+ expect(subject[:target][:first_line_in_markdown]).to be_present
+ expect(subject[:target][:attachment][:url]).to eq(note.attachment.url)
+ end
+ end
+
+ context 'when target responds to :reference_link_text' do
+ it 'exposes reference_link_text' do
+ expect(subject[:target][:reference_link_text]).to eq(merge_request.reference_link_text)
+ end
+ end
+ end
+
+ context 'with resource parent' do
+ it 'exposes resource parent fields' do
+ resource_parent = event.resource_parent
+
+ expect(subject[:resource_parent][:type]).to eq('project')
+ expect(subject[:resource_parent][:full_name]).to eq(resource_parent.full_name)
+ expect(subject[:resource_parent][:full_path]).to eq(resource_parent.full_path)
+ end
+ end
+
+ context 'for private events' do
+ let(:event) { build(:event, :merged, author: target_user) }
+
+ context 'when include_private_contributions? is true' do
+ let(:target_user) { build(:user, include_private_contributions: true) }
+
+ it 'exposes only created_at, action, and author', :aggregate_failures do
+ expect(subject[:created_at]).to eq(event.created_at)
+ expect(subject[:action]).to eq('private')
+ expect(subject[:author][:id]).to eq(target_user.id)
+ expect(subject[:author][:name]).to eq(target_user.name)
+ expect(subject[:author][:path]).to eq(target_user.username)
+
+ is_expected.not_to include(:ref, :commit, :target, :resource_parent)
+ end
+ end
+
+ context 'when include_private_contributions? is false' do
+ let(:target_user) { build(:user, include_private_contributions: false) }
+
+ it { is_expected.to be_empty }
+ end
+ end
+end
diff --git a/spec/serializers/project_import_entity_spec.rb b/spec/serializers/project_import_entity_spec.rb
index 6d292d18ae7..521d0127dbb 100644
--- a/spec/serializers/project_import_entity_spec.rb
+++ b/spec/serializers/project_import_entity_spec.rb
@@ -5,10 +5,11 @@ require 'spec_helper'
RSpec.describe ProjectImportEntity, feature_category: :importers do
include ImportHelper
- let_it_be(:project) { create(:project, import_status: :started, import_source: 'namespace/project') }
+ let_it_be(:project) { create(:project, import_status: :started, import_source: 'import_user/project') }
let(:provider_url) { 'https://provider.com' }
- let(:entity) { described_class.represent(project, provider_url: provider_url) }
+ let(:client) { nil }
+ let(:entity) { described_class.represent(project, provider_url: provider_url, client: client) }
before do
create(:import_failure, project: project)
@@ -23,6 +24,31 @@ RSpec.describe ProjectImportEntity, feature_category: :importers do
expect(subject[:human_import_status_name]).to eq(project.human_import_status_name)
expect(subject[:provider_link]).to eq(provider_project_link_url(provider_url, project[:import_source]))
expect(subject[:import_error]).to eq(nil)
+ expect(subject[:relation_type]).to eq(nil)
+ end
+
+ context 'when client option present', :clean_gitlab_redis_cache do
+ let(:octokit) { instance_double(Octokit::Client, access_token: 'stub') }
+ let(:client) do
+ instance_double(
+ ::Gitlab::GithubImport::Clients::Proxy,
+ user: { login: 'import_user' }, octokit: octokit
+ )
+ end
+
+ it 'includes relation_type' do
+ expect(subject[:relation_type]).to eq('owned')
+ end
+
+ context 'with remove_legacy_github_client FF is disabled' do
+ before do
+ stub_feature_flags(remove_legacy_github_client: false)
+ end
+
+ it "doesn't include relation_type" do
+ expect(subject[:relation_type]).to eq(nil)
+ end
+ end
end
context 'when import is failed' do
diff --git a/spec/serializers/runner_entity_spec.rb b/spec/serializers/runner_entity_spec.rb
index f34cb794834..b94e1e225ed 100644
--- a/spec/serializers/runner_entity_spec.rb
+++ b/spec/serializers/runner_entity_spec.rb
@@ -22,5 +22,23 @@ RSpec.describe RunnerEntity do
expect(subject).to include(:edit_path)
expect(subject).to include(:short_sha)
end
+
+ context 'without admin permissions' do
+ it 'does not contain admin_path field' do
+ expect(subject).not_to include(:admin_path)
+ end
+ end
+
+ context 'with admin permissions' do
+ let_it_be(:user) { create(:user, :admin) }
+
+ before do
+ allow(user).to receive(:can_admin_all_resources?).and_return(true)
+ end
+
+ it 'contains admin_path field' do
+ expect(subject).to include(:admin_path)
+ end
+ end
end
end
diff --git a/spec/services/access_token_validation_service_spec.rb b/spec/services/access_token_validation_service_spec.rb
index 2bf74d64dc9..4cdce094358 100644
--- a/spec/services/access_token_validation_service_spec.rb
+++ b/spec/services/access_token_validation_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AccessTokenValidationService do
+RSpec.describe AccessTokenValidationService, feature_category: :system_access do
describe ".include_any_scope?" do
let(:request) { double("request") }
diff --git a/spec/services/achievements/award_service_spec.rb b/spec/services/achievements/award_service_spec.rb
new file mode 100644
index 00000000000..c70c1d5c22d
--- /dev/null
+++ b/spec/services/achievements/award_service_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Achievements::AwardService, feature_category: :user_profile do
+ describe '#execute' do
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+ let_it_be(:recipient) { create(:user) }
+
+ let(:achievement_id) { achievement.id }
+ let(:recipient_id) { recipient.id }
+
+ subject(:response) { described_class.new(current_user, achievement_id, recipient_id).execute }
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when user does not have permission' do
+ let(:current_user) { developer }
+
+ it 'returns an error' do
+ expect(response).to be_error
+ expect(response.message).to match_array(
+ ['You have insufficient permissions to award this achievement'])
+ end
+ end
+
+ context 'when user has permission' do
+ let(:current_user) { maintainer }
+ let(:notification_service) { instance_double(NotificationService) }
+ let(:mail_message) { instance_double(ActionMailer::MessageDelivery) }
+
+ it 'creates an achievement and sends an e-mail' do
+ allow(NotificationService).to receive(:new).and_return(notification_service)
+ expect(notification_service).to receive(:new_achievement_email).with(recipient, achievement)
+ .and_return(mail_message)
+ expect(mail_message).to receive(:deliver_later)
+
+ expect(response).to be_success
+ end
+
+ context 'when the achievement is not persisted' do
+ let(:user_achievement) { instance_double('Achievements::UserAchievement') }
+
+ it 'returns the correct error' do
+ allow(user_achievement).to receive(:persisted?).and_return(false)
+ allow(user_achievement).to receive(:errors).and_return(nil)
+ allow(Achievements::UserAchievement).to receive(:create).and_return(user_achievement)
+
+ expect(response).to be_error
+ expect(response.message).to match_array(["Failed to award achievement"])
+ end
+ end
+
+ context 'when the achievement does not exist' do
+ let(:achievement_id) { non_existing_record_id }
+
+ it 'returns the correct error' do
+ expect(response).to be_error
+ expect(response.message)
+ .to contain_exactly("Couldn't find Achievements::Achievement with 'id'=#{non_existing_record_id}")
+ end
+ end
+
+ context 'when the recipient does not exist' do
+ let(:recipient_id) { non_existing_record_id }
+
+ it 'returns the correct error' do
+ expect(response).to be_error
+ expect(response.message).to contain_exactly("Couldn't find User with 'id'=#{non_existing_record_id}")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/achievements/destroy_service_spec.rb b/spec/services/achievements/destroy_service_spec.rb
new file mode 100644
index 00000000000..7af10ceec6a
--- /dev/null
+++ b/spec/services/achievements/destroy_service_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Achievements::DestroyService, feature_category: :user_profile do
+ describe '#execute' do
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ let(:achievement) { create(:achievement, namespace: group) }
+
+ subject(:response) { described_class.new(current_user, achievement).execute }
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when user does not have permission' do
+ let(:current_user) { developer }
+
+ it 'returns an error' do
+ expect(response).to be_error
+ expect(response.message).to match_array(
+ ['You have insufficient permissions to delete this achievement'])
+ end
+ end
+
+ context 'when user has permission' do
+ let(:current_user) { maintainer }
+
+ it 'deletes the achievement' do
+ expect(response).to be_success
+ expect(Achievements::Achievement.find_by(id: achievement.id)).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/achievements/revoke_service_spec.rb b/spec/services/achievements/revoke_service_spec.rb
new file mode 100644
index 00000000000..c9925f1e3df
--- /dev/null
+++ b/spec/services/achievements/revoke_service_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Achievements::RevokeService, feature_category: :user_profile do
+ describe '#execute' do
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+ let_it_be(:user_achievement) { create(:user_achievement, achievement: achievement) }
+
+ let(:user_achievement_param) { user_achievement }
+
+ subject(:response) { described_class.new(current_user, user_achievement_param).execute }
+
+ before_all do
+ group.add_developer(developer)
+ group.add_maintainer(maintainer)
+ end
+
+ context 'when user does not have permission' do
+ let(:current_user) { developer }
+
+ it 'returns an error' do
+ expect(response).to be_error
+ expect(response.message).to match_array(
+ ['You have insufficient permissions to revoke this achievement'])
+ end
+ end
+
+ context 'when user has permission' do
+ let(:current_user) { maintainer }
+
+ it 'revokes an achievement' do
+ expect(response).to be_success
+ end
+
+ context 'when the achievement has already been revoked' do
+ let_it_be(:revoked_achievement) { create(:user_achievement, :revoked, achievement: achievement) }
+ let(:user_achievement_param) { revoked_achievement }
+
+ it 'returns the correct error' do
+ expect(response).to be_error
+ expect(response.message)
+ .to contain_exactly('This achievement has already been revoked')
+ end
+ end
+
+ context 'when the user achievement fails to save' do
+ let(:user_achievement_param) { instance_double('Achievements::UserAchievement') }
+
+ it 'returns the correct error' do
+ allow(user_achievement_param).to receive(:save).and_return(false)
+ allow(user_achievement_param).to receive(:achievement).and_return(achievement)
+ allow(user_achievement_param).to receive(:revoked?).and_return(false)
+ allow(user_achievement_param).to receive(:errors).and_return(nil)
+ expect(user_achievement_param).to receive(:assign_attributes)
+
+ expect(response).to be_error
+ expect(response.message).to match_array(["Failed to revoke achievement"])
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/achievements/update_service_spec.rb b/spec/services/achievements/update_service_spec.rb
new file mode 100644
index 00000000000..6168d60450b
--- /dev/null
+++ b/spec/services/achievements/update_service_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Achievements::UpdateService, feature_category: :user_profile do
+ describe '#execute' do
+ let_it_be(:user) { create(:user) }
+
+ let(:params) { attributes_for(:achievement, namespace: group) }
+
+ subject(:response) { described_class.new(user, group, params).execute }
+
+ context 'when user does not have permission' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+
+ before_all do
+ group.add_developer(user)
+ end
+
+ it 'returns an error' do
+ expect(response).to be_error
+ expect(response.message).to match_array(
+ ['You have insufficient permission to update this achievement'])
+ end
+ end
+
+ context 'when user has permission' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:achievement) { create(:achievement, namespace: group) }
+
+ before_all do
+ group.add_maintainer(user)
+ end
+
+ it 'updates an achievement' do
+ expect(response).to be_success
+ end
+
+ it 'returns an error when the achievement cannot be updated' do
+ params[:name] = nil
+
+ expect(response).to be_error
+ expect(response.message).to include("Name can't be blank")
+ end
+ end
+ end
+end
diff --git a/spec/services/admin/abuse_report_update_service_spec.rb b/spec/services/admin/abuse_report_update_service_spec.rb
new file mode 100644
index 00000000000..e85b516b87f
--- /dev/null
+++ b/spec/services/admin/abuse_report_update_service_spec.rb
@@ -0,0 +1,199 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::AbuseReportUpdateService, feature_category: :instance_resiliency do
+ let_it_be_with_reload(:abuse_report) { create(:abuse_report) }
+ let(:action) { 'ban_user' }
+ let(:close) { true }
+ let(:reason) { 'spam' }
+ let(:params) { { user_action: action, close: close, reason: reason, comment: 'obvious spam' } }
+ let_it_be(:admin) { create(:admin) }
+
+ let(:service) { described_class.new(abuse_report, admin, params) }
+
+ describe '#execute', :enable_admin_mode do
+ subject { service.execute }
+
+ shared_examples 'returns an error response' do |error|
+ it 'returns an error response' do
+ expect(subject.status).to eq :error
+ expect(subject.message).to eq error
+ end
+ end
+
+ shared_examples 'closes the report' do
+ it 'closes the report' do
+ expect { subject }.to change { abuse_report.closed? }.from(false).to(true)
+ end
+ end
+
+ shared_examples 'does not close the report' do
+ it 'does not close the report' do
+ subject
+ expect(abuse_report.closed?).to be(false)
+ end
+ end
+
+ shared_examples 'does not record an event' do
+ it 'does not record an event' do
+ expect { subject }.not_to change { abuse_report.events.count }
+ end
+ end
+
+ shared_examples 'records an event' do |action:|
+ it 'records the event', :aggregate_failures do
+ expect { subject }.to change { abuse_report.events.count }.by(1)
+
+ expect(abuse_report.events.last).to have_attributes(
+ action: action,
+ user: admin,
+ reason: reason,
+ comment: params[:comment]
+ )
+ end
+ end
+
+ context 'when invalid parameters are given' do
+ describe 'invalid user' do
+ describe 'when no user is given' do
+ let_it_be(:admin) { nil }
+
+ it_behaves_like 'returns an error response', 'Admin is required'
+ end
+
+ describe 'when given user is no admin' do
+ let_it_be(:admin) { create(:user) }
+
+ it_behaves_like 'returns an error response', 'Admin is required'
+ end
+ end
+
+ describe 'invalid action' do
+ describe 'when no action is given' do
+ let(:action) { '' }
+ let(:close) { 'false' }
+
+ it_behaves_like 'returns an error response', 'Action is required'
+ end
+
+ describe 'when unknown action is given' do
+ let(:action) { 'unknown' }
+ let(:close) { 'false' }
+
+ it_behaves_like 'returns an error response', 'Action is required'
+ end
+ end
+
+ describe 'invalid reason' do
+ let(:reason) { '' }
+
+ it 'sets the reason to `other`' do
+ subject
+
+ expect(abuse_report.events.last).to have_attributes(reason: 'other')
+ end
+ end
+ end
+
+ describe 'when banning the user' do
+ it 'calls the Users::BanService' do
+ expect_next_instance_of(Users::BanService, admin) do |service|
+ expect(service).to receive(:execute).with(abuse_report.user).and_return(status: :success)
+ end
+
+ subject
+ end
+
+ context 'when closing the report' do
+ it_behaves_like 'closes the report'
+ it_behaves_like 'records an event', action: 'ban_user_and_close_report'
+ end
+
+ context 'when not closing the report' do
+ let(:close) { 'false' }
+
+ it_behaves_like 'does not close the report'
+ it_behaves_like 'records an event', action: 'ban_user'
+ end
+
+ context 'when banning the user fails' do
+ before do
+ allow_next_instance_of(Users::BanService, admin) do |service|
+ allow(service).to receive(:execute).with(abuse_report.user)
+ .and_return(status: :error, message: 'Banning the user failed')
+ end
+ end
+
+ it_behaves_like 'returns an error response', 'Banning the user failed'
+ it_behaves_like 'does not close the report'
+ it_behaves_like 'does not record an event'
+ end
+ end
+
+ describe 'when blocking the user' do
+ let(:action) { 'block_user' }
+
+ it 'calls the Users::BlockService' do
+ expect_next_instance_of(Users::BlockService, admin) do |service|
+ expect(service).to receive(:execute).with(abuse_report.user).and_return(status: :success)
+ end
+
+ subject
+ end
+
+ context 'when closing the report' do
+ it_behaves_like 'closes the report'
+ it_behaves_like 'records an event', action: 'block_user_and_close_report'
+ end
+
+ context 'when not closing the report' do
+ let(:close) { 'false' }
+
+ it_behaves_like 'does not close the report'
+ it_behaves_like 'records an event', action: 'block_user'
+ end
+
+ context 'when blocking the user fails' do
+ before do
+ allow_next_instance_of(Users::BlockService, admin) do |service|
+ allow(service).to receive(:execute).with(abuse_report.user)
+ .and_return(status: :error, message: 'Blocking the user failed')
+ end
+ end
+
+ it_behaves_like 'returns an error response', 'Blocking the user failed'
+ it_behaves_like 'does not close the report'
+ it_behaves_like 'does not record an event'
+ end
+ end
+
+ describe 'when deleting the user' do
+ let(:action) { 'delete_user' }
+
+ it 'calls the delete_async method' do
+ expect(abuse_report.user).to receive(:delete_async).with(deleted_by: admin)
+ subject
+ end
+
+ context 'when closing the report' do
+ it_behaves_like 'closes the report'
+ it_behaves_like 'records an event', action: 'delete_user_and_close_report'
+ end
+
+ context 'when not closing the report' do
+ let(:close) { 'false' }
+
+ it_behaves_like 'does not close the report'
+ it_behaves_like 'records an event', action: 'delete_user'
+ end
+ end
+
+ describe 'when only closing the report' do
+ let(:action) { '' }
+
+ it_behaves_like 'closes the report'
+ it_behaves_like 'records an event', action: 'close_report'
+ end
+ end
+end
diff --git a/spec/services/admin/set_feature_flag_service_spec.rb b/spec/services/admin/set_feature_flag_service_spec.rb
index 45ee914558a..e66802f6332 100644
--- a/spec/services/admin/set_feature_flag_service_spec.rb
+++ b/spec/services/admin/set_feature_flag_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Admin::SetFeatureFlagService do
+RSpec.describe Admin::SetFeatureFlagService, feature_category: :feature_flags do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
diff --git a/spec/services/alert_management/alerts/todo/create_service_spec.rb b/spec/services/alert_management/alerts/todo/create_service_spec.rb
index fa4fd8ed0b2..fd81c0893ed 100644
--- a/spec/services/alert_management/alerts/todo/create_service_spec.rb
+++ b/spec/services/alert_management/alerts/todo/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AlertManagement::Alerts::Todo::CreateService do
+RSpec.describe AlertManagement::Alerts::Todo::CreateService, feature_category: :incident_management do
let_it_be(:user) { create(:user) }
let_it_be(:alert) { create(:alert_management_alert) }
diff --git a/spec/services/alert_management/alerts/update_service_spec.rb b/spec/services/alert_management/alerts/update_service_spec.rb
index 8375c8cdf7d..69e2f2de291 100644
--- a/spec/services/alert_management/alerts/update_service_spec.rb
+++ b/spec/services/alert_management/alerts/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AlertManagement::Alerts::UpdateService do
+RSpec.describe AlertManagement::Alerts::UpdateService, feature_category: :incident_management do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:other_user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
diff --git a/spec/services/alert_management/create_alert_issue_service_spec.rb b/spec/services/alert_management/create_alert_issue_service_spec.rb
index 7255a722d26..b8d93f99ae4 100644
--- a/spec/services/alert_management/create_alert_issue_service_spec.rb
+++ b/spec/services/alert_management/create_alert_issue_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AlertManagement::CreateAlertIssueService do
+RSpec.describe AlertManagement::CreateAlertIssueService, feature_category: :incident_management do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
diff --git a/spec/services/alert_management/http_integrations/create_service_spec.rb b/spec/services/alert_management/http_integrations/create_service_spec.rb
index ac5c62caf84..5200ec27dd1 100644
--- a/spec/services/alert_management/http_integrations/create_service_spec.rb
+++ b/spec/services/alert_management/http_integrations/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AlertManagement::HttpIntegrations::CreateService do
+RSpec.describe AlertManagement::HttpIntegrations::CreateService, feature_category: :incident_management do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be_with_reload(:project) { create(:project) }
diff --git a/spec/services/alert_management/http_integrations/destroy_service_spec.rb b/spec/services/alert_management/http_integrations/destroy_service_spec.rb
index cd949d728de..a8e9746cb85 100644
--- a/spec/services/alert_management/http_integrations/destroy_service_spec.rb
+++ b/spec/services/alert_management/http_integrations/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AlertManagement::HttpIntegrations::DestroyService do
+RSpec.describe AlertManagement::HttpIntegrations::DestroyService, feature_category: :incident_management do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be(:project) { create(:project) }
diff --git a/spec/services/alert_management/http_integrations/update_service_spec.rb b/spec/services/alert_management/http_integrations/update_service_spec.rb
index 94c34d9a29c..3f1a0967aa9 100644
--- a/spec/services/alert_management/http_integrations/update_service_spec.rb
+++ b/spec/services/alert_management/http_integrations/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AlertManagement::HttpIntegrations::UpdateService do
+RSpec.describe AlertManagement::HttpIntegrations::UpdateService, feature_category: :incident_management do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be(:project) { create(:project) }
diff --git a/spec/services/alert_management/metric_images/upload_service_spec.rb b/spec/services/alert_management/metric_images/upload_service_spec.rb
index 527d9db0fd9..2cafd2c9029 100644
--- a/spec/services/alert_management/metric_images/upload_service_spec.rb
+++ b/spec/services/alert_management/metric_images/upload_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AlertManagement::MetricImages::UploadService do
+RSpec.describe AlertManagement::MetricImages::UploadService, feature_category: :metrics do
subject(:service) { described_class.new(alert, current_user, params) }
let_it_be_with_refind(:project) { create(:project) }
diff --git a/spec/services/alert_management/process_prometheus_alert_service_spec.rb b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
index ae52a09be48..eb5f3808021 100644
--- a/spec/services/alert_management/process_prometheus_alert_service_spec.rb
+++ b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AlertManagement::ProcessPrometheusAlertService do
+RSpec.describe AlertManagement::ProcessPrometheusAlertService, feature_category: :incident_management do
let_it_be(:project, reload: true) { create(:project, :repository) }
let(:service) { described_class.new(project, payload) }
diff --git a/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb b/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb
index 7bfae0cd9fc..c39965a2799 100644
--- a/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb
+++ b/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Analytics::CycleAnalytics::Stages::ListService do
+RSpec.describe Analytics::CycleAnalytics::Stages::ListService, feature_category: :value_stream_management do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:project_namespace) { project.project_namespace.reload }
diff --git a/spec/services/application_settings/update_service_spec.rb b/spec/services/application_settings/update_service_spec.rb
index e20d59fb0ef..79d4fc67538 100644
--- a/spec/services/application_settings/update_service_spec.rb
+++ b/spec/services/application_settings/update_service_spec.rb
@@ -110,7 +110,7 @@ RSpec.describe ApplicationSettings::UpdateService do
end
end
- describe 'markdown cache invalidators' do
+ describe 'markdown cache invalidators', feature_category: :team_planning do
shared_examples 'invalidates markdown cache' do |attribute|
let(:params) { attribute }
@@ -144,7 +144,7 @@ RSpec.describe ApplicationSettings::UpdateService do
end
end
- describe 'performance bar settings' do
+ describe 'performance bar settings', feature_category: :application_performance do
using RSpec::Parameterized::TableSyntax
where(:params_performance_bar_enabled,
@@ -247,7 +247,7 @@ RSpec.describe ApplicationSettings::UpdateService do
end
end
- context 'when external authorization is enabled' do
+ context 'when external authorization is enabled', feature_category: :system_access do
before do
enable_external_authorization_service_check
end
diff --git a/spec/services/audit_event_service_spec.rb b/spec/services/audit_event_service_spec.rb
index 4f8b90fcb4a..8a20f7775eb 100644
--- a/spec/services/audit_event_service_spec.rb
+++ b/spec/services/audit_event_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuditEventService, :with_license do
+RSpec.describe AuditEventService, :with_license, feature_category: :audit_events do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user, :with_sign_ins) }
let_it_be(:project_member) { create(:project_member, user: user) }
diff --git a/spec/services/audit_events/build_service_spec.rb b/spec/services/audit_events/build_service_spec.rb
index caf405a53aa..575ec9e58b8 100644
--- a/spec/services/audit_events/build_service_spec.rb
+++ b/spec/services/audit_events/build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuditEvents::BuildService do
+RSpec.describe AuditEvents::BuildService, feature_category: :audit_events do
let(:author) { build_stubbed(:author, current_sign_in_ip: '127.0.0.1') }
let(:deploy_token) { build_stubbed(:deploy_token, user: author) }
let(:scope) { build_stubbed(:group) }
diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb
index ba7acd3d3df..90aba1ae54c 100644
--- a/spec/services/auth/container_registry_authentication_service_spec.rb
+++ b/spec/services/auth/container_registry_authentication_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Auth::ContainerRegistryAuthenticationService do
+RSpec.describe Auth::ContainerRegistryAuthenticationService, feature_category: :container_registry do
include AdminModeHelper
it_behaves_like 'a container registry auth service'
diff --git a/spec/services/auth/dependency_proxy_authentication_service_spec.rb b/spec/services/auth/dependency_proxy_authentication_service_spec.rb
index 667f361dc34..8f92fbe272c 100644
--- a/spec/services/auth/dependency_proxy_authentication_service_spec.rb
+++ b/spec/services/auth/dependency_proxy_authentication_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Auth::DependencyProxyAuthenticationService do
+RSpec.describe Auth::DependencyProxyAuthenticationService, feature_category: :dependency_proxy do
let_it_be(:user) { create(:user) }
let(:service) { Auth::DependencyProxyAuthenticationService.new(nil, user) }
diff --git a/spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb b/spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb
index 691fb3f60f4..e8f86b4d7c5 100644
--- a/spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb
+++ b/spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::FindRecordsDueForRefreshService do
+RSpec.describe AuthorizedProjectUpdate::FindRecordsDueForRefreshService, feature_category: :projects do
# We're using let! here so that any expectations for the service class are not
# triggered twice.
let!(:project) { create(:project) }
diff --git a/spec/services/authorized_project_update/periodic_recalculate_service_spec.rb b/spec/services/authorized_project_update/periodic_recalculate_service_spec.rb
index 782f6858870..51cab6d188b 100644
--- a/spec/services/authorized_project_update/periodic_recalculate_service_spec.rb
+++ b/spec/services/authorized_project_update/periodic_recalculate_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::PeriodicRecalculateService do
+RSpec.describe AuthorizedProjectUpdate::PeriodicRecalculateService, feature_category: :projects do
subject(:service) { described_class.new }
describe '#execute' do
diff --git a/spec/services/authorized_project_update/project_access_changed_service_spec.rb b/spec/services/authorized_project_update/project_access_changed_service_spec.rb
index da428bece20..7c09d7755ca 100644
--- a/spec/services/authorized_project_update/project_access_changed_service_spec.rb
+++ b/spec/services/authorized_project_update/project_access_changed_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::ProjectAccessChangedService do
+RSpec.describe AuthorizedProjectUpdate::ProjectAccessChangedService, feature_category: :projects do
describe '#execute' do
it 'executes projects_authorizations refresh' do
expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to receive(:bulk_perform_async)
diff --git a/spec/services/authorized_project_update/project_recalculate_per_user_service_spec.rb b/spec/services/authorized_project_update/project_recalculate_per_user_service_spec.rb
index 62862d0e558..7b2dd52810f 100644
--- a/spec/services/authorized_project_update/project_recalculate_per_user_service_spec.rb
+++ b/spec/services/authorized_project_update/project_recalculate_per_user_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::ProjectRecalculatePerUserService, '#execute' do
+RSpec.describe AuthorizedProjectUpdate::ProjectRecalculatePerUserService, '#execute', feature_category: :projects do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:another_user) { create(:user) }
diff --git a/spec/services/authorized_project_update/project_recalculate_service_spec.rb b/spec/services/authorized_project_update/project_recalculate_service_spec.rb
index c339faaeabf..8360f3c67ab 100644
--- a/spec/services/authorized_project_update/project_recalculate_service_spec.rb
+++ b/spec/services/authorized_project_update/project_recalculate_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::ProjectRecalculateService, '#execute' do
+RSpec.describe AuthorizedProjectUpdate::ProjectRecalculateService, '#execute', feature_category: :projects do
let_it_be(:project) { create(:project) }
subject(:execute) { described_class.new(project).execute }
diff --git a/spec/services/auto_merge/base_service_spec.rb b/spec/services/auto_merge/base_service_spec.rb
index 6c804a14620..7afe5d406ba 100644
--- a/spec/services/auto_merge/base_service_spec.rb
+++ b/spec/services/auto_merge/base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AutoMerge::BaseService do
+RSpec.describe AutoMerge::BaseService, feature_category: :code_review_workflow do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:service) { described_class.new(project, user, params) }
diff --git a/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb b/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb
index 676f55be28a..a0b22267960 100644
--- a/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb
+++ b/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AutoMerge::MergeWhenPipelineSucceedsService do
+RSpec.describe AutoMerge::MergeWhenPipelineSucceedsService, feature_category: :code_review_workflow do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
diff --git a/spec/services/auto_merge_service_spec.rb b/spec/services/auto_merge_service_spec.rb
index 7584e44152e..94f4b414dca 100644
--- a/spec/services/auto_merge_service_spec.rb
+++ b/spec/services/auto_merge_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AutoMergeService do
+RSpec.describe AutoMergeService, feature_category: :code_review_workflow do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/award_emojis/add_service_spec.rb b/spec/services/award_emojis/add_service_spec.rb
index 0fbb785e2d6..99dbe6dc606 100644
--- a/spec/services/award_emojis/add_service_spec.rb
+++ b/spec/services/award_emojis/add_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AwardEmojis::AddService do
+RSpec.describe AwardEmojis::AddService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:awardable) { create(:note, project: project) }
diff --git a/spec/services/award_emojis/base_service_spec.rb b/spec/services/award_emojis/base_service_spec.rb
index e0c8fd39ad9..f1ee4d1cfb8 100644
--- a/spec/services/award_emojis/base_service_spec.rb
+++ b/spec/services/award_emojis/base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AwardEmojis::BaseService do
+RSpec.describe AwardEmojis::BaseService, feature_category: :team_planning do
let(:awardable) { build(:note) }
let(:current_user) { build(:user) }
diff --git a/spec/services/award_emojis/collect_user_emoji_service_spec.rb b/spec/services/award_emojis/collect_user_emoji_service_spec.rb
index bf5aa0eb9ef..d75d5804f93 100644
--- a/spec/services/award_emojis/collect_user_emoji_service_spec.rb
+++ b/spec/services/award_emojis/collect_user_emoji_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AwardEmojis::CollectUserEmojiService do
+RSpec.describe AwardEmojis::CollectUserEmojiService, feature_category: :team_planning do
describe '#execute' do
it 'returns an Array containing the awarded emoji names' do
user = create(:user)
diff --git a/spec/services/award_emojis/copy_service_spec.rb b/spec/services/award_emojis/copy_service_spec.rb
index abb9c65e25d..6c1d7fb21e2 100644
--- a/spec/services/award_emojis/copy_service_spec.rb
+++ b/spec/services/award_emojis/copy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AwardEmojis::CopyService do
+RSpec.describe AwardEmojis::CopyService, feature_category: :team_planning do
let_it_be(:from_awardable) do
create(
:issue,
diff --git a/spec/services/award_emojis/destroy_service_spec.rb b/spec/services/award_emojis/destroy_service_spec.rb
index f743de7c59e..109bdbfa986 100644
--- a/spec/services/award_emojis/destroy_service_spec.rb
+++ b/spec/services/award_emojis/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AwardEmojis::DestroyService do
+RSpec.describe AwardEmojis::DestroyService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:awardable) { create(:note) }
let_it_be(:project) { awardable.project }
diff --git a/spec/services/award_emojis/toggle_service_spec.rb b/spec/services/award_emojis/toggle_service_spec.rb
index 74e97c66193..61dcc22561f 100644
--- a/spec/services/award_emojis/toggle_service_spec.rb
+++ b/spec/services/award_emojis/toggle_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AwardEmojis::ToggleService do
+RSpec.describe AwardEmojis::ToggleService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:awardable) { create(:note, project: project) }
diff --git a/spec/services/base_container_service_spec.rb b/spec/services/base_container_service_spec.rb
index 1de79eec702..7406f0aea93 100644
--- a/spec/services/base_container_service_spec.rb
+++ b/spec/services/base_container_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BaseContainerService do
+RSpec.describe BaseContainerService, feature_category: :container_registry do
let(:project) { Project.new }
let(:user) { User.new }
diff --git a/spec/services/base_count_service_spec.rb b/spec/services/base_count_service_spec.rb
index 18cab2e8e9a..9a731f52b09 100644
--- a/spec/services/base_count_service_spec.rb
+++ b/spec/services/base_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BaseCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe BaseCountService, :use_clean_rails_memory_store_caching, feature_category: :shared do
let(:service) { described_class.new }
describe '#relation_for_count' do
diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb
index f6a9f0903ce..5aaef9d529c 100644
--- a/spec/services/boards/create_service_spec.rb
+++ b/spec/services/boards/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::CreateService do
+RSpec.describe Boards::CreateService, feature_category: :team_planning do
describe '#execute' do
context 'when board parent is a project' do
let(:parent) { create(:project) }
diff --git a/spec/services/boards/destroy_service_spec.rb b/spec/services/boards/destroy_service_spec.rb
index cd6df832547..feaca3cfcce 100644
--- a/spec/services/boards/destroy_service_spec.rb
+++ b/spec/services/boards/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::DestroyService do
+RSpec.describe Boards::DestroyService, feature_category: :team_planning do
context 'with project board' do
let_it_be(:parent) { create(:project) }
diff --git a/spec/services/boards/issues/create_service_spec.rb b/spec/services/boards/issues/create_service_spec.rb
index c4f1eb093dc..f9a9c338f58 100644
--- a/spec/services/boards/issues/create_service_spec.rb
+++ b/spec/services/boards/issues/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::Issues::CreateService do
+RSpec.describe Boards::Issues::CreateService, feature_category: :team_planning do
describe '#execute' do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb
index 1959710bb0c..4089e9e6da0 100644
--- a/spec/services/boards/issues/list_service_spec.rb
+++ b/spec/services/boards/issues/list_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::Issues::ListService do
+RSpec.describe Boards::Issues::ListService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:user) { create(:user) }
@@ -57,7 +57,15 @@ RSpec.describe Boards::Issues::ListService do
end
context 'when filtering' do
- let_it_be(:incident) { create(:labeled_issue, project: project, milestone: m1, labels: [development, p1], issue_type: 'incident') }
+ let_it_be(:incident) do
+ create(
+ :labeled_issue,
+ :incident,
+ project: project,
+ milestone: m1,
+ labels: [development, p1]
+ )
+ end
context 'when filtering by type' do
it 'only returns the specified type' do
@@ -77,7 +85,6 @@ RSpec.describe Boards::Issues::ListService do
end
end
- # rubocop: disable RSpec/MultipleMemoizedHelpers
context 'when parent is a group' do
let(:project) { create(:project, :empty_repo, namespace: group) }
let(:project1) { create(:project, :empty_repo, namespace: group) }
@@ -148,7 +155,6 @@ RSpec.describe Boards::Issues::ListService do
it_behaves_like 'issues list service'
end
end
- # rubocop: enable RSpec/MultipleMemoizedHelpers
end
describe '.initialize_relative_positions' do
diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb
index 3a25f13762c..9c173f3f86e 100644
--- a/spec/services/boards/issues/move_service_spec.rb
+++ b/spec/services/boards/issues/move_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::Issues::MoveService do
+RSpec.describe Boards::Issues::MoveService, feature_category: :team_planning do
describe '#execute' do
context 'when parent is a project' do
let(:user) { create(:user) }
diff --git a/spec/services/boards/lists/create_service_spec.rb b/spec/services/boards/lists/create_service_spec.rb
index cac26b3c88d..da317fdd474 100644
--- a/spec/services/boards/lists/create_service_spec.rb
+++ b/spec/services/boards/lists/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::Lists::CreateService do
+RSpec.describe Boards::Lists::CreateService, feature_category: :team_planning do
context 'when board parent is a project' do
let_it_be(:parent) { create(:project) }
let_it_be(:board) { create(:board, project: parent) }
diff --git a/spec/services/boards/lists/destroy_service_spec.rb b/spec/services/boards/lists/destroy_service_spec.rb
index d5358bcc1e1..837635d49e8 100644
--- a/spec/services/boards/lists/destroy_service_spec.rb
+++ b/spec/services/boards/lists/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::Lists::DestroyService do
+RSpec.describe Boards::Lists::DestroyService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let(:list_type) { :list }
diff --git a/spec/services/boards/lists/list_service_spec.rb b/spec/services/boards/lists/list_service_spec.rb
index 2d41de42581..90b705e05c3 100644
--- a/spec/services/boards/lists/list_service_spec.rb
+++ b/spec/services/boards/lists/list_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::Lists::ListService do
+RSpec.describe Boards::Lists::ListService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
diff --git a/spec/services/boards/lists/move_service_spec.rb b/spec/services/boards/lists/move_service_spec.rb
index 2861fc48b4d..abf7d48e114 100644
--- a/spec/services/boards/lists/move_service_spec.rb
+++ b/spec/services/boards/lists/move_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::Lists::MoveService do
+RSpec.describe Boards::Lists::MoveService, feature_category: :team_planning do
describe '#execute' do
context 'when board parent is a project' do
let(:project) { create(:project) }
diff --git a/spec/services/boards/lists/update_service_spec.rb b/spec/services/boards/lists/update_service_spec.rb
index 21216e1b945..341eaa52292 100644
--- a/spec/services/boards/lists/update_service_spec.rb
+++ b/spec/services/boards/lists/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::Lists::UpdateService do
+RSpec.describe Boards::Lists::UpdateService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let!(:list) { create(:list, board: board, position: 0) }
diff --git a/spec/services/boards/visits/create_service_spec.rb b/spec/services/boards/visits/create_service_spec.rb
index 8910345d170..4af4e914da5 100644
--- a/spec/services/boards/visits/create_service_spec.rb
+++ b/spec/services/boards/visits/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::Visits::CreateService do
+RSpec.describe Boards::Visits::CreateService, feature_category: :team_planning do
describe '#execute' do
let(:user) { create(:user) }
diff --git a/spec/services/branches/create_service_spec.rb b/spec/services/branches/create_service_spec.rb
index 19a32aafa38..7fb7d9d440d 100644
--- a/spec/services/branches/create_service_spec.rb
+++ b/spec/services/branches/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Branches::CreateService, :use_clean_rails_redis_caching do
+RSpec.describe Branches::CreateService, :use_clean_rails_redis_caching, feature_category: :source_code_management do
subject(:service) { described_class.new(project, user) }
let_it_be(:project) { create(:project_empty_repo) }
@@ -108,7 +108,7 @@ RSpec.describe Branches::CreateService, :use_clean_rails_redis_caching do
control = RedisCommands::Recorder.new(pattern: ':branch_names:') { subject }
- expect(control.by_command(:sadd).count).to eq(1)
+ expect(control).not_to exceed_redis_command_calls_limit(:sadd, 1)
end
end
diff --git a/spec/services/branches/delete_merged_service_spec.rb b/spec/services/branches/delete_merged_service_spec.rb
index 46611670fe1..23a892a56ef 100644
--- a/spec/services/branches/delete_merged_service_spec.rb
+++ b/spec/services/branches/delete_merged_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Branches::DeleteMergedService do
+RSpec.describe Branches::DeleteMergedService, feature_category: :source_code_management do
include ProjectForksHelper
subject(:service) { described_class.new(project, project.first_owner) }
diff --git a/spec/services/branches/delete_service_spec.rb b/spec/services/branches/delete_service_spec.rb
index 727cadc5a50..003645b1b8c 100644
--- a/spec/services/branches/delete_service_spec.rb
+++ b/spec/services/branches/delete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Branches::DeleteService do
+RSpec.describe Branches::DeleteService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:user) { create(:user) }
diff --git a/spec/services/branches/diverging_commit_counts_service_spec.rb b/spec/services/branches/diverging_commit_counts_service_spec.rb
index 34a2b81c831..3cccc74735c 100644
--- a/spec/services/branches/diverging_commit_counts_service_spec.rb
+++ b/spec/services/branches/diverging_commit_counts_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Branches::DivergingCommitCountsService do
+RSpec.describe Branches::DivergingCommitCountsService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
diff --git a/spec/services/branches/validate_new_service_spec.rb b/spec/services/branches/validate_new_service_spec.rb
index 02127c8c10d..a5b75a09353 100644
--- a/spec/services/branches/validate_new_service_spec.rb
+++ b/spec/services/branches/validate_new_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Branches::ValidateNewService do
+RSpec.describe Branches::ValidateNewService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
subject(:service) { described_class.new(project) }
diff --git a/spec/services/bulk_create_integration_service_spec.rb b/spec/services/bulk_create_integration_service_spec.rb
index 22bb1736f9f..57bdfdbd4cb 100644
--- a/spec/services/bulk_create_integration_service_spec.rb
+++ b/spec/services/bulk_create_integration_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkCreateIntegrationService do
+RSpec.describe BulkCreateIntegrationService, feature_category: :integrations do
include JiraIntegrationHelpers
before_all do
diff --git a/spec/services/bulk_imports/archive_extraction_service_spec.rb b/spec/services/bulk_imports/archive_extraction_service_spec.rb
index da9df31cde9..40f8d8718ae 100644
--- a/spec/services/bulk_imports/archive_extraction_service_spec.rb
+++ b/spec/services/bulk_imports/archive_extraction_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::ArchiveExtractionService do
+RSpec.describe BulkImports::ArchiveExtractionService, feature_category: :importers do
let_it_be(:tmpdir) { Dir.mktmpdir }
let_it_be(:filename) { 'symlink_export.tar' }
let_it_be(:filepath) { File.join(tmpdir, filename) }
diff --git a/spec/services/bulk_imports/batched_relation_export_service_spec.rb b/spec/services/bulk_imports/batched_relation_export_service_spec.rb
new file mode 100644
index 00000000000..c361dfe5052
--- /dev/null
+++ b/spec/services/bulk_imports/batched_relation_export_service_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::BatchedRelationExportService, feature_category: :importers do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:portable) { create(:group) }
+
+ let(:relation) { 'labels' }
+ let(:jid) { '123' }
+
+ subject(:service) { described_class.new(user, portable, relation, jid) }
+
+ describe '#execute' do
+ context 'when there are batches to export' do
+ let_it_be(:label) { create(:group_label, group: portable) }
+
+ it 'marks export as started' do
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.reload.started?).to eq(true)
+ end
+
+ it 'removes existing batches' do
+ expect_next_instance_of(BulkImports::Export) do |export|
+ expect(export.batches).to receive(:destroy_all)
+ end
+
+ service.execute
+ end
+
+ it 'enqueues export jobs for each batch & caches batch record ids' do
+ expect(BulkImports::RelationBatchExportWorker).to receive(:perform_async)
+ expect(Gitlab::Cache::Import::Caching).to receive(:set_add)
+
+ service.execute
+ end
+
+ it 'enqueues FinishBatchedRelationExportWorker' do
+ expect(BulkImports::FinishBatchedRelationExportWorker).to receive(:perform_async)
+
+ service.execute
+ end
+
+ context 'when there are multiple batches' do
+ it 'creates a batch record for each batch of records' do
+ stub_const("#{described_class.name}::BATCH_SIZE", 1)
+
+ create_list(:group_label, 10, group: portable)
+
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.batches.count).to eq(11)
+ end
+ end
+ end
+
+ context 'when there are no batches to export' do
+ let(:relation) { 'milestones' }
+
+ it 'marks export as finished' do
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.finished?).to eq(true)
+ expect(export.batches.count).to eq(0)
+ end
+ end
+
+ context 'when exception occurs' do
+ it 'tracks exception and marks export as failed' do
+ allow_next_instance_of(BulkImports::Export) do |export|
+ allow(export).to receive(:update!).and_call_original
+
+ allow(export)
+ .to receive(:update!)
+ .with(status_event: 'finish', total_objects_count: 0, batched: true, batches_count: 0, jid: jid, error: nil)
+ .and_raise(StandardError, 'Error!')
+ end
+
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(StandardError, portable_id: portable.id, portable_type: portable.class.name)
+
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.reload.failed?).to eq(true)
+ end
+ end
+ end
+
+ describe '.cache_key' do
+ it 'returns cache key given export and batch ids' do
+ expect(described_class.cache_key(1, 1)).to eq('bulk_imports/batched_relation_export/1/1')
+ end
+ end
+end
diff --git a/spec/services/bulk_imports/create_service_spec.rb b/spec/services/bulk_imports/create_service_spec.rb
index 7f892cfe722..ff4afd6abd0 100644
--- a/spec/services/bulk_imports/create_service_spec.rb
+++ b/spec/services/bulk_imports/create_service_spec.rb
@@ -35,6 +35,9 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
]
end
+ let(:source_entity_identifier) { ERB::Util.url_encode(params[0][:source_full_path]) }
+ let(:source_entity_type) { BulkImports::CreateService::ENTITY_TYPES_MAPPING.fetch(params[0][:source_type]) }
+
subject { described_class.new(user, params, credentials) }
describe '#execute' do
@@ -59,6 +62,34 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
end
end
+ context 'when direct transfer setting query returns a 404' do
+ it 'raises a ServiceResponse::Error' do
+ stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
+ stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
+ .to_return(
+ status: 200,
+ body: source_version.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=token")
+ .to_return(status: 404)
+
+ expect_next_instance_of(BulkImports::Clients::HTTP) do |client|
+ expect(client).to receive(:get).and_raise(BulkImports::Error.setting_not_enabled)
+ end
+
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result).to be_error
+ expect(result.message)
+ .to eq(
+ "Group import disabled on source or destination instance. " \
+ "Ask an administrator to enable it on both instances and try again."
+ )
+ end
+ end
+
context 'when required scopes are not present' do
it 'returns ServiceResponse with error if token does not have api scope' do
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
@@ -68,9 +99,13 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
body: source_version.to_json,
headers: { 'Content-Type' => 'application/json' }
)
+ stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=token")
+ .to_return(
+ status: 200
+ )
allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
- allow(client).to receive(:validate_instance_version!).and_raise(BulkImports::Error.scope_validation_failure)
+ allow(client).to receive(:validate_import_scopes!).and_raise(BulkImports::Error.scope_validation_failure)
end
result = subject.execute
@@ -79,8 +114,8 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
expect(result).to be_error
expect(result.message)
.to eq(
- "Import aborted as the provided personal access token does not have the required 'api' scope or is " \
- "no longer valid."
+ "Personal access token does not " \
+ "have the required 'api' scope or is no longer valid."
)
end
end
@@ -90,16 +125,21 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
.to_return(status: 200, body: source_version.to_json, headers: { 'Content-Type' => 'application/json' })
+ stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=token")
+ .to_return(
+ status: 200
+ )
stub_request(:get, 'http://gitlab.example/api/v4/personal_access_tokens/self?private_token=token')
.to_return(
status: 200,
body: { 'scopes' => ['api'] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
+
+ parent_group.add_owner(user)
end
it 'creates bulk import' do
- parent_group.add_owner(user)
expect { subject.execute }.to change { BulkImport.count }.by(1)
last_bulk_import = BulkImport.last
@@ -111,7 +151,8 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
expect_snowplow_event(
category: 'BulkImports::CreateService',
action: 'create',
- label: 'bulk_import_group'
+ label: 'bulk_import_group',
+ extra: { source_equals_destination: false }
)
expect_snowplow_event(
@@ -123,6 +164,23 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
)
end
+ context 'on the same instance' do
+ before do
+ allow(Settings.gitlab).to receive(:base_url).and_return('http://gitlab.example')
+ end
+
+ it 'tracks the same instance migration' do
+ expect { subject.execute }.to change { BulkImport.count }.by(1)
+
+ expect_snowplow_event(
+ category: 'BulkImports::CreateService',
+ action: 'create',
+ label: 'bulk_import_group',
+ extra: { source_equals_destination: true }
+ )
+ end
+ end
+
describe 'projects migration flag' do
let(:import) { BulkImport.last }
@@ -169,11 +227,16 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
allow_next_instance_of(BulkImports::Clients::HTTP) do |instance|
allow(instance).to receive(:instance_version).and_return(source_version)
allow(instance).to receive(:instance_enterprise).and_return(false)
+ stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=token")
+ .to_return(
+ status: 200
+ )
end
+
+ parent_group.add_owner(user)
end
it 'creates bulk import' do
- parent_group.add_owner(user)
expect { subject.execute }.to change { BulkImport.count }.by(1)
last_bulk_import = BulkImport.last
@@ -186,7 +249,8 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
expect_snowplow_event(
category: 'BulkImports::CreateService',
action: 'create',
- label: 'bulk_import_group'
+ label: 'bulk_import_group',
+ extra: { source_equals_destination: false }
)
expect_snowplow_event(
@@ -198,6 +262,23 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
)
end
+ context 'on the same instance' do
+ before do
+ allow(Settings.gitlab).to receive(:base_url).and_return('http://gitlab.example')
+ end
+
+ it 'tracks the same instance migration' do
+ expect { subject.execute }.to change { BulkImport.count }.by(1)
+
+ expect_snowplow_event(
+ category: 'BulkImports::CreateService',
+ action: 'create',
+ label: 'bulk_import_group',
+ extra: { source_equals_destination: true }
+ )
+ end
+ end
+
it 'creates bulk import entities' do
expect { subject.execute }.to change { BulkImports::Entity.count }.by(3)
end
@@ -227,11 +308,10 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
expect(result).to be_a(ServiceResponse)
expect(result).to be_error
expect(result.message).to eq("Validation failed: Source full path can't be blank, " \
- "Source full path cannot start with a non-alphanumeric character except " \
- "for periods or underscores, can contain only alphanumeric characters, " \
- "forward slashes, periods, and underscores, cannot end with " \
- "a period or forward slash, and has a relative path structure " \
- "with no http protocol chars or leading or trailing forward slashes")
+ "Source full path must have a relative path structure with " \
+ "no HTTP protocol characters, or leading or trailing forward slashes. " \
+ "Path segments must not start or end with a special character, and " \
+ "must not contain consecutive special characters.")
end
describe '#user-role' do
@@ -263,6 +343,8 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
end
it 'defines access_level as not a member' do
+ parent_group.members.delete_all
+
subject.execute
expect_snowplow_event(
category: 'BulkImports::CreateService',
@@ -325,7 +407,210 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
end
end
- describe '.validate_destination_full_path' do
+ describe '#validate_setting_enabled!' do
+ let(:entity_source_id) { 'gid://gitlab/Model/12345' }
+ let(:graphql_client) { instance_double(BulkImports::Clients::Graphql) }
+ let(:http_client) { instance_double(BulkImports::Clients::HTTP) }
+ let(:http_response) { double(code: 200, success?: true) } # rubocop:disable RSpec/VerifiedDoubles
+
+ before do
+ allow(BulkImports::Clients::HTTP).to receive(:new).and_return(http_client)
+ allow(BulkImports::Clients::Graphql).to receive(:new).and_return(graphql_client)
+
+ allow(http_client).to receive(:instance_version).and_return(status: 200)
+ allow(http_client).to receive(:instance_enterprise).and_return(false)
+ allow(http_client).to receive(:validate_instance_version!).and_return(source_version)
+ allow(http_client).to receive(:validate_import_scopes!).and_return(true)
+ end
+
+ context 'when the source_type is a group' do
+ context 'when the source_full_path contains only integer characters' do
+ let(:query_string) { BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil).to_s }
+ let(:graphql_response) do
+ double(original_hash: { 'data' => { 'group' => { 'id' => entity_source_id } } }) # rubocop:disable RSpec/VerifiedDoubles
+ end
+
+ let(:params) do
+ [
+ {
+ source_type: 'group_entity',
+ source_full_path: '67890',
+ destination_slug: 'destination-group-1',
+ destination_namespace: 'destination1'
+ }
+ ]
+ end
+
+ before do
+ allow(graphql_client).to receive(:parse).with(query_string)
+ allow(graphql_client).to receive(:execute).and_return(graphql_response)
+
+ allow(http_client).to receive(:get)
+ .with("/groups/12345/export_relations/status")
+ .and_return(http_response)
+
+ stub_request(:get, "http://gitlab.example/api/v4/groups/12345/export_relations/status?page=1&per_page=30&private_token=token")
+ .to_return(status: 200, body: "", headers: {})
+ end
+
+ it 'makes a graphql request using the group full path and an http request with the correct id' do
+ expect(graphql_client).to receive(:parse).with(query_string)
+ expect(graphql_client).to receive(:execute).and_return(graphql_response)
+
+ expect(http_client).to receive(:get).with("/groups/12345/export_relations/status")
+
+ subject.execute
+ end
+ end
+ end
+
+ context 'when the source_type is a project' do
+ context 'when the source_full_path contains only integer characters' do
+ let(:query_string) { BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil).to_s }
+ let(:graphql_response) do
+ double(original_hash: { 'data' => { 'project' => { 'id' => entity_source_id } } }) # rubocop:disable RSpec/VerifiedDoubles
+ end
+
+ let(:params) do
+ [
+ {
+ source_type: 'project_entity',
+ source_full_path: '67890',
+ destination_slug: 'destination-group-1',
+ destination_namespace: 'destination1'
+ }
+ ]
+ end
+
+ before do
+ allow(graphql_client).to receive(:parse).with(query_string)
+ allow(graphql_client).to receive(:execute).and_return(graphql_response)
+
+ allow(http_client).to receive(:get)
+ .with("/projects/12345/export_relations/status")
+ .and_return(http_response)
+
+ stub_request(:get, "http://gitlab.example/api/v4/projects/12345/export_relations/status?page=1&per_page=30&private_token=token")
+ .to_return(status: 200, body: "", headers: {})
+ end
+
+ it 'makes a graphql request using the group full path and an http request with the correct id' do
+ expect(graphql_client).to receive(:parse).with(query_string)
+ expect(graphql_client).to receive(:execute).and_return(graphql_response)
+
+ expect(http_client).to receive(:get).with("/projects/12345/export_relations/status")
+
+ subject.execute
+ end
+ end
+ end
+ end
+
+ describe '#validate_destination_namespace' do
+ context 'when the destination_namespace does not exist' do
+ let(:params) do
+ [
+ {
+ source_type: 'group_entity',
+ source_full_path: 'full/path/to/source',
+ destination_slug: 'destination-slug',
+ destination_namespace: 'destination-namespace',
+ migrate_projects: migrate_projects
+ }
+ ]
+ end
+
+ it 'returns ServiceResponse with an error message' do
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result).to be_error
+ expect(result.message)
+ .to eq("Import failed. Destination 'destination-namespace' is invalid, or you don't have permission.")
+ end
+ end
+
+ context 'when the user does not have permission to create subgroups' do
+ let(:params) do
+ [
+ {
+ source_type: 'group_entity',
+ source_full_path: 'full/path/to/source',
+ destination_slug: 'destination-slug',
+ destination_namespace: parent_group.path,
+ migrate_projects: migrate_projects
+ }
+ ]
+ end
+
+ it 'returns ServiceResponse with an error message' do
+ parent_group.members.delete_all
+
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result).to be_error
+ expect(result.message)
+ .to eq("Import failed. Destination '#{parent_group.path}' is invalid, or you don't have permission.")
+ end
+ end
+
+ context 'when the user does not have permission to create projects' do
+ let(:params) do
+ [
+ {
+ source_type: 'project_entity',
+ source_full_path: 'full/path/to/source',
+ destination_slug: 'destination-slug',
+ destination_namespace: parent_group.path,
+ migrate_projects: migrate_projects
+ }
+ ]
+ end
+
+ it 'returns ServiceResponse with an error message' do
+ parent_group.members.delete_all
+
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result).to be_error
+ expect(result.message)
+ .to eq("Import failed. Destination '#{parent_group.path}' is invalid, or you don't have permission.")
+ end
+ end
+ end
+
+ describe '#validate_destination_slug' do
+ context 'when the destination_slug is invalid' do
+ let(:params) do
+ [
+ {
+ source_type: 'group_entity',
+ source_full_path: 'full/path/to/source',
+ destination_slug: 'destin-*-ation-slug',
+ destination_namespace: parent_group.path,
+ migrate_projects: migrate_projects
+ }
+ ]
+ end
+
+ it 'returns ServiceResponse with an error message' do
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result).to be_error
+ expect(result.message)
+ .to eq(
+ "Import failed. Destination URL " \
+ "must not start or end with a special character and must " \
+ "not contain consecutive special characters."
+ )
+ end
+ end
+ end
+
+ describe '#validate_destination_full_path' do
context 'when the source_type is a group' do
context 'when the provided destination_slug already exists in the destination_namespace' do
let_it_be(:existing_subgroup) { create(:group, path: 'existing-subgroup', parent_id: parent_group.id ) }
@@ -349,7 +634,7 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
expect(result).to be_error
expect(result.message)
.to eq(
- "Import aborted as 'parent-group/existing-subgroup' already exists. " \
+ "Import failed. 'parent-group/existing-subgroup' already exists. " \
"Change the destination and try again."
)
end
@@ -376,7 +661,7 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
expect(result).to be_error
expect(result.message)
.to eq(
- "Import aborted as 'top-level-group' already exists. " \
+ "Import failed. 'top-level-group' already exists. " \
"Change the destination and try again."
)
end
@@ -421,13 +706,15 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
end
it 'returns ServiceResponse with an error message' do
+ existing_group.add_owner(user)
+
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result).to be_error
expect(result.message)
.to eq(
- "Import aborted as 'existing-group/existing-project' already exists. " \
+ "Import failed. 'existing-group/existing-project' already exists. " \
"Change the destination and try again."
)
end
@@ -448,6 +735,8 @@ RSpec.describe BulkImports::CreateService, feature_category: :importers do
end
it 'returns success ServiceResponse' do
+ existing_group.add_owner(user)
+
result = subject.execute
expect(result).to be_a(ServiceResponse)
diff --git a/spec/services/bulk_imports/export_service_spec.rb b/spec/services/bulk_imports/export_service_spec.rb
index 2414f7c5ca7..25a4547477c 100644
--- a/spec/services/bulk_imports/export_service_spec.rb
+++ b/spec/services/bulk_imports/export_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::ExportService do
+RSpec.describe BulkImports::ExportService, feature_category: :importers do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
@@ -13,17 +13,36 @@ RSpec.describe BulkImports::ExportService do
subject { described_class.new(portable: group, user: user) }
describe '#execute' do
- it 'schedules RelationExportWorker for each top level relation' do
- expect(subject).to receive(:execute).and_return(ServiceResponse.success).and_call_original
- top_level_relations = BulkImports::FileTransfer.config_for(group).portable_relations
-
- top_level_relations.each do |relation|
- expect(BulkImports::RelationExportWorker)
- .to receive(:perform_async)
- .with(user.id, group.id, group.class.name, relation)
+ let_it_be(:top_level_relations) { BulkImports::FileTransfer.config_for(group).portable_relations }
+
+ before do
+ allow(subject).to receive(:execute).and_return(ServiceResponse.success).and_call_original
+ end
+
+ context 'when export is not batched' do
+ it 'schedules RelationExportWorker for each top level relation' do
+ top_level_relations.each do |relation|
+ expect(BulkImports::RelationExportWorker)
+ .to receive(:perform_async)
+ .with(user.id, group.id, group.class.name, relation, false)
+ end
+
+ subject.execute
end
+ end
+
+ context 'when export is batched' do
+ subject { described_class.new(portable: group, user: user, batched: true) }
- subject.execute
+ it 'schedules RelationExportWorker with a `batched: true` flag' do
+ top_level_relations.each do |relation|
+ expect(BulkImports::RelationExportWorker)
+ .to receive(:perform_async)
+ .with(user.id, group.id, group.class.name, relation, true)
+ end
+
+ subject.execute
+ end
end
context 'when exception occurs' do
@@ -38,6 +57,20 @@ RSpec.describe BulkImports::ExportService do
service.execute
end
+
+ context 'when user is not allowed to perform export' do
+ let(:another_user) { create(:user) }
+
+ it 'does not schedule RelationExportWorker' do
+ another_user = create(:user)
+ service = described_class.new(portable: group, user: another_user)
+ response = service.execute
+
+ expect(response.status).to eq(:error)
+ expect(response.message).to eq(Gitlab::ImportExport::Error)
+ expect(response.http_status).to eq(:unprocessable_entity)
+ end
+ end
end
end
end
diff --git a/spec/services/bulk_imports/file_decompression_service_spec.rb b/spec/services/bulk_imports/file_decompression_service_spec.rb
index 77348428d60..9b8320aeac5 100644
--- a/spec/services/bulk_imports/file_decompression_service_spec.rb
+++ b/spec/services/bulk_imports/file_decompression_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::FileDecompressionService do
+RSpec.describe BulkImports::FileDecompressionService, feature_category: :importers do
let_it_be(:tmpdir) { Dir.mktmpdir }
let_it_be(:ndjson_filename) { 'labels.ndjson' }
let_it_be(:ndjson_filepath) { File.join(tmpdir, ndjson_filename) }
diff --git a/spec/services/bulk_imports/file_download_service_spec.rb b/spec/services/bulk_imports/file_download_service_spec.rb
index 27f77b678e3..7c64d6efc65 100644
--- a/spec/services/bulk_imports/file_download_service_spec.rb
+++ b/spec/services/bulk_imports/file_download_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::FileDownloadService do
+RSpec.describe BulkImports::FileDownloadService, feature_category: :importers do
describe '#execute' do
let_it_be(:allowed_content_types) { %w(application/gzip application/octet-stream) }
let_it_be(:file_size_limit) { 5.gigabytes }
diff --git a/spec/services/bulk_imports/file_export_service_spec.rb b/spec/services/bulk_imports/file_export_service_spec.rb
index 453fc1d0c0d..001fccb2054 100644
--- a/spec/services/bulk_imports/file_export_service_spec.rb
+++ b/spec/services/bulk_imports/file_export_service_spec.rb
@@ -2,21 +2,23 @@
require 'spec_helper'
-RSpec.describe BulkImports::FileExportService do
+RSpec.describe BulkImports::FileExportService, feature_category: :importers do
let_it_be(:project) { create(:project) }
+ let(:relations) do
+ {
+ 'uploads' => BulkImports::UploadsExportService,
+ 'lfs_objects' => BulkImports::LfsObjectsExportService,
+ 'repository' => BulkImports::RepositoryBundleExportService,
+ 'design' => BulkImports::RepositoryBundleExportService
+ }
+ end
+
describe '#execute' do
it 'executes export service and archives exported data for each file relation' do
- relations = {
- 'uploads' => BulkImports::UploadsExportService,
- 'lfs_objects' => BulkImports::LfsObjectsExportService,
- 'repository' => BulkImports::RepositoryBundleExportService,
- 'design' => BulkImports::RepositoryBundleExportService
- }
-
relations.each do |relation, klass|
Dir.mktmpdir do |export_path|
- service = described_class.new(project, export_path, relation)
+ service = described_class.new(project, export_path, relation, nil)
expect_next_instance_of(klass) do |service|
expect(service).to receive(:execute)
@@ -31,18 +33,58 @@ RSpec.describe BulkImports::FileExportService do
context 'when unsupported relation is passed' do
it 'raises an error' do
- service = described_class.new(project, nil, 'unsupported')
+ service = described_class.new(project, nil, 'unsupported', nil)
expect { service.execute }.to raise_error(BulkImports::Error, 'Unsupported relation export type')
end
end
end
+ describe '#execute_batch' do
+ it 'calls execute with provided array of record ids' do
+ relations.each do |relation, klass|
+ Dir.mktmpdir do |export_path|
+ service = described_class.new(project, export_path, relation, nil)
+
+ expect_next_instance_of(klass) do |service|
+ expect(service).to receive(:execute).with({ batch_ids: [1, 2, 3] })
+ end
+
+ service.export_batch([1, 2, 3])
+ end
+ end
+ end
+ end
+
describe '#exported_filename' do
it 'returns filename of the exported file' do
- service = described_class.new(project, nil, 'uploads')
+ service = described_class.new(project, nil, 'uploads', nil)
expect(service.exported_filename).to eq('uploads.tar')
end
end
+
+ describe '#exported_objects_count' do
+ context 'when relation is a collection' do
+ it 'returns a number of exported relations' do
+ %w[uploads lfs_objects].each do |relation|
+ service = described_class.new(project, nil, relation, nil)
+
+ allow(service).to receive_message_chain(:export_service, :exported_objects_count).and_return(10)
+
+ expect(service.exported_objects_count).to eq(10)
+ end
+ end
+ end
+
+ context 'when relation is a repository' do
+ it 'returns 1' do
+ %w[repository design].each do |relation|
+ service = described_class.new(project, nil, relation, nil)
+
+ expect(service.exported_objects_count).to eq(1)
+ end
+ end
+ end
+ end
end
diff --git a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
index 894789c7941..587c99d9897 100644
--- a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
+++ b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::LfsObjectsExportService do
+RSpec.describe BulkImports::LfsObjectsExportService, feature_category: :importers do
let_it_be(:project) { create(:project) }
let_it_be(:lfs_json_filename) { "#{BulkImports::FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION}.json" }
let_it_be(:remote_url) { 'http://my-object-storage.local' }
@@ -53,6 +53,19 @@ RSpec.describe BulkImports::LfsObjectsExportService do
)
end
+ context 'when export is batched' do
+ it 'exports only specified lfs objects' do
+ new_lfs_object = create(:lfs_object, :with_file)
+
+ project.lfs_objects << new_lfs_object
+
+ service.execute(batch_ids: [new_lfs_object.id])
+
+ expect(File).to exist(File.join(export_path, new_lfs_object.oid))
+ expect(File).not_to exist(File.join(export_path, lfs_object.oid))
+ end
+ end
+
context 'when lfs object has file on disk missing' do
it 'does not attempt to copy non-existent file' do
FileUtils.rm(lfs_object.file.path)
@@ -79,4 +92,14 @@ RSpec.describe BulkImports::LfsObjectsExportService do
end
end
end
+
+ describe '#exported_objects_count' do
+ it 'return the number of exported lfs objects' do
+ project.lfs_objects << create(:lfs_object, :with_file)
+
+ service.execute
+
+ expect(service.exported_objects_count).to eq(2)
+ end
+ end
end
diff --git a/spec/services/bulk_imports/relation_batch_export_service_spec.rb b/spec/services/bulk_imports/relation_batch_export_service_spec.rb
new file mode 100644
index 00000000000..c3abd02aff8
--- /dev/null
+++ b/spec/services/bulk_imports/relation_batch_export_service_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::RelationBatchExportService, feature_category: :importers do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:label) { create(:label, project: project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:export) { create(:bulk_import_export, :batched, project: project) }
+ let_it_be(:batch) { create(:bulk_import_export_batch, export: export) }
+ let_it_be(:cache_key) { BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id) }
+
+ subject(:service) { described_class.new(user.id, batch.id) }
+
+ before(:all) do
+ Gitlab::Cache::Import::Caching.set_add(cache_key, label.id)
+ end
+
+ after(:all) do
+ Gitlab::Cache::Import::Caching.expire(cache_key, 0)
+ end
+
+ describe '#execute' do
+ it 'exports relation batch' do
+ expect(Gitlab::Cache::Import::Caching).to receive(:values_from_set).with(cache_key).and_call_original
+
+ service.execute
+ batch.reload
+
+ expect(batch.finished?).to eq(true)
+ expect(batch.objects_count).to eq(1)
+ expect(batch.error).to be_nil
+ expect(export.upload.export_file).to be_present
+ end
+
+ it 'removes exported contents after export' do
+ double = instance_double(BulkImports::FileTransfer::ProjectConfig, export_path: 'foo')
+
+ allow(BulkImports::FileTransfer).to receive(:config_for).and_return(double)
+ allow(double).to receive(:export_service_for).and_raise(StandardError, 'Error!')
+ allow(FileUtils).to receive(:remove_entry)
+
+ expect(FileUtils).to receive(:remove_entry).with('foo')
+
+ service.execute
+ end
+
+ context 'when exception occurs' do
+ before do
+ allow(service).to receive(:gzip).and_raise(StandardError, 'Error!')
+ end
+
+ it 'marks batch as failed' do
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(StandardError, portable_id: project.id, portable_type: 'Project')
+
+ service.execute
+ batch.reload
+
+ expect(batch.failed?).to eq(true)
+ expect(batch.objects_count).to eq(0)
+ expect(batch.error).to eq('Error!')
+ end
+ end
+ end
+end
diff --git a/spec/services/bulk_imports/relation_export_service_spec.rb b/spec/services/bulk_imports/relation_export_service_spec.rb
index f0f85217d2e..1c050fe4143 100644
--- a/spec/services/bulk_imports/relation_export_service_spec.rb
+++ b/spec/services/bulk_imports/relation_export_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::RelationExportService do
+RSpec.describe BulkImports::RelationExportService, feature_category: :importers do
let_it_be(:jid) { 'jid' }
let_it_be(:relation) { 'labels' }
let_it_be(:user) { create(:user) }
@@ -35,6 +35,10 @@ RSpec.describe BulkImports::RelationExportService do
expect(export.reload.upload.export_file).to be_present
expect(export.finished?).to eq(true)
+ expect(export.batched?).to eq(false)
+ expect(export.batches_count).to eq(0)
+ expect(export.batches.count).to eq(0)
+ expect(export.total_objects_count).to eq(0)
end
it 'removes temp export files' do
@@ -133,13 +137,23 @@ RSpec.describe BulkImports::RelationExportService do
include_examples 'tracks exception', ActiveRecord::RecordInvalid
end
+ end
+
+ context 'when export was batched' do
+ let(:relation) { 'milestones' }
+ let(:export) { create(:bulk_import_export, group: group, relation: relation, batched: true, batches_count: 2) }
- context 'when user is not allowed to perform export' do
- let(:another_user) { create(:user) }
+ it 'removes existing batches and marks export as not batched' do
+ create(:bulk_import_export_batch, batch_number: 1, export: export)
+ create(:bulk_import_export_batch, batch_number: 2, export: export)
- subject { described_class.new(another_user, group, relation, jid) }
+ expect { described_class.new(user, group, relation, jid).execute }
+ .to change { export.reload.batches.count }
+ .from(2)
+ .to(0)
- include_examples 'tracks exception', Gitlab::ImportExport::Error
+ expect(export.batched?).to eq(false)
+ expect(export.batches_count).to eq(0)
end
end
end
diff --git a/spec/services/bulk_imports/repository_bundle_export_service_spec.rb b/spec/services/bulk_imports/repository_bundle_export_service_spec.rb
index f0d63de1ab9..92d5d21c33f 100644
--- a/spec/services/bulk_imports/repository_bundle_export_service_spec.rb
+++ b/spec/services/bulk_imports/repository_bundle_export_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::RepositoryBundleExportService do
+RSpec.describe BulkImports::RepositoryBundleExportService, feature_category: :importers do
let(:project) { create(:project) }
let(:export_path) { Dir.mktmpdir }
diff --git a/spec/services/bulk_imports/tree_export_service_spec.rb b/spec/services/bulk_imports/tree_export_service_spec.rb
index 6e26cb6dc2b..ae78858976f 100644
--- a/spec/services/bulk_imports/tree_export_service_spec.rb
+++ b/spec/services/bulk_imports/tree_export_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::TreeExportService do
+RSpec.describe BulkImports::TreeExportService, feature_category: :importers do
let_it_be(:project) { create(:project) }
let_it_be(:export_path) { Dir.mktmpdir }
@@ -53,4 +53,14 @@ RSpec.describe BulkImports::TreeExportService do
end
end
end
+
+ describe '#export_batch' do
+ it 'serializes relation with specified ids' do
+ expect_next_instance_of(Gitlab::ImportExport::Json::StreamingSerializer) do |serializer|
+ expect(serializer).to receive(:serialize_relation).with(anything, batch_ids: [1, 2, 3])
+ end
+
+ subject.export_batch([1, 2, 3])
+ end
+ end
end
diff --git a/spec/services/bulk_imports/uploads_export_service_spec.rb b/spec/services/bulk_imports/uploads_export_service_spec.rb
index ad6e005485c..709ade4a504 100644
--- a/spec/services/bulk_imports/uploads_export_service_spec.rb
+++ b/spec/services/bulk_imports/uploads_export_service_spec.rb
@@ -2,10 +2,9 @@
require 'spec_helper'
-RSpec.describe BulkImports::UploadsExportService do
- let_it_be(:export_path) { Dir.mktmpdir }
- let_it_be(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) }
-
+RSpec.describe BulkImports::UploadsExportService, feature_category: :importers do
+ let(:export_path) { Dir.mktmpdir }
+ let(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) }
let!(:upload) { create(:upload, :with_file, :issuable_upload, uploader: FileUploader, model: project) }
let(:exported_filepath) { File.join(export_path, upload.secret, upload.retrieve_uploader.filename) }
@@ -23,6 +22,16 @@ RSpec.describe BulkImports::UploadsExportService do
expect(File).to exist(exported_filepath)
end
+ context 'when export is batched' do
+ it 'exports only specified uploads' do
+ service.execute(batch_ids: [upload.id])
+
+ expect(service.exported_objects_count).to eq(1)
+ expect(File).not_to exist(File.join(export_path, 'avatar', 'rails_sample.png'))
+ expect(File).to exist(exported_filepath)
+ end
+ end
+
context 'when upload has underlying file missing' do
context 'with an upload missing its file' do
it 'does not cause errors' do
@@ -53,6 +62,16 @@ RSpec.describe BulkImports::UploadsExportService do
}
)
+ expect(Gitlab::ErrorTracking)
+ .to receive(:log_exception)
+ .with(
+ instance_of(exception), {
+ portable_id: project.id,
+ portable_class: 'Project',
+ upload_id: project.avatar.upload.id
+ }
+ )
+
service.execute
expect(File).not_to exist(exported_filepath)
@@ -73,4 +92,12 @@ RSpec.describe BulkImports::UploadsExportService do
end
end
end
+
+ describe '#exported_objects_count' do
+ it 'return the number of exported uploads' do
+ service.execute
+
+ expect(service.exported_objects_count).to eq(2)
+ end
+ end
end
diff --git a/spec/services/bulk_push_event_payload_service_spec.rb b/spec/services/bulk_push_event_payload_service_spec.rb
index 381c735c003..95e1c831498 100644
--- a/spec/services/bulk_push_event_payload_service_spec.rb
+++ b/spec/services/bulk_push_event_payload_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkPushEventPayloadService do
+RSpec.describe BulkPushEventPayloadService, feature_category: :source_code_management do
let(:event) { create(:push_event) }
let(:push_data) do
diff --git a/spec/services/bulk_update_integration_service_spec.rb b/spec/services/bulk_update_integration_service_spec.rb
index 24a868b524d..9095fa9a0fa 100644
--- a/spec/services/bulk_update_integration_service_spec.rb
+++ b/spec/services/bulk_update_integration_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkUpdateIntegrationService do
+RSpec.describe BulkUpdateIntegrationService, feature_category: :integrations do
include JiraIntegrationHelpers
before_all do
@@ -56,14 +56,14 @@ RSpec.describe BulkUpdateIntegrationService do
end
it 'does not change the created_at timestamp' do
- subgroup_integration.update_column(:created_at, Time.utc('2022-01-01'))
+ subgroup_integration.update_column(:created_at, Time.utc(2022, 1, 1))
expect do
described_class.new(subgroup_integration, batch).execute
end.not_to change { integration.reload.created_at }
end
- it 'sets the updated_at timestamp to the current time', time_travel_to: Time.utc('2022-01-01') do
+ it 'sets the updated_at timestamp to the current time', time_travel_to: Time.utc(2022, 1, 1) do
expect do
described_class.new(subgroup_integration, batch).execute
end.to change { integration.reload.updated_at }.to(Time.current)
@@ -85,14 +85,14 @@ RSpec.describe BulkUpdateIntegrationService do
end
it 'does not change the created_at timestamp' do
- subgroup_integration.data_fields.update_column(:created_at, Time.utc('2022-01-02'))
+ subgroup_integration.data_fields.update_column(:created_at, Time.utc(2022, 1, 2))
expect do
described_class.new(subgroup_integration, batch).execute
end.not_to change { integration.data_fields.reload.created_at }
end
- it 'sets the updated_at timestamp to the current time', time_travel_to: Time.utc('2022-01-01') do
+ it 'sets the updated_at timestamp to the current time', time_travel_to: Time.utc(2022, 1, 1) do
expect do
described_class.new(subgroup_integration, batch).execute
end.to change { integration.data_fields.reload.updated_at }.to(Time.current)
diff --git a/spec/services/captcha/captcha_verification_service_spec.rb b/spec/services/captcha/captcha_verification_service_spec.rb
index fe2199fb53e..b67e725bf91 100644
--- a/spec/services/captcha/captcha_verification_service_spec.rb
+++ b/spec/services/captcha/captcha_verification_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Captcha::CaptchaVerificationService do
+RSpec.describe Captcha::CaptchaVerificationService, feature_category: :team_planning do
describe '#execute' do
let(:captcha_response) { 'abc123' }
let(:fake_ip) { '1.2.3.4' }
diff --git a/spec/services/chat_names/find_user_service_spec.rb b/spec/services/chat_names/find_user_service_spec.rb
index 10cb0a2f065..14bece4efb4 100644
--- a/spec/services/chat_names/find_user_service_spec.rb
+++ b/spec/services/chat_names/find_user_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ChatNames::FindUserService, :clean_gitlab_redis_shared_state do
+RSpec.describe ChatNames::FindUserService, :clean_gitlab_redis_shared_state, feature_category: :user_profile do
describe '#execute' do
subject { described_class.new(team_id, user_id).execute }
diff --git a/spec/services/ci/abort_pipelines_service_spec.rb b/spec/services/ci/abort_pipelines_service_spec.rb
index e43faf0af51..60f3ee11442 100644
--- a/spec/services/ci/abort_pipelines_service_spec.rb
+++ b/spec/services/ci/abort_pipelines_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::AbortPipelinesService do
+RSpec.describe Ci::AbortPipelinesService, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) }
diff --git a/spec/services/ci/append_build_trace_service_spec.rb b/spec/services/ci/append_build_trace_service_spec.rb
index 20f7967d1f1..113c88dc5f3 100644
--- a/spec/services/ci/append_build_trace_service_spec.rb
+++ b/spec/services/ci/append_build_trace_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::AppendBuildTraceService do
+RSpec.describe Ci::AppendBuildTraceService, feature_category: :continuous_integration do
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
let_it_be_with_reload(:build) { create(:ci_build, :running, pipeline: pipeline) }
diff --git a/spec/services/ci/archive_trace_service_spec.rb b/spec/services/ci/archive_trace_service_spec.rb
index 3fb9d092ae7..e7f489e841a 100644
--- a/spec/services/ci/archive_trace_service_spec.rb
+++ b/spec/services/ci/archive_trace_service_spec.rb
@@ -63,19 +63,6 @@ RSpec.describe Ci::ArchiveTraceService, '#execute', feature_category: :continuou
end
end
- context 'when job does not have trace' do
- let(:job) { create(:ci_build, :success) }
-
- it 'leaves a warning message in sidekiq log' do
- expect(Sidekiq.logger).to receive(:warn).with(
- class: Ci::ArchiveTraceWorker.name,
- message: 'The job does not have live trace but going to be archived.',
- job_id: job.id)
-
- subject
- end
- end
-
context 'when the job is out of archival attempts' do
before do
create(:ci_build_trace_metadata,
@@ -149,23 +136,6 @@ RSpec.describe Ci::ArchiveTraceService, '#execute', feature_category: :continuou
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|
- allow(instance).to receive(:archive!) {}
- end
- end
-
- it 'leaves a warning message in sidekiq log' do
- expect(Sidekiq.logger).to receive(:warn).with(
- class: Ci::ArchiveTraceWorker.name,
- message: 'The job does not have archived trace after archiving.',
- job_id: job.id)
-
- subject
- end
- end
end
context 'when job is running' do
@@ -175,8 +145,8 @@ RSpec.describe Ci::ArchiveTraceService, '#execute', feature_category: :continuou
expect(Gitlab::ErrorTracking)
.to receive(:track_and_raise_for_dev_exception)
.with(::Gitlab::Ci::Trace::ArchiveError,
- issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/51502',
- job_id: job.id).once
+ issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/51502',
+ job_id: job.id).once
expect(Sidekiq.logger).to receive(:warn).with(
class: Ci::ArchiveTraceWorker.name,
diff --git a/spec/services/ci/build_cancel_service_spec.rb b/spec/services/ci/build_cancel_service_spec.rb
index fe036dc1368..314d2e86bd3 100644
--- a/spec/services/ci/build_cancel_service_spec.rb
+++ b/spec/services/ci/build_cancel_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::BuildCancelService do
+RSpec.describe Ci::BuildCancelService, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
diff --git a/spec/services/ci/build_erase_service_spec.rb b/spec/services/ci/build_erase_service_spec.rb
index e750a163621..35e74f76a26 100644
--- a/spec/services/ci/build_erase_service_spec.rb
+++ b/spec/services/ci/build_erase_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::BuildEraseService do
+RSpec.describe Ci::BuildEraseService, feature_category: :continuous_integration do
let_it_be(:user) { user }
let(:build) { create(:ci_build, :artifacts, :trace_artifact, artifacts_expire_at: 100.days.from_now) }
diff --git a/spec/services/ci/build_report_result_service_spec.rb b/spec/services/ci/build_report_result_service_spec.rb
index c5238b7f5e0..c3ce6714241 100644
--- a/spec/services/ci/build_report_result_service_spec.rb
+++ b/spec/services/ci/build_report_result_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::BuildReportResultService do
+RSpec.describe Ci::BuildReportResultService, feature_category: :continuous_integration do
describe '#execute', :clean_gitlab_redis_shared_state do
subject(:build_report_result) { described_class.new.execute(build) }
diff --git a/spec/services/ci/build_unschedule_service_spec.rb b/spec/services/ci/build_unschedule_service_spec.rb
index d784d9a2754..539c66047e4 100644
--- a/spec/services/ci/build_unschedule_service_spec.rb
+++ b/spec/services/ci/build_unschedule_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::BuildUnscheduleService do
+RSpec.describe Ci::BuildUnscheduleService, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
diff --git a/spec/services/ci/catalog/validate_resource_service_spec.rb b/spec/services/ci/catalog/validate_resource_service_spec.rb
new file mode 100644
index 00000000000..3bee37b7e55
--- /dev/null
+++ b/spec/services/ci/catalog/validate_resource_service_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::Catalog::ValidateResourceService, feature_category: :pipeline_composition do
+ describe '#execute' do
+ context 'with a project that has a README and a description' do
+ it 'is valid' do
+ project = create(:project, :repository, description: 'Component project')
+ response = described_class.new(project, project.default_branch).execute
+
+ expect(response).to be_success
+ end
+ end
+
+ context 'with a project that has neither a description nor a README' do
+ it 'is not valid' do
+ project = create(:project, :empty_repo)
+ project.repository.create_file(
+ project.creator,
+ 'ruby.rb',
+ 'I like this',
+ message: 'Ruby like this',
+ branch_name: 'master'
+ )
+ response = described_class.new(project, project.default_branch).execute
+
+ expect(response.message).to eq('Project must have a README , Project must have a description')
+ end
+ end
+
+ context 'with a project that has a description but not a README' do
+ it 'is not valid' do
+ project = create(:project, :empty_repo, description: 'project with no README')
+ project.repository.create_file(
+ project.creator,
+ 'text.txt',
+ 'I do not like this',
+ message: 'only text like text',
+ branch_name: 'master'
+ )
+ response = described_class.new(project, project.default_branch).execute
+
+ expect(response.message).to eq('Project must have a README')
+ end
+ end
+
+ context 'with a project that has a README and not a description' do
+ it 'is not valid' do
+ project = create(:project, :repository)
+ response = described_class.new(project, project.default_branch).execute
+
+ expect(response.message).to eq('Project must have a description')
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/change_variable_service_spec.rb b/spec/services/ci/change_variable_service_spec.rb
index f86a87132b1..fd2ddded375 100644
--- a/spec/services/ci/change_variable_service_spec.rb
+++ b/spec/services/ci/change_variable_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ChangeVariableService do
+RSpec.describe Ci::ChangeVariableService, feature_category: :secrets_management do
let(:service) { described_class.new(container: group, current_user: user, params: params) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/ci/change_variables_service_spec.rb b/spec/services/ci/change_variables_service_spec.rb
index b710ca78554..e22aebb8f5d 100644
--- a/spec/services/ci/change_variables_service_spec.rb
+++ b/spec/services/ci/change_variables_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ChangeVariablesService do
+RSpec.describe Ci::ChangeVariablesService, feature_category: :secrets_management do
let(:service) { described_class.new(container: group, current_user: user, params: params) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/ci/compare_accessibility_reports_service_spec.rb b/spec/services/ci/compare_accessibility_reports_service_spec.rb
index e0b84219834..57514c18042 100644
--- a/spec/services/ci/compare_accessibility_reports_service_spec.rb
+++ b/spec/services/ci/compare_accessibility_reports_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CompareAccessibilityReportsService do
+RSpec.describe Ci::CompareAccessibilityReportsService, feature_category: :continuous_integration do
let(:service) { described_class.new(project) }
let(:project) { create(:project, :repository) }
diff --git a/spec/services/ci/compare_codequality_reports_service_spec.rb b/spec/services/ci/compare_codequality_reports_service_spec.rb
index ef762a2e9ad..de2300c354b 100644
--- a/spec/services/ci/compare_codequality_reports_service_spec.rb
+++ b/spec/services/ci/compare_codequality_reports_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CompareCodequalityReportsService do
+RSpec.describe Ci::CompareCodequalityReportsService, feature_category: :continuous_integration do
let(:service) { described_class.new(project) }
let(:project) { create(:project, :repository) }
diff --git a/spec/services/ci/compare_reports_base_service_spec.rb b/spec/services/ci/compare_reports_base_service_spec.rb
index 20d8cd37553..2906d61066d 100644
--- a/spec/services/ci/compare_reports_base_service_spec.rb
+++ b/spec/services/ci/compare_reports_base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CompareReportsBaseService do
+RSpec.describe Ci::CompareReportsBaseService, feature_category: :continuous_integration do
let(:service) { described_class.new(project) }
let(:project) { create(:project, :repository) }
diff --git a/spec/services/ci/compare_test_reports_service_spec.rb b/spec/services/ci/compare_test_reports_service_spec.rb
index f259072fe87..d29cef0c583 100644
--- a/spec/services/ci/compare_test_reports_service_spec.rb
+++ b/spec/services/ci/compare_test_reports_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CompareTestReportsService do
+RSpec.describe Ci::CompareTestReportsService, feature_category: :continuous_integration do
let(:service) { described_class.new(project) }
let(:project) { create(:project, :repository) }
diff --git a/spec/services/ci/components/fetch_service_spec.rb b/spec/services/ci/components/fetch_service_spec.rb
index f2eaa8d31b4..532098b3b20 100644
--- a/spec/services/ci/components/fetch_service_spec.rb
+++ b/spec/services/ci/components/fetch_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::Components::FetchService, feature_category: :pipeline_authoring do
+RSpec.describe Ci::Components::FetchService, feature_category: :pipeline_composition do
let_it_be(:project) { create(:project, :repository, create_tag: 'v1.0') }
let_it_be(:user) { create(:user) }
let_it_be(:current_user) { user }
diff --git a/spec/services/ci/copy_cross_database_associations_service_spec.rb b/spec/services/ci/copy_cross_database_associations_service_spec.rb
index 5938ac258d0..2be0cbd9582 100644
--- a/spec/services/ci/copy_cross_database_associations_service_spec.rb
+++ b/spec/services/ci/copy_cross_database_associations_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CopyCrossDatabaseAssociationsService do
+RSpec.describe Ci::CopyCrossDatabaseAssociationsService, feature_category: :continuous_integration do
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
let_it_be(:old_build) { create(:ci_build, pipeline: pipeline) }
diff --git a/spec/services/ci/create_downstream_pipeline_service_spec.rb b/spec/services/ci/create_downstream_pipeline_service_spec.rb
index 7b576339c61..6da6ec3379a 100644
--- a/spec/services/ci/create_downstream_pipeline_service_spec.rb
+++ b/spec/services/ci/create_downstream_pipeline_service_spec.rb
@@ -26,10 +26,13 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute', feature_category
end
let(:bridge) do
- create(:ci_bridge, status: :pending,
- user: user,
- options: trigger,
- pipeline: upstream_pipeline)
+ create(
+ :ci_bridge,
+ status: :pending,
+ user: user,
+ options: trigger,
+ pipeline: upstream_pipeline
+ )
end
let(:service) { described_class.new(upstream_project, user) }
@@ -704,8 +707,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute', feature_category
context 'when user does not have access to push protected branch of downstream project' do
before do
- create(:protected_branch, :maintainers_can_push,
- project: downstream_project, name: 'feature')
+ create(:protected_branch, :maintainers_can_push, project: downstream_project, name: 'feature')
end
it 'changes status of the bridge build' do
diff --git a/spec/services/ci/create_pipeline_service/artifacts_spec.rb b/spec/services/ci/create_pipeline_service/artifacts_spec.rb
index e5e405492a0..c193900b2d3 100644
--- a/spec/services/ci/create_pipeline_service/artifacts_spec.rb
+++ b/spec/services/ci/create_pipeline_service/artifacts_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness, feature_category: :build_artifacts do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/cache_spec.rb b/spec/services/ci/create_pipeline_service/cache_spec.rb
index f9640f99031..2a65f92bfd6 100644
--- a/spec/services/ci/create_pipeline_service/cache_spec.rb
+++ b/spec/services/ci/create_pipeline_service/cache_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
context 'cache' do
let(:project) { create(:project, :custom_repo, files: files) }
let(:user) { project.first_owner }
@@ -38,7 +39,8 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
policy: 'pull-push',
untracked: true,
unprotect: false,
- when: 'on_success'
+ when: 'on_success',
+ fallback_keys: []
}
expect(pipeline).to be_persisted
@@ -71,7 +73,8 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
paths: ['logs/'],
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
}
expect(pipeline).to be_persisted
@@ -88,7 +91,8 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
paths: ['logs/'],
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
}
expect(pipeline).to be_persisted
@@ -122,7 +126,8 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
paths: ['logs/'],
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
}
expect(pipeline).to be_persisted
@@ -139,7 +144,8 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
paths: ['logs/'],
policy: 'pull-push',
when: 'on_success',
- unprotect: false
+ unprotect: false,
+ fallback_keys: []
}
expect(pipeline).to be_persisted
diff --git a/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb b/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb
index 0ebcecdd6e6..036116dc037 100644
--- a/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb
+++ b/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness, feature_category: :continuous_integration do
describe 'creation errors and warnings' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb b/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb
index 0d5017a763f..e6bdb2a3fc6 100644
--- a/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb
+++ b/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb
@@ -2,11 +2,12 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, '#execute', :yaml_processor_feature_flag_corectness do
- let_it_be(:group) { create(:group, name: 'my-organization') }
+RSpec.describe Ci::CreatePipelineService, '#execute', :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
+ let_it_be(:group) { create(:group) }
- let(:upstream_project) { create(:project, :repository, name: 'upstream', group: group) }
- let(:downstream_project) { create(:project, :repository, name: 'downstream', group: group) }
+ let(:upstream_project) { create(:project, :repository, group: group) }
+ let(:downstream_project) { create(:project, :repository, group: group) }
let(:user) { create(:user) }
let(:service) do
@@ -27,7 +28,7 @@ RSpec.describe Ci::CreatePipelineService, '#execute', :yaml_processor_feature_fl
stage: test
resource_group: iOS
trigger:
- project: my-organization/downstream
+ project: #{downstream_project.full_path}
strategy: depend
YAML
end
diff --git a/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb
index dafa227c4c8..819946bfd27 100644
--- a/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb
+++ b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness, feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/custom_yaml_tags_spec.rb b/spec/services/ci/create_pipeline_service/custom_yaml_tags_spec.rb
index 3b042f05fc0..b78ad68194a 100644
--- a/spec/services/ci/create_pipeline_service/custom_yaml_tags_spec.rb
+++ b/spec/services/ci/create_pipeline_service/custom_yaml_tags_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
describe '!reference tags' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/dry_run_spec.rb b/spec/services/ci/create_pipeline_service/dry_run_spec.rb
index de1ed251c82..7136fa5dc13 100644
--- a/spec/services/ci/create_pipeline_service/dry_run_spec.rb
+++ b/spec/services/ci/create_pipeline_service/dry_run_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/environment_spec.rb b/spec/services/ci/create_pipeline_service/environment_spec.rb
index b713cad2cad..96e54af43cd 100644
--- a/spec/services/ci/create_pipeline_service/environment_spec.rb
+++ b/spec/services/ci/create_pipeline_service/environment_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :pipeline_composition do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:developer) { create(:user) }
diff --git a/spec/services/ci/create_pipeline_service/evaluate_runner_tags_spec.rb b/spec/services/ci/create_pipeline_service/evaluate_runner_tags_spec.rb
index e84726d31f6..b40e504f99b 100644
--- a/spec/services/ci/create_pipeline_service/evaluate_runner_tags_spec.rb
+++ b/spec/services/ci/create_pipeline_service/evaluate_runner_tags_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :pipeline_composition do
let_it_be(:group) { create(:group, :private) }
let_it_be(:group_variable) { create(:ci_group_variable, group: group, key: 'RUNNER_TAG', value: 'group') }
let_it_be(:project) { create(:project, :repository, group: group) }
diff --git a/spec/services/ci/create_pipeline_service/include_spec.rb b/spec/services/ci/create_pipeline_service/include_spec.rb
index f18b4883aaf..1280ab4b7bd 100644
--- a/spec/services/ci/create_pipeline_service/include_spec.rb
+++ b/spec/services/ci/create_pipeline_service/include_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Ci::CreatePipelineService,
-:yaml_processor_feature_flag_corectness, feature_category: :pipeline_authoring do
+ :yaml_processor_feature_flag_corectness, feature_category: :pipeline_composition do
include RepoHelpers
context 'include:' do
diff --git a/spec/services/ci/create_pipeline_service/limit_active_jobs_spec.rb b/spec/services/ci/create_pipeline_service/limit_active_jobs_spec.rb
index 003d109a27c..b0730eaf215 100644
--- a/spec/services/ci/create_pipeline_service/limit_active_jobs_spec.rb
+++ b/spec/services/ci/create_pipeline_service/limit_active_jobs_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
let_it_be(:existing_pipeline) { create(:ci_pipeline, project: project) }
diff --git a/spec/services/ci/create_pipeline_service/logger_spec.rb b/spec/services/ci/create_pipeline_service/logger_spec.rb
index ecb24a61075..6a1987fcc7c 100644
--- a/spec/services/ci/create_pipeline_service/logger_spec.rb
+++ b/spec/services/ci/create_pipeline_service/logger_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe Ci::CreatePipelineService, # rubocop: disable RSpec/FilePath
- :yaml_processor_feature_flag_corectness,
- feature_category: :continuous_integration do
+ :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
describe 'pipeline logger' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
@@ -142,7 +142,7 @@ RSpec.describe Ci::CreatePipelineService, # rubocop: disable RSpec/FilePath
describe 'pipeline includes count' do
before do
- stub_const('Gitlab::Ci::Config::External::Context::MAX_INCLUDES', 2)
+ stub_const('Gitlab::Ci::Config::External::Context::TEMP_MAX_INCLUDES', 2)
end
context 'when the includes count exceeds the maximum' do
diff --git a/spec/services/ci/create_pipeline_service/merge_requests_spec.rb b/spec/services/ci/create_pipeline_service/merge_requests_spec.rb
index 80f48451e5c..25f6e43d600 100644
--- a/spec/services/ci/create_pipeline_service/merge_requests_spec.rb
+++ b/spec/services/ci/create_pipeline_service/merge_requests_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
context 'merge requests handling' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
@@ -30,11 +31,13 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
context 'when pushing a change' do
context 'when a merge request already exists' do
let!(:merge_request) do
- create(:merge_request,
- source_project: project,
- source_branch: 'feature',
- target_project: project,
- target_branch: 'master')
+ create(
+ :merge_request,
+ source_project: project,
+ source_branch: 'feature',
+ target_project: project,
+ target_branch: 'master'
+ )
end
it 'does not create a pipeline' do
diff --git a/spec/services/ci/create_pipeline_service/needs_spec.rb b/spec/services/ci/create_pipeline_service/needs_spec.rb
index 38e330316ea..068cad68e64 100644
--- a/spec/services/ci/create_pipeline_service/needs_spec.rb
+++ b/spec/services/ci/create_pipeline_service/needs_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :pipeline_composition do
context 'needs' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/parallel_spec.rb b/spec/services/ci/create_pipeline_service/parallel_spec.rb
index 5ee378a9719..71434fe0b0c 100644
--- a/spec/services/ci/create_pipeline_service/parallel_spec.rb
+++ b/spec/services/ci/create_pipeline_service/parallel_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/parameter_content_spec.rb b/spec/services/ci/create_pipeline_service/parameter_content_spec.rb
index cae88bb67cf..16555dd68d6 100644
--- a/spec/services/ci/create_pipeline_service/parameter_content_spec.rb
+++ b/spec/services/ci/create_pipeline_service/parameter_content_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
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 eb17935967c..e644273df9a 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
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, '#execute', :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, '#execute', :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/ci/create_pipeline_service/partitioning_spec.rb b/spec/services/ci/create_pipeline_service/partitioning_spec.rb
index a87135cefdd..70c4eb49698 100644
--- a/spec/services/ci/create_pipeline_service/partitioning_spec.rb
+++ b/spec/services/ci/create_pipeline_service/partitioning_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness, :aggregate_failures,
-:ci_partitionable, feature_category: :continuous_integration do
+ :ci_partitionable, feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb b/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb
index db110bdc608..d935824e6cc 100644
--- a/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb
+++ b/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
describe '.pre/.post stages' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/rate_limit_spec.rb b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb
index dfa74870341..26a9484dbc4 100644
--- a/spec/services/ci/create_pipeline_service/rate_limit_spec.rb
+++ b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb
@@ -2,8 +2,9 @@
require 'spec_helper'
RSpec.describe Ci::CreatePipelineService, :freeze_time,
- :clean_gitlab_redis_rate_limiting,
- :yaml_processor_feature_flag_corectness do
+ :clean_gitlab_redis_rate_limiting,
+ :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
describe 'rate limiting' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/rules_spec.rb b/spec/services/ci/create_pipeline_service/rules_spec.rb
index 26bb8b7d006..87112137675 100644
--- a/spec/services/ci/create_pipeline_service/rules_spec.rb
+++ b/spec/services/ci/create_pipeline_service/rules_spec.rb
@@ -1,25 +1,18 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness, feature_category: :pipeline_authoring do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness, feature_category: :pipeline_composition do
let(:project) { create(:project, :repository) }
let(:user) { project.first_owner }
let(:ref) { 'refs/heads/master' }
let(:source) { :push }
- let(:service) { described_class.new(project, user, { ref: ref }) }
- let(:response) { execute_service }
+ let(:service) { described_class.new(project, user, initialization_params) }
+ let(:response) { service.execute(source) }
let(:pipeline) { response.payload }
let(:build_names) { pipeline.builds.pluck(:name) }
- def execute_service(before: '00000000', variables_attributes: nil)
- params = { ref: ref, before: before, after: project.commit(ref).sha, variables_attributes: variables_attributes }
-
- described_class
- .new(project, user, params)
- .execute(source) do |pipeline|
- yield(pipeline) if block_given?
- end
- end
+ let(:base_initialization_params) { { ref: ref, before: '00000000', after: project.commit(ref).sha, variables_attributes: nil } }
+ let(:initialization_params) { base_initialization_params }
context 'job:rules' do
let(:regular_job) { find_job('regular-job') }
@@ -393,6 +386,109 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
expect(regular_job.allow_failure).to eq(true)
end
end
+
+ context 'with needs:' do
+ let(:config) do
+ <<-EOY
+ job1:
+ script: ls
+
+ job2:
+ script: ls
+ rules:
+ - if: $var == null
+ needs: [job1]
+ - when: on_success
+
+ job3:
+ script: ls
+ rules:
+ - if: $var == null
+ needs: [job1]
+ - needs: [job2]
+
+ job4:
+ script: ls
+ needs: [job1]
+ rules:
+ - if: $var == null
+ needs: [job2]
+ - when: on_success
+ needs: [job3]
+ EOY
+ end
+
+ let(:job1) { pipeline.builds.find_by(name: 'job1') }
+ let(:job2) { pipeline.builds.find_by(name: 'job2') }
+ let(:job3) { pipeline.builds.find_by(name: 'job3') }
+ let(:job4) { pipeline.builds.find_by(name: 'job4') }
+
+ context 'when the `$var` rule matches' do
+ it 'creates a pipeline with overridden needs' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('job1', 'job2', 'job3', 'job4')
+
+ expect(job1.needs).to be_empty
+ expect(job2.needs).to contain_exactly(an_object_having_attributes(name: 'job1'))
+ expect(job3.needs).to contain_exactly(an_object_having_attributes(name: 'job1'))
+ expect(job4.needs).to contain_exactly(an_object_having_attributes(name: 'job2'))
+ end
+ end
+
+ context 'when the `$var` rule does not match' do
+ let(:initialization_params) { base_initialization_params.merge(variables_attributes: variables_attributes) }
+
+ let(:variables_attributes) do
+ [{ key: 'var', secret_value: 'SOME_VAR' }]
+ end
+
+ it 'creates a pipeline with overridden needs' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('job1', 'job2', 'job3', 'job4')
+
+ expect(job1.needs).to be_empty
+ expect(job2.needs).to be_empty
+ expect(job3.needs).to contain_exactly(an_object_having_attributes(name: 'job2'))
+ expect(job4.needs).to contain_exactly(an_object_having_attributes(name: 'job3'))
+ end
+ end
+
+ context 'when the FF introduce_rules_with_needs is disabled' do
+ before do
+ stub_feature_flags(introduce_rules_with_needs: false)
+ end
+
+ context 'when the `$var` rule matches' do
+ it 'creates a pipeline without overridden needs' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('job1', 'job2', 'job3', 'job4')
+
+ expect(job1.needs).to be_empty
+ expect(job2.needs).to be_empty
+ expect(job3.needs).to be_empty
+ expect(job4.needs).to contain_exactly(an_object_having_attributes(name: 'job1'))
+ end
+ end
+
+ context 'when the `$var` rule does not match' do
+ let(:initialization_params) { base_initialization_params.merge(variables_attributes: variables_attributes) }
+
+ let(:variables_attributes) do
+ [{ key: 'var', secret_value: 'SOME_VAR' }]
+ end
+
+ it 'creates a pipeline without overridden needs' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('job1', 'job2', 'job3', 'job4')
+
+ expect(job1.needs).to be_empty
+ expect(job2.needs).to be_empty
+ expect(job3.needs).to be_empty
+ expect(job4.needs).to contain_exactly(an_object_having_attributes(name: 'job1'))
+ end
+ end
+ end
+ end
end
context 'changes:' do
@@ -516,11 +612,10 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
)
end
+ let(:initialization_params) { base_initialization_params.merge(before: nil) }
let(:changed_file) { 'file2.txt' }
let(:ref) { 'feature_2' }
- let(:response) { execute_service(before: nil) }
-
context 'for jobs rules' do
let(:config) do
<<-EOY
@@ -1230,9 +1325,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
end
context 'with pipeline variables' do
- let(:pipeline) do
- execute_service(variables_attributes: variables_attributes).payload
- end
+ let(:initialization_params) { base_initialization_params.merge(variables_attributes: variables_attributes) }
let(:config) do
<<-EOY
@@ -1267,10 +1360,10 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
end
context 'with trigger variables' do
- let(:pipeline) do
- execute_service do |pipeline|
+ let(:response) do
+ service.execute(source) do |pipeline|
pipeline.variables.build(variables)
- end.payload
+ end
end
let(:config) do
@@ -1434,10 +1527,10 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
[{ key: 'SOME_VARIABLE', secret_value: 'SOME_VAL' }]
end
- let(:pipeline) do
- execute_service do |pipeline|
+ let(:response) do
+ service.execute(source) do |pipeline|
pipeline.variables.build(variables)
- end.payload
+ end
end
let(:config) do
diff --git a/spec/services/ci/create_pipeline_service/scripts_spec.rb b/spec/services/ci/create_pipeline_service/scripts_spec.rb
index 50b558e505a..d541257a086 100644
--- a/spec/services/ci/create_pipeline_service/scripts_spec.rb
+++ b/spec/services/ci/create_pipeline_service/scripts_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
@@ -83,30 +84,5 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
options: { script: ["echo 'hello job3 script'"] }
)
end
-
- context 'when the FF ci_hooks_pre_get_sources_script is disabled' do
- before do
- stub_feature_flags(ci_hooks_pre_get_sources_script: false)
- end
-
- it 'creates jobs without hook data' do
- expect(pipeline).to be_created_successfully
- expect(pipeline.builds.find_by(name: 'job1')).to have_attributes(
- name: 'job1',
- stage: 'test',
- options: { script: ["echo 'hello job1 script'"] }
- )
- expect(pipeline.builds.find_by(name: 'job2')).to have_attributes(
- name: 'job2',
- stage: 'test',
- options: { script: ["echo 'hello job2 script'"] }
- )
- expect(pipeline.builds.find_by(name: 'job3')).to have_attributes(
- name: 'job3',
- stage: 'test',
- options: { script: ["echo 'hello job3 script'"] }
- )
- 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
index 7450df11eac..cb2dbf1c3a4 100644
--- a/spec/services/ci/create_pipeline_service/tags_spec.rb
+++ b/spec/services/ci/create_pipeline_service/tags_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness, feature_category: :continuous_integration do
describe 'tags:' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service/variables_spec.rb b/spec/services/ci/create_pipeline_service/variables_spec.rb
index fd138bde656..aac9a0c9c2d 100644
--- a/spec/services/ci/create_pipeline_service/variables_spec.rb
+++ b/spec/services/ci/create_pipeline_service/variables_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness do
+RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness,
+ feature_category: :secrets_management do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.first_owner }
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index b0ba07ea295..b08dda72a69 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -794,7 +794,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
before do
config = YAML.dump(
deploy: {
- environment: { name: "review/id1$CI_PIPELINE_ID/id2$CI_BUILD_ID" },
+ environment: { name: "review/id1$CI_PIPELINE_ID/id2$CI_JOB_ID" },
script: 'ls'
}
)
@@ -802,7 +802,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
stub_ci_pipeline_yaml_file(config)
end
- it 'skipps persisted variables in environment name' do
+ it 'skips persisted variables in environment name' do
result = execute_service.payload
expect(result).to be_persisted
@@ -810,6 +810,32 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
end
end
+ context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do
+ before do
+ stub_feature_flags(ci_remove_legacy_predefined_variables: false)
+ end
+
+ context 'with environment name including persisted variables' do
+ before do
+ config = YAML.dump(
+ deploy: {
+ environment: { name: "review/id1$CI_PIPELINE_ID/id2$CI_BUILD_ID" },
+ script: 'ls'
+ }
+ )
+
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ it 'skips persisted variables in environment name' do
+ result = execute_service.payload
+
+ expect(result).to be_persisted
+ expect(Environment.find_by(name: "review/id1/id2")).to be_present
+ end
+ end
+ end
+
context 'environment with Kubernetes configuration' do
let(:kubernetes_namespace) { 'custom-namespace' }
@@ -1167,8 +1193,10 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
context 'when pipeline is running for a tag' do
before do
- config = YAML.dump(test: { script: 'test', only: ['branches'] },
- deploy: { script: 'deploy', only: ['tags'] })
+ config = YAML.dump(
+ test: { script: 'test', only: ['branches'] },
+ deploy: { script: 'deploy', only: ['tags'] }
+ )
stub_ci_pipeline_yaml_file(config)
end
@@ -1343,11 +1371,13 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
describe 'Pipeline for external pull requests' do
let(:response) do
- execute_service(source: source,
- external_pull_request: pull_request,
- ref: ref_name,
- source_sha: source_sha,
- target_sha: target_sha)
+ execute_service(
+ source: source,
+ external_pull_request: pull_request,
+ ref: ref_name,
+ source_sha: source_sha,
+ target_sha: target_sha
+ )
end
let(:pipeline) { response.payload }
@@ -1499,11 +1529,13 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
describe 'Pipelines for merge requests' do
let(:response) do
- execute_service(source: source,
- merge_request: merge_request,
- ref: ref_name,
- source_sha: source_sha,
- target_sha: target_sha)
+ execute_service(
+ source: source,
+ merge_request: merge_request,
+ ref: ref_name,
+ source_sha: source_sha,
+ target_sha: target_sha
+ )
end
let(:pipeline) { response.payload }
@@ -1898,5 +1930,278 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
end
end
end
+
+ describe 'pipeline components' do
+ let(:components_project) do
+ create(:project, :repository, creator: user, namespace: user.namespace)
+ end
+
+ let(:component_path) do
+ "#{Gitlab.config.gitlab.host}/#{components_project.full_path}/my-component@v0.1"
+ end
+
+ let(:template) do
+ <<~YAML
+ spec:
+ inputs:
+ stage:
+ suffix:
+ default: my-job
+ ---
+ test-$[[ inputs.suffix ]]:
+ stage: $[[ inputs.stage ]]
+ script: run tests
+ YAML
+ end
+
+ let(:sha) do
+ components_project.repository.create_file(
+ user,
+ 'my-component/template.yml',
+ template,
+ message: 'Add my first CI component',
+ branch_name: 'master'
+ )
+ end
+
+ let(:config) do
+ <<~YAML
+ include:
+ - component: #{component_path}
+ inputs:
+ stage: my-stage
+
+ stages:
+ - my-stage
+
+ test-1:
+ stage: my-stage
+ script: run test-1
+ YAML
+ end
+
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ context 'when there is no version with specified tag' do
+ before do
+ components_project.repository.add_tag(user, 'v0.01', sha)
+ end
+
+ it 'does not create a pipeline' do
+ response = execute_service(save_on_errors: true)
+
+ pipeline = response.payload
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.yaml_errors)
+ .to include "my-component@v0.1' - content not found"
+ end
+ end
+
+ context 'when there is a proper revision available' do
+ before do
+ components_project.repository.add_tag(user, 'v0.1', sha)
+ end
+
+ context 'when component is valid' do
+ it 'creates a pipeline using a pipeline component' do
+ response = execute_service(save_on_errors: true)
+
+ pipeline = response.payload
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.yaml_errors).to be_blank
+ expect(pipeline.statuses.count).to eq 2
+ expect(pipeline.statuses.map(&:name)).to match_array %w[test-1 test-my-job]
+ end
+ end
+
+ context 'when interpolation is invalid' do
+ let(:template) do
+ <<~YAML
+ spec:
+ inputs:
+ stage:
+ ---
+ test:
+ stage: $[[ inputs.stage ]]
+ script: rspec --suite $[[ inputs.suite ]]
+ YAML
+ end
+
+ it 'does not create a pipeline' do
+ response = execute_service(save_on_errors: true)
+
+ pipeline = response.payload
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.yaml_errors)
+ .to include 'interpolation interrupted by errors, unknown interpolation key: `suite`'
+ end
+ end
+
+ context 'when there is a syntax error in the template' do
+ let(:template) do
+ <<~YAML
+ spec:
+ inputs:
+ stage:
+ ---
+ :test
+ stage: $[[ inputs.stage ]]
+ YAML
+ end
+
+ it 'does not create a pipeline' do
+ response = execute_service(save_on_errors: true)
+
+ pipeline = response.payload
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.yaml_errors)
+ .to include 'content does not have a valid YAML syntax'
+ end
+ end
+ end
+ end
+
+ # TODO: Remove this test section when include:with is removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/408369
+ describe 'pipeline components using include:with instead of include:inputs' do
+ let(:components_project) do
+ create(:project, :repository, creator: user, namespace: user.namespace)
+ end
+
+ let(:component_path) do
+ "#{Gitlab.config.gitlab.host}/#{components_project.full_path}/my-component@v0.1"
+ end
+
+ let(:template) do
+ <<~YAML
+ spec:
+ inputs:
+ stage:
+ suffix:
+ default: my-job
+ ---
+ test-$[[ inputs.suffix ]]:
+ stage: $[[ inputs.stage ]]
+ script: run tests
+ YAML
+ end
+
+ let(:sha) do
+ components_project.repository.create_file(
+ user,
+ 'my-component/template.yml',
+ template,
+ message: 'Add my first CI component',
+ branch_name: 'master'
+ )
+ end
+
+ let(:config) do
+ <<~YAML
+ include:
+ - component: #{component_path}
+ with:
+ stage: my-stage
+
+ stages:
+ - my-stage
+
+ test-1:
+ stage: my-stage
+ script: run test-1
+ YAML
+ end
+
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ context 'when there is no version with specified tag' do
+ before do
+ components_project.repository.add_tag(user, 'v0.01', sha)
+ end
+
+ it 'does not create a pipeline' do
+ response = execute_service(save_on_errors: true)
+
+ pipeline = response.payload
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.yaml_errors)
+ .to include "my-component@v0.1' - content not found"
+ end
+ end
+
+ context 'when there is a proper revision available' do
+ before do
+ components_project.repository.add_tag(user, 'v0.1', sha)
+ end
+
+ context 'when component is valid' do
+ it 'creates a pipeline using a pipeline component' do
+ response = execute_service(save_on_errors: true)
+
+ pipeline = response.payload
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.yaml_errors).to be_blank
+ expect(pipeline.statuses.count).to eq 2
+ expect(pipeline.statuses.map(&:name)).to match_array %w[test-1 test-my-job]
+ end
+ end
+
+ context 'when interpolation is invalid' do
+ let(:template) do
+ <<~YAML
+ spec:
+ inputs:
+ stage:
+ ---
+ test:
+ stage: $[[ inputs.stage ]]
+ script: rspec --suite $[[ inputs.suite ]]
+ YAML
+ end
+
+ it 'does not create a pipeline' do
+ response = execute_service(save_on_errors: true)
+
+ pipeline = response.payload
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.yaml_errors)
+ .to include 'interpolation interrupted by errors, unknown interpolation key: `suite`'
+ end
+ end
+
+ context 'when there is a syntax error in the template' do
+ let(:template) do
+ <<~YAML
+ spec:
+ inputs:
+ stage:
+ ---
+ :test
+ stage: $[[ inputs.stage ]]
+ YAML
+ end
+
+ it 'does not create a pipeline' do
+ response = execute_service(save_on_errors: true)
+
+ pipeline = response.payload
+
+ expect(pipeline).to be_persisted
+ expect(pipeline.yaml_errors)
+ .to include 'content does not have a valid YAML syntax'
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/services/ci/create_web_ide_terminal_service_spec.rb b/spec/services/ci/create_web_ide_terminal_service_spec.rb
index 3462b48cfe7..b22ca1472b7 100644
--- a/spec/services/ci/create_web_ide_terminal_service_spec.rb
+++ b/spec/services/ci/create_web_ide_terminal_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CreateWebIdeTerminalService do
+RSpec.describe Ci::CreateWebIdeTerminalService, feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/ci/daily_build_group_report_result_service_spec.rb b/spec/services/ci/daily_build_group_report_result_service_spec.rb
index 32651247adb..bb6ce559fbd 100644
--- a/spec/services/ci/daily_build_group_report_result_service_spec.rb
+++ b/spec/services/ci/daily_build_group_report_result_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::DailyBuildGroupReportResultService, '#execute' do
+RSpec.describe Ci::DailyBuildGroupReportResultService, '#execute', feature_category: :continuous_integration do
let_it_be(:group) { create(:group, :private) }
let_it_be(:pipeline) { create(:ci_pipeline, project: create(:project, group: group), created_at: '2020-02-06 00:01:10') }
let_it_be(:rspec_job) { create(:ci_build, pipeline: pipeline, name: 'rspec 3/3', coverage: 80) }
diff --git a/spec/services/ci/delete_objects_service_spec.rb b/spec/services/ci/delete_objects_service_spec.rb
index 448f8979681..939b72cef3b 100644
--- a/spec/services/ci/delete_objects_service_spec.rb
+++ b/spec/services/ci/delete_objects_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::DeleteObjectsService, :aggregate_failure do
+RSpec.describe Ci::DeleteObjectsService, :aggregate_failures, feature_category: :continuous_integration do
let(:service) { described_class.new }
let(:artifact) { create(:ci_job_artifact, :archive) }
let(:data) { [artifact] }
diff --git a/spec/services/ci/delete_unit_tests_service_spec.rb b/spec/services/ci/delete_unit_tests_service_spec.rb
index 4c63c513d48..2f07e709107 100644
--- a/spec/services/ci/delete_unit_tests_service_spec.rb
+++ b/spec/services/ci/delete_unit_tests_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::DeleteUnitTestsService do
+RSpec.describe Ci::DeleteUnitTestsService, feature_category: :continuous_integration do
describe '#execute' do
let!(:unit_test_1) { create(:ci_unit_test) }
let!(:unit_test_2) { create(:ci_unit_test) }
diff --git a/spec/services/ci/deployments/destroy_service_spec.rb b/spec/services/ci/deployments/destroy_service_spec.rb
index 60a57c05728..d0e7f5acb2b 100644
--- a/spec/services/ci/deployments/destroy_service_spec.rb
+++ b/spec/services/ci/deployments/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Ci::Deployments::DestroyService do
+RSpec.describe ::Ci::Deployments::DestroyService, feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let(:environment) { create(:environment, project: project) }
diff --git a/spec/services/ci/destroy_pipeline_service_spec.rb b/spec/services/ci/destroy_pipeline_service_spec.rb
index 6bd7fe7559c..a1883d90b0a 100644
--- a/spec/services/ci/destroy_pipeline_service_spec.rb
+++ b/spec/services/ci/destroy_pipeline_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Ci::DestroyPipelineService do
+RSpec.describe ::Ci::DestroyPipelineService, feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let!(:pipeline) { create(:ci_pipeline, :success, project: project, sha: project.commit.id) }
diff --git a/spec/services/ci/destroy_secure_file_service_spec.rb b/spec/services/ci/destroy_secure_file_service_spec.rb
index 6a30d33f4ca..321efc2ed71 100644
--- a/spec/services/ci/destroy_secure_file_service_spec.rb
+++ b/spec/services/ci/destroy_secure_file_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Ci::DestroySecureFileService do
+RSpec.describe ::Ci::DestroySecureFileService, feature_category: :continuous_integration do
let_it_be(:maintainer_user) { create(:user) }
let_it_be(:developer_user) { create(:user) }
let_it_be(:project) { create(:project) }
diff --git a/spec/services/ci/disable_user_pipeline_schedules_service_spec.rb b/spec/services/ci/disable_user_pipeline_schedules_service_spec.rb
index 4ff8dcf075b..d422cf0dab9 100644
--- a/spec/services/ci/disable_user_pipeline_schedules_service_spec.rb
+++ b/spec/services/ci/disable_user_pipeline_schedules_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::DisableUserPipelineSchedulesService do
+RSpec.describe Ci::DisableUserPipelineSchedulesService, feature_category: :continuous_integration do
describe '#execute' do
let(:user) { create(:user) }
diff --git a/spec/services/ci/drop_pipeline_service_spec.rb b/spec/services/ci/drop_pipeline_service_spec.rb
index ddb53712d9c..ed45b3460c1 100644
--- a/spec/services/ci/drop_pipeline_service_spec.rb
+++ b/spec/services/ci/drop_pipeline_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::DropPipelineService do
+RSpec.describe Ci::DropPipelineService, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let(:failure_reason) { :user_blocked }
diff --git a/spec/services/ci/ensure_stage_service_spec.rb b/spec/services/ci/ensure_stage_service_spec.rb
index 026814edda6..5d6025095a1 100644
--- a/spec/services/ci/ensure_stage_service_spec.rb
+++ b/spec/services/ci/ensure_stage_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::EnsureStageService, '#execute' do
+RSpec.describe Ci::EnsureStageService, '#execute', feature_category: :continuous_integration do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/ci/expire_pipeline_cache_service_spec.rb b/spec/services/ci/expire_pipeline_cache_service_spec.rb
index 8cfe756faf3..3d0ce456aa5 100644
--- a/spec/services/ci/expire_pipeline_cache_service_spec.rb
+++ b/spec/services/ci/expire_pipeline_cache_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ExpirePipelineCacheService do
+RSpec.describe Ci::ExpirePipelineCacheService, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
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 d5881d3b204..1b548aaf614 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do
+RSpec.describe Ci::ExternalPullRequests::CreatePipelineService, feature_category: :continuous_integration do
describe '#execute' do
let_it_be(:project) { create(:project, :auto_devops, :repository) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/ci/find_exposed_artifacts_service_spec.rb b/spec/services/ci/find_exposed_artifacts_service_spec.rb
index 6e11c153a75..69360e73b86 100644
--- a/spec/services/ci/find_exposed_artifacts_service_spec.rb
+++ b/spec/services/ci/find_exposed_artifacts_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::FindExposedArtifactsService do
+RSpec.describe Ci::FindExposedArtifactsService, feature_category: :build_artifacts do
include Gitlab::Routing
let(:metadata) do
diff --git a/spec/services/ci/generate_codequality_mr_diff_report_service_spec.rb b/spec/services/ci/generate_codequality_mr_diff_report_service_spec.rb
index 63bc7a1caf8..c33b182e9a9 100644
--- a/spec/services/ci/generate_codequality_mr_diff_report_service_spec.rb
+++ b/spec/services/ci/generate_codequality_mr_diff_report_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::GenerateCodequalityMrDiffReportService do
+RSpec.describe Ci::GenerateCodequalityMrDiffReportService, feature_category: :code_review_workflow do
let(:service) { described_class.new(project) }
let(:project) { create(:project, :repository) }
diff --git a/spec/services/ci/generate_coverage_reports_service_spec.rb b/spec/services/ci/generate_coverage_reports_service_spec.rb
index 212e6be9d07..811431bf9d6 100644
--- a/spec/services/ci/generate_coverage_reports_service_spec.rb
+++ b/spec/services/ci/generate_coverage_reports_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::GenerateCoverageReportsService do
+RSpec.describe Ci::GenerateCoverageReportsService, feature_category: :code_testing do
let_it_be(:project) { create(:project, :repository) }
let(:service) { described_class.new(project) }
diff --git a/spec/services/ci/generate_kubeconfig_service_spec.rb b/spec/services/ci/generate_kubeconfig_service_spec.rb
index c0858b0f0c9..a03c6ef0c9d 100644
--- a/spec/services/ci/generate_kubeconfig_service_spec.rb
+++ b/spec/services/ci/generate_kubeconfig_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::GenerateKubeconfigService do
+RSpec.describe Ci::GenerateKubeconfigService, feature_category: :deployment_management do
describe '#execute' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
@@ -13,12 +13,12 @@ RSpec.describe Ci::GenerateKubeconfigService do
let_it_be(:project_agent_authorization) do
agent = create(:cluster_agent, project: agent_project)
- create(:agent_project_authorization, agent: agent, project: project)
+ create(:agent_ci_access_project_authorization, agent: agent, project: project)
end
let_it_be(:group_agent_authorization) do
agent = create(:cluster_agent, project: agent_project)
- create(:agent_group_authorization, agent: agent, group: group)
+ create(:agent_ci_access_group_authorization, agent: agent, group: group)
end
let(:template) do
@@ -33,7 +33,7 @@ RSpec.describe Ci::GenerateKubeconfigService do
let(:agent_authorizations) { [project_agent_authorization, group_agent_authorization] }
let(:filter_service) do
instance_double(
- ::Clusters::Agents::FilterAuthorizationsService,
+ ::Clusters::Agents::Authorizations::CiAccess::FilterService,
execute: agent_authorizations
)
end
@@ -42,7 +42,7 @@ RSpec.describe Ci::GenerateKubeconfigService do
before do
allow(Gitlab::Kubernetes::Kubeconfig::Template).to receive(:new).and_return(template)
- allow(::Clusters::Agents::FilterAuthorizationsService).to receive(:new).and_return(filter_service)
+ allow(::Clusters::Agents::Authorizations::CiAccess::FilterService).to receive(:new).and_return(filter_service)
end
it 'returns a Kubeconfig Template' do
@@ -59,7 +59,7 @@ RSpec.describe Ci::GenerateKubeconfigService do
end
it "filters the pipeline's agents by `nil` environment" do
- expect(::Clusters::Agents::FilterAuthorizationsService).to receive(:new).with(
+ expect(::Clusters::Agents::Authorizations::CiAccess::FilterService).to receive(:new).with(
pipeline.cluster_agent_authorizations,
environment: nil
)
@@ -89,7 +89,7 @@ RSpec.describe Ci::GenerateKubeconfigService do
subject(:execute) { described_class.new(pipeline, token: build.token, environment: 'production').execute }
it "filters the pipeline's agents by the specified environment" do
- expect(::Clusters::Agents::FilterAuthorizationsService).to receive(:new).with(
+ expect(::Clusters::Agents::Authorizations::CiAccess::FilterService).to receive(:new).with(
pipeline.cluster_agent_authorizations,
environment: 'production'
)
diff --git a/spec/services/ci/generate_terraform_reports_service_spec.rb b/spec/services/ci/generate_terraform_reports_service_spec.rb
index c32e8bcaeb8..b2142d391b8 100644
--- a/spec/services/ci/generate_terraform_reports_service_spec.rb
+++ b/spec/services/ci/generate_terraform_reports_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::GenerateTerraformReportsService do
+RSpec.describe Ci::GenerateTerraformReportsService, feature_category: :infrastructure_as_code do
let_it_be(:project) { create(:project, :repository) }
describe '#execute' do
diff --git a/spec/services/ci/job_artifacts/bulk_delete_by_project_service_spec.rb b/spec/services/ci/job_artifacts/bulk_delete_by_project_service_spec.rb
new file mode 100644
index 00000000000..a180837f9a9
--- /dev/null
+++ b/spec/services/ci/job_artifacts/bulk_delete_by_project_service_spec.rb
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ci::JobArtifacts::BulkDeleteByProjectService, "#execute", feature_category: :build_artifacts do
+ subject(:execute) do
+ described_class.new(
+ job_artifact_ids: job_artifact_ids,
+ current_user: current_user,
+ project: project).execute
+ end
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:build, reload: true) do
+ create(:ci_build, :artifacts, :trace_artifact, user: current_user)
+ end
+
+ let_it_be(:project) { build.project }
+ let_it_be(:job_artifact_ids) { build.job_artifacts.map(&:id) }
+
+ describe '#execute' do
+ context 'when number of artifacts exceeds limits to delete' do
+ let_it_be(:second_build, reload: true) do
+ create(:ci_build, :artifacts, :trace_artifact, user: current_user, project: project)
+ end
+
+ let_it_be(:job_artifact_ids) { ::Ci::JobArtifact.all.map(&:id) }
+
+ before do
+ project.add_maintainer(current_user)
+ stub_const("#{described_class}::JOB_ARTIFACTS_COUNT_LIMIT", 1)
+ end
+
+ it 'fails to destroy' do
+ result = execute
+
+ expect(result).to be_error
+ expect(result[:message]).to eq('Can only delete up to 1 job artifacts per call')
+ end
+ end
+
+ context 'when requested not existing artifacts do delete' do
+ let_it_be(:deleted_build, reload: true) do
+ create(:ci_build, :artifacts, :trace_artifact, user: current_user, project: project)
+ end
+
+ let_it_be(:deleted_job_artifacts) { deleted_build.job_artifacts }
+ let_it_be(:job_artifact_ids) { ::Ci::JobArtifact.all.map(&:id) }
+
+ before do
+ project.add_maintainer(current_user)
+ deleted_job_artifacts.each(&:destroy!)
+ end
+
+ it 'fails to destroy' do
+ result = execute
+
+ expect(result).to be_error
+ expect(result[:message]).to eq("Artifacts (#{deleted_job_artifacts.map(&:id).join(',')}) not found")
+ end
+ end
+
+ context 'when maintainer has access to the project' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ it 'is successful' do
+ result = execute
+
+ expect(result).to be_success
+ expect(result.payload).to eq(
+ {
+ destroyed_count: job_artifact_ids.count,
+ destroyed_ids: job_artifact_ids,
+ errors: []
+ }
+ )
+ expect(::Ci::JobArtifact.where(id: job_artifact_ids).count).to eq(0)
+ end
+
+ context 'and partially owns artifacts' do
+ let_it_be(:orphan_artifact) { create(:ci_job_artifact, :archive) }
+ let_it_be(:orphan_artifact_id) { orphan_artifact.id }
+ let_it_be(:owned_artifacts_ids) { build.job_artifacts.erasable.map(&:id) }
+ let_it_be(:job_artifact_ids) { [orphan_artifact_id] + owned_artifacts_ids }
+
+ it 'fails to destroy' do
+ result = execute
+
+ expect(result).to be_error
+ expect(result[:message]).to be('Not all artifacts belong to requested project')
+ expect(::Ci::JobArtifact.where(id: job_artifact_ids).count).to eq(3)
+ end
+ end
+
+ context 'and request all artifacts from a different project' do
+ let_it_be(:different_project_artifact) { create(:ci_job_artifact, :archive) }
+ let_it_be(:job_artifact_ids) { [different_project_artifact] }
+
+ let_it_be(:different_build, reload: true) do
+ create(:ci_build, :artifacts, :trace_artifact, user: current_user)
+ end
+
+ let_it_be(:different_project) { different_build.project }
+
+ before do
+ different_project.add_maintainer(current_user)
+ end
+
+ it 'returns a error' do
+ result = execute
+
+ expect(result).to be_error
+ expect(result[:message]).to be('Not all artifacts belong to requested project')
+ expect(::Ci::JobArtifact.where(id: job_artifact_ids).count).to eq(job_artifact_ids.count)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/job_artifacts/create_service_spec.rb b/spec/services/ci/job_artifacts/create_service_spec.rb
index 47e9e5994ef..f71d7feb04a 100644
--- a/spec/services/ci/job_artifacts/create_service_spec.rb
+++ b/spec/services/ci/job_artifacts/create_service_spec.rb
@@ -2,108 +2,215 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::CreateService do
+RSpec.describe Ci::JobArtifacts::CreateService, :clean_gitlab_redis_shared_state, feature_category: :build_artifacts do
+ include WorkhorseHelpers
+ include Gitlab::Utils::Gzip
+
let_it_be(:project) { create(:project) }
let(:service) { described_class.new(job) }
let(:job) { create(:ci_build, project: project) }
- let(:artifacts_sha256) { '0' * 64 }
- let(:metadata_file) { nil }
- let(:artifacts_file) do
- file_to_upload('spec/fixtures/ci_build_artifacts.zip', sha256: artifacts_sha256)
- end
+ describe '#authorize', :aggregate_failures do
+ let(:artifact_type) { 'archive' }
+ let(:filesize) { nil }
- let(:params) do
- {
- 'artifact_type' => 'archive',
- 'artifact_format' => 'zip'
- }.with_indifferent_access
- end
+ subject(:authorize) { service.authorize(artifact_type: artifact_type, filesize: filesize) }
- def file_to_upload(path, params = {})
- upload = Tempfile.new('upload')
- FileUtils.copy(path, upload.path)
- # This is a workaround for https://github.com/docker/for-linux/issues/1015
- FileUtils.touch(upload.path)
+ shared_examples_for 'handling lsif artifact' do
+ context 'when artifact is lsif' do
+ let(:artifact_type) { 'lsif' }
- UploadedFile.new(upload.path, **params)
- end
+ it 'includes ProcessLsif in the headers' do
+ expect(authorize[:headers][:ProcessLsif]).to eq(true)
+ end
+ end
+ end
- describe '#execute' do
- subject { service.execute(artifacts_file, params, metadata_file: metadata_file) }
+ shared_examples_for 'validating requirements' do
+ context 'when filesize is specified' do
+ let(:max_artifact_size) { 10 }
- context 'when artifacts file is uploaded' do
- it 'logs the created artifact' do
- expect(Gitlab::Ci::Artifacts::Logger)
- .to receive(:log_created)
- .with(an_instance_of(Ci::JobArtifact))
+ before do
+ allow(Ci::JobArtifact)
+ .to receive(:max_artifact_size)
+ .with(type: artifact_type, project: project)
+ .and_return(max_artifact_size)
+ end
- subject
- end
+ context 'and filesize exceeds the limit' do
+ let(:filesize) { max_artifact_size + 1 }
- it 'returns artifact in the response' do
- response = subject
- new_artifact = job.job_artifacts.last
+ it 'returns error' do
+ expect(authorize[:status]).to eq(:error)
+ end
+ end
- expect(response[:artifact]).to eq(new_artifact)
- end
+ context 'and filesize does not exceed the limit' do
+ let(:filesize) { max_artifact_size - 1 }
- it 'saves artifact for the given type' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(1)
+ it 'returns success' do
+ expect(authorize[:status]).to eq(:success)
+ end
+ end
+ end
+ end
- new_artifact = job.job_artifacts.last
- expect(new_artifact.project).to eq(job.project)
- expect(new_artifact.file).to be_present
- expect(new_artifact.file_type).to eq(params['artifact_type'])
- expect(new_artifact.file_format).to eq(params['artifact_format'])
- expect(new_artifact.file_sha256).to eq(artifacts_sha256)
- expect(new_artifact.locked).to eq(job.pipeline.locked)
+ shared_examples_for 'uploading to temp location' do |store_type|
+ # We are not testing the entire headers here because this is fully tested
+ # in workhorse_authorize's spec. We just want to confirm that it indeed used the temp path
+ # by checking some indicators in the headers returned.
+ if store_type == :object_storage
+ it 'includes the authorize headers' do
+ expect(authorize[:status]).to eq(:success)
+ expect(authorize[:headers][:RemoteObject][:StoreURL]).to include(ObjectStorage::TMP_UPLOAD_PATH)
+ end
+ else
+ it 'includes the authorize headers' do
+ expect(authorize[:status]).to eq(:success)
+ expect(authorize[:headers][:TempPath]).to include(ObjectStorage::TMP_UPLOAD_PATH)
+ end
end
- it 'sets accessibility level by default to public' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(1)
+ it_behaves_like 'handling lsif artifact'
+ it_behaves_like 'validating requirements'
+ end
- new_artifact = job.job_artifacts.last
- expect(new_artifact).to be_public_accessibility
- end
+ context 'when object storage is enabled' do
+ context 'and direct upload is enabled' do
+ let(:final_store_path) { '12/34/abc-123' }
- context 'when accessibility level passed as private' do
before do
- params.merge!('accessibility' => 'private')
+ stub_artifacts_object_storage(JobArtifactUploader, direct_upload: true)
+ allow(JobArtifactUploader).to receive(:generate_final_store_path).and_return(final_store_path)
end
- it 'sets accessibility level to private' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(1)
+ it 'includes the authorize headers' do
+ expect(authorize[:status]).to eq(:success)
- new_artifact = job.job_artifacts.last
- expect(new_artifact).to be_private_accessibility
+ expect(authorize[:headers][:RemoteObject][:ID]).to eq(final_store_path)
+
+ # We are not testing the entire headers here because this is fully tested
+ # in workhorse_authorize's spec. We just want to confirm that it indeed used the final path
+ # by checking some indicators in the headers returned.
+ expect(authorize[:headers][:RemoteObject][:StoreURL])
+ .to include(final_store_path)
+
+ # We have to ensure to tell Workhorse to skip deleting the file after upload
+ # because we are uploading the file to its final location
+ expect(authorize[:headers][:RemoteObject][:SkipDelete]).to eq(true)
+ end
+
+ it_behaves_like 'handling lsif artifact'
+ it_behaves_like 'validating requirements'
+
+ context 'with ci_artifacts_upload_to_final_location feature flag disabled' do
+ before do
+ stub_feature_flags(ci_artifacts_upload_to_final_location: false)
+ end
+
+ it_behaves_like 'uploading to temp location', :object_storage
end
end
- context 'when accessibility passed as public' do
+ context 'and direct upload is disabled' do
before do
- params.merge!('accessibility' => 'public')
+ stub_artifacts_object_storage(JobArtifactUploader, direct_upload: false)
end
+ it_behaves_like 'uploading to temp location', :local_storage
+ end
+ end
+
+ context 'when object storage is disabled' do
+ it_behaves_like 'uploading to temp location', :local_storage
+ end
+ end
+
+ describe '#execute' do
+ let(:artifacts_sha256) { '0' * 64 }
+ let(:metadata_file) { nil }
+
+ let(:params) do
+ {
+ 'artifact_type' => 'archive',
+ 'artifact_format' => 'zip'
+ }.with_indifferent_access
+ end
+
+ subject(:execute) { service.execute(artifacts_file, params, metadata_file: metadata_file) }
+
+ shared_examples_for 'handling accessibility' do
+ shared_examples 'public accessibility' do
it 'sets accessibility to public level' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(1)
+ expect(job.job_artifacts).to all be_public_accessibility
+ end
+ end
- new_artifact = job.job_artifacts.last
- expect(new_artifact).to be_public_accessibility
+ shared_examples 'private accessibility' do
+ it 'sets accessibility to private level' do
+ expect(job.job_artifacts).to all be_private_accessibility
+ end
+ end
+
+ context 'when non_public_artifacts flag is disabled' do
+ before do
+ stub_feature_flags(non_public_artifacts: false)
+ end
+
+ it_behaves_like 'public accessibility'
+ end
+
+ context 'when non_public_artifacts flag is enabled' do
+ context 'and accessibility is defined in the params' do
+ context 'and is passed as private' do
+ before do
+ params.merge!('accessibility' => 'private')
+ end
+
+ it_behaves_like 'private accessibility'
+ end
+
+ context 'and is passed as public' do
+ before do
+ params.merge!('accessibility' => 'public')
+ end
+
+ it_behaves_like 'public accessibility'
+ end
+ end
+
+ context 'and accessibility is not defined in the params' do
+ context 'and job has no public artifacts defined in its CI config' do
+ it_behaves_like 'public accessibility'
+ end
+
+ context 'and job artifacts defined as private in the CI config' do
+ let(:job) { create(:ci_build, :with_private_artifacts_config, project: project) }
+
+ it_behaves_like 'private accessibility'
+ end
+
+ context 'and job artifacts defined as public in the CI config' do
+ let(:job) { create(:ci_build, :with_public_artifacts_config, project: project) }
+
+ it_behaves_like 'public accessibility'
+ end
end
end
context 'when accessibility passed as invalid value' do
before do
- params.merge!('accessibility' => 'invalid_value')
+ params.merge!('accessibility' => 'foo')
end
it 'fails with argument error' do
- expect { subject }.to raise_error(ArgumentError)
+ expect { execute }.to raise_error(ArgumentError, "'foo' is not a valid accessibility")
end
end
+ end
+ shared_examples_for 'handling metadata file' do
context 'when metadata file is also uploaded' do
let(:metadata_file) do
file_to_upload('spec/fixtures/ci_build_artifacts_metadata.gz', sha256: artifacts_sha256)
@@ -113,8 +220,8 @@ RSpec.describe Ci::JobArtifacts::CreateService do
stub_application_setting(default_artifacts_expire_in: '1 day')
end
- it 'saves metadata artifact' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(2)
+ it 'creates a new metadata job artifact' do
+ expect { execute }.to change { Ci::JobArtifact.where(file_type: :metadata).count }.by(1)
new_artifact = job.job_artifacts.last
expect(new_artifact.project).to eq(job.project)
@@ -125,13 +232,6 @@ RSpec.describe Ci::JobArtifacts::CreateService do
expect(new_artifact.locked).to eq(job.pipeline.locked)
end
- it 'sets accessibility by default to public' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(2)
-
- new_artifact = job.job_artifacts.last
- expect(new_artifact).to be_public_accessibility
- end
-
it 'logs the created artifact and metadata' do
expect(Gitlab::Ci::Artifacts::Logger)
.to receive(:log_created)
@@ -140,36 +240,12 @@ RSpec.describe Ci::JobArtifacts::CreateService do
subject
end
- context 'when accessibility level passed as private' do
- before do
- params.merge!('accessibility' => 'private')
- end
-
- it 'sets accessibility to private level' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(2)
-
- new_artifact = job.job_artifacts.last
- expect(new_artifact).to be_private_accessibility
- end
- end
-
- context 'when accessibility passed as public' do
- before do
- params.merge!('accessibility' => 'public')
- end
-
- it 'sets accessibility level to public' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(2)
-
- new_artifact = job.job_artifacts.last
- expect(new_artifact).to be_public_accessibility
- end
- end
+ it_behaves_like 'handling accessibility'
it 'sets expiration date according to application settings' do
expected_expire_at = 1.day.from_now
- expect(subject).to match(a_hash_including(status: :success, artifact: anything))
+ expect(execute).to match(a_hash_including(status: :success, artifact: anything))
archive_artifact, metadata_artifact = job.job_artifacts.last(2)
expect(job.artifacts_expire_at).to be_within(1.minute).of(expected_expire_at)
@@ -185,7 +261,7 @@ RSpec.describe Ci::JobArtifacts::CreateService do
it 'sets expiration date according to the parameter' do
expected_expire_at = 2.hours.from_now
- expect(subject).to match(a_hash_including(status: :success, artifact: anything))
+ expect(execute).to match(a_hash_including(status: :success, artifact: anything))
archive_artifact, metadata_artifact = job.job_artifacts.last(2)
expect(job.artifacts_expire_at).to be_within(1.minute).of(expected_expire_at)
@@ -202,7 +278,7 @@ RSpec.describe Ci::JobArtifacts::CreateService do
it 'sets expiration date according to the parameter' do
expected_expire_at = nil
- expect(subject).to be_truthy
+ expect(execute).to be_truthy
archive_artifact, metadata_artifact = job.job_artifacts.last(2)
expect(job.artifacts_expire_at).to eq(expected_expire_at)
@@ -213,96 +289,237 @@ RSpec.describe Ci::JobArtifacts::CreateService do
end
end
- context 'when artifacts file already exists' do
- let!(:existing_artifact) do
- create(:ci_job_artifact, :archive, file_sha256: existing_sha256, job: job)
- end
+ shared_examples_for 'handling dotenv' do |storage_type|
+ context 'when artifact type is dotenv' do
+ let(:params) do
+ {
+ 'artifact_type' => 'dotenv',
+ 'artifact_format' => 'gzip'
+ }.with_indifferent_access
+ end
- context 'when sha256 of uploading artifact is the same of the existing one' do
- let(:existing_sha256) { artifacts_sha256 }
+ if storage_type == :object_storage
+ let(:object_body) { File.read('spec/fixtures/build.env.gz') }
+ let(:upload_filename) { 'build.env.gz' }
- it 'ignores the changes' do
- expect { subject }.not_to change { Ci::JobArtifact.count }
- expect(subject).to match(a_hash_including(status: :success))
+ before do
+ stub_request(:get, %r{s3.amazonaws.com/#{remote_path}})
+ .to_return(status: 200, body: File.read('spec/fixtures/build.env.gz'))
+ end
+ else
+ let(:artifacts_file) do
+ file_to_upload('spec/fixtures/build.env.gz', sha256: artifacts_sha256)
+ end
+ end
+
+ it 'calls parse service' do
+ expect_any_instance_of(Ci::ParseDotenvArtifactService) do |service|
+ expect(service).to receive(:execute).once.and_call_original
+ end
+
+ expect(execute[:status]).to eq(:success)
+ expect(job.job_variables.as_json(only: [:key, :value, :source])).to contain_exactly(
+ hash_including('key' => 'KEY1', 'value' => 'VAR1', 'source' => 'dotenv'),
+ hash_including('key' => 'KEY2', 'value' => 'VAR2', 'source' => 'dotenv'))
end
end
+ end
- context 'when sha256 of uploading artifact is different than the existing one' do
- let(:existing_sha256) { '1' * 64 }
+ shared_examples_for 'handling object storage errors' do
+ shared_examples 'rescues object storage error' do |klass, message, expected_message|
+ it "handles #{klass}" do
+ allow_next_instance_of(JobArtifactUploader) do |uploader|
+ allow(uploader).to receive(:store!).and_raise(klass, message)
+ end
- it 'returns error status' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception).and_call_original
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .and_call_original
- expect { subject }.not_to change { Ci::JobArtifact.count }
- expect(subject).to match(
+ expect(execute).to match(
a_hash_including(
- http_status: :bad_request, message: 'another artifact of the same type already exists', status: :error))
+ http_status: :service_unavailable,
+ message: expected_message || message,
+ status: :error))
end
end
+
+ it_behaves_like 'rescues object storage error',
+ Errno::EIO, 'some/path', 'Input/output error - some/path'
+
+ it_behaves_like 'rescues object storage error',
+ Google::Apis::ServerError, 'Server error'
+
+ it_behaves_like 'rescues object storage error',
+ Signet::RemoteServerError, 'The service is currently unavailable'
end
- context 'when artifact type is dotenv' do
- let(:artifacts_file) do
- file_to_upload('spec/fixtures/build.env.gz', sha256: artifacts_sha256)
- end
+ shared_examples_for 'validating requirements' do
+ context 'when filesize is specified' do
+ let(:max_artifact_size) { 10 }
+
+ before do
+ allow(Ci::JobArtifact)
+ .to receive(:max_artifact_size)
+ .with(type: 'archive', project: project)
+ .and_return(max_artifact_size)
- let(:params) do
- {
- 'artifact_type' => 'dotenv',
- 'artifact_format' => 'gzip'
- }.with_indifferent_access
+ allow(artifacts_file).to receive(:size).and_return(filesize)
+ end
+
+ context 'and filesize exceeds the limit' do
+ let(:filesize) { max_artifact_size + 1 }
+
+ it 'returns error' do
+ expect(execute[:status]).to eq(:error)
+ end
+ end
+
+ context 'and filesize does not exceed the limit' do
+ let(:filesize) { max_artifact_size - 1 }
+
+ it 'returns success' do
+ expect(execute[:status]).to eq(:success)
+ end
+ end
end
+ end
- it 'calls parse service' do
- expect_any_instance_of(Ci::ParseDotenvArtifactService) do |service|
- expect(service).to receive(:execute).once.and_call_original
+ shared_examples_for 'handling existing artifact' do
+ context 'when job already has an artifact of the same file type' do
+ let!(:existing_artifact) do
+ create(:ci_job_artifact, params[:artifact_type], file_sha256: existing_sha256, job: job)
end
- expect(subject[:status]).to eq(:success)
- expect(job.job_variables.as_json(only: [:key, :value, :source])).to contain_exactly(
- hash_including('key' => 'KEY1', 'value' => 'VAR1', 'source' => 'dotenv'),
- hash_including('key' => 'KEY2', 'value' => 'VAR2', 'source' => 'dotenv'))
+ context 'when sha256 of uploading artifact is the same of the existing one' do
+ let(:existing_sha256) { artifacts_sha256 }
+
+ it 'ignores the changes' do
+ expect { execute }.not_to change { Ci::JobArtifact.count }
+ expect(execute).to match(a_hash_including(status: :success))
+ end
+ end
+
+ context 'when sha256 of uploading artifact is different than the existing one' do
+ let(:existing_sha256) { '1' * 64 }
+
+ it 'returns error status' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).and_call_original
+
+ expect { execute }.not_to change { Ci::JobArtifact.count }
+ expect(execute).to match(
+ a_hash_including(
+ http_status: :bad_request,
+ message: 'another artifact of the same type already exists',
+ status: :error
+ )
+ )
+ end
+ end
+ end
+ end
+
+ shared_examples_for 'logging artifact' do
+ it 'logs the created artifact' do
+ expect(Gitlab::Ci::Artifacts::Logger)
+ .to receive(:log_created)
+ .with(an_instance_of(Ci::JobArtifact))
+
+ execute
end
end
- context 'with job partitioning', :ci_partitionable do
- let(:pipeline) { create(:ci_pipeline, project: project, partition_id: ci_testing_partition_id) }
- let(:job) { create(:ci_build, pipeline: pipeline) }
+ shared_examples_for 'handling uploads' do
+ context 'when artifacts file is uploaded' do
+ it 'creates a new job artifact' do
+ expect { execute }.to change { Ci::JobArtifact.count }.by(1)
- it 'sets partition_id on artifacts' do
- expect { subject }.to change { Ci::JobArtifact.count }
+ new_artifact = execute[:artifact]
+ expect(new_artifact).to eq(job.job_artifacts.last)
+ expect(new_artifact.project).to eq(job.project)
+ expect(new_artifact.file.filename).to eq(artifacts_file.original_filename)
+ expect(new_artifact.file_identifier).to eq(artifacts_file.original_filename)
+ expect(new_artifact.file_type).to eq(params['artifact_type'])
+ expect(new_artifact.file_format).to eq(params['artifact_format'])
+ expect(new_artifact.file_sha256).to eq(artifacts_sha256)
+ expect(new_artifact.locked).to eq(job.pipeline.locked)
+ expect(new_artifact.size).to eq(artifacts_file.size)
- artifacts_partitions = job.job_artifacts.map(&:partition_id).uniq
+ expect(execute[:status]).to eq(:success)
+ end
- expect(artifacts_partitions).to eq([ci_testing_partition_id])
+ it_behaves_like 'handling accessibility'
+ it_behaves_like 'handling metadata file'
+ it_behaves_like 'handling partitioning'
+ it_behaves_like 'logging artifact'
end
end
- shared_examples 'rescues object storage error' do |klass, message, expected_message|
- it "handles #{klass}" do
- allow_next_instance_of(JobArtifactUploader) do |uploader|
- allow(uploader).to receive(:store!).and_raise(klass, message)
+ shared_examples_for 'handling partitioning' do
+ context 'with job partitioned', :ci_partitionable do
+ let(:pipeline) { create(:ci_pipeline, project: project, partition_id: ci_testing_partition_id) }
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+
+ it 'sets partition_id on artifacts' do
+ expect { execute }.to change { Ci::JobArtifact.count }
+
+ artifacts_partitions = job.job_artifacts.map(&:partition_id).uniq
+
+ expect(artifacts_partitions).to eq([ci_testing_partition_id])
end
+ end
+ end
- expect(Gitlab::ErrorTracking)
- .to receive(:track_exception)
- .and_call_original
+ context 'when object storage and direct upload is enabled' do
+ let(:fog_connection) { stub_artifacts_object_storage(JobArtifactUploader, direct_upload: true) }
+ let(:remote_path) { File.join(remote_store_path, remote_id) }
+ let(:object_body) { File.open('spec/fixtures/ci_build_artifacts.zip') }
+ let(:upload_filename) { 'artifacts.zip' }
+ let(:object) do
+ fog_connection.directories
+ .new(key: 'artifacts')
+ .files
+ .create( # rubocop:disable Rails/SaveBang
+ key: remote_path,
+ body: object_body
+ )
+ end
- expect(subject).to match(
- a_hash_including(
- http_status: :service_unavailable,
- message: expected_message || message,
- status: :error))
+ let(:artifacts_file) do
+ fog_to_uploaded_file(
+ object,
+ filename: upload_filename,
+ sha256: artifacts_sha256,
+ remote_id: remote_id
+ )
end
+
+ let(:remote_id) { 'generated-remote-id-12345' }
+ let(:remote_store_path) { ObjectStorage::TMP_UPLOAD_PATH }
+
+ it_behaves_like 'handling uploads'
+ it_behaves_like 'handling dotenv', :object_storage
+ it_behaves_like 'handling object storage errors'
+ it_behaves_like 'validating requirements'
end
- it_behaves_like 'rescues object storage error',
- Errno::EIO, 'some/path', 'Input/output error - some/path'
+ context 'when using local storage' do
+ let(:artifacts_file) do
+ file_to_upload('spec/fixtures/ci_build_artifacts.zip', sha256: artifacts_sha256)
+ end
+
+ it_behaves_like 'handling uploads'
+ it_behaves_like 'handling dotenv', :local_storage
+ it_behaves_like 'validating requirements'
+ end
+ end
- it_behaves_like 'rescues object storage error',
- Google::Apis::ServerError, 'Server error'
+ def file_to_upload(path, params = {})
+ upload = Tempfile.new('upload')
+ FileUtils.copy(path, upload.path)
+ # This is a workaround for https://github.com/docker/for-linux/issues/1015
+ FileUtils.touch(upload.path)
- it_behaves_like 'rescues object storage error',
- Signet::RemoteServerError, 'The service is currently unavailable'
+ UploadedFile.new(upload.path, **params)
end
end
diff --git a/spec/services/ci/job_artifacts/delete_project_artifacts_service_spec.rb b/spec/services/ci/job_artifacts/delete_project_artifacts_service_spec.rb
index 74fa42962f3..9c711e54b00 100644
--- a/spec/services/ci/job_artifacts/delete_project_artifacts_service_spec.rb
+++ b/spec/services/ci/job_artifacts/delete_project_artifacts_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::DeleteProjectArtifactsService do
+RSpec.describe Ci::JobArtifacts::DeleteProjectArtifactsService, feature_category: :build_artifacts do
let_it_be(:project) { create(:project) }
subject { described_class.new(project: project) }
diff --git a/spec/services/ci/job_artifacts/delete_service_spec.rb b/spec/services/ci/job_artifacts/delete_service_spec.rb
index 78e8be48255..1560d0fc6f4 100644
--- a/spec/services/ci/job_artifacts/delete_service_spec.rb
+++ b/spec/services/ci/job_artifacts/delete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::DeleteService do
+RSpec.describe Ci::JobArtifacts::DeleteService, feature_category: :build_artifacts do
let_it_be(:build, reload: true) do
create(:ci_build, :artifacts, :trace_artifact, artifacts_expire_at: 100.days.from_now)
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 457be67c1ea..cdbb0c0f8ce 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
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_shared_state,
-feature_category: :build_artifacts do
+ feature_category: :build_artifacts do
include ExclusiveLeaseHelpers
let(:service) { described_class.new }
@@ -39,32 +39,12 @@ feature_category: :build_artifacts do
second_artifact
end
- context 'with ci_destroy_unlocked_job_artifacts feature flag disabled' do
- before do
- stub_feature_flags(ci_destroy_unlocked_job_artifacts: false)
- end
-
- it 'performs a consistent number of queries' do
- control = ActiveRecord::QueryRecorder.new { service.execute }
-
- more_artifacts
-
- expect { subject }.not_to exceed_query_limit(control.count)
- end
- end
-
- context 'with ci_destroy_unlocked_job_artifacts feature flag enabled' do
- before do
- stub_feature_flags(ci_destroy_unlocked_job_artifacts: true)
- end
-
- it 'performs a consistent number of queries' do
- control = ActiveRecord::QueryRecorder.new { service.execute }
+ it 'performs a consistent number of queries' do
+ control = ActiveRecord::QueryRecorder.new { service.execute }
- more_artifacts
+ more_artifacts
- expect { subject }.not_to exceed_query_limit(control.count)
- end
+ expect { subject }.not_to exceed_query_limit(control.count)
end
end
@@ -251,6 +231,16 @@ feature_category: :build_artifacts do
end
end
+ context 'when some artifacts are trace' do
+ let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) }
+ let!(:trace_artifact) { create(:ci_job_artifact, :trace, :expired, job: job, locked: job.pipeline.locked) }
+
+ it 'destroys only non trace artifacts' do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
+ expect(trace_artifact).to be_persisted
+ end
+ end
+
context 'when all artifacts are locked' do
let!(:artifact) { create(:ci_job_artifact, :expired, job: locked_job, locked: locked_job.pipeline.locked) }
diff --git a/spec/services/ci/job_artifacts/destroy_associations_service_spec.rb b/spec/services/ci/job_artifacts/destroy_associations_service_spec.rb
index ca36c923dcf..f4839ccb04b 100644
--- a/spec/services/ci/job_artifacts/destroy_associations_service_spec.rb
+++ b/spec/services/ci/job_artifacts/destroy_associations_service_spec.rb
@@ -2,24 +2,37 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::DestroyAssociationsService do
+RSpec.describe Ci::JobArtifacts::DestroyAssociationsService, feature_category: :build_artifacts do
let_it_be(:project_1) { create(:project) }
let_it_be(:project_2) { create(:project) }
let_it_be(:artifact_1, refind: true) { create(:ci_job_artifact, :zip, project: project_1) }
- let_it_be(:artifact_2, refind: true) { create(:ci_job_artifact, :zip, project: project_2) }
- let_it_be(:artifact_3, refind: true) { create(:ci_job_artifact, :zip, project: project_1) }
+ let_it_be(:artifact_2, refind: true) { create(:ci_job_artifact, :junit, project: project_2) }
+ let_it_be(:artifact_3, refind: true) { create(:ci_job_artifact, :terraform, project: project_1) }
+ let_it_be(:artifact_4, refind: true) { create(:ci_job_artifact, :trace, project: project_2) }
+ let_it_be(:artifact_5, refind: true) { create(:ci_job_artifact, :metadata, project: project_2) }
- let(:artifacts) { Ci::JobArtifact.where(id: [artifact_1.id, artifact_2.id, artifact_3.id]) }
+ let_it_be(:locked_artifact, refind: true) { create(:ci_job_artifact, :zip, :locked, project: project_1) }
+
+ let(:artifact_ids_to_be_removed) { [artifact_1.id, artifact_2.id, artifact_3.id, artifact_4.id, artifact_5.id] }
+ let(:artifacts) { Ci::JobArtifact.where(id: artifact_ids_to_be_removed) }
let(:service) { described_class.new(artifacts) }
describe '#destroy_records' do
- it 'removes artifacts without updating statistics' do
+ it 'removes all types of artifacts without updating statistics' do
expect_next_instance_of(Ci::JobArtifacts::DestroyBatchService) do |service|
expect(service).to receive(:execute).with(update_stats: false).and_call_original
end
- expect { service.destroy_records }.to change { Ci::JobArtifact.count }.by(-3)
+ expect { service.destroy_records }.to change { Ci::JobArtifact.count }.by(-artifact_ids_to_be_removed.count)
+ end
+
+ context 'with a locked artifact' do
+ let(:artifact_ids_to_be_removed) { [artifact_1.id, locked_artifact.id] }
+
+ it 'removes all artifacts' do
+ expect { service.destroy_records }.to change { Ci::JobArtifact.count }.by(-artifact_ids_to_be_removed.count)
+ end
end
context 'when there are no artifacts' do
@@ -42,7 +55,11 @@ RSpec.describe Ci::JobArtifacts::DestroyAssociationsService do
have_attributes(amount: -artifact_1.size, ref: artifact_1.id),
have_attributes(amount: -artifact_3.size, ref: artifact_3.id)
]
- project2_increments = [have_attributes(amount: -artifact_2.size, ref: artifact_2.id)]
+ project2_increments = [
+ have_attributes(amount: -artifact_2.size, ref: artifact_2.id),
+ have_attributes(amount: -artifact_4.size, ref: artifact_4.id),
+ have_attributes(amount: -artifact_5.size, ref: artifact_5.id)
+ ]
expect(ProjectStatistics).to receive(:bulk_increment_statistic).once
.with(project_1, :build_artifacts_size, match_array(project1_increments))
diff --git a/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb b/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb
index cde42783d8c..6f9dcf47535 100644
--- a/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb
+++ b/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::DestroyBatchService do
- let(:artifacts) { Ci::JobArtifact.where(id: [artifact_with_file.id, artifact_without_file.id, trace_artifact.id]) }
+RSpec.describe Ci::JobArtifacts::DestroyBatchService, feature_category: :build_artifacts do
+ let(:artifacts) { Ci::JobArtifact.where(id: [artifact_with_file.id, artifact_without_file.id]) }
let(:skip_projects_on_refresh) { false }
let(:service) do
described_class.new(
@@ -25,10 +25,6 @@ RSpec.describe Ci::JobArtifacts::DestroyBatchService do
create(:ci_job_artifact)
end
- let_it_be(:trace_artifact, refind: true) do
- create(:ci_job_artifact, :trace, :expired)
- end
-
describe '#execute' do
subject(:execute) { service.execute }
@@ -60,11 +56,6 @@ RSpec.describe Ci::JobArtifacts::DestroyBatchService do
execute
end
- it 'preserves trace artifacts' do
- expect { subject }
- .to not_change { Ci::JobArtifact.exists?(trace_artifact.id) }
- end
-
context 'when artifact belongs to a project that is undergoing stats refresh' do
let!(:artifact_under_refresh_1) do
create(:ci_job_artifact, :zip)
@@ -287,7 +278,7 @@ RSpec.describe Ci::JobArtifacts::DestroyBatchService do
end
it 'reports the number of destroyed artifacts' do
- is_expected.to eq(destroyed_artifacts_count: 0, statistics_updates: {}, status: :success)
+ is_expected.to eq(destroyed_artifacts_count: 0, destroyed_ids: [], statistics_updates: {}, status: :success)
end
end
end
diff --git a/spec/services/ci/job_artifacts/expire_project_build_artifacts_service_spec.rb b/spec/services/ci/job_artifacts/expire_project_build_artifacts_service_spec.rb
index fb9dd6b876b..69cdf39107a 100644
--- a/spec/services/ci/job_artifacts/expire_project_build_artifacts_service_spec.rb
+++ b/spec/services/ci/job_artifacts/expire_project_build_artifacts_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::ExpireProjectBuildArtifactsService do
+RSpec.describe Ci::JobArtifacts::ExpireProjectBuildArtifactsService, feature_category: :build_artifacts do
let_it_be(:project) { create(:project) }
let_it_be(:pipeline, reload: true) { create(:ci_pipeline, :unlocked, project: project) }
diff --git a/spec/services/ci/job_artifacts/track_artifact_report_service_spec.rb b/spec/services/ci/job_artifacts/track_artifact_report_service_spec.rb
index d4d56825e1f..af0c9b0833d 100644
--- a/spec/services/ci/job_artifacts/track_artifact_report_service_spec.rb
+++ b/spec/services/ci/job_artifacts/track_artifact_report_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::TrackArtifactReportService do
+RSpec.describe Ci::JobArtifacts::TrackArtifactReportService, feature_category: :build_artifacts do
describe '#execute', :clean_gitlab_redis_shared_state do
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: group) }
@@ -33,13 +33,9 @@ RSpec.describe Ci::JobArtifacts::TrackArtifactReportService do
.with(test_event_name_1, values: user1.id)
.and_call_original
- expect { track_artifact_report }
- .to change {
- counter.unique_events(event_names: test_event_name_1,
- start_date: start_time,
- end_date: end_time)
- }
- .by 1
+ expect { track_artifact_report }.to change {
+ counter.unique_events(event_names: test_event_name_1, start_date: start_time, end_date: end_time)
+ }.by 1
end
end
@@ -81,13 +77,9 @@ RSpec.describe Ci::JobArtifacts::TrackArtifactReportService do
expect do
described_class.new.execute(pipeline1)
described_class.new.execute(pipeline2)
- end
- .to change {
- counter.unique_events(event_names: test_event_name_1,
- start_date: start_time,
- end_date: end_time)
- }
- .by 1
+ end.to change {
+ counter.unique_events(event_names: test_event_name_1, start_date: start_time, end_date: end_time)
+ }.by 1
end
end
@@ -109,13 +101,9 @@ RSpec.describe Ci::JobArtifacts::TrackArtifactReportService do
expect do
described_class.new.execute(pipeline1)
described_class.new.execute(pipeline2)
- end
- .to change {
- counter.unique_events(event_names: test_event_name_1,
- start_date: start_time,
- end_date: end_time)
- }
- .by 2
+ end.to change {
+ counter.unique_events(event_names: test_event_name_1, start_date: start_time, end_date: end_time)
+ }.by 2
end
end
@@ -134,13 +122,9 @@ RSpec.describe Ci::JobArtifacts::TrackArtifactReportService do
.with(test_event_name_2, values: user1.id)
.and_call_original
- expect { track_artifact_report }
- .to change {
- counter.unique_events(event_names: test_event_name_2,
- start_date: start_time,
- end_date: end_time)
- }
- .by 1
+ expect { track_artifact_report }.to change {
+ counter.unique_events(event_names: test_event_name_2, start_date: start_time, end_date: end_time)
+ }.by 1
end
end
@@ -158,13 +142,9 @@ RSpec.describe Ci::JobArtifacts::TrackArtifactReportService do
expect do
described_class.new.execute(pipeline1)
described_class.new.execute(pipeline2)
- end
- .to change {
- counter.unique_events(event_names: test_event_name_2,
- start_date: start_time,
- end_date: end_time)
- }
- .by 1
+ end.to change {
+ counter.unique_events(event_names: test_event_name_2, start_date: start_time, end_date: end_time)
+ }.by 1
end
end
@@ -186,13 +166,9 @@ RSpec.describe Ci::JobArtifacts::TrackArtifactReportService do
expect do
described_class.new.execute(pipeline1)
described_class.new.execute(pipeline2)
- end
- .to change {
- counter.unique_events(event_names: test_event_name_2,
- start_date: start_time,
- end_date: end_time)
- }
- .by 2
+ end.to change {
+ counter.unique_events(event_names: test_event_name_2, start_date: start_time, end_date: end_time)
+ }.by 2
end
end
end
diff --git a/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb b/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb
index 67412e41fb8..5f6a89b89e1 100644
--- a/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb
+++ b/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::UpdateUnknownLockedStatusService, :clean_gitlab_redis_shared_state do
+RSpec.describe Ci::JobArtifacts::UpdateUnknownLockedStatusService, :clean_gitlab_redis_shared_state,
+ feature_category: :build_artifacts do
include ExclusiveLeaseHelpers
let(:service) { described_class.new }
diff --git a/spec/services/ci/job_token_scope/add_project_service_spec.rb b/spec/services/ci/job_token_scope/add_project_service_spec.rb
index e6674ee384f..dc7ad81afef 100644
--- a/spec/services/ci/job_token_scope/add_project_service_spec.rb
+++ b/spec/services/ci/job_token_scope/add_project_service_spec.rb
@@ -37,8 +37,8 @@ RSpec.describe Ci::JobTokenScope::AddProjectService, feature_category: :continuo
it_behaves_like 'adds project'
- it 'creates an outbound link by default' do
- expect(resulting_direction).to eq('outbound')
+ it 'creates an inbound link by default' do
+ expect(resulting_direction).to eq('inbound')
end
context 'when direction is specified' do
diff --git a/spec/services/ci/list_config_variables_service_spec.rb b/spec/services/ci/list_config_variables_service_spec.rb
index e2bbdefef7f..07c9085b83a 100644
--- a/spec/services/ci/list_config_variables_service_spec.rb
+++ b/spec/services/ci/list_config_variables_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Ci::ListConfigVariablesService,
-:use_clean_rails_memory_store_caching, feature_category: :pipeline_authoring do
+ :use_clean_rails_memory_store_caching, feature_category: :secrets_management do
include ReactiveCachingHelpers
let(:ci_config) { {} }
diff --git a/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb b/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb
index c4558bddc85..b7b32d2a0af 100644
--- a/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb
+++ b/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineArtifacts::CoverageReportService do
+RSpec.describe Ci::PipelineArtifacts::CoverageReportService, feature_category: :build_artifacts do
describe '#execute' do
let_it_be(:project) { create(:project, :repository) }
diff --git a/spec/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service_spec.rb b/spec/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service_spec.rb
index 5d854b61f14..20265a0ca48 100644
--- a/spec/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service_spec.rb
+++ b/spec/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Ci::PipelineArtifacts::CreateCodeQualityMrDiffReportService do
+RSpec.describe ::Ci::PipelineArtifacts::CreateCodeQualityMrDiffReportService, feature_category: :build_artifacts do
describe '#execute' do
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
diff --git a/spec/services/ci/pipeline_artifacts/destroy_all_expired_service_spec.rb b/spec/services/ci/pipeline_artifacts/destroy_all_expired_service_spec.rb
index 47e8766c215..b46648760e1 100644
--- a/spec/services/ci/pipeline_artifacts/destroy_all_expired_service_spec.rb
+++ b/spec/services/ci/pipeline_artifacts/destroy_all_expired_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_shared_state do
+RSpec.describe Ci::PipelineArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_shared_state,
+ feature_category: :build_artifacts do
let(:service) { described_class.new }
describe '.execute' do
diff --git a/spec/services/ci/pipeline_bridge_status_service_spec.rb b/spec/services/ci/pipeline_bridge_status_service_spec.rb
index 1346f68c952..3d8219251d6 100644
--- a/spec/services/ci/pipeline_bridge_status_service_spec.rb
+++ b/spec/services/ci/pipeline_bridge_status_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineBridgeStatusService do
+RSpec.describe Ci::PipelineBridgeStatusService, feature_category: :continuous_integration do
let(:user) { build(:user) }
let_it_be(:project) { create(:project) }
diff --git a/spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb b/spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb
index ab4ba20e716..06139c091b9 100644
--- a/spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb
+++ b/spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineCreation::StartPipelineService do
+RSpec.describe Ci::PipelineCreation::StartPipelineService, feature_category: :continuous_integration do
let(:pipeline) { build(:ci_pipeline) }
subject(:service) { described_class.new(pipeline) }
diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb
index d0aa1ba4c6c..89b3c45485b 100644
--- a/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb
+++ b/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineProcessing::AtomicProcessingService::StatusCollection do
+RSpec.describe Ci::PipelineProcessing::AtomicProcessingService::StatusCollection,
+ feature_category: :continuous_integration do
using RSpec::Parameterized::TableSyntax
let_it_be(:pipeline) { create(:ci_pipeline) }
@@ -31,15 +32,15 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService::StatusCollection
let(:collection) { described_class.new(pipeline) }
- describe '#set_processable_status' do
- it 'does update existing status of processable' do
- collection.set_processable_status(test_a.id, 'success', 100)
+ describe '#set_job_status' do
+ it 'does update existing status of job' do
+ collection.set_job_status(test_a.id, 'success', 100)
- expect(collection.status_for_names(['test-a'], dag: false)).to eq('success')
+ expect(collection.status_of_jobs(['test-a'])).to eq('success')
end
- it 'ignores a missing processable' do
- collection.set_processable_status(-1, 'failed', 100)
+ it 'ignores a missing job' do
+ collection.set_job_status(-1, 'failed', 100)
end
end
@@ -49,24 +50,21 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService::StatusCollection
end
end
- describe '#status_for_names' do
- where(:names, :status, :dag) do
- %w[build-a] | 'success' | false
- %w[build-a build-b] | 'failed' | false
- %w[build-a test-a] | 'running' | false
- %w[build-a] | 'success' | true
- %w[build-a build-b] | 'failed' | true
- %w[build-a test-a] | 'pending' | true
+ describe '#status_of_jobs' do
+ where(:names, :status) do
+ %w[build-a] | 'success'
+ %w[build-a build-b] | 'failed'
+ %w[build-a test-a] | 'running'
end
with_them do
it 'returns composite status of given names' do
- expect(collection.status_for_names(names, dag: dag)).to eq(status)
+ expect(collection.status_of_jobs(names)).to eq(status)
end
end
end
- describe '#status_for_prior_stage_position' do
+ describe '#status_of_jobs_prior_to_stage' do
where(:stage, :status) do
0 | 'success'
1 | 'failed'
@@ -74,13 +72,13 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService::StatusCollection
end
with_them do
- it 'returns composite status for processables in prior stages' do
- expect(collection.status_for_prior_stage_position(stage)).to eq(status)
+ it 'returns composite status for jobs in prior stages' do
+ expect(collection.status_of_jobs_prior_to_stage(stage)).to eq(status)
end
end
end
- describe '#status_for_stage_position' do
+ describe '#status_of_stage' do
where(:stage, :status) do
0 | 'failed'
1 | 'running'
@@ -88,23 +86,23 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService::StatusCollection
end
with_them do
- it 'returns composite status for processables at a given stages' do
- expect(collection.status_for_stage_position(stage)).to eq(status)
+ it 'returns composite status for jobs at a given stages' do
+ expect(collection.status_of_stage(stage)).to eq(status)
end
end
end
- describe '#created_processable_ids_for_stage_position' do
- it 'returns IDs of processables at a given stage position' do
- expect(collection.created_processable_ids_for_stage_position(0)).to be_empty
- expect(collection.created_processable_ids_for_stage_position(1)).to be_empty
- expect(collection.created_processable_ids_for_stage_position(2)).to contain_exactly(deploy.id)
+ describe '#created_job_ids_in_stage' do
+ it 'returns IDs of jobs at a given stage position' do
+ expect(collection.created_job_ids_in_stage(0)).to be_empty
+ expect(collection.created_job_ids_in_stage(1)).to be_empty
+ expect(collection.created_job_ids_in_stage(2)).to contain_exactly(deploy.id)
end
end
- describe '#processing_processables' do
- it 'returns processables marked as processing' do
- expect(collection.processing_processables.map { |processable| processable[:id] })
+ describe '#processing_jobs' do
+ it 'returns jobs marked as processing' do
+ expect(collection.processing_jobs.map { |job| job[:id] })
.to contain_exactly(build_a.id, build_b.id, test_a.id, test_b.id, deploy.id)
end
end
diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb
index c1669e0424a..8c52603e769 100644
--- a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb
+++ b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb
@@ -59,17 +59,17 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService, feature_category
end
def event_on_jobs(event, job_names)
- statuses = pipeline.latest_statuses.by_name(job_names).to_a
- expect(statuses.count).to eq(job_names.count) # ensure that we have the same counts
+ jobs = pipeline.latest_statuses.by_name(job_names).to_a
+ expect(jobs.count).to eq(job_names.count) # ensure that we have the same counts
- statuses.each do |status|
+ jobs.each do |job|
case event
when 'play'
- status.play(user)
+ job.play(user)
when 'retry'
- ::Ci::RetryJobService.new(project, user).execute(status)
+ ::Ci::RetryJobService.new(project, user).execute(job)
else
- status.public_send("#{event}!")
+ job.public_send("#{event}!")
end
end
end
@@ -646,8 +646,7 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService, feature_category
# Users need ability to merge into a branch in order to trigger
# protected manual actions.
#
- create(:protected_branch, :developers_can_merge,
- name: 'master', project: project)
+ create(:protected_branch, :developers_can_merge, name: 'master', project: project)
end
it 'properly processes entire pipeline' do
@@ -983,8 +982,8 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService, feature_category
bridge1 = all_builds.find_by(name: 'deploy: [ovh, monitoring]')
bridge2 = all_builds.find_by(name: 'deploy: [ovh, app]')
- downstream_job1 = bridge1.downstream_pipeline.processables.first
- downstream_job2 = bridge2.downstream_pipeline.processables.first
+ downstream_job1 = bridge1.downstream_pipeline.all_jobs.first
+ downstream_job2 = bridge2.downstream_pipeline.all_jobs.first
expect(downstream_job1.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring')
expect(downstream_job2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app')
@@ -1068,7 +1067,7 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService, feature_category
private
def all_builds
- pipeline.processables.order(:stage_idx, :id)
+ pipeline.all_jobs.order(:stage_idx, :id)
end
def builds
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_no_needs.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_no_needs.yml
new file mode 100644
index 00000000000..12c51828628
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_no_needs.yml
@@ -0,0 +1,31 @@
+config:
+ test1:
+ stage: test
+ script: exit 0
+ needs: []
+
+ test2:
+ stage: test
+ when: on_failure
+ script: exit 0
+ needs: []
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ jobs:
+ test1: pending
+ test2: skipped
+
+transitions:
+ - event: success
+ jobs: [test1]
+ expect:
+ pipeline: success
+ stages:
+ test: success
+ jobs:
+ test1: success
+ test2: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_build_cancels_test1_and_test2_have_when.yml b/spec/services/ci/pipeline_processing/test_cases/stage_build_cancels_test1_and_test2_have_when.yml
new file mode 100644
index 00000000000..cc92aaba679
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_build_cancels_test1_and_test2_have_when.yml
@@ -0,0 +1,46 @@
+config:
+ build:
+ stage: build
+ script: sleep 10
+
+ test1:
+ stage: test
+ script: exit 0
+ when: on_success
+
+ test2:
+ stage: test
+ script: exit 0
+ when: on_failure
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test1: created
+ test2: created
+ deploy: created
+
+transitions:
+ - event: cancel
+ jobs: [build]
+ expect:
+ pipeline: canceled
+ stages:
+ build: canceled
+ test: skipped
+ deploy: skipped
+ jobs:
+ build: canceled
+ test1: skipped
+ test2: skipped
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_build_cancels_with_allow_failure_test1_and_test2_have_when.yml b/spec/services/ci/pipeline_processing/test_cases/stage_build_cancels_with_allow_failure_test1_and_test2_have_when.yml
new file mode 100644
index 00000000000..34f01afe1de
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_build_cancels_with_allow_failure_test1_and_test2_have_when.yml
@@ -0,0 +1,47 @@
+config:
+ build:
+ stage: build
+ script: sleep 10
+ allow_failure: true
+
+ test1:
+ stage: test
+ script: exit 0
+ when: on_success
+
+ test2:
+ stage: test
+ script: exit 0
+ when: on_failure
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test1: created
+ test2: created
+ deploy: created
+
+transitions:
+ - event: cancel
+ jobs: [build]
+ expect:
+ pipeline: pending
+ stages:
+ build: success
+ test: pending
+ deploy: created
+ jobs:
+ build: canceled
+ test1: pending
+ test2: skipped
+ deploy: created
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_no_prev_stage.yml b/spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_no_prev_stage.yml
new file mode 100644
index 00000000000..57b3aa9ae80
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_no_prev_stage.yml
@@ -0,0 +1,29 @@
+config:
+ test1:
+ stage: test
+ script: exit 0
+
+ test2:
+ stage: test
+ when: on_failure
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ jobs:
+ test1: pending
+ test2: skipped
+
+transitions:
+ - event: success
+ jobs: [test1]
+ expect:
+ pipeline: success
+ stages:
+ test: success
+ jobs:
+ test1: success
+ test2: skipped
diff --git a/spec/services/ci/pipeline_schedules/take_ownership_service_spec.rb b/spec/services/ci/pipeline_schedules/take_ownership_service_spec.rb
index 9a3aad20d89..1d45a06f9ea 100644
--- a/spec/services/ci/pipeline_schedules/take_ownership_service_spec.rb
+++ b/spec/services/ci/pipeline_schedules/take_ownership_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineSchedules::TakeOwnershipService do
+RSpec.describe Ci::PipelineSchedules::TakeOwnershipService, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let_it_be(:owner) { create(:user) }
let_it_be(:reporter) { create(:user) }
diff --git a/spec/services/ci/pipeline_trigger_service_spec.rb b/spec/services/ci/pipeline_trigger_service_spec.rb
index 4946367380e..b6e07e82bb5 100644
--- a/spec/services/ci/pipeline_trigger_service_spec.rb
+++ b/spec/services/ci/pipeline_trigger_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineTriggerService do
+RSpec.describe Ci::PipelineTriggerService, feature_category: :continuous_integration do
include AfterNextHelpers
let_it_be(:project) { create(:project, :repository) }
diff --git a/spec/services/ci/pipelines/add_job_service_spec.rb b/spec/services/ci/pipelines/add_job_service_spec.rb
index c62aa9506bd..6380a6a5ec3 100644
--- a/spec/services/ci/pipelines/add_job_service_spec.rb
+++ b/spec/services/ci/pipelines/add_job_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::Pipelines::AddJobService do
+RSpec.describe Ci::Pipelines::AddJobService, feature_category: :continuous_integration do
include ExclusiveLeaseHelpers
let_it_be_with_reload(:pipeline) { create(:ci_pipeline) }
@@ -86,5 +86,15 @@ RSpec.describe Ci::Pipelines::AddJobService do
expect(execute.payload[:job]).to eq(job)
end
end
+
+ it 'locks pipelines and stages before persisting builds', :aggregate_failures do
+ expect(job).not_to be_persisted
+
+ recorder = ActiveRecord::QueryRecorder.new(skip_cached: false) { execute }
+ entries = recorder.log.select { |query| query.match(/LOCK|INSERT INTO ".{0,2}ci_builds"/) }
+
+ expect(entries.size).to eq(2)
+ expect(entries.first).to match(/LOCK "ci_pipelines", "ci_stages" IN ROW SHARE MODE;/)
+ end
end
end
diff --git a/spec/services/ci/pipelines/hook_service_spec.rb b/spec/services/ci/pipelines/hook_service_spec.rb
index 8d138a3d957..e773ae2d2c3 100644
--- a/spec/services/ci/pipelines/hook_service_spec.rb
+++ b/spec/services/ci/pipelines/hook_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::Pipelines::HookService do
+RSpec.describe Ci::Pipelines::HookService, feature_category: :continuous_integration do
describe '#execute_hooks' do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project) { create(:project, :repository, namespace: namespace) }
diff --git a/spec/services/ci/play_bridge_service_spec.rb b/spec/services/ci/play_bridge_service_spec.rb
index 56b1615a56d..5727ed64f8b 100644
--- a/spec/services/ci/play_bridge_service_spec.rb
+++ b/spec/services/ci/play_bridge_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PlayBridgeService, '#execute' do
+RSpec.describe Ci::PlayBridgeService, '#execute', feature_category: :continuous_integration do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:pipeline) { create(:ci_pipeline, project: project) }
diff --git a/spec/services/ci/play_build_service_spec.rb b/spec/services/ci/play_build_service_spec.rb
index fc07801b672..46b6622d6ec 100644
--- a/spec/services/ci/play_build_service_spec.rb
+++ b/spec/services/ci/play_build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PlayBuildService, '#execute' do
+RSpec.describe Ci::PlayBuildService, '#execute', feature_category: :continuous_integration do
let(:user) { create(:user, developer_projects: [project]) }
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
@@ -16,8 +16,7 @@ RSpec.describe Ci::PlayBuildService, '#execute' do
let(:project) { create(:project) }
it 'allows user to play build if protected branch rules are met' do
- create(:protected_branch, :developers_can_merge,
- name: build.ref, project: project)
+ create(:protected_branch, :developers_can_merge, name: build.ref, project: project)
service.execute(build)
diff --git a/spec/services/ci/play_manual_stage_service_spec.rb b/spec/services/ci/play_manual_stage_service_spec.rb
index 24f0a21f3dd..dd8e037b129 100644
--- a/spec/services/ci/play_manual_stage_service_spec.rb
+++ b/spec/services/ci/play_manual_stage_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PlayManualStageService, '#execute' do
+RSpec.describe Ci::PlayManualStageService, '#execute', feature_category: :continuous_integration do
let(:current_user) { create(:user) }
let(:pipeline) { create(:ci_pipeline, user: current_user) }
let(:project) { pipeline.project }
@@ -11,10 +11,7 @@ RSpec.describe Ci::PlayManualStageService, '#execute' do
let(:stage_status) { 'manual' }
let(:stage) do
- create(:ci_stage,
- pipeline: pipeline,
- project: project,
- name: 'test')
+ create(:ci_stage, pipeline: pipeline, project: project, name: 'test')
end
before do
diff --git a/spec/services/ci/prepare_build_service_spec.rb b/spec/services/ci/prepare_build_service_spec.rb
index f75cb322fe9..8583b8e667c 100644
--- a/spec/services/ci/prepare_build_service_spec.rb
+++ b/spec/services/ci/prepare_build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PrepareBuildService do
+RSpec.describe Ci::PrepareBuildService, feature_category: :continuous_integration do
describe '#execute' do
let(:build) { create(:ci_build, :preparing) }
diff --git a/spec/services/ci/process_build_service_spec.rb b/spec/services/ci/process_build_service_spec.rb
index de308bb1a87..d1442b75731 100644
--- a/spec/services/ci/process_build_service_spec.rb
+++ b/spec/services/ci/process_build_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Ci::ProcessBuildService, '#execute' do
+RSpec.describe Ci::ProcessBuildService, '#execute', feature_category: :continuous_integration do
using RSpec::Parameterized::TableSyntax
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb
index 404e1bf7c87..d1586ad4c8b 100644
--- a/spec/services/ci/process_pipeline_service_spec.rb
+++ b/spec/services/ci/process_pipeline_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ProcessPipelineService do
+RSpec.describe Ci::ProcessPipelineService, feature_category: :continuous_integration do
let_it_be(:project) { create(:project) }
let(:pipeline) do
diff --git a/spec/services/ci/process_sync_events_service_spec.rb b/spec/services/ci/process_sync_events_service_spec.rb
index 7ab7911e578..84b6d7d96f6 100644
--- a/spec/services/ci/process_sync_events_service_spec.rb
+++ b/spec/services/ci/process_sync_events_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ProcessSyncEventsService do
+RSpec.describe Ci::ProcessSyncEventsService, feature_category: :continuous_integration do
let!(:group) { create(:group) }
let!(:project1) { create(:project, group: group) }
let!(:project2) { create(:project, group: group) }
@@ -147,8 +147,7 @@ RSpec.describe Ci::ProcessSyncEventsService do
context 'when the FFs use_traversal_ids and use_traversal_ids_for_ancestors are disabled' do
before do
- stub_feature_flags(use_traversal_ids: false,
- use_traversal_ids_for_ancestors: false)
+ stub_feature_flags(use_traversal_ids: false, use_traversal_ids_for_ancestors: false)
end
it_behaves_like 'event consuming'
diff --git a/spec/services/ci/prometheus_metrics/observe_histograms_service_spec.rb b/spec/services/ci/prometheus_metrics/observe_histograms_service_spec.rb
index 0b100af5902..a9ee5216d81 100644
--- a/spec/services/ci/prometheus_metrics/observe_histograms_service_spec.rb
+++ b/spec/services/ci/prometheus_metrics/observe_histograms_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PrometheusMetrics::ObserveHistogramsService do
+RSpec.describe Ci::PrometheusMetrics::ObserveHistogramsService, feature_category: :continuous_integration do
let_it_be(:project) { create(:project) }
let(:params) { {} }
diff --git a/spec/services/ci/queue/pending_builds_strategy_spec.rb b/spec/services/ci/queue/pending_builds_strategy_spec.rb
index 6f22c256c17..ea9207ddb8f 100644
--- a/spec/services/ci/queue/pending_builds_strategy_spec.rb
+++ b/spec/services/ci/queue/pending_builds_strategy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::Queue::PendingBuildsStrategy do
+RSpec.describe Ci::Queue::PendingBuildsStrategy, feature_category: :continuous_integration do
let_it_be(:group) { create(:group) }
let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group]) }
let_it_be(:project) { create(:project, group: group) }
diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb
index 9183df359b4..6fb61bb3ec5 100644
--- a/spec/services/ci/register_job_service_spec.rb
+++ b/spec/services/ci/register_job_service_spec.rb
@@ -14,128 +14,157 @@ module Ci
let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
describe '#execute' do
- subject(:execute) { described_class.new(runner, runner_machine).execute }
+ subject(:execute) { described_class.new(runner, runner_manager).execute }
- context 'with runner_machine specified' do
- let(:runner) { project_runner }
- let!(:runner_machine) { create(:ci_runner_machine, runner: project_runner) }
+ let(:runner_manager) { nil }
+
+ context 'checks database loadbalancing stickiness' do
+ let(:runner) { shared_runner }
before do
- pending_job.update!(tag_list: ["linux"])
- pending_job.reload
- pending_job.create_queuing_entry!
- project_runner.update!(tag_list: ["linux"])
+ project.update!(shared_runners_enabled: false)
end
- it 'sets runner_machine on job' do
- expect { execute }.to change { pending_job.reload.runner_machine }.from(nil).to(runner_machine)
+ it 'result is valid if replica did caught-up', :aggregate_failures do
+ expect(ApplicationRecord.sticking).to receive(:all_caught_up?).with(:runner, runner.id) { true }
- expect(execute.build).to eq(pending_job)
+ expect { execute }.not_to change { Ci::RunnerManagerBuild.count }.from(0)
+ expect(execute).to be_valid
+ expect(execute.build).to be_nil
+ expect(execute.build_json).to be_nil
end
- end
-
- context 'with no runner machine' do
- let(:runner_machine) { nil }
-
- context 'checks database loadbalancing stickiness' do
- let(:runner) { shared_runner }
-
- before do
- project.update!(shared_runners_enabled: false)
- end
- it 'result is valid if replica did caught-up', :aggregate_failures do
- expect(ApplicationRecord.sticking).to receive(:all_caught_up?).with(:runner, runner.id) { true }
+ it 'result is invalid if replica did not caught-up', :aggregate_failures do
+ expect(ApplicationRecord.sticking).to receive(:all_caught_up?)
+ .with(:runner, shared_runner.id) { false }
- expect(execute).to be_valid
- expect(execute.build).to be_nil
- expect(execute.build_json).to be_nil
- end
+ expect(subject).not_to be_valid
+ expect(subject.build).to be_nil
+ expect(subject.build_json).to be_nil
+ end
+ end
- it 'result is invalid if replica did not caught-up', :aggregate_failures do
- expect(ApplicationRecord.sticking).to receive(:all_caught_up?)
- .with(:runner, shared_runner.id) { false }
+ shared_examples 'handles runner assignment' do
+ context 'runner follows tag list' do
+ subject(:build) { build_on(project_runner, runner_manager: project_runner_manager) }
- expect(subject).not_to be_valid
- expect(subject.build).to be_nil
- expect(subject.build_json).to be_nil
- end
- end
+ let(:project_runner_manager) { nil }
- shared_examples 'handles runner assignment' do
- context 'runner follow tag list' do
- it "picks build with the same tag" do
+ context 'when job has tag' do
+ before do
pending_job.update!(tag_list: ["linux"])
pending_job.reload
pending_job.create_queuing_entry!
- project_runner.update!(tag_list: ["linux"])
- expect(build_on(project_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!
- project_runner.update!(tag_list: ["win32"])
- expect(build_on(project_runner)).to be_falsey
+ context 'and runner has matching tag' do
+ before do
+ project_runner.update!(tag_list: ["linux"])
+ end
+
+ context 'with no runner manager specified' do
+ it 'picks build' do
+ expect(build).to eq(pending_job)
+ expect(pending_job.runner_manager).to be_nil
+ end
+ end
+
+ context 'with runner manager specified' do
+ let(:project_runner_manager) { create(:ci_runner_machine, runner: project_runner) }
+
+ it 'picks build and assigns runner manager' do
+ expect(build).to eq(pending_job)
+ expect(pending_job.runner_manager).to eq(project_runner_manager)
+ end
+ end
end
- it "picks build without tag" do
- expect(build_on(project_runner)).to eq(pending_job)
+ it 'does not pick build with different tag' do
+ project_runner.update!(tag_list: ["win32"])
+ expect(build).to be_falsey
end
- it "does not pick build with tag" do
- pending_job.update!(tag_list: ["linux"])
- pending_job.reload
+ it 'does not pick build with tag' do
pending_job.create_queuing_entry!
- expect(build_on(project_runner)).to be_falsey
+ expect(build).to be_falsey
end
+ end
- it "pick build without tag" do
- project_runner.update!(tag_list: ["win32"])
- expect(build_on(project_runner)).to eq(pending_job)
+ context 'when job has no tag' do
+ it 'picks build' do
+ expect(build).to eq(pending_job)
+ end
+
+ context 'when runner has tag' do
+ before do
+ project_runner.update!(tag_list: ["win32"])
+ end
+
+ it 'picks build' do
+ expect(build).to eq(pending_job)
+ end
end
end
+ end
- context 'deleted projects' do
+ context 'deleted projects' do
+ before do
+ project.update!(pending_delete: true)
+ end
+
+ context 'for shared runners' do
before do
- project.update!(pending_delete: true)
+ project.update!(shared_runners_enabled: true)
end
- context 'for shared runners' do
- before do
- project.update!(shared_runners_enabled: true)
- end
+ it 'does not pick a build' do
+ expect(build_on(shared_runner)).to be_nil
+ end
+ end
+
+ context 'for project runner' do
+ subject(:build) { build_on(project_runner, runner_manager: project_runner_manager) }
+ let(:project_runner_manager) { nil }
+
+ context 'with no runner manager specified' do
it 'does not pick a build' do
- expect(build_on(shared_runner)).to be_nil
+ expect(build).to be_nil
+ expect(pending_job.reload).to be_failed
+ expect(pending_job.queuing_entry).to be_nil
+ expect(Ci::RunnerManagerBuild.all).to be_empty
end
end
- context 'for project runner' do
+ context 'with runner manager specified' do
+ let(:project_runner_manager) { create(:ci_runner_machine, runner: project_runner) }
+
it 'does not pick a build' do
- expect(build_on(project_runner)).to be_nil
+ expect(build).to be_nil
expect(pending_job.reload).to be_failed
expect(pending_job.queuing_entry).to be_nil
+ expect(Ci::RunnerManagerBuild.all).to be_empty
end
end
end
+ end
- context 'allow shared runners' do
- before do
- project.update!(shared_runners_enabled: true)
- pipeline.reload
- pending_job.reload
- pending_job.create_queuing_entry!
- end
+ context 'allow shared runners' do
+ before do
+ project.update!(shared_runners_enabled: true)
+ pipeline.reload
+ pending_job.reload
+ pending_job.create_queuing_entry!
+ end
- context 'when build owner has been blocked' do
- let(:user) { create(:user, :blocked) }
+ context 'when build owner has been blocked' do
+ let(:user) { create(:user, :blocked) }
- before do
- pending_job.update!(user: user)
- end
+ before do
+ pending_job.update!(user: user)
+ end
+ context 'with no runner manager specified' do
it 'does not pick the build and drops the build' do
expect(build_on(shared_runner)).to be_falsey
@@ -143,690 +172,701 @@ module Ci
end
end
- context 'for multiple builds' do
- let!(:project2) { create :project, shared_runners_enabled: true }
- let!(:pipeline2) { create :ci_pipeline, project: project2 }
- let!(:project3) { create :project, shared_runners_enabled: true }
- let!(:pipeline3) { create :ci_pipeline, project: project3 }
- let!(:build1_project1) { pending_job }
- let!(:build2_project1) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
- let!(:build3_project1) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
- let!(:build1_project2) { create(:ci_build, :pending, :queued, pipeline: pipeline2) }
- let!(:build2_project2) { create(:ci_build, :pending, :queued, pipeline: pipeline2) }
- let!(:build1_project3) { create(:ci_build, :pending, :queued, pipeline: pipeline3) }
+ context 'with runner manager specified' do
+ let(:runner_manager) { create(:ci_runner_machine, runner: runner) }
- it 'picks builds one-by-one' do
- expect(Ci::Build).to receive(:find).with(pending_job.id).and_call_original
+ it 'does not pick the build and does not create join record' do
+ expect(build_on(shared_runner, runner_manager: runner_manager)).to be_falsey
- expect(build_on(shared_runner)).to eq(build1_project1)
+ expect(Ci::RunnerManagerBuild.all).to be_empty
end
+ end
+ end
- context 'when using fair scheduling' do
- context 'when all builds are pending' do
- it 'prefers projects without builds first' do
- # it gets for one build from each of the projects
- expect(build_on(shared_runner)).to eq(build1_project1)
- expect(build_on(shared_runner)).to eq(build1_project2)
- expect(build_on(shared_runner)).to eq(build1_project3)
-
- # then it gets a second build from each of the projects
- expect(build_on(shared_runner)).to eq(build2_project1)
- expect(build_on(shared_runner)).to eq(build2_project2)
-
- # in the end the third build
- expect(build_on(shared_runner)).to eq(build3_project1)
- end
- end
+ context 'for multiple builds' do
+ let!(:project2) { create :project, shared_runners_enabled: true }
+ let!(:pipeline2) { create :ci_pipeline, project: project2 }
+ let!(:project3) { create :project, shared_runners_enabled: true }
+ let!(:pipeline3) { create :ci_pipeline, project: project3 }
+ let!(:build1_project1) { pending_job }
+ let!(:build2_project1) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
+ let!(:build3_project1) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
+ let!(:build1_project2) { create(:ci_build, :pending, :queued, pipeline: pipeline2) }
+ let!(:build2_project2) { create(:ci_build, :pending, :queued, pipeline: pipeline2) }
+ let!(:build1_project3) { create(:ci_build, :pending, :queued, pipeline: pipeline3) }
+
+ it 'picks builds one-by-one' do
+ expect(Ci::Build).to receive(:find).with(pending_job.id).and_call_original
+
+ expect(build_on(shared_runner)).to eq(build1_project1)
+ end
- context 'when some builds transition to success' do
- it 'equalises number of running builds' do
- # after finishing the first build for project 1, get a second build from the same project
- expect(build_on(shared_runner)).to eq(build1_project1)
- build1_project1.reload.success
- expect(build_on(shared_runner)).to eq(build2_project1)
-
- expect(build_on(shared_runner)).to eq(build1_project2)
- build1_project2.reload.success
- expect(build_on(shared_runner)).to eq(build2_project2)
- expect(build_on(shared_runner)).to eq(build1_project3)
- expect(build_on(shared_runner)).to eq(build3_project1)
- end
+ context 'when using fair scheduling' do
+ context 'when all builds are pending' do
+ it 'prefers projects without builds first' do
+ # it gets for one build from each of the projects
+ expect(build_on(shared_runner)).to eq(build1_project1)
+ expect(build_on(shared_runner)).to eq(build1_project2)
+ expect(build_on(shared_runner)).to eq(build1_project3)
+
+ # then it gets a second build from each of the projects
+ expect(build_on(shared_runner)).to eq(build2_project1)
+ expect(build_on(shared_runner)).to eq(build2_project2)
+
+ # in the end the third build
+ expect(build_on(shared_runner)).to eq(build3_project1)
end
end
- context 'when using DEFCON mode that disables fair scheduling' do
- before do
- stub_feature_flags(ci_queueing_disaster_recovery_disable_fair_scheduling: true)
+ context 'when some builds transition to success' do
+ it 'equalises number of running builds' do
+ # after finishing the first build for project 1, get a second build from the same project
+ expect(build_on(shared_runner)).to eq(build1_project1)
+ build1_project1.reload.success
+ expect(build_on(shared_runner)).to eq(build2_project1)
+
+ expect(build_on(shared_runner)).to eq(build1_project2)
+ build1_project2.reload.success
+ expect(build_on(shared_runner)).to eq(build2_project2)
+ expect(build_on(shared_runner)).to eq(build1_project3)
+ expect(build_on(shared_runner)).to eq(build3_project1)
end
+ end
+ end
- context 'when all builds are pending' do
- it 'returns builds in order of creation (FIFO)' do
- # it gets for one build from each of the projects
- expect(build_on(shared_runner)).to eq(build1_project1)
- expect(build_on(shared_runner)).to eq(build2_project1)
- expect(build_on(shared_runner)).to eq(build3_project1)
- expect(build_on(shared_runner)).to eq(build1_project2)
- expect(build_on(shared_runner)).to eq(build2_project2)
- expect(build_on(shared_runner)).to eq(build1_project3)
- end
+ context 'when using DEFCON mode that disables fair scheduling' do
+ before do
+ stub_feature_flags(ci_queueing_disaster_recovery_disable_fair_scheduling: true)
+ end
+
+ context 'when all builds are pending' do
+ it 'returns builds in order of creation (FIFO)' do
+ # it gets for one build from each of the projects
+ expect(build_on(shared_runner)).to eq(build1_project1)
+ expect(build_on(shared_runner)).to eq(build2_project1)
+ expect(build_on(shared_runner)).to eq(build3_project1)
+ expect(build_on(shared_runner)).to eq(build1_project2)
+ expect(build_on(shared_runner)).to eq(build2_project2)
+ expect(build_on(shared_runner)).to eq(build1_project3)
end
+ end
- context 'when some builds transition to success' do
- it 'returns builds in order of creation (FIFO)' do
- expect(build_on(shared_runner)).to eq(build1_project1)
- build1_project1.reload.success
- expect(build_on(shared_runner)).to eq(build2_project1)
-
- expect(build_on(shared_runner)).to eq(build3_project1)
- build2_project1.reload.success
- expect(build_on(shared_runner)).to eq(build1_project2)
- expect(build_on(shared_runner)).to eq(build2_project2)
- expect(build_on(shared_runner)).to eq(build1_project3)
- end
+ context 'when some builds transition to success' do
+ it 'returns builds in order of creation (FIFO)' do
+ expect(build_on(shared_runner)).to eq(build1_project1)
+ build1_project1.reload.success
+ expect(build_on(shared_runner)).to eq(build2_project1)
+
+ expect(build_on(shared_runner)).to eq(build3_project1)
+ build2_project1.reload.success
+ expect(build_on(shared_runner)).to eq(build1_project2)
+ expect(build_on(shared_runner)).to eq(build2_project2)
+ expect(build_on(shared_runner)).to eq(build1_project3)
end
end
end
+ end
- context 'shared runner' do
- let(:response) { described_class.new(shared_runner, nil).execute }
- let(:build) { response.build }
+ context 'shared runner' do
+ let(:response) { described_class.new(shared_runner, nil).execute }
+ let(:build) { response.build }
- it { expect(build).to be_kind_of(Build) }
- it { expect(build).to be_valid }
- it { expect(build).to be_running }
- it { expect(build.runner).to eq(shared_runner) }
- it { expect(Gitlab::Json.parse(response.build_json)['id']).to eq(build.id) }
- end
+ it { expect(build).to be_kind_of(Build) }
+ it { expect(build).to be_valid }
+ it { expect(build).to be_running }
+ it { expect(build.runner).to eq(shared_runner) }
+ it { expect(Gitlab::Json.parse(response.build_json)['id']).to eq(build.id) }
+ end
- context 'project runner' do
- let(:build) { build_on(project_runner) }
+ context 'project runner' do
+ let(:build) { build_on(project_runner) }
- it { expect(build).to be_kind_of(Build) }
- it { expect(build).to be_valid }
- it { expect(build).to be_running }
- it { expect(build.runner).to eq(project_runner) }
- end
+ it { expect(build).to be_kind_of(Build) }
+ it { expect(build).to be_valid }
+ it { expect(build).to be_running }
+ it { expect(build.runner).to eq(project_runner) }
end
+ end
- context 'disallow shared runners' do
- before do
- project.update!(shared_runners_enabled: false)
- end
+ context 'disallow shared runners' do
+ before do
+ project.update!(shared_runners_enabled: false)
+ end
- context 'shared runner' do
- let(:build) { build_on(shared_runner) }
+ context 'shared runner' do
+ let(:build) { build_on(shared_runner) }
- it { expect(build).to be_nil }
- end
+ it { expect(build).to be_nil }
+ end
- context 'project runner' do
- let(:build) { build_on(project_runner) }
+ context 'project runner' do
+ let(:build) { build_on(project_runner) }
- it { expect(build).to be_kind_of(Build) }
- it { expect(build).to be_valid }
- it { expect(build).to be_running }
- it { expect(build.runner).to eq(project_runner) }
- end
+ it { expect(build).to be_kind_of(Build) }
+ it { expect(build).to be_valid }
+ it { expect(build).to be_running }
+ it { expect(build.runner).to eq(project_runner) }
end
+ end
- context 'disallow when builds are disabled' do
- before do
- project.update!(shared_runners_enabled: true, group_runners_enabled: true)
- project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
+ context 'disallow when builds are disabled' do
+ before do
+ project.update!(shared_runners_enabled: true, group_runners_enabled: true)
+ project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
- pending_job.reload.create_queuing_entry!
- end
+ pending_job.reload.create_queuing_entry!
+ end
- context 'and uses shared runner' do
- let(:build) { build_on(shared_runner) }
+ context 'and uses shared runner' do
+ let(:build) { build_on(shared_runner) }
- it { expect(build).to be_nil }
- end
+ it { expect(build).to be_nil }
+ end
- context 'and uses group runner' do
- let(:build) { build_on(group_runner) }
+ context 'and uses group runner' do
+ let(:build) { build_on(group_runner) }
- it { expect(build).to be_nil }
- end
+ it { expect(build).to be_nil }
+ end
- context 'and uses project runner' do
- let(:build) { build_on(project_runner) }
+ context 'and uses project runner' do
+ let(:build) { build_on(project_runner) }
- 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
+ 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
- context 'allow group runners' do
- before do
- project.update!(group_runners_enabled: true)
- end
+ context 'allow group runners' do
+ before do
+ project.update!(group_runners_enabled: true)
+ end
- context 'for multiple builds' do
- let!(:project2) { create(:project, group_runners_enabled: true, group: group) }
- let!(:pipeline2) { create(:ci_pipeline, project: project2) }
- let!(:project3) { create(:project, group_runners_enabled: true, group: group) }
- let!(:pipeline3) { create(:ci_pipeline, project: project3) }
+ context 'for multiple builds' do
+ let!(:project2) { create(:project, group_runners_enabled: true, group: group) }
+ let!(:pipeline2) { create(:ci_pipeline, project: project2) }
+ let!(:project3) { create(:project, group_runners_enabled: true, group: group) }
+ let!(:pipeline3) { create(:ci_pipeline, project: project3) }
- let!(:build1_project1) { pending_job }
- let!(:build2_project1) { create(:ci_build, :queued, pipeline: pipeline) }
- let!(:build3_project1) { create(:ci_build, :queued, pipeline: pipeline) }
- let!(:build1_project2) { create(:ci_build, :queued, pipeline: pipeline2) }
- let!(:build2_project2) { create(:ci_build, :queued, pipeline: pipeline2) }
- let!(:build1_project3) { create(:ci_build, :queued, pipeline: pipeline3) }
+ let!(:build1_project1) { pending_job }
+ let!(:build2_project1) { create(:ci_build, :queued, pipeline: pipeline) }
+ let!(:build3_project1) { create(:ci_build, :queued, pipeline: pipeline) }
+ let!(:build1_project2) { create(:ci_build, :queued, pipeline: pipeline2) }
+ let!(:build2_project2) { create(:ci_build, :queued, pipeline: pipeline2) }
+ let!(:build1_project3) { create(:ci_build, :queued, pipeline: pipeline3) }
- # these shouldn't influence the scheduling
- let!(:unrelated_group) { create(:group) }
- let!(:unrelated_project) { create(:project, group_runners_enabled: true, group: unrelated_group) }
- let!(:unrelated_pipeline) { create(:ci_pipeline, project: unrelated_project) }
- let!(:build1_unrelated_project) { create(:ci_build, :pending, :queued, pipeline: unrelated_pipeline) }
- let!(:unrelated_group_runner) { create(:ci_runner, :group, groups: [unrelated_group]) }
+ # these shouldn't influence the scheduling
+ let!(:unrelated_group) { create(:group) }
+ let!(:unrelated_project) { create(:project, group_runners_enabled: true, group: unrelated_group) }
+ let!(:unrelated_pipeline) { create(:ci_pipeline, project: unrelated_project) }
+ let!(:build1_unrelated_project) { create(:ci_build, :pending, :queued, pipeline: unrelated_pipeline) }
+ let!(:unrelated_group_runner) { create(:ci_runner, :group, groups: [unrelated_group]) }
- it 'does not consider builds from other group runners' do
- queue = ::Ci::Queue::BuildQueueService.new(group_runner)
+ it 'does not consider builds from other group runners' do
+ queue = ::Ci::Queue::BuildQueueService.new(group_runner)
- expect(queue.builds_for_group_runner.size).to eq 6
- build_on(group_runner)
+ expect(queue.builds_for_group_runner.size).to eq 6
+ build_on(group_runner)
- expect(queue.builds_for_group_runner.size).to eq 5
- build_on(group_runner)
+ expect(queue.builds_for_group_runner.size).to eq 5
+ build_on(group_runner)
- expect(queue.builds_for_group_runner.size).to eq 4
- build_on(group_runner)
+ expect(queue.builds_for_group_runner.size).to eq 4
+ build_on(group_runner)
- expect(queue.builds_for_group_runner.size).to eq 3
- build_on(group_runner)
+ expect(queue.builds_for_group_runner.size).to eq 3
+ build_on(group_runner)
- expect(queue.builds_for_group_runner.size).to eq 2
- build_on(group_runner)
+ expect(queue.builds_for_group_runner.size).to eq 2
+ build_on(group_runner)
- expect(queue.builds_for_group_runner.size).to eq 1
- build_on(group_runner)
+ expect(queue.builds_for_group_runner.size).to eq 1
+ build_on(group_runner)
- expect(queue.builds_for_group_runner.size).to eq 0
- expect(build_on(group_runner)).to be_nil
- end
+ expect(queue.builds_for_group_runner.size).to eq 0
+ expect(build_on(group_runner)).to be_nil
end
+ end
- context 'group runner' do
- let(:build) { build_on(group_runner) }
+ context 'group runner' do
+ let(:build) { build_on(group_runner) }
- it { expect(build).to be_kind_of(Build) }
- it { expect(build).to be_valid }
- it { expect(build).to be_running }
- it { expect(build.runner).to eq(group_runner) }
- end
+ it { expect(build).to be_kind_of(Build) }
+ it { expect(build).to be_valid }
+ it { expect(build).to be_running }
+ it { expect(build.runner).to eq(group_runner) }
end
+ end
- context 'disallow group runners' do
- before do
- project.update!(group_runners_enabled: false)
+ context 'disallow group runners' do
+ before do
+ project.update!(group_runners_enabled: false)
- pending_job.reload.create_queuing_entry!
- end
+ pending_job.reload.create_queuing_entry!
+ end
- context 'group runner' do
- let(:build) { build_on(group_runner) }
+ context 'group runner' do
+ let(:build) { build_on(group_runner) }
- it { expect(build).to be_nil }
- end
+ it { expect(build).to be_nil }
end
+ end
- context 'when first build is stalled' do
- before do
- allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!).and_call_original
- allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!)
- .with(pending_job, anything).and_raise(ActiveRecord::StaleObjectError)
- end
+ context 'when first build is stalled' do
+ before do
+ allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!).and_call_original
+ allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!)
+ .with(pending_job, anything).and_raise(ActiveRecord::StaleObjectError)
+ end
- subject { described_class.new(project_runner, nil).execute }
+ subject { described_class.new(project_runner, nil).execute }
- context 'with multiple builds are in queue' do
- let!(:other_build) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
+ context 'with multiple builds are in queue' do
+ let!(:other_build) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
- before do
- allow_any_instance_of(::Ci::Queue::BuildQueueService)
- .to receive(:execute)
- .and_return(Ci::Build.where(id: [pending_job, other_build]).pluck(:id))
- end
+ before do
+ allow_any_instance_of(::Ci::Queue::BuildQueueService)
+ .to receive(:execute)
+ .and_return(Ci::Build.where(id: [pending_job, other_build]).pluck(:id))
+ end
- it "receives second build from the queue" do
- expect(subject).to be_valid
- expect(subject.build).to eq(other_build)
- end
+ it "receives second build from the queue" do
+ expect(subject).to be_valid
+ expect(subject.build).to eq(other_build)
end
+ end
- context 'when single build is in queue' do
- before do
- allow_any_instance_of(::Ci::Queue::BuildQueueService)
- .to receive(:execute)
- .and_return(Ci::Build.where(id: pending_job).pluck(:id))
- end
+ context 'when single build is in queue' do
+ before do
+ allow_any_instance_of(::Ci::Queue::BuildQueueService)
+ .to receive(:execute)
+ .and_return(Ci::Build.where(id: pending_job).pluck(:id))
+ end
- it "does not receive any valid result" do
- expect(subject).not_to be_valid
- end
+ it "does not receive any valid result" do
+ expect(subject).not_to be_valid
end
+ end
- context 'when there is no build in queue' do
- before do
- allow_any_instance_of(::Ci::Queue::BuildQueueService)
- .to receive(:execute)
- .and_return([])
- end
+ context 'when there is no build in queue' do
+ before do
+ allow_any_instance_of(::Ci::Queue::BuildQueueService)
+ .to receive(:execute)
+ .and_return([])
+ end
- it "does not receive builds but result is valid" do
- expect(subject).to be_valid
- expect(subject.build).to be_nil
- end
+ it "does not receive builds but result is valid" do
+ expect(subject).to be_valid
+ expect(subject.build).to be_nil
end
end
+ end
- context 'when access_level of runner is not_protected' do
- let!(:project_runner) { create(:ci_runner, :project, projects: [project]) }
+ context 'when access_level of runner is not_protected' do
+ let!(:project_runner) { create(:ci_runner, :project, projects: [project]) }
- context 'when a job is protected' do
- let!(:pending_job) { create(:ci_build, :pending, :queued, :protected, pipeline: pipeline) }
+ context 'when a job is protected' do
+ let!(:pending_job) { create(:ci_build, :pending, :queued, :protected, pipeline: pipeline) }
- it 'picks the job' do
- expect(build_on(project_runner)).to eq(pending_job)
- end
+ it 'picks the job' do
+ expect(build_on(project_runner)).to eq(pending_job)
end
+ end
- context 'when a job is unprotected' do
- let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
+ context 'when a job is unprotected' do
+ let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
- it 'picks the job' do
- expect(build_on(project_runner)).to eq(pending_job)
- end
+ it 'picks the job' do
+ expect(build_on(project_runner)).to eq(pending_job)
end
+ end
- context 'when protected attribute of a job is nil' do
- let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
+ context 'when protected attribute of a job is nil' do
+ let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
- before do
- pending_job.update_attribute(:protected, nil)
- end
+ before do
+ pending_job.update_attribute(:protected, nil)
+ end
- it 'picks the job' do
- expect(build_on(project_runner)).to eq(pending_job)
- end
+ it 'picks the job' do
+ expect(build_on(project_runner)).to eq(pending_job)
end
end
+ end
- context 'when access_level of runner is ref_protected' do
- let!(:project_runner) { create(:ci_runner, :project, :ref_protected, projects: [project]) }
+ context 'when access_level of runner is ref_protected' do
+ let!(:project_runner) { create(:ci_runner, :project, :ref_protected, projects: [project]) }
- context 'when a job is protected' do
- let!(:pending_job) { create(:ci_build, :pending, :queued, :protected, pipeline: pipeline) }
+ context 'when a job is protected' do
+ let!(:pending_job) { create(:ci_build, :pending, :queued, :protected, pipeline: pipeline) }
- it 'picks the job' do
- expect(build_on(project_runner)).to eq(pending_job)
- end
+ it 'picks the job' do
+ expect(build_on(project_runner)).to eq(pending_job)
end
+ end
- context 'when a job is unprotected' do
- let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
+ context 'when a job is unprotected' do
+ let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
- it 'does not pick the job' do
- expect(build_on(project_runner)).to be_nil
- end
+ it 'does not pick the job' do
+ expect(build_on(project_runner)).to be_nil
end
+ end
- context 'when protected attribute of a job is nil' do
- let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
+ context 'when protected attribute of a job is nil' do
+ let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
- before do
- pending_job.update_attribute(:protected, nil)
- end
+ before do
+ pending_job.update_attribute(:protected, nil)
+ end
- it 'does not pick the job' do
- expect(build_on(project_runner)).to be_nil
- end
+ it 'does not pick the job' do
+ expect(build_on(project_runner)).to be_nil
end
end
+ end
- context 'runner feature set is verified' do
- let(:options) { { artifacts: { reports: { junit: "junit.xml" } } } }
- let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, options: options) }
+ context 'runner feature set is verified' do
+ let(:options) { { artifacts: { reports: { junit: "junit.xml" } } } }
+ let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, options: options) }
- subject { build_on(project_runner, params: params) }
+ subject { build_on(project_runner, params: params) }
- context 'when feature is missing by runner' do
- let(:params) { {} }
+ context 'when feature is missing by runner' do
+ let(:params) { {} }
- it 'does not pick the build and drops the build' do
- expect(subject).to be_nil
- expect(pending_job.reload).to be_failed
- expect(pending_job).to be_runner_unsupported
- end
+ it 'does not pick the build and drops the build' do
+ expect(subject).to be_nil
+ expect(pending_job.reload).to be_failed
+ expect(pending_job).to be_runner_unsupported
end
+ end
- context 'when feature is supported by runner' do
- let(:params) do
- { info: { features: { upload_multiple_artifacts: true } } }
- end
+ context 'when feature is supported by runner' do
+ let(:params) do
+ { info: { features: { upload_multiple_artifacts: true } } }
+ end
- it 'does pick job' do
- expect(subject).not_to be_nil
- end
+ it 'does pick job' do
+ expect(subject).not_to be_nil
end
end
+ end
- context 'when "dependencies" keyword is specified' do
- let!(:pre_stage_job) do
- create(:ci_build, :success, :artifacts, pipeline: pipeline, name: 'test', stage_idx: 0)
- end
+ context 'when "dependencies" keyword is specified' do
+ let!(:pre_stage_job) do
+ create(:ci_build, :success, :artifacts, pipeline: pipeline, name: 'test', stage_idx: 0)
+ end
- let!(:pending_job) do
- create(:ci_build, :pending, :queued,
- pipeline: pipeline, stage_idx: 1,
- options: { script: ["bash"], dependencies: dependencies })
- end
+ let!(:pending_job) do
+ create(:ci_build, :pending, :queued,
+ pipeline: pipeline, stage_idx: 1,
+ options: { script: ["bash"], dependencies: dependencies })
+ end
- let(:dependencies) { %w[test] }
+ let(:dependencies) { %w[test] }
- subject { build_on(project_runner) }
+ subject { build_on(project_runner) }
- it 'picks a build with a dependency' do
- picked_build = build_on(project_runner)
+ it 'picks a build with a dependency' do
+ picked_build = build_on(project_runner)
- expect(picked_build).to be_present
+ expect(picked_build).to be_present
+ end
+
+ context 'when there are multiple dependencies with artifacts' do
+ let!(:pre_stage_job_second) do
+ create(:ci_build, :success, :artifacts, pipeline: pipeline, name: 'deploy', stage_idx: 0)
end
- context 'when there are multiple dependencies with artifacts' do
- let!(:pre_stage_job_second) do
- create(:ci_build, :success, :artifacts, pipeline: pipeline, name: 'deploy', stage_idx: 0)
- end
+ let(:dependencies) { %w[test deploy] }
- let(:dependencies) { %w[test deploy] }
+ it 'logs build artifacts size' do
+ build_on(project_runner)
- it 'logs build artifacts size' do
- build_on(project_runner)
+ artifacts_size = [pre_stage_job, pre_stage_job_second].sum do |job|
+ job.job_artifacts_archive.size
+ end
- artifacts_size = [pre_stage_job, pre_stage_job_second].sum do |job|
- job.job_artifacts_archive.size
- end
+ expect(artifacts_size).to eq 107464 * 2
+ expect(Gitlab::ApplicationContext.current).to include({
+ 'meta.artifacts_dependencies_size' => artifacts_size,
+ 'meta.artifacts_dependencies_count' => 2
+ })
+ end
+ end
- expect(artifacts_size).to eq 107464 * 2
- expect(Gitlab::ApplicationContext.current).to include({
- 'meta.artifacts_dependencies_size' => artifacts_size,
- 'meta.artifacts_dependencies_count' => 2
- })
- end
+ shared_examples 'not pick' do
+ it 'does not pick the build and drops the build' do
+ expect(subject).to be_nil
+ expect(pending_job.reload).to be_failed
+ expect(pending_job).to be_missing_dependency_failure
end
+ end
- shared_examples 'not pick' do
- it 'does not pick the build and drops the build' do
- expect(subject).to be_nil
- expect(pending_job.reload).to be_failed
- expect(pending_job).to be_missing_dependency_failure
+ shared_examples 'validation is active' do
+ context 'when depended job has not been completed yet' do
+ let!(:pre_stage_job) do
+ create(:ci_build, :pending, :queued, :manual, pipeline: pipeline, name: 'test', stage_idx: 0)
end
- end
- shared_examples 'validation is active' do
- context 'when depended job has not been completed yet' do
- let!(:pre_stage_job) do
- create(:ci_build, :pending, :queued, :manual, pipeline: pipeline, name: 'test', stage_idx: 0)
- end
+ it { is_expected.to eq(pending_job) }
+ end
- it { is_expected.to eq(pending_job) }
+ context 'when artifacts of depended job has been expired' do
+ let!(:pre_stage_job) do
+ create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0)
end
- context 'when artifacts of depended job has been expired' do
- let!(:pre_stage_job) do
- create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0)
+ context 'when the pipeline is locked' do
+ before do
+ pipeline.artifacts_locked!
end
- context 'when the pipeline is locked' do
- before do
- pipeline.artifacts_locked!
- end
+ it { is_expected.to eq(pending_job) }
+ end
- it { is_expected.to eq(pending_job) }
+ context 'when the pipeline is unlocked' do
+ before do
+ pipeline.unlocked!
end
- context 'when the pipeline is unlocked' do
- before do
- pipeline.unlocked!
- end
+ it_behaves_like 'not pick'
+ end
+ end
- it_behaves_like 'not pick'
- end
+ context 'when artifacts of depended job has been erased' do
+ let!(:pre_stage_job) do
+ create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago)
end
- context 'when artifacts of depended job has been erased' do
- let!(:pre_stage_job) do
- create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago)
- end
+ it_behaves_like 'not pick'
+ end
- it_behaves_like 'not pick'
+ context 'when job object is staled' do
+ let!(:pre_stage_job) do
+ create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0)
end
- context 'when job object is staled' do
- let!(:pre_stage_job) do
- create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0)
- end
-
- before do
- pipeline.unlocked!
+ before do
+ pipeline.unlocked!
- allow_next_instance_of(Ci::Build) do |build|
- expect(build).to receive(:drop!)
- .and_raise(ActiveRecord::StaleObjectError.new(pending_job, :drop!))
- end
+ allow_next_instance_of(Ci::Build) do |build|
+ expect(build).to receive(:drop!)
+ .and_raise(ActiveRecord::StaleObjectError.new(pending_job, :drop!))
end
+ end
- it 'does not drop nor pick' do
- expect(subject).to be_nil
- end
+ it 'does not drop nor pick' do
+ expect(subject).to be_nil
end
end
+ end
- shared_examples 'validation is not active' do
- context 'when depended job has not been completed yet' do
- let!(:pre_stage_job) do
- create(:ci_build, :pending, :queued, :manual, pipeline: pipeline, name: 'test', stage_idx: 0)
- end
-
- it { expect(subject).to eq(pending_job) }
+ shared_examples 'validation is not active' do
+ context 'when depended job has not been completed yet' do
+ let!(:pre_stage_job) do
+ create(:ci_build, :pending, :queued, :manual, pipeline: pipeline, name: 'test', stage_idx: 0)
end
- context 'when artifacts of depended job has been expired' do
- let!(:pre_stage_job) do
- create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0)
- end
+ it { expect(subject).to eq(pending_job) }
+ end
- it { expect(subject).to eq(pending_job) }
+ context 'when artifacts of depended job has been expired' do
+ let!(:pre_stage_job) do
+ create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0)
end
- context 'when artifacts of depended job has been erased' do
- let!(:pre_stage_job) do
- create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago)
- end
+ it { expect(subject).to eq(pending_job) }
+ end
- it { expect(subject).to eq(pending_job) }
+ context 'when artifacts of depended job has been erased' do
+ let!(:pre_stage_job) do
+ create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago)
end
- end
- it_behaves_like 'validation is active'
+ it { expect(subject).to eq(pending_job) }
+ end
end
- context 'when build is degenerated' do
- let!(:pending_job) { create(:ci_build, :pending, :queued, :degenerated, pipeline: pipeline) }
+ it_behaves_like 'validation is active'
+ end
- subject { build_on(project_runner) }
+ context 'when build is degenerated' do
+ let!(:pending_job) { create(:ci_build, :pending, :queued, :degenerated, pipeline: pipeline) }
- it 'does not pick the build and drops the build' do
- expect(subject).to be_nil
+ subject { build_on(project_runner) }
- pending_job.reload
- expect(pending_job).to be_failed
- expect(pending_job).to be_archived_failure
- end
+ it 'does not pick the build and drops the build' do
+ expect(subject).to be_nil
+
+ pending_job.reload
+ expect(pending_job).to be_failed
+ expect(pending_job).to be_archived_failure
end
+ end
- context 'when build has data integrity problem' do
- let!(:pending_job) do
- create(:ci_build, :pending, :queued, pipeline: pipeline)
- end
+ context 'when build has data integrity problem' do
+ let!(:pending_job) do
+ create(:ci_build, :pending, :queued, pipeline: pipeline)
+ end
- before do
- pending_job.update_columns(options: "string")
- end
+ before do
+ pending_job.update_columns(options: "string")
+ end
- subject { build_on(project_runner) }
+ subject { build_on(project_runner) }
- it 'does drop the build and logs both failures' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception)
- .with(anything, a_hash_including(build_id: pending_job.id))
- .twice
- .and_call_original
+ it 'does drop the build and logs both failures' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(anything, a_hash_including(build_id: pending_job.id))
+ .twice
+ .and_call_original
- expect(subject).to be_nil
+ expect(subject).to be_nil
- pending_job.reload
- expect(pending_job).to be_failed
- expect(pending_job).to be_data_integrity_failure
- end
+ pending_job.reload
+ expect(pending_job).to be_failed
+ expect(pending_job).to be_data_integrity_failure
end
+ end
- context 'when build fails to be run!' do
- let!(:pending_job) do
- create(:ci_build, :pending, :queued, pipeline: pipeline)
- end
+ context 'when build fails to be run!' do
+ let!(:pending_job) do
+ create(:ci_build, :pending, :queued, pipeline: pipeline)
+ end
- before do
- expect_any_instance_of(Ci::Build).to receive(:run!)
- .and_raise(RuntimeError, 'scheduler error')
- end
+ before do
+ expect_any_instance_of(Ci::Build).to receive(:run!)
+ .and_raise(RuntimeError, 'scheduler error')
+ end
- subject { build_on(project_runner) }
+ subject { build_on(project_runner) }
- it 'does drop the build and logs failure' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception)
- .with(anything, a_hash_including(build_id: pending_job.id))
- .once
- .and_call_original
+ it 'does drop the build and logs failure' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(anything, a_hash_including(build_id: pending_job.id))
+ .once
+ .and_call_original
- expect(subject).to be_nil
+ expect(subject).to be_nil
- pending_job.reload
- expect(pending_job).to be_failed
- expect(pending_job).to be_scheduler_failure
- end
+ pending_job.reload
+ expect(pending_job).to be_failed
+ expect(pending_job).to be_scheduler_failure
end
+ end
- context 'when an exception is raised during a persistent ref creation' do
- before do
- allow_any_instance_of(Ci::PersistentRef).to receive(:exist?) { false }
- allow_any_instance_of(Ci::PersistentRef).to receive(:create_ref) { raise ArgumentError }
- end
+ context 'when an exception is raised during a persistent ref creation' do
+ before do
+ allow_any_instance_of(Ci::PersistentRef).to receive(:exist?) { false }
+ allow_any_instance_of(Ci::PersistentRef).to receive(:create_ref) { raise ArgumentError }
+ end
- subject { build_on(project_runner) }
+ subject { build_on(project_runner) }
- it 'picks the build' do
- expect(subject).to eq(pending_job)
+ it 'picks the build' do
+ expect(subject).to eq(pending_job)
- pending_job.reload
- expect(pending_job).to be_running
- end
+ pending_job.reload
+ expect(pending_job).to be_running
end
+ end
- context 'when only some builds can be matched by runner' do
- let!(:project_runner) { create(:ci_runner, :project, projects: [project], tag_list: %w[matching]) }
- let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, tag_list: %w[matching]) }
+ context 'when only some builds can be matched by runner' do
+ let!(:project_runner) { create(:ci_runner, :project, projects: [project], tag_list: %w[matching]) }
+ let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, tag_list: %w[matching]) }
- before do
- # create additional matching and non-matching jobs
- create_list(:ci_build, 2, :pending, :queued, pipeline: pipeline, tag_list: %w[matching])
- create(:ci_build, :pending, :queued, pipeline: pipeline, tag_list: %w[non-matching])
- end
+ before do
+ # create additional matching and non-matching jobs
+ create_list(:ci_build, 2, :pending, :queued, pipeline: pipeline, tag_list: %w[matching])
+ create(:ci_build, :pending, :queued, pipeline: pipeline, tag_list: %w[non-matching])
+ end
- it 'observes queue size of only matching jobs' do
- # pending_job + 2 x matching ones
- expect(Gitlab::Ci::Queue::Metrics.queue_size_total).to receive(:observe)
- .with({ runner_type: project_runner.runner_type }, 3)
+ it 'observes queue size of only matching jobs' do
+ # pending_job + 2 x matching ones
+ expect(Gitlab::Ci::Queue::Metrics.queue_size_total).to receive(:observe)
+ .with({ runner_type: project_runner.runner_type }, 3)
- expect(build_on(project_runner)).to eq(pending_job)
- end
+ expect(build_on(project_runner)).to eq(pending_job)
+ end
- it 'observes queue processing time by the runner type' do
- expect(Gitlab::Ci::Queue::Metrics.queue_iteration_duration_seconds)
- .to receive(:observe)
- .with({ runner_type: project_runner.runner_type }, anything)
+ it 'observes queue processing time by the runner type' do
+ expect(Gitlab::Ci::Queue::Metrics.queue_iteration_duration_seconds)
+ .to receive(:observe)
+ .with({ runner_type: project_runner.runner_type }, anything)
- expect(Gitlab::Ci::Queue::Metrics.queue_retrieval_duration_seconds)
- .to receive(:observe)
- .with({ runner_type: project_runner.runner_type }, anything)
+ expect(Gitlab::Ci::Queue::Metrics.queue_retrieval_duration_seconds)
+ .to receive(:observe)
+ .with({ runner_type: project_runner.runner_type }, anything)
- expect(build_on(project_runner)).to eq(pending_job)
- end
+ expect(build_on(project_runner)).to eq(pending_job)
end
+ end
- context 'when ci_register_job_temporary_lock is enabled' do
- before do
- stub_feature_flags(ci_register_job_temporary_lock: true)
+ context 'when ci_register_job_temporary_lock is enabled' do
+ before do
+ stub_feature_flags(ci_register_job_temporary_lock: true)
+
+ allow(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
+ end
- allow(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
+ context 'when a build is temporarily locked' do
+ let(:service) { described_class.new(project_runner, nil) }
+
+ before do
+ service.send(:acquire_temporary_lock, pending_job.id)
end
- context 'when a build is temporarily locked' do
- let(:service) { described_class.new(project_runner, nil) }
+ it 'skips this build and marks queue as invalid' do
+ expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
+ .with(operation: :queue_iteration)
+ expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
+ .with(operation: :build_temporary_locked)
- before do
- service.send(:acquire_temporary_lock, pending_job.id)
- end
+ expect(service.execute).not_to be_valid
+ end
- it 'skips this build and marks queue as invalid' do
+ context 'when there is another build in queue' do
+ let!(:next_pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
+
+ it 'skips this build and picks another build' do
expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
- .with(operation: :queue_iteration)
+ .with(operation: :queue_iteration).twice
expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
.with(operation: :build_temporary_locked)
- expect(service.execute).not_to be_valid
- end
-
- context 'when there is another build in queue' do
- let!(:next_pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) }
-
- it 'skips this build and picks another build' do
- expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
- .with(operation: :queue_iteration).twice
- expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
- .with(operation: :build_temporary_locked)
+ result = service.execute
- result = service.execute
-
- expect(result.build).to eq(next_pending_job)
- expect(result).to be_valid
- end
+ expect(result.build).to eq(next_pending_job)
+ expect(result).to be_valid
end
end
end
end
+ end
- context 'when using pending builds table' do
- include_examples 'handles runner assignment'
+ context 'when using pending builds table' do
+ let!(:runner) { create(:ci_runner, :project, projects: [project], tag_list: %w[conflict]) }
- context 'when a conflicting data is stored in denormalized table' do
- let!(:runner) { create(:ci_runner, :project, projects: [project], tag_list: %w[conflict]) }
- let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, tag_list: %w[conflict]) }
+ include_examples 'handles runner assignment'
- before do
- pending_job.update_column(:status, :running)
- end
+ context 'when a conflicting data is stored in denormalized table' do
+ let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline, tag_list: %w[conflict]) }
- it 'removes queuing entry upon build assignment attempt' do
- expect(pending_job.reload).to be_running
- expect(pending_job.queuing_entry).to be_present
+ before do
+ pending_job.update_column(:status, :running)
+ end
- expect(execute).not_to be_valid
- expect(pending_job.reload.queuing_entry).not_to be_present
- end
+ it 'removes queuing entry upon build assignment attempt' do
+ expect(pending_job.reload).to be_running
+ expect(pending_job.queuing_entry).to be_present
+
+ expect(execute).not_to be_valid
+ expect(pending_job.reload.queuing_entry).not_to be_present
end
end
end
@@ -997,8 +1037,8 @@ module Ci
end
end
- def build_on(runner, runner_machine: nil, params: {})
- described_class.new(runner, runner_machine).execute(params).build
+ def build_on(runner, runner_manager: nil, params: {})
+ described_class.new(runner, runner_manager).execute(params).build
end
end
end
diff --git a/spec/services/ci/reset_skipped_jobs_service_spec.rb b/spec/services/ci/reset_skipped_jobs_service_spec.rb
index 712a21e665b..ba6a4a4e822 100644
--- a/spec/services/ci/reset_skipped_jobs_service_spec.rb
+++ b/spec/services/ci/reset_skipped_jobs_service_spec.rb
@@ -6,13 +6,22 @@ RSpec.describe Ci::ResetSkippedJobsService, :sidekiq_inline, feature_category: :
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:user) { project.first_owner }
+ let(:pipeline) do
+ Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push).payload
+ end
+
+ let(:a1) { find_job('a1') }
+ let(:a2) { find_job('a2') }
+ let(:b1) { find_job('b1') }
+ let(:input_processables) { a1 } # This is the input used when running service.execute()
+
before_all do
project.repository.create_file(user, 'init', 'init', message: 'init', branch_name: 'master')
end
subject(:service) { described_class.new(project, user) }
- context 'with a stage-dag mixed pipeline' do
+ shared_examples 'with a stage-dag mixed pipeline' do
let(:config) do
<<-YAML
stages: [a, b, c]
@@ -52,13 +61,6 @@ RSpec.describe Ci::ResetSkippedJobsService, :sidekiq_inline, feature_category: :
YAML
end
- let(:pipeline) do
- Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push).payload
- end
-
- let(:a1) { find_job('a1') }
- let(:b1) { find_job('b1') }
-
before do
stub_ci_pipeline_yaml_file(config)
check_jobs_statuses(
@@ -107,7 +109,7 @@ RSpec.describe Ci::ResetSkippedJobsService, :sidekiq_inline, feature_category: :
end
it 'marks subsequent skipped jobs as processable' do
- execute_after_requeue_service(a1)
+ service.execute(input_processables)
check_jobs_statuses(
a1: 'pending',
@@ -135,7 +137,7 @@ RSpec.describe Ci::ResetSkippedJobsService, :sidekiq_inline, feature_category: :
{ 'name' => 'c2', 'status' => 'skipped', 'user_id' => user.id, 'needs' => [] }
)
- execute_after_requeue_service(a1)
+ service.execute(input_processables)
expect(jobs_name_status_owner_needs).to contain_exactly(
{ 'name' => 'a1', 'status' => 'pending', 'user_id' => user.id, 'needs' => [] },
@@ -150,7 +152,7 @@ RSpec.describe Ci::ResetSkippedJobsService, :sidekiq_inline, feature_category: :
end
end
- context 'with stage-dag mixed pipeline with some same-stage needs' do
+ shared_examples 'with stage-dag mixed pipeline with some same-stage needs' do
let(:config) do
<<-YAML
stages: [a, b, c]
@@ -184,12 +186,6 @@ RSpec.describe Ci::ResetSkippedJobsService, :sidekiq_inline, feature_category: :
YAML
end
- let(:pipeline) do
- Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push).payload
- end
-
- let(:a1) { find_job('a1') }
-
before do
stub_ci_pipeline_yaml_file(config)
check_jobs_statuses(
@@ -224,7 +220,7 @@ RSpec.describe Ci::ResetSkippedJobsService, :sidekiq_inline, feature_category: :
end
it 'marks subsequent skipped jobs as processable' do
- execute_after_requeue_service(a1)
+ service.execute(input_processables)
check_jobs_statuses(
a1: 'pending',
@@ -237,61 +233,465 @@ RSpec.describe Ci::ResetSkippedJobsService, :sidekiq_inline, feature_category: :
end
end
- context 'with same-stage needs' do
+ shared_examples 'with same-stage needs' do
let(:config) do
<<-YAML
- a:
+ a1:
script: exit $(($RANDOM % 2))
- b:
+ b1:
script: exit 0
- needs: [a]
+ needs: [a1]
- c:
+ c1:
script: exit 0
- needs: [b]
+ needs: [b1]
YAML
end
- let(:pipeline) do
- Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push).payload
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ check_jobs_statuses(
+ a1: 'pending',
+ b1: 'created',
+ c1: 'created'
+ )
+
+ a1.drop!
+ check_jobs_statuses(
+ a1: 'failed',
+ b1: 'skipped',
+ c1: 'skipped'
+ )
+
+ new_a1 = Ci::RetryJobService.new(project, user).clone!(a1)
+ new_a1.enqueue!
+ check_jobs_statuses(
+ a1: 'pending',
+ b1: 'skipped',
+ c1: 'skipped'
+ )
end
- let(:a) { find_job('a') }
+ it 'marks subsequent skipped jobs as processable' do
+ service.execute(input_processables)
+
+ check_jobs_statuses(
+ a1: 'pending',
+ b1: 'created',
+ c1: 'created'
+ )
+ end
+ end
+
+ context 'with same-stage needs where the parent jobs do not share the same descendants' do
+ let(:config) do
+ <<-YAML
+ a1:
+ script: exit $(($RANDOM % 2))
+
+ a2:
+ script: exit $(($RANDOM % 2))
+
+ b1:
+ script: exit 0
+ needs: [a1]
+
+ b2:
+ script: exit 0
+ needs: [a2]
+
+ c1:
+ script: exit 0
+ needs: [b1]
+
+ c2:
+ script: exit 0
+ needs: [b2]
+ YAML
+ end
before do
stub_ci_pipeline_yaml_file(config)
check_jobs_statuses(
- a: 'pending',
- b: 'created',
- c: 'created'
+ a1: 'pending',
+ a2: 'pending',
+ b1: 'created',
+ b2: 'created',
+ c1: 'created',
+ c2: 'created'
)
- a.drop!
+ a1.drop!
+ a2.drop!
+
check_jobs_statuses(
- a: 'failed',
- b: 'skipped',
- c: 'skipped'
+ a1: 'failed',
+ a2: 'failed',
+ b1: 'skipped',
+ b2: 'skipped',
+ c1: 'skipped',
+ c2: 'skipped'
+ )
+
+ new_a1 = Ci::RetryJobService.new(project, user).clone!(a1)
+ new_a1.enqueue!
+
+ check_jobs_statuses(
+ a1: 'pending',
+ a2: 'failed',
+ b1: 'skipped',
+ b2: 'skipped',
+ c1: 'skipped',
+ c2: 'skipped'
)
- new_a = Ci::RetryJobService.new(project, user).clone!(a)
- new_a.enqueue!
+ new_a2 = Ci::RetryJobService.new(project, user).clone!(a2)
+ new_a2.enqueue!
+
check_jobs_statuses(
- a: 'pending',
- b: 'skipped',
- c: 'skipped'
+ a1: 'pending',
+ a2: 'pending',
+ b1: 'skipped',
+ b2: 'skipped',
+ c1: 'skipped',
+ c2: 'skipped'
)
end
+ # This demonstrates that when only a1 is inputted, only the *1 subsequent jobs are reset.
+ # This is in contrast to the following example when both a1 and a2 are inputted.
it 'marks subsequent skipped jobs as processable' do
- execute_after_requeue_service(a)
+ service.execute(input_processables)
check_jobs_statuses(
- a: 'pending',
- b: 'created',
- c: 'created'
+ a1: 'pending',
+ a2: 'pending',
+ b1: 'created',
+ b2: 'skipped',
+ c1: 'created',
+ c2: 'skipped'
)
end
+
+ context 'when multiple processables are inputted' do
+ # When both a1 and a2 are inputted, all subsequent jobs are reset.
+ it 'marks subsequent skipped jobs as processable' do
+ input_processables = [a1, a2]
+ service.execute(input_processables)
+
+ check_jobs_statuses(
+ a1: 'pending',
+ a2: 'pending',
+ b1: 'created',
+ b2: 'created',
+ c1: 'created',
+ c2: 'created'
+ )
+ end
+ end
+ end
+
+ context 'when a single processable is inputted' do
+ it_behaves_like 'with a stage-dag mixed pipeline'
+ it_behaves_like 'with stage-dag mixed pipeline with some same-stage needs'
+ it_behaves_like 'with same-stage needs'
+ end
+
+ context 'when multiple processables are inputted' do
+ let(:input_processables) { [a1, b1] }
+
+ it_behaves_like 'with a stage-dag mixed pipeline'
+ it_behaves_like 'with stage-dag mixed pipeline with some same-stage needs'
+ it_behaves_like 'with same-stage needs'
+ end
+
+ context 'when FF is `ci_support_reset_skipped_jobs_for_multiple_jobs` disabled' do
+ before do
+ stub_feature_flags(ci_support_reset_skipped_jobs_for_multiple_jobs: false)
+ end
+
+ context 'with a stage-dag mixed pipeline' do
+ let(:config) do
+ <<-YAML
+ stages: [a, b, c]
+
+ a1:
+ stage: a
+ script: exit $(($RANDOM % 2))
+
+ a2:
+ stage: a
+ script: exit 0
+ needs: [a1]
+
+ a3:
+ stage: a
+ script: exit 0
+ needs: [a2]
+
+ b1:
+ stage: b
+ script: exit 0
+ needs: []
+
+ b2:
+ stage: b
+ script: exit 0
+ needs: [a2]
+
+ c1:
+ stage: c
+ script: exit 0
+ needs: [b2]
+
+ c2:
+ stage: c
+ script: exit 0
+ YAML
+ end
+
+ let(:pipeline) do
+ Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push).payload
+ end
+
+ let(:a1) { find_job('a1') }
+ let(:b1) { find_job('b1') }
+
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ check_jobs_statuses(
+ a1: 'pending',
+ a2: 'created',
+ a3: 'created',
+ b1: 'pending',
+ b2: 'created',
+ c1: 'created',
+ c2: 'created'
+ )
+
+ b1.success!
+ check_jobs_statuses(
+ a1: 'pending',
+ a2: 'created',
+ a3: 'created',
+ b1: 'success',
+ b2: 'created',
+ c1: 'created',
+ c2: 'created'
+ )
+
+ a1.drop!
+ check_jobs_statuses(
+ a1: 'failed',
+ a2: 'skipped',
+ a3: 'skipped',
+ b1: 'success',
+ b2: 'skipped',
+ c1: 'skipped',
+ c2: 'skipped'
+ )
+
+ new_a1 = Ci::RetryJobService.new(project, user).clone!(a1)
+ new_a1.enqueue!
+ check_jobs_statuses(
+ a1: 'pending',
+ a2: 'skipped',
+ a3: 'skipped',
+ b1: 'success',
+ b2: 'skipped',
+ c1: 'skipped',
+ c2: 'skipped'
+ )
+ end
+
+ it 'marks subsequent skipped jobs as processable' do
+ execute_after_requeue_service(a1)
+
+ check_jobs_statuses(
+ a1: 'pending',
+ a2: 'created',
+ a3: 'created',
+ b1: 'success',
+ b2: 'created',
+ c1: 'created',
+ c2: 'created'
+ )
+ end
+
+ context 'when executed by a different user than the original owner' do
+ let(:retryer) { create(:user).tap { |u| project.add_maintainer(u) } }
+ let(:service) { described_class.new(project, retryer) }
+
+ it 'reassigns jobs with updated statuses to the retryer' do
+ expect(jobs_name_status_owner_needs).to contain_exactly(
+ { 'name' => 'a1', 'status' => 'pending', 'user_id' => user.id, 'needs' => [] },
+ { 'name' => 'a2', 'status' => 'skipped', 'user_id' => user.id, 'needs' => ['a1'] },
+ { 'name' => 'a3', 'status' => 'skipped', 'user_id' => user.id, 'needs' => ['a2'] },
+ { 'name' => 'b1', 'status' => 'success', 'user_id' => user.id, 'needs' => [] },
+ { 'name' => 'b2', 'status' => 'skipped', 'user_id' => user.id, 'needs' => ['a2'] },
+ { 'name' => 'c1', 'status' => 'skipped', 'user_id' => user.id, 'needs' => ['b2'] },
+ { 'name' => 'c2', 'status' => 'skipped', 'user_id' => user.id, 'needs' => [] }
+ )
+
+ execute_after_requeue_service(a1)
+
+ expect(jobs_name_status_owner_needs).to contain_exactly(
+ { 'name' => 'a1', 'status' => 'pending', 'user_id' => user.id, 'needs' => [] },
+ { 'name' => 'a2', 'status' => 'created', 'user_id' => retryer.id, 'needs' => ['a1'] },
+ { 'name' => 'a3', 'status' => 'created', 'user_id' => retryer.id, 'needs' => ['a2'] },
+ { 'name' => 'b1', 'status' => 'success', 'user_id' => user.id, 'needs' => [] },
+ { 'name' => 'b2', 'status' => 'created', 'user_id' => retryer.id, 'needs' => ['a2'] },
+ { 'name' => 'c1', 'status' => 'created', 'user_id' => retryer.id, 'needs' => ['b2'] },
+ { 'name' => 'c2', 'status' => 'created', 'user_id' => retryer.id, 'needs' => [] }
+ )
+ end
+ end
+ end
+
+ context 'with stage-dag mixed pipeline with some same-stage needs' do
+ let(:config) do
+ <<-YAML
+ stages: [a, b, c]
+
+ a1:
+ stage: a
+ script: exit $(($RANDOM % 2))
+
+ a2:
+ stage: a
+ script: exit 0
+ needs: [a1]
+
+ b1:
+ stage: b
+ script: exit 0
+ needs: [b2]
+
+ b2:
+ stage: b
+ script: exit 0
+
+ c1:
+ stage: c
+ script: exit 0
+ needs: [b2]
+
+ c2:
+ stage: c
+ script: exit 0
+ YAML
+ end
+
+ let(:pipeline) do
+ Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push).payload
+ end
+
+ let(:a1) { find_job('a1') }
+
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ check_jobs_statuses(
+ a1: 'pending',
+ a2: 'created',
+ b1: 'created',
+ b2: 'created',
+ c1: 'created',
+ c2: 'created'
+ )
+
+ a1.drop!
+ check_jobs_statuses(
+ a1: 'failed',
+ a2: 'skipped',
+ b1: 'skipped',
+ b2: 'skipped',
+ c1: 'skipped',
+ c2: 'skipped'
+ )
+
+ new_a1 = Ci::RetryJobService.new(project, user).clone!(a1)
+ new_a1.enqueue!
+ check_jobs_statuses(
+ a1: 'pending',
+ a2: 'skipped',
+ b1: 'skipped',
+ b2: 'skipped',
+ c1: 'skipped',
+ c2: 'skipped'
+ )
+ end
+
+ it 'marks subsequent skipped jobs as processable' do
+ execute_after_requeue_service(a1)
+
+ check_jobs_statuses(
+ a1: 'pending',
+ a2: 'created',
+ b1: 'created',
+ b2: 'created',
+ c1: 'created',
+ c2: 'created'
+ )
+ end
+ end
+
+ context 'with same-stage needs' do
+ let(:config) do
+ <<-YAML
+ a:
+ script: exit $(($RANDOM % 2))
+
+ b:
+ script: exit 0
+ needs: [a]
+
+ c:
+ script: exit 0
+ needs: [b]
+ YAML
+ end
+
+ let(:pipeline) do
+ Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push).payload
+ end
+
+ let(:a) { find_job('a') }
+
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ check_jobs_statuses(
+ a: 'pending',
+ b: 'created',
+ c: 'created'
+ )
+
+ a.drop!
+ check_jobs_statuses(
+ a: 'failed',
+ b: 'skipped',
+ c: 'skipped'
+ )
+
+ new_a = Ci::RetryJobService.new(project, user).clone!(a)
+ new_a.enqueue!
+ check_jobs_statuses(
+ a: 'pending',
+ b: 'skipped',
+ c: 'skipped'
+ )
+ end
+
+ it 'marks subsequent skipped jobs as processable' do
+ execute_after_requeue_service(a)
+
+ check_jobs_statuses(
+ a: 'pending',
+ b: 'created',
+ c: 'created'
+ )
+ end
+ end
end
private
@@ -314,6 +714,7 @@ RSpec.describe Ci::ResetSkippedJobsService, :sidekiq_inline, feature_category: :
end
end
+ # Remove this method when FF is `ci_support_reset_skipped_jobs_for_multiple_jobs` is removed
def execute_after_requeue_service(processable)
service.execute(processable)
end
diff --git a/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb b/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb
index 3d1abe290bc..ea15e3ea2c0 100644
--- a/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb
+++ b/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ResourceGroups::AssignResourceFromResourceGroupService do
+RSpec.describe Ci::ResourceGroups::AssignResourceFromResourceGroupService, feature_category: :continuous_integration do
include ConcurrentHelpers
let_it_be(:project) { create(:project) }
diff --git a/spec/services/ci/retry_job_service_spec.rb b/spec/services/ci/retry_job_service_spec.rb
index fed66bc535d..f15f4a16d4f 100644
--- a/spec/services/ci/retry_job_service_spec.rb
+++ b/spec/services/ci/retry_job_service_spec.rb
@@ -51,11 +51,13 @@ RSpec.describe Ci::RetryJobService, feature_category: :continuous_integration do
let_it_be(:another_pipeline) { create(:ci_empty_pipeline, project: project) }
let_it_be(:job_to_clone) do
- create(:ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags,
- :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group,
- description: 'my-job', ci_stage: stage,
- pipeline: pipeline, auto_canceled_by: another_pipeline,
- scheduled_at: 10.seconds.since)
+ create(
+ :ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags,
+ :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group,
+ description: 'my-job', ci_stage: stage,
+ pipeline: pipeline, auto_canceled_by: another_pipeline,
+ scheduled_at: 10.seconds.since
+ )
end
before do
@@ -236,8 +238,7 @@ RSpec.describe Ci::RetryJobService, feature_category: :continuous_integration do
context 'when a build with a deployment is retried' do
let!(:job) do
- create(:ci_build, :with_deployment, :deploy_to_production,
- pipeline: pipeline, ci_stage: stage)
+ create(:ci_build, :with_deployment, :deploy_to_production, pipeline: pipeline, ci_stage: stage)
end
it 'creates a new deployment' do
diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb
index 07518c35fab..fc2c66e7f73 100644
--- a/spec/services/ci/retry_pipeline_service_spec.rb
+++ b/spec/services/ci/retry_pipeline_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::RetryPipelineService, '#execute' do
+RSpec.describe Ci::RetryPipelineService, '#execute', feature_category: :continuous_integration do
include ProjectForksHelper
let_it_be_with_refind(:user) { create(:user) }
@@ -19,8 +19,7 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
before do
project.add_developer(user)
- create(:protected_branch, :developers_can_merge,
- name: pipeline.ref, project: project)
+ create(:protected_branch, :developers_can_merge, name: pipeline.ref, project: project)
end
context 'when there are already retried jobs present' do
@@ -408,8 +407,7 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
context 'when user is not allowed to trigger manual action' do
before do
project.add_developer(user)
- create(:protected_branch, :maintainers_can_push,
- name: pipeline.ref, project: project)
+ create(:protected_branch, :maintainers_can_push, name: pipeline.ref, project: project)
end
context 'when there is a failed manual action present' do
@@ -490,11 +488,15 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
end
def create_processable(type, name, status, stage, **opts)
- create(type, name: name,
- status: status,
- ci_stage: stage,
- stage_idx: stage.position,
- pipeline: pipeline, **opts) do |_job|
+ create(
+ type,
+ name: name,
+ status: status,
+ ci_stage: stage,
+ stage_idx: stage.position,
+ pipeline: pipeline,
+ **opts
+ ) do |_job|
::Ci::ProcessPipelineService.new(pipeline).execute
end
end
diff --git a/spec/services/ci/run_scheduled_build_service_spec.rb b/spec/services/ci/run_scheduled_build_service_spec.rb
index 27d25e88944..33f9efcb89f 100644
--- a/spec/services/ci/run_scheduled_build_service_spec.rb
+++ b/spec/services/ci/run_scheduled_build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::RunScheduledBuildService do
+RSpec.describe Ci::RunScheduledBuildService, feature_category: :continuous_integration do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
@@ -13,8 +13,7 @@ RSpec.describe Ci::RunScheduledBuildService do
before do
project.add_developer(user)
- create(:protected_branch, :developers_can_merge,
- name: pipeline.ref, project: project)
+ create(:protected_branch, :developers_can_merge, name: pipeline.ref, project: project)
end
context 'when build is scheduled' do
diff --git a/spec/services/ci/runners/create_runner_service_spec.rb b/spec/services/ci/runners/create_runner_service_spec.rb
index 673bf3ef90e..db337b0b005 100644
--- a/spec/services/ci/runners/create_runner_service_spec.rb
+++ b/spec/services/ci/runners/create_runner_service_spec.rb
@@ -3,24 +3,20 @@
require 'spec_helper'
RSpec.describe ::Ci::Runners::CreateRunnerService, "#execute", feature_category: :runner_fleet do
- subject(:execute) { described_class.new(user: current_user, type: type, params: params).execute }
+ subject(:execute) { described_class.new(user: current_user, params: params).execute }
let(:runner) { execute.payload[:runner] }
let_it_be(:admin) { create(:admin) }
let_it_be(:non_admin_user) { create(:user) }
let_it_be(:anonymous) { nil }
+ let_it_be(:group_owner) { create(:user) }
- shared_context 'when admin user' do
- let(:current_user) { admin }
-
- before do
- allow(current_user).to receive(:can?).with(:create_instance_runners).and_return true
- end
- end
+ let_it_be(:group) { create(:group) }
shared_examples 'it can create a runner' do
- it 'creates a runner of the specified type' do
+ it 'creates a runner of the specified type', :aggregate_failures do
+ is_expected.to be_success
expect(runner.runner_type).to eq expected_type
end
@@ -42,7 +38,7 @@ RSpec.describe ::Ci::Runners::CreateRunnerService, "#execute", feature_category:
expect(runner.active).to be true
expect(runner.creator).to be current_user
expect(runner.authenticated_user_registration_type?).to be_truthy
- expect(runner.runner_type).to eq 'instance_type'
+ expect(runner.runner_type).to eq expected_type
end
end
@@ -81,7 +77,50 @@ RSpec.describe ::Ci::Runners::CreateRunnerService, "#execute", feature_category:
expect(runner.maximum_timeout).to eq args[:maximum_timeout]
expect(runner.authenticated_user_registration_type?).to be_truthy
- expect(runner.runner_type).to eq 'instance_type'
+ expect(runner.runner_type).to eq expected_type
+ end
+
+ context 'with a nil paused value' do
+ let(:args) do
+ {
+ paused: nil,
+ description: 'some description',
+ maintenance_note: 'a note',
+ tag_list: %w[tag1 tag2],
+ access_level: 'ref_protected',
+ locked: true,
+ maximum_timeout: 600,
+ run_untagged: false
+ }
+ end
+
+ it { is_expected.to be_success }
+
+ it 'creates runner with active set to true' do
+ expect(runner).to be_an_instance_of(::Ci::Runner)
+ expect(runner.active).to eq true
+ end
+ end
+
+ context 'with no paused value given' do
+ let(:args) do
+ {
+ description: 'some description',
+ maintenance_note: 'a note',
+ tag_list: %w[tag1 tag2],
+ access_level: 'ref_protected',
+ locked: true,
+ maximum_timeout: 600,
+ run_untagged: false
+ }
+ end
+
+ it { is_expected.to be_success }
+
+ it 'creates runner with active set to true' do
+ expect(runner).to be_an_instance_of(::Ci::Runner)
+ expect(runner.active).to eq true
+ end
end
end
end
@@ -95,7 +134,6 @@ RSpec.describe ::Ci::Runners::CreateRunnerService, "#execute", feature_category:
end
shared_examples 'it can return an error' do
- let(:group) { create(:group) }
let(:runner_double) { Ci::Runner.new }
context 'when the runner fails to save' do
@@ -111,25 +149,148 @@ RSpec.describe ::Ci::Runners::CreateRunnerService, "#execute", feature_category:
end
end
- context 'with type param set to nil' do
+ context 'with :runner_type param set to instance_type' do
let(:expected_type) { 'instance_type' }
- let(:type) { nil }
- let(:params) { {} }
+ let(:params) { { runner_type: 'instance_type' } }
- it_behaves_like 'it cannot create a runner' do
+ context 'when anonymous user' do
let(:current_user) { anonymous }
+
+ it_behaves_like 'it cannot create a runner'
end
- it_behaves_like 'it cannot create a runner' do
+ context 'when non-admin user' do
let(:current_user) { non_admin_user }
+
+ it_behaves_like 'it cannot create a runner'
end
- it_behaves_like 'it can create a runner' do
- include_context 'when admin user'
+ context 'when admin user' do
+ let(:current_user) { admin }
+
+ it_behaves_like 'it cannot create a runner'
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it_behaves_like 'it can create a runner'
+ it_behaves_like 'it can return an error'
+
+ context 'with unexpected scope param specified' do
+ let(:params) { { runner_type: 'instance_type', scope: group } }
+
+ it_behaves_like 'it cannot create a runner'
+ end
+
+ context 'when model validation fails' do
+ let(:params) { { runner_type: 'instance_type', run_untagged: false, tag_list: [] } }
+
+ it_behaves_like 'it cannot create a runner'
+
+ it 'returns error message and reason', :aggregate_failures do
+ expect(execute.reason).to eq(:save_error)
+ expect(execute.message).to contain_exactly(a_string_including('Tags list can not be empty'))
+ end
+ end
+ end
+ end
+ end
+
+ context 'with :runner_type param set to group_type' do
+ let(:expected_type) { 'group_type' }
+ let(:params) { { runner_type: 'group_type', scope: group } }
+
+ before do
+ group.add_developer(non_admin_user)
+ group.add_owner(group_owner)
+ end
+
+ context 'when anonymous user' do
+ let(:current_user) { anonymous }
+
+ it_behaves_like 'it cannot create a runner'
+ end
+
+ context 'when non-admin user' do
+ let(:current_user) { non_admin_user }
+
+ it_behaves_like 'it cannot create a runner'
end
- it_behaves_like 'it can return an error' do
- include_context 'when admin user'
+ context 'when group owner' do
+ let(:current_user) { group_owner }
+
+ it_behaves_like 'it can create a runner'
+
+ context 'with missing scope param' do
+ let(:params) { { runner_type: 'group_type' } }
+
+ it_behaves_like 'it cannot create a runner'
+ end
+ end
+
+ context 'when admin user' do
+ let(:current_user) { admin }
+
+ it_behaves_like 'it cannot create a runner'
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it_behaves_like 'it can create a runner'
+ it_behaves_like 'it can return an error'
+ end
+ end
+ end
+
+ context 'with :runner_type param set to project_type' do
+ let_it_be(:project) { create(:project, namespace: group) }
+
+ let(:expected_type) { 'project_type' }
+ let(:params) { { runner_type: 'project_type', scope: project } }
+
+ before do
+ group.add_developer(non_admin_user)
+ group.add_owner(group_owner)
+ end
+
+ context 'when anonymous user' do
+ let(:current_user) { anonymous }
+
+ it_behaves_like 'it cannot create a runner'
+ end
+
+ context 'when group owner' do
+ let(:current_user) { group_owner }
+
+ it_behaves_like 'it can create a runner'
+
+ context 'with missing scope param' do
+ let(:params) { { runner_type: 'project_type' } }
+
+ it_behaves_like 'it cannot create a runner'
+ end
+ end
+
+ context 'when non-admin user' do
+ let(:current_user) { non_admin_user }
+
+ it_behaves_like 'it cannot create a runner'
+
+ context 'with project permissions to create runner' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ it_behaves_like 'it can create a runner'
+ end
+ end
+
+ context 'when admin user' do
+ let(:current_user) { admin }
+
+ it_behaves_like 'it cannot create a runner'
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it_behaves_like 'it can create a runner'
+ it_behaves_like 'it can return an error'
+ end
end
end
end
diff --git a/spec/services/ci/runners/process_runner_version_update_service_spec.rb b/spec/services/ci/runners/process_runner_version_update_service_spec.rb
index e62cb1ec3e3..f8b7aa281af 100644
--- a/spec/services/ci/runners/process_runner_version_update_service_spec.rb
+++ b/spec/services/ci/runners/process_runner_version_update_service_spec.rb
@@ -29,6 +29,19 @@ RSpec.describe Ci::Runners::ProcessRunnerVersionUpdateService, feature_category:
end
end
+ context 'when fetching runner releases is disabled' do
+ before do
+ stub_application_setting(update_runner_versions_enabled: false)
+ end
+
+ it 'does not update ci_runner_versions records', :aggregate_failures do
+ expect do
+ expect(execute).to be_error
+ expect(execute.message).to eq 'version update disabled'
+ end.not_to change(Ci::RunnerVersion, :count).from(0)
+ end
+ end
+
context 'with successful result from upgrade check' do
before do
url = ::Gitlab::CurrentSettings.current_application_settings.public_runner_releases_url
diff --git a/spec/services/ci/runners/register_runner_service_spec.rb b/spec/services/ci/runners/register_runner_service_spec.rb
index c67040e45eb..b5921773364 100644
--- a/spec/services/ci/runners/register_runner_service_spec.rb
+++ b/spec/services/ci/runners/register_runner_service_spec.rb
@@ -7,13 +7,23 @@ RSpec.describe ::Ci::Runners::RegisterRunnerService, '#execute', feature_categor
let(:token) {}
let(:args) { {} }
let(:runner) { execute.payload[:runner] }
+ let(:allow_runner_registration_token) { true }
before do
stub_application_setting(runners_registration_token: registration_token)
stub_application_setting(valid_runner_registrars: ApplicationSetting::VALID_RUNNER_REGISTRAR_TYPES)
+ stub_application_setting(allow_runner_registration_token: allow_runner_registration_token)
end
- subject(:execute) { described_class.new.execute(token, args) }
+ subject(:execute) { described_class.new(token, args).execute }
+
+ shared_examples 'runner registration is disallowed' do
+ it 'returns error response with runner_registration_disallowed reason' do
+ expect(execute).to be_error
+ expect(execute.message).to eq 'runner registration disallowed'
+ expect(execute.reason).to eq :runner_registration_disallowed
+ end
+ end
context 'when no token is provided' do
let(:token) { '' }
@@ -36,7 +46,7 @@ RSpec.describe ::Ci::Runners::RegisterRunnerService, '#execute', feature_categor
end
context 'when valid token is provided' do
- context 'with a registration token' do
+ context 'when instance registration token is used' do
let(:token) { registration_token }
it 'creates runner with default values' do
@@ -51,6 +61,12 @@ RSpec.describe ::Ci::Runners::RegisterRunnerService, '#execute', feature_categor
expect(runner).to be_instance_type
end
+ context 'when registering instance runners is disallowed' do
+ let(:allow_runner_registration_token) { false }
+
+ it_behaves_like 'runner registration is disallowed'
+ end
+
context 'with non-default arguments' do
let(:args) do
{
@@ -112,9 +128,15 @@ RSpec.describe ::Ci::Runners::RegisterRunnerService, '#execute', feature_categor
end
end
- context 'when project token is used' do
- let(:project) { create(:project) }
+ context 'when project registration token is used' do
+ let_it_be(:project) { create(:project, :with_namespace_settings) }
+
let(:token) { project.runners_token }
+ let(:allow_group_runner_registration_token) { true }
+
+ before do
+ project.namespace.update!(allow_runner_registration_token: allow_group_runner_registration_token)
+ end
it 'creates project runner' do
expect(execute).to be_success
@@ -127,6 +149,18 @@ RSpec.describe ::Ci::Runners::RegisterRunnerService, '#execute', feature_categor
expect(runner).to be_project_type
end
+ context 'with runner registration disabled at instance level' do
+ let(:allow_runner_registration_token) { false }
+
+ it_behaves_like 'runner registration is disallowed'
+ end
+
+ context 'with runner registration disabled at group level' do
+ let(:allow_group_runner_registration_token) { false }
+
+ it_behaves_like 'runner registration is disallowed'
+ end
+
context 'when it exceeds the application limits' do
before do
create(:ci_runner, runner_type: :project_type, projects: [project], contacted_at: 1.second.ago)
@@ -173,9 +207,15 @@ RSpec.describe ::Ci::Runners::RegisterRunnerService, '#execute', feature_categor
end
end
- context 'when group token is used' do
- let(:group) { create(:group) }
+ context 'when group registration token is used' do
+ let_it_be_with_refind(:group) { create(:group) }
+
let(:token) { group.runners_token }
+ let(:allow_group_runner_registration_token) { true }
+
+ before do
+ group.update!(allow_runner_registration_token: allow_group_runner_registration_token)
+ end
it 'creates a group runner' do
expect(execute).to be_success
@@ -188,6 +228,18 @@ RSpec.describe ::Ci::Runners::RegisterRunnerService, '#execute', feature_categor
expect(runner).to be_group_type
end
+ context 'with runner registration disabled at instance level' do
+ let(:allow_runner_registration_token) { false }
+
+ it_behaves_like 'runner registration is disallowed'
+ end
+
+ context 'with runner registration disabled at group level' do
+ let(:allow_group_runner_registration_token) { false }
+
+ it_behaves_like 'runner registration is disallowed'
+ end
+
context 'when it exceeds the application limits' do
before do
create(:ci_runner, runner_type: :group_type, groups: [group], contacted_at: nil, created_at: 1.month.ago)
diff --git a/spec/services/ci/runners/stale_machines_cleanup_service_spec.rb b/spec/services/ci/runners/stale_machines_cleanup_service_spec.rb
deleted file mode 100644
index 456dbcebb84..00000000000
--- a/spec/services/ci/runners/stale_machines_cleanup_service_spec.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Ci::Runners::StaleMachinesCleanupService, feature_category: :runner_fleet do
- let(:service) { described_class.new }
- let!(:runner_machine3) { create(:ci_runner_machine, created_at: 6.months.ago, contacted_at: Time.current) }
-
- subject(:response) { service.execute }
-
- context 'with no stale runner machines' do
- it 'does not clean any runner machines and returns :success status' do
- expect do
- expect(response).to be_success
- expect(response.payload).to match({ deleted_machines: false })
- end.not_to change { Ci::RunnerMachine.count }.from(1)
- end
- end
-
- context 'with some stale runner machines' do
- before do
- create(:ci_runner_machine, :stale)
- create(:ci_runner_machine, :stale, contacted_at: nil)
- end
-
- it 'only leaves non-stale runners' do
- expect(response).to be_success
- expect(response.payload).to match({ deleted_machines: true })
- expect(Ci::RunnerMachine.all).to contain_exactly(runner_machine3)
- end
-
- context 'with more stale runners than MAX_DELETIONS' do
- before do
- stub_const("#{described_class}::MAX_DELETIONS", 1)
- end
-
- it 'only leaves non-stale runners' do
- expect do
- expect(response).to be_success
- expect(response.payload).to match({ deleted_machines: true })
- end.to change { Ci::RunnerMachine.count }.by(-Ci::Runners::StaleMachinesCleanupService::MAX_DELETIONS)
- end
- end
- end
-end
diff --git a/spec/services/ci/runners/stale_managers_cleanup_service_spec.rb b/spec/services/ci/runners/stale_managers_cleanup_service_spec.rb
new file mode 100644
index 00000000000..a78506ca5f7
--- /dev/null
+++ b/spec/services/ci/runners/stale_managers_cleanup_service_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::Runners::StaleManagersCleanupService, feature_category: :runner_fleet do
+ let(:service) { described_class.new }
+ let!(:runner_manager3) { create(:ci_runner_machine, created_at: 6.months.ago, contacted_at: Time.current) }
+
+ subject(:response) { service.execute }
+
+ context 'with no stale runner managers' do
+ it 'does not clean any runner managers and returns :success status' do
+ expect do
+ expect(response).to be_success
+ expect(response.payload).to match({ deleted_managers: false })
+ end.not_to change { Ci::RunnerManager.count }.from(1)
+ end
+ end
+
+ context 'with some stale runner managers' do
+ before do
+ create(:ci_runner_machine, :stale)
+ create(:ci_runner_machine, :stale, contacted_at: nil)
+ end
+
+ it 'only leaves non-stale runners' do
+ expect(response).to be_success
+ expect(response.payload).to match({ deleted_managers: true })
+ expect(Ci::RunnerManager.all).to contain_exactly(runner_manager3)
+ end
+
+ context 'with more stale runners than MAX_DELETIONS' do
+ before do
+ stub_const("#{described_class}::MAX_DELETIONS", 1)
+ end
+
+ it 'only leaves non-stale runners' do
+ expect do
+ expect(response).to be_success
+ expect(response.payload).to match({ deleted_managers: true })
+ end.to change { Ci::RunnerManager.count }.by(-Ci::Runners::StaleManagersCleanupService::MAX_DELETIONS)
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/runners/unregister_runner_manager_service_spec.rb b/spec/services/ci/runners/unregister_runner_manager_service_spec.rb
new file mode 100644
index 00000000000..8bfda8e2083
--- /dev/null
+++ b/spec/services/ci/runners/unregister_runner_manager_service_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ci::Runners::UnregisterRunnerManagerService, '#execute', feature_category: :runner_fleet do
+ subject(:execute) { described_class.new(runner, 'some_token', system_id: system_id).execute }
+
+ context 'with runner registered with registration token' do
+ let!(:runner) { create(:ci_runner, registration_type: :registration_token) }
+ let(:system_id) { nil }
+
+ it 'does not destroy runner or runner managers' do
+ expect do
+ expect(execute).to be_error
+ end.to not_change { Ci::Runner.count }
+ .and not_change { Ci::RunnerManager.count }
+ expect(runner[:errors]).to be_nil
+ end
+ end
+
+ context 'with runner created in UI' do
+ let!(:runner_manager1) { create(:ci_runner_machine, runner: runner, system_xid: 'system_id_1') }
+ let!(:runner_manager2) { create(:ci_runner_machine, runner: runner, system_xid: 'system_id_2') }
+ let!(:runner) { create(:ci_runner, registration_type: :authenticated_user) }
+
+ context 'with system_id specified' do
+ let(:system_id) { runner_manager1.system_xid }
+
+ it 'destroys runner_manager1 and leaves runner', :aggregate_failures do
+ expect do
+ expect(execute).to be_success
+ end.to change { Ci::RunnerManager.count }.by(-1)
+ .and not_change { Ci::Runner.count }
+ expect(runner[:errors]).to be_nil
+ expect(runner.runner_managers).to contain_exactly(runner_manager2)
+ end
+ end
+
+ context 'with unknown system_id' do
+ let(:system_id) { 'unknown_system_id' }
+
+ it 'raises RecordNotFound error', :aggregate_failures do
+ expect do
+ execute
+ end.to raise_error(ActiveRecord::RecordNotFound)
+ .and not_change { Ci::Runner.count }
+ .and not_change { Ci::RunnerManager.count }
+ end
+ end
+
+ context 'with system_id missing' do
+ let(:system_id) { nil }
+
+ it 'returns error and leaves runner_manager1', :aggregate_failures do
+ expect do
+ expect(execute).to be_error
+ expect(execute.message).to eq('`system_id` needs to be specified for runners created in the UI.')
+ end.to not_change { Ci::Runner.count }
+ .and not_change { Ci::RunnerManager.count }
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/stuck_builds/drop_pending_service_spec.rb b/spec/services/ci/stuck_builds/drop_pending_service_spec.rb
index a452a65829a..6d91f5098eb 100644
--- a/spec/services/ci/stuck_builds/drop_pending_service_spec.rb
+++ b/spec/services/ci/stuck_builds/drop_pending_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::StuckBuilds::DropPendingService do
+RSpec.describe Ci::StuckBuilds::DropPendingService, feature_category: :runner_fleet do
let_it_be(:runner) { create(:ci_runner) }
let_it_be(:pipeline) { create(:ci_empty_pipeline) }
let_it_be_with_reload(:job) do
diff --git a/spec/services/ci/stuck_builds/drop_running_service_spec.rb b/spec/services/ci/stuck_builds/drop_running_service_spec.rb
index c1c92c2b8e2..deb807753c2 100644
--- a/spec/services/ci/stuck_builds/drop_running_service_spec.rb
+++ b/spec/services/ci/stuck_builds/drop_running_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::StuckBuilds::DropRunningService do
+RSpec.describe Ci::StuckBuilds::DropRunningService, feature_category: :runner_fleet do
let!(:runner) { create :ci_runner }
let!(:job) { create(:ci_build, runner: runner, created_at: created_at, updated_at: updated_at, status: status) }
diff --git a/spec/services/ci/stuck_builds/drop_scheduled_service_spec.rb b/spec/services/ci/stuck_builds/drop_scheduled_service_spec.rb
index a4f9f97fffc..f2e658c3ae3 100644
--- a/spec/services/ci/stuck_builds/drop_scheduled_service_spec.rb
+++ b/spec/services/ci/stuck_builds/drop_scheduled_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::StuckBuilds::DropScheduledService do
+RSpec.describe Ci::StuckBuilds::DropScheduledService, feature_category: :runner_fleet do
let_it_be(:runner) { create :ci_runner }
let!(:job) { create :ci_build, :scheduled, scheduled_at: scheduled_at, runner: runner }
diff --git a/spec/services/ci/test_failure_history_service_spec.rb b/spec/services/ci/test_failure_history_service_spec.rb
index 10f6c6f5007..e77c6533483 100644
--- a/spec/services/ci/test_failure_history_service_spec.rb
+++ b/spec/services/ci/test_failure_history_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::TestFailureHistoryService, :aggregate_failures do
+RSpec.describe Ci::TestFailureHistoryService, :aggregate_failures, feature_category: :continuous_integration do
let_it_be(:project) { create(:project, :repository) }
let_it_be_with_reload(:pipeline) do
diff --git a/spec/services/ci/track_failed_build_service_spec.rb b/spec/services/ci/track_failed_build_service_spec.rb
index 676769d2fc7..23e7cee731d 100644
--- a/spec/services/ci/track_failed_build_service_spec.rb
+++ b/spec/services/ci/track_failed_build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::TrackFailedBuildService do
+RSpec.describe Ci::TrackFailedBuildService, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
diff --git a/spec/services/ci/unlock_artifacts_service_spec.rb b/spec/services/ci/unlock_artifacts_service_spec.rb
index c15e1cb2b5d..0d6ac333587 100644
--- a/spec/services/ci/unlock_artifacts_service_spec.rb
+++ b/spec/services/ci/unlock_artifacts_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::UnlockArtifactsService do
+RSpec.describe Ci::UnlockArtifactsService, feature_category: :continuous_integration do
using RSpec::Parameterized::TableSyntax
where(:tag) do
@@ -24,7 +24,7 @@ RSpec.describe Ci::UnlockArtifactsService do
let!(:older_ambiguous_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: !tag, project: project, locked: :artifacts_locked) }
let!(:code_coverage_pipeline) { create(:ci_pipeline, :with_coverage_report_artifact, ref: ref, tag: tag, project: project, locked: :artifacts_locked) }
let!(:pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: tag, project: project, locked: :artifacts_locked) }
- let!(:child_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: tag, project: project, locked: :artifacts_locked) }
+ let!(:child_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: tag, child_of: pipeline, project: project, locked: :artifacts_locked) }
let!(:newer_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: tag, project: project, locked: :artifacts_locked) }
let!(:other_ref_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: 'other_ref', tag: tag, project: project, locked: :artifacts_locked) }
let!(:sources_pipeline) { create(:ci_sources_pipeline, source_job: source_job, source_project: project, pipeline: child_pipeline, project: project) }
@@ -120,6 +120,12 @@ RSpec.describe Ci::UnlockArtifactsService do
let(:before_pipeline) { pipeline }
it 'produces the expected SQL string' do
+ # To be removed when the ignored column id_convert_to_bigint for ci_pipelines is removed
+ # see https://gitlab.com/gitlab-org/gitlab/-/issues/397000
+ selected_columns =
+ Ci::Pipeline.column_names.map do |field|
+ Ci::Pipeline.connection.quote_table_name("#{Ci::Pipeline.table_name}.#{field}")
+ end.join(', ')
expect(subject.squish).to eq <<~SQL.squish
UPDATE
"ci_pipelines"
@@ -140,14 +146,14 @@ RSpec.describe Ci::UnlockArtifactsService do
"base_and_descendants"
AS
((SELECT
- "ci_pipelines".*
+ #{selected_columns}
FROM
"ci_pipelines"
WHERE
"ci_pipelines"."id" = #{before_pipeline.id})
UNION
(SELECT
- "ci_pipelines".*
+ #{selected_columns}
FROM
"ci_pipelines",
"base_and_descendants",
@@ -201,8 +207,7 @@ RSpec.describe Ci::UnlockArtifactsService do
describe '#unlock_job_artifacts_query' do
subject { described_class.new(pipeline.project, pipeline.user).unlock_job_artifacts_query(pipeline_ids) }
- context 'when running on a ref before a pipeline' do
- let(:before_pipeline) { pipeline }
+ context 'when given a single pipeline ID' do
let(:pipeline_ids) { [older_pipeline.id] }
it 'produces the expected SQL string' do
@@ -226,8 +231,7 @@ RSpec.describe Ci::UnlockArtifactsService do
end
end
- context 'when running on just the ref' do
- let(:before_pipeline) { nil }
+ context 'when given multiple pipeline IDs' do
let(:pipeline_ids) { [older_pipeline.id, newer_pipeline.id, pipeline.id] }
it 'produces the expected SQL string' do
diff --git a/spec/services/ci/update_build_queue_service_spec.rb b/spec/services/ci/update_build_queue_service_spec.rb
index dd26339831c..4fd4492278d 100644
--- a/spec/services/ci/update_build_queue_service_spec.rb
+++ b/spec/services/ci/update_build_queue_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::UpdateBuildQueueService do
+RSpec.describe Ci::UpdateBuildQueueService, feature_category: :continuous_integration do
let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) }
diff --git a/spec/services/ci/update_instance_variables_service_spec.rb b/spec/services/ci/update_instance_variables_service_spec.rb
index f235d006e34..889f49eca5a 100644
--- a/spec/services/ci/update_instance_variables_service_spec.rb
+++ b/spec/services/ci/update_instance_variables_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::UpdateInstanceVariablesService do
+RSpec.describe Ci::UpdateInstanceVariablesService, feature_category: :secrets_management do
let(:params) { { variables_attributes: variables_attributes } }
subject { described_class.new(params) }
diff --git a/spec/services/ci/update_pending_build_service_spec.rb b/spec/services/ci/update_pending_build_service_spec.rb
index e49b22299f0..abf31dd5184 100644
--- a/spec/services/ci/update_pending_build_service_spec.rb
+++ b/spec/services/ci/update_pending_build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::UpdatePendingBuildService do
+RSpec.describe Ci::UpdatePendingBuildService, feature_category: :continuous_integration do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be_with_reload(:pending_build_1) { create(:ci_pending_build, project: project, instance_runners_enabled: false) }
diff --git a/spec/services/clusters/agent_tokens/create_service_spec.rb b/spec/services/clusters/agent_tokens/create_service_spec.rb
index dc7abd1504b..803bd947629 100644
--- a/spec/services/clusters/agent_tokens/create_service_spec.rb
+++ b/spec/services/clusters/agent_tokens/create_service_spec.rb
@@ -2,14 +2,14 @@
require 'spec_helper'
-RSpec.describe Clusters::AgentTokens::CreateService do
- subject(:service) { described_class.new(container: project, current_user: user, params: params) }
+RSpec.describe Clusters::AgentTokens::CreateService, feature_category: :deployment_management do
+ subject(:service) { described_class.new(agent: cluster_agent, current_user: user, params: params) }
let_it_be(:user) { create(:user) }
let(:cluster_agent) { create(:cluster_agent) }
let(:project) { cluster_agent.project }
- let(:params) { { agent_id: cluster_agent.id, description: 'token description', name: 'token name' } }
+ let(:params) { { description: 'token description', name: 'token name' } }
describe '#execute' do
subject { service.execute }
@@ -75,7 +75,7 @@ RSpec.describe Clusters::AgentTokens::CreateService do
it 'returns validation errors', :aggregate_failures do
expect(subject.status).to eq(:error)
- expect(subject.message).to eq(["Agent must exist", "Name can't be blank"])
+ expect(subject.message).to eq(["Name can't be blank"])
end
end
end
diff --git a/spec/services/clusters/agent_tokens/revoke_service_spec.rb b/spec/services/clusters/agent_tokens/revoke_service_spec.rb
new file mode 100644
index 00000000000..a1537658723
--- /dev/null
+++ b/spec/services/clusters/agent_tokens/revoke_service_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::AgentTokens::RevokeService, feature_category: :deployment_management do
+ describe '#execute' do
+ subject { described_class.new(token: agent_token, current_user: user).execute }
+
+ let(:agent) { create(:cluster_agent) }
+ let(:agent_token) { create(:cluster_agent_token, agent: agent) }
+ let(:project) { agent.project }
+ let(:user) { agent.created_by_user }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'when user is authorized' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'when user revokes agent token' do
+ it 'succeeds' do
+ subject
+
+ expect(agent_token.revoked?).to be true
+ end
+
+ it 'creates an activity event' do
+ expect { subject }.to change { ::Clusters::Agents::ActivityEvent.count }.by(1)
+
+ event = agent.activity_events.last
+
+ expect(event).to have_attributes(
+ kind: 'token_revoked',
+ level: 'info',
+ recorded_at: agent_token.reload.updated_at,
+ user: user,
+ agent_token: agent_token
+ )
+ end
+ end
+
+ context 'when there is a validation failure' do
+ before do
+ agent_token.name = '' # make the record invalid, as we require a name to be present
+ end
+
+ it 'fails without raising an error', :aggregate_failures do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to eq(["Name can't be blank"])
+ end
+
+ it 'does not create an activity event' do
+ expect { subject }.not_to change { ::Clusters::Agents::ActivityEvent.count }
+ end
+ end
+ end
+
+ context 'when user is not authorized' do
+ let(:user) { create(:user) }
+
+ before do
+ project.add_guest(user)
+ end
+
+ context 'when user attempts to revoke agent token' do
+ it 'fails' do
+ subject
+
+ expect(agent_token.revoked?).to be false
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/clusters/agent_tokens/track_usage_service_spec.rb b/spec/services/clusters/agent_tokens/track_usage_service_spec.rb
index 3350b15a5ce..6bea8afcc80 100644
--- a/spec/services/clusters/agent_tokens/track_usage_service_spec.rb
+++ b/spec/services/clusters/agent_tokens/track_usage_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::AgentTokens::TrackUsageService do
+RSpec.describe Clusters::AgentTokens::TrackUsageService, feature_category: :deployment_management do
let_it_be(:agent) { create(:cluster_agent) }
describe '#execute', :clean_gitlab_redis_cache do
diff --git a/spec/services/clusters/agents/authorizations/ci_access/filter_service_spec.rb b/spec/services/clusters/agents/authorizations/ci_access/filter_service_spec.rb
new file mode 100644
index 00000000000..45443cfd887
--- /dev/null
+++ b/spec/services/clusters/agents/authorizations/ci_access/filter_service_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::CiAccess::FilterService, feature_category: :continuous_integration do
+ describe '#execute' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+
+ let(:agent_authorizations_without_env) do
+ [
+ build(:agent_ci_access_project_authorization, project: project, agent: build(:cluster_agent, project: project)),
+ build(:agent_ci_access_group_authorization, group: group, agent: build(:cluster_agent, project: project)),
+ ::Clusters::Agents::Authorizations::CiAccess::ImplicitAuthorization.new(agent: build(:cluster_agent, project: project))
+ ]
+ end
+
+ let(:filter_params) { {} }
+
+ subject(:execute_filter) { described_class.new(agent_authorizations, filter_params).execute }
+
+ context 'when there are no filters' do
+ let(:agent_authorizations) { agent_authorizations_without_env }
+
+ it 'returns the authorizations as is' do
+ expect(execute_filter).to eq agent_authorizations
+ end
+ end
+
+ context 'when filtering by environment' do
+ let(:agent_authorizations_with_env) do
+ [
+ build(
+ :agent_ci_access_project_authorization,
+ project: project,
+ agent: build(:cluster_agent, project: project),
+ environments: ['staging', 'review/*', 'production']
+ ),
+ build(
+ :agent_ci_access_group_authorization,
+ group: group,
+ agent: build(:cluster_agent, project: project),
+ environments: ['staging', 'review/*', 'production']
+ )
+ ]
+ end
+
+ let(:agent_authorizations_with_different_env) do
+ [
+ build(
+ :agent_ci_access_project_authorization,
+ project: project,
+ agent: build(:cluster_agent, project: project),
+ environments: ['staging']
+ ),
+ build(
+ :agent_ci_access_group_authorization,
+ group: group,
+ agent: build(:cluster_agent, project: project),
+ environments: ['staging']
+ )
+ ]
+ end
+
+ let(:agent_authorizations) do
+ (
+ agent_authorizations_without_env +
+ agent_authorizations_with_env +
+ agent_authorizations_with_different_env
+ )
+ end
+
+ let(:filter_params) { { environment: 'production' } }
+
+ it 'returns the authorizations with the given environment AND authorizations without any environment' do
+ expected_authorizations = agent_authorizations_with_env + agent_authorizations_without_env
+
+ expect(execute_filter).to match_array expected_authorizations
+ end
+
+ context 'when environment filter has a wildcard' do
+ let(:filter_params) { { environment: 'review/123' } }
+
+ it 'returns the authorizations with matching environments AND authorizations without any environment' do
+ expected_authorizations = agent_authorizations_with_env + agent_authorizations_without_env
+
+ expect(execute_filter).to match_array expected_authorizations
+ end
+ end
+
+ context 'when environment filter is nil' do
+ let(:filter_params) { { environment: nil } }
+
+ it 'returns the authorizations without any environment' do
+ expect(execute_filter).to match_array agent_authorizations_without_env
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/clusters/agents/authorizations/ci_access/refresh_service_spec.rb b/spec/services/clusters/agents/authorizations/ci_access/refresh_service_spec.rb
new file mode 100644
index 00000000000..c12592cc071
--- /dev/null
+++ b/spec/services/clusters/agents/authorizations/ci_access/refresh_service_spec.rb
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::CiAccess::RefreshService, feature_category: :deployment_management 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, path: 'group-path-with-UPPERCASE', 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, path: 'project-path-with-UPPERCASE', 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' },
+ # Uppercase path verifies case-insensitive matching.
+ { id: modified_group.full_path.upcase, default_namespace: 'new-namespace' }
+ ],
+ projects: [
+ { id: added_project.full_path, default_namespace: 'default' },
+ # Uppercase path verifies case-insensitive matching.
+ { id: modified_project.full_path.upcase, default_namespace: 'new-namespace' }
+ ]
+ }
+ }.deep_stringify_keys
+ end
+
+ subject { described_class.new(agent, config: config).execute }
+
+ before do
+ default_config = { default_namespace: 'default' }
+
+ agent.ci_access_group_authorizations.create!(group: removed_group, config: default_config)
+ agent.ci_access_group_authorizations.create!(group: modified_group, config: default_config)
+
+ agent.ci_access_project_authorizations.create!(project: removed_project, config: default_config)
+ agent.ci_access_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.ci_access_authorized_groups).to contain_exactly(added_group, modified_group)
+
+ added_authorization = agent.ci_access_group_authorizations.find_by(group: added_group)
+ expect(added_authorization.config).to eq({ 'default_namespace' => 'default' })
+
+ modified_authorization = agent.ci_access_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.ci_access_authorized_groups).to contain_exactly(added_group)
+ end
+ end
+
+ include_examples 'removing authorization' do
+ let(:authorizations) { agent.ci_access_authorized_groups }
+ end
+ end
+
+ describe 'project authorization' do
+ it 'refreshes authorizations for the agent' do
+ expect(subject).to be_truthy
+ expect(agent.ci_access_authorized_projects).to contain_exactly(added_project, modified_project)
+
+ added_authorization = agent.ci_access_project_authorizations.find_by(project: added_project)
+ expect(added_authorization.config).to eq({ 'default_namespace' => 'default' })
+
+ modified_authorization = agent.ci_access_project_authorizations.find_by(project: modified_project)
+ expect(modified_authorization.config).to eq({ 'default_namespace' => 'new-namespace' })
+ end
+
+ context 'project does not belong to a group, and is in the same namespace as the agent' do
+ let(:root_ancestor) { create(:namespace) }
+ let(:added_project) { create(:project, namespace: root_ancestor) }
+
+ it 'creates an authorization record for the project' do
+ expect(subject).to be_truthy
+ expect(agent.ci_access_authorized_projects).to contain_exactly(added_project)
+ end
+ end
+
+ context 'project does not belong to a group, and is authorizing itself' do
+ let(:root_ancestor) { create(:namespace) }
+ let(:added_project) { project }
+
+ it 'creates an authorization record for the project' do
+ expect(subject).to be_truthy
+ expect(agent.ci_access_authorized_projects).to contain_exactly(added_project)
+ end
+ 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.ci_access_authorized_projects).to contain_exactly(added_project)
+ end
+ end
+
+ include_examples 'removing authorization' do
+ let(:authorizations) { agent.ci_access_authorized_projects }
+ end
+ end
+ end
+end
diff --git a/spec/services/clusters/agents/authorizations/user_access/refresh_service_spec.rb b/spec/services/clusters/agents/authorizations/user_access/refresh_service_spec.rb
new file mode 100644
index 00000000000..da546ca44a9
--- /dev/null
+++ b/spec/services/clusters/agents/authorizations/user_access/refresh_service_spec.rb
@@ -0,0 +1,181 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::Authorizations::UserAccess::RefreshService, feature_category: :deployment_management do
+ describe '#execute' do
+ let_it_be(:root_ancestor) { create(:group) }
+ let_it_be(:agent_management_project) { create(:project, namespace: root_ancestor) }
+ let_it_be(:group_1) { create(:group, path: 'group-path-with-UPPERCASE', parent: root_ancestor) }
+ let_it_be(:group_2) { create(:group, parent: root_ancestor) }
+ let_it_be(:project_1) { create(:project, path: 'project-path-with-UPPERCASE', namespace: root_ancestor) }
+ let_it_be(:project_2) { create(:project, namespace: root_ancestor) }
+
+ let(:agent) { create(:cluster_agent, project: agent_management_project) }
+
+ let(:config) do
+ {
+ user_access: {
+ groups: [
+ { id: group_2.full_path }
+ ],
+ projects: [
+ { id: project_2.full_path }
+ ]
+ }
+ }.deep_merge(extra_config).deep_stringify_keys
+ end
+
+ let(:extra_config) { {} }
+
+ subject { described_class.new(agent, config: config).execute }
+
+ before do
+ agent.user_access_group_authorizations.create!(group: group_1, config: {})
+ agent.user_access_project_authorizations.create!(project: project_1, config: {})
+ end
+
+ shared_examples 'removing authorization' do
+ context 'when config contains no groups or projects' do
+ let(:config) { {} }
+
+ it 'removes all authorizations' do
+ expect(subject).to be_truthy
+ expect(authorizations).to be_empty
+ end
+ end
+
+ context 'when config contains groups or projects outside of the configuration project hierarchy' do
+ let_it_be(:agent_management_project) { create(:project, namespace: create(:group)) }
+
+ it 'removes all authorizations' do
+ expect(subject).to be_truthy
+ expect(authorizations).to be_empty
+ end
+ end
+
+ context 'when configuration project does not belong to a group' do
+ let_it_be(:agent_management_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.user_access_authorized_groups).to contain_exactly(group_2)
+
+ added_authorization = agent.user_access_group_authorizations.find_by(group: group_2)
+ expect(added_authorization.config).to eq({})
+ end
+
+ context 'when config contains "access_as" keyword' do
+ let(:extra_config) do
+ {
+ user_access: {
+ access_as: {
+ agent: {}
+ }
+ }
+ }
+ end
+
+ it 'refreshes authorizations for the agent' do
+ expect(subject).to be_truthy
+ expect(agent.user_access_authorized_groups).to contain_exactly(group_2)
+
+ added_authorization = agent.user_access_group_authorizations.find_by(group: group_2)
+ expect(added_authorization.config).to eq({ 'access_as' => { 'agent' => {} } })
+ end
+ end
+
+ context 'when config contains too many groups' do
+ before do
+ stub_const("#{described_class}::AUTHORIZED_ENTITY_LIMIT", 0)
+ end
+
+ it 'authorizes groups up to the limit' do
+ expect(subject).to be_truthy
+ expect(agent.user_access_authorized_groups).to be_empty
+ end
+ end
+
+ include_examples 'removing authorization' do
+ let(:authorizations) { agent.user_access_authorized_groups }
+ end
+ end
+
+ describe 'project authorization' do
+ it 'refreshes authorizations for the agent' do
+ expect(subject).to be_truthy
+ expect(agent.user_access_authorized_projects).to contain_exactly(project_2)
+
+ added_authorization = agent.user_access_project_authorizations.find_by(project: project_2)
+ expect(added_authorization.config).to eq({})
+ end
+
+ context 'when config contains "access_as" keyword' do
+ let(:extra_config) do
+ {
+ user_access: {
+ access_as: {
+ agent: {}
+ }
+ }
+ }
+ end
+
+ it 'refreshes authorizations for the agent' do
+ expect(subject).to be_truthy
+ expect(agent.user_access_authorized_projects).to contain_exactly(project_2)
+
+ added_authorization = agent.user_access_project_authorizations.find_by(project: project_2)
+ expect(added_authorization.config).to eq({ 'access_as' => { 'agent' => {} } })
+ end
+ end
+
+ context 'when project belongs to a user namespace, and is in the same namespace as the agent' do
+ let_it_be(:root_ancestor) { create(:namespace) }
+ let_it_be(:agent_management_project) { create(:project, namespace: root_ancestor) }
+ let_it_be(:project_1) { create(:project, path: 'project-path-with-UPPERCASE', namespace: root_ancestor) }
+ let_it_be(:project_2) { create(:project, namespace: root_ancestor) }
+
+ it 'creates an authorization record for the project' do
+ expect(subject).to be_truthy
+ expect(agent.user_access_authorized_projects).to contain_exactly(project_2)
+ end
+ end
+
+ context 'when project belongs to a user namespace, and is authorizing itself' do
+ let_it_be(:root_ancestor) { create(:namespace) }
+ let_it_be(:agent_management_project) { create(:project, namespace: root_ancestor) }
+ let_it_be(:project_1) { create(:project, path: 'project-path-with-UPPERCASE', namespace: root_ancestor) }
+ let_it_be(:project_2) { agent_management_project }
+
+ it 'creates an authorization record for the project' do
+ expect(subject).to be_truthy
+ expect(agent.user_access_authorized_projects).to contain_exactly(project_2)
+ end
+ end
+
+ context 'when config contains too many projects' do
+ before do
+ stub_const("#{described_class}::AUTHORIZED_ENTITY_LIMIT", 0)
+ end
+
+ it 'authorizes projects up to the limit' do
+ expect(subject).to be_truthy
+ expect(agent.user_access_authorized_projects).to be_empty
+ end
+ end
+
+ include_examples 'removing authorization' do
+ let(:authorizations) { agent.user_access_authorized_projects }
+ end
+ end
+ end
+end
diff --git a/spec/services/clusters/agents/authorize_proxy_user_service_spec.rb b/spec/services/clusters/agents/authorize_proxy_user_service_spec.rb
new file mode 100644
index 00000000000..2d6c79c5cb3
--- /dev/null
+++ b/spec/services/clusters/agents/authorize_proxy_user_service_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::AuthorizeProxyUserService, feature_category: :deployment_management do
+ subject(:service_response) { service.execute }
+
+ let(:service) { described_class.new(user, agent) }
+ let(:user) { create(:user) }
+
+ let_it_be(:organization) { create(:group) }
+ let_it_be(:configuration_project) { create(:project, group: organization) }
+ let_it_be(:agent) { create(:cluster_agent, name: 'the-agent', project: configuration_project) }
+ let_it_be(:deployment_project) { create(:project, group: organization) }
+ let_it_be(:deployment_group) { create(:group, parent: organization) }
+
+ let(:user_access_config) do
+ {
+ 'user_access' => {
+ 'access_as' => { 'agent' => {} },
+ 'projects' => [{ 'id' => deployment_project.full_path }],
+ 'groups' => [{ 'id' => deployment_group.full_path }]
+ }
+ }
+ end
+
+ before do
+ Clusters::Agents::Authorizations::UserAccess::RefreshService.new(agent, config: user_access_config).execute
+ end
+
+ it 'returns forbidden when user has no access to any project', :aggregate_failures do
+ expect(service_response).to be_error
+ expect(service_response.reason).to eq :forbidden
+ end
+
+ context 'when user is member of an authorized group' do
+ it 'authorizes developers', :aggregate_failures do
+ deployment_group.add_member(user, :developer)
+ expect(service_response).to be_success
+ expect(service_response.payload[:user]).to include(id: user.id, username: user.username)
+ expect(service_response.payload[:agent]).to include(id: agent.id, config_project: { id: agent.project.id })
+ end
+
+ it 'does not authorize reporters', :aggregate_failures do
+ deployment_group.add_member(user, :reporter)
+ expect(service_response).to be_error
+ expect(service_response.reason).to eq :forbidden
+ end
+ end
+
+ context 'when user is member of an authorized project' do
+ it 'authorizes developers', :aggregate_failures do
+ deployment_project.add_member(user, :developer)
+ expect(service_response).to be_success
+ expect(service_response.payload[:user]).to include(id: user.id, username: user.username)
+ expect(service_response.payload[:agent]).to include(id: agent.id, config_project: { id: agent.project.id })
+ end
+
+ it 'does not authorize reporters', :aggregate_failures do
+ deployment_project.add_member(user, :reporter)
+ expect(service_response).to be_error
+ expect(service_response.reason).to eq :forbidden
+ end
+ end
+end
diff --git a/spec/services/clusters/agents/create_activity_event_service_spec.rb b/spec/services/clusters/agents/create_activity_event_service_spec.rb
index 7a8f0e16d60..0d784bb69c7 100644
--- a/spec/services/clusters/agents/create_activity_event_service_spec.rb
+++ b/spec/services/clusters/agents/create_activity_event_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Agents::CreateActivityEventService do
+RSpec.describe Clusters::Agents::CreateActivityEventService, feature_category: :deployment_management do
let_it_be(:agent) { create(:cluster_agent) }
let_it_be(:token) { create(:cluster_agent_token, agent: agent) }
let_it_be(:user) { create(:user) }
@@ -40,5 +40,16 @@ RSpec.describe Clusters::Agents::CreateActivityEventService do
subject
end
+
+ context 'when activity event creation fails' do
+ let(:params) { {} }
+
+ it 'tracks the exception without raising' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(instance_of(ActiveRecord::RecordInvalid), agent_id: agent.id)
+
+ subject
+ end
+ end
end
end
diff --git a/spec/services/clusters/agents/create_service_spec.rb b/spec/services/clusters/agents/create_service_spec.rb
index 2b3bbcae13c..85607fcdf3a 100644
--- a/spec/services/clusters/agents/create_service_spec.rb
+++ b/spec/services/clusters/agents/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Agents::CreateService do
+RSpec.describe Clusters::Agents::CreateService, feature_category: :deployment_management do
subject(:service) { described_class.new(project, user) }
let(:project) { create(:project, :public, :repository) }
diff --git a/spec/services/clusters/agents/delete_expired_events_service_spec.rb b/spec/services/clusters/agents/delete_expired_events_service_spec.rb
index 3dc166f54eb..7dc9c280ab4 100644
--- a/spec/services/clusters/agents/delete_expired_events_service_spec.rb
+++ b/spec/services/clusters/agents/delete_expired_events_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Agents::DeleteExpiredEventsService do
+RSpec.describe Clusters::Agents::DeleteExpiredEventsService, feature_category: :deployment_management do
let_it_be(:agent) { create(:cluster_agent) }
describe '#execute' do
diff --git a/spec/services/clusters/agents/delete_service_spec.rb b/spec/services/clusters/agents/delete_service_spec.rb
index abe1bdaab27..febbb7ba5c8 100644
--- a/spec/services/clusters/agents/delete_service_spec.rb
+++ b/spec/services/clusters/agents/delete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Agents::DeleteService do
+RSpec.describe Clusters::Agents::DeleteService, feature_category: :deployment_management do
subject(:service) { described_class.new(container: project, current_user: user) }
let(:cluster_agent) { create(:cluster_agent) }
diff --git a/spec/services/clusters/agents/filter_authorizations_service_spec.rb b/spec/services/clusters/agents/filter_authorizations_service_spec.rb
deleted file mode 100644
index 62cff405d0c..00000000000
--- a/spec/services/clusters/agents/filter_authorizations_service_spec.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Agents::FilterAuthorizationsService, feature_category: :continuous_integration do
- describe '#execute' do
- let_it_be(:group) { create(:group) }
- let_it_be(:project) { create(:project, group: group) }
-
- let(:agent_authorizations_without_env) do
- [
- build(:agent_project_authorization, project: project, agent: build(:cluster_agent, project: project)),
- build(:agent_group_authorization, group: group, agent: build(:cluster_agent, project: project)),
- ::Clusters::Agents::ImplicitAuthorization.new(agent: build(:cluster_agent, project: project))
- ]
- end
-
- let(:filter_params) { {} }
-
- subject(:execute_filter) { described_class.new(agent_authorizations, filter_params).execute }
-
- context 'when there are no filters' do
- let(:agent_authorizations) { agent_authorizations_without_env }
-
- it 'returns the authorizations as is' do
- expect(execute_filter).to eq agent_authorizations
- end
- end
-
- context 'when filtering by environment' do
- let(:agent_authorizations_with_env) do
- [
- build(
- :agent_project_authorization,
- project: project,
- agent: build(:cluster_agent, project: project),
- environments: ['staging', 'review/*', 'production']
- ),
- build(
- :agent_group_authorization,
- group: group,
- agent: build(:cluster_agent, project: project),
- environments: ['staging', 'review/*', 'production']
- )
- ]
- end
-
- let(:agent_authorizations_with_different_env) do
- [
- build(
- :agent_project_authorization,
- project: project,
- agent: build(:cluster_agent, project: project),
- environments: ['staging']
- ),
- build(
- :agent_group_authorization,
- group: group,
- agent: build(:cluster_agent, project: project),
- environments: ['staging']
- )
- ]
- end
-
- let(:agent_authorizations) do
- (
- agent_authorizations_without_env +
- agent_authorizations_with_env +
- agent_authorizations_with_different_env
- )
- end
-
- let(:filter_params) { { environment: 'production' } }
-
- it 'returns the authorizations with the given environment AND authorizations without any environment' do
- expected_authorizations = agent_authorizations_with_env + agent_authorizations_without_env
-
- expect(execute_filter).to match_array expected_authorizations
- end
-
- context 'when environment filter has a wildcard' do
- let(:filter_params) { { environment: 'review/123' } }
-
- it 'returns the authorizations with matching environments AND authorizations without any environment' do
- expected_authorizations = agent_authorizations_with_env + agent_authorizations_without_env
-
- expect(execute_filter).to match_array expected_authorizations
- end
- end
-
- context 'when environment filter is nil' do
- let(:filter_params) { { environment: nil } }
-
- it 'returns the authorizations without any environment' do
- expect(execute_filter).to match_array agent_authorizations_without_env
- 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
deleted file mode 100644
index 51c054ddc98..00000000000
--- a/spec/services/clusters/agents/refresh_authorization_service_spec.rb
+++ /dev/null
@@ -1,154 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Clusters::Agents::RefreshAuthorizationService, feature_category: :kubernetes_management 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, path: 'group-path-with-UPPERCASE', 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, path: 'project-path-with-UPPERCASE', 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' },
- # Uppercase path verifies case-insensitive matching.
- { id: modified_group.full_path.upcase, default_namespace: 'new-namespace' }
- ],
- projects: [
- { id: added_project.full_path, default_namespace: 'default' },
- # Uppercase path verifies case-insensitive matching.
- { id: modified_project.full_path.upcase, 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 'project does not belong to a group, and is in the same namespace as the agent' do
- let(:root_ancestor) { create(:namespace) }
- let(:added_project) { create(:project, namespace: root_ancestor) }
-
- it 'creates an authorization record for the project' do
- expect(subject).to be_truthy
- expect(agent.authorized_projects).to contain_exactly(added_project)
- end
- end
-
- context 'project does not belong to a group, and is authorizing itself' do
- let(:root_ancestor) { create(:namespace) }
- let(:added_project) { project }
-
- it 'creates an authorization record for the project' do
- expect(subject).to be_truthy
- expect(agent.authorized_projects).to contain_exactly(added_project)
- end
- 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/clusters/build_kubernetes_namespace_service_spec.rb b/spec/services/clusters/build_kubernetes_namespace_service_spec.rb
index 4ee933374f6..fea17495914 100644
--- a/spec/services/clusters/build_kubernetes_namespace_service_spec.rb
+++ b/spec/services/clusters/build_kubernetes_namespace_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::BuildKubernetesNamespaceService do
+RSpec.describe Clusters::BuildKubernetesNamespaceService, feature_category: :deployment_management do
let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
let(:environment) { create(:environment) }
let(:project) { environment.project }
diff --git a/spec/services/clusters/build_service_spec.rb b/spec/services/clusters/build_service_spec.rb
index c7a64435d3b..909d3f58c48 100644
--- a/spec/services/clusters/build_service_spec.rb
+++ b/spec/services/clusters/build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::BuildService do
+RSpec.describe Clusters::BuildService, feature_category: :deployment_management do
describe '#execute' do
subject { described_class.new(cluster_subject).execute }
diff --git a/spec/services/clusters/cleanup/project_namespace_service_spec.rb b/spec/services/clusters/cleanup/project_namespace_service_spec.rb
index 8d3ae217a9f..34311d6e830 100644
--- a/spec/services/clusters/cleanup/project_namespace_service_spec.rb
+++ b/spec/services/clusters/cleanup/project_namespace_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Cleanup::ProjectNamespaceService do
+RSpec.describe Clusters::Cleanup::ProjectNamespaceService, feature_category: :deployment_management do
describe '#execute' do
subject { service.execute }
diff --git a/spec/services/clusters/cleanup/service_account_service_spec.rb b/spec/services/clusters/cleanup/service_account_service_spec.rb
index 769762237f9..f5a3c2e8eb1 100644
--- a/spec/services/clusters/cleanup/service_account_service_spec.rb
+++ b/spec/services/clusters/cleanup/service_account_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Cleanup::ServiceAccountService do
+RSpec.describe Clusters::Cleanup::ServiceAccountService, feature_category: :deployment_management do
describe '#execute' do
subject { service.execute }
@@ -55,14 +55,16 @@ RSpec.describe Clusters::Cleanup::ServiceAccountService do
context 'when there is a Kubeclient::HttpError' do
['Unauthorized', 'forbidden', 'Certificate verify Failed'].each do |message|
- before do
- allow(kubeclient_instance_double)
- .to receive(:delete_service_account)
- .and_raise(Kubeclient::HttpError.new(401, message, nil))
- end
+ context "with error:#{message}" do
+ before do
+ allow(kubeclient_instance_double)
+ .to receive(:delete_service_account)
+ .and_raise(Kubeclient::HttpError.new(401, message, nil))
+ end
- it 'destroys cluster' do
- expect { subject }.to change { Clusters::Cluster.where(id: cluster.id).exists? }.from(true).to(false)
+ it 'destroys cluster' do
+ expect { subject }.to change { Clusters::Cluster.where(id: cluster.id).exists? }.from(true).to(false)
+ end
end
end
end
diff --git a/spec/services/clusters/create_service_spec.rb b/spec/services/clusters/create_service_spec.rb
index 95f10cdbd80..e130f713cb2 100644
--- a/spec/services/clusters/create_service_spec.rb
+++ b/spec/services/clusters/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::CreateService do
+RSpec.describe Clusters::CreateService, feature_category: :deployment_management do
let(:access_token) { 'xxx' }
let(:project) { create(:project) }
let(:user) { create(:user) }
@@ -50,7 +50,7 @@ RSpec.describe Clusters::CreateService do
end
context 'when project has a cluster' do
- include_context 'valid cluster create params'
+ include_context 'with valid cluster create params'
let!(:cluster) { create(:cluster, :provided_by_gcp, :production_environment, projects: [project]) }
it 'creates another cluster' do
diff --git a/spec/services/clusters/destroy_service_spec.rb b/spec/services/clusters/destroy_service_spec.rb
index dc600c9e830..dd3e24d0e12 100644
--- a/spec/services/clusters/destroy_service_spec.rb
+++ b/spec/services/clusters/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::DestroyService do
+RSpec.describe Clusters::DestroyService, feature_category: :deployment_management do
describe '#execute' do
subject { described_class.new(cluster.user, params).execute(cluster) }
diff --git a/spec/services/clusters/integrations/create_service_spec.rb b/spec/services/clusters/integrations/create_service_spec.rb
index 9104e07504d..b716e4f4651 100644
--- a/spec/services/clusters/integrations/create_service_spec.rb
+++ b/spec/services/clusters/integrations/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Integrations::CreateService, '#execute' do
+RSpec.describe Clusters::Integrations::CreateService, '#execute', feature_category: :deployment_management do
let_it_be(:project) { create(:project) }
let_it_be_with_reload(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
diff --git a/spec/services/clusters/integrations/prometheus_health_check_service_spec.rb b/spec/services/clusters/integrations/prometheus_health_check_service_spec.rb
index 526462931a6..9390d4b368b 100644
--- a/spec/services/clusters/integrations/prometheus_health_check_service_spec.rb
+++ b/spec/services/clusters/integrations/prometheus_health_check_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Integrations::PrometheusHealthCheckService, '#execute' do
+RSpec.describe Clusters::Integrations::PrometheusHealthCheckService, '#execute', feature_category: :deployment_management do
let(:service) { described_class.new(cluster) }
subject { service.execute }
diff --git a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
index 90956e7b4ea..48941792c4b 100644
--- a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
+++ b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute' do
+RSpec.describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute', feature_category: :deployment_management do
include KubernetesHelpers
let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
@@ -11,7 +11,7 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute'
let(:project) { cluster.project }
let(:environment) { create(:environment, project: project) }
let(:cluster_project) { cluster.cluster_project }
- let(:namespace) { "#{project.name}-#{project.id}-#{environment.slug}" }
+ let(:namespace) { "#{project.path}-#{project.id}-#{environment.slug}" }
subject do
described_class.new(
diff --git a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
index 37478a0bcd9..ab0c5691b06 100644
--- a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
+++ b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
+RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService, feature_category: :deployment_management do
include KubernetesHelpers
let(:api_url) { 'http://111.111.111.111' }
diff --git a/spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb b/spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb
index 03c402fb066..439dc37e684 100644
--- a/spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb
+++ b/spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Kubernetes::FetchKubernetesTokenService do
+RSpec.describe Clusters::Kubernetes::FetchKubernetesTokenService, feature_category: :deployment_management do
include KubernetesHelpers
describe '#execute' do
diff --git a/spec/services/clusters/kubernetes_spec.rb b/spec/services/clusters/kubernetes_spec.rb
index 12af63890fc..cd430f81a65 100644
--- a/spec/services/clusters/kubernetes_spec.rb
+++ b/spec/services/clusters/kubernetes_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Kubernetes do
+RSpec.describe Clusters::Kubernetes, feature_category: :deployment_management do
it { is_expected.to be_const_defined(:GITLAB_SERVICE_ACCOUNT_NAME) }
it { is_expected.to be_const_defined(:GITLAB_SERVICE_ACCOUNT_NAMESPACE) }
it { is_expected.to be_const_defined(:GITLAB_ADMIN_TOKEN_NAME) }
diff --git a/spec/services/clusters/management/validate_management_project_permissions_service_spec.rb b/spec/services/clusters/management/validate_management_project_permissions_service_spec.rb
index a21c378d3d1..46032de600d 100644
--- a/spec/services/clusters/management/validate_management_project_permissions_service_spec.rb
+++ b/spec/services/clusters/management/validate_management_project_permissions_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Management::ValidateManagementProjectPermissionsService do
+RSpec.describe Clusters::Management::ValidateManagementProjectPermissionsService, feature_category: :deployment_management do
describe '#execute' do
subject { described_class.new(user).execute(cluster, management_project_id) }
diff --git a/spec/services/clusters/update_service_spec.rb b/spec/services/clusters/update_service_spec.rb
index 9aead97f41c..cc759407376 100644
--- a/spec/services/clusters/update_service_spec.rb
+++ b/spec/services/clusters/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::UpdateService do
+RSpec.describe Clusters::UpdateService, feature_category: :deployment_management do
include KubernetesHelpers
describe '#execute' do
diff --git a/spec/services/cohorts_service_spec.rb b/spec/services/cohorts_service_spec.rb
index dce8d4f80f2..ab53bcf8657 100644
--- a/spec/services/cohorts_service_spec.rb
+++ b/spec/services/cohorts_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CohortsService do
+RSpec.describe CohortsService, feature_category: :shared do
describe '#execute' do
def month_start(months_ago)
months_ago.months.ago.beginning_of_month.to_date
diff --git a/spec/services/commits/cherry_pick_service_spec.rb b/spec/services/commits/cherry_pick_service_spec.rb
index 2565e17ac90..880ebea1c09 100644
--- a/spec/services/commits/cherry_pick_service_spec.rb
+++ b/spec/services/commits/cherry_pick_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Commits::CherryPickService do
+RSpec.describe Commits::CherryPickService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
# * ddd0f15ae83993f5cb66a927a28673882e99100b (HEAD -> master, origin/master, origin/HEAD) Merge branch 'po-fix-test-en
# |\
diff --git a/spec/services/commits/commit_patch_service_spec.rb b/spec/services/commits/commit_patch_service_spec.rb
index edd0918e488..a9d61be23be 100644
--- a/spec/services/commits/commit_patch_service_spec.rb
+++ b/spec/services/commits/commit_patch_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Commits::CommitPatchService do
+RSpec.describe Commits::CommitPatchService, feature_category: :source_code_management do
describe '#execute' do
let(:patches) do
patches_folder = Rails.root.join('spec/fixtures/patchfiles')
diff --git a/spec/services/commits/tag_service_spec.rb b/spec/services/commits/tag_service_spec.rb
index dd742ebe469..25aa84276c3 100644
--- a/spec/services/commits/tag_service_spec.rb
+++ b/spec/services/commits/tag_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Commits::TagService do
+RSpec.describe Commits::TagService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
diff --git a/spec/services/compare_service_spec.rb b/spec/services/compare_service_spec.rb
index e96a7f2f4f4..6757fbdf5d4 100644
--- a/spec/services/compare_service_spec.rb
+++ b/spec/services/compare_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CompareService do
+RSpec.describe CompareService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:service) { described_class.new(project, 'feature') }
diff --git a/spec/services/concerns/audit_event_save_type_spec.rb b/spec/services/concerns/audit_event_save_type_spec.rb
index fbaebd9f85c..a89eb513d27 100644
--- a/spec/services/concerns/audit_event_save_type_spec.rb
+++ b/spec/services/concerns/audit_event_save_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuditEventSaveType do
+RSpec.describe AuditEventSaveType, feature_category: :audit_events do
subject(:target) { Object.new.extend(described_class) }
describe '#should_save_database? and #should_save_stream?' do
diff --git a/spec/services/concerns/exclusive_lease_guard_spec.rb b/spec/services/concerns/exclusive_lease_guard_spec.rb
index 6a2aa0a377b..ca8bff4ecc4 100644
--- a/spec/services/concerns/exclusive_lease_guard_spec.rb
+++ b/spec/services/concerns/exclusive_lease_guard_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ExclusiveLeaseGuard, :clean_gitlab_redis_shared_state do
+RSpec.describe ExclusiveLeaseGuard, :clean_gitlab_redis_shared_state, feature_category: :shared do
subject :subject_class do
Class.new do
include ExclusiveLeaseGuard
@@ -49,11 +49,49 @@ RSpec.describe ExclusiveLeaseGuard, :clean_gitlab_redis_shared_state do
subject.exclusive_lease.cancel
end
- it 'does not call internal_method but logs error', :aggregate_failures do
- expect(subject).not_to receive(:internal_method)
- expect(Gitlab::AppLogger).to receive(:error).with("Cannot obtain an exclusive lease for #{subject.lease_key}. There must be another instance already in execution.")
+ context 'when the class does not override lease_taken_log_level' do
+ it 'does not call internal_method but logs error', :aggregate_failures do
+ expect(subject).not_to receive(:internal_method)
+ expect(Gitlab::AppJsonLogger).to receive(:error).with({ message: "Cannot obtain an exclusive lease. There must be another instance already in execution.", lease_key: 'exclusive_lease_guard_test_class', class_name: 'ExclusiveLeaseGuardTestClass', lease_timeout: 1.second })
- subject.call
+ subject.call
+ end
+ end
+
+ context 'when the class overrides lease_taken_log_level to return :info' do
+ subject :overwritten_subject_class do
+ Class.new(subject_class) do
+ def lease_taken_log_level
+ :info
+ end
+ end
+ end
+
+ let(:subject) { overwritten_subject_class.new }
+
+ it 'logs info', :aggregate_failures do
+ expect(Gitlab::AppJsonLogger).to receive(:info).with({ message: "Cannot obtain an exclusive lease. There must be another instance already in execution.", lease_key: 'exclusive_lease_guard_test_class', class_name: 'ExclusiveLeaseGuardTestClass', lease_timeout: 1.second })
+
+ subject.call
+ end
+ end
+
+ context 'when the class overrides lease_taken_log_level to return :debug' do
+ subject :overwritten_subject_class do
+ Class.new(subject_class) do
+ def lease_taken_log_level
+ :debug
+ end
+ end
+ end
+
+ let(:subject) { overwritten_subject_class.new }
+
+ it 'logs debug', :aggregate_failures do
+ expect(Gitlab::AppJsonLogger).to receive(:debug).with({ message: "Cannot obtain an exclusive lease. There must be another instance already in execution.", lease_key: 'exclusive_lease_guard_test_class', class_name: 'ExclusiveLeaseGuardTestClass', lease_timeout: 1.second })
+
+ subject.call
+ end
end
end
diff --git a/spec/services/concerns/merge_requests/assigns_merge_params_spec.rb b/spec/services/concerns/merge_requests/assigns_merge_params_spec.rb
index 5b1e8fca31b..c6ee5b78c13 100644
--- a/spec/services/concerns/merge_requests/assigns_merge_params_spec.rb
+++ b/spec/services/concerns/merge_requests/assigns_merge_params_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::AssignsMergeParams do
+RSpec.describe MergeRequests::AssignsMergeParams, feature_category: :code_review_workflow do
it 'raises an error when used from an instance that does not respond to #current_user' do
define_class = -> { Class.new { include MergeRequests::AssignsMergeParams }.new }
diff --git a/spec/services/concerns/rate_limited_service_spec.rb b/spec/services/concerns/rate_limited_service_spec.rb
index d913cd17067..2172c756ecf 100644
--- a/spec/services/concerns/rate_limited_service_spec.rb
+++ b/spec/services/concerns/rate_limited_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RateLimitedService do
+RSpec.describe RateLimitedService, feature_category: :rate_limiting do
let(:key) { :issues_create }
let(:scope) { [:container, :current_user] }
let(:opts) { { scope: scope, users_allowlist: -> { [User.support_bot.username] } } }
diff --git a/spec/services/container_expiration_policies/cleanup_service_spec.rb b/spec/services/container_expiration_policies/cleanup_service_spec.rb
index 6e1be7271e1..4663944f0b9 100644
--- a/spec/services/container_expiration_policies/cleanup_service_spec.rb
+++ b/spec/services/container_expiration_policies/cleanup_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ContainerExpirationPolicies::CleanupService do
+RSpec.describe ContainerExpirationPolicies::CleanupService, feature_category: :container_registry do
let_it_be(:repository, reload: true) { create(:container_repository, expiration_policy_started_at: 30.minutes.ago) }
let_it_be(:project) { repository.project }
@@ -190,6 +190,7 @@ RSpec.describe ContainerExpirationPolicies::CleanupService do
context 'with only the current repository started_at before the policy next_run_at' do
before do
+ repository.update!(expiration_policy_started_at: policy.next_run_at + 9.minutes)
repository2.update!(expiration_policy_started_at: policy.next_run_at + 10.minutes)
repository3.update!(expiration_policy_started_at: policy.next_run_at + 12.minutes)
end
diff --git a/spec/services/container_expiration_policies/update_service_spec.rb b/spec/services/container_expiration_policies/update_service_spec.rb
index 7d949b77de7..992240201e0 100644
--- a/spec/services/container_expiration_policies/update_service_spec.rb
+++ b/spec/services/container_expiration_policies/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ContainerExpirationPolicies::UpdateService do
+RSpec.describe ContainerExpirationPolicies::UpdateService, feature_category: :container_registry do
using RSpec::Parameterized::TableSyntax
let_it_be(:project, reload: true) { create(:project) }
diff --git a/spec/services/customer_relations/contacts/create_service_spec.rb b/spec/services/customer_relations/contacts/create_service_spec.rb
index db6cce799fe..91aa51385e7 100644
--- a/spec/services/customer_relations/contacts/create_service_spec.rb
+++ b/spec/services/customer_relations/contacts/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CustomerRelations::Contacts::CreateService do
+RSpec.describe CustomerRelations::Contacts::CreateService, feature_category: :service_desk do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:not_found_or_does_not_belong) { 'The specified organization was not found or does not belong to this group' }
@@ -50,8 +50,8 @@ RSpec.describe CustomerRelations::Contacts::CreateService do
end
it 'returns an error when the organization belongs to a different group' do
- organization = create(:organization)
- params[:organization_id] = organization.id
+ crm_organization = create(:crm_organization)
+ params[:organization_id] = crm_organization.id
expect(response).to be_error
expect(response.message).to match_array([not_found_or_does_not_belong])
diff --git a/spec/services/customer_relations/contacts/update_service_spec.rb b/spec/services/customer_relations/contacts/update_service_spec.rb
index 729fdc2058b..105b5bad5f7 100644
--- a/spec/services/customer_relations/contacts/update_service_spec.rb
+++ b/spec/services/customer_relations/contacts/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CustomerRelations::Contacts::UpdateService do
+RSpec.describe CustomerRelations::Contacts::UpdateService, feature_category: :service_desk do
let_it_be(:user) { create(:user) }
let(:contact) { create(:contact, first_name: 'Mark', group: group, state: 'active') }
diff --git a/spec/services/customer_relations/organizations/create_service_spec.rb b/spec/services/customer_relations/organizations/create_service_spec.rb
index 18eefdd716e..8748fe44763 100644
--- a/spec/services/customer_relations/organizations/create_service_spec.rb
+++ b/spec/services/customer_relations/organizations/create_service_spec.rb
@@ -2,16 +2,16 @@
require 'spec_helper'
-RSpec.describe CustomerRelations::Organizations::CreateService do
+RSpec.describe CustomerRelations::Organizations::CreateService, feature_category: :service_desk do
describe '#execute' do
let_it_be(:user) { create(:user) }
let(:group) { create(:group, :crm_enabled) }
- let(:params) { attributes_for(:organization, group: group) }
+ let(:params) { attributes_for(:crm_organization, group: group) }
subject(:response) { described_class.new(group: group, current_user: user, params: params).execute }
- it 'creates an organization' do
+ it 'creates a crm_organization' do
group.add_developer(user)
expect(response).to be_success
@@ -24,7 +24,7 @@ RSpec.describe CustomerRelations::Organizations::CreateService do
expect(response.message).to match_array(['You have insufficient permissions to create an organization for this group'])
end
- it 'returns an error when the organization is not persisted' do
+ it 'returns an error when the crm_organization is not persisted' do
group.add_developer(user)
params[:name] = nil
diff --git a/spec/services/customer_relations/organizations/update_service_spec.rb b/spec/services/customer_relations/organizations/update_service_spec.rb
index 4764ba85551..f11b99b101e 100644
--- a/spec/services/customer_relations/organizations/update_service_spec.rb
+++ b/spec/services/customer_relations/organizations/update_service_spec.rb
@@ -2,12 +2,12 @@
require 'spec_helper'
-RSpec.describe CustomerRelations::Organizations::UpdateService do
+RSpec.describe CustomerRelations::Organizations::UpdateService, feature_category: :service_desk do
let_it_be(:user) { create(:user) }
- let(:organization) { create(:organization, name: 'Test', group: group, state: 'active') }
+ let(:crm_organization) { create(:crm_organization, name: 'Test', group: group, state: 'active') }
- subject(:update) { described_class.new(group: group, current_user: user, params: params).execute(organization) }
+ subject(:update) { described_class.new(group: group, current_user: user, params: params).execute(crm_organization) }
describe '#execute' do
context 'when the user has no permission' do
@@ -33,7 +33,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do
context 'when name is changed' do
let(:params) { { name: 'GitLab' } }
- it 'updates the organization' do
+ it 'updates the crm_organization' do
response = update
expect(response).to be_success
@@ -42,7 +42,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do
end
context 'when activating' do
- let(:organization) { create(:organization, state: 'inactive') }
+ let(:crm_organization) { create(:crm_organization, state: 'inactive') }
let(:params) { { active: true } }
it 'updates the contact' do
@@ -56,7 +56,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do
context 'when deactivating' do
let(:params) { { active: false } }
- it 'updates the organization' do
+ it 'updates the crm_organization' do
response = update
expect(response).to be_success
@@ -64,7 +64,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do
end
end
- context 'when the organization is invalid' do
+ context 'when the crm_organization is invalid' do
let(:params) { { name: nil } }
it 'returns an error' do
diff --git a/spec/services/database/consistency_check_service_spec.rb b/spec/services/database/consistency_check_service_spec.rb
index 6288fedfb59..8b7560f80ad 100644
--- a/spec/services/database/consistency_check_service_spec.rb
+++ b/spec/services/database/consistency_check_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Database::ConsistencyCheckService, feature_category: :pods do
+RSpec.describe Database::ConsistencyCheckService, feature_category: :cell do
let(:batch_size) { 5 }
let(:max_batches) { 2 }
diff --git a/spec/services/database/consistency_fix_service_spec.rb b/spec/services/database/consistency_fix_service_spec.rb
index 9a0fac2191c..ea0916e8d2b 100644
--- a/spec/services/database/consistency_fix_service_spec.rb
+++ b/spec/services/database/consistency_fix_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Database::ConsistencyFixService do
+RSpec.describe Database::ConsistencyFixService, feature_category: :cell do
describe '#execute' do
context 'fixing namespaces inconsistencies' do
subject(:consistency_fix_service) do
diff --git a/spec/services/dependency_proxy/auth_token_service_spec.rb b/spec/services/dependency_proxy/auth_token_service_spec.rb
index c686f57c5cb..2612c5765a4 100644
--- a/spec/services/dependency_proxy/auth_token_service_spec.rb
+++ b/spec/services/dependency_proxy/auth_token_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DependencyProxy::AuthTokenService do
+RSpec.describe DependencyProxy::AuthTokenService, feature_category: :dependency_proxy do
include DependencyProxyHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/dependency_proxy/find_cached_manifest_service_spec.rb b/spec/services/dependency_proxy/find_cached_manifest_service_spec.rb
index 470c6eb9e03..13620b3dfc1 100644
--- a/spec/services/dependency_proxy/find_cached_manifest_service_spec.rb
+++ b/spec/services/dependency_proxy/find_cached_manifest_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DependencyProxy::FindCachedManifestService do
+RSpec.describe DependencyProxy::FindCachedManifestService, feature_category: :dependency_proxy do
include DependencyProxyHelpers
let_it_be(:image) { 'alpine' }
diff --git a/spec/services/dependency_proxy/group_settings/update_service_spec.rb b/spec/services/dependency_proxy/group_settings/update_service_spec.rb
index 4954d9ec267..38f837a828a 100644
--- a/spec/services/dependency_proxy/group_settings/update_service_spec.rb
+++ b/spec/services/dependency_proxy/group_settings/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::DependencyProxy::GroupSettings::UpdateService do
+RSpec.describe ::DependencyProxy::GroupSettings::UpdateService, feature_category: :dependency_proxy do
using RSpec::Parameterized::TableSyntax
let_it_be_with_reload(:group) { create(:group) }
diff --git a/spec/services/dependency_proxy/head_manifest_service_spec.rb b/spec/services/dependency_proxy/head_manifest_service_spec.rb
index 949a8eb3bee..a9646a185bc 100644
--- a/spec/services/dependency_proxy/head_manifest_service_spec.rb
+++ b/spec/services/dependency_proxy/head_manifest_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DependencyProxy::HeadManifestService do
+RSpec.describe DependencyProxy::HeadManifestService, feature_category: :dependency_proxy do
include DependencyProxyHelpers
let(:image) { 'alpine' }
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
index 3a6ba2cca71..f58434222a5 100644
--- 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::DependencyProxy::ImageTtlGroupPolicies::UpdateService do
+RSpec.describe ::DependencyProxy::ImageTtlGroupPolicies::UpdateService, feature_category: :dependency_proxy do
using RSpec::Parameterized::TableSyntax
let_it_be_with_reload(:group) { create(:group) }
diff --git a/spec/services/dependency_proxy/request_token_service_spec.rb b/spec/services/dependency_proxy/request_token_service_spec.rb
index 8b3ba783b8d..0cc3695f0b0 100644
--- a/spec/services/dependency_proxy/request_token_service_spec.rb
+++ b/spec/services/dependency_proxy/request_token_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DependencyProxy::RequestTokenService do
+RSpec.describe DependencyProxy::RequestTokenService, feature_category: :dependency_proxy do
include DependencyProxyHelpers
let(:image) { 'alpine:3.9' }
diff --git a/spec/services/deploy_keys/create_service_spec.rb b/spec/services/deploy_keys/create_service_spec.rb
index 2e3318236f5..8bff80b2d11 100644
--- a/spec/services/deploy_keys/create_service_spec.rb
+++ b/spec/services/deploy_keys/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DeployKeys::CreateService do
+RSpec.describe DeployKeys::CreateService, feature_category: :continuous_delivery do
let(:user) { create(:user) }
let(:params) { attributes_for(:deploy_key) }
diff --git a/spec/services/deployments/archive_in_project_service_spec.rb b/spec/services/deployments/archive_in_project_service_spec.rb
index a316c210d64..ed03ce06255 100644
--- a/spec/services/deployments/archive_in_project_service_spec.rb
+++ b/spec/services/deployments/archive_in_project_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::ArchiveInProjectService do
+RSpec.describe Deployments::ArchiveInProjectService, feature_category: :continuous_delivery do
let_it_be(:project) { create(:project, :repository) }
let(:service) { described_class.new(project, nil) }
diff --git a/spec/services/deployments/create_for_build_service_spec.rb b/spec/services/deployments/create_for_build_service_spec.rb
index 3748df87d99..c07fc07cfbf 100644
--- a/spec/services/deployments/create_for_build_service_spec.rb
+++ b/spec/services/deployments/create_for_build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::CreateForBuildService do
+RSpec.describe Deployments::CreateForBuildService, feature_category: :continuous_delivery do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/deployments/create_service_spec.rb b/spec/services/deployments/create_service_spec.rb
index 0f2a6ce32e1..2a70d450575 100644
--- a/spec/services/deployments/create_service_spec.rb
+++ b/spec/services/deployments/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::CreateService do
+RSpec.describe Deployments::CreateService, feature_category: :continuous_delivery do
let(:user) { create(:user) }
describe '#execute' do
diff --git a/spec/services/deployments/link_merge_requests_service_spec.rb b/spec/services/deployments/link_merge_requests_service_spec.rb
index a653cd2b48b..a468af90ffb 100644
--- a/spec/services/deployments/link_merge_requests_service_spec.rb
+++ b/spec/services/deployments/link_merge_requests_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::LinkMergeRequestsService do
+RSpec.describe Deployments::LinkMergeRequestsService, feature_category: :continuous_delivery do
let(:project) { create(:project, :repository) }
# * ddd0f15 Merge branch 'po-fix-test-env-path' into 'master'
diff --git a/spec/services/deployments/older_deployments_drop_service_spec.rb b/spec/services/deployments/older_deployments_drop_service_spec.rb
index d9a512a5dd2..7e3074a1688 100644
--- a/spec/services/deployments/older_deployments_drop_service_spec.rb
+++ b/spec/services/deployments/older_deployments_drop_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::OlderDeploymentsDropService do
+RSpec.describe Deployments::OlderDeploymentsDropService, feature_category: :continuous_delivery do
let(:environment) { create(:environment) }
let(:deployment) { create(:deployment, environment: environment) }
let(:service) { described_class.new(deployment) }
diff --git a/spec/services/deployments/update_environment_service_spec.rb b/spec/services/deployments/update_environment_service_spec.rb
index 31a3abda8c7..33c9c9ed592 100644
--- a/spec/services/deployments/update_environment_service_spec.rb
+++ b/spec/services/deployments/update_environment_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::UpdateEnvironmentService do
+RSpec.describe Deployments::UpdateEnvironmentService, feature_category: :continuous_delivery do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:options) { { name: environment_name } }
diff --git a/spec/services/deployments/update_service_spec.rb b/spec/services/deployments/update_service_spec.rb
index d3840189ba4..0814091765c 100644
--- a/spec/services/deployments/update_service_spec.rb
+++ b/spec/services/deployments/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::UpdateService do
+RSpec.describe Deployments::UpdateService, feature_category: :continuous_delivery do
let(:deploy) { create(:deployment) }
describe '#execute' do
diff --git a/spec/services/design_management/copy_design_collection/copy_service_spec.rb b/spec/services/design_management/copy_design_collection/copy_service_spec.rb
index 89a78c9bf5f..048327792e0 100644
--- a/spec/services/design_management/copy_design_collection/copy_service_spec.rb
+++ b/spec/services/design_management/copy_design_collection/copy_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DesignManagement::CopyDesignCollection::CopyService, :clean_gitlab_redis_shared_state do
+RSpec.describe DesignManagement::CopyDesignCollection::CopyService, :clean_gitlab_redis_shared_state, feature_category: :portfolio_management do
include DesignManagementTestHelpers
let_it_be(:user) { create(:user) }
@@ -117,6 +117,7 @@ RSpec.describe DesignManagement::CopyDesignCollection::CopyService, :clean_gitla
new_designs.zip(old_designs).each do |new_design, old_design|
expect(new_design).to have_attributes(
filename: old_design.filename,
+ description: old_design.description,
relative_position: old_design.relative_position,
issue: target_issue,
project: target_issue.project
diff --git a/spec/services/design_management/copy_design_collection/queue_service_spec.rb b/spec/services/design_management/copy_design_collection/queue_service_spec.rb
index 05a7b092ccf..e6809e65d8a 100644
--- a/spec/services/design_management/copy_design_collection/queue_service_spec.rb
+++ b/spec/services/design_management/copy_design_collection/queue_service_spec.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DesignManagement::CopyDesignCollection::QueueService, :clean_gitlab_redis_shared_state do
+RSpec.describe DesignManagement::CopyDesignCollection::QueueService, :clean_gitlab_redis_shared_state,
+ feature_category: :design_management do
include DesignManagementTestHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/design_management/delete_designs_service_spec.rb b/spec/services/design_management/delete_designs_service_spec.rb
index 48e53a92758..22570a14443 100644
--- a/spec/services/design_management/delete_designs_service_spec.rb
+++ b/spec/services/design_management/delete_designs_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DesignManagement::DeleteDesignsService do
+RSpec.describe DesignManagement::DeleteDesignsService, feature_category: :design_management do
include DesignManagementTestHelpers
let_it_be(:project) { create(:project) }
@@ -99,7 +99,7 @@ RSpec.describe DesignManagement::DeleteDesignsService do
rescue StandardError
nil
end
- .not_to change { redis_hll.unique_events(event_names: event, start_date: 1.day.ago, end_date: 1.day.from_now) }
+ .not_to change { redis_hll.unique_events(event_names: event, start_date: Date.today, end_date: 1.week.from_now) }
begin
run_service
diff --git a/spec/services/design_management/design_user_notes_count_service_spec.rb b/spec/services/design_management/design_user_notes_count_service_spec.rb
index 37806d3461c..1dbd055038c 100644
--- a/spec/services/design_management/design_user_notes_count_service_spec.rb
+++ b/spec/services/design_management/design_user_notes_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DesignManagement::DesignUserNotesCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe DesignManagement::DesignUserNotesCountService, :use_clean_rails_memory_store_caching, feature_category: :design_management do
let_it_be(:design) { create(:design, :with_file) }
subject { described_class.new(design) }
diff --git a/spec/services/design_management/generate_image_versions_service_spec.rb b/spec/services/design_management/generate_image_versions_service_spec.rb
index 5409ec12016..08442f221fa 100644
--- a/spec/services/design_management/generate_image_versions_service_spec.rb
+++ b/spec/services/design_management/generate_image_versions_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DesignManagement::GenerateImageVersionsService do
+RSpec.describe DesignManagement::GenerateImageVersionsService, feature_category: :design_management do
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:version) { create(:design, :with_lfs_file, issue: issue).versions.first }
diff --git a/spec/services/design_management/move_designs_service_spec.rb b/spec/services/design_management/move_designs_service_spec.rb
index 519378a8dd4..8276d8d186a 100644
--- a/spec/services/design_management/move_designs_service_spec.rb
+++ b/spec/services/design_management/move_designs_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DesignManagement::MoveDesignsService do
+RSpec.describe DesignManagement::MoveDesignsService, feature_category: :design_management do
include DesignManagementTestHelpers
let_it_be(:issue) { create(:issue) }
diff --git a/spec/services/design_management/save_designs_service_spec.rb b/spec/services/design_management/save_designs_service_spec.rb
index a87494d87f7..ea53fcc3b12 100644
--- a/spec/services/design_management/save_designs_service_spec.rb
+++ b/spec/services/design_management/save_designs_service_spec.rb
@@ -11,7 +11,10 @@ RSpec.describe DesignManagement::SaveDesignsService, feature_category: :design_m
let(:project) { issue.project }
let(:user) { developer }
let(:files) { [rails_sample] }
- let(:design_repository) { ::Gitlab::GlRepository::DESIGN.repository_resolver.call(project) }
+ let(:design_repository) do
+ ::Gitlab::GlRepository::DESIGN.repository_resolver.call(project)
+ end
+
let(:rails_sample_name) { 'rails_sample.jpg' }
let(:rails_sample) { sample_image(rails_sample_name) }
let(:dk_png) { sample_image('dk.png') }
diff --git a/spec/services/discussions/capture_diff_note_position_service_spec.rb b/spec/services/discussions/capture_diff_note_position_service_spec.rb
index 11614ccfd55..313e828bf0a 100644
--- a/spec/services/discussions/capture_diff_note_position_service_spec.rb
+++ b/spec/services/discussions/capture_diff_note_position_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Discussions::CaptureDiffNotePositionService do
+RSpec.describe Discussions::CaptureDiffNotePositionService, feature_category: :code_review_workflow do
subject { described_class.new(note.noteable, paths) }
context 'image note on diff' do
diff --git a/spec/services/discussions/capture_diff_note_positions_service_spec.rb b/spec/services/discussions/capture_diff_note_positions_service_spec.rb
index 8ba54495d4c..96922535eb2 100644
--- a/spec/services/discussions/capture_diff_note_positions_service_spec.rb
+++ b/spec/services/discussions/capture_diff_note_positions_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Discussions::CaptureDiffNotePositionsService do
+RSpec.describe Discussions::CaptureDiffNotePositionsService, feature_category: :code_review_workflow do
context 'when merge request has a discussion' do
let(:source_branch) { 'compare-with-merge-head-source' }
let(:target_branch) { 'compare-with-merge-head-target' }
diff --git a/spec/services/discussions/update_diff_position_service_spec.rb b/spec/services/discussions/update_diff_position_service_spec.rb
index e7a3505bbd4..274430fdccb 100644
--- a/spec/services/discussions/update_diff_position_service_spec.rb
+++ b/spec/services/discussions/update_diff_position_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Discussions::UpdateDiffPositionService do
+RSpec.describe Discussions::UpdateDiffPositionService, feature_category: :code_review_workflow do
let(:project) { create(:project, :repository) }
let(:current_user) { project.first_owner }
let(:create_commit) { project.commit("913c66a37b4a45b9769037c55c2d238bd0942d2e") }
diff --git a/spec/services/draft_notes/create_service_spec.rb b/spec/services/draft_notes/create_service_spec.rb
index 528c8717525..93731a80dcc 100644
--- a/spec/services/draft_notes/create_service_spec.rb
+++ b/spec/services/draft_notes/create_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DraftNotes::CreateService do
+RSpec.describe DraftNotes::CreateService, feature_category: :code_review_workflow do
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.target_project }
let(:user) { merge_request.author }
diff --git a/spec/services/draft_notes/destroy_service_spec.rb b/spec/services/draft_notes/destroy_service_spec.rb
index 1f246a56eb3..f4cc9daa9e9 100644
--- a/spec/services/draft_notes/destroy_service_spec.rb
+++ b/spec/services/draft_notes/destroy_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DraftNotes::DestroyService do
+RSpec.describe DraftNotes::DestroyService, feature_category: :code_review_workflow do
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.target_project }
let(:user) { merge_request.author }
diff --git a/spec/services/draft_notes/publish_service_spec.rb b/spec/services/draft_notes/publish_service_spec.rb
index 9e811eaa25e..dab06637c1a 100644
--- a/spec/services/draft_notes/publish_service_spec.rb
+++ b/spec/services/draft_notes/publish_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe DraftNotes::PublishService do
+RSpec.describe DraftNotes::PublishService, feature_category: :code_review_workflow do
include RepoHelpers
let(:merge_request) { create(:merge_request) }
diff --git a/spec/services/emails/confirm_service_spec.rb b/spec/services/emails/confirm_service_spec.rb
index e8d3c0d673b..43fca75a5ea 100644
--- a/spec/services/emails/confirm_service_spec.rb
+++ b/spec/services/emails/confirm_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Emails::ConfirmService do
+RSpec.describe Emails::ConfirmService, feature_category: :user_management do
let_it_be(:user) { create(:user) }
subject(:service) { described_class.new(user) }
diff --git a/spec/services/emails/create_service_spec.rb b/spec/services/emails/create_service_spec.rb
index b13197f21b8..3ef67036483 100644
--- a/spec/services/emails/create_service_spec.rb
+++ b/spec/services/emails/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Emails::CreateService do
+RSpec.describe Emails::CreateService, feature_category: :user_management do
let_it_be(:user) { create(:user) }
let(:opts) { { email: 'new@email.com', user: user } }
diff --git a/spec/services/emails/destroy_service_spec.rb b/spec/services/emails/destroy_service_spec.rb
index 7dcf367016e..9d5e2b45647 100644
--- a/spec/services/emails/destroy_service_spec.rb
+++ b/spec/services/emails/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Emails::DestroyService do
+RSpec.describe Emails::DestroyService, feature_category: :user_management do
let!(:user) { create(:user) }
let!(:email) { create(:email, user: user) }
diff --git a/spec/services/environments/auto_stop_service_spec.rb b/spec/services/environments/auto_stop_service_spec.rb
index d688690c376..57fb860a557 100644
--- a/spec/services/environments/auto_stop_service_spec.rb
+++ b/spec/services/environments/auto_stop_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Environments::AutoStopService, :clean_gitlab_redis_shared_state, :sidekiq_inline do
+RSpec.describe Environments::AutoStopService, :clean_gitlab_redis_shared_state, :sidekiq_inline,
+ feature_category: :continuous_delivery do
include CreateEnvironmentsHelpers
include ExclusiveLeaseHelpers
diff --git a/spec/services/environments/canary_ingress/update_service_spec.rb b/spec/services/environments/canary_ingress/update_service_spec.rb
index 531f7d68a9f..f7d446c13f9 100644
--- a/spec/services/environments/canary_ingress/update_service_spec.rb
+++ b/spec/services/environments/canary_ingress/update_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Environments::CanaryIngress::UpdateService, :clean_gitlab_redis_cache do
+RSpec.describe Environments::CanaryIngress::UpdateService, :clean_gitlab_redis_cache,
+ feature_category: :continuous_delivery do
include KubernetesHelpers
let_it_be(:project, refind: true) { create(:project) }
diff --git a/spec/services/environments/create_for_build_service_spec.rb b/spec/services/environments/create_for_build_service_spec.rb
index c7aadb20c01..223401a243d 100644
--- a/spec/services/environments/create_for_build_service_spec.rb
+++ b/spec/services/environments/create_for_build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Environments::CreateForBuildService do
+RSpec.describe Environments::CreateForBuildService, feature_category: :continuous_delivery do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
diff --git a/spec/services/environments/reset_auto_stop_service_spec.rb b/spec/services/environments/reset_auto_stop_service_spec.rb
index 4a0b091c12d..a3b8b2e0aa1 100644
--- a/spec/services/environments/reset_auto_stop_service_spec.rb
+++ b/spec/services/environments/reset_auto_stop_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Environments::ResetAutoStopService do
+RSpec.describe Environments::ResetAutoStopService, feature_category: :continuous_delivery do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } }
diff --git a/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb b/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb
index 401d6203b2c..3047f415815 100644
--- a/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb
+++ b/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Environments::ScheduleToDeleteReviewAppsService do
+RSpec.describe Environments::ScheduleToDeleteReviewAppsService, feature_category: :continuous_delivery do
include ExclusiveLeaseHelpers
let_it_be(:maintainer) { create(:user) }
diff --git a/spec/services/environments/stop_service_spec.rb b/spec/services/environments/stop_service_spec.rb
index 5f983a2151a..6e3b36b5636 100644
--- a/spec/services/environments/stop_service_spec.rb
+++ b/spec/services/environments/stop_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Environments::StopService do
+RSpec.describe Environments::StopService, feature_category: :continuous_delivery do
include CreateEnvironmentsHelpers
let(:project) { create(:project, :private, :repository) }
diff --git a/spec/services/error_tracking/base_service_spec.rb b/spec/services/error_tracking/base_service_spec.rb
index de3523cb847..ed9efd9f95a 100644
--- a/spec/services/error_tracking/base_service_spec.rb
+++ b/spec/services/error_tracking/base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ErrorTracking::BaseService do
+RSpec.describe ErrorTracking::BaseService, feature_category: :error_tracking do
describe '#compose_response' do
let(:project) { build_stubbed(:project) }
let(:user) { build_stubbed(:user, id: non_existing_record_id) }
diff --git a/spec/services/error_tracking/collect_error_service_spec.rb b/spec/services/error_tracking/collect_error_service_spec.rb
index 159c070c683..3ff753e8c65 100644
--- a/spec/services/error_tracking/collect_error_service_spec.rb
+++ b/spec/services/error_tracking/collect_error_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ErrorTracking::CollectErrorService do
+RSpec.describe ErrorTracking::CollectErrorService, feature_category: :error_tracking do
let_it_be(:project) { create(:project) }
let(:parsed_event_file) { 'error_tracking/parsed_event.json' }
diff --git a/spec/services/error_tracking/issue_details_service_spec.rb b/spec/services/error_tracking/issue_details_service_spec.rb
index 29f8154a27c..7ac41ffead6 100644
--- a/spec/services/error_tracking/issue_details_service_spec.rb
+++ b/spec/services/error_tracking/issue_details_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ErrorTracking::IssueDetailsService do
+RSpec.describe ErrorTracking::IssueDetailsService, feature_category: :error_tracking do
include_context 'sentry error tracking context'
subject { described_class.new(project, user, params) }
diff --git a/spec/services/error_tracking/issue_latest_event_service_spec.rb b/spec/services/error_tracking/issue_latest_event_service_spec.rb
index aa2430ddffb..bfde14c7ef1 100644
--- a/spec/services/error_tracking/issue_latest_event_service_spec.rb
+++ b/spec/services/error_tracking/issue_latest_event_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ErrorTracking::IssueLatestEventService do
+RSpec.describe ErrorTracking::IssueLatestEventService, feature_category: :error_tracking do
include_context 'sentry error tracking context'
let(:params) { {} }
diff --git a/spec/services/error_tracking/issue_update_service_spec.rb b/spec/services/error_tracking/issue_update_service_spec.rb
index a06c3588264..4dae6cc2fa0 100644
--- a/spec/services/error_tracking/issue_update_service_spec.rb
+++ b/spec/services/error_tracking/issue_update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ErrorTracking::IssueUpdateService do
+RSpec.describe ErrorTracking::IssueUpdateService, feature_category: :error_tracking do
include_context 'sentry error tracking context'
let(:arguments) { { issue_id: non_existing_record_id, status: 'resolved' } }
diff --git a/spec/services/error_tracking/list_issues_service_spec.rb b/spec/services/error_tracking/list_issues_service_spec.rb
index a7bd6c75df5..2c35c2b8acd 100644
--- a/spec/services/error_tracking/list_issues_service_spec.rb
+++ b/spec/services/error_tracking/list_issues_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ErrorTracking::ListIssuesService do
+RSpec.describe ErrorTracking::ListIssuesService, feature_category: :error_tracking do
include_context 'sentry error tracking context'
let(:params) { {} }
diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb
index b969bd76053..6a4769d77d5 100644
--- a/spec/services/event_create_service_spec.rb
+++ b/spec/services/event_create_service_spec.rb
@@ -2,20 +2,31 @@
require 'spec_helper'
-RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state do
+RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state, feature_category: :service_ping do
include SnowplowHelpers
let(:service) { described_class.new }
+ let(:dates) { { start_date: Date.today.beginning_of_week, end_date: Date.today.next_week } }
let_it_be(:user, reload: true) { create :user }
let_it_be(:project) { create(:project) }
shared_examples 'it records the event in the event counter' do
specify do
- tracking_params = { event_action: event_action, date_from: Date.yesterday, date_to: Date.today }
+ tracking_params = { event_names: event_action, **dates }
expect { subject }
- .to change { Gitlab::UsageDataCounters::TrackUniqueEvents.count_unique_events(**tracking_params) }
+ .to change { Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(**tracking_params) }
+ .by(1)
+ end
+ end
+
+ shared_examples 'it records a git write event' do
+ specify do
+ tracking_params = { event_names: 'git_write_action', **dates }
+
+ expect { subject }
+ .to change { Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(**tracking_params) }
.by(1)
end
end
@@ -65,11 +76,10 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
end
it_behaves_like "it records the event in the event counter" do
- let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION }
+ let(:event_action) { :merge_request_action }
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'created' }
let(:label) { described_class::MR_EVENT_LABEL }
@@ -95,11 +105,10 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
end
it_behaves_like "it records the event in the event counter" do
- let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION }
+ let(:event_action) { :merge_request_action }
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'closed' }
let(:label) { described_class::MR_EVENT_LABEL }
@@ -125,11 +134,10 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
end
it_behaves_like "it records the event in the event counter" do
- let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION }
+ let(:event_action) { :merge_request_action }
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.name }
let(:action) { 'merged' }
let(:label) { described_class::MR_EVENT_LABEL }
@@ -276,8 +284,10 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
end
it_behaves_like "it records the event in the event counter" do
- let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::WIKI_ACTION }
+ let(:event_action) { :wiki_action }
end
+
+ it_behaves_like "it records a git write event"
end
(Event.actions.keys - Event::WIKI_ACTIONS).each do |bad_action|
@@ -312,9 +322,11 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
it_behaves_like 'service for creating a push event', PushEventPayloadService
it_behaves_like "it records the event in the event counter" do
- let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::PUSH_ACTION }
+ let(:event_action) { :project_action }
end
+ it_behaves_like "it records a git write event"
+
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
let(:category) { described_class.to_s }
let(:action) { :push }
@@ -338,9 +350,11 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
it_behaves_like 'service for creating a push event', BulkPushEventPayloadService
it_behaves_like "it records the event in the event counter" do
- let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::PUSH_ACTION }
+ let(:event_action) { :project_action }
end
+ it_behaves_like "it records a git write event"
+
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
let(:category) { described_class.to_s }
let(:action) { :push }
@@ -400,16 +414,17 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
end
it_behaves_like "it records the event in the event counter" do
- let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION }
+ let(:event_action) { :design_action }
end
+ it_behaves_like "it records a git write event"
+
describe 'Snowplow tracking' do
let(:project) { design.project }
let(:namespace) { project.namespace }
let(:category) { described_class.name }
- let(:property) { Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION.to_s }
+ let(:property) { :design_action.to_s }
let(:label) { ::EventCreateService::DEGIGN_EVENT_LABEL }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
context 'for create event' do
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
@@ -448,9 +463,11 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
end
it_behaves_like "it records the event in the event counter" do
- let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION }
+ let(:event_action) { :design_action }
end
+ it_behaves_like "it records a git write event"
+
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
subject(:design_service) { service.destroy_designs([design], author) }
@@ -459,9 +476,8 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
let(:category) { described_class.name }
let(:action) { 'destroy' }
let(:user) { author }
- let(:property) { Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION.to_s }
+ let(:property) { :design_action.to_s }
let(:label) { ::EventCreateService::DEGIGN_EVENT_LABEL }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
end
end
end
@@ -471,7 +487,7 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
let(:note) { create(:note) }
let(:author) { create(:user) }
- let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION }
+ let(:event_action) { :merge_request_action }
it { expect(leave_note).to be_truthy }
@@ -485,7 +501,6 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
it_behaves_like "it records the event in the event counter"
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:note) { create(:diff_note_on_merge_request) }
let(:category) { described_class.name }
let(:action) { 'commented' }
@@ -502,10 +517,9 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
context 'when it is not a diff note' do
it 'does not change the unique action counter' do
- counter_class = Gitlab::UsageDataCounters::TrackUniqueEvents
- tracking_params = { event_action: event_action, date_from: Date.yesterday, date_to: Date.today }
+ tracking_params = { event_names: event_action, start_date: Date.yesterday, end_date: Date.today }
- expect { subject }.not_to change { counter_class.count_unique_events(**tracking_params) }
+ expect { subject }.not_to change { Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(**tracking_params) }
end
end
end
diff --git a/spec/services/events/destroy_service_spec.rb b/spec/services/events/destroy_service_spec.rb
index 8b07852c040..e50fe247238 100644
--- a/spec/services/events/destroy_service_spec.rb
+++ b/spec/services/events/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Events::DestroyService do
+RSpec.describe Events::DestroyService, feature_category: :user_profile do
subject(:service) { described_class.new(project) }
let_it_be(:project, reload: true) { create(:project, :repository) }
diff --git a/spec/services/events/render_service_spec.rb b/spec/services/events/render_service_spec.rb
index 24a3b9abe14..2e8cd26781b 100644
--- a/spec/services/events/render_service_spec.rb
+++ b/spec/services/events/render_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Events::RenderService do
+RSpec.describe Events::RenderService, feature_category: :user_profile do
describe '#execute' do
let!(:note) { build(:note) }
let!(:event) { build(:event, target: note, project: note.project) }
diff --git a/spec/services/feature_flags/create_service_spec.rb b/spec/services/feature_flags/create_service_spec.rb
index 1a32faad948..18c48714ccd 100644
--- a/spec/services/feature_flags/create_service_spec.rb
+++ b/spec/services/feature_flags/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe FeatureFlags::CreateService do
+RSpec.describe FeatureFlags::CreateService, feature_category: :feature_flags do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:reporter) { create(:user) }
@@ -46,6 +46,8 @@ RSpec.describe FeatureFlags::CreateService do
end
context 'when feature flag is saved correctly' do
+ let(:audit_event_details) { AuditEvent.last.details }
+ let(:audit_event_message) { audit_event_details[:custom_message] }
let(:params) do
{
name: 'feature_flag',
@@ -88,9 +90,9 @@ RSpec.describe FeatureFlags::CreateService do
it 'creates audit event', :with_license do
expect { subject }.to change { AuditEvent.count }.by(1)
- 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".')
+ expect(audit_event_message).to start_with('Created feature flag feature_flag with description "description".')
+ expect(audit_event_message).to include('Created strategy "default" with scopes "*".')
+ expect(audit_event_message).to include('Created strategy "default" with scopes "production".')
end
context 'when user is reporter' do
diff --git a/spec/services/feature_flags/destroy_service_spec.rb b/spec/services/feature_flags/destroy_service_spec.rb
index b2793dc0560..1ec0ee6e68c 100644
--- a/spec/services/feature_flags/destroy_service_spec.rb
+++ b/spec/services/feature_flags/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe FeatureFlags::DestroyService do
+RSpec.describe FeatureFlags::DestroyService, feature_category: :feature_flags do
include FeatureFlagHelpers
let_it_be(:project) { create(:project) }
@@ -20,7 +20,8 @@ RSpec.describe FeatureFlags::DestroyService do
describe '#execute' do
subject { described_class.new(project, user, params).execute(feature_flag) }
- let(:audit_event_message) { AuditEvent.last.details[:custom_message] }
+ let(:audit_event_details) { AuditEvent.last.details }
+ let(:audit_event_message) { audit_event_details[:custom_message] }
let(:params) { {} }
it 'returns status success' do
diff --git a/spec/services/feature_flags/hook_service_spec.rb b/spec/services/feature_flags/hook_service_spec.rb
index f3edaca52a9..2a3ce9085b8 100644
--- a/spec/services/feature_flags/hook_service_spec.rb
+++ b/spec/services/feature_flags/hook_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe FeatureFlags::HookService do
+RSpec.describe FeatureFlags::HookService, feature_category: :feature_flags do
describe '#execute_hooks' do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project) { create(:project, :repository, namespace: namespace) }
diff --git a/spec/services/feature_flags/update_service_spec.rb b/spec/services/feature_flags/update_service_spec.rb
index 1c5af71a50a..55c09f06f16 100644
--- a/spec/services/feature_flags/update_service_spec.rb
+++ b/spec/services/feature_flags/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe FeatureFlags::UpdateService, :with_license do
+RSpec.describe FeatureFlags::UpdateService, :with_license, feature_category: :feature_flags do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:reporter) { create(:user) }
@@ -19,9 +19,8 @@ RSpec.describe FeatureFlags::UpdateService, :with_license do
subject { described_class.new(project, user, params).execute(feature_flag) }
let(:params) { { name: 'new_name' } }
- let(:audit_event_message) do
- AuditEvent.last.details[:custom_message]
- end
+ let(:audit_event_details) { AuditEvent.last.details }
+ let(:audit_event_message) { audit_event_details[:custom_message] }
it 'returns success status' do
expect(subject[:status]).to eq(:success)
diff --git a/spec/services/files/create_service_spec.rb b/spec/services/files/create_service_spec.rb
index 3b3dbd1fcfe..26f57f43120 100644
--- a/spec/services/files/create_service_spec.rb
+++ b/spec/services/files/create_service_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Files::CreateService do
+RSpec.describe Files::CreateService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:user) { create(:user, :commit_email) }
diff --git a/spec/services/files/delete_service_spec.rb b/spec/services/files/delete_service_spec.rb
index 3823d027812..dd99e5f9742 100644
--- a/spec/services/files/delete_service_spec.rb
+++ b/spec/services/files/delete_service_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Files::DeleteService do
+RSpec.describe Files::DeleteService, feature_category: :source_code_management do
subject { described_class.new(project, user, commit_params) }
let(:project) { create(:project, :repository) }
@@ -52,8 +52,8 @@ RSpec.describe Files::DeleteService do
end
describe "#execute" do
- context "when the file's last commit sha does not match the supplied last_commit_sha" do
- let(:last_commit_sha) { "foo" }
+ context "when the file's last commit is earlier than the latest commit for this branch" do
+ let(:last_commit_sha) { Gitlab::Git::Commit.last_for_path(project.repository, project.default_branch, file_path).parent_id }
it "returns a hash with the correct error message and a :error status" do
expect { subject.execute }
diff --git a/spec/services/files/multi_service_spec.rb b/spec/services/files/multi_service_spec.rb
index 6a5c7d2749d..7149fa77d6a 100644
--- a/spec/services/files/multi_service_spec.rb
+++ b/spec/services/files/multi_service_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Files::MultiService do
+RSpec.describe Files::MultiService, feature_category: :source_code_management do
subject { described_class.new(project, user, commit_params) }
let(:project) { create(:project, :repository) }
@@ -19,6 +19,10 @@ RSpec.describe Files::MultiService do
Gitlab::Git::Commit.last_for_path(project.repository, branch_name, original_file_path).sha
end
+ let(:branch_commit_id) do
+ Gitlab::Git::Commit.find(project.repository, branch_name).sha
+ end
+
let(:default_action) do
{
action: action,
@@ -78,6 +82,16 @@ RSpec.describe Files::MultiService do
end
end
+ context 'when file not changed, but later commit id is used' do
+ let(:actions) { [default_action.merge(last_commit_id: branch_commit_id)] }
+
+ it 'accepts the commit' do
+ results = subject.execute
+
+ expect(results[:status]).to eq(:success)
+ end
+ end
+
context 'when the file have not been modified' do
it 'accepts the commit' do
results = subject.execute
diff --git a/spec/services/files/update_service_spec.rb b/spec/services/files/update_service_spec.rb
index 6d7459e0b29..6a9f9d6b86f 100644
--- a/spec/services/files/update_service_spec.rb
+++ b/spec/services/files/update_service_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Files::UpdateService do
+RSpec.describe Files::UpdateService, feature_category: :source_code_management do
subject { described_class.new(project, user, commit_params) }
let(:project) { create(:project, :repository) }
@@ -31,8 +31,8 @@ RSpec.describe Files::UpdateService do
end
describe "#execute" do
- context "when the file's last commit sha does not match the supplied last_commit_sha" do
- let(:last_commit_sha) { "foo" }
+ context "when the file's last commit sha is earlier than the latest change for that branch" do
+ let(:last_commit_sha) { Gitlab::Git::Commit.last_for_path(project.repository, project.default_branch, file_path).parent_id }
it "returns a hash with the correct error message and a :error status" do
expect { subject.execute }
diff --git a/spec/services/git/base_hooks_service_spec.rb b/spec/services/git/base_hooks_service_spec.rb
index 5afd7b30ab0..8a686a19c4c 100644
--- a/spec/services/git/base_hooks_service_spec.rb
+++ b/spec/services/git/base_hooks_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Git::BaseHooksService do
+RSpec.describe Git::BaseHooksService, feature_category: :source_code_management do
include RepoHelpers
let_it_be(:user) { create(:user) }
@@ -325,4 +325,40 @@ RSpec.describe Git::BaseHooksService do
end
end
end
+
+ describe 'notifying KAS' do
+ let(:kas_enabled) { true }
+
+ before do
+ allow(Gitlab::Kas).to receive(:enabled?).and_return(kas_enabled)
+ end
+
+ it 'enqueues the notification worker' do
+ expect(Clusters::Agents::NotifyGitPushWorker).to receive(:perform_async).with(project.id).once
+
+ subject.execute
+ end
+
+ context 'when KAS is disabled' do
+ let(:kas_enabled) { false }
+
+ it do
+ expect(Clusters::Agents::NotifyGitPushWorker).not_to receive(:perform_async)
+
+ subject.execute
+ end
+ end
+
+ context 'when :notify_kas_on_git_push feature flag is disabled' do
+ before do
+ stub_feature_flags(notify_kas_on_git_push: false)
+ end
+
+ it do
+ expect(Clusters::Agents::NotifyGitPushWorker).not_to receive(:perform_async)
+
+ subject.execute
+ end
+ end
+ end
end
diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb
index 973ead28462..e991b5bd842 100644
--- a/spec/services/git/branch_hooks_service_spec.rb
+++ b/spec/services/git/branch_hooks_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do
+RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state, feature_category: :source_code_management do
include RepoHelpers
include ProjectForksHelper
diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb
index a9f5b07fef4..aa534777f3e 100644
--- a/spec/services/git/branch_push_service_spec.rb
+++ b/spec/services/git/branch_push_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Git::BranchPushService, :use_clean_rails_redis_caching, services: true do
+RSpec.describe Git::BranchPushService, :use_clean_rails_redis_caching, services: true, feature_category: :source_code_management do
include RepoHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/git/process_ref_changes_service_spec.rb b/spec/services/git/process_ref_changes_service_spec.rb
index 8d2da4a899e..9ec13bc957b 100644
--- a/spec/services/git/process_ref_changes_service_spec.rb
+++ b/spec/services/git/process_ref_changes_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Git::ProcessRefChangesService do
+RSpec.describe Git::ProcessRefChangesService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:user) { project.first_owner }
let(:params) { { changes: git_changes } }
diff --git a/spec/services/git/tag_hooks_service_spec.rb b/spec/services/git/tag_hooks_service_spec.rb
index 01a0d2e8600..73f6eff36ba 100644
--- a/spec/services/git/tag_hooks_service_spec.rb
+++ b/spec/services/git/tag_hooks_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Git::TagHooksService, :service do
+RSpec.describe Git::TagHooksService, :service, feature_category: :source_code_management do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
diff --git a/spec/services/git/tag_push_service_spec.rb b/spec/services/git/tag_push_service_spec.rb
index 597254d46fa..0d40c331d11 100644
--- a/spec/services/git/tag_push_service_spec.rb
+++ b/spec/services/git/tag_push_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Git::TagPushService do
+RSpec.describe Git::TagPushService, feature_category: :source_code_management do
include RepoHelpers
let(:user) { create(:user) }
diff --git a/spec/services/git/wiki_push_service/change_spec.rb b/spec/services/git/wiki_push_service/change_spec.rb
index 3616bf62b20..719e67666ce 100644
--- a/spec/services/git/wiki_push_service/change_spec.rb
+++ b/spec/services/git/wiki_push_service/change_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Git::WikiPushService::Change do
+RSpec.describe Git::WikiPushService::Change, feature_category: :source_code_management do
subject { described_class.new(project_wiki, change, raw_change) }
let(:project_wiki) { double('ProjectWiki') }
@@ -60,11 +60,13 @@ RSpec.describe Git::WikiPushService::Change do
end
%i[added renamed modified].each do |op|
- let(:operation) { op }
- let(:slug) { new_path.chomp('.md') }
- let(:revision) { change[:newrev] }
+ context "the operation is #{op}" do
+ let(:operation) { op }
+ let(:slug) { new_path.chomp('.md') }
+ let(:revision) { change[:newrev] }
- it { is_expected.to have_attributes(page: wiki_page) }
+ it { is_expected.to have_attributes(page: wiki_page) }
+ end
end
end
end
diff --git a/spec/services/google_cloud/create_cloudsql_instance_service_spec.rb b/spec/services/google_cloud/create_cloudsql_instance_service_spec.rb
index cd0dd75e576..4f2e0bea623 100644
--- a/spec/services/google_cloud/create_cloudsql_instance_service_spec.rb
+++ b/spec/services/google_cloud/create_cloudsql_instance_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GoogleCloud::CreateCloudsqlInstanceService do
+RSpec.describe GoogleCloud::CreateCloudsqlInstanceService, feature_category: :deployment_management do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:gcp_project_id) { 'gcp_project_120' }
diff --git a/spec/services/google_cloud/create_service_accounts_service_spec.rb b/spec/services/google_cloud/create_service_accounts_service_spec.rb
index 3f500e7c235..3b57f2a9e5f 100644
--- a/spec/services/google_cloud/create_service_accounts_service_spec.rb
+++ b/spec/services/google_cloud/create_service_accounts_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GoogleCloud::CreateServiceAccountsService do
+RSpec.describe GoogleCloud::CreateServiceAccountsService, feature_category: :deployment_management do
describe '#execute' do
before do
mock_google_oauth2_creds = Struct.new(:app_id, :app_secret)
diff --git a/spec/services/google_cloud/enable_cloud_run_service_spec.rb b/spec/services/google_cloud/enable_cloud_run_service_spec.rb
index 6d2b1f5cfd5..3de9e7fcd5c 100644
--- a/spec/services/google_cloud/enable_cloud_run_service_spec.rb
+++ b/spec/services/google_cloud/enable_cloud_run_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GoogleCloud::EnableCloudRunService do
+RSpec.describe GoogleCloud::EnableCloudRunService, feature_category: :deployment_management do
describe 'when a project does not have any gcp projects' do
let_it_be(:project) { create(:project) }
diff --git a/spec/services/google_cloud/enable_cloudsql_service_spec.rb b/spec/services/google_cloud/enable_cloudsql_service_spec.rb
index aa6d2402d7c..b14b827e8b8 100644
--- a/spec/services/google_cloud/enable_cloudsql_service_spec.rb
+++ b/spec/services/google_cloud/enable_cloudsql_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GoogleCloud::EnableCloudsqlService do
+RSpec.describe GoogleCloud::EnableCloudsqlService, feature_category: :deployment_management do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:params) do
diff --git a/spec/services/google_cloud/gcp_region_add_or_replace_service_spec.rb b/spec/services/google_cloud/gcp_region_add_or_replace_service_spec.rb
index b2cd5632be0..a748fed7134 100644
--- a/spec/services/google_cloud/gcp_region_add_or_replace_service_spec.rb
+++ b/spec/services/google_cloud/gcp_region_add_or_replace_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GoogleCloud::GcpRegionAddOrReplaceService do
+RSpec.describe GoogleCloud::GcpRegionAddOrReplaceService, feature_category: :deployment_management do
it 'adds and replaces GCP region vars' do
project = create(:project, :public)
service = described_class.new(project)
diff --git a/spec/services/google_cloud/generate_pipeline_service_spec.rb b/spec/services/google_cloud/generate_pipeline_service_spec.rb
index a78d8ff6661..c18514884ca 100644
--- a/spec/services/google_cloud/generate_pipeline_service_spec.rb
+++ b/spec/services/google_cloud/generate_pipeline_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GoogleCloud::GeneratePipelineService do
+RSpec.describe GoogleCloud::GeneratePipelineService, feature_category: :deployment_management do
describe 'for cloud-run' do
describe 'when there is no existing pipeline' do
let_it_be(:project) { create(:project, :repository) }
diff --git a/spec/services/google_cloud/get_cloudsql_instances_service_spec.rb b/spec/services/google_cloud/get_cloudsql_instances_service_spec.rb
index 4587a5077c0..ed41d0fd487 100644
--- a/spec/services/google_cloud/get_cloudsql_instances_service_spec.rb
+++ b/spec/services/google_cloud/get_cloudsql_instances_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GoogleCloud::GetCloudsqlInstancesService do
+RSpec.describe GoogleCloud::GetCloudsqlInstancesService, feature_category: :deployment_management do
let(:service) { described_class.new(project) }
let(:project) { create(:project) }
diff --git a/spec/services/google_cloud/service_accounts_service_spec.rb b/spec/services/google_cloud/service_accounts_service_spec.rb
index 10e387126a3..c900bf7d300 100644
--- a/spec/services/google_cloud/service_accounts_service_spec.rb
+++ b/spec/services/google_cloud/service_accounts_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GoogleCloud::ServiceAccountsService do
+RSpec.describe GoogleCloud::ServiceAccountsService, feature_category: :deployment_management do
let(:service) { described_class.new(project) }
describe 'find_for_project' do
diff --git a/spec/services/google_cloud/setup_cloudsql_instance_service_spec.rb b/spec/services/google_cloud/setup_cloudsql_instance_service_spec.rb
index 0a0f05ab4be..5095277f61a 100644
--- a/spec/services/google_cloud/setup_cloudsql_instance_service_spec.rb
+++ b/spec/services/google_cloud/setup_cloudsql_instance_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GoogleCloud::SetupCloudsqlInstanceService do
+RSpec.describe GoogleCloud::SetupCloudsqlInstanceService, feature_category: :deployment_management do
let(:random_user) { create(:user) }
let(:project) { create(:project) }
let(:list_databases_empty) { Google::Apis::SqladminV1beta4::ListDatabasesResponse.new(items: []) }
diff --git a/spec/services/gpg_keys/create_service_spec.rb b/spec/services/gpg_keys/create_service_spec.rb
index 9ac56355b4b..d603ce951ec 100644
--- a/spec/services/gpg_keys/create_service_spec.rb
+++ b/spec/services/gpg_keys/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GpgKeys::CreateService do
+RSpec.describe GpgKeys::CreateService, feature_category: :source_code_management do
let(:user) { create(:user) }
let(:params) { attributes_for(:gpg_key) }
diff --git a/spec/services/grafana/proxy_service_spec.rb b/spec/services/grafana/proxy_service_spec.rb
index 99120de3593..7029bab379a 100644
--- a/spec/services/grafana/proxy_service_spec.rb
+++ b/spec/services/grafana/proxy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Grafana::ProxyService do
+RSpec.describe Grafana::ProxyService, feature_category: :metrics do
include ReactiveCachingHelpers
let_it_be(:project) { create(:project) }
diff --git a/spec/services/gravatar_service_spec.rb b/spec/services/gravatar_service_spec.rb
index a6418b02f78..6ccb362cc5c 100644
--- a/spec/services/gravatar_service_spec.rb
+++ b/spec/services/gravatar_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GravatarService do
+RSpec.describe GravatarService, feature_category: :user_profile do
describe '#execute' do
let(:url) { 'http://example.com/avatar?hash=%{hash}&size=%{size}&email=%{email}&username=%{username}' }
diff --git a/spec/services/groups/auto_devops_service_spec.rb b/spec/services/groups/auto_devops_service_spec.rb
index 486a99dd8df..0724e072dab 100644
--- a/spec/services/groups/auto_devops_service_spec.rb
+++ b/spec/services/groups/auto_devops_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Groups::AutoDevopsService, '#execute' do
+RSpec.describe Groups::AutoDevopsService, '#execute', feature_category: :auto_devops do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/groups/autocomplete_service_spec.rb b/spec/services/groups/autocomplete_service_spec.rb
index 00d0ad3b347..9f55322e72d 100644
--- a/spec/services/groups/autocomplete_service_spec.rb
+++ b/spec/services/groups/autocomplete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::AutocompleteService do
+RSpec.describe Groups::AutocompleteService, feature_category: :subgroups do
let_it_be(:group, refind: true) { create(:group, :nested, :private, avatar: fixture_file_upload('spec/fixtures/dk.png')) }
let_it_be(:sub_group) { create(:group, :private, parent: group) }
diff --git a/spec/services/groups/deploy_tokens/create_service_spec.rb b/spec/services/groups/deploy_tokens/create_service_spec.rb
index 0c28075f998..e408c2787d8 100644
--- a/spec/services/groups/deploy_tokens/create_service_spec.rb
+++ b/spec/services/groups/deploy_tokens/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::DeployTokens::CreateService do
+RSpec.describe Groups::DeployTokens::CreateService, feature_category: :deployment_management do
it_behaves_like 'a deploy token creation service' do
let(:entity) { create(:group) }
let(:deploy_token_class) { GroupDeployToken }
diff --git a/spec/services/groups/deploy_tokens/destroy_service_spec.rb b/spec/services/groups/deploy_tokens/destroy_service_spec.rb
index 28e60b12993..c4694758b2f 100644
--- a/spec/services/groups/deploy_tokens/destroy_service_spec.rb
+++ b/spec/services/groups/deploy_tokens/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::DeployTokens::DestroyService do
+RSpec.describe Groups::DeployTokens::DestroyService, feature_category: :deployment_management do
it_behaves_like 'a deploy token deletion service' do
let_it_be(:entity) { create(:group) }
let_it_be(:deploy_token_class) { GroupDeployToken }
diff --git a/spec/services/groups/deploy_tokens/revoke_service_spec.rb b/spec/services/groups/deploy_tokens/revoke_service_spec.rb
index fcf11bbb8e6..c302dd14e3b 100644
--- a/spec/services/groups/deploy_tokens/revoke_service_spec.rb
+++ b/spec/services/groups/deploy_tokens/revoke_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::DeployTokens::RevokeService do
+RSpec.describe Groups::DeployTokens::RevokeService, feature_category: :deployment_management do
let_it_be(:entity) { create(:group) }
let_it_be(:deploy_token) { create(:deploy_token, :group, groups: [entity]) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/groups/group_links/create_service_spec.rb b/spec/services/groups/group_links/create_service_spec.rb
index bfbaedbd06f..ced87421858 100644
--- a/spec/services/groups/group_links/create_service_spec.rb
+++ b/spec/services/groups/group_links/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::GroupLinks::CreateService, '#execute' do
+RSpec.describe Groups::GroupLinks::CreateService, '#execute', feature_category: :subgroups do
let_it_be(:shared_with_group_parent) { create(:group, :private) }
let_it_be(:shared_with_group) { create(:group, :private, parent: shared_with_group_parent) }
let_it_be(:shared_with_group_child) { create(:group, :private, parent: shared_with_group) }
diff --git a/spec/services/groups/group_links/destroy_service_spec.rb b/spec/services/groups/group_links/destroy_service_spec.rb
index a570c28cf8b..5821ec44192 100644
--- a/spec/services/groups/group_links/destroy_service_spec.rb
+++ b/spec/services/groups/group_links/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::GroupLinks::DestroyService, '#execute' do
+RSpec.describe Groups::GroupLinks::DestroyService, '#execute', feature_category: :subgroups do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:shared_group) { create(:group, :private) }
diff --git a/spec/services/groups/group_links/update_service_spec.rb b/spec/services/groups/group_links/update_service_spec.rb
index 31446c8e4bf..f17d2f50a02 100644
--- a/spec/services/groups/group_links/update_service_spec.rb
+++ b/spec/services/groups/group_links/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::GroupLinks::UpdateService, '#execute' do
+RSpec.describe Groups::GroupLinks::UpdateService, '#execute', feature_category: :subgroups do
let(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
@@ -18,7 +18,7 @@ RSpec.describe Groups::GroupLinks::UpdateService, '#execute' do
expires_at: expiry_date }
end
- subject { described_class.new(link).execute(group_link_params) }
+ subject { described_class.new(link, user).execute(group_link_params) }
before do
group.add_developer(group_member_user)
diff --git a/spec/services/groups/import_export/export_service_spec.rb b/spec/services/groups/import_export/export_service_spec.rb
index ec42a728409..c44c2e3b911 100644
--- a/spec/services/groups/import_export/export_service_spec.rb
+++ b/spec/services/groups/import_export/export_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::ImportExport::ExportService do
+RSpec.describe Groups::ImportExport::ExportService, feature_category: :importers do
describe '#async_execute' do
let(:user) { create(:user) }
let(:group) { create(:group) }
diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb
index 972b12d7ee5..75db6e26cbf 100644
--- a/spec/services/groups/import_export/import_service_spec.rb
+++ b/spec/services/groups/import_export/import_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::ImportExport::ImportService do
+RSpec.describe Groups::ImportExport::ImportService, feature_category: :importers do
describe '#async_execute' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
diff --git a/spec/services/groups/merge_requests_count_service_spec.rb b/spec/services/groups/merge_requests_count_service_spec.rb
index 8bd350d6f0e..32c4c618eda 100644
--- a/spec/services/groups/merge_requests_count_service_spec.rb
+++ b/spec/services/groups/merge_requests_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::MergeRequestsCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Groups::MergeRequestsCountService, :use_clean_rails_memory_store_caching, feature_category: :subgroups do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :repository, namespace: group) }
diff --git a/spec/services/groups/nested_create_service_spec.rb b/spec/services/groups/nested_create_service_spec.rb
index a43c1d8d9c3..476bc2aa23c 100644
--- a/spec/services/groups/nested_create_service_spec.rb
+++ b/spec/services/groups/nested_create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::NestedCreateService do
+RSpec.describe Groups::NestedCreateService, feature_category: :subgroups do
let(:user) { create(:user) }
subject(:service) { described_class.new(user, params) }
diff --git a/spec/services/groups/open_issues_count_service_spec.rb b/spec/services/groups/open_issues_count_service_spec.rb
index 923caa6c150..725b913bf15 100644
--- a/spec/services/groups/open_issues_count_service_spec.rb
+++ b/spec/services/groups/open_issues_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_caching, feature_category: :subgroups do
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/groups/participants_service_spec.rb b/spec/services/groups/participants_service_spec.rb
index 750aead277f..37966a523c2 100644
--- a/spec/services/groups/participants_service_spec.rb
+++ b/spec/services/groups/participants_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::ParticipantsService do
+RSpec.describe Groups::ParticipantsService, feature_category: :subgroups do
describe '#group_members' do
let(:user) { create(:user) }
let(:parent_group) { create(:group) }
diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb
index 10399bed655..d6eb060ea7e 100644
--- a/spec/services/groups/transfer_service_spec.rb
+++ b/spec/services/groups/transfer_service_spec.rb
@@ -35,10 +35,10 @@ RSpec.describe Groups::TransferService, :sidekiq_inline, feature_category: :subg
end
context 'handling packages' do
- let_it_be(:group) { create(:group, :public) }
- let_it_be(:new_group) { create(:group, :public) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:new_group) { create(:group) }
- let(:project) { create(:project, :public, namespace: group) }
+ let_it_be(:project) { create(:project, namespace: group) }
before do
group.add_owner(user)
@@ -46,46 +46,63 @@ RSpec.describe Groups::TransferService, :sidekiq_inline, feature_category: :subg
end
context 'with an npm package' do
- before do
- create(:npm_package, project: project)
- end
+ let_it_be(:npm_package) { create(:npm_package, project: project, name: "@testscope/test") }
- shared_examples 'transfer not allowed' do
- it 'does not allow transfer when there is a root namespace change' do
+ shared_examples 'transfer allowed' do
+ it 'allows transfer' do
transfer_service.execute(new_group)
- expect(transfer_service.error).to eq('Transfer failed: Group contains projects with NPM packages.')
- expect(group.parent).not_to eq(new_group)
+ expect(transfer_service.error).to be nil
+ expect(group.parent).to eq(new_group)
end
end
- it_behaves_like 'transfer not allowed'
+ it_behaves_like 'transfer allowed'
context 'with a project within subgroup' do
let_it_be(:root_group) { create(:group) }
let_it_be(:group) { create(:group, parent: root_group) }
+ let_it_be(:project) { create(:project, namespace: group) }
before do
root_group.add_owner(user)
end
- it_behaves_like 'transfer not allowed'
+ it_behaves_like 'transfer allowed'
context 'without a root namespace change' do
- let(:new_group) { create(:group, parent: root_group) }
+ let_it_be(:new_group) { create(:group, parent: root_group) }
+
+ it_behaves_like 'transfer allowed'
+ end
+
+ context 'with namespaced packages present' do
+ let_it_be(:package) { create(:npm_package, project: project, name: "@#{project.root_namespace.path}/test") }
- it 'allows transfer' do
+ it 'does not allow transfer' do
transfer_service.execute(new_group)
- expect(transfer_service.error).to be nil
- expect(group.parent).to eq(new_group)
+ expect(transfer_service.error).to eq('Transfer failed: Group contains projects with NPM packages scoped to the current root level group.')
+ expect(group.parent).not_to eq(new_group)
+ end
+
+ context 'namespaced package is pending destruction' do
+ let!(:group) { create(:group) }
+
+ before do
+ package.pending_destruction!
+ end
+
+ it_behaves_like 'transfer allowed'
end
end
context 'when transferring a group into a root group' do
- let(:new_group) { nil }
+ let_it_be(:root_group) { create(:group) }
+ let_it_be(:group) { create(:group, parent: root_group) }
+ let_it_be(:new_group) { nil }
- it_behaves_like 'transfer not allowed'
+ it_behaves_like 'transfer allowed'
end
end
end
@@ -458,7 +475,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline, feature_category: :subg
it 'updates projects path' do
new_parent_path = new_parent_group.path
group.projects.each do |project|
- expect(project.full_path).to eq("#{new_parent_path}/#{group.path}/#{project.name}")
+ expect(project.full_path).to eq("#{new_parent_path}/#{group.path}/#{project.path}")
end
end
@@ -525,7 +542,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline, feature_category: :subg
it 'updates projects path' do
new_parent_path = new_parent_group.path
group.projects.each do |project|
- expect(project.full_path).to eq("#{new_parent_path}/#{group.path}/#{project.name}")
+ expect(project.full_path).to eq("#{new_parent_path}/#{group.path}/#{project.path}")
end
end
@@ -576,7 +593,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline, feature_category: :subg
it 'updates projects path' do
new_parent_path = "#{new_parent_group.path}/#{group.path}"
subgroup1.projects.each do |project|
- project_full_path = "#{new_parent_path}/#{project.namespace.path}/#{project.name}"
+ project_full_path = "#{new_parent_path}/#{project.namespace.path}/#{project.path}"
expect(project.full_path).to eq(project_full_path)
end
end
@@ -890,7 +907,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline, feature_category: :subg
let(:subsub_project) { create(:project, group: subsubgroup) }
let!(:contacts) { create_list(:contact, 4, group: root_group) }
- let!(:organizations) { create_list(:organization, 2, group: root_group) }
+ let!(:organizations) { create_list(:crm_organization, 2, group: root_group) }
before do
create(:issue_customer_relations_contact, contact: contacts[0], issue: create(:issue, project: root_project))
diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb
index c758d3d5477..6baa8e5d6b6 100644
--- a/spec/services/groups/update_service_spec.rb
+++ b/spec/services/groups/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::UpdateService do
+RSpec.describe Groups::UpdateService, feature_category: :subgroups do
let!(:user) { create(:user) }
let!(:private_group) { create(:group, :private) }
let!(:internal_group) { create(:group, :internal) }
diff --git a/spec/services/groups/update_shared_runners_service_spec.rb b/spec/services/groups/update_shared_runners_service_spec.rb
index a29f73a71c2..48c81f109aa 100644
--- a/spec/services/groups/update_shared_runners_service_spec.rb
+++ b/spec/services/groups/update_shared_runners_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::UpdateSharedRunnersService do
+RSpec.describe Groups::UpdateSharedRunnersService, feature_category: :subgroups do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:params) { {} }
diff --git a/spec/services/groups/update_statistics_service_spec.rb b/spec/services/groups/update_statistics_service_spec.rb
index 84b18b670a7..13a88839de0 100644
--- a/spec/services/groups/update_statistics_service_spec.rb
+++ b/spec/services/groups/update_statistics_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::UpdateStatisticsService do
+RSpec.describe Groups::UpdateStatisticsService, feature_category: :subgroups do
let_it_be(:group, reload: true) { create(:group) }
let(:statistics) { %w(wiki_size) }
diff --git a/spec/services/ide/base_config_service_spec.rb b/spec/services/ide/base_config_service_spec.rb
index ee57f2c18ec..ac57a13d7fc 100644
--- a/spec/services/ide/base_config_service_spec.rb
+++ b/spec/services/ide/base_config_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ide::BaseConfigService do
+RSpec.describe Ide::BaseConfigService, feature_category: :web_ide do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/ide/schemas_config_service_spec.rb b/spec/services/ide/schemas_config_service_spec.rb
index f277b8e9954..b6f229edc78 100644
--- a/spec/services/ide/schemas_config_service_spec.rb
+++ b/spec/services/ide/schemas_config_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ide::SchemasConfigService do
+RSpec.describe Ide::SchemasConfigService, feature_category: :web_ide do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/ide/terminal_config_service_spec.rb b/spec/services/ide/terminal_config_service_spec.rb
index 73614f28b06..76c8d9f2e6f 100644
--- a/spec/services/ide/terminal_config_service_spec.rb
+++ b/spec/services/ide/terminal_config_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ide::TerminalConfigService do
+RSpec.describe Ide::TerminalConfigService, feature_category: :web_ide do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/import/bitbucket_server_service_spec.rb b/spec/services/import/bitbucket_server_service_spec.rb
index 555812ca9cf..ca554fb01c3 100644
--- a/spec/services/import/bitbucket_server_service_spec.rb
+++ b/spec/services/import/bitbucket_server_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::BitbucketServerService do
+RSpec.describe Import::BitbucketServerService, feature_category: :importers do
let_it_be(:user) { create(:user) }
let(:base_uri) { "https://test:7990" }
@@ -93,7 +93,7 @@ RSpec.describe Import::BitbucketServerService do
result = subject.execute(credentials)
expect(result).to include(
- message: "You don't have permissions to create this project",
+ message: "You don't have permissions to import this project",
status: :error,
http_status: :unauthorized
)
diff --git a/spec/services/import/fogbugz_service_spec.rb b/spec/services/import/fogbugz_service_spec.rb
index 027d0240a7a..e9c676dcd23 100644
--- a/spec/services/import/fogbugz_service_spec.rb
+++ b/spec/services/import/fogbugz_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::FogbugzService do
+RSpec.describe Import::FogbugzService, feature_category: :importers do
let_it_be(:user) { create(:user) }
let(:base_uri) { "https://test:7990" }
@@ -18,6 +18,7 @@ RSpec.describe Import::FogbugzService do
before do
allow(subject).to receive(:authorized?).and_return(true)
+ stub_application_setting(import_sources: ['fogbugz'])
end
context 'when no repo is found' do
@@ -61,7 +62,7 @@ RSpec.describe Import::FogbugzService do
result = subject.execute(credentials)
expect(result).to include(
- message: "You don't have permissions to create this project",
+ message: "You don't have permissions to import this project",
status: :error,
http_status: :unauthorized
)
diff --git a/spec/services/import/github/cancel_project_import_service_spec.rb b/spec/services/import/github/cancel_project_import_service_spec.rb
index 77b8771ee65..d8ea303fa50 100644
--- a/spec/services/import/github/cancel_project_import_service_spec.rb
+++ b/spec/services/import/github/cancel_project_import_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::Github::CancelProjectImportService do
+RSpec.describe Import::Github::CancelProjectImportService, feature_category: :importers do
subject(:import_cancel) { described_class.new(project, project.owner) }
let_it_be(:user) { create(:user) }
@@ -14,6 +14,18 @@ RSpec.describe Import::Github::CancelProjectImportService do
it 'update import state to be canceled' do
expect(import_cancel.execute).to eq({ status: :success, project: project })
end
+
+ it 'tracks canceled imports' do
+ metrics_double = instance_double('Gitlab::Import::Metrics')
+
+ expect(Gitlab::Import::Metrics)
+ .to receive(:new)
+ .with(:github_importer, project)
+ .and_return(metrics_double)
+ expect(metrics_double).to receive(:track_canceled_import)
+
+ import_cancel.execute
+ end
end
context 'when import is finished' do
diff --git a/spec/services/import/github/notes/create_service_spec.rb b/spec/services/import/github/notes/create_service_spec.rb
index 57699def848..37cb903b66e 100644
--- a/spec/services/import/github/notes/create_service_spec.rb
+++ b/spec/services/import/github/notes/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::Github::Notes::CreateService do
+RSpec.describe Import::Github::Notes::CreateService, feature_category: :importers do
it 'does not support quick actions' do
project = create(:project, :repository)
user = create(:user)
diff --git a/spec/services/import/github_service_spec.rb b/spec/services/import/github_service_spec.rb
index 293e247c140..fa8b2489599 100644
--- a/spec/services/import/github_service_spec.rb
+++ b/spec/services/import/github_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::GithubService do
+RSpec.describe Import::GithubService, feature_category: :importers do
let_it_be(:user) { create(:user) }
let_it_be(:token) { 'complex-token' }
let_it_be(:access_params) { { github_access_token: 'github-complex-token' } }
@@ -152,7 +152,8 @@ RSpec.describe Import::GithubService do
{
single_endpoint_issue_events_import: true,
single_endpoint_notes_import: 'false',
- attachments_import: false
+ attachments_import: false,
+ collaborators_import: true
}
end
@@ -291,7 +292,7 @@ RSpec.describe Import::GithubService do
{
status: :error,
http_status: :unprocessable_entity,
- message: 'This namespace has already been taken. Choose a different one.'
+ message: 'You are not allowed to import projects in this namespace.'
}
end
end
diff --git a/spec/services/import/gitlab_projects/create_project_service_spec.rb b/spec/services/import/gitlab_projects/create_project_service_spec.rb
index 59c384bad3c..a77e9bdfce1 100644
--- a/spec/services/import/gitlab_projects/create_project_service_spec.rb
+++ b/spec/services/import/gitlab_projects/create_project_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Import::GitlabProjects::CreateProjectService, :aggregate_failures do
+RSpec.describe ::Import::GitlabProjects::CreateProjectService, :aggregate_failures, feature_category: :importers do
let(:fake_file_acquisition_strategy) do
Class.new do
attr_reader :errors
@@ -35,6 +35,7 @@ RSpec.describe ::Import::GitlabProjects::CreateProjectService, :aggregate_failur
before do
stub_const('FakeStrategy', fake_file_acquisition_strategy)
+ stub_application_setting(import_sources: ['gitlab_project'])
end
describe 'validation' do
@@ -127,7 +128,7 @@ RSpec.describe ::Import::GitlabProjects::CreateProjectService, :aggregate_failur
expect(response.payload).to eq(other_errors: [])
end
- context 'when the project contains multilple errors' do
+ context 'when the project contains multiple errors' do
it 'fails to create a project' do
params.merge!(name: '_ an invalid name _', path: '_ an invalid path _')
@@ -137,10 +138,13 @@ RSpec.describe ::Import::GitlabProjects::CreateProjectService, :aggregate_failur
expect(response).to be_error
expect(response.http_status).to eq(:bad_request)
- expect(response.message)
- .to eq(%{Project namespace path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'})
+ expect(response.message).to eq(
+ 'Project namespace path must not start or end with a special character and must not contain consecutive ' \
+ 'special characters.'
+ )
expect(response.payload).to eq(
other_errors: [
+ %{Project namespace path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'},
%{Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'},
%{Path must not start or end with a special character and must not contain consecutive special characters.}
])
diff --git a/spec/services/import/gitlab_projects/file_acquisition_strategies/file_upload_spec.rb b/spec/services/import/gitlab_projects/file_acquisition_strategies/file_upload_spec.rb
index 3c788138157..3a94ed02dd5 100644
--- a/spec/services/import/gitlab_projects/file_acquisition_strategies/file_upload_spec.rb
+++ b/spec/services/import/gitlab_projects/file_acquisition_strategies/file_upload_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Import::GitlabProjects::FileAcquisitionStrategies::FileUpload, :aggregate_failures do
+RSpec.describe ::Import::GitlabProjects::FileAcquisitionStrategies::FileUpload, :aggregate_failures, feature_category: :importers do
let(:file) { UploadedFile.new(File.join('spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz')) }
describe 'validation' do
diff --git a/spec/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3_spec.rb b/spec/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3_spec.rb
index d9042e95149..411e2ec5286 100644
--- a/spec/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3_spec.rb
+++ b/spec/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Import::GitlabProjects::FileAcquisitionStrategies::RemoteFileS3, :aggregate_failures do
+RSpec.describe ::Import::GitlabProjects::FileAcquisitionStrategies::RemoteFileS3, :aggregate_failures, feature_category: :importers do
let(:region_name) { 'region_name' }
let(:bucket_name) { 'bucket_name' }
let(:file_key) { 'file_key' }
diff --git a/spec/services/import/prepare_service_spec.rb b/spec/services/import/prepare_service_spec.rb
index 0097198f7a9..fcb90575d96 100644
--- a/spec/services/import/prepare_service_spec.rb
+++ b/spec/services/import/prepare_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::PrepareService do
+RSpec.describe Import::PrepareService, feature_category: :importers do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/import/validate_remote_git_endpoint_service_spec.rb b/spec/services/import/validate_remote_git_endpoint_service_spec.rb
index 221ac2cd73a..1d2b3975832 100644
--- a/spec/services/import/validate_remote_git_endpoint_service_spec.rb
+++ b/spec/services/import/validate_remote_git_endpoint_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::ValidateRemoteGitEndpointService do
+RSpec.describe Import::ValidateRemoteGitEndpointService, feature_category: :importers do
include StubRequests
let_it_be(:base_url) { 'http://demo.host/path' }
@@ -35,6 +35,28 @@ RSpec.describe Import::ValidateRemoteGitEndpointService do
end
end
+ context 'when uri is using an invalid protocol' do
+ subject { described_class.new(url: 'ssh://demo.host/repo') }
+
+ it 'reports error when invalid URL is provided' do
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ end
+ end
+
+ context 'when uri is invalid' do
+ subject { described_class.new(url: 'http:example.com') }
+
+ it 'reports error when invalid URL is provided' do
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result.error?).to be(true)
+ end
+ end
+
context 'when receiving HTTP response' do
subject { described_class.new(url: base_url) }
diff --git a/spec/services/import_csv/base_service_spec.rb b/spec/services/import_csv/base_service_spec.rb
index 0c0ed40ff4d..93fff0d546a 100644
--- a/spec/services/import_csv/base_service_spec.rb
+++ b/spec/services/import_csv/base_service_spec.rb
@@ -24,40 +24,65 @@ RSpec.describe ImportCsv::BaseService, feature_category: :importers do
it_behaves_like 'abstract method', :validate_headers_presence!, "any"
it_behaves_like 'abstract method', :create_object_class
- describe '#detect_col_sep' do
- context 'when header contains invalid separators' do
- it 'raises error' do
- header = 'Name&email'
+ context 'when given a class' do
+ let(:importer_klass) do
+ Class.new(described_class) do
+ def attributes_for(row)
+ { title: row[:title] }
+ end
- expect { subject.send(:detect_col_sep, header) }.to raise_error(CSV::MalformedCSVError)
- end
- end
+ def validate_headers_presence!(headers)
+ raise CSV::MalformedCSVError.new("Missing required headers", 1) unless headers.present?
+ end
- context 'when header is valid' do
- shared_examples 'header with valid separators' do
- let(:header) { "Name#{separator}email" }
+ def create_object_class
+ Class.new
+ end
- it 'returns separator value' do
- expect(subject.send(:detect_col_sep, header)).to eq(separator)
+ def email_results_to_user
+ # no-op
end
end
+ end
- context 'with ; as separator' do
- let(:separator) { ';' }
+ let(:service) do
+ uploader = FileUploader.new(project)
+ uploader.store!(file)
- it_behaves_like 'header with valid separators'
- end
+ importer_klass.new(user, project, uploader)
+ end
+
+ subject { service.execute }
+
+ it_behaves_like 'correctly handles invalid files'
+
+ describe '#detect_col_sep' do
+ using RSpec::Parameterized::TableSyntax
- context 'with \t as separator' do
- let(:separator) { "\t" }
+ let(:file) { double }
- it_behaves_like 'header with valid separators'
+ before do
+ allow(service).to receive_message_chain('csv_data.lines.first').and_return(header)
end
- context 'with , as separator' do
- let(:separator) { ',' }
+ where(:sep_character, :valid) do
+ '&' | false
+ '?' | false
+ ';' | true
+ ',' | true
+ "\t" | true
+ end
+
+ with_them do
+ let(:header) { "Name#{sep_character}email" }
- it_behaves_like 'header with valid separators'
+ it 'responds appropriately' do
+ if valid
+ expect(service.send(:detect_col_sep)).to eq sep_character
+ else
+ expect { service.send(:detect_col_sep) }.to raise_error(CSV::MalformedCSVError)
+ end
+ end
end
end
end
diff --git a/spec/services/import_export_clean_up_service_spec.rb b/spec/services/import_export_clean_up_service_spec.rb
index 2bcdfa6dd8f..7b638b4948b 100644
--- a/spec/services/import_export_clean_up_service_spec.rb
+++ b/spec/services/import_export_clean_up_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ImportExportCleanUpService do
+RSpec.describe ImportExportCleanUpService, feature_category: :importers do
describe '#execute' do
let(:service) { described_class.new }
diff --git a/spec/services/incident_management/incidents/create_service_spec.rb b/spec/services/incident_management/incidents/create_service_spec.rb
index 7db762b9c5b..e6ded379434 100644
--- a/spec/services/incident_management/incidents/create_service_spec.rb
+++ b/spec/services/incident_management/incidents/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::Incidents::CreateService do
+RSpec.describe IncidentManagement::Incidents::CreateService, feature_category: :incident_management do
let_it_be(:project) { create(:project) }
let_it_be(:user) { User.alert_bot }
diff --git a/spec/services/incident_management/issuable_escalation_statuses/after_update_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/after_update_service_spec.rb
index 4b0c8d9113c..9b1994af1bb 100644
--- a/spec/services/incident_management/issuable_escalation_statuses/after_update_service_spec.rb
+++ b/spec/services/incident_management/issuable_escalation_statuses/after_update_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::IssuableEscalationStatuses::AfterUpdateService do
+RSpec.describe IncidentManagement::IssuableEscalationStatuses::AfterUpdateService,
+ feature_category: :incident_management do
let_it_be(:current_user) { create(:user) }
let_it_be(:escalation_status, reload: true) { create(:incident_management_issuable_escalation_status, :triggered) }
let_it_be(:issue, reload: true) { escalation_status.issue }
diff --git a/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb
index b5c5238d483..56a159f452c 100644
--- a/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb
+++ b/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::IssuableEscalationStatuses::BuildService do
+RSpec.describe IncidentManagement::IssuableEscalationStatuses::BuildService, feature_category: :incident_management do
let_it_be(:project) { create(:project) }
let_it_be(:incident, reload: true) { create(:incident, project: project) }
diff --git a/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb
index b6ae03a19fe..e6c63d63123 100644
--- a/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb
+++ b/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::IssuableEscalationStatuses::CreateService do
+RSpec.describe IncidentManagement::IssuableEscalationStatuses::CreateService, feature_category: :incident_management do
let_it_be(:project) { create(:project) }
let(:incident) { create(:incident, project: project) }
diff --git a/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb
index e8208c410d5..3f3174d0112 100644
--- a/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb
+++ b/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService, factory_default: :keep do
+RSpec.describe IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService, factory_default: :keep,
+ feature_category: :incident_management do
let_it_be(:project) { create_default(:project) }
let_it_be(:escalation_status) { create(:incident_management_issuable_escalation_status, :triggered) }
let_it_be(:user_with_permissions) { create(:user) }
diff --git a/spec/services/incident_management/pager_duty/create_incident_issue_service_spec.rb b/spec/services/incident_management/pager_duty/create_incident_issue_service_spec.rb
index 2fda789cf56..caa5ee495b7 100644
--- a/spec/services/incident_management/pager_duty/create_incident_issue_service_spec.rb
+++ b/spec/services/incident_management/pager_duty/create_incident_issue_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::PagerDuty::CreateIncidentIssueService do
+RSpec.describe IncidentManagement::PagerDuty::CreateIncidentIssueService, feature_category: :incident_management do
let_it_be(:project, reload: true) { create(:project) }
let_it_be(:user) { User.alert_bot }
diff --git a/spec/services/incident_management/pager_duty/process_webhook_service_spec.rb b/spec/services/incident_management/pager_duty/process_webhook_service_spec.rb
index e2aba0b61af..06f423bc63c 100644
--- a/spec/services/incident_management/pager_duty/process_webhook_service_spec.rb
+++ b/spec/services/incident_management/pager_duty/process_webhook_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::PagerDuty::ProcessWebhookService do
+RSpec.describe IncidentManagement::PagerDuty::ProcessWebhookService, feature_category: :incident_management do
let_it_be(:project, reload: true) { create(:project) }
describe '#execute' do
diff --git a/spec/services/incident_management/timeline_event_tags/create_service_spec.rb b/spec/services/incident_management/timeline_event_tags/create_service_spec.rb
index c1b993ce3d9..b21a116d5f9 100644
--- a/spec/services/incident_management/timeline_event_tags/create_service_spec.rb
+++ b/spec/services/incident_management/timeline_event_tags/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::TimelineEventTags::CreateService do
+RSpec.describe IncidentManagement::TimelineEventTags::CreateService, feature_category: :incident_management do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be_with_reload(:project) { create(:project) }
diff --git a/spec/services/incident_management/timeline_events/create_service_spec.rb b/spec/services/incident_management/timeline_events/create_service_spec.rb
index fa5f4c64a43..fff6241f083 100644
--- a/spec/services/incident_management/timeline_events/create_service_spec.rb
+++ b/spec/services/incident_management/timeline_events/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::TimelineEvents::CreateService do
+RSpec.describe IncidentManagement::TimelineEvents::CreateService, feature_category: :incident_management do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be(:project) { create(:project) }
@@ -57,7 +57,6 @@ RSpec.describe IncidentManagement::TimelineEvents::CreateService do
it_behaves_like 'an incident management tracked event', :incident_management_timeline_event_created
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace.reload }
let(:category) { described_class.to_s }
let(:user) { current_user }
@@ -286,7 +285,6 @@ RSpec.describe IncidentManagement::TimelineEvents::CreateService do
it_behaves_like 'an incident management tracked event', :incident_management_timeline_event_created
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace.reload }
let(:category) { described_class.to_s }
let(:user) { current_user }
diff --git a/spec/services/incident_management/timeline_events/destroy_service_spec.rb b/spec/services/incident_management/timeline_events/destroy_service_spec.rb
index f90ff72a2bf..78f6659beec 100644
--- a/spec/services/incident_management/timeline_events/destroy_service_spec.rb
+++ b/spec/services/incident_management/timeline_events/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::TimelineEvents::DestroyService do
+RSpec.describe IncidentManagement::TimelineEvents::DestroyService, feature_category: :incident_management do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be(:project) { create(:project) }
@@ -67,7 +67,6 @@ RSpec.describe IncidentManagement::TimelineEvents::DestroyService do
it_behaves_like 'an incident management tracked event', :incident_management_timeline_event_deleted
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace.reload }
let(:category) { described_class.to_s }
let(:user) { current_user }
diff --git a/spec/services/incident_management/timeline_events/update_service_spec.rb b/spec/services/incident_management/timeline_events/update_service_spec.rb
index ebaa4dde7a2..c38126baa65 100644
--- a/spec/services/incident_management/timeline_events/update_service_spec.rb
+++ b/spec/services/incident_management/timeline_events/update_service_spec.rb
@@ -50,7 +50,6 @@ RSpec.describe IncidentManagement::TimelineEvents::UpdateService, feature_catego
it_behaves_like 'an incident management tracked event', :incident_management_timeline_event_edited
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace.reload }
let(:category) { described_class.to_s }
let(:action) { 'incident_management_timeline_event_edited' }
diff --git a/spec/services/integrations/propagate_service_spec.rb b/spec/services/integrations/propagate_service_spec.rb
index c971c4a0ad0..0267b1b0ed0 100644
--- a/spec/services/integrations/propagate_service_spec.rb
+++ b/spec/services/integrations/propagate_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Integrations::PropagateService do
+RSpec.describe Integrations::PropagateService, feature_category: :integrations do
describe '.propagate' do
include JiraIntegrationHelpers
diff --git a/spec/services/integrations/slack_event_service_spec.rb b/spec/services/integrations/slack_event_service_spec.rb
new file mode 100644
index 00000000000..17433aee329
--- /dev/null
+++ b/spec/services/integrations/slack_event_service_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackEventService, feature_category: :integrations do
+ describe '#execute' do
+ subject(:execute) { described_class.new(params).execute }
+
+ let(:params) do
+ {
+ type: 'event_callback',
+ event: {
+ type: 'app_home_opened',
+ foo: 'bar'
+ }
+ }
+ end
+
+ it 'queues a worker and returns success response' do
+ expect(Integrations::SlackEventWorker).to receive(:perform_async)
+ .with(
+ {
+ slack_event: 'app_home_opened',
+ params: {
+ event: {
+ foo: 'bar'
+ }
+ }
+ }
+ )
+ expect(execute.payload).to eq({})
+ is_expected.to be_success
+ end
+
+ context 'when event a url verification request' do
+ let(:params) { { type: 'url_verification', foo: 'bar' } }
+
+ it 'executes the service instead of queueing a worker and returns success response' do
+ expect(Integrations::SlackEventWorker).not_to receive(:perform_async)
+ expect_next_instance_of(Integrations::SlackEvents::UrlVerificationService, { foo: 'bar' }) do |service|
+ expect(service).to receive(:execute).and_return({ baz: 'qux' })
+ end
+ expect(execute.payload).to eq({ baz: 'qux' })
+ is_expected.to be_success
+ end
+ end
+
+ context 'when event is unknown' do
+ let(:params) { super().merge(event: { type: 'foo' }) }
+
+ it 'raises an error' do
+ expect { execute }.to raise_error(described_class::UnknownEventError)
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_events/app_home_opened_service_spec.rb b/spec/services/integrations/slack_events/app_home_opened_service_spec.rb
new file mode 100644
index 00000000000..0eb4c019e0a
--- /dev/null
+++ b/spec/services/integrations/slack_events/app_home_opened_service_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackEvents::AppHomeOpenedService, feature_category: :integrations do
+ describe '#execute' do
+ let_it_be(:slack_installation) { create(:slack_integration) }
+
+ let(:slack_workspace_id) { slack_installation.team_id }
+ let(:slack_user_id) { 'U0123ABCDEF' }
+ let(:api_url) { "#{Slack::API::BASE_URL}/views.publish" }
+ let(:api_response) { { ok: true } }
+ let(:params) do
+ {
+ team_id: slack_workspace_id,
+ event: { user: slack_user_id },
+ event_id: 'Ev03SA75UJKB'
+ }
+ end
+
+ subject(:execute) { described_class.new(params).execute }
+
+ before do
+ stub_request(:post, api_url)
+ .to_return(
+ status: 200,
+ body: api_response.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ shared_examples 'there is no bot token' do
+ it 'does not call the Slack API, logs info, and returns a success response' do
+ expect(Gitlab::IntegrationsLogger).to receive(:info).with(
+ {
+ slack_user_id: slack_user_id,
+ slack_workspace_id: slack_workspace_id,
+ message: 'SlackInstallation record has no bot token'
+ }
+ )
+
+ is_expected.to be_success
+ end
+ end
+
+ it 'calls the Slack API correctly and returns a success response' do
+ mock_view = { type: 'home', blocks: [] }
+
+ expect_next_instance_of(Slack::BlockKit::AppHomeOpened) do |ui|
+ expect(ui).to receive(:build).and_return(mock_view)
+ end
+
+ is_expected.to be_success
+
+ expect(WebMock).to have_requested(:post, api_url).with(
+ body: {
+ user_id: slack_user_id,
+ view: mock_view
+ },
+ headers: {
+ 'Authorization' => "Bearer #{slack_installation.bot_access_token}",
+ 'Content-Type' => 'application/json; charset=utf-8'
+ })
+ end
+
+ context 'when the slack installation is a legacy record' do
+ let_it_be(:slack_installation) { create(:slack_integration, :legacy) }
+
+ it_behaves_like 'there is no bot token'
+ end
+
+ context 'when the slack installation cannot be found' do
+ let(:slack_workspace_id) { non_existing_record_id }
+
+ it_behaves_like 'there is no bot token'
+ end
+
+ context 'when the Slack API call raises an HTTP exception' do
+ before do
+ allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED, 'error message')
+ end
+
+ it 'tracks the exception and returns an error response' do
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ Errno::ECONNREFUSED.new('HTTP exception when calling Slack API'),
+ {
+ slack_user_id: slack_user_id,
+ slack_workspace_id: slack_workspace_id
+ }
+ )
+ is_expected.to be_error
+ end
+ end
+
+ context 'when the Slack API returns an error' do
+ let(:api_response) { { ok: false, foo: 'bar' } }
+
+ it 'tracks the exception and returns an error response' do
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ StandardError.new('Slack API returned an error'),
+ {
+ slack_user_id: slack_user_id,
+ slack_workspace_id: slack_workspace_id,
+ response: api_response.with_indifferent_access
+ }
+ )
+ is_expected.to be_error
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_events/url_verification_service_spec.rb b/spec/services/integrations/slack_events/url_verification_service_spec.rb
new file mode 100644
index 00000000000..0d668acafb9
--- /dev/null
+++ b/spec/services/integrations/slack_events/url_verification_service_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackEvents::UrlVerificationService, feature_category: :integrations do
+ describe '#execute' do
+ it 'returns the challenge' do
+ expect(described_class.new({ challenge: 'foo' }).execute).to eq({ challenge: 'foo' })
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_interaction_service_spec.rb b/spec/services/integrations/slack_interaction_service_spec.rb
new file mode 100644
index 00000000000..599320c7986
--- /dev/null
+++ b/spec/services/integrations/slack_interaction_service_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackInteractionService, feature_category: :integrations do
+ describe '#execute' do
+ subject(:execute) { described_class.new(params).execute }
+
+ let(:params) do
+ {
+ type: slack_interaction,
+ foo: 'bar'
+ }
+ end
+
+ context 'when view is closed' do
+ let(:slack_interaction) { 'view_closed' }
+
+ it 'executes the correct service' do
+ view_closed_service = described_class::INTERACTIONS['view_closed']
+
+ expect_next_instance_of(view_closed_service, { foo: 'bar' }) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ execute
+ end
+ end
+
+ context 'when view is submitted' do
+ let(:slack_interaction) { 'view_submission' }
+
+ it 'executes the submission service' do
+ view_submission_service = described_class::INTERACTIONS['view_submission']
+
+ expect_next_instance_of(view_submission_service, { foo: 'bar' }) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ execute
+ end
+ end
+
+ context 'when block action service is submitted' do
+ let(:slack_interaction) { 'block_actions' }
+
+ it 'executes the block actions service' do
+ block_action_service = described_class::INTERACTIONS['block_actions']
+
+ expect_next_instance_of(block_action_service, { foo: 'bar' }) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ execute
+ end
+ end
+
+ context 'when slack_interaction is not known' do
+ let(:slack_interaction) { 'foo' }
+
+ it 'raises an error and does not execute a service class' do
+ described_class::INTERACTIONS.each_value do |service_class|
+ expect(service_class).not_to receive(:new)
+ end
+
+ expect { execute }.to raise_error(described_class::UnknownInteractionError)
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_interactions/block_action_service_spec.rb b/spec/services/integrations/slack_interactions/block_action_service_spec.rb
new file mode 100644
index 00000000000..9a188ddcfe4
--- /dev/null
+++ b/spec/services/integrations/slack_interactions/block_action_service_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackInteractions::BlockActionService, feature_category: :integrations do
+ describe '#execute' do
+ let_it_be(:slack_installation) { create(:slack_integration) }
+
+ let(:params) do
+ {
+ view: {
+ team_id: slack_installation.team_id
+ },
+ actions: [{
+ action_id: action_id
+ }]
+ }
+ end
+
+ subject(:execute) { described_class.new(params).execute }
+
+ context 'when action_id is incident_management_project' do
+ let(:action_id) { 'incident_management_project' }
+
+ it 'executes the correct handler' do
+ project_handler = described_class::ALLOWED_UPDATES_HANDLERS['incident_management_project']
+
+ expect_next_instance_of(project_handler, params, params[:actions].first) do |handler|
+ expect(handler).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ execute
+ end
+ end
+
+ context 'when action_id is not known' do
+ let(:action_id) { 'random' }
+
+ it 'does not execute the handlers' do
+ described_class::ALLOWED_UPDATES_HANDLERS.each_value do |handler_class|
+ expect(handler_class).not_to receive(:new)
+ end
+
+ execute
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_interactions/incident_management/incident_modal_closed_service_spec.rb b/spec/services/integrations/slack_interactions/incident_management/incident_modal_closed_service_spec.rb
new file mode 100644
index 00000000000..64cddf9a66b
--- /dev/null
+++ b/spec/services/integrations/slack_interactions/incident_management/incident_modal_closed_service_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackInteractions::IncidentManagement::IncidentModalClosedService,
+ feature_category: :integrations do
+ describe '#execute' do
+ let_it_be(:request_body) do
+ {
+ replace_original: 'true',
+ text: 'Incident creation cancelled.'
+ }
+ end
+
+ let(:params) do
+ {
+ view: {
+ private_metadata: 'https://api.slack.com/id/1234'
+ }
+ }
+ end
+
+ let(:service) { described_class.new(params) }
+
+ before do
+ allow(Gitlab::HTTP).to receive(:post).and_return({ ok: true })
+ end
+
+ context 'when executed' do
+ it 'makes the POST call and closes the modal' do
+ expect(Gitlab::HTTP).to receive(:post).with(
+ 'https://api.slack.com/id/1234',
+ body: Gitlab::Json.dump(request_body),
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ service.execute
+ end
+ end
+
+ context 'when the POST call raises an HTTP exception' do
+ before do
+ allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED, 'error message')
+ end
+
+ it 'tracks the exception and returns an error response' do
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ Errno::ECONNREFUSED.new('HTTP exception when calling Slack API'),
+ {
+ params: params
+ }
+ )
+
+ service.execute
+ end
+ end
+
+ context 'when response is not ok' do
+ before do
+ allow(Gitlab::HTTP).to receive(:post).and_return({ ok: false })
+ end
+
+ it 'returns error response and tracks the exception' do
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ StandardError.new('Something went wrong while closing the incident form.'),
+ {
+ response: { ok: false },
+ params: params
+ }
+ )
+
+ service.execute
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_interactions/incident_management/incident_modal_opened_service_spec.rb b/spec/services/integrations/slack_interactions/incident_management/incident_modal_opened_service_spec.rb
new file mode 100644
index 00000000000..2a1aa0aabec
--- /dev/null
+++ b/spec/services/integrations/slack_interactions/incident_management/incident_modal_opened_service_spec.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackInteractions::IncidentManagement::IncidentModalOpenedService,
+ feature_category: :incident_management do
+ describe '#execute' do
+ let_it_be(:slack_installation) { create(:slack_integration) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, developer_projects: [project]) }
+ let_it_be(:trigger_id) { '12345.98765.abcd2358fdea' }
+
+ let(:slack_workspace_id) { slack_installation.team_id }
+ let(:response_url) { 'https://api.slack.com/id/123' }
+ let(:api_url) { "#{Slack::API::BASE_URL}/views.open" }
+ let(:mock_modal) { { type: 'modal', blocks: [] } }
+ let(:params) do
+ {
+ team_id: slack_workspace_id,
+ response_url: response_url,
+ trigger_id: trigger_id
+ }
+ end
+
+ before do
+ response = {
+ id: '123',
+ state: {
+ values: {
+ project_and_severity_selector: {
+ incident_management_project: {
+ selected_option: {
+ value: project.id.to_s
+ }
+ }
+ }
+ }
+ }
+ }
+ stub_request(:post, api_url)
+ .to_return(
+ status: 200,
+ body: Gitlab::Json.dump({ ok: true, view: response }),
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ subject { described_class.new(slack_installation, user, params) }
+
+ context 'when triggered' do
+ it 'opens the modal' do
+ expect_next_instance_of(Slack::BlockKit::IncidentManagement::IncidentModalOpened) do |ui|
+ expect(ui).to receive(:build).and_return(mock_modal)
+ end
+
+ expect(Rails.cache).to receive(:write).with(
+ 'slack:incident_modal_opened:123', project.id.to_s, { expires_in: 5.minutes })
+
+ response = subject.execute
+
+ expect(WebMock).to have_requested(:post, api_url).with(
+ body: {
+ trigger_id: trigger_id,
+ view: mock_modal
+ },
+ headers: {
+ 'Authorization' => "Bearer #{slack_installation.bot_access_token}",
+ 'Content-Type' => 'application/json; charset=utf-8'
+ })
+
+ expect(response.message).to eq('Please complete the incident creation form.')
+ end
+ end
+
+ context 'when there are no projects with slack integration' do
+ let(:params) do
+ {
+ team_id: 'some_random_id',
+ response_url: response_url,
+ trigger_id: trigger_id
+ }
+ end
+
+ let(:user) { create(:user) }
+
+ it 'does not open the modal' do
+ response = subject.execute
+
+ expect(Rails.cache).not_to receive(:write)
+ expect(response.message).to be('You do not have access to any projects for creating incidents.')
+ end
+ end
+
+ context 'when Slack API call raises an HTTP exception' do
+ before do
+ allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED, 'error message')
+ end
+
+ it 'tracks the exception and returns an error response' do
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ Errno::ECONNREFUSED.new('HTTP exception when calling Slack API'),
+ {
+ slack_workspace_id: slack_workspace_id
+ }
+ )
+
+ expect(Rails.cache).not_to receive(:write)
+ expect(subject.execute).to be_error
+ end
+ end
+
+ context 'when api returns an error' do
+ before do
+ stub_request(:post, api_url)
+ .to_return(
+ status: 404,
+ body: Gitlab::Json.dump({ ok: false }),
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns error when called' do
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ StandardError.new('Something went wrong while opening the incident form.'),
+ {
+ response: { "ok" => false },
+ slack_workspace_id: slack_workspace_id,
+ slack_user_id: slack_installation.user_id
+ }
+ )
+
+ expect(Rails.cache).not_to receive(:write)
+ response = subject.execute
+
+ expect(response.message).to eq('Something went wrong while opening the incident form.')
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_interactions/incident_management/incident_modal_submit_service_spec.rb b/spec/services/integrations/slack_interactions/incident_management/incident_modal_submit_service_spec.rb
new file mode 100644
index 00000000000..adaeadaa997
--- /dev/null
+++ b/spec/services/integrations/slack_interactions/incident_management/incident_modal_submit_service_spec.rb
@@ -0,0 +1,296 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackInteractions::IncidentManagement::IncidentModalSubmitService,
+ feature_category: :incident_management do
+ include Gitlab::Routing
+
+ describe '#execute' do
+ let_it_be(:slack_installation) { create(:slack_integration) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:api_url) { 'https://api.slack.com/id/1234' }
+
+ let_it_be(:chat_name) do
+ create(:chat_name,
+ user: user,
+ team_id: slack_installation.team_id,
+ chat_id: slack_installation.user_id
+ )
+ end
+
+ # Setting below params as they are optional, have added values wherever required in specs
+ let(:zoom_link) { '' }
+ let(:severity) { {} }
+ let(:status) { '' }
+ let(:assignee_id) { nil }
+ let(:selected_label_ids) { [] }
+ let(:label_ids) { { selected_options: selected_label_ids } }
+ let(:confidential_selected_options) { [] }
+ let(:confidential) { { selected_options: confidential_selected_options } }
+ let(:title) { 'Incident title' }
+
+ let(:zoom) do
+ {
+ link: {
+ value: zoom_link
+ }
+ }
+ end
+
+ let(:params) do
+ {
+ team: {
+ id: slack_installation.team_id
+ },
+ user: {
+ id: slack_installation.user_id
+ },
+ view: {
+ private_metadata: api_url,
+ state: {
+ values: {
+ title_input: {
+ title: {
+ value: title
+ }
+ },
+ incident_description: {
+ description: {
+ value: 'Incident description'
+ }
+ },
+ project_and_severity_selector: {
+ incident_management_project: {
+ selected_option: {
+ value: project.id.to_s
+ }
+ },
+ severity: severity
+ },
+ confidentiality: {
+ confidential: confidential
+ },
+ zoom: zoom,
+ status_and_assignee_selector: {
+ status: {
+ selected_option: {
+ value: status
+ }
+ },
+ assignee: {
+ selected_option: {
+ value: assignee_id
+ }
+ }
+ },
+ label_selector: {
+ labels: label_ids
+ }
+ }
+ }
+ }
+ }
+ end
+
+ subject(:execute_service) { described_class.new(params).execute }
+
+ shared_examples 'error in creation' do |error_message|
+ it 'returns error and raises exception' do
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ described_class::IssueCreateError.new(error_message),
+ {
+ slack_workspace_id: slack_installation.team_id,
+ slack_user_id: slack_installation.user_id
+ }
+ )
+
+ expect(Gitlab::HTTP).to receive(:post)
+ .with(
+ api_url,
+ body: Gitlab::Json.dump(
+ {
+ replace_original: 'true',
+ text: 'There was a problem creating the incident. Please try again.'
+ }
+ ),
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ response = execute_service
+
+ expect(response).to be_error
+ expect(response.message).to eq(error_message)
+ end
+ end
+
+ context 'when user has permissions to create incidents' do
+ let(:api_response) { '{"ok":true}' }
+
+ before do
+ project.add_developer(user)
+ stub_request(:post, api_url)
+ .to_return(body: api_response, headers: { 'Content-Type' => 'application/json' })
+ end
+
+ context 'with markup string in title' do
+ let(:title) { '<a href="url">incident title</a>' }
+ let(:incident) { create(:incident, title: title, project: project) }
+
+ before do
+ allow_next_instance_of(Issues::CreateService) do |service|
+ allow(service).to receive(:execute).and_return(
+ ServiceResponse.success(payload: { issue: incident, error: [] })
+ )
+ end
+ end
+
+ it 'strips the markup and saves sends the title' do
+ expect(Gitlab::HTTP).to receive(:post)
+ .with(
+ api_url,
+ body: Gitlab::Json.dump(
+ {
+ replace_original: 'true',
+ text: "New incident has been created: " \
+ "<#{issue_url(incident)}|#{incident.to_reference} - a href=\"url\"incident title/a>. "
+ }
+ ),
+ headers: { 'Content-Type' => 'application/json' }
+ ).and_return(api_response)
+
+ execute_service
+ end
+ end
+
+ context 'with non-optional params' do
+ it 'creates incident' do
+ response = execute_service
+ incident = response[:incident]
+
+ expect(response).to be_success
+ expect(incident).not_to be_nil
+ expect(incident.description).to eq('Incident description')
+ expect(incident.author).to eq(user)
+ expect(incident.severity).to eq('unknown')
+ expect(incident.confidential).to be_falsey
+ expect(incident.escalation_status).to be_triggered
+ end
+
+ it 'sends incident link to slack' do
+ execute_service
+
+ expect(WebMock).to have_requested(:post, api_url)
+ end
+ end
+
+ context 'with zoom_link' do
+ let(:zoom_link) { 'https://gitlab.zoom.us/j/1234' }
+
+ it 'sets zoom link as quick action' do
+ incident = execute_service[:incident]
+ zoom_meeting = ZoomMeeting.find_by_issue_id(incident.id)
+
+ expect(incident.description).to eq("Incident description")
+ expect(zoom_meeting.url).to eq(zoom_link)
+ end
+ end
+
+ context 'with confidential and severity' do
+ let(:confidential_selected_options) { ['confidential'] }
+ let(:severity) do
+ {
+ selected_option: {
+ value: 'high'
+ }
+ }
+ end
+
+ it 'sets confidential and severity' do
+ incident = execute_service[:incident]
+
+ expect(incident.confidential).to be_truthy
+ expect(incident.severity).to eq('high')
+ end
+ end
+
+ context 'with incident status' do
+ let(:status) { 'resolved' }
+
+ it 'sets the incident status' do
+ incident = execute_service[:incident]
+
+ expect(incident.escalation_status).to be_resolved
+ end
+ end
+
+ context 'with assignee id' do
+ let(:assignee_id) { user.id.to_s }
+
+ it 'assigns the incident to user' do
+ incident = execute_service[:incident]
+
+ expect(incident.assignees).to contain_exactly(user)
+ end
+
+ context 'when user is not a member of the project' do
+ let(:assignee_id) { create(:user).id.to_s }
+
+ it 'does not assign the user' do
+ incident = execute_service[:incident]
+
+ expect(incident.assignees).to be_empty
+ end
+ end
+ end
+
+ context 'with label ids' do
+ let_it_be(:project_label1) { create(:label, project: project, title: 'Label 1') }
+ let_it_be(:project_label2) { create(:label, project: project, title: 'Label 2') }
+
+ let(:selected_label_ids) do
+ [
+ { value: project_label1.id.to_s },
+ { value: project_label2.id.to_s }
+ ]
+ end
+
+ it 'assigns the label to the incident' do
+ incident = execute_service[:incident]
+
+ expect(incident.labels).to contain_exactly(project_label1, project_label2)
+ end
+ end
+
+ context 'when response is not ok' do
+ let(:api_response) { '{"ok":false}' }
+
+ it 'returns error response and tracks the exception' do
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ StandardError.new('Something went wrong when sending the incident link to Slack.'),
+ {
+ response: { 'ok' => false },
+ slack_workspace_id: slack_installation.team_id,
+ slack_user_id: slack_installation.user_id
+ }
+ )
+
+ execute_service
+ end
+ end
+
+ context 'when incident creation fails' do
+ let(:title) { '' }
+
+ it_behaves_like 'error in creation', "Title can't be blank"
+ end
+ end
+
+ context 'when user does not have permission to create incidents' do
+ it_behaves_like 'error in creation', 'Operation not allowed'
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler_spec.rb b/spec/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler_spec.rb
new file mode 100644
index 00000000000..5edffc99977
--- /dev/null
+++ b/spec/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler_spec.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackInteractions::SlackBlockActions::IncidentManagement::ProjectUpdateHandler,
+ feature_category: :incident_management do
+ describe '#execute' do
+ let_it_be(:slack_installation) { create(:slack_integration) }
+ let_it_be(:old_project) { create(:project) }
+ let_it_be(:new_project) { create(:project) }
+ let_it_be(:user) { create(:user, developer_projects: [old_project, new_project]) }
+ let_it_be(:chat_name) { create(:chat_name, user: user) }
+ let_it_be(:api_url) { "#{Slack::API::BASE_URL}/views.update" }
+
+ let(:block) do
+ {
+ block_id: 'incident_description',
+ element: {
+ initial_value: ''
+ }
+ }
+ end
+
+ let(:view) do
+ {
+ id: 'V04EQH1SP27',
+ team_id: slack_installation.team_id,
+ blocks: [block]
+ }
+ end
+
+ let(:action) do
+ {
+ selected_option: {
+ value: new_project.id.to_s
+ }
+ }
+ end
+
+ let(:params) do
+ {
+ view: view,
+ user: {
+ id: slack_installation.user_id
+ }
+ }
+ end
+
+ before do
+ allow_next_instance_of(ChatNames::FindUserService) do |user_service|
+ allow(user_service).to receive(:execute).and_return(chat_name)
+ end
+
+ stub_request(:post, api_url)
+ .to_return(
+ status: 200,
+ body: Gitlab::Json.dump({ ok: true }),
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ shared_examples 'does not make api call' do
+ it 'does not make the api call and returns nil' do
+ expect(Rails.cache).to receive(:read).and_return(project.id.to_s)
+ expect(Rails.cache).not_to receive(:write)
+
+ expect(execute).to be_nil
+ expect(WebMock).not_to have_requested(:post, api_url)
+ end
+ end
+
+ subject(:execute) { described_class.new(params, action).execute }
+
+ context 'when project is updated' do
+ it 'returns success response and updates cache' do
+ expect(Rails.cache).to receive(:read).and_return(old_project.id.to_s)
+ expect(Rails.cache).to receive(:write).with(
+ "slack:incident_modal_opened:#{view[:id]}",
+ new_project.id.to_s,
+ expires_in: 5.minutes
+ )
+
+ expect(execute.message).to eq('Modal updated')
+
+ updated_block = block.dup
+ updated_block[:block_id] = new_project.id.to_s
+ view[:blocks] = [updated_block]
+
+ expect(WebMock).to have_requested(:post, api_url).with(
+ body: {
+ view_id: view[:id],
+ view: view.except!(:team_id, :id)
+ },
+ headers: {
+ 'Authorization' => "Bearer #{slack_installation.bot_access_token}",
+ 'Content-Type' => 'application/json; charset=utf-8'
+ })
+ end
+ end
+
+ context 'when project is unchanged' do
+ it_behaves_like 'does not make api call' do
+ let(:project) { new_project }
+ end
+ end
+
+ context 'when user does not have permission to read a project' do
+ it_behaves_like 'does not make api call' do
+ let(:project) { create(:project) }
+ end
+ end
+
+ context 'when api response is not ok' do
+ before do
+ stub_request(:post, api_url)
+ .to_return(
+ status: 404,
+ body: Gitlab::Json.dump({ ok: false }),
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns error response' do
+ expect(Rails.cache).to receive(:read).and_return(old_project.id.to_s)
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ StandardError.new('Something went wrong while updating the modal.'),
+ {
+ response: { "ok" => false },
+ slack_workspace_id: slack_installation.team_id,
+ slack_user_id: slack_installation.user_id
+ }
+ )
+
+ expect(execute.message).to eq('Something went wrong while updating the modal.')
+ end
+ end
+
+ context 'when Slack API call raises an HTTP exception' do
+ before do
+ allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED, 'error message')
+ end
+
+ it 'tracks the exception and returns an error message' do
+ expect(Rails.cache).to receive(:read).and_return(old_project.id.to_s)
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(
+ Errno::ECONNREFUSED.new('HTTP exception when calling Slack API'),
+ {
+ slack_workspace_id: slack_installation.team_id
+ }
+ )
+
+ expect(execute).to be_error
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_option_service_spec.rb b/spec/services/integrations/slack_option_service_spec.rb
new file mode 100644
index 00000000000..2e114b932d2
--- /dev/null
+++ b/spec/services/integrations/slack_option_service_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackOptionService, feature_category: :integrations do
+ describe '#execute' do
+ subject(:execute) { described_class.new(params).execute }
+
+ let_it_be(:slack_installation) { create(:slack_integration) }
+ let_it_be(:user) { create(:user) }
+
+ let_it_be(:chat_name) do
+ create(:chat_name,
+ user: user,
+ team_id: slack_installation.team_id,
+ chat_id: slack_installation.user_id
+ )
+ end
+
+ let(:params) do
+ {
+ action_id: action_id,
+ view: {
+ id: 'VHDFR54DSA'
+ },
+ value: 'Search value',
+ team: {
+ id: slack_installation.team_id
+ },
+ user: {
+ id: slack_installation.user_id
+ }
+ }
+ end
+
+ context 'when action_id is assignee' do
+ let(:action_id) { 'assignee' }
+
+ it 'executes the user search handler' do
+ user_search_handler = described_class::OPTIONS['assignee']
+
+ expect_next_instance_of(user_search_handler, chat_name, 'Search value', 'VHDFR54DSA') do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ execute
+ end
+ end
+
+ context 'when action_id is labels' do
+ let(:action_id) { 'labels' }
+
+ it 'executes the label search handler' do
+ label_search_handler = described_class::OPTIONS['labels']
+
+ expect_next_instance_of(label_search_handler, chat_name, 'Search value', 'VHDFR54DSA') do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ execute
+ end
+ end
+
+ context 'when action_id is unknown' do
+ let(:action_id) { 'foo' }
+
+ it 'raises an error and does not execute a service class' do
+ described_class::OPTIONS.each_value do |service_class|
+ expect(service_class).not_to receive(:new)
+ end
+
+ expect { execute }.to raise_error(described_class::UnknownOptionError)
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_options/label_search_handler_spec.rb b/spec/services/integrations/slack_options/label_search_handler_spec.rb
new file mode 100644
index 00000000000..3b006061f1d
--- /dev/null
+++ b/spec/services/integrations/slack_options/label_search_handler_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackOptions::LabelSearchHandler, feature_category: :integrations do
+ describe '#execute' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :private, namespace: group) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:chat_name) { create(:chat_name, user: current_user) }
+ let_it_be(:project_label1) { create(:label, project: project, title: 'Label 1') }
+ let_it_be(:project_label2) { create(:label, project: project, title: 'Label 2') }
+ let_it_be(:group_label1) { create(:group_label, group: group, title: 'LabelG 1') }
+ let_it_be(:group_label2) { create(:group_label, group: group, title: 'glb 2') }
+ let_it_be(:view_id) { 'VXHD54DR' }
+
+ let(:search_value) { 'Lab' }
+
+ subject(:execute) { described_class.new(chat_name, search_value, view_id).execute }
+
+ context 'when user has permission to read project and group labels' do
+ before do
+ allow(Rails.cache).to receive(:read).and_return(project.id)
+ project.add_developer(current_user)
+ end
+
+ it 'returns the labels matching the search term' do
+ labels = execute.payload[:options]
+ label_names = labels.map { |label| label.dig(:text, :text) }
+
+ expect(label_names).to contain_exactly(
+ project_label1.name,
+ project_label2.name,
+ group_label1.name
+ )
+ end
+ end
+
+ context 'when user does not have permissions to read project/group labels' do
+ it 'returns empty array' do
+ expect(LabelsFinder).not_to receive(:execute)
+
+ expect(execute.payload).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/slack_options/user_search_handler_spec.rb b/spec/services/integrations/slack_options/user_search_handler_spec.rb
new file mode 100644
index 00000000000..e827bf643d2
--- /dev/null
+++ b/spec/services/integrations/slack_options/user_search_handler_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackOptions::UserSearchHandler, feature_category: :integrations do
+ describe '#execute' do
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:chat_name) { create(:chat_name, user: current_user) }
+ let_it_be(:user1) { create(:user, name: 'Rajendra Kadam') }
+ let_it_be(:user2) { create(:user, name: 'Rajesh K') }
+ let_it_be(:user3) { create(:user) }
+ let_it_be(:view_id) { 'VXHD54DR' }
+
+ let(:search_value) { 'Raj' }
+
+ subject(:execute) { described_class.new(chat_name, search_value, view_id).execute }
+
+ context 'when user has permissions to read project members' do
+ before do
+ project.add_developer(current_user)
+ project.add_guest(user1)
+ project.add_reporter(user2)
+ project.add_maintainer(user3)
+ end
+
+ it 'returns the user matching the search term' do
+ expect(Rails.cache).to receive(:read).and_return(project.id)
+
+ members = execute.payload[:options]
+ user_names = members.map { |member| member.dig(:text, :text) }
+
+ expect(members.count).to eq(2)
+ expect(user_names).to contain_exactly(
+ "#{user1.name} - #{user1.username}",
+ "#{user2.name} - #{user2.username}"
+ )
+ end
+ end
+
+ context 'when user does not have permissions to read project members' do
+ it 'returns empty array' do
+ expect(Rails.cache).to receive(:read).and_return(project.id)
+ expect(MembersFinder).not_to receive(:execute)
+
+ members = execute.payload
+
+ expect(members).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/services/integrations/test/project_service_spec.rb b/spec/services/integrations/test/project_service_spec.rb
index 74833686283..4f8f932fb45 100644
--- a/spec/services/integrations/test/project_service_spec.rb
+++ b/spec/services/integrations/test/project_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Integrations::Test::ProjectService do
+RSpec.describe Integrations::Test::ProjectService, feature_category: :integrations do
include AfterNextHelpers
describe '#execute' do
diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb
index 7ba349ceeae..a76d575a1e0 100644
--- a/spec/services/issuable/bulk_update_service_spec.rb
+++ b/spec/services/issuable/bulk_update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issuable::BulkUpdateService do
+RSpec.describe Issuable::BulkUpdateService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository, namespace: user.namespace) }
diff --git a/spec/services/issuable/callbacks/milestone_spec.rb b/spec/services/issuable/callbacks/milestone_spec.rb
new file mode 100644
index 00000000000..085ed029a6c
--- /dev/null
+++ b/spec/services/issuable/callbacks/milestone_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Issuable::Callbacks::Milestone, feature_category: :team_planning do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :private, group: group) }
+ let_it_be(:project_milestone) { create(:milestone, project: project) }
+ let_it_be(:group_milestone) { create(:milestone, group: group) }
+ let_it_be(:reporter) do
+ create(:user).tap { |u| project.add_reporter(u) }
+ end
+
+ let(:issuable) { build(:issue, project: project) }
+ let(:current_user) { reporter }
+ let(:params) { { milestone_id: project_milestone.id } }
+ let(:callback) { described_class.new(issuable: issuable, current_user: current_user, params: params) }
+
+ describe '#after_initialize' do
+ it "sets the issuable's milestone" do
+ expect { callback.after_initialize }.to change { issuable.milestone }.from(nil).to(project_milestone)
+ end
+
+ context 'when assigning a group milestone' do
+ let(:params) { { milestone_id: group_milestone.id } }
+
+ it "sets the issuable's milestone" do
+ expect { callback.after_initialize }.to change { issuable.milestone }.from(nil).to(group_milestone)
+ end
+ end
+
+ context 'when assigning a group milestone outside the project ancestors' do
+ let(:another_group_milestone) { create(:milestone, group: create(:group)) }
+ let(:params) { { milestone_id: another_group_milestone.id } }
+
+ it "does not change the issuable's milestone" do
+ expect { callback.after_initialize }.not_to change { issuable.milestone }
+ end
+ end
+
+ context 'when user is not allowed to set issuable metadata' do
+ let(:current_user) { create(:user) }
+
+ it "does not change the issuable's milestone" do
+ expect { callback.after_initialize }.not_to change { issuable.milestone }
+ end
+ end
+
+ context 'when unsetting a milestone' do
+ let(:issuable) { create(:issue, project: project, milestone: project_milestone) }
+
+ context 'when milestone_id is nil' do
+ let(:params) { { milestone_id: nil } }
+
+ it "unsets the issuable's milestone" do
+ expect { callback.after_initialize }.to change { issuable.milestone }.from(project_milestone).to(nil)
+ end
+ end
+
+ context 'when milestone_id is an empty string' do
+ let(:params) { { milestone_id: '' } }
+
+ it "unsets the issuable's milestone" do
+ expect { callback.after_initialize }.to change { issuable.milestone }.from(project_milestone).to(nil)
+ end
+ end
+
+ context 'when milestone_id is 0' do
+ let(:params) { { milestone_id: '0' } }
+
+ it "unsets the issuable's milestone" do
+ expect { callback.after_initialize }.to change { issuable.milestone }.from(project_milestone).to(nil)
+ end
+ end
+
+ context "when milestone_id is '0'" do
+ let(:params) { { milestone_id: 0 } }
+
+ it "unsets the issuable's milestone" do
+ expect { callback.after_initialize }.to change { issuable.milestone }.from(project_milestone).to(nil)
+ end
+ end
+
+ context 'when milestone_id is not given' do
+ let(:params) { {} }
+
+ it "does not unset the issuable's milestone" do
+ expect { callback.after_initialize }.not_to change { issuable.milestone }
+ end
+ end
+
+ context 'when new type does not support milestones' do
+ let(:params) { { excluded_in_new_type: true } }
+
+ it "unsets the issuable's milestone" do
+ expect { callback.after_initialize }.to change { issuable.milestone }.from(project_milestone).to(nil)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/issuable/common_system_notes_service_spec.rb b/spec/services/issuable/common_system_notes_service_spec.rb
index 0d2b8a4ac3c..9306aeaac44 100644
--- a/spec/services/issuable/common_system_notes_service_spec.rb
+++ b/spec/services/issuable/common_system_notes_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issuable::CommonSystemNotesService do
+RSpec.describe Issuable::CommonSystemNotesService, feature_category: :team_planning do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/issuable/destroy_label_links_service_spec.rb b/spec/services/issuable/destroy_label_links_service_spec.rb
index bbc69e266c9..f0a92c201d2 100644
--- a/spec/services/issuable/destroy_label_links_service_spec.rb
+++ b/spec/services/issuable/destroy_label_links_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issuable::DestroyLabelLinksService do
+RSpec.describe Issuable::DestroyLabelLinksService, feature_category: :team_planning do
describe '#execute' do
context 'when target is an Issue' do
let_it_be(:target) { create(:issue) }
diff --git a/spec/services/issuable/destroy_service_spec.rb b/spec/services/issuable/destroy_service_spec.rb
index 29f548e1c47..1acaf01dce0 100644
--- a/spec/services/issuable/destroy_service_spec.rb
+++ b/spec/services/issuable/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issuable::DestroyService do
+RSpec.describe Issuable::DestroyService, feature_category: :team_planning do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, group: group) }
diff --git a/spec/services/issuable/discussions_list_service_spec.rb b/spec/services/issuable/discussions_list_service_spec.rb
index a6f57088ad1..03b6a1b4556 100644
--- a/spec/services/issuable/discussions_list_service_spec.rb
+++ b/spec/services/issuable/discussions_list_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issuable::DiscussionsListService do
+RSpec.describe Issuable::DiscussionsListService, feature_category: :team_planning do
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, :repository, :private, group: group) }
diff --git a/spec/services/issuable/process_assignees_spec.rb b/spec/services/issuable/process_assignees_spec.rb
index 9e909b68172..2c8d4c5e11d 100644
--- a/spec/services/issuable/process_assignees_spec.rb
+++ b/spec/services/issuable/process_assignees_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issuable::ProcessAssignees do
+RSpec.describe Issuable::ProcessAssignees, feature_category: :team_planning do
describe '#execute' do
it 'returns assignee_ids when add_assignee_ids and remove_assignee_ids are not specified' do
process = Issuable::ProcessAssignees.new(assignee_ids: %w(5 7 9),
diff --git a/spec/services/issue_links/create_service_spec.rb b/spec/services/issue_links/create_service_spec.rb
index 0629b8b091b..71603da1c90 100644
--- a/spec/services/issue_links/create_service_spec.rb
+++ b/spec/services/issue_links/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IssueLinks::CreateService do
+RSpec.describe IssueLinks::CreateService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:user) { create :user }
let_it_be(:namespace) { create :namespace }
@@ -43,7 +43,6 @@ RSpec.describe IssueLinks::CreateService do
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { issue.namespace }
let(:category) { described_class.to_s }
let(:action) { 'incident_management_incident_relate' }
diff --git a/spec/services/issue_links/destroy_service_spec.rb b/spec/services/issue_links/destroy_service_spec.rb
index ecb53b5cd31..5c4814f5ad1 100644
--- a/spec/services/issue_links/destroy_service_spec.rb
+++ b/spec/services/issue_links/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IssueLinks::DestroyService do
+RSpec.describe IssueLinks::DestroyService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:project) { create(:project_empty_repo, :private) }
let_it_be(:user) { create(:user) }
@@ -27,7 +27,6 @@ RSpec.describe IssueLinks::DestroyService do
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { issue_b.namespace }
let(:category) { described_class.to_s }
let(:action) { 'incident_management_incident_unrelate' }
diff --git a/spec/services/issue_links/list_service_spec.rb b/spec/services/issue_links/list_service_spec.rb
index 7a3ba845c7c..bfb6127ed56 100644
--- a/spec/services/issue_links/list_service_spec.rb
+++ b/spec/services/issue_links/list_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IssueLinks::ListService do
+RSpec.describe IssueLinks::ListService, feature_category: :team_planning do
let(:user) { create :user }
let(:project) { create(:project_empty_repo, :private) }
let(:issue) { create :issue, project: project }
diff --git a/spec/services/issues/after_create_service_spec.rb b/spec/services/issues/after_create_service_spec.rb
index 39a6799dbad..b59578b14a0 100644
--- a/spec/services/issues/after_create_service_spec.rb
+++ b/spec/services/issues/after_create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::AfterCreateService do
+RSpec.describe Issues::AfterCreateService, feature_category: :team_planning do
include AfterNextHelpers
let_it_be(:project) { create(:project) }
@@ -28,13 +28,6 @@ RSpec.describe Issues::AfterCreateService do
expect { after_create_service.execute(issue) }.to change { Todo.where(attributes).count }.by(1)
end
- it 'deletes milestone issues count cache' do
- expect_next(Milestones::IssuesCountService, milestone)
- .to receive(:delete_cache).and_call_original
-
- after_create_service.execute(issue)
- end
-
context 'with a regular issue' do
it_behaves_like 'does not track incident management event', :incident_management_incident_created do
subject { after_create_service.execute(issue) }
diff --git a/spec/services/issues/base_service_spec.rb b/spec/services/issues/base_service_spec.rb
new file mode 100644
index 00000000000..94165d557d8
--- /dev/null
+++ b/spec/services/issues/base_service_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Issues::BaseService, feature_category: :team_planning do
+ describe '#constructor_container_arg' do
+ it { expect(described_class.constructor_container_arg("some-value")).to eq({ container: "some-value" }) }
+ end
+end
diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb
index 2160c45d079..8368a34caf0 100644
--- a/spec/services/issues/build_service_spec.rb
+++ b/spec/services/issues/build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::BuildService do
+RSpec.describe Issues::BuildService, feature_category: :team_planning do
using RSpec::Parameterized::TableSyntax
let_it_be(:project) { create(:project, :repository) }
@@ -161,8 +161,8 @@ RSpec.describe Issues::BuildService do
end
end
- context 'when guest' do
- let(:user) { guest }
+ context 'when user is not a project member' do
+ let(:user) { create(:user) }
it 'cannot set milestone' do
milestone = create(:milestone, project: project)
@@ -175,31 +175,37 @@ RSpec.describe Issues::BuildService do
describe 'setting issue type' do
context 'with a corresponding WorkItems::Type' do
+ let_it_be(:type_task) { WorkItems::Type.default_by_type(:task) }
+ let_it_be(:type_task_id) { type_task.id }
let_it_be(:type_issue_id) { WorkItems::Type.default_issue_type.id }
let_it_be(:type_incident_id) { WorkItems::Type.default_by_type(:incident).id }
-
- where(:issue_type, :current_user, :work_item_type_id, :resulting_issue_type) do
- nil | ref(:guest) | ref(:type_issue_id) | 'issue'
- 'issue' | ref(:guest) | ref(:type_issue_id) | 'issue'
- 'incident' | ref(:guest) | ref(:type_issue_id) | 'issue'
- 'incident' | ref(:reporter) | ref(:type_incident_id) | 'incident'
+ let(:combined_params) { { work_item_type: type_task, issue_type: 'issue' } }
+ let(:work_item_params) { { work_item_type_id: type_task_id } }
+
+ where(:issue_params, :current_user, :work_item_type_id, :resulting_issue_type) do
+ { issue_type: nil } | ref(:guest) | ref(:type_issue_id) | 'issue'
+ { issue_type: 'issue' } | ref(:guest) | ref(:type_issue_id) | 'issue'
+ { issue_type: 'incident' } | ref(:guest) | ref(:type_issue_id) | 'issue'
+ { issue_type: 'incident' } | ref(:reporter) | ref(:type_incident_id) | 'incident'
+ ref(:combined_params) | ref(:reporter) | ref(:type_task_id) | 'task'
+ ref(:work_item_params) | ref(:reporter) | ref(:type_task_id) | 'task'
# update once support for test_case is enabled
- 'test_case' | ref(:guest) | ref(:type_issue_id) | 'issue'
+ { issue_type: 'test_case' } | ref(:guest) | ref(:type_issue_id) | 'issue'
# update once support for requirement is enabled
- 'requirement' | ref(:guest) | ref(:type_issue_id) | 'issue'
- 'invalid' | ref(:guest) | ref(:type_issue_id) | 'issue'
+ { issue_type: 'requirement' } | ref(:guest) | ref(:type_issue_id) | 'issue'
+ { issue_type: 'invalid' } | ref(:guest) | ref(:type_issue_id) | 'issue'
# ensure that we don't set a value which has a permission check but is an invalid issue type
- 'project' | ref(:guest) | ref(:type_issue_id) | 'issue'
+ { issue_type: 'project' } | ref(:guest) | ref(:type_issue_id) | 'issue'
end
with_them do
let(:user) { current_user }
it 'builds an issue' do
- issue = build_issue(issue_type: issue_type)
+ issue = build_issue(**issue_params)
- expect(issue.issue_type).to eq(resulting_issue_type)
expect(issue.work_item_type_id).to eq(work_item_type_id)
+ expect(issue.attributes['issue_type']).to eq(resulting_issue_type)
end
end
end
diff --git a/spec/services/issues/clone_service_spec.rb b/spec/services/issues/clone_service_spec.rb
index eafaea93015..2fb14d8ce8e 100644
--- a/spec/services/issues/clone_service_spec.rb
+++ b/spec/services/issues/clone_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::CloneService do
+RSpec.describe Issues::CloneService, feature_category: :team_planning do
include DesignManagementTestHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index 803808e667c..47925236a74 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -2,8 +2,9 @@
require 'spec_helper'
-RSpec.describe Issues::CloseService do
+RSpec.describe Issues::CloseService, feature_category: :team_planning do
let(:project) { create(:project, :repository) }
+ let(:delegated_project) { project.project_namespace.project }
let(:user) { create(:user, email: "user@example.com") }
let(:user2) { create(:user, email: "user2@example.com") }
let(:guest) { create(:user) }
@@ -100,7 +101,6 @@ RSpec.describe Issues::CloseService do
it_behaves_like 'an incident management tracked event', :incident_management_incident_closed
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { issue.namespace }
let(:category) { described_class.to_s }
let(:action) { 'incident_management_incident_closed' }
@@ -202,34 +202,17 @@ RSpec.describe Issues::CloseService do
end
it 'mentions closure via a merge request' do
- close_issue
-
- email = ActionMailer::Base.deliveries.last
+ expect_next_instance_of(NotificationService::Async) do |service|
+ expect(service).to receive(:close_issue).with(issue, user, { closed_via: closing_merge_request })
+ end
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
- expect(email.body.parts.map(&:body)).to all(include(closing_merge_request.to_reference))
+ close_issue
end
it_behaves_like 'records an onboarding progress action', :issue_auto_closed do
let(:namespace) { project.namespace }
end
- context 'when user cannot read merge request' do
- it 'does not mention merge request' do
- project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
-
- close_issue
-
- email = ActionMailer::Base.deliveries.last
- body_text = email.body.parts.map(&:body).join(" ")
-
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
- expect(body_text).not_to include(closing_merge_request.to_reference)
- end
- end
-
context 'updating `metrics.first_mentioned_in_commit_at`' do
context 'when `metrics.first_mentioned_in_commit_at` is not set' do
it 'uses the first commit authored timestamp' do
@@ -265,31 +248,11 @@ RSpec.describe Issues::CloseService do
context "closed by a commit", :sidekiq_might_not_need_inline do
it 'mentions closure via a commit' do
- perform_enqueued_jobs do
- described_class.new(container: project, current_user: user).close_issue(issue, closed_via: closing_commit)
+ expect_next_instance_of(NotificationService::Async) do |service|
+ expect(service).to receive(:close_issue).with(issue, user, { closed_via: "commit #{closing_commit.id}" })
end
- email = ActionMailer::Base.deliveries.last
-
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
- expect(email.body.parts.map(&:body)).to all(include(closing_commit.id))
- end
-
- context 'when user cannot read the commit' do
- it 'does not mention the commit id' do
- project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
- perform_enqueued_jobs do
- described_class.new(container: project, current_user: user).close_issue(issue, closed_via: closing_commit)
- end
-
- email = ActionMailer::Base.deliveries.last
- body_text = email.body.parts.map(&:body).join(" ")
-
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
- expect(body_text).not_to include(closing_commit.id)
- end
+ described_class.new(container: project, current_user: user).close_issue(issue, closed_via: closing_commit)
end
end
@@ -321,12 +284,12 @@ RSpec.describe Issues::CloseService do
expect(issue.reload.closed_by_id).to be(user.id)
end
- it 'sends email to user2 about assign of new issue', :sidekiq_might_not_need_inline do
- close_issue
+ it 'sends notification', :sidekiq_might_not_need_inline do
+ expect_next_instance_of(NotificationService::Async) do |service|
+ expect(service).to receive(:close_issue).with(issue, user, { closed_via: nil })
+ end
- email = ActionMailer::Base.deliveries.last
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
+ close_issue
end
it 'creates resource state event about the issue being closed' do
@@ -435,10 +398,10 @@ RSpec.describe Issues::CloseService do
end
it 'executes issue hooks' do
- expect(project).to receive(:execute_hooks).with(expected_payload, :issue_hooks)
- expect(project).to receive(:execute_integrations).with(expected_payload, :issue_hooks)
+ expect(delegated_project).to receive(:execute_hooks).with(expected_payload, :issue_hooks)
+ expect(delegated_project).to receive(:execute_integrations).with(expected_payload, :issue_hooks)
- described_class.new(container: project, current_user: user).close_issue(issue)
+ described_class.new(container: delegated_project, current_user: user).close_issue(issue)
end
end
@@ -446,8 +409,8 @@ RSpec.describe Issues::CloseService do
it 'executes confidential issue hooks' do
issue = create(:issue, :confidential, project: project)
- expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :confidential_issue_hooks)
- expect(project).to receive(:execute_integrations).with(an_instance_of(Hash), :confidential_issue_hooks)
+ expect(delegated_project).to receive(:execute_hooks).with(an_instance_of(Hash), :confidential_issue_hooks)
+ expect(delegated_project).to receive(:execute_integrations).with(an_instance_of(Hash), :confidential_issue_hooks)
described_class.new(container: project, current_user: user).close_issue(issue)
end
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index ada5b300d7a..548d9455ebf 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::CreateService do
+RSpec.describe Issues::CreateService, feature_category: :team_planning do
include AfterNextHelpers
let_it_be(:group) { create(:group, :crm_enabled) }
@@ -124,6 +124,15 @@ RSpec.describe Issues::CreateService do
expect(issue.issue_customer_relations_contacts).to be_empty
end
+ context 'with milestone' do
+ it 'deletes milestone issues count cache' do
+ expect_next(Milestones::IssuesCountService, milestone)
+ .to receive(:delete_cache).and_call_original
+
+ expect(result).to be_success
+ end
+ end
+
context 'when the work item type is not allowed to create' do
before do
allow_next_instance_of(::Issues::BuildService) do |instance|
@@ -136,7 +145,6 @@ RSpec.describe Issues::CreateService do
expect(issue).to be_persisted
expect(issue).to be_a(::Issue)
expect(issue.work_item_type.base_type).to eq('issue')
- expect(issue.issue_type).to eq('issue')
end
end
@@ -149,7 +157,7 @@ RSpec.describe Issues::CreateService do
context 'when a build_service is provided' do
let(:result) { described_class.new(container: project, current_user: user, params: opts, spam_params: spam_params, build_service: build_service).execute }
- let(:issue_from_builder) { WorkItem.new(project: project, title: 'Issue from builder') }
+ let(:issue_from_builder) { build(:work_item, project: project, title: 'Issue from builder') }
let(:build_service) { double(:build_service, execute: issue_from_builder) }
it 'uses the provided service to build the issue' do
@@ -372,6 +380,13 @@ RSpec.describe Issues::CreateService do
expect(assignee.assigned_open_issues_count).to eq 1
end
+
+ it 'records the assignee assignment event' do
+ result = described_class.new(container: project, current_user: user, params: opts, spam_params: spam_params).execute
+
+ issue = result.payload[:issue]
+ expect(issue.assignment_events).to match([have_attributes(user_id: assignee.id, action: 'add')])
+ end
end
context 'when duplicate label titles are given' do
@@ -436,8 +451,8 @@ RSpec.describe Issues::CreateService do
end
it 'executes issue hooks' do
- expect(project).to receive(:execute_hooks).with(expected_payload, :issue_hooks)
- expect(project).to receive(:execute_integrations).with(expected_payload, :issue_hooks)
+ expect(project.project_namespace).to receive(:execute_hooks).with(expected_payload, :issue_hooks)
+ expect(project.project_namespace).to receive(:execute_integrations).with(expected_payload, :issue_hooks)
described_class.new(container: project, current_user: user, params: opts, spam_params: spam_params).execute
end
@@ -459,8 +474,8 @@ RSpec.describe Issues::CreateService do
end
it 'executes confidential issue hooks' do
- expect(project).to receive(:execute_hooks).with(expected_payload, :confidential_issue_hooks)
- expect(project).to receive(:execute_integrations).with(expected_payload, :confidential_issue_hooks)
+ expect(project.project_namespace).to receive(:execute_hooks).with(expected_payload, :confidential_issue_hooks)
+ expect(project.project_namespace).to receive(:execute_integrations).with(expected_payload, :confidential_issue_hooks)
described_class.new(container: project, current_user: user, params: opts, spam_params: spam_params).execute
end
@@ -493,7 +508,7 @@ RSpec.describe Issues::CreateService do
end
it 'schedules a namespace onboarding create action worker' do
- expect(Onboarding::IssueCreatedWorker).to receive(:perform_async).with(project.namespace.id)
+ expect(Onboarding::IssueCreatedWorker).to receive(:perform_async).with(project.project_namespace_id)
issue
end
@@ -565,36 +580,6 @@ RSpec.describe Issues::CreateService do
end
context 'Quick actions' do
- context 'as work item' do
- let(:opts) do
- {
- title: "My work item",
- work_item_type: work_item_type,
- description: "/shrug"
- }
- end
-
- context 'when work item type is not the default Issue' do
- let(:work_item_type) { create(:work_item_type, namespace: project.namespace) }
-
- it 'saves the work item without applying the quick action' do
- expect(result).to be_success
- expect(issue).to be_persisted
- expect(issue.description).to eq("/shrug")
- end
- end
-
- context 'when work item type is the default Issue' do
- let(:work_item_type) { WorkItems::Type.default_by_type(:issue) }
-
- it 'saves the work item and applies the quick action' do
- expect(result).to be_success
- expect(issue).to be_persisted
- expect(issue.description).to eq(" ¯\\_(ツ)_/¯")
- end
- end
- end
-
context 'with assignee, milestone, and contact in params and command' do
let_it_be(:contact) { create(:contact, group: group) }
@@ -687,6 +672,22 @@ RSpec.describe Issues::CreateService do
expect(issue.labels).to eq([label])
end
end
+
+ context 'when using promote_to_incident' do
+ let(:opts) { { title: 'Title', description: '/promote_to_incident' } }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'creates an issue with the correct issue type' do
+ expect { result }.to change(Issue, :count).by(1)
+
+ created_issue = Issue.last
+
+ expect(created_issue.work_item_type).to eq(WorkItems::Type.default_by_type('incident'))
+ end
+ end
end
context 'resolving discussions' do
diff --git a/spec/services/issues/duplicate_service_spec.rb b/spec/services/issues/duplicate_service_spec.rb
index f49bce70cd0..f9d8bf04ae9 100644
--- a/spec/services/issues/duplicate_service_spec.rb
+++ b/spec/services/issues/duplicate_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::DuplicateService do
+RSpec.describe Issues::DuplicateService, feature_category: :team_planning do
let(:user) { create(:user) }
let(:canonical_project) { create(:project) }
let(:duplicate_project) { create(:project) }
diff --git a/spec/services/issues/import_csv_service_spec.rb b/spec/services/issues/import_csv_service_spec.rb
index 90e360f9cf1..6a147782209 100644
--- a/spec/services/issues/import_csv_service_spec.rb
+++ b/spec/services/issues/import_csv_service_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Issues::ImportCsvService, feature_category: :team_planning do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:assignee) { create(:user, username: 'csv_assignee') }
+ let(:file) { fixture_file_upload('spec/fixtures/csv_complex.csv') }
let(:service) do
uploader = FileUploader.new(project)
uploader.store!(file)
@@ -19,8 +20,6 @@ RSpec.describe Issues::ImportCsvService, feature_category: :team_planning do
end
describe '#execute' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_complex.csv') }
-
subject { service.execute }
it 'sets all issueable attributes and executes quick actions' do
diff --git a/spec/services/issues/issuable_base_service_spec.rb b/spec/services/issues/issuable_base_service_spec.rb
new file mode 100644
index 00000000000..e1680d5c908
--- /dev/null
+++ b/spec/services/issues/issuable_base_service_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssuableBaseService, feature_category: :team_planning do
+ describe '#constructor_container_arg' do
+ it { expect(described_class.constructor_container_arg("some-value")).to eq({ container: "some-value" }) }
+ end
+end
diff --git a/spec/services/issues/prepare_import_csv_service_spec.rb b/spec/services/issues/prepare_import_csv_service_spec.rb
index ded23ee43b9..d318d4fd25e 100644
--- a/spec/services/issues/prepare_import_csv_service_spec.rb
+++ b/spec/services/issues/prepare_import_csv_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::PrepareImportCsvService do
+RSpec.describe Issues::PrepareImportCsvService, feature_category: :team_planning do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/issues/referenced_merge_requests_service_spec.rb b/spec/services/issues/referenced_merge_requests_service_spec.rb
index aee3583b834..4781daf7688 100644
--- a/spec/services/issues/referenced_merge_requests_service_spec.rb
+++ b/spec/services/issues/referenced_merge_requests_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::ReferencedMergeRequestsService do
+RSpec.describe Issues::ReferencedMergeRequestsService, feature_category: :team_planning do
def create_referencing_mr(attributes = {})
create(:merge_request, attributes).tap do |merge_request|
create(:note, :system, project: project, noteable: issue, author: user, note: merge_request.to_reference(full: true))
diff --git a/spec/services/issues/related_branches_service_spec.rb b/spec/services/issues/related_branches_service_spec.rb
index 05c61d0abfc..940d988668e 100644
--- a/spec/services/issues/related_branches_service_spec.rb
+++ b/spec/services/issues/related_branches_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::RelatedBranchesService do
+RSpec.describe Issues::RelatedBranchesService, feature_category: :team_planning do
let_it_be(:project) { create(:project, :repository, :public, public_builds: false) }
let_it_be(:developer) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
diff --git a/spec/services/issues/relative_position_rebalancing_service_spec.rb b/spec/services/issues/relative_position_rebalancing_service_spec.rb
index 27c0394ac8b..68f1af49b5f 100644
--- a/spec/services/issues/relative_position_rebalancing_service_spec.rb
+++ b/spec/services/issues/relative_position_rebalancing_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::RelativePositionRebalancingService, :clean_gitlab_redis_shared_state do
+RSpec.describe Issues::RelativePositionRebalancingService, :clean_gitlab_redis_shared_state, feature_category: :team_planning do
let_it_be(:project, reload: true) { create(:project, :repository_disabled, skip_disk_validation: true) }
let_it_be(:user) { project.creator }
let_it_be(:start) { RelativePositioning::START_POSITION }
diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb
index 68015a2327e..bb1151dfac7 100644
--- a/spec/services/issues/reopen_service_spec.rb
+++ b/spec/services/issues/reopen_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::ReopenService do
+RSpec.describe Issues::ReopenService, feature_category: :team_planning do
let(:project) { create(:project) }
let(:issue) { create(:issue, :closed, project: project) }
@@ -75,7 +75,6 @@ RSpec.describe Issues::ReopenService do
it_behaves_like 'an incident management tracked event', :incident_management_incident_reopened
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { issue.namespace }
let(:category) { described_class.to_s }
let(:action) { 'incident_management_incident_reopened' }
@@ -110,8 +109,8 @@ RSpec.describe Issues::ReopenService do
end
it 'executes issue hooks' do
- expect(project).to receive(:execute_hooks).with(expected_payload, :issue_hooks)
- expect(project).to receive(:execute_integrations).with(expected_payload, :issue_hooks)
+ expect(project.project_namespace).to receive(:execute_hooks).with(expected_payload, :issue_hooks)
+ expect(project.project_namespace).to receive(:execute_integrations).with(expected_payload, :issue_hooks)
execute
end
@@ -121,8 +120,9 @@ RSpec.describe Issues::ReopenService do
let(:issue) { create(:issue, :confidential, :closed, project: project) }
it 'executes confidential issue hooks' do
- expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :confidential_issue_hooks)
- expect(project).to receive(:execute_integrations).with(an_instance_of(Hash), :confidential_issue_hooks)
+ issue_hooks = :confidential_issue_hooks
+ expect(project.project_namespace).to receive(:execute_hooks).with(an_instance_of(Hash), issue_hooks)
+ expect(project.project_namespace).to receive(:execute_integrations).with(an_instance_of(Hash), issue_hooks)
execute
end
diff --git a/spec/services/issues/reorder_service_spec.rb b/spec/services/issues/reorder_service_spec.rb
index 430a9e9f526..b98d23e0f7f 100644
--- a/spec/services/issues/reorder_service_spec.rb
+++ b/spec/services/issues/reorder_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::ReorderService do
+RSpec.describe Issues::ReorderService, feature_category: :team_planning do
let_it_be(:user) { create_default(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project, reload: true) { create(:project, namespace: group) }
diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb
index 1ac71b966bc..c2111bffdda 100644
--- a/spec/services/issues/resolve_discussions_spec.rb
+++ b/spec/services/issues/resolve_discussions_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::ResolveDiscussions do
+RSpec.describe Issues::ResolveDiscussions, feature_category: :team_planning do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
@@ -11,7 +11,7 @@ RSpec.describe Issues::ResolveDiscussions do
DummyService.class_eval do
include ::Issues::ResolveDiscussions
- def initialize(project:, current_user: nil, params: {})
+ def initialize(container:, current_user: nil, params: {})
super
filter_resolve_discussion_params
end
@@ -26,7 +26,7 @@ RSpec.describe Issues::ResolveDiscussions do
let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "fix") }
describe "#merge_request_for_resolving_discussion" do
- let(:service) { DummyService.new(project: project, current_user: user, params: { merge_request_to_resolve_discussions_of: merge_request.iid }) }
+ let(:service) { DummyService.new(container: project, current_user: user, params: { merge_request_to_resolve_discussions_of: merge_request.iid }) }
it "finds the merge request" do
expect(service.merge_request_to_resolve_discussions_of).to eq(merge_request)
@@ -45,7 +45,7 @@ RSpec.describe Issues::ResolveDiscussions do
describe "#discussions_to_resolve" do
it "contains a single discussion when matching merge request and discussion are passed" do
service = DummyService.new(
- project: project,
+ container: project,
current_user: user,
params: {
discussion_to_resolve: discussion.id,
@@ -65,7 +65,7 @@ RSpec.describe Issues::ResolveDiscussions do
project: merge_request.target_project,
line_number: 15)])
service = DummyService.new(
- project: project,
+ container: project,
current_user: user,
params: { merge_request_to_resolve_discussions_of: merge_request.iid }
)
@@ -83,7 +83,7 @@ RSpec.describe Issues::ResolveDiscussions do
line_number: 15
)])
service = DummyService.new(
- project: project,
+ container: project,
current_user: user,
params: { merge_request_to_resolve_discussions_of: merge_request.iid }
)
@@ -96,7 +96,7 @@ RSpec.describe Issues::ResolveDiscussions do
it "is empty when a discussion and another merge request are passed" do
service = DummyService.new(
- project: project,
+ container: project,
current_user: user,
params: {
discussion_to_resolve: discussion.id,
diff --git a/spec/services/issues/set_crm_contacts_service_spec.rb b/spec/services/issues/set_crm_contacts_service_spec.rb
index 5613cc49cc5..aa5dec20a13 100644
--- a/spec/services/issues/set_crm_contacts_service_spec.rb
+++ b/spec/services/issues/set_crm_contacts_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::SetCrmContactsService do
+RSpec.describe Issues::SetCrmContactsService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:project) { create(:project, group: create(:group, :crm_enabled, parent: group)) }
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index 973025bd2e3..f96fbf54f08 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::UpdateService, :mailer do
+RSpec.describe Issues::UpdateService, :mailer, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:user2) { create(:user) }
let_it_be(:user3) { create(:user) }
@@ -191,7 +191,6 @@ RSpec.describe Issues::UpdateService, :mailer do
it_behaves_like 'an incident management tracked event', :incident_management_incident_change_confidential
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { issue.namespace }
let(:category) { described_class.to_s }
let(:label) { 'redis_hll_counters.incident_management.incident_management_total_unique_counts_monthly' }
@@ -260,7 +259,7 @@ RSpec.describe Issues::UpdateService, :mailer do
it 'creates system note about issue type' do
update_issue(issue_type: 'incident')
- note = find_note('changed issue type to incident')
+ note = find_note('changed type from issue to incident')
expect(note).not_to eq(nil)
end
@@ -593,8 +592,8 @@ RSpec.describe Issues::UpdateService, :mailer do
end
it 'executes confidential issue hooks' do
- expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :confidential_issue_hooks)
- expect(project).to receive(:execute_integrations).with(an_instance_of(Hash), :confidential_issue_hooks)
+ expect(project.project_namespace).to receive(:execute_hooks).with(an_instance_of(Hash), :confidential_issue_hooks)
+ expect(project.project_namespace).to receive(:execute_integrations).with(an_instance_of(Hash), :confidential_issue_hooks)
update_issue(confidential: true)
end
@@ -696,7 +695,6 @@ RSpec.describe Issues::UpdateService, :mailer do
it_behaves_like 'an incident management tracked event', :incident_management_incident_assigned
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { issue.namespace }
let(:category) { described_class.to_s }
let(:label) { 'redis_hll_counters.incident_management.incident_management_total_unique_counts_monthly' }
@@ -1109,19 +1107,37 @@ RSpec.describe Issues::UpdateService, :mailer do
end
context 'updating asssignee_id' do
+ it 'changes assignee' do
+ expect_next_instance_of(NotificationService::Async) do |service|
+ expect(service).to receive(:reassigned_issue).with(issue, user, [user3])
+ end
+
+ update_issue(assignee_ids: [user2.id])
+
+ expect(issue.reload.assignees).to eq([user2])
+ end
+
it 'does not update assignee when assignee_id is invalid' do
+ expect(NotificationService).not_to receive(:new)
+
update_issue(assignee_ids: [-1])
expect(issue.reload.assignees).to eq([user3])
end
it 'unassigns assignee when user id is 0' do
+ expect_next_instance_of(NotificationService::Async) do |service|
+ expect(service).to receive(:reassigned_issue).with(issue, user, [user3])
+ end
+
update_issue(assignee_ids: [0])
expect(issue.reload.assignees).to be_empty
end
it 'does not update assignee_id when user cannot read issue' do
+ expect(NotificationService).not_to receive(:new)
+
update_issue(assignee_ids: [create(:user).id])
expect(issue.reload.assignees).to eq([user3])
@@ -1132,6 +1148,8 @@ RSpec.describe Issues::UpdateService, :mailer do
levels.each do |level|
it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do
+ expect(NotificationService).not_to receive(:new)
+
assignee = create(:user)
project.update!(visibility_level: level)
feature_visibility_attr = :"#{issue.model_name.plural}_access_level"
@@ -1141,6 +1159,39 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
end
+
+ it 'tracks the assignment events' do
+ original_assignee = issue.assignees.first!
+
+ update_issue(assignee_ids: [user2.id])
+ update_issue(assignee_ids: [])
+ update_issue(assignee_ids: [user3.id])
+
+ expected_events = [
+ have_attributes({
+ issue_id: issue.id,
+ user_id: original_assignee.id,
+ action: 'remove'
+ }),
+ have_attributes({
+ issue_id: issue.id,
+ user_id: user2.id,
+ action: 'add'
+ }),
+ have_attributes({
+ issue_id: issue.id,
+ user_id: user2.id,
+ action: 'remove'
+ }),
+ have_attributes({
+ issue_id: issue.id,
+ user_id: user3.id,
+ action: 'add'
+ })
+ ]
+
+ expect(issue.assignment_events).to match_array(expected_events)
+ end
end
context 'updating mentions' do
@@ -1166,9 +1217,9 @@ RSpec.describe Issues::UpdateService, :mailer do
end
it 'triggers webhooks' do
- expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks)
- expect(project).to receive(:execute_integrations).with(an_instance_of(Hash), :issue_hooks)
- expect(project).to receive(:execute_integrations).with(an_instance_of(Hash), :incident_hooks)
+ expect(project.project_namespace).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks)
+ expect(project.project_namespace).to receive(:execute_integrations).with(an_instance_of(Hash), :issue_hooks)
+ expect(project.project_namespace).to receive(:execute_integrations).with(an_instance_of(Hash), :incident_hooks)
update_issue(opts)
end
@@ -1280,9 +1331,9 @@ RSpec.describe Issues::UpdateService, :mailer do
end
it 'triggers webhooks' do
- expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks)
- expect(project).to receive(:execute_integrations).with(an_instance_of(Hash), :issue_hooks)
- expect(project).to receive(:execute_integrations).with(an_instance_of(Hash), :incident_hooks)
+ expect(project.project_namespace).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks)
+ expect(project.project_namespace).to receive(:execute_integrations).with(an_instance_of(Hash), :issue_hooks)
+ expect(project.project_namespace).to receive(:execute_integrations).with(an_instance_of(Hash), :incident_hooks)
update_issue(opts)
end
@@ -1475,31 +1526,5 @@ RSpec.describe Issues::UpdateService, :mailer do
let(:existing_issue) { create(:issue, project: project) }
let(:issuable) { described_class.new(container: project, current_user: user, params: params).execute(existing_issue) }
end
-
- context 'with quick actions' do
- context 'as work item' do
- let(:opts) { { description: "/shrug" } }
-
- context 'when work item type is not the default Issue' do
- let(:issue) { create(:work_item, :task, description: "") }
-
- it 'does not apply the quick action' do
- expect do
- update_issue(opts)
- end.to change(issue, :description).to("/shrug")
- end
- end
-
- context 'when work item type is the default Issue' do
- let(:issue) { create(:work_item, :issue, description: "") }
-
- it 'does not apply the quick action' do
- expect do
- update_issue(opts)
- end.to change(issue, :description).to(" ¯\\_(ツ)_/¯")
- end
- end
- end
- end
end
end
diff --git a/spec/services/issues/zoom_link_service_spec.rb b/spec/services/issues/zoom_link_service_spec.rb
index 230e4c1b5e1..f2a81cbe33f 100644
--- a/spec/services/issues/zoom_link_service_spec.rb
+++ b/spec/services/issues/zoom_link_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::ZoomLinkService do
+RSpec.describe Issues::ZoomLinkService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue) }
@@ -97,7 +97,6 @@ RSpec.describe Issues::ZoomLinkService do
it_behaves_like 'an incident management tracked event', :incident_management_incident_zoom_meeting
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { issue.namespace }
let(:category) { described_class.to_s }
let(:action) { 'incident_management_incident_zoom_meeting' }
diff --git a/spec/services/jira/requests/projects/list_service_spec.rb b/spec/services/jira/requests/projects/list_service_spec.rb
index 78ee9cb9698..37e9f66d273 100644
--- a/spec/services/jira/requests/projects/list_service_spec.rb
+++ b/spec/services/jira/requests/projects/list_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Jira::Requests::Projects::ListService do
+RSpec.describe Jira::Requests::Projects::ListService, feature_category: :projects do
include AfterNextHelpers
let(:jira_integration) { create(:jira_integration) }
diff --git a/spec/services/jira_connect/sync_service_spec.rb b/spec/services/jira_connect/sync_service_spec.rb
index 32580a7735f..7457cdca13c 100644
--- a/spec/services/jira_connect/sync_service_spec.rb
+++ b/spec/services/jira_connect/sync_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraConnect::SyncService do
+RSpec.describe JiraConnect::SyncService, feature_category: :integrations do
include AfterNextHelpers
describe '#execute' do
@@ -44,16 +44,18 @@ RSpec.describe JiraConnect::SyncService do
subject
end
- context 'when a request returns an error' do
- it 'logs the response as an error' do
+ context 'when a request returns errors' do
+ it 'logs each response as an error' do
expect_next(client).to store_info(
[
{ 'errorMessages' => ['some error message'] },
- { 'errorMessages' => ['x'] }
+ { 'errorMessage' => 'a single error message' },
+ { 'errorMessages' => [] },
+ { 'errorMessage' => '' }
])
expect_log(:error, { 'errorMessages' => ['some error message'] })
- expect_log(:error, { 'errorMessages' => ['x'] })
+ expect_log(:error, { 'errorMessage' => 'a single error message' })
subject
end
diff --git a/spec/services/jira_connect_installations/destroy_service_spec.rb b/spec/services/jira_connect_installations/destroy_service_spec.rb
index bb5bab53ccb..b8b59d6cc57 100644
--- a/spec/services/jira_connect_installations/destroy_service_spec.rb
+++ b/spec/services/jira_connect_installations/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraConnectInstallations::DestroyService do
+RSpec.describe JiraConnectInstallations::DestroyService, feature_category: :integrations do
describe '.execute' do
it 'creates an instance and calls execute' do
expect_next_instance_of(described_class, 'param1', 'param2', 'param3') do |destroy_service|
diff --git a/spec/services/jira_connect_installations/proxy_lifecycle_event_service_spec.rb b/spec/services/jira_connect_installations/proxy_lifecycle_event_service_spec.rb
index c621388a734..3c144de2208 100644
--- a/spec/services/jira_connect_installations/proxy_lifecycle_event_service_spec.rb
+++ b/spec/services/jira_connect_installations/proxy_lifecycle_event_service_spec.rb
@@ -94,9 +94,9 @@ RSpec.describe JiraConnectInstallations::ProxyLifecycleEventService, feature_cat
expect(Gitlab::IntegrationsLogger).to receive(:info).with(
integration: 'JiraConnect',
message: 'Proxy lifecycle event received error response',
- event_type: evnet_type,
- status_code: 422,
- body: 'Error message'
+ jira_event_type: evnet_type,
+ jira_status_code: 422,
+ jira_body: 'Error message'
)
execute_service
diff --git a/spec/services/jira_connect_subscriptions/create_service_spec.rb b/spec/services/jira_connect_subscriptions/create_service_spec.rb
index 85208a30c30..f9d3954b84c 100644
--- a/spec/services/jira_connect_subscriptions/create_service_spec.rb
+++ b/spec/services/jira_connect_subscriptions/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraConnectSubscriptions::CreateService do
+RSpec.describe JiraConnectSubscriptions::CreateService, feature_category: :integrations do
let_it_be(:installation) { create(:jira_connect_installation) }
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group) }
diff --git a/spec/services/jira_import/cloud_users_mapper_service_spec.rb b/spec/services/jira_import/cloud_users_mapper_service_spec.rb
index 6b06a982a80..e3f3d550467 100644
--- a/spec/services/jira_import/cloud_users_mapper_service_spec.rb
+++ b/spec/services/jira_import/cloud_users_mapper_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraImport::CloudUsersMapperService do
+RSpec.describe JiraImport::CloudUsersMapperService, feature_category: :integrations do
let(:start_at) { 7 }
let(:url) { "/rest/api/2/users?maxResults=50&startAt=#{start_at}" }
diff --git a/spec/services/jira_import/server_users_mapper_service_spec.rb b/spec/services/jira_import/server_users_mapper_service_spec.rb
index 71cb8aea0be..e2304953dd2 100644
--- a/spec/services/jira_import/server_users_mapper_service_spec.rb
+++ b/spec/services/jira_import/server_users_mapper_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraImport::ServerUsersMapperService do
+RSpec.describe JiraImport::ServerUsersMapperService, feature_category: :integrations do
let(:start_at) { 7 }
let(:url) { "/rest/api/2/user/search?username=''&maxResults=50&startAt=#{start_at}" }
diff --git a/spec/services/jira_import/start_import_service_spec.rb b/spec/services/jira_import/start_import_service_spec.rb
index c0db3012a30..9cb163e3d1a 100644
--- a/spec/services/jira_import/start_import_service_spec.rb
+++ b/spec/services/jira_import/start_import_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraImport::StartImportService do
+RSpec.describe JiraImport::StartImportService, feature_category: :integrations do
include JiraIntegrationHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/jira_import/users_importer_spec.rb b/spec/services/jira_import/users_importer_spec.rb
index ace9e0d5779..39f8475754a 100644
--- a/spec/services/jira_import/users_importer_spec.rb
+++ b/spec/services/jira_import/users_importer_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraImport::UsersImporter do
+RSpec.describe JiraImport::UsersImporter, feature_category: :integrations do
include JiraIntegrationHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/keys/create_service_spec.rb b/spec/services/keys/create_service_spec.rb
index 1dbe383ad8e..0a9fe2f5856 100644
--- a/spec/services/keys/create_service_spec.rb
+++ b/spec/services/keys/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Keys::CreateService do
+RSpec.describe Keys::CreateService, feature_category: :source_code_management do
let(:user) { create(:user) }
let(:params) { attributes_for(:key) }
diff --git a/spec/services/keys/destroy_service_spec.rb b/spec/services/keys/destroy_service_spec.rb
index dd40f9d73fd..9f064cb7236 100644
--- a/spec/services/keys/destroy_service_spec.rb
+++ b/spec/services/keys/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Keys::DestroyService do
+RSpec.describe Keys::DestroyService, feature_category: :source_code_management do
let(:user) { create(:user) }
subject { described_class.new(user) }
diff --git a/spec/services/keys/expiry_notification_service_spec.rb b/spec/services/keys/expiry_notification_service_spec.rb
index 7cb6cbce311..b8db4f28df7 100644
--- a/spec/services/keys/expiry_notification_service_spec.rb
+++ b/spec/services/keys/expiry_notification_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Keys::ExpiryNotificationService do
+RSpec.describe Keys::ExpiryNotificationService, feature_category: :source_code_management do
let_it_be_with_reload(:user) { create(:user) }
let(:params) { { keys: user.keys, expiring_soon: expiring_soon } }
diff --git a/spec/services/keys/last_used_service_spec.rb b/spec/services/keys/last_used_service_spec.rb
index a2cd5ffdd38..32100d793ff 100644
--- a/spec/services/keys/last_used_service_spec.rb
+++ b/spec/services/keys/last_used_service_spec.rb
@@ -2,33 +2,51 @@
require 'spec_helper'
-RSpec.describe Keys::LastUsedService do
+RSpec.describe Keys::LastUsedService, feature_category: :source_code_management do
describe '#execute', :clean_gitlab_redis_shared_state do
- it 'updates the key when it has not been used recently' do
- key = create(:key, last_used_at: 1.year.ago)
- time = Time.zone.now
+ context 'when it has not been used recently' do
+ let(:key) { create(:key, last_used_at: 1.year.ago) }
+ let(:time) { Time.zone.now }
- travel_to(time) { described_class.new(key).execute }
+ it 'updates the key' do
+ travel_to(time) { described_class.new(key).execute }
- expect(key.reload.last_used_at).to be_like_time(time)
+ expect(key.reload.last_used_at).to be_like_time(time)
+ end
end
- it 'does not update the key when it has been used recently' do
- time = 1.minute.ago
- key = create(:key, last_used_at: time)
+ context 'when it has been used recently' do
+ let(:time) { 1.minute.ago }
+ let(:key) { create(:key, last_used_at: time) }
- described_class.new(key).execute
+ it 'does not update the key' do
+ described_class.new(key).execute
- expect(key.last_used_at).to be_like_time(time)
+ expect(key.reload.last_used_at).to be_like_time(time)
+ end
end
+ end
+
+ describe '#execute_async', :clean_gitlab_redis_shared_state do
+ context 'when it has not been used recently' do
+ let(:key) { create(:key, last_used_at: 1.year.ago) }
+ let(:time) { Time.zone.now }
- it 'does not update the updated_at field' do
- # Since a lot of these updates could happen in parallel for different keys
- # we want these updates to be as lightweight as possible, hence we want to
- # make sure we _only_ update last_used_at and not always updated_at.
- key = create(:key, last_used_at: 1.year.ago)
+ it 'schedules a job to update last_used_at' do
+ expect(::SshKeys::UpdateLastUsedAtWorker).to receive(:perform_async)
- expect { described_class.new(key).execute }.not_to change { key.updated_at }
+ travel_to(time) { described_class.new(key).execute_async }
+ end
+ end
+
+ context 'when it has been used recently' do
+ let(:key) { create(:key, last_used_at: 1.minute.ago) }
+
+ it 'does not schedule a job to update last_used_at' do
+ expect(::SshKeys::UpdateLastUsedAtWorker).not_to receive(:perform_async)
+
+ described_class.new(key).execute_async
+ end
end
end
@@ -47,14 +65,6 @@ RSpec.describe Keys::LastUsedService do
expect(service.update?).to eq(true)
end
- it 'returns false when a lease has already been obtained' do
- key = build(:key, last_used_at: 1.year.ago)
- service = described_class.new(key)
-
- expect(service.update?).to eq(true)
- expect(service.update?).to eq(false)
- end
-
it 'returns false when the key does not yet need to be updated' do
key = build(:key, last_used_at: 1.minute.ago)
service = described_class.new(key)
diff --git a/spec/services/keys/revoke_service_spec.rb b/spec/services/keys/revoke_service_spec.rb
index ec07701b4b7..8294ec5bbd1 100644
--- a/spec/services/keys/revoke_service_spec.rb
+++ b/spec/services/keys/revoke_service_spec.rb
@@ -32,17 +32,4 @@ RSpec.describe Keys::RevokeService, feature_category: :source_code_management do
expect { service.execute(key) }.not_to change { signature.reload.verification_status }
expect(key).to be_persisted
end
-
- context 'when revoke_ssh_signatures disabled' do
- before do
- stub_feature_flags(revoke_ssh_signatures: false)
- end
-
- it 'does not unverifies signatures' do
- key = create(:key)
- signature = create(:ssh_signature, key: key)
-
- expect { service.execute(key) }.not_to change { signature.reload.verification_status }
- end
- end
end
diff --git a/spec/services/labels/available_labels_service_spec.rb b/spec/services/labels/available_labels_service_spec.rb
index 355dbd0c712..51314c2c226 100644
--- a/spec/services/labels/available_labels_service_spec.rb
+++ b/spec/services/labels/available_labels_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Labels::AvailableLabelsService do
+RSpec.describe Labels::AvailableLabelsService, feature_category: :team_planning do
let(:user) { create(:user) }
let(:project) { create(:project, :public, group: group) }
let(:group) { create(:group) }
diff --git a/spec/services/labels/create_service_spec.rb b/spec/services/labels/create_service_spec.rb
index 02dec8ae690..9be611490cf 100644
--- a/spec/services/labels/create_service_spec.rb
+++ b/spec/services/labels/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Labels::CreateService do
+RSpec.describe Labels::CreateService, feature_category: :team_planning do
describe '#execute' do
let(:project) { create(:project) }
let(:group) { create(:group) }
diff --git a/spec/services/labels/find_or_create_service_spec.rb b/spec/services/labels/find_or_create_service_spec.rb
index 3ea2727dc60..0bc1326942d 100644
--- a/spec/services/labels/find_or_create_service_spec.rb
+++ b/spec/services/labels/find_or_create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Labels::FindOrCreateService do
+RSpec.describe Labels::FindOrCreateService, feature_category: :team_planning do
describe '#execute' do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
diff --git a/spec/services/labels/promote_service_spec.rb b/spec/services/labels/promote_service_spec.rb
index 3af6cf4c8f4..79cc88c65c8 100644
--- a/spec/services/labels/promote_service_spec.rb
+++ b/spec/services/labels/promote_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Labels::PromoteService do
+RSpec.describe Labels::PromoteService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:user) { create(:user) }
diff --git a/spec/services/labels/transfer_service_spec.rb b/spec/services/labels/transfer_service_spec.rb
index e67ab6025a5..bf895692e64 100644
--- a/spec/services/labels/transfer_service_spec.rb
+++ b/spec/services/labels/transfer_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Labels::TransferService do
+RSpec.describe Labels::TransferService, feature_category: :team_planning do
shared_examples 'transfer labels' do
describe '#execute' do
let_it_be(:user) { create(:user) }
diff --git a/spec/services/labels/update_service_spec.rb b/spec/services/labels/update_service_spec.rb
index abc456f75f9..b9ac5282d10 100644
--- a/spec/services/labels/update_service_spec.rb
+++ b/spec/services/labels/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Labels::UpdateService do
+RSpec.describe Labels::UpdateService, feature_category: :team_planning do
describe '#execute' do
let(:project) { create(:project) }
diff --git a/spec/services/lfs/lock_file_service_spec.rb b/spec/services/lfs/lock_file_service_spec.rb
index b3a121866c8..47bf0c5f4ce 100644
--- a/spec/services/lfs/lock_file_service_spec.rb
+++ b/spec/services/lfs/lock_file_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Lfs::LockFileService do
+RSpec.describe Lfs::LockFileService, feature_category: :source_code_management do
let(:project) { create(:project) }
let(:current_user) { create(:user) }
diff --git a/spec/services/lfs/locks_finder_service_spec.rb b/spec/services/lfs/locks_finder_service_spec.rb
index 1167212eb69..38f8dadd38d 100644
--- a/spec/services/lfs/locks_finder_service_spec.rb
+++ b/spec/services/lfs/locks_finder_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Lfs::LocksFinderService do
+RSpec.describe Lfs::LocksFinderService, feature_category: :source_code_management do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:params) { {} }
diff --git a/spec/services/lfs/push_service_spec.rb b/spec/services/lfs/push_service_spec.rb
index f52bba94eea..1ec143a7fc9 100644
--- a/spec/services/lfs/push_service_spec.rb
+++ b/spec/services/lfs/push_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Lfs::PushService do
+RSpec.describe Lfs::PushService, feature_category: :source_code_management do
let(:logger) { service.send(:logger) }
let(:lfs_client) { service.send(:lfs_client) }
diff --git a/spec/services/lfs/unlock_file_service_spec.rb b/spec/services/lfs/unlock_file_service_spec.rb
index 7ab269f897a..45fd1adcfb4 100644
--- a/spec/services/lfs/unlock_file_service_spec.rb
+++ b/spec/services/lfs/unlock_file_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Lfs::UnlockFileService do
+RSpec.describe Lfs::UnlockFileService, feature_category: :source_code_management do
let(:project) { create(:project) }
let(:current_user) { create(:user) }
let(:lock_author) { create(:user) }
diff --git a/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb b/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb
index 735f090d926..6eee83d5ee9 100644
--- a/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb
+++ b/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe LooseForeignKeys::BatchCleanerService do
+RSpec.describe LooseForeignKeys::BatchCleanerService, feature_category: :database do
include MigrationsHelpers
def create_table_structure
diff --git a/spec/services/loose_foreign_keys/cleaner_service_spec.rb b/spec/services/loose_foreign_keys/cleaner_service_spec.rb
index 2cfd8385953..04f6270c5f2 100644
--- a/spec/services/loose_foreign_keys/cleaner_service_spec.rb
+++ b/spec/services/loose_foreign_keys/cleaner_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe LooseForeignKeys::CleanerService do
+RSpec.describe LooseForeignKeys::CleanerService, feature_category: :database do
let(:schema) { ApplicationRecord.connection.current_schema }
let(:deleted_records) do
[
diff --git a/spec/services/loose_foreign_keys/process_deleted_records_service_spec.rb b/spec/services/loose_foreign_keys/process_deleted_records_service_spec.rb
index 1824f822ba8..af010547cc9 100644
--- a/spec/services/loose_foreign_keys/process_deleted_records_service_spec.rb
+++ b/spec/services/loose_foreign_keys/process_deleted_records_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe LooseForeignKeys::ProcessDeletedRecordsService do
+RSpec.describe LooseForeignKeys::ProcessDeletedRecordsService, feature_category: :database do
include MigrationsHelpers
def create_table_structure
diff --git a/spec/services/markdown_content_rewriter_service_spec.rb b/spec/services/markdown_content_rewriter_service_spec.rb
index d94289856cf..bf15ef08647 100644
--- a/spec/services/markdown_content_rewriter_service_spec.rb
+++ b/spec/services/markdown_content_rewriter_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MarkdownContentRewriterService do
+RSpec.describe MarkdownContentRewriterService, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
let_it_be(:source_parent) { create(:project, :public) }
let_it_be(:target_parent) { create(:project, :public) }
diff --git a/spec/services/markup/rendering_service_spec.rb b/spec/services/markup/rendering_service_spec.rb
index 99ab87f2072..952ee33da98 100644
--- a/spec/services/markup/rendering_service_spec.rb
+++ b/spec/services/markup/rendering_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Markup::RenderingService do
+RSpec.describe Markup::RenderingService, feature_category: :projects do
describe '#execute' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) do
@@ -111,5 +111,22 @@ RSpec.describe Markup::RenderingService do
is_expected.to eq(expected_html)
end
end
+
+ context 'with reStructuredText' do
+ let(:file_name) { 'foo.rst' }
+ let(:text) { "####\nPART\n####" }
+
+ it 'returns rendered html' do
+ is_expected.to eq("<h1>PART</h1>\n\n")
+ end
+
+ context 'when input has an invalid syntax' do
+ let(:text) { "####\nPART\n##" }
+
+ it 'uses a simple formatter for html' do
+ is_expected.to eq("<p>####\n<br>PART\n<br>##</p>")
+ end
+ end
+ end
end
end
diff --git a/spec/services/mattermost/create_team_service_spec.rb b/spec/services/mattermost/create_team_service_spec.rb
new file mode 100644
index 00000000000..b9e5162aab4
--- /dev/null
+++ b/spec/services/mattermost/create_team_service_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mattermost::CreateTeamService, feature_category: :integrations do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+
+ subject { described_class.new(group, user) }
+
+ it 'creates a team' do
+ expect_next_instance_of(::Mattermost::Team) do |instance|
+ expect(instance).to receive(:create).with(name: anything, display_name: anything, type: anything)
+ end
+
+ subject.execute
+ end
+
+ it 'adds an error if a team could not be created' do
+ expect_next_instance_of(::Mattermost::Team) do |instance|
+ expect(instance).to receive(:create).and_raise(::Mattermost::ClientError, 'client error')
+ end
+
+ subject.execute
+
+ expect(group.errors).to be_present
+ end
+end
diff --git a/spec/services/members/approve_access_request_service_spec.rb b/spec/services/members/approve_access_request_service_spec.rb
index ca5c052d032..6c0d47e98ba 100644
--- a/spec/services/members/approve_access_request_service_spec.rb
+++ b/spec/services/members/approve_access_request_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Members::ApproveAccessRequestService do
+RSpec.describe Members::ApproveAccessRequestService, feature_category: :subgroups do
let(:project) { create(:project, :public) }
let(:group) { create(:group, :public) }
let(:current_user) { create(:user) }
@@ -14,13 +14,17 @@ RSpec.describe Members::ApproveAccessRequestService do
shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do
it 'raises Gitlab::Access::AccessDeniedError' do
- expect { described_class.new(current_user, params).execute(access_requester, **opts) }.to raise_error(Gitlab::Access::AccessDeniedError)
+ expect do
+ described_class.new(current_user, params).execute(access_requester, **opts)
+ end.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
shared_examples 'a service approving an access request' do
it 'succeeds' do
- expect { described_class.new(current_user, params).execute(access_requester, **opts) }.to change { source.requesters.count }.by(-1)
+ expect do
+ described_class.new(current_user, params).execute(access_requester, **opts)
+ end.to change { source.requesters.count }.by(-1)
end
it 'returns a <Source>Member' do
@@ -32,7 +36,15 @@ RSpec.describe Members::ApproveAccessRequestService do
it 'calls the method to resolve access request for the approver' do
expect_next_instance_of(described_class) do |instance|
- expect(instance).to receive(:resolve_access_request_todos).with(current_user, access_requester)
+ expect(instance).to receive(:resolve_access_request_todos).with(access_requester)
+ end
+
+ described_class.new(current_user, params).execute(access_requester, **opts)
+ end
+
+ it 'resolves the todos for the access requests' do
+ expect_next_instance_of(TodoService) do |instance|
+ expect(instance).to receive(:resolve_access_request_todos).with(access_requester)
end
described_class.new(current_user, params).execute(access_requester, **opts)
diff --git a/spec/services/members/base_service_spec.rb b/spec/services/members/base_service_spec.rb
index b2db599db9c..514c25fbc03 100644
--- a/spec/services/members/base_service_spec.rb
+++ b/spec/services/members/base_service_spec.rb
@@ -3,17 +3,16 @@
require 'spec_helper'
RSpec.describe Members::BaseService, feature_category: :projects do
- let_it_be(:current_user) { create(:user) }
let_it_be(:access_requester) { create(:group_member) }
describe '#resolve_access_request_todos' do
it 'calls the resolve_access_request_todos of todo service' do
expect_next_instance_of(TodoService) do |todo_service|
expect(todo_service)
- .to receive(:resolve_access_request_todos).with(current_user, access_requester)
+ .to receive(:resolve_access_request_todos).with(access_requester)
end
- described_class.new.send(:resolve_access_request_todos, current_user, access_requester)
+ described_class.new.send(:resolve_access_request_todos, access_requester)
end
end
end
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index 756e1cf403c..13f233162cd 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state, :sidekiq_inline do
+RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state, :sidekiq_inline,
+ feature_category: :subgroups do
let_it_be(:source, reload: true) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:member) { create(:user) }
diff --git a/spec/services/members/creator_service_spec.rb b/spec/services/members/creator_service_spec.rb
index ad4c649086b..8191eefbe95 100644
--- a/spec/services/members/creator_service_spec.rb
+++ b/spec/services/members/creator_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Members::CreatorService do
+RSpec.describe Members::CreatorService, feature_category: :subgroups do
let_it_be(:source, reload: true) { create(:group, :public) }
let_it_be(:member_type) { GroupMember }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb
index 2b956bec469..498b9576875 100644
--- a/spec/services/members/destroy_service_spec.rb
+++ b/spec/services/members/destroy_service_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe Members::DestroyService, feature_category: :subgroups do
it 'resolves the access request todos for the owner' do
expect_next_instance_of(described_class) do |instance|
- expect(instance).to receive(:resolve_access_request_todos).with(current_user, member)
+ expect(instance).to receive(:resolve_access_request_todos).with(member)
end
described_class.new(current_user).execute(member, **opts)
@@ -463,16 +463,26 @@ RSpec.describe Members::DestroyService, feature_category: :subgroups do
end
context 'subresources' do
- let(:user) { create(:user) }
- let(:member_user) { create(:user) }
+ let_it_be_with_reload(:user) { create(:user) }
+ let_it_be_with_reload(:member_user) { create(:user) }
+
+ let_it_be_with_reload(:group) { create(:group, :public) }
+ let_it_be_with_reload(:subgroup) { create(:group, parent: group) }
+ let_it_be(:private_subgroup) { create(:group, :private, parent: group, name: 'private_subgroup') }
+ let_it_be(:private_subgroup_with_direct_membership) { create(:group, :private, parent: group) }
+ let_it_be_with_reload(:subsubgroup) { create(:group, parent: subgroup) }
+
+ let_it_be_with_reload(:group_project) { create(:project, :public, group: group) }
+ let_it_be_with_reload(:control_project) { create(:project, :private, group: subsubgroup) }
+ let_it_be_with_reload(:subsubproject) { create(:project, :public, group: subsubgroup) }
- let(:group) { create(:group, :public) }
- let(:subgroup) { create(:group, parent: group) }
- let(:subsubgroup) { create(:group, parent: subgroup) }
- let(:subsubproject) { create(:project, group: subsubgroup) }
+ let_it_be(:private_subgroup_project) do
+ create(:project, :private, group: private_subgroup, name: 'private_subgroup_project')
+ end
- let(:group_project) { create(:project, :public, group: group) }
- let(:control_project) { create(:project, group: subsubgroup) }
+ let_it_be(:private_subgroup_with_direct_membership_project) do
+ create(:project, :private, group: private_subgroup_with_direct_membership, name: 'private_subgroup_project')
+ end
context 'with memberships' do
before do
@@ -481,14 +491,68 @@ RSpec.describe Members::DestroyService, feature_category: :subgroups do
subsubproject.add_developer(member_user)
group_project.add_developer(member_user)
control_project.add_maintainer(user)
+ private_subgroup_with_direct_membership.add_developer(member_user)
group.add_owner(user)
@group_member = create(:group_member, :developer, group: group, user: member_user)
end
+ let_it_be(:todo_in_public_group_project) do
+ create(:todo, :pending,
+ project: group_project,
+ user: member_user,
+ target: create(:issue, project: group_project)
+ )
+ end
+
+ let_it_be(:mr_in_public_group_project) do
+ create(:merge_request, source_project: group_project, assignees: [member_user])
+ end
+
+ let_it_be(:todo_in_private_subgroup_project) do
+ create(:todo, :pending,
+ project: private_subgroup_project,
+ user: member_user,
+ target: create(:issue, project: private_subgroup_project)
+ )
+ end
+
+ let_it_be(:mr_in_private_subgroup_project) do
+ create(:merge_request, source_project: private_subgroup_project, assignees: [member_user])
+ end
+
+ let_it_be(:todo_in_public_subsubgroup_project) do
+ create(:todo, :pending,
+ project: subsubproject,
+ user: member_user,
+ target: create(:issue, project: subsubproject)
+ )
+ end
+
+ let_it_be(:mr_in_public_subsubgroup_project) do
+ create(:merge_request, source_project: subsubproject, assignees: [member_user])
+ end
+
+ let_it_be(:todo_in_private_subgroup_with_direct_membership_project) do
+ create(:todo, :pending,
+ project: private_subgroup_with_direct_membership_project,
+ user: member_user,
+ target: create(:issue, project: private_subgroup_with_direct_membership_project)
+ )
+ end
+
+ let_it_be(:mr_in_private_subgroup_with_direct_membership_project) do
+ create(:merge_request,
+ source_project: private_subgroup_with_direct_membership_project,
+ assignees: [member_user]
+ )
+ end
+
context 'with skipping of subresources' do
+ subject(:execute_service) { described_class.new(user).execute(@group_member, skip_subresources: true) }
+
before do
- described_class.new(user).execute(@group_member, skip_subresources: true)
+ execute_service
end
it 'removes the group membership' do
@@ -514,11 +578,35 @@ RSpec.describe Members::DestroyService, feature_category: :subgroups do
it 'does not remove the user from the control project' do
expect(control_project.members.map(&:user)).to include(user)
end
+
+ context 'todos', :sidekiq_inline do
+ it 'removes todos for which the user no longer has access' do
+ expect(member_user.todos).to include(
+ todo_in_public_group_project,
+ todo_in_public_subsubgroup_project,
+ todo_in_private_subgroup_with_direct_membership_project
+ )
+
+ expect(member_user.todos).not_to include(todo_in_private_subgroup_project)
+ end
+ end
+
+ context 'issuables', :sidekiq_inline do
+ subject(:execute_service) do
+ described_class.new(user).execute(@group_member, skip_subresources: true, unassign_issuables: true)
+ end
+
+ it 'removes assigned issuables, even in subresources' do
+ expect(member_user.assigned_merge_requests).to be_empty
+ end
+ end
end
context 'without skipping of subresources' do
+ subject(:execute_service) { described_class.new(user).execute(@group_member, skip_subresources: false) }
+
before do
- described_class.new(user).execute(@group_member, skip_subresources: false)
+ execute_service
end
it 'removes the project membership' do
@@ -544,6 +632,30 @@ RSpec.describe Members::DestroyService, feature_category: :subgroups do
it 'does not remove the user from the control project' do
expect(control_project.members.map(&:user)).to include(user)
end
+
+ context 'todos', :sidekiq_inline do
+ it 'removes todos for which the user no longer has access' do
+ expect(member_user.todos).to include(
+ todo_in_public_group_project,
+ todo_in_public_subsubgroup_project
+ )
+
+ expect(member_user.todos).not_to include(
+ todo_in_private_subgroup_project,
+ todo_in_private_subgroup_with_direct_membership_project
+ )
+ end
+ end
+
+ context 'issuables', :sidekiq_inline do
+ subject(:execute_service) do
+ described_class.new(user).execute(@group_member, skip_subresources: false, unassign_issuables: true)
+ end
+
+ it 'removes assigned issuables' do
+ expect(member_user.assigned_merge_requests).to be_empty
+ end
+ end
end
end
@@ -626,4 +738,13 @@ RSpec.describe Members::DestroyService, feature_category: :subgroups do
expect(project.members.not_accepted_invitations_by_user(member_user)).to be_empty
end
end
+
+ describe '#mark_as_recursive_call' do
+ it 'marks the instance as recursive' do
+ service = described_class.new(current_user)
+ service.mark_as_recursive_call
+
+ expect(service.send(:recursive_call?)).to eq(true)
+ end
+ end
end
diff --git a/spec/services/members/groups/creator_service_spec.rb b/spec/services/members/groups/creator_service_spec.rb
index fced7195046..4c13106145e 100644
--- a/spec/services/members/groups/creator_service_spec.rb
+++ b/spec/services/members/groups/creator_service_spec.rb
@@ -2,8 +2,9 @@
require 'spec_helper'
-RSpec.describe Members::Groups::CreatorService do
+RSpec.describe Members::Groups::CreatorService, feature_category: :subgroups do
let_it_be(:source, reload: true) { create(:group, :public) }
+ let_it_be(:source2, reload: true) { create(:group, :public) }
let_it_be(:user) { create(:user) }
describe '.access_levels' do
@@ -16,6 +17,7 @@ RSpec.describe Members::Groups::CreatorService do
describe '.add_members' do
it_behaves_like 'bulk member creation' do
+ let_it_be(:source_type) { Group }
let_it_be(:member_type) { GroupMember }
end
end
diff --git a/spec/services/members/import_project_team_service_spec.rb b/spec/services/members/import_project_team_service_spec.rb
index 96e8db1ba73..af9b30aa0b3 100644
--- a/spec/services/members/import_project_team_service_spec.rb
+++ b/spec/services/members/import_project_team_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Members::ImportProjectTeamService do
+RSpec.describe Members::ImportProjectTeamService, feature_category: :subgroups do
describe '#execute' do
let_it_be(:source_project) { create(:project) }
let_it_be(:target_project) { create(:project) }
diff --git a/spec/services/members/invitation_reminder_email_service_spec.rb b/spec/services/members/invitation_reminder_email_service_spec.rb
index 768a8719d54..da23965eabe 100644
--- a/spec/services/members/invitation_reminder_email_service_spec.rb
+++ b/spec/services/members/invitation_reminder_email_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Members::InvitationReminderEmailService do
+RSpec.describe Members::InvitationReminderEmailService, feature_category: :subgroups do
describe 'sending invitation reminders' do
subject { described_class.new(invitation).execute }
diff --git a/spec/services/members/invite_member_builder_spec.rb b/spec/services/members/invite_member_builder_spec.rb
index 52de65364c4..e7bbec4e0ef 100644
--- a/spec/services/members/invite_member_builder_spec.rb
+++ b/spec/services/members/invite_member_builder_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Members::InviteMemberBuilder do
+RSpec.describe Members::InviteMemberBuilder, feature_category: :subgroups do
let_it_be(:source) { create(:group) }
let_it_be(:existing_member) { create(:group_member) }
diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb
index 23d4d671afc..22294b3fda5 100644
--- a/spec/services/members/invite_service_spec.rb
+++ b/spec/services/members/invite_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_shared_state, :sidekiq_inline do
+RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_shared_state, :sidekiq_inline,
+ feature_category: :subgroups do
let_it_be(:project, reload: true) { create(:project) }
let_it_be(:user) { project.first_owner }
let_it_be(:project_user) { create(:user) }
diff --git a/spec/services/members/projects/creator_service_spec.rb b/spec/services/members/projects/creator_service_spec.rb
index 5dfba7adf0f..7ec7361a285 100644
--- a/spec/services/members/projects/creator_service_spec.rb
+++ b/spec/services/members/projects/creator_service_spec.rb
@@ -2,8 +2,9 @@
require 'spec_helper'
-RSpec.describe Members::Projects::CreatorService do
+RSpec.describe Members::Projects::CreatorService, feature_category: :projects do
let_it_be(:source, reload: true) { create(:project, :public) }
+ let_it_be(:source2, reload: true) { create(:project, :public) }
let_it_be(:user) { create(:user) }
describe '.access_levels' do
@@ -16,6 +17,7 @@ RSpec.describe Members::Projects::CreatorService do
describe '.add_members' do
it_behaves_like 'bulk member creation' do
+ let_it_be(:source_type) { Project }
let_it_be(:member_type) { ProjectMember }
end
end
diff --git a/spec/services/members/request_access_service_spec.rb b/spec/services/members/request_access_service_spec.rb
index 69eea2aea4b..ef8ee6492ab 100644
--- a/spec/services/members/request_access_service_spec.rb
+++ b/spec/services/members/request_access_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Members::RequestAccessService do
+RSpec.describe Members::RequestAccessService, feature_category: :subgroups do
let(:user) { create(:user) }
shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do
diff --git a/spec/services/members/standard_member_builder_spec.rb b/spec/services/members/standard_member_builder_spec.rb
index 16daff53d31..69b764f3f16 100644
--- a/spec/services/members/standard_member_builder_spec.rb
+++ b/spec/services/members/standard_member_builder_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Members::StandardMemberBuilder do
+RSpec.describe Members::StandardMemberBuilder, feature_category: :subgroups do
let_it_be(:source) { create(:group) }
let_it_be(:existing_member) { create(:group_member) }
diff --git a/spec/services/members/unassign_issuables_service_spec.rb b/spec/services/members/unassign_issuables_service_spec.rb
index 3f7ccb7bab3..37dfbd16c56 100644
--- a/spec/services/members/unassign_issuables_service_spec.rb
+++ b/spec/services/members/unassign_issuables_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Members::UnassignIssuablesService do
+RSpec.describe Members::UnassignIssuablesService, feature_category: :subgroups do
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:user, reload: true) { create(:user) }
diff --git a/spec/services/members/update_service_spec.rb b/spec/services/members/update_service_spec.rb
index 8a7f9a84c77..b94b44c8485 100644
--- a/spec/services/members/update_service_spec.rb
+++ b/spec/services/members/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Members::UpdateService do
+RSpec.describe Members::UpdateService, feature_category: :subgroups do
let_it_be(:project) { create(:project, :public) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:current_user) { create(:user) }
diff --git a/spec/services/merge_requests/add_context_service_spec.rb b/spec/services/merge_requests/add_context_service_spec.rb
index 448be27efe8..5fca2c17a3c 100644
--- a/spec/services/merge_requests/add_context_service_spec.rb
+++ b/spec/services/merge_requests/add_context_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::AddContextService do
+RSpec.describe MergeRequests::AddContextService, feature_category: :code_review_workflow do
let(:project) { create(:project, :repository) }
let(:admin) { create(:admin) }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: admin) }
diff --git a/spec/services/merge_requests/add_spent_time_service_spec.rb b/spec/services/merge_requests/add_spent_time_service_spec.rb
index 1e0b3e07f26..5d6d33c14d7 100644
--- a/spec/services/merge_requests/add_spent_time_service_spec.rb
+++ b/spec/services/merge_requests/add_spent_time_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::AddSpentTimeService do
+RSpec.describe MergeRequests::AddSpentTimeService, feature_category: :code_review_workflow do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be_with_reload(:merge_request) { create(:merge_request, :simple, :unique_branches, source_project: project) }
diff --git a/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb b/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb
index 8d1abe5ea89..1307e2be3be 100644
--- a/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb
+++ b/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::MergeRequests::AddTodoWhenBuildFailsService do
+RSpec.describe ::MergeRequests::AddTodoWhenBuildFailsService, feature_category: :code_review_workflow do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:sha) { '1234567890abcdef1234567890abcdef12345678' }
@@ -74,10 +74,13 @@ RSpec.describe ::MergeRequests::AddTodoWhenBuildFailsService do
context 'when build belongs to a merge request pipeline' do
let(:pipeline) do
- create(:ci_pipeline, source: :merge_request_event,
- ref: merge_request.merge_ref_path,
- merge_request: merge_request,
- merge_requests_as_head_pipeline: [merge_request])
+ create(
+ :ci_pipeline,
+ source: :merge_request_event,
+ ref: merge_request.merge_ref_path,
+ merge_request: merge_request,
+ merge_requests_as_head_pipeline: [merge_request]
+ )
end
let(:commit_status) { create(:ci_build, ref: merge_request.merge_ref_path, pipeline: pipeline) }
@@ -119,10 +122,13 @@ RSpec.describe ::MergeRequests::AddTodoWhenBuildFailsService do
context 'when build belongs to a merge request pipeline' do
let(:pipeline) do
- create(:ci_pipeline, source: :merge_request_event,
- ref: merge_request.merge_ref_path,
- merge_request: merge_request,
- merge_requests_as_head_pipeline: [merge_request])
+ create(
+ :ci_pipeline,
+ source: :merge_request_event,
+ ref: merge_request.merge_ref_path,
+ merge_request: merge_request,
+ merge_requests_as_head_pipeline: [merge_request]
+ )
end
let(:commit_status) { create(:ci_build, ref: merge_request.merge_ref_path, pipeline: pipeline) }
diff --git a/spec/services/merge_requests/after_create_service_spec.rb b/spec/services/merge_requests/after_create_service_spec.rb
index f2823b1f0c7..50a3d49d4a3 100644
--- a/spec/services/merge_requests/after_create_service_spec.rb
+++ b/spec/services/merge_requests/after_create_service_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe MergeRequests::AfterCreateService, feature_category: :code_review_workflow do
let_it_be(:merge_request) { create(:merge_request) }
+ let(:project) { merge_request.project }
subject(:after_create_service) do
described_class.new(project: merge_request.target_project, current_user: merge_request.author)
@@ -68,6 +69,12 @@ RSpec.describe MergeRequests::AfterCreateService, feature_category: :code_review
execute_service
end
+ it 'executes hooks with default action' do
+ expect(project).to receive(:execute_hooks)
+
+ execute_service
+ end
+
it_behaves_like 'records an onboarding progress action', :merge_request_created do
let(:namespace) { merge_request.target_project.namespace }
end
@@ -143,22 +150,6 @@ RSpec.describe MergeRequests::AfterCreateService, feature_category: :code_review
expect { execute_service }.to change { counter.read(:create) }.by(1)
end
- context 'with a milestone' do
- let(:milestone) { create(:milestone, project: merge_request.target_project) }
-
- before do
- merge_request.update!(milestone_id: milestone.id)
- end
-
- it 'deletes the cache key for milestone merge request counter', :use_clean_rails_memory_store_caching do
- expect_next_instance_of(Milestones::MergeRequestsCountService, milestone) do |service|
- expect(service).to receive(:delete_cache).and_call_original
- end
-
- execute_service
- end
- end
-
context 'todos' do
it 'does not creates todos' do
attributes = {
diff --git a/spec/services/merge_requests/approval_service_spec.rb b/spec/services/merge_requests/approval_service_spec.rb
index 1d6427900b9..6140021c8d2 100644
--- a/spec/services/merge_requests/approval_service_spec.rb
+++ b/spec/services/merge_requests/approval_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ApprovalService do
+RSpec.describe MergeRequests::ApprovalService, feature_category: :code_review_workflow do
describe '#execute' do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request, reviewers: [user]) }
diff --git a/spec/services/merge_requests/assign_issues_service_spec.rb b/spec/services/merge_requests/assign_issues_service_spec.rb
index cf405c0102e..9f82207086b 100644
--- a/spec/services/merge_requests/assign_issues_service_spec.rb
+++ b/spec/services/merge_requests/assign_issues_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::AssignIssuesService do
+RSpec.describe MergeRequests::AssignIssuesService, feature_category: :code_review_workflow do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
let(:issue) { create(:issue, project: project) }
@@ -37,12 +37,14 @@ RSpec.describe MergeRequests::AssignIssuesService do
it 'accepts precomputed data for closes_issues' do
issue2 = create(:issue, project: project)
- service2 = described_class.new(project: project,
- current_user: user,
- params: {
- merge_request: merge_request,
- closes_issues: [issue, issue2]
- })
+ service2 = described_class.new(
+ project: project,
+ current_user: user,
+ params: {
+ merge_request: merge_request,
+ closes_issues: [issue, issue2]
+ }
+ )
expect(service2.assignable_issues.count).to eq 2
end
diff --git a/spec/services/merge_requests/base_service_spec.rb b/spec/services/merge_requests/base_service_spec.rb
index bd907ba6015..1ca4bfe622c 100644
--- a/spec/services/merge_requests/base_service_spec.rb
+++ b/spec/services/merge_requests/base_service_spec.rb
@@ -15,6 +15,7 @@ RSpec.describe MergeRequests::BaseService, feature_category: :code_review_workfl
let_it_be(:project) { create(:project, :repository) }
+ let(:user) { project.first_owner }
let(:title) { 'Awesome merge_request' }
let(:params) do
{
@@ -25,14 +26,14 @@ RSpec.describe MergeRequests::BaseService, feature_category: :code_review_workfl
}
end
- subject { MergeRequests::CreateService.new(project: project, current_user: project.first_owner, params: params) }
-
describe '#execute_hooks' do
+ subject { MergeRequests::CreateService.new(project: project, current_user: user, params: params).execute }
+
shared_examples 'enqueues Jira sync worker' do
specify :aggregate_failures do
expect(JiraConnect::SyncMergeRequestWorker).to receive(:perform_async).with(kind_of(Numeric), kind_of(Numeric)).and_call_original
Sidekiq::Testing.fake! do
- expect { subject.execute }.to change(JiraConnect::SyncMergeRequestWorker.jobs, :size).by(1)
+ expect { subject }.to change(JiraConnect::SyncMergeRequestWorker.jobs, :size).by(1)
end
end
end
@@ -40,7 +41,7 @@ RSpec.describe MergeRequests::BaseService, feature_category: :code_review_workfl
shared_examples 'does not enqueue Jira sync worker' do
it do
Sidekiq::Testing.fake! do
- expect { subject.execute }.not_to change(JiraConnect::SyncMergeRequestWorker.jobs, :size)
+ expect { subject }.not_to change(JiraConnect::SyncMergeRequestWorker.jobs, :size)
end
end
end
@@ -53,7 +54,20 @@ RSpec.describe MergeRequests::BaseService, feature_category: :code_review_workfl
context 'MR contains Jira issue key' do
let(:title) { 'Awesome merge_request with issue JIRA-123' }
- it_behaves_like 'enqueues Jira sync worker'
+ it_behaves_like 'does not enqueue Jira sync worker'
+
+ context 'for UpdateService' do
+ subject { MergeRequests::UpdateService.new(project: project, current_user: user, params: params).execute(merge_request) }
+
+ let(:merge_request) do
+ create(:merge_request, :simple, title: 'Old title',
+ assignee_ids: [user.id],
+ source_project: project,
+ author: user)
+ end
+
+ it_behaves_like 'enqueues Jira sync worker'
+ end
end
context 'MR does not contain Jira issue key' do
@@ -69,13 +83,13 @@ RSpec.describe MergeRequests::BaseService, feature_category: :code_review_workfl
describe `#create_pipeline_for` do
let_it_be(:merge_request) { create(:merge_request) }
- subject { MergeRequests::ExampleService.new(project: project, current_user: project.first_owner, params: params) }
+ subject { MergeRequests::ExampleService.new(project: project, current_user: user, params: params) }
context 'async: false' do
it 'creates a pipeline directly' do
expect(MergeRequests::CreatePipelineService)
.to receive(:new)
- .with(hash_including(project: project, current_user: project.first_owner, params: { allow_duplicate: false }))
+ .with(hash_including(project: project, current_user: user, params: { allow_duplicate: false }))
.and_call_original
expect(MergeRequests::CreatePipelineWorker).not_to receive(:perform_async)
@@ -86,7 +100,7 @@ RSpec.describe MergeRequests::BaseService, feature_category: :code_review_workfl
it 'passes :allow_duplicate as true' do
expect(MergeRequests::CreatePipelineService)
.to receive(:new)
- .with(hash_including(project: project, current_user: project.first_owner, params: { allow_duplicate: true }))
+ .with(hash_including(project: project, current_user: user, params: { allow_duplicate: true }))
.and_call_original
expect(MergeRequests::CreatePipelineWorker).not_to receive(:perform_async)
@@ -100,7 +114,7 @@ RSpec.describe MergeRequests::BaseService, feature_category: :code_review_workfl
expect(MergeRequests::CreatePipelineService).not_to receive(:new)
expect(MergeRequests::CreatePipelineWorker)
.to receive(:perform_async)
- .with(project.id, project.first_owner.id, merge_request.id, { "allow_duplicate" => false })
+ .with(project.id, user.id, merge_request.id, { "allow_duplicate" => false })
.and_call_original
Sidekiq::Testing.fake! do
@@ -113,7 +127,7 @@ RSpec.describe MergeRequests::BaseService, feature_category: :code_review_workfl
expect(MergeRequests::CreatePipelineService).not_to receive(:new)
expect(MergeRequests::CreatePipelineWorker)
.to receive(:perform_async)
- .with(project.id, project.first_owner.id, merge_request.id, { "allow_duplicate" => true })
+ .with(project.id, user.id, merge_request.id, { "allow_duplicate" => true })
.and_call_original
Sidekiq::Testing.fake! do
@@ -123,4 +137,8 @@ RSpec.describe MergeRequests::BaseService, feature_category: :code_review_workfl
end
end
end
+
+ describe '#constructor_container_arg' do
+ it { expect(described_class.constructor_container_arg("some-value")).to eq({ project: "some-value" }) }
+ end
end
diff --git a/spec/services/merge_requests/cleanup_refs_service_spec.rb b/spec/services/merge_requests/cleanup_refs_service_spec.rb
index e8690ae5bf2..960b8101c36 100644
--- a/spec/services/merge_requests/cleanup_refs_service_spec.rb
+++ b/spec/services/merge_requests/cleanup_refs_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::CleanupRefsService do
+RSpec.describe MergeRequests::CleanupRefsService, feature_category: :code_review_workflow do
describe '.schedule' do
let(:merge_request) { create(:merge_request) }
diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb
index 2c0817550c6..25c75ae7244 100644
--- a/spec/services/merge_requests/close_service_spec.rb
+++ b/spec/services/merge_requests/close_service_spec.rb
@@ -88,7 +88,11 @@ RSpec.describe MergeRequests::CloseService, feature_category: :code_review_workf
end
it 'refreshes the number of open merge requests for a valid MR', :use_clean_rails_memory_store_caching do
- expect { execute }
+ expect do
+ execute
+
+ BatchLoader::Executor.clear_current
+ end
.to change { project.open_merge_requests_count }.from(1).to(0)
end
diff --git a/spec/services/merge_requests/conflicts/list_service_spec.rb b/spec/services/merge_requests/conflicts/list_service_spec.rb
index 5132eac0158..5eb53b1bcba 100644
--- a/spec/services/merge_requests/conflicts/list_service_spec.rb
+++ b/spec/services/merge_requests/conflicts/list_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Conflicts::ListService do
+RSpec.describe MergeRequests::Conflicts::ListService, feature_category: :code_review_workflow do
describe '#can_be_resolved_in_ui?' do
def create_merge_request(source_branch, target_branch = 'conflict-start')
create(:merge_request, source_branch: source_branch, target_branch: target_branch, merge_status: :unchecked) do |mr|
diff --git a/spec/services/merge_requests/conflicts/resolve_service_spec.rb b/spec/services/merge_requests/conflicts/resolve_service_spec.rb
index 0abc70f71b0..002a07ff14e 100644
--- a/spec/services/merge_requests/conflicts/resolve_service_spec.rb
+++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Conflicts::ResolveService do
+RSpec.describe MergeRequests::Conflicts::ResolveService, feature_category: :code_review_workflow do
include ProjectForksHelper
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
@@ -12,15 +12,22 @@ RSpec.describe MergeRequests::Conflicts::ResolveService do
end
let(:merge_request) do
- create(:merge_request,
- source_branch: 'conflict-resolvable', source_project: project,
- target_branch: 'conflict-start')
+ create(
+ :merge_request,
+ source_branch: 'conflict-resolvable',
+ source_project: project,
+ target_branch: 'conflict-start'
+ )
end
let(:merge_request_from_fork) do
- create(:merge_request,
- source_branch: 'conflict-resolvable-fork', source_project: forked_project,
- target_branch: 'conflict-start', target_project: project)
+ create(
+ :merge_request,
+ source_branch: 'conflict-resolvable-fork',
+ source_project: forked_project,
+ target_branch: 'conflict-start',
+ target_project: project
+ )
end
describe '#execute' do
diff --git a/spec/services/merge_requests/create_approval_event_service_spec.rb b/spec/services/merge_requests/create_approval_event_service_spec.rb
index 3d41ace11a7..4876c992337 100644
--- a/spec/services/merge_requests/create_approval_event_service_spec.rb
+++ b/spec/services/merge_requests/create_approval_event_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::CreateApprovalEventService do
+RSpec.describe MergeRequests::CreateApprovalEventService, feature_category: :code_review_workflow do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
diff --git a/spec/services/merge_requests/create_pipeline_service_spec.rb b/spec/services/merge_requests/create_pipeline_service_spec.rb
index f11e3d0d1df..9c2321a2f16 100644
--- a/spec/services/merge_requests/create_pipeline_service_spec.rb
+++ b/spec/services/merge_requests/create_pipeline_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::CreatePipelineService, :clean_gitlab_redis_cache do
+RSpec.describe MergeRequests::CreatePipelineService, :clean_gitlab_redis_cache, feature_category: :code_review_workflow do
include ProjectForksHelper
let_it_be(:project, refind: true) { create(:project, :repository) }
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index 394fc269ac3..7705278f30d 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state, feature_category: :code_review_workflow do
include ProjectForksHelper
+ include AfterNextHelpers
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
@@ -27,7 +28,6 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state, f
before do
project.add_maintainer(user)
project.add_developer(user2)
- allow(service).to receive(:execute_hooks)
end
it 'creates an MR' do
@@ -38,13 +38,18 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state, f
expect(merge_request.merge_params['force_remove_source_branch']).to eq('1')
end
- it 'executes hooks with default action' do
- expect(service).to have_received(:execute_hooks).with(merge_request)
+ it 'does not execute hooks' do
+ expect(project).not_to receive(:execute_hooks)
+
+ service.execute
end
it 'refreshes the number of open merge requests', :use_clean_rails_memory_store_caching do
- expect { service.execute }
- .to change { project.open_merge_requests_count }.from(0).to(1)
+ expect do
+ service.execute
+
+ BatchLoader::Executor.clear_current
+ end.to change { project.open_merge_requests_count }.from(0).to(1)
end
it 'creates exactly 1 create MR event', :sidekiq_might_not_need_inline do
@@ -245,10 +250,13 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state, f
context "when branch pipeline was created before a merge request pipline has been created" do
before do
- create(:ci_pipeline, project: merge_request.source_project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch,
- tag: false)
+ create(
+ :ci_pipeline,
+ project: merge_request.source_project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch,
+ tag: false
+ )
merge_request
end
@@ -333,6 +341,19 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state, f
end
end
+ context 'with a milestone' do
+ let(:milestone) { create(:milestone, project: project) }
+
+ let(:opts) { { title: 'Awesome merge_request', source_branch: 'feature', target_branch: 'master', milestone_id: milestone.id } }
+
+ it 'deletes the cache key for milestone merge request counter' do
+ expect_next(Milestones::MergeRequestsCountService, milestone)
+ .to receive(:delete_cache).and_call_original
+
+ expect(merge_request).to be_persisted
+ end
+ end
+
it_behaves_like 'reviewer_ids filter' do
let(:execute) { service.execute }
end
@@ -428,19 +449,29 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state, f
}
end
- it 'invalidates open merge request counter for assignees when merge request is assigned' do
+ before do
project.add_maintainer(user2)
+ end
+ it 'invalidates open merge request counter for assignees when merge request is assigned' do
described_class.new(project: project, current_user: user, params: opts).execute
expect(user2.assigned_open_merge_requests_count).to eq 1
end
+
+ it 'records the assignee assignment event', :sidekiq_inline do
+ mr = described_class.new(project: project, current_user: user, params: opts).execute.reload
+
+ expect(mr.assignment_events).to match([have_attributes(user_id: user2.id, action: 'add')])
+ end
end
context "when issuable feature is private" do
before do
- project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE,
- merge_requests_access_level: ProjectFeature::PRIVATE)
+ project.project_feature.update!(
+ issues_access_level: ProjectFeature::PRIVATE,
+ merge_requests_access_level: ProjectFeature::PRIVATE
+ )
end
levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC]
diff --git a/spec/services/merge_requests/delete_non_latest_diffs_service_spec.rb b/spec/services/merge_requests/delete_non_latest_diffs_service_spec.rb
index d2070a466b1..d9e60911ada 100644
--- a/spec/services/merge_requests/delete_non_latest_diffs_service_spec.rb
+++ b/spec/services/merge_requests/delete_non_latest_diffs_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe MergeRequests::DeleteNonLatestDiffsService, :clean_gitlab_redis_shared_state do
+RSpec.describe MergeRequests::DeleteNonLatestDiffsService, :clean_gitlab_redis_shared_state,
+ feature_category: :code_review_workflow do
let(:merge_request) { create(:merge_request) }
let!(:subject) { described_class.new(merge_request) }
diff --git a/spec/services/merge_requests/execute_approval_hooks_service_spec.rb b/spec/services/merge_requests/execute_approval_hooks_service_spec.rb
index 863c47e8191..9f460648b05 100644
--- a/spec/services/merge_requests/execute_approval_hooks_service_spec.rb
+++ b/spec/services/merge_requests/execute_approval_hooks_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ExecuteApprovalHooksService do
+RSpec.describe MergeRequests::ExecuteApprovalHooksService, feature_category: :code_review_workflow do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
diff --git a/spec/services/merge_requests/ff_merge_service_spec.rb b/spec/services/merge_requests/ff_merge_service_spec.rb
index 5027acbba0a..f2dbc02f12c 100644
--- a/spec/services/merge_requests/ff_merge_service_spec.rb
+++ b/spec/services/merge_requests/ff_merge_service_spec.rb
@@ -2,15 +2,17 @@
require 'spec_helper'
-RSpec.describe MergeRequests::FfMergeService do
+RSpec.describe MergeRequests::FfMergeService, feature_category: :code_review_workflow do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:merge_request) do
- create(:merge_request,
- source_branch: 'flatten-dir',
- target_branch: 'improve/awesome',
- assignees: [user2],
- author: create(:user))
+ create(
+ :merge_request,
+ source_branch: 'flatten-dir',
+ target_branch: 'improve/awesome',
+ assignees: [user2],
+ author: create(:user)
+ )
end
let(:project) { merge_request.project }
diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb
index 5f81e1728fa..31b3e513a51 100644
--- a/spec/services/merge_requests/get_urls_service_spec.rb
+++ b/spec/services/merge_requests/get_urls_service_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe MergeRequests::GetUrlsService do
+RSpec.describe MergeRequests::GetUrlsService, feature_category: :code_review_workflow do
include ProjectForksHelper
let(:project) { create(:project, :public, :repository) }
diff --git a/spec/services/merge_requests/handle_assignees_change_service_spec.rb b/spec/services/merge_requests/handle_assignees_change_service_spec.rb
index 3db3efedb84..951e59afe7f 100644
--- a/spec/services/merge_requests/handle_assignees_change_service_spec.rb
+++ b/spec/services/merge_requests/handle_assignees_change_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::HandleAssigneesChangeService do
+RSpec.describe MergeRequests::HandleAssigneesChangeService, feature_category: :code_review_workflow do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:assignee) { create(:user) }
diff --git a/spec/services/merge_requests/mark_reviewer_reviewed_service_spec.rb b/spec/services/merge_requests/mark_reviewer_reviewed_service_spec.rb
index 8437876c3cf..172c2133168 100644
--- a/spec/services/merge_requests/mark_reviewer_reviewed_service_spec.rb
+++ b/spec/services/merge_requests/mark_reviewer_reviewed_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::MarkReviewerReviewedService do
+RSpec.describe MergeRequests::MarkReviewerReviewedService, feature_category: :code_review_workflow do
let(:current_user) { create(:user) }
let(:merge_request) { create(:merge_request, reviewers: [current_user]) }
let(:reviewer) { merge_request.merge_request_reviewers.find_by(user_id: current_user.id) }
diff --git a/spec/services/merge_requests/merge_orchestration_service_spec.rb b/spec/services/merge_requests/merge_orchestration_service_spec.rb
index ebcd2f0e277..389956bf258 100644
--- a/spec/services/merge_requests/merge_orchestration_service_spec.rb
+++ b/spec/services/merge_requests/merge_orchestration_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::MergeOrchestrationService do
+RSpec.describe MergeRequests::MergeOrchestrationService, feature_category: :code_review_workflow do
let_it_be(:maintainer) { create(:user) }
let(:merge_params) { { sha: merge_request.diff_head_sha } }
@@ -10,8 +10,11 @@ RSpec.describe MergeRequests::MergeOrchestrationService do
let(:service) { described_class.new(project, user, merge_params) }
let!(:merge_request) do
- create(:merge_request, source_project: project, source_branch: 'feature',
- target_project: project, target_branch: 'master')
+ create(
+ :merge_request,
+ source_project: project, source_branch: 'feature',
+ target_project: project, target_branch: 'master'
+ )
end
shared_context 'fresh repository' do
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index d3bf203d6bb..c77cf288f56 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::MergeService do
+RSpec.describe MergeRequests::MergeService, feature_category: :code_review_workflow do
include ExclusiveLeaseHelpers
let_it_be(:user) { create(:user) }
@@ -69,12 +69,15 @@ RSpec.describe MergeRequests::MergeService do
let(:merge_request) do
# A merge request with 5 commits
- create(:merge_request, :simple,
- author: user2,
- assignees: [user2],
- squash: true,
- source_branch: 'improve/awesome',
- target_branch: 'fix')
+ create(
+ :merge_request,
+ :simple,
+ author: user2,
+ assignees: [user2],
+ squash: true,
+ source_branch: 'improve/awesome',
+ target_branch: 'fix'
+ )
end
it 'merges the merge request with squashed commits' do
@@ -147,12 +150,15 @@ RSpec.describe MergeRequests::MergeService do
context 'when an invalid sha is passed' do
let(:merge_request) do
- create(:merge_request, :simple,
- author: user2,
- assignees: [user2],
- squash: true,
- source_branch: 'improve/awesome',
- target_branch: 'fix')
+ create(
+ :merge_request,
+ :simple,
+ author: user2,
+ assignees: [user2],
+ squash: true,
+ source_branch: 'improve/awesome',
+ target_branch: 'fix'
+ )
end
let(:merge_params) do
@@ -351,9 +357,12 @@ RSpec.describe MergeRequests::MergeService do
service.execute(merge_request)
expect(merge_request.merge_error).to eq(error_message)
- expect(Gitlab::AppLogger).to have_received(:error)
- .with(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(error_message)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(error_message)
+ )
+ )
end
end
@@ -366,9 +375,12 @@ RSpec.describe MergeRequests::MergeService do
service.execute(merge_request)
expect(merge_request.merge_error).to eq(described_class::GENERIC_ERROR_MESSAGE)
- expect(Gitlab::AppLogger).to have_received(:error)
- .with(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(error_message)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(error_message)
+ )
+ )
end
it 'logs and saves error if user is not authorized' do
@@ -394,9 +406,12 @@ RSpec.describe MergeRequests::MergeService do
service.execute(merge_request)
expect(merge_request.merge_error).to include('Something went wrong during merge pre-receive hook')
- expect(Gitlab::AppLogger).to have_received(:error)
- .with(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(error_message)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(error_message)
+ )
+ )
end
it 'logs and saves error if commit is not created' do
@@ -408,9 +423,12 @@ RSpec.describe MergeRequests::MergeService do
expect(merge_request).to be_open
expect(merge_request.merge_commit_sha).to be_nil
expect(merge_request.merge_error).to include(described_class::GENERIC_ERROR_MESSAGE)
- expect(Gitlab::AppLogger).to have_received(:error)
- .with(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(described_class::GENERIC_ERROR_MESSAGE)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(described_class::GENERIC_ERROR_MESSAGE)
+ )
+ )
end
context 'when squashing is required' do
@@ -429,9 +447,12 @@ RSpec.describe MergeRequests::MergeService do
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(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(error_message)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(error_message)
+ )
+ )
end
end
@@ -452,9 +473,12 @@ RSpec.describe MergeRequests::MergeService do
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(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(error_message)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(error_message)
+ )
+ )
end
it 'logs and saves error if there is an PreReceiveError exception' do
@@ -470,9 +494,12 @@ RSpec.describe MergeRequests::MergeService do
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('Something went wrong during merge pre-receive hook')
- expect(Gitlab::AppLogger).to have_received(:error)
- .with(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(error_message)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(error_message)
+ )
+ )
end
context 'when fast-forward merge is not allowed' do
@@ -494,9 +521,12 @@ RSpec.describe MergeRequests::MergeService do
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(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(error_message)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(error_message)
+ )
+ )
end
end
end
@@ -513,9 +543,12 @@ RSpec.describe MergeRequests::MergeService do
it 'logs and saves error' do
service.execute(merge_request)
- expect(Gitlab::AppLogger).to have_received(:error)
- .with(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(error_message)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(error_message)
+ )
+ )
end
end
@@ -527,9 +560,12 @@ RSpec.describe MergeRequests::MergeService do
it 'logs and saves error' do
service.execute(merge_request)
- expect(Gitlab::AppLogger).to have_received(:error)
- .with(hash_including(merge_request_info: merge_request.to_reference(full: true),
- message: a_string_matching(error_message)))
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ hash_including(
+ merge_request_info: merge_request.to_reference(full: true),
+ message: a_string_matching(error_message)
+ )
+ )
end
context 'when passing `skip_discussions_check: true` as `options` parameter' do
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 19fac3b5095..8200f60b072 100644
--- a/spec/services/merge_requests/merge_to_ref_service_spec.rb
+++ b/spec/services/merge_requests/merge_to_ref_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::MergeToRefService do
+RSpec.describe MergeRequests::MergeToRefService, feature_category: :code_review_workflow do
shared_examples_for 'MergeService for target ref' do
it 'target_ref has the same state of target branch' do
repo = merge_request.target_project.repository
@@ -210,11 +210,14 @@ RSpec.describe MergeRequests::MergeToRefService do
let(:merge_request) { create(:merge_request, assignees: [user], author: user) }
let(:project) { merge_request.project }
let!(:todo) do
- create(:todo, :assigned,
- project: project,
- author: user,
- user: user,
- target: merge_request)
+ create(
+ :todo,
+ :assigned,
+ project: project,
+ author: user,
+ user: user,
+ target: merge_request
+ )
end
before do
@@ -258,8 +261,10 @@ RSpec.describe MergeRequests::MergeToRefService do
context 'when first merge happens' do
let(:merge_request) do
- create(:merge_request, source_project: project, source_branch: 'feature',
- target_project: project, target_branch: 'master')
+ create(
+ :merge_request, source_project: project, source_branch: 'feature',
+ target_project: project, target_branch: 'master'
+ )
end
it_behaves_like 'successfully merges to ref with merge method' do
@@ -269,8 +274,11 @@ RSpec.describe MergeRequests::MergeToRefService do
context 'when second merge happens' do
let(:merge_request) do
- create(:merge_request, source_project: project, source_branch: 'improve/awesome',
- target_project: project, target_branch: 'master')
+ create(
+ :merge_request,
+ source_project: project, source_branch: 'improve/awesome',
+ target_project: project, target_branch: 'master'
+ )
end
it_behaves_like 'successfully merges to ref with merge method' do
diff --git a/spec/services/merge_requests/mergeability/check_base_service_spec.rb b/spec/services/merge_requests/mergeability/check_base_service_spec.rb
index f07522b43cb..806bde61c23 100644
--- a/spec/services/merge_requests/mergeability/check_base_service_spec.rb
+++ b/spec/services/merge_requests/mergeability/check_base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Mergeability::CheckBaseService do
+RSpec.describe MergeRequests::Mergeability::CheckBaseService, feature_category: :code_review_workflow do
subject(:check_base_service) { described_class.new(merge_request: merge_request, params: params) }
let(:merge_request) { double }
diff --git a/spec/services/merge_requests/mergeability/check_broken_status_service_spec.rb b/spec/services/merge_requests/mergeability/check_broken_status_service_spec.rb
index 6cc1079c94a..b6ee1049bb9 100644
--- a/spec/services/merge_requests/mergeability/check_broken_status_service_spec.rb
+++ b/spec/services/merge_requests/mergeability/check_broken_status_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Mergeability::CheckBrokenStatusService do
+RSpec.describe MergeRequests::Mergeability::CheckBrokenStatusService, feature_category: :code_review_workflow do
subject(:check_broken_status) { described_class.new(merge_request: merge_request, params: {}) }
let(:merge_request) { build(:merge_request) }
diff --git a/spec/services/merge_requests/mergeability/check_ci_status_service_spec.rb b/spec/services/merge_requests/mergeability/check_ci_status_service_spec.rb
index def3cb0ca28..cf835cf70a3 100644
--- a/spec/services/merge_requests/mergeability/check_ci_status_service_spec.rb
+++ b/spec/services/merge_requests/mergeability/check_ci_status_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Mergeability::CheckCiStatusService do
+RSpec.describe MergeRequests::Mergeability::CheckCiStatusService, feature_category: :code_review_workflow do
subject(:check_ci_status) { described_class.new(merge_request: merge_request, params: params) }
let(:merge_request) { build(:merge_request) }
diff --git a/spec/services/merge_requests/mergeability/check_discussions_status_service_spec.rb b/spec/services/merge_requests/mergeability/check_discussions_status_service_spec.rb
index 9f107ce046a..a3b77558ec3 100644
--- a/spec/services/merge_requests/mergeability/check_discussions_status_service_spec.rb
+++ b/spec/services/merge_requests/mergeability/check_discussions_status_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Mergeability::CheckDiscussionsStatusService do
+RSpec.describe MergeRequests::Mergeability::CheckDiscussionsStatusService, feature_category: :code_review_workflow do
subject(:check_discussions_status) { described_class.new(merge_request: merge_request, params: params) }
let(:merge_request) { build(:merge_request) }
diff --git a/spec/services/merge_requests/mergeability/check_draft_status_service_spec.rb b/spec/services/merge_requests/mergeability/check_draft_status_service_spec.rb
index e9363e5d676..cb624705a02 100644
--- a/spec/services/merge_requests/mergeability/check_draft_status_service_spec.rb
+++ b/spec/services/merge_requests/mergeability/check_draft_status_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Mergeability::CheckDraftStatusService do
+RSpec.describe MergeRequests::Mergeability::CheckDraftStatusService, feature_category: :code_review_workflow do
subject(:check_draft_status) { described_class.new(merge_request: merge_request, params: {}) }
let(:merge_request) { build(:merge_request) }
diff --git a/spec/services/merge_requests/mergeability/check_open_status_service_spec.rb b/spec/services/merge_requests/mergeability/check_open_status_service_spec.rb
index 936524b020a..53ad77ea4df 100644
--- a/spec/services/merge_requests/mergeability/check_open_status_service_spec.rb
+++ b/spec/services/merge_requests/mergeability/check_open_status_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Mergeability::CheckOpenStatusService do
+RSpec.describe MergeRequests::Mergeability::CheckOpenStatusService, feature_category: :code_review_workflow do
subject(:check_open_status) { described_class.new(merge_request: merge_request, params: {}) }
let(:merge_request) { build(:merge_request) }
diff --git a/spec/services/merge_requests/mergeability/detailed_merge_status_service_spec.rb b/spec/services/merge_requests/mergeability/detailed_merge_status_service_spec.rb
index 5722bb79cc5..66bcb948cb6 100644
--- a/spec/services/merge_requests/mergeability/detailed_merge_status_service_spec.rb
+++ b/spec/services/merge_requests/mergeability/detailed_merge_status_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::MergeRequests::Mergeability::DetailedMergeStatusService do
+RSpec.describe ::MergeRequests::Mergeability::DetailedMergeStatusService, feature_category: :code_review_workflow do
subject(:detailed_merge_status) { described_class.new(merge_request: merge_request).execute }
context 'when merge status is cannot_be_merged_rechecking' do
@@ -17,7 +17,19 @@ RSpec.describe ::MergeRequests::Mergeability::DetailedMergeStatusService do
let(:merge_request) { create(:merge_request, merge_status: :preparing) }
it 'returns :checking' do
- expect(detailed_merge_status).to eq(:checking)
+ allow(merge_request.merge_request_diff).to receive(:persisted?).and_return(false)
+
+ expect(detailed_merge_status).to eq(:preparing)
+ end
+ end
+
+ context 'when merge status is preparing and merge request diff is persisted' do
+ let(:merge_request) { create(:merge_request, merge_status: :preparing) }
+
+ it 'returns :checking' do
+ allow(merge_request.merge_request_diff).to receive(:persisted?).and_return(true)
+
+ expect(detailed_merge_status).to eq(:mergeable)
end
end
@@ -72,9 +84,14 @@ RSpec.describe ::MergeRequests::Mergeability::DetailedMergeStatusService do
context 'when pipeline exists' do
before do
- create(:ci_pipeline, ci_status, merge_request: merge_request,
- project: merge_request.project, sha: merge_request.source_branch_sha,
- head_pipeline_of: merge_request)
+ create(
+ :ci_pipeline,
+ ci_status,
+ merge_request: merge_request,
+ project: merge_request.project,
+ sha: merge_request.source_branch_sha,
+ head_pipeline_of: merge_request
+ )
end
context 'when the pipeline is running' do
diff --git a/spec/services/merge_requests/mergeability/logger_spec.rb b/spec/services/merge_requests/mergeability/logger_spec.rb
index 3e2a1e9f9fd..1f56b6bebdb 100644
--- a/spec/services/merge_requests/mergeability/logger_spec.rb
+++ b/spec/services/merge_requests/mergeability/logger_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Mergeability::Logger, :request_store do
+RSpec.describe MergeRequests::Mergeability::Logger, :request_store, feature_category: :code_review_workflow do
let_it_be(:merge_request) { create(:merge_request) }
subject(:logger) { described_class.new(merge_request: merge_request) }
diff --git a/spec/services/merge_requests/mergeability/run_checks_service_spec.rb b/spec/services/merge_requests/mergeability/run_checks_service_spec.rb
index c56b38bccc1..bfff582994b 100644
--- a/spec/services/merge_requests/mergeability/run_checks_service_spec.rb
+++ b/spec/services/merge_requests/mergeability/run_checks_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::Mergeability::RunChecksService, :clean_gitlab_redis_cache do
+RSpec.describe MergeRequests::Mergeability::RunChecksService, :clean_gitlab_redis_cache, feature_category: :code_review_workflow do
subject(:run_checks) { described_class.new(merge_request: merge_request, params: {}) }
describe '#execute' do
diff --git a/spec/services/merge_requests/mergeability_check_service_spec.rb b/spec/services/merge_requests/mergeability_check_service_spec.rb
index ee23238314e..82ef8440380 100644
--- a/spec/services/merge_requests/mergeability_check_service_spec.rb
+++ b/spec/services/merge_requests/mergeability_check_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::MergeabilityCheckService, :clean_gitlab_redis_shared_state do
+RSpec.describe MergeRequests::MergeabilityCheckService, :clean_gitlab_redis_shared_state, feature_category: :code_review_workflow do
shared_examples_for 'unmergeable merge request' do
it 'updates or keeps merge status as cannot_be_merged' do
subject
@@ -158,9 +158,11 @@ RSpec.describe MergeRequests::MergeabilityCheckService, :clean_gitlab_redis_shar
threads = execute_within_threads(amount: 3, retry_lease: false)
results = threads.map { |t| [t.value.status, t.value.message] }
- expect(results).to contain_exactly([:error, 'Failed to obtain a lock'],
- [:error, 'Failed to obtain a lock'],
- [:success, nil])
+ expect(results).to contain_exactly(
+ [:error, 'Failed to obtain a lock'],
+ [:error, 'Failed to obtain a lock'],
+ [:success, nil]
+ )
end
end
end
@@ -183,11 +185,13 @@ RSpec.describe MergeRequests::MergeabilityCheckService, :clean_gitlab_redis_shar
context 'when it cannot be merged on git' do
let(:merge_request) do
- create(:merge_request,
- merge_status: :unchecked,
- source_branch: 'conflict-resolvable',
- source_project: project,
- target_branch: 'conflict-start')
+ create(
+ :merge_request,
+ merge_status: :unchecked,
+ source_branch: 'conflict-resolvable',
+ source_project: project,
+ target_branch: 'conflict-start'
+ )
end
it 'returns ServiceResponse.error and keeps merge status as cannot_be_merged' do
diff --git a/spec/services/merge_requests/migrate_external_diffs_service_spec.rb b/spec/services/merge_requests/migrate_external_diffs_service_spec.rb
index 6ea8626ba73..c7f78dfa992 100644
--- a/spec/services/merge_requests/migrate_external_diffs_service_spec.rb
+++ b/spec/services/merge_requests/migrate_external_diffs_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::MigrateExternalDiffsService do
+RSpec.describe MergeRequests::MigrateExternalDiffsService, feature_category: :code_review_workflow do
let(:merge_request) { create(:merge_request) }
let(:diff) { merge_request.merge_request_diff }
diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb
index e486daae15e..f7526c169bd 100644
--- a/spec/services/merge_requests/post_merge_service_spec.rb
+++ b/spec/services/merge_requests/post_merge_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::PostMergeService do
+RSpec.describe MergeRequests::PostMergeService, feature_category: :code_review_workflow do
include ProjectForksHelper
let_it_be(:user) { create(:user) }
@@ -23,7 +23,11 @@ RSpec.describe MergeRequests::PostMergeService do
# Cache the counter before the MR changed state.
project.open_merge_requests_count
- expect { subject }.to change { project.open_merge_requests_count }.from(1).to(0)
+ expect do
+ subject
+
+ BatchLoader::Executor.clear_current
+ end.to change { project.open_merge_requests_count }.from(1).to(0)
end
it 'updates metrics' do
diff --git a/spec/services/merge_requests/push_options_handler_service_spec.rb b/spec/services/merge_requests/push_options_handler_service_spec.rb
index 03f3d56cdd2..49ec8b09939 100644
--- a/spec/services/merge_requests/push_options_handler_service_spec.rb
+++ b/spec/services/merge_requests/push_options_handler_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::PushOptionsHandlerService do
+RSpec.describe MergeRequests::PushOptionsHandlerService, feature_category: :source_code_management do
include ProjectForksHelper
let_it_be(:parent_group) { create(:group, :public) }
diff --git a/spec/services/merge_requests/rebase_service_spec.rb b/spec/services/merge_requests/rebase_service_spec.rb
index 704dc1f9000..c8b9ab5a34e 100644
--- a/spec/services/merge_requests/rebase_service_spec.rb
+++ b/spec/services/merge_requests/rebase_service_spec.rb
@@ -8,10 +8,12 @@ RSpec.describe MergeRequests::RebaseService, feature_category: :source_code_mana
let(:user) { create(:user) }
let(:rebase_jid) { 'fake-rebase-jid' }
let(:merge_request) do
- create :merge_request,
- source_branch: 'feature_conflict',
- target_branch: 'master',
- rebase_jid: rebase_jid
+ create(
+ :merge_request,
+ source_branch: 'feature_conflict',
+ target_branch: 'master',
+ rebase_jid: rebase_jid
+ )
end
let(:project) { merge_request.project }
@@ -102,8 +104,9 @@ RSpec.describe MergeRequests::RebaseService, feature_category: :source_code_mana
end
it 'returns an error' do
- expect(service.execute(merge_request)).to match(status: :error,
- message: described_class::REBASE_ERROR)
+ expect(service.execute(merge_request)).to match(
+ status: :error, message: described_class::REBASE_ERROR
+ )
end
it 'logs the error' do
@@ -154,8 +157,9 @@ RSpec.describe MergeRequests::RebaseService, feature_category: :source_code_mana
end
it 'returns an error' do
- expect(service.execute(merge_request)).to match(status: :error,
- message: described_class::REBASE_ERROR)
+ expect(service.execute(merge_request)).to match(
+ status: :error, message: described_class::REBASE_ERROR
+ )
end
end
@@ -215,9 +219,11 @@ RSpec.describe MergeRequests::RebaseService, feature_category: :source_code_mana
message: 'Add new file to target',
branch_name: 'master')
- create(:merge_request,
- source_branch: 'master', source_project: forked_project,
- target_branch: 'master', target_project: project)
+ create(
+ :merge_request,
+ source_branch: 'master', source_project: forked_project,
+ target_branch: 'master', target_project: project
+ )
end
it 'rebases source branch', :sidekiq_might_not_need_inline do
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 0814942b6b7..4d533b67690 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -19,43 +19,53 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
@project = create(:project, :repository, namespace: group)
@fork_project = fork_project(@project, @user, repository: true)
- @merge_request = create(:merge_request,
- source_project: @project,
- source_branch: 'master',
- target_branch: 'feature',
- target_project: @project,
- auto_merge_enabled: true,
- auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
- merge_user: @user)
-
- @another_merge_request = create(:merge_request,
- source_project: @project,
- source_branch: 'master',
- target_branch: 'test',
- target_project: @project,
- auto_merge_enabled: true,
- auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
- merge_user: @user)
-
- @fork_merge_request = create(:merge_request,
- source_project: @fork_project,
- source_branch: 'master',
- target_branch: 'feature',
- target_project: @project)
-
- @build_failed_todo = create(:todo,
- :build_failed,
- user: @user,
- project: @project,
- target: @merge_request,
- author: @user)
-
- @fork_build_failed_todo = create(:todo,
- :build_failed,
- user: @user,
- project: @project,
- target: @merge_request,
- author: @user)
+ @merge_request = create(
+ :merge_request,
+ source_project: @project,
+ source_branch: 'master',
+ target_branch: 'feature',
+ target_project: @project,
+ auto_merge_enabled: true,
+ auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
+ merge_user: @user
+ )
+
+ @another_merge_request = create(
+ :merge_request,
+ source_project: @project,
+ source_branch: 'master',
+ target_branch: 'test',
+ target_project: @project,
+ auto_merge_enabled: true,
+ auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
+ merge_user: @user
+ )
+
+ @fork_merge_request = create(
+ :merge_request,
+ source_project: @fork_project,
+ source_branch: 'master',
+ target_branch: 'feature',
+ target_project: @project
+ )
+
+ @build_failed_todo = create(
+ :todo,
+ :build_failed,
+ user: @user,
+ project: @project,
+ target: @merge_request,
+ author: @user
+ )
+
+ @fork_build_failed_todo = create(
+ :todo,
+ :build_failed,
+ user: @user,
+ project: @project,
+ target: @merge_request,
+ author: @user
+ )
@commits = @merge_request.commits
@@ -109,6 +119,14 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
expect(@fork_build_failed_todo).to be_done
end
+ it 'triggers mergeRequestMergeStatusUpdated GraphQL subscription conditionally' do
+ expect(GraphqlTriggers).to receive(:merge_request_merge_status_updated).with(@merge_request)
+ expect(GraphqlTriggers).to receive(:merge_request_merge_status_updated).with(@another_merge_request)
+ expect(GraphqlTriggers).not_to receive(:merge_request_merge_status_updated).with(@fork_merge_request)
+
+ refresh_service.execute(@oldrev, @newrev, 'refs/heads/master')
+ end
+
context 'when a merge error exists' do
let(:error_message) { 'This is a merge error' }
@@ -298,10 +316,13 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
context "when branch pipeline was created before a detaced merge request pipeline has been created" do
before do
- create(:ci_pipeline, project: @merge_request.source_project,
- sha: @merge_request.diff_head_sha,
- ref: @merge_request.source_branch,
- tag: false)
+ create(
+ :ci_pipeline,
+ project: @merge_request.source_project,
+ sha: @merge_request.diff_head_sha,
+ ref: @merge_request.source_branch,
+ tag: false
+ )
subject
end
@@ -332,9 +353,11 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
context 'when the pipeline should be skipped' do
it 'saves a skipped detached merge request pipeline' do
- project.repository.create_file(@user, 'new-file.txt', 'A new file',
- message: '[skip ci] This is a test',
- branch_name: 'master')
+ project.repository.create_file(
+ @user, 'new-file.txt', 'A new file',
+ message: '[skip ci] This is a test',
+ branch_name: 'master'
+ )
expect { subject }
.to change { @merge_request.pipelines_for_merge_request.count }.by(1)
@@ -444,11 +467,13 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
context 'when an MR to be closed was empty already' do
let!(:empty_fork_merge_request) do
- create(:merge_request,
- source_project: @fork_project,
- source_branch: 'master',
- target_branch: 'master',
- target_project: @project)
+ create(
+ :merge_request,
+ source_project: @fork_project,
+ source_branch: 'master',
+ target_branch: 'master',
+ target_project: @project
+ )
end
before do
@@ -589,23 +614,33 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
context 'forked projects with the same source branch name as target branch' do
let!(:first_commit) do
- @fork_project.repository.create_file(@user, 'test1.txt', 'Test data',
- message: 'Test commit',
- branch_name: 'master')
+ @fork_project.repository.create_file(
+ @user,
+ 'test1.txt',
+ 'Test data',
+ message: 'Test commit',
+ branch_name: 'master'
+ )
end
let!(:second_commit) do
- @fork_project.repository.create_file(@user, 'test2.txt', 'More test data',
- message: 'Second test commit',
- branch_name: 'master')
+ @fork_project.repository.create_file(
+ @user,
+ 'test2.txt',
+ 'More test data',
+ message: 'Second test commit',
+ branch_name: 'master'
+ )
end
let!(:forked_master_mr) do
- create(:merge_request,
- source_project: @fork_project,
- source_branch: 'master',
- target_branch: 'master',
- target_project: @project)
+ create(
+ :merge_request,
+ source_project: @fork_project,
+ source_branch: 'master',
+ target_branch: 'master',
+ target_project: @project
+ )
end
let(:force_push_commit) { @project.commit('feature').id }
@@ -639,9 +674,13 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
end
it 'does not increase the diff count for a new push to target branch' do
- new_commit = @project.repository.create_file(@user, 'new-file.txt', 'A new file',
- message: 'This is a test',
- branch_name: 'master')
+ new_commit = @project.repository.create_file(
+ @user,
+ 'new-file.txt',
+ 'A new file',
+ message: 'This is a test',
+ branch_name: 'master'
+ )
expect do
service.new(project: @project, current_user: @user).execute(@newrev, new_commit, 'refs/heads/master')
@@ -713,10 +752,12 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
CommitCollection.new(project, [commit], 'close-by-commit')
)
- merge_request = create(:merge_request,
- target_branch: 'master',
- source_branch: 'close-by-commit',
- source_project: project)
+ merge_request = create(
+ :merge_request,
+ target_branch: 'master',
+ source_branch: 'close-by-commit',
+ source_project: project
+ )
refresh_service = service.new(project: project, current_user: user)
allow(refresh_service).to receive(:execute_hooks)
@@ -735,11 +776,13 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
CommitCollection.new(forked_project, [commit], 'close-by-commit')
)
- merge_request = create(:merge_request,
- target_branch: 'master',
- target_project: project,
- source_branch: 'close-by-commit',
- source_project: forked_project)
+ merge_request = create(
+ :merge_request,
+ target_branch: 'master',
+ target_project: project,
+ source_branch: 'close-by-commit',
+ source_project: forked_project
+ )
refresh_service = service.new(project: forked_project, current_user: user)
allow(refresh_service).to receive(:execute_hooks)
@@ -759,11 +802,13 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
end
it 'marks the merge request as draft from fixup commits' do
- fixup_merge_request = create(:merge_request,
- source_project: @project,
- source_branch: 'wip',
- target_branch: 'master',
- target_project: @project)
+ fixup_merge_request = create(
+ :merge_request,
+ source_project: @project,
+ source_branch: 'wip',
+ target_branch: 'master',
+ target_project: @project
+ )
commits = fixup_merge_request.commits
oldrev = commits.last.id
newrev = commits.first.id
@@ -778,11 +823,13 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
end
it 'references the commit that caused the draft status' do
- draft_merge_request = create(:merge_request,
- source_project: @project,
- source_branch: 'wip',
- target_branch: 'master',
- target_project: @project)
+ draft_merge_request = create(
+ :merge_request,
+ source_project: @project,
+ source_branch: 'wip',
+ target_branch: 'master',
+ target_project: @project
+ )
commits = draft_merge_request.commits
oldrev = commits.last.id
@@ -896,22 +943,23 @@ RSpec.describe MergeRequests::RefreshService, feature_category: :code_review_wor
end
let_it_be(:merge_request, refind: true) do
- create(:merge_request,
- author: author,
- source_project: source_project,
- source_branch: 'feature',
- target_branch: 'master',
- target_project: target_project,
- auto_merge_enabled: true,
- auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
- merge_user: user)
+ create(
+ :merge_request,
+ author: author,
+ source_project: source_project,
+ source_branch: 'feature',
+ target_branch: 'master',
+ target_project: target_project,
+ auto_merge_enabled: true,
+ auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
+ merge_user: user
+ )
end
let_it_be(:newrev) do
- target_project
- .repository
- .create_file(user, 'test1.txt', 'Test data',
- message: 'Test commit', branch_name: 'master')
+ target_project.repository.create_file(
+ user, 'test1.txt', 'Test data', message: 'Test commit', branch_name: 'master'
+ )
end
let_it_be(:oldrev) do
diff --git a/spec/services/merge_requests/reload_diffs_service_spec.rb b/spec/services/merge_requests/reload_diffs_service_spec.rb
index 6cf8af1fcf6..77056cbe541 100644
--- a/spec/services/merge_requests/reload_diffs_service_spec.rb
+++ b/spec/services/merge_requests/reload_diffs_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ReloadDiffsService, :use_clean_rails_memory_store_caching do
+RSpec.describe MergeRequests::ReloadDiffsService, :use_clean_rails_memory_store_caching,
+ feature_category: :code_review_workflow do
let(:current_user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:subject) { described_class.new(merge_request, current_user) }
@@ -18,10 +19,9 @@ RSpec.describe MergeRequests::ReloadDiffsService, :use_clean_rails_memory_store_
new_diff_refs = merge_request.diff_refs
expect(merge_request).to receive(:create_merge_request_diff).and_return(new_diff)
- expect(merge_request).to receive(:update_diff_discussion_positions)
- .with(old_diff_refs: old_diff_refs,
- new_diff_refs: new_diff_refs,
- current_user: current_user)
+ expect(merge_request).to receive(:update_diff_discussion_positions).with(
+ old_diff_refs: old_diff_refs, new_diff_refs: new_diff_refs, current_user: current_user
+ )
subject.execute
end
diff --git a/spec/services/merge_requests/reload_merge_head_diff_service_spec.rb b/spec/services/merge_requests/reload_merge_head_diff_service_spec.rb
index 20b5cf5e3a1..1c315f12221 100644
--- a/spec/services/merge_requests/reload_merge_head_diff_service_spec.rb
+++ b/spec/services/merge_requests/reload_merge_head_diff_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ReloadMergeHeadDiffService do
+RSpec.describe MergeRequests::ReloadMergeHeadDiffService, feature_category: :code_review_workflow do
let(:merge_request) { create(:merge_request) }
subject { described_class.new(merge_request).execute }
diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb
index b9df31b6727..7399b29d06e 100644
--- a/spec/services/merge_requests/reopen_service_spec.rb
+++ b/spec/services/merge_requests/reopen_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ReopenService do
+RSpec.describe MergeRequests::ReopenService, feature_category: :code_review_workflow do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:guest) { create(:user) }
@@ -92,7 +92,11 @@ RSpec.describe MergeRequests::ReopenService do
it 'refreshes the number of open merge requests for a valid MR' do
service = described_class.new(project: project, current_user: user)
- expect { service.execute(merge_request) }
+ expect do
+ service.execute(merge_request)
+
+ BatchLoader::Executor.clear_current
+ end
.to change { project.open_merge_requests_count }.from(0).to(1)
end
diff --git a/spec/services/merge_requests/request_review_service_spec.rb b/spec/services/merge_requests/request_review_service_spec.rb
index 1d3f92b083f..ef96bf11e0b 100644
--- a/spec/services/merge_requests/request_review_service_spec.rb
+++ b/spec/services/merge_requests/request_review_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::RequestReviewService do
+RSpec.describe MergeRequests::RequestReviewService, feature_category: :code_review_workflow do
let(:current_user) { create(:user) }
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request, reviewers: [user]) }
diff --git a/spec/services/merge_requests/resolve_todos_service_spec.rb b/spec/services/merge_requests/resolve_todos_service_spec.rb
index 53bd259f0f4..de7ddbea8bb 100644
--- a/spec/services/merge_requests/resolve_todos_service_spec.rb
+++ b/spec/services/merge_requests/resolve_todos_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ResolveTodosService do
+RSpec.describe MergeRequests::ResolveTodosService, feature_category: :code_review_workflow do
let_it_be(:merge_request) { create(:merge_request) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb b/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb
index 2f191f2ee44..c3a99431dcc 100644
--- a/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb
+++ b/spec/services/merge_requests/resolved_discussion_notification_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ResolvedDiscussionNotificationService do
+RSpec.describe MergeRequests::ResolvedDiscussionNotificationService, feature_category: :code_review_workflow do
let(:merge_request) { create(:merge_request) }
let(:user) { create(:user) }
let(:project) { merge_request.project }
diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb
index 471bb03f18c..1afca466fb5 100644
--- a/spec/services/merge_requests/squash_service_spec.rb
+++ b/spec/services/merge_requests/squash_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::SquashService do
+RSpec.describe MergeRequests::SquashService, feature_category: :source_code_management do
let(:service) { described_class.new(project: project, current_user: user, params: { merge_request: merge_request }) }
let(:user) { project.first_owner }
let(:project) { create(:project, :repository) }
@@ -13,21 +13,27 @@ RSpec.describe MergeRequests::SquashService do
end
let(:merge_request_with_one_commit) do
- create(:merge_request,
- source_branch: 'feature', source_project: project,
- target_branch: 'master', target_project: project)
+ create(
+ :merge_request,
+ source_branch: 'feature', source_project: project,
+ target_branch: 'master', target_project: project
+ )
end
let(:merge_request_with_only_new_files) do
- create(:merge_request,
- source_branch: 'video', source_project: project,
- target_branch: 'master', target_project: project)
+ create(
+ :merge_request,
+ source_branch: 'video', source_project: project,
+ target_branch: 'master', target_project: project
+ )
end
let(:merge_request_with_large_files) do
- create(:merge_request,
- source_branch: 'squash-large-files', source_project: project,
- target_branch: 'master', target_project: project)
+ create(
+ :merge_request,
+ source_branch: 'squash-large-files', source_project: project,
+ target_branch: 'master', target_project: project
+ )
end
shared_examples 'the squash succeeds' do
diff --git a/spec/services/merge_requests/update_assignees_service_spec.rb b/spec/services/merge_requests/update_assignees_service_spec.rb
index 2d80d75a262..85d749de83c 100644
--- a/spec/services/merge_requests/update_assignees_service_spec.rb
+++ b/spec/services/merge_requests/update_assignees_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::UpdateAssigneesService do
+RSpec.describe MergeRequests::UpdateAssigneesService, feature_category: :code_review_workflow do
include AfterNextHelpers
let_it_be(:group) { create(:group, :public) }
@@ -12,13 +12,17 @@ RSpec.describe MergeRequests::UpdateAssigneesService do
let_it_be(:user3) { create(:user) }
let_it_be_with_reload(:merge_request) do
- create(:merge_request, :simple, :unique_branches,
- title: 'Old title',
- description: "FYI #{user2.to_reference}",
- assignee_ids: [user3.id],
- source_project: project,
- target_project: project,
- author: create(:user))
+ create(
+ :merge_request,
+ :simple,
+ :unique_branches,
+ title: 'Old title',
+ description: "FYI #{user2.to_reference}",
+ assignee_ids: [user3.id],
+ source_project: project,
+ target_project: project,
+ author: create(:user)
+ )
end
before do
@@ -107,12 +111,16 @@ RSpec.describe MergeRequests::UpdateAssigneesService do
.with(merge_request, [user3], execute_hooks: true)
end
- other_mr = create(:merge_request, :simple, :unique_branches,
- title: merge_request.title,
- description: merge_request.description,
- assignee_ids: merge_request.assignee_ids,
- source_project: merge_request.project,
- author: merge_request.author)
+ other_mr = create(
+ :merge_request,
+ :simple,
+ :unique_branches,
+ title: merge_request.title,
+ description: merge_request.description,
+ assignee_ids: merge_request.assignee_ids,
+ source_project: merge_request.project,
+ author: merge_request.author
+ )
update_service = ::MergeRequests::UpdateService.new(project: project, current_user: user, params: opts)
diff --git a/spec/services/merge_requests/update_reviewers_service_spec.rb b/spec/services/merge_requests/update_reviewers_service_spec.rb
index 9f935e1cecf..1ae20e8c29c 100644
--- a/spec/services/merge_requests/update_reviewers_service_spec.rb
+++ b/spec/services/merge_requests/update_reviewers_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::UpdateReviewersService do
+RSpec.describe MergeRequests::UpdateReviewersService, feature_category: :code_review_workflow do
include AfterNextHelpers
let_it_be(:group) { create(:group, :public) }
@@ -12,13 +12,17 @@ RSpec.describe MergeRequests::UpdateReviewersService do
let_it_be(:user3) { create(:user) }
let_it_be_with_reload(:merge_request) do
- create(:merge_request, :simple, :unique_branches,
- title: 'Old title',
- description: "FYI #{user2.to_reference}",
- reviewer_ids: [user3.id],
- source_project: project,
- target_project: project,
- author: create(:user))
+ create(
+ :merge_request,
+ :simple,
+ :unique_branches,
+ title: 'Old title',
+ description: "FYI #{user2.to_reference}",
+ reviewer_ids: [user3.id],
+ source_project: project,
+ target_project: project,
+ author: create(:user)
+ )
end
before do
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index e20ebf18e7c..012eb5f6fca 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -15,11 +15,15 @@ RSpec.describe MergeRequests::UpdateService, :mailer, feature_category: :code_re
let(:milestone) { create(:milestone, project: project) }
let(:merge_request) do
- create(:merge_request, :simple, title: 'Old title',
- description: "FYI #{user2.to_reference}",
- assignee_ids: [user3.id],
- source_project: project,
- author: create(:user))
+ create(
+ :merge_request,
+ :simple,
+ title: 'Old title',
+ description: "FYI #{user2.to_reference}",
+ assignee_ids: [user3.id],
+ source_project: project,
+ author: create(:user)
+ )
end
before do
@@ -782,6 +786,27 @@ RSpec.describe MergeRequests::UpdateService, :mailer, feature_category: :code_re
expect(user3.assigned_open_merge_requests_count).to eq 0
expect(user2.assigned_open_merge_requests_count).to eq 1
end
+
+ it 'records the assignment history', :sidekiq_inline do
+ original_assignee = merge_request.assignees.first!
+
+ update_merge_request(assignee_ids: [user2.id])
+
+ expected_events = [
+ have_attributes({
+ merge_request_id: merge_request.id,
+ user_id: original_assignee.id,
+ action: 'remove'
+ }),
+ have_attributes({
+ merge_request_id: merge_request.id,
+ user_id: user2.id,
+ action: 'add'
+ })
+ ]
+
+ expect(merge_request.assignment_events).to match_array(expected_events)
+ end
end
context 'when the target branch changes' do
@@ -1166,10 +1191,12 @@ RSpec.describe MergeRequests::UpdateService, :mailer, feature_category: :code_re
let(:source_project) { fork_project(target_project, nil, repository: true) }
let(:user) { create(:user) }
let(:merge_request) do
- create(:merge_request,
- source_project: source_project,
- source_branch: 'fixes',
- target_project: target_project)
+ create(
+ :merge_request,
+ source_project: source_project,
+ source_branch: 'fixes',
+ target_project: target_project
+ )
end
before do
@@ -1201,10 +1228,12 @@ RSpec.describe MergeRequests::UpdateService, :mailer, feature_category: :code_re
let(:source_project) { fork_project(target_project, nil, repository: true) }
let(:user) { target_project.first_owner }
let(:merge_request) do
- create(:merge_request,
- source_project: source_project,
- source_branch: 'fixes',
- target_project: target_project)
+ create(
+ :merge_request,
+ source_project: source_project,
+ source_branch: 'fixes',
+ target_project: target_project
+ )
end
it "cannot be done by members of the target project when they don't have access" do
@@ -1222,10 +1251,12 @@ RSpec.describe MergeRequests::UpdateService, :mailer, feature_category: :code_re
context 'updating `target_branch`' do
let(:merge_request) do
- create(:merge_request,
- source_project: project,
- source_branch: 'mr-b',
- target_branch: 'mr-a')
+ create(
+ :merge_request,
+ source_project: project,
+ source_branch: 'mr-b',
+ target_branch: 'mr-a'
+ )
end
it 'updates to master' do
diff --git a/spec/services/metrics/dashboard/annotations/create_service_spec.rb b/spec/services/metrics/dashboard/annotations/create_service_spec.rb
index 8f5484fcabe..2bcfa54ead7 100644
--- a/spec/services/metrics/dashboard/annotations/create_service_spec.rb
+++ b/spec/services/metrics/dashboard/annotations/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::Annotations::CreateService do
+RSpec.describe Metrics::Dashboard::Annotations::CreateService, feature_category: :metrics do
let_it_be(:user) { create(:user) }
let(:description) { 'test annotation' }
diff --git a/spec/services/metrics/dashboard/annotations/delete_service_spec.rb b/spec/services/metrics/dashboard/annotations/delete_service_spec.rb
index ec2bd3772bf..557d6d95767 100644
--- a/spec/services/metrics/dashboard/annotations/delete_service_spec.rb
+++ b/spec/services/metrics/dashboard/annotations/delete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::Annotations::DeleteService do
+RSpec.describe Metrics::Dashboard::Annotations::DeleteService, feature_category: :metrics do
let(:user) { create(:user) }
let(:service_instance) { described_class.new(user, annotation) }
diff --git a/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb b/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb
index 47e5557105b..bb11b905a7c 100644
--- a/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::CloneDashboardService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::CloneDashboardService, :use_clean_rails_memory_store_caching, feature_category: :metrics do
include MetricsDashboardHelpers
let_it_be(:user) { create(:user) }
@@ -92,10 +92,6 @@ RSpec.describe Metrics::Dashboard::CloneDashboardService, :use_clean_rails_memor
::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter
]
- it_behaves_like 'valid dashboard cloning process',
- ::Metrics::Dashboard::SelfMonitoringDashboardService::DASHBOARD_PATH,
- [::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter]
-
context 'selected branch already exists' do
let(:branch) { 'existing_branch' }
diff --git a/spec/services/metrics/dashboard/cluster_dashboard_service_spec.rb b/spec/services/metrics/dashboard/cluster_dashboard_service_spec.rb
index f2e32d5eb35..beed23a366f 100644
--- a/spec/services/metrics/dashboard/cluster_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/cluster_dashboard_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::ClusterDashboardService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::ClusterDashboardService, :use_clean_rails_memory_store_caching,
+ feature_category: :metrics do
include MetricsDashboardHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/metrics/dashboard/cluster_metrics_embed_service_spec.rb b/spec/services/metrics/dashboard/cluster_metrics_embed_service_spec.rb
index dbb89af45d0..5d63505e5cc 100644
--- a/spec/services/metrics/dashboard/cluster_metrics_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/cluster_metrics_embed_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::ClusterMetricsEmbedService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::ClusterMetricsEmbedService, :use_clean_rails_memory_store_caching,
+ feature_category: :metrics do
include MetricsDashboardHelpers
using RSpec::Parameterized::TableSyntax
diff --git a/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb b/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb
index afeb1646005..940daa38ae7 100644
--- a/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::CustomDashboardService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::CustomDashboardService, :use_clean_rails_memory_store_caching,
+ feature_category: :metrics do
include MetricsDashboardHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb b/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb
index 127cec6275c..8117296b048 100644
--- a/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::CustomMetricEmbedService do
+RSpec.describe Metrics::Dashboard::CustomMetricEmbedService, feature_category: :metrics do
include MetricsDashboardHelpers
let_it_be(:project, reload: true) { build(:project) }
diff --git a/spec/services/metrics/dashboard/default_embed_service_spec.rb b/spec/services/metrics/dashboard/default_embed_service_spec.rb
index 647778eadc1..6ef248f6b09 100644
--- a/spec/services/metrics/dashboard/default_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/default_embed_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::DefaultEmbedService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::DefaultEmbedService, :use_clean_rails_memory_store_caching,
+ feature_category: :metrics do
include MetricsDashboardHelpers
let_it_be(:project) { build(:project) }
diff --git a/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb b/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb
index 5eb8f24266c..1643f552a70 100644
--- a/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::DynamicEmbedService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::DynamicEmbedService, :use_clean_rails_memory_store_caching,
+ feature_category: :metrics do
include MetricsDashboardHelpers
let_it_be(:project) { build(:project) }
diff --git a/spec/services/metrics/dashboard/gitlab_alert_embed_service_spec.rb b/spec/services/metrics/dashboard/gitlab_alert_embed_service_spec.rb
index 2905e4599f3..25812a492b2 100644
--- a/spec/services/metrics/dashboard/gitlab_alert_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/gitlab_alert_embed_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::GitlabAlertEmbedService do
+RSpec.describe Metrics::Dashboard::GitlabAlertEmbedService, feature_category: :metrics do
include MetricsDashboardHelpers
let_it_be(:alert) { create(:prometheus_alert) }
diff --git a/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb b/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb
index 5263fd40a40..877a455ea44 100644
--- a/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::GrafanaMetricEmbedService do
+RSpec.describe Metrics::Dashboard::GrafanaMetricEmbedService, feature_category: :metrics do
include MetricsDashboardHelpers
include ReactiveCachingHelpers
include GrafanaApiHelpers
diff --git a/spec/services/metrics/dashboard/panel_preview_service_spec.rb b/spec/services/metrics/dashboard/panel_preview_service_spec.rb
index 787c61cc918..584be717d7c 100644
--- a/spec/services/metrics/dashboard/panel_preview_service_spec.rb
+++ b/spec/services/metrics/dashboard/panel_preview_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::PanelPreviewService do
+RSpec.describe Metrics::Dashboard::PanelPreviewService, feature_category: :metrics do
let_it_be(:project) { create(:project) }
let_it_be(:environment) { create(:environment, project: project) }
let_it_be(:panel_yml) do
@@ -64,18 +64,20 @@ RSpec.describe Metrics::Dashboard::PanelPreviewService do
Gitlab::Config::Loader::Yaml::DataTooLargeError,
Gitlab::Config::Loader::FormatError
].each do |error_class|
- before do
- allow_next_instance_of(::Gitlab::Metrics::Dashboard::Processor) do |processor|
- allow(processor).to receive(:process).and_raise(error_class.new('error'))
+ context "with #{error_class}" do
+ before do
+ allow_next_instance_of(::Gitlab::Metrics::Dashboard::Processor) do |processor|
+ allow(processor).to receive(:process).and_raise(error_class.new('error'))
+ end
end
- end
- it 'returns error service response' do
- expect(service_response.error?).to be_truthy
- end
+ it 'returns error service response' do
+ expect(service_response.error?).to be_truthy
+ end
- it 'returns error message' do
- expect(service_response.message).to eq('error')
+ it 'returns error message' do
+ expect(service_response.message).to eq('error')
+ end
end
end
end
diff --git a/spec/services/metrics/dashboard/pod_dashboard_service_spec.rb b/spec/services/metrics/dashboard/pod_dashboard_service_spec.rb
index 0ea812e93ee..a6fcb6b4842 100644
--- a/spec/services/metrics/dashboard/pod_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/pod_dashboard_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::PodDashboardService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::PodDashboardService, :use_clean_rails_memory_store_caching,
+ feature_category: :cell do
include MetricsDashboardHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb b/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb
deleted file mode 100644
index d0cefdbeb30..00000000000
--- a/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Metrics::Dashboard::SelfMonitoringDashboardService, :use_clean_rails_memory_store_caching do
- include MetricsDashboardHelpers
-
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
- let_it_be(:environment) { create(:environment, project: project) }
-
- let(:service_params) { [project, user, { environment: environment }] }
-
- before do
- project.add_maintainer(user) if user
- stub_application_setting(self_monitoring_project_id: project.id)
- end
-
- subject do
- described_class.new(service_params)
- end
-
- describe '#raw_dashboard' do
- it_behaves_like '#raw_dashboard raises error if dashboard loading fails'
- end
-
- describe '#get_dashboard' do
- let(:service_call) { subject.get_dashboard }
-
- subject { described_class.new(*service_params) }
-
- it_behaves_like 'valid dashboard service response'
- it_behaves_like 'raises error for users with insufficient permissions'
- it_behaves_like 'caches the unprocessed dashboard for subsequent calls'
- it_behaves_like 'refreshes cache when dashboard_version is changed'
- it_behaves_like 'updates gitlab_metrics_dashboard_processing_time_ms metric'
-
- it_behaves_like 'dashboard_version contains SHA256 hash of dashboard file content' do
- let(:dashboard_path) { described_class::DASHBOARD_PATH }
- let(:dashboard_version) { subject.send(:dashboard_version) }
- end
- end
-
- describe '.all_dashboard_paths' do
- it 'returns the dashboard attributes' do
- all_dashboards = described_class.all_dashboard_paths(project)
-
- expect(all_dashboards).to eq(
- [{
- path: described_class::DASHBOARD_PATH,
- display_name: described_class::DASHBOARD_NAME,
- default: true,
- system_dashboard: true,
- out_of_the_box_dashboard: true
- }]
- )
- end
- end
-
- describe '.valid_params?' do
- subject { described_class.valid_params?(params) }
-
- context 'with environment' do
- let(:params) { { environment: environment } }
-
- it { is_expected.to be_truthy }
- end
-
- context 'with dashboard_path' do
- let(:params) { { dashboard_path: self_monitoring_dashboard_path } }
-
- it { is_expected.to be_truthy }
- end
-
- context 'with a different dashboard selected' do
- let(:dashboard_path) { '.gitlab/dashboards/test.yml' }
- let(:params) { { dashboard_path: dashboard_path, environment: environment } }
-
- it { is_expected.to be_falsey }
- end
-
- context 'missing environment and dashboard_path' do
- let(:params) { {} }
-
- it { is_expected.to be_falsey }
- end
- end
-end
diff --git a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb
index e1c6aaeec66..b08b980e50e 100644
--- a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::SystemDashboardService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::SystemDashboardService, :use_clean_rails_memory_store_caching,
+ feature_category: :metrics do
include MetricsDashboardHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/metrics/dashboard/transient_embed_service_spec.rb b/spec/services/metrics/dashboard/transient_embed_service_spec.rb
index 53ea83c33d6..1e3ccde6ae3 100644
--- a/spec/services/metrics/dashboard/transient_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/transient_embed_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::TransientEmbedService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::TransientEmbedService, :use_clean_rails_memory_store_caching,
+ feature_category: :metrics do
let_it_be(:project) { build(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:environment) { create(:environment, project: project) }
diff --git a/spec/services/metrics/dashboard/update_dashboard_service_spec.rb b/spec/services/metrics/dashboard/update_dashboard_service_spec.rb
index 148005480ea..15bbe9f9364 100644
--- a/spec/services/metrics/dashboard/update_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/update_dashboard_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::UpdateDashboardService, :use_clean_rails_memory_store_caching do
+RSpec.describe Metrics::Dashboard::UpdateDashboardService, :use_clean_rails_memory_store_caching, feature_category: :metrics do
include MetricsDashboardHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/services/metrics/global_metrics_update_service_spec.rb b/spec/services/metrics/global_metrics_update_service_spec.rb
new file mode 100644
index 00000000000..38c7f9282d9
--- /dev/null
+++ b/spec/services/metrics/global_metrics_update_service_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Metrics::GlobalMetricsUpdateService, :prometheus, feature_category: :metrics do
+ describe '#execute' do
+ it 'sets gitlab_maintenance_mode gauge metric' do
+ metric = subject.maintenance_mode_metric
+ expect(Gitlab).to receive(:maintenance_mode?).and_return(true)
+
+ expect { subject.execute }.to change { metric.get }.from(0).to(1)
+ end
+ end
+end
diff --git a/spec/services/metrics/sample_metrics_service_spec.rb b/spec/services/metrics/sample_metrics_service_spec.rb
index b94345500f0..3442b4303db 100644
--- a/spec/services/metrics/sample_metrics_service_spec.rb
+++ b/spec/services/metrics/sample_metrics_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::SampleMetricsService do
+RSpec.describe Metrics::SampleMetricsService, feature_category: :metrics do
describe 'query' do
let(:range_start) { '2019-12-02T23:31:45.000Z' }
let(:range_end) { '2019-12-03T00:01:45.000Z' }
diff --git a/spec/services/metrics/users_starred_dashboards/create_service_spec.rb b/spec/services/metrics/users_starred_dashboards/create_service_spec.rb
index 1435e39e458..e08bdca8410 100644
--- a/spec/services/metrics/users_starred_dashboards/create_service_spec.rb
+++ b/spec/services/metrics/users_starred_dashboards/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::UsersStarredDashboards::CreateService do
+RSpec.describe Metrics::UsersStarredDashboards::CreateService, feature_category: :metrics do
let_it_be(:user) { create(:user) }
let(:dashboard_path) { 'config/prometheus/common_metrics.yml' }
diff --git a/spec/services/metrics/users_starred_dashboards/delete_service_spec.rb b/spec/services/metrics/users_starred_dashboards/delete_service_spec.rb
index 5cdffe681eb..8c4bcecc239 100644
--- a/spec/services/metrics/users_starred_dashboards/delete_service_spec.rb
+++ b/spec/services/metrics/users_starred_dashboards/delete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::UsersStarredDashboards::DeleteService do
+RSpec.describe Metrics::UsersStarredDashboards::DeleteService, feature_category: :metrics do
subject(:service_instance) { described_class.new(user, project, dashboard_path) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb
index 53751b40667..f362c8da642 100644
--- a/spec/services/milestones/close_service_spec.rb
+++ b/spec/services/milestones/close_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Milestones::CloseService do
+RSpec.describe Milestones::CloseService, feature_category: :team_planning do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:milestone) { create(:milestone, title: "Milestone v1.2", project: project) }
diff --git a/spec/services/milestones/closed_issues_count_service_spec.rb b/spec/services/milestones/closed_issues_count_service_spec.rb
index a3865d08972..f0ed0872c2d 100644
--- a/spec/services/milestones/closed_issues_count_service_spec.rb
+++ b/spec/services/milestones/closed_issues_count_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Milestones::ClosedIssuesCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Milestones::ClosedIssuesCountService, :use_clean_rails_memory_store_caching,
+ feature_category: :team_planning do
let(:project) { create(:project) }
let(:milestone) { create(:milestone, project: project) }
diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb
index 93ca4ff653f..78cb05532eb 100644
--- a/spec/services/milestones/create_service_spec.rb
+++ b/spec/services/milestones/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Milestones::CreateService do
+RSpec.describe Milestones::CreateService, feature_category: :team_planning do
let(:project) { create(:project) }
let(:user) { create(:user) }
diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb
index 6c08b7db43a..209177c348b 100644
--- a/spec/services/milestones/destroy_service_spec.rb
+++ b/spec/services/milestones/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Milestones::DestroyService do
+RSpec.describe Milestones::DestroyService, feature_category: :team_planning do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:milestone) { create(:milestone, title: 'Milestone v1.0', project: project) }
diff --git a/spec/services/milestones/find_or_create_service_spec.rb b/spec/services/milestones/find_or_create_service_spec.rb
index 1bcaf578441..8a72778a22a 100644
--- a/spec/services/milestones/find_or_create_service_spec.rb
+++ b/spec/services/milestones/find_or_create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Milestones::FindOrCreateService do
+RSpec.describe Milestones::FindOrCreateService, feature_category: :team_planning do
describe '#execute' do
subject(:service) { described_class.new(project, user, params) }
diff --git a/spec/services/milestones/issues_count_service_spec.rb b/spec/services/milestones/issues_count_service_spec.rb
index c944055e4e7..a80b27822b6 100644
--- a/spec/services/milestones/issues_count_service_spec.rb
+++ b/spec/services/milestones/issues_count_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Milestones::IssuesCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Milestones::IssuesCountService, :use_clean_rails_memory_store_caching,
+ feature_category: :team_planning do
let(:project) { create(:project) }
let(:milestone) { create(:milestone, project: project) }
diff --git a/spec/services/milestones/merge_requests_count_service_spec.rb b/spec/services/milestones/merge_requests_count_service_spec.rb
index aecc7d5ef52..00b7b95aeb6 100644
--- a/spec/services/milestones/merge_requests_count_service_spec.rb
+++ b/spec/services/milestones/merge_requests_count_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Milestones::MergeRequestsCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Milestones::MergeRequestsCountService, :use_clean_rails_memory_store_caching,
+ feature_category: :team_planning do
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:milestone) { create(:milestone, project: project) }
diff --git a/spec/services/milestones/promote_service_spec.rb b/spec/services/milestones/promote_service_spec.rb
index 8f4201d8d94..203ac2d3f40 100644
--- a/spec/services/milestones/promote_service_spec.rb
+++ b/spec/services/milestones/promote_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Milestones::PromoteService do
+RSpec.describe Milestones::PromoteService, feature_category: :team_planning do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:user) { create(:user) }
diff --git a/spec/services/milestones/transfer_service_spec.rb b/spec/services/milestones/transfer_service_spec.rb
index de02226661c..ea65f713902 100644
--- a/spec/services/milestones/transfer_service_spec.rb
+++ b/spec/services/milestones/transfer_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Milestones::TransferService do
+RSpec.describe Milestones::TransferService, feature_category: :team_planning do
describe '#execute' do
subject(:service) { described_class.new(user, old_group, project) }
diff --git a/spec/services/milestones/update_service_spec.rb b/spec/services/milestones/update_service_spec.rb
index 85fd89c11ac..76110af2514 100644
--- a/spec/services/milestones/update_service_spec.rb
+++ b/spec/services/milestones/update_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Milestones::UpdateService do
+RSpec.describe Milestones::UpdateService, feature_category: :team_planning do
let(:project) { create(:project) }
let(:user) { build(:user) }
let(:milestone) { create(:milestone, project: project) }
diff --git a/spec/services/ml/experiment_tracking/candidate_repository_spec.rb b/spec/services/ml/experiment_tracking/candidate_repository_spec.rb
index e3c05178025..079c36c9613 100644
--- a/spec/services/ml/experiment_tracking/candidate_repository_spec.rb
+++ b/spec/services/ml/experiment_tracking/candidate_repository_spec.rb
@@ -2,23 +2,23 @@
require 'spec_helper'
-RSpec.describe ::Ml::ExperimentTracking::CandidateRepository do
+RSpec.describe ::Ml::ExperimentTracking::CandidateRepository, feature_category: :experimentation_activation do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:experiment) { create(:ml_experiments, user: user, project: project) }
- let_it_be(:candidate) { create(:ml_candidates, user: user, experiment: experiment) }
+ let_it_be(:candidate) { create(:ml_candidates, user: user, experiment: experiment, project: project) }
let(:repository) { described_class.new(project, user) }
- describe '#by_iid' do
- let(:iid) { candidate.iid }
+ describe '#by_eid' do
+ let(:eid) { candidate.eid }
- subject { repository.by_iid(iid) }
+ subject { repository.by_eid(eid) }
it { is_expected.to eq(candidate) }
context 'when iid does not exist' do
- let(:iid) { non_existing_record_iid.to_s }
+ let(:eid) { non_existing_record_iid.to_s }
it { is_expected.to be_nil }
end
@@ -38,7 +38,7 @@ RSpec.describe ::Ml::ExperimentTracking::CandidateRepository do
it 'creates the candidate' do
expect(subject.start_time).to eq(1234)
- expect(subject.iid).not_to be_nil
+ expect(subject.eid).not_to be_nil
expect(subject.end_time).to be_nil
expect(subject.name).to eq('some_candidate')
end
@@ -166,6 +166,14 @@ RSpec.describe ::Ml::ExperimentTracking::CandidateRepository do
expect { repository.add_tag!(candidate, 'new', props[:value]) }.to raise_error(ActiveRecord::RecordInvalid)
end
end
+
+ context 'when tag starts with gitlab.' do
+ it 'calls HandleCandidateGitlabMetadataService' do
+ expect(Ml::ExperimentTracking::HandleCandidateGitlabMetadataService).to receive(:new).and_call_original
+
+ repository.add_tag!(candidate, 'gitlab.CI_USER_ID', user.id)
+ end
+ end
end
describe "#add_params" do
@@ -291,5 +299,15 @@ RSpec.describe ::Ml::ExperimentTracking::CandidateRepository do
expect { subject }.to change { candidate.reload.metadata.size }.by(1)
end
end
+
+ context 'when tags is nil' do
+ let(:tags) { nil }
+
+ it 'does not handle gitlab tags' do
+ expect(repository).not_to receive(:handle_gitlab_tags)
+
+ subject
+ end
+ end
end
end
diff --git a/spec/services/ml/experiment_tracking/experiment_repository_spec.rb b/spec/services/ml/experiment_tracking/experiment_repository_spec.rb
index c3c716b831a..3c645fa84b4 100644
--- a/spec/services/ml/experiment_tracking/experiment_repository_spec.rb
+++ b/spec/services/ml/experiment_tracking/experiment_repository_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Ml::ExperimentTracking::ExperimentRepository do
+RSpec.describe ::Ml::ExperimentTracking::ExperimentRepository, feature_category: :experimentation_activation do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:experiment) { create(:ml_experiments, user: user, project: project) }
diff --git a/spec/services/ml/experiment_tracking/handle_candidate_gitlab_metadata_service_spec.rb b/spec/services/ml/experiment_tracking/handle_candidate_gitlab_metadata_service_spec.rb
new file mode 100644
index 00000000000..f0e7c241d5d
--- /dev/null
+++ b/spec/services/ml/experiment_tracking/handle_candidate_gitlab_metadata_service_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ml::ExperimentTracking::HandleCandidateGitlabMetadataService, feature_category: :experimentation_activation do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.owner }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be(:build) { create(:ci_build, :success, pipeline: pipeline) }
+
+ let(:metadata) { [] }
+ let(:candidate) { create(:ml_candidates, project: project, user: user) }
+
+ describe 'execute' do
+ subject { described_class.new(candidate, metadata).execute }
+
+ context 'when metadata includes gitlab.CI_JOB_ID', 'and gitlab.CI_JOB_ID is valid' do
+ let(:metadata) do
+ [
+ { key: 'gitlab.CI_JOB_ID', value: build.id.to_s }
+ ]
+ end
+
+ it 'updates candidate correctly', :aggregate_failures do
+ subject
+
+ expect(candidate.ci_build).to eq(build)
+ end
+ end
+
+ context 'when metadata includes gitlab.CI_JOB_ID and gitlab.CI_JOB_ID is invalid' do
+ let(:metadata) { [{ key: 'gitlab.CI_JOB_ID', value: non_existing_record_id.to_s }] }
+
+ it 'raises error' do
+ expect { subject }
+ .to raise_error(ArgumentError, 'gitlab.CI_JOB_ID must refer to an existing build')
+ end
+ end
+ end
+end
diff --git a/spec/services/namespace_settings/update_service_spec.rb b/spec/services/namespace_settings/update_service_spec.rb
index e0f32cb3821..5f1ff6746bc 100644
--- a/spec/services/namespace_settings/update_service_spec.rb
+++ b/spec/services/namespace_settings/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe NamespaceSettings::UpdateService do
+RSpec.describe NamespaceSettings::UpdateService, feature_category: :subgroups do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:settings) { {} }
diff --git a/spec/services/namespaces/in_product_marketing_emails_service_spec.rb b/spec/services/namespaces/in_product_marketing_emails_service_spec.rb
index b44c256802f..8a2ecd5c3e0 100644
--- a/spec/services/namespaces/in_product_marketing_emails_service_spec.rb
+++ b/spec/services/namespaces/in_product_marketing_emails_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do
+RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute', feature_category: :purchase do
subject(:execute_service) { described_class.new(track, interval).execute }
let(:track) { :create }
diff --git a/spec/services/namespaces/package_settings/update_service_spec.rb b/spec/services/namespaces/package_settings/update_service_spec.rb
index 10926c5ef57..e21c9a8f1b9 100644
--- a/spec/services/namespaces/package_settings/update_service_spec.rb
+++ b/spec/services/namespaces/package_settings/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Namespaces::PackageSettings::UpdateService do
+RSpec.describe ::Namespaces::PackageSettings::UpdateService, feature_category: :package_registry do
using RSpec::Parameterized::TableSyntax
let_it_be_with_reload(:namespace) { create(:group) }
diff --git a/spec/services/namespaces/statistics_refresher_service_spec.rb b/spec/services/namespaces/statistics_refresher_service_spec.rb
index 2d5f9235bd4..750f98615cc 100644
--- a/spec/services/namespaces/statistics_refresher_service_spec.rb
+++ b/spec/services/namespaces/statistics_refresher_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Namespaces::StatisticsRefresherService, '#execute' do
+RSpec.describe Namespaces::StatisticsRefresherService, '#execute', feature_category: :subgroups do
let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) }
let(:projects) { create_list(:project, 5, namespace: group) }
diff --git a/spec/services/note_summary_spec.rb b/spec/services/note_summary_spec.rb
index ad244f62292..1cbbb68205d 100644
--- a/spec/services/note_summary_spec.rb
+++ b/spec/services/note_summary_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe NoteSummary do
+RSpec.describe NoteSummary, feature_category: :code_review_workflow do
let(:project) { build(:project) }
let(:noteable) { build(:issue) }
let(:user) { build(:user) }
diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb
index 67d8b37f809..b300a1621de 100644
--- a/spec/services/notes/build_service_spec.rb
+++ b/spec/services/notes/build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Notes::BuildService do
+RSpec.describe Notes::BuildService, feature_category: :team_planning do
include AdminModeHelper
let_it_be(:project) { create(:project, :repository) }
@@ -67,10 +67,12 @@ RSpec.describe Notes::BuildService do
context 'personal snippet note' do
def reply(note, user = other_user)
- described_class.new(nil,
- user,
- note: 'Test',
- in_reply_to_discussion_id: note.discussion_id).execute
+ described_class.new(
+ nil,
+ user,
+ note: 'Test',
+ in_reply_to_discussion_id: note.discussion_id
+ ).execute
end
let_it_be(:snippet_author) { noteable_author }
diff --git a/spec/services/notes/copy_service_spec.rb b/spec/services/notes/copy_service_spec.rb
index 2fa9a462bb9..a7b5e37ccce 100644
--- a/spec/services/notes/copy_service_spec.rb
+++ b/spec/services/notes/copy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Notes::CopyService do
+RSpec.describe Notes::CopyService, feature_category: :team_planning do
describe '#initialize' do
let_it_be(:noteable) { create(:issue) }
@@ -25,8 +25,10 @@ RSpec.describe Notes::CopyService do
context 'simple notes' do
let!(:notes) do
[
- create(:note, noteable: from_noteable, project: from_noteable.project,
- created_at: 2.weeks.ago, updated_at: 1.week.ago),
+ create(
+ :note, noteable: from_noteable, project: from_noteable.project,
+ created_at: 2.weeks.ago, updated_at: 1.week.ago
+ ),
create(:note, noteable: from_noteable, project: from_noteable.project),
create(:note, system: true, noteable: from_noteable, project: from_noteable.project)
]
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 1ee9e51433e..240d81bb485 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -108,7 +108,6 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { issue.namespace }
let(:category) { described_class.to_s }
let(:action) { 'incident_management_incident_comment' }
@@ -124,10 +123,6 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
let(:execute_create_service) { described_class.new(project, user, opts).execute }
- before do
- stub_feature_flags(notes_create_service_tracking: false)
- end
-
it 'tracks commit comment usage data', :clean_gitlab_redis_shared_state do
expect(counter).to receive(:count).with(:create, 'Commit').and_call_original
@@ -141,7 +136,6 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
let(:action) { 'create_commit_comment' }
let(:label) { 'counts.commit_comment' }
let(:namespace) { project.namespace }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase4 }
end
end
@@ -157,7 +151,9 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
.and_call_original
expect do
execute_create_service
- end.to change { counter.unique_events(event_names: event, start_date: 1.day.ago, end_date: 1.day.from_now) }.by(1)
+ end.to change {
+ counter.unique_events(event_names: event, start_date: Date.today.beginning_of_week, end_date: 1.week.from_now)
+ }.by(1)
end
it 'does not track merge request usage data' do
@@ -178,22 +174,45 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
create(:merge_request, source_project: project_with_repo, target_project: project_with_repo)
end
+ let(:new_opts) { opts.merge(noteable_type: 'MergeRequest', noteable_id: merge_request.id) }
+
+ it 'calls MergeRequests::MarkReviewerReviewedService service' do
+ expect_next_instance_of(
+ MergeRequests::MarkReviewerReviewedService,
+ project: project_with_repo, current_user: user
+ ) do |service|
+ expect(service).to receive(:execute).with(merge_request)
+ end
+
+ described_class.new(project_with_repo, user, new_opts).execute
+ end
+
+ it 'does not call MergeRequests::MarkReviewerReviewedService service when skip_set_reviewed is true' do
+ expect(MergeRequests::MarkReviewerReviewedService).not_to receive(:new)
+
+ described_class.new(project_with_repo, user, new_opts).execute(skip_set_reviewed: true)
+ end
+
context 'noteable highlight cache clearing' do
let(:position) do
- Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
- new_path: "files/ruby/popen.rb",
- old_line: nil,
- new_line: 14,
- diff_refs: merge_request.diff_refs)
+ Gitlab::Diff::Position.new(
+ old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 14,
+ diff_refs: merge_request.diff_refs
+ )
end
let(:new_opts) do
- opts.merge(in_reply_to_discussion_id: nil,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: position.to_h,
- confidential: false)
+ opts.merge(
+ in_reply_to_discussion_id: nil,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: position.to_h,
+ confidential: false
+ )
end
before do
@@ -223,12 +242,14 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
prev_note =
create(:diff_note_on_merge_request, noteable: merge_request, project: project_with_repo)
reply_opts =
- opts.merge(in_reply_to_discussion_id: prev_note.discussion_id,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: position.to_h,
- confidential: false)
+ opts.merge(
+ in_reply_to_discussion_id: prev_note.discussion_id,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: position.to_h,
+ confidential: false
+ )
expect(merge_request).not_to receive(:diffs)
@@ -239,11 +260,13 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
context 'note diff file' do
let(:line_number) { 14 }
let(:position) do
- Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
- new_path: "files/ruby/popen.rb",
- old_line: nil,
- new_line: line_number,
- diff_refs: merge_request.diff_refs)
+ Gitlab::Diff::Position.new(
+ old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: line_number,
+ diff_refs: merge_request.diff_refs
+ )
end
let(:previous_note) do
@@ -255,12 +278,14 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
end
context 'when eligible to have a note diff file' do
let(:new_opts) do
- opts.merge(in_reply_to_discussion_id: nil,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: position.to_h,
- confidential: false)
+ opts.merge(
+ in_reply_to_discussion_id: nil,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: position.to_h,
+ confidential: false
+ )
end
it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do
@@ -331,12 +356,14 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
context 'when DiffNote is a reply' do
let(:new_opts) do
- opts.merge(in_reply_to_discussion_id: previous_note.discussion_id,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: position.to_h,
- confidential: false)
+ opts.merge(
+ in_reply_to_discussion_id: previous_note.discussion_id,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: position.to_h,
+ confidential: false
+ )
end
it 'note is not associated with a note diff file' do
@@ -350,23 +377,27 @@ RSpec.describe Notes::CreateService, feature_category: :team_planning do
context 'when DiffNote from an image' do
let(:image_position) do
- Gitlab::Diff::Position.new(old_path: "files/images/6049019_460s.jpg",
- new_path: "files/images/6049019_460s.jpg",
- width: 100,
- height: 100,
- x: 1,
- y: 100,
- diff_refs: merge_request.diff_refs,
- position_type: 'image')
+ Gitlab::Diff::Position.new(
+ old_path: "files/images/6049019_460s.jpg",
+ new_path: "files/images/6049019_460s.jpg",
+ width: 100,
+ height: 100,
+ x: 1,
+ y: 100,
+ diff_refs: merge_request.diff_refs,
+ position_type: 'image'
+ )
end
let(:new_opts) do
- opts.merge(in_reply_to_discussion_id: nil,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: image_position.to_h,
- confidential: false)
+ opts.merge(
+ in_reply_to_discussion_id: nil,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: image_position.to_h,
+ confidential: false
+ )
end
it 'note is not associated with a note diff file' do
diff --git a/spec/services/notes/destroy_service_spec.rb b/spec/services/notes/destroy_service_spec.rb
index 744808525f5..396e23351c9 100644
--- a/spec/services/notes/destroy_service_spec.rb
+++ b/spec/services/notes/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Notes::DestroyService do
+RSpec.describe Notes::DestroyService, feature_category: :team_planning do
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) }
@@ -38,7 +38,9 @@ RSpec.describe Notes::DestroyService do
.and_call_original
expect do
service_action
- end.to change { counter.unique_events(event_names: property, start_date: 1.day.ago, end_date: 1.day.from_now) }.by(1)
+ end.to change {
+ counter.unique_events(event_names: property, start_date: Date.today.beginning_of_week, end_date: 1.week.from_now)
+ }.by(1)
end
it_behaves_like 'issue_edit snowplow tracking' do
@@ -94,8 +96,12 @@ RSpec.describe Notes::DestroyService do
it 'tracks design comment removal' do
note = create(:note_on_design, project: project)
- expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_design_comment_removed_action).with(author: note.author,
- project: project)
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(
+ :track_issue_design_comment_removed_action
+ ).with(
+ author: note.author,
+ project: project
+ )
described_class.new(project, user).execute(note)
end
diff --git a/spec/services/notes/post_process_service_spec.rb b/spec/services/notes/post_process_service_spec.rb
index 17001733c5b..0bcfd6b63d2 100644
--- a/spec/services/notes/post_process_service_spec.rb
+++ b/spec/services/notes/post_process_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Notes::PostProcessService do
+RSpec.describe Notes::PostProcessService, feature_category: :team_planning do
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb
index bca954c3959..c65a077f907 100644
--- a/spec/services/notes/quick_actions_service_spec.rb
+++ b/spec/services/notes/quick_actions_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Notes::QuickActionsService do
+RSpec.describe Notes::QuickActionsService, feature_category: :team_planning do
shared_context 'note on noteable' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } }
@@ -182,7 +182,7 @@ RSpec.describe Notes::QuickActionsService do
context 'on an incident' do
before do
- issue.update!(issue_type: :incident)
+ issue.update!(issue_type: :incident, work_item_type: WorkItems::Type.default_by_type(:incident))
end
it 'leaves the note empty' do
@@ -224,7 +224,7 @@ RSpec.describe Notes::QuickActionsService do
context 'on an incident' do
before do
- issue.update!(issue_type: :incident)
+ issue.update!(issue_type: :incident, work_item_type: WorkItems::Type.default_by_type(:incident))
end
it 'leaves the note empty' do
@@ -250,28 +250,6 @@ RSpec.describe Notes::QuickActionsService do
end
end
- describe '.noteable_update_service_class' do
- include_context 'note on noteable'
-
- it 'returns Issues::UpdateService for a note on an issue' do
- note = create(:note_on_issue, project: project)
-
- expect(described_class.noteable_update_service_class(note)).to eq(Issues::UpdateService)
- end
-
- it 'returns MergeRequests::UpdateService for a note on a merge request' do
- note = create(:note_on_merge_request, project: project)
-
- expect(described_class.noteable_update_service_class(note)).to eq(MergeRequests::UpdateService)
- end
-
- it 'returns Commits::TagService for a note on a commit' do
- note = create(:note_on_commit, project: project)
-
- expect(described_class.noteable_update_service_class(note)).to eq(Commits::TagService)
- end
- end
-
describe '.supported?' do
include_context 'note on noteable'
@@ -322,6 +300,84 @@ RSpec.describe Notes::QuickActionsService do
let(:merge_request) { create(:merge_request, source_project: project) }
let(:note) { build(:note_on_merge_request, project: project, noteable: merge_request) }
end
+
+ context 'note on work item that supports quick actions' do
+ include_context 'note on noteable'
+
+ let_it_be(:work_item, reload: true) { create(:work_item, project: project) }
+
+ let(:note) { build(:note_on_work_item, project: project, noteable: work_item) }
+
+ let!(:labels) { create_pair(:label, project: project) }
+
+ before do
+ note.note = note_text
+ end
+
+ describe 'note with only command' do
+ describe '/close, /label & /assign' do
+ let(:note_text) do
+ %(/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n)
+ end
+
+ it 'closes noteable, sets labels, assigns and leave no note' do
+ content = execute(note)
+
+ expect(content).to be_empty
+ expect(note.noteable).to be_closed
+ expect(note.noteable.labels).to match_array(labels)
+ expect(note.noteable.assignees).to eq([assignee])
+ end
+ end
+
+ describe '/reopen' do
+ before do
+ note.noteable.close!
+ expect(note.noteable).to be_closed
+ end
+ let(:note_text) { '/reopen' }
+
+ it 'opens the noteable, and leave no note' do
+ content = execute(note)
+
+ expect(content).to be_empty
+ expect(note.noteable).to be_open
+ end
+ end
+ end
+
+ describe 'note with command & text' do
+ describe '/close, /label, /assign' do
+ let(:note_text) do
+ %(HELLO\n/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\nWORLD)
+ end
+
+ it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do
+ content = execute(note)
+
+ expect(content).to eq "HELLO\nWORLD"
+ expect(note.noteable).to be_closed
+ expect(note.noteable.labels).to match_array(labels)
+ expect(note.noteable.assignees).to eq([assignee])
+ end
+ end
+
+ describe '/reopen' do
+ before do
+ note.noteable.close
+ expect(note.noteable).to be_closed
+ end
+ let(:note_text) { "HELLO\n/reopen\nWORLD" }
+
+ it 'opens the noteable' do
+ content = execute(note)
+
+ expect(content).to eq "HELLO\nWORLD"
+ expect(note.noteable).to be_open
+ end
+ end
+ end
+ end
end
context 'CE restriction for issue assignees' do
diff --git a/spec/services/notes/render_service_spec.rb b/spec/services/notes/render_service_spec.rb
index 09cd7dc572b..d633cdd0448 100644
--- a/spec/services/notes/render_service_spec.rb
+++ b/spec/services/notes/render_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Notes::RenderService do
+RSpec.describe Notes::RenderService, feature_category: :team_planning do
describe '#execute' do
it 'renders a Note' do
note = double(:note)
@@ -27,12 +27,14 @@ RSpec.describe Notes::RenderService do
.to receive(:render)
.with([note], :note)
- described_class.new(user).execute([note],
- requested_path: 'foo',
- project_wiki: wiki,
- ref: 'bar',
- only_path: nil,
- xhtml: false)
+ described_class.new(user).execute(
+ [note],
+ requested_path: 'foo',
+ project_wiki: wiki,
+ ref: 'bar',
+ only_path: nil,
+ xhtml: false
+ )
end
end
end
diff --git a/spec/services/notes/resolve_service_spec.rb b/spec/services/notes/resolve_service_spec.rb
index 1c5b308aed1..1b5586ee1b3 100644
--- a/spec/services/notes/resolve_service_spec.rb
+++ b/spec/services/notes/resolve_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Notes::ResolveService do
+RSpec.describe Notes::ResolveService, feature_category: :team_planning do
let(:merge_request) { create(:merge_request) }
let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.project) }
let(:user) { merge_request.author }
diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb
index 05703ac548d..245cc046775 100644
--- a/spec/services/notes/update_service_spec.rb
+++ b/spec/services/notes/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Notes::UpdateService do
+RSpec.describe Notes::UpdateService, feature_category: :team_planning do
let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, group: group) }
let(:private_group) { create(:group, :private) }
@@ -65,7 +65,7 @@ RSpec.describe Notes::UpdateService do
.and_call_original
expect do
update_note(note: 'new text')
- end.to change { counter.unique_events(event_names: event, start_date: 1.day.ago, end_date: 1.day.from_now) }.by(1)
+ end.to change { counter.unique_events(event_names: event, start_date: Date.today.beginning_of_week, end_date: 1.week.from_now) }.by(1)
end
it_behaves_like 'issue_edit snowplow tracking' do
diff --git a/spec/services/notification_recipients/build_service_spec.rb b/spec/services/notification_recipients/build_service_spec.rb
index 899d23ec641..bfd1dcd7d80 100644
--- a/spec/services/notification_recipients/build_service_spec.rb
+++ b/spec/services/notification_recipients/build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe NotificationRecipients::BuildService do
+RSpec.describe NotificationRecipients::BuildService, feature_category: :team_planning do
let(:service) { described_class }
let(:assignee) { create(:user) }
let(:project) { create(:project, :public) }
diff --git a/spec/services/notification_recipients/builder/default_spec.rb b/spec/services/notification_recipients/builder/default_spec.rb
index 4d0ddc7c4f7..da991b5951a 100644
--- a/spec/services/notification_recipients/builder/default_spec.rb
+++ b/spec/services/notification_recipients/builder/default_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe NotificationRecipients::Builder::Default do
+RSpec.describe NotificationRecipients::Builder::Default, feature_category: :team_planning do
describe '#build!' do
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group).tap { |p| p.add_developer(project_watcher) if project_watcher } }
diff --git a/spec/services/notification_recipients/builder/new_note_spec.rb b/spec/services/notification_recipients/builder/new_note_spec.rb
index 7d2a4f682c5..e87824f3156 100644
--- a/spec/services/notification_recipients/builder/new_note_spec.rb
+++ b/spec/services/notification_recipients/builder/new_note_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe NotificationRecipients::Builder::NewNote do
+RSpec.describe NotificationRecipients::Builder::NewNote, feature_category: :team_planning do
describe '#notification_recipients' do
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 4161f93cdac..f63f982708d 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -253,6 +253,16 @@ RSpec.describe NotificationService, :mailer, feature_category: :team_planning do
it_behaves_like 'participating by assignee notification', check_delivery_jobs_queue: check_delivery_jobs_queue
end
+ shared_examples 'declines the invite' do
+ specify do
+ member = source.members.last
+
+ expect do
+ notification.decline_invite(member)
+ end.to change { ActionMailer::Base.deliveries.size }.by(1)
+ end
+ end
+
describe '.permitted_actions' do
it 'includes public methods' do
expect(described_class.permitted_actions).to include(:access_token_created)
@@ -518,8 +528,8 @@ RSpec.describe NotificationService, :mailer, feature_category: :team_planning do
allow(Notify).to receive(:service_desk_new_note_email)
.with(Integer, Integer, String).and_return(mailer)
- allow(::Gitlab::IncomingEmail).to receive(:enabled?) { true }
- allow(::Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true }
+ allow(::Gitlab::Email::IncomingEmail).to receive(:enabled?) { true }
+ allow(::Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?) { true }
end
let(:subject) { NotificationService.new }
@@ -3029,7 +3039,7 @@ RSpec.describe NotificationService, :mailer, feature_category: :team_planning do
end
end
- describe '#decline_group_invite' do
+ describe '#decline_invite' do
let(:creator) { create(:user) }
let(:group) { create(:group) }
let(:member) { create(:user) }
@@ -3039,12 +3049,8 @@ RSpec.describe NotificationService, :mailer, feature_category: :team_planning do
group.add_developer(member, creator)
end
- it do
- group_member = group.members.last
-
- expect do
- notification.decline_group_invite(group_member)
- end.to change { ActionMailer::Base.deliveries.size }.by(1)
+ it_behaves_like 'declines the invite' do
+ let(:source) { group }
end
end
@@ -3201,19 +3207,15 @@ RSpec.describe NotificationService, :mailer, feature_category: :team_planning do
end
end
- describe '#decline_project_invite' do
+ describe '#decline_invite' do
let(:member) { create(:user) }
before do
project.add_developer(member, current_user: project.first_owner)
end
- it do
- project_member = project.members.last
-
- expect do
- notification.decline_project_invite(project_member)
- end.to change { ActionMailer::Base.deliveries.size }.by(1)
+ it_behaves_like 'declines the invite' do
+ let(:source) { project }
end
end
diff --git a/spec/services/onboarding/progress_service_spec.rb b/spec/services/onboarding/progress_service_spec.rb
index 8f3f723613e..e1d6b4cd44b 100644
--- a/spec/services/onboarding/progress_service_spec.rb
+++ b/spec/services/onboarding/progress_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Onboarding::ProgressService do
+RSpec.describe Onboarding::ProgressService, feature_category: :onboarding do
describe '.async' do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:action) { :git_pull }
diff --git a/spec/services/packages/cleanup/execute_policy_service_spec.rb b/spec/services/packages/cleanup/execute_policy_service_spec.rb
index 93335c4a821..a083dc0d4ea 100644
--- a/spec/services/packages/cleanup/execute_policy_service_spec.rb
+++ b/spec/services/packages/cleanup/execute_policy_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Cleanup::ExecutePolicyService do
+RSpec.describe Packages::Cleanup::ExecutePolicyService, feature_category: :package_registry do
let_it_be(:project) { create(:project) }
let_it_be_with_reload(:policy) { create(:packages_cleanup_policy, project: project) }
diff --git a/spec/services/packages/cleanup/update_policy_service_spec.rb b/spec/services/packages/cleanup/update_policy_service_spec.rb
index a11fbb766f5..8068c351e5f 100644
--- a/spec/services/packages/cleanup/update_policy_service_spec.rb
+++ b/spec/services/packages/cleanup/update_policy_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Cleanup::UpdatePolicyService do
+RSpec.describe Packages::Cleanup::UpdatePolicyService, feature_category: :package_registry do
using RSpec::Parameterized::TableSyntax
let_it_be_with_reload(:project) { create(:project) }
diff --git a/spec/services/packages/composer/composer_json_service_spec.rb b/spec/services/packages/composer/composer_json_service_spec.rb
index d2187688c4c..15acd79c49e 100644
--- a/spec/services/packages/composer/composer_json_service_spec.rb
+++ b/spec/services/packages/composer/composer_json_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Composer::ComposerJsonService do
+RSpec.describe Packages::Composer::ComposerJsonService, feature_category: :package_registry do
describe '#execute' do
let(:branch) { project.repository.find_branch('master') }
let(:target) { branch.target }
diff --git a/spec/services/packages/composer/create_package_service_spec.rb b/spec/services/packages/composer/create_package_service_spec.rb
index 26429a7b5d9..78d5d76fe4f 100644
--- a/spec/services/packages/composer/create_package_service_spec.rb
+++ b/spec/services/packages/composer/create_package_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Composer::CreatePackageService do
+RSpec.describe Packages::Composer::CreatePackageService, feature_category: :package_registry do
include PackagesManagerApiSpecHelpers
let_it_be(:package_name) { 'composer-package-name' }
diff --git a/spec/services/packages/composer/version_parser_service_spec.rb b/spec/services/packages/composer/version_parser_service_spec.rb
index 69253ff934e..ac50f2e2e55 100644
--- a/spec/services/packages/composer/version_parser_service_spec.rb
+++ b/spec/services/packages/composer/version_parser_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Composer::VersionParserService do
+RSpec.describe Packages::Composer::VersionParserService, feature_category: :package_registry do
let_it_be(:params) { {} }
describe '#execute' do
diff --git a/spec/services/packages/conan/create_package_file_service_spec.rb b/spec/services/packages/conan/create_package_file_service_spec.rb
index e655b8d1f9e..6859e52560a 100644
--- a/spec/services/packages/conan/create_package_file_service_spec.rb
+++ b/spec/services/packages/conan/create_package_file_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Conan::CreatePackageFileService do
+RSpec.describe Packages::Conan::CreatePackageFileService, feature_category: :package_registry do
include WorkhorseHelpers
let_it_be(:package) { create(:conan_package) }
diff --git a/spec/services/packages/conan/create_package_service_spec.rb b/spec/services/packages/conan/create_package_service_spec.rb
index 6f644f5ef95..db06463b7fa 100644
--- a/spec/services/packages/conan/create_package_service_spec.rb
+++ b/spec/services/packages/conan/create_package_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Conan::CreatePackageService do
+RSpec.describe Packages::Conan::CreatePackageService, feature_category: :package_registry do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/packages/conan/search_service_spec.rb b/spec/services/packages/conan/search_service_spec.rb
index 9e8be164d8c..83ece404d5f 100644
--- a/spec/services/packages/conan/search_service_spec.rb
+++ b/spec/services/packages/conan/search_service_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Packages::Conan::SearchService, feature_category: :package_regist
let!(:conan_package) { create(:conan_package, project: project) }
let!(:conan_package2) { create(:conan_package, project: project) }
- subject { described_class.new(user, query: query) }
+ subject { described_class.new(project, user, query: query) }
before do
project.add_developer(user)
@@ -24,7 +24,7 @@ RSpec.describe Packages::Conan::SearchService, feature_category: :package_regist
result = subject.execute
expect(result.status).to eq :success
- expect(result.payload).to eq(results: [conan_package.conan_recipe, conan_package2.conan_recipe])
+ expect(result.payload).to eq(results: [conan_package2.conan_recipe, conan_package.conan_recipe])
end
end
@@ -71,5 +71,29 @@ RSpec.describe Packages::Conan::SearchService, feature_category: :package_regist
expect(result.payload).to eq(results: [])
end
end
+
+ context 'for project' do
+ let_it_be(:project2) { create(:project, :public) }
+ let(:query) { conan_package.name }
+ let!(:conan_package3) { create(:conan_package, name: conan_package.name, project: project2) }
+
+ context 'when passing a project' do
+ it 'returns only packages of the given project' do
+ result = subject.execute
+
+ expect(result.status).to eq :success
+ expect(result[:results]).to match_array([conan_package.conan_recipe])
+ end
+ end
+
+ context 'when passing a project with nil' do
+ it 'returns all packages' do
+ result = described_class.new(nil, user, query: query).execute
+
+ expect(result.status).to eq :success
+ expect(result[:results]).to eq([conan_package3.conan_recipe, conan_package.conan_recipe])
+ end
+ end
+ end
end
end
diff --git a/spec/services/packages/conan/single_package_search_service_spec.rb b/spec/services/packages/conan/single_package_search_service_spec.rb
new file mode 100644
index 00000000000..1d95d1d4f64
--- /dev/null
+++ b/spec/services/packages/conan/single_package_search_service_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Conan::SinglePackageSearchService, feature_category: :package_registry do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+
+ let!(:conan_package) { create(:conan_package, project: project) }
+ let!(:conan_package2) { create(:conan_package, project: project) }
+
+ describe '#execute' do
+ context 'with a valid query and user with permissions' do
+ before do
+ allow_next_instance_of(described_class) do |service|
+ allow(service).to receive(:can_access_project_package?).and_return(true)
+ end
+ end
+
+ it 'returns the correct package' do
+ [conan_package, conan_package2].each do |package|
+ result = described_class.new(package.conan_recipe, user).execute
+
+ expect(result.status).to eq :success
+ expect(result[:results]).to match_array([package.conan_recipe])
+ end
+ end
+ end
+
+ context 'with a user without permissions' do
+ before do
+ allow_next_instance_of(described_class) do |service|
+ allow(service).to receive(:can_access_project_package?).and_return(false)
+ end
+ end
+
+ it 'returns an empty array' do
+ result = described_class.new(conan_package.conan_recipe, user).execute
+
+ expect(result.status).to eq :success
+ expect(result[:results]).to match_array([])
+ end
+ end
+ end
+end
diff --git a/spec/services/packages/create_dependency_service_spec.rb b/spec/services/packages/create_dependency_service_spec.rb
index f95e21cd045..06a7a13bdd9 100644
--- a/spec/services/packages/create_dependency_service_spec.rb
+++ b/spec/services/packages/create_dependency_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::CreateDependencyService do
+RSpec.describe Packages::CreateDependencyService, feature_category: :package_registry do
describe '#execute' do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:version) { '1.0.1' }
diff --git a/spec/services/packages/create_event_service_spec.rb b/spec/services/packages/create_event_service_spec.rb
index 58fa68b11fe..45c758ec866 100644
--- a/spec/services/packages/create_event_service_spec.rb
+++ b/spec/services/packages/create_event_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::CreateEventService do
+RSpec.describe Packages::CreateEventService, feature_category: :package_registry do
let(:scope) { 'generic' }
let(:event_name) { 'push_package' }
@@ -15,47 +15,6 @@ RSpec.describe Packages::CreateEventService do
subject { described_class.new(nil, user, params).execute }
describe '#execute' do
- shared_examples 'db package event creation' do |originator_type, expected_scope|
- before do
- allow(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
- end
-
- context 'with feature flag disable' do
- before do
- stub_feature_flags(collect_package_events: false)
- end
-
- it 'does not create an event' do
- expect { subject }.not_to change { Packages::Event.count }
- end
- end
-
- context 'with feature flag enabled' do
- before do
- stub_feature_flags(collect_package_events: true)
- end
-
- it 'creates the event' do
- expect { subject }.to change { Packages::Event.count }.by(1)
-
- expect(subject.originator_type).to eq(originator_type)
- expect(subject.originator).to eq(user&.id)
- expect(subject.event_scope).to eq(expected_scope)
- expect(subject.event_type).to eq(event_name)
- end
-
- context 'on a read-only instance' do
- before do
- allow(Gitlab::Database).to receive(:read_only?).and_return(true)
- end
-
- it 'does not create an event' do
- expect { subject }.not_to change { Packages::Event.count }
- end
- end
- end
- end
-
shared_examples 'redis package unique event creation' do |originator_type, expected_scope|
it 'tracks the event' do
expect(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(/package/, values: user.id)
@@ -75,7 +34,6 @@ RSpec.describe Packages::CreateEventService do
context 'with a user' do
let(:user) { create(:user) }
- it_behaves_like 'db package event creation', 'user', 'generic'
it_behaves_like 'redis package unique event creation', 'user', 'generic'
it_behaves_like 'redis package count event creation', 'user', 'generic'
end
@@ -83,7 +41,6 @@ RSpec.describe Packages::CreateEventService do
context 'with a deploy token' do
let(:user) { create(:deploy_token) }
- it_behaves_like 'db package event creation', 'deploy_token', 'generic'
it_behaves_like 'redis package unique event creation', 'deploy_token', 'generic'
it_behaves_like 'redis package count event creation', 'deploy_token', 'generic'
end
@@ -91,7 +48,6 @@ RSpec.describe Packages::CreateEventService do
context 'with no user' do
let(:user) { nil }
- it_behaves_like 'db package event creation', 'guest', 'generic'
it_behaves_like 'redis package count event creation', 'guest', 'generic'
end
@@ -101,14 +57,12 @@ RSpec.describe Packages::CreateEventService do
context 'as guest' do
let(:user) { nil }
- it_behaves_like 'db package event creation', 'guest', 'npm'
it_behaves_like 'redis package count event creation', 'guest', 'npm'
end
context 'with user' do
let(:user) { create(:user) }
- it_behaves_like 'db package event creation', 'user', 'npm'
it_behaves_like 'redis package unique event creation', 'user', 'npm'
it_behaves_like 'redis package count event creation', 'user', 'npm'
end
diff --git a/spec/services/packages/create_package_file_service_spec.rb b/spec/services/packages/create_package_file_service_spec.rb
index 2ff00ea8568..5b4ea3e1530 100644
--- a/spec/services/packages/create_package_file_service_spec.rb
+++ b/spec/services/packages/create_package_file_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::CreatePackageFileService do
+RSpec.describe Packages::CreatePackageFileService, feature_category: :package_registry do
let_it_be(:package) { create(:maven_package) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/packages/create_temporary_package_service_spec.rb b/spec/services/packages/create_temporary_package_service_spec.rb
index 4b8d37401d8..be8b5afc1e0 100644
--- a/spec/services/packages/create_temporary_package_service_spec.rb
+++ b/spec/services/packages/create_temporary_package_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::CreateTemporaryPackageService do
+RSpec.describe Packages::CreateTemporaryPackageService, feature_category: :package_registry do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:params) { {} }
diff --git a/spec/services/packages/debian/create_package_file_service_spec.rb b/spec/services/packages/debian/create_package_file_service_spec.rb
index 43928669eb1..b527bf8c1de 100644
--- a/spec/services/packages/debian/create_package_file_service_spec.rb
+++ b/spec/services/packages/debian/create_package_file_service_spec.rb
@@ -57,7 +57,7 @@ RSpec.describe Packages::Debian::CreatePackageFileService, feature_category: :pa
expect(package_file).to be_valid
expect(package_file.file.read).to start_with('Format: 1.8')
- expect(package_file.size).to eq(2143)
+ expect(package_file.size).to eq(2422)
expect(package_file.file_name).to eq(file_name)
expect(package_file.file_sha1).to eq('54321')
expect(package_file.file_sha256).to eq('543212345')
diff --git a/spec/services/packages/debian/extract_changes_metadata_service_spec.rb b/spec/services/packages/debian/extract_changes_metadata_service_spec.rb
index 4d6acac219b..a22c1fc7acc 100644
--- a/spec/services/packages/debian/extract_changes_metadata_service_spec.rb
+++ b/spec/services/packages/debian/extract_changes_metadata_service_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Packages::Debian::ExtractChangesMetadataService, feature_category
expect(subject[:file_type]).to eq(:changes)
expect(subject[:architecture]).to be_nil
expect(subject[:fields]).to include(expected_fields)
- expect(subject[:files].count).to eq(6)
+ expect(subject[:files].count).to eq(7)
end
end
diff --git a/spec/services/packages/debian/extract_metadata_service_spec.rb b/spec/services/packages/debian/extract_metadata_service_spec.rb
index 412f285152b..1983c49c6b7 100644
--- a/spec/services/packages/debian/extract_metadata_service_spec.rb
+++ b/spec/services/packages/debian/extract_metadata_service_spec.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+
require 'spec_helper'
RSpec.describe Packages::Debian::ExtractMetadataService, feature_category: :package_registry do
@@ -6,12 +7,13 @@ RSpec.describe Packages::Debian::ExtractMetadataService, feature_category: :pack
subject { service.execute }
- RSpec.shared_context 'Debian ExtractMetadata Service' do |trait|
+ RSpec.shared_context 'with Debian package file' do |trait|
let(:package_file) { create(:debian_package_file, trait) }
end
RSpec.shared_examples 'Test Debian ExtractMetadata Service' do |expected_file_type, expected_architecture, expected_fields|
- it "returns file_type #{expected_file_type.inspect}, architecture #{expected_architecture.inspect} and fields #{expected_fields.nil? ? '' : 'including '}#{expected_fields.inspect}", :aggregate_failures do
+ it "returns file_type #{expected_file_type.inspect}, architecture #{expected_architecture.inspect} and fields #{expected_fields.nil? ? '' : 'including '}#{expected_fields.inspect}",
+ :aggregate_failures do
expect(subject[:file_type]).to eq(expected_file_type)
expect(subject[:architecture]).to eq(expected_architecture)
@@ -25,29 +27,79 @@ RSpec.describe Packages::Debian::ExtractMetadataService, feature_category: :pack
using RSpec::Parameterized::TableSyntax
- where(:case_name, :trait, :expected_file_type, :expected_architecture, :expected_fields) do
- 'with invalid' | :invalid | :unknown | nil | nil
- 'with source' | :source | :source | nil | nil
- 'with dsc' | :dsc | :dsc | nil | { 'Binary' => 'sample-dev, libsample0, sample-udeb' }
- 'with deb' | :deb | :deb | 'amd64' | { 'Multi-Arch' => 'same' }
- 'with udeb' | :udeb | :udeb | 'amd64' | { 'Package' => 'sample-udeb' }
- 'with buildinfo' | :buildinfo | :buildinfo | nil | { 'Architecture' => 'amd64 source', 'Build-Architecture' => 'amd64' }
- 'with changes' | :changes | :changes | nil | { 'Architecture' => 'source amd64', 'Binary' => 'libsample0 sample-dev sample-udeb' }
+ context 'with valid file types' do
+ where(:case_name, :trait, :expected_file_type, :expected_architecture, :expected_fields) do
+ 'with source' | :source | :source | nil | nil
+ 'with dsc' | :dsc | :dsc | nil | { 'Binary' => 'sample-dev, libsample0, sample-udeb, sample-ddeb' }
+ 'with deb' | :deb | :deb | 'amd64' | { 'Multi-Arch' => 'same' }
+ 'with udeb' | :udeb | :udeb | 'amd64' | { 'Package' => 'sample-udeb' }
+ 'with ddeb' | :ddeb | :ddeb | 'amd64' | { 'Package' => 'sample-ddeb' }
+ 'with buildinfo' | :buildinfo | :buildinfo | nil | { 'Architecture' => 'amd64 source',
+ 'Build-Architecture' => 'amd64' }
+ 'with changes' | :changes | :changes | nil | { 'Architecture' => 'source amd64',
+ 'Binary' => 'libsample0 sample-dev sample-udeb' }
+ end
+
+ with_them do
+ include_context 'with Debian package file', params[:trait] do
+ it_behaves_like 'Test Debian ExtractMetadata Service',
+ params[:expected_file_type],
+ params[:expected_architecture],
+ params[:expected_fields]
+ end
+ end
end
- with_them do
- include_context 'Debian ExtractMetadata Service', params[:trait] do
- it_behaves_like 'Test Debian ExtractMetadata Service',
- params[:expected_file_type],
- params[:expected_architecture],
- params[:expected_fields]
+ context 'with valid source extensions' do
+ where(:ext) do
+ %i[gz bz2 lzma xz]
+ end
+
+ with_them do
+ let(:package_file) do
+ create(:debian_package_file, :source, file_name: "myfile.tar.#{ext}",
+ file_fixture: 'spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xz')
+ end
+
+ it_behaves_like 'Test Debian ExtractMetadata Service', :source
+ end
+ end
+
+ context 'with invalid source extensions' do
+ where(:ext) do
+ %i[gzip bzip2]
+ end
+
+ with_them do
+ let(:package_file) do
+ create(:debian_package_file, :source, file_name: "myfile.tar.#{ext}",
+ file_fixture: 'spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xz')
+ end
+
+ it 'raises an error' do
+ expect do
+ subject
+ end.to raise_error(described_class::ExtractionError,
+ "unsupported file extension for file #{package_file.file_name}")
+ end
+ end
+ end
+
+ context 'with invalid file name' do
+ let(:package_file) { create(:debian_package_file, :invalid) }
+
+ it 'raises an error' do
+ expect do
+ subject
+ end.to raise_error(described_class::ExtractionError,
+ "unsupported file extension for file #{package_file.file_name}")
end
end
context 'with invalid package file' do
let(:package_file) { create(:conan_package_file) }
- it 'raise error' do
+ it 'raises an error' do
expect { subject }.to raise_error(described_class::ExtractionError, 'invalid package file')
end
end
diff --git a/spec/services/packages/debian/find_or_create_package_service_spec.rb b/spec/services/packages/debian/find_or_create_package_service_spec.rb
index 36f96008582..c2ae3d56864 100644
--- a/spec/services/packages/debian/find_or_create_package_service_spec.rb
+++ b/spec/services/packages/debian/find_or_create_package_service_spec.rb
@@ -4,13 +4,17 @@ require 'spec_helper'
RSpec.describe Packages::Debian::FindOrCreatePackageService, feature_category: :package_registry do
let_it_be(:distribution) { create(:debian_project_distribution, :with_suite) }
+ let_it_be(:distribution2) { create(:debian_project_distribution, :with_suite) }
+
let_it_be(:project) { distribution.project }
let_it_be(:user) { create(:user) }
let(:service) { described_class.new(project, user, params) }
+ let(:params2) { params }
+ let(:service2) { described_class.new(project, user, params2) }
let(:package) { subject.payload[:package] }
- let(:package2) { service.execute.payload[:package] }
+ let(:package2) { service2.execute.payload[:package] }
shared_examples 'find or create Debian package' do
it 'returns the same object' do
@@ -55,11 +59,24 @@ RSpec.describe Packages::Debian::FindOrCreatePackageService, feature_category: :
it_behaves_like 'find or create Debian package'
end
+ context 'with existing package in another distribution' do
+ let(:params) { { name: 'foo', version: '1.0+debian', distribution_name: distribution.codename } }
+ let(:params2) { { name: 'foo', version: '1.0+debian', distribution_name: distribution2.codename } }
+
+ it 'raises ArgumentError' do
+ expect { subject }.to change { ::Packages::Package.count }.by(1)
+
+ expect { package2 }.to raise_error(ArgumentError, "Debian package #{package.name} #{package.version} exists " \
+ "in distribution #{distribution.codename}")
+ end
+ end
+
context 'with non-existing distribution' do
let(:params) { { name: 'foo', version: '1.0+debian', distribution_name: 'not-existing' } }
it 'raises ActiveRecord::RecordNotFound' do
- expect { package }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { package }.to raise_error(ActiveRecord::RecordNotFound,
+ /^Couldn't find Packages::Debian::ProjectDistribution/)
end
end
end
diff --git a/spec/services/packages/debian/generate_distribution_service_spec.rb b/spec/services/packages/debian/generate_distribution_service_spec.rb
index 6d179c791a3..27206b847e4 100644
--- a/spec/services/packages/debian/generate_distribution_service_spec.rb
+++ b/spec/services/packages/debian/generate_distribution_service_spec.rb
@@ -3,20 +3,32 @@
require 'spec_helper'
RSpec.describe Packages::Debian::GenerateDistributionService, feature_category: :package_registry do
- describe '#execute' do
- subject { described_class.new(distribution).execute }
+ include_context 'with published Debian package'
- let(:subject2) { described_class.new(distribution).execute }
- let(:subject3) { described_class.new(distribution).execute }
+ let(:service) { described_class.new(distribution) }
- include_context 'with published Debian package'
+ [:project, :group].each do |container_type|
+ context "for #{container_type}" do
+ include_context 'with Debian distribution', container_type
- [:project, :group].each do |container_type|
- context "for #{container_type}" do
- include_context 'with Debian distribution', container_type
+ describe '#execute' do
+ subject { service.execute }
+
+ let(:subject2) { described_class.new(distribution).execute }
+ let(:subject3) { described_class.new(distribution).execute }
it_behaves_like 'Generate Debian Distribution and component files'
end
+
+ describe '#lease_key' do
+ subject { service.send(:lease_key) }
+
+ let(:prefix) { "packages:debian:generate_distribution_service:" }
+
+ it 'returns an unique key' do
+ is_expected.to eq "#{prefix}#{container_type}_distribution:#{distribution.id}"
+ end
+ end
end
end
end
diff --git a/spec/services/packages/debian/parse_debian822_service_spec.rb b/spec/services/packages/debian/parse_debian822_service_spec.rb
index 35b7ead9209..624d4d95e5a 100644
--- a/spec/services/packages/debian/parse_debian822_service_spec.rb
+++ b/spec/services/packages/debian/parse_debian822_service_spec.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+
require 'spec_helper'
RSpec.describe Packages::Debian::ParseDebian822Service, feature_category: :package_registry do
@@ -76,14 +77,19 @@ RSpec.describe Packages::Debian::ParseDebian822Service, feature_category: :packa
'Multi-Arch' => 'same',
'Depends' => '${shlibs:Depends}, ${misc:Depends}',
'Description' => "Some mostly empty lib\nUsed in GitLab tests.\n\nTesting another paragraph."
- },
+ },
'Package: sample-udeb' => {
- 'Package' => 'sample-udeb',
- 'Package-Type' => 'udeb',
- 'Architecture' => 'any',
- 'Depends' => 'installed-base',
- 'Description' => 'Some mostly empty udeb'
- }
+ 'Package' => 'sample-udeb',
+ 'Package-Type' => 'udeb',
+ 'Architecture' => 'any',
+ 'Depends' => 'installed-base',
+ 'Description' => 'Some mostly empty udeb'
+ },
+ 'Package: sample-ddeb' => {
+ 'Package' => 'sample-ddeb',
+ 'Architecture' => 'any',
+ 'Description' => 'Some fake Ubuntu ddeb'
+ }
}
expect(subject.execute.to_s).to eq(expected.to_s)
diff --git a/spec/services/packages/debian/process_changes_service_spec.rb b/spec/services/packages/debian/process_changes_service_spec.rb
index e3ed744377e..dbfcc359f9c 100644
--- a/spec/services/packages/debian/process_changes_service_spec.rb
+++ b/spec/services/packages/debian/process_changes_service_spec.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+
require 'spec_helper'
RSpec.describe Packages::Debian::ProcessChangesService, feature_category: :package_registry do
@@ -18,7 +19,7 @@ RSpec.describe Packages::Debian::ProcessChangesService, feature_category: :packa
expect { subject.execute }
.to change { Packages::Package.count }.from(1).to(2)
.and not_change { Packages::PackageFile.count }
- .and change { incoming.package_files.count }.from(7).to(0)
+ .and change { incoming.package_files.count }.from(8).to(0)
.and change { package_file.debian_file_metadatum&.reload&.file_type }.from('unknown').to('changes')
created_package = Packages::Package.last
@@ -55,7 +56,7 @@ RSpec.describe Packages::Debian::ProcessChangesService, feature_category: :packa
it_behaves_like 'raises error with missing field', 'Distribution'
end
- context 'with existing package' do
+ context 'with existing package in the same distribution' do
let_it_be_with_reload(:existing_package) do
create(:debian_package, name: 'sample', version: '1.2.3~alpha2', project: distribution.project, published_in: distribution)
end
@@ -64,10 +65,37 @@ RSpec.describe Packages::Debian::ProcessChangesService, feature_category: :packa
expect { subject.execute }
.to not_change { Packages::Package.count }
.and not_change { Packages::PackageFile.count }
- .and change(package_file, :package).to(existing_package)
+ .and change { package_file.package }.to(existing_package)
+ end
+
+ context 'and marked as pending_destruction' do
+ it 'does not re-use the existing package' do
+ existing_package.pending_destruction!
+
+ expect { subject.execute }
+ .to change { Packages::Package.count }.by(1)
+ .and not_change { Packages::PackageFile.count }
+ end
+ end
+ end
+
+ context 'with existing package in another distribution' do
+ let_it_be_with_reload(:existing_package) do
+ create(:debian_package, name: 'sample', version: '1.2.3~alpha2', project: distribution.project)
+ end
+
+ it 'raise ExtractionError' do
+ expect(::Packages::Debian::GenerateDistributionWorker).not_to receive(:perform_async)
+ expect { subject.execute }
+ .to not_change { Packages::Package.count }
+ .and not_change { Packages::PackageFile.count }
+ .and not_change { incoming.package_files.count }
+ .and raise_error(ArgumentError,
+ "Debian package #{existing_package.name} #{existing_package.version} exists " \
+ "in distribution #{existing_package.debian_distribution.codename}")
end
- context 'marked as pending_destruction' do
+ context 'and marked as pending_destruction' do
it 'does not re-use the existing package' do
existing_package.pending_destruction!
diff --git a/spec/services/packages/debian/process_package_file_service_spec.rb b/spec/services/packages/debian/process_package_file_service_spec.rb
index caf29cfc4fa..7782b5fc1a6 100644
--- a/spec/services/packages/debian/process_package_file_service_spec.rb
+++ b/spec/services/packages/debian/process_package_file_service_spec.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+
require 'spec_helper'
RSpec.describe Packages::Debian::ProcessPackageFileService, feature_category: :package_registry do
@@ -19,14 +20,14 @@ RSpec.describe Packages::Debian::ProcessPackageFileService, feature_category: :p
expect { subject.execute }
.to not_change(Packages::Package, :count)
.and not_change(Packages::PackageFile, :count)
- .and change(Packages::Debian::Publication, :count).by(1)
+ .and change { Packages::Debian::Publication.count }.by(1)
.and not_change(package.package_files, :count)
.and change { package.reload.name }.to('sample')
.and change { package.reload.version }.to('1.2.3~alpha2')
.and change { package.reload.status }.from('processing').to('default')
.and change { package.reload.debian_publication }.from(nil)
- .and change(debian_file_metadatum, :file_type).from('unknown').to(expected_file_type)
- .and change(debian_file_metadatum, :component).from(nil).to(component_name)
+ .and change { debian_file_metadatum.file_type }.from('unknown').to(expected_file_type)
+ .and change { debian_file_metadatum.component }.from(nil).to(component_name)
end
end
@@ -35,6 +36,7 @@ RSpec.describe Packages::Debian::ProcessPackageFileService, feature_category: :p
where(:case_name, :expected_file_type, :file_name, :component_name) do
'with a deb' | 'deb' | 'libsample0_1.2.3~alpha2_amd64.deb' | 'main'
'with an udeb' | 'udeb' | 'sample-udeb_1.2.3~alpha2_amd64.udeb' | 'contrib'
+ 'with an ddeb' | 'ddeb' | 'sample-ddeb_1.2.3~alpha2_amd64.ddeb' | 'main'
end
with_them do
@@ -66,21 +68,42 @@ RSpec.describe Packages::Debian::ProcessPackageFileService, feature_category: :p
expect(::Packages::Debian::GenerateDistributionWorker)
.to receive(:perform_async).with(:project, distribution.id)
expect { subject.execute }
- .to change(Packages::Package, :count).from(2).to(1)
- .and change(Packages::PackageFile, :count).from(14).to(8)
+ .to change { Packages::Package.count }.from(2).to(1)
+ .and change { Packages::PackageFile.count }.from(16).to(9)
.and not_change(Packages::Debian::Publication, :count)
- .and change(package.package_files, :count).from(7).to(0)
- .and change(package_file, :package).from(package).to(matching_package)
+ .and change { package.package_files.count }.from(8).to(0)
+ .and change { package_file.package }.from(package).to(matching_package)
.and not_change(matching_package, :name)
.and not_change(matching_package, :version)
- .and change(debian_file_metadatum, :file_type).from('unknown').to(expected_file_type)
- .and change(debian_file_metadatum, :component).from(nil).to(component_name)
+ .and change { debian_file_metadatum.file_type }.from('unknown').to(expected_file_type)
+ .and change { debian_file_metadatum.component }.from(nil).to(component_name)
expect { package.reload }
.to raise_error(ActiveRecord::RecordNotFound)
end
end
+ context 'when there is a matching published package in another distribution' do
+ let!(:matching_package) do
+ create(
+ :debian_package,
+ project: distribution.project,
+ name: 'sample',
+ version: '1.2.3~alpha2'
+ )
+ end
+
+ it 'raise ArgumentError', :aggregate_failures do
+ expect(::Packages::Debian::GenerateDistributionWorker).not_to receive(:perform_async)
+ expect { subject.execute }
+ .to not_change(Packages::Package, :count)
+ .and not_change(Packages::PackageFile, :count)
+ .and not_change(package.package_files, :count)
+ .and raise_error(ArgumentError, "Debian package sample 1.2.3~alpha2 exists " \
+ "in distribution #{matching_package.debian_distribution.codename}")
+ end
+ end
+
context 'when there is a matching published package pending destruction' do
let!(:matching_package) do
create(
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 9d6784b7721..06a78c7820f 100644
--- a/spec/services/packages/generic/create_package_file_service_spec.rb
+++ b/spec/services/packages/generic/create_package_file_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Generic::CreatePackageFileService do
+RSpec.describe Packages::Generic::CreatePackageFileService, feature_category: :package_registry do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:pipeline) { create(:ci_pipeline, user: user) }
@@ -81,9 +81,7 @@ RSpec.describe Packages::Generic::CreatePackageFileService do
it_behaves_like 'assigns build to package file'
context 'with existing package' do
- before do
- create(:package_file, package: package, file_name: file_name)
- end
+ let_it_be(:duplicate_file) { create(:package_file, package: package, file_name: file_name) }
it { expect { execute_service }.to change { project.package_files.count }.by(1) }
@@ -97,6 +95,16 @@ RSpec.describe Packages::Generic::CreatePackageFileService do
.and change { project.package_files.count }.by(0)
end
+ context 'when the file is pending destruction' do
+ before do
+ duplicate_file.update_column(:status, :pending_destruction)
+ end
+
+ it 'allows creating the file' do
+ expect { execute_service }.to change { project.package_files.count }.by(1)
+ end
+ end
+
context 'when the package name matches the exception regex' do
before do
package.project.namespace.package_settings.update!(generic_duplicate_exception_regex: '.*')
diff --git a/spec/services/packages/generic/find_or_create_package_service_spec.rb b/spec/services/packages/generic/find_or_create_package_service_spec.rb
index 10ec917bc99..07054fe3651 100644
--- a/spec/services/packages/generic/find_or_create_package_service_spec.rb
+++ b/spec/services/packages/generic/find_or_create_package_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Generic::FindOrCreatePackageService do
+RSpec.describe Packages::Generic::FindOrCreatePackageService, feature_category: :package_registry do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:ci_build) { create(:ci_build, :running, user: user) }
diff --git a/spec/services/packages/go/create_package_service_spec.rb b/spec/services/packages/go/create_package_service_spec.rb
index 4ca1119fbaa..f552af81077 100644
--- a/spec/services/packages/go/create_package_service_spec.rb
+++ b/spec/services/packages/go/create_package_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Go::CreatePackageService do
+RSpec.describe Packages::Go::CreatePackageService, feature_category: :package_registry do
let_it_be(:project) { create :project_empty_repo, path: 'my-go-lib' }
let_it_be(:mod) { create :go_module, project: project }
diff --git a/spec/services/packages/go/sync_packages_service_spec.rb b/spec/services/packages/go/sync_packages_service_spec.rb
index 565b0f252ce..2881b6fdac9 100644
--- a/spec/services/packages/go/sync_packages_service_spec.rb
+++ b/spec/services/packages/go/sync_packages_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Go::SyncPackagesService do
+RSpec.describe Packages::Go::SyncPackagesService, feature_category: :package_registry do
include_context 'basic Go module'
let(:params) { { info: true, mod: true, zip: true } }
diff --git a/spec/services/packages/helm/extract_file_metadata_service_spec.rb b/spec/services/packages/helm/extract_file_metadata_service_spec.rb
index f4c61c12344..861d326d12a 100644
--- a/spec/services/packages/helm/extract_file_metadata_service_spec.rb
+++ b/spec/services/packages/helm/extract_file_metadata_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Helm::ExtractFileMetadataService do
+RSpec.describe Packages::Helm::ExtractFileMetadataService, feature_category: :package_registry do
let_it_be(:package_file) { create(:helm_package_file) }
let(:service) { described_class.new(package_file) }
diff --git a/spec/services/packages/helm/process_file_service_spec.rb b/spec/services/packages/helm/process_file_service_spec.rb
index 1be0153a4a5..a1f53e8756c 100644
--- a/spec/services/packages/helm/process_file_service_spec.rb
+++ b/spec/services/packages/helm/process_file_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Helm::ProcessFileService do
+RSpec.describe Packages::Helm::ProcessFileService, feature_category: :package_registry do
let(:package) { create(:helm_package, without_package_files: true, status: 'processing') }
let!(:package_file) { create(:helm_package_file, without_loaded_metadatum: true, package: package) }
let(:channel) { 'stable' }
diff --git a/spec/services/packages/mark_package_files_for_destruction_service_spec.rb b/spec/services/packages/mark_package_files_for_destruction_service_spec.rb
index 66534338003..a00a0b79854 100644
--- a/spec/services/packages/mark_package_files_for_destruction_service_spec.rb
+++ b/spec/services/packages/mark_package_files_for_destruction_service_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Packages::MarkPackageFilesForDestructionService, :aggregate_failures do
+RSpec.describe Packages::MarkPackageFilesForDestructionService, :aggregate_failures,
+ feature_category: :package_registry do
let(:service) { described_class.new(package_files) }
describe '#execute', :aggregate_failures do
diff --git a/spec/services/packages/mark_package_for_destruction_service_spec.rb b/spec/services/packages/mark_package_for_destruction_service_spec.rb
index 125ec53ad61..d65e62b84a6 100644
--- a/spec/services/packages/mark_package_for_destruction_service_spec.rb
+++ b/spec/services/packages/mark_package_for_destruction_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::MarkPackageForDestructionService do
+RSpec.describe Packages::MarkPackageForDestructionService, feature_category: :package_registry do
let_it_be(:user) { create(:user) }
let_it_be_with_reload(:package) { create(:npm_package) }
@@ -36,6 +36,12 @@ RSpec.describe Packages::MarkPackageForDestructionService do
end
it 'returns an error ServiceResponse' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ instance_of(StandardError),
+ project_id: package.project_id,
+ package_id: package.id
+ )
+
response = service.execute
expect(package).not_to receive(:sync_maven_metadata)
diff --git a/spec/services/packages/mark_packages_for_destruction_service_spec.rb b/spec/services/packages/mark_packages_for_destruction_service_spec.rb
index 5c043b89de8..22278f9927d 100644
--- a/spec/services/packages/mark_packages_for_destruction_service_spec.rb
+++ b/spec/services/packages/mark_packages_for_destruction_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::MarkPackagesForDestructionService, :sidekiq_inline do
+RSpec.describe Packages::MarkPackagesForDestructionService, :sidekiq_inline, feature_category: :package_registry do
let_it_be(:project) { create(:project) }
let_it_be_with_reload(:packages) { create_list(:npm_package, 3, project: project) }
@@ -76,6 +76,11 @@ RSpec.describe Packages::MarkPackagesForDestructionService, :sidekiq_inline do
it 'returns an error ServiceResponse' do
expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new)
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ instance_of(StandardError),
+ package_ids: package_ids
+ )
+
expect { subject }.to not_change { ::Packages::Package.pending_destruction.count }
.and not_change { ::Packages::PackageFile.pending_destruction.count }
diff --git a/spec/services/packages/maven/create_package_service_spec.rb b/spec/services/packages/maven/create_package_service_spec.rb
index 11bf00c1399..2c528c3591e 100644
--- a/spec/services/packages/maven/create_package_service_spec.rb
+++ b/spec/services/packages/maven/create_package_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Maven::CreatePackageService do
+RSpec.describe Packages::Maven::CreatePackageService, feature_category: :package_registry do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:app_name) { 'my-app' }
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 cca074e2fa6..8b84d2541eb 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
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Maven::FindOrCreatePackageService do
+RSpec.describe Packages::Maven::FindOrCreatePackageService, feature_category: :package_registry do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
@@ -44,6 +44,15 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
end
end
+ shared_examples 'returning an error' do |with_message: ''|
+ it { expect { subject }.not_to change { project.package_files.count } }
+
+ it 'returns an error', :aggregate_failures do
+ expect(subject.payload).to be_empty
+ expect(subject.errors).to include(with_message)
+ end
+ end
+
context 'path with version' do
# Note that "path with version" and "file type maven metadata xml" only exists for snapshot versions
# In other words, we will never have an metadata xml upload on a path with version for a non snapshot version
@@ -128,11 +137,19 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
let!(:existing_package) { create(:maven_package, name: path, version: version, project: project) }
- it { expect { subject }.not_to change { project.package_files.count } }
+ let(:existing_file_name) { file_name }
+ let(:jar_file) { existing_package.package_files.with_file_name_like('%.jar').first }
- it 'returns an error', :aggregate_failures do
- expect(subject.payload).to be_empty
- expect(subject.errors).to include('Duplicate package is not allowed')
+ before do
+ jar_file.update_column(:file_name, existing_file_name)
+ end
+
+ it_behaves_like 'returning an error', with_message: 'Duplicate package is not allowed'
+
+ context 'for a SNAPSHOT version' do
+ let(:version) { '1.0.0-SNAPSHOT' }
+
+ it_behaves_like 'returning an error', with_message: 'Duplicate package is not allowed'
end
context 'when uploading to the versionless package which contains metadata about all versions' do
@@ -144,8 +161,7 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
context 'when uploading different non-duplicate files to the same package' do
before do
- package_file = existing_package.package_files.find_by(file_name: 'my-app-1.0-20180724.124855-1.jar')
- package_file.destroy!
+ jar_file.destroy!
end
it_behaves_like 'reuse existing package'
@@ -166,6 +182,27 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
it_behaves_like 'reuse existing package'
end
+
+ context 'when uploading a similar package file name with a classifier' do
+ let(:existing_file_name) { 'test.jar' }
+ let(:file_name) { 'test-javadoc.jar' }
+
+ it_behaves_like 'reuse existing package'
+
+ context 'for a SNAPSHOT version' do
+ let(:version) { '1.0.0-SNAPSHOT' }
+ let(:existing_file_name) { 'test-1.0-20230303.163304-1.jar' }
+ let(:file_name) { 'test-1.0-20230303.163304-1-javadoc.jar' }
+
+ it_behaves_like 'reuse existing package'
+ end
+ end
+ end
+
+ context 'with a very large file name' do
+ let(:params) { super().merge(file_name: 'a' * (described_class::MAX_FILE_NAME_LENGTH + 1)) }
+
+ it_behaves_like 'returning an error', with_message: 'File name is too long'
end
end
end
diff --git a/spec/services/packages/maven/metadata/append_package_file_service_spec.rb b/spec/services/packages/maven/metadata/append_package_file_service_spec.rb
index f3a90d31158..f65029f7b64 100644
--- a/spec/services/packages/maven/metadata/append_package_file_service_spec.rb
+++ b/spec/services/packages/maven/metadata/append_package_file_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Packages::Maven::Metadata::AppendPackageFileService do
+RSpec.describe ::Packages::Maven::Metadata::AppendPackageFileService, feature_category: :package_registry do
let_it_be(:package) { create(:maven_package, version: nil) }
let(:service) { described_class.new(package: package, metadata_content: content) }
diff --git a/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb b/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb
index 6fc1087940d..d0ef037b2d9 100644
--- a/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb
+++ b/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Packages::Maven::Metadata::CreatePluginsXmlService do
+RSpec.describe ::Packages::Maven::Metadata::CreatePluginsXmlService, feature_category: :package_registry do
let_it_be(:group_id) { 'my/test' }
let_it_be(:package) { create(:maven_package, name: group_id, version: nil) }
diff --git a/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb b/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb
index 70c2bbad87a..6ae84b5df4e 100644
--- a/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb
+++ b/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Packages::Maven::Metadata::CreateVersionsXmlService do
+RSpec.describe ::Packages::Maven::Metadata::CreateVersionsXmlService, feature_category: :package_registry do
let_it_be(:package) { create(:maven_package, version: nil) }
let(:versions_in_database) { %w[1.3 2.0-SNAPSHOT 1.6 1.4 1.5-SNAPSHOT] }
diff --git a/spec/services/packages/maven/metadata/sync_service_spec.rb b/spec/services/packages/maven/metadata/sync_service_spec.rb
index 9a704d749b3..eaed54d959b 100644
--- a/spec/services/packages/maven/metadata/sync_service_spec.rb
+++ b/spec/services/packages/maven/metadata/sync_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Packages::Maven::Metadata::SyncService do
+RSpec.describe ::Packages::Maven::Metadata::SyncService, feature_category: :package_registry do
using RSpec::Parameterized::TableSyntax
let_it_be(:project) { create(:project) }
diff --git a/spec/services/packages/npm/create_metadata_cache_service_spec.rb b/spec/services/packages/npm/create_metadata_cache_service_spec.rb
new file mode 100644
index 00000000000..75f822f0ddb
--- /dev/null
+++ b/spec/services/packages/npm/create_metadata_cache_service_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Npm::CreateMetadataCacheService, :clean_gitlab_redis_shared_state, feature_category: :package_registry do
+ include ExclusiveLeaseHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:package_name) { "@#{project.root_namespace.path}/npm-test" }
+ let_it_be(:package) { create(:npm_package, version: '1.0.0', project: project, name: package_name) }
+
+ let(:packages) { project.packages }
+ let(:lease_key) { "packages:npm:create_metadata_cache_service:metadata_caches:#{project.id}_#{package_name}" }
+ let(:service) { described_class.new(project, package_name, packages) }
+
+ describe '#execute' do
+ let(:npm_metadata_cache) { Packages::Npm::MetadataCache.last }
+
+ subject { service.execute }
+
+ it 'creates a new metadata cache', :aggregate_failures do
+ expect { subject }.to change { Packages::Npm::MetadataCache.count }.by(1)
+
+ metadata = Gitlab::Json.parse(npm_metadata_cache.file.read)
+
+ expect(npm_metadata_cache.package_name).to eq(package_name)
+ expect(npm_metadata_cache.project_id).to eq(project.id)
+ expect(npm_metadata_cache.size).to eq(metadata.to_json.bytesize)
+ expect(metadata['name']).to eq(package_name)
+ expect(metadata['versions'].keys).to contain_exactly('1.0.0')
+ end
+
+ context 'with existing metadata cache' do
+ let_it_be(:npm_metadata_cache) { create(:npm_metadata_cache, package_name: package_name, project_id: project.id) }
+ let_it_be(:metadata) { Gitlab::Json.parse(npm_metadata_cache.file.read) }
+ let_it_be(:metadata_size) { npm_metadata_cache.size }
+ let_it_be(:tag_name) { 'new-tag' }
+ let_it_be(:tag) { create(:packages_tag, package: package, name: tag_name) }
+
+ it 'does not create a new metadata cache' do
+ expect { subject }.to change { Packages::Npm::MetadataCache.count }.by(0)
+ end
+
+ it 'updates the metadata cache', :aggregate_failures do
+ subject
+
+ new_metadata = Gitlab::Json.parse(npm_metadata_cache.file.read)
+
+ expect(new_metadata).not_to eq(metadata)
+ expect(new_metadata['dist_tags'].keys).to include(tag_name)
+ expect(npm_metadata_cache.reload.size).not_to eq(metadata_size)
+ end
+ end
+
+ it 'obtains a lease to create a new metadata cache' do
+ expect_to_obtain_exclusive_lease(lease_key, timeout: described_class::DEFAULT_LEASE_TIMEOUT)
+
+ subject
+ end
+
+ context 'when the lease is already taken' do
+ before do
+ stub_exclusive_lease_taken(lease_key, timeout: described_class::DEFAULT_LEASE_TIMEOUT)
+ end
+
+ it 'does not create a new metadata cache' do
+ expect { subject }.to change { Packages::Npm::MetadataCache.count }.by(0)
+ end
+
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+ end
+
+ describe '#lease_key' do
+ subject { service.send(:lease_key) }
+
+ it 'returns an unique key' do
+ is_expected.to eq lease_key
+ end
+ end
+end
diff --git a/spec/services/packages/npm/create_package_service_spec.rb b/spec/services/packages/npm/create_package_service_spec.rb
index ef8cdf2e8ab..a12d86412d8 100644
--- a/spec/services/packages/npm/create_package_service_spec.rb
+++ b/spec/services/packages/npm/create_package_service_spec.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Npm::CreatePackageService do
+RSpec.describe Packages::Npm::CreatePackageService, feature_category: :package_registry do
+ include ExclusiveLeaseHelpers
+
let(:namespace) { create(:namespace) }
let(:project) { create(:project, namespace: namespace) }
let(:user) { create(:user) }
@@ -14,9 +16,11 @@ RSpec.describe Packages::Npm::CreatePackageService do
end
let(:package_name) { "@#{namespace.path}/my-app" }
- let(:version_data) { params.dig('versions', '1.0.1') }
+ let(:version_data) { params.dig('versions', version) }
+ let(:lease_key) { "packages:npm:create_package_service:packages:#{project.id}_#{package_name}_#{version}" }
+ let(:service) { described_class.new(project, user, params) }
- subject { described_class.new(project, user, params).execute }
+ subject { service.execute }
shared_examples 'valid package' do
it 'creates a package' do
@@ -57,17 +61,90 @@ RSpec.describe Packages::Npm::CreatePackageService do
end
end
- context 'with a too large metadata structure' do
- before do
- params[:versions][version][:test] = 'test' * 10000
+ context 'when the npm metadatum creation results in a size error' do
+ shared_examples 'a package json structure size too large error' do
+ it 'does not create the package' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ instance_of(ActiveRecord::RecordInvalid),
+ field_sizes: expected_field_sizes
+ )
+
+ expect { subject }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Package json structure is too large')
+ .and not_change { Packages::Package.count }
+ .and not_change { Packages::Package.npm.count }
+ .and not_change { Packages::Tag.count }
+ .and not_change { Packages::Npm::Metadatum.count }
+ end
+ end
+
+ context 'when some of the field sizes are above the error tracking size' do
+ let(:package_json) do
+ params[:versions][version].except(*::Packages::Npm::CreatePackageService::PACKAGE_JSON_NOT_ALLOWED_FIELDS)
+ end
+
+ # Only the fields that exceed the field size limit should be passed to error tracking
+ let(:expected_field_sizes) do
+ {
+ 'test' => ('test' * 10000).size,
+ 'field2' => ('a' * (::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING + 1)).size
+ }
+ end
+
+ before do
+ params[:versions][version][:test] = 'test' * 10000
+ params[:versions][version][:field1] =
+ 'a' * (::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING - 1)
+ params[:versions][version][:field2] =
+ 'a' * (::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING + 1)
+ end
+
+ it_behaves_like 'a package json structure size too large error'
+ end
+
+ context 'when all of the field sizes are below the error tracking size' do
+ let(:package_json) do
+ params[:versions][version].except(*::Packages::Npm::CreatePackageService::PACKAGE_JSON_NOT_ALLOWED_FIELDS)
+ end
+
+ let(:expected_size) { ('a' * (::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING - 1)).size }
+ # Only the five largest fields should be passed to error tracking
+ let(:expected_field_sizes) do
+ {
+ 'field1' => expected_size,
+ 'field2' => expected_size,
+ 'field3' => expected_size,
+ 'field4' => expected_size,
+ 'field5' => expected_size
+ }
+ end
+
+ before do
+ 5.times do |i|
+ params[:versions][version]["field#{i + 1}"] =
+ 'a' * (::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING - 1)
+ end
+ end
+
+ it_behaves_like 'a package json structure size too large error'
end
+ end
+
+ context 'when the npm metadatum creation results in a different error' do
+ it 'does not track the error' do
+ error_message = 'boom'
+ invalid_npm_metadatum_error = ActiveRecord::RecordInvalid.new(
+ build(:npm_metadatum).tap do |metadatum|
+ metadatum.errors.add(:base, error_message)
+ end
+ )
+
+ allow_next_instance_of(::Packages::Package) do |package|
+ allow(package).to receive(:create_npm_metadatum!).and_raise(invalid_npm_metadatum_error)
+ end
- it 'does not create the package' do
- expect { subject }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Package json structure is too large')
- .and not_change { Packages::Package.count }
- .and not_change { Packages::Package.npm.count }
- .and not_change { Packages::Tag.count }
- .and not_change { Packages::Npm::Metadatum.count }
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
+
+ expect { subject }.to raise_error(ActiveRecord::RecordInvalid, /#{error_message}/)
end
end
@@ -216,5 +293,82 @@ RSpec.describe Packages::Npm::CreatePackageService do
it { expect { subject }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Version is invalid') }
end
end
+
+ context 'with empty attachment data' do
+ let(:params) { super().merge({ _attachments: { "#{package_name}-#{version}.tgz" => { data: '' } } }) }
+
+ it { expect(subject[:http_status]).to eq 400 }
+ it { expect(subject[:message]).to eq 'Attachment data is empty.' }
+ end
+
+ it 'obtains a lease to create a new package' do
+ expect_to_obtain_exclusive_lease(lease_key, timeout: described_class::DEFAULT_LEASE_TIMEOUT)
+
+ subject
+ end
+
+ context 'when the lease is already taken' do
+ before do
+ stub_exclusive_lease_taken(lease_key, timeout: described_class::DEFAULT_LEASE_TIMEOUT)
+ end
+
+ it { expect(subject[:http_status]).to eq 400 }
+ it { expect(subject[:message]).to eq 'Could not obtain package lease.' }
+ end
+
+ context 'when many of the same packages are created at the same time', :delete do
+ it 'only creates one package' do
+ expect { create_packages(project, user, params) }.to change { Packages::Package.count }.by(1)
+ end
+ end
+
+ context 'when many packages with different versions are created at the same time', :delete do
+ it 'creates all packages' do
+ expect { create_packages_with_versions(project, user, params) }.to change { Packages::Package.count }.by(5)
+ end
+ end
+
+ def create_packages(project, user, params)
+ with_threads do
+ described_class.new(project, user, params).execute
+ end
+ end
+
+ def create_packages_with_versions(project, user, params)
+ with_threads do |i|
+ # Modify the package's version
+ modified_params = Gitlab::Json.parse(params.to_json
+ .gsub(version, "1.0.#{i}")).with_indifferent_access
+
+ described_class.new(project, user, modified_params).execute
+ end
+ end
+
+ def with_threads(count: 5, &block)
+ return unless block
+
+ # create a race condition - structure from https://blog.arkency.com/2015/09/testing-race-conditions/
+ wait_for_it = true
+
+ threads = Array.new(count) do |i|
+ Thread.new do
+ # A loop to make threads busy until we `join` them
+ true while wait_for_it
+
+ yield(i)
+ end
+ end
+
+ wait_for_it = false
+ threads.each(&:join)
+ end
+ end
+
+ describe '#lease_key' do
+ subject { service.send(:lease_key) }
+
+ it 'returns an unique key' do
+ is_expected.to eq lease_key
+ end
end
end
diff --git a/spec/services/packages/npm/create_tag_service_spec.rb b/spec/services/packages/npm/create_tag_service_spec.rb
index a4b07bf97cc..682effc9f4f 100644
--- a/spec/services/packages/npm/create_tag_service_spec.rb
+++ b/spec/services/packages/npm/create_tag_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Npm::CreateTagService do
+RSpec.describe Packages::Npm::CreateTagService, feature_category: :package_registry do
let(:package) { create(:npm_package) }
let(:tag_name) { 'test-tag' }
diff --git a/spec/services/packages/npm/deprecate_package_service_spec.rb b/spec/services/packages/npm/deprecate_package_service_spec.rb
new file mode 100644
index 00000000000..a3686e3a8b5
--- /dev/null
+++ b/spec/services/packages/npm/deprecate_package_service_spec.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Npm::DeprecatePackageService, feature_category: :package_registry do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, namespace: namespace) }
+
+ let_it_be(:package_name) { "@#{namespace.path}/my-app" }
+ let_it_be_with_reload(:package_1) do
+ create(:npm_package, project: project, name: package_name, version: '1.0.1').tap do |package|
+ create(:npm_metadatum, package: package)
+ end
+ end
+
+ let_it_be(:package_2) do
+ create(:npm_package, project: project, name: package_name, version: '1.0.2').tap do |package|
+ create(:npm_metadatum, package: package)
+ end
+ end
+
+ let(:service) { described_class.new(project, params) }
+
+ subject(:execute) { service.execute }
+
+ describe '#execute' do
+ context 'when passing deprecatation message' do
+ let(:params) do
+ {
+ 'package_name' => package_name,
+ 'versions' => {
+ '1.0.1' => {
+ 'name' => package_name,
+ 'deprecated' => 'This version is deprecated'
+ },
+ '1.0.2' => {
+ 'name' => package_name,
+ 'deprecated' => 'This version is deprecated'
+ }
+ }
+ }
+ end
+
+ before do
+ package_json = package_2.npm_metadatum.package_json
+ package_2.npm_metadatum.update!(package_json: package_json.merge('deprecated' => 'old deprecation message'))
+ end
+
+ it 'adds or updates the deprecated field' do
+ expect { execute }
+ .to change { package_1.reload.npm_metadatum.package_json['deprecated'] }.to('This version is deprecated')
+ .and change { package_2.reload.npm_metadatum.package_json['deprecated'] }
+ .from('old deprecation message').to('This version is deprecated')
+ end
+
+ it 'executes 5 queries' do
+ queries = ActiveRecord::QueryRecorder.new do
+ execute
+ end
+
+ # 1. each_batch lower bound
+ # 2. each_batch upper bound
+ # 3. SELECT packages_packages.id, packages_packages.version FROM packages_packages
+ # 4. SELECT packages_npm_metadata.* FROM packages_npm_metadata
+ # 5. UPDATE packages_npm_metadata SET package_json =
+ expect(queries.count).to eq(5)
+ end
+ end
+
+ context 'when passing deprecated as empty string' do
+ let(:params) do
+ {
+ 'package_name' => package_name,
+ 'versions' => {
+ '1.0.1' => {
+ 'name' => package_name,
+ 'deprecated' => ''
+ }
+ }
+ }
+ end
+
+ before do
+ package_json = package_1.npm_metadatum.package_json
+ package_1.npm_metadatum.update!(package_json: package_json.merge('deprecated' => 'This version is deprecated'))
+ end
+
+ it 'removes the deprecation warning' do
+ expect { execute }
+ .to change { package_1.reload.npm_metadatum.package_json['deprecated'] }
+ .from('This version is deprecated').to(nil)
+ end
+ end
+
+ context 'when passing async: true to execute' do
+ let(:params) do
+ {
+ package_name: package_name,
+ versions: {
+ '1.0.1': {
+ deprecated: 'This version is deprecated'
+ }
+ }
+ }
+ end
+
+ it 'calls the worker and return' do
+ expect(::Packages::Npm::DeprecatePackageWorker).to receive(:perform_async).with(project.id, params)
+ expect(service).not_to receive(:packages)
+
+ service.execute(async: true)
+ end
+ end
+ end
+end
diff --git a/spec/services/packages/npm/generate_metadata_service_spec.rb b/spec/services/packages/npm/generate_metadata_service_spec.rb
new file mode 100644
index 00000000000..1e3b0f71972
--- /dev/null
+++ b/spec/services/packages/npm/generate_metadata_service_spec.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Packages::Npm::GenerateMetadataService, feature_category: :package_registry do
+ using RSpec::Parameterized::TableSyntax
+
+ 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(:packages) { project.packages.npm.with_name(package_name).last_of_each_version }
+ let(:metadata) { described_class.new(package_name, packages).execute }
+
+ describe '#versions' do
+ let_it_be(:version_schema) { 'public_api/v4/packages/npm_package_version' }
+ let_it_be(:package_json) do
+ {
+ name: package_name,
+ version: '2.0.4',
+ deprecated: 'warning!',
+ bin: './cli.js',
+ directories: ['lib'],
+ engines: { npm: '^7.5.6' },
+ _hasShrinkwrap: false,
+ dist: {
+ tarball: 'http://localhost/tarball.tgz',
+ shasum: '1234567890'
+ },
+ custom_field: 'foo_bar'
+ }
+ end
+
+ subject { metadata[:versions] }
+
+ where(:has_dependencies, :has_metadatum) do
+ true | true
+ false | true
+ true | false
+ false | false
+ end
+
+ with_them do
+ if params[:has_dependencies]
+ ::Packages::DependencyLink.dependency_types.each_key do |dependency_type| # rubocop:disable RSpec/UselessDynamicDefinition
+ let_it_be("package_dependency_link_for_#{dependency_type}") do
+ create(:packages_dependency_link, package: package1, dependency_type: dependency_type)
+ end
+ end
+ end
+
+ if params[:has_metadatum]
+ let_it_be(:package_metadatadum) { create(:npm_metadatum, package: package1, package_json: package_json) }
+ end
+
+ it { is_expected.to be_a(Hash) }
+ it { expect(subject[package1.version].with_indifferent_access).to match_schema(version_schema) }
+ it { expect(subject[package2.version].with_indifferent_access).to match_schema(version_schema) }
+ it { expect(subject[package1.version]['custom_field']).to be_blank }
+
+ context 'for dependencies' do
+ ::Packages::DependencyLink.dependency_types.each_key do |dependency_type|
+ if params[:has_dependencies]
+ it { expect(subject.dig(package1.version, dependency_type.to_s)).to be_any }
+ else
+ it { expect(subject.dig(package1.version, dependency_type)).to be nil }
+ end
+
+ it { expect(subject.dig(package2.version, dependency_type)).to be nil }
+ end
+ end
+
+ context 'for metadatum' do
+ ::Packages::Npm::GenerateMetadataService::PACKAGE_JSON_ALLOWED_FIELDS.each do |metadata_field|
+ if params[:has_metadatum]
+ it { expect(subject.dig(package1.version, metadata_field)).not_to be nil }
+ else
+ it { expect(subject.dig(package1.version, metadata_field)).to be nil }
+ end
+
+ it { expect(subject.dig(package2.version, metadata_field)).to be nil }
+ end
+ end
+
+ it 'avoids N+1 database queries' do
+ check_n_plus_one do
+ create_list(:npm_package, 5, project: project, name: package_name).each do |npm_package|
+ next unless has_dependencies
+
+ ::Packages::DependencyLink.dependency_types.each_key do |dependency_type|
+ create(:packages_dependency_link, package: npm_package, dependency_type: dependency_type)
+ end
+ end
+ end
+ end
+ end
+
+ context 'with package files pending destruction' do
+ let_it_be(:package_file_pending_destruction) do
+ create(:package_file, :pending_destruction, package: package2, file_sha1: 'pending_destruction_sha1')
+ end
+
+ let(:shasums) { subject.values.map { |v| v.dig(:dist, :shasum) } }
+
+ it 'does not return them' do
+ expect(shasums).not_to include(package_file_pending_destruction.file_sha1)
+ end
+ end
+ end
+
+ describe '#dist_tags' do
+ subject { metadata[:dist_tags] }
+
+ 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(only_dist_tags: true) do
+ create_list(:npm_package, 5, project: project, name: package_name)
+ end
+ end
+ end
+
+ context 'for packages with tags' do
+ 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) }
+ it { expect(subject[package_tag2.name]).to eq(package1.version) }
+ 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(only_dist_tags: true) 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
+
+ context 'when passing only_dist_tags: true' do
+ subject { described_class.new(package_name, packages).execute(only_dist_tags: true) }
+
+ it 'returns only dist tags' do
+ expect(subject.payload.keys).to contain_exactly(:dist_tags)
+ end
+ end
+
+ def check_n_plus_one(only_dist_tags: false)
+ pkgs = project.packages.npm.with_name(package_name).last_of_each_version.preload_files
+ control = ActiveRecord::QueryRecorder.new do
+ described_class.new(package_name, pkgs).execute(only_dist_tags: only_dist_tags)
+ end
+
+ yield
+
+ pkgs = project.packages.npm.with_name(package_name).last_of_each_version.preload_files
+
+ expect do
+ described_class.new(package_name, pkgs).execute(only_dist_tags: only_dist_tags)
+ end.not_to exceed_query_limit(control)
+ end
+end
diff --git a/spec/services/packages/nuget/create_dependency_service_spec.rb b/spec/services/packages/nuget/create_dependency_service_spec.rb
index 268c8837e25..10daec8b871 100644
--- a/spec/services/packages/nuget/create_dependency_service_spec.rb
+++ b/spec/services/packages/nuget/create_dependency_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Nuget::CreateDependencyService do
+RSpec.describe Packages::Nuget::CreateDependencyService, feature_category: :package_registry do
let_it_be(:package, reload: true) { create(:nuget_package) }
describe '#execute' do
diff --git a/spec/services/packages/nuget/metadata_extraction_service_spec.rb b/spec/services/packages/nuget/metadata_extraction_service_spec.rb
index 12bab30b4a7..9177a5379d9 100644
--- a/spec/services/packages/nuget/metadata_extraction_service_spec.rb
+++ b/spec/services/packages/nuget/metadata_extraction_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Nuget::MetadataExtractionService do
+RSpec.describe Packages::Nuget::MetadataExtractionService, feature_category: :package_registry do
let_it_be(:package_file) { create(:nuget_package).package_files.first }
let(:service) { described_class.new(package_file.id) }
diff --git a/spec/services/packages/nuget/search_service_spec.rb b/spec/services/packages/nuget/search_service_spec.rb
index 66c91487a8f..b5f32c9b727 100644
--- a/spec/services/packages/nuget/search_service_spec.rb
+++ b/spec/services/packages/nuget/search_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Nuget::SearchService do
+RSpec.describe Packages::Nuget::SearchService, feature_category: :package_registry do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) }
diff --git a/spec/services/packages/nuget/sync_metadatum_service_spec.rb b/spec/services/packages/nuget/sync_metadatum_service_spec.rb
index 32093c48b76..ae07f312fcc 100644
--- a/spec/services/packages/nuget/sync_metadatum_service_spec.rb
+++ b/spec/services/packages/nuget/sync_metadatum_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Nuget::SyncMetadatumService do
+RSpec.describe Packages::Nuget::SyncMetadatumService, feature_category: :package_registry do
let_it_be(:package, reload: true) { create(:nuget_package) }
let_it_be(:metadata) 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 6a4dbeb10dc..c35863030b0 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_redis_shared_state do
+RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_redis_shared_state, feature_category: :package_registry do
include ExclusiveLeaseHelpers
let!(:package) { create(:nuget_package, :processing, :with_symbol_package) }
@@ -259,11 +259,13 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_
]
invalid_names.each do |invalid_name|
- before do
- allow(service).to receive(:package_name).and_return(invalid_name)
- end
+ context "with #{invalid_name}" do
+ before do
+ allow(service).to receive(:package_name).and_return(invalid_name)
+ end
- it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
+ it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
+ end
end
end
@@ -271,18 +273,19 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_
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
+ context "with #{invalid_version}" do
+ before do
+ allow(service).to receive(:package_version).and_return(invalid_version)
+ end
- it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
+ it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
+ end
end
end
end
diff --git a/spec/services/packages/pypi/create_package_service_spec.rb b/spec/services/packages/pypi/create_package_service_spec.rb
index 6794ab4d9d6..0d278e32e89 100644
--- a/spec/services/packages/pypi/create_package_service_spec.rb
+++ b/spec/services/packages/pypi/create_package_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Pypi::CreatePackageService, :aggregate_failures do
+RSpec.describe Packages::Pypi::CreatePackageService, :aggregate_failures, feature_category: :package_registry do
include PackagesManagerApiSpecHelpers
let_it_be(:project) { create(:project) }
diff --git a/spec/services/packages/remove_tag_service_spec.rb b/spec/services/packages/remove_tag_service_spec.rb
index 084635824e5..4ad478d487a 100644
--- a/spec/services/packages/remove_tag_service_spec.rb
+++ b/spec/services/packages/remove_tag_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::RemoveTagService do
+RSpec.describe Packages::RemoveTagService, feature_category: :package_registry do
let!(:package_tag) { create(:packages_tag) }
describe '#execute' do
diff --git a/spec/services/packages/rpm/parse_package_service_spec.rb b/spec/services/packages/rpm/parse_package_service_spec.rb
index f330587bfa0..80907d8f43f 100644
--- a/spec/services/packages/rpm/parse_package_service_spec.rb
+++ b/spec/services/packages/rpm/parse_package_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Rpm::ParsePackageService do
+RSpec.describe Packages::Rpm::ParsePackageService, feature_category: :package_registry do
let(:package_file) { File.open('spec/fixtures/packages/rpm/hello-0.0.1-1.fc29.x86_64.rpm') }
describe 'dynamic private methods' do
diff --git a/spec/services/packages/rpm/repository_metadata/build_filelist_xml_service_spec.rb b/spec/services/packages/rpm/repository_metadata/build_filelist_xml_service_spec.rb
index d93d6ab9fcb..e0d9e192d97 100644
--- a/spec/services/packages/rpm/repository_metadata/build_filelist_xml_service_spec.rb
+++ b/spec/services/packages/rpm/repository_metadata/build_filelist_xml_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Rpm::RepositoryMetadata::BuildFilelistXmlService do
+RSpec.describe Packages::Rpm::RepositoryMetadata::BuildFilelistXmlService, feature_category: :package_registry do
describe '#execute' do
subject { described_class.new(data).execute }
diff --git a/spec/services/packages/rpm/repository_metadata/build_other_xml_service_spec.rb b/spec/services/packages/rpm/repository_metadata/build_other_xml_service_spec.rb
index 201f9e67ce9..e81a436e006 100644
--- a/spec/services/packages/rpm/repository_metadata/build_other_xml_service_spec.rb
+++ b/spec/services/packages/rpm/repository_metadata/build_other_xml_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Rpm::RepositoryMetadata::BuildOtherXmlService do
+RSpec.describe Packages::Rpm::RepositoryMetadata::BuildOtherXmlService, feature_category: :package_registry do
describe '#execute' do
subject { described_class.new(data).execute }
diff --git a/spec/services/packages/rpm/repository_metadata/build_primary_xml_service_spec.rb b/spec/services/packages/rpm/repository_metadata/build_primary_xml_service_spec.rb
index 9bbfa5c9863..1e534782841 100644
--- a/spec/services/packages/rpm/repository_metadata/build_primary_xml_service_spec.rb
+++ b/spec/services/packages/rpm/repository_metadata/build_primary_xml_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Rpm::RepositoryMetadata::BuildPrimaryXmlService do
+RSpec.describe Packages::Rpm::RepositoryMetadata::BuildPrimaryXmlService, feature_category: :package_registry do
describe '#execute' do
subject { described_class.new(data).execute }
diff --git a/spec/services/packages/rpm/repository_metadata/build_repomd_xml_service_spec.rb b/spec/services/packages/rpm/repository_metadata/build_repomd_xml_service_spec.rb
index cf28301fa2c..99fcf0fabbf 100644
--- a/spec/services/packages/rpm/repository_metadata/build_repomd_xml_service_spec.rb
+++ b/spec/services/packages/rpm/repository_metadata/build_repomd_xml_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Rpm::RepositoryMetadata::BuildRepomdXmlService do
+RSpec.describe Packages::Rpm::RepositoryMetadata::BuildRepomdXmlService, feature_category: :package_registry do
describe '#execute' do
subject { described_class.new(data).execute }
diff --git a/spec/services/packages/rpm/repository_metadata/update_xml_service_spec.rb b/spec/services/packages/rpm/repository_metadata/update_xml_service_spec.rb
index e351392ba1c..68a3ac7d82f 100644
--- a/spec/services/packages/rpm/repository_metadata/update_xml_service_spec.rb
+++ b/spec/services/packages/rpm/repository_metadata/update_xml_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Rpm::RepositoryMetadata::UpdateXmlService do
+RSpec.describe Packages::Rpm::RepositoryMetadata::UpdateXmlService, feature_category: :package_registry do
describe '#execute' do
subject { described_class.new(filename: filename, xml: xml, data: data).execute }
diff --git a/spec/services/packages/rubygems/create_dependencies_service_spec.rb b/spec/services/packages/rubygems/create_dependencies_service_spec.rb
index b6e12b1cc61..d689bae96ff 100644
--- a/spec/services/packages/rubygems/create_dependencies_service_spec.rb
+++ b/spec/services/packages/rubygems/create_dependencies_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Rubygems::CreateDependenciesService do
+RSpec.describe Packages::Rubygems::CreateDependenciesService, feature_category: :package_registry do
include RubygemsHelpers
let_it_be(:package) { create(:rubygems_package) }
diff --git a/spec/services/packages/rubygems/create_gemspec_service_spec.rb b/spec/services/packages/rubygems/create_gemspec_service_spec.rb
index 839fb4d955a..17890100b93 100644
--- a/spec/services/packages/rubygems/create_gemspec_service_spec.rb
+++ b/spec/services/packages/rubygems/create_gemspec_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Rubygems::CreateGemspecService do
+RSpec.describe Packages::Rubygems::CreateGemspecService, feature_category: :package_registry do
include RubygemsHelpers
let_it_be(:package_file) { create(:package_file, :gem) }
diff --git a/spec/services/packages/rubygems/dependency_resolver_service_spec.rb b/spec/services/packages/rubygems/dependency_resolver_service_spec.rb
index bb84e0cd361..9a72c51e99c 100644
--- a/spec/services/packages/rubygems/dependency_resolver_service_spec.rb
+++ b/spec/services/packages/rubygems/dependency_resolver_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::Rubygems::DependencyResolverService do
+RSpec.describe Packages::Rubygems::DependencyResolverService, feature_category: :package_registry do
let_it_be(:project) { create(:project, :private) }
let_it_be(:package) { create(:package, project: project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/packages/rubygems/metadata_extraction_service_spec.rb b/spec/services/packages/rubygems/metadata_extraction_service_spec.rb
index bbd5b6f3d59..87d63eff311 100644
--- a/spec/services/packages/rubygems/metadata_extraction_service_spec.rb
+++ b/spec/services/packages/rubygems/metadata_extraction_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
require 'rubygems/package'
-RSpec.describe Packages::Rubygems::MetadataExtractionService do
+RSpec.describe Packages::Rubygems::MetadataExtractionService, feature_category: :package_registry do
include RubygemsHelpers
let_it_be(:package) { create(:rubygems_package) }
diff --git a/spec/services/packages/rubygems/process_gem_service_spec.rb b/spec/services/packages/rubygems/process_gem_service_spec.rb
index caff338ef53..a1b4eae9655 100644
--- a/spec/services/packages/rubygems/process_gem_service_spec.rb
+++ b/spec/services/packages/rubygems/process_gem_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Rubygems::ProcessGemService do
+RSpec.describe Packages::Rubygems::ProcessGemService, feature_category: :package_registry do
include ExclusiveLeaseHelpers
include RubygemsHelpers
diff --git a/spec/services/packages/terraform_module/create_package_service_spec.rb b/spec/services/packages/terraform_module/create_package_service_spec.rb
index f73b5682835..3355dfcf5ec 100644
--- a/spec/services/packages/terraform_module/create_package_service_spec.rb
+++ b/spec/services/packages/terraform_module/create_package_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::TerraformModule::CreatePackageService do
+RSpec.describe Packages::TerraformModule::CreatePackageService, feature_category: :package_registry do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project) { create(:project, namespace: namespace) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/packages/update_package_file_service_spec.rb b/spec/services/packages/update_package_file_service_spec.rb
index d988049c43a..5d081059105 100644
--- a/spec/services/packages/update_package_file_service_spec.rb
+++ b/spec/services/packages/update_package_file_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::UpdatePackageFileService do
+RSpec.describe Packages::UpdatePackageFileService, feature_category: :package_registry do
let_it_be(:another_package) { create(:package) }
let_it_be(:old_file_name) { 'old_file_name.txt' }
let_it_be(:new_file_name) { 'new_file_name.txt' }
diff --git a/spec/services/packages/update_tags_service_spec.rb b/spec/services/packages/update_tags_service_spec.rb
index c4256699c94..d8f572fff32 100644
--- a/spec/services/packages/update_tags_service_spec.rb
+++ b/spec/services/packages/update_tags_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Packages::UpdateTagsService do
+RSpec.describe Packages::UpdateTagsService, feature_category: :package_registry do
let_it_be(:package, reload: true) { create(:nuget_package) }
let(:tags) { %w(test-tag tag1 tag2 tag3) }
diff --git a/spec/services/pages/delete_service_spec.rb b/spec/services/pages/delete_service_spec.rb
index 8b9e72ac9b1..590378af22b 100644
--- a/spec/services/pages/delete_service_spec.rb
+++ b/spec/services/pages/delete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Pages::DeleteService do
+RSpec.describe Pages::DeleteService, feature_category: :pages do
let_it_be(:admin) { create(:admin) }
let(:project) { create(:project, path: "my.project") }
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 177467aac85..b18f62c1c28 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Pages::MigrateLegacyStorageToDeploymentService do
+RSpec.describe Pages::MigrateLegacyStorageToDeploymentService, feature_category: :pages do
let(:project) { create(:project, :repository) }
let(:service) { described_class.new(project) }
diff --git a/spec/services/pages/zip_directory_service_spec.rb b/spec/services/pages/zip_directory_service_spec.rb
index 00fe75dbbfd..4917bc65a02 100644
--- a/spec/services/pages/zip_directory_service_spec.rb
+++ b/spec/services/pages/zip_directory_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Pages::ZipDirectoryService do
+RSpec.describe Pages::ZipDirectoryService, feature_category: :pages do
around do |example|
Dir.mktmpdir do |dir|
@work_dir = dir
diff --git a/spec/services/pages_domains/create_acme_order_service_spec.rb b/spec/services/pages_domains/create_acme_order_service_spec.rb
index 35b2cc56973..97534d52c67 100644
--- a/spec/services/pages_domains/create_acme_order_service_spec.rb
+++ b/spec/services/pages_domains/create_acme_order_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PagesDomains::CreateAcmeOrderService do
+RSpec.describe PagesDomains::CreateAcmeOrderService, feature_category: :pages do
include LetsEncryptHelpers
let(:pages_domain) { create(:pages_domain) }
diff --git a/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb b/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb
index ecb445fa441..2377fbcf003 100644
--- a/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb
+++ b/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PagesDomains::ObtainLetsEncryptCertificateService do
+RSpec.describe PagesDomains::ObtainLetsEncryptCertificateService, feature_category: :pages do
include LetsEncryptHelpers
let(:pages_domain) { create(:pages_domain, :without_certificate, :without_key) }
diff --git a/spec/services/personal_access_tokens/create_service_spec.rb b/spec/services/personal_access_tokens/create_service_spec.rb
index b8a4c8f30d2..d80be5cccce 100644
--- a/spec/services/personal_access_tokens/create_service_spec.rb
+++ b/spec/services/personal_access_tokens/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PersonalAccessTokens::CreateService do
+RSpec.describe PersonalAccessTokens::CreateService, feature_category: :system_access do
shared_examples_for 'a successfully created token' do
it 'creates personal access token record' do
expect(subject.success?).to be true
@@ -40,7 +40,7 @@ RSpec.describe PersonalAccessTokens::CreateService do
let(:current_user) { create(:user) }
let(:user) { create(:user) }
let(:params) { { name: 'Test token', impersonation: false, scopes: [:api], expires_at: Date.today + 1.month } }
- let(:service) { described_class.new(current_user: current_user, target_user: user, params: params) }
+ let(:service) { described_class.new(current_user: current_user, target_user: user, params: params, concatenate_errors: false) }
let(:token) { subject.payload[:personal_access_token] }
context 'when current_user is an administrator' do
@@ -66,5 +66,21 @@ RSpec.describe PersonalAccessTokens::CreateService do
it_behaves_like 'a successfully created token'
end
end
+
+ context 'when invalid scope' do
+ let(:params) { { name: 'Test token', impersonation: false, scopes: [:no_valid], expires_at: Date.today + 1.month } }
+
+ context 'when concatenate_errors: true' do
+ let(:service) { described_class.new(current_user: user, target_user: user, params: params) }
+
+ it { expect(subject.message).to be_an_instance_of(String) }
+ end
+
+ context 'when concatenate_errors: false' do
+ let(:service) { described_class.new(current_user: user, target_user: user, params: params, concatenate_errors: false) }
+
+ it { expect(subject.message).to be_an_instance_of(Array) }
+ end
+ end
end
end
diff --git a/spec/services/personal_access_tokens/last_used_service_spec.rb b/spec/services/personal_access_tokens/last_used_service_spec.rb
index 6fc74e27dd9..20eabc20338 100644
--- a/spec/services/personal_access_tokens/last_used_service_spec.rb
+++ b/spec/services/personal_access_tokens/last_used_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PersonalAccessTokens::LastUsedService do
+RSpec.describe PersonalAccessTokens::LastUsedService, feature_category: :system_access do
describe '#execute' do
subject { described_class.new(personal_access_token).execute }
diff --git a/spec/services/personal_access_tokens/revoke_service_spec.rb b/spec/services/personal_access_tokens/revoke_service_spec.rb
index a9b4df9749f..4c5d106660a 100644
--- a/spec/services/personal_access_tokens/revoke_service_spec.rb
+++ b/spec/services/personal_access_tokens/revoke_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PersonalAccessTokens::RevokeService do
+RSpec.describe PersonalAccessTokens::RevokeService, feature_category: :system_access do
shared_examples_for 'a successfully revoked token' do
it { expect(subject.success?).to be true }
it { expect(service.token.revoked?).to be true }
diff --git a/spec/services/personal_access_tokens/rotate_service_spec.rb b/spec/services/personal_access_tokens/rotate_service_spec.rb
new file mode 100644
index 00000000000..e026b0b6485
--- /dev/null
+++ b/spec/services/personal_access_tokens/rotate_service_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PersonalAccessTokens::RotateService, feature_category: :system_access do
+ describe '#execute' do
+ let_it_be(:token, reload: true) { create(:personal_access_token) }
+
+ subject(:response) { described_class.new(token.user, token).execute }
+
+ it "rotates user's own token", :freeze_time do
+ expect(response).to be_success
+
+ new_token = response.payload[:personal_access_token]
+
+ expect(new_token.token).not_to eq(token.token)
+ expect(new_token.expires_at).to eq(Date.today + 1.week)
+ expect(new_token.user).to eq(token.user)
+ end
+
+ it 'revokes the previous token' do
+ expect { response }.to change { token.reload.revoked? }.from(false).to(true)
+
+ new_token = response.payload[:personal_access_token]
+ expect(new_token).not_to be_revoked
+ end
+
+ context 'when user tries to rotate already revoked token' do
+ let_it_be(:token, reload: true) { create(:personal_access_token, :revoked) }
+
+ it 'returns an error' do
+ expect { response }.not_to change { token.reload.revoked? }.from(true)
+ expect(response).to be_error
+ expect(response.message).to eq('token already revoked')
+ end
+ end
+
+ context 'when revoking previous token fails' do
+ it 'returns an error' do
+ expect(token).to receive(:revoke!).and_return(false)
+
+ expect(response).to be_error
+ end
+ end
+
+ context 'when creating the new token fails' do
+ let(:error_message) { 'boom!' }
+
+ before do
+ allow_next_instance_of(PersonalAccessToken) do |token|
+ allow(token).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return(error_message)
+ allow(token).to receive_message_chain(:errors, :clear)
+ allow(token).to receive_message_chain(:errors, :empty?).and_return(false)
+ end
+ end
+
+ it 'returns an error' do
+ expect(response).to be_error
+ expect(response.message).to eq(error_message)
+ end
+
+ it 'reverts the changes' do
+ expect { response }.not_to change { token.reload.revoked? }.from(false)
+ end
+ end
+ end
+end
diff --git a/spec/services/post_receive_service_spec.rb b/spec/services/post_receive_service_spec.rb
index aa955b3445b..13bd103003f 100644
--- a/spec/services/post_receive_service_spec.rb
+++ b/spec/services/post_receive_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PostReceiveService do
+RSpec.describe PostReceiveService, feature_category: :team_planning do
include GitlabShellHelpers
include Gitlab::Routing
diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb
index d1bc10cfd28..6fa44310ae5 100644
--- a/spec/services/preview_markdown_service_spec.rb
+++ b/spec/services/preview_markdown_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PreviewMarkdownService do
+RSpec.describe PreviewMarkdownService, feature_category: :team_planning do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
@@ -117,6 +117,16 @@ RSpec.describe PreviewMarkdownService do
expect(result[:text]).to eq 'Please do it'
end
+ context 'when render_quick_actions' do
+ it 'keeps quick actions' do
+ params[:render_quick_actions] = true
+
+ result = service.execute
+
+ expect(result[:text]).to eq "Please do it\n\n/assign #{user.to_reference}"
+ end
+ end
+
it 'explains quick actions effect' do
result = service.execute
diff --git a/spec/services/product_analytics/build_activity_graph_service_spec.rb b/spec/services/product_analytics/build_activity_graph_service_spec.rb
index e303656da34..cd1bc42e156 100644
--- a/spec/services/product_analytics/build_activity_graph_service_spec.rb
+++ b/spec/services/product_analytics/build_activity_graph_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProductAnalytics::BuildActivityGraphService do
+RSpec.describe ProductAnalytics::BuildActivityGraphService, feature_category: :product_analytics do
let_it_be(:project) { create(:project) }
let_it_be(:time_now) { Time.zone.now }
let_it_be(:time_ago) { Time.zone.now - 5.days }
diff --git a/spec/services/product_analytics/build_graph_service_spec.rb b/spec/services/product_analytics/build_graph_service_spec.rb
index 933a2bfee92..ee0e2190501 100644
--- a/spec/services/product_analytics/build_graph_service_spec.rb
+++ b/spec/services/product_analytics/build_graph_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProductAnalytics::BuildGraphService do
+RSpec.describe ProductAnalytics::BuildGraphService, feature_category: :product_analytics do
let_it_be(:project) { create(:project) }
let_it_be(:events) do
diff --git a/spec/services/projects/after_rename_service_spec.rb b/spec/services/projects/after_rename_service_spec.rb
index 72bb0adbf56..3097d6d1498 100644
--- a/spec/services/projects/after_rename_service_spec.rb
+++ b/spec/services/projects/after_rename_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::AfterRenameService do
+RSpec.describe Projects::AfterRenameService, feature_category: :projects do
let(:legacy_storage) { Storage::LegacyProject.new(project) }
let(:hashed_storage) { Storage::Hashed.new(project) }
let!(:path_before_rename) { project.path }
diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb
index aa2ef39bf98..8cd9b5d3e00 100644
--- a/spec/services/projects/alerting/notify_service_spec.rb
+++ b/spec/services/projects/alerting/notify_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::Alerting::NotifyService do
+RSpec.describe Projects::Alerting::NotifyService, feature_category: :projects do
let_it_be_with_reload(:project) { create(:project) }
let(:payload) { ActionController::Parameters.new(payload_raw).permit! }
diff --git a/spec/services/projects/all_issues_count_service_spec.rb b/spec/services/projects/all_issues_count_service_spec.rb
index d7e35991940..e8e08a25c45 100644
--- a/spec/services/projects/all_issues_count_service_spec.rb
+++ b/spec/services/projects/all_issues_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::AllIssuesCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Projects::AllIssuesCountService, :use_clean_rails_memory_store_caching, feature_category: :projects do
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:banned_user) { create(:user, :banned) }
diff --git a/spec/services/projects/all_merge_requests_count_service_spec.rb b/spec/services/projects/all_merge_requests_count_service_spec.rb
index 13954d688aa..ca10fbc00ad 100644
--- a/spec/services/projects/all_merge_requests_count_service_spec.rb
+++ b/spec/services/projects/all_merge_requests_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::AllMergeRequestsCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Projects::AllMergeRequestsCountService, :use_clean_rails_memory_store_caching, feature_category: :projects do
let_it_be(:project) { create(:project) }
subject { described_class.new(project) }
@@ -11,18 +11,9 @@ RSpec.describe Projects::AllMergeRequestsCountService, :use_clean_rails_memory_s
describe '#count' do
it 'returns the number of all merge requests' do
- create(:merge_request,
- :opened,
- source_project: project,
- target_project: project)
- create(:merge_request,
- :closed,
- source_project: project,
- target_project: project)
- create(:merge_request,
- :merged,
- source_project: project,
- target_project: project)
+ create(:merge_request, :opened, source_project: project, target_project: project)
+ create(:merge_request, :closed, source_project: project, target_project: project)
+ create(:merge_request, :merged, source_project: project, target_project: project)
expect(subject.count).to eq(3)
end
diff --git a/spec/services/projects/android_target_platform_detector_service_spec.rb b/spec/services/projects/android_target_platform_detector_service_spec.rb
deleted file mode 100644
index 74fd320bb48..00000000000
--- a/spec/services/projects/android_target_platform_detector_service_spec.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::AndroidTargetPlatformDetectorService do
- let_it_be(:project) { build(:project) }
-
- subject { described_class.new(project).execute }
-
- before do
- allow(Gitlab::FileFinder).to receive(:new) { finder }
- end
-
- context 'when project is not an Android project' do
- let(:finder) { instance_double(Gitlab::FileFinder, find: []) }
-
- it { is_expected.to be_nil }
- end
-
- context 'when project is an Android project' do
- let(:finder) { instance_double(Gitlab::FileFinder) }
-
- before do
- query = described_class::MANIFEST_FILE_SEARCH_QUERY
- allow(finder).to receive(:find).with(query) { [instance_double(Gitlab::Search::FoundBlob)] }
- end
-
- it { is_expected.to eq :android }
- end
-end
diff --git a/spec/services/projects/apple_target_platform_detector_service_spec.rb b/spec/services/projects/apple_target_platform_detector_service_spec.rb
index 6391161824c..787faaa0f79 100644
--- a/spec/services/projects/apple_target_platform_detector_service_spec.rb
+++ b/spec/services/projects/apple_target_platform_detector_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::AppleTargetPlatformDetectorService do
+RSpec.describe Projects::AppleTargetPlatformDetectorService, feature_category: :projects do
let_it_be(:project) { build(:project) }
subject { described_class.new(project).execute }
diff --git a/spec/services/projects/auto_devops/disable_service_spec.rb b/spec/services/projects/auto_devops/disable_service_spec.rb
index 1f161990fb2..fd70362a53f 100644
--- a/spec/services/projects/auto_devops/disable_service_spec.rb
+++ b/spec/services/projects/auto_devops/disable_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Projects::AutoDevops::DisableService, '#execute' do
+RSpec.describe Projects::AutoDevops::DisableService, '#execute', feature_category: :auto_devops do
let(:project) { create(:project, :repository, :auto_devops) }
let(:auto_devops) { project.auto_devops }
diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb
index bc95a1f3c8b..9d3075874a2 100644
--- a/spec/services/projects/autocomplete_service_spec.rb
+++ b/spec/services/projects/autocomplete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::AutocompleteService do
+RSpec.describe Projects::AutocompleteService, feature_category: :projects do
describe '#issues' do
describe 'confidential issues' do
let(:author) { create(:user) }
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 89a4abbf9c9..d29115a697f 100644
--- a/spec/services/projects/batch_open_issues_count_service_spec.rb
+++ b/spec/services/projects/batch_open_issues_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::BatchOpenIssuesCountService do
+RSpec.describe Projects::BatchOpenIssuesCountService, feature_category: :projects do
let!(:project_1) { create(:project) }
let!(:project_2) { create(:project) }
diff --git a/spec/services/projects/batch_open_merge_requests_count_service_spec.rb b/spec/services/projects/batch_open_merge_requests_count_service_spec.rb
new file mode 100644
index 00000000000..96fc6c5e9dd
--- /dev/null
+++ b/spec/services/projects/batch_open_merge_requests_count_service_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::BatchOpenMergeRequestsCountService, feature_category: :code_review_workflow do
+ subject { described_class.new([project_1, project_2]) }
+
+ let_it_be(:project_1) { create(:project) }
+ let_it_be(:project_2) { create(:project) }
+
+ describe '#refresh_cache_and_retrieve_data', :use_clean_rails_memory_store_caching do
+ before do
+ create(:merge_request, source_project: project_1, target_project: project_1)
+ create(:merge_request, source_project: project_2, target_project: project_2)
+ end
+
+ it 'refreshes cache keys correctly when cache is clean', :aggregate_failures do
+ subject.refresh_cache_and_retrieve_data
+
+ expect(Rails.cache.read(get_cache_key(subject, project_1))).to eq(1)
+ expect(Rails.cache.read(get_cache_key(subject, project_2))).to eq(1)
+
+ expect { subject.refresh_cache_and_retrieve_data }.not_to exceed_query_limit(0)
+ end
+ end
+
+ def get_cache_key(subject, project)
+ subject.count_service
+ .new(project)
+ .cache_key
+ end
+end
diff --git a/spec/services/projects/blame_service_spec.rb b/spec/services/projects/blame_service_spec.rb
deleted file mode 100644
index 52b0ed3412d..00000000000
--- a/spec/services/projects/blame_service_spec.rb
+++ /dev/null
@@ -1,131 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::BlameService, :aggregate_failures do
- subject(:service) { described_class.new(blob, commit, params) }
-
- let_it_be(:project) { create(:project, :repository) }
- let_it_be(:commit) { project.repository.commit }
- let_it_be(:blob) { project.repository.blob_at('HEAD', 'README.md') }
-
- let(:params) { { page: page } }
- let(:page) { nil }
-
- before do
- stub_const("#{described_class.name}::PER_PAGE", 2)
- end
-
- describe '#blame' do
- subject { service.blame }
-
- it 'returns a correct Gitlab::Blame object' do
- is_expected.to be_kind_of(Gitlab::Blame)
-
- expect(subject.blob).to eq(blob)
- expect(subject.commit).to eq(commit)
- expect(subject.range).to eq(1..2)
- end
-
- describe 'Pagination range calculation' do
- subject { service.blame.range }
-
- context 'with page = 1' do
- let(:page) { 1 }
-
- it { is_expected.to eq(1..2) }
- end
-
- context 'with page = 2' do
- let(:page) { 2 }
-
- it { is_expected.to eq(3..4) }
- end
-
- context 'with page = 3 (overlimit)' do
- let(:page) { 3 }
-
- it { is_expected.to eq(1..2) }
- end
-
- context 'with page = 0 (incorrect)' do
- let(:page) { 0 }
-
- it { is_expected.to eq(1..2) }
- end
-
- context 'when user disabled the pagination' do
- let(:params) { super().merge(no_pagination: 1) }
-
- it { is_expected.to be_nil }
- end
-
- context 'when feature flag disabled' do
- before do
- stub_feature_flags(blame_page_pagination: false)
- end
-
- it { is_expected.to be_nil }
- end
- end
- end
-
- describe '#pagination' do
- subject { service.pagination }
-
- it 'returns a pagination object' do
- is_expected.to be_kind_of(Kaminari::PaginatableArray)
-
- expect(subject.current_page).to eq(1)
- expect(subject.total_pages).to eq(2)
- expect(subject.total_count).to eq(4)
- end
-
- context 'when user disabled the pagination' do
- let(:params) { super().merge(no_pagination: 1) }
-
- it { is_expected.to be_nil }
- end
-
- context 'when feature flag disabled' do
- before do
- stub_feature_flags(blame_page_pagination: false)
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'when per_page is above the global max per page limit' do
- before do
- stub_const("#{described_class.name}::PER_PAGE", 1000)
- allow(blob).to receive_message_chain(:data, :lines, :count) { 500 }
- end
-
- it 'returns a correct pagination object' do
- is_expected.to be_kind_of(Kaminari::PaginatableArray)
-
- expect(subject.current_page).to eq(1)
- expect(subject.total_pages).to eq(1)
- expect(subject.total_count).to eq(500)
- end
- end
-
- describe 'Pagination attributes' do
- using RSpec::Parameterized::TableSyntax
-
- where(:page, :current_page, :total_pages) do
- 1 | 1 | 2
- 2 | 2 | 2
- 3 | 1 | 2 # Overlimit
- 0 | 1 | 2 # Incorrect
- end
-
- with_them do
- it 'returns the correct pagination attributes' do
- expect(subject.current_page).to eq(current_page)
- expect(subject.total_pages).to eq(total_pages)
- end
- end
- end
- end
-end
diff --git a/spec/services/projects/branches_by_mode_service_spec.rb b/spec/services/projects/branches_by_mode_service_spec.rb
index 9a63563b37b..bfe76b34310 100644
--- a/spec/services/projects/branches_by_mode_service_spec.rb
+++ b/spec/services/projects/branches_by_mode_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::BranchesByModeService do
+RSpec.describe Projects::BranchesByModeService, feature_category: :source_code_management do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
diff --git a/spec/services/projects/cleanup_service_spec.rb b/spec/services/projects/cleanup_service_spec.rb
index f2c052d9397..533a09f7bc7 100644
--- a/spec/services/projects/cleanup_service_spec.rb
+++ b/spec/services/projects/cleanup_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::CleanupService do
+RSpec.describe Projects::CleanupService, feature_category: :source_code_management do
subject(:service) { described_class.new(project) }
describe '.enqueue' do
diff --git a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
index 8311c4e4d9b..b8ad63d9b8a 100644
--- a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ContainerRepository::CleanupTagsService do
+RSpec.describe Projects::ContainerRepository::CleanupTagsService, feature_category: :container_registry do
let_it_be_with_reload(:container_repository) { create(:container_repository) }
let_it_be(:user) { container_repository.project.owner }
@@ -77,7 +77,7 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService do
context 'with a migrated repository' do
before do
- container_repository.update_column(:migration_state, :import_done)
+ allow(container_repository).to receive(:migrated?).and_return(true)
end
context 'supporting the gitlab api' do
@@ -99,8 +99,7 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService do
context 'with a non migrated repository' do
before do
- container_repository.update_column(:migration_state, :default)
- container_repository.update!(created_at: ContainerRepository::MIGRATION_PHASE_1_ENDED_AT - 1.week)
+ allow(container_repository).to receive(:migrated?).and_return(false)
end
it_behaves_like 'calling service', ::Projects::ContainerRepository::ThirdParty::CleanupTagsService, extra_log_data: { third_party_cleanup_tags_service: true }
diff --git a/spec/services/projects/container_repository/delete_tags_service_spec.rb b/spec/services/projects/container_repository/delete_tags_service_spec.rb
index 9e6849aa514..5b67d614dfb 100644
--- a/spec/services/projects/container_repository/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ContainerRepository::DeleteTagsService do
+RSpec.describe Projects::ContainerRepository::DeleteTagsService, feature_category: :container_registry do
using RSpec::Parameterized::TableSyntax
include_context 'container repository delete tags service shared context'
diff --git a/spec/services/projects/container_repository/destroy_service_spec.rb b/spec/services/projects/container_repository/destroy_service_spec.rb
index fed1d13daa5..a142360f99d 100644
--- a/spec/services/projects/container_repository/destroy_service_spec.rb
+++ b/spec/services/projects/container_repository/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ContainerRepository::DestroyService do
+RSpec.describe Projects::ContainerRepository::DestroyService, feature_category: :container_registry do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :private) }
let_it_be(:params) { {} }
diff --git a/spec/services/projects/container_repository/gitlab/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/gitlab/cleanup_tags_service_spec.rb
index b06a5709bd5..f662d8bfc0c 100644
--- a/spec/services/projects/container_repository/gitlab/cleanup_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/gitlab/cleanup_tags_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ContainerRepository::Gitlab::CleanupTagsService do
+RSpec.describe Projects::ContainerRepository::Gitlab::CleanupTagsService, feature_category: :container_registry do
using RSpec::Parameterized::TableSyntax
include_context 'for a cleanup tags service'
@@ -11,11 +11,13 @@ RSpec.describe Projects::ContainerRepository::Gitlab::CleanupTagsService do
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project, :private) }
- let(:repository) { create(:container_repository, :root, :import_done, project: project) }
+ let(:repository) { create(:container_repository, :root, project: project) }
let(:service) { described_class.new(container_repository: repository, current_user: user, params: params) }
let(:tags) { %w[latest A Ba Bb C D E] }
before do
+ allow(repository).to receive(:migrated?).and_return(true)
+
project.add_maintainer(user) if user
stub_container_registry_config(enabled: true)
@@ -47,23 +49,23 @@ RSpec.describe Projects::ContainerRepository::Gitlab::CleanupTagsService do
let(:tags_page_size) { 2 }
it_behaves_like 'when regex matching everything is specified',
- delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[E]]
+ delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[E]]
it_behaves_like 'when regex matching everything is specified and latest is not kept',
- delete_expectations: [%w[latest A], %w[Ba Bb], %w[C D], %w[E]]
+ delete_expectations: [%w[latest A], %w[Ba Bb], %w[C D], %w[E]]
it_behaves_like 'when delete regex matching specific tags is used'
it_behaves_like 'when delete regex matching specific tags is used with overriding allow regex'
it_behaves_like 'with allow regex value',
- delete_expectations: [%w[A], %w[C D], %w[E]]
+ delete_expectations: [%w[A], %w[C D], %w[E]]
it_behaves_like 'when keeping only N tags',
- delete_expectations: [%w[Bb]]
+ delete_expectations: [%w[Bb]]
it_behaves_like 'when not keeping N tags',
- delete_expectations: [%w[A], %w[Ba Bb], %w[C]]
+ delete_expectations: [%w[A], %w[Ba Bb], %w[C]]
context 'when removing keeping only 3' do
let(:params) do
@@ -77,13 +79,13 @@ RSpec.describe Projects::ContainerRepository::Gitlab::CleanupTagsService do
end
it_behaves_like 'when removing older than 1 day',
- delete_expectations: [%w[Ba Bb], %w[C]]
+ delete_expectations: [%w[Ba Bb], %w[C]]
it_behaves_like 'when combining all parameters',
- delete_expectations: [%w[Bb], %w[C]]
+ delete_expectations: [%w[Bb], %w[C]]
it_behaves_like 'when running a container_expiration_policy',
- delete_expectations: [%w[Bb], %w[C]]
+ delete_expectations: [%w[Bb], %w[C]]
context 'with a timeout' do
let(:params) do
@@ -111,7 +113,7 @@ RSpec.describe Projects::ContainerRepository::Gitlab::CleanupTagsService do
end
it_behaves_like 'when regex matching everything is specified',
- delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[E]]
+ delete_expectations: [%w[A], %w[Ba Bb], %w[C D], %w[E]]
end
end
end
@@ -120,32 +122,46 @@ RSpec.describe Projects::ContainerRepository::Gitlab::CleanupTagsService do
let(:tags_page_size) { 1000 }
it_behaves_like 'when regex matching everything is specified',
- delete_expectations: [%w[A Ba Bb C D E]]
+ delete_expectations: [%w[A Ba Bb C D E]]
it_behaves_like 'when delete regex matching specific tags is used'
it_behaves_like 'when delete regex matching specific tags is used with overriding allow regex'
it_behaves_like 'with allow regex value',
- delete_expectations: [%w[A C D E]]
+ delete_expectations: [%w[A C D E]]
it_behaves_like 'when keeping only N tags',
- delete_expectations: [%w[Ba Bb C]]
+ delete_expectations: [%w[Ba Bb C]]
it_behaves_like 'when not keeping N tags',
- delete_expectations: [%w[A Ba Bb C]]
+ delete_expectations: [%w[A Ba Bb C]]
it_behaves_like 'when removing keeping only 3',
- delete_expectations: [%w[Ba Bb C]]
+ delete_expectations: [%w[Ba Bb C]]
it_behaves_like 'when removing older than 1 day',
- delete_expectations: [%w[Ba Bb C]]
+ delete_expectations: [%w[Ba Bb C]]
it_behaves_like 'when combining all parameters',
- delete_expectations: [%w[Ba Bb C]]
+ delete_expectations: [%w[Ba Bb C]]
it_behaves_like 'when running a container_expiration_policy',
- delete_expectations: [%w[Ba Bb C]]
+ delete_expectations: [%w[Ba Bb C]]
+ end
+
+ context 'with no tags page' do
+ let(:tags_page_size) { 1000 }
+ let(:deleted) { [] }
+ let(:params) { {} }
+
+ before do
+ allow(repository.gitlab_api_client)
+ .to receive(:tags)
+ .and_return({})
+ end
+
+ it { is_expected.to eq(expected_service_response(status: :success, deleted: [], original_size: 0)) }
end
end
diff --git a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
index f03912dba80..c4e6c7f4a11 100644
--- a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
+RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService, feature_category: :container_registry do
include_context 'container repository delete tags service shared context'
let(:service) { described_class.new(repository, tags) }
diff --git a/spec/services/projects/container_repository/third_party/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/third_party/cleanup_tags_service_spec.rb
index 7227834b131..836e722eb99 100644
--- a/spec/services/projects/container_repository/third_party/cleanup_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/third_party/cleanup_tags_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ContainerRepository::ThirdParty::CleanupTagsService, :clean_gitlab_redis_cache do
+RSpec.describe Projects::ContainerRepository::ThirdParty::CleanupTagsService, :clean_gitlab_redis_cache, feature_category: :container_registry do
using RSpec::Parameterized::TableSyntax
include_context 'for a cleanup tags service'
@@ -42,112 +42,112 @@ RSpec.describe Projects::ContainerRepository::ThirdParty::CleanupTagsService, :c
subject { service.execute }
it_behaves_like 'when regex matching everything is specified',
- delete_expectations: [%w[A Ba Bb C D E]],
- service_response_extra: {
- before_truncate_size: 6,
- after_truncate_size: 6,
- before_delete_size: 6,
- cached_tags_count: 0
- },
- supports_caching: true
+ delete_expectations: [%w[A Ba Bb C D E]],
+ service_response_extra: {
+ before_truncate_size: 6,
+ after_truncate_size: 6,
+ before_delete_size: 6,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'when regex matching everything is specified and latest is not kept',
- delete_expectations: [%w[A Ba Bb C D E latest]],
- service_response_extra: {
- before_truncate_size: 7,
- after_truncate_size: 7,
- before_delete_size: 7,
- cached_tags_count: 0
- },
- supports_caching: true
+ delete_expectations: [%w[A Ba Bb C D E latest]],
+ service_response_extra: {
+ before_truncate_size: 7,
+ after_truncate_size: 7,
+ before_delete_size: 7,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'when delete regex matching specific tags is used',
- service_response_extra: {
- before_truncate_size: 2,
- after_truncate_size: 2,
- before_delete_size: 2,
- cached_tags_count: 0
- },
- supports_caching: true
+ service_response_extra: {
+ before_truncate_size: 2,
+ after_truncate_size: 2,
+ before_delete_size: 2,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'when delete regex matching specific tags is used with overriding allow regex',
- service_response_extra: {
- before_truncate_size: 1,
- after_truncate_size: 1,
- before_delete_size: 1,
- cached_tags_count: 0
- },
- supports_caching: true
+ service_response_extra: {
+ before_truncate_size: 1,
+ after_truncate_size: 1,
+ before_delete_size: 1,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'with allow regex value',
- delete_expectations: [%w[A C D E]],
- service_response_extra: {
- before_truncate_size: 4,
- after_truncate_size: 4,
- before_delete_size: 4,
- cached_tags_count: 0
- },
- supports_caching: true
+ delete_expectations: [%w[A C D E]],
+ service_response_extra: {
+ before_truncate_size: 4,
+ after_truncate_size: 4,
+ before_delete_size: 4,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'when keeping only N tags',
- delete_expectations: [%w[Bb Ba C]],
- service_response_extra: {
- before_truncate_size: 4,
- after_truncate_size: 4,
- before_delete_size: 3,
- cached_tags_count: 0
- },
- supports_caching: true
+ delete_expectations: [%w[Bb Ba C]],
+ service_response_extra: {
+ before_truncate_size: 4,
+ after_truncate_size: 4,
+ before_delete_size: 3,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'when not keeping N tags',
- delete_expectations: [%w[A Ba Bb C]],
- service_response_extra: {
- before_truncate_size: 4,
- after_truncate_size: 4,
- before_delete_size: 4,
- cached_tags_count: 0
- },
- supports_caching: true
+ delete_expectations: [%w[A Ba Bb C]],
+ service_response_extra: {
+ before_truncate_size: 4,
+ after_truncate_size: 4,
+ before_delete_size: 4,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'when removing keeping only 3',
- delete_expectations: [%w[Bb Ba C]],
- service_response_extra: {
- before_truncate_size: 6,
- after_truncate_size: 6,
- before_delete_size: 3,
- cached_tags_count: 0
- },
- supports_caching: true
+ delete_expectations: [%w[Bb Ba C]],
+ service_response_extra: {
+ before_truncate_size: 6,
+ after_truncate_size: 6,
+ before_delete_size: 3,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'when removing older than 1 day',
- delete_expectations: [%w[Ba Bb C]],
- service_response_extra: {
- before_truncate_size: 6,
- after_truncate_size: 6,
- before_delete_size: 3,
- cached_tags_count: 0
- },
- supports_caching: true
+ delete_expectations: [%w[Ba Bb C]],
+ service_response_extra: {
+ before_truncate_size: 6,
+ after_truncate_size: 6,
+ before_delete_size: 3,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'when combining all parameters',
- delete_expectations: [%w[Bb Ba C]],
- service_response_extra: {
- before_truncate_size: 6,
- after_truncate_size: 6,
- before_delete_size: 3,
- cached_tags_count: 0
- },
- supports_caching: true
+ delete_expectations: [%w[Bb Ba C]],
+ service_response_extra: {
+ before_truncate_size: 6,
+ after_truncate_size: 6,
+ before_delete_size: 3,
+ cached_tags_count: 0
+ },
+ supports_caching: true
it_behaves_like 'when running a container_expiration_policy',
- delete_expectations: [%w[Bb Ba C]],
- service_response_extra: {
- before_truncate_size: 6,
- after_truncate_size: 6,
- before_delete_size: 3,
- cached_tags_count: 0
- },
- supports_caching: true
+ delete_expectations: [%w[Bb Ba C]],
+ service_response_extra: {
+ before_truncate_size: 6,
+ after_truncate_size: 6,
+ before_delete_size: 3,
+ cached_tags_count: 0
+ },
+ supports_caching: true
context 'when running a container_expiration_policy with caching' do
let(:user) { nil }
diff --git a/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb b/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb
index 4de36452684..0c297b6e1f7 100644
--- a/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ContainerRepository::ThirdParty::DeleteTagsService do
+RSpec.describe Projects::ContainerRepository::ThirdParty::DeleteTagsService, feature_category: :container_registry do
include_context 'container repository delete tags service shared context'
let(:service) { described_class.new(repository, tags) }
diff --git a/spec/services/projects/count_service_spec.rb b/spec/services/projects/count_service_spec.rb
index 11b2b57a277..71940fa396e 100644
--- a/spec/services/projects/count_service_spec.rb
+++ b/spec/services/projects/count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::CountService do
+RSpec.describe Projects::CountService, feature_category: :projects do
let(:project) { build(:project, id: 1) }
let(:service) { described_class.new(project) }
diff --git a/spec/services/projects/create_from_template_service_spec.rb b/spec/services/projects/create_from_template_service_spec.rb
index fba6225b87a..a3fdb258f75 100644
--- a/spec/services/projects/create_from_template_service_spec.rb
+++ b/spec/services/projects/create_from_template_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::CreateFromTemplateService do
+RSpec.describe Projects::CreateFromTemplateService, feature_category: :projects do
let(:user) { create(:user) }
let(:template_name) { 'rails' }
let(:project_params) do
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index e435db4efa6..303a98cb35b 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -254,6 +254,27 @@ RSpec.describe Projects::CreateService, '#execute', feature_category: :projects
end
it_behaves_like 'has sync-ed traversal_ids'
+
+ context 'when project is an import' do
+ before do
+ stub_application_setting(import_sources: ['gitlab_project'])
+ end
+
+ context 'when user is not allowed to import projects' do
+ let(:group) do
+ create(:group).tap do |group|
+ group.add_developer(user)
+ end
+ end
+
+ it 'does not create the project' do
+ project = create_project(user, opts.merge!(namespace_id: group.id, import_type: 'gitlab_project'))
+
+ expect(project).not_to be_persisted
+ expect(project.errors.messages[:user].first).to eq('is not allowed to import projects')
+ end
+ end
+ end
end
context 'group sharing', :sidekiq_inline do
@@ -339,9 +360,12 @@ RSpec.describe Projects::CreateService, '#execute', feature_category: :projects
before do
group.add_maintainer(group_maintainer)
- create(:group_group_link, shared_group: subgroup_for_projects,
- shared_with_group: subgroup_for_access,
- group_access: share_max_access_level)
+ create(
+ :group_group_link,
+ shared_group: subgroup_for_projects,
+ shared_with_group: subgroup_for_access,
+ group_access: share_max_access_level
+ )
end
context 'membership is higher from group hierarchy' do
@@ -716,16 +740,34 @@ RSpec.describe Projects::CreateService, '#execute', feature_category: :projects
end
end
- context 'and a default_branch_name is specified' do
+ context 'and default_branch is specified' do
before do
- allow(Gitlab::CurrentSettings).to receive(:default_branch_name).and_return('example_branch')
+ opts[:default_branch] = 'example_branch'
end
it 'creates the correct branch' do
- branches = project.repository.branches
+ expect(project.repository.branch_names).to contain_exactly('example_branch')
+ end
- expect(branches.size).to eq(1)
- expect(branches.collect(&:name)).to contain_exactly('example_branch')
+ it_behaves_like 'a repo with a README.md' do
+ let(:expected_content) do
+ <<~MARKDOWN
+ cd existing_repo
+ git remote add origin #{project.http_url_to_repo}
+ git branch -M example_branch
+ git push -uf origin example_branch
+ MARKDOWN
+ end
+ end
+ end
+
+ context 'and the default branch setting is configured' do
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:default_branch_name).and_return('example_branch')
+ end
+
+ it 'creates the correct branch' do
+ expect(project.repository.branch_names).to contain_exactly('example_branch')
end
it_behaves_like 'a repo with a README.md' do
@@ -956,11 +998,11 @@ RSpec.describe Projects::CreateService, '#execute', feature_category: :projects
receive(:perform_async).and_call_original
)
expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in)
- .with(1.hour,
- array_including([user.id], [other_user.id]),
- batch_delay: 30.seconds, batch_size: 100)
- .and_call_original
+ receive(:bulk_perform_in).with(
+ 1.hour,
+ array_including([user.id], [other_user.id]),
+ batch_delay: 30.seconds, batch_size: 100
+ ).and_call_original
)
project = create_project(user, opts)
diff --git a/spec/services/projects/deploy_tokens/create_service_spec.rb b/spec/services/projects/deploy_tokens/create_service_spec.rb
index 831dbc06588..96458a51fb4 100644
--- a/spec/services/projects/deploy_tokens/create_service_spec.rb
+++ b/spec/services/projects/deploy_tokens/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::DeployTokens::CreateService do
+RSpec.describe Projects::DeployTokens::CreateService, feature_category: :continuous_delivery do
it_behaves_like 'a deploy token creation service' do
let(:entity) { create(:project) }
let(:deploy_token_class) { ProjectDeployToken }
diff --git a/spec/services/projects/deploy_tokens/destroy_service_spec.rb b/spec/services/projects/deploy_tokens/destroy_service_spec.rb
index edb2345aa6c..3d0323c60ba 100644
--- a/spec/services/projects/deploy_tokens/destroy_service_spec.rb
+++ b/spec/services/projects/deploy_tokens/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::DeployTokens::DestroyService do
+RSpec.describe Projects::DeployTokens::DestroyService, feature_category: :continuous_delivery do
it_behaves_like 'a deploy token deletion service' do
let_it_be(:entity) { create(:project) }
let_it_be(:deploy_token_class) { ProjectDeployToken }
diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb
index 0689a65c2f4..665f930a0a8 100644
--- a/spec/services/projects/destroy_service_spec.rb
+++ b/spec/services/projects/destroy_service_spec.rb
@@ -207,9 +207,11 @@ RSpec.describe Projects::DestroyService, :aggregate_failures, :event_store_publi
context 'when project has exports' do
let!(:project_with_export) do
create(:project, :repository, namespace: user.namespace).tap do |project|
- create(:import_export_upload,
- project: project,
- export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz'))
+ create(
+ :import_export_upload,
+ project: project,
+ export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz')
+ )
end
end
@@ -337,8 +339,7 @@ RSpec.describe Projects::DestroyService, :aggregate_failures, :event_store_publi
let(:container_repository) { create(:container_repository) }
before do
- stub_container_registry_tags(repository: project.full_path + '/image',
- tags: ['tag'])
+ stub_container_registry_tags(repository: project.full_path + '/image', tags: ['tag'])
project.container_repositories << container_repository
end
@@ -387,8 +388,7 @@ RSpec.describe Projects::DestroyService, :aggregate_failures, :event_store_publi
context 'when there are tags for legacy root repository' do
before do
- stub_container_registry_tags(repository: project.full_path,
- tags: ['tag'])
+ stub_container_registry_tags(repository: project.full_path, tags: ['tag'])
end
context 'when image repository tags deletion succeeds' do
@@ -414,8 +414,7 @@ RSpec.describe Projects::DestroyService, :aggregate_failures, :event_store_publi
context 'when there are no tags for legacy root repository' do
before do
- stub_container_registry_tags(repository: project.full_path,
- tags: [])
+ stub_container_registry_tags(repository: project.full_path, tags: [])
end
it 'does not try to destroy the repository' do
diff --git a/spec/services/projects/detect_repository_languages_service_spec.rb b/spec/services/projects/detect_repository_languages_service_spec.rb
index cf4c7a5024d..5759f8128d0 100644
--- a/spec/services/projects/detect_repository_languages_service_spec.rb
+++ b/spec/services/projects/detect_repository_languages_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::DetectRepositoryLanguagesService, :clean_gitlab_redis_shared_state do
+RSpec.describe Projects::DetectRepositoryLanguagesService, :clean_gitlab_redis_shared_state, feature_category: :projects do
let_it_be(:project, reload: true) { create(:project, :repository) }
subject { described_class.new(project) }
diff --git a/spec/services/projects/download_service_spec.rb b/spec/services/projects/download_service_spec.rb
index f158b11a9fa..52bdbefe01a 100644
--- a/spec/services/projects/download_service_spec.rb
+++ b/spec/services/projects/download_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::DownloadService do
+RSpec.describe Projects::DownloadService, feature_category: :projects do
describe 'File service' do
before do
@user = create(:user)
diff --git a/spec/services/projects/enable_deploy_key_service_spec.rb b/spec/services/projects/enable_deploy_key_service_spec.rb
index c0b3992037e..59c76a96d07 100644
--- a/spec/services/projects/enable_deploy_key_service_spec.rb
+++ b/spec/services/projects/enable_deploy_key_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::EnableDeployKeyService do
+RSpec.describe Projects::EnableDeployKeyService, feature_category: :continuous_delivery do
let(:deploy_key) { create(:deploy_key, public: true) }
let(:project) { create(:project) }
let(:user) { project.creator }
diff --git a/spec/services/projects/fetch_statistics_increment_service_spec.rb b/spec/services/projects/fetch_statistics_increment_service_spec.rb
index 16121a42c39..9e24e68fa98 100644
--- a/spec/services/projects/fetch_statistics_increment_service_spec.rb
+++ b/spec/services/projects/fetch_statistics_increment_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
module Projects
- RSpec.describe FetchStatisticsIncrementService do
+ RSpec.describe FetchStatisticsIncrementService, feature_category: :projects do
let(:project) { create(:project) }
describe '#execute' do
diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb
index 48756cf774b..4ba72b5870d 100644
--- a/spec/services/projects/fork_service_spec.rb
+++ b/spec/services/projects/fork_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ForkService do
+RSpec.describe Projects::ForkService, feature_category: :source_code_management do
include ProjectForksHelper
shared_examples 'forks count cache refresh' do
@@ -22,14 +22,16 @@ RSpec.describe Projects::ForkService do
@from_user = create(:user)
@from_namespace = @from_user.namespace
avatar = fixture_file_upload("spec/fixtures/dk.png", "image/png")
- @from_project = create(:project,
- :repository,
- creator_id: @from_user.id,
- namespace: @from_namespace,
- star_count: 107,
- avatar: avatar,
- description: 'wow such project',
- external_authorization_classification_label: 'classification-label')
+ @from_project = create(
+ :project,
+ :repository,
+ creator_id: @from_user.id,
+ namespace: @from_namespace,
+ star_count: 107,
+ avatar: avatar,
+ description: 'wow such project',
+ external_authorization_classification_label: 'classification-label'
+ )
@to_user = create(:user)
@to_namespace = @to_user.namespace
@from_project.add_member(@to_user, :developer)
@@ -148,12 +150,11 @@ RSpec.describe Projects::ForkService do
context 'project already exists' do
it "fails due to validation, not transaction failure" do
- @existing_project = create(:project, :repository, creator_id: @to_user.id, name: @from_project.name, namespace: @to_namespace)
+ @existing_project = create(:project, :repository, creator_id: @to_user.id, path: @from_project.path, namespace: @to_namespace)
@to_project = fork_project(@from_project, @to_user, namespace: @to_namespace, using_service: true)
expect(@existing_project).to be_persisted
expect(@to_project).not_to be_persisted
- expect(@to_project.errors[:name]).to eq(['has already been taken'])
expect(@to_project.errors[:path]).to eq(['has already been taken'])
end
end
@@ -258,11 +259,13 @@ RSpec.describe Projects::ForkService do
before do
@group_owner = create(:user)
@developer = create(:user)
- @project = create(:project, :repository,
- creator_id: @group_owner.id,
- star_count: 777,
- description: 'Wow, such a cool project!',
- ci_config_path: 'debian/salsa-ci.yml')
+ @project = create(
+ :project, :repository,
+ creator_id: @group_owner.id,
+ star_count: 777,
+ description: 'Wow, such a cool project!',
+ ci_config_path: 'debian/salsa-ci.yml'
+ )
@group = create(:group)
@group.add_member(@group_owner, GroupMember::OWNER)
@group.add_member(@developer, GroupMember::DEVELOPER)
@@ -297,12 +300,9 @@ RSpec.describe Projects::ForkService do
context 'project already exists in group' do
it 'fails due to validation, not transaction failure' do
- existing_project = create(:project, :repository,
- name: @project.name,
- namespace: @group)
+ existing_project = create(:project, :repository, path: @project.path, namespace: @group)
to_project = fork_project(@project, @group_owner, @opts)
expect(existing_project.persisted?).to be_truthy
- expect(to_project.errors[:name]).to eq(['has already been taken'])
expect(to_project.errors[:path]).to eq(['has already been taken'])
end
end
diff --git a/spec/services/projects/forks/sync_service_spec.rb b/spec/services/projects/forks/sync_service_spec.rb
new file mode 100644
index 00000000000..aeb53992ed4
--- /dev/null
+++ b/spec/services/projects/forks/sync_service_spec.rb
@@ -0,0 +1,185 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Forks::SyncService, feature_category: :source_code_management do
+ include ProjectForksHelper
+ include RepoHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:source_project) { create(:project, :repository, :public) }
+ let_it_be(:project) { fork_project(source_project, user, { repository: true }) }
+
+ let(:fork_branch) { project.default_branch }
+ let(:service) { described_class.new(project, user, fork_branch) }
+
+ def details
+ Projects::Forks::Details.new(project, fork_branch)
+ end
+
+ def expect_to_cancel_exclusive_lease
+ expect(Gitlab::ExclusiveLease).to receive(:cancel)
+ end
+
+ describe '#execute' do
+ context 'when fork is up-to-date with the upstream' do
+ it 'does not perform merge' do
+ expect_to_cancel_exclusive_lease
+ expect(project.repository).not_to receive(:merge_to_branch)
+ expect(project.repository).not_to receive(:ff_merge)
+
+ expect(service.execute).to be_success
+ end
+ end
+
+ context 'when fork is behind the upstream' do
+ let_it_be(:base_commit) { source_project.commit.sha }
+
+ before_all do
+ source_project.repository.commit_files(
+ user,
+ branch_name: source_project.repository.root_ref, message: 'Commit to root ref',
+ actions: [{ action: :create, file_path: 'encoding/CHANGELOG', content: 'One more' }]
+ )
+
+ source_project.repository.commit_files(
+ user,
+ branch_name: source_project.repository.root_ref, message: 'Another commit to root ref',
+ actions: [{ action: :create, file_path: 'encoding/NEW-CHANGELOG', content: 'One more time' }]
+ )
+ end
+
+ before do
+ project.repository.create_branch(fork_branch, base_commit)
+ end
+
+ context 'when fork is not ahead of the upstream' do
+ let(:fork_branch) { 'fork-without-new-commits' }
+
+ it 'updates the fork using ff merge' do
+ expect_to_cancel_exclusive_lease
+ expect(project.commit(fork_branch).sha).to eq(base_commit)
+ expect(project.repository).to receive(:ff_merge)
+ .with(user, source_project.commit.sha, fork_branch, target_sha: base_commit)
+ .and_call_original
+
+ expect do
+ expect(service.execute).to be_success
+ end.to change { details.counts }.from({ ahead: 0, behind: 2 }).to({ ahead: 0, behind: 0 })
+ end
+ end
+
+ context 'when fork is ahead of the upstream' do
+ context 'and has conflicts with the upstream', :use_clean_rails_redis_caching do
+ let(:fork_branch) { 'fork-with-conflicts' }
+
+ it 'returns an error' do
+ project.repository.commit_files(
+ user,
+ branch_name: fork_branch, message: 'Committing something',
+ actions: [{ action: :create, file_path: 'encoding/CHANGELOG', content: 'New file' }]
+ )
+
+ expect_to_cancel_exclusive_lease
+ expect(details).not_to have_conflicts
+
+ expect do
+ result = service.execute
+
+ expect(result).to be_error
+ expect(result.message).to eq("9:merging commits: merge: there are conflicting files.")
+ end.not_to change { details.counts }
+
+ expect(details).to have_conflicts
+ end
+ end
+
+ context 'and does not have conflicts with the upstream' do
+ let(:fork_branch) { 'fork-with-new-commits' }
+
+ it 'updates the fork using merge' do
+ project.repository.commit_files(
+ user,
+ branch_name: fork_branch, message: 'Committing completely new changelog',
+ actions: [{ action: :create, file_path: 'encoding/COMPLETELY-NEW-CHANGELOG', content: 'New file' }]
+ )
+
+ commit_message = "Merge branch #{source_project.path}:#{source_project.default_branch} into #{fork_branch}"
+ expect(project.repository).to receive(:merge_to_branch).with(
+ user,
+ source_sha: source_project.commit.sha,
+ target_branch: fork_branch,
+ target_sha: project.commit(fork_branch).sha,
+ message: commit_message
+ ).and_call_original
+ expect_to_cancel_exclusive_lease
+
+ expect do
+ expect(service.execute).to be_success
+ end.to change { details.counts }.from({ ahead: 1, behind: 2 }).to({ ahead: 2, behind: 0 })
+
+ commits = project.repository.commits_between(source_project.commit.sha, project.commit(fork_branch).sha)
+ expect(commits.map(&:message)).to eq([
+ "Committing completely new changelog",
+ commit_message
+ ])
+ end
+ end
+ end
+
+ context 'when a merge cannot happen due to another ongoing merge' do
+ it 'does not merge' do
+ expect(service).to receive(:perform_merge).and_return(nil)
+
+ result = service.execute
+
+ expect(result).to be_error
+ expect(result.message).to eq(described_class::ONGOING_MERGE_ERROR)
+ end
+ end
+
+ context 'when upstream branch contains lfs reference' do
+ let(:source_project) { create(:project, :repository, :public) }
+ let(:project) { fork_project(source_project, user, { repository: true }) }
+ let(:fork_branch) { 'fork-fetches-lfs-pointers' }
+
+ before do
+ source_project.change_head('lfs')
+
+ allow(source_project).to receive(:lfs_enabled?).and_return(true)
+ allow(project).to receive(:lfs_enabled?).and_return(true)
+
+ create_file_in_repo(source_project, 'lfs', 'lfs', 'one.lfs', 'One')
+ create_file_in_repo(source_project, 'lfs', 'lfs', 'two.lfs', 'Two')
+ end
+
+ it 'links fetched lfs objects to the fork project', :aggregate_failures do
+ expect_to_cancel_exclusive_lease
+
+ expect do
+ expect(service.execute).to be_success
+ end.to change { project.reload.lfs_objects.size }.from(0).to(2)
+ .and change { details.counts }.from({ ahead: 0, behind: 3 }).to({ ahead: 0, behind: 0 })
+
+ expect(project.lfs_objects).to match_array(source_project.lfs_objects)
+ end
+
+ context 'and there are too many of them for a single sync' do
+ let(:fork_branch) { 'fork-too-many-lfs-pointers' }
+
+ it 'updates the fork successfully' do
+ expect_to_cancel_exclusive_lease
+ stub_const('Projects::LfsPointers::LfsLinkService::MAX_OIDS', 1)
+
+ expect do
+ result = service.execute
+
+ expect(result).to be_error
+ expect(result.message).to eq('Too many LFS object ids to link, please push them manually')
+ end.not_to change { details.counts }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/forks_count_service_spec.rb b/spec/services/projects/forks_count_service_spec.rb
index 31662f78973..403d8656b7c 100644
--- a/spec/services/projects/forks_count_service_spec.rb
+++ b/spec/services/projects/forks_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ForksCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Projects::ForksCountService, :use_clean_rails_memory_store_caching, feature_category: :source_code_management do
let(:project) { build(:project) }
subject { described_class.new(project) }
diff --git a/spec/services/projects/git_deduplication_service_spec.rb b/spec/services/projects/git_deduplication_service_spec.rb
index e6eff936de7..2b9f0974ae2 100644
--- a/spec/services/projects/git_deduplication_service_spec.rb
+++ b/spec/services/projects/git_deduplication_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GitDeduplicationService do
+RSpec.describe Projects::GitDeduplicationService, feature_category: :source_code_management do
include ExclusiveLeaseHelpers
let(:pool) { create(:pool_repository, :ready) }
@@ -139,7 +139,7 @@ RSpec.describe Projects::GitDeduplicationService do
end
it 'fails when a lease is already out' do
- expect(service).to receive(:log_error).with("Cannot obtain an exclusive lease for #{lease_key}. There must be another instance already in execution.")
+ expect(Gitlab::AppJsonLogger).to receive(:error).with({ message: "Cannot obtain an exclusive lease. There must be another instance already in execution.", lease_key: lease_key, class_name: described_class.name, lease_timeout: lease_timeout })
service.execute
end
diff --git a/spec/services/projects/gitlab_projects_import_service_spec.rb b/spec/services/projects/gitlab_projects_import_service_spec.rb
index d32e720a49f..b1468a40212 100644
--- a/spec/services/projects/gitlab_projects_import_service_spec.rb
+++ b/spec/services/projects/gitlab_projects_import_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GitlabProjectsImportService do
+RSpec.describe Projects::GitlabProjectsImportService, feature_category: :importers do
let_it_be(:namespace) { create(:namespace) }
let(:path) { 'test-path' }
diff --git a/spec/services/projects/group_links/create_service_spec.rb b/spec/services/projects/group_links/create_service_spec.rb
index eae898b4f68..4f2f480cf1c 100644
--- a/spec/services/projects/group_links/create_service_spec.rb
+++ b/spec/services/projects/group_links/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GroupLinks::CreateService, '#execute' do
+RSpec.describe Projects::GroupLinks::CreateService, '#execute', feature_category: :subgroups do
let_it_be(:user) { create :user }
let_it_be(:group) { create :group }
let_it_be(:project) { create(:project, namespace: create(:namespace, :with_namespace_settings)) }
@@ -69,11 +69,11 @@ RSpec.describe Projects::GroupLinks::CreateService, '#execute' do
.and_call_original
)
expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in)
- .with(1.hour,
- array_including([user.id], [other_user.id]),
- batch_delay: 30.seconds, batch_size: 100)
- .and_call_original
+ receive(:bulk_perform_in).with(
+ 1.hour,
+ array_including([user.id], [other_user.id]),
+ batch_delay: 30.seconds, batch_size: 100
+ ).and_call_original
)
subject.execute
@@ -82,8 +82,7 @@ RSpec.describe Projects::GroupLinks::CreateService, '#execute' do
context 'when sharing outside the hierarchy is disabled' do
let_it_be(:shared_group_parent) do
- create(:group,
- namespace_settings: create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: true))
+ create(:group, namespace_settings: create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: true))
end
let_it_be(:project, reload: true) { create(:project, group: shared_group_parent) }
diff --git a/spec/services/projects/group_links/destroy_service_spec.rb b/spec/services/projects/group_links/destroy_service_spec.rb
index 89865d6bc3b..76bdd536a0d 100644
--- a/spec/services/projects/group_links/destroy_service_spec.rb
+++ b/spec/services/projects/group_links/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GroupLinks::DestroyService, '#execute' do
+RSpec.describe Projects::GroupLinks::DestroyService, '#execute', feature_category: :subgroups do
let_it_be(:user) { create :user }
let_it_be(:project) { create(:project, :private) }
let_it_be(:group) { create(:group) }
@@ -31,10 +31,11 @@ RSpec.describe Projects::GroupLinks::DestroyService, '#execute' do
stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false)
expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in)
- .with(1.hour,
- [[user.id]],
- batch_delay: 30.seconds, batch_size: 100)
+ receive(:bulk_perform_in).with(
+ 1.hour,
+ [[user.id]],
+ batch_delay: 30.seconds, batch_size: 100
+ )
)
subject.execute(group_link)
diff --git a/spec/services/projects/group_links/update_service_spec.rb b/spec/services/projects/group_links/update_service_spec.rb
index 1acbb770763..4232412cf54 100644
--- a/spec/services/projects/group_links/update_service_spec.rb
+++ b/spec/services/projects/group_links/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::GroupLinks::UpdateService, '#execute' do
+RSpec.describe Projects::GroupLinks::UpdateService, '#execute', feature_category: :subgroups do
let_it_be(:user) { create :user }
let_it_be(:group) { create :group }
let_it_be(:project) { create :project }
@@ -45,10 +45,11 @@ RSpec.describe Projects::GroupLinks::UpdateService, '#execute' do
stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false)
expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in)
- .with(1.hour,
- [[user.id]],
- batch_delay: 30.seconds, batch_size: 100)
+ receive(:bulk_perform_in).with(
+ 1.hour,
+ [[user.id]],
+ batch_delay: 30.seconds, batch_size: 100
+ )
)
subject
diff --git a/spec/services/projects/hashed_storage/base_attachment_service_spec.rb b/spec/services/projects/hashed_storage/base_attachment_service_spec.rb
index 86e3fb3820c..01036fc2d9c 100644
--- a/spec/services/projects/hashed_storage/base_attachment_service_spec.rb
+++ b/spec/services/projects/hashed_storage/base_attachment_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::HashedStorage::BaseAttachmentService do
+RSpec.describe Projects::HashedStorage::BaseAttachmentService, feature_category: :projects do
let(:project) { create(:project, :repository, storage_version: 0, skip_disk_validation: true) }
subject(:service) { described_class.new(project: project, old_disk_path: project.full_path, logger: nil) }
diff --git a/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb b/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb
index c8f24c6ce00..39263506bca 100644
--- a/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb
+++ b/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::HashedStorage::MigrateAttachmentsService do
+RSpec.describe Projects::HashedStorage::MigrateAttachmentsService, feature_category: :projects do
subject(:service) { described_class.new(project: project, old_disk_path: project.full_path, logger: nil) }
let(:project) { create(:project, :repository, storage_version: 1, skip_disk_validation: true) }
diff --git a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
index eb8d94ebfa5..bcc914e72b5 100644
--- a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
+++ b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::HashedStorage::MigrateRepositoryService do
+RSpec.describe Projects::HashedStorage::MigrateRepositoryService, feature_category: :projects do
let(:gitlab_shell) { Gitlab::Shell.new }
let(:project) { create(:project, :legacy_storage, :repository, :wiki_repo, :design_repo) }
let(:legacy_storage) { Storage::LegacyProject.new(project) }
diff --git a/spec/services/projects/hashed_storage/migration_service_spec.rb b/spec/services/projects/hashed_storage/migration_service_spec.rb
index ef96c17dd85..89bc55dbaf6 100644
--- a/spec/services/projects/hashed_storage/migration_service_spec.rb
+++ b/spec/services/projects/hashed_storage/migration_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::HashedStorage::MigrationService do
+RSpec.describe Projects::HashedStorage::MigrationService, feature_category: :projects do
let(:project) { create(:project, :empty_repo, :wiki_repo, :legacy_storage) }
let(:logger) { double }
let!(:project_attachment) { build(:file_uploader, project: project) }
@@ -16,9 +16,11 @@ RSpec.describe Projects::HashedStorage::MigrationService do
describe '#execute' do
context 'repository migration' do
let(:repository_service) do
- Projects::HashedStorage::MigrateRepositoryService.new(project: project,
- old_disk_path: project.full_path,
- logger: logger)
+ Projects::HashedStorage::MigrateRepositoryService.new(
+ project: project,
+ old_disk_path: project.full_path,
+ logger: logger
+ )
end
it 'delegates migration to Projects::HashedStorage::MigrateRepositoryService' do
@@ -53,9 +55,11 @@ RSpec.describe Projects::HashedStorage::MigrationService do
let(:project) { create(:project, :empty_repo, :wiki_repo, storage_version: ::Project::HASHED_STORAGE_FEATURES[:repository]) }
let(:attachments_service) do
- Projects::HashedStorage::MigrateAttachmentsService.new(project: project,
- old_disk_path: project.full_path,
- logger: logger)
+ Projects::HashedStorage::MigrateAttachmentsService.new(
+ project: project,
+ old_disk_path: project.full_path,
+ logger: logger
+ )
end
it 'delegates migration to Projects::HashedStorage::MigrateRepositoryService' do
diff --git a/spec/services/projects/hashed_storage/rollback_attachments_service_spec.rb b/spec/services/projects/hashed_storage/rollback_attachments_service_spec.rb
index d4cb46c82ad..95491d63df2 100644
--- a/spec/services/projects/hashed_storage/rollback_attachments_service_spec.rb
+++ b/spec/services/projects/hashed_storage/rollback_attachments_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::HashedStorage::RollbackAttachmentsService do
+RSpec.describe Projects::HashedStorage::RollbackAttachmentsService, feature_category: :projects do
subject(:service) { described_class.new(project: project, old_disk_path: project.disk_path, logger: nil) }
let(:project) { create(:project, :repository, skip_disk_validation: true) }
diff --git a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
index 385c03e6308..19f1856e39a 100644
--- a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
+++ b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis_shared_state do
+RSpec.describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis_shared_state, feature_category: :projects do
let(:gitlab_shell) { Gitlab::Shell.new }
let(:project) { create(:project, :repository, :wiki_repo, :design_repo, storage_version: ::Project::HASHED_STORAGE_FEATURES[:repository]) }
let(:legacy_storage) { Storage::LegacyProject.new(project) }
diff --git a/spec/services/projects/hashed_storage/rollback_service_spec.rb b/spec/services/projects/hashed_storage/rollback_service_spec.rb
index 0bd63f2da2a..6d047f856ec 100644
--- a/spec/services/projects/hashed_storage/rollback_service_spec.rb
+++ b/spec/services/projects/hashed_storage/rollback_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::HashedStorage::RollbackService do
+RSpec.describe Projects::HashedStorage::RollbackService, feature_category: :projects do
let(:project) { create(:project, :empty_repo, :wiki_repo) }
let(:logger) { double }
let!(:project_attachment) { build(:file_uploader, project: project) }
diff --git a/spec/services/projects/import_error_filter_spec.rb b/spec/services/projects/import_error_filter_spec.rb
index fd31cd52cc4..be07208c7f2 100644
--- a/spec/services/projects/import_error_filter_spec.rb
+++ b/spec/services/projects/import_error_filter_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ImportErrorFilter do
+RSpec.describe Projects::ImportErrorFilter, feature_category: :importers do
it 'filters any full paths' do
message = 'Error importing into /my/folder Permission denied @ unlink_internal - /var/opt/gitlab/gitlab-rails/shared/a/b/c/uploads/file'
diff --git a/spec/services/projects/import_export/relation_export_service_spec.rb b/spec/services/projects/import_export/relation_export_service_spec.rb
index 94f5653ee7d..4b44a37b299 100644
--- a/spec/services/projects/import_export/relation_export_service_spec.rb
+++ b/spec/services/projects/import_export/relation_export_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ImportExport::RelationExportService do
+RSpec.describe Projects::ImportExport::RelationExportService, feature_category: :importers do
using RSpec::Parameterized::TableSyntax
subject(:service) { described_class.new(relation_export, 'jid') }
@@ -49,6 +49,7 @@ RSpec.describe Projects::ImportExport::RelationExportService do
expect(logger).to receive(:error).with(
export_error: '',
message: 'Project relation export failed',
+ relation: relation_export.relation,
project_export_job_id: project_export_job.id,
project_id: project_export_job.project.id,
project_name: project_export_job.project.name
@@ -78,6 +79,7 @@ RSpec.describe Projects::ImportExport::RelationExportService do
expect(logger).to receive(:error).with(
export_error: 'Error!',
message: 'Project relation export failed',
+ relation: relation_export.relation,
project_export_job_id: project_export_job.id,
project_id: project_export_job.project.id,
project_name: project_export_job.project.name
diff --git a/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb b/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb
index 4c51c8a4ac8..4ad6fd0edff 100644
--- a/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb
+++ b/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::InProductMarketingCampaignEmailsService do
+RSpec.describe Projects::InProductMarketingCampaignEmailsService, feature_category: :experimentation_adoption do
describe '#execute' do
let(:user) { create(:user, email_opted_in: true) }
let(:project) { create(:project) }
diff --git a/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb
index 80b3c4d0403..0aaaae19f5a 100644
--- a/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Projects::LfsPointers::LfsDownloadLinkListService do
+RSpec.describe Projects::LfsPointers::LfsDownloadLinkListService, feature_category: :source_code_management do
let(:import_url) { 'http://www.gitlab.com/demo/repo.git' }
let(:lfs_endpoint) { "#{import_url}/info/lfs/objects/batch" }
let!(:project) { create(:project, import_url: import_url) }
diff --git a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
index c815ad38843..00c156ba538 100644
--- a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Projects::LfsPointers::LfsDownloadService do
+RSpec.describe Projects::LfsPointers::LfsDownloadService, feature_category: :source_code_management do
include StubRequests
let_it_be(:project) { create(:project) }
diff --git a/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb
index 32b86ade81e..f1e4db55962 100644
--- a/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Projects::LfsPointers::LfsImportService do
+RSpec.describe Projects::LfsPointers::LfsImportService, feature_category: :source_code_management do
let(:project) { create(:project) }
let(:user) { project.creator }
let(:import_url) { 'http://www.gitlab.com/demo/repo.git' }
diff --git a/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb
index 0e7d16f18e8..fb3cc9bdac9 100644
--- a/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_link_service_spec.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Projects::LfsPointers::LfsLinkService do
- let!(:project) { create(:project, lfs_enabled: true) }
- let!(:lfs_objects_project) { create_list(:lfs_objects_project, 2, project: project) }
+RSpec.describe Projects::LfsPointers::LfsLinkService, feature_category: :source_code_management do
+ let_it_be(:project) { create(:project, lfs_enabled: true) }
+ let_it_be(:lfs_objects_project) { create_list(:lfs_objects_project, 2, project: project) }
+
let(:new_oids) { { 'oid1' => 123, 'oid2' => 125 } }
let(:all_oids) { LfsObject.pluck(:oid, :size).to_h.merge(new_oids) }
let(:new_lfs_object) { create(:lfs_object) }
@@ -17,12 +18,26 @@ RSpec.describe Projects::LfsPointers::LfsLinkService do
describe '#execute' do
it 'raises an error when trying to link too many objects at once' do
+ stub_const("#{described_class}::MAX_OIDS", 5)
+
oids = Array.new(described_class::MAX_OIDS) { |i| "oid-#{i}" }
oids << 'the straw'
expect { subject.execute(oids) }.to raise_error(described_class::TooManyOidsError)
end
+ it 'executes a block after validation and before execution' do
+ block = instance_double(Proc)
+
+ expect(subject).to receive(:validate!).ordered
+ expect(block).to receive(:call).ordered
+ expect(subject).to receive(:link_existing_lfs_objects).ordered
+
+ subject.execute([]) do
+ block.call
+ end
+ end
+
it 'links existing lfs objects to the project' do
expect(project.lfs_objects.count).to eq 2
@@ -41,13 +56,13 @@ RSpec.describe Projects::LfsPointers::LfsLinkService do
it 'links in batches' do
stub_const("#{described_class}::BATCH_SIZE", 3)
- expect(Gitlab::Import::Logger)
- .to receive(:info)
- .with(class: described_class.name,
- project_id: project.id,
- project_path: project.full_path,
- lfs_objects_linked_count: 7,
- iterations: 3)
+ expect(Gitlab::Import::Logger).to receive(:info).with(
+ class: described_class.name,
+ project_id: project.id,
+ project_path: project.full_path,
+ lfs_objects_linked_count: 7,
+ iterations: 3
+ )
lfs_objects = create_list(:lfs_object, 7)
linked = subject.execute(lfs_objects.pluck(:oid))
diff --git a/spec/services/projects/lfs_pointers/lfs_object_download_list_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_object_download_list_service_spec.rb
index 59eb1ed7a29..f5dcae05959 100644
--- a/spec/services/projects/lfs_pointers/lfs_object_download_list_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_object_download_list_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Projects::LfsPointers::LfsObjectDownloadListService do
+RSpec.describe Projects::LfsPointers::LfsObjectDownloadListService, feature_category: :source_code_management do
let(:import_url) { 'http://www.gitlab.com/demo/repo.git' }
let(:default_endpoint) { "#{import_url}/info/lfs/objects/batch" }
let(:group) { create(:group, lfs_enabled: true) }
diff --git a/spec/services/projects/move_access_service_spec.rb b/spec/services/projects/move_access_service_spec.rb
index 45e10c3ca84..b9244002f6c 100644
--- a/spec/services/projects/move_access_service_spec.rb
+++ b/spec/services/projects/move_access_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::MoveAccessService do
+RSpec.describe Projects::MoveAccessService, feature_category: :projects do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project_with_access) { create(:project, namespace: user.namespace) }
diff --git a/spec/services/projects/move_deploy_keys_projects_service_spec.rb b/spec/services/projects/move_deploy_keys_projects_service_spec.rb
index 59674a3a4ef..b40eb4a18d1 100644
--- a/spec/services/projects/move_deploy_keys_projects_service_spec.rb
+++ b/spec/services/projects/move_deploy_keys_projects_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::MoveDeployKeysProjectsService do
+RSpec.describe Projects::MoveDeployKeysProjectsService, feature_category: :continuous_delivery do
let!(:user) { create(:user) }
let!(:project_with_deploy_keys) { create(:project, namespace: user.namespace) }
let!(:target_project) { create(:project, namespace: user.namespace) }
diff --git a/spec/services/projects/move_forks_service_spec.rb b/spec/services/projects/move_forks_service_spec.rb
index 7d3637b7758..093562207dd 100644
--- a/spec/services/projects/move_forks_service_spec.rb
+++ b/spec/services/projects/move_forks_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::MoveForksService do
+RSpec.describe Projects::MoveForksService, feature_category: :source_code_management do
include ProjectForksHelper
let!(:user) { create(:user) }
diff --git a/spec/services/projects/move_lfs_objects_projects_service_spec.rb b/spec/services/projects/move_lfs_objects_projects_service_spec.rb
index e3df5fed9cf..f3cc4014b1c 100644
--- a/spec/services/projects/move_lfs_objects_projects_service_spec.rb
+++ b/spec/services/projects/move_lfs_objects_projects_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::MoveLfsObjectsProjectsService do
+RSpec.describe Projects::MoveLfsObjectsProjectsService, feature_category: :source_code_management do
let!(:user) { create(:user) }
let!(:project_with_lfs_objects) { create(:project, namespace: user.namespace) }
let!(:target_project) { create(:project, namespace: user.namespace) }
diff --git a/spec/services/projects/move_notification_settings_service_spec.rb b/spec/services/projects/move_notification_settings_service_spec.rb
index e381ae7590f..5ef6e8a0647 100644
--- a/spec/services/projects/move_notification_settings_service_spec.rb
+++ b/spec/services/projects/move_notification_settings_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::MoveNotificationSettingsService do
+RSpec.describe Projects::MoveNotificationSettingsService, feature_category: :projects do
let(:user) { create(:user) }
let(:project_with_notifications) { create(:project, namespace: user.namespace) }
let(:target_project) { create(:project, namespace: user.namespace) }
diff --git a/spec/services/projects/move_project_authorizations_service_spec.rb b/spec/services/projects/move_project_authorizations_service_spec.rb
index d47b13ca939..6cd0b056325 100644
--- a/spec/services/projects/move_project_authorizations_service_spec.rb
+++ b/spec/services/projects/move_project_authorizations_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::MoveProjectAuthorizationsService do
+RSpec.describe Projects::MoveProjectAuthorizationsService, feature_category: :projects do
let!(:user) { create(:user) }
let(:project_with_users) { create(:project, namespace: user.namespace) }
let(:target_project) { create(:project, namespace: user.namespace) }
diff --git a/spec/services/projects/move_project_group_links_service_spec.rb b/spec/services/projects/move_project_group_links_service_spec.rb
index 1fca96a0367..cfd4b51b001 100644
--- a/spec/services/projects/move_project_group_links_service_spec.rb
+++ b/spec/services/projects/move_project_group_links_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::MoveProjectGroupLinksService do
+RSpec.describe Projects::MoveProjectGroupLinksService, feature_category: :projects do
let!(:user) { create(:user) }
let(:project_with_groups) { create(:project, namespace: user.namespace) }
let(:target_project) { create(:project, namespace: user.namespace) }
diff --git a/spec/services/projects/move_project_members_service_spec.rb b/spec/services/projects/move_project_members_service_spec.rb
index 8fbd0ba3270..364fb7faaf2 100644
--- a/spec/services/projects/move_project_members_service_spec.rb
+++ b/spec/services/projects/move_project_members_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::MoveProjectMembersService do
+RSpec.describe Projects::MoveProjectMembersService, feature_category: :projects do
let!(:user) { create(:user) }
let(:project_with_users) { create(:project, namespace: user.namespace) }
let(:target_project) { create(:project, namespace: user.namespace) }
diff --git a/spec/services/projects/move_users_star_projects_service_spec.rb b/spec/services/projects/move_users_star_projects_service_spec.rb
index b580d3d8772..b99e51d954b 100644
--- a/spec/services/projects/move_users_star_projects_service_spec.rb
+++ b/spec/services/projects/move_users_star_projects_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::MoveUsersStarProjectsService do
+RSpec.describe Projects::MoveUsersStarProjectsService, feature_category: :projects do
let!(:user) { create(:user) }
let!(:project_with_stars) { create(:project, namespace: user.namespace) }
let!(:target_project) { create(:project, namespace: user.namespace) }
diff --git a/spec/services/projects/open_issues_count_service_spec.rb b/spec/services/projects/open_issues_count_service_spec.rb
index c739fea5ecf..89405f06f5d 100644
--- a/spec/services/projects/open_issues_count_service_spec.rb
+++ b/spec/services/projects/open_issues_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::OpenIssuesCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Projects::OpenIssuesCountService, :use_clean_rails_memory_store_caching, feature_category: :team_planning do
let(:project) { create(:project) }
subject { described_class.new(project) }
diff --git a/spec/services/projects/open_merge_requests_count_service_spec.rb b/spec/services/projects/open_merge_requests_count_service_spec.rb
index 6caef181e77..9d94fff2d20 100644
--- a/spec/services/projects/open_merge_requests_count_service_spec.rb
+++ b/spec/services/projects/open_merge_requests_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::OpenMergeRequestsCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Projects::OpenMergeRequestsCountService, :use_clean_rails_memory_store_caching, feature_category: :code_review_workflow do
let_it_be(:project) { create(:project) }
subject { described_class.new(project) }
@@ -11,10 +11,7 @@ RSpec.describe Projects::OpenMergeRequestsCountService, :use_clean_rails_memory_
describe '#count' do
it 'returns the number of open merge requests' do
- create(:merge_request,
- :opened,
- source_project: project,
- target_project: project)
+ create(:merge_request, :opened, source_project: project, target_project: project)
expect(subject.count).to eq(1)
end
diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb
index 95f2176dbc0..7babaf4d0d8 100644
--- a/spec/services/projects/operations/update_service_spec.rb
+++ b/spec/services/projects/operations/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::Operations::UpdateService do
+RSpec.describe Projects::Operations::UpdateService, feature_category: :projects do
let_it_be_with_refind(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/projects/overwrite_project_service_spec.rb b/spec/services/projects/overwrite_project_service_spec.rb
index 7038910508f..b4faf45a1cb 100644
--- a/spec/services/projects/overwrite_project_service_spec.rb
+++ b/spec/services/projects/overwrite_project_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::OverwriteProjectService do
+RSpec.describe Projects::OverwriteProjectService, feature_category: :projects do
include ProjectForksHelper
let(:user) { create(:user) }
diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb
index fc745cd669f..bd297343879 100644
--- a/spec/services/projects/participants_service_spec.rb
+++ b/spec/services/projects/participants_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ParticipantsService do
+RSpec.describe Projects::ParticipantsService, feature_category: :projects do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
diff --git a/spec/services/projects/prometheus/alerts/notify_service_spec.rb b/spec/services/projects/prometheus/alerts/notify_service_spec.rb
index 43d23023d83..0feac6c3e72 100644
--- a/spec/services/projects/prometheus/alerts/notify_service_spec.rb
+++ b/spec/services/projects/prometheus/alerts/notify_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::Prometheus::Alerts::NotifyService do
+RSpec.describe Projects::Prometheus::Alerts::NotifyService, feature_category: :metrics do
include PrometheusHelpers
using RSpec::Parameterized::TableSyntax
@@ -45,10 +45,8 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do
end
before do
- create(:clusters_integrations_prometheus,
- cluster: prd_cluster, alert_manager_token: token)
- create(:clusters_integrations_prometheus,
- cluster: stg_cluster, alert_manager_token: nil)
+ create(:clusters_integrations_prometheus, cluster: prd_cluster, alert_manager_token: token)
+ create(:clusters_integrations_prometheus, cluster: stg_cluster, alert_manager_token: nil)
end
context 'without token' do
@@ -78,10 +76,12 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do
cluster.update!(enabled: cluster_enabled)
unless integration_enabled.nil?
- create(:clusters_integrations_prometheus,
- cluster: cluster,
- enabled: integration_enabled,
- alert_manager_token: configured_token)
+ create(
+ :clusters_integrations_prometheus,
+ cluster: cluster,
+ enabled: integration_enabled,
+ alert_manager_token: configured_token
+ )
end
end
@@ -118,9 +118,11 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do
create(:prometheus_integration, project: project)
if alerting_setting
- create(:project_alerting_setting,
- project: project,
- token: configured_token)
+ create(
+ :project_alerting_setting,
+ project: project,
+ token: configured_token
+ )
end
end
diff --git a/spec/services/projects/prometheus/metrics/destroy_service_spec.rb b/spec/services/projects/prometheus/metrics/destroy_service_spec.rb
index b4af81f2c87..4c2a959a149 100644
--- a/spec/services/projects/prometheus/metrics/destroy_service_spec.rb
+++ b/spec/services/projects/prometheus/metrics/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::Prometheus::Metrics::DestroyService do
+RSpec.describe Projects::Prometheus::Metrics::DestroyService, feature_category: :metrics do
let(:metric) { create(:prometheus_metric) }
subject { described_class.new(metric) }
diff --git a/spec/services/projects/protect_default_branch_service_spec.rb b/spec/services/projects/protect_default_branch_service_spec.rb
index 9f9e89ff8f8..a4fdd9983b8 100644
--- a/spec/services/projects/protect_default_branch_service_spec.rb
+++ b/spec/services/projects/protect_default_branch_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ProtectDefaultBranchService do
+RSpec.describe Projects::ProtectDefaultBranchService, feature_category: :source_code_management do
let(:service) { described_class.new(project) }
let(:project) { create(:project) }
@@ -247,6 +247,7 @@ RSpec.describe Projects::ProtectDefaultBranchService do
context 'when feature flag `group_protected_branches` disabled' do
before do
stub_feature_flags(group_protected_branches: false)
+ stub_feature_flags(allow_protected_branches_for_group: false)
end
it 'return false' do
@@ -257,6 +258,7 @@ RSpec.describe Projects::ProtectDefaultBranchService do
context 'when feature flag `group_protected_branches` enabled' do
before do
stub_feature_flags(group_protected_branches: true)
+ stub_feature_flags(allow_protected_branches_for_group: true)
end
it 'return true' do
diff --git a/spec/services/projects/readme_renderer_service_spec.rb b/spec/services/projects/readme_renderer_service_spec.rb
index 14cdcf67640..842d75e82ee 100644
--- a/spec/services/projects/readme_renderer_service_spec.rb
+++ b/spec/services/projects/readme_renderer_service_spec.rb
@@ -2,14 +2,14 @@
require 'spec_helper'
-RSpec.describe Projects::ReadmeRendererService, '#execute' do
+RSpec.describe Projects::ReadmeRendererService, '#execute', feature_category: :projects do
using RSpec::Parameterized::TableSyntax
subject(:service) { described_class.new(project, nil, opts) }
let_it_be(:project) { create(:project, title: 'My Project', description: '_custom_description_') }
- let(:opts) { {} }
+ let(:opts) { { default_branch: 'master' } }
it 'renders the an ERB readme template' do
expect(service.execute).to start_with(<<~MARKDOWN)
diff --git a/spec/services/projects/record_target_platforms_service_spec.rb b/spec/services/projects/record_target_platforms_service_spec.rb
index 22ff325a62e..17aa7fd7009 100644
--- a/spec/services/projects/record_target_platforms_service_spec.rb
+++ b/spec/services/projects/record_target_platforms_service_spec.rb
@@ -2,41 +2,30 @@
require 'spec_helper'
-RSpec.describe Projects::RecordTargetPlatformsService, '#execute' do
+RSpec.describe Projects::RecordTargetPlatformsService, '#execute', feature_category: :projects do
let_it_be(:project) { create(:project) }
let(:detector_service) { Projects::AppleTargetPlatformDetectorService }
subject(:execute) { described_class.new(project, detector_service).execute }
- context 'when detector returns target platform values' do
- let(:detector_result) { [:ios, :osx] }
- let(:service_result) { detector_result.map(&:to_s) }
+ context 'when project is an XCode project' do
+ def project_setting
+ ProjectSetting.find_by_project_id(project.id)
+ end
before do
- double = instance_double(detector_service, execute: detector_result)
- allow(detector_service).to receive(:new) { double }
+ double = instance_double(detector_service, execute: [:ios, :osx])
+ allow(Projects::AppleTargetPlatformDetectorService).to receive(:new) { double }
end
- shared_examples 'saves and returns detected target platforms' do
- it 'creates a new setting record for the project', :aggregate_failures do
- expect { execute }.to change { ProjectSetting.count }.from(0).to(1)
- expect(ProjectSetting.last.target_platforms).to match_array(service_result)
- end
-
- it 'returns the array of stored target platforms' do
- expect(execute).to match_array service_result
- end
+ it 'creates a new setting record for the project', :aggregate_failures do
+ expect { execute }.to change { ProjectSetting.count }.from(0).to(1)
+ expect(ProjectSetting.last.target_platforms).to match_array(%w(ios osx))
end
- it_behaves_like 'saves and returns detected target platforms'
-
- context 'when detector returns a non-array value' do
- let(:detector_service) { Projects::AndroidTargetPlatformDetectorService }
- let(:detector_result) { :android }
- let(:service_result) { [detector_result.to_s] }
-
- it_behaves_like 'saves and returns detected target platforms'
+ it 'returns array of detected target platforms' do
+ expect(execute).to match_array %w(ios osx)
end
context 'when a project has an existing setting record' do
@@ -44,10 +33,6 @@ RSpec.describe Projects::RecordTargetPlatformsService, '#execute' do
create(:project_setting, project: project, target_platforms: saved_target_platforms)
end
- def project_setting
- ProjectSetting.find_by_project_id(project.id)
- end
-
context 'when target platforms changed' do
let(:saved_target_platforms) { %w(tvos) }
@@ -98,44 +83,23 @@ RSpec.describe Projects::RecordTargetPlatformsService, '#execute' do
it_behaves_like 'tracks experiment assignment event'
end
- shared_examples 'does not send email' do
- it 'does not execute a Projects::InProductMarketingCampaignEmailsService' do
- expect(Projects::InProductMarketingCampaignEmailsService).not_to receive(:new)
-
- execute
- end
- end
-
context 'experiment control' do
before do
stub_experiments(build_ios_app_guide_email: :control)
end
- it_behaves_like 'does not send email'
- it_behaves_like 'tracks experiment assignment event'
- end
-
- context 'when project is not an iOS project' do
- let(:detector_service) { Projects::AppleTargetPlatformDetectorService }
- let(:detector_result) { :android }
-
- before do
- stub_experiments(build_ios_app_guide_email: :candidate)
- end
-
- it_behaves_like 'does not send email'
-
- it 'does not track experiment assignment event', :experiment do
- expect(experiment(:build_ios_app_guide_email))
- .not_to track(:assignment)
+ it 'does not execute a Projects::InProductMarketingCampaignEmailsService' do
+ expect(Projects::InProductMarketingCampaignEmailsService).not_to receive(:new)
execute
end
+
+ it_behaves_like 'tracks experiment assignment event'
end
end
end
- context 'when detector does not return any target platform values' do
+ context 'when project is not an XCode project' do
before do
double = instance_double(Projects::AppleTargetPlatformDetectorService, execute: [])
allow(Projects::AppleTargetPlatformDetectorService).to receive(:new).with(project) { double }
diff --git a/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb b/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb
index 62330441d2f..591cd1cba8d 100644
--- a/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb
+++ b/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitlab_redis_shared_state do
+RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitlab_redis_shared_state, feature_category: :build_artifacts do
let(:service) { described_class.new }
describe '#execute' do
diff --git a/spec/services/projects/repository_languages_service_spec.rb b/spec/services/projects/repository_languages_service_spec.rb
index 50d5fba6b84..a02844309b2 100644
--- a/spec/services/projects/repository_languages_service_spec.rb
+++ b/spec/services/projects/repository_languages_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::RepositoryLanguagesService do
+RSpec.describe Projects::RepositoryLanguagesService, feature_category: :source_code_management do
let(:service) { described_class.new(project, project.first_owner) }
context 'when detected_repository_languages flag is set' do
diff --git a/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb b/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb
index 76830396104..3d89f6efa6f 100644
--- a/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb
+++ b/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ScheduleBulkRepositoryShardMovesService do
+RSpec.describe Projects::ScheduleBulkRepositoryShardMovesService, feature_category: :source_code_management do
it_behaves_like 'moves repository shard in bulk' do
let_it_be_with_reload(:container) { create(:project, :repository) }
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index 32818535146..48d5935f22f 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::TransferService do
+RSpec.describe Projects::TransferService, feature_category: :projects do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let_it_be(:group_integration) { create(:integrations_slack, :group, group: group, webhook: 'http://group.slack.com') }
@@ -20,12 +20,32 @@ RSpec.describe Projects::TransferService do
subject(:transfer_service) { described_class.new(project, user) }
- let!(:package) { create(:npm_package, project: project) }
+ let!(:package) { create(:npm_package, project: project, name: "@testscope/test") }
context 'with a root namespace change' do
+ it 'allow the transfer' do
+ expect(transfer_service.execute(group)).to be true
+ expect(project.errors[:new_namespace]).to be_empty
+ end
+ end
+
+ context 'with pending destruction package' do
+ before do
+ package.pending_destruction!
+ end
+
+ it 'allow the transfer' do
+ expect(transfer_service.execute(group)).to be true
+ expect(project.errors[:new_namespace]).to be_empty
+ end
+ end
+
+ context 'with namespaced packages present' do
+ let!(:package) { create(:npm_package, project: project, name: "@#{project.root_namespace.path}/test") }
+
it 'does not allow the transfer' do
expect(transfer_service.execute(group)).to be false
- expect(project.errors[:new_namespace]).to include("Root namespace can't be updated if project has NPM packages")
+ expect(project.errors[:new_namespace]).to include("Root namespace can't be updated if the project has NPM packages scoped to the current root level namespace.")
end
end
@@ -39,7 +59,7 @@ RSpec.describe Projects::TransferService do
other_group.add_owner(user)
end
- it 'does allow the transfer' do
+ it 'allow the transfer' do
expect(transfer_service.execute(other_group)).to be true
expect(project.errors[:new_namespace]).to be_empty
end
@@ -667,10 +687,11 @@ RSpec.describe Projects::TransferService do
user_ids = [user.id, member_of_old_group.id, member_of_new_group.id].map { |id| [id] }
expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in)
- .with(1.hour,
- user_ids,
- batch_delay: 30.seconds, batch_size: 100)
+ receive(:bulk_perform_in).with(
+ 1.hour,
+ user_ids,
+ batch_delay: 30.seconds, batch_size: 100
+ )
)
subject
@@ -694,10 +715,15 @@ RSpec.describe Projects::TransferService do
project.design_repository
end
+ def clear_design_repo_memoization
+ project.design_management_repository.clear_memoization(:repository)
+ project.clear_memoization(:design_repository)
+ end
+
it 'does not create a design repository' do
expect(subject.execute(group)).to be true
- project.clear_memoization(:design_repository)
+ clear_design_repo_memoization
expect(design_repository.exists?).to be false
end
@@ -713,7 +739,7 @@ RSpec.describe Projects::TransferService do
it 'moves the repository' do
expect(subject.execute(group)).to be true
- project.clear_memoization(:design_repository)
+ clear_design_repo_memoization
expect(design_repository).to have_attributes(
disk_path: new_full_path,
@@ -725,7 +751,7 @@ RSpec.describe Projects::TransferService do
allow(subject).to receive(:execute_system_hooks).and_raise('foo')
expect { subject.execute(group) }.to raise_error('foo')
- project.clear_memoization(:design_repository)
+ clear_design_repo_memoization
expect(design_repository).to have_attributes(
disk_path: old_full_path,
@@ -742,7 +768,7 @@ RSpec.describe Projects::TransferService do
expect(subject.execute(group)).to be true
- project.clear_memoization(:design_repository)
+ clear_design_repo_memoization
expect(design_repository).to have_attributes(
disk_path: old_disk_path,
@@ -756,7 +782,7 @@ RSpec.describe Projects::TransferService do
allow(subject).to receive(:execute_system_hooks).and_raise('foo')
expect { subject.execute(group) }.to raise_error('foo')
- project.clear_memoization(:design_repository)
+ clear_design_repo_memoization
expect(design_repository).to have_attributes(
disk_path: old_disk_path,
diff --git a/spec/services/projects/unlink_fork_service_spec.rb b/spec/services/projects/unlink_fork_service_spec.rb
index d939a79b7e9..872e38aba1d 100644
--- a/spec/services/projects/unlink_fork_service_spec.rb
+++ b/spec/services/projects/unlink_fork_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::UnlinkForkService, :use_clean_rails_memory_store_caching do
+RSpec.describe Projects::UnlinkForkService, :use_clean_rails_memory_store_caching, feature_category: :source_code_management do
include ProjectForksHelper
subject { described_class.new(forked_project, user) }
@@ -116,8 +116,10 @@ RSpec.describe Projects::UnlinkForkService, :use_clean_rails_memory_store_cachin
expect(project.fork_network_member).to be_nil
expect(project.fork_network).to be_nil
- expect(forked_project.fork_network).to have_attributes(root_project_id: nil,
- deleted_root_project_name: project.full_name)
+ expect(forked_project.fork_network).to have_attributes(
+ root_project_id: nil,
+ deleted_root_project_name: project.full_name
+ )
expect(project.forked_to_members.count).to eq(0)
expect(forked_project.forked_to_members.count).to eq(1)
expect(fork_of_fork.forked_to_members.count).to eq(0)
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index d908a169898..a97369c4b08 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -2,19 +2,22 @@
require "spec_helper"
-RSpec.describe Projects::UpdatePagesService do
+RSpec.describe Projects::UpdatePagesService, feature_category: :pages do
let_it_be(:project, refind: true) { create(:project, :repository) }
let_it_be(:old_pipeline) { create(:ci_pipeline, project: project, sha: project.commit('HEAD').sha) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit('HEAD').sha) }
- let(:build) { create(:ci_build, pipeline: pipeline, ref: 'HEAD') }
+ let(:options) { {} }
+ let(:build) { create(:ci_build, pipeline: pipeline, ref: 'HEAD', options: options) }
let(:invalid_file) { fixture_file_upload('spec/fixtures/dk.png') }
let(:file) { fixture_file_upload("spec/fixtures/pages.zip") }
+ let(:custom_root_file) { fixture_file_upload("spec/fixtures/pages_with_custom_root.zip") }
let(:empty_file) { fixture_file_upload("spec/fixtures/pages_empty.zip") }
let(:empty_metadata_filename) { "spec/fixtures/pages_empty.zip.meta" }
let(:metadata_filename) { "spec/fixtures/pages.zip.meta" }
+ let(:custom_root_file_metadata) { "spec/fixtures/pages_with_custom_root.zip.meta" }
let(:metadata) { fixture_file_upload(metadata_filename) if File.exist?(metadata_filename) }
subject { described_class.new(project, build) }
@@ -97,6 +100,7 @@ RSpec.describe Projects::UpdatePagesService do
expect(deployment.file_sha256).to eq(artifacts_archive.file_sha256)
expect(project.pages_metadatum.reload.pages_deployment_id).to eq(deployment.id)
expect(deployment.ci_build_id).to eq(build.id)
+ expect(deployment.root_directory).to be_nil
end
it 'does not fail if pages_metadata is absent' do
@@ -116,9 +120,11 @@ RSpec.describe Projects::UpdatePagesService do
it 'schedules a destruction of older deployments' do
expect(DestroyPagesDeploymentsWorker).to(
- receive(:perform_in).with(described_class::OLD_DEPLOYMENTS_DESTRUCTION_DELAY,
- project.id,
- instance_of(Integer))
+ receive(:perform_in).with(
+ described_class::OLD_DEPLOYMENTS_DESTRUCTION_DELAY,
+ project.id,
+ instance_of(Integer)
+ )
)
execute
@@ -140,7 +146,45 @@ RSpec.describe Projects::UpdatePagesService do
it 'returns an error' do
expect(execute).not_to eq(:success)
- expect(GenericCommitStatus.last.description).to eq("Error: The `public/` folder is missing, or not declared in `.gitlab-ci.yml`.")
+ expect(GenericCommitStatus.last.description).to eq("Error: You need to either include a `public/` folder in your artifacts, or specify which one to use for Pages using `publish` in `.gitlab-ci.yml`")
+ end
+ end
+
+ context 'when there is a custom root config' do
+ let(:file) { custom_root_file }
+ let(:metadata_filename) { custom_root_file_metadata }
+
+ context 'when the directory specified with `publish` is included in the artifacts' do
+ let(:options) { { publish: 'foo' } }
+
+ it 'creates pages_deployment and saves it in the metadata' do
+ expect(execute).to eq(:success)
+
+ deployment = project.pages_deployments.last
+ expect(deployment.root_directory).to eq(options[:publish])
+ end
+ end
+
+ context 'when the directory specified with `publish` is not included in the artifacts' do
+ let(:options) { { publish: 'bar' } }
+
+ it 'returns an error' do
+ expect(execute).not_to eq(:success)
+
+ expect(GenericCommitStatus.last.description).to eq("Error: You need to either include a `public/` folder in your artifacts, or specify which one to use for Pages using `publish` in `.gitlab-ci.yml`")
+ end
+ end
+
+ context 'when there is a folder named `public`, but `publish` specifies a different one' do
+ let(:options) { { publish: 'foo' } }
+ let(:file) { fixture_file_upload("spec/fixtures/pages.zip") }
+ let(:metadata_filename) { "spec/fixtures/pages.zip.meta" }
+
+ it 'returns an error' do
+ expect(execute).not_to eq(:success)
+
+ expect(GenericCommitStatus.last.description).to eq("Error: You need to either include a `public/` folder in your artifacts, or specify which one to use for Pages using `publish` in `.gitlab-ci.yml`")
+ end
end
end
@@ -322,10 +366,14 @@ RSpec.describe Projects::UpdatePagesService do
context 'when retrying the job' do
let(:stage) { create(:ci_stage, position: 1_000_000, name: 'deploy', pipeline: pipeline) }
let!(:older_deploy_job) do
- create(:generic_commit_status, :failed, pipeline: pipeline,
- ref: build.ref,
- ci_stage: stage,
- name: 'pages:deploy')
+ create(
+ :generic_commit_status,
+ :failed,
+ pipeline: pipeline,
+ ref: build.ref,
+ ci_stage: stage,
+ name: 'pages:deploy'
+ )
end
before do
diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb
index 547641867bc..b65f7a50e4c 100644
--- a/spec/services/projects/update_remote_mirror_service_spec.rb
+++ b/spec/services/projects/update_remote_mirror_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::UpdateRemoteMirrorService do
+RSpec.describe Projects::UpdateRemoteMirrorService, feature_category: :source_code_management do
let_it_be(:project) { create(:project, :repository, lfs_enabled: true) }
let_it_be(:remote_project) { create(:forked_project_with_submodules) }
let_it_be(:remote_mirror) { create(:remote_mirror, project: project, enabled: true) }
diff --git a/spec/services/projects/update_repository_storage_service_spec.rb b/spec/services/projects/update_repository_storage_service_spec.rb
index ee8f7fb2ef2..af920d51776 100644
--- a/spec/services/projects/update_repository_storage_service_spec.rb
+++ b/spec/services/projects/update_repository_storage_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::UpdateRepositoryStorageService do
+RSpec.describe Projects::UpdateRepositoryStorageService, feature_category: :source_code_management do
include Gitlab::ShellAdapter
subject { described_class.new(repository_storage_move) }
diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb
index 3cda6bc2627..8f55ee705ab 100644
--- a/spec/services/projects/update_service_spec.rb
+++ b/spec/services/projects/update_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Projects::UpdateService do
+RSpec.describe Projects::UpdateService, feature_category: :projects do
include ExternalAuthorizationServiceHelpers
include ProjectForksHelper
@@ -227,48 +227,16 @@ RSpec.describe Projects::UpdateService do
let(:user) { project.first_owner }
let(:forked_project) { fork_project(project) }
- context 'and unlink forks feature flag is off' do
- before do
- stub_feature_flags(unlink_fork_network_upon_visibility_decrease: false)
- end
-
- it 'updates forks visibility level when parent set to more restrictive' do
- opts = { visibility_level: Gitlab::VisibilityLevel::PRIVATE }
-
- expect(project).to be_internal
- expect(forked_project).to be_internal
-
- expect(update_project(project, user, opts)).to eq({ status: :success })
+ it 'does not change visibility of forks' do
+ opts = { visibility_level: Gitlab::VisibilityLevel::PRIVATE }
- expect(project).to be_private
- expect(forked_project.reload).to be_private
- end
-
- it 'does not update forks visibility level when parent set to less restrictive' do
- opts = { visibility_level: Gitlab::VisibilityLevel::PUBLIC }
-
- expect(project).to be_internal
- expect(forked_project).to be_internal
+ expect(project).to be_internal
+ expect(forked_project).to be_internal
- expect(update_project(project, user, opts)).to eq({ status: :success })
+ expect(update_project(project, user, opts)).to eq({ status: :success })
- expect(project).to be_public
- expect(forked_project.reload).to be_internal
- end
- end
-
- context 'and unlink forks feature flag is on' do
- it 'does not change visibility of forks' do
- opts = { visibility_level: Gitlab::VisibilityLevel::PRIVATE }
-
- expect(project).to be_internal
- expect(forked_project).to be_internal
-
- expect(update_project(project, user, opts)).to eq({ status: :success })
-
- expect(project).to be_private
- expect(forked_project.reload).to be_internal
- end
+ expect(project).to be_private
+ expect(forked_project.reload).to be_internal
end
end
@@ -358,7 +326,9 @@ RSpec.describe Projects::UpdateService do
it 'logs an error and creates a metric when wiki can not be created' do
project.project_feature.update!(wiki_access_level: ProjectFeature::DISABLED)
- expect_any_instance_of(ProjectWiki).to receive(:create_wiki_repository).and_raise(Wiki::CouldNotCreateWikiError)
+ expect_next_instance_of(ProjectWiki) do |project_wiki|
+ expect(project_wiki).to receive(:create_wiki_repository).and_raise(Wiki::CouldNotCreateWikiError)
+ end
expect_any_instance_of(described_class).to receive(:log_error).with("Could not create wiki for #{project.full_name}")
counter = double(:counter)
@@ -387,24 +357,26 @@ RSpec.describe Projects::UpdateService do
# Using some sample features for testing.
# Not using all the features because some of them must be enabled/disabled together
%w[issues wiki forking].each do |feature_name|
- let(:feature) { "#{feature_name}_access_level" }
- let(:params) do
- { project_feature_attributes: { feature => ProjectFeature::ENABLED } }
- end
+ context "with feature_name:#{feature_name}" do
+ let(:feature) { "#{feature_name}_access_level" }
+ let(:params) do
+ { project_feature_attributes: { feature => ProjectFeature::ENABLED } }
+ end
- before do
- project.project_feature.update!(feature => ProjectFeature::DISABLED)
- end
+ before do
+ project.project_feature.update!(feature => ProjectFeature::DISABLED)
+ end
- it 'publishes Projects::ProjectFeaturesChangedEvent' do
- expect { update_project(project, user, params) }
- .to publish_event(Projects::ProjectFeaturesChangedEvent)
- .with(
- project_id: project.id,
- namespace_id: project.namespace_id,
- root_namespace_id: project.root_namespace.id,
- features: ["updated_at", feature]
- )
+ it 'publishes Projects::ProjectFeaturesChangedEvent' do
+ expect { update_project(project, user, params) }
+ .to publish_event(Projects::ProjectFeaturesChangedEvent)
+ .with(
+ project_id: project.id,
+ namespace_id: project.namespace_id,
+ root_namespace_id: project.root_namespace.id,
+ features: array_including(feature, "updated_at")
+ )
+ end
end
end
end
@@ -548,6 +520,25 @@ RSpec.describe Projects::UpdateService do
end
end
+ context 'when updating #runner_registration_enabled' do
+ it 'updates the attribute' do
+ expect { update_project(project, user, runner_registration_enabled: false) }
+ .to change { project.runner_registration_enabled }
+ .to(false)
+ end
+
+ context 'when runner registration is disabled for all projects' do
+ before do
+ stub_application_setting(valid_runner_registrars: [])
+ end
+
+ it 'restricts updating the attribute' do
+ expect { update_project(project, user, runner_registration_enabled: false) }
+ .not_to change { project.runner_registration_enabled }
+ end
+ end
+ end
+
context 'when updating runners settings' do
let(:settings) do
{ instance_runners_enabled: true, namespace_traversal_ids: [123] }
@@ -653,17 +644,19 @@ RSpec.describe Projects::UpdateService do
context 'when updating nested attributes for prometheus integration' do
context 'prometheus integration exists' do
let(:prometheus_integration_attributes) do
- attributes_for(:prometheus_integration,
- project: project,
- properties: { api_url: "http://new.prometheus.com", manual_configuration: "0" }
- )
+ attributes_for(
+ :prometheus_integration,
+ project: project,
+ properties: { api_url: "http://new.prometheus.com", manual_configuration: "0" }
+ )
end
let!(:prometheus_integration) do
- create(:prometheus_integration,
- project: project,
- properties: { api_url: "http://old.prometheus.com", manual_configuration: "0" }
- )
+ create(
+ :prometheus_integration,
+ project: project,
+ properties: { api_url: "http://old.prometheus.com", manual_configuration: "0" }
+ )
end
it 'updates existing record' do
@@ -677,10 +670,11 @@ RSpec.describe Projects::UpdateService do
context 'prometheus integration does not exist' do
context 'valid parameters' do
let(:prometheus_integration_attributes) do
- attributes_for(:prometheus_integration,
- project: project,
- properties: { api_url: "http://example.prometheus.com", manual_configuration: "0" }
- )
+ attributes_for(
+ :prometheus_integration,
+ project: project,
+ properties: { api_url: "http://example.prometheus.com", manual_configuration: "0" }
+ )
end
it 'creates new record' do
@@ -693,10 +687,11 @@ RSpec.describe Projects::UpdateService do
context 'invalid parameters' do
let(:prometheus_integration_attributes) do
- attributes_for(:prometheus_integration,
- project: project,
- properties: { api_url: nil, manual_configuration: "1" }
- )
+ attributes_for(
+ :prometheus_integration,
+ project: project,
+ properties: { api_url: nil, manual_configuration: "1" }
+ )
end
it 'does not create new record' do
@@ -794,6 +789,112 @@ RSpec.describe Projects::UpdateService do
expect(project.topic_list).to eq(%w[tag_list])
end
end
+
+ describe 'when updating pages unique domain', feature_category: :pages do
+ let(:group) { create(:group, path: 'group') }
+ let(:project) { create(:project, path: 'project', group: group) }
+
+ context 'with pages_unique_domain feature flag disabled' do
+ before do
+ stub_feature_flags(pages_unique_domain: false)
+ end
+
+ it 'does not change pages unique domain' do
+ expect(project)
+ .to receive(:update)
+ .with({ project_setting_attributes: { has_confluence: true } })
+ .and_call_original
+
+ expect do
+ update_project(project, user, project_setting_attributes: {
+ has_confluence: true,
+ pages_unique_domain_enabled: true
+ })
+ end.not_to change { project.project_setting.pages_unique_domain_enabled }
+ end
+
+ it 'does not remove other attributes' do
+ expect(project)
+ .to receive(:update)
+ .with({ name: 'True' })
+ .and_call_original
+
+ update_project(project, user, name: 'True')
+ end
+ end
+
+ context 'with pages_unique_domain feature flag enabled' do
+ before do
+ stub_feature_flags(pages_unique_domain: true)
+ end
+
+ it 'updates project pages unique domain' do
+ expect do
+ update_project(project, user, project_setting_attributes: {
+ pages_unique_domain_enabled: true
+ })
+ end.to change { project.project_setting.pages_unique_domain_enabled }
+
+ expect(project.project_setting.pages_unique_domain_enabled).to eq true
+ expect(project.project_setting.pages_unique_domain).to match %r{project-group-\w+}
+ end
+
+ it 'does not changes unique domain when it already exists' do
+ project.project_setting.update!(
+ pages_unique_domain_enabled: false,
+ pages_unique_domain: 'unique-domain'
+ )
+
+ expect do
+ update_project(project, user, project_setting_attributes: {
+ pages_unique_domain_enabled: true
+ })
+ end.to change { project.project_setting.pages_unique_domain_enabled }
+
+ expect(project.project_setting.pages_unique_domain_enabled).to eq true
+ expect(project.project_setting.pages_unique_domain).to eq 'unique-domain'
+ end
+
+ it 'does not changes unique domain when it disabling unique domain' do
+ project.project_setting.update!(
+ pages_unique_domain_enabled: true,
+ pages_unique_domain: 'unique-domain'
+ )
+
+ expect do
+ update_project(project, user, project_setting_attributes: {
+ pages_unique_domain_enabled: false
+ })
+ end.not_to change { project.project_setting.pages_unique_domain }
+
+ expect(project.project_setting.pages_unique_domain_enabled).to eq false
+ expect(project.project_setting.pages_unique_domain).to eq 'unique-domain'
+ end
+
+ context 'when there is another project with the unique domain' do
+ it 'fails pages unique domain already exists' do
+ create(
+ :project_setting,
+ pages_unique_domain_enabled: true,
+ pages_unique_domain: 'unique-domain'
+ )
+
+ allow(Gitlab::Pages::RandomDomain)
+ .to receive(:generate)
+ .and_return('unique-domain')
+
+ result = update_project(project, user, project_setting_attributes: {
+ pages_unique_domain_enabled: true
+ })
+
+ expect(result).to eq(
+ status: :error,
+ message: 'Project setting pages unique domain has already been taken'
+ )
+ end
+ end
+ end
+ end
end
describe '#run_auto_devops_pipeline?' do
diff --git a/spec/services/projects/update_statistics_service_spec.rb b/spec/services/projects/update_statistics_service_spec.rb
index 1cc69e7e2fe..f685b86acc0 100644
--- a/spec/services/projects/update_statistics_service_spec.rb
+++ b/spec/services/projects/update_statistics_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::UpdateStatisticsService do
+RSpec.describe Projects::UpdateStatisticsService, feature_category: :projects do
using RSpec::Parameterized::TableSyntax
let(:service) { described_class.new(project, nil, statistics: statistics) }
@@ -27,7 +27,7 @@ RSpec.describe Projects::UpdateStatisticsService do
['repository_size'] | [:size]
[:repository_size] | [:size]
[:lfs_objects_size] | nil
- [:commit_count] | [:commit_count] # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
+ [:commit_count] | [:commit_count]
[:repository_size, :commit_count] | %i(size commit_count)
[:repository_size, :commit_count, :lfs_objects_size] | %i(size commit_count)
end
diff --git a/spec/services/prometheus/proxy_service_spec.rb b/spec/services/prometheus/proxy_service_spec.rb
index b78683cace7..f71662f62ad 100644
--- a/spec/services/prometheus/proxy_service_spec.rb
+++ b/spec/services/prometheus/proxy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Prometheus::ProxyService do
+RSpec.describe Prometheus::ProxyService, feature_category: :metrics do
include ReactiveCachingHelpers
let_it_be(:project) { create(:project) }
diff --git a/spec/services/prometheus/proxy_variable_substitution_service_spec.rb b/spec/services/prometheus/proxy_variable_substitution_service_spec.rb
index d8c1fdffb98..fbee4b9c7d7 100644
--- a/spec/services/prometheus/proxy_variable_substitution_service_spec.rb
+++ b/spec/services/prometheus/proxy_variable_substitution_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Prometheus::ProxyVariableSubstitutionService do
+RSpec.describe Prometheus::ProxyVariableSubstitutionService, feature_category: :metrics do
describe '#execute' do
let_it_be(:environment) { create(:environment) }
diff --git a/spec/services/protected_branches/api_service_spec.rb b/spec/services/protected_branches/api_service_spec.rb
index c98e253267b..f7f5f451a49 100644
--- a/spec/services/protected_branches/api_service_spec.rb
+++ b/spec/services/protected_branches/api_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProtectedBranches::ApiService do
+RSpec.describe ProtectedBranches::ApiService, feature_category: :compliance_management do
shared_examples 'execute with entity' do
it 'creates a protected branch with prefilled defaults' do
expect(::ProtectedBranches::CreateService).to receive(:new).with(
diff --git a/spec/services/protected_branches/cache_service_spec.rb b/spec/services/protected_branches/cache_service_spec.rb
index ea434922661..0abf8a673f9 100644
--- a/spec/services/protected_branches/cache_service_spec.rb
+++ b/spec/services/protected_branches/cache_service_spec.rb
@@ -3,7 +3,7 @@
#
require 'spec_helper'
-RSpec.describe ProtectedBranches::CacheService, :clean_gitlab_redis_cache do
+RSpec.describe ProtectedBranches::CacheService, :clean_gitlab_redis_cache, feature_category: :compliance_management do
shared_examples 'execute with entity' do
subject(:service) { described_class.new(entity, user) }
@@ -145,6 +145,7 @@ RSpec.describe ProtectedBranches::CacheService, :clean_gitlab_redis_cache do
context 'when feature flag disabled' do
before do
stub_feature_flags(group_protected_branches: false)
+ stub_feature_flags(allow_protected_branches_for_group: false)
end
it_behaves_like 'execute with entity'
diff --git a/spec/services/protected_branches/destroy_service_spec.rb b/spec/services/protected_branches/destroy_service_spec.rb
index 421d4aae5bb..e02b4475c02 100644
--- a/spec/services/protected_branches/destroy_service_spec.rb
+++ b/spec/services/protected_branches/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProtectedBranches::DestroyService do
+RSpec.describe ProtectedBranches::DestroyService, feature_category: :compliance_management do
shared_examples 'execute with entity' do
subject(:service) { described_class.new(entity, user) }
diff --git a/spec/services/protected_branches/update_service_spec.rb b/spec/services/protected_branches/update_service_spec.rb
index c70cc032a6a..8b11604aa15 100644
--- a/spec/services/protected_branches/update_service_spec.rb
+++ b/spec/services/protected_branches/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProtectedBranches::UpdateService do
+RSpec.describe ProtectedBranches::UpdateService, feature_category: :compliance_management do
shared_examples 'execute with entity' do
let(:params) { { name: new_name } }
diff --git a/spec/services/protected_tags/create_service_spec.rb b/spec/services/protected_tags/create_service_spec.rb
index a0b99b595e3..78b14aae029 100644
--- a/spec/services/protected_tags/create_service_spec.rb
+++ b/spec/services/protected_tags/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProtectedTags::CreateService do
+RSpec.describe ProtectedTags::CreateService, feature_category: :compliance_management do
let(:project) { create(:project) }
let(:user) { project.first_owner }
let(:params) do
diff --git a/spec/services/protected_tags/destroy_service_spec.rb b/spec/services/protected_tags/destroy_service_spec.rb
index 658a4f5557e..fcb30d39520 100644
--- a/spec/services/protected_tags/destroy_service_spec.rb
+++ b/spec/services/protected_tags/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProtectedTags::DestroyService do
+RSpec.describe ProtectedTags::DestroyService, feature_category: :compliance_management do
let(:protected_tag) { create(:protected_tag) }
let(:project) { protected_tag.project }
let(:user) { project.first_owner }
diff --git a/spec/services/protected_tags/update_service_spec.rb b/spec/services/protected_tags/update_service_spec.rb
index 4b6e726bb6e..2fb6cf84719 100644
--- a/spec/services/protected_tags/update_service_spec.rb
+++ b/spec/services/protected_tags/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProtectedTags::UpdateService do
+RSpec.describe ProtectedTags::UpdateService, feature_category: :compliance_management do
let(:protected_tag) { create(:protected_tag) }
let(:project) { protected_tag.project }
let(:user) { project.first_owner }
diff --git a/spec/services/push_event_payload_service_spec.rb b/spec/services/push_event_payload_service_spec.rb
index de2bec21a3c..50da5ca9b24 100644
--- a/spec/services/push_event_payload_service_spec.rb
+++ b/spec/services/push_event_payload_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PushEventPayloadService do
+RSpec.describe PushEventPayloadService, feature_category: :source_code_management do
let(:event) { create(:push_event) }
describe '#execute' do
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index 257e7eb972b..966782aca98 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -746,10 +746,10 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning
it 'assigns to users with escaped underscores' do
user = create(:user)
base = user.username
- user.update!(username: "#{base}_")
+ user.update!(username: "#{base}_new")
issuable.project.add_developer(user)
- cmd = "/assign @#{base}\\_"
+ cmd = "/assign @#{base}\\_new"
_, updates, _ = service.execute(cmd, issuable)
@@ -1399,34 +1399,11 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning
let(:issuable) { issue }
end
- # /draft is a toggle (ff disabled)
- it_behaves_like 'draft command' do
- let(:content) { '/draft' }
- let(:issuable) { merge_request }
-
- before do
- stub_feature_flags(draft_quick_action_non_toggle: false)
- end
- end
-
- # /draft is a toggle (ff disabled)
- it_behaves_like 'ready command' do
- let(:content) { '/draft' }
- let(:issuable) { merge_request }
-
- before do
- stub_feature_flags(draft_quick_action_non_toggle: false)
- issuable.update!(title: issuable.draft_title)
- end
- end
-
- # /draft is one way (ff enabled)
it_behaves_like 'draft command' do
let(:content) { '/draft' }
let(:issuable) { merge_request }
end
- # /draft is one way (ff enabled)
it_behaves_like 'draft/ready command no action' do
let(:content) { '/draft' }
let(:issuable) { merge_request }
@@ -2150,116 +2127,8 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning
end
end
- context 'relate command' do
- let_it_be_with_refind(:group) { create(:group) }
-
- shared_examples 'relate command' do
- it 'relates issues' do
- service.execute(content, issue)
-
- expect(IssueLink.where(source: issue).map(&:target)).to match_array(issues_related)
- end
- end
-
- context 'user is member of group' do
- before do
- group.add_developer(developer)
- end
-
- context 'relate a single issue' do
- let(:other_issue) { create(:issue, project: project) }
- let(:issues_related) { [other_issue] }
- let(:content) { "/relate #{other_issue.to_reference}" }
-
- it_behaves_like 'relate command'
- end
-
- context 'relate multiple issues at once' do
- let(:second_issue) { create(:issue, project: project) }
- let(:third_issue) { create(:issue, project: project) }
- let(:issues_related) { [second_issue, third_issue] }
- let(:content) { "/relate #{second_issue.to_reference} #{third_issue.to_reference}" }
-
- it_behaves_like 'relate command'
- end
-
- context 'when quick action target is unpersisted' do
- let(:issue) { build(:issue, project: project) }
- let(:other_issue) { create(:issue, project: project) }
- let(:issues_related) { [other_issue] }
- let(:content) { "/relate #{other_issue.to_reference}" }
-
- it 'relates the issues after the issue is persisted' do
- service.execute(content, issue)
-
- issue.save!
-
- expect(IssueLink.where(source: issue).map(&:target)).to match_array(issues_related)
- end
- end
-
- context 'empty relate command' do
- let(:issues_related) { [] }
- let(:content) { '/relate' }
-
- it_behaves_like 'relate command'
- end
-
- context 'already having related issues' do
- let(:second_issue) { create(:issue, project: project) }
- let(:third_issue) { create(:issue, project: project) }
- let(:issues_related) { [second_issue, third_issue] }
- let(:content) { "/relate #{third_issue.to_reference(project)}" }
-
- before do
- create(:issue_link, source: issue, target: second_issue)
- end
-
- it_behaves_like 'relate command'
- end
-
- context 'cross project' do
- let(:another_group) { create(:group, :public) }
- let(:other_project) { create(:project, group: another_group) }
-
- before do
- another_group.add_developer(developer)
- end
-
- context 'relate a cross project issue' do
- let(:other_issue) { create(:issue, project: other_project) }
- let(:issues_related) { [other_issue] }
- let(:content) { "/relate #{other_issue.to_reference(project)}" }
-
- it_behaves_like 'relate command'
- end
-
- context 'relate multiple cross projects issues at once' do
- let(:second_issue) { create(:issue, project: other_project) }
- let(:third_issue) { create(:issue, project: other_project) }
- let(:issues_related) { [second_issue, third_issue] }
- let(:content) { "/relate #{second_issue.to_reference(project)} #{third_issue.to_reference(project)}" }
-
- it_behaves_like 'relate command'
- end
-
- context 'relate a non-existing issue' do
- let(:issues_related) { [] }
- let(:content) { "/relate imaginary##{non_existing_record_iid}" }
-
- it_behaves_like 'relate command'
- end
-
- context 'relate a private issue' do
- let(:private_project) { create(:project, :private) }
- let(:other_issue) { create(:issue, project: private_project) }
- let(:issues_related) { [] }
- let(:content) { "/relate #{other_issue.to_reference(project)}" }
-
- it_behaves_like 'relate command'
- end
- end
- end
+ it_behaves_like 'issues link quick action', :relate do
+ let(:user) { developer }
end
context 'invite_email command' do
@@ -2507,6 +2376,55 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning
expect(message).to eq("Added ~\"Bug\" label.")
end
end
+
+ describe 'type command' do
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:work_item) { create(:work_item, project: project) }
+
+ let(:command) { '/type Task' }
+
+ context 'when user has sufficient permissions to create new type' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(current_user, :create_task, work_item).and_return(true)
+ end
+
+ it 'populates :issue_type: and :work_item_type' do
+ _, updates, message = service.execute(command, work_item)
+
+ expect(message).to eq(_('Type changed successfully.'))
+ expect(updates).to eq({ issue_type: 'task', work_item_type: WorkItems::Type.default_by_type(:task) })
+ end
+
+ it 'returns error with an invalid type' do
+ _, updates, message = service.execute('/type foo', work_item)
+
+ expect(message).to eq(_("Failed to convert this work item: Provided type is not supported."))
+ expect(updates).to eq({})
+ end
+
+ it 'returns error with same type' do
+ _, updates, message = service.execute('/type Issue', work_item)
+
+ expect(message).to eq(_("Failed to convert this work item: Types are the same."))
+ expect(updates).to eq({})
+ end
+ end
+
+ context 'when user has insufficient permissions to create new type' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(current_user, :create_task, work_item).and_return(false)
+ end
+
+ it 'returns error' do
+ _, updates, message = service.execute(command, work_item)
+
+ expect(message).to eq(_("Failed to convert this work item: You have insufficient permissions."))
+ expect(updates).to eq({})
+ end
+ end
+ end
end
describe '#explain' do
@@ -2517,8 +2435,9 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning
let(:content) { '/close' }
it 'includes issuable name' do
- _, explanations = service.explain(content, issue)
+ content_result, explanations = service.explain(content, issue)
+ expect(content_result).to eq('')
expect(explanations).to eq(['Closes this issue.'])
end
end
@@ -2704,27 +2623,6 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning
end
end
- describe 'draft command toggle (deprecated)' do
- let(:content) { '/draft' }
-
- before do
- stub_feature_flags(draft_quick_action_non_toggle: false)
- end
-
- it 'includes the new status' do
- _, explanations = service.explain(content, merge_request)
-
- expect(explanations).to match_array(['Marks this merge request as a draft.'])
- end
-
- it 'sets the ready status on a draft' do
- merge_request.update!(title: merge_request.draft_title)
- _, explanations = service.explain(content, merge_request)
-
- expect(explanations).to match_array(["Marks this merge request as ready."])
- end
- end
-
describe 'draft command set' do
let(:content) { '/draft' }
@@ -2946,6 +2844,61 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning
end
end
end
+
+ context 'with keep_actions' do
+ let(:content) { '/close' }
+
+ it 'keeps quick actions' do
+ content_result, explanations = service.explain(content, issue, keep_actions: true)
+
+ expect(content_result).to eq("\n/close")
+ expect(explanations).to eq(['Closes this issue.'])
+ end
+
+ it 'removes the quick action' do
+ content_result, explanations = service.explain(content, issue, keep_actions: false)
+
+ expect(content_result).to eq('')
+ expect(explanations).to eq(['Closes this issue.'])
+ end
+ end
+
+ describe 'type command' do
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:work_item) { create(:work_item, :task, project: project) }
+
+ let(:command) { '/type Issue' }
+
+ it 'has command available' do
+ _, explanations = service.explain(command, work_item)
+
+ expect(explanations)
+ .to contain_exactly("Converts work item to Issue. Widgets not supported in new type are removed.")
+ end
+
+ context 'when feature flag work_items_mvc_2 is disabled' do
+ before do
+ stub_feature_flags(work_items_mvc_2: false)
+ end
+
+ it 'does not have the command available' do
+ _, explanations = service.explain(command, work_item)
+
+ expect(explanations).to be_empty
+ end
+ end
+ end
+
+ describe 'relate command' do
+ let_it_be(:other_issue) { create(:issue, project: project) }
+ let(:content) { "/relate #{other_issue.to_reference}" }
+
+ it 'includes explain message' do
+ _, explanations = service.explain(content, issue)
+
+ expect(explanations).to eq(["Marks this issue as related to #{other_issue.to_reference}."])
+ end
+ end
end
describe '#available_commands' do
diff --git a/spec/services/quick_actions/target_service_spec.rb b/spec/services/quick_actions/target_service_spec.rb
index 1b0a5d4ae73..5f4e92cf955 100644
--- a/spec/services/quick_actions/target_service_spec.rb
+++ b/spec/services/quick_actions/target_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe QuickActions::TargetService do
+RSpec.describe QuickActions::TargetService, feature_category: :team_planning do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:service) { described_class.new(project, user) }
diff --git a/spec/services/releases/create_evidence_service_spec.rb b/spec/services/releases/create_evidence_service_spec.rb
index 0ac15a7291d..75d0a2b9c0e 100644
--- a/spec/services/releases/create_evidence_service_spec.rb
+++ b/spec/services/releases/create_evidence_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Releases::CreateEvidenceService do
+RSpec.describe Releases::CreateEvidenceService, feature_category: :release_orchestration do
let_it_be(:project) { create(:project) }
let(:release) { create(:release, project: project) }
diff --git a/spec/services/releases/create_service_spec.rb b/spec/services/releases/create_service_spec.rb
index 9768ceb12e8..ca5dd912e77 100644
--- a/spec/services/releases/create_service_spec.rb
+++ b/spec/services/releases/create_service_spec.rb
@@ -55,6 +55,26 @@ RSpec.describe Releases::CreateService, feature_category: :continuous_integratio
end
end
+ context 'when project is a catalog resource' do
+ let(:ref) { 'master' }
+ let!(:catalog_resource) { create(:catalog_resource, project: project) }
+
+ context 'and it is valid' do
+ let_it_be(:project) { create(:project, :repository, description: 'our components') }
+
+ it_behaves_like 'a successful release creation'
+ end
+
+ context 'and it is invalid' do
+ it 'raises an error and does not update the release' do
+ result = service.execute
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('Project must have a description')
+ end
+ end
+ end
+
context 'when ref is provided' do
let(:ref) { 'master' }
let(:tag_name) { 'foobar' }
diff --git a/spec/services/releases/destroy_service_spec.rb b/spec/services/releases/destroy_service_spec.rb
index 46550ac5bef..953490ac379 100644
--- a/spec/services/releases/destroy_service_spec.rb
+++ b/spec/services/releases/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Releases::DestroyService do
+RSpec.describe Releases::DestroyService, feature_category: :release_orchestration do
let(:project) { create(:project, :repository) }
let(:mainatiner) { create(:user) }
let(:repoter) { create(:user) }
diff --git a/spec/services/releases/links/create_service_spec.rb b/spec/services/releases/links/create_service_spec.rb
new file mode 100644
index 00000000000..9928d2162d7
--- /dev/null
+++ b/spec/services/releases/links/create_service_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Releases::Links::CreateService, feature_category: :release_orchestration do
+ let(:service) { described_class.new(release, user, params) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:release) { create(:release, project: project, author: user, tag: 'v1.1.0') }
+
+ let(:params) { { name: name, url: url, direct_asset_path: direct_asset_path, link_type: link_type } }
+ let(:name) { 'link' }
+ let(:url) { 'https://example.com' }
+ let(:direct_asset_path) { '/path' }
+ let(:link_type) { 'other' }
+
+ before do
+ project.add_developer(user)
+ end
+
+ describe '#execute' do
+ subject(:execute) { service.execute }
+
+ let(:link) { subject.payload[:link] }
+
+ it 'successfully creates a release link' do
+ expect { execute }.to change { Releases::Link.count }.by(1)
+
+ expect(link).to have_attributes(
+ name: name,
+ url: url,
+ filepath: direct_asset_path,
+ link_type: link_type
+ )
+ end
+
+ context 'when user does not have access to create release link' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns an error' do
+ expect { execute }.not_to change { Releases::Link.count }
+
+ is_expected.to be_error
+ expect(execute.message).to include('Access Denied')
+ expect(execute.reason).to eq(:forbidden)
+ end
+ end
+
+ context 'when url is invalid' do
+ let(:url) { 'not_a_url' }
+
+ it 'returns an error' do
+ expect { execute }.not_to change { Releases::Link.count }
+
+ is_expected.to be_error
+ expect(execute.message[0]).to include('Url is blocked')
+ expect(execute.reason).to eq(:bad_request)
+ end
+ end
+
+ context 'when both direct_asset_path and filepath are provided' do
+ let(:params) { super().merge(filepath: '/filepath') }
+
+ it 'prefers direct_asset_path' do
+ is_expected.to be_success
+
+ expect(link.filepath).to eq(direct_asset_path)
+ end
+ end
+
+ context 'when only filepath is set' do
+ let(:params) { super().merge(filepath: '/filepath') }
+ let(:direct_asset_path) { nil }
+
+ it 'uses filepath' do
+ is_expected.to be_success
+
+ expect(link.filepath).to eq('/filepath')
+ end
+ end
+ end
+end
diff --git a/spec/services/releases/links/destroy_service_spec.rb b/spec/services/releases/links/destroy_service_spec.rb
new file mode 100644
index 00000000000..a248932eada
--- /dev/null
+++ b/spec/services/releases/links/destroy_service_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Releases::Links::DestroyService, feature_category: :release_orchestration do
+ let(:service) { described_class.new(release, user, {}) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:release) { create(:release, project: project, author: user, tag: 'v1.1.0') }
+
+ let!(:release_link) do
+ create(
+ :release_link,
+ release: release,
+ name: 'awesome-app.dmg',
+ url: 'https://example.com/download/awesome-app.dmg'
+ )
+ end
+
+ before do
+ project.add_developer(user)
+ end
+
+ describe '#execute' do
+ subject(:execute) { service.execute(release_link) }
+
+ it 'successfully deletes a release link' do
+ expect { execute }.to change { release.links.count }.by(-1)
+
+ is_expected.to be_success
+ end
+
+ context 'when user does not have access to delete release link' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns an error' do
+ expect { execute }.not_to change { release.links.count }
+
+ is_expected.to be_error
+ expect(execute.message).to include('Access Denied')
+ expect(execute.reason).to eq(:forbidden)
+ end
+ end
+
+ context 'when release link does not exist' do
+ let(:release_link) { nil }
+
+ it 'returns an error' do
+ expect { execute }.not_to change { release.links.count }
+
+ is_expected.to be_error
+ expect(execute.message).to eq('Link does not exist')
+ expect(execute.reason).to eq(:not_found)
+ end
+ end
+
+ context 'when release link deletion failed' do
+ before do
+ allow(release_link).to receive(:destroy).and_return(false)
+ end
+
+ it 'returns an error' do
+ expect { execute }.not_to change { release.links.count }
+
+ is_expected.to be_error
+ expect(execute.reason).to eq(:bad_request)
+ end
+ end
+ end
+end
diff --git a/spec/services/releases/links/update_service_spec.rb b/spec/services/releases/links/update_service_spec.rb
new file mode 100644
index 00000000000..3f48985cf60
--- /dev/null
+++ b/spec/services/releases/links/update_service_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Releases::Links::UpdateService, feature_category: :release_orchestration do
+ let(:service) { described_class.new(release, user, params) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:release) { create(:release, project: project, author: user, tag: 'v1.1.0') }
+
+ let(:release_link) do
+ create(
+ :release_link,
+ release: release,
+ name: 'awesome-app.dmg',
+ url: 'https://example.com/download/awesome-app.dmg'
+ )
+ end
+
+ let(:params) { { name: name, url: url, direct_asset_path: direct_asset_path, link_type: link_type } }
+ let(:name) { 'link' }
+ let(:url) { 'https://example.com' }
+ let(:direct_asset_path) { '/path' }
+ let(:link_type) { 'other' }
+
+ before do
+ project.add_developer(user)
+ end
+
+ describe '#execute' do
+ subject(:execute) { service.execute(release_link) }
+
+ let(:updated_link) { execute.payload[:link] }
+
+ it 'successfully updates a release link' do
+ is_expected.to be_success
+
+ expect(updated_link).to have_attributes(
+ name: name,
+ url: url,
+ filepath: direct_asset_path,
+ link_type: link_type
+ )
+ end
+
+ context 'when user does not have access to update release link' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns an error' do
+ is_expected.to be_error
+ expect(execute.message).to include('Access Denied')
+ expect(execute.reason).to eq(:forbidden)
+ end
+ end
+
+ context 'when url is invalid' do
+ let(:url) { 'not_a_url' }
+
+ it 'returns an error' do
+ is_expected.to be_error
+ expect(execute.message[0]).to include('Url is blocked')
+ expect(execute.reason).to eq(:bad_request)
+ end
+ end
+
+ context 'when both direct_asset_path and filepath are provided' do
+ let(:params) { super().merge(filepath: '/filepath') }
+
+ it 'prefers direct_asset_path' do
+ is_expected.to be_success
+
+ expect(updated_link.filepath).to eq(direct_asset_path)
+ end
+ end
+
+ context 'when only filepath is set' do
+ let(:params) { super().merge(filepath: '/filepath') }
+ let(:direct_asset_path) { nil }
+
+ it 'uses filepath' do
+ is_expected.to be_success
+
+ expect(updated_link.filepath).to eq('/filepath')
+ end
+ end
+ end
+end
diff --git a/spec/services/repositories/changelog_service_spec.rb b/spec/services/repositories/changelog_service_spec.rb
index 42b586637ad..1b5300672e3 100644
--- a/spec/services/repositories/changelog_service_spec.rb
+++ b/spec/services/repositories/changelog_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Repositories::ChangelogService do
+RSpec.describe Repositories::ChangelogService, feature_category: :source_code_management do
describe '#execute' do
let!(:project) { create(:project, :empty_repo) }
let!(:creator) { project.creator }
diff --git a/spec/services/repositories/destroy_service_spec.rb b/spec/services/repositories/destroy_service_spec.rb
index 565a18d501a..b3bad4fd84d 100644
--- a/spec/services/repositories/destroy_service_spec.rb
+++ b/spec/services/repositories/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Repositories::DestroyService do
+RSpec.describe Repositories::DestroyService, feature_category: :source_code_management do
let_it_be(:user) { create(:user) }
let!(:project) { create(:project, :repository, namespace: user.namespace) }
diff --git a/spec/services/repository_archive_clean_up_service_spec.rb b/spec/services/repository_archive_clean_up_service_spec.rb
index 8db1a6858fa..1ce68080c73 100644
--- a/spec/services/repository_archive_clean_up_service_spec.rb
+++ b/spec/services/repository_archive_clean_up_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RepositoryArchiveCleanUpService do
+RSpec.describe RepositoryArchiveCleanUpService, feature_category: :source_code_management do
subject(:service) { described_class.new }
describe '#execute (new archive locations)' do
diff --git a/spec/services/reset_project_cache_service_spec.rb b/spec/services/reset_project_cache_service_spec.rb
index 165b38ee338..6ae516a5f07 100644
--- a/spec/services/reset_project_cache_service_spec.rb
+++ b/spec/services/reset_project_cache_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ResetProjectCacheService do
+RSpec.describe ResetProjectCacheService, feature_category: :projects do
let(:project) { create(:project) }
let(:user) { create(:user) }
diff --git a/spec/services/resource_access_tokens/create_service_spec.rb b/spec/services/resource_access_tokens/create_service_spec.rb
index a8c8d41ca09..59d582f038a 100644
--- a/spec/services/resource_access_tokens/create_service_spec.rb
+++ b/spec/services/resource_access_tokens/create_service_spec.rb
@@ -2,13 +2,16 @@
require 'spec_helper'
-RSpec.describe ResourceAccessTokens::CreateService do
+RSpec.describe ResourceAccessTokens::CreateService, feature_category: :system_access do
subject { described_class.new(user, resource, params).execute }
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :private) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:params) { {} }
+ let_it_be(:max_pat_access_token_lifetime) do
+ PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now.to_date.freeze
+ end
before do
stub_config_setting(host: 'example.com')
@@ -99,12 +102,20 @@ RSpec.describe ResourceAccessTokens::CreateService do
end
end
- context 'bot email' do
- it 'check email domain' do
- response = subject
- access_token = response.payload[:access_token]
+ context 'bot username and email' do
+ include_examples 'username and email pair is generated by Gitlab::Utils::UsernameAndEmailGenerator' do
+ subject do
+ response = described_class.new(user, resource, params).execute
+ response.payload[:access_token].user
+ end
- expect(access_token.user.email).to end_with("@noreply.#{Gitlab.config.gitlab.host}")
+ let(:username_prefix) do
+ "#{resource.class.name.downcase}_#{resource.id}_bot"
+ end
+
+ let(:email_domain) do
+ "noreply.#{Gitlab.config.gitlab.host}"
+ end
end
end
@@ -119,9 +130,7 @@ RSpec.describe ResourceAccessTokens::CreateService do
end
end
- context 'when user specifies an access level' do
- let_it_be(:params) { { access_level: Gitlab::Access::DEVELOPER } }
-
+ shared_examples 'bot with access level' do
it 'adds the bot user with the specified access level in the resource' do
response = subject
access_token = response.payload[:access_token]
@@ -131,6 +140,18 @@ RSpec.describe ResourceAccessTokens::CreateService do
end
end
+ context 'when user specifies an access level' do
+ let_it_be(:params) { { access_level: Gitlab::Access::DEVELOPER } }
+
+ it_behaves_like 'bot with access level'
+ end
+
+ context 'with DEVELOPER access_level, in string format' do
+ let_it_be(:params) { { access_level: Gitlab::Access::DEVELOPER.to_s } }
+
+ it_behaves_like 'bot with access level'
+ end
+
context 'when user is external' do
before do
user.update!(external: true)
@@ -167,20 +188,51 @@ RSpec.describe ResourceAccessTokens::CreateService do
context 'expires_at' do
context 'when no expiration value is passed' do
- it 'uses nil expiration value' do
- response = subject
- access_token = response.payload[:access_token]
+ context 'when default_pat_expiration feature flag is true' do
+ it 'defaults to PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS' do
+ freeze_time do
+ response = subject
+ access_token = response.payload[:access_token]
+
+ expect(access_token.expires_at).to eq(
+ max_pat_access_token_lifetime.to_date
+ )
+ end
+ end
- expect(access_token.expires_at).to eq(nil)
+ context 'expiry of the project bot member' do
+ it 'project bot membership does not expire' do
+ response = subject
+ access_token = response.payload[:access_token]
+ project_bot = access_token.user
+
+ expect(resource.members.find_by(user_id: project_bot.id).expires_at).to eq(
+ max_pat_access_token_lifetime.to_date
+ )
+ end
+ end
end
- context 'expiry of the project bot member' do
- it 'project bot membership does not expire' do
+ context 'when default_pat_expiration feature flag is false' do
+ before do
+ stub_feature_flags(default_pat_expiration: false)
+ end
+
+ it 'uses nil expiration value' do
response = subject
access_token = response.payload[:access_token]
- project_bot = access_token.user
- expect(resource.members.find_by(user_id: project_bot.id).expires_at).to eq(nil)
+ expect(access_token.expires_at).to eq(nil)
+ end
+
+ context 'expiry of the project bot member' do
+ it 'project bot membership expires' do
+ response = subject
+ access_token = response.payload[:access_token]
+ project_bot = access_token.user
+
+ expect(resource.members.find_by(user_id: project_bot.id).expires_at).to eq(nil)
+ end
end
end
end
@@ -201,7 +253,7 @@ RSpec.describe ResourceAccessTokens::CreateService do
access_token = response.payload[:access_token]
project_bot = access_token.user
- expect(resource.members.find_by(user_id: project_bot.id).expires_at).to eq(params[:expires_at])
+ expect(resource.members.find_by(user_id: project_bot.id).expires_at).to eq(access_token.expires_at)
end
end
end
@@ -219,17 +271,31 @@ RSpec.describe ResourceAccessTokens::CreateService do
let_it_be(:bot_user) { create(:user, :project_bot) }
let(:unpersisted_member) { build(:project_member, source: resource, user: bot_user) }
- let(:error_message) { 'Could not provision maintainer access to project access token' }
+ let(:error_message) { 'Could not provision maintainer access to the access token. ERROR: error message' }
before do
allow_next_instance_of(ResourceAccessTokens::CreateService) do |service|
allow(service).to receive(:create_user).and_return(bot_user)
allow(service).to receive(:create_membership).and_return(unpersisted_member)
end
+
+ allow(unpersisted_member).to receive_message_chain(:errors, :full_messages, :to_sentence)
+ .and_return('error message')
end
- it_behaves_like 'token creation fails'
- it_behaves_like 'correct error message'
+ context 'with MAINTAINER access_level, in integer format' do
+ let_it_be(:params) { { access_level: Gitlab::Access::MAINTAINER } }
+
+ it_behaves_like 'token creation fails'
+ it_behaves_like 'correct error message'
+ end
+
+ context 'with MAINTAINER access_level, in string format' do
+ let_it_be(:params) { { access_level: Gitlab::Access::MAINTAINER.to_s } }
+
+ it_behaves_like 'token creation fails'
+ it_behaves_like 'correct error message'
+ end
end
end
diff --git a/spec/services/resource_access_tokens/revoke_service_spec.rb b/spec/services/resource_access_tokens/revoke_service_spec.rb
index 28f173f1bc7..c00146961e3 100644
--- a/spec/services/resource_access_tokens/revoke_service_spec.rb
+++ b/spec/services/resource_access_tokens/revoke_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ResourceAccessTokens::RevokeService do
+RSpec.describe ResourceAccessTokens::RevokeService, feature_category: :system_access do
subject { described_class.new(user, resource, access_token).execute }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/resource_events/change_labels_service_spec.rb b/spec/services/resource_events/change_labels_service_spec.rb
index d94b49de9d7..8393ce78df8 100644
--- a/spec/services/resource_events/change_labels_service_spec.rb
+++ b/spec/services/resource_events/change_labels_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
# feature category is shared among plan(issues, epics), monitor(incidents), create(merge request) stages
-RSpec.describe ResourceEvents::ChangeLabelsService, feature_category: :shared do
+RSpec.describe ResourceEvents::ChangeLabelsService, feature_category: :team_planning do
let_it_be(:project) { create(:project) }
let_it_be(:author) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
diff --git a/spec/services/resource_events/change_milestone_service_spec.rb b/spec/services/resource_events/change_milestone_service_spec.rb
index 425d5b19907..077058df1d5 100644
--- a/spec/services/resource_events/change_milestone_service_spec.rb
+++ b/spec/services/resource_events/change_milestone_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ResourceEvents::ChangeMilestoneService do
+RSpec.describe ResourceEvents::ChangeMilestoneService, feature_category: :team_planning do
let_it_be(:timebox) { create(:milestone) }
let(:created_at_time) { Time.utc(2019, 12, 30) }
diff --git a/spec/services/resource_events/change_state_service_spec.rb b/spec/services/resource_events/change_state_service_spec.rb
index b679943073c..a63b4302635 100644
--- a/spec/services/resource_events/change_state_service_spec.rb
+++ b/spec/services/resource_events/change_state_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ResourceEvents::ChangeStateService do
+RSpec.describe ResourceEvents::ChangeStateService, feature_category: :team_planning do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/resource_events/merge_into_notes_service_spec.rb b/spec/services/resource_events/merge_into_notes_service_spec.rb
index ebfd942066f..6eb6780d704 100644
--- a/spec/services/resource_events/merge_into_notes_service_spec.rb
+++ b/spec/services/resource_events/merge_into_notes_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ResourceEvents::MergeIntoNotesService do
+RSpec.describe ResourceEvents::MergeIntoNotesService, feature_category: :team_planning do
def create_event(params)
event_params = { action: :add, label: label, issue: resource,
user: user }
diff --git a/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb b/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb
index 71b1d0993ee..3396abaff9e 100644
--- a/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb
+++ b/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ResourceEvents::SyntheticLabelNotesBuilderService do
+RSpec.describe ResourceEvents::SyntheticLabelNotesBuilderService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:user) { create(:user) }
diff --git a/spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb b/spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb
index f368e107c60..20537aa3685 100644
--- a/spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb
+++ b/spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ResourceEvents::SyntheticMilestoneNotesBuilderService do
+RSpec.describe ResourceEvents::SyntheticMilestoneNotesBuilderService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, author: user) }
@@ -11,7 +11,8 @@ RSpec.describe ResourceEvents::SyntheticMilestoneNotesBuilderService do
let_it_be(:events) do
[
create(:resource_milestone_event, issue: issue, milestone: milestone, action: :add, created_at: '2020-01-01 04:00'),
- create(:resource_milestone_event, issue: issue, milestone: milestone, action: :remove, created_at: '2020-01-02 08:00')
+ create(:resource_milestone_event, issue: issue, milestone: milestone, action: :remove, created_at: '2020-01-02 08:00'),
+ create(:resource_milestone_event, issue: issue, milestone: nil, action: :remove, created_at: '2020-01-02 08:00')
]
end
@@ -22,7 +23,8 @@ RSpec.describe ResourceEvents::SyntheticMilestoneNotesBuilderService do
expect(notes.map(&:note)).to eq(
[
"changed milestone to %#{milestone.iid}",
- 'removed milestone'
+ "removed milestone %#{milestone.iid}",
+ "removed milestone "
])
end
diff --git a/spec/services/resource_events/synthetic_state_notes_builder_service_spec.rb b/spec/services/resource_events/synthetic_state_notes_builder_service_spec.rb
index 79500f3768b..9f838660f92 100644
--- a/spec/services/resource_events/synthetic_state_notes_builder_service_spec.rb
+++ b/spec/services/resource_events/synthetic_state_notes_builder_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ResourceEvents::SyntheticStateNotesBuilderService do
+RSpec.describe ResourceEvents::SyntheticStateNotesBuilderService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:user) { create(:user) }
diff --git a/spec/services/search/global_service_spec.rb b/spec/services/search/global_service_spec.rb
index e8716ef4d90..6250d32574f 100644
--- a/spec/services/search/global_service_spec.rb
+++ b/spec/services/search/global_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Search::GlobalService do
+RSpec.describe Search::GlobalService, feature_category: :global_search do
let(:user) { create(:user) }
let(:internal_user) { create(:user) }
diff --git a/spec/services/search/group_service_spec.rb b/spec/services/search/group_service_spec.rb
index c9bfa7cb7b4..e8a4a228f8f 100644
--- a/spec/services/search/group_service_spec.rb
+++ b/spec/services/search/group_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Search::GroupService do
+RSpec.describe Search::GroupService, feature_category: :global_search do
shared_examples_for 'group search' do
context 'finding projects by name' do
let(:user) { create(:user) }
diff --git a/spec/services/search/snippet_service_spec.rb b/spec/services/search/snippet_service_spec.rb
index d204f626635..d60b60d28e4 100644
--- a/spec/services/search/snippet_service_spec.rb
+++ b/spec/services/search/snippet_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Search::SnippetService do
+RSpec.describe Search::SnippetService, feature_category: :global_search do
let_it_be(:author) { create(:author) }
let_it_be(:project) { create(:project, :public) }
diff --git a/spec/services/security/ci_configuration/container_scanning_create_service_spec.rb b/spec/services/security/ci_configuration/container_scanning_create_service_spec.rb
index df76750efc8..a56fbb026c1 100644
--- a/spec/services/security/ci_configuration/container_scanning_create_service_spec.rb
+++ b/spec/services/security/ci_configuration/container_scanning_create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Security::CiConfiguration::ContainerScanningCreateService, :snowplow do
+RSpec.describe Security::CiConfiguration::ContainerScanningCreateService, :snowplow, feature_category: :container_scanning do
subject(:result) { described_class.new(project, user).execute }
let(:branch_name) { 'set-container-scanning-config-1' }
diff --git a/spec/services/security/ci_configuration/dependency_scanning_create_service_spec.rb b/spec/services/security/ci_configuration/dependency_scanning_create_service_spec.rb
index 719a2cf24e9..7ac2249642a 100644
--- a/spec/services/security/ci_configuration/dependency_scanning_create_service_spec.rb
+++ b/spec/services/security/ci_configuration/dependency_scanning_create_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Security::CiConfiguration::DependencyScanningCreateService, :snowplow,
- feature_category: :dependency_scanning do
+ feature_category: :software_composition_analysis do
subject(:result) { described_class.new(project, user).execute }
let(:branch_name) { 'set-dependency-scanning-config-1' }
diff --git a/spec/services/security/ci_configuration/sast_iac_create_service_spec.rb b/spec/services/security/ci_configuration/sast_iac_create_service_spec.rb
index deb10732b37..7f1ad543f7c 100644
--- a/spec/services/security/ci_configuration/sast_iac_create_service_spec.rb
+++ b/spec/services/security/ci_configuration/sast_iac_create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Security::CiConfiguration::SastIacCreateService, :snowplow do
+RSpec.describe Security::CiConfiguration::SastIacCreateService, :snowplow, feature_category: :static_application_security_testing do
subject(:result) { described_class.new(project, user).execute }
let(:branch_name) { 'set-sast-iac-config-1' }
diff --git a/spec/services/security/ci_configuration/sast_parser_service_spec.rb b/spec/services/security/ci_configuration/sast_parser_service_spec.rb
index 9211beb76f8..051bbcd194b 100644
--- a/spec/services/security/ci_configuration/sast_parser_service_spec.rb
+++ b/spec/services/security/ci_configuration/sast_parser_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Security::CiConfiguration::SastParserService do
+RSpec.describe Security::CiConfiguration::SastParserService, feature_category: :static_application_security_testing do
describe '#configuration' do
include_context 'read ci configuration for sast enabled project'
diff --git a/spec/services/security/ci_configuration/secret_detection_create_service_spec.rb b/spec/services/security/ci_configuration/secret_detection_create_service_spec.rb
index c1df3ebdca5..6cbeb219d11 100644
--- a/spec/services/security/ci_configuration/secret_detection_create_service_spec.rb
+++ b/spec/services/security/ci_configuration/secret_detection_create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Security::CiConfiguration::SecretDetectionCreateService, :snowplow do
+RSpec.describe Security::CiConfiguration::SecretDetectionCreateService, :snowplow, feature_category: :container_scanning do
subject(:result) { described_class.new(project, user).execute }
let(:branch_name) { 'set-secret-detection-config-1' }
diff --git a/spec/services/security/merge_reports_service_spec.rb b/spec/services/security/merge_reports_service_spec.rb
index 249f4da5f34..809d0b27c20 100644
--- a/spec/services/security/merge_reports_service_spec.rb
+++ b/spec/services/security/merge_reports_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
# rubocop: disable RSpec/MultipleMemoizedHelpers
-RSpec.describe Security::MergeReportsService, '#execute' do
+RSpec.describe Security::MergeReportsService, '#execute', feature_category: :code_review_workflow do
let(:scanner_1) { build(:ci_reports_security_scanner, external_id: 'scanner-1', name: 'Scanner 1') }
let(:scanner_2) { build(:ci_reports_security_scanner, external_id: 'scanner-2', name: 'Scanner 2') }
let(:scanner_3) { build(:ci_reports_security_scanner, external_id: 'scanner-3', name: 'Scanner 3') }
diff --git a/spec/services/serverless/associate_domain_service_spec.rb b/spec/services/serverless/associate_domain_service_spec.rb
deleted file mode 100644
index 2f45806589e..00000000000
--- a/spec/services/serverless/associate_domain_service_spec.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Serverless::AssociateDomainService do
- let_it_be(:sdc_pages_domain) { create(:pages_domain, :instance_serverless) }
- let_it_be(:sdc_cluster) { create(:cluster, :with_installed_helm, :provided_by_gcp) }
- let_it_be(:sdc_knative) { create(:clusters_applications_knative, cluster: sdc_cluster) }
- let_it_be(:sdc_creator) { create(:user) }
-
- let(:sdc) do
- create(:serverless_domain_cluster,
- knative: sdc_knative,
- creator: sdc_creator,
- pages_domain: sdc_pages_domain)
- end
-
- let(:knative) { sdc.knative }
- let(:creator) { sdc.creator }
- let(:pages_domain_id) { sdc.pages_domain_id }
-
- subject { described_class.new(knative, pages_domain_id: pages_domain_id, creator: creator) }
-
- context 'when the domain is unchanged' do
- let(:creator) { create(:user) }
-
- it 'does not update creator' do
- expect { subject.execute }.not_to change { sdc.reload.creator }
- end
- end
-
- context 'when domain is changed to nil' do
- let_it_be(:creator) { create(:user) }
- let_it_be(:pages_domain_id) { nil }
-
- it 'removes the association between knative and the domain' do
- expect { subject.execute }.to change { knative.reload.pages_domain }.from(sdc.pages_domain).to(nil)
- end
-
- it 'does not attempt to update creator' do
- expect { subject.execute }.not_to raise_error
- end
- end
-
- context 'when a new domain is associated' do
- let_it_be(:creator) { create(:user) }
- let_it_be(:pages_domain_id) { create(:pages_domain, :instance_serverless).id }
-
- it 'creates an association with the domain' do
- expect { subject.execute }.to change { knative.reload.pages_domain.id }
- .from(sdc.pages_domain.id)
- .to(pages_domain_id)
- end
-
- it 'updates creator' do
- expect { subject.execute }.to change { sdc.reload.creator }.from(sdc.creator).to(creator)
- end
- end
-
- context 'when knative is not authorized to use the pages domain' do
- let_it_be(:pages_domain_id) { create(:pages_domain).id }
-
- before do
- expect(knative).to receive(:available_domains).and_return(PagesDomain.none)
- end
-
- it 'sets pages_domain_id to nil' do
- expect { subject.execute }.to change { knative.reload.pages_domain }.from(sdc.pages_domain).to(nil)
- end
- end
-
- describe 'for new knative application' do
- let_it_be(:cluster) { create(:cluster, :with_installed_helm, :provided_by_gcp) }
-
- context 'when knative hostname is nil' do
- let(:knative) { build(:clusters_applications_knative, cluster: cluster, hostname: nil) }
-
- it 'sets hostname to a placeholder value' do
- expect { subject.execute }.to change { knative.hostname }.to('example.com')
- end
- end
-
- context 'when knative hostname exists' do
- let(:knative) { build(:clusters_applications_knative, cluster: cluster, hostname: 'hostname.com') }
-
- it 'does not change hostname' do
- expect { subject.execute }.not_to change { knative.hostname }
- end
- end
- end
-end
diff --git a/spec/services/service_desk_settings/update_service_spec.rb b/spec/services/service_desk_settings/update_service_spec.rb
index 72134af1369..342fb2b6b7a 100644
--- a/spec/services/service_desk_settings/update_service_spec.rb
+++ b/spec/services/service_desk_settings/update_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe ServiceDeskSettings::UpdateService do
+RSpec.describe ServiceDeskSettings::UpdateService, feature_category: :service_desk do
describe '#execute' do
let_it_be(:settings) { create(:service_desk_setting, outgoing_name: 'original name') }
let_it_be(:user) { create(:user) }
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 b02f1e84d25..2248febda5c 100644
--- a/spec/services/service_ping/submit_service_ping_service_spec.rb
+++ b/spec/services/service_ping/submit_service_ping_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ServicePing::SubmitService do
+RSpec.describe ServicePing::SubmitService, feature_category: :service_ping do
include StubRequests
include UsageDataHelpers
diff --git a/spec/services/service_response_spec.rb b/spec/services/service_response_spec.rb
index 58dd2fd4c5e..6171ca1a8a6 100644
--- a/spec/services/service_response_spec.rb
+++ b/spec/services/service_response_spec.rb
@@ -7,7 +7,7 @@ require 're2'
require_relative '../../app/services/service_response'
require_relative '../../lib/gitlab/error_tracking'
-RSpec.describe ServiceResponse do
+RSpec.describe ServiceResponse, feature_category: :shared do
describe '.success' do
it 'creates a successful response without a message' do
expect(described_class.success).to be_success
diff --git a/spec/services/snippets/bulk_destroy_service_spec.rb b/spec/services/snippets/bulk_destroy_service_spec.rb
index 4142aa349e4..208386aee48 100644
--- a/spec/services/snippets/bulk_destroy_service_spec.rb
+++ b/spec/services/snippets/bulk_destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::BulkDestroyService do
+RSpec.describe Snippets::BulkDestroyService, feature_category: :source_code_management do
let_it_be(:project) { create(:project) }
let(:user) { create(:user) }
diff --git a/spec/services/snippets/count_service_spec.rb b/spec/services/snippets/count_service_spec.rb
index 5ce637d0bac..4ad9b07d518 100644
--- a/spec/services/snippets/count_service_spec.rb
+++ b/spec/services/snippets/count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::CountService do
+RSpec.describe Snippets::CountService, feature_category: :source_code_management do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
diff --git a/spec/services/snippets/create_service_spec.rb b/spec/services/snippets/create_service_spec.rb
index 0eb73c8edd2..725f1b165a2 100644
--- a/spec/services/snippets/create_service_spec.rb
+++ b/spec/services/snippets/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::CreateService do
+RSpec.describe Snippets::CreateService, feature_category: :source_code_management do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create(:user, :admin) }
diff --git a/spec/services/snippets/destroy_service_spec.rb b/spec/services/snippets/destroy_service_spec.rb
index 23765243dd6..ace9847185e 100644
--- a/spec/services/snippets/destroy_service_spec.rb
+++ b/spec/services/snippets/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::DestroyService do
+RSpec.describe Snippets::DestroyService, feature_category: :source_code_management do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:other_user) { create(:user) }
@@ -144,7 +144,7 @@ RSpec.describe Snippets::DestroyService do
end
end
- context 'when the repository does not exists' do
+ context 'when the repository does not exist' do
let(:snippet) { create(:personal_snippet, author: user) }
it 'does not schedule anything and return success' do
diff --git a/spec/services/snippets/repository_validation_service_spec.rb b/spec/services/snippets/repository_validation_service_spec.rb
index 8166ce144e1..c9cd9f21481 100644
--- a/spec/services/snippets/repository_validation_service_spec.rb
+++ b/spec/services/snippets/repository_validation_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::RepositoryValidationService do
+RSpec.describe Snippets::RepositoryValidationService, feature_category: :source_code_management do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:snippet) { create(:personal_snippet, :empty_repo, author: user) }
diff --git a/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb b/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb
index 9286d73ed4a..e88969ccf2d 100644
--- a/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb
+++ b/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::ScheduleBulkRepositoryShardMovesService do
+RSpec.describe Snippets::ScheduleBulkRepositoryShardMovesService, feature_category: :source_code_management do
it_behaves_like 'moves repository shard in bulk' do
let_it_be_with_reload(:container) { create(:snippet, :repository) }
diff --git a/spec/services/snippets/update_repository_storage_service_spec.rb b/spec/services/snippets/update_repository_storage_service_spec.rb
index 9874189f73a..c417fbfd8b1 100644
--- a/spec/services/snippets/update_repository_storage_service_spec.rb
+++ b/spec/services/snippets/update_repository_storage_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::UpdateRepositoryStorageService do
+RSpec.describe Snippets::UpdateRepositoryStorageService, feature_category: :source_code_management do
subject { described_class.new(repository_storage_move) }
describe "#execute" do
diff --git a/spec/services/snippets/update_service_spec.rb b/spec/services/snippets/update_service_spec.rb
index 67cc258b4b6..99bb70a3077 100644
--- a/spec/services/snippets/update_service_spec.rb
+++ b/spec/services/snippets/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::UpdateService do
+RSpec.describe Snippets::UpdateService, feature_category: :source_code_management do
describe '#execute', :aggregate_failures do
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create :user, admin: true }
diff --git a/spec/services/snippets/update_statistics_service_spec.rb b/spec/services/snippets/update_statistics_service_spec.rb
index 27ae054676a..2d1872a09c4 100644
--- a/spec/services/snippets/update_statistics_service_spec.rb
+++ b/spec/services/snippets/update_statistics_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::UpdateStatisticsService do
+RSpec.describe Snippets::UpdateStatisticsService, feature_category: :source_code_management do
describe '#execute' do
subject { described_class.new(snippet).execute }
diff --git a/spec/services/spam/akismet_mark_as_spam_service_spec.rb b/spec/services/spam/akismet_mark_as_spam_service_spec.rb
index 12666e23e47..f07fa8d262b 100644
--- a/spec/services/spam/akismet_mark_as_spam_service_spec.rb
+++ b/spec/services/spam/akismet_mark_as_spam_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Spam::AkismetMarkAsSpamService do
+RSpec.describe Spam::AkismetMarkAsSpamService, feature_category: :instance_resiliency do
let(:user_agent_detail) { build(:user_agent_detail) }
let(:spammable) { build(:issue, user_agent_detail: user_agent_detail) }
let(:fake_akismet_service) { double(:akismet_service, submit_spam: true) }
diff --git a/spec/services/spam/akismet_service_spec.rb b/spec/services/spam/akismet_service_spec.rb
index d9f62258a53..4d6a1650327 100644
--- a/spec/services/spam/akismet_service_spec.rb
+++ b/spec/services/spam/akismet_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Spam::AkismetService do
+RSpec.describe Spam::AkismetService, feature_category: :instance_resiliency do
let(:fake_akismet_client) { double(:akismet_client) }
let(:ip) { '1.2.3.4' }
let(:user_agent) { 'some user_agent' }
diff --git a/spec/services/spam/ham_service_spec.rb b/spec/services/spam/ham_service_spec.rb
index 0101a8e7704..00906bc4b3d 100644
--- a/spec/services/spam/ham_service_spec.rb
+++ b/spec/services/spam/ham_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Spam::HamService do
+RSpec.describe Spam::HamService, feature_category: :instance_resiliency do
let_it_be(:user) { create(:user) }
let!(:spam_log) { create(:spam_log, user: user, submitted_as_ham: false) }
diff --git a/spec/services/spam/spam_action_service_spec.rb b/spec/services/spam/spam_action_service_spec.rb
index 4dfec9735ba..e2cc2ea7ce3 100644
--- a/spec/services/spam/spam_action_service_spec.rb
+++ b/spec/services/spam/spam_action_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Spam::SpamActionService do
+RSpec.describe Spam::SpamActionService, feature_category: :instance_resiliency do
include_context 'includes Spam constants'
let(:issue) { create(:issue, project: project, author: author) }
@@ -53,6 +53,16 @@ RSpec.describe Spam::SpamActionService do
end
end
+ shared_examples 'allows user' do
+ it 'does not perform spam check' do
+ expect(Spam::SpamVerdictService).not_to receive(:new)
+
+ response = subject
+
+ expect(response.message).to match(/user was allowlisted/)
+ end
+ end
+
shared_examples 'creates a spam log' do |target_type|
it do
expect { subject }
@@ -73,7 +83,6 @@ RSpec.describe Spam::SpamActionService do
shared_examples 'execute spam action service' do |target_type|
let(:fake_captcha_verification_service) { double(:captcha_verification_service) }
let(:fake_verdict_service) { double(:spam_verdict_service) }
- let(:allowlisted) { false }
let(:verdict_service_opts) do
{
@@ -101,7 +110,6 @@ RSpec.describe Spam::SpamActionService do
subject do
described_service = described_class.new(spammable: target, spam_params: spam_params, extra_features:
extra_features, user: user, action: :create)
- allow(described_service).to receive(:allowlisted?).and_return(allowlisted)
described_service.execute
end
@@ -158,16 +166,20 @@ RSpec.describe Spam::SpamActionService do
target.description = 'Lovely Spam! Wonderful Spam!'
end
- context 'when allowlisted' do
- let(:allowlisted) { true }
-
- it 'does not perform spam check' do
- expect(Spam::SpamVerdictService).not_to receive(:new)
+ context 'when user is a gitlab bot' do
+ before do
+ allow(user).to receive(:gitlab_bot?).and_return(true)
+ end
- response = subject
+ it_behaves_like 'allows user'
+ end
- expect(response.message).to match(/user was allowlisted/)
+ context 'when user is a gitlab service user' do
+ before do
+ allow(user).to receive(:gitlab_service_user?).and_return(true)
end
+
+ it_behaves_like 'allows user'
end
context 'when disallowed by the spam verdict service' do
diff --git a/spec/services/spam/spam_params_spec.rb b/spec/services/spam/spam_params_spec.rb
index 7e74641c0fa..39c3b303529 100644
--- a/spec/services/spam/spam_params_spec.rb
+++ b/spec/services/spam/spam_params_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Spam::SpamParams do
+RSpec.describe Spam::SpamParams, feature_category: :instance_resiliency do
shared_examples 'constructs from a request' do
it 'constructs from a request' do
expected = ::Spam::SpamParams.new(
diff --git a/spec/services/spam/spam_verdict_service_spec.rb b/spec/services/spam/spam_verdict_service_spec.rb
index dde93aa6b93..00e320ed56c 100644
--- a/spec/services/spam/spam_verdict_service_spec.rb
+++ b/spec/services/spam/spam_verdict_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Spam::SpamVerdictService do
+RSpec.describe Spam::SpamVerdictService, feature_category: :instance_resiliency do
include_context 'includes Spam constants'
let(:fake_ip) { '1.2.3.4' }
@@ -14,6 +14,22 @@ RSpec.describe Spam::SpamVerdictService do
'HTTP_REFERER' => fake_referer }
end
+ let(:verdict_value) { ::Spamcheck::SpamVerdict::Verdict::ALLOW }
+ let(:verdict_score) { 0.01 }
+ let(:verdict_evaluated) { true }
+
+ let(:response) do
+ response = ::Spamcheck::SpamVerdict.new
+ response.verdict = verdict_value
+ response.score = verdict_score
+ response.evaluated = verdict_evaluated
+ response
+ end
+
+ let(:spam_client_result) do
+ Gitlab::Spamcheck::Result.new(response)
+ end
+
let(:check_for_spam) { true }
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, author: user) }
@@ -23,43 +39,38 @@ RSpec.describe Spam::SpamVerdictService do
described_class.new(user: user, target: target, options: {})
end
- let(:attribs) do
- extra_attributes = { "monitorMode" => "false" }
- extra_attributes
- end
-
shared_examples 'execute spam verdict service' do
- subject { service.execute }
+ subject(:execute) { service.execute }
before do
- allow(service).to receive(:akismet_verdict).and_return(nil)
- allow(service).to receive(:spamcheck_verdict).and_return([nil, attribs])
+ allow(service).to receive(:get_akismet_verdict).and_return(nil)
+ allow(service).to receive(:get_spamcheck_verdict).and_return(nil)
end
context 'if all services return nil' do
it 'renders ALLOW verdict' do
- expect(subject).to eq ALLOW
+ is_expected.to eq ALLOW
end
end
context 'if only one service returns a verdict' do
context 'and it is supported' do
before do
- allow(service).to receive(:akismet_verdict).and_return(DISALLOW)
+ allow(service).to receive(:get_akismet_verdict).and_return(DISALLOW)
end
it 'renders that verdict' do
- expect(subject).to eq DISALLOW
+ is_expected.to eq DISALLOW
end
end
context 'and it is unexpected' do
before do
- allow(service).to receive(:akismet_verdict).and_return("unexpected")
+ allow(service).to receive(:get_akismet_verdict).and_return("unexpected")
end
it 'allows' do
- expect(subject).to eq ALLOW
+ is_expected.to eq ALLOW
end
end
end
@@ -67,50 +78,34 @@ RSpec.describe Spam::SpamVerdictService do
context 'if more than one service returns a verdict' do
context 'and they are supported' do
before do
- allow(service).to receive(:akismet_verdict).and_return(DISALLOW)
- allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
+ allow(service).to receive(:get_akismet_verdict).and_return(DISALLOW)
+ allow(service).to receive(:get_spamcheck_verdict).and_return(BLOCK_USER)
end
it 'renders the more restrictive verdict' do
- expect(subject).to eq BLOCK_USER
+ is_expected.to eq BLOCK_USER
end
end
context 'and one is supported' do
before do
- allow(service).to receive(:akismet_verdict).and_return('nonsense')
- allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
+ allow(service).to receive(:get_akismet_verdict).and_return('nonsense')
+ allow(service).to receive(:get_spamcheck_verdict).and_return(BLOCK_USER)
end
it 'renders the more restrictive verdict' do
- expect(subject).to eq BLOCK_USER
+ is_expected.to eq BLOCK_USER
end
end
context 'and none are supported' do
before do
- allow(service).to receive(:akismet_verdict).and_return('nonsense')
- allow(service).to receive(:spamcheck_verdict).and_return(['rubbish', attribs])
- end
-
- it 'renders the more restrictive verdict' do
- expect(subject).to eq ALLOW
- end
- end
-
- context 'and attribs - monitorMode is true' do
- let(:attribs) do
- extra_attributes = { "monitorMode" => "true" }
- extra_attributes
- end
-
- before do
- allow(service).to receive(:akismet_verdict).and_return(DISALLOW)
- allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
+ allow(service).to receive(:get_akismet_verdict).and_return('nonsense')
+ allow(service).to receive(:get_spamcheck_verdict).and_return('rubbish')
end
it 'renders the more restrictive verdict' do
- expect(subject).to eq(DISALLOW)
+ is_expected.to eq ALLOW
end
end
end
@@ -122,21 +117,21 @@ RSpec.describe Spam::SpamVerdictService do
context 'and a service returns a verdict that should be overridden' do
before do
- allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
+ allow(service).to receive(:get_spamcheck_verdict).and_return(BLOCK_USER)
end
it 'overrides and renders the override verdict' do
- expect(subject).to eq OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM
+ is_expected.to eq OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM
end
end
context 'and a service returns a verdict that does not need to be overridden' do
before do
- allow(service).to receive(:spamcheck_verdict).and_return([ALLOW, attribs])
+ allow(service).to receive(:get_spamcheck_verdict).and_return(ALLOW)
end
it 'does not override and renders the original verdict' do
- expect(subject).to eq ALLOW
+ is_expected.to eq ALLOW
end
end
end
@@ -146,24 +141,23 @@ RSpec.describe Spam::SpamVerdictService do
using RSpec::Parameterized::TableSyntax
- where(:verdict, :error, :label) do
- Spam::SpamConstants::ALLOW | false | 'ALLOW'
- Spam::SpamConstants::ALLOW | true | 'ERROR'
- Spam::SpamConstants::CONDITIONAL_ALLOW | false | 'CONDITIONAL_ALLOW'
- Spam::SpamConstants::BLOCK_USER | false | 'BLOCK'
- Spam::SpamConstants::DISALLOW | false | 'DISALLOW'
- Spam::SpamConstants::NOOP | false | 'NOOP'
+ where(:verdict, :label) do
+ Spam::SpamConstants::ALLOW | 'ALLOW'
+ Spam::SpamConstants::CONDITIONAL_ALLOW | 'CONDITIONAL_ALLOW'
+ Spam::SpamConstants::BLOCK_USER | 'BLOCK'
+ Spam::SpamConstants::DISALLOW | 'DISALLOW'
+ Spam::SpamConstants::NOOP | 'NOOP'
end
with_them do
before do
allow(Gitlab::Metrics).to receive(:histogram).with(:gitlab_spamcheck_request_duration_seconds, anything).and_return(histogram)
- allow(service).to receive(:spamcheck_verdict).and_return([verdict, attribs, error])
+ allow(service).to receive(:get_spamcheck_verdict).and_return(verdict)
end
it 'records duration with labels' do
expect(histogram).to receive(:observe).with(a_hash_including(result: label), anything)
- subject
+ execute
end
end
end
@@ -171,7 +165,8 @@ RSpec.describe Spam::SpamVerdictService do
shared_examples 'akismet verdict' do
let(:target) { issue }
- subject { service.send(:akismet_verdict) }
+
+ subject(:get_akismet_verdict) { service.send(:get_akismet_verdict) }
context 'if Akismet is enabled' do
before do
@@ -190,7 +185,7 @@ RSpec.describe Spam::SpamVerdictService do
end
it 'returns conditionally allow verdict' do
- expect(subject).to eq CONDITIONAL_ALLOW
+ is_expected.to eq CONDITIONAL_ALLOW
end
end
@@ -200,7 +195,7 @@ RSpec.describe Spam::SpamVerdictService do
end
it 'renders disallow verdict' do
- expect(subject).to eq DISALLOW
+ is_expected.to eq DISALLOW
end
end
end
@@ -209,7 +204,7 @@ RSpec.describe Spam::SpamVerdictService do
let(:akismet_result) { false }
it 'renders allow verdict' do
- expect(subject).to eq ALLOW
+ is_expected.to eq ALLOW
end
end
end
@@ -220,13 +215,13 @@ RSpec.describe Spam::SpamVerdictService do
end
it 'renders allow verdict' do
- expect(subject).to eq ALLOW
+ is_expected.to eq ALLOW
end
end
end
shared_examples 'spamcheck verdict' do
- subject { service.send(:spamcheck_verdict) }
+ subject(:get_spamcheck_verdict) { service.send(:get_spamcheck_verdict) }
context 'if a Spam Check endpoint enabled and set to a URL' do
let(:spam_check_body) { {} }
@@ -242,45 +237,50 @@ RSpec.describe Spam::SpamVerdictService do
end
context 'if the endpoint is accessible' do
- let(:error) { '' }
- let(:verdict) { nil }
-
- let(:attribs) do
- extra_attributes = { "monitorMode" => "false" }
- extra_attributes
- end
-
before do
allow(service).to receive(:spamcheck_client).and_return(spam_client)
- allow(spam_client).to receive(:spam?).and_return([verdict, attribs, error])
+ allow(spam_client).to receive(:spam?).and_return(spam_client_result)
end
context 'if the result is a NOOP verdict' do
- let(:verdict) { NOOP }
+ let(:verdict_evaluated) { false }
+ let(:verdict_value) { ::Spamcheck::SpamVerdict::Verdict::NOOP }
it 'returns the verdict' do
- expect(subject).to eq([NOOP, attribs])
+ is_expected.to eq(NOOP)
+ expect(user.spam_score).to eq(0.0)
end
end
- context 'if attribs - monitorMode is true' do
- let(:attribs) do
- extra_attributes = { "monitorMode" => "true" }
- extra_attributes
+ context 'the result is a valid verdict' do
+ let(:verdict_score) { 0.05 }
+ let(:verdict_value) { ::Spamcheck::SpamVerdict::Verdict::ALLOW }
+
+ context 'the result was evaluated' do
+ it 'returns the verdict and updates the spam score' do
+ is_expected.to eq(ALLOW)
+ expect(user.spam_score).to be_within(0.000001).of(verdict_score)
+ end
end
- let(:verdict) { ALLOW }
+ context 'the result was not evaluated' do
+ let(:verdict_evaluated) { false }
- it 'returns the verdict' do
- expect(subject).to eq([ALLOW, attribs])
+ it 'returns the verdict and does not update the spam score' do
+ expect(subject).to eq(ALLOW)
+ expect(user.spam_score).to eq(0.0)
+ end
end
- end
- context 'the result is a valid verdict' do
- let(:verdict) { ALLOW }
+ context 'user spam score feature is disabled' do
+ before do
+ stub_feature_flags(user_spam_scores: false)
+ end
- it 'returns the verdict' do
- expect(subject).to eq([ALLOW, attribs])
+ it 'returns the verdict and does not update the spam score' do
+ expect(subject).to eq(ALLOW)
+ expect(user.spam_score).to eq(0.0)
+ end
end
end
@@ -291,20 +291,17 @@ RSpec.describe Spam::SpamVerdictService do
using RSpec::Parameterized::TableSyntax
- # rubocop: disable Lint/BinaryOperatorWithIdenticalOperands
- where(:verdict_value, :expected) do
- ::Spam::SpamConstants::ALLOW | ::Spam::SpamConstants::ALLOW
- ::Spam::SpamConstants::CONDITIONAL_ALLOW | ::Spam::SpamConstants::CONDITIONAL_ALLOW
- ::Spam::SpamConstants::DISALLOW | ::Spam::SpamConstants::DISALLOW
- ::Spam::SpamConstants::BLOCK_USER | ::Spam::SpamConstants::BLOCK_USER
+ where(:verdict_value, :expected, :verdict_score) do
+ ::Spamcheck::SpamVerdict::Verdict::ALLOW | ::Spam::SpamConstants::ALLOW | 0.1
+ ::Spamcheck::SpamVerdict::Verdict::CONDITIONAL_ALLOW | ::Spam::SpamConstants::CONDITIONAL_ALLOW | 0.5
+ ::Spamcheck::SpamVerdict::Verdict::DISALLOW | ::Spam::SpamConstants::DISALLOW | 0.8
+ ::Spamcheck::SpamVerdict::Verdict::BLOCK | ::Spam::SpamConstants::BLOCK_USER | 0.9
end
- # rubocop: enable Lint/BinaryOperatorWithIdenticalOperands
with_them do
- let(:verdict) { verdict_value }
-
- it "returns expected spam constant" do
- expect(subject).to eq([expected, attribs])
+ it "returns expected spam constant and updates the spam score" do
+ is_expected.to eq(expected)
+ expect(user.spam_score).to be_within(0.000001).of(verdict_score)
end
end
end
@@ -314,54 +311,23 @@ RSpec.describe Spam::SpamVerdictService do
allow(Gitlab::Recaptcha).to receive(:enabled?).and_return(false)
end
- [::Spam::SpamConstants::ALLOW,
- ::Spam::SpamConstants::CONDITIONAL_ALLOW,
- ::Spam::SpamConstants::DISALLOW,
- ::Spam::SpamConstants::BLOCK_USER].each do |verdict_value|
- let(:verdict) { verdict_value }
- let(:expected) { [verdict_value, attribs] }
-
- it "returns expected spam constant" do
- expect(subject).to eq(expected)
- end
- end
- end
-
- context 'the verdict is an unexpected value' do
- let(:verdict) { :this_is_fine }
-
- it 'returns the string' do
- expect(subject).to eq([verdict, attribs])
- end
- end
-
- context 'the verdict is an empty string' do
- let(:verdict) { '' }
-
- it 'returns nil' do
- expect(subject).to eq([verdict, attribs])
- end
- end
-
- context 'the verdict is nil' do
- let(:verdict) { nil }
+ using RSpec::Parameterized::TableSyntax
- it 'returns nil' do
- expect(subject).to eq([nil, attribs])
+ where(:verdict_value, :expected) do
+ ::Spamcheck::SpamVerdict::Verdict::ALLOW | ::Spam::SpamConstants::ALLOW
+ ::Spamcheck::SpamVerdict::Verdict::CONDITIONAL_ALLOW | ::Spam::SpamConstants::CONDITIONAL_ALLOW
+ ::Spamcheck::SpamVerdict::Verdict::DISALLOW | ::Spam::SpamConstants::DISALLOW
+ ::Spamcheck::SpamVerdict::Verdict::BLOCK | ::Spam::SpamConstants::BLOCK_USER
end
- end
- context 'there is an error' do
- let(:error) { "Sorry Dave, I can't do that" }
-
- it 'returns nil' do
- expect(subject).to eq([nil, attribs])
+ with_them do
+ it "returns expected spam constant" do
+ is_expected.to eq(expected)
+ end
end
end
context 'the requested is aborted' do
- let(:attribs) { nil }
-
before do
allow(spam_client).to receive(:spam?).and_raise(GRPC::Aborted)
end
@@ -370,22 +336,11 @@ RSpec.describe Spam::SpamVerdictService do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
an_instance_of(GRPC::Aborted), error: ::Spam::SpamConstants::ERROR_TYPE
)
- expect(subject).to eq([ALLOW, attribs, true])
- end
- end
-
- context 'the confused API endpoint returns both an error and a verdict' do
- let(:verdict) { 'disallow' }
- let(:error) { 'oh noes!' }
-
- it 'renders the verdict' do
- expect(subject).to eq [DISALLOW, attribs]
+ is_expected.to be_nil
end
end
context 'if the endpoint times out' do
- let(:attribs) { nil }
-
before do
allow(spam_client).to receive(:spam?).and_raise(GRPC::DeadlineExceeded)
end
@@ -394,7 +349,7 @@ RSpec.describe Spam::SpamVerdictService do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
an_instance_of(GRPC::DeadlineExceeded), error: ::Spam::SpamConstants::ERROR_TYPE
)
- expect(subject).to eq([ALLOW, attribs, true])
+ is_expected.to be_nil
end
end
end
@@ -406,7 +361,7 @@ RSpec.describe Spam::SpamVerdictService do
end
it 'returns nil' do
- expect(subject).to be_nil
+ is_expected.to be_nil
end
end
@@ -416,7 +371,7 @@ RSpec.describe Spam::SpamVerdictService do
end
it 'returns nil' do
- expect(subject).to be_nil
+ is_expected.to be_nil
end
end
end
@@ -435,7 +390,7 @@ RSpec.describe Spam::SpamVerdictService do
end
end
- describe '#akismet_verdict' do
+ describe '#get_akismet_verdict' do
describe 'issue' do
let(:target) { issue }
@@ -449,7 +404,7 @@ RSpec.describe Spam::SpamVerdictService do
end
end
- describe '#spamcheck_verdict' do
+ describe '#get_spamcheck_verdict' do
describe 'issue' do
let(:target) { issue }
diff --git a/spec/services/submodules/update_service_spec.rb b/spec/services/submodules/update_service_spec.rb
index 1a53da7b9fe..aeaf8ec1c7b 100644
--- a/spec/services/submodules/update_service_spec.rb
+++ b/spec/services/submodules/update_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Submodules::UpdateService do
+RSpec.describe Submodules::UpdateService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:user) { create(:user, :commit_email) }
diff --git a/spec/services/suggestions/apply_service_spec.rb b/spec/services/suggestions/apply_service_spec.rb
index 41ccd8523fa..6e2c623035e 100644
--- a/spec/services/suggestions/apply_service_spec.rb
+++ b/spec/services/suggestions/apply_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Suggestions::ApplyService do
+RSpec.describe Suggestions::ApplyService, feature_category: :code_suggestions do
include ProjectForksHelper
def build_position(**optional_args)
diff --git a/spec/services/suggestions/create_service_spec.rb b/spec/services/suggestions/create_service_spec.rb
index a4e62431128..a8bc3cba697 100644
--- a/spec/services/suggestions/create_service_spec.rb
+++ b/spec/services/suggestions/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Suggestions::CreateService do
+RSpec.describe Suggestions::CreateService, feature_category: :code_suggestions do
let(:project_with_repo) { create(:project, :repository) }
let(:merge_request) do
create(:merge_request, source_project: project_with_repo,
diff --git a/spec/services/suggestions/outdate_service_spec.rb b/spec/services/suggestions/outdate_service_spec.rb
index e8891f88548..7bd70866bf7 100644
--- a/spec/services/suggestions/outdate_service_spec.rb
+++ b/spec/services/suggestions/outdate_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Suggestions::OutdateService do
+RSpec.describe Suggestions::OutdateService, feature_category: :code_suggestions do
describe '#execute' do
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.target_project }
diff --git a/spec/services/system_hooks_service_spec.rb b/spec/services/system_hooks_service_spec.rb
index 5d60b6e0487..883a7d3a2ce 100644
--- a/spec/services/system_hooks_service_spec.rb
+++ b/spec/services/system_hooks_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SystemHooksService do
+RSpec.describe SystemHooksService, feature_category: :webhooks do
describe '#execute_hooks_for' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 38b6943b12a..1eb11c80264 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -692,10 +692,10 @@ RSpec.describe SystemNoteService, feature_category: :shared do
it 'calls IssuableService' do
expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
- expect(service).to receive(:change_issue_type)
+ expect(service).to receive(:change_issue_type).with('issue')
end
- described_class.change_issue_type(incident, author)
+ described_class.change_issue_type(incident, author, 'issue')
end
end
diff --git a/spec/services/system_notes/alert_management_service_spec.rb b/spec/services/system_notes/alert_management_service_spec.rb
index 039975c1bf6..4d40a6a6cfd 100644
--- a/spec/services/system_notes/alert_management_service_spec.rb
+++ b/spec/services/system_notes/alert_management_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::SystemNotes::AlertManagementService do
+RSpec.describe ::SystemNotes::AlertManagementService, feature_category: :projects do
let_it_be(:author) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:noteable) { create(:alert_management_alert, :with_incident, :acknowledged, project: project) }
diff --git a/spec/services/system_notes/base_service_spec.rb b/spec/services/system_notes/base_service_spec.rb
index efb165f8e4c..6ea4751b613 100644
--- a/spec/services/system_notes/base_service_spec.rb
+++ b/spec/services/system_notes/base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SystemNotes::BaseService do
+RSpec.describe SystemNotes::BaseService, feature_category: :projects do
let(:noteable) { double }
let(:project) { double }
let(:author) { double }
diff --git a/spec/services/system_notes/commit_service_spec.rb b/spec/services/system_notes/commit_service_spec.rb
index 0399603980d..8dfb83f63fe 100644
--- a/spec/services/system_notes/commit_service_spec.rb
+++ b/spec/services/system_notes/commit_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SystemNotes::CommitService do
+RSpec.describe SystemNotes::CommitService, feature_category: :code_review_workflow do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
let_it_be(:author) { create(:user) }
@@ -13,7 +13,7 @@ RSpec.describe SystemNotes::CommitService do
subject { commit_service.add_commits(new_commits, old_commits, oldrev) }
let(:noteable) { create(:merge_request, source_project: project, target_project: project) }
- let(:new_commits) { noteable.commits }
+ let(:new_commits) { create_commits(10) }
let(:old_commits) { [] }
let(:oldrev) { nil }
@@ -43,6 +43,48 @@ RSpec.describe SystemNotes::CommitService do
expect(decoded_note_content).to include("<li>#{commit.short_id} - #{commit.title}</li>")
end
end
+
+ context 'with HTML content' do
+ let(:new_commits) { [double(title: '<pre>This is a test</pre>', short_id: '12345678')] }
+
+ it 'escapes HTML titles' do
+ expect(note_lines[1]).to eq("<ul><li>12345678 - &lt;pre&gt;This is a test&lt;/pre&gt;</li></ul>")
+ end
+ end
+
+ context 'with one commit exceeding the NEW_COMMIT_DISPLAY_LIMIT' do
+ let(:new_commits) { create_commits(11) }
+ let(:earlier_commit_summary_line) { note_lines[1] }
+
+ it 'includes the truncated new commits summary' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>#{new_commits[0].short_id} - 1 earlier commit")
+ end
+
+ context 'with oldrev' do
+ let(:oldrev) { '12345678abcd' }
+
+ it 'includes the truncated new commits summary with the oldrev' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>#{new_commits[0].short_id} - 1 earlier commit")
+ end
+ end
+ end
+
+ context 'with multiple commits exceeding the NEW_COMMIT_DISPLAY_LIMIT' do
+ let(:new_commits) { create_commits(13) }
+ let(:earlier_commit_summary_line) { note_lines[1] }
+
+ it 'includes the truncated new commits summary' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>#{new_commits[0].short_id}..#{new_commits[2].short_id} - 3 earlier commits")
+ end
+
+ context 'with oldrev' do
+ let(:oldrev) { '12345678abcd' }
+
+ it 'includes the truncated new commits summary with the oldrev' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>12345678...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
+ end
end
describe 'summary line for existing commits' do
@@ -54,6 +96,15 @@ RSpec.describe SystemNotes::CommitService do
it 'includes the existing commit' do
expect(summary_line).to start_with("<ul><li>#{old_commits.first.short_id} - 1 commit from branch <code>feature</code>")
end
+
+ context 'with new commits exceeding the display limit' do
+ let(:summary_line) { note_lines[1] }
+ let(:new_commits) { create_commits(13) }
+
+ it 'includes the existing commit as well as the truncated new commit summary' do
+ expect(summary_line).to start_with("<ul><li>#{old_commits.first.short_id} - 1 commit from branch <code>feature</code></li><li>#{old_commits.last.short_id}...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
end
context 'with multiple existing commits' do
@@ -66,6 +117,15 @@ RSpec.describe SystemNotes::CommitService do
expect(summary_line)
.to start_with("<ul><li>#{Commit.truncate_sha(oldrev)}...#{old_commits.last.short_id} - 26 commits from branch <code>feature</code>")
end
+
+ context 'with new commits exceeding the display limit' do
+ let(:new_commits) { create_commits(13) }
+
+ it 'includes the existing commit as well as the truncated new commit summary' do
+ expect(summary_line)
+ .to start_with("<ul><li>#{Commit.truncate_sha(oldrev)}...#{old_commits.last.short_id} - 26 commits from branch <code>feature</code></li><li>#{old_commits.last.short_id}...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
end
context 'without oldrev' do
@@ -73,6 +133,15 @@ RSpec.describe SystemNotes::CommitService do
expect(summary_line)
.to start_with("<ul><li>#{old_commits[0].short_id}..#{old_commits[-1].short_id} - 26 commits from branch <code>feature</code>")
end
+
+ context 'with new commits exceeding the display limit' do
+ let(:new_commits) { create_commits(13) }
+
+ it 'includes the existing commit as well as the truncated new commit summary' do
+ expect(summary_line)
+ .to start_with("<ul><li>#{old_commits.first.short_id}..#{old_commits.last.short_id} - 26 commits from branch <code>feature</code></li><li>#{old_commits.last.short_id}...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
end
context 'on a fork' do
@@ -106,12 +175,9 @@ RSpec.describe SystemNotes::CommitService do
end
end
- describe '#new_commit_summary' do
- it 'escapes HTML titles' do
- commit = double(title: '<pre>This is a test</pre>', short_id: '12345678')
- escaped = '&lt;pre&gt;This is a test&lt;/pre&gt;'
-
- expect(described_class.new.new_commit_summary([commit])).to all(match(/- #{escaped}/))
+ def create_commits(count)
+ Array.new(count) do |i|
+ double(title: "Test commit #{i}", short_id: "abcd00#{i}")
end
end
end
diff --git a/spec/services/system_notes/design_management_service_spec.rb b/spec/services/system_notes/design_management_service_spec.rb
index 19e1f338eb8..92568890c6f 100644
--- a/spec/services/system_notes/design_management_service_spec.rb
+++ b/spec/services/system_notes/design_management_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SystemNotes::DesignManagementService do
+RSpec.describe SystemNotes::DesignManagementService, feature_category: :design_management do
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
diff --git a/spec/services/system_notes/incident_service_spec.rb b/spec/services/system_notes/incident_service_spec.rb
index 5de352ad8fa..0e9828c0a81 100644
--- a/spec/services/system_notes/incident_service_spec.rb
+++ b/spec/services/system_notes/incident_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::SystemNotes::IncidentService do
+RSpec.describe ::SystemNotes::IncidentService, feature_category: :incident_management do
let_it_be(:author) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:noteable) { create(:incident, project: project) }
diff --git a/spec/services/system_notes/incidents_service_spec.rb b/spec/services/system_notes/incidents_service_spec.rb
index 6439f9fae93..5452d51dfc0 100644
--- a/spec/services/system_notes/incidents_service_spec.rb
+++ b/spec/services/system_notes/incidents_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SystemNotes::IncidentsService do
+RSpec.describe SystemNotes::IncidentsService, feature_category: :incident_management do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:author) { create(:user) }
diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb
index 3263e410d3c..af660a9b72e 100644
--- a/spec/services/system_notes/issuables_service_spec.rb
+++ b/spec/services/system_notes/issuables_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::SystemNotes::IssuablesService do
+RSpec.describe ::SystemNotes::IssuablesService, feature_category: :team_planning do
include ProjectForksHelper
let_it_be(:group) { create(:group) }
@@ -861,15 +861,29 @@ RSpec.describe ::SystemNotes::IssuablesService do
end
describe '#change_issue_type' do
- let(:noteable) { create(:incident, project: project) }
+ context 'with issue' do
+ let_it_be_with_reload(:noteable) { create(:issue, project: project) }
- subject { service.change_issue_type }
+ subject { service.change_issue_type('incident') }
- it_behaves_like 'a system note' do
- let(:action) { 'issue_type' }
+ it_behaves_like 'a system note' do
+ let(:action) { 'issue_type' }
+ end
+
+ it { expect(subject.note).to eq "changed type from incident to issue" }
end
- it { expect(subject.note).to eq "changed issue type to incident" }
+ context 'with work item' do
+ let_it_be_with_reload(:noteable) { create(:work_item, project: project) }
+
+ subject { service.change_issue_type('task') }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'issue_type' }
+ end
+
+ it { expect(subject.note).to eq "changed type from task to issue" }
+ end
end
describe '#hierarchy_changed' do
diff --git a/spec/services/system_notes/merge_requests_service_spec.rb b/spec/services/system_notes/merge_requests_service_spec.rb
index 3e66ccef106..7ddcd799a55 100644
--- a/spec/services/system_notes/merge_requests_service_spec.rb
+++ b/spec/services/system_notes/merge_requests_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::SystemNotes::MergeRequestsService do
+RSpec.describe ::SystemNotes::MergeRequestsService, feature_category: :code_review_workflow do
include Gitlab::Routing
let_it_be(:group) { create(:group) }
diff --git a/spec/services/system_notes/time_tracking_service_spec.rb b/spec/services/system_notes/time_tracking_service_spec.rb
index c856caa3f3e..71228050085 100644
--- a/spec/services/system_notes/time_tracking_service_spec.rb
+++ b/spec/services/system_notes/time_tracking_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::SystemNotes::TimeTrackingService do
+RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_planning do
let_it_be(:author) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
@@ -37,7 +37,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService do
end
it 'sets the correct note message' do
- expect(note.note).to eq('removed start date and removed due date')
+ expect(note.note).to eq("removed start date #{start_date.to_s(:long)} and removed due date #{due_date.to_s(:long)}")
end
end
@@ -52,7 +52,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService do
let(:changed_dates) { { 'due_date' => [nil, due_date], 'start_date' => [start_date, nil] } }
it 'sets the correct note message' do
- expect(note.note).to eq("removed start date and changed due date to #{due_date.to_s(:long)}")
+ expect(note.note).to eq("removed start date #{start_date.to_s(:long)} and changed due date to #{due_date.to_s(:long)}")
end
end
end
@@ -80,7 +80,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService do
let(:changed_dates) { { 'due_date' => [due_date, nil], 'start_date' => [nil, start_date] } }
it 'sets the correct note message' do
- expect(note.note).to eq("changed start date to #{start_date.to_s(:long)} and removed due date")
+ expect(note.note).to eq("changed start date to #{start_date.to_s(:long)} and removed due date #{due_date.to_s(:long)}")
end
end
end
diff --git a/spec/services/system_notes/zoom_service_spec.rb b/spec/services/system_notes/zoom_service_spec.rb
index 986324c9664..b46b4113e12 100644
--- a/spec/services/system_notes/zoom_service_spec.rb
+++ b/spec/services/system_notes/zoom_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::SystemNotes::ZoomService do
+RSpec.describe ::SystemNotes::ZoomService, feature_category: :integrations do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:author) { create(:user) }
diff --git a/spec/services/tags/create_service_spec.rb b/spec/services/tags/create_service_spec.rb
index bbf6fe62959..51b8bace626 100644
--- a/spec/services/tags/create_service_spec.rb
+++ b/spec/services/tags/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Tags::CreateService do
+RSpec.describe Tags::CreateService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:user) { create(:user) }
diff --git a/spec/services/tags/destroy_service_spec.rb b/spec/services/tags/destroy_service_spec.rb
index 6160f337552..343a87785ad 100644
--- a/spec/services/tags/destroy_service_spec.rb
+++ b/spec/services/tags/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Tags::DestroyService do
+RSpec.describe Tags::DestroyService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:user) { create(:user) }
diff --git a/spec/services/task_list_toggle_service_spec.rb b/spec/services/task_list_toggle_service_spec.rb
index f889f298213..5d55c1ca8de 100644
--- a/spec/services/task_list_toggle_service_spec.rb
+++ b/spec/services/task_list_toggle_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TaskListToggleService do
+RSpec.describe TaskListToggleService, feature_category: :team_planning do
let(:markdown) do
<<-EOT.strip_heredoc
* [ ] Task 1
@@ -18,6 +18,8 @@ RSpec.describe TaskListToggleService do
with an embedded paragraph
+ [ ] No-break space (U+00A0)
+
+ 2) [ ] Another item
EOT
end
@@ -53,6 +55,11 @@ RSpec.describe TaskListToggleService do
<input type="checkbox" class="task-list-item-checkbox" disabled=""> No-break space (U+00A0)
</li>
</ul>
+ <ol start="2" data-sourcepos="15:1-15:19" class="task-list" dir="auto">
+ <li data-sourcepos="15:1-15:19" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled> Another item
+ </li>
+ </ol>
EOT
end
@@ -92,10 +99,20 @@ RSpec.describe TaskListToggleService do
line_source: '+ [ ] No-break space (U+00A0)', line_number: 13)
expect(toggler.execute).to be_truthy
- expect(toggler.updated_markdown.lines[12]).to eq "+ [x] No-break space (U+00A0)"
+ expect(toggler.updated_markdown.lines[12]).to eq "+ [x] No-break space (U+00A0)\n"
expect(toggler.updated_markdown_html).to include('disabled checked> No-break space (U+00A0)')
end
+ it 'checks Another item' do
+ toggler = described_class.new(markdown, markdown_html,
+ toggle_as_checked: true,
+ line_source: '2) [ ] Another item', line_number: 15)
+
+ expect(toggler.execute).to be_truthy
+ expect(toggler.updated_markdown.lines[14]).to eq "2) [x] Another item"
+ expect(toggler.updated_markdown_html).to include('disabled checked> Another item')
+ end
+
it 'returns false if line_source does not match the text' do
toggler = described_class.new(markdown, markdown_html,
toggle_as_checked: false,
diff --git a/spec/services/tasks_to_be_done/base_service_spec.rb b/spec/services/tasks_to_be_done/base_service_spec.rb
index cfeff36cc0d..3ca9d140197 100644
--- a/spec/services/tasks_to_be_done/base_service_spec.rb
+++ b/spec/services/tasks_to_be_done/base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TasksToBeDone::BaseService do
+RSpec.describe TasksToBeDone::BaseService, feature_category: :team_planning do
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let_it_be(:assignee_one) { create(:user) }
@@ -33,9 +33,9 @@ RSpec.describe TasksToBeDone::BaseService do
add_labels: label.title
}
- expect(Issues::BuildService)
+ expect(Issues::CreateService)
.to receive(:new)
- .with(container: project, current_user: current_user, params: params)
+ .with(container: project, current_user: current_user, params: params, spam_params: nil)
.and_call_original
expect { service.execute }.to change(Issue, :count).by(1)
diff --git a/spec/services/terraform/remote_state_handler_spec.rb b/spec/services/terraform/remote_state_handler_spec.rb
index 369309e4d5a..4590a9ad0e9 100644
--- a/spec/services/terraform/remote_state_handler_spec.rb
+++ b/spec/services/terraform/remote_state_handler_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Terraform::RemoteStateHandler do
+RSpec.describe Terraform::RemoteStateHandler, feature_category: :infrastructure_as_code do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user, developer_projects: [project]) }
let_it_be(:maintainer) { create(:user, maintainer_projects: [project]) }
@@ -85,6 +85,7 @@ RSpec.describe Terraform::RemoteStateHandler do
end
expect(record.reload.name).to eq 'new-name'
+ expect(record.reload.project).to eq project
end
it 'raises exception if lock has not been acquired before' do
diff --git a/spec/services/terraform/states/destroy_service_spec.rb b/spec/services/terraform/states/destroy_service_spec.rb
index 5acf32cd73c..3515a758827 100644
--- a/spec/services/terraform/states/destroy_service_spec.rb
+++ b/spec/services/terraform/states/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Terraform::States::DestroyService do
+RSpec.describe Terraform::States::DestroyService, feature_category: :infrastructure_as_code do
let_it_be(:state) { create(:terraform_state, :with_version, :deletion_in_progress) }
let(:file) { instance_double(Terraform::StateUploader, relative_path: 'path') }
diff --git a/spec/services/terraform/states/trigger_destroy_service_spec.rb b/spec/services/terraform/states/trigger_destroy_service_spec.rb
index 459f4c3bdb9..0b37d962353 100644
--- a/spec/services/terraform/states/trigger_destroy_service_spec.rb
+++ b/spec/services/terraform/states/trigger_destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Terraform::States::TriggerDestroyService do
+RSpec.describe Terraform::States::TriggerDestroyService, feature_category: :infrastructure_as_code do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user, maintainer_projects: [project]) }
diff --git a/spec/services/test_hooks/project_service_spec.rb b/spec/services/test_hooks/project_service_spec.rb
index 13f863dbbdb..31f97edbd08 100644
--- a/spec/services/test_hooks/project_service_spec.rb
+++ b/spec/services/test_hooks/project_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TestHooks::ProjectService do
+RSpec.describe TestHooks::ProjectService, feature_category: :code_testing do
include AfterNextHelpers
let(:current_user) { create(:user) }
diff --git a/spec/services/test_hooks/system_service_spec.rb b/spec/services/test_hooks/system_service_spec.rb
index e94ea4669c6..4c5009fea54 100644
--- a/spec/services/test_hooks/system_service_spec.rb
+++ b/spec/services/test_hooks/system_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TestHooks::SystemService do
+RSpec.describe TestHooks::SystemService, feature_category: :code_testing do
include AfterNextHelpers
describe '#execute' do
diff --git a/spec/services/timelogs/delete_service_spec.rb b/spec/services/timelogs/delete_service_spec.rb
index ee1133af6b3..c0543bafcec 100644
--- a/spec/services/timelogs/delete_service_spec.rb
+++ b/spec/services/timelogs/delete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Timelogs::DeleteService do
+RSpec.describe Timelogs::DeleteService, feature_category: :team_planning do
let_it_be(:author) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) }
diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb
index f73eae70d3c..1ec6a3250fc 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TodoService do
+RSpec.describe TodoService, feature_category: :team_planning do
include AfterNextHelpers
let_it_be(:project) { create(:project, :repository) }
@@ -211,7 +211,6 @@ RSpec.describe TodoService do
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:namespace) { project.namespace }
let(:category) { described_class.to_s }
let(:action) { 'incident_management_incident_todo' }
@@ -395,6 +394,39 @@ RSpec.describe TodoService do
end
end
+ describe '#resolve_todos_with_attributes_for_target' do
+ it 'marks related pending todos to the target for all the users as done' do
+ first_todo = create(:todo, :assigned, user: member, project: project, target: issue, author: author)
+ second_todo = create(:todo, :review_requested, user: john_doe, project: project, target: issue, author: author)
+ another_todo = create(:todo, :assigned, user: john_doe, project: project, target: project, author: author)
+
+ service.resolve_todos_with_attributes_for_target(issue, {})
+
+ expect(first_todo.reload).to be_done
+ expect(second_todo.reload).to be_done
+ expect(another_todo.reload).to be_pending
+ end
+
+ it 'marks related only filtered pending todos to the target for all the users as done' do
+ first_todo = create(:todo, :assigned, user: member, project: project, target: issue, author: author)
+ second_todo = create(:todo, :review_requested, user: john_doe, project: project, target: issue, author: author)
+ another_todo = create(:todo, :assigned, user: john_doe, project: project, target: project, author: author)
+
+ service.resolve_todos_with_attributes_for_target(issue, { action: Todo::ASSIGNED })
+
+ expect(first_todo.reload).to be_done
+ expect(second_todo.reload).to be_pending
+ expect(another_todo.reload).to be_pending
+ end
+
+ it 'fetches the pending todos with users preloaded' do
+ expect(PendingTodosFinder).to receive(:new)
+ .with(a_hash_including(preload_user_association: true)).and_call_original
+
+ service.resolve_todos_with_attributes_for_target(issue, { action: Todo::ASSIGNED })
+ end
+ end
+
describe '#new_note' do
let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) }
let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) }
@@ -1225,20 +1257,64 @@ RSpec.describe TodoService do
end
describe '#resolve_access_request_todos' do
- let_it_be(:source) { create(:group, :public) }
- let_it_be(:requester) { create(:group_member, :access_request, group: source, user: assignee) }
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:group_requester) { create(:group_member, :access_request, group: group, user: assignee) }
+ let_it_be(:project_requester) { create(:project_member, :access_request, project: project, user: non_member) }
+ let_it_be(:another_pending_todo) { create(:todo, state: :pending, user: john_doe) }
+ # access request by another user
+ let_it_be(:another_group_todo) do
+ create(:todo, state: :pending, target: group, action: Todo::MEMBER_ACCESS_REQUESTED)
+ end
- it 'marks the todos for request handler as done' do
- request_handler_todo = create(:todo,
- user: member,
- state: :pending,
- action: Todo::MEMBER_ACCESS_REQUESTED,
- author: requester.user,
- target: source)
+ let_it_be(:another_project_todo) do
+ create(:todo, state: :pending, target: project, action: Todo::MEMBER_ACCESS_REQUESTED)
+ end
- service.resolve_access_request_todos(member, requester)
+ it 'marks the todos for group access request handlers as done' do
+ access_request_todos = [member, john_doe].map do |group_user|
+ create(:todo,
+ user: group_user,
+ state: :pending,
+ action: Todo::MEMBER_ACCESS_REQUESTED,
+ author: group_requester.user,
+ target: group
+ )
+ end
- expect(request_handler_todo.reload).to be_done
+ expect do
+ service.resolve_access_request_todos(group_requester)
+ end.to change {
+ Todo.pending.where(target: group).for_author(group_requester.user)
+ .for_action(Todo::MEMBER_ACCESS_REQUESTED).count
+ }.from(2).to(0)
+
+ expect(access_request_todos.each(&:reload)).to all be_done
+ expect(another_pending_todo.reload).not_to be_done
+ expect(another_group_todo.reload).not_to be_done
+ end
+
+ it 'marks the todos for project access request handlers as done' do
+ # The project has 1 owner already. Adding another owner here
+ project.add_member(john_doe, Gitlab::Access::OWNER)
+
+ access_request_todo = create(:todo,
+ user: john_doe,
+ state: :pending,
+ action: Todo::MEMBER_ACCESS_REQUESTED,
+ author: project_requester.user,
+ target: project
+ )
+
+ expect do
+ service.resolve_access_request_todos(project_requester)
+ end.to change {
+ Todo.pending.where(target: project).for_author(project_requester.user)
+ .for_action(Todo::MEMBER_ACCESS_REQUESTED).count
+ }.from(2).to(0) # The original owner todo was created with the pending access request
+
+ expect(access_request_todo.reload).to be_done
+ expect(another_pending_todo.reload).to be_pending
+ expect(another_project_todo.reload).to be_pending
end
end
diff --git a/spec/services/todos/allowed_target_filter_service_spec.rb b/spec/services/todos/allowed_target_filter_service_spec.rb
index 1d2b1b044db..3929e3788d0 100644
--- a/spec/services/todos/allowed_target_filter_service_spec.rb
+++ b/spec/services/todos/allowed_target_filter_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Todos::AllowedTargetFilterService do
+RSpec.describe Todos::AllowedTargetFilterService, feature_category: :team_planning do
include DesignManagementTestHelpers
let_it_be(:authorized_group) { create(:group, :private) }
diff --git a/spec/services/todos/destroy/confidential_issue_service_spec.rb b/spec/services/todos/destroy/confidential_issue_service_spec.rb
index e3dcc2bae95..9de71faf8bf 100644
--- a/spec/services/todos/destroy/confidential_issue_service_spec.rb
+++ b/spec/services/todos/destroy/confidential_issue_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Todos::Destroy::ConfidentialIssueService do
+RSpec.describe Todos::Destroy::ConfidentialIssueService, feature_category: :team_planning do
let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:author) { create(:user) }
diff --git a/spec/services/todos/destroy/design_service_spec.rb b/spec/services/todos/destroy/design_service_spec.rb
index 92b25d94dc6..628398e7062 100644
--- a/spec/services/todos/destroy/design_service_spec.rb
+++ b/spec/services/todos/destroy/design_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Todos::Destroy::DesignService do
+RSpec.describe Todos::Destroy::DesignService, feature_category: :design_management do
let_it_be(:user) { create(:user) }
let_it_be(:user_2) { create(:user) }
let_it_be(:design) { create(:design) }
diff --git a/spec/services/todos/destroy/destroyed_issuable_service_spec.rb b/spec/services/todos/destroy/destroyed_issuable_service_spec.rb
index 6d6abe06d1c..63ff189ede5 100644
--- a/spec/services/todos/destroy/destroyed_issuable_service_spec.rb
+++ b/spec/services/todos/destroy/destroyed_issuable_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Todos::Destroy::DestroyedIssuableService do
+RSpec.describe Todos::Destroy::DestroyedIssuableService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:user) { create(:user) }
diff --git a/spec/services/todos/destroy/project_private_service_spec.rb b/spec/services/todos/destroy/project_private_service_spec.rb
index 1d1c010535d..cc15f6eab8b 100644
--- a/spec/services/todos/destroy/project_private_service_spec.rb
+++ b/spec/services/todos/destroy/project_private_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Todos::Destroy::ProjectPrivateService do
+RSpec.describe Todos::Destroy::ProjectPrivateService, feature_category: :team_planning do
let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, group: group) }
let(:user) { create(:user) }
diff --git a/spec/services/todos/destroy/unauthorized_features_service_spec.rb b/spec/services/todos/destroy/unauthorized_features_service_spec.rb
index 5f6c9b0cdf0..c02c0dfd5c8 100644
--- a/spec/services/todos/destroy/unauthorized_features_service_spec.rb
+++ b/spec/services/todos/destroy/unauthorized_features_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Todos::Destroy::UnauthorizedFeaturesService do
+RSpec.describe Todos::Destroy::UnauthorizedFeaturesService, feature_category: :team_planning do
let_it_be(:project, reload: true) { create(:project, :public, :repository) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:mr) { create(:merge_request, source_project: project) }
diff --git a/spec/services/topics/merge_service_spec.rb b/spec/services/topics/merge_service_spec.rb
index 98247250a61..705b222b39e 100644
--- a/spec/services/topics/merge_service_spec.rb
+++ b/spec/services/topics/merge_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Topics::MergeService do
+RSpec.describe Topics::MergeService, feature_category: :shared do
let_it_be(:source_topic) { create(:topic, name: 'source_topic') }
let_it_be(:target_topic) { create(:topic, name: 'target_topic') }
let_it_be(:project_1) { create(:project, :public, topic_list: source_topic.name) }
@@ -47,7 +47,7 @@ RSpec.describe Topics::MergeService do
where(:source_topic_parameter, :target_topic_parameter, :expected_message) do
nil | ref(:target_topic) | 'The source topic is not a topic.'
ref(:source_topic) | nil | 'The target topic is not a topic.'
- ref(:target_topic) | ref(:target_topic) | 'The source topic and the target topic are identical.' # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
+ ref(:target_topic) | ref(:target_topic) | 'The source topic and the target topic are identical.'
end
with_them do
diff --git a/spec/services/two_factor/destroy_service_spec.rb b/spec/services/two_factor/destroy_service_spec.rb
index 30c189520fd..0811ce336c8 100644
--- a/spec/services/two_factor/destroy_service_spec.rb
+++ b/spec/services/two_factor/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TwoFactor::DestroyService do
+RSpec.describe TwoFactor::DestroyService, feature_category: :system_access do
let_it_be(:current_user) { create(:user) }
subject { described_class.new(current_user, user: user).execute }
diff --git a/spec/services/update_container_registry_info_service_spec.rb b/spec/services/update_container_registry_info_service_spec.rb
index 64071e79508..416b08bd04b 100644
--- a/spec/services/update_container_registry_info_service_spec.rb
+++ b/spec/services/update_container_registry_info_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UpdateContainerRegistryInfoService do
+RSpec.describe UpdateContainerRegistryInfoService, feature_category: :container_registry do
let_it_be(:application_settings) { Gitlab::CurrentSettings }
let_it_be(:api_url) { 'http://registry.gitlab' }
diff --git a/spec/services/update_merge_request_metrics_service_spec.rb b/spec/services/update_merge_request_metrics_service_spec.rb
index a07fcee91e4..f30836fbaf5 100644
--- a/spec/services/update_merge_request_metrics_service_spec.rb
+++ b/spec/services/update_merge_request_metrics_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequestMetricsService do
+RSpec.describe MergeRequestMetricsService, feature_category: :code_review_workflow do
let(:metrics) { create(:merge_request).metrics }
describe '#merge' do
diff --git a/spec/services/upload_service_spec.rb b/spec/services/upload_service_spec.rb
index 48aa65451f3..518d12d5b41 100644
--- a/spec/services/upload_service_spec.rb
+++ b/spec/services/upload_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UploadService do
+RSpec.describe UploadService, feature_category: :shared do
describe 'File service' do
before do
@user = create(:user)
diff --git a/spec/services/uploads/destroy_service_spec.rb b/spec/services/uploads/destroy_service_spec.rb
index bb58da231b6..76ac2ec245e 100644
--- a/spec/services/uploads/destroy_service_spec.rb
+++ b/spec/services/uploads/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Uploads::DestroyService do
+RSpec.describe Uploads::DestroyService, feature_category: :shared do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be_with_reload(:upload) { create(:upload, :issuable_upload, model: project) }
diff --git a/spec/services/user_preferences/update_service_spec.rb b/spec/services/user_preferences/update_service_spec.rb
index 59089a4a7af..601a023c30d 100644
--- a/spec/services/user_preferences/update_service_spec.rb
+++ b/spec/services/user_preferences/update_service_spec.rb
@@ -2,9 +2,9 @@
require 'spec_helper'
-RSpec.describe UserPreferences::UpdateService do
+RSpec.describe UserPreferences::UpdateService, feature_category: :user_profile do
let(:user) { create(:user) }
- let(:params) { { view_diffs_file_by_file: false } }
+ let(:params) { { view_diffs_file_by_file: false, pass_user_identities_to_ci_jwt: true } }
describe '#execute' do
subject(:service) { described_class.new(user, params) }
@@ -15,6 +15,8 @@ RSpec.describe UserPreferences::UpdateService do
expect(result.status).to eq(:success)
expect(result.payload[:preferences].view_diffs_file_by_file).to eq(params[:view_diffs_file_by_file])
+ expect(result.payload[:preferences].pass_user_identities_to_ci_jwt
+ ).to eq(params[:pass_user_identities_to_ci_jwt])
end
end
diff --git a/spec/services/user_project_access_changed_service_spec.rb b/spec/services/user_project_access_changed_service_spec.rb
index 356675d55f2..563af8e7e9e 100644
--- a/spec/services/user_project_access_changed_service_spec.rb
+++ b/spec/services/user_project_access_changed_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UserProjectAccessChangedService, feature_category: :authentication_and_authorization do
+RSpec.describe UserProjectAccessChangedService, feature_category: :system_access do
describe '#execute' do
it 'permits high-priority operation' do
expect(AuthorizedProjectsWorker).to receive(:bulk_perform_async)
diff --git a/spec/services/users/activity_service_spec.rb b/spec/services/users/activity_service_spec.rb
index 6c0d93f568a..e2141f9bf6f 100644
--- a/spec/services/users/activity_service_spec.rb
+++ b/spec/services/users/activity_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::ActivityService do
+RSpec.describe Users::ActivityService, feature_category: :user_profile do
include ExclusiveLeaseHelpers
let(:user) { create(:user, last_activity_on: last_activity_on) }
@@ -57,7 +57,6 @@ RSpec.describe Users::ActivityService do
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
subject(:record_activity) { described_class.new(author: user, namespace: namespace, project: project).execute }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase3 }
let(:category) { described_class.name }
let(:action) { 'perform_action' }
let(:label) { 'redis_hll_counters.manage.unique_active_users_monthly' }
diff --git a/spec/services/users/approve_service_spec.rb b/spec/services/users/approve_service_spec.rb
index 34eb5b18ff6..09379857c38 100644
--- a/spec/services/users/approve_service_spec.rb
+++ b/spec/services/users/approve_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::ApproveService do
+RSpec.describe Users::ApproveService, feature_category: :user_management do
let_it_be(:current_user) { create(:admin) }
let(:user) { create(:user, :blocked_pending_approval) }
@@ -75,6 +75,24 @@ RSpec.describe Users::ApproveService do
expect { subject }.to have_enqueued_mail(DeviseMailer, :user_admin_approval)
end
+ context 'when the user was created via sign up' do
+ it 'does not send a password reset email' do
+ expect { subject }.not_to have_enqueued_mail(Notify, :new_user_email)
+ end
+ end
+
+ context 'when the user was created by an admin' do
+ let(:user) { create(:user, :blocked_pending_approval, created_by_id: current_user.id) }
+
+ it 'sends a password reset email' do
+ allow(user).to receive(:generate_reset_token).and_return(:reset_token)
+
+ expect(Notify).to receive(:new_user_email).with(user.id, :reset_token).and_call_original
+
+ expect { subject }.to have_enqueued_mail(Notify, :new_user_email)
+ end
+ end
+
context 'email confirmation status' do
context 'user is unconfirmed' do
let(:user) { create(:user, :blocked_pending_approval, :unconfirmed) }
diff --git a/spec/services/users/authorized_build_service_spec.rb b/spec/services/users/authorized_build_service_spec.rb
index 57a122cbf35..7eed6833cba 100644
--- a/spec/services/users/authorized_build_service_spec.rb
+++ b/spec/services/users/authorized_build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::AuthorizedBuildService do
+RSpec.describe Users::AuthorizedBuildService, feature_category: :user_management do
describe '#execute' do
let_it_be(:current_user) { create(:user) }
diff --git a/spec/services/users/ban_service_spec.rb b/spec/services/users/ban_service_spec.rb
index 3f9c7ebf067..5be5de82e91 100644
--- a/spec/services/users/ban_service_spec.rb
+++ b/spec/services/users/ban_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::BanService do
+RSpec.describe Users::BanService, feature_category: :user_management do
let(:user) { create(:user) }
let_it_be(:current_user) { create(:admin) }
diff --git a/spec/services/users/banned_user_base_service_spec.rb b/spec/services/users/banned_user_base_service_spec.rb
index 29a549f0f49..65b24e08d80 100644
--- a/spec/services/users/banned_user_base_service_spec.rb
+++ b/spec/services/users/banned_user_base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::BannedUserBaseService do
+RSpec.describe Users::BannedUserBaseService, feature_category: :user_management do
let(:admin) { create(:admin) }
let(:base_service) { described_class.new(admin) }
diff --git a/spec/services/users/batch_status_cleaner_service_spec.rb b/spec/services/users/batch_status_cleaner_service_spec.rb
index 46a004542d8..8feec761fd0 100644
--- a/spec/services/users/batch_status_cleaner_service_spec.rb
+++ b/spec/services/users/batch_status_cleaner_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::BatchStatusCleanerService do
+RSpec.describe Users::BatchStatusCleanerService, feature_category: :user_management do
let_it_be(:user_status_1) { create(:user_status, emoji: 'coffee', message: 'msg1', clear_status_at: 1.year.ago) }
let_it_be(:user_status_2) { create(:user_status, emoji: 'coffee', message: 'msg1', clear_status_at: 1.year.from_now) }
let_it_be(:user_status_3) { create(:user_status, emoji: 'coffee', message: 'msg1', clear_status_at: 2.years.ago) }
diff --git a/spec/services/users/block_service_spec.rb b/spec/services/users/block_service_spec.rb
index 7ff9a887f38..63aa375c8af 100644
--- a/spec/services/users/block_service_spec.rb
+++ b/spec/services/users/block_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::BlockService do
+RSpec.describe Users::BlockService, feature_category: :user_management do
let_it_be(:current_user) { create(:admin) }
subject(:service) { described_class.new(current_user) }
diff --git a/spec/services/users/build_service_spec.rb b/spec/services/users/build_service_spec.rb
index 98fe6d9b5ba..f3236d40412 100644
--- a/spec/services/users/build_service_spec.rb
+++ b/spec/services/users/build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::BuildService do
+RSpec.describe Users::BuildService, feature_category: :user_management do
using RSpec::Parameterized::TableSyntax
describe '#execute' do
diff --git a/spec/services/users/create_service_spec.rb b/spec/services/users/create_service_spec.rb
index f3c9701c556..eac4faa2042 100644
--- a/spec/services/users/create_service_spec.rb
+++ b/spec/services/users/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::CreateService do
+RSpec.describe Users::CreateService, feature_category: :user_management do
describe '#execute' do
let(:password) { User.random_password }
let(:admin_user) { create(:admin) }
diff --git a/spec/services/users/deactivate_service_spec.rb b/spec/services/users/deactivate_service_spec.rb
new file mode 100644
index 00000000000..0bb6e51a3b1
--- /dev/null
+++ b/spec/services/users/deactivate_service_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Users::DeactivateService, feature_category: :user_management do
+ let_it_be(:current_user) { build(:admin) }
+ let_it_be(:user) { build(:user) }
+
+ subject(:service) { described_class.new(current_user) }
+
+ describe '#execute' do
+ subject(:operation) { service.execute(user) }
+
+ context 'when successful', :enable_admin_mode do
+ let(:user) { create(:user) }
+
+ it 'returns success status' do
+ expect(operation[:status]).to eq(:success)
+ end
+
+ it "changes the user's state" do
+ expect { operation }.to change { user.state }.to('deactivated')
+ end
+
+ it 'creates a log entry' do
+ expect(Gitlab::AppLogger).to receive(:info).with(message: "User deactivated", user: user.username,
+ email: user.email, deactivated_by: current_user.username, ip_address: current_user.current_sign_in_ip.to_s)
+
+ operation
+ end
+ end
+
+ context 'when the user is already deactivated', :enable_admin_mode do
+ let(:user) { create(:user, :deactivated) }
+
+ it 'returns error result' do
+ aggregate_failures 'error result' do
+ expect(operation[:status]).to eq(:success)
+ expect(operation[:message]).to eq('User has already been deactivated')
+ end
+ end
+
+ it "does not change the user's state" do
+ expect { operation }.not_to change { user.state }
+ end
+ end
+
+ context 'when internal user', :enable_admin_mode do
+ let(:user) { create(:user, :bot) }
+
+ it 'returns an error message' do
+ expect(operation[:status]).to eq(:error)
+ expect(operation[:message]).to eq('Internal users cannot be deactivated')
+ expect(operation.reason).to eq :forbidden
+ end
+ end
+
+ context 'when user is blocked', :enable_admin_mode do
+ let(:user) { create(:user, :blocked) }
+
+ it 'returns an error message' do
+ expect(operation[:status]).to eq(:error)
+ expect(operation[:message]).to eq('Error occurred. A blocked user cannot be deactivated')
+ expect(operation.reason).to eq :forbidden
+ end
+ end
+
+ context 'when user is not an admin' do
+ it 'returns permissions error message' do
+ expect(operation[:status]).to eq(:error)
+ expect(operation[:message]).to eq("You are not authorized to perform this action")
+ expect(operation.reason).to eq :forbidden
+ end
+ end
+
+ context 'when skip_authorization is true' do
+ let(:non_admin_user) { create(:user) }
+ let(:user_to_deactivate) { create(:user) }
+ let(:skip_authorization_service) { described_class.new(non_admin_user, skip_authorization: true) }
+
+ it 'deactivates the user even if the current user is not an admin' do
+ expect(skip_authorization_service.execute(user_to_deactivate)[:status]).to eq(:success)
+ end
+ end
+ end
+end
diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb
index 18ad946b289..5cd11efe942 100644
--- a/spec/services/users/destroy_service_spec.rb
+++ b/spec/services/users/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::DestroyService do
+RSpec.describe Users::DestroyService, feature_category: :user_management do
let!(:user) { create(:user) }
let!(:admin) { create(:admin) }
let!(:namespace) { user.namespace }
diff --git a/spec/services/users/dismiss_callout_service_spec.rb b/spec/services/users/dismiss_callout_service_spec.rb
index 6ba9f180444..776388ef5f1 100644
--- a/spec/services/users/dismiss_callout_service_spec.rb
+++ b/spec/services/users/dismiss_callout_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::DismissCalloutService do
+RSpec.describe Users::DismissCalloutService, feature_category: :user_management do
describe '#execute' do
let_it_be(:user) { create(:user) }
diff --git a/spec/services/users/dismiss_group_callout_service_spec.rb b/spec/services/users/dismiss_group_callout_service_spec.rb
index d74602a7606..a653fa7ee00 100644
--- a/spec/services/users/dismiss_group_callout_service_spec.rb
+++ b/spec/services/users/dismiss_group_callout_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::DismissGroupCalloutService do
+RSpec.describe Users::DismissGroupCalloutService, feature_category: :user_management do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
diff --git a/spec/services/users/dismiss_project_callout_service_spec.rb b/spec/services/users/dismiss_project_callout_service_spec.rb
index 73e50a4c37d..7bcb11e4dbc 100644
--- a/spec/services/users/dismiss_project_callout_service_spec.rb
+++ b/spec/services/users/dismiss_project_callout_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::DismissProjectCalloutService do
+RSpec.describe Users::DismissProjectCalloutService, feature_category: :user_management do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
diff --git a/spec/services/users/email_verification/generate_token_service_spec.rb b/spec/services/users/email_verification/generate_token_service_spec.rb
index e7aa1bf8306..8b2b57cbf62 100644
--- a/spec/services/users/email_verification/generate_token_service_spec.rb
+++ b/spec/services/users/email_verification/generate_token_service_spec.rb
@@ -2,12 +2,13 @@
require 'spec_helper'
-RSpec.describe Users::EmailVerification::GenerateTokenService do
+RSpec.describe Users::EmailVerification::GenerateTokenService, feature_category: :system_access do
using RSpec::Parameterized::TableSyntax
- let(:service) { described_class.new(attr: attr) }
+ let(:user) { build_stubbed(:user) }
+ let(:service) { described_class.new(attr: attr, user: user) }
let(:token) { 'token' }
- let(:digest) { Devise.token_generator.digest(User, attr, token) }
+ let(:digest) { service.send(:digest) }
describe '#execute' do
context 'with a valid attribute' do
@@ -33,5 +34,21 @@ RSpec.describe Users::EmailVerification::GenerateTokenService do
expect { service.execute }.to raise_error(ArgumentError, 'Invalid attribute')
end
end
+
+ context 'when similar tokens are generated' do
+ let(:attr) { :confirmation_token }
+
+ before do
+ allow_next_instance_of(described_class) do |service|
+ allow(service).to receive(:generate_token).and_return(token)
+ end
+ end
+
+ it 'generates a unique digest' do
+ second_service = described_class.new(attr: attr, user: build_stubbed(:user))
+
+ expect(service.execute[1]).not_to eq(second_service.execute[1])
+ end
+ end
end
end
diff --git a/spec/services/users/email_verification/validate_token_service_spec.rb b/spec/services/users/email_verification/validate_token_service_spec.rb
index 44af4a4d36f..c8924bc20b7 100644
--- a/spec/services/users/email_verification/validate_token_service_spec.rb
+++ b/spec/services/users/email_verification/validate_token_service_spec.rb
@@ -2,15 +2,16 @@
require 'spec_helper'
-RSpec.describe Users::EmailVerification::ValidateTokenService, :clean_gitlab_redis_rate_limiting do
+RSpec.describe Users::EmailVerification::ValidateTokenService, :clean_gitlab_redis_rate_limiting, feature_category: :system_access do
using RSpec::Parameterized::TableSyntax
let(:service) { described_class.new(attr: attr, user: user, token: token) }
+ let(:email) { build_stubbed(:user).email }
let(:token) { 'token' }
- let(:encrypted_token) { Devise.token_generator.digest(User, attr, token) }
+ let(:encrypted_token) { Devise.token_generator.digest(User, email, token) }
let(:generated_at_attr) { attr == :unlock_token ? :locked_at : :confirmation_sent_at }
let(:token_generated_at) { 1.minute.ago }
- let(:user) { build(:user, attr => encrypted_token, generated_at_attr => token_generated_at) }
+ let(:user) { build(:user, email: email, attr => encrypted_token, generated_at_attr => token_generated_at) }
describe '#execute' do
context 'with a valid attribute' do
diff --git a/spec/services/users/in_product_marketing_email_records_spec.rb b/spec/services/users/in_product_marketing_email_records_spec.rb
index 0b9400dcd12..059f0890b53 100644
--- a/spec/services/users/in_product_marketing_email_records_spec.rb
+++ b/spec/services/users/in_product_marketing_email_records_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::InProductMarketingEmailRecords do
+RSpec.describe Users::InProductMarketingEmailRecords, feature_category: :onboarding do
let_it_be(:user) { create :user }
subject(:records) { described_class.new }
diff --git a/spec/services/users/keys_count_service_spec.rb b/spec/services/users/keys_count_service_spec.rb
index 607d2946b2c..258fe351e4b 100644
--- a/spec/services/users/keys_count_service_spec.rb
+++ b/spec/services/users/keys_count_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::KeysCountService, :use_clean_rails_memory_store_caching do
+RSpec.describe Users::KeysCountService, :use_clean_rails_memory_store_caching, feature_category: :system_access do
let(:user) { create(:user) }
subject { described_class.new(user) }
diff --git a/spec/services/users/last_push_event_service_spec.rb b/spec/services/users/last_push_event_service_spec.rb
index 5b755db407f..fe61f12fe1a 100644
--- a/spec/services/users/last_push_event_service_spec.rb
+++ b/spec/services/users/last_push_event_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::LastPushEventService do
+RSpec.describe Users::LastPushEventService, feature_category: :source_code_management do
let(:user) { build(:user, id: 1) }
let(:project) { build(:project, id: 2) }
let(:event) { build(:push_event, id: 3, author: user, project: project) }
diff --git a/spec/services/users/migrate_records_to_ghost_user_in_batches_service_spec.rb b/spec/services/users/migrate_records_to_ghost_user_in_batches_service_spec.rb
index 107ff82016c..0b9f92a868e 100644
--- a/spec/services/users/migrate_records_to_ghost_user_in_batches_service_spec.rb
+++ b/spec/services/users/migrate_records_to_ghost_user_in_batches_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::MigrateRecordsToGhostUserInBatchesService do
+RSpec.describe Users::MigrateRecordsToGhostUserInBatchesService, feature_category: :user_management do
let(:service) { described_class.new }
let_it_be(:ghost_user_migration) { create(:ghost_user_migration) }
diff --git a/spec/services/users/migrate_records_to_ghost_user_service_spec.rb b/spec/services/users/migrate_records_to_ghost_user_service_spec.rb
index 827d6f652a4..cfa0ddff04d 100644
--- a/spec/services/users/migrate_records_to_ghost_user_service_spec.rb
+++ b/spec/services/users/migrate_records_to_ghost_user_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::MigrateRecordsToGhostUserService do
+RSpec.describe Users::MigrateRecordsToGhostUserService, feature_category: :user_management do
include BatchDestroyDependentAssociationsHelper
let!(:user) { create(:user) }
diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb
index e33886d2add..55b27954a74 100644
--- a/spec/services/users/refresh_authorized_projects_service_spec.rb
+++ b/spec/services/users/refresh_authorized_projects_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::RefreshAuthorizedProjectsService do
+RSpec.describe Users::RefreshAuthorizedProjectsService, feature_category: :user_management do
include ExclusiveLeaseHelpers
# We're using let! here so that any expectations for the service class are not
diff --git a/spec/services/users/registrations_build_service_spec.rb b/spec/services/users/registrations_build_service_spec.rb
index fa53a4cc604..736db855fe0 100644
--- a/spec/services/users/registrations_build_service_spec.rb
+++ b/spec/services/users/registrations_build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::RegistrationsBuildService do
+RSpec.describe Users::RegistrationsBuildService, feature_category: :system_access do
describe '#execute' do
let(:base_params) { build_stubbed(:user).slice(:first_name, :last_name, :username, :email, :password) }
let(:skip_param) { {} }
diff --git a/spec/services/users/reject_service_spec.rb b/spec/services/users/reject_service_spec.rb
index 37d003c5dac..f72666d8a63 100644
--- a/spec/services/users/reject_service_spec.rb
+++ b/spec/services/users/reject_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::RejectService do
+RSpec.describe Users::RejectService, feature_category: :user_management do
let_it_be(:current_user) { create(:admin) }
let(:user) { create(:user, :blocked_pending_approval) }
diff --git a/spec/services/users/repair_ldap_blocked_service_spec.rb b/spec/services/users/repair_ldap_blocked_service_spec.rb
index 54540d68af2..424c14ccdbc 100644
--- a/spec/services/users/repair_ldap_blocked_service_spec.rb
+++ b/spec/services/users/repair_ldap_blocked_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::RepairLdapBlockedService do
+RSpec.describe Users::RepairLdapBlockedService, feature_category: :system_access do
let(:user) { create(:omniauth_user, provider: 'ldapmain', state: 'ldap_blocked') }
let(:identity) { user.ldap_identity }
diff --git a/spec/services/users/respond_to_terms_service_spec.rb b/spec/services/users/respond_to_terms_service_spec.rb
index 1997dcd0e04..dc33f98535a 100644
--- a/spec/services/users/respond_to_terms_service_spec.rb
+++ b/spec/services/users/respond_to_terms_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::RespondToTermsService do
+RSpec.describe Users::RespondToTermsService, feature_category: :user_profile do
let(:user) { create(:user) }
let(:term) { create(:term) }
diff --git a/spec/services/users/saved_replies/create_service_spec.rb b/spec/services/users/saved_replies/create_service_spec.rb
index e01b6248308..ee42a53a220 100644
--- a/spec/services/users/saved_replies/create_service_spec.rb
+++ b/spec/services/users/saved_replies/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::SavedReplies::CreateService do
+RSpec.describe Users::SavedReplies::CreateService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:current_user) { create(:user) }
let_it_be(:saved_reply) { create(:saved_reply, user: current_user) }
diff --git a/spec/services/users/saved_replies/destroy_service_spec.rb b/spec/services/users/saved_replies/destroy_service_spec.rb
index cb97fac7b7c..41c2013e3df 100644
--- a/spec/services/users/saved_replies/destroy_service_spec.rb
+++ b/spec/services/users/saved_replies/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::SavedReplies::DestroyService do
+RSpec.describe Users::SavedReplies::DestroyService, feature_category: :team_planning do
describe '#execute' do
let!(:saved_reply) { create(:saved_reply) }
diff --git a/spec/services/users/saved_replies/update_service_spec.rb b/spec/services/users/saved_replies/update_service_spec.rb
index bdb54d7c8f7..c18b7395040 100644
--- a/spec/services/users/saved_replies/update_service_spec.rb
+++ b/spec/services/users/saved_replies/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::SavedReplies::UpdateService do
+RSpec.describe Users::SavedReplies::UpdateService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:current_user) { create(:user) }
let_it_be(:saved_reply) { create(:saved_reply, user: current_user) }
diff --git a/spec/services/users/set_status_service_spec.rb b/spec/services/users/set_status_service_spec.rb
index 76e86506d94..b75c558785f 100644
--- a/spec/services/users/set_status_service_spec.rb
+++ b/spec/services/users/set_status_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::SetStatusService do
+RSpec.describe Users::SetStatusService, feature_category: :user_management do
let(:current_user) { create(:user) }
subject(:service) { described_class.new(current_user, params) }
diff --git a/spec/services/users/signup_service_spec.rb b/spec/services/users/signup_service_spec.rb
index ef532e01d0b..29663411346 100644
--- a/spec/services/users/signup_service_spec.rb
+++ b/spec/services/users/signup_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::SignupService do
+RSpec.describe Users::SignupService, feature_category: :system_access do
let(:user) { create(:user, setup_for_company: true) }
describe '#execute' do
@@ -48,11 +48,7 @@ RSpec.describe Users::SignupService do
expect(user.reload.setup_for_company).to be(false)
end
- context 'when on .com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
+ context 'when on SaaS', :saas do
it 'returns an error result when setup_for_company is missing' do
result = update_user(user, setup_for_company: '')
diff --git a/spec/services/users/unban_service_spec.rb b/spec/services/users/unban_service_spec.rb
index 3dcb8450e7b..20fe40b370f 100644
--- a/spec/services/users/unban_service_spec.rb
+++ b/spec/services/users/unban_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::UnbanService do
+RSpec.describe Users::UnbanService, feature_category: :user_management do
let(:user) { create(:user) }
let_it_be(:current_user) { create(:admin) }
diff --git a/spec/services/users/unblock_service_spec.rb b/spec/services/users/unblock_service_spec.rb
index 25ee99427ab..95a077d6100 100644
--- a/spec/services/users/unblock_service_spec.rb
+++ b/spec/services/users/unblock_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::UnblockService do
+RSpec.describe Users::UnblockService, feature_category: :user_management do
let_it_be(:current_user) { create(:admin) }
subject(:service) { described_class.new(current_user) }
diff --git a/spec/services/users/update_canonical_email_service_spec.rb b/spec/services/users/update_canonical_email_service_spec.rb
index 1dead13d338..d3c414f6db4 100644
--- a/spec/services/users/update_canonical_email_service_spec.rb
+++ b/spec/services/users/update_canonical_email_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::UpdateCanonicalEmailService do
+RSpec.describe Users::UpdateCanonicalEmailService, feature_category: :user_profile do
let(:other_email) { "differentaddress@includeddomain.com" }
before do
@@ -92,23 +92,25 @@ RSpec.describe Users::UpdateCanonicalEmailService do
context 'when the user email is not processable' do
[nil, 'nonsense'].each do |invalid_address|
- before do
- user.email = invalid_address
- end
+ context "with #{invalid_address}" do
+ before do
+ user.email = invalid_address
+ end
- specify do
- subject.execute
+ specify do
+ subject.execute
- expect(user.user_canonical_email).to be_nil
- end
+ expect(user.user_canonical_email).to be_nil
+ end
- it 'preserves any existing record' do
- user.email = nil
- user.user_canonical_email = build(:user_canonical_email, canonical_email: other_email)
+ it 'preserves any existing record' do
+ user.email = nil
+ user.user_canonical_email = build(:user_canonical_email, canonical_email: other_email)
- subject.execute
+ subject.execute
- expect(user.user_canonical_email.canonical_email).to eq other_email
+ expect(user.user_canonical_email.canonical_email).to eq other_email
+ end
end
end
end
diff --git a/spec/services/users/update_highest_member_role_service_spec.rb b/spec/services/users/update_highest_member_role_service_spec.rb
index 89ddd635bb6..06f4d787d72 100644
--- a/spec/services/users/update_highest_member_role_service_spec.rb
+++ b/spec/services/users/update_highest_member_role_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::UpdateHighestMemberRoleService do
+RSpec.describe Users::UpdateHighestMemberRoleService, feature_category: :user_management do
let(:user) { create(:user) }
let(:execute_service) { described_class.new(user).execute }
diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb
index f4ea757f81a..9ff3d9208fa 100644
--- a/spec/services/users/update_service_spec.rb
+++ b/spec/services/users/update_service_spec.rb
@@ -2,18 +2,17 @@
require 'spec_helper'
-RSpec.describe Users::UpdateService do
+RSpec.describe Users::UpdateService, feature_category: :user_profile do
let(:password) { User.random_password }
let(:user) { create(:user, password: password, password_confirmation: password) }
describe '#execute' do
it 'updates time preferences' do
- result = update_user(user, timezone: 'Europe/Warsaw', time_display_relative: true, time_format_in_24h: false)
+ result = update_user(user, timezone: 'Europe/Warsaw', time_display_relative: true)
expect(result).to eq(status: :success)
expect(user.reload.timezone).to eq('Europe/Warsaw')
expect(user.time_display_relative).to eq(true)
- expect(user.time_format_in_24h).to eq(false)
end
it 'returns an error result when record cannot be updated' do
@@ -185,6 +184,49 @@ RSpec.describe Users::UpdateService do
end.not_to raise_error
end
+ describe 'updates the enabled_following' do
+ let(:user) { create(:user) }
+
+ before do
+ 3.times do
+ user.follow(create(:user))
+ create(:user).follow(user)
+ end
+ user.reload
+ end
+
+ it 'removes followers and followees' do
+ expect do
+ update_user(user, enabled_following: false)
+ end.to change { user.followed_users.count }.from(3).to(0)
+ .and change { user.following_users.count }.from(3).to(0)
+ expect(user.enabled_following).to eq(false)
+ end
+
+ it 'does not remove followers/followees if feature flag is off' do
+ stub_feature_flags(disable_follow_users: false)
+
+ expect do
+ update_user(user, enabled_following: false)
+ end.to not_change { user.followed_users.count }
+ .and not_change { user.following_users.count }
+ end
+
+ context 'when there is more followers/followees then batch limit' do
+ before do
+ stub_env('BATCH_SIZE', 1)
+ end
+
+ it 'removes followers and followees' do
+ expect do
+ update_user(user, enabled_following: false)
+ end.to change { user.followed_users.count }.from(3).to(0)
+ .and change { user.following_users.count }.from(3).to(0)
+ expect(user.enabled_following).to eq(false)
+ end
+ end
+ end
+
def update_user(user, opts)
described_class.new(user, opts.merge(user: user)).execute
end
diff --git a/spec/services/users/update_todo_count_cache_service_spec.rb b/spec/services/users/update_todo_count_cache_service_spec.rb
index 3d96af928df..eec637cf5b4 100644
--- a/spec/services/users/update_todo_count_cache_service_spec.rb
+++ b/spec/services/users/update_todo_count_cache_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::UpdateTodoCountCacheService do
+RSpec.describe Users::UpdateTodoCountCacheService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:user1) { create(:user) }
let_it_be(:user2) { create(:user) }
diff --git a/spec/services/users/upsert_credit_card_validation_service_spec.rb b/spec/services/users/upsert_credit_card_validation_service_spec.rb
index ac7e619612f..ebd2502398d 100644
--- a/spec/services/users/upsert_credit_card_validation_service_spec.rb
+++ b/spec/services/users/upsert_credit_card_validation_service_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe Users::UpsertCreditCardValidationService do
- let_it_be(:user) { create(:user, requires_credit_card_verification: true) }
+RSpec.describe Users::UpsertCreditCardValidationService, feature_category: :user_profile do
+ let_it_be(:user) { create(:user) }
let(:user_id) { user.id }
let(:credit_card_validated_time) { Time.utc(2020, 1, 1) }
@@ -21,7 +21,7 @@ RSpec.describe Users::UpsertCreditCardValidationService do
end
describe '#execute' do
- subject(:service) { described_class.new(params, user) }
+ subject(:service) { described_class.new(params) }
context 'successfully set credit card validation record for the user' do
context 'when user does not have credit card validation record' do
@@ -42,10 +42,6 @@ RSpec.describe Users::UpsertCreditCardValidationService do
expiration_date: Date.new(expiration_year, 1, 31)
)
end
-
- it 'sets the requires_credit_card_verification attribute on the user to false' do
- expect { service.execute }.to change { user.reload.requires_credit_card_verification }.to(false)
- end
end
context 'when user has credit card validation record' do
diff --git a/spec/services/users/validate_manual_otp_service_spec.rb b/spec/services/users/validate_manual_otp_service_spec.rb
index d71735814f2..9a6083bc41c 100644
--- a/spec/services/users/validate_manual_otp_service_spec.rb
+++ b/spec/services/users/validate_manual_otp_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::ValidateManualOtpService do
+RSpec.describe Users::ValidateManualOtpService, feature_category: :user_profile do
let_it_be(:user) { create(:user) }
let(:otp_code) { 42 }
@@ -32,6 +32,20 @@ RSpec.describe Users::ValidateManualOtpService do
validate
end
+
+ it 'handles unexpected error' do
+ error_message = "boom!"
+
+ expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::FortiAuthenticator::ManualOtp) do |strategy|
+ expect(strategy).to receive(:validate).with(otp_code).once.and_raise(StandardError, error_message)
+ end
+ expect(Gitlab::ErrorTracking).to receive(:log_exception)
+
+ result = validate
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq(error_message)
+ end
end
context 'FortiTokenCloud' do
@@ -49,16 +63,23 @@ RSpec.describe Users::ValidateManualOtpService do
end
end
- context 'unexpected error' do
+ context 'DuoAuth' do
before do
- stub_feature_flags(forti_authenticator: user)
- allow(::Gitlab.config.forti_authenticator).to receive(:enabled).and_return(true)
+ allow(::Gitlab.config.duo_auth).to receive(:enabled).and_return(true)
end
- it 'returns error' do
+ it 'calls DuoAuth strategy' do
+ expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::DuoAuth::ManualOtp) do |strategy|
+ expect(strategy).to receive(:validate).with(otp_code).once
+ end
+
+ validate
+ end
+
+ it "handles unexpected error" do
error_message = "boom!"
- expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::FortiAuthenticator::ManualOtp) do |strategy|
+ expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::DuoAuth::ManualOtp) do |strategy|
expect(strategy).to receive(:validate).with(otp_code).once.and_raise(StandardError, error_message)
end
expect(Gitlab::ErrorTracking).to receive(:log_exception)
diff --git a/spec/services/users/validate_push_otp_service_spec.rb b/spec/services/users/validate_push_otp_service_spec.rb
index 960b6bcd3bb..4ef374cbb7f 100644
--- a/spec/services/users/validate_push_otp_service_spec.rb
+++ b/spec/services/users/validate_push_otp_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::ValidatePushOtpService do
+RSpec.describe Users::ValidatePushOtpService, feature_category: :user_profile do
let_it_be(:user) { create(:user) }
subject(:validate) { described_class.new(user).execute }
diff --git a/spec/services/verify_pages_domain_service_spec.rb b/spec/services/verify_pages_domain_service_spec.rb
index 42f7ebc85f9..d66d584d3d0 100644
--- a/spec/services/verify_pages_domain_service_spec.rb
+++ b/spec/services/verify_pages_domain_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe VerifyPagesDomainService do
+RSpec.describe VerifyPagesDomainService, feature_category: :pages do
using RSpec::Parameterized::TableSyntax
include EmailHelpers
diff --git a/spec/services/web_hooks/destroy_service_spec.rb b/spec/services/web_hooks/destroy_service_spec.rb
index ca8cb8a1b75..642c25ab312 100644
--- a/spec/services/web_hooks/destroy_service_spec.rb
+++ b/spec/services/web_hooks/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WebHooks::DestroyService do
+RSpec.describe WebHooks::DestroyService, feature_category: :webhooks do
let_it_be(:user) { create(:user) }
subject { described_class.new(user) }
diff --git a/spec/services/web_hooks/log_destroy_service_spec.rb b/spec/services/web_hooks/log_destroy_service_spec.rb
index 7634726e5a4..b0444b659ba 100644
--- a/spec/services/web_hooks/log_destroy_service_spec.rb
+++ b/spec/services/web_hooks/log_destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WebHooks::LogDestroyService do
+RSpec.describe WebHooks::LogDestroyService, feature_category: :webhooks do
subject(:service) { described_class.new(hook.id) }
describe '#execute' do
diff --git a/spec/services/web_hooks/log_execution_service_spec.rb b/spec/services/web_hooks/log_execution_service_spec.rb
index 8a845f60ad2..f56c07386fa 100644
--- a/spec/services/web_hooks/log_execution_service_spec.rb
+++ b/spec/services/web_hooks/log_execution_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WebHooks::LogExecutionService do
+RSpec.describe WebHooks::LogExecutionService, feature_category: :webhooks do
include ExclusiveLeaseHelpers
using RSpec::Parameterized::TableSyntax
diff --git a/spec/services/webauthn/authenticate_service_spec.rb b/spec/services/webauthn/authenticate_service_spec.rb
index b40f9465b63..ca940dff0eb 100644
--- a/spec/services/webauthn/authenticate_service_spec.rb
+++ b/spec/services/webauthn/authenticate_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require 'webauthn/fake_client'
-RSpec.describe Webauthn::AuthenticateService do
+RSpec.describe Webauthn::AuthenticateService, feature_category: :system_access do
let(:client) { WebAuthn::FakeClient.new(origin) }
let(:user) { create(:user) }
let(:challenge) { Base64.strict_encode64(SecureRandom.random_bytes(32)) }
diff --git a/spec/services/webauthn/register_service_spec.rb b/spec/services/webauthn/register_service_spec.rb
index bb9fa2080d2..2286d261e94 100644
--- a/spec/services/webauthn/register_service_spec.rb
+++ b/spec/services/webauthn/register_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require 'webauthn/fake_client'
-RSpec.describe Webauthn::RegisterService do
+RSpec.describe Webauthn::RegisterService, feature_category: :system_access do
let(:client) { WebAuthn::FakeClient.new(origin) }
let(:user) { create(:user) }
let(:challenge) { Base64.strict_encode64(SecureRandom.random_bytes(32)) }
diff --git a/spec/services/wiki_pages/base_service_spec.rb b/spec/services/wiki_pages/base_service_spec.rb
index 6ccc796014c..f434dc689ef 100644
--- a/spec/services/wiki_pages/base_service_spec.rb
+++ b/spec/services/wiki_pages/base_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WikiPages::BaseService do
+RSpec.describe WikiPages::BaseService, feature_category: :wiki do
let(:project) { double('project') }
let(:user) { double('user') }
diff --git a/spec/services/wiki_pages/create_service_spec.rb b/spec/services/wiki_pages/create_service_spec.rb
index fd3776f4207..ca2d38ad70d 100644
--- a/spec/services/wiki_pages/create_service_spec.rb
+++ b/spec/services/wiki_pages/create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WikiPages::CreateService do
+RSpec.describe WikiPages::CreateService, feature_category: :wiki do
it_behaves_like 'WikiPages::CreateService#execute', :project
describe '#execute' do
diff --git a/spec/services/wiki_pages/destroy_service_spec.rb b/spec/services/wiki_pages/destroy_service_spec.rb
index 9384ea1cd43..ff29fc59b3e 100644
--- a/spec/services/wiki_pages/destroy_service_spec.rb
+++ b/spec/services/wiki_pages/destroy_service_spec.rb
@@ -2,6 +2,6 @@
require 'spec_helper'
-RSpec.describe WikiPages::DestroyService do
+RSpec.describe WikiPages::DestroyService, feature_category: :wiki do
it_behaves_like 'WikiPages::DestroyService#execute', :project
end
diff --git a/spec/services/wiki_pages/event_create_service_spec.rb b/spec/services/wiki_pages/event_create_service_spec.rb
index 8476f872e98..cbc2bd82a98 100644
--- a/spec/services/wiki_pages/event_create_service_spec.rb
+++ b/spec/services/wiki_pages/event_create_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WikiPages::EventCreateService do
+RSpec.describe WikiPages::EventCreateService, feature_category: :wiki do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/wiki_pages/update_service_spec.rb b/spec/services/wiki_pages/update_service_spec.rb
index 62881817e32..79b2b55907b 100644
--- a/spec/services/wiki_pages/update_service_spec.rb
+++ b/spec/services/wiki_pages/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WikiPages::UpdateService do
+RSpec.describe WikiPages::UpdateService, feature_category: :wiki do
it_behaves_like 'WikiPages::UpdateService#execute', :project
describe '#execute' do
diff --git a/spec/services/wikis/create_attachment_service_spec.rb b/spec/services/wikis/create_attachment_service_spec.rb
index 22e34e1f373..fccdbd3040b 100644
--- a/spec/services/wikis/create_attachment_service_spec.rb
+++ b/spec/services/wikis/create_attachment_service_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Wikis::CreateAttachmentService do
+RSpec.describe Wikis::CreateAttachmentService, feature_category: :wiki do
let(:container) { create(:project, :wiki_repo) }
let(:user) { create(:user) }
let(:file_name) { 'filename.txt' }
diff --git a/spec/services/work_items/build_service_spec.rb b/spec/services/work_items/build_service_spec.rb
index 405b4414fc2..3ecf78e0659 100644
--- a/spec/services/work_items/build_service_spec.rb
+++ b/spec/services/work_items/build_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::BuildService do
+RSpec.describe WorkItems::BuildService, feature_category: :team_planning do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:guest) { create(:user) }
diff --git a/spec/services/work_items/create_from_task_service_spec.rb b/spec/services/work_items/create_from_task_service_spec.rb
index 7c5430f038c..b2f81f1dc54 100644
--- a/spec/services/work_items/create_from_task_service_spec.rb
+++ b/spec/services/work_items/create_from_task_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::CreateFromTaskService do
+RSpec.describe WorkItems::CreateFromTaskService, feature_category: :team_planning do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:list_work_item, refind: true) { create(:work_item, project: project, description: "- [ ] Item to be converted\n second line\n third line") }
diff --git a/spec/services/work_items/create_service_spec.rb b/spec/services/work_items/create_service_spec.rb
index 1b134c308f2..46e598c3f11 100644
--- a/spec/services/work_items/create_service_spec.rb
+++ b/spec/services/work_items/create_service_spec.rb
@@ -2,200 +2,255 @@
require 'spec_helper'
-RSpec.describe WorkItems::CreateService do
+RSpec.describe WorkItems::CreateService, feature_category: :team_planning do
include AfterNextHelpers
- let_it_be_with_reload(:project) { create(:project) }
- let_it_be(:parent) { create(:work_item, project: project) }
- let_it_be(:guest) { create(:user) }
- let_it_be(:reporter) { create(:user) }
- let_it_be(:user_with_no_access) { create(:user) }
-
- let(:widget_params) { {} }
- let(:spam_params) { double }
- let(:current_user) { guest }
- let(:opts) do
- {
- title: 'Awesome work_item',
- description: 'please fix'
- }
- end
-
- before_all do
- project.add_guest(guest)
- project.add_reporter(reporter)
- end
+ RSpec.shared_examples 'creates work item in container' do |container_type|
+ let_it_be_with_reload(:project) { create(:project) }
+ let_it_be_with_reload(:group) { create(:group) }
- describe '#execute' do
- let(:service) do
- described_class.new(
- container: project,
- current_user: current_user,
- params: opts,
- spam_params: spam_params,
- widget_params: widget_params
- )
+ let_it_be(:container) do
+ case container_type
+ when :project then project
+ when :project_namespace then project.project_namespace
+ when :group then group
+ end
end
- subject(:service_result) { service.execute }
+ let_it_be(:container_args) do
+ case container_type
+ when :project, :project_namespace then { project: project }
+ when :group then { namespace: group }
+ end
+ end
- before do
- stub_spam_services
+ let_it_be(:parent) { create(:work_item, **container_args) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:user_with_no_access) { create(:user) }
+
+ let(:widget_params) { {} }
+ let(:spam_params) { double }
+ let(:current_user) { guest }
+ let(:opts) do
+ {
+ title: 'Awesome work_item',
+ description: 'please fix'
+ }
end
- context 'when user is not allowed to create a work item in the project' do
- let(:current_user) { user_with_no_access }
+ before_all do
+ memberships_container = container.is_a?(Namespaces::ProjectNamespace) ? container.reload.project : container
+ memberships_container.add_guest(guest)
+ memberships_container.add_reporter(reporter)
+ end
- it { is_expected.to be_error }
+ describe '#execute' do
+ shared_examples 'fails creating work item and returns errors' do
+ it 'does not create new work item if parent can not be set' do
+ expect { service_result }.not_to change(WorkItem, :count)
- it 'returns an access error' do
- expect(service_result.errors).to contain_exactly('Operation not allowed')
+ expect(service_result[:status]).to be(:error)
+ expect(service_result[:message]).to match(error_message)
+ end
end
- end
- context 'when params are valid' do
- it 'created instance is a WorkItem' do
- expect(Issuable::CommonSystemNotesService).to receive_message_chain(:new, :execute)
+ let(:service) do
+ described_class.new(
+ container: container,
+ current_user: current_user,
+ params: opts,
+ spam_params: spam_params,
+ widget_params: widget_params
+ )
+ end
- work_item = service_result[:work_item]
+ subject(:service_result) { service.execute }
- expect(work_item).to be_persisted
- expect(work_item).to be_a(::WorkItem)
- expect(work_item.title).to eq('Awesome work_item')
- expect(work_item.description).to eq('please fix')
- expect(work_item.work_item_type.base_type).to eq('issue')
+ before do
+ stub_spam_services
end
- it 'calls NewIssueWorker with correct arguments' do
- expect(NewIssueWorker).to receive(:perform_async).with(Integer, current_user.id, 'WorkItem')
+ context 'when user is not allowed to create a work item in the container' do
+ let(:current_user) { user_with_no_access }
- service_result
+ it { is_expected.to be_error }
+
+ it 'returns an access error' do
+ expect(service_result.errors).to contain_exactly('Operation not allowed')
+ end
end
- end
- context 'when params are invalid' do
- let(:opts) { { title: '' } }
+ context 'when applying quick actions' do
+ let(:work_item) { service_result[:work_item] }
+ let(:opts) do
+ {
+ title: 'My work item',
+ work_item_type: work_item_type,
+ description: '/shrug'
+ }
+ end
- it { is_expected.to be_error }
+ context 'when work item type is not the default Issue' do
+ let(:work_item_type) { create(:work_item_type, :task, namespace: group) }
- it 'returns validation errors' do
- expect(service_result.errors).to contain_exactly("Title can't be blank")
- end
+ it 'saves the work item without applying the quick action' do
+ expect(service_result).to be_success
+ expect(work_item).to be_persisted
+ expect(work_item.description).to eq('/shrug')
+ end
+ end
- it 'does not execute after-create transaction widgets' do
- expect(service).to receive(:create).and_call_original
- expect(service).not_to receive(:execute_widgets)
- .with(callback: :after_create_in_transaction, widget_params: widget_params)
+ context 'when work item type is the default Issue' do
+ let(:work_item_type) { WorkItems::Type.default_by_type(:issue) }
- service_result
+ it 'saves the work item and applies the quick action' do
+ expect(service_result).to be_success
+ expect(work_item).to be_persisted
+ expect(work_item.description).to eq(' ¯\_(ツ)_/¯')
+ end
+ end
end
- end
- context 'checking spam' do
- it 'executes SpamActionService' do
- expect_next_instance_of(
- Spam::SpamActionService,
- {
- spammable: kind_of(WorkItem),
- spam_params: spam_params,
- user: an_instance_of(User),
- action: :create
- }
- ) do |instance|
- expect(instance).to receive(:execute)
+ context 'when params are valid' do
+ it 'created instance is a WorkItem' do
+ expect(Issuable::CommonSystemNotesService).to receive_message_chain(:new, :execute)
+
+ work_item = service_result[:work_item]
+
+ expect(work_item).to be_persisted
+ expect(work_item).to be_a(::WorkItem)
+ expect(work_item.title).to eq('Awesome work_item')
+ expect(work_item.description).to eq('please fix')
+ expect(work_item.work_item_type.base_type).to eq('issue')
end
- service_result
- end
- end
+ it 'calls NewIssueWorker with correct arguments' do
+ expect(NewIssueWorker).to receive(:perform_async).with(Integer, current_user.id, 'WorkItem')
- it_behaves_like 'work item widgetable service' do
- let(:widget_params) do
- {
- hierarchy_widget: { parent: parent }
- }
+ service_result
+ end
end
- let(:service) do
- described_class.new(
- container: project,
- current_user: current_user,
- params: opts,
- spam_params: spam_params,
- widget_params: widget_params
- )
+ context 'when params are invalid' do
+ let(:opts) { { title: '' } }
+
+ it { is_expected.to be_error }
+
+ it 'returns validation errors' do
+ expect(service_result.errors).to contain_exactly("Title can't be blank")
+ end
+
+ it 'does not execute after-create transaction widgets' do
+ expect(service).to receive(:create).and_call_original
+ expect(service).not_to receive(:execute_widgets)
+ .with(callback: :after_create_in_transaction, widget_params: widget_params)
+
+ service_result
+ end
end
- let(:service_execute) { service.execute }
+ context 'checking spam' do
+ it 'executes SpamActionService' do
+ expect_next_instance_of(
+ Spam::SpamActionService,
+ {
+ spammable: kind_of(WorkItem),
+ spam_params: spam_params,
+ user: an_instance_of(User),
+ action: :create
+ }
+ ) do |instance|
+ expect(instance).to receive(:execute)
+ end
+
+ service_result
+ end
+ end
- let(:supported_widgets) do
- [
+ it_behaves_like 'work item widgetable service' do
+ let(:widget_params) do
{
- klass: WorkItems::Widgets::HierarchyService::CreateService,
- callback: :after_create_in_transaction,
- params: { parent: parent }
+ hierarchy_widget: { parent: parent }
}
- ]
- end
- end
+ end
- describe 'hierarchy widget' do
- let(:widget_params) { { hierarchy_widget: { parent: parent } } }
+ let(:service) do
+ described_class.new(
+ container: container,
+ current_user: current_user,
+ params: opts,
+ spam_params: spam_params,
+ widget_params: widget_params
+ )
+ end
- shared_examples 'fails creating work item and returns errors' do
- it 'does not create new work item if parent can not be set' do
- expect { service_result }.not_to change(WorkItem, :count)
+ let(:service_execute) { service.execute }
- expect(service_result[:status]).to be(:error)
- expect(service_result[:message]).to match(error_message)
+ let(:supported_widgets) do
+ [
+ {
+ klass: WorkItems::Widgets::HierarchyService::CreateService,
+ callback: :after_create_in_transaction,
+ params: { parent: parent }
+ }
+ ]
end
end
- context 'when user can admin parent link' do
- let(:current_user) { reporter }
+ describe 'hierarchy widget' do
+ let(:widget_params) { { hierarchy_widget: { parent: parent } } }
- context 'when parent is valid work item' do
- let(:opts) do
- {
- title: 'Awesome work_item',
- description: 'please fix',
- work_item_type: WorkItems::Type.default_by_type(:task)
- }
- end
+ context 'when user can admin parent link' do
+ let(:current_user) { reporter }
- it 'creates new work item and sets parent reference' do
- expect { service_result }.to change(
- WorkItem, :count).by(1).and(change(
- WorkItems::ParentLink, :count).by(1))
+ context 'when parent is valid work item' do
+ let(:opts) do
+ {
+ title: 'Awesome work_item',
+ description: 'please fix',
+ work_item_type: WorkItems::Type.default_by_type(:task)
+ }
+ end
- expect(service_result[:status]).to be(:success)
+ it 'creates new work item and sets parent reference' do
+ expect { service_result }.to change(WorkItem, :count).by(1).and(
+ change(WorkItems::ParentLink, :count).by(1)
+ )
+
+ expect(service_result[:status]).to be(:success)
+ end
end
- end
- context 'when parent type is invalid' do
- let_it_be(:parent) { create(:work_item, :task, project: project) }
+ context 'when parent type is invalid' do
+ let_it_be(:parent) { create(:work_item, :task, **container_args) }
- it_behaves_like 'fails creating work item and returns errors' do
- let(:error_message) { 'is not allowed to add this type of parent' }
+ it_behaves_like 'fails creating work item and returns errors' do
+ let(:error_message) { 'is not allowed to add this type of parent' }
+ end
end
end
- end
- context 'when user cannot admin parent link' do
- let(:current_user) { guest }
+ context 'when user cannot admin parent link' do
+ let(:current_user) { guest }
- let(:opts) do
- {
- title: 'Awesome work_item',
- description: 'please fix',
- work_item_type: WorkItems::Type.default_by_type(:task)
- }
- end
+ let(:opts) do
+ {
+ title: 'Awesome work_item',
+ description: 'please fix',
+ work_item_type: WorkItems::Type.default_by_type(:task)
+ }
+ end
- it_behaves_like 'fails creating work item and returns errors' do
- let(:error_message) { 'No matching work item found. Make sure that you are adding a valid work item ID.' }
+ it_behaves_like 'fails creating work item and returns errors' do
+ let(:error_message) { 'No matching work item found. Make sure that you are adding a valid work item ID.' }
+ end
end
end
end
end
+
+ it_behaves_like 'creates work item in container', :project
+ it_behaves_like 'creates work item in container', :project_namespace
+ it_behaves_like 'creates work item in container', :group
end
diff --git a/spec/services/work_items/delete_service_spec.rb b/spec/services/work_items/delete_service_spec.rb
index 69ae881a12f..ac72815a57e 100644
--- a/spec/services/work_items/delete_service_spec.rb
+++ b/spec/services/work_items/delete_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::DeleteService do
+RSpec.describe WorkItems::DeleteService, feature_category: :team_planning do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:guest) { create(:user) }
let_it_be(:work_item, refind: true) { create(:work_item, project: project, author: guest) }
diff --git a/spec/services/work_items/delete_task_service_spec.rb b/spec/services/work_items/delete_task_service_spec.rb
index 07a0d8d6c1a..dc01da65771 100644
--- a/spec/services/work_items/delete_task_service_spec.rb
+++ b/spec/services/work_items/delete_task_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::DeleteTaskService do
+RSpec.describe WorkItems::DeleteTaskService, feature_category: :team_planning do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
let_it_be_with_refind(:task) { create(:work_item, project: project, author: developer) }
diff --git a/spec/services/work_items/export_csv_service_spec.rb b/spec/services/work_items/export_csv_service_spec.rb
index 0718d3b686a..948ff89245e 100644
--- a/spec/services/work_items/export_csv_service_spec.rb
+++ b/spec/services/work_items/export_csv_service_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe WorkItems::ExportCsvService, :with_license, feature_category: :te
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :public, group: group) }
- let_it_be(:work_item_1) { create(:work_item, project: project) }
+ let_it_be(:work_item_1) { create(:work_item, description: 'test', project: project) }
let_it_be(:work_item_2) { create(:work_item, :incident, project: project) }
subject { described_class.new(WorkItem.all, project) }
@@ -30,9 +30,8 @@ RSpec.describe WorkItems::ExportCsvService, :with_license, feature_category: :te
end
describe '#email' do
- # TODO - will be implemented as part of https://gitlab.com/gitlab-org/gitlab/-/issues/379082
- xit 'emails csv' do
- expect { subject.email(user) }.o change { ActionMailer::Base.deliveries.count }.from(0).to(1)
+ it 'emails csv' do
+ expect { subject.email(user) }.to change { ActionMailer::Base.deliveries.count }.from(0).to(1)
end
end
@@ -65,6 +64,11 @@ RSpec.describe WorkItems::ExportCsvService, :with_license, feature_category: :te
expect(csv[0]['Created At (UTC)']).to eq(work_item_1.created_at.to_s(:csv))
end
+ specify 'description' do
+ expect(csv[0]['Description']).to be_present
+ expect(csv[0]['Description']).to eq(work_item_1.description)
+ end
+
it 'preloads fields to avoid N+1 queries' do
control = ActiveRecord::QueryRecorder.new { subject.csv_data }
@@ -74,4 +78,20 @@ RSpec.describe WorkItems::ExportCsvService, :with_license, feature_category: :te
end
it_behaves_like 'a service that returns invalid fields from selection'
+
+ # TODO - once we have a UI for this feature
+ # we can turn these into feature specs.
+ # more info at: https://gitlab.com/gitlab-org/gitlab/-/issues/396943
+ context 'when importing an exported file' do
+ context 'for work item of type issue' do
+ it_behaves_like 'a exported file that can be imported' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:origin_project) { create(:project) }
+ let_it_be(:target_project) { create(:project) }
+ let_it_be(:work_item) { create(:work_item, project: origin_project) }
+
+ let(:expected_matching_fields) { %w[title work_item_type] }
+ end
+ end
+ end
end
diff --git a/spec/services/work_items/import_csv_service_spec.rb b/spec/services/work_items/import_csv_service_spec.rb
new file mode 100644
index 00000000000..3c710640f4a
--- /dev/null
+++ b/spec/services/work_items/import_csv_service_spec.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::ImportCsvService, feature_category: :team_planning do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:author) { create(:user, username: 'csv_author') }
+ let(:file) { fixture_file_upload('spec/fixtures/work_items_valid_types.csv') }
+ let(:service) do
+ uploader = FileUploader.new(project)
+ uploader.store!(file)
+
+ described_class.new(user, project, uploader)
+ end
+
+ let_it_be(:issue_type) { ::WorkItems::Type.default_issue_type }
+
+ let(:work_items) { ::WorkItems::WorkItemsFinder.new(user, project: project).execute }
+ let(:email_method) { :import_work_items_csv_email }
+
+ subject { service.execute }
+
+ describe '#execute', :aggregate_failures do
+ context 'when user has permission' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it_behaves_like 'importer with email notification'
+
+ context 'when file format is valid' do
+ context 'when work item types are available' do
+ it 'creates the expected number of work items' do
+ expect { subject }.to change { work_items.count }.by 2
+ end
+
+ it 'sets work item attributes' do
+ result = subject
+
+ expect(work_items.reload).to contain_exactly(
+ have_attributes(
+ title: 'Valid issue',
+ work_item_type_id: issue_type.id
+ ),
+ have_attributes(
+ title: 'Valid issue with alternate case',
+ work_item_type_id: issue_type.id
+ )
+ )
+
+ expect(result[:success]).to eq(2)
+ expect(result[:error_lines]).to eq([])
+ expect(result[:type_errors]).to be_nil
+ expect(result[:parse_error]).to eq(false)
+ end
+ end
+
+ context 'when csv contains work item types that are missing or not available' do
+ let(:file) { fixture_file_upload('spec/fixtures/work_items_invalid_types.csv') }
+
+ it 'creates no work items' do
+ expect { subject }.not_to change { work_items.count }
+ end
+
+ it 'returns the correct result' do
+ result = subject
+
+ expect(result[:success]).to eq(0)
+ expect(result[:error_lines]).to be_empty # there are problematic lines detailed below
+ expect(result[:parse_error]).to eq(false)
+ expect(result[:type_errors]).to match({
+ blank: [4],
+ disallowed: {}, # tested in the EE version
+ missing: {
+ "isssue" => [2],
+ "issue!!" => [3]
+ }
+ })
+ end
+ end
+ end
+
+ context 'when file is missing necessary headers' do
+ let(:file) { fixture_file_upload('spec/fixtures/work_items_missing_header.csv') }
+
+ it 'creates no records' do
+ result = subject
+
+ expect(result[:success]).to eq(0)
+ expect(result[:error_lines]).to eq([1])
+ expect(result[:type_errors]).to be_nil
+ expect(result[:parse_error]).to eq(true)
+ end
+
+ it 'creates no work items' do
+ expect { subject }.not_to change { work_items.count }
+ end
+ end
+
+ context 'when import_export_work_items_csv feature flag is off' do
+ before do
+ stub_feature_flags(import_export_work_items_csv: false)
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(/This feature is currently behind a feature flag and it is not available./)
+ end
+ end
+ end
+
+ context 'when user does not have permission' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(/You do not have permission to import work items in this project/)
+ end
+ end
+ end
+end
diff --git a/spec/services/work_items/parent_links/base_service_spec.rb b/spec/services/work_items/parent_links/base_service_spec.rb
new file mode 100644
index 00000000000..dbdbc774d3c
--- /dev/null
+++ b/spec/services/work_items/parent_links/base_service_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+module WorkItems
+ class ParentLinksService < WorkItems::ParentLinks::BaseService; end
+end
+
+RSpec.describe WorkItems::ParentLinks::BaseService, feature_category: :portfolio_management do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:work_item) { create(:work_item, :objective, project: project) }
+ let_it_be(:target_work_item) { create(:work_item, :objective, project: project) }
+
+ let(:params) { { target_issuable: target_work_item } }
+ let(:described_class_descendant) { WorkItems::ParentLinksService }
+
+ before do
+ project.add_reporter(user)
+ end
+
+ describe '#execute' do
+ subject { described_class_descendant.new(work_item, user, params).execute }
+
+ context 'when user has sufficient permissions' do
+ it 'raises NotImplementedError' do
+ expect { subject }.to raise_error(NotImplementedError)
+ end
+ end
+ end
+end
diff --git a/spec/services/work_items/parent_links/create_service_spec.rb b/spec/services/work_items/parent_links/create_service_spec.rb
index 5884847eac3..41ae6398614 100644
--- a/spec/services/work_items/parent_links/create_service_spec.rb
+++ b/spec/services/work_items/parent_links/create_service_spec.rb
@@ -9,8 +9,8 @@ RSpec.describe WorkItems::ParentLinks::CreateService, feature_category: :portfol
let_it_be(:project) { create(:project) }
let_it_be(:work_item) { create(:work_item, project: project) }
let_it_be(:task) { create(:work_item, :task, project: project) }
- let_it_be(:task1) { create(:work_item, :task, project: project) }
- let_it_be(:task2) { create(:work_item, :task, project: project) }
+ let_it_be_with_reload(:task1) { create(:work_item, :task, project: project) }
+ let_it_be_with_reload(:task2) { create(:work_item, :task, project: project) }
let_it_be(:guest_task) { create(:work_item, :task) }
let_it_be(:invalid_task) { build_stubbed(:work_item, :task, id: non_existing_record_id) }
let_it_be(:another_project) { (create :project) }
@@ -68,6 +68,40 @@ RSpec.describe WorkItems::ParentLinks::CreateService, feature_category: :portfol
end
end
+ context 'when adjacent is already in place' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be_with_reload(:parent_item) { create(:work_item, :objective, project: project) }
+ let_it_be_with_reload(:current_item) { create(:work_item, :objective, project: project) }
+
+ let_it_be_with_reload(:adjacent) do
+ create(:work_item, :objective, project: project)
+ end
+
+ let_it_be_with_reload(:link_to_adjacent) do
+ create(:parent_link, work_item_parent: parent_item, work_item: adjacent)
+ end
+
+ subject { described_class.new(parent_item, user, { target_issuable: current_item }).execute }
+
+ where(:adjacent_position, :expected_order) do
+ -100 | lazy { [adjacent, current_item] }
+ 0 | lazy { [adjacent, current_item] }
+ 100 | lazy { [adjacent, current_item] }
+ end
+
+ with_them do
+ before do
+ link_to_adjacent.update!(relative_position: adjacent_position)
+ end
+
+ it 'sets relative positions' do
+ expect { subject }.to change(parent_link_class, :count).by(1)
+ expect(parent_item.work_item_children_by_relative_position).to eq(expected_order)
+ end
+ end
+ end
+
context 'when there are tasks to relate' do
let(:params) { { issuable_references: [task1, task2] } }
@@ -84,26 +118,74 @@ RSpec.describe WorkItems::ParentLinks::CreateService, feature_category: :portfol
expect(subject[:created_references].map(&:work_item_id)).to match_array([task1.id, task2.id])
end
- it 'creates notes', :aggregate_failures do
- subject
+ it 'creates notes and records the events', :aggregate_failures do
+ expect { subject }.to change(WorkItems::ResourceLinkEvent, :count).by(2)
work_item_notes = work_item.notes.last(2)
+ resource_link_events = WorkItems::ResourceLinkEvent.last(2)
expect(work_item_notes.first.note).to eq("added #{task1.to_reference} as child task")
expect(work_item_notes.last.note).to eq("added #{task2.to_reference} as child task")
expect(task1.notes.last.note).to eq("added #{work_item.to_reference} as parent issue")
expect(task2.notes.last.note).to eq("added #{work_item.to_reference} as parent issue")
+ expect(resource_link_events.first).to have_attributes(
+ user_id: user.id,
+ issue_id: work_item.id,
+ child_work_item_id: task1.id,
+ action: "add",
+ system_note_metadata_id: task1.notes.last.system_note_metadata.id
+ )
+ expect(resource_link_events.last).to have_attributes(
+ user_id: user.id,
+ issue_id: work_item.id,
+ child_work_item_id: task2.id,
+ action: "add",
+ system_note_metadata_id: task2.notes.last.system_note_metadata.id
+ )
+ end
+
+ context 'when note creation fails for some reason' do
+ let(:params) { { issuable_references: [task1] } }
+
+ [Note.new, nil].each do |relate_child_note|
+ it 'still records the link event', :aggregate_failures do
+ allow_next_instance_of(WorkItems::ParentLinks::CreateService) do |instance|
+ allow(instance).to receive(:create_notes).and_return(relate_child_note)
+ end
+
+ expect { subject }
+ .to change(WorkItems::ResourceLinkEvent, :count).by(1)
+ .and not_change(Note, :count)
+
+ expect(WorkItems::ResourceLinkEvent.last).to have_attributes(
+ user_id: user.id,
+ issue_id: work_item.id,
+ child_work_item_id: task1.id,
+ action: "add",
+ system_note_metadata_id: nil
+ )
+ end
+ end
end
context 'when task is already assigned' do
let(:params) { { issuable_references: [task, task2] } }
it 'creates links only for non related tasks', :aggregate_failures do
- expect { subject }.to change(parent_link_class, :count).by(1)
+ expect { subject }
+ .to change(parent_link_class, :count).by(1)
+ .and change(WorkItems::ResourceLinkEvent, :count).by(1)
expect(subject[:created_references].map(&:work_item_id)).to match_array([task2.id])
expect(work_item.notes.last.note).to eq("added #{task2.to_reference} as child task")
expect(task2.notes.last.note).to eq("added #{work_item.to_reference} as parent issue")
expect(task.notes).to be_empty
+ expect(WorkItems::ResourceLinkEvent.last).to have_attributes(
+ user_id: user.id,
+ issue_id: work_item.id,
+ child_work_item_id: task2.id,
+ action: "add",
+ system_note_metadata_id: task2.notes.last.system_note_metadata.id
+ )
end
end
@@ -160,7 +242,7 @@ RSpec.describe WorkItems::ParentLinks::CreateService, feature_category: :portfol
end
context 'when params include invalid ids' do
- let(:params) { { issuable_references: [task1, invalid_task] } }
+ let(:params) { { issuable_references: [task1, guest_task] } }
it 'creates links only for valid IDs' do
expect { subject }.to change(parent_link_class, :count).by(1)
diff --git a/spec/services/work_items/parent_links/destroy_service_spec.rb b/spec/services/work_items/parent_links/destroy_service_spec.rb
index 654a03ef6f7..7e2e3949b73 100644
--- a/spec/services/work_items/parent_links/destroy_service_spec.rb
+++ b/spec/services/work_items/parent_links/destroy_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::ParentLinks::DestroyService do
+RSpec.describe WorkItems::ParentLinks::DestroyService, feature_category: :team_planning do
describe '#execute' do
let_it_be(:reporter) { create(:user) }
let_it_be(:guest) { create(:user) }
@@ -24,23 +24,53 @@ RSpec.describe WorkItems::ParentLinks::DestroyService do
let(:user) { reporter }
it 'removes relation and creates notes', :aggregate_failures do
- expect { subject }.to change(parent_link_class, :count).by(-1)
+ expect { subject }
+ .to change(parent_link_class, :count).by(-1)
+ .and change(WorkItems::ResourceLinkEvent, :count).by(1)
expect(work_item.notes.last.note).to eq("removed child task #{task.to_reference}")
expect(task.notes.last.note).to eq("removed parent issue #{work_item.to_reference}")
+ expect(WorkItems::ResourceLinkEvent.last).to have_attributes(
+ user_id: user.id,
+ issue_id: work_item.id,
+ child_work_item_id: task.id,
+ action: "remove",
+ system_note_metadata_id: task.notes.last.system_note_metadata.id
+ )
end
it 'returns success message' do
is_expected.to eq(message: 'Relation was removed', status: :success)
end
+
+ context 'when note creation fails for some reason' do
+ [Note.new, nil].each do |unrelate_child_note|
+ it 'still records the link event', :aggregate_failures do
+ allow(SystemNoteService).to receive(:unrelate_work_item).and_return(unrelate_child_note)
+
+ expect { subject }
+ .to change(WorkItems::ResourceLinkEvent, :count).by(1)
+ .and not_change(Note, :count)
+
+ expect(WorkItems::ResourceLinkEvent.last).to have_attributes(
+ user_id: user.id,
+ issue_id: work_item.id,
+ child_work_item_id: task.id,
+ action: "remove",
+ system_note_metadata_id: nil
+ )
+ end
+ end
+ end
end
context 'when user has insufficient permissions' do
let(:user) { guest }
it 'does not remove relation', :aggregate_failures do
- expect { subject }.not_to change(parent_link_class, :count).from(1)
-
+ expect { subject }
+ .to not_change(parent_link_class, :count).from(1)
+ .and not_change(WorkItems::ResourceLinkEvent, :count)
expect(SystemNoteService).not_to receive(:unrelate_work_item)
end
diff --git a/spec/services/work_items/parent_links/reorder_service_spec.rb b/spec/services/work_items/parent_links/reorder_service_spec.rb
new file mode 100644
index 00000000000..0448429d2bb
--- /dev/null
+++ b/spec/services/work_items/parent_links/reorder_service_spec.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::ParentLinks::ReorderService, feature_category: :portfolio_management do
+ describe '#execute' do
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be_with_reload(:parent) { create(:work_item, :objective, project: project) }
+ let_it_be_with_reload(:work_item) { create(:work_item, :objective, project: project) }
+ let_it_be_with_reload(:top_adjacent) { create(:work_item, :objective, project: project) }
+ let_it_be_with_reload(:last_adjacent) { create(:work_item, :objective, project: project) }
+
+ let(:parent_link_class) { WorkItems::ParentLink }
+ let(:user) { reporter }
+ let(:params) { { target_issuable: work_item } }
+ let(:relative_range) { [top_adjacent, last_adjacent].map(&:parent_link).map(&:relative_position) }
+
+ subject { described_class.new(parent, user, params).execute }
+
+ before do
+ project.add_reporter(reporter)
+ project.add_guest(guest)
+
+ create(:parent_link, work_item: top_adjacent, work_item_parent: parent)
+ create(:parent_link, work_item: last_adjacent, work_item_parent: parent)
+ end
+
+ shared_examples 'raises a service error' do |message, status = 409|
+ it { is_expected.to eq(service_error(message, http_status: status)) }
+ end
+
+ shared_examples 'returns not found error' do
+ it 'returns error' do
+ error = "No matching work item found. Make sure that you are adding a valid work item ID."
+
+ is_expected.to eq(service_error(error))
+ end
+
+ it 'creates no relationship' do
+ expect { subject }.not_to change { parent_link_class.count }
+ end
+ end
+
+ shared_examples 'returns conflict error' do
+ it_behaves_like 'raises a service error', 'Work item(s) already assigned'
+
+ it 'creates no relationship' do
+ expect { subject }.to not_change { parent_link_class.count }
+ end
+ end
+
+ shared_examples 'processes ordered hierarchy' do
+ it 'returns success status and processed links', :aggregate_failures do
+ expect(subject.keys).to match_array([:status, :created_references])
+ expect(subject[:status]).to eq(:success)
+ expect(subject[:created_references].map(&:work_item_id)).to match_array([work_item.id])
+ end
+
+ it 'orders hierarchy' do
+ subject
+
+ expect(last_adjacent.parent_link.relative_position).to be_between(*relative_range)
+ end
+ end
+
+ context 'when user has insufficient permissions' do
+ let(:user) { guest }
+
+ it_behaves_like 'returns not found error'
+
+ context 'when user is a guest assigned to the work item' do
+ before do
+ work_item.assignees = [guest]
+ end
+
+ it_behaves_like 'returns not found error'
+ end
+ end
+
+ context 'when child and parent are already linked' do
+ before do
+ create(:parent_link, work_item: work_item, work_item_parent: parent)
+ end
+
+ it_behaves_like 'returns conflict error'
+
+ context 'when adjacents are already in place and the user has sufficient permissions' do
+ let(:base_param) { { target_issuable: work_item } }
+
+ shared_examples 'updates hierarchy order without notes' do
+ it_behaves_like 'processes ordered hierarchy'
+
+ it 'keeps relationships', :aggregate_failures do
+ expect { subject }.to not_change { parent_link_class.count }
+
+ expect(parent_link_class.where(work_item: work_item).last.work_item_parent).to eq(parent)
+ end
+
+ it 'does not create notes', :aggregate_failures do
+ expect { subject }.to not_change { work_item.notes.count }.and(not_change { work_item.notes.count })
+ end
+ end
+
+ context 'when moving before adjacent work item' do
+ let(:params) { base_param.merge({ adjacent_work_item: last_adjacent, relative_position: 'BEFORE' }) }
+
+ it_behaves_like 'updates hierarchy order without notes'
+ end
+
+ context 'when moving after adjacent work item' do
+ let(:params) { base_param.merge({ adjacent_work_item: top_adjacent, relative_position: 'AFTER' }) }
+
+ it_behaves_like 'updates hierarchy order without notes'
+ end
+ end
+ end
+
+ context 'when new parent is assigned' do
+ shared_examples 'updates hierarchy order and creates notes' do
+ it_behaves_like 'processes ordered hierarchy'
+
+ it 'creates notes', :aggregate_failures do
+ subject
+
+ expect(parent.notes.last.note).to eq("added #{work_item.to_reference} as child objective")
+ expect(work_item.notes.last.note).to eq("added #{parent.to_reference} as parent objective")
+ end
+ end
+
+ context 'when adjacents are already in place and the user has sufficient permissions' do
+ let(:base_param) { { target_issuable: work_item } }
+
+ context 'when moving before adjacent work item' do
+ let(:params) { base_param.merge({ adjacent_work_item: last_adjacent, relative_position: 'BEFORE' }) }
+
+ it_behaves_like 'updates hierarchy order and creates notes'
+ end
+
+ context 'when moving after adjacent work item' do
+ let(:params) { base_param.merge({ adjacent_work_item: top_adjacent, relative_position: 'AFTER' }) }
+
+ it_behaves_like 'updates hierarchy order and creates notes'
+ end
+
+ context 'when previous parent was in place' do
+ before do
+ create(:parent_link, work_item: work_item,
+ work_item_parent: create(:work_item, :objective, project: project))
+ end
+
+ context 'when moving before adjacent work item' do
+ let(:params) { base_param.merge({ adjacent_work_item: last_adjacent, relative_position: 'BEFORE' }) }
+
+ it_behaves_like 'updates hierarchy order and creates notes'
+ end
+
+ context 'when moving after adjacent work item' do
+ let(:params) { base_param.merge({ adjacent_work_item: top_adjacent, relative_position: 'AFTER' }) }
+
+ it_behaves_like 'updates hierarchy order and creates notes'
+ end
+ end
+ end
+ end
+ end
+
+ def service_error(message, http_status: 404)
+ {
+ message: message,
+ status: :error,
+ http_status: http_status
+ }
+ end
+end
diff --git a/spec/services/work_items/prepare_import_csv_service_spec.rb b/spec/services/work_items/prepare_import_csv_service_spec.rb
new file mode 100644
index 00000000000..6a657120690
--- /dev/null
+++ b/spec/services/work_items/prepare_import_csv_service_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::PrepareImportCsvService, feature_category: :team_planning do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ let(:file) { double }
+ let(:upload_service) { double }
+ let(:uploader) { double }
+ let(:upload) { double }
+
+ let(:subject) do
+ described_class.new(project, user, file: file).execute
+ end
+
+ context 'when file is uploaded correctly' do
+ let(:upload_id) { 99 }
+
+ before do
+ mock_upload
+ end
+
+ it 'returns a success message' do
+ result = subject
+
+ expect(result[:status]).to eq(:success)
+ expect(result[:message]).to eq(
+ "Your work items are being imported. Once finished, you'll receive a confirmation email.")
+ end
+
+ it 'enqueues the ImportWorkItemsCsvWorker' do
+ expect(WorkItems::ImportWorkItemsCsvWorker).to receive(:perform_async).with(user.id, project.id, upload_id)
+
+ subject
+ end
+ end
+
+ context 'when file upload fails' do
+ before do
+ mock_upload(false)
+ end
+
+ it 'returns an error message' do
+ result = subject
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('File upload error.')
+ end
+ end
+end
diff --git a/spec/services/work_items/task_list_reference_removal_service_spec.rb b/spec/services/work_items/task_list_reference_removal_service_spec.rb
index 91b7814ae92..4e87ce66c21 100644
--- a/spec/services/work_items/task_list_reference_removal_service_spec.rb
+++ b/spec/services/work_items/task_list_reference_removal_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::TaskListReferenceRemovalService do
+RSpec.describe WorkItems::TaskListReferenceRemovalService, feature_category: :team_planning do
let_it_be(:developer) { create(:user) }
let_it_be(:project) { create(:project, :repository).tap { |project| project.add_developer(developer) } }
let_it_be(:task) { create(:work_item, project: project, title: 'Task title') }
diff --git a/spec/services/work_items/task_list_reference_replacement_service_spec.rb b/spec/services/work_items/task_list_reference_replacement_service_spec.rb
index 965c5f1d554..8f696109fa1 100644
--- a/spec/services/work_items/task_list_reference_replacement_service_spec.rb
+++ b/spec/services/work_items/task_list_reference_replacement_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::TaskListReferenceReplacementService do
+RSpec.describe WorkItems::TaskListReferenceReplacementService, feature_category: :team_planning do
let_it_be(:developer) { create(:user) }
let_it_be(:project) { create(:project, :repository).tap { |project| project.add_developer(developer) } }
let_it_be(:single_line_work_item, refind: true) { create(:work_item, project: project, description: '- [ ] single line', lock_version: 3) }
diff --git a/spec/services/work_items/update_service_spec.rb b/spec/services/work_items/update_service_spec.rb
index 435995c6570..2cf52ee853a 100644
--- a/spec/services/work_items/update_service_spec.rb
+++ b/spec/services/work_items/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::UpdateService do
+RSpec.describe WorkItems::UpdateService, feature_category: :team_planning do
let_it_be(:developer) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:project) { create(:project) }
@@ -44,6 +44,33 @@ RSpec.describe WorkItems::UpdateService do
end
end
+ context 'when applying quick actions' do
+ let(:opts) { { description: "/shrug" } }
+
+ context 'when work item type is not the default Issue' do
+ before do
+ task_type = WorkItems::Type.default_by_type(:task)
+ work_item.update_columns(issue_type: task_type.base_type, work_item_type_id: task_type.id)
+ end
+
+ it 'does not apply the quick action' do
+ expect do
+ update_work_item
+ end.to change(work_item, :description).to('/shrug')
+ end
+ end
+
+ context 'when work item type is the default Issue' do
+ let(:issue) { create(:work_item, :issue, description: '') }
+
+ it 'applies the quick action' do
+ expect do
+ update_work_item
+ end.to change(work_item, :description).to(' ¯\_(ツ)_/¯')
+ end
+ end
+ end
+
context 'when title is changed' do
let(:opts) { { title: 'changed' } }
diff --git a/spec/services/work_items/widgets/assignees_service/update_service_spec.rb b/spec/services/work_items/widgets/assignees_service/update_service_spec.rb
index 0ab2c85f078..66e30e2f882 100644
--- a/spec/services/work_items/widgets/assignees_service/update_service_spec.rb
+++ b/spec/services/work_items/widgets/assignees_service/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::Widgets::AssigneesService::UpdateService, :freeze_time do
+RSpec.describe WorkItems::Widgets::AssigneesService::UpdateService, :freeze_time, feature_category: :portfolio_management do
let_it_be(:reporter) { create(:user) }
let_it_be(:project) { create(:project, :private) }
let_it_be(:new_assignee) { create(:user) }
@@ -21,10 +21,9 @@ RSpec.describe WorkItems::Widgets::AssigneesService::UpdateService, :freeze_time
end
describe '#before_update_in_transaction' do
- subject do
- described_class.new(widget: widget, current_user: current_user)
- .before_update_in_transaction(params: params)
- end
+ let(:service) { described_class.new(widget: widget, current_user: current_user) }
+
+ subject { service.before_update_in_transaction(params: params) }
it 'updates the assignees and sets updated_at to the current time' do
subject
@@ -112,5 +111,20 @@ RSpec.describe WorkItems::Widgets::AssigneesService::UpdateService, :freeze_time
expect(work_item.updated_at).to be_like_time(1.day.ago)
end
end
+
+ context 'when widget does not exist in new type' do
+ let(:params) { {} }
+
+ before do
+ allow(service).to receive(:new_type_excludes_widget?).and_return(true)
+ work_item.assignee_ids = [new_assignee.id]
+ end
+
+ it "resets the work item's assignees" do
+ subject
+
+ expect(work_item.assignee_ids).to be_empty
+ end
+ end
end
end
diff --git a/spec/services/work_items/widgets/award_emoji_service/update_service_spec.rb b/spec/services/work_items/widgets/award_emoji_service/update_service_spec.rb
new file mode 100644
index 00000000000..186e4d56cc4
--- /dev/null
+++ b/spec/services/work_items/widgets/award_emoji_service/update_service_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::Widgets::AwardEmojiService::UpdateService, feature_category: :team_planning do
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:unauthorized_user) { create(:user) }
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:work_item) { create(:work_item, project: project) }
+
+ let(:current_user) { reporter }
+ let(:widget) { work_item.widgets.find { |widget| widget.is_a?(WorkItems::Widgets::AwardEmoji) } }
+
+ before_all do
+ project.add_reporter(reporter)
+ end
+
+ describe '#before_update_in_transaction' do
+ subject do
+ described_class.new(widget: widget, current_user: current_user)
+ .before_update_in_transaction(params: params)
+ end
+
+ shared_examples 'raises a WidgetError' do
+ it { expect { subject }.to raise_error(described_class::WidgetError, message) }
+ end
+
+ context 'when awarding an emoji' do
+ let(:params) { { action: :add, name: 'star' } }
+
+ context 'when user has no access' do
+ let(:current_user) { unauthorized_user }
+
+ it 'does not award the emoji' do
+ expect { subject }.not_to change { AwardEmoji.count }
+ end
+ end
+
+ context 'when user has access' do
+ it 'awards the emoji to the work item' do
+ expect { subject }.to change { AwardEmoji.count }.by(1)
+
+ emoji = AwardEmoji.last
+
+ expect(emoji.name).to eq('star')
+ expect(emoji.awardable_id).to eq(work_item.id)
+ expect(emoji.user).to eq(current_user)
+ end
+
+ context 'when the name is incorrect' do
+ let(:params) { { action: :add, name: 'foo' } }
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { 'Name is not a valid emoji name' }
+ end
+ end
+
+ context 'when the action is incorrect' do
+ let(:params) { { action: :foo, name: 'star' } }
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { 'foo is not a valid action.' }
+ end
+ end
+ end
+ end
+
+ context 'when removing emoji' do
+ let(:params) { { action: :remove, name: 'thumbsup' } }
+
+ context 'when user has no access' do
+ let(:current_user) { unauthorized_user }
+
+ it 'does not remove the emoji' do
+ expect { subject }.not_to change { AwardEmoji.count }
+ end
+ end
+
+ context 'when user has access' do
+ it 'removes existing emoji' do
+ create(:award_emoji, :upvote, awardable: work_item, user: current_user)
+
+ expect { subject }.to change { AwardEmoji.count }.by(-1)
+ end
+
+ context 'when work item does not have the emoji' do
+ let(:params) { { action: :remove, name: 'star' } }
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { 'User has not awarded emoji of type star on the awardable' }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/work_items/widgets/current_user_todos_service/update_service_spec.rb b/spec/services/work_items/widgets/current_user_todos_service/update_service_spec.rb
new file mode 100644
index 00000000000..85b7e7a70df
--- /dev/null
+++ b/spec/services/work_items/widgets/current_user_todos_service/update_service_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::Widgets::CurrentUserTodosService::UpdateService, feature_category: :team_planning do
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:current_user) { reporter }
+ let_it_be(:work_item) { create(:work_item, project: project) }
+
+ let_it_be(:pending_todo1) do
+ create(:todo, state: :pending, target: work_item, target_type: work_item.class.name, user: current_user)
+ end
+
+ let_it_be(:pending_todo2) do
+ create(:todo, state: :pending, target: work_item, target_type: work_item.class.name, user: current_user)
+ end
+
+ let_it_be(:done_todo) do
+ create(:todo, state: :done, target: work_item, target_type: work_item.class.name, user: current_user)
+ end
+
+ let_it_be(:other_work_item_todo) { create(:todo, state: :pending, target: create(:work_item), user: current_user) }
+ let_it_be(:other_user_todo) do
+ create(:todo, state: :pending, target: work_item, target_type: work_item.class.name, user: create(:user))
+ end
+
+ let(:widget) { work_item.widgets.find { |widget| widget.is_a?(WorkItems::Widgets::CurrentUserTodos) } }
+
+ before_all do
+ project.add_reporter(reporter)
+ end
+
+ describe '#before_update_in_transaction' do
+ subject do
+ described_class.new(widget: widget, current_user: current_user)
+ .before_update_in_transaction(params: params)
+ end
+
+ context 'when adding a todo' do
+ let(:params) { { action: "add" } }
+
+ context 'when user has no access' do
+ let(:current_user) { create(:user) }
+
+ it 'does add a todo' do
+ expect { subject }.not_to change { Todo.count }
+ end
+ end
+
+ context 'when user has access' do
+ let(:params) { { action: "add" } }
+
+ it 'creates a new todo for the user and the work item' do
+ expect { subject }.to change { current_user.todos.count }.by(1)
+
+ todo = current_user.todos.last
+
+ expect(todo.target).to eq(work_item)
+ expect(todo).to be_pending
+ end
+ end
+ end
+
+ context 'when marking as done' do
+ let(:params) { { action: "mark_as_done" } }
+
+ context 'when user has no access' do
+ let(:current_user) { create(:user) }
+
+ it 'does not change todo status' do
+ subject
+
+ expect(pending_todo1.reload).to be_pending
+ expect(pending_todo2.reload).to be_pending
+ expect(other_work_item_todo.reload).to be_pending
+ expect(other_user_todo.reload).to be_pending
+ end
+ end
+
+ context 'when resolving all todos of the work item', :aggregate_failures do
+ it 'resolves todos of the user for the work item' do
+ subject
+
+ expect(pending_todo1.reload).to be_done
+ expect(pending_todo2.reload).to be_done
+ expect(other_work_item_todo.reload).to be_pending
+ expect(other_user_todo.reload).to be_pending
+ end
+ end
+
+ context 'when resolving a specific todo', :aggregate_failures do
+ let(:params) { { action: "mark_as_done", todo_id: pending_todo1.id } }
+
+ it 'resolves todos of the user for the work item' do
+ subject
+
+ expect(pending_todo1.reload).to be_done
+ expect(pending_todo2.reload).to be_pending
+ expect(other_work_item_todo.reload).to be_pending
+ expect(other_user_todo.reload).to be_pending
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/work_items/widgets/description_service/update_service_spec.rb b/spec/services/work_items/widgets/description_service/update_service_spec.rb
index 4275950e720..7da5b24a3b7 100644
--- a/spec/services/work_items/widgets/description_service/update_service_spec.rb
+++ b/spec/services/work_items/widgets/description_service/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::Widgets::DescriptionService::UpdateService do
+RSpec.describe WorkItems::Widgets::DescriptionService::UpdateService, feature_category: :portfolio_management do
let_it_be(:random_user) { create(:user) }
let_it_be(:author) { create(:user) }
let_it_be(:guest) { create(:user) }
@@ -20,7 +20,9 @@ RSpec.describe WorkItems::Widgets::DescriptionService::UpdateService do
let(:widget) { work_item.widgets.find { |widget| widget.is_a?(WorkItems::Widgets::Description) } }
describe '#update' do
- subject { described_class.new(widget: widget, current_user: current_user).before_update_callback(params: params) }
+ let(:service) { described_class.new(widget: widget, current_user: current_user) }
+
+ subject(:before_update_callback) { service.before_update_callback(params: params) }
shared_examples 'sets work item description' do
it 'correctly sets work item description value' do
@@ -78,6 +80,23 @@ RSpec.describe WorkItems::Widgets::DescriptionService::UpdateService do
it_behaves_like 'does not set work item description'
end
+
+ context 'when widget does not exist in new type' do
+ let(:current_user) { author }
+ let(:params) { {} }
+
+ before do
+ allow(service).to receive(:new_type_excludes_widget?).and_return(true)
+ work_item.update!(description: 'test')
+ end
+
+ it "resets the work item's description" do
+ expect { before_update_callback }
+ .to change { work_item.description }
+ .from('test')
+ .to(nil)
+ end
+ end
end
context 'when user does not have permission to update description' do
diff --git a/spec/services/work_items/widgets/hierarchy_service/create_service_spec.rb b/spec/services/work_items/widgets/hierarchy_service/create_service_spec.rb
new file mode 100644
index 00000000000..8d834c9a4f8
--- /dev/null
+++ b/spec/services/work_items/widgets/hierarchy_service/create_service_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::Widgets::HierarchyService::CreateService, feature_category: :portfolio_management do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:parent_item) { create(:work_item, project: project) }
+
+ let(:widget) { parent_item.widgets.find { |widget| widget.is_a?(WorkItems::Widgets::Hierarchy) } }
+
+ shared_examples 'raises a WidgetError' do
+ it { expect { subject }.to raise_error(described_class::WidgetError, message) }
+ end
+
+ before(:all) do
+ project.add_developer(user)
+ end
+
+ describe '#create' do
+ subject { described_class.new(widget: widget, current_user: user).after_create_in_transaction(params: params) }
+
+ context 'when invalid params are present' do
+ let(:params) { { other_parent: 'parent_work_item' } }
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { 'One or more arguments are invalid: other_parent.' }
+ end
+ end
+ end
+end
diff --git a/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb b/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb
index 6285b43311d..229ba81d676 100644
--- a/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb
+++ b/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb
@@ -14,7 +14,13 @@ RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService, feature_cate
let(:widget) { work_item.widgets.find { |widget| widget.is_a?(WorkItems::Widgets::Hierarchy) } }
let(:not_found_error) { 'No matching work item found. Make sure that you are adding a valid work item ID.' }
- shared_examples 'raises a WidgetError' do
+ shared_examples 'raises a WidgetError' do |message|
+ it { expect { subject }.to raise_error(described_class::WidgetError, message) }
+ end
+
+ shared_examples 'raises a WidgetError with message' do
+ let(:message) { not_found_error }
+
it { expect { subject }.to raise_error(described_class::WidgetError, message) }
end
@@ -24,16 +30,30 @@ RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService, feature_cate
context 'when parent and children params are present' do
let(:params) { { parent: parent_work_item, children: [child_work_item] } }
- it_behaves_like 'raises a WidgetError' do
- let(:message) { 'A Work Item can be a parent or a child, but not both.' }
- end
+ it_behaves_like 'raises a WidgetError', 'A Work Item can be a parent or a child, but not both.'
end
context 'when invalid params are present' do
let(:params) { { other_parent: parent_work_item } }
- it_behaves_like 'raises a WidgetError' do
- let(:message) { 'One or more arguments are invalid: other_parent.' }
+ it_behaves_like 'raises a WidgetError', 'One or more arguments are invalid: other_parent.'
+ end
+
+ context 'when relative position params are incomplete' do
+ context 'when only adjacent_work_item is present' do
+ let(:params) do
+ { parent: parent_work_item, adjacent_work_item: child_work_item }
+ end
+
+ it_behaves_like 'raises a WidgetError', described_class::INVALID_RELATIVE_POSITION_ERROR
+ end
+
+ context 'when only relative_position is present' do
+ let(:params) do
+ { parent: parent_work_item, relative_position: 'AFTER' }
+ end
+
+ it_behaves_like 'raises a WidgetError', described_class::INVALID_RELATIVE_POSITION_ERROR
end
end
@@ -45,7 +65,7 @@ RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService, feature_cate
context 'when user has insufficient permissions to link work items' do
let(:params) { { children: [child_work_item4] } }
- it_behaves_like 'raises a WidgetError' do
+ it_behaves_like 'raises a WidgetError with message' do
let(:message) { not_found_error }
end
end
@@ -55,7 +75,7 @@ RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService, feature_cate
project.add_developer(user)
end
- context 'with valid params' do
+ context 'with valid children params' do
let(:params) { { children: [child_work_item2, child_work_item3] } }
it 'correctly sets work item parent' do
@@ -64,14 +84,30 @@ RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService, feature_cate
expect(work_item.reload.work_item_children)
.to contain_exactly(child_work_item, child_work_item2, child_work_item3)
end
+
+ context 'when relative_position and adjacent_work_item are given' do
+ context 'with BEFORE value' do
+ let(:params) do
+ { children: [child_work_item3], relative_position: 'BEFORE', adjacent_work_item: child_work_item }
+ end
+
+ it_behaves_like 'raises a WidgetError', described_class::CHILDREN_REORDERING_ERROR
+ end
+
+ context 'with AFTER value' do
+ let(:params) do
+ { children: [child_work_item2], relative_position: 'AFTER', adjacent_work_item: child_work_item }
+ end
+
+ it_behaves_like 'raises a WidgetError', described_class::CHILDREN_REORDERING_ERROR
+ end
+ end
end
context 'when child is already assigned' do
let(:params) { { children: [child_work_item] } }
- it_behaves_like 'raises a WidgetError' do
- let(:message) { 'Work item(s) already assigned' }
- end
+ it_behaves_like 'raises a WidgetError', 'Work item(s) already assigned'
end
context 'when child type is invalid' do
@@ -79,10 +115,8 @@ RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService, feature_cate
let(:params) { { children: [child_issue] } }
- it_behaves_like 'raises a WidgetError' do
- let(:message) do
- "#{child_issue.to_reference} cannot be added: is not allowed to add this type of parent"
- end
+ it_behaves_like 'raises a WidgetError with message' do
+ let(:message) { "#{child_issue.to_reference} cannot be added: is not allowed to add this type of parent" }
end
end
end
@@ -94,7 +128,7 @@ RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService, feature_cate
let(:params) { { parent: parent_work_item } }
context 'when user has insufficient permissions to link work items' do
- it_behaves_like 'raises a WidgetError' do
+ it_behaves_like 'raises a WidgetError with message' do
let(:message) { not_found_error }
end
end
@@ -121,7 +155,7 @@ RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService, feature_cate
end.to change(work_item, :work_item_parent).from(parent_work_item).to(nil)
end
- it 'returns success status if parent not present', :aggregate_failure do
+ it 'returns success status if parent not present', :aggregate_failures do
work_item.update!(work_item_parent: nil)
expect(subject[:status]).to eq(:success)
@@ -134,10 +168,34 @@ RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService, feature_cate
let(:params) { { parent: parent_task } }
- it_behaves_like 'raises a WidgetError' do
- let(:message) do
- "#{work_item.to_reference} cannot be added: is not allowed to add this type of parent"
+ it_behaves_like 'raises a WidgetError with message' do
+ let(:message) { "#{work_item.to_reference} cannot be added: is not allowed to add this type of parent" }
+ end
+ end
+
+ context 'with positioning arguments' do
+ let_it_be_with_reload(:adjacent) { create(:work_item, :task, project: project) }
+
+ let_it_be_with_reload(:adjacent_link) do
+ create(:parent_link, work_item: adjacent, work_item_parent: parent_work_item)
+ end
+
+ let(:params) { { parent: parent_work_item, adjacent_work_item: adjacent, relative_position: 'AFTER' } }
+
+ it 'correctly sets new parent and position' do
+ expect(subject[:status]).to eq(:success)
+ expect(work_item.work_item_parent).to eq(parent_work_item)
+ expect(work_item.parent_link.relative_position).to be > adjacent_link.relative_position
+ end
+
+ context 'when other hierarchy adjacent is provided' do
+ let_it_be(:other_hierarchy_adjacent) { create(:parent_link).work_item }
+
+ let(:params) do
+ { parent: parent_work_item, adjacent_work_item: other_hierarchy_adjacent, relative_position: 'AFTER' }
end
+
+ it_behaves_like 'raises a WidgetError', described_class::UNRELATED_ADJACENT_HIERARCHY_ERROR
end
end
end
diff --git a/spec/services/work_items/widgets/labels_service/update_service_spec.rb b/spec/services/work_items/widgets/labels_service/update_service_spec.rb
new file mode 100644
index 00000000000..17daec2b1ea
--- /dev/null
+++ b/spec/services/work_items/widgets/labels_service/update_service_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::Widgets::LabelsService::UpdateService, feature_category: :team_planning do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:label1) { create(:label, project: project) }
+ let_it_be(:label2) { create(:label, project: project) }
+ let_it_be(:label3) { create(:label, project: project) }
+ let_it_be(:current_user) { create(:user) }
+
+ let(:work_item) { create(:work_item, project: project, labels: [label1, label2]) }
+ let(:widget) { work_item.widgets.find { |widget| widget.is_a?(WorkItems::Widgets::Labels) } }
+ let(:service) { described_class.new(widget: widget, current_user: current_user) }
+
+ describe '#prepare_update_params' do
+ context 'when params are set' do
+ let(:params) { { add_label_ids: [label1.id], remove_label_ids: [label2.id] } }
+
+ it "sets params correctly" do
+ expect(service.prepare_update_params(params: params)).to include(
+ {
+ add_label_ids: match_array([label1.id]),
+ remove_label_ids: match_array([label2.id])
+ }
+ )
+ end
+ end
+
+ context 'when widget does not exist in new type' do
+ let(:params) { {} }
+
+ before do
+ allow(service).to receive(:new_type_excludes_widget?).and_return(true)
+ end
+
+ it "sets correct params to remove work item labels" do
+ expect(service.prepare_update_params(params: params)).to include(
+ {
+ remove_label_ids: match_array([label1.id, label2.id]),
+ add_label_ids: []
+ }
+ )
+ end
+ end
+ end
+end
diff --git a/spec/services/work_items/widgets/milestone_service/create_service_spec.rb b/spec/services/work_items/widgets/milestone_service/create_service_spec.rb
deleted file mode 100644
index 3f90784b703..00000000000
--- a/spec/services/work_items/widgets/milestone_service/create_service_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe WorkItems::Widgets::MilestoneService::CreateService do
- let_it_be(:group) { create(:group) }
- let_it_be(:project) { create(:project, :private, group: group) }
- let_it_be(:project_milestone) { create(:milestone, project: project) }
- let_it_be(:group_milestone) { create(:milestone, group: group) }
- let_it_be(:guest) { create(:user) }
-
- let(:current_user) { guest }
- let(:work_item) { build(:work_item, project: project, updated_at: 1.day.ago) }
- let(:widget) { work_item.widgets.find { |widget| widget.is_a?(WorkItems::Widgets::Milestone) } }
- let(:service) { described_class.new(widget: widget, current_user: current_user) }
-
- before do
- project.add_guest(guest)
- end
-
- describe '#before_create_callback' do
- it_behaves_like "setting work item's milestone" do
- subject(:execute_callback) do
- service.before_create_callback(params: params)
- end
- end
- end
-end
diff --git a/spec/services/work_items/widgets/milestone_service/update_service_spec.rb b/spec/services/work_items/widgets/milestone_service/update_service_spec.rb
deleted file mode 100644
index f3a7fc156b9..00000000000
--- a/spec/services/work_items/widgets/milestone_service/update_service_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe WorkItems::Widgets::MilestoneService::UpdateService do
- let_it_be(:group) { create(:group) }
- let_it_be(:project) { create(:project, :private, group: group) }
- let_it_be(:project_milestone) { create(:milestone, project: project) }
- let_it_be(:group_milestone) { create(:milestone, group: group) }
- let_it_be(:reporter) { create(:user) }
- let_it_be(:guest) { create(:user) }
-
- let(:work_item) { create(:work_item, project: project, updated_at: 1.day.ago) }
- let(:widget) { work_item.widgets.find { |widget| widget.is_a?(WorkItems::Widgets::Milestone) } }
- let(:service) { described_class.new(widget: widget, current_user: current_user) }
-
- before do
- project.add_reporter(reporter)
- project.add_guest(guest)
- end
-
- describe '#before_update_callback' do
- context 'when current user is not allowed to set work item metadata' do
- let(:current_user) { guest }
- let(:params) { { milestone_id: group_milestone.id } }
-
- it "does not set the work item's milestone" do
- expect { service.before_update_callback(params: params) }
- .to not_change(work_item, :milestone)
- end
- end
-
- context "when current user is allowed to set work item metadata" do
- let(:current_user) { reporter }
-
- it_behaves_like "setting work item's milestone" do
- subject(:execute_callback) do
- service.before_update_callback(params: params)
- end
- end
-
- context 'when unsetting a milestone' do
- let(:params) { { milestone_id: nil } }
-
- before do
- work_item.update!(milestone: project_milestone)
- end
-
- it "sets the work item's milestone" do
- expect { service.before_update_callback(params: params) }
- .to change(work_item, :milestone)
- .from(project_milestone)
- .to(nil)
- end
- end
- end
- end
-end
diff --git a/spec/services/work_items/widgets/notifications_service/update_service_spec.rb b/spec/services/work_items/widgets/notifications_service/update_service_spec.rb
new file mode 100644
index 00000000000..9615020fe49
--- /dev/null
+++ b/spec/services/work_items/widgets/notifications_service/update_service_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::Widgets::NotificationsService::UpdateService, feature_category: :team_planning do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :private, group: group) }
+ let_it_be(:guest) { create(:user).tap { |u| project.add_guest(u) } }
+ let_it_be(:author) { create(:user).tap { |u| project.add_guest(u) } }
+ let_it_be_with_reload(:work_item) { create(:work_item, project: project, author: author) }
+ let_it_be(:current_user) { guest }
+
+ let(:widget) { work_item.widgets.find { |widget| widget.is_a?(WorkItems::Widgets::Notifications) } }
+ let(:service) { described_class.new(widget: widget, current_user: current_user) }
+
+ describe '#before_update_in_transaction' do
+ let(:expected) { params[:subscribed] }
+
+ subject(:update_notifications) { service.before_update_in_transaction(params: params) }
+
+ shared_examples 'failing to update subscription' do
+ context 'when user is subscribed with a subscription record' do
+ let_it_be(:subscription) { create_subscription(:subscribed) }
+
+ it "does not update the work item's subscription" do
+ expect do
+ update_notifications
+ subscription.reload
+ end.to not_change { subscription.subscribed }
+ .and(not_change { work_item.subscribed?(current_user, project) })
+ end
+ end
+
+ context 'when user is subscribed by being a participant' do
+ let_it_be(:current_user) { author }
+
+ it 'does not create subscription record or change subscription state' do
+ expect { update_notifications }
+ .to not_change { Subscription.count }
+ .and(not_change { work_item.subscribed?(current_user, project) })
+ end
+ end
+ end
+
+ shared_examples 'updating notifications subscription successfully' do
+ it 'updates existing subscription record' do
+ expect do
+ update_notifications
+ subscription.reload
+ end.to change { subscription.subscribed }.to(expected)
+ .and(change { work_item.subscribed?(current_user, project) }.to(expected))
+ end
+ end
+
+ context 'when update fails' do
+ context 'when user lack update_subscription permissions' do
+ let_it_be(:params) { { subscribed: false } }
+
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?)
+ .with(current_user, :update_subscription, work_item)
+ .and_return(false)
+ end
+
+ it_behaves_like 'failing to update subscription'
+ end
+
+ context 'when notifications params are not present' do
+ let_it_be(:params) { {} }
+
+ it_behaves_like 'failing to update subscription'
+ end
+ end
+
+ context 'when update is successful' do
+ context 'when subscribing' do
+ let_it_be(:subscription) { create_subscription(:unsubscribed) }
+ let(:params) { { subscribed: true } }
+
+ it_behaves_like 'updating notifications subscription successfully'
+ end
+
+ context 'when unsubscribing' do
+ let(:params) { { subscribed: false } }
+
+ context 'when user is subscribed with a subscription record' do
+ let_it_be(:subscription) { create_subscription(:subscribed) }
+
+ it_behaves_like 'updating notifications subscription successfully'
+ end
+
+ context 'when user is subscribed by being a participant' do
+ let_it_be(:current_user) { author }
+
+ it 'creates a subscription with expected value' do
+ expect { update_notifications }
+ .to change { Subscription.count }.by(1)
+ .and(change { work_item.subscribed?(current_user, project) }.to(expected))
+
+ expect(Subscription.last.subscribed).to eq(expected)
+ end
+ end
+ end
+ end
+ end
+
+ def create_subscription(state)
+ create(
+ :subscription,
+ project: project,
+ user: current_user,
+ subscribable: work_item,
+ subscribed: (state == :subscribed)
+ )
+ end
+end
diff --git a/spec/services/work_items/widgets/start_and_due_date_service/update_service_spec.rb b/spec/services/work_items/widgets/start_and_due_date_service/update_service_spec.rb
index d328c541fc7..0196e7c2b02 100644
--- a/spec/services/work_items/widgets/start_and_due_date_service/update_service_spec.rb
+++ b/spec/services/work_items/widgets/start_and_due_date_service/update_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkItems::Widgets::StartAndDueDateService::UpdateService do
+RSpec.describe WorkItems::Widgets::StartAndDueDateService::UpdateService, feature_category: :portfolio_management do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be_with_reload(:work_item) { create(:work_item, project: project) }
@@ -12,10 +12,9 @@ RSpec.describe WorkItems::Widgets::StartAndDueDateService::UpdateService do
describe '#before_update_callback' do
let(:start_date) { Date.today }
let(:due_date) { 1.week.from_now.to_date }
+ let(:service) { described_class.new(widget: widget, current_user: user) }
- subject(:update_params) do
- described_class.new(widget: widget, current_user: user).before_update_callback(params: params)
- end
+ subject(:update_params) { service.before_update_callback(params: params) }
context 'when start and due date params are present' do
let(:params) { { start_date: Date.today, due_date: 1.week.from_now.to_date } }
@@ -58,5 +57,22 @@ RSpec.describe WorkItems::Widgets::StartAndDueDateService::UpdateService do
end
end
end
+
+ context 'when widget does not exist in new type' do
+ let(:params) { {} }
+
+ before do
+ allow(service).to receive(:new_type_excludes_widget?).and_return(true)
+ work_item.update!(start_date: start_date, due_date: due_date)
+ end
+
+ it 'sets both dates to null' do
+ expect do
+ update_params
+ end.to change(work_item, :start_date).from(start_date).to(nil).and(
+ change(work_item, :due_date).from(due_date).to(nil)
+ )
+ end
+ end
end
end
diff --git a/spec/services/x509_certificate_revoke_service_spec.rb b/spec/services/x509_certificate_revoke_service_spec.rb
index ff5d2dc058b..460381afd79 100644
--- a/spec/services/x509_certificate_revoke_service_spec.rb
+++ b/spec/services/x509_certificate_revoke_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe X509CertificateRevokeService do
+RSpec.describe X509CertificateRevokeService, feature_category: :system_access do
describe '#execute' do
let(:service) { described_class.new }
let!(:x509_signature_1) { create(:x509_commit_signature, x509_certificate: x509_certificate, verification_status: :verified) }
diff --git a/spec/simplecov_env.rb b/spec/simplecov_env.rb
index 07a23021ef5..bea312369f7 100644
--- a/spec/simplecov_env.rb
+++ b/spec/simplecov_env.rb
@@ -10,6 +10,7 @@ module SimpleCovEnv
def start!
return if !ENV.key?('SIMPLECOV') || ENV['SIMPLECOV'] == '0'
+ return if SimpleCov.running
configure_profile
configure_job
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 4e8f990fc10..f8bbad393e6 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -7,9 +7,7 @@ if $LOADED_FEATURES.include?(File.expand_path('fast_spec_helper.rb', __dir__))
abort 'Aborting...'
end
-# Enable deprecation warnings by default and make them more visible
-# to developers to ease upgrading to newer Ruby versions.
-Warning[:deprecated] = true unless ENV.key?('SILENCE_DEPRECATIONS')
+require './spec/deprecation_warnings'
require './spec/deprecation_toolkit_env'
DeprecationToolkitEnv.configure!
@@ -38,6 +36,7 @@ require 'test_prof/recipes/rspec/let_it_be'
require 'test_prof/factory_default'
require 'test_prof/factory_prof/nate_heckler'
require 'parslet/rig/rspec'
+require 'axe-rspec'
rspec_profiling_is_configured =
ENV['RSPEC_PROFILING_POSTGRES_URL'].present? ||
@@ -173,11 +172,15 @@ RSpec.configure do |config|
config.include RailsHelpers
config.include SidekiqMiddleware
config.include StubActionCableConnection, type: :channel
+ config.include StubMemberAccessLevel
config.include StubSpamServices
config.include SnowplowHelpers
config.include RenderedHelpers
config.include RSpec::Benchmark::Matchers, type: :benchmark
config.include DetailedErrorHelpers
+ config.include RequestUrgencyMatcher, type: :controller
+ config.include RequestUrgencyMatcher, type: :request
+ config.include Capybara::RSpecMatchers, type: :request
config.include_context 'when rendered has no HTML escapes', type: :view
@@ -266,6 +269,10 @@ RSpec.configure do |config|
stub_feature_flags(ci_queueing_disaster_recovery_disable_fair_scheduling: false)
stub_feature_flags(ci_queueing_disaster_recovery_disable_quota: false)
+ # Only a few percent of users will be "enrolled" into the new nav with this flag.
+ # Having it enabled globally would make it impossible to test the current nav.
+ stub_feature_flags(super_sidebar_nav_enrolled: false)
+
# It's disabled in specs because we don't support certain features which
# cause spec failures.
stub_feature_flags(use_click_house_database_for_error_tracking: false)
@@ -304,14 +311,6 @@ RSpec.configure do |config|
# most cases. We do test the email verification flow in the appropriate specs.
stub_feature_flags(require_email_verification: false)
- # This feature flag is for selectively disabling by actor, therefore we don't enable it by default.
- # See https://docs.gitlab.com/ee/development/feature_flags/#selectively-disable-by-actor
- stub_feature_flags(legacy_merge_request_state_check_for_merged_result_pipelines: false)
-
- # Disable the `vue_issues_dashboard` feature flag in specs as we migrate the issues
- # dashboard page to Vue. https://gitlab.com/gitlab-org/gitlab/-/issues/379025
- stub_feature_flags(vue_issues_dashboard: false)
-
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else
unstub_all_feature_flags
@@ -355,92 +354,7 @@ RSpec.configure do |config|
# The ongoing implementation of Admin Mode for API is behind the :admin_mode_for_api feature flag.
# All API specs will be adapted continuously. The following list contains the specs that have not yet been adapted.
# The feature flag is disabled for these specs as long as they are not yet adapted.
- admin_mode_for_api_feature_flag_paths = %w[
- ./spec/frontend/fixtures/api_deploy_keys.rb
- ./spec/requests/api/admin/batched_background_migrations_spec.rb
- ./spec/requests/api/admin/ci/variables_spec.rb
- ./spec/requests/api/admin/instance_clusters_spec.rb
- ./spec/requests/api/admin/plan_limits_spec.rb
- ./spec/requests/api/admin/sidekiq_spec.rb
- ./spec/requests/api/broadcast_messages_spec.rb
- ./spec/requests/api/ci/pipelines_spec.rb
- ./spec/requests/api/ci/runners_reset_registration_token_spec.rb
- ./spec/requests/api/ci/runners_spec.rb
- ./spec/requests/api/deploy_keys_spec.rb
- ./spec/requests/api/deploy_tokens_spec.rb
- ./spec/requests/api/freeze_periods_spec.rb
- ./spec/requests/api/graphql/user/starred_projects_query_spec.rb
- ./spec/requests/api/groups_spec.rb
- ./spec/requests/api/issues/get_group_issues_spec.rb
- ./spec/requests/api/issues/get_project_issues_spec.rb
- ./spec/requests/api/issues/issues_spec.rb
- ./spec/requests/api/issues/post_projects_issues_spec.rb
- ./spec/requests/api/issues/put_projects_issues_spec.rb
- ./spec/requests/api/keys_spec.rb
- ./spec/requests/api/merge_requests_spec.rb
- ./spec/requests/api/namespaces_spec.rb
- ./spec/requests/api/notes_spec.rb
- ./spec/requests/api/pages/internal_access_spec.rb
- ./spec/requests/api/pages/pages_spec.rb
- ./spec/requests/api/pages/private_access_spec.rb
- ./spec/requests/api/pages/public_access_spec.rb
- ./spec/requests/api/pages_domains_spec.rb
- ./spec/requests/api/personal_access_tokens/self_information_spec.rb
- ./spec/requests/api/personal_access_tokens_spec.rb
- ./spec/requests/api/project_export_spec.rb
- ./spec/requests/api/project_repository_storage_moves_spec.rb
- ./spec/requests/api/project_snapshots_spec.rb
- ./spec/requests/api/project_snippets_spec.rb
- ./spec/requests/api/projects_spec.rb
- ./spec/requests/api/releases_spec.rb
- ./spec/requests/api/sidekiq_metrics_spec.rb
- ./spec/requests/api/snippet_repository_storage_moves_spec.rb
- ./spec/requests/api/snippets_spec.rb
- ./spec/requests/api/statistics_spec.rb
- ./spec/requests/api/system_hooks_spec.rb
- ./spec/requests/api/topics_spec.rb
- ./spec/requests/api/usage_data_non_sql_metrics_spec.rb
- ./spec/requests/api/usage_data_queries_spec.rb
- ./spec/requests/api/users_spec.rb
- ./spec/requests/api/v3/github_spec.rb
- ./spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb
- ./spec/support/shared_examples/requests/api/hooks_shared_examples.rb
- ./spec/support/shared_examples/requests/api/notes_shared_examples.rb
- ./spec/support/shared_examples/requests/api/pipelines/visibility_table_shared_examples.rb
- ./spec/support/shared_examples/requests/api/repository_storage_moves_shared_examples.rb
- ./spec/support/shared_examples/requests/api/snippets_shared_examples.rb
- ./spec/support/shared_examples/requests/api/status_shared_examples.rb
- ./spec/support/shared_examples/requests/clusters/certificate_based_clusters_feature_flag_shared_examples.rb
- ./spec/support/shared_examples/requests/snippet_shared_examples.rb
- ./ee/spec/requests/api/audit_events_spec.rb
- ./ee/spec/requests/api/ci/minutes_spec.rb
- ./ee/spec/requests/api/elasticsearch_indexed_namespaces_spec.rb
- ./ee/spec/requests/api/epics_spec.rb
- ./ee/spec/requests/api/geo_nodes_spec.rb
- ./ee/spec/requests/api/geo_replication_spec.rb
- ./ee/spec/requests/api/geo_spec.rb
- ./ee/spec/requests/api/group_push_rule_spec.rb
- ./ee/spec/requests/api/group_repository_storage_moves_spec.rb
- ./ee/spec/requests/api/groups_spec.rb
- ./ee/spec/requests/api/internal/upcoming_reconciliations_spec.rb
- ./ee/spec/requests/api/invitations_spec.rb
- ./ee/spec/requests/api/license_spec.rb
- ./ee/spec/requests/api/merge_request_approvals_spec.rb
- ./ee/spec/requests/api/namespaces_spec.rb
- ./ee/spec/requests/api/notes_spec.rb
- ./ee/spec/requests/api/project_aliases_spec.rb
- ./ee/spec/requests/api/project_approval_rules_spec.rb
- ./ee/spec/requests/api/project_approval_settings_spec.rb
- ./ee/spec/requests/api/project_approvals_spec.rb
- ./ee/spec/requests/api/projects_spec.rb
- ./ee/spec/requests/api/settings_spec.rb
- ./ee/spec/requests/api/users_spec.rb
- ./ee/spec/requests/api/vulnerabilities_spec.rb
- ./ee/spec/requests/api/vulnerability_exports_spec.rb
- ./ee/spec/requests/api/vulnerability_findings_spec.rb
- ./ee/spec/requests/api/vulnerability_issue_links_spec.rb
- ./ee/spec/support/shared_examples/requests/api/project_approval_rules_api_shared_examples.rb
- ]
+ admin_mode_for_api_feature_flag_paths = %w[]
if example.metadata[:file_path].start_with?(*admin_mode_for_api_feature_flag_paths)
stub_feature_flags(admin_mode_for_api: false)
@@ -544,19 +458,17 @@ RSpec.configure do |config|
end
end
- # Makes diffs show entire non-truncated values.
- config.before(:each, unlimited_max_formatted_output_length: true) do |_example|
- config.expect_with :rspec do |c|
- c.max_formatted_output_length = nil
- end
- end
-
# Ensures that any Javascript script that tries to make the external VersionCheck API call skips it and returns a response
config.before(:each, :js) do
allow_any_instance_of(VersionCheck).to receive(:response).and_return({ "severity" => "success" })
end
end
+# Disabled because it's causing N+1 queries.
+# See https://gitlab.com/gitlab-org/gitlab/-/issues/396352.
+# Support::AbilityCheck.inject(Ability.singleton_class)
+Support::PermissionsCheck.inject(Ability.singleton_class)
+
ActiveRecord::Migration.maintain_test_schema!
Shoulda::Matchers.configure do |config|
diff --git a/spec/support/ability_check.rb b/spec/support/ability_check.rb
new file mode 100644
index 00000000000..213944506bb
--- /dev/null
+++ b/spec/support/ability_check.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'gitlab/utils/strong_memoize'
+
+module Support
+ module AbilityCheck
+ def self.inject(mod)
+ mod.prepend AbilityExtension
+ end
+
+ module AbilityExtension
+ def before_check(policy, ability, user, subject, opts)
+ return super if Checker.ok?(policy, ability)
+
+ ActiveSupport::Deprecation.warn(<<~WARNING)
+ Ability #{ability.inspect} in #{policy.class} not found.
+ user=#{user.inspect}, subject=#{subject}, opts=#{opts.inspect}"
+
+ To exclude this check add this entry to #{Checker::TODO_YAML}:
+ #{policy.class}:
+ - #{ability}
+ WARNING
+ end
+ end
+
+ module Checker
+ include Gitlab::Utils::StrongMemoize
+ extend self
+
+ TODO_YAML = File.join(__dir__, 'ability_check_todo.yml')
+
+ def ok?(policy, ability)
+ ignored?(policy, ability) || ability_found?(policy, ability)
+ end
+
+ private
+
+ def ignored?(policy, ability)
+ todo_list[policy.class.name]&.include?(ability.to_s)
+ end
+
+ # Use Policy#has_ability? instead after it has been accepted and released.
+ # See https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/-/issues/25
+ def ability_found?(policy, ability)
+ # NilPolicy has no abilities. Ignore it.
+ return true if policy.is_a?(DeclarativePolicy::NilPolicy)
+
+ # Search in current policy first
+ return true if policy.class.ability_map.map.key?(ability)
+
+ # Search recursively in all delegations otherwise.
+ # This is potentially slow.
+ # Stolen from:
+ # https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/-/blob/d691e/lib/declarative_policy/base.rb#L360-369
+ policy.class.delegations.any? do |_, block|
+ new_subject = policy.instance_eval(&block)
+ new_policy = policy.policy_for(new_subject)
+
+ ability_found?(new_policy, ability)
+ end
+ end
+
+ def todo_list
+ hash = YAML.load_file(TODO_YAML)
+ return {} unless hash.is_a?(Hash)
+
+ hash.transform_values(&:to_set)
+ end
+
+ strong_memoize_attr :todo_list
+ end
+ end
+end
diff --git a/spec/support/ability_check_todo.yml b/spec/support/ability_check_todo.yml
new file mode 100644
index 00000000000..eafd595b137
--- /dev/null
+++ b/spec/support/ability_check_todo.yml
@@ -0,0 +1,73 @@
+# This list tracks unknown abilities per policy.
+#
+# This file is used by `spec/support/ability_check.rb`.
+#
+# Each TODO entry means that an ability wasn't found in
+# the particular policy class or its delegations.
+#
+# This could be one of the reasons:
+# * The ability is misspelled.
+# - Suggested action: Fix typo.
+# * The ability has been removed from a policy but is still in use.
+# - Remove production code in question.
+# * The ability is defined in EE policy but is used in FOSS code.
+# - Guard the check or move it to EE folder.
+# - See https://docs.gitlab.com/ee/development/ee_features.html
+# * The ability is defined in another policy but delegation is missing.
+# - Add delegation policy or guard the check with a type check.
+# - See https://docs.gitlab.com/ee/development/policies.html#delegation
+# * The ability check is polymorphic (for example, Issuable) and some policies
+# do not implement this ability.
+# - Exclude TODO permanently below.
+# - Guard the check with a type check.
+# * The ability check is defined on GraphQL field which does not support
+# authorization on resolved field values yet.
+# See https://gitlab.com/gitlab-org/gitlab/-/issues/300922
+---
+# <Policy class>:
+# - <ability name>
+# - <ability name>
+# ...
+
+# Temporary excludes:
+
+Ci::BridgePolicy:
+- read_job_artifacts
+CommitStatusPolicy:
+- read_job_artifacts
+EpicPolicy:
+- create_timelog
+- read_emoji
+- set_issue_crm_contacts
+GlobalPolicy:
+- read_achievement
+- read_on_demand_dast_scan
+- update_max_pages_size
+GroupPolicy:
+- admin_merge_request
+- change_push_rules
+- manage_owners
+IssuePolicy:
+- create_test_case
+MergeRequestPolicy:
+- set_confidentiality
+- set_issue_crm_contacts
+Namespaces::UserNamespacePolicy:
+- read_crm_contact
+PersonalSnippetPolicy:
+- read_internal_note
+- read_project
+ProjectMemberPolicy:
+- override_project_member
+ProjectPolicy:
+- admin_feature_flags_issue_links
+- admin_vulnerability
+- create_requirement
+- create_test_case
+- read_group_saml_identity
+UserPolicy:
+- admin_observability
+- admin_terraform_state
+- read_observability
+
+# Permanent excludes (please provide a reason):
diff --git a/spec/support/banzai/filter_timeout_shared_examples.rb b/spec/support/banzai/filter_timeout_shared_examples.rb
deleted file mode 100644
index 1f2ebe6fef6..00000000000
--- a/spec/support/banzai/filter_timeout_shared_examples.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-# This shared_example requires the following variables:
-# - text: The text to be run through the filter
-#
-# Usage:
-#
-# it_behaves_like 'filter timeout' do
-# let(:text) { 'some text' }
-# end
-RSpec.shared_examples 'filter timeout' do
- context 'when rendering takes too long' do
- let_it_be(:project) { create(:project) }
- let_it_be(:context) { { project: project } }
-
- it 'times out' do
- stub_const("Banzai::Filter::TimeoutHtmlPipelineFilter::RENDER_TIMEOUT", 0.1)
- allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:call_with_timeout) do
- sleep(0.2)
- text
- end
- end
-
- expect(Gitlab::RenderTimeout).to receive(:timeout).and_call_original
- expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
- instance_of(Timeout::Error),
- project_id: context[:project].id,
- class_name: described_class.name.demodulize
- )
-
- result = filter(text)
-
- expect(result.to_html).to eq text
- end
- end
-end
diff --git a/spec/support/banzai/reference_filter_shared_examples.rb b/spec/support/banzai/reference_filter_shared_examples.rb
deleted file mode 100644
index 0046d931e7d..00000000000
--- a/spec/support/banzai/reference_filter_shared_examples.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-# frozen_string_literal: true
-
-# Specs for reference links containing HTML.
-#
-# Requires a reference:
-# let(:reference) { '#42' }
-RSpec.shared_examples 'a reference containing an element node' do
- let(:inner_html) { 'element <code>node</code> inside' }
- let(:reference_with_element) { %{<a href="#{reference}">#{inner_html}</a>} }
-
- it 'does not escape inner html' do
- doc = reference_filter(reference_with_element)
- expect(doc.children.first.inner_html).to eq(inner_html)
- end
-end
-
-# Requires a reference, subject and subject_name:
-# subject { create(:user) }
-# let(:reference) { subject.to_reference }
-# let(:subject_name) { 'user' }
-RSpec.shared_examples 'user reference or project reference' do
- shared_examples 'it contains a data- attribute' do
- it 'includes a data- attribute' do
- doc = reference_filter("Hey #{reference}")
- link = doc.css('a').first
-
- expect(link).to have_attribute("data-#{subject_name}")
- expect(link.attr("data-#{subject_name}")).to eq subject.id.to_s
- end
- end
-
- context 'mentioning a resource' do
- it_behaves_like 'a reference containing an element node'
- it_behaves_like 'it contains a data- attribute'
-
- it "links to a resource" do
- doc = reference_filter("Hey #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.send("#{subject_name}_url", subject)
- end
-
- it 'links to a resource with a period' do
- subject = create(subject_name.to_sym, name: 'alphA.Beta')
-
- doc = reference_filter("Hey #{get_reference(subject)}")
- expect(doc.css('a').length).to eq 1
- end
-
- it 'links to a resource with an underscore' do
- subject = create(subject_name.to_sym, name: 'ping_pong_king')
-
- doc = reference_filter("Hey #{get_reference(subject)}")
- expect(doc.css('a').length).to eq 1
- end
-
- it 'links to a resource with different case-sensitivity' do
- subject = create(subject_name.to_sym, name: 'RescueRanger')
- reference = get_reference(subject)
-
- doc = reference_filter("Hey #{reference.upcase}")
- expect(doc.css('a').length).to eq 1
- expect(doc.css('a').text).to eq(reference)
- end
- end
-
- it 'supports an :only_path context' do
- doc = reference_filter("Hey #{reference}", only_path: true)
- link = doc.css('a').first.attr('href')
-
- expect(link).not_to match %r(https?://)
- expect(link).to eq urls.send "#{subject_name}_path", subject
- end
-
- context 'referencing a resource in a link href' do
- let(:reference) { %Q{<a href="#{get_reference(subject)}">Some text</a>} }
-
- it_behaves_like 'it contains a data- attribute'
-
- it 'links to the resource' do
- doc = reference_filter("Hey #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.send "#{subject_name}_url", subject
- end
-
- it 'links with adjacent text' do
- doc = reference_filter("Mention me (#{reference}.)")
- expect(doc.to_html).to match(%r{\(<a.+>Some text</a>\.\)})
- end
- end
-end
diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb
index fe9bff827dc..0de1300bc50 100644
--- a/spec/support/capybara.rb
+++ b/spec/support/capybara.rb
@@ -7,7 +7,7 @@ require 'capybara-screenshot/rspec'
require 'selenium-webdriver'
# Give CI some extra time
-timeout = ENV['CI'] || ENV['CI_SERVER'] ? 30 : 10
+timeout = ENV['CI'] || ENV['CI_SERVER'] ? 45 : 10
# Support running Capybara on a specific port to allow saving commonly used pages
Capybara.server_port = ENV['CAPYBARA_PORT'] if ENV['CAPYBARA_PORT']
@@ -24,13 +24,21 @@ JS_CONSOLE_FILTER = Regexp.union(
'Download the Vue Devtools extension',
'Download the Apollo DevTools',
"Unrecognized feature: 'interest-cohort'",
- 'Does this page need fixes or improvements?'
+ 'Does this page need fixes or improvements?',
+
+ # Needed after https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60933
+ # which opts out gitlab from FloC by default
+ # see https://web.dev/floc/ for more info on FloC
+ "Origin trial controlled feature not enabled: 'interest-cohort'",
+
+ # ERR_CONNECTION error could happen due to automated test session disabling browser network request
+ 'net::ERR_CONNECTION'
]
)
CAPYBARA_WINDOW_SIZE = [1366, 768].freeze
-SCREENSHOT_FILENAME_LENGTH = ENV['CI'] || ENV['CI_SERVER'] ? 255 : 99
+SCREENSHOT_FILENAME_LENGTH = ENV['CI'] || ENV['CI_SERVER'] ? 150 : 99
@blackhole_tcp_server = nil
@@ -42,21 +50,20 @@ Capybara.register_server :puma_via_workhorse do |app, port, host, **options|
file.close! # We just want the filename
TestEnv.with_workhorse(host, port, socket_path) do
+ # In cases of multiple installations of chromedriver, prioritize the version installed by SeleniumManager
+ # selenium-manager doesn't work with Linux arm64 yet:
+ # https://github.com/SeleniumHQ/selenium/issues/11357
+ if RUBY_PLATFORM =~ /x86_64-linux|darwin/
+ chrome_options = Selenium::WebDriver::Chrome::Options.chrome
+ chromedriver_path = File.dirname(Selenium::WebDriver::SeleniumManager.driver_path(chrome_options))
+ ENV['PATH'] = "#{chromedriver_path}:#{ENV['PATH']}" # rubocop:disable RSpec/EnvAssignment
+ end
+
Capybara.servers[:puma].call(app, nil, socket_path, **options)
end
end
Capybara.register_driver :chrome do |app|
- capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
- # This enables access to logs with `page.driver.manage.get_log(:browser)`
- loggingPrefs: {
- browser: "ALL",
- client: "ALL",
- driver: "ALL",
- server: "ALL"
- }
- )
-
options = Selenium::WebDriver::Chrome::Options.new
# Force the browser's scale factor to prevent inconsistencies on high-res devices
@@ -82,9 +89,6 @@ Capybara.register_driver :chrome do |app|
options.add_preference("download.prompt_for_download", false)
end
- # Chrome 75 defaults to W3C mode which doesn't allow console log access
- options.add_option(:w3c, false)
-
# Set up a proxy server to block all external traffic.
@blackhole_tcp_server = TCPServer.new(0)
Thread.new do
@@ -99,18 +103,11 @@ Capybara.register_driver :chrome do |app|
Capybara::Selenium::Driver.new(
app,
browser: :chrome,
- desired_capabilities: capabilities,
options: options
)
end
Capybara.register_driver :firefox do |app|
- capabilities = Selenium::WebDriver::Remote::Capabilities.firefox(
- log: {
- level: :trace
- }
- )
-
options = Selenium::WebDriver::Firefox::Options.new(log_level: :trace)
options.add_argument("--window-size=#{CAPYBARA_WINDOW_SIZE.join(',')}")
@@ -121,7 +118,6 @@ Capybara.register_driver :firefox do |app|
Capybara::Selenium::Driver.new(
app,
browser: :firefox,
- desired_capabilities: capabilities,
options: options
)
end
@@ -213,20 +209,11 @@ RSpec.configure do |config|
# fixed. If we raised the `JSException` the fixed test would be marked as
# failed again.
if example.exception && !example.exception.is_a?(RSpec::Core::Pending::PendingExampleFixedError)
- begin
- console = page.driver.browser.manage.logs.get(:browser)&.reject { |log| log.message =~ JS_CONSOLE_FILTER }
-
- if console.present?
- message = "Unexpected browser console output:\n" + console.map(&:message).join("\n")
- raise JSConsoleError, message
- end
- rescue Selenium::WebDriver::Error::WebDriverError => error
- if error.message =~ %r{unknown command: session/[0-9a-zA-Z]+(?:/se)?/log}
- message = "Unable to access Chrome javascript console logs. You may be using an outdated version of ChromeDriver."
- raise JSConsoleError, message
- else
- raise error
- end
+ console = page.driver.browser.logs.get(:browser)&.reject { |log| log.message =~ JS_CONSOLE_FILTER }
+
+ if console.present?
+ message = "Unexpected browser console output:\n" + console.map(&:message).join("\n")
+ raise JSConsoleError, message
end
end
diff --git a/spec/support/capybara_wait_for_all_requests.rb b/spec/support/capybara_wait_for_all_requests.rb
new file mode 100644
index 00000000000..36b63619b08
--- /dev/null
+++ b/spec/support/capybara_wait_for_all_requests.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require_relative 'helpers/capybara_helpers'
+require_relative 'helpers/wait_for_requests'
+
+module Capybara
+ class Session
+ module WaitForAllRequestsAfterVisitPage
+ include CapybaraHelpers
+ include WaitForRequests
+
+ def visit(visit_uri)
+ super
+
+ wait_for_all_requests
+ end
+ end
+
+ prepend WaitForAllRequestsAfterVisitPage
+ end
+
+ module Node
+ module Actions
+ include CapybaraHelpers
+ include WaitForRequests
+
+ module WaitForAllRequestsAfterClickButton
+ def click_button(locator = nil, **options)
+ super
+
+ wait_for_all_requests
+ end
+ end
+
+ module WaitForAllRequestsAfterClickLink
+ def click_link(locator = nil, **options)
+ super
+
+ wait_for_all_requests
+ end
+ end
+
+ prepend WaitForAllRequestsAfterClickButton
+ prepend WaitForAllRequestsAfterClickLink
+ end
+ end
+end
diff --git a/spec/support/cycle_analytics_helpers/test_generation.rb b/spec/support/cycle_analytics_helpers/test_generation.rb
deleted file mode 100644
index 816caf5f775..00000000000
--- a/spec/support/cycle_analytics_helpers/test_generation.rb
+++ /dev/null
@@ -1,160 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop:disable Metrics/AbcSize
-
-# Note: The ABC size is large here because we have a method generating test cases with
-# multiple nested contexts. This shouldn't count as a violation.
-module CycleAnalyticsHelpers
- module TestGeneration
- # Generate the most common set of specs that all value stream analytics phases need to have.
- #
- # Arguments:
- #
- # phase: Which phase are we testing? Will call `CycleAnalytics.new.send(phase)` for the final assertion
- # data_fn: A function that returns a hash, constituting initial data for the test case
- # start_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with
- # `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`).
- # Each `condition_fn` is expected to implement a case which consitutes the start of the given value stream analytics phase.
- # end_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with
- # `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`).
- # Each `condition_fn` is expected to implement a case which consitutes the end of the given value stream analytics phase.
- # before_end_fn: This function is run before calling the end time conditions. Used for setup that needs to be run between the start and end conditions.
- # post_fn: Code that needs to be run after running the end time conditions.
-
- def generate_cycle_analytics_spec(phase:, data_fn:, start_time_conditions:, end_time_conditions:, before_end_fn: nil, post_fn: nil)
- combinations_of_start_time_conditions = (1..start_time_conditions.size).flat_map { |size| start_time_conditions.combination(size).to_a }
- combinations_of_end_time_conditions = (1..end_time_conditions.size).flat_map { |size| end_time_conditions.combination(size).to_a }
-
- scenarios = combinations_of_start_time_conditions.product(combinations_of_end_time_conditions)
- scenarios.each do |start_time_conditions, end_time_conditions|
- let_it_be(:other_project) { create(:project, :repository) }
-
- before do
- other_project.add_developer(self.user)
- end
-
- context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do
- context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do
- it "finds the median of available durations between the two conditions", :sidekiq_might_not_need_inline do
- time_differences = Array.new(5) do |index|
- data = data_fn[self]
- start_time = (index * 10).days.from_now
- end_time = start_time + rand(1..5).days
-
- start_time_conditions.each do |condition_name, condition_fn|
- travel_to(start_time) { condition_fn[self, data] }
- end
-
- # Run `before_end_fn` at the midpoint between `start_time` and `end_time`
- travel_to(start_time + (end_time - start_time) / 2) { before_end_fn[self, data] } if before_end_fn
-
- end_time_conditions.each do |condition_name, condition_fn|
- travel_to(end_time) { condition_fn[self, data] }
- end
-
- travel_to(end_time + 1.day) { post_fn[self, data] } if post_fn
-
- end_time - start_time
- end
-
- median_time_difference = time_differences.sort[2]
- expect(subject[phase].project_median).to be_within(5).of(median_time_difference)
- end
-
- context "when the data belongs to another project" do
- it "returns nil" do
- # Use a stub to "trick" the data/condition functions
- # into using another project. This saves us from having to
- # define separate data/condition functions for this particular
- # test case.
- allow(self).to receive(:project) { other_project }
-
- data = data_fn[self]
- start_time = Time.now
- end_time = rand(1..10).days.from_now
-
- start_time_conditions.each do |condition_name, condition_fn|
- travel_to(start_time) { condition_fn[self, data] }
- end
-
- end_time_conditions.each do |condition_name, condition_fn|
- travel_to(end_time) { condition_fn[self, data] }
- end
-
- travel_to(end_time + 1.day) { post_fn[self, data] } if post_fn
-
- # Turn off the stub before checking assertions
- allow(self).to receive(:project).and_call_original
-
- expect(subject[phase].project_median).to be_nil
- end
- end
-
- context "when the end condition happens before the start condition" do
- it 'returns nil' do
- data = data_fn[self]
- start_time = Time.now
- end_time = start_time + rand(1..5).days
-
- # Run `before_end_fn` at the midpoint between `start_time` and `end_time`
- travel_to(start_time + (end_time - start_time) / 2) { before_end_fn[self, data] } if before_end_fn
-
- end_time_conditions.each do |condition_name, condition_fn|
- travel_to(start_time) { condition_fn[self, data] }
- end
-
- start_time_conditions.each do |condition_name, condition_fn|
- travel_to(end_time) { condition_fn[self, data] }
- end
-
- travel_to(end_time + 1.day) { post_fn[self, data] } if post_fn
-
- expect(subject[phase].project_median).to be_nil
- end
- end
- end
- end
-
- context "start condition NOT PRESENT: #{start_time_conditions.map(&:first).to_sentence}" do
- context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do
- it "returns nil" do
- data = data_fn[self]
- end_time = rand(1..10).days.from_now
-
- end_time_conditions.each_with_index do |(_condition_name, condition_fn), index|
- travel_to(end_time + index.days) { condition_fn[self, data] }
- end
-
- travel_to(end_time + 1.day) { post_fn[self, data] } if post_fn
-
- expect(subject[phase].project_median).to be_nil
- end
- end
- end
-
- context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do
- context "end condition NOT PRESENT: #{end_time_conditions.map(&:first).to_sentence}" do
- it "returns nil" do
- data = data_fn[self]
- start_time = Time.now
-
- start_time_conditions.each do |condition_name, condition_fn|
- travel_to(start_time) { condition_fn[self, data] }
- end
-
- post_fn[self, data] if post_fn
-
- expect(subject[phase].project_median).to be_nil
- end
- end
- end
- end
-
- context "when none of the start / end conditions are matched" do
- it "returns nil" do
- expect(subject[phase].project_median).to be_nil
- end
- end
- end
- end
-end
diff --git a/spec/support/database/prevent_cross_joins.rb b/spec/support/database/prevent_cross_joins.rb
index 8e08824c464..c44bf96a268 100644
--- a/spec/support/database/prevent_cross_joins.rb
+++ b/spec/support/database/prevent_cross_joins.rb
@@ -40,7 +40,7 @@ module Database
return
end
- schemas = ::Gitlab::Database::GitlabSchema.table_schemas(tables)
+ schemas = ::Gitlab::Database::GitlabSchema.table_schemas!(tables)
schemas.subtract(IGNORED_SCHEMAS)
if schemas.many?
diff --git a/spec/support/fast_quarantine.rb b/spec/support/fast_quarantine.rb
new file mode 100644
index 00000000000..b5ed1a2aa96
--- /dev/null
+++ b/spec/support/fast_quarantine.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+return unless ENV['CI']
+return if ENV['FAST_QUARANTINE'] == "false"
+return if ENV['CI_MERGE_REQUEST_LABELS'].to_s.include?('pipeline:run-flaky-tests')
+
+require_relative '../../tooling/lib/tooling/fast_quarantine'
+
+RSpec.configure do |config|
+ fast_quarantine_local_path = ENV.fetch('RSPEC_FAST_QUARANTINE_LOCAL_PATH', 'rspec/fast_quarantine-gitlab.txt')
+ fast_quarantine_path = ENV.fetch(
+ 'RSPEC_FAST_QUARANTINE_PATH',
+ File.expand_path("../../#{fast_quarantine_local_path}", __dir__)
+ )
+ fast_quarantine = Tooling::FastQuarantine.new(fast_quarantine_path: fast_quarantine_path)
+ skipped_examples = []
+
+ config.around do |example|
+ if fast_quarantine.skip_example?(example)
+ skipped_examples << example.id
+ skip "Skipping #{example.id} because it's been fast-quarantined."
+ else
+ example.run
+ end
+ end
+
+ config.after(:suite) do
+ next if skipped_examples.empty?
+
+ skipped_tests_report_path = ENV.fetch(
+ 'SKIPPED_TESTS_REPORT_PATH',
+ File.expand_path("../../rspec/flaky/skipped_tests.txt", __dir__)
+ )
+
+ File.write(skipped_tests_report_path, "#{ENV.fetch('CI_JOB_URL', 'local-run')}\n#{skipped_examples.join("\n")}\n\n")
+ end
+end
diff --git a/spec/support/finder_collection_allowlist.yml b/spec/support/finder_collection_allowlist.yml
index 750295e16c4..8fcb4ee7b9c 100644
--- a/spec/support/finder_collection_allowlist.yml
+++ b/spec/support/finder_collection_allowlist.yml
@@ -24,7 +24,8 @@
- Ci::CommitStatusesFinder
- Ci::DailyBuildGroupReportResultsFinder
- ClusterAncestorsFinder
-- Clusters::AgentAuthorizationsFinder
+- Clusters::Agents::Authorizations::CiAccess::Finder
+- Clusters::Agents::Authorizations::UserAccess::Finder
- Clusters::KubernetesNamespaceFinder
- ComplianceManagement::MergeRequests::ComplianceViolationsFinder
- ContainerRepositoriesFinder
@@ -62,6 +63,7 @@
- Security::TrainingUrlsFinder
- Security::TrainingProviders::KontraUrlFinder
- Security::TrainingProviders::SecureCodeWarriorUrlFinder
+- Security::TrainingProviders::SecureFlagUrlFinder
- SentryIssueFinder
- ServerlessDomainFinder
- TagsFinder
@@ -69,3 +71,4 @@
- UploaderFinder
- UserGroupNotificationSettingsFinder
- UserGroupsCounter
+- DataTransfer::MockedTransferFinder # Can be removed when https://gitlab.com/gitlab-org/gitlab/-/issues/397693 is closed
diff --git a/spec/support/flaky_tests.rb b/spec/support/flaky_tests.rb
deleted file mode 100644
index 4df0d23bfc3..00000000000
--- a/spec/support/flaky_tests.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-return unless ENV['CI']
-return if ENV['SKIP_FLAKY_TESTS_AUTOMATICALLY'] == "false"
-return if ENV['CI_MERGE_REQUEST_LABELS'].to_s.include?('pipeline:run-flaky-tests')
-
-require_relative '../../tooling/rspec_flaky/config'
-require_relative '../../tooling/rspec_flaky/report'
-
-RSpec.configure do |config|
- $flaky_test_example_ids = begin # rubocop:disable Style/GlobalVars
- raise "#{RspecFlaky::Config.suite_flaky_examples_report_path} doesn't exist" unless File.exist?(RspecFlaky::Config.suite_flaky_examples_report_path)
-
- RspecFlaky::Report.load(RspecFlaky::Config.suite_flaky_examples_report_path).map { |_, flaky_test_data| flaky_test_data.to_h[:example_id] }
- rescue => e # rubocop:disable Style/RescueStandardError
- puts e
- []
- end
- $skipped_flaky_tests_report = [] # rubocop:disable Style/GlobalVars
-
- config.around do |example|
- # Skip flaky tests automatically
- if $flaky_test_example_ids.include?(example.id) # rubocop:disable Style/GlobalVars
- puts "Skipping #{example.id} '#{example.full_description}' because it's flaky."
- $skipped_flaky_tests_report << example.id # rubocop:disable Style/GlobalVars
- else
- example.run
- end
- end
-
- config.after(:suite) do
- next unless RspecFlaky::Config.skipped_flaky_tests_report_path
- next if $skipped_flaky_tests_report.empty? # rubocop:disable Style/GlobalVars
-
- File.write(RspecFlaky::Config.skipped_flaky_tests_report_path, "#{ENV['CI_JOB_URL']}\n#{$skipped_flaky_tests_report.join("\n")}\n\n") # rubocop:disable Style/GlobalVars
- end
-end
diff --git a/spec/support/google_api/cloud_platform_helpers.rb b/spec/support/google_api/cloud_platform_helpers.rb
deleted file mode 100644
index b9752577c76..00000000000
--- a/spec/support/google_api/cloud_platform_helpers.rb
+++ /dev/null
@@ -1,166 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleApi
- module CloudPlatformHelpers
- def stub_google_api_validate_token
- request.session[GoogleApi::CloudPlatform::Client.session_key_for_token] = 'token'
- request.session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = 1.hour.since.to_i.to_s
- end
-
- def stub_google_api_expired_token
- request.session[GoogleApi::CloudPlatform::Client.session_key_for_token] = 'token'
- request.session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = 1.hour.ago.to_i.to_s
- end
-
- def stub_cloud_platform_projects_list(options)
- WebMock.stub_request(:get, cloud_platform_projects_list_url)
- .to_return(cloud_platform_response(cloud_platform_projects_body(options)))
- end
-
- def stub_cloud_platform_projects_get_billing_info(project_id, billing_enabled)
- WebMock.stub_request(:get, cloud_platform_projects_get_billing_info_url(project_id))
- .to_return(cloud_platform_response(cloud_platform_projects_billing_info_body(project_id, billing_enabled)))
- end
-
- def stub_cloud_platform_get_zone_cluster(project_id, zone, cluster_id, options = {})
- WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id))
- .to_return(cloud_platform_response(cloud_platform_cluster_body(options)))
- end
-
- def stub_cloud_platform_get_zone_cluster_error(project_id, zone, cluster_id)
- WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id))
- .to_return(status: [500, "Internal Server Error"])
- end
-
- def stub_cloud_platform_create_cluster(project_id, zone, options = {})
- WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone))
- .to_return(cloud_platform_response(cloud_platform_operation_body(options)))
- end
-
- def stub_cloud_platform_create_cluster_error(project_id, zone)
- WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone))
- .to_return(status: [500, "Internal Server Error"])
- end
-
- def stub_cloud_platform_get_zone_operation(project_id, zone, operation_id, options = {})
- WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id))
- .to_return(cloud_platform_response(cloud_platform_operation_body(options)))
- end
-
- def stub_cloud_platform_get_zone_operation_error(project_id, zone, operation_id)
- WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id))
- .to_return(status: [500, "Internal Server Error"])
- end
-
- def cloud_platform_projects_list_url
- "https://cloudresourcemanager.googleapis.com/v1/projects"
- end
-
- def cloud_platform_projects_get_billing_info_url(project_id)
- "https://cloudbilling.googleapis.com/v1/projects/#{project_id}/billingInfo"
- end
-
- def cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id)
- "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/clusters/#{cluster_id}"
- end
-
- def cloud_platform_create_cluster_url(project_id, zone)
- "https://container.googleapis.com/v1beta1/projects/#{project_id}/zones/#{zone}/clusters"
- end
-
- def cloud_platform_get_zone_operation_url(project_id, zone, operation_id)
- "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/operations/#{operation_id}"
- end
-
- def cloud_platform_response(body)
- { status: 200, headers: { 'Content-Type' => 'application/json' }, body: body.to_json }
- end
-
- def load_sample_cert
- pem_file = File.expand_path(Rails.root.join('spec/fixtures/clusters/sample_cert.pem'))
- Base64.encode64(File.read(pem_file))
- end
-
- ##
- # gcloud container clusters create
- # https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.zones.clusters/create
- # rubocop:disable Metrics/CyclomaticComplexity
- # rubocop:disable Metrics/PerceivedComplexity
- def cloud_platform_cluster_body(options)
- {
- "name": options[:name] || 'string',
- "description": options[:description] || 'string',
- "initialNodeCount": options[:initialNodeCount] || 'number',
- "masterAuth": {
- "username": options[:username] || 'string',
- "password": options[:password] || 'string',
- "clusterCaCertificate": options[:clusterCaCertificate] || load_sample_cert,
- "clientCertificate": options[:clientCertificate] || 'string',
- "clientKey": options[:clientKey] || 'string'
- },
- "loggingService": options[:loggingService] || 'string',
- "monitoringService": options[:monitoringService] || 'string',
- "network": options[:network] || 'string',
- "clusterIpv4Cidr": options[:clusterIpv4Cidr] || 'string',
- "subnetwork": options[:subnetwork] || 'string',
- "enableKubernetesAlpha": options[:enableKubernetesAlpha] || 'boolean',
- "labelFingerprint": options[:labelFingerprint] || 'string',
- "selfLink": options[:selfLink] || 'string',
- "zone": options[:zone] || 'string',
- "endpoint": options[:endpoint] || 'string',
- "initialClusterVersion": options[:initialClusterVersion] || 'string',
- "currentMasterVersion": options[:currentMasterVersion] || 'string',
- "currentNodeVersion": options[:currentNodeVersion] || 'string',
- "createTime": options[:createTime] || 'string',
- "status": options[:status] || 'RUNNING',
- "statusMessage": options[:statusMessage] || 'string',
- "nodeIpv4CidrSize": options[:nodeIpv4CidrSize] || 'number',
- "servicesIpv4Cidr": options[:servicesIpv4Cidr] || 'string',
- "currentNodeCount": options[:currentNodeCount] || 'number',
- "expireTime": options[:expireTime] || 'string'
- }
- end
-
- def cloud_platform_operation_body(options)
- {
- "name": options[:name] || 'operation-1234567891234-1234567',
- "zone": options[:zone] || 'us-central1-a',
- "operationType": options[:operationType] || 'CREATE_CLUSTER',
- "status": options[:status] || 'PENDING',
- "detail": options[:detail] || 'detail',
- "statusMessage": options[:statusMessage] || '',
- "selfLink": options[:selfLink] || 'https://container.googleapis.com/v1/projects/123456789101/zones/us-central1-a/operations/operation-1234567891234-1234567',
- "targetLink": options[:targetLink] || 'https://container.googleapis.com/v1/projects/123456789101/zones/us-central1-a/clusters/test-cluster',
- "startTime": options[:startTime] || '2017-09-13T16:49:13.055601589Z',
- "endTime": options[:endTime] || ''
- }
- end
-
- def cloud_platform_projects_body(options)
- {
- "projects": [
- {
- "projectNumber": options[:project_number] || "1234",
- "projectId": options[:project_id] || "test-project-1234",
- "lifecycleState": "ACTIVE",
- "name": options[:name] || "test-project",
- "createTime": "2017-12-16T01:48:29.129Z",
- "parent": {
- "type": "organization",
- "id": "12345"
- }
- }
- ]
- }
- end
-
- def cloud_platform_projects_billing_info_body(project_id, billing_enabled)
- {
- "name": "projects/#{project_id}/billingInfo",
- "projectId": project_id.to_s,
- "billingAccountName": "account-name",
- "billingEnabled": billing_enabled
- }
- end
- end
-end
diff --git a/spec/support/graphql/fake_query_type.rb b/spec/support/graphql/fake_query_type.rb
deleted file mode 100644
index 18cf2cf3e82..00000000000
--- a/spec/support/graphql/fake_query_type.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-require 'graphql'
-
-module Graphql
- class FakeQueryType < ::GraphQL::Schema::Object
- graphql_name 'FakeQuery'
-
- field :hello_world, String, null: true do
- argument :message, String, required: false
- end
-
- field :breaking_field, String, null: true
-
- def hello_world(message: "world")
- "Hello #{message}!"
- end
-
- def breaking_field
- raise "This field is supposed to break"
- end
- end
-end
diff --git a/spec/support/graphql/subscriptions/action_cable/mock_action_cable.rb b/spec/support/graphql/subscriptions/action_cable/mock_action_cable.rb
deleted file mode 100644
index 5467564a79e..00000000000
--- a/spec/support/graphql/subscriptions/action_cable/mock_action_cable.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-# frozen_string_literal: true
-
-# A stub implementation of ActionCable.
-# Any methods to support the mock backend have `mock` in the name.
-module Graphql
- module Subscriptions
- module ActionCable
- class MockActionCable
- class MockChannel
- def initialize
- @mock_broadcasted_messages = []
- end
-
- attr_reader :mock_broadcasted_messages
-
- def stream_from(stream_name, coder: nil, &block)
- # Rails uses `coder`, we don't
- block ||= ->(msg) { @mock_broadcasted_messages << msg }
- MockActionCable.mock_stream_for(stream_name).add_mock_channel(self, block)
- end
- end
-
- class MockStream
- def initialize
- @mock_channels = {}
- end
-
- def add_mock_channel(channel, handler)
- @mock_channels[channel] = handler
- end
-
- def mock_broadcast(message)
- @mock_channels.each do |channel, handler|
- handler && handler.call(message)
- end
- end
- end
-
- class << self
- def clear_mocks
- @mock_streams = {}
- end
-
- def server
- self
- end
-
- def broadcast(stream_name, message)
- stream = @mock_streams[stream_name]
- stream && stream.mock_broadcast(message)
- end
-
- def mock_stream_for(stream_name)
- @mock_streams[stream_name] ||= MockStream.new
- end
-
- def get_mock_channel
- MockChannel.new
- end
-
- def mock_stream_names
- @mock_streams.keys
- end
- end
- end
-
- class MockSchema < GraphQL::Schema
- class << self
- def find_by_gid(gid)
- return unless gid
-
- if gid.model_class < ApplicationRecord
- Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find
- elsif gid.model_class.respond_to?(:lazy_find)
- gid.model_class.lazy_find(gid.model_id)
- else
- gid.find
- end
- end
-
- def id_from_object(object, _type = nil, _ctx = nil)
- unless object.respond_to?(:to_global_id)
- # This is an error in our schema and needs to be solved. So raise a
- # more meaningful error message
- raise "#{object} does not implement `to_global_id`. " \
- "Include `GlobalID::Identification` into `#{object.class}"
- end
-
- object.to_global_id
- end
- end
-
- query(::Types::QueryType)
- subscription(::Types::SubscriptionType)
-
- use GraphQL::Subscriptions::ActionCableSubscriptions, action_cable: MockActionCable, action_cable_coder: JSON
- end
- end
- end
-end
diff --git a/spec/support/helpers/api_internal_base_helpers.rb b/spec/support/helpers/api_internal_base_helpers.rb
index e89716571f9..0c334e164a6 100644
--- a/spec/support/helpers/api_internal_base_helpers.rb
+++ b/spec/support/helpers/api_internal_base_helpers.rb
@@ -13,8 +13,6 @@ module APIInternalBaseHelpers
Gitlab::GlRepository::PROJECT.identifier_for_container(container)
when Snippet
Gitlab::GlRepository::SNIPPET.identifier_for_container(container)
- else
- nil
end
end
@@ -44,12 +42,14 @@ module APIInternalBaseHelpers
end
def push(key, container, protocol = 'ssh', env: nil, changes: nil)
- push_with_path(key,
- full_path: full_path_for(container),
- gl_repository: gl_repository_for(container),
- protocol: protocol,
- env: env,
- changes: changes)
+ push_with_path(
+ key,
+ full_path: full_path_for(container),
+ gl_repository: gl_repository_for(container),
+ protocol: protocol,
+ env: env,
+ changes: changes
+ )
end
def push_with_path(key, full_path:, gl_repository: nil, protocol: 'ssh', env: nil, changes: nil)
diff --git a/spec/support/helpers/board_helpers.rb b/spec/support/helpers/board_helpers.rb
index d7277ba9a20..c7a7993c52b 100644
--- a/spec/support/helpers/board_helpers.rb
+++ b/spec/support/helpers/board_helpers.rb
@@ -29,13 +29,15 @@ module BoardHelpers
# ensure there is enough horizontal space for four board lists
resize_window(2000, 800)
- drag_to(selector: selector,
- scrollable: '#board-app',
- list_from_index: list_from_index,
- from_index: from_index,
- to_index: to_index,
- list_to_index: list_to_index,
- perform_drop: perform_drop)
+ drag_to(
+ selector: selector,
+ scrollable: '#board-app',
+ list_from_index: list_from_index,
+ from_index: from_index,
+ to_index: to_index,
+ list_to_index: list_to_index,
+ perform_drop: perform_drop
+ )
end
wait_for_requests
diff --git a/spec/support/helpers/callouts_test_helper.rb b/spec/support/helpers/callouts_test_helper.rb
deleted file mode 100644
index 8c7faa71d9f..00000000000
--- a/spec/support/helpers/callouts_test_helper.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module CalloutsTestHelper
- def callouts_trials_link_path
- '/-/trial_registrations/new?glm_content=gold-callout&glm_source=gitlab.com'
- end
-end
-
-CalloutsTestHelper.prepend_mod
diff --git a/spec/support/chunked_io/chunked_io_helpers.rb b/spec/support/helpers/chunked_io_helpers.rb
index 278f577f3cb..278f577f3cb 100644
--- a/spec/support/chunked_io/chunked_io_helpers.rb
+++ b/spec/support/helpers/chunked_io_helpers.rb
diff --git a/spec/support/helpers/ci/source_pipeline_helpers.rb b/spec/support/helpers/ci/source_pipeline_helpers.rb
index b99f499cc16..ef3aea7de52 100644
--- a/spec/support/helpers/ci/source_pipeline_helpers.rb
+++ b/spec/support/helpers/ci/source_pipeline_helpers.rb
@@ -3,11 +3,13 @@
module Ci
module SourcePipelineHelpers
def create_source_pipeline(upstream, downstream)
- create(:ci_sources_pipeline,
- source_job: create(:ci_build, pipeline: upstream),
- source_project: upstream.project,
- pipeline: downstream,
- project: downstream.project)
+ create(
+ :ci_sources_pipeline,
+ source_job: create(:ci_build, pipeline: upstream),
+ source_project: upstream.project,
+ pipeline: downstream,
+ project: downstream.project
+ )
end
end
end
diff --git a/spec/support/helpers/ci/template_helpers.rb b/spec/support/helpers/ci/template_helpers.rb
index cd3ab4bd82d..1818dec5fc7 100644
--- a/spec/support/helpers/ci/template_helpers.rb
+++ b/spec/support/helpers/ci/template_helpers.rb
@@ -6,6 +6,10 @@ module Ci
'registry.gitlab.com'
end
+ def auto_build_image_repository
+ "gitlab-org/cluster-integration/auto-build-image"
+ end
+
def public_image_exist?(registry, repository, image)
public_image_manifest(registry, repository, image).present?
end
diff --git a/spec/support/helpers/content_editor_helpers.rb b/spec/support/helpers/content_editor_helpers.rb
new file mode 100644
index 00000000000..83c18f8073f
--- /dev/null
+++ b/spec/support/helpers/content_editor_helpers.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module ContentEditorHelpers
+ def switch_to_content_editor
+ click_button("Switch to rich text")
+ end
+
+ def type_in_content_editor(keys)
+ find(content_editor_testid).send_keys keys
+ end
+
+ def click_attachment_button
+ page.find('svg[data-testid="paperclip-icon"]').click
+ end
+
+ def set_source_editor_content(content)
+ find('.js-gfm-input').set content
+ end
+
+ def expect_media_bubble_menu_to_be_visible
+ expect(page).to have_css('[data-testid="media-bubble-menu"]')
+ end
+
+ def upload_asset(fixture_name)
+ attach_file('content_editor_image', Rails.root.join('spec', 'fixtures', fixture_name), make_visible: true)
+ end
+
+ def wait_until_hidden_field_is_updated(value)
+ expect(page).to have_field(with: value, type: 'hidden')
+ end
+
+ def display_media_bubble_menu(media_element_selector, fixture_file)
+ upload_asset fixture_file
+
+ wait_for_requests
+
+ expect(page).to have_css(media_element_selector)
+
+ page.find(media_element_selector).click
+ end
+
+ def click_edit_diagram_button
+ page.find('[data-testid="edit-diagram"]').click
+ end
+
+ def expect_drawio_editor_is_opened
+ expect(page).to have_css('#drawio-frame', visible: :hidden)
+ end
+end
diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb
index eba5771e062..0accb341cb9 100644
--- a/spec/support/helpers/cycle_analytics_helpers.rb
+++ b/spec/support/helpers/cycle_analytics_helpers.rb
@@ -91,13 +91,13 @@ module CycleAnalyticsHelpers
wait_for_requests
end
- def create_value_stream_group_aggregation(group)
- aggregation = Analytics::CycleAnalytics::Aggregation.safe_create_for_namespace(group)
+ def create_value_stream_aggregation(group_or_project_namespace)
+ aggregation = Analytics::CycleAnalytics::Aggregation.safe_create_for_namespace(group_or_project_namespace)
Analytics::CycleAnalytics::AggregatorService.new(aggregation: aggregation).execute
end
def select_group_and_custom_value_stream(group, custom_value_stream_name)
- create_value_stream_group_aggregation(group)
+ create_value_stream_aggregation(group)
select_group(group)
select_value_stream(custom_value_stream_name)
@@ -235,4 +235,13 @@ module CycleAnalyticsHelpers
pipeline: dummy_pipeline(project),
protected: false)
end
+
+ 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 API so we have aggregated data
+ ::Dora::DailyMetrics::RefreshWorker.new.perform(environment.id, Time.current.to_date.to_s) if Gitlab.ee?
+ end
end
diff --git a/spec/support/helpers/cycle_analytics_helpers/test_generation.rb b/spec/support/helpers/cycle_analytics_helpers/test_generation.rb
new file mode 100644
index 00000000000..1c7c45c06a1
--- /dev/null
+++ b/spec/support/helpers/cycle_analytics_helpers/test_generation.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+# rubocop:disable Layout/LineLength
+# rubocop:disable Metrics/CyclomaticComplexity
+# rubocop:disable Metrics/PerceivedComplexity
+# rubocop:disable Metrics/AbcSize
+
+# Note: The ABC size is large here because we have a method generating test cases with
+# multiple nested contexts. This shouldn't count as a violation.
+module CycleAnalyticsHelpers
+ module TestGeneration
+ # Generate the most common set of specs that all value stream analytics phases need to have.
+ #
+ # Arguments:
+ #
+ # phase: Which phase are we testing? Will call `CycleAnalytics.new.send(phase)` for the final assertion
+ # data_fn: A function that returns a hash, constituting initial data for the test case
+ # start_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with
+ # `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`).
+ # Each `condition_fn` is expected to implement a case which consitutes the start of the given value stream analytics phase.
+ # end_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with
+ # `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`).
+ # Each `condition_fn` is expected to implement a case which consitutes the end of the given value stream analytics phase.
+ # before_end_fn: This function is run before calling the end time conditions. Used for setup that needs to be run between the start and end conditions.
+ # post_fn: Code that needs to be run after running the end time conditions.
+
+ def generate_cycle_analytics_spec(phase:, data_fn:, start_time_conditions:, end_time_conditions:, before_end_fn: nil, post_fn: nil)
+ combinations_of_start_time_conditions = (1..start_time_conditions.size).flat_map { |size| start_time_conditions.combination(size).to_a }
+ combinations_of_end_time_conditions = (1..end_time_conditions.size).flat_map { |size| end_time_conditions.combination(size).to_a }
+
+ scenarios = combinations_of_start_time_conditions.product(combinations_of_end_time_conditions)
+ scenarios.each do |start_time_conditions, end_time_conditions|
+ let_it_be(:other_project) { create(:project, :repository) }
+
+ before do
+ other_project.add_developer(user)
+ end
+
+ context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do
+ context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do
+ it "finds the median of available durations between the two conditions", :sidekiq_might_not_need_inline do
+ time_differences = Array.new(5) do |index|
+ data = data_fn[self]
+ start_time = (index * 10).days.from_now
+ end_time = start_time + rand(1..5).days
+
+ start_time_conditions.each_value do |condition_fn|
+ travel_to(start_time) { condition_fn[self, data] }
+ end
+
+ # Run `before_end_fn` at the midpoint between `start_time` and `end_time`
+ travel_to(start_time + ((end_time - start_time) / 2)) { before_end_fn[self, data] } if before_end_fn
+
+ end_time_conditions.each_value do |condition_fn|
+ travel_to(end_time) { condition_fn[self, data] }
+ end
+
+ travel_to(end_time + 1.day) { post_fn[self, data] } if post_fn
+
+ end_time - start_time
+ end
+
+ median_time_difference = time_differences.sort[2]
+ expect(subject[phase].project_median).to be_within(5).of(median_time_difference)
+ end
+
+ context "when the data belongs to another project" do
+ it "returns nil" do
+ # Use a stub to "trick" the data/condition functions
+ # into using another project. This saves us from having to
+ # define separate data/condition functions for this particular
+ # test case.
+ allow(self).to receive(:project) { other_project }
+
+ data = data_fn[self]
+ start_time = Time.now
+ end_time = rand(1..10).days.from_now
+
+ start_time_conditions.each_value do |condition_fn|
+ travel_to(start_time) { condition_fn[self, data] }
+ end
+
+ end_time_conditions.each_value do |condition_fn|
+ travel_to(end_time) { condition_fn[self, data] }
+ end
+
+ travel_to(end_time + 1.day) { post_fn[self, data] } if post_fn
+
+ # Turn off the stub before checking assertions
+ allow(self).to receive(:project).and_call_original
+
+ expect(subject[phase].project_median).to be_nil
+ end
+ end
+
+ context "when the end condition happens before the start condition" do
+ it 'returns nil' do
+ data = data_fn[self]
+ start_time = Time.now
+ end_time = start_time + rand(1..5).days
+
+ # Run `before_end_fn` at the midpoint between `start_time` and `end_time`
+ travel_to(start_time + ((end_time - start_time) / 2)) { before_end_fn[self, data] } if before_end_fn
+
+ end_time_conditions.each_value do |condition_fn|
+ travel_to(start_time) { condition_fn[self, data] }
+ end
+
+ start_time_conditions.each_value do |condition_fn|
+ travel_to(end_time) { condition_fn[self, data] }
+ end
+
+ travel_to(end_time + 1.day) { post_fn[self, data] } if post_fn
+
+ expect(subject[phase].project_median).to be_nil
+ end
+ end
+ end
+
+ context "end condition NOT PRESENT: #{end_time_conditions.map(&:first).to_sentence}" do
+ it "returns nil" do
+ data = data_fn[self]
+ start_time = Time.now
+
+ start_time_conditions.each_value do |condition_fn|
+ travel_to(start_time) { condition_fn[self, data] }
+ end
+
+ post_fn[self, data] if post_fn
+
+ expect(subject[phase].project_median).to be_nil
+ end
+ end
+ end
+
+ context "start condition NOT PRESENT: #{start_time_conditions.map(&:first).to_sentence}" do
+ context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do
+ it "returns nil" do
+ data = data_fn[self]
+ end_time = rand(1..10).days.from_now
+
+ end_time_conditions.each_with_index do |(_condition_name, condition_fn), index|
+ travel_to(end_time + index.days) { condition_fn[self, data] }
+ end
+
+ travel_to(end_time + 1.day) { post_fn[self, data] } if post_fn
+
+ expect(subject[phase].project_median).to be_nil
+ end
+ end
+ end
+ end
+
+ context "when none of the start / end conditions are matched" do
+ it "returns nil" do
+ expect(subject[phase].project_median).to be_nil
+ end
+ end
+ end
+ end
+end
+
+# rubocop:enable Layout/LineLength
+# rubocop:enable Metrics/CyclomaticComplexity
+# rubocop:enable Metrics/PerceivedComplexity
+# rubocop:enable Metrics/AbcSize
diff --git a/spec/support/helpers/database/database_helpers.rb b/spec/support/helpers/database/database_helpers.rb
index ecc42041e93..ff694bcd15b 100644
--- a/spec/support/helpers/database/database_helpers.rb
+++ b/spec/support/helpers/database/database_helpers.rb
@@ -4,11 +4,13 @@ module Database
module DatabaseHelpers
# In order to directly work with views using factories,
# we can swapout the view for a table of identical structure.
- def swapout_view_for_table(view, connection:)
+ def swapout_view_for_table(view, connection:, schema: nil)
+ table_name = [schema, "_test_#{view}_copy"].compact.join('.')
+
connection.execute(<<~SQL.squish)
- CREATE TABLE #{view}_copy (LIKE #{view});
+ CREATE TABLE #{table_name} (LIKE #{view});
DROP VIEW #{view};
- ALTER TABLE #{view}_copy RENAME TO #{view};
+ ALTER TABLE #{table_name} RENAME TO #{view};
SQL
end
diff --git a/spec/support/helpers/database/inject_failure_helpers.rb b/spec/support/helpers/database/inject_failure_helpers.rb
new file mode 100644
index 00000000000..df98f45e69f
--- /dev/null
+++ b/spec/support/helpers/database/inject_failure_helpers.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Database
+ module InjectFailureHelpers
+ # These methods are used by specs that inject faults into the migration procedure and then ensure
+ # that it migrates correctly when rerun
+ def fail_first_time
+ # We can't directly use a boolean here, as we need something that will be passed by-reference to the proc
+ fault_status = { faulted: false }
+ proc do |m, *args, **kwargs|
+ next m.call(*args, **kwargs) if fault_status[:faulted]
+
+ fault_status[:faulted] = true
+ raise 'fault!'
+ end
+ end
+
+ def fail_sql_matching(regex)
+ proc do
+ allow(migration_context.connection).to receive(:execute).and_call_original
+ allow(migration_context.connection).to receive(:execute).with(regex).and_wrap_original(&fail_first_time)
+ end
+ end
+
+ def fail_adding_fk(from_table, to_table)
+ proc do
+ allow(migration_context.connection).to receive(:add_foreign_key).and_call_original
+ expect(migration_context.connection).to receive(:add_foreign_key).with(from_table, to_table, any_args)
+ .and_wrap_original(&fail_first_time)
+ end
+ end
+
+ def fail_removing_fk(from_table, to_table)
+ proc do
+ allow(migration_context.connection).to receive(:remove_foreign_key).and_call_original
+ expect(migration_context.connection).to receive(:remove_foreign_key).with(from_table, to_table, any_args)
+ .and_wrap_original(&fail_first_time)
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/database/multiple_databases_helpers.rb b/spec/support/helpers/database/multiple_databases_helpers.rb
index 5083ea1ff53..3c9a5762c47 100644
--- a/spec/support/helpers/database/multiple_databases_helpers.rb
+++ b/spec/support/helpers/database/multiple_databases_helpers.rb
@@ -4,6 +4,28 @@ module Database
module MultipleDatabasesHelpers
EXTRA_DBS = ::Gitlab::Database::DATABASE_NAMES.map(&:to_sym) - [:main]
+ def database_exists?(database_name)
+ ::Gitlab::Database.has_database?(database_name)
+ end
+
+ def skip_if_shared_database(database_name)
+ skip "Skipping because #{database_name} is shared or doesn't not exist" unless database_exists?(database_name)
+ end
+
+ def skip_if_database_exists(database_name)
+ skip "Skipping because database #{database_name} exists" if database_exists?(database_name)
+ end
+
+ def execute_on_each_database(query, databases: %I[main ci])
+ databases = databases.select { |database_name| database_exists?(database_name) }
+
+ Gitlab::Database::EachDatabase.each_database_connection(only: databases, include_shared: false) do |connection, _|
+ next unless Gitlab::Database.gitlab_schemas_for_connection(connection).include?(:gitlab_shared)
+
+ connection.execute(query)
+ end
+ end
+
def skip_if_multiple_databases_not_setup(*databases)
unless (databases - EXTRA_DBS).empty?
raise "Unsupported database in #{databases}. It must be one of #{EXTRA_DBS}."
diff --git a/spec/support/helpers/email_helpers.rb b/spec/support/helpers/email_helpers.rb
index f4bdaa7e425..57386233775 100644
--- a/spec/support/helpers/email_helpers.rb
+++ b/spec/support/helpers/email_helpers.rb
@@ -76,4 +76,25 @@ module EmailHelpers
composed_expectation.and(have_enqueued_mail(mailer_class, mailer_method).with(*arguments))
end
end
+
+ def expect_sender(user, sender_email: nil)
+ sender = subject.header[:from].addrs[0]
+ expect(sender.display_name).to eq("#{user.name} (@#{user.username})")
+ expect(sender.address).to eq(sender_email.presence || gitlab_sender)
+ end
+
+ def expect_service_desk_custom_email_delivery_options(service_desk_setting)
+ expect(subject.delivery_method).to be_a Mail::SMTP
+ expect(service_desk_setting.custom_email_credential).to be_present
+
+ credential = service_desk_setting.custom_email_credential
+
+ expect(subject.delivery_method.settings).to include(
+ address: credential.smtp_address,
+ port: credential.smtp_port,
+ user_name: credential.smtp_username,
+ password: credential.smtp_password,
+ domain: service_desk_setting.custom_email.split('@').last
+ )
+ end
end
diff --git a/spec/support/helpers/every_sidekiq_worker_test_helper.rb b/spec/support/helpers/every_sidekiq_worker_test_helper.rb
new file mode 100644
index 00000000000..b053ed04b58
--- /dev/null
+++ b/spec/support/helpers/every_sidekiq_worker_test_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module EverySidekiqWorkerTestHelper
+ def extra_retry_exceptions
+ {}
+ end
+end
+
+EverySidekiqWorkerTestHelper.prepend_mod
diff --git a/spec/support/helpers/fake_u2f_device.rb b/spec/support/helpers/fake_u2f_device.rb
deleted file mode 100644
index 2ed1222ebd3..00000000000
--- a/spec/support/helpers/fake_u2f_device.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-class FakeU2fDevice
- attr_reader :name
-
- def initialize(page, name, device = nil)
- @page = page
- @name = name
- @u2f_device = device
- end
-
- def respond_to_u2f_registration
- app_id = @page.evaluate_script('gon.u2f.app_id')
- challenges = @page.evaluate_script('gon.u2f.challenges')
-
- json_response = u2f_device(app_id).register_response(challenges[0])
-
- @page.execute_script("
- u2f.register = function(appId, registerRequests, signRequests, callback) {
- callback(#{json_response});
- };
- ")
- end
-
- def respond_to_u2f_authentication
- app_id = @page.evaluate_script('gon.u2f.app_id')
- challenge = @page.evaluate_script('gon.u2f.challenge')
- json_response = u2f_device(app_id).sign_response(challenge)
-
- @page.execute_script("
- u2f.sign = function(appId, challenges, signRequests, callback) {
- callback(#{json_response});
- };
- window.gl.u2fAuthenticate.start();
- ")
- end
-
- def fake_u2f_authentication
- @page.execute_script("window.gl.u2fAuthenticate.renderAuthenticated('abc');")
- end
-
- private
-
- def u2f_device(app_id)
- @u2f_device ||= U2F::FakeU2F.new(app_id)
- end
-end
diff --git a/spec/support/helpers/fake_webauthn_device.rb b/spec/support/helpers/fake_webauthn_device.rb
index d2c2f7d6bf3..5a535735817 100644
--- a/spec/support/helpers/fake_webauthn_device.rb
+++ b/spec/support/helpers/fake_webauthn_device.rb
@@ -45,7 +45,7 @@ class FakeWebauthnDevice
return Promise.resolve(result);
};
JS
- @page.click_link('Try again?', href: false)
+ @page.click_button(_('Try again?'))
end
def fake_webauthn_authentication
diff --git a/spec/support/helpers/feature_flag_helpers.rb b/spec/support/helpers/feature_flag_helpers.rb
index 4e57002a7c6..3cf611c66e6 100644
--- a/spec/support/helpers/feature_flag_helpers.rb
+++ b/spec/support/helpers/feature_flag_helpers.rb
@@ -2,22 +2,32 @@
module FeatureFlagHelpers
def create_flag(project, name, active = true, description: nil, version: Operations::FeatureFlag.versions['new_version_flag'])
- create(:operations_feature_flag, name: name, active: active, version: version,
- description: description, project: project)
+ create(
+ :operations_feature_flag,
+ name: name,
+ active: active,
+ version: version,
+ description: description,
+ project: project
+ )
end
def create_scope(feature_flag, environment_scope, active = true, strategies = [{ name: "default", parameters: {} }])
- create(:operations_feature_flag_scope,
+ create(
+ :operations_feature_flag_scope,
feature_flag: feature_flag,
environment_scope: environment_scope,
active: active,
- strategies: strategies)
+ strategies: strategies
+ )
end
def create_strategy(feature_flag, name = 'default', parameters = {})
- create(:operations_strategy,
+ create(
+ :operations_strategy,
feature_flag: feature_flag,
- name: name)
+ name: name
+ )
end
def within_feature_flag_row(index)
@@ -95,6 +105,6 @@ module FeatureFlagHelpers
end
def expect_user_to_see_feature_flags_index_page
- expect(page).to have_text('Feature Flags')
+ expect(page).to have_text('Feature flags')
end
end
diff --git a/spec/support/helpers/features/access_token_helpers.rb b/spec/support/helpers/features/access_token_helpers.rb
index f4bdb70c160..bc839642914 100644
--- a/spec/support/helpers/features/access_token_helpers.rb
+++ b/spec/support/helpers/features/access_token_helpers.rb
@@ -1,18 +1,15 @@
# frozen_string_literal: true
-module Spec
- module Support
- module Helpers
- module AccessTokenHelpers
- def active_access_tokens
- find("[data-testid='active-tokens']")
- end
- def created_access_token
- within('[data-testid=access-token-section]') do
- find('[data-testid=toggle-visibility-button]').click
- find_field('new-access-token').value
- end
- end
+module Features
+ module AccessTokenHelpers
+ def active_access_tokens
+ find("[data-testid='active-tokens']")
+ end
+
+ def created_access_token
+ within('[data-testid=access-token-section]') do
+ find('[data-testid=toggle-visibility-button]').click
+ find_field('new-access-token').value
end
end
end
diff --git a/spec/support/helpers/features/admin_users_helpers.rb b/spec/support/helpers/features/admin_users_helpers.rb
index 99b19eedcff..9a87ccf113a 100644
--- a/spec/support/helpers/features/admin_users_helpers.rb
+++ b/spec/support/helpers/features/admin_users_helpers.rb
@@ -1,24 +1,18 @@
# frozen_string_literal: true
-module Spec
- module Support
- module Helpers
- module Features
- module AdminUsersHelpers
- def click_user_dropdown_toggle(user_id)
- page.within("[data-testid='user-actions-#{user_id}']") do
- find("[data-testid='dropdown-toggle']").click
- end
- end
+module Features
+ module AdminUsersHelpers
+ def click_user_dropdown_toggle(user_id)
+ page.within("[data-testid='user-actions-#{user_id}']") do
+ find("[data-testid='dropdown-toggle']").click
+ end
+ end
- def click_action_in_user_dropdown(user_id, action)
- click_user_dropdown_toggle(user_id)
+ def click_action_in_user_dropdown(user_id, action)
+ click_user_dropdown_toggle(user_id)
- within find("[data-testid='user-actions-#{user_id}']") do
- find('li button', exact_text: action).click
- end
- end
- end
+ within find("[data-testid='user-actions-#{user_id}']") do
+ find('li button', exact_text: action).click
end
end
end
diff --git a/spec/support/helpers/features/blob_spec_helpers.rb b/spec/support/helpers/features/blob_spec_helpers.rb
index 7ccfc9be7e2..8254e1d76bd 100644
--- a/spec/support/helpers/features/blob_spec_helpers.rb
+++ b/spec/support/helpers/features/blob_spec_helpers.rb
@@ -1,14 +1,16 @@
# frozen_string_literal: true
-# These helpers help you interact within the blobs page and blobs edit page (Single file editor).
-module BlobSpecHelpers
- include ActionView::Helpers::JavaScriptHelper
+module Features
+ # These helpers help you interact within the blobs page and blobs edit page (Single file editor).
+ module BlobSpecHelpers
+ include ActionView::Helpers::JavaScriptHelper
- def set_default_button(type)
- evaluate_script("localStorage.setItem('gl-web-ide-button-selected', '#{type}')")
- end
+ def set_default_button(type)
+ evaluate_script("localStorage.setItem('gl-web-ide-button-selected', '#{type}')")
+ end
- def unset_default_button
- set_default_button('')
+ def unset_default_button
+ set_default_button('')
+ end
end
end
diff --git a/spec/support/helpers/features/branches_helpers.rb b/spec/support/helpers/features/branches_helpers.rb
index dc4fa448167..9fb6236d052 100644
--- a/spec/support/helpers/features/branches_helpers.rb
+++ b/spec/support/helpers/features/branches_helpers.rb
@@ -4,31 +4,28 @@
#
# Usage:
# describe "..." do
-# include Spec::Support::Helpers::Features::BranchesHelpers
+# include Features::BranchesHelpers
# ...
#
# create_branch("feature")
# select_branch("master")
#
-module Spec
- module Support
- module Helpers
- module Features
- module BranchesHelpers
- def create_branch(branch_name, source_branch_name = "master")
- fill_in("branch_name", with: branch_name)
- select_branch(source_branch_name)
- click_button("Create branch")
- end
+module Features
+ module BranchesHelpers
+ include ListboxHelpers
- def select_branch(branch_name)
- wait_for_requests
+ def create_branch(branch_name, source_branch_name = "master")
+ fill_in("branch_name", with: branch_name)
+ select_branch(source_branch_name)
+ click_button("Create branch")
+ end
+
+ def select_branch(branch_name)
+ wait_for_requests
- click_button branch_name
- send_keys branch_name
- end
- end
- end
+ click_button branch_name
+ send_keys branch_name
+ select_listbox_item(branch_name)
end
end
end
diff --git a/spec/support/helpers/features/canonical_link_helpers.rb b/spec/support/helpers/features/canonical_link_helpers.rb
index da3a28f1cb2..6ef934a924b 100644
--- a/spec/support/helpers/features/canonical_link_helpers.rb
+++ b/spec/support/helpers/features/canonical_link_helpers.rb
@@ -4,25 +4,19 @@
#
# Usage:
# describe "..." do
-# include Spec::Support::Helpers::Features::CanonicalLinkHelpers
+# include Features::CanonicalLinkHelpers
# ...
#
# expect(page).to have_canonical_link(url)
#
-module Spec
- module Support
- module Helpers
- module Features
- module CanonicalLinkHelpers
- def have_canonical_link(url)
- have_xpath("//link[@rel=\"canonical\" and @href=\"#{url}\"]", visible: false)
- end
+module Features
+ module CanonicalLinkHelpers
+ def have_canonical_link(url)
+ have_xpath("//link[@rel=\"canonical\" and @href=\"#{url}\"]", visible: false)
+ end
- def have_any_canonical_links
- have_xpath('//link[@rel="canonical"]', visible: false)
- end
- end
- end
+ def have_any_canonical_links
+ have_xpath('//link[@rel="canonical"]', visible: false)
end
end
end
diff --git a/spec/support/helpers/features/invite_members_modal_helper.rb b/spec/support/helpers/features/invite_members_modal_helper.rb
deleted file mode 100644
index 47cbd6b5208..00000000000
--- a/spec/support/helpers/features/invite_members_modal_helper.rb
+++ /dev/null
@@ -1,154 +0,0 @@
-# frozen_string_literal: true
-
-module Spec
- module Support
- module Helpers
- module Features
- module InviteMembersModalHelper
- def invite_member(names, role: 'Guest', expires_at: nil)
- click_on 'Invite members'
-
- page.within invite_modal_selector do
- select_members(names)
- choose_options(role, expires_at)
- submit_invites
- end
-
- wait_for_requests
- end
-
- def invite_member_by_email(role)
- click_on _('Invite members')
-
- page.within invite_modal_selector do
- choose_options(role, nil)
- find(member_dropdown_selector).set('new_email@gitlab.com')
- wait_for_requests
-
- find('.dropdown-item', text: 'Invite "new_email@gitlab.com" by email').click
-
- submit_invites
-
- wait_for_requests
- end
- end
-
- def input_invites(names)
- click_on 'Invite members'
-
- page.within invite_modal_selector do
- select_members(names)
- end
- end
-
- def select_members(names)
- Array.wrap(names).each do |name|
- find(member_dropdown_selector).set(name)
-
- wait_for_requests
- click_button name
- end
- end
-
- def invite_group(name, role: 'Guest', expires_at: nil)
- click_on 'Invite a group'
-
- click_on 'Select a group'
- wait_for_requests
- click_button name
- choose_options(role, expires_at)
-
- submit_invites
- end
-
- def submit_invites
- click_button 'Invite'
- end
-
- def choose_options(role, expires_at)
- select role, from: 'Select a role'
- fill_in 'YYYY-MM-DD', with: expires_at.strftime('%Y-%m-%d') if expires_at
- end
-
- def click_groups_tab
- expect(page).to have_link 'Groups'
- click_link "Groups"
- end
-
- def group_dropdown_selector
- '[data-testid="group-select-dropdown"]'
- end
-
- def member_dropdown_selector
- '[data-testid="members-token-select-input"]'
- end
-
- def invite_modal_selector
- '[data-testid="invite-modal"]'
- end
-
- def member_token_error_selector(id)
- "[data-testid='error-icon-#{id}']"
- end
-
- def member_token_avatar_selector
- "[data-testid='token-avatar']"
- end
-
- def member_token_selector(id)
- "[data-token-id='#{id}']"
- end
-
- def more_invite_errors_button_selector
- "[data-testid='accordion-button']"
- end
-
- def limited_invite_error_selector
- "[data-testid='errors-limited-item']"
- end
-
- def expanded_invite_error_selector
- "[data-testid='errors-expanded-item']"
- end
-
- def remove_token(id)
- page.within member_token_selector(id) do
- find('[data-testid="close-icon"]').click
- end
- end
-
- def expect_to_have_successful_invite_indicator(page, user)
- expect(page).to have_selector("#{member_token_selector(user.id)} .gl-bg-green-100")
- expect(page).not_to have_text("#{user.name}: ")
- end
-
- def expect_to_have_invalid_invite_indicator(page, user, message: true)
- expect(page).to have_selector("#{member_token_selector(user.id)} .gl-bg-red-100")
- expect(page).to have_selector(member_token_error_selector(user.id))
- expect(page).to have_text("#{user.name}: Access level should be greater than or equal to") if message
- end
-
- def expect_to_have_normal_invite_indicator(page, user)
- expect(page).to have_selector(member_token_selector(user.id))
- expect(page).not_to have_selector("#{member_token_selector(user.id)} .gl-bg-red-100")
- expect(page).not_to have_selector("#{member_token_selector(user.id)} .gl-bg-green-100")
- expect(page).not_to have_text("#{user.name}: ")
- end
-
- def expect_to_have_invite_removed(page, user)
- expect(page).not_to have_selector(member_token_selector(user.id))
- expect(page).not_to have_text("#{user.name}: Access level should be greater than or equal to")
- end
-
- def expect_to_have_group(group)
- expect(page).to have_selector("[entity-id='#{group.id}']")
- end
-
- def expect_not_to_have_group(group)
- expect(page).not_to have_selector("[entity-id='#{group.id}']")
- end
- end
- end
- end
- end
-end
diff --git a/spec/support/helpers/features/invite_members_modal_helpers.rb b/spec/support/helpers/features/invite_members_modal_helpers.rb
new file mode 100644
index 00000000000..75573616686
--- /dev/null
+++ b/spec/support/helpers/features/invite_members_modal_helpers.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+module Features
+ module InviteMembersModalHelpers
+ def invite_member(names, role: 'Guest', expires_at: nil)
+ click_on 'Invite members'
+
+ page.within invite_modal_selector do
+ select_members(names)
+ choose_options(role, expires_at)
+ submit_invites
+ end
+
+ wait_for_requests
+ end
+
+ def invite_member_by_email(role)
+ click_on _('Invite members')
+
+ page.within invite_modal_selector do
+ choose_options(role, nil)
+ find(member_dropdown_selector).set('new_email@gitlab.com')
+ wait_for_requests
+
+ find('.dropdown-item', text: 'Invite "new_email@gitlab.com" by email').click
+
+ submit_invites
+
+ wait_for_requests
+ end
+ end
+
+ def input_invites(names)
+ click_on 'Invite members'
+
+ page.within invite_modal_selector do
+ select_members(names)
+ end
+ end
+
+ def select_members(names)
+ Array.wrap(names).each do |name|
+ find(member_dropdown_selector).set(name)
+
+ wait_for_requests
+ click_button name
+ end
+ end
+
+ def invite_group(name, role: 'Guest', expires_at: nil)
+ click_on 'Invite a group'
+
+ click_on 'Select a group'
+ wait_for_requests
+ click_button name
+ choose_options(role, expires_at)
+
+ submit_invites
+ end
+
+ def submit_invites
+ click_button 'Invite'
+ end
+
+ def choose_options(role, expires_at)
+ select role, from: 'Select a role'
+ fill_in 'YYYY-MM-DD', with: expires_at.strftime('%Y-%m-%d') if expires_at
+ end
+
+ def click_groups_tab
+ expect(page).to have_link 'Groups'
+ click_link "Groups"
+ end
+
+ def group_dropdown_selector
+ '[data-testid="group-select-dropdown"]'
+ end
+
+ def member_dropdown_selector
+ '[data-testid="members-token-select-input"]'
+ end
+
+ def invite_modal_selector
+ '[data-testid="invite-modal"]'
+ end
+
+ def member_token_error_selector(id)
+ "[data-testid='error-icon-#{id}']"
+ end
+
+ def member_token_avatar_selector
+ "[data-testid='token-avatar']"
+ end
+
+ def member_token_selector(id)
+ "[data-token-id='#{id}']"
+ end
+
+ def more_invite_errors_button_selector
+ "[data-testid='accordion-button']"
+ end
+
+ def limited_invite_error_selector
+ "[data-testid='errors-limited-item']"
+ end
+
+ def expanded_invite_error_selector
+ "[data-testid='errors-expanded-item']"
+ end
+
+ def remove_token(id)
+ page.within member_token_selector(id) do
+ find('[data-testid="close-icon"]').click
+ end
+ end
+
+ def expect_to_have_successful_invite_indicator(page, user)
+ expect(page).to have_selector("#{member_token_selector(user.id)} .gl-bg-green-100")
+ expect(page).not_to have_text("#{user.name}: ")
+ end
+
+ def expect_to_have_invalid_invite_indicator(page, user, message: true)
+ expect(page).to have_selector("#{member_token_selector(user.id)} .gl-bg-red-100")
+ expect(page).to have_selector(member_token_error_selector(user.id))
+ expect(page).to have_text("#{user.name}: Access level should be greater than or equal to") if message
+ end
+
+ def expect_to_have_normal_invite_indicator(page, user)
+ expect(page).to have_selector(member_token_selector(user.id))
+ expect(page).not_to have_selector("#{member_token_selector(user.id)} .gl-bg-red-100")
+ expect(page).not_to have_selector("#{member_token_selector(user.id)} .gl-bg-green-100")
+ expect(page).not_to have_text("#{user.name}: ")
+ end
+
+ def expect_to_have_invite_removed(page, user)
+ expect(page).not_to have_selector(member_token_selector(user.id))
+ expect(page).not_to have_text("#{user.name}: Access level should be greater than or equal to")
+ end
+
+ def expect_to_have_group(group)
+ expect(page).to have_selector("[entity-id='#{group.id}']")
+ end
+
+ def expect_not_to_have_group(group)
+ expect(page).not_to have_selector("[entity-id='#{group.id}']")
+ end
+ end
+end
diff --git a/spec/support/helpers/features/iteration_helpers.rb b/spec/support/helpers/features/iteration_helpers.rb
index 8e1d252f55f..fab373a547f 100644
--- a/spec/support/helpers/features/iteration_helpers.rb
+++ b/spec/support/helpers/features/iteration_helpers.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
-module IterationHelpers
- def iteration_period(iteration)
- "#{iteration.start_date.to_s(:medium)} - #{iteration.due_date.to_s(:medium)}"
+
+module Features
+ module IterationHelpers
+ def iteration_period(iteration)
+ "#{iteration.start_date.to_s(:medium)} - #{iteration.due_date.to_s(:medium)}"
+ end
end
end
diff --git a/spec/support/helpers/features/list_rows_helpers.rb b/spec/support/helpers/features/list_rows_helpers.rb
deleted file mode 100644
index 0626415361c..00000000000
--- a/spec/support/helpers/features/list_rows_helpers.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-# These helpers allow you to access rows in the list
-#
-# Usage:
-# describe "..." do
-# include Spec::Support::Helpers::Features::ListRowsHelpers
-# ...
-#
-# expect(first_row.text).to include("John Doe")
-# expect(second_row.text).to include("John Smith")
-#
-module Spec
- module Support
- module Helpers
- module Features
- module ListRowsHelpers
- def first_row
- page.all('ul.content-list > li')[0]
- end
-
- def second_row
- page.all('ul.content-list > li')[1]
- end
- end
- end
- end
- end
-end
diff --git a/spec/support/helpers/features/members_helpers.rb b/spec/support/helpers/features/members_helpers.rb
index 2d3f0902a3c..9882767cecf 100644
--- a/spec/support/helpers/features/members_helpers.rb
+++ b/spec/support/helpers/features/members_helpers.rb
@@ -1,78 +1,72 @@
# frozen_string_literal: true
-module Spec
- module Support
- module Helpers
- module Features
- module MembersHelpers
- def members_table
- page.find('[data-testid="members-table"]')
- end
+module Features
+ module MembersHelpers
+ def members_table
+ page.find('[data-testid="members-table"]')
+ end
- def all_rows
- page.within(members_table) do
- page.all('tbody > tr')
- end
- end
+ def all_rows
+ page.within(members_table) do
+ page.all('tbody > tr')
+ end
+ end
- def first_row
- all_rows[0]
- end
+ def first_row
+ all_rows[0]
+ end
- def second_row
- all_rows[1]
- end
+ def second_row
+ all_rows[1]
+ end
- def third_row
- all_rows[2]
- end
+ def third_row
+ all_rows[2]
+ end
- def find_row(name)
- page.within(members_table) do
- page.find('tbody > tr', text: name)
- end
- end
+ def find_row(name)
+ page.within(members_table) do
+ page.find('tbody > tr', text: name)
+ end
+ end
- def find_member_row(user)
- find_row(user.name)
- end
+ def find_member_row(user)
+ find_row(user.name)
+ end
- def find_username_row(user)
- find_row(user.username)
- end
+ def find_username_row(user)
+ find_row(user.username)
+ end
- def find_invited_member_row(email)
- find_row(email)
- end
+ def find_invited_member_row(email)
+ find_row(email)
+ end
- def find_group_row(group)
- find_row(group.full_name)
- end
+ def find_group_row(group)
+ find_row(group.full_name)
+ end
- def fill_in_filtered_search(label, with:)
- page.within '[data-testid="members-filtered-search-bar"]' do
- find_field(label).click
- find('input').native.send_keys(with)
- click_button 'Search'
- end
- end
+ def fill_in_filtered_search(label, with:)
+ page.within '[data-testid="members-filtered-search-bar"]' do
+ find_field(label).click
+ find('input').native.send_keys(with)
+ click_button 'Search'
+ end
+ end
- def user_action_dropdown
- '[data-testid="user-action-dropdown"]'
- end
+ def user_action_dropdown
+ '[data-testid="user-action-dropdown"]'
+ end
- def show_actions
- within user_action_dropdown do
- find('button').click
- end
- end
+ def show_actions
+ within user_action_dropdown do
+ find('button').click
+ end
+ end
- def show_actions_for_username(user)
- within find_username_row(user) do
- show_actions
- end
- end
- end
+ def show_actions_for_username(user)
+ within find_username_row(user) do
+ show_actions
end
end
end
diff --git a/spec/support/helpers/features/merge_request_helpers.rb b/spec/support/helpers/features/merge_request_helpers.rb
index 53896e1fe12..260a55487ea 100644
--- a/spec/support/helpers/features/merge_request_helpers.rb
+++ b/spec/support/helpers/features/merge_request_helpers.rb
@@ -1,25 +1,19 @@
# frozen_string_literal: true
-module Spec
- module Support
- module Helpers
- module Features
- module MergeRequestHelpers
- def preload_view_requirements(merge_request, note)
- # This will load the status fields of the author of the note and merge request
- # to avoid queries when rendering the view being tested.
- #
- merge_request.author.status
- note.author.status
- end
+module Features
+ module MergeRequestHelpers
+ def preload_view_requirements(merge_request, note)
+ # This will load the status fields of the author of the note and merge request
+ # to avoid queries when rendering the view being tested.
+ #
+ merge_request.author.status
+ note.author.status
+ end
- def serialize_issuable_sidebar(user, project, merge_request)
- MergeRequestSerializer
- .new(current_user: user, project: project)
- .represent(merge_request, serializer: 'sidebar')
- end
- end
- end
+ def serialize_issuable_sidebar(user, project, merge_request)
+ MergeRequestSerializer
+ .new(current_user: user, project: project)
+ .represent(merge_request, serializer: 'sidebar')
end
end
end
diff --git a/spec/support/helpers/features/mirroring_helpers.rb b/spec/support/helpers/features/mirroring_helpers.rb
new file mode 100644
index 00000000000..0c3006cd1d1
--- /dev/null
+++ b/spec/support/helpers/features/mirroring_helpers.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# These helpers allow you to set up mirroring.
+#
+# Usage:
+# describe "..." do
+# include Features::MirroringHelpers
+# ...
+#
+# fill_and_wait_for_mirror_url_javascript("url", "ssh://user@localhost/project.git")
+# wait_for_mirror_field_javascript("protected", "0")
+#
+module Features
+ module MirroringHelpers
+ # input_identifier - identifier of the input field, passed to `fill_in` (can be an ID or a label).
+ # url - the URL to fill the input field with.
+ def fill_and_wait_for_mirror_url_javascript(input_identifier, url)
+ fill_in input_identifier, with: url
+ wait_for_mirror_field_javascript('url', url)
+ end
+
+ # attribute - can be `url` or `protected`. It's used in the `.js-mirror-<field>-hidden` selector.
+ # expected_value - the expected value of the hidden field.
+ def wait_for_mirror_field_javascript(attribute, expected_value)
+ expect(page).to have_css(".js-mirror-#{attribute}-hidden[value=\"#{expected_value}\"]", visible: :hidden)
+ end
+ end
+end
diff --git a/spec/support/helpers/features/notes_helpers.rb b/spec/support/helpers/features/notes_helpers.rb
index f8252254531..7973d541f9c 100644
--- a/spec/support/helpers/features/notes_helpers.rb
+++ b/spec/support/helpers/features/notes_helpers.rb
@@ -4,53 +4,47 @@
#
# Usage:
# describe "..." do
-# include Spec::Support::Helpers::Features::NotesHelpers
+# include Features::NotesHelpers
# ...
#
# add_note("Hello world!")
#
-module Spec
- module Support
- module Helpers
- module Features
- module NotesHelpers
- def add_note(text)
- perform_enqueued_jobs do
- page.within(".js-main-target-form") do
- fill_in("note[note]", with: text)
- find(".js-comment-submit-button").click
- end
- end
-
- wait_for_requests
- end
-
- def edit_note(note_text_to_edit, new_note_text)
- page.within('#notes-list li.note', text: note_text_to_edit) do
- find('.js-note-edit').click
- fill_in('note[note]', with: new_note_text)
- find('.js-comment-button').click
- end
-
- wait_for_requests
- end
-
- def preview_note(text)
- page.within('.js-main-target-form') do
- filled_text = fill_in('note[note]', with: text)
-
- # Wait for quick action prompt to load and then dismiss it with ESC
- # because it may block the Preview button
- wait_for_requests
- filled_text.send_keys(:escape)
-
- click_on('Preview')
-
- yield if block_given?
- end
- end
+module Features
+ module NotesHelpers
+ def add_note(text)
+ perform_enqueued_jobs do
+ page.within(".js-main-target-form") do
+ fill_in("note[note]", with: text)
+ find(".js-comment-submit-button").click
end
end
+
+ wait_for_requests
+ end
+
+ def edit_note(note_text_to_edit, new_note_text)
+ page.within('#notes-list li.note', text: note_text_to_edit) do
+ find('.js-note-edit').click
+ fill_in('note[note]', with: new_note_text)
+ find('.js-comment-button').click
+ end
+
+ wait_for_requests
+ end
+
+ def preview_note(text)
+ page.within('.js-main-target-form') do
+ filled_text = fill_in('note[note]', with: text)
+
+ # Wait for quick action prompt to load and then dismiss it with ESC
+ # because it may block the Preview button
+ wait_for_requests
+ filled_text.send_keys(:escape)
+
+ click_button("Preview")
+
+ yield if block_given?
+ end
end
end
end
diff --git a/spec/support/helpers/features/releases_helpers.rb b/spec/support/helpers/features/releases_helpers.rb
index 545e12341ef..d5846aad15d 100644
--- a/spec/support/helpers/features/releases_helpers.rb
+++ b/spec/support/helpers/features/releases_helpers.rb
@@ -4,80 +4,83 @@
#
# Usage:
# describe "..." do
-# include Spec::Support::Helpers::Features::ReleasesHelpers
+# include Features::ReleasesHelpers
# ...
#
# fill_tag_name("v1.0")
# select_create_from("my-feature-branch")
#
-module Spec
- module Support
- module Helpers
- module Features
- module ReleasesHelpers
- include ListboxHelpers
+module Features
+ module ReleasesHelpers
+ include ListboxHelpers
- def select_new_tag_name(tag_name)
- page.within '[data-testid="tag-name-field"]' do
- find('button').click
- wait_for_all_requests
+ def select_new_tag_name(tag_name)
+ open_tag_popover
- find('input[aria-label="Search or create tag"]').set(tag_name)
- wait_for_all_requests
+ page.within '[data-testid="tag-name-search"]' do
+ find('input[type="search"]').set(tag_name)
+ wait_for_all_requests
- click_button("Create tag #{tag_name}")
- click_button tag_name
- end
- end
-
- def select_create_from(branch_name)
- page.within '[data-testid="create-from-field"]' do
- find('button').click
+ click_button("Create tag #{tag_name}")
+ end
+ end
- wait_for_all_requests
+ def select_create_from(branch_name)
+ open_tag_popover
- find('input[aria-label="Search branches, tags, and commits"]').set(branch_name)
+ page.within '[data-testid="create-from-field"]' do
+ find('.ref-selector button').click
- wait_for_all_requests
+ wait_for_all_requests
- select_listbox_item(branch_name.to_s, exact_text: true)
- end
- end
+ find('input[aria-label="Search branches, tags, and commits"]').set(branch_name)
- def fill_release_title(release_title)
- fill_in('Release title', with: release_title)
- end
+ wait_for_all_requests
- def select_milestone(milestone_title)
- page.within '[data-testid="milestones-field"]' do
- find('button').click
+ select_listbox_item(branch_name.to_s, exact_text: true)
- wait_for_all_requests
+ click_button _('Save')
+ end
+ end
- find('input[aria-label="Search Milestones"]').set(milestone_title)
+ def fill_release_title(release_title)
+ fill_in('Release title', with: release_title)
+ end
- wait_for_all_requests
+ def select_milestone(milestone_title)
+ page.within '[data-testid="milestones-field"]' do
+ find('button').click
- find('button', text: milestone_title, match: :first).click
- end
- end
+ wait_for_all_requests
- def fill_release_notes(release_notes)
- fill_in('Release notes', with: release_notes)
- end
+ find('input[aria-label="Search Milestones"]').set(milestone_title)
- def fill_asset_link(link)
- all('input[name="asset-url"]').last.set(link[:url])
- all('input[name="asset-link-name"]').last.set(link[:title])
- all('select[name="asset-type"]').last.find("option[value=\"#{link[:type]}\"").select_option
- end
+ wait_for_all_requests
- # Click "Add another link" and tab back to the beginning of the new row
- def add_another_asset_link
- click_button('Add another link')
- end
- end
+ find('button', text: milestone_title, match: :first).click
end
end
+
+ def fill_release_notes(release_notes)
+ fill_in('Release notes', with: release_notes)
+ end
+
+ def fill_asset_link(link)
+ all('input[name="asset-url"]').last.set(link[:url])
+ all('input[name="asset-link-name"]').last.set(link[:title])
+ all('select[name="asset-type"]').last.find("option[value=\"#{link[:type]}\"").select_option
+ end
+
+ # Click "Add another link" and tab back to the beginning of the new row
+ def add_another_asset_link
+ click_button('Add another link')
+ end
+
+ def open_tag_popover(name = s_('Release|Search or create tag name'))
+ return if page.has_css? '.release-tag-selector'
+
+ click_button name
+ wait_for_all_requests
+ end
end
end
diff --git a/spec/support/helpers/features/responsive_table_helpers.rb b/spec/support/helpers/features/responsive_table_helpers.rb
index 7a175219fe9..980f09b7eea 100644
--- a/spec/support/helpers/features/responsive_table_helpers.rb
+++ b/spec/support/helpers/features/responsive_table_helpers.rb
@@ -3,7 +3,7 @@
#
# Usage:
# describe "..." do
-# include Spec::Support::Helpers::Features::ResponsiveTableHelpers
+# include Features::ResponsiveTableHelpers
# ...
#
# expect(first_row.text).to include("John Doe")
@@ -13,20 +13,14 @@
# index starts at 1 as index 0 is expected to be the table header
#
#
-module Spec
- module Support
- module Helpers
- module Features
- module ResponsiveTableHelpers
- def first_row
- page.all('.gl-responsive-table-row')[1]
- end
+module Features
+ module ResponsiveTableHelpers
+ def first_row
+ page.all('.gl-responsive-table-row')[1]
+ end
- def second_row
- page.all('.gl-responsive-table-row')[2]
- end
- end
- end
+ def second_row
+ page.all('.gl-responsive-table-row')[2]
end
end
end
diff --git a/spec/support/helpers/features/runners_helpers.rb b/spec/support/helpers/features/runners_helpers.rb
index c5d26108953..0504e883b82 100644
--- a/spec/support/helpers/features/runners_helpers.rb
+++ b/spec/support/helpers/features/runners_helpers.rb
@@ -1,68 +1,62 @@
# frozen_string_literal: true
-module Spec
- module Support
- module Helpers
- module Features
- module RunnersHelpers
- def within_runner_row(runner_id)
- within "[data-testid='runner-row-#{runner_id}']" do
- yield
- end
- end
-
- def search_bar_selector
- '[data-testid="runners-filtered-search"]'
- end
+module Features
+ module RunnersHelpers
+ def within_runner_row(runner_id)
+ within "[data-testid='runner-row-#{runner_id}']" do
+ yield
+ end
+ end
- # The filters must be clicked first to be able to receive events
- # See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1493
- def focus_filtered_search
- page.within(search_bar_selector) do
- page.find('.gl-filtered-search-term-token').click
- end
- end
+ def search_bar_selector
+ '[data-testid="runners-filtered-search"]'
+ end
- def input_filtered_search_keys(search_term)
- focus_filtered_search
+ # The filters must be clicked first to be able to receive events
+ # See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1493
+ def focus_filtered_search
+ page.within(search_bar_selector) do
+ page.find('.gl-filtered-search-term-token').click
+ end
+ end
- page.within(search_bar_selector) do
- page.find('input').send_keys(search_term)
- click_on 'Search'
- end
+ def input_filtered_search_keys(search_term)
+ focus_filtered_search
- wait_for_requests
- end
+ page.within(search_bar_selector) do
+ page.find('input').send_keys(search_term)
+ click_on 'Search'
+ end
- def open_filtered_search_suggestions(filter)
- focus_filtered_search
+ wait_for_requests
+ end
- page.within(search_bar_selector) do
- click_on filter
- end
+ def open_filtered_search_suggestions(filter)
+ focus_filtered_search
- wait_for_requests
- end
+ page.within(search_bar_selector) do
+ click_on filter
+ end
- def input_filtered_search_filter_is_only(filter, value)
- focus_filtered_search
+ wait_for_requests
+ end
- page.within(search_bar_selector) do
- click_on filter
+ def input_filtered_search_filter_is_only(filter, value)
+ focus_filtered_search
- # For OPERATORS_IS, clicking the filter
- # immediately preselects "=" operator
+ page.within(search_bar_selector) do
+ click_on filter
- page.find('input').send_keys(value)
- page.find('input').send_keys(:enter)
+ # For OPERATORS_IS, clicking the filter
+ # immediately preselects "=" operator
- click_on 'Search'
- end
+ page.find('input').send_keys(value)
+ page.find('input').send_keys(:enter)
- wait_for_requests
- end
- end
+ click_on 'Search'
end
+
+ wait_for_requests
end
end
end
diff --git a/spec/support/helpers/features/snippet_helpers.rb b/spec/support/helpers/features/snippet_helpers.rb
deleted file mode 100644
index 3e32b0e4c67..00000000000
--- a/spec/support/helpers/features/snippet_helpers.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-# frozen_string_literal: true
-
-# These helpers help you interact within the Source Editor (single-file editor, snippets, etc.).
-#
-
-require Rails.root.join("spec/support/helpers/features/source_editor_spec_helpers.rb")
-
-module Spec
- module Support
- module Helpers
- module Features
- module SnippetSpecHelpers
- include ActionView::Helpers::JavaScriptHelper
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
-
- def snippet_description_locator
- 'snippet-description'
- end
-
- def snippet_blob_path_locator
- 'snippet_file_name'
- end
-
- def snippet_description_view_selector
- '.snippet-header .snippet-description'
- end
-
- def snippet_description_field_collapsed
- find('.js-description-input').find('input,textarea')
- end
-
- def snippet_get_first_blob_path
- page.find_field('snippet_file_name', match: :first).value
- end
-
- def snippet_get_first_blob_value
- page.find('.gl-source-editor', match: :first)
- end
-
- def snippet_description_value
- page.find_field(snippet_description_locator).value
- end
-
- def snippet_fill_in_visibility(text)
- page.find('#visibility-level-setting').choose(text)
- end
-
- def snippet_fill_in_title(value)
- fill_in 'snippet-title', with: value
- end
-
- def snippet_fill_in_description(value)
- # Click placeholder first to expand full description field
- snippet_description_field_collapsed.click
- fill_in snippet_description_locator, with: value
- end
-
- def snippet_fill_in_content(value)
- page.within('.gl-source-editor') do
- el = find('.inputarea')
- el.send_keys value
- end
- end
-
- def snippet_fill_in_file_name(value)
- fill_in(snippet_blob_path_locator, match: :first, with: value)
- end
-
- def snippet_fill_in_form(title: nil, content: nil, file_name: nil, description: nil, visibility: nil)
- if content
- snippet_fill_in_content(content)
- # It takes some time after sending keys for the vue component to
- # update so let Capybara wait for the content before proceeding
- expect(page).to have_content(content)
- end
-
- snippet_fill_in_title(title) if title
-
- snippet_fill_in_description(description) if description
-
- snippet_fill_in_file_name(file_name) if file_name
-
- snippet_fill_in_visibility(visibility) if visibility
- end
- end
- end
- end
- end
-end
diff --git a/spec/support/helpers/features/snippet_spec_helpers.rb b/spec/support/helpers/features/snippet_spec_helpers.rb
new file mode 100644
index 00000000000..19393f6e438
--- /dev/null
+++ b/spec/support/helpers/features/snippet_spec_helpers.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+# These helpers help you interact within the Source Editor (single-file editor, snippets, etc.).
+#
+
+require Rails.root.join("spec/support/helpers/features/source_editor_spec_helpers.rb")
+
+module Features
+ module SnippetSpecHelpers
+ include ActionView::Helpers::JavaScriptHelper
+ include Features::SourceEditorSpecHelpers
+
+ def snippet_description_locator
+ 'snippet-description'
+ end
+
+ def snippet_blob_path_locator
+ 'snippet_file_name'
+ end
+
+ def snippet_description_view_selector
+ '.snippet-header .snippet-description'
+ end
+
+ def snippet_description_field_collapsed
+ find('.js-description-input').find('input,textarea')
+ end
+
+ def snippet_get_first_blob_path
+ page.find_field('snippet_file_name', match: :first).value
+ end
+
+ def snippet_get_first_blob_value
+ page.find('.gl-source-editor', match: :first)
+ end
+
+ def snippet_description_value
+ page.find_field(snippet_description_locator).value
+ end
+
+ def snippet_fill_in_visibility(text)
+ page.find('#visibility-level-setting').choose(text)
+ end
+
+ def snippet_fill_in_title(value)
+ fill_in 'snippet-title', with: value
+ end
+
+ def snippet_fill_in_description(value)
+ # Click placeholder first to expand full description field
+ snippet_description_field_collapsed.click
+ fill_in snippet_description_locator, with: value
+ end
+
+ def snippet_fill_in_content(value)
+ page.within('.gl-source-editor') do
+ el = find('.inputarea')
+ el.send_keys value
+ end
+ end
+
+ def snippet_fill_in_file_name(value)
+ fill_in(snippet_blob_path_locator, match: :first, with: value)
+ end
+
+ def snippet_fill_in_form(title: nil, content: nil, file_name: nil, description: nil, visibility: nil)
+ if content
+ snippet_fill_in_content(content)
+ # It takes some time after sending keys for the vue component to
+ # update so let Capybara wait for the content before proceeding
+ expect(page).to have_content(content)
+ end
+
+ snippet_fill_in_title(title) if title
+
+ snippet_fill_in_description(description) if description
+
+ snippet_fill_in_file_name(file_name) if file_name
+
+ snippet_fill_in_visibility(visibility) if visibility
+ end
+ end
+end
diff --git a/spec/support/helpers/features/sorting_helpers.rb b/spec/support/helpers/features/sorting_helpers.rb
index 504a9b764cf..8dda16af625 100644
--- a/spec/support/helpers/features/sorting_helpers.rb
+++ b/spec/support/helpers/features/sorting_helpers.rb
@@ -4,33 +4,27 @@
#
# Usage:
# describe "..." do
-# include Spec::Support::Helpers::Features::SortingHelpers
+# include Features::SortingHelpers
# ...
#
# sort_by("Last updated")
#
-module Spec
- module Support
- module Helpers
- module Features
- module SortingHelpers
- def sort_by(value)
- find('.filter-dropdown-container .dropdown').click
+module Features
+ module SortingHelpers
+ def sort_by(value)
+ find('.filter-dropdown-container .dropdown').click
- page.within('ul.dropdown-menu.dropdown-menu-right li') do
- click_link(value)
- end
- end
-
- # pajamas_sort_by is used to sort new pajamas dropdowns. When
- # all of the dropdowns are converted, pajamas_sort_by can be renamed to sort_by
- # https://gitlab.com/groups/gitlab-org/-/epics/7551
- def pajamas_sort_by(value)
- find('.filter-dropdown-container .gl-new-dropdown').click
- find('.gl-new-dropdown-item', text: value).click
- end
- end
+ page.within('ul.dropdown-menu.dropdown-menu-right li') do
+ click_link(value)
end
end
+
+ # pajamas_sort_by is used to sort new pajamas dropdowns. When
+ # all of the dropdowns are converted, pajamas_sort_by can be renamed to sort_by
+ # https://gitlab.com/groups/gitlab-org/-/epics/7551
+ def pajamas_sort_by(value)
+ find('.filter-dropdown-container .gl-new-dropdown').click
+ find('.gl-new-dropdown-item', text: value).click
+ end
end
end
diff --git a/spec/support/helpers/features/source_editor_spec_helpers.rb b/spec/support/helpers/features/source_editor_spec_helpers.rb
index f7eb2a52507..e20ded60b01 100644
--- a/spec/support/helpers/features/source_editor_spec_helpers.rb
+++ b/spec/support/helpers/features/source_editor_spec_helpers.rb
@@ -2,24 +2,18 @@
# These helpers help you interact within the Source Editor (single-file editor, snippets, etc.).
#
-module Spec
- module Support
- module Helpers
- module Features
- module SourceEditorSpecHelpers
- include ActionView::Helpers::JavaScriptHelper
+module Features
+ module SourceEditorSpecHelpers
+ include ActionView::Helpers::JavaScriptHelper
- def editor_set_value(value)
- editor = find('.monaco-editor')
- uri = editor['data-uri']
- execute_script("localMonaco.getModel('#{uri}').setValue('#{escape_javascript(value)}')")
+ def editor_set_value(value)
+ editor = find('.monaco-editor')
+ uri = editor['data-uri']
+ execute_script("localMonaco.getModel('#{uri}').setValue('#{escape_javascript(value)}')")
- # We only check that the first line is present because when the content is long,
- # only a part of the text will be rendered in the DOM due to scrolling
- page.has_selector?('.gl-source-editor .view-lines', text: value.lines.first)
- end
- end
- end
+ # We only check that the first line is present because when the content is long,
+ # only a part of the text will be rendered in the DOM due to scrolling
+ page.has_selector?('.gl-source-editor .view-lines', text: value.lines.first)
end
end
end
diff --git a/spec/support/helpers/features/top_nav_spec_helpers.rb b/spec/support/helpers/features/top_nav_spec_helpers.rb
index de495eceabc..ecc05189fb4 100644
--- a/spec/support/helpers/features/top_nav_spec_helpers.rb
+++ b/spec/support/helpers/features/top_nav_spec_helpers.rb
@@ -2,37 +2,31 @@
# These helpers help you interact within the Source Editor (single-file editor, snippets, etc.).
#
-module Spec
- module Support
- module Helpers
- module Features
- module TopNavSpecHelpers
- def open_top_nav
- find('.js-top-nav-dropdown-toggle').click
- end
+module Features
+ module TopNavSpecHelpers
+ def open_top_nav
+ find('.js-top-nav-dropdown-toggle').click
+ end
- def within_top_nav
- within('.js-top-nav-dropdown-menu') do
- yield
- end
- end
+ def within_top_nav
+ within('.js-top-nav-dropdown-menu') do
+ yield
+ end
+ end
- def open_top_nav_projects
- open_top_nav
+ def open_top_nav_projects
+ open_top_nav
- within_top_nav do
- click_button('Projects')
- end
- end
+ within_top_nav do
+ click_button('Projects')
+ end
+ end
- def open_top_nav_groups
- open_top_nav
+ def open_top_nav_groups
+ open_top_nav
- within_top_nav do
- click_button('Groups')
- end
- end
- end
+ within_top_nav do
+ click_button('Groups')
end
end
end
diff --git a/spec/support/helpers/features/two_factor_helpers.rb b/spec/support/helpers/features/two_factor_helpers.rb
index 08a7665201f..e0469091d96 100644
--- a/spec/support/helpers/features/two_factor_helpers.rb
+++ b/spec/support/helpers/features/two_factor_helpers.rb
@@ -4,71 +4,86 @@
#
# Usage:
# describe "..." do
-# include Spec::Support::Helpers::Features::TwoFactorHelpers
+# include Features::TwoFactorHelpers
# ...
#
# manage_two_factor_authentication
#
-module Spec
- module Support
- module Helpers
- module Features
- module TwoFactorHelpers
- def manage_two_factor_authentication
- click_on 'Manage two-factor authentication'
- expect(page).to have_content("Set up new device")
- wait_for_requests
- end
-
- def register_u2f_device(u2f_device = nil, name: 'My device')
- u2f_device ||= FakeU2fDevice.new(page, name)
- u2f_device.respond_to_u2f_registration
- click_on 'Set up new device'
- expect(page).to have_content('Your device was successfully set up')
- fill_in "Pick a name", with: name
- click_on 'Register device'
- u2f_device
- end
+module Features
+ module TwoFactorHelpers
+ def copy_recovery_codes
+ click_on _('Copy codes')
+ click_on _('Proceed')
+ end
- # Registers webauthn device via UI
- def register_webauthn_device(webauthn_device = nil, name: 'My device')
- webauthn_device ||= FakeWebauthnDevice.new(page, name)
- webauthn_device.respond_to_webauthn_registration
- click_on 'Set up new device'
- expect(page).to have_content('Your device was successfully set up')
- fill_in 'Pick a name', with: name
- click_on 'Register device'
- webauthn_device
- end
+ def enable_two_factor_authentication
+ click_on _('Enable two-factor authentication')
+ expect(page).to have_content(_('Set up new device'))
+ wait_for_requests
+ end
- # Adds webauthn device directly via database
- def add_webauthn_device(app_id, user, fake_device = nil, name: 'My device')
- fake_device ||= WebAuthn::FakeClient.new(app_id)
+ def manage_two_factor_authentication
+ click_on 'Manage two-factor authentication'
+ expect(page).to have_content("Set up new device")
+ wait_for_requests
+ end
- options_for_create = WebAuthn::Credential.options_for_create(
- user: { id: user.webauthn_xid, name: user.username },
- authenticator_selection: { user_verification: 'discouraged' },
- rp: { name: 'GitLab' }
- )
- challenge = options_for_create.challenge
+ # Registers webauthn device via UI
+ # Remove after `webauthn_without_totp` feature flag is deleted.
+ def register_webauthn_device(webauthn_device = nil, name: 'My device')
+ webauthn_device ||= FakeWebauthnDevice.new(page, name)
+ webauthn_device.respond_to_webauthn_registration
+ click_on 'Set up new device'
+ expect(page).to have_content('Your device was successfully set up')
+ fill_in 'Pick a name', with: name
+ click_on 'Register device'
+ webauthn_device
+ end
- device_response = fake_device.create(challenge: challenge).to_json # rubocop:disable Rails/SaveBang
- device_registration_params = { device_response: device_response,
- name: name }
+ def webauthn_device_registration(webauthn_device: nil, name: 'My device', password: 'fake')
+ webauthn_device ||= FakeWebauthnDevice.new(page, name)
+ webauthn_device.respond_to_webauthn_registration
+ click_on _('Set up new device')
+ webauthn_fill_form_and_submit(name: name, password: password)
+ webauthn_device
+ end
- Webauthn::RegisterService.new(
- user, device_registration_params, challenge).execute
- FakeWebauthnDevice.new(page, name, fake_device)
- end
+ def webauthn_fill_form_and_submit(name: 'My device', password: 'fake')
+ content = _('Your device was successfully set up! Give it a name and register it with the GitLab server.')
+ expect(page).to have_content(content)
- def assert_fallback_ui(page)
- expect(page).to have_button('Verify code')
- expect(page).to have_css('#user_otp_attempt')
- expect(page).not_to have_link('Sign in via 2FA code')
- expect(page).not_to have_css("#js-authenticate-token-2fa")
- end
- end
+ within '[data-testid="create-webauthn"]' do
+ fill_in _('Device name'), with: name
+ fill_in _('Current password'), with: password
+ click_on _('Register device')
end
end
+
+ # Adds webauthn device directly via database
+ def add_webauthn_device(app_id, user, fake_device = nil, name: 'My device')
+ fake_device ||= WebAuthn::FakeClient.new(app_id)
+
+ options_for_create = WebAuthn::Credential.options_for_create(
+ user: { id: user.webauthn_xid, name: user.username },
+ authenticator_selection: { user_verification: 'discouraged' },
+ rp: { name: 'GitLab' }
+ )
+ challenge = options_for_create.challenge
+
+ device_response = fake_device.create(challenge: challenge).to_json # rubocop:disable Rails/SaveBang
+ device_registration_params = { device_response: device_response,
+ name: name }
+
+ Webauthn::RegisterService.new(
+ user, device_registration_params, challenge).execute
+ FakeWebauthnDevice.new(page, name, fake_device)
+ end
+
+ def assert_fallback_ui(page)
+ expect(page).to have_button('Verify code')
+ expect(page).to have_css('#user_otp_attempt')
+ expect(page).not_to have_link('Sign in via 2FA code')
+ expect(page).not_to have_css("#js-authenticate-token-2fa")
+ end
end
end
diff --git a/spec/support/helpers/features/web_ide_spec_helpers.rb b/spec/support/helpers/features/web_ide_spec_helpers.rb
index 4793c9479fe..c51116b55b2 100644
--- a/spec/support/helpers/features/web_ide_spec_helpers.rb
+++ b/spec/support/helpers/features/web_ide_spec_helpers.rb
@@ -4,119 +4,120 @@
#
# Usage:
# describe "..." do
-# include WebIdeSpecHelpers
+# include Features::WebIdeSpecHelpers
# ...
#
# ide_visit(project)
# ide_commit
-#
-module WebIdeSpecHelpers
- include Spec::Support::Helpers::Features::SourceEditorSpecHelpers
-
- # Open the IDE from anywhere by first visiting the given project's page
- def ide_visit(project)
- visit project_path(project)
-
- ide_visit_from_link
- end
+module Features
+ module WebIdeSpecHelpers
+ include Features::SourceEditorSpecHelpers
- # Open the IDE from the current page by clicking the Web IDE link
- def ide_visit_from_link(link_sel = 'Web IDE')
- new_tab = window_opened_by { click_link(link_sel) }
+ # Open the IDE from anywhere by first visiting the given project's page
+ def ide_visit(project)
+ visit project_path(project)
- switch_to_window new_tab
- end
+ ide_visit_from_link
+ end
- def ide_tree_body
- page.find('.ide-tree-body')
- end
+ # Open the IDE from the current page by clicking the Web IDE link
+ def ide_visit_from_link(link_sel = 'Web IDE')
+ new_tab = window_opened_by { click_link(link_sel) }
- def ide_tree_actions
- page.find('.ide-tree-actions')
- end
+ switch_to_window new_tab
+ end
- def ide_tab_selector(mode)
- ".js-ide-#{mode}-mode"
- end
+ def ide_tree_body
+ page.find('.ide-tree-body')
+ end
- def ide_folder_row_open?(row)
- row.matches_css?('.folder.is-open')
- end
+ def ide_tree_actions
+ page.find('.ide-tree-actions')
+ end
- # Deletes a file by traversing to `path`
- # then clicking the 'Delete' action.
- #
- # - Throws an error if the file is not found
- def ide_delete_file(path)
- container = ide_traverse_to_file(path)
+ def ide_tab_selector(mode)
+ ".js-ide-#{mode}-mode"
+ end
- click_file_action(container, 'Delete')
- end
+ def ide_folder_row_open?(row)
+ row.matches_css?('.folder.is-open')
+ end
- # Opens parent directories until the file at `path`
- # is exposed.
- #
- # - Returns a reference to the file row at `path`
- # - Throws an error if the file is not found
- def ide_traverse_to_file(path)
- paths = path.split('/')
- container = nil
+ # Deletes a file by traversing to `path`
+ # then clicking the 'Delete' action.
+ #
+ # - Throws an error if the file is not found
+ def ide_delete_file(path)
+ container = ide_traverse_to_file(path)
- paths.each_with_index do |path, index|
- ide_open_file_row(container) if container
- container = find_file_child(container, path, level: index)
+ click_file_action(container, 'Delete')
end
- container
- end
+ # Opens parent directories until the file at `path`
+ # is exposed.
+ #
+ # - Returns a reference to the file row at `path`
+ # - Throws an error if the file is not found
+ def ide_traverse_to_file(path)
+ paths = path.split('/')
+ container = nil
+
+ paths.each_with_index do |path, index|
+ ide_open_file_row(container) if container
+ container = find_file_child(container, path, level: index)
+ end
+
+ container
+ end
- def ide_open_file_row(row)
- return if ide_folder_row_open?(row)
+ def ide_open_file_row(row)
+ return if ide_folder_row_open?(row)
- row.click
- end
+ row.click
+ end
- def ide_set_editor_value(value)
- editor_set_value(value)
- end
+ def ide_set_editor_value(value)
+ editor_set_value(value)
+ end
- def ide_commit_tab_selector
- ide_tab_selector('commit')
- end
+ def ide_commit_tab_selector
+ ide_tab_selector('commit')
+ end
- def ide_commit
- find(ide_commit_tab_selector).click
+ def ide_commit
+ find(ide_commit_tab_selector).click
- commit_to_current_branch
- end
+ commit_to_current_branch
+ end
- private
+ private
- def file_row_container(row)
- row ? row.find(:xpath, '..') : ide_tree_body
- end
+ def file_row_container(row)
+ row ? row.find(:xpath, '..') : ide_tree_body
+ end
- def find_file_child(row, name, level: nil)
- container = file_row_container(row)
- container.find(".file-row[data-level=\"#{level}\"]", text: name)
- end
+ def find_file_child(row, name, level: nil)
+ container = file_row_container(row)
+ container.find(".file-row[data-level=\"#{level}\"]", text: name)
+ end
- def click_file_action(row, text)
- row.hover
- dropdown = row.find('.ide-new-btn')
- dropdown.find('button').click
- dropdown.find('button', text: text).click
- end
+ def click_file_action(row, text)
+ row.hover
+ dropdown = row.find('.ide-new-btn')
+ dropdown.find('button').click
+ dropdown.find('button', text: text).click
+ end
- def commit_to_current_branch(option: 'Commit to master branch', message: '')
- within '.multi-file-commit-form' do
- fill_in('commit-message', with: message) if message
+ def commit_to_current_branch(option: 'Commit to master branch', message: '')
+ within '.multi-file-commit-form' do
+ fill_in('commit-message', with: message) if message
- choose(option)
+ choose(option)
- click_button('Commit')
+ click_button('Commit')
- wait_for_requests
+ wait_for_requests
+ end
end
end
end
diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb
index 677cea7b804..60638eb06cd 100644
--- a/spec/support/helpers/filtered_search_helpers.rb
+++ b/spec/support/helpers/filtered_search_helpers.rb
@@ -69,12 +69,6 @@ module FilteredSearchHelpers
filtered_search.send_keys(:enter)
end
- def init_label_search
- filtered_search.set('label:=')
- # This ensures the dropdown is shown
- expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading')
- end
-
def expect_filtered_search_input_empty
expect(find('.filtered-search').value).to eq('')
end
@@ -190,9 +184,9 @@ module FilteredSearchHelpers
##
# For use with gl-filtered-search
- def select_tokens(*args, submit: false)
+ def select_tokens(*args, submit: false, input_text: 'Search')
within '[data-testid="filtered-search-input"]' do
- find_field('Search').click
+ find_field(input_text).click
args.each do |token|
# Move mouse away to prevent invoking tooltips on usernames, which blocks the search input
@@ -219,7 +213,7 @@ module FilteredSearchHelpers
def submit_search_term(value)
click_filtered_search_bar
- send_keys(value, :enter)
+ send_keys(value, :enter, :enter)
end
def click_filtered_search_bar
@@ -230,6 +224,13 @@ module FilteredSearchHelpers
find('.gl-filtered-search-token-segment', text: value).click
end
+ def toggle_sort_direction
+ page.within('.vue-filtered-search-bar-container .sort-dropdown-container') do
+ page.find("button[title^='Sort direction']").click
+ wait_for_requests
+ end
+ end
+
def expect_visible_suggestions_list
expect(page).to have_css('.gl-filtered-search-suggestion-list')
end
diff --git a/spec/support/helpers/fixture_helpers.rb b/spec/support/helpers/fixture_helpers.rb
index 7b3b8ae5f7a..eafdecb2e3d 100644
--- a/spec/support/helpers/fixture_helpers.rb
+++ b/spec/support/helpers/fixture_helpers.rb
@@ -8,6 +8,6 @@ module FixtureHelpers
end
def expand_fixture_path(filename, dir: '')
- File.expand_path(Rails.root.join(dir, 'spec', 'fixtures', filename))
+ File.expand_path(rails_root_join(dir, 'spec', 'fixtures', filename))
end
end
diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb
index 398a2a20f2f..7db9e0aaf09 100644
--- a/spec/support/helpers/gitaly_setup.rb
+++ b/spec/support/helpers/gitaly_setup.rb
@@ -10,7 +10,6 @@ require 'securerandom'
require 'socket'
require 'logger'
require 'fileutils'
-require 'bundler'
require_relative '../../../lib/gitlab/utils'
@@ -50,51 +49,18 @@ module GitalySetup
expand_path('.gitlab_shell_secret')
end
- def gemfile
- File.join(tmp_tests_gitaly_dir, 'ruby', 'Gemfile')
- end
-
- def gemfile_dir
- File.dirname(gemfile)
- end
-
def gitlab_shell_secret_file
File.join(tmp_tests_gitlab_shell_dir, '.gitlab_shell_secret')
end
def env
{
- 'GEM_PATH' => Gem.path.join(':'),
- 'BUNDLER_SETUP' => nil,
- 'BUNDLE_INSTALL_FLAGS' => nil,
- 'BUNDLE_IGNORE_CONFIG' => '1',
- 'BUNDLE_PATH' => bundle_path,
- 'BUNDLE_GEMFILE' => gemfile,
- 'BUNDLE_JOBS' => '4',
- 'BUNDLE_RETRY' => '3',
- 'RUBYOPT' => nil,
-
# Git hooks can't run during tests as the internal API is not running.
'GITALY_TESTING_NO_GIT_HOOKS' => "1",
'GITALY_TESTING_ENABLE_ALL_FEATURE_FLAGS' => "true"
}
end
- def bundle_path
- # Allow the user to override BUNDLE_PATH if they need to
- return ENV['GITALY_TEST_BUNDLE_PATH'] if ENV['GITALY_TEST_BUNDLE_PATH']
-
- if ENV['CI']
- expand_path('vendor/gitaly-ruby')
- else
- explicit_path = Bundler.configured_bundle_path.explicit_path
-
- return unless explicit_path
-
- expand_path(explicit_path)
- end
- end
-
def config_path(service)
case service
when :gitaly
@@ -125,10 +91,6 @@ module GitalySetup
system(env, *cmd, exception: true, chdir: tmp_tests_gitaly_dir)
end
- def install_gitaly_gems
- run_command(%W[make #{tmp_tests_gitaly_dir}/.ruby-bundle], env: env)
- end
-
def build_gitaly
run_command(%w[make all WITH_BUNDLED_GIT=YesPlease], env: env.merge('GIT_VERSION' => nil))
end
@@ -188,20 +150,6 @@ module GitalySetup
end
end
- def check_gitaly_config!
- LOGGER.debug "Checking gitaly-ruby Gemfile...\n"
-
- unless File.exist?(gemfile)
- message = "#{gemfile} does not exist."
- message += "\n\nThis might have happened if the CI artifacts for this build were destroyed." if ENV['CI']
- abort message
- end
-
- LOGGER.debug "Checking gitaly-ruby bundle...\n"
- out = ENV['CI'] ? $stdout : '/dev/null'
- abort 'bundle check failed' unless system(env, 'bundle', 'check', out: out, chdir: gemfile_dir)
- end
-
def connect_proc(toml)
# This code needs to work in an environment where we cannot use bundler,
# so we cannot easily use the toml-rb gem. This ad-hoc parser should be
@@ -343,8 +291,6 @@ module GitalySetup
end
def spawn_gitaly(toml = nil)
- check_gitaly_config!
-
pids = []
if toml
diff --git a/spec/support/helpers/google_api/cloud_platform_helpers.rb b/spec/support/helpers/google_api/cloud_platform_helpers.rb
new file mode 100644
index 00000000000..3d4ffe88da9
--- /dev/null
+++ b/spec/support/helpers/google_api/cloud_platform_helpers.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+module GoogleApi
+ module CloudPlatformHelpers
+ def stub_google_api_validate_token
+ request.session[GoogleApi::CloudPlatform::Client.session_key_for_token] = 'token'
+ request.session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = 1.hour.since.to_i.to_s
+ end
+
+ def stub_google_api_expired_token
+ request.session[GoogleApi::CloudPlatform::Client.session_key_for_token] = 'token'
+ request.session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = 1.hour.ago.to_i.to_s
+ end
+
+ def stub_cloud_platform_projects_list(options)
+ WebMock.stub_request(:get, cloud_platform_projects_list_url)
+ .to_return(cloud_platform_response(cloud_platform_projects_body(options)))
+ end
+
+ def stub_cloud_platform_projects_get_billing_info(project_id, billing_enabled)
+ WebMock.stub_request(:get, cloud_platform_projects_get_billing_info_url(project_id))
+ .to_return(cloud_platform_response(cloud_platform_projects_billing_info_body(project_id, billing_enabled)))
+ end
+
+ def stub_cloud_platform_get_zone_cluster(project_id, zone, cluster_id, options = {})
+ WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id))
+ .to_return(cloud_platform_response(cloud_platform_cluster_body(options)))
+ end
+
+ def stub_cloud_platform_get_zone_cluster_error(project_id, zone, cluster_id)
+ WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id))
+ .to_return(status: [500, "Internal Server Error"])
+ end
+
+ def stub_cloud_platform_create_cluster(project_id, zone, options = {})
+ WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone))
+ .to_return(cloud_platform_response(cloud_platform_operation_body(options)))
+ end
+
+ def stub_cloud_platform_create_cluster_error(project_id, zone)
+ WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone))
+ .to_return(status: [500, "Internal Server Error"])
+ end
+
+ def stub_cloud_platform_get_zone_operation(project_id, zone, operation_id, options = {})
+ WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id))
+ .to_return(cloud_platform_response(cloud_platform_operation_body(options)))
+ end
+
+ def stub_cloud_platform_get_zone_operation_error(project_id, zone, operation_id)
+ WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id))
+ .to_return(status: [500, "Internal Server Error"])
+ end
+
+ def cloud_platform_projects_list_url
+ "https://cloudresourcemanager.googleapis.com/v1/projects"
+ end
+
+ def cloud_platform_projects_get_billing_info_url(project_id)
+ "https://cloudbilling.googleapis.com/v1/projects/#{project_id}/billingInfo"
+ end
+
+ def cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id)
+ "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/clusters/#{cluster_id}"
+ end
+
+ def cloud_platform_create_cluster_url(project_id, zone)
+ "https://container.googleapis.com/v1beta1/projects/#{project_id}/zones/#{zone}/clusters"
+ end
+
+ def cloud_platform_get_zone_operation_url(project_id, zone, operation_id)
+ "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/operations/#{operation_id}"
+ end
+
+ def cloud_platform_response(body)
+ { status: 200, headers: { 'Content-Type' => 'application/json' }, body: body.to_json }
+ end
+
+ def load_sample_cert
+ pem_file = File.expand_path(Rails.root.join('spec/fixtures/clusters/sample_cert.pem'))
+ Base64.encode64(File.read(pem_file))
+ end
+
+ ##
+ # gcloud container clusters create
+ # https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.zones.clusters/create
+ # rubocop:disable Metrics/CyclomaticComplexity
+ # rubocop:disable Metrics/PerceivedComplexity
+ def cloud_platform_cluster_body(options)
+ {
+ name: options[:name] || 'string',
+ description: options[:description] || 'string',
+ initialNodeCount: options[:initialNodeCount] || 'number',
+ masterAuth: {
+ username: options[:username] || 'string',
+ password: options[:password] || 'string',
+ clusterCaCertificate: options[:clusterCaCertificate] || load_sample_cert,
+ clientCertificate: options[:clientCertificate] || 'string',
+ clientKey: options[:clientKey] || 'string'
+ },
+ loggingService: options[:loggingService] || 'string',
+ monitoringService: options[:monitoringService] || 'string',
+ network: options[:network] || 'string',
+ clusterIpv4Cidr: options[:clusterIpv4Cidr] || 'string',
+ subnetwork: options[:subnetwork] || 'string',
+ enableKubernetesAlpha: options[:enableKubernetesAlpha] || 'boolean',
+ labelFingerprint: options[:labelFingerprint] || 'string',
+ selfLink: options[:selfLink] || 'string',
+ zone: options[:zone] || 'string',
+ endpoint: options[:endpoint] || 'string',
+ initialClusterVersion: options[:initialClusterVersion] || 'string',
+ currentMasterVersion: options[:currentMasterVersion] || 'string',
+ currentNodeVersion: options[:currentNodeVersion] || 'string',
+ createTime: options[:createTime] || 'string',
+ status: options[:status] || 'RUNNING',
+ statusMessage: options[:statusMessage] || 'string',
+ nodeIpv4CidrSize: options[:nodeIpv4CidrSize] || 'number',
+ servicesIpv4Cidr: options[:servicesIpv4Cidr] || 'string',
+ currentNodeCount: options[:currentNodeCount] || 'number',
+ expireTime: options[:expireTime] || 'string'
+ }
+ end
+ # rubocop:enable Metrics/CyclomaticComplexity
+ # rubocop:enable Metrics/PerceivedComplexity
+
+ def cloud_platform_operation_body(options)
+ {
+ name: options[:name] || 'operation-1234567891234-1234567',
+ zone: options[:zone] || 'us-central1-a',
+ operationType: options[:operationType] || 'CREATE_CLUSTER',
+ status: options[:status] || 'PENDING',
+ detail: options[:detail] || 'detail',
+ statusMessage: options[:statusMessage] || '',
+ selfLink: options[:selfLink] || 'https://container.googleapis.com/v1/projects/123456789101/zones/us-central1-a/operations/operation-1234567891234-1234567',
+ targetLink: options[:targetLink] || 'https://container.googleapis.com/v1/projects/123456789101/zones/us-central1-a/clusters/test-cluster',
+ startTime: options[:startTime] || '2017-09-13T16:49:13.055601589Z',
+ endTime: options[:endTime] || ''
+ }
+ end
+
+ def cloud_platform_projects_body(options)
+ {
+ projects: [
+ {
+ projectNumber: options[:project_number] || "1234",
+ projectId: options[:project_id] || "test-project-1234",
+ lifecycleState: "ACTIVE",
+ name: options[:name] || "test-project",
+ createTime: "2017-12-16T01:48:29.129Z",
+ parent: {
+ type: "organization",
+ id: "12345"
+ }
+ }
+ ]
+ }
+ end
+
+ def cloud_platform_projects_billing_info_body(project_id, billing_enabled)
+ {
+ name: "projects/#{project_id}/billingInfo",
+ projectId: project_id.to_s,
+ billingAccountName: "account-name",
+ billingEnabled: billing_enabled
+ }
+ end
+ end
+end
diff --git a/spec/support/graphql/arguments.rb b/spec/support/helpers/graphql/arguments.rb
index 478a460a0f6..478a460a0f6 100644
--- a/spec/support/graphql/arguments.rb
+++ b/spec/support/helpers/graphql/arguments.rb
diff --git a/spec/support/helpers/graphql/fake_query_type.rb b/spec/support/helpers/graphql/fake_query_type.rb
new file mode 100644
index 00000000000..bdf30908532
--- /dev/null
+++ b/spec/support/helpers/graphql/fake_query_type.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'graphql'
+
+module Graphql
+ class FakeQueryType < ::GraphQL::Schema::Object
+ graphql_name 'FakeQuery'
+
+ field :hello_world, String, null: true do
+ argument :message, String, required: false
+ end
+
+ field :breaking_field, String, null: true
+
+ def hello_world(message: "world")
+ "Hello #{message}!"
+ end
+
+ def breaking_field
+ raise "This field is supposed to break"
+ end
+ end
+end
diff --git a/spec/support/graphql/fake_tracer.rb b/spec/support/helpers/graphql/fake_tracer.rb
index 58688c9abd0..58688c9abd0 100644
--- a/spec/support/graphql/fake_tracer.rb
+++ b/spec/support/helpers/graphql/fake_tracer.rb
diff --git a/spec/support/graphql/field_inspection.rb b/spec/support/helpers/graphql/field_inspection.rb
index 8730f82b893..8730f82b893 100644
--- a/spec/support/graphql/field_inspection.rb
+++ b/spec/support/helpers/graphql/field_inspection.rb
diff --git a/spec/support/graphql/field_selection.rb b/spec/support/helpers/graphql/field_selection.rb
index 432340cfdb5..432340cfdb5 100644
--- a/spec/support/graphql/field_selection.rb
+++ b/spec/support/helpers/graphql/field_selection.rb
diff --git a/spec/support/graphql/resolver_factories.rb b/spec/support/helpers/graphql/resolver_factories.rb
index 76df4b58943..76df4b58943 100644
--- a/spec/support/graphql/resolver_factories.rb
+++ b/spec/support/helpers/graphql/resolver_factories.rb
diff --git a/spec/support/helpers/graphql/subscriptions/action_cable/mock_action_cable.rb b/spec/support/helpers/graphql/subscriptions/action_cable/mock_action_cable.rb
new file mode 100644
index 00000000000..2ccc62a8729
--- /dev/null
+++ b/spec/support/helpers/graphql/subscriptions/action_cable/mock_action_cable.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+# A stub implementation of ActionCable.
+# Any methods to support the mock backend have `mock` in the name.
+module Graphql
+ module Subscriptions
+ module ActionCable
+ class MockActionCable
+ class MockChannel
+ def initialize
+ @mock_broadcasted_messages = []
+ end
+
+ attr_reader :mock_broadcasted_messages
+
+ def stream_from(stream_name, coder: nil, &block) # rubocop:disable Lint/UnusedMethodArgument
+ # Rails uses `coder`, we don't
+ block ||= ->(msg) { @mock_broadcasted_messages << msg }
+ MockActionCable.mock_stream_for(stream_name).add_mock_channel(self, block)
+ end
+ end
+
+ class MockStream
+ def initialize
+ @mock_channels = {}
+ end
+
+ def add_mock_channel(channel, handler)
+ @mock_channels[channel] = handler
+ end
+
+ def mock_broadcast(message)
+ @mock_channels.each_value do |handler|
+ handler && handler.call(message)
+ end
+ end
+ end
+
+ class << self
+ def clear_mocks
+ @mock_streams = {}
+ end
+
+ def server
+ self
+ end
+
+ def broadcast(stream_name, message)
+ stream = @mock_streams[stream_name]
+ stream && stream.mock_broadcast(message)
+ end
+
+ def mock_stream_for(stream_name)
+ @mock_streams[stream_name] ||= MockStream.new
+ end
+
+ def get_mock_channel
+ MockChannel.new
+ end
+
+ def mock_stream_names
+ @mock_streams.keys
+ end
+ end
+ end
+
+ class MockSchema < GraphQL::Schema
+ class << self
+ def find_by_gid(gid)
+ return unless gid
+
+ if gid.model_class < ApplicationRecord
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find
+ elsif gid.model_class.respond_to?(:lazy_find)
+ gid.model_class.lazy_find(gid.model_id)
+ else
+ gid.find
+ end
+ end
+
+ def id_from_object(object, _type = nil, _ctx = nil)
+ unless object.respond_to?(:to_global_id)
+ # This is an error in our schema and needs to be solved. So raise a
+ # more meaningful error message
+ raise "#{object} does not implement `to_global_id`. " \
+ "Include `GlobalID::Identification` into `#{object.class}"
+ end
+
+ object.to_global_id
+ end
+ end
+
+ query(::Types::QueryType)
+ subscription(::Types::SubscriptionType)
+
+ use GraphQL::Subscriptions::ActionCableSubscriptions, action_cable: MockActionCable, action_cable_coder: JSON
+ end
+ end
+ end
+end
diff --git a/spec/support/graphql/subscriptions/action_cable/mock_gitlab_schema.rb b/spec/support/helpers/graphql/subscriptions/action_cable/mock_gitlab_schema.rb
index cd5d78cc78b..cd5d78cc78b 100644
--- a/spec/support/graphql/subscriptions/action_cable/mock_gitlab_schema.rb
+++ b/spec/support/helpers/graphql/subscriptions/action_cable/mock_gitlab_schema.rb
diff --git a/spec/support/graphql/subscriptions/notes/helper.rb b/spec/support/helpers/graphql/subscriptions/notes/helper.rb
index 9a552f9879e..9a552f9879e 100644
--- a/spec/support/graphql/subscriptions/notes/helper.rb
+++ b/spec/support/helpers/graphql/subscriptions/notes/helper.rb
diff --git a/spec/support/graphql/var.rb b/spec/support/helpers/graphql/var.rb
index 4f2c774e898..4f2c774e898 100644
--- a/spec/support/graphql/var.rb
+++ b/spec/support/helpers/graphql/var.rb
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index 2176a477371..a9ad853b028 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -89,13 +89,16 @@ module GraphqlHelpers
# All mutations accept a single `:input` argument. Wrap arguments here.
args = { input: args } if resolver_class <= ::Mutations::BaseMutation && !args.key?(:input)
- resolve_field(field, obj,
- args: args,
- ctx: ctx,
- schema: schema,
- object_type: resolver_parent,
- extras: { parent: parent, lookahead: lookahead },
- arg_style: arg_style)
+ resolve_field(
+ field,
+ obj,
+ args: args,
+ ctx: ctx,
+ schema: schema,
+ object_type: resolver_parent,
+ extras: { parent: parent, lookahead: lookahead },
+ arg_style: arg_style
+ )
end
# Resolve the value of a field on an object.
@@ -310,7 +313,7 @@ module GraphqlHelpers
"{ #{q} }"
end
- def graphql_mutation(name, input, fields = nil, &block)
+ def graphql_mutation(name, input, fields = nil, excluded = [], &block)
raise ArgumentError, 'Please pass either `fields` parameter or a block to `#graphql_mutation`, but not both.' if fields.present? && block
name = name.graphql_name if name.respond_to?(:graphql_name)
@@ -319,7 +322,7 @@ module GraphqlHelpers
mutation_field = GitlabSchema.mutation.fields[mutation_name]
fields = yield if block
- fields ||= all_graphql_fields_for(mutation_field.type.to_type_signature)
+ fields ||= all_graphql_fields_for(mutation_field.type.to_type_signature, excluded: excluded)
query = <<~MUTATION
mutation(#{input_variable_name}: #{mutation_field.arguments['input'].type.to_type_signature}) {
@@ -513,20 +516,23 @@ module GraphqlHelpers
end
def post_graphql_mutation(mutation, current_user: nil, token: {})
- post_graphql(mutation.query,
- current_user: current_user,
- variables: mutation.variables,
- token: token)
+ post_graphql(
+ mutation.query,
+ current_user: current_user,
+ variables: mutation.variables,
+ token: token
+ )
end
def post_graphql_mutation_with_uploads(mutation, current_user: nil)
file_paths = file_paths_in_mutation(mutation)
params = mutation_to_apollo_uploads_param(mutation, files: file_paths)
- workhorse_post_with_file(api('/', current_user, version: 'graphql'),
- params: params,
- file_key: '1'
- )
+ workhorse_post_with_file(
+ api('/', current_user, version: 'graphql'),
+ params: params,
+ file_key: '1'
+ )
end
def file_paths_in_mutation(mutation)
@@ -633,7 +639,11 @@ module GraphqlHelpers
end
def expect_graphql_errors_to_be_empty
- expect(flattened_errors).to be_empty
+ # TODO: using eq([]) instead of be_empty makes it print out the full error message including the
+ # raisedAt key which contains the full stacktrace. This is necessary to know where the
+ # unexpected error occurred during tests.
+ # This or an equivalent fix should be added in a separate MR on master.
+ expect(flattened_errors).to eq([])
end
# Helps migrate to the new GraphQL interpreter,
diff --git a/spec/support/helpers/http_io_helpers.rb b/spec/support/helpers/http_io_helpers.rb
new file mode 100644
index 00000000000..638d780cdc2
--- /dev/null
+++ b/spec/support/helpers/http_io_helpers.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module HttpIOHelpers
+ def stub_remote_url_206(url, file_path)
+ WebMock.stub_request(:get, url)
+ .to_return { |request| remote_url_response(file_path, request, 206) }
+ end
+
+ def stub_remote_url_200(url, file_path)
+ WebMock.stub_request(:get, url)
+ .to_return { |request| remote_url_response(file_path, request, 200) }
+ end
+
+ def stub_remote_url_500(url)
+ WebMock.stub_request(:get, url)
+ .to_return(status: [500, "Internal Server Error"])
+ end
+
+ def remote_url_response(file_path, request, response_status)
+ range = request.headers['Range'].match(/bytes=(\d+)-(\d+)/)
+
+ body = File.read(file_path).force_encoding(Encoding::BINARY)
+ size = body.bytesize
+
+ {
+ status: response_status,
+ headers: remote_url_response_headers(response_status, range[1].to_i, range[2].to_i, size),
+ body: body[range[1].to_i..range[2].to_i]
+ }
+ end
+
+ def remote_url_response_headers(response_status, from, to, size)
+ { 'Content-Type' => 'text/plain' }.tap do |headers|
+ headers.merge('Content-Range' => "bytes #{from}-#{to}/#{size}") if response_status == 206
+ end
+ end
+
+ def set_smaller_buffer_size_than(file_size)
+ blocks = (file_size / 128)
+ new_size = (blocks / 2) * 128
+ stub_const("Gitlab::HttpIO::BUFFER_SIZE", new_size)
+ end
+
+ def set_larger_buffer_size_than(file_size)
+ blocks = (file_size / 128)
+ new_size = (blocks * 2) * 128
+ stub_const("Gitlab::HttpIO::BUFFER_SIZE", new_size)
+ end
+end
diff --git a/spec/support/helpers/jira_integration_helpers.rb b/spec/support/helpers/jira_integration_helpers.rb
index 66940314589..098f2968b0b 100644
--- a/spec/support/helpers/jira_integration_helpers.rb
+++ b/spec/support/helpers/jira_integration_helpers.rb
@@ -11,7 +11,7 @@ module JiraIntegrationHelpers
jira_issue_transition_id = '1'
jira_tracker.update!(
- url: url, username: username, password: password,
+ url: url, username: username, password: password, jira_auth_type: 0,
jira_issue_transition_id: jira_issue_transition_id, active: true
)
end
diff --git a/spec/support/helpers/keyset_pagination_helpers.rb b/spec/support/helpers/keyset_pagination_helpers.rb
new file mode 100644
index 00000000000..4a476c47fda
--- /dev/null
+++ b/spec/support/helpers/keyset_pagination_helpers.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module KeysetPaginationHelpers
+ def pagination_links(response)
+ link = response.headers['LINK']
+ return unless link
+
+ link.split(',').filter_map do |link|
+ match = link.match(/<(?<url>.*)>; rel="(?<rel>\w+)"/)
+ next unless match
+
+ { url: match[:url], rel: match[:rel] }
+ end
+ end
+
+ def pagination_params_from_next_url(response)
+ next_link = pagination_links(response).find { |link| link[:rel] == 'next' }
+ next_url = next_link&.fetch(:url)
+ return unless next_url
+
+ Rack::Utils.parse_query(URI.parse(next_url).query)
+ end
+end
diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb
index 5fde80e6dc9..67315b9d81e 100644
--- a/spec/support/helpers/login_helpers.rb
+++ b/spec/support/helpers/login_helpers.rb
@@ -51,7 +51,7 @@ module LoginHelpers
def gitlab_enable_admin_mode_sign_in(user)
visit new_admin_session_path
fill_in 'user_password', with: user.password
- click_button 'Enter Admin Mode'
+ click_button 'Enter admin mode'
wait_for_requests
end
@@ -82,7 +82,7 @@ module LoginHelpers
open_top_nav
within_top_nav do
- click_on 'Leave Admin Mode'
+ click_on 'Leave admin mode'
end
end
@@ -94,8 +94,8 @@ module LoginHelpers
# remember - Whether or not to check "Remember me" (default: false)
# two_factor_auth - If two-factor authentication is enabled (default: false)
# password - password to attempt to login with (default: user.password)
- def gitlab_sign_in_with(user, remember: false, two_factor_auth: false, password: nil)
- visit new_user_session_path
+ def gitlab_sign_in_with(user, remember: false, two_factor_auth: false, password: nil, visit: true)
+ visit new_user_session_path if visit
fill_in "user_login", with: user.email
fill_in "user_password", with: (password || user.password)
@@ -114,9 +114,9 @@ module LoginHelpers
def login_via(provider, user, uid, remember_me: false, additional_info: {})
mock_auth_hash(provider, uid, user.email, additional_info: additional_info)
visit new_user_session_path
- expect(page).to have_content('Sign in with')
+ expect(page).to have_css('.omniauth-container')
- check 'remember_me' if remember_me
+ check 'remember_me_omniauth' if remember_me
click_button "oauth-login-#{provider}"
end
@@ -139,11 +139,6 @@ module LoginHelpers
click_link_or_button "oauth-login-#{provider}"
end
- def fake_successful_u2f_authentication
- allow(U2fRegistration).to receive(:authenticate).and_return(true)
- FakeU2fDevice.new(page, nil).fake_u2f_authentication
- end
-
def fake_successful_webauthn_authentication
allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).and_return(true)
FakeWebauthnDevice.new(page, nil).fake_webauthn_authentication
@@ -218,6 +213,15 @@ module LoginHelpers
config
end
+ def prepare_provider_route(provider_name)
+ routes = Rails.application.routes
+ routes.disable_clear_and_finalize = true
+ routes.formatter.clear
+ routes.draw do
+ post "/users/auth/#{provider_name}" => "omniauth_callbacks##{provider_name}"
+ end
+ end
+
def stub_omniauth_provider(provider, context: Rails.application)
env = env_from_context(context)
diff --git a/spec/support/helpers/markdown_feature.rb b/spec/support/helpers/markdown_feature.rb
index 0cb2863dc2c..5d9ef557ae6 100644
--- a/spec/support/helpers/markdown_feature.rb
+++ b/spec/support/helpers/markdown_feature.rb
@@ -48,6 +48,10 @@ class MarkdownFeature
@issue ||= create(:issue, project: project)
end
+ def work_item
+ @issue ||= create(:work_item, project: project)
+ end
+
def merge_request
@merge_request ||= create(:merge_request, :simple, source_project: project)
end
@@ -106,6 +110,10 @@ class MarkdownFeature
@xissue ||= create(:issue, project: xproject)
end
+ def xwork_item
+ @xwork_item ||= create(:work_item, project: xproject)
+ end
+
def xmerge_request
@xmerge_request ||= create(:merge_request, :simple, source_project: xproject)
end
diff --git a/spec/support/helpers/metrics_dashboard_helpers.rb b/spec/support/helpers/metrics_dashboard_helpers.rb
index a384e44f428..417baeda33a 100644
--- a/spec/support/helpers/metrics_dashboard_helpers.rb
+++ b/spec/support/helpers/metrics_dashboard_helpers.rb
@@ -49,8 +49,4 @@ module MetricsDashboardHelpers
def business_metric_title
Enums::PrometheusMetric.group_details[:business][:group_title]
end
-
- def self_monitoring_dashboard_path
- Metrics::Dashboard::SelfMonitoringDashboardService::DASHBOARD_PATH
- end
end
diff --git a/spec/support/helpers/migrations_helpers.rb b/spec/support/helpers/migrations_helpers.rb
index 6fc5904fc83..1b8c3388051 100644
--- a/spec/support/helpers/migrations_helpers.rb
+++ b/spec/support/helpers/migrations_helpers.rb
@@ -92,7 +92,7 @@ module MigrationsHelpers
end
def reset_column_information(klass)
- klass.reset_column_information
+ klass.reset_column_information if klass.instance_variable_get(:@table_name)
end
# In some migration tests, we're using factories to create records,
diff --git a/spec/support/migrations_helpers/cluster_helpers.rb b/spec/support/helpers/migrations_helpers/cluster_helpers.rb
index 03104e22bcf..03104e22bcf 100644
--- a/spec/support/migrations_helpers/cluster_helpers.rb
+++ b/spec/support/helpers/migrations_helpers/cluster_helpers.rb
diff --git a/spec/support/helpers/migrations_helpers/namespaces_helper.rb b/spec/support/helpers/migrations_helpers/namespaces_helper.rb
new file mode 100644
index 00000000000..d9a4e0d1731
--- /dev/null
+++ b/spec/support/helpers/migrations_helpers/namespaces_helper.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module MigrationHelpers
+ module NamespacesHelpers
+ def create_namespace(name, visibility, options = {})
+ table(:namespaces).create!(
+ {
+ name: name,
+ path: name,
+ type: 'Group',
+ visibility_level: visibility
+ }.merge(options))
+ end
+ end
+end
diff --git a/spec/support/helpers/migrations_helpers/schema_version_finder.rb b/spec/support/helpers/migrations_helpers/schema_version_finder.rb
new file mode 100644
index 00000000000..69469959ce5
--- /dev/null
+++ b/spec/support/helpers/migrations_helpers/schema_version_finder.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+# Sometimes data migration specs require adding invalid test data in order to test
+# the migration (e.g. adding a row with null foreign key). Certain db migrations that
+# add constraints (e.g. NOT NULL constraint) prevent invalid records from being added
+# and data migration from being tested. For this reason, SchemaVersionFinder can be used
+# to find and use schema prior to specified one.
+#
+# @example
+# RSpec.describe CleanupThings, :migration,
+# schema: MigrationHelpers::SchemaVersionFinder.migration_prior(AddNotNullConstraint) do ...
+#
+# SchemaVersionFinder returns schema version prior to the one specified, which allows to then add
+# invalid records to the database, which in return allows to properly test data migration.
+module MigrationHelpers
+ class SchemaVersionFinder
+ def self.migrations_paths
+ ActiveRecord::Migrator.migrations_paths
+ end
+
+ def self.migration_context
+ ActiveRecord::MigrationContext.new(migrations_paths, ActiveRecord::SchemaMigration)
+ end
+
+ def self.migrations
+ migration_context.migrations
+ end
+
+ def self.migration_prior(migration_klass)
+ migrations.each_cons(2) do |previous, migration|
+ break previous.version if migration.name == migration_klass.name
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/migrations_helpers/vulnerabilities_findings_helper.rb b/spec/support/helpers/migrations_helpers/vulnerabilities_findings_helper.rb
new file mode 100644
index 00000000000..1f8505978f5
--- /dev/null
+++ b/spec/support/helpers/migrations_helpers/vulnerabilities_findings_helper.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+module MigrationHelpers
+ module VulnerabilitiesFindingsHelper
+ def attributes_for_vulnerabilities_finding
+ uuid = SecureRandom.uuid
+
+ {
+ project_fingerprint: SecureRandom.hex(20),
+ location_fingerprint: Digest::SHA1.hexdigest(SecureRandom.hex(10)), # rubocop:disable Fips/SHA1
+ uuid: uuid,
+ name: "Vulnerability Finding #{uuid}",
+ metadata_version: '1.3',
+ raw_metadata: raw_metadata
+ }
+ end
+
+ def raw_metadata
+ {
+ "description" => "The cipher does not provide data integrity update 1",
+ "message" => "The cipher does not provide data integrity",
+ "cve" => "818bf5dacb291e15d9e6dc3c5ac32178:CIPHER",
+ "solution" => "GCM mode introduces an HMAC into the resulting encrypted data, providing integrity of the result.", # rubocop:disable Layout/LineLength
+ "location" => {
+ "file" => "maven/src/main/java/com/gitlab/security_products/tests/App.java",
+ "start_line" => 29,
+ "end_line" => 29,
+ "class" => "com.gitlab.security_products.tests.App",
+ "method" => "insecureCypher"
+ },
+ "links" => [
+ {
+ "name" => "Cipher does not check for integrity first?",
+ "url" => "https://crypto.stackexchange.com/questions/31428/pbewithmd5anddes-cipher-does-not-check-for-integrity-first"
+ }
+ ],
+ "assets" => [
+ {
+ "type" => "postman",
+ "name" => "Test Postman Collection",
+ "url" => "http://localhost/test.collection"
+ }
+ ],
+ "evidence" => {
+ "summary" => "Credit card detected",
+ "request" => {
+ "method" => "GET",
+ "url" => "http://goat:8080/WebGoat/logout",
+ "body" => nil,
+ "headers" => [
+ {
+ "name" => "Accept",
+ "value" => "*/*"
+ }
+ ]
+ },
+ "response" => {
+ "reason_phrase" => "OK",
+ "status_code" => 200,
+ "body" => nil,
+ "headers" => [
+ {
+ "name" => "Content-Length",
+ "value" => "0"
+ }
+ ]
+ },
+ "source" => {
+ "id" => "assert:Response Body Analysis",
+ "name" => "Response Body Analysis",
+ "url" => "htpp://hostname/documentation"
+ },
+ "supporting_messages" => [
+ {
+ "name" => "Origional",
+ "request" => {
+ "method" => "GET",
+ "url" => "http://goat:8080/WebGoat/logout",
+ "body" => "",
+ "headers" => [
+ {
+ "name" => "Accept",
+ "value" => "*/*"
+ }
+ ]
+ }
+ },
+ {
+ "name" => "Recorded",
+ "request" => {
+ "method" => "GET",
+ "url" => "http://goat:8080/WebGoat/logout",
+ "body" => "",
+ "headers" => [
+ {
+ "name" => "Accept",
+ "value" => "*/*"
+ }
+ ]
+ },
+ "response" => {
+ "reason_phrase" => "OK",
+ "status_code" => 200,
+ "body" => "",
+ "headers" => [
+ {
+ "name" => "Content-Length",
+ "value" => "0"
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+ end
+ end
+end
diff --git a/spec/support/helpers/models/ci/partitioning_testing/cascade_check.rb b/spec/support/helpers/models/ci/partitioning_testing/cascade_check.rb
new file mode 100644
index 00000000000..81c2d2cb225
--- /dev/null
+++ b/spec/support/helpers/models/ci/partitioning_testing/cascade_check.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module PartitioningTesting
+ module CascadeCheck
+ extend ActiveSupport::Concern
+
+ included do
+ after_create :check_partition_cascade_value
+ end
+
+ def check_partition_cascade_value
+ raise 'Partition value not found' unless partition_scope_value
+
+ return if partition_id == partition_scope_value
+
+ raise "partition_id was expected to equal #{partition_scope_value} but it was #{partition_id}."
+ end
+
+ class_methods do
+ # Allowing partition callback to be used with BulkInsertSafe
+ def _bulk_insert_callback_allowed?(name, args)
+ super || (args.first == :after && args.second == :check_partition_cascade_value)
+ end
+ end
+ end
+end
+
+Ci::Partitionable::Testing::PARTITIONABLE_MODELS.each do |klass|
+ next if klass == 'Ci::Pipeline'
+
+ model = klass.safe_constantize
+
+ model.include(PartitioningTesting::CascadeCheck)
+end
diff --git a/spec/support/models/ci/partitioning_testing/partition_identifiers.rb b/spec/support/helpers/models/ci/partitioning_testing/partition_identifiers.rb
index aa091095fb6..aa091095fb6 100644
--- a/spec/support/models/ci/partitioning_testing/partition_identifiers.rb
+++ b/spec/support/helpers/models/ci/partitioning_testing/partition_identifiers.rb
diff --git a/spec/support/helpers/models/ci/partitioning_testing/rspec_hooks.rb b/spec/support/helpers/models/ci/partitioning_testing/rspec_hooks.rb
new file mode 100644
index 00000000000..3f0a2bb7f3b
--- /dev/null
+++ b/spec/support/helpers/models/ci/partitioning_testing/rspec_hooks.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+RSpec.configure do |config|
+ config.include Ci::PartitioningTesting::PartitionIdentifiers
+
+ config.around(:each, :ci_partitionable) do |example|
+ unless Ci::Build.table_name.to_s.starts_with?('p_')
+ skip 'Skipping partitioning tests until `ci_builds` is partitioned'
+ end
+
+ Ci::PartitioningTesting::SchemaHelpers.with_routing_tables do
+ example.run
+ end
+ end
+
+ config.before(:all) do
+ Ci::PartitioningTesting::SchemaHelpers.setup
+ end
+
+ config.after(:all) do
+ Ci::PartitioningTesting::SchemaHelpers.teardown
+ end
+end
diff --git a/spec/support/models/ci/partitioning_testing/schema_helpers.rb b/spec/support/helpers/models/ci/partitioning_testing/schema_helpers.rb
index 4107bbcb976..4107bbcb976 100644
--- a/spec/support/models/ci/partitioning_testing/schema_helpers.rb
+++ b/spec/support/helpers/models/ci/partitioning_testing/schema_helpers.rb
diff --git a/spec/support/helpers/models/merge_request_without_merge_request_diff.rb b/spec/support/helpers/models/merge_request_without_merge_request_diff.rb
new file mode 100644
index 00000000000..e9f97a2c95a
--- /dev/null
+++ b/spec/support/helpers/models/merge_request_without_merge_request_diff.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequestWithoutMergeRequestDiff < ::MergeRequest # rubocop:disable Gitlab/NamespacedClass
+ self.inheritance_column = :_type_disabled
+
+ def ensure_merge_request_diff; end
+end
diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb
index 48c6e590e1b..9a6af5fb8ae 100644
--- a/spec/support/helpers/navbar_structure_helper.rb
+++ b/spec/support/helpers/navbar_structure_helper.rb
@@ -73,7 +73,7 @@ module NavbarStructureHelper
insert_after_sub_nav_item(
_('Package Registry'),
within: _('Packages and registries'),
- new_sub_nav_item_name: _('Infrastructure Registry')
+ new_sub_nav_item_name: _('Terraform modules')
)
end
@@ -100,12 +100,28 @@ module NavbarStructureHelper
def insert_infrastructure_google_cloud_nav
insert_after_sub_nav_item(
- _('Terraform'),
+ s_('Terraform|Terraform states'),
within: _('Infrastructure'),
new_sub_nav_item_name: _('Google Cloud')
)
end
+ def insert_infrastructure_aws_nav
+ insert_after_sub_nav_item(
+ _('Google Cloud'),
+ within: _('Infrastructure'),
+ new_sub_nav_item_name: _('AWS')
+ )
+ end
+
+ def insert_model_experiments_nav(within)
+ insert_after_sub_nav_item(
+ within,
+ within: _('Packages and registries'),
+ new_sub_nav_item_name: _('Model experiments')
+ )
+ end
+
def project_analytics_sub_nav_item
[
_('Value stream'),
diff --git a/spec/support/helpers/note_interaction_helpers.rb b/spec/support/helpers/note_interaction_helpers.rb
index fa2705a64fa..40f1f6fe6f3 100644
--- a/spec/support/helpers/note_interaction_helpers.rb
+++ b/spec/support/helpers/note_interaction_helpers.rb
@@ -7,6 +7,6 @@ module NoteInteractionHelpers
note_element = find_by_scrolling("#note_#{note.id}")
note_element.find('.more-actions-toggle').click
- note_element.find('.more-actions .dropdown-menu li', match: :first)
+ note_element.find('.more-actions li', match: :first)
end
end
diff --git a/spec/support/helpers/project_template_test_helper.rb b/spec/support/helpers/project_template_test_helper.rb
index bedbb8601e8..35e40faeea7 100644
--- a/spec/support/helpers/project_template_test_helper.rb
+++ b/spec/support/helpers/project_template_test_helper.rb
@@ -4,12 +4,12 @@ module ProjectTemplateTestHelper
def all_templates
%w[
rails spring express iosswift dotnetcore android
- gomicro gatsby hugo jekyll plainhtml gitbook
+ gomicro gatsby hugo jekyll plainhtml
hexo middleman gitpod_spring_petclinic nfhugo
nfjekyll nfplainhtml nfgitbook nfhexo salesforcedx
serverless_framework tencent_serverless_framework
jsonnet cluster_management kotlin_native_linux
- pelican bridgetown typo3_distribution
+ pelican bridgetown typo3_distribution laravel
]
end
end
diff --git a/spec/support/helpers/prometheus/metric_builders.rb b/spec/support/helpers/prometheus/metric_builders.rb
new file mode 100644
index 00000000000..53329ee8dce
--- /dev/null
+++ b/spec/support/helpers/prometheus/metric_builders.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Prometheus
+ module MetricBuilders
+ def simple_query(suffix = 'a', **opts)
+ { query_range: "query_range_#{suffix}" }.merge(opts)
+ end
+
+ def simple_queries
+ [simple_query, simple_query('b', label: 'label', unit: 'unit')]
+ end
+
+ def simple_metric(title: 'title', required_metrics: [], queries: [simple_query])
+ Gitlab::Prometheus::Metric.new(title: title, required_metrics: required_metrics, weight: 1, queries: queries)
+ end
+
+ def simple_metrics(added_metric_name: 'metric_a')
+ [
+ simple_metric(required_metrics: %W[#{added_metric_name} metric_b], queries: simple_queries),
+ simple_metric(required_metrics: [added_metric_name], queries: [simple_query('empty')]),
+ simple_metric(required_metrics: %w[metric_c])
+ ]
+ end
+
+ def simple_metric_group(name: 'name', metrics: simple_metrics)
+ Gitlab::Prometheus::MetricGroup.new(name: name, priority: 1, metrics: metrics)
+ end
+ end
+end
diff --git a/spec/support/helpers/query_recorder.rb b/spec/support/helpers/query_recorder.rb
index 5be9ba9ae1e..e8fa73a1b95 100644
--- a/spec/support/helpers/query_recorder.rb
+++ b/spec/support/helpers/query_recorder.rb
@@ -102,6 +102,10 @@ module ActiveRecord
@occurrences ||= @log.group_by(&:to_s).transform_values(&:count)
end
+ def occurrences_starting_with(str)
+ occurrences.select { |query, _count| query.starts_with?(str) }
+ end
+
def ignorable?(values)
return true if skip_schema_queries && values[:name]&.include?("SCHEMA")
return true if values[:name]&.match(/License Load/)
diff --git a/spec/support/redis/redis_helpers.rb b/spec/support/helpers/redis_helpers.rb
index 2c5ceb2f09e..2c5ceb2f09e 100644
--- a/spec/support/redis/redis_helpers.rb
+++ b/spec/support/helpers/redis_helpers.rb
diff --git a/spec/support/helpers/repo_helpers.rb b/spec/support/helpers/repo_helpers.rb
index 9f37cf61cc9..45467fb7099 100644
--- a/spec/support/helpers/repo_helpers.rb
+++ b/spec/support/helpers/repo_helpers.rb
@@ -41,6 +41,7 @@ eos
line_code: '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_14',
line_code_path: 'files/ruby/popen.rb',
del_line_code: '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_13_13',
+ referenced_by: [],
message: <<eos
Change some files
Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>
@@ -56,6 +57,7 @@ eos
author_full_name: "Sytse Sijbrandij",
author_email: "sytse@gitlab.com",
files_changed_count: 1,
+ referenced_by: [],
message: <<eos
Add directory structure for tree_helper spec
@@ -74,6 +76,7 @@ eos
sha: "913c66a37b4a45b9769037c55c2d238bd0942d2e",
author_full_name: "Dmitriy Zaporozhets",
author_email: "dmitriy.zaporozhets@gmail.com",
+ referenced_by: [],
message: <<eos
Files, encoding and much more
Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>
@@ -89,6 +92,7 @@ eos
author_email: "dmitriy.zaporozhets@gmail.com",
old_blob_id: '33f3729a45c02fc67d00adb1b8bca394b0e761d9',
new_blob_id: '2f63565e7aac07bcdadb654e253078b727143ec4',
+ referenced_by: [],
message: <<eos
Modified image
Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>
diff --git a/spec/support/helpers/search_helpers.rb b/spec/support/helpers/search_helpers.rb
index eab30be9243..75853371c0f 100644
--- a/spec/support/helpers/search_helpers.rb
+++ b/spec/support/helpers/search_helpers.rb
@@ -2,9 +2,6 @@
module SearchHelpers
def fill_in_search(text)
- # Once the `new_header_search` feature flag has been removed
- # We can remove the `.search-input-wrap` selector
- # https://gitlab.com/gitlab-org/gitlab/-/issues/339348
page.within('.header-search-new') do
find('#search').click
fill_in 'search', with: text
@@ -14,10 +11,7 @@ module SearchHelpers
end
def submit_search(query)
- # Once the `new_header_search` feature flag has been removed
- # We can remove the `.search-form` selector
- # https://gitlab.com/gitlab-org/gitlab/-/issues/339348
- page.within('.header-search, .search-form, .search-page-form') do
+ page.within('.header-search, .search-page-form') do
field = find_field('search')
field.click
field.fill_in(with: query)
diff --git a/spec/support/helpers/session_helpers.rb b/spec/support/helpers/session_helpers.rb
index 394a401afca..5554695a27d 100644
--- a/spec/support/helpers/session_helpers.rb
+++ b/spec/support/helpers/session_helpers.rb
@@ -39,4 +39,10 @@ module SessionHelpers
def get_ttl(key)
Gitlab::Redis::Sessions.with { |redis| redis.ttl(key) }
end
+
+ def expire_session
+ get_session_keys.each do |key|
+ ::Gitlab::Redis::Sessions.with { |redis| redis.expire(key, -1) }
+ end
+ end
end
diff --git a/spec/support/helpers/snowplow_helpers.rb b/spec/support/helpers/snowplow_helpers.rb
index 265e1c38b09..a04e5d46df9 100644
--- a/spec/support/helpers/snowplow_helpers.rb
+++ b/spec/support/helpers/snowplow_helpers.rb
@@ -46,7 +46,7 @@ module SnowplowHelpers
# }
# ]
# )
- def expect_snowplow_event(category:, action:, context: nil, **kwargs)
+ def expect_snowplow_event(category:, action:, context: nil, tracking_method: :event, **kwargs)
if context
if context.is_a?(Array)
kwargs[:context] = []
@@ -60,7 +60,7 @@ module SnowplowHelpers
end
end
- expect(Gitlab::Tracking).to have_received(:event) # rubocop:disable RSpec/ExpectGitlabTracking
+ expect(Gitlab::Tracking).to have_received(tracking_method) # rubocop:disable RSpec/ExpectGitlabTracking
.with(category, action, **kwargs).at_least(:once)
end
@@ -79,11 +79,11 @@ module SnowplowHelpers
# expect_no_snowplow_event
# end
# end
- def expect_no_snowplow_event(category: nil, action: nil, **kwargs)
+ def expect_no_snowplow_event(category: nil, action: nil, tracking_method: :event, **kwargs)
if category && action
- expect(Gitlab::Tracking).not_to have_received(:event).with(category, action, **kwargs) # rubocop:disable RSpec/ExpectGitlabTracking
+ expect(Gitlab::Tracking).not_to have_received(tracking_method).with(category, action, **kwargs) # rubocop:disable RSpec/ExpectGitlabTracking
else
- expect(Gitlab::Tracking).not_to have_received(:event) # rubocop:disable RSpec/ExpectGitlabTracking
+ expect(Gitlab::Tracking).not_to have_received(tracking_method) # rubocop:disable RSpec/ExpectGitlabTracking
end
end
end
diff --git a/spec/support/helpers/stub_configuration.rb b/spec/support/helpers/stub_configuration.rb
index 4ca8f26be9e..4c997aceeee 100644
--- a/spec/support/helpers/stub_configuration.rb
+++ b/spec/support/helpers/stub_configuration.rb
@@ -102,7 +102,7 @@ module StubConfiguration
messages[storage_name] = Gitlab::GitalyClient::StorageSettings.new(storage_hash.to_h)
end
- allow(Gitlab.config.repositories).to receive(:storages).and_return(Settingslogic.new(messages))
+ allow(Gitlab.config.repositories).to receive(:storages).and_return(::GitlabSettings::Options.build(messages))
end
def stub_sentry_settings(enabled: true)
@@ -175,11 +175,11 @@ module StubConfiguration
end
end
- # Support nested hashes by converting all values into Settingslogic objects
+ # Support nested hashes by converting all values into GitlabSettings::Objects objects
def to_settings(hash)
hash.transform_values do |value|
if value.is_a? Hash
- Settingslogic.new(value.deep_stringify_keys)
+ ::GitlabSettings::Options.build(value)
else
value
end
diff --git a/spec/support/helpers/stub_gitlab_calls.rb b/spec/support/helpers/stub_gitlab_calls.rb
index e5c30769531..748ea525e40 100644
--- a/spec/support/helpers/stub_gitlab_calls.rb
+++ b/spec/support/helpers/stub_gitlab_calls.rb
@@ -94,10 +94,10 @@ module StubGitlabCalls
end
def stub_commonmark_sourcepos_disabled
- render_options = Banzai::Filter::MarkdownEngines::CommonMark::RENDER_OPTIONS
+ engine = Banzai::Filter::MarkdownFilter.render_engine(nil)
- allow_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance|
- allow(instance).to receive(:render_options).and_return(render_options)
+ allow_next_instance_of(engine) do |instance|
+ allow(instance).to receive(:sourcepos_disabled?).and_return(true)
end
end
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index 6b633856228..e7587e59a91 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -2,9 +2,11 @@
module StubObjectStorage
def stub_dependency_proxy_object_storage(**params)
- stub_object_storage_uploader(config: ::Gitlab.config.dependency_proxy.object_store,
- uploader: ::DependencyProxy::FileUploader,
- **params)
+ stub_object_storage_uploader(
+ config: ::Gitlab.config.dependency_proxy.object_store,
+ uploader: ::DependencyProxy::FileUploader,
+ **params
+ )
end
def stub_object_storage_uploader(
@@ -15,7 +17,7 @@ module StubObjectStorage
direct_upload: false,
cdn: {}
)
- old_config = Settingslogic.new(config.deep_stringify_keys)
+ old_config = ::GitlabSettings::Options.build(config.to_h.deep_stringify_keys)
new_config = config.to_h.deep_symbolize_keys.merge({
enabled: enabled,
proxy_download: proxy_download,
@@ -30,14 +32,16 @@ module StubObjectStorage
allow(config).to receive(:proxy_download) { proxy_download }
allow(config).to receive(:direct_upload) { direct_upload }
- uploader_config = Settingslogic.new(new_config.deep_stringify_keys)
+ uploader_config = ::GitlabSettings::Options.build(new_config.to_h.deep_stringify_keys)
allow(uploader).to receive(:object_store_options).and_return(uploader_config)
allow(uploader.options).to receive(:object_store).and_return(uploader_config)
return unless enabled
- stub_object_storage(connection_params: uploader.object_store_credentials,
- remote_directory: old_config.remote_directory)
+ stub_object_storage(
+ connection_params: uploader.object_store_credentials,
+ remote_directory: old_config.remote_directory
+ )
end
def stub_object_storage(connection_params:, remote_directory:)
@@ -55,63 +59,99 @@ module StubObjectStorage
end
def stub_artifacts_object_storage(uploader = JobArtifactUploader, **params)
- stub_object_storage_uploader(config: Gitlab.config.artifacts.object_store,
- uploader: uploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.artifacts.object_store,
+ uploader: uploader,
+ **params
+ )
end
def stub_external_diffs_object_storage(uploader = described_class, **params)
- stub_object_storage_uploader(config: Gitlab.config.external_diffs.object_store,
- uploader: uploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.external_diffs.object_store,
+ uploader: uploader,
+ **params
+ )
end
def stub_lfs_object_storage(**params)
- stub_object_storage_uploader(config: Gitlab.config.lfs.object_store,
- uploader: LfsObjectUploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.lfs.object_store,
+ uploader: LfsObjectUploader,
+ **params
+ )
end
def stub_package_file_object_storage(**params)
- stub_object_storage_uploader(config: Gitlab.config.packages.object_store,
- uploader: ::Packages::PackageFileUploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.packages.object_store,
+ uploader: ::Packages::PackageFileUploader,
+ **params
+ )
end
def stub_rpm_repository_file_object_storage(**params)
- stub_object_storage_uploader(config: Gitlab.config.packages.object_store,
- uploader: ::Packages::Rpm::RepositoryFileUploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.packages.object_store,
+ uploader: ::Packages::Rpm::RepositoryFileUploader,
+ **params
+ )
end
def stub_composer_cache_object_storage(**params)
- stub_object_storage_uploader(config: Gitlab.config.packages.object_store,
- uploader: ::Packages::Composer::CacheUploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.packages.object_store,
+ uploader: ::Packages::Composer::CacheUploader,
+ **params
+ )
+ end
+
+ def debian_component_file_object_storage(**params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.packages.object_store,
+ uploader: ::Packages::Debian::ComponentFileUploader,
+ **params
+ )
+ end
+
+ def debian_distribution_release_file_object_storage(**params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.packages.object_store,
+ uploader: ::Packages::Debian::DistributionReleaseFileUploader,
+ **params
+ )
end
def stub_uploads_object_storage(uploader = described_class, **params)
- stub_object_storage_uploader(config: Gitlab.config.uploads.object_store,
- uploader: uploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.uploads.object_store,
+ uploader: uploader,
+ **params
+ )
end
def stub_ci_secure_file_object_storage(**params)
- stub_object_storage_uploader(config: Gitlab.config.ci_secure_files.object_store,
- uploader: Ci::SecureFileUploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.ci_secure_files.object_store,
+ uploader: Ci::SecureFileUploader,
+ **params
+ )
end
def stub_terraform_state_object_storage(**params)
- stub_object_storage_uploader(config: Gitlab.config.terraform_state.object_store,
- uploader: Terraform::StateUploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.terraform_state.object_store,
+ uploader: Terraform::StateUploader,
+ **params
+ )
end
def stub_pages_object_storage(uploader = described_class, **params)
- stub_object_storage_uploader(config: Gitlab.config.pages.object_store,
- uploader: uploader,
- **params)
+ stub_object_storage_uploader(
+ config: Gitlab.config.pages.object_store,
+ uploader: uploader,
+ **params
+ )
end
def stub_object_storage_multipart_init(endpoint, upload_id = "upload_id")
@@ -125,4 +165,16 @@ module StubObjectStorage
</InitiateMultipartUploadResult>
EOS
end
+
+ def stub_object_storage_multipart_init_with_final_store_path(full_path, upload_id = "upload_id")
+ stub_request(:post, %r{\A#{full_path}\?uploads\z})
+ .to_return status: 200, body: <<-EOS.strip_heredoc
+ <?xml version="1.0" encoding="UTF-8"?>
+ <InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <Bucket>example-bucket</Bucket>
+ <Key>example-object</Key>
+ <UploadId>#{upload_id}</UploadId>
+ </InitiateMultipartUploadResult>
+ EOS
+ end
end
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index 3403064bf0b..ceb567e54c4 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -2,6 +2,7 @@
require 'parallel'
require_relative 'gitaly_setup'
+require_relative '../../../lib/gitlab/setup_helper'
module TestEnv
extend self
@@ -233,7 +234,7 @@ module TestEnv
end
def workhorse_dir
- @workhorse_path ||= File.join('tmp', 'tests', 'gitlab-workhorse')
+ @workhorse_path ||= Rails.root.join('tmp', 'tests', 'gitlab-workhorse')
end
def with_workhorse(host, port, upstream, &blk)
@@ -371,6 +372,7 @@ module TestEnv
def seed_db
Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.upsert_types
Gitlab::DatabaseImporters::WorkItems::HierarchyRestrictionsImporter.upsert_restrictions
+ FactoryBot.create(:organization, :default)
end
private
diff --git a/spec/support/helpers/test_reports_helper.rb b/spec/support/helpers/test_reports_helper.rb
new file mode 100644
index 00000000000..4c5a1cf3c74
--- /dev/null
+++ b/spec/support/helpers/test_reports_helper.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module TestReportsHelper
+ def create_test_case_rspec_success(name = 'test_spec')
+ Gitlab::Ci::Reports::TestCase.new(
+ suite_name: 'rspec',
+ name: 'Test#sum when a is 1 and b is 3 returns summary',
+ classname: "spec.#{name}",
+ file: './spec/test_spec.rb',
+ execution_time: 1.11,
+ status: Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS)
+ end
+
+ def create_test_case_rspec_failed(name = 'test_spec', execution_time = 2.22)
+ Gitlab::Ci::Reports::TestCase.new(
+ suite_name: 'rspec',
+ name: 'Test#sum when a is 1 and b is 3 returns summary',
+ classname: "spec.#{name}",
+ file: './spec/test_spec.rb',
+ execution_time: execution_time,
+ system_output: sample_rspec_failed_message,
+ status: Gitlab::Ci::Reports::TestCase::STATUS_FAILED)
+ end
+
+ def create_test_case_rspec_skipped(name = 'test_spec')
+ Gitlab::Ci::Reports::TestCase.new(
+ suite_name: 'rspec',
+ name: 'Test#sum when a is 3 and b is 3 returns summary',
+ classname: "spec.#{name}",
+ file: './spec/test_spec.rb',
+ execution_time: 3.33,
+ status: Gitlab::Ci::Reports::TestCase::STATUS_SKIPPED)
+ end
+
+ def create_test_case_rspec_error(name = 'test_spec')
+ Gitlab::Ci::Reports::TestCase.new(
+ suite_name: 'rspec',
+ name: 'Test#sum when a is 4 and b is 4 returns summary',
+ classname: "spec.#{name}",
+ file: './spec/test_spec.rb',
+ execution_time: 4.44,
+ status: Gitlab::Ci::Reports::TestCase::STATUS_ERROR)
+ end
+
+ def sample_rspec_failed_message
+ <<-TEST_REPORT_MESSAGE.strip_heredoc
+ Failure/Error: is_expected.to eq(3)
+
+ expected: 3
+ got: -1
+
+ (compared using ==)
+ ./spec/test_spec.rb:12:in `block (4 levels) in &lt;top (required)&gt;&apos;
+ TEST_REPORT_MESSAGE
+ end
+
+ def create_test_case_java_success(name = 'addTest')
+ Gitlab::Ci::Reports::TestCase.new(
+ suite_name: 'java',
+ name: name,
+ classname: 'CalculatorTest',
+ execution_time: 5.55,
+ status: Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS)
+ end
+
+ def create_test_case_java_failed(name = 'addTest')
+ Gitlab::Ci::Reports::TestCase.new(
+ suite_name: 'java',
+ name: name,
+ classname: 'CalculatorTest',
+ execution_time: 6.66,
+ system_output: sample_java_failed_message,
+ status: Gitlab::Ci::Reports::TestCase::STATUS_FAILED)
+ end
+
+ def create_test_case_java_skipped(name = 'addTest')
+ Gitlab::Ci::Reports::TestCase.new(
+ suite_name: 'java',
+ name: name,
+ classname: 'CalculatorTest',
+ execution_time: 7.77,
+ status: Gitlab::Ci::Reports::TestCase::STATUS_SKIPPED)
+ end
+
+ def create_test_case_java_error(name = 'addTest')
+ Gitlab::Ci::Reports::TestCase.new(
+ suite_name: 'java',
+ name: name,
+ classname: 'CalculatorTest',
+ execution_time: 8.88,
+ status: Gitlab::Ci::Reports::TestCase::STATUS_ERROR)
+ end
+
+ def sample_java_failed_message
+ <<-TEST_REPORT_MESSAGE.strip_heredoc
+ junit.framework.AssertionFailedError: expected:&lt;1&gt; but was:&lt;3&gt;
+ at CalculatorTest.subtractExpression(Unknown Source)
+ at java.base/jdk.internal.database.NativeMethodAccessorImpl.invoke0(Native Method)
+ at java.base/jdk.internal.database.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
+ at java.base/jdk.internal.database.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
+ TEST_REPORT_MESSAGE
+ end
+end
diff --git a/spec/support/trace/trace_helpers.rb b/spec/support/helpers/trace_helpers.rb
index 9255715ff71..9255715ff71 100644
--- a/spec/support/trace/trace_helpers.rb
+++ b/spec/support/helpers/trace_helpers.rb
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index 2bec945fbc8..a1c25338312 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -7,7 +7,6 @@ module UsageDataHelpers
ci_external_pipelines
ci_pipeline_config_auto_devops
ci_pipeline_config_repository
- ci_runners
ci_triggers
ci_pipeline_schedules
auto_devops_enabled
@@ -54,8 +53,6 @@ module UsageDataHelpers
projects_asana_active
projects_jenkins_active
projects_jira_active
- projects_jira_server_active
- projects_jira_cloud_active
projects_jira_dvcs_cloud_active
projects_jira_dvcs_server_active
projects_slack_active
@@ -86,15 +83,9 @@ module UsageDataHelpers
).freeze
USAGE_DATA_KEYS = %i(
- active_user_count
counts
counts_monthly
recorded_at
- edition
- version
- installation_type
- uuid
- hostname
mattermost_enabled
signup_enabled
ldap_enabled
@@ -122,14 +113,14 @@ module UsageDataHelpers
end
def stub_prometheus_queries
- stub_request(:get, %r{^https?://::1:9090/-/ready})
+ stub_request(:get, %r{^https?://.*:9090/-/ready})
.to_return(
status: 200,
body: [{}].to_json,
headers: { 'Content-Type' => 'application/json' }
)
- stub_request(:get, %r{^https?://::1:9090/api/v1/query\?query=.*})
+ stub_request(:get, %r{^https?://.*:9090/api/v1/query\?query=.*})
.to_return(
status: 200,
body: [{}].to_json,
diff --git a/spec/support/helpers/user_login_helper.rb b/spec/support/helpers/user_login_helper.rb
index 47e858cb68c..d8368a94ad7 100644
--- a/spec/support/helpers/user_login_helper.rb
+++ b/spec/support/helpers/user_login_helper.rb
@@ -30,4 +30,20 @@ module UserLoginHelper
def ensure_one_active_pane
expect(page).to have_selector('.tab-pane.active', count: 1)
end
+
+ def ensure_remember_me_in_tab(tab_name)
+ find_link(tab_name).click
+
+ within '.tab-pane.active' do
+ expect(page).to have_content _('Remember me')
+ end
+ end
+
+ def ensure_remember_me_not_in_tab(tab_name)
+ find_link(tab_name).click
+
+ within '.tab-pane.active' do
+ expect(page).not_to have_content _('Remember me')
+ end
+ end
end
diff --git a/spec/support/helpers/wait_for_requests.rb b/spec/support/helpers/wait_for_requests.rb
index 8fd9bb47053..5e2e8ad53e0 100644
--- a/spec/support/helpers/wait_for_requests.rb
+++ b/spec/support/helpers/wait_for_requests.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
+require_relative 'wait_helpers'
+
module WaitForRequests
+ include WaitHelpers
extend self
# This is inspired by http://www.salsify.com/blog/engineering/tearing-capybara-ajax-tests
diff --git a/spec/support/helpers/workhorse_helpers.rb b/spec/support/helpers/workhorse_helpers.rb
index f894aff373c..f3b1d3af501 100644
--- a/spec/support/helpers/workhorse_helpers.rb
+++ b/spec/support/helpers/workhorse_helpers.rb
@@ -29,31 +29,48 @@ module WorkhorseHelpers
# workhorse_form_with_file will transform file_key inside params as if it was disk accelerated by workhorse
def workhorse_form_with_file(url, file_key:, params:, method: :post)
- workhorse_request_with_file(method, url,
- file_key: file_key,
- params: params,
- env: { 'CONTENT_TYPE' => 'multipart/form-data' },
- send_rewritten_field: true
+ workhorse_request_with_file(
+ method, url,
+ file_key: file_key,
+ params: params,
+ env: { 'CONTENT_TYPE' => 'multipart/form-data' },
+ send_rewritten_field: true
)
end
# workhorse_finalize will transform file_key inside params as if it was the finalize call of an inline object storage upload.
# note that based on the content of the params it can simulate a disc acceleration or an object storage upload
def workhorse_finalize(url, file_key:, params:, method: :post, headers: {}, send_rewritten_field: false)
- workhorse_finalize_with_multiple_files(url, method: method, file_keys: file_key, params: params, headers: headers, send_rewritten_field: send_rewritten_field)
+ workhorse_finalize_with_multiple_files(
+ url,
+ method: method,
+ file_keys: file_key,
+ params: params,
+ headers: headers,
+ send_rewritten_field: send_rewritten_field
+ )
end
def workhorse_finalize_with_multiple_files(url, file_keys:, params:, method: :post, headers: {}, send_rewritten_field: false)
- workhorse_request_with_multiple_files(method, url,
- file_keys: file_keys,
- params: params,
- extra_headers: headers,
- send_rewritten_field: send_rewritten_field
+ workhorse_request_with_multiple_files(
+ method, url,
+ file_keys: file_keys,
+ params: params,
+ extra_headers: headers,
+ send_rewritten_field: send_rewritten_field
)
end
def workhorse_request_with_file(method, url, file_key:, params:, send_rewritten_field:, env: {}, extra_headers: {})
- workhorse_request_with_multiple_files(method, url, file_keys: file_key, params: params, env: env, extra_headers: extra_headers, send_rewritten_field: send_rewritten_field)
+ workhorse_request_with_multiple_files(
+ method,
+ url,
+ file_keys: file_key,
+ params: params,
+ env: env,
+ extra_headers: extra_headers,
+ send_rewritten_field: send_rewritten_field
+ )
end
def workhorse_request_with_multiple_files(method, url, file_keys:, params:, send_rewritten_field:, env: {}, extra_headers: {})
@@ -118,14 +135,15 @@ module WorkhorseHelpers
end
end
- def fog_to_uploaded_file(file, sha256: nil)
- filename = File.basename(file.key)
+ def fog_to_uploaded_file(file, filename: nil, sha256: nil, remote_id: nil)
+ filename ||= File.basename(file.key)
- UploadedFile.new(nil,
- filename: filename,
- remote_id: filename,
- size: file.content_length,
- sha256: sha256
- )
+ UploadedFile.new(
+ nil,
+ filename: filename,
+ remote_id: remote_id || filename,
+ size: file.content_length,
+ sha256: sha256
+ )
end
end
diff --git a/spec/support/http_io/http_io_helpers.rb b/spec/support/http_io/http_io_helpers.rb
deleted file mode 100644
index 0193db81fa9..00000000000
--- a/spec/support/http_io/http_io_helpers.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-# frozen_string_literal: true
-
-module HttpIOHelpers
- def stub_remote_url_206(url, file_path)
- WebMock.stub_request(:get, url)
- .to_return { |request| remote_url_response(file_path, request, 206) }
- end
-
- def stub_remote_url_200(url, file_path)
- WebMock.stub_request(:get, url)
- .to_return { |request| remote_url_response(file_path, request, 200) }
- end
-
- def stub_remote_url_500(url)
- WebMock.stub_request(:get, url)
- .to_return(status: [500, "Internal Server Error"])
- end
-
- def remote_url_response(file_path, request, response_status)
- range = request.headers['Range'].match(/bytes=(\d+)-(\d+)/)
-
- body = File.read(file_path).force_encoding(Encoding::BINARY)
- size = body.bytesize
-
- {
- status: response_status,
- headers: remote_url_response_headers(response_status, range[1].to_i, range[2].to_i, size),
- body: body[range[1].to_i..range[2].to_i]
- }
- end
-
- def remote_url_response_headers(response_status, from, to, size)
- { 'Content-Type' => 'text/plain' }.tap do |headers|
- if response_status == 206
- headers.merge('Content-Range' => "bytes #{from}-#{to}/#{size}")
- end
- end
- end
-
- def set_smaller_buffer_size_than(file_size)
- blocks = (file_size / 128)
- new_size = (blocks / 2) * 128
- stub_const("Gitlab::HttpIO::BUFFER_SIZE", new_size)
- end
-
- def set_larger_buffer_size_than(file_size)
- blocks = (file_size / 128)
- new_size = (blocks * 2) * 128
- stub_const("Gitlab::HttpIO::BUFFER_SIZE", new_size)
- end
-end
diff --git a/spec/support/import_export/common_util.rb b/spec/support/import_export/common_util.rb
index f8f32fa59d1..53e943dc3bc 100644
--- a/spec/support/import_export/common_util.rb
+++ b/spec/support/import_export/common_util.rb
@@ -18,14 +18,8 @@ module ImportExport
allow(Gitlab::ImportExport).to receive(:export_path) { export_path }
end
- def setup_reader(reader)
- if reader == :ndjson_reader && Feature.enabled?(:project_import_ndjson)
- allow_any_instance_of(Gitlab::ImportExport::Json::LegacyReader::File).to receive(:exist?).and_return(false)
- allow_any_instance_of(Gitlab::ImportExport::Json::NdjsonReader).to receive(:exist?).and_return(true)
- else
- allow_any_instance_of(Gitlab::ImportExport::Json::LegacyReader::File).to receive(:exist?).and_return(true)
- allow_any_instance_of(Gitlab::ImportExport::Json::NdjsonReader).to receive(:exist?).and_return(false)
- end
+ def setup_reader
+ allow_any_instance_of(Gitlab::ImportExport::Json::NdjsonReader).to receive(:exist?).and_return(true)
end
def fixtures_path
@@ -36,19 +30,12 @@ module ImportExport
"tmp/tests/gitlab-test/import_export"
end
- def get_json(path, exportable_path, key, ndjson_enabled)
- if ndjson_enabled
- json = if key == :projects
- consume_attributes(path, exportable_path)
- else
- consume_relations(path, exportable_path, key)
- end
+ def get_json(path, exportable_path, key)
+ if key == :projects
+ consume_attributes(path, exportable_path)
else
- json = project_json(path)
- json = json[key.to_s] unless key == :projects
+ consume_relations(path, exportable_path, key)
end
-
- json
end
def restore_then_save_project(project, user, import_path:, export_path:)
diff --git a/spec/support/import_export/export_file_helper.rb b/spec/support/import_export/export_file_helper.rb
index 9a26f50903f..ee1b4a3c33a 100644
--- a/spec/support/import_export/export_file_helper.rb
+++ b/spec/support/import_export/export_file_helper.rb
@@ -21,21 +21,25 @@ module ExportFileHelper
create(:label_link, label: label, target: issue)
- ci_pipeline = create(:ci_pipeline,
- project: project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch,
- statuses: [commit_status])
+ ci_pipeline = create(
+ :ci_pipeline,
+ project: project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch,
+ statuses: [commit_status]
+ )
create(:ci_build, pipeline: ci_pipeline, project: project)
create(:milestone, project: project)
create(:note, noteable: issue, project: project)
create(:note, noteable: merge_request, project: project)
create(:note, noteable: snippet, project: project)
- create(:note_on_commit,
- author: user,
- project: project,
- commit_id: ci_pipeline.sha)
+ create(
+ :note_on_commit,
+ author: user,
+ project: project,
+ commit_id: ci_pipeline.sha
+ )
event = create(:event, :created, target: milestone, project: project, author: user, action: 5)
create(:push_event_payload, event: event)
diff --git a/spec/support/matchers/background_migrations_matchers.rb b/spec/support/matchers/background_migrations_matchers.rb
index 9f39f576b95..97993b158c8 100644
--- a/spec/support/matchers/background_migrations_matchers.rb
+++ b/spec/support/matchers/background_migrations_matchers.rb
@@ -100,3 +100,17 @@ RSpec::Matchers.define :be_finalize_background_migration_of do |migration|
end
end
end
+
+RSpec::Matchers.define :ensure_batched_background_migration_is_finished_for do |migration_arguments|
+ define_method :matches? do |klass|
+ expect_next_instance_of(klass) do |instance|
+ expect(instance).to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+ end
+
+ define_method :does_not_match? do |klass|
+ expect_next_instance_of(klass) do |instance|
+ expect(instance).not_to receive(:ensure_batched_background_migration_is_finished).with(migration_arguments)
+ end
+ end
+end
diff --git a/spec/support/matchers/be_a_foreign_key_column_of.rb b/spec/support/matchers/be_a_foreign_key_column_of.rb
new file mode 100644
index 00000000000..af190991216
--- /dev/null
+++ b/spec/support/matchers/be_a_foreign_key_column_of.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# Assert all the given id columns are one of the foreign key columns:
+#
+# ```
+# id_columns = ['partition_id']
+# composite_keys = [['partition_id', 'build_id', 'name']]
+# expect(id_columns).to be_a_foreign_key_column_of(composite_keys)
+# ```
+#
+RSpec::Matchers.define :be_a_foreign_key_column_of do |composite_keys|
+ match do |id_columns|
+ id_columns.all? do |id_column|
+ composite_keys.any? do |composite_key|
+ composite_key.include?(id_column)
+ end
+ end
+ end
+end
diff --git a/spec/support/matchers/be_indexed_by.rb b/spec/support/matchers/be_indexed_by.rb
new file mode 100644
index 00000000000..ae955624ae9
--- /dev/null
+++ b/spec/support/matchers/be_indexed_by.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+# Assert all the given foreign keys are indexed:
+#
+# ```
+# composite_foreign_keys = [['build_id', 'partition_id']]
+# indexed_columns = [['build_id', 'name', 'partition_id'], ['partition_id', 'build_id', 'name']]
+# expect(composite_foreign_keys).to be_indexed_by(indexed_columns)
+# ```
+#
+RSpec::Matchers.define :be_indexed_by do |indexed_columns|
+ match do |composite_foreign_keys|
+ composite_foreign_keys.all? do |composite_foreign_key|
+ indexed_columns.any? do |columns|
+ # for example, [build_id, partition_id] should be covered by indexes e.g.
+ # - [build_id, partition_id, name]
+ # - [partition_id, build_id, name]
+ # but not by [build_id, name, partition_id]
+ # therefore, we just need to take the first few columns (same length as composite key)
+ # e.g. [partition_id, build_id] of [partition_id, build_id, name]
+ # and compare with [build_id, partition_id]
+ (composite_foreign_key - columns.first(composite_foreign_key.length)).blank?
+ end
+ end
+ end
+end
diff --git a/spec/support/matchers/exceed_redis_call_limit.rb b/spec/support/matchers/exceed_redis_call_limit.rb
new file mode 100644
index 00000000000..2b1e1ebad23
--- /dev/null
+++ b/spec/support/matchers/exceed_redis_call_limit.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module ExceedRedisCallLimitHelpers
+ def build_recorder(block)
+ return block if block.is_a?(RedisCommands::Recorder)
+
+ RedisCommands::Recorder.new(&block)
+ end
+
+ def verify_count(expected, block)
+ @actual = build_recorder(block).count
+
+ @actual > expected
+ end
+
+ def verify_commands_count(command, expected, block)
+ @actual = build_recorder(block).by_command(command).count
+
+ @actual > expected
+ end
+end
+
+RSpec::Matchers.define :exceed_redis_calls_limit do |expected|
+ supports_block_expectations
+
+ include ExceedRedisCallLimitHelpers
+
+ match do |block|
+ verify_count(expected, block)
+ end
+
+ failure_message do
+ "Expected at least #{expected} calls, but got #{actual}"
+ end
+
+ failure_message_when_negated do
+ "Expected a maximum of #{expected} calls, but got #{actual}"
+ end
+end
+
+RSpec::Matchers.define :exceed_redis_command_calls_limit do |command, expected|
+ supports_block_expectations
+
+ include ExceedRedisCallLimitHelpers
+
+ match do |block|
+ verify_commands_count(command, expected, block)
+ end
+
+ failure_message do
+ "Expected at least #{expected} calls to '#{command}', but got #{actual}"
+ end
+
+ failure_message_when_negated do
+ "Expected a maximum of #{expected} calls to '#{command}', but got #{actual}"
+ end
+end
diff --git a/spec/support/matchers/have_plain_text_content.rb b/spec/support/matchers/have_plain_text_content.rb
new file mode 100644
index 00000000000..94f65ce3771
--- /dev/null
+++ b/spec/support/matchers/have_plain_text_content.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# can be replaced with https://github.com/email-spec/email-spec/pull/196 in the future
+RSpec::Matchers.define :have_plain_text_content do |expected_text|
+ match do |actual_email|
+ plain_text_body(actual_email).include? expected_text
+ end
+
+ failure_message do |actual_email|
+ "Expected email\n#{plain_text_body(actual_email).indent(2)}\nto contain\n#{expected_text.indent(2)}"
+ end
+
+ def plain_text_body(email)
+ email.text_part.body.to_s
+ end
+end
diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb
index a80c269f915..8fdece7b26d 100644
--- a/spec/support/matchers/markdown_matchers.rb
+++ b/spec/support/matchers/markdown_matchers.rb
@@ -115,7 +115,16 @@ module MarkdownMatchers
set_default_markdown_messages
match do |actual|
- expect(actual).to have_selector('a.gfm.gfm-issue', count: 6)
+ expect(actual).to have_selector('a.gfm.gfm-issue', count: 9)
+ end
+ end
+
+ # WorkItemReferenceFilter
+ matcher :reference_work_items do
+ set_default_markdown_messages
+
+ match do |actual|
+ expect(actual).to have_selector('a.gfm.gfm-work_item', count: 2)
end
end
@@ -202,7 +211,7 @@ module MarkdownMatchers
match do |actual|
expect(actual).to have_selector('[data-math-style="inline"]', count: 4)
- expect(actual).to have_selector('[data-math-style="display"]', count: 4)
+ expect(actual).to have_selector('[data-math-style="display"]', count: 6)
end
end
diff --git a/spec/support/matchers/request_urgency_matcher.rb b/spec/support/matchers/request_urgency_matcher.rb
new file mode 100644
index 00000000000..d3c5093719e
--- /dev/null
+++ b/spec/support/matchers/request_urgency_matcher.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+module RequestUrgencyMatcher
+ RSpec::Matchers.define :have_request_urgency do |request_urgency|
+ match do |_actual|
+ if controller_instance = request.env["action_controller.instance"]
+ controller_instance.urgency.name == request_urgency
+ elsif endpoint = request.env['api.endpoint']
+ urgency = endpoint.options[:for].try(:urgency_for_app, endpoint)
+ urgency.name == request_urgency
+ else
+ raise 'neither a controller nor a request spec'
+ end
+ end
+
+ failure_message do |_actual|
+ if controller_instance = request.env["action_controller.instance"]
+ "request urgency #{controller_instance.urgency.name} is set, \
+ but expected to be #{request_urgency}".squish
+ elsif endpoint = request.env['api.endpoint']
+ urgency = endpoint.options[:for].try(:urgency_for_app, endpoint)
+ "request urgency #{urgency.name} is set, \
+ but expected to be #{request_urgency}".squish
+ end
+ end
+ end
+end
diff --git a/spec/support/matchers/snapshot_matcher.rb b/spec/support/matchers/snapshot_matcher.rb
new file mode 100644
index 00000000000..ec1e9cb0815
--- /dev/null
+++ b/spec/support/matchers/snapshot_matcher.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+RSpec::Matchers.define :have_snapshot do |date, expected_states|
+ match do |actual_snapshots|
+ snapshot = actual_snapshots.find { |snapshot| snapshot[:date] == date }
+
+ @snapshot_not_found = snapshot.nil?
+ @item_states_not_found = []
+ @not_eq_error = nil
+
+ break false if @snapshot_not_found
+
+ expected_states.each do |expected_state|
+ actual_state = snapshot[:item_states].find { |state| state[:item_id] == expected_state[:item_id] }
+
+ if actual_state.nil?
+ @item_states_not_found << expected_state[:issue_id]
+ else
+ default_state = {
+ weight: 0,
+ start_state: ResourceStateEvent.states[:opened],
+ end_state: ResourceStateEvent.states[:opened],
+ parent_id: nil,
+ children_ids: Set.new
+ }
+ begin
+ expect(actual_state).to eq(default_state.merge(expected_state))
+ rescue RSpec::Expectations::ExpectationNotMetError => e
+ @error_item_title = WorkItem.find(expected_state[:item_id]).title
+ @not_eq_error = e
+
+ raise
+ end
+ end
+ end
+ end
+
+ failure_message do |_|
+ break "No snapshot found for the given date #{date}" if @snapshot_not_found
+
+ messages = []
+
+ messages << <<~MESSAGE
+ Expected the snapshot on #{date} to match the expected snapshot.
+
+ Errors:
+ MESSAGE
+
+ messages << "Item states not found for: #{@item_states_not_found.join(', ')}" unless @item_states_not_found.empty?
+
+ messages << "`#{@error_item_title}` does not have the expected states.\n#{@not_eq_error}" if @not_eq_error
+
+ messages.join("\n")
+ end
+end
diff --git a/spec/support/migrations_helpers/namespaces_helper.rb b/spec/support/migrations_helpers/namespaces_helper.rb
deleted file mode 100644
index c62ef6a4620..00000000000
--- a/spec/support/migrations_helpers/namespaces_helper.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-module MigrationHelpers
- module NamespacesHelpers
- def create_namespace(name, visibility, options = {})
- table(:namespaces).create!({
- name: name,
- path: name,
- type: 'Group',
- visibility_level: visibility
- }.merge(options))
- end
- end
-end
diff --git a/spec/support/migrations_helpers/schema_version_finder.rb b/spec/support/migrations_helpers/schema_version_finder.rb
deleted file mode 100644
index b677db7ea26..00000000000
--- a/spec/support/migrations_helpers/schema_version_finder.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-# Sometimes data migration specs require adding invalid test data in order to test
-# the migration (e.g. adding a row with null foreign key). Certain db migrations that
-# add constraints (e.g. NOT NULL constraint) prevent invalid records from being added
-# and data migration from being tested. For this reason, SchemaVersionFinder can be used
-# to find and use schema prior to specified one.
-#
-# @example
-# RSpec.describe CleanupThings, :migration, schema: MigrationHelpers::SchemaVersionFinder.migration_prior(AddNotNullConstraint) do ...
-#
-# SchemaVersionFinder returns schema version prior to the one specified, which allows to then add
-# invalid records to the database, which in return allows to properly test data migration.
-module MigrationHelpers
- class SchemaVersionFinder
- def self.migrations_paths
- ActiveRecord::Migrator.migrations_paths
- end
-
- def self.migration_context
- ActiveRecord::MigrationContext.new(migrations_paths, ActiveRecord::SchemaMigration)
- end
-
- def self.migrations
- migration_context.migrations
- end
-
- def self.migration_prior(migration_klass)
- migrations.each_cons(2) do |previous, migration|
- break previous.version if migration.name == migration_klass.name
- end
- end
- end
-end
diff --git a/spec/support/migrations_helpers/vulnerabilities_findings_helper.rb b/spec/support/migrations_helpers/vulnerabilities_findings_helper.rb
deleted file mode 100644
index 9a5313c3fa4..00000000000
--- a/spec/support/migrations_helpers/vulnerabilities_findings_helper.rb
+++ /dev/null
@@ -1,118 +0,0 @@
-# frozen_string_literal: true
-
-module MigrationHelpers
- module VulnerabilitiesFindingsHelper
- def attributes_for_vulnerabilities_finding
- uuid = SecureRandom.uuid
-
- {
- project_fingerprint: SecureRandom.hex(20),
- location_fingerprint: Digest::SHA1.hexdigest(SecureRandom.hex(10)),
- uuid: uuid,
- name: "Vulnerability Finding #{uuid}",
- metadata_version: '1.3',
- raw_metadata: raw_metadata
- }
- end
-
- def raw_metadata
- {
- "description" => "The cipher does not provide data integrity update 1",
- "message" => "The cipher does not provide data integrity",
- "cve" => "818bf5dacb291e15d9e6dc3c5ac32178:CIPHER",
- "solution" => "GCM mode introduces an HMAC into the resulting encrypted data, providing integrity of the result.",
- "location" => {
- "file" => "maven/src/main/java/com/gitlab/security_products/tests/App.java",
- "start_line" => 29,
- "end_line" => 29,
- "class" => "com.gitlab.security_products.tests.App",
- "method" => "insecureCypher"
- },
- "links" => [
- {
- "name" => "Cipher does not check for integrity first?",
- "url" => "https://crypto.stackexchange.com/questions/31428/pbewithmd5anddes-cipher-does-not-check-for-integrity-first"
- }
- ],
- "assets" => [
- {
- "type" => "postman",
- "name" => "Test Postman Collection",
- "url" => "http://localhost/test.collection"
- }
- ],
- "evidence" => {
- "summary" => "Credit card detected",
- "request" => {
- "method" => "GET",
- "url" => "http://goat:8080/WebGoat/logout",
- "body" => nil,
- "headers" => [
- {
- "name" => "Accept",
- "value" => "*/*"
- }
- ]
- },
- "response" => {
- "reason_phrase" => "OK",
- "status_code" => 200,
- "body" => nil,
- "headers" => [
- {
- "name" => "Content-Length",
- "value" => "0"
- }
- ]
- },
- "source" => {
- "id" => "assert:Response Body Analysis",
- "name" => "Response Body Analysis",
- "url" => "htpp://hostname/documentation"
- },
- "supporting_messages" => [
- {
- "name" => "Origional",
- "request" => {
- "method" => "GET",
- "url" => "http://goat:8080/WebGoat/logout",
- "body" => "",
- "headers" => [
- {
- "name" => "Accept",
- "value" => "*/*"
- }
- ]
- }
- },
- {
- "name" => "Recorded",
- "request" => {
- "method" => "GET",
- "url" => "http://goat:8080/WebGoat/logout",
- "body" => "",
- "headers" => [
- {
- "name" => "Accept",
- "value" => "*/*"
- }
- ]
- },
- "response" => {
- "reason_phrase" => "OK",
- "status_code" => 200,
- "body" => "",
- "headers" => [
- {
- "name" => "Content-Length",
- "value" => "0"
- }
- ]
- }
- }
- ]
- }
- }
- end
- end
-end
diff --git a/spec/support/models/ci/partitioning_testing/cascade_check.rb b/spec/support/models/ci/partitioning_testing/cascade_check.rb
deleted file mode 100644
index bcfc9675476..00000000000
--- a/spec/support/models/ci/partitioning_testing/cascade_check.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-module PartitioningTesting
- module CascadeCheck
- extend ActiveSupport::Concern
-
- included do
- after_create :check_partition_cascade_value
- end
-
- def check_partition_cascade_value
- raise 'Partition value not found' unless partition_scope_value
-
- return if partition_id == partition_scope_value
-
- raise "partition_id was expected to equal #{partition_scope_value} but it was #{partition_id}."
- end
-
- class_methods do
- # Allowing partition callback to be used with BulkInsertSafe
- def _bulk_insert_callback_allowed?(name, args)
- super || args.first == :after && args.second == :check_partition_cascade_value
- end
- end
- end
-end
-
-Ci::Partitionable::Testing::PARTITIONABLE_MODELS.each do |klass|
- next if klass == 'Ci::Pipeline'
-
- model = klass.safe_constantize
-
- model.include(PartitioningTesting::CascadeCheck)
-end
diff --git a/spec/support/models/ci/partitioning_testing/rspec_hooks.rb b/spec/support/models/ci/partitioning_testing/rspec_hooks.rb
deleted file mode 100644
index 39b15ba8721..00000000000
--- a/spec/support/models/ci/partitioning_testing/rspec_hooks.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.configure do |config|
- config.include Ci::PartitioningTesting::PartitionIdentifiers
-
- config.around(:each, :ci_partitionable) do |example|
- Ci::PartitioningTesting::SchemaHelpers.with_routing_tables do
- example.run
- end
- end
-
- config.before(:all) do
- Ci::PartitioningTesting::SchemaHelpers.setup
- end
-
- config.after(:all) do
- Ci::PartitioningTesting::SchemaHelpers.teardown
- end
-end
diff --git a/spec/support/models/merge_request_without_merge_request_diff.rb b/spec/support/models/merge_request_without_merge_request_diff.rb
deleted file mode 100644
index 5cdf1feb7a5..00000000000
--- a/spec/support/models/merge_request_without_merge_request_diff.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-class MergeRequestWithoutMergeRequestDiff < ::MergeRequest
- self.inheritance_column = :_type_disabled
-
- def ensure_merge_request_diff; end
-end
diff --git a/spec/support/permissions_check.rb b/spec/support/permissions_check.rb
new file mode 100644
index 00000000000..efe0ecb530b
--- /dev/null
+++ b/spec/support/permissions_check.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Support
+ module PermissionsCheck
+ def self.inject(mod)
+ mod.prepend PermissionsExtension if Gitlab::Utils.to_boolean(ENV['GITLAB_DEBUG_POLICIES'])
+ end
+
+ module PermissionsExtension
+ def before_check(policy, ability, _user, _subject, _opts)
+ puts(
+ "POLICY CHECK DEBUG -> " \
+ "policy: #{policy.class.name}, ability: #{ability}, called_from: #{caller_locations(2, 5)}"
+ )
+ end
+ end
+ end
+end
diff --git a/spec/support/prometheus/additional_metrics_shared_examples.rb b/spec/support/prometheus/additional_metrics_shared_examples.rb
deleted file mode 100644
index e589baf0909..00000000000
--- a/spec/support/prometheus/additional_metrics_shared_examples.rb
+++ /dev/null
@@ -1,159 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'additional metrics query' do
- include Prometheus::MetricBuilders
-
- let(:metric_group_class) { Gitlab::Prometheus::MetricGroup }
- let(:metric_class) { Gitlab::Prometheus::Metric }
-
- let(:metric_names) { %w{metric_a metric_b} }
-
- let(:query_range_result) do
- [{ 'metric': {}, 'values': [[1488758662.506, '0.00002996364761904785'], [1488758722.506, '0.00003090239047619091']] }]
- end
-
- let(:client) { double('prometheus_client') }
- let(:query_result) { described_class.new(client).query(*query_params) }
- let(:project) { create(:project, :repository) }
- let(:environment) { create(:environment, slug: 'environment-slug', project: project) }
-
- before do
- allow(client).to receive(:label_values).and_return(metric_names)
- allow(metric_group_class).to receive(:common_metrics).and_return([simple_metric_group(metrics: [simple_metric])])
- end
-
- context 'metrics query context' do
- subject! { described_class.new(client) }
-
- shared_examples 'query context containing environment slug and filter' do
- it 'contains ci_environment_slug' do
- expect(subject).to receive(:query_metrics).with(project, environment, hash_including(ci_environment_slug: environment.slug))
-
- subject.query(*query_params)
- end
-
- it 'contains environment filter' do
- expect(subject).to receive(:query_metrics).with(
- project,
- environment,
- hash_including(
- environment_filter: "container_name!=\"POD\",environment=\"#{environment.slug}\""
- )
- )
-
- subject.query(*query_params)
- end
- end
-
- describe 'project has Kubernetes service' do
- context 'when user configured kubernetes from CI/CD > Clusters' do
- let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
- let(:environment) { create(:environment, slug: 'environment-slug', project: project) }
- let(:kube_namespace) { environment.deployment_namespace }
-
- it_behaves_like 'query context containing environment slug and filter'
-
- it 'query context contains kube_namespace' do
- expect(subject).to receive(:query_metrics).with(project, environment, hash_including(kube_namespace: kube_namespace))
-
- subject.query(*query_params)
- end
- end
- end
-
- describe 'project without Kubernetes service' do
- it_behaves_like 'query context containing environment slug and filter'
-
- it 'query context contains empty kube_namespace' do
- expect(subject).to receive(:query_metrics).with(project, environment, hash_including(kube_namespace: ''))
-
- subject.query(*query_params)
- end
- end
- end
-
- context 'with one group where two metrics is found' do
- before do
- allow(metric_group_class).to receive(:common_metrics).and_return([simple_metric_group])
- end
-
- context 'some queries return results' do
- before do
- allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
- allow(client).to receive(:query_range).with('query_range_b', any_args).and_return(query_range_result)
- allow(client).to receive(:query_range).with('query_range_empty', any_args).and_return([])
- end
-
- it 'return group data only for queries with results' do
- expected = [
- {
- group: 'name',
- priority: 1,
- metrics: [
- {
- title: 'title', weight: 1, y_label: 'Values', queries: [
- { query_range: 'query_range_a', result: query_range_result },
- { query_range: 'query_range_b', label: 'label', unit: 'unit', result: query_range_result }
- ]
- }
- ]
- }
- ]
-
- expect(query_result.to_json).to match_schema('prometheus/additional_metrics_query_result')
- expect(query_result).to eq(expected)
- end
- end
- end
-
- context 'with two groups with one metric each' do
- let(:metrics) { [simple_metric(queries: [simple_query])] }
-
- before do
- allow(metric_group_class).to receive(:common_metrics).and_return(
- [
- simple_metric_group(name: 'group_a', metrics: [simple_metric(queries: [simple_query])]),
- simple_metric_group(name: 'group_b', metrics: [simple_metric(title: 'title_b', queries: [simple_query('b')])])
- ])
- allow(client).to receive(:label_values).and_return(metric_names)
- end
-
- context 'both queries return results' do
- before do
- allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
- allow(client).to receive(:query_range).with('query_range_b', any_args).and_return(query_range_result)
- end
-
- it 'return group data both queries' do
- queries_with_result_a = { queries: [{ query_range: 'query_range_a', result: query_range_result }] }
- queries_with_result_b = { queries: [{ query_range: 'query_range_b', result: query_range_result }] }
-
- expect(query_result.to_json).to match_schema('prometheus/additional_metrics_query_result')
-
- expect(query_result.count).to eq(2)
- expect(query_result).to all(satisfy { |r| r[:metrics].count == 1 })
-
- expect(query_result[0][:metrics].first).to include(queries_with_result_a)
- expect(query_result[1][:metrics].first).to include(queries_with_result_b)
- end
- end
-
- context 'one query returns result' do
- before do
- allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
- allow(client).to receive(:query_range).with('query_range_b', any_args).and_return([])
- end
-
- it 'return group data only for query with results' do
- queries_with_result = { queries: [{ query_range: 'query_range_a', result: query_range_result }] }
-
- expect(query_result.to_json).to match_schema('prometheus/additional_metrics_query_result')
-
- expect(query_result.count).to eq(1)
- expect(query_result).to all(satisfy { |r| r[:metrics].count == 1 })
-
- expect(query_result.first[:metrics].first).to include(queries_with_result)
- end
- end
- end
-end
diff --git a/spec/support/prometheus/metric_builders.rb b/spec/support/prometheus/metric_builders.rb
deleted file mode 100644
index 512e32a44d0..00000000000
--- a/spec/support/prometheus/metric_builders.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-module Prometheus
- module MetricBuilders
- def simple_query(suffix = 'a', **opts)
- { query_range: "query_range_#{suffix}" }.merge(opts)
- end
-
- def simple_queries
- [simple_query, simple_query('b', label: 'label', unit: 'unit')]
- end
-
- def simple_metric(title: 'title', required_metrics: [], queries: [simple_query])
- Gitlab::Prometheus::Metric.new(title: title, required_metrics: required_metrics, weight: 1, queries: queries)
- end
-
- def simple_metrics(added_metric_name: 'metric_a')
- [
- simple_metric(required_metrics: %W(#{added_metric_name} metric_b), queries: simple_queries),
- simple_metric(required_metrics: [added_metric_name], queries: [simple_query('empty')]),
- simple_metric(required_metrics: %w{metric_c})
- ]
- end
-
- def simple_metric_group(name: 'name', metrics: simple_metrics)
- Gitlab::Prometheus::MetricGroup.new(name: name, priority: 1, metrics: metrics)
- end
- end
-end
diff --git a/spec/support/protected_branch_helpers.rb b/spec/support/protected_branch_helpers.rb
index b34b9ec4641..d983d03fd2e 100644
--- a/spec/support/protected_branch_helpers.rb
+++ b/spec/support/protected_branch_helpers.rb
@@ -2,18 +2,10 @@
module ProtectedBranchHelpers
def set_allowed_to(operation, option = 'Maintainers', form: '.js-new-protected-branch')
- within form do
- select_elem = find(".js-allowed-to-#{operation}")
- select_elem.click
-
- wait_for_requests
-
- within('.dropdown-content') do
+ within(form) do
+ within_select(".js-allowed-to-#{operation}") do
Array(option).each { |opt| click_on(opt) }
end
-
- # Enhanced select is used in EE, therefore an extra click is needed.
- select_elem.click if select_elem['aria-expanded'] == 'true'
end
end
@@ -32,4 +24,15 @@ module ProtectedBranchHelpers
click_on "Protect"
wait_for_requests
end
+
+ def within_select(selector, &block)
+ select_input = find(selector)
+ select_input.click
+ wait_for_requests
+
+ within('.dropdown.show .dropdown-menu', &block)
+
+ # Enhanced select is used in EE, therefore an extra click is needed.
+ select_input.click if select_input['aria-expanded'] == 'true'
+ end
end
diff --git a/spec/support/protected_tags/access_control_ce_shared_examples.rb b/spec/support/protected_tags/access_control_ce_shared_examples.rb
deleted file mode 100644
index 8666c19481c..00000000000
--- a/spec/support/protected_tags/access_control_ce_shared_examples.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples "protected tags > access control > CE" do
- ProtectedRefAccess::HUMAN_ACCESS_LEVELS.each do |(access_type_id, access_type_name)|
- it "allows creating protected tags that #{access_type_name} can create" do
- visit project_protected_tags_path(project)
-
- set_protected_tag_name('master')
-
- within('.js-new-protected-tag') do
- allowed_to_create_button = find(".js-allowed-to-create")
-
- unless allowed_to_create_button.text == access_type_name
- allowed_to_create_button.click
- find('.create_access_levels-container .dropdown-menu li', match: :first)
- within('.create_access_levels-container .dropdown-menu') { click_on access_type_name }
- end
- end
-
- click_on "Protect"
-
- expect(ProtectedTag.count).to eq(1)
- expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to eq([access_type_id])
- end
-
- it "allows updating protected tags so that #{access_type_name} can create them" do
- visit project_protected_tags_path(project)
-
- set_protected_tag_name('master')
-
- click_on "Protect"
-
- expect(ProtectedTag.count).to eq(1)
-
- within(".protected-tags-list") do
- find(".js-allowed-to-create").click
-
- within('.js-allowed-to-create-container') do
- expect(first("li")).to have_content("Roles")
- click_on access_type_name
- end
- end
-
- wait_for_requests
-
- expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to include(access_type_id)
- end
- end
-end
diff --git a/spec/support/redis/redis_new_instance_shared_examples.rb b/spec/support/redis/redis_new_instance_shared_examples.rb
deleted file mode 100644
index 435d342fcca..00000000000
--- a/spec/support/redis/redis_new_instance_shared_examples.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.shared_examples "redis_new_instance_shared_examples" do |name, fallback_class|
- include TmpdirHelper
-
- let(:instance_specific_config_file) { "config/redis.#{name}.yml" }
- let(:environment_config_file_name) { "GITLAB_REDIS_#{name.upcase}_CONFIG_FILE" }
- let(:fallback_config_file) { nil }
- let(:rails_root) { mktmpdir }
-
- before do
- allow(fallback_class).to receive(:config_file_name).and_return(fallback_config_file)
- end
-
- it_behaves_like "redis_shared_examples"
-
- describe '.config_file_name' do
- subject { described_class.config_file_name }
-
- before do
- # Undo top-level stub of config_file_name because we are testing that method now.
- allow(described_class).to receive(:config_file_name).and_call_original
-
- allow(described_class).to receive(:rails_root).and_return(rails_root)
- FileUtils.mkdir_p(File.join(rails_root, 'config'))
- end
-
- context 'and there is a global env override' do
- before do
- stub_env('GITLAB_REDIS_CONFIG_FILE', 'global override')
- end
-
- it { expect(subject).to eq('global override') }
-
- context "and #{fallback_class.name.demodulize} has a different config file" do
- let(:fallback_config_file) { 'fallback config file' }
-
- it { expect(subject).to eq('fallback config file') }
- end
- end
- end
-
- describe '#fetch_config' do
- subject { described_class.new('test').send(:fetch_config) }
-
- before do
- FileUtils.mkdir_p(File.join(rails_root, 'config'))
-
- allow(described_class).to receive(:rails_root).and_return(rails_root)
- end
-
- context 'when redis.yml exists' do
- before do
- allow(described_class).to receive(:config_file_name).and_call_original
- allow(described_class).to receive(:redis_yml_path).and_call_original
- end
-
- context 'when the fallback has a redis.yml entry' do
- before do
- File.write(File.join(rails_root, 'config/redis.yml'), {
- 'test' => {
- described_class.config_fallback.store_name.underscore => { 'fallback redis.yml' => 123 }
- }
- }.to_json)
- end
-
- it { expect(subject).to eq({ 'fallback redis.yml' => 123 }) }
-
- context 'and an instance config file exists' do
- before do
- File.write(File.join(rails_root, instance_specific_config_file), {
- 'test' => { 'instance specific file' => 456 }
- }.to_json)
- end
-
- it { expect(subject).to eq({ 'instance specific file' => 456 }) }
-
- context 'and the instance has a redis.yml entry' do
- before do
- File.write(File.join(rails_root, 'config/redis.yml'), {
- 'test' => { name => { 'instance redis.yml' => 789 } }
- }.to_json)
- end
-
- it { expect(subject).to eq({ 'instance redis.yml' => 789 }) }
- end
- end
- end
- end
-
- context 'when no redis config file exsits' do
- it 'returns nil' do
- expect(subject).to eq(nil)
- end
-
- context 'when resque.yml exists' do
- before do
- File.write(File.join(rails_root, 'config/resque.yml'), {
- 'test' => { 'foobar' => 123 }
- }.to_json)
- end
-
- it 'returns the config from resque.yml' do
- expect(subject).to eq({ 'foobar' => 123 })
- end
- end
- end
- end
-end
diff --git a/spec/support/redis/redis_shared_examples.rb b/spec/support/redis/redis_shared_examples.rb
deleted file mode 100644
index 8c195a9dbeb..00000000000
--- a/spec/support/redis/redis_shared_examples.rb
+++ /dev/null
@@ -1,459 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples "redis_shared_examples" do
- include StubENV
- include TmpdirHelper
-
- let(:test_redis_url) { "redis://redishost:#{redis_port}" }
- let(:test_cluster_config) { { cluster: [{ host: "redis://redishost", port: redis_port }] } }
- let(:config_file_name) { instance_specific_config_file }
- let(:config_old_format_socket) { "spec/fixtures/config/redis_old_format_socket.yml" }
- let(:config_new_format_socket) { "spec/fixtures/config/redis_new_format_socket.yml" }
- let(:old_socket_path) { "/path/to/old/redis.sock" }
- let(:new_socket_path) { "/path/to/redis.sock" }
- let(:config_old_format_host) { "spec/fixtures/config/redis_old_format_host.yml" }
- let(:config_new_format_host) { "spec/fixtures/config/redis_new_format_host.yml" }
- let(:config_cluster_format_host) { "spec/fixtures/config/redis_cluster_format_host.yml" }
- let(:redis_port) { 6379 }
- let(:redis_database) { 99 }
- let(:sentinel_port) { 26379 }
- let(:config_with_environment_variable_inside) { "spec/fixtures/config/redis_config_with_env.yml" }
- let(:config_env_variable_url) { "TEST_GITLAB_REDIS_URL" }
- let(:rails_root) { mktmpdir }
-
- before do
- allow(described_class).to receive(:config_file_name).and_return(Rails.root.join(config_file_name).to_s)
- allow(described_class).to receive(:redis_yml_path).and_return('/dev/null')
- end
-
- describe '.config_file_name' do
- subject { described_class.config_file_name }
-
- before do
- # Undo top-level stub of config_file_name because we are testing that method now.
- allow(described_class).to receive(:config_file_name).and_call_original
-
- allow(described_class).to receive(:rails_root).and_return(rails_root)
- FileUtils.mkdir_p(File.join(rails_root, 'config'))
- end
-
- context 'when there is no config file anywhere' do
- it { expect(subject).to be_nil }
-
- context 'and there is a global env override' do
- before do
- stub_env('GITLAB_REDIS_CONFIG_FILE', 'global override')
- end
-
- it { expect(subject).to eq('global override') }
-
- context 'and there is an instance specific config file' do
- before do
- FileUtils.touch(File.join(rails_root, instance_specific_config_file))
- end
-
- it { expect(subject).to eq("#{rails_root}/#{instance_specific_config_file}") }
-
- it 'returns a path that exists' do
- expect(File.file?(subject)).to eq(true)
- end
-
- context 'and there is a specific env override' do
- before do
- stub_env(environment_config_file_name, 'instance specific override')
- end
-
- it { expect(subject).to eq('instance specific override') }
- end
- end
- end
- end
- end
-
- describe '.store' do
- let(:rails_env) { 'development' }
-
- subject { described_class.new(rails_env).store }
-
- shared_examples 'redis store' do
- let(:redis_store) { ::Redis::Store }
- let(:redis_store_to_s) { "Redis Client connected to #{host} against DB #{redis_database}" }
-
- it 'instantiates Redis::Store' do
- is_expected.to be_a(redis_store)
-
- expect(subject.to_s).to eq(redis_store_to_s)
- end
-
- context 'with the namespace' do
- let(:namespace) { 'namespace_name' }
- let(:redis_store_to_s) { "Redis Client connected to #{host} against DB #{redis_database} with namespace #{namespace}" }
-
- subject { described_class.new(rails_env).store(namespace: namespace) }
-
- it "uses specified namespace" do
- expect(subject.to_s).to eq(redis_store_to_s)
- end
- end
- end
-
- context 'with old format' do
- it_behaves_like 'redis store' do
- let(:config_file_name) { config_old_format_host }
- let(:host) { "localhost:#{redis_port}" }
- end
- end
-
- context 'with new format' do
- it_behaves_like 'redis store' do
- let(:config_file_name) { config_new_format_host }
- let(:host) { "development-host:#{redis_port}" }
- end
- end
- end
-
- describe '.params' do
- subject { described_class.new(rails_env).params }
-
- let(:rails_env) { 'development' }
- let(:config_file_name) { config_old_format_socket }
-
- it 'withstands mutation' do
- params1 = described_class.params
- params2 = described_class.params
- params1[:foo] = :bar
-
- expect(params2).not_to have_key(:foo)
- end
-
- context 'when url contains unix socket reference' do
- context 'with old format' do
- let(:config_file_name) { config_old_format_socket }
-
- it 'returns path key instead' do
- is_expected.to include(path: old_socket_path)
- is_expected.not_to have_key(:url)
- end
- end
-
- context 'with new format' do
- let(:config_file_name) { config_new_format_socket }
-
- it 'returns path key instead' do
- is_expected.to include(path: new_socket_path)
- is_expected.not_to have_key(:url)
- end
- end
- end
-
- context 'when url is host based' do
- context 'with old format' do
- let(:config_file_name) { config_old_format_host }
-
- it 'returns hash with host, port, db, and password' do
- is_expected.to include(host: 'localhost', password: 'mypassword', port: redis_port, db: redis_database)
- is_expected.not_to have_key(:url)
- end
- end
-
- context 'with new format' do
- let(:config_file_name) { config_new_format_host }
-
- where(:rails_env, :host) do
- [
- %w[development development-host],
- %w[test test-host],
- %w[production production-host]
- ]
- end
-
- with_them do
- it 'returns hash with host, port, db, and password' do
- is_expected.to include(host: host, password: 'mynewpassword', port: redis_port, db: redis_database)
- is_expected.not_to have_key(:url)
- end
- end
- end
-
- context 'with redis cluster format' do
- let(:config_file_name) { config_cluster_format_host }
-
- where(:rails_env, :host) do
- [
- %w[development development-master],
- %w[test test-master],
- %w[production production-master]
- ]
- end
-
- with_them do
- it 'returns hash with cluster and password' do
- is_expected.to include(password: 'myclusterpassword',
- cluster: [
- { host: "#{host}1", port: redis_port },
- { host: "#{host}2", port: redis_port }
- ]
- )
- is_expected.not_to have_key(:url)
- end
- end
- end
- end
- end
-
- describe '.url' do
- let(:config_file_name) { config_old_format_socket }
-
- it 'withstands mutation' do
- url1 = described_class.url
- url2 = described_class.url
- url1 << 'foobar' unless url1.frozen?
-
- expect(url2).not_to end_with('foobar')
- end
-
- context 'when yml file with env variable' do
- let(:config_file_name) { config_with_environment_variable_inside }
-
- before do
- stub_env(config_env_variable_url, test_redis_url)
- end
-
- it 'reads redis url from env variable' do
- expect(described_class.url).to eq test_redis_url
- end
- end
- end
-
- describe '.version' do
- it 'returns a version' do
- expect(described_class.version).to be_present
- end
- end
-
- describe '.with' do
- let(:config_file_name) { config_old_format_socket }
-
- before do
- clear_pool
- end
- after do
- clear_pool
- end
-
- context 'when running on single-threaded runtime' do
- before do
- allow(Gitlab::Runtime).to receive(:multi_threaded?).and_return(false)
- end
-
- it 'instantiates a connection pool with size 5' do
- expect(ConnectionPool).to receive(:new).with(size: 5).and_call_original
-
- described_class.with { |_redis_shared_example| true }
- end
- end
-
- context 'when running on multi-threaded runtime' do
- before do
- allow(Gitlab::Runtime).to receive(:multi_threaded?).and_return(true)
- allow(Gitlab::Runtime).to receive(:max_threads).and_return(18)
- end
-
- it 'instantiates a connection pool with a size based on the concurrency of the worker' do
- expect(ConnectionPool).to receive(:new).with(size: 18 + 5).and_call_original
-
- described_class.with { |_redis_shared_example| true }
- end
- end
-
- context 'when there is no config at all' do
- before do
- # Undo top-level stub of config_file_name because we are testing that method now.
- allow(described_class).to receive(:config_file_name).and_call_original
-
- allow(described_class).to receive(:rails_root).and_return(rails_root)
- end
-
- it 'can run an empty block' do
- expect { described_class.with { nil } }.not_to raise_error
- end
- end
- end
-
- describe '#db' do
- let(:rails_env) { 'development' }
-
- subject { described_class.new(rails_env).db }
-
- context 'with old format' do
- let(:config_file_name) { config_old_format_host }
-
- it 'returns the correct db' do
- expect(subject).to eq(redis_database)
- end
- end
-
- context 'with new format' do
- let(:config_file_name) { config_new_format_host }
-
- it 'returns the correct db' do
- expect(subject).to eq(redis_database)
- end
- end
-
- context 'with cluster-mode' do
- let(:config_file_name) { config_cluster_format_host }
-
- it 'returns the correct db' do
- expect(subject).to eq(0)
- end
- end
- end
-
- describe '#sentinels' do
- subject { described_class.new(rails_env).sentinels }
-
- let(:rails_env) { 'development' }
-
- context 'when sentinels are defined' do
- let(:config_file_name) { config_new_format_host }
-
- where(:rails_env, :hosts) do
- [
- ['development', %w[development-replica1 development-replica2]],
- ['test', %w[test-replica1 test-replica2]],
- ['production', %w[production-replica1 production-replica2]]
- ]
- end
-
- with_them do
- it 'returns an array of hashes with host and port keys' do
- is_expected.to include(host: hosts[0], port: sentinel_port)
- is_expected.to include(host: hosts[1], port: sentinel_port)
- end
- end
- end
-
- context 'when sentinels are not defined' do
- let(:config_file_name) { config_old_format_host }
-
- it 'returns nil' do
- is_expected.to be_nil
- end
- end
-
- context 'when cluster is defined' do
- let(:config_file_name) { config_cluster_format_host }
-
- it 'returns nil' do
- is_expected.to be_nil
- end
- end
- end
-
- describe '#sentinels?' do
- subject { described_class.new(Rails.env).sentinels? }
-
- context 'when sentinels are defined' do
- let(:config_file_name) { config_new_format_host }
-
- it 'returns true' do
- is_expected.to be_truthy
- end
- end
-
- context 'when sentinels are not defined' do
- let(:config_file_name) { config_old_format_host }
-
- it { expect(subject).to eq(nil) }
- end
-
- context 'when cluster is defined' do
- let(:config_file_name) { config_cluster_format_host }
-
- it 'returns false' do
- is_expected.to be_falsey
- end
- end
- end
-
- describe '#raw_config_hash' do
- it 'returns old-style single url config in a hash' do
- expect(subject).to receive(:fetch_config) { test_redis_url }
- expect(subject.send(:raw_config_hash)).to eq(url: test_redis_url)
- end
-
- it 'returns cluster config without url key in a hash' do
- expect(subject).to receive(:fetch_config) { test_cluster_config }
- expect(subject.send(:raw_config_hash)).to eq(test_cluster_config)
- end
- end
-
- describe '#fetch_config' do
- before do
- FileUtils.mkdir_p(File.join(rails_root, 'config'))
- # Undo top-level stub of config_file_name because we are testing that method now.
- allow(described_class).to receive(:config_file_name).and_call_original
- allow(described_class).to receive(:rails_root).and_return(rails_root)
- end
-
- it 'raises an exception when the config file contains invalid yaml' do
- Tempfile.open('bad.yml') do |file|
- file.write('{"not":"yaml"')
- file.flush
- allow(described_class).to receive(:config_file_name) { file.path }
-
- expect { subject.send(:fetch_config) }.to raise_error(Psych::SyntaxError)
- end
- end
-
- it 'has a value for the legacy default URL' do
- allow(subject).to receive(:fetch_config) { nil }
-
- expect(subject.send(:raw_config_hash)).to include(url: a_string_matching(%r{\Aredis://localhost:638[012]\Z}))
- end
-
- context 'when redis.yml exists' do
- subject { described_class.new('test').send(:fetch_config) }
-
- before do
- allow(described_class).to receive(:redis_yml_path).and_call_original
- end
-
- it 'uses config/redis.yml' do
- File.write(File.join(rails_root, 'config/redis.yml'), {
- 'test' => { described_class.store_name.underscore => { 'foobar' => 123 } }
- }.to_json)
-
- expect(subject).to eq({ 'foobar' => 123 })
- end
- end
-
- context 'when no config file exsits' do
- subject { described_class.new('test').send(:fetch_config) }
-
- it 'returns nil' do
- expect(subject).to eq(nil)
- end
-
- context 'but resque.yml exists' do
- before do
- FileUtils.mkdir_p(File.join(rails_root, 'config'))
- File.write(File.join(rails_root, 'config/resque.yml'), {
- 'test' => { 'foobar' => 123 }
- }.to_json)
- end
-
- it 'returns the config from resque.yml' do
- expect(subject).to eq({ 'foobar' => 123 })
- end
- end
- end
- end
-
- def clear_pool
- described_class.remove_instance_variable(:@pool)
- rescue NameError
- # raised if @pool was not set; ignore
- end
-end
diff --git a/spec/support/rspec.rb b/spec/support/rspec.rb
index ff0b5bebe33..94c43669173 100644
--- a/spec/support/rspec.rb
+++ b/spec/support/rspec.rb
@@ -19,6 +19,13 @@ RSpec.configure do |config|
# Re-run failures locally with `--only-failures`
config.example_status_persistence_file_path = ENV.fetch('RSPEC_LAST_RUN_RESULTS_FILE', './spec/examples.txt')
+ # Makes diffs show entire non-truncated values.
+ config.before(:each, :unlimited_max_formatted_output_length) do
+ config.expect_with :rspec do |c|
+ c.max_formatted_output_length = nil
+ end
+ end
+
unless ENV['CI']
# Allow running `:focus` examples locally,
# falling back to all tests when there is no `:focus` example.
@@ -43,7 +50,10 @@ RSpec.configure do |config|
# Add warning for example missing feature_category
config.before do |example|
if warn_missing_feature_category && example.metadata[:feature_category].blank? && !ENV['CI']
- warn "Missing metadata feature_category: #{example.location} See https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#feature-category-metadata"
+ location =
+ example.metadata[:shared_group_inclusion_backtrace].last&.formatted_inclusion_location ||
+ example.location
+ warn "Missing metadata feature_category: #{location} See https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#feature-category-metadata"
end
end
end
diff --git a/spec/support/rspec_order.rb b/spec/support/rspec_order.rb
index c128e18b38e..0305ae7241d 100644
--- a/spec/support/rspec_order.rb
+++ b/spec/support/rspec_order.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require 'yaml'
+
module Support
module RspecOrder
TODO_YAML = File.join(__dir__, 'rspec_order_todo.yml')
diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml
index 7aa7d8e8abd..82dc6659dbf 100644
--- a/spec/support/rspec_order_todo.yml
+++ b/spec/support/rspec_order_todo.yml
@@ -34,7 +34,6 @@
- './ee/spec/controllers/concerns/geo_instrumentation_spec.rb'
- './ee/spec/controllers/concerns/gitlab_subscriptions/seat_count_alert_spec.rb'
- './ee/spec/controllers/concerns/internal_redirect_spec.rb'
-- './ee/spec/controllers/concerns/registrations/verification_spec.rb'
- './ee/spec/controllers/concerns/routable_actions_spec.rb'
- './ee/spec/controllers/countries_controller_spec.rb'
- './ee/spec/controllers/country_states_controller_spec.rb'
@@ -62,7 +61,6 @@
- './ee/spec/controllers/groups/analytics/cycle_analytics_controller_spec.rb'
- './ee/spec/controllers/groups/analytics/cycle_analytics/stages_controller_spec.rb'
- './ee/spec/controllers/groups/analytics/cycle_analytics/summary_controller_spec.rb'
-- './ee/spec/controllers/groups/analytics/cycle_analytics/value_streams_controller_spec.rb'
- './ee/spec/controllers/groups/analytics/productivity_analytics_controller_spec.rb'
- './ee/spec/controllers/groups/analytics/repository_analytics_controller_spec.rb'
- './ee/spec/controllers/groups/analytics/tasks_by_type_controller_spec.rb'
@@ -123,7 +121,6 @@
- './ee/spec/controllers/projects/branches_controller_spec.rb'
- './ee/spec/controllers/projects/clusters_controller_spec.rb'
- './ee/spec/controllers/projects_controller_spec.rb'
-- './ee/spec/controllers/projects/dependencies_controller_spec.rb'
- './ee/spec/controllers/projects/deploy_keys_controller_spec.rb'
- './ee/spec/controllers/projects/environments_controller_spec.rb'
- './ee/spec/controllers/projects/feature_flag_issues_controller_spec.rb'
@@ -226,7 +223,6 @@
- './ee/spec/features/analytics/code_analytics_spec.rb'
- './ee/spec/features/analytics/group_analytics_spec.rb'
- './ee/spec/features/billings/billing_plans_spec.rb'
-- './ee/spec/features/billings/extend_reactivate_trial_spec.rb'
- './ee/spec/features/billings/qrtly_reconciliation_alert_spec.rb'
- './ee/spec/features/boards/board_filters_spec.rb'
- './ee/spec/features/boards/boards_licensed_features_spec.rb'
@@ -286,7 +282,6 @@
- './ee/spec/features/groups/audit_events_spec.rb'
- './ee/spec/features/groups/billing_spec.rb'
- './ee/spec/features/groups/contribution_analytics_spec.rb'
-- './ee/spec/features/groups/feature_discovery_moments_spec.rb'
- './ee/spec/features/groups/group_overview_spec.rb'
- './ee/spec/features/groups/group_page_with_external_authorization_service_spec.rb'
- './ee/spec/features/groups/group_projects_spec.rb'
@@ -330,7 +325,6 @@
- './ee/spec/features/groups/wiki/user_views_wiki_empty_spec.rb'
- './ee/spec/features/ide/user_opens_ide_spec.rb'
- './ee/spec/features/integrations/jira/jira_issues_list_spec.rb'
-- './ee/spec/features/invites_spec.rb'
- './ee/spec/features/issues/blocking_issues_spec.rb'
- './ee/spec/features/issues/epic_in_issue_sidebar_spec.rb'
- './ee/spec/features/issues/filtered_search/filter_issues_by_iteration_spec.rb'
@@ -497,9 +491,7 @@
- './ee/spec/features/trial_registrations/company_information_spec.rb'
- './ee/spec/features/trial_registrations/signin_spec.rb'
- './ee/spec/features/trial_registrations/signup_spec.rb'
-- './ee/spec/features/trials/select_namespace_spec.rb'
- './ee/spec/features/trials/show_trial_banner_spec.rb'
-- './ee/spec/features/users/arkose_labs_csp_spec.rb'
- './ee/spec/features/users/login_spec.rb'
- './ee/spec/features/users/signup_spec.rb'
- './ee/spec/features/user_unsubscribes_from_admin_notifications_spec.rb'
@@ -529,7 +521,6 @@
- './ee/spec/finders/ee/alert_management/http_integrations_finder_spec.rb'
- './ee/spec/finders/ee/autocomplete/users_finder_spec.rb'
- './ee/spec/finders/ee/ci/daily_build_group_report_results_finder_spec.rb'
-- './ee/spec/finders/ee/clusters/agent_authorizations_finder_spec.rb'
- './ee/spec/finders/ee/clusters/agents_finder_spec.rb'
- './ee/spec/finders/ee/fork_targets_finder_spec.rb'
- './ee/spec/finders/ee/group_members_finder_spec.rb'
@@ -564,7 +555,6 @@
- './ee/spec/finders/license_template_finder_spec.rb'
- './ee/spec/finders/merge_requests/by_approvers_finder_spec.rb'
- './ee/spec/finders/merge_requests_finder_spec.rb'
-- './ee/spec/finders/merge_trains_finder_spec.rb'
- './ee/spec/finders/notes_finder_spec.rb'
- './ee/spec/finders/productivity_analytics_finder_spec.rb'
- './ee/spec/finders/projects/integrations/jira/by_ids_finder_spec.rb'
@@ -602,7 +592,6 @@
- './ee/spec/frontend/fixtures/merge_requests.rb'
- './ee/spec/frontend/fixtures/on_demand_dast_scans.rb'
- './ee/spec/frontend/fixtures/project_quality_summary.rb'
-- './ee/spec/frontend/fixtures/projects.rb'
- './ee/spec/frontend/fixtures/runner.rb'
- './ee/spec/frontend/fixtures/saml_providers.rb'
- './ee/spec/frontend/fixtures/search.rb'
@@ -957,7 +946,6 @@
- './ee/spec/helpers/ee/geo_helper_spec.rb'
- './ee/spec/helpers/ee/gitlab_routing_helper_spec.rb'
- './ee/spec/helpers/ee/graph_helper_spec.rb'
-- './ee/spec/helpers/ee/groups/analytics/cycle_analytics_helper_spec.rb'
- './ee/spec/helpers/ee/groups/group_members_helper_spec.rb'
- './ee/spec/helpers/ee/groups_helper_spec.rb'
- './ee/spec/helpers/ee/groups/settings_helper_spec.rb'
@@ -984,14 +972,11 @@
- './ee/spec/helpers/ee/subscribable_banner_helper_spec.rb'
- './ee/spec/helpers/ee/system_note_helper_spec.rb'
- './ee/spec/helpers/ee/todos_helper_spec.rb'
-- './ee/spec/helpers/ee/trial_helper_spec.rb'
-- './ee/spec/helpers/ee/trial_registration_helper_spec.rb'
- './ee/spec/helpers/ee/users/callouts_helper_spec.rb'
- './ee/spec/helpers/ee/version_check_helper_spec.rb'
- './ee/spec/helpers/ee/wiki_helper_spec.rb'
- './ee/spec/helpers/epics_helper_spec.rb'
- './ee/spec/helpers/gitlab_subscriptions/upcoming_reconciliation_helper_spec.rb'
-- './ee/spec/helpers/groups/feature_discovery_moments_helper_spec.rb'
- './ee/spec/helpers/groups/ldap_sync_helper_spec.rb'
- './ee/spec/helpers/groups/security_features_helper_spec.rb'
- './ee/spec/helpers/groups/sso_helper_spec.rb'
@@ -1122,19 +1107,13 @@
- './ee/spec/lib/ee/gitlab/background_migration/backfill_project_statistics_container_repository_size_spec.rb'
- './ee/spec/lib/ee/gitlab/background_migration/create_security_setting_spec.rb'
- './ee/spec/lib/ee/gitlab/background_migration/delete_invalid_epic_issues_spec.rb'
-- './ee/spec/lib/ee/gitlab/background_migration/drop_invalid_remediations_spec.rb'
-- './ee/spec/lib/ee/gitlab/background_migration/fix_incorrect_max_seats_used_spec.rb'
- './ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_check_progress_spec.rb'
- './ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_in_batch_spec.rb'
- './ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_spec.rb'
- './ee/spec/lib/ee/gitlab/background_migration/migrate_shared_vulnerability_scanners_spec.rb'
- './ee/spec/lib/ee/gitlab/background_migration/populate_latest_pipeline_ids_spec.rb'
-- './ee/spec/lib/ee/gitlab/background_migration/populate_namespace_statistics_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/purge_stale_security_scans_spec.rb'
-- './ee/spec/lib/ee/gitlab/background_migration/recalculate_vulnerability_finding_signatures_for_findings_spec.rb'
-- './ee/spec/lib/ee/gitlab/background_migration/update_vulnerability_occurrences_location_spec.rb'
- './ee/spec/lib/ee/gitlab/checks/push_rule_check_spec.rb'
- './ee/spec/lib/ee/gitlab/checks/push_rules/branch_check_spec.rb'
- './ee/spec/lib/ee/gitlab/checks/push_rules/commit_check_spec.rb'
@@ -1309,8 +1288,6 @@
- './ee/spec/lib/gitlab/auth/smartcard/session_enforcer_spec.rb'
- './ee/spec/lib/gitlab/auth/smartcard/session_spec.rb'
- './ee/spec/lib/gitlab/background_migration/migrate_requirements_to_work_items_spec.rb'
-- './ee/spec/lib/gitlab/background_migration/populate_test_reports_issue_id_spec.rb'
-- './ee/spec/lib/gitlab/background_migration/remove_all_trace_expiration_dates_spec.rb'
- './ee/spec/lib/gitlab/bullet/exclusions_spec.rb'
- './ee/spec/lib/gitlab/cache_spec.rb'
- './ee/spec/lib/gitlab/checks/changes_access_spec.rb'
@@ -1323,7 +1300,6 @@
- './ee/spec/lib/gitlab/ci/config/required/processor_spec.rb'
- './ee/spec/lib/gitlab/ci/config/security_orchestration_policies/processor_spec.rb'
- './ee/spec/lib/gitlab/cidr_spec.rb'
-- './ee/spec/lib/gitlab/ci/minutes/build_consumption_spec.rb'
- './ee/spec/lib/gitlab/ci/minutes/cached_quota_spec.rb'
- './ee/spec/lib/gitlab/ci/minutes/cost_factor_spec.rb'
- './ee/spec/lib/gitlab/ci/minutes/gitlab_contribution_cost_factor_spec.rb'
@@ -1371,7 +1347,6 @@
- './ee/spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb'
- './ee/spec/lib/gitlab/ci/templates/Jobs/dast_default_branch_gitlab_ci_yaml_spec.rb'
- './ee/spec/lib/gitlab/ci/templates/Jobs/load_performance_testing_gitlab_ci_yaml_spec.rb'
-- './ee/spec/lib/gitlab/ci/templates/license_scanning_gitlab_ci_yaml_spec.rb'
- './ee/spec/lib/gitlab/ci/templates/sast_gitlab_ci_yaml_spec.rb'
- './ee/spec/lib/gitlab/ci/templates/sast_iac_gitlab_ci_yaml_spec.rb'
- './ee/spec/lib/gitlab/ci/templates/sast_latest_gitlab_ci_yaml_spec.rb'
@@ -1577,13 +1552,10 @@
- './ee/spec/lib/omni_auth/strategies/group_saml_spec.rb'
- './ee/spec/lib/omni_auth/strategies/kerberos_spec.rb'
- './ee/spec/lib/peek/views/elasticsearch_spec.rb'
-- './ee/spec/lib/sidebars/groups/menus/administration_menu_spec.rb'
- './ee/spec/lib/sidebars/groups/menus/analytics_menu_spec.rb'
- './ee/spec/lib/sidebars/groups/menus/epics_menu_spec.rb'
- './ee/spec/lib/sidebars/groups/menus/security_compliance_menu_spec.rb'
-- './ee/spec/lib/sidebars/groups/menus/trial_experiment_menu_spec.rb'
- './ee/spec/lib/sidebars/groups/menus/wiki_menu_spec.rb'
-- './ee/spec/lib/sidebars/projects/menus/trial_experiment_menu_spec.rb'
- './ee/spec/lib/system_check/app/search_check_spec.rb'
- './ee/spec/lib/system_check/geo/authorized_keys_check_spec.rb'
- './ee/spec/lib/system_check/geo/authorized_keys_flag_check_spec.rb'
@@ -1613,21 +1585,12 @@
- './ee/spec/mailers/notify_spec.rb'
- './ee/spec/migrations/20220411173544_cleanup_orphans_approval_project_rules_spec.rb'
- './ee/spec/migrations/20220517144749_remove_vulnerability_approval_rules_spec.rb'
-- './ee/spec/migrations/add_non_null_constraint_for_escalation_rule_on_pending_alert_escalations_spec.rb'
-- './ee/spec/migrations/async_build_trace_expire_at_index_spec.rb'
- './ee/spec/migrations/backfill_delayed_group_deletion_spec.rb'
-- './ee/spec/migrations/drop_invalid_remediations_spec.rb'
- './ee/spec/migrations/geo/fix_state_column_in_file_registry_spec.rb'
- './ee/spec/migrations/geo/fix_state_column_in_lfs_object_registry_spec.rb'
- './ee/spec/migrations/geo/migrate_ci_job_artifacts_to_separate_registry_spec.rb'
- './ee/spec/migrations/geo/migrate_lfs_objects_to_separate_registry_spec.rb'
- './ee/spec/migrations/geo/set_resync_flag_for_retried_projects_spec.rb'
-- './ee/spec/migrations/remove_schedule_and_status_null_constraints_from_pending_escalations_alert_spec.rb'
-- './ee/spec/migrations/schedule_delete_invalid_epic_issues_revised_spec.rb'
-- './ee/spec/migrations/schedule_populate_test_reports_issue_id_spec.rb'
-- './ee/spec/migrations/schedule_requirements_migration_spec.rb'
-- './ee/spec/migrations/schedule_trace_expiry_removal_spec.rb'
-- './ee/spec/migrations/update_vulnerability_occurrences_location_spec.rb'
- './ee/spec/models/alert_management/alert_payload_field_spec.rb'
- './ee/spec/models/allowed_email_domain_spec.rb'
- './ee/spec/models/analytics/cycle_analytics/aggregation_context_spec.rb'
@@ -1662,7 +1625,6 @@
- './ee/spec/models/boards/epic_user_preference_spec.rb'
- './ee/spec/models/board_spec.rb'
- './ee/spec/models/board_user_preference_spec.rb'
-- './ee/spec/models/broadcast_message_spec.rb'
- './ee/spec/models/burndown_spec.rb'
- './ee/spec/models/ci/bridge_spec.rb'
- './ee/spec/models/ci/daily_build_group_report_result_spec.rb'
@@ -1683,7 +1645,6 @@
- './ee/spec/models/concerns/approver_migrate_hook_spec.rb'
- './ee/spec/models/concerns/auditable_spec.rb'
- './ee/spec/models/concerns/deprecated_approvals_before_merge_spec.rb'
-- './ee/spec/models/concerns/ee/clusters/agents/authorization_config_scopes_spec.rb'
- './ee/spec/models/concerns/ee/issuable_spec.rb'
- './ee/spec/models/concerns/ee/mentionable_spec.rb'
- './ee/spec/models/concerns/ee/milestoneable_spec.rb'
@@ -1733,7 +1694,6 @@
- './ee/spec/models/dora/lead_time_for_changes_metric_spec.rb'
- './ee/spec/models/dora/time_to_restore_service_metric_spec.rb'
- './ee/spec/models/ee/alert_management/alert_spec.rb'
-- './ee/spec/models/ee/analytics/cycle_analytics/stage_event_hash_spec.rb'
- './ee/spec/models/ee/analytics/usage_trends/measurement_spec.rb'
- './ee/spec/models/ee/appearance_spec.rb'
- './ee/spec/models/ee/audit_event_spec.rb'
@@ -1754,7 +1714,6 @@
- './ee/spec/models/ee/integrations/jira_spec.rb'
- './ee/spec/models/ee/integration_spec.rb'
- './ee/spec/models/ee/iterations/cadence_spec.rb'
-- './ee/spec/models/ee/iteration_spec.rb'
- './ee/spec/models/ee/key_spec.rb'
- './ee/spec/models/ee/label_spec.rb'
- './ee/spec/models/ee/lfs_object_spec.rb'
@@ -1791,7 +1750,6 @@
- './ee/spec/models/ee/user_spec.rb'
- './ee/spec/models/ee/users_statistics_spec.rb'
- './ee/spec/models/ee/vulnerability_spec.rb'
-- './ee/spec/models/ee/work_items/type_spec.rb'
- './ee/spec/models/elastic/index_setting_spec.rb'
- './ee/spec/models/elastic/migration_record_spec.rb'
- './ee/spec/models/elastic/reindexing_slice_spec.rb'
@@ -1874,7 +1832,6 @@
- './ee/spec/models/merge_requests/external_status_check_spec.rb'
- './ee/spec/models/merge_request_spec.rb'
- './ee/spec/models/merge_requests/status_check_response_spec.rb'
-- './ee/spec/models/merge_train_spec.rb'
- './ee/spec/models/milestone_release_spec.rb'
- './ee/spec/models/milestone_spec.rb'
- './ee/spec/models/namespace_limit_spec.rb'
@@ -1934,7 +1891,6 @@
- './ee/spec/models/storage_shard_spec.rb'
- './ee/spec/models/uploads/local_spec.rb'
- './ee/spec/models/upload_spec.rb'
-- './ee/spec/models/user_detail_spec.rb'
- './ee/spec/models/user_permission_export_upload_spec.rb'
- './ee/spec/models/user_preference_spec.rb'
- './ee/spec/models/users_security_dashboard_project_spec.rb'
@@ -2330,7 +2286,6 @@
- './ee/spec/requests/groups_controller_spec.rb'
- './ee/spec/requests/groups/epics/epic_links_controller_spec.rb'
- './ee/spec/requests/groups/epics/related_epic_links_controller_spec.rb'
-- './ee/spec/requests/groups/feature_discovery_moments_spec.rb'
- './ee/spec/requests/groups/group_members_controller_spec.rb'
- './ee/spec/requests/groups/hook_logs_controller_spec.rb'
- './ee/spec/requests/groups/labels_spec.rb'
@@ -2471,10 +2426,7 @@
- './ee/spec/services/analytics/cycle_analytics/aggregator_service_spec.rb'
- './ee/spec/services/analytics/cycle_analytics/consistency_check_service_spec.rb'
- './ee/spec/services/analytics/cycle_analytics/data_loader_service_spec.rb'
-- './ee/spec/services/analytics/cycle_analytics/stages/create_service_spec.rb'
-- './ee/spec/services/analytics/cycle_analytics/stages/delete_service_spec.rb'
- './ee/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb'
-- './ee/spec/services/analytics/cycle_analytics/stages/update_service_spec.rb'
- './ee/spec/services/analytics/cycle_analytics/value_streams/create_service_spec.rb'
- './ee/spec/services/analytics/cycle_analytics/value_streams/update_service_spec.rb'
- './ee/spec/services/analytics/devops_adoption/enabled_namespaces/bulk_delete_service_spec.rb'
@@ -2534,7 +2486,6 @@
- './ee/spec/services/audit_events/release_associate_milestone_audit_event_service_spec.rb'
- './ee/spec/services/audit_events/release_created_audit_event_service_spec.rb'
- './ee/spec/services/audit_events/release_updated_audit_event_service_spec.rb'
-- './ee/spec/services/audit_events/repository_download_started_audit_event_service_spec.rb'
- './ee/spec/services/audit_events/runner_custom_audit_event_service_spec.rb'
- './ee/spec/services/audit_events/runners_token_audit_event_service_spec.rb'
- './ee/spec/services/audit_events/streaming/headers/base_spec.rb'
@@ -2587,7 +2538,6 @@
- './ee/spec/services/ci/external_pull_requests/process_github_event_service_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/batch_reset_service_spec.rb'
- './ee/spec/services/ci/minutes/email_notification_service_spec.rb'
- './ee/spec/services/ci/minutes/refresh_cached_data_service_spec.rb'
- './ee/spec/services/ci/minutes/reset_usage_service_spec.rb'
@@ -2668,9 +2618,7 @@
- './ee/spec/services/ee/issuable/destroy_service_spec.rb'
- './ee/spec/services/ee/issue_links/create_service_spec.rb'
- './ee/spec/services/ee/issues/after_create_service_spec.rb'
-- './ee/spec/services/ee/issues/build_from_vulnerability_service_spec.rb'
- './ee/spec/services/ee/issues/clone_service_spec.rb'
-- './ee/spec/services/ee/issues/create_from_vulnerability_data_service_spec.rb'
- './ee/spec/services/ee/issues/create_service_spec.rb'
- './ee/spec/services/ee/issues/move_service_spec.rb'
- './ee/spec/services/ee/issues/update_service_spec.rb'
@@ -2761,7 +2709,6 @@
- './ee/spec/services/epics/related_epic_links/list_service_spec.rb'
- './ee/spec/services/epics/reopen_service_spec.rb'
- './ee/spec/services/epics/transfer_service_spec.rb'
-- './ee/spec/services/epics/tree_reorder_service_spec.rb'
- './ee/spec/services/epics/update_dates_service_spec.rb'
- './ee/spec/services/epics/update_service_spec.rb'
- './ee/spec/services/external_status_checks/create_service_spec.rb'
@@ -2815,10 +2762,8 @@
- './ee/spec/services/gitlab_subscriptions/create_hand_raise_lead_service_spec.rb'
- './ee/spec/services/gitlab_subscriptions/create_service_spec.rb'
- './ee/spec/services/gitlab_subscriptions/create_trial_or_lead_service_spec.rb'
-- './ee/spec/services/gitlab_subscriptions/extend_reactivate_trial_service_spec.rb'
- './ee/spec/services/gitlab_subscriptions/fetch_purchase_eligible_namespaces_service_spec.rb'
- './ee/spec/services/gitlab_subscriptions/fetch_subscription_plans_service_spec.rb'
-- './ee/spec/services/gitlab_subscriptions/notify_seats_exceeded_service_spec.rb'
- './ee/spec/services/gitlab_subscriptions/plan_upgrade_service_spec.rb'
- './ee/spec/services/gitlab_subscriptions/preview_billable_user_change_service_spec.rb'
- './ee/spec/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service_spec.rb'
@@ -2869,7 +2814,6 @@
- './ee/spec/services/licenses/destroy_service_spec.rb'
- './ee/spec/services/members/activate_service_spec.rb'
- './ee/spec/services/members/await_service_spec.rb'
-- './ee/spec/services/merge_commits/export_csv_service_spec.rb'
- './ee/spec/services/merge_request_approval_settings/update_service_spec.rb'
- './ee/spec/services/merge_requests/approval_service_spec.rb'
- './ee/spec/services/merge_requests/build_service_spec.rb'
@@ -2946,7 +2890,6 @@
- './ee/spec/services/repositories/housekeeping_service_spec.rb'
- './ee/spec/services/requirements_management/export_csv_service_spec.rb'
- './ee/spec/services/requirements_management/import_csv_service_spec.rb'
-- './ee/spec/services/requirements_management/map_export_fields_service_spec.rb'
- './ee/spec/services/requirements_management/prepare_import_csv_service_spec.rb'
- './ee/spec/services/requirements_management/process_test_reports_service_spec.rb'
- './ee/spec/services/resource_access_tokens/create_service_spec.rb'
@@ -3017,7 +2960,6 @@
- './ee/spec/services/security/update_training_service_spec.rb'
- './ee/spec/services/security/vulnerability_counting_service_spec.rb'
- './ee/spec/services/sitemap/create_service_spec.rb'
-- './ee/spec/services/slash_commands/global_slack_handler_spec.rb'
- './ee/spec/services/software_license_policies/create_service_spec.rb'
- './ee/spec/services/software_license_policies/update_service_spec.rb'
- './ee/spec/services/start_pull_mirroring_service_spec.rb'
@@ -3106,7 +3048,6 @@
- './ee/spec/views/groups/_compliance_frameworks.html.haml_spec.rb'
- './ee/spec/views/groups/compliance_frameworks/new.html.haml_spec.rb'
- './ee/spec/views/groups/edit.html.haml_spec.rb'
-- './ee/spec/views/groups/feature_discovery_moments/advanced_features_dashboard.html.haml_spec.rb'
- './ee/spec/views/groups/hook_logs/show.html.haml_spec.rb'
- './ee/spec/views/groups/hooks/edit.html.haml_spec.rb'
- './ee/spec/views/groups/security/discover/show.html.haml_spec.rb'
@@ -3116,13 +3057,11 @@
- './ee/spec/views/layouts/checkout.html.haml_spec.rb'
- './ee/spec/views/layouts/header/_current_user_dropdown.html.haml_spec.rb'
- './ee/spec/views/layouts/header/_ee_subscribable_banner.html.haml_spec.rb'
-- './ee/spec/views/layouts/header/help_dropdown/_cross_stage_fdm.html.haml_spec.rb'
- './ee/spec/views/layouts/header/_read_only_banner.html.haml_spec.rb'
- './ee/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb'
- './ee/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb'
- './ee/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb'
- './ee/spec/views/layouts/nav/sidebar/_push_rules_link.html.haml_spec.rb'
-- './ee/spec/views/layouts/_search.html.haml_spec.rb'
- './ee/spec/views/operations/environments.html.haml_spec.rb'
- './ee/spec/views/operations/index.html.haml_spec.rb'
- './ee/spec/views/profiles/preferences/show.html.haml_spec.rb'
@@ -3141,7 +3080,6 @@
- './ee/spec/views/projects/security/sast_configuration/show.html.haml_spec.rb'
- './ee/spec/views/projects/settings/subscriptions/_index.html.haml_spec.rb'
- './ee/spec/views/registrations/groups_projects/new.html.haml_spec.rb'
-- './ee/spec/views/search/_category.html.haml_spec.rb'
- './ee/spec/views/shared/billings/_billing_plan_actions.html.haml_spec.rb'
- './ee/spec/views/shared/billings/_billing_plan.html.haml_spec.rb'
- './ee/spec/views/shared/billings/_billing_plans.html.haml_spec.rb'
@@ -3201,7 +3139,6 @@
- './ee/spec/workers/concerns/elastic/indexing_control_spec.rb'
- './ee/spec/workers/concerns/elastic/migration_obsolete_spec.rb'
- './ee/spec/workers/concerns/elastic/migration_options_spec.rb'
-- './ee/spec/workers/concerns/geo_queue_spec.rb'
- './ee/spec/workers/concerns/update_orchestration_policy_configuration_spec.rb'
- './ee/spec/workers/create_github_webhook_worker_spec.rb'
- './ee/spec/workers/deployments/auto_rollback_worker_spec.rb'
@@ -3265,7 +3202,6 @@
- './ee/spec/workers/geo/verification_state_backfill_worker_spec.rb'
- './ee/spec/workers/geo/verification_timeout_worker_spec.rb'
- './ee/spec/workers/geo/verification_worker_spec.rb'
-- './ee/spec/workers/gitlab_subscriptions/notify_seats_exceeded_worker_spec.rb'
- './ee/spec/workers/group_saml_group_sync_worker_spec.rb'
- './ee/spec/workers/groups/create_event_worker_spec.rb'
- './ee/spec/workers/groups/export_memberships_worker_spec.rb'
@@ -3314,7 +3250,6 @@
- './ee/spec/workers/sync_seat_link_worker_spec.rb'
- './ee/spec/workers/todos_destroyer/confidential_epic_worker_spec.rb'
- './ee/spec/workers/update_all_mirrors_worker_spec.rb'
-- './ee/spec/workers/update_max_seats_used_for_gitlab_com_subscriptions_worker_spec.rb'
- './ee/spec/workers/vulnerabilities/historical_statistics/deletion_worker_spec.rb'
- './ee/spec/workers/vulnerabilities/statistics/adjustment_worker_spec.rb'
- './ee/spec/workers/vulnerabilities/statistics/schedule_worker_spec.rb'
@@ -3323,7 +3258,6 @@
- './spec/bin/feature_flag_spec.rb'
- './spec/bin/sidekiq_cluster_spec.rb'
- './spec/channels/application_cable/connection_spec.rb'
-- './spec/channels/awareness_channel_spec.rb'
- './spec/commands/metrics_server/metrics_server_spec.rb'
- './spec/commands/sidekiq_cluster/cli_spec.rb'
- './spec/components/diffs/overflow_warning_component_spec.rb'
@@ -3368,7 +3302,6 @@
- './spec/controllers/admin/jobs_controller_spec.rb'
- './spec/controllers/admin/plan_limits_controller_spec.rb'
- './spec/controllers/admin/projects_controller_spec.rb'
-- './spec/controllers/admin/runner_projects_controller_spec.rb'
- './spec/controllers/admin/runners_controller_spec.rb'
- './spec/controllers/admin/sessions_controller_spec.rb'
- './spec/controllers/admin/spam_logs_controller_spec.rb'
@@ -3458,7 +3391,6 @@
- './spec/controllers/import/github_controller_spec.rb'
- './spec/controllers/import/gitlab_controller_spec.rb'
- './spec/controllers/import/manifest_controller_spec.rb'
-- './spec/controllers/import/phabricator_controller_spec.rb'
- './spec/controllers/invites_controller_spec.rb'
- './spec/controllers/jira_connect/app_descriptor_controller_spec.rb'
- './spec/controllers/jira_connect/branches_controller_spec.rb'
@@ -3618,13 +3550,10 @@
- './spec/experiments/force_company_trial_experiment_spec.rb'
- './spec/experiments/in_product_guidance_environments_webide_experiment_spec.rb'
- './spec/experiments/ios_specific_templates_experiment_spec.rb'
-- './spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb'
-- './spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb'
- './spec/features/abuse_report_spec.rb'
- './spec/features/action_cable_logging_spec.rb'
- './spec/features/admin/admin_abuse_reports_spec.rb'
- './spec/features/admin/admin_appearance_spec.rb'
-- './spec/features/admin/admin_broadcast_messages_spec.rb'
- './spec/features/admin/admin_browse_spam_logs_spec.rb'
- './spec/features/admin/admin_deploy_keys_spec.rb'
- './spec/features/admin/admin_dev_ops_reports_spec.rb'
@@ -3814,12 +3743,10 @@
- './spec/features/ics/dashboard_issues_spec.rb'
- './spec/features/ics/group_issues_spec.rb'
- './spec/features/ics/project_issues_spec.rb'
-- './spec/features/ide/clientside_preview_csp_spec.rb'
- './spec/features/ide_spec.rb'
- './spec/features/ide/static_object_external_storage_csp_spec.rb'
- './spec/features/ide/user_opens_merge_request_spec.rb'
- './spec/features/import/manifest_import_spec.rb'
-- './spec/features/invites_spec.rb'
- './spec/features/issuables/issuable_list_spec.rb'
- './spec/features/issuables/markdown_references/internal_references_spec.rb'
- './spec/features/issuables/markdown_references/jira_spec.rb'
@@ -3980,7 +3907,6 @@
- './spec/features/merge_request/user_sees_suggest_pipeline_spec.rb'
- './spec/features/merge_request/user_sees_system_notes_spec.rb'
- './spec/features/merge_request/user_sees_versions_spec.rb'
-- './spec/features/merge_request/user_sees_wip_help_message_spec.rb'
- './spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb'
- './spec/features/merge_request/user_squashes_merge_request_spec.rb'
- './spec/features/merge_request/user_suggests_changes_on_diff_spec.rb'
@@ -4108,7 +4034,6 @@
- './spec/features/projects/files/project_owner_creates_license_file_spec.rb'
- './spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb'
- './spec/features/projects/files/template_selector_menu_spec.rb'
-- './spec/features/projects/files/template_type_dropdown_spec.rb'
- './spec/features/projects/files/undo_template_spec.rb'
- './spec/features/projects/files/user_browses_a_tree_with_a_folder_containing_only_a_folder_spec.rb'
- './spec/features/projects/files/user_browses_files_spec.rb'
@@ -4349,7 +4274,6 @@
- './spec/features/task_lists_spec.rb'
- './spec/features/topic_show_spec.rb'
- './spec/features/triggers_spec.rb'
-- './spec/features/u2f_spec.rb'
- './spec/features/unsubscribe_links_spec.rb'
- './spec/features/uploads/user_uploads_avatar_to_group_spec.rb'
- './spec/features/uploads/user_uploads_avatar_to_profile_spec.rb'
@@ -4377,10 +4301,8 @@
- './spec/features/users/snippets_spec.rb'
- './spec/features/users/terms_spec.rb'
- './spec/features/users/user_browses_projects_on_user_page_spec.rb'
-- './spec/features/users/zuora_csp_spec.rb'
- './spec/features/webauthn_spec.rb'
- './spec/features/whats_new_spec.rb'
-- './spec/features/work_items/work_item_children_spec.rb'
- './spec/finders/abuse_reports_finder_spec.rb'
- './spec/finders/access_requests_finder_spec.rb'
- './spec/finders/admin/plans_finder_spec.rb'
@@ -4414,7 +4336,6 @@
- './spec/finders/ci/runners_finder_spec.rb'
- './spec/finders/ci/variables_finder_spec.rb'
- './spec/finders/cluster_ancestors_finder_spec.rb'
-- './spec/finders/clusters/agent_authorizations_finder_spec.rb'
- './spec/finders/clusters/agents_finder_spec.rb'
- './spec/finders/clusters_finder_spec.rb'
- './spec/finders/clusters/knative_services_finder_spec.rb'
@@ -4515,7 +4436,6 @@
- './spec/finders/security/license_compliance_jobs_finder_spec.rb'
- './spec/finders/security/security_jobs_finder_spec.rb'
- './spec/finders/sentry_issue_finder_spec.rb'
-- './spec/finders/serverless_domain_finder_spec.rb'
- './spec/finders/snippets_finder_spec.rb'
- './spec/finders/starred_projects_finder_spec.rb'
- './spec/finders/tags_finder_spec.rb'
@@ -4571,7 +4491,6 @@
- './spec/frontend/fixtures/tags.rb'
- './spec/frontend/fixtures/timezones.rb'
- './spec/frontend/fixtures/todos.rb'
-- './spec/frontend/fixtures/u2f.rb'
- './spec/frontend/fixtures/webauthn.rb'
- './spec/graphql/features/authorization_spec.rb'
- './spec/graphql/gitlab_schema_spec.rb'
@@ -4603,7 +4522,6 @@
- './spec/graphql/mutations/clusters/agent_tokens/create_spec.rb'
- './spec/graphql/mutations/clusters/agent_tokens/revoke_spec.rb'
- './spec/graphql/mutations/commits/create_spec.rb'
-- './spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb'
- './spec/graphql/mutations/concerns/mutations/resolves_group_spec.rb'
- './spec/graphql/mutations/concerns/mutations/resolves_issuable_spec.rb'
- './spec/graphql/mutations/container_expiration_policies/update_spec.rb'
@@ -5084,7 +5002,6 @@
- './spec/helpers/admin/deploy_key_helper_spec.rb'
- './spec/helpers/admin/identities_helper_spec.rb'
- './spec/helpers/admin/user_actions_helper_spec.rb'
-- './spec/helpers/analytics/cycle_analytics_helper_spec.rb'
- './spec/helpers/appearances_helper_spec.rb'
- './spec/helpers/application_helper_spec.rb'
- './spec/helpers/application_settings_helper_spec.rb'
@@ -5099,14 +5016,12 @@
- './spec/helpers/boards_helper_spec.rb'
- './spec/helpers/branches_helper_spec.rb'
- './spec/helpers/breadcrumbs_helper_spec.rb'
-- './spec/helpers/broadcast_messages_helper_spec.rb'
- './spec/helpers/button_helper_spec.rb'
- './spec/helpers/calendar_helper_spec.rb'
- './spec/helpers/ci/builds_helper_spec.rb'
- './spec/helpers/ci/jobs_helper_spec.rb'
- './spec/helpers/ci/pipeline_editor_helper_spec.rb'
- './spec/helpers/ci/pipelines_helper_spec.rb'
-- './spec/helpers/ci/runners_helper_spec.rb'
- './spec/helpers/ci/secure_files_helper_spec.rb'
- './spec/helpers/ci/status_helper_spec.rb'
- './spec/helpers/ci/triggers_helper_spec.rb'
@@ -5142,7 +5057,6 @@
- './spec/helpers/groups/settings_helper_spec.rb'
- './spec/helpers/hooks_helper_spec.rb'
- './spec/helpers/icons_helper_spec.rb'
-- './spec/helpers/ide_helper_spec.rb'
- './spec/helpers/import_helper_spec.rb'
- './spec/helpers/instance_configuration_helper_spec.rb'
- './spec/helpers/integrations_helper_spec.rb'
@@ -5158,7 +5072,6 @@
- './spec/helpers/members_helper_spec.rb'
- './spec/helpers/merge_requests_helper_spec.rb'
- './spec/helpers/namespaces_helper_spec.rb'
-- './spec/helpers/nav_helper_spec.rb'
- './spec/helpers/nav/new_dropdown_helper_spec.rb'
- './spec/helpers/nav/top_nav_helper_spec.rb'
- './spec/helpers/notes_helper_spec.rb'
@@ -5201,7 +5114,6 @@
- './spec/helpers/tab_helper_spec.rb'
- './spec/helpers/terms_helper_spec.rb'
- './spec/helpers/timeboxes_helper_spec.rb'
-- './spec/helpers/timeboxes_routing_helper_spec.rb'
- './spec/helpers/time_helper_spec.rb'
- './spec/helpers/time_zone_helper_spec.rb'
- './spec/helpers/todos_helper_spec.rb'
@@ -5280,7 +5192,6 @@
- './spec/lib/api/entities/ci/job_request/port_spec.rb'
- './spec/lib/api/entities/ci/job_request/service_spec.rb'
- './spec/lib/api/entities/ci/pipeline_spec.rb'
-- './spec/lib/api/entities/clusters/agent_authorization_spec.rb'
- './spec/lib/api/entities/clusters/agent_spec.rb'
- './spec/lib/api/entities/deploy_key_spec.rb'
- './spec/lib/api/entities/deploy_keys_project_spec.rb'
@@ -5674,25 +5585,19 @@
- './spec/lib/gitlab/auth/saml/user_spec.rb'
- './spec/lib/gitlab/auth_spec.rb'
- './spec/lib/gitlab/auth/two_factor_auth_verifier_spec.rb'
-- './spec/lib/gitlab/auth/u2f_webauthn_converter_spec.rb'
- './spec/lib/gitlab/auth/unique_ips_limiter_spec.rb'
- './spec/lib/gitlab/auth/user_access_denied_reason_spec.rb'
- './spec/lib/gitlab/avatar_cache_spec.rb'
-- './spec/lib/gitlab/background_migration/backfill_ci_queuing_tables_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_with_corrected_regex_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_group_features_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_imported_issue_search_data_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_integrations_enable_ssl_verification_spec.rb'
-- './spec/lib/gitlab/background_migration/backfill_integrations_type_new_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb'
-- './spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_member_namespace_for_group_members_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_namespace_id_for_namespace_route_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_namespace_id_of_vulnerability_reads_spec.rb'
-- './spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_children_spec.rb'
-- './spec/lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_note_discussion_id_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_project_feature_package_registry_access_level_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_project_import_level_spec.rb'
@@ -5701,8 +5606,6 @@
- './spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_topics_title_spec.rb'
-- './spec/lib/gitlab/background_migration/backfill_upvotes_count_on_issues_spec.rb'
-- './spec/lib/gitlab/background_migration/backfill_user_namespace_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_vulnerability_reads_cluster_agent_spec.rb'
- './spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb'
- './spec/lib/gitlab/background_migration/base_job_spec.rb'
@@ -5714,48 +5617,26 @@
- './spec/lib/gitlab/background_migration/batching_strategies/loose_index_scan_batching_strategy_spec.rb'
- './spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb'
- './spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb'
-- './spec/lib/gitlab/background_migration/cleanup_orphaned_lfs_objects_projects_spec.rb'
- './spec/lib/gitlab/background_migration/cleanup_orphaned_routes_spec.rb'
- './spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb'
-- './spec/lib/gitlab/background_migration/delete_orphaned_deployments_spec.rb'
- './spec/lib/gitlab/background_migration/destroy_invalid_group_members_spec.rb'
-- './spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb'
- './spec/lib/gitlab/background_migration/disable_legacy_open_source_licence_for_recent_public_projects_spec.rb'
- './spec/lib/gitlab/background_migration/disable_legacy_open_source_license_for_inactive_public_projects_spec.rb'
- './spec/lib/gitlab/background_migration/disable_legacy_open_source_license_for_no_issues_no_repo_projects_spec.rb'
- './spec/lib/gitlab/background_migration/disable_legacy_open_source_license_for_one_member_no_repo_projects_spec.rb'
-- './spec/lib/gitlab/background_migration/drop_invalid_security_findings_spec.rb'
-- './spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb'
-- './spec/lib/gitlab/background_migration/encrypt_integration_properties_spec.rb'
-- './spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb'
- './spec/lib/gitlab/background_migration/expire_o_auth_tokens_spec.rb'
-- './spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb'
- './spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb'
-- './spec/lib/gitlab/background_migration/fix_first_mentioned_in_commit_at_spec.rb'
-- './spec/lib/gitlab/background_migration/fix_merge_request_diff_commit_users_spec.rb'
- './spec/lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata_spec.rb'
- './spec/lib/gitlab/background_migration/job_coordinator_spec.rb'
- './spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb'
- './spec/lib/gitlab/background_migration/legacy_uploads_migrator_spec.rb'
- './spec/lib/gitlab/background_migration/mailers/unconfirm_mailer_spec.rb'
-- './spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb'
-- './spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb'
- './spec/lib/gitlab/background_migration/migrate_personal_namespace_project_maintainer_to_owner_spec.rb'
-- './spec/lib/gitlab/background_migration/migrate_project_taggings_context_from_tags_to_topics_spec.rb'
- './spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb'
-- './spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb'
-- './spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb'
- './spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb'
- './spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb'
-- './spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb'
- './spec/lib/gitlab/background_migration/populate_operation_visibility_permissions_from_operations_spec.rb'
-- './spec/lib/gitlab/background_migration/populate_topics_non_private_projects_count_spec.rb'
-- './spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb'
-- './spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb'
- './spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb'
-- './spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb'
-- './spec/lib/gitlab/background_migration/remove_all_trace_expiration_dates_spec.rb'
-- './spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb'
- './spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb'
- './spec/lib/gitlab/background_migration/remove_self_managed_wiki_notes_spec.rb'
- './spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb'
@@ -5765,16 +5646,10 @@
- './spec/lib/gitlab/background_migration/set_correct_vulnerability_state_spec.rb'
- './spec/lib/gitlab/background_migration/set_legacy_open_source_license_available_for_non_public_projects_spec.rb'
- './spec/lib/gitlab/background_migration_spec.rb'
-- './spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb'
- './spec/lib/gitlab/background_migration/update_delayed_project_removal_to_null_for_user_namespaces_spec.rb'
- './spec/lib/gitlab/background_migration/update_jira_tracker_data_deployment_type_based_on_url_spec.rb'
-- './spec/lib/gitlab/background_migration/update_timelogs_null_spent_at_spec.rb'
-- './spec/lib/gitlab/background_migration/update_timelogs_project_id_spec.rb'
-- './spec/lib/gitlab/background_migration/update_users_where_two_factor_auth_required_from_group_spec.rb'
- './spec/lib/gitlab/background_task_spec.rb'
- './spec/lib/gitlab/backtrace_cleaner_spec.rb'
-- './spec/lib/gitlab/bare_repository_import/importer_spec.rb'
-- './spec/lib/gitlab/bare_repository_import/repository_spec.rb'
- './spec/lib/gitlab/batch_worker_context_spec.rb'
- './spec/lib/gitlab/bitbucket_import/importer_spec.rb'
- './spec/lib/gitlab/bitbucket_import/project_creator_spec.rb'
@@ -5973,7 +5848,6 @@
- './spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb'
- './spec/lib/gitlab/ci/pipeline/chain/command_spec.rb'
- './spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb'
-- './spec/lib/gitlab/ci/pipeline/chain/create_deployments_spec.rb'
- './spec/lib/gitlab/ci/pipeline/chain/create_spec.rb'
- './spec/lib/gitlab/ci/pipeline/chain/ensure_environments_spec.rb'
- './spec/lib/gitlab/ci/pipeline/chain/ensure_resource_groups_spec.rb'
@@ -6042,7 +5916,6 @@
- './spec/lib/gitlab/ci/reports/security/scanned_resource_spec.rb'
- './spec/lib/gitlab/ci/reports/security/scanner_spec.rb'
- './spec/lib/gitlab/ci/reports/security/scan_spec.rb'
-- './spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb'
- './spec/lib/gitlab/ci/reports/terraform_reports_spec.rb'
- './spec/lib/gitlab/ci/reports/test_case_spec.rb'
- './spec/lib/gitlab/ci/reports/test_failure_history_spec.rb'
@@ -6235,8 +6108,6 @@
- './spec/lib/gitlab/database_importers/common_metrics/importer_spec.rb'
- './spec/lib/gitlab/database_importers/common_metrics/prometheus_metric_spec.rb'
- './spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb'
-- './spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb'
-- './spec/lib/gitlab/database_importers/self_monitoring/project/delete_service_spec.rb'
- './spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb'
- './spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb'
- './spec/lib/gitlab/database/load_balancing/configuration_spec.rb'
@@ -6280,7 +6151,6 @@
- './spec/lib/gitlab/database/migrations/test_background_runner_spec.rb'
- './spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb'
- './spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb'
-- './spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb'
- './spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb'
- './spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb'
- './spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb'
@@ -6418,7 +6288,6 @@
- './spec/lib/gitlab/email/hook/delivery_metrics_observer_spec.rb'
- './spec/lib/gitlab/email/hook/disable_email_interceptor_spec.rb'
- './spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb'
-- './spec/lib/gitlab/email/hook/validate_addresses_interceptor_spec.rb'
- './spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb'
- './spec/lib/gitlab/email/message/in_product_marketing/admin_verify_spec.rb'
- './spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb'
@@ -6449,7 +6318,6 @@
- './spec/lib/gitlab/error_tracking/processor/sanitize_error_message_processor_spec.rb'
- './spec/lib/gitlab/error_tracking/processor/sanitizer_processor_spec.rb'
- './spec/lib/gitlab/error_tracking/processor/sidekiq_processor_spec.rb'
-- './spec/lib/gitlab/error_tracking_spec.rb'
- './spec/lib/gitlab/error_tracking/stack_trace_highlight_decorator_spec.rb'
- './spec/lib/gitlab/etag_caching/middleware_spec.rb'
- './spec/lib/gitlab/etag_caching/router/graphql_spec.rb'
@@ -6507,7 +6375,6 @@
- './spec/lib/gitlab/gitaly_client/ref_service_spec.rb'
- './spec/lib/gitlab/gitaly_client/remote_service_spec.rb'
- './spec/lib/gitlab/gitaly_client/repository_service_spec.rb'
-- './spec/lib/gitlab/gitaly_client/server_service_spec.rb'
- './spec/lib/gitlab/gitaly_client_spec.rb'
- './spec/lib/gitlab/gitaly_client/storage_settings_spec.rb'
- './spec/lib/gitlab/gitaly_client/util_spec.rb'
@@ -6593,9 +6460,6 @@
- './spec/lib/gitlab/github_import_spec.rb'
- './spec/lib/gitlab/github_import/user_finder_spec.rb'
- './spec/lib/gitlab/git/keep_around_spec.rb'
-- './spec/lib/gitlab/gitlab_import/client_spec.rb'
-- './spec/lib/gitlab/gitlab_import/importer_spec.rb'
-- './spec/lib/gitlab/gitlab_import/project_creator_spec.rb'
- './spec/lib/gitlab/git/lfs_changes_spec.rb'
- './spec/lib/gitlab/git/lfs_pointer_file_spec.rb'
- './spec/lib/gitlab/git/merge_base_spec.rb'
@@ -6641,7 +6505,6 @@
- './spec/lib/gitlab/graphql/batch_key_spec.rb'
- './spec/lib/gitlab/graphql/calls_gitaly/field_extension_spec.rb'
- './spec/lib/gitlab/graphql/copy_field_description_spec.rb'
-- './spec/lib/gitlab/graphql/deprecation_spec.rb'
- './spec/lib/gitlab/graphql/generic_tracing_spec.rb'
- './spec/lib/gitlab/graphql/known_operations_spec.rb'
- './spec/lib/gitlab/graphql/lazy_spec.rb'
@@ -6729,7 +6592,6 @@
- './spec/lib/gitlab/import_export/error_spec.rb'
- './spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb'
- './spec/lib/gitlab/import_export/file_importer_spec.rb'
-- './spec/lib/gitlab/import_export/fork_spec.rb'
- './spec/lib/gitlab/import_export/group/object_builder_spec.rb'
- './spec/lib/gitlab/import_export/group/relation_factory_spec.rb'
- './spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb'
@@ -6737,13 +6599,9 @@
- './spec/lib/gitlab/import_export/group/tree_saver_spec.rb'
- './spec/lib/gitlab/import_export/hash_util_spec.rb'
- './spec/lib/gitlab/import_export/importer_spec.rb'
-- './spec/lib/gitlab/import_export/import_export_equivalence_spec.rb'
- './spec/lib/gitlab/import_export/import_export_spec.rb'
- './spec/lib/gitlab/import_export/import_failure_service_spec.rb'
- './spec/lib/gitlab/import_export/import_test_coverage_spec.rb'
-- './spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb'
-- './spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb'
-- './spec/lib/gitlab/import_export/json/legacy_writer_spec.rb'
- './spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb'
- './spec/lib/gitlab/import_export/json/ndjson_writer_spec.rb'
- './spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb'
@@ -6790,7 +6648,6 @@
- './spec/lib/gitlab/import/set_async_jid_spec.rb'
- './spec/lib/gitlab/import_sources_spec.rb'
- './spec/lib/gitlab/inactive_projects_deletion_warning_tracker_spec.rb'
-- './spec/lib/gitlab/incoming_email_spec.rb'
- './spec/lib/gitlab/insecure_key_fingerprint_spec.rb'
- './spec/lib/gitlab/instrumentation_helper_spec.rb'
- './spec/lib/gitlab/instrumentation/rate_limiting_gates_spec.rb'
@@ -6829,19 +6686,6 @@
- './spec/lib/gitlab/kubernetes/default_namespace_spec.rb'
- './spec/lib/gitlab/kubernetes/deployment_spec.rb'
- './spec/lib/gitlab/kubernetes/generic_secret_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/api_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/pod_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v2/base_command_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v2/certificate_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v2/delete_command_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v2/init_command_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v2/install_command_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v2/patch_command_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v2/reset_command_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v3/base_command_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v3/delete_command_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v3/install_command_spec.rb'
-- './spec/lib/gitlab/kubernetes/helm/v3/patch_command_spec.rb'
- './spec/lib/gitlab/kubernetes/ingress_spec.rb'
- './spec/lib/gitlab/kubernetes/kube_client_spec.rb'
- './spec/lib/gitlab/kubernetes/kubeconfig/entry/cluster_spec.rb'
@@ -7031,21 +6875,6 @@
- './spec/lib/gitlab/performance_bar_spec.rb'
- './spec/lib/gitlab/performance_bar/stats_spec.rb'
- './spec/lib/gitlab/performance_bar/with_top_level_warnings_spec.rb'
-- './spec/lib/gitlab/phabricator_import/cache/map_spec.rb'
-- './spec/lib/gitlab/phabricator_import/conduit/client_spec.rb'
-- './spec/lib/gitlab/phabricator_import/conduit/maniphest_spec.rb'
-- './spec/lib/gitlab/phabricator_import/conduit/response_spec.rb'
-- './spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb'
-- './spec/lib/gitlab/phabricator_import/conduit/user_spec.rb'
-- './spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb'
-- './spec/lib/gitlab/phabricator_import/importer_spec.rb'
-- './spec/lib/gitlab/phabricator_import/issues/importer_spec.rb'
-- './spec/lib/gitlab/phabricator_import/issues/task_importer_spec.rb'
-- './spec/lib/gitlab/phabricator_import/project_creator_spec.rb'
-- './spec/lib/gitlab/phabricator_import/representation/task_spec.rb'
-- './spec/lib/gitlab/phabricator_import/representation/user_spec.rb'
-- './spec/lib/gitlab/phabricator_import/user_finder_spec.rb'
-- './spec/lib/gitlab/phabricator_import/worker_state_spec.rb'
- './spec/lib/gitlab/pipeline_scope_counts_spec.rb'
- './spec/lib/gitlab/polling_interval_spec.rb'
- './spec/lib/gitlab/popen/runner_spec.rb'
@@ -7068,7 +6897,6 @@
- './spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb'
- './spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb'
- './spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb'
-- './spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb'
- './spec/lib/gitlab/prometheus/queries/matched_metric_query_spec.rb'
- './spec/lib/gitlab/prometheus/queries/validate_query_spec.rb'
- './spec/lib/gitlab/prometheus/query_variables_spec.rb'
@@ -7085,14 +6913,12 @@
- './spec/lib/gitlab/quick_actions/spend_time_and_date_separator_spec.rb'
- './spec/lib/gitlab/quick_actions/substitution_definition_spec.rb'
- './spec/lib/gitlab/quick_actions/users_extractor_spec.rb'
-- './spec/lib/gitlab/rack_attack/instrumented_cache_store_spec.rb'
- './spec/lib/gitlab/rack_attack/request_spec.rb'
- './spec/lib/gitlab/rack_attack_spec.rb'
- './spec/lib/gitlab/rack_attack/user_allowlist_spec.rb'
- './spec/lib/gitlab/reactive_cache_set_cache_spec.rb'
- './spec/lib/gitlab/redis/boolean_spec.rb'
- './spec/lib/gitlab/redis/cache_spec.rb'
-- './spec/lib/gitlab/redis/duplicate_jobs_spec.rb'
- './spec/lib/gitlab/redis/hll_spec.rb'
- './spec/lib/gitlab/redis/multi_store_spec.rb'
- './spec/lib/gitlab/redis/queues_spec.rb'
@@ -7154,8 +6980,6 @@
- './spec/lib/gitlab/seeder_spec.rb'
- './spec/lib/gitlab/serializer/ci/variables_spec.rb'
- './spec/lib/gitlab/serializer/pagination_spec.rb'
-- './spec/lib/gitlab/serverless/service_spec.rb'
-- './spec/lib/gitlab/service_desk_email_spec.rb'
- './spec/lib/gitlab/service_desk_spec.rb'
- './spec/lib/gitlab/session_spec.rb'
- './spec/lib/gitlab/setup_helper/praefect_spec.rb'
@@ -7225,7 +7049,6 @@
- './spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb'
- './spec/lib/gitlab/slash_commands/presenters/run_spec.rb'
- './spec/lib/gitlab/slash_commands/run_spec.rb'
-- './spec/lib/gitlab/slug/environment_spec.rb'
- './spec/lib/gitlab/snippet_search_results_spec.rb'
- './spec/lib/gitlab/sourcegraph_spec.rb'
- './spec/lib/gitlab/spamcheck/client_spec.rb'
@@ -7306,7 +7129,6 @@
- './spec/lib/gitlab/usage_data_counters/snippet_counter_spec.rb'
- './spec/lib/gitlab/usage_data_counters/source_code_counter_spec.rb'
- './spec/lib/gitlab/usage_data_counters_spec.rb'
-- './spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb'
- './spec/lib/gitlab/usage_data_counters/vscode_extension_activity_unique_counter_spec.rb'
- './spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb'
- './spec/lib/gitlab/usage_data_counters/wiki_page_counter_spec.rb'
@@ -7445,7 +7267,6 @@
- './spec/lib/sidebars/concerns/link_with_html_options_spec.rb'
- './spec/lib/sidebars/groups/menus/ci_cd_menu_spec.rb'
- './spec/lib/sidebars/groups/menus/group_information_menu_spec.rb'
-- './spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb'
- './spec/lib/sidebars/groups/menus/issues_menu_spec.rb'
- './spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb'
- './spec/lib/sidebars/groups/menus/merge_requests_menu_spec.rb'
@@ -7463,7 +7284,6 @@
- './spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb'
- './spec/lib/sidebars/projects/menus/hidden_menu_spec.rb'
- './spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb'
-- './spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb'
- './spec/lib/sidebars/projects/menus/issues_menu_spec.rb'
- './spec/lib/sidebars/projects/menus/merge_requests_menu_spec.rb'
- './spec/lib/sidebars/projects/menus/monitor_menu_spec.rb'
@@ -7490,7 +7310,6 @@
- './spec/lib/system_check/sidekiq_check_spec.rb'
- './spec/lib/system_check/simple_executor_spec.rb'
- './spec/lib/system_check_spec.rb'
-- './spec/lib/tasks/gitlab/metrics_exporter_task_spec.rb'
- './spec/lib/unnested_in_filters/dsl_spec.rb'
- './spec/lib/unnested_in_filters/rewriter_spec.rb'
- './spec/lib/uploaded_file_spec.rb'
@@ -7513,72 +7332,6 @@
- './spec/mailers/notify_spec.rb'
- './spec/mailers/repository_check_mailer_spec.rb'
- './spec/metrics_server/metrics_server_spec.rb'
-- './spec/migrations/20210603222333_remove_builds_email_service_from_services_spec.rb'
-- './spec/migrations/20210610153556_delete_legacy_operations_feature_flags_spec.rb'
-- './spec/migrations/2021061716138_cascade_delete_freeze_periods_spec.rb'
-- './spec/migrations/20210708130419_reschedule_merge_request_diff_users_background_migration_spec.rb'
-- './spec/migrations/20210713042000_fix_ci_sources_pipelines_index_names_spec.rb'
-- './spec/migrations/20210722042939_update_issuable_slas_where_issue_closed_spec.rb'
-- './spec/migrations/20210722150102_operations_feature_flags_correct_flexible_rollout_values_spec.rb'
-- './spec/migrations/20210804150320_create_base_work_item_types_spec.rb'
-- './spec/migrations/20210805192450_update_trial_plans_ci_daily_pipeline_schedule_triggers_spec.rb'
-- './spec/migrations/20210811122206_update_external_project_bots_spec.rb'
-- './spec/migrations/20210812013042_remove_duplicate_project_authorizations_spec.rb'
-- './spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb'
-- './spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb'
-- './spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb'
-- './spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb'
-- './spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb'
-- './spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb'
-- './spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb'
-- './spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb'
-- './spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb'
-- './spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb'
-- './spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb'
-- './spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb'
-- './spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb'
-- './spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb'
-- './spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb'
-- './spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb'
-- './spec/migrations/20211012134316_clean_up_migrate_merge_request_diff_commit_users_spec.rb'
-- './spec/migrations/20211018152654_schedule_remove_duplicate_vulnerabilities_findings3_spec.rb'
-- './spec/migrations/20211028155449_schedule_fix_merge_request_diff_commit_users_migration_spec.rb'
-- './spec/migrations/20211101222614_consume_remaining_user_namespace_jobs_spec.rb'
-- './spec/migrations/20211110143306_add_not_null_constraint_to_security_findings_uuid_spec.rb'
-- './spec/migrations/20211110151350_schedule_drop_invalid_security_findings_spec.rb'
-- './spec/migrations/20211116091751_change_namespace_type_default_to_user_spec.rb'
-- './spec/migrations/20211116111644_schedule_remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb'
-- './spec/migrations/20211117084814_migrate_remaining_u2f_registrations_spec.rb'
-- './spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb'
-- './spec/migrations/20211126204445_add_task_to_work_item_types_spec.rb'
-- './spec/migrations/20211130165043_backfill_sequence_column_for_sprints_table_spec.rb'
-- './spec/migrations/20211203091642_add_index_to_projects_on_marked_for_deletion_at_spec.rb'
-- './spec/migrations/20211207125331_remove_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb'
-- './spec/migrations/20211207135331_schedule_recalculate_uuid_on_vulnerabilities_occurrences4_spec.rb'
-- './spec/migrations/20211210140629_encrypt_static_object_token_spec.rb'
-- './spec/migrations/20211217174331_mark_recalculate_finding_signatures_as_completed_spec.rb'
-- './spec/migrations/20220106111958_add_insert_or_update_vulnerability_reads_trigger_spec.rb'
-- './spec/migrations/20220106112043_add_update_vulnerability_reads_trigger_spec.rb'
-- './spec/migrations/20220106112085_add_update_vulnerability_reads_location_trigger_spec.rb'
-- './spec/migrations/20220106163326_add_has_issues_on_vulnerability_reads_trigger_spec.rb'
-- './spec/migrations/20220107064845_populate_vulnerability_reads_spec.rb'
-- './spec/migrations/20220120094340_drop_position_from_security_findings_spec.rb'
-- './spec/migrations/20220124130028_dedup_runner_projects_spec.rb'
-- './spec/migrations/20220128155251_remove_dangling_running_builds_spec.rb'
-- './spec/migrations/20220128155814_fix_approval_rules_code_owners_rule_type_index_spec.rb'
-- './spec/migrations/20220202105733_delete_service_template_records_spec.rb'
-- './spec/migrations/20220204095121_backfill_namespace_statistics_with_dependency_proxy_size_spec.rb'
-- './spec/migrations/20220204194347_encrypt_integration_properties_spec.rb'
-- './spec/migrations/20220208080921_schedule_migrate_personal_namespace_project_maintainer_to_owner_spec.rb'
-- './spec/migrations/20220211214605_update_integrations_trigger_type_new_on_insert_null_safe_spec.rb'
-- './spec/migrations/20220213103859_remove_integrations_type_spec.rb'
-- './spec/migrations/20220222192524_create_not_null_constraint_releases_tag_spec.rb'
-- './spec/migrations/20220222192525_remove_null_releases_spec.rb'
-- './spec/migrations/20220223124428_schedule_merge_topics_with_same_name_spec.rb'
-- './spec/migrations/20220305223212_add_security_training_providers_spec.rb'
-- './spec/migrations/20220307192610_remove_duplicate_project_tag_releases_spec.rb'
-- './spec/migrations/20220309084954_remove_leftover_external_pull_request_deletions_spec.rb'
-- './spec/migrations/20220310141349_remove_dependency_list_usage_data_from_redis_spec.rb'
- './spec/migrations/20220315171129_cleanup_draft_data_from_faulty_regex_spec.rb'
- './spec/migrations/20220316202640_populate_container_repositories_migration_plan_spec.rb'
- './spec/migrations/20220321234317_remove_all_issuable_escalation_statuses_spec.rb'
@@ -7621,28 +7374,11 @@
- './spec/migrations/20220802114351_reschedule_backfill_container_registry_size_into_project_statistics_spec.rb'
- './spec/migrations/20220802204737_remove_deactivated_user_highest_role_stats_spec.rb'
- './spec/migrations/active_record/schema_spec.rb'
-- './spec/migrations/add_default_project_approval_rules_vuln_allowed_spec.rb'
- './spec/migrations/add_epics_relative_position_spec.rb'
-- './spec/migrations/add_open_source_plan_spec.rb'
-- './spec/migrations/add_premium_and_ultimate_plan_limits_spec.rb'
-- './spec/migrations/add_triggers_to_integrations_type_new_spec.rb'
-- './spec/migrations/add_upvotes_count_index_to_issues_spec.rb'
- './spec/migrations/add_web_hook_calls_to_plan_limits_paid_tiers_spec.rb'
-- './spec/migrations/associate_existing_dast_builds_with_variables_spec.rb'
-- './spec/migrations/backfill_all_project_namespaces_spec.rb'
-- './spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb'
-- './spec/migrations/backfill_cycle_analytics_aggregations_spec.rb'
-- './spec/migrations/backfill_group_features_spec.rb'
- './spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb'
-- './spec/migrations/backfill_integrations_type_new_spec.rb'
-- './spec/migrations/backfill_issues_upvotes_count_spec.rb'
-- './spec/migrations/backfill_member_namespace_id_for_group_members_spec.rb'
-- './spec/migrations/backfill_namespace_id_for_namespace_routes_spec.rb'
- './spec/migrations/backfill_namespace_id_for_project_routes_spec.rb'
- './spec/migrations/backfill_project_import_level_spec.rb'
-- './spec/migrations/backfill_project_namespaces_for_group_spec.rb'
-- './spec/migrations/backfill_stage_event_hash_spec.rb'
-- './spec/migrations/backfill_user_namespace_spec.rb'
- './spec/migrations/bulk_insert_cluster_enabled_grants_spec.rb'
- './spec/migrations/change_public_projects_cost_factor_spec.rb'
- './spec/migrations/cleanup_after_fixing_issue_when_admin_changed_primary_email_spec.rb'
@@ -7650,60 +7386,21 @@
- './spec/migrations/cleanup_backfill_integrations_enable_ssl_verification_spec.rb'
- './spec/migrations/cleanup_mr_attention_request_todos_spec.rb'
- './spec/migrations/cleanup_orphaned_routes_spec.rb'
-- './spec/migrations/cleanup_remaining_orphan_invites_spec.rb'
-- './spec/migrations/confirm_security_bot_spec.rb'
-- './spec/migrations/disable_expiration_policies_linked_to_no_container_images_spec.rb'
-- './spec/migrations/disable_job_token_scope_when_unused_spec.rb'
- './spec/migrations/finalize_orphaned_routes_cleanup_spec.rb'
- './spec/migrations/finalize_project_namespaces_backfill_spec.rb'
- './spec/migrations/finalize_routes_backfilling_for_projects_spec.rb'
-- './spec/migrations/finalize_traversal_ids_background_migrations_spec.rb'
- './spec/migrations/fix_and_backfill_project_namespaces_for_projects_with_duplicate_name_spec.rb'
-- './spec/migrations/fix_batched_migrations_old_format_job_arguments_spec.rb'
-- './spec/migrations/generate_customers_dot_jwt_signing_key_spec.rb'
-- './spec/migrations/migrate_protected_attribute_to_pending_builds_spec.rb'
-- './spec/migrations/orphaned_invite_tokens_cleanup_spec.rb'
-- './spec/migrations/populate_audit_event_streaming_verification_token_spec.rb'
- './spec/migrations/populate_operation_visibility_permissions_spec.rb'
- './spec/migrations/queue_backfill_project_feature_package_registry_access_level_spec.rb'
-- './spec/migrations/recreate_index_security_ci_builds_on_name_and_id_parser_features_spec.rb'
-- './spec/migrations/recreate_index_security_ci_builds_on_name_and_id_parser_with_new_features_spec.rb'
-- './spec/migrations/remove_duplicate_dast_site_tokens_spec.rb'
-- './spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb'
- './spec/migrations/remove_invalid_integrations_spec.rb'
-- './spec/migrations/remove_not_null_contraint_on_title_from_sprints_spec.rb'
-- './spec/migrations/remove_schedule_and_status_from_pending_alert_escalations_spec.rb'
- './spec/migrations/remove_wiki_notes_spec.rb'
-- './spec/migrations/rename_services_to_integrations_spec.rb'
-- './spec/migrations/replace_external_wiki_triggers_spec.rb'
- './spec/migrations/reschedule_backfill_imported_issue_search_data_spec.rb'
-- './spec/migrations/reschedule_delete_orphaned_deployments_spec.rb'
-- './spec/migrations/re_schedule_latest_pipeline_id_population_with_all_security_related_artifact_types_spec.rb'
-- './spec/migrations/reset_job_token_scope_enabled_again_spec.rb'
-- './spec/migrations/reset_job_token_scope_enabled_spec.rb'
-- './spec/migrations/reset_severity_levels_to_new_default_spec.rb'
-- './spec/migrations/retry_backfill_traversal_ids_spec.rb'
- './spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb'
- './spec/migrations/schedule_backfilling_the_namespace_id_for_vulnerability_reads_spec.rb'
-- './spec/migrations/schedule_copy_ci_builds_columns_to_security_scans2_spec.rb'
-- './spec/migrations/schedule_fix_incorrect_max_seats_used2_spec.rb'
-- './spec/migrations/schedule_fix_incorrect_max_seats_used_spec.rb'
- './spec/migrations/schedule_populate_requirements_issue_id_spec.rb'
- './spec/migrations/schedule_purging_stale_security_scans_spec.rb'
-- './spec/migrations/schedule_recalculate_vulnerability_finding_signatures_for_findings_spec.rb'
-- './spec/migrations/schedule_security_setting_creation_spec.rb'
- './spec/migrations/schedule_set_correct_vulnerability_state_spec.rb'
-- './spec/migrations/schedule_update_timelogs_null_spent_at_spec.rb'
-- './spec/migrations/set_default_job_token_scope_true_spec.rb'
-- './spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb'
-- './spec/migrations/start_backfill_ci_queuing_tables_spec.rb'
-- './spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb'
- './spec/migrations/toggle_vsa_aggregations_enable_spec.rb'
-- './spec/migrations/update_application_settings_container_registry_exp_pol_worker_capacity_default_spec.rb'
-- './spec/migrations/update_application_settings_protected_paths_spec.rb'
-- './spec/migrations/update_default_scan_method_of_dast_site_profile_spec.rb'
-- './spec/migrations/update_integrations_trigger_type_new_on_insert_spec.rb'
-- './spec/migrations/update_invalid_member_states_spec.rb'
- './spec/models/ability_spec.rb'
- './spec/models/abuse_report_spec.rb'
- './spec/models/active_session_spec.rb'
@@ -7718,7 +7415,6 @@
- './spec/models/analytics/cycle_analytics/aggregation_spec.rb'
- './spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb'
- './spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb'
-- './spec/models/analytics/cycle_analytics/project_value_stream_spec.rb'
- './spec/models/analytics/cycle_analytics/stage_event_hash_spec.rb'
- './spec/models/analytics/usage_trends/measurement_spec.rb'
- './spec/models/appearance_spec.rb'
@@ -7730,7 +7426,6 @@
- './spec/models/audit_event_spec.rb'
- './spec/models/authentication_event_spec.rb'
- './spec/models/award_emoji_spec.rb'
-- './spec/models/awareness_session_spec.rb'
- './spec/models/aws/role_spec.rb'
- './spec/models/badges/group_badge_spec.rb'
- './spec/models/badge_spec.rb'
@@ -7754,7 +7449,6 @@
- './spec/models/board_group_recent_visit_spec.rb'
- './spec/models/board_project_recent_visit_spec.rb'
- './spec/models/board_spec.rb'
-- './spec/models/broadcast_message_spec.rb'
- './spec/models/bulk_imports/configuration_spec.rb'
- './spec/models/bulk_imports/entity_spec.rb'
- './spec/models/bulk_imports/export_spec.rb'
@@ -7824,20 +7518,8 @@
- './spec/models/ci/unit_test_spec.rb'
- './spec/models/ci/variable_spec.rb'
- './spec/models/clusters/agents/activity_event_spec.rb'
-- './spec/models/clusters/agents/group_authorization_spec.rb'
-- './spec/models/clusters/agents/implicit_authorization_spec.rb'
- './spec/models/clusters/agent_spec.rb'
-- './spec/models/clusters/agents/project_authorization_spec.rb'
- './spec/models/clusters/agent_token_spec.rb'
-- './spec/models/clusters/applications/cert_manager_spec.rb'
-- './spec/models/clusters/applications/cilium_spec.rb'
-- './spec/models/clusters/applications/crossplane_spec.rb'
-- './spec/models/clusters/applications/helm_spec.rb'
-- './spec/models/clusters/applications/ingress_spec.rb'
-- './spec/models/clusters/applications/jupyter_spec.rb'
-- './spec/models/clusters/applications/knative_spec.rb'
-- './spec/models/clusters/applications/prometheus_spec.rb'
-- './spec/models/clusters/applications/runner_spec.rb'
- './spec/models/clusters/cluster_enabled_grant_spec.rb'
- './spec/models/clusters/clusters_hierarchy_spec.rb'
- './spec/models/clusters/cluster_spec.rb'
@@ -7862,7 +7544,6 @@
- './spec/models/concerns/atomic_internal_id_spec.rb'
- './spec/models/concerns/avatarable_spec.rb'
- './spec/models/concerns/awardable_spec.rb'
-- './spec/models/concerns/awareness_spec.rb'
- './spec/models/concerns/batch_destroy_dependent_associations_spec.rb'
- './spec/models/concerns/batch_nullify_dependent_associations_spec.rb'
- './spec/models/concerns/blob_language_from_git_attributes_spec.rb'
@@ -7880,7 +7561,6 @@
- './spec/models/concerns/ci/has_status_spec.rb'
- './spec/models/concerns/ci/has_variable_spec.rb'
- './spec/models/concerns/ci/maskable_spec.rb'
-- './spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb'
- './spec/models/concerns/counter_attribute_spec.rb'
- './spec/models/concerns/cron_schedulable_spec.rb'
- './spec/models/concerns/cross_database_modification_spec.rb'
@@ -7956,7 +7636,6 @@
- './spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb'
- './spec/models/concerns/transactions_spec.rb'
- './spec/models/concerns/triggerable_hooks_spec.rb'
-- './spec/models/concerns/uniquify_spec.rb'
- './spec/models/concerns/usage_statistics_spec.rb'
- './spec/models/concerns/vulnerability_finding_helpers_spec.rb'
- './spec/models/concerns/vulnerability_finding_signature_helpers_spec.rb'
@@ -8015,7 +7694,6 @@
- './spec/models/exported_protected_branch_spec.rb'
- './spec/models/external_issue_spec.rb'
- './spec/models/external_pull_request_spec.rb'
-- './spec/models/factories_spec.rb'
- './spec/models/fork_network_member_spec.rb'
- './spec/models/fork_network_spec.rb'
- './spec/models/generic_commit_status_spec.rb'
@@ -8131,7 +7809,6 @@
- './spec/models/loose_foreign_keys/modification_tracker_spec.rb'
- './spec/models/members/group_member_spec.rb'
- './spec/models/members/last_group_owner_assigner_spec.rb'
-- './spec/models/members/member_role_spec.rb'
- './spec/models/members/member_task_spec.rb'
- './spec/models/member_spec.rb'
- './spec/models/members/project_member_spec.rb'
@@ -8239,7 +7916,6 @@
- './spec/models/preloaders/merge_request_diff_preloader_spec.rb'
- './spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb'
- './spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb'
-- './spec/models/preloaders/users_max_access_level_in_projects_preloader_spec.rb'
- './spec/models/product_analytics_event_spec.rb'
- './spec/models/programming_language_spec.rb'
- './spec/models/project_authorization_spec.rb'
@@ -8300,9 +7976,6 @@
- './spec/models/route_spec.rb'
- './spec/models/sent_notification_spec.rb'
- './spec/models/sentry_issue_spec.rb'
-- './spec/models/serverless/domain_cluster_spec.rb'
-- './spec/models/serverless/domain_spec.rb'
-- './spec/models/serverless/function_spec.rb'
- './spec/models/service_desk_setting_spec.rb'
- './spec/models/shard_spec.rb'
- './spec/models/snippet_blob_spec.rb'
@@ -8566,7 +8239,6 @@
- './spec/requests/api/environments_spec.rb'
- './spec/requests/api/error_tracking/client_keys_spec.rb'
- './spec/requests/api/error_tracking/collector_spec.rb'
-- './spec/requests/api/error_tracking/project_settings_spec.rb'
- './spec/requests/api/events_spec.rb'
- './spec/requests/api/feature_flags_spec.rb'
- './spec/requests/api/feature_flags_user_lists_spec.rb'
@@ -8612,7 +8284,6 @@
- './spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb'
- './spec/requests/api/graphql/group/group_members_spec.rb'
- './spec/requests/api/graphql/group/issues_spec.rb'
-- './spec/requests/api/graphql/group/labels_query_spec.rb'
- './spec/requests/api/graphql/group/merge_requests_spec.rb'
- './spec/requests/api/graphql/group/milestones_spec.rb'
- './spec/requests/api/graphql/group/packages_spec.rb'
@@ -8650,12 +8321,8 @@
- './spec/requests/api/graphql/mutations/boards/lists/destroy_spec.rb'
- './spec/requests/api/graphql/mutations/boards/lists/update_spec.rb'
- './spec/requests/api/graphql/mutations/branches/create_spec.rb'
-- './spec/requests/api/graphql/mutations/ci/job_cancel_spec.rb'
-- './spec/requests/api/graphql/mutations/ci/job_play_spec.rb'
-- './spec/requests/api/graphql/mutations/ci/job_retry_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/ci/job_unschedule_spec.rb'
- './spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb'
- './spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb'
- './spec/requests/api/graphql/mutations/ci/pipeline_retry_spec.rb'
@@ -8956,7 +8623,6 @@
- './spec/requests/groups/settings/access_tokens_controller_spec.rb'
- './spec/requests/groups/settings/applications_controller_spec.rb'
- './spec/requests/health_controller_spec.rb'
-- './spec/requests/ide_controller_spec.rb'
- './spec/requests/import/gitlab_groups_controller_spec.rb'
- './spec/requests/import/gitlab_projects_controller_spec.rb'
- './spec/requests/import/url_controller_spec.rb'
@@ -8965,7 +8631,6 @@
- './spec/requests/jira_connect/oauth_application_ids_controller_spec.rb'
- './spec/requests/jira_connect/oauth_callbacks_controller_spec.rb'
- './spec/requests/jira_connect/subscriptions_controller_spec.rb'
-- './spec/requests/jira_connect/users_controller_spec.rb'
- './spec/requests/jira_routing_spec.rb'
- './spec/requests/jwks_controller_spec.rb'
- './spec/requests/jwt_controller_spec.rb'
@@ -9024,7 +8689,6 @@
- './spec/requests/runner_setup_controller_spec.rb'
- './spec/requests/sandbox_controller_spec.rb'
- './spec/requests/search_controller_spec.rb'
-- './spec/requests/self_monitoring_project_spec.rb'
- './spec/requests/sessions_spec.rb'
- './spec/requests/terraform/services_controller_spec.rb'
- './spec/requests/user_activity_spec.rb'
@@ -9086,7 +8750,6 @@
- './spec/serializers/ci/trigger_entity_spec.rb'
- './spec/serializers/ci/trigger_serializer_spec.rb'
- './spec/serializers/ci/variable_entity_spec.rb'
-- './spec/serializers/cluster_application_entity_spec.rb'
- './spec/serializers/cluster_entity_spec.rb'
- './spec/serializers/cluster_serializer_spec.rb'
- './spec/serializers/clusters/kubernetes_error_entity_spec.rb'
@@ -9407,7 +9070,6 @@
- './spec/services/clusters/agents/create_service_spec.rb'
- './spec/services/clusters/agents/delete_expired_events_service_spec.rb'
- './spec/services/clusters/agents/delete_service_spec.rb'
-- './spec/services/clusters/agents/refresh_authorization_service_spec.rb'
- './spec/services/clusters/agent_tokens/create_service_spec.rb'
- './spec/services/clusters/agent_tokens/track_usage_service_spec.rb'
- './spec/services/clusters/build_kubernetes_namespace_service_spec.rb'
@@ -9685,7 +9347,6 @@
- './spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb'
- './spec/services/metrics/dashboard/panel_preview_service_spec.rb'
- './spec/services/metrics/dashboard/pod_dashboard_service_spec.rb'
-- './spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb'
- './spec/services/metrics/dashboard/system_dashboard_service_spec.rb'
- './spec/services/metrics/dashboard/transient_embed_service_spec.rb'
- './spec/services/metrics/dashboard/update_dashboard_service_spec.rb'
@@ -9795,12 +9456,10 @@
- './spec/services/projects/alerting/notify_service_spec.rb'
- './spec/services/projects/all_issues_count_service_spec.rb'
- './spec/services/projects/all_merge_requests_count_service_spec.rb'
-- './spec/services/projects/android_target_platform_detector_service_spec.rb'
- './spec/services/projects/apple_target_platform_detector_service_spec.rb'
- './spec/services/projects/autocomplete_service_spec.rb'
- './spec/services/projects/auto_devops/disable_service_spec.rb'
- './spec/services/projects/batch_open_issues_count_service_spec.rb'
-- './spec/services/projects/blame_service_spec.rb'
- './spec/services/projects/branches_by_mode_service_spec.rb'
- './spec/services/projects/cleanup_service_spec.rb'
- './spec/services/projects/container_repository/cleanup_tags_service_spec.rb'
@@ -9911,7 +9570,6 @@
- './spec/services/security/ci_configuration/sast_parser_service_spec.rb'
- './spec/services/security/ci_configuration/secret_detection_create_service_spec.rb'
- './spec/services/security/merge_reports_service_spec.rb'
-- './spec/services/serverless/associate_domain_service_spec.rb'
- './spec/services/service_desk_settings/update_service_spec.rb'
- './spec/services/service_ping/submit_service_ping_service_spec.rb'
- './spec/services/service_response_spec.rb'
@@ -10057,7 +9715,6 @@
- './spec/tasks/cache/clear/redis_spec.rb'
- './spec/tasks/config_lint_spec.rb'
- './spec/tasks/dev_rake_spec.rb'
-- './spec/tasks/gettext_rake_spec.rb'
- './spec/tasks/gitlab/artifacts/check_rake_spec.rb'
- './spec/tasks/gitlab/artifacts/migrate_rake_spec.rb'
- './spec/tasks/gitlab/background_migrations_rake_spec.rb'
@@ -10106,7 +9763,7 @@
- './spec/tooling/danger/customer_success_spec.rb'
- './spec/tooling/danger/datateam_spec.rb'
- './spec/tooling/danger/feature_flag_spec.rb'
-- './spec/tooling/danger/product_intelligence_spec.rb'
+- './spec/tooling/danger/analytics_instrumentation_spec.rb'
- './spec/tooling/danger/project_helper_spec.rb'
- './spec/tooling/danger/sidekiq_queues_spec.rb'
- './spec/tooling/danger/specs_spec.rb'
@@ -10188,7 +9845,6 @@
- './spec/views/admin/application_settings/_repository_check.html.haml_spec.rb'
- './spec/views/admin/application_settings/repository.html.haml_spec.rb'
- './spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb'
-- './spec/views/admin/broadcast_messages/index.html.haml_spec.rb'
- './spec/views/admin/dashboard/index.html.haml_spec.rb'
- './spec/views/admin/identities/index.html.haml_spec.rb'
- './spec/views/admin/sessions/new.html.haml_spec.rb'
@@ -10231,7 +9887,6 @@
- './spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb'
- './spec/views/layouts/profile.html.haml_spec.rb'
- './spec/views/layouts/_published_experiments.html.haml_spec.rb'
-- './spec/views/layouts/_search.html.haml_spec.rb'
- './spec/views/layouts/signup_onboarding.html.haml_spec.rb'
- './spec/views/layouts/simple_registration.html.haml_spec.rb'
- './spec/views/layouts/terms.html.haml_spec.rb'
@@ -10307,7 +9962,6 @@
- './spec/views/shared/projects/_inactive_project_deletion_alert.html.haml_spec.rb'
- './spec/views/shared/projects/_list.html.haml_spec.rb'
- './spec/views/shared/projects/_project.html.haml_spec.rb'
-- './spec/views/shared/runners/_runner_details.html.haml_spec.rb'
- './spec/views/shared/snippets/_snippet.html.haml_spec.rb'
- './spec/views/shared/wikis/_sidebar.html.haml_spec.rb'
- './spec/workers/admin_email_worker_spec.rb'
@@ -10343,7 +9997,6 @@
- './spec/workers/ci/build_schedule_worker_spec.rb'
- './spec/workers/ci/build_trace_chunk_flush_worker_spec.rb'
- './spec/workers/ci/cancel_pipeline_worker_spec.rb'
-- './spec/workers/ci/create_cross_project_pipeline_worker_spec.rb'
- './spec/workers/ci/create_downstream_pipeline_worker_spec.rb'
- './spec/workers/ci/daily_build_group_report_results_worker_spec.rb'
- './spec/workers/ci/delete_objects_worker_spec.rb'
@@ -10381,18 +10034,14 @@
- './spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb'
- './spec/workers/concerns/application_worker_spec.rb'
- './spec/workers/concerns/cluster_agent_queue_spec.rb'
-- './spec/workers/concerns/cluster_queue_spec.rb'
- './spec/workers/concerns/cronjob_queue_spec.rb'
- './spec/workers/concerns/gitlab/github_import/object_importer_spec.rb'
-- './spec/workers/concerns/gitlab/github_import/queue_spec.rb'
- './spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb'
- './spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb'
- './spec/workers/concerns/gitlab/notify_upon_death_spec.rb'
- './spec/workers/concerns/limited_capacity/job_tracker_spec.rb'
- './spec/workers/concerns/limited_capacity/worker_spec.rb'
- './spec/workers/concerns/packages/cleanup_artifact_worker_spec.rb'
-- './spec/workers/concerns/pipeline_background_queue_spec.rb'
-- './spec/workers/concerns/pipeline_queue_spec.rb'
- './spec/workers/concerns/project_import_options_spec.rb'
- './spec/workers/concerns/reenqueuer_spec.rb'
- './spec/workers/concerns/repository_check_queue_spec.rb'
@@ -10422,7 +10071,6 @@
- './spec/workers/dependency_proxy/cleanup_manifest_worker_spec.rb'
- './spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb'
- './spec/workers/deployments/archive_in_project_worker_spec.rb'
-- './spec/workers/deployments/drop_older_deployments_worker_spec.rb'
- './spec/workers/deployments/hooks_worker_spec.rb'
- './spec/workers/deployments/link_merge_request_worker_spec.rb'
- './spec/workers/deployments/update_environment_worker_spec.rb'
@@ -10475,8 +10123,6 @@
- './spec/workers/gitlab/jira_import/stage/start_import_worker_spec.rb'
- './spec/workers/gitlab/jira_import/stuck_jira_import_jobs_worker_spec.rb'
- './spec/workers/gitlab_performance_bar_stats_worker_spec.rb'
-- './spec/workers/gitlab/phabricator_import/base_worker_spec.rb'
-- './spec/workers/gitlab/phabricator_import/import_tasks_worker_spec.rb'
- './spec/workers/gitlab_service_ping_worker_spec.rb'
- './spec/workers/gitlab_shell_worker_spec.rb'
- './spec/workers/google_cloud/create_cloudsql_instance_worker_spec.rb'
@@ -10614,8 +10260,6 @@
- './spec/workers/run_pipeline_schedule_worker_spec.rb'
- './spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb'
- './spec/workers/schedule_migrate_external_diffs_worker_spec.rb'
-- './spec/workers/self_monitoring_project_create_worker_spec.rb'
-- './spec/workers/self_monitoring_project_delete_worker_spec.rb'
- './spec/workers/service_desk_email_receiver_worker_spec.rb'
- './spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb'
- './spec/workers/snippets/update_repository_storage_worker_spec.rb'
diff --git a/spec/support/services/clusters/create_service_shared.rb b/spec/support/services/clusters/create_service_shared.rb
deleted file mode 100644
index 80fa7c58515..00000000000
--- a/spec/support/services/clusters/create_service_shared.rb
+++ /dev/null
@@ -1,64 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_context 'valid cluster create params' do
- let(:clusterable) { Clusters::Instance.new }
- let(:params) do
- {
- name: 'test-cluster',
- provider_type: :gcp,
- provider_gcp_attributes: {
- gcp_project_id: 'gcp-project',
- zone: 'us-central1-a',
- num_nodes: 1,
- machine_type: 'machine_type-a',
- legacy_abac: 'true'
- },
- clusterable: clusterable
- }
- end
-end
-
-RSpec.shared_context 'invalid cluster create params' do
- let(:clusterable) { Clusters::Instance.new }
- let(:params) do
- {
- name: 'test-cluster',
- provider_type: :gcp,
- provider_gcp_attributes: {
- gcp_project_id: '!!!!!!!',
- zone: 'us-central1-a',
- num_nodes: 1,
- machine_type: 'machine_type-a'
- },
- clusterable: clusterable
-
- }
- end
-end
-
-RSpec.shared_examples 'create cluster service success' do
- it 'creates a cluster object' do
- expect { subject }
- .to change { Clusters::Cluster.count }.by(1)
- .and change { Clusters::Providers::Gcp.count }.by(1)
-
- expect(subject.name).to eq('test-cluster')
- expect(subject.user).to eq(user)
- expect(subject.project).to eq(project)
- expect(subject.provider.gcp_project_id).to eq('gcp-project')
- expect(subject.provider.zone).to eq('us-central1-a')
- expect(subject.provider.num_nodes).to eq(1)
- expect(subject.provider.machine_type).to eq('machine_type-a')
- expect(subject.provider.access_token).to eq(access_token)
- expect(subject.provider).to be_legacy_abac
- expect(subject.platform).to be_nil
- expect(subject.namespace_per_environment).to eq true
- end
-end
-
-RSpec.shared_examples 'create cluster service error' do
- it 'returns an error' do
- expect { subject }.to change { Clusters::Cluster.count }.by(0)
- expect(subject.errors[:"provider_gcp.gcp_project_id"]).to be_present
- end
-end
diff --git a/spec/support/services/deploy_token_shared_examples.rb b/spec/support/services/deploy_token_shared_examples.rb
deleted file mode 100644
index d322b3fc81d..00000000000
--- a/spec/support/services/deploy_token_shared_examples.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'a deploy token creation service' do
- let(:user) { create(:user) }
- let(:deploy_token_params) { attributes_for(:deploy_token) }
-
- describe '#execute' do
- subject { described_class.new(entity, user, deploy_token_params).execute }
-
- context 'when the deploy token is valid' do
- it 'creates a new DeployToken' do
- expect { subject }.to change { DeployToken.count }.by(1)
- end
-
- it 'creates a new ProjectDeployToken' do
- expect { subject }.to change { deploy_token_class.count }.by(1)
- end
-
- it 'returns a DeployToken' do
- expect(subject[:deploy_token]).to be_an_instance_of DeployToken
- end
-
- it 'sets the creator_id as the id of the current_user' do
- expect(subject[:deploy_token].read_attribute(:creator_id)).to eq(user.id)
- end
- end
-
- context 'when expires at date is not passed' do
- let(:deploy_token_params) { attributes_for(:deploy_token, expires_at: '') }
-
- it 'sets Forever.date' do
- expect(subject[:deploy_token].read_attribute(:expires_at)).to eq(Forever.date)
- end
- end
-
- context 'when username is empty string' do
- let(:deploy_token_params) { attributes_for(:deploy_token, username: '') }
-
- it 'converts it to nil' do
- expect(subject[:deploy_token].read_attribute(:username)).to be_nil
- end
- end
-
- context 'when username is provided' do
- let(:deploy_token_params) { attributes_for(:deploy_token, username: 'deployer') }
-
- it 'keeps the provided username' do
- expect(subject[:deploy_token].read_attribute(:username)).to eq('deployer')
- end
- end
-
- context 'when the deploy token is invalid' do
- let(:deploy_token_params) { attributes_for(:deploy_token, read_repository: false, read_registry: false, write_registry: false) }
-
- it 'does not create a new DeployToken' do
- expect { subject }.not_to change { DeployToken.count }
- end
-
- it 'does not create a new ProjectDeployToken' do
- expect { subject }.not_to change { deploy_token_class.count }
- end
- end
- end
-end
-
-RSpec.shared_examples 'a deploy token deletion service' do
- let(:user) { create(:user) }
- let(:deploy_token_params) { { token_id: deploy_token.id } }
-
- describe '#execute' do
- subject { described_class.new(entity, user, deploy_token_params).execute }
-
- it "destroys a token record and it's associated DeployToken" do
- expect { subject }.to change { deploy_token_class.count }.by(-1)
- .and change { DeployToken.count }.by(-1)
- end
-
- context 'invalid token id' do
- let(:deploy_token_params) { { token_id: 9999 } }
-
- it 'raises an error' do
- expect { subject }.to raise_error(ActiveRecord::RecordNotFound)
- end
- end
- end
-end
diff --git a/spec/support/services/issuable_import_csv_service_shared_examples.rb b/spec/support/services/issuable_import_csv_service_shared_examples.rb
deleted file mode 100644
index 0dea6cfb729..00000000000
--- a/spec/support/services/issuable_import_csv_service_shared_examples.rb
+++ /dev/null
@@ -1,138 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.shared_examples 'issuable import csv service' do |issuable_type|
- let_it_be_with_refind(:project) { create(:project) }
- let_it_be(:user) { create(:user) }
-
- subject { service.execute }
-
- shared_examples_for 'an issuable importer' do
- if issuable_type == 'issue'
- it 'records the import attempt if resource is an issue' do
- expect { subject }
- .to change { Issues::CsvImport.where(project: project, user: user).count }
- .by 1
- end
- end
- end
-
- shared_examples_for 'importer with email notification' do
- it 'notifies user of import result' do
- expect(Notify).to receive_message_chain(email_method, :deliver_later)
-
- subject
- end
- end
-
- shared_examples_for 'invalid file' do
- it 'returns invalid file error' do
- expect(subject[:success]).to eq(0)
- expect(subject[:parse_error]).to eq(true)
- end
-
- it_behaves_like 'importer with email notification'
- it_behaves_like 'an issuable importer'
- end
-
- describe '#execute' do
- before do
- project.add_developer(user)
- end
-
- context 'invalid file extension' do
- let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') }
-
- it_behaves_like 'invalid file'
- end
-
- context 'empty file' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_empty.csv') }
-
- it_behaves_like 'invalid file'
- end
-
- context 'file without headers' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_no_headers.csv') }
-
- it_behaves_like 'invalid file'
- end
-
- context 'with a file generated by Gitlab CSV export' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_gitlab_export.csv') }
-
- it 'imports the CSV without errors' do
- expect(subject[:success]).to eq(4)
- expect(subject[:error_lines]).to eq([])
- expect(subject[:parse_error]).to eq(false)
- end
-
- it 'correctly sets the issuable attributes' do
- expect { subject }.to change { issuables.count }.by 4
-
- expect(issuables.reload).to include(have_attributes({ title: 'Test Title', description: 'Test Description' }))
- end
-
- it_behaves_like 'importer with email notification'
- it_behaves_like 'an issuable importer'
- end
-
- context 'comma delimited file' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_comma.csv') }
-
- it 'imports CSV without errors' do
- expect(subject[:success]).to eq(3)
- expect(subject[:error_lines]).to eq([])
- expect(subject[:parse_error]).to eq(false)
- end
-
- it 'correctly sets the issuable attributes' do
- expect { subject }.to change { issuables.count }.by 3
-
- expect(issuables.reload).to include(have_attributes(title: 'Title with quote"', description: 'Description'))
- end
-
- it_behaves_like 'importer with email notification'
- it_behaves_like 'an issuable importer'
- end
-
- context 'tab delimited file with error row' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_tab.csv') }
-
- it 'imports CSV with some error rows' do
- expect(subject[:success]).to eq(2)
- expect(subject[:error_lines]).to eq([3])
- expect(subject[:parse_error]).to eq(false)
- end
-
- it 'correctly sets the issuable attributes' do
- expect { subject }.to change { issuables.count }.by 2
-
- expect(issuables.reload).to include(have_attributes(title: 'Hello', description: 'World'))
- end
-
- it_behaves_like 'importer with email notification'
- it_behaves_like 'an issuable importer'
- end
-
- context 'semicolon delimited file with CRLF' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_semicolon.csv') }
-
- it 'imports CSV with a blank row' do
- expect(subject[:success]).to eq(3)
- expect(subject[:error_lines]).to eq([4])
- expect(subject[:parse_error]).to eq(false)
- end
-
- it 'correctly sets the issuable attributes' do
- expect { subject }.to change { issuables.count }.by 3
-
- expect(issuables.reload).to include(have_attributes(title: 'Hello', description: 'World'))
- end
-
- it_behaves_like 'importer with email notification'
- it_behaves_like 'an issuable importer'
- end
- end
-end
diff --git a/spec/support/services/issuable_update_service_shared_examples.rb b/spec/support/services/issuable_update_service_shared_examples.rb
deleted file mode 100644
index feea21be428..00000000000
--- a/spec/support/services/issuable_update_service_shared_examples.rb
+++ /dev/null
@@ -1,99 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'issuable update service' do
- def update_issuable(opts)
- described_class.new(project, user, opts).execute(open_issuable)
- end
-
- context 'changing state' do
- let(:hook_event) { :"#{closed_issuable.class.name.underscore.to_sym}_hooks" }
-
- context 'to reopened' do
- let(:expected_payload) do
- include(
- changes: include(
- state_id: { current: 1, previous: 2 },
- updated_at: { current: kind_of(Time), previous: kind_of(Time) }
- ),
- object_attributes: include(
- state: 'opened',
- action: 'reopen'
- )
- )
- end
-
- it 'executes hooks' do
- expect(project).to receive(:execute_hooks).with(expected_payload, hook_event)
- expect(project).to receive(:execute_integrations).with(expected_payload, hook_event)
-
- described_class.new(**described_class.constructor_container_arg(project), current_user: user, params: { state_event: 'reopen' }).execute(closed_issuable)
- end
- end
-
- context 'to closed' do
- let(:expected_payload) do
- include(
- changes: include(
- state_id: { current: 2, previous: 1 },
- updated_at: { current: kind_of(Time), previous: kind_of(Time) }
- ),
- object_attributes: include(
- state: 'closed',
- action: 'close'
- )
- )
- end
-
- it 'executes hooks' do
- expect(project).to receive(:execute_hooks).with(expected_payload, hook_event)
- expect(project).to receive(:execute_integrations).with(expected_payload, hook_event)
-
- described_class.new(**described_class.constructor_container_arg(project), current_user: user, params: { state_event: 'close' }).execute(open_issuable)
- end
- end
- end
-end
-
-RSpec.shared_examples 'keeps issuable labels sorted after update' do
- before do
- update_issuable(label_ids: [label_b.id])
- end
-
- context 'when label is changed' do
- it 'keeps the labels sorted by title ASC' do
- update_issuable({ add_label_ids: [label_a.id] })
-
- expect(issuable.labels).to eq([label_a, label_b])
- end
- end
-end
-
-RSpec.shared_examples 'broadcasting issuable labels updates' do
- before do
- update_issuable(label_ids: [label_a.id])
- end
-
- context 'when label is added' do
- it 'triggers the GraphQL subscription' do
- expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable)
-
- update_issuable(add_label_ids: [label_b.id])
- end
- end
-
- context 'when label is removed' do
- it 'triggers the GraphQL subscription' do
- expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable)
-
- update_issuable(remove_label_ids: [label_a.id])
- end
- end
-
- context 'when label is unchanged' do
- it 'does not trigger the GraphQL subscription' do
- expect(GraphqlTriggers).not_to receive(:issuable_labels_updated).with(issuable)
-
- update_issuable(label_ids: [label_a.id])
- end
- end
-end
diff --git a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb
deleted file mode 100644
index ae98ce689e3..00000000000
--- a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples "migrating a deleted user's associated records to the ghost user" do |record_class, fields|
- record_class_name = record_class.to_s.titleize.downcase
-
- let(:project) do
- case record_class
- when MergeRequest
- create(:project, :repository)
- else
- create(:project)
- end
- end
-
- before do
- project.add_developer(user)
- end
-
- context "for a #{record_class_name} the user has created" do
- let!(:record) { created_record }
- let(:migrated_fields) { fields || [:author] }
-
- it "does not delete the #{record_class_name}" do
- service.execute
-
- expect(record_class.find_by_id(record.id)).to be_present
- end
-
- it "blocks the user before migrating #{record_class_name}s to the 'Ghost User'" do
- service.execute
-
- expect(user).to be_blocked
- end
-
- it 'migrates all associated fields to the "Ghost user"' do
- service.execute
-
- migrated_record = record_class.find_by_id(record.id)
-
- migrated_fields.each do |field|
- expect(migrated_record.public_send(field)).to eq(User.ghost)
- end
- end
-
- it 'will only migrate specific records during a hard_delete' do
- service.execute(hard_delete: true)
-
- migrated_record = record_class.find_by_id(record.id)
-
- check_user = always_ghost ? User.ghost : user
-
- migrated_fields.each do |field|
- expect(migrated_record.public_send(field)).to eq(check_user)
- end
- end
-
- context "race conditions" do
- context "when #{record_class_name} migration fails and is rolled back" do
- before do
- allow_any_instance_of(ActiveRecord::Associations::CollectionProxy)
- .to receive(:update_all).and_raise(ActiveRecord::StatementTimeout)
- end
-
- it 'rolls back the user block' do
- expect { service.execute }.to raise_error(ActiveRecord::StatementTimeout)
-
- expect(user.reload).not_to be_blocked
- end
-
- it "doesn't unblock a previously-blocked user" do
- expect(user.starred_projects).to receive(:update_all).and_call_original
- user.block
-
- expect { service.execute }.to raise_error(ActiveRecord::StatementTimeout)
-
- expect(user.reload).to be_blocked
- end
- end
-
- it "blocks the user before #{record_class_name} migration begins" do
- expect(service).to receive("migrate_#{record_class_name.parameterize(separator: '_').pluralize}".to_sym) do
- expect(user.reload).to be_blocked
- end
-
- service.execute
- end
- end
- end
-end
diff --git a/spec/support/services/service_response_shared_examples.rb b/spec/support/services/service_response_shared_examples.rb
deleted file mode 100644
index 186627347fb..00000000000
--- a/spec/support/services/service_response_shared_examples.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'returning an error service response' do |message: nil|
- it 'returns an error service response' do
- result = subject
-
- expect(result).to be_error
-
- if message
- expect(result.message).to eq(message)
- end
- end
-end
-
-RSpec.shared_examples 'returning a success service response' do |message: nil|
- it 'returns a success service response' do
- result = subject
-
- expect(result).to be_success
-
- if message
- expect(result.message).to eq(message)
- end
- end
-end
diff --git a/spec/support/shared_contexts/bulk_imports_requests_shared_context.rb b/spec/support/shared_contexts/bulk_imports_requests_shared_context.rb
index e8fc498cbf7..7074b073a0c 100644
--- a/spec/support/shared_contexts/bulk_imports_requests_shared_context.rb
+++ b/spec/support/shared_contexts/bulk_imports_requests_shared_context.rb
@@ -24,16 +24,18 @@ RSpec.shared_context 'bulk imports requests context' do |url|
stub_request(:get, "https://gitlab.example.com/api/v4/groups?min_access_level=50&page=1&per_page=20&private_token=demo-pat&search=test&top_level_only=true")
.with(headers: request_headers)
- .to_return(status: 200,
- body: [{
- id: 2595440,
- web_url: 'https://gitlab.com/groups/test',
- name: 'Test',
- path: 'stub-test-group',
- full_name: 'Test',
- full_path: 'stub-test-group'
- }].to_json,
- headers: page_response_headers)
+ .to_return(
+ status: 200,
+ body: [{
+ id: 2595440,
+ web_url: 'https://gitlab.com/groups/test',
+ name: 'Test',
+ path: 'stub-test-group',
+ full_name: 'Test',
+ full_path: 'stub-test-group'
+ }].to_json,
+ headers: page_response_headers
+ )
stub_request(:get, "%{url}/api/v4/groups?min_access_level=50&page=1&per_page=20&private_token=demo-pat&search=&top_level_only=true" % { url: url })
.to_return(
@@ -45,6 +47,7 @@ RSpec.shared_context 'bulk imports requests context' do |url|
full_name: 'Stub',
full_path: 'stub-group'
}].to_json,
- headers: page_response_headers)
+ headers: page_response_headers
+ )
end
end
diff --git a/spec/support/shared_contexts/design_management_shared_contexts.rb b/spec/support/shared_contexts/design_management_shared_contexts.rb
index d89bcada1df..32b66723c5d 100644
--- a/spec/support/shared_contexts/design_management_shared_contexts.rb
+++ b/spec/support/shared_contexts/design_management_shared_contexts.rb
@@ -13,24 +13,33 @@ RSpec.shared_context 'four designs in three versions' do
let_it_be(:design_d) { create(:design, issue: issue) }
let_it_be(:first_version) do
- create(:design_version, issue: issue,
- created_designs: [design_a],
- modified_designs: [],
- deleted_designs: [])
+ create(
+ :design_version,
+ issue: issue,
+ created_designs: [design_a],
+ modified_designs: [],
+ deleted_designs: []
+ )
end
let_it_be(:second_version) do
- create(:design_version, issue: issue,
- created_designs: [design_b, design_c, design_d],
- modified_designs: [design_a],
- deleted_designs: [])
+ create(
+ :design_version,
+ issue: issue,
+ created_designs: [design_b, design_c, design_d],
+ modified_designs: [design_a],
+ deleted_designs: []
+ )
end
let_it_be(:third_version) do
- create(:design_version, issue: issue,
- created_designs: [],
- modified_designs: [design_a],
- deleted_designs: [design_d])
+ create(
+ :design_version,
+ issue: issue,
+ created_designs: [],
+ modified_designs: [design_a],
+ deleted_designs: [design_d]
+ )
end
before do
diff --git a/spec/support/shared_contexts/features/integrations/instance_and_group_integrations_shared_context.rb b/spec/support/shared_contexts/features/integrations/instance_and_group_integrations_shared_context.rb
index 58ee341f71f..41b500c0d4d 100644
--- a/spec/support/shared_contexts/features/integrations/instance_and_group_integrations_shared_context.rb
+++ b/spec/support/shared_contexts/features/integrations/instance_and_group_integrations_shared_context.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_context 'instance and group integration activation' do
- include_context 'integration activation'
+ include_context 'with integration activation'
def click_save_integration
click_save_changes_button
diff --git a/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb b/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb
index 2c92ef64815..c1f7dd79c08 100644
--- a/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb
+++ b/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb
@@ -1,120 +1,135 @@
# frozen_string_literal: true
-Integration.available_integration_names.each do |integration|
- RSpec.shared_context integration do
- include JiraIntegrationHelpers if integration == 'jira'
+# Shared context for all integrations
+#
+# The following let binding should be defined:
+# - `integration`: Integration name. See `Integration.available_integration_names`.
+RSpec.shared_context 'with integration' do
+ include JiraIntegrationHelpers
- let(:dashed_integration) { integration.dasherize }
- let(:integration_method) { Project.integration_association_name(integration) }
- let(:integration_klass) { Integration.integration_name_to_model(integration) }
- let(:integration_instance) { integration_klass.new }
+ let(:dashed_integration) { integration.dasherize }
+ let(:integration_method) { Project.integration_association_name(integration) }
+ let(:integration_klass) { Integration.integration_name_to_model(integration) }
+ let(:integration_instance) { integration_klass.new }
- # Build a list of all attributes that an integration supports.
- let(:integration_attrs_list) do
- integration_fields + integration_events + custom_attributes.fetch(integration.to_sym, [])
- end
+ # Build a list of all attributes that an integration supports.
+ let(:integration_attrs_list) do
+ integration_fields + integration_events + custom_attributes.fetch(integration.to_sym, [])
+ end
- # Attributes defined as fields.
- let(:integration_fields) do
- integration_instance.fields.map { _1[:name].to_sym }
- end
+ # Attributes defined as fields.
+ let(:integration_fields) do
+ integration_instance.fields.map { |field| field[:name].to_sym }
+ end
- # Attributes for configurable event triggers.
- let(:integration_events) do
- integration_instance.configurable_events.map { IntegrationsHelper.integration_event_field_name(_1).to_sym }
- end
+ # Attributes for configurable event triggers.
+ let(:integration_events) do
+ integration_instance.configurable_events
+ .map { |event| IntegrationsHelper.integration_event_field_name(event).to_sym }
+ end
- # Other special cases, this list might be incomplete.
- #
- # Some of these won't be needed anymore after we've converted them to use the field DSL
- # in https://gitlab.com/gitlab-org/gitlab/-/issues/354899.
- #
- # Others like `comment_on_event_disabled` are actual columns on `integrations`, maybe we should migrate
- # these to fields as well.
- let(:custom_attributes) do
- {
- jira: %i[comment_on_event_enabled jira_issue_transition_automatic jira_issue_transition_id project_key
- issues_enabled vulnerabilities_enabled vulnerabilities_issuetype]
- }
- end
+ # Other special cases, this list might be incomplete.
+ #
+ # Some of these won't be needed anymore after we've converted them to use the field DSL
+ # in https://gitlab.com/gitlab-org/gitlab/-/issues/354899.
+ #
+ # Others like `comment_on_event_disabled` are actual columns on `integrations`, maybe we should migrate
+ # these to fields as well.
+ let(:custom_attributes) do
+ {
+ jira: %i[
+ comment_on_event_enabled jira_issue_transition_automatic jira_issue_transition_id project_key
+ issues_enabled vulnerabilities_enabled vulnerabilities_issuetype
+ ]
+ }
+ end
- let(:integration_attrs) do
- integration_attrs_list.inject({}) do |hash, k|
- if k =~ /^(token*|.*_token|.*_key)/ && k =~ /^[^app_store]/
- hash.merge!(k => 'secrettoken')
- elsif integration == 'confluence' && k == :confluence_url
- hash.merge!(k => 'https://example.atlassian.net/wiki')
- elsif integration == 'datadog' && k == :datadog_site
- hash.merge!(k => 'datadoghq.com')
- elsif integration == 'datadog' && k == :datadog_tags
- hash.merge!(k => 'key:value')
- elsif integration == 'packagist' && k == :server
- hash.merge!(k => 'https://packagist.example.com')
- elsif k =~ /^(.*_url|url|webhook)/
- hash.merge!(k => "http://example.com")
- elsif integration_klass.method_defined?("#{k}?")
- hash.merge!(k => true)
- elsif integration == 'irker' && k == :recipients
- hash.merge!(k => 'irc://irc.network.net:666/#channel')
- elsif integration == 'irker' && k == :server_port
- hash.merge!(k => 1234)
- elsif integration == 'jira' && k == :jira_issue_transition_id
- hash.merge!(k => '1,2,3')
- elsif integration == 'jira' && k == :jira_issue_transition_automatic
- hash.merge!(k => true)
- elsif integration == 'emails_on_push' && k == :recipients
- hash.merge!(k => 'foo@bar.com')
- elsif (integration == 'slack' || integration == 'mattermost') && k == :labels_to_be_notified_behavior
- hash.merge!(k => "match_any")
- elsif integration == 'campfire' && k == :room
- hash.merge!(k => '1234')
- elsif integration == 'apple_app_store' && k == :app_store_issuer_id
- hash.merge!(k => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
- elsif integration == 'apple_app_store' && k == :app_store_private_key
- hash.merge!(k => File.read('spec/fixtures/ssl_key.pem'))
- elsif integration == 'apple_app_store' && k == :app_store_key_id
- hash.merge!(k => 'ABC1')
- else
- hash.merge!(k => "someword")
- end
+ let(:integration_attrs) do
+ integration_attrs_list.inject({}) do |hash, k|
+ if k =~ /^(token*|.*_token|.*_key)/ && !integration.in?(%w[apple_app_store google_play])
+ hash.merge!(k => 'secrettoken')
+ elsif integration == 'confluence' && k == :confluence_url
+ hash.merge!(k => 'https://example.atlassian.net/wiki')
+ elsif integration == 'datadog' && k == :datadog_site
+ hash.merge!(k => 'datadoghq.com')
+ elsif integration == 'datadog' && k == :datadog_tags
+ hash.merge!(k => 'key:value')
+ elsif integration == 'packagist' && k == :server
+ hash.merge!(k => 'https://packagist.example.com')
+ elsif k =~ /^(.*_url|url|webhook)/
+ hash.merge!(k => "http://example.com")
+ elsif integration_klass.method_defined?("#{k}?")
+ hash.merge!(k => true)
+ elsif integration == 'irker' && k == :recipients
+ hash.merge!(k => 'irc://irc.network.net:666/#channel')
+ elsif integration == 'irker' && k == :server_port
+ hash.merge!(k => 1234)
+ elsif integration == 'jira' && k == :jira_issue_transition_id
+ hash.merge!(k => '1,2,3')
+ elsif integration == 'jira' && k == :jira_issue_transition_automatic # rubocop:disable Lint/DuplicateBranch
+ hash.merge!(k => true)
+ elsif integration == 'jira' && k == :jira_auth_type # rubocop:disable Lint/DuplicateBranch
+ hash.merge!(k => 0)
+ elsif integration == 'emails_on_push' && k == :recipients
+ hash.merge!(k => 'foo@bar.com')
+ elsif (integration == 'slack' || integration == 'mattermost') && k == :labels_to_be_notified_behavior
+ hash.merge!(k => "match_any")
+ elsif integration == 'campfire' && k == :room
+ hash.merge!(k => '1234')
+ elsif integration == 'apple_app_store' && k == :app_store_issuer_id
+ hash.merge!(k => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
+ elsif integration == 'apple_app_store' && k == :app_store_private_key
+ hash.merge!(k => File.read('spec/fixtures/ssl_key.pem'))
+ elsif integration == 'apple_app_store' && k == :app_store_key_id
+ hash.merge!(k => 'ABC1')
+ elsif integration == 'apple_app_store' && k == :app_store_private_key_file_name
+ hash.merge!(k => 'ssl_key.pem')
+ elsif integration == 'google_play' && k == :package_name
+ hash.merge!(k => 'com.gitlab.foo.bar')
+ elsif integration == 'google_play' && k == :service_account_key
+ hash.merge!(k => File.read('spec/fixtures/service_account.json'))
+ elsif integration == 'google_play' && k == :service_account_key_file_name
+ hash.merge!(k => 'service_account.json')
+ else
+ hash.merge!(k => "someword")
end
end
+ end
- let(:licensed_features) do
- {
- 'github' => :github_integration
- }
- end
+ let(:licensed_features) do
+ {
+ 'github' => :github_integration
+ }
+ end
- before do
- enable_license_for_integration(integration)
- stub_jira_integration_test if integration == 'jira'
- end
+ before do
+ enable_license_for_integration(integration)
+ stub_jira_integration_test if integration == 'jira'
+ end
- def initialize_integration(integration, attrs = {})
- record = project.find_or_initialize_integration(integration)
- record.reset_updated_properties if integration == 'datadog'
- record.attributes = attrs
- record.properties = integration_attrs
- record.save!
- record
- end
+ def initialize_integration(integration, attrs = {})
+ record = project.find_or_initialize_integration(integration)
+ record.reset_updated_properties if integration == 'datadog'
+ record.attributes = attrs
+ record.properties = integration_attrs
+ record.save!
+ record
+ end
- private
+ private
- def enable_license_for_integration(integration)
- return unless respond_to?(:stub_licensed_features)
+ def enable_license_for_integration(integration)
+ return unless respond_to?(:stub_licensed_features)
- licensed_feature = licensed_features[integration]
- return unless licensed_feature
+ licensed_feature = licensed_features[integration]
+ return unless licensed_feature
- stub_licensed_features(licensed_feature => true)
- project.clear_memoization(:disabled_integrations)
- end
+ stub_licensed_features(licensed_feature => true)
+ project.clear_memoization(:disabled_integrations)
end
end
-RSpec.shared_context 'integration activation' do
+RSpec.shared_context 'with integration activation' do
def click_active_checkbox
find('label', text: 'Active').click
end
diff --git a/spec/support/shared_contexts/features/integrations/project_integrations_jira_context.rb b/spec/support/shared_contexts/features/integrations/project_integrations_jira_context.rb
index fadd46a7e12..f16d19e5858 100644
--- a/spec/support/shared_contexts/features/integrations/project_integrations_jira_context.rb
+++ b/spec/support/shared_contexts/features/integrations/project_integrations_jira_context.rb
@@ -10,5 +10,6 @@ RSpec.shared_context 'project integration Jira context' do
fill_in 'service_url', with: url
fill_in 'service_username', with: 'username'
fill_in 'service_password', with: 'password'
+ select('Basic', from: 'service_jira_auth_type')
end
end
diff --git a/spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb b/spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb
index bac7bd00f46..a9b9a5246e6 100644
--- a/spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb
+++ b/spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_context 'project integration activation' do
- include_context 'integration activation'
+ include_context 'with integration activation'
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb
index afb3976e3b8..16d23f63fd0 100644
--- a/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb
+++ b/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb
@@ -12,47 +12,55 @@ RSpec.shared_context 'IssuesFinder context' do
let_it_be(:milestone) { create(:milestone, project: project1, releases: [release]) }
let_it_be(:label) { create(:label, project: project2) }
let_it_be(:label2) { create(:label, project: project2) }
- let_it_be(:item1, reload: true) do
- create(:issue,
- author: user,
- assignees: [user],
- project: project1,
- milestone: milestone,
- title: 'gitlab',
- created_at: 1.week.ago,
- updated_at: 1.week.ago)
+ let_it_be_with_reload(:item1) do
+ create(
+ :issue,
+ author: user,
+ assignees: [user],
+ project: project1,
+ milestone: milestone,
+ title: 'gitlab',
+ created_at: 1.week.ago,
+ updated_at: 1.week.ago
+ )
end
- let_it_be(:item2, reload: true) do
- create(:issue,
- author: user,
- assignees: [user],
- project: project2,
- description: 'gitlab',
- created_at: 1.week.from_now,
- updated_at: 1.week.from_now)
+ let_it_be_with_reload(:item2) do
+ create(
+ :issue,
+ author: user,
+ assignees: [user],
+ project: project2,
+ description: 'gitlab',
+ created_at: 1.week.from_now,
+ updated_at: 1.week.from_now
+ )
end
- let_it_be(:item3, reload: true) do
- create(:issue,
- author: user2,
- assignees: [user2],
- project: project2,
- title: 'tanuki',
- description: 'tanuki',
- created_at: 2.weeks.from_now,
- updated_at: 2.weeks.from_now)
+ let_it_be_with_reload(:item3) do
+ create(
+ :issue,
+ author: user2,
+ assignees: [user2],
+ project: project2,
+ title: 'tanuki',
+ description: 'tanuki',
+ created_at: 2.weeks.from_now,
+ updated_at: 2.weeks.from_now
+ )
end
- let_it_be(:item4, reload: true) { create(:issue, project: project3) }
- let_it_be(:item5, reload: true) do
- create(:issue,
- author: user,
- assignees: [user],
- project: project1,
- title: 'wotnot',
- created_at: 3.days.ago,
- updated_at: 3.days.ago)
+ let_it_be_with_reload(:item4) { create(:issue, project: project3) }
+ let_it_be_with_reload(:item5) do
+ create(
+ :issue,
+ author: user,
+ assignees: [user],
+ project: project1,
+ title: 'wotnot',
+ created_at: 3.days.ago,
+ updated_at: 3.days.ago
+ )
end
let_it_be(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: item1) }
diff --git a/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb
index 8a64efe9df5..507bcd44ee8 100644
--- a/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb
+++ b/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb
@@ -54,34 +54,44 @@ RSpec.shared_context 'MergeRequestsFinder multiple projects with merge requests
let_it_be(:label2) { create(:label, project: project1) }
let!(:merge_request1) do
- create(:merge_request, assignees: [user], author: user, reviewers: [user2],
- source_project: project2, target_project: project1,
- target_branch: 'merged-target')
+ create(
+ :merge_request, assignees: [user], author: user, reviewers: [user2],
+ source_project: project2, target_project: project1,
+ target_branch: 'merged-target'
+ )
end
let!(:merge_request2) do
- create(:merge_request, :conflict, assignees: [user], author: user, reviewers: [user2],
- source_project: project2, target_project: project1,
- state: 'closed')
+ create(
+ :merge_request, :conflict, assignees: [user], author: user, reviewers: [user2],
+ source_project: project2, target_project: project1,
+ state: 'closed'
+ )
end
let!(:merge_request3) do
- create(:merge_request, :simple, author: user, assignees: [user2], reviewers: [user],
- source_project: project2, target_project: project2,
- state: 'locked',
- title: 'thing WIP thing')
+ create(
+ :merge_request, :simple, author: user, assignees: [user2], reviewers: [user],
+ source_project: project2, target_project: project2,
+ state: 'locked',
+ title: 'thing WIP thing'
+ )
end
let!(:merge_request4) do
- create(:merge_request, :simple, author: user,
- source_project: project3, target_project: project3,
- title: 'WIP thing')
+ create(
+ :merge_request, :simple, author: user,
+ source_project: project3, target_project: project3,
+ title: 'WIP thing'
+ )
end
let_it_be(:merge_request5) do
- create(:merge_request, :simple, author: user,
- source_project: project4, target_project: project4,
- title: '[WIP]')
+ create(
+ :merge_request, :simple, author: user,
+ source_project: project4, target_project: project4,
+ title: '[WIP]'
+ )
end
let!(:label_link) { create(:label_link, label: label, target: merge_request2) }
diff --git a/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb
index 8c5bc339db5..1118039d164 100644
--- a/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb
+++ b/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb
@@ -12,47 +12,55 @@ RSpec.shared_context 'WorkItemsFinder context' do
let_it_be(:milestone) { create(:milestone, project: project1, releases: [release]) }
let_it_be(:label) { create(:label, project: project2) }
let_it_be(:label2) { create(:label, project: project2) }
- let_it_be(:item1, reload: true) do
- create(:work_item,
- author: user,
- assignees: [user],
- project: project1,
- milestone: milestone,
- title: 'gitlab',
- created_at: 1.week.ago,
- updated_at: 1.week.ago)
+ let_it_be_with_reload(:item1) do
+ create(
+ :work_item,
+ author: user,
+ assignees: [user],
+ project: project1,
+ milestone: milestone,
+ title: 'gitlab',
+ created_at: 1.week.ago,
+ updated_at: 1.week.ago
+ )
end
- let_it_be(:item2, reload: true) do
- create(:work_item,
- author: user,
- assignees: [user],
- project: project2,
- description: 'gitlab',
- created_at: 1.week.from_now,
- updated_at: 1.week.from_now)
+ let_it_be_with_reload(:item2) do
+ create(
+ :work_item,
+ author: user,
+ assignees: [user],
+ project: project2,
+ description: 'gitlab',
+ created_at: 1.week.from_now,
+ updated_at: 1.week.from_now
+ )
end
- let_it_be(:item3, reload: true) do
- create(:work_item,
- author: user2,
- assignees: [user2],
- project: project2,
- title: 'tanuki',
- description: 'tanuki',
- created_at: 2.weeks.from_now,
- updated_at: 2.weeks.from_now)
+ let_it_be_with_reload(:item3) do
+ create(
+ :work_item,
+ author: user2,
+ assignees: [user2],
+ project: project2,
+ title: 'tanuki',
+ description: 'tanuki',
+ created_at: 2.weeks.from_now,
+ updated_at: 2.weeks.from_now
+ )
end
- let_it_be(:item4, reload: true) { create(:work_item, project: project3) }
- let_it_be(:item5, reload: true) do
- create(:work_item,
- author: user,
- assignees: [user],
- project: project1,
- title: 'wotnot',
- created_at: 3.days.ago,
- updated_at: 3.days.ago)
+ let_it_be_with_reload(:item4) { create(:work_item, project: project3) }
+ let_it_be_with_reload(:item5) do
+ create(
+ :work_item,
+ author: user,
+ assignees: [user],
+ project: project1,
+ title: 'wotnot',
+ created_at: 3.days.ago,
+ updated_at: 3.days.ago
+ )
end
let_it_be(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: item1) }
diff --git a/spec/support/shared_contexts/glfm/example_snapshot_fixtures.rb b/spec/support/shared_contexts/glfm/example_snapshot_fixtures.rb
index 22b401bc841..83bf622af67 100644
--- a/spec/support/shared_contexts/glfm/example_snapshot_fixtures.rb
+++ b/spec/support/shared_contexts/glfm/example_snapshot_fixtures.rb
@@ -8,7 +8,7 @@ RSpec.shared_context 'with GLFM example snapshot fixtures' do
# NOTE: We hardcode the IDs on all fixtures to prevent variability in the
# rendered HTML/Prosemirror JSON, and to minimize the need for normalization:
# https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#normalization
- create(:project, :repository, creator: user, group: group, name: 'glfm_project', id: 77777)
+ create(:project, :repository, creator: user, group: group, path: 'glfm_project', id: 77777)
end
let_it_be(:project_snippet) { create(:project_snippet, title: 'glfm_project_snippet', id: 88888, project: project) }
diff --git a/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb b/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb
index 1585ef0e7fc..095c8639d15 100644
--- a/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb
+++ b/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb
@@ -7,6 +7,7 @@ RSpec.shared_context 'with FOSS query type fields' do
:board_list,
:ci_application_settings,
:ci_config,
+ :ci_pipeline_stage,
:ci_variables,
:container_repository,
:current_user,
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 b9cde12c537..35c1511c96a 100644
--- a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb
+++ b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_context 'merge request show action' do
- include Spec::Support::Helpers::Features::MergeRequestHelpers
+ include Features::MergeRequestHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
diff --git a/spec/support/shared_contexts/lib/gitlab/database/load_balancing/wal_tracking_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/database/load_balancing/wal_tracking_shared_context.rb
new file mode 100644
index 00000000000..cbbd3754108
--- /dev/null
+++ b/spec/support/shared_contexts/lib/gitlab/database/load_balancing/wal_tracking_shared_context.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'when tracking WAL location reference' do
+ let(:current_location) { '0/D525E3A8' }
+
+ around do |example|
+ Gitlab::Database::LoadBalancing::Session.clear_session
+ example.run
+ Gitlab::Database::LoadBalancing::Session.clear_session
+ end
+
+ def expect_tracked_locations_when_replicas_available
+ {}.tap do |locations|
+ Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
+ expect(lb.host).to receive(:database_replica_location).and_return(current_location)
+
+ locations[lb.name] = current_location
+ end
+ end
+ end
+
+ def expect_tracked_locations_when_no_replicas_available
+ {}.tap do |locations|
+ Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
+ expect(lb).to receive(:host).at_least(:once).and_return(nil)
+ expect(lb).to receive(:primary_write_location).and_return(current_location)
+
+ locations[lb.name] = current_location
+ end
+ end
+ end
+
+ def expect_tracked_locations_from_primary_only
+ {}.tap do |locations|
+ Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
+ expect(lb).to receive(:primary_write_location).and_return(current_location)
+
+ locations[lb.name] = current_location
+ end
+ end
+ end
+
+ def stub_load_balancing_disabled!
+ Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
+ allow(lb).to receive(:primary_only?).and_return(true)
+ end
+ end
+
+ def stub_load_balancing_enabled!
+ Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
+ allow(lb).to receive(:primary_only?).and_return(false)
+ end
+ end
+
+ def stub_no_writes_performed!
+ allow(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary?).and_return(false)
+ end
+
+ def stub_write_performed!
+ allow(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary?).and_return(true)
+ end
+
+ def stub_replica_available!(available)
+ ::Gitlab::Database::LoadBalancing.each_load_balancer do |lb|
+ allow(lb).to receive(:select_up_to_date_host).with(current_location).and_return(available)
+ end
+ end
+end
diff --git a/spec/support/shared_contexts/lib/gitlab/database/partitioning/list_partitioning_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/database/partitioning/list_partitioning_shared_context.rb
new file mode 100644
index 00000000000..e9cd1bdbbf5
--- /dev/null
+++ b/spec/support/shared_contexts/lib/gitlab/database/partitioning/list_partitioning_shared_context.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'with a table structure for converting a table to a list partition' do
+ let(:migration_context) do
+ Gitlab::Database::Migration[2.1].new.tap do |migration|
+ migration.extend Gitlab::Database::PartitioningMigrationHelpers::TableManagementHelpers
+ migration.extend Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
+ end
+ end
+
+ let(:connection) { migration_context.connection }
+ let(:table_name) { '_test_table_to_partition' }
+ let(:table_identifier) { "#{connection.current_schema}.#{table_name}" }
+ let(:partitioning_column) { :partition_number }
+ let(:partitioning_default) { 1 }
+ let(:referenced_table_name) { '_test_referenced_table' }
+ let(:other_referenced_table_name) { '_test_other_referenced_table' }
+ let(:referencing_table_name) { '_test_referencing_table' }
+ let(:other_referencing_table_name) { '_test_other_referencing_table' }
+ let(:parent_table_name) { "#{table_name}_parent" }
+ let(:parent_table_identifier) { "#{connection.current_schema}.#{parent_table_name}" }
+ let(:lock_tables) { [] }
+
+ let(:model) { define_batchable_model(table_name, connection: connection) }
+
+ let(:parent_model) { define_batchable_model(parent_table_name, connection: connection) }
+ let(:referencing_model) { define_batchable_model(referencing_table_name, connection: connection) }
+
+ before do
+ # Suppress printing migration progress
+ allow(migration_context).to receive(:puts)
+ allow(migration_context.connection).to receive(:transaction_open?).and_return(false)
+
+ connection.execute(<<~SQL)
+ create table #{referenced_table_name} (
+ id bigserial primary key not null
+ )
+ SQL
+
+ connection.execute(<<~SQL)
+ create table #{other_referenced_table_name} (
+ id bigserial primary key not null
+ )
+ SQL
+
+ connection.execute(<<~SQL)
+ insert into #{referenced_table_name} default values;
+ insert into #{other_referenced_table_name} default values;
+ SQL
+
+ connection.execute(<<~SQL)
+ create table #{table_name} (
+ id bigserial not null,
+ #{partitioning_column} bigint not null default #{partitioning_default},
+ referenced_id bigint not null references #{referenced_table_name} (id) on delete cascade,
+ other_referenced_id bigint not null references #{other_referenced_table_name} (id) on delete set null,
+ primary key (id, #{partitioning_column})
+ )
+ SQL
+
+ connection.execute(<<~SQL)
+ create table #{referencing_table_name} (
+ id bigserial primary key not null,
+ #{partitioning_column} bigint not null,
+ ref_id bigint not null,
+ constraint fk_referencing foreign key (#{partitioning_column}, ref_id) references #{table_name} (#{partitioning_column}, id) on delete cascade
+ )
+ SQL
+
+ connection.execute(<<~SQL)
+ create table #{other_referencing_table_name} (
+ id bigserial not null,
+ #{partitioning_column} bigint not null,
+ ref_id bigint not null,
+ primary key (#{partitioning_column}, id),
+ constraint fk_referencing_other foreign key (#{partitioning_column}, ref_id) references #{table_name} (#{partitioning_column}, id)
+ ) partition by hash(#{partitioning_column});
+
+ create table #{other_referencing_table_name}_1
+ partition of #{other_referencing_table_name} for values with (modulus 2, remainder 0);
+
+ create table #{other_referencing_table_name}_2
+ partition of #{other_referencing_table_name} for values with (modulus 2, remainder 1);
+ SQL
+
+ connection.execute(<<~SQL)
+ insert into #{table_name} (referenced_id, other_referenced_id)
+ select #{referenced_table_name}.id, #{other_referenced_table_name}.id
+ from #{referenced_table_name}, #{other_referenced_table_name};
+ SQL
+ end
+end
diff --git a/spec/support/shared_contexts/merge_request_create_shared_context.rb b/spec/support/shared_contexts/merge_request_create_shared_context.rb
index f2defa4eab9..fc9a3767365 100644
--- a/spec/support/shared_contexts/merge_request_create_shared_context.rb
+++ b/spec/support/shared_contexts/merge_request_create_shared_context.rb
@@ -15,12 +15,13 @@ RSpec.shared_context 'merge request create context' do
target_project.add_maintainer(user2)
sign_in(user)
- visit project_new_merge_request_path(target_project,
- merge_request: {
- source_project_id: source_project.id,
- target_project_id: target_project.id,
- source_branch: 'fix',
- target_branch: 'master'
- })
+ visit project_new_merge_request_path(
+ target_project,
+ merge_request: {
+ source_project_id: source_project.id,
+ target_project_id: target_project.id,
+ source_branch: 'fix',
+ target_branch: 'master'
+ })
end
end
diff --git a/spec/support/shared_contexts/merge_request_edit_shared_context.rb b/spec/support/shared_contexts/merge_request_edit_shared_context.rb
index d490d26adfb..f0e89b0c5f9 100644
--- a/spec/support/shared_contexts/merge_request_edit_shared_context.rb
+++ b/spec/support/shared_contexts/merge_request_edit_shared_context.rb
@@ -9,11 +9,13 @@ RSpec.shared_context 'merge request edit context' do
let(:target_project) { create(:project, :public, :repository) }
let(:source_project) { target_project }
let(:merge_request) do
- create(:merge_request,
- source_project: source_project,
- target_project: target_project,
- source_branch: 'fix',
- target_branch: 'master')
+ create(
+ :merge_request,
+ source_project: source_project,
+ target_project: target_project,
+ source_branch: 'fix',
+ target_branch: 'master'
+ )
end
before do
diff --git a/spec/support/shared_contexts/merge_requests_allowing_collaboration_shared_context.rb b/spec/support/shared_contexts/merge_requests_allowing_collaboration_shared_context.rb
index 5412a991b22..50761f94c10 100644
--- a/spec/support/shared_contexts/merge_requests_allowing_collaboration_shared_context.rb
+++ b/spec/support/shared_contexts/merge_requests_allowing_collaboration_shared_context.rb
@@ -8,10 +8,12 @@ RSpec.shared_context 'merge request allowing collaboration' do
before do
canonical.add_maintainer(user)
- create(:merge_request,
- target_project: canonical,
- source_project: forked_project,
- source_branch: 'feature',
- allow_collaboration: true)
+ create(
+ :merge_request,
+ target_project: canonical,
+ source_project: forked_project,
+ source_branch: 'feature',
+ allow_collaboration: true
+ )
end
end
diff --git a/spec/support/shared_contexts/models/distribution_shared_context.rb b/spec/support/shared_contexts/models/distribution_shared_context.rb
new file mode 100644
index 00000000000..30f6b750e22
--- /dev/null
+++ b/spec/support/shared_contexts/models/distribution_shared_context.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# This shared context requires:
+# - factory: either :debian_project_distribution or :debian_group_distribution
+# - can_freeze: whether to freeze the created object or not
+RSpec.shared_context 'for Debian Distribution' do |factory, can_freeze|
+ let_it_be(:distribution_with_suite, freeze: can_freeze) { create(factory, :with_suite) }
+ let_it_be(:distribution_with_same_container, freeze: can_freeze) do
+ create(factory, container: distribution_with_suite.container)
+ end
+
+ let_it_be(:distribution_with_same_codename, freeze: can_freeze) do
+ create(factory, codename: distribution_with_suite.codename)
+ end
+
+ let_it_be(:distribution_with_same_suite, freeze: can_freeze) { create(factory, suite: distribution_with_suite.suite) }
+ let_it_be(:distribution_with_codename_and_suite_flipped, freeze: can_freeze) do
+ create(factory, codename: distribution_with_suite.suite, suite: distribution_with_suite.codename)
+ end
+
+ let_it_be_with_refind(:distribution) { create(factory, container: distribution_with_suite.container) }
+end
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index b74819d2ac7..7b839594816 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -5,10 +5,10 @@ RSpec.shared_context 'project navbar structure' do
let(:security_and_compliance_nav_item) do
{
- nav_item: _('Security & Compliance'),
+ nav_item: _('Security and Compliance'),
nav_sub_items: [
(_('Audit events') if Gitlab.ee?),
- _('Configuration')
+ _('Security configuration')
]
}
end
@@ -34,10 +34,10 @@ RSpec.shared_context 'project navbar structure' do
_('Commits'),
_('Branches'),
_('Tags'),
- _('Contributors'),
+ _('Contributor statistics'),
_('Graph'),
- _('Compare'),
- (_('Locked Files') if Gitlab.ee?)
+ _('Compare revisions'),
+ (_('Locked files') if Gitlab.ee?)
]
},
{
@@ -68,7 +68,7 @@ RSpec.shared_context 'project navbar structure' do
nav_item: _('Deployments'),
nav_sub_items: [
_('Environments'),
- _('Feature Flags'),
+ s_('FeatureFlags|Feature flags'),
_('Releases')
]
},
@@ -76,7 +76,7 @@ RSpec.shared_context 'project navbar structure' do
nav_item: _('Infrastructure'),
nav_sub_items: [
_('Kubernetes clusters'),
- _('Terraform')
+ s_('Terraform|Terraform states')
]
},
{
@@ -85,8 +85,7 @@ RSpec.shared_context 'project navbar structure' do
_('Metrics'),
_('Error Tracking'),
_('Alerts'),
- _('Incidents'),
- _('Airflow')
+ _('Incidents')
]
},
{
@@ -141,6 +140,7 @@ RSpec.shared_context 'group navbar structure' do
_('CI/CD'),
_('Applications'),
_('Packages and registries'),
+ s_('UsageQuota|Usage Quotas'),
_('Domain Verification')
]
}
@@ -155,18 +155,9 @@ RSpec.shared_context 'group navbar structure' do
}
end
- let(:administration_nav_item) do
- {
- nav_item: _('Administration'),
- nav_sub_items: [
- s_('UsageQuota|Usage Quotas')
- ]
- }
- end
-
let(:security_and_compliance_nav_item) do
{
- nav_item: _('Security & Compliance'),
+ nav_item: _('Security and Compliance'),
nav_sub_items: [
_('Audit events')
]
@@ -268,3 +259,30 @@ RSpec.shared_context 'dashboard navbar structure' do
]
end
end
+
+RSpec.shared_context '"Explore" navbar structure' do
+ let(:structure) do
+ [
+ {
+ nav_item: "Explore",
+ nav_sub_items: []
+ },
+ {
+ nav_item: _("Projects"),
+ nav_sub_items: []
+ },
+ {
+ nav_item: _("Groups"),
+ nav_sub_items: []
+ },
+ {
+ nav_item: _("Topics"),
+ nav_sub_items: []
+ },
+ {
+ nav_item: _("Snippets"),
+ nav_sub_items: []
+ }
+ ]
+ end
+end
diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb
index 4c081c8464e..111fd3dc7df 100644
--- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb
@@ -12,7 +12,7 @@ RSpec.shared_context 'GroupPolicy context' do
let(:public_permissions) do
%i[
- read_group read_counts read_achievement
+ read_group read_counts
read_label read_issue_board_list read_milestone read_issue_board
]
end
@@ -42,9 +42,7 @@ RSpec.shared_context 'GroupPolicy context' do
let(:developer_permissions) do
%i[
- create_metrics_dashboard_annotation
- delete_metrics_dashboard_annotation
- update_metrics_dashboard_annotation
+ admin_metrics_dashboard_annotation
create_custom_emoji
create_package
read_cluster
@@ -59,6 +57,7 @@ RSpec.shared_context 'GroupPolicy context' do
create_cluster update_cluster admin_cluster add_cluster
destroy_upload
admin_achievement
+ award_achievement
]
end
diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
index afc7fc8766f..5014a810f35 100644
--- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
@@ -38,8 +38,8 @@ RSpec.shared_context 'ProjectPolicy context' do
read_commit_status read_confidential_issues read_container_image
read_harbor_registry read_deployment read_environment read_merge_request
read_metrics_dashboard_annotation read_pipeline read_prometheus
- read_sentry_issue update_issue create_merge_request_in read_external_emails
- read_internal_note
+ read_sentry_issue update_issue create_merge_request_in
+ read_external_emails read_internal_note export_work_items
]
end
@@ -52,12 +52,11 @@ RSpec.shared_context 'ProjectPolicy context' do
admin_merge_request admin_tag create_build
create_commit_status create_container_image create_deployment
create_environment create_merge_request_from
- create_metrics_dashboard_annotation create_pipeline create_release
- create_wiki delete_metrics_dashboard_annotation
- destroy_container_image push_code read_pod_logs read_terraform_state
- resolve_note update_build update_commit_status update_container_image
- update_deployment update_environment update_merge_request
- update_metrics_dashboard_annotation update_pipeline update_release destroy_release
+ admin_metrics_dashboard_annotation create_pipeline create_release
+ create_wiki destroy_container_image push_code read_pod_logs
+ read_terraform_state resolve_note update_build update_commit_status
+ update_container_image update_deployment update_environment
+ update_merge_request update_pipeline update_release destroy_release
read_resource_group update_resource_group update_escalation_status
]
end
diff --git a/spec/support/shared_contexts/rack_attack_shared_context.rb b/spec/support/shared_contexts/rack_attack_shared_context.rb
index 12625ead72b..4e8c6e9dec6 100644
--- a/spec/support/shared_contexts/rack_attack_shared_context.rb
+++ b/spec/support/shared_contexts/rack_attack_shared_context.rb
@@ -5,8 +5,7 @@ RSpec.shared_context 'rack attack cache store' do
# Instead of test environment's :null_store so the throttles can increment
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
- # Make time-dependent tests deterministic
- freeze_time { example.run }
+ example.run
Rack::Attack.cache.store = Rails.cache
end
diff --git a/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb b/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb
index 57967fb9414..ad64e4d5be5 100644
--- a/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb
+++ b/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb
@@ -58,6 +58,9 @@ RSpec.shared_context 'Debian repository shared context' do |container_type, can_
let(:distribution) { { private: private_distribution, public: public_distribution }[visibility_level] }
let(:architecture) { { private: private_architecture, public: public_architecture }[visibility_level] }
let(:component) { { private: private_component, public: public_component }[visibility_level] }
+ let(:component_file) { { private: private_component_file, public: public_component_file }[visibility_level] }
+ let(:component_file_sources) { { private: private_component_file_sources, public: public_component_file_sources }[visibility_level] }
+ let(:component_file_di) { { private: private_component_file_di, public: public_component_file_di }[visibility_level] }
let(:component_file_older_sha256) { { private: private_component_file_older_sha256, public: public_component_file_older_sha256 }[visibility_level] }
let(:component_file_sources_older_sha256) { { private: private_component_file_sources_older_sha256, public: public_component_file_sources_older_sha256 }[visibility_level] }
let(:component_file_di_older_sha256) { { private: private_component_file_di_older_sha256, public: public_component_file_di_older_sha256 }[visibility_level] }
diff --git a/spec/support/shared_contexts/requests/api/graphql/releases_and_group_releases_shared_context.rb b/spec/support/shared_contexts/requests/api/graphql/releases_and_group_releases_shared_context.rb
index 81076ea6fdc..e56cd82e369 100644
--- a/spec/support/shared_contexts/requests/api/graphql/releases_and_group_releases_shared_context.rb
+++ b/spec/support/shared_contexts/requests/api/graphql/releases_and_group_releases_shared_context.rb
@@ -13,40 +13,40 @@ RSpec.shared_context 'when releases and group releases shared context' do
let(:query) do
graphql_query_for(resource_type, { fullPath: resource.full_path },
- %(
- releases {
- count
- nodes {
- tagName
- tagPath
- name
- commit {
- sha
- }
- assets {
- count
- sources {
+ %(
+ releases {
+ count
+ nodes {
+ tagName
+ tagPath
+ name
+ commit {
+ sha
+ }
+ assets {
+ count
+ sources {
+ nodes {
+ url
+ }
+ }
+ }
+ evidences {
nodes {
- url
+ sha
}
}
- }
- evidences {
- nodes {
- sha
+ links {
+ selfUrl
+ openedMergeRequestsUrl
+ mergedMergeRequestsUrl
+ closedMergeRequestsUrl
+ openedIssuesUrl
+ closedIssuesUrl
}
}
- links {
- selfUrl
- openedMergeRequestsUrl
- mergedMergeRequestsUrl
- closedMergeRequestsUrl
- openedIssuesUrl
- closedIssuesUrl
- }
}
- }
- ))
+ ))
end
let(:params_for_issues_and_mrs) { { scope: 'all', state: 'opened', release_tag: release.tag } }
diff --git a/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb b/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb
index dc5195e4b01..1fa1a5c69f4 100644
--- a/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb
+++ b/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_context '"Security & Compliance" permissions' do
+RSpec.shared_context '"Security and Compliance" permissions' do
let(:project_instance) { an_instance_of(Project) }
let(:user_instance) { an_instance_of(User) }
let(:before_request_defined) { false }
@@ -18,7 +18,7 @@ RSpec.shared_context '"Security & Compliance" permissions' do
allow(Ability).to receive(:allowed?).with(user_instance, :access_security_and_compliance, project_instance).and_return(true)
end
- context 'when the "Security & Compliance" feature is disabled' do
+ context 'when the "Security and Compliance" feature is disabled' do
subject { response }
before do
diff --git a/spec/support/shared_contexts/services/clusters/create_service_shared_context.rb b/spec/support/shared_contexts/services/clusters/create_service_shared_context.rb
new file mode 100644
index 00000000000..393e90da1d3
--- /dev/null
+++ b/spec/support/shared_contexts/services/clusters/create_service_shared_context.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'with valid cluster create params' do
+ let(:clusterable) { Clusters::Instance.new }
+ let(:params) do
+ {
+ name: 'test-cluster',
+ provider_type: :gcp,
+ provider_gcp_attributes: {
+ gcp_project_id: 'gcp-project',
+ zone: 'us-central1-a',
+ num_nodes: 1,
+ machine_type: 'machine_type-a',
+ legacy_abac: 'true'
+ },
+ clusterable: clusterable
+ }
+ end
+end
diff --git a/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb b/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb
index 7db479bcfd2..0cf026749ee 100644
--- a/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb
+++ b/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb
@@ -8,9 +8,11 @@ RSpec.shared_context 'container repository delete tags service shared context' d
let(:params) { { tags: tags } }
before do
- stub_container_registry_config(enabled: true,
- api_url: 'http://registry.gitlab',
- host_port: 'registry.gitlab')
+ stub_container_registry_config(
+ enabled: true,
+ api_url: 'http://registry.gitlab',
+ host_port: 'registry.gitlab'
+ )
stub_container_registry_tags(
repository: repository.path,
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 9746d287440..cd792ccc4e3 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
@@ -4,9 +4,10 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do
include UsageDataHelpers
let(:metrics_definitions) { standard_metrics + subscription_metrics + operational_metrics + optional_metrics }
+ # ToDo: remove during https://gitlab.com/gitlab-org/gitlab/-/issues/396824 (license metrics migration)
+ let(:subscription_metrics) { [] }
let(:standard_metrics) do
[
- metric_attributes('uuid', 'standard'),
metric_attributes('recorded_at', 'standard'),
metric_attributes('settings.collected_data_categories', 'standard', 'object')
]
diff --git a/spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb b/spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb
new file mode 100644
index 00000000000..9c096c5a158
--- /dev/null
+++ b/spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb
@@ -0,0 +1,629 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'validation on Time arguments' do
+ context 'when `to` parameter is higher than `from`' do
+ let(:variables) do
+ {
+ path: full_path,
+ from: 1.day.ago.iso8601,
+ to: 2.days.ago.iso8601
+ }
+ end
+
+ it 'returns error' do
+ expect(result).to be_nil
+ expect(graphql_errors.first['message']).to include('`from` argument must be before `to` argument')
+ end
+ end
+
+ context 'when from and to parameter range is higher than 180 days' do
+ let(:variables) do
+ {
+ path: full_path,
+ from: Time.now,
+ to: 181.days.from_now
+ }
+ end
+
+ it 'returns error' do
+ expect(result).to be_nil
+ expect(graphql_errors.first['message']).to include('Max of 180 days timespan is allowed')
+ end
+ end
+end
+
+RSpec.shared_examples 'value stream analytics flow metrics issueCount examples' do
+ let_it_be(:milestone) { create(:milestone, group: group) }
+ let_it_be(:label) { create(:group_label, group: group) }
+
+ let_it_be(:author) { create(:user) }
+ let_it_be(:assignee) { create(:user) }
+
+ let_it_be(:issue1) { create(:issue, project: project1, author: author, created_at: 12.days.ago) }
+ let_it_be(:issue2) { create(:issue, project: project2, author: author, created_at: 13.days.ago) }
+
+ let_it_be(:issue3) do
+ create(:labeled_issue,
+ project: project1,
+ labels: [label],
+ author: author,
+ milestone: milestone,
+ assignees: [assignee],
+ created_at: 14.days.ago)
+ end
+
+ let_it_be(:issue4) do
+ create(:labeled_issue,
+ project: project2,
+ labels: [label],
+ assignees: [assignee],
+ created_at: 15.days.ago)
+ end
+
+ let_it_be(:issue_outside_of_the_range) { create(:issue, project: project2, author: author, created_at: 50.days.ago) }
+
+ let(:query) do
+ <<~QUERY
+ query($path: ID!, $assigneeUsernames: [String!], $authorUsername: String, $milestoneTitle: String, $labelNames: [String!], $from: Time!, $to: Time!) {
+ #{context}(fullPath: $path) {
+ flowMetrics {
+ issueCount(assigneeUsernames: $assigneeUsernames, authorUsername: $authorUsername, milestoneTitle: $milestoneTitle, labelNames: $labelNames, from: $from, to: $to) {
+ value
+ unit
+ identifier
+ title
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ let(:variables) do
+ {
+ path: full_path,
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ subject(:result) do
+ post_graphql(query, current_user: current_user, variables: variables)
+
+ graphql_data.dig(context.to_s, 'flowMetrics', 'issueCount')
+ end
+
+ it 'returns the correct count' do
+ expect(result).to eq({
+ 'identifier' => 'issues',
+ 'unit' => nil,
+ 'value' => 4,
+ 'title' => n_('New Issue', 'New Issues', 4)
+ })
+ end
+
+ context 'with partial filters' do
+ let(:variables) do
+ {
+ path: full_path,
+ assigneeUsernames: [assignee.username],
+ labelNames: [label.title],
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ it 'returns filtered count' do
+ expect(result).to eq({
+ 'identifier' => 'issues',
+ 'unit' => nil,
+ 'value' => 2,
+ 'title' => n_('New Issue', 'New Issues', 2)
+ })
+ end
+ end
+
+ context 'with all filters' do
+ let(:variables) do
+ {
+ path: full_path,
+ assigneeUsernames: [assignee.username],
+ labelNames: [label.title],
+ authorUsername: author.username,
+ milestoneTitle: milestone.title,
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ it 'returns filtered count' do
+ expect(result).to eq({
+ 'identifier' => 'issues',
+ 'unit' => nil,
+ 'value' => 1,
+ 'title' => n_('New Issue', 'New Issues', 1)
+ })
+ end
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'returns nil' do
+ expect(result).to eq(nil)
+ end
+ end
+
+ it_behaves_like 'validation on Time arguments'
+end
+
+RSpec.shared_examples 'value stream analytics flow metrics deploymentCount examples' do
+ let_it_be(:deployment1) do
+ create(:deployment, :success, environment: production_environment1, finished_at: 5.days.ago)
+ end
+
+ let_it_be(:deployment2) do
+ create(:deployment, :success, environment: production_environment2, finished_at: 10.days.ago)
+ end
+
+ let_it_be(:deployment3) do
+ create(:deployment, :success, environment: production_environment2, finished_at: 15.days.ago)
+ end
+
+ let(:variables) do
+ {
+ path: full_path,
+ from: 12.days.ago.iso8601,
+ to: 3.days.ago.iso8601
+ }
+ end
+
+ let(:query) do
+ <<~QUERY
+ query($path: ID!, $from: Time!, $to: Time!) {
+ #{context}(fullPath: $path) {
+ flowMetrics {
+ deploymentCount(from: $from, to: $to) {
+ value
+ unit
+ identifier
+ title
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ subject(:result) do
+ post_graphql(query, current_user: current_user, variables: variables)
+
+ graphql_data.dig(context.to_s, 'flowMetrics', 'deploymentCount')
+ end
+
+ it 'returns the correct count' do
+ expect(result).to eq({
+ 'identifier' => 'deploys',
+ 'unit' => nil,
+ 'value' => 2,
+ 'title' => n_('Deploy', 'Deploys', 2)
+ })
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'returns nil' do
+ expect(result).to eq(nil)
+ end
+ end
+
+ context 'when outside of the date range' do
+ let(:variables) do
+ {
+ path: full_path,
+ from: 20.days.ago.iso8601,
+ to: 18.days.ago.iso8601
+ }
+ end
+
+ it 'returns 0 count' do
+ expect(result).to eq({
+ 'identifier' => 'deploys',
+ 'unit' => nil,
+ 'value' => 0,
+ 'title' => n_('Deploy', 'Deploys', 0)
+ })
+ end
+ end
+
+ it_behaves_like 'validation on Time arguments'
+end
+
+RSpec.shared_examples 'value stream analytics flow metrics leadTime examples' do
+ let_it_be(:milestone) { create(:milestone, group: group) }
+ let_it_be(:label) { create(:group_label, group: group) }
+
+ let_it_be(:author) { create(:user) }
+ let_it_be(:assignee) { create(:user) }
+
+ let_it_be(:issue1) do
+ create(:issue, project: project1, author: author, created_at: 17.days.ago, closed_at: 12.days.ago)
+ end
+
+ let_it_be(:issue2) do
+ create(:issue, project: project2, author: author, created_at: 16.days.ago, closed_at: 13.days.ago)
+ end
+
+ let_it_be(:issue3) do
+ create(:labeled_issue,
+ project: project1,
+ labels: [label],
+ author: author,
+ milestone: milestone,
+ assignees: [assignee],
+ created_at: 14.days.ago,
+ closed_at: 11.days.ago)
+ end
+
+ let_it_be(:issue4) do
+ create(:labeled_issue,
+ project: project2,
+ labels: [label],
+ assignees: [assignee],
+ created_at: 20.days.ago,
+ closed_at: 15.days.ago)
+ end
+
+ before do
+ Analytics::CycleAnalytics::DataLoaderService.new(group: group, model: Issue).execute
+ end
+
+ let(:query) do
+ <<~QUERY
+ query($path: ID!, $assigneeUsernames: [String!], $authorUsername: String, $milestoneTitle: String, $labelNames: [String!], $from: Time!, $to: Time!) {
+ #{context}(fullPath: $path) {
+ flowMetrics {
+ leadTime(assigneeUsernames: $assigneeUsernames, authorUsername: $authorUsername, milestoneTitle: $milestoneTitle, labelNames: $labelNames, from: $from, to: $to) {
+ value
+ unit
+ identifier
+ title
+ links {
+ label
+ url
+ }
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ let(:variables) do
+ {
+ path: full_path,
+ from: 21.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ subject(:result) do
+ post_graphql(query, current_user: current_user, variables: variables)
+
+ graphql_data.dig(context.to_s, 'flowMetrics', 'leadTime')
+ end
+
+ it 'returns the correct value' do
+ expect(result).to match(a_hash_including({
+ 'identifier' => 'lead_time',
+ 'unit' => n_('day', 'days', 4),
+ 'value' => 4,
+ 'title' => _('Lead Time'),
+ 'links' => [
+ { 'label' => s_('ValueStreamAnalytics|Dashboard'), 'url' => match(/issues_analytics/) },
+ { 'label' => s_('ValueStreamAnalytics|Go to docs'), 'url' => match(/definitions/) }
+ ]
+ }))
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'returns nil' do
+ expect(result).to eq(nil)
+ end
+ end
+
+ context 'when outside of the date range' do
+ let(:variables) do
+ {
+ path: full_path,
+ from: 30.days.ago.iso8601,
+ to: 25.days.ago.iso8601
+ }
+ end
+
+ it 'returns 0 count' do
+ expect(result).to match(a_hash_including({ 'value' => nil }))
+ end
+ end
+
+ context 'with all filters' do
+ let(:variables) do
+ {
+ path: full_path,
+ assigneeUsernames: [assignee.username],
+ labelNames: [label.title],
+ authorUsername: author.username,
+ milestoneTitle: milestone.title,
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ it 'returns filtered count' do
+ expect(result).to match(a_hash_including({ 'value' => 3 }))
+ end
+ end
+end
+
+RSpec.shared_examples 'value stream analytics flow metrics cycleTime examples' do
+ let_it_be(:milestone) { create(:milestone, group: group) }
+ let_it_be(:label) { create(:group_label, group: group) }
+
+ let_it_be(:author) { create(:user) }
+ let_it_be(:assignee) { create(:user) }
+
+ let_it_be(:issue1) do
+ create(:issue, project: project1, author: author, closed_at: 12.days.ago).tap do |issue|
+ issue.metrics.update!(first_mentioned_in_commit_at: 17.days.ago)
+ end
+ end
+
+ let_it_be(:issue2) do
+ create(:issue, project: project2, author: author, closed_at: 13.days.ago).tap do |issue|
+ issue.metrics.update!(first_mentioned_in_commit_at: 16.days.ago)
+ end
+ end
+
+ let_it_be(:issue3) do
+ create(:labeled_issue,
+ project: project1,
+ labels: [label],
+ author: author,
+ milestone: milestone,
+ assignees: [assignee],
+ closed_at: 11.days.ago).tap do |issue|
+ issue.metrics.update!(first_mentioned_in_commit_at: 14.days.ago)
+ end
+ end
+
+ let_it_be(:issue4) do
+ create(:labeled_issue,
+ project: project2,
+ labels: [label],
+ assignees: [assignee],
+ closed_at: 15.days.ago).tap do |issue|
+ issue.metrics.update!(first_mentioned_in_commit_at: 20.days.ago)
+ end
+ end
+
+ before do
+ Analytics::CycleAnalytics::DataLoaderService.new(group: group, model: Issue).execute
+ end
+
+ let(:query) do
+ <<~QUERY
+ query($path: ID!, $assigneeUsernames: [String!], $authorUsername: String, $milestoneTitle: String, $labelNames: [String!], $from: Time!, $to: Time!) {
+ #{context}(fullPath: $path) {
+ flowMetrics {
+ cycleTime(assigneeUsernames: $assigneeUsernames, authorUsername: $authorUsername, milestoneTitle: $milestoneTitle, labelNames: $labelNames, from: $from, to: $to) {
+ value
+ unit
+ identifier
+ title
+ links {
+ label
+ url
+ }
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ let(:variables) do
+ {
+ path: full_path,
+ from: 21.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ subject(:result) do
+ post_graphql(query, current_user: current_user, variables: variables)
+
+ graphql_data.dig(context.to_s, 'flowMetrics', 'cycleTime')
+ end
+
+ it 'returns the correct value' do
+ expect(result).to eq({
+ 'identifier' => 'cycle_time',
+ 'unit' => n_('day', 'days', 4),
+ 'value' => 4,
+ 'title' => _('Cycle Time'),
+ 'links' => []
+ })
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'returns nil' do
+ expect(result).to eq(nil)
+ end
+ end
+
+ context 'when outside of the date range' do
+ let(:variables) do
+ {
+ path: full_path,
+ from: 30.days.ago.iso8601,
+ to: 25.days.ago.iso8601
+ }
+ end
+
+ it 'returns 0 count' do
+ expect(result).to match(a_hash_including({ 'value' => nil }))
+ end
+ end
+
+ context 'with all filters' do
+ let(:variables) do
+ {
+ path: full_path,
+ assigneeUsernames: [assignee.username],
+ labelNames: [label.title],
+ authorUsername: author.username,
+ milestoneTitle: milestone.title,
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ it 'returns filtered count' do
+ expect(result).to match(a_hash_including({ 'value' => 3 }))
+ end
+ end
+end
+
+RSpec.shared_examples 'value stream analytics flow metrics issuesCompleted examples' do
+ let_it_be(:milestone) { create(:milestone, group: group) }
+ let_it_be(:label) { create(:group_label, group: group) }
+
+ let_it_be(:author) { create(:user) }
+ let_it_be(:assignee) { create(:user) }
+
+ # we don't care about opened date, only closed date.
+ let_it_be(:issue1) do
+ create(:issue, project: project1, author: author, created_at: 17.days.ago, closed_at: 12.days.ago)
+ end
+
+ let_it_be(:issue2) do
+ create(:issue, project: project2, author: author, created_at: 16.days.ago, closed_at: 13.days.ago)
+ end
+
+ let_it_be(:issue3) do
+ create(:labeled_issue,
+ project: project1,
+ labels: [label],
+ author: author,
+ milestone: milestone,
+ assignees: [assignee],
+ created_at: 14.days.ago,
+ closed_at: 11.days.ago)
+ end
+
+ let_it_be(:issue4) do
+ create(:labeled_issue,
+ project: project2,
+ labels: [label],
+ assignees: [assignee],
+ created_at: 20.days.ago,
+ closed_at: 15.days.ago)
+ end
+
+ before do
+ Analytics::CycleAnalytics::DataLoaderService.new(group: group, model: Issue).execute
+ end
+
+ let(:query) do
+ <<~QUERY
+ query($path: ID!, $assigneeUsernames: [String!], $authorUsername: String, $milestoneTitle: String, $labelNames: [String!], $from: Time!, $to: Time!) {
+ #{context}(fullPath: $path) {
+ flowMetrics {
+ issuesCompletedCount(assigneeUsernames: $assigneeUsernames, authorUsername: $authorUsername, milestoneTitle: $milestoneTitle, labelNames: $labelNames, from: $from, to: $to) {
+ value
+ unit
+ identifier
+ title
+ links {
+ label
+ url
+ }
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ let(:variables) do
+ {
+ path: full_path,
+ from: 21.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ subject(:result) do
+ post_graphql(query, current_user: current_user, variables: variables)
+
+ graphql_data.dig(context.to_s, 'flowMetrics', 'issuesCompletedCount')
+ end
+
+ it 'returns the correct value' do
+ expect(result).to match(a_hash_including({
+ 'identifier' => 'issues_completed',
+ 'unit' => n_('issue', 'issues', 4),
+ 'value' => 4,
+ 'title' => _('Issues Completed'),
+ 'links' => [
+ { 'label' => s_('ValueStreamAnalytics|Dashboard'), 'url' => match(/issues_analytics/) },
+ { 'label' => s_('ValueStreamAnalytics|Go to docs'), 'url' => match(/definitions/) }
+ ]
+ }))
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'returns nil' do
+ expect(result).to eq(nil)
+ end
+ end
+
+ context 'when outside of the date range' do
+ let(:variables) do
+ {
+ path: full_path,
+ from: 30.days.ago.iso8601,
+ to: 25.days.ago.iso8601
+ }
+ end
+
+ it 'returns 0 count' do
+ expect(result).to match(a_hash_including({ 'value' => 0.0 }))
+ end
+ end
+
+ context 'with all filters' do
+ let(:variables) do
+ {
+ path: full_path,
+ assigneeUsernames: [assignee.username],
+ labelNames: [label.title],
+ authorUsername: author.username,
+ milestoneTitle: milestone.title,
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ it 'returns filtered count' do
+ expect(result).to match(a_hash_including({ 'value' => 1.0 }))
+ end
+ end
+end
diff --git a/spec/support/shared_examples/analytics/cycle_analytics/request_params_examples.rb b/spec/support/shared_examples/analytics/cycle_analytics/request_params_examples.rb
new file mode 100644
index 00000000000..ef9830fbce8
--- /dev/null
+++ b/spec/support/shared_examples/analytics/cycle_analytics/request_params_examples.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'unlicensed cycle analytics request params' do
+ let(:params) do
+ {
+ created_after: '2019-01-01',
+ created_before: '2019-03-01',
+ project_ids: [2, 3],
+ namespace: namespace,
+ current_user: user
+ }
+ end
+
+ subject { described_class.new(params) }
+
+ before do
+ root_group.add_owner(user)
+ end
+
+ describe 'validations' do
+ it 'is valid' do
+ expect(subject).to be_valid
+ end
+
+ context 'when `created_before` is missing' do
+ before do
+ params[:created_before] = nil
+ end
+
+ it 'is valid', time_travel_to: '2019-03-01' do
+ expect(subject).to be_valid
+ end
+ end
+
+ context 'when `created_before` is earlier than `created_after`' do
+ before do
+ params[:created_before] = '2015-01-01'
+ end
+
+ it 'is invalid' do
+ expect(subject).not_to be_valid
+ expect(subject.errors.messages[:created_before]).not_to be_empty
+ end
+ end
+
+ context 'when the date range exceeds 180 days' do
+ before do
+ params[:created_before] = '2019-07-15'
+ end
+
+ it 'is invalid' do
+ expect(subject).not_to be_valid
+ message = s_('CycleAnalytics|The given date range is larger than 180 days')
+ expect(subject.errors.messages[:created_after]).to include(message)
+ end
+ end
+ end
+
+ it 'casts `created_after` to `Time`' do
+ expect(subject.created_after).to be_a_kind_of(Time)
+ end
+
+ it 'casts `created_before` to `Time`' do
+ expect(subject.created_before).to be_a_kind_of(Time)
+ end
+
+ describe 'optional `value_stream`' do
+ context 'when `value_stream` is not empty' do
+ let(:value_stream) { instance_double('Analytics::CycleAnalytics::ValueStream') }
+
+ before do
+ params[:value_stream] = value_stream
+ end
+
+ it { expect(subject.value_stream).to eq(value_stream) }
+ end
+
+ context 'when `value_stream` is nil' do
+ before do
+ params[:value_stream] = nil
+ end
+
+ it { expect(subject.value_stream).to eq(nil) }
+ end
+ end
+
+ describe 'sorting params' do
+ before do
+ params.merge!(sort: 'duration', direction: 'asc')
+ end
+
+ it 'converts sorting params to symbol when passing it to data collector' do
+ data_collector_params = subject.to_data_collector_params
+
+ expect(data_collector_params[:sort]).to eq(:duration)
+ expect(data_collector_params[:direction]).to eq(:asc)
+ end
+
+ it 'adds sorting params to data attributes' do
+ data_attributes = subject.to_data_attributes
+
+ expect(data_attributes[:sort]).to eq('duration')
+ expect(data_attributes[:direction]).to eq('asc')
+ end
+ end
+
+ describe 'aggregation params' do
+ context 'when not licensed' do
+ it 'returns nil' do
+ data_collector_params = subject.to_data_attributes
+ expect(data_collector_params[:aggregation]).to eq(nil)
+ end
+ end
+ end
+
+ describe 'use_aggregated_data_collector param' do
+ subject(:value) { described_class.new(params).to_data_collector_params[:use_aggregated_data_collector] }
+
+ it { is_expected.to eq(false) }
+ end
+
+ describe 'feature availablity data attributes' do
+ subject(:value) { described_class.new(params).to_data_attributes }
+
+ it 'disables all paid features' do
+ is_expected.to match(a_hash_including(enable_tasks_by_type_chart: 'false',
+ enable_customizable_stages: 'false',
+ enable_projects_filter: 'false'))
+ end
+ end
+end
diff --git a/spec/support/shared_examples/banzai/filters/filter_timeout_shared_examples.rb b/spec/support/shared_examples/banzai/filters/filter_timeout_shared_examples.rb
new file mode 100644
index 00000000000..618be53cb3b
--- /dev/null
+++ b/spec/support/shared_examples/banzai/filters/filter_timeout_shared_examples.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+# These shared_examples require the following variables:
+# - text: The text to be run through the filter
+#
+# Usage:
+#
+# it_behaves_like 'html filter timeout' do
+# let(:text) { 'some text' }
+# end
+RSpec.shared_examples 'html filter timeout' do
+ context 'when rendering takes too long' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:context) { { project: project } }
+
+ it 'times out' do
+ stub_const("Banzai::Filter::TimeoutHtmlPipelineFilter::RENDER_TIMEOUT", 0.1)
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:call_with_timeout) do
+ sleep(0.2)
+ text
+ end
+ end
+
+ expect(Gitlab::RenderTimeout).to receive(:timeout).and_call_original
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ instance_of(Timeout::Error),
+ project_id: context[:project].id,
+ class_name: described_class.name.demodulize
+ )
+
+ result = filter(text)
+
+ expect(result.to_html).to eq text
+ end
+ end
+end
+
+# Usage:
+#
+# it_behaves_like 'text html filter timeout' do
+# let(:text) { 'some text' }
+# end
+RSpec.shared_examples 'text filter timeout' do
+ context 'when rendering takes too long' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:context) { { project: project } }
+
+ it 'times out' do
+ stub_const("Banzai::Filter::TimeoutTextPipelineFilter::RENDER_TIMEOUT", 0.1)
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:call_with_timeout) do
+ sleep(0.2)
+ text
+ end
+ end
+
+ expect(Gitlab::RenderTimeout).to receive(:timeout).and_call_original
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ instance_of(Timeout::Error),
+ project_id: context[:project].id,
+ class_name: described_class.name.demodulize
+ )
+
+ result = filter(text)
+
+ expect(result).to eq text
+ end
+ end
+end
diff --git a/spec/support/shared_examples/banzai/filters/inline_embeds_shared_examples.rb b/spec/support/shared_examples/banzai/filters/inline_embeds_shared_examples.rb
index 599161abbfe..8f2f3f89914 100644
--- a/spec/support/shared_examples/banzai/filters/inline_embeds_shared_examples.rb
+++ b/spec/support/shared_examples/banzai/filters/inline_embeds_shared_examples.rb
@@ -7,6 +7,10 @@ RSpec.shared_examples 'a metrics embed filter' do
let(:input) { %(<a href="#{url}">example</a>) }
let(:doc) { filter(input) }
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
context 'when the document has an external link' do
let(:url) { 'https://foo.com' }
@@ -38,6 +42,18 @@ RSpec.shared_examples 'a metrics embed filter' do
expect(doc.at_css('.js-render-metrics')).to be_present
end
end
+
+ context 'when metrics dashboard feature is unavailable' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: true)
+ end
+
+ it 'does not append a metrics chart placeholder' do
+ node = doc.at_css('.js-render-metrics')
+
+ expect(node).not_to be_present
+ end
+ end
end
# Nokogiri escapes the URLs, but we don't care about that
diff --git a/spec/support/shared_examples/banzai/filters/reference_filter_shared_examples.rb b/spec/support/shared_examples/banzai/filters/reference_filter_shared_examples.rb
new file mode 100644
index 00000000000..6912bcaee34
--- /dev/null
+++ b/spec/support/shared_examples/banzai/filters/reference_filter_shared_examples.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+# Specs for reference links containing HTML.
+#
+# Requires a reference:
+# let(:reference) { '#42' }
+RSpec.shared_examples 'a reference containing an element node' do
+ let(:inner_html) { 'element <code>node</code> inside' }
+ let(:reference_with_element) { %(<a href="#{reference}">#{inner_html}</a>) }
+
+ it 'does not escape inner html' do
+ doc = reference_filter(reference_with_element)
+ expect(doc.children.first.inner_html).to eq(inner_html)
+ end
+end
+
+# Requires a reference, subject and subject_name:
+# subject { create(:user) }
+# let(:reference) { subject.to_reference }
+# let(:subject_name) { 'user' }
+RSpec.shared_examples 'user reference or project reference' do
+ shared_examples 'it contains a data- attribute' do
+ it 'includes a data- attribute' do
+ doc = reference_filter("Hey #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute("data-#{subject_name}")
+ expect(link.attr("data-#{subject_name}")).to eq subject.id.to_s
+ end
+ end
+
+ context 'when mentioning a resource' do
+ it_behaves_like 'a reference containing an element node'
+ it_behaves_like 'it contains a data- attribute'
+
+ it "links to a resource" do
+ doc = reference_filter("Hey #{reference}")
+ expect(doc.css('a').first.attr('href')).to eq urls.send("#{subject_name}_url", subject)
+ end
+
+ it 'links to a resource with a period' do
+ subject = create(subject_name.to_sym, name: 'alphA.Beta')
+
+ doc = reference_filter("Hey #{get_reference(subject)}")
+ expect(doc.css('a').length).to eq 1
+ end
+
+ it 'links to a resource with an underscore' do
+ subject = create(subject_name.to_sym, name: 'ping_pong_king')
+
+ doc = reference_filter("Hey #{get_reference(subject)}")
+ expect(doc.css('a').length).to eq 1
+ end
+
+ it 'links to a resource with different case-sensitivity' do
+ subject = create(subject_name.to_sym, name: 'RescueRanger')
+ reference = get_reference(subject)
+
+ doc = reference_filter("Hey #{reference.upcase}")
+ expect(doc.css('a').length).to eq 1
+ expect(doc.css('a').text).to eq(reference)
+ end
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Hey #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r{https?://}
+ expect(link).to eq urls.send "#{subject_name}_path", subject
+ end
+
+ describe 'referencing a resource in a link href' do
+ let(:reference) { %(<a href="#{get_reference(subject)}">Some text</a>) }
+
+ it_behaves_like 'it contains a data- attribute'
+
+ it 'links to the resource' do
+ doc = reference_filter("Hey #{reference}")
+ expect(doc.css('a').first.attr('href')).to eq urls.send "#{subject_name}_url", subject
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Mention me (#{reference}.)")
+ expect(doc.to_html).to match(%r{\(<a.+>Some text</a>\.\)})
+ end
+ 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 7df4b7635d3..ddd3bbd636a 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
@@ -80,7 +80,7 @@ RSpec.shared_examples 'multiple issue boards' do
click_button 'Select a label'
- page.choose(planning.title)
+ find('label', text: planning.title).click
click_button 'Add to board'
diff --git a/spec/support/shared_examples/bulk_imports/visibility_level_examples.rb b/spec/support/shared_examples/bulk_imports/visibility_level_examples.rb
index 40e9726f89c..02eae250e6a 100644
--- a/spec/support/shared_examples/bulk_imports/visibility_level_examples.rb
+++ b/spec/support/shared_examples/bulk_imports/visibility_level_examples.rb
@@ -27,14 +27,6 @@ RSpec.shared_examples 'visibility level settings' do
expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
end
-
- context 'when destination is blank' do
- let(:destination_namespace) { '' }
-
- it 'sets visibility level to public' do
- expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::PUBLIC)
- end
- end
end
context 'when internal' do
@@ -63,27 +55,6 @@ RSpec.shared_examples 'visibility level settings' do
expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
end
-
- context 'when destination is blank' do
- let(:destination_namespace) { '' }
-
- it 'sets visibility level to internal' do
- expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::INTERNAL)
- end
-
- context 'when visibility level is restricted' do
- it 'sets visibility level to private' do
- stub_application_setting(
- restricted_visibility_levels: [
- Gitlab::VisibilityLevel::INTERNAL,
- Gitlab::VisibilityLevel::PUBLIC
- ]
- )
-
- expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::PRIVATE)
- end
- end
- end
end
context 'when private' do
@@ -112,13 +83,5 @@ RSpec.shared_examples 'visibility level settings' do
expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
end
-
- context 'when destination is blank' do
- let(:destination_namespace) { '' }
-
- it 'sets visibility level to private' do
- expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel::PRIVATE)
- 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 de38d1ff9f8..af1843bae28 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
@@ -138,6 +138,19 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET status' do
.not_to exceed_all_query_limit(control_count)
end
+ context 'when user is not allowed to import projects' do
+ let(:user) { create(:user) }
+ let!(:group) { create(:group).tap { |group| group.add_developer(user) } }
+
+ it 'returns 404' do
+ expect(stub_client(repos: [], orgs: [])).to receive(:repos)
+
+ get :status, params: { namespace_id: group.id }, format: :html
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'when filtering' do
let(:repo_2) { repo_fake.new(login: 'emacs', full_name: 'asd/emacs', name: 'emacs', owner: { login: 'owner' }) }
let(:project) { create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo') }
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 44baadaaade..e94f063399d 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,4 +19,26 @@ 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
+
+ context 'when format is html' do
+ context 'when namespace_id is present' do
+ let!(:developer_group) { create(:group).tap { |g| g.add_developer(user) } }
+
+ context 'when user cannot import projects' do
+ it 'returns 404' do
+ get :status, params: { namespace_id: developer_group.id }, format: :html
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user can import projects' do
+ it 'returns 200' do
+ get :status, params: { namespace_id: group.id }, format: :html
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
end
diff --git a/spec/support/controllers/project_import_rate_limiter_shared_examples.rb b/spec/support/shared_examples/controllers/project_import_rate_limiter_shared_examples.rb
index 66d753a4010..66d753a4010 100644
--- a/spec/support/controllers/project_import_rate_limiter_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/project_import_rate_limiter_shared_examples.rb
diff --git a/spec/support/shared_examples/controllers/repositories/git_http_controller_shared_examples.rb b/spec/support/shared_examples/controllers/repositories/git_http_controller_shared_examples.rb
index cc28a79b4ca..e75188f8249 100644
--- a/spec/support/shared_examples/controllers/repositories/git_http_controller_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/repositories/git_http_controller_shared_examples.rb
@@ -60,19 +60,6 @@ RSpec.shared_examples Repositories::GitHttpController do
expect(response).to have_gitlab_http_status(:ok)
end
- it 'updates the user activity' do
- activity_project = container.is_a?(PersonalSnippet) ? nil : project
-
- activity_service = instance_double(Users::ActivityService)
-
- args = { author: user, project: activity_project, namespace: activity_project&.namespace }
- expect(Users::ActivityService).to receive(:new).with(args).and_return(activity_service)
-
- expect(activity_service).to receive(:execute)
-
- get :info_refs, params: params
- end
-
include_context 'parsed logs' do
it 'adds user info to the logs' do
get :info_refs, params: params
@@ -87,14 +74,20 @@ RSpec.shared_examples Repositories::GitHttpController do
end
describe 'POST #git_upload_pack' do
- before do
+ it 'returns 200' do
allow(controller).to receive(:verify_workhorse_api!).and_return(true)
- end
- it 'returns 200' do
post :git_upload_pack, params: params
expect(response).to have_gitlab_http_status(:ok)
end
+
+ context 'when JWT token is not provided' do
+ it 'returns 403' do
+ post :git_upload_pack, params: params
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
end
end
diff --git a/spec/support/shared_examples/controllers/snippets_sort_order_shared_examples.rb b/spec/support/shared_examples/controllers/snippets_sort_order_shared_examples.rb
index 112b9cbb204..f658cfac0f5 100644
--- a/spec/support/shared_examples/controllers/snippets_sort_order_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/snippets_sort_order_shared_examples.rb
@@ -15,8 +15,9 @@ RSpec.shared_examples 'snippets sort order' do
context 'when no sort param is provided' do
it 'calls SnippetsFinder with updated_at sort option' do
- expect(SnippetsFinder).to receive(:new).with(user,
- hash_including(sort: 'updated_desc')).and_call_original
+ expect(SnippetsFinder).to receive(:new)
+ .with(user, hash_including(sort: 'updated_desc'))
+ .and_call_original
subject
end
@@ -27,8 +28,9 @@ RSpec.shared_examples 'snippets sort order' do
let(:sort_argument) { { sort: order } }
it 'calls SnippetsFinder with the given sort param' do
- expect(SnippetsFinder).to receive(:new).with(user,
- hash_including(sort: order)).and_call_original
+ expect(SnippetsFinder).to receive(:new)
+ .with(user, hash_including(sort: order))
+ .and_call_original
subject
end
diff --git a/spec/support/shared_examples/controllers/unique_hll_events_examples.rb b/spec/support/shared_examples/controllers/unique_hll_events_examples.rb
index 38c3157e898..b5528afa0b5 100644
--- a/spec/support/shared_examples/controllers/unique_hll_events_examples.rb
+++ b/spec/support/shared_examples/controllers/unique_hll_events_examples.rb
@@ -7,6 +7,9 @@
RSpec.shared_examples 'tracking unique hll events' do
it 'tracks unique event' do
+ # Allow any event tracking before we expect the specific event we want to check below
+ allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).and_call_original
+
expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(
receive(:track_event)
.with(target_event, values: expected_value)
diff --git a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
index 5d77ed5fdfc..32aa566c27e 100644
--- a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
@@ -15,20 +15,33 @@ RSpec.shared_examples 'wiki controller actions' do
sign_in(user)
end
- shared_examples 'recovers from git timeout' do
+ shared_examples 'recovers from git errors' do
let(:method_name) { :page }
- context 'when we encounter git command errors' do
+ context 'when we encounter CommandTimedOut error' do
it 'renders the appropriate template', :aggregate_failures do
- expect(controller).to receive(method_name) do
- raise ::Gitlab::Git::CommandTimedOut, 'Deadline Exceeded'
- end
+ expect(controller)
+ .to receive(method_name)
+ .and_raise(::Gitlab::Git::CommandTimedOut, 'Deadline Exceeded')
request
expect(response).to render_template('shared/wikis/git_error')
end
end
+
+ context 'when we encounter a NoRepository error' do
+ it 'renders the appropriate template', :aggregate_failures do
+ expect(controller)
+ .to receive(method_name)
+ .and_raise(Gitlab::Git::Repository::NoRepository)
+
+ request
+
+ expect(response).to render_template('shared/wikis/empty')
+ expect(assigns(:error)).to eq('Could not access the Wiki Repository at this time.')
+ end
+ end
end
describe 'GET #new' do
@@ -65,7 +78,7 @@ RSpec.shared_examples 'wiki controller actions' do
get :pages, params: routing_params.merge(id: wiki_title)
end
- it_behaves_like 'recovers from git timeout' do
+ it_behaves_like 'recovers from git errors' do
subject(:request) { get :pages, params: routing_params.merge(id: wiki_title) }
let(:method_name) { :wiki_pages }
@@ -122,7 +135,7 @@ RSpec.shared_examples 'wiki controller actions' do
end
end
- it_behaves_like 'recovers from git timeout' do
+ it_behaves_like 'recovers from git errors' do
subject(:request) { get :history, params: routing_params.merge(id: wiki_title) }
let(:allow_read_wiki) { true }
@@ -170,7 +183,7 @@ RSpec.shared_examples 'wiki controller actions' do
end
end
- it_behaves_like 'recovers from git timeout' do
+ it_behaves_like 'recovers from git errors' do
subject(:request) { get :diff, params: routing_params.merge(id: wiki_title, version_id: wiki.repository.commit.id) }
end
end
@@ -185,7 +198,7 @@ RSpec.shared_examples 'wiki controller actions' do
context 'when page exists' do
let(:id) { wiki_title }
- it_behaves_like 'recovers from git timeout'
+ it_behaves_like 'recovers from git errors'
it 'renders the page' do
request
@@ -366,7 +379,7 @@ RSpec.shared_examples 'wiki controller actions' do
subject(:request) { get(:edit, params: routing_params.merge(id: id_param)) }
it_behaves_like 'edit action'
- it_behaves_like 'recovers from git timeout'
+ it_behaves_like 'recovers from git errors'
context 'when page content encoding is valid' do
render_views
@@ -386,11 +399,10 @@ RSpec.shared_examples 'wiki controller actions' do
let(:id_param) { wiki_title }
subject(:request) do
- patch(:update,
- params: routing_params.merge(
- id: id_param,
- wiki: { title: new_title, content: new_content }
- ))
+ patch(:update, params: routing_params.merge(
+ id: id_param,
+ wiki: { title: new_title, content: new_content }
+ ))
end
it_behaves_like 'edit action'
@@ -426,10 +438,9 @@ RSpec.shared_examples 'wiki controller actions' do
let(:new_content) { 'New content' }
subject(:request) do
- post(:create,
- params: routing_params.merge(
- wiki: { title: new_title, content: new_content }
- ))
+ post(:create, params: routing_params.merge(
+ wiki: { title: new_title, content: new_content }
+ ))
end
context 'when page is valid' do
@@ -463,10 +474,9 @@ RSpec.shared_examples 'wiki controller actions' do
let(:delete_user) { user }
subject(:request) do
- delete(:destroy,
- params: routing_params.merge(
- id: id_param
- ))
+ delete(:destroy, params: routing_params.merge(
+ id: id_param
+ ))
end
before do
diff --git a/spec/support/shared_examples/db/seeds/data_seeder_shared_examples.rb b/spec/support/shared_examples/db/seeds/data_seeder_shared_examples.rb
new file mode 100644
index 00000000000..4e8d65ac25e
--- /dev/null
+++ b/spec/support/shared_examples/db/seeds/data_seeder_shared_examples.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'raises an error when specifying an invalid factory' do
+ it 'raises an error' do
+ expect { parser.parse }.to raise_error(RuntimeError, /invalids.*to a valid registered Factory/)
+ end
+end
+
+RSpec.shared_examples 'specifying invalid traits to a factory' do
+ it 'raises an error', :aggregate_failures do
+ expect { parser.parse }.to raise_error do |error|
+ expect(error).to be_a(RuntimeError)
+ expect(error.message).to include('Trait not registered: \\"invalid\\"')
+ expect(error.message).to include('for Factory \\"issue\\"')
+ end
+ end
+end
+
+RSpec.shared_examples 'specifying invalid attributes to a factory' do
+ it 'raises an error' do
+ expect { parser.parse }.to raise_error(RuntimeError, /is not a valid attribute/)
+ end
+
+ it 'contains possible alternatives' do
+ expect { parser.parse }.to raise_error(RuntimeError, /Did you mean/)
+ end
+end
+
+RSpec.shared_examples 'an id already exists' do
+ it 'raises a validation error' do
+ expect { parser.parse }.to raise_error(/id `my_label` must be unique/)
+ end
+end
+
+RSpec.shared_examples 'name is not specified' do
+ it 'raises an error when name is not specified' do
+ expect { parser.parse }.to raise_error(/Seed file must specify a name/)
+ end
+end
+
+RSpec.shared_examples 'factory definitions' do
+ it 'has exactly two definitions' do
+ parser.parse
+
+ expect(parser.definitions.size).to eq(2)
+ end
+
+ it 'creates the group label' do
+ expect { parser.parse }.to change { GroupLabel.count }.by(1)
+ end
+
+ it 'creates the project' do
+ expect { parser.parse }.to change { Project.count }.by(1)
+ end
+end
+
+RSpec.shared_examples 'passes traits' do
+ it 'passes traits' do
+ expect_next_instance_of(Gitlab::DataSeeder::FactoryDefinitions::FactoryDefinition) do |instance|
+ # `described` trait will automaticaly generate a description
+ expect(instance.build(binding).description).to eq('Description of Test Label')
+ end
+
+ parser.parse
+ end
+end
+
+RSpec.shared_examples 'has a name' do
+ it 'has a name' do
+ parser.parse
+
+ expect(parser.name).to eq('Test')
+ end
+end
+
+RSpec.shared_examples 'definition has an id' do
+ it 'binds the object', :aggregate_failures do
+ parser.parse
+
+ expect(group_labels).to be_a(OpenStruct) # rubocop:disable Style/OpenStructUse
+ expect(group_labels.my_label).to be_a(GroupLabel)
+ expect(group_labels.my_label.title).to eq('My Label')
+ end
+end
+
+RSpec.shared_examples 'id has spaces' do
+ it 'binds to an underscored variable', :aggregate_failures do
+ parser.parse
+
+ expect(group_labels).to respond_to(:id_with_spaces)
+ expect(group_labels.id_with_spaces.title).to eq('With Spaces')
+ end
+
+ it 'renders a warning' do
+ expect { parser.parse }.to output(%(parsing id "id with spaces" as "id_with_spaces"\n)).to_stderr
+ end
+end
+
+RSpec.shared_examples 'definition does not have an id' do
+ it 'does not bind the object' do
+ parser.parse
+
+ expect(group_labels.to_h).to be_empty
+ end
+end
+
+RSpec.shared_examples 'invalid id' do |message|
+ it 'raises an error' do
+ expect { parser.parse }.to raise_error(message)
+ end
+end
diff --git a/spec/support/shared_examples/features/2fa_shared_examples.rb b/spec/support/shared_examples/features/2fa_shared_examples.rb
index 44f30c32472..6c4e98c9989 100644
--- a/spec/support/shared_examples/features/2fa_shared_examples.rb
+++ b/spec/support/shared_examples/features/2fa_shared_examples.rb
@@ -1,13 +1,11 @@
# frozen_string_literal: true
RSpec.shared_examples 'hardware device for 2fa' do |device_type|
- include Spec::Support::Helpers::Features::TwoFactorHelpers
+ include Features::TwoFactorHelpers
include Spec::Support::Helpers::ModalHelpers
def register_device(device_type, **kwargs)
case device_type.downcase
- when "u2f"
- register_u2f_device(**kwargs)
when "webauthn"
register_webauthn_device(**kwargs)
else
@@ -98,9 +96,7 @@ RSpec.shared_examples 'hardware device for 2fa' do |device_type|
end
it 'provides a button that shows the fallback otp code UI' do
- expect(page).to have_link('Sign in via 2FA code')
-
- click_link('Sign in via 2FA code')
+ click_button(_('Sign in via 2FA code'))
assert_fallback_ui(page)
end
diff --git a/spec/support/shared_examples/features/abuse_report_shared_examples.rb b/spec/support/shared_examples/features/abuse_report_shared_examples.rb
new file mode 100644
index 00000000000..ea9b4e9f4b2
--- /dev/null
+++ b/spec/support/shared_examples/features/abuse_report_shared_examples.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'reports the user with an abuse category' do
+ it 'creates abuse report' do
+ click_button 'Report abuse'
+ choose "They're posting spam."
+ click_button 'Next'
+
+ page.attach_file('spec/fixtures/dk.png') do
+ click_button "Choose file"
+ end
+
+ fill_in 'abuse_report_message', with: 'This user sends spam'
+ click_button 'Send report'
+
+ expect(page).to have_content 'Thank you for your report'
+ end
+end
diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
index 32a7b32ac72..3c78869ffaa 100644
--- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb
+++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
@@ -9,7 +9,7 @@ RSpec.shared_examples 'resource access tokens missing access rights' do
end
RSpec.shared_examples 'resource access tokens creation' do |resource_type|
- include Spec::Support::Helpers::AccessTokenHelpers
+ include Features::AccessTokenHelpers
it 'allows creation of an access token', :aggregate_failures do
name = 'My access token'
diff --git a/spec/support/shared_examples/features/confidential_notes_shared_examples.rb b/spec/support/shared_examples/features/confidential_notes_shared_examples.rb
index 289da025af6..cd0e8f94934 100644
--- a/spec/support/shared_examples/features/confidential_notes_shared_examples.rb
+++ b/spec/support/shared_examples/features/confidential_notes_shared_examples.rb
@@ -3,7 +3,7 @@
require "spec_helper"
RSpec.shared_examples 'confidential notes on issuables' do
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
context 'when user does not have permissions' do
it 'does not show confidential note checkbox' do
diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb
index 6cd9c4ce1c4..41114197ff5 100644
--- a/spec/support/shared_examples/features/content_editor_shared_examples.rb
+++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb
@@ -1,116 +1,310 @@
# frozen_string_literal: true
+require 'spec_helper'
+
RSpec.shared_examples 'edits content using the content editor' do
+ include ContentEditorHelpers
+
let(:content_editor_testid) { '[data-testid="content-editor"] [contenteditable].ProseMirror' }
- def switch_to_content_editor
- click_button _('View rich text')
- click_button _('Rich text')
- end
+ let(:is_mac) { page.evaluate_script('navigator.platform').include?('Mac') }
+ let(:modifier_key) { is_mac ? :command : :control }
- def type_in_content_editor(keys)
- find(content_editor_testid).send_keys keys
- end
+ it 'saves page content in local storage if the user navigates away' do
+ switch_to_content_editor
- def open_insert_media_dropdown
- page.find('svg[data-testid="media-icon"]').click
- end
+ expect(page).to have_css(content_editor_testid)
- def set_source_editor_content(content)
- find('.js-gfm-input').set content
- end
+ type_in_content_editor ' Typing text in the content editor'
- def expect_formatting_menu_to_be_visible
- expect(page).to have_css('[data-testid="formatting-bubble-menu"]')
- end
+ wait_until_hidden_field_is_updated /Typing text in the content editor/
- def expect_formatting_menu_to_be_hidden
- expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]')
- end
+ begin
+ refresh
+ rescue Selenium::WebDriver::Error::UnexpectedAlertOpenError
+ end
- def expect_media_bubble_menu_to_be_visible
- expect(page).to have_css('[data-testid="media-bubble-menu"]')
+ expect(page).to have_text('Typing text in the content editor')
end
- def upload_asset(fixture_name)
- attach_file('content_editor_image', Rails.root.join('spec', 'fixtures', fixture_name), make_visible: true)
- end
+ describe 'creating and editing links' do
+ before do
+ switch_to_content_editor
+ end
- def wait_until_hidden_field_is_updated(value)
- expect(page).to have_field('wiki[content]', with: value, type: 'hidden')
- end
+ context 'when clicking the link icon in the toolbar' do
+ it 'shows the link bubble menu' do
+ page.find('[data-testid="formatting-toolbar"] [data-testid="link"]').click
- it 'saves page content in local storage if the user navigates away' do
- switch_to_content_editor
+ expect(page).to have_css('[data-testid="link-bubble-menu"]')
+ end
- expect(page).to have_css(content_editor_testid)
+ context 'if no text is selected' do
+ before do
+ page.find('[data-testid="formatting-toolbar"] [data-testid="link"]').click
+ end
+
+ it 'opens an empty inline modal to create a link' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ expect(page).to have_field('link-text', with: '')
+ expect(page).to have_field('link-href', with: '')
+ end
+ end
+
+ context 'when the user clicks the apply button' do
+ it 'applies the changes to the document' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ fill_in 'link-text', with: 'Link to GitLab home page'
+ fill_in 'link-href', with: 'https://gitlab.com'
+
+ click_button 'Apply'
+ end
+
+ page.within content_editor_testid do
+ expect(page).to have_css('a[href="https://gitlab.com"]')
+ expect(page).to have_text('Link to GitLab home page')
+ end
+ end
+ end
+
+ context 'when the user clicks the cancel button' do
+ it 'does not apply the changes to the document' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ fill_in 'link-text', with: 'Link to GitLab home page'
+ fill_in 'link-href', with: 'https://gitlab.com'
+
+ click_button 'Cancel'
+ end
+
+ page.within content_editor_testid do
+ expect(page).not_to have_css('a')
+ end
+ end
+ end
+ end
- type_in_content_editor ' Typing text in the content editor'
+ context 'if text is selected' do
+ before do
+ type_in_content_editor 'The quick brown fox jumps over the lazy dog'
+ type_in_content_editor [:shift, :left]
+ type_in_content_editor [:shift, :left]
+ type_in_content_editor [:shift, :left]
+
+ page.find('[data-testid="formatting-toolbar"] [data-testid="link"]').click
+ end
+
+ it 'prefills inline modal to create a link' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ expect(page).to have_field('link-text', with: 'dog')
+ expect(page).to have_field('link-href', with: '')
+ end
+ end
+
+ context 'when the user clicks the apply button' do
+ it 'applies the changes to the document' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ fill_in 'link-text', with: 'new dog'
+ fill_in 'link-href', with: 'https://en.wikipedia.org/wiki/Shiba_Inu'
+
+ click_button 'Apply'
+ end
+
+ page.within content_editor_testid do
+ expect(page).to have_selector('a[href="https://en.wikipedia.org/wiki/Shiba_Inu"]',
+ text: 'new dog'
+ )
+ end
+ end
+ end
+ end
+ end
- wait_until_hidden_field_is_updated /Typing text in the content editor/
+ context 'if cursor is placed on an existing link' do
+ before do
+ type_in_content_editor 'Link to [GitLab home **page**](https://gitlab.com)'
+ type_in_content_editor :left
+ end
- refresh
+ it 'prefills inline modal to edit the link' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ page.find('[data-testid="edit-link"]').click
- expect(page).to have_text('Typing text in the content editor')
+ expect(page).to have_field('link-text', with: 'GitLab home page')
+ expect(page).to have_field('link-href', with: 'https://gitlab.com')
+ end
+ end
- refresh # also retained after second refresh
+ it 'updates the link attributes if text is not updated' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ page.find('[data-testid="edit-link"]').click
- expect(page).to have_text('Typing text in the content editor')
+ fill_in 'link-href', with: 'https://about.gitlab.com'
- click_link 'Cancel' # draft is deleted on cancel
+ click_button 'Apply'
+ end
- page.go_back
+ page.within content_editor_testid do
+ expect(page).to have_selector('a[href="https://about.gitlab.com"]')
+ expect(page.find('a')).to have_text('GitLab home page')
+ expect(page).to have_selector('strong', text: 'page')
+ end
+ end
- expect(page).not_to have_text('Typing text in the content editor')
- end
+ it 'updates the link attributes and text if text is updated' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ page.find('[data-testid="edit-link"]').click
- describe 'formatting bubble menu' do
- it 'shows a formatting bubble menu for a regular paragraph and headings' do
- switch_to_content_editor
+ fill_in 'link-text', with: 'GitLab about page'
+ fill_in 'link-href', with: 'https://about.gitlab.com'
- expect(page).to have_css(content_editor_testid)
+ click_button 'Apply'
+ end
- type_in_content_editor 'Typing text in the content editor'
- type_in_content_editor [:shift, :left]
+ page.within content_editor_testid do
+ expect(page).to have_selector('a[href="https://about.gitlab.com"]',
+ text: 'GitLab about page'
+ )
+ expect(page).not_to have_selector('strong')
+ end
+ end
- expect_formatting_menu_to_be_visible
+ it 'does nothing if Cancel is clicked' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ page.find('[data-testid="edit-link"]').click
- type_in_content_editor [:right, :right, :enter, '## Heading']
+ click_button 'Cancel'
+ end
- expect_formatting_menu_to_be_hidden
+ page.within content_editor_testid do
+ expect(page).to have_selector('a[href="https://gitlab.com"]',
+ text: 'GitLab home page'
+ )
+ expect(page).to have_selector('strong')
+ end
+ end
- type_in_content_editor [:shift, :left]
+ context 'when the user clicks the unlink button' do
+ it 'removes the link' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ page.find('[data-testid="remove-link"]').click
+ end
+
+ page.within content_editor_testid do
+ expect(page).not_to have_selector('a')
+ expect(page).to have_selector('strong', text: 'page')
+ end
+ end
+ end
+ end
+
+ context 'when selection spans more than a link' do
+ before do
+ type_in_content_editor 'a [b **c**](https://gitlab.com)'
+
+ type_in_content_editor [:shift, :left]
+ type_in_content_editor [:shift, :left]
+ type_in_content_editor [:shift, :left]
+ type_in_content_editor [:shift, :left]
+ type_in_content_editor [:shift, :left]
+
+ page.find('[data-testid="formatting-toolbar"] [data-testid="link"]').click
+ end
+
+ it 'prefills inline modal with the entire selection' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ expect(page).to have_field('link-text', with: 'a b c')
+ expect(page).to have_field('link-href', with: '')
+ end
+ end
- expect_formatting_menu_to_be_visible
+ it 'expands the link and updates the link attributes if text is not updated' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ fill_in 'link-href', with: 'https://about.gitlab.com'
+
+ click_button 'Apply'
+ end
+
+ page.within content_editor_testid do
+ expect(page).to have_selector('a[href="https://about.gitlab.com"]')
+ expect(page.find('a')).to have_text('a b c')
+ expect(page).to have_selector('strong', text: 'c')
+ end
+ end
+
+ it 'expands the link, updates the link attributes and text if text is updated' do
+ page.within '[data-testid="link-bubble-menu"]' do
+ fill_in 'link-text', with: 'new text'
+ fill_in 'link-href', with: 'https://about.gitlab.com'
+
+ click_button 'Apply'
+ end
+
+ page.within content_editor_testid do
+ expect(page).to have_selector('a[href="https://about.gitlab.com"]',
+ text: 'new text'
+ )
+ expect(page).not_to have_selector('strong')
+ end
+ end
end
end
- describe 'media elements bubble menu' do
+ describe 'selecting text' do
before do
switch_to_content_editor
- open_insert_media_dropdown
+ # delete all text first
+ type_in_content_editor [modifier_key, 'a']
+ type_in_content_editor :backspace
+
+ type_in_content_editor 'The quick **brown** fox _jumps_ over the lazy dog!'
+ type_in_content_editor :enter
+ type_in_content_editor '[Link](https://gitlab.com)'
+ type_in_content_editor :enter
+ type_in_content_editor 'Jackdaws love my ~~big~~ sphinx of quartz!'
+
+ # select all text
+ type_in_content_editor [modifier_key, 'a']
end
- def test_displays_media_bubble_menu(media_element_selector, fixture_file)
- upload_asset fixture_file
+ it 'renders selected text in a .content-editor-selection class' do
+ page.within content_editor_testid do
+ assert_selected 'The quick'
+ assert_selected 'brown'
+ assert_selected 'fox'
+ assert_selected 'jumps'
+ assert_selected 'over the lazy dog!'
- wait_for_requests
+ assert_selected 'Link'
- expect(page).to have_css(media_element_selector)
+ assert_selected 'Jackdaws love my'
+ assert_selected 'big'
+ assert_selected 'sphinx of quartz!'
+ end
+ end
- page.find(media_element_selector).click
+ def assert_selected(text)
+ expect(page).to have_selector('.content-editor-selection', text: text)
+ end
+ end
- expect_formatting_menu_to_be_hidden
- expect_media_bubble_menu_to_be_visible
+ describe 'media elements bubble menu' do
+ before do
+ switch_to_content_editor
+
+ click_attachment_button
end
it 'displays correct media bubble menu for images', :js do
- test_displays_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'dk.png'
+ display_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'dk.png'
+
+ expect_media_bubble_menu_to_be_visible
end
it 'displays correct media bubble menu for video', :js do
- test_displays_media_bubble_menu '[data-testid="content_editor_editablebox"] video', 'video_sample.mp4'
+ display_media_bubble_menu '[data-testid="content_editor_editablebox"] video', 'video_sample.mp4'
+
+ expect_media_bubble_menu_to_be_visible
end
end
@@ -150,7 +344,6 @@ RSpec.shared_examples 'edits content using the content editor' do
type_in_content_editor 'var a = 0'
type_in_content_editor [:shift, :left]
- expect_formatting_menu_to_be_hidden
expect(page).to have_css('[data-testid="code-block-bubble-menu"]')
end
@@ -187,8 +380,8 @@ RSpec.shared_examples 'edits content using the content editor' do
expect(iframe['src']).to include('/-/sandbox/mermaid')
within_frame(iframe) do
- expect(find('svg').text).to include('JohnDoe12')
- expect(find('svg').text).to include('HelloWorld34')
+ expect(find('svg .nodes').text).to include('JohnDoe12')
+ expect(find('svg .nodes').text).to include('HelloWorld34')
end
expect(iframe['height'].to_i).to be > 100
@@ -198,12 +391,13 @@ RSpec.shared_examples 'edits content using the content editor' do
within_frame(iframe) do
page.has_content?('JaneDoe34')
- expect(find('svg').text).to include('JaneDoe34')
- expect(find('svg').text).to include('HelloWorld56')
+ expect(find('svg .nodes').text).to include('JaneDoe34')
+ expect(find('svg .nodes').text).to include('HelloWorld56')
end
end
- it 'toggles the diagram when preview button is clicked' do
+ it 'toggles the diagram when preview button is clicked',
+ quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/397682' do
find('[data-testid="preview-diagram"]').click
expect(find(content_editor_testid)).not_to have_selector('iframe')
@@ -213,8 +407,61 @@ RSpec.shared_examples 'edits content using the content editor' do
iframe = find(content_editor_testid).find('iframe')
within_frame(iframe) do
- expect(find('svg').text).to include('JohnDoe12')
- expect(find('svg').text).to include('HelloWorld34')
+ expect(find('svg .nodes').text).to include('JohnDoe12')
+ expect(find('svg .nodes').text).to include('HelloWorld34')
+ end
+ end
+ end
+
+ describe 'pasting text' do
+ before do
+ switch_to_content_editor
+
+ type_in_content_editor "Some **rich** _text_ ~~content~~ [link](https://gitlab.com)"
+
+ type_in_content_editor [modifier_key, 'a']
+ type_in_content_editor [modifier_key, 'x']
+ end
+
+ it 'pastes text with formatting if ctrl + v is pressed' do
+ type_in_content_editor [modifier_key, 'v']
+
+ page.within content_editor_testid do
+ expect(page).to have_selector('strong', text: 'rich')
+ expect(page).to have_selector('em', text: 'text')
+ expect(page).to have_selector('s', text: 'content')
+ expect(page).to have_selector('a[href="https://gitlab.com"]', text: 'link')
+ end
+ end
+
+ it 'pastes raw text without formatting if shift + ctrl + v is pressed' do
+ type_in_content_editor [modifier_key, :shift, 'v']
+
+ page.within content_editor_testid do
+ expect(page).to have_text('Some rich text content link')
+
+ expect(page).not_to have_selector('strong')
+ expect(page).not_to have_selector('em')
+ expect(page).not_to have_selector('s')
+ expect(page).not_to have_selector('a')
+ end
+ end
+
+ it 'pastes raw text without formatting, stripping whitespaces, if shift + ctrl + v is pressed' do
+ type_in_content_editor " Some **rich**"
+ type_in_content_editor :enter
+ type_in_content_editor " _text_"
+ type_in_content_editor :enter
+ type_in_content_editor " ~~content~~"
+ type_in_content_editor :enter
+ type_in_content_editor " [link](https://gitlab.com)"
+
+ type_in_content_editor [modifier_key, 'a']
+ type_in_content_editor [modifier_key, 'x']
+ type_in_content_editor [modifier_key, :shift, 'v']
+
+ page.within content_editor_testid do
+ expect(page).to have_text('Some rich text content link')
end
end
end
@@ -225,7 +472,7 @@ RSpec.shared_examples 'edits content using the content editor' do
before do
if defined?(project)
create(:issue, project: project, title: 'My Cool Linked Issue')
- create(:merge_request, source_project: project, title: 'My Cool Merge Request')
+ create(:merge_request, source_project: project, source_branch: 'branch-1', title: 'My Cool Merge Request')
create(:label, project: project, title: 'My Cool Label')
create(:milestone, project: project, title: 'My Cool Milestone')
@@ -234,7 +481,7 @@ RSpec.shared_examples 'edits content using the content editor' do
project = create(:project, group: group)
create(:issue, project: project, title: 'My Cool Linked Issue')
- create(:merge_request, source_project: project, title: 'My Cool Merge Request')
+ create(:merge_request, source_project: project, source_branch: 'branch-1', title: 'My Cool Merge Request')
create(:group_label, group: group, title: 'My Cool Label')
create(:milestone, group: group, title: 'My Cool Milestone')
@@ -251,7 +498,9 @@ RSpec.shared_examples 'edits content using the content editor' do
expect(find(suggestions_dropdown)).to have_text('abc123')
expect(find(suggestions_dropdown)).to have_text('all')
- expect(find(suggestions_dropdown)).to have_text('Group Members (2)')
+ expect(find(suggestions_dropdown)).to have_text('Group Members')
+
+ type_in_content_editor 'bc'
send_keys [:arrow_down, :enter]
@@ -332,3 +581,23 @@ RSpec.shared_examples 'edits content using the content editor' do
end
end
end
+
+RSpec.shared_examples 'inserts diagrams.net diagram using the content editor' do
+ include ContentEditorHelpers
+
+ before do
+ switch_to_content_editor
+
+ click_attachment_button
+ end
+
+ it 'displays correct media bubble menu with edit diagram button' do
+ display_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'diagram.drawio.svg'
+
+ expect_media_bubble_menu_to_be_visible
+
+ click_edit_diagram_button
+
+ expect_drawio_editor_is_opened
+ end
+end
diff --git a/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb b/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb
index 96e57980c68..7e0e235698e 100644
--- a/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb
+++ b/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb
@@ -5,20 +5,20 @@ RSpec.shared_examples 'a creatable merge request' do
include ListboxHelpers
it 'creates new merge request', :js do
- find('.js-assignee-search').click
+ find('[data-testid="assignee-ids-dropdown-toggle"]').click
page.within '.dropdown-menu-user' do
click_link user2.name
end
expect(find('input[name="merge_request[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s)
- page.within '.js-assignee-search' do
+ page.within '[data-testid="assignee-ids-dropdown-toggle"]' do
expect(page).to have_content user2.name
end
click_link 'Assign to me'
expect(find('input[name="merge_request[assignee_ids][]"]', visible: false).value).to match(user.id.to_s)
- page.within '.js-assignee-search' do
+ page.within '[data-testid="assignee-ids-dropdown-toggle"]' do
expect(page).to have_content user.name
end
diff --git a/spec/support/shared_examples/features/dashboard/sidebar_shared_examples.rb b/spec/support/shared_examples/features/dashboard/sidebar_shared_examples.rb
index efbd735c451..9b5d9d66890 100644
--- a/spec/support/shared_examples/features/dashboard/sidebar_shared_examples.rb
+++ b/spec/support/shared_examples/features/dashboard/sidebar_shared_examples.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples "a dashboard page with sidebar" do |page_path, menu_label|
+RSpec.shared_examples 'a "Your work" page with sidebar and breadcrumbs' do |page_path, menu_label|
before do
sign_in(user)
visit send(page_path)
@@ -18,4 +18,13 @@ RSpec.shared_examples "a dashboard page with sidebar" do |page_path, menu_label|
expect(page).to have_css(active_menu_item_css)
end
end
+
+ describe "breadcrumbs" do
+ it 'has "Your work" as its root breadcrumb' do
+ breadcrumbs = page.find('[data-testid="breadcrumb-links"]')
+ within breadcrumbs do
+ expect(page).to have_css("li:first-child a[href=\"#{root_path}\"]", text: "Your work")
+ end
+ end
+ 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 9fe08e5c996..80f5f1d805c 100644
--- a/spec/support/shared_examples/features/deploy_token_shared_examples.rb
+++ b/spec/support/shared_examples/features/deploy_token_shared_examples.rb
@@ -17,9 +17,11 @@ RSpec.shared_examples 'a deploy token in settings' do
it 'add a new deploy token', :js do
visit page_path
- fill_in _('Name'), with: 'new_deploy_key'
- fill_in _('Expiration date (optional)'), with: (Date.today + 1.month).to_s
- fill_in _('Username (optional)'), with: 'deployer'
+ within('#js-deploy-tokens') do
+ fill_in _('Name'), with: 'new_deploy_key'
+ fill_in _('Expiration date (optional)'), with: (Date.today + 1.month).to_s
+ fill_in _('Username (optional)'), with: 'deployer'
+ end
check 'read_repository'
check 'read_registry'
click_button 'Create deploy token'
diff --git a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
index ea6d1655694..14e53dc8655 100644
--- a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
+++ b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
@@ -77,15 +77,21 @@ RSpec.shared_examples 'an editable merge request' do
expect(page).to have_selector('.js-quick-submit')
end
- it 'warns about version conflict' do
+ it 'warns about version conflict', :js do
merge_request.update!(title: "New title")
fill_in 'merge_request_title', with: 'bug 345'
fill_in 'merge_request_description', with: 'bug description'
- click_button 'Save changes'
+ click_button _('Save changes')
- expect(page).to have_content 'Someone edited the merge request the same time you did'
+ expect(page).to have_content(
+ format(
+ _("Someone edited this %{model_name} at the same time you did. Please check out the %{link_to_model} and make sure your changes will not unintentionally remove theirs."), # rubocop:disable Layout/LineLength
+ model_name: _('merge request'),
+ link_to_model: _('merge request')
+ )
+ )
end
it 'preserves description textarea height', :js do
@@ -104,8 +110,8 @@ RSpec.shared_examples 'an editable merge request' do
fill_in 'merge_request_description', with: long_description
height = get_textarea_height
- find('.js-md-preview-button').click
- find('.js-md-write-button').click
+ click_button("Preview")
+ click_button("Continue editing")
new_height = get_textarea_height
expect(height).to eq(new_height)
diff --git a/spec/support/shared_examples/features/explore/sidebar_shared_examples.rb b/spec/support/shared_examples/features/explore/sidebar_shared_examples.rb
new file mode 100644
index 00000000000..1754c8bf53d
--- /dev/null
+++ b/spec/support/shared_examples/features/explore/sidebar_shared_examples.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'an "Explore" page with sidebar and breadcrumbs' do |page_path, menu_label|
+ before do
+ visit send(page_path)
+ end
+
+ let(:sidebar_css) { 'aside.nav-sidebar[aria-label="Explore"]' }
+ let(:active_menu_item_css) { "li.active[data-track-label=\"#{menu_label}_menu\"]" }
+
+ it 'shows the "Explore" sidebar' do
+ expect(page).to have_css(sidebar_css)
+ end
+
+ it 'shows the correct sidebar menu item as active' do
+ within(sidebar_css) do
+ expect(page).to have_css(active_menu_item_css)
+ end
+ end
+
+ describe 'breadcrumbs' do
+ it 'has "Explore" as its root breadcrumb' do
+ within '.breadcrumbs-list' do
+ expect(page).to have_css("li:first a[href=\"#{explore_root_path}\"]", text: 'Explore')
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/incident_details_routing_shared_examples.rb b/spec/support/shared_examples/features/incident_details_routing_shared_examples.rb
index dab125caa60..b8e42843e6f 100644
--- a/spec/support/shared_examples/features/incident_details_routing_shared_examples.rb
+++ b/spec/support/shared_examples/features/incident_details_routing_shared_examples.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples 'for each incident details route' do |example, tab_text:|
+RSpec.shared_examples 'for each incident details route' do |example, tab_text:, tab:|
before do
sign_in(user)
visit incident_path
@@ -25,4 +25,16 @@ RSpec.shared_examples 'for each incident details route' do |example, tab_text:|
it_behaves_like example
end
+
+ context "for /-/issues/incident/:id/#{tab} route" do
+ let(:incident_path) { incident_project_issues_path(project, incident, tab) }
+
+ it_behaves_like example
+ end
+
+ context "for /-/issues/:id/#{tab} route" do
+ let(:incident_path) { incident_issue_project_issue_path(project, incident, tab) }
+
+ it_behaves_like example
+ end
end
diff --git a/spec/support/shared_examples/features/integrations/user_activates_mattermost_slash_command_integration_shared_examples.rb b/spec/support/shared_examples/features/integrations/user_activates_mattermost_slash_command_integration_shared_examples.rb
index 4c312b42c0a..148ff2cfb54 100644
--- a/spec/support/shared_examples/features/integrations/user_activates_mattermost_slash_command_integration_shared_examples.rb
+++ b/spec/support/shared_examples/features/integrations/user_activates_mattermost_slash_command_integration_shared_examples.rb
@@ -8,7 +8,7 @@ RSpec.shared_examples 'user activates the Mattermost Slash Command integration'
it 'shows a token placeholder' do
token_placeholder = find_field('service_token')['placeholder']
- expect(token_placeholder).to eq('XXxxXXxxXXxxXXxxXXxxXXxx')
+ expect(token_placeholder).to eq('')
end
it 'redirects to the integrations page after saving but not activating' do
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 b6f7094e422..b8c6b85adb2 100644
--- a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb
+++ b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'issuable invite members' do
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::InviteMembersModalHelpers
context 'when a privileged user can invite' do
before do
@@ -17,8 +17,6 @@ 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-action="click_invite_members"]')
- expect(page).to have_selector('[data-track-label="edit_assignee"]')
end
click_link 'Invite Members'
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 b59f3f1e27b..b8fd58e7efa 100644
--- a/spec/support/shared_examples/features/manage_applications_shared_examples.rb
+++ b/spec/support/shared_examples/features/manage_applications_shared_examples.rb
@@ -5,87 +5,40 @@ RSpec.shared_examples 'manage applications' do
let_it_be(:application_name_changed) { "#{application_name} changed" }
let_it_be(:application_redirect_uri) { 'https://foo.bar' }
- context 'when hash_oauth_secrets flag set' do
- before do
- stub_feature_flags(hash_oauth_secrets: true)
- end
-
- it 'allows user to manage applications', :js do
- visit new_application_path
+ it 'allows user to manage applications', :js do
+ visit new_application_path
- expect(page).to have_content 'Add new application'
+ expect(page).to have_content 'Add new application'
- fill_in :doorkeeper_application_name, with: application_name
- fill_in :doorkeeper_application_redirect_uri, with: application_redirect_uri
- check :doorkeeper_application_scopes_read_user
- click_on 'Save application'
+ fill_in :doorkeeper_application_name, with: application_name
+ fill_in :doorkeeper_application_redirect_uri, with: application_redirect_uri
+ check :doorkeeper_application_scopes_read_user
+ click_on 'Save application'
- validate_application(application_name, 'Yes')
- expect(page).to have_content _('This is the only time the secret is accessible. Copy the secret and store it securely')
- expect(page).to have_link('Continue', href: index_path)
+ validate_application(application_name, 'Yes')
+ expect(page).to have_content _('This is the only time the secret is accessible. Copy the secret and store it securely')
+ expect(page).to have_link('Continue', href: index_path)
- expect(page).to have_css("button[title=\"Copy secret\"]", text: 'Copy')
+ expect(page).to have_button(_('Copy secret'))
- click_on 'Edit'
+ click_on 'Edit'
- application_name_changed = "#{application_name} changed"
+ application_name_changed = "#{application_name} changed"
- fill_in :doorkeeper_application_name, with: application_name_changed
- uncheck :doorkeeper_application_confidential
- click_on 'Save application'
-
- validate_application(application_name_changed, 'No')
- expect(page).not_to have_link('Continue')
- expect(page).to have_content _('The secret is only available when you first create the application')
-
- visit_applications_path
-
- page.within '.oauth-applications' do
- click_on 'Destroy'
- end
- expect(page.find('.oauth-applications')).not_to have_content 'test_changed'
- end
- end
-
- context 'when hash_oauth_secrets flag not set' do
- before do
- stub_feature_flags(hash_oauth_secrets: false)
- end
-
- it 'allows user to manage applications', :js do
- visit new_application_path
-
- expect(page).to have_content 'Add new application'
-
- fill_in :doorkeeper_application_name, with: application_name
- fill_in :doorkeeper_application_redirect_uri, with: application_redirect_uri
- check :doorkeeper_application_scopes_read_user
- click_on 'Save application'
-
- validate_application(application_name, 'Yes')
- expect(page).to have_link('Continue', href: index_path)
-
- application = Doorkeeper::Application.find_by(name: application_name)
- expect(page).to have_css("button[title=\"Copy secret\"][data-clipboard-text=\"#{application.secret}\"]", text: 'Copy')
-
- click_on 'Edit'
-
- application_name_changed = "#{application_name} changed"
-
- fill_in :doorkeeper_application_name, with: application_name_changed
- uncheck :doorkeeper_application_confidential
- click_on 'Save application'
+ fill_in :doorkeeper_application_name, with: application_name_changed
+ uncheck :doorkeeper_application_confidential
+ click_on 'Save application'
- validate_application(application_name_changed, 'No')
- expect(page).not_to have_link('Continue')
+ validate_application(application_name_changed, 'No')
+ expect(page).not_to have_link('Continue')
+ expect(page).to have_content _('The secret is only available when you create the application or renew the secret.')
- visit_applications_path
+ visit_applications_path
- page.within '.oauth-applications' do
- click_on 'Destroy'
- end
- expect(page.find('.oauth-applications')).not_to have_content 'test_changed'
+ page.within '.oauth-applications' do
+ click_on 'Destroy'
end
+ expect(page.find('.oauth-applications')).not_to have_content 'test_changed'
end
context 'when scopes are blank' do
diff --git a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb
index c2dc87b0fb0..6487e6a94c1 100644
--- a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb
+++ b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'Maintainer manages access requests' do
- include Spec::Support::Helpers::Features::MembersHelpers
+ include Features::MembersHelpers
let(:user) { create(:user) }
let(:maintainer) { create(:user) }
diff --git a/spec/support/shared_examples/features/milestone_editing_shared_examples.rb b/spec/support/shared_examples/features/milestone_editing_shared_examples.rb
new file mode 100644
index 00000000000..d21bf62ecfa
--- /dev/null
+++ b/spec/support/shared_examples/features/milestone_editing_shared_examples.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'milestone handling version conflicts' do
+ it 'warns about version conflict when milestone has been updated in the background' do
+ # Update the milestone in the background in order to trigger a version conflict
+ milestone.update!(title: "New title")
+
+ fill_in _('Title'), with: 'Title for version conflict'
+ fill_in _('Description'), with: 'Description for version conflict'
+
+ click_button _('Save changes')
+
+ expect(page).to have_content(
+ format(
+ _("Someone edited this %{model_name} at the same time you did. Please check out the %{link_to_model} and make sure your changes will not unintentionally remove theirs."), # rubocop:disable Layout/LineLength
+ model_name: _('milestone'),
+ link_to_model: _('milestone')
+ )
+ )
+ end
+end
diff --git a/spec/support/shared_examples/features/packages_shared_examples.rb b/spec/support/shared_examples/features/packages_shared_examples.rb
index f09cf0613a1..5126e849c2e 100644
--- a/spec/support/shared_examples/features/packages_shared_examples.rb
+++ b/spec/support/shared_examples/features/packages_shared_examples.rb
@@ -9,7 +9,7 @@ RSpec.shared_examples 'packages list' do |check_project_name: false|
expect(package_row).to have_content(pkg.name)
expect(package_row).to have_content(pkg.version)
- expect(package_row).to have_content(pkg.project.name) if check_project_name
+ expect(package_row).to have_content(pkg.project.path) if check_project_name
end
end
@@ -18,7 +18,35 @@ RSpec.shared_examples 'packages list' do |check_project_name: false|
end
end
+RSpec.shared_examples 'pipelines on packages list' do
+ let_it_be(:pipelines) do
+ %w[c83d6e391c22777fca1ed3012fce84f633d7fed0
+ d83d6e391c22777fca1ed3012fce84f633d7fed0].map do |sha|
+ create(:ci_pipeline, project: project, sha: sha)
+ end
+ end
+
+ before do
+ pipelines.each do |pipeline|
+ create(:package_build_info, package: package, pipeline: pipeline)
+ end
+ end
+
+ it 'shows the latest pipeline' do
+ # Test after reload
+ page.evaluate_script 'window.location.reload()'
+
+ wait_for_requests
+
+ expect(page).to have_content('d83d6e39')
+ end
+end
+
RSpec.shared_examples 'package details link' do |property|
+ before do
+ stub_application_setting(npm_package_requests_forwarding: false)
+ end
+
it 'navigates to the correct url' do
page.within(packages_table_selector) do
click_link package.name
@@ -30,6 +58,45 @@ RSpec.shared_examples 'package details link' do |property|
expect(page).to have_content('Installation')
expect(page).to have_content('Registry setup')
+ expect(page).to have_content('Other versions 0')
+ end
+
+ context 'with other versions' do
+ let_it_be(:npm_package1) { create(:npm_package, project: project, name: 'zzz', version: '1.1.0') }
+ let_it_be(:npm_package2) { create(:npm_package, project: project, name: 'zzz', version: '1.2.0') }
+
+ before do
+ page.within(packages_table_selector) do
+ first(:link, package.name).click
+ end
+ end
+
+ it 'shows tab with count' do
+ expect(page).to have_content('Other versions 2')
+ end
+
+ it 'visiting tab shows total on page' do
+ click_link 'Other versions'
+
+ expect(page).to have_content('2 versions')
+ end
+
+ it 'deleting version updates count' do
+ click_link 'Other versions'
+
+ find('[data-testid="delete-dropdown"]', match: :first).click
+ find('[data-testid="action-delete"]', match: :first).click
+ click_button('Permanently delete')
+
+ expect(page).to have_content 'Package deleted successfully'
+
+ expect(page).to have_content('Other versions 1')
+ expect(page).to have_content('1 version')
+
+ expect(page).not_to have_content('1.0.0')
+ expect(page).to have_content('1.1.0')
+ expect(page).to have_content('1.2.0')
+ end
end
end
diff --git a/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb b/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb
index 81d548e000a..2d3f1949716 100644
--- a/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb
+++ b/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb
@@ -1,126 +1,67 @@
# frozen_string_literal: true
RSpec.shared_examples "protected branches > access control > CE" do
- ProtectedRefAccess::HUMAN_ACCESS_LEVELS.each do |(access_type_id, access_type_name)|
+ let(:no_one) { ProtectedRef::AccessLevel.humanize(::Gitlab::Access::NO_ACCESS) }
+
+ ProtectedRef::AccessLevel.human_access_levels.each do |(access_type_id, access_type_name)|
it "allows creating protected branches that #{access_type_name} can push to" do
visit project_protected_branches_path(project)
set_protected_branch_name('master')
-
- find(".js-allowed-to-merge").click
- within('[data-testid="allowed-to-merge-dropdown"]') do
- expect(first("li")).to have_content("Roles")
- find(:link, 'No one').click
- end
-
- within('.js-new-protected-branch') do
- allowed_to_push_button = find(".js-allowed-to-push")
-
- unless allowed_to_push_button.text == access_type_name
- allowed_to_push_button.click
- within(".dropdown.show .dropdown-menu") { click_on access_type_name }
- end
- end
-
+ set_allowed_to('merge', no_one)
+ set_allowed_to('push', access_type_name)
click_on_protect
- wait_for_requests
expect(ProtectedBranch.count).to eq(1)
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to eq([access_type_id])
end
- it "allows updating protected branches so that #{access_type_name} can push to them" do
+ it "allows creating protected branches that #{access_type_name} can merge to" do
visit project_protected_branches_path(project)
set_protected_branch_name('master')
-
- find(".js-allowed-to-merge").click
- within('[data-testid="allowed-to-merge-dropdown"]') do
- expect(first("li")).to have_content("Roles")
- find(:link, 'No one').click
- end
-
- find(".js-allowed-to-push").click
- within('[data-testid="allowed-to-push-dropdown"]') do
- expect(first("li")).to have_content("Roles")
- find(:link, 'No one').click
- end
-
+ set_allowed_to('merge', access_type_name)
+ set_allowed_to('push', no_one)
click_on_protect
expect(ProtectedBranch.count).to eq(1)
-
- within(".protected-branches-list") do
- find(".js-allowed-to-push").click
-
- within('.js-allowed-to-push-container') do
- expect(first("li")).to have_content("Roles")
- find(:link, access_type_name).click
- end
-
- find(".js-allowed-to-push").click
- end
-
- wait_for_requests
-
- expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id)
+ expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to eq([access_type_id])
end
- end
- ProtectedRefAccess::HUMAN_ACCESS_LEVELS.each do |(access_type_id, access_type_name)|
- it "allows creating protected branches that #{access_type_name} can merge to" do
+ it "allows updating protected branches so that #{access_type_name} can push to them" do
visit project_protected_branches_path(project)
set_protected_branch_name('master')
+ set_allowed_to('merge', no_one)
+ set_allowed_to('push', no_one)
+ click_on_protect
- within('.js-new-protected-branch') do
- allowed_to_merge_button = find(".js-allowed-to-merge")
+ expect(ProtectedBranch.count).to eq(1)
- unless allowed_to_merge_button.text == access_type_name
- allowed_to_merge_button.click
- within(".dropdown.show .dropdown-menu") { click_on access_type_name }
+ within(".protected-branches-list") do
+ within_select(".js-allowed-to-push") do
+ click_on(access_type_name)
end
end
- find(".js-allowed-to-push").click
- within('[data-testid="allowed-to-push-dropdown"]') do
- expect(first("li")).to have_content("Roles")
- find(:link, 'No one').click
- end
-
- click_on_protect
+ wait_for_requests
- expect(ProtectedBranch.count).to eq(1)
- expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to eq([access_type_id])
+ expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id)
end
it "allows updating protected branches so that #{access_type_name} can merge to them" do
visit project_protected_branches_path(project)
set_protected_branch_name('master')
-
- find(".js-allowed-to-merge").click
- within('[data-testid="allowed-to-merge-dropdown"]') do
- expect(first("li")).to have_content("Roles")
- find(:link, 'No one').click
- end
-
- find(".js-allowed-to-push").click
- within('[data-testid="allowed-to-push-dropdown"]') do
- expect(first("li")).to have_content("Roles")
- find(:link, 'No one').click
- end
-
+ set_allowed_to('merge', no_one)
+ set_allowed_to('push', no_one)
click_on_protect
expect(ProtectedBranch.count).to eq(1)
within(".protected-branches-list") do
- find(".js-allowed-to-merge").click
-
- within('.js-allowed-to-merge-container') do
- expect(first("li")).to have_content("Roles")
- find(:link, access_type_name).click
+ within_select(".js-allowed-to-merge") do
+ click_on(access_type_name)
end
end
diff --git a/spec/support/shared_examples/features/protected_tags_with_deploy_keys_examples.rb b/spec/support/shared_examples/features/protected_tags_with_deploy_keys_examples.rb
new file mode 100644
index 00000000000..cc0984b6226
--- /dev/null
+++ b/spec/support/shared_examples/features/protected_tags_with_deploy_keys_examples.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'Deploy keys with protected tags' do
+ let(:dropdown_sections_minus_deploy_keys) { all_dropdown_sections - ['Deploy Keys'] }
+
+ context 'when deploy keys are enabled to this project' do
+ let!(:deploy_key_1) { create(:deploy_key, title: 'title 1', projects: [project]) }
+ let!(:deploy_key_2) { create(:deploy_key, title: 'title 2', projects: [project]) }
+
+ context 'when only one deploy key can push' do
+ before do
+ deploy_key_1.deploy_keys_projects.first.update!(can_push: true)
+ end
+
+ it "shows all dropdown sections in the 'Allowed to create' main dropdown, with only one deploy key" do
+ visit project_protected_tags_path(project)
+
+ find(".js-allowed-to-create").click
+ wait_for_requests
+
+ within('[data-testid="allowed-to-create-dropdown"]') do
+ dropdown_headers = page.all('.dropdown-header').map(&:text)
+
+ expect(dropdown_headers).to contain_exactly(*all_dropdown_sections)
+ expect(page).to have_content('title 1')
+ expect(page).not_to have_content('title 2')
+ end
+ end
+
+ it "shows all sections in the 'Allowed to create' update dropdown" do
+ create(:protected_tag, :no_one_can_create, project: project, name: 'v1.0.0')
+
+ visit project_protected_tags_path(project)
+
+ within(".js-protected-tag-edit-form") do
+ find(".js-allowed-to-create").click
+ wait_for_requests
+
+ dropdown_headers = page.all('.dropdown-header').map(&:text)
+
+ expect(dropdown_headers).to contain_exactly(*all_dropdown_sections)
+ end
+ end
+ end
+
+ context 'when no deploy key can push' do
+ it "just shows all sections but not deploy keys in the 'Allowed to create' dropdown" do
+ visit project_protected_tags_path(project)
+
+ find(".js-allowed-to-create").click
+ wait_for_requests
+
+ within('[data-testid="allowed-to-create-dropdown"]') do
+ dropdown_headers = page.all('.dropdown-header').map(&:text)
+
+ expect(dropdown_headers).to contain_exactly(*dropdown_sections_minus_deploy_keys)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/reportable_note_shared_examples.rb b/spec/support/shared_examples/features/reportable_note_shared_examples.rb
index bb3fab5b23e..133da230bed 100644
--- a/spec/support/shared_examples/features/reportable_note_shared_examples.rb
+++ b/spec/support/shared_examples/features/reportable_note_shared_examples.rb
@@ -20,7 +20,7 @@ RSpec.shared_examples 'reportable note' do |type|
dropdown = comment.find(more_actions_selector)
open_dropdown(dropdown)
- expect(dropdown).to have_button('Report abuse to administrator')
+ expect(dropdown).to have_button('Report abuse')
if type == 'issue' || type == 'merge_request'
expect(dropdown).to have_button('Delete comment')
@@ -33,7 +33,7 @@ RSpec.shared_examples 'reportable note' do |type|
dropdown = comment.find(more_actions_selector)
open_dropdown(dropdown)
- dropdown.click_button('Report abuse to administrator')
+ dropdown.click_button('Report abuse')
choose "They're posting spam."
click_button "Next"
@@ -48,6 +48,6 @@ RSpec.shared_examples 'reportable note' do |type|
restore_window_size
dropdown.find('.more-actions-toggle').click
- dropdown.find('.dropdown-menu li', match: :first)
+ dropdown.find('.more-actions li', match: :first)
end
end
diff --git a/spec/support/shared_examples/features/rss_shared_examples.rb b/spec/support/shared_examples/features/rss_shared_examples.rb
index ad865b084e1..f6566214e32 100644
--- a/spec/support/shared_examples/features/rss_shared_examples.rb
+++ b/spec/support/shared_examples/features/rss_shared_examples.rb
@@ -13,6 +13,12 @@ RSpec.shared_examples "it has an RSS button with current_user's feed token" do
end
end
+RSpec.shared_examples "it has an RSS link with current_user's feed token" do
+ it "shows the RSS link with current_user's feed token" do
+ expect(page).to have_link 'Subscribe to RSS feed', href: /feed_token=#{user.feed_token}/
+ end
+end
+
RSpec.shared_examples "an autodiscoverable RSS feed without a feed token" do
it "has an RSS autodiscovery link tag without a feed token" do
expect(page).to have_css("link[type*='atom+xml']:not([href*='feed_token'])", visible: false)
@@ -26,10 +32,18 @@ RSpec.shared_examples "it has an RSS button without a feed token" do
end
end
+RSpec.shared_examples "it has an RSS link without a feed token" do
+ it "shows the RSS link without a feed token" do
+ expect(page).to have_link 'Subscribe to RSS feed'
+ expect(page).not_to have_link 'Subscribe to RSS feed', href: /feed_token/
+ end
+end
+
RSpec.shared_examples "updates atom feed link" do |type|
it "for #{type}" do
sign_in(user)
visit path
+ click_button 'Actions', match: :first
link = find_link('Subscribe to RSS feed')
params = CGI.parse(URI.parse(link[:href]).query)
diff --git a/spec/support/shared_examples/features/runners_shared_examples.rb b/spec/support/shared_examples/features/runners_shared_examples.rb
index 63a0832117d..7edf306183e 100644
--- a/spec/support/shared_examples/features/runners_shared_examples.rb
+++ b/spec/support/shared_examples/features/runners_shared_examples.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'shows and resets runner registration token' do
- include Spec::Support::Helpers::Features::RunnersHelpers
+ include Features::RunnersHelpers
include Spec::Support::Helpers::ModalHelpers
before do
@@ -63,16 +63,15 @@ RSpec.shared_examples 'shows and resets runner registration token' do
end
RSpec.shared_examples 'shows no runners registered' do
- it 'shows total count with 0' do
+ it 'shows 0 count and the empty state' do
expect(find('[data-testid="runner-type-tabs"]')).to have_text "#{s_('Runners|All')} 0"
# No stats are shown
expect(page).not_to have_text s_('Runners|Online')
expect(page).not_to have_text s_('Runners|Offline')
expect(page).not_to have_text s_('Runners|Stale')
- end
- it 'shows "no runners" message' do
+ # "no runners" message
expect(page).to have_text s_('Runners|Get started with runners')
end
end
@@ -84,16 +83,14 @@ RSpec.shared_examples 'shows no runners found' do
end
RSpec.shared_examples 'shows runner in list' do
- it 'does not show empty state' do
- expect(page).not_to have_content s_('Runners|Get started with runners')
- end
-
- it 'shows runner row' do
+ it 'shows runner row and no empty state' do
within_runner_row(runner.id) do
expect(page).to have_text "##{runner.id}"
expect(page).to have_text runner.short_sha
expect(page).to have_text runner.description
end
+
+ expect(page).not_to have_content s_('Runners|Get started with runners')
end
end
@@ -229,3 +226,33 @@ RSpec.shared_examples 'submits edit runner form' do
end
end
end
+
+RSpec.shared_examples 'creates runner and shows register page' do
+ context 'when runner is saved' do
+ before do
+ fill_in s_('Runners|Runner description'), with: 'runner-foo'
+ fill_in s_('Runners|Tags'), with: 'tag1'
+ click_on _('Submit')
+ wait_for_requests
+ end
+
+ it 'navigates to registration page and opens install instructions drawer' do
+ expect(page.find('[data-testid="alert-success"]')).to have_content(s_('Runners|Runner created.'))
+ expect(current_url).to match(register_path_pattern)
+
+ click_on 'How do I install GitLab Runner?'
+ expect(page.find('[data-testid="runner-platforms-drawer"]')).to have_content('gitlab-runner install')
+ end
+
+ it 'warns from leaving page without finishing registration' do
+ click_on s_('Runners|Go to runners page')
+
+ alert = page.driver.browser.switch_to.alert
+
+ expect(alert).not_to be_nil
+ alert.dismiss
+
+ expect(current_url).to match(register_path_pattern)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/search/redacted_search_results_shared_examples.rb b/spec/support/shared_examples/features/search/redacted_search_results_shared_examples.rb
index 4d242d0e719..cbd0ffbab21 100644
--- a/spec/support/shared_examples/features/search/redacted_search_results_shared_examples.rb
+++ b/spec/support/shared_examples/features/search/redacted_search_results_shared_examples.rb
@@ -48,14 +48,18 @@ RSpec.shared_examples 'a redacted search results' do
it 'redacts the inaccessible issue' do
expect(search_service.send(:logger))
.to receive(:error)
- .with(hash_including(
- message: "redacted_search_results",
- current_user_id: user.id,
- query: search,
- filtered: array_including(
- [
- { class_name: 'Issue', id: unreadable.id, ability: :read_issue }
- ])))
+ .with(
+ hash_including(
+ message: "redacted_search_results",
+ current_user_id: user.id,
+ query: search,
+ filtered: array_including(
+ [
+ { class_name: 'Issue', id: unreadable.id, ability: :read_issue }
+ ]
+ )
+ )
+ )
expect(result).to contain_exactly(readable)
end
@@ -95,16 +99,18 @@ RSpec.shared_examples 'a redacted search results' do
end
let(:unredacted_results) do
- ar_relation(Note,
- readable_note_on_commit,
- readable_diff_note,
- readable_note_on_mr,
- readable_diff_note_on_mr,
- readable_note_on_project_snippet,
- unreadable_note_on_commit,
- unreadable_diff_note,
- unreadable_note_on_mr,
- unreadable_note_on_project_snippet)
+ ar_relation(
+ Note,
+ readable_note_on_commit,
+ readable_diff_note,
+ readable_note_on_mr,
+ readable_diff_note_on_mr,
+ readable_note_on_project_snippet,
+ unreadable_note_on_commit,
+ unreadable_diff_note,
+ unreadable_note_on_mr,
+ unreadable_note_on_project_snippet
+ )
end
let(:scope) { 'notes' }
@@ -112,23 +118,29 @@ RSpec.shared_examples 'a redacted search results' do
it 'redacts the inaccessible notes' do
expect(search_service.send(:logger))
.to receive(:error)
- .with(hash_including(
- message: "redacted_search_results",
- current_user_id: user.id,
- query: search,
- filtered: array_including(
- [
- { class_name: 'Note', id: unreadable_note_on_commit.id, ability: :read_note },
- { class_name: 'DiffNote', id: unreadable_diff_note.id, ability: :read_note },
- { class_name: 'DiscussionNote', id: unreadable_note_on_mr.id, ability: :read_note },
- { class_name: 'Note', id: unreadable_note_on_project_snippet.id, ability: :read_note }
- ])))
-
- expect(result).to contain_exactly(readable_note_on_commit,
- readable_diff_note,
- readable_note_on_mr,
- readable_diff_note_on_mr,
- readable_note_on_project_snippet)
+ .with(
+ hash_including(
+ message: "redacted_search_results",
+ current_user_id: user.id,
+ query: search,
+ filtered: array_including(
+ [
+ { class_name: 'Note', id: unreadable_note_on_commit.id, ability: :read_note },
+ { class_name: 'DiffNote', id: unreadable_diff_note.id, ability: :read_note },
+ { class_name: 'DiscussionNote', id: unreadable_note_on_mr.id, ability: :read_note },
+ { class_name: 'Note', id: unreadable_note_on_project_snippet.id, ability: :read_note }
+ ]
+ )
+ )
+ )
+
+ expect(result).to contain_exactly(
+ readable_note_on_commit,
+ readable_diff_note,
+ readable_note_on_mr,
+ readable_diff_note_on_mr,
+ readable_note_on_project_snippet
+ )
end
end
@@ -141,14 +153,18 @@ RSpec.shared_examples 'a redacted search results' do
it 'redacts the inaccessible merge request' do
expect(search_service.send(:logger))
.to receive(:error)
- .with(hash_including(
- message: "redacted_search_results",
- current_user_id: user.id,
- query: search,
- filtered: array_including(
- [
- { class_name: 'MergeRequest', id: unreadable.id, ability: :read_merge_request }
- ])))
+ .with(
+ hash_including(
+ message: "redacted_search_results",
+ current_user_id: user.id,
+ query: search,
+ filtered: array_including(
+ [
+ { class_name: 'MergeRequest', id: unreadable.id, ability: :read_merge_request }
+ ]
+ )
+ )
+ )
expect(result).to contain_exactly(readable)
end
@@ -169,14 +185,18 @@ RSpec.shared_examples 'a redacted search results' do
it 'redacts the inaccessible blob' do
expect(search_service.send(:logger))
.to receive(:error)
- .with(hash_including(
- message: "redacted_search_results",
- current_user_id: user.id,
- query: search,
- filtered: array_including(
- [
- { class_name: 'Gitlab::Search::FoundBlob', id: unreadable.id, ability: :read_blob }
- ])))
+ .with(
+ hash_including(
+ message: "redacted_search_results",
+ current_user_id: user.id,
+ query: search,
+ filtered: array_including(
+ [
+ { class_name: 'Gitlab::Search::FoundBlob', id: unreadable.id, ability: :read_blob }
+ ]
+ )
+ )
+ )
expect(result).to contain_exactly(readable)
end
@@ -191,14 +211,18 @@ RSpec.shared_examples 'a redacted search results' do
it 'redacts the inaccessible blob' do
expect(search_service.send(:logger))
.to receive(:error)
- .with(hash_including(
- message: "redacted_search_results",
- current_user_id: user.id,
- query: search,
- filtered: array_including(
- [
- { class_name: 'Gitlab::Search::FoundWikiPage', id: unreadable.id, ability: :read_wiki_page }
- ])))
+ .with(
+ hash_including(
+ message: "redacted_search_results",
+ current_user_id: user.id,
+ query: search,
+ filtered: array_including(
+ [
+ { class_name: 'Gitlab::Search::FoundWikiPage', id: unreadable.id, ability: :read_wiki_page }
+ ]
+ )
+ )
+ )
expect(result).to contain_exactly(readable)
end
@@ -213,14 +237,18 @@ RSpec.shared_examples 'a redacted search results' do
it 'redacts the inaccessible snippet' do
expect(search_service.send(:logger))
.to receive(:error)
- .with(hash_including(
- message: "redacted_search_results",
- current_user_id: user.id,
- query: search,
- filtered: array_including(
- [
- { class_name: 'ProjectSnippet', id: unreadable.id, ability: :read_snippet }
- ])))
+ .with(
+ hash_including(
+ message: "redacted_search_results",
+ current_user_id: user.id,
+ query: search,
+ filtered: array_including(
+ [
+ { class_name: 'ProjectSnippet', id: unreadable.id, ability: :read_snippet }
+ ]
+ )
+ )
+ )
expect(result).to contain_exactly(readable)
end
@@ -239,14 +267,18 @@ RSpec.shared_examples 'a redacted search results' do
it 'redacts the inaccessible snippet' do
expect(search_service.send(:logger))
.to receive(:error)
- .with(hash_including(
- message: "redacted_search_results",
- current_user_id: user.id,
- query: search,
- filtered: array_including(
- [
- { class_name: 'PersonalSnippet', id: unreadable.id, ability: :read_snippet }
- ])))
+ .with(
+ hash_including(
+ message: "redacted_search_results",
+ current_user_id: user.id,
+ query: search,
+ filtered: array_including(
+ [
+ { class_name: 'PersonalSnippet', id: unreadable.id, ability: :read_snippet }
+ ]
+ )
+ )
+ )
expect(result).to contain_exactly(readable)
end
@@ -265,14 +297,18 @@ RSpec.shared_examples 'a redacted search results' do
it 'redacts the inaccessible commit' do
expect(search_service.send(:logger))
.to receive(:error)
- .with(hash_including(
- message: "redacted_search_results",
- current_user_id: user.id,
- query: search,
- filtered: array_including(
- [
- { class_name: 'Commit', id: unreadable.id, ability: :read_commit }
- ])))
+ .with(
+ hash_including(
+ message: "redacted_search_results",
+ current_user_id: user.id,
+ query: search,
+ filtered: array_including(
+ [
+ { class_name: 'Commit', id: unreadable.id, ability: :read_commit }
+ ]
+ )
+ )
+ )
expect(result).to contain_exactly(readable)
end
diff --git a/spec/support/shared_examples/features/secure_oauth_authorizations_shared_examples.rb b/spec/support/shared_examples/features/secure_oauth_authorizations_shared_examples.rb
index 028e075c87a..231406289b4 100644
--- a/spec/support/shared_examples/features/secure_oauth_authorizations_shared_examples.rb
+++ b/spec/support/shared_examples/features/secure_oauth_authorizations_shared_examples.rb
@@ -10,7 +10,7 @@ RSpec.shared_examples 'Secure OAuth Authorizations' do
end
context 'when user is unconfirmed' do
- let(:user) { create(:user, confirmed_at: nil) }
+ let(:user) { create(:user, :unconfirmed) }
it 'displays an error' do
expect(page).to have_text I18n.t('doorkeeper.errors.messages.unconfirmed_email')
diff --git a/spec/support/shared_examples/features/trial_email_validation_shared_example.rb b/spec/support/shared_examples/features/trial_email_validation_shared_example.rb
index 8304a91af86..81c9ac1164b 100644
--- a/spec/support/shared_examples/features/trial_email_validation_shared_example.rb
+++ b/spec/support/shared_examples/features/trial_email_validation_shared_example.rb
@@ -1,59 +1,38 @@
# frozen_string_literal: true
RSpec.shared_examples 'user email validation' do
- let(:email_hint_message) { 'We recommend a work email address.' }
- let(:email_error_message) { 'Please provide a valid email address.' }
+ let(:email_hint_message) { _('We recommend a work email address.') }
+ let(:email_error_message) { _('Please provide a valid email address.') }
let(:email_warning_message) do
- 'This email address does not look right, are you sure you typed it correctly?'
+ _('This email address does not look right, are you sure you typed it correctly?')
end
- context 'with trial_email_validation flag enabled' do
- it 'shows an error message until a correct email is entered' do
- visit path
- expect(page).to have_content(email_hint_message)
- expect(page).not_to have_content(email_error_message)
- expect(page).not_to have_content(email_warning_message)
+ it 'shows an error message until a correct email is entered' do
+ visit path
+ expect(page).to have_content(email_hint_message)
+ expect(page).not_to have_content(email_error_message)
+ expect(page).not_to have_content(email_warning_message)
- fill_in 'new_user_email', with: 'foo@'
- fill_in 'new_user_first_name', with: ''
+ fill_in 'new_user_email', with: 'foo@'
+ fill_in 'new_user_first_name', with: ''
- expect(page).not_to have_content(email_hint_message)
- expect(page).to have_content(email_error_message)
- expect(page).not_to have_content(email_warning_message)
+ expect(page).not_to have_content(email_hint_message)
+ expect(page).to have_content(email_error_message)
+ expect(page).not_to have_content(email_warning_message)
- fill_in 'new_user_email', with: 'foo@bar'
- fill_in 'new_user_first_name', with: ''
+ fill_in 'new_user_email', with: 'foo@bar'
+ fill_in 'new_user_first_name', with: ''
- expect(page).not_to have_content(email_hint_message)
- expect(page).not_to have_content(email_error_message)
- expect(page).to have_content(email_warning_message)
+ expect(page).not_to have_content(email_hint_message)
+ expect(page).not_to have_content(email_error_message)
+ expect(page).to have_content(email_warning_message)
- fill_in 'new_user_email', with: 'foo@gitlab.com'
- fill_in 'new_user_first_name', with: ''
+ fill_in 'new_user_email', with: 'foo@gitlab.com'
+ fill_in 'new_user_first_name', with: ''
- expect(page).not_to have_content(email_hint_message)
- expect(page).not_to have_content(email_error_message)
- expect(page).not_to have_content(email_warning_message)
- end
- end
-
- context 'when trial_email_validation flag disabled' do
- before do
- stub_feature_flags trial_email_validation: false
- end
-
- it 'does not show an error message' do
- visit path
- expect(page).to have_content(email_hint_message)
- expect(page).not_to have_content(email_error_message)
- expect(page).not_to have_content(email_warning_message)
-
- fill_in 'new_user_email', with: 'foo@'
-
- expect(page).to have_content(email_hint_message)
- expect(page).not_to have_content(email_error_message)
- expect(page).not_to have_content(email_warning_message)
- end
+ expect(page).not_to have_content(email_hint_message)
+ expect(page).not_to have_content(email_error_message)
+ expect(page).not_to have_content(email_warning_message)
end
end
diff --git a/spec/support/shared_examples/features/variable_list_pagination_shared_examples.rb b/spec/support/shared_examples/features/variable_list_pagination_shared_examples.rb
new file mode 100644
index 00000000000..0b0c9edcb42
--- /dev/null
+++ b/spec/support/shared_examples/features/variable_list_pagination_shared_examples.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'variable list pagination' do |variable_type|
+ first_page_count = 20
+
+ before do
+ first_page_count.times do |i|
+ case variable_type
+ when :ci_variable
+ create(variable_type, key: "test_key_#{i}", value: 'test_value', masked: true, project: project)
+ when :ci_group_variable
+ create(variable_type, key: "test_key_#{i}", value: 'test_value', masked: true, group: group)
+ else
+ create(variable_type, key: "test_key_#{i}", value: 'test_value', masked: true)
+ end
+ end
+
+ visit page_path
+ wait_for_requests
+ end
+
+ it 'can navigate between pages' do
+ page.within('[data-testid="ci-variable-table"]') do
+ expect(page.all('.js-ci-variable-row').length).to be(first_page_count)
+ end
+
+ click_button 'Next'
+ wait_for_requests
+
+ page.within('[data-testid="ci-variable-table"]') do
+ expect(page.all('.js-ci-variable-row').length).to be(1)
+ end
+
+ click_button 'Previous'
+ wait_for_requests
+
+ page.within('[data-testid="ci-variable-table"]') do
+ expect(page.all('.js-ci-variable-row').length).to be(first_page_count)
+ end
+ end
+
+ it 'sorts variables alphabetically in ASC and DESC order' do
+ page.within('[data-testid="ci-variable-table"]') do
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq(variable.key)
+ expect(find('.js-ci-variable-row:nth-child(20) td[data-label="Key"]').text).to eq('test_key_8')
+ end
+
+ click_button 'Next'
+ wait_for_requests
+
+ page.within('[data-testid="ci-variable-table"]') do
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('test_key_9')
+ end
+
+ page.within('[data-testid="ci-variable-table"]') do
+ find('.b-table-sort-icon-left').click
+ end
+
+ wait_for_requests
+
+ page.within('[data-testid="ci-variable-table"]') do
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('test_key_9')
+ expect(find('.js-ci-variable-row:nth-child(20) td[data-label="Key"]').text).to eq('test_key_0')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/variable_list_shared_examples.rb b/spec/support/shared_examples/features/variable_list_shared_examples.rb
index f0b72cfaee3..1211c9d19e6 100644
--- a/spec/support/shared_examples/features/variable_list_shared_examples.rb
+++ b/spec/support/shared_examples/features/variable_list_shared_examples.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples 'variable list' do |is_admin|
+RSpec.shared_examples 'variable list' do
it 'shows a list of variables' do
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq(variable.key)
@@ -256,14 +256,6 @@ RSpec.shared_examples 'variable list' do |is_admin|
expect(find('[data-testid="ci-variable-protected-checkbox"]')).to be_checked
end
end
-
- it 'shows a message regarding the changed default' do
- if is_admin
- expect(page).to have_content 'Environment variables on this GitLab instance are configured to be protected by default'
- else
- expect(page).to have_content 'Environment variables are configured by your administrator to be protected by default'
- end
- end
end
context 'application setting is false' do
diff --git a/spec/support/shared_examples/features/wiki/file_attachments_shared_examples.rb b/spec/support/shared_examples/features/wiki/file_attachments_shared_examples.rb
index 7a3b94ad81d..6451c531aec 100644
--- a/spec/support/shared_examples/features/wiki/file_attachments_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/file_attachments_shared_examples.rb
@@ -62,7 +62,7 @@ RSpec.shared_examples 'wiki file attachments' do
attach_with_dropzone(true)
wait_for_requests
- find('.js-md-preview-button').click
+ click_button("Preview")
file_path = page.find('input[name="files[]"]', visible: :hidden).value
link = page.find('a.no-attachment-icon')['href']
img_link = page.find('a.no-attachment-icon img')['src']
diff --git a/spec/support/shared_examples/features/wiki/user_previews_wiki_changes_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_previews_wiki_changes_shared_examples.rb
index 3e285bb8ad7..ca68df9a89b 100644
--- a/spec/support/shared_examples/features/wiki/user_previews_wiki_changes_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_previews_wiki_changes_shared_examples.rb
@@ -78,7 +78,7 @@ RSpec.shared_examples 'User previews wiki changes' do
it_behaves_like 'relative links' do
before do
- click_on 'Preview'
+ click_button("Preview")
end
let(:element) { preview }
@@ -88,7 +88,7 @@ RSpec.shared_examples 'User previews wiki changes' do
# using two `\n` ensures we're sublist to it's own line due
# to list auto-continue
fill_in :wiki_content, with: "1. one\n\n - sublist\n"
- click_on "Preview"
+ click_button("Preview")
# the above generates two separate lists (not embedded) in CommonMark
expect(preview).to have_content("sublist")
@@ -102,7 +102,7 @@ RSpec.shared_examples 'User previews wiki changes' do
[[also_do_not_linkify]]
```
HEREDOC
- click_on "Preview"
+ click_button("Preview")
expect(preview).to have_content("do_not_linkify")
expect(preview).to have_content('[[do_not_linkify]]')
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 0334187e4b1..c1e4185e058 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
@@ -150,6 +150,7 @@ RSpec.shared_examples 'User updates wiki page' do
end
it_behaves_like 'edits content using the content editor'
+ it_behaves_like 'inserts diagrams.net diagram using the content editor'
it_behaves_like 'autocompletes items'
end
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 a7c32932ba7..767caffd417 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
@@ -9,9 +9,11 @@ RSpec.shared_examples 'User views a wiki page' do
let(:path) { 'image.png' }
let(:wiki_page) do
- create(:wiki_page,
- wiki: wiki,
- title: 'home', content: "Look at this [image](#{path})\n\n ![alt text](#{path})")
+ create(
+ :wiki_page,
+ wiki: wiki,
+ title: 'home', content: "Look at this [image](#{path})\n\n ![alt text](#{path})"
+ )
end
before do
diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_sidebar_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_sidebar_shared_examples.rb
index 639eb3f2b99..21c7e2b6c75 100644
--- a/spec/support/shared_examples/features/wiki/user_views_wiki_sidebar_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_views_wiki_sidebar_shared_examples.rb
@@ -84,6 +84,44 @@ RSpec.shared_examples 'User views wiki sidebar' do
expect(page).not_to have_link('View All Pages')
end
+ it 'shows all collapse buttons in the sidebar' do
+ visit wiki_path(wiki)
+
+ within('.right-sidebar') do
+ expect(page.all("[data-testid='chevron-down-icon']").size).to eq(3)
+ end
+ end
+
+ it 'collapses/expands children when click collapse/expand button in the sidebar', :js do
+ visit wiki_path(wiki)
+
+ within('.right-sidebar') do
+ first("[data-testid='chevron-down-icon']").click
+ (11..15).each { |i| expect(page).not_to have_content("my page #{i}") }
+ expect(page.all("[data-testid='chevron-down-icon']").size).to eq(1)
+ expect(page.all("[data-testid='chevron-right-icon']").size).to eq(1)
+
+ first("[data-testid='chevron-right-icon']").click
+ (11..15).each { |i| expect(page).to have_content("my page #{i}") }
+ expect(page.all("[data-testid='chevron-down-icon']").size).to eq(3)
+ expect(page.all("[data-testid='chevron-right-icon']").size).to eq(0)
+ end
+ end
+
+ it 'shows create child page button when hover to the page title in the sidebar', :js do
+ visit wiki_path(wiki)
+
+ within('.right-sidebar') do
+ first_wiki_list = first("[data-testid='wiki-list']")
+ wiki_link = first("[data-testid='wiki-list'] a:last-of-type")['href']
+
+ first_wiki_list.hover
+ wiki_new_page_link = first("[data-testid='wiki-list'] a")['href']
+
+ expect(wiki_new_page_link).to eq "#{wiki_link}/%7Bnew_page_title%7D"
+ end
+ end
+
context 'when there are more than 15 existing pages' do
before do
create(:wiki_page, wiki: wiki, title: 'my page 16')
diff --git a/spec/support/shared_examples/features/work_items_shared_examples.rb b/spec/support/shared_examples/features/work_items_shared_examples.rb
index 4f3d957ad71..526a56e7dab 100644
--- a/spec/support/shared_examples/features/work_items_shared_examples.rb
+++ b/spec/support/shared_examples/features/work_items_shared_examples.rb
@@ -1,5 +1,20 @@
# frozen_string_literal: true
+RSpec.shared_examples 'work items title' do
+ let(:title_selector) { '[data-testid="work-item-title"]' }
+
+ it 'successfully shows and changes the title of the work item' do
+ expect(work_item.reload.title).to eq work_item.title
+
+ find(title_selector).set("Work item title")
+ find(title_selector).native.send_keys(:return)
+
+ wait_for_requests
+
+ expect(work_item.reload.title).to eq 'Work item title'
+ end
+end
+
RSpec.shared_examples 'work items status' do
let(:state_selector) { '[data-testid="work-item-state-select"]' }
@@ -15,18 +30,110 @@ RSpec.shared_examples 'work items status' do
end
end
-RSpec.shared_examples 'work items comments' do
+RSpec.shared_examples 'work items comments' do |type|
let(:form_selector) { '[data-testid="work-item-add-comment"]' }
+ let(:textarea_selector) { '[data-testid="work-item-add-comment"] #work-item-add-or-edit-comment' }
+ let(:is_mac) { page.evaluate_script('navigator.platform').include?('Mac') }
+ let(:modifier_key) { is_mac ? :command : :control }
+ let(:comment) { 'Test comment' }
+
+ def set_comment
+ find(form_selector).fill_in(with: comment)
+ end
it 'successfully creates and shows comments' do
- click_button 'Add a comment'
+ set_comment
- find(form_selector).fill_in(with: "Test comment")
click_button "Comment"
wait_for_requests
- expect(page).to have_content "Test comment"
+ page.within(".main-notes-list") do
+ expect(page).to have_content comment
+ end
+ end
+
+ context 'for work item note actions signed in user with developer role' do
+ it 'shows work item note actions' do
+ set_comment
+
+ click_button "Comment"
+
+ wait_for_requests
+
+ page.within(".main-notes-list") do
+ expect(page).to have_selector('[data-testid="work-item-note-actions"]')
+
+ find('[data-testid="work-item-note-actions"]', match: :first).click
+
+ expect(page).to have_selector('[data-testid="copy-link-action"]')
+ expect(page).not_to have_selector('[data-testid="assign-note-action"]')
+ end
+ end
+ end
+
+ it 'successfully posts comments using shortcut and checks if textarea is blank when reinitiated' do
+ set_comment
+
+ send_keys([modifier_key, :enter])
+
+ wait_for_requests
+
+ page.within(".main-notes-list") do
+ expect(page).to have_content comment
+ end
+
+ expect(find(textarea_selector)).to have_content ""
+ end
+
+ context 'when using quick actions' do
+ it 'autocompletes quick actions common to all work item types', :aggregate_failures do
+ click_reply_and_enter_slash
+
+ page.within('#at-view-commands') do
+ expect(page).to have_text("/title")
+ expect(page).to have_text("/shrug")
+ expect(page).to have_text("/tableflip")
+ expect(page).to have_text("/close")
+ expect(page).to have_text("/cc")
+ end
+ end
+
+ context 'when a widget is enabled' do
+ before do
+ WorkItems::Type.default_by_type(type).widget_definitions
+ .find_by_widget_type(:assignees).update!(disabled: false)
+ end
+
+ it 'autocompletes quick action for the enabled widget' do
+ click_reply_and_enter_slash
+
+ page.within('#at-view-commands') do
+ expect(page).to have_text("/assign")
+ end
+ end
+ end
+
+ context 'when a widget is disabled' do
+ before do
+ WorkItems::Type.default_by_type(type).widget_definitions
+ .find_by_widget_type(:assignees).update!(disabled: true)
+ end
+
+ it 'does not autocomplete quick action for the disabled widget' do
+ click_reply_and_enter_slash
+
+ page.within('#at-view-commands') do
+ expect(page).not_to have_text("/assign")
+ end
+ end
+ end
+
+ def click_reply_and_enter_slash
+ find(form_selector).fill_in(with: "/")
+
+ wait_for_all_requests
+ end
end
end
@@ -39,7 +146,6 @@ RSpec.shared_examples 'work items assignees' do
# submit and simulate blur to save
send_keys(:enter)
find("body").click
-
wait_for_requests
expect(work_item.assignees).to include(user)
@@ -47,6 +153,8 @@ RSpec.shared_examples 'work items assignees' do
end
RSpec.shared_examples 'work items labels' do
+ let(:label_title_selector) { '[data-testid="labels-title"]' }
+
it 'successfully assigns a label' do
label = create(:label, project: work_item.project, title: "testing-label")
@@ -55,8 +163,7 @@ RSpec.shared_examples 'work items labels' do
# submit and simulate blur to save
send_keys(:enter)
- find("body").click
-
+ find(label_title_selector).click
wait_for_requests
expect(work_item.labels).to include(label)
@@ -83,7 +190,7 @@ RSpec.shared_examples 'work items description' do
wait_for_requests
- page.within('.atwho-container') do
+ page.within('#at-view-commands') do
expect(page).to have_text("title")
expect(page).to have_text("shrug")
expect(page).to have_text("tableflip")
@@ -125,7 +232,7 @@ RSpec.shared_examples 'work items description' do
end
RSpec.shared_examples 'work items invite members' do
- include Spec::Support::Helpers::Features::InviteMembersModalHelper
+ include Features::InviteMembersModalHelpers
it 'successfully assigns the current user by searching' do
# The button is only when the mouse is over the input
@@ -139,3 +246,143 @@ RSpec.shared_examples 'work items invite members' do
end
end
end
+
+RSpec.shared_examples 'work items milestone' do
+ def set_milestone(milestone_dropdown, milestone_text)
+ milestone_dropdown.click
+
+ find('[data-testid="work-item-milestone-dropdown"] .gl-form-input', visible: true).send_keys "\"#{milestone_text}\""
+ wait_for_requests
+
+ click_button(milestone_text)
+ wait_for_requests
+ end
+
+ let(:milestone_dropdown_selector) { '[data-testid="work-item-milestone-dropdown"]' }
+
+ it 'searches and sets or removes milestone for the work item' do
+ set_milestone(find(milestone_dropdown_selector), milestone.title)
+
+ expect(page.find(milestone_dropdown_selector)).to have_text(milestone.title)
+
+ set_milestone(find(milestone_dropdown_selector), 'No milestone')
+
+ expect(page.find(milestone_dropdown_selector)).to have_text('Add to milestone')
+ end
+end
+
+RSpec.shared_examples 'work items comment actions for guest users' do
+ context 'for guest user' do
+ it 'hides other actions other than copy link' do
+ page.within(".main-notes-list") do
+ expect(page).to have_selector('[data-testid="work-item-note-actions"]')
+
+ find('[data-testid="work-item-note-actions"]', match: :first).click
+
+ expect(page).to have_selector('[data-testid="copy-link-action"]')
+ expect(page).not_to have_selector('[data-testid="assign-note-action"]')
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'work items notifications' do
+ let(:actions_dropdown_selector) { '[data-testid="work-item-actions-dropdown"]' }
+ let(:notifications_toggle_selector) { '[data-testid="notifications-toggle-action"] > button' }
+
+ it 'displays toast when notification is toggled' do
+ find(actions_dropdown_selector).click
+
+ page.within('[data-testid="notifications-toggle-form"]') do
+ expect(page).not_to have_css(".is-checked")
+
+ find(notifications_toggle_selector).click
+ wait_for_requests
+
+ expect(page).to have_css(".is-checked")
+ end
+
+ page.within('.gl-toast') do
+ expect(find('.toast-body')).to have_content(_('Notifications turned on.'))
+ end
+ end
+end
+
+RSpec.shared_examples 'work items todos' do
+ let(:todos_action_selector) { '[data-testid="work-item-todos-action"]' }
+ let(:todos_icon_selector) { '[data-testid="work-item-todos-icon"]' }
+ let(:header_section_selector) { '[data-testid="work-item-body"]' }
+
+ def toggle_todo_action
+ find(todos_action_selector).click
+ wait_for_requests
+ end
+
+ it 'adds item to the list' do
+ page.within(header_section_selector) do
+ expect(find(todos_action_selector)['aria-label']).to eq('Add a to do')
+
+ toggle_todo_action
+
+ expect(find(todos_action_selector)['aria-label']).to eq('Mark as done')
+ end
+
+ page.within ".header-content span[aria-label='#{_('Todos count')}']" do
+ expect(page).to have_content '1'
+ end
+ end
+
+ it 'marks a todo as done' do
+ page.within(header_section_selector) do
+ toggle_todo_action
+ toggle_todo_action
+ end
+
+ expect(find(todos_action_selector)['aria-label']).to eq('Add a to do')
+ expect(page).to have_selector(".header-content span[aria-label='#{_('Todos count')}']", visible: :hidden)
+ end
+end
+
+RSpec.shared_examples 'work items award emoji' do
+ let(:award_section_selector) { '[data-testid="work-item-award-list"]' }
+ let(:award_action_selector) { '[data-testid="award-button"]' }
+ let(:selected_award_action_selector) { '[data-testid="award-button"].selected' }
+ let(:emoji_picker_action_selector) { '[data-testid="emoji-picker"]' }
+ let(:basketball_emoji_selector) { 'gl-emoji[data-name="basketball"]' }
+
+ def select_emoji
+ first(award_action_selector).click
+
+ wait_for_requests
+ end
+
+ it 'adds award to the work item' do
+ within(award_section_selector) do
+ select_emoji
+
+ expect(page).to have_selector(selected_award_action_selector)
+ expect(first(award_action_selector)).to have_content '1'
+ end
+ end
+
+ it 'removes award from work item' do
+ within(award_section_selector) do
+ select_emoji
+
+ expect(first(award_action_selector)).to have_content '1'
+
+ select_emoji
+
+ expect(first(award_action_selector)).to have_content '0'
+ end
+ end
+
+ it 'add custom award to the work item' do
+ within(award_section_selector) do
+ find(emoji_picker_action_selector).click
+ find(basketball_emoji_selector).click
+
+ expect(page).to have_selector(basketball_emoji_selector)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/finders/issues_finder_shared_examples.rb b/spec/support/shared_examples/finders/issues_finder_shared_examples.rb
index 93f9e42241b..67fed00b5ca 100644
--- a/spec/support/shared_examples/finders/issues_finder_shared_examples.rb
+++ b/spec/support/shared_examples/finders/issues_finder_shared_examples.rb
@@ -161,10 +161,12 @@ RSpec.shared_examples 'issues or work items finder' do |factory, execute_context
let_it_be(:another_release) { create(:release, project: project1, tag: 'v2.0.0') }
let_it_be(:another_milestone) { create(:milestone, project: project1, releases: [another_release]) }
let_it_be(:another_item) do
- create(factory,
- project: project1,
- milestone: another_milestone,
- title: 'another item')
+ create(
+ factory,
+ project: project1,
+ milestone: another_milestone,
+ title: 'another item'
+ )
end
let(:params) { { not: { release_tag: release.tag, project_id: project1.id } } }
@@ -421,8 +423,11 @@ RSpec.shared_examples 'issues or work items finder' do |factory, execute_context
let!(:created_items) do
milestones.map do |milestone|
- create(factory, project: milestone.project || project_in_group,
- milestone: milestone, author: user, assignees: [user])
+ create(
+ factory,
+ project: milestone.project || project_in_group,
+ milestone: milestone, author: user, assignees: [user]
+ )
end
end
@@ -593,7 +598,7 @@ RSpec.shared_examples 'issues or work items finder' do |factory, execute_context
end
context 'filtering by no label' do
- let(:params) { { label_name: described_class::Params::FILTER_NONE } }
+ let(:params) { { label_name: IssuableFinder::Params::FILTER_NONE } }
it 'returns items with no labels' do
expect(items).to contain_exactly(item1, item4, item5)
@@ -601,7 +606,7 @@ RSpec.shared_examples 'issues or work items finder' do |factory, execute_context
end
context 'filtering by any label' do
- let(:params) { { label_name: described_class::Params::FILTER_ANY } }
+ let(:params) { { label_name: IssuableFinder::Params::FILTER_ANY } }
it 'returns items that have one or more label' do
create_list(:label_link, 2, label: create(:label, project: project2), target: item3)
@@ -909,9 +914,9 @@ RSpec.shared_examples 'issues or work items finder' do |factory, execute_context
end
context 'filtering by item type' do
- let_it_be(:incident_item) { create(factory, issue_type: :incident, project: project1) }
- let_it_be(:objective) { create(factory, issue_type: :objective, project: project1) }
- let_it_be(:key_result) { create(factory, issue_type: :key_result, project: project1) }
+ let_it_be(:incident_item) { create(factory, :incident, project: project1) }
+ let_it_be(:objective) { create(factory, :objective, project: project1) }
+ let_it_be(:key_result) { create(factory, :key_result, project: project1) }
context 'no type given' do
let(:params) { { issue_types: [] } }
@@ -983,9 +988,9 @@ RSpec.shared_examples 'issues or work items finder' do |factory, execute_context
let_it_be(:root_group) { create(:group) }
let_it_be(:group) { create(:group, parent: root_group) }
let_it_be(:project_crm) { create(:project, :public, group: group) }
- let_it_be(:organization) { create(:organization, group: root_group) }
- let_it_be(:contact1) { create(:contact, group: root_group, organization: organization) }
- let_it_be(:contact2) { create(:contact, group: root_group, organization: organization) }
+ let_it_be(:crm_organization) { create(:crm_organization, group: root_group) }
+ let_it_be(:contact1) { create(:contact, group: root_group, organization: crm_organization) }
+ let_it_be(:contact2) { create(:contact, group: root_group, organization: crm_organization) }
let_it_be(:contact1_item1) { create(factory, project: project_crm) }
let_it_be(:contact1_item2) { create(factory, project: project_crm) }
@@ -1023,10 +1028,10 @@ RSpec.shared_examples 'issues or work items finder' do |factory, execute_context
end
context 'filtering by crm organization' do
- let(:params) { { project_id: project_crm.id, crm_organization_id: organization.id } }
+ let(:params) { { project_id: project_crm.id, crm_organization_id: crm_organization.id } }
context 'when the user can read crm organization' do
- it 'returns for that organization' do
+ it 'returns for that crm organization' do
root_group.add_reporter(user)
expect(items).to contain_exactly(contact1_item1, contact1_item2, contact2_item1)
@@ -1034,7 +1039,7 @@ RSpec.shared_examples 'issues or work items finder' do |factory, execute_context
end
context 'when the user can not read crm organization' do
- it 'does not filter by organization' do
+ it 'does not filter by crm organization' do
expect(items).to match_array(all_project_issues)
end
end
diff --git a/spec/support/shared_examples/graphql/members_shared_examples.rb b/spec/support/shared_examples/graphql/members_shared_examples.rb
index 5cba8baa829..5ab17f5a49d 100644
--- a/spec/support/shared_examples/graphql/members_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/members_shared_examples.rb
@@ -39,8 +39,10 @@ RSpec.shared_examples 'querying members with a group' do
let(:base_args) { { relations: described_class.arguments['relations'].default_value } }
subject do
- resolve(described_class, obj: resource, args: base_args.merge(args),
- ctx: { current_user: user_4 }, arg_style: :internal)
+ resolve(
+ described_class, obj: resource, args: base_args.merge(args),
+ ctx: { current_user: user_4 }, arg_style: :internal
+ )
end
describe '#resolve' do
@@ -83,8 +85,10 @@ RSpec.shared_examples 'querying members with a group' do
let_it_be(:other_user) { create(:user) }
subject do
- resolve(described_class, obj: resource, args: base_args.merge(args),
- ctx: { current_user: other_user }, arg_style: :internal)
+ resolve(
+ described_class, obj: resource, args: base_args.merge(args),
+ ctx: { current_user: other_user }, arg_style: :internal
+ )
end
it 'generates an error' do
diff --git a/spec/support/shared_examples/graphql/mutation_shared_examples.rb b/spec/support/shared_examples/graphql/mutation_shared_examples.rb
index dc590e23ace..808fb097f29 100644
--- a/spec/support/shared_examples/graphql/mutation_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/mutation_shared_examples.rb
@@ -15,7 +15,7 @@ RSpec.shared_examples 'a mutation that returns top-level errors' do |errors: []|
expect(graphql_errors).to be_present
- error_messages = graphql_errors.map { |e| e['message'] }
+ error_messages = graphql_errors.pluck('message')
expect(error_messages).to match_errors
end
@@ -25,7 +25,7 @@ end
# the mutation.
RSpec.shared_examples 'a mutation that returns a top-level access error' do
include_examples 'a mutation that returns top-level errors',
- errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
+ errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
end
RSpec.shared_examples 'an invalid argument to the mutation' do |argument_name:|
diff --git a/spec/support/shared_examples/graphql/mutations/members/bulk_update_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/members/bulk_update_shared_examples.rb
new file mode 100644
index 00000000000..e885b5d283e
--- /dev/null
+++ b/spec/support/shared_examples/graphql/mutations/members/bulk_update_shared_examples.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'members bulk update mutation' do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:user1) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+ let_it_be(:member1) { create(member_type, source: source, user: user1) }
+ let_it_be(:member2) { create(member_type, source: source, user: user2) }
+
+ let(:extra_params) { { expires_at: 10.days.from_now } }
+ let(:input_params) { input.merge(extra_params) }
+ let(:mutation) { graphql_mutation(mutation_name, input_params) }
+ let(:mutation_response) { graphql_mutation_response(mutation_name) }
+
+ let(:input) do
+ {
+ source_id_key => source.to_global_id.to_s,
+ 'user_ids' => [user1.to_global_id.to_s, user2.to_global_id.to_s],
+ 'access_level' => 'GUEST'
+ }
+ end
+
+ context 'when user is not logged-in' do
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user is not an owner' do
+ before do
+ source.add_developer(current_user)
+ end
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user is an owner' do
+ before do
+ source.add_owner(current_user)
+ end
+
+ shared_examples 'updates the user access role' do
+ specify do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ new_access_levels = mutation_response[response_member_field].map do |member|
+ member['accessLevel']['integerValue']
+ end
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['errors']).to be_empty
+ expect(new_access_levels).to all(be Gitlab::Access::GUEST)
+ end
+ end
+
+ it_behaves_like 'updates the user access role'
+
+ context 'when inherited members are passed' do
+ let(:input) do
+ {
+ source_id_key => source.to_global_id.to_s,
+ 'user_ids' => [user1.to_global_id.to_s, user2.to_global_id.to_s, parent_group_member.user.to_global_id.to_s],
+ 'access_level' => 'GUEST'
+ }
+ end
+
+ it 'does not update the members' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ error = Mutations::Members::BulkUpdateBase::INVALID_MEMBERS_ERROR
+ expect(json_response['errors'].first['message']).to include(error)
+ end
+ end
+
+ context 'when members count is more than the allowed limit' do
+ let(:max_members_update_limit) { 1 }
+
+ before do
+ stub_const('Mutations::Members::BulkUpdateBase::MAX_MEMBERS_UPDATE_LIMIT', max_members_update_limit)
+ end
+
+ it 'does not update the members' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ error = Mutations::Members::BulkUpdateBase::MAX_MEMBERS_UPDATE_ERROR
+ expect(json_response['errors'].first['message']).to include(error)
+ end
+ end
+
+ context 'when the update service raises access denied error' do
+ before do
+ allow_next_instance_of(Members::UpdateService) do |instance|
+ allow(instance).to receive(:execute).and_raise(Gitlab::Access::AccessDeniedError)
+ end
+ end
+
+ it 'does not update the members' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(mutation_response[response_member_field]).to be_nil
+ expect(mutation_response['errors'])
+ .to contain_exactly("Unable to update members, please check user permissions.")
+ end
+ end
+
+ context 'when the update service returns an error message' do
+ before do
+ allow_next_instance_of(Members::UpdateService) do |instance|
+ error_result = {
+ message: 'Expires at cannot be a date in the past',
+ status: :error,
+ members: [member1]
+ }
+ allow(instance).to receive(:execute).and_return(error_result)
+ end
+ end
+
+ it 'will pass through the error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(mutation_response[response_member_field].first['id']).to eq(member1.to_global_id.to_s)
+ expect(mutation_response['errors']).to contain_exactly('Expires at cannot be a date in the past')
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/graphql/mutations/set_assignees_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/set_assignees_shared_examples.rb
index 022e2308517..3b9dadf2e80 100644
--- a/spec/support/shared_examples/graphql/mutations/set_assignees_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/mutations/set_assignees_shared_examples.rb
@@ -16,10 +16,12 @@ RSpec.shared_examples 'an assignable resource' do
let(:mode) { described_class.arguments['operationMode'].default_value }
subject do
- mutation.resolve(project_path: resource.project.full_path,
- iid: resource.iid,
- operation_mode: mode,
- assignee_usernames: assignee_usernames)
+ mutation.resolve(
+ project_path: resource.project.full_path,
+ iid: resource.iid,
+ operation_mode: mode,
+ assignee_usernames: assignee_usernames
+ )
end
it 'raises an error if the resource is not accessible to the user' do
diff --git a/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb b/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb
index 0d2e9f6ec8c..99d122e8254 100644
--- a/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb
@@ -4,9 +4,11 @@ RSpec.shared_context 'exposing regular notes on a noteable in GraphQL' do
include GraphqlHelpers
let(:note) do
- create(:note,
- noteable: noteable,
- project: (noteable.project if noteable.respond_to?(:project)))
+ create(
+ :note,
+ noteable: noteable,
+ project: (noteable.project if noteable.respond_to?(:project))
+ )
end
let(:user) { note.author }
@@ -46,7 +48,7 @@ RSpec.shared_context 'exposing regular notes on a noteable in GraphQL' do
discussions {
edges {
node {
- #{all_graphql_fields_for('Discussion', max_depth: 4)}
+ #{all_graphql_fields_for('Discussion', max_depth: 4, excluded: ['productAnalyticsState'])}
}
}
}
diff --git a/spec/support/shared_examples/graphql/notes_quick_actions_for_work_items_shared_examples.rb b/spec/support/shared_examples/graphql/notes_quick_actions_for_work_items_shared_examples.rb
new file mode 100644
index 00000000000..52908c5b6df
--- /dev/null
+++ b/spec/support/shared_examples/graphql/notes_quick_actions_for_work_items_shared_examples.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'work item supports assignee widget updates via quick actions' do
+ let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
+
+ context 'when assigning a user' do
+ let(:body) { "/assign @#{developer.username}" }
+
+ it 'updates the work item assignee' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ noteable.reload
+ end.to change { noteable.assignee_ids }.from([]).to([developer.id])
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+ end
+
+ context 'when unassigning a user' do
+ let(:body) { "/unassign @#{developer.username}" }
+
+ before do
+ noteable.update!(assignee_ids: [developer.id])
+ end
+
+ it 'updates the work item assignee' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ noteable.reload
+ end.to change { noteable.assignee_ids }.from([developer.id]).to([])
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+ end
+end
+
+RSpec.shared_examples 'work item does not support assignee widget updates via quick actions' do
+ let(:developer) { create(:user).tap { |user| project.add_developer(user) } }
+ let(:body) { "Updating assignee.\n/assign @#{developer.username}" }
+
+ before do
+ WorkItems::Type.default_by_type(:task).widget_definitions
+ .find_by_widget_type(:assignees).update!(disabled: true)
+ end
+
+ it 'ignores the quick action' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ noteable.reload
+ end.not_to change { noteable.assignee_ids }
+ end
+end
+
+RSpec.shared_examples 'work item supports labels widget updates via quick actions' do
+ shared_examples 'work item labels are updated' do
+ it do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ noteable.reload
+ end.to change { noteable.labels.count }.to(expected_labels.count)
+
+ expect(noteable.labels).to match_array(expected_labels)
+ end
+ end
+
+ let_it_be(:existing_label) { create(:label, project: project) }
+ let_it_be(:label1) { create(:label, project: project) }
+ let_it_be(:label2) { create(:label, project: project) }
+
+ let(:add_label_ids) { [] }
+ let(:remove_label_ids) { [] }
+
+ before_all do
+ noteable.update!(labels: [existing_label])
+ end
+
+ context 'when only removing labels' do
+ let(:remove_label_ids) { [existing_label.to_gid.to_s] }
+ let(:expected_labels) { [] }
+ let(:body) { "/remove_label ~\"#{existing_label.name}\"" }
+
+ it_behaves_like 'work item labels are updated'
+ end
+
+ context 'when only adding labels' do
+ let(:add_label_ids) { [label1.to_gid.to_s, label2.to_gid.to_s] }
+ let(:expected_labels) { [label1, label2, existing_label] }
+ let(:body) { "/labels ~\"#{label1.name}\" ~\"#{label2.name}\"" }
+
+ it_behaves_like 'work item labels are updated'
+ end
+
+ context 'when adding and removing labels' do
+ let(:remove_label_ids) { [existing_label.to_gid.to_s] }
+ let(:add_label_ids) { [label1.to_gid.to_s, label2.to_gid.to_s] }
+ let(:expected_labels) { [label1, label2] }
+ let(:body) { "/label ~\"#{label1.name}\" ~\"#{label2.name}\"\n/remove_label ~\"#{existing_label.name}\"" }
+
+ it_behaves_like 'work item labels are updated'
+ end
+end
+
+RSpec.shared_examples 'work item does not support labels widget updates via quick actions' do
+ let(:label1) { create(:label, project: project) }
+ let(:body) { "Updating labels.\n/labels ~\"#{label1.name}\"" }
+
+ before do
+ WorkItems::Type.default_by_type(:task).widget_definitions
+ .find_by_widget_type(:labels).update!(disabled: true)
+ end
+
+ it 'ignores the quick action' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ noteable.reload
+ end.not_to change { noteable.labels.count }
+
+ expect(noteable.labels).to be_empty
+ end
+end
+
+RSpec.shared_examples 'work item supports start and due date widget updates via quick actions' do
+ let(:due_date) { Date.today }
+ let(:body) { "/remove_due_date" }
+
+ before do
+ noteable.update!(due_date: due_date)
+ end
+
+ it 'updates start and due date' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ noteable.reload
+ end.to not_change(noteable, :start_date).and(
+ change { noteable.due_date }.from(due_date).to(nil)
+ )
+ end
+end
+
+RSpec.shared_examples 'work item does not support start and due date widget updates via quick actions' do
+ let(:body) { "Updating due date.\n/due today" }
+
+ before do
+ WorkItems::Type.default_by_type(:task).widget_definitions
+ .find_by_widget_type(:start_and_due_date).update!(disabled: true)
+ end
+
+ it 'ignores the quick action' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ noteable.reload
+ end.not_to change { noteable.due_date }
+ end
+end
+
+RSpec.shared_examples 'work item supports type change via quick actions' do
+ let_it_be(:assignee) { create(:user) }
+ let_it_be(:task_type) { WorkItems::Type.default_by_type(:task) }
+
+ let(:body) { "Updating type.\n/type Issue" }
+
+ before do
+ noteable.update!(work_item_type: task_type, issue_type: task_type.base_type)
+ end
+
+ it 'updates type' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ noteable.reload
+ end.to change { noteable.work_item_type.base_type }.from('task').to('issue')
+
+ expect(response).to have_gitlab_http_status(:success)
+ end
+
+ context 'when quick command for unsupported widget is present' do
+ let(:body) { "\n/type Issue\n/assign @#{assignee.username}" }
+
+ before do
+ WorkItems::Type.default_by_type(:issue).widget_definitions
+ .find_by_widget_type(:assignees).update!(disabled: true)
+ end
+
+ it 'updates only type' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ noteable.reload
+ end.to change { noteable.work_item_type.base_type }.from('task').to('issue')
+ .and change { noteable.assignees }.to([])
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['errors'])
+ .to include("Commands only Type changed successfully. Assigned @#{assignee.username}.")
+ end
+ end
+end
diff --git a/spec/support/shared_examples/graphql/resolvers/data_transfer_resolver_shared_examples.rb b/spec/support/shared_examples/graphql/resolvers/data_transfer_resolver_shared_examples.rb
new file mode 100644
index 00000000000..8551bd052ce
--- /dev/null
+++ b/spec/support/shared_examples/graphql/resolvers/data_transfer_resolver_shared_examples.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'Data transfer resolver' do
+ it 'returns mock data' do |_query_object|
+ mocked_data = ['mocked_data']
+
+ allow_next_instance_of(DataTransfer::MockedTransferFinder) do |instance|
+ allow(instance).to receive(:execute).and_return(mocked_data)
+ end
+
+ expect(resolve_egress[:egress_nodes]).to eq(mocked_data)
+ end
+
+ context 'when data_transfer_monitoring is disabled' do
+ before do
+ stub_feature_flags(data_transfer_monitoring: false)
+ end
+
+ it 'returns empty result' do
+ expect(resolve_egress).to eq(egress_nodes: [])
+ end
+ end
+end
diff --git a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
index 4dc2ce61c4d..b346f35bdc9 100644
--- a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
@@ -65,7 +65,7 @@ RSpec.shared_examples 'Gitlab-style deprecations' do
deprecable = subject(deprecated: { milestone: '1.10', reason: :alpha })
expect(deprecable.deprecation_reason).to eq(
- 'This feature is in Alpha. It can be changed or removed at any time. Introduced in 1.10.'
+ 'This feature is an Experiment. It can be changed or removed at any time. Introduced in 1.10.'
)
end
@@ -73,7 +73,7 @@ RSpec.shared_examples 'Gitlab-style deprecations' do
deprecable = subject(alpha: { milestone: '1.10' })
expect(deprecable.deprecation_reason).to eq(
- 'This feature is in Alpha. It can be changed or removed at any time. Introduced in 1.10.'
+ 'This feature is an Experiment. It can be changed or removed at any time. Introduced in 1.10.'
)
end
@@ -82,7 +82,7 @@ RSpec.shared_examples 'Gitlab-style deprecations' do
subject(alpha: { milestone: '1.10' }, deprecated: { milestone: '1.10', reason: 'my reason' } )
end.to raise_error(
ArgumentError,
- eq("`alpha` and `deprecated` arguments cannot be passed at the same time")
+ eq("`experiment` and `deprecated` arguments cannot be passed at the same time")
)
end
diff --git a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb
index bb33a7559dc..3dffc2066ae 100644
--- a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb
@@ -42,8 +42,13 @@ RSpec.shared_examples "a user type with merge request interaction type" do
profileEnableGitpodPath
savedReplies
savedReply
+ user_achievements
]
+ # TODO: 'workspaces' needs to be included, but only when this spec is run in EE context, to account for the
+ # ee-only extension in ee/app/graphql/ee/types/user_interface.rb. Not sure how else to handle this.
+ expected_fields << 'workspaces' if Gitlab.ee?
+
expect(described_class).to have_graphql_fields(*expected_fields)
end
diff --git a/spec/support/shared_examples/helpers/callouts_for_web_hooks.rb b/spec/support/shared_examples/helpers/callouts_for_web_hooks.rb
new file mode 100644
index 00000000000..b3d3000aa06
--- /dev/null
+++ b/spec/support/shared_examples/helpers/callouts_for_web_hooks.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'CalloutsHelper#web_hook_disabled_dismissed shared examples' do
+ context 'when the web-hook failure callout has never been dismissed' do
+ it 'is false' do
+ expect(helper).not_to be_web_hook_disabled_dismissed(container)
+ end
+ end
+
+ context 'when the web-hook failure callout has been dismissed', :freeze_time, :clean_gitlab_redis_shared_state do
+ before do
+ create(factory,
+ feature_name: Users::CalloutsHelper::WEB_HOOK_DISABLED,
+ user: user,
+ dismissed_at: 1.week.ago,
+ container_key => container)
+ end
+
+ it 'is true' do
+ expect(helper).to be_web_hook_disabled_dismissed(container)
+ end
+
+ it 'is true when passed as a presenter' do
+ skip "Does not apply to #{container.class}" unless container.is_a?(Presentable)
+
+ expect(helper).to be_web_hook_disabled_dismissed(container.present)
+ end
+
+ context 'when there was an older failure' do
+ before do
+ Gitlab::Redis::SharedState.with { |r| r.set(key, 1.month.ago.iso8601) }
+ end
+
+ it 'is true' do
+ expect(helper).to be_web_hook_disabled_dismissed(container)
+ end
+ end
+
+ context 'when there has been a more recent failure' do
+ before do
+ Gitlab::Redis::SharedState.with { |r| r.set(key, 1.day.ago.iso8601) }
+ end
+
+ it 'is false' do
+ expect(helper).not_to be_web_hook_disabled_dismissed(container)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/integrations/integration_settings_form.rb b/spec/support/shared_examples/integrations/integration_settings_form.rb
index aeb4e0feb12..c43bdfa53ff 100644
--- a/spec/support/shared_examples/integrations/integration_settings_form.rb
+++ b/spec/support/shared_examples/integrations/integration_settings_form.rb
@@ -2,12 +2,16 @@
RSpec.shared_examples 'integration settings form' do
include IntegrationsHelper
+
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
# Note: these specs don't validate channel fields
# which are present on a few integrations
it 'displays all the integrations', feature_category: :integrations do
aggregate_failures do
integrations.each do |integration|
- stub_feature_flags(integration_slack_app_notifications: false)
navigate_to_integration(integration)
page.within('form.integration-settings-form') do
diff --git a/spec/support/shared_examples/lib/api/ai_workhorse_shared_examples.rb b/spec/support/shared_examples/lib/api/ai_workhorse_shared_examples.rb
new file mode 100644
index 00000000000..7ace223723c
--- /dev/null
+++ b/spec/support/shared_examples/lib/api/ai_workhorse_shared_examples.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'delegates AI request to Workhorse' do |provider_flag|
+ context "when #{provider_flag} is disabled" do
+ before do
+ stub_feature_flags(provider_flag => false)
+ end
+
+ it 'responds as not found' do
+ post api(url, current_user), params: input_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when ai_experimentation_api is disabled' do
+ before do
+ stub_feature_flags(ai_experimentation_api: false)
+ end
+
+ it 'responds as not found' do
+ post api(url, current_user), params: input_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ it 'responds with Workhorse send-url headers' do
+ post api(url, current_user), params: input_params
+
+ expect(response.body).to eq('""')
+ expect(response).to have_gitlab_http_status(:ok)
+
+ send_url_prefix, encoded_data = response.headers['Gitlab-Workhorse-Send-Data'].split(':')
+ data = Gitlab::Json.parse(Base64.urlsafe_decode64(encoded_data))
+
+ expect(send_url_prefix).to eq('send-url')
+ expect(data).to eq({
+ 'AllowRedirects' => false,
+ 'Method' => 'POST'
+ }.merge(expected_params))
+ end
+end
diff --git a/spec/support/shared_examples/lib/api/terraform_state_enabled_shared_examples.rb b/spec/support/shared_examples/lib/api/terraform_state_enabled_shared_examples.rb
new file mode 100644
index 00000000000..b88eade7db2
--- /dev/null
+++ b/spec/support/shared_examples/lib/api/terraform_state_enabled_shared_examples.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'it depends on value of the `terraform_state.enabled` config' do |params = {}|
+ let(:expected_success_status) { params[:success_status] || :ok }
+
+ context 'when terraform_state.enabled=false' do
+ before do
+ stub_config(terraform_state: { enabled: false })
+ end
+
+ it 'returns `forbidden` response' do
+ request
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when terraform_state.enabled=true' do
+ before do
+ stub_config(terraform_state: { enabled: true })
+ end
+
+ it 'returns a successful response' do
+ request
+
+ expect(response).to have_gitlab_http_status(expected_success_status)
+ end
+ end
+end
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
index d471a758f3e..c8d62205c1e 100644
--- a/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb
+++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb
@@ -1,14 +1,7 @@
# frozen_string_literal: true
RSpec.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 API so we have aggregated data
- ::Dora::DailyMetrics::RefreshWorker.new.perform(environment.id, Time.current.to_date.to_s) if Gitlab.ee?
- end
+ include CycleAnalyticsHelpers
describe "#deploys" do
subject { stage_summary.third }
diff --git a/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb
index bce889b454d..5740adb3f0e 100644
--- a/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb
@@ -68,3 +68,64 @@ RSpec.shared_examples_for 'LEFT JOIN-able value stream analytics event' do
end
end
end
+
+RSpec.shared_examples_for 'value stream analytics first assignment event methods' do
+ let_it_be(:model1) { create(model_factory) } # rubocop: disable Rails/SaveBang
+ let_it_be(:model2) { create(model_factory) } # rubocop: disable Rails/SaveBang
+
+ let_it_be(:assignment_event1) do
+ create(event_factory, action: :add, created_at: 3.years.ago, model_factory => model1)
+ end
+
+ let_it_be(:assignment_event2) do
+ create(event_factory, action: :add, created_at: 2.years.ago, model_factory => model1)
+ end
+
+ let_it_be(:unassignment_event1) do
+ create(event_factory, action: :remove, created_at: 1.year.ago, model_factory => model1)
+ end
+
+ let(:query) { model1.class.where(id: [model1.id, model2.id]) }
+ let(:event) { described_class.new({}) }
+
+ describe '#apply_query_customization' do
+ subject(:records) { event.apply_query_customization(query).pluck(:id, *event.column_list).to_a }
+
+ it 'looks up the first assignment event timestamp' do
+ expect(records).to match_array([[model1.id, be_within(1.second).of(assignment_event1.created_at)]])
+ end
+ end
+
+ describe '#apply_negated_query_customization' do
+ subject(:records) { event.apply_negated_query_customization(query).pluck(:id).to_a }
+
+ it 'returns records where the event has not happened yet' do
+ expect(records).to eq([model2.id])
+ end
+ end
+
+ describe '#include_in' do
+ subject(:records) { event.include_in(query).pluck(:id, *event.column_list).to_a }
+
+ it 'returns both records' do
+ expect(records).to match_array([
+ [model1.id, be_within(1.second).of(assignment_event1.created_at)],
+ [model2.id, nil]
+ ])
+ end
+
+ context 'when invoked multiple times' do
+ subject(:records) do
+ scope = event.include_in(query)
+ event.include_in(scope).pluck(:id, *event.column_list).to_a
+ end
+
+ it 'returns both records' do
+ expect(records).to match_array([
+ [model1.id, be_within(1.second).of(assignment_event1.created_at)],
+ [model2.id, nil]
+ ])
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/database/async_constraints_validation_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/async_constraints_validation_shared_examples.rb
new file mode 100644
index 00000000000..b9d71183851
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/database/async_constraints_validation_shared_examples.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'async constraints validation' do
+ include ExclusiveLeaseHelpers
+
+ let!(:lease) { stub_exclusive_lease(lease_key, :uuid, timeout: lease_timeout) }
+ let(:lease_key) { "gitlab/database/asyncddl/actions/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" }
+ let(:lease_timeout) { described_class::TIMEOUT_PER_ACTION }
+
+ let(:constraints_model) { Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation }
+ let(:table_name) { '_test_async_constraints' }
+ let(:constraint_name) { 'constraint_parent_id' }
+
+ let(:validation) do
+ create(:postgres_async_constraint_validation,
+ table_name: table_name,
+ name: constraint_name,
+ constraint_type: constraint_type)
+ end
+
+ let(:connection) { validation.connection }
+
+ subject { described_class.new(validation) }
+
+ it 'validates the constraint while controlling statement timeout' do
+ allow(connection).to receive(:execute).and_call_original
+ expect(connection).to receive(:execute)
+ .with("SET statement_timeout TO '43200s'").ordered.and_call_original
+ expect(connection).to receive(:execute)
+ .with(/ALTER TABLE "#{table_name}" VALIDATE CONSTRAINT "#{constraint_name}";/).ordered.and_call_original
+ expect(connection).to receive(:execute)
+ .with("RESET statement_timeout").ordered.and_call_original
+
+ subject.perform
+ end
+
+ it 'removes the constraint validation record from table' do
+ expect(validation).to receive(:destroy!).and_call_original
+
+ expect { subject.perform }.to change { constraints_model.count }.by(-1)
+ end
+
+ it 'skips logic if not able to acquire exclusive lease' do
+ expect(lease).to receive(:try_obtain).ordered.and_return(false)
+ expect(connection).not_to receive(:execute).with(/ALTER TABLE/)
+ expect(validation).not_to receive(:destroy!)
+
+ expect { subject.perform }.not_to change { constraints_model.count }
+ end
+
+ it 'logs messages around execution' do
+ allow(Gitlab::AppLogger).to receive(:info).and_call_original
+
+ subject.perform
+
+ expect(Gitlab::AppLogger)
+ .to have_received(:info)
+ .with(a_hash_including(message: 'Starting to validate constraint'))
+
+ expect(Gitlab::AppLogger)
+ .to have_received(:info)
+ .with(a_hash_including(message: 'Finished validating constraint'))
+ end
+
+ context 'when the constraint does not exist' do
+ before do
+ connection.create_table(table_name, force: true)
+ end
+
+ it 'skips validation and removes the record' do
+ expect(connection).not_to receive(:execute).with(/ALTER TABLE/)
+
+ expect { subject.perform }.to change { constraints_model.count }.by(-1)
+ end
+
+ it 'logs an appropriate message' do
+ expected_message = /Skipping #{constraint_name} validation since it does not exist/
+
+ allow(Gitlab::AppLogger).to receive(:info).and_call_original
+
+ subject.perform
+
+ expect(Gitlab::AppLogger)
+ .to have_received(:info)
+ .with(a_hash_including(message: expected_message))
+ end
+ end
+
+ context 'with error handling' do
+ before do
+ allow(connection).to receive(:execute).and_call_original
+
+ allow(connection).to receive(:execute)
+ .with(/ALTER TABLE "#{table_name}" VALIDATE CONSTRAINT "#{constraint_name}";/)
+ .and_raise(ActiveRecord::StatementInvalid)
+ end
+
+ context 'on production' do
+ before do
+ allow(Gitlab::ErrorTracking).to receive(:should_raise_for_dev?).and_return(false)
+ end
+
+ it 'increases execution attempts' do
+ expect { subject.perform }.to change { validation.attempts }.by(1)
+
+ expect(validation.last_error).to be_present
+ expect(validation).not_to be_destroyed
+ end
+
+ it 'logs an error message including the constraint_name' do
+ expect(Gitlab::AppLogger)
+ .to receive(:error)
+ .with(a_hash_including(:message, :constraint_name))
+ .and_call_original
+
+ subject.perform
+ end
+ end
+
+ context 'on development' do
+ it 'also raises errors' do
+ expect { subject.perform }
+ .to raise_error(ActiveRecord::StatementInvalid)
+ .and change { validation.attempts }.by(1)
+
+ expect(validation.last_error).to be_present
+ expect(validation).not_to be_destroyed
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/database/index_validators_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/index_validators_shared_examples.rb
new file mode 100644
index 00000000000..6f0cede7130
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/database/index_validators_shared_examples.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples "index validators" do |validator, expected_result|
+ let(:structure_file_path) { Rails.root.join('spec/fixtures/structure.sql') }
+ let(:database_indexes) do
+ [
+ ['wrong_index', 'CREATE UNIQUE INDEX wrong_index ON public.table_name (column_name)'],
+ ['extra_index', 'CREATE INDEX extra_index ON public.table_name (column_name)'],
+ ['index', 'CREATE UNIQUE INDEX "index" ON public.achievements USING btree (namespace_id, lower(name))']
+ ]
+ end
+
+ let(:inconsistency_type) { validator.name.demodulize.underscore }
+
+ let(:database_name) { 'main' }
+
+ let(:database_model) { Gitlab::Database.database_base_models[database_name] }
+
+ let(:connection) { database_model.connection }
+
+ let(:schema) { connection.current_schema }
+
+ let(:database) { Gitlab::Database::SchemaValidation::Database.new(connection) }
+ let(:structure_file) { Gitlab::Database::SchemaValidation::StructureSql.new(structure_file_path, schema) }
+
+ subject(:result) { validator.new(structure_file, database).execute }
+
+ before do
+ allow(connection).to receive(:select_rows).and_return(database_indexes)
+ end
+
+ it 'returns index inconsistencies' do
+ expect(result.map(&:object_name)).to match_array(expected_result)
+ expect(result.map(&:type)).to all(eql inconsistency_type)
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/database/schema_objects_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/schema_objects_shared_examples.rb
new file mode 100644
index 00000000000..ec7a881f7ce
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/database/schema_objects_shared_examples.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples "schema objects assertions for" do |stmt_name|
+ let(:stmt) { PgQuery.parse(statement).tree.stmts.first.stmt }
+ let(:schema_object) { described_class.new(stmt.public_send(stmt_name)) }
+
+ describe '#name' do
+ it 'returns schema object name' do
+ expect(schema_object.name).to eq(name)
+ end
+ end
+
+ describe '#statement' do
+ it 'returns schema object statement' do
+ expect(schema_object.statement).to eq(statement)
+ end
+ end
+
+ describe '#table_name' do
+ it 'returns schema object table_name' do
+ expect(schema_object.table_name).to eq(table_name)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/database/table_validators_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/table_validators_shared_examples.rb
new file mode 100644
index 00000000000..96e58294675
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/database/table_validators_shared_examples.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples "table validators" do |validator, expected_result|
+ subject(:result) { validator.new(structure_file, database).execute }
+
+ let(:structure_file_path) { Rails.root.join('spec/fixtures/structure.sql') }
+ let(:inconsistency_type) { validator.name.demodulize.underscore }
+ let(:database_model) { Gitlab::Database.database_base_models['main'] }
+ let(:connection) { database_model.connection }
+ let(:schema) { connection.current_schema }
+ let(:database) { Gitlab::Database::SchemaValidation::Database.new(connection) }
+ let(:structure_file) { Gitlab::Database::SchemaValidation::StructureSql.new(structure_file_path, schema) }
+ let(:database_tables) do
+ [
+ {
+ 'table_name' => 'wrong_table',
+ 'column_name' => 'id',
+ 'not_null' => true,
+ 'data_type' => 'integer',
+ 'column_default' => "nextval('audit_events_id_seq'::regclass)"
+ },
+ {
+ 'table_name' => 'wrong_table',
+ 'column_name' => 'description',
+ 'not_null' => true,
+ 'data_type' => 'character varying',
+ 'column_default' => nil
+ },
+ {
+ 'table_name' => 'extra_table',
+ 'column_name' => 'id',
+ 'not_null' => true,
+ 'data_type' => 'integer',
+ 'column_default' => "nextval('audit_events_id_seq'::regclass)"
+ },
+ {
+ 'table_name' => 'extra_table',
+ 'column_name' => 'email',
+ 'not_null' => true,
+ 'data_type' => 'character varying',
+ 'column_default' => nil
+ },
+ {
+ 'table_name' => 'extra_table_columns',
+ 'column_name' => 'id',
+ 'not_null' => true,
+ 'data_type' => 'bigint',
+ 'column_default' => "nextval('audit_events_id_seq'::regclass)"
+ },
+ {
+ 'table_name' => 'extra_table_columns',
+ 'column_name' => 'name',
+ 'not_null' => true,
+ 'data_type' => 'character varying(255)',
+ 'column_default' => nil
+ },
+ {
+ 'table_name' => 'extra_table_columns',
+ 'column_name' => 'extra_column',
+ 'not_null' => true,
+ 'data_type' => 'character varying(255)',
+ 'column_default' => nil
+ },
+ {
+ 'table_name' => 'missing_table_columns',
+ 'column_name' => 'id',
+ 'not_null' => true,
+ 'data_type' => 'bigint',
+ 'column_default' => 'NOT NULL'
+ }
+ ]
+ end
+
+ before do
+ allow(connection).to receive(:exec_query).and_return(database_tables)
+ end
+
+ it 'returns table inconsistencies' do
+ expect(result.map(&:object_name)).to match_array(expected_result)
+ expect(result.map(&:type)).to all(eql inconsistency_type)
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/database/trigger_validators_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/trigger_validators_shared_examples.rb
new file mode 100644
index 00000000000..13a112275c2
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/database/trigger_validators_shared_examples.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'trigger validators' do |validator, expected_result|
+ subject(:result) { validator.new(structure_file, database).execute }
+
+ let(:structure_file_path) { Rails.root.join('spec/fixtures/structure.sql') }
+ let(:structure_file) { Gitlab::Database::SchemaValidation::StructureSql.new(structure_file_path, schema) }
+ let(:inconsistency_type) { validator.name.demodulize.underscore }
+ let(:database_name) { 'main' }
+ let(:schema) { 'public' }
+ let(:database_model) { Gitlab::Database.database_base_models[database_name] }
+ let(:connection) { database_model.connection }
+ let(:database) { Gitlab::Database::SchemaValidation::Database.new(connection) }
+
+ let(:database_triggers) do
+ [
+ ['trigger', 'CREATE TRIGGER trigger AFTER INSERT ON public.t1 FOR EACH ROW EXECUTE FUNCTION t1()'],
+ ['wrong_trigger', 'CREATE TRIGGER wrong_trigger BEFORE UPDATE ON public.t2 FOR EACH ROW EXECUTE FUNCTION t2()'],
+ ['extra_trigger', 'CREATE TRIGGER extra_trigger BEFORE INSERT ON public.t4 FOR EACH ROW EXECUTE FUNCTION t4()']
+ ]
+ end
+
+ before do
+ allow(connection).to receive(:select_rows).and_return(database_triggers)
+ end
+
+ it 'returns trigger inconsistencies' do
+ expect(result.map(&:object_name)).to match_array(expected_result)
+ expect(result.map(&:type)).to all(eql inconsistency_type)
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/gitaly_client_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/gitaly_client_shared_examples.rb
index f26b9a4a7bd..d388abb16c6 100644
--- a/spec/support/shared_examples/lib/gitlab/gitaly_client_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/gitaly_client_shared_examples.rb
@@ -1,10 +1,12 @@
# frozen_string_literal: true
def raw_repo_without_container(repository)
- Gitlab::Git::Repository.new(repository.shard,
- "#{repository.disk_path}.git",
- repository.repo_type.identifier_for_container(repository.container),
- repository.container.full_path)
+ Gitlab::Git::Repository.new(
+ repository.shard,
+ "#{repository.disk_path}.git",
+ repository.repo_type.identifier_for_container(repository.container),
+ repository.container.full_path
+ )
end
RSpec.shared_examples 'Gitaly feature flag actors are inferred from repository' do
diff --git a/spec/support/shared_examples/lib/gitlab/json_logger_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/json_logger_shared_examples.rb
new file mode 100644
index 00000000000..8a5e8397c3d
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/json_logger_shared_examples.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'a json logger' do |extra_params|
+ let(:now) { Time.now }
+ let(:correlation_id) { Labkit::Correlation::CorrelationId.current_id }
+
+ it 'formats strings' do
+ output = subject.format_message('INFO', now, 'test', 'Hello world')
+ data = Gitlab::Json.parse(output)
+
+ expect(data['severity']).to eq('INFO')
+ expect(data['time']).to eq(now.utc.iso8601(3))
+ expect(data['message']).to eq('Hello world')
+ expect(data['correlation_id']).to eq(correlation_id)
+ expect(data).to include(extra_params)
+ end
+
+ it 'formats hashes' do
+ output = subject.format_message('INFO', now, 'test', { hello: 1 })
+ data = Gitlab::Json.parse(output)
+
+ expect(data['severity']).to eq('INFO')
+ expect(data['time']).to eq(now.utc.iso8601(3))
+ expect(data['hello']).to eq(1)
+ expect(data['message']).to be_nil
+ expect(data['correlation_id']).to eq(correlation_id)
+ expect(data).to include(extra_params)
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/local_and_remote_storage_migration_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/local_and_remote_storage_migration_shared_examples.rb
index 27ca27a9035..4b0e3234750 100644
--- a/spec/support/shared_examples/lib/gitlab/local_and_remote_storage_migration_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/local_and_remote_storage_migration_shared_examples.rb
@@ -8,9 +8,9 @@ RSpec.shared_examples 'local and remote storage migration' do
where(:start_store, :end_store, :method) do
ObjectStorage::Store::LOCAL | ObjectStorage::Store::REMOTE | :migrate_to_remote_storage
- ObjectStorage::Store::REMOTE | ObjectStorage::Store::REMOTE | :migrate_to_remote_storage # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
+ ObjectStorage::Store::REMOTE | ObjectStorage::Store::REMOTE | :migrate_to_remote_storage
ObjectStorage::Store::REMOTE | ObjectStorage::Store::LOCAL | :migrate_to_local_storage
- ObjectStorage::Store::LOCAL | ObjectStorage::Store::LOCAL | :migrate_to_local_storage # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
+ ObjectStorage::Store::LOCAL | ObjectStorage::Store::LOCAL | :migrate_to_local_storage
end
with_them do
diff --git a/spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb
index f83fecee4ea..0016f1e670d 100644
--- a/spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb
@@ -38,8 +38,7 @@ RSpec.shared_examples 'access restricted confidential issues' do
let(:user) { author }
it 'lists project confidential issues' do
- expect(objects).to contain_exactly(issue,
- security_issue_1)
+ expect(objects).to contain_exactly(issue, security_issue_1)
expect(results.limited_issues_count).to eq 2
end
end
@@ -48,8 +47,7 @@ RSpec.shared_examples 'access restricted confidential issues' do
let(:user) { assignee }
it 'lists project confidential issues for assignee' do
- expect(objects).to contain_exactly(issue,
- security_issue_2)
+ expect(objects).to contain_exactly(issue, security_issue_2)
expect(results.limited_issues_count).to eq 2
end
end
@@ -60,9 +58,7 @@ RSpec.shared_examples 'access restricted confidential issues' do
end
it 'lists project confidential issues' do
- expect(objects).to contain_exactly(issue,
- security_issue_1,
- security_issue_2)
+ expect(objects).to contain_exactly(issue, security_issue_1, security_issue_2)
expect(results.limited_issues_count).to eq 3
end
end
@@ -72,9 +68,7 @@ RSpec.shared_examples 'access restricted confidential issues' do
context 'when admin mode is enabled', :enable_admin_mode do
it 'lists all project issues' do
- expect(objects).to contain_exactly(issue,
- security_issue_1,
- security_issue_2)
+ expect(objects).to contain_exactly(issue, security_issue_1, security_issue_2)
end
end
diff --git a/spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb
index 025f0d5c7ea..c2898513424 100644
--- a/spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb
@@ -15,7 +15,7 @@ RSpec.shared_examples 'a repo type' do
describe '#repository_for' do
it 'finds the repository for the repo type' do
- expect(described_class.repository_for(expected_container)).to eq(expected_repository)
+ expect(described_class.repository_for(expected_repository_resolver)).to eq(expected_repository)
end
it 'returns nil when container is nil' do
diff --git a/spec/support/shared_examples/lib/gitlab/search_language_filter_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_language_filter_shared_examples.rb
index a3e4379f4d3..18545698c27 100644
--- a/spec/support/shared_examples/lib/gitlab/search_language_filter_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/search_language_filter_shared_examples.rb
@@ -26,29 +26,4 @@ RSpec.shared_examples 'search results filtered by language' do
expect(blob_results.size).to eq(5)
expect(paths).to match_array(expected_paths)
end
-
- context 'when the search_blobs_language_aggregation feature flag is disabled' do
- before do
- stub_feature_flags(search_blobs_language_aggregation: false)
- end
-
- it 'does not filter by language', :sidekiq_inline, :aggregate_failures do
- expected_paths = %w[
- CHANGELOG
- CONTRIBUTING.md
- bar/branch-test.txt
- custom-highlighting/test.gitlab-custom
- files/ruby/popen.rb
- files/ruby/regex.rb
- files/ruby/version_info.rb
- files/whitespace
- encoding/test.txt
- files/markdown/ruby-style-guide.md
- ]
-
- paths = blob_results.map { |blob| blob.binary_path }
- expect(blob_results.size).to eq(10)
- expect(paths).to match_array(expected_paths)
- 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 ff03051ed37..74570a4da5c 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
@@ -5,7 +5,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name|
instance_double(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, duplicate_key_ttl: Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DEFAULT_DUPLICATE_KEY_TTL)
end
- let(:expected_message) { "dropped #{strategy_name.to_s.humanize.downcase}" }
+ let(:humanized_strategy_name) { strategy_name.to_s.humanize.downcase }
subject(:strategy) { Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies.for(strategy_name).new(fake_duplicate_job) }
@@ -155,7 +155,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name|
fake_logger = instance_double(Gitlab::SidekiqLogging::DeduplicationLogger)
expect(Gitlab::SidekiqLogging::DeduplicationLogger).to receive(:instance).and_return(fake_logger)
- expect(fake_logger).to receive(:deduplicated_log).with(a_hash_including({ 'jid' => 'new jid' }), expected_message, {})
+ expect(fake_logger).to receive(:deduplicated_log).with(a_hash_including({ 'jid' => 'new jid' }), humanized_strategy_name, {})
strategy.schedule({ 'jid' => 'new jid' }) {}
end
@@ -165,7 +165,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name|
expect(Gitlab::SidekiqLogging::DeduplicationLogger).to receive(:instance).and_return(fake_logger)
allow(fake_duplicate_job).to receive(:options).and_return({ foo: :bar })
- expect(fake_logger).to receive(:deduplicated_log).with(a_hash_including({ 'jid' => 'new jid' }), expected_message, { foo: :bar })
+ expect(fake_logger).to receive(:deduplicated_log).with(a_hash_including({ 'jid' => 'new jid' }), humanized_strategy_name, { foo: :bar })
strategy.schedule({ 'jid' => 'new jid' }) {}
end
diff --git a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb
index d4802a19202..169fceced7a 100644
--- a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb
@@ -1,11 +1,11 @@
# frozen_string_literal: true
-RSpec.shared_examples 'a daily tracked issuable snowplow and service ping events for given event params' do
+RSpec.shared_examples 'tracked issuable snowplow and service ping events for given event params' do
before do
stub_application_setting(usage_ping_enabled: true)
end
- def count_unique(date_from: 1.minute.ago, date_to: 1.minute.from_now)
+ def count_unique(date_from: Date.today.beginning_of_week, date_to: 1.week.from_now)
Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: action, start_date: date_from, end_date: date_to)
end
@@ -27,35 +27,23 @@ RSpec.shared_examples 'a daily tracked issuable snowplow and service ping events
expect_snowplow_event(**{ category: category, action: event_action, user: user1 }.merge(event_params))
end
-
- context 'with route_hll_to_snowplow_phase2 disabled' do
- before do
- stub_feature_flags(route_hll_to_snowplow_phase2: false)
- end
-
- it 'does not emit snowplow event' do
- track_action({ author: user1 }.merge(track_params))
-
- expect_no_snowplow_event
- end
- end
end
-RSpec.shared_examples 'daily tracked issuable snowplow and service ping events with project' do
- it_behaves_like 'a daily tracked issuable snowplow and service ping events for given event params' do
+RSpec.shared_examples 'tracked issuable snowplow and service ping events with project' do
+ it_behaves_like 'tracked issuable snowplow and service ping events for given event params' do
let(:context) do
Gitlab::Tracking::ServicePingContext
.new(data_source: :redis_hll, event: event_property)
.to_h
end
- let(:track_params) { { project: project } }
- let(:event_params) { track_params.merge(label: event_label, property: event_property, namespace: project.namespace, context: [context]) }
+ let(:track_params) { original_params || { project: project } }
+ let(:event_params) { { project: project }.merge(label: event_label, property: event_property, namespace: project.namespace, context: [context]) }
end
end
-RSpec.shared_examples 'a daily tracked issuable snowplow and service ping events with namespace' do
- it_behaves_like 'a daily tracked issuable snowplow and service ping events for given event params' do
+RSpec.shared_examples 'tracked issuable snowplow and service ping events with namespace' do
+ it_behaves_like 'tracked issuable snowplow and service ping events for given event params' do
let(:context) do
Gitlab::Tracking::ServicePingContext
.new(data_source: :redis_hll, event: event_property)
diff --git a/spec/support/shared_examples/lib/gitlab/utils/username_and_email_generator_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/utils/username_and_email_generator_shared_examples.rb
new file mode 100644
index 00000000000..a42d1450e4d
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/utils/username_and_email_generator_shared_examples.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'username and email pair is generated by Gitlab::Utils::UsernameAndEmailGenerator' do
+ let(:randomhex) { 'randomhex' }
+
+ it 'check email domain' do
+ expect(subject.email).to end_with("@#{email_domain}")
+ end
+
+ it 'contains SecureRandom part' do
+ allow(SecureRandom).to receive(:hex).at_least(:once).and_return(randomhex)
+
+ expect(subject.username).to include("_#{randomhex}")
+ expect(subject.email).to include("_#{randomhex}@")
+ end
+
+ it 'email name is the same as username' do
+ expect(subject.email).to include("#{subject.username}@")
+ end
+
+ context 'when conflicts' do
+ let(:reserved_username) { "#{username_prefix}_#{randomhex}" }
+ let(:reserved_email) { "#{reserved_username}@#{email_domain}" }
+
+ shared_examples 'uniquifies username and email' do
+ it 'uniquifies username and email' do
+ expect(subject.username).to eq("#{reserved_username}1")
+ expect(subject.email).to include("#{subject.username}@")
+ end
+ end
+
+ context 'when username is reserved' do
+ context 'when username is reserved by user' do
+ before do
+ create(:user, username: reserved_username)
+ allow(SecureRandom).to receive(:hex).at_least(:once).and_return(randomhex)
+ end
+
+ include_examples 'uniquifies username and email'
+ end
+
+ context 'when it conflicts with top-level group namespace' do
+ before do
+ create(:group, path: reserved_username)
+ allow(SecureRandom).to receive(:hex).at_least(:once).and_return(randomhex)
+ end
+
+ include_examples 'uniquifies username and email'
+ end
+
+ context 'when it conflicts with top-level group namespace that includes upcased characters' do
+ before do
+ create(:group, path: reserved_username.upcase)
+ allow(SecureRandom).to receive(:hex).at_least(:once).and_return(randomhex)
+ end
+
+ include_examples 'uniquifies username and email'
+ end
+ end
+
+ context 'when email is reserved' do
+ context 'when it conflicts with confirmed primary email' do
+ before do
+ create(:user, email: reserved_email)
+ allow(SecureRandom).to receive(:hex).at_least(:once).and_return(randomhex)
+ end
+
+ include_examples 'uniquifies username and email'
+ end
+
+ context 'when it conflicts with unconfirmed primary email' do
+ before do
+ create(:user, :unconfirmed, email: reserved_email)
+ allow(SecureRandom).to receive(:hex).at_least(:once).and_return(randomhex)
+ end
+
+ include_examples 'uniquifies username and email'
+ end
+
+ context 'when it conflicts with confirmed secondary email' do
+ before do
+ create(:email, :confirmed, email: reserved_email)
+ allow(SecureRandom).to receive(:hex).at_least(:once).and_return(randomhex)
+ end
+
+ include_examples 'uniquifies username and email'
+ end
+ end
+
+ context 'when email and username is reserved' do
+ before do
+ create(:user, email: reserved_email)
+ create(:user, username: "#{reserved_username}1")
+ allow(SecureRandom).to receive(:hex).at_least(:once).and_return(randomhex)
+ end
+
+ it 'uniquifies username and email' do
+ expect(subject.username).to eq("#{reserved_username}2")
+
+ expect(subject.email).to include("#{subject.username}@")
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/menus_shared_examples.rb b/spec/support/shared_examples/lib/menus_shared_examples.rb
index 2c2cb362b07..0aa98517444 100644
--- a/spec/support/shared_examples/lib/menus_shared_examples.rb
+++ b/spec/support/shared_examples/lib/menus_shared_examples.rb
@@ -37,3 +37,58 @@ RSpec.shared_examples_for 'pill_count formatted results' do
expect(pill_count).to eq('112.6k')
end
end
+
+RSpec.shared_examples_for 'serializable as super_sidebar_menu_args' do
+ let(:extra_attrs) { raise NotImplementedError }
+
+ it 'returns hash with provided attributes' do
+ expect(menu.serialize_as_menu_item_args).to eq({
+ title: menu.title,
+ link: menu.link,
+ active_routes: menu.active_routes,
+ container_html_options: menu.container_html_options,
+ **extra_attrs
+ })
+ end
+
+ it 'returns hash with an item_id' do
+ expect(menu.serialize_as_menu_item_args[:item_id]).not_to be_nil
+ end
+end
+
+RSpec.shared_examples_for 'not serializable as super_sidebar_menu_args' do
+ it 'returns nil' do
+ expect(menu.serialize_as_menu_item_args).to be_nil
+ end
+end
+
+RSpec.shared_examples_for 'a panel with uniquely identifiable menu items' do
+ let(:menu_items) do
+ subject.instance_variable_get(:@menus)
+ .flat_map { |menu| menu.instance_variable_get(:@items) }
+ end
+
+ it 'all menu_items have unique item_id' do
+ duplicated_ids = menu_items.group_by(&:item_id).reject { |_, v| (v.size < 2) }
+
+ expect(duplicated_ids).to eq({})
+ end
+
+ it 'all menu_items have an item_id' do
+ items_with_nil_id = menu_items.select { |item| item.item_id.nil? }
+
+ expect(items_with_nil_id).to match_array([])
+ end
+end
+
+RSpec.shared_examples_for 'a panel with all menu_items categorized' do
+ let(:uncategorized_menu) do
+ subject.instance_variable_get(:@menus)
+ .find { |menu| menu.instance_of?(::Sidebars::UncategorizedMenu) }
+ end
+
+ it 'has no uncategorized menu_items' do
+ uncategorized_menu_items = uncategorized_menu.instance_variable_get(:@items)
+ expect(uncategorized_menu_items).to eq([])
+ end
+end
diff --git a/spec/support/shared_examples/lib/sentry/client_shared_examples.rb b/spec/support/shared_examples/lib/sentry/client_shared_examples.rb
index e0b411e1e2a..fa3e9bf5340 100644
--- a/spec/support/shared_examples/lib/sentry/client_shared_examples.rb
+++ b/spec/support/shared_examples/lib/sentry/client_shared_examples.rb
@@ -90,7 +90,9 @@ RSpec.shared_examples 'Sentry API response size limit' do
end
it 'raises an exception when response is too large' do
- expect { subject }.to raise_error(ErrorTracking::SentryClient::ResponseInvalidSizeError,
- 'Sentry API response is too big. Limit is 1 MB.')
+ expect { subject }.to raise_error(
+ ErrorTracking::SentryClient::ResponseInvalidSizeError,
+ 'Sentry API response is too big. Limit is 1 MB.'
+ )
end
end
diff --git a/spec/support/shared_examples/lib/sidebars/admin/menus/admin_menus_shared_examples.rb b/spec/support/shared_examples/lib/sidebars/admin/menus/admin_menus_shared_examples.rb
new file mode 100644
index 00000000000..f913c6b8a9e
--- /dev/null
+++ b/spec/support/shared_examples/lib/sidebars/admin/menus/admin_menus_shared_examples.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'Admin menu' do |link:, title:, icon:, separated: false|
+ let_it_be(:user) { build(:user, :admin) }
+
+ before do
+ allow(user).to receive(:can_admin_all_resources?).and_return(true)
+ end
+
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it 'renders the correct link' do
+ expect(subject.link).to match link
+ end
+
+ it 'renders the correct title' do
+ expect(subject.title).to eq title
+ end
+
+ it 'renders the correct icon' do
+ expect(subject.sprite_icon).to be icon
+ end
+
+ it 'renders the separator if needed' do
+ expect(subject.separated?).to be separated
+ end
+
+ describe '#render?' do
+ context 'when user is admin' do
+ it 'renders' do
+ expect(subject.render?).to be true
+ end
+ end
+
+ context 'when user is not admin' do
+ it 'does not render' do
+ expect(described_class.new(Sidebars::Context.new(current_user: build(:user),
+ container: nil)).render?).to be false
+ end
+ end
+
+ context 'when user is not logged in' do
+ it 'does not render' do
+ expect(described_class.new(Sidebars::Context.new(current_user: nil, container: nil)).render?).to be false
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'Admin menu without sub menus' do |active_routes:|
+ let_it_be(:user) { build(:user, :admin) }
+
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it 'does not contain any sub menu(s)' do
+ expect(subject.has_items?).to be false
+ end
+
+ it 'defines correct active route' do
+ expect(subject.active_routes).to eq active_routes
+ end
+end
+
+RSpec.shared_examples 'Admin menu with sub menus' do
+ let_it_be(:user) { build(:user, :admin) }
+
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it 'contains submemus' do
+ expect(subject.has_items?).to be true
+ end
+end
diff --git a/spec/support/shared_examples/lib/sidebars/user_profile/user_profile_menus_shared_examples.rb b/spec/support/shared_examples/lib/sidebars/user_profile/user_profile_menus_shared_examples.rb
new file mode 100644
index 00000000000..5e8aebb4f29
--- /dev/null
+++ b/spec/support/shared_examples/lib/sidebars/user_profile/user_profile_menus_shared_examples.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'User profile menu' do |title:, icon:, active_route:|
+ let_it_be(:current_user) { build(:user) }
+ let_it_be(:user) { build(:user) }
+
+ let(:context) { Sidebars::Context.new(current_user: current_user, container: user) }
+
+ subject { described_class.new(context) }
+
+ it 'does not contain any sub menu' do
+ expect(subject.has_items?).to be false
+ end
+
+ it 'renders the correct link' do
+ expect(subject.link).to match link
+ end
+
+ it 'renders the correct title' do
+ expect(subject.title).to eq title
+ end
+
+ it 'renders the correct icon' do
+ expect(subject.sprite_icon).to eq icon
+ end
+
+ it 'defines correct active route' do
+ expect(subject.active_routes[:path]).to be active_route
+ end
+
+ it 'renders if user is logged in' do
+ expect(subject.render?).to be true
+ end
+
+ [:blocked, :banned].each do |trait|
+ context "when viewed user is #{trait}" do
+ let_it_be(:viewed_user) { build(:user, trait) }
+ let(:context) { Sidebars::Context.new(current_user: user, container: viewed_user) }
+
+ context 'when user is not logged in' do
+ it 'is not allowed to view the menu item' do
+ expect(described_class.new(Sidebars::Context.new(current_user: nil,
+ container: viewed_user)).render?).to be false
+ end
+ end
+
+ context 'when current user has permission' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :read_user_profile, viewed_user).and_return(true)
+ end
+
+ it 'is allowed to view the menu item' do
+ expect(described_class.new(context).render?).to be true
+ end
+ end
+
+ context 'when current user does not have permission' do
+ it 'is not allowed to view the menu item' do
+ expect(described_class.new(context).render?).to be false
+ end
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'Followers/followees counts' do |symbol|
+ let_it_be(:current_user) { build(:user) }
+ let_it_be(:user) { build(:user) }
+
+ let(:context) { Sidebars::Context.new(current_user: current_user, container: user) }
+
+ subject { described_class.new(context) }
+
+ context 'when there are items' do
+ before do
+ allow(user).to receive(symbol).and_return([1, 2])
+ end
+
+ it 'renders the pill' do
+ expect(subject.has_pill?).to be(true)
+ end
+
+ it 'returns the count' do
+ expect(subject.pill_count).to be(2)
+ end
+ end
+
+ context 'when there are no items' do
+ it 'does not render the pill' do
+ expect(subject.has_pill?).to be(false)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/sidebars/user_settings/menus/user_settings_menus_shared_examples.rb b/spec/support/shared_examples/lib/sidebars/user_settings/menus/user_settings_menus_shared_examples.rb
new file mode 100644
index 00000000000..b91386d1935
--- /dev/null
+++ b/spec/support/shared_examples/lib/sidebars/user_settings/menus/user_settings_menus_shared_examples.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'User settings menu' do |link:, title:, icon:, active_routes:|
+ let_it_be(:user) { create(:user) }
+
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ subject { described_class.new(context) }
+
+ it 'does not contain any sub menu' do
+ expect(subject.has_items?).to be false
+ end
+
+ it 'renders the correct link' do
+ expect(subject.link).to match link
+ end
+
+ it 'renders the correct title' do
+ expect(subject.title).to eq title
+ end
+
+ it 'renders the correct icon' do
+ expect(subject.sprite_icon).to be icon
+ end
+
+ it 'defines correct active route' do
+ expect(subject.active_routes).to eq active_routes
+ end
+end
+
+RSpec.shared_examples 'User settings menu #render? method' do
+ describe '#render?' do
+ subject { described_class.new(context) }
+
+ context 'when user is logged in' do
+ let_it_be(:user) { build(:user) }
+ let(:context) { Sidebars::Context.new(current_user: user, container: nil) }
+
+ it 'renders' do
+ expect(subject.render?).to be true
+ end
+ end
+
+ context 'when user is not logged in' do
+ let(:context) { Sidebars::Context.new(current_user: nil, container: nil) }
+
+ it 'does not render' do
+ expect(subject.render?).to be false
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/mailers/export_csv_shared_examples.rb b/spec/support/shared_examples/mailers/export_csv_shared_examples.rb
new file mode 100644
index 00000000000..731d7c810f9
--- /dev/null
+++ b/spec/support/shared_examples/mailers/export_csv_shared_examples.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'export csv email' do |collection_type|
+ include_context 'gitlab email notification'
+
+ it 'attachment has csv mime type' do
+ expect(attachment.mime_type).to eq 'text/csv'
+ end
+
+ it 'generates a useful filename' do
+ expect(attachment.filename).to include(Date.today.year.to_s)
+ expect(attachment.filename).to include(collection_type)
+ expect(attachment.filename).to include('myproject')
+ expect(attachment.filename).to end_with('.csv')
+ end
+
+ it 'mentions number of objects and project name' do
+ expect(subject).to have_content '3'
+ expect(subject).to have_content empty_project.name
+ end
+
+ it "doesn't need to mention truncation by default" do
+ expect(subject).not_to have_content 'truncated'
+ end
+
+ context 'when truncated' do
+ let(:export_status) { { truncated: true, rows_expected: 12, rows_written: 10 } }
+
+ it 'mentions that the csv has been truncated' do
+ expect(subject).to have_content 'truncated'
+ end
+
+ it 'mentions the number of objects written and expected' do
+ expect(subject).to have_content "10 of 12 #{collection_type.humanize.downcase}"
+ 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 2e182fb399d..cf1ab7697ab 100644
--- a/spec/support/shared_examples/mailers/notify_shared_examples.rb
+++ b/spec/support/shared_examples/mailers/notify_shared_examples.rb
@@ -59,7 +59,7 @@ end
RSpec.shared_examples 'an email with X-GitLab headers containing project details' do
it 'has X-GitLab-Project headers' do
aggregate_failures do
- full_path_as_domain = "#{project.name}.#{project.namespace.path}"
+ full_path_as_domain = "#{project.path}.#{project.namespace.path}"
is_expected.to have_header('X-GitLab-Project', /#{project.name}/)
is_expected.to have_header('X-GitLab-Project-Id', /#{project.id}/)
is_expected.to have_header('X-GitLab-Project-Path', /#{project.full_path}/)
@@ -294,3 +294,17 @@ RSpec.shared_examples 'does not render a manage notifications link' do
end
end
end
+
+RSpec.shared_examples 'email with default notification reason' do
+ it do
+ is_expected.to have_body_text("You're receiving this email because of your account")
+ is_expected.to have_plain_text_content("You're receiving this email because of your account")
+ end
+end
+
+RSpec.shared_examples 'email with link to issue' do
+ it do
+ is_expected.to have_body_text(%(<a href="#{project_issue_url(project, issue)}">view it on GitLab</a>))
+ is_expected.to have_plain_text_content("view it on GitLab: #{project_issue_url(project, issue)}")
+ end
+end
diff --git a/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb b/spec/support/shared_examples/metrics_instrumentation_shared_examples.rb
index cef9860fe25..cef9860fe25 100644
--- a/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb
+++ b/spec/support/shared_examples/metrics_instrumentation_shared_examples.rb
diff --git a/spec/support/shared_examples/migrations/add_work_item_widget_shared_examples.rb b/spec/support/shared_examples/migrations/add_work_item_widget_shared_examples.rb
new file mode 100644
index 00000000000..28eac52256f
--- /dev/null
+++ b/spec/support/shared_examples/migrations/add_work_item_widget_shared_examples.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'migration that adds widget to work items definitions' do |widget_name:|
+ let(:migration) { described_class.new }
+ let(:work_item_definitions) { table(:work_item_widget_definitions) }
+
+ describe '#up' do
+ it "creates widget definition in all types" do
+ work_item_definitions.where(name: widget_name).delete_all
+
+ expect { migrate! }.to change { work_item_definitions.count }.by(7)
+ expect(work_item_definitions.all.pluck(:name)).to include(widget_name)
+ end
+
+ it 'logs a warning if the type is missing' do
+ allow(described_class::WorkItemType).to receive(:find_by_name_and_namespace_id).and_call_original
+ allow(described_class::WorkItemType).to receive(:find_by_name_and_namespace_id)
+ .with('Issue', nil).and_return(nil)
+
+ expect(Gitlab::AppLogger).to receive(:warn).with('type Issue is missing, not adding widget')
+ migrate!
+ end
+ end
+
+ describe '#down' do
+ it "removes definitions for widget" do
+ migrate!
+
+ expect { migration.down }.to change { work_item_definitions.count }.by(-7)
+ expect(work_item_definitions.all.pluck(:name)).not_to include(widget_name)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/active_record_enum_shared_examples.rb b/spec/support/shared_examples/models/active_record_enum_shared_examples.rb
index 3d765b6ca93..10f3263d4fc 100644
--- a/spec/support/shared_examples/models/active_record_enum_shared_examples.rb
+++ b/spec/support/shared_examples/models/active_record_enum_shared_examples.rb
@@ -10,3 +10,13 @@ RSpec.shared_examples 'having unique enum values' do
end
end
end
+
+RSpec.shared_examples 'having enum with nil value' do
+ it 'has enum with nil value' do
+ subject.public_send("#{attr_value}!")
+
+ expect(subject.public_send("#{attr}_for_database")).to be_nil
+ expect(subject.public_send("#{attr}?")).to eq(true)
+ expect(subject.class.public_send(attr_value)).to eq([subject])
+ end
+end
diff --git a/spec/support/shared_examples/models/chat_integration_shared_examples.rb b/spec/support/shared_examples/models/chat_integration_shared_examples.rb
index 085fec6ff1e..addd37cde32 100644
--- a/spec/support/shared_examples/models/chat_integration_shared_examples.rb
+++ b/spec/support/shared_examples/models/chat_integration_shared_examples.rb
@@ -221,11 +221,13 @@ RSpec.shared_examples "chat integration" do |integration_name|
context "with commit comment" do
let_it_be(:note) do
- create(:note_on_commit,
- author: user,
- project: project,
- commit_id: project.repository.commit.id,
- note: "a comment on a commit")
+ create(
+ :note_on_commit,
+ author: user,
+ project: project,
+ commit_id: project.repository.commit.id,
+ note: "a comment on a commit"
+ )
end
it_behaves_like "triggered #{integration_name} integration"
@@ -261,9 +263,11 @@ RSpec.shared_examples "chat integration" do |integration_name|
context "with failed pipeline" do
let_it_be(:pipeline) do
- create(:ci_pipeline,
- project: project, status: "failed",
- sha: project.commit.sha, ref: project.default_branch)
+ create(
+ :ci_pipeline,
+ project: project, status: "failed",
+ sha: project.commit.sha, ref: project.default_branch
+ )
end
it_behaves_like "triggered #{integration_name} integration"
@@ -271,9 +275,11 @@ RSpec.shared_examples "chat integration" do |integration_name|
context "with succeeded pipeline" do
let_it_be(:pipeline) do
- create(:ci_pipeline,
- project: project, status: "success",
- sha: project.commit.sha, ref: project.default_branch)
+ create(
+ :ci_pipeline,
+ project: project, status: "success",
+ sha: project.commit.sha, ref: project.default_branch
+ )
end
context "with default notify_only_broken_pipelines" do
diff --git a/spec/support/shared_examples/models/ci/token_format_shared_examples.rb b/spec/support/shared_examples/models/ci/token_format_shared_examples.rb
new file mode 100644
index 00000000000..0272982e2d0
--- /dev/null
+++ b/spec/support/shared_examples/models/ci/token_format_shared_examples.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples_for 'ensures runners_token is prefixed' do |factory|
+ subject(:record) { FactoryBot.build(factory) }
+
+ describe '#runners_token', feature_category: :system_access do
+ let(:runners_prefix) { RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX }
+
+ it 'generates runners_token which starts with runner prefix' do
+ expect(record.runners_token).to match(a_string_starting_with(runners_prefix))
+ end
+
+ context 'when record has an invalid token' do
+ subject(:record) { FactoryBot.build(factory, runners_token: invalid_runners_token) }
+
+ let(:invalid_runners_token) { "not_start_with_runners_prefix" }
+
+ it 'generates runners_token which starts with runner prefix' do
+ expect(record.runners_token).to match(a_string_starting_with(runners_prefix))
+ end
+
+ it 'changes the attribute values for runners_token and runners_token_encrypted' do
+ expect { record.runners_token }
+ .to change { record[:runners_token] }.from(invalid_runners_token).to(nil)
+ .and change { record[:runners_token_encrypted] }.from(nil)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/clusters/prometheus_client_shared.rb b/spec/support/shared_examples/models/clusters/prometheus_client_shared.rb
index 8d6dcfef925..140968da272 100644
--- a/spec/support/shared_examples/models/clusters/prometheus_client_shared.rb
+++ b/spec/support/shared_examples/models/clusters/prometheus_client_shared.rb
@@ -41,10 +41,12 @@ RSpec.shared_examples '#prometheus_client shared' do
subject.cluster.platform_kubernetes.namespace = 'a-namespace'
stub_kubeclient_discover(cluster.platform_kubernetes.api_url)
- create(:cluster_kubernetes_namespace,
- cluster: cluster,
- cluster_project: cluster.cluster_project,
- project: cluster.cluster_project.project)
+ create(
+ :cluster_kubernetes_namespace,
+ cluster: cluster,
+ cluster_project: cluster.cluster_project,
+ project: cluster.cluster_project.project
+ )
end
it 'creates proxy prometheus_client' do
diff --git a/spec/support/shared_examples/models/concerns/auto_disabling_hooks_shared_examples.rb b/spec/support/shared_examples/models/concerns/auto_disabling_hooks_shared_examples.rb
index 122774a9028..a196b63585c 100644
--- a/spec/support/shared_examples/models/concerns/auto_disabling_hooks_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/auto_disabling_hooks_shared_examples.rb
@@ -17,8 +17,12 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do
[4, 1.second.from_now], # Exceeded the grace period, set by #backoff!
[4, Time.current] # Exceeded the grace period, set by #backoff!, edge-case
].map do |(recent_failures, disabled_until)|
- create(hook_factory, **default_factory_arguments, recent_failures: recent_failures,
-disabled_until: disabled_until)
+ create(
+ hook_factory,
+ **default_factory_arguments,
+ recent_failures: recent_failures,
+ disabled_until: disabled_until
+ )
end
end
@@ -45,8 +49,12 @@ disabled_until: disabled_until)
[0, suspended],
[0, expired]
].map do |(recent_failures, disabled_until)|
- create(hook_factory, **default_factory_arguments, recent_failures: recent_failures,
-disabled_until: disabled_until)
+ create(
+ hook_factory,
+ **default_factory_arguments,
+ recent_failures: recent_failures,
+ disabled_until: disabled_until
+ )
end
end
@@ -61,6 +69,20 @@ disabled_until: disabled_until)
# Nothing is missing
expect(find_hooks.executable.to_a + find_hooks.disabled.to_a).to match_array(find_hooks.to_a)
end
+
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(auto_disabling_web_hooks: false)
+ end
+
+ it 'causes all hooks to be considered executable' do
+ expect(find_hooks.executable.count).to eq(16)
+ end
+
+ it 'causes no hooks to be considered disabled' do
+ expect(find_hooks.disabled).to be_empty
+ end
+ end
end
describe '#executable?', :freeze_time do
@@ -108,6 +130,16 @@ disabled_until: disabled_until)
it 'has the correct state' do
expect(web_hook.executable?).to eq(executable)
end
+
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(auto_disabling_web_hooks: false)
+ end
+
+ it 'is always executable' do
+ expect(web_hook).to be_executable
+ end
+ end
end
end
@@ -151,7 +183,7 @@ disabled_until: disabled_until)
context 'when we have exhausted the grace period' do
before do
- hook.update!(recent_failures: WebHook::FAILURE_THRESHOLD)
+ hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD)
end
context 'when the hook is permanently disabled' do
@@ -172,6 +204,16 @@ disabled_until: disabled_until)
def run_expectation
expect { hook.backoff! }.to change { hook.backoff_count }.by(1)
end
+
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(auto_disabling_web_hooks: false)
+ end
+
+ it 'does not increment backoff count' do
+ expect { hook.failed! }.not_to change { hook.backoff_count }
+ end
+ end
end
end
end
@@ -181,6 +223,16 @@ disabled_until: disabled_until)
def run_expectation
expect { hook.failed! }.to change { hook.recent_failures }.by(1)
end
+
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(auto_disabling_web_hooks: false)
+ end
+
+ it 'does not increment recent failure count' do
+ expect { hook.failed! }.not_to change { hook.recent_failures }
+ end
+ end
end
end
@@ -189,6 +241,16 @@ disabled_until: disabled_until)
expect { hook.disable! }.to change { hook.executable? }.from(true).to(false)
end
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(auto_disabling_web_hooks: false)
+ end
+
+ it 'does not disable the hook' do
+ expect { hook.disable! }.not_to change { hook.executable? }
+ end
+ end
+
it 'does nothing if the hook is already disabled' do
allow(hook).to receive(:permanently_disabled?).and_return(true)
@@ -210,7 +272,7 @@ disabled_until: disabled_until)
end
it 'allows FAILURE_THRESHOLD initial failures before we back-off' do
- WebHook::FAILURE_THRESHOLD.times do
+ WebHooks::AutoDisabling::FAILURE_THRESHOLD.times do
hook.backoff!
expect(hook).not_to be_temporarily_disabled
end
@@ -221,13 +283,23 @@ disabled_until: disabled_until)
context 'when hook has been told to back off' do
before do
- hook.update!(recent_failures: WebHook::FAILURE_THRESHOLD)
+ hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD)
hook.backoff!
end
it 'is true' do
expect(hook).to be_temporarily_disabled
end
+
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(auto_disabling_web_hooks: false)
+ end
+
+ it 'is false' do
+ expect(hook).not_to be_temporarily_disabled
+ end
+ end
end
end
@@ -244,6 +316,16 @@ disabled_until: disabled_until)
it 'is true' do
expect(hook).to be_permanently_disabled
end
+
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(auto_disabling_web_hooks: false)
+ end
+
+ it 'is false' do
+ expect(hook).not_to be_permanently_disabled
+ end
+ end
end
end
@@ -258,15 +340,31 @@ disabled_until: disabled_until)
end
it { is_expected.to eq :disabled }
+
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(auto_disabling_web_hooks: false)
+ end
+
+ it { is_expected.to eq(:executable) }
+ end
end
context 'when hook has been backed off' do
before do
- hook.update!(recent_failures: WebHook::FAILURE_THRESHOLD + 1)
+ hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD + 1)
hook.disabled_until = 1.hour.from_now
end
it { is_expected.to eq :temporarily_disabled }
+
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(auto_disabling_web_hooks: false)
+ end
+
+ it { is_expected.to eq(:executable) }
+ end
end
end
end
diff --git a/spec/support/shared_examples/models/concerns/cascading_namespace_setting_shared_examples.rb b/spec/support/shared_examples/models/concerns/cascading_namespace_setting_shared_examples.rb
index a4db4e25db3..c51e4999e81 100644
--- a/spec/support/shared_examples/models/concerns/cascading_namespace_setting_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/cascading_namespace_setting_shared_examples.rb
@@ -112,9 +112,10 @@ RSpec.shared_examples 'a cascading namespace setting boolean attribute' do
it 'does not allow the local value to be saved' do
subgroup_settings.send("#{settings_attribute_name}=", nil)
- expect { subgroup_settings.save! }
- .to raise_error(ActiveRecord::RecordInvalid,
- /cannot be changed because it is locked by an ancestor/)
+ expect { subgroup_settings.save! }.to raise_error(
+ ActiveRecord::RecordInvalid,
+ /cannot be changed because it is locked by an ancestor/
+ )
end
end
@@ -171,15 +172,59 @@ RSpec.shared_examples 'a cascading namespace setting boolean attribute' do
end
describe "##{settings_attribute_name}=" do
- before do
- subgroup_settings.update!(settings_attribute_name => nil)
- group_settings.update!(settings_attribute_name => true)
+ using RSpec::Parameterized::TableSyntax
+
+ where(:parent_value, :current_subgroup_value, :new_subgroup_value, :expected_subgroup_value_after_update) do
+ true | nil | true | nil
+ true | nil | "true" | nil
+ true | false | true | true
+ true | false | "true" | true
+ true | true | false | false
+ true | true | "false" | false
+ false | nil | false | nil
+ false | nil | true | true
+ false | true | false | false
+ false | false | true | true
end
- it 'does not save the value locally when it matches the cascaded value' do
- subgroup_settings.update!(settings_attribute_name => true)
+ with_them do
+ before do
+ subgroup_settings.update!(settings_attribute_name => current_subgroup_value)
+ group_settings.update!(settings_attribute_name => parent_value)
+ end
- expect(subgroup_settings.read_attribute(settings_attribute_name)).to eq(nil)
+ it 'validates starting values from before block', :aggregate_failures do
+ expect(group_settings.reload.read_attribute(settings_attribute_name)).to eq(parent_value)
+ expect(subgroup_settings.reload.read_attribute(settings_attribute_name)).to eq(current_subgroup_value)
+ end
+
+ it 'does not save the value locally when it matches cascaded value', :aggregate_failures do
+ subgroup_settings.send("#{settings_attribute_name}=", new_subgroup_value)
+
+ # Verify dirty value
+ expect(subgroup_settings.read_attribute(settings_attribute_name)).to eq(expected_subgroup_value_after_update)
+
+ subgroup_settings.save!
+
+ # Verify persisted value
+ expect(subgroup_settings.reload.read_attribute(settings_attribute_name))
+ .to eq(expected_subgroup_value_after_update)
+ end
+
+ context 'when mass assigned' do
+ before do
+ subgroup_settings.attributes =
+ { settings_attribute_name => new_subgroup_value, "lock_#{settings_attribute_name}" => false }
+ end
+
+ it 'does not save the value locally when it matches cascaded value', :aggregate_failures do
+ subgroup_settings.save!
+
+ # Verify persisted value
+ expect(subgroup_settings.reload.read_attribute(settings_attribute_name))
+ .to eq(expected_subgroup_value_after_update)
+ end
+ end
end
end
@@ -277,9 +322,10 @@ RSpec.shared_examples 'a cascading namespace setting boolean attribute' do
it 'does not allow the attribute to be saved' do
subgroup_settings.send("lock_#{settings_attribute_name}=", true)
- expect { subgroup_settings.save! }
- .to raise_error(ActiveRecord::RecordInvalid,
- /cannot be changed because it is locked by an ancestor/)
+ expect { subgroup_settings.save! }.to raise_error(
+ ActiveRecord::RecordInvalid,
+ /cannot be changed because it is locked by an ancestor/
+ )
end
end
@@ -299,9 +345,10 @@ RSpec.shared_examples 'a cascading namespace setting boolean attribute' do
it 'does not allow the lock to be saved when the attribute is nil' do
subgroup_settings.send("#{settings_attribute_name}=", nil)
- expect { subgroup_settings.save! }
- .to raise_error(ActiveRecord::RecordInvalid,
- /cannot be nil when locking the attribute/)
+ expect { subgroup_settings.save! }.to raise_error(
+ ActiveRecord::RecordInvalid,
+ /cannot be nil when locking the attribute/
+ )
end
it 'copies the cascaded value when locking the attribute if the local value is nil', :aggregate_failures do
@@ -320,9 +367,10 @@ RSpec.shared_examples 'a cascading namespace setting boolean attribute' do
it 'does not allow the attribute to be saved' do
subgroup_settings.send("lock_#{settings_attribute_name}=", true)
- expect { subgroup_settings.save! }
- .to raise_error(ActiveRecord::RecordInvalid,
- /cannot be changed because it is locked by an ancestor/)
+ expect { subgroup_settings.save! }.to raise_error(
+ ActiveRecord::RecordInvalid,
+ /cannot be changed because it is locked by an ancestor/
+ )
end
end
diff --git a/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb b/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb
index 5755b9a56b1..9d189842b28 100644
--- a/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb
@@ -17,6 +17,11 @@ RSpec.shared_examples_for CounterAttribute do |counter_attributes|
let(:amount) { 10 }
let(:increment) { Gitlab::Counters::Increment.new(amount: amount, ref: 3) }
let(:counter_key) { model.counter(attribute).key }
+ let(:returns_current) do
+ model.class.counter_attributes
+ .find { |a| a[:attribute] == attribute }
+ .fetch(:returns_current, false)
+ end
subject { model.increment_counter(attribute, increment) }
@@ -61,6 +66,33 @@ RSpec.shared_examples_for CounterAttribute do |counter_attributes|
end
end
+ describe '#increment_amount' do
+ it 'increases the egress in cache' do
+ model.increment_amount(attribute, 3)
+
+ expect(model.counter(attribute).get).to eq(3)
+ end
+ end
+
+ describe '#current_counter' do
+ let(:data_transfer_node) do
+ args = { project: project }
+ args[attribute] = 2
+ create(:project_data_transfer, **args)
+ end
+
+ it 'increases the amount in cache' do
+ if returns_current
+ incremented_by = 4
+ db_state = model.read_attribute(attribute)
+
+ model.send("increment_#{attribute}".to_sym, incremented_by)
+
+ expect(model.send(attribute)).to eq(db_state + incremented_by)
+ end
+ end
+ end
+
context 'when increment amount is 0' do
let(:amount) { 0 }
@@ -155,14 +187,24 @@ RSpec.shared_examples_for CounterAttribute do |counter_attributes|
end
describe '#update_counters_with_lease' do
- let(:increments) { { build_artifacts_size: 1, packages_size: 2 } }
+ let_it_be(:first_attribute) { counter_attributes.first }
+ let_it_be(:second_attribute) { counter_attributes.second }
+
+ let_it_be(:increments) do
+ increments_hash = {}
+
+ increments_hash[first_attribute] = 1
+ increments_hash[second_attribute] = 2
+
+ increments_hash
+ end
subject { model.update_counters_with_lease(increments) }
it 'updates counters of the record' do
expect { subject }
- .to change { model.reload.build_artifacts_size }.by(1)
- .and change { model.reload.packages_size }.by(2)
+ .to change { model.reload.send(first_attribute) }.by(1)
+ .and change { model.reload.send(second_attribute) }.by(2)
end
it_behaves_like 'obtaining lease to update database' do
@@ -193,17 +235,4 @@ RSpec.shared_examples 'obtaining lease to update database' do
expect { subject }.not_to raise_error
end
end
-
- context 'when feature flag counter_attribute_db_lease_for_update is disabled' do
- before do
- stub_feature_flags(counter_attribute_db_lease_for_update: false)
- allow(model).to receive(:in_lock).and_call_original
- end
-
- it 'does not attempt to get a lock' do
- expect(model).not_to receive(:in_lock)
-
- subject
- end
- end
end
diff --git a/spec/support/shared_examples/models/concerns/integrations/base_slack_notification_shared_examples.rb b/spec/support/shared_examples/models/concerns/integrations/base_slack_notification_shared_examples.rb
index 2e528f7996c..2dad35dc46e 100644
--- a/spec/support/shared_examples/models/concerns/integrations/base_slack_notification_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/integrations/base_slack_notification_shared_examples.rb
@@ -35,7 +35,6 @@ RSpec.shared_examples Integrations::BaseSlackNotification do |factory:|
end
it_behaves_like 'Snowplow event tracking with RedisHLL context' do
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:category) { described_class.to_s }
let(:action) { 'perform_integrations_action' }
let(:namespace) { project.namespace }
diff --git a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb
index 0ef9ab25505..28d2d4f1597 100644
--- a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb
@@ -465,10 +465,13 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
context 'when commit comment event executed' do
let(:commit_note) do
- create(:note_on_commit, author: user,
- project: project,
- commit_id: project.repository.commit.id,
- note: 'a comment on a commit')
+ create(
+ :note_on_commit,
+ author: user,
+ project: project,
+ commit_id: project.repository.commit.id,
+ note: 'a comment on a commit'
+ )
end
let(:data) do
@@ -480,8 +483,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
context 'when merge request comment event executed' do
let(:merge_request_note) do
- create(:note_on_merge_request, project: project,
- note: 'a comment on a merge request')
+ create(:note_on_merge_request, project: project, note: 'a comment on a merge request')
end
let(:data) do
@@ -493,8 +495,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
context 'when issue comment event executed' do
let(:issue_note) do
- create(:note_on_issue, project: project,
- note: 'a comment on an issue')
+ create(:note_on_issue, project: project, note: 'a comment on an issue')
end
let(:data) do
@@ -506,8 +507,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
context 'when snippet comment event executed' do
let(:snippet_note) do
- create(:note_on_project_snippet, project: project,
- note: 'a comment on a snippet')
+ create(:note_on_project_snippet, project: project, note: 'a comment on a snippet')
end
let(:data) do
@@ -522,9 +522,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
let_it_be(:user) { create(:user) }
let_it_be_with_refind(:project) { create(:project, :repository, creator: user) }
let(:pipeline) do
- create(:ci_pipeline,
- project: project, status: status,
- sha: project.commit.sha, ref: project.default_branch)
+ create(:ci_pipeline, project: project, status: status, sha: project.commit.sha, ref: project.default_branch)
end
before do
@@ -557,9 +555,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
context 'with failed pipeline' do
context 'on default branch' do
let(:pipeline) do
- create(:ci_pipeline,
- project: project, status: :failed,
- sha: project.commit.sha, ref: project.default_branch)
+ create(:ci_pipeline, project: project, status: :failed, sha: project.commit.sha, ref: project.default_branch)
end
let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) }
@@ -587,9 +583,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
end
let(:pipeline) do
- create(:ci_pipeline,
- project: project, status: :failed,
- sha: project.commit.sha, ref: 'a-protected-branch')
+ create(:ci_pipeline, project: project, status: :failed, sha: project.commit.sha, ref: 'a-protected-branch')
end
let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) }
@@ -617,9 +611,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
end
let(:pipeline) do
- create(:ci_pipeline,
- project: project, status: :failed,
- sha: project.commit.sha, ref: '1-stable')
+ create(:ci_pipeline, project: project, status: :failed, sha: project.commit.sha, ref: '1-stable')
end
let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) }
@@ -643,9 +635,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
context 'on a neither protected nor default branch' do
let(:pipeline) do
- create(:ci_pipeline,
- project: project, status: :failed,
- sha: project.commit.sha, ref: 'a-random-branch')
+ create(:ci_pipeline, project: project, status: :failed, sha: project.commit.sha, ref: 'a-random-branch')
end
let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) }
diff --git a/spec/support/shared_examples/models/concerns/protected_branch_access_examples.rb b/spec/support/shared_examples/models/concerns/protected_branch_access_examples.rb
new file mode 100644
index 00000000000..dd27ff3844f
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/protected_branch_access_examples.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'protected branch access' do
+ include_examples 'protected ref access', :protected_branch
+
+ it { is_expected.to belong_to(:protected_branch) }
+
+ describe '#project' do
+ before do
+ allow(protected_ref).to receive(:project)
+ end
+
+ it 'delegates project to protected_branch association' do
+ described_class.new(protected_branch: protected_ref).project
+
+ expect(protected_ref).to have_received(:project)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/protected_ref_access_allowed_access_levels_examples.rb b/spec/support/shared_examples/models/concerns/protected_ref_access_allowed_access_levels_examples.rb
new file mode 100644
index 00000000000..8e15720c79a
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/protected_ref_access_allowed_access_levels_examples.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'protected ref access allowed_access_levels' do |excludes: []|
+ describe '::allowed_access_levels' do
+ subject { described_class.allowed_access_levels }
+
+ let(:all_levels) do
+ [
+ Gitlab::Access::DEVELOPER,
+ Gitlab::Access::MAINTAINER,
+ Gitlab::Access::ADMIN,
+ Gitlab::Access::NO_ACCESS
+ ]
+ end
+
+ context 'when running on Gitlab.com?' do
+ let(:levels) { all_levels.excluding(Gitlab::Access::ADMIN, *excludes) }
+
+ before do
+ allow(Gitlab).to receive(:com?).and_return(true)
+ end
+
+ it { is_expected.to match_array(levels) }
+ end
+
+ context 'when self hosted?' do
+ let(:levels) { all_levels.excluding(*excludes) }
+
+ before do
+ allow(Gitlab).to receive(:com?).and_return(false)
+ end
+
+ it { is_expected.to match_array(levels) }
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/protected_ref_access_examples.rb b/spec/support/shared_examples/models/concerns/protected_ref_access_examples.rb
new file mode 100644
index 00000000000..4753d7a4556
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/protected_ref_access_examples.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'protected ref access' do |association|
+ include ExternalAuthorizationServiceHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:protected_ref) { create(association, project: project) } # rubocop:disable Rails/SaveBang
+
+ it { is_expected.to validate_inclusion_of(:access_level).in_array(described_class.allowed_access_levels) }
+
+ it { is_expected.to validate_presence_of(:access_level) }
+
+ context 'when not role?' do
+ before do
+ allow(subject).to receive(:role?).and_return(false)
+ end
+
+ it { is_expected.not_to validate_presence_of(:access_level) }
+ end
+
+ describe '::human_access_levels' do
+ subject { described_class.human_access_levels }
+
+ let(:levels) do
+ {
+ Gitlab::Access::DEVELOPER => "Developers + Maintainers",
+ Gitlab::Access::MAINTAINER => "Maintainers",
+ Gitlab::Access::ADMIN => 'Instance admins',
+ Gitlab::Access::NO_ACCESS => "No one"
+ }.slice(*described_class.allowed_access_levels)
+ end
+
+ it { is_expected.to eq(levels) }
+ end
+
+ describe '#check_access' do
+ let_it_be(:current_user) { create(:user) }
+
+ let(:access_level) { ::Gitlab::Access::DEVELOPER }
+
+ before_all do
+ project.add_developer(current_user)
+ end
+
+ subject do
+ described_class.new(
+ association => protected_ref,
+ access_level: access_level
+ )
+ end
+
+ context 'when current_user is nil' do
+ it { expect(subject.check_access(nil)).to eq(false) }
+ end
+
+ context 'when access_level is NO_ACCESS' do
+ let(:access_level) { ::Gitlab::Access::NO_ACCESS }
+
+ it { expect(subject.check_access(current_user)).to eq(false) }
+ end
+
+ context 'when instance admin access is configured' do
+ let(:access_level) { Gitlab::Access::ADMIN }
+
+ context 'when current_user is a maintainer' do
+ it { expect(subject.check_access(current_user)).to eq(false) }
+ end
+
+ context 'when current_user is admin' do
+ before do
+ allow(current_user).to receive(:admin?).and_return(true)
+ end
+
+ it { expect(subject.check_access(current_user)).to eq(true) }
+ end
+ end
+
+ context 'when current_user can push_code to project' do
+ context 'and member access is high enough' do
+ it { expect(subject.check_access(current_user)).to eq(true) }
+
+ context 'when external authorization denies access' do
+ before do
+ external_service_deny_access(current_user, project)
+ end
+
+ it { expect(subject.check_access(current_user)).to be_falsey }
+ end
+ end
+
+ context 'and member access is too low' do
+ let(:access_level) { ::Gitlab::Access::MAINTAINER }
+
+ it { expect(subject.check_access(current_user)).to eq(false) }
+ end
+ end
+
+ context 'when current_user cannot push_code to project' do
+ before do
+ allow(current_user).to receive(:can?).with(:push_code, project).and_return(false)
+ end
+
+ it { expect(subject.check_access(current_user)).to eq(false) }
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/protected_tag_access_examples.rb b/spec/support/shared_examples/models/concerns/protected_tag_access_examples.rb
new file mode 100644
index 00000000000..49f616d5a59
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/protected_tag_access_examples.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'protected tag access' do
+ include_examples 'protected ref access', :protected_tag
+
+ let_it_be(:protected_tag) { create(:protected_tag) }
+
+ it { is_expected.to belong_to(:protected_tag) }
+
+ describe '#project' do
+ before do
+ allow(protected_tag).to receive(:project)
+ end
+
+ it 'delegates project to protected_tag association' do
+ described_class.new(protected_tag: protected_tag).project
+
+ expect(protected_tag).to have_received(:project)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
index e4958779957..b04ac40b309 100644
--- a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
@@ -84,9 +84,12 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
let(:max_date) { mid_point + 10.days }
def box(from, to)
- create(factory, *timebox_args,
- start_date: from || open_on_left,
- due_date: to || open_on_right)
+ create(
+ factory,
+ *timebox_args,
+ start_date: from || open_on_left,
+ due_date: to || open_on_right
+ )
end
it 'can find overlapping timeboxes' do
diff --git a/spec/support/shared_examples/models/concerns/unstoppable_hooks_shared_examples.rb b/spec/support/shared_examples/models/concerns/unstoppable_hooks_shared_examples.rb
index 848840ee297..f98528ffedc 100644
--- a/spec/support/shared_examples/models/concerns/unstoppable_hooks_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/unstoppable_hooks_shared_examples.rb
@@ -18,8 +18,12 @@ RSpec.shared_examples 'a hook that does not get automatically disabled on failur
[3, nil],
[3, 1.day.ago]
].map do |(recent_failures, disabled_until)|
- create(hook_factory, **default_factory_arguments, recent_failures: recent_failures,
-disabled_until: disabled_until)
+ create(
+ hook_factory,
+ **default_factory_arguments,
+ recent_failures: recent_failures,
+ disabled_until: disabled_until
+ )
end
end
@@ -110,7 +114,7 @@ disabled_until: disabled_until)
context 'when we have exhausted the grace period' do
before do
- hook.update!(recent_failures: WebHook::FAILURE_THRESHOLD)
+ hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD)
end
it 'does not disable the hook' do
@@ -131,7 +135,7 @@ disabled_until: disabled_until)
expect(hook).not_to be_temporarily_disabled
# Backing off
- WebHook::FAILURE_THRESHOLD.times do
+ WebHooks::AutoDisabling::FAILURE_THRESHOLD.times do
hook.backoff!
expect(hook).not_to be_temporarily_disabled
end
@@ -167,7 +171,7 @@ disabled_until: disabled_until)
context 'when hook has been backed off' do
before do
- hook.update!(recent_failures: WebHook::FAILURE_THRESHOLD + 1)
+ hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD + 1)
hook.disabled_until = 1.hour.from_now
end
diff --git a/spec/support/shared_examples/models/concerns/web_hooks/has_web_hooks_shared_examples.rb b/spec/support/shared_examples/models/concerns/web_hooks/has_web_hooks_shared_examples.rb
index cd6eb8c77fa..113dcc266fc 100644
--- a/spec/support/shared_examples/models/concerns/web_hooks/has_web_hooks_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/web_hooks/has_web_hooks_shared_examples.rb
@@ -19,7 +19,7 @@ RSpec.shared_examples 'something that has web-hooks' do
context 'when there is a failed hook' do
before do
hook = create_hook
- hook.update!(recent_failures: WebHook::FAILURE_THRESHOLD + 1)
+ hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD + 1)
end
it { is_expected.to eq(true) }
@@ -83,7 +83,7 @@ RSpec.shared_examples 'something that has web-hooks' do
describe '#fetch_web_hook_failure', :clean_gitlab_redis_shared_state do
context 'when a value has not been stored' do
- it 'does not call #any_hook_failed?' do
+ it 'calls #any_hook_failed?' do
expect(object.get_web_hook_failure).to be_nil
expect(object).to receive(:any_hook_failed?).and_return(true)
diff --git a/spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb b/spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb
index 5eeefacdeb9..3f532629961 100644
--- a/spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb
+++ b/spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb
@@ -290,6 +290,7 @@ RSpec.shared_examples 'value stream analytics label based stage' do
context 'when `ProjectLabel is given' do
let_it_be(:label) { create(:label) }
+ let(:expected_error) { s_('CycleAnalyticsStage|is not available for the selected group') }
it 'raises error when `ProjectLabel` is given for `start_event_label`' do
params = {
@@ -300,7 +301,9 @@ RSpec.shared_examples 'value stream analytics label based stage' do
end_event_identifier: :issue_closed
}
- expect { described_class.new(params) }.to raise_error(ActiveRecord::AssociationTypeMismatch)
+ stage = described_class.new(params)
+ expect(stage).to be_invalid
+ expect(stage.errors.messages_for(:start_event_label_id)).to eq([expected_error])
end
it 'raises error when `ProjectLabel` is given for `end_event_label`' do
@@ -312,7 +315,9 @@ RSpec.shared_examples 'value stream analytics label based stage' do
end_event_label: label
}
- expect { described_class.new(params) }.to raise_error(ActiveRecord::AssociationTypeMismatch)
+ stage = described_class.new(params)
+ expect(stage).to be_invalid
+ expect(stage.errors.messages_for(:end_event_label_id)).to eq([expected_error])
end
end
end
diff --git a/spec/support/shared_examples/models/database_event_tracking_shared_examples.rb b/spec/support/shared_examples/models/database_event_tracking_shared_examples.rb
new file mode 100644
index 00000000000..3d98d9136e2
--- /dev/null
+++ b/spec/support/shared_examples/models/database_event_tracking_shared_examples.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'database events tracking' do
+ describe 'events tracking' do
+ # required definitions:
+ # :record, :update_params
+ #
+ # other available attributes:
+ # :project, :namespace
+
+ let(:user) { nil }
+ let(:category) { described_class.to_s }
+ let(:label) { described_class.table_name }
+ let(:action) { "database_event_#{property}" }
+ let(:feature_flag_name) { :product_intelligence_database_event_tracking }
+ let(:record_tracked_attributes) { record.attributes.slice(*described_class::SNOWPLOW_ATTRIBUTES.map(&:to_s)) }
+ let(:base_extra) { record_tracked_attributes.merge(project: try(:project), namespace: try(:namespace)) }
+
+ before do
+ allow(Gitlab::Tracking).to receive(:database_event).and_call_original
+ end
+
+ describe '#create' do
+ it_behaves_like 'Snowplow event tracking', overrides: { tracking_method: :database_event } do
+ subject(:create_record) { record }
+
+ let(:extra) { base_extra }
+ let(:property) { 'create' }
+ end
+ end
+
+ describe '#update', :freeze_time do
+ it_behaves_like 'Snowplow event tracking', overrides: { tracking_method: :database_event } do
+ subject(:update_record) { record.update!(update_params) }
+
+ let(:extra) { base_extra.merge(update_params.stringify_keys) }
+ let(:property) { 'update' }
+ end
+ end
+
+ describe '#destroy' do
+ it_behaves_like 'Snowplow event tracking', overrides: { tracking_method: :database_event } do
+ subject(:delete_record) { record.destroy! }
+
+ let(:extra) { base_extra }
+ let(:property) { 'destroy' }
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'database events tracking batch 2' do
+ it_behaves_like 'database events tracking' do
+ let(:feature_flag_name) { :product_intelligence_database_event_tracking_batch2 }
+ end
+end
diff --git a/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb b/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb
index 64390ccdc25..f1f6d799cf3 100644
--- a/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb
+++ b/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb
@@ -10,10 +10,12 @@ RSpec.shared_examples 'a valid diff note with after commit callback' do
it 'raises an error' do
allow(diff_file_from_repository).to receive(:line_for_position).with(position).and_return(nil)
- expect { subject.save! }.to raise_error(::DiffNote::NoteDiffFileCreationError,
- "Failed to find diff line for: #{diff_file_from_repository.file_path}, "\
- "old_line: #{position.old_line}"\
- ", new_line: #{position.new_line}")
+ expect { subject.save! }.to raise_error(
+ ::DiffNote::NoteDiffFileCreationError,
+ "Failed to find diff line for: #{diff_file_from_repository.file_path}, "\
+ "old_line: #{position.old_line}"\
+ ", new_line: #{position.new_line}"
+ )
end
end
diff --git a/spec/support/shared_examples/models/integrations/base_slash_commands_shared_examples.rb b/spec/support/shared_examples/models/integrations/base_slash_commands_shared_examples.rb
index 7dfdd24177e..0cf109ce5c5 100644
--- a/spec/support/shared_examples/models/integrations/base_slash_commands_shared_examples.rb
+++ b/spec/support/shared_examples/models/integrations/base_slash_commands_shared_examples.rb
@@ -3,7 +3,6 @@
RSpec.shared_examples Integrations::BaseSlashCommands do
describe "Associations" do
it { is_expected.to respond_to :token }
- it { is_expected.to have_many :chat_names }
end
describe 'default values' do
@@ -85,7 +84,7 @@ RSpec.shared_examples Integrations::BaseSlashCommands do
end
context 'when the user is authenticated' do
- let!(:chat_name) { create(:chat_name, integration: subject) }
+ let!(:chat_name) { create(:chat_name) }
let(:params) { { token: 'token', team_id: chat_name.team_id, user_id: chat_name.chat_id } }
subject do
diff --git a/spec/support/shared_examples/models/issue_tracker_service_shared_examples.rb b/spec/support/shared_examples/models/issue_tracker_service_shared_examples.rb
index 6d519e561ee..d438918eb60 100644
--- a/spec/support/shared_examples/models/issue_tracker_service_shared_examples.rb
+++ b/spec/support/shared_examples/models/issue_tracker_service_shared_examples.rb
@@ -10,19 +10,19 @@ end
RSpec.shared_examples 'allows project key on reference pattern' do |url_attr|
it 'allows underscores in the project name' do
- expect(described_class.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
+ expect(subject.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
end
it 'allows numbers in the project name' do
- expect(described_class.reference_pattern.match('EXT3_EXT-1234')[0]).to eq 'EXT3_EXT-1234'
+ expect(subject.reference_pattern.match('EXT3_EXT-1234')[0]).to eq 'EXT3_EXT-1234'
end
it 'requires the project name to begin with A-Z' do
- expect(described_class.reference_pattern.match('3EXT_EXT-1234')).to eq nil
- expect(described_class.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
+ expect(subject.reference_pattern.match('3EXT_EXT-1234')).to eq nil
+ expect(subject.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
end
it 'does not allow issue number to finish with a letter' do
- expect(described_class.reference_pattern.match('EXT-123A')).to eq(nil)
+ expect(subject.reference_pattern.match('EXT-123A')).to eq(nil)
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 7159c55e303..e9e25dee746 100644
--- a/spec/support/shared_examples/models/member_shared_examples.rb
+++ b/spec/support/shared_examples/models/member_shared_examples.rb
@@ -392,6 +392,30 @@ RSpec.shared_examples_for "bulk member creation" do
expect(members.first).to be_invite
end
+ context 'with different source types' do
+ shared_examples 'supports multiple sources' do
+ specify do
+ members = described_class.add_members(sources, [user1, user2], :maintainer)
+
+ expect(members.map(&:user)).to contain_exactly(user1, user2, user1, user2)
+ expect(members).to all(be_a(member_type))
+ expect(members).to all(be_persisted)
+ end
+ end
+
+ context 'with an array of sources' do
+ let_it_be(:sources) { [source, source2] }
+
+ it_behaves_like 'supports multiple sources'
+ end
+
+ context 'with a query producing sources' do
+ let_it_be(:sources) { source_type.id_in([source, source2]) }
+
+ it_behaves_like 'supports multiple sources'
+ end
+ end
+
context 'with de-duplication' do
it 'has the same user by id and user' do
members = described_class.add_members(source, [user1.id, user1, user1.id, user2, user2.id, user2], :maintainer)
@@ -484,11 +508,13 @@ RSpec.shared_examples_for "bulk member creation" do
create(:member_task, member: member, project: task_project, tasks_to_be_done: %w(code ci))
expect do
- described_class.add_members(source,
- [user1.id],
- :developer,
- tasks_to_be_done: %w(issues),
- tasks_project_id: task_project.id)
+ described_class.add_members(
+ source,
+ [user1.id],
+ :developer,
+ tasks_to_be_done: %w(issues),
+ tasks_project_id: task_project.id
+ )
end.not_to change { MemberTask.count }
member.reset
@@ -498,11 +524,13 @@ RSpec.shared_examples_for "bulk member creation" do
it 'adds tasks to be done if they do not exist', :aggregate_failures do
expect do
- described_class.add_members(source,
- [user1.id],
- :developer,
- tasks_to_be_done: %w(issues),
- tasks_project_id: task_project.id)
+ described_class.add_members(
+ source,
+ [user1.id],
+ :developer,
+ tasks_to_be_done: %w(issues),
+ tasks_project_id: task_project.id
+ )
end.to change { MemberTask.count }.by(1)
member = source.members.find_by(user_id: user1.id)
diff --git a/spec/support/shared_examples/models/members_notifications_shared_example.rb b/spec/support/shared_examples/models/members_notifications_shared_example.rb
index e28220334ac..329cb812a08 100644
--- a/spec/support/shared_examples/models/members_notifications_shared_example.rb
+++ b/spec/support/shared_examples/models/members_notifications_shared_example.rb
@@ -69,7 +69,7 @@ RSpec.shared_examples 'members notifications' do |entity_type|
let(:member) { create(:"#{entity_type}_member", :invited) }
it "calls NotificationService.decline_#{entity_type}_invite" do
- expect(notification_service).to receive(:"decline_#{entity_type}_invite").with(member)
+ expect(notification_service).to receive(:decline_invite).with(member)
member.decline_invite!
end
diff --git a/spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb b/spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb
index 5be0f6349ea..c2c123277ee 100644
--- a/spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb
+++ b/spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb
@@ -20,7 +20,6 @@ RSpec.shared_examples 'Debian Component File' do |container_type, can_freeze|
let_it_be(:component_file_other_architecture, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_2) }
let_it_be(:component_file_other_component, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_2, architecture: architecture1_1) }
let_it_be(:component_file_other_compression_type, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_1, compression_type: :xz) }
- let_it_be(:component_file_other_file_md5, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_1, file_md5: 'other_md5') }
let_it_be(:component_file_other_file_sha256, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component1_1, architecture: architecture1_1, file_sha256: 'other_sha256') }
let_it_be(:component_file_other_container, freeze: can_freeze) { create("debian_#{container_type}_component_file", component: component2_1, architecture: architecture2_1) }
let_it_be_with_refind(:component_file_with_file_type_sources) { create("debian_#{container_type}_component_file", :sources, component: component1_1) }
@@ -100,10 +99,6 @@ RSpec.shared_examples 'Debian Component File' do |container_type, can_freeze|
it { is_expected.to validate_presence_of(:file_store) }
end
- describe '#file_md5' do
- it { is_expected.to validate_presence_of(:file_md5) }
- end
-
describe '#file_sha256' do
it { is_expected.to validate_presence_of(:file_sha256) }
end
@@ -231,4 +226,20 @@ RSpec.shared_examples 'Debian Component File' do |container_type, can_freeze|
it { is_expected.to eq("#{component1_1.name}/binary-#{architecture1_1.name}/Packages.xz") }
end
end
+
+ describe '#empty?' do
+ subject { component_file_with_architecture.empty? }
+
+ context 'with a non-empty component' do
+ it { is_expected.to be_falsey }
+ end
+
+ context 'with an empty component' do
+ before do
+ component_file_with_architecture.update! size: 0
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
end
diff --git a/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb b/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb
index ac4ad4525aa..3ea2ff4d8f0 100644
--- a/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb
+++ b/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb
@@ -1,32 +1,13 @@
# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.shared_examples 'Debian Distribution' do |factory, container, can_freeze|
- let_it_be(:distribution_with_suite, freeze: can_freeze) { create(factory, :with_suite) }
- let_it_be(:distribution_with_same_container, freeze: can_freeze) { create(factory, container: distribution_with_suite.container ) }
- let_it_be(:distribution_with_same_codename, freeze: can_freeze) { create(factory, codename: distribution_with_suite.codename ) }
- let_it_be(:distribution_with_same_suite, freeze: can_freeze) { create(factory, suite: distribution_with_suite.suite ) }
- let_it_be(:distribution_with_codename_and_suite_flipped, freeze: can_freeze) { create(factory, codename: distribution_with_suite.suite, suite: distribution_with_suite.codename) }
-
- let_it_be_with_refind(:distribution) { create(factory, container: distribution_with_suite.container ) }
-
+RSpec.shared_examples 'Debian Distribution for common behavior' do
subject { distribution }
describe 'relationships' do
- it { is_expected.to belong_to(container) }
it { is_expected.to belong_to(:creator).class_name('User') }
-
- it { is_expected.to have_one(:key).class_name("Packages::Debian::#{container.capitalize}DistributionKey").with_foreign_key(:distribution_id).inverse_of(:distribution) }
- it { is_expected.to have_many(:components).class_name("Packages::Debian::#{container.capitalize}Component").inverse_of(:distribution) }
- it { is_expected.to have_many(:architectures).class_name("Packages::Debian::#{container.capitalize}Architecture").inverse_of(:distribution) }
end
describe 'validations' do
- describe "##{container}" do
- it { is_expected.to validate_presence_of(container) }
- end
-
describe "#creator" do
it { is_expected.not_to validate_presence_of(:creator) }
end
@@ -47,57 +28,6 @@ RSpec.shared_examples 'Debian Distribution' do |factory, container, can_freeze|
it { is_expected.not_to allow_value('hé').for(:suite) }
end
- describe '#unique_debian_suite_and_codename' do
- using RSpec::Parameterized::TableSyntax
-
- where(:with_existing_suite, :suite, :codename, :errors) do
- false | nil | :keep | nil
- false | 'testing' | :keep | nil
- false | nil | :codename | ["Codename has already been taken"]
- false | :codename | :keep | ["Suite has already been taken as Codename"]
- false | :codename | :codename | ["Codename has already been taken", "Suite has already been taken as Codename"]
- true | nil | :keep | nil
- true | 'testing' | :keep | nil
- true | nil | :codename | ["Codename has already been taken"]
- true | :codename | :keep | ["Suite has already been taken as Codename"]
- true | :codename | :codename | ["Codename has already been taken", "Suite has already been taken as Codename"]
- true | nil | :suite | ["Codename has already been taken as Suite"]
- true | :suite | :keep | ["Suite has already been taken"]
- true | :suite | :suite | ["Suite has already been taken", "Codename has already been taken as Suite"]
- end
-
- with_them do
- context factory do
- let(:new_distribution) { build(factory, container: distribution.container) }
-
- before do
- distribution.update_column(:suite, 'suite-' + distribution.codename) if with_existing_suite
-
- if suite.is_a?(Symbol)
- new_distribution.suite = distribution.send suite unless suite == :keep
- else
- new_distribution.suite = suite
- end
-
- if codename.is_a?(Symbol)
- new_distribution.codename = distribution.send codename unless codename == :keep
- else
- new_distribution.codename = codename
- end
- end
-
- it do
- if errors
- expect(new_distribution).not_to be_valid
- expect(new_distribution.errors.to_a).to eq(errors)
- else
- expect(new_distribution).to be_valid
- end
- end
- end
- end
- end
-
describe '#origin' do
it { is_expected.to allow_value(nil).for(:origin) }
it { is_expected.to allow_value('Debian').for(:origin) }
@@ -179,7 +109,11 @@ RSpec.shared_examples 'Debian Distribution' do |factory, container, can_freeze|
subject { described_class.with_codename_or_suite(distribution_with_suite.codename) }
it 'does not return other distributions' do
- expect(subject.to_a).to contain_exactly(distribution_with_suite, distribution_with_same_codename, distribution_with_codename_and_suite_flipped)
+ expect(subject.to_a)
+ .to contain_exactly(
+ distribution_with_suite,
+ distribution_with_same_codename,
+ distribution_with_codename_and_suite_flipped)
end
end
@@ -187,54 +121,169 @@ RSpec.shared_examples 'Debian Distribution' do |factory, container, can_freeze|
subject { described_class.with_codename_or_suite(distribution_with_suite.suite) }
it 'does not return other distributions' do
- expect(subject.to_a).to contain_exactly(distribution_with_suite, distribution_with_same_suite, distribution_with_codename_and_suite_flipped)
+ expect(subject.to_a)
+ .to contain_exactly(
+ distribution_with_suite,
+ distribution_with_same_suite,
+ distribution_with_codename_and_suite_flipped)
end
end
end
end
+end
- if container == :project
- describe 'project distribution specifics' do
- describe 'relationships' do
- it { is_expected.to have_many(:publications).class_name('Packages::Debian::Publication').inverse_of(:distribution).with_foreign_key(:distribution_id) }
- it { is_expected.to have_many(:packages).class_name('Packages::Package').through(:publications) }
- end
+RSpec.shared_examples 'Debian Distribution for specific behavior' do |factory|
+ describe '#unique_debian_suite_and_codename' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:with_existing_suite, :suite, :codename, :errors) do
+ false | nil | :keep | nil
+ false | 'testing' | :keep | nil
+ false | nil | :codename | ["Codename has already been taken"]
+ false | :codename | :keep | ["Suite has already been taken as Codename"]
+ false | :codename | :codename | ["Codename has already been taken", "Suite has already been taken as Codename"]
+ true | nil | :keep | nil
+ true | 'testing' | :keep | nil
+ true | nil | :codename | ["Codename has already been taken"]
+ true | :codename | :keep | ["Suite has already been taken as Codename"]
+ true | :codename | :codename | ["Codename has already been taken", "Suite has already been taken as Codename"]
+ true | nil | :suite | ["Codename has already been taken as Suite"]
+ true | :suite | :keep | ["Suite has already been taken"]
+ true | :suite | :suite | ["Suite has already been taken", "Codename has already been taken as Suite"]
end
- else
- describe 'group distribution specifics' do
- let_it_be(:public_project) { create(:project, :public, group: distribution_with_suite.container) }
- let_it_be(:public_distribution_with_same_codename) { create(:debian_project_distribution, container: public_project, codename: distribution_with_suite.codename) }
- let_it_be(:public_package_with_same_codename) { create(:debian_package, project: public_project, published_in: public_distribution_with_same_codename) }
- let_it_be(:public_distribution_with_same_suite) { create(:debian_project_distribution, container: public_project, suite: distribution_with_suite.suite) }
- let_it_be(:public_package_with_same_suite) { create(:debian_package, project: public_project, published_in: public_distribution_with_same_suite) }
-
- let_it_be(:private_project) { create(:project, :private, group: distribution_with_suite.container) }
- let_it_be(:private_distribution_with_same_codename) { create(:debian_project_distribution, container: private_project, codename: distribution_with_suite.codename) }
- let_it_be(:private_package_with_same_codename) { create(:debian_package, project: private_project, published_in: private_distribution_with_same_codename) }
- let_it_be(:private_distribution_with_same_suite) { create(:debian_project_distribution, container: private_project, suite: distribution_with_suite.suite) }
- let_it_be(:private_package_with_same_suite) { create(:debian_package, project: private_project, published_in: private_distribution_with_same_codename) }
-
- describe '#packages' do
- subject { distribution_with_suite.packages }
-
- it 'returns only public packages with same codename' do
- expect(subject.to_a).to contain_exactly(public_package_with_same_codename)
+
+ with_them do
+ context factory do
+ let(:new_distribution) { build(factory, container: distribution.container) }
+
+ before do
+ distribution.update_column(:suite, "suite-#{distribution.codename}") if with_existing_suite
+
+ if suite.is_a?(Symbol)
+ new_distribution.suite = distribution.send suite unless suite == :keep
+ else
+ new_distribution.suite = suite
+ end
+
+ if codename.is_a?(Symbol)
+ new_distribution.codename = distribution.send codename unless codename == :keep
+ else
+ new_distribution.codename = codename
+ end
+ end
+
+ it do
+ if errors
+ expect(new_distribution).not_to be_valid
+ expect(new_distribution.errors.to_a).to eq(errors)
+ else
+ expect(new_distribution).to be_valid
+ end
end
end
+ end
+ end
+end
- describe '#package_files' do
- subject { distribution_with_suite.package_files }
+RSpec.shared_examples 'Debian Distribution with project container' do
+ it_behaves_like 'Debian Distribution for specific behavior', :debian_project_distribution
- it 'returns only files from public packages with same codename' do
- expect(subject.to_a).to contain_exactly(*public_package_with_same_codename.package_files)
- end
+ describe 'relationships' do
+ it { is_expected.to belong_to(:project) }
- context 'with pending destruction package files' do
- let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: public_package_with_same_codename) }
+ it { is_expected.to have_one(:key).class_name("Packages::Debian::ProjectDistributionKey").with_foreign_key(:distribution_id).inverse_of(:distribution) }
+ it { is_expected.to have_many(:components).class_name("Packages::Debian::ProjectComponent").inverse_of(:distribution) }
+ it { is_expected.to have_many(:architectures).class_name("Packages::Debian::ProjectArchitecture").inverse_of(:distribution) }
+ end
- it 'does not return them' do
- expect(subject.to_a).not_to include(package_file_pending_destruction)
- end
+ describe "#project" do
+ it { is_expected.to validate_presence_of(:project) }
+ end
+
+ describe 'project distribution specifics' do
+ describe 'relationships' do
+ it do
+ is_expected.to have_many(:publications).class_name('Packages::Debian::Publication').inverse_of(:distribution)
+ .with_foreign_key(:distribution_id)
+ end
+
+ it { is_expected.to have_many(:packages).class_name('Packages::Package').through(:publications) }
+ end
+ end
+end
+
+RSpec.shared_examples 'Debian Distribution with group container' do
+ it_behaves_like 'Debian Distribution for specific behavior', :debian_group_distribution
+
+ describe 'relationships' do
+ it { is_expected.to belong_to(:group) }
+
+ it { is_expected.to have_one(:key).class_name("Packages::Debian::GroupDistributionKey").with_foreign_key(:distribution_id).inverse_of(:distribution) }
+ it { is_expected.to have_many(:components).class_name("Packages::Debian::GroupComponent").inverse_of(:distribution) }
+ it { is_expected.to have_many(:architectures).class_name("Packages::Debian::GroupArchitecture").inverse_of(:distribution) }
+ end
+
+ describe "#group" do
+ it { is_expected.to validate_presence_of(:group) }
+ end
+
+ describe 'group distribution specifics' do
+ let_it_be(:public_project) { create(:project, :public, group: distribution_with_suite.container) }
+ let_it_be(:public_distribution_with_same_codename) do
+ create(:debian_project_distribution, container: public_project, codename: distribution_with_suite.codename)
+ end
+
+ let_it_be(:public_package_with_same_codename) do
+ create(:debian_package, project: public_project, published_in: public_distribution_with_same_codename)
+ end
+
+ let_it_be(:public_distribution_with_same_suite) do
+ create(:debian_project_distribution, container: public_project, suite: distribution_with_suite.suite)
+ end
+
+ let_it_be(:public_package_with_same_suite) do
+ create(:debian_package, project: public_project, published_in: public_distribution_with_same_suite)
+ end
+
+ let_it_be(:private_project) { create(:project, :private, group: distribution_with_suite.container) }
+ let_it_be(:private_distribution_with_same_codename) do
+ create(:debian_project_distribution, container: private_project, codename: distribution_with_suite.codename)
+ end
+
+ let_it_be(:private_package_with_same_codename) do
+ create(:debian_package, project: private_project, published_in: private_distribution_with_same_codename)
+ end
+
+ let_it_be(:private_distribution_with_same_suite) do
+ create(:debian_project_distribution, container: private_project, suite: distribution_with_suite.suite)
+ end
+
+ let_it_be(:private_package_with_same_suite) do
+ create(:debian_package, project: private_project, published_in: private_distribution_with_same_codename)
+ end
+
+ describe '#packages' do
+ subject { distribution_with_suite.packages }
+
+ it 'returns only public packages with same codename' do
+ expect(subject.to_a).to contain_exactly(public_package_with_same_codename)
+ end
+ end
+
+ describe '#package_files' do
+ subject { distribution_with_suite.package_files }
+
+ it 'returns only files from public packages with same codename' do
+ expect(subject.to_a).to contain_exactly(*public_package_with_same_codename.package_files)
+ end
+
+ context 'with pending destruction package files' do
+ let_it_be(:package_file_pending_destruction) do
+ create(:package_file, :pending_destruction, package: public_package_with_same_codename)
+ end
+
+ it 'does not return them' do
+ expect(subject.to_a).not_to include(package_file_pending_destruction)
end
end
end
diff --git a/spec/support/shared_examples/models/project_ci_cd_settings_shared_examples.rb b/spec/support/shared_examples/models/project_ci_cd_settings_shared_examples.rb
index 3caf58da4d2..f1af1760e8d 100644
--- a/spec/support/shared_examples/models/project_ci_cd_settings_shared_examples.rb
+++ b/spec/support/shared_examples/models/project_ci_cd_settings_shared_examples.rb
@@ -19,7 +19,7 @@ RSpec.shared_examples 'ci_cd_settings delegation' do
end
end
-RSpec.shared_examples 'a ci_cd_settings predicate method' do |prefix: ''|
+RSpec.shared_examples 'a ci_cd_settings predicate method' do |prefix: '', default: false|
using RSpec::Parameterized::TableSyntax
context 'when ci_cd_settings is nil' do
@@ -28,7 +28,7 @@ RSpec.shared_examples 'a ci_cd_settings predicate method' do |prefix: ''|
end
it 'returns false' do
- expect(project.send("#{prefix}#{delegated_method}")).to be(false)
+ expect(project.send("#{prefix}#{delegated_method}")).to be(default)
end
end
diff --git a/spec/support/shared_examples/models/resource_event_shared_examples.rb b/spec/support/shared_examples/models/resource_event_shared_examples.rb
index 038ff33c68a..1409f7caea8 100644
--- a/spec/support/shared_examples/models/resource_event_shared_examples.rb
+++ b/spec/support/shared_examples/models/resource_event_shared_examples.rb
@@ -10,6 +10,8 @@ RSpec.shared_examples 'a resource event' do
let_it_be(:issue2) { create(:issue, author: user1) }
let_it_be(:issue3) { create(:issue, author: user2) }
+ let(:resource_event) { described_class.name.demodulize.underscore.to_sym }
+
describe 'importable' do
it { is_expected.to respond_to(:importing?) }
it { is_expected.to respond_to(:imported?) }
@@ -36,9 +38,9 @@ RSpec.shared_examples 'a resource event' do
let!(:created_at2) { 2.days.ago }
let!(:created_at3) { 3.days.ago }
- let!(:event1) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: created_at1) }
- let!(:event2) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: created_at2) }
- let!(:event3) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: created_at3) }
+ let!(:event1) { create(resource_event, issue: issue1, created_at: created_at1) }
+ let!(:event2) { create(resource_event, issue: issue2, created_at: created_at2) }
+ let!(:event3) { create(resource_event, issue: issue2, created_at: created_at3) }
it 'returns the expected events' do
events = described_class.created_after(created_at3)
@@ -62,9 +64,10 @@ RSpec.shared_examples 'a resource event for issues' do
let_it_be(:issue2) { create(:issue, author: user1) }
let_it_be(:issue3) { create(:issue, author: user2) }
- let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1) }
- let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2) }
- let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1) }
+ let_it_be(:resource_event) { described_class.name.demodulize.underscore.to_sym }
+ let_it_be(:event1) { create(resource_event, issue: issue1) }
+ let_it_be(:event2) { create(resource_event, issue: issue2) }
+ let_it_be(:event3) { create(resource_event, issue: issue1) }
describe 'associations' do
it { is_expected.to belong_to(:issue) }
@@ -93,9 +96,9 @@ RSpec.shared_examples 'a resource event for issues' do
end
describe '.by_created_at_earlier_or_equal_to' do
- let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-10') }
- let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: '2020-03-10') }
- let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-12') }
+ let_it_be(:event1) { create(resource_event, issue: issue1, created_at: '2020-03-10') }
+ let_it_be(:event2) { create(resource_event, issue: issue2, created_at: '2020-03-10') }
+ let_it_be(:event3) { create(resource_event, issue: issue1, created_at: '2020-03-12') }
it 'returns the expected events' do
events = described_class.by_created_at_earlier_or_equal_to('2020-03-11 23:59:59')
@@ -112,7 +115,7 @@ RSpec.shared_examples 'a resource event for issues' do
if described_class.method_defined?(:issuable)
describe '#issuable' do
- let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue2) }
+ let_it_be(:event1) { create(resource_event, issue: issue2) }
it 'returns the expected issuable' do
expect(event1.issuable).to eq(issue2)
@@ -125,6 +128,7 @@ RSpec.shared_examples 'a resource event for merge requests' do
let_it_be(:user1) { create(:user) }
let_it_be(:user2) { create(:user) }
+ let_it_be(:resource_event) { described_class.name.demodulize.underscore.to_sym }
let_it_be(:merge_request1) { create(:merge_request, author: user1) }
let_it_be(:merge_request2) { create(:merge_request, author: user1) }
let_it_be(:merge_request3) { create(:merge_request, author: user2) }
@@ -134,9 +138,9 @@ RSpec.shared_examples 'a resource event for merge requests' do
end
describe '.by_merge_request' do
- let_it_be(:event1) { create(described_class.name.underscore.to_sym, merge_request: merge_request1) }
- let_it_be(:event2) { create(described_class.name.underscore.to_sym, merge_request: merge_request2) }
- let_it_be(:event3) { create(described_class.name.underscore.to_sym, merge_request: merge_request1) }
+ let_it_be(:event1) { create(resource_event, merge_request: merge_request1) }
+ let_it_be(:event2) { create(resource_event, merge_request: merge_request2) }
+ let_it_be(:event3) { create(resource_event, merge_request: merge_request1) }
it 'returns the expected records for an issue with events' do
events = described_class.by_merge_request(merge_request1)
@@ -153,7 +157,7 @@ RSpec.shared_examples 'a resource event for merge requests' do
if described_class.method_defined?(:issuable)
describe '#issuable' do
- let_it_be(:event1) { create(described_class.name.underscore.to_sym, merge_request: merge_request2) }
+ let_it_be(:event1) { create(resource_event, merge_request: merge_request2) }
it 'returns the expected issuable' do
expect(event1.issuable).to eq(merge_request2)
@@ -163,7 +167,7 @@ RSpec.shared_examples 'a resource event for merge requests' do
context 'on callbacks' do
it 'does not trigger note created subscription' do
- event = build(described_class.name.underscore.to_sym, merge_request: merge_request1)
+ event = build(resource_event, merge_request: merge_request1)
expect(GraphqlTriggers).not_to receive(:work_item_note_created)
expect(event).not_to receive(:trigger_note_subscription_create)
@@ -177,15 +181,17 @@ RSpec.shared_examples 'a note for work item resource event' do
let_it_be(:project) { create(:project) }
let_it_be(:work_item) { create(:work_item, :task, project: project, author: user) }
+ let(:resource_event) { described_class.name.demodulize.underscore.to_sym }
+
it 'builds synthetic note with correct synthetic_note_class' do
- event = build(described_class.name.underscore.to_sym, issue: work_item)
+ event = build(resource_event, issue: work_item)
expect(event.work_item_synthetic_system_note.class.name).to eq(event.synthetic_note_class.name)
end
context 'on callbacks' do
it 'triggers note created subscription' do
- event = build(described_class.name.underscore.to_sym, issue: work_item)
+ event = build(resource_event, issue: work_item)
expect(GraphqlTriggers).to receive(:work_item_note_created)
expect(event).to receive(:trigger_note_subscription_create).and_call_original
diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb
index 7e69a6663d5..017e51ecd24 100644
--- a/spec/support/shared_examples/models/wiki_shared_examples.rb
+++ b/spec/support/shared_examples/models/wiki_shared_examples.rb
@@ -94,6 +94,40 @@ RSpec.shared_examples 'wiki model' do
end
end
+ describe '#has_home_page?' do
+ context 'when home page exists' do
+ before do
+ wiki.repository.create_file(
+ user,
+ 'home.md',
+ 'home file',
+ branch_name: wiki.default_branch,
+ message: "created home page",
+ author_email: user.email,
+ author_name: user.name
+ )
+ end
+
+ it 'returns true' do
+ expect(wiki.has_home_page?).to eq(true)
+ end
+
+ it 'returns false when #find_page raise an error' do
+ allow(wiki)
+ .to receive(:find_page)
+ .and_raise(StandardError)
+
+ expect(wiki.has_home_page?).to eq(false)
+ end
+ end
+
+ context 'when home page does not exist' do
+ it 'returns false' do
+ expect(wiki.has_home_page?).to eq(false)
+ end
+ end
+ end
+
describe '#to_global_id' do
it 'returns a global ID' do
expect(wiki.to_global_id.to_s).to eq("gid://gitlab/#{wiki.class.name}/#{wiki.id}")
@@ -791,6 +825,21 @@ RSpec.shared_examples 'wiki model' do
end
end
+ context 'when the repository fails to update' do
+ let!(:page) { create(:wiki_page, wiki: subject, title: 'test page') }
+
+ it 'returns false and sets error message', :aggregate_failures do
+ expect(subject.repository)
+ .to receive(:update_file)
+ .and_raise(Gitlab::Git::Index::IndexError.new)
+
+ expect(subject.update_page(page.page, content: 'new content', format: :markdown))
+ .to eq(false)
+ expect(subject.error_message)
+ .to match("Duplicate page: A page with that title already exists")
+ end
+ end
+
context 'when page path does not have a default extension' do
let!(:page) { create(:wiki_page, wiki: subject, title: 'test page') }
diff --git a/spec/support/shared_examples/observability/csp_shared_examples.rb b/spec/support/shared_examples/observability/csp_shared_examples.rb
index 0cd211f69eb..9d6e7e75f4d 100644
--- a/spec/support/shared_examples/observability/csp_shared_examples.rb
+++ b/spec/support/shared_examples/observability/csp_shared_examples.rb
@@ -31,19 +31,19 @@ RSpec.shared_examples 'observability csp policy' do |controller_class = describe
let(:observability_url) { Gitlab::Observability.observability_url }
let(:signin_url) do
Gitlab::Utils.append_path(Gitlab.config.gitlab.url,
- '/users/sign_in')
+ '/users/sign_in')
end
let(:oauth_url) do
Gitlab::Utils.append_path(Gitlab.config.gitlab.url,
- '/oauth/authorize')
+ '/oauth/authorize')
end
before do
setup_csp_for_controller(controller_class, csp, any_time: true)
group.add_developer(user)
login_as(user)
- allow(Gitlab::Observability).to receive(:observability_enabled?).and_return(true)
+ stub_feature_flags(observability_group_tab: true)
end
subject do
@@ -67,7 +67,7 @@ RSpec.shared_examples 'observability csp policy' do |controller_class = describe
end
before do
- allow(Gitlab::Observability).to receive(:observability_enabled?).and_return(false)
+ stub_feature_flags(observability_group_tab: false)
end
it 'does not add observability urls to the csp header' do
@@ -76,23 +76,6 @@ RSpec.shared_examples 'observability csp policy' do |controller_class = describe
end
end
- context 'when checking if observability is enabled' do
- let(:csp) do
- ActionDispatch::ContentSecurityPolicy.new do |p|
- p.frame_src 'https://something.test'
- end
- end
-
- it 'check access for a given user and group' do
- allow(Gitlab::Observability).to receive(:observability_enabled?)
-
- get tested_path
-
- expect(Gitlab::Observability).to have_received(:observability_enabled?)
- .with(user, group).at_least(:once)
- end
- end
-
context 'when frame-src exists in the CSP config' do
let(:csp) do
ActionDispatch::ContentSecurityPolicy.new do |p|
diff --git a/spec/support/shared_examples/observability/embed_observabilities_examples.rb b/spec/support/shared_examples/observability/embed_observabilities_examples.rb
new file mode 100644
index 00000000000..c8d4e9e0d7e
--- /dev/null
+++ b/spec/support/shared_examples/observability/embed_observabilities_examples.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'embeds observability' do
+ it 'renders iframe in description' do
+ page.within('.description') do
+ expect_observability_iframe(page.html)
+ end
+ end
+
+ it 'renders iframe in comment' do
+ expect(page).not_to have_css('.note-text')
+
+ page.within('.js-main-target-form') do
+ fill_in('note[note]', with: observable_url)
+ click_button('Comment')
+ end
+
+ wait_for_requests
+
+ page.within('.note-text') do
+ expect_observability_iframe(page.html)
+ end
+ end
+end
+
+RSpec.shared_examples 'does not embed observability' do
+ it 'does not render iframe in description' do
+ page.within('.description') do
+ expect_observability_iframe(page.html, to_be_nil: true)
+ end
+ end
+
+ it 'does not render iframe in comment' do
+ expect(page).not_to have_css('.note-text')
+
+ page.within('.js-main-target-form') do
+ fill_in('note[note]', with: observable_url)
+ click_button('Comment')
+ end
+
+ wait_for_requests
+
+ page.within('.note-text') do
+ expect_observability_iframe(page.html, to_be_nil: true)
+ end
+ end
+end
+
+def expect_observability_iframe(html, to_be_nil: false)
+ iframe = Nokogiri::HTML.parse(html).at_css('#observability-ui-iframe')
+
+ expect(html).to include(observable_url)
+
+ if to_be_nil
+ expect(iframe).to be_nil
+ else
+ expect(iframe).not_to be_nil
+ iframe_src = "#{expected_observable_url}&theme=light&username=#{user.username}&kiosk=inline-embed"
+ expect(iframe.attributes['src'].value).to eq(iframe_src)
+ end
+end
diff --git a/spec/support/shared_examples/policies/project_policy_shared_examples.rb b/spec/support/shared_examples/policies/project_policy_shared_examples.rb
index 9ec1b8b3f5a..d1f5a01b10c 100644
--- a/spec/support/shared_examples/policies/project_policy_shared_examples.rb
+++ b/spec/support/shared_examples/policies/project_policy_shared_examples.rb
@@ -401,3 +401,24 @@ RSpec.shared_examples 'package access with repository disabled' do
it { is_expected.to be_allowed(:read_package) }
end
+
+RSpec.shared_examples 'equivalent project policy abilities' do
+ where(:project_visibility, :user_role_on_project) do
+ project_visibilities = [:public, :internal, :private]
+ user_role_on_project = [:anonymous, :non_member, :guest, :reporter, :developer, :maintainer, :owner, :admin]
+ project_visibilities.product(user_role_on_project)
+ end
+
+ with_them do
+ it 'evaluates the same' do
+ project = public_send("#{project_visibility}_project")
+ current_user = public_send(user_role_on_project)
+ enable_admin_mode!(current_user) if user_role_on_project == :admin
+ policy = ProjectPolicy.new(current_user, project)
+ old_permissions = policy.allowed?(old_policy)
+ new_permissions = policy.allowed?(new_policy)
+
+ expect(old_permissions).to eq new_permissions
+ end
+ end
+end
diff --git a/spec/support/shared_examples/projects/container_repository/cleanup_tags_service_shared_examples.rb b/spec/support/shared_examples/projects/container_repository/cleanup_tags_service_shared_examples.rb
index f70621673d5..f9f8435c211 100644
--- a/spec/support/shared_examples/projects/container_repository/cleanup_tags_service_shared_examples.rb
+++ b/spec/support/shared_examples/projects/container_repository/cleanup_tags_service_shared_examples.rb
@@ -7,9 +7,9 @@ RSpec.shared_examples 'when regex matching everything is specified' do
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: delete_expectations
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: delete_expectations
context 'with deprecated name_regex param' do
let(:params) do
@@ -17,9 +17,9 @@ RSpec.shared_examples 'when regex matching everything is specified' do
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: delete_expectations
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: delete_expectations
end
end
@@ -31,9 +31,9 @@ RSpec.shared_examples 'when regex matching everything is specified and latest is
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: delete_expectations
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: delete_expectations
end
RSpec.shared_examples 'when delete regex matching specific tags is used' do
@@ -43,9 +43,9 @@ RSpec.shared_examples 'when delete regex matching specific tags is used' do
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: [%w[C D]]
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: [%w[C D]]
end
RSpec.shared_examples 'when delete regex matching specific tags is used with overriding allow regex' do
@@ -58,9 +58,9 @@ RSpec.shared_examples 'when delete regex matching specific tags is used with ove
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: [%w[D]]
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: [%w[D]]
context 'with name_regex_delete overriding deprecated name_regex' do
let(:params) do
@@ -71,9 +71,9 @@ RSpec.shared_examples 'when delete regex matching specific tags is used with ove
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: [%w[D]]
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: [%w[D]]
end
end
@@ -87,9 +87,9 @@ RSpec.shared_examples 'with allow regex value' do
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: delete_expectations
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: delete_expectations
end
RSpec.shared_examples 'when keeping only N tags' do
@@ -135,9 +135,9 @@ RSpec.shared_examples 'when removing keeping only 3' do
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: delete_expectations
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: delete_expectations
end
RSpec.shared_examples 'when removing older than 1 day' do
@@ -150,9 +150,9 @@ RSpec.shared_examples 'when removing older than 1 day' do
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: delete_expectations
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: delete_expectations
end
RSpec.shared_examples 'when combining all parameters' do
@@ -166,9 +166,9 @@ RSpec.shared_examples 'when combining all parameters' do
end
it_behaves_like 'removing the expected tags',
- service_response_extra: service_response_extra,
- supports_caching: supports_caching,
- delete_expectations: delete_expectations
+ service_response_extra: service_response_extra,
+ supports_caching: supports_caching,
+ delete_expectations: delete_expectations
end
RSpec.shared_examples 'when running a container_expiration_policy' do
diff --git a/spec/support/shared_examples/prometheus/additional_metrics_shared_examples.rb b/spec/support/shared_examples/prometheus/additional_metrics_shared_examples.rb
new file mode 100644
index 00000000000..d196114b227
--- /dev/null
+++ b/spec/support/shared_examples/prometheus/additional_metrics_shared_examples.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'additional metrics query' do
+ include Prometheus::MetricBuilders
+
+ let(:metric_group_class) { Gitlab::Prometheus::MetricGroup }
+ let(:metric_class) { Gitlab::Prometheus::Metric }
+
+ let(:metric_names) { %w[metric_a metric_b] }
+
+ let(:query_range_result) do
+ [{ metric: {}, values: [[1488758662.506, '0.00002996364761904785'], [1488758722.506, '0.00003090239047619091']] }]
+ end
+
+ let(:client) { instance_double('Gitlab::PrometheusClient') }
+ let(:query_result) { described_class.new(client).query(*query_params) }
+ let(:project) { create(:project, :repository) }
+ let(:environment) { create(:environment, slug: 'environment-slug', project: project) }
+
+ before do
+ allow(client).to receive(:label_values).and_return(metric_names)
+ allow(metric_group_class).to receive(:common_metrics).and_return([simple_metric_group(metrics: [simple_metric])])
+ end
+
+ describe 'metrics query context' do
+ subject! { described_class.new(client) }
+
+ shared_examples 'query context containing environment slug and filter' do
+ it 'contains ci_environment_slug' do
+ expect(subject)
+ .to receive(:query_metrics).with(project, environment, hash_including(ci_environment_slug: environment.slug))
+
+ subject.query(*query_params)
+ end
+
+ it 'contains environment filter' do
+ expect(subject).to receive(:query_metrics).with(
+ project,
+ environment,
+ hash_including(
+ environment_filter: "container_name!=\"POD\",environment=\"#{environment.slug}\""
+ )
+ )
+
+ subject.query(*query_params)
+ end
+ end
+
+ describe 'project has Kubernetes service' do
+ context 'when user configured kubernetes from CI/CD > Clusters' do
+ let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
+ let(:environment) { create(:environment, slug: 'environment-slug', project: project) }
+ let(:kube_namespace) { environment.deployment_namespace }
+
+ it_behaves_like 'query context containing environment slug and filter'
+
+ it 'query context contains kube_namespace' do
+ expect(subject)
+ .to receive(:query_metrics).with(project, environment, hash_including(kube_namespace: kube_namespace))
+
+ subject.query(*query_params)
+ end
+ end
+ end
+
+ describe 'project without Kubernetes service' do
+ it_behaves_like 'query context containing environment slug and filter'
+
+ it 'query context contains empty kube_namespace' do
+ expect(subject).to receive(:query_metrics).with(project, environment, hash_including(kube_namespace: ''))
+
+ subject.query(*query_params)
+ end
+ end
+ end
+
+ context 'with one group where two metrics is found' do
+ before do
+ allow(metric_group_class).to receive(:common_metrics).and_return([simple_metric_group])
+ end
+
+ context 'when some queries return results' do
+ before do
+ allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_b', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_empty', any_args).and_return([])
+ end
+
+ it 'return group data only for queries with results' do
+ expected = [
+ {
+ group: 'name',
+ priority: 1,
+ metrics: [
+ {
+ title: 'title', weight: 1, y_label: 'Values', queries: [
+ { query_range: 'query_range_a', result: query_range_result },
+ { query_range: 'query_range_b', label: 'label', unit: 'unit', result: query_range_result }
+ ]
+ }
+ ]
+ }
+ ]
+
+ expect(query_result.to_json).to match_schema('prometheus/additional_metrics_query_result')
+ expect(query_result).to eq(expected)
+ end
+ end
+ end
+
+ context 'with two groups with one metric each' do
+ let(:metrics) { [simple_metric(queries: [simple_query])] }
+
+ before do
+ allow(metric_group_class).to receive(:common_metrics).and_return(
+ [
+ simple_metric_group(name: 'group_a', metrics: [simple_metric(queries: [simple_query])]),
+ simple_metric_group(name: 'group_b', metrics: [simple_metric(title: 'title_b', queries: [simple_query('b')])])
+ ])
+ allow(client).to receive(:label_values).and_return(metric_names)
+ end
+
+ context 'when both queries return results' do
+ before do
+ allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_b', any_args).and_return(query_range_result)
+ end
+
+ it 'return group data both queries' do
+ queries_with_result_a = { queries: [{ query_range: 'query_range_a', result: query_range_result }] }
+ queries_with_result_b = { queries: [{ query_range: 'query_range_b', result: query_range_result }] }
+
+ expect(query_result.to_json).to match_schema('prometheus/additional_metrics_query_result')
+
+ expect(query_result.count).to eq(2)
+ expect(query_result).to all(satisfy { |r| r[:metrics].count == 1 })
+
+ expect(query_result[0][:metrics].first).to include(queries_with_result_a)
+ expect(query_result[1][:metrics].first).to include(queries_with_result_b)
+ end
+ end
+
+ context 'when one query returns result' do
+ before do
+ allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
+ allow(client).to receive(:query_range).with('query_range_b', any_args).and_return([])
+ end
+
+ it 'return group data only for query with results' do
+ queries_with_result = { queries: [{ query_range: 'query_range_a', result: query_range_result }] }
+
+ expect(query_result.to_json).to match_schema('prometheus/additional_metrics_query_result')
+
+ expect(query_result.count).to eq(1)
+ expect(query_result).to all(satisfy { |r| r[:metrics].count == 1 })
+
+ expect(query_result.first[:metrics].first).to include(queries_with_result)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/protected_tags/access_control_ce_shared_examples.rb b/spec/support/shared_examples/protected_tags/access_control_ce_shared_examples.rb
new file mode 100644
index 00000000000..f308b4ad372
--- /dev/null
+++ b/spec/support/shared_examples/protected_tags/access_control_ce_shared_examples.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples "protected tags > access control > CE" do
+ ProtectedRef::AccessLevel.human_access_levels.each do |(access_type_id, access_type_name)|
+ it "allows creating protected tags that #{access_type_name} can create" do
+ visit project_protected_tags_path(project)
+
+ set_protected_tag_name('master')
+ set_allowed_to('create', access_type_name)
+ click_on_protect
+
+ expect(ProtectedTag.count).to eq(1)
+ expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to eq([access_type_id])
+ end
+
+ it "allows updating protected tags so that #{access_type_name} can create them" do
+ visit project_protected_tags_path(project)
+
+ set_protected_tag_name('master')
+ set_allowed_to('create', 'No one')
+ click_on_protect
+
+ expect(ProtectedTag.count).to eq(1)
+
+ set_allowed_to('create', access_type_name, form: '.protected-tags-list')
+
+ wait_for_requests
+
+ expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to include(access_type_id)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/quick_actions/issuable/close_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issuable/close_quick_action_shared_examples.rb
index d8690356f81..7cbaf40721a 100644
--- a/spec/support/shared_examples/quick_actions/issuable/close_quick_action_shared_examples.rb
+++ b/spec/support/shared_examples/quick_actions/issuable/close_quick_action_shared_examples.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'close quick action' do |issuable_type|
- include Spec::Support::Helpers::Features::NotesHelpers
+ include Features::NotesHelpers
before do
project.add_maintainer(maintainer)
diff --git a/spec/support/shared_examples/quick_actions/issuable/max_issuable_examples.rb b/spec/support/shared_examples/quick_actions/issuable/max_issuable_examples.rb
index b5704ad8f17..9b03cdbb3bf 100644
--- a/spec/support/shared_examples/quick_actions/issuable/max_issuable_examples.rb
+++ b/spec/support/shared_examples/quick_actions/issuable/max_issuable_examples.rb
@@ -23,11 +23,11 @@ RSpec.shared_examples 'does not exceed the issuable size limit' do
end
note = described_class.new(project, user, opts.merge(
- note: note_text,
- noteable_type: noteable_type,
- noteable_id: issuable.id,
- confidential: false
- )).execute
+ note: note_text,
+ noteable_type: noteable_type,
+ noteable_id: issuable.id,
+ confidential: false
+ )).execute
expect(note.errors[:validation]).to match_array([validation_message])
end
@@ -44,11 +44,11 @@ RSpec.shared_examples 'does not exceed the issuable size limit' do
end
note = described_class.new(project, user, opts.merge(
- note: note_text,
- noteable_type: noteable_type,
- noteable_id: issuable.id,
- confidential: false
- )).execute
+ note: note_text,
+ noteable_type: noteable_type,
+ noteable_id: issuable.id,
+ confidential: false
+ )).execute
expect(note.errors[:validation]).to be_empty
end
diff --git a/spec/support/shared_examples/quick_actions/issue/issue_links_quick_actions_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/issue_links_quick_actions_shared_examples.rb
new file mode 100644
index 00000000000..811b5ee4de2
--- /dev/null
+++ b/spec/support/shared_examples/quick_actions/issue/issue_links_quick_actions_shared_examples.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'issues link quick action' do |command|
+ let_it_be_with_refind(:group) { create(:group) }
+ let_it_be_with_reload(:other_issue) { create(:issue, project: project) }
+ let_it_be_with_reload(:second_issue) { create(:issue, project: project) }
+ let_it_be_with_reload(:third_issue) { create(:issue, project: project) }
+
+ let(:link_type) { command == :relate ? 'relates_to' : 'blocks' }
+ let(:links_query) do
+ if command == :blocked_by
+ IssueLink.where(target: issue, link_type: link_type).map(&:source)
+ else
+ IssueLink.where(source: issue, link_type: link_type).map(&:target)
+ end
+ end
+
+ shared_examples 'link command' do
+ it 'links issues' do
+ service.execute(content, issue)
+
+ expect(links_query).to match_array(issues_linked)
+ end
+ end
+
+ context 'when user is member of group' do
+ before do
+ group.add_developer(user)
+ end
+
+ context 'when linking a single issue' do
+ let(:issues_linked) { [other_issue] }
+ let(:content) { "/#{command} #{other_issue.to_reference}" }
+
+ it_behaves_like 'link command'
+ end
+
+ context 'when linking multiple issues at once' do
+ let(:issues_linked) { [second_issue, third_issue] }
+ let(:content) { "/#{command} #{second_issue.to_reference} #{third_issue.to_reference}" }
+
+ it_behaves_like 'link command'
+ end
+
+ context 'when quick action target is unpersisted' do
+ let(:issue) { build(:issue, project: project) }
+ let(:issues_linked) { [other_issue] }
+ let(:content) { "/#{command} #{other_issue.to_reference}" }
+
+ it 'links the issues after the issue is persisted' do
+ service.execute(content, issue)
+
+ issue.save!
+
+ expect(links_query).to match_array(issues_linked)
+ end
+ end
+
+ context 'with empty link command' do
+ let(:issues_linked) { [] }
+ let(:content) { "/#{command}" }
+
+ it_behaves_like 'link command'
+ end
+
+ context 'with already having linked issues' do
+ let(:issues_linked) { [second_issue, third_issue] }
+ let(:content) { "/#{command} #{third_issue.to_reference(project)}" }
+
+ before do
+ create_existing_link(command)
+ end
+
+ it_behaves_like 'link command'
+ end
+
+ context 'with cross project' do
+ let_it_be_with_reload(:another_group) { create(:group, :public) }
+ let_it_be_with_reload(:other_project) { create(:project, group: another_group) }
+
+ before do
+ another_group.add_developer(user)
+ [other_issue, second_issue, third_issue].map { |i| i.update!(project: other_project) }
+ end
+
+ context 'when linking a cross project issue' do
+ let(:issues_linked) { [other_issue] }
+ let(:content) { "/#{command} #{other_issue.to_reference(project)}" }
+
+ it_behaves_like 'link command'
+ end
+
+ context 'when linking multiple cross projects issues at once' do
+ let(:issues_linked) { [second_issue, third_issue] }
+ let(:content) { "/#{command} #{second_issue.to_reference(project)} #{third_issue.to_reference(project)}" }
+
+ it_behaves_like 'link command'
+ end
+
+ context 'when linking a non-existing issue' do
+ let(:issues_linked) { [] }
+ let(:content) { "/#{command} imaginary##{non_existing_record_iid}" }
+
+ it_behaves_like 'link command'
+ end
+
+ context 'when linking a private issue' do
+ let_it_be(:private_issue) { create(:issue, project: create(:project, :private)) }
+ let(:issues_linked) { [] }
+ let(:content) { "/#{command} #{private_issue.to_reference(project)}" }
+
+ it_behaves_like 'link command'
+ end
+ end
+ end
+
+ def create_existing_link(command)
+ issues = [issue, second_issue]
+ source, target = command == :blocked_by ? issues.reverse : issues
+
+ create(:issue_link, source: source, target: target, link_type: link_type)
+ end
+end
diff --git a/spec/support/shared_examples/quick_actions/issue/promote_to_incident_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/promote_to_incident_quick_action_shared_examples.rb
index 3f1a98ca08e..7bd7500d546 100644
--- a/spec/support/shared_examples/quick_actions/issue/promote_to_incident_quick_action_shared_examples.rb
+++ b/spec/support/shared_examples/quick_actions/issue/promote_to_incident_quick_action_shared_examples.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
RSpec.shared_examples 'promote_to_incident quick action' do
+ include ListboxHelpers
+
describe '/promote_to_incident' do
context 'when issue can be promoted' do
it 'promotes issue to incident' do
@@ -52,9 +54,11 @@ RSpec.shared_examples 'promote_to_incident quick action' do
context 'when incident is selected for issue type' do
it 'promotes issue to incident' do
visit new_project_issue_path(project)
+ wait_for_requests
+
fill_in('Title', with: 'Title')
find('.js-issuable-type-filter-dropdown-wrap').click
- click_link('Incident')
+ select_listbox_item(_('Incident'))
fill_in('Description', with: '/promote_to_incident')
click_button('Create issue')
diff --git a/spec/support/shared_examples/redis/redis_new_instance_shared_examples.rb b/spec/support/shared_examples/redis/redis_new_instance_shared_examples.rb
new file mode 100644
index 00000000000..4a3732efe13
--- /dev/null
+++ b/spec/support/shared_examples/redis/redis_new_instance_shared_examples.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples "redis_new_instance_shared_examples" do |name, fallback_class|
+ include TmpdirHelper
+
+ let(:instance_specific_config_file) { "config/redis.#{name}.yml" }
+ let(:fallback_config_file) { nil }
+ let(:rails_root) { mktmpdir }
+
+ before do
+ allow(fallback_class).to receive(:config_file_name).and_return(fallback_config_file)
+ end
+
+ it_behaves_like "redis_shared_examples"
+
+ describe '#fetch_config' do
+ subject { described_class.new('test').send(:fetch_config) }
+
+ before do
+ FileUtils.mkdir_p(File.join(rails_root, 'config'))
+
+ allow(described_class).to receive(:rails_root).and_return(rails_root)
+ end
+
+ context 'when redis.yml exists' do
+ before do
+ allow(described_class).to receive(:config_file_name).and_call_original
+ allow(described_class).to receive(:redis_yml_path).and_call_original
+ end
+
+ context 'when the fallback has a redis.yml entry' do
+ before do
+ File.write(File.join(rails_root, 'config/redis.yml'), {
+ 'test' => {
+ described_class.config_fallback.store_name.underscore => { 'fallback redis.yml' => 123 }
+ }
+ }.to_json)
+ end
+
+ it { expect(subject).to eq({ 'fallback redis.yml' => 123 }) }
+
+ context 'and an instance config file exists' do
+ before do
+ File.write(File.join(rails_root, instance_specific_config_file), {
+ 'test' => { 'instance specific file' => 456 }
+ }.to_json)
+ end
+
+ it { expect(subject).to eq({ 'instance specific file' => 456 }) }
+
+ context 'and the instance has a redis.yml entry' do
+ before do
+ File.write(File.join(rails_root, 'config/redis.yml'), {
+ 'test' => { name => { 'instance redis.yml' => 789 } }
+ }.to_json)
+ end
+
+ it { expect(subject).to eq({ 'instance redis.yml' => 789 }) }
+ end
+ end
+ end
+ end
+
+ context 'when no redis config file exsits' do
+ it 'returns nil' do
+ expect(subject).to eq(nil)
+ end
+
+ context 'when resque.yml exists' do
+ before do
+ File.write(File.join(rails_root, 'config/resque.yml'), {
+ 'test' => { 'foobar' => 123 }
+ }.to_json)
+ end
+
+ it 'returns the config from resque.yml' do
+ expect(subject).to eq({ 'foobar' => 123 })
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/redis/redis_shared_examples.rb b/spec/support/shared_examples/redis/redis_shared_examples.rb
new file mode 100644
index 00000000000..9224e01b1fe
--- /dev/null
+++ b/spec/support/shared_examples/redis/redis_shared_examples.rb
@@ -0,0 +1,429 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples "redis_shared_examples" do
+ include StubENV
+ include TmpdirHelper
+
+ let(:test_redis_url) { "redis://redishost:#{redis_port}" }
+ let(:test_cluster_config) { { cluster: [{ host: "redis://redishost", port: redis_port }] } }
+ let(:config_file_name) { instance_specific_config_file }
+ let(:config_old_format_socket) { "spec/fixtures/config/redis_old_format_socket.yml" }
+ let(:config_new_format_socket) { "spec/fixtures/config/redis_new_format_socket.yml" }
+ let(:old_socket_path) { "/path/to/old/redis.sock" }
+ let(:new_socket_path) { "/path/to/redis.sock" }
+ let(:config_old_format_host) { "spec/fixtures/config/redis_old_format_host.yml" }
+ let(:config_new_format_host) { "spec/fixtures/config/redis_new_format_host.yml" }
+ let(:config_cluster_format_host) { "spec/fixtures/config/redis_cluster_format_host.yml" }
+ let(:redis_port) { 6379 }
+ let(:redis_database) { 99 }
+ let(:sentinel_port) { 26379 }
+ let(:config_with_environment_variable_inside) { "spec/fixtures/config/redis_config_with_env.yml" }
+ let(:config_env_variable_url) { "TEST_GITLAB_REDIS_URL" }
+ let(:rails_root) { mktmpdir }
+
+ before do
+ allow(described_class).to receive(:config_file_name).and_return(Rails.root.join(config_file_name).to_s)
+ allow(described_class).to receive(:redis_yml_path).and_return('/dev/null')
+ end
+
+ describe '.config_file_name' do
+ subject { described_class.config_file_name }
+
+ before do
+ # Undo top-level stub of config_file_name because we are testing that method now.
+ allow(described_class).to receive(:config_file_name).and_call_original
+
+ allow(described_class).to receive(:rails_root).and_return(rails_root)
+ FileUtils.mkdir_p(File.join(rails_root, 'config'))
+ end
+
+ context 'when there is no config file anywhere' do
+ it { expect(subject).to be_nil }
+ end
+ end
+
+ describe '.store' do
+ let(:rails_env) { 'development' }
+
+ subject { described_class.new(rails_env).store }
+
+ shared_examples 'redis store' do
+ let(:redis_store) { ::Redis::Store }
+ let(:redis_store_to_s) { "Redis Client connected to #{host} against DB #{redis_database}" }
+
+ it 'instantiates Redis::Store' do
+ is_expected.to be_a(redis_store)
+
+ expect(subject.to_s).to eq(redis_store_to_s)
+ end
+
+ context 'with the namespace' do
+ let(:namespace) { 'namespace_name' }
+ let(:redis_store_to_s) do
+ "Redis Client connected to #{host} against DB #{redis_database} with namespace #{namespace}"
+ end
+
+ subject { described_class.new(rails_env).store(namespace: namespace) }
+
+ it "uses specified namespace" do
+ expect(subject.to_s).to eq(redis_store_to_s)
+ end
+ end
+ end
+
+ context 'with old format' do
+ it_behaves_like 'redis store' do
+ let(:config_file_name) { config_old_format_host }
+ let(:host) { "localhost:#{redis_port}" }
+ end
+ end
+
+ context 'with new format' do
+ it_behaves_like 'redis store' do
+ let(:config_file_name) { config_new_format_host }
+ let(:host) { "development-host:#{redis_port}" }
+ end
+ end
+ end
+
+ describe '.params' do
+ subject { described_class.new(rails_env).params }
+
+ let(:rails_env) { 'development' }
+ let(:config_file_name) { config_old_format_socket }
+
+ it 'withstands mutation' do
+ params1 = described_class.params
+ params2 = described_class.params
+ params1[:foo] = :bar
+
+ expect(params2).not_to have_key(:foo)
+ end
+
+ context 'when url contains unix socket reference' do
+ context 'with old format' do
+ let(:config_file_name) { config_old_format_socket }
+
+ it 'returns path key instead' do
+ is_expected.to include(path: old_socket_path)
+ is_expected.not_to have_key(:url)
+ end
+ end
+
+ context 'with new format' do
+ let(:config_file_name) { config_new_format_socket }
+
+ it 'returns path key instead' do
+ is_expected.to include(path: new_socket_path)
+ is_expected.not_to have_key(:url)
+ end
+ end
+ end
+
+ context 'when url is host based' do
+ context 'with old format' do
+ let(:config_file_name) { config_old_format_host }
+
+ it 'returns hash with host, port, db, and password' do
+ is_expected.to include(host: 'localhost', password: 'mypassword', port: redis_port, db: redis_database)
+ is_expected.not_to have_key(:url)
+ end
+ end
+
+ context 'with new format' do
+ let(:config_file_name) { config_new_format_host }
+
+ where(:rails_env, :host) do
+ [
+ %w[development development-host],
+ %w[test test-host],
+ %w[production production-host]
+ ]
+ end
+
+ with_them do
+ it 'returns hash with host, port, db, and password' do
+ is_expected.to include(host: host, password: 'mynewpassword', port: redis_port, db: redis_database)
+ is_expected.not_to have_key(:url)
+ end
+ end
+ end
+
+ context 'with redis cluster format' do
+ let(:config_file_name) { config_cluster_format_host }
+
+ where(:rails_env, :host) do
+ [
+ %w[development development-master],
+ %w[test test-master],
+ %w[production production-master]
+ ]
+ end
+
+ with_them do
+ it 'returns hash with cluster and password' do
+ is_expected.to include(
+ password: 'myclusterpassword',
+ cluster: [
+ { host: "#{host}1", port: redis_port },
+ { host: "#{host}2", port: redis_port }
+ ]
+ )
+ is_expected.not_to have_key(:url)
+ end
+ end
+ end
+ end
+ end
+
+ describe '.url' do
+ let(:config_file_name) { config_old_format_socket }
+
+ it 'withstands mutation' do
+ url1 = described_class.url
+ url2 = described_class.url
+ url1 << 'foobar' unless url1.frozen?
+
+ expect(url2).not_to end_with('foobar')
+ end
+
+ context 'when yml file with env variable' do
+ let(:config_file_name) { config_with_environment_variable_inside }
+
+ before do
+ stub_env(config_env_variable_url, test_redis_url)
+ end
+
+ it 'reads redis url from env variable' do
+ expect(described_class.url).to eq test_redis_url
+ end
+ end
+ end
+
+ describe '.version' do
+ it 'returns a version' do
+ expect(described_class.version).to be_present
+ end
+ end
+
+ describe '.with' do
+ let(:config_file_name) { config_old_format_socket }
+
+ before do
+ clear_pool
+ end
+
+ after do
+ clear_pool
+ end
+
+ context 'when running on single-threaded runtime' do
+ before do
+ allow(Gitlab::Runtime).to receive(:multi_threaded?).and_return(false)
+ end
+
+ it 'instantiates a connection pool with size 5' do
+ expect(ConnectionPool).to receive(:new).with(size: 5).and_call_original
+
+ described_class.with { |_redis_shared_example| true }
+ end
+ end
+
+ context 'when running on multi-threaded runtime' do
+ before do
+ allow(Gitlab::Runtime).to receive(:multi_threaded?).and_return(true)
+ allow(Gitlab::Runtime).to receive(:max_threads).and_return(18)
+ end
+
+ it 'instantiates a connection pool with a size based on the concurrency of the worker' do
+ expect(ConnectionPool).to receive(:new).with(size: 18 + 5).and_call_original
+
+ described_class.with { |_redis_shared_example| true }
+ end
+ end
+
+ context 'when there is no config at all' do
+ before do
+ # Undo top-level stub of config_file_name because we are testing that method now.
+ allow(described_class).to receive(:config_file_name).and_call_original
+
+ allow(described_class).to receive(:rails_root).and_return(rails_root)
+ end
+
+ it 'can run an empty block' do
+ expect { described_class.with { nil } }.not_to raise_error
+ end
+ end
+ end
+
+ describe '#db' do
+ let(:rails_env) { 'development' }
+
+ subject { described_class.new(rails_env).db }
+
+ context 'with old format' do
+ let(:config_file_name) { config_old_format_host }
+
+ it 'returns the correct db' do
+ expect(subject).to eq(redis_database)
+ end
+ end
+
+ context 'with new format' do
+ let(:config_file_name) { config_new_format_host }
+
+ it 'returns the correct db' do
+ expect(subject).to eq(redis_database)
+ end
+ end
+
+ context 'with cluster-mode' do
+ let(:config_file_name) { config_cluster_format_host }
+
+ it 'returns the correct db' do
+ expect(subject).to eq(0)
+ end
+ end
+ end
+
+ describe '#sentinels' do
+ subject { described_class.new(rails_env).sentinels }
+
+ let(:rails_env) { 'development' }
+
+ context 'when sentinels are defined' do
+ let(:config_file_name) { config_new_format_host }
+
+ where(:rails_env, :hosts) do
+ [
+ ['development', %w[development-replica1 development-replica2]],
+ ['test', %w[test-replica1 test-replica2]],
+ ['production', %w[production-replica1 production-replica2]]
+ ]
+ end
+
+ with_them do
+ it 'returns an array of hashes with host and port keys' do
+ is_expected.to include(host: hosts[0], port: sentinel_port)
+ is_expected.to include(host: hosts[1], port: sentinel_port)
+ end
+ end
+ end
+
+ context 'when sentinels are not defined' do
+ let(:config_file_name) { config_old_format_host }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+
+ context 'when cluster is defined' do
+ let(:config_file_name) { config_cluster_format_host }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#sentinels?' do
+ subject { described_class.new(Rails.env).sentinels? }
+
+ context 'when sentinels are defined' do
+ let(:config_file_name) { config_new_format_host }
+
+ it 'returns true' do
+ is_expected.to be_truthy
+ end
+ end
+
+ context 'when sentinels are not defined' do
+ let(:config_file_name) { config_old_format_host }
+
+ it { expect(subject).to eq(nil) }
+ end
+
+ context 'when cluster is defined' do
+ let(:config_file_name) { config_cluster_format_host }
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+ end
+
+ describe '#raw_config_hash' do
+ it 'returns old-style single url config in a hash' do
+ expect(subject).to receive(:fetch_config) { test_redis_url }
+ expect(subject.send(:raw_config_hash)).to eq(url: test_redis_url)
+ end
+
+ it 'returns cluster config without url key in a hash' do
+ expect(subject).to receive(:fetch_config) { test_cluster_config }
+ expect(subject.send(:raw_config_hash)).to eq(test_cluster_config)
+ end
+ end
+
+ describe '#fetch_config' do
+ before do
+ FileUtils.mkdir_p(File.join(rails_root, 'config'))
+ # Undo top-level stub of config_file_name because we are testing that method now.
+ allow(described_class).to receive(:config_file_name).and_call_original
+ allow(described_class).to receive(:rails_root).and_return(rails_root)
+ end
+
+ it 'raises an exception when the config file contains invalid yaml' do
+ Tempfile.open('bad.yml') do |file|
+ file.write('{"not":"yaml"')
+ file.flush
+ allow(described_class).to receive(:config_file_name) { file.path }
+
+ expect { subject.send(:fetch_config) }.to raise_error(Psych::SyntaxError)
+ end
+ end
+
+ context 'when redis.yml exists' do
+ subject { described_class.new('test').send(:fetch_config) }
+
+ before do
+ allow(described_class).to receive(:redis_yml_path).and_call_original
+ end
+
+ it 'uses config/redis.yml' do
+ File.write(File.join(rails_root, 'config/redis.yml'), {
+ 'test' => { described_class.store_name.underscore => { 'foobar' => 123 } }
+ }.to_json)
+
+ expect(subject).to eq({ 'foobar' => 123 })
+ end
+ end
+
+ context 'when no config file exsits' do
+ subject { described_class.new('test').send(:fetch_config) }
+
+ it 'returns nil' do
+ expect(subject).to eq(nil)
+ end
+
+ context 'when resque.yml exists' do
+ before do
+ FileUtils.mkdir_p(File.join(rails_root, 'config'))
+ File.write(File.join(rails_root, 'config/resque.yml'), {
+ 'test' => { 'foobar' => 123 }
+ }.to_json)
+ end
+
+ it 'returns the config from resque.yml' do
+ expect(subject).to eq({ 'foobar' => 123 })
+ end
+ end
+ end
+ end
+
+ def clear_pool
+ described_class.remove_instance_variable(:@pool)
+ rescue NameError
+ # raised if @pool was not set; ignore
+ end
+end
diff --git a/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
index 2170025824f..74dbec063e0 100644
--- a/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
+++ b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
@@ -14,7 +14,7 @@ RSpec.shared_examples 'GET resource access tokens available' do
it 'lists all available scopes' do
get_access_tokens
- expect(assigns(:scopes)).to eq(Gitlab::Auth.resource_bot_scopes)
+ expect(assigns(:scopes)).to eq(Gitlab::Auth.available_scopes_for(resource))
end
it 'returns for json response' do
diff --git a/spec/support/shared_examples/requests/admin_mode_shared_examples.rb b/spec/support/shared_examples/requests/admin_mode_shared_examples.rb
index 07fde7d3f35..4f198dfb740 100644
--- a/spec/support/shared_examples/requests/admin_mode_shared_examples.rb
+++ b/spec/support/shared_examples/requests/admin_mode_shared_examples.rb
@@ -1,98 +1,79 @@
# frozen_string_literal: true
-RSpec.shared_examples 'GET request permissions for admin mode' do
- it_behaves_like 'GET request permissions for admin mode when user'
- it_behaves_like 'GET request permissions for admin mode when admin'
-end
-
-RSpec.shared_examples 'PUT request permissions for admin mode' do |params|
- it_behaves_like 'PUT request permissions for admin mode when user', params
- it_behaves_like 'PUT request permissions for admin mode when admin', params
-end
-
-RSpec.shared_examples 'POST request permissions for admin mode' do |params|
- it_behaves_like 'POST request permissions for admin mode when user', params
- it_behaves_like 'POST request permissions for admin mode when admin', params
-end
RSpec.shared_examples 'DELETE request permissions for admin mode' do
- it_behaves_like 'DELETE request permissions for admin mode when user'
- it_behaves_like 'DELETE request permissions for admin mode when admin'
-end
-
-RSpec.shared_examples 'GET request permissions for admin mode when user' do
- subject { get api(path, current_user, admin_mode: admin_mode) }
+ subject { delete api(path, current_user, admin_mode: admin_mode) }
- let_it_be(:current_user) { create(:user) }
+ let_it_be(:success_status_code) { :no_content }
+ let_it_be(:failed_status_code) { :forbidden }
- it_behaves_like 'admin mode on', true, :forbidden
- it_behaves_like 'admin mode on', false, :forbidden
+ it_behaves_like 'when admin'
+ it_behaves_like 'when user'
end
-RSpec.shared_examples 'GET request permissions for admin mode when admin' do
+RSpec.shared_examples 'GET request permissions for admin mode' do
subject { get api(path, current_user, admin_mode: admin_mode) }
- let_it_be(:current_user) { create(:admin) }
-
- it_behaves_like 'admin mode on', true, :ok
- it_behaves_like 'admin mode on', false, :forbidden
-end
-
-RSpec.shared_examples 'PUT request permissions for admin mode when user' do |params|
- subject { put api(path, current_user, admin_mode: admin_mode), params: params }
-
- let_it_be(:current_user) { create(:user) }
+ let_it_be(:success_status_code) { :ok }
+ let_it_be(:failed_status_code) { :forbidden }
- it_behaves_like 'admin mode on', true, :forbidden
- it_behaves_like 'admin mode on', false, :forbidden
+ it_behaves_like 'when admin'
+ it_behaves_like 'when user'
end
-RSpec.shared_examples 'PUT request permissions for admin mode when admin' do |params|
+RSpec.shared_examples 'PUT request permissions for admin mode' do
subject { put api(path, current_user, admin_mode: admin_mode), params: params }
- let_it_be(:current_user) { create(:admin) }
+ let_it_be(:success_status_code) { :ok }
+ let_it_be(:failed_status_code) { :forbidden }
- it_behaves_like 'admin mode on', true, :ok
- it_behaves_like 'admin mode on', false, :forbidden
+ it_behaves_like 'when admin'
+ it_behaves_like 'when user'
end
-RSpec.shared_examples 'POST request permissions for admin mode when user' do |params|
+RSpec.shared_examples 'POST request permissions for admin mode' do
subject { post api(path, current_user, admin_mode: admin_mode), params: params }
- let_it_be(:current_user) { create(:user) }
+ let_it_be(:success_status_code) { :created }
+ let_it_be(:failed_status_code) { :forbidden }
- it_behaves_like 'admin mode on', true, :forbidden
- it_behaves_like 'admin mode on', false, :forbidden
+ it_behaves_like 'when admin'
+ it_behaves_like 'when user'
end
-RSpec.shared_examples 'POST request permissions for admin mode when admin' do |params|
- subject { post api(path, current_user, admin_mode: admin_mode), params: params }
+RSpec.shared_examples 'when user' do
+ let_it_be(:current_user) { create(:user) }
- let_it_be(:current_user) { create(:admin) }
+ include_examples 'makes request' do
+ let(:status) { failed_status_code }
+ let(:admin_mode) { true }
+ end
- it_behaves_like 'admin mode on', true, :created
- it_behaves_like 'admin mode on', false, :forbidden
+ it_behaves_like 'makes request' do
+ let(:status) { failed_status_code }
+ let(:admin_mode) { false }
+ end
end
-RSpec.shared_examples 'DELETE request permissions for admin mode when user' do
- subject { delete api(path, current_user, admin_mode: admin_mode) }
+RSpec.shared_examples 'when admin' do
+ let_it_be(:current_user) { create(:admin) }
- let_it_be(:current_user) { create(:user) }
+ it_behaves_like 'makes request' do
+ let(:status) { success_status_code }
+ let(:admin_mode) { true }
+ end
- it_behaves_like 'admin mode on', true, :forbidden
- it_behaves_like 'admin mode on', false, :forbidden
+ it_behaves_like 'makes request' do
+ let(:status) { failed_status_code }
+ let(:admin_mode) { false }
+ end
end
-RSpec.shared_examples 'DELETE request permissions for admin mode when admin' do
- subject { delete api(path, current_user, admin_mode: admin_mode) }
-
- let_it_be(:current_user) { create(:admin) }
-
- it_behaves_like 'admin mode on', true, :no_content
- it_behaves_like 'admin mode on', false, :forbidden
-end
+RSpec.shared_examples "makes request" do
+ let_it_be(:status) { nil }
-RSpec.shared_examples "admin mode on" do |admin_mode, status|
- let_it_be(:admin_mode) { admin_mode }
+ it "returns" do
+ subject
- it_behaves_like 'returning response status', status
+ expect(response).to have_gitlab_http_status(status)
+ end
end
diff --git a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
index f5c41416763..3ff52166990 100644
--- a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
@@ -18,7 +18,7 @@ RSpec.shared_examples 'returns repositories for allowed users' do |user_type, sc
subject
expect(json_response.length).to eq(2)
- expect(json_response.map { |repository| repository['id'] }).to contain_exactly(
+ expect(json_response.pluck('id')).to contain_exactly(
root_repository.id, test_repository.id)
expect(response.body).not_to include('tags')
expect(response.body).not_to include('tags_count')
@@ -47,7 +47,7 @@ RSpec.shared_examples 'returns tags for allowed users' do |user_type, scope|
subject
expect(json_response.length).to eq(2)
- expect(json_response.map { |repository| repository['id'] }).to contain_exactly(
+ expect(json_response.pluck('id')).to contain_exactly(
root_repository.id, test_repository.id)
expect(response.body).to include('tags')
end
diff --git a/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb b/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb
index f31cbcfdec1..804221b7416 100644
--- a/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb
@@ -4,7 +4,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
let!(:custom_attribute1) { attributable.custom_attributes.create! key: 'foo', value: 'foo' }
let!(:custom_attribute2) { attributable.custom_attributes.create! key: 'bar', value: 'bar' }
- describe "GET /#{attributable_name} with custom attributes filter" do
+ describe "GET /#{attributable_name} with custom attributes filter", :aggregate_failures do
before do
other_attributable
end
@@ -14,13 +14,13 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
get api("/#{attributable_name}", user), params: { custom_attributes: { foo: 'foo', bar: 'bar' } }
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.map { |r| r['id'] }).to include(attributable.id, other_attributable.id)
+ expect(json_response.pluck('id')).to include(attributable.id, other_attributable.id)
end
end
context 'with an authorized user' do
it 'filters by custom attributes' do
- get api("/#{attributable_name}", admin), params: { custom_attributes: { foo: 'foo', bar: 'bar' } }
+ get api("/#{attributable_name}", admin, admin_mode: true), params: { custom_attributes: { foo: 'foo', bar: 'bar' } }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to be 1
@@ -29,7 +29,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
end
end
- describe "GET /#{attributable_name} with custom attributes" do
+ describe "GET /#{attributable_name} with custom attributes", :aggregate_failures do
before do
other_attributable
end
@@ -46,7 +46,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
context 'with an authorized user' do
it 'does not include custom attributes by default' do
- get api("/#{attributable_name}", admin)
+ get api("/#{attributable_name}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).not_to be_empty
@@ -54,7 +54,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
end
it 'includes custom attributes if requested' do
- get api("/#{attributable_name}", admin), params: { with_custom_attributes: true }
+ get api("/#{attributable_name}", admin, admin_mode: true), params: { with_custom_attributes: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).not_to be_empty
@@ -72,7 +72,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
end
end
- describe "GET /#{attributable_name}/:id with custom attributes" do
+ describe "GET /#{attributable_name}/:id with custom attributes", :aggregate_failures do
context 'with an unauthorized user' do
it 'does not include custom attributes' do
get api("/#{attributable_name}/#{attributable.id}", user), params: { with_custom_attributes: true }
@@ -84,14 +84,14 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
context 'with an authorized user' do
it 'does not include custom attributes by default' do
- get api("/#{attributable_name}/#{attributable.id}", admin)
+ get api("/#{attributable_name}/#{attributable.id}", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).not_to include 'custom_attributes'
end
it 'includes custom attributes if requested' do
- get api("/#{attributable_name}/#{attributable.id}", admin), params: { with_custom_attributes: true }
+ get api("/#{attributable_name}/#{attributable.id}", admin, admin_mode: true), params: { with_custom_attributes: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['custom_attributes']).to contain_exactly(
@@ -102,7 +102,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
end
end
- describe "GET /#{attributable_name}/:id/custom_attributes" do
+ describe "GET /#{attributable_name}/:id/custom_attributes", :aggregate_failures do
context 'with an unauthorized user' do
subject { get api("/#{attributable_name}/#{attributable.id}/custom_attributes", user) }
@@ -111,7 +111,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
context 'with an authorized user' do
it 'returns all custom attributes' do
- get api("/#{attributable_name}/#{attributable.id}/custom_attributes", admin)
+ get api("/#{attributable_name}/#{attributable.id}/custom_attributes", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to contain_exactly(
@@ -122,7 +122,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
end
end
- describe "GET /#{attributable_name}/:id/custom_attributes/:key" do
+ describe "GET /#{attributable_name}/:id/custom_attributes/:key", :aggregate_failures do
context 'with an unauthorized user' do
subject { get api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", user) }
@@ -131,7 +131,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
context 'with an authorized user' do
it 'returns a single custom attribute' do
- get api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin)
+ get api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin, admin_mode: true)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq({ 'key' => 'foo', 'value' => 'foo' })
@@ -139,7 +139,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
end
end
- describe "PUT /#{attributable_name}/:id/custom_attributes/:key" do
+ describe "PUT /#{attributable_name}/:id/custom_attributes/:key", :aggregate_failures do
context 'with an unauthorized user' do
subject { put api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", user), params: { value: 'new' } }
@@ -149,7 +149,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
context 'with an authorized user' do
it 'creates a new custom attribute' do
expect do
- put api("/#{attributable_name}/#{attributable.id}/custom_attributes/new", admin), params: { value: 'new' }
+ put api("/#{attributable_name}/#{attributable.id}/custom_attributes/new", admin, admin_mode: true), params: { value: 'new' }
end.to change { attributable.custom_attributes.count }.by(1)
expect(response).to have_gitlab_http_status(:ok)
@@ -159,7 +159,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
it 'updates an existing custom attribute' do
expect do
- put api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin), params: { value: 'new' }
+ put api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin, admin_mode: true), params: { value: 'new' }
end.not_to change { attributable.custom_attributes.count }
expect(response).to have_gitlab_http_status(:ok)
@@ -169,7 +169,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
end
end
- describe "DELETE /#{attributable_name}/:id/custom_attributes/:key" do
+ describe "DELETE /#{attributable_name}/:id/custom_attributes/:key", :aggregate_failures do
context 'with an unauthorized user' do
subject { delete api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", user) }
@@ -179,7 +179,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name|
context 'with an authorized user' do
it 'deletes an existing custom attribute' do
expect do
- delete api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin)
+ delete api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin, admin_mode: true)
end.to change { attributable.custom_attributes.count }.by(-1)
expect(response).to have_gitlab_http_status(:no_content)
diff --git a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb
index 6d29076da0f..bc7ad570441 100644
--- a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb
@@ -165,3 +165,41 @@ RSpec.shared_examples 'Debian packages write endpoint' do |desired_behavior, suc
it_behaves_like 'rejects Debian access with unknown container id', :unauthorized, :basic
end
+
+RSpec.shared_examples 'Debian packages endpoint catching ObjectStorage::RemoteStoreError' do
+ include_context 'Debian repository access', :public, :developer, :basic do
+ it "returns forbidden" do
+ expect(::Packages::Debian::CreatePackageFileService).to receive(:new).and_raise ObjectStorage::RemoteStoreError
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+end
+
+RSpec.shared_examples 'Debian packages index endpoint' do |success_body|
+ it_behaves_like 'Debian packages read endpoint', 'GET', :success, success_body
+
+ context 'when no ComponentFile is found' do
+ let(:target_component_name) { component.name + FFaker::Lorem.word }
+
+ it_behaves_like 'Debian packages read endpoint', 'GET', :no_content, /^$/
+ end
+end
+
+RSpec.shared_examples 'Debian packages index sha256 endpoint' do |success_body|
+ it_behaves_like 'Debian packages read endpoint', 'GET', :success, success_body
+
+ context 'with empty checksum' do
+ let(:target_sha256) { 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' }
+
+ it_behaves_like 'Debian packages read endpoint', 'GET', :no_content, /^$/
+ end
+
+ context 'when ComponentFile is not found' do
+ let(:target_component_name) { component.name + FFaker::Lorem.word }
+
+ it_behaves_like 'Debian packages read endpoint', 'GET', :not_found, /^{"message":"404 Not Found"}$/
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/discussions_shared_examples.rb b/spec/support/shared_examples/requests/api/discussions_shared_examples.rb
index f577e2ad323..2996c794e52 100644
--- a/spec/support/shared_examples/requests/api/discussions_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/discussions_shared_examples.rb
@@ -123,18 +123,6 @@ RSpec.shared_examples 'discussions API' do |parent_type, noteable_type, id_name,
expect_snowplow_event(category: 'Notes::CreateService', action: 'execute', label: 'note', value: anything)
end
- context 'with notes_create_service_tracking feature flag disabled' do
- before do
- stub_feature_flags(notes_create_service_tracking: false)
- end
-
- it 'does not track Notes::CreateService events', :snowplow do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions"), params: { body: 'hi!' }
-
- expect_no_snowplow_event(category: 'Notes::CreateService', action: 'execute')
- end
- end
-
context 'when an admin or owner makes the request' do
it 'accepts the creation date to be set' do
creation_time = 2.weeks.ago
@@ -243,8 +231,7 @@ RSpec.shared_examples 'discussions API' do |parent_type, noteable_type, id_name,
it 'returns a 404 error when note id not found' do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
- "discussions/#{note.discussion_id}/notes/#{non_existing_record_id}", user),
- params: { body: 'Hello!' }
+ "discussions/#{note.discussion_id}/notes/#{non_existing_record_id}", user), params: { body: 'Hello!' }
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb
index 6c8b792bf92..930c47dac52 100644
--- a/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb
@@ -480,6 +480,7 @@ RSpec.shared_examples 'graphql issue list request spec' do
context 'when fetching escalation status' do
let_it_be(:escalation_status) { create(:incident_management_issuable_escalation_status, issue: issue_a) }
+ let_it_be(:incident_type) { WorkItems::Type.default_by_type(:incident) }
let(:fields) do
<<~QUERY
@@ -491,7 +492,7 @@ RSpec.shared_examples 'graphql issue list request spec' do
end
before do
- issue_a.update_columns(issue_type: Issue.issue_types[:incident])
+ issue_a.update_columns(issue_type: WorkItems::Type.base_types[:incident], work_item_type_id: incident_type.id)
end
it 'returns the escalation status values' do
diff --git a/spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb
index b459e479c91..53329c5caec 100644
--- a/spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb
@@ -6,7 +6,7 @@ RSpec.shared_examples 'when the snippet is not found' do
end
it_behaves_like 'a mutation that returns top-level errors',
- errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
+ errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
end
RSpec.shared_examples 'snippet edit usage data counters' do
diff --git a/spec/support/shared_examples/requests/api/graphql/mutations/subscription_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/mutations/subscription_shared_examples.rb
index 40b88ef370f..4dc0264172f 100644
--- a/spec/support/shared_examples/requests/api/graphql/mutations/subscription_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/mutations/subscription_shared_examples.rb
@@ -36,9 +36,9 @@ RSpec.shared_examples 'a subscribable resource api' do
context 'when the user is not authorized' do
it_behaves_like 'a mutation that returns top-level errors',
- errors: ["The resource that you are attempting to access "\
- "does not exist or you don't have permission to "\
- "perform this action"]
+ errors: ["The resource that you are attempting to access "\
+ "does not exist or you don't have permission to "\
+ "perform this action"]
end
context 'when user is authorized' do
diff --git a/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb
index f5835460a77..5e9dfc826d4 100644
--- a/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb
@@ -279,11 +279,11 @@ RSpec.shared_examples 'group and project packages query' do
end
def npm_pipeline_ids
- graphql_data_npm_package.dig('pipelines', 'nodes').map { |pipeline| pipeline['id'] }
+ graphql_data_npm_package.dig('pipelines', 'nodes').pluck('id')
end
def composer_pipeline_ids
- graphql_data_composer_package.dig('pipelines', 'nodes').map { |pipeline| pipeline['id'] }
+ graphql_data_composer_package.dig('pipelines', 'nodes').pluck('id')
end
def graphql_data_npm_package
diff --git a/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb
index b4019d7c232..161f4a02b8c 100644
--- a/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb
@@ -38,7 +38,7 @@ RSpec.shared_examples 'a package with files' do
context 'with package files pending destruction' do
let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: package) }
- let(:response_package_file_ids) { package_files_response.map { |pf| pf['id'] } }
+ let(:response_package_file_ids) { package_files_response.pluck('id') }
it 'does not return them' do
expect(package.reload.package_files).to include(package_file_pending_destruction)
diff --git a/spec/support/shared_examples/requests/api/graphql/projects/branch_protections/access_level_request_examples.rb b/spec/support/shared_examples/requests/api/graphql/projects/branch_protections/access_level_request_examples.rb
index 6b4d8cae2ce..6648c18fb70 100644
--- a/spec/support/shared_examples/requests/api/graphql/projects/branch_protections/access_level_request_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/projects/branch_protections/access_level_request_examples.rb
@@ -13,13 +13,15 @@ RSpec.shared_examples 'a GraphQL query for access levels' do |access_level_kind|
let(:maintainer_access_level) { access_levels.for_role.first }
let(:maintainer_access_level_data) { access_levels_data.first }
let(:access_levels_data) do
- graphql_data_at('project',
- 'branchRules',
- 'nodes',
- 0,
- 'branchProtection',
- "#{access_level_kind.to_s.camelize(:lower)}AccessLevels",
- 'nodes')
+ graphql_data_at(
+ 'project',
+ 'branchRules',
+ 'nodes',
+ 0,
+ 'branchProtection',
+ "#{access_level_kind.to_s.camelize(:lower)}AccessLevels",
+ 'nodes'
+ )
end
let(:query) do
diff --git a/spec/support/shared_examples/requests/api/hooks_shared_examples.rb b/spec/support/shared_examples/requests/api/hooks_shared_examples.rb
index f2002de4b55..a2c34aa6a54 100644
--- a/spec/support/shared_examples/requests/api/hooks_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/hooks_shared_examples.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
RSpec.shared_examples 'web-hook API endpoints test hook' do |prefix|
- describe "POST #{prefix}/:hook_id" do
+ describe "POST #{prefix}/:hook_id", :aggregate_failures do
it 'tests the hook' do
expect(WebHookService)
.to receive(:new).with(hook, anything, String, force: false)
.and_return(instance_double(WebHookService, execute: nil))
- post api(hook_uri, user)
+ post api(hook_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:created)
end
@@ -17,7 +17,7 @@ end
RSpec.shared_examples 'web-hook API endpoints with branch-filter' do |prefix|
describe "POST #{prefix}/hooks" do
it "returns a 422 error if branch filter is not valid" do
- post api(collection_uri, user),
+ post api(collection_uri, user, admin_mode: user.admin?),
params: { url: "http://example.com", push_events_branch_filter: '~badbranchname/' }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
@@ -58,10 +58,10 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
let(:default_values) { {} }
- describe "GET #{prefix}/hooks" do
+ describe "GET #{prefix}/hooks", :aggregate_failures do
context "authorized user" do
it "returns all hooks" do
- get api(collection_uri, user)
+ get api(collection_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_collection_schema
@@ -70,7 +70,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
context "when user is forbidden" do
it "prevents access to hooks" do
- get api(collection_uri, unauthorized_user)
+ get api(collection_uri, unauthorized_user, admin_mode: true)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -90,7 +90,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
end
it 'returns the names of the url variables' do
- get api(collection_uri, user)
+ get api(collection_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to contain_exactly(
@@ -102,10 +102,10 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
end
end
- describe "GET #{prefix}/hooks/:hook_id" do
+ describe "GET #{prefix}/hooks/:hook_id", :aggregate_failures do
context "authorized user" do
it "returns a project hook" do
- get api(hook_uri, user)
+ get api(hook_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_hook_schema
@@ -114,7 +114,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
end
it "returns a 404 error if hook id is not available" do
- get api(hook_uri(non_existing_record_id), user)
+ get api(hook_uri(non_existing_record_id), user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -125,7 +125,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
end
it "has the correct alert status", :aggregate_failures do
- get api(hook_uri, user)
+ get api(hook_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:ok)
@@ -135,12 +135,12 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
context 'the hook is backed-off' do
before do
- WebHook::FAILURE_THRESHOLD.times { hook.backoff! }
+ WebHooks::AutoDisabling::FAILURE_THRESHOLD.times { hook.backoff! }
hook.backoff!
end
it "has the correct alert status", :aggregate_failures do
- get api(hook_uri, user)
+ get api(hook_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:ok)
@@ -156,7 +156,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
context "when user is forbidden" do
it "does not access an existing hook" do
- get api(hook_uri, unauthorized_user)
+ get api(hook_uri, unauthorized_user, admin_mode: true)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -171,13 +171,12 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
end
end
- describe "POST #{prefix}/hooks" do
+ describe "POST #{prefix}/hooks", :aggregate_failures do
let(:hook_creation_params) { hook_params }
it "adds hook", :aggregate_failures do
expect do
- post api(collection_uri, user),
- params: hook_creation_params
+ post api(collection_uri, user, admin_mode: user.admin?), params: hook_creation_params
end.to change { hooks_count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -201,8 +200,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
token = "secret token"
expect do
- post api(collection_uri, user),
- params: { url: "http://example.com", token: token }
+ post api(collection_uri, user, admin_mode: user.admin?), params: { url: "http://example.com", token: token }
end.to change { hooks_count }.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -216,19 +214,19 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
end
it "returns a 400 error if url not given" do
- post api(collection_uri, user), params: { event_names.first => true }
+ post api(collection_uri, user, admin_mode: user.admin?), params: { event_names.first => true }
expect(response).to have_gitlab_http_status(:bad_request)
end
it "returns a 400 error if no parameters are provided" do
- post api(collection_uri, user)
+ post api(collection_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'sets default values for events', :aggregate_failures do
- post api(collection_uri, user), params: { url: 'http://mep.mep' }
+ post api(collection_uri, user, admin_mode: user.admin?), params: { url: 'http://mep.mep' }
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_hook_schema
@@ -239,22 +237,22 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
end
it "returns a 422 error if token not valid" do
- post api(collection_uri, user),
+ post api(collection_uri, user, admin_mode: user.admin?),
params: { url: "http://example.com", token: "foo\nbar" }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
it "returns a 422 error if url not valid" do
- post api(collection_uri, user), params: { url: "ftp://example.com" }
+ post api(collection_uri, user, admin_mode: user.admin?), params: { url: "ftp://example.com" }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
end
- describe "PUT #{prefix}/hooks/:hook_id" do
+ describe "PUT #{prefix}/hooks/:hook_id", :aggregate_failures do
it "updates an existing hook" do
- put api(hook_uri, user), params: update_params
+ put api(hook_uri, user, admin_mode: user.admin?), params: update_params
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_hook_schema
@@ -267,7 +265,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
it 'updates the URL variables' do
hook.update!(url_variables: { 'abc' => 'some value' })
- put api(hook_uri, user),
+ put api(hook_uri, user, admin_mode: user.admin?),
params: { url_variables: [{ key: 'def', value: 'other value' }] }
expect(response).to have_gitlab_http_status(:ok)
@@ -280,7 +278,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
it "adds the token without including it in the response" do
token = "secret token"
- put api(hook_uri, user), params: { url: "http://example.org", token: token }
+ put api(hook_uri, user, admin_mode: user.admin?), params: { url: "http://example.org", token: token }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response["url"]).to eq("http://example.org")
@@ -291,68 +289,68 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
end
it "returns 404 error if hook id not found" do
- put api(hook_uri(non_existing_record_id), user), params: { url: 'http://example.org' }
+ put api(hook_uri(non_existing_record_id), user, admin_mode: user.admin?), params: { url: 'http://example.org' }
expect(response).to have_gitlab_http_status(:not_found)
end
it "returns 400 error if no parameters are provided" do
- put api(hook_uri, user)
+ put api(hook_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:bad_request)
end
it "returns a 422 error if url is not valid" do
- put api(hook_uri, user), params: { url: 'ftp://example.com' }
+ put api(hook_uri, user, admin_mode: user.admin?), params: { url: 'ftp://example.com' }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
it "returns a 422 error if token is not valid" do
- put api(hook_uri, user), params: { token: %w[foo bar].join("\n") }
+ put api(hook_uri, user, admin_mode: user.admin?), params: { token: %w[foo bar].join("\n") }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
end
- describe "DELETE /projects/:id/hooks/:hook_id" do
+ describe "DELETE /projects/:id/hooks/:hook_id", :aggregate_failures do
it "deletes hook from project" do
expect do
- delete api(hook_uri, user)
+ delete api(hook_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { hooks_count }.by(-1)
end
it "returns a 404 error when deleting non existent hook" do
- delete api(hook_uri(non_existing_record_id), user)
+ delete api(hook_uri(non_existing_record_id), user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
it "returns a 404 error if hook id not given" do
- delete api(collection_uri, user)
+ delete api(collection_uri, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
it "returns forbidden if a user attempts to delete hooks they do not own" do
- delete api(hook_uri, unauthorized_user)
+ delete api(hook_uri, unauthorized_user, admin_mode: true)
expect(response).to have_gitlab_http_status(:forbidden)
expect(WebHook.exists?(hook.id)).to be_truthy
end
it_behaves_like '412 response' do
- let(:request) { api(hook_uri, user) }
+ let(:request) { api(hook_uri, user, admin_mode: user.admin?) }
end
end
describe "PUT #{prefix}/hooks/:hook_id/url_variables/:key", :aggregate_failures do
it 'sets the variable' do
expect do
- put api("#{hook_uri}/url_variables/abc", user),
- params: { value: 'some secret value' }
+ put api("#{hook_uri}/url_variables/abc", user, admin_mode: user.admin?),
+ params: { value: 'some secret value' }
end.to change { hook.reload.url_variables }.to(eq('abc' => 'some secret value'))
expect(response).to have_gitlab_http_status(:no_content)
@@ -361,30 +359,30 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
it 'overwrites existing values' do
hook.update!(url_variables: { 'abc' => 'xyz', 'def' => 'other value' })
- put api("#{hook_uri}/url_variables/abc", user),
- params: { value: 'some secret value' }
+ put api("#{hook_uri}/url_variables/abc", user, admin_mode: user.admin?),
+ params: { value: 'some secret value' }
expect(response).to have_gitlab_http_status(:no_content)
expect(hook.reload.url_variables).to eq('abc' => 'some secret value', 'def' => 'other value')
end
it "returns a 404 error when editing non existent hook" do
- put api("#{hook_uri(non_existing_record_id)}/url_variables/abc", user),
- params: { value: 'xyz' }
+ put api("#{hook_uri(non_existing_record_id)}/url_variables/abc", user, admin_mode: user.admin?),
+ params: { value: 'xyz' }
expect(response).to have_gitlab_http_status(:not_found)
end
it "returns a 422 error when the key is illegal" do
- put api("#{hook_uri}/url_variables/abc%20def", user),
- params: { value: 'xyz' }
+ put api("#{hook_uri}/url_variables/abc%20def", user, admin_mode: user.admin?),
+ params: { value: 'xyz' }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
it "returns a 422 error when the value is illegal" do
- put api("#{hook_uri}/url_variables/abc", user),
- params: { value: '' }
+ put api("#{hook_uri}/url_variables/abc", user, admin_mode: user.admin?),
+ params: { value: '' }
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
@@ -397,7 +395,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
it 'unsets the variable' do
expect do
- delete api("#{hook_uri}/url_variables/abc", user)
+ delete api("#{hook_uri}/url_variables/abc", user, admin_mode: user.admin?)
end.to change { hook.reload.url_variables }.to(eq({ 'def' => 'other value' }))
expect(response).to have_gitlab_http_status(:no_content)
@@ -406,13 +404,13 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix|
it 'returns 404 for keys that do not exist' do
hook.update!(url_variables: { 'def' => 'other value' })
- delete api("#{hook_uri}/url_variables/abc", user)
+ delete api("#{hook_uri}/url_variables/abc", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
it "returns a 404 error when deleting a variable from a non existent hook" do
- delete api(hook_uri(non_existing_record_id) + "/url_variables/abc", user)
+ delete api(hook_uri(non_existing_record_id) + "/url_variables/abc", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/support/shared_examples/requests/api/integrations/github_enterprise_jira_dvcs_end_of_life_shared_examples.rb b/spec/support/shared_examples/requests/api/integrations/github_enterprise_jira_dvcs_end_of_life_shared_examples.rb
new file mode 100644
index 00000000000..6799dec7b80
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/integrations/github_enterprise_jira_dvcs_end_of_life_shared_examples.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'a GitHub Enterprise Jira DVCS reversible end of life endpoint' do
+ it 'is a reachable endpoint' do
+ subject
+
+ expect(response).not_to have_gitlab_http_status(:not_found)
+ end
+
+ context 'when the flag is disabled' do
+ before do
+ stub_feature_flags(jira_dvcs_end_of_life_amnesty: false)
+ end
+
+ it 'presents as an endpoint that does not exist' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/integrations/slack/slack_request_verification_shared_examples.rb b/spec/support/shared_examples/requests/api/integrations/slack/slack_request_verification_shared_examples.rb
new file mode 100644
index 00000000000..ddda9ca6bcc
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/integrations/slack/slack_request_verification_shared_examples.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'Slack request verification' do
+ describe 'unauthorized request' do
+ shared_examples 'an unauthorized request' do
+ specify do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ shared_examples 'a successful request that generates a tracked error' do
+ specify do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).once
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.body).to be_empty
+ end
+ end
+
+ context 'when the slack_app_signing_secret setting is not set' do
+ before do
+ stub_application_setting(slack_app_signing_secret: nil)
+ end
+
+ it_behaves_like 'an unauthorized request'
+ end
+
+ context 'when the timestamp header has expired' do
+ before do
+ headers[::API::Integrations::Slack::Request::VERIFICATION_TIMESTAMP_HEADER] = 5.minutes.ago.to_i.to_s
+ end
+
+ it_behaves_like 'an unauthorized request'
+ end
+
+ context 'when the timestamp header is missing' do
+ before do
+ headers.delete(::API::Integrations::Slack::Request::VERIFICATION_TIMESTAMP_HEADER)
+ end
+
+ it_behaves_like 'an unauthorized request'
+ end
+
+ context 'when the signature header is missing' do
+ before do
+ headers.delete(::API::Integrations::Slack::Request::VERIFICATION_SIGNATURE_HEADER)
+ end
+
+ it_behaves_like 'an unauthorized request'
+ end
+
+ context 'when the signature is not verified' do
+ before do
+ headers[::API::Integrations::Slack::Request::VERIFICATION_SIGNATURE_HEADER] = 'unverified_signature'
+ end
+
+ it_behaves_like 'an unauthorized request'
+ end
+
+ context 'when type param is missing' do
+ it_behaves_like 'a successful request that generates a tracked error'
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/issuable_update_shared_examples.rb b/spec/support/shared_examples/requests/api/issuable_update_shared_examples.rb
index 1045a92f332..e2c9874e7fc 100644
--- a/spec/support/shared_examples/requests/api/issuable_update_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/issuable_update_shared_examples.rb
@@ -34,5 +34,14 @@ RSpec.shared_examples 'issuable update endpoint' do
expect(json_response['labels']).to include '&'
expect(json_response['labels']).to include '?'
end
+
+ it 'clears milestone when milestone_id=0' do
+ entity.update!(milestone: milestone)
+
+ put api(url, user), params: { milestone_id: 0 }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['milestone']).to be_nil
+ end
end
end
diff --git a/spec/support/shared_examples/requests/api/labels_api_shared_examples.rb b/spec/support/shared_examples/requests/api/labels_api_shared_examples.rb
index 41d21490343..fba0533251a 100644
--- a/spec/support/shared_examples/requests/api/labels_api_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/labels_api_shared_examples.rb
@@ -9,6 +9,6 @@ RSpec.shared_examples 'fetches labels' do
expect(json_response).to be_an Array
expect(json_response).to all(match_schema('public_api/v4/labels/label'))
expect(json_response.size).to eq(expected_labels.size)
- expect(json_response.map { |r| r['name'] }).to match_array(expected_labels)
+ expect(json_response.pluck('name')).to match_array(expected_labels)
end
end
diff --git a/spec/support/shared_examples/requests/api/milestones_shared_examples.rb b/spec/support/shared_examples/requests/api/milestones_shared_examples.rb
index 1ea11ba3d7c..ee7d0e86771 100644
--- a/spec/support/shared_examples/requests/api/milestones_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/milestones_shared_examples.rb
@@ -52,7 +52,7 @@ RSpec.shared_examples 'group and project milestones' do |route_definition|
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.length).to eq(2)
- expect(json_response.map { |m| m['id'] }).to match_array([closed_milestone.id, other_milestone.id])
+ expect(json_response.pluck('id')).to match_array([closed_milestone.id, other_milestone.id])
end
it 'does not return any milestone if none found' do
@@ -293,7 +293,7 @@ RSpec.shared_examples 'group and project milestones' do |route_definition|
expect(json_response).to be_an Array
# 2 for projects, 3 for group(which has another project with an issue)
expect(json_response.size).to be_between(2, 3)
- expect(json_response.map { |issue| issue['id'] }).to include(issue.id, confidential_issue.id)
+ expect(json_response.pluck('id')).to include(issue.id, confidential_issue.id)
end
it 'does not return confidential issues to team members with guest role' do
@@ -306,7 +306,7 @@ RSpec.shared_examples 'group and project milestones' do |route_definition|
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.size).to eq(1)
- expect(json_response.map { |issue| issue['id'] }).to include(issue.id)
+ expect(json_response.pluck('id')).to include(issue.id)
end
it 'does not return confidential issues to regular users' do
@@ -316,7 +316,7 @@ RSpec.shared_examples 'group and project milestones' do |route_definition|
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.size).to eq(1)
- expect(json_response.map { |issue| issue['id'] }).to include(issue.id)
+ expect(json_response.pluck('id')).to include(issue.id)
end
it 'returns issues ordered by label priority' do
diff --git a/spec/support/shared_examples/requests/api/ml/mlflow/mlflow_shared_examples.rb b/spec/support/shared_examples/requests/api/ml/mlflow/mlflow_shared_examples.rb
new file mode 100644
index 00000000000..2ca62698daf
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/ml/mlflow/mlflow_shared_examples.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'MLflow|Not Found - Resource Does Not Exist' do
+ it "is Resource Does Not Exist", :aggregate_failures do
+ is_expected.to have_gitlab_http_status(:not_found)
+
+ expect(json_response).to include({ "error_code" => 'RESOURCE_DOES_NOT_EXIST' })
+ end
+end
+
+RSpec.shared_examples 'MLflow|Requires api scope' do
+ context 'when user has access but token has wrong scope' do
+ let(:access_token) { tokens[:read] }
+
+ it { is_expected.to have_gitlab_http_status(:forbidden) }
+ end
+end
+
+RSpec.shared_examples 'MLflow|Requires read_api scope' do
+ context 'when user has access but token has wrong scope' do
+ let(:access_token) { tokens[:no_access] }
+
+ it { is_expected.to have_gitlab_http_status(:forbidden) }
+ end
+end
+
+RSpec.shared_examples 'MLflow|Bad Request' do
+ it "is Bad Request" do
+ is_expected.to have_gitlab_http_status(:bad_request)
+ end
+end
+
+RSpec.shared_examples 'MLflow|shared error cases' do
+ context 'when not authenticated' do
+ let(:headers) { {} }
+
+ it "is Unauthorized" do
+ is_expected.to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user does not have access' do
+ let(:access_token) { tokens[:different_user] }
+
+ it "is Not Found" do
+ is_expected.to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when ff is disabled' do
+ let(:ff_value) { false }
+
+ it "is Not Found" do
+ is_expected.to have_gitlab_http_status(:not_found)
+ end
+ end
+end
+
+RSpec.shared_examples 'MLflow|Bad Request on missing required' do |keys|
+ keys.each do |key|
+ context "when \"#{key}\" is missing" do
+ let(:params) { default_params.tap { |p| p.delete(key) } }
+
+ it "is Bad Request" do
+ is_expected.to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/notes_shared_examples.rb b/spec/support/shared_examples/requests/api/notes_shared_examples.rb
index efe5ed3bcf9..b44ff952cdf 100644
--- a/spec/support/shared_examples/requests/api/notes_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/notes_shared_examples.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
- describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do
+ describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes", :aggregate_failures do
context 'sorting' do
before do
params = { noteable: noteable, author: user }
@@ -12,9 +12,9 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
context 'without sort params' do
it 'sorts by created_at in descending order by default' do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?)
- response_dates = json_response.map { |note| note['created_at'] }
+ response_dates = json_response.pluck('created_at')
expect(json_response.length).to eq(4)
expect(response_dates).to eq(response_dates.sort.reverse)
@@ -23,7 +23,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
it 'fetches notes using parent path as id paremeter' do
parent_id = CGI.escape(parent.full_path)
- get api("/#{parent_type}/#{parent_id}/#{noteable_type}/#{noteable[id_name]}/notes", user)
+ get api("/#{parent_type}/#{parent_id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -40,18 +40,18 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
it 'page breaks first page correctly' do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?per_page=4", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?per_page=4", user, admin_mode: user.admin?)
- response_ids = json_response.map { |note| note['id'] }
+ response_ids = json_response.pluck('id')
expect(response_ids).to include(@note2.id)
expect(response_ids).not_to include(@first_note.id)
end
it 'page breaks second page correctly' do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?per_page=4&page=2", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?per_page=4&page=2", user, admin_mode: user.admin?)
- response_ids = json_response.map { |note| note['id'] }
+ response_ids = json_response.pluck('id')
expect(response_ids).not_to include(@note2.id)
expect(response_ids).to include(@first_note.id)
@@ -60,27 +60,27 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
it 'sorts by ascending order when requested' do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?sort=asc", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?sort=asc", user, admin_mode: user.admin?)
- response_dates = json_response.map { |note| note['created_at'] }
+ response_dates = json_response.pluck('created_at')
expect(json_response.length).to eq(4)
expect(response_dates).to eq(response_dates.sort)
end
it 'sorts by updated_at in descending order when requested' do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?order_by=updated_at", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?order_by=updated_at", user, admin_mode: user.admin?)
- response_dates = json_response.map { |note| note['updated_at'] }
+ response_dates = json_response.pluck('updated_at')
expect(json_response.length).to eq(4)
expect(response_dates).to eq(response_dates.sort.reverse)
end
it 'sorts by updated_at in ascending order when requested' do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?order_by=updated_at&sort=asc", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?order_by=updated_at&sort=asc", user, admin_mode: user.admin?)
- response_dates = json_response.map { |note| note['updated_at'] }
+ response_dates = json_response.pluck('updated_at')
expect(json_response.length).to eq(4)
expect(response_dates).to eq(response_dates.sort)
@@ -88,7 +88,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
it "returns an array of notes" do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
@@ -97,7 +97,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
it "returns a 404 error when noteable id not found" do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{non_existing_record_id}/notes", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{non_existing_record_id}/notes", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -105,36 +105,36 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
it "returns 404 when not authorized" do
parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user, admin_mode: private_user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
end
- describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do
+ describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id", :aggregate_failures do
it "returns a note by id" do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['body']).to eq(note.note)
end
it "returns a 404 error if note not found" do
- get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{non_existing_record_id}", user)
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{non_existing_record_id}", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
end
- describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do
+ describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes", :aggregate_failures do
let(:params) { { body: 'hi!' } }
subject do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?), params: params
end
it "creates a new note" do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: { body: 'hi!' }
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?), params: { body: 'hi!' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['body']).to eq('hi!')
@@ -143,7 +143,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
it "returns a 400 bad request error if body not given" do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user)
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -158,7 +158,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
uri = "/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes"
expect do
- post api(uri, user), params: { body: 'hi!' }
+ post api(uri, user, admin_mode: user.admin?), params: { body: 'hi!' }
end.to change { Event.count }.by(1)
end
@@ -169,7 +169,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
context 'by an admin' do
it 'sets the creation time on the new note' do
admin = create(:admin)
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", admin), params: params
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", admin, admin_mode: true), params: params
expect(response).to have_gitlab_http_status(:created)
expect(json_response['body']).to eq('hi!')
@@ -185,7 +185,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
let(:user) { project.first_owner }
it 'sets the creation time on the new note' do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?), params: params
expect(response).to have_gitlab_http_status(:created)
expect(json_response['body']).to eq('hi!')
@@ -215,7 +215,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
when 'groups'
context 'by a group owner' do
it 'sets the creation time on the new note' do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?), params: params
expect(response).to have_gitlab_http_status(:created)
expect(json_response['body']).to eq('hi!')
@@ -253,7 +253,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
context 'when the user is posting an award emoji on their own noteable' do
it 'creates a new note' do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: { body: ':+1:' }
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?), params: { body: ':+1:' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['body']).to eq(':+1:')
@@ -266,7 +266,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
it 'responds with 404' do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user),
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user, admin_mode: private_user.admin?),
params: { body: 'Foo' }
expect(response).to have_gitlab_http_status(:not_found)
@@ -299,11 +299,11 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
end
- describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do
+ describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id", :aggregate_failures do
let(:params) { { body: 'Hello!' } }
subject do
- put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user), params: params
+ put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user, admin_mode: user.admin?), params: params
end
context 'when only body param is present' do
@@ -329,40 +329,40 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
it 'returns a 404 error when note id not found' do
- put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{non_existing_record_id}", user),
- params: { body: 'Hello!' }
+ put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{non_existing_record_id}", user, admin_mode: user.admin?),
+ params: { body: 'Hello!' }
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns a 400 bad request error if body is empty' do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
- "notes/#{note.id}", user), params: { body: '' }
+ "notes/#{note.id}", user, admin_mode: user.admin?), params: { body: '' }
expect(response).to have_gitlab_http_status(:bad_request)
end
end
- describe "DELETE /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do
+ describe "DELETE /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id", :aggregate_failures do
it 'deletes a note' do
delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
- "notes/#{note.id}", user)
+ "notes/#{note.id}", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:no_content)
# Check if note is really deleted
delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
- "notes/#{note.id}", user)
+ "notes/#{note.id}", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns a 404 error when note id not found' do
- delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{non_existing_record_id}", user)
+ delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{non_existing_record_id}", user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
it_behaves_like '412 response' do
- let(:request) { api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user) }
+ let(:request) { api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user, admin_mode: user.admin?) }
end
end
end
@@ -370,16 +370,16 @@ end
RSpec.shared_examples 'noteable API with confidential notes' do |parent_type, noteable_type, id_name|
it_behaves_like 'noteable API', parent_type, noteable_type, id_name
- describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do
+ describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes", :aggregate_failures do
let(:params) { { body: 'hi!' } }
subject do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?), params: params
end
context 'with internal param' do
it "creates a confidential note if internal is set to true" do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params.merge(internal: true)
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?), params: params.merge(internal: true)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['body']).to eq('hi!')
@@ -391,7 +391,7 @@ RSpec.shared_examples 'noteable API with confidential notes' do |parent_type, no
context 'with deprecated confidential param' do
it "creates a confidential note if confidential is set to true" do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params.merge(confidential: true)
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user, admin_mode: user.admin?), params: params.merge(confidential: true)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['body']).to eq('hi!')
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 b55639a6b82..f53532d00d7 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
@@ -507,55 +507,118 @@ RSpec.shared_examples 'handling get dist tags requests' do |scope: :project|
it_behaves_like 'returning response status', status
end
- shared_examples 'handling different package names, visibilities and user roles' do
- where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do
- :scoped_naming_convention | :public | :anonymous | :accept | :ok
- :scoped_naming_convention | :public | :guest | :accept | :ok
- :scoped_naming_convention | :public | :reporter | :accept | :ok
- :scoped_no_naming_convention | :public | :anonymous | :accept | :ok
- :scoped_no_naming_convention | :public | :guest | :accept | :ok
- :scoped_no_naming_convention | :public | :reporter | :accept | :ok
- :unscoped | :public | :anonymous | :accept | :ok
- :unscoped | :public | :guest | :accept | :ok
- :unscoped | :public | :reporter | :accept | :ok
- :non_existing | :public | :anonymous | :reject | :not_found
- :non_existing | :public | :guest | :reject | :not_found
- :non_existing | :public | :reporter | :reject | :not_found
-
- :scoped_naming_convention | :private | :anonymous | :reject | :not_found
- :scoped_naming_convention | :private | :guest | :reject | :forbidden
- :scoped_naming_convention | :private | :reporter | :accept | :ok
- :scoped_no_naming_convention | :private | :anonymous | :reject | :not_found
- :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
- :scoped_no_naming_convention | :private | :reporter | :accept | :ok
- :unscoped | :private | :anonymous | :reject | :not_found
- :unscoped | :private | :guest | :reject | :forbidden
- :unscoped | :private | :reporter | :accept | :ok
- :non_existing | :private | :anonymous | :reject | :not_found
- :non_existing | :private | :guest | :reject | :forbidden
- :non_existing | :private | :reporter | :reject | :not_found
-
- :scoped_naming_convention | :internal | :anonymous | :reject | :not_found
- :scoped_naming_convention | :internal | :guest | :accept | :ok
- :scoped_naming_convention | :internal | :reporter | :accept | :ok
- :scoped_no_naming_convention | :internal | :anonymous | :reject | :not_found
- :scoped_no_naming_convention | :internal | :guest | :accept | :ok
- :scoped_no_naming_convention | :internal | :reporter | :accept | :ok
- :unscoped | :internal | :anonymous | :reject | :not_found
- :unscoped | :internal | :guest | :accept | :ok
- :unscoped | :internal | :reporter | :accept | :ok
- :non_existing | :internal | :anonymous | :reject | :not_found
- :non_existing | :internal | :guest | :reject | :not_found
- :non_existing | :internal | :reporter | :reject | :not_found
+ shared_examples 'handling all conditions' do
+ where(:auth, :package_name_type, :visibility, :user_role, :expected_result, :expected_status) do
+ nil | :scoped_naming_convention | :public | nil | :accept | :ok
+ nil | :scoped_no_naming_convention | :public | nil | :accept | :ok
+ nil | :unscoped | :public | nil | :accept | :ok
+ nil | :non_existing | :public | nil | :reject | :not_found
+ nil | :scoped_naming_convention | :private | nil | :reject | :not_found
+ nil | :scoped_no_naming_convention | :private | nil | :reject | :not_found
+ nil | :unscoped | :private | nil | :reject | :not_found
+ nil | :non_existing | :private | nil | :reject | :not_found
+ nil | :scoped_naming_convention | :internal | nil | :reject | :not_found
+ nil | :scoped_no_naming_convention | :internal | nil | :reject | :not_found
+ nil | :unscoped | :internal | nil | :reject | :not_found
+ nil | :non_existing | :internal | nil | :reject | :not_found
+
+ :oauth | :scoped_naming_convention | :public | :guest | :accept | :ok
+ :oauth | :scoped_naming_convention | :public | :reporter | :accept | :ok
+ :oauth | :scoped_no_naming_convention | :public | :guest | :accept | :ok
+ :oauth | :scoped_no_naming_convention | :public | :reporter | :accept | :ok
+ :oauth | :unscoped | :public | :guest | :accept | :ok
+ :oauth | :unscoped | :public | :reporter | :accept | :ok
+ :oauth | :non_existing | :public | :guest | :reject | :not_found
+ :oauth | :non_existing | :public | :reporter | :reject | :not_found
+ :oauth | :scoped_naming_convention | :private | :guest | :reject | :forbidden
+ :oauth | :scoped_naming_convention | :private | :reporter | :accept | :ok
+ :oauth | :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
+ :oauth | :scoped_no_naming_convention | :private | :reporter | :accept | :ok
+ :oauth | :unscoped | :private | :guest | :reject | :forbidden
+ :oauth | :unscoped | :private | :reporter | :accept | :ok
+ :oauth | :non_existing | :private | :guest | :reject | :forbidden
+ :oauth | :non_existing | :private | :reporter | :reject | :not_found
+ :oauth | :scoped_naming_convention | :internal | :guest | :accept | :ok
+ :oauth | :scoped_naming_convention | :internal | :reporter | :accept | :ok
+ :oauth | :scoped_no_naming_convention | :internal | :guest | :accept | :ok
+ :oauth | :scoped_no_naming_convention | :internal | :reporter | :accept | :ok
+ :oauth | :unscoped | :internal | :guest | :accept | :ok
+ :oauth | :unscoped | :internal | :reporter | :accept | :ok
+ :oauth | :non_existing | :internal | :guest | :reject | :not_found
+ :oauth | :non_existing | :internal | :reporter | :reject | :not_found
+
+ :personal_access_token | :scoped_naming_convention | :public | :guest | :accept | :ok
+ :personal_access_token | :scoped_naming_convention | :public | :reporter | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | :public | :guest | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | :public | :reporter | :accept | :ok
+ :personal_access_token | :unscoped | :public | :guest | :accept | :ok
+ :personal_access_token | :unscoped | :public | :reporter | :accept | :ok
+ :personal_access_token | :non_existing | :public | :guest | :reject | :not_found
+ :personal_access_token | :non_existing | :public | :reporter | :reject | :not_found
+ :personal_access_token | :scoped_naming_convention | :private | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_naming_convention | :private | :reporter | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_no_naming_convention | :private | :reporter | :accept | :ok
+ :personal_access_token | :unscoped | :private | :guest | :reject | :forbidden
+ :personal_access_token | :unscoped | :private | :reporter | :accept | :ok
+ :personal_access_token | :non_existing | :private | :guest | :reject | :forbidden
+ :personal_access_token | :non_existing | :private | :reporter | :reject | :not_found
+ :personal_access_token | :scoped_naming_convention | :internal | :guest | :accept | :ok
+ :personal_access_token | :scoped_naming_convention | :internal | :reporter | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | :internal | :guest | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | :internal | :reporter | :accept | :ok
+ :personal_access_token | :unscoped | :internal | :guest | :accept | :ok
+ :personal_access_token | :unscoped | :internal | :reporter | :accept | :ok
+ :personal_access_token | :non_existing | :internal | :guest | :reject | :not_found
+ :personal_access_token | :non_existing | :internal | :reporter | :reject | :not_found
+
+ :job_token | :scoped_naming_convention | :public | :developer | :accept | :ok
+ :job_token | :scoped_no_naming_convention | :public | :developer | :accept | :ok
+ :job_token | :unscoped | :public | :developer | :accept | :ok
+ :job_token | :non_existing | :public | :developer | :reject | :not_found
+ :job_token | :scoped_naming_convention | :private | :developer | :accept | :ok
+ :job_token | :scoped_no_naming_convention | :private | :developer | :accept | :ok
+ :job_token | :unscoped | :private | :developer | :accept | :ok
+ :job_token | :non_existing | :private | :developer | :reject | :not_found
+ :job_token | :scoped_naming_convention | :internal | :developer | :accept | :ok
+ :job_token | :scoped_no_naming_convention | :internal | :developer | :accept | :ok
+ :job_token | :unscoped | :internal | :developer | :accept | :ok
+ :job_token | :non_existing | :internal | :developer | :reject | :not_found
+
+ :deploy_token | :scoped_naming_convention | :public | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | :public | nil | :accept | :ok
+ :deploy_token | :unscoped | :public | nil | :accept | :ok
+ :deploy_token | :non_existing | :public | nil | :reject | :not_found
+ :deploy_token | :scoped_naming_convention | :private | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | :private | nil | :accept | :ok
+ :deploy_token | :unscoped | :private | nil | :accept | :ok
+ :deploy_token | :non_existing | :private | nil | :reject | :not_found
+ :deploy_token | :scoped_naming_convention | :internal | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | :internal | nil | :accept | :ok
+ :deploy_token | :unscoped | :internal | nil | :accept | :ok
+ :deploy_token | :non_existing | :internal | nil | :reject | :not_found
end
with_them do
- let(:anonymous) { user_role == :anonymous }
+ let(:headers) do
+ case auth
+ when :oauth
+ build_token_auth_header(token.plaintext_token)
+ when :personal_access_token
+ build_token_auth_header(personal_access_token.token)
+ when :job_token
+ build_token_auth_header(job.token)
+ when :deploy_token
+ build_token_auth_header(deploy_token.token)
+ else
+ {}
+ end
+ end
- subject { get(url, headers: anonymous ? {} : headers) }
+ subject { get(url, headers: headers) }
before do
- project.send("add_#{user_role}", user) unless anonymous
+ project.send("add_#{user_role}", user) if user_role
project.update!(visibility: visibility.to_s)
end
@@ -571,20 +634,6 @@ RSpec.shared_examples 'handling get dist tags requests' do |scope: :project|
end
end
- shared_examples 'handling all conditions' do
- context 'with oauth token' do
- let(:headers) { build_token_auth_header(token.plaintext_token) }
-
- it_behaves_like 'handling different package names, visibilities and user roles'
- end
-
- context 'with personal access token' do
- let(:headers) { build_token_auth_header(personal_access_token.token) }
-
- it_behaves_like 'handling different package names, visibilities and user roles'
- end
- end
-
context 'with a group namespace' do
it_behaves_like 'handling all conditions'
end
@@ -599,7 +648,6 @@ RSpec.shared_examples 'handling get dist tags requests' do |scope: :project|
end
RSpec.shared_examples 'handling create dist tag requests' do |scope: :project|
- using RSpec::Parameterized::TableSyntax
include_context 'set package name from package name type'
let_it_be(:tag_name) { 'test' }
@@ -617,82 +665,10 @@ RSpec.shared_examples 'handling create dist tag requests' do |scope: :project|
it_behaves_like 'returning response status', status
end
- shared_examples 'handling different package names, visibilities and user roles' do
- where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do
- :scoped_naming_convention | :public | :anonymous | :reject | :forbidden
- :scoped_naming_convention | :public | :guest | :reject | :forbidden
- :scoped_naming_convention | :public | :developer | :accept | :ok
- :scoped_no_naming_convention | :public | :anonymous | :reject | :forbidden
- :scoped_no_naming_convention | :public | :guest | :reject | :forbidden
- :scoped_no_naming_convention | :public | :developer | :accept | :ok
- :unscoped | :public | :anonymous | :reject | :forbidden
- :unscoped | :public | :guest | :reject | :forbidden
- :unscoped | :public | :developer | :accept | :ok
- :non_existing | :public | :anonymous | :reject | :forbidden
- :non_existing | :public | :guest | :reject | :forbidden
- :non_existing | :public | :developer | :reject | :not_found
-
- :scoped_naming_convention | :private | :anonymous | :reject | :not_found
- :scoped_naming_convention | :private | :guest | :reject | :forbidden
- :scoped_naming_convention | :private | :developer | :accept | :ok
- :scoped_no_naming_convention | :private | :anonymous | :reject | :not_found
- :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
- :scoped_no_naming_convention | :private | :developer | :accept | :ok
- :unscoped | :private | :anonymous | :reject | :not_found
- :unscoped | :private | :guest | :reject | :forbidden
- :unscoped | :private | :developer | :accept | :ok
- :non_existing | :private | :anonymous | :reject | :not_found
- :non_existing | :private | :guest | :reject | :forbidden
- :non_existing | :private | :developer | :reject | :not_found
-
- :scoped_naming_convention | :internal | :anonymous | :reject | :forbidden
- :scoped_naming_convention | :internal | :guest | :reject | :forbidden
- :scoped_naming_convention | :internal | :developer | :accept | :ok
- :scoped_no_naming_convention | :internal | :anonymous | :reject | :forbidden
- :scoped_no_naming_convention | :internal | :guest | :reject | :forbidden
- :scoped_no_naming_convention | :internal | :developer | :accept | :ok
- :unscoped | :internal | :anonymous | :reject | :forbidden
- :unscoped | :internal | :guest | :reject | :forbidden
- :unscoped | :internal | :developer | :accept | :ok
- :non_existing | :internal | :anonymous | :reject | :forbidden
- :non_existing | :internal | :guest | :reject | :forbidden
- :non_existing | :internal | :developer | :reject | :not_found
- end
-
- with_them do
- let(:anonymous) { user_role == :anonymous }
-
- subject { put(url, env: env, headers: headers) }
-
- before do
- project.send("add_#{user_role}", user) unless anonymous
- project.update!(visibility: visibility.to_s)
- end
-
- example_name = "#{params[:expected_result]} create package tag request"
- status = params[:expected_status]
-
- if scope == :instance && params[:package_name_type] != :scoped_naming_convention
- example_name = 'reject create package tag request'
- status = :not_found
- end
-
- it_behaves_like example_name, status: status
- end
- end
-
shared_examples 'handling all conditions' do
- context 'with oauth token' do
- let(:headers) { build_token_auth_header(token.plaintext_token) }
+ subject { put(url, env: env, headers: headers) }
- it_behaves_like 'handling different package names, visibilities and user roles'
- end
-
- context 'with personal access token' do
- let(:headers) { build_token_auth_header(personal_access_token.token) }
-
- it_behaves_like 'handling different package names, visibilities and user roles'
- end
+ it_behaves_like 'handling different package names, visibilities and user roles for tags create or delete', action: :create, scope: scope
end
context 'with a group namespace' do
@@ -709,7 +685,6 @@ RSpec.shared_examples 'handling create dist tag requests' do |scope: :project|
end
RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project|
- using RSpec::Parameterized::TableSyntax
include_context 'set package name from package name type'
let_it_be(:package_tag) { create(:packages_tag, package: package) }
@@ -725,82 +700,10 @@ RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project|
it_behaves_like 'returning response status', status
end
- shared_examples 'handling different package names, visibilities and user roles' do
- where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do
- :scoped_naming_convention | :public | :anonymous | :reject | :forbidden
- :scoped_naming_convention | :public | :guest | :reject | :forbidden
- :scoped_naming_convention | :public | :maintainer | :accept | :ok
- :scoped_no_naming_convention | :public | :anonymous | :reject | :forbidden
- :scoped_no_naming_convention | :public | :guest | :reject | :forbidden
- :scoped_no_naming_convention | :public | :maintainer | :accept | :ok
- :unscoped | :public | :anonymous | :reject | :forbidden
- :unscoped | :public | :guest | :reject | :forbidden
- :unscoped | :public | :maintainer | :accept | :ok
- :non_existing | :public | :anonymous | :reject | :forbidden
- :non_existing | :public | :guest | :reject | :forbidden
- :non_existing | :public | :maintainer | :reject | :not_found
-
- :scoped_naming_convention | :private | :anonymous | :reject | :not_found
- :scoped_naming_convention | :private | :guest | :reject | :forbidden
- :scoped_naming_convention | :private | :maintainer | :accept | :ok
- :scoped_no_naming_convention | :private | :anonymous | :reject | :not_found
- :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
- :scoped_no_naming_convention | :private | :maintainer | :accept | :ok
- :unscoped | :private | :anonymous | :reject | :not_found
- :unscoped | :private | :guest | :reject | :forbidden
- :unscoped | :private | :maintainer | :accept | :ok
- :non_existing | :private | :anonymous | :reject | :not_found
- :non_existing | :private | :guest | :reject | :forbidden
- :non_existing | :private | :maintainer | :reject | :not_found
-
- :scoped_naming_convention | :internal | :anonymous | :reject | :forbidden
- :scoped_naming_convention | :internal | :guest | :reject | :forbidden
- :scoped_naming_convention | :internal | :maintainer | :accept | :ok
- :scoped_no_naming_convention | :internal | :anonymous | :reject | :forbidden
- :scoped_no_naming_convention | :internal | :guest | :reject | :forbidden
- :scoped_no_naming_convention | :internal | :maintainer | :accept | :ok
- :unscoped | :internal | :anonymous | :reject | :forbidden
- :unscoped | :internal | :guest | :reject | :forbidden
- :unscoped | :internal | :maintainer | :accept | :ok
- :non_existing | :internal | :anonymous | :reject | :forbidden
- :non_existing | :internal | :guest | :reject | :forbidden
- :non_existing | :internal | :maintainer | :reject | :not_found
- end
-
- with_them do
- let(:anonymous) { user_role == :anonymous }
-
- subject { delete(url, headers: headers) }
-
- before do
- project.send("add_#{user_role}", user) unless anonymous
- project.update!(visibility: visibility.to_s)
- end
-
- example_name = "#{params[:expected_result]} delete package tag request"
- status = params[:expected_status]
-
- if scope == :instance && params[:package_name_type] != :scoped_naming_convention
- example_name = 'reject delete package tag request'
- status = :not_found
- end
-
- it_behaves_like example_name, status: status
- end
- end
-
shared_examples 'handling all conditions' do
- context 'with oauth token' do
- let(:headers) { build_token_auth_header(token.plaintext_token) }
-
- it_behaves_like 'handling different package names, visibilities and user roles'
- end
-
- context 'with personal access token' do
- let(:headers) { build_token_auth_header(personal_access_token.token) }
+ subject { delete(url, headers: headers) }
- it_behaves_like 'handling different package names, visibilities and user roles'
- end
+ it_behaves_like 'handling different package names, visibilities and user roles for tags create or delete', action: :delete, scope: scope
end
context 'with a group namespace' do
@@ -815,3 +718,134 @@ RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project|
end
end
end
+
+RSpec.shared_examples 'handling different package names, visibilities and user roles for tags create or delete' do |action:, scope: :project|
+ using RSpec::Parameterized::TableSyntax
+
+ role = action == :create ? :developer : :maintainer
+
+ where(:auth, :package_name_type, :visibility, :user_role, :expected_result, :expected_status) do
+ nil | :scoped_naming_convention | :public | nil | :reject | :unauthorized
+ nil | :scoped_no_naming_convention | :public | nil | :reject | :unauthorized
+ nil | :unscoped | :public | nil | :reject | :unauthorized
+ nil | :non_existing | :public | nil | :reject | :unauthorized
+ nil | :scoped_naming_convention | :private | nil | :reject | :unauthorized
+ nil | :scoped_no_naming_convention | :private | nil | :reject | :unauthorized
+ nil | :unscoped | :private | nil | :reject | :unauthorized
+ nil | :non_existing | :private | nil | :reject | :unauthorized
+ nil | :scoped_naming_convention | :internal | nil | :reject | :unauthorized
+ nil | :scoped_no_naming_convention | :internal | nil | :reject | :unauthorized
+ nil | :unscoped | :internal | nil | :reject | :unauthorized
+ nil | :non_existing | :internal | nil | :reject | :unauthorized
+
+ :oauth | :scoped_naming_convention | :public | :guest | :reject | :forbidden
+ :oauth | :scoped_naming_convention | :public | role | :accept | :ok
+ :oauth | :scoped_no_naming_convention | :public | :guest | :reject | :forbidden
+ :oauth | :scoped_no_naming_convention | :public | role | :accept | :ok
+ :oauth | :unscoped | :public | :guest | :reject | :forbidden
+ :oauth | :unscoped | :public | role | :accept | :ok
+ :oauth | :non_existing | :public | :guest | :reject | :forbidden
+ :oauth | :non_existing | :public | role | :reject | :not_found
+ :oauth | :scoped_naming_convention | :private | :guest | :reject | :forbidden
+ :oauth | :scoped_naming_convention | :private | role | :accept | :ok
+ :oauth | :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
+ :oauth | :scoped_no_naming_convention | :private | role | :accept | :ok
+ :oauth | :unscoped | :private | :guest | :reject | :forbidden
+ :oauth | :unscoped | :private | role | :accept | :ok
+ :oauth | :non_existing | :private | :guest | :reject | :forbidden
+ :oauth | :non_existing | :private | role | :reject | :not_found
+ :oauth | :scoped_naming_convention | :internal | :guest | :reject | :forbidden
+ :oauth | :scoped_naming_convention | :internal | role | :accept | :ok
+ :oauth | :scoped_no_naming_convention | :internal | :guest | :reject | :forbidden
+ :oauth | :scoped_no_naming_convention | :internal | role | :accept | :ok
+ :oauth | :unscoped | :internal | :guest | :reject | :forbidden
+ :oauth | :unscoped | :internal | role | :accept | :ok
+ :oauth | :non_existing | :internal | :guest | :reject | :forbidden
+ :oauth | :non_existing | :internal | role | :reject | :not_found
+
+ :personal_access_token | :scoped_naming_convention | :public | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_naming_convention | :public | role | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | :public | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_no_naming_convention | :public | role | :accept | :ok
+ :personal_access_token | :unscoped | :public | :guest | :reject | :forbidden
+ :personal_access_token | :unscoped | :public | role | :accept | :ok
+ :personal_access_token | :non_existing | :public | :guest | :reject | :forbidden
+ :personal_access_token | :non_existing | :public | role | :reject | :not_found
+ :personal_access_token | :scoped_naming_convention | :private | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_naming_convention | :private | role | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_no_naming_convention | :private | role | :accept | :ok
+ :personal_access_token | :unscoped | :private | :guest | :reject | :forbidden
+ :personal_access_token | :unscoped | :private | role | :accept | :ok
+ :personal_access_token | :non_existing | :private | :guest | :reject | :forbidden
+ :personal_access_token | :non_existing | :private | role | :reject | :not_found
+ :personal_access_token | :scoped_naming_convention | :internal | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_naming_convention | :internal | role | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | :internal | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_no_naming_convention | :internal | role | :accept | :ok
+ :personal_access_token | :unscoped | :internal | :guest | :reject | :forbidden
+ :personal_access_token | :unscoped | :internal | role | :accept | :ok
+ :personal_access_token | :non_existing | :internal | :guest | :reject | :forbidden
+ :personal_access_token | :non_existing | :internal | role | :reject | :not_found
+
+ :job_token | :scoped_naming_convention | :public | role | :accept | :ok
+ :job_token | :scoped_no_naming_convention | :public | role | :accept | :ok
+ :job_token | :unscoped | :public | role | :accept | :ok
+ :job_token | :non_existing | :public | role | :reject | :not_found
+ :job_token | :scoped_naming_convention | :private | role | :accept | :ok
+ :job_token | :scoped_no_naming_convention | :private | role | :accept | :ok
+ :job_token | :unscoped | :private | role | :accept | :ok
+ :job_token | :non_existing | :private | role | :reject | :not_found
+ :job_token | :scoped_naming_convention | :internal | role | :accept | :ok
+ :job_token | :scoped_no_naming_convention | :internal | role | :accept | :ok
+ :job_token | :unscoped | :internal | role | :accept | :ok
+ :job_token | :non_existing | :internal | role | :reject | :not_found
+
+ :deploy_token | :scoped_naming_convention | :public | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | :public | nil | :accept | :ok
+ :deploy_token | :unscoped | :public | nil | :accept | :ok
+ :deploy_token | :non_existing | :public | nil | :reject | :not_found
+ :deploy_token | :scoped_naming_convention | :private | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | :private | nil | :accept | :ok
+ :deploy_token | :unscoped | :private | nil | :accept | :ok
+ :deploy_token | :non_existing | :private | nil | :reject | :not_found
+ :deploy_token | :scoped_naming_convention | :internal | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | :internal | nil | :accept | :ok
+ :deploy_token | :unscoped | :internal | nil | :accept | :ok
+ :deploy_token | :non_existing | :internal | nil | :reject | :not_found
+ end
+
+ with_them do
+ let(:headers) do
+ case auth
+ when :oauth
+ build_token_auth_header(token.plaintext_token)
+ when :personal_access_token
+ build_token_auth_header(personal_access_token.token)
+ when :job_token
+ build_token_auth_header(job.token)
+ when :deploy_token
+ build_token_auth_header(deploy_token.token)
+ else
+ {}
+ end
+ end
+
+ before do
+ project.send("add_#{user_role}", user) if user_role
+ project.update!(visibility: visibility.to_s)
+ end
+
+ example_name = "#{params[:expected_result]} #{action} package tag request"
+ status = params[:expected_status]
+
+ if scope == :instance && params[:package_name_type] != :scoped_naming_convention
+ example_name = "reject #{action} package tag request"
+ # Due to #authenticate_non_get, anonymous requests on private resources
+ # are rejected with unauthorized status
+ status = params[:auth].nil? ? :unauthorized : :not_found
+ end
+
+ it_behaves_like example_name, status: status
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb
index 1d79a61fbb0..7c20ea661b5 100644
--- a/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb
@@ -1,13 +1,5 @@
# frozen_string_literal: true
-RSpec.shared_examples 'rejects package tags access' do |status:|
- before do
- package.update!(name: package_name) unless package_name == 'non-existing-package'
- end
-
- it_behaves_like 'returning response status', status
-end
-
RSpec.shared_examples 'accept package tags request' do |status:|
using RSpec::Parameterized::TableSyntax
include_context 'dependency proxy helpers context'
@@ -23,6 +15,7 @@ RSpec.shared_examples 'accept package tags request' do |status:|
end
it_behaves_like 'returning response status', status
+ it_behaves_like 'track event', :list_tags
it 'returns a valid json response' do
subject
@@ -63,6 +56,7 @@ RSpec.shared_examples 'accept create package tag request' do |user_type|
end
it_behaves_like 'returning response status', :no_content
+ it_behaves_like 'track event', :create_tag
it 'creates the package tag' do
expect { subject }.to change { Packages::Tag.count }.by(1)
@@ -145,6 +139,7 @@ RSpec.shared_examples 'accept delete package tag request' do |user_type|
end
it_behaves_like 'returning response status', :no_content
+ it_behaves_like 'track event', :delete_tag
it 'returns a valid response' do
subject
@@ -190,3 +185,21 @@ RSpec.shared_examples 'accept delete package tag request' do |user_type|
end
end
end
+
+RSpec.shared_examples 'track event' do |event_name|
+ let(:event_user) do
+ if auth == :deploy_token
+ deploy_token
+ elsif user_role
+ user
+ end
+ end
+
+ let(:snowplow_gitlab_standard_context) do
+ { project: project, namespace: project.namespace, property: 'i_package_npm_user' }.tap do |context|
+ context[:user] = event_user if event_user
+ end
+ end
+
+ it_behaves_like 'a package tracking event', described_class.name, event_name.to_s
+end
diff --git a/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb
index 17d8b9c7fab..7cafe8bb368 100644
--- a/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb
@@ -36,6 +36,7 @@ RSpec.shared_examples 'handling nuget service requests' do |example_names_with_s
with_them do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) }
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: user_role) }
subject { get api(url), headers: headers }
@@ -72,6 +73,7 @@ RSpec.shared_examples 'handling nuget service requests' do |example_names_with_s
with_them do
let(:job) { user_token ? create(:ci_build, project: project, user: user, status: :running) : double(token: 'wrong') }
let(:headers) { user_role == :anonymous ? {} : job_basic_auth_header(job) }
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: user_role) }
subject { get api(url), headers: headers }
@@ -140,6 +142,7 @@ RSpec.shared_examples 'handling nuget metadata requests with package name' do |e
with_them do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) }
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: user_role) }
subject { get api(url), headers: headers }
@@ -207,6 +210,7 @@ RSpec.shared_examples 'handling nuget metadata requests with package name and pa
with_them do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) }
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: user_role) }
subject { get api(url), headers: headers }
@@ -277,6 +281,7 @@ RSpec.shared_examples 'handling nuget search requests' do |example_names_with_st
with_them do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) }
+ let(:snowplow_gitlab_standard_context) { snowplow_context(user_role: user_role) }
subject { get api(url), headers: headers }
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 98264baa61d..3168f25e4fa 100644
--- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb
@@ -143,37 +143,37 @@ RSpec.shared_examples 'job token for package uploads' do |authorize_endpoint: fa
end
RSpec.shared_examples 'a package tracking event' do |category, action, service_ping_context = true|
- before do
- stub_feature_flags(collect_package_events: true)
- end
-
let(:context) do
- [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll,
- event: snowplow_gitlab_standard_context[:property]).to_h]
+ [
+ Gitlab::Tracking::ServicePingContext.new(
+ data_source: :redis_hll,
+ event: snowplow_gitlab_standard_context[:property]
+ ).to_h
+ ]
end
it "creates a gitlab tracking event #{action}", :snowplow, :aggregate_failures do
- expect { subject }.to change { Packages::Event.count }.by(1)
+ subject
if service_ping_context
- expect_snowplow_event(category: category, action: action,
- label: "redis_hll_counters.user_packages.user_packages_total_unique_counts_monthly",
- context: context, **snowplow_gitlab_standard_context)
+ expect_snowplow_event(
+ category: category,
+ action: action,
+ label: "redis_hll_counters.user_packages.user_packages_total_unique_counts_monthly",
+ context: context,
+ **snowplow_gitlab_standard_context
+ )
else
expect_snowplow_event(category: category, action: action, **snowplow_gitlab_standard_context)
end
end
end
-RSpec.shared_examples 'not a package tracking event' do
- before do
- stub_feature_flags(collect_package_events: true)
- end
-
+RSpec.shared_examples 'not a package tracking event' do |category, action|
it 'does not create a gitlab tracking event', :snowplow, :aggregate_failures do
- expect { subject }.not_to change { Packages::Event.count }
+ subject
- expect_no_snowplow_event
+ expect_no_snowplow_event category: category, action: action
end
end
@@ -183,3 +183,15 @@ RSpec.shared_examples 'bumping the package last downloaded at field' do
.to change { package.reload.last_downloaded_at }.from(nil).to(instance_of(ActiveSupport::TimeWithZone))
end
end
+
+RSpec.shared_examples 'a successful package creation' do
+ it 'creates npm package with file' do
+ expect { subject }
+ .to change { project.packages.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+ .and change { Packages::Tag.count }.by(1)
+ .and change { Packages::Npm::Metadatum.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/pipelines/visibility_table_shared_examples.rb b/spec/support/shared_examples/requests/api/pipelines/visibility_table_shared_examples.rb
index 8dd2ef6ccc6..9847ea4e1e2 100644
--- a/spec/support/shared_examples/requests/api/pipelines/visibility_table_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/pipelines/visibility_table_shared_examples.rb
@@ -224,10 +224,10 @@ RSpec.shared_examples 'pipelines visibility table' do
project.project_feature.update!(project_feature_attributes)
project.add_role(ci_user, user_role) if user_role && user_role != :non_member
- get api(pipelines_api_path, api_user)
+ get api(pipelines_api_path, api_user, admin_mode: is_admin)
end
- it do
+ specify do
expect(response).to have_gitlab_http_status(response_status)
expect(api_response).to match(expected_response)
end
diff --git a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb
index 6065b1163c4..9bd430c3b4f 100644
--- a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb
@@ -254,6 +254,13 @@ RSpec.shared_examples 'pypi simple API endpoint' do
with_them do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) }
+ let(:snowplow_gitlab_standard_context) do
+ if user_role == :anonymous || (visibility_level == :public && !user_token)
+ snowplow_context
+ else
+ snowplow_context.merge(user: user)
+ end
+ end
before do
project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility_level.to_s))
@@ -269,7 +276,7 @@ RSpec.shared_examples 'pypi simple API endpoint' do
let(:url) { "/projects/#{project.id}/packages/pypi/simple/my-package" }
let(:headers) { basic_auth_header(user.username, personal_access_token.token) }
- let(:snowplow_gitlab_standard_context) { { project: project, namespace: group, property: 'i_package_pypi_user' } }
+ let(:snowplow_gitlab_standard_context) { snowplow_context.merge({ project: project, user: user }) }
it_behaves_like 'PyPI package versions', :developer, :success
end
diff --git a/spec/support/shared_examples/requests/api/repository_storage_moves_shared_examples.rb b/spec/support/shared_examples/requests/api/repository_storage_moves_shared_examples.rb
index 2154a76d765..3913d29e086 100644
--- a/spec/support/shared_examples/requests/api/repository_storage_moves_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/repository_storage_moves_shared_examples.rb
@@ -9,7 +9,7 @@ RSpec.shared_examples 'repository_storage_moves API' do |container_type|
let(:repository_storage_move_id) { storage_move.id }
def get_container_repository_storage_move
- get api(url, user)
+ get api(url, user, admin_mode: user.admin?)
end
it 'returns a container repository storage move', :aggregate_failures do
@@ -39,7 +39,7 @@ RSpec.shared_examples 'repository_storage_moves API' do |container_type|
shared_examples 'get container repository storage move list' do
def get_container_repository_storage_moves
- get api(url, user)
+ get api(url, user, admin_mode: user.admin?)
end
it 'returns container repository storage moves', :aggregate_failures do
@@ -70,7 +70,7 @@ RSpec.shared_examples 'repository_storage_moves API' do |container_type|
get_container_repository_storage_moves
- json_ids = json_response.map { |storage_move| storage_move['id'] }
+ json_ids = json_response.pluck('id')
expect(json_ids).to eq([storage_move.id, storage_move_middle.id, storage_move_oldest.id])
end
@@ -90,7 +90,7 @@ RSpec.shared_examples 'repository_storage_moves API' do |container_type|
let(:container_id) { non_existing_record_id }
it 'returns not found' do
- get api(url, user)
+ get api(url, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -108,7 +108,7 @@ RSpec.shared_examples 'repository_storage_moves API' do |container_type|
let(:repository_storage_move_id) { storage_move.id }
it 'returns not found' do
- get api(url, user)
+ get api(url, user, admin_mode: user.admin?)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -127,20 +127,20 @@ RSpec.shared_examples 'repository_storage_moves API' do |container_type|
end
end
- describe "POST /#{container_type}/:id/repository_storage_moves" do
+ describe "POST /#{container_type}/:id/repository_storage_moves", :aggregate_failures do
let(:container_id) { container.id }
let(:url) { "/#{container_type}/#{container_id}/repository_storage_moves" }
let(:destination_storage_name) { 'test_second_storage' }
def create_container_repository_storage_move
- post api(url, user), params: { destination_storage_name: destination_storage_name }
+ post api(url, user, admin_mode: user.admin?), params: { destination_storage_name: destination_storage_name }
end
before do
stub_storage_settings('test_second_storage' => { 'path' => 'tmp/tests/extra_storage' })
end
- it 'schedules a container repository storage move', :aggregate_failures do
+ it 'schedules a container repository storage move' do
create_container_repository_storage_move
storage_move = container.repository_storage_moves.last
@@ -158,7 +158,7 @@ RSpec.shared_examples 'repository_storage_moves API' do |container_type|
it { expect { create_container_repository_storage_move }.to be_denied_for(:user) }
end
- context 'destination_storage_name is missing', :aggregate_failures do
+ context 'destination_storage_name is missing' do
let(:destination_storage_name) { nil }
it 'schedules a container repository storage move' do
@@ -192,7 +192,7 @@ RSpec.shared_examples 'repository_storage_moves API' do |container_type|
let(:destination_storage_name) { 'test_second_storage' }
def create_container_repository_storage_moves
- post api(url, user), params: {
+ post api(url, user, admin_mode: user.admin?), params: {
source_storage_name: source_storage_name,
destination_storage_name: destination_storage_name
}
diff --git a/spec/support/shared_examples/requests/api/resolvable_discussions_shared_examples.rb b/spec/support/shared_examples/requests/api/resolvable_discussions_shared_examples.rb
index b5139bd8c99..2770e293683 100644
--- a/spec/support/shared_examples/requests/api/resolvable_discussions_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/resolvable_discussions_shared_examples.rb
@@ -71,8 +71,7 @@ RSpec.shared_examples 'resolvable discussions API' do |parent_type, noteable_typ
it 'returns a 404 error when note id not found' do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
- "discussions/#{note.discussion_id}/notes/#{non_existing_record_id}", user),
- params: { body: 'Hello!' }
+ "discussions/#{note.discussion_id}/notes/#{non_existing_record_id}", user), params: { body: 'Hello!' }
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/support/shared_examples/requests/api/snippets_shared_examples.rb b/spec/support/shared_examples/requests/api/snippets_shared_examples.rb
index 1b92eb56f54..56f2394c005 100644
--- a/spec/support/shared_examples/requests/api/snippets_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/snippets_shared_examples.rb
@@ -1,12 +1,19 @@
# frozen_string_literal: true
RSpec.shared_examples 'raw snippet files' do
- let_it_be(:user_token) { create(:personal_access_token, user: snippet.author) }
let(:snippet_id) { snippet.id }
- let(:user) { snippet.author }
+ let_it_be(:user) { snippet.author }
let(:file_path) { '%2Egitattributes' }
let(:ref) { 'master' }
+ let_it_be(:user_token) do
+ if user.admin?
+ create(:personal_access_token, :admin_mode, user: user)
+ else
+ create(:personal_access_token, user: user)
+ end
+ end
+
subject { get api(api_path, personal_access_token: user_token) }
context 'with an invalid snippet ID' do
@@ -15,8 +22,10 @@ RSpec.shared_examples 'raw snippet files' do
it 'returns 404' do
subject
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('404 Snippet Not Found')
+ aggregate_failures do
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Snippet Not Found')
+ end
end
end
@@ -185,7 +194,7 @@ RSpec.shared_examples 'snippet individual non-file updates' do
end
RSpec.shared_examples 'invalid snippet updates' do
- it 'returns 404 for invalid snippet id' do
+ it 'returns 404 for invalid snippet id', :aggregate_failures do
update_snippet(snippet_id: non_existing_record_id, params: { title: 'foo' })
expect(response).to have_gitlab_http_status(:not_found)
@@ -204,7 +213,7 @@ RSpec.shared_examples 'invalid snippet updates' do
expect(response).to have_gitlab_http_status(:bad_request)
end
- it 'returns 400 if title is blank' do
+ it 'returns 400 if title is blank', :aggregate_failures do
update_snippet(params: { title: '' })
expect(response).to have_gitlab_http_status(:bad_request)
@@ -236,7 +245,9 @@ RSpec.shared_examples 'snippet access with different users' do
it 'returns the correct response' do
request_user = user_for(requester)
- get api(path, request_user)
+ admin_mode = requester == :admin
+
+ get api(path, request_user, admin_mode: admin_mode)
expect(response).to have_gitlab_http_status(status)
end
@@ -250,8 +261,6 @@ RSpec.shared_examples 'snippet access with different users' do
other_user
when :admin
admin
- else
- nil
end
end
diff --git a/spec/support/shared_examples/requests/api/status_shared_examples.rb b/spec/support/shared_examples/requests/api/status_shared_examples.rb
index 40843ccbd15..ff3947c0e73 100644
--- a/spec/support/shared_examples/requests/api/status_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/status_shared_examples.rb
@@ -21,6 +21,23 @@ RSpec.shared_examples '400 response' do
end
end
+RSpec.shared_examples '401 response' do
+ let(:message) { nil }
+
+ before do
+ # Fires the request
+ request
+ end
+
+ it 'returns 401' do
+ expect(response).to have_gitlab_http_status(:unauthorized)
+
+ if message.present?
+ expect(json_response['message']).to eq(message)
+ end
+ end
+end
+
RSpec.shared_examples '403 response' do
before do
# Fires the request
@@ -54,7 +71,7 @@ RSpec.shared_examples '412 response' do
let(:params) { nil }
let(:success_status) { 204 }
- context 'for a modified ressource' do
+ context 'for a modified resource' do
before do
delete request, params: params, headers: { 'HTTP_IF_UNMODIFIED_SINCE' => '1990-01-12T00:00:48-0600' }
end
@@ -65,7 +82,7 @@ RSpec.shared_examples '412 response' do
end
end
- context 'for an unmodified ressource' do
+ context 'for an unmodified resource' do
before do
delete request, params: params, headers: { 'HTTP_IF_UNMODIFIED_SINCE' => Time.now }
end
diff --git a/spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb b/spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb
index 86a1fd76d09..398421c7a79 100644
--- a/spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb
@@ -173,8 +173,7 @@ RSpec.shared_examples 'time tracking endpoints' do |issuable_name|
describe "GET /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_stats" do
it "returns the time stats for #{issuable_name}" do
- issuable.update!(spend_time: { duration: 1800, user_id: user.id },
- time_estimate: 3600)
+ issuable.update!(spend_time: { duration: 1800, user_id: user.id }, time_estimate: 3600)
get api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_stats", user)
diff --git a/spec/support/shared_examples/requests/applications_controller_shared_examples.rb b/spec/support/shared_examples/requests/applications_controller_shared_examples.rb
index 642930dd982..4a7a7492398 100644
--- a/spec/support/shared_examples/requests/applications_controller_shared_examples.rb
+++ b/spec/support/shared_examples/requests/applications_controller_shared_examples.rb
@@ -7,40 +7,14 @@ RSpec.shared_examples 'applications controller - GET #show' do
expect(response).to render_template :show
end
-
- context 'when application is viewed after being created' do
- before do
- create_application
- stub_feature_flags(hash_oauth_secrets: false)
- end
-
- it 'sets `@created` instance variable to `true`' do
- get show_path
-
- expect(assigns[:created]).to eq(true)
- end
- end
-
- context 'when application is reviewed' do
- before do
- stub_feature_flags(hash_oauth_secrets: false)
- end
-
- it 'sets `@created` instance variable to `false`' do
- get show_path
-
- expect(assigns[:created]).to eq(false)
- end
- end
end
end
RSpec.shared_examples 'applications controller - POST #create' do
- it "sets `#{OauthApplications::CREATED_SESSION_KEY}` session key to `true`" do
- stub_feature_flags(hash_oauth_secrets: false)
+ it "sets `@created` instance variable to `true`" do
create_application
- expect(session[OauthApplications::CREATED_SESSION_KEY]).to eq(true)
+ expect(assigns[:created]).to eq(true)
end
end
diff --git a/spec/support/shared_examples/requests/graphql_shared_examples.rb b/spec/support/shared_examples/requests/graphql_shared_examples.rb
index d133c5ea641..2c08f946468 100644
--- a/spec/support/shared_examples/requests/graphql_shared_examples.rb
+++ b/spec/support/shared_examples/requests/graphql_shared_examples.rb
@@ -58,5 +58,5 @@ end
RSpec.shared_examples 'a mutation on an unauthorized resource' do
it_behaves_like 'a mutation that returns top-level errors',
- errors: [::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
+ errors: [::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
end
diff --git a/spec/support/shared_examples/requests/projects/aws/aws__ff_examples.rb b/spec/support/shared_examples/requests/projects/aws/aws__ff_examples.rb
new file mode 100644
index 00000000000..2221baf5b90
--- /dev/null
+++ b/spec/support/shared_examples/requests/projects/aws/aws__ff_examples.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'requires feature flag `cloudseed_aws` enabled' do
+ context 'when feature flag is disabled' do
+ before do
+ project.add_maintainer(user)
+ stub_feature_flags(cloudseed_aws: false)
+ end
+
+ it 'renders not found' do
+ sign_in(user)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ 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 3f457890f35..dafa324b3c6 100644
--- a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
+++ b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
@@ -567,8 +567,8 @@ RSpec.shared_examples 'rate-limited unauthenticated requests' do
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)
+ params: { events: events }.to_json,
+ headers: registry_headers.merge('Authorization' => secret_token)
expect(response).to have_gitlab_http_status(:ok)
end
diff --git a/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb b/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb
deleted file mode 100644
index f8a752a5673..00000000000
--- a/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb
+++ /dev/null
@@ -1,130 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'not accessible to non-admin users' do
- context 'with unauthenticated user' do
- it 'redirects to signin page' do
- subject
-
- expect(response).to redirect_to(new_user_session_path)
- end
- end
-
- context 'with authenticated non-admin user' do
- before do
- login_as(create(:user))
- end
-
- it 'returns status not_found' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'with authenticated admin user without admin mode' do
- before do
- login_as(create(:admin))
- end
-
- it 'redirects to enable admin mode' do
- subject
-
- expect(response).to redirect_to(new_admin_session_path)
- end
- end
-end
-
-# Requires subject and worker_class and status_api to be defined
-# let(:worker_class) { SelfMonitoringProjectCreateWorker }
-# let(:status_api) { status_create_self_monitoring_project_admin_application_settings_path }
-# subject { post create_self_monitoring_project_admin_application_settings_path }
-RSpec.shared_examples 'triggers async worker, returns sidekiq job_id with response accepted' do
- before do
- allow(worker_class).to receive(:with_status).and_return(worker_class)
- end
-
- it 'returns sidekiq job_id of expected length' do
- subject
-
- job_id = json_response['job_id']
-
- aggregate_failures do
- expect(job_id).to be_present
- expect(job_id.length).to be <= Admin::ApplicationSettingsController::PARAM_JOB_ID_MAX_SIZE
- end
- end
-
- it 'triggers async worker' do
- expect(worker_class).to receive(:perform_async)
-
- subject
- end
-
- it 'returns accepted response' do
- subject
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:accepted)
- expect(json_response.keys).to contain_exactly('job_id', 'monitor_status')
- expect(json_response).to include(
- 'monitor_status' => status_api
- )
- end
- end
-
- it 'returns job_id' do
- fake_job_id = 'b5b28910d97563e58c2fe55f'
- allow(worker_class).to receive(:perform_async).and_return(fake_job_id)
-
- subject
-
- expect(json_response).to include('job_id' => fake_job_id)
- end
-end
-
-# Requires job_id and subject to be defined
-# let(:job_id) { 'job_id' }
-# subject do
-# get status_create_self_monitoring_project_admin_application_settings_path,
-# params: { job_id: job_id }
-# end
-RSpec.shared_examples 'handles invalid job_id' do
- context 'with invalid job_id' do
- let(:job_id) { 'a' * 51 }
-
- it 'returns bad_request if job_id too long' do
- subject
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response).to eq('message' => 'Parameter "job_id" cannot ' \
- "exceed length of #{Admin::ApplicationSettingsController::PARAM_JOB_ID_MAX_SIZE}")
- end
- end
- end
-end
-
-# Requires in_progress_message and subject to be defined
-# let(:in_progress_message) { 'Job to create self-monitoring project is in progress' }
-# subject do
-# get status_create_self_monitoring_project_admin_application_settings_path,
-# params: { job_id: job_id }
-# end
-RSpec.shared_examples 'sets polling header and returns accepted' do
- it 'sets polling header' do
- expect(::Gitlab::PollingInterval).to receive(:set_header)
-
- subject
- end
-
- it 'returns accepted' do
- subject
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:accepted)
- expect(json_response).to eq(
- 'message' => in_progress_message
- )
- end
- end
-end
diff --git a/spec/support/shared_examples/requests/user_activity_shared_examples.rb b/spec/support/shared_examples/requests/user_activity_shared_examples.rb
index 37da1ce5c63..9c0165f7150 100644
--- a/spec/support/shared_examples/requests/user_activity_shared_examples.rb
+++ b/spec/support/shared_examples/requests/user_activity_shared_examples.rb
@@ -5,7 +5,7 @@ RSpec.shared_examples 'updating of user activity' do |paths_to_visit|
before do
group = create(:group, name: 'group')
- project = create(:project, :public, namespace: group, name: 'project')
+ project = create(:project, :public, namespace: group, path: 'project')
create(:issue, project: project, iid: 10)
create(:merge_request, source_project: project, iid: 15)
diff --git a/spec/support/shared_examples/security_training_providers_importer.rb b/spec/support/shared_examples/security_training_providers_importer.rb
index 69d92964270..81b3d22ab23 100644
--- a/spec/support/shared_examples/security_training_providers_importer.rb
+++ b/spec/support/shared_examples/security_training_providers_importer.rb
@@ -8,7 +8,7 @@ RSpec.shared_examples 'security training providers importer' do
end
it 'upserts security training providers' do
- expect { 2.times { subject } }.to change { security_training_providers.count }.from(0).to(2)
- expect(security_training_providers.all.map(&:name)).to match_array(['Kontra', 'Secure Code Warrior'])
+ expect { 3.times { subject } }.to change { security_training_providers.count }.from(0).to(3)
+ expect(security_training_providers.all.map(&:name)).to match_array(['Kontra', 'Secure Code Warrior', 'SecureFlag'])
end
end
diff --git a/spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb b/spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb
index 32adf98969c..df01f9a5b0b 100644
--- a/spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb
+++ b/spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb
@@ -2,13 +2,15 @@
RSpec.shared_examples 'diff file base entity' do
it 'exposes essential attributes' do
- expect(subject).to include(:content_sha, :submodule, :submodule_link,
- :submodule_tree_url, :old_path_html,
- :new_path_html, :blob, :can_modify_blob,
- :file_hash, :file_path, :old_path, :new_path,
- :viewer, :diff_refs, :stored_externally,
- :external_storage, :renamed_file, :deleted_file,
- :a_mode, :b_mode, :new_file, :file_identifier_hash)
+ expect(subject).to include(
+ :content_sha, :submodule, :submodule_link,
+ :submodule_tree_url, :old_path_html,
+ :new_path_html, :blob, :can_modify_blob,
+ :file_hash, :file_path, :old_path, :new_path,
+ :viewer, :diff_refs, :stored_externally,
+ :external_storage, :renamed_file, :deleted_file,
+ :a_mode, :b_mode, :new_file, :file_identifier_hash
+ )
end
# Converted diff files from GitHub import does not contain blob file
@@ -30,13 +32,70 @@ RSpec.shared_examples 'diff file entity' do
it_behaves_like 'diff file base entity'
it 'exposes correct attributes' do
- expect(subject).to include(:added_lines, :removed_lines,
- :context_lines_path)
+ expect(subject).to include(:added_lines, :removed_lines, :context_lines_path)
end
- it 'includes viewer' do
- expect(subject[:viewer].with_indifferent_access)
+ context 'when a viewer' do
+ let(:collapsed) { false }
+ let(:added_lines) { 1 }
+ let(:removed_lines) { 0 }
+ let(:highlighted_lines) { nil }
+
+ before do
+ allow(diff_file).to receive(:diff_lines_for_serializer)
+ .and_return(highlighted_lines)
+
+ allow(diff_file).to receive(:added_lines)
+ .and_return(added_lines)
+
+ allow(diff_file).to receive(:removed_lines)
+ .and_return(removed_lines)
+
+ allow(diff_file).to receive(:collapsed?)
+ .and_return(collapsed)
+ end
+
+ it 'matches the schema' do
+ expect(subject[:viewer].with_indifferent_access)
.to match_schema('entities/diff_viewer')
+ end
+
+ context 'when it is a whitespace only change' do
+ it 'has whitespace_only true' do
+ expect(subject[:viewer][:whitespace_only])
+ .to eq(true)
+ end
+ end
+
+ context 'when the highlighted lines arent shown' do
+ before do
+ allow(diff_file).to receive(:text?)
+ .and_return(false)
+ end
+
+ it 'has whitespace_only nil' do
+ expect(subject[:viewer][:whitespace_only])
+ .to eq(nil)
+ end
+ end
+
+ context 'when it is a new file' do
+ let(:added_lines) { 0 }
+
+ it 'has whitespace_only false' do
+ expect(subject[:viewer][:whitespace_only])
+ .to eq(false)
+ end
+ end
+
+ context 'when it is a collapsed file' do
+ let(:collapsed) { true }
+
+ it 'has whitespace_only false' do
+ expect(subject[:viewer][:whitespace_only])
+ .to eq(false)
+ end
+ end
end
context 'diff files' do
diff --git a/spec/support/shared_examples/serializers/note_entity_shared_examples.rb b/spec/support/shared_examples/serializers/note_entity_shared_examples.rb
index b5e3a407b53..e8238480ced 100644
--- a/spec/support/shared_examples/serializers/note_entity_shared_examples.rb
+++ b/spec/support/shared_examples/serializers/note_entity_shared_examples.rb
@@ -18,7 +18,8 @@ RSpec.shared_examples 'note entity' do
:noteable_note_url,
:report_abuse_path,
:resolvable,
- :type
+ :type,
+ :external_author
)
end
diff --git a/spec/support/shared_examples/services/base_helm_service_shared_examples.rb b/spec/support/shared_examples/services/base_helm_service_shared_examples.rb
deleted file mode 100644
index c2252c83140..00000000000
--- a/spec/support/shared_examples/services/base_helm_service_shared_examples.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'logs kubernetes errors' do
- let(:error_hash) do
- {
- service: service.class.name,
- app_id: application.id,
- project_ids: application.cluster.project_ids,
- group_ids: [],
- error_code: error_code
- }
- end
-
- it 'logs into kubernetes.log and Sentry' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
- error,
- hash_including(error_hash)
- )
-
- service.execute
- end
-end
diff --git a/spec/support/shared_examples/services/clusters/create_service_shared_examples.rb b/spec/support/shared_examples/services/clusters/create_service_shared_examples.rb
new file mode 100644
index 00000000000..7cd76e45ecd
--- /dev/null
+++ b/spec/support/shared_examples/services/clusters/create_service_shared_examples.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'create cluster service success' do
+ it 'creates a cluster object' do
+ expect { subject }
+ .to change { Clusters::Cluster.count }.by(1)
+ .and change { Clusters::Providers::Gcp.count }.by(1)
+
+ expect(subject.name).to eq('test-cluster')
+ expect(subject.user).to eq(user)
+ expect(subject.project).to eq(project)
+ expect(subject.provider.gcp_project_id).to eq('gcp-project')
+ expect(subject.provider.zone).to eq('us-central1-a')
+ expect(subject.provider.num_nodes).to eq(1)
+ expect(subject.provider.machine_type).to eq('machine_type-a')
+ expect(subject.provider.access_token).to eq(access_token)
+ expect(subject.provider).to be_legacy_abac
+ expect(subject.platform).to be_nil
+ expect(subject.namespace_per_environment).to eq true
+ end
+end
+
+RSpec.shared_examples 'create cluster service error' do
+ it 'returns an error' do
+ expect { subject }.to change { Clusters::Cluster.count }.by(0)
+ expect(subject.errors[:"provider_gcp.gcp_project_id"]).to be_present
+ end
+end
diff --git a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb
index 58659775d8c..493a96b8dae 100644
--- a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb
@@ -79,7 +79,8 @@ RSpec.shared_examples 'an accessible' do
let(:access) do
[{ 'type' => 'repository',
'name' => project.full_path,
- 'actions' => actions }]
+ 'actions' => actions,
+ 'meta' => { 'project_path' => project.full_path } }]
end
it_behaves_like 'a valid token'
@@ -244,12 +245,14 @@ RSpec.shared_examples 'a container registry auth service' do
{
'type' => 'repository',
'name' => project.full_path,
- 'actions' => ['pull']
+ 'actions' => ['pull'],
+ 'meta' => { 'project_path' => project.full_path }
},
{
'type' => 'repository',
'name' => "#{project.full_path}/*",
- 'actions' => ['pull']
+ 'actions' => ['pull'],
+ 'meta' => { 'project_path' => project.full_path }
}
]
end
@@ -822,16 +825,20 @@ RSpec.shared_examples 'a container registry auth service' do
[
{ 'type' => 'repository',
'name' => internal_project.full_path,
- 'actions' => ['pull'] },
+ 'actions' => ['pull'],
+ 'meta' => { 'project_path' => internal_project.full_path } },
{ 'type' => 'repository',
'name' => private_project.full_path,
- 'actions' => ['pull'] },
+ 'actions' => ['pull'],
+ 'meta' => { 'project_path' => private_project.full_path } },
{ 'type' => 'repository',
'name' => public_project.full_path,
- 'actions' => ['pull'] },
+ 'actions' => ['pull'],
+ 'meta' => { 'project_path' => public_project.full_path } },
{ 'type' => 'repository',
'name' => public_project_private_container_registry.full_path,
- 'actions' => ['pull'] }
+ 'actions' => ['pull'],
+ 'meta' => { 'project_path' => public_project_private_container_registry.full_path } }
]
end
end
@@ -845,10 +852,12 @@ RSpec.shared_examples 'a container registry auth service' do
[
{ 'type' => 'repository',
'name' => internal_project.full_path,
- 'actions' => ['pull'] },
+ 'actions' => ['pull'],
+ 'meta' => { 'project_path' => internal_project.full_path } },
{ 'type' => 'repository',
'name' => public_project.full_path,
- 'actions' => ['pull'] }
+ 'actions' => ['pull'],
+ 'meta' => { 'project_path' => public_project.full_path } }
]
end
end
@@ -862,7 +871,8 @@ RSpec.shared_examples 'a container registry auth service' do
[
{ 'type' => 'repository',
'name' => public_project.full_path,
- 'actions' => ['pull'] }
+ 'actions' => ['pull'],
+ 'meta' => { 'project_path' => public_project.full_path } }
]
end
end
@@ -1258,4 +1268,29 @@ RSpec.shared_examples 'a container registry auth service' do
end
end
end
+
+ context 'with a project with a path containing special characters' do
+ let_it_be(:bad_project) { create(:project) }
+
+ before do
+ bad_project.update_attribute(:path, "#{bad_project.path}_")
+ end
+
+ describe '#access_token' do
+ let(:token) { described_class.access_token(['pull'], [bad_project.full_path]) }
+ let(:access) do
+ [{ 'type' => 'repository',
+ 'name' => bad_project.full_path,
+ 'actions' => ['pull'] }]
+ end
+
+ subject { { token: token } }
+
+ it_behaves_like 'a valid token'
+
+ it 'has the correct scope' do
+ expect(payload).to include('access' => access)
+ end
+ end
+ end
end
diff --git a/spec/support/shared_examples/services/deploy_token_shared_examples.rb b/spec/support/shared_examples/services/deploy_token_shared_examples.rb
new file mode 100644
index 00000000000..814b6565497
--- /dev/null
+++ b/spec/support/shared_examples/services/deploy_token_shared_examples.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'a deploy token creation service' do
+ let(:user) { create(:user) }
+ let(:deploy_token_params) { attributes_for(:deploy_token) }
+
+ describe '#execute' do
+ subject { described_class.new(entity, user, deploy_token_params).execute }
+
+ context 'when the deploy token is valid' do
+ it 'creates a new DeployToken' do
+ expect { subject }.to change { DeployToken.count }.by(1)
+ end
+
+ it 'creates a new ProjectDeployToken' do
+ expect { subject }.to change { deploy_token_class.count }.by(1)
+ end
+
+ it 'returns a DeployToken' do
+ expect(subject[:deploy_token]).to be_an_instance_of DeployToken
+ end
+
+ it 'sets the creator_id as the id of the current_user' do
+ expect(subject[:deploy_token].read_attribute(:creator_id)).to eq(user.id)
+ end
+ end
+
+ context 'when expires at date is not passed' do
+ let(:deploy_token_params) { attributes_for(:deploy_token, expires_at: '') }
+
+ it 'sets Forever.date' do
+ expect(subject[:deploy_token].read_attribute(:expires_at)).to eq(Forever.date)
+ end
+ end
+
+ context 'when username is empty string' do
+ let(:deploy_token_params) { attributes_for(:deploy_token, username: '') }
+
+ it 'converts it to nil' do
+ expect(subject[:deploy_token].read_attribute(:username)).to be_nil
+ end
+ end
+
+ context 'when username is provided' do
+ let(:deploy_token_params) { attributes_for(:deploy_token, username: 'deployer') }
+
+ it 'keeps the provided username' do
+ expect(subject[:deploy_token].read_attribute(:username)).to eq('deployer')
+ end
+ end
+
+ context 'when the deploy token is invalid' do
+ let(:deploy_token_params) do
+ attributes_for(:deploy_token, read_repository: false, read_registry: false, write_registry: false)
+ end
+
+ it 'does not create a new DeployToken' do
+ expect { subject }.not_to change { DeployToken.count }
+ end
+
+ it 'does not create a new ProjectDeployToken' do
+ expect { subject }.not_to change { deploy_token_class.count }
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'a deploy token deletion service' do
+ let(:user) { create(:user) }
+ let(:deploy_token_params) { { token_id: deploy_token.id } }
+
+ describe '#execute' do
+ subject { described_class.new(entity, user, deploy_token_params).execute }
+
+ it "destroys a token record and it's associated DeployToken" do
+ expect { subject }.to change { deploy_token_class.count }.by(-1)
+ .and change { DeployToken.count }.by(-1)
+ end
+
+ context 'with invalid token id' do
+ let(:deploy_token_params) { { token_id: 9999 } }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/import_csv_service_shared_examples.rb b/spec/support/shared_examples/services/import_csv_service_shared_examples.rb
new file mode 100644
index 00000000000..1555497ae48
--- /dev/null
+++ b/spec/support/shared_examples/services/import_csv_service_shared_examples.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples_for 'importer with email notification' do
+ it 'notifies user of import result' do
+ expect(Notify).to receive_message_chain(email_method, :deliver_later)
+
+ subject
+ end
+end
+
+RSpec.shared_examples 'correctly handles invalid files' do
+ shared_examples_for 'invalid file' do
+ it 'returns invalid file error' do
+ expect(subject[:success]).to eq(0)
+ expect(subject[:parse_error]).to eq(true)
+ end
+ end
+
+ context 'when given file with unsupported extension' do
+ let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') }
+
+ it_behaves_like 'invalid file'
+ end
+
+ context 'when given empty file' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_empty.csv') }
+
+ it_behaves_like 'invalid file'
+ end
+
+ context 'when given file without headers' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_no_headers.csv') }
+
+ it_behaves_like 'invalid file'
+ end
+end
diff --git a/spec/support/shared_examples/services/incident_shared_examples.rb b/spec/support/shared_examples/services/incident_shared_examples.rb
index a87e7c1f801..db2b448f567 100644
--- a/spec/support/shared_examples/services/incident_shared_examples.rb
+++ b/spec/support/shared_examples/services/incident_shared_examples.rb
@@ -12,7 +12,6 @@
# include_examples 'incident issue'
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
@@ -29,7 +28,6 @@ end
# include_examples 'not an incident issue'
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
end
diff --git a/spec/support/services/issuable_description_quick_actions_shared_examples.rb b/spec/support/shared_examples/services/issuable/issuable_description_quick_actions_shared_examples.rb
index 1970301e4c9..1970301e4c9 100644
--- a/spec/support/services/issuable_description_quick_actions_shared_examples.rb
+++ b/spec/support/shared_examples/services/issuable/issuable_description_quick_actions_shared_examples.rb
diff --git a/spec/support/shared_examples/services/issuable/issuable_import_csv_service_shared_examples.rb b/spec/support/shared_examples/services/issuable/issuable_import_csv_service_shared_examples.rb
new file mode 100644
index 00000000000..5336e0f4c2f
--- /dev/null
+++ b/spec/support/shared_examples/services/issuable/issuable_import_csv_service_shared_examples.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'issuable import csv service' do |issuable_type|
+ let_it_be_with_refind(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ subject { service.execute }
+
+ shared_examples_for 'an issuable importer' do
+ if issuable_type == 'issue'
+ it 'records the import attempt if resource is an issue' do
+ expect { subject }
+ .to change { Issues::CsvImport.where(project: project, user: user).count }
+ .by 1
+ end
+ end
+ end
+
+ describe '#execute' do
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'correctly handles invalid files' do
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+
+ context 'with a file generated by Gitlab CSV export' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_gitlab_export.csv') }
+
+ it 'imports the CSV without errors' do
+ expect(subject[:success]).to eq(4)
+ expect(subject[:error_lines]).to eq([])
+ expect(subject[:parse_error]).to eq(false)
+ end
+
+ it 'correctly sets the issuable attributes' do
+ expect { subject }.to change { issuables.count }.by 4
+
+ expect(issuables.reload).to include(have_attributes({ title: 'Test Title', description: 'Test Description' }))
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+
+ context 'with comma delimited file' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_comma.csv') }
+
+ it 'imports CSV without errors' do
+ expect(subject[:success]).to eq(3)
+ expect(subject[:error_lines]).to eq([])
+ expect(subject[:parse_error]).to eq(false)
+ end
+
+ it 'correctly sets the issuable attributes' do
+ expect { subject }.to change { issuables.count }.by 3
+
+ expect(issuables.reload).to include(have_attributes(title: 'Title with quote"', description: 'Description'))
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+
+ context 'with tab delimited file with error row' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_tab.csv') }
+
+ it 'imports CSV with some error rows' do
+ expect(subject[:success]).to eq(2)
+ expect(subject[:error_lines]).to eq([3])
+ expect(subject[:parse_error]).to eq(false)
+ end
+
+ it 'correctly sets the issuable attributes' do
+ expect { subject }.to change { issuables.count }.by 2
+
+ expect(issuables.reload).to include(have_attributes(title: 'Hello', description: 'World'))
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+
+ context 'with semicolon delimited file with CRLF' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_semicolon.csv') }
+
+ it 'imports CSV with a blank row' do
+ expect(subject[:success]).to eq(3)
+ expect(subject[:error_lines]).to eq([4])
+ expect(subject[:parse_error]).to eq(false)
+ end
+
+ it 'correctly sets the issuable attributes' do
+ expect { subject }.to change { issuables.count }.by 3
+
+ expect(issuables.reload).to include(have_attributes(title: 'Hello', description: 'World'))
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/issuable/issuable_update_service_shared_examples.rb b/spec/support/shared_examples/services/issuable/issuable_update_service_shared_examples.rb
new file mode 100644
index 00000000000..85a05bbe56d
--- /dev/null
+++ b/spec/support/shared_examples/services/issuable/issuable_update_service_shared_examples.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'issuable update service' do
+ def update_issuable(opts)
+ described_class.new(project, user, opts).execute(open_issuable)
+ end
+
+ describe 'changing state' do
+ let(:hook_event) { :"#{closed_issuable.class.name.underscore.to_sym}_hooks" }
+
+ describe 'to reopened' do
+ let(:expected_payload) do
+ include(
+ changes: include(
+ state_id: { current: 1, previous: 2 },
+ updated_at: { current: kind_of(Time), previous: kind_of(Time) }
+ ),
+ object_attributes: include(
+ state: 'opened',
+ action: 'reopen'
+ )
+ )
+ end
+
+ it 'executes hooks' do
+ hooks_container = described_class < Issues::BaseService ? project.project_namespace : project
+ expect(hooks_container).to receive(:execute_hooks).with(expected_payload, hook_event)
+ expect(hooks_container).to receive(:execute_integrations).with(expected_payload, hook_event)
+
+ described_class.new(
+ **described_class.constructor_container_arg(project),
+ current_user: user,
+ params: { state_event: 'reopen' }
+ ).execute(closed_issuable)
+ end
+ end
+
+ describe 'to closed' do
+ let(:expected_payload) do
+ include(
+ changes: include(
+ state_id: { current: 2, previous: 1 },
+ updated_at: { current: kind_of(Time), previous: kind_of(Time) }
+ ),
+ object_attributes: include(
+ state: 'closed',
+ action: 'close'
+ )
+ )
+ end
+
+ it 'executes hooks' do
+ hooks_container = described_class < Issues::BaseService ? project.project_namespace : project
+ expect(hooks_container).to receive(:execute_hooks).with(expected_payload, hook_event)
+ expect(hooks_container).to receive(:execute_integrations).with(expected_payload, hook_event)
+
+ described_class.new(
+ **described_class.constructor_container_arg(project),
+ current_user: user,
+ params: { state_event: 'close' }
+ ).execute(open_issuable)
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'keeps issuable labels sorted after update' do
+ before do
+ update_issuable(label_ids: [label_b.id])
+ end
+
+ context 'when label is changed' do
+ it 'keeps the labels sorted by title ASC' do
+ update_issuable({ add_label_ids: [label_a.id] })
+
+ expect(issuable.labels).to eq([label_a, label_b])
+ end
+ end
+end
+
+RSpec.shared_examples 'broadcasting issuable labels updates' do
+ before do
+ update_issuable(label_ids: [label_a.id])
+ end
+
+ context 'when label is added' do
+ it 'triggers the GraphQL subscription' do
+ expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable)
+
+ update_issuable(add_label_ids: [label_b.id])
+ end
+ end
+
+ context 'when label is removed' do
+ it 'triggers the GraphQL subscription' do
+ expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable)
+
+ update_issuable(remove_label_ids: [label_a.id])
+ end
+ end
+
+ context 'when label is unchanged' do
+ it 'does not trigger the GraphQL subscription' do
+ expect(GraphqlTriggers).not_to receive(:issuable_labels_updated).with(issuable)
+
+ update_issuable(label_ids: [label_a.id])
+ end
+ end
+end
+
+RSpec.shared_examples_for 'issuable update service updating last_edited_at values' do
+ context 'when updating the title of the issuable' do
+ let(:update_params) { { title: 'updated title' } }
+
+ it 'does not update last_edited values' do
+ expect { update_issuable }.to change { issuable.title }.from(issuable.title).to('updated title').and(
+ not_change(issuable, :last_edited_at)
+ ).and(
+ not_change(issuable, :last_edited_by)
+ )
+ end
+ end
+
+ context 'when updating the description of the issuable' do
+ let(:update_params) { { description: 'updated description' } }
+
+ it 'updates last_edited values' do
+ expect do
+ update_issuable
+ end.to change { issuable.description }.from(issuable.description).to('updated description').and(
+ change { issuable.last_edited_at }
+ ).and(
+ change { issuable.last_edited_by }
+ )
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/issuable/update_service_shared_examples.rb b/spec/support/shared_examples/services/issuable/update_service_shared_examples.rb
deleted file mode 100644
index ff7acc7e907..00000000000
--- a/spec/support/shared_examples/services/issuable/update_service_shared_examples.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples_for 'issuable update service updating last_edited_at values' do
- context 'when updating the title of the issuable' do
- let(:update_params) { { title: 'updated title' } }
-
- it 'does not update last_edited values' do
- expect { update_issuable }.to change { issuable.title }.from(issuable.title).to('updated title').and(
- not_change(issuable, :last_edited_at)
- ).and(
- not_change(issuable, :last_edited_by)
- )
- end
- end
-
- context 'when updating the description of the issuable' do
- let(:update_params) { { description: 'updated description' } }
-
- it 'updates last_edited values' do
- expect do
- update_issuable
- end.to change { issuable.description }.from(issuable.description).to('updated description').and(
- change { issuable.last_edited_at }
- ).and(
- change { issuable.last_edited_by }
- )
- end
- end
-end
diff --git a/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb b/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb
index e47ff2fcd59..0bf8bc4ff04 100644
--- a/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb
+++ b/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb
@@ -34,7 +34,11 @@ RSpec.shared_examples 'issuable link creation' do
end
it 'returns error' do
- is_expected.to eq(message: "No matching #{issuable_type} found. Make sure that you are adding a valid #{issuable_type} URL.", status: :error, http_status: 404)
+ if issuable_type == :issue
+ is_expected.to eq(message: "Couldn't link #{issuable_type}. You must have at least the Reporter role in both projects.", status: :error, http_status: 403)
+ else
+ is_expected.to eq(message: "No matching #{issuable_type} found. Make sure that you are adding a valid #{issuable_type} URL.", status: :error, http_status: 404)
+ end
end
it 'no relationship is created' do
diff --git a/spec/support/services/issues/move_and_clone_services_shared_examples.rb b/spec/support/shared_examples/services/issues/move_and_clone_services_shared_examples.rb
index 2b2e90c0461..2b2e90c0461 100644
--- a/spec/support/services/issues/move_and_clone_services_shared_examples.rb
+++ b/spec/support/shared_examples/services/issues/move_and_clone_services_shared_examples.rb
diff --git a/spec/support/shared_examples/services/migrate_to_ghost_user_service_shared_examples.rb b/spec/support/shared_examples/services/migrate_to_ghost_user_service_shared_examples.rb
new file mode 100644
index 00000000000..e77d73d1c72
--- /dev/null
+++ b/spec/support/shared_examples/services/migrate_to_ghost_user_service_shared_examples.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples "migrating a deleted user's associated records to the ghost user" do |record_class, fields|
+ record_class_name = record_class.to_s.titleize.downcase
+
+ let(:project) do
+ case record_class
+ when MergeRequest
+ create(:project, :repository)
+ else
+ create(:project)
+ end
+ end
+
+ before do
+ project.add_developer(user)
+ end
+
+ context "for a #{record_class_name} the user has created" do
+ let!(:record) { created_record }
+ let(:migrated_fields) { fields || [:author] }
+
+ it "does not delete the #{record_class_name}" do
+ service.execute
+
+ expect(record_class.find_by_id(record.id)).to be_present
+ end
+
+ it "blocks the user before migrating #{record_class_name}s to the 'Ghost User'" do
+ service.execute
+
+ expect(user).to be_blocked
+ end
+
+ it 'migrates all associated fields to the "Ghost user"' do
+ service.execute
+
+ migrated_record = record_class.find_by_id(record.id)
+
+ migrated_fields.each do |field|
+ expect(migrated_record.public_send(field)).to eq(User.ghost)
+ end
+ end
+
+ it 'will only migrate specific records during a hard_delete' do
+ service.execute(hard_delete: true)
+
+ migrated_record = record_class.find_by_id(record.id)
+
+ check_user = always_ghost ? User.ghost : user
+
+ migrated_fields.each do |field|
+ expect(migrated_record.public_send(field)).to eq(check_user)
+ end
+ end
+
+ describe "race conditions" do
+ context "when #{record_class_name} migration fails and is rolled back" do
+ before do
+ allow_next_instance_of(ActiveRecord::Associations::CollectionProxy)
+ .to receive(:update_all).and_raise(ActiveRecord::StatementTimeout)
+ end
+
+ it 'rolls back the user block' do
+ expect { service.execute }.to raise_error(ActiveRecord::StatementTimeout)
+
+ expect(user.reload).not_to be_blocked
+ end
+
+ it "doesn't unblock a previously-blocked user" do
+ expect(user.starred_projects).to receive(:update_all).and_call_original
+ user.block
+
+ expect { service.execute }.to raise_error(ActiveRecord::StatementTimeout)
+
+ expect(user.reload).to be_blocked
+ end
+ end
+
+ it "blocks the user before #{record_class_name} migration begins" do
+ expect(service).to receive("migrate_#{record_class_name.parameterize(separator: '_').pluralize}".to_sym) do
+ expect(user.reload).to be_blocked
+ end
+
+ service.execute
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/packages/debian/generate_distribution_shared_examples.rb b/spec/support/shared_examples/services/packages/debian/generate_distribution_shared_examples.rb
index a3042ac2e26..cb544f42765 100644
--- a/spec/support/shared_examples/services/packages/debian/generate_distribution_shared_examples.rb
+++ b/spec/support/shared_examples/services/packages/debian/generate_distribution_shared_examples.rb
@@ -29,26 +29,76 @@ RSpec.shared_examples 'Generate Debian Distribution and component files' do
let_it_be(:architecture_amd64) { create("debian_#{container_type}_architecture", distribution: distribution, name: 'amd64') }
let_it_be(:architecture_arm64) { create("debian_#{container_type}_architecture", distribution: distribution, name: 'arm64') }
- let_it_be(:component_file1) { create("debian_#{container_type}_component_file", component: component_contrib, architecture: architecture_all, updated_at: '2020-01-24T08:00:00Z', file_sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', file_md5: 'd41d8cd98f00b204e9800998ecf8427e', file_fixture: nil, size: 0) } # updated
- let_it_be(:component_file2) { create("debian_#{container_type}_component_file", component: component_main, architecture: architecture_all, updated_at: '2020-01-24T09:00:00Z', file_sha256: 'a') } # destroyed
- let_it_be(:component_file3) { create("debian_#{container_type}_component_file", component: component_main, architecture: architecture_amd64, updated_at: '2020-01-24T10:54:59Z', file_sha256: 'b') } # destroyed, 1 second before last generation
- let_it_be(:component_file4) { create("debian_#{container_type}_component_file", component: component_contrib, architecture: architecture_all, updated_at: '2020-01-24T10:55:00Z', file_sha256: 'c') } # kept, last generation
- let_it_be(:component_file5) { create("debian_#{container_type}_component_file", component: component_contrib, architecture: architecture_all, updated_at: '2020-01-24T10:55:00Z', file_sha256: 'd') } # kept, last generation
- let_it_be(:component_file6) { create("debian_#{container_type}_component_file", component: component_contrib, architecture: architecture_amd64, updated_at: '2020-01-25T15:17:18Z', file_sha256: 'e') } # kept, less than 1 hour ago
-
- def check_component_file(release_date, component_name, component_file_type, architecture_name, expected_content)
+ let_it_be(:component_file_old_main_amd64) { create("debian_#{container_type}_component_file", component: component_main, architecture: architecture_amd64, updated_at: '2020-01-24T08:00:00Z', file_sha256: 'a') } # destroyed
+
+ let_it_be(:component_file_oldest_kept_contrib_all) { create("debian_#{container_type}_component_file", component: component_contrib, architecture: architecture_all, updated_at: '2020-01-24T10:55:00Z', file_sha256: 'b') } # oldest kept
+ let_it_be(:component_file_oldest_kept_contrib_amd64) { create("debian_#{container_type}_component_file", component: component_contrib, architecture: architecture_amd64, updated_at: '2020-01-24T10:55:00Z', file_sha256: 'c') } # oldest kept
+ let_it_be(:component_file_recent_contrib_amd64) { create("debian_#{container_type}_component_file", component: component_contrib, architecture: architecture_amd64, updated_at: '2020-01-25T15:17:18Z', file_sha256: 'd') } # kept, less than 1 hour ago
+
+ let_it_be(:component_file_empty_contrib_all_di) { create("debian_#{container_type}_component_file", :di_packages, :empty, component: component_contrib, architecture: architecture_all, updated_at: '2020-01-24T10:55:00Z') } # oldest kept
+ let_it_be(:component_file_empty_contrib_amd64_di) { create("debian_#{container_type}_component_file", :di_packages, :empty, component: component_contrib, architecture: architecture_amd64, updated_at: '2020-01-24T10:55:00Z') } # touched, as last empty
+ let_it_be(:component_file_recent_contrib_amd64_di) { create("debian_#{container_type}_component_file", :di_packages, component: component_contrib, architecture: architecture_amd64, updated_at: '2020-01-25T15:17:18Z', file_sha256: 'f') } # kept, less than 1 hour ago
+
+ let(:pool_prefix) do
+ prefix = "pool/#{distribution.codename}"
+ prefix += "/#{project.id}" if container_type == :group
+ prefix += "/#{package.name[0]}/#{package.name}/#{package.version}"
+ prefix
+ end
+
+ let(:expected_main_amd64_di_content) do
+ <<~MAIN_AMD64_DI_CONTENT
+ Section: misc
+ Priority: extra
+ Filename: #{pool_prefix}/sample-udeb_1.2.3~alpha2_amd64.udeb
+ Size: 409600
+ SHA256: #{package.package_files.with_debian_file_type(:udeb).first.file_sha256}
+ MAIN_AMD64_DI_CONTENT
+ end
+
+ let(:expected_main_amd64_di_sha256) { Digest::SHA256.hexdigest(expected_main_amd64_di_content) }
+ let!(:component_file_old_main_amd64_di) do # touched
+ create("debian_#{container_type}_component_file", :di_packages, component: component_main, architecture: architecture_amd64, updated_at: '2020-01-24T08:00:00Z', file_sha256: expected_main_amd64_di_sha256).tap do |cf|
+ cf.update! file: CarrierWaveStringFile.new(expected_main_amd64_di_content), size: expected_main_amd64_di_content.size
+ end
+ end
+
+ def check_component_file(
+ release_date, component_name, component_file_type, architecture_name, expected_content,
+ updated: true, id_of: nil
+ )
component_file = distribution
.component_files
.with_component_name(component_name)
.with_file_type(component_file_type)
.with_architecture_name(architecture_name)
+ .with_compression_type(nil)
.order_updated_asc
.last
+ if expected_content.nil?
+ expect(component_file).to be_nil
+ return
+ end
+
expect(component_file).not_to be_nil
- expect(component_file.updated_at).to eq(release_date)
- unless expected_content.nil?
+ if id_of
+ expect(component_file&.id).to eq(id_of.id)
+ else
+ # created
+ expect(component_file&.id).to be > component_file_old_main_amd64_di.id
+ end
+
+ if updated
+ expect(component_file.updated_at).to eq(release_date)
+ else
+ expect(component_file.updated_at).not_to eq(release_date)
+ end
+
+ if expected_content == ''
+ expect(component_file.size).to eq(0)
+ else
expect(expected_content).not_to include('MD5')
component_file.file.use_file do |file_path|
expect(File.read(file_path)).to eq(expected_content)
@@ -57,30 +107,23 @@ RSpec.shared_examples 'Generate Debian Distribution and component files' do
end
it 'generates Debian distribution and component files', :aggregate_failures do
- current_time = Time.utc(2020, 01, 25, 15, 17, 18, 123456)
+ current_time = Time.utc(2020, 1, 25, 15, 17, 19)
travel_to(current_time) do
expect(Gitlab::ErrorTracking).not_to receive(:log_exception)
- components_count = 2
- architectures_count = 3
-
- initial_count = 6
- destroyed_count = 2
- updated_count = 1
- created_count = components_count * (architectures_count * 2 + 1) - updated_count
+ initial_count = 8
+ destroyed_count = 1
+ created_count = 4 # main_amd64 + main_sources + empty contrib_all + empty contrib_amd64
expect { subject }
.to not_change { Packages::Package.count }
.and not_change { Packages::PackageFile.count }
.and change { distribution.reload.updated_at }.to(current_time.round)
.and change { distribution.component_files.reset.count }.from(initial_count).to(initial_count - destroyed_count + created_count)
- .and change { component_file1.reload.updated_at }.to(current_time.round)
+ .and change { component_file_old_main_amd64_di.reload.updated_at }.to(current_time.round)
package_files = package.package_files.order(id: :asc).preload_debian_file_metadata.to_a
- pool_prefix = "pool/#{distribution.codename}"
- pool_prefix += "/#{project.id}" if container_type == :group
- pool_prefix += "/#{package.name[0]}/#{package.name}/#{package.version}"
expected_main_amd64_content = <<~EOF
Package: libsample0
Source: #{package.name}
@@ -120,17 +163,9 @@ RSpec.shared_examples 'Generate Debian Distribution and component files' do
SHA256: #{package_files[3].file_sha256}
EOF
- expected_main_amd64_di_content = <<~EOF
- Section: misc
- Priority: extra
- Filename: #{pool_prefix}/sample-udeb_1.2.3~alpha2_amd64.udeb
- Size: 409600
- SHA256: #{package_files[4].file_sha256}
- EOF
-
expected_main_sources_content = <<~EOF
Package: #{package.name}
- Binary: sample-dev, libsample0, sample-udeb
+ Binary: sample-dev, libsample0, sample-udeb, sample-ddeb
Version: #{package.version}
Maintainer: #{package_files[1].debian_fields['Maintainer']}
Build-Depends: debhelper-compat (= 13)
@@ -139,13 +174,13 @@ RSpec.shared_examples 'Generate Debian Distribution and component files' do
Format: 3.0 (native)
Files:
#{package_files[1].file_md5} #{package_files[1].size} #{package_files[1].file_name}
- d5ca476e4229d135a88f9c729c7606c9 864 sample_1.2.3~alpha2.tar.xz
+ #{package_files[0].file_md5} 964 #{package_files[0].file_name}
Checksums-Sha256:
#{package_files[1].file_sha256} #{package_files[1].size} #{package_files[1].file_name}
- 40e4682bb24a73251ccd7c7798c0094a649091e5625d6a14bcec9b4e7174f3da 864 sample_1.2.3~alpha2.tar.xz
+ #{package_files[0].file_sha256} 964 #{package_files[0].file_name}
Checksums-Sha1:
#{package_files[1].file_sha1} #{package_files[1].size} #{package_files[1].file_name}
- c5cfc111ea924842a89a06d5673f07dfd07de8ca 864 sample_1.2.3~alpha2.tar.xz
+ #{package_files[0].file_sha1} 964 #{package_files[0].file_name}
Homepage: #{package_files[1].debian_fields['Homepage']}
Section: misc
Priority: extra
@@ -157,42 +192,38 @@ RSpec.shared_examples 'Generate Debian Distribution and component files' do
check_component_file(current_time.round, 'main', :packages, 'arm64', nil)
check_component_file(current_time.round, 'main', :di_packages, 'all', nil)
- check_component_file(current_time.round, 'main', :di_packages, 'amd64', expected_main_amd64_di_content)
+ check_component_file(current_time.round, 'main', :di_packages, 'amd64', expected_main_amd64_di_content, id_of: component_file_old_main_amd64_di)
check_component_file(current_time.round, 'main', :di_packages, 'arm64', nil)
check_component_file(current_time.round, 'main', :sources, nil, expected_main_sources_content)
- check_component_file(current_time.round, 'contrib', :packages, 'all', nil)
- check_component_file(current_time.round, 'contrib', :packages, 'amd64', nil)
+ check_component_file(current_time.round, 'contrib', :packages, 'all', '')
+ check_component_file(current_time.round, 'contrib', :packages, 'amd64', '')
check_component_file(current_time.round, 'contrib', :packages, 'arm64', nil)
- check_component_file(current_time.round, 'contrib', :di_packages, 'all', nil)
- check_component_file(current_time.round, 'contrib', :di_packages, 'amd64', nil)
+ check_component_file(current_time.round, 'contrib', :di_packages, 'all', '', updated: false, id_of: component_file_empty_contrib_all_di)
+ check_component_file(current_time.round, 'contrib', :di_packages, 'amd64', '', id_of: component_file_empty_contrib_amd64_di)
check_component_file(current_time.round, 'contrib', :di_packages, 'arm64', nil)
check_component_file(current_time.round, 'contrib', :sources, nil, nil)
- main_amd64_size = expected_main_amd64_content.length
- main_amd64_sha256 = Digest::SHA256.hexdigest(expected_main_amd64_content)
+ expected_main_amd64_size = expected_main_amd64_content.bytesize
+ expected_main_amd64_sha256 = Digest::SHA256.hexdigest(expected_main_amd64_content)
- contrib_all_size = component_file1.size
- contrib_all_sha256 = component_file1.file_sha256
+ expected_main_amd64_di_size = expected_main_amd64_di_content.length
- main_amd64_di_size = expected_main_amd64_di_content.length
- main_amd64_di_sha256 = Digest::SHA256.hexdigest(expected_main_amd64_di_content)
-
- main_sources_size = expected_main_sources_content.length
- main_sources_sha256 = Digest::SHA256.hexdigest(expected_main_sources_content)
+ expected_main_sources_size = expected_main_sources_content.length
+ expected_main_sources_sha256 = Digest::SHA256.hexdigest(expected_main_sources_content)
expected_release_content = <<~EOF
Codename: #{distribution.codename}
- Date: Sat, 25 Jan 2020 15:17:18 +0000
- Valid-Until: Mon, 27 Jan 2020 15:17:18 +0000
+ Date: Sat, 25 Jan 2020 15:17:19 +0000
+ Valid-Until: Mon, 27 Jan 2020 15:17:19 +0000
Acquire-By-Hash: yes
Architectures: all amd64 arm64
Components: contrib main
SHA256:
- #{contrib_all_sha256} #{contrib_all_size.to_s.rjust(8)} contrib/binary-all/Packages
+ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 contrib/binary-all/Packages
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 contrib/debian-installer/binary-all/Packages
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 contrib/binary-amd64/Packages
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 contrib/debian-installer/binary-amd64/Packages
@@ -201,11 +232,11 @@ RSpec.shared_examples 'Generate Debian Distribution and component files' do
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 contrib/source/Sources
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 main/binary-all/Packages
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 main/debian-installer/binary-all/Packages
- #{main_amd64_sha256} #{main_amd64_size.to_s.rjust(8)} main/binary-amd64/Packages
- #{main_amd64_di_sha256} #{main_amd64_di_size.to_s.rjust(8)} main/debian-installer/binary-amd64/Packages
+ #{expected_main_amd64_sha256} #{expected_main_amd64_size.to_s.rjust(8)} main/binary-amd64/Packages
+ #{expected_main_amd64_di_sha256} #{expected_main_amd64_di_size.to_s.rjust(8)} main/debian-installer/binary-amd64/Packages
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 main/binary-arm64/Packages
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 main/debian-installer/binary-arm64/Packages
- #{main_sources_sha256} #{main_sources_size.to_s.rjust(8)} main/source/Sources
+ #{expected_main_sources_sha256} #{expected_main_sources_size.to_s.rjust(8)} main/source/Sources
EOF
expected_release_content = "Suite: #{distribution.suite}\n#{expected_release_content}" if distribution.suite
@@ -222,7 +253,7 @@ RSpec.shared_examples 'Generate Debian Distribution and component files' do
context 'without components and architectures' do
it 'generates minimal distribution', :aggregate_failures do
- travel_to(Time.utc(2020, 01, 25, 15, 17, 18, 123456)) do
+ travel_to(Time.utc(2020, 1, 25, 15, 17, 18, 123456)) do
expect(Gitlab::ErrorTracking).not_to receive(:log_exception)
expect { subject }
diff --git a/spec/support/shared_examples/services/packages_shared_examples.rb b/spec/support/shared_examples/services/packages_shared_examples.rb
index f63693dbf26..7a4d7f81e96 100644
--- a/spec/support/shared_examples/services/packages_shared_examples.rb
+++ b/spec/support/shared_examples/services/packages_shared_examples.rb
@@ -76,7 +76,7 @@ RSpec.shared_examples 'returns packages' do |container_type, user_type|
subject
expect(json_response.length).to eq(2)
- expect(json_response.map { |package| package['id'] }).to contain_exactly(package1.id, package2.id)
+ expect(json_response.pluck('id')).to contain_exactly(package1.id, package2.id)
end
end
end
@@ -123,7 +123,7 @@ RSpec.shared_examples 'returns packages with subgroups' do |container_type, user
subject
expect(json_response.length).to eq(3)
- expect(json_response.map { |package| package['id'] }).to contain_exactly(package1.id, package2.id, package3.id)
+ expect(json_response.pluck('id')).to contain_exactly(package1.id, package2.id, package3.id)
end
end
end
@@ -138,7 +138,7 @@ RSpec.shared_examples 'package sorting' do |order_by|
it 'returns the sorted packages' do
subject
- expect(json_response.map { |package| package['id'] }).to eq(packages.map(&:id))
+ expect(json_response.pluck('id')).to eq(packages.map(&:id))
end
end
@@ -148,7 +148,7 @@ RSpec.shared_examples 'package sorting' do |order_by|
it 'returns the sorted packages' do
subject
- expect(json_response.map { |package| package['id'] }).to eq(packages.reverse.map(&:id))
+ expect(json_response.pluck('id')).to eq(packages.reverse.map(&:id))
end
end
end
@@ -225,7 +225,7 @@ RSpec.shared_examples 'filters on each package_type' do |is_project: false|
subject
expect(json_response.length).to eq(1)
- expect(json_response.map { |package| package['package_type'] }).to contain_exactly(package_type)
+ expect(json_response.pluck('package_type')).to contain_exactly(package_type)
end
end
end
@@ -253,7 +253,7 @@ RSpec.shared_examples 'with versionless packages' do
it 'does not return the package' do
subject
- expect(json_response.map { |package| package['id'] }).not_to include(versionless_package.id)
+ expect(json_response.pluck('id')).not_to include(versionless_package.id)
end
end
@@ -268,7 +268,7 @@ RSpec.shared_examples 'with versionless packages' do
it 'returns the package' do
subject
- expect(json_response.map { |package| package['id'] }).to include(versionless_package.id)
+ expect(json_response.pluck('id')).to include(versionless_package.id)
end
end
end
@@ -295,7 +295,7 @@ RSpec.shared_examples 'with status param' do
it 'does not return the package' do
subject
- expect(json_response.map { |package| package['id'] }).not_to include(hidden_package.id)
+ expect(json_response.pluck('id')).not_to include(hidden_package.id)
end
end
@@ -309,7 +309,7 @@ RSpec.shared_examples 'with status param' do
it 'returns the package' do
subject
- expect(json_response.map { |package| package['id'] }).to include(hidden_package.id)
+ expect(json_response.pluck('id')).to include(hidden_package.id)
end
end
end
diff --git a/spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb b/spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb
index 9f940d27341..2070cac24b0 100644
--- a/spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb
@@ -63,35 +63,6 @@ RSpec.shared_examples 'moves repository to another storage' do |repository_type|
expect(gitlab_shell.repository_exists?('default', old_project_repository_path)).to be(false)
expect(gitlab_shell.repository_exists?('default', old_repository_path)).to be(false)
end
-
- context ':repack_after_shard_migration feature flag disabled' do
- before do
- stub_feature_flags(repack_after_shard_migration: false)
- end
-
- it 'does not enqueue a GC run' do
- expect { subject.execute }
- .not_to change { Projects::GitGarbageCollectWorker.jobs.count }
- end
- end
-
- context ':repack_after_shard_migration feature flag enabled' do
- before do
- stub_feature_flags(repack_after_shard_migration: true)
- end
-
- it 'does not enqueue a GC run if housekeeping is disabled' do
- stub_application_setting(housekeeping_enabled: false)
-
- expect { subject.execute }
- .not_to change { Projects::GitGarbageCollectWorker.jobs.count }
- end
-
- it 'enqueues a GC run' do
- expect { subject.execute }
- .to change { Projects::GitGarbageCollectWorker.jobs.count }.by(1)
- end
- end
end
context 'when the filesystems are the same' do
diff --git a/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb b/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb
index 209be09c807..21dc3c2bf70 100644
--- a/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb
@@ -114,7 +114,8 @@ RSpec.shared_examples_for 'services security ci configuration create service' do
it 'fails with error' do
expect(project).to receive(:ci_config_for).and_return(unsupported_yaml)
- expect { result }.to raise_error(Gitlab::Graphql::Errors::MutationError, '.gitlab-ci.yml with aliases/anchors is not supported. Please change the CI configuration manually.')
+ expect { result }.to raise_error(Gitlab::Graphql::Errors::MutationError, Gitlab::Utils::ErrorMessage.to_user_facing(
+ _(".gitlab-ci.yml with aliases/anchors is not supported. Please change the CI configuration manually.")))
end
end
@@ -145,7 +146,7 @@ RSpec.shared_examples_for 'services security ci configuration create service' do
let_it_be(:repository) { project.repository }
it 'is successful' do
- expect(repository).to receive(:root_ref_sha).and_raise(StandardError)
+ expect(repository).to receive(:commit).and_return(nil)
expect(result.status).to eq(:success)
end
end
@@ -168,7 +169,7 @@ RSpec.shared_examples_for 'services security ci configuration create service' do
it 'returns an error' do
expect { result }.to raise_error { |error|
expect(error).to be_a(Gitlab::Graphql::Errors::MutationError)
- expect(error.message).to eq('You must <a target="_blank" rel="noopener noreferrer" ' \
+ expect(error.message).to eq('UF You must <a target="_blank" rel="noopener noreferrer" ' \
'href="http://localhost/help/user/project/repository/index.md' \
'#add-files-to-a-repository">add at least one file to the repository' \
'</a> before using Security features.')
diff --git a/spec/support/shared_examples/services/service_response_shared_examples.rb b/spec/support/shared_examples/services/service_response_shared_examples.rb
new file mode 100644
index 00000000000..e55f16a2994
--- /dev/null
+++ b/spec/support/shared_examples/services/service_response_shared_examples.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'returning an error service response' do |message: nil|
+ it 'returns an error service response' do
+ result = subject
+
+ expect(result).to be_error
+
+ expect(result.message).to eq(message) if message
+ end
+end
+
+RSpec.shared_examples 'returning a success service response' do |message: nil|
+ it 'returns a success service response' do
+ result = subject
+
+ expect(result).to be_success
+
+ expect(result.message).to eq(message) if message
+ end
+end
diff --git a/spec/support/shared_examples/services/snowplow_tracking_shared_examples.rb b/spec/support/shared_examples/services/snowplow_tracking_shared_examples.rb
index e72e8e79411..d3b3434b339 100644
--- a/spec/support/shared_examples/services/snowplow_tracking_shared_examples.rb
+++ b/spec/support/shared_examples/services/snowplow_tracking_shared_examples.rb
@@ -5,7 +5,6 @@ RSpec.shared_examples 'issue_edit snowplow tracking' do
let(:action) { Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_ACTION }
let(:label) { Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_LABEL }
let(:namespace) { project.namespace }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
it_behaves_like 'Snowplow event tracking with RedisHLL context'
end
diff --git a/spec/support/shared_examples/services/work_items/widgets/milestone_service_shared_examples.rb b/spec/support/shared_examples/services/work_items/widgets/milestone_service_shared_examples.rb
deleted file mode 100644
index ac064ed4c33..00000000000
--- a/spec/support/shared_examples/services/work_items/widgets/milestone_service_shared_examples.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples "setting work item's milestone" do
- context "when 'milestone' param does not exist" do
- let(:params) { {} }
-
- it "does not set the work item's milestone" do
- expect { execute_callback }.to not_change(work_item, :milestone)
- end
- end
-
- context "when 'milestone' is not in the work item's project's hierarchy" do
- let(:another_group_milestone) { create(:milestone, group: create(:group)) }
- let(:params) { { milestone_id: another_group_milestone.id } }
-
- it "does not set the work item's milestone" do
- expect { execute_callback }.to not_change(work_item, :milestone)
- end
- end
-
- context 'when assigning a group milestone' do
- let(:params) { { milestone_id: group_milestone.id } }
-
- it "sets the work item's milestone" do
- expect { execute_callback }
- .to change { work_item.milestone }
- .from(nil)
- .to(group_milestone)
- end
- end
-
- context 'when assigning a project milestone' do
- let(:params) { { milestone_id: project_milestone.id } }
-
- it "sets the work item's milestone" do
- expect { execute_callback }
- .to change { work_item.milestone }
- .from(nil)
- .to(project_milestone)
- end
- end
-end
diff --git a/spec/support/shared_examples/views/pipeline_status_changes_email.rb b/spec/support/shared_examples/views/pipeline_status_changes_email.rb
index 698f11c2216..fe6cc5e03d2 100644
--- a/spec/support/shared_examples/views/pipeline_status_changes_email.rb
+++ b/spec/support/shared_examples/views/pipeline_status_changes_email.rb
@@ -8,12 +8,14 @@ RSpec.shared_examples 'pipeline status changes email' do
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
let(:pipeline) do
- create(:ci_pipeline,
- project: project,
- user: user,
- ref: project.default_branch,
- sha: project.commit.sha,
- status: status)
+ create(
+ :ci_pipeline,
+ project: project,
+ user: user,
+ ref: project.default_branch,
+ sha: project.commit.sha,
+ status: status
+ )
end
before do
diff --git a/spec/support/shared_examples/work_items/export_and_import_shared_examples.rb b/spec/support/shared_examples/work_items/export_and_import_shared_examples.rb
new file mode 100644
index 00000000000..bbbfacfdf53
--- /dev/null
+++ b/spec/support/shared_examples/work_items/export_and_import_shared_examples.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples_for 'a exported file that can be imported' do
+ before do
+ origin_project.add_reporter(user)
+ target_project.add_reporter(user)
+ end
+
+ def export_work_items_for(project)
+ origin_work_items = WorkItem.where(project: origin_project)
+ export = described_class.new(origin_work_items, project)
+ export.email(user)
+ attachment = ActionMailer::Base.deliveries.last.attachments.first
+ file = Tempfile.new('temp_work_item_export.csv')
+ file.write(attachment.read)
+
+ file
+ end
+
+ def import_file_for(project, file)
+ uploader = FileUploader.new(project)
+ uploader.store!(file)
+ import_service = WorkItems::ImportCsvService.new(user, target_project, uploader)
+
+ import_service.execute
+ end
+
+ it 'imports work item with correct attributes', :aggregate_failures do
+ csv_file = export_work_items_for(origin_project)
+
+ imported_work_items = ::WorkItems::WorkItemsFinder.new(user, project: target_project).execute
+ expect { import_file_for(target_project, csv_file) }.to change { imported_work_items.count }.by 1
+ imported_work_item = imported_work_items.first
+ expect(imported_work_item.author).to eq(user)
+ expected_matching_fields.each do |field|
+ expect(imported_work_item.public_send(field)).to eq(work_item.public_send(field))
+ end
+ end
+end
diff --git a/spec/support/shared_examples/workers/batched_background_migration_execution_worker_shared_example.rb b/spec/support/shared_examples/workers/batched_background_migration_execution_worker_shared_example.rb
index e224b71da91..095c32c3136 100644
--- a/spec/support/shared_examples/workers/batched_background_migration_execution_worker_shared_example.rb
+++ b/spec/support/shared_examples/workers/batched_background_migration_execution_worker_shared_example.rb
@@ -50,14 +50,20 @@ RSpec.shared_examples 'batched background migrations execution worker' do
end
describe '.max_running_jobs' do
- it 'returns MAX_RUNNING_MIGRATIONS' do
- expect(described_class.max_running_jobs).to eq(described_class::MAX_RUNNING_MIGRATIONS)
+ it 'returns database_max_running_batched_background_migrations application setting' do
+ stub_application_setting(database_max_running_batched_background_migrations: 3)
+
+ expect(described_class.max_running_jobs)
+ .to eq(Gitlab::CurrentSettings.database_max_running_batched_background_migrations)
end
end
describe '#max_running_jobs' do
- it 'returns MAX_RUNNING_MIGRATIONS' do
- expect(described_class.new.max_running_jobs).to eq(described_class::MAX_RUNNING_MIGRATIONS)
+ it 'returns database_max_running_batched_background_migrations application setting' do
+ stub_application_setting(database_max_running_batched_background_migrations: 3)
+
+ expect(described_class.new.max_running_jobs)
+ .to eq(Gitlab::CurrentSettings.database_max_running_batched_background_migrations)
end
end
diff --git a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb
index 8ec955940c0..06877aee565 100644
--- a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb
+++ b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb
@@ -88,9 +88,9 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d
end
end
- context 'when the feature flag is disabled' do
+ context 'when the tracking database is shared' do
before do
- stub_feature_flags(execute_batched_migrations_on_schedule: false)
+ skip_if_database_exists(tracking_database)
end
it 'does nothing' do
@@ -101,22 +101,17 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d
end
end
- context 'when the feature flag is enabled' do
- let(:base_model) { Gitlab::Database.database_base_models[tracking_database] }
-
+ context 'when the tracking database is not shared' do
before do
- stub_feature_flags(execute_batched_migrations_on_schedule: true)
-
- allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration)
- .with(connection: base_model.connection)
- .and_return(nil)
+ skip_if_shared_database(tracking_database)
end
- context 'when database config is shared' do
- it 'does nothing' do
- expect(Gitlab::Database).to receive(:db_config_share_with)
- .with(base_model.connection_db_config).and_return('main')
+ context 'when the feature flag is disabled' do
+ before do
+ stub_feature_flags(execute_batched_migrations_on_schedule: false)
+ end
+ it 'does nothing' do
expect(worker).not_to receive(:active_migration)
expect(worker).not_to receive(:run_active_migration)
@@ -124,123 +119,146 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d
end
end
- context 'when no active migrations exist' do
- context 'when parallel execution is disabled' do
- before do
- stub_feature_flags(batched_migrations_parallel_execution: false)
- end
+ context 'when the feature flag is enabled' do
+ let(:base_model) { Gitlab::Database.database_base_models[tracking_database] }
+ let(:connection) { base_model.connection }
+
+ before do
+ stub_feature_flags(execute_batched_migrations_on_schedule: true)
+ allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration)
+ .with(connection: connection)
+ .and_return(nil)
+ end
+
+ context 'when database config is shared' do
it 'does nothing' do
+ expect(Gitlab::Database).to receive(:db_config_share_with)
+ .with(base_model.connection_db_config).and_return('main')
+
+ expect(worker).not_to receive(:active_migration)
expect(worker).not_to receive(:run_active_migration)
worker.perform
end
end
- context 'when parallel execution is enabled' do
- before do
- stub_feature_flags(batched_migrations_parallel_execution: true)
- end
+ context 'when no active migrations exist' do
+ context 'when parallel execution is disabled' do
+ before do
+ stub_feature_flags(batched_migrations_parallel_execution: false)
+ end
- it 'does nothing' do
- expect(worker).not_to receive(:queue_migrations_for_execution)
+ it 'does nothing' do
+ expect(worker).not_to receive(:run_active_migration)
- worker.perform
+ worker.perform
+ end
end
- end
- end
- context 'when active migrations exist' do
- let(:job_interval) { 5.minutes }
- let(:lease_timeout) { 15.minutes }
- let(:lease_key) { described_class.name.demodulize.underscore }
- let(:migration_id) { 123 }
- let(:migration) do
- build(
- :batched_background_migration, :active,
- id: migration_id, interval: job_interval, table_name: table_name
- )
- end
+ context 'when parallel execution is enabled' do
+ before do
+ stub_feature_flags(batched_migrations_parallel_execution: true)
+ end
- let(:execution_worker_class) do
- case tracking_database
- when :main
- Database::BatchedBackgroundMigration::MainExecutionWorker
- when :ci
- Database::BatchedBackgroundMigration::CiExecutionWorker
+ it 'does nothing' do
+ expect(worker).not_to receive(:queue_migrations_for_execution)
+
+ worker.perform
+ end
end
end
- before do
- allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration)
- .with(connection: base_model.connection)
- .and_return(migration)
- end
+ context 'when active migrations exist' do
+ let(:job_interval) { 5.minutes }
+ let(:lease_timeout) { 15.minutes }
+ let(:lease_key) { described_class.name.demodulize.underscore }
+ let(:migration_id) { 123 }
+ let(:migration) do
+ build(
+ :batched_background_migration, :active,
+ id: migration_id, interval: job_interval, table_name: table_name
+ )
+ end
+
+ let(:execution_worker_class) do
+ case tracking_database
+ when :main
+ Database::BatchedBackgroundMigration::MainExecutionWorker
+ when :ci
+ Database::BatchedBackgroundMigration::CiExecutionWorker
+ end
+ end
- context 'when parallel execution is disabled' do
before do
- stub_feature_flags(batched_migrations_parallel_execution: false)
+ allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration)
+ .with(connection: connection)
+ .and_return(migration)
end
- let(:execution_worker) { instance_double(execution_worker_class) }
+ context 'when parallel execution is disabled' do
+ before do
+ stub_feature_flags(batched_migrations_parallel_execution: false)
+ end
- context 'when the calculated timeout is less than the minimum allowed' do
- let(:minimum_timeout) { described_class::MINIMUM_LEASE_TIMEOUT }
- let(:job_interval) { 2.minutes }
+ let(:execution_worker) { instance_double(execution_worker_class) }
- it 'sets the lease timeout to the minimum value' do
- expect_to_obtain_exclusive_lease(lease_key, timeout: minimum_timeout)
+ context 'when the calculated timeout is less than the minimum allowed' do
+ let(:minimum_timeout) { described_class::MINIMUM_LEASE_TIMEOUT }
+ let(:job_interval) { 2.minutes }
- expect(execution_worker_class).to receive(:new).and_return(execution_worker)
- expect(execution_worker).to receive(:perform_work).with(tracking_database, migration_id)
+ it 'sets the lease timeout to the minimum value' do
+ expect_to_obtain_exclusive_lease(lease_key, timeout: minimum_timeout)
- expect(worker).to receive(:run_active_migration).and_call_original
+ expect(execution_worker_class).to receive(:new).and_return(execution_worker)
+ expect(execution_worker).to receive(:perform_work).with(tracking_database, migration_id)
- worker.perform
- end
- end
+ expect(worker).to receive(:run_active_migration).and_call_original
- it 'always cleans up the exclusive lease' do
- lease = stub_exclusive_lease_taken(lease_key, timeout: lease_timeout)
+ worker.perform
+ end
+ end
- expect(lease).to receive(:try_obtain).and_return(true)
+ it 'always cleans up the exclusive lease' do
+ lease = stub_exclusive_lease_taken(lease_key, timeout: lease_timeout)
- expect(worker).to receive(:run_active_migration).and_raise(RuntimeError, 'I broke')
- expect(lease).to receive(:cancel)
+ expect(lease).to receive(:try_obtain).and_return(true)
- expect { worker.perform }.to raise_error(RuntimeError, 'I broke')
- end
+ expect(worker).to receive(:run_active_migration).and_raise(RuntimeError, 'I broke')
+ expect(lease).to receive(:cancel)
- it 'delegetes the execution to ExecutionWorker' do
- base_model = Gitlab::Database.database_base_models[tracking_database]
+ expect { worker.perform }.to raise_error(RuntimeError, 'I broke')
+ end
- expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(base_model.connection).and_yield
- expect(execution_worker_class).to receive(:new).and_return(execution_worker)
- expect(execution_worker).to receive(:perform_work).with(tracking_database, migration_id)
+ it 'delegetes the execution to ExecutionWorker' do
+ expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(connection).and_yield
+ expect(execution_worker_class).to receive(:new).and_return(execution_worker)
+ expect(execution_worker).to receive(:perform_work).with(tracking_database, migration_id)
- worker.perform
+ worker.perform
+ end
end
- end
- context 'when parallel execution is enabled' do
- before do
- stub_feature_flags(batched_migrations_parallel_execution: true)
- end
+ context 'when parallel execution is enabled' do
+ before do
+ stub_feature_flags(batched_migrations_parallel_execution: true)
+ end
- it 'delegetes the execution to ExecutionWorker' do
- expect(Gitlab::Database::BackgroundMigration::BatchedMigration)
- .to receive(:active_migrations_distinct_on_table).with(
- connection: base_model.connection,
- limit: execution_worker_class.max_running_jobs
- ).and_return([migration])
+ it 'delegetes the execution to ExecutionWorker' do
+ expect(Gitlab::Database::BackgroundMigration::BatchedMigration)
+ .to receive(:active_migrations_distinct_on_table).with(
+ connection: base_model.connection,
+ limit: execution_worker_class.max_running_jobs
+ ).and_return([migration])
- expected_arguments = [
- [tracking_database.to_s, migration_id]
- ]
+ expected_arguments = [
+ [tracking_database.to_s, migration_id]
+ ]
- expect(execution_worker_class).to receive(:perform_with_capacity).with(expected_arguments)
+ expect(execution_worker_class).to receive(:perform_with_capacity).with(expected_arguments)
- worker.perform
+ worker.perform
+ end
end
end
end
@@ -248,7 +266,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d
end
end
- describe 'executing an entire migration', :freeze_time, if: Gitlab::Database.has_config?(tracking_database) do
+ describe 'executing an entire migration', :freeze_time, if: Gitlab::Database.has_database?(tracking_database) do
include Gitlab::Database::DynamicModelHelpers
include Database::DatabaseHelpers
diff --git a/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb b/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb
deleted file mode 100644
index e6da96e12ec..00000000000
--- a/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-# This shared_example requires the following variables:
-# let(:service_class) { Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService }
-# let(:service) { instance_double(service_class) }
-RSpec.shared_examples 'executes service' do
- before do
- allow(service_class).to receive(:new) { service }
- end
-
- it 'runs the service' do
- expect(service).to receive(:execute)
-
- subject.perform
- end
-end
-
-RSpec.shared_examples 'returns in_progress based on Sidekiq::Status' do
- it 'returns true when job is enqueued' do
- jid = described_class.with_status.perform_async
-
- expect(described_class.in_progress?(jid)).to eq(true)
- end
-
- it 'returns false when job does not exist' do
- expect(described_class.in_progress?('fake_jid')).to eq(false)
- end
-end
diff --git a/spec/support/stub_dot_com_check.rb b/spec/support/stub_dot_com_check.rb
new file mode 100644
index 00000000000..6934b33d111
--- /dev/null
+++ b/spec/support/stub_dot_com_check.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+RSpec.configure do |config|
+ %i[saas saas_registration].each do |metadata|
+ config.before(:context, metadata) do
+ # Ensure Gitlab.com? returns true during context.
+ # This is needed for let_it_be which is shared across examples,
+ # therefore the value must be changed in before_all,
+ # but RSpec prevent stubbing method calls in before_all,
+ # therefore we have to resort to temporarily swap url value.
+ @_original_gitlab_url = Gitlab.config.gitlab['url']
+ Gitlab.config.gitlab['url'] = Gitlab::Saas.com_url
+ end
+
+ config.after(:context, metadata) do
+ # Swap back original value
+ Gitlab.config.gitlab['url'] = @_original_gitlab_url
+ end
+ end
+end
diff --git a/spec/support/stub_member_access_level.rb b/spec/support/stub_member_access_level.rb
new file mode 100644
index 00000000000..62e932ee1fc
--- /dev/null
+++ b/spec/support/stub_member_access_level.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module StubMemberAccessLevel
+ # Stubs access level of a member of +object+.
+ #
+ # The following types are supported:
+ # * `Project` - stubs `project.team.max_member_access(user.id)`
+ # * `Group` - stubs `group.max_member_access_for_user(user)`
+ #
+ # @example
+ #
+ # stub_member_access_level(project, maintainer: user)
+ # project.team.max_member_access(user.id) # => Gitlab::Access::MAINTAINER
+ #
+ # stub_member_access_level(group, developer: user)
+ # group.max_member_access_for_user(user) # => Gitlab::Access::DEVELOPER
+ #
+ # stub_member_access_level(project, reporter: user, guest: [guest1, guest2])
+ # project.team.max_member_access(user.id) # => Gitlab::Access::REPORTER
+ # project.team.max_member_access(guests.first.id) # => Gitlab::Access::GUEST
+ # project.team.max_member_access(guests.last.id) # => Gitlab::Access::GUEST
+ #
+ # @param object [Project, Group] Object to be stubbed.
+ # @param access_levels [Hash<Symbol, User>, Hash<Symbol, [User]>] Map of access level to users
+ def stub_member_access_level(object, **access_levels)
+ expectation = case object
+ when Project
+ ->(user) { expect(object.team).to receive(:max_member_access).with(user.id) }
+ when Group
+ ->(user) { expect(object).to receive(:max_member_access_for_user).with(user) }
+ else
+ raise ArgumentError,
+ "Stubbing member access level unsupported for #{object.inspect} (#{object.class})"
+ end
+
+ access_levels.each do |access_level, users|
+ access_level = Gitlab::Access.sym_options_with_owner.fetch(access_level) do
+ raise ArgumentError, "Invalid access level #{access_level.inspect}"
+ end
+
+ Array(users).each do |user|
+ expectation.call(user).at_least(1).times.and_return(access_level)
+ end
+ end
+ end
+end
diff --git a/spec/support/test_reports/test_reports_helper.rb b/spec/support/test_reports/test_reports_helper.rb
deleted file mode 100644
index 85483062958..00000000000
--- a/spec/support/test_reports/test_reports_helper.rb
+++ /dev/null
@@ -1,103 +0,0 @@
-# frozen_string_literal: true
-
-module TestReportsHelper
- def create_test_case_rspec_success(name = 'test_spec')
- Gitlab::Ci::Reports::TestCase.new(
- suite_name: 'rspec',
- name: 'Test#sum when a is 1 and b is 3 returns summary',
- classname: "spec.#{name}",
- file: './spec/test_spec.rb',
- execution_time: 1.11,
- status: Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS)
- end
-
- def create_test_case_rspec_failed(name = 'test_spec', execution_time = 2.22)
- Gitlab::Ci::Reports::TestCase.new(
- suite_name: 'rspec',
- name: 'Test#sum when a is 1 and b is 3 returns summary',
- classname: "spec.#{name}",
- file: './spec/test_spec.rb',
- execution_time: execution_time,
- system_output: sample_rspec_failed_message,
- status: Gitlab::Ci::Reports::TestCase::STATUS_FAILED)
- end
-
- def create_test_case_rspec_skipped(name = 'test_spec')
- Gitlab::Ci::Reports::TestCase.new(
- suite_name: 'rspec',
- name: 'Test#sum when a is 3 and b is 3 returns summary',
- classname: "spec.#{name}",
- file: './spec/test_spec.rb',
- execution_time: 3.33,
- status: Gitlab::Ci::Reports::TestCase::STATUS_SKIPPED)
- end
-
- def create_test_case_rspec_error(name = 'test_spec')
- Gitlab::Ci::Reports::TestCase.new(
- suite_name: 'rspec',
- name: 'Test#sum when a is 4 and b is 4 returns summary',
- classname: "spec.#{name}",
- file: './spec/test_spec.rb',
- execution_time: 4.44,
- status: Gitlab::Ci::Reports::TestCase::STATUS_ERROR)
- end
-
- def sample_rspec_failed_message
- <<-EOF.strip_heredoc
- Failure/Error: is_expected.to eq(3)
-
- expected: 3
- got: -1
-
- (compared using ==)
- ./spec/test_spec.rb:12:in `block (4 levels) in &lt;top (required)&gt;&apos;
- EOF
- end
-
- def create_test_case_java_success(name = 'addTest')
- Gitlab::Ci::Reports::TestCase.new(
- suite_name: 'java',
- name: name,
- classname: 'CalculatorTest',
- execution_time: 5.55,
- status: Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS)
- end
-
- def create_test_case_java_failed(name = 'addTest')
- Gitlab::Ci::Reports::TestCase.new(
- suite_name: 'java',
- name: name,
- classname: 'CalculatorTest',
- execution_time: 6.66,
- system_output: sample_java_failed_message,
- status: Gitlab::Ci::Reports::TestCase::STATUS_FAILED)
- end
-
- def create_test_case_java_skipped(name = 'addTest')
- Gitlab::Ci::Reports::TestCase.new(
- suite_name: 'java',
- name: name,
- classname: 'CalculatorTest',
- execution_time: 7.77,
- status: Gitlab::Ci::Reports::TestCase::STATUS_SKIPPED)
- end
-
- def create_test_case_java_error(name = 'addTest')
- Gitlab::Ci::Reports::TestCase.new(
- suite_name: 'java',
- name: name,
- classname: 'CalculatorTest',
- execution_time: 8.88,
- status: Gitlab::Ci::Reports::TestCase::STATUS_ERROR)
- end
-
- def sample_java_failed_message
- <<-EOF.strip_heredoc
- junit.framework.AssertionFailedError: expected:&lt;1&gt; but was:&lt;3&gt;
- at CalculatorTest.subtractExpression(Unknown Source)
- at java.base/jdk.internal.database.NativeMethodAccessorImpl.invoke0(Native Method)
- at java.base/jdk.internal.database.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
- at java.base/jdk.internal.database.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
- EOF
- end
-end
diff --git a/spec/support/tmpdir.rb b/spec/support/tmpdir.rb
index ea8e26d2878..92126ec1522 100644
--- a/spec/support/tmpdir.rb
+++ b/spec/support/tmpdir.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require 'tmpdir'
+
module TmpdirHelper
def mktmpdir
@tmpdir_helper_dirs ||= []
diff --git a/spec/support_specs/ability_check_spec.rb b/spec/support_specs/ability_check_spec.rb
new file mode 100644
index 00000000000..ce841112d86
--- /dev/null
+++ b/spec/support_specs/ability_check_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+require 'declarative_policy'
+require 'request_store'
+require 'tempfile'
+
+require 'gitlab/safe_request_store'
+
+require_relative '../../app/models/ability'
+require_relative '../support/ability_check'
+
+RSpec.describe Support::AbilityCheck, feature_category: :system_access do # rubocop:disable RSpec/FilePath
+ let(:user) { :user }
+ let(:child) { Testing::Child.new }
+ let(:parent) { Testing::Parent.new(child) }
+
+ before do
+ # Usually done in spec/spec_helper.
+ described_class.inject(Ability.singleton_class)
+
+ stub_const('Testing::BasePolicy', Class.new(DeclarativePolicy::Base))
+
+ stub_const('Testing::Parent', Struct.new(:parent_of))
+ stub_const('Testing::ParentPolicy', Class.new(Testing::BasePolicy) do
+ delegate { @subject.parent_of }
+ condition(:is_adult) { @subject.is_a?(Testing::Parent) }
+ rule { is_adult }.enable :drink_coffee
+ end)
+
+ stub_const('Testing::Child', Class.new)
+ stub_const('Testing::ChildPolicy', Class.new(Testing::BasePolicy) do
+ condition(:always) { true }
+ rule { always }.enable :eat_ice
+ end)
+ end
+
+ def expect_no_deprecation_warning(&block)
+ expect(&block).not_to output.to_stderr
+ end
+
+ def expect_deprecation_warning(policy_class, ability, &block)
+ expect(&block)
+ .to output(/DEPRECATION WARNING: Ability :#{ability} in #{policy_class} not found./)
+ .to_stderr
+ end
+
+ def expect_allowed(user, ability, subject)
+ expect(Ability.allowed?(user, ability, subject))
+ end
+
+ shared_examples 'ability found' do
+ it 'policy ability is found' do
+ expect_no_deprecation_warning do
+ expect_allowed(user, ability, subject).to eq(true)
+ end
+ end
+ end
+
+ shared_examples 'ability not found' do |warning:|
+ description = 'policy ability is not found'
+ description += warning ? ' and emits a warning' : ' without warning'
+
+ it description do
+ check = -> { expect_allowed(user, ability, subject).to eq(false) }
+
+ if warning
+ expect_deprecation_warning(warning, ability, &check)
+ else
+ expect_no_deprecation_warning(&check)
+ end
+ end
+ end
+
+ shared_context 'with custom TODO YAML' do
+ let(:yaml_file) { Tempfile.new }
+
+ before do
+ yaml_file.write(yaml_content)
+ yaml_file.rewind
+
+ stub_const("#{described_class}::Checker::TODO_YAML", yaml_file.path)
+ described_class::Checker.clear_memoization(:todo_list)
+ end
+
+ after do
+ described_class::Checker.clear_memoization(:todo_list)
+ yaml_file.unlink
+ end
+ end
+
+ describe 'checking ability' do
+ context 'with valid direct ability' do
+ let(:subject) { parent }
+ let(:ability) { :drink_coffee }
+
+ include_examples 'ability found'
+
+ context 'with empty TODO yaml' do
+ let(:yaml_content) { nil }
+
+ include_context 'with custom TODO YAML'
+ include_examples 'ability found'
+ end
+
+ context 'with non-Hash TODO yaml' do
+ let(:yaml_content) { '[]' }
+
+ include_context 'with custom TODO YAML'
+ include_examples 'ability found'
+ end
+ end
+
+ context 'with unreachable ability' do
+ let(:subject) { child }
+ let(:ability) { :drink_coffee }
+
+ include_examples 'ability not found', warning: 'Testing::ChildPolicy'
+
+ context 'when ignored in TODO YAML' do
+ let(:yaml_content) do
+ <<~YAML
+ Testing::ChildPolicy:
+ - #{ability}
+ YAML
+ end
+
+ include_context 'with custom TODO YAML'
+ include_examples 'ability not found', warning: false
+ end
+ end
+
+ context 'with unknown ability' do
+ let(:subject) { parent }
+ let(:ability) { :unknown }
+
+ include_examples 'ability not found', warning: 'Testing::ParentPolicy'
+ end
+
+ context 'with delegated ability' do
+ let(:subject) { parent }
+ let(:ability) { :eat_ice }
+
+ include_examples 'ability found'
+ end
+ end
+end
diff --git a/spec/support_specs/capybara_wait_for_all_requests_spec.rb b/spec/support_specs/capybara_wait_for_all_requests_spec.rb
new file mode 100644
index 00000000000..ddd4be6c644
--- /dev/null
+++ b/spec/support_specs/capybara_wait_for_all_requests_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'capybara'
+require 'support/capybara_wait_for_all_requests'
+
+RSpec.describe 'capybara_wait_for_all_requests', feature_category: :tooling do # rubocop:disable RSpec/FilePath
+ context 'for Capybara::Session::WaitForAllRequestsAfterVisitPage' do
+ let(:page_visitor) do
+ Class.new do
+ def visit(visit_uri)
+ visit_uri
+ end
+
+ prepend Capybara::Session::WaitForAllRequestsAfterVisitPage
+ end.new
+ end
+
+ it 'waits for all requests after a page visit' do
+ expect(page_visitor).to receive(:wait_for_all_requests)
+
+ page_visitor.visit('http://test.com')
+ end
+ end
+
+ context 'for Capybara::Node::Actions::WaitForAllRequestsAfterClickButton' do
+ let(:node) do
+ Class.new do
+ def click_button(locator = nil, **_options)
+ locator
+ end
+
+ prepend Capybara::Node::Actions::WaitForAllRequestsAfterClickButton
+ end.new
+ end
+
+ it 'waits for all requests after a click button' do
+ expect(node).to receive(:wait_for_all_requests)
+
+ node.click_button
+ end
+ end
+
+ context 'for Capybara::Node::Actions::WaitForAllRequestsAfterClickLink' do
+ let(:node) do
+ Class.new do
+ def click_link(locator = nil, **_options)
+ locator
+ end
+
+ prepend Capybara::Node::Actions::WaitForAllRequestsAfterClickLink
+ end.new
+ end
+
+ it 'waits for all requests after a click link' do
+ expect(node).to receive(:wait_for_all_requests)
+
+ node.click_link
+ end
+ end
+end
diff --git a/spec/support_specs/helpers/keyset_pagination_helpers_spec.rb b/spec/support_specs/helpers/keyset_pagination_helpers_spec.rb
new file mode 100644
index 00000000000..ec63f33776c
--- /dev/null
+++ b/spec/support_specs/helpers/keyset_pagination_helpers_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe KeysetPaginationHelpers, feature_category: :api do
+ include described_class
+
+ let(:headers) { { 'LINK' => %(<#{url}>; rel="#{rel}") } }
+ let(:response) { instance_double('HTTParty::Response', headers: headers) }
+ let(:rel) { 'next' }
+ let(:url) do
+ 'http://127.0.0.1:3000/api/v4/projects/7/audit_eve' \
+ 'nts?cursor=eyJpZCI6IjYyMjAiLCJfa2QiOiJuIn0%3D&id=7&o' \
+ 'rder_by=id&page=1&pagination=keyset&per_page=2'
+ end
+
+ describe '#pagination_links' do
+ subject { pagination_links(response) }
+
+ let(:expected_result) { [{ url: url, rel: rel }] }
+
+ it { is_expected.to eq expected_result }
+
+ context 'with a partially malformed LINK header' do
+ # malformed as the regxe is expecting the url to be surrounded by `<>`
+ let(:headers) do
+ { 'LINK' => %(<#{url}>; rel="next", GARBAGE, #{url}; rel="prev") }
+ end
+
+ it { is_expected.to eq expected_result }
+ end
+
+ context 'with a malformed LINK header' do
+ # malformed as the regxe is expecting the url to be surrounded by `<>`
+ let(:headers) { { 'LINK' => %(rel="next", GARBAGE, #{url}; rel="prev") } }
+ let(:expected_result) { [] }
+
+ it { is_expected.to eq expected_result }
+ end
+ end
+
+ describe '#pagination_params_from_next_url' do
+ subject { pagination_params_from_next_url(response) }
+
+ let(:expected_result) do
+ {
+ 'cursor' => 'eyJpZCI6IjYyMjAiLCJfa2QiOiJuIn0=',
+ 'id' => '7',
+ 'order_by' => 'id',
+ 'page' => '1',
+ 'pagination' => 'keyset',
+ 'per_page' => '2'
+ }
+ end
+
+ it { is_expected.to eq expected_result }
+
+ context 'with both prev and next rel links' do
+ let(:prev_url) do
+ 'http://127.0.0.1:3000/api/v4/projects/7/audit_eve' \
+ 'nts?cursor=foocursor&id=8&o' \
+ 'rder_by=id&page=0&pagination=keyset&per_page=2'
+ end
+
+ let(:headers) do
+ { 'LINK' => %(<#{url}>; rel="next", <#{prev_url}>; rel="prev") }
+ end
+
+ it { is_expected.to eq expected_result }
+ end
+
+ context 'with a partially malformed LINK header' do
+ # malformed as the regxe is expecting the url to be surrounded by `<>`
+ let(:headers) do
+ { 'LINK' => %(<#{url}>; rel="next", GARBAGE, #{url}; rel="prev") }
+ end
+
+ it { is_expected.to eq expected_result }
+ end
+
+ context 'with a malformed LINK header' do
+ # malformed as the regxe is expecting the url to be surrounded by `<>`
+ let(:headers) { { 'LINK' => %(rel="next", GARBAGE, #{url}; rel="prev") } }
+
+ it { is_expected.to be nil }
+ end
+ end
+end
diff --git a/spec/support_specs/helpers/migrations_helpers_spec.rb b/spec/support_specs/helpers/migrations_helpers_spec.rb
index 5d44dac8eb7..2af16151350 100644
--- a/spec/support_specs/helpers/migrations_helpers_spec.rb
+++ b/spec/support_specs/helpers/migrations_helpers_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MigrationsHelpers do
+RSpec.describe MigrationsHelpers, feature_category: :database do
let(:helper_class) do
Class.new.tap do |klass|
klass.include described_class
@@ -71,4 +71,40 @@ RSpec.describe MigrationsHelpers do
end
end
end
+
+ describe '#reset_column_information' do
+ context 'with a regular ActiveRecord model class' do
+ let(:klass) { Project }
+
+ it 'calls reset_column_information' do
+ expect(klass).to receive(:reset_column_information)
+
+ helper.reset_column_information(klass)
+ end
+ end
+
+ context 'with an anonymous class with table name defined' do
+ let(:klass) do
+ Class.new(ActiveRecord::Base) do
+ self.table_name = :projects
+ end
+ end
+
+ it 'calls reset_column_information' do
+ expect(klass).to receive(:reset_column_information)
+
+ helper.reset_column_information(klass)
+ end
+ end
+
+ context 'with an anonymous class with no table name defined' do
+ let(:klass) { Class.new(ActiveRecord::Base) }
+
+ it 'does not call reset_column_information' do
+ expect(klass).not_to receive(:reset_column_information)
+
+ helper.reset_column_information(klass)
+ end
+ end
+ end
end
diff --git a/spec/support_specs/matchers/event_store_spec.rb b/spec/support_specs/matchers/event_store_spec.rb
index 3614d05fde8..bd77f7124c1 100644
--- a/spec/support_specs/matchers/event_store_spec.rb
+++ b/spec/support_specs/matchers/event_store_spec.rb
@@ -5,7 +5,7 @@ require 'json_schemer'
load File.expand_path('../../../spec/support/matchers/event_store.rb', __dir__)
-RSpec.describe 'event store matchers', :aggregate_errors do
+RSpec.describe 'event store matchers', feature_category: :shared do
let(:event_type1) do
Class.new(Gitlab::EventStore::Event) do
def schema
diff --git a/spec/support_specs/matchers/exceed_redis_call_limit_spec.rb b/spec/support_specs/matchers/exceed_redis_call_limit_spec.rb
new file mode 100644
index 00000000000..819f50e26b6
--- /dev/null
+++ b/spec/support_specs/matchers/exceed_redis_call_limit_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'RedisCommand matchers', :use_clean_rails_redis_caching, feature_category: :source_code_management do
+ let(:control) do
+ RedisCommands::Recorder.new do
+ Rails.cache.read('test')
+ Rails.cache.read('test')
+ Rails.cache.write('test', 1)
+ end
+ end
+
+ before do
+ Rails.cache.read('warmup')
+ end
+
+ it 'verifies maximum number of Redis calls' do
+ expect(control).not_to exceed_redis_calls_limit(3)
+
+ expect(control).not_to exceed_redis_command_calls_limit(:get, 2)
+ expect(control).not_to exceed_redis_command_calls_limit(:set, 1)
+ end
+
+ it 'verifies minimum number of Redis calls' do
+ expect(control).to exceed_redis_calls_limit(2)
+
+ expect(control).to exceed_redis_command_calls_limit(:get, 1)
+ expect(control).to exceed_redis_command_calls_limit(:set, 0)
+ end
+
+ context 'with Recorder matching only some Redis calls' do
+ it 'counts only Redis calls captured by Recorder' do
+ Rails.cache.write('ignored', 1)
+
+ control = RedisCommands::Recorder.new do
+ Rails.cache.read('recorded')
+ end
+
+ Rails.cache.write('also_ignored', 1)
+
+ expect(control).not_to exceed_redis_calls_limit(1)
+ expect(control).not_to exceed_redis_command_calls_limit(:set, 0)
+ expect(control).not_to exceed_redis_command_calls_limit(:get, 1)
+ end
+ end
+
+ context 'when expect part is a function' do
+ it 'automatically enables RedisCommand::Recorder for it' do
+ func = -> do
+ Rails.cache.read('test')
+ Rails.cache.read('test')
+ end
+
+ expect { func.call }.not_to exceed_redis_calls_limit(2)
+ expect { func.call }.not_to exceed_redis_command_calls_limit(:get, 2)
+ end
+ end
+end
diff --git a/spec/support_specs/stub_member_access_level_spec.rb b/spec/support_specs/stub_member_access_level_spec.rb
new file mode 100644
index 00000000000..c76bd2ee417
--- /dev/null
+++ b/spec/support_specs/stub_member_access_level_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_relative '../support/stub_member_access_level'
+
+RSpec.describe StubMemberAccessLevel, feature_category: :system_access do
+ include described_class
+
+ describe 'stub_member_access_level' do
+ shared_examples 'access level stubs' do
+ let(:guests) { build_stubbed_list(:user, 2) }
+ let(:maintainer) { build_stubbed(:user) }
+ let(:no_access) { build_stubbed(:user) }
+
+ it 'stubs max member access level per user' do
+ stub_member_access_level(object, maintainer: maintainer, guest: guests)
+
+ # Ensure that multple calls are allowed
+ 2.times do
+ expect(access_level_for(maintainer)).to eq(Gitlab::Access::MAINTAINER)
+ expect(access_level_for(guests.first)).to eq(Gitlab::Access::GUEST)
+ expect(access_level_for(guests.last)).to eq(Gitlab::Access::GUEST)
+
+ # Partially stub so we expect a mock error.
+ expect { access_level_for(no_access) }.to raise_error(RSpec::Mocks::MockExpectationError)
+ end
+ end
+
+ it 'fails for unstubbed access' do
+ expect(access_level_for(no_access)).to eq(Gitlab::Access::NO_ACCESS)
+ end
+
+ it 'fails for invalid access level' do
+ expect { stub_member_access_level(object, unknown: :anything) }
+ .to raise_error(ArgumentError, "Invalid access level :unknown")
+ end
+ end
+
+ context 'with project' do
+ let(:object) { build_stubbed(:project) }
+
+ it_behaves_like 'access level stubs' do
+ def access_level_for(user)
+ object.team.max_member_access(user.id)
+ end
+ end
+ end
+
+ context 'with group' do
+ let(:object) { build_stubbed(:group) }
+
+ it_behaves_like 'access level stubs' do
+ def access_level_for(user)
+ object.max_member_access_for_user(user)
+ end
+ end
+ end
+
+ context 'with unsupported object' do
+ let(:object) { :a_symbol }
+
+ it 'raises an error' do
+ expect { stub_member_access_level(object) }
+ .to raise_error(ArgumentError, "Stubbing member access level unsupported for :a_symbol (Symbol)")
+ end
+ end
+ end
+end
diff --git a/spec/tasks/dev_rake_spec.rb b/spec/tasks/dev_rake_spec.rb
index ef047b383a6..82c9bb4faa2 100644
--- a/spec/tasks/dev_rake_spec.rb
+++ b/spec/tasks/dev_rake_spec.rb
@@ -121,7 +121,7 @@ RSpec.describe 'dev rake tasks' do
context 'when a database is not found' do
before do
- skip_if_multiple_databases_not_setup
+ skip_if_shared_database(:ci)
end
it 'continues to next connection' do
@@ -135,7 +135,7 @@ RSpec.describe 'dev rake tasks' do
context 'multiple databases' do
before do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
end
context 'with a valid database' do
diff --git a/spec/tasks/gettext_rake_spec.rb b/spec/tasks/gettext_rake_spec.rb
index 29caa363f7b..c44c1734432 100644
--- a/spec/tasks/gettext_rake_spec.rb
+++ b/spec/tasks/gettext_rake_spec.rb
@@ -1,8 +1,10 @@
# frozen_string_literal: true
require 'rake_helper'
+require_relative '../../tooling/lib/tooling/gettext_extractor'
+require_relative '../support/matchers/abort_matcher'
-RSpec.describe 'gettext', :silence_stdout do
+RSpec.describe 'gettext', :silence_stdout, feature_category: :internationalization do
let(:locale_path) { Rails.root.join('tmp/gettext_spec') }
let(:pot_file_path) { File.join(locale_path, 'gitlab.pot') }
@@ -21,28 +23,43 @@ RSpec.describe 'gettext', :silence_stdout do
end
describe ':compile' do
- before do
- allow(Rake::Task).to receive(:[]).and_call_original
+ let(:compile_command) do
+ [
+ "node", "./scripts/frontend/po_to_json.js",
+ "--locale-root", Rails.root.join('locale').to_s,
+ "--output-dir", Rails.root.join('app/assets/javascripts/locale').to_s
+ ]
end
- it 'creates a pot file and invokes the \'gettext:po_to_json\' task' do
- expect(Rake::Task).to receive(:[]).with('gettext:po_to_json').and_return(double(invoke: true))
+ it 'creates a pot file and runs po-to-json conversion via node script' do
+ expect(Kernel).to receive(:system).with(*compile_command).and_return(true)
expect { run_rake_task('gettext:compile') }
.to change { File.exist?(pot_file_path) }
.to be_truthy
end
+
+ it 'aborts with non-successful po-to-json conversion via node script' do
+ expect(Kernel).to receive(:system).with(*compile_command).and_return(false)
+
+ expect { run_rake_task('gettext:compile') }.to abort_execution
+ end
end
describe ':regenerate' do
+ let(:locale_nz_path) { File.join(locale_path, 'en_NZ') }
+ let(:po_file_path) { File.join(locale_nz_path, 'gitlab.po') }
+ let(:extractor) { instance_double(Tooling::GettextExtractor, generate_pot: '') }
+
before do
+ FileUtils.mkdir(locale_nz_path)
+ File.write(po_file_path, fixture_file('valid.po'))
+
# this task takes a *really* long time to complete, so stub it for the spec
- allow(Rake::Task['gettext:find']).to receive(:invoke) { invoke_find.call }
+ allow(Tooling::GettextExtractor).to receive(:new).and_return(extractor)
end
context 'when the locale folder is not found' do
- let(:invoke_find) { -> { true } }
-
before do
FileUtils.rm_r(locale_path) if Dir.exist?(locale_path)
end
@@ -53,67 +70,14 @@ RSpec.describe 'gettext', :silence_stdout do
end
end
- context 'where there are existing /**/gitlab.po files' do
- let(:locale_nz_path) { File.join(locale_path, 'en_NZ') }
- let(:po_file_path) { File.join(locale_nz_path, 'gitlab.po') }
-
- let(:invoke_find) { -> { File.write pot_file_path, 'pot file test updates' } }
-
- before do
- FileUtils.mkdir(locale_nz_path)
- File.write(po_file_path, fixture_file('valid.po'))
- end
-
- it 'does not remove that locale' do
- expect { run_rake_task('gettext:regenerate') }
- .not_to change { Dir.exist?(locale_nz_path) }
- end
- end
-
- context 'when there are locale folders without a gitlab.po file' do
- let(:empty_locale_path) { File.join(locale_path, 'en_NZ') }
-
- let(:invoke_find) { -> { File.write pot_file_path, 'pot file test updates' } }
-
- before do
- FileUtils.mkdir(empty_locale_path)
- end
-
- it 'removes those folders' do
- expect { run_rake_task('gettext:regenerate') }
- .to change { Dir.exist?(empty_locale_path) }
- .to eq false
- end
- end
-
context 'when the gitlab.pot file cannot be generated' do
- let(:invoke_find) { -> { true } }
-
it 'prints an error' do
+ allow(File).to receive(:exist?).and_return(false)
+
expect { run_rake_task('gettext:regenerate') }
.to raise_error(/gitlab.pot file not generated/)
end
end
-
- context 'when gettext:find changes the revision dates' do
- let(:invoke_find) { -> { File.write pot_file_path, fixture_file('valid.po') } }
-
- before do
- File.write pot_file_path, fixture_file('valid.po')
- end
-
- it 'resets the changes' do
- pot_file = File.read(pot_file_path)
- expect(pot_file).to include('PO-Revision-Date: 2017-07-13 12:10-0500')
- expect(pot_file).to include('PO-Creation-Date: 2016-07-13 12:11-0500')
-
- run_rake_task('gettext:regenerate')
-
- pot_file = File.read(pot_file_path)
- expect(pot_file).not_to include('PO-Revision-Date: 2017-07-13 12:10-0500')
- expect(pot_file).not_to include('PO-Creation-Date: 2016-07-13 12:11-0500')
- end
- end
end
describe ':lint' do
diff --git a/spec/tasks/gitlab/background_migrations_rake_spec.rb b/spec/tasks/gitlab/background_migrations_rake_spec.rb
index 876b56d1208..04be713e0d4 100644
--- a/spec/tasks/gitlab/background_migrations_rake_spec.rb
+++ b/spec/tasks/gitlab/background_migrations_rake_spec.rb
@@ -2,7 +2,8 @@
require 'rake_helper'
-RSpec.describe 'gitlab:background_migrations namespace rake tasks', :suppress_gitlab_schemas_validate_connection do
+RSpec.describe 'gitlab:background_migrations namespace rake tasks', :suppress_gitlab_schemas_validate_connection,
+ feature_category: :database do
before do
Rake.application.rake_require 'tasks/gitlab/background_migrations'
end
@@ -62,7 +63,7 @@ RSpec.describe 'gitlab:background_migrations namespace rake tasks', :suppress_gi
let(:databases) { [Gitlab::Database::MAIN_DATABASE_NAME, ci_database_name] }
before do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models)
end
@@ -114,12 +115,6 @@ RSpec.describe 'gitlab:background_migrations namespace rake tasks', :suppress_gi
let(:connection) { double(:connection) }
let(:base_models) { { 'main' => model }.with_indifferent_access }
- around do |example|
- Gitlab::Database::SharedModel.using_connection(model.connection) do
- example.run
- end
- end
-
it 'outputs the status of background migrations' do
allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models)
@@ -130,15 +125,33 @@ RSpec.describe 'gitlab:background_migrations namespace rake tasks', :suppress_gi
OUTPUT
end
- context 'when multiple database feature is enabled' do
+ context 'when running the rake task against one database in multiple databases setup' do
before do
- skip_if_multiple_databases_not_setup
+ skip_if_shared_database(:ci)
end
- context 'with a single database' do
- subject(:status_task) { run_rake_task("gitlab:background_migrations:status:#{main_database_name}") }
+ subject(:status_task) { run_rake_task("gitlab:background_migrations:status:#{main_database_name}") }
- it 'outputs the status of background migrations' do
+ it 'outputs the status of background migrations' do
+ expect { status_task }.to output(<<~OUTPUT).to_stdout
+ Database: #{main_database_name}
+ finished | #{migration1.job_class_name},#{migration1.table_name},#{migration1.column_name},[["id1","id2"]]
+ failed | #{migration2.job_class_name},#{migration2.table_name},#{migration2.column_name},[]
+ OUTPUT
+ end
+ end
+
+ context 'when multiple databases are configured' do
+ before do
+ skip_if_multiple_databases_not_setup(:ci)
+ end
+
+ context 'with two connections sharing the same database' do
+ before do
+ skip_if_database_exists(:ci)
+ end
+
+ it 'skips the shared database' do
expect { status_task }.to output(<<~OUTPUT).to_stdout
Database: #{main_database_name}
finished | #{migration1.job_class_name},#{migration1.table_name},#{migration1.column_name},[["id1","id2"]]
@@ -153,6 +166,10 @@ RSpec.describe 'gitlab:background_migrations namespace rake tasks', :suppress_gi
end
context 'with multiple databases' do
+ before do
+ skip_if_shared_database(:ci)
+ end
+
subject(:status_task) { run_rake_task('gitlab:background_migrations:status') }
let(:base_models) { { main: main_model, ci: ci_model } }
@@ -161,6 +178,8 @@ RSpec.describe 'gitlab:background_migrations namespace rake tasks', :suppress_gi
it 'outputs the status for each database' do
allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models)
+ allow(Gitlab::Database).to receive(:has_database?).with(:main).and_return(true)
+ allow(Gitlab::Database).to receive(:has_database?).with(:ci).and_return(true)
expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(main_model.connection).and_yield
expect(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:find_each).and_yield(migration1)
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index c0196c09e3c..7113818ed34 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -55,6 +55,7 @@ RSpec.describe 'gitlab:backup namespace rake tasks', :delete, feature_category:
after do
FileUtils.rm(tars_glob, force: true)
FileUtils.rm(backup_files, force: true)
+ FileUtils.rm(backup_restore_pid_path, force: true)
FileUtils.rm_rf(backup_directories, secure: true)
FileUtils.rm_rf('tmp/tests/public/uploads', secure: true)
end
@@ -66,32 +67,80 @@ RSpec.describe 'gitlab:backup namespace rake tasks', :delete, feature_category:
end
describe 'lock parallel backups' do
- using RSpec::Parameterized::TableSyntax
+ let(:progress) { $stdout }
+ let(:delete_message) { /-- Deleting backup and restore PID file/ }
+ let(:pid_file) do
+ File.open(backup_restore_pid_path, File::RDWR | File::CREAT)
+ end
+
+ before do
+ allow(Kernel).to receive(:system).and_return(true)
+ allow(YAML).to receive(:safe_load_file).and_return({ gitlab_version: Gitlab::VERSION })
+ end
- context 'when a process is running' do
- let(:pid_file) { instance_double(File) }
+ context 'when a process is running in parallel' do
+ before do
+ File.open(backup_restore_pid_path, 'wb') do |file|
+ file.write('123456')
+ file.close
+ end
+ end
it 'exits the new process' do
allow(File).to receive(:open).and_call_original
allow(File).to receive(:open).with(backup_restore_pid_path, any_args).and_yield(pid_file)
- allow(pid_file).to receive(:read).and_return('123456')
- allow(pid_file).to receive(:flock).with(any_args)
+ allow(Process).to receive(:getpgid).with(123456).and_return(123456)
expect { run_rake_task('gitlab:backup:create') }.to raise_error(SystemExit).and output(
- <<~HEREDOC
+ <<~MESSAGE
Backup and restore in progress:
- There is a backup and restore task in progress. Please, try to run the current task once the previous one ends.
- If there is no other process running, please remove the PID file manually: rm #{backup_restore_pid_path}
- HEREDOC
+ There is a backup and restore task in progress (PID 123456).
+ Try to run the current task once the previous one ends.
+ MESSAGE
).to_stdout
end
end
- context 'when no processes are running' do
- let(:progress) { $stdout }
- let(:pid_file) { instance_double(File, write: 12345) }
+ context 'when no process is running in parallel but a PID file exists' do
+ let(:rewritten_message) do
+ <<~MESSAGE
+ The PID file #{backup_restore_pid_path} exists and contains 123456, but the process is not running.
+ The PID file will be rewritten with the current process ID #{Process.pid}.
+ MESSAGE
+ end
- where(:tasks_name, :rake_task) do
+ before do
+ File.open(backup_restore_pid_path, 'wb') do |file|
+ file.write('123456')
+ file.close
+ end
+ end
+
+ it 'rewrites, locks and deletes the PID file while logging a message' do
+ allow(File).to receive(:open).and_call_original
+ allow(File).to receive(:open).with(backup_restore_pid_path, any_args).and_yield(pid_file)
+ allow(Process).to receive(:getpgid).with(123456).and_raise(Errno::ESRCH)
+ allow(progress).to receive(:puts).with(delete_message).once
+ allow(progress).to receive(:puts).with(rewritten_message).once
+
+ allow_next_instance_of(::Backup::Manager) do |instance|
+ allow(instance).to receive(:run_restore_task).with('db')
+ end
+
+ expect(pid_file).to receive(:flock).with(File::LOCK_EX)
+ expect(pid_file).to receive(:flock).with(File::LOCK_UN)
+ expect(File).to receive(:delete).with(backup_restore_pid_path)
+ expect(progress).to receive(:puts).with(rewritten_message).once
+ expect(progress).to receive(:puts).with(delete_message).once
+
+ run_rake_task('gitlab:backup:db:restore')
+ end
+ end
+
+ context 'when no process is running in parallel' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:task_name, :rake_task) do
'db' | 'gitlab:backup:db:restore'
'repositories' | 'gitlab:backup:repo:restore'
'builds' | 'gitlab:backup:builds:restore'
@@ -106,34 +155,23 @@ RSpec.describe 'gitlab:backup namespace rake tasks', :delete, feature_category:
with_them do
before do
- allow(Kernel).to receive(:system).and_return(true)
- allow(YAML).to receive(:load_file).and_return({ gitlab_version: Gitlab::VERSION })
- allow(File).to receive(:delete).with(backup_restore_pid_path).and_return(1)
allow(File).to receive(:open).and_call_original
allow(File).to receive(:open).with(backup_restore_pid_path, any_args).and_yield(pid_file)
- allow(pid_file).to receive(:read).and_return('')
- allow(pid_file).to receive(:flock).with(any_args)
- allow(pid_file).to receive(:write).with(12345).and_return(true)
- allow(pid_file).to receive(:flush)
+ allow(File).to receive(:delete).with(backup_restore_pid_path)
allow(progress).to receive(:puts).at_least(:once)
allow_next_instance_of(::Backup::Manager) do |instance|
- Array(tasks_name).each do |task|
+ Array(task_name).each do |task|
allow(instance).to receive(:run_restore_task).with(task)
end
end
end
- it 'locks the PID file' do
+ it 'locks and deletes the PID file while logging a message' do
expect(pid_file).to receive(:flock).with(File::LOCK_EX)
expect(pid_file).to receive(:flock).with(File::LOCK_UN)
-
- run_rake_task(rake_task)
- end
-
- it 'deletes the PID file and logs a message' do
expect(File).to receive(:delete).with(backup_restore_pid_path)
- expect(progress).to receive(:puts).with(/-- Deleting backup and restore lock file/)
+ expect(progress).to receive(:puts).with(delete_message)
run_rake_task(rake_task)
end
@@ -158,7 +196,7 @@ RSpec.describe 'gitlab:backup namespace rake tasks', :delete, feature_category:
context 'when restore matches gitlab version' do
before do
- allow(YAML).to receive(:load_file)
+ allow(YAML).to receive(:safe_load_file)
.and_return({ gitlab_version: gitlab_version })
expect_next_instance_of(::Backup::Manager) do |instance|
backup_types.each do |subtask|
@@ -212,7 +250,7 @@ RSpec.describe 'gitlab:backup namespace rake tasks', :delete, feature_category:
allow(Kernel).to receive(:system).and_return(true)
allow(FileUtils).to receive(:cp_r).and_return(true)
allow(FileUtils).to receive(:mv).and_return(true)
- allow(YAML).to receive(:load_file)
+ allow(YAML).to receive(:safe_load_file)
.and_return({ gitlab_version: Gitlab::VERSION })
expect_next_instance_of(::Backup::Manager) do |instance|
diff --git a/spec/tasks/gitlab/db/decomposition/connection_status_spec.rb b/spec/tasks/gitlab/db/decomposition/connection_status_spec.rb
new file mode 100644
index 00000000000..78f86049ebb
--- /dev/null
+++ b/spec/tasks/gitlab/db/decomposition/connection_status_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+
+RSpec.describe 'gitlab:db:decomposition:connection_status', feature_category: :cell do
+ let(:max_connections) { 500 }
+ let(:current_connections) { 300 }
+
+ subject { run_rake_task('gitlab:db:decomposition:connection_status') }
+
+ before :all do
+ Rake.application.rake_require 'tasks/gitlab/db/decomposition/connection_status'
+ end
+
+ before do
+ allow(ApplicationRecord.connection).to receive(:select_one).with(any_args).and_return(
+ { "active" => current_connections, "max" => max_connections }
+ )
+ end
+
+ context 'when separate ci database is not configured' do
+ before do
+ skip_if_multiple_databases_are_setup
+ end
+
+ context "when PostgreSQL max_connections is too low" do
+ it 'suggests to increase it' do
+ expect { subject }.to output(
+ "Currently using #{current_connections} connections out of #{max_connections} max_connections,\n" \
+ "which may run out when you switch to two database connections.\n\n" \
+ "Consider increasing PostgreSQL 'max_connections' setting.\n" \
+ "Depending on the installation method, there are different ways to\n" \
+ "increase that setting. Please consult the GitLab documentation.\n"
+ ).to_stdout
+ end
+ end
+
+ context "when PostgreSQL max_connections is high enough" do
+ let(:max_connections) { 1000 }
+
+ it 'only shows current status' do
+ expect { subject }.to output(
+ "Currently using #{current_connections} connections out of #{max_connections} max_connections,\n" \
+ "which is enough for running GitLab using two database connections.\n"
+ ).to_stdout
+ end
+ end
+ end
+
+ context 'when separate ci database is configured' do
+ before do
+ skip_if_multiple_databases_not_setup
+ end
+
+ it "does not show connection information" do
+ expect { subject }.to output(
+ "GitLab database already running on two connections\n"
+ ).to_stdout
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/db/decomposition/rollback/bump_ci_sequences_rake_spec.rb b/spec/tasks/gitlab/db/decomposition/rollback/bump_ci_sequences_rake_spec.rb
index 0682a4b39cf..5a9d44221ba 100644
--- a/spec/tasks/gitlab/db/decomposition/rollback/bump_ci_sequences_rake_spec.rb
+++ b/spec/tasks/gitlab/db/decomposition/rollback/bump_ci_sequences_rake_spec.rb
@@ -3,7 +3,7 @@
require 'rake_helper'
RSpec.describe 'gitlab:db:decomposition:rollback:bump_ci_sequences', :silence_stdout,
- :suppress_gitlab_schemas_validate_connection, feature_category: :pods do
+ :suppress_gitlab_schemas_validate_connection, feature_category: :cell, query_analyzers: false do
before :all do
Rake.application.rake_require 'tasks/gitlab/db/decomposition/rollback/bump_ci_sequences'
@@ -86,7 +86,7 @@ RSpec.describe 'gitlab:db:decomposition:rollback:bump_ci_sequences', :silence_st
context 'when multiple databases' do
before do
- skip_if_multiple_databases_not_setup(:ci)
+ skip_if_shared_database(:ci)
end
it 'does not change ci sequences on the ci database' do
@@ -100,5 +100,7 @@ RSpec.describe 'gitlab:db:decomposition:rollback:bump_ci_sequences', :silence_st
end
def last_value_of_sequence(connection, sequence_name)
- connection.select_value("select last_value from #{sequence_name}")
+ allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408220') do
+ connection.select_value("select last_value from #{sequence_name}")
+ end
end
diff --git a/spec/tasks/gitlab/db/lock_writes_rake_spec.rb b/spec/tasks/gitlab/db/lock_writes_rake_spec.rb
index 9d54241aa7f..90612bcf9f7 100644
--- a/spec/tasks/gitlab/db/lock_writes_rake_spec.rb
+++ b/spec/tasks/gitlab/db/lock_writes_rake_spec.rb
@@ -2,7 +2,7 @@
require 'rake_helper'
-RSpec.describe 'gitlab:db:lock_writes', :reestablished_active_record_base, feature_category: :pods do
+RSpec.describe 'gitlab:db:lock_writes', :reestablished_active_record_base, feature_category: :cell do
before :all do
Rake.application.rake_require 'active_record/railties/databases'
Rake.application.rake_require 'tasks/seed_fu'
diff --git a/spec/tasks/gitlab/db/truncate_legacy_tables_rake_spec.rb b/spec/tasks/gitlab/db/truncate_legacy_tables_rake_spec.rb
index 6e245b6f227..301da891244 100644
--- a/spec/tasks/gitlab/db/truncate_legacy_tables_rake_spec.rb
+++ b/spec/tasks/gitlab/db/truncate_legacy_tables_rake_spec.rb
@@ -3,7 +3,7 @@
require 'rake_helper'
RSpec.describe 'gitlab:db:truncate_legacy_tables', :silence_stdout, :reestablished_active_record_base,
- :suppress_gitlab_schemas_validate_connection, feature_category: :pods do
+ :suppress_gitlab_schemas_validate_connection, feature_category: :cell do
let(:main_connection) { ApplicationRecord.connection }
let(:ci_connection) { Ci::ApplicationRecord.connection }
let(:test_gitlab_main_table) { '_test_gitlab_main_table' }
@@ -20,19 +20,16 @@ RSpec.describe 'gitlab:db:truncate_legacy_tables', :silence_stdout, :reestablish
end
before do
- skip_if_multiple_databases_not_setup(:ci)
-
- # Filling the table on both databases main and ci
- Gitlab::Database.database_base_models.each_value do |base_model|
- base_model.connection.execute(<<~SQL)
- CREATE TABLE #{test_gitlab_main_table} (id integer NOT NULL);
- INSERT INTO #{test_gitlab_main_table} VALUES(generate_series(1, 50));
- SQL
- base_model.connection.execute(<<~SQL)
- CREATE TABLE #{test_gitlab_ci_table} (id integer NOT NULL);
- INSERT INTO #{test_gitlab_ci_table} VALUES(generate_series(1, 50));
- SQL
- end
+ skip_if_shared_database(:ci)
+
+ execute_on_each_database(<<~SQL)
+ CREATE TABLE #{test_gitlab_main_table} (id integer NOT NULL);
+ INSERT INTO #{test_gitlab_main_table} VALUES(generate_series(1, 50));
+ SQL
+ execute_on_each_database(<<~SQL)
+ CREATE TABLE #{test_gitlab_ci_table} (id integer NOT NULL);
+ INSERT INTO #{test_gitlab_ci_table} VALUES(generate_series(1, 50));
+ SQL
allow(Gitlab::Database::GitlabSchema).to receive(:tables_to_schema).and_return(
{
diff --git a/spec/tasks/gitlab/db/validate_config_rake_spec.rb b/spec/tasks/gitlab/db/validate_config_rake_spec.rb
index cc90345c7e0..94808232d7e 100644
--- a/spec/tasks/gitlab/db/validate_config_rake_spec.rb
+++ b/spec/tasks/gitlab/db/validate_config_rake_spec.rb
@@ -2,7 +2,7 @@
require 'rake_helper'
-RSpec.describe 'gitlab:db:validate_config', :silence_stdout, :suppress_gitlab_schemas_validate_connection, feature_category: :pods do
+RSpec.describe 'gitlab:db:validate_config', :silence_stdout, :suppress_gitlab_schemas_validate_connection, feature_category: :cell do
# We don't need to delete this data since it only modifies `ar_internal_metadata`
# which would not be cleaned either by `DbCleaner`
self.use_transactional_tests = false
diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb
index 933eba40719..95730f62b28 100644
--- a/spec/tasks/gitlab/db_rake_spec.rb
+++ b/spec/tasks/gitlab/db_rake_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
before do
# Stub out db tasks
allow(Rake::Task['db:migrate']).to receive(:invoke).and_return(true)
- allow(Rake::Task['db:structure:load']).to receive(:invoke).and_return(true)
+ allow(Rake::Task['db:schema:load']).to receive(:invoke).and_return(true)
allow(Rake::Task['db:seed_fu']).to receive(:invoke).and_return(true)
end
@@ -25,7 +25,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
let(:main_model) { ApplicationRecord }
before do
- skip_if_multiple_databases_are_setup
+ skip_if_database_exists(:ci)
end
it 'marks the migration complete on the given database' do
@@ -43,7 +43,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
let(:base_models) { { 'main' => main_model, 'ci' => ci_model } }
before do
- skip_unless_ci_uses_database_tasks
+ skip_if_shared_database(:ci)
allow(Gitlab::Database).to receive(:database_base_models_with_gitlab_shared).and_return(base_models)
end
@@ -130,7 +130,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
let(:main_config) { double(:config, name: 'main') }
before do
- skip_if_multiple_databases_are_setup
+ skip_if_database_exists(:ci)
end
context 'when geo is not configured' do
@@ -259,7 +259,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
let(:ci_config) { double(:config, name: 'ci') }
before do
- skip_unless_ci_uses_database_tasks
+ skip_if_shared_database(:ci)
allow(Gitlab::Database).to receive(:database_base_models_with_gitlab_shared).and_return(base_models)
end
@@ -352,6 +352,37 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
end
end
+ describe 'schema inconsistencies' do
+ let(:runner) { instance_double(Gitlab::Database::SchemaValidation::Runner, execute: inconsistencies) }
+ let(:inconsistency_class) { Gitlab::Database::SchemaValidation::Inconsistency }
+
+ let(:inconsistencies) do
+ [
+ instance_double(inconsistency_class, inspect: 'index_statement_1', type: 'wrong_indexes'),
+ instance_double(inconsistency_class, inspect: 'index_statement_2', type: 'missing_indexes'),
+ instance_double(inconsistency_class, inspect: 'table_statement_1', type: 'extra_tables',
+ table_name: 'test_replication'),
+ instance_double(inconsistency_class, inspect: 'trigger_statement', type: 'missing_triggers',
+ object_name: 'gitlab_schema_write_trigger_for_users')
+ ]
+ end
+
+ let(:rake_output) do
+ <<~MSG
+ index_statement_1
+ index_statement_2
+ MSG
+ end
+
+ before do
+ allow(Gitlab::Database::SchemaValidation::Runner).to receive(:new).and_return(runner)
+ end
+
+ it 'prints the inconsistency message' do
+ expect { run_rake_task('gitlab:db:schema_checker:run') }.to output(rake_output).to_stdout
+ end
+ end
+
describe 'dictionary generate' do
let(:db_config) { instance_double(ActiveRecord::DatabaseConfigurations::HashConfig, name: 'fake_db') }
@@ -395,13 +426,19 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
end
end
- context 'when the dictionary files already exist' do
+ context 'when a new model class is added to the codebase' do
let(:table_class) do
Class.new(ApplicationRecord) do
self.table_name = 'table1'
end
end
+ let(:migration_table_class) do
+ Class.new(Gitlab::Database::Migration[1.0]::MigrationRecord) do
+ self.table_name = 'table1'
+ end
+ end
+
let(:view_class) do
Class.new(ApplicationRecord) do
self.table_name = 'view1'
@@ -410,7 +447,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
table_metadata = {
'table_name' => 'table1',
- 'classes' => [],
+ 'classes' => ['TableClass'],
'feature_categories' => [],
'description' => nil,
'introduced_by_url' => nil,
@@ -418,7 +455,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
}
view_metadata = {
'view_name' => 'view1',
- 'classes' => [],
+ 'classes' => ['ViewClass'],
'feature_categories' => [],
'description' => nil,
'introduced_by_url' => nil,
@@ -426,23 +463,60 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
}
before do
- stub_const('TableClass', table_class)
- stub_const('ViewClass', view_class)
+ stub_const('TableClass1', table_class)
+ stub_const('MIgrationTableClass1', migration_table_class)
+ stub_const('ViewClass1', view_class)
+
+ File.write(table_file_path, table_metadata.to_yaml)
+ File.write(view_file_path, view_metadata.to_yaml)
+
+ allow(model).to receive(:descendants).and_return([table_class, migration_table_class, view_class])
+ end
+
+ it 'appends new classes to the dictionary' do
+ run_rake_task('gitlab:db:dictionary:generate')
+
+ table_metadata = YAML.safe_load(File.read(table_file_path))
+ expect(table_metadata['classes']).to match_array(%w[TableClass TableClass1])
+
+ view_metadata = YAML.safe_load(File.read(view_file_path))
+ expect(view_metadata['classes']).to match_array(%w[ViewClass ViewClass1])
+ end
+ end
+
+ context 'when a model class is removed from the codebase' do
+ table_metadata = {
+ 'table_name' => 'table1',
+ 'classes' => ['TableClass'],
+ 'feature_categories' => [],
+ 'description' => nil,
+ 'introduced_by_url' => nil,
+ 'milestone' => 14.3
+ }
+ view_metadata = {
+ 'view_name' => 'view1',
+ 'classes' => ['ViewClass'],
+ 'feature_categories' => [],
+ 'description' => nil,
+ 'introduced_by_url' => nil,
+ 'milestone' => 14.3
+ }
+ before do
File.write(table_file_path, table_metadata.to_yaml)
File.write(view_file_path, view_metadata.to_yaml)
- allow(model).to receive(:descendants).and_return([table_class, view_class])
+ allow(model).to receive(:descendants).and_return([])
end
- it 'update the dictionary content' do
+ it 'keeps the dictionary classes' do
run_rake_task('gitlab:db:dictionary:generate')
table_metadata = YAML.safe_load(File.read(table_file_path))
- expect(table_metadata['classes']).to match_array(['TableClass'])
+ expect(table_metadata['classes']).to match_array(%w[TableClass])
view_metadata = YAML.safe_load(File.read(view_file_path))
- expect(view_metadata['classes']).to match_array(['ViewClass'])
+ expect(view_metadata['classes']).to match_array(%w[ViewClass])
end
end
end
@@ -489,6 +563,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
end
if Gitlab.ee?
+ allow(File).to receive(:open).with(Rails.root.join(Gitlab::Database::EMBEDDING_DATABASE_DIR, 'structure.sql').to_s, any_args).and_yield(output)
allow(File).to receive(:open).with(Rails.root.join(Gitlab::Database::GEO_DATABASE_DIR, 'structure.sql').to_s, any_args).and_yield(output)
end
end
@@ -510,8 +585,14 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
describe 'drop_tables' do
let(:tables) { %w(one two schema_migrations) }
- let(:views) { %w(three four) }
+ let(:views) { %w(three four pg_stat_statements) }
let(:schemas) { Gitlab::Database::EXTRA_SCHEMAS }
+ let(:ignored_views) { double(ActiveRecord::Relation, pluck: ['pg_stat_statements']) }
+
+ before do
+ allow(Gitlab::Database::PgDepend).to receive(:using_connection).and_yield
+ allow(Gitlab::Database::PgDepend).to receive(:from_pg_extension).with('VIEW').and_return(ignored_views)
+ end
context 'with a single database' do
let(:connection) { ActiveRecord::Base.connection }
@@ -538,7 +619,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
let(:base_models) { { 'main' => main_model, 'ci' => ci_model } }
before do
- skip_unless_ci_uses_database_tasks
+ skip_if_shared_database(:ci)
allow(Gitlab::Database).to receive(:database_base_models_with_gitlab_shared).and_return(base_models)
@@ -586,6 +667,8 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
expect(connection).to receive(:execute).with('DROP VIEW IF EXISTS "three" CASCADE')
expect(connection).to receive(:execute).with('DROP VIEW IF EXISTS "four" CASCADE')
+ expect(Gitlab::Database::PgDepend).to receive(:from_pg_extension).with('VIEW')
+ expect(connection).not_to receive(:execute).with('DROP VIEW IF EXISTS "pg_stat_statements" CASCADE')
expect(connection).to receive(:execute).with('TRUNCATE schema_migrations')
@@ -610,7 +693,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
context 'with multiple databases' do
before do
- skip_unless_ci_uses_database_tasks
+ skip_if_shared_database(:ci)
end
context 'when running the multi-database variant' do
@@ -645,7 +728,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
describe 'reindex' do
context 'with a single database' do
before do
- skip_if_multiple_databases_are_setup
+ skip_if_shared_database(:ci)
end
it 'delegates to Gitlab::Database::Reindexing' do
@@ -681,7 +764,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
context 'when the single database task is used' do
before do
- skip_unless_ci_uses_database_tasks
+ skip_if_shared_database(:ci)
end
it 'delegates to Gitlab::Database::Reindexing with a specific database' do
@@ -733,7 +816,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
describe 'execute_async_index_operations' do
before do
- skip_if_multiple_databases_not_setup
+ skip_if_shared_database(:ci)
end
it 'delegates ci task to Gitlab::Database::AsyncIndexes' do
@@ -805,23 +888,95 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
end
end
+ describe 'validate_async_constraints' do
+ before do
+ skip_if_shared_database(:ci)
+ end
+
+ it 'delegates ci task to Gitlab::Database::AsyncConstraints' do
+ expect(Gitlab::Database::AsyncConstraints).to receive(:validate_pending_entries!).with(how_many: 2)
+
+ run_rake_task('gitlab:db:validate_async_constraints:ci')
+ end
+
+ it 'delegates ci task to Gitlab::Database::AsyncConstraints with specified argument' do
+ expect(Gitlab::Database::AsyncConstraints).to receive(:validate_pending_entries!).with(how_many: 5)
+
+ run_rake_task('gitlab:db:validate_async_constraints:ci', '[5]')
+ end
+
+ it 'delegates main task to Gitlab::Database::AsyncConstraints' do
+ expect(Gitlab::Database::AsyncConstraints).to receive(:validate_pending_entries!).with(how_many: 2)
+
+ run_rake_task('gitlab:db:validate_async_constraints:main')
+ end
+
+ it 'delegates main task to Gitlab::Database::AsyncConstraints with specified argument' do
+ expect(Gitlab::Database::AsyncConstraints).to receive(:validate_pending_entries!).with(how_many: 7)
+
+ run_rake_task('gitlab:db:validate_async_constraints:main', '[7]')
+ end
+
+ it 'delegates all task to every database with higher default for dev' do
+ expect(Rake::Task['gitlab:db:validate_async_constraints:ci']).to receive(:invoke).with(1000)
+ expect(Rake::Task['gitlab:db:validate_async_constraints:main']).to receive(:invoke).with(1000)
+
+ run_rake_task('gitlab:db:validate_async_constraints:all')
+ end
+
+ it 'delegates all task to every database with lower default for prod' do
+ allow(Gitlab).to receive(:dev_or_test_env?).and_return(false)
+
+ expect(Rake::Task['gitlab:db:validate_async_constraints:ci']).to receive(:invoke).with(2)
+ expect(Rake::Task['gitlab:db:validate_async_constraints:main']).to receive(:invoke).with(2)
+
+ run_rake_task('gitlab:db:validate_async_constraints:all')
+ end
+
+ it 'delegates all task to every database with specified argument' do
+ expect(Rake::Task['gitlab:db:validate_async_constraints:ci']).to receive(:invoke).with('50')
+ expect(Rake::Task['gitlab:db:validate_async_constraints:main']).to receive(:invoke).with('50')
+
+ run_rake_task('gitlab:db:validate_async_constraints:all', '[50]')
+ end
+
+ context 'when feature is not enabled' do
+ it 'is a no-op' do
+ stub_feature_flags(database_async_foreign_key_validation: false)
+
+ expect(Gitlab::Database::AsyncConstraints).not_to receive(:validate_pending_entries!)
+
+ expect { run_rake_task('gitlab:db:validate_async_constraints:main') }.to raise_error(SystemExit)
+ end
+ end
+
+ context 'with geo configured' do
+ before do
+ skip_unless_geo_configured
+ end
+
+ it 'does not create a task for the geo database' do
+ expect { run_rake_task('gitlab:db:validate_async_constraints:geo') }
+ .to raise_error(/Don't know how to build task 'gitlab:db:validate_async_constraints:geo'/)
+ end
+ end
+ end
+
describe 'active' do
using RSpec::Parameterized::TableSyntax
let(:task) { 'gitlab:db:active' }
- let(:self_monitoring) { double('self_monitoring') }
- where(:needs_migration, :self_monitoring_project, :project_count, :exit_status, :exit_code) do
- true | nil | nil | 1 | false
- false | :self_monitoring | 1 | 1 | false
- false | nil | 0 | 1 | false
- false | :self_monitoring | 2 | 0 | true
+ where(:needs_migration, :project_count, :exit_status, :exit_code) do
+ true | nil | 1 | false
+ false | 1 | 0 | true
+ false | 0 | 1 | false
+ false | 2 | 0 | true
end
with_them do
it 'exits 0 or 1 depending on user modifications to the database' do
allow_any_instance_of(ActiveRecord::MigrationContext).to receive(:needs_migration?).and_return(needs_migration)
- allow_any_instance_of(ApplicationSetting).to receive(:self_monitoring_project).and_return(self_monitoring_project)
allow(Project).to receive(:count).and_return(project_count)
expect { run_rake_task(task) }.to raise_error do |error|
@@ -972,15 +1127,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
context 'with multiple databases', :reestablished_active_record_base do
before do
- skip_unless_ci_uses_database_tasks
- end
-
- describe 'db:structure:dump against a single database' do
- it 'invokes gitlab:db:clean_structure_sql' do
- expect(Rake::Task['gitlab:db:clean_structure_sql']).to receive(:invoke).twice.and_return(true)
-
- expect { run_rake_task('db:structure:dump:main') }.not_to raise_error
- end
+ skip_if_shared_database(:ci)
end
describe 'db:schema:dump against a single database' do
@@ -1062,14 +1209,6 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout, feature_categor
run_rake_task(test_task_name)
end
- def skip_unless_ci_uses_database_tasks
- skip "Skipping because database tasks won't run against the ci database" unless ci_database_tasks?
- end
-
- def ci_database_tasks?
- !!ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'ci')&.database_tasks?
- end
-
def skip_unless_geo_configured
skip 'Skipping because the geo database is not configured' unless geo_configured?
end
diff --git a/spec/tasks/gitlab/feature_categories_rake_spec.rb b/spec/tasks/gitlab/feature_categories_rake_spec.rb
index 22f36309a7c..33f4bca4c85 100644
--- a/spec/tasks/gitlab/feature_categories_rake_spec.rb
+++ b/spec/tasks/gitlab/feature_categories_rake_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe 'gitlab:feature_categories:index', :silence_stdout, feature_categ
)
),
'api_endpoints' => a_hash_including(
- 'authentication_and_authorization' => a_collection_including(
+ 'system_access' => a_collection_including(
klass: 'API::AccessRequests',
action: '/groups/:id/access_requests',
source_location: [
diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb
index d2f4fa0b8ef..a161f33373d 100644
--- a/spec/tasks/gitlab/gitaly_rake_spec.rb
+++ b/spec/tasks/gitlab/gitaly_rake_spec.rb
@@ -66,7 +66,7 @@ RSpec.describe 'gitlab:gitaly namespace rake task', :silence_stdout do
.with(%w[which gmake])
.and_return(['/usr/bin/gmake', 0])
expect(Gitlab::Popen).to receive(:popen)
- .with(%w[gmake clean-build all], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil })
+ .with(%w[gmake clean all])
.and_return(['ok', 0])
subject
@@ -78,7 +78,7 @@ RSpec.describe 'gitlab:gitaly namespace rake task', :silence_stdout do
.with(%w[which gmake])
.and_return(['/usr/bin/gmake', 0])
expect(Gitlab::Popen).to receive(:popen)
- .with(%w[gmake clean-build all], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil })
+ .with(%w[gmake clean all])
.and_return(['output', 1])
expect { subject }.to raise_error /Gitaly failed to compile: output/
@@ -95,27 +95,11 @@ RSpec.describe 'gitlab:gitaly namespace rake task', :silence_stdout do
it 'calls make in the gitaly directory' do
expect(Gitlab::Popen).to receive(:popen)
- .with(%w[make clean-build all], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil })
+ .with(%w[make clean all])
.and_return(['output', 0])
subject
end
-
- context 'when Rails.env is test' do
- let(:command) { %w[make clean-build all] }
-
- before do
- stub_rails_env('test')
- end
-
- it 'calls make in the gitaly directory with BUNDLE_DEPLOYMENT and GEM_HOME variables' do
- expect(Gitlab::Popen).to receive(:popen)
- .with(command, nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil, "BUNDLE_DEPLOYMENT" => 'false', "GEM_HOME" => Bundler.bundle_path.to_s })
- .and_return(['/usr/bin/gmake', 0])
-
- subject
- end
- end
end
end
end
diff --git a/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb b/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb
index 3ee01977cba..f0fc3c501c5 100644
--- a/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb
+++ b/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb
@@ -2,7 +2,7 @@
require 'rake_helper'
-RSpec.describe 'gitlab:refresh_project_statistics_build_artifacts_size rake task', :silence_stdout do
+RSpec.describe 'gitlab:refresh_project_statistics_build_artifacts_size rake task', :silence_stdout, feature_category: :build_artifacts do
let(:rake_task) { 'gitlab:refresh_project_statistics_build_artifacts_size' }
describe 'enqueuing build artifacts size statistics refresh for given list of project IDs' do
@@ -10,8 +10,6 @@ RSpec.describe 'gitlab:refresh_project_statistics_build_artifacts_size rake task
let_it_be(:project_2) { create(:project) }
let_it_be(:project_3) { create(:project) }
- let(:string_of_ids) { "#{project_1.id} #{project_2.id} #{project_3.id} 999999" }
- let(:csv_url) { 'https://www.example.com/foo.csv' }
let(:csv_body) do
<<~BODY
PROJECT_ID
@@ -26,13 +24,12 @@ RSpec.describe 'gitlab:refresh_project_statistics_build_artifacts_size rake task
stub_const("BUILD_ARTIFACTS_SIZE_REFRESH_ENQUEUE_BATCH_SIZE", 2)
- stub_request(:get, csv_url).to_return(status: 200, body: csv_body)
allow(Kernel).to receive(:sleep).with(1)
end
- context 'when given a list of space-separated IDs through rake argument' do
+ shared_examples_for 'recalculates project statistics successfully' do
it 'enqueues the projects for refresh' do
- expect { run_rake_task(rake_task, csv_url) }.to output(/Done/).to_stdout
+ expect { run_rake_task(rake_task, csv_path) }.to output(/Done/).to_stdout
expect(Projects::BuildArtifactsSizeRefresh.all.map(&:project)).to match_array([project_1, project_2, project_3])
end
@@ -42,11 +39,11 @@ RSpec.describe 'gitlab:refresh_project_statistics_build_artifacts_size rake task
expect(Kernel).to receive(:sleep).with(1)
expect(Projects::BuildArtifactsSizeRefresh).to receive(:enqueue_refresh).with([project_3]).ordered
- run_rake_task(rake_task, csv_url)
+ run_rake_task(rake_task, csv_path)
end
end
- context 'when CSV has invalid header' do
+ shared_examples_for 'raises error for invalid header' do
let(:csv_body) do
<<~BODY
projectid
@@ -57,8 +54,34 @@ RSpec.describe 'gitlab:refresh_project_statistics_build_artifacts_size rake task
end
it 'returns an error message' do
- expect { run_rake_task(rake_task, csv_url) }.to output(/Project IDs must be listed in the CSV under the header PROJECT_ID/).to_stdout
+ expect { run_rake_task(rake_task, csv_path) }.to output(/Project IDs must be listed in the CSV under the header PROJECT_ID/).to_stdout
end
end
+
+ context 'when given a remote CSV file' do
+ let(:csv_path) { 'https://www.example.com/foo.csv' }
+
+ before do
+ stub_request(:get, csv_path).to_return(status: 200, body: csv_body)
+ end
+
+ it_behaves_like 'recalculates project statistics successfully'
+ it_behaves_like 'raises error for invalid header'
+ end
+
+ context 'when given a local CSV file' do
+ before do
+ File.write(csv_path, csv_body, mode: 'w')
+ end
+
+ after do
+ FileUtils.rm_f(csv_path)
+ end
+
+ let(:csv_path) { 'foo.csv' }
+
+ it_behaves_like 'recalculates project statistics successfully'
+ it_behaves_like 'raises error for invalid header'
+ end
end
end
diff --git a/spec/tasks/gitlab/security/update_banned_ssh_keys_rake_spec.rb b/spec/tasks/gitlab/security/update_banned_ssh_keys_rake_spec.rb
index 25ea5d75a56..264dea815f4 100644
--- a/spec/tasks/gitlab/security/update_banned_ssh_keys_rake_spec.rb
+++ b/spec/tasks/gitlab/security/update_banned_ssh_keys_rake_spec.rb
@@ -7,7 +7,7 @@ require 'rake_helper'
# is hit in the rake task.
require 'git'
-RSpec.describe 'gitlab:security namespace rake tasks', :silence_stdout, feature_category: :credential_management do
+RSpec.describe 'gitlab:security namespace rake tasks', :silence_stdout, feature_category: :user_management do
let(:fixture_path) { Rails.root.join('spec/fixtures/tasks/gitlab/security') }
let(:output_file) { File.join(__dir__, 'tmp/banned_keys_test.yml') }
let(:git_url) { 'https://github.com/rapid7/ssh-badkeys.git' }
diff --git a/spec/tasks/gitlab/setup_rake_spec.rb b/spec/tasks/gitlab/setup_rake_spec.rb
index c31546fc259..80e997fcf88 100644
--- a/spec/tasks/gitlab/setup_rake_spec.rb
+++ b/spec/tasks/gitlab/setup_rake_spec.rb
@@ -7,6 +7,8 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do
Rake.application.rake_require 'active_record/railties/databases'
Rake.application.rake_require 'tasks/seed_fu'
Rake.application.rake_require 'tasks/dev'
+ Rake.application.rake_require 'tasks/gitlab/db/validate_config'
+ Rake.application.rake_require 'tasks/gitlab/db/lock_writes'
Rake.application.rake_require 'tasks/gitlab/setup'
end
@@ -115,11 +117,13 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do
def expect_database_to_be_setup
expect(Rake::Task['db:reset']).to receive(:invoke)
+ expect(Rake::Task['gitlab:db:lock_writes']).to receive(:invoke)
expect(Rake::Task['db:seed_fu']).to receive(:invoke)
end
def expect_database_not_to_be_setup
expect(Rake::Task['db:reset']).not_to receive(:invoke)
+ expect(Rake::Task['gitlab:db:lock_writes']).not_to receive(:invoke)
expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
end
end
diff --git a/spec/tasks/gitlab/storage_rake_spec.rb b/spec/tasks/gitlab/storage_rake_spec.rb
index a2546b8d033..cd520673143 100644
--- a/spec/tasks/gitlab/storage_rake_spec.rb
+++ b/spec/tasks/gitlab/storage_rake_spec.rb
@@ -2,7 +2,7 @@
require 'rake_helper'
-RSpec.describe 'rake gitlab:storage:*', :silence_stdout, feature_category: :pods do
+RSpec.describe 'rake gitlab:storage:*', :silence_stdout, feature_category: :cell do
before do
Rake.application.rake_require 'tasks/gitlab/storage'
diff --git a/spec/tooling/danger/analytics_instrumentation_spec.rb b/spec/tooling/danger/analytics_instrumentation_spec.rb
new file mode 100644
index 00000000000..5d12647e02f
--- /dev/null
+++ b/spec/tooling/danger/analytics_instrumentation_spec.rb
@@ -0,0 +1,234 @@
+# frozen_string_literal: true
+
+require 'gitlab-dangerfiles'
+require 'gitlab/dangerfiles/spec_helper'
+
+require_relative '../../../tooling/danger/analytics_instrumentation'
+require_relative '../../../tooling/danger/project_helper'
+
+RSpec.describe Tooling::Danger::AnalyticsInstrumentation, feature_category: :service_ping do
+ include_context "with dangerfile"
+
+ subject(:analytics_instrumentation) { fake_danger.new(helper: fake_helper) }
+
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
+ let(:previous_label_to_add) { 'label_to_add' }
+ let(:labels_to_add) { [previous_label_to_add] }
+ let(:ci_env) { true }
+ let(:has_analytics_instrumentation_label) { true }
+
+ before do
+ allow(fake_helper).to receive(:changed_lines).and_return(changed_lines) if defined?(changed_lines)
+ allow(fake_helper).to receive(:labels_to_add).and_return(labels_to_add)
+ allow(fake_helper).to receive(:ci?).and_return(ci_env)
+ allow(fake_helper).to receive(:mr_has_labels?).with('analytics instrumentation').and_return(has_analytics_instrumentation_label)
+ end
+
+ describe '#check!' do
+ subject { analytics_instrumentation.check! }
+
+ let(:markdown_formatted_list) { 'markdown formatted list' }
+ let(:review_pending_label) { 'analytics instrumentation::review pending' }
+ let(:approved_label) { 'analytics instrumentation::approved' }
+ let(:changed_files) { ['metrics/counts_7d/test_metric.yml'] }
+ let(:changed_lines) { ['+tier: ee'] }
+ let(:fake_changes) { instance_double(Gitlab::Dangerfiles::Changes, files: changed_files) }
+
+ before do
+ allow(fake_changes).to receive(:by_category).with(:analytics_instrumentation).and_return(fake_changes)
+ allow(fake_helper).to receive(:changes).and_return(fake_changes)
+ allow(fake_helper).to receive(:all_changed_files).and_return(changed_files)
+ allow(fake_helper).to receive(:markdown_list).with(changed_files).and_return(markdown_formatted_list)
+ end
+
+ shared_examples "doesn't add new labels" do
+ it "doesn't add new labels" do
+ subject
+
+ expect(labels_to_add).to match_array [previous_label_to_add]
+ end
+ end
+
+ shared_examples "doesn't add new warnings" do
+ it "doesn't add new warnings" do
+ expect(analytics_instrumentation).not_to receive(:warn)
+
+ subject
+ end
+ end
+
+ shared_examples 'adds new labels' do
+ it 'adds new labels' do
+ subject
+
+ expect(labels_to_add).to match_array [previous_label_to_add, review_pending_label]
+ end
+
+ it 'receives all the changed files by calling the correct helper method', :aggregate_failures do
+ expect(fake_helper).not_to receive(:changes_by_category)
+ expect(fake_helper).to receive(:changes)
+ expect(fake_changes).to receive(:by_category).with(:analytics_instrumentation)
+ expect(fake_changes).to receive(:files)
+
+ subject
+ end
+ end
+
+ context 'with growth experiment label' do
+ before do
+ allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(true)
+ end
+
+ include_examples "doesn't add new labels"
+ include_examples "doesn't add new warnings"
+ end
+
+ context 'without growth experiment label' do
+ before do
+ allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(false)
+ end
+
+ context 'with approved label' do
+ let(:mr_labels) { [approved_label] }
+
+ include_examples "doesn't add new labels"
+ include_examples "doesn't add new warnings"
+ end
+
+ context 'without approved label' do
+ include_examples 'adds new labels'
+
+ it 'warns with proper message' do
+ expect(analytics_instrumentation).to receive(:warn).with(%r{#{markdown_formatted_list}})
+
+ subject
+ end
+ end
+
+ context 'with analytics instrumentation::review pending label' do
+ let(:mr_labels) { ['analytics instrumentation::review pending'] }
+
+ include_examples "doesn't add new labels"
+ end
+
+ context 'with analytics instrumentation::approved label' do
+ let(:mr_labels) { ['analytics instrumentation::approved'] }
+
+ include_examples "doesn't add new labels"
+ end
+
+ context 'with the analytics instrumentation label' do
+ let(:has_analytics_instrumentation_label) { true }
+
+ context 'with ci? false' do
+ let(:ci_env) { false }
+
+ include_examples "doesn't add new labels"
+ end
+
+ context 'with ci? true' do
+ include_examples 'adds new labels'
+ end
+ end
+ end
+ end
+
+ describe '#check_affected_scopes!' do
+ let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'tooling', 'fixtures', 'metrics', '*.rb')) }
+ let(:changed_lines) { ['+ scope :active, -> { iwhere(email: Array(emails)) }'] }
+
+ before do
+ allow(Dir).to receive(:glob).and_return(fixture_dir_glob)
+ allow(fake_helper).to receive(:markdown_list).with({ 'active' => fixture_dir_glob }).and_return('a')
+ end
+
+ context 'when a model was modified' do
+ let(:modified_files) { ['app/models/super_user.rb'] }
+
+ context 'when a scope is changed' do
+ context 'and a metrics uses the affected scope' do
+ it 'producing warning' do
+ expect(analytics_instrumentation).to receive(:warn).with(%r{#{modified_files}})
+
+ analytics_instrumentation.check_affected_scopes!
+ end
+ end
+
+ context 'when no metrics using the affected scope' do
+ let(:changed_lines) { ['+scope :foo, -> { iwhere(email: Array(emails)) }'] }
+
+ it 'doesnt do anything' do
+ expect(analytics_instrumentation).not_to receive(:warn)
+
+ analytics_instrumentation.check_affected_scopes!
+ end
+ end
+ end
+ end
+
+ context 'when an unrelated model with matching scope was modified' do
+ let(:modified_files) { ['app/models/post_box.rb'] }
+
+ it 'doesnt do anything' do
+ expect(analytics_instrumentation).not_to receive(:warn)
+
+ analytics_instrumentation.check_affected_scopes!
+ end
+ end
+
+ context 'when models arent modified' do
+ let(:modified_files) { ['spec/app/models/user_spec.rb'] }
+
+ it 'doesnt do anything' do
+ expect(analytics_instrumentation).not_to receive(:warn)
+
+ analytics_instrumentation.check_affected_scopes!
+ end
+ end
+ end
+
+ describe '#check_usage_data_insertions!' do
+ context 'when usage_data.rb is modified' do
+ let(:modified_files) { ['lib/gitlab/usage_data.rb'] }
+
+ before do
+ allow(fake_helper).to receive(:changed_lines).with("lib/gitlab/usage_data.rb").and_return(changed_lines)
+ end
+
+ context 'and has insertions' do
+ let(:changed_lines) { ['+ ci_runners: count(::Ci::CiRunner),'] }
+
+ it 'produces warning' do
+ expect(analytics_instrumentation).to receive(:warn).with(/usage_data\.rb has been deprecated/)
+
+ analytics_instrumentation.check_usage_data_insertions!
+ end
+ end
+
+ context 'and changes are not insertions' do
+ let(:changed_lines) { ['- ci_runners: count(::Ci::CiRunner),'] }
+
+ it 'doesnt do anything' do
+ expect(analytics_instrumentation).not_to receive(:warn)
+
+ analytics_instrumentation.check_usage_data_insertions!
+ end
+ end
+ end
+
+ context 'when usage_data.rb is not modified' do
+ context 'and another file has insertions' do
+ let(:modified_files) { ['tooling/danger/analytics_instrumentation.rb'] }
+
+ it 'doesnt do anything' do
+ expect(fake_helper).to receive(:changed_lines).with("lib/gitlab/usage_data.rb").and_return([])
+ allow(fake_helper).to receive(:changed_lines).with("tooling/danger/analytics_instrumentation.rb").and_return(["+ Inserting"])
+
+ expect(analytics_instrumentation).not_to receive(:warn)
+
+ analytics_instrumentation.check_usage_data_insertions!
+ end
+ end
+ end
+ end
+end
diff --git a/spec/tooling/danger/database_dictionary_spec.rb b/spec/tooling/danger/database_dictionary_spec.rb
new file mode 100644
index 00000000000..1a771a6cec0
--- /dev/null
+++ b/spec/tooling/danger/database_dictionary_spec.rb
@@ -0,0 +1,152 @@
+# frozen_string_literal: true
+
+require 'gitlab-dangerfiles'
+require 'gitlab/dangerfiles/spec_helper'
+
+require_relative '../../../tooling/danger/database_dictionary'
+
+RSpec.describe Tooling::Danger::DatabaseDictionary, feature_category: :shared do
+ include_context "with dangerfile"
+
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
+
+ subject(:database_dictionary) { fake_danger.new(helper: fake_helper) }
+
+ describe '#database_dictionary_files' do
+ let(:database_dictionary_files) do
+ [
+ 'db/docs/ci_pipelines.yml',
+ 'db/docs/projects.yml'
+ ]
+ end
+
+ let(:other_files) do
+ [
+ 'app/models/model.rb',
+ 'app/assets/javascripts/file.js'
+ ]
+ end
+
+ shared_examples 'an array of Found objects' do |change_type|
+ it 'returns an array of Found objects' do
+ expect(database_dictionary.database_dictionary_files(change_type: change_type))
+ .to contain_exactly(
+ an_instance_of(described_class::Found),
+ an_instance_of(described_class::Found)
+ )
+
+ expect(database_dictionary.database_dictionary_files(change_type: change_type).map(&:path))
+ .to eq(database_dictionary_files)
+ end
+ end
+
+ shared_examples 'an empty array' do |change_type|
+ it 'returns an array of Found objects' do
+ expect(database_dictionary.database_dictionary_files(change_type: change_type)).to be_empty
+ end
+ end
+
+ describe 'retrieves added database dictionary files' do
+ context 'with added added database dictionary files' do
+ let(:added_files) { database_dictionary_files }
+
+ include_examples 'an array of Found objects', :added
+ end
+
+ context 'without added added database dictionary files' do
+ let(:added_files) { other_files }
+
+ include_examples 'an empty array', :added
+ end
+ end
+
+ describe 'retrieves modified database dictionary files' do
+ context 'with modified modified database dictionary files' do
+ let(:modified_files) { database_dictionary_files }
+
+ include_examples 'an array of Found objects', :modified
+ end
+
+ context 'without modified modified database dictionary files' do
+ let(:modified_files) { other_files }
+
+ include_examples 'an empty array', :modified
+ end
+ end
+
+ describe 'retrieves deleted database dictionary files' do
+ context 'with deleted deleted database dictionary files' do
+ let(:deleted_files) { database_dictionary_files }
+
+ include_examples 'an array of Found objects', :deleted
+ end
+
+ context 'without deleted deleted database dictionary files' do
+ let(:deleted_files) { other_files }
+
+ include_examples 'an empty array', :deleted
+ end
+ end
+ end
+
+ describe described_class::Found do
+ let(:database_dictionary_path) { 'db/docs/ci_pipelines.yml' }
+ let(:gitlab_schema) { 'gitlab_ci' }
+
+ let(:yaml) do
+ {
+
+ 'table_name' => 'ci_pipelines',
+ 'classes' => ['Ci::Pipeline'],
+ 'feature_categories' => ['continuous_integration'],
+ 'description' => 'TODO',
+ 'introduced_by_url' => 'https://gitlab.com/gitlab-org/gitlab/-/commit/c6ae290cea4b88ecaa9cfe0bc9d88e8fd32070c1',
+ 'milestone' => '9.0',
+ 'gitlab_schema' => gitlab_schema
+ }
+ end
+
+ let(:raw_yaml) { YAML.dump(yaml) }
+
+ subject(:found) { described_class.new(database_dictionary_path) }
+
+ before do
+ allow(File).to receive(:read).and_call_original
+ allow(File).to receive(:read).with(database_dictionary_path).and_return(raw_yaml)
+ end
+
+ described_class::ATTRIBUTES.each do |attribute|
+ describe "##{attribute}" do
+ it 'returns value from the YAML' do
+ expect(found.public_send(attribute)).to eq(yaml[attribute])
+ end
+ end
+ end
+
+ describe '#raw' do
+ it 'returns the raw YAML' do
+ expect(found.raw).to eq(raw_yaml)
+ end
+ end
+
+ describe '#ci_schema?' do
+ it { expect(found.ci_schema?).to be_truthy }
+
+ context 'with main schema' do
+ let(:gitlab_schema) { 'gitlab_main' }
+
+ it { expect(found.ci_schema?).to be_falsey }
+ end
+ end
+
+ describe '#main_schema?' do
+ it { expect(found.main_schema?).to be_falsey }
+
+ context 'with main schema' do
+ let(:gitlab_schema) { 'gitlab_main' }
+
+ it { expect(found.main_schema?).to be_truthy }
+ end
+ end
+ end
+end
diff --git a/spec/tooling/danger/feature_flag_spec.rb b/spec/tooling/danger/feature_flag_spec.rb
index 4575d8ca981..f4df2e1226c 100644
--- a/spec/tooling/danger/feature_flag_spec.rb
+++ b/spec/tooling/danger/feature_flag_spec.rb
@@ -83,6 +83,28 @@ RSpec.describe Tooling::Danger::FeatureFlag do
end
end
+ describe '#stage_label' do
+ before do
+ allow(fake_helper).to receive(:mr_labels).and_return(labels)
+ end
+
+ context 'when there is no stage label' do
+ let(:labels) { [] }
+
+ it 'returns nil' do
+ expect(feature_flag.stage_label).to be_nil
+ end
+ end
+
+ context 'when there is a stage label' do
+ let(:labels) { ['devops::verify', 'group::pipeline execution'] }
+
+ it 'returns the stage label' do
+ expect(feature_flag.stage_label).to eq(labels.first)
+ end
+ end
+ end
+
describe described_class::Found do
let(:feature_flag_path) { 'config/feature_flags/development/entry.yml' }
let(:group) { 'group::source code' }
diff --git a/spec/tooling/danger/multiversion_spec.rb b/spec/tooling/danger/multiversion_spec.rb
new file mode 100644
index 00000000000..90edad61d47
--- /dev/null
+++ b/spec/tooling/danger/multiversion_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'rspec-parameterized'
+require 'gitlab-dangerfiles'
+require 'gitlab/dangerfiles/spec_helper'
+
+require_relative '../../../tooling/danger/multiversion'
+require_relative '../../../tooling/danger/project_helper'
+
+RSpec.describe Tooling::Danger::Multiversion, feature_category: :shared do
+ include_context "with dangerfile"
+
+ subject(:multiversion) { fake_danger.new(helper: fake_helper, git: fake_git) }
+
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
+ let(:ci_env) { true }
+
+ before do
+ allow(fake_helper).to receive(:ci?).and_return(ci_env)
+ allow(fake_git).to receive(:modified_files).and_return(modified_files)
+ allow(fake_git).to receive(:added_files).and_return(added_files)
+ end
+
+ describe '#check!' do
+ using RSpec::Parameterized::TableSyntax
+
+ context 'when not in ci environment' do
+ let(:ci_env) { false }
+
+ it 'does not add the warning markdown section' do
+ expect(multiversion).not_to receive(:markdown)
+
+ multiversion.check!
+ end
+ end
+
+ context 'when GraphQL API and frontend assets have not been simultaneously updated' do
+ where(:modified_files, :added_files) do
+ %w[app/assets/helloworld.vue] | %w[]
+ %w[app/assets/helloworld.vue] | %w[app/type.rb]
+ %w[app/assets/helloworld.js] | %w[app/graphql.rb]
+ %w[app/assets/helloworld.graphql] | %w[app/models/graphql.rb]
+ %w[] | %w[app/graphql/type.rb]
+ %w[app/vue.txt] | %w[app/graphql/type.rb]
+ %w[app/views/foo.haml] | %w[app/graphql/type.rb]
+ %w[foo] | %w[]
+ %w[] | %w[]
+ end
+
+ with_them do
+ it 'does not add the warning markdown section' do
+ expect(multiversion).not_to receive(:markdown)
+
+ multiversion.check!
+ end
+ end
+ end
+
+ context 'when GraphQL API and frontend assets have been simultaneously updated' do
+ where(:modified_files, :added_files) do
+ %w[app/assets/helloworld.vue] | %w[app/graphql/type.rb]
+ %w[app/assets/helloworld.vue] | %w[app/graphql/type.rb]
+ %w[app/assets/helloworld.js] | %w[app/graphql/type.rb]
+ %w[ee/app/assets/helloworld.js] | %w[app/graphql/type.rb]
+ %w[app/assets/helloworld.graphql] | %w[ee/app/graphql/type.rb]
+ %w[ee/app/assets/helloworld.graphql] | %w[ee/app/graphql/type.rb]
+ %w[ee/app/assets/helloworld.graphql] | %w[jh/app/graphql/type.rb]
+ end
+
+ with_them do
+ it 'adds the warning markdown section' do
+ expect(multiversion).to receive(:markdown)
+
+ multiversion.check!
+ end
+ end
+ end
+ end
+end
diff --git a/spec/tooling/danger/product_intelligence_spec.rb b/spec/tooling/danger/product_intelligence_spec.rb
deleted file mode 100644
index c4cd0e5bfb6..00000000000
--- a/spec/tooling/danger/product_intelligence_spec.rb
+++ /dev/null
@@ -1,223 +0,0 @@
-# frozen_string_literal: true
-
-require 'gitlab-dangerfiles'
-require 'gitlab/dangerfiles/spec_helper'
-
-require_relative '../../../tooling/danger/product_intelligence'
-require_relative '../../../tooling/danger/project_helper'
-
-RSpec.describe Tooling::Danger::ProductIntelligence do
- include_context "with dangerfile"
-
- subject(:product_intelligence) { fake_danger.new(helper: fake_helper) }
-
- let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
- let(:previous_label_to_add) { 'label_to_add' }
- let(:labels_to_add) { [previous_label_to_add] }
- let(:ci_env) { true }
- let(:has_product_intelligence_label) { true }
-
- before do
- allow(fake_helper).to receive(:changed_lines).and_return(changed_lines) if defined?(changed_lines)
- allow(fake_helper).to receive(:labels_to_add).and_return(labels_to_add)
- allow(fake_helper).to receive(:ci?).and_return(ci_env)
- allow(fake_helper).to receive(:mr_has_labels?).with('product intelligence').and_return(has_product_intelligence_label)
- end
-
- describe '#check!' do
- subject { product_intelligence.check! }
-
- let(:markdown_formatted_list) { 'markdown formatted list' }
- let(:review_pending_label) { 'product intelligence::review pending' }
- let(:approved_label) { 'product intelligence::approved' }
- let(:changed_files) { ['metrics/counts_7d/test_metric.yml'] }
- let(:changed_lines) { ['+tier: ee'] }
-
- before do
- allow(fake_helper).to receive(:all_changed_files).and_return(changed_files)
- allow(fake_helper).to receive(:changes_by_category).and_return(product_intelligence: changed_files, database: ['other_files.yml'])
- allow(fake_helper).to receive(:markdown_list).with(changed_files).and_return(markdown_formatted_list)
- end
-
- shared_examples "doesn't add new labels" do
- it "doesn't add new labels" do
- subject
-
- expect(labels_to_add).to match_array [previous_label_to_add]
- end
- end
-
- shared_examples "doesn't add new warnings" do
- it "doesn't add new warnings" do
- expect(product_intelligence).not_to receive(:warn)
-
- subject
- end
- end
-
- shared_examples 'adds new labels' do
- it 'adds new labels' do
- subject
-
- expect(labels_to_add).to match_array [previous_label_to_add, review_pending_label]
- end
- end
-
- context 'with growth experiment label' do
- before do
- allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(true)
- end
-
- include_examples "doesn't add new labels"
- include_examples "doesn't add new warnings"
- end
-
- context 'without growth experiment label' do
- before do
- allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(false)
- end
-
- context 'with approved label' do
- let(:mr_labels) { [approved_label] }
-
- include_examples "doesn't add new labels"
- include_examples "doesn't add new warnings"
- end
-
- context 'without approved label' do
- include_examples 'adds new labels'
-
- it 'warns with proper message' do
- expect(product_intelligence).to receive(:warn).with(%r{#{markdown_formatted_list}})
-
- subject
- end
- end
-
- context 'with product intelligence::review pending label' do
- let(:mr_labels) { ['product intelligence::review pending'] }
-
- include_examples "doesn't add new labels"
- end
-
- context 'with product intelligence::approved label' do
- let(:mr_labels) { ['product intelligence::approved'] }
-
- include_examples "doesn't add new labels"
- end
-
- context 'with the product intelligence label' do
- let(:has_product_intelligence_label) { true }
-
- context 'with ci? false' do
- let(:ci_env) { false }
-
- include_examples "doesn't add new labels"
- end
-
- context 'with ci? true' do
- include_examples 'adds new labels'
- end
- end
- end
- end
-
- describe '#check_affected_scopes!' do
- let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'tooling', 'fixtures', 'metrics', '*.rb')) }
- let(:changed_lines) { ['+ scope :active, -> { iwhere(email: Array(emails)) }'] }
-
- before do
- allow(Dir).to receive(:glob).and_return(fixture_dir_glob)
- allow(fake_helper).to receive(:markdown_list).with({ 'active' => fixture_dir_glob }).and_return('a')
- end
-
- context 'when a model was modified' do
- let(:modified_files) { ['app/models/super_user.rb'] }
-
- context 'when a scope is changed' do
- context 'and a metrics uses the affected scope' do
- it 'producing warning' do
- expect(product_intelligence).to receive(:warn).with(%r{#{modified_files}})
-
- product_intelligence.check_affected_scopes!
- end
- end
-
- context 'when no metrics using the affected scope' do
- let(:changed_lines) { ['+scope :foo, -> { iwhere(email: Array(emails)) }'] }
-
- it 'doesnt do anything' do
- expect(product_intelligence).not_to receive(:warn)
-
- product_intelligence.check_affected_scopes!
- end
- end
- end
- end
-
- context 'when an unrelated model with matching scope was modified' do
- let(:modified_files) { ['app/models/post_box.rb'] }
-
- it 'doesnt do anything' do
- expect(product_intelligence).not_to receive(:warn)
-
- product_intelligence.check_affected_scopes!
- end
- end
-
- context 'when models arent modified' do
- let(:modified_files) { ['spec/app/models/user_spec.rb'] }
-
- it 'doesnt do anything' do
- expect(product_intelligence).not_to receive(:warn)
-
- product_intelligence.check_affected_scopes!
- end
- end
- end
-
- describe '#check_usage_data_insertions!' do
- context 'when usage_data.rb is modified' do
- let(:modified_files) { ['lib/gitlab/usage_data.rb'] }
-
- before do
- allow(fake_helper).to receive(:changed_lines).with("lib/gitlab/usage_data.rb").and_return(changed_lines)
- end
-
- context 'and has insertions' do
- let(:changed_lines) { ['+ ci_runners: count(::Ci::CiRunner),'] }
-
- it 'produces warning' do
- expect(product_intelligence).to receive(:warn).with(/usage_data\.rb has been deprecated/)
-
- product_intelligence.check_usage_data_insertions!
- end
- end
-
- context 'and changes are not insertions' do
- let(:changed_lines) { ['- ci_runners: count(::Ci::CiRunner),'] }
-
- it 'doesnt do anything' do
- expect(product_intelligence).not_to receive(:warn)
-
- product_intelligence.check_usage_data_insertions!
- end
- end
- end
-
- context 'when usage_data.rb is not modified' do
- context 'and another file has insertions' do
- let(:modified_files) { ['tooling/danger/product_intelligence.rb'] }
-
- it 'doesnt do anything' do
- expect(fake_helper).to receive(:changed_lines).with("lib/gitlab/usage_data.rb").and_return([])
- allow(fake_helper).to receive(:changed_lines).with("tooling/danger/product_intelligence.rb").and_return(["+ Inserting"])
-
- expect(product_intelligence).not_to receive(:warn)
-
- product_intelligence.check_usage_data_insertions!
- end
- end
- end
- end
-end
diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb
index 48050649f54..898c0ffa10c 100644
--- a/spec/tooling/danger/project_helper_spec.rb
+++ b/spec/tooling/danger/project_helper_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe Tooling::Danger::ProjectHelper do
'doc/api/openapi/openapi.yaml' | [:docs, :backend]
'doc/api/openapi/any_other_file.yaml' | [:docs, :backend]
- 'usage_data.rb' | [:database, :backend, :product_intelligence]
+ 'usage_data.rb' | [:database, :backend, :analytics_instrumentation]
'doc/foo.md' | [:docs]
'CONTRIBUTING.md' | [:docs]
'LICENSE' | [:docs]
@@ -178,75 +178,75 @@ RSpec.describe Tooling::Danger::ProjectHelper do
'foo/bar.txt' | [:none]
'foo/bar.md' | [:none]
- 'ee/config/metrics/counts_7d/20210216174919_g_analytics_issues_weekly.yml' | [:product_intelligence]
- 'lib/gitlab/usage_data_counters/aggregated_metrics/common.yml' | [:product_intelligence]
- 'lib/gitlab/usage_data_counters/hll_redis_counter.rb' | [:backend, :product_intelligence]
- 'lib/gitlab/tracking.rb' | [:backend, :product_intelligence]
- 'lib/gitlab/usage/service_ping_report.rb' | [:backend, :product_intelligence]
- 'lib/gitlab/usage/metrics/key_path_processor.rb' | [:backend, :product_intelligence]
- 'spec/lib/gitlab/tracking_spec.rb' | [:backend, :product_intelligence]
- 'app/helpers/tracking_helper.rb' | [:backend, :product_intelligence]
- 'spec/helpers/tracking_helper_spec.rb' | [:backend, :product_intelligence]
- 'lib/generators/rails/usage_metric_definition_generator.rb' | [:backend, :product_intelligence]
- 'spec/lib/generators/usage_metric_definition_generator_spec.rb' | [:backend, :product_intelligence]
- 'config/metrics/schema.json' | [:product_intelligence]
- 'app/assets/javascripts/tracking/foo.js' | [:frontend, :product_intelligence]
- 'spec/frontend/tracking/foo.js' | [:frontend, :product_intelligence]
- 'spec/frontend/tracking_spec.js' | [:frontend, :product_intelligence]
+ 'ee/config/metrics/counts_7d/20210216174919_g_analytics_issues_weekly.yml' | [:analytics_instrumentation]
+ 'lib/gitlab/usage_data_counters/aggregated_metrics/common.yml' | [:analytics_instrumentation]
+ 'lib/gitlab/usage_data_counters/hll_redis_counter.rb' | [:backend, :analytics_instrumentation]
+ 'lib/gitlab/tracking.rb' | [:backend, :analytics_instrumentation]
+ 'lib/gitlab/usage/service_ping_report.rb' | [:backend, :analytics_instrumentation]
+ 'lib/gitlab/usage/metrics/key_path_processor.rb' | [:backend, :analytics_instrumentation]
+ 'spec/lib/gitlab/tracking_spec.rb' | [:backend, :analytics_instrumentation]
+ 'app/helpers/tracking_helper.rb' | [:backend, :analytics_instrumentation]
+ 'spec/helpers/tracking_helper_spec.rb' | [:backend, :analytics_instrumentation]
+ 'lib/generators/rails/usage_metric_definition_generator.rb' | [:backend, :analytics_instrumentation]
+ 'spec/lib/generators/usage_metric_definition_generator_spec.rb' | [:backend, :analytics_instrumentation]
+ 'config/metrics/schema.json' | [:analytics_instrumentation]
+ 'app/assets/javascripts/tracking/foo.js' | [:frontend, :analytics_instrumentation]
+ 'spec/frontend/tracking/foo.js' | [:frontend, :analytics_instrumentation]
+ 'spec/frontend/tracking_spec.js' | [:frontend, :analytics_instrumentation]
'lib/gitlab/usage_database/foo.rb' | [:backend]
- 'config/metrics/counts_7d/test_metric.yml' | [:product_intelligence]
- 'config/events/snowplow_event.yml' | [:product_intelligence]
- 'config/metrics/schema.json' | [:product_intelligence]
- 'doc/api/usage_data.md' | [:product_intelligence]
- 'spec/lib/gitlab/usage_data_spec.rb' | [:product_intelligence]
- 'spec/lib/gitlab/usage/service_ping_report.rb' | [:backend, :product_intelligence]
- 'spec/lib/gitlab/usage/metrics/key_path_processor.rb' | [:backend, :product_intelligence]
-
- 'app/models/integration.rb' | [:integrations_be, :backend]
- 'ee/app/models/integrations/github.rb' | [:integrations_be, :backend]
- 'ee/app/models/ee/integrations/jira.rb' | [:integrations_be, :backend]
- 'app/models/integrations/chat_message/pipeline_message.rb' | [:integrations_be, :backend]
- 'app/models/jira_connect_subscription.rb' | [:integrations_be, :backend]
- 'app/models/hooks/service_hook.rb' | [:integrations_be, :backend]
- 'ee/app/models/ee/hooks/system_hook.rb' | [:integrations_be, :backend]
- 'app/services/concerns/integrations/project_test_data.rb' | [:integrations_be, :backend]
- 'ee/app/services/ee/integrations/test/project_service.rb' | [:integrations_be, :backend]
- 'app/controllers/concerns/integrations/actions.rb' | [:integrations_be, :backend]
- 'ee/app/controllers/concerns/ee/integrations/params.rb' | [:integrations_be, :backend]
- 'ee/app/controllers/projects/integrations/jira/issues_controller.rb' | [:integrations_be, :backend]
- 'app/controllers/projects/hooks_controller.rb' | [:integrations_be, :backend]
- 'app/controllers/admin/hook_logs_controller.rb' | [:integrations_be, :backend]
- 'app/controllers/groups/settings/integrations_controller.rb' | [:integrations_be, :backend]
- 'app/controllers/jira_connect/branches_controller.rb' | [:integrations_be, :backend]
- 'app/controllers/oauth/jira/authorizations_controller.rb' | [:integrations_be, :backend]
- 'ee/app/finders/projects/integrations/jira/by_ids_finder.rb' | [:integrations_be, :database, :backend]
- 'app/workers/jira_connect/sync_merge_request_worker.rb' | [:integrations_be, :backend]
- 'app/workers/propagate_integration_inherit_worker.rb' | [:integrations_be, :backend]
- 'app/workers/web_hooks/log_execution_worker.rb' | [:integrations_be, :backend]
- 'app/workers/web_hook_worker.rb' | [:integrations_be, :backend]
- 'app/workers/project_service_worker.rb' | [:integrations_be, :backend]
- 'lib/atlassian/jira_connect/serializers/commit_entity.rb' | [:integrations_be, :backend]
- 'lib/api/entities/project_integration.rb' | [:integrations_be, :backend]
- 'lib/gitlab/hook_data/note_builder.rb' | [:integrations_be, :backend]
- 'lib/gitlab/data_builder/note.rb' | [:integrations_be, :backend]
- 'lib/gitlab/web_hooks/recursion_detection.rb' | [:integrations_be, :backend]
- 'ee/lib/ee/gitlab/integrations/sti_type.rb' | [:integrations_be, :backend]
- 'ee/lib/ee/api/helpers/integrations_helpers.rb' | [:integrations_be, :backend]
- 'ee/app/serializers/integrations/jira_serializers/issue_entity.rb' | [:integrations_be, :backend]
- 'app/serializers/jira_connect/app_data_serializer.rb' | [:integrations_be, :backend]
- 'lib/api/github/entities.rb' | [:integrations_be, :backend]
- 'lib/api/v3/github.rb' | [:integrations_be, :backend]
+ 'config/metrics/counts_7d/test_metric.yml' | [:analytics_instrumentation]
+ 'config/events/snowplow_event.yml' | [:analytics_instrumentation]
+ 'config/metrics/schema.json' | [:analytics_instrumentation]
+ 'doc/api/usage_data.md' | [:analytics_instrumentation]
+ 'spec/lib/gitlab/usage_data_spec.rb' | [:analytics_instrumentation]
+ 'spec/lib/gitlab/usage/service_ping_report.rb' | [:backend, :analytics_instrumentation]
+ 'spec/lib/gitlab/usage/metrics/key_path_processor.rb' | [:backend, :analytics_instrumentation]
+
+ 'app/models/integration.rb' | [:import_integrate_be, :backend]
+ 'ee/app/models/integrations/github.rb' | [:import_integrate_be, :backend]
+ 'ee/app/models/ee/integrations/jira.rb' | [:import_integrate_be, :backend]
+ 'app/models/integrations/chat_message/pipeline_message.rb' | [:import_integrate_be, :backend]
+ 'app/models/jira_connect_subscription.rb' | [:import_integrate_be, :backend]
+ 'app/models/hooks/service_hook.rb' | [:import_integrate_be, :backend]
+ 'ee/app/models/ee/hooks/system_hook.rb' | [:import_integrate_be, :backend]
+ 'app/services/concerns/integrations/project_test_data.rb' | [:import_integrate_be, :backend]
+ 'ee/app/services/ee/integrations/test/project_service.rb' | [:import_integrate_be, :backend]
+ 'app/controllers/concerns/integrations/actions.rb' | [:import_integrate_be, :backend]
+ 'ee/app/controllers/concerns/ee/integrations/params.rb' | [:import_integrate_be, :backend]
+ 'ee/app/controllers/projects/integrations/jira/issues_controller.rb' | [:import_integrate_be, :backend]
+ 'app/controllers/projects/hooks_controller.rb' | [:import_integrate_be, :backend]
+ 'app/controllers/admin/hook_logs_controller.rb' | [:import_integrate_be, :backend]
+ 'app/controllers/groups/settings/integrations_controller.rb' | [:import_integrate_be, :backend]
+ 'app/controllers/jira_connect/branches_controller.rb' | [:import_integrate_be, :backend]
+ 'app/controllers/oauth/jira/authorizations_controller.rb' | [:import_integrate_be, :backend]
+ 'ee/app/finders/projects/integrations/jira/by_ids_finder.rb' | [:import_integrate_be, :database, :backend]
+ 'app/workers/jira_connect/sync_merge_request_worker.rb' | [:import_integrate_be, :backend]
+ 'app/workers/propagate_integration_inherit_worker.rb' | [:import_integrate_be, :backend]
+ 'app/workers/web_hooks/log_execution_worker.rb' | [:import_integrate_be, :backend]
+ 'app/workers/web_hook_worker.rb' | [:import_integrate_be, :backend]
+ 'app/workers/project_service_worker.rb' | [:import_integrate_be, :backend]
+ 'lib/atlassian/jira_connect/serializers/commit_entity.rb' | [:import_integrate_be, :backend]
+ 'lib/api/entities/project_integration.rb' | [:import_integrate_be, :backend]
+ 'lib/gitlab/hook_data/note_builder.rb' | [:import_integrate_be, :backend]
+ 'lib/gitlab/data_builder/note.rb' | [:import_integrate_be, :backend]
+ 'lib/gitlab/web_hooks/recursion_detection.rb' | [:import_integrate_be, :backend]
+ 'ee/lib/ee/gitlab/integrations/sti_type.rb' | [:import_integrate_be, :backend]
+ 'ee/lib/ee/api/helpers/integrations_helpers.rb' | [:import_integrate_be, :backend]
+ 'ee/app/serializers/integrations/jira_serializers/issue_entity.rb' | [:import_integrate_be, :backend]
+ 'app/serializers/jira_connect/app_data_serializer.rb' | [:import_integrate_be, :backend]
+ 'lib/api/github/entities.rb' | [:import_integrate_be, :backend]
+ 'lib/api/v3/github.rb' | [:import_integrate_be, :backend]
'app/controllers/clusters/integrations_controller.rb' | [:backend]
'app/services/clusters/integrations/prometheus_health_check_service.rb' | [:backend]
'app/graphql/types/alert_management/integration_type.rb' | [:backend]
- 'app/views/jira_connect/branches/new.html.haml' | [:integrations_fe, :frontend]
- 'app/views/layouts/jira_connect.html.haml' | [:integrations_fe, :frontend]
- 'app/assets/javascripts/jira_connect/branches/pages/index.vue' | [:integrations_fe, :frontend]
- 'ee/app/views/projects/integrations/jira/issues/show.html.haml' | [:integrations_fe, :frontend]
- 'ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql' | [:integrations_fe, :frontend]
- 'app/assets/javascripts/pages/projects/settings/integrations/show/index.js' | [:integrations_fe, :frontend]
- 'ee/app/assets/javascripts/pages/groups/hooks/index.js' | [:integrations_fe, :frontend]
+ 'app/views/jira_connect/branches/new.html.haml' | [:import_integrate_fe, :frontend]
+ 'app/views/layouts/jira_connect.html.haml' | [:import_integrate_fe, :frontend]
+ 'app/assets/javascripts/jira_connect/branches/pages/index.vue' | [:import_integrate_fe, :frontend]
+ 'ee/app/views/projects/integrations/jira/issues/show.html.haml' | [:import_integrate_fe, :frontend]
+ 'ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql' | [:import_integrate_fe, :frontend]
+ 'app/assets/javascripts/pages/projects/settings/integrations/show/index.js' | [:import_integrate_fe, :frontend]
+ 'ee/app/assets/javascripts/pages/groups/hooks/index.js' | [:import_integrate_fe, :frontend]
'app/views/clusters/clusters/_integrations_tab.html.haml' | [:frontend, :backend]
'app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql' | [:frontend]
'app/assets/javascripts/filtered_search/droplab/hook_input.js' | [:frontend]
@@ -263,22 +263,22 @@ RSpec.describe Tooling::Danger::ProjectHelper do
context 'having specific changes' do
where(:expected_categories, :patch, :changed_files) do
- [:product_intelligence] | '+data-track-action' | ['components/welcome.vue']
- [:product_intelligence] | '+ data: { track_label:' | ['admin/groups/_form.html.haml']
- [:product_intelligence] | '+ Gitlab::Tracking.event' | ['dashboard/todos_controller.rb', 'admin/groups/_form.html.haml']
- [:database, :backend, :product_intelligence] | '+ count(User.active)' | ['usage_data.rb', 'lib/gitlab/usage_data.rb', 'ee/lib/ee/gitlab/usage_data.rb']
- [:database, :backend, :product_intelligence] | '+ estimate_batch_distinct_count(User.active)' | ['usage_data.rb']
- [:backend, :product_intelligence] | '+ alt_usage_data(User.active)' | ['lib/gitlab/usage_data.rb']
- [:backend, :product_intelligence] | '+ count(User.active)' | ['lib/gitlab/usage_data/topology.rb']
- [:backend, :product_intelligence] | '+ foo_count(User.active)' | ['lib/gitlab/usage_data.rb']
- [:backend] | '+ count(User.active)' | ['user.rb']
- [:integrations_be, :database, :migration] | '+ add_column :integrations, :foo, :text' | ['db/migrate/foo.rb']
- [:integrations_be, :database, :migration] | '+ create_table :zentao_tracker_data do |t|' | ['ee/db/post_migrate/foo.rb']
- [:integrations_be, :backend] | '+ Integrations::Foo' | ['app/foo/bar.rb']
- [:integrations_be, :backend] | '+ project.execute_hooks(foo, :bar)' | ['ee/lib/ee/foo.rb']
- [:integrations_be, :backend] | '+ project.execute_integrations(foo, :bar)' | ['app/foo.rb']
- [:frontend, :product_intelligence] | '+ api.trackRedisCounterEvent("foo")' | ['app/assets/javascripts/telemetry.js', 'ee/app/assets/javascripts/mr_widget.vue']
- [:frontend, :product_intelligence] | '+ api.trackRedisHllUserEvent("bar")' | ['app/assets/javascripts/telemetry.js', 'ee/app/assets/javascripts/mr_widget.vue']
+ [:analytics_instrumentation] | '+data-track-action' | ['components/welcome.vue']
+ [:analytics_instrumentation] | '+ data: { track_label:' | ['admin/groups/_form.html.haml']
+ [:analytics_instrumentation] | '+ Gitlab::Tracking.event' | ['dashboard/todos_controller.rb', 'admin/groups/_form.html.haml']
+ [:database, :backend, :analytics_instrumentation] | '+ count(User.active)' | ['usage_data.rb', 'lib/gitlab/usage_data.rb', 'ee/lib/ee/gitlab/usage_data.rb']
+ [:database, :backend, :analytics_instrumentation] | '+ estimate_batch_distinct_count(User.active)' | ['usage_data.rb']
+ [:backend, :analytics_instrumentation] | '+ alt_usage_data(User.active)' | ['lib/gitlab/usage_data.rb']
+ [:backend, :analytics_instrumentation] | '+ count(User.active)' | ['lib/gitlab/usage_data/topology.rb']
+ [:backend, :analytics_instrumentation] | '+ foo_count(User.active)' | ['lib/gitlab/usage_data.rb']
+ [:backend] | '+ count(User.active)' | ['user.rb']
+ [:import_integrate_be, :database, :migration] | '+ add_column :integrations, :foo, :text' | ['db/migrate/foo.rb']
+ [:import_integrate_be, :database, :migration] | '+ create_table :zentao_tracker_data do |t|' | ['ee/db/post_migrate/foo.rb']
+ [:import_integrate_be, :backend] | '+ Integrations::Foo' | ['app/foo/bar.rb']
+ [:import_integrate_be, :backend] | '+ project.execute_hooks(foo, :bar)' | ['ee/lib/ee/foo.rb']
+ [:import_integrate_be, :backend] | '+ project.execute_integrations(foo, :bar)' | ['app/foo.rb']
+ [:frontend, :analytics_instrumentation] | '+ api.trackRedisCounterEvent("foo")' | ['app/assets/javascripts/telemetry.js', 'ee/app/assets/javascripts/mr_widget.vue']
+ [:frontend, :analytics_instrumentation] | '+ api.trackRedisHllUserEvent("bar")' | ['app/assets/javascripts/telemetry.js', 'ee/app/assets/javascripts/mr_widget.vue']
end
with_them do
diff --git a/spec/tooling/danger/sidekiq_args_spec.rb b/spec/tooling/danger/sidekiq_args_spec.rb
new file mode 100644
index 00000000000..bfa9ef169de
--- /dev/null
+++ b/spec/tooling/danger/sidekiq_args_spec.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+require 'rspec-parameterized'
+require 'gitlab-dangerfiles'
+require 'danger'
+require 'danger/plugins/internal/helper'
+require 'gitlab/dangerfiles/spec_helper'
+
+require_relative '../../../tooling/danger/sidekiq_args'
+require_relative '../../../tooling/danger/project_helper'
+
+RSpec.describe Tooling::Danger::SidekiqArgs, feature_category: :tooling do
+ include_context "with dangerfile"
+
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
+ let(:fake_project_helper) { Tooling::Danger::ProjectHelper }
+
+ subject(:specs) { fake_danger.new(helper: fake_helper) }
+
+ before do
+ allow(specs).to receive(:project_helper).and_return(fake_project_helper)
+ end
+
+ describe '#args_changed?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:before, :after, :result) do
+ " - def perform" | " + def perform(abc)" | true
+ " - def perform" | " + def perform(abc)" | true
+ " - def perform(abc)" | " + def perform(def)" | true
+ " - def perform(abc, def)" | " + def perform(abc)" | true
+ " - def perform(abc, def)" | " + def perform(def, abc)" | true
+ " - def perform" | " - def perform" | false
+ " + def perform" | " + def perform" | false
+ " - def perform(abc)" | " - def perform(abc)" | false
+ " + def perform(abc)" | " + def perform(abc)" | false
+ " - def perform(abc)" | " + def perform_foo(abc)" | false
+ end
+
+ with_them do
+ it 'returns correct result' do
+ expect(specs.args_changed?([before, after])).to eq(result)
+ end
+ end
+ end
+
+ describe '#add_comment_for_matched_line' do
+ let(:filename) { 'app/workers/hello_worker.rb' }
+ let(:file_lines) do
+ [
+ "Module Worker",
+ " def perform",
+ " puts hello world",
+ " end",
+ "end"
+ ]
+ end
+
+ before do
+ allow(specs.project_helper).to receive(:file_lines).and_return(file_lines)
+ end
+
+ context 'when args are changed' do
+ before do
+ allow(specs.helper).to receive(:changed_lines).and_return([" - def perform", " + def perform(abc)"])
+ allow(specs).to receive(:args_changed?).and_return(true)
+ end
+
+ it 'adds suggestion at the correct lines' do
+ expect(specs).to receive(:markdown).with(format(described_class::SUGGEST_MR_COMMENT), file: filename, line: 2)
+
+ specs.add_comment_for_matched_line(filename)
+ end
+ end
+
+ context 'when args are not changed' do
+ before do
+ allow(specs.helper).to receive(:changed_lines).and_return([" - def perform", " - def perform"])
+ allow(specs).to receive(:args_changed?).and_return(false)
+ end
+
+ it 'does not add suggestion' do
+ expect(specs).not_to receive(:markdown)
+
+ specs.add_comment_for_matched_line(filename)
+ end
+ end
+ end
+
+ describe '#changed_worker_files' do
+ let(:base_expected_files) { %w[app/workers/a.rb app/workers/b.rb ee/app/workers/e.rb] }
+
+ before do
+ all_changed_files = %w[
+ app/workers/a.rb
+ app/workers/b.rb
+ ee/app/workers/e.rb
+ spec/foo_spec.rb
+ ee/spec/foo_spec.rb
+ spec/bar_spec.rb
+ ee/spec/bar_spec.rb
+ spec/zab_spec.rb
+ ee/spec/zab_spec.rb
+ ]
+
+ allow(specs.helper).to receive(:all_changed_files).and_return(all_changed_files)
+ end
+
+ it 'returns added, modified, and renamed_after files by default' do
+ expect(specs.changed_worker_files).to match_array(base_expected_files)
+ end
+
+ context 'with include_ee: :exclude' do
+ it 'returns spec files without EE-specific files' do
+ expect(specs.changed_worker_files(ee: :exclude)).not_to include(%w[ee/app/workers/e.rb])
+ end
+ end
+
+ context 'with include_ee: :only' do
+ it 'returns EE-specific spec files only' do
+ expect(specs.changed_worker_files(ee: :only)).to match_array(%w[ee/app/workers/e.rb])
+ end
+ end
+ end
+end
diff --git a/spec/tooling/danger/specs/feature_category_suggestion_spec.rb b/spec/tooling/danger/specs/feature_category_suggestion_spec.rb
new file mode 100644
index 00000000000..87eb20e5e50
--- /dev/null
+++ b/spec/tooling/danger/specs/feature_category_suggestion_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require 'gitlab/dangerfiles/spec_helper'
+
+require_relative '../../../../tooling/danger/specs'
+require_relative '../../../../tooling/danger/project_helper'
+
+RSpec.describe Tooling::Danger::Specs::FeatureCategorySuggestion, feature_category: :tooling do
+ include_context "with dangerfile"
+
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(Tooling::Danger::Specs) }
+ let(:fake_project_helper) { instance_double('Tooling::Danger::ProjectHelper') }
+ let(:filename) { 'spec/foo_spec.rb' }
+
+ let(:template) do
+ <<~SUGGESTION_MARKDOWN.chomp
+ ```suggestion
+ %<suggested_line>s
+ ```
+
+ Consider adding `feature_category: <feature_category_name>` for this example if it is not set already.
+ See [testing best practices](https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#feature-category-metadata).
+ SUGGESTION_MARKDOWN
+ end
+
+ let(:file_lines) do
+ [
+ " require 'spec_helper'",
+ " \n",
+ " RSpec.describe Projects::SummaryController, feature_category: :team_planning do",
+ " end",
+ "RSpec.describe Projects::SummaryController do",
+ " let_it_be(:user) { create(:user) }",
+ " end",
+ " describe 'GET \"time_summary\"' do",
+ " end",
+ " RSpec.describe Projects::SummaryController do",
+ " let_it_be(:user) { create(:user) }",
+ " end",
+ " describe 'GET \"time_summary\"' do",
+ " end",
+ " \n",
+ "RSpec.describe Projects :aggregate_failures,",
+ " feature_category :team_planning do",
+ " \n",
+ "RSpec.describe Epics :aggregate_failures,",
+ " ee: true do",
+ "\n",
+ "RSpec.describe Issues :aggregate_failures,",
+ " feature_category: :team_planning do",
+ "\n",
+ "RSpec.describe MergeRequest :aggregate_failures,",
+ " :js,",
+ " feature_category: :team_planning do"
+ ]
+ end
+
+ let(:changed_lines) do
+ [
+ "+ RSpec.describe Projects::SummaryController, feature_category: :team_planning do",
+ "+RSpec.describe Projects::SummaryController do",
+ "+ let_it_be(:user) { create(:user) }",
+ "- end",
+ "+ describe 'GET \"time_summary\"' do",
+ "+ RSpec.describe Projects::SummaryController do",
+ "+RSpec.describe Projects :aggregate_failures,",
+ "+ feature_category: :team_planning do",
+ "+RSpec.describe Epics :aggregate_failures,",
+ "+ ee: true do",
+ "+RSpec.describe Issues :aggregate_failures,",
+ "+RSpec.describe MergeRequest :aggregate_failures,",
+ "+ :js,",
+ "+ feature_category: :team_planning do",
+ "+RSpec.describe 'line in commit diff but no longer in working copy' do"
+ ]
+ end
+
+ subject(:specs) { fake_danger.new(helper: fake_helper) }
+
+ before do
+ allow(specs).to receive(:project_helper).and_return(fake_project_helper)
+ allow(specs.helper).to receive(:changed_lines).with(filename).and_return(changed_lines)
+ allow(specs.project_helper).to receive(:file_lines).and_return(file_lines)
+ end
+
+ it 'adds suggestions at the correct lines', :aggregate_failures do
+ [
+ { suggested_line: "RSpec.describe Projects::SummaryController do", number: 5 },
+ { suggested_line: " RSpec.describe Projects::SummaryController do", number: 10 },
+ { suggested_line: "RSpec.describe Epics :aggregate_failures,", number: 19 }
+
+ ].each do |test_case|
+ comment = format(template, suggested_line: test_case[:suggested_line])
+ expect(specs).to receive(:markdown).with(comment, file: filename, line: test_case[:number])
+ end
+
+ specs.add_suggestions_for(filename)
+ end
+end
diff --git a/spec/tooling/danger/specs/match_with_array_suggestion_spec.rb b/spec/tooling/danger/specs/match_with_array_suggestion_spec.rb
new file mode 100644
index 00000000000..b065772a09b
--- /dev/null
+++ b/spec/tooling/danger/specs/match_with_array_suggestion_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require 'gitlab/dangerfiles/spec_helper'
+
+require_relative '../../../../tooling/danger/specs'
+require_relative '../../../../tooling/danger/project_helper'
+
+RSpec.describe Tooling::Danger::Specs::MatchWithArraySuggestion, feature_category: :tooling do
+ include_context "with dangerfile"
+
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(Tooling::Danger::Specs) }
+ let(:fake_project_helper) { instance_double('Tooling::Danger::ProjectHelper') }
+ let(:filename) { 'spec/foo_spec.rb' }
+
+ let(:file_lines) do
+ [
+ " describe 'foo' do",
+ " expect(foo).to match(['bar', 'baz'])",
+ " end",
+ " expect(foo).to match(['bar', 'baz'])", # same line as line 1 above, we expect two different suggestions
+ " ",
+ " expect(foo).to match ['bar', 'baz']",
+ " expect(foo).to eq(['bar', 'baz'])",
+ " expect(foo).to eq ['bar', 'baz']",
+ " expect(foo).to(match(['bar', 'baz']))",
+ " expect(foo).to(eq(['bar', 'baz']))",
+ " expect(foo).to(eq([bar, baz]))",
+ " expect(foo).to(eq(['bar']))",
+ " foo.eq(['bar'])"
+ ]
+ end
+
+ let(:matching_lines) do
+ [
+ "+ expect(foo).to match(['should not error'])",
+ "+ expect(foo).to match(['bar', 'baz'])",
+ "+ expect(foo).to match(['bar', 'baz'])",
+ "+ expect(foo).to match ['bar', 'baz']",
+ "+ expect(foo).to eq(['bar', 'baz'])",
+ "+ expect(foo).to eq ['bar', 'baz']",
+ "+ expect(foo).to(match(['bar', 'baz']))",
+ "+ expect(foo).to(eq(['bar', 'baz']))",
+ "+ expect(foo).to(eq([bar, baz]))"
+ ]
+ end
+
+ let(:changed_lines) do
+ [
+ " expect(foo).to match(['bar', 'baz'])",
+ " expect(foo).to match(['bar', 'baz'])",
+ " expect(foo).to match ['bar', 'baz']",
+ " expect(foo).to eq(['bar', 'baz'])",
+ " expect(foo).to eq ['bar', 'baz']",
+ "- expect(foo).to match(['bar', 'baz'])",
+ "- expect(foo).to match(['bar', 'baz'])",
+ "- expect(foo).to match ['bar', 'baz']",
+ "- expect(foo).to eq(['bar', 'baz'])",
+ "- expect(foo).to eq ['bar', 'baz']",
+ "- expect(foo).to eq [bar, foo]",
+ "+ expect(foo).to eq([])"
+ ] + matching_lines
+ end
+
+ let(:template) do
+ <<~MARKDOWN.chomp
+ ```suggestion
+ %<suggested_line>s
+ ```
+
+ If order of the result is not important, please consider using `match_array` to avoid flakiness.
+ MARKDOWN
+ end
+
+ subject(:specs) { fake_danger.new(helper: fake_helper) }
+
+ before do
+ allow(specs).to receive(:project_helper).and_return(fake_project_helper)
+ allow(specs.helper).to receive(:changed_lines).with(filename).and_return(changed_lines)
+ allow(specs.project_helper).to receive(:file_lines).and_return(file_lines)
+ end
+
+ it 'adds suggestions at the correct lines' do
+ [
+ { suggested_line: " expect(foo).to match_array(['bar', 'baz'])", number: 2 },
+ { suggested_line: " expect(foo).to match_array(['bar', 'baz'])", number: 4 },
+ { suggested_line: " expect(foo).to match_array ['bar', 'baz']", number: 6 },
+ { suggested_line: " expect(foo).to match_array(['bar', 'baz'])", number: 7 },
+ { suggested_line: " expect(foo).to match_array ['bar', 'baz']", number: 8 },
+ { suggested_line: " expect(foo).to(match_array(['bar', 'baz']))", number: 9 },
+ { suggested_line: " expect(foo).to(match_array(['bar', 'baz']))", number: 10 },
+ { suggested_line: " expect(foo).to(match_array([bar, baz]))", number: 11 }
+ ].each do |test_case|
+ comment = format(template, suggested_line: test_case[:suggested_line])
+ expect(specs).to receive(:markdown).with(comment, file: filename, line: test_case[:number])
+ end
+
+ specs.add_suggestions_for(filename)
+ end
+end
diff --git a/spec/tooling/danger/specs/project_factory_suggestion_spec.rb b/spec/tooling/danger/specs/project_factory_suggestion_spec.rb
new file mode 100644
index 00000000000..9b10ab1a6f4
--- /dev/null
+++ b/spec/tooling/danger/specs/project_factory_suggestion_spec.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+require 'gitlab/dangerfiles/spec_helper'
+
+require_relative '../../../../tooling/danger/specs'
+require_relative '../../../../tooling/danger/project_helper'
+
+RSpec.describe Tooling::Danger::Specs::ProjectFactorySuggestion, feature_category: :tooling do
+ include_context "with dangerfile"
+
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(Tooling::Danger::Specs) }
+ let(:fake_project_helper) { instance_double('Tooling::Danger::ProjectHelper') }
+ let(:filename) { 'spec/foo_spec.rb' }
+
+ let(:template) do
+ <<~MARKDOWN.chomp
+ ```suggestion
+ %<suggested_line>s
+ ```
+
+ Project creations are very slow. Using `let_it_be`, `build` or `build_stubbed` can improve test performance.
+
+ Warning: `let_it_be` may not be suitable if your test modifies data as this could result in state leaks!
+
+ In those cases, please use `let_it_be_with_reload` or `let_it_be_with_refind` instead.
+
+ If your are unsure which is the right method to use,
+ please refer to [testing best practices](https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#optimize-factory-usage)
+ for background information and alternative options for optimizing factory usage.
+
+ Feel free to ignore this comment if you know `let` or `let!` are the better options and/or worry about causing state leaks.
+ MARKDOWN
+ end
+
+ let(:file_lines) do
+ [
+ " let(:project) { create(:project) }",
+ " let_it_be(:project) { create(:project, :repository)",
+ " let!(:project) { create(:project) }",
+ " let(:var) { create(:project) }",
+ " let(:merge_request) { create(:merge_request, project: project)",
+ " context 'when merge request exists' do",
+ " it { is_expected.to be_success }",
+ " end",
+ " let!(:var) { create(:project) }",
+ " let(:project) { create(:thing) }",
+ " let(:project) { build(:project) }",
+ " let(:project) do",
+ " create(:project)",
+ " end",
+ " let(:project) { create(:project, :repository) }",
+ " str = 'let(:project) { create(:project) }'",
+ " let(:project) { create(:project_empty_repo) }",
+ " let(:project) { create(:forked_project_with_submodules) }",
+ " let(:project) { create(:project_with_design) }",
+ " let(:authorization) { create(:project_authorization) }"
+ ]
+ end
+
+ let(:matching_lines) do
+ [
+ "+ let(:should_not_error) { create(:project) }",
+ "+ let(:project) { create(:project) }",
+ "+ let!(:project) { create(:project) }",
+ "+ let(:var) { create(:project) }",
+ "+ let!(:var) { create(:project) }",
+ "+ let(:project) { create(:project, :repository) }",
+ "+ let(:project) { create(:project_empty_repo) }",
+ "+ let(:project) { create(:forked_project_with_submodules) }",
+ "+ let(:project) { create(:project_with_design) }"
+ ]
+ end
+
+ let(:changed_lines) do
+ [
+ "+ line which doesn't exist in the file and should not cause an error",
+ "+ let_it_be(:project) { create(:project, :repository)",
+ "+ let(:project) { create(:thing) }",
+ "+ let(:project) do",
+ "+ create(:project)",
+ "+ end",
+ "+ str = 'let(:project) { create(:project) }'",
+ "+ let(:authorization) { create(:project_authorization) }"
+ ] + matching_lines
+ end
+
+ subject(:specs) { fake_danger.new(helper: fake_helper) }
+
+ before do
+ allow(specs).to receive(:project_helper).and_return(fake_project_helper)
+ allow(specs.helper).to receive(:changed_lines).with(filename).and_return(changed_lines)
+ allow(specs.project_helper).to receive(:file_lines).and_return(file_lines)
+ end
+
+ it 'adds suggestions at the correct lines', :aggregate_failures do
+ [
+ { suggested_line: " let_it_be(:project) { create(:project) }", number: 1 },
+ { suggested_line: " let_it_be(:project) { create(:project) }", number: 3 },
+ { suggested_line: " let_it_be(:var) { create(:project) }", number: 4 },
+ { suggested_line: " let_it_be(:var) { create(:project) }", number: 9 },
+ { suggested_line: " let_it_be(:project) { create(:project, :repository) }", number: 15 },
+ { suggested_line: " let_it_be(:project) { create(:project_empty_repo) }", number: 17 },
+ { suggested_line: " let_it_be(:project) { create(:forked_project_with_submodules) }", number: 18 },
+ { suggested_line: " let_it_be(:project) { create(:project_with_design) }", number: 19 }
+ ].each do |test_case|
+ comment = format(template, suggested_line: test_case[:suggested_line])
+ expect(specs).to receive(:markdown).with(comment, file: filename, line: test_case[:number])
+ end
+
+ specs.add_suggestions_for(filename)
+ end
+end
diff --git a/spec/tooling/danger/specs_spec.rb b/spec/tooling/danger/specs_spec.rb
index cdac5954f92..b4953858ef7 100644
--- a/spec/tooling/danger/specs_spec.rb
+++ b/spec/tooling/danger/specs_spec.rb
@@ -1,80 +1,24 @@
# frozen_string_literal: true
-require 'rspec-parameterized'
-require 'gitlab-dangerfiles'
-require 'danger'
-require 'danger/plugins/internal/helper'
require 'gitlab/dangerfiles/spec_helper'
require_relative '../../../tooling/danger/specs'
-require_relative '../../../tooling/danger/project_helper'
RSpec.describe Tooling::Danger::Specs, feature_category: :tooling do
include_context "with dangerfile"
let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
- let(:fake_project_helper) { instance_double('Tooling::Danger::ProjectHelper') }
let(:filename) { 'spec/foo_spec.rb' }
- let(:file_lines) do
- [
- " describe 'foo' do",
- " expect(foo).to match(['bar', 'baz'])",
- " end",
- " expect(foo).to match(['bar', 'baz'])", # same line as line 1 above, we expect two different suggestions
- " ",
- " expect(foo).to match ['bar', 'baz']",
- " expect(foo).to eq(['bar', 'baz'])",
- " expect(foo).to eq ['bar', 'baz']",
- " expect(foo).to(match(['bar', 'baz']))",
- " expect(foo).to(eq(['bar', 'baz']))",
- " expect(foo).to(eq([bar, baz]))",
- " expect(foo).to(eq(['bar']))",
- " foo.eq(['bar'])"
- ]
- end
-
- let(:matching_lines) do
- [
- "+ expect(foo).to match(['should not error'])",
- "+ expect(foo).to match(['bar', 'baz'])",
- "+ expect(foo).to match(['bar', 'baz'])",
- "+ expect(foo).to match ['bar', 'baz']",
- "+ expect(foo).to eq(['bar', 'baz'])",
- "+ expect(foo).to eq ['bar', 'baz']",
- "+ expect(foo).to(match(['bar', 'baz']))",
- "+ expect(foo).to(eq(['bar', 'baz']))",
- "+ expect(foo).to(eq([bar, baz]))"
- ]
- end
-
- let(:changed_lines) do
- [
- " expect(foo).to match(['bar', 'baz'])",
- " expect(foo).to match(['bar', 'baz'])",
- " expect(foo).to match ['bar', 'baz']",
- " expect(foo).to eq(['bar', 'baz'])",
- " expect(foo).to eq ['bar', 'baz']",
- "- expect(foo).to match(['bar', 'baz'])",
- "- expect(foo).to match(['bar', 'baz'])",
- "- expect(foo).to match ['bar', 'baz']",
- "- expect(foo).to eq(['bar', 'baz'])",
- "- expect(foo).to eq ['bar', 'baz']",
- "- expect(foo).to eq [bar, foo]",
- "+ expect(foo).to eq([])"
- ] + matching_lines
- end
-
subject(:specs) { fake_danger.new(helper: fake_helper) }
- before do
- allow(specs).to receive(:project_helper).and_return(fake_project_helper)
- allow(specs.helper).to receive(:changed_lines).with(filename).and_return(matching_lines)
- allow(specs.project_helper).to receive(:file_lines).and_return(file_lines)
- end
-
describe '#changed_specs_files' do
- let(:base_expected_files) { %w[spec/foo_spec.rb ee/spec/foo_spec.rb spec/bar_spec.rb ee/spec/bar_spec.rb spec/zab_spec.rb ee/spec/zab_spec.rb] }
+ let(:base_expected_files) do
+ %w[
+ spec/foo_spec.rb ee/spec/foo_spec.rb spec/bar_spec.rb
+ ee/spec/bar_spec.rb spec/zab_spec.rb ee/spec/zab_spec.rb
+ ]
+ end
before do
all_changed_files = %w[
@@ -98,203 +42,16 @@ RSpec.describe Tooling::Danger::Specs, feature_category: :tooling do
context 'with include_ee: :exclude' do
it 'returns spec files without EE-specific files' do
- expect(specs.changed_specs_files(ee: :exclude)).not_to include(%w[ee/spec/foo_spec.rb ee/spec/bar_spec.rb ee/spec/zab_spec.rb])
+ expect(specs.changed_specs_files(ee: :exclude))
+ .not_to include(%w[ee/spec/foo_spec.rb ee/spec/bar_spec.rb ee/spec/zab_spec.rb])
end
end
context 'with include_ee: :only' do
it 'returns EE-specific spec files only' do
- expect(specs.changed_specs_files(ee: :only)).to match_array(%w[ee/spec/foo_spec.rb ee/spec/bar_spec.rb ee/spec/zab_spec.rb])
- end
- end
- end
-
- describe '#add_suggestions_for_match_with_array' do
- let(:template) do
- <<~MARKDOWN.chomp
- ```suggestion
- %<suggested_line>s
- ```
-
- If order of the result is not important, please consider using `match_array` to avoid flakiness.
- MARKDOWN
- end
-
- it 'adds suggestions at the correct lines' do
- [
- { suggested_line: " expect(foo).to match_array(['bar', 'baz'])", number: 2 },
- { suggested_line: " expect(foo).to match_array(['bar', 'baz'])", number: 4 },
- { suggested_line: " expect(foo).to match_array ['bar', 'baz']", number: 6 },
- { suggested_line: " expect(foo).to match_array(['bar', 'baz'])", number: 7 },
- { suggested_line: " expect(foo).to match_array ['bar', 'baz']", number: 8 },
- { suggested_line: " expect(foo).to(match_array(['bar', 'baz']))", number: 9 },
- { suggested_line: " expect(foo).to(match_array(['bar', 'baz']))", number: 10 },
- { suggested_line: " expect(foo).to(match_array([bar, baz]))", number: 11 }
- ].each do |test_case|
- comment = format(template, suggested_line: test_case[:suggested_line])
- expect(specs).to receive(:markdown).with(comment, file: filename, line: test_case[:number])
- end
-
- specs.add_suggestions_for_match_with_array(filename)
- end
- end
-
- describe '#add_suggestions_for_project_factory_usage' do
- let(:template) do
- <<~MARKDOWN.chomp
- ```suggestion
- %<suggested_line>s
- ```
-
- Project creations are very slow. Use `let_it_be`, `build` or `build_stubbed` if possible.
- See [testing best practices](https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#optimize-factory-usage)
- for background information and alternative options.
- MARKDOWN
- end
-
- let(:file_lines) do
- [
- " let(:project) { create(:project) }",
- " let_it_be(:project) { create(:project, :repository)",
- " let!(:project) { create(:project) }",
- " let(:var) { create(:project) }",
- " let(:merge_request) { create(:merge_request, project: project)",
- " context 'when merge request exists' do",
- " it { is_expected.to be_success }",
- " end",
- " let!(:var) { create(:project) }",
- " let(:project) { create(:thing) }",
- " let(:project) { build(:project) }",
- " let(:project) do",
- " create(:project)",
- " end",
- " let(:project) { create(:project, :repository) }",
- " str = 'let(:project) { create(:project) }'",
- " let(:project) { create(:project_empty_repo) }",
- " let(:project) { create(:forked_project_with_submodules) }",
- " let(:project) { create(:project_with_design) }",
- " let(:authorization) { create(:project_authorization) }"
- ]
- end
-
- let(:matching_lines) do
- [
- "+ let(:should_not_error) { create(:project) }",
- "+ let(:project) { create(:project) }",
- "+ let!(:project) { create(:project) }",
- "+ let(:var) { create(:project) }",
- "+ let!(:var) { create(:project) }",
- "+ let(:project) { create(:project, :repository) }",
- "+ let(:project) { create(:project_empty_repo) }",
- "+ let(:project) { create(:forked_project_with_submodules) }",
- "+ let(:project) { create(:project_with_design) }"
- ]
- end
-
- let(:changed_lines) do
- [
- "+ line which doesn't exist in the file and should not cause an error",
- "+ let_it_be(:project) { create(:project, :repository)",
- "+ let(:project) { create(:thing) }",
- "+ let(:project) do",
- "+ create(:project)",
- "+ end",
- "+ str = 'let(:project) { create(:project) }'",
- "+ let(:authorization) { create(:project_authorization) }"
- ] + matching_lines
- end
-
- it 'adds suggestions at the correct lines', :aggregate_failures do
- [
- { suggested_line: " let_it_be(:project) { create(:project) }", number: 1 },
- { suggested_line: " let_it_be(:project) { create(:project) }", number: 3 },
- { suggested_line: " let_it_be(:var) { create(:project) }", number: 4 },
- { suggested_line: " let_it_be(:var) { create(:project) }", number: 9 },
- { suggested_line: " let_it_be(:project) { create(:project, :repository) }", number: 15 },
- { suggested_line: " let_it_be(:project) { create(:project_empty_repo) }", number: 17 },
- { suggested_line: " let_it_be(:project) { create(:forked_project_with_submodules) }", number: 18 },
- { suggested_line: " let_it_be(:project) { create(:project_with_design) }", number: 19 }
- ].each do |test_case|
- comment = format(template, suggested_line: test_case[:suggested_line])
- expect(specs).to receive(:markdown).with(comment, file: filename, line: test_case[:number])
- end
-
- specs.add_suggestions_for_project_factory_usage(filename)
- end
- end
-
- describe '#add_suggestions_for_feature_category' do
- let(:template) do
- <<~SUGGESTION_MARKDOWN.chomp
- ```suggestion
- %<suggested_line>s
- ```
-
- Consider adding `feature_category: <feature_category_name>` for this example if it is not set already.
- See [testing best practices](https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#feature-category-metadata).
- SUGGESTION_MARKDOWN
- end
-
- let(:file_lines) do
- [
- " require 'spec_helper'",
- " \n",
- " RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController, feature_category: :planning_analytics do",
- " end",
- "RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do",
- " let_it_be(:user) { create(:user) }",
- " end",
- " describe 'GET \"time_summary\"' do",
- " end",
- " RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do",
- " let_it_be(:user) { create(:user) }",
- " end",
- " describe 'GET \"time_summary\"' do",
- " end",
- " \n",
- "RSpec.describe Projects :aggregate_failures,",
- " feature_category: planning_analytics do",
- " \n",
- "RSpec.describe Epics :aggregate_failures,",
- " ee: true do",
- "\n",
- "RSpec.describe Issues :aggregate_failures,",
- " feature_category: :team_planning do"
- ]
- end
-
- let(:changed_lines) do
- [
- "+ RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController, feature_category: :planning_analytics do",
- "+RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do",
- "+ let_it_be(:user) { create(:user) }",
- "- end",
- "+ describe 'GET \"time_summary\"' do",
- "+ RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do",
- "+RSpec.describe Projects :aggregate_failures,",
- "+ feature_category: planning_analytics do",
- "+RSpec.describe Epics :aggregate_failures,",
- "+ ee: true do",
- "+RSpec.describe Issues :aggregate_failures,"
- ]
- end
-
- before do
- allow(specs.helper).to receive(:changed_lines).with(filename).and_return(changed_lines)
- end
-
- it 'adds suggestions at the correct lines', :aggregate_failures do
- [
- { suggested_line: "RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do", number: 5 },
- { suggested_line: " RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do", number: 10 },
- { suggested_line: "RSpec.describe Epics :aggregate_failures,", number: 19 }
-
- ].each do |test_case|
- comment = format(template, suggested_line: test_case[:suggested_line])
- expect(specs).to receive(:markdown).with(comment, file: filename, line: test_case[:number])
+ expect(specs.changed_specs_files(ee: :only))
+ .to match_array(%w[ee/spec/foo_spec.rb ee/spec/bar_spec.rb ee/spec/zab_spec.rb])
end
-
- specs.add_suggestions_for_feature_category(filename)
end
end
end
diff --git a/spec/tooling/danger/stable_branch_spec.rb b/spec/tooling/danger/stable_branch_spec.rb
index 4d86e066c20..439a878a5e6 100644
--- a/spec/tooling/danger/stable_branch_spec.rb
+++ b/spec/tooling/danger/stable_branch_spec.rb
@@ -93,7 +93,7 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do
let(:pipeline_bridges_response) do
[
{
- 'name' => 'e2e:package-and-test',
+ 'name' => 'e2e:package-and-test-ee',
'status' => pipeline_bridge_state,
'downstream_pipeline' => {
'id' => '123',
@@ -197,7 +197,7 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do
let(:pipeline_bridges_response) do
[
{
- 'name' => 'e2e:package-and-test',
+ 'name' => 'e2e:package-and-test-ee',
'status' => pipeline_bridge_state,
'downstream_pipeline' => nil
}
@@ -241,13 +241,23 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do
context 'when not an applicable version' do
let(:target_branch) { '14-9-stable-ee' }
- it_behaves_like 'with a warning', described_class::VERSION_WARNING_MESSAGE
+ it 'warns about the package-and-test pipeline and the version' do
+ expect(stable_branch).to receive(:warn).with(described_class::WARN_PACKAGE_AND_TEST_MESSAGE)
+ expect(stable_branch).to receive(:warn).with(described_class::VERSION_WARNING_MESSAGE)
+
+ subject
+ end
end
context 'when the version API request fails' do
let(:response_success) { false }
- it_behaves_like 'with a warning', described_class::FAILED_VERSION_REQUEST_MESSAGE
+ it 'warns about the package-and-test pipeline and the version request' do
+ expect(stable_branch).to receive(:warn).with(described_class::WARN_PACKAGE_AND_TEST_MESSAGE)
+ expect(stable_branch).to receive(:warn).with(described_class::FAILED_VERSION_REQUEST_MESSAGE)
+
+ subject
+ end
end
context 'when more than one page of versions is needed' do
@@ -293,6 +303,7 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do
it 'adds a warning' do
expect(HTTParty).to receive(:get).and_return(version_response).at_least(10).times
+ expect(stable_branch).to receive(:warn).with(described_class::WARN_PACKAGE_AND_TEST_MESSAGE)
expect(stable_branch).to receive(:warn).with(described_class::FAILED_VERSION_REQUEST_MESSAGE)
subject
@@ -351,4 +362,26 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do
it { is_expected.to eq(result) }
end
end
+
+ describe '#valid_stable_branch?' do
+ it "returns false when on the default branch" do
+ allow(fake_helper).to receive(:mr_target_branch).and_return('main')
+
+ expect(stable_branch.valid_stable_branch?).to be(false)
+ end
+
+ it "returns true when on a stable branch" do
+ allow(fake_helper).to receive(:mr_target_branch).and_return('15-1-stable-ee')
+ allow(fake_helper).to receive(:security_mr?).and_return(false)
+
+ expect(stable_branch.valid_stable_branch?).to be(true)
+ end
+
+ it "returns false when on a stable branch on a security MR" do
+ allow(fake_helper).to receive(:mr_target_branch).and_return('15-1-stable-ee')
+ allow(fake_helper).to receive(:security_mr?).and_return(true)
+
+ expect(stable_branch.valid_stable_branch?).to be(false)
+ end
+ end
end
diff --git a/spec/tooling/docs/deprecation_handling_spec.rb b/spec/tooling/docs/deprecation_handling_spec.rb
index 94c93d99b94..78e613c37c7 100644
--- a/spec/tooling/docs/deprecation_handling_spec.rb
+++ b/spec/tooling/docs/deprecation_handling_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Docs::DeprecationHandling do
allow(YAML).to receive(:load_file) do |file_name|
{
'title' => file_name[/[a-z]*\.yml/],
- 'announcement_milestone' => file_name[/\d+-\d+/].tr('-', '.')
+ 'removal_milestone' => file_name[/\d+-\d+/].tr('-', '.')
}
end
end
diff --git a/spec/tooling/graphql/docs/renderer_spec.rb b/spec/tooling/graphql/docs/renderer_spec.rb
index bf2383507aa..911dab09701 100644
--- a/spec/tooling/graphql/docs/renderer_spec.rb
+++ b/spec/tooling/graphql/docs/renderer_spec.rb
@@ -377,7 +377,7 @@ RSpec.describe Tooling::Graphql::Docs::Renderer do
| Name | Type | Description |
| ---- | ---- | ----------- |
- | <a id="alphatestfoofooarg"></a>`fooArg` **{warning-solid}** | [`String`](#string) | **Introduced** in 101.2. This feature is in Alpha. It can be changed or removed at any time. Argument description. |
+ | <a id="alphatestfoofooarg"></a>`fooArg` **{warning-solid}** | [`String`](#string) | **Introduced** in 101.2. This feature is an Experiment. It can be changed or removed at any time. Argument description. |
DOC
end
@@ -415,7 +415,7 @@ RSpec.describe Tooling::Graphql::Docs::Renderer do
| Name | Type | Description |
| ---- | ---- | ----------- |
- | <a id="alphatestfoo"></a>`foo` **{warning-solid}** | [`String!`](#string) | **Introduced** in 1.10. This feature is in Alpha. It can be changed or removed at any time. A description. |
+ | <a id="alphatestfoo"></a>`foo` **{warning-solid}** | [`String!`](#string) | **Introduced** in 1.10. This feature is an Experiment. It can be changed or removed at any time. A description. |
#### Fields with arguments
@@ -425,7 +425,7 @@ RSpec.describe Tooling::Graphql::Docs::Renderer do
WARNING:
**Introduced** in 1.10.
- This feature is in Alpha. It can be changed or removed at any time.
+ This feature is an Experiment. It can be changed or removed at any time.
Returns [`String!`](#string).
@@ -460,7 +460,7 @@ RSpec.describe Tooling::Graphql::Docs::Renderer do
WARNING:
**Introduced** in 10.11.
- This feature is in Alpha. It can be changed or removed at any time.
+ This feature is an Experiment. It can be changed or removed at any time.
Returns [`Int`](#int).
DOC
diff --git a/spec/tooling/lib/tooling/fast_quarantine_spec.rb b/spec/tooling/lib/tooling/fast_quarantine_spec.rb
new file mode 100644
index 00000000000..bb60a335ce2
--- /dev/null
+++ b/spec/tooling/lib/tooling/fast_quarantine_spec.rb
@@ -0,0 +1,193 @@
+# frozen_string_literal: true
+
+require_relative '../../../../tooling/lib/tooling/fast_quarantine'
+require 'tempfile'
+
+RSpec.describe Tooling::FastQuarantine, feature_category: :tooling do
+ attr_accessor :fast_quarantine_file
+
+ around do |example|
+ self.fast_quarantine_file = Tempfile.new('fast_quarantine_file')
+
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ example.run
+ ensure
+ fast_quarantine_file.close
+ fast_quarantine_file.unlink
+ end
+ end
+
+ let(:fast_quarantine_path) { fast_quarantine_file.path }
+ let(:fast_quarantine_file_content) { '' }
+ let(:instance) do
+ described_class.new(fast_quarantine_path: fast_quarantine_path)
+ end
+
+ before do
+ File.write(fast_quarantine_path, fast_quarantine_file_content)
+ end
+
+ describe '#initialize' do
+ context 'when fast_quarantine_path does not exist' do
+ it 'prints a warning' do
+ allow(File).to receive(:exist?).and_return(false)
+
+ expect { instance }.to output("#{fast_quarantine_path} doesn't exist!\n").to_stderr
+ end
+ end
+
+ context 'when fast_quarantine_path exists' do
+ it 'does not raise an error' do
+ expect { instance }.not_to raise_error
+ end
+ end
+ end
+
+ describe '#identifiers' do
+ before do
+ allow(File).to receive(:read).and_call_original
+ end
+
+ context 'when the fast quarantine file is empty' do
+ let(:fast_quarantine_file_content) { '' }
+
+ it 'returns []' do
+ expect(instance.identifiers).to eq([])
+ end
+ end
+
+ context 'when the fast quarantine file is not empty' do
+ let(:fast_quarantine_file_content) { "./spec/foo_spec.rb\nspec/foo_spec.rb:42\n./spec/baz_spec.rb[1:2:3]" }
+
+ it 'returns parsed and sanitized lines' do
+ expect(instance.identifiers).to eq(%w[
+ spec/foo_spec.rb
+ spec/foo_spec.rb:42
+ spec/baz_spec.rb[1:2:3]
+ ])
+ end
+
+ context 'when reading the file raises an error' do
+ before do
+ allow(File).to receive(:read).with(fast_quarantine_path).and_raise('')
+ end
+
+ it 'returns []' do
+ expect(instance.identifiers).to eq([])
+ end
+ end
+
+ describe 'memoization' do
+ it 'memoizes the identifiers list' do
+ expect(File).to receive(:read).with(fast_quarantine_path).once.and_call_original
+
+ instance.identifiers
+
+ # calling #identifiers again doesn't call File.read
+ instance.identifiers
+ end
+ end
+ end
+ end
+
+ describe '#skip_example?' do
+ let(:fast_quarantine_file_content) { "./spec/foo_spec.rb\nspec/bar_spec.rb:42\n./spec/baz_spec.rb[1:2:3]" }
+ let(:example_id) { './spec/foo_spec.rb[1:2:3]' }
+ let(:example_metadata) { {} }
+ let(:example) { instance_double(RSpec::Core::Example, id: example_id, metadata: example_metadata) }
+
+ describe 'skipping example by id' do
+ let(:example_id) { './spec/baz_spec.rb[1:2:3]' }
+
+ it 'skips example by id' do
+ expect(instance.skip_example?(example)).to be_truthy
+ end
+ end
+
+ describe 'skipping example by line' do
+ context 'when example location matches' do
+ let(:example_metadata) do
+ { location: './spec/bar_spec.rb:42' }
+ end
+
+ it 'skips example by line' do
+ expect(instance.skip_example?(example)).to be_truthy
+ end
+ end
+
+ context 'when example group location matches' do
+ let(:example_metadata) do
+ {
+ example_group: { location: './spec/bar_spec.rb:42' }
+ }
+ end
+
+ it 'skips example by line' do
+ expect(instance.skip_example?(example)).to be_truthy
+ end
+ end
+
+ context 'when nested parent example group location matches' do
+ let(:example_metadata) do
+ {
+ example_group: {
+ parent_example_group: {
+ parent_example_group: {
+ parent_example_group: { location: './spec/bar_spec.rb:42' }
+ }
+ }
+ }
+ }
+ end
+
+ it 'skips example by line' do
+ expect(instance.skip_example?(example)).to be_truthy
+ end
+ end
+ end
+
+ describe 'skipping example by file' do
+ context 'when example file_path matches' do
+ let(:example_metadata) do
+ { file_path: './spec/foo_spec.rb' }
+ end
+
+ it 'skips example by file' do
+ expect(instance.skip_example?(example)).to be_truthy
+ end
+ end
+
+ context 'when example group file_path matches' do
+ let(:example_metadata) do
+ {
+ example_group: { file_path: './spec/foo_spec.rb' }
+ }
+ end
+
+ it 'skips example by file' do
+ expect(instance.skip_example?(example)).to be_truthy
+ end
+ end
+
+ context 'when nested parent example group file_path matches' do
+ let(:example_metadata) do
+ {
+ example_group: {
+ parent_example_group: {
+ parent_example_group: {
+ parent_example_group: { file_path: './spec/foo_spec.rb' }
+ }
+ }
+ }
+ }
+ end
+
+ it 'skips example by file' do
+ expect(instance.skip_example?(example)).to be_truthy
+ end
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/find_changes_spec.rb b/spec/tooling/lib/tooling/find_changes_spec.rb
new file mode 100644
index 00000000000..43c3da5699d
--- /dev/null
+++ b/spec/tooling/lib/tooling/find_changes_spec.rb
@@ -0,0 +1,289 @@
+# frozen_string_literal: true
+
+require_relative '../../../../tooling/lib/tooling/find_changes'
+require_relative '../../../support/helpers/stub_env'
+require 'json'
+require 'tempfile'
+
+RSpec.describe Tooling::FindChanges, feature_category: :tooling do
+ include StubENV
+
+ attr_accessor :changed_files_file, :predictive_tests_file, :frontend_fixtures_mapping_file
+
+ let(:instance) do
+ described_class.new(
+ changed_files_pathname: changed_files_pathname,
+ predictive_tests_pathname: predictive_tests_pathname,
+ frontend_fixtures_mapping_pathname: frontend_fixtures_mapping_pathname,
+ from: from)
+ end
+
+ let(:changed_files_pathname) { changed_files_file.path }
+ let(:predictive_tests_pathname) { predictive_tests_file.path }
+ let(:frontend_fixtures_mapping_pathname) { frontend_fixtures_mapping_file.path }
+ let(:from) { :api }
+ let(:gitlab_client) { double('GitLab') } # rubocop:disable RSpec/VerifiedDoubles
+
+ around do |example|
+ self.changed_files_file = Tempfile.new('changed_files_file')
+ self.predictive_tests_file = Tempfile.new('predictive_tests_file')
+ self.frontend_fixtures_mapping_file = Tempfile.new('frontend_fixtures_mapping_file')
+
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ example.run
+ ensure
+ frontend_fixtures_mapping_file.close
+ frontend_fixtures_mapping_file.unlink
+ predictive_tests_file.close
+ predictive_tests_file.unlink
+ changed_files_file.close
+ changed_files_file.unlink
+ end
+ end
+
+ before do
+ stub_env(
+ 'CI_API_V4_URL' => 'gitlab_api_url',
+ 'CI_MERGE_REQUEST_IID' => '1234',
+ 'CI_MERGE_REQUEST_PROJECT_PATH' => 'dummy-project',
+ 'PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE' => 'dummy-token'
+ )
+ end
+
+ describe '#initialize' do
+ context 'when fetching changes from unknown' do
+ let(:from) { :unknown }
+
+ it 'raises an ArgumentError' do
+ expect { instance }.to raise_error(
+ ArgumentError, ":from can only be :api or :changed_files"
+ )
+ end
+ end
+ end
+
+ describe '#execute' do
+ subject { instance.execute }
+
+ before do
+ allow(instance).to receive(:gitlab).and_return(gitlab_client)
+ end
+
+ context 'when there is no changed files file' do
+ let(:changed_files_pathname) { nil }
+
+ it 'raises an ArgumentError' do
+ expect { subject }.to raise_error(
+ ArgumentError, "A path to the changed files file must be given as :changed_files_pathname"
+ )
+ end
+ end
+
+ context 'when fetching changes from API' do
+ let(:from) { :api }
+
+ it 'calls GitLab API to retrieve the MR diff' do
+ expect(gitlab_client).to receive_message_chain(:merge_request_changes, :changes).and_return([])
+
+ subject
+ end
+ end
+
+ context 'when fetching changes from changed files' do
+ let(:from) { :changed_files }
+
+ it 'does not call GitLab API to retrieve the MR diff' do
+ expect(gitlab_client).not_to receive(:merge_request_changes)
+
+ subject
+ end
+
+ context 'when there are no file changes' do
+ it 'writes an empty string to changed files file' do
+ expect { subject }.not_to change { File.read(changed_files_pathname) }
+ end
+ end
+
+ context 'when there are file changes' do
+ before do
+ File.write(changed_files_pathname, changed_files_file_content)
+ end
+
+ let(:changed_files_file_content) { 'first_file_changed second_file_changed' }
+
+ # This is because we don't have frontend fixture mappings: we will just write the same data that we read.
+ it 'does not change the changed files file' do
+ expect { subject }.not_to change { File.read(changed_files_pathname) }
+ end
+ end
+
+ context 'when there is no matched tests file' do
+ let(:predictive_tests_pathname) { nil }
+
+ it 'does not add frontend fixtures mapping to the changed files file' do
+ expect { subject }.not_to change { File.read(changed_files_pathname) }
+ end
+ end
+
+ context 'when there is no frontend fixture files' do
+ let(:frontend_fixtures_mapping_pathname) { nil }
+
+ it 'does not add frontend fixtures mapping to the changed files file' do
+ expect { subject }.not_to change { File.read(changed_files_pathname) }
+ end
+ end
+
+ context 'when the matched tests file and frontend fixture files are provided' do
+ before do
+ File.write(predictive_tests_pathname, matched_tests)
+ File.write(frontend_fixtures_mapping_pathname, frontend_fixtures_mapping_json)
+ File.write(changed_files_pathname, changed_files_file_content)
+ end
+
+ let(:changed_files_file_content) { '' }
+
+ context 'when there are no mappings for the matched tests' do
+ let(:matched_tests) { 'match_spec1 match_spec_2' }
+ let(:frontend_fixtures_mapping_json) do
+ { other_spec: ['other_mapping'] }.to_json
+ end
+
+ it 'does not change the changed files file' do
+ expect { subject }.not_to change { File.read(changed_files_pathname) }
+ end
+ end
+
+ context 'when there are available mappings for the matched tests' do
+ let(:matched_tests) { 'match_spec1 match_spec_2' }
+ let(:spec_mappings) { %w[spec1_mapping1 spec1_mapping2] }
+ let(:frontend_fixtures_mapping_json) do
+ { match_spec1: spec_mappings }.to_json
+ end
+
+ context 'when the changed files file is initially empty' do
+ it 'adds the frontend fixtures mappings to the changed files file' do
+ expect { subject }.to change { File.read(changed_files_pathname) }.from('').to(spec_mappings.join(' '))
+ end
+ end
+
+ context 'when the changed files file is initially not empty' do
+ let(:changed_files_file_content) { 'initial_content1 initial_content2' }
+
+ it 'adds the frontend fixtures mappings to the changed files file' do
+ expect { subject }.to change { File.read(changed_files_pathname) }
+ .from(changed_files_file_content)
+ .to("#{changed_files_file_content} #{spec_mappings.join(' ')}")
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe '#only_allowed_files_changed' do
+ subject { instance.only_allowed_files_changed }
+
+ context 'when fetching changes from changed files' do
+ let(:from) { :changed_files }
+
+ before do
+ File.write(changed_files_pathname, changed_files_file_content)
+ end
+
+ context 'when changed files contain only *.js changes' do
+ let(:changed_files_file_content) { 'a.js b.js' }
+
+ it 'returns true' do
+ expect(subject).to be true
+ end
+ end
+
+ context 'when changed files contain both *.vue and *.js changes' do
+ let(:changed_files_file_content) { 'a.js b.vue' }
+
+ it 'returns true' do
+ expect(subject).to be true
+ end
+ end
+
+ context 'when changed files contain not allowed changes' do
+ let(:changed_files_file_content) { 'a.js b.vue c.rb' }
+
+ it 'returns false' do
+ expect(subject).to be false
+ end
+ end
+ end
+
+ context 'when fetching changes from API' do
+ let(:from) { :api }
+
+ let(:mr_changes_array) { [] }
+
+ before do
+ allow(instance).to receive(:gitlab).and_return(gitlab_client)
+
+ # The class from the GitLab gem isn't public, so we cannot use verified doubles for it.
+ #
+ # rubocop:disable RSpec/VerifiedDoubles
+ allow(gitlab_client).to receive(:merge_request_changes)
+ .with('dummy-project', '1234')
+ .and_return(double(changes: mr_changes_array))
+ # rubocop:enable RSpec/VerifiedDoubles
+ end
+
+ context 'when a file is passed as an argument' do
+ it 'calls GitLab API' do
+ expect(gitlab_client).to receive(:merge_request_changes)
+ .with('dummy-project', '1234')
+
+ subject
+ end
+ end
+
+ context 'when there are no file changes' do
+ let(:mr_changes_array) { [] }
+
+ it 'returns false' do
+ expect(subject).to be false
+ end
+ end
+
+ context 'when there are changes to files other than JS files' do
+ let(:mr_changes_array) do
+ [
+ {
+ "new_path" => "scripts/gitlab_component_helpers.sh",
+ "old_path" => "scripts/gitlab_component_helpers.sh"
+ },
+ {
+ "new_path" => "scripts/test.js",
+ "old_path" => "scripts/test.js"
+ }
+ ]
+ end
+
+ it 'returns false' do
+ expect(subject).to be false
+ end
+ end
+
+ context 'when there are changes only to JS files' do
+ let(:mr_changes_array) do
+ [
+ {
+ "new_path" => "scripts/test.js",
+ "old_path" => "scripts/test.js"
+ }
+ ]
+ end
+
+ it 'returns true' do
+ expect(subject).to be true
+ end
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/find_files_using_feature_flags_spec.rb b/spec/tooling/lib/tooling/find_files_using_feature_flags_spec.rb
new file mode 100644
index 00000000000..f553d34768f
--- /dev/null
+++ b/spec/tooling/lib/tooling/find_files_using_feature_flags_spec.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+require_relative '../../../../tooling/lib/tooling/find_files_using_feature_flags'
+
+RSpec.describe Tooling::FindFilesUsingFeatureFlags, feature_category: :tooling do
+ attr_accessor :changed_files_file
+
+ let(:changed_files_pathname) { changed_files_file.path }
+ let(:instance) { described_class.new(changed_files_pathname: changed_files_pathname) }
+ let(:changed_files_content) { '' }
+
+ around do |example|
+ self.changed_files_file = Tempfile.new('changed_files_file')
+
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ example.run
+ ensure
+ changed_files_file.close
+ changed_files_file.unlink
+ end
+ end
+
+ before do
+ allow(File).to receive(:exist?).and_call_original
+ allow(File).to receive(:read).and_call_original
+
+ File.write(changed_files_pathname, changed_files_content)
+ end
+
+ describe '#execute' do
+ subject { instance.execute }
+
+ let(:valid_ff_pathname_1) { 'config/feature_flags/development/my_feature_flag.yml' }
+ let(:valid_ff_pathname_2) { 'config/feature_flags/development/my_other_feature_flag.yml' }
+ let(:changed_files_content) { "#{valid_ff_pathname_1} #{valid_ff_pathname_2}" }
+ let(:ruby_files) { [] }
+
+ before do
+ allow(File).to receive(:exist?).with(valid_ff_pathname_1).and_return(true)
+ allow(File).to receive(:exist?).with(valid_ff_pathname_2).and_return(true)
+ allow(Dir).to receive(:[]).with('**/*.rb').and_return(ruby_files)
+ end
+
+ context 'when no ruby files are using the modified feature flag' do
+ let(:ruby_files) { [] }
+
+ it 'does not add anything to the input file' do
+ expect { subject }.not_to change { File.read(changed_files_pathname) }
+ end
+ end
+
+ context 'when some ruby files are using the modified feature flags' do
+ let(:matching_ruby_file_1) { 'first-ruby-file' }
+ let(:matching_ruby_file_2) { 'second-ruby-file' }
+ let(:not_matching_ruby_file) { 'third-ruby-file' }
+ let(:ruby_files) { [matching_ruby_file_1, matching_ruby_file_2, not_matching_ruby_file] }
+
+ before do
+ allow(File).to receive(:read).with(matching_ruby_file_1).and_return('my_feature_flag')
+ allow(File).to receive(:read).with(matching_ruby_file_2).and_return('my_other_feature_flag')
+ allow(File).to receive(:read).with(not_matching_ruby_file).and_return('other text')
+ end
+
+ it 'add the matching ruby files to the input file' do
+ expect { subject }.to change { File.read(changed_files_pathname) }
+ .from(changed_files_content)
+ .to("#{changed_files_content} #{matching_ruby_file_1} #{matching_ruby_file_2}")
+ end
+ end
+ end
+
+ describe '#filter_files' do
+ subject { instance.filter_files }
+
+ let(:changed_files_content) { path_to_file }
+
+ context 'when the file does not exist on disk' do
+ let(:path_to_file) { "config/other_feature_flags_folder/feature.yml" }
+
+ before do
+ allow(File).to receive(:exist?).with(path_to_file).and_return(false)
+ end
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when the file exists on disk' do
+ before do
+ allow(File).to receive(:exist?).with(path_to_file).and_return(true)
+ end
+
+ context 'when the file is not in the features folder' do
+ let(:path_to_file) { "config/other_folder/development/feature.yml" }
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when the filename does not have the correct extension' do
+ let(:path_to_file) { "config/feature_flags/development/feature.rb" }
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when the ruby file uses a valid feature flag file' do
+ let(:path_to_file) { "config/feature_flags/development/feature.yml" }
+
+ it 'returns the file' do
+ expect(subject).to match_array(path_to_file)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/find_tests_spec.rb b/spec/tooling/lib/tooling/find_tests_spec.rb
new file mode 100644
index 00000000000..905f81c4bbd
--- /dev/null
+++ b/spec/tooling/lib/tooling/find_tests_spec.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+require_relative '../../../../tooling/lib/tooling/find_tests'
+require_relative '../../../support/helpers/stub_env'
+
+RSpec.describe Tooling::FindTests, feature_category: :tooling do
+ include StubENV
+
+ attr_accessor :changed_files_file, :predictive_tests_file
+
+ let(:instance) { described_class.new(changed_files_pathname, predictive_tests_pathname) }
+ let(:mock_test_file_finder) { instance_double(TestFileFinder::FileFinder) }
+ let(:new_matching_tests) { ["new_matching_spec.rb"] }
+ let(:changed_files_pathname) { changed_files_file.path }
+ let(:predictive_tests_pathname) { predictive_tests_file.path }
+ let(:changed_files_content) { "changed_file1 changed_file2" }
+ let(:predictive_tests_content) { "previously_matching_spec.rb" }
+
+ around do |example|
+ self.changed_files_file = Tempfile.new('changed_files_file')
+ self.predictive_tests_file = Tempfile.new('predictive_tests_file')
+
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ example.run
+ ensure
+ changed_files_file.close
+ predictive_tests_file.close
+ changed_files_file.unlink
+ predictive_tests_file.unlink
+ end
+ end
+
+ before do
+ allow(mock_test_file_finder).to receive(:use)
+ allow(mock_test_file_finder).to receive(:test_files).and_return(new_matching_tests)
+ allow(TestFileFinder::FileFinder).to receive(:new).and_return(mock_test_file_finder)
+
+ stub_env(
+ 'RSPEC_TESTS_MAPPING_ENABLED' => nil,
+ 'RSPEC_TESTS_MAPPING_PATH' => '/tmp/does-not-exist.out'
+ )
+
+ # We write into the temp files initially, to later check how the code modified those files
+ File.write(changed_files_pathname, changed_files_content)
+ File.write(predictive_tests_pathname, predictive_tests_content)
+ end
+
+ describe '#execute' do
+ subject { instance.execute }
+
+ context 'when the predictive_tests_pathname file does not exist' do
+ let(:instance) { described_class.new(non_existing_output_pathname, predictive_tests_pathname) }
+ let(:non_existing_output_pathname) { 'tmp/another_file.out' }
+
+ around do |example|
+ example.run
+ ensure
+ FileUtils.rm_rf(non_existing_output_pathname)
+ end
+
+ it 'creates the file' do
+ expect { subject }.to change { File.exist?(non_existing_output_pathname) }.from(false).to(true)
+ end
+ end
+
+ context 'when the predictive_tests_pathname file already exists' do
+ it 'does not create an empty file' do
+ expect(File).not_to receive(:write).with(predictive_tests_pathname, '')
+
+ subject
+ end
+ end
+
+ it 'does not modify the content of the input file' do
+ expect { subject }.not_to change { File.read(changed_files_pathname) }
+ end
+
+ it 'does not overwrite the output file' do
+ expect { subject }.to change { File.read(predictive_tests_pathname) }
+ .from(predictive_tests_content)
+ .to("#{predictive_tests_content} #{new_matching_tests.uniq.join(' ')}")
+ end
+
+ it 'loads the tests.yml file with a pattern matching mapping' do
+ expect(TestFileFinder::MappingStrategies::PatternMatching).to receive(:load).with('tests.yml')
+
+ subject
+ end
+
+ context 'when RSPEC_TESTS_MAPPING_ENABLED env variable is set' do
+ before do
+ stub_env(
+ 'RSPEC_TESTS_MAPPING_ENABLED' => 'true',
+ 'RSPEC_TESTS_MAPPING_PATH' => 'crystalball-test/mapping.json'
+ )
+ end
+
+ it 'loads the direct matching pattern file' do
+ expect(TestFileFinder::MappingStrategies::DirectMatching)
+ .to receive(:load_json)
+ .with('crystalball-test/mapping.json')
+
+ subject
+ end
+ end
+
+ context 'when RSPEC_TESTS_MAPPING_ENABLED env variable is not set' do
+ let(:rspec_tests_mapping_enabled) { '' }
+
+ before do
+ stub_env(
+ 'RSPEC_TESTS_MAPPING_ENABLED' => rspec_tests_mapping_enabled,
+ 'RSPEC_TESTS_MAPPING_PATH' => rspec_tests_mapping_path
+ )
+ end
+
+ context 'when RSPEC_TESTS_MAPPING_PATH is set' do
+ let(:rspec_tests_mapping_path) { 'crystalball-test/mapping.json' }
+
+ it 'does not load the direct matching pattern file' do
+ expect(TestFileFinder::MappingStrategies::DirectMatching).not_to receive(:load_json)
+
+ subject
+ end
+ end
+
+ context 'when RSPEC_TESTS_MAPPING_PATH is not set' do
+ let(:rspec_tests_mapping_path) { nil }
+
+ it 'does not load the direct matching pattern file' do
+ expect(TestFileFinder::MappingStrategies::DirectMatching).not_to receive(:load_json)
+
+ subject
+ end
+ end
+ end
+
+ context 'when the same spec is matching multiple times' do
+ let(:new_matching_tests) do
+ [
+ "new_matching_spec.rb",
+ "duplicate_spec.rb",
+ "duplicate_spec.rb"
+ ]
+ end
+
+ it 'writes uniquely matching specs to the output' do
+ subject
+
+ expect(File.read(predictive_tests_pathname).split(' ')).to match_array(
+ predictive_tests_content.split(' ') + new_matching_tests.uniq
+ )
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/gettext_extractor_spec.rb b/spec/tooling/lib/tooling/gettext_extractor_spec.rb
new file mode 100644
index 00000000000..3c0f91342c2
--- /dev/null
+++ b/spec/tooling/lib/tooling/gettext_extractor_spec.rb
@@ -0,0 +1,276 @@
+# frozen_string_literal: true
+
+require 'rspec/parameterized'
+
+require_relative '../../../../tooling/lib/tooling/gettext_extractor'
+require_relative '../../../support/helpers/stub_env'
+require_relative '../../../support/tmpdir'
+
+RSpec.describe Tooling::GettextExtractor, feature_category: :tooling do
+ include StubENV
+ include TmpdirHelper
+
+ let(:base_dir) { mktmpdir }
+ let(:instance) { described_class.new(backend_glob: '*.{rb,haml,erb}', glob_base: base_dir) }
+ let(:frontend_status) { true }
+
+ let(:files) do
+ {
+ rb_file: File.join(base_dir, 'ruby.rb'),
+ haml_file: File.join(base_dir, 'template.haml'),
+ erb_file: File.join(base_dir, 'template.erb')
+ }
+ end
+
+ before do
+ # Disable parallelism in specs in order to suppress some confusing stack traces
+ stub_env(
+ 'PARALLEL_PROCESSOR_COUNT' => 0
+ )
+ # Mock Backend files
+ File.write(files[:rb_file], '[_("RB"), _("All"), n_("Apple", "Apples", size), s_("Context|A"), N_("All2") ]')
+ File.write(
+ files[:erb_file],
+ '<h1><%= _("ERB") + _("All") + n_("Pear", "Pears", size) + s_("Context|B") + N_("All2") %></h1>'
+ )
+ File.write(
+ files[:haml_file],
+ '%h1= _("HAML") + _("All") + n_("Cabbage", "Cabbages", size) + s_("Context|C") + N_("All2")'
+ )
+ # Stub out Frontend file parsing
+ status = {}
+ allow(status).to receive(:success?).and_return(frontend_status)
+ allow(Open3).to receive(:capture2)
+ .with("node scripts/frontend/extract_gettext_all.js --all")
+ .and_return([
+ '{"example.js": [ ["JS"], ["All"], ["Mango\u0000Mangoes"], ["Context|D"], ["All2"] ] }',
+ status
+ ])
+ end
+
+ describe '::HamlParser' do
+ # Testing with a non-externalized string, as the functionality
+ # is properly tested later on
+ it '#parse_source' do
+ expect(described_class::HamlParser.new(files[:haml_file]).parse_source('%h1= "Test"')).to match_array([])
+ end
+ end
+
+ describe '#parse' do
+ it 'collects and merges translatable strings from frontend and backend' do
+ expect(instance.parse([]).to_h { |entry| [entry.msgid, entry.msgid_plural] }).to eq({
+ 'All' => nil,
+ 'All2' => nil,
+ 'Context|A' => nil,
+ 'Context|B' => nil,
+ 'Context|C' => nil,
+ 'Context|D' => nil,
+ 'ERB' => nil,
+ 'HAML' => nil,
+ 'JS' => nil,
+ 'RB' => nil,
+ 'Apple' => 'Apples',
+ 'Cabbage' => 'Cabbages',
+ 'Mango' => 'Mangoes',
+ 'Pear' => 'Pears'
+ })
+ end
+
+ it 're-raises error from backend extraction' do
+ allow(instance).to receive(:parse_backend_file).and_raise(StandardError)
+
+ expect { instance.parse([]) }.to raise_error(StandardError)
+ end
+
+ context 'when frontend extraction raises an error' do
+ let(:frontend_status) { false }
+
+ it 'is re-raised' do
+ expect { instance.parse([]) }.to raise_error(StandardError, 'Could not parse frontend files')
+ end
+ end
+ end
+
+ describe '#generate_pot' do
+ subject { instance.generate_pot }
+
+ it 'produces pot without date headers' do
+ expect(subject).not_to include('POT-Creation-Date:')
+ expect(subject).not_to include('PO-Revision-Date:')
+ end
+
+ it 'produces pot file with all translated strings, sorted by msg id' do
+ expect(subject).to eql <<~POT_FILE
+ # SOME DESCRIPTIVE TITLE.
+ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+ # This file is distributed under the same license as the gitlab package.
+ # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+ #
+ #, fuzzy
+ msgid ""
+ msgstr ""
+ "Project-Id-Version: gitlab 1.0.0\\n"
+ "Report-Msgid-Bugs-To: \\n"
+ "Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
+ "Language-Team: LANGUAGE <LL@li.org>\\n"
+ "Language: \\n"
+ "MIME-Version: 1.0\\n"
+ "Content-Type: text/plain; charset=UTF-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n"
+
+ msgid "All"
+ msgstr ""
+
+ msgid "All2"
+ msgstr ""
+
+ msgid "Apple"
+ msgid_plural "Apples"
+ msgstr[0] ""
+ msgstr[1] ""
+
+ msgid "Cabbage"
+ msgid_plural "Cabbages"
+ msgstr[0] ""
+ msgstr[1] ""
+
+ msgid "Context|A"
+ msgstr ""
+
+ msgid "Context|B"
+ msgstr ""
+
+ msgid "Context|C"
+ msgstr ""
+
+ msgid "Context|D"
+ msgstr ""
+
+ msgid "ERB"
+ msgstr ""
+
+ msgid "HAML"
+ msgstr ""
+
+ msgid "JS"
+ msgstr ""
+
+ msgid "Mango"
+ msgid_plural "Mangoes"
+ msgstr[0] ""
+ msgstr[1] ""
+
+ msgid "Pear"
+ msgid_plural "Pears"
+ msgstr[0] ""
+ msgstr[1] ""
+
+ msgid "RB"
+ msgstr ""
+ POT_FILE
+ end
+ end
+
+ # This private methods is tested directly, because unfortunately it is called
+ # with the "Parallel" gem. As the parallel gem executes this function in a different
+ # thread, our coverage reporting is confused
+ #
+ # On the other hand, the tests are also more readable, so maybe a win-win
+ describe '#parse_backend_file' do
+ subject { instance.send(:parse_backend_file, curr_file) }
+
+ where do
+ {
+ 'with ruby file' => {
+ invalid_syntax: 'x = {id: _("RB")',
+ file: :rb_file,
+ result: {
+ 'All' => nil,
+ 'All2' => nil,
+ 'Context|A' => nil,
+ 'RB' => nil, 'Apple' => 'Apples'
+ },
+ parser: GetText::RubyParser
+ },
+ 'with haml file' => {
+ invalid_syntax: " %a\n- content = _('HAML')",
+ file: :haml_file,
+ result: {
+ 'All' => nil,
+ 'All2' => nil,
+ 'Context|C' => nil,
+ 'HAML' => nil,
+ 'Cabbage' => 'Cabbages'
+ },
+ parser: described_class::HamlParser
+ },
+ 'with erb file' => {
+ invalid_syntax: "<% x = {id: _('ERB') %>",
+ file: :erb_file,
+ result: {
+ 'All' => nil,
+ 'All2' => nil,
+ 'Context|B' => nil,
+ 'ERB' => nil,
+ 'Pear' => 'Pears'
+ },
+ parser: GetText::ErbParser
+ }
+ }
+ end
+
+ with_them do
+ let(:curr_file) { files[file] }
+
+ context 'when file has valid syntax' do
+ before do
+ allow(parser).to receive(:new).and_call_original
+ end
+
+ it 'parses file and returns extracted strings as POEntries' do
+ expect(subject.map(&:class).uniq).to match_array([GetText::POEntry])
+ expect(subject.to_h { |entry| [entry.msgid, entry.msgid_plural] }).to eq(result)
+ expect(parser).to have_received(:new)
+ end
+ end
+
+ # We do not worry about syntax errors in these file types, as it is _not_ the job of
+ # gettext extractor to ensure correctness of the files. These errors should raise
+ # in other places
+ context 'when file has invalid syntax' do
+ before do
+ File.write(curr_file, invalid_syntax)
+ end
+
+ it 'does not raise error' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when file does not contain "_("' do
+ before do
+ allow(parser).to receive(:new).and_call_original
+ File.write(curr_file, '"abcdef"')
+ end
+
+ it 'never parses the file and returns empty array' do
+ expect(subject).to match_array([])
+ expect(parser).not_to have_received(:new)
+ end
+ end
+ end
+
+ context 'with unsupported file containing "_("' do
+ let(:curr_file) { File.join(base_dir, 'foo.unsupported') }
+
+ before do
+ File.write(curr_file, '_("Test")')
+ end
+
+ it 'raises error' do
+ expect { subject }.to raise_error(NotImplementedError)
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/helpers/file_handler_spec.rb b/spec/tooling/lib/tooling/helpers/file_handler_spec.rb
new file mode 100644
index 00000000000..b78f0a3bb6b
--- /dev/null
+++ b/spec/tooling/lib/tooling/helpers/file_handler_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+require_relative '../../../../../tooling/lib/tooling/helpers/file_handler'
+
+class MockClass # rubocop:disable Gitlab/NamespacedClass
+ include Tooling::Helpers::FileHandler
+end
+
+RSpec.describe Tooling::Helpers::FileHandler, feature_category: :tooling do
+ attr_accessor :input_file_path, :output_file_path
+
+ around do |example|
+ input_file = Tempfile.new('input')
+ output_file = Tempfile.new('output')
+
+ self.input_file_path = input_file.path
+ self.output_file_path = output_file.path
+
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ example.run
+ ensure
+ output_file.close
+ input_file.close
+ output_file.unlink
+ input_file.unlink
+ end
+ end
+
+ let(:instance) { MockClass.new }
+ let(:initial_content) { 'previous_content1 previous_content2' }
+
+ before do
+ # We write into the temp files initially, to later check how the code modified those files
+ File.write(input_file_path, initial_content)
+ File.write(output_file_path, initial_content)
+ end
+
+ describe '#read_array_from_file' do
+ subject { instance.read_array_from_file(input_file_path) }
+
+ context 'when the input file does not exist' do
+ let(:non_existing_input_pathname) { 'tmp/another_file.out' }
+
+ subject { instance.read_array_from_file(non_existing_input_pathname) }
+
+ around do |example|
+ example.run
+ ensure
+ FileUtils.rm_rf(non_existing_input_pathname)
+ end
+
+ it 'creates the file' do
+ expect { subject }.to change { File.exist?(non_existing_input_pathname) }.from(false).to(true)
+ end
+ end
+
+ context 'when the input file is not empty' do
+ let(:initial_content) { 'previous_content1 previous_content2' }
+
+ it 'returns the content of the file in an array' do
+ expect(subject).to eq(initial_content.split(' '))
+ end
+ end
+ end
+
+ describe '#write_array_to_file' do
+ let(:content_array) { %w[new_entry] }
+ let(:append_flag) { true }
+
+ subject { instance.write_array_to_file(output_file_path, content_array, append: append_flag) }
+
+ context 'when the output file does not exist' do
+ let(:non_existing_output_file) { 'tmp/another_file.out' }
+
+ subject { instance.write_array_to_file(non_existing_output_file, content_array) }
+
+ around do |example|
+ example.run
+ ensure
+ FileUtils.rm_rf(non_existing_output_file)
+ end
+
+ it 'creates the file' do
+ expect { subject }.to change { File.exist?(non_existing_output_file) }.from(false).to(true)
+ end
+ end
+
+ context 'when the output file is empty' do
+ let(:initial_content) { '' }
+
+ it 'writes the correct content to the file' do
+ expect { subject }.to change { File.read(output_file_path) }.from('').to(content_array.join(' '))
+ end
+
+ context 'when the content array is not sorted' do
+ let(:content_array) { %w[new_entry a_new_entry] }
+
+ it 'sorts the array before writing it to file' do
+ expect { subject }.to change { File.read(output_file_path) }.from('').to(content_array.sort.join(' '))
+ end
+ end
+ end
+
+ context 'when the output file is not empty' do
+ let(:initial_content) { 'previous_content1 previous_content2' }
+
+ it 'appends the correct content to the file' do
+ expect { subject }.to change { File.read(output_file_path) }
+ .from(initial_content)
+ .to((initial_content.split(' ') + content_array).join(' '))
+ end
+
+ context 'when the append flag is set to false' do
+ let(:append_flag) { false }
+
+ it 'overwrites the previous content' do
+ expect { subject }.to change { File.read(output_file_path) }
+ .from(initial_content)
+ .to(content_array.join(' '))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/helpers/predictive_tests_helper_spec.rb b/spec/tooling/lib/tooling/helpers/predictive_tests_helper_spec.rb
new file mode 100644
index 00000000000..48a5866ac56
--- /dev/null
+++ b/spec/tooling/lib/tooling/helpers/predictive_tests_helper_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+require_relative '../../../../../tooling/lib/tooling/helpers/predictive_tests_helper'
+
+class MockClass # rubocop:disable Gitlab/NamespacedClass
+ include Tooling::Helpers::PredictiveTestsHelper
+end
+
+RSpec.describe Tooling::Helpers::PredictiveTestsHelper, feature_category: :tooling do
+ let(:instance) { MockClass.new }
+
+ describe '#folders_for_available_editions' do
+ let(:base_folder_path) { 'app/views' }
+
+ subject { instance.folders_for_available_editions(base_folder_path) }
+
+ context 'when FOSS' do
+ before do
+ allow(GitlabEdition).to receive(:ee?).and_return(false)
+ allow(GitlabEdition).to receive(:jh?).and_return(false)
+ end
+
+ it 'returns the correct paths' do
+ expect(subject).to match_array([base_folder_path])
+ end
+ end
+
+ context 'when EE' do
+ before do
+ allow(GitlabEdition).to receive(:ee?).and_return(true)
+ allow(GitlabEdition).to receive(:jh?).and_return(false)
+ end
+
+ it 'returns the correct paths' do
+ expect(subject).to match_array([base_folder_path, "ee/#{base_folder_path}"])
+ end
+ end
+
+ context 'when JiHu' do
+ before do
+ allow(GitlabEdition).to receive(:ee?).and_return(true)
+ allow(GitlabEdition).to receive(:jh?).and_return(true)
+ end
+
+ it 'returns the correct paths' do
+ expect(subject).to match_array([base_folder_path, "ee/#{base_folder_path}", "jh/#{base_folder_path}"])
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/kubernetes_client_spec.rb b/spec/tooling/lib/tooling/kubernetes_client_spec.rb
index 50d33182a42..8d127f1345b 100644
--- a/spec/tooling/lib/tooling/kubernetes_client_spec.rb
+++ b/spec/tooling/lib/tooling/kubernetes_client_spec.rb
@@ -1,286 +1,200 @@
# frozen_string_literal: true
+require 'time'
require_relative '../../../../tooling/lib/tooling/kubernetes_client'
RSpec.describe Tooling::KubernetesClient do
- let(:namespace) { 'review-apps' }
- let(:release_name) { 'my-release' }
- let(:pod_for_release) { "pod-my-release-abcd" }
- let(:raw_resource_names_str) { "NAME\nfoo\n#{pod_for_release}\nbar" }
- let(:raw_resource_names) { raw_resource_names_str.lines.map(&:strip) }
-
- subject { described_class.new(namespace: namespace) }
-
- describe 'RESOURCE_LIST' do
- it 'returns the correct list of resources separated by commas' do
- expect(described_class::RESOURCE_LIST).to eq('ingress,svc,pdb,hpa,deploy,statefulset,job,pod,secret,configmap,pvc,secret,clusterrole,clusterrolebinding,role,rolebinding,sa,crd')
- end
+ let(:instance) { described_class.new }
+ let(:one_day_ago) { Time.now - 3600 * 24 * 1 }
+ let(:two_days_ago) { Time.now - 3600 * 24 * 2 }
+ let(:three_days_ago) { Time.now - 3600 * 24 * 3 }
+
+ before do
+ # Global mock to ensure that no kubectl commands are run by accident in a test.
+ allow(instance).to receive(:run_command)
end
- describe '#cleanup_by_release' do
- before do
- allow(subject).to receive(:raw_resource_names).and_return(raw_resource_names)
- end
-
- shared_examples 'a kubectl command to delete resources' do
- let(:wait) { true }
- let(:release_names_in_command) { release_name.respond_to?(:join) ? %(-l 'release in (#{release_name.join(', ')})') : %(-l release="#{release_name}") }
-
- specify do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with(["kubectl delete #{described_class::RESOURCE_LIST} " +
- %(--namespace "#{namespace}" --now --ignore-not-found --wait=#{wait} #{release_names_in_command})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true)))
-
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with([%(kubectl delete --namespace "#{namespace}" --ignore-not-found #{pod_for_release})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true)))
-
- # We're not verifying the output here, just silencing it
- expect { subject.cleanup_by_release(release_name: release_name) }.to output.to_stdout
- end
- end
-
- it 'raises an error if the Kubernetes command fails' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with(["kubectl delete #{described_class::RESOURCE_LIST} " +
- %(--namespace "#{namespace}" --now --ignore-not-found --wait=true -l release="#{release_name}")])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false)))
-
- expect { subject.cleanup_by_release(release_name: release_name) }.to raise_error(described_class::CommandFailedError)
- end
-
- it_behaves_like 'a kubectl command to delete resources'
-
- context 'with multiple releases' do
- let(:release_name) { %w[my-release my-release-2] }
-
- it_behaves_like 'a kubectl command to delete resources'
- end
-
- context 'with `wait: false`' do
- let(:wait) { false }
-
- it_behaves_like 'a kubectl command to delete resources'
- end
- end
-
- describe '#cleanup_by_created_at' do
- let(:two_days_ago) { Time.now - 3600 * 24 * 2 }
- let(:resource_type) { 'pvc' }
- let(:resource_names) { [pod_for_release] }
+ describe '#cleanup_namespaces_by_created_at' do
+ let(:namespace_1_created_at) { three_days_ago }
+ let(:namespace_2_created_at) { three_days_ago }
+ let(:namespace_1_name) { 'review-first-review-app' }
+ let(:namespace_2_name) { 'review-second-review-app' }
+ let(:kubectl_namespaces_json) do
+ <<~JSON
+ {
+ "apiVersion": "v1",
+ "items": [
+ {
+ "apiVersion": "v1",
+ "kind": "namespace",
+ "metadata": {
+ "creationTimestamp": "#{namespace_1_created_at.utc.iso8601}",
+ "name": "#{namespace_1_name}"
+ }
+ },
+ {
+ "apiVersion": "v1",
+ "kind": "namespace",
+ "metadata": {
+ "creationTimestamp": "#{namespace_2_created_at.utc.iso8601}",
+ "name": "#{namespace_2_name}"
+ }
+ }
+ ]
+ }
+ JSON
+ end
+
+ subject { instance.cleanup_namespaces_by_created_at(created_before: two_days_ago) }
before do
- allow(subject).to receive(:resource_names_created_before).with(resource_type: resource_type, created_before: two_days_ago).and_return(resource_names)
+ allow(instance).to receive(:run_command).with(
+ "kubectl get namespace --all-namespaces --sort-by='{.metadata.creationTimestamp}' -o json"
+ ).and_return(kubectl_namespaces_json)
end
- shared_examples 'a kubectl command to delete resources by older than given creation time' do
- let(:wait) { true }
- let(:release_names_in_command) { resource_names.join(' ') }
+ context 'when no namespaces are stale' do
+ let(:namespace_1_created_at) { one_day_ago }
+ let(:namespace_2_created_at) { one_day_ago }
- specify do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with(["kubectl delete #{resource_type} ".squeeze(' ') +
- %(--namespace "#{namespace}" --now --ignore-not-found --wait=#{wait} #{release_names_in_command})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true)))
+ it 'does not delete any namespace' do
+ expect(instance).not_to receive(:run_command).with(/kubectl delete namespace/)
- # We're not verifying the output here, just silencing it
- expect { subject.cleanup_by_created_at(resource_type: resource_type, created_before: two_days_ago) }.to output.to_stdout
+ subject
end
end
- it 'raises an error if the Kubernetes command fails' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with(["kubectl delete #{resource_type} " +
- %(--namespace "#{namespace}" --now --ignore-not-found --wait=true #{pod_for_release})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false)))
-
- expect { subject.cleanup_by_created_at(resource_type: resource_type, created_before: two_days_ago) }.to raise_error(described_class::CommandFailedError)
- end
+ context 'when some namespaces are stale' do
+ let(:namespace_1_created_at) { three_days_ago }
+ let(:namespace_2_created_at) { three_days_ago }
- it_behaves_like 'a kubectl command to delete resources by older than given creation time'
+ context 'when some namespaces are not review app namespaces' do
+ let(:namespace_1_name) { 'review-my-review-app' }
+ let(:namespace_2_name) { 'review-apps' } # This is not a review apps namespace, so we should not try to delete it
- context 'with multiple resource names' do
- let(:resource_names) { %w[pod-1 pod-2] }
+ it 'only deletes the review app namespaces' do
+ expect(instance).to receive(:run_command).with("kubectl delete namespace --now --ignore-not-found #{namespace_1_name}")
- it_behaves_like 'a kubectl command to delete resources by older than given creation time'
- end
-
- context 'with `wait: false`' do
- let(:wait) { false }
-
- it_behaves_like 'a kubectl command to delete resources by older than given creation time'
- end
-
- context 'with no resource_type given' do
- let(:resource_type) { nil }
-
- it_behaves_like 'a kubectl command to delete resources by older than given creation time'
- end
-
- context 'with multiple resource_type given' do
- let(:resource_type) { 'pvc,service' }
-
- it_behaves_like 'a kubectl command to delete resources by older than given creation time'
- end
+ subject
+ end
+ end
- context 'with no resources found' do
- let(:resource_names) { [] }
+ context 'when all namespaces are review app namespaces' do
+ let(:namespace_1_name) { 'review-my-review-app' }
+ let(:namespace_2_name) { 'review-another-review-app' }
- it 'does not call #delete_by_exact_names' do
- expect(subject).not_to receive(:delete_by_exact_names)
+ it 'deletes all of the stale namespaces' do
+ expect(instance).to receive(:run_command).with("kubectl delete namespace --now --ignore-not-found #{namespace_1_name} #{namespace_2_name}")
- subject.cleanup_by_created_at(resource_type: resource_type, created_before: two_days_ago)
+ subject
+ end
end
end
end
- describe '#cleanup_review_app_namespaces' do
- let(:two_days_ago) { Time.now - 3600 * 24 * 2 }
- let(:namespaces) { %w[review-abc-123 review-xyz-789] }
+ describe '#delete_namespaces' do
+ subject { instance.delete_namespaces(namespaces) }
- subject { described_class.new(namespace: nil) }
+ context 'when at least one namespace is not a review app namespace' do
+ let(:namespaces) { %w[review-ns-1 default] }
- before do
- allow(subject).to receive(:review_app_namespaces_created_before).with(created_before: two_days_ago).and_return(namespaces)
- end
-
- shared_examples 'a kubectl command to delete namespaces older than given creation time' do
- let(:wait) { true }
-
- specify do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with(["kubectl delete namespace " +
- %(--now --ignore-not-found --wait=#{wait} #{namespaces.join(' ')})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true)))
+ it 'does not delete any namespace' do
+ expect(instance).not_to receive(:run_command).with(/kubectl delete namespace/)
- # We're not verifying the output here, just silencing it
- expect { subject.cleanup_review_app_namespaces(created_before: two_days_ago) }.to output.to_stdout
+ subject
end
end
- it_behaves_like 'a kubectl command to delete namespaces older than given creation time'
-
- it 'raises an error if the Kubernetes command fails' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with(["kubectl delete namespace " +
- %(--now --ignore-not-found --wait=true #{namespaces.join(' ')})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false)))
-
- expect { subject.cleanup_review_app_namespaces(created_before: two_days_ago) }.to raise_error(described_class::CommandFailedError)
- end
-
- context 'with no namespaces found' do
- let(:namespaces) { [] }
+ context 'when all namespaces are review app namespaces' do
+ let(:namespaces) { %w[review-ns-1 review-ns-2] }
- it 'does not call #delete_namespaces_by_exact_names' do
- expect(subject).not_to receive(:delete_namespaces_by_exact_names)
+ it 'deletes the namespaces' do
+ expect(instance).to receive(:run_command).with("kubectl delete namespace --now --ignore-not-found #{namespaces.join(' ')}")
- subject.cleanup_review_app_namespaces(created_before: two_days_ago)
+ subject
end
end
end
- describe '#raw_resource_names' do
- it 'calls kubectl to retrieve the resource names' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with(["kubectl get #{described_class::RESOURCE_LIST} " +
- %(--namespace "#{namespace}" -o name)])
- .and_return(Gitlab::Popen::Result.new([], raw_resource_names_str, '', double(success?: true)))
-
- expect(subject.__send__(:raw_resource_names)).to eq(raw_resource_names)
+ describe '#namespaces_created_before' do
+ subject { instance.namespaces_created_before(created_before: two_days_ago) }
+
+ let(:namespace_1_created_at) { three_days_ago }
+ let(:namespace_2_created_at) { one_day_ago }
+ let(:namespace_1_name) { 'review-first-review-app' }
+ let(:namespace_2_name) { 'review-second-review-app' }
+ let(:kubectl_namespaces_json) do
+ <<~JSON
+ {
+ "apiVersion": "v1",
+ "items": [
+ {
+ "apiVersion": "v1",
+ "kind": "namespace",
+ "metadata": {
+ "creationTimestamp": "#{namespace_1_created_at.utc.iso8601}",
+ "name": "#{namespace_1_name}"
+ }
+ },
+ {
+ "apiVersion": "v1",
+ "kind": "namespace",
+ "metadata": {
+ "creationTimestamp": "#{namespace_2_created_at.utc.iso8601}",
+ "name": "#{namespace_2_name}"
+ }
+ }
+ ]
+ }
+ JSON
+ end
+
+ it 'returns an array of namespaces' do
+ allow(instance).to receive(:run_command).with(
+ "kubectl get namespace --all-namespaces --sort-by='{.metadata.creationTimestamp}' -o json"
+ ).and_return(kubectl_namespaces_json)
+
+ expect(subject).to match_array(%w[review-first-review-app])
end
end
- describe '#resource_names_created_before' do
- let(:three_days_ago) { Time.now - 3600 * 24 * 3 }
- let(:two_days_ago) { Time.now - 3600 * 24 * 2 }
- let(:pvc_created_three_days_ago) { 'pvc-created-three-days-ago' }
- let(:resource_type) { 'pvc' }
- let(:raw_resources) do
- {
- items: [
- {
- apiVersion: "v1",
- kind: "PersistentVolumeClaim",
- metadata: {
- creationTimestamp: three_days_ago,
- name: pvc_created_three_days_ago
- }
- },
- {
- apiVersion: "v1",
- kind: "PersistentVolumeClaim",
- metadata: {
- creationTimestamp: Time.now,
- name: 'another-pvc'
- }
- }
- ]
- }.to_json
- end
+ describe '#run_command' do
+ subject { instance.run_command(command) }
- shared_examples 'a kubectl command to retrieve resource names sorted by creationTimestamp' do
- specify do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with(["kubectl get #{resource_type} ".squeeze(' ') +
- %(--namespace "#{namespace}" ) +
- "--sort-by='{.metadata.creationTimestamp}' -o json"])
- .and_return(Gitlab::Popen::Result.new([], raw_resources, '', double(success?: true)))
+ before do
+ # We undo the global mock just for this method
+ allow(instance).to receive(:run_command).and_call_original
- expect(subject.__send__(:resource_names_created_before, resource_type: resource_type, created_before: two_days_ago)).to contain_exactly(pvc_created_three_days_ago)
- end
+ # Mock stdout
+ allow(instance).to receive(:puts)
end
- it_behaves_like 'a kubectl command to retrieve resource names sorted by creationTimestamp'
-
- context 'with no resource_type given' do
- let(:resource_type) { nil }
+ context 'when executing a successful command' do
+ let(:command) { 'true' } # https://linux.die.net/man/1/true
- it_behaves_like 'a kubectl command to retrieve resource names sorted by creationTimestamp'
- end
+ it 'displays the name of the command to stdout' do
+ expect(instance).to receive(:puts).with("Running command: `#{command}`")
- context 'with multiple resource_type given' do
- let(:resource_type) { 'pvc,service' }
+ subject
+ end
- it_behaves_like 'a kubectl command to retrieve resource names sorted by creationTimestamp'
+ it 'does not raise an error' do
+ expect { subject }.not_to raise_error
+ end
end
- end
- describe '#review_app_namespaces_created_before' do
- let(:three_days_ago) { Time.now - 3600 * 24 * 3 }
- let(:two_days_ago) { Time.now - 3600 * 24 * 2 }
- let(:namespace_created_three_days_ago) { 'review-ns-created-three-days-ago' }
- let(:resource_type) { 'namespace' }
- let(:raw_resources) do
- {
- items: [
- {
- apiVersion: "v1",
- kind: "Namespace",
- metadata: {
- creationTimestamp: three_days_ago,
- name: namespace_created_three_days_ago
- }
- },
- {
- apiVersion: "v1",
- kind: "Namespace",
- metadata: {
- creationTimestamp: Time.now,
- name: 'another-namespace'
- }
- }
- ]
- }.to_json
- end
+ context 'when executing an unsuccessful command' do
+ let(:command) { 'false' } # https://linux.die.net/man/1/false
- specify do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with(["kubectl get namespace --sort-by='{.metadata.creationTimestamp}' -o json"])
- .and_return(Gitlab::Popen::Result.new([], raw_resources, '', double(success?: true)))
+ it 'displays the name of the command to stdout' do
+ expect(instance).to receive(:puts).with("Running command: `#{command}`")
- expect(subject.__send__(:review_app_namespaces_created_before, created_before: two_days_ago)).to eq([namespace_created_three_days_ago])
+ expect { subject }.to raise_error(described_class::CommandFailedError)
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(described_class::CommandFailedError)
+ end
end
end
end
diff --git a/spec/tooling/lib/tooling/mappings/base_spec.rb b/spec/tooling/lib/tooling/mappings/base_spec.rb
deleted file mode 100644
index 935f833fa8b..00000000000
--- a/spec/tooling/lib/tooling/mappings/base_spec.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../../../../tooling/lib/tooling/mappings/view_to_js_mappings'
-
-RSpec.describe Tooling::Mappings::Base, feature_category: :tooling do
- describe '#folders_for_available_editions' do
- let(:base_folder_path) { 'app/views' }
-
- subject { described_class.new.folders_for_available_editions(base_folder_path) }
-
- context 'when FOSS' do
- before do
- allow(GitlabEdition).to receive(:ee?).and_return(false)
- allow(GitlabEdition).to receive(:jh?).and_return(false)
- end
-
- it 'returns the correct paths' do
- expect(subject).to match_array([base_folder_path])
- end
- end
-
- context 'when EE' do
- before do
- allow(GitlabEdition).to receive(:ee?).and_return(true)
- allow(GitlabEdition).to receive(:jh?).and_return(false)
- end
-
- it 'returns the correct paths' do
- expect(subject).to match_array([base_folder_path, "ee/#{base_folder_path}"])
- end
- end
-
- context 'when JiHu' do
- before do
- allow(GitlabEdition).to receive(:ee?).and_return(true)
- allow(GitlabEdition).to receive(:jh?).and_return(true)
- end
-
- it 'returns the correct paths' do
- expect(subject).to match_array([base_folder_path, "ee/#{base_folder_path}", "jh/#{base_folder_path}"])
- end
- end
- end
-end
diff --git a/spec/tooling/lib/tooling/mappings/graphql_base_type_mappings_spec.rb b/spec/tooling/lib/tooling/mappings/graphql_base_type_mappings_spec.rb
new file mode 100644
index 00000000000..b6459428214
--- /dev/null
+++ b/spec/tooling/lib/tooling/mappings/graphql_base_type_mappings_spec.rb
@@ -0,0 +1,251 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+require_relative '../../../../../tooling/lib/tooling/mappings/graphql_base_type_mappings'
+
+RSpec.describe Tooling::Mappings::GraphqlBaseTypeMappings, feature_category: :tooling do
+ # We set temporary folders, and those readers give access to those folder paths
+ attr_accessor :foss_folder, :ee_folder, :jh_folder
+ attr_accessor :changed_files_file, :predictive_tests_file
+
+ let(:changed_files_pathname) { changed_files_file.path }
+ let(:predictive_tests_pathname) { predictive_tests_file.path }
+ let(:instance) { described_class.new(changed_files_pathname, predictive_tests_pathname) }
+ let(:changed_files_content) { "changed_file1 changed_file2" }
+ let(:predictive_tests_initial_content) { "previously_matching_spec.rb" }
+
+ around do |example|
+ self.changed_files_file = Tempfile.new('changed_files_file')
+ self.predictive_tests_file = Tempfile.new('predictive_tests_file')
+
+ Dir.mktmpdir('FOSS') do |foss_folder|
+ Dir.mktmpdir('EE') do |ee_folder|
+ Dir.mktmpdir('JH') do |jh_folder|
+ self.foss_folder = foss_folder
+ self.ee_folder = ee_folder
+ self.jh_folder = jh_folder
+
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ example.run
+ ensure
+ changed_files_file.close
+ predictive_tests_file.close
+ changed_files_file.unlink
+ predictive_tests_file.unlink
+ end
+ end
+ end
+ end
+ end
+
+ before do
+ stub_const("Tooling::Mappings::GraphqlBaseTypeMappings::GRAPHQL_TYPES_FOLDERS", {
+ nil => [foss_folder],
+ 'ee' => [foss_folder, ee_folder],
+ 'jh' => [foss_folder, ee_folder, jh_folder]
+ })
+
+ # We write into the temp files initially, to later check how the code modified those files
+ File.write(changed_files_pathname, changed_files_content)
+ File.write(predictive_tests_pathname, predictive_tests_initial_content)
+ end
+
+ describe '#execute' do
+ subject { instance.execute }
+
+ context 'when no GraphQL files were changed' do
+ let(:changed_files_content) { '' }
+
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(predictive_tests_pathname) }
+ end
+ end
+
+ context 'when some GraphQL files were changed' do
+ let(:changed_files_content) do
+ [
+ "#{foss_folder}/my_graphql_file.rb",
+ "#{foss_folder}/my_other_graphql_file.rb"
+ ].join(' ')
+ end
+
+ context 'when none of those GraphQL types are included in other GraphQL types' do
+ before do
+ File.write("#{foss_folder}/my_graphql_file.rb", "some graphQL code; implements-test MyOtherGraphqlFile")
+ File.write("#{foss_folder}/my_other_graphql_file.rb", "some graphQL code")
+ end
+
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(predictive_tests_pathname) }
+ end
+ end
+
+ context 'when the GraphQL types are included in other GraphQL types' do
+ before do
+ File.write("#{foss_folder}/my_graphql_file.rb", "some graphQL code; implements MyOtherGraphqlFile")
+ File.write("#{foss_folder}/my_other_graphql_file.rb", "some graphQL code")
+
+ # We mock this because we are using temp directories, so we cannot rely on just replacing `app`` with `spec`
+ allow(instance).to receive(:filename_to_spec_filename)
+ .with("#{foss_folder}/my_graphql_file.rb")
+ .and_return('spec/my_graphql_file_spec.rb')
+ end
+
+ it 'writes the correct specs in the output' do
+ expect { subject }.to change { File.read(predictive_tests_pathname) }
+ .from(predictive_tests_initial_content)
+ .to("#{predictive_tests_initial_content} spec/my_graphql_file_spec.rb")
+ end
+ end
+ end
+ end
+
+ describe '#filter_files' do
+ subject { instance.filter_files }
+
+ before do
+ File.write("#{foss_folder}/my_graphql_file.rb", "my_graphql_file.rb")
+ File.write("#{foss_folder}/my_other_graphql_file.rb", "my_other_graphql_file.rb")
+ File.write("#{foss_folder}/another_file.erb", "another_file.erb")
+ end
+
+ context 'when no files were changed' do
+ let(:changed_files_content) { '' }
+
+ it 'returns an empty array' do
+ expect(subject).to match_array([])
+ end
+ end
+
+ context 'when GraphQL files were changed' do
+ let(:changed_files_content) do
+ [
+ "#{foss_folder}/my_graphql_file.rb",
+ "#{foss_folder}/my_other_graphql_file.rb",
+ "#{foss_folder}/another_file.erb"
+ ].join(' ')
+ end
+
+ it 'returns the path to the GraphQL files' do
+ expect(subject).to match_array([
+ "#{foss_folder}/my_graphql_file.rb",
+ "#{foss_folder}/my_other_graphql_file.rb"
+ ])
+ end
+ end
+
+ context 'when files are deleted' do
+ let(:changed_files_content) { "#{foss_folder}/deleted.rb" }
+
+ it 'returns an empty array' do
+ expect(subject).to match_array([])
+ end
+ end
+ end
+
+ describe '#types_hierarchies' do
+ subject { instance.types_hierarchies }
+
+ context 'when no types are implementing other types' do
+ before do
+ File.write("#{foss_folder}/foss_file.rb", "some graphQL code")
+ File.write("#{ee_folder}/ee_file.rb", "some graphQL code")
+ File.write("#{jh_folder}/jh_file.rb", "some graphQL code")
+ end
+
+ it 'returns nothing' do
+ expect(subject).to eq(
+ nil => {},
+ 'ee' => {},
+ 'jh' => {}
+ )
+ end
+ end
+
+ context 'when types are implementing other types' do
+ before do
+ File.write("#{foss_folder}/foss_file.rb", "some graphQL code; implements NoteableInterface")
+ File.write("#{ee_folder}/ee_file.rb", "some graphQL code; implements NoteableInterface")
+ File.write("#{jh_folder}/jh_file.rb", "some graphQL code; implements NoteableInterface")
+ end
+
+ context 'when FOSS' do
+ it 'returns only FOSS types' do
+ expect(subject).to include(
+ nil => {
+ 'NoteableInterface' => [
+ "#{foss_folder}/foss_file.rb"
+ ]
+ }
+ )
+ end
+ end
+
+ context 'when EE' do
+ it 'returns the correct children types' do
+ expect(subject).to include(
+ 'ee' => {
+ 'NoteableInterface' => [
+ "#{foss_folder}/foss_file.rb",
+ "#{ee_folder}/ee_file.rb"
+ ]
+ }
+ )
+ end
+ end
+
+ context 'when JH' do
+ it 'returns the correct children types' do
+ expect(subject).to include(
+ 'jh' => {
+ 'NoteableInterface' => [
+ "#{foss_folder}/foss_file.rb",
+ "#{ee_folder}/ee_file.rb",
+ "#{jh_folder}/jh_file.rb"
+ ]
+ }
+ )
+ end
+ end
+ end
+ end
+
+ describe '#filename_to_class_name' do
+ let(:filename) { 'app/graphql/types/user_merge_request_interaction_type.rb' }
+
+ subject { instance.filename_to_class_name(filename) }
+
+ it 'returns the correct class name' do
+ expect(subject).to eq('UserMergeRequestInteractionType')
+ end
+ end
+
+ describe '#filename_to_spec_filename' do
+ let(:filename) { 'ee/app/graphql/ee/types/application_type.rb' }
+ let(:expected_spec_filename) { 'ee/spec/graphql/ee/types/application_type_spec.rb' }
+
+ subject { instance.filename_to_spec_filename(filename) }
+
+ context 'when the spec file exists' do
+ before do
+ allow(File).to receive(:exist?).with(expected_spec_filename).and_return(true)
+ end
+
+ it 'returns the correct spec filename' do
+ expect(subject).to eq(expected_spec_filename)
+ end
+ end
+
+ context 'when the spec file does not exist' do
+ before do
+ allow(File).to receive(:exist?).with(expected_spec_filename).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(subject).to eq(nil)
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/mappings/js_to_system_specs_mappings_spec.rb b/spec/tooling/lib/tooling/mappings/js_to_system_specs_mappings_spec.rb
index 72e02547938..e1f35bedebb 100644
--- a/spec/tooling/lib/tooling/mappings/js_to_system_specs_mappings_spec.rb
+++ b/spec/tooling/lib/tooling/mappings/js_to_system_specs_mappings_spec.rb
@@ -6,33 +6,63 @@ require_relative '../../../../../tooling/lib/tooling/mappings/js_to_system_specs
RSpec.describe Tooling::Mappings::JsToSystemSpecsMappings, feature_category: :tooling do
# We set temporary folders, and those readers give access to those folder paths
attr_accessor :js_base_folder, :system_specs_base_folder
+ attr_accessor :changed_files_file, :predictive_tests_file
+
+ let(:changed_files_pathname) { changed_files_file.path }
+ let(:predictive_tests_pathname) { predictive_tests_file.path }
+ let(:changed_files_content) { "changed_file1 changed_file2" }
+ let(:predictive_tests_content) { "previously_matching_spec.rb" }
+
+ let(:instance) do
+ described_class.new(
+ changed_files_pathname,
+ predictive_tests_pathname,
+ system_specs_base_folder: system_specs_base_folder,
+ js_base_folder: js_base_folder
+ )
+ end
around do |example|
+ self.changed_files_file = Tempfile.new('changed_files_file')
+ self.predictive_tests_file = Tempfile.new('predictive_tests_file')
+
Dir.mktmpdir do |tmp_js_base_folder|
Dir.mktmpdir do |tmp_system_specs_base_folder|
self.system_specs_base_folder = tmp_system_specs_base_folder
self.js_base_folder = tmp_js_base_folder
- example.run
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ example.run
+ ensure
+ changed_files_file.close
+ predictive_tests_file.close
+ changed_files_file.unlink
+ predictive_tests_file.unlink
+ end
end
end
end
+ before do
+ # We write into the temp files initially, to later check how the code modified those files
+ File.write(changed_files_pathname, changed_files_content)
+ File.write(predictive_tests_pathname, predictive_tests_content)
+ end
+
describe '#execute' do
- let(:instance) do
- described_class.new(
- system_specs_base_folder: system_specs_base_folder,
- js_base_folder: js_base_folder
- )
- end
+ subject { instance.execute }
- subject { instance.execute(changed_files) }
+ before do
+ File.write(changed_files_pathname, changed_files.join(' '))
+ end
context 'when no JS files were changed' do
let(:changed_files) { [] }
- it 'returns nothing' do
- expect(subject).to match_array([])
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(predictive_tests_pathname) }
end
end
@@ -40,8 +70,8 @@ RSpec.describe Tooling::Mappings::JsToSystemSpecsMappings, feature_category: :to
let(:changed_files) { ["#{js_base_folder}/issues/secret_values.js"] }
context 'when the JS files are not present on disk' do
- it 'returns nothing' do
- expect(subject).to match_array([])
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(predictive_tests_pathname) }
end
end
@@ -52,8 +82,8 @@ RSpec.describe Tooling::Mappings::JsToSystemSpecsMappings, feature_category: :to
end
context 'when no system specs match the JS keyword' do
- it 'returns nothing' do
- expect(subject).to match_array([])
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(predictive_tests_pathname) }
end
end
@@ -63,8 +93,10 @@ RSpec.describe Tooling::Mappings::JsToSystemSpecsMappings, feature_category: :to
File.write("#{system_specs_base_folder}/confidential_issues/issues_spec.rb", "a test")
end
- it 'returns something' do
- expect(subject).to match_array(["#{system_specs_base_folder}/confidential_issues/issues_spec.rb"])
+ it 'adds the new specs to the output file' do
+ expect { subject }.to change { File.read(predictive_tests_pathname) }
+ .from(predictive_tests_content)
+ .to("#{predictive_tests_content} #{system_specs_base_folder}/confidential_issues/issues_spec.rb")
end
end
end
@@ -72,12 +104,13 @@ RSpec.describe Tooling::Mappings::JsToSystemSpecsMappings, feature_category: :to
end
describe '#filter_files' do
- subject { described_class.new(js_base_folder: js_base_folder).filter_files(changed_files) }
+ subject { instance.filter_files }
before do
File.write("#{js_base_folder}/index.js", "index.js")
File.write("#{js_base_folder}/index-with-ee-in-it.js", "index-with-ee-in-it.js")
File.write("#{js_base_folder}/index-with-jh-in-it.js", "index-with-jh-in-it.js")
+ File.write(changed_files_pathname, changed_files.join(' '))
end
context 'when no files were changed' do
@@ -117,7 +150,7 @@ RSpec.describe Tooling::Mappings::JsToSystemSpecsMappings, feature_category: :to
end
describe '#construct_js_keywords' do
- subject { described_class.new.construct_js_keywords(js_files) }
+ subject { described_class.new(changed_files_file, predictive_tests_file).construct_js_keywords(js_files) }
let(:js_files) do
%w[
@@ -129,11 +162,49 @@ RSpec.describe Tooling::Mappings::JsToSystemSpecsMappings, feature_category: :to
it 'returns a singularized keyword based on the first folder the file is in' do
expect(subject).to eq(%w[board query])
end
+
+ context 'when the files are under the pages folder' do
+ let(:js_files) do
+ %w[
+ app/assets/javascripts/pages/boards/issue_board_filters.js
+ ee/app/assets/javascripts/pages2/queries/epic_due_date.query.graphql
+ ee/app/assets/javascripts/queries/epic_due_date.query.graphql
+ ]
+ end
+
+ it 'captures the second folder' do
+ expect(subject).to eq(%w[board pages2 query])
+ end
+ end
end
describe '#system_specs_for_edition' do
subject do
- described_class.new(system_specs_base_folder: system_specs_base_folder).system_specs_for_edition(edition)
+ instance.system_specs_for_edition(edition)
+ end
+
+ let(:edition) { nil }
+
+ context 'when a file is not a ruby spec' do
+ before do
+ File.write("#{system_specs_base_folder}/issues_spec.tar.gz", "a test")
+ end
+
+ it 'does not return that file' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when a file is a ruby spec' do
+ let(:spec_pathname) { "#{system_specs_base_folder}/issues_spec.rb" }
+
+ before do
+ File.write(spec_pathname, "a test")
+ end
+
+ it 'returns that file' do
+ expect(subject).to match_array(spec_pathname)
+ end
end
context 'when FOSS' do
diff --git a/spec/tooling/lib/tooling/mappings/partial_to_views_mappings_spec.rb b/spec/tooling/lib/tooling/mappings/partial_to_views_mappings_spec.rb
new file mode 100644
index 00000000000..75ddee18985
--- /dev/null
+++ b/spec/tooling/lib/tooling/mappings/partial_to_views_mappings_spec.rb
@@ -0,0 +1,280 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+require 'fileutils'
+require_relative '../../../../../tooling/lib/tooling/mappings/partial_to_views_mappings'
+
+RSpec.describe Tooling::Mappings::PartialToViewsMappings, feature_category: :tooling do
+ attr_accessor :view_base_folder, :changed_files_file, :views_with_partials_file
+
+ let(:instance) do
+ described_class.new(changed_files_pathname, views_with_partials_pathname, view_base_folder: view_base_folder)
+ end
+
+ let(:changed_files_pathname) { changed_files_file.path }
+ let(:views_with_partials_pathname) { views_with_partials_file.path }
+ let(:changed_files_content) { "changed_file1 changed_file2" }
+ let(:views_with_partials_content) { "previously_added_view.html.haml" }
+
+ around do |example|
+ self.changed_files_file = Tempfile.new('changed_files_file')
+ self.views_with_partials_file = Tempfile.new('views_with_partials_file')
+
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ Dir.mktmpdir do |tmp_views_base_folder|
+ self.view_base_folder = tmp_views_base_folder
+ example.run
+ end
+ ensure
+ changed_files_file.close
+ views_with_partials_file.close
+ changed_files_file.unlink
+ views_with_partials_file.unlink
+ end
+ end
+
+ before do
+ # We write into the temp files initially, to check how the code modified those files
+ File.write(changed_files_pathname, changed_files_content)
+ File.write(views_with_partials_pathname, views_with_partials_content)
+ end
+
+ describe '#execute' do
+ subject { instance.execute }
+
+ let(:changed_files) { ["#{view_base_folder}/my_view.html.haml"] }
+ let(:changed_files_content) { changed_files.join(" ") }
+
+ before do
+ # We create all of the changed_files, so that they are part of the filtered files
+ changed_files.each { |changed_file| FileUtils.touch(changed_file) }
+ end
+
+ it 'does not modify the content of the input file' do
+ expect { subject }.not_to change { File.read(changed_files_pathname) }
+ end
+
+ context 'when no partials were modified' do
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(views_with_partials_pathname) }
+ end
+ end
+
+ context 'when some partials were modified' do
+ let(:changed_files) do
+ [
+ "#{view_base_folder}/my_view.html.haml",
+ "#{view_base_folder}/_my_partial.html.haml",
+ "#{view_base_folder}/_my_other_partial.html.haml"
+ ]
+ end
+
+ before do
+ # We create a red-herring partial to have a more convincing test suite
+ FileUtils.touch("#{view_base_folder}/_another_partial.html.haml")
+ end
+
+ context 'when the partials are not included in any views' do
+ before do
+ File.write("#{view_base_folder}/my_view.html.haml", "render 'another_partial'")
+ end
+
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(views_with_partials_pathname) }
+ end
+ end
+
+ context 'when the partials are included in views' do
+ before do
+ File.write("#{view_base_folder}/my_view.html.haml", "render 'my_partial'")
+ end
+
+ it 'writes the view including the partial to the output' do
+ expect { subject }.to change { File.read(views_with_partials_pathname) }
+ .from(views_with_partials_content)
+ .to(views_with_partials_content + " #{view_base_folder}/my_view.html.haml")
+ end
+ end
+ end
+ end
+
+ describe '#filter_files' do
+ subject { instance.filter_files }
+
+ let(:changed_files_content) { file_path }
+
+ context 'when the file does not exist on disk' do
+ let(:file_path) { "#{view_base_folder}/_index.html.erb" }
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when the file exists on disk' do
+ before do
+ File.write(file_path, "I am a partial!")
+ end
+
+ context 'when the file is not in the view base folders' do
+ let(:file_path) { "/tmp/_index.html.haml" }
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when the filename does not start with an underscore' do
+ let(:file_path) { "#{view_base_folder}/index.html.haml" }
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when the filename does not have the correct extension' do
+ let(:file_path) { "#{view_base_folder}/_index.html.erb" }
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when the file is a partial' do
+ let(:file_path) { "#{view_base_folder}/_index.html.haml" }
+
+ it 'returns the file' do
+ expect(subject).to match_array(file_path)
+ end
+ end
+ end
+ end
+
+ describe '#extract_partial_keyword' do
+ subject { instance.extract_partial_keyword('ee/app/views/shared/_new_project_item_vue_select.html.haml') }
+
+ it 'returns the correct partial keyword' do
+ expect(subject).to eq('new_project_item_vue_select')
+ end
+ end
+
+ describe '#view_includes_modified_partial?' do
+ subject { instance.view_includes_modified_partial?(view_file, included_partial_name) }
+
+ context 'when the included partial name is relative to the view file' do
+ let(:view_file) { "#{view_base_folder}/components/my_view.html.haml" }
+ let(:included_partial_name) { 'subfolder/relative_partial' }
+
+ before do
+ FileUtils.mkdir_p("#{view_base_folder}/components/subfolder")
+ File.write(changed_files_content, "I am a partial!")
+ end
+
+ context 'when the partial is not part of the changed files' do
+ let(:changed_files_content) { "#{view_base_folder}/components/subfolder/_not_the_partial.html.haml" }
+
+ it 'returns false' do
+ expect(subject).to be_falsey
+ end
+ end
+
+ context 'when the partial is part of the changed files' do
+ let(:changed_files_content) { "#{view_base_folder}/components/subfolder/_relative_partial.html.haml" }
+
+ it 'returns true' do
+ expect(subject).to be_truthy
+ end
+ end
+ end
+
+ context 'when the included partial name is relative to the base views folder' do
+ let(:view_file) { "#{view_base_folder}/components/my_view.html.haml" }
+ let(:included_partial_name) { 'shared/absolute_partial' }
+
+ before do
+ FileUtils.mkdir_p("#{view_base_folder}/components")
+ FileUtils.mkdir_p("#{view_base_folder}/shared")
+ File.write(changed_files_content, "I am a partial!")
+ end
+
+ context 'when the partial is not part of the changed files' do
+ let(:changed_files_content) { "#{view_base_folder}/shared/not_the_partial" }
+
+ it 'returns false' do
+ expect(subject).to be_falsey
+ end
+ end
+
+ context 'when the partial is part of the changed files' do
+ let(:changed_files_content) { "#{view_base_folder}/shared/_absolute_partial.html.haml" }
+
+ it 'returns true' do
+ expect(subject).to be_truthy
+ end
+ end
+ end
+ end
+
+ describe '#reconstruct_partial_filename' do
+ subject { instance.reconstruct_partial_filename(partial_name) }
+
+ context 'when the partial does not contain a path' do
+ let(:partial_name) { 'sidebar' }
+
+ it 'returns the correct filename' do
+ expect(subject).to eq('_sidebar.html.haml')
+ end
+ end
+
+ context 'when the partial contains a path' do
+ let(:partial_name) { 'shared/components/sidebar' }
+
+ it 'returns the correct filename' do
+ expect(subject).to eq('shared/components/_sidebar.html.haml')
+ end
+ end
+ end
+
+ describe '#find_pattern_in_file' do
+ let(:subject) { instance.find_pattern_in_file(file.path, /pattern/) }
+ let(:file) { Tempfile.new('find_pattern_in_file') }
+
+ before do
+ file.write(file_content)
+ file.close
+ end
+
+ context 'when the file contains the pattern' do
+ let(:file_content) do
+ <<~FILE
+ Beginning of file
+
+ pattern
+ pattern
+ pattern
+
+ End of file
+ FILE
+ end
+
+ it 'returns the pattern once' do
+ expect(subject).to match_array(%w[pattern])
+ end
+ end
+
+ context 'when the file does not contain the pattern' do
+ let(:file_content) do
+ <<~FILE
+ Beginning of file
+ End of file
+ FILE
+ end
+
+ it 'returns an empty array' do
+ expect(subject).to match_array([])
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/mappings/view_to_js_mappings_spec.rb b/spec/tooling/lib/tooling/mappings/view_to_js_mappings_spec.rb
index eaa0124370d..6d007843716 100644
--- a/spec/tooling/lib/tooling/mappings/view_to_js_mappings_spec.rb
+++ b/spec/tooling/lib/tooling/mappings/view_to_js_mappings_spec.rb
@@ -6,37 +6,67 @@ require_relative '../../../../../tooling/lib/tooling/mappings/view_to_js_mapping
RSpec.describe Tooling::Mappings::ViewToJsMappings, feature_category: :tooling do
# We set temporary folders, and those readers give access to those folder paths
attr_accessor :view_base_folder, :js_base_folder
+ attr_accessor :changed_files_file, :predictive_tests_file
+
+ let(:changed_files_pathname) { changed_files_file.path }
+ let(:predictive_tests_pathname) { predictive_tests_file.path }
+ let(:changed_files_content) { "changed_file1 changed_file2" }
+ let(:predictive_tests_content) { "previously_matching_spec.rb" }
+
+ let(:instance) do
+ described_class.new(
+ changed_files_pathname,
+ predictive_tests_pathname,
+ view_base_folder: view_base_folder,
+ js_base_folder: js_base_folder
+ )
+ end
around do |example|
+ self.changed_files_file = Tempfile.new('changed_files_file')
+ self.predictive_tests_file = Tempfile.new('matching_tests')
+
Dir.mktmpdir do |tmp_js_base_folder|
Dir.mktmpdir do |tmp_views_base_folder|
self.js_base_folder = tmp_js_base_folder
self.view_base_folder = tmp_views_base_folder
- example.run
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ example.run
+ ensure
+ changed_files_file.close
+ predictive_tests_file.close
+ changed_files_file.unlink
+ predictive_tests_file.unlink
+ end
end
end
end
- describe '#execute' do
- let(:instance) do
- described_class.new(
- view_base_folder: view_base_folder,
- js_base_folder: js_base_folder
- )
- end
+ before do
+ # We write into the temp files initially, to later check how the code modified those files
+ File.write(changed_files_pathname, changed_files_content)
+ File.write(predictive_tests_pathname, predictive_tests_content)
+ end
+ describe '#execute' do
let(:changed_files) { %W[#{view_base_folder}/index.html] }
- subject { instance.execute(changed_files) }
+ subject { instance.execute }
+
+ before do
+ File.write(changed_files_pathname, changed_files.join(' '))
+ end
context 'when no view files have been changed' do
before do
allow(instance).to receive(:filter_files).and_return([])
end
- it 'returns nothing' do
- expect(subject).to match_array([])
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(predictive_tests_pathname) }
end
end
@@ -53,8 +83,8 @@ RSpec.describe Tooling::Mappings::ViewToJsMappings, feature_category: :tooling d
FILE
end
- it 'returns nothing' do
- expect(subject).to match_array([])
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(predictive_tests_pathname) }
end
end
@@ -70,8 +100,8 @@ RSpec.describe Tooling::Mappings::ViewToJsMappings, feature_category: :tooling d
end
context 'when no matching JS files are found' do
- it 'returns nothing' do
- expect(subject).to match_array([])
+ it 'does not change the output file' do
+ expect { subject }.not_to change { File.read(predictive_tests_pathname) }
end
end
@@ -90,8 +120,10 @@ RSpec.describe Tooling::Mappings::ViewToJsMappings, feature_category: :tooling d
File.write("#{js_base_folder}/index.js", index_js_content)
end
- it 'returns the matching JS files' do
- expect(subject).to match_array(["#{js_base_folder}/index.js"])
+ it 'adds the matching JS files to the output' do
+ expect { subject }.to change { File.read(predictive_tests_pathname) }
+ .from(predictive_tests_content)
+ .to("#{predictive_tests_content} #{js_base_folder}/index.js")
end
end
end
@@ -135,17 +167,20 @@ RSpec.describe Tooling::Mappings::ViewToJsMappings, feature_category: :tooling d
end
it 'scans those partials for the HTML attribute value' do
- expect(subject).to match_array(["#{js_base_folder}/index.js"])
+ expect { subject }.to change { File.read(predictive_tests_pathname) }
+ .from(predictive_tests_content)
+ .to("#{predictive_tests_content} #{js_base_folder}/index.js")
end
end
end
describe '#filter_files' do
- subject { described_class.new(view_base_folder: view_base_folder).filter_files(changed_files) }
+ subject { instance.filter_files }
before do
File.write("#{js_base_folder}/index.js", "index.js")
File.write("#{view_base_folder}/index.html", "index.html")
+ File.write(changed_files_pathname, changed_files.join(' '))
end
context 'when no files were changed' do
@@ -182,7 +217,7 @@ RSpec.describe Tooling::Mappings::ViewToJsMappings, feature_category: :tooling d
end
describe '#find_partials' do
- subject { described_class.new(view_base_folder: view_base_folder).find_partials(file_path) }
+ subject { instance.find_partials(file_path) }
let(:file_path) { "#{view_base_folder}/my_html_file.html" }
@@ -230,12 +265,12 @@ RSpec.describe Tooling::Mappings::ViewToJsMappings, feature_category: :tooling d
= render partial: "subfolder/my-partial4"
= render(partial:"subfolder/my-partial5", path: 'else')
= render partial:"subfolder/my-partial6"
- = render_if_exist("subfolder/my-partial7", path: 'else')
- = render_if_exist "subfolder/my-partial8"
- = render_if_exist(partial: "subfolder/my-partial9", path: 'else')
- = render_if_exist partial: "subfolder/my-partial10"
- = render_if_exist(partial:"subfolder/my-partial11", path: 'else')
- = render_if_exist partial:"subfolder/my-partial12"
+ = render_if_exists("subfolder/my-partial7", path: 'else')
+ = render_if_exists "subfolder/my-partial8"
+ = render_if_exists(partial: "subfolder/my-partial9", path: 'else')
+ = render_if_exists partial: "subfolder/my-partial10"
+ = render_if_exists(partial:"subfolder/my-partial11", path: 'else')
+ = render_if_exists partial:"subfolder/my-partial12"
End of file
FILE
@@ -275,7 +310,7 @@ RSpec.describe Tooling::Mappings::ViewToJsMappings, feature_category: :tooling d
end
describe '#find_pattern_in_file' do
- let(:subject) { described_class.new.find_pattern_in_file(file.path, /pattern/) }
+ let(:subject) { instance.find_pattern_in_file(file.path, /pattern/) }
let(:file) { Tempfile.new('find_pattern_in_file') }
before do
diff --git a/spec/tooling/lib/tooling/mappings/view_to_system_specs_mappings_spec.rb b/spec/tooling/lib/tooling/mappings/view_to_system_specs_mappings_spec.rb
new file mode 100644
index 00000000000..b8a13c50c9b
--- /dev/null
+++ b/spec/tooling/lib/tooling/mappings/view_to_system_specs_mappings_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+require 'fileutils'
+require_relative '../../../../../tooling/lib/tooling/mappings/view_to_system_specs_mappings'
+
+RSpec.describe Tooling::Mappings::ViewToSystemSpecsMappings, feature_category: :tooling do
+ attr_accessor :view_base_folder, :changed_files_file, :predictive_tests_file
+
+ let(:instance) do
+ described_class.new(changed_files_pathname, predictive_tests_pathname, view_base_folder: view_base_folder)
+ end
+
+ let(:changed_files_pathname) { changed_files_file.path }
+ let(:predictive_tests_pathname) { predictive_tests_file.path }
+ let(:changed_files_content) { "changed_file1 changed_file2" }
+ let(:predictive_tests_initial_content) { "previously_added_spec.rb" }
+
+ around do |example|
+ self.changed_files_file = Tempfile.new('changed_files_file')
+ self.predictive_tests_file = Tempfile.new('predictive_tests_file')
+
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ Dir.mktmpdir do |tmp_views_base_folder|
+ self.view_base_folder = tmp_views_base_folder
+ example.run
+ end
+ ensure
+ changed_files_file.close
+ predictive_tests_file.close
+ changed_files_file.unlink
+ predictive_tests_file.unlink
+ end
+ end
+
+ before do
+ FileUtils.mkdir_p("#{view_base_folder}/app/views/dashboard")
+
+ # We write into the temp files initially, to check how the code modified those files
+ File.write(changed_files_pathname, changed_files_content)
+ File.write(predictive_tests_pathname, predictive_tests_initial_content)
+ end
+
+ shared_examples 'writes nothing to the output file' do
+ it 'writes nothing to the output file' do
+ expect { subject }.not_to change { File.read(changed_files_pathname) }
+ end
+ end
+
+ describe '#execute' do
+ subject { instance.execute }
+
+ let(:changed_files) { ["#{view_base_folder}/app/views/dashboard/my_view.html.haml"] }
+ let(:changed_files_content) { changed_files.join(" ") }
+
+ before do
+ # We create all of the changed_files, so that they are part of the filtered files
+ changed_files.each { |changed_file| FileUtils.touch(changed_file) }
+ end
+
+ context 'when the changed files are not view files' do
+ let(:changed_files) { ["#{view_base_folder}/app/views/dashboard/my_helper.rb"] }
+
+ it_behaves_like 'writes nothing to the output file'
+ end
+
+ context 'when the changed files are view files' do
+ let(:changed_files) { ["#{view_base_folder}/app/views/dashboard/my_view.html.haml"] }
+
+ context 'when the view files do not exist on disk' do
+ before do
+ allow(File).to receive(:exist?).with(changed_files.first).and_return(false)
+ end
+
+ it_behaves_like 'writes nothing to the output file'
+ end
+
+ context 'when the view files exist on disk' do
+ context 'when no feature match the view' do
+ # Nothing in this context, because the spec corresponding to `changed_files` doesn't exist
+
+ it_behaves_like 'writes nothing to the output file'
+ end
+
+ context 'when there is a feature spec that exactly matches the view' do
+ let(:expected_feature_spec) { "#{view_base_folder}/spec/features/dashboard/my_view_spec.rb" }
+
+ before do
+ allow(File).to receive(:exist?).and_call_original
+ allow(File).to receive(:exist?).with(expected_feature_spec).and_return(true)
+ end
+
+ it 'writes that feature spec to the output file' do
+ expect { subject }.to change { File.read(predictive_tests_pathname) }
+ .from(predictive_tests_initial_content)
+ .to("#{predictive_tests_initial_content} #{expected_feature_spec}")
+ end
+ end
+
+ context 'when there is a feature spec that matches the parent folder of the view' do
+ let(:expected_feature_specs) do
+ [
+ "#{view_base_folder}/spec/features/dashboard/another_feature_spec.rb",
+ "#{view_base_folder}/spec/features/dashboard/other_feature_spec.rb"
+ ]
+ end
+
+ before do
+ FileUtils.mkdir_p("#{view_base_folder}/spec/features/dashboard")
+
+ expected_feature_specs.each do |expected_feature_spec|
+ FileUtils.touch(expected_feature_spec)
+ end
+ end
+
+ it 'writes all of the feature specs for the parent folder to the output file' do
+ expect { subject }.to change { File.read(predictive_tests_pathname) }
+ .from(predictive_tests_initial_content)
+ .to("#{predictive_tests_initial_content} #{expected_feature_specs.join(' ')}")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb b/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb
index 4b44d991d89..b7b39c37819 100644
--- a/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb
+++ b/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb
@@ -1,90 +1,84 @@
# frozen_string_literal: true
+require 'tempfile'
+
require_relative '../../../../tooling/lib/tooling/parallel_rspec_runner'
RSpec.describe Tooling::ParallelRSpecRunner do # rubocop:disable RSpec/FilePath
describe '#run' do
- let(:allocator) { instance_double(Knapsack::Allocator) }
- let(:rspec_args) { '--seed 123' }
- let(:filter_tests_file) { 'tests.txt' }
- let(:node_tests) { %w[01_spec.rb 03_spec.rb 05_spec.rb] }
- let(:filter_tests) { '01_spec.rb 02_spec.rb 03_spec.rb' }
let(:test_dir) { 'spec' }
+ let(:node_tests) { %w[01_spec.rb 03_spec.rb] }
+ let(:allocator) { instance_double(Knapsack::Allocator, test_dir: test_dir, node_tests: node_tests) }
+ let(:allocator_builder) { double(Knapsack::AllocatorBuilder, allocator: allocator) } # rubocop:disable RSpec/VerifiedDoubles
+
+ let(:filter_tests) { [] }
+ let(:filter_tests_file) { nil }
+ let(:filter_tests_file_path) { nil }
before do
+ allow(Knapsack::AllocatorBuilder).to receive(:new).and_return(allocator_builder)
allow(Knapsack.logger).to receive(:info)
- allow(allocator).to receive(:node_tests).and_return(node_tests)
- allow(allocator).to receive(:test_dir).and_return(test_dir)
- allow(File).to receive(:exist?).with(filter_tests_file).and_return(true)
- allow(File).to receive(:read).and_call_original
- allow(File).to receive(:read).with(filter_tests_file).and_return(filter_tests)
- allow(subject).to receive(:exec)
end
- subject { described_class.new(allocator: allocator, filter_tests_file: filter_tests_file, rspec_args: rspec_args) }
-
- shared_examples 'runs node tests' do
- it 'runs rspec with tests allocated for this node' do
- expect_command(%w[bundle exec rspec --seed 123 --default-path spec -- 01_spec.rb 03_spec.rb 05_spec.rb])
-
- subject.run
+ after do
+ if filter_tests_file.respond_to?(:close)
+ filter_tests_file.close
+ File.unlink(filter_tests_file)
end
end
- context 'given filter tests' do
- it 'reads filter tests file for list of tests' do
- expect(File).to receive(:read).with(filter_tests_file)
+ subject { described_class.new(filter_tests_file: filter_tests_file_path, rspec_args: rspec_args) }
- subject.run
- end
+ shared_examples 'runs node tests' do
+ let(:rspec_args) { nil }
- it 'runs rspec filter tests that are allocated for this node' do
- expect_command(%w[bundle exec rspec --seed 123 --default-path spec -- 01_spec.rb 03_spec.rb])
+ it 'runs rspec with tests allocated for this node' do
+ expect(allocator_builder).to receive(:filter_tests=).with(filter_tests)
+ expect_command(%W[bundle exec rspec#{rspec_args} --] + node_tests)
subject.run
end
-
- context 'when there is no intersect between allocated tests and filtered tests' do
- let(:filter_tests) { '99_spec.rb' }
-
- it 'does not run rspec' do
- expect(subject).not_to receive(:exec)
-
- subject.run
- end
- end
end
- context 'with empty filter tests file' do
- let(:filter_tests) { '' }
+ context 'without filter_tests_file option' do
+ subject { described_class.new(rspec_args: rspec_args) }
it_behaves_like 'runs node tests'
end
- context 'without filter_tests_file option' do
- let(:filter_tests_file) { nil }
+ context 'given filter tests file' do
+ let(:filter_tests_file) do
+ Tempfile.create.tap do |f| # rubocop:disable Rails/SaveBang
+ f.write(filter_tests.join(' '))
+ f.rewind
+ end
+ end
- it_behaves_like 'runs node tests'
- end
+ let(:filter_tests_file_path) { filter_tests_file.path }
- context 'if filter_tests_file does not exist' do
- before do
- allow(File).to receive(:exist?).with(filter_tests_file).and_return(false)
+ context 'when filter_tests_file is empty' do
+ it_behaves_like 'runs node tests'
end
- it_behaves_like 'runs node tests'
- end
+ context 'when filter_tests_file does not exist' do
+ let(:filter_tests_file_path) { 'doesnt_exist' }
- context 'without rspec args' do
- let(:rspec_args) { nil }
+ it_behaves_like 'runs node tests'
+ end
- it 'runs rspec with without extra arguments' do
- expect_command(%w[bundle exec rspec --default-path spec -- 01_spec.rb 03_spec.rb])
+ context 'when filter_tests_file is not empty' do
+ let(:filter_tests) { %w[01_spec.rb 02_spec.rb 03_spec.rb] }
- subject.run
+ it_behaves_like 'runs node tests'
end
end
+ context 'with rspec args' do
+ let(:rspec_args) { ' --seed 123' }
+
+ it_behaves_like 'runs node tests'
+ end
+
def expect_command(cmd)
expect(subject).to receive(:exec).with(*cmd)
end
diff --git a/spec/tooling/lib/tooling/predictive_tests_spec.rb b/spec/tooling/lib/tooling/predictive_tests_spec.rb
new file mode 100644
index 00000000000..b82364fe6f6
--- /dev/null
+++ b/spec/tooling/lib/tooling/predictive_tests_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+require 'fileutils'
+require_relative '../../../../tooling/lib/tooling/predictive_tests'
+require_relative '../../../support/helpers/stub_env'
+
+RSpec.describe Tooling::PredictiveTests, feature_category: :tooling do
+ include StubENV
+
+ let(:instance) { described_class.new }
+ let(:matching_tests_initial_content) { 'initial_matching_spec' }
+ let(:fixtures_mapping_content) { '{}' }
+
+ attr_accessor :changed_files, :changed_files_path, :fixtures_mapping,
+ :matching_js_files, :matching_tests, :views_with_partials
+
+ around do |example|
+ self.changed_files = Tempfile.new('test-folder/changed_files.txt')
+ self.changed_files_path = changed_files.path
+ self.fixtures_mapping = Tempfile.new('test-folder/fixtures_mapping.txt')
+ self.matching_js_files = Tempfile.new('test-folder/matching_js_files.txt')
+ self.matching_tests = Tempfile.new('test-folder/matching_tests.txt')
+ self.views_with_partials = Tempfile.new('test-folder/views_with_partials.txt')
+
+ # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/
+ # Tempfile.html#class-Tempfile-label-Explicit+close
+ begin
+ # In practice, we let PredictiveTests create the file, and we just
+ # use its file name.
+ changed_files.close
+ changed_files.unlink
+
+ example.run
+ ensure
+ # Since example.run can create the file again, let's remove it again
+ FileUtils.rm_f(changed_files_path)
+ fixtures_mapping.close
+ fixtures_mapping.unlink
+ matching_js_files.close
+ matching_js_files.unlink
+ matching_tests.close
+ matching_tests.unlink
+ views_with_partials.close
+ views_with_partials.unlink
+ end
+ end
+
+ before do
+ stub_env(
+ 'RSPEC_CHANGED_FILES_PATH' => changed_files_path,
+ 'RSPEC_MATCHING_TESTS_PATH' => matching_tests.path,
+ 'RSPEC_VIEWS_INCLUDING_PARTIALS_PATH' => views_with_partials.path,
+ 'FRONTEND_FIXTURES_MAPPING_PATH' => fixtures_mapping.path,
+ 'RSPEC_MATCHING_JS_FILES_PATH' => matching_js_files.path,
+ 'RSPEC_TESTS_MAPPING_ENABLED' => "false",
+ 'RSPEC_TESTS_MAPPING_PATH' => '/tmp/does-not-exist.out'
+ )
+
+ # We write some data to later on verify that we only append to this file.
+ File.write(matching_tests.path, matching_tests_initial_content)
+ File.write(fixtures_mapping.path, fixtures_mapping_content)
+
+ allow(Gitlab).to receive(:configure)
+ end
+
+ describe '#execute' do
+ subject { instance.execute }
+
+ context 'when ENV variables are missing' do
+ before do
+ stub_env(
+ 'RSPEC_CHANGED_FILES_PATH' => '',
+ 'FRONTEND_FIXTURES_MAPPING_PATH' => ''
+ )
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(
+ '[predictive tests] Missing ENV variable(s): RSPEC_CHANGED_FILES_PATH,FRONTEND_FIXTURES_MAPPING_PATH.'
+ )
+ end
+ end
+
+ context 'when all ENV variables are provided' do
+ before do
+ change = double('GitLab::Change') # rubocop:disable RSpec/VerifiedDoubles
+ allow(change).to receive_message_chain(:to_h, :values_at)
+ .and_return([changed_files_content, changed_files_content])
+
+ allow(Gitlab).to receive_message_chain(:merge_request_changes, :changes)
+ .and_return([change])
+ end
+
+ context 'when no files were changed' do
+ let(:changed_files_content) { '' }
+
+ it 'does not change files other than RSPEC_CHANGED_FILES_PATH' do
+ expect { subject }.not_to change { File.read(matching_tests.path) }
+ expect { subject }.not_to change { File.read(views_with_partials.path) }
+ expect { subject }.not_to change { File.read(fixtures_mapping.path) }
+ expect { subject }.not_to change { File.read(matching_js_files.path) }
+ end
+ end
+
+ context 'when some files used for frontend fixtures were changed' do
+ let(:changed_files_content) { 'app/models/todo.rb' }
+ let(:changed_files_matching_test) { 'spec/models/todo_spec.rb' }
+ let(:matching_frontend_fixture) { 'tmp/tests/frontend/fixtures-ee/todos/todos.html' }
+ let(:fixtures_mapping_content) do
+ JSON.dump(changed_files_matching_test => [matching_frontend_fixture]) # rubocop:disable Gitlab/Json
+ end
+
+ it 'writes to RSPEC_CHANGED_FILES_PATH with API contents and appends with matching fixtures' do
+ subject
+
+ expect(File.read(changed_files_path)).to eq("#{changed_files_content} #{matching_frontend_fixture}")
+ end
+
+ it 'appends the spec file to RSPEC_MATCHING_TESTS_PATH' do
+ expect { subject }.to change { File.read(matching_tests.path) }
+ .from(matching_tests_initial_content)
+ .to("#{matching_tests_initial_content} #{changed_files_matching_test}")
+ end
+
+ it 'does not change files other than RSPEC_CHANGED_FILES_PATH nor RSPEC_MATCHING_TESTS_PATH' do
+ expect { subject }.not_to change { File.read(views_with_partials.path) }
+ expect { subject }.not_to change { File.read(fixtures_mapping.path) }
+ expect { subject }.not_to change { File.read(matching_js_files.path) }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/tooling/quality/test_level_spec.rb b/spec/tooling/quality/test_level_spec.rb
index aac7d19c079..a7e4e42206a 100644
--- a/spec/tooling/quality/test_level_spec.rb
+++ b/spec/tooling/quality/test_level_spec.rb
@@ -46,7 +46,7 @@ RSpec.describe Quality::TestLevel, feature_category: :tooling do
context 'when level is unit' do
it 'returns a pattern' do
expect(subject.pattern(:unit))
- .to eq("spec/{bin,channels,config,contracts,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,lib,metrics_server,models,policies,presenters,rack_servers,replicators,routing,rubocop,scripts,serializers,services,sidekiq,sidekiq_cluster,spam,support_specs,tasks,uploaders,validators,views,workers,tooling,components}{,/**/}*_spec.rb")
+ .to eq("spec/{bin,channels,components,config,contracts,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,lib,metrics_server,models,policies,presenters,rack_servers,replicators,routing,rubocop,scripts,serializers,services,sidekiq,sidekiq_cluster,spam,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb")
end
end
@@ -121,7 +121,7 @@ RSpec.describe Quality::TestLevel, feature_category: :tooling do
context 'when level is unit' do
it 'returns a regexp' do
expect(subject.regexp(:unit))
- .to eq(%r{spec/(bin|channels|config|contracts|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|lib|metrics_server|models|policies|presenters|rack_servers|replicators|routing|rubocop|scripts|serializers|services|sidekiq|sidekiq_cluster|spam|support_specs|tasks|uploaders|validators|views|workers|tooling|components)/})
+ .to eq(%r{spec/(bin|channels|components|config|contracts|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|lib|metrics_server|models|policies|presenters|rack_servers|replicators|routing|rubocop|scripts|serializers|services|sidekiq|sidekiq_cluster|spam|support_specs|tasks|uploaders|validators|views|workers|tooling)/})
end
end
@@ -167,6 +167,13 @@ RSpec.describe Quality::TestLevel, feature_category: :tooling do
end
end
+ context 'when start_with == true' do
+ it 'returns a regexp' do
+ expect(described_class.new(['ee/']).regexp(:system, true))
+ .to eq(%r{^(ee/)spec/(features)/})
+ end
+ end
+
describe 'performance' do
it 'memoizes the regexp for a given level' do
expect(subject.regexp(:system).object_id).to eq(subject.regexp(:system).object_id)
diff --git a/spec/tooling/rspec_flaky/config_spec.rb b/spec/tooling/rspec_flaky/config_spec.rb
index c95e5475d66..63f42d7c6cc 100644
--- a/spec/tooling/rspec_flaky/config_spec.rb
+++ b/spec/tooling/rspec_flaky/config_spec.rb
@@ -14,7 +14,6 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do
stub_env('FLAKY_RSPEC_SUITE_REPORT_PATH', nil)
stub_env('FLAKY_RSPEC_REPORT_PATH', nil)
stub_env('NEW_FLAKY_RSPEC_REPORT_PATH', nil)
- stub_env('SKIPPED_FLAKY_TESTS_REPORT_PATH', nil)
# Ensure the behavior is the same locally and on CI (where Rails is defined since we run this test as part of the whole suite), i.e. Rails isn't defined
allow(described_class).to receive(:rails_path).and_wrap_original do |method, path|
path
@@ -104,22 +103,4 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do
end
end
end
-
- describe '.skipped_flaky_tests_report_path' do
- context "when ENV['SKIPPED_FLAKY_TESTS_REPORT_PATH'] is not set" do
- it 'returns the default path' do
- expect(described_class.skipped_flaky_tests_report_path).to eq('rspec/flaky/skipped_flaky_tests_report.txt')
- end
- end
-
- context "when ENV['SKIPPED_FLAKY_TESTS_REPORT_PATH'] is set" do
- before do
- stub_env('SKIPPED_FLAKY_TESTS_REPORT_PATH', 'foo/skipped_flaky_tests_report.txt')
- end
-
- it 'returns the value of the env variable' do
- expect(described_class.skipped_flaky_tests_report_path).to eq('foo/skipped_flaky_tests_report.txt')
- end
- end
- end
end
diff --git a/spec/uploaders/attachment_uploader_spec.rb b/spec/uploaders/attachment_uploader_spec.rb
index 05cffff1f1a..a035402e207 100644
--- a/spec/uploaders/attachment_uploader_spec.rb
+++ b/spec/uploaders/attachment_uploader_spec.rb
@@ -10,9 +10,9 @@ RSpec.describe AttachmentUploader do
subject { uploader }
it_behaves_like 'builds correct paths',
- store_dir: %r[uploads/-/system/note/attachment/],
- upload_path: %r[uploads/-/system/note/attachment/],
- absolute_path: %r[#{CarrierWave.root}/uploads/-/system/note/attachment/]
+ store_dir: %r[uploads/-/system/note/attachment/],
+ upload_path: %r[uploads/-/system/note/attachment/],
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/note/attachment/]
context "object_store is REMOTE" do
before do
@@ -22,8 +22,8 @@ RSpec.describe AttachmentUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like 'builds correct paths',
- store_dir: %r[note/attachment/],
- upload_path: %r[note/attachment/]
+ store_dir: %r[note/attachment/],
+ upload_path: %r[note/attachment/]
end
describe "#migrate!" do
diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb
index a55e5c23fe8..e472ac46e66 100644
--- a/spec/uploaders/avatar_uploader_spec.rb
+++ b/spec/uploaders/avatar_uploader_spec.rb
@@ -10,9 +10,9 @@ RSpec.describe AvatarUploader do
subject { uploader }
it_behaves_like 'builds correct paths',
- store_dir: %r[uploads/-/system/user/avatar/],
- upload_path: %r[uploads/-/system/user/avatar/],
- absolute_path: %r[#{CarrierWave.root}/uploads/-/system/user/avatar/]
+ store_dir: %r[uploads/-/system/user/avatar/],
+ upload_path: %r[uploads/-/system/user/avatar/],
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/user/avatar/]
context "object_store is REMOTE" do
before do
@@ -22,8 +22,8 @@ RSpec.describe AvatarUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like 'builds correct paths',
- store_dir: %r[user/avatar/],
- upload_path: %r[user/avatar/]
+ store_dir: %r[user/avatar/],
+ upload_path: %r[user/avatar/]
end
context "with a file" do
diff --git a/spec/uploaders/ci/pipeline_artifact_uploader_spec.rb b/spec/uploaders/ci/pipeline_artifact_uploader_spec.rb
index 0630e9f6546..3935f081372 100644
--- a/spec/uploaders/ci/pipeline_artifact_uploader_spec.rb
+++ b/spec/uploaders/ci/pipeline_artifact_uploader_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe Ci::PipelineArtifactUploader do
subject { uploader }
it_behaves_like "builds correct paths",
- store_dir: %r[\h{2}/\h{2}/\h{64}/pipelines/\d+/artifacts/\d+],
- cache_dir: %r[artifacts/tmp/cache],
- work_dir: %r[artifacts/tmp/work]
+ store_dir: %r[\h{2}/\h{2}/\h{64}/pipelines/\d+/artifacts/\d+],
+ cache_dir: %r[artifacts/tmp/cache],
+ work_dir: %r[artifacts/tmp/work]
context 'when object store is REMOTE' do
before do
diff --git a/spec/uploaders/dependency_proxy/file_uploader_spec.rb b/spec/uploaders/dependency_proxy/file_uploader_spec.rb
index eb12e7dffa5..3cb2d1ea0f0 100644
--- a/spec/uploaders/dependency_proxy/file_uploader_spec.rb
+++ b/spec/uploaders/dependency_proxy/file_uploader_spec.rb
@@ -11,9 +11,9 @@ RSpec.describe DependencyProxy::FileUploader do
subject { uploader }
it_behaves_like "builds correct paths",
- store_dir: %r[\h{2}/\h{2}],
- cache_dir: %r[/dependency_proxy/tmp/cache],
- work_dir: %r[/dependency_proxy/tmp/work]
+ store_dir: %r[\h{2}/\h{2}],
+ cache_dir: %r[/dependency_proxy/tmp/cache],
+ work_dir: %r[/dependency_proxy/tmp/work]
context 'object store is remote' do
before do
@@ -22,8 +22,7 @@ RSpec.describe DependencyProxy::FileUploader do
include_context 'with storage', described_class::Store::REMOTE
- it_behaves_like "builds correct paths",
- store_dir: %r[\h{2}/\h{2}]
+ it_behaves_like "builds correct paths", store_dir: %r[\h{2}/\h{2}]
end
end
diff --git a/spec/uploaders/design_management/design_v432x230_uploader_spec.rb b/spec/uploaders/design_management/design_v432x230_uploader_spec.rb
index a18a37e73da..f3dd77d67a0 100644
--- a/spec/uploaders/design_management/design_v432x230_uploader_spec.rb
+++ b/spec/uploaders/design_management/design_v432x230_uploader_spec.rb
@@ -11,10 +11,10 @@ RSpec.describe DesignManagement::DesignV432x230Uploader do
subject(:uploader) { described_class.new(model, :image_v432x230) }
it_behaves_like 'builds correct paths',
- store_dir: %r[uploads/-/system/design_management/action/image_v432x230/],
- upload_path: %r[uploads/-/system/design_management/action/image_v432x230/],
- relative_path: %r[uploads/-/system/design_management/action/image_v432x230/],
- absolute_path: %r[#{CarrierWave.root}/uploads/-/system/design_management/action/image_v432x230/]
+ store_dir: %r[uploads/-/system/design_management/action/image_v432x230/],
+ upload_path: %r[uploads/-/system/design_management/action/image_v432x230/],
+ relative_path: %r[uploads/-/system/design_management/action/image_v432x230/],
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/design_management/action/image_v432x230/]
context 'object_store is REMOTE' do
before do
@@ -24,9 +24,9 @@ RSpec.describe DesignManagement::DesignV432x230Uploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like 'builds correct paths',
- store_dir: %r[design_management/action/image_v432x230/],
- upload_path: %r[design_management/action/image_v432x230/],
- relative_path: %r[design_management/action/image_v432x230/]
+ store_dir: %r[design_management/action/image_v432x230/],
+ upload_path: %r[design_management/action/image_v432x230/],
+ relative_path: %r[design_management/action/image_v432x230/]
end
describe "#migrate!" do
diff --git a/spec/uploaders/external_diff_uploader_spec.rb b/spec/uploaders/external_diff_uploader_spec.rb
index a889181b72c..2121e9cbc29 100644
--- a/spec/uploaders/external_diff_uploader_spec.rb
+++ b/spec/uploaders/external_diff_uploader_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe ExternalDiffUploader do
subject(:uploader) { described_class.new(diff, :external_diff) }
it_behaves_like "builds correct paths",
- store_dir: %r[merge_request_diffs/mr-\d+],
- cache_dir: %r[/external-diffs/tmp/cache],
- work_dir: %r[/external-diffs/tmp/work]
+ store_dir: %r[merge_request_diffs/mr-\d+],
+ cache_dir: %r[/external-diffs/tmp/cache],
+ work_dir: %r[/external-diffs/tmp/work]
context "object store is REMOTE" do
before do
@@ -21,7 +21,7 @@ RSpec.describe ExternalDiffUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like "builds correct paths",
- store_dir: %r[merge_request_diffs/mr-\d+]
+ store_dir: %r[merge_request_diffs/mr-\d+]
end
describe 'remote file' do
diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb
index 1287b809223..3340725dd6d 100644
--- a/spec/uploaders/file_uploader_spec.rb
+++ b/spec/uploaders/file_uploader_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe FileUploader do
- let(:group) { create(:group, name: 'awesome') }
- let(:project) { create(:project, :legacy_storage, namespace: group, name: 'project') }
+ let(:group) { create(:group, path: 'awesome') }
+ let(:project) { create(:project, :legacy_storage, namespace: group, path: 'project') }
let(:uploader) { described_class.new(project, :avatar) }
let(:upload) { double(model: project, path: "#{secret}/foo.jpg") }
let(:secret) { "55dc16aa0edd05693fd98b5051e83321" } # this would be nicer as SecureRandom.hex, but the shared_examples breaks
@@ -13,9 +13,9 @@ RSpec.describe FileUploader do
shared_examples 'builds correct legacy storage paths' do
include_examples 'builds correct paths',
- store_dir: %r{awesome/project/\h+},
- upload_path: %r{\h+/<filename>},
- absolute_path: %r{#{described_class.root}/awesome/project/55dc16aa0edd05693fd98b5051e83321/foo.jpg}
+ store_dir: %r{awesome/project/\h+},
+ upload_path: %r{\h+/<filename>},
+ absolute_path: %r{#{described_class.root}/awesome/project/55dc16aa0edd05693fd98b5051e83321/foo.jpg}
end
context 'legacy storage' do
@@ -23,15 +23,15 @@ RSpec.describe FileUploader do
context 'uses hashed storage' do
context 'when rolled out attachments' do
- let(:project) { build_stubbed(:project, namespace: group, name: 'project') }
+ let(:project) { build_stubbed(:project, namespace: group, path: 'project') }
include_examples 'builds correct paths',
- store_dir: %r{@hashed/\h{2}/\h{2}/\h+},
- upload_path: %r{\h+/<filename>}
+ store_dir: %r{@hashed/\h{2}/\h{2}/\h+},
+ upload_path: %r{\h+/<filename>}
end
context 'when only repositories are rolled out' do
- let(:project) { build_stubbed(:project, namespace: group, name: 'project', storage_version: Project::HASHED_STORAGE_FEATURES[:repository]) }
+ let(:project) { build_stubbed(:project, namespace: group, path: 'project', storage_version: Project::HASHED_STORAGE_FEATURES[:repository]) }
it_behaves_like 'builds correct legacy storage paths'
end
@@ -47,8 +47,8 @@ RSpec.describe FileUploader do
# always use hashed storage path for remote uploads
it_behaves_like 'builds correct paths',
- store_dir: %r{@hashed/\h{2}/\h{2}/\h+},
- upload_path: %r{@hashed/\h{2}/\h{2}/\h+/\h+/<filename>}
+ store_dir: %r{@hashed/\h{2}/\h{2}/\h+},
+ upload_path: %r{@hashed/\h{2}/\h{2}/\h+/\h+/<filename>}
end
describe 'initialize' do
diff --git a/spec/uploaders/gitlab_uploader_spec.rb b/spec/uploaders/gitlab_uploader_spec.rb
index f62ab726631..bd86f1fe08a 100644
--- a/spec/uploaders/gitlab_uploader_spec.rb
+++ b/spec/uploaders/gitlab_uploader_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require 'carrierwave/storage/fog'
-RSpec.describe GitlabUploader do
+RSpec.describe GitlabUploader, feature_category: :shared do
let(:uploader_class) { Class.new(described_class) }
subject(:uploader) { uploader_class.new(double) }
@@ -179,4 +179,19 @@ RSpec.describe GitlabUploader do
it { expect { subject }.to raise_error(RuntimeError, /not supported/) }
end
+
+ describe '.storage_location' do
+ it 'sets the identifier for the storage location options' do
+ uploader_class.storage_location(:artifacts)
+
+ expect(uploader_class.options).to eq(Gitlab.config.artifacts)
+ end
+
+ context 'when given identifier is not known' do
+ it 'raises an error' do
+ expect { uploader_class.storage_location(:foo) }
+ .to raise_error(KeyError)
+ end
+ end
+ end
end
diff --git a/spec/uploaders/job_artifact_uploader_spec.rb b/spec/uploaders/job_artifact_uploader_spec.rb
index d7c9ef7e0d5..dac9e97641d 100644
--- a/spec/uploaders/job_artifact_uploader_spec.rb
+++ b/spec/uploaders/job_artifact_uploader_spec.rb
@@ -10,9 +10,9 @@ RSpec.describe JobArtifactUploader do
subject { uploader }
it_behaves_like "builds correct paths",
- store_dir: %r[\h{2}/\h{2}/\h{64}/\d{4}_\d{1,2}_\d{1,2}/\d+/\d+\z],
- cache_dir: %r[artifacts/tmp/cache],
- work_dir: %r[artifacts/tmp/work]
+ store_dir: %r[\h{2}/\h{2}/\h{64}/\d{4}_\d{1,2}_\d{1,2}/\d+/\d+\z],
+ cache_dir: %r[artifacts/tmp/cache],
+ work_dir: %r[artifacts/tmp/work]
context "object store is REMOTE" do
before do
@@ -22,7 +22,7 @@ RSpec.describe JobArtifactUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like "builds correct paths",
- store_dir: %r[\h{2}/\h{2}/\h{64}/\d{4}_\d{1,2}_\d{1,2}/\d+/\d+\z]
+ store_dir: %r[\h{2}/\h{2}/\h{64}/\d{4}_\d{1,2}_\d{1,2}/\d+/\d+\z]
describe '#cdn_enabled_url' do
it 'returns URL and false' do
diff --git a/spec/uploaders/lfs_object_uploader_spec.rb b/spec/uploaders/lfs_object_uploader_spec.rb
index b85892a42b5..9bbfd910ada 100644
--- a/spec/uploaders/lfs_object_uploader_spec.rb
+++ b/spec/uploaders/lfs_object_uploader_spec.rb
@@ -10,9 +10,9 @@ RSpec.describe LfsObjectUploader do
subject { uploader }
it_behaves_like "builds correct paths",
- store_dir: %r[\h{2}/\h{2}],
- cache_dir: %r[/lfs-objects/tmp/cache],
- work_dir: %r[/lfs-objects/tmp/work]
+ store_dir: %r[\h{2}/\h{2}],
+ cache_dir: %r[/lfs-objects/tmp/cache],
+ work_dir: %r[/lfs-objects/tmp/work]
context "object store is REMOTE" do
before do
@@ -22,7 +22,7 @@ RSpec.describe LfsObjectUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like "builds correct paths",
- store_dir: %r[\h{2}/\h{2}]
+ store_dir: %r[\h{2}/\h{2}]
end
describe 'remote file' do
diff --git a/spec/uploaders/object_storage/cdn/google_cdn_spec.rb b/spec/uploaders/object_storage/cdn/google_cdn_spec.rb
index 184c664f6dc..96413f622e8 100644
--- a/spec/uploaders/object_storage/cdn/google_cdn_spec.rb
+++ b/spec/uploaders/object_storage/cdn/google_cdn_spec.rb
@@ -99,9 +99,10 @@ RSpec.describe ObjectStorage::CDN::GoogleCDN,
let(:path) { '/path/to/file.txt' }
let(:expiration) { (Time.current + 10.minutes).utc.to_i }
let(:cdn_query_params) { "Expires=#{expiration}&KeyName=#{key_name}" }
+ let(:encoded_path) { Addressable::URI.encode_component(path, Addressable::URI::CharacterClasses::PATH) }
def verify_signature(url, unsigned_url)
- expect(url).to start_with("#{options[:url]}#{path}")
+ expect(url).to start_with("#{options[:url]}#{encoded_path}")
uri = Addressable::URI.parse(url)
query = uri.query_values
@@ -116,6 +117,16 @@ RSpec.describe ObjectStorage::CDN::GoogleCDN,
end
end
+ context 'with UTF-8 characters in path' do
+ let(:path) { "/path/to/©️job🧪" }
+ let(:url) { subject.signed_url(path) }
+ let(:unsigned_url) { "#{options[:url]}#{encoded_path}?#{cdn_query_params}" }
+
+ it 'returns a valid signed URL' do
+ verify_signature(url, unsigned_url)
+ end
+ end
+
context 'with default query parameters' do
let(:url) { subject.signed_url(path) }
let(:unsigned_url) { "#{options[:url]}#{path}?#{cdn_query_params}" }
diff --git a/spec/uploaders/object_storage/cdn_spec.rb b/spec/uploaders/object_storage/cdn_spec.rb
index d6c638297fa..28b3313428b 100644
--- a/spec/uploaders/object_storage/cdn_spec.rb
+++ b/spec/uploaders/object_storage/cdn_spec.rb
@@ -3,19 +3,6 @@
require 'spec_helper'
RSpec.describe ObjectStorage::CDN, feature_category: :build_artifacts do
- let(:cdn_options) do
- {
- 'object_store' => {
- 'cdn' => {
- 'provider' => 'google',
- 'url' => 'https://gitlab.example.com',
- 'key_name' => 'test-key',
- 'key' => Base64.urlsafe_encode64('12345')
- }
- }
- }.freeze
- end
-
let(:uploader_class) do
Class.new(GitlabUploader) do
include ObjectStorage::Concern
@@ -39,44 +26,66 @@ RSpec.describe ObjectStorage::CDN, feature_category: :build_artifacts do
subject { uploader_class.new(object, :file) }
context 'with CDN config' do
+ let(:cdn_options) do
+ {
+ 'object_store' => {
+ 'cdn' => {
+ 'provider' => cdn_provider,
+ 'url' => 'https://gitlab.example.com',
+ 'key_name' => 'test-key',
+ 'key' => Base64.urlsafe_encode64('12345')
+ }
+ }
+ }.freeze
+ end
+
before do
stub_artifacts_object_storage(enabled: true)
- uploader_class.options = Settingslogic.new(Gitlab.config.uploads.deep_merge(cdn_options))
+ options = Gitlab.config.uploads.deep_merge(cdn_options)
+ allow(uploader_class).to receive(:options).and_return(options)
end
- describe '#cdn_enabled_url' do
- it 'calls #cdn_signed_url' do
- expect(subject).not_to receive(:url)
- expect(subject).to receive(:cdn_signed_url).with(query_params).and_call_original
+ context 'with a known CDN provider' do
+ let(:cdn_provider) { 'google' }
- result = subject.cdn_enabled_url(public_ip, query_params)
+ describe '#cdn_enabled_url' do
+ it 'calls #cdn_signed_url' do
+ expect(subject).not_to receive(:url)
+ expect(subject).to receive(:cdn_signed_url).with(query_params).and_call_original
+
+ result = subject.cdn_enabled_url(public_ip, query_params)
- expect(result.used_cdn).to be true
+ expect(result.used_cdn).to be true
+ end
end
- end
- describe '#use_cdn?' do
- it 'returns true' do
- expect(subject.use_cdn?(public_ip)).to be true
+ describe '#use_cdn?' do
+ it 'returns true' do
+ expect(subject.use_cdn?(public_ip)).to be true
+ end
end
- end
- describe '#cdn_signed_url' do
- it 'returns a URL' do
- expect_next_instance_of(ObjectStorage::CDN::GoogleCDN) do |cdn|
- expect(cdn).to receive(:signed_url).and_return("https://cdn.example.com/path")
+ describe '#cdn_signed_url' do
+ it 'returns a URL' do
+ expect_next_instance_of(ObjectStorage::CDN::GoogleCDN) do |cdn|
+ expect(cdn).to receive(:signed_url).and_return("https://cdn.example.com/path")
+ end
+
+ expect(subject.cdn_signed_url).to eq("https://cdn.example.com/path")
end
+ end
+ end
+
+ context 'with an unknown CDN provider' do
+ let(:cdn_provider) { 'amazon' }
- expect(subject.cdn_signed_url).to eq("https://cdn.example.com/path")
+ it 'raises an error' do
+ expect { subject.use_cdn?(public_ip) }.to raise_error("Unknown CDN provider: amazon")
end
end
end
context 'without CDN config' do
- before do
- uploader_class.options = Gitlab.config.uploads
- end
-
describe '#cdn_enabled_url' do
it 'calls #url' do
expect(subject).not_to receive(:cdn_signed_url)
@@ -94,15 +103,4 @@ RSpec.describe ObjectStorage::CDN, feature_category: :build_artifacts do
end
end
end
-
- context 'with an unknown CDN provider' do
- before do
- cdn_options['object_store']['cdn']['provider'] = 'amazon'
- uploader_class.options = Settingslogic.new(Gitlab.config.uploads.deep_merge(cdn_options))
- end
-
- it 'raises an error' do
- expect { subject.use_cdn?(public_ip) }.to raise_error("Unknown CDN provider: amazon")
- end
- end
end
diff --git a/spec/uploaders/object_storage_spec.rb b/spec/uploaders/object_storage_spec.rb
index 5344dbeb512..1566021934a 100644
--- a/spec/uploaders/object_storage_spec.rb
+++ b/spec/uploaders/object_storage_spec.rb
@@ -8,7 +8,7 @@ class Implementation < GitlabUploader
include ::RecordsUploads::Concern
prepend ::ObjectStorage::Extension::RecordsUploads
- storage_options Gitlab.config.uploads
+ storage_location :uploads
private
@@ -18,10 +18,12 @@ class Implementation < GitlabUploader
end
end
-RSpec.describe ObjectStorage do
+# TODO: Update feature_category once object storage group ownership has been determined.
+RSpec.describe ObjectStorage, :clean_gitlab_redis_shared_state, feature_category: :shared do
let(:uploader_class) { Implementation }
let(:object) { build_stubbed(:user) }
- let(:uploader) { uploader_class.new(object, :file) }
+ let(:file_column) { :file }
+ let(:uploader) { uploader_class.new(object, file_column) }
describe '#object_store=' do
before do
@@ -103,6 +105,34 @@ RSpec.describe ObjectStorage do
expect(subject).to eq("my/prefix/user/#{object.id}/filename")
end
end
+
+ context 'when model has final path defined for the file column' do
+ # Changing this to `foo` to make a point that not all uploaders are mounted
+ # as `file`. They can be mounted as different names, for example, `avatar`.
+ let(:file_column) { :foo }
+
+ before do
+ allow(object).to receive(:foo_final_path).and_return('123-final-path')
+ end
+
+ it 'uses the final path instead' do
+ expect(subject).to eq('123-final-path')
+ end
+
+ context 'and a bucket prefix is configured' do
+ before do
+ allow(uploader_class).to receive(:object_store_options) do
+ double(
+ bucket_prefix: 'my/prefix'
+ )
+ end
+ end
+
+ it 'uses the prefix with the final path' do
+ expect(subject).to eq("my/prefix/123-final-path")
+ end
+ end
+ end
end
end
end
@@ -446,7 +476,7 @@ RSpec.describe ObjectStorage do
end
describe '#fog_credentials' do
- let(:connection) { Settingslogic.new("provider" => "AWS") }
+ let(:connection) { GitlabSettings::Options.build("provider" => "AWS") }
before do
allow(uploader_class).to receive(:options) do
@@ -479,7 +509,7 @@ RSpec.describe ObjectStorage do
}
end
- let(:options) { Settingslogic.new(raw_options) }
+ let(:options) { GitlabSettings::Options.build(raw_options) }
before do
allow(uploader_class).to receive(:options) do
@@ -494,8 +524,15 @@ RSpec.describe ObjectStorage do
describe '.workhorse_authorize' do
let(:has_length) { true }
let(:maximum_size) { nil }
+ let(:use_final_store_path) { false }
- subject { uploader_class.workhorse_authorize(has_length: has_length, maximum_size: maximum_size) }
+ subject do
+ uploader_class.workhorse_authorize(
+ has_length: has_length,
+ maximum_size: maximum_size,
+ use_final_store_path: use_final_store_path
+ )
+ end
context 'when FIPS is enabled', :fips_mode do
it 'response enables FIPS' do
@@ -528,18 +565,23 @@ RSpec.describe ObjectStorage do
shared_examples 'uses remote storage' do
it_behaves_like 'returns the maximum size given' do
- it "returns remote store" do
+ it "returns remote object properties for a temporary upload" do
is_expected.to have_key(:RemoteObject)
expect(subject[:RemoteObject]).to have_key(:ID)
expect(subject[:RemoteObject]).to include(Timeout: a_kind_of(Integer))
expect(subject[:RemoteObject][:Timeout]).to be(ObjectStorage::DirectUpload::TIMEOUT)
- expect(subject[:RemoteObject]).to have_key(:GetURL)
- expect(subject[:RemoteObject]).to have_key(:DeleteURL)
- expect(subject[:RemoteObject]).to have_key(:StoreURL)
- expect(subject[:RemoteObject][:GetURL]).to include(described_class::TMP_UPLOAD_PATH)
- expect(subject[:RemoteObject][:DeleteURL]).to include(described_class::TMP_UPLOAD_PATH)
- expect(subject[:RemoteObject][:StoreURL]).to include(described_class::TMP_UPLOAD_PATH)
+
+ upload_path = File.join(described_class::TMP_UPLOAD_PATH, subject[:RemoteObject][:ID])
+
+ expect(subject[:RemoteObject][:GetURL]).to include(upload_path)
+ expect(subject[:RemoteObject][:DeleteURL]).to include(upload_path)
+ expect(subject[:RemoteObject][:StoreURL]).to include(upload_path)
+ expect(subject[:RemoteObject][:SkipDelete]).to eq(false)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.hlen(ObjectStorage::PendingDirectUpload::KEY)).to be_zero
+ end
end
end
end
@@ -551,12 +593,12 @@ RSpec.describe ObjectStorage do
expect(subject[:RemoteObject]).to have_key(:MultipartUpload)
expect(subject[:RemoteObject][:MultipartUpload]).to have_key(:PartSize)
- expect(subject[:RemoteObject][:MultipartUpload]).to have_key(:PartURLs)
- expect(subject[:RemoteObject][:MultipartUpload]).to have_key(:CompleteURL)
- expect(subject[:RemoteObject][:MultipartUpload]).to have_key(:AbortURL)
- expect(subject[:RemoteObject][:MultipartUpload][:PartURLs]).to all(include(described_class::TMP_UPLOAD_PATH))
- expect(subject[:RemoteObject][:MultipartUpload][:CompleteURL]).to include(described_class::TMP_UPLOAD_PATH)
- expect(subject[:RemoteObject][:MultipartUpload][:AbortURL]).to include(described_class::TMP_UPLOAD_PATH)
+
+ upload_path = File.join(described_class::TMP_UPLOAD_PATH, subject[:RemoteObject][:ID])
+
+ expect(subject[:RemoteObject][:MultipartUpload][:PartURLs]).to all(include(upload_path))
+ expect(subject[:RemoteObject][:MultipartUpload][:CompleteURL]).to include(upload_path)
+ expect(subject[:RemoteObject][:MultipartUpload][:AbortURL]).to include(upload_path)
end
end
end
@@ -570,6 +612,80 @@ RSpec.describe ObjectStorage do
end
end
+ shared_examples 'handling object storage final upload path' do |multipart|
+ context 'when use_final_store_path is true' do
+ let(:use_final_store_path) { true }
+ let(:final_store_path) { File.join('@final', 'abc', '123', 'somefilename') }
+ let(:escaped_path) { escape_path(final_store_path) }
+
+ before do
+ stub_object_storage_multipart_init_with_final_store_path("#{storage_url}#{final_store_path}") if multipart
+
+ allow(uploader_class).to receive(:generate_final_store_path).and_return(final_store_path)
+ end
+
+ it 'uses the full path instead of the temporary one' do
+ expect(subject[:RemoteObject][:ID]).to eq(final_store_path)
+
+ expect(subject[:RemoteObject][:GetURL]).to include(escaped_path)
+ expect(subject[:RemoteObject][:StoreURL]).to include(escaped_path)
+
+ if multipart
+ expect(subject[:RemoteObject][:MultipartUpload][:PartURLs]).to all(include(escaped_path))
+ expect(subject[:RemoteObject][:MultipartUpload][:CompleteURL]).to include(escaped_path)
+ expect(subject[:RemoteObject][:MultipartUpload][:AbortURL]).to include(escaped_path)
+ end
+
+ expect(subject[:RemoteObject][:SkipDelete]).to eq(true)
+
+ expect(
+ ObjectStorage::PendingDirectUpload.exists?(uploader_class.storage_location_identifier, final_store_path)
+ ).to eq(true)
+ end
+
+ context 'and bucket prefix is configured' do
+ let(:prefixed_final_store_path) { "my/prefix/#{final_store_path}" }
+ let(:escaped_path) { escape_path(prefixed_final_store_path) }
+
+ before do
+ allow(uploader_class.object_store_options).to receive(:bucket_prefix).and_return('my/prefix')
+
+ if multipart
+ stub_object_storage_multipart_init_with_final_store_path("#{storage_url}#{prefixed_final_store_path}")
+ end
+ end
+
+ it 'sets the remote object ID to the final path without prefix' do
+ expect(subject[:RemoteObject][:ID]).to eq(final_store_path)
+ end
+
+ it 'returns the final path with prefix' do
+ expect(subject[:RemoteObject][:GetURL]).to include(escaped_path)
+ expect(subject[:RemoteObject][:StoreURL]).to include(escaped_path)
+
+ if multipart
+ expect(subject[:RemoteObject][:MultipartUpload][:PartURLs]).to all(include(escaped_path))
+ expect(subject[:RemoteObject][:MultipartUpload][:CompleteURL]).to include(escaped_path)
+ expect(subject[:RemoteObject][:MultipartUpload][:AbortURL]).to include(escaped_path)
+ end
+ end
+
+ it 'creates the pending upload entry without the prefix' do
+ is_expected.to have_key(:RemoteObject)
+
+ expect(
+ ObjectStorage::PendingDirectUpload.exists?(uploader_class.storage_location_identifier, final_store_path)
+ ).to eq(true)
+ end
+ end
+ end
+
+ def escape_path(path)
+ # This is what the private method Fog::AWS::Storage#object_to_path will do to the object name
+ Fog::AWS.escape(path).gsub('%2F', '/')
+ end
+ end
+
context 'when object storage is disabled' do
before do
allow(Gitlab.config.uploads.object_store).to receive(:enabled) { false }
@@ -613,6 +729,8 @@ RSpec.describe ObjectStorage do
expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url)
end
end
+
+ it_behaves_like 'handling object storage final upload path'
end
context 'for unknown length' do
@@ -633,6 +751,8 @@ RSpec.describe ObjectStorage do
expect(subject[:RemoteObject][:MultipartUpload][:AbortURL]).to start_with(storage_url)
end
end
+
+ it_behaves_like 'handling object storage final upload path', :multipart
end
end
@@ -660,6 +780,8 @@ RSpec.describe ObjectStorage do
expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url)
end
end
+
+ it_behaves_like 'handling object storage final upload path'
end
context 'for unknown length' do
@@ -673,6 +795,8 @@ RSpec.describe ObjectStorage do
expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url)
end
end
+
+ it_behaves_like 'handling object storage final upload path'
end
end
@@ -701,6 +825,8 @@ RSpec.describe ObjectStorage do
expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url)
end
end
+
+ it_behaves_like 'handling object storage final upload path'
end
context 'for unknown length' do
@@ -721,6 +847,8 @@ RSpec.describe ObjectStorage do
expect(subject[:RemoteObject][:MultipartUpload][:AbortURL]).to start_with(storage_url)
end
end
+
+ it_behaves_like 'handling object storage final upload path', :multipart
end
end
end
@@ -880,9 +1008,10 @@ RSpec.describe ObjectStorage do
expect(uploader).to be_exists
expect(uploader).to be_cached
+ expect(uploader.cache_only).to be_falsey
expect(uploader).not_to be_file_storage
expect(uploader.path).not_to be_nil
- expect(uploader.path).not_to include('tmp/cache')
+ expect(uploader.path).to include('tmp/uploads')
expect(uploader.path).not_to include('tmp/cache')
expect(uploader.object_store).to eq(described_class::Store::REMOTE)
end
@@ -905,41 +1034,167 @@ RSpec.describe ObjectStorage do
expect(uploader.object_store).to eq(described_class::Store::REMOTE)
end
end
+
+ context 'when uploaded file remote_id matches a pending direct upload entry' do
+ let(:object) { build_stubbed(:ci_job_artifact) }
+ let(:final_path) { '@final/test/123123' }
+ let(:fog_config) { Gitlab.config.uploads.object_store }
+ let(:bucket) { 'uploads' }
+ let(:uploaded_file) { UploadedFile.new(temp_file.path, filename: "my_file.txt", remote_id: final_path) }
+ let(:fog_file_path) { final_path }
+
+ let(:fog_connection_2) do
+ stub_object_storage_uploader(
+ config: fog_config,
+ uploader: uploader_class,
+ direct_upload: true
+ )
+ end
+
+ let!(:fog_file_2) do
+ fog_connection_2.directories.new(key: bucket).files.create( # rubocop:disable Rails/SaveBang
+ key: fog_file_path,
+ body: 'content'
+ )
+ end
+
+ before do
+ ObjectStorage::PendingDirectUpload.prepare(
+ uploader_class.storage_location_identifier,
+ final_path
+ )
+ end
+
+ it 'file to be cached and remote stored with final path set' do
+ expect { subject }.not_to raise_error
+
+ expect(uploader).to be_exists
+ expect(uploader).to be_cached
+ expect(uploader.cache_only).to be_falsey
+ expect(uploader).not_to be_file_storage
+ expect(uploader.path).to eq(uploaded_file.remote_id)
+ expect(uploader.object_store).to eq(described_class::Store::REMOTE)
+
+ expect(object.file_final_path).to eq(uploaded_file.remote_id)
+ end
+
+ context 'when bucket prefix is configured' do
+ let(:fog_config) do
+ Gitlab.config.uploads.object_store.tap do |config|
+ config[:remote_directory] = 'main-bucket'
+ config[:bucket_prefix] = 'uploads'
+ end
+ end
+
+ let(:bucket) { 'main-bucket' }
+ let(:fog_file_path) { "uploads/#{final_path}" }
+
+ it 'stores the file final path in the db without the prefix' do
+ expect { subject }.not_to raise_error
+
+ expect(uploader.store_path).to eq("uploads/#{final_path}")
+ expect(object.file_final_path).to eq(final_path)
+ end
+ end
+
+ context 'when file is stored' do
+ subject do
+ uploader.store!(uploaded_file)
+ end
+
+ it 'file to be remotely stored in permament location' do
+ subject
+
+ expect(uploader).to be_exists
+ expect(uploader).not_to be_cached
+ expect(uploader.path).to eq(uploaded_file.remote_id)
+ end
+
+ it 'does not trigger Carrierwave copy and delete because it is already in the final location' do
+ expect_next_instance_of(CarrierWave::Storage::Fog::File) do |instance|
+ expect(instance).not_to receive(:copy_to)
+ expect(instance).not_to receive(:delete)
+ end
+
+ subject
+ end
+ end
+ end
end
end
end
end
describe '#retrieve_from_store!' do
- [:group, :project, :user].each do |model|
- context "for #{model}s" do
- let(:models) { create_list(model, 3, :with_avatar).map(&:reload) }
- let(:avatars) { models.map(&:avatar) }
+ context 'uploaders that includes the RecordsUploads extension' do
+ [:group, :project, :user].each do |model|
+ context "for #{model}s" do
+ let(:models) { create_list(model, 3, :with_avatar).map(&:reload) }
+ let(:avatars) { models.map(&:avatar) }
+
+ it 'batches fetching uploads from the database' do
+ # Ensure that these are all created and fully loaded before we start
+ # running queries for avatars
+ models
+
+ expect { avatars }.not_to exceed_query_limit(1)
+ end
+
+ it 'does not attempt to replace methods' do
+ models.each do |model|
+ expect(model.avatar.upload).to receive(:method_missing).and_call_original
- it 'batches fetching uploads from the database' do
- # Ensure that these are all created and fully loaded before we start
- # running queries for avatars
- models
+ model.avatar.upload.path
+ end
+ end
- expect { avatars }.not_to exceed_query_limit(1)
+ it 'fetches a unique upload for each model' do
+ expect(avatars.map(&:url).uniq).to eq(avatars.map(&:url))
+ expect(avatars.map(&:upload).uniq).to eq(avatars.map(&:upload))
+ end
end
+ end
+ end
- it 'does not attempt to replace methods' do
- models.each do |model|
- expect(model.avatar.upload).to receive(:method_missing).and_call_original
+ describe 'filename' do
+ let(:model) { create(:ci_job_artifact, :remote_store, :archive) }
- model.avatar.upload.path
- end
+ before do
+ stub_artifacts_object_storage
+ end
+
+ shared_examples 'ensuring correct filename' do
+ it 'uses the original filename' do
+ expect(model.reload.file.filename).to eq('ci_build_artifacts.zip')
end
+ end
- it 'fetches a unique upload for each model' do
- expect(avatars.map(&:url).uniq).to eq(avatars.map(&:url))
- expect(avatars.map(&:upload).uniq).to eq(avatars.map(&:upload))
+ context 'when model has final path defined for the file column' do
+ before do
+ model.update_column(:file_final_path, 'some/final/path/abc-123')
end
+
+ it_behaves_like 'ensuring correct filename'
+ end
+
+ context 'when model has no final path defined for the file column' do
+ it_behaves_like 'ensuring correct filename'
end
end
end
+ describe '.generate_final_store_path' do
+ subject(:final_path) { uploader_class.generate_final_store_path }
+
+ before do
+ allow(Digest::SHA2).to receive(:hexdigest).and_return('somehash1234')
+ end
+
+ it 'returns the generated hashed path' do
+ expect(final_path).to eq('@final/so/me/hash1234')
+ end
+ end
+
describe 'OpenFile' do
subject { ObjectStorage::Concern::OpenFile.new(file) }
diff --git a/spec/uploaders/packages/composer/cache_uploader_spec.rb b/spec/uploaders/packages/composer/cache_uploader_spec.rb
index 7ceaa24f463..7eea4a839ab 100644
--- a/spec/uploaders/packages/composer/cache_uploader_spec.rb
+++ b/spec/uploaders/packages/composer/cache_uploader_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe Packages::Composer::CacheUploader do
subject { uploader }
it_behaves_like "builds correct paths",
- store_dir: %r[^\h{2}/\h{2}/\h{64}/packages/composer_cache/\d+$],
- cache_dir: %r[/packages/tmp/cache],
- work_dir: %r[/packages/tmp/work]
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/packages/composer_cache/\d+$],
+ cache_dir: %r[/packages/tmp/cache],
+ work_dir: %r[/packages/tmp/work]
context 'object store is remote' do
before do
@@ -21,7 +21,7 @@ RSpec.describe Packages::Composer::CacheUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like "builds correct paths",
- store_dir: %r[^\h{2}/\h{2}/\h{64}/packages/composer_cache/\d+$]
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/packages/composer_cache/\d+$]
end
describe 'remote file' do
diff --git a/spec/uploaders/packages/debian/component_file_uploader_spec.rb b/spec/uploaders/packages/debian/component_file_uploader_spec.rb
index bee82fb2715..84ba751c737 100644
--- a/spec/uploaders/packages/debian/component_file_uploader_spec.rb
+++ b/spec/uploaders/packages/debian/component_file_uploader_spec.rb
@@ -12,9 +12,9 @@ RSpec.describe Packages::Debian::ComponentFileUploader do
subject { uploader }
it_behaves_like "builds correct paths",
- store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_component_file/\d+$],
- cache_dir: %r[/packages/tmp/cache$],
- work_dir: %r[/packages/tmp/work$]
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_component_file/\d+$],
+ cache_dir: %r[/packages/tmp/cache$],
+ work_dir: %r[/packages/tmp/work$]
context 'object store is remote' do
before do
@@ -24,9 +24,9 @@ RSpec.describe Packages::Debian::ComponentFileUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like "builds correct paths",
- store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_component_file/\d+$],
- cache_dir: %r[/packages/tmp/cache$],
- work_dir: %r[/packages/tmp/work$]
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_component_file/\d+$],
+ cache_dir: %r[/packages/tmp/cache$],
+ work_dir: %r[/packages/tmp/work$]
end
describe 'remote file' do
diff --git a/spec/uploaders/packages/debian/distribution_release_file_uploader_spec.rb b/spec/uploaders/packages/debian/distribution_release_file_uploader_spec.rb
index 96655edb186..df630569856 100644
--- a/spec/uploaders/packages/debian/distribution_release_file_uploader_spec.rb
+++ b/spec/uploaders/packages/debian/distribution_release_file_uploader_spec.rb
@@ -12,9 +12,9 @@ RSpec.describe Packages::Debian::DistributionReleaseFileUploader do
subject { uploader }
it_behaves_like "builds correct paths",
- store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_distribution/\d+$],
- cache_dir: %r[/packages/tmp/cache$],
- work_dir: %r[/packages/tmp/work$]
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_distribution/\d+$],
+ cache_dir: %r[/packages/tmp/cache$],
+ work_dir: %r[/packages/tmp/work$]
context 'object store is remote' do
before do
@@ -24,9 +24,9 @@ RSpec.describe Packages::Debian::DistributionReleaseFileUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like "builds correct paths",
- store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_distribution/\d+$],
- cache_dir: %r[/packages/tmp/cache$],
- work_dir: %r[/packages/tmp/work$]
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/debian_#{container_type}_distribution/\d+$],
+ cache_dir: %r[/packages/tmp/cache$],
+ work_dir: %r[/packages/tmp/work$]
end
describe 'remote file' do
diff --git a/spec/uploaders/packages/npm/metadata_cache_uploader_spec.rb b/spec/uploaders/packages/npm/metadata_cache_uploader_spec.rb
new file mode 100644
index 00000000000..0bcf05932a5
--- /dev/null
+++ b/spec/uploaders/packages/npm/metadata_cache_uploader_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Npm::MetadataCacheUploader, feature_category: :package_registry do
+ let(:object_storage_key) { 'object/storage/key' }
+ let(:npm_metadata_cache) { build_stubbed(:npm_metadata_cache, object_storage_key: object_storage_key) }
+
+ subject { described_class.new(npm_metadata_cache, :file) }
+
+ describe '#filename' do
+ it 'returns metadata.json' do
+ expect(subject.filename).to eq('metadata.json')
+ end
+ end
+
+ describe '#store_dir' do
+ it 'uses the object_storage_key' do
+ expect(subject.store_dir).to eq(object_storage_key)
+ end
+
+ context 'without the object_storage_key' do
+ let(:object_storage_key) { nil }
+
+ it 'raises the error' do
+ expect { subject.store_dir }
+ .to raise_error(
+ described_class::ObjectNotReadyError,
+ 'Packages::Npm::MetadataCache model not ready'
+ )
+ end
+ end
+ end
+end
diff --git a/spec/uploaders/packages/package_file_uploader_spec.rb b/spec/uploaders/packages/package_file_uploader_spec.rb
index 7d270ad03c9..ddd9823d55c 100644
--- a/spec/uploaders/packages/package_file_uploader_spec.rb
+++ b/spec/uploaders/packages/package_file_uploader_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe Packages::PackageFileUploader do
subject { uploader }
it_behaves_like "builds correct paths",
- store_dir: %r[^\h{2}/\h{2}/\h{64}/packages/\d+/files/\d+$],
- cache_dir: %r[/packages/tmp/cache],
- work_dir: %r[/packages/tmp/work]
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/packages/\d+/files/\d+$],
+ cache_dir: %r[/packages/tmp/cache],
+ work_dir: %r[/packages/tmp/work]
context 'object store is remote' do
before do
@@ -21,7 +21,7 @@ RSpec.describe Packages::PackageFileUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like "builds correct paths",
- store_dir: %r[^\h{2}/\h{2}/\h{64}/packages/\d+/files/\d+$]
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/packages/\d+/files/\d+$]
end
describe 'remote file' do
diff --git a/spec/uploaders/packages/rpm/repository_file_uploader_spec.rb b/spec/uploaders/packages/rpm/repository_file_uploader_spec.rb
index b3767ae179a..a36a035fde3 100644
--- a/spec/uploaders/packages/rpm/repository_file_uploader_spec.rb
+++ b/spec/uploaders/packages/rpm/repository_file_uploader_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe Packages::Rpm::RepositoryFileUploader do
subject { uploader }
it_behaves_like 'builds correct paths',
- store_dir: %r[^\h{2}/\h{2}/\h{64}/projects/\d+/rpm/repository_files/\d+$],
- cache_dir: %r{/packages/tmp/cache},
- work_dir: %r{/packages/tmp/work}
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/projects/\d+/rpm/repository_files/\d+$],
+ cache_dir: %r{/packages/tmp/cache},
+ work_dir: %r{/packages/tmp/work}
context 'when object store is remote' do
before do
@@ -21,7 +21,7 @@ RSpec.describe Packages::Rpm::RepositoryFileUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like 'builds correct paths',
- store_dir: %r[^\h{2}/\h{2}/\h{64}/projects/\d+/rpm/repository_files/\d+$]
+ store_dir: %r[^\h{2}/\h{2}/\h{64}/projects/\d+/rpm/repository_files/\d+$]
end
describe 'remote file' do
diff --git a/spec/uploaders/pages/deployment_uploader_spec.rb b/spec/uploaders/pages/deployment_uploader_spec.rb
index 1832f73bd67..7686efd4fe4 100644
--- a/spec/uploaders/pages/deployment_uploader_spec.rb
+++ b/spec/uploaders/pages/deployment_uploader_spec.rb
@@ -13,9 +13,9 @@ RSpec.describe Pages::DeploymentUploader do
subject { uploader }
it_behaves_like "builds correct paths",
- store_dir: %r[/\h{2}/\h{2}/\h{64}/pages_deployments/\d+],
- cache_dir: %r[pages/@hashed/tmp/cache],
- work_dir: %r[pages/@hashed/tmp/work]
+ store_dir: %r[/\h{2}/\h{2}/\h{64}/pages_deployments/\d+],
+ cache_dir: %r[pages/@hashed/tmp/cache],
+ work_dir: %r[pages/@hashed/tmp/work]
context 'when object store is REMOTE' do
before do
diff --git a/spec/uploaders/personal_file_uploader_spec.rb b/spec/uploaders/personal_file_uploader_spec.rb
index 1373ccac23d..58edf3f093d 100644
--- a/spec/uploaders/personal_file_uploader_spec.rb
+++ b/spec/uploaders/personal_file_uploader_spec.rb
@@ -50,9 +50,9 @@ RSpec.describe PersonalFileUploader do
context 'object_store is LOCAL' do
it_behaves_like 'builds correct paths',
- store_dir: %r[uploads/-/system/personal_snippet/\d+/\h+],
- upload_path: %r[\h+/\S+],
- absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/\h+/\S+$]
+ store_dir: %r[uploads/-/system/personal_snippet/\d+/\h+],
+ upload_path: %r[\h+/\S+],
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/\h+/\S+$]
it_behaves_like '#base_dir'
it_behaves_like '#to_h'
@@ -66,8 +66,8 @@ RSpec.describe PersonalFileUploader do
include_context 'with storage', described_class::Store::REMOTE
it_behaves_like 'builds correct paths',
- store_dir: %r[\d+/\h+],
- upload_path: %r[^personal_snippet/\d+/\h+/<filename>]
+ store_dir: %r[\d+/\h+],
+ upload_path: %r[^personal_snippet/\d+/\h+/<filename>]
it_behaves_like '#base_dir'
it_behaves_like '#to_h'
diff --git a/spec/validators/addressable_url_validator_spec.rb b/spec/validators/addressable_url_validator_spec.rb
index 9109a899881..c95c0563a55 100644
--- a/spec/validators/addressable_url_validator_spec.rb
+++ b/spec/validators/addressable_url_validator_spec.rb
@@ -49,10 +49,15 @@ RSpec.describe AddressableUrlValidator do
end
end
- it 'provides all arguments to UrlBlock validate' do
+ it 'provides all arguments to UrlBlocker.validate!' do
+ # AddressableUrlValidator evaluates all procs before passing as arguments.
+ expected_opts = described_class::BLOCKER_VALIDATE_OPTIONS.transform_values do |value|
+ value.is_a?(Proc) ? value.call : value
+ end
+
expect(Gitlab::UrlBlocker)
.to receive(:validate!)
- .with(badge.link_url, described_class::BLOCKER_VALIDATE_OPTIONS)
+ .with(badge.link_url, expected_opts)
.and_return(true)
subject
@@ -302,6 +307,67 @@ RSpec.describe AddressableUrlValidator do
end
end
+ context 'when deny_all_requests_except_allowed is' do
+ let(:url) { 'http://example.com' }
+ let(:options) { { attributes: [:link_url] } }
+ let(:validator) { described_class.new(**options) }
+
+ context 'true' do
+ let(:options) { super().merge(deny_all_requests_except_allowed: true) }
+
+ it 'prevents the url' do
+ badge.link_url = url
+
+ subject
+
+ expect(badge.errors).to be_present
+ end
+ end
+
+ context 'false' do
+ let(:options) { super().merge(deny_all_requests_except_allowed: false) }
+
+ it 'allows the url' do
+ badge.link_url = url
+
+ subject
+
+ expect(badge.errors).to be_empty
+ end
+ end
+
+ context 'not given' do
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:current_application_settings?).and_return(true)
+ stub_application_setting(deny_all_requests_except_allowed: app_setting)
+ end
+
+ context 'when app setting is true' do
+ let(:app_setting) { true }
+
+ it 'prevents the url' do
+ badge.link_url = url
+
+ subject
+
+ expect(badge.errors).to be_present
+ end
+ end
+
+ context 'when app setting is false' do
+ let(:app_setting) { false }
+
+ it 'allows the url' do
+ badge.link_url = url
+
+ subject
+
+ expect(badge.errors).to be_empty
+ end
+ end
+ end
+ end
+
context 'when enforce_sanitization is' do
let(:validator) { described_class.new(attributes: [:link_url], enforce_sanitization: enforce_sanitization) }
let(:unsafe_url) { "https://replaceme.com/'><script>alert(document.cookie)</script>" }
diff --git a/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb b/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb
index d5aa7139e2b..dc65063c97b 100644
--- a/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb
+++ b/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb
@@ -60,8 +60,9 @@ RSpec.describe 'admin/application_settings/_ci_cd' do
expect(rendered).to have_field('Maximum number of runners registered per project', type: 'number')
expect(page.find_field('Maximum number of runners registered per project').value).to eq('70')
- expect(rendered).to have_field("Maximum number of downstream pipelines in a pipeline's hierarchy tree",
-type: 'number')
+ expect(rendered).to have_field(
+ "Maximum number of downstream pipelines in a pipeline's hierarchy tree", type: 'number'
+ )
expect(page.find_field("Maximum number of downstream pipelines in a pipeline's hierarchy tree").value)
.to eq('300')
end
diff --git a/spec/views/admin/application_settings/_repository_check.html.haml_spec.rb b/spec/views/admin/application_settings/_repository_check.html.haml_spec.rb
index 011f05eac21..f10ee35060b 100644
--- a/spec/views/admin/application_settings/_repository_check.html.haml_spec.rb
+++ b/spec/views/admin/application_settings/_repository_check.html.haml_spec.rb
@@ -46,12 +46,13 @@ RSpec.describe 'admin/application_settings/_repository_check.html.haml', feature
describe 'inactive project deletion' do
let_it_be(:application_setting) do
- build(:application_setting,
- delete_inactive_projects: true,
- inactive_projects_delete_after_months: 2,
- inactive_projects_min_size_mb: 250,
- inactive_projects_send_warning_email_after_months: 1
- )
+ build(
+ :application_setting,
+ delete_inactive_projects: true,
+ inactive_projects_delete_after_months: 2,
+ inactive_projects_min_size_mb: 250,
+ inactive_projects_send_warning_email_after_months: 1
+ )
end
it 'has the setting subsection' do
diff --git a/spec/views/admin/application_settings/ci_cd.html.haml_spec.rb b/spec/views/admin/application_settings/ci_cd.html.haml_spec.rb
index 5ef9399487f..d2a30f2c5c0 100644
--- a/spec/views/admin/application_settings/ci_cd.html.haml_spec.rb
+++ b/spec/views/admin/application_settings/ci_cd.html.haml_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe 'admin/application_settings/ci_cd.html.haml' do
allow(view).to receive(:current_user).and_return(user)
end
- describe 'CI CD Runner Registration' do
+ describe 'CI CD Runners' do
it 'has the setting section' do
render
@@ -26,6 +26,9 @@ RSpec.describe 'admin/application_settings/ci_cd.html.haml' do
expect(rendered).to have_content("Runner registration")
expect(rendered).to have_content(s_("Runners|If both settings are disabled, new runners cannot be registered."))
+ expect(rendered).to have_content(
+ s_("Runners|Fetch GitLab Runner release version data from GitLab.com")
+ )
end
end
end
diff --git a/spec/views/admin/application_settings/network.html.haml_spec.rb b/spec/views/admin/application_settings/network.html.haml_spec.rb
new file mode 100644
index 00000000000..17515dbcc2c
--- /dev/null
+++ b/spec/views/admin/application_settings/network.html.haml_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'admin/application_settings/network.html.haml', feature_category: :projects do
+ let_it_be(:admin) { build_stubbed(:admin) }
+ let_it_be(:application_setting) { build(:application_setting) }
+
+ before do
+ assign(:application_setting, application_setting)
+ allow(view).to receive(:current_user) { admin }
+ end
+
+ context 'for Projects API rate limit' do
+ it 'renders the `projects_api_rate_limit_unauthenticated` field' do
+ render
+
+ expect(rendered).to have_field('application_setting_projects_api_rate_limit_unauthenticated')
+ end
+ end
+end
diff --git a/spec/views/admin/groups/_form.html.haml_spec.rb b/spec/views/admin/groups/_form.html.haml_spec.rb
new file mode 100644
index 00000000000..87929571a84
--- /dev/null
+++ b/spec/views/admin/groups/_form.html.haml_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'admin/groups/_form', feature_category: :subgroups do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:admin) { build(:user, :admin) }
+ let_it_be(:group) { build(:group, namespace_settings: build(:namespace_settings)) }
+
+ before do
+ allow(view).to receive(:current_user).and_return(admin)
+ allow(view).to receive(:visibility_level).and_return(group.visibility_level)
+ assign(:group, group)
+ end
+
+ describe 'group runner registration setting' do
+ where(:runner_registration_enabled, :valid_runner_registrars, :checked, :disabled) do
+ true | ['group'] | true | false
+ false | ['group'] | false | false
+ false | ['project'] | false | true
+ end
+
+ with_them do
+ before do
+ allow(group).to receive(:runner_registration_enabled?).and_return(runner_registration_enabled)
+ stub_application_setting(valid_runner_registrars: valid_runner_registrars)
+ end
+
+ it 'renders the checkbox correctly' do
+ render
+
+ expect(rendered).to have_field(
+ 'New group runners can be registered',
+ type: 'checkbox',
+ checked: checked,
+ disabled: disabled
+ )
+ end
+ end
+ end
+end
diff --git a/spec/views/admin/projects/_form.html.haml_spec.rb b/spec/views/admin/projects/_form.html.haml_spec.rb
new file mode 100644
index 00000000000..d36b32a1cbc
--- /dev/null
+++ b/spec/views/admin/projects/_form.html.haml_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'admin/projects/_form', feature_category: :projects do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:admin) { build_stubbed(:admin) }
+ let_it_be(:project) { build_stubbed(:project) }
+
+ before do
+ allow(view).to receive(:current_user).and_return(:admin)
+ assign(:project, project)
+ end
+
+ describe 'project runner registration setting' do
+ where(:runner_registration_enabled, :valid_runner_registrars, :checked, :disabled) do
+ true | ['project'] | true | false
+ false | ['project'] | false | false
+ false | ['group'] | false | true
+ end
+
+ with_them do
+ before do
+ allow(project).to receive(:runner_registration_enabled).and_return(runner_registration_enabled)
+ stub_application_setting(valid_runner_registrars: valid_runner_registrars)
+ end
+
+ it 'renders the checkbox correctly' do
+ render
+
+ expect(rendered).to have_field(
+ 'New project runners can be registered',
+ type: 'checkbox',
+ checked: checked,
+ disabled: disabled
+ )
+ end
+ end
+ end
+end
diff --git a/spec/views/admin/sessions/new.html.haml_spec.rb b/spec/views/admin/sessions/new.html.haml_spec.rb
index ac35bbef5b4..c1f4cafce0c 100644
--- a/spec/views/admin/sessions/new.html.haml_spec.rb
+++ b/spec/views/admin/sessions/new.html.haml_spec.rb
@@ -43,9 +43,9 @@ RSpec.describe 'admin/sessions/new.html.haml' do
it 'shows omniauth form' do
render
- expect(rendered).to have_css('.omniauth-container')
- expect(rendered).to have_content _('Sign in with')
expect(rendered).not_to have_content _('No authentication methods configured.')
+ expect(rendered).to have_content _('or')
+ expect(rendered).to have_css('.omniauth-container')
end
end
diff --git a/spec/views/admin/sessions/two_factor.html.haml_spec.rb b/spec/views/admin/sessions/two_factor.html.haml_spec.rb
index c7e0edbcd58..6503c08b84c 100644
--- a/spec/views/admin/sessions/two_factor.html.haml_spec.rb
+++ b/spec/views/admin/sessions/two_factor.html.haml_spec.rb
@@ -29,14 +29,10 @@ RSpec.describe 'admin/sessions/two_factor.html.haml' do
end
end
- context 'user has u2f active' do
- let(:user) { create(:admin, :two_factor_via_u2f) }
+ context 'user has WebAuthn active' do
+ let(:user) { create(:admin, :two_factor_via_webauthn) }
- before do
- stub_feature_flags(webauthn: false)
- end
-
- it 'shows enter u2f form' do
+ it 'shows enter WebAuthn form' do
render
expect(rendered).to have_css('#js-login-2fa-device.btn')
diff --git a/spec/views/ci/status/_badge.html.haml_spec.rb b/spec/views/ci/status/_badge.html.haml_spec.rb
index 6cbd9a61e98..ff8cfe2cca0 100644
--- a/spec/views/ci/status/_badge.html.haml_spec.rb
+++ b/spec/views/ci/status/_badge.html.haml_spec.rb
@@ -49,10 +49,12 @@ RSpec.describe 'ci/status/_badge' do
context 'status has external target url' do
before do
- external_job = create(:generic_commit_status,
- status: :running,
- pipeline: pipeline,
- target_url: 'http://gitlab.com')
+ external_job = create(
+ :generic_commit_status,
+ status: :running,
+ pipeline: pipeline,
+ target_url: 'http://gitlab.com'
+ )
render_status(external_job)
end
diff --git a/spec/views/ci/status/_icon.html.haml_spec.rb b/spec/views/ci/status/_icon.html.haml_spec.rb
index d0579734451..78b19957cf0 100644
--- a/spec/views/ci/status/_icon.html.haml_spec.rb
+++ b/spec/views/ci/status/_icon.html.haml_spec.rb
@@ -48,10 +48,12 @@ RSpec.describe 'ci/status/_icon' do
context 'status has external target url' do
before do
- external_job = create(:generic_commit_status,
- status: :running,
- pipeline: pipeline,
- target_url: 'http://gitlab.com')
+ external_job = create(
+ :generic_commit_status,
+ status: :running,
+ pipeline: pipeline,
+ target_url: 'http://gitlab.com'
+ )
render_status(external_job)
end
diff --git a/spec/views/devise/confirmations/almost_there.html.haml_spec.rb b/spec/views/devise/confirmations/almost_there.html.haml_spec.rb
index c091efe9295..8e12fb5a17e 100644
--- a/spec/views/devise/confirmations/almost_there.html.haml_spec.rb
+++ b/spec/views/devise/confirmations/almost_there.html.haml_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe 'devise/confirmations/almost_there' do
- describe 'confirmations text' do
- subject { render(template: 'devise/confirmations/almost_there') }
+ subject { render(template: 'devise/confirmations/almost_there') }
+ describe 'confirmations text' do
before do
allow(view).to receive(:params).and_return(email: email)
end
@@ -34,4 +34,17 @@ RSpec.describe 'devise/confirmations/almost_there' do
end
end
end
+
+ describe 'register again prompt' do
+ specify do
+ subject
+
+ expect(rendered).to have_content(
+ 'If the email address is incorrect, you can register again with a different email'
+ )
+ expect(rendered).to have_link(
+ 'register again with a different email', href: new_user_registration_path
+ )
+ end
+ end
end
diff --git a/spec/views/devise/sessions/new.html.haml_spec.rb b/spec/views/devise/sessions/new.html.haml_spec.rb
index 798c891e75c..8de2eab36e9 100644
--- a/spec/views/devise/sessions/new.html.haml_spec.rb
+++ b/spec/views/devise/sessions/new.html.haml_spec.rb
@@ -3,14 +3,13 @@
require 'spec_helper'
RSpec.describe 'devise/sessions/new' do
- describe 'marketing text' do
+ describe 'marketing text', :saas do
subject { render(template: 'devise/sessions/new', layout: 'layouts/devise') }
before do
stub_devise
disable_captcha
stub_feature_flags(restyle_login_page: false)
- allow(Gitlab).to receive(:com?).and_return(true)
end
it 'when flash is anything it renders marketing text' do
@@ -32,71 +31,73 @@ RSpec.describe 'devise/sessions/new' do
flag_values = [true, false]
flag_values.each do |val|
- before do
- stub_feature_flags(restyle_login_page: val)
- end
+ context "with #{val}" do
+ before do
+ stub_feature_flags(restyle_login_page: val)
+ end
- describe 'ldap' do
- include LdapHelpers
+ describe 'ldap' do
+ include LdapHelpers
- let(:server) { { provider_name: 'ldapmain', label: 'LDAP' }.with_indifferent_access }
+ let(:server) { { provider_name: 'ldapmain', label: 'LDAP' }.with_indifferent_access }
- before do
- enable_ldap
- stub_devise
- disable_captcha
- disable_sign_up
- disable_other_signin_methods
+ before do
+ enable_ldap
+ stub_devise
+ disable_captcha
+ disable_sign_up
+ disable_other_signin_methods
- allow(view).to receive(:experiment_enabled?).and_return(false)
- end
+ allow(view).to receive(:experiment_enabled?).and_return(false)
+ end
- it 'is shown when enabled' do
- render
+ it 'is shown when enabled' do
+ render
- expect(rendered).to have_selector('.new-session-tabs')
- expect(rendered).to have_selector('[data-testid="ldap-tab"]')
- expect(rendered).to have_field('LDAP Username')
- end
+ expect(rendered).to have_selector('.new-session-tabs')
+ expect(rendered).to have_selector('[data-testid="ldap-tab"]')
+ expect(rendered).to have_field('LDAP Username')
+ end
- it 'is not shown when LDAP sign in is disabled' do
- disable_ldap_sign_in
+ it 'is not shown when LDAP sign in is disabled' do
+ disable_ldap_sign_in
- render
+ render
- expect(rendered).to have_content('No authentication methods configured')
- expect(rendered).not_to have_selector('[data-testid="ldap-tab"]')
- expect(rendered).not_to have_field('LDAP Username')
+ expect(rendered).to have_content('No authentication methods configured')
+ expect(rendered).not_to have_selector('[data-testid="ldap-tab"]')
+ expect(rendered).not_to have_field('LDAP Username')
+ end
end
- end
-
- describe 'Google Tag Manager' do
- let!(:gtm_id) { 'GTM-WWKMTWS' }
- subject { rendered }
+ describe 'Google Tag Manager' do
+ let!(:gtm_id) { 'GTM-WWKMTWS' }
- before do
- stub_devise
- disable_captcha
- stub_config(extra: { google_tag_manager_id: gtm_id, google_tag_manager_nonce_id: gtm_id })
- end
+ subject { rendered }
- describe 'when Google Tag Manager is enabled' do
before do
- enable_gtm
- render
+ stub_devise
+ disable_captcha
+ stub_config(extra: { google_tag_manager_id: gtm_id, google_tag_manager_nonce_id: gtm_id })
end
- it { is_expected.to match /www.googletagmanager.com/ }
- end
+ describe 'when Google Tag Manager is enabled' do
+ before do
+ enable_gtm
+ render
+ end
- describe 'when Google Tag Manager is disabled' do
- before do
- disable_gtm
- render
+ it { is_expected.to match /www.googletagmanager.com/ }
end
- it { is_expected.not_to match /www.googletagmanager.com/ }
+ describe 'when Google Tag Manager is disabled' do
+ before do
+ disable_gtm
+ render
+ end
+
+ it { is_expected.not_to match /www.googletagmanager.com/ }
+ end
end
end
end
diff --git a/spec/views/devise/shared/_error_messages.html.haml_spec.rb b/spec/views/devise/shared/_error_messages.html.haml_spec.rb
new file mode 100644
index 00000000000..9f23b049caf
--- /dev/null
+++ b/spec/views/devise/shared/_error_messages.html.haml_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'devise/shared/_error_messages', feature_category: :system_access do
+ describe 'Error messages' do
+ let(:resource) do
+ instance_spy(User, errors: errors, class: User)
+ end
+
+ before do
+ allow(view).to receive(:resource).and_return(resource)
+ end
+
+ context 'with errors', :aggregate_failures do
+ let(:errors) { errors_stub(['Invalid name', 'Invalid password']) }
+
+ it 'shows errors' do
+ render
+
+ expect(rendered).to have_selector('#error_explanation')
+ expect(rendered).to have_content('Invalid name')
+ expect(rendered).to have_content('Invalid password')
+ end
+ end
+
+ context 'without errors' do
+ let(:errors) { [] }
+
+ it 'does not show errors' do
+ render
+
+ expect(rendered).not_to have_selector('#error_explanation')
+ end
+ end
+ end
+
+ def errors_stub(*messages)
+ ActiveModel::Errors.new(double).tap do |errors|
+ messages.each { |msg| errors.add(:base, msg) }
+ end
+ end
+end
diff --git a/spec/views/devise/shared/_signup_box.html.haml_spec.rb b/spec/views/devise/shared/_signup_box.html.haml_spec.rb
index ee9ccbf6ff5..377e29e18e7 100644
--- a/spec/views/devise/shared/_signup_box.html.haml_spec.rb
+++ b/spec/views/devise/shared/_signup_box.html.haml_spec.rb
@@ -20,6 +20,7 @@ RSpec.describe 'devise/shared/_signup_box' do
before do
stub_devise
+ allow(view).to receive(:arkose_labs_enabled?).and_return(false)
allow(view).to receive(:show_omniauth_providers).and_return(false)
allow(view).to receive(:url).and_return('_url_')
allow(view).to receive(:terms_path).and_return(terms_path)
@@ -29,10 +30,12 @@ RSpec.describe 'devise/shared/_signup_box' do
end
def text(translation)
- format(translation,
- button_text: button_text,
- link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>",
- link_end: '</a>')
+ format(
+ translation,
+ button_text: button_text,
+ link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>",
+ link_end: '</a>'
+ )
end
context 'when terms are enforced' do
diff --git a/spec/views/events/event/_common.html.haml_spec.rb b/spec/views/events/event/_common.html.haml_spec.rb
index 2160245fb63..de6f6d1abfa 100644
--- a/spec/views/events/event/_common.html.haml_spec.rb
+++ b/spec/views/events/event/_common.html.haml_spec.rb
@@ -18,22 +18,9 @@ RSpec.describe 'events/event/_common.html.haml' do
create(:event, :created, project: project, target: work_item, target_type: 'WorkItem', author: user)
end
- context 'when use_iid_in_work_items_path feature flag is disabled' do
- before do
- stub_feature_flags(use_iid_in_work_items_path: false)
- render partial: 'events/event/common', locals: { event: event.present }
- end
-
- it 'renders the correct url' do
- expect(rendered).to have_link(
- work_item.reference_link_text, href: "/#{project.full_path}/-/work_items/#{work_item.id}"
- )
- end
- end
-
it 'renders the correct url with iid' do
expect(rendered).to have_link(
- work_item.reference_link_text, href: "/#{project.full_path}/-/work_items/#{work_item.iid}?iid_path=true"
+ work_item.reference_link_text, href: "/#{project.full_path}/-/work_items/#{work_item.iid}"
)
end
diff --git a/spec/views/groups/edit.html.haml_spec.rb b/spec/views/groups/edit.html.haml_spec.rb
index fda93ebab51..1400791f12b 100644
--- a/spec/views/groups/edit.html.haml_spec.rb
+++ b/spec/views/groups/edit.html.haml_spec.rb
@@ -2,9 +2,13 @@
require 'spec_helper'
-RSpec.describe 'groups/edit.html.haml' do
+RSpec.describe 'groups/edit.html.haml', feature_category: :subgroups do
include Devise::Test::ControllerHelpers
+ before do
+ stub_template 'groups/settings/_code_suggestions' => ''
+ end
+
describe '"Share with group lock" setting' do
let(:root_owner) { create(:user) }
let(:root_group) { create(:group) }
diff --git a/spec/views/groups/group_members/index.html.haml_spec.rb b/spec/views/groups/group_members/index.html.haml_spec.rb
index 0b3b149238f..fdc6b09d32a 100644
--- a/spec/views/groups/group_members/index.html.haml_spec.rb
+++ b/spec/views/groups/group_members/index.html.haml_spec.rb
@@ -25,7 +25,6 @@ RSpec.describe 'groups/group_members/index', :aggregate_failures, feature_catego
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')
end
end
diff --git a/spec/views/groups/packages/index.html.haml_spec.rb b/spec/views/groups/packages/index.html.haml_spec.rb
new file mode 100644
index 00000000000..26f6268a224
--- /dev/null
+++ b/spec/views/groups/packages/index.html.haml_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'groups/packages/index.html.haml', feature_category: :package_registry do
+ let_it_be(:group) { build(:group) }
+
+ subject { rendered }
+
+ before do
+ assign(:group, group)
+ end
+
+ it 'renders vue entrypoint' do
+ render
+
+ expect(rendered).to have_selector('#js-vue-packages-list')
+ end
+
+ describe 'settings path' do
+ it 'without permission sets empty settings path' do
+ allow(view).to receive(:show_group_package_registry_settings).and_return(false)
+
+ render
+
+ expect(rendered).to have_selector('[data-settings-path=""]')
+ end
+
+ it 'with permission sets group settings path' do
+ allow(view).to receive(:show_group_package_registry_settings).and_return(true)
+
+ render
+
+ expect(rendered).to have_selector(
+ "[data-settings-path=\"#{group_settings_packages_and_registries_path(group)}\"]"
+ )
+ end
+ end
+end
diff --git a/spec/views/groups/settings/_general.html.haml_spec.rb b/spec/views/groups/settings/_general.html.haml_spec.rb
new file mode 100644
index 00000000000..9f16e43be13
--- /dev/null
+++ b/spec/views/groups/settings/_general.html.haml_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'groups/settings/_general.html.haml', feature_category: :subgroups do
+ describe 'Group Settings README' do
+ let_it_be(:group) { build_stubbed(:group) }
+ let_it_be(:user) { build_stubbed(:admin) }
+
+ before do
+ assign(:group, group)
+ allow(view).to receive(:current_user).and_return(user)
+ end
+
+ it 'renders #js-group-settings-readme' do
+ render
+
+ expect(rendered).to have_selector('#js-group-settings-readme')
+ end
+ end
+end
diff --git a/spec/views/groups/show.html.haml_spec.rb b/spec/views/groups/show.html.haml_spec.rb
new file mode 100644
index 00000000000..ac687f68ef6
--- /dev/null
+++ b/spec/views/groups/show.html.haml_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'groups/show', feature_category: :subgroups do
+ describe 'group README' do
+ let_it_be(:group) { build_stubbed(:group) }
+ let_it_be(:readme_project) { build_stubbed(:project, :readme) }
+
+ before do
+ assign(:group, group)
+ end
+
+ context 'with readme project' do
+ before do
+ allow(group).to receive(:group_readme).and_return(readme_project)
+ end
+
+ it 'renders #js-group-readme' do
+ render
+
+ expect(rendered).to have_selector('#js-group-readme')
+ end
+ end
+
+ context 'without readme project' do
+ before do
+ allow(group).to receive(:group_readme).and_return(nil)
+ end
+
+ it 'does not render #js-group-readme' do
+ render
+
+ expect(rendered).not_to have_selector('#js-group-readme')
+ end
+ end
+ end
+end
diff --git a/spec/views/help/index.html.haml_spec.rb b/spec/views/help/index.html.haml_spec.rb
index c041c41a412..f530e6a8f8d 100644
--- a/spec/views/help/index.html.haml_spec.rb
+++ b/spec/views/help/index.html.haml_spec.rb
@@ -21,11 +21,6 @@ RSpec.describe 'help/index' do
end
context 'when logged in' do
- def version_link_regexp(path)
- base_url = "#{view.source_host_url}/#{view.source_code_group}"
- %r{#{Regexp.escape(base_url)}/(gitlab|gitlab-foss)/#{Regexp.escape(path)}}
- end
-
before do
stub_user
end
@@ -36,7 +31,7 @@ RSpec.describe 'help/index' do
render
expect(rendered).to match '8.0.2'
- expect(rendered).to have_link('8.0.2', href: version_link_regexp('-/tags/v8.0.2'))
+ expect(rendered).to have_link('8.0.2', href: Gitlab::Source.release_url)
end
it 'shows a link to the commit for pre-releases' do
@@ -45,7 +40,7 @@ RSpec.describe 'help/index' do
render
expect(rendered).to match '8.0.2'
- expect(rendered).to have_link('abcdefg', href: version_link_regexp('-/commits/abcdefg'))
+ expect(rendered).to have_link('abcdefg', href: Gitlab::Source.release_url)
end
end
end
diff --git a/spec/views/layouts/_head.html.haml_spec.rb b/spec/views/layouts/_head.html.haml_spec.rb
index f9725c73d05..a44c69748e5 100644
--- a/spec/views/layouts/_head.html.haml_spec.rb
+++ b/spec/views/layouts/_head.html.haml_spec.rb
@@ -59,7 +59,7 @@ RSpec.describe 'layouts/_head' do
render
- expect(rendered).to match('<link rel="stylesheet" media="print" href="/stylesheets/highlight/themes/solarised-light.css" />')
+ expect(rendered).to match('<link rel="stylesheet" media="all" href="/stylesheets/highlight/themes/solarised-light.css" />')
end
context 'when an asset_host is set and snowplow url is set', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/346542' do
diff --git a/spec/views/layouts/_search.html.haml_spec.rb b/spec/views/layouts/_search.html.haml_spec.rb
deleted file mode 100644
index ceb82e3640e..00000000000
--- a/spec/views/layouts/_search.html.haml_spec.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'layouts/_search' do
- let(:group) { nil }
- let(:project) { nil }
- let(:scope) { 'issues' }
- let(:search_context) do
- instance_double(Gitlab::SearchContext,
- project: project,
- group: group,
- scope: scope,
- ref: nil,
- snippets: [],
- search_url: '/search',
- project_metadata: {},
- group_metadata: {})
- end
-
- before do
- allow(view).to receive(:search_context).and_return(search_context)
- allow(search_context).to receive(:code_search?).and_return(false)
- allow(search_context).to receive(:for_snippets?).and_return(false)
- end
-
- shared_examples 'search context scope is set' do
- context 'when rendering' do
- it 'sets the placeholder' do
- render
-
- expect(rendered).to include('placeholder="Search GitLab"')
- expect(rendered).to include('aria-label="Search GitLab"')
- end
- end
-
- context 'when on issues' do
- it 'sets scope to issues' do
- render
-
- expect(rendered).to have_css("input[name='scope'][value='issues']", count: 1, visible: false)
- end
- end
-
- context 'when on merge requests' do
- let(:scope) { 'merge_requests' }
-
- it 'sets scope to merge_requests' do
- render
-
- expect(rendered).to have_css("input[name='scope'][value='merge_requests']", count: 1, visible: false)
- end
- end
- end
-
- context 'when doing project level search' do
- let(:project) { create(:project) }
-
- before do
- allow(search_context).to receive(:for_project?).and_return(true)
- allow(search_context).to receive(:for_group?).and_return(false)
- end
-
- it_behaves_like 'search context scope is set'
- end
-
- context 'when doing group level search' do
- let(:group) { create(:group) }
-
- before do
- allow(search_context).to receive(:for_project?).and_return(false)
- allow(search_context).to receive(:for_group?).and_return(true)
- end
-
- it_behaves_like 'search context scope is set'
- end
-end
diff --git a/spec/views/layouts/application.html.haml_spec.rb b/spec/views/layouts/application.html.haml_spec.rb
index 527ba1498b9..d4d40a9ade9 100644
--- a/spec/views/layouts/application.html.haml_spec.rb
+++ b/spec/views/layouts/application.html.haml_spec.rb
@@ -6,10 +6,6 @@ RSpec.describe 'layouts/application' do
let(:user) { create(:user) }
before do
- allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings)
- allow(view).to receive(:experiment_enabled?).and_return(false)
- allow(view).to receive(:session).and_return({})
- allow(view).to receive(:user_signed_in?).and_return(true)
allow(view).to receive(:current_user).and_return(user)
allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(user))
end
diff --git a/spec/views/layouts/devise.html.haml_spec.rb b/spec/views/layouts/devise.html.haml_spec.rb
index b37bdeceb7e..a9215730370 100644
--- a/spec/views/layouts/devise.html.haml_spec.rb
+++ b/spec/views/layouts/devise.html.haml_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'layouts/devise' do
+RSpec.describe 'layouts/devise', feature_category: :user_management do
it_behaves_like 'a layout which reflects the application theme setting'
describe 'logo' do
@@ -22,4 +22,12 @@ RSpec.describe 'layouts/devise' do
end
end
end
+
+ context 'without broadcast messaging' do
+ it 'does not render the broadcast layout' do
+ render
+
+ expect(rendered).not_to render_template('layouts/_broadcast')
+ end
+ end
end
diff --git a/spec/views/layouts/group.html.haml_spec.rb b/spec/views/layouts/group.html.haml_spec.rb
new file mode 100644
index 00000000000..0b8f735a1d6
--- /dev/null
+++ b/spec/views/layouts/group.html.haml_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'layouts/group', feature_category: :subgroups do
+ let_it_be(:group) { create(:group) } # rubocop:todo RSpec/FactoryBot/AvoidCreate
+ let(:invite_member) { true }
+
+ before do
+ allow(view).to receive(:can_admin_group_member?).and_return(invite_member)
+ assign(:group, group)
+ allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(build_stubbed(:user)))
+ end
+
+ subject do
+ render
+
+ rendered
+ end
+
+ context 'with ability to invite members' do
+ it { is_expected.to have_selector('.js-invite-members-modal') }
+ end
+
+ context 'without ability to invite members' do
+ let(:invite_member) { false }
+
+ it { is_expected.not_to have_selector('.js-invite-members-modal') }
+ end
+end
diff --git a/spec/views/layouts/header/_new_dropdown.haml_spec.rb b/spec/views/layouts/header/_new_dropdown.haml_spec.rb
index 178448022d1..2c5882fce3d 100644
--- a/spec/views/layouts/header/_new_dropdown.haml_spec.rb
+++ b/spec/views/layouts/header/_new_dropdown.haml_spec.rb
@@ -7,13 +7,13 @@ RSpec.describe 'layouts/header/_new_dropdown', feature_category: :navigation do
shared_examples_for 'invite member selector' do
context 'with ability to invite members' do
- it { is_expected.to have_link('Invite members', href: href) }
+ it { is_expected.to have_selector('.js-invite-members-trigger') }
end
context 'without ability to invite members' do
let(:invite_member) { false }
- it { is_expected.not_to have_link('Invite members') }
+ it { is_expected.not_to have_selector('.js-invite-members-trigger') }
end
end
@@ -159,6 +159,29 @@ RSpec.describe 'layouts/header/_new_dropdown', feature_category: :navigation do
expect(rendered).to have_link('New snippet', href: new_snippet_path)
end
+ context 'when partial exists in a menu item' do
+ it 'renders the menu item partial without rendering invite modal partial' do
+ view_model = {
+ title: '_title_',
+ menu_sections: [
+ {
+ title: '_section_title_',
+ menu_items: [
+ ::Gitlab::Nav::TopNavMenuItem
+ .build(id: '_id_', title: '_title_', partial: 'groups/invite_members_top_nav_link')
+ ]
+ }
+ ]
+ }
+
+ allow(view).to receive(:new_dropdown_view_model).and_return(view_model)
+
+ render
+
+ expect(response).to render_template(partial: 'groups/_invite_members_top_nav_link')
+ end
+ end
+
context 'when the user is not allowed to do anything' do
let(:user) { create(:user, :external) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
diff --git a/spec/views/layouts/minimal.html.haml_spec.rb b/spec/views/layouts/minimal.html.haml_spec.rb
new file mode 100644
index 00000000000..97cd699d32f
--- /dev/null
+++ b/spec/views/layouts/minimal.html.haml_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'layouts/minimal', feature_category: :onboarding do
+ context 'without broadcast messaging' do
+ it 'does not render the broadcast layout' do
+ render
+
+ expect(rendered).not_to render_template('layouts/_broadcast')
+ end
+ end
+end
diff --git a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
index 163f39568e5..3097598aaca 100644
--- a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
@@ -2,7 +2,14 @@
require 'spec_helper'
-RSpec.describe 'layouts/nav/sidebar/_admin' do
+RSpec.describe 'layouts/nav/sidebar/_admin', feature_category: :navigation do
+ let(:user) { build(:admin) }
+
+ before do
+ allow(user).to receive(:can_admin_all_resources?).and_return(true)
+ allow(view).to receive(:current_user).and_return(user)
+ end
+
shared_examples 'page has active tab' do |title|
it "activates #{title} tab" do
render
@@ -32,7 +39,7 @@ RSpec.describe 'layouts/nav/sidebar/_admin' do
context 'on projects' do
before do
- allow(controller).to receive(:controller_name).and_return('projects')
+ allow(controller).to receive(:controller_name).and_return('admin/projects')
allow(controller).to receive(:controller_path).and_return('admin/projects')
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 cddff276317..94ea9043857 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -106,11 +106,11 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
end
end
- describe 'Contributors' do
+ describe 'Contributor statistics' do
it 'has a link to the project contributors path' do
render
- expect(rendered).to have_link('Contributors', href: project_graph_path(project, current_ref, ref_type: 'heads'))
+ expect(rendered).to have_link('Contributor statistics', href: project_graph_path(project, current_ref, ref_type: 'heads'))
end
end
@@ -122,11 +122,11 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
end
end
- describe 'Compare' do
+ describe 'Compare revisions' do
it 'has a link to the project compare path' do
render
- expect(rendered).to have_link('Compare', href: project_compare_index_path(project, from: project.repository.root_ref, to: current_ref))
+ expect(rendered).to have_link('Compare revisions', href: project_compare_index_path(project, from: project.repository.root_ref, to: current_ref))
end
end
end
@@ -310,7 +310,7 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
it 'top level navigation link is not visible' do
render
- expect(rendered).not_to have_link('Security & Compliance')
+ expect(rendered).not_to have_link('Security and Compliance')
end
end
@@ -322,11 +322,11 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
end
it 'top level navigation link is visible' do
- expect(rendered).to have_link('Security & Compliance')
+ expect(rendered).to have_link('Security and Compliance')
end
it 'security configuration link is visible' do
- expect(rendered).to have_link('Configuration', href: project_security_configuration_path(project))
+ expect(rendered).to have_link('Security configuration', href: project_security_configuration_path(project))
end
end
end
@@ -334,12 +334,12 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
describe 'Deployments' do
let(:page) { Nokogiri::HTML.parse(rendered) }
- describe 'Feature Flags' do
+ describe 'Feature flags' do
it 'has a link to the feature flags page' do
render
- expect(page.at_css('.shortcuts-deployments').parent.css('[aria-label="Feature Flags"]')).not_to be_empty
- expect(rendered).to have_link('Feature Flags', href: project_feature_flags_path(project))
+ expect(page.at_css('.shortcuts-deployments').parent.css('[aria-label="Feature flags"]')).not_to be_empty
+ expect(rendered).to have_link('Feature flags', href: project_feature_flags_path(project))
end
describe 'when the user does not have access' do
@@ -348,7 +348,7 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
it 'does not have a link to the feature flags page' do
render
- expect(rendered).not_to have_link('Feature Flags')
+ expect(rendered).not_to have_link('Feature flags')
end
end
end
@@ -382,6 +382,10 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
end
describe 'Monitor' do
+ before do
+ stub_feature_flags(remove_monitor_metrics: false)
+ end
+
it 'top level navigation link is visible for user with permissions' do
render
@@ -466,7 +470,7 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
it 'has a link to the terraform page' do
render
- expect(rendered).to have_link('Terraform', href: project_terraform_index_path(project))
+ expect(rendered).to have_link('Terraform states', href: project_terraform_index_path(project))
end
describe 'when the user does not have access' do
@@ -475,7 +479,7 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
it 'does not have a link to the terraform page' do
render
- expect(rendered).not_to have_link('Terraform')
+ expect(rendered).not_to have_link('Terraform states')
end
end
end
@@ -567,11 +571,11 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
end
end
- describe 'Infrastructure Registry' do
- it 'shows link to infrastructure registry page' do
+ describe 'Terraform modules' do
+ it 'shows link to terraform modules page' do
render
- expect(rendered).to have_link('Infrastructure Registry', href: project_infrastructure_registry_index_path(project))
+ expect(rendered).to have_link('Terraform modules', href: project_infrastructure_registry_index_path(project))
end
context 'when package registry config is disabled' do
@@ -580,7 +584,7 @@ RSpec.describe 'layouts/nav/sidebar/_project', feature_category: :navigation do
render
- expect(rendered).not_to have_link('Infrastructure Registry', href: project_infrastructure_registry_index_path(project))
+ expect(rendered).not_to have_link('Terraform modules', href: project_infrastructure_registry_index_path(project))
end
end
end
diff --git a/spec/views/layouts/project.html.haml_spec.rb b/spec/views/layouts/project.html.haml_spec.rb
new file mode 100644
index 00000000000..588828f7bd6
--- /dev/null
+++ b/spec/views/layouts/project.html.haml_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'layouts/project', feature_category: :projects do
+ let(:invite_member) { true }
+
+ before do
+ allow(view).to receive(:can_admin_project_member?).and_return(invite_member)
+ assign(:project, build_stubbed(:project))
+ allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(build_stubbed(:user)))
+ end
+
+ subject do
+ render
+
+ rendered
+ end
+
+ context 'with ability to invite members' do
+ it { is_expected.to have_selector('.js-invite-members-modal') }
+ end
+
+ context 'without ability to invite members' do
+ let(:invite_member) { false }
+
+ it { is_expected.not_to have_selector('.js-invite-members-modal') }
+ end
+end
diff --git a/spec/views/notify/autodevops_disabled_email.text.erb_spec.rb b/spec/views/notify/autodevops_disabled_email.text.erb_spec.rb
index d8299d637e1..4e053711dcf 100644
--- a/spec/views/notify/autodevops_disabled_email.text.erb_spec.rb
+++ b/spec/views/notify/autodevops_disabled_email.text.erb_spec.rb
@@ -9,12 +9,14 @@ RSpec.describe 'notify/autodevops_disabled_email.text.erb' do
let(:project) { create(:project, :repository) }
let(:pipeline) do
- create(:ci_pipeline,
- :failed,
- project: project,
- user: user,
- ref: project.default_branch,
- sha: project.commit.sha)
+ create(
+ :ci_pipeline,
+ :failed,
+ project: project,
+ user: user,
+ ref: project.default_branch,
+ sha: project.commit.sha
+ )
end
before do
diff --git a/spec/views/notify/import_issues_csv_email.html.haml_spec.rb b/spec/views/notify/import_issues_csv_email.html.haml_spec.rb
index 43dfab87ac9..c3d320a837b 100644
--- a/spec/views/notify/import_issues_csv_email.html.haml_spec.rb
+++ b/spec/views/notify/import_issues_csv_email.html.haml_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'notify/import_issues_csv_email.html.haml' do
let(:user) { create(:user) }
let(:project) { create(:project) }
- let(:correct_results) { { success: 3, valid_file: true } }
- let(:errored_results) { { success: 3, error_lines: [5, 6, 7], valid_file: true } }
+ let(:correct_results) { { success: 3, parse_error: false } }
+ let(:errored_results) { { success: 3, error_lines: [5, 6, 7], parse_error: false } }
let(:parse_error_results) { { success: 0, parse_error: true } }
before do
diff --git a/spec/views/notify/import_work_items_csv_email.html.haml_spec.rb b/spec/views/notify/import_work_items_csv_email.html.haml_spec.rb
new file mode 100644
index 00000000000..989481fc2e6
--- /dev/null
+++ b/spec/views/notify/import_work_items_csv_email.html.haml_spec.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'notify/import_work_items_csv_email.html.haml', feature_category: :team_planning do
+ let_it_be(:user) { create(:user) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+ let_it_be(:project) { create(:project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+
+ let(:parse_error) { "Error parsing CSV file. Please make sure it has the correct format" }
+
+ before do
+ assign(:user, user)
+ assign(:project, project)
+ assign(:results, results)
+
+ render
+ end
+
+ shared_examples_for 'no records created' do
+ specify do
+ expect(rendered).to have_content("No work items have been imported.")
+ expect(rendered).not_to have_content("work items successfully imported.")
+ end
+ end
+
+ shared_examples_for 'work item records created' do
+ specify do
+ expect(rendered).not_to have_content("No work items have been imported.")
+ expect(rendered).to have_content("work items successfully imported.")
+ end
+ end
+
+ shared_examples_for 'contains project link' do
+ specify do
+ expect(rendered).to have_link(project.full_name, href: project_url(project))
+ end
+ end
+
+ shared_examples_for 'contains parse error' do
+ specify do
+ expect(rendered).to have_content(parse_error)
+ end
+ end
+
+ shared_examples_for 'does not contain parse error' do
+ specify do
+ expect(rendered).not_to have_content(parse_error)
+ end
+ end
+
+ context 'when no errors found while importing' do
+ let(:results) { { success: 3, parse_error: false } }
+
+ it 'renders correctly' do
+ expect(rendered).not_to have_content("Errors found on line")
+ end
+
+ it_behaves_like 'contains project link'
+ it_behaves_like 'work item records created'
+ it_behaves_like 'does not contain parse error'
+ end
+
+ context 'when import errors reported' do
+ let(:results) { { success: 3, error_lines: [5, 6, 7], parse_error: false } }
+
+ it 'renders correctly' do
+ expect(rendered).to have_content("Errors found on lines: #{results[:error_lines].join(', ')}. \
+Please check that these lines have the following fields: title, type")
+ end
+
+ it_behaves_like 'contains project link'
+ it_behaves_like 'work item records created'
+ it_behaves_like 'does not contain parse error'
+ end
+
+ context 'when parse error reported while importing' do
+ let(:results) { { success: 0, parse_error: true } }
+
+ it_behaves_like 'contains project link'
+ it_behaves_like 'no records created'
+ it_behaves_like 'contains parse error'
+ end
+
+ context 'when work item type column contains blank entries' do
+ let(:results) { { success: 0, parse_error: false, type_errors: { blank: [4] } } }
+
+ it 'renders with missing work item message' do
+ expect(rendered).to have_content("Work item type is empty")
+ end
+
+ it_behaves_like 'contains project link'
+ it_behaves_like 'no records created'
+ it_behaves_like 'does not contain parse error'
+ end
+
+ context 'when work item type column contains missing entries' do
+ let(:results) { { success: 0, parse_error: false, type_errors: { missing: [5] } } }
+
+ it 'renders with missing work item message' do
+ expect(rendered).to have_content("Work item type cannot be found or is not supported.")
+ end
+
+ it_behaves_like 'contains project link'
+ it_behaves_like 'no records created'
+ it_behaves_like 'does not contain parse error'
+ end
+
+ context 'when work item type column contains disallowed entries' do
+ let(:results) { { success: 0, parse_error: false, type_errors: { disallowed: [6] } } }
+
+ it 'renders with missing work item message' do
+ expect(rendered).to have_content("Work item type is not available.")
+ end
+
+ it_behaves_like 'contains project link'
+ it_behaves_like 'no records created'
+ it_behaves_like 'does not contain parse error'
+ end
+
+ context 'when CSV contains multiple kinds of work item type errors' do
+ let(:results) { { success: 0, parse_error: false, type_errors: { blank: [4], missing: [5], disallowed: [6] } } }
+
+ it 'renders with missing work item message' do
+ expect(rendered).to have_content("Work item type is empty")
+ expect(rendered).to have_content("Work item type cannot be found or is not supported.")
+ expect(rendered).to have_content("Work item type is not available. Please check your license and permissions.")
+ end
+
+ it_behaves_like 'contains project link'
+ it_behaves_like 'no records created'
+ it_behaves_like 'does not contain parse error'
+ end
+end
diff --git a/spec/views/notify/new_achievement_email.html.haml_spec.rb b/spec/views/notify/new_achievement_email.html.haml_spec.rb
new file mode 100644
index 00000000000..9f577e6c043
--- /dev/null
+++ b/spec/views/notify/new_achievement_email.html.haml_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'email_spec'
+
+RSpec.describe 'notify/new_achievement_email.html.haml', feature_category: :user_profile do
+ let(:user) { build(:user) }
+ let(:achievement) { build(:achievement) }
+
+ before do
+ allow(view).to receive(:message) { instance_double(Mail::Message, subject: 'Subject') }
+ assign(:user, user)
+ assign(:achievement, achievement)
+ end
+
+ it 'contains achievement information' do
+ render
+
+ expect(rendered).to have_content(achievement.namespace.full_path)
+ expect(rendered).to have_content(" awarded you the ")
+ expect(rendered).to have_content(achievement.name)
+ expect(rendered).to have_content(" achievement!")
+
+ expect(rendered).to have_content("View your achievements on your profile")
+ end
+end
diff --git a/spec/views/notify/pipeline_failed_email.text.erb_spec.rb b/spec/views/notify/pipeline_failed_email.text.erb_spec.rb
index dd637af5137..9bd5722954f 100644
--- a/spec/views/notify/pipeline_failed_email.text.erb_spec.rb
+++ b/spec/views/notify/pipeline_failed_email.text.erb_spec.rb
@@ -9,12 +9,14 @@ RSpec.describe 'notify/pipeline_failed_email.text.erb' do
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
let(:pipeline) do
- create(:ci_pipeline,
- :failed,
- project: project,
- user: user,
- ref: project.default_branch,
- sha: project.commit.sha)
+ create(
+ :ci_pipeline,
+ :failed,
+ project: project,
+ user: user,
+ ref: project.default_branch,
+ sha: project.commit.sha
+ )
end
before do
diff --git a/spec/views/profiles/keys/_key.html.haml_spec.rb b/spec/views/profiles/keys/_key.html.haml_spec.rb
index 2ddbd3e6e14..4d14ce7c909 100644
--- a/spec/views/profiles/keys/_key.html.haml_spec.rb
+++ b/spec/views/profiles/keys/_key.html.haml_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'profiles/keys/_key.html.haml', feature_category: :authentication_and_authorization do
+RSpec.describe 'profiles/keys/_key.html.haml', feature_category: :system_access do
let_it_be(:user) { create(:user) }
before do
@@ -12,10 +12,12 @@ RSpec.describe 'profiles/keys/_key.html.haml', feature_category: :authentication
context 'when the key partial is used' do
let_it_be(:key) do
- create(:personal_key,
- user: user,
- last_used_at: 7.days.ago,
- expires_at: 2.days.from_now)
+ create(
+ :personal_key,
+ user: user,
+ last_used_at: 7.days.ago,
+ expires_at: 2.days.from_now
+ )
end
it 'displays the correct values', :aggregate_failures do
@@ -54,9 +56,7 @@ RSpec.describe 'profiles/keys/_key.html.haml', feature_category: :authentication
context 'when the key has not been used' do
let_it_be(:key) do
- create(:personal_key,
- user: user,
- last_used_at: nil)
+ create(:personal_key, user: user, last_used_at: nil)
end
it 'renders "Never" for last used' do
@@ -68,30 +68,21 @@ RSpec.describe 'profiles/keys/_key.html.haml', feature_category: :authentication
end
context 'displays the usage type' do
- where(:usage_type, :usage_type_text, :displayed_buttons, :hidden_buttons, :revoke_ssh_signatures_ff) do
+ where(:usage_type, :usage_type_text, :displayed_buttons, :hidden_buttons) do
[
- [:auth, 'Authentication', ['Remove'], ['Revoke'], true],
- [:auth_and_signing, 'Authentication & Signing', %w[Remove Revoke], [], true],
- [:signing, 'Signing', %w[Remove Revoke], [], true],
- [:auth, 'Authentication', ['Remove'], ['Revoke'], false],
- [:auth_and_signing, 'Authentication & Signing', %w[Remove], ['Revoke'], false],
- [:signing, 'Signing', %w[Remove], ['Revoke'], false]
+ [:auth, 'Authentication', ['Remove'], ['Revoke']],
+ [:auth_and_signing, 'Authentication & Signing', %w[Remove Revoke], []],
+ [:signing, 'Signing', %w[Remove Revoke], []]
]
end
with_them do
let(:key) { create(:key, user: user, usage_type: usage_type) }
- it 'renders usage type text' do
+ it 'renders usage type text and remove/revoke buttons', :aggregate_failures do
render
expect(rendered).to have_text(usage_type_text)
- end
-
- it 'renders remove/revoke buttons', :aggregate_failures do
- stub_feature_flags(revoke_ssh_signatures: revoke_ssh_signatures_ff)
-
- render
displayed_buttons.each do |button|
expect(rendered).to have_text(button)
@@ -106,9 +97,7 @@ RSpec.describe 'profiles/keys/_key.html.haml', feature_category: :authentication
context 'when the key does not have an expiration date' do
let_it_be(:key) do
- create(:personal_key,
- user: user,
- expires_at: nil)
+ create(:personal_key, user: user, expires_at: nil)
end
it 'renders "Never" for expires' do
diff --git a/spec/views/profiles/preferences/show.html.haml_spec.rb b/spec/views/profiles/preferences/show.html.haml_spec.rb
index 6e0c6d67d85..9a177ba0394 100644
--- a/spec/views/profiles/preferences/show.html.haml_spec.rb
+++ b/spec/views/profiles/preferences/show.html.haml_spec.rb
@@ -54,9 +54,9 @@ RSpec.describe 'profiles/preferences/show' do
end
it 'has helpful homepage setup guidance' do
- expect(rendered).to have_selector('[data-label="Dashboard"]')
+ expect(rendered).to have_selector('[data-label="Homepage"]')
expect(rendered).to have_selector("[data-description=" \
- "'Choose what content you want to see by default on your dashboard.']")
+ "'Choose what content you want to see by default on your homepage.']")
end
end
diff --git a/spec/views/projects/_home_panel.html.haml_spec.rb b/spec/views/projects/_home_panel.html.haml_spec.rb
index 6f6a2d9a04d..e5081df4c22 100644
--- a/spec/views/projects/_home_panel.html.haml_spec.rb
+++ b/spec/views/projects/_home_panel.html.haml_spec.rb
@@ -199,18 +199,6 @@ RSpec.describe 'projects/_home_panel' do
expect(rendered).not_to have_content("Forked from #{source_project.full_name}")
end
-
- context 'when fork_divergence_counts is disabled' do
- before do
- stub_feature_flags(fork_divergence_counts: false)
- end
-
- it 'shows the forked-from project' do
- render
-
- expect(rendered).to have_content("Forked from #{source_project.full_name}")
- end
- end
end
context 'user cannot read fork source' do
@@ -223,18 +211,6 @@ RSpec.describe 'projects/_home_panel' do
expect(rendered).not_to have_content("Forked from an inaccessible project")
end
-
- context 'when fork_divergence_counts is disabled' do
- before do
- stub_feature_flags(fork_divergence_counts: false)
- end
-
- it 'shows the message that forked project is inaccessible' do
- render
-
- expect(rendered).to have_content("Forked from an inaccessible project")
- end
- end
end
end
end
diff --git a/spec/views/projects/commit/_commit_box.html.haml_spec.rb b/spec/views/projects/commit/_commit_box.html.haml_spec.rb
index 4335a0901ae..ee76560ac3b 100644
--- a/spec/views/projects/commit/_commit_box.html.haml_spec.rb
+++ b/spec/views/projects/commit/_commit_box.html.haml_spec.rb
@@ -52,8 +52,7 @@ RSpec.describe 'projects/commit/_commit_box.html.haml' do
context 'when pipeline for the commit is blocked' do
let!(:pipeline) do
- create(:ci_pipeline, :blocked, project: project,
- sha: project.commit.id)
+ create(:ci_pipeline, :blocked, project: project, sha: project.commit.id)
end
it 'shows correct pipeline description' do
diff --git a/spec/views/projects/commit/show.html.haml_spec.rb b/spec/views/projects/commit/show.html.haml_spec.rb
index eba54628215..6d2237e773e 100644
--- a/spec/views/projects/commit/show.html.haml_spec.rb
+++ b/spec/views/projects/commit/show.html.haml_spec.rb
@@ -28,19 +28,6 @@ RSpec.describe 'projects/commit/show.html.haml', feature_category: :source_code_
allow(view).to receive(:pagination_params).and_return({})
end
- context 'inline diff view' do
- before do
- allow(view).to receive(:diff_view).and_return(:inline)
- allow(view).to receive(:diff_view).and_return(:inline)
-
- render
- end
-
- it 'has limited width' do
- expect(rendered).to have_selector('.limit-container-width')
- end
- end
-
context 'parallel diff view' do
before do
allow(view).to receive(:diff_view).and_return(:parallel)
diff --git a/spec/views/projects/edit.html.haml_spec.rb b/spec/views/projects/edit.html.haml_spec.rb
index bf154b61609..77336aa7d86 100644
--- a/spec/views/projects/edit.html.haml_spec.rb
+++ b/spec/views/projects/edit.html.haml_spec.rb
@@ -13,9 +13,11 @@ RSpec.describe 'projects/edit' do
assign(:project, project)
allow(controller).to receive(:current_user).and_return(user)
- allow(view).to receive_messages(current_user: user,
- can?: true,
- current_application_settings: Gitlab::CurrentSettings.current_application_settings)
+ allow(view).to receive_messages(
+ current_user: user,
+ can?: true,
+ current_application_settings: Gitlab::CurrentSettings.current_application_settings
+ )
end
context 'project export disabled' do
@@ -101,4 +103,28 @@ RSpec.describe 'projects/edit' do
it_behaves_like 'renders registration features prompt', :project_disabled_repository_size_limit
end
end
+
+ describe 'pages menu entry callout' do
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(show_pages_in_deployments_menu: false)
+ end
+
+ it 'does not show a callout' do
+ render
+ expect(rendered).not_to have_content('GitLab Pages has moved')
+ end
+ end
+
+ context 'with feature flag enabled' do
+ before do
+ stub_feature_flags(show_pages_in_deployments_menu: true)
+ end
+
+ it 'does show a callout' do
+ render
+ expect(rendered).to have_content('GitLab Pages has moved')
+ end
+ end
+ end
end
diff --git a/spec/views/projects/empty.html.haml_spec.rb b/spec/views/projects/empty.html.haml_spec.rb
index 6077dda3c98..2b19b364365 100644
--- a/spec/views/projects/empty.html.haml_spec.rb
+++ b/spec/views/projects/empty.html.haml_spec.rb
@@ -73,9 +73,6 @@ RSpec.describe 'projects/empty' do
expect(rendered).to have_content('Invite your team')
expect(rendered).to have_content('Add members to this project and start collaborating with your team.')
expect(rendered).to have_selector('.js-invite-members-trigger')
- expect(rendered).to have_selector('.js-invite-members-modal')
- expect(rendered).to have_selector('[data-label=invite_members_empty_project]')
- expect(rendered).to have_selector('[data-event=click_button]')
expect(rendered).to have_selector('[data-trigger-source=project-empty-page]')
end
@@ -87,7 +84,6 @@ RSpec.describe 'projects/empty' do
expect(rendered).not_to have_content('Invite your team')
expect(rendered).not_to have_selector('.js-invite-members-trigger')
- expect(rendered).not_to have_selector('.js-invite-members-modal')
end
end
end
diff --git a/spec/views/projects/issues/_related_issues.html.haml_spec.rb b/spec/views/projects/issues/_related_issues.html.haml_spec.rb
new file mode 100644
index 00000000000..0dbca032c4b
--- /dev/null
+++ b/spec/views/projects/issues/_related_issues.html.haml_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'projects/issues/_related_issues.html.haml', feature_category: :team_planning do
+ let_it_be(:project) { build_stubbed(:project) }
+ let_it_be(:issue) { build_stubbed(:issue, project: project) }
+
+ context 'when current user cannot read issue link for the project' do
+ before do
+ allow(view).to receive(:can?).and_return(false)
+ end
+
+ it 'does not render the related issues root node' do
+ render
+
+ expect(rendered).not_to have_selector(".js-related-issues-root")
+ end
+ end
+
+ context 'when current user can read issue link for the project' do
+ before do
+ allow(view).to receive(:can?).and_return(true)
+
+ assign(:project, project)
+ assign(:issue, issue)
+ end
+
+ it 'adds the report abuse path as a data attribute' do
+ render
+
+ expect(rendered).to have_selector(
+ ".js-related-issues-root[data-report-abuse-path=\"#{add_category_abuse_reports_path}\"]"
+ )
+ end
+ end
+end
diff --git a/spec/views/projects/merge_requests/edit.html.haml_spec.rb b/spec/views/projects/merge_requests/edit.html.haml_spec.rb
index 75956160c0a..bb8a4455775 100644
--- a/spec/views/projects/merge_requests/edit.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/edit.html.haml_spec.rb
@@ -35,30 +35,76 @@ RSpec.describe 'projects/merge_requests/edit.html.haml' do
.and_return(User.find(closed_merge_request.author_id))
end
- context 'when a merge request without fork' do
- it "shows editable fields" do
- unlink_project.execute
- closed_merge_request.reload
-
+ shared_examples 'merge request shows editable fields' do
+ it 'shows editable fields' do
render
expect(rendered).to have_field('merge_request[title]')
- expect(rendered).to have_field('merge_request[description]')
- expect(rendered).to have_selector('input[name="merge_request[label_ids][]"]', visible: false)
+ expect(rendered).to have_selector('input[name="merge_request[description]"]', visible: false)
expect(rendered).to have_selector('.js-milestone-dropdown-root')
- expect(rendered).not_to have_selector('#merge_request_target_branch', visible: false)
+ expect(rendered).to have_selector('#merge_request_target_branch', visible: false)
end
end
- context 'when a merge request with an existing source project is closed' do
- it "shows editable fields" do
- render
+ context 'with the visible_label_selection_on_metadata feature flag enabled' do
+ before do
+ stub_feature_flags(visible_label_selection_on_metadata: true)
+ end
- expect(rendered).to have_field('merge_request[title]')
- expect(rendered).to have_field('merge_request[description]')
- expect(rendered).to have_selector('input[name="merge_request[label_ids][]"]', visible: false)
- expect(rendered).to have_selector('.js-milestone-dropdown-root')
- expect(rendered).to have_selector('#merge_request_target_branch', visible: false)
+ context 'when a merge request without fork' do
+ it_behaves_like 'merge request shows editable fields'
+
+ it "shows editable fields" do
+ unlink_project.execute
+ closed_merge_request.reload
+
+ render
+
+ expect(rendered).not_to have_selector('#merge_request_target_branch', visible: false)
+ expect(rendered).to have_selector('.js-issuable-form-label-selector')
+ end
+ end
+
+ context 'when a merge request with an existing source project is closed' do
+ it_behaves_like 'merge request shows editable fields'
+
+ it "shows editable fields" do
+ render
+
+ expect(rendered).to have_selector('#merge_request_target_branch', visible: false)
+ expect(rendered).to have_selector('.js-issuable-form-label-selector')
+ end
+ end
+ end
+
+ context 'with the visible_label_selection_on_metadata feature flag disabled' do
+ before do
+ stub_feature_flags(visible_label_selection_on_metadata: false)
+ end
+
+ context 'when a merge request without fork' do
+ it_behaves_like 'merge request shows editable fields'
+
+ it "shows editable fields" do
+ unlink_project.execute
+ closed_merge_request.reload
+
+ render
+
+ expect(rendered).not_to have_selector('#merge_request_target_branch', visible: false)
+ expect(rendered).not_to have_selector('.js-issuable-form-label-selector')
+ end
+ end
+
+ context 'when a merge request with an existing source project is closed' do
+ it_behaves_like 'merge request shows editable fields'
+
+ it "shows editable fields" do
+ render
+
+ expect(rendered).to have_selector('#merge_request_target_branch', visible: false)
+ expect(rendered).not_to have_selector('.js-issuable-form-label-selector')
+ end
end
end
end
diff --git a/spec/views/projects/packages/index.html.haml_spec.rb b/spec/views/projects/packages/index.html.haml_spec.rb
new file mode 100644
index 00000000000..2557ceb70b3
--- /dev/null
+++ b/spec/views/projects/packages/index.html.haml_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'projects/packages/packages/index.html.haml', feature_category: :package_registry do
+ let_it_be(:project) { build(:project) }
+
+ subject { rendered }
+
+ before do
+ assign(:project, project)
+ end
+
+ it 'renders vue entrypoint' do
+ render
+
+ expect(rendered).to have_selector('#js-vue-packages-list')
+ end
+
+ describe 'settings path' do
+ it 'without permission sets empty settings path' do
+ allow(view).to receive(:show_package_registry_settings).and_return(false)
+
+ render
+
+ expect(rendered).to have_selector('[data-settings-path=""]')
+ end
+
+ it 'with permission sets project settings path' do
+ allow(view).to receive(:show_package_registry_settings).and_return(true)
+
+ render
+
+ expect(rendered).to have_selector(
+ "[data-settings-path=\"#{project_settings_packages_and_registries_path(project)}\"]"
+ )
+ end
+ end
+end
diff --git a/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb b/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb
index 37c9908af1d..13ec7207ec9 100644
--- a/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb
+++ b/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe 'projects/pipeline_schedules/_pipeline_schedule' do
let(:user) { maintainer }
before do
- allow(view).to receive(:can?).with(maintainer, :take_ownership_pipeline_schedule, pipeline_schedule).and_return(true)
+ allow(view).to receive(:can?).with(maintainer, :admin_pipeline_schedule, pipeline_schedule).and_return(true)
end
it 'non-owner can take ownership of pipeline' do
@@ -36,7 +36,7 @@ RSpec.describe 'projects/pipeline_schedules/_pipeline_schedule' do
let(:user) { owner }
before do
- allow(view).to receive(:can?).with(owner, :take_ownership_pipeline_schedule, pipeline_schedule).and_return(false)
+ allow(view).to receive(:can?).with(owner, :admin_pipeline_schedule, pipeline_schedule).and_return(false)
end
it 'owner cannot take ownership of pipeline' do
diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb
index b9c7da20d1a..81a11874886 100644
--- a/spec/views/projects/pipelines/show.html.haml_spec.rb
+++ b/spec/views/projects/pipelines/show.html.haml_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'projects/pipelines/show', feature_category: :pipeline_authoring do
+RSpec.describe 'projects/pipelines/show', feature_category: :pipeline_composition do
include Devise::Test::ControllerHelpers
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
@@ -13,6 +13,7 @@ RSpec.describe 'projects/pipelines/show', feature_category: :pipeline_authoring
before do
assign(:project, project)
assign(:pipeline, presented_pipeline)
+ allow(view).to receive(:current_user) { user }
end
context 'when pipeline has errors' do
@@ -32,6 +33,22 @@ RSpec.describe 'projects/pipelines/show', feature_category: :pipeline_authoring
expect(rendered).not_to have_selector('#js-pipeline-tabs')
end
+
+ it 'renders the pipeline editor button with correct link for users who can view' do
+ project.add_developer(user)
+
+ render
+
+ expect(rendered).to have_link s_('Go to the pipeline editor'),
+ href: project_ci_pipeline_editor_path(project)
+ end
+
+ it 'renders the pipeline editor button with correct link for users who can not view' do
+ render
+
+ expect(rendered).not_to have_link s_('Go to the pipeline editor'),
+ href: project_ci_pipeline_editor_path(project)
+ end
end
context 'when pipeline is valid' do
diff --git a/spec/views/projects/project_members/index.html.haml_spec.rb b/spec/views/projects/project_members/index.html.haml_spec.rb
index 4c4cde01cca..2fcc5c6935b 100644
--- a/spec/views/projects/project_members/index.html.haml_spec.rb
+++ b/spec/views/projects/project_members/index.html.haml_spec.rb
@@ -28,7 +28,6 @@ RSpec.describe 'projects/project_members/index', :aggregate_failures, feature_ca
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
diff --git a/spec/views/projects/runners/_project_runners.html.haml_spec.rb b/spec/views/projects/runners/_project_runners.html.haml_spec.rb
index 8a7e693bdeb..d96b77b368c 100644
--- a/spec/views/projects/runners/_project_runners.html.haml_spec.rb
+++ b/spec/views/projects/runners/_project_runners.html.haml_spec.rb
@@ -15,30 +15,66 @@ RSpec.describe 'projects/runners/_project_runners.html.haml', feature_category:
allow(view).to receive(:reset_registration_token_namespace_project_settings_ci_cd_path).and_return('banana_url')
end
- context 'when project runner registration is allowed' do
+ context 'when create_runner_workflow_for_namespace is disabled' do
before do
- stub_application_setting(valid_runner_registrars: ['project'])
- allow(view).to receive(:can?).with(user, :register_project_runners, project).and_return(true)
+ stub_feature_flags(create_runner_workflow_for_namespace: false)
end
- it 'enables the Remove project button for a project' do
- render 'projects/runners/project_runners', project: project
+ context 'when project runner registration is allowed' do
+ before do
+ stub_application_setting(valid_runner_registrars: ['project'])
+ allow(view).to receive(:can?).with(user, :register_project_runners, project).and_return(true)
+ end
- expect(rendered).to have_selector '#js-install-runner'
- expect(rendered).not_to have_content 'Please contact an admin to register runners.'
+ it 'enables the Remove project button for a project' do
+ render 'projects/runners/project_runners', project: project
+
+ expect(rendered).to have_selector '#js-install-runner'
+ expect(rendered).not_to have_content 'Please contact an admin to register runners.'
+ end
+ end
+
+ context 'when project runner registration is not allowed' do
+ before do
+ stub_application_setting(valid_runner_registrars: ['group'])
+ end
+
+ it 'does not enable the Remove project button for a project' do
+ render 'projects/runners/project_runners', project: project
+
+ expect(rendered).to have_content 'Please contact an admin to register runners.'
+ expect(rendered).not_to have_selector '#js-install-runner'
+ end
end
end
- context 'when project runner registration is not allowed' do
+ context 'when create_runner_workflow_for_namespace is enabled' do
before do
- stub_application_setting(valid_runner_registrars: ['group'])
+ stub_feature_flags(create_runner_workflow_for_namespace: project.namespace)
end
- it 'does not enable the Remove project button for a project' do
- render 'projects/runners/project_runners', project: project
+ context 'when user can create project runner' do
+ before do
+ allow(view).to receive(:can?).with(user, :create_runner, project).and_return(true)
+ end
+
+ it 'renders the New project runner button' do
+ render 'projects/runners/project_runners', project: project
+
+ expect(rendered).to have_link(s_('Runners|New project runner'), href: new_project_runner_path(project))
+ end
+ end
+
+ context 'when user cannot create project runner' do
+ before do
+ allow(view).to receive(:can?).with(user, :create_runner, project).and_return(false)
+ end
+
+ it 'does not render the New project runner button' do
+ render 'projects/runners/project_runners', project: project
- expect(rendered).to have_content 'Please contact an admin to register runners.'
- expect(rendered).not_to have_selector '#js-install-runner'
+ expect(rendered).not_to have_link(s_('Runners|New project runner'))
+ end
end
end
end
diff --git a/spec/views/projects/settings/merge_requests/show.html.haml_spec.rb b/spec/views/projects/settings/merge_requests/show.html.haml_spec.rb
index 821f430eb10..1a7bfc5b5cd 100644
--- a/spec/views/projects/settings/merge_requests/show.html.haml_spec.rb
+++ b/spec/views/projects/settings/merge_requests/show.html.haml_spec.rb
@@ -13,9 +13,11 @@ RSpec.describe 'projects/settings/merge_requests/show' do
assign(:project, project)
allow(controller).to receive(:current_user).and_return(user)
- allow(view).to receive_messages(current_user: user,
- can?: true,
- current_application_settings: Gitlab::CurrentSettings.current_application_settings)
+ allow(view).to receive_messages(
+ current_user: user,
+ can?: true,
+ current_application_settings: Gitlab::CurrentSettings.current_application_settings
+ )
end
describe 'merge suggestions settings' do
diff --git a/spec/views/projects/tags/index.html.haml_spec.rb b/spec/views/projects/tags/index.html.haml_spec.rb
index 99db5d9e2a8..dfa27afb72f 100644
--- a/spec/views/projects/tags/index.html.haml_spec.rb
+++ b/spec/views/projects/tags/index.html.haml_spec.rb
@@ -6,9 +6,7 @@ RSpec.describe 'projects/tags/index.html.haml' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:git_tag) { project.repository.tags.last }
let_it_be(:release) do
- create(:release, project: project,
- sha: git_tag.target_commit.sha,
- tag: 'v1.1.0')
+ create(:release, project: project, sha: git_tag.target_commit.sha, tag: 'v1.1.0')
end
let(:pipeline) { create(:ci_pipeline, :success, project: project, ref: git_tag.name, sha: release.sha) }
diff --git a/spec/views/registrations/welcome/show.html.haml_spec.rb b/spec/views/registrations/welcome/show.html.haml_spec.rb
index 372dbf01a64..e229df555b1 100644
--- a/spec/views/registrations/welcome/show.html.haml_spec.rb
+++ b/spec/views/registrations/welcome/show.html.haml_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe 'registrations/welcome/show' do
before do
allow(view).to receive(:current_user).and_return(user)
- allow(view).to receive(:glm_tracking_params).and_return({})
+ allow(view).to receive(:welcome_update_params).and_return({})
render
end
diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb
index ed71a03c7e0..832cc5b7cf3 100644
--- a/spec/views/search/_results.html.haml_spec.rb
+++ b/spec/views/search/_results.html.haml_spec.rb
@@ -97,12 +97,6 @@ RSpec.describe 'search/_results', feature_category: :global_search do
expect(rendered).not_to have_selector('[data-track-property=search_result]')
end
end
-
- it 'does render the sidebar' do
- render
-
- expect(rendered).to have_selector('#js-search-sidebar')
- end
end
end
diff --git a/spec/views/search/show.html.haml_spec.rb b/spec/views/search/show.html.haml_spec.rb
index db06adfeb6b..0158a9049b9 100644
--- a/spec/views/search/show.html.haml_spec.rb
+++ b/spec/views/search/show.html.haml_spec.rb
@@ -41,6 +41,12 @@ RSpec.describe 'search/show', feature_category: :global_search do
expect(rendered).not_to render_template('search/_results')
end
+
+ it 'does render the sidebar' do
+ render
+
+ expect(rendered).to have_selector('#js-search-sidebar')
+ end
end
context 'unfurling support' do
diff --git a/spec/views/shared/_label_row.html.haml_spec.rb b/spec/views/shared/_label_row.html.haml_spec.rb
index 6fe74b6633b..eb277930c1d 100644
--- a/spec/views/shared/_label_row.html.haml_spec.rb
+++ b/spec/views/shared/_label_row.html.haml_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe 'shared/_label_row.html.haml' do
end
it 'shows the path from where the label was created' do
- expect(rendered).to have_css('.label-badge', text: project.full_name)
+ expect(rendered).to have_text(project.full_name)
end
end
@@ -70,7 +70,7 @@ RSpec.describe 'shared/_label_row.html.haml' do
end
it 'shows the path from where the label was created' do
- expect(rendered).to have_css('.label-badge', text: subgroup.full_name)
+ expect(rendered).to have_text(subgroup.full_name)
end
end
diff --git a/spec/views/shared/milestones/_issuables.html.haml_spec.rb b/spec/views/shared/milestones/_issuables.html.haml_spec.rb
index 5eed2c96a45..cd11c028bd7 100644
--- a/spec/views/shared/milestones/_issuables.html.haml_spec.rb
+++ b/spec/views/shared/milestones/_issuables.html.haml_spec.rb
@@ -6,8 +6,13 @@ RSpec.describe 'shared/milestones/_issuables.html.haml' do
let(:issuables_size) { 100 }
before do
- allow(view).to receive_messages(title: nil, id: nil, show_project_name: nil, dom_class: '',
- issuables: double(length: issuables_size).as_null_object)
+ allow(view).to receive_messages(
+ title: nil,
+ id: nil,
+ show_project_name: nil,
+ dom_class: '',
+ issuables: double(length: issuables_size).as_null_object
+ )
stub_template 'shared/milestones/_issuable.html.haml' => ''
end
diff --git a/spec/views/shared/runners/_runner_details.html.haml_spec.rb b/spec/views/shared/runners/_runner_details.html.haml_spec.rb
index 6e95f6e8075..a597c719d87 100644
--- a/spec/views/shared/runners/_runner_details.html.haml_spec.rb
+++ b/spec/views/shared/runners/_runner_details.html.haml_spec.rb
@@ -2,15 +2,18 @@
require 'spec_helper'
-RSpec.describe 'shared/runners/_runner_details.html.haml' do
+RSpec.describe 'shared/runners/_runner_details.html.haml', feature_category: :runner_fleet do
include PageLayoutHelper
- let(:runner) do
- create(:ci_runner, name: 'test runner',
- version: '11.4.0',
- ip_address: '127.1.2.3',
- revision: 'abcd1234',
- architecture: 'amd64' )
+ let_it_be(:runner) do
+ build_stubbed(
+ :ci_runner,
+ name: 'test runner',
+ version: '11.4.0',
+ ip_address: '127.1.2.3',
+ revision: 'abcd1234',
+ architecture: 'amd64'
+ )
end
before do
@@ -22,29 +25,19 @@ RSpec.describe 'shared/runners/_runner_details.html.haml' do
rendered
end
- describe 'Page title' do
- before do
- expect(view).to receive(:page_title).with("##{runner.id} (#{runner.short_sha})")
- end
-
- it 'sets proper page title' do
- render
- end
- end
-
describe 'Runner id and type' do
context 'when runner is of type instance' do
it { is_expected.to have_content("Runner ##{runner.id} shared") }
end
context 'when runner is of type group' do
- let(:runner) { create(:ci_runner, :group) }
+ let(:runner) { build_stubbed(:ci_runner, :group) }
it { is_expected.to have_content("Runner ##{runner.id} group") }
end
context 'when runner is of type project' do
- let(:runner) { create(:ci_runner, :project) }
+ let(:runner) { build_stubbed(:ci_runner, :project) }
it { is_expected.to have_content("Runner ##{runner.id} project") }
end
@@ -56,7 +49,7 @@ RSpec.describe 'shared/runners/_runner_details.html.haml' do
end
context 'when runner is inactive' do
- let(:runner) { create(:ci_runner, :inactive) }
+ let(:runner) { build_stubbed(:ci_runner, :inactive) }
it { is_expected.to have_content('Active No') }
end
@@ -68,7 +61,7 @@ RSpec.describe 'shared/runners/_runner_details.html.haml' do
end
context 'when runner is protected' do
- let(:runner) { create(:ci_runner, :ref_protected) }
+ let(:runner) { build_stubbed(:ci_runner, :ref_protected) }
it { is_expected.to have_content('Protected Yes') }
end
@@ -80,7 +73,7 @@ RSpec.describe 'shared/runners/_runner_details.html.haml' do
end
context 'when runner run untagged job is unset' do
- let(:runner) { create(:ci_runner, :tagged_only) }
+ let(:runner) { build_stubbed(:ci_runner, :tagged_only) }
it { is_expected.to have_content('Can run untagged jobs No') }
end
@@ -91,19 +84,19 @@ RSpec.describe 'shared/runners/_runner_details.html.haml' do
it { is_expected.to have_content('Locked to this project No') }
context 'when runner is of type group' do
- let(:runner) { create(:ci_runner, :group) }
+ let(:runner) { build_stubbed(:ci_runner, :group) }
it { is_expected.not_to have_content('Locked to this project') }
end
end
context 'when runner locked is set' do
- let(:runner) { create(:ci_runner, :locked) }
+ let(:runner) { build_stubbed(:ci_runner, :locked) }
it { is_expected.to have_content('Locked to this project Yes') }
context 'when runner is of type group' do
- let(:runner) { create(:ci_runner, :group, :locked) }
+ let(:runner) { build_stubbed(:ci_runner, :group, :locked) }
it { is_expected.not_to have_content('Locked to this project') }
end
@@ -117,7 +110,7 @@ RSpec.describe 'shared/runners/_runner_details.html.haml' do
end
context 'when runner have tags' do
- let(:runner) { create(:ci_runner, tag_list: %w(tag2 tag3 tag1)) }
+ let(:runner) { build_stubbed(:ci_runner, tag_list: %w[tag2 tag3 tag1]) }
it { is_expected.to have_content('Tags tag1 tag2 tag3') }
it { is_expected.to have_selector('span.gl-badge.badge.badge-info') }
@@ -135,7 +128,7 @@ RSpec.describe 'shared/runners/_runner_details.html.haml' do
end
describe 'Maximum job timeout value' do
- let(:runner) { create(:ci_runner, maximum_timeout: 5400) }
+ let(:runner) { build_stubbed(:ci_runner, maximum_timeout: 5400) }
it { is_expected.to have_content('Maximum job timeout 1h 30m') }
end
@@ -146,7 +139,7 @@ RSpec.describe 'shared/runners/_runner_details.html.haml' do
end
context 'when runner have already contacted' do
- let(:runner) { create(:ci_runner, contacted_at: DateTime.now - 6.days) }
+ let(:runner) { build_stubbed(:ci_runner, contacted_at: DateTime.now - 6.days) }
let(:expected_contacted_at) { I18n.l(runner.contacted_at, format: "%b %d, %Y") }
it { is_expected.to have_content("Last contact #{expected_contacted_at}") }
diff --git a/spec/workers/admin_email_worker_spec.rb b/spec/workers/admin_email_worker_spec.rb
index 1a5cb90bc17..bedf8f0362f 100644
--- a/spec/workers/admin_email_worker_spec.rb
+++ b/spec/workers/admin_email_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AdminEmailWorker do
+RSpec.describe AdminEmailWorker, feature_category: :source_code_management do
subject(:worker) { described_class.new }
describe '.perform' do
diff --git a/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb b/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb
index 735e4a214a9..cdb7357c184 100644
--- a/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb
+++ b/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Analytics::UsageTrends::CountJobTriggerWorker do
+RSpec.describe Analytics::UsageTrends::CountJobTriggerWorker, feature_category: :devops_reports do
it_behaves_like 'an idempotent worker'
context 'triggers a job for each measurement identifiers' do
diff --git a/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb b/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb
index ee1bbafa9b5..4155e3522a7 100644
--- a/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb
+++ b/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Analytics::UsageTrends::CounterJobWorker do
+RSpec.describe Analytics::UsageTrends::CounterJobWorker, feature_category: :devops_reports do
let_it_be(:user_1) { create(:user) }
let_it_be(:user_2) { create(:user) }
diff --git a/spec/workers/approve_blocked_pending_approval_users_worker_spec.rb b/spec/workers/approve_blocked_pending_approval_users_worker_spec.rb
index bd603bd870d..ffcc58132db 100644
--- a/spec/workers/approve_blocked_pending_approval_users_worker_spec.rb
+++ b/spec/workers/approve_blocked_pending_approval_users_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ApproveBlockedPendingApprovalUsersWorker, type: :worker do
+RSpec.describe ApproveBlockedPendingApprovalUsersWorker, type: :worker, feature_category: :user_profile do
let_it_be(:admin) { create(:admin) }
let_it_be(:active_user) { create(:user) }
let_it_be(:blocked_user) { create(:user, state: 'blocked_pending_approval') }
diff --git a/spec/workers/authorized_keys_worker_spec.rb b/spec/workers/authorized_keys_worker_spec.rb
index 50236f9ea7b..9fab6910441 100644
--- a/spec/workers/authorized_keys_worker_spec.rb
+++ b/spec/workers/authorized_keys_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedKeysWorker do
+RSpec.describe AuthorizedKeysWorker, feature_category: :source_code_management do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb b/spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb
index 9d4d48d0568..77c56497ef0 100644
--- a/spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb
+++ b/spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::PeriodicRecalculateWorker do
+RSpec.describe AuthorizedProjectUpdate::PeriodicRecalculateWorker, feature_category: :source_code_management do
describe '#perform' do
it 'calls AuthorizedProjectUpdate::PeriodicRecalculateService' do
expect_next_instance_of(AuthorizedProjectUpdate::PeriodicRecalculateService) do |service|
diff --git a/spec/workers/authorized_project_update/project_recalculate_per_user_worker_spec.rb b/spec/workers/authorized_project_update/project_recalculate_per_user_worker_spec.rb
index 57a0726000f..5dcb4a67ae4 100644
--- a/spec/workers/authorized_project_update/project_recalculate_per_user_worker_spec.rb
+++ b/spec/workers/authorized_project_update/project_recalculate_per_user_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker do
+RSpec.describe AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker, feature_category: :system_access do
include ExclusiveLeaseHelpers
let_it_be(:project) { create(:project) }
diff --git a/spec/workers/authorized_project_update/project_recalculate_worker_spec.rb b/spec/workers/authorized_project_update/project_recalculate_worker_spec.rb
index a9a15565580..7c9d2891b01 100644
--- a/spec/workers/authorized_project_update/project_recalculate_worker_spec.rb
+++ b/spec/workers/authorized_project_update/project_recalculate_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::ProjectRecalculateWorker do
+RSpec.describe AuthorizedProjectUpdate::ProjectRecalculateWorker, feature_category: :system_access do
include ExclusiveLeaseHelpers
let_it_be(:project) { create(:project) }
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 da4b726c0b5..e6a312d34ce 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::UserRefreshFromReplicaWorker do
+RSpec.describe AuthorizedProjectUpdate::UserRefreshFromReplicaWorker, feature_category: :system_access do
let_it_be(:project) { create(:project) }
let_it_be(:user) { project.namespace.owner }
diff --git a/spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb b/spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb
index 7c0c4d5bab4..fcc157f9998 100644
--- a/spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb
+++ b/spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker do
+RSpec.describe AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker, feature_category: :system_access do
let_it_be(:project) { create(:project) }
let(:user) { project.namespace.owner }
@@ -10,9 +10,7 @@ RSpec.describe AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker do
let(:end_user_id) { start_user_id }
let(:execute_worker) { subject.perform(start_user_id, end_user_id) }
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
describe '#perform' do
context 'checks if project authorization update is required' do
diff --git a/spec/workers/authorized_project_update/user_refresh_with_low_urgency_worker_spec.rb b/spec/workers/authorized_project_update/user_refresh_with_low_urgency_worker_spec.rb
index bd16eeb4712..ef6c3dd43c8 100644
--- a/spec/workers/authorized_project_update/user_refresh_with_low_urgency_worker_spec.rb
+++ b/spec/workers/authorized_project_update/user_refresh_with_low_urgency_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker do
+RSpec.describe AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker, feature_category: :system_access do
it 'is labeled as low urgency' do
expect(described_class.get_urgency).to eq(:low)
end
diff --git a/spec/workers/authorized_projects_worker_spec.rb b/spec/workers/authorized_projects_worker_spec.rb
index fbfde77be97..ea009f06a28 100644
--- a/spec/workers/authorized_projects_worker_spec.rb
+++ b/spec/workers/authorized_projects_worker_spec.rb
@@ -2,6 +2,6 @@
require 'spec_helper'
-RSpec.describe AuthorizedProjectsWorker do
+RSpec.describe AuthorizedProjectsWorker, feature_category: :system_access do
it_behaves_like "refreshes user's project authorizations"
end
diff --git a/spec/workers/auto_devops/disable_worker_spec.rb b/spec/workers/auto_devops/disable_worker_spec.rb
index e1de97e0ce5..8f7f305b186 100644
--- a/spec/workers/auto_devops/disable_worker_spec.rb
+++ b/spec/workers/auto_devops/disable_worker_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe AutoDevops::DisableWorker, '#perform' do
+RSpec.describe AutoDevops::DisableWorker, '#perform', feature_category: :auto_devops do
let(:user) { create(:user, developer_projects: [project]) }
let(:project) { create(:project, :repository, :auto_devops) }
let(:auto_devops) { project.auto_devops }
diff --git a/spec/workers/auto_merge_process_worker_spec.rb b/spec/workers/auto_merge_process_worker_spec.rb
index 00d27d9c2b5..550590ff6a3 100644
--- a/spec/workers/auto_merge_process_worker_spec.rb
+++ b/spec/workers/auto_merge_process_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AutoMergeProcessWorker do
+RSpec.describe AutoMergeProcessWorker, feature_category: :continuous_delivery do
describe '#perform' do
subject { described_class.new.perform(merge_request&.id) }
diff --git a/spec/workers/background_migration/ci_database_worker_spec.rb b/spec/workers/background_migration/ci_database_worker_spec.rb
index 82c562c4042..3f2977a0aaa 100644
--- a/spec/workers/background_migration/ci_database_worker_spec.rb
+++ b/spec/workers/background_migration/ci_database_worker_spec.rb
@@ -2,6 +2,10 @@
require 'spec_helper'
-RSpec.describe BackgroundMigration::CiDatabaseWorker, :clean_gitlab_redis_shared_state, if: Gitlab::Database.has_config?(:ci) do
+RSpec.describe BackgroundMigration::CiDatabaseWorker, :clean_gitlab_redis_shared_state, feature_category: :database do
+ before do
+ skip_if_shared_database(:ci)
+ end
+
it_behaves_like 'it runs background migration jobs', 'ci'
end
diff --git a/spec/workers/background_migration_worker_spec.rb b/spec/workers/background_migration_worker_spec.rb
index 1558c3c9250..32ee6708736 100644
--- a/spec/workers/background_migration_worker_spec.rb
+++ b/spec/workers/background_migration_worker_spec.rb
@@ -2,6 +2,6 @@
require 'spec_helper'
-RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state, feature_category: :database do
it_behaves_like 'it runs background migration jobs', 'main'
end
diff --git a/spec/workers/build_hooks_worker_spec.rb b/spec/workers/build_hooks_worker_spec.rb
index 80dc36d268f..adae0417a9a 100644
--- a/spec/workers/build_hooks_worker_spec.rb
+++ b/spec/workers/build_hooks_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BuildHooksWorker do
+RSpec.describe BuildHooksWorker, feature_category: :continuous_integration do
describe '#perform' do
context 'when build exists' do
let!(:build) { create(:ci_build) }
@@ -42,7 +42,5 @@ RSpec.describe BuildHooksWorker do
end
end
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
end
diff --git a/spec/workers/build_queue_worker_spec.rb b/spec/workers/build_queue_worker_spec.rb
index 0786722e647..079e11acde3 100644
--- a/spec/workers/build_queue_worker_spec.rb
+++ b/spec/workers/build_queue_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BuildQueueWorker do
+RSpec.describe BuildQueueWorker, feature_category: :continuous_integration do
describe '#perform' do
context 'when build exists' do
let!(:build) { create(:ci_build) }
@@ -24,7 +24,5 @@ RSpec.describe BuildQueueWorker do
end
end
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :sticky
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :sticky
end
diff --git a/spec/workers/build_success_worker_spec.rb b/spec/workers/build_success_worker_spec.rb
index 3241c931dc5..be9802eb2ce 100644
--- a/spec/workers/build_success_worker_spec.rb
+++ b/spec/workers/build_success_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BuildSuccessWorker do
+RSpec.describe BuildSuccessWorker, feature_category: :continuous_integration do
describe '#perform' do
subject { described_class.new.perform(build.id) }
diff --git a/spec/workers/bulk_imports/entity_worker_spec.rb b/spec/workers/bulk_imports/entity_worker_spec.rb
index 4cd37c93d5f..dada4ef63b3 100644
--- a/spec/workers/bulk_imports/entity_worker_spec.rb
+++ b/spec/workers/bulk_imports/entity_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::EntityWorker do
+RSpec.describe BulkImports::EntityWorker, feature_category: :importers do
let_it_be(:entity) { create(:bulk_import_entity) }
let_it_be(:pipeline_tracker) do
diff --git a/spec/workers/bulk_imports/export_request_worker_spec.rb b/spec/workers/bulk_imports/export_request_worker_spec.rb
index 7260e0c0f67..2faa28ba489 100644
--- a/spec/workers/bulk_imports/export_request_worker_spec.rb
+++ b/spec/workers/bulk_imports/export_request_worker_spec.rb
@@ -80,7 +80,7 @@ RSpec.describe BulkImports::ExportRequestWorker, feature_category: :importers do
'source_full_path' => entity.source_full_path,
'exception.backtrace' => anything,
'exception.class' => 'NoMethodError',
- 'exception.message' => "undefined method `model_id' for nil:NilClass",
+ 'exception.message' => /^undefined method `model_id' for nil:NilClass/,
'message' => 'Failed to fetch source entity id',
'importer' => 'gitlab_migration',
'source_version' => entity.bulk_import.source_version_info.to_s
diff --git a/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb b/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb
new file mode 100644
index 00000000000..6fbcb267c0a
--- /dev/null
+++ b/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::FinishBatchedRelationExportWorker, feature_category: :importers do
+ let(:export) { create(:bulk_import_export, :started) }
+ let(:batch) { create(:bulk_import_export_batch, :finished, export: export) }
+ let(:export_id) { export.id }
+ let(:job_args) { [export_id] }
+
+ describe '#perform' do
+ it_behaves_like 'an idempotent worker' do
+ it 'marks export as finished and expires batches cache' do
+ cache_key = BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id)
+
+ expect(Gitlab::Cache::Import::Caching).to receive(:expire).with(cache_key, 0)
+
+ perform_multiple(job_args)
+
+ expect(export.reload.finished?).to eq(true)
+ end
+
+ context 'when export is finished' do
+ let(:export) { create(:bulk_import_export, :finished) }
+
+ it 'returns without updating export' do
+ perform_multiple(job_args)
+
+ expect(export.reload.finished?).to eq(true)
+ end
+ end
+
+ context 'when export is failed' do
+ let(:export) { create(:bulk_import_export, :failed) }
+
+ it 'returns without updating export' do
+ perform_multiple(job_args)
+
+ expect(export.reload.failed?).to eq(true)
+ end
+ end
+
+ context 'when export is in progress' do
+ it 'reenqueues itself' do
+ create(:bulk_import_export_batch, :started, export: export)
+
+ expect(described_class).to receive(:perform_in).twice
+
+ perform_multiple(job_args)
+
+ expect(export.reload.started?).to eq(true)
+ end
+ end
+
+ context 'when export timed out' do
+ it 'marks export as failed' do
+ expect(export.reload.failed?).to eq(false)
+ expect(batch.reload.failed?).to eq(false)
+
+ export.update!(updated_at: 1.day.ago)
+
+ perform_multiple(job_args)
+
+ expect(export.reload.failed?).to eq(true)
+ expect(batch.reload.failed?).to eq(true)
+ end
+ end
+
+ context 'when export is missing' do
+ let(:export_id) { nil }
+
+ it 'returns' do
+ expect(described_class).not_to receive(:perform_in)
+
+ perform_multiple(job_args)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb b/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb
new file mode 100644
index 00000000000..4a2c8d48742
--- /dev/null
+++ b/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::RelationBatchExportWorker, feature_category: :importers do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:batch) { create(:bulk_import_export_batch) }
+
+ let(:job_args) { [user.id, batch.id] }
+
+ describe '#perform' do
+ include_examples 'an idempotent worker' do
+ it 'executes RelationBatchExportService' do
+ service = instance_double(BulkImports::RelationBatchExportService)
+
+ expect(BulkImports::RelationBatchExportService)
+ .to receive(:new)
+ .with(user.id, batch.id)
+ .twice.and_return(service)
+ expect(service).to receive(:execute).twice
+
+ perform_multiple(job_args)
+ end
+ end
+ end
+end
diff --git a/spec/workers/bulk_imports/relation_export_worker_spec.rb b/spec/workers/bulk_imports/relation_export_worker_spec.rb
index 63f1992d186..f91db0388a4 100644
--- a/spec/workers/bulk_imports/relation_export_worker_spec.rb
+++ b/spec/workers/bulk_imports/relation_export_worker_spec.rb
@@ -2,19 +2,20 @@
require 'spec_helper'
-RSpec.describe BulkImports::RelationExportWorker do
+RSpec.describe BulkImports::RelationExportWorker, feature_category: :importers do
let_it_be(:jid) { 'jid' }
- let_it_be(:relation) { 'labels' }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
- let(:job_args) { [user.id, group.id, group.class.name, relation] }
+ let(:batched) { false }
+ let(:relation) { 'labels' }
+ let(:job_args) { [user.id, group.id, group.class.name, relation, batched] }
describe '#perform' do
include_examples 'an idempotent worker' do
context 'when export record does not exist' do
let(:another_group) { create(:group) }
- let(:job_args) { [user.id, another_group.id, another_group.class.name, relation] }
+ let(:job_args) { [user.id, another_group.id, another_group.class.name, relation, batched] }
it 'creates export record' do
another_group.add_owner(user)
@@ -26,21 +27,53 @@ RSpec.describe BulkImports::RelationExportWorker do
end
end
- it 'executes RelationExportService' do
- group.add_owner(user)
+ shared_examples 'export service' do |export_service|
+ it 'executes export service' do
+ group.add_owner(user)
- service = instance_double(BulkImports::RelationExportService)
+ service = instance_double(export_service)
- expect(BulkImports::RelationExportService)
- .to receive(:new)
- .with(user, group, relation, anything)
- .twice
- .and_return(service)
- expect(service)
- .to receive(:execute)
- .twice
+ expect(export_service)
+ .to receive(:new)
+ .with(user, group, relation, anything)
+ .twice
+ .and_return(service)
+ expect(service).to receive(:execute).twice
- perform_multiple(job_args)
+ perform_multiple(job_args)
+ end
+ end
+
+ context 'when export is batched' do
+ let(:batched) { true }
+
+ context 'when bulk_imports_batched_import_export feature flag is disabled' do
+ before do
+ stub_feature_flags(bulk_imports_batched_import_export: false)
+ end
+
+ include_examples 'export service', BulkImports::RelationExportService
+ end
+
+ context 'when bulk_imports_batched_import_export feature flag is enabled' do
+ before do
+ stub_feature_flags(bulk_imports_batched_import_export: true)
+ end
+
+ context 'when relation is batchable' do
+ include_examples 'export service', BulkImports::BatchedRelationExportService
+ end
+
+ context 'when relation is not batchable' do
+ let(:relation) { 'namespace_settings' }
+
+ include_examples 'export service', BulkImports::RelationExportService
+ end
+ end
+ end
+
+ context 'when export is not batched' do
+ include_examples 'export service', BulkImports::RelationExportService
end
end
end
diff --git a/spec/workers/bulk_imports/stuck_import_worker_spec.rb b/spec/workers/bulk_imports/stuck_import_worker_spec.rb
index 7dfb6532c07..ba1b1b66b00 100644
--- a/spec/workers/bulk_imports/stuck_import_worker_spec.rb
+++ b/spec/workers/bulk_imports/stuck_import_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::StuckImportWorker do
+RSpec.describe BulkImports::StuckImportWorker, feature_category: :importers do
let_it_be(:created_bulk_import) { create(:bulk_import, :created) }
let_it_be(:started_bulk_import) { create(:bulk_import, :started) }
let_it_be(:stale_created_bulk_import) { create(:bulk_import, :created, created_at: 3.days.ago) }
diff --git a/spec/workers/chat_notification_worker_spec.rb b/spec/workers/chat_notification_worker_spec.rb
index a20a136d197..a9413a94e4b 100644
--- a/spec/workers/chat_notification_worker_spec.rb
+++ b/spec/workers/chat_notification_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ChatNotificationWorker do
+RSpec.describe ChatNotificationWorker, feature_category: :integrations do
let(:worker) { described_class.new }
let(:chat_build) do
create(:ci_build, pipeline: create(:ci_pipeline, source: :chat))
diff --git a/spec/workers/ci/archive_trace_worker_spec.rb b/spec/workers/ci/archive_trace_worker_spec.rb
index 3ac769aab9e..056093f01b4 100644
--- a/spec/workers/ci/archive_trace_worker_spec.rb
+++ b/spec/workers/ci/archive_trace_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ArchiveTraceWorker do
+RSpec.describe Ci::ArchiveTraceWorker, feature_category: :continuous_integration do
describe '#perform' do
subject { described_class.new.perform(job&.id) }
diff --git a/spec/workers/ci/archive_traces_cron_worker_spec.rb b/spec/workers/ci/archive_traces_cron_worker_spec.rb
index 0c1010960a1..1eb88258c62 100644
--- a/spec/workers/ci/archive_traces_cron_worker_spec.rb
+++ b/spec/workers/ci/archive_traces_cron_worker_spec.rb
@@ -42,20 +42,6 @@ RSpec.describe Ci::ArchiveTracesCronWorker, feature_category: :continuous_integr
subject
end
- context "with FF deduplicate_archive_traces_cron_worker false" do
- before do
- stub_feature_flags(deduplicate_archive_traces_cron_worker: false)
- end
-
- it 'calls execute service' do
- expect_next_instance_of(Ci::ArchiveTraceService) do |instance|
- expect(instance).to receive(:execute).with(build, worker_name: "Ci::ArchiveTracesCronWorker")
- end
-
- subject
- end
- end
-
context 'when the job finished recently' do
let(:finished_at) { 1.hour.ago }
diff --git a/spec/workers/ci/build_finished_worker_spec.rb b/spec/workers/ci/build_finished_worker_spec.rb
index 049f3af1dd7..6da30a86b54 100644
--- a/spec/workers/ci/build_finished_worker_spec.rb
+++ b/spec/workers/ci/build_finished_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::BuildFinishedWorker do
+RSpec.describe Ci::BuildFinishedWorker, feature_category: :continuous_integration do
include AfterNextHelpers
subject { described_class.new.perform(build.id) }
diff --git a/spec/workers/ci/build_prepare_worker_spec.rb b/spec/workers/ci/build_prepare_worker_spec.rb
index b2c74a920ea..d3d607d8f39 100644
--- a/spec/workers/ci/build_prepare_worker_spec.rb
+++ b/spec/workers/ci/build_prepare_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::BuildPrepareWorker do
+RSpec.describe Ci::BuildPrepareWorker, feature_category: :continuous_integration do
subject { described_class.new.perform(build_id) }
context 'build exists' do
diff --git a/spec/workers/ci/build_schedule_worker_spec.rb b/spec/workers/ci/build_schedule_worker_spec.rb
index f8b4efc562b..f0d43ef810d 100644
--- a/spec/workers/ci/build_schedule_worker_spec.rb
+++ b/spec/workers/ci/build_schedule_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::BuildScheduleWorker do
+RSpec.describe Ci::BuildScheduleWorker, feature_category: :continuous_integration do
subject { described_class.new.perform(build.id) }
context 'when build is found' do
diff --git a/spec/workers/ci/build_trace_chunk_flush_worker_spec.rb b/spec/workers/ci/build_trace_chunk_flush_worker_spec.rb
index 8aac80a02be..851b8f6bc90 100644
--- a/spec/workers/ci/build_trace_chunk_flush_worker_spec.rb
+++ b/spec/workers/ci/build_trace_chunk_flush_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::BuildTraceChunkFlushWorker do
+RSpec.describe Ci::BuildTraceChunkFlushWorker, feature_category: :continuous_integration do
let(:data) { 'x' * Ci::BuildTraceChunk::CHUNK_SIZE }
let(:chunk) do
diff --git a/spec/workers/ci/cancel_pipeline_worker_spec.rb b/spec/workers/ci/cancel_pipeline_worker_spec.rb
index 6165aaff1c7..874273a39e1 100644
--- a/spec/workers/ci/cancel_pipeline_worker_spec.rb
+++ b/spec/workers/ci/cancel_pipeline_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CancelPipelineWorker, :aggregate_failures do
+RSpec.describe Ci::CancelPipelineWorker, :aggregate_failures, feature_category: :continuous_integration do
let!(:pipeline) { create(:ci_pipeline, :running) }
describe '#perform' do
diff --git a/spec/workers/ci/create_cross_project_pipeline_worker_spec.rb b/spec/workers/ci/create_cross_project_pipeline_worker_spec.rb
deleted file mode 100644
index 372b0de1b54..00000000000
--- a/spec/workers/ci/create_cross_project_pipeline_worker_spec.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Ci::CreateCrossProjectPipelineWorker do
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
- let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
-
- let(:bridge) { create(:ci_bridge, user: user, pipeline: pipeline) }
-
- let(:service) { double('pipeline creation service') }
-
- describe '#perform' do
- context 'when bridge exists' do
- it 'calls cross project pipeline creation service' do
- expect(Ci::CreateDownstreamPipelineService)
- .to receive(:new)
- .with(project, user)
- .and_return(service)
-
- expect(service).to receive(:execute).with(bridge)
-
- described_class.new.perform(bridge.id)
- end
- end
-
- context 'when bridge does not exist' do
- it 'does nothing' do
- expect(Ci::CreateDownstreamPipelineService)
- .not_to receive(:new)
-
- described_class.new.perform(non_existing_record_id)
- end
- end
- end
-end
diff --git a/spec/workers/ci/create_downstream_pipeline_worker_spec.rb b/spec/workers/ci/create_downstream_pipeline_worker_spec.rb
index b4add681e67..bb763e68504 100644
--- a/spec/workers/ci/create_downstream_pipeline_worker_spec.rb
+++ b/spec/workers/ci/create_downstream_pipeline_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::CreateDownstreamPipelineWorker do
+RSpec.describe Ci::CreateDownstreamPipelineWorker, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
diff --git a/spec/workers/ci/daily_build_group_report_results_worker_spec.rb b/spec/workers/ci/daily_build_group_report_results_worker_spec.rb
index e13c6311e46..3d86ff6999b 100644
--- a/spec/workers/ci/daily_build_group_report_results_worker_spec.rb
+++ b/spec/workers/ci/daily_build_group_report_results_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::DailyBuildGroupReportResultsWorker do
+RSpec.describe Ci::DailyBuildGroupReportResultsWorker, feature_category: :code_testing do
describe '#perform' do
let!(:pipeline) { create(:ci_pipeline) }
diff --git a/spec/workers/ci/delete_objects_worker_spec.rb b/spec/workers/ci/delete_objects_worker_spec.rb
index 3d985dffdc5..808ad95531e 100644
--- a/spec/workers/ci/delete_objects_worker_spec.rb
+++ b/spec/workers/ci/delete_objects_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::DeleteObjectsWorker do
+RSpec.describe Ci::DeleteObjectsWorker, feature_category: :continuous_integration do
let(:worker) { described_class.new }
it { expect(described_class.idempotent?).to be_truthy }
diff --git a/spec/workers/ci/delete_unit_tests_worker_spec.rb b/spec/workers/ci/delete_unit_tests_worker_spec.rb
index ff2575b19c1..ae8fe762334 100644
--- a/spec/workers/ci/delete_unit_tests_worker_spec.rb
+++ b/spec/workers/ci/delete_unit_tests_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::DeleteUnitTestsWorker do
+RSpec.describe Ci::DeleteUnitTestsWorker, feature_category: :code_testing do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/ci/drop_pipeline_worker_spec.rb b/spec/workers/ci/drop_pipeline_worker_spec.rb
index 5e626112520..23ae95ee53a 100644
--- a/spec/workers/ci/drop_pipeline_worker_spec.rb
+++ b/spec/workers/ci/drop_pipeline_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::DropPipelineWorker do
+RSpec.describe Ci::DropPipelineWorker, feature_category: :continuous_integration do
include AfterNextHelpers
let(:pipeline) { create(:ci_pipeline, :running) }
diff --git a/spec/workers/ci/job_artifacts/expire_project_build_artifacts_worker_spec.rb b/spec/workers/ci/job_artifacts/expire_project_build_artifacts_worker_spec.rb
index 0460738f3f2..9d4e5380474 100644
--- a/spec/workers/ci/job_artifacts/expire_project_build_artifacts_worker_spec.rb
+++ b/spec/workers/ci/job_artifacts/expire_project_build_artifacts_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::ExpireProjectBuildArtifactsWorker do
+RSpec.describe Ci::JobArtifacts::ExpireProjectBuildArtifactsWorker, feature_category: :build_artifacts do
let(:worker) { described_class.new }
let(:current_time) { Time.current }
diff --git a/spec/workers/ci/job_artifacts/track_artifact_report_worker_spec.rb b/spec/workers/ci/job_artifacts/track_artifact_report_worker_spec.rb
index 0d4b8243050..9c3d249c6aa 100644
--- a/spec/workers/ci/job_artifacts/track_artifact_report_worker_spec.rb
+++ b/spec/workers/ci/job_artifacts/track_artifact_report_worker_spec.rb
@@ -2,15 +2,14 @@
require 'spec_helper'
-RSpec.describe Ci::JobArtifacts::TrackArtifactReportWorker do
+RSpec.describe Ci::JobArtifacts::TrackArtifactReportWorker, feature_category: :code_testing do
describe '#perform', :clean_gitlab_redis_shared_state do
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:pipeline) do
- create(:ci_pipeline, :with_test_reports, :with_coverage_reports,
- project: project, user: user)
+ create(:ci_pipeline, :with_test_reports, :with_coverage_reports, project: project, user: user)
end
subject(:perform) { described_class.new.perform(pipeline_id) }
diff --git a/spec/workers/ci/merge_requests/add_todo_when_build_fails_worker_spec.rb b/spec/workers/ci/merge_requests/add_todo_when_build_fails_worker_spec.rb
index e5de0ba0143..ec12ee845a4 100644
--- a/spec/workers/ci/merge_requests/add_todo_when_build_fails_worker_spec.rb
+++ b/spec/workers/ci/merge_requests/add_todo_when_build_fails_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::MergeRequests::AddTodoWhenBuildFailsWorker do
+RSpec.describe Ci::MergeRequests::AddTodoWhenBuildFailsWorker, feature_category: :code_review_workflow do
describe '#perform' do
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, :detached_merge_request_pipeline) }
diff --git a/spec/workers/ci/parse_secure_file_metadata_worker_spec.rb b/spec/workers/ci/parse_secure_file_metadata_worker_spec.rb
index 57bbd8a6ff0..11a01352fcc 100644
--- a/spec/workers/ci/parse_secure_file_metadata_worker_spec.rb
+++ b/spec/workers/ci/parse_secure_file_metadata_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ParseSecureFileMetadataWorker do
+RSpec.describe Ci::ParseSecureFileMetadataWorker, feature_category: :mobile_devops do
describe '#perform' do
include_examples 'an idempotent worker' do
let(:secure_file) { create(:ci_secure_file) }
diff --git a/spec/workers/ci/pending_builds/update_group_worker_spec.rb b/spec/workers/ci/pending_builds/update_group_worker_spec.rb
index 8c6bf018158..c16262c0502 100644
--- a/spec/workers/ci/pending_builds/update_group_worker_spec.rb
+++ b/spec/workers/ci/pending_builds/update_group_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PendingBuilds::UpdateGroupWorker do
+RSpec.describe Ci::PendingBuilds::UpdateGroupWorker, feature_category: :subgroups do
describe '#perform' do
let(:worker) { described_class.new }
diff --git a/spec/workers/ci/pending_builds/update_project_worker_spec.rb b/spec/workers/ci/pending_builds/update_project_worker_spec.rb
index 4a67127564e..281b4fb920b 100644
--- a/spec/workers/ci/pending_builds/update_project_worker_spec.rb
+++ b/spec/workers/ci/pending_builds/update_project_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PendingBuilds::UpdateProjectWorker do
+RSpec.describe Ci::PendingBuilds::UpdateProjectWorker, feature_category: :projects do
describe '#perform' do
let(:worker) { described_class.new }
diff --git a/spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb b/spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb
index 7b28384a5bf..b594f661a9a 100644
--- a/spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb
+++ b/spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineArtifacts::CoverageReportWorker do
+RSpec.describe Ci::PipelineArtifacts::CoverageReportWorker, feature_category: :code_testing do
describe '#perform' do
let(:pipeline_id) { pipeline.id }
diff --git a/spec/workers/ci/pipeline_artifacts/create_quality_report_worker_spec.rb b/spec/workers/ci/pipeline_artifacts/create_quality_report_worker_spec.rb
index 5096691270a..2ad1609c7c4 100644
--- a/spec/workers/ci/pipeline_artifacts/create_quality_report_worker_spec.rb
+++ b/spec/workers/ci/pipeline_artifacts/create_quality_report_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Ci::PipelineArtifacts::CreateQualityReportWorker do
+RSpec.describe ::Ci::PipelineArtifacts::CreateQualityReportWorker, feature_category: :code_quality do
describe '#perform' do
subject { described_class.new.perform(pipeline_id) }
diff --git a/spec/workers/ci/pipeline_artifacts/expire_artifacts_worker_spec.rb b/spec/workers/ci/pipeline_artifacts/expire_artifacts_worker_spec.rb
index 274f848ad88..ca4fcc564fa 100644
--- a/spec/workers/ci/pipeline_artifacts/expire_artifacts_worker_spec.rb
+++ b/spec/workers/ci/pipeline_artifacts/expire_artifacts_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineArtifacts::ExpireArtifactsWorker do
+RSpec.describe Ci::PipelineArtifacts::ExpireArtifactsWorker, feature_category: :build_artifacts do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/ci/pipeline_bridge_status_worker_spec.rb b/spec/workers/ci/pipeline_bridge_status_worker_spec.rb
index 6ec5eb0e639..4662b25a3cb 100644
--- a/spec/workers/ci/pipeline_bridge_status_worker_spec.rb
+++ b/spec/workers/ci/pipeline_bridge_status_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineBridgeStatusWorker do
+RSpec.describe Ci::PipelineBridgeStatusWorker, feature_category: :continuous_integration do
describe '#perform' do
subject { described_class.new.perform(pipeline_id) }
diff --git a/spec/workers/ci/pipeline_success_unlock_artifacts_worker_spec.rb b/spec/workers/ci/pipeline_success_unlock_artifacts_worker_spec.rb
index 3b33972c76f..70821f3a833 100644
--- a/spec/workers/ci/pipeline_success_unlock_artifacts_worker_spec.rb
+++ b/spec/workers/ci/pipeline_success_unlock_artifacts_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::PipelineSuccessUnlockArtifactsWorker do
+RSpec.describe Ci::PipelineSuccessUnlockArtifactsWorker, feature_category: :build_artifacts do
describe '#perform' do
subject(:perform) { described_class.new.perform(pipeline_id) }
diff --git a/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb b/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb
index f14b7f9d1d0..ede4dad1272 100644
--- a/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb
+++ b/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::RefDeleteUnlockArtifactsWorker do
+RSpec.describe Ci::RefDeleteUnlockArtifactsWorker, feature_category: :build_artifacts do
describe '#perform' do
subject(:perform) { worker.perform(project_id, user_id, ref) }
diff --git a/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb b/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb
index 785cba24f9d..e3e7047db56 100644
--- a/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb
+++ b/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ResourceGroups::AssignResourceFromResourceGroupWorker do
+RSpec.describe Ci::ResourceGroups::AssignResourceFromResourceGroupWorker, feature_category: :continuous_delivery do
let(:worker) { described_class.new }
it 'has the `until_executed` deduplicate strategy' do
diff --git a/spec/workers/ci/retry_pipeline_worker_spec.rb b/spec/workers/ci/retry_pipeline_worker_spec.rb
index c7600a24280..f41b6b88c6f 100644
--- a/spec/workers/ci/retry_pipeline_worker_spec.rb
+++ b/spec/workers/ci/retry_pipeline_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::RetryPipelineWorker do
+RSpec.describe Ci::RetryPipelineWorker, feature_category: :continuous_integration do
describe '#perform' do
subject(:perform) { described_class.new.perform(pipeline_id, user_id) }
diff --git a/spec/workers/ci/runners/stale_machines_cleanup_cron_worker_spec.rb b/spec/workers/ci/runners/stale_machines_cleanup_cron_worker_spec.rb
index d8f620bc024..619012eaa6e 100644
--- a/spec/workers/ci/runners/stale_machines_cleanup_cron_worker_spec.rb
+++ b/spec/workers/ci/runners/stale_machines_cleanup_cron_worker_spec.rb
@@ -8,16 +8,16 @@ RSpec.describe Ci::Runners::StaleMachinesCleanupCronWorker, feature_category: :r
describe '#perform', :freeze_time do
subject(:perform) { worker.perform }
- let!(:runner_machine1) do
+ let!(:runner_manager1) do
create(:ci_runner_machine, created_at: 7.days.ago, contacted_at: 7.days.ago)
end
- let!(:runner_machine2) { create(:ci_runner_machine) }
- let!(:runner_machine3) { create(:ci_runner_machine, created_at: 6.days.ago) }
+ let!(:runner_manager2) { create(:ci_runner_machine) }
+ let!(:runner_manager3) { create(:ci_runner_machine, created_at: 6.days.ago) }
it_behaves_like 'an idempotent worker' do
it 'delegates to Ci::Runners::StaleMachinesCleanupService' do
- expect_next_instance_of(Ci::Runners::StaleMachinesCleanupService) do |service|
+ expect_next_instance_of(Ci::Runners::StaleManagersCleanupService) do |service|
expect(service)
.to receive(:execute).and_call_original
end
@@ -26,16 +26,16 @@ RSpec.describe Ci::Runners::StaleMachinesCleanupCronWorker, feature_category: :r
expect(worker.logging_extras).to eq({
"extra.ci_runners_stale_machines_cleanup_cron_worker.status" => :success,
- "extra.ci_runners_stale_machines_cleanup_cron_worker.deleted_machines" => true
+ "extra.ci_runners_stale_machines_cleanup_cron_worker.deleted_managers" => true
})
end
- it 'cleans up stale runner machines', :aggregate_failures do
- expect(Ci::RunnerMachine.stale.count).to eq 1
+ it 'cleans up stale runner managers', :aggregate_failures do
+ expect(Ci::RunnerManager.stale.count).to eq 1
- expect { perform }.to change { Ci::RunnerMachine.count }.from(3).to(2)
+ expect { perform }.to change { Ci::RunnerManager.count }.from(3).to(2)
- expect(Ci::RunnerMachine.all).to match_array [runner_machine2, runner_machine3]
+ expect(Ci::RunnerManager.all).to match_array [runner_manager2, runner_manager3]
end
end
end
diff --git a/spec/workers/ci/schedule_delete_objects_cron_worker_spec.rb b/spec/workers/ci/schedule_delete_objects_cron_worker_spec.rb
index 142df271f90..cab282df5f2 100644
--- a/spec/workers/ci/schedule_delete_objects_cron_worker_spec.rb
+++ b/spec/workers/ci/schedule_delete_objects_cron_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::ScheduleDeleteObjectsCronWorker do
+RSpec.describe Ci::ScheduleDeleteObjectsCronWorker, feature_category: :continuous_integration do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/ci/stuck_builds/drop_running_worker_spec.rb b/spec/workers/ci/stuck_builds/drop_running_worker_spec.rb
index 6d3aa71fe81..6823686997f 100644
--- a/spec/workers/ci/stuck_builds/drop_running_worker_spec.rb
+++ b/spec/workers/ci/stuck_builds/drop_running_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::StuckBuilds::DropRunningWorker do
+RSpec.describe Ci::StuckBuilds::DropRunningWorker, feature_category: :continuous_integration do
include ExclusiveLeaseHelpers
let(:worker) { described_class.new }
diff --git a/spec/workers/ci/stuck_builds/drop_scheduled_worker_spec.rb b/spec/workers/ci/stuck_builds/drop_scheduled_worker_spec.rb
index 57be799d890..58b07d11d33 100644
--- a/spec/workers/ci/stuck_builds/drop_scheduled_worker_spec.rb
+++ b/spec/workers/ci/stuck_builds/drop_scheduled_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::StuckBuilds::DropScheduledWorker do
+RSpec.describe Ci::StuckBuilds::DropScheduledWorker, feature_category: :continuous_integration do
include ExclusiveLeaseHelpers
let(:worker) { described_class.new }
diff --git a/spec/workers/ci/test_failure_history_worker_spec.rb b/spec/workers/ci/test_failure_history_worker_spec.rb
index 7530077d4ad..bf8ec44ce4d 100644
--- a/spec/workers/ci/test_failure_history_worker_spec.rb
+++ b/spec/workers/ci/test_failure_history_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Ci::TestFailureHistoryWorker do
+RSpec.describe ::Ci::TestFailureHistoryWorker, feature_category: :static_application_security_testing do
describe '#perform' do
subject(:perform) { described_class.new.perform(pipeline_id) }
diff --git a/spec/workers/ci/track_failed_build_worker_spec.rb b/spec/workers/ci/track_failed_build_worker_spec.rb
index 12d0e64afc5..5d12e86d844 100644
--- a/spec/workers/ci/track_failed_build_worker_spec.rb
+++ b/spec/workers/ci/track_failed_build_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Ci::TrackFailedBuildWorker do
+RSpec.describe ::Ci::TrackFailedBuildWorker, feature_category: :static_application_security_testing do
let_it_be(:build) { create(:ci_build, :failed, :sast_report) }
let_it_be(:exit_code) { 42 }
let_it_be(:failure_reason) { "script_failure" }
diff --git a/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb b/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb
index b42d135b1b6..4bb1d3561f9 100644
--- a/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb
+++ b/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::UpdateLockedUnknownArtifactsWorker do
+RSpec.describe Ci::UpdateLockedUnknownArtifactsWorker, feature_category: :build_artifacts do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/ci_platform_metrics_update_cron_worker_spec.rb b/spec/workers/ci_platform_metrics_update_cron_worker_spec.rb
index 0bb6822a0a5..ac00956e1c0 100644
--- a/spec/workers/ci_platform_metrics_update_cron_worker_spec.rb
+++ b/spec/workers/ci_platform_metrics_update_cron_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CiPlatformMetricsUpdateCronWorker, type: :worker do
+RSpec.describe CiPlatformMetricsUpdateCronWorker, type: :worker, feature_category: :continuous_integration do
describe '#perform' do
subject { described_class.new.perform }
diff --git a/spec/workers/cleanup_container_repository_worker_spec.rb b/spec/workers/cleanup_container_repository_worker_spec.rb
index 817b71c8cc6..c970c9ef842 100644
--- a/spec/workers/cleanup_container_repository_worker_spec.rb
+++ b/spec/workers/cleanup_container_repository_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CleanupContainerRepositoryWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe CleanupContainerRepositoryWorker, :clean_gitlab_redis_shared_state, feature_category: :container_registry do
let(:repository) { create(:container_repository) }
let(:project) { repository.project }
let(:user) { project.first_owner }
diff --git a/spec/workers/clusters/agents/delete_expired_events_worker_spec.rb b/spec/workers/clusters/agents/delete_expired_events_worker_spec.rb
index 1a5ca744091..8f2bd189d5c 100644
--- a/spec/workers/clusters/agents/delete_expired_events_worker_spec.rb
+++ b/spec/workers/clusters/agents/delete_expired_events_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Agents::DeleteExpiredEventsWorker do
+RSpec.describe Clusters::Agents::DeleteExpiredEventsWorker, feature_category: :deployment_management do
let(:agent) { create(:cluster_agent) }
describe '#perform' do
diff --git a/spec/workers/clusters/agents/notify_git_push_worker_spec.rb b/spec/workers/clusters/agents/notify_git_push_worker_spec.rb
new file mode 100644
index 00000000000..561a66b86e9
--- /dev/null
+++ b/spec/workers/clusters/agents/notify_git_push_worker_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::NotifyGitPushWorker, feature_category: :deployment_management do
+ let_it_be(:project) { create(:project) }
+
+ describe '#perform' do
+ let(:project_id) { project.id }
+ let(:kas_client) { instance_double(Gitlab::Kas::Client) }
+
+ subject { described_class.new.perform(project_id) }
+
+ it 'calls the deletion service' do
+ expect(Gitlab::Kas::Client).to receive(:new).and_return(kas_client)
+ expect(kas_client).to receive(:send_git_push_event).with(project: project)
+
+ subject
+ end
+
+ context 'when the project no longer exists' do
+ let(:project_id) { -1 }
+
+ it 'completes without raising an error' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when the :notify_kas_on_git_push feature flag is disabled' do
+ before do
+ stub_feature_flags(notify_kas_on_git_push: false)
+ end
+
+ it 'does not notify KAS' do
+ expect(Gitlab::Kas::Client).not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/workers/clusters/applications/activate_integration_worker_spec.rb b/spec/workers/clusters/applications/activate_integration_worker_spec.rb
index 5163e4681fa..58b133aa6de 100644
--- a/spec/workers/clusters/applications/activate_integration_worker_spec.rb
+++ b/spec/workers/clusters/applications/activate_integration_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Applications::ActivateIntegrationWorker, '#perform' do
+RSpec.describe Clusters::Applications::ActivateIntegrationWorker, '#perform', feature_category: :deployment_management do
context 'when cluster exists' do
describe 'prometheus integration' do
let(:integration_name) { 'prometheus' }
diff --git a/spec/workers/clusters/applications/deactivate_integration_worker_spec.rb b/spec/workers/clusters/applications/deactivate_integration_worker_spec.rb
index 62792a3b7d9..5f7cd786ea3 100644
--- a/spec/workers/clusters/applications/deactivate_integration_worker_spec.rb
+++ b/spec/workers/clusters/applications/deactivate_integration_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Applications::DeactivateIntegrationWorker, '#perform' do
+RSpec.describe Clusters::Applications::DeactivateIntegrationWorker, '#perform', feature_category: :deployment_management do
context 'when cluster exists' do
describe 'prometheus integration' do
let(:integration_name) { 'prometheus' }
diff --git a/spec/workers/clusters/cleanup/project_namespace_worker_spec.rb b/spec/workers/clusters/cleanup/project_namespace_worker_spec.rb
index c24ca71eb35..7119664d706 100644
--- a/spec/workers/clusters/cleanup/project_namespace_worker_spec.rb
+++ b/spec/workers/clusters/cleanup/project_namespace_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Cleanup::ProjectNamespaceWorker do
+RSpec.describe Clusters::Cleanup::ProjectNamespaceWorker, feature_category: :deployment_management do
describe '#perform' do
context 'when cluster.cleanup_status is cleanup_removing_project_namespaces' do
let!(:cluster) { create(:cluster, :with_environments, :cleanup_removing_project_namespaces) }
@@ -27,7 +27,6 @@ RSpec.describe Clusters::Cleanup::ProjectNamespaceWorker do
exception: 'ClusterCleanupMethods::ExceededExecutionLimitError',
cluster_id: kind_of(Integer),
class_name: described_class.name,
- applications: "",
cleanup_status: cluster.cleanup_status_name,
event: :failed_to_remove_cluster_and_resources,
message: "exceeded execution limit of 10 tries"
diff --git a/spec/workers/clusters/cleanup/service_account_worker_spec.rb b/spec/workers/clusters/cleanup/service_account_worker_spec.rb
index dabc32a0ccd..cc388841c91 100644
--- a/spec/workers/clusters/cleanup/service_account_worker_spec.rb
+++ b/spec/workers/clusters/cleanup/service_account_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Cleanup::ServiceAccountWorker do
+RSpec.describe Clusters::Cleanup::ServiceAccountWorker, feature_category: :deployment_management do
describe '#perform' do
let!(:cluster) { create(:cluster, :cleanup_removing_service_account) }
diff --git a/spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb b/spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb
index 6f70870bd09..1f5892a36da 100644
--- a/spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb
+++ b/spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Clusters::Integrations::CheckPrometheusHealthWorker, '#perform' do
+RSpec.describe Clusters::Integrations::CheckPrometheusHealthWorker, '#perform', feature_category: :incident_management do
subject { described_class.new.perform }
it 'triggers health service' do
diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb
index 0abb029f146..e4df91adef2 100644
--- a/spec/workers/concerns/application_worker_spec.rb
+++ b/spec/workers/concerns/application_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ApplicationWorker do
+RSpec.describe ApplicationWorker, feature_category: :shared do
# We depend on the lazy-load characteristic of rspec. If the worker is loaded
# before setting up, it's likely to go wrong. Consider this catcha:
# before do
diff --git a/spec/workers/concerns/cluster_agent_queue_spec.rb b/spec/workers/concerns/cluster_agent_queue_spec.rb
index b5189cbd8c8..77417601748 100644
--- a/spec/workers/concerns/cluster_agent_queue_spec.rb
+++ b/spec/workers/concerns/cluster_agent_queue_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ClusterAgentQueue do
+RSpec.describe ClusterAgentQueue, feature_category: :deployment_management do
let(:worker) do
Class.new do
def self.name
@@ -14,6 +14,5 @@ RSpec.describe ClusterAgentQueue do
end
end
- it { expect(worker.queue).to eq('cluster_agent:example') }
- it { expect(worker.get_feature_category).to eq(:kubernetes_management) }
+ it { expect(worker.get_feature_category).to eq(:deployment_management) }
end
diff --git a/spec/workers/concerns/cluster_queue_spec.rb b/spec/workers/concerns/cluster_queue_spec.rb
deleted file mode 100644
index c03ca9cea48..00000000000
--- a/spec/workers/concerns/cluster_queue_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ClusterQueue do
- let(:worker) do
- Class.new do
- def self.name
- 'DummyWorker'
- end
-
- include ApplicationWorker
- include ClusterQueue
- end
- end
-
- it 'sets a default pipelines queue automatically' do
- expect(worker.sidekiq_options['queue'])
- .to eq 'gcp_cluster:dummy'
- end
-end
diff --git a/spec/workers/concerns/cronjob_queue_spec.rb b/spec/workers/concerns/cronjob_queue_spec.rb
index 0244535051f..26680fcc870 100644
--- a/spec/workers/concerns/cronjob_queue_spec.rb
+++ b/spec/workers/concerns/cronjob_queue_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CronjobQueue do
+RSpec.describe CronjobQueue, feature_category: :shared do
let(:worker) do
Class.new do
def self.name
@@ -40,15 +40,11 @@ RSpec.describe CronjobQueue do
stub_const("AnotherWorker", another_worker)
end
- it 'sets the queue name of a worker' do
- expect(worker.sidekiq_options['queue'].to_s).to eq('cronjob:dummy')
- end
-
it 'disables retrying of failed jobs' do
expect(worker.sidekiq_options['retry']).to eq(false)
end
- it 'automatically clears project, user and namespace from the context', :aggregate_failues do
+ it 'automatically clears project, user and namespace from the context', :aggregate_failures do
worker_context = worker.get_worker_context.to_lazy_hash.transform_values { |v| v.try(:call) }
expect(worker_context[:user]).to be_nil
diff --git a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb
index 02190201986..18a3e3c2c5b 100644
--- a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb
+++ b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do
+RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures, feature_category: :importers do
let(:worker) do
Class.new do
def self.name
@@ -30,7 +30,8 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do
let(:github_identifiers) do
{
some_id: 1,
- some_type: '_some_type_'
+ some_type: '_some_type_',
+ object_type: 'dummy'
}
end
@@ -52,7 +53,8 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do
def github_identifiers
{
some_id: 1,
- some_type: '_some_type_'
+ some_type: '_some_type_',
+ object_type: 'dummy'
}
end
end
@@ -196,6 +198,19 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do
end
context 'when the record is invalid' do
+ let(:exception) { ActiveRecord::RecordInvalid.new }
+
+ before do
+ expect(importer_class)
+ .to receive(:new)
+ .with(instance_of(MockRepresantation), project, client)
+ .and_return(importer_instance)
+
+ expect(importer_instance)
+ .to receive(:execute)
+ .and_raise(exception)
+ end
+
it 'logs an error' do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
@@ -208,16 +223,6 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do
}
)
- expect(importer_class)
- .to receive(:new)
- .with(instance_of(MockRepresantation), project, client)
- .and_return(importer_instance)
-
- exception = ActiveRecord::RecordInvalid.new
- expect(importer_instance)
- .to receive(:execute)
- .and_raise(exception)
-
expect(Gitlab::Import::ImportFailureService)
.to receive(:track)
.with(
@@ -230,6 +235,15 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do
worker.import(project, client, { 'number' => 10, 'github_id' => 1 })
end
+
+ it 'updates external_identifiers of the correct failure' do
+ worker.import(project, client, { 'number' => 10, 'github_id' => 1 })
+
+ import_failures = project.import_failures
+
+ expect(import_failures.count).to eq(1)
+ expect(import_failures.first.external_identifiers).to eq(github_identifiers.with_indifferent_access)
+ end
end
end
@@ -240,4 +254,56 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do
expect(worker).to be_increment_object_counter(issue)
end
end
+
+ describe '.sidekiq_retries_exhausted' do
+ let(:correlation_id) { 'abc' }
+ let(:job) do
+ {
+ 'args' => [project.id, { number: 123, state: 'open' }, '123abc'],
+ 'jid' => '123',
+ 'correlation_id' => correlation_id
+ }
+ end
+
+ subject(:sidekiq_retries_exhausted) { worker.class.sidekiq_retries_exhausted_block.call(job, StandardError.new) }
+
+ context 'when all arguments are given' do
+ it 'notifies the JobWaiter' do
+ expect(Gitlab::JobWaiter)
+ .to receive(:notify)
+ .with(
+ job['args'].last,
+ job['jid']
+ )
+
+ sidekiq_retries_exhausted
+ end
+ end
+
+ context 'when not all arguments are given' do
+ let(:job) do
+ {
+ 'args' => [project.id, { number: 123, state: 'open' }],
+ 'jid' => '123',
+ 'correlation_id' => correlation_id
+ }
+ end
+
+ it 'does not notify the JobWaiter' do
+ expect(Gitlab::JobWaiter).not_to receive(:notify)
+
+ sidekiq_retries_exhausted
+ end
+ end
+
+ it 'updates external_identifiers of the correct failure' do
+ failure_1, failure_2 = create_list(:import_failure, 2, project: project)
+ failure_2.update_column(:correlation_id_value, correlation_id)
+
+ sidekiq_retries_exhausted
+
+ expect(failure_1.reload.external_identifiers).to be_empty
+ expect(failure_2.reload.external_identifiers).to eq(github_identifiers.with_indifferent_access)
+ end
+ end
end
diff --git a/spec/workers/concerns/gitlab/github_import/queue_spec.rb b/spec/workers/concerns/gitlab/github_import/queue_spec.rb
deleted file mode 100644
index beca221b593..00000000000
--- a/spec/workers/concerns/gitlab/github_import/queue_spec.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::GithubImport::Queue do
- it 'sets the Sidekiq options for the worker' do
- worker = Class.new do
- def self.name
- 'DummyWorker'
- end
-
- include ApplicationWorker
- include Gitlab::GithubImport::Queue
- end
-
- expect(worker.sidekiq_options['queue']).to eq('github_importer:dummy')
- end
-end
diff --git a/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb b/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb
index 8727756ce50..cdaff2fc1f4 100644
--- a/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb
+++ b/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ReschedulingMethods do
+RSpec.describe Gitlab::GithubImport::ReschedulingMethods, feature_category: :importers do
let(:worker) do
Class.new { include(Gitlab::GithubImport::ReschedulingMethods) }.new
end
@@ -25,9 +25,15 @@ RSpec.describe Gitlab::GithubImport::ReschedulingMethods do
end
end
- context 'with an existing project' do
+ context 'with an existing project', :clean_gitlab_redis_cache do
let(:project) { create(:project, import_url: 'https://t0ken@github.com/repo/repo.git') }
+ before do
+ allow_next_instance_of(Gitlab::GithubImport::Client) do |instance|
+ allow(instance).to receive(:rate_limit_resets_in).and_return(14)
+ end
+ end
+
it 'notifies any waiters upon successfully importing the data' do
expect(worker)
.to receive(:try_import)
@@ -57,13 +63,13 @@ RSpec.describe Gitlab::GithubImport::ReschedulingMethods do
expect(worker)
.not_to receive(:notify_waiter)
- expect_next_instance_of(Gitlab::GithubImport::Client) do |instance|
- expect(instance).to receive(:rate_limit_resets_in).and_return(14)
- end
+ expect(worker)
+ .to receive(:object_type)
+ .and_return(:pull_request)
expect(worker.class)
.to receive(:perform_in)
- .with(14, project.id, { 'number' => 2 }, '123')
+ .with(15, project.id, { 'number' => 2 }, '123')
worker.perform(project.id, { 'number' => 2 }, '123')
end
diff --git a/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb b/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb
index 0ac1733781a..ce9a9db5dd9 100644
--- a/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb
+++ b/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::StageMethods do
+RSpec.describe Gitlab::GithubImport::StageMethods, feature_category: :importers do
let_it_be(:project) { create(:project, :import_started, import_url: 'https://t0ken@github.com/repo/repo.git') }
let_it_be(:project2) { create(:project, :import_canceled) }
diff --git a/spec/workers/concerns/gitlab/notify_upon_death_spec.rb b/spec/workers/concerns/gitlab/notify_upon_death_spec.rb
index dd0a1cadc9c..36faf3ee296 100644
--- a/spec/workers/concerns/gitlab/notify_upon_death_spec.rb
+++ b/spec/workers/concerns/gitlab/notify_upon_death_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::NotifyUponDeath do
+RSpec.describe Gitlab::NotifyUponDeath, feature_category: :shared do
let(:worker_class) do
Class.new do
include Sidekiq::Worker
diff --git a/spec/workers/concerns/limited_capacity/job_tracker_spec.rb b/spec/workers/concerns/limited_capacity/job_tracker_spec.rb
index 0e3fa350fcd..20635d1a045 100644
--- a/spec/workers/concerns/limited_capacity/job_tracker_spec.rb
+++ b/spec/workers/concerns/limited_capacity/job_tracker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe LimitedCapacity::JobTracker, :clean_gitlab_redis_shared_state do
+RSpec.describe LimitedCapacity::JobTracker, :clean_gitlab_redis_shared_state, feature_category: :shared do
let(:job_tracker) do
described_class.new('namespace')
end
diff --git a/spec/workers/concerns/limited_capacity/worker_spec.rb b/spec/workers/concerns/limited_capacity/worker_spec.rb
index 790b5c3544d..65906eef0fa 100644
--- a/spec/workers/concerns/limited_capacity/worker_spec.rb
+++ b/spec/workers/concerns/limited_capacity/worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe LimitedCapacity::Worker, :clean_gitlab_redis_queues, :aggregate_failures do
+RSpec.describe LimitedCapacity::Worker, :clean_gitlab_redis_queues, :aggregate_failures, feature_category: :shared do
let(:worker_class) do
Class.new do
def self.name
diff --git a/spec/workers/concerns/packages/cleanup_artifact_worker_spec.rb b/spec/workers/concerns/packages/cleanup_artifact_worker_spec.rb
index 95962d4810e..daecda8c92e 100644
--- a/spec/workers/concerns/packages/cleanup_artifact_worker_spec.rb
+++ b/spec/workers/concerns/packages/cleanup_artifact_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Packages::CleanupArtifactWorker do
+RSpec.describe ::Packages::CleanupArtifactWorker, feature_category: :build_artifacts do
let_it_be(:worker_class) do
Class.new do
def self.name
diff --git a/spec/workers/concerns/pipeline_background_queue_spec.rb b/spec/workers/concerns/pipeline_background_queue_spec.rb
deleted file mode 100644
index 77c7e7440c5..00000000000
--- a/spec/workers/concerns/pipeline_background_queue_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe PipelineBackgroundQueue do
- let(:worker) do
- Class.new do
- def self.name
- 'DummyWorker'
- end
-
- include ApplicationWorker
- include PipelineBackgroundQueue
- end
- end
-
- it 'sets a default object storage queue automatically' do
- expect(worker.sidekiq_options['queue'])
- .to eq 'pipeline_background:dummy'
- end
-end
diff --git a/spec/workers/concerns/pipeline_queue_spec.rb b/spec/workers/concerns/pipeline_queue_spec.rb
deleted file mode 100644
index 6c1ac2052e4..00000000000
--- a/spec/workers/concerns/pipeline_queue_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe PipelineQueue do
- let(:worker) do
- Class.new do
- def self.name
- 'DummyWorker'
- end
-
- include ApplicationWorker
- include PipelineQueue
- end
- end
-
- it 'sets a default pipelines queue automatically' do
- expect(worker.sidekiq_options['queue'])
- .to eq 'pipeline_default:dummy'
- end
-end
diff --git a/spec/workers/concerns/project_import_options_spec.rb b/spec/workers/concerns/project_import_options_spec.rb
index 85a26ddb0cb..51d7f9fdea4 100644
--- a/spec/workers/concerns/project_import_options_spec.rb
+++ b/spec/workers/concerns/project_import_options_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProjectImportOptions do
+RSpec.describe ProjectImportOptions, feature_category: :importers do
let(:project) { create(:project, :import_started) }
let(:job) { { 'args' => [project.id, nil, nil], 'jid' => '123' } }
let(:worker_class) do
diff --git a/spec/workers/concerns/reenqueuer_spec.rb b/spec/workers/concerns/reenqueuer_spec.rb
index e7287b55af2..42873578014 100644
--- a/spec/workers/concerns/reenqueuer_spec.rb
+++ b/spec/workers/concerns/reenqueuer_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Reenqueuer do
+RSpec.describe Reenqueuer, feature_category: :shared do
include ExclusiveLeaseHelpers
let_it_be(:worker_class) do
diff --git a/spec/workers/concerns/repository_check_queue_spec.rb b/spec/workers/concerns/repository_check_queue_spec.rb
index ae377c09b37..12082e2dff5 100644
--- a/spec/workers/concerns/repository_check_queue_spec.rb
+++ b/spec/workers/concerns/repository_check_queue_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RepositoryCheckQueue do
+RSpec.describe RepositoryCheckQueue, feature_category: :source_code_management do
let(:worker) do
Class.new do
def self.name
@@ -14,10 +14,6 @@ RSpec.describe RepositoryCheckQueue do
end
end
- it 'sets the queue name of a worker' do
- expect(worker.sidekiq_options['queue'].to_s).to eq('repository_check:dummy')
- end
-
it 'disables retrying of failed jobs' do
expect(worker.sidekiq_options['retry']).to eq(false)
end
diff --git a/spec/workers/concerns/waitable_worker_spec.rb b/spec/workers/concerns/waitable_worker_spec.rb
deleted file mode 100644
index 737424ffd8c..00000000000
--- a/spec/workers/concerns/waitable_worker_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe WaitableWorker do
- let(:worker) do
- Class.new do
- def self.name
- 'Gitlab::Foo::Bar::DummyWorker'
- end
-
- cattr_accessor(:counter) { 0 }
-
- include ApplicationWorker
- prepend WaitableWorker
-
- def perform(count = 0)
- self.class.counter += count
- end
- end
- end
-
- subject(:job) { worker.new }
-
- describe '#perform' do
- shared_examples 'perform' do
- it 'notifies the JobWaiter when done if the key is provided' do
- key = Gitlab::JobWaiter.new.key
- expect(Gitlab::JobWaiter).to receive(:notify).with(key, job.jid)
-
- job.perform(*args, key)
- end
-
- it 'does not notify the JobWaiter when done if no key is provided' do
- expect(Gitlab::JobWaiter).not_to receive(:notify)
-
- job.perform(*args)
- end
- end
-
- context 'when the worker takes arguments' do
- let(:args) { [1] }
-
- it_behaves_like 'perform'
- end
-
- context 'when the worker takes no arguments' do
- let(:args) { [] }
-
- it_behaves_like 'perform'
- end
- end
-end
diff --git a/spec/workers/concerns/worker_attributes_spec.rb b/spec/workers/concerns/worker_attributes_spec.rb
index 5e8f68923fd..ac9d3fa824e 100644
--- a/spec/workers/concerns/worker_attributes_spec.rb
+++ b/spec/workers/concerns/worker_attributes_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkerAttributes do
+RSpec.describe WorkerAttributes, feature_category: :shared do
using RSpec::Parameterized::TableSyntax
let(:worker) do
diff --git a/spec/workers/concerns/worker_context_spec.rb b/spec/workers/concerns/worker_context_spec.rb
index 80b427b2b42..700d9e37a55 100644
--- a/spec/workers/concerns/worker_context_spec.rb
+++ b/spec/workers/concerns/worker_context_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WorkerContext do
+RSpec.describe WorkerContext, feature_category: :shared do
let(:worker) do
Class.new do
def self.name
@@ -73,9 +73,11 @@ RSpec.describe WorkerContext do
describe '.bulk_perform_async_with_contexts' do
subject do
- worker.bulk_perform_async_with_contexts(%w(hello world),
- context_proc: -> (_) { { user: build_stubbed(:user) } },
- arguments_proc: -> (word) { word })
+ worker.bulk_perform_async_with_contexts(
+ %w(hello world),
+ context_proc: -> (_) { { user: build_stubbed(:user) } },
+ arguments_proc: -> (word) { word }
+ )
end
it 'calls bulk_perform_async with the arguments' do
@@ -89,10 +91,12 @@ RSpec.describe WorkerContext do
describe '.bulk_perform_in_with_contexts' do
subject do
- worker.bulk_perform_in_with_contexts(10.minutes,
- %w(hello world),
- context_proc: -> (_) { { user: build_stubbed(:user) } },
- arguments_proc: -> (word) { word })
+ worker.bulk_perform_in_with_contexts(
+ 10.minutes,
+ %w(hello world),
+ context_proc: -> (_) { { user: build_stubbed(:user) } },
+ arguments_proc: -> (word) { word }
+ )
end
it 'calls bulk_perform_in with the arguments and delay' do
diff --git a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb
index 8eda943f36e..8dae859b168 100644
--- a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb
+++ b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do
+RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker, feature_category: :container_registry do
using RSpec::Parameterized::TableSyntax
let_it_be(:repository, refind: true) { create(:container_repository, :cleanup_scheduled, expiration_policy_started_at: 1.month.ago) }
@@ -348,16 +348,18 @@ RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do
subject { worker.send(:container_repository) }
- if params[:expected_selected_repository] == :none
- it 'does not select any repository' do
+ it 'selects the correct repository', :freeze_time do
+ case expected_selected_repository
+ when :none
expect(subject).to eq(nil)
+ next
+ when :repository
+ expect(subject).to eq(repository)
+ when :other_repository
+ expect(subject).to eq(other_repository)
end
- else
- it 'does select a repository' do
- selected_repository = expected_selected_repository == :repository ? repository : other_repository
-
- expect(subject).to eq(selected_repository)
- end
+ expect(subject).to be_cleanup_ongoing
+ expect(subject.expiration_policy_started_at).to eq(Time.zone.now)
end
def update_container_repository(container_repository, cleanup_status, policy_status)
@@ -511,6 +513,16 @@ RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do
subject
end
end
+
+ context 'with a stuck container repository' do
+ before do
+ repository.cleanup_ongoing!
+ repository.update_column(:expiration_policy_started_at, nil)
+ policy.update_column(:next_run_at, 5.minutes.ago)
+ end
+
+ it { is_expected.to eq(0) }
+ end
end
describe '#max_running_jobs' do
diff --git a/spec/workers/container_expiration_policy_worker_spec.rb b/spec/workers/container_expiration_policy_worker_spec.rb
index ef6266aeba3..fcb43b86084 100644
--- a/spec/workers/container_expiration_policy_worker_spec.rb
+++ b/spec/workers/container_expiration_policy_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ContainerExpirationPolicyWorker do
+RSpec.describe ContainerExpirationPolicyWorker, feature_category: :container_registry do
include ExclusiveLeaseHelpers
let(:worker) { described_class.new }
@@ -33,15 +33,17 @@ RSpec.describe ContainerExpirationPolicyWorker do
end
context 'process stale ongoing cleanups' do
- let_it_be(:stuck_cleanup) { create(:container_repository, :cleanup_ongoing, expiration_policy_started_at: 1.day.ago) }
+ let_it_be(:stuck_cleanup1) { create(:container_repository, :cleanup_ongoing, expiration_policy_started_at: 1.day.ago) }
+ let_it_be(:stuck_cleanup2) { create(:container_repository, :cleanup_ongoing, expiration_policy_started_at: nil) }
let_it_be(:container_repository1) { create(:container_repository, :cleanup_scheduled) }
let_it_be(:container_repository2) { create(:container_repository, :cleanup_unfinished) }
it 'set them as unfinished' do
expect { subject }
- .to change { ContainerRepository.cleanup_ongoing.count }.from(1).to(0)
- .and change { ContainerRepository.cleanup_unfinished.count }.from(1).to(2)
- expect(stuck_cleanup.reload).to be_cleanup_unfinished
+ .to change { ContainerRepository.cleanup_ongoing.count }.from(2).to(0)
+ .and change { ContainerRepository.cleanup_unfinished.count }.from(1).to(3)
+ expect(stuck_cleanup1.reload).to be_cleanup_unfinished
+ expect(stuck_cleanup2.reload).to be_cleanup_unfinished
end
end
diff --git a/spec/workers/container_registry/cleanup_worker_spec.rb b/spec/workers/container_registry/cleanup_worker_spec.rb
index a510b660412..955d2175085 100644
--- a/spec/workers/container_registry/cleanup_worker_spec.rb
+++ b/spec/workers/container_registry/cleanup_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ContainerRegistry::CleanupWorker, :aggregate_failures do
+RSpec.describe ContainerRegistry::CleanupWorker, :aggregate_failures, feature_category: :container_registry do
let(:worker) { described_class.new }
describe '#perform' do
@@ -46,6 +46,77 @@ RSpec.describe ContainerRegistry::CleanupWorker, :aggregate_failures do
end
end
+ context 'with stale ongoing repair details' do
+ let_it_be(:stale_updated_at) { (described_class::STALE_REPAIR_DETAIL_THRESHOLD + 5.minutes).ago }
+ let_it_be(:recent_updated_at) { (described_class::STALE_REPAIR_DETAIL_THRESHOLD - 5.minutes).ago }
+ let_it_be(:old_repair_detail) { create(:container_registry_data_repair_detail, updated_at: stale_updated_at) }
+ let_it_be(:new_repair_detail) { create(:container_registry_data_repair_detail, updated_at: recent_updated_at) }
+
+ it 'deletes them' do
+ expect { perform }.to change { ContainerRegistry::DataRepairDetail.count }.from(2).to(1)
+ expect(ContainerRegistry::DataRepairDetail.all).to contain_exactly(new_repair_detail)
+ end
+ end
+
+ shared_examples 'does not enqueue record repair detail jobs' do
+ it 'does not enqueue record repair detail jobs' do
+ expect(ContainerRegistry::RecordDataRepairDetailWorker).not_to receive(:perform_with_capacity)
+
+ perform
+ end
+ end
+
+ context 'when on gitlab.com', :saas do
+ context 'when the gitlab api is supported' do
+ let(:relation) { instance_double(ActiveRecord::Relation) }
+
+ before do
+ allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(true)
+ allow(Project).to receive(:pending_data_repair_analysis).and_return(relation)
+ end
+
+ context 'when there are pending projects to analyze' do
+ before do
+ allow(relation).to receive(:exists?).and_return(true)
+ end
+
+ it "enqueues record repair detail jobs" do
+ expect(ContainerRegistry::RecordDataRepairDetailWorker).to receive(:perform_with_capacity)
+
+ perform
+ end
+ end
+
+ context 'when there are no pending projects to analyze' do
+ before do
+ allow(relation).to receive(:exists?).and_return(false)
+ end
+
+ it_behaves_like 'does not enqueue record repair detail jobs'
+ end
+ end
+
+ context 'when the Gitlab API is not supported' do
+ before do
+ allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(false)
+ end
+
+ it_behaves_like 'does not enqueue record repair detail jobs'
+ end
+ end
+
+ context 'when not on Gitlab.com' do
+ it_behaves_like 'does not enqueue record repair detail jobs'
+ end
+
+ context 'when registry_data_repair_worker feature is disabled' do
+ before do
+ stub_feature_flags(registry_data_repair_worker: false)
+ end
+
+ it_behaves_like 'does not enqueue record repair detail jobs'
+ end
+
context 'for counts logging' do
let_it_be(:delete_started_at) { (described_class::STALE_DELETE_THRESHOLD + 5.minutes).ago }
let_it_be(:stale_delete_container_repository) do
diff --git a/spec/workers/container_registry/delete_container_repository_worker_spec.rb b/spec/workers/container_registry/delete_container_repository_worker_spec.rb
index 381e0cc164c..8bab0133d55 100644
--- a/spec/workers/container_registry/delete_container_repository_worker_spec.rb
+++ b/spec/workers/container_registry/delete_container_repository_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ContainerRegistry::DeleteContainerRepositoryWorker, :aggregate_failures do
+RSpec.describe ContainerRegistry::DeleteContainerRepositoryWorker, :aggregate_failures, feature_category: :container_registry do
let_it_be_with_reload(:container_repository) { create(:container_repository) }
let_it_be(:second_container_repository) { create(:container_repository) }
diff --git a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
index c2381c0ced7..4a603e538ef 100644
--- a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
+++ b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures, :clean_gitlab_redis_shared_state do
+RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures, :clean_gitlab_redis_shared_state,
+ feature_category: :container_registry do
using RSpec::Parameterized::TableSyntax
include ExclusiveLeaseHelpers
diff --git a/spec/workers/container_registry/migration/guard_worker_spec.rb b/spec/workers/container_registry/migration/guard_worker_spec.rb
index 4ad2d5c300c..40ade93ab0d 100644
--- a/spec/workers/container_registry/migration/guard_worker_spec.rb
+++ b/spec/workers/container_registry/migration/guard_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
+RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures, feature_category: :container_registry do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/container_registry/migration/observer_worker_spec.rb b/spec/workers/container_registry/migration/observer_worker_spec.rb
index fec6640d7ec..7bf3d90d9d3 100644
--- a/spec/workers/container_registry/migration/observer_worker_spec.rb
+++ b/spec/workers/container_registry/migration/observer_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ContainerRegistry::Migration::ObserverWorker, :aggregate_failures do
+RSpec.describe ContainerRegistry::Migration::ObserverWorker, :aggregate_failures, feature_category: :container_registry do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/container_registry/record_data_repair_detail_worker_spec.rb b/spec/workers/container_registry/record_data_repair_detail_worker_spec.rb
new file mode 100644
index 00000000000..f107144d397
--- /dev/null
+++ b/spec/workers/container_registry/record_data_repair_detail_worker_spec.rb
@@ -0,0 +1,191 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ContainerRegistry::RecordDataRepairDetailWorker, :aggregate_failures, :clean_gitlab_redis_shared_state,
+ feature_category: :container_registry do
+ include ExclusiveLeaseHelpers
+
+ let(:worker) { described_class.new }
+
+ describe '#perform_work' do
+ subject(:perform_work) { worker.perform_work }
+
+ context 'with no work to do - no projects pending analysis' do
+ it 'will not try to get an exclusive lease and connect to the endpoint' do
+ allow(Project).to receive(:pending_data_repair_analysis).and_return([])
+ expect(::Gitlab::ExclusiveLease).not_to receive(:new)
+
+ expect(::ContainerRegistry::GitlabApiClient).not_to receive(:each_sub_repositories_with_tag_page)
+
+ perform_work
+ end
+ end
+
+ context 'with work to do' do
+ let_it_be(:path) { 'build/cng/docker-alpine' }
+ let_it_be(:group) { create(:group, path: 'build') }
+ let_it_be(:project) { create(:project, name: 'cng', namespace: group) }
+
+ let_it_be(:container_repository) { create(:container_repository, project: project, name: "docker-alpine") }
+ let_it_be(:lease_key) { "container_registry_data_repair_detail_worker:#{project.id}" }
+
+ before do
+ allow(ContainerRegistry::GitlabApiClient).to receive(:each_sub_repositories_with_tag_page)
+ allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(true)
+ end
+
+ context 'when on Gitlab.com', :saas do
+ it 'obtains exclusive lease on the project' do
+ expect(Project).to receive(:pending_data_repair_analysis).and_call_original
+ expect_to_obtain_exclusive_lease("container_registry_data_repair_detail_worker:#{project.id}",
+ timeout: described_class::LEASE_TIMEOUT)
+ expect_to_cancel_exclusive_lease("container_registry_data_repair_detail_worker:#{project.id}", 'uuid')
+
+ perform_work
+ end
+
+ it 'queries how many are existing repositories and counts the missing ones' do
+ stub_exclusive_lease("container_registry_data_repair_detail_worker:#{project.id}",
+ timeout: described_class::LEASE_TIMEOUT)
+ allow(ContainerRegistry::GitlabApiClient).to receive(:each_sub_repositories_with_tag_page)
+ .with(path: project.full_path, page_size: 50).and_yield(
+ [
+ { "path" => container_repository.path },
+ { "path" => 'missing1/repository' },
+ { "path" => 'missing2/repository' }
+ ]
+ )
+
+ expect(worker).not_to receive(:log_extra_metadata_on_done)
+ expect { perform_work }.to change { ContainerRegistry::DataRepairDetail.count }.from(0).to(1)
+ expect(ContainerRegistry::DataRepairDetail.first).to have_attributes(project: project, missing_count: 2)
+ end
+
+ it 'logs invalid paths' do
+ stub_exclusive_lease("container_registry_data_repair_detail_worker:#{project.id}",
+ timeout: described_class::LEASE_TIMEOUT)
+ valid_path = ContainerRegistry::Path.new('valid/path')
+ invalid_path = ContainerRegistry::Path.new('invalid/path')
+ allow(valid_path).to receive(:valid?).and_return(true)
+ allow(invalid_path).to receive(:valid?).and_return(false)
+
+ allow(ContainerRegistry::GitlabApiClient).to receive(:each_sub_repositories_with_tag_page)
+ .with(path: project.full_path, page_size: 50).and_yield(
+ [
+ { "path" => valid_path.to_s },
+ { "path" => invalid_path.to_s }
+ ]
+ )
+
+ allow(ContainerRegistry::Path).to receive(:new).with(valid_path.to_s).and_return(valid_path)
+ allow(ContainerRegistry::Path).to receive(:new).with(invalid_path.to_s).and_return(invalid_path)
+
+ expect(worker).to receive(:log_extra_metadata_on_done).with(
+ :invalid_paths_parsed_in_container_repository_repair,
+ "invalid/path"
+ )
+ perform_work
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ it 'creates a data repair detail' do
+ expect { perform_work }.to change { ContainerRegistry::DataRepairDetail.count }.from(0).to(1)
+ expect(project.container_registry_data_repair_detail).to be_present
+ end
+ end
+
+ context 'when the lease cannot be obtained' do
+ before do
+ stub_exclusive_lease_taken(lease_key, timeout: described_class::LEASE_TIMEOUT)
+ end
+
+ it 'logs an error and does not proceed' do
+ expect(worker).to receive(:log_lease_taken)
+ expect(ContainerRegistry::GitlabApiClient).not_to receive(:each_sub_repositories_with_tag_page)
+
+ perform_work
+ end
+
+ it 'does not create the data repair detail' do
+ perform_work
+
+ expect(project.reload.container_registry_data_repair_detail).to be_nil
+ end
+ end
+
+ context 'when an error occurs' do
+ before do
+ stub_exclusive_lease("container_registry_data_repair_detail_worker:#{project.id}",
+ timeout: described_class::LEASE_TIMEOUT)
+ allow(ContainerRegistry::GitlabApiClient).to receive(:each_sub_repositories_with_tag_page)
+ .with(path: project.full_path, page_size: 50).and_raise(RuntimeError)
+ end
+
+ it 'logs the error' do
+ expect(::Gitlab::ErrorTracking).to receive(:log_exception)
+ .with(instance_of(RuntimeError), class: described_class.name)
+
+ perform_work
+ end
+
+ it 'sets the status of the repair detail to failed' do
+ expect { perform_work }.to change { ContainerRegistry::DataRepairDetail.failed.count }.from(0).to(1)
+ expect(project.reload.container_registry_data_repair_detail.failed?).to eq(true)
+ end
+ end
+ end
+
+ context 'when not on Gitlab.com' do
+ it 'will not do anything' do
+ expect(::Gitlab::ExclusiveLease).not_to receive(:new)
+ expect(::ContainerRegistry::GitlabApiClient).not_to receive(:each_sub_repositories_with_tag_page)
+
+ perform_work
+ end
+ end
+ end
+ end
+
+ describe '#max_running_jobs' do
+ subject { worker.max_running_jobs }
+
+ it { is_expected.to eq(described_class::MAX_CAPACITY) }
+ end
+
+ describe '#remaining_work_count' do
+ let_it_be(:pending_projects) do
+ create_list(:project, described_class::MAX_CAPACITY + 2)
+ end
+
+ subject { worker.remaining_work_count }
+
+ context 'when on Gitlab.com', :saas do
+ before do
+ allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(true)
+ end
+
+ it { is_expected.to eq(described_class::MAX_CAPACITY + 1) }
+
+ context 'when the Gitlab API is not supported' do
+ before do
+ allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(false)
+ end
+
+ it { is_expected.to eq(0) }
+ end
+ end
+
+ context 'when not on Gitlab.com' do
+ it { is_expected.to eq(0) }
+ end
+
+ context 'when registry_data_repair_worker feature is disabled' do
+ before do
+ stub_feature_flags(registry_data_repair_worker: false)
+ end
+
+ it { is_expected.to eq(0) }
+ end
+ end
+end
diff --git a/spec/workers/counters/cleanup_refresh_worker_spec.rb b/spec/workers/counters/cleanup_refresh_worker_spec.rb
index a56c98f72a0..a18f78f1706 100644
--- a/spec/workers/counters/cleanup_refresh_worker_spec.rb
+++ b/spec/workers/counters/cleanup_refresh_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Counters::CleanupRefreshWorker do
+RSpec.describe Counters::CleanupRefreshWorker, feature_category: :shared do
let(:model) { create(:project_statistics) }
describe '#perform', :redis do
diff --git a/spec/workers/create_commit_signature_worker_spec.rb b/spec/workers/create_commit_signature_worker_spec.rb
index 9d3c63efc8a..722632b6796 100644
--- a/spec/workers/create_commit_signature_worker_spec.rb
+++ b/spec/workers/create_commit_signature_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CreateCommitSignatureWorker do
+RSpec.describe CreateCommitSignatureWorker, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
let(:commits) { project.repository.commits('HEAD', limit: 3).commits }
let(:commit_shas) { commits.map(&:id) }
diff --git a/spec/workers/create_note_diff_file_worker_spec.rb b/spec/workers/create_note_diff_file_worker_spec.rb
index 6d1d6d93e44..6e4a5e3ab6a 100644
--- a/spec/workers/create_note_diff_file_worker_spec.rb
+++ b/spec/workers/create_note_diff_file_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CreateNoteDiffFileWorker do
+RSpec.describe CreateNoteDiffFileWorker, feature_category: :code_review_workflow do
describe '#perform' do
let(:diff_note) { create(:diff_note_on_merge_request) }
diff --git a/spec/workers/create_pipeline_worker_spec.rb b/spec/workers/create_pipeline_worker_spec.rb
index da85d700429..23a2dc075a7 100644
--- a/spec/workers/create_pipeline_worker_spec.rb
+++ b/spec/workers/create_pipeline_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe CreatePipelineWorker do
+RSpec.describe CreatePipelineWorker, feature_category: :continuous_integration do
describe '#perform' do
let(:worker) { described_class.new }
diff --git a/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb b/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb
index dfe7a266be2..782f949eacf 100644
--- a/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb
+++ b/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb
@@ -2,6 +2,7 @@
require 'spec_helper'
-RSpec.describe Database::BatchedBackgroundMigration::CiDatabaseWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe Database::BatchedBackgroundMigration::CiDatabaseWorker, :clean_gitlab_redis_shared_state,
+ feature_category: :database do
it_behaves_like 'it runs batched background migration jobs', :ci, :ci_builds
end
diff --git a/spec/workers/database/batched_background_migration_worker_spec.rb b/spec/workers/database/batched_background_migration_worker_spec.rb
index e57bd7581c2..a6825c5ca76 100644
--- a/spec/workers/database/batched_background_migration_worker_spec.rb
+++ b/spec/workers/database/batched_background_migration_worker_spec.rb
@@ -2,6 +2,6 @@
require 'spec_helper'
-RSpec.describe Database::BatchedBackgroundMigrationWorker do
+RSpec.describe Database::BatchedBackgroundMigrationWorker, feature_category: :database do
it_behaves_like 'it runs batched background migration jobs', :main, :events
end
diff --git a/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb b/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb
index 1c083d1d8a3..84ea5db4bab 100644
--- a/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb
+++ b/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Database::CiNamespaceMirrorsConsistencyCheckWorker do
+RSpec.describe Database::CiNamespaceMirrorsConsistencyCheckWorker, feature_category: :cell do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb b/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb
index 8c839410ccd..0895f3d0559 100644
--- a/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb
+++ b/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Database::CiProjectMirrorsConsistencyCheckWorker do
+RSpec.describe Database::CiProjectMirrorsConsistencyCheckWorker, feature_category: :cell do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/database/drop_detached_partitions_worker_spec.rb b/spec/workers/database/drop_detached_partitions_worker_spec.rb
index a10fcaaa5d9..2ab77e71070 100644
--- a/spec/workers/database/drop_detached_partitions_worker_spec.rb
+++ b/spec/workers/database/drop_detached_partitions_worker_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Database::DropDetachedPartitionsWorker do
+RSpec.describe Database::DropDetachedPartitionsWorker, feature_category: :database do
describe '#perform' do
subject { described_class.new.perform }
diff --git a/spec/workers/database/partition_management_worker_spec.rb b/spec/workers/database/partition_management_worker_spec.rb
index e5362e95f48..203181ef28d 100644
--- a/spec/workers/database/partition_management_worker_spec.rb
+++ b/spec/workers/database/partition_management_worker_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Database::PartitionManagementWorker do
+RSpec.describe Database::PartitionManagementWorker, feature_category: :database do
describe '#perform' do
subject { described_class.new.perform }
diff --git a/spec/workers/delete_container_repository_worker_spec.rb b/spec/workers/delete_container_repository_worker_spec.rb
index 6ad131b4c14..6260bea6949 100644
--- a/spec/workers/delete_container_repository_worker_spec.rb
+++ b/spec/workers/delete_container_repository_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DeleteContainerRepositoryWorker do
+RSpec.describe DeleteContainerRepositoryWorker, feature_category: :container_registry do
let_it_be(:repository) { create(:container_repository) }
let(:project) { repository.project }
diff --git a/spec/workers/delete_diff_files_worker_spec.rb b/spec/workers/delete_diff_files_worker_spec.rb
index c124847ca45..1f1b00e324e 100644
--- a/spec/workers/delete_diff_files_worker_spec.rb
+++ b/spec/workers/delete_diff_files_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DeleteDiffFilesWorker do
+RSpec.describe DeleteDiffFilesWorker, feature_category: :code_review_workflow do
describe '#perform' do
let(:merge_request) { create(:merge_request) }
let(:merge_request_diff) { merge_request.merge_request_diff }
diff --git a/spec/workers/delete_merged_branches_worker_spec.rb b/spec/workers/delete_merged_branches_worker_spec.rb
index 056fcb1200d..f3b6dccad2e 100644
--- a/spec/workers/delete_merged_branches_worker_spec.rb
+++ b/spec/workers/delete_merged_branches_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DeleteMergedBranchesWorker do
+RSpec.describe DeleteMergedBranchesWorker, feature_category: :source_code_management do
subject(:worker) { described_class.new }
let(:project) { create(:project, :repository) }
diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb
index 4046b670640..8a99f69c079 100644
--- a/spec/workers/delete_user_worker_spec.rb
+++ b/spec/workers/delete_user_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DeleteUserWorker do
+RSpec.describe DeleteUserWorker, feature_category: :user_management do
let!(:user) { create(:user) }
let!(:current_user) { create(:user) }
@@ -21,4 +21,54 @@ RSpec.describe DeleteUserWorker do
described_class.new.perform(current_user.id, user.id, { "test" => "test" })
end
+
+ shared_examples 'does nothing' do
+ it "does not instantiate a DeleteUserWorker" do
+ expect(Users::DestroyService).not_to receive(:new)
+
+ perform
+ end
+ end
+
+ context 'when user is banned' do
+ subject(:perform) { described_class.new.perform(current_user.id, user.id) }
+
+ before do
+ user.ban
+ end
+
+ it_behaves_like 'does nothing'
+
+ context 'when delay_delete_own_user feature flag is disabled' do
+ before do
+ stub_feature_flags(delay_delete_own_user: false)
+ end
+
+ it "proceeds with deletion" do
+ expect_next_instance_of(Users::DestroyService) do |service|
+ expect(service).to receive(:execute).with(user, {})
+ end
+
+ perform
+ end
+ end
+ end
+
+ context 'when user to delete does not exist' do
+ subject(:perform) { described_class.new.perform(current_user.id, non_existing_record_id) }
+
+ it_behaves_like 'does nothing'
+ end
+
+ context 'when current user does not exist' do
+ subject(:perform) { described_class.new.perform(non_existing_record_id, user.id) }
+
+ it_behaves_like 'does nothing'
+ end
+
+ context 'when user to delete and current user do not exist' do
+ subject(:perform) { described_class.new.perform(non_existing_record_id, non_existing_record_id) }
+
+ it_behaves_like 'does nothing'
+ end
end
diff --git a/spec/workers/dependency_proxy/cleanup_blob_worker_spec.rb b/spec/workers/dependency_proxy/cleanup_blob_worker_spec.rb
index b67a56cca7b..9ef5fd9ec93 100644
--- a/spec/workers/dependency_proxy/cleanup_blob_worker_spec.rb
+++ b/spec/workers/dependency_proxy/cleanup_blob_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DependencyProxy::CleanupBlobWorker do
+RSpec.describe DependencyProxy::CleanupBlobWorker, feature_category: :dependency_proxy do
let_it_be(:factory_type) { :dependency_proxy_blob }
it_behaves_like 'dependency_proxy_cleanup_worker'
diff --git a/spec/workers/dependency_proxy/cleanup_dependency_proxy_worker_spec.rb b/spec/workers/dependency_proxy/cleanup_dependency_proxy_worker_spec.rb
index 1100f9a7fae..3040189d1c8 100644
--- a/spec/workers/dependency_proxy/cleanup_dependency_proxy_worker_spec.rb
+++ b/spec/workers/dependency_proxy/cleanup_dependency_proxy_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DependencyProxy::CleanupDependencyProxyWorker do
+RSpec.describe DependencyProxy::CleanupDependencyProxyWorker, feature_category: :dependency_proxy do
describe '#perform' do
subject { described_class.new.perform }
diff --git a/spec/workers/dependency_proxy/cleanup_manifest_worker_spec.rb b/spec/workers/dependency_proxy/cleanup_manifest_worker_spec.rb
index d53b3e6a1fd..730acc49110 100644
--- a/spec/workers/dependency_proxy/cleanup_manifest_worker_spec.rb
+++ b/spec/workers/dependency_proxy/cleanup_manifest_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DependencyProxy::CleanupManifestWorker do
+RSpec.describe DependencyProxy::CleanupManifestWorker, feature_category: :dependency_proxy do
let_it_be(:factory_type) { :dependency_proxy_manifest }
it_behaves_like 'dependency_proxy_cleanup_worker'
diff --git a/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb b/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb
index 6a2fdfbe8f5..44a21439ff8 100644
--- a/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb
+++ b/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DependencyProxy::ImageTtlGroupPolicyWorker do
+RSpec.describe DependencyProxy::ImageTtlGroupPolicyWorker, feature_category: :dependency_proxy do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/deployments/archive_in_project_worker_spec.rb b/spec/workers/deployments/archive_in_project_worker_spec.rb
index 6435fe8bea1..2d244344913 100644
--- a/spec/workers/deployments/archive_in_project_worker_spec.rb
+++ b/spec/workers/deployments/archive_in_project_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::ArchiveInProjectWorker do
+RSpec.describe Deployments::ArchiveInProjectWorker, feature_category: :continuous_delivery do
subject { described_class.new.perform(deployment&.project_id) }
describe '#perform' do
diff --git a/spec/workers/deployments/drop_older_deployments_worker_spec.rb b/spec/workers/deployments/drop_older_deployments_worker_spec.rb
deleted file mode 100644
index 0cf524ca16f..00000000000
--- a/spec/workers/deployments/drop_older_deployments_worker_spec.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Deployments::DropOlderDeploymentsWorker do
- subject { described_class.new.perform(deployment&.id) }
-
- describe '#perform' do
- let(:deployment) { create(:deployment, :success) }
-
- it 'executes Deployments::OlderDeploymentsDropService' do
- expect(Deployments::OlderDeploymentsDropService)
- .to receive(:new).with(deployment.id).and_call_original
-
- subject
- end
- end
-end
diff --git a/spec/workers/deployments/hooks_worker_spec.rb b/spec/workers/deployments/hooks_worker_spec.rb
index 7c5f288fa57..e620ed6e05c 100644
--- a/spec/workers/deployments/hooks_worker_spec.rb
+++ b/spec/workers/deployments/hooks_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::HooksWorker do
+RSpec.describe Deployments::HooksWorker, feature_category: :continuous_delivery do
let(:worker) { described_class.new }
describe '#perform' do
@@ -60,8 +60,6 @@ RSpec.describe Deployments::HooksWorker do
worker.perform(deployment_id: deployment.id, status_changed_at: status_changed_at)
end
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
end
end
diff --git a/spec/workers/deployments/link_merge_request_worker_spec.rb b/spec/workers/deployments/link_merge_request_worker_spec.rb
index a55dd897bc7..0e484c50f21 100644
--- a/spec/workers/deployments/link_merge_request_worker_spec.rb
+++ b/spec/workers/deployments/link_merge_request_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::LinkMergeRequestWorker do
+RSpec.describe Deployments::LinkMergeRequestWorker, feature_category: :continuous_delivery do
subject(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/deployments/update_environment_worker_spec.rb b/spec/workers/deployments/update_environment_worker_spec.rb
index d67cbd62616..befe8576e88 100644
--- a/spec/workers/deployments/update_environment_worker_spec.rb
+++ b/spec/workers/deployments/update_environment_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Deployments::UpdateEnvironmentWorker do
+RSpec.describe Deployments::UpdateEnvironmentWorker, feature_category: :continuous_delivery do
subject(:worker) { described_class.new }
context 'when successful deployment' do
diff --git a/spec/workers/design_management/copy_design_collection_worker_spec.rb b/spec/workers/design_management/copy_design_collection_worker_spec.rb
index 45bfc47ca7e..daa69a8bd6d 100644
--- a/spec/workers/design_management/copy_design_collection_worker_spec.rb
+++ b/spec/workers/design_management/copy_design_collection_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DesignManagement::CopyDesignCollectionWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe DesignManagement::CopyDesignCollectionWorker, :clean_gitlab_redis_shared_state, feature_category: :design_management do
describe '#perform' do
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue) }
diff --git a/spec/workers/design_management/new_version_worker_spec.rb b/spec/workers/design_management/new_version_worker_spec.rb
index 3320d7a062d..baf6409a64f 100644
--- a/spec/workers/design_management/new_version_worker_spec.rb
+++ b/spec/workers/design_management/new_version_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DesignManagement::NewVersionWorker do
+RSpec.describe DesignManagement::NewVersionWorker, feature_category: :design_management do
describe '#perform' do
let(:worker) { described_class.new }
@@ -57,9 +57,11 @@ RSpec.describe DesignManagement::NewVersionWorker do
context 'the version includes multiple types of action' do
let_it_be(:version) do
- create(:design_version, :with_lfs_file,
- created_designs: create_list(:design, 1, :with_lfs_file),
- modified_designs: create_list(:design, 1))
+ create(
+ :design_version, :with_lfs_file,
+ created_designs: create_list(:design, 1, :with_lfs_file),
+ modified_designs: create_list(:design, 1)
+ )
end
it 'creates two system notes' do
diff --git a/spec/workers/destroy_pages_deployments_worker_spec.rb b/spec/workers/destroy_pages_deployments_worker_spec.rb
index 2c20c9004ef..4bfde6d220a 100644
--- a/spec/workers/destroy_pages_deployments_worker_spec.rb
+++ b/spec/workers/destroy_pages_deployments_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DestroyPagesDeploymentsWorker do
+RSpec.describe DestroyPagesDeploymentsWorker, feature_category: :pages do
subject(:worker) { described_class.new }
let(:project) { create(:project) }
diff --git a/spec/workers/detect_repository_languages_worker_spec.rb b/spec/workers/detect_repository_languages_worker_spec.rb
index 217e16bd155..27d60720d24 100644
--- a/spec/workers/detect_repository_languages_worker_spec.rb
+++ b/spec/workers/detect_repository_languages_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DetectRepositoryLanguagesWorker do
+RSpec.describe DetectRepositoryLanguagesWorker, feature_category: :source_code_management do
let_it_be(:project) { create(:project) }
subject { described_class.new }
diff --git a/spec/workers/disallow_two_factor_for_group_worker_spec.rb b/spec/workers/disallow_two_factor_for_group_worker_spec.rb
index 3a875727cce..c732f8a3d00 100644
--- a/spec/workers/disallow_two_factor_for_group_worker_spec.rb
+++ b/spec/workers/disallow_two_factor_for_group_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DisallowTwoFactorForGroupWorker do
+RSpec.describe DisallowTwoFactorForGroupWorker, feature_category: :subgroups do
let_it_be(:group) { create(:group, require_two_factor_authentication: true) }
let_it_be(:user) { create(:user, require_two_factor_authentication_from_group: true) }
diff --git a/spec/workers/disallow_two_factor_for_subgroups_worker_spec.rb b/spec/workers/disallow_two_factor_for_subgroups_worker_spec.rb
index c3be8263171..7584355deab 100644
--- a/spec/workers/disallow_two_factor_for_subgroups_worker_spec.rb
+++ b/spec/workers/disallow_two_factor_for_subgroups_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe DisallowTwoFactorForSubgroupsWorker do
+RSpec.describe DisallowTwoFactorForSubgroupsWorker, feature_category: :subgroups do
let_it_be(:group) { create(:group) }
let_it_be(:subgroup_with_2fa) { create(:group, parent: group, require_two_factor_authentication: true) }
let_it_be(:subgroup_without_2fa) { create(:group, parent: group, require_two_factor_authentication: false) }
diff --git a/spec/workers/email_receiver_worker_spec.rb b/spec/workers/email_receiver_worker_spec.rb
index dba535654a1..51a77a09e16 100644
--- a/spec/workers/email_receiver_worker_spec.rb
+++ b/spec/workers/email_receiver_worker_spec.rb
@@ -2,12 +2,12 @@
require "spec_helper"
-RSpec.describe EmailReceiverWorker, :mailer do
+RSpec.describe EmailReceiverWorker, :mailer, feature_category: :team_planning do
let(:raw_message) { fixture_file('emails/valid_reply.eml') }
context "when reply by email is enabled" do
before do
- allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(true)
end
it "calls the email receiver" do
@@ -67,7 +67,7 @@ RSpec.describe EmailReceiverWorker, :mailer do
context "when reply by email is disabled" do
before do
- allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(false)
+ allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(false)
end
it "doesn't call the email receiver" do
diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb
index 7d11957e2df..9e8fad19c20 100644
--- a/spec/workers/emails_on_push_worker_spec.rb
+++ b/spec/workers/emails_on_push_worker_spec.rb
@@ -2,12 +2,12 @@
require 'spec_helper'
-RSpec.describe EmailsOnPushWorker, :mailer do
- include RepoHelpers
+RSpec.describe EmailsOnPushWorker, :mailer, feature_category: :source_code_management do
include EmailSpec::Matchers
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) }
let(:recipients) { user.email }
let(:perform) { subject.perform(project.id, recipients, data.stringify_keys) }
@@ -91,7 +91,7 @@ RSpec.describe EmailsOnPushWorker, :mailer do
context "when there is an SMTP error" do
before do
- allow(Notify).to receive(:repository_push_email).and_raise(Net::SMTPFatalError)
+ allow(Notify).to receive(:repository_push_email).and_raise(Net::SMTPFatalError.new(nil))
allow(subject).to receive_message_chain(:logger, :info)
perform
end
diff --git a/spec/workers/environments/auto_delete_cron_worker_spec.rb b/spec/workers/environments/auto_delete_cron_worker_spec.rb
index b18f3da5d10..d7e707b225e 100644
--- a/spec/workers/environments/auto_delete_cron_worker_spec.rb
+++ b/spec/workers/environments/auto_delete_cron_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Environments::AutoDeleteCronWorker do
+RSpec.describe Environments::AutoDeleteCronWorker, feature_category: :continuous_delivery do
include CreateEnvironmentsHelpers
let(:worker) { described_class.new }
diff --git a/spec/workers/environments/auto_stop_cron_worker_spec.rb b/spec/workers/environments/auto_stop_cron_worker_spec.rb
index 1e86597d288..ad44cf97e07 100644
--- a/spec/workers/environments/auto_stop_cron_worker_spec.rb
+++ b/spec/workers/environments/auto_stop_cron_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Environments::AutoStopCronWorker do
+RSpec.describe Environments::AutoStopCronWorker, feature_category: :continuous_delivery do
subject { worker.perform }
let(:worker) { described_class.new }
diff --git a/spec/workers/environments/auto_stop_worker_spec.rb b/spec/workers/environments/auto_stop_worker_spec.rb
index cb162b5a01c..cfdca4a385c 100644
--- a/spec/workers/environments/auto_stop_worker_spec.rb
+++ b/spec/workers/environments/auto_stop_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Environments::AutoStopWorker do
+RSpec.describe Environments::AutoStopWorker, feature_category: :continuous_delivery do
include CreateEnvironmentsHelpers
subject { worker.perform(environment_id) }
diff --git a/spec/workers/environments/canary_ingress/update_worker_spec.rb b/spec/workers/environments/canary_ingress/update_worker_spec.rb
index e7782c2fba1..abfca54c850 100644
--- a/spec/workers/environments/canary_ingress/update_worker_spec.rb
+++ b/spec/workers/environments/canary_ingress/update_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Environments::CanaryIngress::UpdateWorker do
+RSpec.describe Environments::CanaryIngress::UpdateWorker, feature_category: :continuous_delivery do
let_it_be(:environment) { create(:environment) }
let(:worker) { described_class.new }
diff --git a/spec/workers/error_tracking_issue_link_worker_spec.rb b/spec/workers/error_tracking_issue_link_worker_spec.rb
index 90e747c8788..fcb39597f9e 100644
--- a/spec/workers/error_tracking_issue_link_worker_spec.rb
+++ b/spec/workers/error_tracking_issue_link_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ErrorTrackingIssueLinkWorker do
+RSpec.describe ErrorTrackingIssueLinkWorker, feature_category: :error_tracking do
let_it_be(:error_tracking) { create(:project_error_tracking_setting) }
let_it_be(:project) { error_tracking.project }
let_it_be(:issue) { create(:issue, project: project) }
diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb
index c0d46a206ce..16173e322b6 100644
--- a/spec/workers/every_sidekiq_worker_spec.rb
+++ b/spec/workers/every_sidekiq_worker_spec.rb
@@ -2,7 +2,9 @@
require 'spec_helper'
-RSpec.describe 'Every Sidekiq worker' do
+RSpec.describe 'Every Sidekiq worker', feature_category: :shared do
+ include EverySidekiqWorkerTestHelper
+
let(:workers_without_defaults) do
Gitlab::SidekiqConfig.workers - Gitlab::SidekiqConfig::DEFAULT_WORKERS.values
end
@@ -154,7 +156,6 @@ RSpec.describe 'Every Sidekiq worker' do
'Ci::BuildPrepareWorker' => 3,
'Ci::BuildScheduleWorker' => 3,
'Ci::BuildTraceChunkFlushWorker' => 3,
- 'Ci::CreateCrossProjectPipelineWorker' => 3,
'Ci::CreateDownstreamPipelineWorker' => 3,
'Ci::DailyBuildGroupReportResultsWorker' => 3,
'Ci::DeleteObjectsWorker' => 0,
@@ -162,6 +163,7 @@ RSpec.describe 'Every Sidekiq worker' do
'Ci::InitialPipelineProcessWorker' => 3,
'Ci::MergeRequests::AddTodoWhenBuildFailsWorker' => 3,
'Ci::Minutes::UpdateProjectAndNamespaceUsageWorker' => 3,
+ 'Ci::Llm::GenerateConfigWorker' => 3,
'Ci::PipelineArtifacts::CoverageReportWorker' => 3,
'Ci::PipelineArtifacts::CreateQualityReportWorker' => 3,
'Ci::PipelineBridgeStatusWorker' => 3,
@@ -190,6 +192,7 @@ RSpec.describe 'Every Sidekiq worker' do
'Clusters::Cleanup::ServiceAccountWorker' => 3,
'ContainerExpirationPolicies::CleanupContainerRepositoryWorker' => 0,
'ContainerRegistry::DeleteContainerRepositoryWorker' => 0,
+ 'ContainerRegistry::RecordDataRepairDetailWorker' => 0,
'CreateCommitSignatureWorker' => 3,
'CreateGithubWebhookWorker' => 3,
'CreateNoteDiffFileWorker' => 3,
@@ -204,7 +207,6 @@ RSpec.describe 'Every Sidekiq worker' do
'DependencyProxy::CleanupBlobWorker' => 3,
'DependencyProxy::CleanupManifestWorker' => 3,
'Deployments::AutoRollbackWorker' => 3,
- 'Deployments::DropOlderDeploymentsWorker' => 3,
'Deployments::FinishedWorker' => 3,
'Deployments::ForwardDeploymentWorker' => 3,
'Deployments::LinkMergeRequestWorker' => 3,
@@ -241,7 +243,6 @@ RSpec.describe 'Every Sidekiq worker' do
'Geo::DesignRepositorySyncWorker' => 1,
'Geo::DestroyWorker' => 3,
'Geo::EventWorker' => 3,
- 'Geo::FileRegistryRemovalWorker' => 3,
'Geo::FileRemovalWorker' => 3,
'Geo::ProjectSyncWorker' => 1,
'Geo::RenameRepositoryWorker' => 3,
@@ -272,9 +273,12 @@ RSpec.describe 'Every Sidekiq worker' do
'Gitlab::GithubImport::ImportLfsObjectWorker' => 5,
'Gitlab::GithubImport::ImportNoteWorker' => 5,
'Gitlab::GithubImport::ImportProtectedBranchWorker' => 5,
+ 'Gitlab::GithubImport::ImportCollaboratorWorker' => 5,
'Gitlab::GithubImport::ImportPullRequestMergedByWorker' => 5,
'Gitlab::GithubImport::ImportPullRequestReviewWorker' => 5,
'Gitlab::GithubImport::PullRequests::ImportReviewRequestWorker' => 5,
+ 'Gitlab::GithubImport::PullRequests::ImportReviewWorker' => 5,
+ 'Gitlab::GithubImport::PullRequests::ImportMergedByWorker' => 5,
'Gitlab::GithubImport::ImportPullRequestWorker' => 5,
'Gitlab::GithubImport::RefreshImportJidWorker' => 5,
'Gitlab::GithubImport::Stage::FinishImportWorker' => 5,
@@ -285,6 +289,7 @@ RSpec.describe 'Every Sidekiq worker' do
'Gitlab::GithubImport::Stage::ImportAttachmentsWorker' => 5,
'Gitlab::GithubImport::Stage::ImportProtectedBranchesWorker' => 5,
'Gitlab::GithubImport::Stage::ImportNotesWorker' => 5,
+ 'Gitlab::GithubImport::Stage::ImportCollaboratorsWorker' => 5,
'Gitlab::GithubImport::Stage::ImportPullRequestsMergedByWorker' => 5,
'Gitlab::GithubImport::Stage::ImportPullRequestsReviewRequestsWorker' => 5,
'Gitlab::GithubImport::Stage::ImportPullRequestsReviewsWorker' => 5,
@@ -301,7 +306,6 @@ RSpec.describe 'Every Sidekiq worker' do
'Gitlab::JiraImport::Stage::ImportLabelsWorker' => 5,
'Gitlab::JiraImport::Stage::ImportNotesWorker' => 5,
'Gitlab::JiraImport::Stage::StartImportWorker' => 5,
- 'Gitlab::PhabricatorImport::ImportTasksWorker' => 5,
'GitlabPerformanceBarStatsWorker' => 3,
'GitlabSubscriptions::RefreshSeatsWorker' => 0,
'GitlabShellWorker' => 3,
@@ -368,6 +372,7 @@ RSpec.describe 'Every Sidekiq worker' do
'Namespaces::RefreshRootStatisticsWorker' => 3,
'Namespaces::RootStatisticsWorker' => 3,
'Namespaces::ScheduleAggregationWorker' => 3,
+ 'Namespaces::FreeUserCap::NotificationClearingWorker' => false,
'NewEpicWorker' => 3,
'NewIssueWorker' => 3,
'NewMergeRequestWorker' => 3,
@@ -405,6 +410,7 @@ RSpec.describe 'Every Sidekiq worker' do
'ProjectScheduleBulkRepositoryShardMovesWorker' => 3,
'ProjectTemplateExportWorker' => false,
'ProjectUpdateRepositoryStorageWorker' => 3,
+ 'Projects::DeregisterSuggestedReviewersProjectWorker' => 3,
'Projects::DisableLegacyOpenSourceLicenseForInactiveProjectsWorker' => 3,
'Projects::GitGarbageCollectWorker' => false,
'Projects::InactiveProjectsDeletionNotificationWorker' => 3,
@@ -439,8 +445,6 @@ RSpec.describe 'Every Sidekiq worker' do
'Security::AutoFixWorker' => 3,
'Security::StoreScansWorker' => 3,
'Security::TrackSecureScansWorker' => 1,
- 'SelfMonitoringProjectCreateWorker' => 3,
- 'SelfMonitoringProjectDeleteWorker' => 3,
'ServiceDeskEmailReceiverWorker' => 3,
'SetUserStatusBasedOnUserCapSettingWorker' => 3,
'SnippetScheduleBulkRepositoryShardMovesWorker' => 3,
@@ -475,9 +479,11 @@ RSpec.describe 'Every Sidekiq worker' do
'WebHooks::DestroyWorker' => 3,
'WebHooks::LogExecutionWorker' => 3,
'Wikis::GitGarbageCollectWorker' => false,
+ 'WorkItems::ImportWorkItemsCsvWorker' => 3,
'X509CertificateRevokeWorker' => 3,
- 'ComplianceManagement::MergeRequests::ComplianceViolationsWorker' => 3
- }
+ 'ComplianceManagement::MergeRequests::ComplianceViolationsWorker' => 3,
+ 'Zoekt::IndexerWorker' => 2
+ }.merge(extra_retry_exceptions)
end
it 'uses the default number of retries for new jobs' do
@@ -491,7 +497,7 @@ RSpec.describe 'Every Sidekiq worker' do
it 'uses specified numbers of retries for workers with exceptions encoded here', :aggregate_failures do
retry_exception_workers.each do |worker|
expect(worker.retries).to eq(retry_exceptions[worker.klass.to_s]),
- "#{worker.klass} has #{worker.retries} retries, expected #{retry_exceptions[worker.klass]}"
+ "#{worker.klass} has #{worker.retries} retries, expected #{retry_exceptions[worker.klass]}"
end
end
end
diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb
index 3f8da3fb71c..2ef832eaaed 100644
--- a/spec/workers/expire_build_artifacts_worker_spec.rb
+++ b/spec/workers/expire_build_artifacts_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ExpireBuildArtifactsWorker do
+RSpec.describe ExpireBuildArtifactsWorker, feature_category: :build_artifacts do
let(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/export_csv_worker_spec.rb b/spec/workers/export_csv_worker_spec.rb
index 88ccfac0a02..2de6c58958f 100644
--- a/spec/workers/export_csv_worker_spec.rb
+++ b/spec/workers/export_csv_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ExportCsvWorker do
+RSpec.describe ExportCsvWorker, feature_category: :team_planning do
let(:user) { create(:user) }
let(:project) { create(:project, creator: user) }
diff --git a/spec/workers/external_service_reactive_caching_worker_spec.rb b/spec/workers/external_service_reactive_caching_worker_spec.rb
index 907894d9b9a..58d9bec343a 100644
--- a/spec/workers/external_service_reactive_caching_worker_spec.rb
+++ b/spec/workers/external_service_reactive_caching_worker_spec.rb
@@ -2,6 +2,6 @@
require 'spec_helper'
-RSpec.describe ExternalServiceReactiveCachingWorker do
+RSpec.describe ExternalServiceReactiveCachingWorker, feature_category: :shared do
it_behaves_like 'reactive cacheable worker'
end
diff --git a/spec/workers/file_hook_worker_spec.rb b/spec/workers/file_hook_worker_spec.rb
index c171dc37e5f..00cd0e9c98e 100644
--- a/spec/workers/file_hook_worker_spec.rb
+++ b/spec/workers/file_hook_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe FileHookWorker do
+RSpec.describe FileHookWorker, feature_category: :integrations do
include RepoHelpers
let(:filename) { 'my_file_hook.rb' }
diff --git a/spec/workers/flush_counter_increments_worker_spec.rb b/spec/workers/flush_counter_increments_worker_spec.rb
index 83670acf4b6..1bdda3100c9 100644
--- a/spec/workers/flush_counter_increments_worker_spec.rb
+++ b/spec/workers/flush_counter_increments_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe FlushCounterIncrementsWorker, :counter_attribute do
+RSpec.describe FlushCounterIncrementsWorker, :counter_attribute, feature_category: :shared do
let(:project_statistics) { create(:project_statistics) }
let(:model) { CounterAttributeModel.find(project_statistics.id) }
diff --git a/spec/workers/gitlab/github_gists_import/import_gist_worker_spec.rb b/spec/workers/gitlab/github_gists_import/import_gist_worker_spec.rb
index 1c24cdcccae..fcbe1b2cf99 100644
--- a/spec/workers/gitlab/github_gists_import/import_gist_worker_spec.rb
+++ b/spec/workers/gitlab/github_gists_import/import_gist_worker_spec.rb
@@ -74,20 +74,68 @@ RSpec.describe Gitlab::GithubGistsImport::ImportGistWorker, feature_category: :i
.with(log_attributes.merge('message' => 'importer finished'))
subject.perform(user.id, gist_hash, 'some_key')
+
+ expect_snowplow_event(
+ category: 'Gitlab::GithubGistsImport::ImportGistWorker',
+ label: 'github_gist_import',
+ action: 'create',
+ user: user,
+ status: 'success'
+ )
end
end
- context 'when importer raised an error' do
- it 'raises an error' do
- exception = StandardError.new('_some_error_')
+ context 'when failure' do
+ context 'when importer raised an error' do
+ it 'raises an error' do
+ exception = StandardError.new('_some_error_')
- expect(importer).to receive(:execute).and_raise(exception)
- expect(Gitlab::GithubImport::Logger)
- .to receive(:error)
- .with(log_attributes.merge('message' => 'importer failed', 'error.message' => '_some_error_'))
- expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ expect(importer).to receive(:execute).and_raise(exception)
+ expect(Gitlab::GithubImport::Logger)
+ .to receive(:error)
+ .with(log_attributes.merge('message' => 'importer failed', 'error.message' => '_some_error_'))
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+
+ expect { subject.perform(user.id, gist_hash, 'some_key') }.to raise_error(StandardError)
+ end
+ end
+
+ context 'when importer returns error' do
+ let(:importer_result) { instance_double('ServiceResponse', errors: 'error_message', success?: false) }
+
+ it 'tracks and logs error' do
+ expect(importer).to receive(:execute).and_return(importer_result)
+ expect(Gitlab::GithubImport::Logger)
+ .to receive(:error)
+ .with(log_attributes.merge('message' => 'importer failed', 'error.message' => 'error_message'))
+ expect(Gitlab::JobWaiter).to receive(:notify).with('some_key', subject.jid)
+
+ subject.perform(user.id, gist_hash, 'some_key')
+
+ expect_snowplow_event(
+ category: 'Gitlab::GithubGistsImport::ImportGistWorker',
+ label: 'github_gist_import',
+ action: 'create',
+ user: user,
+ status: 'failed'
+ )
+ end
+ end
+ end
+
+ describe '.sidekiq_retries_exhausted' do
+ it 'sends snowplow event' do
+ job = { 'args' => [user.id, 'some_key', '1'], 'jid' => '123' }
+
+ described_class.sidekiq_retries_exhausted_block.call(job)
- expect { subject.perform(user.id, gist_hash, 'some_key') }.to raise_error(StandardError)
+ expect_snowplow_event(
+ category: 'Gitlab::GithubGistsImport::ImportGistWorker',
+ label: 'github_gist_import',
+ action: 'create',
+ user: user,
+ status: 'failed'
+ )
end
end
end
diff --git a/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb b/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb
index 4e8261f61c4..121f30ea9d5 100644
--- a/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::AdvanceStageWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe Gitlab::GithubImport::AdvanceStageWorker, :clean_gitlab_redis_shared_state, feature_category: :importers do
let(:project) { create(:project) }
let(:import_state) { create(:import_state, project: project, jid: '123') }
let(:worker) { described_class.new }
diff --git a/spec/workers/gitlab/github_import/attachments/import_issue_worker_spec.rb b/spec/workers/gitlab/github_import/attachments/import_issue_worker_spec.rb
index 6d617755861..e0db440232c 100644
--- a/spec/workers/gitlab/github_import/attachments/import_issue_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/attachments/import_issue_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Attachments::ImportIssueWorker do
+RSpec.describe Gitlab::GithubImport::Attachments::ImportIssueWorker, feature_category: :importers do
subject(:worker) { described_class.new }
describe '#import' do
@@ -14,6 +14,19 @@ RSpec.describe Gitlab::GithubImport::Attachments::ImportIssueWorker do
let(:client) { instance_double('Gitlab::GithubImport::Client') }
+ let(:issue_hash) do
+ {
+ 'record_db_id' => rand(100),
+ 'record_type' => 'Issue',
+ 'iid' => 2,
+ 'text' => <<-TEXT
+ Some text...
+
+ ![special-image](https://user-images.githubusercontent.com...)
+ TEXT
+ }
+ end
+
it 'imports an issue attachments' do
expect_next_instance_of(
Gitlab::GithubImport::Importer::NoteAttachmentsImporter,
@@ -28,7 +41,7 @@ RSpec.describe Gitlab::GithubImport::Attachments::ImportIssueWorker do
.to receive(:increment)
.and_call_original
- worker.import(project, client, {})
+ worker.import(project, client, issue_hash)
end
end
end
diff --git a/spec/workers/gitlab/github_import/attachments/import_merge_request_worker_spec.rb b/spec/workers/gitlab/github_import/attachments/import_merge_request_worker_spec.rb
index 66dfc027e6e..b4be229af2a 100644
--- a/spec/workers/gitlab/github_import/attachments/import_merge_request_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/attachments/import_merge_request_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Attachments::ImportMergeRequestWorker do
+RSpec.describe Gitlab::GithubImport::Attachments::ImportMergeRequestWorker, feature_category: :importers do
subject(:worker) { described_class.new }
describe '#import' do
@@ -14,6 +14,19 @@ RSpec.describe Gitlab::GithubImport::Attachments::ImportMergeRequestWorker do
let(:client) { instance_double('Gitlab::GithubImport::Client') }
+ let(:mr_hash) do
+ {
+ 'record_db_id' => rand(100),
+ 'record_type' => 'MergeRequest',
+ 'iid' => 2,
+ 'text' => <<-TEXT
+ Some text...
+
+ ![special-image](https://user-images.githubusercontent.com...)
+ TEXT
+ }
+ end
+
it 'imports an merge request attachments' do
expect_next_instance_of(
Gitlab::GithubImport::Importer::NoteAttachmentsImporter,
@@ -28,7 +41,7 @@ RSpec.describe Gitlab::GithubImport::Attachments::ImportMergeRequestWorker do
.to receive(:increment)
.and_call_original
- worker.import(project, client, {})
+ worker.import(project, client, mr_hash)
end
end
end
diff --git a/spec/workers/gitlab/github_import/attachments/import_note_worker_spec.rb b/spec/workers/gitlab/github_import/attachments/import_note_worker_spec.rb
index 7b60cdecca6..60b49901fd9 100644
--- a/spec/workers/gitlab/github_import/attachments/import_note_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/attachments/import_note_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Attachments::ImportNoteWorker do
+RSpec.describe Gitlab::GithubImport::Attachments::ImportNoteWorker, feature_category: :importers do
subject(:worker) { described_class.new }
describe '#import' do
@@ -19,6 +19,7 @@ RSpec.describe Gitlab::GithubImport::Attachments::ImportNoteWorker do
{
'record_db_id' => rand(100),
'record_type' => 'Note',
+ 'noteable_type' => 'Issue',
'text' => <<-TEXT
Some text...
diff --git a/spec/workers/gitlab/github_import/attachments/import_release_worker_spec.rb b/spec/workers/gitlab/github_import/attachments/import_release_worker_spec.rb
index e49b2fb6504..83cb7b9fecf 100644
--- a/spec/workers/gitlab/github_import/attachments/import_release_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/attachments/import_release_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Attachments::ImportReleaseWorker do
+RSpec.describe Gitlab::GithubImport::Attachments::ImportReleaseWorker, feature_category: :importers do
subject(:worker) { described_class.new }
describe '#import' do
@@ -19,6 +19,7 @@ RSpec.describe Gitlab::GithubImport::Attachments::ImportReleaseWorker do
{
'record_db_id' => rand(100),
'record_type' => 'Release',
+ 'tag' => 'v1.0',
'text' => <<-TEXT
Some text...
diff --git a/spec/workers/gitlab/github_import/import_collaborator_worker_spec.rb b/spec/workers/gitlab/github_import/import_collaborator_worker_spec.rb
new file mode 100644
index 00000000000..b9463fb9a2d
--- /dev/null
+++ b/spec/workers/gitlab/github_import/import_collaborator_worker_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::ImportCollaboratorWorker, feature_category: :importers do
+ let(:worker) { described_class.new }
+
+ describe '#import' do
+ let(:project) do
+ instance_double('Project', full_path: 'foo/bar', id: 1, import_state: import_state)
+ end
+
+ let(:import_state) { build_stubbed(:import_state, :started) }
+ let(:client) { instance_double('Gitlab::GithubImport::Client') }
+ let(:importer) { instance_double('Gitlab::GithubImport::Importer::NoteAttachmentsImporter') }
+
+ it 'imports a collaborator' do
+ expect(Gitlab::GithubImport::Importer::CollaboratorImporter)
+ .to receive(:new)
+ .with(
+ an_instance_of(Gitlab::GithubImport::Representation::Collaborator),
+ project,
+ client
+ )
+ .and_return(importer)
+
+ expect(importer).to receive(:execute)
+
+ expect(Gitlab::GithubImport::ObjectCounter)
+ .to receive(:increment)
+ .and_call_original
+
+ worker.import(
+ project, client, { 'id' => 100500, 'login' => 'alice', 'role_name' => 'maintainer' }
+ )
+ end
+ end
+end
diff --git a/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb b/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb
index c92741e8f10..35d31848fe1 100644
--- a/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ImportDiffNoteWorker do
+RSpec.describe Gitlab::GithubImport::ImportDiffNoteWorker, feature_category: :importers do
let(:worker) { described_class.new }
describe '#import' do
diff --git a/spec/workers/gitlab/github_import/import_issue_event_worker_spec.rb b/spec/workers/gitlab/github_import/import_issue_event_worker_spec.rb
index 03a6503fb84..aa8243154ef 100644
--- a/spec/workers/gitlab/github_import/import_issue_event_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_issue_event_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ImportIssueEventWorker do
+RSpec.describe Gitlab::GithubImport::ImportIssueEventWorker, feature_category: :importers do
subject(:worker) { described_class.new }
describe '#import' do
diff --git a/spec/workers/gitlab/github_import/import_issue_worker_spec.rb b/spec/workers/gitlab/github_import/import_issue_worker_spec.rb
index ef1d2e3f3e7..76f0b512af4 100644
--- a/spec/workers/gitlab/github_import/import_issue_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_issue_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ImportIssueWorker do
+RSpec.describe Gitlab::GithubImport::ImportIssueWorker, feature_category: :importers do
let(:worker) { described_class.new }
describe '#import' do
diff --git a/spec/workers/gitlab/github_import/import_note_worker_spec.rb b/spec/workers/gitlab/github_import/import_note_worker_spec.rb
index 16ca5658f77..b11ea560516 100644
--- a/spec/workers/gitlab/github_import/import_note_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_note_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ImportNoteWorker do
+RSpec.describe Gitlab::GithubImport::ImportNoteWorker, feature_category: :importers do
let(:worker) { described_class.new }
describe '#import' do
diff --git a/spec/workers/gitlab/github_import/import_protected_branch_worker_spec.rb b/spec/workers/gitlab/github_import/import_protected_branch_worker_spec.rb
index c2b8ee661a3..d6e8f760033 100644
--- a/spec/workers/gitlab/github_import/import_protected_branch_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_protected_branch_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ImportProtectedBranchWorker do
+RSpec.describe Gitlab::GithubImport::ImportProtectedBranchWorker, feature_category: :importers do
let(:worker) { described_class.new }
let(:import_state) { build_stubbed(:import_state, :started) }
diff --git a/spec/workers/gitlab/github_import/import_pull_request_merged_by_worker_spec.rb b/spec/workers/gitlab/github_import/import_pull_request_merged_by_worker_spec.rb
index 728b4c6b440..4fbdfb1903f 100644
--- a/spec/workers/gitlab/github_import/import_pull_request_merged_by_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_pull_request_merged_by_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ImportPullRequestMergedByWorker do
+RSpec.describe Gitlab::GithubImport::ImportPullRequestMergedByWorker, feature_category: :importers do
it { is_expected.to include_module(Gitlab::GithubImport::ObjectImporter) }
describe '#representation_class' do
@@ -10,6 +10,6 @@ RSpec.describe Gitlab::GithubImport::ImportPullRequestMergedByWorker do
end
describe '#importer_class' do
- it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequestMergedByImporter) }
+ it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequests::MergedByImporter) }
end
end
diff --git a/spec/workers/gitlab/github_import/import_pull_request_review_worker_spec.rb b/spec/workers/gitlab/github_import/import_pull_request_review_worker_spec.rb
index 0607add52cd..41f97224bb4 100644
--- a/spec/workers/gitlab/github_import/import_pull_request_review_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_pull_request_review_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ImportPullRequestReviewWorker do
+RSpec.describe Gitlab::GithubImport::ImportPullRequestReviewWorker, feature_category: :importers do
it { is_expected.to include_module(Gitlab::GithubImport::ObjectImporter) }
describe '#representation_class' do
@@ -10,6 +10,6 @@ RSpec.describe Gitlab::GithubImport::ImportPullRequestReviewWorker do
end
describe '#importer_class' do
- it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequestReviewImporter) }
+ it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequests::ReviewImporter) }
end
end
diff --git a/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb b/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb
index 59f45b437c4..8378b67f0b5 100644
--- a/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ImportPullRequestWorker do
+RSpec.describe Gitlab::GithubImport::ImportPullRequestWorker, feature_category: :importers do
let(:worker) { described_class.new }
describe '#import' do
@@ -50,5 +50,21 @@ RSpec.describe Gitlab::GithubImport::ImportPullRequestWorker do
worker.import(project, client, hash)
end
+
+ describe '#importer_class' do
+ it { expect(worker.importer_class).to eq Gitlab::GithubImport::Importer::PullRequestImporter }
+ end
+
+ describe '#representation_class' do
+ it { expect(worker.representation_class).to eq Gitlab::GithubImport::Representation::PullRequest }
+ end
+
+ describe '#object_type' do
+ it { expect(worker.object_type).to eq(:pull_request) }
+ end
+
+ describe '#parallel_import_batch' do
+ it { expect(worker.parallel_import_batch).to eq({ size: 200, delay: 1.minute }) }
+ end
end
end
diff --git a/spec/workers/gitlab/github_import/import_release_attachments_worker_spec.rb b/spec/workers/gitlab/github_import/import_release_attachments_worker_spec.rb
index 1d32d5c0e21..62a9e3446f8 100644
--- a/spec/workers/gitlab/github_import/import_release_attachments_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_release_attachments_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::ImportReleaseAttachmentsWorker do
+RSpec.describe Gitlab::GithubImport::ImportReleaseAttachmentsWorker, feature_category: :importers do
subject(:worker) { described_class.new }
describe '#import' do
@@ -17,8 +17,10 @@ RSpec.describe Gitlab::GithubImport::ImportReleaseAttachmentsWorker do
let(:release_hash) do
{
- 'release_db_id' => rand(100),
- 'description' => <<-TEXT
+ 'record_db_id' => rand(100),
+ 'record_type' => 'Release',
+ 'tag' => 'v1.0',
+ 'text' => <<-TEXT
Some text...
![special-image](https://user-images.githubusercontent.com...)
diff --git a/spec/workers/gitlab/github_import/pull_requests/import_merged_by_worker_spec.rb b/spec/workers/gitlab/github_import/pull_requests/import_merged_by_worker_spec.rb
new file mode 100644
index 00000000000..23631c76a61
--- /dev/null
+++ b/spec/workers/gitlab/github_import/pull_requests/import_merged_by_worker_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::PullRequests::ImportMergedByWorker, feature_category: :importers do
+ it { is_expected.to include_module(Gitlab::GithubImport::ObjectImporter) }
+
+ describe '#representation_class' do
+ it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::PullRequest) }
+ end
+
+ describe '#importer_class' do
+ it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequests::MergedByImporter) }
+ end
+
+ describe '#object_type' do
+ it { expect(subject.object_type).to eq(:pull_request_merged_by) }
+ end
+end
diff --git a/spec/workers/gitlab/github_import/pull_requests/import_review_request_worker_spec.rb b/spec/workers/gitlab/github_import/pull_requests/import_review_request_worker_spec.rb
index fdcbfb18beb..99ed83ae2db 100644
--- a/spec/workers/gitlab/github_import/pull_requests/import_review_request_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/pull_requests/import_review_request_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::PullRequests::ImportReviewRequestWorker do
+RSpec.describe Gitlab::GithubImport::PullRequests::ImportReviewRequestWorker, feature_category: :importers do
subject(:worker) { described_class.new }
describe '#import' do
diff --git a/spec/workers/gitlab/github_import/pull_requests/import_review_worker_spec.rb b/spec/workers/gitlab/github_import/pull_requests/import_review_worker_spec.rb
new file mode 100644
index 00000000000..59d92a4bb2c
--- /dev/null
+++ b/spec/workers/gitlab/github_import/pull_requests/import_review_worker_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::PullRequests::ImportReviewWorker, feature_category: :importers do
+ it { is_expected.to include_module(Gitlab::GithubImport::ObjectImporter) }
+
+ describe '#representation_class' do
+ it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::PullRequestReview) }
+ end
+
+ describe '#importer_class' do
+ it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::PullRequests::ReviewImporter) }
+ end
+
+ describe '#object_type' do
+ it { expect(subject.object_type).to eq(:pull_request_review) }
+ end
+end
diff --git a/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb b/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb
index 3a8b585fa77..abba6cd7734 100644
--- a/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::RefreshImportJidWorker do
+RSpec.describe Gitlab::GithubImport::RefreshImportJidWorker, feature_category: :importers do
let(:worker) { described_class.new }
describe '.perform_in_the_future' do
diff --git a/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb b/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb
index 5f60dfc8ca1..e517f30ee2c 100644
--- a/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::FinishImportWorker do
+RSpec.describe Gitlab::GithubImport::Stage::FinishImportWorker, feature_category: :importers do
let(:project) { create(:project) }
let(:worker) { described_class.new }
diff --git a/spec/workers/gitlab/github_import/stage/import_attachments_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_attachments_worker_spec.rb
index ecfece735af..2945bcbe641 100644
--- a/spec/workers/gitlab/github_import/stage/import_attachments_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_attachments_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportAttachmentsWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportAttachmentsWorker, feature_category: :importers do
subject(:worker) { described_class.new }
let_it_be(:project) { create(:project) }
diff --git a/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb
index 7b2218b1725..1ad027a007a 100644
--- a/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportBaseDataWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportBaseDataWorker, feature_category: :importers do
let_it_be(:project) { create(:project) }
let_it_be(:import_state) { create(:import_state, project: project) }
diff --git a/spec/workers/gitlab/github_import/stage/import_collaborators_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_collaborators_worker_spec.rb
new file mode 100644
index 00000000000..33ecf848997
--- /dev/null
+++ b/spec/workers/gitlab/github_import/stage/import_collaborators_worker_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Stage::ImportCollaboratorsWorker, feature_category: :importers do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:import_state) { create(:import_state, project: project) }
+ let(:settings) { Gitlab::GithubImport::Settings.new(project) }
+ let(:stage_enabled) { true }
+
+ let(:worker) { described_class.new }
+ let(:importer) { instance_double(Gitlab::GithubImport::Importer::CollaboratorsImporter) }
+ let(:client) { instance_double(Gitlab::GithubImport::Client) }
+
+ describe '#import' do
+ let(:push_rights_granted) { true }
+
+ before do
+ settings.write({ collaborators_import: stage_enabled })
+ allow(client).to receive(:repository).with(project.import_source)
+ .and_return({ permissions: { push: push_rights_granted } })
+ end
+
+ context 'when user has push access for this repo' do
+ it 'imports all collaborators' do
+ waiter = Gitlab::JobWaiter.new(2, '123')
+
+ expect(Gitlab::GithubImport::Importer::CollaboratorsImporter)
+ .to receive(:new)
+ .with(project, client)
+ .and_return(importer)
+ expect(importer).to receive(:execute).and_return(waiter)
+
+ expect(import_state).to receive(:refresh_jid_expiration)
+
+ expect(Gitlab::GithubImport::AdvanceStageWorker)
+ .to receive(:perform_async)
+ .with(project.id, { '123' => 2 }, :pull_requests_merged_by)
+
+ worker.import(client, project)
+ end
+ end
+
+ context 'when user do not have push access for this repo' do
+ let(:push_rights_granted) { false }
+
+ it 'skips stage' do
+ expect(Gitlab::GithubImport::Importer::CollaboratorsImporter).not_to receive(:new)
+
+ expect(Gitlab::GithubImport::AdvanceStageWorker)
+ .to receive(:perform_async)
+ .with(project.id, {}, :pull_requests_merged_by)
+
+ worker.import(client, project)
+ end
+ end
+
+ context 'when stage is disabled' do
+ let(:stage_enabled) { false }
+
+ it 'skips collaborators import and calls next stage' do
+ expect(Gitlab::GithubImport::Importer::CollaboratorsImporter).not_to receive(:new)
+
+ expect(Gitlab::GithubImport::AdvanceStageWorker)
+ .to receive(:perform_async)
+ .with(project.id, {}, :pull_requests_merged_by)
+
+ worker.import(client, project)
+ end
+ end
+
+ it 'raises an error' do
+ exception = StandardError.new('_some_error_')
+
+ expect_next_instance_of(Gitlab::GithubImport::Importer::CollaboratorsImporter) do |importer|
+ expect(importer).to receive(:execute).and_raise(exception)
+ end
+ expect(Gitlab::Import::ImportFailureService).to receive(:track)
+ .with(
+ project_id: project.id,
+ exception: exception,
+ error_source: described_class.name,
+ fail_import: true,
+ metrics: true
+ ).and_call_original
+
+ expect { worker.import(client, project) }.to raise_error(StandardError)
+ end
+ end
+end
diff --git a/spec/workers/gitlab/github_import/stage/import_issue_events_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_issue_events_worker_spec.rb
index 199d1b9a3ca..c70ee0250e8 100644
--- a/spec/workers/gitlab/github_import/stage/import_issue_events_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_issue_events_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportIssueEventsWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportIssueEventsWorker, feature_category: :importers do
subject(:worker) { described_class.new }
let(:project) { create(:project) }
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 beef0864715..872201ece93 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportIssuesAndDiffNotesWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportIssuesAndDiffNotesWorker, feature_category: :importers do
let(:project) { create(:project) }
let(:worker) { described_class.new }
diff --git a/spec/workers/gitlab/github_import/stage/import_lfs_objects_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_lfs_objects_worker_spec.rb
index 103d55890c4..2449c0505f5 100644
--- a/spec/workers/gitlab/github_import/stage/import_lfs_objects_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_lfs_objects_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportLfsObjectsWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportLfsObjectsWorker, feature_category: :importers do
let(:project) { create(:project) }
let(:worker) { described_class.new }
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 dbcf2083ec1..8c0004c0f3f 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
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportNotesWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportNotesWorker, feature_category: :importers do
let(:project) { create(:project) }
let(:worker) { described_class.new }
diff --git a/spec/workers/gitlab/github_import/stage/import_protected_branches_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_protected_branches_worker_spec.rb
index 0770af524a1..f848293a3b2 100644
--- a/spec/workers/gitlab/github_import/stage/import_protected_branches_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_protected_branches_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportProtectedBranchesWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportProtectedBranchesWorker, feature_category: :importers do
let_it_be(:project) { create(:project) }
let_it_be(:import_state) { create(:import_state, project: project) }
diff --git a/spec/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker_spec.rb
index 5d6dcdc10ee..0debabda0cc 100644
--- a/spec/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsMergedByWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsMergedByWorker, feature_category: :importers do
let(:project) { create(:project) }
let(:import_state) { create(:import_state, project: project) }
let(:worker) { described_class.new }
@@ -13,7 +13,7 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsMergedByWorker do
client = double(:client)
waiter = Gitlab::JobWaiter.new(2, '123')
- expect(Gitlab::GithubImport::Importer::PullRequestsMergedByImporter)
+ expect(Gitlab::GithubImport::Importer::PullRequests::AllMergedByImporter)
.to receive(:new)
.with(project, client)
.and_return(importer)
diff --git a/spec/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker_spec.rb
index 151de9bdffc..41c0b29df7c 100644
--- a/spec/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsReviewRequestsWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsReviewRequestsWorker, feature_category: :importers do
subject(:worker) { described_class.new }
let(:project) { instance_double(Project, id: 1, import_state: import_state) }
diff --git a/spec/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker_spec.rb
index 18a70273219..b1141c7f324 100644
--- a/spec/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsReviewsWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsReviewsWorker, feature_category: :importers do
let(:project) { create(:project) }
let(:import_state) { create(:import_state, project: project) }
let(:worker) { described_class.new }
@@ -14,7 +14,7 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsReviewsWorker do
waiter = Gitlab::JobWaiter.new(2, '123')
- expect(Gitlab::GithubImport::Importer::PullRequestsReviewsImporter)
+ expect(Gitlab::GithubImport::Importer::PullRequests::ReviewsImporter)
.to receive(:new)
.with(project, client)
.and_return(importer)
diff --git a/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb
index b18b5ce64d1..6ebf93730eb 100644
--- a/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsWorker, feature_category: :importers do
let_it_be(:project) { create(:project) }
let_it_be(:import_state) { create(:import_state, project: project) }
@@ -28,7 +28,7 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsWorker do
expect(Gitlab::GithubImport::AdvanceStageWorker)
.to receive(:perform_async)
- .with(project.id, { '123' => 2 }, :pull_requests_merged_by)
+ .with(project.id, { '123' => 2 }, :collaborators)
worker.import(client, project)
end
diff --git a/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb
index 24fca3b7c73..94d8155d371 100644
--- a/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::GithubImport::Stage::ImportRepositoryWorker do
+RSpec.describe Gitlab::GithubImport::Stage::ImportRepositoryWorker, feature_category: :importers do
let_it_be(:project) { create(:project, :import_started) }
let(:worker) { described_class.new }
@@ -33,7 +33,7 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportRepositoryWorker do
:issues, project.import_source, options
).and_return([{ number: 5 }].each)
- expect(Issue).to receive(:track_project_iid!).with(project, 5)
+ expect(Issue).to receive(:track_namespace_iid!).with(project.project_namespace, 5)
expect(Gitlab::GithubImport::Stage::ImportBaseDataWorker)
.to receive(:perform_async)
@@ -54,7 +54,7 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportRepositoryWorker do
expect(InternalId).to receive(:exists?).and_return(false)
expect(client).to receive(:each_object).with(:issues, project.import_source, options).and_return([nil].each)
- expect(Issue).not_to receive(:track_project_iid!)
+ expect(Issue).not_to receive(:track_namespace_iid!)
expect(Gitlab::GithubImport::Stage::ImportBaseDataWorker)
.to receive(:perform_async)
@@ -74,7 +74,7 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportRepositoryWorker do
expect(InternalId).to receive(:exists?).and_return(true)
expect(client).not_to receive(:each_object)
- expect(Issue).not_to receive(:track_project_iid!)
+ expect(Issue).not_to receive(:track_namespace_iid!)
expect(Gitlab::GithubImport::Stage::ImportBaseDataWorker)
.to receive(:perform_async)
@@ -96,7 +96,7 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportRepositoryWorker do
expect(InternalId).to receive(:exists?).and_return(false)
expect(client).to receive(:each_object).and_return([nil].each)
- expect(Issue).not_to receive(:track_project_iid!)
+ expect(Issue).not_to receive(:track_namespace_iid!)
expect(Gitlab::Import::ImportFailureService).to receive(:track)
.with(
diff --git a/spec/workers/gitlab/import/stuck_import_job_spec.rb b/spec/workers/gitlab/import/stuck_import_job_spec.rb
index 3a1463e98a0..52721168143 100644
--- a/spec/workers/gitlab/import/stuck_import_job_spec.rb
+++ b/spec/workers/gitlab/import/stuck_import_job_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Import::StuckImportJob do
+RSpec.describe Gitlab::Import::StuckImportJob, feature_category: :importers do
let_it_be(:project) { create(:project, :import_started, import_source: 'foo/bar') }
let(:worker) do
diff --git a/spec/workers/gitlab/import/stuck_project_import_jobs_worker_spec.rb b/spec/workers/gitlab/import/stuck_project_import_jobs_worker_spec.rb
index d12d5a605a7..9994896d3c8 100644
--- a/spec/workers/gitlab/import/stuck_project_import_jobs_worker_spec.rb
+++ b/spec/workers/gitlab/import/stuck_project_import_jobs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Import::StuckProjectImportJobsWorker do
+RSpec.describe Gitlab::Import::StuckProjectImportJobsWorker, feature_category: :importers do
let(:worker) { described_class.new }
describe 'with scheduled import_status' do
diff --git a/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb b/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb
index 0244e69b7b6..5209395923f 100644
--- a/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::JiraImport::ImportIssueWorker do
+RSpec.describe Gitlab::JiraImport::ImportIssueWorker, feature_category: :importers do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:jira_issue_label_1) { create(:label, project: project) }
diff --git a/spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb
index 23a764bd972..d1bf71a2095 100644
--- a/spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::JiraImport::Stage::FinishImportWorker do
+RSpec.describe Gitlab::JiraImport::Stage::FinishImportWorker, feature_category: :importers do
let_it_be(:project) { create(:project) }
let_it_be(:worker) { described_class.new }
diff --git a/spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb
index 28da93b8d00..c594182bf75 100644
--- a/spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::JiraImport::Stage::ImportAttachmentsWorker do
+RSpec.describe Gitlab::JiraImport::Stage::ImportAttachmentsWorker, feature_category: :importers do
let_it_be(:project) { create(:project, import_type: 'jira') }
describe 'modules' do
diff --git a/spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb
index 2b08a592164..594f9618770 100644
--- a/spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::JiraImport::Stage::ImportIssuesWorker do
+RSpec.describe Gitlab::JiraImport::Stage::ImportIssuesWorker, feature_category: :importers do
include JiraIntegrationHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb
index d15f2caba19..d9c2407a423 100644
--- a/spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::JiraImport::Stage::ImportLabelsWorker do
+RSpec.describe Gitlab::JiraImport::Stage::ImportLabelsWorker, feature_category: :importers do
include JiraIntegrationHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb
index 2502bbf1df4..41216356033 100644
--- a/spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::JiraImport::Stage::ImportNotesWorker do
+RSpec.describe Gitlab::JiraImport::Stage::ImportNotesWorker, feature_category: :importers do
let_it_be(:project) { create(:project, import_type: 'jira') }
describe 'modules' do
diff --git a/spec/workers/gitlab/jira_import/stage/start_import_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/start_import_worker_spec.rb
index e440884553f..ea0291b1c44 100644
--- a/spec/workers/gitlab/jira_import/stage/start_import_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/start_import_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::JiraImport::Stage::StartImportWorker do
+RSpec.describe Gitlab::JiraImport::Stage::StartImportWorker, feature_category: :importers do
let_it_be(:project) { create(:project, import_type: 'jira') }
let_it_be(:jid) { '12345678' }
diff --git a/spec/workers/gitlab/jira_import/stuck_jira_import_jobs_worker_spec.rb b/spec/workers/gitlab/jira_import/stuck_jira_import_jobs_worker_spec.rb
index 92754513988..0f9f9b70c3e 100644
--- a/spec/workers/gitlab/jira_import/stuck_jira_import_jobs_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stuck_jira_import_jobs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ::Gitlab::JiraImport::StuckJiraImportJobsWorker do
+RSpec.describe ::Gitlab::JiraImport::StuckJiraImportJobsWorker, feature_category: :importers do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
diff --git a/spec/workers/gitlab/phabricator_import/base_worker_spec.rb b/spec/workers/gitlab/phabricator_import/base_worker_spec.rb
deleted file mode 100644
index 18fa484aa7a..00000000000
--- a/spec/workers/gitlab/phabricator_import/base_worker_spec.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::BaseWorker do
- let(:subclass) do
- # Creating an anonymous class for a worker is complicated, as we generate the
- # queue name from the class name.
- Gitlab::PhabricatorImport::ImportTasksWorker
- end
-
- describe '.schedule' do
- let(:arguments) { %w[project_id the_next_page] }
-
- it 'schedules the job' do
- expect(subclass).to receive(:perform_async).with(*arguments)
-
- subclass.schedule(*arguments)
- end
-
- it 'counts the scheduled job', :clean_gitlab_redis_shared_state do
- state = Gitlab::PhabricatorImport::WorkerState.new('project_id')
-
- allow(subclass).to receive(:remove_job) # otherwise the job is removed before we saw it
-
- expect { subclass.schedule(*arguments) }.to change { state.running_count }.by(1)
- end
- end
-
- describe '#perform' do
- let(:project) { create(:project, :import_started, import_url: "https://a.phab.instance") }
- let(:worker) { subclass.new }
- let(:state) { Gitlab::PhabricatorImport::WorkerState.new(project.id) }
-
- before do
- allow(worker).to receive(:import)
- end
-
- it 'does not break for a non-existing project' do
- expect { worker.perform('not a thing') }.not_to raise_error
- end
-
- it 'does not do anything when the import is not in progress' do
- project = create(:project, :import_failed)
-
- expect(worker).not_to receive(:import)
-
- worker.perform(project.id)
- end
-
- it 'calls import for the project' do
- expect(worker).to receive(:import).with(project, 'other_arg')
-
- worker.perform(project.id, 'other_arg')
- end
-
- it 'marks the project as imported if there was only one job running' do
- worker.perform(project.id)
-
- expect(project.import_state.reload).to be_finished
- end
-
- it 'does not mark the job as finished when there are more scheduled jobs' do
- 2.times { state.add_job }
-
- worker.perform(project.id)
-
- expect(project.import_state.reload).to be_in_progress
- end
-
- it 'decrements the job counter' do
- expect { worker.perform(project.id) }.to change { state.running_count }.by(-1)
- end
- end
-end
diff --git a/spec/workers/gitlab/phabricator_import/import_tasks_worker_spec.rb b/spec/workers/gitlab/phabricator_import/import_tasks_worker_spec.rb
deleted file mode 100644
index 221b6202166..00000000000
--- a/spec/workers/gitlab/phabricator_import/import_tasks_worker_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::PhabricatorImport::ImportTasksWorker do
- describe '#perform' do
- it 'calls the correct importer' do
- project = create(:project, :import_started, import_url: "https://the.phab.ulr")
- fake_importer = instance_double(Gitlab::PhabricatorImport::Issues::Importer)
-
- expect(Gitlab::PhabricatorImport::Issues::Importer).to receive(:new).with(project).and_return(fake_importer)
- expect(fake_importer).to receive(:execute)
-
- described_class.new.perform(project.id)
- end
- end
-end
diff --git a/spec/workers/gitlab_performance_bar_stats_worker_spec.rb b/spec/workers/gitlab_performance_bar_stats_worker_spec.rb
index 3638add1524..45fb58826ba 100644
--- a/spec/workers/gitlab_performance_bar_stats_worker_spec.rb
+++ b/spec/workers/gitlab_performance_bar_stats_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabPerformanceBarStatsWorker do
+RSpec.describe GitlabPerformanceBarStatsWorker, feature_category: :metrics do
include ExclusiveLeaseHelpers
subject(:worker) { described_class.new }
diff --git a/spec/workers/gitlab_service_ping_worker_spec.rb b/spec/workers/gitlab_service_ping_worker_spec.rb
index f17847a7b33..1d16e8c3496 100644
--- a/spec/workers/gitlab_service_ping_worker_spec.rb
+++ b/spec/workers/gitlab_service_ping_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabServicePingWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe GitlabServicePingWorker, :clean_gitlab_redis_shared_state, feature_category: :service_ping do
let(:payload) { { recorded_at: Time.current.rfc3339 } }
before do
@@ -14,15 +14,13 @@ RSpec.describe GitlabServicePingWorker, :clean_gitlab_redis_shared_state do
allow(subject).to receive(:sleep)
end
- it 'does not run for GitLab.com when triggered from cron' do
- allow(Gitlab).to receive(:com?).and_return(true)
+ it 'does not run for SaaS when triggered from cron', :saas do
expect(ServicePing::SubmitService).not_to receive(:new)
subject.perform
end
- it 'runs for GitLab.com when triggered manually' do
- allow(Gitlab).to receive(:com?).and_return(true)
+ it 'runs for SaaS when triggered manually', :saas do
expect(ServicePing::SubmitService).to receive(:new)
subject.perform('triggered_from_cron' => false)
diff --git a/spec/workers/gitlab_shell_worker_spec.rb b/spec/workers/gitlab_shell_worker_spec.rb
index 838f2ef4ba4..9fff4489667 100644
--- a/spec/workers/gitlab_shell_worker_spec.rb
+++ b/spec/workers/gitlab_shell_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabShellWorker, :sidekiq_inline do
+RSpec.describe GitlabShellWorker, :sidekiq_inline, feature_category: :source_code_management do
describe '#perform' do
Gitlab::Shell::PERMITTED_ACTIONS.each do |action|
describe "with the #{action} action" do
diff --git a/spec/workers/google_cloud/create_cloudsql_instance_worker_spec.rb b/spec/workers/google_cloud/create_cloudsql_instance_worker_spec.rb
index 5d595a3679b..7aea40807e8 100644
--- a/spec/workers/google_cloud/create_cloudsql_instance_worker_spec.rb
+++ b/spec/workers/google_cloud/create_cloudsql_instance_worker_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require 'google/apis/sqladmin_v1beta4'
-RSpec.describe GoogleCloud::CreateCloudsqlInstanceWorker do
+RSpec.describe GoogleCloud::CreateCloudsqlInstanceWorker, feature_category: :shared do
let(:random_user) { create(:user) }
let(:project) { create(:project) }
let(:worker_options) do
diff --git a/spec/workers/group_destroy_worker_spec.rb b/spec/workers/group_destroy_worker_spec.rb
index 82ae9010a24..fba4573718a 100644
--- a/spec/workers/group_destroy_worker_spec.rb
+++ b/spec/workers/group_destroy_worker_spec.rb
@@ -2,20 +2,29 @@
require 'spec_helper'
-RSpec.describe GroupDestroyWorker do
- let(:group) { create(:group) }
- let!(:project) { create(:project, namespace: group) }
- let(:user) { create(:user) }
+RSpec.describe GroupDestroyWorker, feature_category: :subgroups do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, namespace: group) }
+ let_it_be(:user) { create(:user) }
before do
group.add_owner(user)
end
- subject { described_class.new }
+ subject(:worker) { described_class.new }
+
+ include_examples 'an idempotent worker' do
+ let(:job_args) { [group.id, user.id] }
+
+ it 'does not change groups when run twice' do
+ expect { worker.perform(group.id, user.id) }.to change { Group.count }.by(-1)
+ expect { worker.perform(group.id, user.id) }.not_to change { Group.count }
+ end
+ end
describe "#perform" do
- it "deletes the project" do
- subject.perform(group.id, user.id)
+ it "deletes the group and associated projects" do
+ worker.perform(group.id, user.id)
expect(Group.all).not_to include(group)
expect(Project.all).not_to include(project)
diff --git a/spec/workers/group_export_worker_spec.rb b/spec/workers/group_export_worker_spec.rb
index 4e58e3886a4..54f9c38a506 100644
--- a/spec/workers/group_export_worker_spec.rb
+++ b/spec/workers/group_export_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GroupExportWorker do
+RSpec.describe GroupExportWorker, feature_category: :importers do
let!(:user) { create(:user) }
let!(:group) { create(:group) }
diff --git a/spec/workers/group_import_worker_spec.rb b/spec/workers/group_import_worker_spec.rb
index 5171de7086b..9aa6aaec24c 100644
--- a/spec/workers/group_import_worker_spec.rb
+++ b/spec/workers/group_import_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GroupImportWorker do
+RSpec.describe GroupImportWorker, feature_category: :importers do
let(:user) { create(:user) }
let(:group) { create(:group) }
diff --git a/spec/workers/groups/update_statistics_worker_spec.rb b/spec/workers/groups/update_statistics_worker_spec.rb
index 7fc166ed300..f47606f0580 100644
--- a/spec/workers/groups/update_statistics_worker_spec.rb
+++ b/spec/workers/groups/update_statistics_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::UpdateStatisticsWorker do
+RSpec.describe Groups::UpdateStatisticsWorker, feature_category: :source_code_management do
let_it_be(:group) { create(:group) }
let(:statistics) { %w(wiki_size) }
diff --git a/spec/workers/groups/update_two_factor_requirement_for_members_worker_spec.rb b/spec/workers/groups/update_two_factor_requirement_for_members_worker_spec.rb
index 9d202b9452f..aa1ca698095 100644
--- a/spec/workers/groups/update_two_factor_requirement_for_members_worker_spec.rb
+++ b/spec/workers/groups/update_two_factor_requirement_for_members_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Groups::UpdateTwoFactorRequirementForMembersWorker do
+RSpec.describe Groups::UpdateTwoFactorRequirementForMembersWorker, feature_category: :system_access do
let_it_be(:group) { create(:group) }
let(:worker) { described_class.new }
diff --git a/spec/workers/hashed_storage/migrator_worker_spec.rb b/spec/workers/hashed_storage/migrator_worker_spec.rb
index e014297756e..f188928cf92 100644
--- a/spec/workers/hashed_storage/migrator_worker_spec.rb
+++ b/spec/workers/hashed_storage/migrator_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe HashedStorage::MigratorWorker do
+RSpec.describe HashedStorage::MigratorWorker, feature_category: :source_code_management do
subject(:worker) { described_class.new }
let(:projects) { create_list(:project, 2, :legacy_storage, :empty_repo) }
diff --git a/spec/workers/hashed_storage/project_migrate_worker_spec.rb b/spec/workers/hashed_storage/project_migrate_worker_spec.rb
index fd460888932..84592e85eaa 100644
--- a/spec/workers/hashed_storage/project_migrate_worker_spec.rb
+++ b/spec/workers/hashed_storage/project_migrate_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe HashedStorage::ProjectMigrateWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe HashedStorage::ProjectMigrateWorker, :clean_gitlab_redis_shared_state, feature_category: :source_code_management do
include ExclusiveLeaseHelpers
let(:migration_service) { ::Projects::HashedStorage::MigrationService }
diff --git a/spec/workers/hashed_storage/project_rollback_worker_spec.rb b/spec/workers/hashed_storage/project_rollback_worker_spec.rb
index fc89ac728b1..f27b5e4b9ce 100644
--- a/spec/workers/hashed_storage/project_rollback_worker_spec.rb
+++ b/spec/workers/hashed_storage/project_rollback_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe HashedStorage::ProjectRollbackWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe HashedStorage::ProjectRollbackWorker, :clean_gitlab_redis_shared_state, feature_category: :source_code_management do
include ExclusiveLeaseHelpers
describe '#perform' do
diff --git a/spec/workers/hashed_storage/rollbacker_worker_spec.rb b/spec/workers/hashed_storage/rollbacker_worker_spec.rb
index 46cca068273..af8957d9b96 100644
--- a/spec/workers/hashed_storage/rollbacker_worker_spec.rb
+++ b/spec/workers/hashed_storage/rollbacker_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe HashedStorage::RollbackerWorker do
+RSpec.describe HashedStorage::RollbackerWorker, feature_category: :source_code_management do
subject(:worker) { described_class.new }
let(:projects) { create_list(:project, 2, :empty_repo) }
diff --git a/spec/workers/import_issues_csv_worker_spec.rb b/spec/workers/import_issues_csv_worker_spec.rb
index 919ab2b1adf..7f38119d499 100644
--- a/spec/workers/import_issues_csv_worker_spec.rb
+++ b/spec/workers/import_issues_csv_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ImportIssuesCsvWorker do
+RSpec.describe ImportIssuesCsvWorker, feature_category: :team_planning do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/workers/incident_management/add_severity_system_note_worker_spec.rb b/spec/workers/incident_management/add_severity_system_note_worker_spec.rb
index 4d6e6610a92..cd19c5694b5 100644
--- a/spec/workers/incident_management/add_severity_system_note_worker_spec.rb
+++ b/spec/workers/incident_management/add_severity_system_note_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::AddSeveritySystemNoteWorker do
+RSpec.describe IncidentManagement::AddSeveritySystemNoteWorker, feature_category: :incident_management do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:incident) { create(:incident, project: project) }
diff --git a/spec/workers/incident_management/close_incident_worker_spec.rb b/spec/workers/incident_management/close_incident_worker_spec.rb
index 145ee780573..3c2e69a4675 100644
--- a/spec/workers/incident_management/close_incident_worker_spec.rb
+++ b/spec/workers/incident_management/close_incident_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::CloseIncidentWorker do
+RSpec.describe IncidentManagement::CloseIncidentWorker, feature_category: :incident_management do
subject(:worker) { described_class.new }
describe '#perform' do
@@ -36,7 +36,7 @@ RSpec.describe IncidentManagement::CloseIncidentWorker do
context 'when issue type is not incident' do
before do
- issue.update!(issue_type: :issue)
+ issue.update!(issue_type: :issue, work_item_type: WorkItems::Type.default_by_type(:issue))
end
it_behaves_like 'does not call the close issue service'
diff --git a/spec/workers/incident_management/pager_duty/process_incident_worker_spec.rb b/spec/workers/incident_management/pager_duty/process_incident_worker_spec.rb
index b81f1a575b5..f537acfaa05 100644
--- a/spec/workers/incident_management/pager_duty/process_incident_worker_spec.rb
+++ b/spec/workers/incident_management/pager_duty/process_incident_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::PagerDuty::ProcessIncidentWorker do
+RSpec.describe IncidentManagement::PagerDuty::ProcessIncidentWorker, feature_category: :incident_management do
let_it_be(:project) { create(:project) }
let_it_be(:incident_management_setting) { create(:project_incident_management_setting, project: project, pagerduty_active: true) }
diff --git a/spec/workers/incident_management/process_alert_worker_v2_spec.rb b/spec/workers/incident_management/process_alert_worker_v2_spec.rb
index 6cde8b758fa..476b6f04942 100644
--- a/spec/workers/incident_management/process_alert_worker_v2_spec.rb
+++ b/spec/workers/incident_management/process_alert_worker_v2_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IncidentManagement::ProcessAlertWorkerV2 do
+RSpec.describe IncidentManagement::ProcessAlertWorkerV2, feature_category: :incident_management do
let_it_be(:project) { create(:project) }
let_it_be(:settings) { create(:project_incident_management_setting, project: project, create_issue: true) }
diff --git a/spec/workers/integrations/create_external_cross_reference_worker_spec.rb b/spec/workers/integrations/create_external_cross_reference_worker_spec.rb
index 8e586b90905..ee974fe2b28 100644
--- a/spec/workers/integrations/create_external_cross_reference_worker_spec.rb
+++ b/spec/workers/integrations/create_external_cross_reference_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Integrations::CreateExternalCrossReferenceWorker do
+RSpec.describe Integrations::CreateExternalCrossReferenceWorker, feature_category: :integrations do
include AfterNextHelpers
using RSpec::Parameterized::TableSyntax
diff --git a/spec/workers/integrations/execute_worker_spec.rb b/spec/workers/integrations/execute_worker_spec.rb
index 0e585e3006c..717e3c65820 100644
--- a/spec/workers/integrations/execute_worker_spec.rb
+++ b/spec/workers/integrations/execute_worker_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Integrations::ExecuteWorker, '#perform' do
+RSpec.describe Integrations::ExecuteWorker, '#perform', feature_category: :integrations do
let_it_be(:integration) { create(:jira_integration) }
let(:worker) { described_class.new }
diff --git a/spec/workers/integrations/irker_worker_spec.rb b/spec/workers/integrations/irker_worker_spec.rb
index 3b7b9af72fd..9866415c99e 100644
--- a/spec/workers/integrations/irker_worker_spec.rb
+++ b/spec/workers/integrations/irker_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Integrations::IrkerWorker, '#perform' do
+RSpec.describe Integrations::IrkerWorker, '#perform', feature_category: :integrations do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:push_data) { HashWithIndifferentAccess.new(Gitlab::DataBuilder::Push.build_sample(project, user)) }
@@ -88,10 +88,11 @@ RSpec.describe Integrations::IrkerWorker, '#perform' do
context 'with new commits to existing branch' do
it 'sends a correct message with a compare url' do
- compare_url = Gitlab::Routing.url_helpers
- .project_compare_url(project,
- from: Commit.truncate_sha(push_data[:before]),
- to: Commit.truncate_sha(push_data[:after]))
+ compare_url = Gitlab::Routing.url_helpers.project_compare_url(
+ project,
+ from: Commit.truncate_sha(push_data[:before]),
+ to: Commit.truncate_sha(push_data[:after])
+ )
message = "pushed #{push_data['total_commits_count']} " \
"new commits to master: #{compare_url}"
@@ -104,7 +105,7 @@ RSpec.describe Integrations::IrkerWorker, '#perform' do
end
def wrap_message(text)
- message = "[#{project.path}] #{push_data['user_name']} #{text}"
+ message = "[#{project.name}] #{push_data['user_name']} #{text}"
to_send = { to: channels, privmsg: message }
Gitlab::Json.dump(to_send)
diff --git a/spec/workers/integrations/slack_event_worker_spec.rb b/spec/workers/integrations/slack_event_worker_spec.rb
new file mode 100644
index 00000000000..6754801a2bd
--- /dev/null
+++ b/spec/workers/integrations/slack_event_worker_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::SlackEventWorker, :clean_gitlab_redis_shared_state, feature_category: :integrations do
+ describe '.event?' do
+ subject { described_class.event?(event) }
+
+ context 'when event is known' do
+ let(:event) { 'app_home_opened' }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when event is not known' do
+ let(:event) { 'foo' }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ describe '#perform' do
+ let(:worker) { described_class.new }
+ let(:event) { 'app_home_opened' }
+ let(:service_class) { ::Integrations::SlackEvents::AppHomeOpenedService }
+
+ let(:args) do
+ {
+ slack_event: event,
+ params: params
+ }
+ end
+
+ let(:params) do
+ {
+ team_id: "T0123A456BC",
+ event: { user: "U0123ABCDEF" },
+ event_id: "Ev03SA75UJKB"
+ }
+ end
+
+ shared_examples 'logs extra metadata on done' do
+ specify do
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:slack_event, event)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:slack_user_id, 'U0123ABCDEF')
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:slack_workspace_id, 'T0123A456BC')
+
+ worker.perform(args)
+ end
+ end
+
+ it 'executes the correct service' do
+ expect_next_instance_of(service_class, params) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ worker.perform(args)
+ end
+
+ it_behaves_like 'logs extra metadata on done'
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [args] }
+ end
+
+ it 'ensures idempotency when called twice by only executing service once' do
+ expect_next_instances_of(service_class, 1, params) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ worker.perform(args)
+ worker.perform(args)
+ end
+
+ it 'executes service twice if service returned an error' do
+ expect_next_instances_of(service_class, 2, params) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.error(message: 'foo'))
+ end
+
+ worker.perform(args)
+ worker.perform(args)
+ end
+
+ it 'executes service twice if service raised an error' do
+ expect_next_instances_of(service_class, 2, params) do |service|
+ expect(service).to receive(:execute).and_raise(ArgumentError)
+ end
+
+ expect { worker.perform(args) }.to raise_error(ArgumentError)
+ expect { worker.perform(args) }.to raise_error(ArgumentError)
+ end
+
+ it 'executes service twice when event_id is different' do
+ second_params = params.dup
+ second_args = args.dup
+ second_params[:event_id] = 'foo'
+ second_args[:params] = second_params
+
+ expect_next_instances_of(service_class, 1, params) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ expect_next_instances_of(service_class, 1, second_params) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ worker.perform(args)
+ worker.perform(second_args)
+ end
+
+ context 'when event is not known' do
+ let(:event) { 'foo' }
+
+ it 'does not execute the service class' do
+ expect(service_class).not_to receive(:new)
+
+ worker.perform(args)
+ end
+
+ it 'logs an error' do
+ expect(Sidekiq.logger).to receive(:error).with({ message: 'Unknown slack_event', slack_event: event })
+
+ worker.perform(args)
+ end
+
+ it_behaves_like 'logs extra metadata on done'
+ end
+ end
+end
diff --git a/spec/workers/invalid_gpg_signature_update_worker_spec.rb b/spec/workers/invalid_gpg_signature_update_worker_spec.rb
index 25c48b55cbb..81a15e79579 100644
--- a/spec/workers/invalid_gpg_signature_update_worker_spec.rb
+++ b/spec/workers/invalid_gpg_signature_update_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe InvalidGpgSignatureUpdateWorker do
+RSpec.describe InvalidGpgSignatureUpdateWorker, feature_category: :source_code_management do
context 'when GpgKey is found' do
it 'calls NotificationService.new.run' do
gpg_key = create(:gpg_key)
diff --git a/spec/workers/issuable/label_links_destroy_worker_spec.rb b/spec/workers/issuable/label_links_destroy_worker_spec.rb
index a838f1c8017..d993c6cb22f 100644
--- a/spec/workers/issuable/label_links_destroy_worker_spec.rb
+++ b/spec/workers/issuable/label_links_destroy_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issuable::LabelLinksDestroyWorker do
+RSpec.describe Issuable::LabelLinksDestroyWorker, feature_category: :team_planning do
let(:job_args) { [1, 'MergeRequest'] }
let(:service) { double }
diff --git a/spec/workers/issuable_export_csv_worker_spec.rb b/spec/workers/issuable_export_csv_worker_spec.rb
index a5172d916b6..e54466b3641 100644
--- a/spec/workers/issuable_export_csv_worker_spec.rb
+++ b/spec/workers/issuable_export_csv_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IssuableExportCsvWorker do
+RSpec.describe IssuableExportCsvWorker, feature_category: :team_planning do
let(:user) { create(:user) }
let(:project) { create(:project, creator: user) }
let(:params) { {} }
@@ -22,6 +22,10 @@ RSpec.describe IssuableExportCsvWorker do
subject
end
+ it 'defines the loggable_arguments' do
+ expect(described_class.loggable_arguments).to match_array([0, 1, 2, 3])
+ end
+
it 'removes sort parameter' do
expect(IssuesFinder).to receive(:new).with(anything, hash_not_including(:sort)).and_call_original
@@ -50,6 +54,19 @@ RSpec.describe IssuableExportCsvWorker do
end
end
+ shared_examples 'export with selected fields' do
+ let(:selected_fields) { %w[Title Description'] }
+
+ it 'calls the export service with selected fields' do
+ params[:selected_fields] = selected_fields
+
+ expect(export_service)
+ .to receive(:new).with(anything, project, selected_fields).once.and_call_original
+
+ subject
+ end
+ end
+
context 'when issuable type is MergeRequest' do
let(:issuable_type) { :merge_request }
@@ -58,7 +75,7 @@ RSpec.describe IssuableExportCsvWorker do
end
it 'calls the MR export service' do
- expect(MergeRequests::ExportCsvService).to receive(:new).with(anything, project).once.and_call_original
+ expect(MergeRequests::ExportCsvService).to receive(:new).with(anything, project, []).once.and_call_original
subject
end
@@ -68,6 +85,34 @@ RSpec.describe IssuableExportCsvWorker do
subject
end
+
+ it_behaves_like 'export with selected fields' do
+ let(:export_service) { MergeRequests::ExportCsvService }
+ end
+ end
+
+ context 'for type WorkItem' do
+ let(:issuable_type) { :work_item }
+
+ it 'emails a CSV' do
+ expect { subject }.to change { ActionMailer::Base.deliveries.size }.by(1)
+ end
+
+ it 'calls the work item export service' do
+ expect(WorkItems::ExportCsvService).to receive(:new).with(anything, project, []).once.and_call_original
+
+ subject
+ end
+
+ it 'calls the WorkItemsFinder' do
+ expect(WorkItems::WorkItemsFinder).to receive(:new).once.and_call_original
+
+ subject
+ end
+
+ it_behaves_like 'export with selected fields' do
+ let(:export_service) { WorkItems::ExportCsvService }
+ end
end
context 'when issuable type is User' do
diff --git a/spec/workers/issuables/clear_groups_issue_counter_worker_spec.rb b/spec/workers/issuables/clear_groups_issue_counter_worker_spec.rb
index ac430f42e7a..e8f39388328 100644
--- a/spec/workers/issuables/clear_groups_issue_counter_worker_spec.rb
+++ b/spec/workers/issuables/clear_groups_issue_counter_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issuables::ClearGroupsIssueCounterWorker do
+RSpec.describe Issuables::ClearGroupsIssueCounterWorker, feature_category: :team_planning do
describe '#perform' do
let_it_be(:user) { create(:user) }
let_it_be(:parent_group) { create(:group) }
diff --git a/spec/workers/issue_due_scheduler_worker_spec.rb b/spec/workers/issue_due_scheduler_worker_spec.rb
index aecff4a3d93..8988dc86168 100644
--- a/spec/workers/issue_due_scheduler_worker_spec.rb
+++ b/spec/workers/issue_due_scheduler_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe IssueDueSchedulerWorker do
+RSpec.describe IssueDueSchedulerWorker, feature_category: :team_planning do
describe '#perform' do
it 'schedules one MailScheduler::IssueDueWorker per project with open issues due tomorrow' do
project1 = create(:project)
diff --git a/spec/workers/issues/close_worker_spec.rb b/spec/workers/issues/close_worker_spec.rb
index 3902618ae03..488be7eb067 100644
--- a/spec/workers/issues/close_worker_spec.rb
+++ b/spec/workers/issues/close_worker_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Issues::CloseWorker do
+RSpec.describe Issues::CloseWorker, feature_category: :team_planning do
describe "#perform" do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
diff --git a/spec/workers/issues/placement_worker_spec.rb b/spec/workers/issues/placement_worker_spec.rb
index 33fa0b31b72..710b3d07938 100644
--- a/spec/workers/issues/placement_worker_spec.rb
+++ b/spec/workers/issues/placement_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::PlacementWorker do
+RSpec.describe Issues::PlacementWorker, feature_category: :team_planning do
describe '#perform' do
let_it_be(:time) { Time.now.utc }
let_it_be(:group) { create(:group) }
diff --git a/spec/workers/issues/rebalancing_worker_spec.rb b/spec/workers/issues/rebalancing_worker_spec.rb
index e1c0b348a4f..e0dccf0c56e 100644
--- a/spec/workers/issues/rebalancing_worker_spec.rb
+++ b/spec/workers/issues/rebalancing_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::RebalancingWorker do
+RSpec.describe Issues::RebalancingWorker, feature_category: :team_planning do
describe '#perform' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
diff --git a/spec/workers/issues/reschedule_stuck_issue_rebalances_worker_spec.rb b/spec/workers/issues/reschedule_stuck_issue_rebalances_worker_spec.rb
index 6723c425f34..fa23ffe92f1 100644
--- a/spec/workers/issues/reschedule_stuck_issue_rebalances_worker_spec.rb
+++ b/spec/workers/issues/reschedule_stuck_issue_rebalances_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Issues::RescheduleStuckIssueRebalancesWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe Issues::RescheduleStuckIssueRebalancesWorker, :clean_gitlab_redis_shared_state, feature_category: :team_planning do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
diff --git a/spec/workers/jira_connect/forward_event_worker_spec.rb b/spec/workers/jira_connect/forward_event_worker_spec.rb
index d3db07b8cb4..1be59d846f2 100644
--- a/spec/workers/jira_connect/forward_event_worker_spec.rb
+++ b/spec/workers/jira_connect/forward_event_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraConnect::ForwardEventWorker do
+RSpec.describe JiraConnect::ForwardEventWorker, feature_category: :integrations do
describe '#perform' do
let!(:jira_connect_installation) { create(:jira_connect_installation, instance_url: self_managed_url, client_key: client_key, shared_secret: shared_secret) }
let(:base_path) { '/-/jira_connect' }
diff --git a/spec/workers/jira_connect/retry_request_worker_spec.rb b/spec/workers/jira_connect/retry_request_worker_spec.rb
index 7a93e5fe41d..e96a050da13 100644
--- a/spec/workers/jira_connect/retry_request_worker_spec.rb
+++ b/spec/workers/jira_connect/retry_request_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JiraConnect::RetryRequestWorker do
+RSpec.describe JiraConnect::RetryRequestWorker, feature_category: :integrations do
describe '#perform' do
let(:jwt) { 'some-jwt' }
let(:event_url) { 'https://example.com/somewhere' }
diff --git a/spec/workers/jira_connect/sync_branch_worker_spec.rb b/spec/workers/jira_connect/sync_branch_worker_spec.rb
index 349ccd10694..1c2661ad0e5 100644
--- a/spec/workers/jira_connect/sync_branch_worker_spec.rb
+++ b/spec/workers/jira_connect/sync_branch_worker_spec.rb
@@ -2,12 +2,10 @@
require 'spec_helper'
-RSpec.describe JiraConnect::SyncBranchWorker do
+RSpec.describe JiraConnect::SyncBranchWorker, feature_category: :integrations do
include AfterNextHelpers
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
describe '#perform' do
let_it_be(:group) { create(:group) }
diff --git a/spec/workers/jira_connect/sync_builds_worker_spec.rb b/spec/workers/jira_connect/sync_builds_worker_spec.rb
index 9be0cccae2b..8c694fe33bd 100644
--- a/spec/workers/jira_connect/sync_builds_worker_spec.rb
+++ b/spec/workers/jira_connect/sync_builds_worker_spec.rb
@@ -2,12 +2,10 @@
require 'spec_helper'
-RSpec.describe ::JiraConnect::SyncBuildsWorker do
+RSpec.describe ::JiraConnect::SyncBuildsWorker, feature_category: :integrations do
include AfterNextHelpers
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
describe '#perform' do
let_it_be(:pipeline) { create(:ci_pipeline) }
diff --git a/spec/workers/jira_connect/sync_deployments_worker_spec.rb b/spec/workers/jira_connect/sync_deployments_worker_spec.rb
index 86ba11ebe9c..39609f331d0 100644
--- a/spec/workers/jira_connect/sync_deployments_worker_spec.rb
+++ b/spec/workers/jira_connect/sync_deployments_worker_spec.rb
@@ -2,12 +2,10 @@
require 'spec_helper'
-RSpec.describe ::JiraConnect::SyncDeploymentsWorker do
+RSpec.describe ::JiraConnect::SyncDeploymentsWorker, feature_category: :integrations do
include AfterNextHelpers
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
describe '#perform' do
let_it_be(:deployment) { create(:deployment) }
diff --git a/spec/workers/jira_connect/sync_feature_flags_worker_spec.rb b/spec/workers/jira_connect/sync_feature_flags_worker_spec.rb
index 6763aefcbec..cc3867d26c1 100644
--- a/spec/workers/jira_connect/sync_feature_flags_worker_spec.rb
+++ b/spec/workers/jira_connect/sync_feature_flags_worker_spec.rb
@@ -2,12 +2,10 @@
require 'spec_helper'
-RSpec.describe ::JiraConnect::SyncFeatureFlagsWorker do
+RSpec.describe ::JiraConnect::SyncFeatureFlagsWorker, feature_category: :integrations do
include AfterNextHelpers
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
describe '#perform' do
let_it_be(:feature_flag) { create(:operations_feature_flag) }
diff --git a/spec/workers/jira_connect/sync_merge_request_worker_spec.rb b/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
index 65976566b22..6b6f7610f07 100644
--- a/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
+++ b/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
@@ -2,12 +2,10 @@
require 'spec_helper'
-RSpec.describe JiraConnect::SyncMergeRequestWorker do
+RSpec.describe JiraConnect::SyncMergeRequestWorker, feature_category: :integrations do
include AfterNextHelpers
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
describe '#perform' do
let_it_be(:group) { create(:group) }
@@ -24,7 +22,7 @@ RSpec.describe JiraConnect::SyncMergeRequestWorker do
it 'calls JiraConnect::SyncService#execute' do
expect_next(JiraConnect::SyncService).to receive(:execute)
- .with(merge_requests: [merge_request], update_sequence_id: update_sequence_id)
+ .with(merge_requests: [merge_request], branches: [have_attributes(name: 'master')], update_sequence_id: update_sequence_id)
perform
end
@@ -38,5 +36,32 @@ RSpec.describe JiraConnect::SyncMergeRequestWorker do
perform
end
end
+
+ shared_examples 'does not send any branch data' do
+ it 'calls JiraConnect::SyncService correctly with nil branches' do
+ expect_next(JiraConnect::SyncService).to receive(:execute)
+ .with(merge_requests: [merge_request], branches: nil, update_sequence_id: update_sequence_id)
+
+ perform
+ end
+ end
+
+ context 'when the merge request is closed' do
+ before do
+ merge_request.close!
+ end
+
+ it_behaves_like 'does not send any branch data'
+ end
+
+ context 'when source branch cannot be found' do
+ before do
+ allow_next_found_instance_of(MergeRequest) do |mr|
+ allow(mr).to receive(:source_branch).and_return('non-existant-branch')
+ end
+ end
+
+ it_behaves_like 'does not send any branch data'
+ end
end
end
diff --git a/spec/workers/jira_connect/sync_project_worker_spec.rb b/spec/workers/jira_connect/sync_project_worker_spec.rb
index d172bde2400..b617508bb3a 100644
--- a/spec/workers/jira_connect/sync_project_worker_spec.rb
+++ b/spec/workers/jira_connect/sync_project_worker_spec.rb
@@ -2,20 +2,19 @@
require 'spec_helper'
-RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do
+RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep, feature_category: :integrations do
include AfterNextHelpers
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
describe '#perform' do
- let_it_be(:project) { create_default(:project).freeze }
+ let_it_be(:project) { create_default(:project, :repository).freeze }
let!(:mr_with_jira_title) { create(:merge_request, :unique_branches, title: 'TEST-123') }
let!(:mr_with_jira_description) { create(:merge_request, :unique_branches, description: 'TEST-323') }
let!(:mr_with_other_title) { create(:merge_request, :unique_branches) }
let!(:jira_subscription) { create(:jira_connect_subscription, namespace: project.namespace) }
+ let(:jira_referencing_branch_name) { 'TEST-123_my-feature-branch' }
let(:jira_connect_sync_service) { JiraConnect::SyncService.new(project) }
let(:job_args) { [project.id, update_sequence_id] }
@@ -27,6 +26,7 @@ RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do
Atlassian::JiraConnect::Serializers::RepositoryEntity.represent(
project,
merge_requests: [mr_with_jira_description, mr_with_jira_title],
+ branches: [project.repository.find_branch(jira_referencing_branch_name)],
update_sequence_id: update_sequence_id
)
]
@@ -58,23 +58,57 @@ RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do
expect { perform(project.id, update_sequence_id) }.not_to exceed_query_limit(control_count)
end
- it 'sends the request with custom update_sequence_id' do
- allow_next(Atlassian::JiraConnect::Client).to receive(:post)
- .with(request_path, request_body)
+ context 'with branches to sync' do
+ context 'on a single branch' do
+ it 'sends the request with custom update_sequence_id' do
+ project.repository.create_branch(jira_referencing_branch_name)
- perform(project.id, update_sequence_id)
+ allow_next(Atlassian::JiraConnect::Client).to receive(:post)
+ .with(request_path, request_body)
+
+ perform(project.id, update_sequence_id)
+ end
+ end
+
+ context 'on multiple branches' do
+ after do
+ project.repository.rm_branch(project.owner, 'TEST-2_my-feature-branch')
+ project.repository.rm_branch(project.owner, 'TEST-3_my-feature-branch')
+ project.repository.rm_branch(project.owner, 'TEST-4_my-feature-branch')
+ end
+
+ it 'does not requests a lot from Gitaly', :request_store do
+ # NOTE: Gitaly N+1 calls when processing stats and diffs on commits.
+ # This should be reduced as we work on reducing Gitaly calls.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/354370
+ described_class.new.perform(project.id, update_sequence_id)
+
+ project.repository.create_branch('TEST-2_my-feature-branch')
+ project.repository.create_branch('TEST-3_my-feature-branch')
+ project.repository.create_branch('TEST-4_my-feature-branch')
+
+ expect { described_class.new.perform(project.id, update_sequence_id) }
+ .to change { Gitlab::GitalyClient.get_request_count }.by(13)
+ end
+ end
end
- context 'when the number of merge requests to sync is higher than the limit' do
+ context 'when the number of items to sync is higher than the limit' do
let!(:most_recent_merge_request) { create(:merge_request, :unique_branches, description: 'TEST-323', title: 'TEST-123') }
before do
- stub_const("#{described_class}::MERGE_REQUEST_LIMIT", 1)
+ stub_const("#{described_class}::MAX_RECORDS_LIMIT", 1)
+
+ project.repository.create_branch('TEST-321_new-branch')
end
- it 'syncs only the most recent merge requests within the limit' do
+ it 'syncs only the most recent merge requests and branches within the limit' do
expect(jira_connect_sync_service).to receive(:execute)
- .with(merge_requests: [most_recent_merge_request], update_sequence_id: update_sequence_id)
+ .with(
+ merge_requests: [most_recent_merge_request],
+ branches: [have_attributes(name: jira_referencing_branch_name)],
+ update_sequence_id: update_sequence_id
+ )
perform(project.id, update_sequence_id)
end
diff --git a/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb b/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb
index 09d58a1189e..e49b4707eb3 100644
--- a/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb
+++ b/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe LooseForeignKeys::CleanupWorker do
+RSpec.describe LooseForeignKeys::CleanupWorker, feature_category: :cell do
include MigrationsHelpers
using RSpec::Parameterized::TableSyntax
diff --git a/spec/workers/mail_scheduler/issue_due_worker_spec.rb b/spec/workers/mail_scheduler/issue_due_worker_spec.rb
index c03cc0bda61..9bdfcd7ca49 100644
--- a/spec/workers/mail_scheduler/issue_due_worker_spec.rb
+++ b/spec/workers/mail_scheduler/issue_due_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MailScheduler::IssueDueWorker do
+RSpec.describe MailScheduler::IssueDueWorker, feature_category: :team_planning do
describe '#perform' do
let(:worker) { described_class.new }
let(:project) { create(:project) }
diff --git a/spec/workers/mail_scheduler/notification_service_worker_spec.rb b/spec/workers/mail_scheduler/notification_service_worker_spec.rb
index 482d99a43c2..5ba7a0c555f 100644
--- a/spec/workers/mail_scheduler/notification_service_worker_spec.rb
+++ b/spec/workers/mail_scheduler/notification_service_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MailScheduler::NotificationServiceWorker do
+RSpec.describe MailScheduler::NotificationServiceWorker, feature_category: :team_planning do
let(:worker) { described_class.new }
let(:method) { 'new_key' }
diff --git a/spec/workers/member_invitation_reminder_emails_worker_spec.rb b/spec/workers/member_invitation_reminder_emails_worker_spec.rb
index bb4ff466584..4c6295285ea 100644
--- a/spec/workers/member_invitation_reminder_emails_worker_spec.rb
+++ b/spec/workers/member_invitation_reminder_emails_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MemberInvitationReminderEmailsWorker do
+RSpec.describe MemberInvitationReminderEmailsWorker, feature_category: :subgroups do
describe '#perform' do
subject { described_class.new.perform }
diff --git a/spec/workers/members_destroyer/unassign_issuables_worker_spec.rb b/spec/workers/members_destroyer/unassign_issuables_worker_spec.rb
index 2a325be1225..51fca67ae99 100644
--- a/spec/workers/members_destroyer/unassign_issuables_worker_spec.rb
+++ b/spec/workers/members_destroyer/unassign_issuables_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MembersDestroyer::UnassignIssuablesWorker do
+RSpec.describe MembersDestroyer::UnassignIssuablesWorker, feature_category: :user_management do
let_it_be(:group) { create(:group, :private) }
let_it_be(:user, reload: true) { create(:user) }
diff --git a/spec/workers/merge_request_cleanup_refs_worker_spec.rb b/spec/workers/merge_request_cleanup_refs_worker_spec.rb
index 1de927a81e4..a2df31037be 100644
--- a/spec/workers/merge_request_cleanup_refs_worker_spec.rb
+++ b/spec/workers/merge_request_cleanup_refs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequestCleanupRefsWorker do
+RSpec.describe MergeRequestCleanupRefsWorker, feature_category: :code_review_workflow do
let(:worker) { described_class.new }
describe '#perform_work' do
@@ -30,12 +30,12 @@ RSpec.describe MergeRequestCleanupRefsWorker do
expect(cleanup_schedule.completed_at).to be_nil
end
- context "and cleanup schedule has already failed #{described_class::FAILURE_THRESHOLD} times" do
- let(:failed_count) { described_class::FAILURE_THRESHOLD }
+ context "and cleanup schedule has already failed #{WebHooks::AutoDisabling::FAILURE_THRESHOLD} times" do
+ let(:failed_count) { WebHooks::AutoDisabling::FAILURE_THRESHOLD }
it 'marks the cleanup schedule as failed and track the failure' do
expect(cleanup_schedule.reload).to be_failed
- expect(cleanup_schedule.failed_count).to eq(described_class::FAILURE_THRESHOLD + 1)
+ expect(cleanup_schedule.failed_count).to eq(failed_count + 1)
expect(cleanup_schedule.completed_at).to be_nil
end
end
diff --git a/spec/workers/merge_request_mergeability_check_worker_spec.rb b/spec/workers/merge_request_mergeability_check_worker_spec.rb
index 32debcf9651..fd959803006 100644
--- a/spec/workers/merge_request_mergeability_check_worker_spec.rb
+++ b/spec/workers/merge_request_mergeability_check_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequestMergeabilityCheckWorker do
+RSpec.describe MergeRequestMergeabilityCheckWorker, feature_category: :code_review_workflow do
subject { described_class.new }
describe '#perform' do
diff --git a/spec/workers/merge_requests/close_issue_worker_spec.rb b/spec/workers/merge_requests/close_issue_worker_spec.rb
index 72fb3be7470..0facd34e790 100644
--- a/spec/workers/merge_requests/close_issue_worker_spec.rb
+++ b/spec/workers/merge_requests/close_issue_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::CloseIssueWorker do
+RSpec.describe MergeRequests::CloseIssueWorker, feature_category: :code_review_workflow do
subject(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/merge_requests/create_approval_event_worker_spec.rb b/spec/workers/merge_requests/create_approval_event_worker_spec.rb
index 8389949ecc9..a9a46420fd5 100644
--- a/spec/workers/merge_requests/create_approval_event_worker_spec.rb
+++ b/spec/workers/merge_requests/create_approval_event_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::CreateApprovalEventWorker do
+RSpec.describe MergeRequests::CreateApprovalEventWorker, feature_category: :code_review_workflow do
let!(:user) { create(:user) }
let!(:project) { create(:project) }
let!(:merge_request) { create(:merge_request, source_project: project) }
diff --git a/spec/workers/merge_requests/create_approval_note_worker_spec.rb b/spec/workers/merge_requests/create_approval_note_worker_spec.rb
index f58d38599fc..1a9e15c18f0 100644
--- a/spec/workers/merge_requests/create_approval_note_worker_spec.rb
+++ b/spec/workers/merge_requests/create_approval_note_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::CreateApprovalNoteWorker do
+RSpec.describe MergeRequests::CreateApprovalNoteWorker, feature_category: :code_review_workflow do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
diff --git a/spec/workers/merge_requests/delete_source_branch_worker_spec.rb b/spec/workers/merge_requests/delete_source_branch_worker_spec.rb
index e17ad02e272..57f8cfbfb83 100644
--- a/spec/workers/merge_requests/delete_source_branch_worker_spec.rb
+++ b/spec/workers/merge_requests/delete_source_branch_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::DeleteSourceBranchWorker do
+RSpec.describe MergeRequests::DeleteSourceBranchWorker, feature_category: :source_code_management do
let_it_be(:user) { create(:user) }
let_it_be(:merge_request) { create(:merge_request, author: user) }
@@ -12,8 +12,11 @@ RSpec.describe MergeRequests::DeleteSourceBranchWorker do
describe '#perform' do
before do
allow_next_instance_of(::Projects::DeleteBranchWorker) do |instance|
- allow(instance).to receive(:perform).with(merge_request.source_project.id, user.id,
- merge_request.source_branch)
+ allow(instance).to receive(:perform).with(
+ merge_request.source_project.id,
+ user.id,
+ merge_request.source_branch
+ )
end
end
@@ -36,8 +39,11 @@ RSpec.describe MergeRequests::DeleteSourceBranchWorker do
context 'with existing user and merge request' do
it 'calls delete branch worker' do
expect_next_instance_of(::Projects::DeleteBranchWorker) do |instance|
- expect(instance).to receive(:perform).with(merge_request.source_project.id, user.id,
- merge_request.source_branch)
+ expect(instance).to receive(:perform).with(
+ merge_request.source_project.id,
+ user.id,
+ merge_request.source_branch
+ )
end
worker.perform(merge_request.id, sha, user.id)
diff --git a/spec/workers/merge_requests/execute_approval_hooks_worker_spec.rb b/spec/workers/merge_requests/execute_approval_hooks_worker_spec.rb
index 0130ef63f50..13c97f8fe49 100644
--- a/spec/workers/merge_requests/execute_approval_hooks_worker_spec.rb
+++ b/spec/workers/merge_requests/execute_approval_hooks_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ExecuteApprovalHooksWorker do
+RSpec.describe MergeRequests::ExecuteApprovalHooksWorker, feature_category: :source_code_management do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
diff --git a/spec/workers/merge_requests/handle_assignees_change_worker_spec.rb b/spec/workers/merge_requests/handle_assignees_change_worker_spec.rb
index 4b45f3562d6..f776c6408ed 100644
--- a/spec/workers/merge_requests/handle_assignees_change_worker_spec.rb
+++ b/spec/workers/merge_requests/handle_assignees_change_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::HandleAssigneesChangeWorker do
+RSpec.describe MergeRequests::HandleAssigneesChangeWorker, feature_category: :code_review_workflow do
include AfterNextHelpers
let_it_be(:merge_request) { create(:merge_request) }
diff --git a/spec/workers/merge_requests/resolve_todos_after_approval_worker_spec.rb b/spec/workers/merge_requests/resolve_todos_after_approval_worker_spec.rb
index f8316a8ff05..39443d48ff6 100644
--- a/spec/workers/merge_requests/resolve_todos_after_approval_worker_spec.rb
+++ b/spec/workers/merge_requests/resolve_todos_after_approval_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ResolveTodosAfterApprovalWorker do
+RSpec.describe MergeRequests::ResolveTodosAfterApprovalWorker, feature_category: :code_review_workflow do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
diff --git a/spec/workers/merge_requests/resolve_todos_worker_spec.rb b/spec/workers/merge_requests/resolve_todos_worker_spec.rb
index 223b8b6803c..1164a1c1ddd 100644
--- a/spec/workers/merge_requests/resolve_todos_worker_spec.rb
+++ b/spec/workers/merge_requests/resolve_todos_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::ResolveTodosWorker do
+RSpec.describe MergeRequests::ResolveTodosWorker, feature_category: :code_review_workflow do
include AfterNextHelpers
let_it_be(:merge_request) { create(:merge_request) }
diff --git a/spec/workers/merge_requests/set_reviewer_reviewed_worker_spec.rb b/spec/workers/merge_requests/set_reviewer_reviewed_worker_spec.rb
new file mode 100644
index 00000000000..942cf8e87e9
--- /dev/null
+++ b/spec/workers/merge_requests/set_reviewer_reviewed_worker_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::SetReviewerReviewedWorker, feature_category: :source_code_management do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+
+ let(:data) { { current_user_id: user.id, merge_request_id: merge_request.id } }
+ let(:approved_event) { MergeRequests::ApprovedEvent.new(data: data) }
+
+ it_behaves_like 'subscribes to event' do
+ let(:event) { approved_event }
+ end
+
+ it 'calls MergeRequests::MarkReviewerReviewedService' do
+ expect_next_instance_of(
+ MergeRequests::MarkReviewerReviewedService,
+ project: project, current_user: user
+ ) do |service|
+ expect(service).to receive(:execute).with(merge_request)
+ end
+
+ consume_event(subscriber: described_class, event: approved_event)
+ end
+
+ shared_examples 'when object does not exist' do
+ it 'logs and does not call MergeRequests::MarkReviewerReviewedService' do
+ expect(Sidekiq.logger).to receive(:info).with(hash_including(log_payload))
+ expect(MergeRequests::MarkReviewerReviewedService).not_to receive(:new)
+
+ expect { consume_event(subscriber: described_class, event: approved_event) }
+ .not_to raise_exception
+ end
+ end
+
+ context 'when the user does not exist' do
+ before do
+ user.destroy!
+ end
+
+ it_behaves_like 'when object does not exist' do
+ let(:log_payload) { { 'message' => 'Current user not found.', 'current_user_id' => user.id } }
+ end
+ end
+
+ context 'when the merge request does not exist' do
+ before do
+ merge_request.destroy!
+ end
+
+ it_behaves_like 'when object does not exist' do
+ let(:log_payload) { { 'message' => 'Merge request not found.', 'merge_request_id' => merge_request.id } }
+ end
+ end
+end
diff --git a/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb b/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb
index 3574b8296a4..b65bd4eb1db 100644
--- a/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb
+++ b/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeRequests::UpdateHeadPipelineWorker do
+RSpec.describe MergeRequests::UpdateHeadPipelineWorker, feature_category: :code_review_workflow do
include ProjectForksHelper
let_it_be(:project) { create(:project, :repository) }
@@ -74,9 +74,12 @@ RSpec.describe MergeRequests::UpdateHeadPipelineWorker do
context 'when there is no pipeline for source branch' do
it "does not update merge request head pipeline" do
- merge_request = create(:merge_request, source_branch: 'feature',
- target_branch: "branch_1",
- source_project: project)
+ merge_request = create(
+ :merge_request,
+ source_branch: 'feature',
+ target_branch: "branch_1",
+ source_project: project
+ )
subject
@@ -96,10 +99,13 @@ RSpec.describe MergeRequests::UpdateHeadPipelineWorker do
end
it 'updates head pipeline for merge request' do
- merge_request = create(:merge_request, source_branch: 'feature',
- target_branch: "master",
- source_project: project,
- target_project: target_project)
+ merge_request = create(
+ :merge_request,
+ source_branch: 'feature',
+ target_branch: "master",
+ source_project: project,
+ target_project: target_project
+ )
subject
@@ -109,9 +115,12 @@ RSpec.describe MergeRequests::UpdateHeadPipelineWorker do
context 'when the pipeline is not the latest for the branch' do
it 'does not update merge request head pipeline' do
- merge_request = create(:merge_request, source_branch: 'master',
- target_branch: "branch_1",
- source_project: project)
+ merge_request = create(
+ :merge_request,
+ source_branch: 'master',
+ target_branch: "branch_1",
+ source_project: project
+ )
create(:ci_pipeline, project: pipeline.project, ref: pipeline.ref)
@@ -127,9 +136,12 @@ RSpec.describe MergeRequests::UpdateHeadPipelineWorker do
end
it 'updates merge request head pipeline reference' do
- merge_request = create(:merge_request, source_branch: 'master',
- target_branch: 'feature',
- source_project: project)
+ merge_request = create(
+ :merge_request,
+ source_branch: 'master',
+ target_branch: 'feature',
+ source_project: project
+ )
subject
diff --git a/spec/workers/merge_worker_spec.rb b/spec/workers/merge_worker_spec.rb
index 0268bc2388f..9c6a6564df6 100644
--- a/spec/workers/merge_worker_spec.rb
+++ b/spec/workers/merge_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MergeWorker do
+RSpec.describe MergeWorker, feature_category: :source_code_management do
describe "remove source branch" do
let!(:merge_request) { create(:merge_request, source_branch: "markdown") }
let!(:source_project) { merge_request.source_project }
diff --git a/spec/workers/metrics/dashboard/prune_old_annotations_worker_spec.rb b/spec/workers/metrics/dashboard/prune_old_annotations_worker_spec.rb
index 491ea64cff1..c7e2bbc2ad9 100644
--- a/spec/workers/metrics/dashboard/prune_old_annotations_worker_spec.rb
+++ b/spec/workers/metrics/dashboard/prune_old_annotations_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::PruneOldAnnotationsWorker do
+RSpec.describe Metrics::Dashboard::PruneOldAnnotationsWorker, feature_category: :metrics do
let_it_be(:now) { DateTime.parse('2020-06-02T00:12:00Z') }
let_it_be(:two_weeks_old_annotation) { create(:metrics_dashboard_annotation, starting_at: now.advance(weeks: -2)) }
let_it_be(:one_day_old_annotation) { create(:metrics_dashboard_annotation, starting_at: now.advance(days: -1)) }
diff --git a/spec/workers/metrics/dashboard/schedule_annotations_prune_worker_spec.rb b/spec/workers/metrics/dashboard/schedule_annotations_prune_worker_spec.rb
index e0a5a8fd448..75866a4eca2 100644
--- a/spec/workers/metrics/dashboard/schedule_annotations_prune_worker_spec.rb
+++ b/spec/workers/metrics/dashboard/schedule_annotations_prune_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::ScheduleAnnotationsPruneWorker do
+RSpec.describe Metrics::Dashboard::ScheduleAnnotationsPruneWorker, feature_category: :metrics do
describe '#perform' do
it 'schedules annotations prune job with default cut off date' do
expect(Metrics::Dashboard::PruneOldAnnotationsWorker).to receive(:perform_async)
diff --git a/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb b/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb
index 4b670a753e7..f7d67b2064e 100644
--- a/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb
+++ b/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Metrics::Dashboard::SyncDashboardsWorker do
+RSpec.describe Metrics::Dashboard::SyncDashboardsWorker, feature_category: :metrics do
include MetricsDashboardHelpers
subject(:worker) { described_class.new }
diff --git a/spec/workers/metrics/global_metrics_update_worker_spec.rb b/spec/workers/metrics/global_metrics_update_worker_spec.rb
new file mode 100644
index 00000000000..d5bfbcc928a
--- /dev/null
+++ b/spec/workers/metrics/global_metrics_update_worker_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Metrics::GlobalMetricsUpdateWorker, feature_category: :metrics do
+ subject { described_class.new }
+
+ describe '#perform' do
+ let(:service) { ::Metrics::GlobalMetricsUpdateService.new }
+
+ it 'delegates to ::Metrics::GlobalMetricsUpdateService' do
+ expect(::Metrics::GlobalMetricsUpdateService).to receive(:new).and_return(service)
+ expect(service).to receive(:execute)
+
+ subject.perform
+ end
+
+ context 'for an idempotent worker' do
+ include_examples 'an idempotent worker' do
+ it 'exports metrics' do
+ allow(Gitlab).to receive(:maintenance_mode?).and_return(true).at_least(1).time
+
+ perform_multiple
+
+ expect(service.maintenance_mode_metric.get).to eq(1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/migrate_external_diffs_worker_spec.rb b/spec/workers/migrate_external_diffs_worker_spec.rb
index 36669b4e694..cb5e3ff3651 100644
--- a/spec/workers/migrate_external_diffs_worker_spec.rb
+++ b/spec/workers/migrate_external_diffs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe MigrateExternalDiffsWorker do
+RSpec.describe MigrateExternalDiffsWorker, feature_category: :code_review_workflow do
let(:worker) { described_class.new }
let(:diff) { create(:merge_request).merge_request_diff }
diff --git a/spec/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker_spec.rb b/spec/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker_spec.rb
new file mode 100644
index 00000000000..5e1742b3298
--- /dev/null
+++ b/spec/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker, feature_category: :mlops do
+ describe '.handle_event' do
+ let_it_be(:candidate) { create(:ml_candidates) }
+ let_it_be(:package) do
+ create(
+ :generic_package,
+ project: candidate.project,
+ name: candidate.package_name,
+ version: candidate.package_version
+ )
+ end
+
+ let(:package_version) { package.version }
+ let(:project_id) { package.project_id }
+ let(:data) do
+ {
+ project_id: project_id,
+ id: package.id,
+ name: package.name,
+ version: package_version,
+ package_type: package.package_type
+ }
+ end
+
+ let(:package_created_event) { Packages::PackageCreatedEvent.new(data: data) }
+
+ it_behaves_like 'subscribes to event' do
+ let(:event) { package_created_event }
+ end
+
+ context 'when package name matches ml_experiment_{id}' do
+ before do
+ consume_event(subscriber: described_class, event: package_created_event)
+ end
+
+ context 'when candidate with iid exists' do
+ it 'associates candidate to package' do
+ expect(candidate.reload.package).to eq(package)
+ end
+ end
+
+ context 'when no candidate with iid exists' do
+ let(:package_version) { non_existing_record_iid.to_s }
+
+ it 'does not associate candidate' do
+ expect(candidate.reload.package).to be_nil
+ end
+ end
+
+ context 'when candidate with iid exists but in a different project' do
+ let(:project_id) { non_existing_record_id }
+
+ it 'does not associate candidate' do
+ expect(candidate.reload.package).to be_nil
+ end
+ end
+ end
+
+ context 'when package is deleted before event is called' do
+ before do
+ package.delete
+ end
+
+ it 'does not associate candidate' do
+ consume_event(subscriber: described_class, event: package_created_event)
+
+ expect(candidate.reload.package_id).to be_nil
+ end
+ end
+ end
+
+ describe '#handles_event?' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:event) do
+ Packages::PackageCreatedEvent.new(
+ data: {
+ project_id: 1,
+ id: 1,
+ name: package_name,
+ version: '',
+ package_type: package_type
+ }
+ )
+ end
+
+ subject { described_class.handles_event?(event) }
+
+ where(:package_name, :package_type, :handles_event) do
+ 'ml_experiment_1234' | 'generic' | true
+ 'ml_experiment_1234' | 'maven' | false
+ '1234' | 'generic' | false
+ 'ml_experiment_' | 'generic' | false
+ 'blah' | 'generic' | false
+ end
+
+ with_them do
+ it { is_expected.to eq(handles_event) }
+ end
+ end
+end
diff --git a/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb b/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb
index 2e7b6356692..237b5081bb1 100644
--- a/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb
+++ b/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Namespaces::InProductMarketingEmailsWorker, '#perform', unless: Gitlab.ee? do
+RSpec.describe Namespaces::InProductMarketingEmailsWorker, '#perform', unless: Gitlab.ee?, feature_category: :experimentation_activation do
# Running this in EE would call the overridden method, which can't be tested in CE.
# The EE code is covered in a separate EE spec.
diff --git a/spec/workers/namespaces/process_sync_events_worker_spec.rb b/spec/workers/namespaces/process_sync_events_worker_spec.rb
index 9f389089609..a5afb5d5cbf 100644
--- a/spec/workers/namespaces/process_sync_events_worker_spec.rb
+++ b/spec/workers/namespaces/process_sync_events_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Namespaces::ProcessSyncEventsWorker do
+RSpec.describe Namespaces::ProcessSyncEventsWorker, feature_category: :cell do
let!(:group1) { create(:group) }
let!(:group2) { create(:group) }
let!(:group3) { create(:group) }
@@ -20,8 +20,8 @@ RSpec.describe Namespaces::ProcessSyncEventsWorker do
expect(described_class.get_deduplicate_strategy).to eq(:until_executed)
end
- it 'has an option to reschedule once if deduplicated' do
- expect(described_class.get_deduplication_options).to include({ if_deduplicated: :reschedule_once })
+ it 'has the option to reschedule once if deduplicated and a TTL of 1 minute' do
+ expect(described_class.get_deduplication_options).to include({ if_deduplicated: :reschedule_once, ttl: 1.minute })
end
it 'expect the job to enqueue itself again if there was more items to be processed', :sidekiq_inline do
diff --git a/spec/workers/namespaces/prune_aggregation_schedules_worker_spec.rb b/spec/workers/namespaces/prune_aggregation_schedules_worker_spec.rb
index d8c60932d92..9f0ea366726 100644
--- a/spec/workers/namespaces/prune_aggregation_schedules_worker_spec.rb
+++ b/spec/workers/namespaces/prune_aggregation_schedules_worker_spec.rb
@@ -2,11 +2,10 @@
require 'spec_helper'
-RSpec.describe Namespaces::PruneAggregationSchedulesWorker, '#perform', :clean_gitlab_redis_shared_state do
+RSpec.describe Namespaces::PruneAggregationSchedulesWorker, '#perform', :clean_gitlab_redis_shared_state, feature_category: :source_code_management do
include ExclusiveLeaseHelpers
let(:namespaces) { create_list(:namespace, 5, :with_aggregation_schedule) }
- let(:timeout) { Namespace::AggregationSchedule.default_lease_timeout }
subject(:worker) { described_class.new }
@@ -19,7 +18,7 @@ RSpec.describe Namespaces::PruneAggregationSchedulesWorker, '#perform', :clean_g
namespaces.each do |namespace|
lease_key = "namespace:namespaces_root_statistics:#{namespace.id}"
- stub_exclusive_lease(lease_key, timeout: timeout)
+ stub_exclusive_lease(lease_key, timeout: namespace.aggregation_schedule.default_lease_timeout)
end
end
diff --git a/spec/workers/namespaces/root_statistics_worker_spec.rb b/spec/workers/namespaces/root_statistics_worker_spec.rb
index e047c94816f..2224d94cd9d 100644
--- a/spec/workers/namespaces/root_statistics_worker_spec.rb
+++ b/spec/workers/namespaces/root_statistics_worker_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe Namespaces::RootStatisticsWorker, '#perform' do
- let(:group) { create(:group, :with_aggregation_schedule) }
+RSpec.describe Namespaces::RootStatisticsWorker, '#perform', feature_category: :source_code_management do
+ let_it_be(:group) { create(:group, :with_aggregation_schedule) }
subject(:worker) { described_class.new }
@@ -79,10 +79,6 @@ RSpec.describe Namespaces::RootStatisticsWorker, '#perform' do
let(:job_args) { [group.id] }
it 'deletes one aggregation schedule' do
- # Make sure the group and it's aggregation schedule are created before
- # counting
- group
-
expect { worker.perform(*job_args) }
.to change { Namespace::AggregationSchedule.count }.by(-1)
expect { worker.perform(*job_args) }
@@ -90,9 +86,7 @@ RSpec.describe Namespaces::RootStatisticsWorker, '#perform' do
end
end
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :sticky
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :sticky
it 'has the `until_executed` deduplicate strategy' do
expect(described_class.get_deduplicate_strategy).to eq(:until_executed)
diff --git a/spec/workers/namespaces/schedule_aggregation_worker_spec.rb b/spec/workers/namespaces/schedule_aggregation_worker_spec.rb
index 62f9be501cc..7d0746a4bb0 100644
--- a/spec/workers/namespaces/schedule_aggregation_worker_spec.rb
+++ b/spec/workers/namespaces/schedule_aggregation_worker_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe Namespaces::ScheduleAggregationWorker, '#perform', :clean_gitlab_redis_shared_state do
- let(:group) { create(:group) }
+RSpec.describe Namespaces::ScheduleAggregationWorker, '#perform', :clean_gitlab_redis_shared_state, feature_category: :source_code_management do
+ let_it_be(:group) { create(:group) }
subject(:worker) { described_class.new }
diff --git a/spec/workers/namespaces/update_root_statistics_worker_spec.rb b/spec/workers/namespaces/update_root_statistics_worker_spec.rb
index f2f633a39ca..85fd68094ce 100644
--- a/spec/workers/namespaces/update_root_statistics_worker_spec.rb
+++ b/spec/workers/namespaces/update_root_statistics_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Namespaces::UpdateRootStatisticsWorker do
+RSpec.describe Namespaces::UpdateRootStatisticsWorker, feature_category: :source_code_management do
let(:namespace_id) { 123 }
let(:event) do
diff --git a/spec/workers/new_issue_worker_spec.rb b/spec/workers/new_issue_worker_spec.rb
index b9053b10419..540296374ef 100644
--- a/spec/workers/new_issue_worker_spec.rb
+++ b/spec/workers/new_issue_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe NewIssueWorker do
+RSpec.describe NewIssueWorker, feature_category: :team_planning do
include AfterNextHelpers
describe '#perform' do
diff --git a/spec/workers/new_merge_request_worker_spec.rb b/spec/workers/new_merge_request_worker_spec.rb
index a8e1c3f4bf1..58f6792f9a0 100644
--- a/spec/workers/new_merge_request_worker_spec.rb
+++ b/spec/workers/new_merge_request_worker_spec.rb
@@ -107,18 +107,6 @@ RSpec.describe NewMergeRequestWorker, feature_category: :code_review_workflow do
stub_feature_flags(add_prepared_state_to_mr: true)
end
- context 'when the merge request is prepared' do
- before do
- merge_request.update!(prepared_at: Time.current)
- end
-
- it 'does not call the create service' do
- expect(MergeRequests::AfterCreateService).not_to receive(:new)
-
- worker.perform(merge_request.id, user.id)
- end
- end
-
context 'when the merge request is not prepared' do
it 'calls the create service' do
expect_next_instance_of(MergeRequests::AfterCreateService, project: merge_request.target_project, current_user: user) do |service|
diff --git a/spec/workers/new_note_worker_spec.rb b/spec/workers/new_note_worker_spec.rb
index 7ba3fe94254..651b5742854 100644
--- a/spec/workers/new_note_worker_spec.rb
+++ b/spec/workers/new_note_worker_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe NewNoteWorker do
+RSpec.describe NewNoteWorker, feature_category: :team_planning do
context 'when Note found' do
let(:note) { create(:note) }
diff --git a/spec/workers/object_pool/create_worker_spec.rb b/spec/workers/object_pool/create_worker_spec.rb
index 4ec409bdf47..573cb3413f5 100644
--- a/spec/workers/object_pool/create_worker_spec.rb
+++ b/spec/workers/object_pool/create_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ObjectPool::CreateWorker do
+RSpec.describe ObjectPool::CreateWorker, feature_category: :shared do
let(:pool) { create(:pool_repository, :scheduled) }
subject { described_class.new }
diff --git a/spec/workers/object_pool/destroy_worker_spec.rb b/spec/workers/object_pool/destroy_worker_spec.rb
index 130a666a42e..7db3404ed36 100644
--- a/spec/workers/object_pool/destroy_worker_spec.rb
+++ b/spec/workers/object_pool/destroy_worker_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.describe ObjectPool::DestroyWorker do
+RSpec.describe ObjectPool::DestroyWorker, feature_category: :shared do
describe '#perform' do
context 'when no pool is in the database' do
it "doesn't raise an error" do
@@ -16,9 +16,13 @@ RSpec.describe ObjectPool::DestroyWorker do
subject { described_class.new }
it 'requests Gitaly to remove the object pool' do
- expect(Gitlab::GitalyClient).to receive(:call)
- .with(pool.shard_name, :object_pool_service, :delete_object_pool,
- Object, timeout: Gitlab::GitalyClient.long_timeout)
+ expect(Gitlab::GitalyClient).to receive(:call).with(
+ pool.shard_name,
+ :object_pool_service,
+ :delete_object_pool,
+ Object,
+ timeout: Gitlab::GitalyClient.long_timeout
+ )
subject.perform(pool.id)
end
diff --git a/spec/workers/object_pool/join_worker_spec.rb b/spec/workers/object_pool/join_worker_spec.rb
index 335c45e14e0..e0173cad53b 100644
--- a/spec/workers/object_pool/join_worker_spec.rb
+++ b/spec/workers/object_pool/join_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ObjectPool::JoinWorker do
+RSpec.describe ObjectPool::JoinWorker, feature_category: :shared do
let(:pool) { create(:pool_repository, :ready) }
let(:project) { pool.source_project }
let(:repository) { project.repository }
diff --git a/spec/workers/onboarding/issue_created_worker_spec.rb b/spec/workers/onboarding/issue_created_worker_spec.rb
index 70a0156d444..d12f51ceff6 100644
--- a/spec/workers/onboarding/issue_created_worker_spec.rb
+++ b/spec/workers/onboarding/issue_created_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Onboarding::IssueCreatedWorker, '#perform' do
+RSpec.describe Onboarding::IssueCreatedWorker, '#perform', feature_category: :onboarding do
let_it_be(:issue) { create(:issue) }
let(:namespace) { issue.project.namespace }
diff --git a/spec/workers/onboarding/pipeline_created_worker_spec.rb b/spec/workers/onboarding/pipeline_created_worker_spec.rb
index 75bdea28eef..e6dc0f9b689 100644
--- a/spec/workers/onboarding/pipeline_created_worker_spec.rb
+++ b/spec/workers/onboarding/pipeline_created_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Onboarding::PipelineCreatedWorker, '#perform' do
+RSpec.describe Onboarding::PipelineCreatedWorker, '#perform', feature_category: :onboarding do
let_it_be(:ci_pipeline) { create(:ci_pipeline) }
it_behaves_like 'records an onboarding progress action', :pipeline_created do
diff --git a/spec/workers/onboarding/progress_worker_spec.rb b/spec/workers/onboarding/progress_worker_spec.rb
index bbf4875069e..da760c23367 100644
--- a/spec/workers/onboarding/progress_worker_spec.rb
+++ b/spec/workers/onboarding/progress_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Onboarding::ProgressWorker, '#perform' do
+RSpec.describe Onboarding::ProgressWorker, '#perform', feature_category: :onboarding do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:action) { 'git_pull' }
diff --git a/spec/workers/onboarding/user_added_worker_spec.rb b/spec/workers/onboarding/user_added_worker_spec.rb
index 6dbd875c93b..d32bd5ee19c 100644
--- a/spec/workers/onboarding/user_added_worker_spec.rb
+++ b/spec/workers/onboarding/user_added_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Onboarding::UserAddedWorker, '#perform' do
+RSpec.describe Onboarding::UserAddedWorker, '#perform', feature_category: :onboarding do
let_it_be(:namespace) { create(:group) }
subject { described_class.new.perform(namespace.id) }
diff --git a/spec/workers/packages/cleanup/delete_orphaned_dependencies_worker_spec.rb b/spec/workers/packages/cleanup/delete_orphaned_dependencies_worker_spec.rb
new file mode 100644
index 00000000000..ffa7767075e
--- /dev/null
+++ b/spec/workers/packages/cleanup/delete_orphaned_dependencies_worker_spec.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Cleanup::DeleteOrphanedDependenciesWorker, feature_category: :package_registry do
+ let(:worker) { described_class.new }
+
+ it { is_expected.to include_module(CronjobQueue) }
+ it { expect(described_class.idempotent?).to be_truthy }
+
+ describe '#perform', :clean_gitlab_redis_shared_state do
+ let_it_be(:orphaned_dependencies) { create_list(:packages_dependency, 2) }
+ let_it_be(:linked_dependency) do
+ create(:packages_dependency).tap do |dependency|
+ create(:packages_dependency_link, dependency: dependency)
+ end
+ end
+
+ subject { worker.perform }
+
+ it 'deletes only orphaned dependencies' do
+ expect { subject }.to change { Packages::Dependency.count }.by(-2)
+ expect(Packages::Dependency.all).to contain_exactly(linked_dependency)
+ end
+
+ it 'executes 3 queries' do
+ queries = ActiveRecord::QueryRecorder.new { subject }
+
+ # 1. (each_batch lower bound) SELECT packages_dependencies.id FROM packages_dependencies
+ # WHERE packages_dependencies.id >= 0
+ # ORDER BY packages_dependencies.id ASC LIMIT 1;
+ # 2. (each_batch upper bound) SELECT packages_dependencies.id FROM packages_dependencies
+ # WHERE packages_dependencies.id >= 0
+ # AND packages_dependencies.id >= 1 ORDER BY packages_dependencies.id ASC
+ # LIMIT 1 OFFSET 100;
+ # 3. (delete query) DELETE FROM packages_dependencies WHERE packages_dependencies.id >= 0
+ # AND packages_dependencies.id >= 1
+ # AND (NOT EXISTS (
+ # SELECT 1 FROM packages_dependency_links
+ # WHERE packages_dependency_links.dependency_id = packages_dependencies.id
+ # ));
+ expect(queries.count).to eq(3)
+ end
+
+ context 'when the worker is running for more than the max time' do
+ before do
+ allow(worker).to receive(:over_time?).and_return(true)
+ end
+
+ it 'sets the last processed dependency id in redis cache' do
+ subject
+
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.get('last_processed_packages_dependency_id').to_i).to eq(Packages::Dependency.last.id)
+ end
+ end
+ end
+
+ context 'when the worker reaches the maximum number of batches' do
+ before do
+ stub_const('Packages::Cleanup::DeleteOrphanedDependenciesWorker::MAX_BATCHES', 1)
+ end
+
+ it 'iterates over only 1 batch' do
+ expect { subject }.to change { Packages::Dependency.count }.by(-2)
+ end
+
+ it 'sets the last processed dependency id in redis cache' do
+ subject
+
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.get('last_processed_packages_dependency_id').to_i).to eq(Packages::Dependency.last.id)
+ end
+ end
+ end
+
+ context 'when the worker finishes processing in less than the max time' do
+ before do
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set('last_processed_packages_dependency_id', orphaned_dependencies.first.id)
+ end
+ end
+
+ it 'clears the last processed last_processed_packages_dependency_id from redis cache' do
+ Gitlab::Redis::SharedState.with do |redis|
+ expect { subject }
+ .to change { redis.get('last_processed_packages_dependency_id') }.to(nil)
+ end
+ end
+ end
+
+ context 'when logging extra metadata' do
+ before do
+ stub_const('Packages::Cleanup::DeleteOrphanedDependenciesWorker::MAX_BATCHES', 1)
+ end
+
+ it 'logs the last proccessed id & the deleted rows count', :aggregate_failures do
+ expect(worker).to receive(:log_extra_metadata_on_done).with(
+ :last_processed_packages_dependency_id,
+ Packages::Dependency.last.id
+ )
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:deleted_rows_count, 2)
+
+ subject
+ end
+ end
+
+ context 'when the FF is disabled' do
+ before do
+ stub_feature_flags(packages_delete_orphaned_dependencies_worker: false)
+ end
+
+ it 'does not execute the worker' do
+ expect { subject }.not_to change { Packages::Dependency.count }
+ end
+ end
+ end
+end
diff --git a/spec/workers/packages/cleanup/execute_policy_worker_spec.rb b/spec/workers/packages/cleanup/execute_policy_worker_spec.rb
index 6325a82ed3d..fc3ed1f3a05 100644
--- a/spec/workers/packages/cleanup/execute_policy_worker_spec.rb
+++ b/spec/workers/packages/cleanup/execute_policy_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Cleanup::ExecutePolicyWorker do
+RSpec.describe Packages::Cleanup::ExecutePolicyWorker, feature_category: :package_registry do
let(:worker) { described_class.new }
describe '#perform_work' do
diff --git a/spec/workers/packages/cleanup_package_file_worker_spec.rb b/spec/workers/packages/cleanup_package_file_worker_spec.rb
index 95cf65c18c5..6e42565abbc 100644
--- a/spec/workers/packages/cleanup_package_file_worker_spec.rb
+++ b/spec/workers/packages/cleanup_package_file_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::CleanupPackageFileWorker do
+RSpec.describe Packages::CleanupPackageFileWorker, feature_category: :package_registry do
let_it_be_with_reload(:package) { create(:package) }
let(:worker) { described_class.new }
diff --git a/spec/workers/packages/cleanup_package_registry_worker_spec.rb b/spec/workers/packages/cleanup_package_registry_worker_spec.rb
index e12f2198f66..f70103070ef 100644
--- a/spec/workers/packages/cleanup_package_registry_worker_spec.rb
+++ b/spec/workers/packages/cleanup_package_registry_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::CleanupPackageRegistryWorker do
+RSpec.describe Packages::CleanupPackageRegistryWorker, feature_category: :package_registry do
describe '#perform' do
let_it_be_with_reload(:package_files) { create_list(:package_file, 2, :pending_destruction) }
let_it_be(:policy) { create(:packages_cleanup_policy, :runnable) }
diff --git a/spec/workers/packages/composer/cache_cleanup_worker_spec.rb b/spec/workers/packages/composer/cache_cleanup_worker_spec.rb
index 39eac4e4ae1..67d4ce36437 100644
--- a/spec/workers/packages/composer/cache_cleanup_worker_spec.rb
+++ b/spec/workers/packages/composer/cache_cleanup_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Composer::CacheCleanupWorker, type: :worker do
+RSpec.describe Packages::Composer::CacheCleanupWorker, type: :worker, feature_category: :package_registry do
describe '#perform' do
let_it_be(:group) { create(:group) }
diff --git a/spec/workers/packages/composer/cache_update_worker_spec.rb b/spec/workers/packages/composer/cache_update_worker_spec.rb
index 6c17d49e986..cc3047895d4 100644
--- a/spec/workers/packages/composer/cache_update_worker_spec.rb
+++ b/spec/workers/packages/composer/cache_update_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Composer::CacheUpdateWorker, type: :worker do
+RSpec.describe Packages::Composer::CacheUpdateWorker, type: :worker, feature_category: :package_registry do
describe '#perform' do
let_it_be(:package_name) { 'sample-project' }
let_it_be(:json) { { 'name' => package_name } }
diff --git a/spec/workers/packages/debian/cleanup_dangling_package_files_worker_spec.rb b/spec/workers/packages/debian/cleanup_dangling_package_files_worker_spec.rb
new file mode 100644
index 00000000000..b6373dbda95
--- /dev/null
+++ b/spec/workers/packages/debian/cleanup_dangling_package_files_worker_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Debian::CleanupDanglingPackageFilesWorker, type: :worker,
+ feature_category: :package_registry do
+ describe '#perform' do
+ let_it_be_with_reload(:distribution) { create(:debian_project_distribution, :with_file, codename: 'unstable') }
+ let_it_be(:incoming) { create(:debian_incoming, project: distribution.project) }
+ let_it_be(:package) { create(:debian_package, project: distribution.project) }
+
+ subject { described_class.new.perform }
+
+ context 'when debian_packages flag is disabled' do
+ before do
+ stub_feature_flags(debian_packages: false)
+ end
+
+ it 'does nothing' do
+ expect(::Packages::MarkPackageFilesForDestructionService).not_to receive(:new)
+
+ subject
+ end
+ end
+
+ context 'with mocked service returning success' do
+ it 'calls MarkPackageFilesForDestructionService' do
+ expect(Gitlab::ErrorTracking).not_to receive(:log_exception)
+ expect_next_instance_of(::Packages::MarkPackageFilesForDestructionService) do |service|
+ expect(service).to receive(:execute)
+ .with(batch_deadline: an_instance_of(ActiveSupport::TimeWithZone))
+ .and_return(ServiceResponse.success)
+ end
+
+ subject
+ end
+ end
+
+ context 'with mocked service returning error' do
+ it 'ignore error' do
+ expect(Gitlab::ErrorTracking).not_to receive(:log_exception)
+ expect_next_instance_of(::Packages::MarkPackageFilesForDestructionService) do |service|
+ expect(service).to receive(:execute)
+ .with(batch_deadline: an_instance_of(ActiveSupport::TimeWithZone))
+ .and_return(ServiceResponse.error(message: 'Custom error'))
+ end
+
+ subject
+ end
+ end
+
+ context 'when the service raises an error' do
+ it 'logs exception' do
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
+ instance_of(ArgumentError)
+ )
+ expect_next_instance_of(::Packages::MarkPackageFilesForDestructionService) do |service|
+ expect(service).to receive(:execute)
+ .and_raise(ArgumentError, 'foobar')
+ end
+
+ subject
+ end
+ end
+
+ context 'with valid parameters' do
+ it_behaves_like 'an idempotent worker' do
+ before do
+ incoming.package_files.first.debian_file_metadatum.update! updated_at: 1.day.ago
+ incoming.package_files.second.update! updated_at: 1.day.ago, status: :error
+ end
+
+ it 'mark dangling package files as pending destruction', :aggregate_failures do
+ expect(Gitlab::ErrorTracking).not_to receive(:log_exception)
+
+ # Using subject inside this block will process the job multiple times
+ expect { subject }
+ .to not_change { distribution.project.package_files.count }
+ .and change { distribution.project.package_files.pending_destruction.count }.from(0).to(1)
+ .and not_change { distribution.project.packages.count }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/packages/debian/generate_distribution_worker_spec.rb b/spec/workers/packages/debian/generate_distribution_worker_spec.rb
index c4e974ec8eb..acdfcc5275f 100644
--- a/spec/workers/packages/debian/generate_distribution_worker_spec.rb
+++ b/spec/workers/packages/debian/generate_distribution_worker_spec.rb
@@ -4,13 +4,13 @@ require 'spec_helper'
RSpec.describe Packages::Debian::GenerateDistributionWorker, type: :worker, feature_category: :package_registry do
describe '#perform' do
- let(:container_type) { distribution.container_type }
+ let(:container_type_as_string) { container_type.to_s }
let(:distribution_id) { distribution.id }
- subject { described_class.new.perform(container_type, distribution_id) }
+ subject { described_class.new.perform(container_type_as_string, distribution_id) }
- let(:subject2) { described_class.new.perform(container_type, distribution_id) }
- let(:subject3) { described_class.new.perform(container_type, distribution_id) }
+ let(:subject2) { described_class.new.perform(container_type_as_string, distribution_id) }
+ let(:subject3) { described_class.new.perform(container_type_as_string, distribution_id) }
include_context 'with published Debian package'
@@ -54,7 +54,7 @@ RSpec.describe Packages::Debian::GenerateDistributionWorker, type: :worker, feat
context 'with valid parameters' do
it_behaves_like 'an idempotent worker' do
- let(:job_args) { [container_type, distribution_id] }
+ let(:job_args) { [container_type_as_string, distribution_id] }
it_behaves_like 'Generate Debian Distribution and component files'
end
diff --git a/spec/workers/packages/debian/process_changes_worker_spec.rb b/spec/workers/packages/debian/process_changes_worker_spec.rb
index b96b75e93b9..ddd608e768c 100644
--- a/spec/workers/packages/debian/process_changes_worker_spec.rb
+++ b/spec/workers/packages/debian/process_changes_worker_spec.rb
@@ -88,7 +88,7 @@ RSpec.describe Packages::Debian::ProcessChangesWorker, type: :worker, feature_ca
expect { subject }
.to not_change { Packages::Package.count }
.and change { Packages::PackageFile.count }.by(-1)
- .and change { incoming.package_files.count }.from(7).to(6)
+ .and change { incoming.package_files.count }.from(8).to(7)
end
end
@@ -104,7 +104,7 @@ RSpec.describe Packages::Debian::ProcessChangesWorker, type: :worker, feature_ca
expect { subject }
.to not_change { Packages::Package.count }
.and change { Packages::PackageFile.count }.by(-1)
- .and change { incoming.package_files.count }.from(7).to(6)
+ .and change { incoming.package_files.count }.from(8).to(7)
expect { package_file.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
@@ -120,7 +120,7 @@ RSpec.describe Packages::Debian::ProcessChangesWorker, type: :worker, feature_ca
expect { subject }
.to change { Packages::Package.count }.from(1).to(2)
.and not_change { Packages::PackageFile.count }
- .and change { incoming.package_files.count }.from(7).to(0)
+ .and change { incoming.package_files.count }.from(8).to(0)
.and change { package_file&.debian_file_metadatum&.reload&.file_type }.from('unknown').to('changes')
created_package = Packages::Package.last
diff --git a/spec/workers/packages/debian/process_package_file_worker_spec.rb b/spec/workers/packages/debian/process_package_file_worker_spec.rb
index 239ee8e1035..6010f4eac27 100644
--- a/spec/workers/packages/debian/process_package_file_worker_spec.rb
+++ b/spec/workers/packages/debian/process_package_file_worker_spec.rb
@@ -31,6 +31,7 @@ RSpec.describe Packages::Debian::ProcessPackageFileWorker, type: :worker, featur
where(:case_name, :expected_file_type, :file_name, :component_name) do
'with a deb' | 'deb' | 'libsample0_1.2.3~alpha2_amd64.deb' | 'main'
'with an udeb' | 'udeb' | 'sample-udeb_1.2.3~alpha2_amd64.udeb' | 'contrib'
+ 'with a ddeb' | 'ddeb' | 'sample-ddeb_1.2.3~alpha2_amd64.ddeb' | 'main'
end
with_them do
@@ -63,6 +64,7 @@ RSpec.describe Packages::Debian::ProcessPackageFileWorker, type: :worker, featur
.to not_change(Packages::Package, :count)
.and not_change { Packages::PackageFile.count }
.and not_change { package.package_files.count }
+ .and change { package_file.reload.status }.to('error')
.and change { package.reload.status }.from('processing').to('error')
end
end
diff --git a/spec/workers/packages/go/sync_packages_worker_spec.rb b/spec/workers/packages/go/sync_packages_worker_spec.rb
index 5eeef1f7c08..5fdb7a242f6 100644
--- a/spec/workers/packages/go/sync_packages_worker_spec.rb
+++ b/spec/workers/packages/go/sync_packages_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Go::SyncPackagesWorker, type: :worker do
+RSpec.describe Packages::Go::SyncPackagesWorker, type: :worker, feature_category: :package_registry do
include_context 'basic Go module'
before do
diff --git a/spec/workers/packages/helm/extraction_worker_spec.rb b/spec/workers/packages/helm/extraction_worker_spec.rb
index 70a090d6989..a764c2ad939 100644
--- a/spec/workers/packages/helm/extraction_worker_spec.rb
+++ b/spec/workers/packages/helm/extraction_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Helm::ExtractionWorker, type: :worker do
+RSpec.describe Packages::Helm::ExtractionWorker, type: :worker, feature_category: :package_registry do
describe '#perform' do
let_it_be(:package) { create(:helm_package, without_package_files: true, status: 'processing') }
diff --git a/spec/workers/packages/mark_package_files_for_destruction_worker_spec.rb b/spec/workers/packages/mark_package_files_for_destruction_worker_spec.rb
index 15d9e4c347b..29fbf17d49a 100644
--- a/spec/workers/packages/mark_package_files_for_destruction_worker_spec.rb
+++ b/spec/workers/packages/mark_package_files_for_destruction_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::MarkPackageFilesForDestructionWorker, :aggregate_failures do
+RSpec.describe Packages::MarkPackageFilesForDestructionWorker, :aggregate_failures, feature_category: :package_registry do
describe '#perform' do
let_it_be(:package) { create(:package) }
let_it_be(:package_files) { create_list(:package_file, 3, package: package) }
diff --git a/spec/workers/packages/npm/deprecate_package_worker_spec.rb b/spec/workers/packages/npm/deprecate_package_worker_spec.rb
new file mode 100644
index 00000000000..100a8a3af73
--- /dev/null
+++ b/spec/workers/packages/npm/deprecate_package_worker_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Npm::DeprecatePackageWorker, feature_category: :package_registry do
+ describe '#perform' do
+ let_it_be(:project) { create(:project) }
+ let(:worker) { described_class.new }
+ let(:params) do
+ {
+ package_name: 'package_name',
+ versions: {
+ '1.0.1' => {
+ name: 'package_name',
+ deprecated: 'This version is deprecated'
+ }
+ }
+ }
+ end
+
+ include_examples 'an idempotent worker' do
+ let(:job_args) { [project.id, params] }
+
+ it 'calls the deprecation service' do
+ expect(::Packages::Npm::DeprecatePackageService).to receive(:new).with(project, params) do
+ double.tap do |service|
+ expect(service).to receive(:execute)
+ end
+ end
+
+ worker.perform(*job_args)
+ end
+ end
+ end
+end
diff --git a/spec/workers/packages/nuget/extraction_worker_spec.rb b/spec/workers/packages/nuget/extraction_worker_spec.rb
index 5186c037dc5..c1d42d556c2 100644
--- a/spec/workers/packages/nuget/extraction_worker_spec.rb
+++ b/spec/workers/packages/nuget/extraction_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Nuget::ExtractionWorker, type: :worker do
+RSpec.describe Packages::Nuget::ExtractionWorker, type: :worker, feature_category: :package_registry do
describe '#perform' do
let!(:package) { create(:nuget_package) }
let(:package_file) { package.package_files.first }
@@ -73,13 +73,15 @@ RSpec.describe Packages::Nuget::ExtractionWorker, type: :worker do
]
invalid_names.each do |invalid_name|
- before do
- allow_next_instance_of(::Packages::Nuget::UpdatePackageFromMetadataService) do |service|
- allow(service).to receive(:package_name).and_return(invalid_name)
+ context "with #{invalid_name}" do
+ before do
+ allow_next_instance_of(::Packages::Nuget::UpdatePackageFromMetadataService) do |service|
+ allow(service).to receive(:package_name).and_return(invalid_name)
+ end
end
- end
- it_behaves_like 'handling the metadata error'
+ it_behaves_like 'handling the metadata error'
+ end
end
end
@@ -87,20 +89,21 @@ RSpec.describe Packages::Nuget::ExtractionWorker, type: :worker 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_next_instance_of(::Packages::Nuget::UpdatePackageFromMetadataService) do |service|
- allow(service).to receive(:package_version).and_return(invalid_version)
+ context "with #{invalid_version}" do
+ before do
+ allow_next_instance_of(::Packages::Nuget::UpdatePackageFromMetadataService) do |service|
+ allow(service).to receive(:package_version).and_return(invalid_version)
+ end
end
- end
- it_behaves_like 'handling the metadata error'
+ it_behaves_like 'handling the metadata error'
+ end
end
end
diff --git a/spec/workers/packages/rubygems/extraction_worker_spec.rb b/spec/workers/packages/rubygems/extraction_worker_spec.rb
index 0e67f3ac62e..8ad4c2e6447 100644
--- a/spec/workers/packages/rubygems/extraction_worker_spec.rb
+++ b/spec/workers/packages/rubygems/extraction_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Rubygems::ExtractionWorker, type: :worker do
+RSpec.describe Packages::Rubygems::ExtractionWorker, type: :worker, feature_category: :package_registry do
describe '#perform' do
let_it_be(:package) { create(:rubygems_package, :processing) }
diff --git a/spec/workers/pages_domain_removal_cron_worker_spec.rb b/spec/workers/pages_domain_removal_cron_worker_spec.rb
index f152d019de6..eb9f87bd831 100644
--- a/spec/workers/pages_domain_removal_cron_worker_spec.rb
+++ b/spec/workers/pages_domain_removal_cron_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PagesDomainRemovalCronWorker do
+RSpec.describe PagesDomainRemovalCronWorker, feature_category: :pages do
subject(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/pages_domain_ssl_renewal_cron_worker_spec.rb b/spec/workers/pages_domain_ssl_renewal_cron_worker_spec.rb
index 70ffef5342e..5711b7787ea 100644
--- a/spec/workers/pages_domain_ssl_renewal_cron_worker_spec.rb
+++ b/spec/workers/pages_domain_ssl_renewal_cron_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PagesDomainSslRenewalCronWorker do
+RSpec.describe PagesDomainSslRenewalCronWorker, feature_category: :pages do
include LetsEncryptHelpers
subject(:worker) { described_class.new }
diff --git a/spec/workers/pages_domain_ssl_renewal_worker_spec.rb b/spec/workers/pages_domain_ssl_renewal_worker_spec.rb
index f8149b23a08..daaa939ecfe 100644
--- a/spec/workers/pages_domain_ssl_renewal_worker_spec.rb
+++ b/spec/workers/pages_domain_ssl_renewal_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PagesDomainSslRenewalWorker do
+RSpec.describe PagesDomainSslRenewalWorker, feature_category: :pages do
include LetsEncryptHelpers
subject(:worker) { described_class.new }
diff --git a/spec/workers/pages_domain_verification_cron_worker_spec.rb b/spec/workers/pages_domain_verification_cron_worker_spec.rb
index 01eaf984c90..be30e45fc94 100644
--- a/spec/workers/pages_domain_verification_cron_worker_spec.rb
+++ b/spec/workers/pages_domain_verification_cron_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PagesDomainVerificationCronWorker do
+RSpec.describe PagesDomainVerificationCronWorker, feature_category: :pages do
subject(:worker) { described_class.new }
describe '#perform', :sidekiq do
diff --git a/spec/workers/pages_domain_verification_worker_spec.rb b/spec/workers/pages_domain_verification_worker_spec.rb
index 6d2f9ee2f8d..08f383c954f 100644
--- a/spec/workers/pages_domain_verification_worker_spec.rb
+++ b/spec/workers/pages_domain_verification_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PagesDomainVerificationWorker do
+RSpec.describe PagesDomainVerificationWorker, feature_category: :pages do
subject(:worker) { described_class.new }
let(:domain) { create(:pages_domain) }
diff --git a/spec/workers/pages_worker_spec.rb b/spec/workers/pages_worker_spec.rb
index f0d29037fa4..74da4707205 100644
--- a/spec/workers/pages_worker_spec.rb
+++ b/spec/workers/pages_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PagesWorker, :sidekiq_inline do
+RSpec.describe PagesWorker, :sidekiq_inline, feature_category: :pages do
let_it_be(:ci_build) { create(:ci_build) }
context 'when called with the deploy action' do
diff --git a/spec/workers/partition_creation_worker_spec.rb b/spec/workers/partition_creation_worker_spec.rb
index 5d15870b7f6..ab525fd5ce2 100644
--- a/spec/workers/partition_creation_worker_spec.rb
+++ b/spec/workers/partition_creation_worker_spec.rb
@@ -2,7 +2,7 @@
#
require 'spec_helper'
-RSpec.describe PartitionCreationWorker do
+RSpec.describe PartitionCreationWorker, feature_category: :database do
subject { described_class.new.perform }
let(:management_worker) { double }
diff --git a/spec/workers/personal_access_tokens/expired_notification_worker_spec.rb b/spec/workers/personal_access_tokens/expired_notification_worker_spec.rb
index 7c3c48b3f80..7a3491b49d6 100644
--- a/spec/workers/personal_access_tokens/expired_notification_worker_spec.rb
+++ b/spec/workers/personal_access_tokens/expired_notification_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PersonalAccessTokens::ExpiredNotificationWorker, type: :worker do
+RSpec.describe PersonalAccessTokens::ExpiredNotificationWorker, type: :worker, feature_category: :system_access do
subject(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/personal_access_tokens/expiring_worker_spec.rb b/spec/workers/personal_access_tokens/expiring_worker_spec.rb
index 7fa777b911a..01ce4e85fe2 100644
--- a/spec/workers/personal_access_tokens/expiring_worker_spec.rb
+++ b/spec/workers/personal_access_tokens/expiring_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do
+RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker, feature_category: :system_access do
subject(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/pipeline_hooks_worker_spec.rb b/spec/workers/pipeline_hooks_worker_spec.rb
index 5d28b1e129a..7a85038d946 100644
--- a/spec/workers/pipeline_hooks_worker_spec.rb
+++ b/spec/workers/pipeline_hooks_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PipelineHooksWorker do
+RSpec.describe PipelineHooksWorker, feature_category: :continuous_integration do
describe '#perform' do
context 'when pipeline exists' do
let(:pipeline) { create(:ci_pipeline) }
@@ -37,7 +37,5 @@ RSpec.describe PipelineHooksWorker do
end
end
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
end
diff --git a/spec/workers/pipeline_metrics_worker_spec.rb b/spec/workers/pipeline_metrics_worker_spec.rb
index c73b84e26a6..7bd98f8f55d 100644
--- a/spec/workers/pipeline_metrics_worker_spec.rb
+++ b/spec/workers/pipeline_metrics_worker_spec.rb
@@ -2,23 +2,23 @@
require 'spec_helper'
-RSpec.describe PipelineMetricsWorker do
+RSpec.describe PipelineMetricsWorker, feature_category: :continuous_integration do
let(:project) { create(:project, :repository) }
let!(:merge_request) do
- create(:merge_request, source_project: project,
- source_branch: pipeline.ref,
- head_pipeline: pipeline)
+ create(:merge_request, source_project: project, source_branch: pipeline.ref, head_pipeline: pipeline)
end
let(:pipeline) do
- create(:ci_empty_pipeline,
- status: status,
- project: project,
- ref: 'master',
- sha: project.repository.commit('master').id,
- started_at: 1.hour.ago,
- finished_at: Time.current)
+ create(
+ :ci_empty_pipeline,
+ status: status,
+ project: project,
+ ref: 'master',
+ sha: project.repository.commit('master').id,
+ started_at: 1.hour.ago,
+ finished_at: Time.current
+ )
end
let(:status) { 'pending' }
diff --git a/spec/workers/pipeline_notification_worker_spec.rb b/spec/workers/pipeline_notification_worker_spec.rb
index 672debd0501..05d0186e873 100644
--- a/spec/workers/pipeline_notification_worker_spec.rb
+++ b/spec/workers/pipeline_notification_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PipelineNotificationWorker, :mailer do
+RSpec.describe PipelineNotificationWorker, :mailer, feature_category: :continuous_integration do
let_it_be(:pipeline) { create(:ci_pipeline) }
describe '#execute' do
diff --git a/spec/workers/pipeline_process_worker_spec.rb b/spec/workers/pipeline_process_worker_spec.rb
index 6e95b7a4753..6ee91b4579d 100644
--- a/spec/workers/pipeline_process_worker_spec.rb
+++ b/spec/workers/pipeline_process_worker_spec.rb
@@ -2,9 +2,17 @@
require 'spec_helper'
-RSpec.describe PipelineProcessWorker do
+RSpec.describe PipelineProcessWorker, feature_category: :continuous_integration do
let_it_be(:pipeline) { create(:ci_pipeline) }
+ it 'has the `until_executed` deduplicate strategy' do
+ expect(described_class.get_deduplicate_strategy).to eq(:until_executed)
+ end
+
+ it 'has the option to reschedule once if deduplicated and a TTL of 1 minute' do
+ expect(described_class.get_deduplication_options).to include({ if_deduplicated: :reschedule_once, ttl: 1.minute })
+ end
+
include_examples 'an idempotent worker' do
let(:pipeline) { create(:ci_pipeline, :created) }
let(:job_args) { [pipeline.id] }
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index 210987555c9..bd1bfc46d53 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PostReceive do
+RSpec.describe PostReceive, feature_category: :source_code_management do
include AfterNextHelpers
let(:changes) do
@@ -280,7 +280,6 @@ RSpec.describe PostReceive do
let(:category) { described_class.name }
let(:namespace) { project.namespace }
let(:user) { project.creator }
- let(:feature_flag_name) { :route_hll_to_snowplow_phase2 }
let(:label) { 'counts.source_code_pushes' }
let(:property) { 'source_code_pushes' }
let(:context) { [Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: label).to_h] }
diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb
index 143809e8f2a..c95119b0d02 100644
--- a/spec/workers/process_commit_worker_spec.rb
+++ b/spec/workers/process_commit_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProcessCommitWorker do
+RSpec.describe ProcessCommitWorker, feature_category: :source_code_management do
let(:worker) { described_class.new }
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
@@ -82,11 +82,13 @@ RSpec.describe ProcessCommitWorker do
context 'when commit is a merge request merge commit to the default branch' do
let(:merge_request) do
- create(:merge_request,
- description: "Closes #{issue.to_reference}",
- source_branch: 'feature-merged',
- target_branch: 'master',
- source_project: project)
+ create(
+ :merge_request,
+ description: "Closes #{issue.to_reference}",
+ source_branch: 'feature-merged',
+ target_branch: 'master',
+ source_project: project
+ )
end
let(:commit) do
diff --git a/spec/workers/project_cache_worker_spec.rb b/spec/workers/project_cache_worker_spec.rb
index 3c807ef9ffd..4d468897599 100644
--- a/spec/workers/project_cache_worker_spec.rb
+++ b/spec/workers/project_cache_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProjectCacheWorker do
+RSpec.describe ProjectCacheWorker, feature_category: :source_code_management do
include ExclusiveLeaseHelpers
let_it_be(:project) { create(:project, :repository) }
@@ -13,10 +13,6 @@ RSpec.describe ProjectCacheWorker do
let(:statistics) { [] }
describe '#perform' do
- before do
- stub_exclusive_lease(lease_key, timeout: lease_timeout)
- end
-
context 'with a non-existing project' do
it 'does nothing' do
expect(worker).not_to receive(:update_statistics)
@@ -37,11 +33,6 @@ RSpec.describe ProjectCacheWorker do
end
context 'with an existing project' do
- before do
- lease_key = "namespace:namespaces_root_statistics:#{project.namespace_id}"
- stub_exclusive_lease_taken(lease_key, timeout: Namespace::AggregationSchedule.default_lease_timeout)
- end
-
it 'refreshes the method caches' do
expect_any_instance_of(Repository).to receive(:refresh_method_caches)
.with(%i(readme))
diff --git a/spec/workers/project_destroy_worker_spec.rb b/spec/workers/project_destroy_worker_spec.rb
index 25508928bbf..d699393d7a0 100644
--- a/spec/workers/project_destroy_worker_spec.rb
+++ b/spec/workers/project_destroy_worker_spec.rb
@@ -2,15 +2,26 @@
require 'spec_helper'
-RSpec.describe ProjectDestroyWorker do
- let(:project) { create(:project, :repository, pending_delete: true) }
- let!(:repository) { project.repository.raw }
+RSpec.describe ProjectDestroyWorker, feature_category: :source_code_management do
+ let_it_be(:project) { create(:project, :repository, pending_delete: true) }
+ let_it_be(:repository) { project.repository.raw }
- subject { described_class.new }
+ let(:user) { project.first_owner }
+
+ subject(:worker) { described_class.new }
+
+ include_examples 'an idempotent worker' do
+ let(:job_args) { [project.id, user.id, {}] }
+
+ it 'does not change projects when run twice' do
+ expect { worker.perform(project.id, user.id, {}) }.to change { Project.count }.by(-1)
+ expect { worker.perform(project.id, user.id, {}) }.not_to change { Project.count }
+ end
+ end
describe '#perform' do
it 'deletes the project' do
- subject.perform(project.id, project.first_owner.id, {})
+ worker.perform(project.id, user.id, {})
expect(Project.all).not_to include(project)
expect(repository).not_to exist
@@ -18,13 +29,13 @@ RSpec.describe ProjectDestroyWorker do
it 'does not raise error when project could not be found' do
expect do
- subject.perform(-1, project.first_owner.id, {})
+ worker.perform(-1, user.id, {})
end.not_to raise_error
end
it 'does not raise error when user could not be found' do
expect do
- subject.perform(project.id, -1, {})
+ worker.perform(project.id, -1, {})
end.not_to raise_error
end
end
diff --git a/spec/workers/project_export_worker_spec.rb b/spec/workers/project_export_worker_spec.rb
index dd0a921059d..eaf1536da63 100644
--- a/spec/workers/project_export_worker_spec.rb
+++ b/spec/workers/project_export_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ProjectExportWorker do
+RSpec.describe ProjectExportWorker, feature_category: :importers do
it_behaves_like 'export worker'
context 'exporters duration measuring' do
diff --git a/spec/workers/projects/after_import_worker_spec.rb b/spec/workers/projects/after_import_worker_spec.rb
index 85d15c89b0a..5af4f49d6e0 100644
--- a/spec/workers/projects/after_import_worker_spec.rb
+++ b/spec/workers/projects/after_import_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::AfterImportWorker do
+RSpec.describe Projects::AfterImportWorker, feature_category: :importers do
subject { worker.perform(project.id) }
let(:worker) { described_class.new }
diff --git a/spec/workers/projects/finalize_project_statistics_refresh_worker_spec.rb b/spec/workers/projects/finalize_project_statistics_refresh_worker_spec.rb
index 932ba29f806..1379b6785eb 100644
--- a/spec/workers/projects/finalize_project_statistics_refresh_worker_spec.rb
+++ b/spec/workers/projects/finalize_project_statistics_refresh_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::FinalizeProjectStatisticsRefreshWorker do
+RSpec.describe Projects::FinalizeProjectStatisticsRefreshWorker, feature_category: :projects do
let_it_be(:record) { create(:project_build_artifacts_size_refresh, :finalizing) }
describe '#perform' do
diff --git a/spec/workers/projects/import_export/create_relation_exports_worker_spec.rb b/spec/workers/projects/import_export/create_relation_exports_worker_spec.rb
new file mode 100644
index 00000000000..2ff91150fda
--- /dev/null
+++ b/spec/workers/projects/import_export/create_relation_exports_worker_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::ImportExport::CreateRelationExportsWorker, feature_category: :importers do
+ let_it_be(:user) { build_stubbed(:user) }
+ let_it_be(:project) { create(:project) }
+
+ let(:after_export_strategy) { {} }
+ let(:job_args) { [user.id, project.id, after_export_strategy] }
+
+ before do
+ allow_next_instance_of(described_class) do |job|
+ allow(job).to receive(:jid) { SecureRandom.hex(8) }
+ end
+ end
+
+ it_behaves_like 'an idempotent worker'
+
+ context 'when job is re-enqueued after an interuption and same JID is used' do
+ before do
+ allow_next_instance_of(described_class) do |job|
+ allow(job).to receive(:jid).and_return(1234)
+ end
+ end
+
+ it_behaves_like 'an idempotent worker'
+
+ it 'does not start the export process twice' do
+ project.export_jobs.create!(jid: 1234, status_event: :start)
+
+ expect { described_class.new.perform(user.id, project.id, after_export_strategy) }
+ .to change { Projects::ImportExport::WaitRelationExportsWorker.jobs.size }.by(0)
+ end
+ end
+
+ it 'creates a export_job and sets the status to `started`' do
+ described_class.new.perform(user.id, project.id, after_export_strategy)
+
+ export_job = project.export_jobs.last
+ expect(export_job.started?).to eq(true)
+ end
+
+ it 'creates relation export records and enqueues a worker for each relation to be exported' do
+ allow(Projects::ImportExport::RelationExport).to receive(:relation_names_list).and_return(%w[relation_1 relation_2])
+
+ expect { described_class.new.perform(user.id, project.id, after_export_strategy) }
+ .to change { Projects::ImportExport::RelationExportWorker.jobs.size }.by(2)
+
+ relation_exports = project.export_jobs.last.relation_exports
+ expect(relation_exports.collect(&:relation)).to match_array(%w[relation_1 relation_2])
+ end
+
+ it 'enqueues a WaitRelationExportsWorker' do
+ allow(Projects::ImportExport::WaitRelationExportsWorker).to receive(:perform_in)
+
+ described_class.new.perform(user.id, project.id, after_export_strategy)
+
+ export_job = project.export_jobs.last
+ expect(Projects::ImportExport::WaitRelationExportsWorker).to have_received(:perform_in).with(
+ described_class::INITIAL_DELAY,
+ export_job.id,
+ user.id,
+ after_export_strategy
+ )
+ end
+end
diff --git a/spec/workers/projects/import_export/relation_export_worker_spec.rb b/spec/workers/projects/import_export/relation_export_worker_spec.rb
index 236650fe55b..16ee73040b1 100644
--- a/spec/workers/projects/import_export/relation_export_worker_spec.rb
+++ b/spec/workers/projects/import_export/relation_export_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ImportExport::RelationExportWorker, type: :worker do
+RSpec.describe Projects::ImportExport::RelationExportWorker, type: :worker, feature_category: :importers do
let(:project_relation_export) { create(:project_relation_export) }
let(:job_args) { [project_relation_export.id] }
@@ -11,26 +11,61 @@ RSpec.describe Projects::ImportExport::RelationExportWorker, type: :worker do
describe '#perform' do
subject(:worker) { described_class.new }
- context 'when relation export has initial state queued' do
- let(:project_relation_export) { create(:project_relation_export) }
+ context 'when relation export has initial status `queued`' do
+ it 'exports the relation' do
+ expect_next_instance_of(Projects::ImportExport::RelationExportService) do |service|
+ expect(service).to receive(:execute)
+ end
- it 'calls RelationExportService' do
+ worker.perform(project_relation_export.id)
+ end
+ end
+
+ context 'when relation export has status `started`' do
+ let(:project_relation_export) { create(:project_relation_export, :started) }
+
+ it 'retries the export of the relation' do
expect_next_instance_of(Projects::ImportExport::RelationExportService) do |service|
expect(service).to receive(:execute)
end
worker.perform(project_relation_export.id)
+
+ expect(project_relation_export.reload.queued?).to eq(true)
end
end
- context 'when relation export does not have queued state' do
- let(:project_relation_export) { create(:project_relation_export, status_event: :start) }
+ context 'when relation export does not have status `queued` or `started`' do
+ let(:project_relation_export) { create(:project_relation_export, :finished) }
- it 'does not call RelationExportService' do
+ it 'does not export the relation' do
expect(Projects::ImportExport::RelationExportService).not_to receive(:new)
worker.perform(project_relation_export.id)
end
end
end
+
+ describe '.sidekiq_retries_exhausted' do
+ let(:job) { { 'args' => [project_relation_export.id], 'error_message' => 'Error message' } }
+
+ it 'sets relation export status to `failed`' do
+ described_class.sidekiq_retries_exhausted_block.call(job)
+
+ expect(project_relation_export.reload.failed?).to eq(true)
+ end
+
+ it 'logs the error message' do
+ expect_next_instance_of(Gitlab::Export::Logger) do |logger|
+ expect(logger).to receive(:error).with(
+ hash_including(
+ message: 'Project relation export failed',
+ export_error: 'Error message'
+ )
+ )
+ end
+
+ described_class.sidekiq_retries_exhausted_block.call(job)
+ end
+ end
end
diff --git a/spec/workers/projects/import_export/wait_relation_exports_worker_spec.rb b/spec/workers/projects/import_export/wait_relation_exports_worker_spec.rb
new file mode 100644
index 00000000000..52394b8998e
--- /dev/null
+++ b/spec/workers/projects/import_export/wait_relation_exports_worker_spec.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::ImportExport::WaitRelationExportsWorker, feature_category: :importers do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project_export_job) { create(:project_export_job, :started) }
+
+ let(:after_export_strategy) { {} }
+ let(:job_args) { [project_export_job.id, user.id, after_export_strategy] }
+
+ def create_relation_export(trait, relation, export_error = nil)
+ create(:project_relation_export, trait,
+ { project_export_job: project_export_job, relation: relation, export_error: export_error }
+ )
+ end
+
+ before do
+ allow_next_instance_of(described_class) do |job|
+ allow(job).to receive(:jid) { SecureRandom.hex(8) }
+ end
+ end
+
+ context 'when export job status is not `started`' do
+ it 'does not perform any operation and finishes the worker' do
+ finished_export_job = create(:project_export_job, :finished)
+
+ expect { described_class.new.perform(finished_export_job.id, user.id, after_export_strategy) }
+ .to change { Projects::ImportExport::ParallelProjectExportWorker.jobs.size }.by(0)
+ .and change { described_class.jobs.size }.by(0)
+ end
+ end
+
+ context 'when there are relation exports with status `queued`' do
+ before do
+ create_relation_export(:finished, 'labels')
+ create_relation_export(:started, 'milestones')
+ create_relation_export(:queued, 'merge_requests')
+ end
+
+ it 'does not enqueue ParallelProjectExportWorker and re-enqueue WaitRelationExportsWorker' do
+ expect { described_class.new.perform(*job_args) }
+ .to change { Projects::ImportExport::ParallelProjectExportWorker.jobs.size }.by(0)
+ .and change { described_class.jobs.size }.by(1)
+ end
+ end
+
+ context 'when there are relation exports with status `started`' do
+ let(:started_relation_export) { create_relation_export(:started, 'releases') }
+
+ before do
+ create_relation_export(:finished, 'labels')
+ create_relation_export(:queued, 'merge_requests')
+ end
+
+ context 'when the Sidekiq Job exporting the relation is still running' do
+ it "does not change relation export's status and re-enqueue WaitRelationExportsWorker" do
+ allow(Gitlab::SidekiqStatus).to receive(:running?).with(started_relation_export.jid).and_return(true)
+
+ expect { described_class.new.perform(*job_args) }
+ .to change { described_class.jobs.size }.by(1)
+
+ expect(started_relation_export.reload.started?).to eq(true)
+ end
+ end
+
+ context 'when the Sidekiq Job exporting the relation is still is no longer running' do
+ it "set the relation export's status to `failed`" do
+ allow(Gitlab::SidekiqStatus).to receive(:running?).with(started_relation_export.jid).and_return(false)
+
+ expect { described_class.new.perform(*job_args) }
+ .to change { described_class.jobs.size }.by(1)
+
+ expect(started_relation_export.reload.failed?).to eq(true)
+ end
+ end
+ end
+
+ context 'when all relation exports have status `finished`' do
+ before do
+ create_relation_export(:finished, 'labels')
+ create_relation_export(:finished, 'issues')
+ end
+
+ it 'enqueues ParallelProjectExportWorker and does not reenqueue WaitRelationExportsWorker' do
+ expect { described_class.new.perform(*job_args) }
+ .to change { Projects::ImportExport::ParallelProjectExportWorker.jobs.size }.by(1)
+ .and change { described_class.jobs.size }.by(0)
+ end
+
+ it_behaves_like 'an idempotent worker'
+ end
+
+ context 'when at least one relation export has status `failed` and the rest have status `finished` or `failed`' do
+ before do
+ create_relation_export(:finished, 'labels')
+ create_relation_export(:failed, 'issues', 'Failed to export issues')
+ create_relation_export(:failed, 'releases', 'Failed to export releases')
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ it 'notifies the failed exports to the user' do
+ expect_next_instance_of(NotificationService) do |notification_service|
+ expect(notification_service).to receive(:project_not_exported)
+ .with(
+ project_export_job.project,
+ user,
+ array_including(['Failed to export issues', 'Failed to export releases'])
+ )
+ .once
+ end
+
+ described_class.new.perform(*job_args)
+ end
+ end
+
+ it 'does not enqueue ParallelProjectExportWorker and re-enqueue WaitRelationExportsWorker' do
+ expect { described_class.new.perform(*job_args) }
+ .to change { Projects::ImportExport::ParallelProjectExportWorker.jobs.size }.by(0)
+ .and change { described_class.jobs.size }.by(0)
+ end
+ end
+end
diff --git a/spec/workers/projects/inactive_projects_deletion_cron_worker_spec.rb b/spec/workers/projects/inactive_projects_deletion_cron_worker_spec.rb
index f3c6434dc85..68af5e61e3b 100644
--- a/spec/workers/projects/inactive_projects_deletion_cron_worker_spec.rb
+++ b/spec/workers/projects/inactive_projects_deletion_cron_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::InactiveProjectsDeletionCronWorker do
+RSpec.describe Projects::InactiveProjectsDeletionCronWorker, feature_category: :compliance_management do
include ProjectHelpers
shared_examples 'worker is running for more than 4 minutes' do
@@ -92,8 +92,11 @@ RSpec.describe Projects::InactiveProjectsDeletionCronWorker do
it 'invokes Projects::InactiveProjectsDeletionNotificationWorker for inactive projects' do
Gitlab::Redis::SharedState.with do |redis|
- expect(redis).to receive(:hset).with('inactive_projects_deletion_warning_email_notified',
- "project:#{inactive_large_project.id}", Date.current)
+ expect(redis).to receive(:hset).with(
+ 'inactive_projects_deletion_warning_email_notified',
+ "project:#{inactive_large_project.id}",
+ Date.current
+ )
end
expect(::Projects::InactiveProjectsDeletionNotificationWorker).to receive(:perform_async).with(
inactive_large_project.id, deletion_date).and_call_original
@@ -104,8 +107,11 @@ RSpec.describe Projects::InactiveProjectsDeletionCronWorker do
it 'does not invoke InactiveProjectsDeletionNotificationWorker for already notified inactive projects' do
Gitlab::Redis::SharedState.with do |redis|
- redis.hset('inactive_projects_deletion_warning_email_notified', "project:#{inactive_large_project.id}",
- Date.current.to_s)
+ redis.hset(
+ 'inactive_projects_deletion_warning_email_notified',
+ "project:#{inactive_large_project.id}",
+ Date.current.to_s
+ )
end
expect(::Projects::InactiveProjectsDeletionNotificationWorker).not_to receive(:perform_async)
@@ -116,8 +122,11 @@ RSpec.describe Projects::InactiveProjectsDeletionCronWorker do
it 'invokes Projects::DestroyService for projects that are inactive even after being notified' do
Gitlab::Redis::SharedState.with do |redis|
- redis.hset('inactive_projects_deletion_warning_email_notified', "project:#{inactive_large_project.id}",
- 15.months.ago.to_date.to_s)
+ redis.hset(
+ 'inactive_projects_deletion_warning_email_notified',
+ "project:#{inactive_large_project.id}",
+ 15.months.ago.to_date.to_s
+ )
end
expect(::Projects::InactiveProjectsDeletionNotificationWorker).not_to receive(:perform_async)
@@ -129,8 +138,9 @@ RSpec.describe Projects::InactiveProjectsDeletionCronWorker do
expect(inactive_large_project.reload.pending_delete).to eq(true)
Gitlab::Redis::SharedState.with do |redis|
- expect(redis.hget('inactive_projects_deletion_warning_email_notified',
- "project:#{inactive_large_project.id}")).to be_nil
+ expect(
+ redis.hget('inactive_projects_deletion_warning_email_notified', "project:#{inactive_large_project.id}")
+ ).to be_nil
end
end
diff --git a/spec/workers/projects/inactive_projects_deletion_notification_worker_spec.rb b/spec/workers/projects/inactive_projects_deletion_notification_worker_spec.rb
index 3ddfec0d346..2ac2b5d0795 100644
--- a/spec/workers/projects/inactive_projects_deletion_notification_worker_spec.rb
+++ b/spec/workers/projects/inactive_projects_deletion_notification_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::InactiveProjectsDeletionNotificationWorker do
+RSpec.describe Projects::InactiveProjectsDeletionNotificationWorker, feature_category: :compliance_management do
describe "#perform" do
subject(:worker) { described_class.new }
@@ -22,14 +22,15 @@ RSpec.describe Projects::InactiveProjectsDeletionNotificationWorker do
worker.perform(project.id, deletion_date)
Gitlab::Redis::SharedState.with do |redis|
- expect(redis.hget('inactive_projects_deletion_warning_email_notified',
- "project:#{project.id}")).to eq(Date.current.to_s)
+ expect(
+ redis.hget('inactive_projects_deletion_warning_email_notified', "project:#{project.id}")
+ ).to eq(Date.current.to_s)
end
end
it 'rescues and logs the exception if project does not exist' do
- expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(ActiveRecord::RecordNotFound),
- { project_id: non_existing_project_id })
+ expect(Gitlab::ErrorTracking).to receive(:log_exception)
+ .with(instance_of(ActiveRecord::RecordNotFound), { project_id: non_existing_project_id })
worker.perform(non_existing_project_id, deletion_date)
end
diff --git a/spec/workers/projects/post_creation_worker_spec.rb b/spec/workers/projects/post_creation_worker_spec.rb
index b702eed9ea4..2c50a07cf48 100644
--- a/spec/workers/projects/post_creation_worker_spec.rb
+++ b/spec/workers/projects/post_creation_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::PostCreationWorker do
+RSpec.describe Projects::PostCreationWorker, feature_category: :source_code_management do
let_it_be(:user) { create :user }
let(:worker) { described_class.new }
diff --git a/spec/workers/projects/process_sync_events_worker_spec.rb b/spec/workers/projects/process_sync_events_worker_spec.rb
index a10a4797b2c..7047d8e8653 100644
--- a/spec/workers/projects/process_sync_events_worker_spec.rb
+++ b/spec/workers/projects/process_sync_events_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ProcessSyncEventsWorker do
+RSpec.describe Projects::ProcessSyncEventsWorker, feature_category: :cell do
let!(:group) { create(:group) }
let!(:project) { create(:project) }
@@ -14,8 +14,8 @@ RSpec.describe Projects::ProcessSyncEventsWorker do
expect(described_class.get_deduplicate_strategy).to eq(:until_executed)
end
- it 'has an option to reschedule once if deduplicated' do
- expect(described_class.get_deduplication_options).to include({ if_deduplicated: :reschedule_once })
+ it 'has the option to reschedule once if deduplicated and a TTL of 1 minute' do
+ expect(described_class.get_deduplication_options).to include({ if_deduplicated: :reschedule_once, ttl: 1.minute })
end
describe '#perform' do
diff --git a/spec/workers/projects/record_target_platforms_worker_spec.rb b/spec/workers/projects/record_target_platforms_worker_spec.rb
index 01852f252b7..0e106fe32f9 100644
--- a/spec/workers/projects/record_target_platforms_worker_spec.rb
+++ b/spec/workers/projects/record_target_platforms_worker_spec.rb
@@ -2,16 +2,16 @@
require 'spec_helper'
-RSpec.describe Projects::RecordTargetPlatformsWorker do
+RSpec.describe Projects::RecordTargetPlatformsWorker, feature_category: :projects do
include ExclusiveLeaseHelpers
let_it_be(:swift) { create(:programming_language, name: 'Swift') }
let_it_be(:objective_c) { create(:programming_language, name: 'Objective-C') }
- let_it_be(:java) { create(:programming_language, name: 'Java') }
- let_it_be(:kotlin) { create(:programming_language, name: 'Kotlin') }
let_it_be(:project) { create(:project, :repository, detected_repository_languages: true) }
let(:worker) { described_class.new }
+ let(:service_result) { %w(ios osx watchos) }
+ let(:service_double) { instance_double(Projects::RecordTargetPlatformsService, execute: service_result) }
let(:lease_key) { "#{described_class.name.underscore}:#{project.id}" }
let(:lease_timeout) { described_class::LEASE_TIMEOUT }
@@ -49,68 +49,19 @@ RSpec.describe Projects::RecordTargetPlatformsWorker do
end
end
- def create_language(language)
- create(:repository_language, project: project, programming_language: language)
- end
-
- context 'when project uses programming language for Apple platform' do
- let(:service_result) { %w(ios osx watchos) }
-
- context 'when project uses Swift programming language' do
- before do
- create_language(swift)
- end
-
- it_behaves_like 'performs detection', Projects::AppleTargetPlatformDetectorService
- end
+ context 'when project uses Swift programming language' do
+ let!(:repository_language) { create(:repository_language, project: project, programming_language: swift) }
- context 'when project uses Objective-C programming language' do
- before do
- create_language(objective_c)
- end
-
- it_behaves_like 'performs detection', Projects::AppleTargetPlatformDetectorService
- end
+ include_examples 'performs detection', Projects::AppleTargetPlatformDetectorService
end
- context 'when project uses programming language for Android platform' do
- let(:feature_enabled) { true }
- let(:service_result) { %w(android) }
-
- before do
- stub_feature_flags(detect_android_projects: feature_enabled)
- end
+ context 'when project uses Objective-C programming language' do
+ let!(:repository_language) { create(:repository_language, project: project, programming_language: objective_c) }
- context 'when project uses Java' do
- before do
- create_language(java)
- end
-
- it_behaves_like 'performs detection', Projects::AndroidTargetPlatformDetectorService
-
- context 'when feature flag is disabled' do
- let(:feature_enabled) { false }
-
- it_behaves_like 'does nothing'
- end
- end
-
- context 'when project uses Kotlin' do
- before do
- create_language(kotlin)
- end
-
- it_behaves_like 'performs detection', Projects::AndroidTargetPlatformDetectorService
-
- context 'when feature flag is disabled' do
- let(:feature_enabled) { false }
-
- it_behaves_like 'does nothing'
- end
- end
+ include_examples 'performs detection', Projects::AppleTargetPlatformDetectorService
end
- context 'when the project does not use programming languages for Apple or Android platforms' do
+ context 'when the project does not contain programming languages for Apple platforms' do
it_behaves_like 'does nothing'
end
diff --git a/spec/workers/projects/refresh_build_artifacts_size_statistics_worker_spec.rb b/spec/workers/projects/refresh_build_artifacts_size_statistics_worker_spec.rb
index 99627ff1ad2..f2b7e75fa10 100644
--- a/spec/workers/projects/refresh_build_artifacts_size_statistics_worker_spec.rb
+++ b/spec/workers/projects/refresh_build_artifacts_size_statistics_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsWorker do
+RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsWorker, feature_category: :build_artifacts do
let(:worker) { described_class.new }
describe '#perform_work' do
diff --git a/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb
index 7eff8e4dcd7..1d328280389 100644
--- a/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb
+++ b/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ScheduleBulkRepositoryShardMovesWorker do
+RSpec.describe Projects::ScheduleBulkRepositoryShardMovesWorker, feature_category: :gitaly do
it_behaves_like 'schedules bulk repository shard moves' do
let_it_be_with_reload(:container) { create(:project, :repository) }
diff --git a/spec/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker_spec.rb b/spec/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker_spec.rb
index b5775f37678..b2111b2efb0 100644
--- a/spec/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker_spec.rb
+++ b/spec/workers/projects/schedule_refresh_build_artifacts_size_statistics_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::ScheduleRefreshBuildArtifactsSizeStatisticsWorker do
+RSpec.describe Projects::ScheduleRefreshBuildArtifactsSizeStatisticsWorker, feature_category: :build_artifacts do
subject(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/projects/update_repository_storage_worker_spec.rb b/spec/workers/projects/update_repository_storage_worker_spec.rb
index 7570d706325..91445c2bbf6 100644
--- a/spec/workers/projects/update_repository_storage_worker_spec.rb
+++ b/spec/workers/projects/update_repository_storage_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::UpdateRepositoryStorageWorker do
+RSpec.describe Projects::UpdateRepositoryStorageWorker, feature_category: :source_code_management do
subject { described_class.new }
it_behaves_like 'an update storage move worker' do
diff --git a/spec/workers/propagate_integration_group_worker_spec.rb b/spec/workers/propagate_integration_group_worker_spec.rb
index 60442438a1d..0d797d22137 100644
--- a/spec/workers/propagate_integration_group_worker_spec.rb
+++ b/spec/workers/propagate_integration_group_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PropagateIntegrationGroupWorker do
+RSpec.describe PropagateIntegrationGroupWorker, feature_category: :integrations do
describe '#perform' do
let_it_be(:group) { create(:group) }
let_it_be(:another_group) { create(:group) }
diff --git a/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb b/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb
index c9a7bfaa8b6..d69dd45a209 100644
--- a/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb
+++ b/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PropagateIntegrationInheritDescendantWorker do
+RSpec.describe PropagateIntegrationInheritDescendantWorker, feature_category: :integrations do
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) }
let_it_be(:group_integration) { create(:redmine_integration, :group, group: group) }
diff --git a/spec/workers/propagate_integration_inherit_worker_spec.rb b/spec/workers/propagate_integration_inherit_worker_spec.rb
index dd5d246d7f9..f5535696fd1 100644
--- a/spec/workers/propagate_integration_inherit_worker_spec.rb
+++ b/spec/workers/propagate_integration_inherit_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PropagateIntegrationInheritWorker do
+RSpec.describe PropagateIntegrationInheritWorker, feature_category: :integrations do
describe '#perform' do
let_it_be(:integration) { create(:redmine_integration, :instance) }
let_it_be(:integration1) { create(:redmine_integration, inherit_from_id: integration.id) }
diff --git a/spec/workers/propagate_integration_worker_spec.rb b/spec/workers/propagate_integration_worker_spec.rb
index 030caefb833..90134b5cd64 100644
--- a/spec/workers/propagate_integration_worker_spec.rb
+++ b/spec/workers/propagate_integration_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PropagateIntegrationWorker do
+RSpec.describe PropagateIntegrationWorker, feature_category: :integrations do
describe '#perform' do
let(:project) { create(:project) }
let(:integration) do
diff --git a/spec/workers/prune_old_events_worker_spec.rb b/spec/workers/prune_old_events_worker_spec.rb
index c1ba9128475..8046fff2cf3 100644
--- a/spec/workers/prune_old_events_worker_spec.rb
+++ b/spec/workers/prune_old_events_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PruneOldEventsWorker do
+RSpec.describe PruneOldEventsWorker, feature_category: :user_profile do
describe '#perform' do
let(:user) { create(:user) }
@@ -30,5 +30,17 @@ RSpec.describe PruneOldEventsWorker do
subject.perform
expect(not_expired_3_years_event).to be_present
end
+
+ context 'with ops_prune_old_events FF disabled' do
+ before do
+ stub_feature_flags(ops_prune_old_events: false)
+ end
+
+ it 'does not delete' do
+ subject.perform
+
+ expect(Event.find_by(id: expired_event.id)).to be_present
+ end
+ end
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 84315fd6ee9..49ef73bad53 100644
--- a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb
+++ b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe PurgeDependencyProxyCacheWorker do
+RSpec.describe PurgeDependencyProxyCacheWorker, feature_category: :dependency_proxy do
let_it_be(:user) { create(:admin) }
let_it_be_with_refind(:blob) { create(:dependency_proxy_blob ) }
let_it_be_with_reload(:group) { blob.group }
diff --git a/spec/workers/reactive_caching_worker_spec.rb b/spec/workers/reactive_caching_worker_spec.rb
index 63b26817a7a..4e9d638c1e1 100644
--- a/spec/workers/reactive_caching_worker_spec.rb
+++ b/spec/workers/reactive_caching_worker_spec.rb
@@ -2,6 +2,6 @@
require 'spec_helper'
-RSpec.describe ReactiveCachingWorker do
+RSpec.describe ReactiveCachingWorker, feature_category: :shared do
it_behaves_like 'reactive cacheable worker'
end
diff --git a/spec/workers/rebase_worker_spec.rb b/spec/workers/rebase_worker_spec.rb
index 4bdfd7219f2..ee8fd8b7461 100644
--- a/spec/workers/rebase_worker_spec.rb
+++ b/spec/workers/rebase_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RebaseWorker, '#perform' do
+RSpec.describe RebaseWorker, '#perform', feature_category: :source_code_management do
include ProjectForksHelper
context 'when rebasing an MR from a fork where upstream has protected branches' do
@@ -10,11 +10,13 @@ RSpec.describe RebaseWorker, '#perform' do
let(:forked_project) { fork_project(upstream_project, nil, repository: true) }
let(:merge_request) do
- create(:merge_request,
- source_project: forked_project,
- source_branch: 'feature_conflict',
- target_project: upstream_project,
- target_branch: 'master')
+ create(
+ :merge_request,
+ source_project: forked_project,
+ source_branch: 'feature_conflict',
+ target_project: upstream_project,
+ target_branch: 'master'
+ )
end
it 'sets the correct project for running hooks' do
diff --git a/spec/workers/releases/create_evidence_worker_spec.rb b/spec/workers/releases/create_evidence_worker_spec.rb
index 7e3edcfe44a..8631b154920 100644
--- a/spec/workers/releases/create_evidence_worker_spec.rb
+++ b/spec/workers/releases/create_evidence_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Releases::CreateEvidenceWorker do
+RSpec.describe Releases::CreateEvidenceWorker, feature_category: :release_evidence do
let(:project) { create(:project, :repository) }
let(:release) { create(:release, project: project) }
let(:pipeline) { create(:ci_empty_pipeline, sha: release.sha, project: project) }
diff --git a/spec/workers/releases/manage_evidence_worker_spec.rb b/spec/workers/releases/manage_evidence_worker_spec.rb
index 0004a4f4bfb..ca33e28b760 100644
--- a/spec/workers/releases/manage_evidence_worker_spec.rb
+++ b/spec/workers/releases/manage_evidence_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Releases::ManageEvidenceWorker do
+RSpec.describe Releases::ManageEvidenceWorker, feature_category: :release_evidence do
let(:project) { create(:project, :repository) }
shared_examples_for 'does not create a new Evidence record' do
diff --git a/spec/workers/remote_mirror_notification_worker_spec.rb b/spec/workers/remote_mirror_notification_worker_spec.rb
index e415e72645c..e7c32d79457 100644
--- a/spec/workers/remote_mirror_notification_worker_spec.rb
+++ b/spec/workers/remote_mirror_notification_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RemoteMirrorNotificationWorker, :mailer do
+RSpec.describe RemoteMirrorNotificationWorker, :mailer, feature_category: :source_code_management do
let_it_be(:project) { create(:project, :repository, :remote_mirror) }
let_it_be(:mirror) { project.remote_mirrors.first }
@@ -30,8 +30,10 @@ RSpec.describe RemoteMirrorNotificationWorker, :mailer do
end
it 'does nothing when a notification has already been sent' do
- mirror.update_columns(last_error: "There was a problem fetching",
- error_notification_sent: true)
+ mirror.update_columns(
+ last_error: "There was a problem fetching",
+ error_notification_sent: true
+ )
expect(NotificationService).not_to receive(:new)
diff --git a/spec/workers/remove_expired_group_links_worker_spec.rb b/spec/workers/remove_expired_group_links_worker_spec.rb
index 7bdf6fc0d59..e08cc3fb5c5 100644
--- a/spec/workers/remove_expired_group_links_worker_spec.rb
+++ b/spec/workers/remove_expired_group_links_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RemoveExpiredGroupLinksWorker do
+RSpec.describe RemoveExpiredGroupLinksWorker, feature_category: :system_access do
describe '#perform' do
context 'ProjectGroupLinks' do
let!(:expired_project_group_link) { create(:project_group_link, expires_at: 1.hour.ago) }
diff --git a/spec/workers/remove_expired_members_worker_spec.rb b/spec/workers/remove_expired_members_worker_spec.rb
index 062a9bcfa83..f77a078750d 100644
--- a/spec/workers/remove_expired_members_worker_spec.rb
+++ b/spec/workers/remove_expired_members_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RemoveExpiredMembersWorker do
+RSpec.describe RemoveExpiredMembersWorker, feature_category: :system_access do
let(:worker) { described_class.new }
describe '#perform' do
@@ -35,8 +35,10 @@ RSpec.describe RemoveExpiredMembersWorker do
new_job = Sidekiq::Worker.jobs.last
- expect(new_job).to include('meta.project' => expired_project_member.project.full_path,
- 'meta.user' => expired_project_member.user.username)
+ expect(new_job).to include(
+ 'meta.project' => expired_project_member.project.full_path,
+ 'meta.user' => expired_project_member.user.username
+ )
end
end
@@ -60,8 +62,7 @@ RSpec.describe RemoveExpiredMembersWorker do
worker.perform
expect(
- Users::GhostUserMigration.where(user: expired_project_bot,
- initiator_user: nil)
+ Users::GhostUserMigration.where(user: expired_project_bot, initiator_user: nil)
).to be_exists
end
end
@@ -116,8 +117,10 @@ RSpec.describe RemoveExpiredMembersWorker do
new_job = Sidekiq::Worker.jobs.last
- expect(new_job).to include('meta.root_namespace' => expired_group_member.group.full_path,
- 'meta.user' => expired_group_member.user.username)
+ expect(new_job).to include(
+ 'meta.root_namespace' => expired_group_member.group.full_path,
+ 'meta.user' => expired_group_member.user.username
+ )
end
end
diff --git a/spec/workers/remove_unaccepted_member_invites_worker_spec.rb b/spec/workers/remove_unaccepted_member_invites_worker_spec.rb
index 96d7cf535ed..5173967c57a 100644
--- a/spec/workers/remove_unaccepted_member_invites_worker_spec.rb
+++ b/spec/workers/remove_unaccepted_member_invites_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RemoveUnacceptedMemberInvitesWorker do
+RSpec.describe RemoveUnacceptedMemberInvitesWorker, feature_category: :system_access do
let(:worker) { described_class.new }
describe '#perform' do
@@ -13,15 +13,19 @@ RSpec.describe RemoveUnacceptedMemberInvitesWorker do
it 'removes unaccepted members', :aggregate_failures do
unaccepted_group_invitee = create(
- :group_member, invite_token: 't0ken',
- invite_email: 'group_invitee@example.com',
- user: nil,
- created_at: Time.current - 5.days)
+ :group_member,
+ invite_token: 't0ken',
+ invite_email: 'group_invitee@example.com',
+ user: nil,
+ created_at: Time.current - 5.days
+ )
unaccepted_project_invitee = create(
- :project_member, invite_token: 't0ken',
- invite_email: 'project_invitee@example.com',
- user: nil,
- created_at: Time.current - 5.days)
+ :project_member,
+ invite_token: 't0ken',
+ invite_email: 'project_invitee@example.com',
+ user: nil,
+ created_at: Time.current - 5.days
+ )
expect { worker.perform }.to change { Member.count }.by(-2)
@@ -33,13 +37,17 @@ RSpec.describe RemoveUnacceptedMemberInvitesWorker do
context 'invited members still within expiration threshold' do
it 'leaves invited members', :aggregate_failures do
group_invitee = create(
- :group_member, invite_token: 't0ken',
- invite_email: 'group_invitee@example.com',
- user: nil)
+ :group_member,
+ invite_token: 't0ken',
+ invite_email: 'group_invitee@example.com',
+ user: nil
+ )
project_invitee = create(
- :project_member, invite_token: 't0ken',
- invite_email: 'project_invitee@example.com',
- user: nil)
+ :project_member,
+ invite_token: 't0ken',
+ invite_email: 'project_invitee@example.com',
+ user: nil
+ )
expect { worker.perform }.not_to change { Member.count }
@@ -56,15 +64,19 @@ RSpec.describe RemoveUnacceptedMemberInvitesWorker do
it 'leaves accepted members', :aggregate_failures do
user = create(:user)
accepted_group_invitee = create(
- :group_member, invite_token: 't0ken',
- invite_email: 'group_invitee@example.com',
- user: user,
- created_at: Time.current - 5.days)
+ :group_member,
+ invite_token: 't0ken',
+ invite_email: 'group_invitee@example.com',
+ user: user,
+ created_at: Time.current - 5.days
+ )
accepted_project_invitee = create(
- :project_member, invite_token: nil,
- invite_email: 'project_invitee@example.com',
- user: user,
- created_at: Time.current - 5.days)
+ :project_member,
+ invite_token: nil,
+ invite_email: 'project_invitee@example.com',
+ user: user,
+ created_at: Time.current - 5.days
+ )
expect { worker.perform }.not_to change { Member.count }
diff --git a/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb b/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb
index 2562a7bc6fe..e5564834443 100644
--- a/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb
+++ b/spec/workers/remove_unreferenced_lfs_objects_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RemoveUnreferencedLfsObjectsWorker do
+RSpec.describe RemoveUnreferencedLfsObjectsWorker, feature_category: :source_code_management do
let(:worker) { described_class.new }
describe '#perform' do
@@ -13,24 +13,16 @@ RSpec.describe RemoveUnreferencedLfsObjectsWorker do
let!(:referenced_lfs_object1) { create(:lfs_object, oid: '3' * 64) }
let!(:referenced_lfs_object2) { create(:lfs_object, oid: '4' * 64) }
let!(:lfs_objects_project1_1) do
- create(:lfs_objects_project,
- project: project1,
- lfs_object: referenced_lfs_object1
+ create(:lfs_objects_project, project: project1, lfs_object: referenced_lfs_object1
)
end
let!(:lfs_objects_project2_1) do
- create(:lfs_objects_project,
- project: project2,
- lfs_object: referenced_lfs_object1
- )
+ create(:lfs_objects_project, project: project2, lfs_object: referenced_lfs_object1)
end
let!(:lfs_objects_project1_2) do
- create(:lfs_objects_project,
- project: project1,
- lfs_object: referenced_lfs_object2
- )
+ create(:lfs_objects_project, project: project1, lfs_object: referenced_lfs_object2)
end
it 'removes unreferenced lfs objects' do
diff --git a/spec/workers/repository_check/batch_worker_spec.rb b/spec/workers/repository_check/batch_worker_spec.rb
index 643b55af573..bb4a4844b9b 100644
--- a/spec/workers/repository_check/batch_worker_spec.rb
+++ b/spec/workers/repository_check/batch_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RepositoryCheck::BatchWorker do
+RSpec.describe RepositoryCheck::BatchWorker, feature_category: :source_code_management do
let(:shard_name) { 'default' }
subject { described_class.new }
diff --git a/spec/workers/repository_check/clear_worker_spec.rb b/spec/workers/repository_check/clear_worker_spec.rb
index b5f09e8a05f..dd11a2705ac 100644
--- a/spec/workers/repository_check/clear_worker_spec.rb
+++ b/spec/workers/repository_check/clear_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RepositoryCheck::ClearWorker do
+RSpec.describe RepositoryCheck::ClearWorker, feature_category: :source_code_management do
it 'clears repository check columns' do
project = create(:project)
project.update_columns(
diff --git a/spec/workers/repository_check/dispatch_worker_spec.rb b/spec/workers/repository_check/dispatch_worker_spec.rb
index 829abc7d895..34ecc645675 100644
--- a/spec/workers/repository_check/dispatch_worker_spec.rb
+++ b/spec/workers/repository_check/dispatch_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RepositoryCheck::DispatchWorker do
+RSpec.describe RepositoryCheck::DispatchWorker, feature_category: :source_code_management do
subject { described_class.new }
it 'does nothing when repository checks are disabled' do
diff --git a/spec/workers/repository_check/single_repository_worker_spec.rb b/spec/workers/repository_check/single_repository_worker_spec.rb
index 0a37a296e7a..3e52ce781ee 100644
--- a/spec/workers/repository_check/single_repository_worker_spec.rb
+++ b/spec/workers/repository_check/single_repository_worker_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
require 'fileutils'
-RSpec.describe RepositoryCheck::SingleRepositoryWorker do
+RSpec.describe RepositoryCheck::SingleRepositoryWorker, feature_category: :source_code_management do
subject(:worker) { described_class.new }
before do
diff --git a/spec/workers/repository_cleanup_worker_spec.rb b/spec/workers/repository_cleanup_worker_spec.rb
index 2b700b944d2..1e80a48451b 100644
--- a/spec/workers/repository_cleanup_worker_spec.rb
+++ b/spec/workers/repository_cleanup_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RepositoryCleanupWorker do
+RSpec.describe RepositoryCleanupWorker, feature_category: :source_code_management do
let(:project) { create(:project) }
let(:user) { create(:user) }
diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb
index 85dee935001..3a5528b6a04 100644
--- a/spec/workers/repository_fork_worker_spec.rb
+++ b/spec/workers/repository_fork_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RepositoryForkWorker do
+RSpec.describe RepositoryForkWorker, feature_category: :source_code_management do
include ProjectForksHelper
describe 'modules' do
diff --git a/spec/workers/repository_update_remote_mirror_worker_spec.rb b/spec/workers/repository_update_remote_mirror_worker_spec.rb
index ef6a8d76d2c..61b9441ec27 100644
--- a/spec/workers/repository_update_remote_mirror_worker_spec.rb
+++ b/spec/workers/repository_update_remote_mirror_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RepositoryUpdateRemoteMirrorWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe RepositoryUpdateRemoteMirrorWorker, :clean_gitlab_redis_shared_state, feature_category: :source_code_management do
let_it_be(:remote_mirror) { create(:remote_mirror) }
let(:scheduled_time) { Time.current - 5.minutes }
@@ -57,14 +57,16 @@ RSpec.describe RepositoryUpdateRemoteMirrorWorker, :clean_gitlab_redis_shared_st
end
it 'retries 3 times for the worker to finish before rescheduling' do
- expect(subject).to receive(:in_lock)
- .with("#{described_class.name}:#{remote_mirror.id}",
- retries: 3,
- ttl: remote_mirror.max_runtime,
- sleep_sec: described_class::LOCK_WAIT_TIME)
- .and_raise(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError)
- expect(described_class).to receive(:perform_in)
- .with(remote_mirror.backoff_delay, remote_mirror.id, scheduled_time, 0)
+ expect(subject).to receive(:in_lock).with(
+ "#{described_class.name}:#{remote_mirror.id}",
+ retries: 3,
+ ttl: remote_mirror.max_runtime,
+ sleep_sec: described_class::LOCK_WAIT_TIME
+ ).and_raise(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError)
+
+ expect(described_class)
+ .to receive(:perform_in)
+ .with(remote_mirror.backoff_delay, remote_mirror.id, scheduled_time, 0)
subject.perform(remote_mirror.id, scheduled_time)
end
diff --git a/spec/workers/run_pipeline_schedule_worker_spec.rb b/spec/workers/run_pipeline_schedule_worker_spec.rb
index 75938d3b793..d0e4de1aa98 100644
--- a/spec/workers/run_pipeline_schedule_worker_spec.rb
+++ b/spec/workers/run_pipeline_schedule_worker_spec.rb
@@ -137,9 +137,11 @@ RSpec.describe RunPipelineScheduleWorker, feature_category: :continuous_integrat
expect(Gitlab::ErrorTracking)
.to receive(:track_and_raise_for_dev_exception)
- .with(ActiveRecord::StatementInvalid,
- issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231',
- schedule_id: pipeline_schedule.id).once
+ .with(
+ ActiveRecord::StatementInvalid,
+ issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231',
+ schedule_id: pipeline_schedule.id
+ ).once
end
it 'increments Prometheus counter' do
diff --git a/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb b/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb
index 49730d9ab8c..b93202fe9b3 100644
--- a/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb
+++ b/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ScheduleMergeRequestCleanupRefsWorker do
+RSpec.describe ScheduleMergeRequestCleanupRefsWorker, feature_category: :code_review_workflow do
subject(:worker) { described_class.new }
describe '#perform' do
diff --git a/spec/workers/schedule_migrate_external_diffs_worker_spec.rb b/spec/workers/schedule_migrate_external_diffs_worker_spec.rb
index 09a0124f6e0..061467a28e0 100644
--- a/spec/workers/schedule_migrate_external_diffs_worker_spec.rb
+++ b/spec/workers/schedule_migrate_external_diffs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ScheduleMigrateExternalDiffsWorker do
+RSpec.describe ScheduleMigrateExternalDiffsWorker, feature_category: :code_review_workflow do
include ExclusiveLeaseHelpers
let(:worker) { described_class.new }
diff --git a/spec/workers/self_monitoring_project_create_worker_spec.rb b/spec/workers/self_monitoring_project_create_worker_spec.rb
deleted file mode 100644
index b618b8ede99..00000000000
--- a/spec/workers/self_monitoring_project_create_worker_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe SelfMonitoringProjectCreateWorker do
- describe '#perform' do
- let(:service_class) { Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService }
- let(:service) { instance_double(service_class) }
-
- it_behaves_like 'executes service'
- end
-
- describe '.in_progress?', :clean_gitlab_redis_shared_state do
- it_behaves_like 'returns in_progress based on Sidekiq::Status'
- end
-end
diff --git a/spec/workers/self_monitoring_project_delete_worker_spec.rb b/spec/workers/self_monitoring_project_delete_worker_spec.rb
deleted file mode 100644
index 9a53fe59a40..00000000000
--- a/spec/workers/self_monitoring_project_delete_worker_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe SelfMonitoringProjectDeleteWorker do
- let_it_be(:jid) { 'b5b28910d97563e58c2fe55f' }
- let_it_be(:data_key) { "self_monitoring_delete_result:#{jid}" }
-
- describe '#perform' do
- let(:service_class) { Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService }
- let(:service) { instance_double(service_class) }
-
- it_behaves_like 'executes service'
- end
-
- describe '.status', :clean_gitlab_redis_shared_state do
- it_behaves_like 'returns in_progress based on Sidekiq::Status'
- end
-end
diff --git a/spec/workers/service_desk_email_receiver_worker_spec.rb b/spec/workers/service_desk_email_receiver_worker_spec.rb
index 60fc951f627..bed66875f34 100644
--- a/spec/workers/service_desk_email_receiver_worker_spec.rb
+++ b/spec/workers/service_desk_email_receiver_worker_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe ServiceDeskEmailReceiverWorker, :mailer do
+RSpec.describe ServiceDeskEmailReceiverWorker, :mailer, feature_category: :service_desk do
describe '#perform' do
let(:worker) { described_class.new }
let(:email) { fixture_file('emails/service_desk_custom_address.eml') }
diff --git a/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb
index be7d8ebe2d3..d31600d9cba 100644
--- a/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb
+++ b/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::ScheduleBulkRepositoryShardMovesWorker do
+RSpec.describe Snippets::ScheduleBulkRepositoryShardMovesWorker, feature_category: :gitaly do
it_behaves_like 'schedules bulk repository shard moves' do
let_it_be_with_reload(:container) { create(:snippet, :repository).tap { |snippet| snippet.create_repository } }
diff --git a/spec/workers/snippets/update_repository_storage_worker_spec.rb b/spec/workers/snippets/update_repository_storage_worker_spec.rb
index 38e9012e9c5..b26384fc75c 100644
--- a/spec/workers/snippets/update_repository_storage_worker_spec.rb
+++ b/spec/workers/snippets/update_repository_storage_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Snippets::UpdateRepositoryStorageWorker do
+RSpec.describe Snippets::UpdateRepositoryStorageWorker, feature_category: :source_code_management do
subject { described_class.new }
it_behaves_like 'an update storage move worker' do
diff --git a/spec/workers/ssh_keys/expired_notification_worker_spec.rb b/spec/workers/ssh_keys/expired_notification_worker_spec.rb
index 26d9460d73e..f3ba586c21e 100644
--- a/spec/workers/ssh_keys/expired_notification_worker_spec.rb
+++ b/spec/workers/ssh_keys/expired_notification_worker_spec.rb
@@ -2,12 +2,11 @@
require 'spec_helper'
-RSpec.describe SshKeys::ExpiredNotificationWorker, type: :worker do
+RSpec.describe SshKeys::ExpiredNotificationWorker, type: :worker, feature_category: :compliance_management do
subject(:worker) { described_class.new }
it 'uses a cronjob queue' do
expect(worker.sidekiq_options_hash).to include(
- 'queue' => 'cronjob:ssh_keys_expired_notification',
'queue_namespace' => :cronjob
)
end
diff --git a/spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb b/spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb
index e907d035020..f6eaf76b54d 100644
--- a/spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb
+++ b/spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb
@@ -2,12 +2,11 @@
require 'spec_helper'
-RSpec.describe SshKeys::ExpiringSoonNotificationWorker, type: :worker do
+RSpec.describe SshKeys::ExpiringSoonNotificationWorker, type: :worker, feature_category: :compliance_management do
subject(:worker) { described_class.new }
it 'uses a cronjob queue' do
expect(worker.sidekiq_options_hash).to include(
- 'queue' => 'cronjob:ssh_keys_expiring_soon_notification',
'queue_namespace' => :cronjob
)
end
diff --git a/spec/workers/ssh_keys/update_last_used_at_worker_spec.rb b/spec/workers/ssh_keys/update_last_used_at_worker_spec.rb
new file mode 100644
index 00000000000..33b3b44955d
--- /dev/null
+++ b/spec/workers/ssh_keys/update_last_used_at_worker_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SshKeys::UpdateLastUsedAtWorker, type: :worker, feature_category: :source_code_management do
+ let_it_be(:key) { create(:key) }
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [key.id] }
+ end
+
+ describe '#perform' do
+ subject(:worker) { described_class.new }
+
+ it 'updates last_used_at column', :freeze_time do
+ expect { worker.perform(key.id) }.to change { key.reload.last_used_at }.to(Time.zone.now)
+ end
+
+ it 'does not update updated_at column' do
+ expect { worker.perform(key.id) }.not_to change { key.reload.updated_at }
+ end
+ end
+end
diff --git a/spec/workers/stage_update_worker_spec.rb b/spec/workers/stage_update_worker_spec.rb
index e50c7183153..bb2f63d3b50 100644
--- a/spec/workers/stage_update_worker_spec.rb
+++ b/spec/workers/stage_update_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe StageUpdateWorker do
+RSpec.describe StageUpdateWorker, feature_category: :continuous_integration do
describe '#perform' do
context 'when stage exists' do
let(:stage) { create(:ci_stage) }
diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb
index 19ff8ec55c2..1c5f54dc55f 100644
--- a/spec/workers/stuck_ci_jobs_worker_spec.rb
+++ b/spec/workers/stuck_ci_jobs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe StuckCiJobsWorker do
+RSpec.describe StuckCiJobsWorker, feature_category: :continuous_integration do
include ExclusiveLeaseHelpers
let(:worker) { described_class.new }
diff --git a/spec/workers/stuck_export_jobs_worker_spec.rb b/spec/workers/stuck_export_jobs_worker_spec.rb
index cbc7adc8e3f..0e300b0077b 100644
--- a/spec/workers/stuck_export_jobs_worker_spec.rb
+++ b/spec/workers/stuck_export_jobs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe StuckExportJobsWorker do
+RSpec.describe StuckExportJobsWorker, feature_category: :importers do
let(:worker) { described_class.new }
shared_examples 'project export job detection' do
diff --git a/spec/workers/stuck_merge_jobs_worker_spec.rb b/spec/workers/stuck_merge_jobs_worker_spec.rb
index bade2e1ca1b..44dc6550cdb 100644
--- a/spec/workers/stuck_merge_jobs_worker_spec.rb
+++ b/spec/workers/stuck_merge_jobs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe StuckMergeJobsWorker do
+RSpec.describe StuckMergeJobsWorker, feature_category: :code_review_workflow do
describe 'perform' do
let(:worker) { described_class.new }
diff --git a/spec/workers/system_hook_push_worker_spec.rb b/spec/workers/system_hook_push_worker_spec.rb
index 43a3f8e3e19..35e44e635cc 100644
--- a/spec/workers/system_hook_push_worker_spec.rb
+++ b/spec/workers/system_hook_push_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SystemHookPushWorker do
+RSpec.describe SystemHookPushWorker, feature_category: :source_code_management do
include RepoHelpers
subject { described_class.new }
diff --git a/spec/workers/tasks_to_be_done/create_worker_spec.rb b/spec/workers/tasks_to_be_done/create_worker_spec.rb
index c3c3612f9a7..643424ae068 100644
--- a/spec/workers/tasks_to_be_done/create_worker_spec.rb
+++ b/spec/workers/tasks_to_be_done/create_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TasksToBeDone::CreateWorker do
+RSpec.describe TasksToBeDone::CreateWorker, feature_category: :onboarding do
let_it_be(:member_task) { create(:member_task, tasks: MemberTask::TASKS.values) }
let_it_be(:current_user) { create(:user) }
diff --git a/spec/workers/terraform/states/destroy_worker_spec.rb b/spec/workers/terraform/states/destroy_worker_spec.rb
index 02e79373279..7cc20e0ad5d 100644
--- a/spec/workers/terraform/states/destroy_worker_spec.rb
+++ b/spec/workers/terraform/states/destroy_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Terraform::States::DestroyWorker do
+RSpec.describe Terraform::States::DestroyWorker, feature_category: :infrastructure_as_code do
let(:state) { create(:terraform_state) }
describe '#perform' do
diff --git a/spec/workers/todos_destroyer/confidential_issue_worker_spec.rb b/spec/workers/todos_destroyer/confidential_issue_worker_spec.rb
index 86202fac1ed..54e2061217f 100644
--- a/spec/workers/todos_destroyer/confidential_issue_worker_spec.rb
+++ b/spec/workers/todos_destroyer/confidential_issue_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TodosDestroyer::ConfidentialIssueWorker do
+RSpec.describe TodosDestroyer::ConfidentialIssueWorker, feature_category: :team_planning do
let(:service) { double }
it "calls the Todos::Destroy::ConfidentialIssueService with issue_id parameter" do
diff --git a/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb b/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb
index 113faeb0d2f..b85ea1a5847 100644
--- a/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb
+++ b/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TodosDestroyer::DestroyedDesignsWorker do
+RSpec.describe TodosDestroyer::DestroyedDesignsWorker, feature_category: :team_planning do
let(:service) { double }
it 'calls the Todos::Destroy::DesignService with design_ids parameter' do
diff --git a/spec/workers/todos_destroyer/destroyed_issuable_worker_spec.rb b/spec/workers/todos_destroyer/destroyed_issuable_worker_spec.rb
index 6ccad25ad76..72adc7ba0c7 100644
--- a/spec/workers/todos_destroyer/destroyed_issuable_worker_spec.rb
+++ b/spec/workers/todos_destroyer/destroyed_issuable_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TodosDestroyer::DestroyedIssuableWorker do
+RSpec.describe TodosDestroyer::DestroyedIssuableWorker, feature_category: :team_planning do
let(:job_args) { [1, 'MergeRequest'] }
it 'calls the Todos::Destroy::DestroyedIssuableService' do
diff --git a/spec/workers/todos_destroyer/entity_leave_worker_spec.rb b/spec/workers/todos_destroyer/entity_leave_worker_spec.rb
index db3b0252056..d7682ad0e5e 100644
--- a/spec/workers/todos_destroyer/entity_leave_worker_spec.rb
+++ b/spec/workers/todos_destroyer/entity_leave_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TodosDestroyer::EntityLeaveWorker do
+RSpec.describe TodosDestroyer::EntityLeaveWorker, feature_category: :team_planning do
it "calls the Todos::Destroy::EntityLeaveService with the params it was given" do
service = double
diff --git a/spec/workers/todos_destroyer/group_private_worker_spec.rb b/spec/workers/todos_destroyer/group_private_worker_spec.rb
index 4903edd4bbe..4d49a852a3e 100644
--- a/spec/workers/todos_destroyer/group_private_worker_spec.rb
+++ b/spec/workers/todos_destroyer/group_private_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TodosDestroyer::GroupPrivateWorker do
+RSpec.describe TodosDestroyer::GroupPrivateWorker, feature_category: :team_planning do
it "calls the Todos::Destroy::GroupPrivateService with the params it was given" do
service = double
diff --git a/spec/workers/todos_destroyer/private_features_worker_spec.rb b/spec/workers/todos_destroyer/private_features_worker_spec.rb
index 88d9be051d0..834e0fb2201 100644
--- a/spec/workers/todos_destroyer/private_features_worker_spec.rb
+++ b/spec/workers/todos_destroyer/private_features_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TodosDestroyer::PrivateFeaturesWorker do
+RSpec.describe TodosDestroyer::PrivateFeaturesWorker, feature_category: :team_planning do
it "calls the Todos::Destroy::PrivateFeaturesService with the params it was given" do
service = double
diff --git a/spec/workers/todos_destroyer/project_private_worker_spec.rb b/spec/workers/todos_destroyer/project_private_worker_spec.rb
index 4e54fbdb275..2435fcade22 100644
--- a/spec/workers/todos_destroyer/project_private_worker_spec.rb
+++ b/spec/workers/todos_destroyer/project_private_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TodosDestroyer::ProjectPrivateWorker do
+RSpec.describe TodosDestroyer::ProjectPrivateWorker, feature_category: :team_planning do
it "calls the Todos::Destroy::ProjectPrivateService with the params it was given" do
service = double
diff --git a/spec/workers/trending_projects_worker_spec.rb b/spec/workers/trending_projects_worker_spec.rb
index 1f1e312e457..b6acd01f7c4 100644
--- a/spec/workers/trending_projects_worker_spec.rb
+++ b/spec/workers/trending_projects_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe TrendingProjectsWorker do
+RSpec.describe TrendingProjectsWorker, feature_category: :source_code_management do
describe '#perform' do
it 'refreshes the trending projects' do
expect(TrendingProject).to receive(:refresh!)
diff --git a/spec/workers/update_container_registry_info_worker_spec.rb b/spec/workers/update_container_registry_info_worker_spec.rb
index ace9e55cbce..a8c501efd51 100644
--- a/spec/workers/update_container_registry_info_worker_spec.rb
+++ b/spec/workers/update_container_registry_info_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UpdateContainerRegistryInfoWorker do
+RSpec.describe UpdateContainerRegistryInfoWorker, feature_category: :container_registry do
describe '#perform' do
it 'calls UpdateContainerRegistryInfoService' do
expect_next_instance_of(UpdateContainerRegistryInfoService) do |service|
diff --git a/spec/workers/update_external_pull_requests_worker_spec.rb b/spec/workers/update_external_pull_requests_worker_spec.rb
index cb6a4e2ebf8..254a4b69f3b 100644
--- a/spec/workers/update_external_pull_requests_worker_spec.rb
+++ b/spec/workers/update_external_pull_requests_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UpdateExternalPullRequestsWorker do
+RSpec.describe UpdateExternalPullRequestsWorker, feature_category: :continuous_integration do
describe '#perform' do
let_it_be(:project) { create(:project, import_source: 'tanuki/repository') }
let_it_be(:user) { create(:user) }
diff --git a/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb b/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb
index 5ed600e308b..c64a597833d 100644
--- a/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb
+++ b/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UpdateHeadPipelineForMergeRequestWorker do
+RSpec.describe UpdateHeadPipelineForMergeRequestWorker, feature_category: :continuous_integration do
describe '#perform' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
@@ -66,11 +66,13 @@ RSpec.describe UpdateHeadPipelineForMergeRequestWorker do
context 'when a merge request pipeline exists' do
let_it_be(:merge_request_pipeline) do
- create(:ci_pipeline,
- project: project,
- source: :merge_request_event,
- sha: latest_sha,
- merge_request: merge_request)
+ create(
+ :ci_pipeline,
+ project: project,
+ source: :merge_request_event,
+ sha: latest_sha,
+ merge_request: merge_request
+ )
end
it 'sets the merge request pipeline as the head pipeline' do
diff --git a/spec/workers/update_highest_role_worker_spec.rb b/spec/workers/update_highest_role_worker_spec.rb
index cd127f26e95..3e4a2f6be36 100644
--- a/spec/workers/update_highest_role_worker_spec.rb
+++ b/spec/workers/update_highest_role_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UpdateHighestRoleWorker, :clean_gitlab_redis_shared_state do
+RSpec.describe UpdateHighestRoleWorker, :clean_gitlab_redis_shared_state, feature_category: :seat_cost_management do
include ExclusiveLeaseHelpers
let(:worker) { described_class.new }
diff --git a/spec/workers/update_merge_requests_worker_spec.rb b/spec/workers/update_merge_requests_worker_spec.rb
index 64fcc2bd388..de164fe352a 100644
--- a/spec/workers/update_merge_requests_worker_spec.rb
+++ b/spec/workers/update_merge_requests_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UpdateMergeRequestsWorker do
+RSpec.describe UpdateMergeRequestsWorker, feature_category: :code_review_workflow do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:oldrev) { "123456" }
diff --git a/spec/workers/update_project_statistics_worker_spec.rb b/spec/workers/update_project_statistics_worker_spec.rb
index 2f356376d7c..c5e6f45a201 100644
--- a/spec/workers/update_project_statistics_worker_spec.rb
+++ b/spec/workers/update_project_statistics_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UpdateProjectStatisticsWorker do
+RSpec.describe UpdateProjectStatisticsWorker, feature_category: :source_code_management do
include ExclusiveLeaseHelpers
let(:worker) { described_class.new }
diff --git a/spec/workers/upload_checksum_worker_spec.rb b/spec/workers/upload_checksum_worker_spec.rb
index 75d7509b6e6..6c47a8e198c 100644
--- a/spec/workers/upload_checksum_worker_spec.rb
+++ b/spec/workers/upload_checksum_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UploadChecksumWorker do
+RSpec.describe UploadChecksumWorker, feature_category: :geo_replication do
describe '#perform' do
subject { described_class.new }
diff --git a/spec/workers/user_status_cleanup/batch_worker_spec.rb b/spec/workers/user_status_cleanup/batch_worker_spec.rb
index 2fd84d0e085..e2ad12672de 100644
--- a/spec/workers/user_status_cleanup/batch_worker_spec.rb
+++ b/spec/workers/user_status_cleanup/batch_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe UserStatusCleanup::BatchWorker do
+RSpec.describe UserStatusCleanup::BatchWorker, feature_category: :user_profile do
include_examples 'an idempotent worker' do
subject do
perform_multiple([], worker: described_class.new)
diff --git a/spec/workers/users/create_statistics_worker_spec.rb b/spec/workers/users/create_statistics_worker_spec.rb
index 2118cc42f3a..2c1c7738533 100644
--- a/spec/workers/users/create_statistics_worker_spec.rb
+++ b/spec/workers/users/create_statistics_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::CreateStatisticsWorker do
+RSpec.describe Users::CreateStatisticsWorker, feature_category: :user_profile do
describe '#perform' do
subject { described_class.new.perform }
diff --git a/spec/workers/users/deactivate_dormant_users_worker_spec.rb b/spec/workers/users/deactivate_dormant_users_worker_spec.rb
index a8318de669b..fdcbb624562 100644
--- a/spec/workers/users/deactivate_dormant_users_worker_spec.rb
+++ b/spec/workers/users/deactivate_dormant_users_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::DeactivateDormantUsersWorker do
+RSpec.describe Users::DeactivateDormantUsersWorker, feature_category: :seat_cost_management do
using RSpec::Parameterized::TableSyntax
describe '#perform' do
@@ -12,8 +12,7 @@ RSpec.describe Users::DeactivateDormantUsersWorker do
subject(:worker) { described_class.new }
- it 'does not run for GitLab.com' do
- expect(Gitlab).to receive(:com?).and_return(true)
+ it 'does not run for SaaS', :saas do
# Now makes a call to current settings to determine period of dormancy
worker.perform
@@ -36,6 +35,7 @@ RSpec.describe Users::DeactivateDormantUsersWorker do
where(:user_type, :expected_state) do
:human | 'deactivated'
+ :human_deprecated | 'deactivated'
:support_bot | 'active'
:alert_bot | 'active'
:visual_review_bot | 'active'
@@ -58,11 +58,13 @@ RSpec.describe Users::DeactivateDormantUsersWorker do
it 'does not deactivate non-active users' do
human_user = create(:user, user_type: :human, state: :blocked, last_activity_on: Gitlab::CurrentSettings.deactivate_dormant_users_period.days.ago.to_date)
+ human_user2 = create(:user, user_type: :human_deprecated, state: :blocked, last_activity_on: Gitlab::CurrentSettings.deactivate_dormant_users_period.days.ago.to_date)
service_user = create(:user, user_type: :service_user, state: :blocked, last_activity_on: Gitlab::CurrentSettings.deactivate_dormant_users_period.days.ago.to_date)
worker.perform
expect(human_user.reload.state).to eq('blocked')
+ expect(human_user2.reload.state).to eq('blocked')
expect(service_user.reload.state).to eq('blocked')
end
diff --git a/spec/workers/users/migrate_records_to_ghost_user_in_batches_worker_spec.rb b/spec/workers/users/migrate_records_to_ghost_user_in_batches_worker_spec.rb
index 7c585542e30..38ea7c43267 100644
--- a/spec/workers/users/migrate_records_to_ghost_user_in_batches_worker_spec.rb
+++ b/spec/workers/users/migrate_records_to_ghost_user_in_batches_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::MigrateRecordsToGhostUserInBatchesWorker do
+RSpec.describe Users::MigrateRecordsToGhostUserInBatchesWorker, feature_category: :seat_cost_management do
include ExclusiveLeaseHelpers
let(:worker) { described_class.new }
diff --git a/spec/workers/web_hook_worker_spec.rb b/spec/workers/web_hook_worker_spec.rb
index e2ff36975c4..be43b83ec0a 100644
--- a/spec/workers/web_hook_worker_spec.rb
+++ b/spec/workers/web_hook_worker_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe WebHookWorker do
+RSpec.describe WebHookWorker, feature_category: :integrations do
include AfterNextHelpers
let_it_be(:project_hook) { create(:project_hook) }
@@ -28,8 +28,6 @@ RSpec.describe WebHookWorker do
.to change { Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid }.to(uuid)
end
- it_behaves_like 'worker with data consistency',
- described_class,
- data_consistency: :delayed
+ it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
end
end
diff --git a/spec/workers/web_hooks/log_destroy_worker_spec.rb b/spec/workers/web_hooks/log_destroy_worker_spec.rb
index 0c107c05360..8c7d29d559d 100644
--- a/spec/workers/web_hooks/log_destroy_worker_spec.rb
+++ b/spec/workers/web_hooks/log_destroy_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WebHooks::LogDestroyWorker do
+RSpec.describe WebHooks::LogDestroyWorker, feature_category: :integrations do
include AfterNextHelpers
let_it_be(:project) { create(:project) }
diff --git a/spec/workers/work_items/import_work_items_csv_worker_spec.rb b/spec/workers/work_items/import_work_items_csv_worker_spec.rb
new file mode 100644
index 00000000000..056960fbcf2
--- /dev/null
+++ b/spec/workers/work_items/import_work_items_csv_worker_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::ImportWorkItemsCsvWorker, feature_category: :team_planning do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ let(:upload) { create(:upload, :with_file) }
+
+ before_all do
+ project.add_reporter(user)
+ end
+
+ subject { described_class.new.perform(user.id, project.id, upload.id) }
+
+ describe '#perform' do
+ it 'calls #execute on WorkItems::ImportCsvService and destroys upload' do
+ expect_next_instance_of(WorkItems::ImportCsvService) do |instance|
+ expect(instance).to receive(:execute).and_return({ success: 5, error_lines: [], parse_error: false })
+ end
+
+ subject
+
+ expect { upload.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [user.id, project.id, upload.id] }
+ end
+ end
+
+ describe '.sidekiq_retries_exhausted' do
+ let_it_be(:job) { { 'args' => [user.id, project.id, create(:upload, :with_file).id] } }
+
+ subject(:sidekiq_retries_exhausted) do
+ described_class.sidekiq_retries_exhausted_block.call(job)
+ end
+
+ it 'destroys upload' do
+ expect { sidekiq_retries_exhausted }.to change { Upload.count }.by(-1)
+ end
+ end
+end
diff --git a/spec/workers/x509_certificate_revoke_worker_spec.rb b/spec/workers/x509_certificate_revoke_worker_spec.rb
index 392cb52d084..badeff25b34 100644
--- a/spec/workers/x509_certificate_revoke_worker_spec.rb
+++ b/spec/workers/x509_certificate_revoke_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe X509CertificateRevokeWorker do
+RSpec.describe X509CertificateRevokeWorker, feature_category: :source_code_management do
describe '#perform' do
context 'with a revoked certificate' do
subject { described_class.new }
diff --git a/spec/workers/x509_issuer_crl_check_worker_spec.rb b/spec/workers/x509_issuer_crl_check_worker_spec.rb
index 5564147d274..55e2e8d335d 100644
--- a/spec/workers/x509_issuer_crl_check_worker_spec.rb
+++ b/spec/workers/x509_issuer_crl_check_worker_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe X509IssuerCrlCheckWorker do
+RSpec.describe X509IssuerCrlCheckWorker, feature_category: :source_code_management do
subject(:worker) { described_class.new }
let(:project) { create(:project, :public, :repository) }